首先,我们应该区分【JVM 内存结构】和【JMM 内存模型】的区别。我们常说的 【JVM 内存结构】指的是JVM 的内存分区;而 【JMM 内存模型】是一种虚拟机规范。

简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性和原子性的规则和保障。


本文主要通过以下几个方面来系统的介绍 JMM 内存结构:

  1. 原子性
  2. 可见性
  3. 有序性
  4. volatile 原理

0 Java 内存模型

Java 虚拟机规范中定义了 Java 内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java程序在各种平台下都能达到一致的并发效果,JMM 规范了 Java 虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

JMM 体现在以下几个方面

1 原子性

【问题】

两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

以上的结果可能是正数、负数、零。为什么呢?

因为 Java 中对静态变量的自增,自减并不是原子操作。例如对于 i++ 而言 (i为静态变量),实际会产生如下的 JVM 字节码指令:

1
2
3
4
getstatic	// 获取静态变量i的值
iconst 1 // 准备常量1
iadd // 自增
putstatic // 将修改后的值存入静态变量i

而对应 i-- 也是类似:

1
2
3
4
getstatic	// 获取静态变量i的值
iconst 1 // 准备常量1
isub // 自减
putstatic // 将修改后的值存入静态变量i

而 Java 的内存模型如下,完成静态变量的自增、自减都需要在主存线程内存中进行数据交换

image-20220826105509541

在并发的环境下,自增和自减的字节码指令可能会交错运行,无法保证自增和自减操作的原子性。所以可能无法得到我们想要的正确结果。

2 可见性

先来看一个现象,main 线程对 run 变量的修改对于 thread 线程不可见,导致了 thread线程无法停止

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import static java.lang.Thread.sleep;

public class DemoJMM1 {
static boolean run = true;

public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(()-> {
while(run){

}
});
thread.start();
sleep(1);
System.out.println("thread STOPPED");
run = false;
}
}

【分析】

  1. 初始状态, thread 线程刚开始从主存中读取到了 run 的值到工作内存。

    image-20220826112040399
  2. 因为 thread 线程要频繁的从主存读取 run 的值,JIT 编译器会对这种"热点代码"做出优化(详见 JVM 篇),会将 run 的值缓存到自己线程内存的栈中,减少对主存中 run 的访问,提高效率。

    image-20220826113253480
  3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 thread 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。

    image-20220826113629008

那么如何解决呢?

  • 我们可以在共享的变量前添加一个修饰符 volatile,被它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,对主存直接操作,从而可以提供对特殊地址的稳定访问。
1
volatile static boolean run = true;
  • 我们也可以使用 synchronized 锁对象实现。因为synchronized 对对象加锁时,会先清空工作内存,再从主存中拷贝共享变量的最新副本到工作内存;执行完代码后,会讲修改后的共享变量的值更新到主存中,最后释放锁。

所以,修饰符 volatilesynchronized 都可以保证共享变量的可见性。但是 synchronized 更重。

可见性 vs 原子性

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况。例如,flag 标记等

原子性的核心是保证代码的顺序执行且不可拆分,而可见性保证的是共享变量在不同线程的可见性(最新值)。

synchronized 既可以保证代码块的完整性,也可以保证代码块内部共享变量的可见性。但是该操作更加重量级,性能相对更低。

终止模式:两阶段终止模式

在一个线程 t1 中如何 “优雅” 终止线程 t2? 这里的【优雅】指的是给 T2一个“料理后事”(中断处理等)的机会。

错误思路
  • 使用线程对象的 stop() 方法停止线程
    • stop() 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁
  • 使用 System.exit(int) 方法停止线程
    • 目的仅是停止一个线程,但这种做法会让整个程序都停止。
使用 interrupt() 方法:
graph TD

A["while(true)"]
B{是否被打断?}
C[料理后事]
D[睡眠2s]
E(结束循环)
F[执行监控记录]
G[设置打断标记]

