Java Concurrency in Practice 学习笔记

《JAVA 并发编程实战》学习笔记(一)


一.概述

​ 在JAVA基础中比较重要的一些知识体系有:NIO,同步容器和并发容器,并发编程等,学习《JAVA 并发编程实战》可以了解到关于线程安全,对象发布,多线程编程,并发模型,线程池构建原理以及同步器构建等相关的内容。

二.基本概念

  • 线程安全

    • 理解线程安全性,需要先明白正确性的概念。程序在按照一定的逻辑组织起来之后,在单线程环境下的运行结果如预期一样,即"所见即所知",那就可以称之为单线程下的正确性。

    • 在此基础上定义线程安全性:当多个类访问某个类时,这个类始终能表现出如单线程下一样的正确行为–逻辑没有被破坏–,那么就称这个类是线程安全的。

    • 无状态的对象是线程安全的:对象无状态意味着没有可以被共享处理的信息,例如书中的例子:一个无状态的Servlet,它的 service 方法使用的局部变量并不会逸出到当前线程之外的地方,因此也就不存在竞争的可能性,所以它是线程安全的。

  • 原子性

    • I++的操作:在代码中比较常见的 i++ 的操作,其中分为三个步骤:1.读取 i 的值 ; 2. 将 i 递增1; 3. 对 i 赋新值 。这种紧凑的语法格式,并不是线程安全的,他不具备 原子性–操作不可分割–因此在多线程访问的情况下,彼此获取的 i 可能处于任意一个处理步骤之后,这会导致非预期的结果。
    • 上面这种由于不恰当的执行时序而出现不正确结果的情况,被称作静态条件(Race Condition)
  • 竞态条件

    • 当计算的正确性却决于多个线程的交替执行时序时,那么就会发生静态条件。常见的静态条件类型是 “先检查再执行”,下一步的执行依赖于一个可能失效的结果。

    • 例如延迟初始化的例子:

      • 1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        public class LazyInitRace{
        private ExpensiveObject instance = null;

        public ExpensiveObject getInstance(){
        //检查实例是否已经初始化
        if(instance == null){ //静态条件
        //若未初始化,就执行构造
        instance = new ExpensiveObject();
        }
        return instance;
        }
        }
        • 在静态条件处,多个线程访问时,会获取到不同状态的对象:尚未构造的,构造中的和已经构造完成的。最糟糕的情况是获取到一个未构造完成的对象,调用中很可能发生各种异常,例如属性为空等。
    • 复合操作:类似于之前的 i++ ,如果这个过程的三个步骤是独占性的被访问进行,那就具备了原子性,可以保证线程安全。

      可以选择方式有很多:synchronized 同步关键字,将 i 定义为 原子对象,加锁等。

      • 将 i 定义为原子变量 :

        • 1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          //书中的例子
          public class CountFactorizer implements Servlet{
          private final AtomicLong count = new AtomicLong(0);

          public Long getCount(){return count.get();}

          public void service(ServletRequest req,ServletReponse resp){
          //原子递增
          count.incrementAndGet();
          }
          }
      • 内置锁:

        • 1
          2
          3
          public synchronized void service(ServletRequest req,ServletResponse resp){
          ...
          }
    • 重入:意味着当线程已经持有一个锁,而且下一次调用需要同一个锁时不需要重新释放锁再请求锁,可以直接调用

      • 可重入的锁也可以避免死锁

        • 1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          public class Widge{
          //内置锁方法
          public synchronized void doSomething(){}
          }
          public class LoggingWidget extends Widget{
          //内置锁方法
          public synchronized void doSomething(){
          super.doSomething();
          }
          }
          • 上面这种情况,当调用子类的doSomething()方法时,会先获父类Widget的锁,然后调用父类方法,如果此时内置锁是不可重入的,那么在已经持有Widget锁的情况下,请求获取Widget的锁,就会造成死锁。
    • 活跃性与性能:在大量独立并发的情况下,锁的粒度会直接影响程序的活跃性和性能。例如某个类中只有一个需要加锁的方法,锁的粒度却是类对象,那么意味着其他不是访问这个方法的线程也需要等待获取锁才能调用其他非同步方法,很容易造成不必要的线程阻塞。因此加锁的粒度越细越好,将竟态条件限制在一个极小的范围内,可以减少甚至避免锁竞争。

