在上一篇中详细介绍了Java内存模型及happens-before原则,如果还没有阅读过该文章的读者建议先阅读上一篇文章,对本文的掌握会有很大的帮助: Java并发篇(2)解读JMM内存模型
在上一篇文章中我们已经知道线程是 通过主内存 去进行线程间的 隐式通信 的,而线程对共享变量的写操作在 工作内存 中完成,由JMM控制 共享变量由工作内存写回到主内存的时机 。
JMM提供了一个保证内存可见性的原则: happens-before原则 。这个原则可以保证线程对共享变量的写操作对其它线程可见。如果在多线程环境下需要满足happens-before原则,就必须对共享变量添加某种特定的读写规则,否则会导致多线程环境下对共享变量的操作无法对其它线程可见,造成 缓存不一致 的现象。
如下图所示,在JMM中定义了8种原子操作保证线程的操作具有内存可见性。
lock - read - load - use 表示一个线程需要 读取并使用共享变量 的过程。
assign - store - write - unlock 表示一个线程需要 写入共享变量 的过程。
这两个读-写操作都是内存可见的,如果破坏了这些顺序,那么就无法保证内存可见性。关于这8个原子操作,不展开细说,枯燥的概念会让人打瞌睡,我们可以这样理解:
我们不需要记住这8种原子操作的顺序,只要满足happens-before原则,就必定满足上图的情况。 换言之,我们可以通过happens-before原则判断内存访问操作是否是线程间可见。
/** * @author Zeng * @date 2020/4/24 9:57 */ public class Test { static class Child { static volatile int A = 0; public static void setA(int a) { A = a; } } public static void main(String[] args) { Child.setA(1); new Thread(() -> { try { // 保证先让主线程读到A的值后再写入 Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } Child.setA(3); System.out.println("child-thread: read variable A - " + Child.A); }).start(); System.out.println("main-thread: read variable A - " + Child.A); // 如果无法感知,主线程一直循环 while (Child.A == 1) { } System.out.println("main-thread: read variable A - " + Child.A); } } 复制代码
上面创建一个新线程对Child类的静态成员变量A写入值,观察主线程 能否接收 到子线程对变量A的写入操作。
结果是可以主线程接收到子线程对变量A的写入, 如果把volatile关键字去掉,那么主线程会陷入死循环。
通过上面的例子,我们可以看到volatile能够让不同线程之间操作共享变量能够实现 内存可见性 ,还记得这幅图吗?
volatile的特殊性从两个角度分析:
/** * @author Zeng * @date 2020/4/10 15:51 */ public class VolatileTest { public static volatile int race = 0; public static void increase() { race++; } private static final int THREADS_COUNT = 20; public static void main(String[] args) { while (true) { race = 0; Thread[] threads = new Thread[THREADS_COUNT]; for (int i = 0; i < THREADS_COUNT; i++){ threads[i] = new Thread(()->{ for (int j = 0; j < 10000; j++) { increase(); } }); threads[i].start(); } while (Thread.activeCount() > 2){ Thread.yield(); } System.out.println(race); } } } 复制代码
对race变量进行20万次的自增操作,理论上执行完以后race = 200000,是否真的是这样呢?
我们可以看到每一次的race都小于200000,即使已经用volatile修饰了,也不能保证线程安全的原子性这一特点。
问题出在自增运算 race++
中,还是这幅图,也许你看到这里会有疑问: 获取主内存的变量时已经lock了,怎么能被其它线程访问呢?
需要注意的是,类似于synchronized这样的关键字才会具有lock和unlock操作,而volatile是保证在读取和写入共享变量时都要在主内存中读取和写入, 简单来说,volatile并不会锁住一个volatile变量。volatile的读和写可以简化成下图
race++经历了三步操作:
所以在第一步就有可能出现错误了,两个线程同时读取r主内存的值race = 2 -> race = 2 + 1 -> 写回主内存race = 3。结果就是少增了一次1,当多个线程一起执行 race++
时,重叠的次数将会大大增加。
以 race++
为例,等价于 race = race + 1
,后一个状态依赖前一个状态的值,因此不能保证原子性。
第一条很好理解,如何理解第二个条件呢?来看看下面的代码:
/** * @author Zeng * @date 2020/4/24 9:57 */ public class Test { volatile static int start = 3; volatile static int end = 6; public static void main(String[] args) { new Thread(()->{ while (start < end){ System.out.println("start < end : " + start + " " + end); //do something } }).start(); new Thread(() -> { while (true) { start+=3; end+=3; } }).start(); } } 复制代码
有两个volatile变量 start
和 end
,不变性约束是 start < end
,思考一下如果执行代码,会发生什么现象?最好可以在IDE上跑一跑,这个现象很有趣呢~也许你会觉得一直打印 start < end : xxx xxx
。事实上它会在某个不确定的地方停下来。你可以多运行几遍看看效果。
那么为什么会出现这种情况呢?
线程B执行了如下操作
while (true) { start+=3; end+=3; } 复制代码
而线程A执行了如下操作
while (start < end){ System.out.println("start < end : " + start + " " + end); //do something } 复制代码
原因在于 start < end
这个地方,如果线程B执行了 start += 3
,还没来得及执行 end += 3
时,线程A判断 start < end
不成立而退出了循环, 所以volatile变量不能与其它状态共同参与不变性约束。
重排序会经历三个阶段:编译器、机器指令、内存系统,重排序是为了让性能提升。 重排序前后必须保证指令序列的执行结果与顺序执行的一致。
禁止重排序会有四种内存屏障:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1 ; LoadLoad ; Load2 | 确保Load1数据的装载先于Load2数据及后续所有Load指令的装载;即 volatile变量的读操作必须先于任何其它volatile变量的读操作 |
StoreStore Barriers | Store1 ; StoreStore ; Store2 | 确保Store1数据对其它线程可见(刷新回主内存)先于Store2及后续所有存储指令的主内存刷新;即 volatile变量的写操作必须先于任何其它volatile变量的写操作 |
Load Store Barriers | Load1 ; LoadStore ; Store2 | 确保Load1数据的装载先于Store2及后续所有存储指令的回主内存刷新;即 volatile变量的读操作必须先于任何其它变量的写操作 |
StoreLoad Barriers | Store1 ; StoreLoad ; Load2 | 确保Store1数据的存储刷新回主内存先发生于Load2及后续所有装载指令的装载;即 volatile变量的写操作必须先于任何其它volatile变量的读操作 |
内存屏障保证了位于内存屏障之前的所有操作先完成于内存屏障后面的所有操作。所以指令无法越过内存屏障,也就是无法重排序。
JMM针对编译器制定了一张volatile重排序规则表:
能否重排 | 第二个操作 | 第二个操作 | 第二个操作 |
---|---|---|---|
第一个操作 | 普通变量的读 / 写 | volatile变量的读 | volatile变量的写 |
普通变量的读 / 写 | NO | ||
volatile变量的读 | NO | NO | NO |
volatile变量的写 | NO | NO |
可以总结出三点:
第3点很好理解,怎么来理解第1点和第2点呢?
第1点:执行volatile读时,需要刷新工作内存中的所有缓存数据,如果重排序,那么后面的操作会被该volatile读操作刷新掉,相当于没有执行后面的操作。
第2点:执行volatile变量的写时,需要保证该线程工作内存中的值是最新的值,从而刷新回主内存中,如果把前面的操作放到该操作的后面,那么在执行volatile写的时候不能保证工作内存中的值已经是最新值,导致出现内存可见性问题。
再简单一些理解: volatile变量的读和写都必须保证工作内存中的值是最新值,与主内存的值保持一致。
使用 synchronized
关键字锁住相同的对象,被 synchronized
包裹的指令可以保证顺序执行,因为执行指令前必须先获得对应的锁。 但是在代码块内部没有禁止指令重排序!!
synchronized(this) { i = 1; j = 2; } 复制代码
上面两行代码可能会进行指令重排序。
synchronized
/** * @author Zeng * @date 2020/4/24 13:15 */ public class Singleton { private volatile static Singleton instance; public static Singleton getInstance() { if (instance == null) { // 第①步 synchronized (Singleton.class) { if (instance == null) { // 第②步 instance = new Singleton(); } } } return instance; } public static void main(String[] args) { Singleton.getInstance(); } } 复制代码
volatile变量的作用: 防止指令重排序 。
如果 instance
没有使用volatile关键字修饰,会不会出现问题?
new Singleton()
的过程简单分为下面三步:
如果 instance
没有volatile关键字修饰,编译器有可能对第二第三步进行重排序:
当线程A执行②进入代码块时,执行了重排序后的第二步,而此时线程B执行到①,发现 instance
不为空,直接返回给调用者,而此时 instance
还没有初始化完成, 调用者获得了一个未被初始化的对象,使用该对象会报空指针异常!
所以, synchronized
同步代码块内部不会禁止指令重排序,如果需要防止指令重排序,一定要想到 volatile
。
花了半天的时间写了这篇文章,过程很艰苦,有些点很难想明白,靠了很多肩膀,下面来总结一下volatile的知识点:
最后,如果你觉得这篇文章写的不错,你的一个点赞是对我最大的支持!感谢你的阅读!
巨人的肩膀:
blog.csdn.net/qq_39054532…
juejin.im/post/5e17a5…
《深入理解Java虚拟机》
《Java并发编程的艺术》