第二十一章 并发


在这里插入图片描述

顺序编程,即程序中的所有事物在任意时刻都只能执行一个步骤。并发编程,程序能够并行地执行程序中的多个部分。

0.并发的多面性
  1. 用并发解决的问题:“速度“和”设计可管理性“
  2. 更快的执行
  • (1) 速度提高是以多核心处理器的形式而不是更快的芯片形式
  • (2) 并发通常时提高运行在单处理器上的程序的性能
  • (3) 并发增加了”上下文切换“的开销
  • (4) 阻塞:程序中的某个任务因为该程序控制范围之外的某些条件(I/O)而不能继续执行,说i名任务或线程阻塞了
    • i.如果 没有 使用并发编程:整个程序会停下来,直至外部条件变化
    • ii.如果使用并发编程:整个程序会继续向前执行
  • (4) 并发最直接的方式:再操作系统级别使用进程
  • (5) 编写多线程的困难:协调不同线程驱动的任务之间对这些资源的使用,以使得这些资源不会同时被多个任务访问
  • (6) 并发:每个人物都作为进程在其自己的地址空间中执行,因此任务之间根本不可能互相干涉
  • (7) 对进程来说,他们之间没有任何彼此通信的需要,因为他们完全独立
  1. 改进代码设计
  • (1) Java 的线程机制是抢占式的,这表示调度机制会周期性地中断线程,将上下文切换到另一个线程,从而为每个线程都提供时间片
  • (2) 在协作式系统中,每个任务都会自动地放弃控制,这要求程序员要有意识地在每个任务中插入某种类型的让步语句
  • (3) 协作多线程的优势是双重的:上下文切换的开销通常比抢占式系统要低廉很多,并且对可以同时执行的线程数量在理论上没有任何限制
  • (4) 并发需要付出代价,包含复杂性代价,但是这些代价与在程序设计、资源负载均衡以及用户方便使用方面的改进相比,就显得微不足道了
  • (5) 线程使你能够创建更加松散耦合的设计,否则,你的代码中各个部分都必须显式地关注那些通常可以由线程来处理的任务
