Java内存模型
Java内存模型是Java语言级别的内存抽象,存在的主要目的,是为了对Java程序员屏蔽各种计算机架构之间的差异。
Java Memory Model,简称JMM。
在JMM中,每个线程首先从主内存读取共享变量到线程本地的工作内存,然后在工作内存中修改共享变量,最后将变量的最新值刷新到主内存。
由于每个线程都有自己的本地内存,因此多个线程中,共享变量的值可能处于不一致的状态。
如下图:
重排序
as-if-serial 语义
单线程环境下,在不改变程序执行结果的情况下,Java编译器可对程序代码进行重排序。
请看如下代码:
int a = 1; //A1
int b = 2; //A2
int c = a + b; //A3
执行序列可能是
A1 -> A2 -> A3
A2 -> A1 -> A3
由于A3依赖于A1和A2,因此A1,A2和A3不能进行重排序。
重排序
从Java源代码到最终的指令序列,经历了如下重排序过程:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
重排序导致的可见性问题
以下面的指令序列为例:
ProcessA | ProcessB |
---|---|
a = 1; //A1 x = b; //A2 |
b = 2; //B1 y = a; //B2 |
初始状态:a = b = 0 处理器允许执行后得到结果:x = y = 0 |
ProcessA可能对指令进行重排序:A2 -> A1
ProcessB可能对指令进行重排序:B2 -> B1
当整个执行序列为:A2 -> B2 -> A1 -> B1 时,x = y = 0。
happens-before规则
在JMM中,通过happens-before规则,保证多线程间内存的可见性,这些规范在JSR133中定义。
A happens-before B,不代表A在时间上一定先发生于B,而是指:
如果A先发生于B,则A操作的结果对B可见
happens-before规则有:
规则名 | 内容 |
---|---|
程序次序规则 | 一个线程内,按照代码顺序,书写在前面的操作happens-before于书写在后面的操作 |
锁定规则 | 一个unLock操作happens-before于后面对同一个锁的lock操作 |
volatile变量规则 | 对一个volatile变量的写操作happens-before于后面对这个volatile变量的读操作 |
传递规则 | 如果操作A happens-before于操作B,而操作B又happens-before于操作C,则可以得出操作A happens-before于操作C |
线程启动规则 | Thread对象的start()方法先行发生于此线程的每个一个动作 |
线程中断规则 | 对线程interrupt()方法的调用happens-before于被中断线程的代码检测到中断事件的发生 |
线程终结规则 | 线程中所有的操作都先行发生于线程的终止检测 |
对象终结规则 | 一个对象的初始化完成先行发生于他的finalize()方法的开始 |
happens-before规则实际上通过禁止重排序实现。
下面通过volatile说明happens-before规则:
public class Example {
int a;
int b;
volatile int c;
public void write() {
a = 1; //A1
b = 2; //A2
c = 3; //A3
}
public void read() {
int d = c; //B1
int e = a; //B2
int f = b; //B3
}
}
假设线程A执行write方法,线程B执行read方法。
1.根据volatile变量规则,如果 A3先于B1执行,则A3的结果对B1可见
2.根据程序次序规则,A1,A2 happens-before A3,B1 happens-before B1, B2
3.根据传递规则,A1,A2 happens-before A3,A3 happens-before B1,B1 happens-before B1,B2
因此,A1, A2 -> A3 -> B1 -> B2,B3。
显然,根据happens-before规则,A1与A3,A2与A3之间禁止重排序,而A1与A2之间可以重排序。
内存屏障
内存屏障是特殊的指令,用于防止特定的处理器重排序。
由于CPU缓存的存在,仅仅禁止编译器级别的重排序,无法保证内存可见性。
如图,A1操作先于A2发起,但A2先于A3完成。于是在另一个处理器看来,原本的先写后读,变成了先读后写。
为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2; |
保证Load1先于Load2完成 |
LoadStore | Load; LoadStore; Store |
保证Load先于Store完成 |
StoreStore | Store1; StoreStore; Store2; |
保证Store1先于Store2完成 |
StoreLoad | Store; StoreLoad; Load; |
保证Store先于Load完成 |
StoreLoad是开销最大的屏障,也是所有处理器都支持的屏障。
happens-before与内存屏障指令
happens-before通过禁止重排序完成,包括禁止编译重排序,以及插入内存屏障禁止某些处理器重排序
以volatile为例:
public class Example {
int a;
volatile flag;
void write() {
a = 1;
//StoreStore,禁止上面的普通写,和下面的volatile写重排序
flag = true;
//StoreLoad 禁止下面的普通读,和上面的volatile写重排序
}
void read() {
if (flag) {
//LoadLoad禁止下面的普通读,和上面的volatile读重排序
//LoadStore禁止下面的普通写,和上面的volatile读重排序
int b = a;
}
}
}
volatile与synchronized
根据happens-before规则,volatile与synchronized在内存可见性的语义上是一致的。
volatile保证可见性。
synchronized保证可见性和原子性。
final
JSR133增强了final的内存语义:
1.在构造函数种,对对象final域的写入,先于将对象引用暴露给外部。
2. 在构造函数种,对final引用的对象的成员域的写入,先于将对象的引用暴露给外部。
目的是保证:当一个对象的引用暴露给外部,其final域必定是初始化完成的
前提是:final引用不能从构造函数中“溢出”
看例子
public class Example {
public static class FinalMember {
int a;
}
public static FinalMember thisOut;
private final FinalMember finalMember;
public Example() {
finalMember = new FinalMember(); //1
finalMember.a = 1; //2
thisOut = finalMember; //3
// 第三步操作可能和 1,2重排序,于是final域提前暴露
}
public void read1() {
int b = finalMember.a; //由于final语义,值为1
}
public static void read2() {
if (thisOut != null) {
int b = thisOut.a; //a可能没初始化完,为0或1
}
}
}
}
参考资料
https://www.infoq.cn/article/java_memory_model
https://blog.csdn.net/javazejian/article/details/72772461