1. JMM内存模型的抽象结构

java线程之间通信是由JMM控制,JMM决定一个线程对共享变量的写入核实对另外一个线程可见,从抽象角度来看,JMM定义了线程和主内存之间的抽象关系

  • 线程之间的共享变量存储在主内存中
  • 每个线程都有一个私有的本地内存
  • 本地内存中存储了该线程从主内存中同步的供以读/写得共享变量副本

本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化

下图就是java内存模型的抽象
image.png
从上图可以看出,A线程和B线程进行通信的话,需要以下几步

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

    1.1 指令重排序

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

  3. 编译器优化的重排序

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序

  1. 指令级并行的重排序

现代处理器采用了指令级并行技术(ILP)来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

  1. 内存系统的重排序

由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行
其中1属于编译器重排序 ,2、3属于处理器重排序
从Java源码到最终实际执行的指令序列,会分别经历这三种重排序
image.png
这些重排序可能会导致多线程程序出现内存可见性问题。
对于编译器,其重排序规则会禁止特定类型的编译器重排序
对于处理器,其重排序规则会要求java编译器的生成指令序列时,插入特定类型的内存屏障(Memory Barriers)称之为 Memory Fence指令,通过内存屏障指令来禁止特定类型的处理器重排序
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,提供了一致的内存可见性保证

1.2 并发编程模型的分类

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

屏障类型 指令示例 说明
LoadLoad Load1;LoadLoad;Load2 确保Load1数据的装载先于Load2及所有后续装载指令的装载
StoreStore Store1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
LoadStore Load1;LoadStore; Store2 确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存
StoreLoad Store1;StoreLoad;Load2 确保Store1数据对其他处理器变得可见(指刷新到内存)先于Load2及所有后续装载指令的装载。StoreLoad会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令

StoreLoad Barriers是个全能型的屏障,它同时具有其他3个屏障的效果,现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓存区中的数据全部刷新到内存中(buffer fully flush)

1.3 happens-before

JSR-133使用happens-before的概念来阐述操作之间的内存可见性,在JMM中,如果一个操作执行的结果需要对另外一个操作可见,那么这两个操作之间必须要存在happens-before关系,这里的两个操作可以是一个线程内的,也可以是不同线程的

与我们密切相关的happens-before规则如下

  1. 程序顺序规则

一个线程中的每个操作,happens-before于线程中的任意后续操作

  1. 监视器锁规则

对一个锁的解锁,happens-before于随后对这个锁加锁

  1. volatile变量规则

对一个volatile域的写,happens-before于任意后续对这个volatile域的读

  1. 传递性

如果A happens-before B,且 B happens-before C,那么 A happens-before C
注意:两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行,happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前
happens-before和JMM的关系

  • happens-before规则是JMM呈现给程序员的视图
  • 禁止某种类型的编译器或处理器重排序是JMM的实现
  • 处理器或编译器重排序规则是JMM定义的规则

简单说来,一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于程序员来说,happens-before规则简单易懂,它避免了程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方式

2. 重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段

2.1 数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个是写操作,此时这两个操作之间就存在数据依赖性
数据依赖氛围3种类型

  • 写后读
  • 写后写
  • 读后写

上面的情况只要进行重排序,程序的执行结果就会被改变
编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序
注意:遵守数据依赖性只会在单个处理器中执行指令序列和单个线程中执行操作,但是在多个处理器之间和不同线程中间的数据依赖性不被编译器和处理器考虑

2.2 as-if-serial语义

其语义的意思是:不管怎么重排序,单线程的程序执行结果不能被改变。编译器,runtime和处理器都必须遵守

  1. double pi = 3.14; // A
  2. double r = 1.0; // B
  3. double area = pi * r * r; // C

上面的java源码转化成指令序列会有两种情况

  • A -> B -> C
  • B -> A -> C

为什么是这两种情况

  1. 根据程序顺序规则,虽然A happens-before B,A应该在B前面执行,但是由于A和B无数据关联性,重排序A和B执行的结果一致,也就是说这种重排序满足as-if-serial语义,JMM会认为这种重排序不是非法的,所以编译器和处理器可以对A和B之间的执行顺序进行重排序
  2. 根据程序顺序规则,A happens-before C,B happens-before C,并且,A、B和C都有数据关联性,所以为了遵守as-if-serial语义,编译器和处理器不能对C的位置进行重排序

