一、Java内存模型基础

1.1 并发编程模型

在并发编程中,线程之间如何通信以及线程之间如何同步是两个关键问题。通信是指线程之间以何种机制来交换信息。同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在命令式编程中,线程之间的通信机制有两种:

  1. 共享内存:线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。同步是显示进行的。Java的并发采用的就是这种模型。
  2. 消息传递:线程之间没有公共状态,线程之间必须通过发送消息来显示进行通信。同步时隐式进行的。

    1.2 Java内存模型的抽象结构

    在Java中,所有的实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,不会有内存可见性问题,也就不受内存模型的影响。
    Java线程之间的通信由Java内存模型JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:

    线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。

image.png
从图可以看出线程A和线程B想要通信的话必须要经过两个步骤:

  1. 线程A吧本地内存A中更新过的共享变量刷新到主内存中去
  2. 线程B到主内存中读取线程A之前已经更新过的共享变量

从整体来看,这个两个步骤实质上是线程A和线程B发送消息,而且这个通信过程必须经过主内存。JMM通过控主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性的保证。

1.3 从源码到指令序列的重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。分为3种类型:

  1. 编译器优化的重排序。在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  3. 内部系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

image.png
上述1属于编译器重排序,2和3属于处理器重排序。

  • 对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。
  • 对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

    1.4 并发编程模型的分类

    现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,他可以避免由于处理器停顿下来等待向内存写数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区对同一内存地址的多次写,减少对内存总线的占用。但是,每个处理器的写缓冲区仅仅对它所在的处理器可见。会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。如表所示是常见的处理器允许的重排序类型的列表
处理器\规则 Load-Load Load-Store Store-Store Store-Load 数据依赖
SPARC-TSO × × × ×
x86 × × × ×
IA64 ×
PowerPC ×

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM吧内存屏障指令分为4类。

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1数据的装载先于Load2及所有后续装载指令的装载。
StoreStore Barriers Store1;StoreStore;Sotre2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续指令的存储
LoadStore Barriers Load1;LoadSotre;Store2 确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存
SotreLoad Barriers Store1;StoreLoad;Load2 确保Store1数据对其他处理器变得可见(刷新到内存)先于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令完成之后才执行该屏障之后的内存访问指令。值得一提的是,该屏障是一个全能型屏障,能同时具有其他3个屏障的效果,但是执行该屏障开销很大,因为它需要把写缓冲区中的数据全部刷新到内存中。

1.5 happens-before简介

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
happens-before规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

    二、重排序

    2.1 as-if-serial 语义

    不管怎么重排序,单线程程序的执行结果不能被改变。为了遵循as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。

    2.2 程序顺序规则

    如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
    如果重排序操作A和操作B后的执行结果与操作A和操作B按照happens-before顺序执行的结果一致。JMM会认为重排序并不非法。

    2.3 顺序一致性

    1. 数据竞争与顺序一致性

    JMM对正确同步的多线程程序的内存一致性做了如下保证:

    如果程序是正确同步的,程序的执行将具有顺序一致性——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

2. 顺序一致性内存模型

  1. 一个线程中的所有操作必须按照程序的顺序来执行
  2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读写操作。

  • 假设两个线程A和B并发执行,如果这两个线程使用监视器锁来正确同步:那么操作的执行整体上有序,且两个线程都只能看到这个执行顺序
  • 假设这两个线程没有做同步:那么操作的执行整体上无序,但是两个线程都只能看到这个执行顺序。

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但是所有线程都只能看到一个一致的整体执行顺序。例如线程A和B看到的执行顺序都是B1->A1->A2->B2->A3->B3,之所以得到这个保证是因为顺序一致性内存模型中的每个操作都必须立即对任意线程可见。
但是在JMM就没有这个保证,未同步程序在JMM中不但整体的执行顺序无序,而且所有线程看到的操作执行顺序也可能不一致。

同步程序的顺序一致性

  1. class SynchronizedExample{
  2. int a=0;
  3. boolean flag=false;
  4. public synchronized void writer(){ // 获取锁
  5. a=1;
  6. flag=true;
  7. } // 释放锁
  8. public synchronized void reader(){ // 获取锁
  9. if(flag){
  10. int i=a;
  11. ...
  12. }
  13. } // 释放锁
  14. }

顺序一致性模型中,所有操作完全按照程序的顺序串行执行,而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码逸出到临界区外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别的处理。虽然线程A在临界区做了重排序,但是由于监视器互斥执行的特性,这里的线程B根本无法观察到A在临界区内的重排序。这种重排序既提高了执行效率,有没有改变程序执行的结果
image.png

未同步程序的执行

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写的值,要么是默认值,JMM保证线程读操作读取到的值不会是无中生有。为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象。
未同步程序在两个模型中执行特性有如下几个差异:

  1. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证。
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证
  3. JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读写操作都具有原子性。

    第三个差异与处理器总线的工作机制密切相关。具体资料见Java并发编程的艺术P36。

三、volatile的内存语义

3.1 volatile 写-读建立的happens-before关系

从内存语义的角度说:volatile的写-读与锁的释放-获取有相同的内存效果。

3.2 volatile写-读的内存语义

  • volatile写的内存语义:当写一个volatile变量时,JMM会把该线程对对于的本地内存中的共享变量值刷新到主内存中。
  • volatile读的内存语义:当读一个volatile变量是,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
  • 线程A写一个volatile变量,实际上使线程A像接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
  • 线程B读一个volatile变量,实际上是线程B接受了之前某个线程发出的(在写这个volatile变量之前对共享变量所做的修改的)消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

    3.3 volatile内存语义的实现

    为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序。
