关于java内存访问重排序的思考

前言

且看一段测试代码, 在不借助外界工具的条件下得出你本身的答案。java

import java.util.*;
import java.util.concurrent.CountDownLatch;

public class Reordering {
    static int a = 0;
    static int b = 0;
    static int x = 0;
    static int y = 0;
    static final Set<Map<Integer, Integer>> ans = new HashSet<>(4);
    public void help() throws InterruptedException {
        final CountDownLatch latch = new CountDownLatch(2);
        Thread threadOne = new Thread(() -> {
            a = 1;
            x = b;
            latch.countDown();
        });

        Thread threadTwo = new Thread(() -> {
           b = 1;
           y = a;
           latch.countDown();
        });
        threadOne.start();
        threadTwo.start();
        latch.await();
        Map<Integer, Integer> map = new HashMap<>();
        map.put(x, y);
        if (!ans.contains(map)) {
            ans.add(map);
        }
    }

    @Test
    public void testReordering() throws InterruptedException {
      for (int i = 0; i < 20000 && ans.size() != 4; i++) {
          help();
          a = x = b = y = 0;
      }
      help();
      System.out.println(ans);
    }
}
复制代码

你的结果ans多是[{0=>1}, {1=>1}, {1=>0}], 由于线程调度是随机的, 有可能一个线程执行了, 另一个线程才得到cpu的执行权, 又或者是两个线程交叠执行, 这种状况下ans的答案无疑是上面三种结果, 至于上面三种结果对应的线程执行顺序, 我这里就不模拟了, 这不是重点。可是其实ans除了上面的三种结果以外, 还有另一种结果{0=>0}, 这是为何呢? 要想出现{0=>0}这种结果无非就是:安全

  1. threadOne先执行x = b = > x = 0;
  2. threadTwo执行b = 1, y = a => y = 0
  3. threadOne执行a = 1。 或者把threadOne和two的角色互换一下。 你或许很疑问为啥会出现x = b happens before a = 1呢? 这其实就是指令重排序。

指令重排序

大多数现代微处理器都会采用将指令乱序执行的方法, 在条件容许的状况下, 直接运行当前有能力当即执行的后续指令, 避开获取下一条指令所需数据时形成的等待。经过乱序执行的技术, 处理器能够大大提升执行效率。除了cpu会对指令重排序来优化性能以外, java JIT也会对指令进行重排序。app

何时不进行指令重排序

那么何时不由止指令重排序或者怎么禁止指令重排序呢?否则一切都乱套了。函数

数据依赖性

其一, 有数据依赖关系的指令不会进行指令重排序! 什么意思呢?工具

a = 1;
x = a;
复制代码

就像上面两条指令, x依赖于a, 因此x = a这条指令不会重排序到a = 1这条指令的前面。性能

有数据依赖关系分为如下三种:测试

  1. 写后读, 就像上面咱们举的那个例子a = 1x = a, 这就是典型的写后读, 这种不会进行指令重排序。
  2. 写后写, 如a = 1a = 2, 这种也不会进行重排序。
  3. 还有最后一种数据依赖关系, 就是读后写, 如x = aa = 1

as-if-serial语义

什么是as-if-serial? as-if-serial语义就是: 无论怎么重排序(编译器和处理器为了提升并行度), 单线程程序的执行结果不能被改变。因此编译器和cpu进行指令重排序时候回遵照as-if-serial语义。举个栗子:优化

x = 1;   //1
y = 1;   //2
ans = x + y;  //3
复制代码

上面三条指令, 指令1和指令2没有数据依赖关系, 指令3依赖指令1和指令2。根据上面咱们讲的重排序不会改变咱们的数据依赖关系, 依据这个结论, 咱们能够确信指令3是不会重排序于指令1和指令2的前面。咱们看一下上面上条指令编译成字节码文件以后:this

public int add() {
  int x = 1;
  int y = 1;
  int ans = x + y;
  return ans
}
复制代码

对应的字节码spa

public int add();
    Code:
       0: iconst_1     // 将int型数值1入操做数栈
       1: istore_1     // 将操做数栈顶数值写到局部变量表的第2个变量(由于非静态方法会传入this, this就是第一个变量)
       2: iconst_1     // 将int型数值1入操做数栈
       3: istore_2     // 将将操做数栈顶数值写到局部变量表的第3个变量
       4: iload_1      // 将第2个变量的值入操做数栈
       5: iload_2      // 将第三个变量的值入操做数栈
       6: iadd         // 操做数栈顶元素和栈顶下一个元素作int型add操做, 并将结果压入栈
       7: istore_3     // 将栈顶的数值存入第四个变量
       8: iload_3      // 将第四个变量入栈
       9: ireturn      // 返回
复制代码

以上的字节码咱们只关心0->7行, 以上8行指令咱们能够分为:

  1. 写x
  2. 写y
  3. 读x
  4. 读y
  5. 加法操做写回ans