所以基于上面的编译器和处理器表现,as-if-serial语义把单线程程序保护了起来,保证执行结果是预期的,而不是不确定的
在不改变程序执行结果的前提下,尽可能提高并发度,编译器和处理器遵从这一目标,从happens-beore的定义就可以看出,JMM同样遵从这一目标

2.3 重排序对多线程的影响

引入一个例子说明下

  1. class ReorderExample {
  2. int a = 0;
  3. boolean flag = false;
  4. public void writer() {
  5. a = 1; // 1
  6. flag = true; // 2
  7. }
  8. Public void reader() {
  9. if (flag) { // 3
  10. int i = a * a; // 4 ……
  11. }
  12. }
  13. }

现在线程A首先执行了writer(),随后线程B执行reader(),这种情况下B在操作4的时候不一定能看到A操作1的共享变量的写入
由于操作1和2不存在数据依赖性,编译器和处理器可以对其进行重排序,同样的操作3和4也是同样的道理

  1. 如果1和2发生了重排序,3和4没有,那么就出现了,2执行完,B线程就执行了3,判断为true,就进行了i的结果运算,这个时候拿到的a的值实际上是0,随后,操作1才会进行写操作,这样多线程的语义就被重排序破坏了
  2. 如果3和4发生了重排序,1和2没有,这里情况发生了改变。操作3和4存在控制依赖关系,(当代码存在控制依赖关系时,会影响指令序列执行的并发度。为此编译器和处理器会采用猜测(Speculation)执行来克服控制依赖性对并发度的影响)以处理器猜测执行为例,执行线程B的处理器可以提前读取并计算i的值,并将计算结果临时保存到一个名为重排序缓存(Reorder Buffer, ROB)的硬件缓存中,当操作3的条件为true时,就可以把计算结果写入到变量i中了。猜测执行实质上对操作3和4进行了重排序,这里同样重排序破坏了多线程语义

在单线程中对存在控制依赖性的操作重排序,不会改变执行结果,这个是有as-if-serial语义所约束的,但是在多线程下,对存在控制依赖的操作重排序,可能就会改变程序的执行结果

3. 顺序一致性

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参考

3.1 数据竞争

当程序为正确同步是,就可能出现数据竞争

java内存模型规范对数据竞争定义如下

  • 在一个线程中写一个变量
  • 在另一个线程读同一个变量
  • 而且写和读没有通过同步来排序

当代码中包含数据竞争,程序的执行结果往往是违法直觉的,如果一个多想成程序能够正确同步,这个程序将不会存在数据竞争,JMM会保证正确同步的多线程程序结果正常。
JMM对正确同步的多线程的内存一致性做了如下保证

如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent),即程序的执行结果与该程序的顺序一致性内存模型中的执行结果一致

3.2 顺序一致性内存模型

顺序一致性内存模型是一个被理想化的理论参考模型,它提供了极强的内存可见性保证
顺序一致性内存模型有两大特性

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

简单来说,顺序一致性模型中,会将所有的内存读写操作串行化,不管是单线程还是多线程,即所有操作之间具有全序关系
现在假设有线程A和线程B并发执行
A中三个操作A1->A2->A3, B中三个操作B1->B2->B3

  1. 假设这两个线程使用监视器锁来正确同步,A执行完,释放锁,B加同一把锁,执行完,释放锁,那么程序在顺序一致性模型中的执行效果是一下情况

A1->A2->A3->B1->B2->B3
操作的执行整体有序,且两个线程都只能看到这个执行顺序

  1. 如果两个线程没做同步,那么程序在顺序一致性模型中的执行效果可能是

B1->A1->A2->A3->B2->B3
操作的执行整体无序,但是两个线程都只能看到这个执行顺序
上面两中情况之所以两个线程都只能看到这一致的整体执行顺序,是因为顺序一致性模型中的每个操作必须立即对任意线程可见
但是,在JMM中就没有这个保证了,未同步的程序在JMM中不但整体执行顺序无序,我热切所有线程看到的操作执行顺序也可能不一致,当前线程的写操作只会影响本地内存,在没有刷新到主内存之前,这个写操作仅对当前线程可见,其他线程不可见,只有当这个线程把本地内存中的数据刷新到主内存之后,这个写操作才对其他线程可见,这种情况下,当前线程和其他线程看到的执行顺序将会不一致

