深入理解volatile关键字
故事要从盘古开天辟地的时候说起,不好意思,走错片场了…
想要深入理解volatile就必须从Java虚拟机层面去理解,所以,在介绍volatile关键字之前就要从硬件谈起。
硬件的效率与一致性
由于计算机的读写速度与其运算速度差距十分巨大,所以,计算机上都会加一层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为内存与处理器之间的缓冲:意思就是将需要用到的数据放到缓存中,让运算快速进行。当运算结束后将数据从缓存同步会内存。
那么,问题来了,挖掘机技术哪家强?第二个问题:缓存一致性
在多处理器中,每个处理器都有自己的高速缓存,而他们共享同一主存,多个处理器运算涉及同一块主存,需要一种协议去保障数据一致性,这类协议有MSI、MESI、MOSI及Dragon Protocol等。Java虚拟机内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的。
为了使处理器内部运算单元充分利用,处理器会对输入代码进行乱起执行优化,处理器会在计算之后将对乱序执行的代码进行重组,确保其准确性。与去处理器乱序执行的优化,Java虚拟机对应的即时编译器中也有类似的指令重排优化。
Java内存模型
Java内存模型主要目的是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量包括实例字段,静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,后者是私有的,不会被共享。
Java内存模型规定了所有变量都存储在内存中,每条线程都有自己的工作内存(与处理器的高速缓存类似),线程的工作内存中保存了该线程使用到的变量是主内存副本拷贝,线程对所有变量的操作(读取赋值)都必须在工作内存中进行,而不能直接读写主内存的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量的传递都必须依靠主内存来完成,线程,主存和工作内存间的交互关系和硬件的类似。
内存之间的交互操作
关于主存与内存之间的交互操作,即一个变量如何从主存拷贝到工作内存,如何从工作内存同步到主存之间的细节,Java内存模型定义了以下八大操作来完成:
- lock(锁定)
- unlock(解锁)
- read(读取)
- load(载入)
- use(使用)
- assign(赋值)
- store(存储)
- write(写入)
Java内存模型围绕着原子性,可见性,有序性来建立的
好,经过前面的铺垫,终于可以要说到今天猪脚了
我们来举例说明
现在有一个静态变量
static int s= 0;
线程A执行以下代码: s = 3;
在JMM中发生的事情为:
1.主内存中加载s变量
2.通过工作内存读写到线程A中 得到 S = 0
3.执行线程A代码,将线程修改的变量同步到主内存中
如果这时候来了一个线程B:执行以下代码:System.out.println(“s=” + s);
结果不一定为3,还有可能为0
为0的情况是因为工作内存所更新的变量并不会立即同步到主内存,所以虽然线程A在工作内存当中已经把变量s的值更新成3,但是线程B从主内存得到的变量s的值仍然是0,从而输出 s=0
那么如何解决这个问题呢?
虽然我们可以使用synchronized来确保线程安全,但是对程序性能影响太大了,有一种轻量级的解决办法,那就是使用我们的的猪脚。
volatile关键字中最重要的特性就是保住了被其修饰的变量对所有线程可见性
这里的可见性是什么意思呢?当一个线程修改了变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会从主内存中拉取最新的变量值。
为什么volatile关键字可以有这样的特性?这得益于java语言的先行发生原则(happens-before)。
先行发生原则是两个事件的结果之间的关系,如果一个事件发生在另一个事件之前,结果必须反映,即使这些事件实际上是乱序执行的(通常是优化程序流程)。
对于一个volatile变量的写操作先行发生于后面对这个变量的读操作。
虽然volatile可以保证变量的可见性,但不能保证其原子性
何为指令重排?
指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。
指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果。
然而,指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,会影响到多线程的执行结果。
那么,指令重排怎么解决呢?可以通过内存屏障来解决
什么是内存屏障?
内存屏障(Memory Barrier)是一种CPU指令
内存屏障也称为内存栅栏或栅栏指令,是一种屏障指令,它使CPU或编译器对屏障指令之前和之后发出的内存操作执行一个排序约束。 这通常意味着在屏障之前发布的操作被保证在屏障之后发布的操作之前执行
内存屏障分为四种:
LoadLoad屏障:
抽象场景:Load1; LoadLoad; Load2
Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:
抽象场景:Store1; StoreStore; Store2
Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见
LoadStore屏障:
抽象场景:Load1; LoadStore; Store2
在Store2被写入前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障
抽象场景:Store1;StoreLoad;Load2
在Load2读取之前,保证Store1的写入对所有处理器可见,StoreLoad屏障的开销是四中屏障中最大的
volatile做了什么?
在一个变量被volatile修饰后,JVM会为我们做两件事:
1.在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。
2.在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。
所以其防止了指令重排
总结一下volatile关键字
volatile特性之一:
保证变量在线程之间的可见性。可见性的保证是基于CPU的内存屏障指令,被JSR-133抽象为happens-before原则。
volatile特性之二:
阻止编译时和运行时的指令重排。编译时JVM编译器遵循内存屏障的约束,运行时依靠CPU屏障指令来阻止重排。
文章参考https://www.cnblogs.com/nexiyi/p/java_memory_model_and_thread.html
文章参考https://blog.****.net/bjweimengshu/article/details/78860580