引言可见性问题基本数据类型的可见性问题引用数据类型可见性问题引用可见性问题成员变量可见性问题可见性问题总结Java内存模型CPU与内存之间的爱恨情仇Java内存模型主存与工作内存间的交互规则Volatile变量特殊规则先行发生原则对先行发生原则的理解volatile的使用保证变量可见性防止指令重排案例解决
工作一段时间的老铁们对这个问题应该都不陌生吧。回想起刚毕业那会儿,我信心满满的拿着简历去面试,面试官问我“请谈谈你对线程可见性及volatile关键字的理解?” 我暗自欣喜,这个问题我可是已经背过好几遍了,于是自信的答道:“可见性是指一个线程所做的修改可以被其他线程观察到,volatile可以保证可见性,还可以防止指令重排序”。面试官可能被我流畅的回答惊呆了,于是愣了几秒钟后说让我回去等通知。回家的路上我回想了一下,觉得这次面试肯定稳了。但是不知道为什么至今那位面试官还没给我打电话。。。。
public class Test {
public static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
// 启动一个线程,通过flag变量状态进行循环
new Thread(() -> {
while (Test.flag) {
// do something
}
}).start();
// 主线程休眠1秒后将flag变量设置为false
Thread.sleep(1000);
flag = false;
System.out.println("主线程运行完毕");
}
}
这段程序包含两个线程(一个是main方法所在的主线程、另一个暂时称之为子线程),我们大致能猜出来这段代码的意图是让子线程监测flag状态做点什么事情,然后再通过主线程将flag状态改变,从而停止子线程的工作。然而理想很现实。。。这段程序将会导致子线程进入死循环
public class Test {
public static Son son ;
public static void main(String[] args) throws InterruptedException {
// 启动一个线程,监测son变量
new Thread(() -> {
while(son==null){
// do something
}
}).start();
// 主线程休眠1秒后将对son变量进行初始化操作
Thread.sleep(1000);
Test.son = new Son("张三");
System.out.println("主线程运行完毕");
}
@AllArgsConstructor
static class Son{
public String name;
}
}
程序执行结果与基本数据类型的栗子一毛一样。。。子线程死循环
public class Test {
public static Son son = new Son("张三");
public static void main(String[] args) throws InterruptedException {
// 启动一个线程,监测son的name值
new Thread(() -> {
while("张三".equals(son.name)){
// do something
}
}).start();
// 主线程休眠1秒后改变son的name值
Thread.sleep(1000);
Test.son.name="李四";
// 主线程休眠2秒后改变son的引用
Thread.sleep(2000);
Test.son=new Son("李四");
System.out.println("主线程运行完毕");
}
@AllArgsConstructor
static class Son{
public String name;
}
}
这个栗子监测Son的name值,当主线程第一次只改变name值时,子线程无法观察到此变化。然后主线程休眠两秒后直接改变son的引用。最后终于。。。还是死循环了。
由此可见多线程环境下常常会出现一些我们意想不到的问题,我们一般会统称为线程安全性问题(这个说法其实并不严谨)。可见性问题是线程安全性问题的其中一种,出现可见性问题的主要原因是线程对共享变量的修改不能够及时的被其他线程观察到。
虽然我们发现了多线程环境下的,但是我们先别忙着找解决方案,而是先来分析一下为什么线程对共享变量的修改不能被其他线程观察到呢?这个问题就解释起来就比较复杂了。。所以我选择了抄书。 tip:如果对原理不敢兴趣的同学可以直接跳到末尾部分volatile的使用
以下部分是我摘抄自《深入理解Java虚拟机》这本书上的原话,读起来可能比较繁琐。这段话的主要目的是为了引入CPU高速缓存这个概念,如果你已经对CPU高速缓存比较了解了,那么你也可以选择跳过这段。 “ 多任务处理在现代计算机操作系统中几乎已是一项必备的功能了。在许多情况下,让计算机同事去做几件事情,不仅是因为计算机的运算能力强大了,还有一个很重要的原因是计算机的运算速度与它的存储和通信子系统速度的差距太大,大量的时间都花费在磁盘I/O、网络IO通信或数据库访问上。如果不希望处理器在大部分时间里都处于等待其他资源的状态,就必须使用一些手段去把处理器的运算能力“压榨”出来,否则就会造成很大的浪费,而让计算机同时处理几项任务是最容易想到、也被证明是非常有效的压榨手段。” “让计算机并发执行若干个运算任务”与“更充分地利用计算机处理器的效能”之间的因果关系,看起来顺理成章,实际上他们之间的关系并没有想象中的那么简单,其中一个重要的复杂性来源是对大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。 基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主内存(Main Memory),如下图所示。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议。这些协议被称为缓存一致性协议(例如MESI等、、);
————摘抄自《深入理解JAVA虚拟机》第二版
Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。定义Java内存模型并非一件容易的事情,这个模型必须定义得足够严谨,才能让java的并发内存访问操作不会产生歧义;但是,也必须定义得足够宽松,使得虚拟机的实现有足够的只有空间去利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好的执行速度。 Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。线程、主内存、工作内存三者的交互关系如下图所示
————摘抄自《深入理解JAVA虚拟机》第二版
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型定义了以下8种操作来完成。虚拟机实现时必须保证下面的每一种操作都是原子性操作
如果要把一个变量从主内存复制到工作内存,那就要顺序的执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。注意,java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
这8种内存访问操作及上述规则限定,再加上稍后介绍的对volatile的一些特殊规定,就已经完全确定了Java程序中哪些内存访问操作在并发下是安全的。由于这种定义相当严谨又十分烦琐,所以末尾会介绍这种定义的一个等效判断原则——先行发生原则,用来确定一个访问在并发环境下是否安全 ————摘抄自《深入理解JAVA虚拟机》第二版
基于之前的理论知识我们了解到,使用主存中中的变量时通常是需要顺序的执行read->load->use操作的。而将工作内存的值写回主存时则是要顺序的执行assign->store->write操作的。而volatile变量在基于此规则的基础上又扩展了几条规则
定义:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。 本质:前面一个操作的结果对后续操作是可见的
public class Test {
public static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
flag = false;
// 启动一个线程,通过flag变量状态进行循环
new Thread(() -> {
while (Test.flag) {
// do something
}
}).start();
System.out.println("主线程运行完毕");
}
// 根据先行发生原则中的程序次序规则得出main方法内部的赋值操作先行发生于子线程的start操作
// 又根据线程启动规则得出子线程的start操作先行发生于子线程内部的任何操作。
// 然后再根据先行发生规则的传递性得出:main方法的赋值操作先行发生于子线程内部的任何操作。
// 即main方法中的赋值操作可以被子线程观察到。
}
本来只想着抄一点的。。。没想到越抄多,觉得这有用、这也有用、这也不能忽略。。。。不过最后发现抄书其实也挺香的。有一些技术点也是在写这篇文章的时候才想明白。
volatile是一种非常轻量级的同步机制,读的性能与普通变量几乎没什么差别。写的操作因为要插入内存屏障防止指令重排所以可能略有一点点影响。当程序中出现线程安全性问题时要先判断是什么原因导致的线程安全问题。如果是可见性问题则应该优先考虑volatile,由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(sync或lock)来保证原子性。
场景:单例模式的实现——双重校验锁就是通过volatile关键字保证了客户端不会获取到未初始化完成的对象 这个我目前了解的还不是很透彻,就不卖弄了。。
文章开头提到的三个例子,都可以通过volatile解决。至于怎么解决就不用我说了吧(这点动手能力还是要有的)。。。。。 最后赶快交出你们的三连(点赞、收藏、转发)