30、JMM(上)
但凡讲到多线程,我们就不得不讲一下 Java 内存模型,Java 内存模型用来解决多线程的三大问题:可见性问题、有序性问题、原子性问题
Java 内存模型也是面试中的常考点,比如:Java 内存模型中的 "内存" 两个字如何理解?跟多线程有什么关系?
既然 CPU 支持 MESI 等缓存一致性协议,为什么还会有可见性问题?volatile 的作用是什么,等等
对于 Java 内存模型,我们分 3 节讲解,本节详细讲解多线程的三个问题是如何产生的,以及简单介绍 Java 内存模型是干什么的
下一节详细讲解 Java 内存模型如何解决多线程的三大问题,下下一节讲解为什么 CPU 支持 MESI 等缓存一致性协议,还会有可见性问题?
1、CPU 缓存导致可见性问题
尽管内存的访问速度很快,但在 CPU 眼里却非常慢速,毕竟内存读写速度跟 CPU 处理速度相比,有好几个数据量级的差距
CPU 执行指令的过程,会涉及大量的内存读写,内存读写太慢,会影响到 CPU 执行指令的效率,那么这个问题该如何解决呢?
我们知道,缓存是提高数据读写速度的常用手段,比如:基于磁盘的数据库的读写速度比较慢,我们可以前置一个基于内存的缓存(比如 Redis)
基于此解决方案,计算机一般在 CPU 和内存之间安置一个高速的 CPU 缓存,CPU、CPU 缓存、内存之间的关系如下图所示
1.1、局部性原理
有同学可能会说,即便引入了 CPU 缓存,但数据还是得从内存中读取,最终还是得写入内存,缓存横插在 CPU 和内存之间,岂不是多此一举?
实际上,缓存起效的条件是数据的访问满足局部原理,其中,局部性原理包含时间局部性原理和空间局部性原理
时间局部性原理
基于时间局部性原理
某个数据一般在一个时间段内会反复读写,CPU 将数据从内存加载到缓存之后
之后的数据读写都只需要在高速缓存中完成,最后在某种情况下同步更新到内存中,由此减少了内存读写的次数
空间局部性原理
基于空间局部性原理,内存中相邻的数据一般会紧挨着被访问,因此 CPU 从内存中读取数据时,会一次性读取一个缓存行(cache line)大小的数据
一般来讲,一个缓存行大小为 64 字节或 128 字节
1.2、可见性问题
在单线程或者单 CPU 多线程情况下,这样的内存访问模型(CPU 通过缓存来读写内存)不存在任何问题
但是在多 CPU 多线程情况下,它就有可能会导致多个 CPU 缓存之间的数据的一致性问题
如下图所示,CPU 1 和 CPU 2 读取了内存中的数据 a = 1 到各自的缓存中
CPU 1 将数据 a 的值更新为 2,更新之后的 a 值不会立刻写入内存,毕竟如果每次更新缓存都要同步更新内存,那么缓存就没有意义了
此时 CPU 1 的缓存和 CPU 2 的缓存中数据值就不一致了,从另一个角度来看,也就相当于 CPU 1 对共享数据的更新对 CPU 2 不可见,因此这个问题也叫做可见性问题
经常有人会拿下面的例子来解释可见性问题,他的理由是
假设线程 t1 和线程 t2 运行在不同的 CPU 上,running 作为共享数据,被加载到不同 CPU 的缓存中
线程 t2 在缓存中对 running 值进行修改,对于线程 t1 来说不可见,因此并不会使线程 t1 中的 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 循环不退出),但分析过程完全不对
实际上,线程 t1 的 while 循环一直运行不退出,跟可见性没有一点关系,在下下节中,我们讲解完 CPU 缓存一致性协议之后你就明白了,本节就先暂时卖个关子
2、指令重排导致有序性问题
刚刚我们讲了 CPU 缓存,它可以有效提高指令的执行效率,但是也带了多线程情况下的可见性问题
接下来我们再来讲另外一个提高指令执行效率的方法:指令重排序,不过它也会导致多线程情况下的代码执行问题,这个问题叫:有序性问题
2.1、有序性问题
我们先来通过一个经典的例子,看下什么是指令重排序,示例代码如下所示,你觉得下列代码的执行结果是什么呢?
public class Demo { private static boolean ready = false; private static int value = 1; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(new Runnable() { @Override public void run() { while (!ready) { } System.out.println(value); } }); Thread t2 = new Thread(new Runnable() { @Override public void run() { value = 2; ready = true; } }); t1.start(); t2.start(); t1.join(); t2.join(); } }
2.2、图示
因为指令有可能重排,所以上述代码的执行结果不确定,有可能是 1,也有可能是 2
因为 t2 线程执行的两行代码并没有依赖关系,如果在单线程环境下(也就是只有 t2 线程在运行),两行语句哪条先执行哪条后执行,对结果没有任何影响
对于线程 t2 的代码来说,CPU 就有可能对其重排序,先执行第二条语句(ready = true),再执行第一条语句(value = 2)
在 ready 变为 true 但 value 仍为 1 时,执行线程 t1 就会打印 value = 1,线程 t1 和 t2 的执行时序关系如下图所示
2.3、指令重排
实际上,在代码的编译执行过程中,会发生 3 种不同类型的重排序
- 编译优化导致的重排序
- CPU 指令并行执行导致的重排序
- 硬件内存模型导致的重排序
对于第一类重排序,我们在 JVM 模块讲解,对于第三类重排序,我们在下下一节中讲解,本节我们重点讲解一下第二类重排序
为了提高指令的执行效率,现在的处理器大都采用指令级并行技术(Instruction-Level Parallelism,简称 ILP),来并行的执行多条指令
CPU 从内存中逐一读取并解码指令,然后放入待执行队列,有依赖关系的指令,显然是无法并行执行的,只能顺序依次执行,没有依赖关系的指令,才能够并行执行
CPU 从执行队列中,选择没有依赖关系的几条指令来并行执行,这样就会导致指令执行顺序的重排列
对于单线程环境来说,指令重排序并不影响代码的最终结果,但是对于多线程来说,指令重排序就会导致上述示例讲到的有序性问题,也就是代码最终的运行结果,跟代码按照书写的顺序来执行得到的结果并不相同
3、线程竞争导致原子性问题
原子操作指的是不可分割的操作,原子操作执行过程中的中间状态不会被访问到
一些看似原子操作的非原子操作,在多线程环境下并发竞争执行(这个概念在后面会讲到)会存在问题,我们把它称为原子性问题
3.1、原子性问题
我们举例解释一下,示例代码如下所示,线程 t1 和线程 t2 共享变量 count,线程 t1 执行 10000 次 count++,线程 t2 执行 10000 次 count++,最终 count 的值是多少呢?
public class Demo { private static volatile int count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 10000; i++) { count++; } } }); Thread t2 = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 10000; i++) { count++; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); } }
3.2、图示
直觉告诉我们最终的 count 值应该为 20000,但事实并非如此,正确的答案是:最终的 count 值并不确定,每次运行结果都不一样,但绝大部分情况下都小于 20000
之所以有这样的运行结果,主要是因为 count++ 是非原子操作
经过编译之后,count++ 这条语句对应 3 条 CPU 指令 :首先是读取数据到寄存器,然后在寄存器上执行自增操作,最后是将寄存器中的数据写入内存
如果两个线程并行(多核)或并发(单核多线程)执行 count++,count++ 对应的 3 条 CPU 指令有可能会交叉执行,从而产生不可预期的结果
我们拿单核多线程来进一步举例分析
前面讲到,线程切换会保存和恢复上下文,比如寄存器的值,因此我们就可以等价理解为:每个线程独享一组寄存器
- 假设线程 t1 将 count 的值 0 读取到寄存器,接着就发生了线程切换
- 线程 t2 将 count 的值 0 也读取到寄存器,线程 t2 将寄存器中的 count 值自增一,然后写入内存,此时内存中的 count 值变为了 1
- 这时又发生了线程切换,CPU 切换为执行线程 t1,线程 t1 的寄存器中的 count 值仍然为 0
线程 t1 将寄存器中的 count 值自增一,然后写入内存,这时内存中的 count 值还是 1 - 两个线程分别对 count 执行了自增一的操作,预期 count 值变为 2 ,但最终结果却是 1
对于刚刚的分析,你可能会有一点疑问:CPU 不是有 CPU 缓存吗?为啥这里直接说将数据写入内存,而不是写入缓存呢?
实际上,因为大部分处理器都实现了缓存一致性协议,所以写入缓存就相当于写入内存,关于这一点,我们还是在下下节中讲解
当然,对于原子操作,即便是多线程执行,也不会出现问题,我们拿赋值语句(例如 count = 5)举例
因为 CPU 不需要将 count 值读取到寄存器再修改,直接更改内存中的 count 值即可,所以 count = 5 这个赋值语句是原子操作
多线程下并行执行赋值语句,每条赋值语句都不可分割,执行结束之后,才会执行下一条赋值语句,因此也就不存在像 count++ 那样的交叉执行的问题了
当然,并不是所有的赋值语句都是原子的,我们知道,Java 是跨平台的,不管运行在 32 位计算机上还是 64 位计算机上,其中的 long、double 类型都是 64 位的
对于 32 位计算机,对 long 或 double 类型数据赋值,需要两次内存写操作才能完成,这就相当于执行了两条 CPU 指令,因此不是原子操作
同理,读取 long 或 double 类型数据,也需要执行两次内存的访问,因此也不是原子操作
在多线程环境下,如果线程 t1 对 long 类型数据 a 进行赋值,在执行完高位 32 位赋值指令之后,切换为线程 t2 执行
此时线程 t2 读取到的数据 a 的值便既不是旧值,也不是新值,而是一个没有意义的中间值
4、Java 内存模型
- CPU 缓存导致了可见性问题(也叫缓存一致性问题,MESI 协议可以解决它)
- 指令重排导致了有序性问题
- 线程切换导致了原子性问题
这些问题只在多线程中存在,而多线程本身只是软件层面的技术
因此解决这些问题理应在软件层面解决,而非在硬件层面解决,由软件层面在必要的时候,禁止 CPU 缓存、禁止指令重排、禁止线程并行或并发运行
然而,CPU 缓存、指令重排、线程切换这些优化,本身都是为了提高代码的执行效率,为了保证代码在多线程情况下能正确运行,过度严格的禁用这些优化,会影响代码的执行效率
所以 Java 提供了一些解决方案,由程序员按需使用,保证多线程下代码的正确运行,Java 提供的解决方案被定义为 Java 内存模型,对应的规范为 JSR-133
之所以叫做 Java 内存模型,是因为要解决的问题,也就是多线程的三大问题,都跟内存数据读写有关
在 JSR-133 定义的 Java 内存模型中,Java 定义了一些关键字(比如 volatile、synchronized),或者对某些关键字进行了功能增强(比如 final)
以此来限制内存中多线程共享数据的读写方式,最终达到解决可见性、有序性、原子性问题
关于 Java 内存模型如何解决多线程的可见性、有序性、原子性问题,我们在下一节中详细讲解
5、课后思考题
可见性、有序性、原子性问题出现在哪些场景下?请在对应的场景下标记 √ 号
单核单线程 | 多核单线程 | 单核多线程 | 多核多线程 | |
---|---|---|---|---|
可见性 | × | × | × | √ |
有序性 | × | × | √ | √ |
原子性 | × | × | √ | √ |
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17476402.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步