重新认识 volatile

本贴最后更新于 1807 天前,其中的信息可能已经东海扬尘

1、CPU 多核心缓存架构分析

并发编程:肯定是为了更合理充分的利用多核 CPU 架构的性能

CPU 主频率远远高于主存,所以引入 CPU 缓存,CPU 加载数据无法绕过缓存,而且对于多核 CPU,一级缓存不是共享的,二级和三级缓存是线程共享的!CPU 计算的数据并不是直接从主存中去拿,而且通过层层在缓存中寻找。

下图是一个单 CPU 多核心的架构图:

thread01.png

2、CPU 缓存一致性协议

先看这样一段代码:

thread04.png

加载过程就是 ThreadA 通过 read 指令读取到 flag,在通过 load 指令加载到缓存,也就是 JMM 中的本地内存,CPU 再通过寄存器去使用 flag,ThreadB 也是同样的流程,通过 assign 指令为本地内存中的 flag 赋值,再写入主存:

thread05.png

由于 while(true)并没有释放时间片,所以在这里可以让它去进行上下文切换,则就会有时间清除缓存

thread06.png

两次的执行结果肯定很简单

thread07.png

什么情况下的上下文切换不会清除缓存呢?可以设置一个非常小的休眠时间

thread08.png

这个时候,设置 500 纳秒的 Sleep 和 500000 纳秒的等待会导致结果完全不一样!!

这与 CPU 多核心架构有关系,CPU 修改之后的值并不会立即刷新到主存,这便导致了缓存不一致的问题,这是属于 CPU 架构的问题,不仅仅存在于 JVM 层面,只要是这种的 CPU 架构都会出现这种问题,所以首先得靠加锁来解决这种问题,在早期有一种东西叫做总线锁:
thread09.png

一旦发生数据修改的回写操作,直接把总线加锁,这样别的线程在使用数据的时候就不得不重新从主存得到新的数据,而且存在严重的性能问题,如果存在大量 IO 操作时,还使用这种总线锁,那么肯定 IO 性能肯定会下降!

于是出现了缓存一致性协议:

主要用到的缓存一致性协议时 MESI 协议(M 修改、E 独占、S 共享、I 无效四种状态,这四种状态记录的是缓存行的转态)。

这里还有一个机制叫做总线嗅探机制,嗅探通过总线的数据,如果只有一个核心用到,那么就会给这个缓存行一个状态叫做 E 状态(独占状态),当另一个线程也用到了这个变量的时候,CPU 就会通过广播机制通知其他的核心把这个缓存行的状态修改为 S 状态(共享状态),接下来其中一个线程对这个变量进行了修改,那么对于这个线程来说,这个变量的缓存行变成了 M 状态(修改状态),回写的时候会通过总线,总线嗅探机制嗅探到这个变量的缓存行已经是 M 状态,它就会通过其他的核心,说缓存无效,则其他核心上的缓存行就会变成 I 状态(无效状态),变成无效状态后如果还需要使用这个变量,那么肯定只能重新从主存中去加载这个变量到缓存,而且必须等待修改核心修改(回写主存)完成!

thread10.png

这里锁的缓存行最大值为 64 byte ,总线锁主要是用到了 lock 原语

3、内存模型 JMM 实现原理

JMM 描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM 是围绕原子性、有序性、可见性展开的

thread02.png

这个 JMM 模型只是一个抽象的概念,图上画出的也只是逻辑空间,通过对应到硬件内存架构是这样的:

thread03.png

4、Volatile 关键字原理剖析

在上面的例子中,我们明显可以通过 volatile 关键字来解决这个问题,那么 volatile 又是如何实现的呢?

通过汇编指令来看看就知道了,运行时加虚拟机运行参数:

-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp

但是 Mac 环境可能会由于缺少部分包:hsdis-amd64.dylib,在这里下载就好了 https://github.com/evolvedmicrobe/benchmarks/blob/master/hsdis-amd64.dylib,下载完毕后放在:/Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre 就好了

先看看如何保证可见性的:

底层实现了 lock addl $0x0,(%rsp) ,触发了缓存一致性协议

thread15.png

什么是重排序呢?是指编译器生成了指令序列,处理乱序执行!

接下来看看指令重排序的一个例子:

thread11.png

打印出来(0,0)(0,1)(1,0)(1,1)都有

这就好比单例模式,请看下面这个例子:

thread12.png

thread13.png

myInstance = new SingletonFactory(),这句话主要是三个指令构成的,如果不能保证指令有序性的话,那么拿到的对象就是未被初始化的对象,是无效的!

对于 x86 架构的 CPU,加上 volatile 写后面的 storeLoad 内存屏障,我们也可以手动加上内存屏障:
thread14.png

这个加上内存屏障的方法定义是 public native void storeFence(); 因此是原生实现,可以看看 OpenJDK 的虚拟机实现,可以看到对于 64 位机器使用的是 rsp 寄存器,对于非 64 位用的是 esp 寄存器

thread16.png

5、可见性、有序性、原子性详解

并发编程的三大特性:

可见性、有序性、原子性

volatile 保证可见性与有序性,但是不能保证原子性,要保证原子性需要借助 synchronized、Lock 锁机制,同理也能保证有序性与可见性,因为 synchronized 和 Lock 能够保证任一时刻只有个线程访问该代码块。关于 volatile 不保证原子性这个其实很好证明:

thread17.png

因为线程进行了很多次无效计算,所以结果并不是 100000,他们都是从主存中拿的值,而且值都是对的,但是计算过程却不是原子的,准确的说应该是资源并没有被锁定,导致自己修改的时候别人也在修改,所以正确理解 volatile 的作用是很重要的!

最后看看 CAS 操作对应的汇编指令:

thread18.png

  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3187 引用 • 8213 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...