volatile 三部曲之可见性

共 3935字,需浏览 8分钟

 ·

2021-04-06 14:51


低并发编程
战略上藐视技术,战术上重视技术

先给自己挖个坑,三部曲如下:
volatile 三部曲之可见性
volatile 三部曲之有序性
volatile 三部曲之经典应用
今天讲可见性,废话不多说,开始。
友情提示:本文基于 Java 语言,CPU 基于 x86 架构。

有一个内存,在其 0x400 位置处,存储着数字 1
有一个处理器,从内存中读数据到寄存器时,会将读到的数据在缓存中存储一份。
现在,这个处理器读取到了三条机器指令,将内存中的数字改写为了 2
我们看到,这个写的过程被细化成了两步,需要先写到处理器缓存,再从缓存刷新到内存。
同样对于读来说,也需要先读缓存,如果读不到再去内存中获取,同时更新缓存。
这样,对于单个处理器来说,由于缓存的存在,读写效率都有所提升。
 

可见性

 
可是,如果有另一个处理器呢?
场景一:处理器 1 未及时将缓存中的值刷新到内存,导致处理器 2 读到了内存中的旧值。
场景二:处理器 1 及时刷新缓存到了内存,但处理器 2 读的是自己缓存中的旧值。
可以看到,这两种场景,都是处理器 1 认为,已经将共享变量改写为了 2,但处理器 2 读到的值仍然是 1。
换句话说,处理器 1 对这个共享变量的修改,对处理器 2 来说"不可见"。
现在我们加入线程的概念,假设线程 1 运行在处理器 1,线程 2 运行在处理器 2。
那么就可以说:
线程 1 对这个共享变量的修改,对线程 2 来说"不可见"。
这个问题,就被称为可见性问题。
 

LOCK

 
假如线程 1 对共享变量的修改,线程 2 立刻就能够看到。
那么就可以说,这个共享变量,具有可见性。
那如何做到这一点呢?
我们首先想想看,刚刚的两个场景,为什么不可见。

1. 线程 1 对共享变量的修改,如果刚刚将其值写入自己的缓存,却还没有刷新到内存,此时内存的值仍为旧值。

2. 即使线程 1 将其修改后的值,从缓存刷新到了内存,但线程 2 仍然从自己的缓存中读取,读到的也可能是旧值。

所以,问题就出在这两个地方。
那要解决这个问题也非常简单,只需要在线程 1 将共享变量进行写操作时,产生如下两个效果即可。
1. 线程 1 将新值写入缓存后,立刻刷新到内存中。
2. 这个写入内存的操作,使线程 2 的缓存无效。若想读取该共享变量,则需要重新从内存中获取。
这样,该共享变量,就具有了可见性。
那如何使得,一个线程在进行写操作时,有上述两个效果呢?
答案是 LOCK 指令。
假如,线程 1 执行了如下指令,将内存中某地址处的值+1。
add [某内存地址], 1
现在这个写操作,不会立即刷新到内存,也不会将其他处理器中的缓存失效,也即不具备可见性。
那只需要加上一个 LOCK 前缀。
lock add [某内存地址], 1
这样,这个操作就会使得:
1. 立即将该处理器缓存(具体说是缓存行)中的数据刷新到内存。
2. 使得其他处理器缓存(具体说是缓存了该内存地址的缓存行)失效。
第一步将缓存刷新到内存后,使得其他处理器缓存失效,也就是第二步的发生,是利用了 CPU 的缓存一致性协议
而为了实现缓存一致性协议,每个处理器通常的一个做法是,通过监听在总线上传播的数据来判断自己的缓存值是否过期,这种方式叫总线嗅探机制
总之,这两个效果一出,在程序员或者线程的眼中,就变成了可见性的保证。

 

