Java 内存模型(Java Memory Model, JMM) 是 Java 为了屏蔽不同硬件和操作系统的内存访问差异, 让 Java 程序在各种平台下都有一致的内存访问效果.
Java 内存模型的主要目标是定义程序中共享变量的访问规则
由于处理器的速度跟主内存的速度不是一个数量级的, 因此处理器读取内存中的数据时, 会读取一段数据并缓存, 处理器不会直接跟主内存交互而是直接使用并修改缓存上的数据.
当同一块主内存的数据在多个线程 (多个处理器) 中有多份拷贝时, 其中一个线程对其缓存的变量修改之后, 不会马上写会到主内存, 即使写回了主内存, 其他的线程不知道这个值已经改变了, 而是继续使用缓存的数据, 因此会有数据不一致的问题(线程不安全).
虚拟机保证以下原子操作:
- lock(锁定): 作用于主内存变量, 标识为线程独有
- unlock(解锁): 作用于主内存变量, 把变量释放
- read(读取): 作用于主内存变量, 把变量传输到工作内存
- load(载入): 作用于工作内存变量, 把read得到的变量放入到工作内存的变量副本
- use(使用): 作用于工作内存变量, 把工作副本的变量传递给执行引擎
- assign(赋值): 作用于工作内存变量, 把执行引擎的值赋值到工作内存
- store(存储): 作用于工作内存, 把工作内存的值送到主内存中
- write(写入): 作用于主内存, 把工作内存中得到的值放入主内存中
lock, unlock, read, load, store, write, assign 和 use 规则如下:
- 不允许 read 和 load, store 和 write 操作之一单独出现, 必须同时出现
- 工作内存中的值改变(assign)了必须同步到主内存中
- 工作内存中的值没有改变(assign)不允许同步回主内存中
- 一个新的变量只能在主内存中创建, 不能使用未初始化(load 或 assign)的变量, 对一个变量执行 use,store 之前, 必须先执行过了 assign和load
- 同一个变量, 同一时刻, 只能一个线程对其 lock 操作, lock 操作可以被同一个线程执行多次, 只有执行相同次数的 unlock 变量才能被解锁
- 对一个变量执行 lock 操作, 会清空工作内存中此变量的值, 其他执行引擎需要重新 load 或 assign 初始化这个变量
- 一个变量没有被 lock 锁定, 不允许 unlock, 不允许 unlock 其他线程锁住的变量
- 对变量执行 unlock 之前, 必须先把此变量同步回主内存中(store, write)
volatile 特殊规则
volatile 保证变量的可见性
volatile 修饰的变量, 每次这个值在一个线程中被修改时, 会立即同步到主内存中, 并且使其他换处理器缓存了这个变量的缓存全部失效, 使用这个变量时, 需要重新从主内存中获取, 因此能保证 volatile 修饰的变量在所有线程中是一致的
但是 volatile 的运算不一定是线程安全的, 因为运算不一定是线程安全的.
下面的例子, 开启了20个线程, 每个线程执行 10000 次 race++, 实际运行结果会小于 200000.
volatile static int race = 0;
for (int i=0; i<20; i++) {
threads[i] = new Thread(() -> {
for (int i=0; i<10000; i++) race ++;
});
threads[i].start();
}
为了方便理解, 这里举一个例子特殊说明为啥最终值会比200000小. 假设有两个线程都缓存了变量 race, 值设为63. 都执行了 race++ 操作, 并且都得到了结果(结果都是64), 当其中一个线程把值(64)写入到主内存后, 另一个线程不会使用新的race值再次计算一遍, 而是直接把64写回了主内存中, 因此经过两次 race++, 值只增加了1.
只有满足下面两个规则才能使用 volatile:
- 运算结果不依赖变量当前的值, 或者或者能确保只有一个单一的线程修改变量的值
- 变量不需要与其他的状态共同参与不变约束
volatile 禁止指令的重排优化
对于普通的变量, 虚拟机只保证单线程中运行出来的结果是正确的, 不能保证指令的执行顺序(在单线程中, 感知不到指令发生重排)
给 volatile 变量赋值之后, 会执行
lock addl $0x0
, 把数据写入到主内存, 并让其他线程的缓存无效. 对与普通变量, 为了提高效率 CPU 会将指令乱序, 只保正最终结果一样.
大多数情况下 volatile 比锁的开销低, volatile 变量的读取跟普通变量一样, 写入的时候要用到内存屏障(后面的指令无法跑到屏障之前的位置), 需要更多的时间
对于 long 和 double 变量的特殊规则
Java规范允许没有声明为 volatile 的 long, double 型变量分两次操作(不是原子操作), 但是也强烈建议不这么做, 基本上的机器都当成原子操作
原子性, 可见性, 有序性
- 原子性: 由 Java 内存模型包装原子性 read, load, assign, use, store, write, lock, unlock 都是原子的
- 可见性: 变量的值被修改之后, 都会同步到主内存, 使用 volatile 保证修改之后马上同步会主内存
- 有序性: 在一个线程中, 所有的操作都是有序的, 在另一个线程中观察, 所有的操作都是无序的
- 内存模型提供了 lock, unlock 来满足用户更大范围的原子操作需求, 使用字节码 monitorenter, monitorexit 完成操作, Java代码中用 synchronized 实现
- 同步块(synchronized 和 final)的可见性, 在执行 unlock 之前, 必须把此变量返回到主内存中 (把锁的对象返回到主内存)
- final 修饰的字段, 一旦对象把 this 传递出去, 其他线程就能看到 final 的值, 如果没有使用 final 修饰, 可能这个对象传递出去之后, 未初始化完, 其他线程调用可能出问题. (没有使用 final 修饰的字段, 可能对象创建之后, 对象的字段未初始化)
先行发生原则(happens-before)
内存模型中紧靠 volatile 和 synchronized 会使操作变得繁琐, 因此有了先行发生的原则, 先行发生规则无需任何同步就能保障成立.
- 程序次序规则: 在一个线程内在前面的代码先行发生于后面的代码
- 管程锁定规则: 一个 unlock 先行发生于同一个锁的 lock
- volatile 规则: 一个 volatile 的写操作先行发生于这个变量的读操作, 这里指时间顺序
- 线程启动规则: Thread 对象的 start() 先行发生于此线程的每一个操作
- 线程终止规则: 线程中所有操作都先行于此线程的终止检测(检查到线程已经终止, 肯定操作都已经结束)
- 线程中断规则: 对线程 interrupt() 方法的调用先行于被中断线程的代码检测到中断事件的发生
- 传递性: A 先行与 B, B 先行于 C, 则 A 先行于 C
- 两个线程操作一个普通共享变量, 不符合上面的任何一个规则, 因此是不安全的
- 先行发生不等于时间上一定先发生, 同一个线程中的, 可能指令重排序
参考《深入理解 Java 虚拟机》
多线程编程 深入理解DCL的安全性
从DCL的对象安全发布谈起