32、JMM(下)
在上上节中,我们讲到,在多线程环境下,CPU 缓存会带来可见性问题
为了保证写入一个 CPU 缓存的数据,能够立刻写入内存,并同步更新其他 CPU 缓存,保持数据的一致性,Java 内存模型提供了 volatile 关键字来解决这个可见性问题
对于大部分业务工程师来说,对可见性的理解,到这个层面就可以的
但是对于经验丰富的同学,可能会有疑问:大部分 CPU 都支持缓存一致性协议的,比如 MESI,在硬件层面就可以解决各个 CPU 缓存数据的一致性问题,也就是刚刚讲的可见性问题,那么是不是就不需要 volatile 关键字了呢?
本节,我们就深入到 CPU 缓存内部,重新审视一下 Java 内存模型中的可见性问题
1、缓存一致性协议
缓存一致性协议是一个硬件层面的协议,目的是为了保持不同 CPU 缓存中的数据的一致性,缓存一致性协议(也可以说是算法)有很多,我们拿比较经典的 MESI 协议来举例讲解
不过,真正可应用的 MESI 协议非常复杂,这里我们只对 MESI 协议做一个简单的介绍,毕竟我们这里讲解 MESI 的目的,是为了让你对缓存一致性协议有个直观的认识,而并非让你去研究或掌握这个协议
前面我们讲到,CPU 以缓存行为单位来读写数据,因此 MESI 协议处理的对象也是缓存行,MESI 提供了 4 种不同的缓存行状态,它们分别如下所示
- M(Modified 修改的):表示当前缓存行中的数据已被修改,但并未同步到内存
- E(Exclusive 独家的):表示当前缓存中有这个数据,其他 CPU 缓存中没有这个数据
- S(Shared 共享的):表示当前缓存中有这个数据,其他 CPU 缓存中也有这个数据
- I(Invalid 无效的):表示当前缓存行中的数据已经失效,说明其他 CPU 对数据进行了修改,下次 CPU 读取数据要从内存中读取,并同步更新缓存
我们举个例子来解释一下这几个状态,假设一台计算机有 3 个 CPU,分别是 CPU 0、CPU 1、CPU 2,它们依次执行一系列读写操作,我们来看下状态都会如何变化
1.1、CPU 0 读 E 独家的
CPU 0 将内存中的数据 a = 0 读取到缓存,此时其他 CPU 没有缓存数据 a,那么在 CPU 0 的缓存中,数据 a 所对应的缓存行的状态标记为 E,如下图所示
你可能会说,CPU 0 读取内存中的数据 a 时,怎么知道其他 CPU 缓存有没有已经读取数据 a 呢?这就要靠总线广播了,更加形象的叫法是总线嗅探
CPU 0 在读取内存中的数据 a 之前,会往总线上广播一个读请求,其他 CPU 缓存获取到 CPU 0 的读请求之后,会检查自己是否已经缓存了数据 a,并告知 CPU 0
1.2、CPU 0 写 M 修改的
当 CPU 0 执行写操作 a = 1 时,CPU 0 更新缓存中的数据,并将状态更新为 M
因为 CPU 0 的缓存行原本处于 E 状态,所以 CPU 0 不需要更新内存中的数据,更不需要同步其他 CPU 缓存更新,如下图所示
1.3、CPU 1 读 S 共享的
当 CPU 1 执行从内存中读取 a 操作时,先广播一条读请求到其他 CPU 缓存
CPU 0 缓存接收到 CPU 1 的读请求之后,发现自己处于 M 状态,于是就会将自己缓存中的值 a = 1,写入到内存
并将缓存行标记为 S,之后才允许 CPU 1 读取内存中的数据,并将 CPU 1 缓存中的 a 对应的缓存行标记为 S,如下图所示
1.4、CPU 2 读 S 共享的
当 CPU 2 执行从内存中读取 a 操作时,也在总线上广播读请求,获知其他 CPU 缓存中也有缓存数据 a 之后,CPU 2 从内存中读取数据 a,并将对应的缓存行标记为 S,如下图所示
1.5、CPU 2 写 I 无效的
当 CPU 2 执行写操作 a = 2 时,因为 CPU 2 的缓存行状态为 S,所以它先会往总线上广播 invalidate(使无效) 消息
其他 CPU 接收到 invalidate 消息之后,会将对应的缓存行状态设置为 I(Invalid),并回复 invalidate ack 消息给 CPU 2
CPU 2 接收到 invalidate ack 消息之后,再将缓存数据改为 2,同步更新到内存中
CPU 2 的缓存行状态设置为 E,因为其他 CPU 中的数据都失效了,相当于 CPU 2 独享新的数据,如下图所示
1.6、STEP 6:CPU 0 读
当 CPU 0 执行读操作时,发现缓存中的数据对应的缓存行状态为 I,说明缓存中的数据失效了,于是 CPU 0 广播读请求
对于广播读请求,CPU 1 不作处理,CPU 2 将对应缓存行设置为 S,然后 CPU 0 从内存中读取数据,读取之后,将缓存行状态设置为 S,如下图所示
以上的状态转换只是举例,并不完备,实际上对于真正应用在计算机中的 MESI 协议,其状态定义并非只有 4 种这么简单,而是多达数十种,处理过程也异常复杂
这就好比分布式一致性协议,基本思想比较简单,但要实现一个可以应用在项目中的分布一致性协议,就会涉及很多细节需要处理
2、Store Buffer
从上述 MESI 协议状态转换举例来看,当多个 CPU 缓存中都有同一数据时,一个 CPU 对缓存数据进行修改,需要广播 invalidate 消息
其他 CPU 收到 invalidate 消息之后,将对应的缓存行设置为 I,然后再发送 invalidate ack 消息给这个 CPU,此时这个 CPU 才可以将数据更新写入缓存和内存
也就是说,为了保证缓存数据的一致性,写操作需要做很多工作,非常耗时,CPU 需要等待写入完成,才能执行其他指令,慢速的写操作,直接影响到 CPU 的执行效率
于是计算机科学家在 CPU 和 CPU 缓存之间,增加了一个类似消息中间件的存储结构,叫做 Store Buffer,用来异步执行写操作,如下图所示
CPU 将写操作的所有信息存储到 Store Buffer 之后,就立刻返回执行其他指令了
由 Store Buffer 来完成剩下的工作,包括:发送 invalidate 消息,接收 invalidate ack,写入缓存和内存
引入 Store Buffer 之后,在读取数据时,CPU 会先从 Store Buffer 中读取,如果读取不到再从缓存中读取,这样就可以保证 CPU 总是能读取到自己写入的最新值
3、Invalidate Queue
Store Buffer 发送给其他 CPU invalidate 消息之后,需要等待其他 CPU 设置缓存失效并返回 invalidate ack 消息,才能执行更新缓存和内存的操作
而其他 CPU 有可能忙于其他事情,导致来不及设置缓存失效和回复 invalidate ack 消息,这样写操作便会堆积在 Store Buffer 中很长时间
Store Buffer 的存储空间很小,当有大量写操作堆积在 Store Buffer 中等待执行,导致 Store Buffer 无法存储更多数据时
CPU 往 Store Buffer 中存储新的写操作,便会阻塞等待,此时 Store Buffer 便失去了作用
为了解决这个问题,计算机科学家又引入了一个新的存储结构:Invalidate Queue,专门用来存储 invlidate 消息和回复 invalidate ack 消息,并异步执行设置缓存行失效操作
这样就进一步节省了 Store Buffer 处理写操作的时间,能够让 Store Buffer 尽快清空
4、重审可见性问题
如果没有 Store Buffer 和 Invalidate Queue,那么缓存一致性协议是可以保证各个 CPU 缓存之间的数据一致性,也就不会存在可见性问题
但是当引入 Store Buffer 和 Invalidate Queue 来异步执行写操作之后,即便使用缓存一致性协议,但各个 CPU 缓存之间仍然会存在短暂的数据不一致的情况
也就是会存在短暂的可见性问题,即在短暂的时间窗口内,一个 CPU 对共享数据的修改,另一个 CPU 并不能感知
4.1、示例 1
我们举例讲解,如下所示,CPU 0 和 CPU 1 均读取了内存中的数据 a = 1 到各自的缓存中,对应的缓存行状态均标记为 S(共享)
CPU 0 执行写入操作 a = 2,为了提高写入的速度,CPU 0 将写入操作 a = 2 存储到 Store Buffer 中后就立刻返回
如果 CPU 0 需要再次读取数据 a,那么它会先从自己的 Store Buffer 中读取,因此 CPU 0 是可以读取到 a 的最新值 2
但是如果 CPU 1 需要再次读取数据 a,那么它也会先在自己的 Store Buffer 中查找,发现没有找到
然后再到缓存中查找,于是就读取到了 a 的旧值 1,此时就出现了两个 CPU 缓存数据的不一致
接下来,Store Buffer 会发送 invalidate 消息到 CPU 1 的 Invalidate Queue
在 Invalidate Queue 还没有将失效信息更新到 CPU 1 的缓存中前,CPU 1 仍然不能读到 a 的最新值 2
你可能会说,CPU 在读取数据时,会先读取一下 Store Buffer,那为什么不也先读一下 Invalidate Queue,看一下数据是否有失效呢?这样不就不会读到失效数据了吗?
Store Buffer 和 Invalidate Queue 与 CPU 的相对物理位置不同
Store Buffer 位于 CPU 和缓存之间,用来临时存储 CPU 写入缓存的数据,Invaliate Queue 位于总线和缓存之间,用来临时存储总线发送过来的失效信息
CPU 可以直接跟 Store Buffer 交互,但不会跟 Invalidate Queue 直接交互
因此 CPU 在从缓存中读取数据之前,会检查 Store Buffer 是否有最新数据,但不会检查 Invalidate Queue 是否有失效信息
再接下来,CPU 1 的 Invalidate Queue 根据失效信息,将对应缓存行状态设置为 I(失效)
当 CPU 1 读取数据 a 时,发现缓存行状态为 I,就往总线上广播读请求,CPU 0 接收到读请求之后,会将 Store Buffer 中的 a 的最新值 2,更新到缓存和内存
CPU 1 绕过缓存从内存中读取数据,从而就可以读取到最新值 2 了
总结一下,在 CPU 0 将写操作写入 Store Buffer 到 Invalidate Queue 将缓存行设置为 I 这之间的一段时间内
CPU 0 和 CPU 1 读取到的数据是不一致的,也就是 CPU 0 对数据的更新,对 CPU 1 不可见,不过这个过程并不会很长,因此可见性问题也只是短暂存在
4.2、示例 2
我们再来看下,上上节中讲到可见性问题时,给出的示例代码,如下所示
尽管线程 t2 对 running 的修改,不能被线程 t1 立即可见,但是这个不可见的时间窗口不会很长,并不会导致线程 t1 一直 while 循环不退出
那么到底是什么导致 while 循环一直不退出的呢?
public class Demo { private static boolean running = true; private static int count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(new Runnable() { @Override public void run() { while (running) { count++; } System.out.println("count: " + count); } }); Thread t2 = new Thread(new Runnable() { @Override public void run() { running = false; } }); t1.start(); Thread.sleep(1000); // 1 s t2.start(); t1.join(); t2.join(); } }
实际上,导致线程 t1 一直 while 循环不退出的原因是 JIT 编译的编译优化
对于热点代码,JIT 会将其编译为二进制机器指令,以提高执行效率,在进行 JIT 编译时,会同步进行一定的编译优化
JIT 编译器在编译线程 t1 中的 while 循环时,因为探测到 running 一直为 true,所以对其进行优化
省掉了每次判定 running 是否为 true 的逻辑,优化之后的代码大致如下所示,因此即便线程 t2 改变了 running 的值,线程 t1 也不可能再感知到了
@Override public void run () { while (true) { count++; } System.out.println("count: " + count); }
为了验证以上结论,我们有两种方法,一种是禁止 JIT 编译来运行代码,发现线程 t1 顺利结束,并成功打印 count 值
另一种是将代码中的线程 t1.start() 和线程 t2.start() 之间的 sleep() 语句去掉,发现线程 t1 也能顺序结束,并成功打印 count 值
这是因为去掉 sleep() 之后,线程 t1 中的 while 循环还未被判定为热点代码之前,也就是还未启动 JIT 编译之前,线程 t2 就已经将 running 设置为 false,因此线程 t1 便可以顺序执行结束
实际上,如果我们把 running 定义为 volatile,在不禁止 JIT 编译的情况下,线程 t1 仍然可以顺利执行结束,这是为什么呢?
前面讲到,volatile 的中文翻译是 "易变的",当将某个变量定义为 volatile 之后,编译器是不会对其进行过于激进的编译优化的
因此在 JIT 编译时,也就不会将 while 循环中的 running 省略掉
5、课后思考题
对于可见性问题的示例代码,除了使用 volatile、禁止 JIT 编译,还有哪些方法可以让线程 t1 顺利执行结束?
如果 while 循环内的逻辑执行时间较长,比如调用 sleep() 函数,那么 while 循环需要运行较长时间才能被判定为热点数据
在此之前,如果线程 t2 已经开始执行,就会将 running 顺利设置为 false,导致线程 t1 运行结束
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17476405.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步