点击关注上方蓝字,阅读更多干货~
前言
对于volatile关键字,相信大家一定都不会陌生。它解决了java并发编程三大特性中的可见性和有序性问题。那么你有没有想过volatile关键字为什么能解决线程可见性问题?本文会从缓存架构,内存模型等方面来阐述volatile是如何解决线程可见性问题。
01
什么是可见性
02
无法终止的循环
package nzo;
public class VolatilevisibilityTest {
private static boolean initFlag = false;
public static void prepareData(){
System.out.println("prepareding data...");
initFlag = true;
System.out.println("prepared end...");
}
public static void main(String[] args) throws InterruptedException {
//线程一
new Thread(new Runnable() {
public void run() {
System.out.println("waiting data ....");
while (!initFlag){
}
System.out.println("===========success");
}
}).start();
Thread.sleep(2000);
//线程二
new Thread(new Runnable() {
public void run() {
prepareData();
}
}).start();
}
}
结果,主线程并没有打印“success”表示执行结束,代表着主线程还在死循环着,initFlag的值并没有被修改。
03
为什么无法终止?
package com.nzo;
public class VolatilevisibilityTest {
private static boolean initFlag = false;
public static void prepareData(){
System.out.println("prepareding data...");
initFlag = true;
System.out.println("prepared end...");
}
public static void main(String[] args) throws InterruptedException {
//线程一
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("waiting data ....");
while (!initFlag){
}
System.out.println("===========success");
}
}).start();
Thread.sleep(2000);
//线程二
new Thread(new Runnable() {
@Override
public void run() {
prepareData();
}
}).start();
//睡一秒,保证线程二已经完成了initFlag值的修改
Thread.sleep(1000);
System.out.println(initFlag);
}
}
不出所料,主线程中的initFlag的值也已经改变了。
04
volatile变量的可见性和JMM内存模型
配合着下面这张JMM内存模型的图,我们就能更好理解上面的代码所发生的事。线程一和线程二都拷贝了一个initFlag共享变量的值的副本,到自己的工作内存中去,两条线程操作的都是自己工作内存中的共享副本变量。修改了initFlag值的线程二没有将变化及时写回主内存,线程一不知道当前工作缓存中的initFlag已经是个脏数据,也感知不到线程二已经将initFlag的值改变,从而没有停止循环。
private static volatile boolean intFlag = false;
以下是运行的结果:
05
volatile如何解决可见性问题
首先我们来了解一下JMM中的数据原子操作:
unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
我们跟着下面这张图再来走一遍上面的代码(ps:内容中的CPU缓存,总线嗅探,缓存一致性等关键词我会在下面的章节进行介绍)
线程一CPU使用(use)工作内存中的initFlag的值进行死循环操作;
线程二read出initFlag的值并load进工作内存,CPU拿到工作内存中的值进行assign赋值操作,将initFlag的值从false改成true并写回工作内存;
线程二将工作内存中的initFlag的新值store回主内存,并write赋值给主内存中的initFlag变量;
lock指令它会锁定这块内存区域的缓存,并立即写回主内存,因为CPU是遵循缓存一致性协议的。这个写回内存的操作会引起其他CPU通过总线嗅探机制使得自己工作内存里的数据失效,CPU获取不到工作内存中的数据时,那么就只能重新回主内存中拿数据;
至此完美解决了多线程的可见性问题。
06
CPU缓存模型
那么什么是CPU的多级缓存,如果读者有windows电脑,那么打开任务管理器就能找到它的身影。
以x86架构intel的CPU为例。多核CPU,每一核心CPU的L1和L2的Cache为核心独占,L3的Cache为多核心共享,越靠近CPU计算单元的上级缓存,速度越快,延时越低。
说了这么多,其实Java多线程内存模型和CPU缓存模型很类似,它是基于CPU缓存模型建立起来的。
07
总线嗅探
08
缓存一致性协议
多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱呢?这里就引出了一个一致性的协议MESI。
MESI 是指4种状态的首字母。每个Cache line有4个状态,它们分别是:
当线程一读取到initFlag值的时候,数据被缓存在1号CPU核心自己的Cache 里面,此时其他CPU核心的Cache没有缓存该数据,于是标记Cache Line 状态为「Exclusive」;
当线程二读取到initFlag值的时候,此时会发送消息给其他CPU核心,由于 1号CPU核心已经缓存了该数据,所以会把数据返回给2号CPU核心。在这个时候,1号和2号核心缓存了相同的数据,Cache Line的状态就会变成「Shared」,并且其Cache中的数据与内存也是一致的;
当2 号CPU核心要修改initFLag的值的时候,发现当前Cache Line的的状态是「Shared」,则要向所有的其他CPU核心广播一个请求到总线,要求先把其他核心的Cache中对应的Cache Line标记为「Invalid」状态,然后2号CPU核心再更新Cache里面的数据,同时标记Cache Line为「Modified」状态,此时Cache中的数据就与内存不一致了。之后2号CPU就会将新的值从Cache中写回主内存;
当1号CPU核心通过总线将对应的Cache Line中的initFlag的值标记为「Invalid」状态后,就会主动从主内存中重新拉取initFlag的值,从而确保自己工作内存中的值是最新的。
09
volatile的适用场景
最后再给大家分享一些比较常用的volatile的使用场景。
volatile boolean shutdownFlag;
...
shutdown();
...
public void shutdown() {
shutdownFLag = true;
}
public void doWork() {
while (!shutdownFlag) {
// do stuff
}
}
2.双重检查锁(DCL)
为了保证线程的安全性,往往要以牺牲性能为代价。为了兼得二者,前人进行了多番尝试,也确实创造出诸多有效方案,双重检查锁就是其中的一种。
public class Singleton {
private static Singleton uniqueSingleton;
private Singleton() {
}
public Singleton getInstance() {
if (null == uniqueSingleton) {
synchronized (Singleton.class) {
if (null == uniqueSingleton) {
uniqueSingleton = new Singleton(); //error
}
}
}
return uniqueSingleton;
}
}
如果对多线程有一定了解的同学第一次看到上述这段代码一定会觉得很好。首先通过一次非同步的检查过滤掉绝大多数的线程,随后在同步块中对少数通过第一次检查的线程进行第二次检查。因为是同步操作,所以最终初始化操作只会进行一次。即满足了线程安全的要求,又达到了高并发的目的,还兼具延迟初始化的特点。
将对象指向刚分配的内存空间
分配内存空间
将对象指向刚分配的内存空间
初始化对象
而加上了volatile关键字后,重排序将被禁止。至此,双重检查锁就可以完美工作了。(关于volatile禁止指令重排的底层原理之后会继续跟大家分享)
如下显示的线程安全的计数器,使用synchronized确保增量操作是原子的,并使用volatile保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及volatile读操作,这通常要优于一个无竞争的锁获取的开销。
public class CheesyCounter {
//读操作,没有synchronized,提高性能
public int getValue() {
return value;
}
//写操作,必须synchronized。因为x++不是原子操作
public synchronized int increment() {
return value++;
}
总结
为什么会有上述如此复杂问题?为什么会有并发编程?为什么会产生可见性,有序性等并发安全问题?
归根结底,还是计算机硬件高速发展的原因。CPU架构的发展,多核CPU的产生以及CPU缓存的适配是导致并发问题产生的原因之一。以上就是volatile关键字到多核CPU的线程可见性的介绍,希望读完对你有所收获!
长按识别二维码
查看更多精彩
-----END-----