写在文章开头
本文将着重从JMM
指令规范以及如何解决程序可见性和有序性两个问题为入口,为读者深入剖析JMM
内存模型,希望对你有帮助。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
详解指令重排序问题
什么是重排序问题
代码在执行过程从,计算机的不同层级为了提高最终指令执行效率,都可能会对执行响应重排序,以Java
程序为例,从编译到执行会经历:
生成指令阶段
:编译器重排,该阶段JMM
通过禁止特定类型的编译器重排序达到要求。处理器阶段
:处理器阶段存在指令并行重排序和内存系统加载重排序,这种处理器级别的重排序问题,则是要求编译器在生成指令阶段通过插入内存屏障即memory barriers
指令禁止特定方式重排序。

编译器重排序
编译器(包括 JVM、JIT 编译器等)
重排序即不影响单线程执行结果的情况下,会针对性的重排代码的效率以提高单线程情况下代码执行效率。当然这种重排序可能也会存在一些问题,假设我们现在有这样一段代码:
- 两个CPU核心加载到一段先初始化localNum
- 各自分别用用变量x、y读取读取对方的
localNum
的值
如下图所示:

极端情况,假设两个CPU都发生编译器重排序就可能出现CPU-0先执行x=lcalNum2,CPU-1执行y=lcalNum1,因为这两个本地变量初始化赋值指令被重排序,导致x、y最终被设置为0:

对于这种情况,JMM
会针对性发生这种重排序的编译器进行禁止来解决这种问题。
指令重排序
现代的处理器会对某些指令进行重叠执行(采用指令级并行技术(Instruction-Level Parallelism,ILP)
,亦或者在不影响执行结果的情况下会允许Java
字节码对应的机器码指令进行顺序调换以提高单线程下代码的执行效率,这种问题的表象和上述情况类似,这里也就不再演示了。
内存重排序
该方式排序并不是真正意义上的重排序,即处理器为了提升程序的处理效率,会将内存中的数据先加载到自己的cache line上,这使得并发场景下CPU本地内存数据可能与内存中的数据不一致的情况,在JMM
上常常表现为主存和本地内存的数据不一致。
如下图,两个CPU同时从内存中加载到x为0,然后cpu-0执行程序中的累加指令,在cpu-0未将指令下回内存时,就短暂的出现数据不一致的情况:

如何避免指令重排序
这一点其实在上述各种重排序都已经简单的说明了:
- 对于编译器,会禁止特定类型的编译器重排序来避免编译器重排序在多线程情况下带来的问题。
- 对于指令重排序即处理器重排序,
JVM
生成程序指令序列时,会根据情况插入特定的内存屏障(Memory Barrier)
来相关指令来告知处理器避免特定类型的指令重排序。
详解Java内存模型JMM
什么是JMM模型
为了屏蔽不同操作系统之间操作系统内存模型的差异,Java
定义了属于自己的内存模型规范 解决这个问题。 JMM
也可以理解为针对Java
并发编程的一组规范,抽象了线程和主内存之间的关系,以类似于volatile 、synchronized等关键字以解决并发场景下重排序带来的问题。
JMM
规定所有示例对象都必须放置在主存中,所以每个线程需要操作这些数据时就需要将数据拷贝一份到本地内存中在进行相应的操作。

而每个Java
将主存中拷贝的变量在完成操作后写回主存中会经历以下过程:
- lock:首先将变量锁住,将这个共享变量设置为线程独占变量。
- read:将主存的共享变量读取到本地内存中。
- load :将变量
load
拷贝一份到本地内存中生成共享变量的副本。 - use:将共享变量副本放到执行引擎中。
- assign:将共享变量副本赋值给本地内存的变量。
- store:将变量放到主内存中
- write:写入主内存对应变量中
- unlock:解锁,该共享变量此时就可以被其他线程操作了。

同时,JMM
模型还规定这些操作还得符合以下规范:
- 线程没有发任何
assign
操作的变量不可以写回主内存中。 - 新的变量只能在主内存中诞生。这就意味的线程中的变量必须是通过
load
从主存加载后再通过assign
得到的。 - 一个线程通过
lock
锁定主内存变量共享变量时,这个线程可以对其上无数次锁(即线程可重入)
,其他线程就不能在对其上锁了。 - 一个线程没有
lock
一个共享变量,就不能对其进行unlock
。 - 在执行
use
操作前,必须清空本地内存,通过load
或者assign
初始化变量值才可操作本地变量。
JVM和JMM有什么区别
JVM
规定了运行时的java程序的内存区域划分,例如实例对象必须放置在堆区等。
而JMM
则决定了线程
和和主内存
之间的关系,例如共享变量
必须存放在主内存
中。通过定义一系列规范和原则简化用户实现并发编程的种种操作且确保Java
代码从编译到转为CPU
机器码执行结果都是准确无误的,也就是说JMM
是一种内存模型语义的抽象并非实际的内存模型。
什么是happens-before原则?常见的happens-before原则有哪些?
happens-before
也是一种JMM内存模型用来阐述内存可见性的一种规约,对应的happens-before
原则共有8条,而常见的有以下5条:
程序顺序规则
:写前面的变量happens-before
于后面的代码。传递规则
:A happens-before B
,B happens-before C
,那么A happens-before C
。volatile 变量规则
:volatile
的变量的写操作,happens-before
后续读该变量的代码。线程启动规则
:Thread
的start
都有先于后面对于该线程的操作。解锁规则
:对一个锁的解锁操作happens-before
对这个锁的加锁操作。
对于不会影响单线程或者多线程指令重排序操作java编译器不做要求,即不会过分干预编译器和处理器的大部分优化操作,例如下面这段代码,在单线程情况下,因为两者声明没有任何关联,处理器为了提高程序执行的并行度完全可以允许其以任意顺序执行,这也就是我们常说的as-if-serial
,即没有强关联的指令,处理器可以根据自己的优化算法执行,任意重排序,对外结果好像就是串行执行一样:

而对于某些场景, JMM
对于编译器或处理的某些会影响指令重排序的操作进行禁止,如下所示,getOne
和getTwo
先于最后计算,计算依赖于前两个变量,操作即两个get
操作happens-before
于最后的计算,但是两个get
操作没有强关联,所以JVM
这两段代码进行指令重排序的时候,JMM
是允许的,所以执行时getTwo
可能会先于getOne
执行。
csharp
public static void main(String[] args) {
int one = getOne();//1
int two = getTwo();//2
System.out.println(one + two);//3
}
private static int getOne() {
return 1;
}
private static int getTwo() {
return 2;
}
与之相反就是最后的计算,因为依赖于前两个get
,所以JMM
模型是明确要求禁止这种情况,于是就提出了happens-before
原则,即写前面的变量happens-before
于后面的代码以及A happens-before B
,B happens-before C
,那么A happens-before C
,按照我们的例子就是每一个get操作都会按照顺序写,因为1操作先于2先于3,所以最终执行顺序就是1、2、3。
happens-before和JMM有什么关系
JMM
原则和禁止重排序的遵循的准则都是基于 happens-before
准则要求,也就是要求针对编译器的指令重排序必须根据该准则通过某种方式落实,最常见的方式就是在生成执行指令前插入内存屏障,避免处理器进行危险的指令重排序。 所以,程序员只需理解happens-before
原则的抽象即可理解可见性,由此避免去理解底层编译器和处理器的复杂实现:

JMM规范如何解决处理器指令重排序问题
为了保证内存可见性,编译器在生成指令指令序列时通过内存屏障指令来禁止特定类型的处理器重排序问题,对应的屏障指令有:
loadload
:先加载load1
先于后load2
的操作,保证load1读取的数据结果对于load2可见。loadstore
:load1
的操作先于后store
,保证store2
的操作可以看见load1
读取数据的最新结果。storestore
:store1写入操作先于store2,保证store1的写入操作结果对于store2可见。storeload
:先store
的操作对于后load
可见,即store操作变量的结果对于后续的load是可见的。
而本质上这些内存屏障在硬件层也就是Load Barrier和Store Barrier两个屏障,大体来说内存屏障的主要作用有:
- 组织屏障前后两个指令重排序。
- 强制把处理器高速缓冲区数据更新结果写回主内存,让其它处理器中缓存数据失效,这也就是大名鼎鼎的MESI协议。
对于Load Barrier而言,若在指令前插入Load Barrier,该屏障可读取数据时强制要求处理器将本地cache line设置为无效,直接从内存中读取数据:

而Store Barrier则是强制要求cpu cache line写入操作要直接从本地cache line强制刷新到内存中让其它核心中的cache line数据失效,而JMM规范就是基于这两个硬件屏障的多种组合保证了操作可见性:

对于java这门语言而言,内存屏障最经典的运用无非是volatile
关键字,可以看到下面这段代码,为了保证volatile变量的可见性,即:
- 在volatile写的前后分别加入了loadstore和storeload,保证读取依赖数据后在执行写入并更新至主存
- 在volatile变量读前后分别加入loadload和loadstore保证读取到正确的数据在执行后续的写,即后续的写入操作对于volatile变量可见
java
private static int normalData;
private static volatile boolean volatileData = false;// volatile确保StoreLoad语义
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
normalData = 1;
//插入loadload屏障,保证上述数据改变可见
volatileData = true;
//插入storeload屏障,保证上述数据写入改变可见
});
Thread thread2 = new Thread(() -> {
//插入loadload屏障,保证volatile读可见之前的读
while (!volatileData) {
//插入loadstore屏障,保证后续写可见volatile变量结果
}
System.out.println(normalData);
});
thread1.start();
thread2.start();
}
小结
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
参考
JMM(Java 内存模型)详解:javaguide.cn/java/concur...
《Java并发编程的艺术》
内存屏障:www.jianshu.com/p/2ab5e3d7e...
一文讲明白内存重排序:cloud.tencent.com/developer/a...
全知乎最详细的并发研究之CPU缓存一致性协议(MESI)有这一篇就够了:zhuanlan.zhihu.com/p/467782159