A --> B
B --yes--> C
B --no--> D
C --> E
D --无异常--> F
D --有异常--> G
F --> A
G --> A
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/*  两阶段终止模式(Two Phase Termination):在进程T1中终止进程T2
在终止进程T2之前让T2释放锁和临界资源
不用stop() 和 System.exit()
*/
class TwoPhaseTermination {
// 监控线程
privite Thread monitor;

// 启动监控线程
public void start() {
monitor = new Thread (() -> {
while (true) {
Thread current = Thread.currentThread();
if (current.isInterrupted()) {
/*
TODO 释放锁和临界资源
*/
System.out.println("释放锁和临界资源");
break;
}
try {
Thread.sleep(1000); // 情况1
/*
TODO 正常功能的代码块
*/
// 情况2
} catch (InterruptedException e) {
e.printStackTrace();
// 若sleep时被打断,会捕获错误e,此时的isInterrupted标记为false,程序会重复执行。所以有以下操作
current.interrupt(); // 重新设置isInterrupted打断标记, true -> false
}
}
});
monitor.start();
}

// 停止监控线程
public void stop() {
monitor.interrupt();
}
}
使用 volatile 标记代替 interrupted()
graph TD

A["while(true)"]
B{"if(STOP)"}
C[料理后事]
D[睡眠2s]
E(结束循环)
F[执行监控记录]
G[设置打断标记]

A --> B
B --yes--> C
B --no--> D
C --> E
D --无异常--> F
D --有异常--> G
F --> A
G --> A
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
28
29
30
31
32
33
34
35
36
class TwoPhaseTermination_Volatile {
// 监控线程
privite Thread monitor;
private volatile boolean STOP;

// 启动监控线程
public void start() {
monitor = new Thread (() -> {
while (true) {
Thread current = Thread.currentThread();
if (STOP) {
/*
TODO 释放锁和临界资源
*/
System.out.println("释放锁和临界资源");
break;
}
try {
Thread.sleep(1000); // 只能等待 sleep 结束
/*
TODO 正常功能的代码块
*/
} catch (InterruptedException e) {
e.printStackTrace();
current.interrupt();
}
}
});
monitor.start();
}

// 停止监控线程
public void stop() {
STOP = true;
}
}

终止模式:Balking 犹豫模式

在上述的例子中,我们使用监控线程,来监控内存的使用等信息,但是这样的监控线程只需要一个就够了。但是在上面的例子里并没有对于监控线程数量的限制。我们可以继续对其改造。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class DemoJMMBalking {
public static void main(String[] args) {
Balking balking = new Balking();
balking.start();
balking.start();
}
}

class Balking {
private Thread monitor;
private boolean STARTING = false; // 检查是否执行的标记

public void start() {
if (STARTING) {
return;
}
STARTING = true;
monitor = new Tread(() ->{
// TODO
});
monitor.start();
}
}

但是在并发的情况下,该程序并不能保证正确运行。因为不能保证其的原子性,因此需要加锁。也可以使用“双标志检查法”。

3 有序性

JVM 会在不影响程序正确性的前提下,调整语句的执行顺序。思考以下代码

1
2
3
4
static int i;
static int j;
i = 0;
j = 1;

我们可以看到,先执行 i 还是 j 并不影响程序的结果。所以在上述代码执行的过程中,既可以

1
2
j = 1;
i = 0;

也可以是

1
2
i = 0;
j = 1;

这种特性我们称之为指令重排,但是在多线程环境下指令重排会影响程序的正确性

如何理解这种优化呢?

指令重排列优化

我们从 CPU 的角度理解这个问题。事实上,CPU 在会一个时钟周期 T 内执行一条指令。我们可以把一条指令再划分为五个更小的阶段:

取指令 (Instruction Fetch) - 指令译码 (Instruction Decode) - 执行指令 (EXecute) - 内存访问 (MEMory access) - 数据写回 (register Write Back)

image-20220826143700494

我们可以在不改变结果的前提下,将这些指令的各个阶段通过重排序组合来实现指令级并行

image-20220826144401033

这种多级指令流水线,可以在一个时钟周期内,同时运行多条指令的不同阶段(相当于一条执行时间最长的复杂指令),本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率

