关于 java 内存访问重排序的思考
前言
且看一段测试代码, 在不借助外界工具的条件下得出你自己的答案importjava.util.*;
importjava.util.concurrent.CountDownLatch;
publicclassReordering{
staticinta=0;
staticintb=0;
staticintx=0;
staticinty=0;
staticfinalSet>ans=newHashSet<>(4);
staticfinalCountDownLatchlatch=newCountDownLatch(2);
publicvoidhelp()throwsInterruptedException{
ThreadthreadOne=newThread(()->{
a=1;
x=b;
latch.countDown();
});
ThreadthreadTwo=newThread(()->{
b=1;
y=a;
latch.countDown();
});
threadOne.start();
threadTwo.start();
latch.await();
Mapmap=newHashMap<>();
map.put(x,y);
if(!ans.contains(map)){
ans.add(map);
}
}
@Test
publicvoidtestReordering()throwsInterruptedException{
for(inti=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} 这种结果无非就是:
threadOne 先执行 x = b => x = 0;
threadTwo 执行 b = 1, y = a => y = 0
threadOne 执行 a = 1 或者把 threadOne 和 two 的角色互换一下 你或许很疑问为啥会出现
x = b happens before a = 1
呢? 这其实就是指令重排序
指令重排序
大多数现代微处理器都会采用将指令乱序执行的方法, 在条件允许的情况下, 直接运行当前有能力立即执行的后续指令, 避开获取下一条指令所需数据时造成的等待通过乱序执行的技术, 处理器可以大大提高执行效率除了 cpu 会对指令重排序来优化性能之外, java JIT 也会对指令进行重排序
什么时候不进行指令重排序
那么什么时候不禁止指令重排序或者怎么禁止指令重排序呢? 不然一切都乱套了
数据依赖性
其一, 有数据依赖关系的指令不会进行指令重排序! 什么意思呢?
a = 1; x = a;
就像上面两条指令, x 依赖于 a, 所以 x = a 这条指令不会重排序到 a = 1 这条指令的前面
有数据依赖关系分为以下三种:
写后读, 就像上面我们举的那个例子 a = 1 和 x = a, 这就是典型的写后读, 这种不会进行指令重排序
写后写, 如 a = 1 和 a = 2, 这种也不会进行重排序
还有最后一种数据依赖关系, 就是读后写, 如 x = a 和 a = 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 的前面我们看一下上面上条指令编译成字节码文件之后:publicintadd(){
intx=1;
inty=1;
intans=x+y;
returnans
}
对应的字节码publicintadd();
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 行指令我们可以分为:
写 x
写 y
读 x
读 y
加法操作写回 ans
上面的 5 个操作, 1 操作和 24 可能会重排序, 2 操作和 13ch 重排序, 操作 3 可能和 24 重排序, 操作 4 可能和 13 重排序对应上面的赋值 x 和赋值 y 有可能会进行重排序, 对, 这并不难以理解, 因为写 x 和写 y 并没有明确的数据依赖关系但是操作 1 和 3 和 5 并不能重排序, 因为 3 依赖 1, 5 依赖 3, 同理操作 245 也不能进行重排序
所以为了保证数据依赖性不被破坏, 重排序要遵守 as-if-serial 语义@Test
publicvoidtestReordering2(){
intx=1;
try{
x=2;//A
y=2/0;//B
}catch(Exceptione){
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 块逻辑变得复杂为代价
程序顺序原则
如果 A happens-before B
如果 B happens-before C 那么
A happens-before C
这就是 happens-before 传递性
重排序与 JMM
Java 内存模型 (Java Memory Model 简称 JMM) 总结了以下 8 条规则, 保证符合以下 8 条规则, 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
指令重排序导致错误的 double-check 单例模式
有人肯定写过下面的 double-check 单例模式publicclassSingleton{
privatestaticSingletoninstance;
publicstaticSingletongetInstance(){
if(instance==null){
synchronized(Singleton.class){
if(instance==null){
instance=newSingleton();
}
}
}
returninstance;
}
}
但是这种 double-check 加锁的单例是正常的吗? No. 因为创建一个实例对象并不是一个原子性的操作, 而且还可能发生重排序, 具体如下: 假定创建一个对象需要:
申请内存
初始化
instance 指向分配的那块内存
上面的 2 和 3 操作是有可能重排序的, 如果 3 重排序到 2 的前面, 这时候 2 操作还没有执行, instance 已经不是 null 了, 当然不是安全的
那么怎么防止这种指令重排序? 修改如下:publicclassSingleton{
privatestaticvolatileSingletoninstance;
publicstaticSingletongetInstance(){
if(instance==null){
synchronized(Singleton.class){
if(instance==null){
instance=newSingleton();
}
}
}
returninstance;
}
}
volatile 关键字有两个语义: 其一保证内存可见性, 这个语义我们下次博客会讲到(其实就是一个线程修改会对另一个线程可见, 如果不是 volatile, 线程操作都是在 TLAB 有副本的, 修改了副本的值之后不即时刷新到主存, 其他线程是不可见的) 其二, 禁止指令重排序, 如果上面 new 的时候, 禁止了指令重排序, 所以能得到期望的情况
题外话, 关于线程安全的单例, 往往可以采用静态内部类的形式来实现, 这种无疑是最合适的了publicclassSingleton{
publicstaticSingletongetInstance(){
returnHelper.instance;
}
staticclassHelper{
privatestaticfinalSingletoninstance=newSingleton();
}
}
怎么禁止指令重排序
我们之前一会允许重排序, 一会禁止重排序, 但是重排序禁止是怎么实现的呢? 是用内存屏障 cpu 指令来实现的, 顾名思义, 就是加个障碍, 不让你重排序
内存屏障可以被分为以下几种类型:
LoadLoad 屏障: 对于这样的语句 Load1; LoadLoad; Load2, 在 Load2 及后续读取操作要读取的数据被访问前, 保证 Load1 要读取的数据被读取完毕
StoreStore 屏障: 对于这样的语句 Store1; StoreStore; Store2, 在 Store2 及后续写入操作执行前, 保证 Store1 的写入操作对其它处理器可见
LoadStore 屏障: 对于这样的语句 Load1; LoadStore; Store2, 在 Store2 及后续写入操作被刷出前, 保证 Load1 要读取的数据被读取完毕
StoreLoad 屏障: 对于这样的语句 Store1; StoreLoad; Load2, 在 Load2 及后续所有读取操作执行前, 保证 Store1 的写入对所有处理器可见它的开销是四种屏障中最大的在大多数处理器的实现中, 这个屏障是个万能屏障, 兼具其它三种内存屏障的功能
来源: https://juejin.im/post/5abe3c856fb9a028bf056ed9