JMM - Java 内存模型
首先,我们应该区分【JVM 内存结构】和【JMM 内存模型】的区别。我们常说的 【JVM 内存结构】指的是JVM 的内存分区;而 【JMM 内存模型】是一种虚拟机规范。
简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性和原子性的规则和保障。
本文主要通过以下几个方面来系统的介绍 JMM
内存结构:
0 Java 内存模型
Java 虚拟机规范中定义了 Java 内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java程序在各种平台下都能达到一致的并发效果,JMM 规范了 Java 虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
JMM 体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 CPU 缓存的影响
- 有序性 - 保证指令不会受 CPU 指令并行优化的影响
volatile
原理
1 原子性
【问题】
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
以上的结果可能是正数、负数、零。为什么呢?
因为 Java 中对静态变量的自增,自减并不是原子操作。例如对于 i++
而言 (i为静态变量),实际会产生如下的 JVM 字节码指令:
1 | getstatic // 获取静态变量i的值 |
而对应 i--
也是类似:
1 | getstatic // 获取静态变量i的值 |
而 Java 的内存模型如下,完成静态变量的自增、自减都需要在主存和线程内存中进行数据交换
在并发的环境下,自增和自减的字节码指令可能会交错运行,无法保证自增和自减操作的原子性。所以可能无法得到我们想要的正确结果。
2 可见性
先来看一个现象,main
线程对 run
变量的修改对于 thread
线程不可见,导致了 thread
线程无法停止
1 | import static java.lang.Thread.sleep; |
【分析】
-
初始状态,
thread
线程刚开始从主存中读取到了run
的值到工作内存。 -
因为
thread
线程要频繁的从主存读取run
的值,JIT 编译器会对这种"热点代码"做出优化(详见 JVM 篇),会将run
的值缓存到自己线程内存的栈中,减少对主存中run
的访问,提高效率。 -
1 秒之后,
main
线程修改了run
的值,并同步至主存,而thread
是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。
那么如何解决呢?
- 我们可以在共享的变量前添加一个修饰符
volatile
,被它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,对主存直接操作,从而可以提供对特殊地址的稳定访问。
1 | volatile static boolean run = true; |
- 我们也可以使用
synchronized
锁对象实现。因为在synchronized
对对象加锁时,会先清空工作内存,再从主存中拷贝共享变量的最新副本到工作内存;执行完代码后,会讲修改后的共享变量的值更新到主存中,最后释放锁。
所以,修饰符 volatile
和 synchronized
都可以保证共享变量的可见性。但是 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 | /* 两阶段终止模式(Two Phase Termination):在进程T1中终止进程T2 |
使用 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 | class TwoPhaseTermination_Volatile { |
终止模式:Balking 犹豫模式
在上述的例子中,我们使用监控线程,来监控内存的使用等信息,但是这样的监控线程只需要一个就够了。但是在上面的例子里并没有对于监控线程数量的限制。我们可以继续对其改造。
1 | public class DemoJMMBalking { |
但是在并发的情况下,该程序并不能保证正确运行。因为不能保证其的原子性,因此需要加锁。也可以使用“双标志检查法”。
3 有序性
JVM 会在不影响程序正确性的前提下,调整语句的执行顺序。思考以下代码
1 | static int i; |
我们可以看到,先执行 i
还是 j
并不影响程序的结果。所以在上述代码执行的过程中,既可以
1 | j = 1; |
也可以是
1 | i = 0; |
这种特性我们称之为指令重排,但是在多线程环境下指令重排会影响程序的正确性。
如何理解这种优化呢?
指令重排列优化
我们从 CPU 的角度理解这个问题。事实上,CPU 在会一个时钟周期 T 内执行一条指令。我们可以把一条指令再划分为五个更小的阶段:
取指令 (Instruction Fetch) - 指令译码 (Instruction Decode) - 执行指令 (EXecute) - 内存访问 (MEMory access) - 数据写回 (register Write Back)
我们可以在不改变结果的前提下,将这些指令的各个阶段通过重排序和组合来实现指令级并行。
这种多级指令流水线,可以在一个时钟周期内,同时运行多条指令的不同阶段(相当于一条执行时间最长的复杂指令),本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
但是以这种方式执行时,一条指令的执行次序就会有所调整。
重排序分为以下几种:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
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 | public final class Singleton { |
我们可以看到,由于 synchronized
是重量级锁,每次调用 getInstance()
方法获得实例对象时都会执行同步代码块、加锁、释放锁…这样效率不高。但是单例模式在创建时,只有第一次应该保护如下的代码块
1 | synchronized(Singleton.class) { |
其余时间可以直接返回单例对象。我们思考能不能将保护的作用范围缩小,只有在第一次创建单例对象的时候使用 synchronized
保护。在此情景下,提出了double-checked locking
思想:
1 | public final class Singleton { |
以上的实现特点是:
- 懒惰的实例化
- 首次使用
getInstance()
才使用synchronized
加锁,后续使用时无需加锁 - 但是很关键的一点:第一个
if
使用了INSTANCE
变量,是在同步块之外,不能保证有序性
下面,我们就来研究其中的问题。我门考虑以下重点代码 Singleton.getInstance()
:
1 | public static Singleton getInstance() { |
其对应的字节码为
1 | public static JMM.Singleton getInstance(); |
其中
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 | static int x; |
- 线程对
volatile
变量的写,对接下来其它线程对该变量的读可见
1 | volatile static int x; |
- 线程
start()
之前对变量的写,对该线程开始后对该变量的读可见
1 | static int x = 10; |
- 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用
t1.isAlive()
或t1.join()
等待它结束)
1 | static int x; |
- 线程
t1
打断t2
(interrupt) 前对变量的写,对于其他线程得知t2
被打断后对变量的读可见(通过
t2.interrupted
或t2.isInterrupted
)
1 | static int x; |
- 对变量默认值(
0,false, null
)的写,对其它线程对该变量的读可见 - 具有传递性,如果
x hb -> y
并且y hb -> z
那么有x hb -> z
,配合volatile
的防指令重排,有下面的例子
1 | volatile static int x; |