三.对象共享

  • 正常的单线程情况下,先写入一个值,然后读取这个值,这种情况是不会出错的,这里有非常明确的执行先后顺序,所以能保证结果的正确性。然而在多线程环境下,多个线程并发地读写一个变量,并不知道彼此写入或者读取的值。也就是说它们的操作彼此是不可见的,为了确保多个线程对共享对象操作执行的顺序,需要是同步来保证可见性。

  • 缺少足够同步的情况下可能会:读取到失效数据。例如对变量进行自增,然后读取,可能会读取到自增前的无效值。

  • 非原子的64位操作:失效数据至少是曾经正确的值,但是也有特例:非volatile(内存可见性关键字)类型的long和double,JM允许将对64位的读或写操作,分解为两个32位操作,因此这种情况下如果没有同步,读取到的值可能是已经写入高32位和原本低32位的值,属于中间状态,完全是一个错误的值。需要使用 volatile变量或者所来保护它们。

  • 加锁与可见性:之前提到过,没有同步的多线程因为执行顺序的不确定性,无法保证预期的结果,锁可以用来保证执行步骤的完整性和先后顺序。

    • 1
      2
      3
      4
      5
      6
      class Test{
      private Double i = 0.11;
      public void synchronized insc(){
      i+=1;
      }
      }
    • 使用 synchronized 关键字修饰对 i 递增的方法,这种情况下对方法 insc 的访问就需要先获得Test 对象上的锁,方法执行完毕后会自动释放锁。这种情况下 i 值在递增之后,其他线程再次调用 insc 方法,会获取到递增后的 i 值,也就是保证了变量值在线程之间的可见性。

  • volatie关键字 : 保证内存可见性的关键字,也可以作为轻量级锁使用

    • 要了解 volatile关键字,先来了解一下 java内存模型(JMM)

      • JMM 规定任何线程读取变量,都是从主存获取了一个变量拷贝,对其进行操作,然后将变量值刷新回主存;随后另一个线程B读取同样的变量,会获取到已经更新的正确的值。但是它们都不知道彼此获得的拷贝值,这些拷贝值是保存在线程栈里面的,线程私有。
      • 而没有同步情况下,后来的线程B读取主存中的变量时,并不会等待线程A将变量值刷新到主存之后再读取。
      • volatile 以及其他的同步措施,就是使得A线程的写入和B线程的读取具备先后次序关系,在JMM中称为偏序关系-HappensBefore,满足这个偏序关系的操作,不会因为处理器优化和编译器 重排序而导致执行步骤混乱。
      • 在 HappensBefore 的偏序关系中,volatile 关键字行为的作用是:对 volatile 变量的写操作一定发生在读操作之前,不管这两个操作是否位于同一线程,而且 Atomic 变量也具备相同的语义。
    • 因此上面的代码不加锁可以这样实现:

      • 1
        2
        3
        4
        5
        6
        class Test{
        private volatile Double i = 0.11;
        public void insc(){
        i+=1;
        }
        }
      • 这样可以保证对于变量 i 的写入操作总是发生在读取操作之前,会产生符合我们预期的正确结果/

  • 发布与逸出

    • 上述情况的并发访问保护,是有意识地使用共享变量,所以会主动对其进行访问保护,但有的情况是不安全的发布对象,导致在不正确的地方引用并修改了对象,产生了错误的结果。

    • 不安全的对象发布:

      • 发布一个对象,是指能够让对象在作用域之外的代码使用。例如对外提供对象的引用,或者将引用传递出去。

      • 在对象发布中,如果不应该被发布的对象发布了,称为逸出,我们可以回想一下简单的单例模式:

        • 1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          public SingletonMode{
          private SingletonMode instance= null;

          public SingletonMode getInstance(){
          if(null == instance){
          instance = new SingletonMode();
          }
          return instance;
          }
          }
      • 在上面的这个简单的单例模式中,判断逻辑似乎是正常的:检查实例 instance 是否为空,空的话就构造一个实例返回。不过和之前的 i++ 一样,先判断再操作是一个复合操作,它不具备原子性,它经历的步骤是:先获取sinstance的值,判断instance是否为空,为空就构造一个instance实例,返回instance实例。

      • 如果是在多线程环境下访问这段代码,当第一个线程进入判断实例为空的逻辑,继而构造instance,在instance还没有构造完成之前,可能其他线程也到了这个逻辑,发现instance实例非空,直接返回实例。而这个时候获得的是一个尚未构造完成的instance实例,如果SingletonMode 类具备其他属性,可能就会返回一个不完整得到instance对象,也就无法保证之后所有和instance相关的逻辑的正确性。

      • 还有一种情况是对象本身没有直接发布,但是将持有对象的容器发布出去了,这种情况下任何持有该容器引用的对象都能够通过遍历容器而获得不应该被发布的对象引用,进而改变对象的状态。

      • 在构造函数中隐式的this引用逸出,对于构造函数,只有当构造函数返回时,this引用才应该从线程逸出。一种常见的错误是在构造函数中启动一个线程(不只是创建),this 引用在这种情况下就会被新创建的线程共享。

    • 线程封闭:

      • 可变数据共享会存在安全性问题,如果是不需要共享的数据,可以借助线程封闭技术,这样可以避免使用同步等手段也能保证安全性,无论对象本身是不是线程安全的(杜绝了共享的可能)。
      • 栈封闭:局部变量只要没有逸出–没有被直接或者间接地发布出去,会被封闭执行线程栈中,其他线程无法访问到这个变量,也就不存在并发问题。
      • ThreadLocal 类 : 这个类提供了可以将线程中的值和线程关联的对象保存的功能–存入的对象可以以get(“objName”)的方式获得,当前执行线程调用set(obj)方法设置对象的最新值,随后get方法去获取自身设置的值。该类为每个执行线程都存有一个变量副本,彼此间不会互相干扰。
    • 不变性:多线程环境下,共享的可变对象会引起线程安全问题

      • 多线程环境下发生的对象状态不一致以及失效数据等问题,都和多线程试图同时访问获得一个可变对象的状态有关,如果对象的状态在构造之后就不可变,那么就不存在这些情况了。
      • 构造对象的不可变性,并不意味着将对象都声明成 final ,即使对象的引用声明为final,如果它持有可变对象,那么这个final 对象的内部状态仍然是可变的— final List<ChangeAbleObject> list;
    • 安全发布:

      • 安全发布的常用模式:要安全地发布一个对象,对象的引用和对象的状态必须同时对其他线程可见,一个正确构造的对象可以通过以下方式发布:
        • 在静态初始化函数中初始化一个对象引用—因为静态函数只会在类加载的时候初始化一次
        • 将对象引用保存到 volatile 域或者 AtomicReference 对象中—前者提供了可见性保证。后者还包含了原子性
        • 将对象的引用保存到正确构造的 final 类型域中—对象引用不可变
        • 将对象的引用保存到一个由锁保护的域中—提供原子性
      • 事实不可变对象:如果对象不可变,那么即使是不安全的访问此对象,也是满足安全发布的
        • 如果对象技术上是可变的,但是它的状态在发布后不会再改变,这种对象被称为事实不可变对象

