Java 内存模型
封面来源:由博主个人绘制,如需使用请联系博主。
参考链接:黑马程序员JVM快速入门
本文涉及的代码:mofan212/jvm-demo
除特别注明外,本文内容都基于 JDK 1.8
1. Java 内存模型
注意「Java 内存结构」和「Java 内存模型」的区别,「Java 内存模型」指的是 Java Memory Model,即 JMM。
更权威的解释参考:JSR-133 Java Memory Model and Thread Specification 1.0 Proposed Final Draft
简单来说,JMM 定义了一套在多线程读写共享数据(成员变量、数组)时,对数据的可见性、有序性和原子性的规则和保障。
2. 原子性
2.1 自增与自减
所谓原子性,即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
通过一个例子回顾一下:有一个初始值为 0 的静态变量,一个线程对其自增,一个线程对其自减,进行 5000 次,最终结果会是 0 吗?
结果不一定是 0。
1 | static int i = 0; |
2.2 问题分析
以上的结果可能是正数、负数和零,为什么呢?
因为 Java 中对静态变量的自增、自减操作并不是原子操作。
对于 i++ 而言(i 是静态变量),实际会产生如下的 JVM 字节码指令:
1 | getstatic i // 获取静态变量 i 的值 |
对应 i-- 也是类似:
1 | getstatic i // 获取静态变量 i 的值 |
Java 的内存模型如下,完成静态变量的自增、自减需要在主内存和线程内存(工作内存)中进行数据交换:

