- 从CPU到Java内存模型
1.1 从CPU内存模型说起
1.2 Java内存模型的引入 - 优化带来的重排序
2.1 编译器优化重排序
2.2 指令级并行重排序
2.3 缓存优化重排序 - happends-before
3.1 happends-before与允许的重排序
3.2 完整的happens-before规则 - volatile与内存屏障
4.1 volatile语义
4.2 使用内存屏障阻止重排序
1. 从CPU到Java内存模型
1.1 从CPU内存模型说起
CPU主要用于指令的执行和运算,程序运行时的数据主要保存在内存(RAM)中;因为CPU的速度远快于主存,为了提高CPU的利用率(减少读写内存时的空等情况),会在CPU和内存之间加入一层或多层高速缓存。
缓存的引入极大地提高了CPU性能,但是也不可避免地引入了缓存一致性的问题。在CPU层面,其内存模型主要确定了:当前CPU写入数据后,其他CPU什么时候能够看到。
有些CPU有很强的内存模型(strong memory model),能够让所有的CPU在任何时候、任何指定的内存地址上,都可以看到完全相同的值。而一些CPU则有较弱的内存模型(weaker memory model),在这种CPU中,必须使用内存屏障(一种特殊的指令)来刷新本地CPU缓存并使本地CPU缓存无效,目的是为了让当前CPU能够看到其他CPU的写操作,或者让其他CPU能看到当前CPU的写操作。内存屏障在高级语言中对程序员是不可见的。
1.2 Java内存模型的引入
为了屏蔽底层CPU的内存模型的各种差异化,Java抽象出了一个自己的内存模型,Java内存模型(Java Memory Model, JMM)确定了在多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。
Java内存模型主要确定了:当前线程写入数据后,其他线程什么时候能够看到。
值得注意的是,这里关注的不再是CPU,而是Java线程,每个线程会有自己的工作内存(本地内存),从主内存(共享内存/堆内存)读取数据后,会拷贝一份至工作内存,下一次读的时候,会通过工作内存进行读取。至于工作内存的具体实现,可以是CPU中的高速缓存,也可能是其他一些缓存介质。Java程序员并不需要操心这些,Java内存模型保证了代码在各种硬件和编译器的优化的情况下,依然能够被正确执行。
什么叫正确执行呢?比如synchronized同步块如果只有一个线程能够进入这叫正确执行,如果两个线程进入,那就不叫正确执行了,因为不符合synchronized在JMM中规定的语义了。
JMM给synchronized、volatile、线程开启等赋予了特定的语义(在代码中代表的含义),底层的实现必须满足这些语义。
2. 优化带来的重排序
如果出现重排序:前面的写挪到了读后面了,自然是“看不见”这个操作了。Java内存模型往往通过确定操作的前后顺序来保证一些特定的操作可见(写完能被其他线程看见);本节先来看看Java程序中可能涉及到重排序。
Java代码执行到最终被可见,其过程主要包括:
- Java代码被前端编译器编译为class字节码;
- 解析执行一定次数后,会被JIT编译器编译为本地机器码(汇编指令);
- CPU将指令加载至寄存器,进行解码指令执行;
- 执行结果可以被其他CPU可见,这里涉及到CPU本身的缓存一致性处理方式。
而上面中的每一步,都有可能会涉及重排序,主要包含:
- 编译器(前后端编译)优化导致的重排序;
- CPU并行执行指令导致的重排序;
- CPU缓存(一致性)优化,使得其他CPU对操作不可见导致的重排序。
以上优化的前提是必须遵守as-if-serial(就好像是串行):即不管如何重排序,(单线程)程序的执行结果都不能改变。
2.1. 编译器优化重排序
比如在某个线程中的程序代码:
a = 1; // 指令1
b = 1; // 指令2
c = a + b; // 指令3
如果编译后没有做任何优化,指令顺序为:
因为指令1和指令2没有依赖关系,编译器可能会根据一定的优化规则,对这两个指令进行重排序,比如编译后的指令顺序可以为2-1-3:
这里顺便一提,重排序只是编译器优化的一种表现,其他一些优化可能使得程序执行的行为显得格外诡异。就好比以下这段经典的死循环代码:
public class SubThread extends Thread {
private static boolean flag = false;
public static void main(String[] args) throws Exception {
new SubThread().start(); // 开始执行子线程的循环
Thread.sleep(100); // 保证循环代码运行次数足够多,触发JIT编译
flag = true;
}
public void run() {
while (!flag) { }; // 死循环,尽管flag变为true也不会停止
}
}
这段代码执行后可能会进入死循环,这并不是什么可见性/重排序等导致的问题,而是编译器优化导致的。编译器会认为这段循环代码在(单线程)运行中,flag变量不会被改变,从而优化为:
// 原指令:while (!flag) { };
// 优化后的指令:
if(flag) { // 只读一次
while (true) { }; // 死循环
}
防止出现死循环的方式有很多,比如:
(1)flag声明为volatile
变量;
(2)循环体中增加打印语句(打印方法中会包含synchronized
);
(3)循环体中增加Thread.yeild()
让出时间片等会引起线程切换方法;
以上动作就像是Java代码给编译器的“暗示”:flag的变量是有可能会变化的,从而编译器不会再进行类似优化。
因为JMM规定读volatile变量和进入同步块前会清空工作内存(保证可见性,具体参考);线程切换(上下文切换)也会导致CPU高速缓存被清空。
除此之外,也可以禁用JIT编译器(-Xint或-Djava.compiler=NONE),禁用后JVM每次都会解释执行,也不会出现死循环的情况。
2.2 指令级并行重排序
为了提高指令执行速度,现代处理器采用了指令级并行技术(Instruction-Level Parallism,ILP)来将多条指令重叠执行。
2.3 缓存优化重排序
我们来看一个多核CPU中指令的执行情况:
// 初始化 a=0, flag=false
// CPU-0 执行
a = 1;
flag = true;
// CPU-1 执行
while ( !flag ) continue; // 未初始化成功,进行空等
assert a == 1
由于对缓存的优化,有时候会出现assert failed(即a != 1
)的情况。
a = 1
虽然比flag = true
先执行(执行后CPU-0看到的值都是最新的);然而flag
有可能先于a
更早地刷入主存,从而被其他CPU看到。CPU-1读到最新的flag = true
后,while循环结束后,如果此时a
对cpu-1尚未可见,会读到内存中的旧值0
。
这时候会出现一种有意思的现象:
从CPU-0的角度看,执行顺序为:a = 1
> flag = true
;
从CPU-1的角度看,执行顺序为:flag = true
> a = 1
(因为flag最新值先可见);这种不及时可见导致的重排序也有人称“伪重排序”。
现代的CPU的缓存一致性多是这种最终一致性(允许短时间内不一致)。上例中a=1
始终会被其他cpu所见,只不过是时间问题而已。
如果需要保证其可见性(写完后即刻可见),则需要在特定位置加入内存屏障指令(Memory barrier),对应到Java程序则是需要加上volatile、synchronized等的同步手段。
为什么叫“缓存优化”呢?在一般的缓存一致性协议中(MSI、MESI等),其协议通常是强一致性的。但是有时候这种强一致性不是必要的,即使没有强一致也不会影响(单线程)执行结果,所以很多CPU缓存往往会引入Store Buffer、Invalidate Queue等手段进行优化(类似缓存的缓存),从而导致了这种可见性的问题。具体可以参考:Why Memory Barriers.
3. happends-before
3.1 happends-before与允许的重排序
JMM规范(JSR-133)对happends-before的简单定义是这样的:
If one action happens-before another, then the first is visible to and ordered before the second.
如果一个操作(action) happens-before 另一个操作,则第一个对第二个可见,且第一个排在第二个之前。
简言之,A happens-before B,A先执行,B后执行,A禁止重排序到B后面。
然而JSR-133中也屡次提到,两个操作之间存在 happens-before 关系并不意味着这些操作必须以这种顺序发生;如果说优化(重排序)后,不影响执行结果(和原来没重排序的结果一样),那么这种优化重排序是允许的。
我们这里先看一种不允许优化的情况:当两个操作存在happens-before 关系的,且存在冲突访问(Conflicting Accesses )时,禁止重排序。
冲突访问指的是:两个操作访问同一个共享变量,且这两个操作中有一个为写操作时,就被认为存在冲突访问(或者叫数据依赖性)。这种情况下,重排序往往会破坏原来的执行结果。
比如:(1)a = 1; (2)b = 1; (3)c = a + b;
,(1)和(2)允许重排序,因为没有冲突访问;但是(1)和(3)不允许重排序,因为(1)写a,(3)读a,这两者存在冲突访问,如果重排序后,结果就不为c == 2
了。
JMM定义的happens-before规则不少,我们先来看一个简单的:
【规则1】程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作。
简单理解就是:代码都是从上往下执行的,前面的代码 happens-before 后面的代码。
举个例子:
public class RecordDemo {
static int a = 0;
static boolean flag = false;
// 线程A执行write()
static void write() {
a = 1;
flag = true;
}
// 线程B执行read()
static void read() {
if (flag) {
int i = a;
...
}
}
}
线程A:a = 1
happens-before flag = true
;
线程B:if(flag)
happens-before i = a
;
值得注意的是,线程A和线程B的操作之间是没有任何happens-before关系的。而因为 happens-before 有时候是允许重排序优化,这就有可能会出现一些诡异的情况:i == 0;
“按道理讲”,如果能够进入if块,说明flag == true
了,那么线程A中的a = 1
肯定执行了;
然而虽然a = 1
happens-before flag = true
,a = 1
和flag = true
却是不存在冲突访问的,JMM允许两者进行重排序,此时整体的执行序列可能为:
这时候就得到 i == 0 这种诡异的结果了。
如果期望进入if块
后读到的a一定是1的话(这个才是符合我们日常认识的),那么就必须引入同步手段了,在此之前,我们先来看看happens-before的另外两条规则:
【规则2】volatile变量规则:对一个volatile变量的写,happens-before 于任意后续对这个volatile变量的读;
【规则3】传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C;
我们引入一个传说中轻量级同步的volatile,将flag声明为volatile变量:
public class RecordDemo {
static int a = 0;
static volatile boolean flag = false;
...其他不变
}
根据【规则2】如果线程A先执行flag=true
,线程B再执行if(flag)
(读flag),此时 flag=true
happens-before if(flag)
;
再看【规则1】a = 1
happens-before flag = true
;if(flag)
happens-before i = a
;
结合【规则3】推断出:a = 1
happen-before i = a
;这两者时存在冲突访问的,禁止重排序,也就是说,i = a
-> a = 1
这样的顺序是禁止的。自然也就不会出现 i == 0这种诡异的情形了。
【规则2】就如同一座桥梁将两个线程之间的happen-before关系给连接起来,synchronized也有类似的规则。
当然如果线程B先执行if(flag)
,线程A再执行flag=true
,此时不满足【规则2】,两个线程之间又没什么关系了;此时 i == 0 这种诡异的情况也不会发生(因为if块压根不会进入了),这个是我们“期待”的。
JMM就是通过制定这样的happens-before规则,来保证加入同步手段后(比如volatile/synchronized/final等)能确定多个线程之间的同步顺序;
在没有类似同步手段/访问冲突的限制时,又允许其底层进行各种各样的优化,尽量提高Java程序的效率。
3.2 完整的happens-before规则
完整的happen-before规则包含了:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作;
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁(synchronized);
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读;
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C;
- start规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作;
- join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回;
- 中断规则:线程A调用线程B的interrupt() happens-before 线程A发现B被中断(B抛出异常或者A通过isInterrupted() / interrupted()检测到B被中断)
- 终结规则:一个对象的构造函数的结束happends-before于这个对象finalizer的开始。
看着挺多规则,但是这些都是为了执行结果能够“符合我们日常认识”,没有必要刻意去记,了解volatile/synchroized/final/Thread.start()/join()等代表的意思就足够了,这些规则大都是为了这些语义服务的。
happens-before 内存模型在JSR-133中被称为“Java内存模型的近似模型”,单纯 happens-before 内存模型可能会导致一些因果关系错乱的问题。完整的Java内存模型其实是 happens-before 内存模型的加强版,个人认为其加强的目的主要还是为了代码执行顺序能够“符合日常认识”。由于完整的Java内存模型过于复杂,个人水平有限,就不再细谈了。
4. volatile与内存屏障
如果说happens-before是用来(理论上)描述JMM规定的先后顺序的话,那么内存屏障可能就是真正的实现者了。
4.1 volatile语义
我们先来看看volatile的语义:
- 可见性,读到的值永远都是最新的(写完之后能即刻被后续的读看到):
1.1 volatile写后,会把该线程的工作内存刷新到主内存;
1.2 volatile读前,会将该线程的工作内存置为无效,从而从主内存读; - 禁止重排序,当存在两个操作涉及到volatile时:
2.1 第一个操作为volatile写,第二个为volatile读时,禁止重排序;
2.2 第一个操作为volatile读,第二个为普通变量的读/写时,禁止重排序;
2.3 第二个操作为volatile写,第一个为普通变量的读/写时,禁止重排序;
这里的语义1其实也可以归为“重排序”(具体参考本文#2.3),本节主要讨论volatile禁止重排序的情况。语义2.1 其实就是本文 #3.1 中涉及的例子,语义2.2和2.3是volatile在JDK1.5之后增强的语义,目的是消除类似DCL单例读到[未完全初始化对象]的“违背常理”的情况。
4.2 使用内存屏障阻止重排序
编译器会根据volatile/synchronized/final等的语义,在特定的位置插入内存屏障。当遇到特定的内存屏障指令时,处理器将禁止其对应的重排序,保证屏障前面的操作可以被后面的操作可见。
根据前后两个(读写)操作的组合,一共可以细为四种内存屏障,插入屏障后,会禁止前一个操作重排序到后一个的后面:
操作 | 内存屏障 | 内存含义(可见性) |
---|---|---|
读读 | Load1, LoadLoad, Load2 | 确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入 |
写写 | Store1, StoreStore, Store2 | 确保Store1的数据在Store2以及后续Store指令操作相关 数据之前对其它处理器可见(例如向主存刷新数据) |
读写 | Load1, LoadStore, Store2 | 确保Load1的数据在Store2和后续Store指令被刷新之前读取 |
写读 | Store1, StoreLoad, Load2 | 确保Store1的数据在被Load2和后续的Load指令读取之前 对其他处理器可见 |
有了这些屏障,我们要满足volatile/Synchronized/final等的内存语义就简单了,比如“2.1 第一个操作为volatile写,第二个为volatile读时,禁止重排序”,我们只需要 在volatile写和volatie读之间插入一个 StoreLoad 屏障即可。
其他的也类似,JMM完整的屏障插入要求如下表:
以volatile为例,下图展示的是一个保守的策略,实际上可以做一些优化,比如两个一样的屏障只需保留一个;分析程序发现volatile写前不会出现普通读的话,LoadStore屏障可以移除等:
这四种屏障对应的底层实现,不同的处理器架构(指令集)处理是不一样的。比如在一些非常强的处理器内存模型中,可能压根就不会有处理器重排序优化,自然用不上这些屏障;但是稍弱一些的内存模型,则需要通过相应的内存屏障指令告诉处理器哪些是需要禁止的,哪些是允许优化的。
以我们常用的 x86-64 架构来说,其内存模型是比较强的,读读/写写/读写均不会出现重排序优化,但是写读(Store, Load)是存在重排序优化的(参考Intel® 64 Architecture Memory Ordering #2),如果需要禁止这种重排序,则需要插入StoreLoad屏障,具体实现指令可以为mfence
或lock addl
等。
参考资料:
[1] JSR-133 (中文版)
[2] JSR 133 (Java Memory Model) FAQ
[3] The JSR-133 Cookbook for Compiler Writers
[4] 译:Why Memory Barriers.
[5] 计算机科学速成课 P1-P9
[6] 《Java并发编程的艺术》