编程笔记Kotlin 并发编程艺术禅与计算机程序设计艺术

Java内存模型简析:重排序/happends-before/内

2019-08-13  本文已影响2人  路过的猪

目录

  1. 从CPU到Java内存模型
    1.1 从CPU内存模型说起
    1.2 Java内存模型的引入
  2. 优化带来的重排序
    2.1 编译器优化重排序
    2.2 指令级并行重排序
    2.3 缓存优化重排序
  3. happends-before
    3.1 happends-before与允许的重排序
    3.2 完整的happens-before规则
  4. volatile与内存屏障
    4.1 volatile语义
    4.2 使用内存屏障阻止重排序

1. 从CPU到Java内存模型

1.1 从CPU内存模型说起

CPU主要用于指令的执行和运算,程序运行时的数据主要保存在内存(RAM)中;因为CPU的速度远快于主存,为了提高CPU的利用率(减少读写内存时的空等情况),会在CPU和内存之间加入一层或多层高速缓存。

缓存的引入极大地提高了CPU性能,但是也不可避免地引入了缓存一致性的问题。在CPU层面,其内存模型主要确定了:当前CPU写入数据后,其他CPU什么时候能够看到

有些CPU有很强的内存模型(strong memory model),能够让所有的CPU在任何时候、任何指定的内存地址上,都可以看到完全相同的值。而一些CPU则有较弱的内存模型(weaker memory model),在这种CPU中,必须使用内存屏障(一种特殊的指令)来刷新本地CPU缓存并使本地CPU缓存无效,目的是为了让当前CPU能够看到其他CPU的写操作,或者让其他CPU能看到当前CPU的写操作。内存屏障在高级语言中对程序员是不可见的。

1.2 Java内存模型的引入

为了屏蔽底层CPU的内存模型的各种差异化,Java抽象出了一个自己的内存模型,Java内存模型(Java Memory Model, JMM)确定了在多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。
Java内存模型主要确定了:当前线程写入数据后,其他线程什么时候能够看到

Java内存模型

值得注意的是,这里关注的不再是CPU,而是Java线程,每个线程会有自己的工作内存(本地内存),从主内存(共享内存/堆内存)读取数据后,会拷贝一份至工作内存,下一次读的时候,会通过工作内存进行读取。至于工作内存的具体实现,可以是CPU中的高速缓存,也可能是其他一些缓存介质。Java程序员并不需要操心这些,Java内存模型保证了代码在各种硬件和编译器的优化的情况下,依然能够被正确执行。

什么叫正确执行呢?比如synchronized同步块如果只有一个线程能够进入这叫正确执行,如果两个线程进入,那就不叫正确执行了,因为不符合synchronized在JMM中规定的语义了。
JMM给synchronized、volatile、线程开启等赋予了特定的语义(在代码中代表的含义),底层的实现必须满足这些语义。

2. 优化带来的重排序

如果出现重排序:前面的写挪到了读后面了,自然是“看不见”这个操作了。Java内存模型往往通过确定操作的前后顺序来保证一些特定的操作可见(写完能被其他线程看见);本节先来看看Java程序中可能涉及到重排序。

Java代码执行到最终被可见,其过程主要包括:

  1. Java代码被前端编译器编译为class字节码;
  2. 解析执行一定次数后,会被JIT编译器编译为本地机器码(汇编指令);
  3. CPU将指令加载至寄存器,进行解码指令执行;
  4. 执行结果可以被其他CPU可见,这里涉及到CPU本身的缓存一致性处理方式。

而上面中的每一步,都有可能会涉及重排序,主要包含:

  1. 编译器(前后端编译)优化导致的重排序;
  2. CPU并行执行指令导致的重排序;
  3. CPU缓存(一致性)优化,使得其他CPU对操作不可见导致的重排序。

以上优化的前提是必须遵守as-if-serial(就好像是串行):即不管如何重排序,(单线程)程序的执行结果都不能改变

2.1. 编译器优化重排序

比如在某个线程中的程序代码:

a = 1; // 指令1
b = 1; // 指令2
c = a + b; // 指令3

如果编译后没有做任何优化,指令顺序为:

原指令顺序