2.基本线程机制
  1. 并发编程是我们可以将程序划分成多个分离的、独立运行的任务

  2. 一个进程可以拥有多个并发的任务(切分CPU时间)

  3. 线程模型简化了,在单一程序中同时交织在一起的多个操作的处理

  4. 定义任务
  • (1) 线程可以驱动任务,可以用Runnable接口描述任务
  • (2) 定于任务:实现Runnable接口,编写run()方法
  • (3) Thread.yield():线程调度器,可以将CPU从一个线程转移给另一个线程
  1. Thread类
  • (1) 将Runnable对象转变未工作任务【Thread t = new Thread(new 实现了Runnable接口的方法)】
  • (2) Thread.start():执行初始化操作
  • (3) 任何线程都可以启动另一个线程
  • (4) 一个相乘会创建一个单独的执行线程,在对start()的调用完成之后,它仍就会继续存在
  1. 使用Executor
  • (1) 执行器(Excutor)将为你管理Thread对象
  • (2) Excutor在客户端和执行人物之间提供了一个间接层;与客户端直接执行任务不同,这个中介对象将执行任务
  • (3) Excutor允许你管理一部任务的执行,而无需显示的管理线程生命周期
  • (4) 生成ExcutorServer:
    • Executors.newCachedThreadPool():将为每个任务都创建一个线程
    • Executors.newFixedThreadPool(int x):可以限制线程数量,预先执行代价高昂的线程分配
    • Executors.newSingleThreadExecutor()
      • 就是线程为1的FixedThreadPool
      • 他会序列化所有提交给它的任务,并会维护它自己(隐藏)的悬挂任务队列
  • (5) 从任务中产生返回值
    • i.实现Callable接口:任务在完成时能返回一个值
    • ii.Callable接口时一种具有类型参数的泛型,它的类型参数表示从call()方法返回的值,并且必须使用ExeutorServer.submit()方法调用它
    • iii.submit()方法长生一个Future集合对象
  1. 休眠
  • (1) sleep(int x):使任务终止执行给定的时间
  • (2) sleep()可以抛出InterruptedException异常
  • (3) 异常不能跨线程传播,所以你必须在本地处理所有在任务内部产生的异常。
  1. 优先级
  • (1) 线程的优先级将该线程的重要性传递给了调度器
  • (2) 调度器将倾向于让优先权最高的线程先执行
  • (3) 优先级不会导致死锁
  • (4) getPriority()读取现有线程的优先级
  • (5) setPriority()修改线程优先级(不建议)
  1. 让步
  • (1) 当调用 yield() 方法,你也是在建议具有“相同优先级”的其他线程可以允许
  • (2) 大体上,对于任何重要的控制或在调整应用时,都不能依赖于 yield()
  1. 后台进程
  • (1) 后台线程:指在程序运行时在后台提供一种通用服务的线程,并且这种线程并不属于程序中不可或缺的部分
  • (2) 当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程(mian()是非后台进程)
  • (3) 必须在线程启动之前(调用start()之前)调用 setDaemon() 方法,才能把它设置为后台线程,而且后台线程创建的任何线程都将被自动设置成后台线程
  • (4) 调用isDaemon()方法来确定进程是否是一个后台进程
  1. 编码的变体
  • (1) 直接继承Thread

  • (2) 实现自管理的Runnable,这样可以继承另一个不同的类而继承Thread不行,如下:

    package Chapter21.Example02;
    // A Runnable containing its own driver Thread.
    
    public class SelfManaged implements Runnable {
          
          
      private int countDown = 5;
      private Thread t = new Thread(this);
      public SelfManaged() {
          
           t.start(); }
      public String toString() {
          
          
    	return Thread.currentThread().getName() +
    	  "(" + countDown + "), ";
      }
      public void run() {
          
          
    	while(true) {
          
          
    	  System.out.print(this);
    	  if(--countDown == 0)
    		return;
    	}
      }
      public static void main(String[] args) {
          
          
    	for(int i = 0; i < 5; i++)
    	  new SelfManaged();
      }
    } /* Output:
    Thread-0(5), Thread-0(4), Thread-0(3), Thread-0(2), Thread-0(1), Thread-1(5), Thread-1(4), Thread-1(3), Thread-1(2), Thread-1(1), Thread-2(5), Thread-2(4), Thread-2(3), Thread-2(2), Thread-2(1), Thread-3(5), Thread-3(4), Thread-3(3), Thread-3(2), Thread-3(1), Thread-4(5), Thread-4(4), Thread-4(3), Thread-4(2), Thread-4(1),
    *///:~
    
  • (3) 在构造器中启动线程可能会变得很有问题,因为另一个任务可能会在构造器结束之前开始执行,这意味着该任务能够访问处于不稳定状态的对象。这是优选Executor而不是显式地创建 Thread 对象的另一个原因

  1. 加入一个线程
  • (1) 如果某个线程在另一个线程 t 上调用 t.join(),此线程将被挂起,直到目标线程 t 结束才恢复(即 t.isAlive() 返回为假)
  • (2) 也可以在调用join()时带上一个超时参数,这样如果目标线程在这段时间到期时还没有结束的话,join()方法总能返回
  • (3) 如果某个线程在另一个线程 t 上调用 t.join(),此线程将被挂起,直到目标线程 t 结束才恢复(即 t.isAlive() 返回为假)
  • (4) 当另一个线程在该线程上调用 interrupt() 时,将给该线程设定一个标志,表明该线程已经被中断。然而,异常被捕获时将清理这个标志,所以在 catch 字句中,在异常被捕获的时候这个标志总是为假
  1. 线程组
  • (1) 线程组持有一个线程的集合
  • (2) 线程是失败的尝试
  1. 捕获异常
  • (1) Thread.UncaughtExceptionHandler 是 Java SE5 中的接口,它允许你在每个 Thread 对象上都附着一个异常处理器。Thread.UncaughtExceptionHandler.uncaughtException() 会在线程因未捕获的异常而临近死亡时被调用
  • (2) 调用顺序
    • i.线程专用处理器。
    • ii.线程组处理器。
    • iii.Thread 的静态域(Thread.setDefaultUncaughtExceptionHandler …)。