3.3 同步程序的顺序一致性效果

引入一段代码说明

  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. }

线程A执行writer(),线程B执行reader(),这是个正确同步的多线程程序,根据JMM规范,该程序的执行结果和该程序在顺序一致性模型内的执行结果一致。
JMM中,临界区内的代码是可以重排序的(临界区就是加锁区域,JMM规定了临界区域内部代码不会溢出到临界区之外,保证了监视器的语义), JMM会在退出临界区和进入临界区两个关键时间做一些特殊处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图,虽然A在临界区内进行了重排序,但是由于监视器锁互斥的原因,B看不到A临界区内的重排序,这种重排序即提高了执行效率,又没有改变程序的执行结果

3.4 未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性。
JMM在堆上分配对象时,首先对内存空间进行清零,然后才会在上面分配对象,因此在已清零的内存空间分配对象时,域的默认初始化已经完成
JMM不保证未同步的程序的执行结果与该程序的顺序一致性模型中的执行结果一致,因为如果想保证一致,JMM需要禁止大量的编译器和处理器优化,这会对程序的执行性能产生很大的影响。
未同步的程序在JMM中执行整体是无序的,执行结果无法预知。
未同步程序在JMM模型中和顺序一致性模型中执行特性的差异有以下几点

  1. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(无数据相关性的指令序列会进行重排序,进行性能优化)
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程看到一直的操作执行顺序(JMM的本地缓存会影响指令序列的执行顺序)
  3. JMM不保证对64位的long和double类型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读写操作都具有原子性

第三个的原因是,在32位机器上运算64位类型变量是比较困难的,所以就会拆成两个32位的数据进行计算,一旦拆开,就会导致操作不是原子性了,在JSR-133之前,一个64位变量可以被拆开成两个32位变量进行读写操作,但是在JSR-133之后,仅仅只能将一个64位数据拆成两个32位数据进行写操作,但是读取必须具有原子性

4. volatile的内存语义

4.1 volatile的特性

  • 可见性:

对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入

  • 原子性:

对任意单个volatile变量的读写具有原子性,但类似于volatile++这种符合操作不具有原子性

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

从JSR-133开始,volatile变量的写-读可以实现线程之间的通信
从内存语义角度来说,volatile的写-读和锁的释放-获取具有相同的内存效果,volatile写和锁的释放有相同的内存语义;volatile读与锁的获取有相同的内存语义
引入例子说明

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

假设A执行了writer()方法之后,线程B执行reader(),根据happens-before规则,这个过程建立的happens-before关系可以分为以下3类:

  • 根据程序次序规则,1 happens-before 2, 3 happens-before 4
  • 根据volatile规则,2 happens-before 3
  • 根据happens-before的传递性规则,1 happens-before 4

    4.3 volatile写-读的内存语义

    当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存中

结合上面的实例,A在写操作后,会把数据刷新到主内存,同时会失效掉其他线程缓存在本地的已修改的共享变量缓存,等其他线程需要读取本地缓存的已修改的户或已失效 的缓存1,会自动从主内存中加载最新的共享变量的值

4.4 volatile内存语义的实现

为了实现volatile的内存语义,JMM会限制编译器重排序和内存重排序
其中有三个规则:

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

为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的。为此,JMM采用保守策略。
下面是基于保守策略的JMM内存屏障插入策略

  • 在每个volatile写操作前面插入一个StoreStore屏障
  • 在每个volatile写操作后面插入一个StoreLoad屏障
  • 在每个volatile读操作后面插入一个LoadLoad屏障
  • 在每个volatile读操作后面插入一个LoadStore屏障

上面的策略非常保守,但是可以保证在任意处理器平台,任意程序中都能得到正确的volatile内存语义,在实际的执行中,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略掉不必要的屏障
image.png

4.5 JSR-133增强volatile内存语义的原因

在JSR-133之前的内存模型中,虽然不允许volatile变量直接进行重排序,但是允许volatile变量和普通变量之间进行重排序,这样就导致了程序执行的不确定性
为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133内存模型增强了volatile内存语义,严格限制编译器和处理器对volatile变量和普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序和处理器内存屏障插入策略来看,只要volatile变量和普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止
volatile仅仅能保证单个volatile变量读写具有原子性,而锁的互斥执行的特性可以确保对整个临界区的代码的执行具有原子性,所以在功能上,锁更强发,在可伸缩性和执行性能上,volatile更有优势

