追溯volatile的前世今生

Posted Bad_Ape

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了追溯volatile的前世今生相关的知识,希望对你有一定的参考价值。

什么是volatile?

volatile可以使得在多处理器环境下保证了共享变量的可见性,那么到底什么是可见性呢?

大家有没有思考过这么一个问题,在单线程的环境中,如果向一个变量先写入一个值,然后在没有写干扰的情况下读取这个变量,那么读到变量的值应该是之前写入的值,这本来就应该是一个很正常的事,但是在多线程环境下,读和写出现在不同线程中时,可能会出现读线程不能及时读取到其他线程写入的最新值,这就是所谓的可见性

为了实现跨线程写入的内存可见性,就必须采用一些机制来实现,volatile就是这么一种机制

volatile是如何保证可见性的?

如果我们在变量上加了volatile,之后在多线程的执行过程中会发现多出来一个lock指令。lock是一种控制指令,在多线程环境下,lock指令可以基于总线锁或者缓存锁的机制来达到可见性的效果(如果想要具体查询这个指令的操作流程可以在评论区留言,我会补上这个操作过程)

既然涉及到了汇编指令,那么接下来就带大家从硬件层面了解可见性的本质

从硬件层面了解可见性的本质

首先一台计算机最核心的部分就是CPU、内存、I/O设备。那么在计算机的发展过程中会不断地升级和提升计算机的处理能力,但是一个功能不是只依赖于CPU或者其中的哪一种,而是共同协作的过程,但是还有一个非常矛盾的点,那就是三者在处理任务的速度上是有差异性的。CPU的处理速度是非常快的,内存次之,最后是I/O设备,但是为了提升计算性能,CPU从单核升级到了多核甚至用到了超线程去最大化CPU的利用率,但是如果其他的部分处理性能没有跟上的话,那么意味着整体的效率也不会有很大的提升,所以为了平衡三者的速度差异,最大化利用CPU提升性能,从硬件、操作系统等方面都做出了很多的优化点

  1. CPU增加了高速缓存
  2. 操作系统增加了进程、线程,通过CPU时间片切换最大化的利用CPU
  3. 编译器指令优化等

CPU高速缓存

线程是CPU调度的最小单元,线程的设计目的就是充分的利用CPU处理任务,但是大多数任务并不是只靠处理器计算就能完成,还需与内存或I/O设备交互,由于CPU和I/O设备或者内存计算速度差距非常大,所以都会增加一层高速缓存以用来提高处理速度

虽然通过增加高速缓存解决了CPU和内存直接的速度问题,但是也增加了整个系统的复杂度,也随之而来引入了一个新的问题,缓存一致性问题

什么是缓存一致性

首先有了高速缓存的存在后,每个CPU处理的过程都是,先将主内存中的数据读取到CPU高速缓存中,之后在CPU中进行相应的计算操作,之后回写主内存

但是由于有多个CPU核心所以同一份数据可能被缓存在多个CPU中,这样就会有可能存在在某一个时间点同一份内存的缓存值不一样,进而就会产生缓存不一致的问题

接来下为了解决缓存不一致的问题,在CPU层面想了很多办法,后来发现主要提供了两种解决方案

  1. 总线锁总线锁,其实就是当一个线程对共享变量操作时,增加一个lock信号,其他的线程就无法操作共享变量,但是这样锁定的开销比较大,这种机制其实是不太合适的
  2. 缓存锁缓存锁,我们只需要保证被同一个共享变量在不同的CPU中是一致的就可以,所以引出了缓存锁,它是基于缓存一致性协议实现的

缓存一致性协议 MESI

接下来给大家简单介绍下,如果有问题可以评论区留言,后面根据留言再看是否需要详细介绍下缓存一致性协议

MESI表示缓存行的四种状态,分别是

  1. M(Modify)表示共享数据只存在当前CPU中并且是被修改的状态,缓存中的数据和主内存的数据不一致
  2. E(Exclusive)表示缓存的独占状态,数据只缓存在当前CPU中,并且没有被修改
  3. S(Shared)表示数据可能被多个CPU缓存,并且缓存中的数据和主内存中的数据一致
  4. I(Invalid)表示缓存已失效

在MESI协议中,每个缓存的缓存控制器不仅知道自己的读写操作,也监听其他缓存行的读写操作

总结可见性本质

由于多个CPU高速缓存的出现,使得多个CPU同时缓存了相同的数据,这时就会出现可见性的问题。也就是CPU0修改了自己本地缓存的数据对CPU1不可见。这样当CPU1对共享变量做写入操作时是使用的脏数据,最终使得结果出现不定性的问题

