Java 内存模型之重排序

http://www.iocoder.cn/JUC/sike/jmm-1/

在执行程序时,为了提高性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件

  1. 单线程环境下,不能改变程序运行的结果
  2. 存在数据依赖关系的情况下,不允许重排序。

如果看过 LZ 上篇博客的就会知道,其实这两点可以归结于一点:无法通过 happens-before 原则推导出来的,JMM 允许任意的排序

1. as-if-serial 语义

as-if-serial 语义的意思是:所有的操作均可以为了优化而被重排序,但是你必须要保证重排序后执行的结果不能被改变,编译器、runtime、处理器都必须遵守 as-if-serial 语义。注意,as-if-serial 只保证单线程环境,多线程环境下无效。

下面我们用一个简单的示例来说明:

int a = 1 ;      // A
int b = 2 ;      // B
int c = a + b;   // C

A、B、C 三个操作存在如下关系:A、B 不存在数据依赖关系,A和C、B和C存在数据依赖关系,因此在进行重排序的时候,A、B 可以随意排序,但是必须位于 C 的前面,执行顺序可以是 A –> B –> C 或者 B –> A –> C 。但是无论是何种执行顺序最终的结果 C 总是等于 3 。

as-if-serail 语义把单线程程序保护起来了,它可以保证在重排序的前提下程序的最终结果始终都是一致的

其实,对于上段代码,他们存在这样的 happen-before 关系:

  1. A happens-before B
  2. B happens-before C
  3. A happens-before C

1、2 是程序顺序次序规则,3 是传递性。但是,不是说通过重排序,B 可能会排在 A 之前执行么,为何还会存在存在 A happens-before B 呢?这里再次申明 A happens-before B 不是 A 一定会在 B 之前执行,而是 A 的执行结果对 B 可见,但是相对于这个程序 A 的执行结果不需要对 B 可见,且他们重排序后不会影响结果,所以 JMM 不会认为这种重排序非法。

我们需要明白这点:在不改变程序执行结果的前提下,尽可能提高程序的运行效率

下面我们在看一段有意思的代码:

public class RecordExample1 {

    public static void main(String[] args){
        int a = 1;
        int b = 2;

        try {
            a = 3;           // A
            b = 1 / 0;       // B
        } catch (Exception e) {

        } finally {
            System.out.println("a = " + a);
        }
    }
    
}

按照重排序的规则,操作 A 与操作 B 有可能会进行重排序,如果重排序了,B 会抛出异常( / by zero),此时A语句一定会执行不到,那么 a 还会等于 3 么?如果按照 as-if-serial 原则它就改变了程序的结果。
其实,JVM 对异常做了一种特殊的处理,为了保证 as-if-serial 语义,Java 异常处理机制对重排序做了一种特殊的处理:JIT 在重排序时,会在catch 语句中插入错误代偿代码(a = 3),这样做虽然会导致 catch 里面的逻辑变得复杂,但是 JIT 优化原则是:尽可能地优化程序正常运行下的逻辑,哪怕以 catch 块逻辑变得复杂为代价

2. 重排序对多线程的影响

在单线程环境下,由于 as-if-serial 语义,重排序无法影响最终的结果,但是对于多线程环境呢?

如下代码(volatile的经典用法):

public class RecordExample2 {
    
    int a = 0;
    boolean flag = false;

    /**
     * A线程执行
     */
    public void writer() {
        a = 1;                  // 1
        flag = true;            // 2
    }

    /**
     * B线程执行
     */
    public void read(){
        if (flag) {                 // 3
           int i = a + a;          // 4
        }
    }

}

A 线程执行 #writer(),线程 B 执行 #read(),线程 B 在执行时能否读到 a = 1 呢?答案是不一定(注:x86 CPU 不支持写写重排序,如果是在 x86 上面操作,这个一定会是 a = 1 , LZ 搞了好久都没有测试出来,最后查资料才发现)。

由于操作 1 和操作 2 之间没有数据依赖性,所以可以进行重排序处理。
操作 3 和操作 4 之间也没有数据依赖性,他们亦可以进行重排序,但是操作 3 和操作 4 之间存在控制依赖性
假如操作1 和操作2 之间重排序:

重排序例子

  • 按照这种执行顺序线程 B 肯定读不到线程 A 设置的 a 值,在这里多线程的语义就已经被重排序破坏了。

实际上,操作 3 和操作 4 之间也可以重排序,虽然他们之间存在一个控制依赖的关系,只有操作 3 成立操作 4 才会执行。当代码中存在控制依赖性时,会影响指令序列的执行的并行度,所以编译器和处理器会采用猜测执行来克服控制依赖对并行度的影响。假如操作 3 和操作 4 重排序了,操作 4 先执行,则会把计算结果临时保存到重排序缓冲中,当操作 3 为真时,才会将计算结果写入变量 i 中。

通过上面的分析,重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义

参考资料

  1. 周志明:《深入理解 Java 虚拟机》
  2. 方腾飞:《Java 并发编程的艺术》的 「3. Java 内存模型」 章节。

666. 彩蛋

整理本小节,简单脑图如下:脑图

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值