0%

Java并发编程-Java内存模型(JMM)

Java并发编程-Java内存模型(JMM)

前言

在上一章 Java并发编程-Android的UI框架为什么是单线程的? 中笔者介绍了并发编程线程安全「三大恶」:「可见性」、「原子性」以及「有序性」

广义上来说,并发编程问题笔者归纳为:是由于后续操作看不到前面操作的结果而引发的

首先「大恶-可见性」顾名思义,即线程B能否看见线程A对共享变量的操作结果

其次「二恶-原子性」CPU 指令/操作被中断/分割,广义上看笔者认为也是可见性问题,比如线程 A 修改共享变量 x += 1 时被中断,去执行线程 B x = 40 ,线程 A 恢复执行时可能看不到 x 已经被修改为 40

最后「三恶-有序性」指的是程序按照代码的先后顺序执行,即先执行 x = 40 ,后续读取 x == 40 一定为真,如果有各种优化导致指令重排序,后续读取 x == 40 时可能不为真

总而言之,并发编程的问题大部分都是「可见性」问题

Java内存模型(JMM)

在上一章中笔者介绍了导致「可见性」的原因是缓存,导致「有序性」的原因是各种优化(编译时、处理器以及运行时),首先笔者想到的是禁用缓存和禁止各种优化操作,在 Java 中如何禁用缓存和禁止各种优化呢?

Happens-Before

Java 内存模型是通过各种操作来定义的[1],包括对变量的读 / 写操作,监视器锁的加锁和释放操作,以及线程的启动和合并操作。JMM 为程序中所有的操作定义了一个偏序关系[2],称之为 Happens-Before。要想保证执行操作 B 的线程看到操作 A 的结果(无论 A 和 B 是否在同一个线程中执行),那么在 A 和 B 之间必须满足 Happens-Before 关系。如果两个操作之间缺乏 Happens-Before 关系,那么 JVM 可以对它们任意的重排序。

Happens-Before 的规则包括:

规则一:程序顺序

这条规则比较符合我们的直觉,在单线程中代码的顺序即程序的执行顺序:int x = 40 将在 int y = 41 之前执行

1
2
3
4
public void test() {
int x = 40;
int y = 41;
}

规则二:监视器锁[3]

监视器锁即内置锁(synchronized),在监视器锁上的解锁操作必须在同一个监视器锁的加锁操作之前执行,这条规则比较好理解,现实世界中的锁在关锁前必须处于打开状态

image-20220525142109393

规则三:volatile 变量[4][5]

对 volatile 变量的写入操作必须在对该变量的读取操作之前执行。

这条规则不大好理解,以下代码与文字摘自 深入理解Java内存模型(四)——volatile

理解 volatile 特性的一个好方法是:把对 volatile 变量的单个读/写,看成是使用同一个监视器锁对这些单个读/写操作做了同步[6]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class VolatileFeaturesExample {
volatile long vl = 0L; // 使用 volatile 声明 64 位的 long 型变量

public void set(long l) {
vl = l; // 单个 volatile 变量的写
}

public void getAndIncrement () {
vl++; // 复合(多个)volatile 变量的读 / 写
}

public long get() {
return vl; // 单个 volatile 变量的读
}
}

假设有多个线程分别调用上面程序的三个方法,这个程序在语意上和下面程序等价:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class VolatileFeaturesExample {
long vl = 0L; // 64 位的 long 型普通变量

public synchronized void set(long l) { // 对单个的普通 变量的写用同一个监视器同步
vl = l;
}

public void getAndIncrement () { // 普通方法调用
long temp = get(); // 调用已同步的读方法
temp += 1L; // 普通写操作
set(temp); // 调用已同步的写方法
}

public synchronized long get() {
return vl; // 对单个的普通变量的读用同一个监视器同步
}
}

如上面示例程序所示,对一个 volatile 变量的单个读 / 写操作,与对一个普通变量的读 / 写操作使用同一个监视器锁来同步,它们之间的执行效果相同。

从内存语义的角度来说,volatile 与监视器锁有相同的效果:volatile 写和监视器的释放有相同的内存语义;volatile 读与监视器的获取有相同的内存语义。

简而言之,volatile 变量自身具有下列特性:

  • 可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
  • 原子性:对任意单个 volatile 变量的读 / 写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。

规则四:线程启动

在线程上对 Thread.start 的调用必须在该线程中执行任何操作之前执行。

