持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情
1 前言:CPU 的缓存一致性和乱序执行优化
我们知道 CPU 中有高速缓存(Cache),CPU 从高速缓存读取数据的速度远远大于从内存中读取数据。
因此,在 CPU 工作过程中,CPU 会将需要计算的数据保存到 Cache 中,减少从内存中读取数据的次数,从而提升运算速度。
这种做法虽然带来了运算速度上的提升,但却带来了新的问题——“缓存一致性”。在 CPU 工作过程中,缓存的数据可能已经失效了,或者缓存的数据更新后,内存中的数据没有即时得到更新,被另一个 CPU 读取了。
因此为了解决“缓存一致性”的问题,需要引入其他协议,诸如:MSI、MESI 等等。但这不是本文的重点,我们只需要知道,计算机中,为了提升计算速度,解决 CPU 与 IO 之间的速度,CPU 利用 Cache 缓存数据,同时为了解决“缓存一致性”,CPU 访问缓存需要遵循协议,确保数据一致。
此外,CPU 还有一个特性,为了使得 CPU 的运算单元得到充分利用,CPU 可能会对指令进行乱序执行优化,但同时也确保了结果与顺序执行是一致的。
例如:
a = 1
b = 3
a = a+2
乱序执行后,代码的顺序可能变为
a = 1
a = a+2
b = 3
在 JVM 中也存在类似的特性,如即时编译器会对虚拟机指令进行“重排序”。
2 什么是 Java 内存模型
Java 内存模型定义了程序中各种变量的访问规则:
- 如何将变量值存储到内存
- 如何从内存中取出变量值
- 这里的变量值包括实例字段、静态字段、数组对象元素等共享变量,不包括线程私有的方法参数、局部变量。
Java 有一个特性,就是一次编写,到处运行,因为 Java 代码是运行于虚拟机上的,而不是具体的机器上的。
也因此,JVM 虚拟机在不同的机器平台上,相同的字节码指令所对应的字节码指令也就不同,向 Java 程序员屏蔽了机器指令的细节。
在多线程情况下,如果没有 Java 内存模型的规定,一个程序在某个平台运行正常,在另一个平台上运行却频繁出错。
💡 即 Java 内存模型定义了程序中访问变量的规则,帮助我们屏蔽了各种硬件和操作系统访问内存的差异,实现了 Java 程序在各种平台下都能达到一致的内存访问效果,确保多线程的运行结果是可预期的。
3 主内存&工作内存
Java 内存模型引入了两个概念主内存和工作内存,是对底层硬件的抽象,屏蔽了底层硬件细节。
- 主内存:所有的变量都保存在主内存中
- 工作内存:每个线程都有自己的工作内存,保存主内存中的变量副本
- 线程只能在工作内存中操作变量,而不能在主内存中对变量直接修改读写
- 从主内存中获取变量并保存为副本
主内存可以大致理解为 Java 内存区域的共享区域:堆、方法区,但实际上它对应到物理内存上。
工作内存可以大致理解为线程私有的虚拟机栈、本地方法栈,但实际上它对应到处理器的缓存、寄存器上。
也正因为 Java 内存模型引入主内存和工作内存,因此 JVM 中也同样会出现类似前言所提到“缓存一致性”问题。
多个线程使用同一个共享数据,但由于工作内存中的修改没有及时同步到主内存中,其他线程因此获取到过时数据,导致出错,这就是“可见性”问题。
4 指令重排序
JVM 会对指令进行优化,改变指令的执行顺序,从而优化执行速度。
指令的执行顺序发生了改变,但是确保了最终的结果是与顺序执行的结果是一致的。
通过以下例子查看指令重排序带来的好处
// 程序代码如下
a = 3;
b = 2;
a = a+1;
// 假设对应的指令如下
load a;
set to 3;
store a;
load b;
set to 2;
store b;
load a;
set to 4;
store a;
// 指令重排序后,可能为
a = 3;
a = a+1;
b = 2;
// 假设对应的指令如下,可以发现指令的执行次数少了
load a;
set to 3;
set to 4;
store a;
load b;
set to 2;
store b;
在单线程情况下,这样子并没有什么不妥,但在多线程情况下,这会带来难以预料的错误。
关于 Java 中的指令重排序在多线程下可能引发的问题,可以用下面的代码演示。
代码的主要功能如下:
a = b = x = y = 0;
// 线程 1 执行
b = 1;
x = a;
// 线程 2 执行
a = 1;
y = b;
//在多线程、没有加锁的情况下,由于线程执行顺序不同可能发生的情况如下:
1. x = 0, y = 1
2. x = 1, y = 0
3. x = 1, y = 1
// 在发生了指令重排序的时候,可能出现如下情况:
4. x = 0, y = 0
//即 第 4 行代码被优化到第 3 行代码前面,第 8 行代码被优化到第 7 行代码前面
Java 代码演示上面的第 4 种情况,即指令重排序带来的问题
public class InstructionReorder {
private static int a = 0, b = 0, x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
while (true) {
a = b = x = y = 0;
// CountDownLatch 是并发工具包下的一个并发工具类,可以设置一个计数,当计数为零时,
// 令被 CountDownLatch 阻塞的线程同时运行
CountDownLatch cdl = new CountDownLatch(3);
Thread t1 = new Thread(() -> {
try {
cdl.countDown();
cdl.await();
b = 1;
x = a;
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(() -> {
try {
cdl.countDown();
cdl.await();
a = 1;
y = b;
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
t2.start();
cdl.countDown();
t1.join();
t2.join();
System.out.println(x + "," + y);
// 发生了指令重排序,退出循环
if (x == 0 && y == 0) {
break;
}
}
}
}
关于如何解决这部分代码所带来的重排序问题,具体可看 6.3 有序性。
5 volatile 关键字
volatile
是 Java 提供的最轻量级的同步机制,相比于 synchronized
和 Lock
来说更轻量,没有上下文切换带来的开销。
volatile
只能修饰变量,而不能修饰代码块。
volatile
具有如下特性:
-
保证修饰的变量对所有线程的可见性
- 读取一个被
vlolatile
修饰的变量时,会先使工作内存中的副本失效,从而强制从主内存中读取数据 - 写一个被
vlolatile
修饰的变量,会立即刷新到主内存中
- 读取一个被
-
不保证原子性
vlolatile
只能修饰变量,而 Java 中对于运算操作符并非原子性的,例如i++
,它由多个字节码指令完成
// 对一下代码使用 javap 进行反编译,查看字节码指令
public class Increment {
private static int i = 0;
public static void main(String[] args) {
i++;
}
}
// 反编译结果,i++ 由 0、3、4、5 这几个指令组成
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field i:I
3: iconst_1
4: iadd
5: putstatic #2 // Field i:I
8: return
Java 中的赋值操作是原子性的,因此如果使用 volatile
变量直接赋值,这个操作也是可以保证原子性的
除此之外的对 volatile
的操作,如果不满足以下条件,那么就要考虑通过 synchronized
或者 Lock
来保证原子性:
- 运算结果不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值,下面的情况需要考虑加锁保证原子性
volatile static int a = 10; // 线程 A 的操作: a += 3; // 依赖变量的当前值,无法保证线程安全,a 可能为 13,也可能为 4 // 线程 B 的操作: a = 1; // 赋值操作,原子性的,线程 B 的结果一定是 a = 1
- 变量不需要其他的状态变量共同参与不变的约束,例如下面的情况就需要考虑加锁保证原子性:
volatile static int a = 1; volatile static int b = 2; // 线程 A 的操作: while (a < b) { // do something } // 线程 B 的操作: a += 1; // 有这么一瞬间,使得 a == b,从而导致 线程 A 结束循环 b += 1;
- 禁止指令重排序,JVM 能够确保
volatile
变量修改后同步到主内存时,其前面的操作都已经完成,而不会被重排序到volatile
变量修改操作的后面。
6 三个特性
6.1 原子性
原子性,即某个操作是不可分割的,要么全部完成,要么都不完成。
Java 中对于基本数据类型的这些操作是保证具备原子性的:
- read:从主内存中读取变量值到工作内存中
- load:将读取到变量值存储到工作内存中
- assign:将一个值赋值给工作内存中变量
- use:当遇到一个字节码指令需要使用变量值时,将变量值传递给执行引擎
- store:将工作内存中的变量值传递到主内存中
- write:将变量值写入到工作内存中
但是 long、double
两种基本数据类型比较特殊,JVM 规范中并没有规定这两种数据类型的操作一定要是原子性的,在 32 位系统中,它们的操作是非原子性的,在 64 位系统中,它们的操作是原子性的,如果在多线程下,为了确保对这两种数据类型的操作具备原子性,可以使用 volatile
修饰变量。
具体可以参阅这篇文章:long 和 double 的原子性问题 - 简书 (jianshu.com)
以上是 Java 的基本数据类型原本就具备的原子性操作,此外,引用类型的赋值操作也是原子性的,以及并发工具包下的原子类诸如:AtomicInteger
、AtomicDouble
等的操作也是原子性的。
除此之外的操作,例如要保证一个代码块内的操作具备原子性,那么可以使用 synchronized
关键字,确保同一时间段只有一个线程在操作。
6.2 可见性
可见性即,当一个线程修改了一个变量,其他线程能够立即得知这个修改。
通过以下代码演示可见性问题,如果一个线程修改了变量,另一个线程没有得知这个修改,会带来什么问题:
public class Visibility {
private static boolean keepRunning = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (keepRunning) ;
System.out.println("keepRunning 是 false,线程停止运行");
});
t.start();
Thread.sleep(2000);
// 令子线程结束运行
keepRunning = false;
// 等待子线程运行结束
t.join();
}
}
运行以上代码,会发现修改了 keepRunning
的值后,子线程并不能正常结束,这是因为主线程的修改,子线程并不能立即得知。
解决办法,使用 volatile
修饰 keepRunning
,利用 volatile
保证可见性的特性:
private volatile static boolean keepRunning = true;
再次运行代码,可以看到程序正常结束。
synchronized
也可以保证可见性,这是 JVM 保证的:
- 对一个变量进行加锁操作之前,必须先从主内存中刷新获取变量值
- 对一个变量进行解锁操作之前,必须将工作内存的变量同步回主内存
final
关键字也能保证可见性,但前提是构造方法执行过程中,没有将 this
实例传递出去(对象逸出)。因为在构造执行一半的过程中,一些实例字段还未初始化完成,此时传递出去,那么其他线程可能得到错误的数据。被 final
修饰的变量一旦初始化完成,那么其他线程都能看到这个常量。
6.3 有序性
- 如果在本线程中观察,那么线程中的操作是有序的,串行执行的。
- 如果在一个线程中观察另一个线程,那么所有操作都是无序的,这是因为:
- 指令重排序,并发的结果无法预期
- 工作内存和主内存同步延迟,两条线程的数据并不一致
为了解决在多线程下,保证有序性,我们可以分别使用 volatile
和 synchronized
来保证。
关于 volatile
是如何保证有序性的,来自于它的一个特性:禁止指令重排序,对 volatile
变量之前的操作都已完成,而其之后的操作不会被重排序到其前面。上面演示指令重排序带来的问题,可以如此解决:
private volatile static int a = 0, b = 0;
private static int x = 0, y = 0;
之后再去运行代码,便不会出现 x = 0, y = 0
的情况。
synchronized 能够保证有序性是因为,同步代码块同一时间只有一个线程执行,因此里面的操作都是串行的。
7 先行发生原则
先行发生原则(Happens-Before),是判断是否存在数据争用、线程是否安全的重要原则,对于编写多线程十分有帮助,可以帮助我们更轻松地编写多线程应用,而不用陷入 Java 内存模型的各种细节中。
所谓先行发生原则,即操作 A 在操作 B 之前完成,操作 B 能够得知操作 A 所做的修改。
先行发生原则有如下 8 条规则:
- 程序次序规则:在一个线程内,按照控制流顺序(考虑上分支跳转、循环等,不仅仅是程序代码顺序),书写在前面的操作一定先行发生于后面的操作。
- 管程锁定规则:一个 unlock 解锁操作一定先行发生于后面对同一个锁的 lock 加锁操作
- volatile 变量规则:对一个 volatile 变量的写操作先行发生于对其的读操作
- 线程启动规则:Thread 实例的 start() 方法先行发生于此线程的每一个动作,即先行发生于 run()
- 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,例如 run() 中的所有操作一定先行发生于
Thread.join()
、Thread.isAlive()
- 线程中断规则:对线程 interrupted() 一定先行发生于被中断线程检测到中断通知(即捕获到中断异常,或者
Thread.isInterruped()
返回 true) - 对象终结规则:一个对象的构造方法执行结束一定先行发生于
finalize
方法的开始 - 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C
💡 如果两个操作不满足以上的规则,则它们的操作的顺序性是无法保障的,它们是无序的,我们无法预料它们会具体出现那种情况。
先行发生原则并不是强调时间上的顺序,例如:
// 有如下两个方法
methodA();
methodB();
// 线程 1 先调用 methodA()
// 线程 2 再调用 methodB()
// 线程 1 和线程 2 得到 CPU 的调度是无法确定的,因此虽然线程 1 先调用了 methodA(),但 methodA() 并不一定先比 methodB() 执行完毕
// 此外,借助上面的规则判断,这两个操作发生于两个线程,不符合程序次序规则;也没有加锁、解锁操作,因此也不符合管程锁定规则;其他规则也不符合,因此它上面的操作并不是安全的
8 总结
Java 内存模型有三个特性:
- 原子性
- 可见性
- 有序性
Java 内存模型定义了一套规则去操作内存中的变量,如何取、如何写,确保在多种平台下,Java 程序的多线程操作是可预期的。
Java 抽象出了主内存和工作内存:
- 主内存存放所有变量
- 每个线程都有自己的工作内存
- 线程只能在工作内存中修改变量值,再将变量值同步回主内存
- 不能直接在主内存中操作变量值
Java 具有指令重排序,可以优化程序运行,但是多线程开发可能带来问题。
volatile 关键字可以修饰变量,是一种轻量级的同步机制:
- 它确保了某个变量的可见性,其他线程能够观察到某个线程对它的修改
- 同时禁止了指令重排序,在操作
volatile
变量的之前的操作,都能被操作volatile
变量即之后的操作观察到。 volatile
能够确保long
、double
类型的操作是原子性的
synchronized 关键字可以保证原子性、可见性、有序性。
编写程序时,可以借助先行发生原则判断两个操作是否线程安全,帮助我们开发多线程应用。