5. 锁的内存语义

锁可以让临界区互斥执行

5.1 锁的释放-获取建立的happens-before关系

锁是java并发编程中最重要的同步机制,锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息
引入示例解读

  1. class MonitorExample {
  2. int a = 0;
  3. public synchronized void writer() { // 1
  4. a++; // 2
  5. } // 3
  6. public synchronized void reader() { // 4
  7. int i = a; // 5
  8. ...
  9. } // 6
  10. }

假设线程A执行writer()方法,随后线程B执行reader()方法,根据happens-before规则,这个过程包含的happens-before关系有以下几种

  1. 根据程序次序规则
    1. 1 happens-before 2
    2. 2 happens-before 3
    3. 4 happens-before 5
    4. 5 happens-before 6
  2. 根据监视器锁规则
    1. 3 happens-before 4
  3. 根据happens-before规则的传递性
    1. 2 happens-before 5

在A线程释放锁之后,随后B线程获取了同一个锁,由于2 happens-before 5,所以A线程在释放锁之前所有可见的共享变量,在B获得同一个锁之后,将立刻变得对B线程可见

5.2 锁的释放和获取的内存语义

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量统一刷新到主内存中
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而是的被监视器保护的临界区代码必须从主内存中读取共享变量
对比锁释放-获取的内存语义与volatile写-读的内存语义可以看出:

  • 锁释放与volatile写有相同的内存语义
  • 锁获取与volatile读有相同的内存语义

总结下锁释放和锁获取的内存语义:

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了消息
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的消息

线程A释放锁,随后线程B获取这个锁,这个过程实质上线程A通过主内存向线程B发送消息

5.3 锁内存语义的实现

借用ReentrantLock来分析锁内存语义实现机制

ReentrantLock的实现依赖于同步器框架AQS,AQS使用一个整形的volatile变量(state)来维护同步状态
ReentrantLock分为公平锁和非公平锁

  1. 公平锁的加锁过程
  • ReentrantLock:lock()
  • FairSync:lock()
  • AbstractQueuedSynchronizer:acquire(int arg)
  • ReentrantLock:tryAcquire(int acquire)

最后一步开始真正加锁

  1. protected final boolean tryAcquire(int acquire) {
  2. final Thread current = Thread.currentThread();
  3. int c = getState();
  4. ...
  5. }

可以看到源码中一进方法就立马去获取了state变量的值

  1. 公平锁的解锁过程
  • ReentrantLock:unlock()
  • AbstractQueuedSynchronizer:release(int arg)
  • Sync:tryRelease(int releases)

最后一步开始释放锁

  1. protected final boolean tryRelease(int releases) {
  2. int c = getState() - release;
  3. ...
  4. setState(c);
  5. return free;
  6. }

在释放锁的最后会写state操作
根据volatile的happens-before规则,释放锁的线程在写volatile变量之前可见的共享变量,在获取锁的线程读取同一个volatile变量后将立即变得对获取锁的线程可见

  1. 非公平锁获取锁的过程
  • ReentrantLock:lock()
  • NonFairSync:lock()
  • AbstractQueuedSynchronizer:compareAndSetState(int expect, int update)

最后一步开始真正加锁

  1. protected final boolean compareAndSetState(int expect, int update) {
  2. return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
  3. }

该方法以原子操作的方式更新state变量,以上的compareAndSet方法简称为CAS

CAS:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值

CAS操作具有volatile读和写得内存语义
CAS如何同时具有volatile读和写得内存语义的

  • 编译器层面

编译器不会对volatile读与volatile读之后的任意内存操作重排序,也不会对
volatile写和volatile写之前的任意内存操作重排序。为了组合起这两个效果,编译
器不能对CAS与CAS前面和后面的任意内存操作重排序

  • 处理器层面

hotspot的atomic_windows_x86.inline.hpp的代码可以看出,程序会根据当前处
理器的类型来决定是否为cmpxchg指令添加lock前缀,如果是多处理器,就加上,
如果是单处理器,就省略