但是以这种方式执行时,一条指令的执行次序就会有所调整。

重排序分为以下几种:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

4 volatile 原理

volatile 的底层实现原理是内存屏障,Memory Barrier (Memory Fence)。

  • volatile 变量的写指令后会加入写屏障
  • volatile 变量的读指令前会加入读屏障

如何保证可见性

  • 写屏障 (sfence) 保证:在该屏障之前的对于共享变量的改动,都同步到主存中
  • 读屏障 (lfence) 保证:在该屏障之后的对于共享变量的改动,加载的是主存中的最新数据
sequenceDiagram
    participant t1 as thread 1
    participant n as num=0
    participant v as volatile ready=false
    participant t2 as thread 2
    
    t1 -->> t1 :num=2
    t1 ->> v : ready = true
    Note over t1,v :写屏障
   
    Note over t2,n :读屏障
    t2 ->> n :读取num,等于2
    t2 ->> v : 读取ready,等于true

如何保证有序性(本线程内)

  • 写屏障 (sfence) 保证:指令重排时,不会将***写屏障之前***的代码排在写屏障之后
  • 读屏障 (lfence) 保证:指令重排时,不会将***读屏障之后***的代码排在读屏障之前
sequenceDiagram
    participant t1 as thread 1
    participant n as num=0
    participant v as volatile ready=false
    participant t2 as thread 2
    
    t1 -->> t1 :num=2
    t1 ->> v : ready = true
    Note over t1,v :写屏障
   
    Note over t2,n :读屏障
    t2 ->> n :读取num,等于2
    t2 ->> v : 读取ready,等于true

【总结】

  • volatile 只能保证可见性有序性,并不能保证原子性
  • synchronized 可以保证以上三种性质。

double-checked locking 问题

以我们在设计模式篇中提到的单例模式为例:

1
2
3
4
5
6
7
8
9
10
public final class Singleton {
private Singleton() {}
private static Singleton INSTANCE = null;
public static synchronized Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}

我们可以看到,由于 synchronized 是重量级锁,每次调用 getInstance() 方法获得实例对象时都会执行同步代码块、加锁、释放锁…这样效率不高。但是单例模式在创建时,只有第一次应该保护如下的代码块

1
2
3
4
5
synchronized(Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}

