前言
在高并发实际场景下,volatile 关键字应用较多,同时,这也是面试的必问的一个知识点了。那么,valatile 关键字有什么作用呢?
内存可见性
这需要从 Java 的内存模型(JMM)说起,JMM 规定所有的变量都需要存放在主内存中,同时,每个线程又有着自己的工作内存 (主要做高速缓存)。
这样,线程工作时需要操作变量时,需要将主内存中的数据拷贝到工作内存中。这样线程对数据的任何操作都是基于线程本身的工作内存(目的是为了提升效率),而不能去直接操作主内存以及其他线程的工作内存的数据,当线程对数据做了更新以后,还需要将更新后的数据刷新到主内存中。
以上图为例,在并发情况下,可能会出现线程 B 读取到的数据是线程 A 更新之前的数据,从而导致数据不一致。
这个时候,就需要 volatile 出场了:
当一个变量被 volatile 修饰的时候,任何线程对其做的写操作都会被立即刷新到主内存中,并且强制让那些缓存了该变量的线程内的该变量数据清空,需要从主内存中重新读取最新数据。
注意:volatile 修饰的变量,并不是让线程直接操作主内存获取数据,还是需要将变量拷贝到工作内存中。
volidate 应用 demo
我们模拟一个简单的应用场景,两个线程需要同时访问主内存中的某个标志位变量 flag, 我们用 volidate 来修饰:
主线程在对 flag 做了修改以后,flag 会立马被刷新到主内存中,从而及时停止线程 A, 如果说 flag 没有被 volidate 修饰,就可能会出现延迟。
volidate 一定能保证线程的安全性吗?
上面说到的 volidate 能够保证被修改的变量及时被刷新到主内存中,很多人会认为这样就能保证线程的安全性。
答案是否定的。volidate 并不能保证线程的安全性!
上面这段代码中,三个线程对 int i 进行累加,最终的结果都会小于 30000,而不是刚好 30000!
这是什么情况?不是说 volidate 保证了内存的可见性吗?
volidate 固然保证了内存的可见性,使得每个线程都能拿到最新的值,但是 count ++ 这个操作并不是原子的,看似简单的自增加 1 的操作,实际上包含了三个操作:
- 获取值;
- 自增;
- 赋值;
但这三个操作没有原子性,并不能同时完成。
那要如何解决对没有原子性的基本数据的并发安全性呢?
- 1.用单线程串行执行(不推荐,无法充分发挥多核 CPU 的优势);
- 2.通过 synchronize 对数据上锁保证原子性;
- 3.通过 Atomic 包中的 AtomicInteger 来替换 int, 它的底层利用了 CAS 算法来保证原子性
validate 之指令重排
validate 除了能够保证内存的可见性,它的另一重作用是防止 JVM 进行指令重排优化。
用代码说事:
上面是一段基础代码,理想情况下它的执行顺序是:1 ==> 2 ==> 3。但是在 JVM 对其进行指令重排优化后,它的执行顺序可能变为: 2 ==> 1 ==> 3.
JVM 的指令重排优化是在保证最终结果不变的情况下进行的。
上面的代码还看不出指令重排对实际业务带来的影响,看下面的代码:
如果上面的 flag 变量没被 volidate 修饰的话,JVM 指令重排优化后,导致 value 还没有被初始化,就有可能被线程 B 使用了。
这里加上 volidate 之后可以保证业务的正确性。
volidate 防指令重排应用
比较经典的应用场景就是双重懒加载的单例模式了:
上面对 Singleton 对象添加 volatile 关键字,就是为了防止指令重排。
如果说我们不使用的话,singleton = new Singleton();,这段代码其实是分为三步:
- 第一步:分配内存空间
- 第二步:初始化对象
- 第三步:将 singleton 对象指向分配的内存地址
加上 volidate 保证上面三步得以顺序执行,否则可能出现 第二步 在 第三步 之前执行的情况发生,就可能导致某个线程拿到的单例对象是还没有被初始化,导致程序报错。
总结
volatile 在 Java 并发应用场景有很多,比如像 Atomic 包中的 value、以及 AbstractQueuedLongSynchronizer 中的 state 都是被定义为 volatile 来用于保证内存可见性。
将这块理解透彻对我们编写并发程序时将获益匪浅。
同时也欢迎大家关注下面我的微信公众号哦 ~~~
Java技术说
閱讀更多 Java技術說 的文章