jmm 指令重排 缓存可见性 Volatile 内存屏障
编译器 指令重排序操作,即生成的机器指令与代码指令顺序不一致
as-if-serial语义
As-if-serial语义的意思是,所有的动作(Action)5都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。 比如,为了保证这一语义,重排序不会发生在有数据依赖的操作之中。
int a = 1;
int b = 2;
int c = a + b;
将上面的代码编译成Java字节码或生成机器指令,可视为展开成了以下几步动作(实际可能会省略或添加某些步骤)。
对a赋值1
对b赋值2
取a的值
取b的值
将取到两个值相加后存入c
在上面5个动作中,动作1可能会和动作2、4重排序,动作2可能会和动作1、3重排序,动作3可能会和动作2、4重排序,动作4可能会和1、3重排序。但动作1和动作3、5不能重排序。动作2和动作4、5不能重排序。因为它们之间存在数据依赖关系,一旦重排,as-if-serial语义便无法保证。
为保证as-if-serial语义,Java异常处理机制也会为重排序做一些特殊处理。例如在下面的代码中,y = 0 / 0可能会被重排序在x = 2之前执行,为了保证最终不致于输出x = 1的错误结果,JIT在重排序时会在catch语句中插入错误代偿代码,将x赋值为2,将程序恢复到发生异常时应有的状态。这种做法的确将异常捕捉的逻辑变得复杂了,但是JIT的优化的原则是,尽力优化正常运行下的代码逻辑,哪怕以catch块逻辑变得复杂为代价,毕竟,进入catch块内是一种“异常”情况的表现。6
public class Reordering {
public static void main(String[] args) {
int x, y;
x = 1;
try {
x = 2;
y = 0 / 0;
} catch (Exception e) {
} finally {
System.out.println("x = " + x);
}
}
}
内存访问重排序与内存可见性
计算机系统中,为了尽可能地避免处理器访问主内存的时间开销,处理器大多会利用缓存(cache)以提高性能。
在这种模型下会存在一个现象,即缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步的。这导致在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。从程序的视角来看,就是在同一个时间点,各个线程所看到的共享变量的值可能是不一致的。
有的观点会将这种现象也视为重排序的一种,命名为“内存系统重排序”。因为这种内存可见性问题造成的结果就好像是内存访问指令发生了重排序一样。
内存访问重排序与Java内存模型JMM
Java的目标是成为一门平台无关性的语言,即Write once, run anywhere. 但是不同硬件环境下指令重排序的规则不尽相同。例如,x86下运行正常的Java程序在IA64下就可能得到非预期的运行结果。为此,JSR-1337制定了Java内存模型(Java Memory Model, JMM),旨在提供一个统一的可参考的规范,屏蔽平台差异性。从Java 5开始,Java内存模型成为Java语言规范的一部分。
根据Java内存模型中的规定,可以总结出以下几条happens-before规则。Happens-before的前后两个操作不会被重排序且后者对前者的内存可见。
程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
监视器锁法则:对一个监视器锁的解锁 happens-before于每一个后续对同一监视器锁的加锁。
volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作。
线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C
Happens-before关系只是对Java内存模型的一种近似性的描述,它并不够严谨,但便于日常程序开发参考使用,关于更严谨的Java内存模型的定义和描述,请阅读JSR-133原文或Java语言规范章节17.4。
除此之外,Java内存模型对volatile和final的语义做了扩展。对volatile语义的扩展保证了volatile变量在一些情况下不会重排序,volatile的64位变量double和long的读取和赋值操作都是原子的。对final语义的扩展保证一个对象的构建方法结束前,所有final成员变量都必须完成初始化(的前提是没有this引用溢出)。
Java内存模型关于重排序的规定,总结后如下表所示:
表中“第二项操作”的含义是指,第一项操作之后的所有指定操作。如,普通读不能与其之后的所有volatile写重排序。另外,JMM也规定了上述volatile和同步块的规则尽适用于存在多线程访问的情景。例如,若编译器(这里的编译器也包括JIT,下同)证明了一个volatile变量只能被单线程访问,那么就可能会把它做为普通变量来处理。
留白的单元格代表允许在不违反Java基本语义的情况下重排序。例如,编译器不会对对同一内存地址的读和写操作重排序,但是允许对不同地址的读和写操作重排序。
除此之外,为了保证final的新增语义。JSR-133对于final变量的重排序也做了限制。
构建方法内部的final成员变量的存储,并且,假如final成员变量本身是一个引用的话,这个final成员变量可以引用到的一切存储操作,都不能与构建方法外的将当期构建对象赋值于多线程共享变量的存储操作重排序。例如对于如下语句:
x.finalField = v; … ;构建方法边界sharedRef = x; v.afield = 1; x.finalField = v; … ; 构建方法边界sharedRef = x;
这两条语句中,构建方法边界前后的指令都不能重排序。
初始读取共享对象与初始读取该共享对象的final成员变量之间不能重排序。例如对于如下语句:
x = sharedRef; … ; i = x.finalField;
前后两句语句之间不会发生重排序。由于这两句语句有数据依赖关系,编译器本身就不会对它们重排序,但确实有一些处理器会对这种情况重排序,因此特别制定了这一规则。
https://tech.meituan.com/2014/09/23/java-memory-reordering.html
CPU指令重排
CPU指令重排是指CPU为了提高指令执行效率,可能会对指令的执行顺序进行优化,使得(单线程下)指令的实际执行顺序与代码中的顺序不同,但结果是一致的。这种优化是通过乱序执行和缓存读写重排来实现的。
乱序执行指的是CPU可以在不影响最终结果的前提下,通过并行执行、延迟执行等方式改变指令的执行顺序,从而提高执行效率。而缓存读写重排指的是CPU会先进行缓存读写操作,而不是直接对内存进行读写,这也会导致多个CPU缓存的可见性问题。
CPU是先操作自己的缓存,然后更新到内存中,其他CPU在从内存中获取更新的数据,更新到自己的缓存中,由于内存是隔一段时间刷新一次从缓存中获取最新数据而不是实时的,所以产生缓存一致性问题,也就是CPU自己的缓存对其他CPU不可见。这就是CPU缓存可见性问题。
CPU指令重排可能会导致多线程程序出现一些难以排查和修复的问题,例如线程安全问题和死锁等。为了避免这种问题的发生,可以使用volatile关键字来禁止指令重排。volatile关键字可以保证指令的有序执行,从而避免多线程程序中的可见性问题。
原文链接:https://blog.csdn.net/weixin_46906359/article/details/129781463
如何解决CPU指令重排 缓存可见性 问题?
CPU提供了两个内存屏障指令(Memory Barrier)用于解决上述两个问题:
写内存屏障(写屏障)
在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其它线程可见。强制写入主内幕才能,这种显示调用,CPU就不会因为性能考虑而去对指令重排。
读内存屏障(读屏障)
在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存中加载,让CPU与主内存保持一致,避免了缓存导致的一致性问题。
volatile通过什么方式禁止指令重排序?
Volatile通过内存屏障可以禁止指令重排序,内存屏障是一个CPU的指令,它可以保证特定操作的执行顺序。
内存屏障分为四种:
StoreStore屏障、StoreLoad屏障、LoadLoad屏障、LoadStore屏障。
JMM针对编译器制定了Volatile重排序的规则。
光看这些理论可能不容易懂,下面我就用通俗的话语来解释一下:
首先是对四种内存屏障的理解,Store相当于是写屏障,Load相当于是读屏障。
比如有两行代码,a=1;x=b;并且我把 a 和 b 修饰为 volatile。
执行 a=1 时,它相当于执行了一次 volatile 写操作;
执行 x=b 时,它相当于先执行 volatile 读取 b,再执行普通写 x 等于 b;
因此在这两行命令之间,就会插入一个 StoreLoad 屏障(前面是写后面也是写),这就是内存屏障。
第一个操作是 volatile 写操作,第二个操作是 volatile 读操作,那么规则中对应的值就是 NO,禁止重排序。这就是 Volatile 进行指令重排序的原理。
现在,只需要把上面代码的 a 和 b 用 volatile 修饰,就不会发生指令重排序了。
链接:https://juejin.cn/post/6901283327160877063
来源:稀土掘金