16_02_Java内存模型

Java内存模型

一、什么是内存模型?

缓存一致性问题:

  • 在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory)
  • 当多处理器对同一块主内存区域进行操作时,可能会导致各自的缓存数据不一致,这就是缓存一致性问题

内存模型:

  • 内存模型,是指在特定的操作协议下,对特定的内存或缓存进行读写访问的抽象过程
  • 内存模型,定义了如何以一种安全可靠的方式去读写访问内存或缓存
  • 简答来讲,内存模型规定了什么时候读入数据、什么时候写入数据、读写的顺序的等内存访问的问题

缓存一致性协议:

  • 为了解决缓存一致性问题,内存模型就需要引入一些缓存一致性协议,比如 MSI、MESI、MOSI 等
  • 缓存一致性协议,并不是针对某种内存模型,而是一种通用的缓存同步规范
  • Java 内存模型,是 JVM 定义的一个内存模型,使用的缓存一致性协议是 MESI 协议

二、为什么要有内存模型?

定义 Java 内存模型的目的:

  • 屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果

定义Java内存模型的好处:

  • Java 内存模型统一了底层不同架构平台上内存模型的接口
  • 程序无需关注运行平台的差异,只需要关注 Java 内存模型提供了怎么样的内存保证
  • 程序不用去考虑底层访问的性能问题,由 JVM 负责去优化,JVM 会根据硬件的特性去优化执行速度

比如说,Java 内存模型保证了 volatile 变量的写入是对所有线程可见的。

程序员只需知道 volatile 变量是所有线程可见的即可,至于内存模型底层是怎么实现的,用的时候无需了解。

三、Java 内存模型的设计

3.1 内存层次划分

内存层次:

  • 主内存(Main Memory):主内存只有一个,所有变量数据都存储在主内存中
  • 工作内存(Working Memory):每条线程都有自己的工作内存,使用的变量都是主内存变量的一份拷贝

内存操作:

  • 线程对变量的所有操作(读取、赋值等)都是在自己的工作内存中进行
  • 不同线程之间不能直接访问对方工作内存中的变量
  • 线程之间的变量值共享,只能通过主内存来完成

3.2 内存间的交互操作

内存交互问题:

  • 变量如何从主内存拷贝到工作内存?
  • 变量如何从工作内存同步回主内存?

原子操作:

  • lock(锁定):作用于主内存变量,锁定主内存中的变量,防止其他线程访问
  • read(读取):作用于主内存变量,读取主内存中的变量
  • load(加载):作用于工作内存变量,将 read 拿到的变量,拷贝到工作内存中
  • use(使用):作用于工作内存变量,使用工作内存中的变量
  • assign(赋值):作用于工作内存变量,对工作内存的变量进行赋值
  • store(存储):作用于工作内存变量,将工作内存的变量传送到主内存
  • write(写入):作用于主内存变量,将 store 传送出来的变量写入主内存变量
  • unlock(解锁):作用于主内存变量,将主内存中变量的锁定状态解除

执行规则:

  • 一个新的变量,只能在主内存中“诞生”
  • 不允许 read 和 load、store 和 write 操作之一单独出现
  • 一个变量在同一时刻只允许一条线程对其进行 lock 操作
  • 对一个变量执行 lock 操作,那将会清空工作内存中此变量的值
  • 对变量执行 unlock 操作前,必须先把此变量同步回主内存中

3.3 内存模型的并发设计

针对并发中出现的原子性、可见性、有序性问题,内存模型给了多种并发保证方式,来确保它们的安全性。

3.3.1 内存指令的并发保证

(1) 原子性:

  • 由 Java 内存模型直接保证的原子性操作包括 read、load、use、assign、store、write
  • 更大范围的原子性操作,Java 内存模型还提供了 lock 和 unlock 来满足

(2) 可见性:

  • Java 内存模型依赖于主内存作为传递媒介,来实现可见性:
    • 写入变量后,将值同步刷新回主内存,比如通过 unlock 操作实现
    • 读取变量前,从主内存中获取最新值,比如通过 lock 操作实现
  • 一个线程的操作结果对于另一个线程可见,那必然是经过了主内存的传递

(3) 有序性:

  • Java 内存模型是通过按需禁止重排序来避免有序性问题的
  • 通过底层指令集的某些指令来禁止重排序,Java 语言层面可以使用 volatile 关键字

3.3.2 关键字的并发保证

Java 语言中的并发关键字包括 synchronized、volatile、final。

(1) synchronized:

  • 可以保证原子性、可见性、有序性
  • 原子性:由“一个变量在同一时刻只允许一条线程对其进行 lock 操作”规则保证
  • 可见性:由“对变量执行 unlock 操作前,必须先把此变量同步回主内存中”规则保证
  • 有序性:由“一个变量在同一时刻只允许一条线程对其进行 lock 操作”规则保证,这条规则决定了持有同一个锁的两个同步块只能串行地进入

