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

@author: stormma
@date 2018/03/30


生命不息,奋斗不止

前言

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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也会对指令进行重排序。

什么时候不进行指令重排序

那么什么时候不禁止指令重排序或者怎么禁止指令重排序呢?不然一切都乱套了。

数据依赖性

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

1
2
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语义。举个栗子:

1
2
3
x = 1; //1
y = 1; //2
ans = x + y; //3

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

1
2
3
4
5
6
public int add() {
int x = 1;
int y = 1;
int ans = x + y;
return ans
}

对应的字节码

1
2
3
4
5
6
7
8
9
10
11
12
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操作和2、4可能会重排序, 2操作和1、3ch重排序, 操作3可能和2、4重排序, 操作4可能和1、3重排序。对应上面的赋值x和赋值y有可能会进行重排序, 对, 这并不难以理解, 因为写x和写y并没有明确的数据依赖关系。但是操作1和3和5并不能重排序, 因为3依赖1, 5依赖3, 同理操作2、4、5也不能进行重排序。

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

1
2
3
4
5
6
7
8
9
10
11
12
@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单例模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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了, 当然不是安全的。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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的时候, 禁止了指令重排序, 所以能得到期望的情况。

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

1
2
3
4
5
6
7
8
9
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的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中, 这个屏障是个万能屏障, 兼具其它三种内存屏障的功能。