因为指令1和指令2没有依赖关系,编译器可能会根据一定的优化规则,对这两个指令进行重排序,比如编译后的指令顺序可以为2-1-3:

编译器重排序

这里顺便一提,重排序只是编译器优化的一种表现,其他一些优化可能使得程序执行的行为显得格外诡异。就好比以下这段经典的死循环代码:

public class SubThread extends Thread {
   private  static boolean flag = false;

   public static void main(String[] args) throws Exception {
       new SubThread().start(); // 开始执行子线程的循环
       Thread.sleep(100); // 保证循环代码运行次数足够多,触发JIT编译
       flag = true;
   }

   public void run() {
       while (!flag) { }; // 死循环,尽管flag变为true也不会停止
   }

}

这段代码执行后可能会进入死循环,这并不是什么可见性/重排序等导致的问题,而是编译器优化导致的。编译器会认为这段循环代码在(单线程)运行中,flag变量不会被改变,从而优化为:

    // 原指令:while (!flag) { }; 
   
   // 优化后的指令:
   if(flag) { // 只读一次
     while (true) { }; // 死循环
   }

防止出现死循环的方式有很多,比如:
(1)flag声明为volatile变量;
(2)循环体中增加打印语句(打印方法中会包含synchronized);
(3)循环体中增加Thread.yeild()让出时间片等会引起线程切换方法;

以上动作就像是Java代码给编译器的“暗示”:flag的变量是有可能会变化的,从而编译器不会再进行类似优化。
因为JMM规定读volatile变量和进入同步块前会清空工作内存(保证可见性,具体参考);线程切换(上下文切换)也会导致CPU高速缓存被清空。

除此之外,也可以禁用JIT编译器(-Xint或-Djava.compiler=NONE),禁用后JVM每次都会解释执行,也不会出现死循环的情况。

2.2 指令级并行重排序

为了提高指令执行速度,现代处理器采用了指令级并行技术(Instruction-Level Parallism,ILP)来将多条指令重叠执行。

指令级并行重排序

2.3 缓存优化重排序

我们来看一个多核CPU中指令的执行情况:

// 初始化 a=0, flag=false
// CPU-0 执行
a = 1; 
flag = true;

// CPU-1 执行
while ( !flag ) continue; // 未初始化成功,进行空等
assert a == 1

由于对缓存的优化,有时候会出现assert failed(即a != 1)的情况。
a = 1虽然比flag = true先执行(执行后CPU-0看到的值都是最新的);然而flag有可能先于a更早地刷入主存,从而被其他CPU看到。CPU-1读到最新的flag = true后,while循环结束后,如果此时a对cpu-1尚未可见,会读到内存中的旧值0

这时候会出现一种有意思的现象:
从CPU-0的角度看,执行顺序为:a = 1 > flag = true
从CPU-1的角度看,执行顺序为:flag = true > a = 1(因为flag最新值先可见);这种不及时可见导致的重排序也有人称“伪重排序”。

现代的CPU的缓存一致性多是这种最终一致性(允许短时间内不一致)。上例中a=1始终会被其他cpu所见,只不过是时间问题而已。
如果需要保证其可见性(写完后即刻可见),则需要在特定位置加入内存屏障指令(Memory barrier),对应到Java程序则是需要加上volatile、synchronized等的同步手段。

为什么叫“缓存优化”呢?在一般的缓存一致性协议中(MSI、MESI等),其协议通常是强一致性的。但是有时候这种强一致性不是必要的,即使没有强一致也不会影响(单线程)执行结果,所以很多CPU缓存往往会引入Store Buffer、Invalidate Queue等手段进行优化(类似缓存的缓存),从而导致了这种可见性的问题。具体可以参考:Why Memory Barriers.

3. happends-before

3.1 happends-before与允许的重排序

JMM规范(JSR-133)对happends-before的简单定义是这样的:

If one action happens-before another, then the first is visible to and ordered before the second.
如果一个操作(action) happens-before 另一个操作,则第一个对第二个可见,且第一个排在第二个之前。

简言之,A happens-before B,A先执行,B后执行,A禁止重排序到B后面。
然而JSR-133中也屡次提到,两个操作之间存在 happens-before 关系并不意味着这些操作必须以这种顺序发生;如果说优化(重排序)后,不影响执行结果(和原来没重排序的结果一样),那么这种优化重排序是允许的