其余时间可以直接返回单例对象。我们思考能不能将保护的作用范围缩小,只有在第一次创建单例对象的时候使用 synchronized 保护。在此情景下,提出了double-checked locking 思想:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class Singleton {
private Singleton() {}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null) { // Frist check: 适用于单例对象创建之后的判断
synchronized(Singleton.class) {
if (INSTANCE == null) { // Second check:适用于第一次单例对象创建的判断,上锁
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

以上的实现特点是:

  • 懒惰的实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 但是很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外不能保证有序性

下面,我们就来研究其中的问题。我门考虑以下重点代码 Singleton.getInstance()

1
2
3
4
5
6
7
8
9
10
public static Singleton getInstance() {
if (INSTANCE == null) { // Frist check: 适用于单例对象创建之后的判断
synchronized(Singleton.class) {
if (INSTANCE == null) { // Second check:适用于第一次单例对象创建的判断,上锁
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}

其对应的字节码为

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
public static JMM.Singleton getInstance();
descriptor: ()LJMM/Singleton;
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=0
0: getstatic #2 // Field INSTANCE:LJMM/Singleton;
3: ifnonnull 37
6: ldc #3 // class JMM/Singleton
8: dup // 复制Singleton引用对象,解锁时用
9: astore_0 // 存入slot0
10: monitorenter // 加锁
11: getstatic #2 // Field INSTANCE:LJMM/Singleton;
14: ifnonnull 27
17: new #3 // * class JMM/Singleton
20: dup // *
21: invokespecial #4 // * Method "<init>":()V
24: putstatic #2 // * Field INSTANCE:LJMM/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:LJMM/Singleton;
40: areturn

其中

  • 17: 表示创建对象,将对象引用入栈 // new Singleton
  • 20: 表示复制一份对象引用 // 引用地址
  • 21: 表示利用一个对象引用,调用构造方法 //根据引用地址调用
  • 24: 表示利用一个对象引用,赋值给 static INSTANCE

我们得到了如上的字节码。在 CPU 的执行期间,有可能由于指令级并行的重排序,我们会得到如下执行序列(先执行 24,再执行 21)

sequenceDiagram
    participant t1 as thread 1
    participant i as INSTANCE
    participant t2 as thread 2
    
    t1 -->> t1 :17: new
    t1 -->> t1 :20: dup
    t1 ->> i : 24: putstatic, 给INSTANCE赋值
   
   t2 ->> i : 0: getstatic, 获取INSTANCE引用
   t2 -->> t2 : 3: ifnotnull 37
   t2 -->> t2 : 37: getstatic, 获取INSTANCE引用
   t2 -->> t2 : 40: areturn
   t2 -->> t2 : 使用对象
   t1 -->> t1 : 21: invokespecial #4, 调用构造方法

此时,如果我们给 INSTANCE 加上 volatile 修饰符,此问题就可以解决。

1
private volatile static Singleton INSTANCE = null;

其对对应的字节码并没有区别,所以我们从【读/写屏障】的角度分析:

  • 写屏障 (sfence) 保证:指令重排时,不会将***写屏障之前***的代码排在写屏障之后
  • 读屏障 (lfence) 保证:指令重排时,不会将***读屏障之后***的代码排在读屏障之前
sequenceDiagram
    participant t1 as thread 1
    participant i as INSTANCE
    participant t2 as thread 2
    
    t1 -->> t1 :17: new
    t1 -->> t1 :20: dup
    t1 -->> t1 : 21: invokespecial #4, 调用构造方法
    t1 ->> i : 24: putstatic, 给INSTANCE赋值
    Note over t1,i :写屏障
   
   Note over t2,i :读屏障
   t2 ->> i : 0: getstatic, 获取INSTANCE引用
   t2 -->> t2 : 3: ifnotnull 37
   t2 -->> t2 : 37: getstatic, 获取INSTANCE引用
   t2 -->> t2 : 40: areturn
   t2 -->> t2 : 使用对象

heppens-before 规则

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int x;
static Object m = new Object ();

new Thread(()-> {
synchronized(m) {
x = 10;
}
}, "t1").start ();

new Thread(0)-> {
synchronized(m) {
System.out.println(x);
}
}, "t2"). start();
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
1
2
3
4
5
6
7
8
9
volatile static int x;

new Thread(()-> {
x = 10;
}, "t1").start ();

new Thread(0)-> {
System.out.println(x);
}, "t2"). start();
  • 线程 start() 之前对变量的写,对该线程开始后对该变量的读可见
1
2
3
4
5
static int x = 10;

new Thread(0)-> {
System.out.println(x);
}, "t2"). start();
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive()t1.join() 等待它结束)
1
2
3
4
5
6
7
8
9
static int x;

Thread t1 = new Thread(0)->{
x = 10;
}, "t1");
t1.start();

t1. join();
System.out.println(x);
  • 线程 t1 打断 t2 (interrupt) 前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过
    t2.interruptedt2.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
static int x;
public static void main(String[] args) {
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread).isInterrupted)){
System.out.println(x);
break;
}
}
}, "t2") ;
t2. start();

new Thread(0)->{
sleep(1);
x = 10;
t2. interrupt();
}, "t1"). start();

while(!t2.isInterrupted()){
Thread.yield();
}

system.out.println(x);
}
  • 对变量默认值(0,false, null)的写,对其它线程对该变量的读可见
  • 具有传递性,如果 x hb -> y 并且 y hb -> z 那么有 x hb -> z,配合 volatile 的防指令重排,有下面的例子
1
2
3
4
5
6
7
8
9
10
11
volatile static int x;
static int y;

new Thread(0)-> {
y = 10;
x = 20;
}, "t1"). start();

new Thread(0)-> {
System.out.println(x);
}, "t2"). start();