3.共享受限资源
  1. 不正确地访问资源
  • (1) 原子性:赋值和返回值这样的简单操作在发生时没有中断的可能,因此你不会看到这个域处于在执行这些简单操作的过程中的中间状态
  • (2) 在Java中,递增不是原子性的操作。因此,如果不保护任务,即使单一的递增也不是安全的
  1. 解决共享资源竞争
  • (1) 防止这种冲突的方法就是当资源被一个任务使用时,在其上加锁
  • (2) 解决线程冲突问题:采用序列化访问共享资源
  • (3) 互斥量(mutex):锁语句产生了一种互相排斥的效果
  • (4) 共享资源一般是以对象形式存在的内存片段。把所有访问这个资源的方法标记为synchronized。如果某个任务处于一个对标记为synchronized的方法的调用中,那么这个线程从该方法返回之前,其他所有要调用类中任何标记为synchronized方法的线程都会被阻塞
  • (5) 在使用并发时,将域设置为private时非常重要的,否则synchronized关键字就不能防止其他任务直接访问域,这样就会产生冲突
  • (6) Brian 的同步规则:如果你正在写一个变量,它可能接下来将被另一个线程读取,或者正在读取一个上一次已经被另一个线程写过的变量,那么你必须使用同步,并且,读写线程都必须用相同的监视器锁同步
  • (7) 每隔访问临界共享资源的方法都必须同步
  • (7) 同步控制EvenGenerator:见Chapter21\Example04\SynchronizedEvenGenerator.java
  • (8) 使用显示的Lock对象
    • i.显示 Lock 对象的优点之一:抛出异常时,可以使用 finally 子句将系统维护在正确的状态(synchronized 则不行)
    • ii.显示的 Lock 对象在加锁和释放锁方面,有更细粒度的控制力,例如用于遍历链接列表中的节点的节节传递的加锁机制(也称为锁耦合),这种遍历代码必须在释放当前节点的锁之前捕获下一个节点的锁
  1. 原子性与易变性
  • (1) 原子操作是不能被线程调度机制中断的操作
  • (2) 原子性可以应用于除 long 和 double 之外的所有基本类型之上的 ”简单操作“
  • (3) JVM 可以将 64 位(long 和 double 变量)的读取和写入当作两个分离的 32 位操作来执行,这就产生了在一个读取和写入操作中间发生上下文切换,从而导致不同的任务看到不正确结果的可能性(这有时被称为 字撕裂)
  • (4) 可视性:volatile 域会立即被写入到主存中(同步也会导致向主存中刷新),而读取操作就发生在主存中
  • (5) 如果一个域完全由synchronized方法或语句块来防护,那就不必将其设置为是volatile的
  • (6) 当一个域的值依赖于它之前的值时(例如递增一个计数器),volatile 就无法工作了。如果某个域的值受到其他域的值的限制,那么 volatile 也无法工作
  • (7) 使用volatile而不是synchronized的唯一安全的情况是类中只有一个可变的域
  • (8) 对域中的值做赋值和返回操作通常都是原子性的
  • (9) 将一个域定义为 volatile,那么它就会告诉编译器不要执行任何移除读取和写入操作的优化,这些操作的目的是用线程中的局部变量维护对这个域的精确同步
  1. 临界区
  • (1) 防止多个线程同时访问方法内部的部分代码而不是防止访问整个方法,而分离出来的代码段被称为临界区(critical section)
  • (2) synchronized(syncObject) { //* }:同步控制块,进入代码前,必须得到syncObject对象的锁
  • (3) 自增操作不是线程安全的
  • (4) 使用“同步控制块”的原因是:使得其他线程能更多的访问
  1. 在其他对象上同步
  • synchronized快必须给定一个在其上进行同步的对象,并且最合理的方式是,使用其方法在被调用的当前对象:synchronized(this)
  1. 线程本地存储
  • (1) 创建和管理线程本地存储可以由ThreadLocal类来实现

  • (2)

    package Chapter21.Example05;
    // Automatically giving each thread its own storage.
    
    import java.util.concurrent.*;
    import java.util.*;
    
    class Accessor implements Runnable {
          
          
    	private final int id;
    
    	public Accessor(int idn) {
          
          
    		id = idn;
    	}
    
    	public void run() {
          
          
    		while (!Thread.currentThread().isInterrupted()) {
          
          
    			ThreadLocalVariableHolder.increment();
    			System.out.println(this);
    			Thread.yield();
    		}
    	}
    
    	public String toString() {
          
          
    		return "#" + id + ": " +
    				ThreadLocalVariableHolder.get();
    	}
    }
    
    // 即使只有一个 ThreadLocalVariableHolder 对象,每个线程都会分配自己的存储
    // 比如可以给每个请求初始化一个 requestId
    public class ThreadLocalVariableHolder {
          
          
    	private static ThreadLocal<Integer> value =
    			new ThreadLocal<Integer>() {
          
          
    				private Random rand = new Random(47);
    
    				protected synchronized Integer initialValue() {
          
          
    					return rand.nextInt(10000);
    				}
    			};
    
    	public static void increment() {
          
          
    		value.set(value.get() + 1);
    	}
    
    	public static int get() {
          
          
    		return value.get();
    	}
    
    	public static void main(String[] args) throws Exception {
          
          
    		ExecutorService exec = Executors.newCachedThreadPool();
    		for (int i = 0; i < 5; i++)
    			exec.execute(new Accessor(i));
    		TimeUnit.SECONDS.sleep(3);  // Run for a while
    		exec.shutdownNow();         // All Accessors will quit
    	}
    } /* Output: (Sample)
    #0: 9259
    #1: 556
    #2: 6694
    #3: 1862
    #4: 962
    #0: 9260
    #1: 557
    #2: 6695
    #3: 1863
    #4: 963
    ...
    *///:~
    
    • (3) ThreadLocal.get() 返回的是副本
    • (4) ThreadLocal.set() 返回的是存储中的旧对象
    • (5) 即使只有一个 ThreadLocalVariableHolder 对象,每个线程都会分配自己的存储
4.终结任务
  1. 在阻塞时终结
  • (1) 线程状态
    • 新建(new):当线程被创建时,只会短暂地处于这种状态。此时它已经分配了必须的系统资源,并执行了初始化。此刻线程已经有资格获得 CPU 时间了,之后调度器将把这个线程转变为可运行状态或阻塞状态。
    • 就绪(Runnable):只要调度器能分配时间片给线程,它就可以运行。
    • 阻塞(Blocked):线程能够运行,但有某个条件阻止它运行。
    • 死亡(Dead):处于死亡或终止状态的线程将不再是可调度的,并且再也不会得到 CPU 时间。
  • (2) 进入阻塞状态
    • sleep() 使任务进入休眠状态。任务在指定时间内不运行
    • wait() 使任务挂起。直到线程得到notify()或notifyAll()消息,线程进入就绪状态
    • 任务等某个输入/输出完成
    • 任务试图在某个对象上调用其同步控制方法,但时对象锁不可用,因为另一个任务已经获得了这个锁
  • (3) 检查中断
    • 当你在线程上调用 interrupt() 时,中断发生的唯一时刻是在任务要进入到阻塞操作中,或者已经在阻塞操作内部时
    • 如果你调用 interrupt() 以停止某个任务,那么在 run() 循环碰巧没有产生任何阻塞调用的情况下,你的任务将需要第二种方式来退出。(由中断状态判断,可以通过调用 interrupted() 来设置)
    • shutdownNow() 将向所有由 ExecutorService 启动的任务发送 interrupt()
    • 所有需要清理的对象创建操作后面,要紧跟try-finally()子句
5.线程之间的协作
  1. wait() 与 notifyAll()
  • (1) 空循环,称为忙等待,通常是一种不良的 CPU 周期使用方式。

  • (2) sleep()、yield() 不会释放锁。

  • (3) wait() 会释放锁。

  • (4) 只能在同步控制方法或同步控制块里调用 wait()、notify() 和 notifyAll()。否则的话,程序能通过编译,但是运行时,将得到 IllegalMonitorStateException 异常和一些含糊的消息,消息的意思是,调用这些方法的任务在调用这些方法前,必须 “拥有”(获取)对象的锁。

  • (5) 因为不操作锁,所以 sleep() 可以在非同步控制方法里调用

  • (6) wait() 的惯用法,主要防止任务被唤醒后,还没开始任务,条件又失效了

    while(conditionIsNotMet) {
          
          
    	wait();
    }
    
  1. notify() 与 notifyAll()
  • (1) 使用 notify() 的条件(三条必须同时满足):

    • 必须保证被唤醒的是恰当的任务。
    • 所有任务必须等待相同的条件,如果有多个任务在等待不同条件,你就不会知道是否唤醒了恰当的任务。
    • 当条件发生变化时,必须只有一个任务能够从中受益。
  • (2) 当 notifyAll() 因某个特定锁而被调用时,只有等待这个锁的任务才会被唤醒

  • (3) 如何实现只唤醒同一个锁的等待任务?下图引自在这里插入图片描述

  1. 生产者与消费者
  • 使用互斥并允许任务挂起的基本类是Condition,你可以通过在Condition上调用await()来挂起一个任务
    • 调用diginal()唤醒一个任务
    • 调用diginalAll()唤醒所有任务(比notifyAll()安全)
  1. 生产者-消费者与队列
  • (1) 同步队列在任何时刻都只允许一个任务插入或移除元素
  • (2) LinkedBlockingQueue:无届队列
  • (3) ArrayBlockingQueue:固定尺寸,可以在其阻塞前,向其中放置有限元素
  1. 任务间使用管道进行输入/输出
  • PipedReader 与普通 I/O(System.in.read())之间最重要的差异 – PipedReader 是可中断的
6.死锁
  1. 死锁:任务之间相互等待的连续循环,没有哪个线程能继续

  2. 发生死锁同时满足的4个条件
  • (1) 互斥条件。任务使用的资源中至少有一个是不能共享的
  • (2) 至少有一个任务它必须持有一个资源且正在等待获取一个当前被别的任务持有的资源
  • (3) 资源不能被任务“抢着占用”。任务必须把资源释放当作普通事件
  • (4) 必须有循环等待
  1. 防止死锁,只需破坏其中一个即可,最容易的方法是破坏第四个条
7.新类库中的构件
  1. CountDownLatch
  • 它被用来同步一个或多个任务,强调它们等待由其他任务执行的一组操作完成
  • 你可以向 CountDownLatch 对象设置一个初始计数值,任何在这个对象上调用 await() 的方法都将阻塞,直至这个计数值到达 0。其他任务在结束其工作时,可以在该对象上调用 countDown() 来减小这个计数值。CountDownLatch 被设计为只触发一次,计数值不能被重置
  1. CyclicBarrier
  • 你希望创建一组任务,它们并行的执行工作,然后在进行下一个步骤之前等待,直至所有任务完成
  • 相比 CountDownLatch,CyclicBarrier 可以多次重用。
  • 可以向 CyclicBarrier 提供一个 “栅栏动作”,它是一个 Runnable,当计数值到达 0 时自动执行。
  1. DelayQueue
  • 无界的 BlockingQueue,用于放置实现了 Delayed 接口的对象,其中的对象只能在其到期时才能从队列中取走。这种队列是有序的,即队头对象的延迟到期的时间最长。如果没有任何延迟到期,那么就不会有任何头元素,并且 poll() 将返回 null
  1. PriorityBlockQueue
  • 基础的优先队列,它具有可阻塞的读写操作
  1. Semaphore
  • 计数信号量允许 n 个任务同时访问这个资源。

  • semaphore.acquire():获取信号量,没信号量可用时,将进行阻塞

  • semaphore.release():释放信号量

  • Semaphore的acquire()方法用法:

    private Semaphore semaphore = new Semaphore(20);  
    semaphore.acquire(5);  
    //省略代码  
    semaphore.release(5);  
    
    在代码中一共有10个许可,每次执行semaphore.acquire(5);代码时耗费掉5个,所以20/5=4,
    说明同一时间只有4个线程允许执行acquire()和release()之间的代码。
    
  1. Exchanger
  • (1) Exchanger是在两个任务之间缉拿换对象的栅栏。可以有更多对象在被创建的同时被消费
  • (2) 比如,ExchangerProducer 和ExchangerConsumer 使用一个 List 作为要交换的对象,
    它们都包含同一个用于这个 List 的 Exchanger。当你调用 Exchanger.exchange() 方法时,它将阻塞直至对方调用它自己的 exchange() 方法,
    那时,这两个 exchange() 方法将全部完成,而 List 则被互换
8.仿真
  • 例如:饭店仿真
    • SynchronousQueue 是一种没有内部容量的阻塞队列,因此每个 put() 都必须等待一个 take(),反之亦然
9.性能调优
  1. 比较各类互斥技术
  • (1) “微基准测试” 的危险:
    • 我们只有在这些互斥存在竞争的情况下,才能看到真正的性能差异,因此必须有多个任务尝试着访问互斥代码区。
    • 我们需要防止编译器去预测结果的可能性。当编译器看到 synchronized 关键字时,有可能会执行特殊的优化,甚至可能会注意到这个线程是单线程的。编译器甚至可能会识别出 counter 被递增的次数是固定数量的,因此会预先计算出其结果。
    • 我们必须使程序更加复杂。首先我们需要多个任务,但并不只是会修改内部值的任务,还包括读取这些值的任务(否则优化器可以识别出这些值从来都不会被使用)。另外,计算必须足够复杂和不可预测,以使得编译器没有机会执行积极优化。
  • (2) 使用Lock通常会比使用synchronized要高效许多,而且synchronized的开销看起来变化范围太大,而Lock相对比较一致
  • (3) synchroized的代码可读性比Lock的“加锁-try/finally-解锁”要高
  • (4) 以 synchronized 关键字入手,只有在性能调优时才替换为 Lock 对象这种做法,是具有实际意义的
  1. 免锁容器
  • (1) 免锁容器背后的通用策略是:对容器的修改可以与读取操作同时发生,读取者只能看能看到完成修改的结果。
  • (2) 修改是在容器数据结构的某个部分的一个单独的副本(有时是整个数据结构的副本)上执行的,当修改完成时,被修改的结构会自动的与主数据结构进行交换,之后读取这就可以看到这个修改了
  • (3) CopyOnWriteArrayList,写入将导致创建整个底层数据的副本,当完成时,一个原子性的操作将把新的数组换入,读取操作就可以看到这个新的修改
  • (4) CopyOnWriteArrayList\ConcurrentHashMap不会抛出ConcurrentModificationException异常
  • (5) 添加写入者时对容器的影响程度:
    • ConcurrentHashMap < CopyOnWriteArrayList/CopyOnWriteSet < synchronized ArrayList
  1. 乐观加锁
  • (1) 乐观加锁:保持数据为未加锁定状态,并希望没有其他任务插入修改它
  • (2) compareAndSet()
    • 去执行某项计算时,没有使用互斥,计算完成后更新Atomic对象,需要使用compareAndSet()方法
    • 将新值和旧值交给compareAndSet()方法,若旧值与Atmoic对象发现的值不一致,那么这个操作就失败了
    • 如果compareAndSet()失败,必须执行某些恢复操作;否则需要使用传统的互斥
  1. ReadWriteLock
  • (1) ReadWriteLock使得你可以同时有多个读者,只要它们都不试图写入即可
  • (2) 如果写锁已经被其他任务持有,那么任何读取者都不能访问,知道这个写锁被释放为止
  • (3) ReadWriteLock 能否提高程序的性能是不可确定的,要考虑以下条件
    • 数据被读取的频率与被修改的频率相比较的结果
    • 读取和写入操作时间
    • 有多少线程竞争
    • 是否在多处理机器上运行等因素
10.活动对象
  1. 多线程模型来自于过程型编程世界,并且几乎没做什么改变
  2. 有两种可替换的方式被称为活动对象(行动者)或者基于代理的编程领域
  3. 任何传递给活动对象方法调用的参数都必须是只读的其他活动对象,或者是不连接对象(作者的术语),即没有任何连接其他任务的对象(这一点很难强制保障,因为没有任何语言支持它)。有了活动对象:
  • (1) 每个对象都可以拥有自己的工作器线程。
  • (2) 每个对象都将维护对它自己的域的全部控制权(这比普通的类要更严苛一些,普通的类只是拥有防护它们的域的选择权)。
  • (3) 所有在活动对象之间的通信都将以在这些对象之间的消息形式发生。
  • (4) 活动对象之间的所有消息都要排队。
  1. 由于一个活动对象系统只是经由消息来通信,所以两个对象在竞争调用另一个对象上的方法时,是不会被阻塞的,而这意味着不会发生死锁

猜你喜欢

转载自blog.csdn.net/Tianc666/article/details/108978111
今日推荐