这条规则比较好理解,即线程 A 启动操作先执行,然后再执行线程 A 内的操作,换言之,线程 A 启动前的操作结果对线程 A 中的操作可见,参考下面的示例代码:

1
2
3
4
5
6
7
8
9
10
Thread thread = new Thread(() -> {
// 线程中的操作, 可以看到线程启动前对共享变量的操作结果
System.out.println(x);
});

// 线程启动前操作共享变量
x = 10;

// 线程启动
thread.start();

规则五:线程结束

线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从 Thread.join 中成功返回,或者在调用 Thread.isAlive 时返回 false。

这条规则也比较好理解,即线程结束前,线程内的任何操作都已经执行,换言之,线程结束后,线程内的操作结果对后续操作可见,参考下面的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Thread thread = new Thread(() -> {
// 线程中的操作, 可以看到线程启动前对共享变量的操作结果
System.out.println(x);

// 线程中操作共享变量
x = 20;
});

// 线程启动前操作共享变量
x = 10;

// 线程启动
thread.start();

// 线程join, 等待线程结束
thread.join();

// 线程中对共享变量的操作结果此处可见
System.out.println(x);

规则六:线程中断

当一个线程在另一个线程上调用 interrupt 时,必须在被中断线程检测到 interrupt 调用之前执行(通过抛出 InterruptedException,或者调用 isInterrupted 和 interrupted)。

这条规则笔者不太理解,只是理解了表面意思即:线程 A 对线程 B interrupt 的调用,必须在线程 B 检测到中断事件之前执行。

规则七:终结器

对象的构造函数必须在启动该对象的终结器[7]之前完成。

这条规则好理解,可以理解为 Android Activity 的生命周期,这里说的构造函数与终结器就是对象的生命周期,调用终结器之前,对象必须已经初始化。

规则八:传递性

如果操作 A 在操作 B 之前执行,并且操作 B 在操作 C 之前执行,那么操作 A 必须在 操作 C 之前执行。

看到这条规则,笔者竟然首先想到了《秦时明月》中《白马非马》辩论:白马 == 传家宝,黑马 == 传家宝,传家宝 == 传家宝,白马 == 黑马;扯远了。

这条规则可以理解为大小关系,比如:a 大于 b,b 大于 c,那么 a 一定大于 c。

传递性规则可以与其他规则结合起来使用,达到意想不到的效果。

小结[1:1]

上述规则虽然只满足偏序关系,但同步操作,如锁的获取与释放等操作,以及 volatile 变量的读取与写入操作,都满足全序关系。因此,在描述 Happens-Before 关系时,可以使用 “后续的锁获取操作” 和 “后续的 volatile 变量的读取操作” 等表达术语。

final 域

在 Java 中,不管是在写类、写变量还是写方法时,大部分程序员可能都会忽视 final 关键字 的作用,从而导致一些不可预见的问题,在 Kotlin 中类和方法默认是 final 的,声明变量时建议使用 val 修饰而不是 var。

在 Java 中基于 final 关键字修饰的变量在多线程中也有不可变的特性,建议尽量优先考虑使用 final 来修饰变量。

总结

Java 内存模型就是对 JVM 的一种规范,其中 Happens-Before 定义了与程序员相关的一些规则,可以理解为 Happens-Before 规则是底层对程序员暴露的一些接口,程序员正确的使用这些接口可以编写出正确的代码。

Happens-Before 规则可以说是强制规定了代码的一些执行顺序,同时表达出 后续操作可以看见前面一个操作的结果

对 Java 内存模型的学习,有助于分析解决遇到的线程安全问题,理解线程安全问题出现的原因,以及重塑自己的编程思想。

笔者认为最重要的是重塑自己的编程思想,俗话说 “读书破万卷, 下笔如有神”,理解原理,掌握规则,重塑思想后,才能在编码时写出 “Bug”(狗头保命)。

Java 并发编程属实难,笔者水平有限,如有错误之处,还请不吝赐教。

说明与参考文献


  1. 《Java并发编程实战-第十六章》 ↩︎ ↩︎

  2. 偏序关系 π 是集合上的一种关系,具有反对称、自反和传递属性 ↩︎

  3. 显示锁与内置锁在加锁和解锁等操作上有相同的内存语义 ↩︎

  4. 原子变量与 volatile 变量在读/写操作上有相同的语义 ↩︎

  5. Java内存模型FAQ(十)volatile是干什么用的 ↩︎

  6. 深入理解Java内存模型(四)——volatile ↩︎

  7. 即 finalize() 方法 ↩︎

欢迎关注我的其它发布渠道