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 的真相,还很遥远。

浏览 9
点赞
评论
收藏
分享

手机扫一扫分享

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

手机扫一扫分享

分享
举报