源于蚂蚁课堂的学习,点击这里查看(老余很给力)
一、前言
谈起volatile,想必大家最多的影响无非其三大特性:线程可见性、防止指令重排序以及不能保证原子性。当然,初学者对于这些特性多数只能做到死记硬背,应付面试。工作中对于其真正的原理及实现方式,可能甚至还达不到一知半解。而若要对volatile的原理娓娓道来,需要先引入它的老冤家JMM了。
二、JMM
JMM(Java Memory Model),即Java内存模型。其主要是按照CPU的工作方式,将内存空间模型化。如下图:
这里我们要明确几个角色:
1、主内存
用于存放全局共享变量。直白一点,就是在主线程中定义了一些变量,但其他子线程想去使用。那么主线程这些变量就存放在主内存中。注意误区:主内存和主线程无关。
2、工作内存
用于线程处理各自内部逻辑所占用的内存空间,也会拷贝主内存中全局共享变量的副本数据方便使用。
3、多级缓存
这是CPU的多级缓存机制。主要为了提升CPU运行效率。缓存是我们比较熟悉的名词。其机制也大致相仿。
理论知识有些抽象,代码也许会有助于你的理解。
/**
* @Description todo
* @CSDN https://blog.csdn.net/yxh13521338301
* @Author: yanxh<br>
* @Date 2020-08-12 09:42<br>
* @Version 1.0<br>
*/
public class Demo extends Thread {
private static String task = "";
public static void main(String[] args) {
new Thread(() -> {
while (!"主线程修改task".equals(task)) {
}
System.out.println(Thread.currentThread().getName() + "执行完毕");
}).start();
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
task = "主线程修改task";
System.out.println(Thread.currentThread().getName() + "执行完毕");
}
}
这是一个典型的案例,主线程修改了全局变量的值,妄想着停止子线程的运行。但事与愿违,子线程视若无睹,依旧沉醉自己的死循环中。
这并非是BUG,而是设计如此。毕竟线程之间要尽可能的做到隔离解耦。非要彼此可见的话,就需要引入volatile。
三、Volatile
Java的关键字,用于标识修饰的变量在线程之间可见。
通俗易懂地讲,volatile修饰的变量变化时,会主动将变更的信息同步至主内存,同时也会通知其他工作内存中,此变量值无效,其它工作内存需要重新去主内存拉取新的共享变量值。
至于同步机制,基于CPU的总线锁/缓存一致性协议了。
1、CPU的总线
解决多核CPU在多个工作内存高速缓存一致性问题,可捕获监听修改各工作内存中共享变量副本数据的状态。
2、CPU的总线锁
对于全局共享变量的修改,会在总线上加锁,确保只有一个工作内存的副本数据变更成功,但这本质上将多核CPU变成了串行,故效率低下,趋于淘汰。
3、缓存一致性协议(MESI)
将全局共享变量的副本数据约定状态。
M(Modified):修改,即当前工作内存的共享变量副本数据发生了变化。
E(Exclusive):独占,即只有当前工作内存的数据和主内存一致。(单核CPU)
S(Shared):共享,即所有工作内存的共享数据均与主内存一致。
I(Invalid):无效,此工作内存的共享数据无效,需重新去主内存获取共享数据。
大致流程:主内存中定义了volatile修饰的全局共享数据,工作内存进行拷贝,多个工作内存中副本数据的状态都是S(共享);当某个工作内存的此共享数据发生变动后,此工作内存的副本数据状态为M(修改);总线嗅探机制捕获到M状态的工作内存副本数据后,会将此副本数据的变更同步至主内存,同时通知其它工作内存将副本状态从S变为I(无效);其它工作内存会重新到主内存拷贝最新的副本数据,最终所有的副本数据再次变为S。
以上是对volatile的可见性进行了底层原理的分析。但也是基于这个理论,我们可以推论出volatile不能保证原子性。
假设主线程在修改共享数据时,子线程也修改共享数据,按照MESI的协议,我们可以得出总线嗅探会使得子线程的副本数据置为无效,子线程重新从主内存拉取,覆盖其自身的修改,所以这也是volatile使用上的一个注意事项。
4、CPU的伪共享
经常会遇到这样的面试问题:new的Java对象到底占用多少个字节?我们先了解一下Java对象的布局吧。
对象头:用于存放hashCode、GC分代信息、偏向锁信息等
实例数据:对象内部定义的成员变量或方法
填充数据:需要补充行的字节
你会有疑问,为什么有填充数据一说?这取决于CPU读取数据方式,即按行读取。因为一个字节慢慢读取的效率远逊色于按行读取,故高性能的处理方式都是以块的方式读取。
操作系统64位的CPU读取每次读64个字节。所以当我们创建的对象不足64字节时,CPU在共享数据时会读取此对象之后的其它对象,从而出现伪共享的问题。那么,Java非常机智地提供了对象的填充数据,我们可以通过注解或者手动的方式,补全对象,防止伪共享。
5、内存屏障
指令重排序是指,在代码指令互补影响的前提下,CPU可能会调整其指令顺序。
比如 int i=1; int j=2; 彼此重排序对程序无影响。
但有些隐式的情形则不然,指令重拍新的话,就会操作程序的错误数据。volatile可以很好地防止这一点。这要得益于它底层的实现,CPU的内存屏障。
即如果改为volatile int i=1; int j=2;底层实现会在i变量后插入一道屏障。
我个人理解,屏障,就是必须所有数据都到底这一状态,才会继续向下执行。也就是屏障上面的代码必须执行完才会执行下面代码。
好了,这对冤家的缠绵史就告一段落了,希望能够帮助大家!
欢迎大家和帝都的雁积极互动,头脑交流会比个人埋头苦学更有效!共勉!
公众号:帝都的雁