自我表扬:《Dubbo 实现原理与源码解析 —— 精品合集》
表扬自己:《D数据库实体设计合集》

摘要: 原创出处 http://cmsblogs.com/?p=2148 「小明哥」欢迎转载,保留摘要,谢谢!

作为「小明哥」的忠实读者,「老艿艿」略作修改,记录在理解过程中,参考的资料。


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

​前篇博客 《【死磕 Java 并发】—– 深入分析 volatile 的实现原理》 中已经阐述了 volatile 的特性了:

  1. volatile 可见性:对一个 volatile 的读,总可以看到对这个变量最终的写。
  2. volatile 原子性:volatile 对单个读 / 写具有原子性(32 位 Long、Double),但是复合操作除外,例如 i++
  3. JVM 底层采用“内存屏障”来实现 volatile 语义。

下面 LZ 就通过 happens-before 原则volatile内存语义,两个方向分析 volatile

1. volatile 与 happens-before

在这篇博客 《【死磕 Java 并发】—– Java 内存模型之 happens-before》 中,LZ 阐述了 happens-before 是用来判断是否存在数据竞争、线程是否安全的主要依据,它保证了多线程环境下的可见性。下面我们就那个经典的例子,来分析 volatile 变量的读写,如何建立的 happens-before 关系。

public class VolatileTest {

int i = 0;
volatile boolean flag = false;

// Thread A
public void write(){
i = 2; // 1
flag = true; // 2
}

// Thread B
public void read(){
if(flag) { // 3
System.out.println("---i = " + i); // 4
}
}
}

依据 happens-before 原则,就上面程序得到如下关系:

  • 程序顺序原则:操作 1 happens-before 操作 2 ,操作 3 happens-before 操作 4 。
  • volatile 原则:操作 2 happens-before 操作 3 。
  • 传递性原则:操作 1 happens-before 操作 4 。

操作 1、操作 4 存在 happens-before 关系,那么操作 1 一定是对 操作 4 是可见的。可能有同学就会问,操作 1、操作 2 可能会发生重排序啊,会吗?如果看过 LZ 的博客就会明白,volatile 除了保证可见性外,还有就是禁止重排序。所以 A 线程在写 volatile 变量之前所有可见的共享变量,在线程 B 读同一个 volatile 变量后,将立即变得对线程 B 可见。

2. volataile 的内存语义及其实现

在 JMM 中,线程之间的通信采用共享内存来实现的。volatile 的内存语义是:

  • 一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值,立即刷新到主内存中。
  • 一个 volatile 变量时,JMM 会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

所以 volatile 的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。

那么 volatile 的内存语义是如何实现的呢?对于一般的变量则会被重排序,而对于 volatile 的变量则不能。这样会影响其内存语义,所以为了实现 volatile 的内存语义,JMM 会限制重排序。其重排序规则如下:

翻译如下:

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

volatile 的底层实现,是通过插入内存屏障。但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM 采用了保守策略

策略如下:

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

原因如下:

  • StoreStore 屏障:保证在 volatile 写之前,其前面的所有普通写操作,都已经刷新到主内存中。
  • StoreLoad 屏障:避免 volatile 写,与后面可能有的 volatile 读 / 写操作重排序
  • LoadLoad 屏障:禁止处理器把上面的 volatile读,与下面的普通读重排序
  • LoadStore 屏障:禁止处理器把上面的 volatile读,与下面的普通写重排序

2.1 案例 1:VolatileTest

下面我们就上面 VolatileTest 例子重新分析下:

public class VolatileTest {

int i = 0;
volatile boolean flag = false;

public void write() {
i = 2;
flag = true;
}

public void read() {
if (flag){
System.out.println("---i = " + i);
}
}

}

内存屏障图例

2.2 案例 2:VolatileBarrierExample

volatile 的内存屏障插入策略非常保守,其实在实际中,只要不改变 volatile 写-读的内存语义,编译器可以根据具体情况优化省略不必要的屏障。如下例子,摘自方腾飞 《Java并发编程的艺术》:

public class VolatileBarrierExample {
int a = 0;
volatile int v1 = 1;
volatile int v2 = 2;

void readAndWrite(){
int i = v1; //volatile读
int j = v2; //volatile读
a = i + j; //普通读
v1 = i + 1; //volatile写
v2 = j * 2; //volatile写
}
}

没有优化的示例图如下:

未优化)

我们来分析,上图有哪些内存屏障指令是多余的

  • 1:这个肯定要保留了
  • 2:禁止下面所有的普通写与上面的 volatile 读重排序,但是由于存在第二个 volatile读,那个普通的读根本无法越过第二个 volatile 读。所以可以省略
  • 3:下面已经不存在普通读了,可以省略
  • 4:保留
  • 5:保留
  • 6:下面跟着一个 volatile 写,所以可以省略
  • 7:保留
  • 8:保留

所以 2、3、6 可以省略,其示意图如下:

已优化

参考资料

  1. 方腾飞:《Java并发编程的艺术》的 「3. Java 内存模型」 章节。

666. 彩蛋

整理本小节,简单脑图如下:脑图

如果你对 Java 并发感兴趣,欢迎加入我的知识星球一起交流。

知识星球

文章目录
  1. 1. 1. volatile 与 happens-before
  2. 2. 2. volataile 的内存语义及其实现
    1. 2.1. 2.1 案例 1:VolatileTest
    2. 2.2. 2.2 案例 2:VolatileBarrierExample
  3. 3. 参考资料
  4. 4. 666. 彩蛋