上面的5个操做, 1操做和二、4可能会重排序, 2操做和一、3ch重排序, 操做3可能和二、4重排序, 操做4可能和一、3重排序。对应上面的赋值x和赋值y有可能会进行重排序, 对, 这并不难以理解, 由于写x和写y并无明确的数据依赖关系。可是操做1和3和5并不能重排序, 由于3依赖1, 5依赖3, 同理操做二、四、5也不能进行重排序。

因此为了保证数据依赖性不被破坏, 重排序要遵照as-if-serial语义。

@Test
    public void testReordering2() {
        int x = 1;
        try {
            x = 2;     //A
            y = 2 / 0;  //B
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            System.out.println(x);
        }
    }
复制代码

上面这段代码A和B是有可能重排序的, 由于x和y并无数据依赖关系, 而且也没有特殊的语义作限制。可是若是发生B happens-before A的话, 此时是否是就打印了错误的x的值, 其实否则: 为了保证as-if-serial语义, Java异常处理机制对重排序作了一种特殊的处理: JIT在重排序时会在catch语句中插入错误代偿代码(即重排序到B后面的A), 这样作虽然会致使catch里面的逻辑变得复杂, 可是JIT优化原则是: 尽量地优化程序正常运行下的逻辑, 哪怕以catch块逻辑变得复杂为代价。

程序顺序原则

  1. 若是A happens-before B
  2. 若是B happens-before C 那么
  3. A happens-before C

这就是happens-before传递性

重排序与JMM

Java内存模型(Java Memory Model简称JMM)总结了如下8条规则, 保证符合如下8条规则, happens-before先后两个操做, 不会被重排序且后者对前者的内存可见。

  1. 程序次序法则: 线程中的每一个动做A都happens-before于该线程中的每个动做B, 其中, 在程序中, 全部的动做B都能出如今A以后。
  2. 监视器锁法则: 对一个监视器锁的解锁happens-before于每个后续对同一监视器锁的加锁。
  3. volatile变量法则: 对volatile域的写入操做happens-before于每个后续对同一个域的读写操做。
  4. 线程启动法则: 在一个线程里, 对Thread.start的调用会happens-before于每一个启动线程的动做。
  5. 线程终结法则: 线程中的任何动做都happens-before于其余线程检测到这个线程已经终结、或者从Thread.join调用中成功返回, 或Thread.isAlive返回false。
  6. 中断法则: 一个线程调用另外一个线程的interrupt happens-before于被中断的线程发现中断。
  7. 终结法则: 一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
  8. 传递性: 若是A happens-before于B, 且B happens-before于C, 则A happens-before于C。

指令重排序致使错误的double-check单例模式

有人确定写过下面的double-check单例模式

public class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
复制代码

可是这种double-check加锁的单例是正常的吗? No. 由于建立一个实例对象并非一个原子性的操做, 并且还可能发生重排序, 具体以下: 假定建立一个对象须要:

  1. 申请内存
  2. 初始化
  3. instance指向分配的那块内存

上面的2和3操做是有可能重排序的, 若是3重排序到2的前面, 这时候2操做尚未执行, instance已经不是null了, 固然不是安全的。

那么怎么防止这种指令重排序? 修改以下:

public class Singleton {
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
复制代码

volatile关键字有两个语义: 其一保证内存可见性, 这个语义咱们下次博客会讲到(其实就是一个线程修改会对另外一个线程可见, 若是不是volatile, 线程操做都是在TLAB有副本的, 修改了副本的值以后不即时刷新到主存, 其余线程是不可见的) 其二, 禁止指令重排序, 若是上面new的时候, 禁止了指令重排序, 因此能获得指望的状况。

题外话, 关于线程安全的单例, 每每能够采用静态内部类的形式来实现, 这种无疑是最合适的了。

public class Singleton {
    public static Singleton getInstance() {
        return Helper.instance;
    }

    static class Helper {
        private static final Singleton instance = new Singleton();
    }
}
复制代码

怎么禁止指令重排序

咱们以前一会容许重排序, 一会禁止重排序, 可是重排序禁止是怎么实现的呢? 是用内存屏障cpu指令来实现的, 顾名思义, 就是加个障碍, 不让你重排序。

内存屏障能够被分为如下几种类型:

  1. LoadLoad屏障: 对于这样的语句Load1; LoadLoad; Load2, 在Load2及后续读取操做要读取的数据被访问前, 保证Load1要读取的数据被读取完毕。
  2. StoreStore屏障: 对于这样的语句Store1; StoreStore; Store2, 在Store2及后续写入操做执行前, 保证Store1的写入操做对其它处理器可见。
  3. LoadStore屏障: 对于这样的语句Load1; LoadStore; Store2, 在Store2及后续写入操做被刷出前, 保证Load1要读取的数据被读取完毕。
  4. StoreLoad屏障: 对于这样的语句Store1; StoreLoad; Load2, 在Load2及后续全部读取操做执行前, 保证Store1的写入对全部处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中, 这个屏障是个万能屏障, 兼具其它三种内存屏障的功能。

原文连接

相关文章
相关标签/搜索