Java多线程编程实战指南 核心篇 总结-4

 

线程间协作
等待与通知:wait/notify
wait方法一个线程因其执行目标动作所需的保护条件未满足而被暂停的过程。wait方法继承自Object类(方法修饰符为fianl native,这也解释了为什么condition类中不能重写wait等方法)

  1. 阻塞:wait方法的调用都会使当前线程阻塞。该线程将会被放置到对该Object的请求等待队列中,然后让出当前对Object所拥有的对象锁。线程会一直暂停所有线程调度,直到下面其中一种情况发生:

    ① 其他线程调用了该Object的notify方法,而该线程刚好是那个被唤醒的线程;

    ② 其他线程调用了该Object的notifyAll方法;

    ③ 其他对象中断/杀死了该线程;

    ④ (这种情况,只针对前两个方法)线程在等待指定的时间后;

  2. 恢复:线程将会从等待队列中移除,重新成为可调度线程。它会与其他线程以常规的方式竞争对象锁。一旦它重新获得对象的锁,所有之前的请求状态都会恢复,也就是线程调用wait的地方的状态。线程将会在之前调用wait的地方继续运行下去。

为什么wait方法必须放在临界区中使用?
由于一个线程只有在持有一个对象的内部锁的情况下才能够调用该对象的wait方法。


通知notify和notifyAll方法:一个线程更新了系统的状态,使得其他线程所需的保护条件得以满足的时候唤醒那些被暂停的线程的过程。notify的作用就是唤醒请求队列中的一个线程,而notifyAll唤醒的是请求队列中的所有线程。

  1. 被唤醒的线程不会马上运行,除非获取了该Object的锁。调用notify的线程,在调用notify后,不会像wait一样,马上阻塞线程的运行。而是继续运行,直到相应的线程调度完成或者让出Object的锁。而被唤醒的线程会在当前线程让出Object锁后,与其他线程以常规的方式竞争对象锁(正如上面提到的)。
  2. 选择:
    1. notify可能导致信号丢失的问题(如果等待队列中线程有多个,notify只会随机的唤醒一个)
    2. notifyAll会把不需要唤醒的等待线程都唤醒了,但是正确性有保障(不需要唤醒的等待线程被唤醒,会导致上下文切换)
    3. 使用notify代替notifyAll的条件:
      1. 一次通知仅需要唤醒至多一个线程。
      2. 相应对象上的所有等待线程都是同质等待线程。