(2) volatile:

  • 可以保证可见性和有序性
  • 可见性:volatile 变量保证了新值可以立即同步回主内存中,以及每次使用前立即从主内存中刷新
  • 有序性:volatile 关键字本身设计就包含了禁止指令重排序地语义

(3) final:

  • 可以保证可见性
  • 可见性:被 final 修饰的字段,在构造器中一旦正确安全初始化完成后(安全发布),就保证了它的可见性

并发关键字可以让程序自己选择是否要控制可见性和有序性。

3.3.3 先行发生原则(happens-before)的并发保证

Java 内存模型为了保证可见性和有序性,规定了一种先行发生原则(happens-before)。

先行发生原则定义:

  • 想要保证执行操作 B 的线程,能够看到执行操作 A 的结果,A 和 B 必须满足 Happen-Before 原则
  • 如果操作 A 发生于操作 B 之前,那么操作 A 产生的“影响”就能被执行操作 B 的线程所观察到
  • 其中“影响”包括:修改了内存中共享变量的值、发送了消息、调用了方法等

先行发生原则特点:

  • 操作 A 先行发生于操作 B,这里先行是指时间顺序上的先行发生
  • 前一个操作的“影响”对后续操作是可见的。比如修改了内存中共享变量的值

先行发生规则:

  • 程序次序规则(Program Order Rule):一个线程内,保证程序语义的串行性
  • 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作(必须是同一个锁)
  • volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量,对于它的写操作先行发生于后面对它的读操作
  • 线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作
  • 线程终止规则(Thread Termination Rule):线程中的所有操作,都先行于对于此线程的终止检测
  • 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于对此线程的中断检测
  • 对象终结规则(Object Termination Rule):对象的初始化完成(构造函数执行结束)先行发生于它的 finallize() 方法的开始
  • 传递性规则(Transitivity Rule):如果 A 先行于 B,B 先行于 C,那么 A 先行于 C

举几个例子:

(1) 管程锁定规则

管程锁定规则,规定了 unlock 操作先行发生于 lock 操作。

也就是说,上一个 unlock 操作之前产生的“影响”,对于下一个 lock 操作是可见的。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 线程 A
synchronized (this) { // 此处自动加锁 lock
// count 是共享变量
this.count = 1;
} // 此处自动解锁 unlock


// 线程 B
synchronized (this) { // 此处会执行 lock 操作
if (this.count == 1) {
// 管程锁定规则可以确保看到 count 变量的值
}
} // 此处会执行 unlock 操作

假设线程 A 先拿到锁,对 count 变量进行了修改,得到 count = 1,然后执行 unlock 操作退出加锁区域。

接着线程 B 再执行 lock 操作去获取锁,线程 A 的 unlock 操作先于线程 B 的 lock 操作执行。

依据管程锁定规则,那么线程 B 就能看到线程 A 的修改,也就是线程 B 内可以看到 count = 1。

(2) 线程启动规则

线程启动规则,规定了 Thread 对象的 start() 方法先行发生于此线程的每一个动作。

也就是说,在 Thread 对象的 start() 方法执行前所产生的“影响”,线程内都是可见的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int count = 0;

Thread B = new Thread(() -> {
// 主线程调用B.start()之前
// 所有对共享变量的修改,此处皆可见
if (count == 1) {
// 线程启动规则可以保证能进入到这里
}
});

// 对共享变量var修改
count = 1;

// count > 0 是为了确保 count = 1 在 B.start(); 之前执行(程序次序规则)
if (count > 0) {
// 主线程启动子线程
B.start();
}

主线程在启动子线程 B 的 start() 方法前的操作,即 count = 1,对于线程 B 内而言,是可见的。

也就是,主线程对 count 的修改,先行于线程 B 的 start() 方法,那么线程 B 内部就可以看到修改的结果。

3.3.4 初始化安全性的并发保证

内存模型还提供了一些在对象初始化方面的并发保证:

  • 静态初始化器,可以保证对所有线程具有可见性
  • 对于被正确构造的对象,所有线程都能看到构造函数给 final 域设置的值,包括 final 域引用对象内可达的值
1
2
3
4
5
6
7
8
9
10
11
12
13
public class SafeStates {

private static Object object = new Object();

private final Map<String, String> states;

public SafeStates() {
states = new HashMap<>();
states.put("1", "a");
states.put("2", "b");
states.put("3", "c");
}
}

也就是说,由 SafeStates 创建的对象,它里面的 object 和 states,对于所有线程而言,都是可见的。

而且,states 里面的值 123 对所有线程也是可见的。

但是,初始化安全性的保证,只保证对象在初始化阶段是安全可见的。

如果后面改变的了对象的值,比如在构造函数外执行 state.put("4", "d"),这个值的可见性就无法保证,必须采用同步。

作者

jiaduo

发布于

2022-05-15

更新于

2023-04-03

许可协议