intel 对lock前缀的说明:

  1. 确保对内存的读-改-写操作原子执行,在Pentinum及Pentinum之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。但是这样会带来昂贵的开销,所以在之后的处理器中,使用了缓存锁定来保证指令执行的原子性,大大降低了执行所带来的开销
  2. 禁止该指令,与之前和之后的读和写指令重排序
  3. 把写入缓冲区中的所有数据刷新内存中

所以一旦指令添加了lock前缀,就足以保证当前指令同时具有volatile读和volatile写得内存语义
从ReentrantLock的分析看来,锁释放-获取的内存语义的实现至少有以下两种方式

  1. 利用volatile变量的写-读所具有的内存语义
  2. 利用CAS所附带的volatile读和volatile写的内存语义

    5.4 concurrent包的实现

    由于CAS同时具有volatile读和写的内存语义,因此java线程之间的通信现在有以下4中方式:

  3. A线程写volatile变量,随后B线程读取这个volatile变量

  4. A线程写volatile变量,随后B线程通过CAS更新这个volatile变量
  5. A线程通过CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量
  6. A线程通过CAS更新一个volatile变量,随后B线程读取这个volatile变量

Java的CAS会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器上实现同步的关键,同时volatile变量的读/写和CAS可以实现线程之间的通信,把这些特性整合起来,就是concurrent包实现的基石

  • 首先声明共享变量为volatile
  • 然后使用CAS的原子条件更新来实现线程之间的同步
  • 同时配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程间的通信

concurrent包的实现示意图
image.png

6. final域的内存语义

和锁和volatile相比,对final域的读和写更像是普通的变量访问

6.1 final域的重排序规则

针对final域,编译器和处理器要遵守两个重排序规则

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序(先写final再赋值,顺序不能变)
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序

    引入示例说明

  1. public class FinalExample {
  2. int i;
  3. final int j;
  4. static FinalExample obj;
  5. public FinalExample() {
  6. i = 1;
  7. j = 2;
  8. }
  9. public static void writer() {
  10. obj = new FinalExample();
  11. }
  12. public static void reader() {
  13. FinalExample object = obj;
  14. int a = object.i;
  15. int b = object.j;
  16. }
  17. }

假设A线程执行writer(), 随后B线程执行reader()

6.2 写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外

  1. JMM禁止编译器把final域的写重排序到构造函数之外
  2. 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障,这个屏障禁止处理器把final域的写重排序到构造函数之外

观察上面的示例,B在执行reader(),在读取普通变量i时,可能进行了重排序,所以再构造函数执行前,B方法错误的 读取了i赋值前的值,但是在读取j的时候,由于写final域的重排序规则限定在了构造函数之内,B读取到的j的值一定是赋值之后的值
写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确的初始化过了,而普通域不具有这个保障

6.3 读final域的重排序规则

读final域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作,编译器会在读final域操作的前面插入一个LoadLoad屏障
初次读对象引用和初次读该对象包含的final域,这两个操作之间存在间接依赖关系,由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作,但是有少部分处理器允许存在间接依赖关系的操作做重排序,那么这个规则就是针对这类处理器的
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包括这个final域的对象的引用,也就说说只要引用不为null,引用对象的final域一定已经被初始化了

6.4 final域为引用类型

引入示例说明

  1. public class FinalReferenceExample {
  2. final int[] intArray; // final是引用类型
  3. static FinalReferenceExample obj;
  4. public FinalReferenceExample() {// 构造函数
  5. intArray = new int[1]; // 1
  6. intArray[0] = 1; // 2
  7. }
  8. public static void writerOne() { // 写线程A执行
  9. obj = new FinalReferenceExample(); // 3
  10. }
  11. public static void writerTwo() { // 写线程B执行
  12. obj.intArray[0] = 2; // 4
  13. }
  14. public static void reader() { // 读线程C执行
  15. if (obj != null) { // 5
  16. int temp1 = obj.intArray[0]; // 6
  17. }
  18. }
  19. }

由于读、写final域的重排序规则, 1、2肯定不能和3进行重排序,保证了final域的正确初始化,所以c至少可以看到下标为0的值为1,而B和C相互操作,可能看到,也可能看不到。JMM不保证B和C之间数据可见,因为B和C之间存在数据竞争,此时执行结果不可预测
如果想确保B和C之间数据可见,需要在B和C之间使用同步原语(lock或volatile)来确保内存可见性