Java并发系列 - 详解Volatile

Volatile是我们在并发编程中经常会碰到的关键字。关于volatile的文章,网上已经非常多了。本文开门见山,结合底层原理以及实际使用场景,分别从以下几个维度,深入剖析volatile关键字。

1)作用

2)实现原理

3)实际案例

作用

1) 可见性。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。被volatile修饰的变量具有可见性。比如:A线程执行,volatile boolean flag = false, 那么另外一个线程可以立刻读到修改后的flag的值。

2)禁用指令重排序。

重排序:CPU会对指令进行重排序优化,假如说有,x=1,y=2,z=x+y,在cpu内的指令可能是,y=2,x=1,z=x+y,cpu对指令进行了重排序,但是可以保证最终的执行结果。这个在单线程内叫做as-if-serial。(只要操作之间没有数据依赖性,如上例所示,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码看起来就像是完全串行地一行行从头执行到尾,这也就是as-if-serial语义)

实现原理

可见性实现原理

要搞清楚volatile的可见性实现原理,那么就要搞明白,可见性是如何产生的呢?可见性在硬件层面如何解决?Volatile采用哪种解决方案?

1) 可见性产生的原因?

在现代计算机中,CPU的运行速度与内存的运行速度有着天壤之别,为了均衡这种速度差异,充分提供计算机整体的性能,CPU引入了L1,L2,L3三级缓存,运行速度:L1>L2>L3。运行一个程序时,CPU会通过系统总线去计算机的内存中加载数据,为了充分提高系统性能,CPU会把一部分数据缓存到CPU的三个层级缓存内。如下图,是CPU缓存的结构图:

Java并发系列 - 详解Volatile_第1张图片

ps:这个图左边是一颗CPU,右边是一颗CPU,每个CPU有两个核心,蓝色的部分是内核,L1,L2缓存是每个核心独享的,L3是一整颗CPU共享的。

综合以上所述,这就是可见性产生的根本原因,即:硬件内CPU有多核心缓存,如何保证一个线程修改后的数据,在其他CPU内核的缓存内立刻见到呢?这就是可见性问题的由来。

2) 可见性问题CPU层面的解决方案?

1. Intel CPU的协议MESI。具体网上的博客说的很详细,https://blog.csdn.net/reliveIT/article/details/50450136,https://www.cnblogs.com/yanlong300/p/8986041.html

2. 采用Lock前缀指令。解释:在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存,因为会锁住总线,导致其他CPU核心不能去内存中获取数据,所以可以保证一致性。但是,在最近的处理器里,LOCK #信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。在目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

采取以上这两种方案的任意一种都可以,目前大多数CPU会采用MESI协议或者MESI协议的变种去解决。

      3.volatile可见性的解决方案?

直接说答案,volatile采用的是lock指令的方式,为何采用lock指令呢,主要还是与volatile的指令重排序有关系。这一部分在下面进行详细分析。

禁用指令重排序的实现原理

首先,要搞明白为什么会发生指令重排序?

CPU底层最根本的原因如下:

Java并发系列 - 详解Volatile_第2张图片

假如CPU执行两条没有关联的指令,例如:y=1,x=2,z=x+y, 在cpu内部可能会优化,优化后不影响最终的结果(必须保证最终的执行结果)。优化后,可能是:x=2,y=1,z=x+y。

volatile禁止指令重排序的原理:

通俗简单来说:volatile就是在两条指令加了内存屏障,不允许发生指令重排序。

以下是JVM规范要求虚拟机必须实现的四种内存屏障:

Java并发系列 - 详解Volatile_第3张图片

解释下:

1)LoadLoad屏障,比如说,读取X1和X2,X1和X2其中有一个是volatile声明的,那么读取的时候,必须是先读X1,再读X2,X1和X2中间有堵墙(内存屏障),不允许发生指令重排序。

2)StoreStore屏障,也是同理,对于X1和X2的写入不允许发生重排序。必须是X1写完后,X2再写。

3)loadStore屏障,必须是先读完,才能再写。

4)storeload屏障,必须是先写完,再读。

volatile禁用指令重排序,在JVM层面使用的内存屏障:

Java并发系列 - 详解Volatile_第4张图片

Volatile写:前面加了storestore内存屏障,就是前面的必须是写完,volatile再写,volatile写完了后面的变量才能再读。整个过程加了内存屏障,不能发生重排序。

Volatile读:先读volatile声明的变量,读完后,才能再读其他的,其他的变量读完,才能再写。整个过程加了内存屏障,不能发生重排序。

其实通过这两个内存屏障,也进一步说明了,一个线程对于volatile变量的写,另一个线程是立马可见的。

在CPU底层是如何实现内存屏障?

英特尔x86 cpu实现模式

Java并发系列 - 详解Volatile_第5张图片

X86架构,CPU的汇编指令,但是老的CPU不支持这些指令。

HotSpot内是如何实现内存屏障?

orderaccess_linux_x86.inline.hpp

inline void OrderAccess::fence() {
  if (os::is_MP()) {
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
}

这里hotspot没有去适配所有的CPU,而是采用一种比较偷懒的做法,使用lock; addl 指令来实现。也可以看到上面可见性的最底层原理,也是采用了lock指令。

Lock指令的语义:它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效。 另外还提供了有序的指令无法越过这个内存屏障的作用。

实际案例

这里的实际案例,就是说在单例的DCL场景下,对象有无必要加volatile,其实这个问题在之前的JMM文章中已经分析过了,这里就不做重复叙述了,贴一下文章链接:

https://blog.csdn.net/y510662669/article/details/104333854

总结

1) 可见性出现的原因:cpu各个核心的缓存数据与内存不一致导致。解决办法:cpu层面是mesi协议,java层面通过声明volatile关键字,volatile关键字的底层是lock指令实现,lock指令会保证当前cpu的缓存内容刷新到内存,并通知其他cpu核心内的缓存失效。

2)指令重排序,主要是cpu对汇编层面的指令执行的顺序做了调整,但是保证了最终的执行结果。使用volatile可以禁用指令重排序,让其命令按照原来的顺序执行。主要的底层原理是通过添加一个内存屏障,对应到hotspot底层也是采用lock指令实现。

你可能感兴趣的:(Java,多线程,java)