我们这里先看一种不允许优化的情况:当两个操作存在happens-before 关系的,且存在冲突访问(Conflicting Accesses )时,禁止重排序

冲突访问指的是:两个操作访问同一个共享变量,且这两个操作中有一个为写操作时,就被认为存在冲突访问(或者叫数据依赖性)。这种情况下,重排序往往会破坏原来的执行结果。
比如:(1)a = 1; (2)b = 1; (3)c = a + b;,(1)和(2)允许重排序,因为没有冲突访问;但是(1)和(3)不允许重排序,因为(1)写a,(3)读a,这两者存在冲突访问,如果重排序后,结果就不为c == 2了。

JMM定义的happens-before规则不少,我们先来看一个简单的:
【规则1】程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作
简单理解就是:代码都是从上往下执行的,前面的代码 happens-before 后面的代码。

举个例子:

public class RecordDemo {
    static int a = 0;
    static boolean flag = false; 

    // 线程A执行write()
    static void write() {
        a = 1; 
        flag = true;  
    }

    // 线程B执行read()
    static void read() {
        if (flag) { 
            int i = a; 
            ...
        }
    }
}

线程A:a = 1 happens-before flag = true
线程B:if(flag) happens-before i = a

值得注意的是,线程A和线程B的操作之间是没有任何happens-before关系的。而因为 happens-before 有时候是允许重排序优化,这就有可能会出现一些诡异的情况:i == 0

“按道理讲”,如果能够进入if块,说明flag == true了,那么线程A中的a = 1肯定执行了;
然而虽然a = 1 happens-before flag = truea = 1flag = true却是不存在冲突访问的,JMM允许两者进行重排序,此时整体的执行序列可能为:

这时候就得到 i == 0 这种诡异的结果了。
如果期望进入if块后读到的a一定是1的话(这个才是符合我们日常认识的),那么就必须引入同步手段了,在此之前,我们先来看看happens-before的另外两条规则:
【规则2】volatile变量规则:对一个volatile变量的写,happens-before 于任意后续对这个volatile变量的读;
【规则3】传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C;

我们引入一个传说中轻量级同步的volatile,将flag声明为volatile变量:

public class RecordDemo {
    static int a = 0;
    static volatile boolean flag = false; 
    ...其他不变
}

根据【规则2】如果线程A先执行flag=true,线程B再执行if(flag) (读flag),此时 flag=true happens-before if(flag)
再看【规则1】a = 1 happens-before flag = trueif(flag) happens-before i = a
结合【规则3】推断出:a = 1 happen-before i = a;这两者时存在冲突访问的,禁止重排序,也就是说,i = a -> a = 1 这样的顺序是禁止的。自然也就不会出现 i == 0这种诡异的情形了。

【规则2】就如同一座桥梁将两个线程之间的happen-before关系给连接起来,synchronized也有类似的规则。

当然如果线程B先执行if(flag),线程A再执行flag=true,此时不满足【规则2】,两个线程之间又没什么关系了;此时 i == 0 这种诡异的情况也不会发生(因为if块压根不会进入了),这个是我们“期待”的。

JMM就是通过制定这样的happens-before规则,来保证加入同步手段后(比如volatile/synchronized/final等)能确定多个线程之间的同步顺序;
在没有类似同步手段/访问冲突的限制时,又允许其底层进行各种各样的优化,尽量提高Java程序的效率。

3.2 完整的happens-before规则

完整的happen-before规则包含了:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作;
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁(synchronized);
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读;
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C;
  5. start规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作;
  6. join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回;
  7. 中断规则:线程A调用线程B的interrupt() happens-before 线程A发现B被中断(B抛出异常或者A通过isInterrupted() / interrupted()检测到B被中断)
  8. 终结规则:一个对象的构造函数的结束happends-before于这个对象finalizer的开始。

看着挺多规则,但是这些都是为了执行结果能够“符合我们日常认识”,没有必要刻意去记,了解volatile/synchroized/final/Thread.start()/join()等代表的意思就足够了,这些规则大都是为了这些语义服务的。