JMM

 
现在,让我们来到 Java 语言的世界。
上面那些处理器、寄存器、缓存等,都是硬件层面的概念,如果把这些无聊的、难学的细节,暴露给程序员,估计 Java 就无法流行起来了吧。
Java 可不希望这种情况发生,于是发明了一个简单的、抽象的内存模型,来屏蔽这些硬件层面的细节。
这个内存模型就叫做 JMM,Java Memory Module。
一个线程写入一个共享变量时,需要先写入自己的本地内存,再刷新到主内存。默认情况下,JMM 并不会保证什么时候刷新到主内存。
同样,一个线程读一个共享变量时,需要先读取自己的本地内存,如果读不到再去主内存中读取,同时更新到自己的本地内存。
有同学就要问了,这个本地内存,是在内存中开辟的一块空间么?一个线程读一个内存中的数据,还需要从内存一个地方拷贝到另一个地方?
为啥上面有个×?因为怕有的人把这个图当成正解了...
注意,JMM 是语言级的内存模型,所以你千万不能把这个模型中的概念,同真实的硬件层的概念相关联,这也是很多同学对此感到迷惑的根源。
JMM 的出现,就是为了让程序员不要去想硬件上的细节,但这样的命名方式,反而使程序员理解起来更加困惑了。
如果非要对应硬件上的原理,那不准确地说,这里的本地内存实际上在并不真实存在,是由于处理器中的缓存机制而产生的抽象概念。这么说可能稍稍解决你的一点点困惑。
之所以说不准确,一是因为处理器有很多不同的架构,并不一定所有的架构都有缓存。二是因为除了缓存之外,还有其他硬件和编译器的优化,可以导致本地内存这个概念的存在。
所以从某种程度上说,JMM 还确实是大大简化和屏蔽了程序员对于硬件细节的了解。
 

volatile

 
根据 JMM 向程序员提供的抽象模型,我们可以推测出如下问题。

此时线程 2 并没有读到线程 1 写入的最新值,a=2,而是读到了主内存中的旧值,a=1。
也即,线程 1 对共享变量的写入,对线程 2 不可见。
那么在 Java 中,如何让一个共享变量具有上述的可见性呢?
答案是加一个 volatile 即可。
在 jls 里是这样描述 volatile 的。
The Java programming language allows threads to access shared variables. As a rule, to ensure that shared variables are consistently and reliably updated, a thread should ensure that it has exclusive use of such variables by obtaining a lock that, conventionally, enforces mutual exclusion for those shared variables.
The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes.
简单说,Java 语言为了确保共享变量得到一致和可靠的更新,可以通过锁,也可以通过更轻量的 volatile 关键字
比如在一个变量 a 前面加上了 volatile 关键字
volatile int a;
那么在写这个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新到主内存。
相应地,当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
以上两点,就是 volatile 的内存语义。
而这两点的实质上,是完成了一次线程间通信,即线程 1 向线程 2 发送了消息。
有的同学可能又要问了,内存语义,那真的是写的时候刷新到主内存,而读的时候让本地内存失效么?
这里我还是要强调,JMM 是语言级的内存模型,无论它硬件层面上是怎么去保证的,在你站在语言层面去学习 JMM 时,就不要去想硬件细节。
为了解决部分同学的困惑,我还是用不准确的语言来说一下,volatile 的底层会被转化成上面所说的 LOCK 指令,写这个共享变量时,就既做了刷新到主内存,同时也将其他处理器缓存失效的操作,并不是写的时候刷新缓存,读的时候再去将本地内存失效。
但在语言层去描述 volatile 的内存语义时,刚刚的说法完全没错,只要程序员按照 JMM 这个内存模型和 volatile 的内存语义去编程,能够方便理解,且能够达到预期的效果,即可。至于是不是准确表达了硬件层面的原理,这个是不重要的。
这让我想到了之前看过的一个演讲,我记得叫“眼见为实”,是说我们看到的,并不一定是这个宇宙的真实面貌,只是能让我们更好地生存并延续后代,而已。


后记





写这篇文章时真的是瑟瑟发抖,一是因为网上讲这个知识点的实在太多了,二是我发现 volatile 这个知识点水很深,从底层硬件一直到上层语言,每一层都有实现原理,层层抽象直到上层表现为我们看到的样子。

我甚至觉得不可能有人对这个知识点完全理解透彻。缓存一致性和总线嗅探,你需要了解 CPU 硬件的原理吧?JMM 内存模型,你需要了解 JVM 虚拟机实现吧?

或者不说实现的事儿,就单单是 JMM 说了什么,很多人觉得懂了,但你看过 JSR133 文档对 JMM 模型的正式规范么?很长,给大家随便截取一小段。

所以随着不断研究这个知识点,我发现我越来越不懂 volatile 了。
但我还是写下了这篇文章,并给自己挖了个坑。
这篇文章我尽全力把网上一些混乱的概念讲解,重新理清楚,且尽量把和可见性无关的东西去掉。
但我还是写的很不满意,也很郁闷。
因为我觉得,我离 volatile 的真相,还很遥远。

浏览 26
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报