wait/notify的开销与问题:

  1. 过早唤醒问题
    1. 产生条件,多个线程竞争一个锁,但不同线程的保护条件不同,导致一个线程因为自己的保护条件成立,唤醒了锁对象中等待队列的所有线程。其他保护条件不成立的线程被过早唤醒。(该情况,只能使用while循环判断保护条件,防止唤醒的线程不是保护条件对应的线程
    2. 副作用,引起上下文切换 。
  2. 信号丢失问题:等待线程错过了一个本来发送给它的信号,导致等待线程一直处于等待状态。
    1. 判断保护条件的方式不对,使用if判断保护条件。规避:要将对保护条件的判断和Object.wait()调用放在一个循环语句之中就可以避免信号丢失。
    2. 使用Object.notify()通知具有多个线程的等待队列。规避:如果有多个线程在等待队列中,使用Object.notifyAll()来通知。
    3. 总的来说,信号丢失本质上是一种代码错误,而不是java标准库API自身的问题。
  3. 欺骗性唤醒问题:等待线程也可能在没有其他任何线程执行Object.notify()/notifyAll()的情况下被唤醒。由于操作系统是允许这种现象产生,因此java平台同样允许这种现象的存在。
  4. 上下文切换 :wait/notify的使用可能导致较多的上下文切换。
    1. 减少wait/notify导致过多的上下文切换。
      1. 在保证程序正确性的前提下,使用Object.notify()代替Object.notifyAll().
      2. 通知线程在执行完Object.notify()/notifyAll()之后尽快释放相应的内部锁。这样可以避免被唤醒的线程在Object.wait()调用返回前再次申请相应的内部锁时,由于该锁尚未被通知线程释放而导致该线程被暂停。


等待与通知:wait/notify是一种非公平的调度方式,等待线程和通知线程是同步在同一对象之上的两种线程。


Thread.join( )

  1. 使用wait和notify配合实现
  2. 当前线程调用,则其它线程等待当前线程执行完毕

Java 条件变量

Condition接口可作为wait/notify的代替品来实现等待/通知,它为解决过早唤醒问题提供了支持,对应方法包括await、singal和singalAll方法 。

  1. Condition 需要与显式锁配合使用。Condition.await/singal也要求执行线程持有创建该Condition实例的显示锁。
  2. Lock.newCondition( )的返回值就是一个Condition实例。
  3. 每个Condition实例内部都维护了一个用于存储等待线程的队列(也就是为什么可以避免过早唤醒的原因)。(Condition样式代码中,condition.await()代码应该在while条件判断中)
  4. Condition解决了两个问题:
    1. 多个保护条件导致的线程过早唤醒的问题。由于条件变量的使用可以避免过早唤醒的问题,因此其使用导致的上下文切换要比wait/notify少一些。
    2. Object.wait(long)存在的问题,无法区分其返回时由于等待超时还是被通知的.Condition.awaitUnil(Date)返回true表示进行的等待尚未达到最后期限,即此时方法的返回是由于其他线程执行了相应条件变量的signal/signalAll方法。

倒计时协调器:CountDownLatch (为解决特定问题的一种Condition

  1. Thread.join()实现的是一个线程等待另外一个线程结束。有时候一个线程可能只需要等待其他线程执行的特定操作结束即可,而不必等待这些线程终止,可以使用CountDownLatch实现
  2. CountDownLatch可以用来实现一个(或者多个)线程等待其他线程完成一组特定操作之后才继续执行。这组操作被称为先决操作(或者叫做满足前提条件后再操作)。(CountDownLatch的使用,可以把线程可以划分为逻辑主线程和特定操作逻辑工作线程,特定操作逻辑工作线程的结果是逻辑主线程特定执行操作的前提)
  3. 该协调器内部维护一个计数器,CountDownLatch.countDown()每被执行一次都会使计数器值减少1.
  4. CountDownLatch.await( ),当计数器不为0时,该方法的调用将会导致执行线程被暂停,这些线程就叫做该CountDownLatch上的等待线程。
  5. CountDownLatch.countDown()相当于一个通知方法,当计数器值达到0时,唤醒所有等待线程。
  6. 当然对应还有CountDownLatch.await(long, TimeUnit)方法.
  7. CountDownLatch的使用是一次性的。

栅栏(CyclicBarrier)(为解决特定问题的一种CountDownLatch

  1. ​​​有时候多个线程可能需要相互等待对方执行到代码中的某个地方(集合点),这时这些线程才能够继续执行。
  2. 使用CyclicBarrier实现等待的线程被称为参与方(Party)。参与方只需要执行CyclicBarrier.await()就可以实现等待。
  3. 该栅栏维护了一个显示锁,可以识别出最后一个参与方,当最后一个参与方调用await()方法时,前面等待的参与方都会被唤醒,并且该最后一个参与方也不会被暂停。
  4. CyclicBarrier的其中一个构造器允许我们指定一个被称为barrierAction的任务(Runnable接口实例)。barrierAction会被最后一个线程执行CyclicBarrier.await()方法时执行,该任务执行结束后其他等待线程才会被唤醒。
  5. 内部实现:
    1. 维护了一个计数器变量count = 参与方的个数
    2. 调用await方法可以使得count-1;
    3. 当判断到是最后一个参与方时,调用singalAll唤醒所有线程。
  6. 应用场景:
    1. 使迭代算法并发化
    2. 在测试代码中模拟高并发
    3. CyclicBarrier用来实现这些工作者线程中的任意一个线程在执行其操作前必须等待其他线程也准备就绪;即实现这些工作者线程尽可能在同一时刻开始其操作
  7. CyclicBarrier的滥用:典型例子,利用CyclicBarrier的构造器barrierAction指定一个任务,以实现一种等待线程结束的效果:barrierAction中的任务只有在目标线程结束后才能够被执行。而这种情况,我们完全可以使用更加对口的Thread.join()或者CountDownLatch来实现。因此,如果代码对CyclicBarrier的调用不是放在一个循环之中,并且使用CyclicBarrier的目的也不是为了模拟高并发操作,那么此时对CyclicBarrier的使用可能是一种滥用。

生产者-消费者模式

  1. 阻塞队列
    1. ArrayBlockingQueue
      1. 内部使用一个数组作为其存储空间,数组的存储空间是预先分配的。(优点:put和take操作不会增加GC的负担
      2. 内部使用一把显示锁,实现队列空时消费者线程暂停,队列满时暂停生产者线程(所以同一个时间只有一个线程操作这个队列),缺点:可能导致锁争用,导致较多的上下文切换。
        1. 由以下源码,因为内部使用一把显示锁,同一个时间只有一个线程操作这个队列,所以通知时,只用使用singal通知任意一个线程就可以了,通知多个线程返回会导致较多的上下文切换。同时put中通知读线程非空,take方法中通过写线程非慢,可以起到控制读写线程交替操作队列的作用,不让只有一方操作队列,会使生产-消费失衡。
        2. public void put(E e) throws InterruptedException {
                  checkNotNull(e);
                  // 获取可重入锁
                  final ReentrantLock lock = this.lock;
                  // 如果当前线程未被中断,则获取锁
                  lock.lockInterruptibly();
                  try {
                      while (count == items.length) // 判断元素是否已满
                          // 若满,则等待
                          notFull.await();
                      // 入队列
                      enqueue(e);
                  } finally {
                      // 释放锁
                      lock.unlock();
                  }
              }
          
          
          private void enqueue(E x) {
                  // assert lock.getHoldCount() == 1;
                  // assert items[putIndex] == null;
                  // 获取数组
                  final Object[] items = this.items;
                  // 将元素放入
                  items[putIndex] = x;
                  if (++putIndex == items.length) // 放入后存元素的索引等于数组长度(表示已满)
                      // 重置存索引为0
                      putIndex = 0;
                  // 元素数量加1
                  count++;
                  // 唤醒在notEmpty条件上等待的线程
                  notEmpty.signal();
              }

           

        3. 适合在生产者线程和消费者线程之间的并发程序较低的情况下使用。

        4. 支持公平调度和非公平调度。

    2. LinkedBlockingQueue
      1. 内部存储空间是一个链表,而链表节点所需的存储空间是动态分配的。(缺点:增加了GC的负担)
      2. 优点:put和take操作使用两个显示锁(putLock和takeLock),所以同一个时间最多存在两个线程操作这个队列。
        1. 由以下源码,因为内部使用两个显示锁(putLock和takeLock) ,同一个时间只存在两个线程(读线程和写线程)操作这个队列 ,由上同理,通知时任意锁使用singal通知任意一个读线程或者一个写线程就可以了,通知多个线程会导致较多的上下文切换。但是由于两把锁,所以无法控制读写线程交替操作队列,所以无论在put方法中还是在take方法中,都会同时通知非空和非慢等待线程,保证生产-消费平衡。
        2. public void put(E e) throws InterruptedException {
                  // 值不为空
                  if (e == null) throw new NullPointerException();
                  // Note: convention in all put/take/etc is to preset local var
                  // holding count negative to indicate failure unless set.
                  // 
                  int c = -1;
                  // 新生结点
                  Node node = new Node(e);
                  // 存元素锁
                  final ReentrantLock putLock = this.putLock;
                  // 元素个数
                  final AtomicInteger count = this.count;
                  // 如果当前线程未被中断,则获取锁
                  putLock.lockInterruptibly();
                  try {
                      /*
                       * Note that count is used in wait guard even though it is
                       * not protected by lock. This works because count can
                       * only decrease at this point (all other puts are shut
                       * out by lock), and we (or some other waiting put) are
                       * signalled if it ever changes from capacity. Similarly
                       * for all other uses of count in other wait guards.
                       */
                      while (count.get() == capacity) { // 元素个数到达指定容量
                          // 在notFull条件上进行等待
                          notFull.await();
                      }
                      // 入队列
                      enqueue(node);
                      // 更新元素个数,返回的是以前的元素个数
                      c = count.getAndIncrement();
                      if (c + 1 < capacity) // 元素个数是否小于容量
                          // 唤醒在notFull条件上等待的某个线程
                          notFull.signal();
                  } finally {
                      // 释放锁
                      putLock.unlock();
                  }
                  if (c == 0) // 元素个数为0,表示已有take线程在notEmpty条件上进入了等待,则需要唤醒在notEmpty条件上等待的线程
                      signalNotEmpty();
              }
          
          
          private void signalNotEmpty() {
                  // 取元素锁
                  final ReentrantLock takeLock = this.takeLock;
                  // 获取锁
                  takeLock.lock();
                  try {
                      // 唤醒在notEmpty条件上等待的某个线程
                      notEmpty.signal();
                  } finally {
                      // 释放锁
                      takeLock.unlock();
                  }
              }
      3. 适合在生产者线程和消费者线程之间的并发程序较高的情况下使用。
      4. 仅支持非公平调度,不支持公平调度。
    3. SynchronousQueue
      1. 可以被看做一种特殊的有界队列。
      2. 生产者线程生产一个产品之后,会等待消费者线程来取走这个产品,才会接着生产下一个产品。
      3. 声明一个SynchronousQueue有两种不同的方式,它们之间有着不太一样的行为。公平模式和非公平模式的区别:
        1. 如果采用公平模式:SynchronousQueue会采用公平锁,并配合一个FIFO队列来阻塞多余的生产者和消费者,从而体系整体的公平策略;
        2. 但如果是非公平模式(SynchronousQueue默认):SynchronousQueue采用非公平锁,同时配合一个栈来管理多余的生产者和消费者,而后一种模式,如果生产者和消费者的处理速度有差距,则很容易出现饥渴的情况,即可能有某些生产者或者是消费者的数据永远都得不到处理。
  2. 适合在生产者线程和消费者线程之间的处理能力相差不大的情况下使用。

流量控制与信号量(Semaphore)

  1. Semaphore用来控制同一时间内对虚拟资源的访问次数, 可以理解为一种线程级别的流量控制。主要是控制线程的规模。
  2. Semaphore.acquire/Semaphore.release分别用于申请和释放许可, 应该总是配对使用。
  3. Semaphore.acquire()在成功获得一个许可后会立即返回。
  4. 如果可用配额不足,那么会使其执行线程暂停。
  5. Semaphore内部会维护一个等待队列用于存储这些被暂停的线程。
  6. Semaphore.release必须放在finally语句中,确保执行。
  7. Semaphore采用非公平调度策略。

双缓冲和Exchanger

  1. 设立两个缓冲区
  2. 消费者线程消费一个已填充的缓冲区时,另外一个缓冲区可以由生产者线程进行填充,实现数据的生产和消费的并发

线程中断机制

  1. java线程中断机制相当于java线程与线程间协作的一套协议框架。
  2. 中断(interrupt)可被看作由一个线程发送给另一个线程的一个指示。
  3. java平台会为每个线程维护一个被称为中断标记的布尔型状态变量用于表示相应线程是否接收了中断。但是设置中断标记并不代表线程立刻执行中断行为。
  4. 能够响应中断的方法通常是在执行阻塞操作前判断中断标志,若中断标志值为true则抛出InterruptedException.
  5. Java多线程编程实战指南 核心篇 总结-4_第1张图片
  6. 中断只是为了引起该线程的注意,来决定如何应对中断。有些很重要的线程,以至于他们不理会中断,而是继续执行,但更多的情况下,线程应该把中断看做是一个终止请求,在终止线程前,有时间做一些收尾清理工作
  7. 阻塞的线程如果被中断,会在阻塞处抛出InterruotException来唤醒线程,并将线程中断标志位设置为false,因为线程已经处理了异常(抛出),就要回到就绪状态。
  8. synchronized, Lock.lock获取锁操作不能被中断,如果拿不到资源,会一直阻塞下去,这种情况可以称为目标线程无法对中断进行响应。InputStream.read()、ReentrantLock.lock()以及申请内部锁等阻塞方法就属于这种类型。(也就是说,synchornized、ReentrantLock.lock()获取锁操作造成的阻塞在中断状态下不能抛出InterruptException,即获取锁操作是不能被中断的,要一直阻塞等到到它获取到锁为止。也就是说如果产生了死锁,则不能被中断。可以使用超时锁来打破死锁,reentrantLock.tryLock(timeout, unit)在timeout后会抛出InterruptException,唤醒线程做响应操作)
  9. 在捕获到InterruptException后如何处理呢,通常有如下两种方式,这两种方式本质上是一样的,都是将中断交由用户应用程序来处理。
    1. 在catch子句中调用Thread.currentThread.interrupt()来重设中断状态位(因为抛出中断异常后,中断状态位会被清除,即false),让外界判断Thread.currentThread.isInterrupted()来决定线程的行为。
      try {
              sleep(delay);
          } catch (InterruptedException e) {
              Thread.currentThread().isInterrupted();
          }

       

    2. 更加直接,不用捕获异常,直接抛出InterruptedException,让用户程序去处理异常。
      try {
              sleep(delay);
          } catch (InterruptedException e) {
              throw e;
          }

       

线程停止

  1. 实现思路:为待停止的线程(目标线程)设置一个线程停止标记(布尔型),目标线程检测到该标志值为true时,设法让其run方法返回,实现线程的终止
  2. 通用的线程优雅停止办法:发起线程更新目标线程的线程停止标记并给其发送中断,目标线程仅在当前无待处理任务且不会产生新的待处理任务情况下才能使run方法返回

 

保障线程安全的设计技术

无状态对象:不包含任何实例变量或者可更新静态变量。

  1. 典型应用: JavaEE中的Servlet, Servlet是一个实现javax.servlet.Servlet接口的托管类,而不是一个普通的类。所谓托管类,是指Servlet类实例的创建、初始化及销毁的整个对象生命周期完全是由Java Web 服务器控制的,而服务器为每一个Servlet类最多只生产一个实例,该唯一的实例会被多个线程共享,因此使Servlet类成为无状态对象有利于提高服务器的并发性。这也是Servlet类一般包括实例变量或者静态变量的原因

不可变对象要同时满足以下所有条件:

  1. 类本身使用final修饰:这是为了防止通过创建子类来改变其定义的行为。
  2. 所有字段都用final修饰:使用final修饰不仅仅是从语义上说明被修饰字段的值不可改变;更重要的是这个语义在多线程环境下保证了被修饰的初始化安全,即final修饰的字段对其他线程可见时,它必定初始化完成。
  3. 对象在初始化过程中没有逸出:防止内部匿名类在对象初始化过程中访问对象本身。
  4. 任何字段,若其引用了其他状态可变的对象(如集合、数组),则这些字段必须是private修饰的,并且这些字段值不能对外暴露。若有相关方法要返回这些字段值,则应该进行防御性复制(Defensive Copy)或者返回之都的Iterator实例。

缺点:增加GC负担。

典型应用场景:

  1. 被建模对象的状态变化不频繁。
  2. 同时对一组相关的数据进行写操作,因此需要保证原子性。
  3. 使用不可变对象作为安全可靠的Map键

线程持有对象ThreadLocal

详细分析及相关案例见:

http://www.importnew.com/22039.html

另外注意,ThreadLocal在线程池中使用时会退化为task级别的私有数据,不再是线程级别的私有数据,所以在涉及线程池的业务中使用ThreadLocal需要谨慎。

 

并发集合
常用的并发集合:

  1. ArrayList----CopyOnWriteArrayList     快照实现遍历
  2. HashSet---CopyOnWriteArraySet        快照实现遍历
  3. LinkedList---ConcurrentLinkedQueue    准实时 (适合更新操作和遍历操作并发的场景,BlockingQueue则适合更新操作并发的场景)
  4. HashMap---ConcurrentHashMap       准实时
  5. TreeMap---ConcurrentSkipListMap     准实时
  6. TreeSet---ConcurrentSkipListSet          准实时

 快照(Snapshot):

  • 是在Iterator实例被创建的那一刻待遍历对象内部结构的一个只读副本;由于对同一个并发集合进行遍历操作的每个线程都会得到一个快照,所以快照相当于这些线程的特有对象。
  • 由于快照是只读的,因此这种方式所返回的Iterator实例是不支持remove方法的。
  • 优点:遍历操作和更新操作互不影响。
  • 缺点:当遍历的集合较大时,创建快照的开销会比较大。

 准实时:

  • 指遍历操作不是针对待遍历对象的副本进行的,但又不借助锁来保障线程安全的;
  • 借助CAS操作或者粒度极小的锁 。
  • 这种方式所返回的Iterator实例是支持remove方法的。

 

你可能感兴趣的:(java,多线程)