Overview
Quote
- 从 JVM 的内存模型了解 Java 中的线程、线程安全、锁优化;
- 从 volatile 关键字了解原子性、可见性、有序性;
- 参考:《深入理解 JVM 虚拟机》
JVM 内存模型
定义:
内存模型:是操作内存的过程的抽象,可以理解为内存模型定义了内存读写的规则;JMM:Java Memory Mode,Java 内存模型,用于屏蔽各种硬件、操作系统的内存访问差异,是围绕原子性、可见性、有序性的一组内存操作规则规范。
主内存与工作内存

说明:
- 所有变量都存在主内存中;
- 不包括线程私有的,如局部变量、方法参数、对象引用。
- 每个线程有自己的工作内存,保存了被该线程所使用到的主内存数据的副本;
- 线程对数据的操作都在工作内存中完成,不能直接在主内存中读写;
- 不同线程之间传递数据需要通过主内存来完成。
内存操作原语
JMM 定义了 8 种内存操作原语:
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
除了操作原语,还有一些内存操作规则 (先行发生原则) 用来保证数据的一致性,它们共同构成了 JVM 内存模型。
先行发生原则
Note
操作 A 先行发生于操作 B,其实就是说在发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。
Java 中无需任何同步手段就能保障的天然的先行发生规则:
程序次序规则(Program Order Rule):在一个线程内,书写在前面的操作先行发生于书写在后面的操作。注意,编译后的字节码可能会重排序。管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。volatile变量规则(volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread::join() 方法是否结束、Thread::isAlive() 的返回值等手段检测线程是否已经终止执行。线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread::interrupted() 方法检测到是否有中断发生。对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。
原子性、可见性、有序性
volatile: 可以保证可见性、有序性,但不保证原子性。即使用 volatile 声明一个变量,可以保证这个变量的改变能立即被其它线程观察到,且能够防止指令重排序。
可见性
i = 0;
// 线程A
i = 1;
// 线程B
j = i;- 假设执行顺序为 A → B,那么 j 一定等于 i 吗?
- 不一定,因为有可能线程 A 的操作还没有同步回主内存。
- 当使用 volatile 修饰变量时,JVM 会保证当变量在一个线程内被修改时,能及时同步回主内存,当其他线程访问该变量时,不会访问工作内存,而是每次都去主内存取。
- 即声明为 volatile 的话,工作内存中的拷贝就失效了,无法起到缓存作用。
原子性
证明:volatile 不能保证原子性,i++ 不是原子操作。
注:使用 AtomicTest 原子类,可以保证 i++ 操作的原子性,底层使用了 CAS。
// 20 个线程,每个使 race++ 10000 次
// 结果不为 200000 证明 volatile 不能保证原子性,i++操作不是原子操作
public class volatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
// 等待所有累加线程都结束
while (Thread.activeCount() > 1)
Thread.yield();
System.out.println(race);
}
}非原子性协定
可以认为基本数据类型的读写都是原子性的,但 long、double 有例外。
JMM 允许虚拟机将 没有被 volatile 修饰 的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现自行选择是否要保证 64 位数据类型的 load、store、read 和 write 这四个操作的原子性。
即有可能会读到半个变量,根据研究很少会出现这个情况。
synchronized
synchronized 使用了 管程 的方法实现同步,字节码指令 monitorenter、monitorexit 底层使用了 lock 、unlock 两条内存操作原语。
JVM 对 synchronized 进行了大量的优化,后面锁优化部分记录。
有序性
指令重排序
CPU 为了使用流水线提升性能,会预读指令、进行重排序。将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。这就造成指令的执行顺序可能和我们代码的书写顺序不太一样。
Java 代码编译时优化、字节码指令执行时,也会重排序:
- 可以保证普通变量在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,即保证重排序后本线程看到的还是一样的结果。
- 不能保证变量赋值操作的顺序与程序代码中的执行顺序一致,对其他依赖该线程某变量的线程,影响很大。
// 模拟两个线程,一个初始化配置资源,一个使用配置资源
Map configOptions;
char[] configText;
// 此变量必须定义为volatile
volatile boolean initialized = false;
// 假设以下代码在线程A中执行
// 模拟读取配置信息,当读取完成后
// 将initialized设置为true,通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// ---
// 假设以下代码在线程B中执行
// 等待initialized为true,代表线程A已经把配置信息初始化完成
while (!initialized) {
sleep();
}
// 使用线程A中初始化好的配置信息
doSomethingWithConfig();说明:
如果没有将变量 initialized 声明为 volatile ,由于存在指令重排序,initialized = true 这一条赋值语句有可能被提前执行,但此时配置资源还没有初始化完毕,就会导致线程 B 在使用配置资源的时候出错。
内存屏障
volatile 的原理是使用了内存屏障,内存屏障是 CPU 读写内存操作的一个同步点。屏障之前的所有写操作都要刷新回主内存,屏障之后的所有读操作都能读取到最新的结果。
Java 中的线程
Java 中的线程是映射到 OS 上的 1:1 的内核级线程。
线程的 6 种状态