​讲解到这大家肯定有疑惑,刚说完缓存一致性协议解决了缓存不一致的问题,那么为什么还需要加volatile关键字呢?为什么还会出现缓存一致性问题呢?

MESI优化带来的可见性问题

MESI协议虽然可以实现缓存一致性,但是也会存在一些问题

例子:CPU缓存行的状态是通过消息来进行传递的,如果CPU0要写入一个共享变量,首先需要发送一个失效的消息给其他缓存了该共享变量的缓存行,并且要等待他们的确认回执。在这个等待的过程中CPU0是处于阻塞的状态,为了避免阻塞带来的资源浪费的问题,在CPU中引入了store Bufferes

在CPU0需要写入共享变量时,只需要将数据写入到store Bufferes中,同时发送invalidate消息,然后继续处理其他指令,这样就能避免CPU0处于阻塞的过程

最后当收到了其他CPU的回执消息后,再将数据储存在cache line中,最后再同步到主内存中

但是这种优化带来了两个问题

  1. 数据什么时候提交是不确定的,因为要等待其他CPU发送回执消息之后才会提交
  2. 引入了 storebufferes 后,处理器会先尝试从 storebuffer 中读取值,如果 storebuffer 中有数据,则直接从storebuffer 中读取,否则就再从缓存行中读取

但是这种还会引来一个别的问题,指令重排序

因为当把数据缓存在store Bufferes中后,CPU去执行其他指令,这时就会出现指令重排序的问题,而这种指令重排序也会带来可见性的问题

这下硬件工程师也抓狂了,其实从硬件层面很难很难知道软件层面的这种前后依赖的关系,所以没有办法用某一种手段去完全解决问题

硬件工程师就说:哼~既然怎么优化都不行,那么你们自己来写吧!

​所以在CPU层面提供了Memory Barrier(内存屏障)的指令,这个指令就是强制刷新store bufferes中的指令。软件层面可以决定在适当的地方插入内存屏障

CPU层面的内存屏障

内存屏障就是将 store bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。

内存屏障还分为读屏障、写屏障、全屏障,但是这里不展开,这个不是重点

总的来说,内存屏障的作用就是防止CPU对内存的乱序访问来保证共享变量在并行执行下的可见性

但是这个屏障怎么来加呢?

回到最开始我们讲volatile关键字的代码,这个关键字会生成一个Lock的汇编指令,这个指令其实就相当于实现了一种内存屏障

这个时候问题又来了,内存屏障、重排序这些东西好像都是和平台以及硬件架构有关系的。作为Java语言的特性,一处编译多处运行的特性,这些不应该由程序员来关心。所以Java语言帮我们实现了这些工作

JMM

什么是JMM

全称 Java Memory Model,通过前面的分析,导致可见性问题的根本原因就是缓存和重排序,而JMM就是提供了合理的禁用缓存以及禁止重排序的方法来实现了可见性。

JMM 属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作 的行为规范:在虚拟机中把共享变量存储到内存以及从内 存中取出共享变量的底层实现细节

通过这些规则来规范对内存的读写操作从而保证指令的正 确性,它解决了 CPU 多级缓存、处理器优化、指令重排序 导致的内存访问问题,保证了并发场景下的可见性。

Java 内存模型底层实现可以简单地这么介绍:

通过内存屏障 (memory barrier)禁止重排序,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。 而对于处理器而言,内存屏障将会导致缓存的刷新操作。

总结

其实对于 volatile来说,编译器就是将在被volatile修饰字段的读写操作前后各插入一些内存屏障,这样使得在多线程访问中,写入共享变量时直接写入到主内存中,读取数据时也是直接读取主内存,这样就解决了可见性和重排序的问题

最后我想问大家晕车了吗?

今天给大家从硬件层面到软件层面深度地剖析了volatile的底层实现,也希望大家能从我的文章中了解到大家还存在遗漏的知识点,今后我也会继续给大家输出一些源码分析Java关键字底层分析大厂常用中间件原理分析的文章,下一篇应该是synchronized底层的讲解,如果大家特别希望作者提前讲解哪些知识点也可以评论区给我留言。

最后送给大家一句话,努力加油吧,多年以后,你一定会感谢曾经那么努力的自己!

​我是爱写代码的何同学,我们下期再见!

以上是关于追溯volatile的前世今生的主要内容,如果未能解决你的问题,请参考以下文章

区块链入门| 01区块链的“前世今生”

一1.Java 的前世今生

一1.Java 的前世今生

菜鸟眼中的java前世今生

JVM系列第1讲:Java 语言的前世今生

java学习总结——你的前世今生