如果是在单线程中,上述 8 行指令是顺序执行的,不会出现交错执行:
1 | // 假设 i 的初始值是 0 |
但在多线程的环境下,这 8 行指令可能会交错执行,导致最终结果错误。
出现负数的情况:
1 | // 假设 i 的初始值是 0 |
出现正数的情况:
1 | // 假设 i 的初始值是 0 |
2.3 解决方法
使用关键字 synchronized。
语法:
1 | synchronized ( 对象 ) { |
使用 synchronized 优化先前的自增、自减代码:
1 | static int i = 0; |
如何理解呢?
可以把 obj 想象成一个房间,线程 t1,t2 想象成两个人。当线程 t1 执行到 synchronized (obj) 时就好比 t1 进入了这个房间,并反手锁住了门,在门内执行 i++ 代码。这时候如果 t2 也运行到了synchronized (obj),它发现门被锁住了,就只能在门外等待。
当 t1 执行完 synchronized 块内的代码后,才会解开门上的锁,从 obj 房间里出来,t2 线程这时才可以进入 obj 房间,反锁住门,执行它的 i-- 代码。
注意:上例中 t1 和 t2 线程必须用 synchronized 锁住同一个 obj 对象,如果 t1 锁住的是 m1对象,t2 锁住的是 m2 对象,就好比两个人分别进入了两个不同的房间,没法起到同步的效果。
3. 可见性
3.1 退不出的循环
在下列代码中,main 线程对 run 变量的修改对于 t 线程不可见,导致 t 线程无法停止:
1 | static boolean run = true; |
这是为什么呢?
初始状态下,t 线程刚开始从主内存中读取了 run 的值到工作内存中:

因为 t 线程需要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存到自己工作内存中的高速缓存中,减少对主内存中 run 的访问,以提高效率:

1 秒后,main 线程修改了 run 的值,并同步至主内存,而 t 线程还是从自己工作内存中的高速缓存中读取这个变量的,结果永远是旧值:

3.2 解决方法
使用 volatile (易变的)关键字。
它可以用来修饰成员变量和静态成员变量,避免线程从自己的工作缓存中查找变量值,而是必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。
1 | static volatile boolean run = true; |
3.3 可见性
先前例子体现的就是可见性,它保证在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见,不能保证原子性, 仅用在一个写线程,多个读线程的情况。
从字节码角度可以这样理解:
1 | getstatic run // 线程 t 获取 run true |
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性,但 synchronized 是重量级操作,性能相对更低。
如果不使用 volatile 关键字,而是在死循环内部加入 System.out.println() 方法,会发现线程 t 也能正常停止,这是为什么呢?
1 | // PrintStream |
这是因为在 println() 方法内部使用了 synchronized 关键字,保证了主内存和工作内存之间的数据一致。
4. 有序性
4.1 诡异的结果
1 | int num = 0; |
I_Result 是一个对象,内部有一个 r1 属性来保存结果,请问 r1 有几种可能?
- 线程 1 先执行,这时
ready = false,所以进入else分支结果为 1 - 线程 2 先执行
num = 2,但没来得及执行ready = true,线程 1 执行,还是进入else分支,结果为 1 - 线程 2 执行到
ready = true,线程 1 执行,进入if分支,结果为 4(因为在线程 2 执行到ready = true时,把num = 2执行了)
实际情况并不只是上述 3 种情况,结果还可能是 0:
- 线程 2 执行
ready = true,切换到线程 1,进入if分支,相加为 0(num = 2还未被执行),然后再切回线程 2 执行num = 2。
WTF?怎么会这样? 😵💫
这种现象叫做指令重排,是 JIT 编译器在运行时进行的一些优化,这个现象需要通过大量测试才能复现。
可以借助 OpenJDK: jcstress 来复现。
新建 Maven 项目,添加 jcstress 依赖:
1 | <dependency> |
提供以下测试类:
1 |
|
执行下列 Maven 命令,对项目进行打包:
1 | mvn clean package |
配置 ConcurrencyTest 的运行方式:

运行后会有类似以下结果:
RESULT SAMPLES FREQ EXPECT DESCRIPTION
0 43,219 0.01% Interesting !!!
1 128,331,985 43.27% Acceptable ok
4 168,193,647 56.71% Acceptable ok
可以看到,确实存在结果为 0 的情况,只不过占比很少,只有 0.01%。
4.2 解决方法
使用 volatile 关键词对 ready 成员变量进行修饰:
1 | volatile boolean ready = false; |
也就是说,volatile 不仅可以保证可见性,还可以避免指令重排,但记住不能保证原子性。
用相同的方式再次运行 ConcurrencyTest,结果出现:
Interesting tests: No matches.
也就是没有出现结果为 0 的情况。
4.3 理解有序性
同一个线程内,JVM 会在不影响正确性的前提下调整语句的执行顺序:
1 | static int i; |
可以看到,至于是先执行 i 还是先执行 j,对最终结果都不会产生影响。所以上述代码真正执行时的顺序可以是:
1 | i = ...; // 较为耗时的操作 |
也可以是:
1 | j = ...; |
这种特性被称为「指令重排」,多线程下「指令重排」会影响正确性,比如著名的 double-checked locking 单例模式:
1 | public final class Singleton { |
上述单例模式的特点是:
- 懒惰实例化
- 首次使用
getInstance()才使用synchronized加锁,后续使用时无需加锁
但在多线程环境下,上述实现存在问题,INSTANCE = new Singleton(); 对应的字节码是:
1 | 0: new #2 // class indi/mofan/Singleton |
其中 4、7 两步的顺序不是固定的,也许 JVM 会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法,如果两个线程 t1、t2 按如下时间序列执行:
1 | 时间 1 t1 线程执行到 INSTANCE = new Singleton(); |
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行许多初始化操作,那么 t2 拿到的将是一个未初始化完毕的单例对象。
解决方法也很简单,对 INSTANCE 使用 volatile 关键字修饰即可,禁用指令重排。
4.4 happens-bofore
happens-bofore 规定了哪些写操作对其他线程的读操作可见,它是可见性和有序性的一套规则总结。
-
线程解锁前对变量
m的写,对接下来其他线程对m加锁时的读可见1
2
3
4
5
6
7
8
9
10
11
12
13
14static int x;
static Object m = new Object();
new Thread(() -> {
synchronized (m) {
x = 10;
}
}, "t1").start();
new Thread(() -> {
synchronized (m) {
System.out.println(x);
}
}, "t2").start(); -
线程对
volatile变量的写,对接下来其他线程对改变的读可见1
2
3
4
5
6
7
8
9static volatile int x;
new Thread(() -> {
x = 10;
}, "t1").start();
new Thread(() -> {
System.out.println(x);
}, "t2").start(); -
线程
start前对变量的写,对该线程start后对该变量的读可见1
2
3static int x;
x = 10;
new Thread(() -> System.out.println(x), "t2").start(); -
线程结束前对变量的写,对其他线程得知它结束后的读可见(比如其他线程调用
t1.isAlive()或t1.join()等待t1线程结束)1
2
3
4
5
6
7static int x;
Thread t1 = new Thread(() -> x = 10, "t1");
t1.start();
t1.join();
System.out.println(x); -
线程
t1打断t2(interrupt)前对变量的写,对于其他线程得知t2被打断后对变量的读可见(通过t2.interrupt()或t2.isInterrupted())1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28static int x;
public static void main(String[] args) {
Thread t2 = new Thread(() -> {
while (true) {
if (Thread.interrupted()) {
System.out.println(x);
break;
}
}
}, "t2");
t2.start();
new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
x = 10;
t2.interrupt();
}, "t1").start();
while (!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x);
} -
对变量默认值(
0、false和null)的写,对其他线程对该变量的读可见 -
具有传递性,如果 x happens-bofore y,y happens-bofore z,那么 x happens-bofore z
注意: 上述所说的 变量 都是指静态变量或成员变量。
5. CAS 与原子类
5.1 CAS
CAS 即 Compare and Swap,体现了一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作:
1 | // 不断尝试 |
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无锁并发,这适用于竞争不激烈、多核CPU的场景下。
- 因为没有使用
synchronized,所以线程不会陷入阻塞,在执行效率上有有所提升 - 但如果竞争激烈,可以想到必然频繁发生重试,效率反而会下降
CAS 底层依赖于 Unsafe 类直接调用操作系统底层的 CAS 指令,下面是直接使用 Unsafe 对象进行线程安全保护的一个例子:
1 | public class TestCAS { |
5.2 乐观锁与悲观锁
CAS 基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量:「就算改了也没关系,我吃亏点再重试呗」。
synchronized 关键字基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量:「我上了锁你们都别想改,我改完了解开锁,你们才有机会」。
5.3 原子操作类
JUC(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如 AtomicInteger、AtomicBoolean 等,它们底层采用了 CAS + volatile 来实现的。
使用 AtomicInteger 改写先前的例子:
1 | private static final AtomicInteger I = new AtomicInteger(0); |
6. synchronized 优化
Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针和 Mark Word)。Mark Word 平时存储这个对象的哈希码、分代年龄,在加锁时,根据情况将这些信息替换为标记位、线程锁记录指针、重量级锁指针、线程 ID 等内容。
6.1 轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
这就好比学生(线程 A)用课本占座,上了半节课,出门了(CPU 时间到),回来一看,发现课本没变,说明没有竞争,就继续上他的课。
如果这期间有其它学生(线程 B)来了,会告知(线程 A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。
重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来。
假设有两个方法同步块,利用同一个对象加锁:
1 | static Object obj = new Object(); |
每个线程的栈帧都会包含一个锁记录结构,内部可以存储锁定对象的 Mark Word。
| 线程 1 | 对象 Mark Word | 线程 2 |
|---|---|---|
| 访问同步块 A,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
| CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
| 成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | - |
| 执行同步块 A | 00(轻量锁)线程 1 锁记录地址 | - |
| 访问同步块 B,把 Mark 复制到线程 1 的锁记录 | 00(轻量锁)线程 1 锁记录地址 | - |
| CAS 修改 Mark 为线程 1 锁记录地址 | 00(轻量锁)线程 1 锁记录地址 | - |
| 失败(发现是自己的锁) | 00(轻量锁)线程 1 锁记录地址 | - |
| 锁重入 | 00(轻量锁)线程 1 锁记录地址 | - |
| 执行同步块 B | 00(轻量锁)线程 1 锁记录地址 | - |
| 同步块 B 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | - |
| 同步块 A 执行完毕 | 00(轻量锁)线程 1 锁记录地址 | - |
| 成功(解锁) | 01(无锁) | - |
| - | 01(无锁) | 访问同步块 A,把 Mark 复制到线程 2 的锁记录 |
| - | 01(无锁) | CAS 修改 Mark 为线程 2 锁记录地址 |
| - | 00(轻量锁)线程 2 锁记录地址 | 成功(加锁) |
| - | … | … |
6.2 锁膨胀
如果在尝试加轻量级锁的过程中,有其它线程为此对象加上了轻量级锁(有竞争),CAS 操作无法成功,这时需要进行锁膨胀,将轻量级锁变为重量级锁。
1 | static Object obj = new Object(); |
| 线程 1 | 对象 Mark Word | 线程 2 |
|---|---|---|
| 访问同步块,把 Mark 复制到线程 1 的锁记录 | 01(无锁) | - |
| CAS 修改 Mark 为线程 1 锁记录地址 | 01(无锁) | - |
| 成功(加锁) | 00(轻量锁)线程 1 锁记录地址 | - |
| 执行同步块 | 00(轻量锁)线程 1 锁记录地址 | - |
| 执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 访问同步块,把 Mark 复制到线程 2 的锁记录 |
| 执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为线程 2 锁记录地址 |
| 执行同步块 | 00(轻量锁)线程 1 锁记录地址 | 失败(发现其他线程已经抢占了锁) |
| 执行同步块 | 00(轻量锁)线程 1 锁记录地址 | CAS 修改 Mark 为重量级锁 |
| 执行同步块 | 10(重量锁)重量锁指针 | 阻塞中 |
| 执行完毕 | 10(重量锁)重量锁指针 | 阻塞中 |
| 失败(解锁) | 10(重量锁)重量锁指针 | 阻塞中 |
| 释放重量锁,唤起阻塞线程竞争 | 10(重量锁) | 阻塞中 |
| - | 10(重量锁) | 竞争重量锁 |
| - | 10(重量锁) | 成功(加锁) |
| - | … | … |
6.3 重量锁
重量级锁竞争的时候还可以使用自旋来进行优化,如果当前线程自旋成功(即持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会更高,就多自旋几次;反之,就少自旋甚至不自旋。
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 下自旋才能发挥优势。
- 好比等红灯时汽车是否熄火,没熄火相当于自旋(等待时间短更划算),熄火相当于阻塞(等待时间长更划算)
- Java 7 之后不能控制是否开启自旋功能
自旋重试成功的情况:
| 线程 1(CPU 1 上) | 对象 Mark Word | 线程 2(CPU 2 上) |
|---|---|---|
| - | 10(重量锁) | - |
| 访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
| 成功(加锁) | 10(重量锁)重量锁指针 | - |
| 执行同步块 | 10(重量锁)重量锁指针 | - |
| 执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
| 执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
| 执行完毕 | 10(重量锁)重量锁指针 | 自旋重试 |
| 成功(解锁) | 10(重量锁) | 自旋重试 |
| - | 10(重量锁)重量锁指针 | 成功(加锁) |
| - | 10(重量锁)重量锁指针 | 执行同步块 |
| - | … | … |
自旋重试失败的情况:
| 线程 1(CPU 1 上) | 对象 Mark Word | 线程 2(CPU 2 上) |
|---|---|---|
| - | 10(重量锁) | - |
| 访问同步块,获取 monitor | 10(重量锁)重量锁指针 | - |
| 成功(加锁) | 10(重量锁)重量锁指针 | - |
| 执行同步块 | 10(重量锁)重量锁指针 | - |
| 执行同步块 | 10(重量锁)重量锁指针 | 访问同步块,获取 monitor |
| 执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
| 执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
| 执行同步块 | 10(重量锁)重量锁指针 | 自旋重试 |
| 执行同步块 | 10(重量锁)重量锁指针 | 阻塞 |
| - | … | … |
6.4 偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。Java 6 中引入了偏向锁进行优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头时才进行 CAS 操作,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。
- 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
- 访问对象的 hashCode 也会撤销偏向锁
- 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程
t1的对象仍有机会重新偏向t2,重偏向会重置对象的 Thread ID - 撤销偏向和重偏向都是以类为单位进行批量操作
- 如果撤销偏向达到某个阀值,整个类的所有对象都会变为不可偏向的
- 可以主动使用虚拟机参数
-XX:UseBiasedLocking禁用偏向锁
可以参考这篇论文:Eliminating Synchronization-Related Atomic Operations with Biased Locking and Bulk Rebiasing
假设有两个方法同步块,利用同一个对象加锁:
1 | static Object obj = new Object(); |
| 线程 1 | 对象 Mark |
|---|---|
| 访问同步块 A,检查 Mark 中是否有线程 ID | 101(无锁可偏向) |
| 尝试加偏向锁 | 101(无锁可偏向)对象 hashCode |
| 成功 | 101(无锁可偏向)线程 ID |
| 执行同步块 A | 101(无锁可偏向)线程 ID |
| 访问同步块 B,检查 Mark 中是否有线程 ID | 101(无锁可偏向)线程 ID |
| 是自己的线程 ID,锁是自己的,无需做更多操作 | 101(无锁可偏向)线程 ID |
| 执行同步块 B | 101(无锁可偏向)线程 ID |
| 执行完毕 | 101(无锁可偏向)对象 hashCode |
6.5 其他优化
减少上锁时间
同步块内的代码要尽量少
减少锁的粒度
将一个锁拆分为多个锁提高并发度,例如:
ConcurrentHashMapLongAdder分为base和cells两部分。没有并发争用的时候或者是cells数组正在初始化的时候,会使用CAS来累加值到base,有并发争用,会初始化cells数组,数组里有多少个cell,就允许有多少线程并行修改,最后将数组中每个cell累加,再加上base就是最终的值LinkedBlockingQueue入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
锁粗化
多次循环进入同步块不如同步块内多次循环。
另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次):
1 | new StringBuffer().append("a").append("b").append("c"); |
锁消除
JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。
读写分离
- CopyOnWriteArrayList
- CopyOnWriteArraySet