新建:创建了但尚未启动的线程。运行:包括 正在运行 和就绪两种状态,即正在参与 cpu 时间片分配的线程。限期等待:不会被分配时间片,无需被其他线程唤醒,一定时间后由系统唤醒。无限期等待:不会被分配时间片,需要被其它线程显式唤醒。阻塞:进入阻塞队列,不分配时间片,等待锁被释放。终止:执行结束。
注意: 可运行不代表可以立即运行,而是等待分配时间片。
锁升级
原因:为了优化 synchronized
过程:自旋 ⇒ 偏向锁 ⇒ 轻量级锁 ⇒重量级锁
总结:
- 持锁时间很短:自旋,不放弃处理器时间,一直请求锁直到一定次数后再阻塞。
- 只有一个线程进入临界区:偏向锁,使重入时不需要再申请锁。
- 多个线程交替进入临界区:轻量级锁,通过 CAS 避免使用互斥量。
- 多个线程同时进入临界区:重量级锁,即使用 OS 的互斥量。
对象头
- 32 位 VM 对象头 Mark Word 部分,64 位差异不大,因为 64 位的话,指针的位数也变大了。
- 为什么指针后面还可以加俩标志位?
- 因为对象是 8 字节对齐,后面三位都可以当做其他用处使用。

偏向锁
**目的:**消除数据在无竞争情况下的同步原语,即重入时不需要再申请锁。
**含义:**偏心于第一个获得锁的线程。
过程:
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、偏向模式设置为“1”,表示进入偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 之中。
成功后,下次持有偏向锁的线程再次进入同步块的时候,JVM 不会在进行任何同步操作。当有其它线程申请锁的时候,会立即退出偏向模式,升级为轻量级锁。
注意:
线程 ID 记录到了原本 hashcode 的部分,故调用了 object::hashcode() 方法的对象,无法使用偏向锁。或使用了偏向锁后要使用 object::hashcode() 方法时,会立即退出偏向锁模式。
而轻量级锁虽然也会改变头信息,但是他们会保存原来头的副本,释放锁的时候进行还原。
轻量级锁
**目的:**通过 CAS 避免使用操作系统提供的互斥量,即重量级锁。
申请过程:
- 某线程即将进入同步块时,判断锁对象标志,如果没有被锁,则首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于备份当前锁对象的 Mark Word,称为 Displaced Mark Word。
- 然后使用 CAS 操作尝试将锁对象的 Mark Word 中相应字段更新为指向 Lock Record 的指针,
- 成功:表示当前线程获得了锁;
- 失败:表示有其他线程在竞争锁,这时检查锁对象的 Mark Word 中指针是否指向当前线程的 Lock Record,
- 指向,说明获得了锁;
- 没有指向,说明被别的线程抢了,这时意味着有 多个线程同时 进入临界区,即轻量级锁应当升级为重量级锁。需要改变锁对象的标志位,并且保存相应的锁对象头信息,便于最后复原,同时后面来的线程都会阻塞。
释放过程:
- 通过 CAS 尝试把 Displaced Mark Word 赋值给 Mark Word,
- 成功:则释放轻量级锁成功;
- 失败:说明锁已近升级为了重量级锁,释放锁的同时,需要唤醒阻塞在该锁上的其他线程。
其他优化
- 锁消除:
- 借助于逃逸分析的支持,当判断堆上的所有数据都不会发生线程逃逸,那就可以把他们当做线程私有的数据来对待,无需使用锁。
- 锁粗化:
- 当一系列操作都是一个线程在对一个锁对象进行反复加锁、释放锁的过程时,可以把锁扩展到这一系列操作的外部。如 StringBuffer 的一系列连续 append()。