happens-before 内存模型在JSR-133中被称为“Java内存模型的近似模型”,单纯 happens-before 内存模型可能会导致一些因果关系错乱的问题。完整的Java内存模型其实是 happens-before 内存模型的加强版,个人认为其加强的目的主要还是为了代码执行顺序能够“符合日常认识”。由于完整的Java内存模型过于复杂,个人水平有限,就不再细谈了。

4. volatile与内存屏障

如果说happens-before是用来(理论上)描述JMM规定的先后顺序的话,那么内存屏障可能就是真正的实现者了。

4.1 volatile语义

我们先来看看volatile的语义:

  1. 可见性,读到的值永远都是最新的(写完之后能即刻被后续的读看到):
    1.1 volatile写后,会把该线程的工作内存刷新到主内存;
    1.2 volatile读前,会将该线程的工作内存置为无效,从而从主内存读;
  2. 禁止重排序,当存在两个操作涉及到volatile时:
    2.1 第一个操作为volatile写,第二个为volatile读时,禁止重排序;
    2.2 第一个操作为volatile读,第二个为普通变量的读/写时,禁止重排序;
    2.3 第二个操作为volatile写,第一个为普通变量的读/写时,禁止重排序;

这里的语义1其实也可以归为“重排序”(具体参考本文#2.3),本节主要讨论volatile禁止重排序的情况。语义2.1 其实就是本文 #3.1 中涉及的例子,语义2.2和2.3是volatile在JDK1.5之后增强的语义,目的是消除类似DCL单例读到[未完全初始化对象]的“违背常理”的情况。

4.2 使用内存屏障阻止重排序

编译器会根据volatile/synchronized/final等的语义,在特定的位置插入内存屏障。当遇到特定的内存屏障指令时,处理器将禁止其对应的重排序,保证屏障前面的操作可以被后面的操作可见。

根据前后两个(读写)操作的组合,一共可以细为四种内存屏障,插入屏障后,会禁止前一个操作重排序到后一个的后面

操作 内存屏障 内存含义(可见性)
读读 Load1, LoadLoad, Load2 确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入
写写 Store1, StoreStore, Store2 确保Store1的数据在Store2以及后续Store指令操作相关
数据之前对其它处理器可见(例如向主存刷新数据)
读写 Load1, LoadStore, Store2 确保Load1的数据在Store2和后续Store指令被刷新之前读取
写读 Store1, StoreLoad, Load2 确保Store1的数据在被Load2和后续的Load指令读取之前
对其他处理器可见

有了这些屏障,我们要满足volatile/Synchronized/final等的内存语义就简单了,比如“2.1 第一个操作为volatile写,第二个为volatile读时,禁止重排序”,我们只需要 在volatile写和volatie读之间插入一个 StoreLoad 屏障即可。
其他的也类似,JMM完整的屏障插入要求如下表:

注:MonitorEnter/Exit对应于synchronized同步块的进入和退出

以volatile为例,下图展示的是一个保守的策略,实际上可以做一些优化,比如两个一样的屏障只需保留一个;分析程序发现volatile写前不会出现普通读的话,LoadStore屏障可以移除等:

volatile涉及的内存屏障

这四种屏障对应的底层实现,不同的处理器架构(指令集)处理是不一样的。比如在一些非常强的处理器内存模型中,可能压根就不会有处理器重排序优化,自然用不上这些屏障;但是稍弱一些的内存模型,则需要通过相应的内存屏障指令告诉处理器哪些是需要禁止的,哪些是允许优化的。

以我们常用的 x86-64 架构来说,其内存模型是比较强的,读读/写写/读写均不会出现重排序优化,但是写读(Store, Load)是存在重排序优化的(参考Intel® 64 Architecture Memory Ordering #2),如果需要禁止这种重排序,则需要插入StoreLoad屏障,具体实现指令可以为mfencelock addl等。


参考资料:
[1] JSR-133中文版
[2] JSR 133 (Java Memory Model) FAQ
[3] The JSR-133 Cookbook for Compiler Writers
[4] 译:Why Memory Barriers.
[5] 计算机科学速成课 P1-P9
[6] 《Java并发编程的艺术》

上一篇下一篇

猜你喜欢

热点阅读