是否能重排序 第二个操作
第一个操作 普通读写 volatile读 volatile写
普通读写

NO
volatile读 NO NO NO
volatile写
NO NO

从表中可以看出:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序,这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。且JMM采取保守策略。

  • 在每个volatile写操作的前面插入一个StoreStore屏障,确保之前的普通写在volatile写之前刷新到主内存中。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障,防止volatile写与后面可能有的volatile读/写操作重排序
  • 在每个volatile读操作的后面插入一个LoadLoad屏障,禁止处理器将volatile读和之后的普通读重排序。
  • 在每个volatile读操作的后面插入一个LoadStore屏障,禁止处理器吧volatile读和之后的普通写重排序。

在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。比如X86处理器仅会对写-读做重排序,因此只会保留StoreStore屏障。

四、锁的内存语义

4.1 锁的释放-获取的内存语义

  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
  • 当线程获取锁时,JMM会把线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所作修改的)消息。
  • 线程B获取一个锁,实质上是线程B接受了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息

    4.2 锁内存语义的实现

    借用ReentrantLock来分别分析公平锁和非公平锁的内存语义。ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer,AQS使用一个整型的volatile变量来维护同步状态。

    1. 公平锁

  • 使用公平锁时,加锁lock()调用轨迹如下:

    1. ReentrantLock:lock()
    2. FairSync:lock()
    3. AbstractQueuedSynchronizer:acquire(int arg)
    4. ReentrantLock:tryAcquire(int acquires)
      1. protected final boolean tryAcquire(int acquires){
      2. final Thread current=Thread.currentThread();
      3. int c=getState(); //获取锁的开始,首先读volatile变量state
      4. if(c==0){
      5. if(isFirst(current) && compareAndSetState(0,acquires)){
      6. setExclusiveOwnerThread(current);
      7. return true;
      8. }
      9. }else if(current == getExclusiveOwnerThread()){
      10. int nextc=c+acquires;
      11. if(nextc<0){
      12. throw new Error("Maximun lock count exceedee");
      13. }
      14. setState(nextc);
      15. teturn true;
      16. }
      17. return false;
      18. }
      从上述源码中可以看出加锁方法首先读volatile变量state。

  • 使用公平锁时,解锁方法unlock()调用轨迹如下:
    1. ReentrantLock:unlock()
    2. AbstractQueuedSynchronizer:release(int arg)
    3. Sync:tryRelease(int release)
      1. protected final boolean tryRelease(int releases){
      2. int c=getState()-releases;
      3. if(Thread.currentThread()!=getExclusiveOwnerThread())
      4. throw new IllegalMonitorStatrException();
      5. boolean free= false;
      6. if(c==0){
      7. free=true;
      8. setExclusiveOwnerThread(null);
      9. }
      10. setState(c); //释放锁的最后,写volatile变量state
      11. return free;
      12. }
      从上述源码可以看出,在释放锁的最后写volatile变量state。

总结:

  • 公平锁在释放锁的最后写volatile变量,在获取锁时首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见。

    2. 非公平锁

  • 非公平锁的释放和公平锁完全一样,所以之分析非公平锁的获取。使用非公平锁的加锁lock()方法调用轨迹如下:

    1. ReentrantLock:lock()
    2. NonfairSync:lock()
    3. AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)
      1. protected final boolean compareAndSetState(int expect,int update){
      2. return unsafe.compareAndSwapInt(this,stateOffset,except,update);
      3. }
      该方法以原子操作的方式更新state变量,此操作具有volatile读和写的内存语义。

      五、final域的内存语义

      5.1 final域的重排序规则

      对于final域,编译器和处理器都要遵守两个重排序规则:
  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

    1. 写final域的重排序规则

    写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含两个方面:

  3. JMM禁止编译器把final域的写重排序到构造函数之外

  4. 编译器会在final域写之后,构造函数之前,插入一个StoreStore屏障。

    2. 读final域的重排序规则

    读final域的重排序规则是:

  5. 在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(仅仅针对处理器)

  6. 编译器会在读final域操作的前面插入一个LoadLoad屏障

    5.2 final域为引用类型

    对于引用类型,写final域的重排序规则对编译器核处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

    六、happens-before

    6.1 happens-before的定义

    《JSR-133:Java Memory Model and Thread Specification》对happens-before关系的定义如下:

  7. 如果一个操作happens-bofore另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

  8. 两个操作之间存在happens-bofore关系,并不意味这Java平台的具体实现必须要按照happens-bofore关系执行的顺序来执行。如果重排序之后的执行结果与按照happens-bofore关系来执行的结果一直,那么这种重排序并不非法

    6.2 happens-bofore规则

  9. 程序顺序规则:一个线程中的每个操作,happens-bofore于该线程的任意后续操作。

  10. 监视器锁规则:对一个锁的解锁,happens-bofore于随后对这个锁的加锁。
  11. volatile变量规则:对一个volatile域的写,happens-bofore于任意后续对这个volatile域的读。
  12. 传递性
  13. start()规则:如果线程A执行操作ThreadB.statr(),那么A线程的ThreadB.start()操作happens-bofore于线程B中的任意操作。
  14. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-bofore于线程A从ThreadB.join()操作成功返回。