四.构建基础模块

  • 同步容器类:和并发容器类要区分开,同步容器类的单个方法使用是线程安全的,复合操作需要加锁保护;而并发容器则是优化了 这一点,避免使用锁(使用原子类和volatile)提高容器的并发访问性,具有线程安全的复合操作,迭代时可以不同步。

    • java 同步同步容器类包括 Vector 和 HashTable ,它们都是线程安全的,但是某些情况下需要对一些复合操作加锁保护,例如:迭代,跳转和条件运算(putIfAbsent),
    • 迭代器与 ConcurrentModificationException : 之前在 ArrayList 类的笔记中有了解到一个变量— modCount , 它记录了集合被结构性修改的次数,每次add/remove时都会对它进行校验,如果和预期结果不一致,会抛ConcurrentModificationException 异常。并发修改时 fast-fail 只是对并发错误的捕获,因此想要安全地迭代容器,需要在迭代内部加锁,以确保串行访问。
    • 然而迭代容器时加锁,会导致性能下降甚至死锁—例如迭代需要进行复杂的计算,所以需要在迭代时避免加锁访问,因此产生了克隆容器,在容器副本上迭代,由于具备线程封闭环境,因此不存在并发问题,这也是并发容器的实现原理。
  • 隐藏迭代器

    • 假设迭代时采用加锁的方式访问,需要在每次显式迭代都记得加锁已经很繁琐,而且还存在一些隐式迭代
    • 书中列举了在容器Set 上调用toString方法拼接字符串的例子,标准容器的toString 方法会迭代每个元素并调用它们各自的toString 拼接成一个完整的字符串返回,这里就是有隐式的迭代过程。
    • 当把容器作为另一个容器的元素或者键值时—addAll,removeAll—就会出现这种隐式迭代的过程
    • 封装对象的状态有助于维持不变性条件,封装对象的同步机制有助于实施同步策略
      • 前面一句在不可变对象那里已经了解到了,封装对象状态的变化,使得对象状态不易于或者不被修改,可以维持对象状态的不变性;
      • 如果代码具备同步措施保护,就可以尽可能避免并发错误,尤其是在不知情的情况下发生的并发错误。
  • 并发容器

    • 同步容器对所有状态的访问都串行化—同步关键字保护—以提供线程安全,但是性能较差,线程竞争激烈时,吞吐量会

      严重降低;并发容器类是专门为多线程访问设计的,一些并发容器类通过复制容器进行读写最后再合并结果这样的手段提高并发访问性能。

    • ConcurentHashMap : 更多详细内容,见 《ConcurrentHashMap 源码学习》

      • ConcurrentHashMap 也是基于散列的容器,它提供了一种细粒度的分段锁的机制来实现更大程度的容器共享。
      • 它分段锁是基于Segment,一种可重入锁—实现的。多线程在访问 ConcurrentHashMap 时,可以各自访问不同的 Segment 段的数据,彼此之间是不需要同步的,ConcurrentHashMap 的并发访问最小单位就是Segment数组中每个Segment维护的各自的HashEntry数组,因此只有当访问到同一个Segment 时才需要显式地同步措施来保证局部串行访问。
      • 允许的并发线程数量是由构造的 concurrencyLevel 参数决定的,默认的concurrencyLevel=16,表示理论上允许16线程并发地访问。
      • 因此它的迭代器不会抛出并发修改的异常,相对的,意味着它的迭代器具备了弱一致性,并不是百分之百保证所有线程的并发修改都能及时反馈到整个容器上,而且对于一些全局的状态例如size判断等,可能会返回一个失效值。但这并不是很大问题,通常在决定使用ConcurrentHashMap 的场景下,就应该是为了提高 put,remove 这种方法的性能,而容器 size 的存在被弱化,需要明确的知道这一点。
    • CopyOnWriteList : 写时复制

      • 这里用到了前面了解到的事实不可变对象:要改变一个事实不可变对象,就要每次修改都构造一个新的事实不可变对象并返回,实现可变形。
      • 它的迭代器一样没有并发修改的异常,但是写入时复制的动作很明显受到元素数量的影响,如果容器的元素数量比较多,又频繁进行容器复制写入,无疑是不值得的,当迭代次数大于修改次数的时候,才应该使用写入时复制容器
  • 阻塞队列与生产消费者模式

    • 阻塞队列提供了可阻塞的put和take方法—队列满时put阻塞,队列空时take阻塞,但是在无界队列上put是不会阻塞的。
    • 阻塞队列的特点很容易实现生产-消费者模式,生产者持续生产信息直到队列充满,进入阻塞,非满时继续填充;消费者持续从队列中获取信息消费,直到队列为空,进入阻塞状态;将数据的生产过程和使用过程解耦开来
    • 由于上述的这种生产-消费模式,存在速率上的差异,因此实际运用中通常会涉及队列充满时的处理策略,以线程池和工作队列为例:当线程池充满时,后续的任务会放到工作队列中;当工作队列也充满时,就需要决定是由当前线程执行任务,还是拒绝任务抛出异常,或者丢弃最旧的一个任务等…
    • 构建高可靠性的程序时,有界队列是一种强大的资源管理工具:能够抑制产生过多的工作项,耗尽系统资源导致整个程序不可用,当有界队列无法满足需求而代码无法再进行优化的时候,应该考虑其他方式或者增加硬件配置来提升性能。
    • 串行线程封闭 : 生产-消费者模式,其实是通过阻塞队列安全地将对象的所有权转移,生产者不会再访问发布的对象,因此没有可能将它再次发布出去;只有一个消费者可以获得该对象,同样没有再次将它发布到别的地方,这个过程就构成了对象所有权的安全转移,利用了串行线程封闭实现安全发布。
    • 这个操作同样可以通过并发容器的remove,原子类的CAS方法等实现,只要确保只有一个线程接受被转移的对象。
  • 阻塞方法与中断方法

    • 先回忆一下线程状态: NEW,RUNNABLE,BLOCKED,WAITING,TIMED-WAITING,TERMINATED.这些字段描述表示线程在不同生命周期时的状态:初始化,可运行,阻塞,等待,终止。
    • 线程阻塞的原因有很多:等待锁,等待竞争锁,执行了时间等待,处于条件等待(活锁)等。线程阻塞时,它通常处于某种阻塞状态(BLOCKED,WAITING)。阻塞操作与耗时操作的区别是:被阻塞的线程必须等待某个不受控制的事件发生后才能继续执行,状态被重新置为RUNNABLE,竞争锁和CPU资源。
    • 当某个方法抛出 InterruptedException 时,表示它是一个阻塞方法,会响应中断,努力提前结束阻塞状态—这并不是必然的。
    • Thread 类的 interrupt 方法用于中断线程或者查询线程是否已经中断。中断是一种协作机制,而不是控制机制;一个线程中断另一个线程时,并不代表被中断线程会被强制结束或者强制要求执行特定的操作,只是要求被中断线程在执行到某个可以暂停的地方时,停止正在执行的操作。
    • 最常使用中断的情况是取消某个操作,方法对中断的响应程度越高,就越容易取消那些耗时的任务。
    • 处理中断:对于中断异常,不能直接忽略,可以选择传递该异常:捕获/不捕获异常直接抛出;恢复中断:当在runnable 内部发生中断时,必须捕获中断异常,并且调用 Thread.currentThread().interrupt();恢复中断状态 ,这样高层代码可以会看到发生中断并进行处理。
  • 同步工具类

    • 同步工具类可以是任何一个对象,只要它根据自身的状态来协调线程的控制流。例如阻塞队列:队列满时,阻塞生产线程直到队列有空余空间;队列空时,阻塞消费者线程,知道队列有对象存在。
    • 所有的同步工具类都包含一些特定的结构化属性:
      • 封装了一些状态
      • 这些状态决定执行同步工具类的线程是等待还是继续执行
      • 还提供了一些对状态进行设置的方法
      • 和另一些方法用于高效地等待同步工具类进入预期状态
    • 这么一看,这不就是AQS了,AbstractQueuedSynchronizer 是构建大部分同步工具的基础,在《AQS队列同步器》里面可以看到相关的代码分析
    • 闭锁:
      • 是一种可以延迟线程的进度,直到其到达终止状态。闭锁的作用相当于一扇门,门关闭时,条件没有满足,任何线程都无法通过;条件满足时,门打开允许所有线程通过,并且一直维持打开的状态。
      • CountDownLatch:
        • 一种灵活的闭锁实现,它包含一个计数器,构造时使用正数初始化闭锁,表示需要等待的事件数量。
        • countDown()方法递减计一次数器,表示有一个事件发生了
        • await()方法等待计数器为0,表示所有事件都已经发生,可以打开闭锁,允许线程通过
      • FutureTask:
        • Executor 框架中表示异步任务,使用Callable 实现
        • FutureTask 的 get() 方法取决于任务的状态,如果任务已经完成,get会立刻返回任务的执行结果;否则get会阻塞直到任务完成。
        • FutureTask 将计算结果从执行线程转到获取结果的线程也是安全发布。
      • 信号量:
        • Counting Semaphore 用来控制同时访问某个特定资源的操作数量,或者执行某个指定操作的数量,可以用来实现资源池或对容器施加边界
        • Semaphore 管理一组许可,获取许可可以访问资源,随后归还许可;这很类似于资源池,例如线程池:有任务到来请求一个线程去执行,执行完成任务后归还线程到线程池;如果线程池中没有可用线程,将会进入工作队列等待;同样阻塞队列也可以做到这一点。
        • 通过对普通容器添加一个信号量进行包装,可以构造出一个有界阻塞容器:初始化信号量许可值为容器的容量,添加元素前先获得许可,添加完成后释放许可;删除元素后也释放一个许可,允许其他元素添加进来。

五.小结

  • 线程安全的代码,在多线程环境下也能保证和单线程一样的运行结果
  • 不可变对象可以被安全地访问,因为它们状态不会改变,因此也不会产生非预期的结果
  • 要改变事实不可变对象,需要构造一个新的不可变对象返回,达到可变的目的
  • 对象的可变状态是至关重要的,所有的并发问题都可以归结为如何协调并发状态的访问,可变状态越少甚至没有,就越容易保证线程安全性
  • 在确定对象域不需要变化的情况下,将它们声明为final
  • 用锁保护可变变量
  • 当保护同一个不变性条件中的所有变量时,要使用同一个锁,避免需要对同一个对象持有不同的锁导致死锁
  • 执行复合操作期间,需要持有锁