我们知道,重排序的目的是在不改变程序执行结果的前提下,提高编译器和处理器对程序的执行性能。但是,重排序不是任意的,所谓无规矩不成方圆。理解重排序就需要知道重排序必须遵守的规则,总结起来就是我们今天要说的Happens-Before规则。在JSR-133: JavaTM Memory Model and Thread Specification中有相关描述,原版英文请见pdf文件,下载了一份供大家学习。
一. Happens-Before规则
Happens-Before规则规定了哪些情况下指令不能进行重排序。
- 程序顺序原则:一个线程内,代码执行的过程必须保证语义的串行性( as-if-serial,看起来是串行的;另外如果程序内数据存在依赖,也不允许进行重排序 )。
- 监视器锁规则:解锁unlock必然发生在加锁lock前。
- 传递性规则:如果操作A先于操作B,操作B先于操作C,那么操作A必先于操作C。
- volatile规则:一个共享变量的写操作,必须先于读操作,这是volatile可见性语义的要求。
- 线程的start规则:线程的start操作先于线程内其他任何操作。
- 线程的join规则:如果线程ThreadA中执行了ThreadB.join()方法,那么ThreadB的所有操作先于ThreadA中ThreadB.join()返回后的操作。
二. 对于Happens-Before规则解释
1. as-if-serial语义
看起来像串行的--编译器和处理器对重排序的机制对程序员是透明的,但是我们观察到的结果跟按照编写程序的顺序是一致的,这就是看起来像的含义。
举一例说明:
int a = 2; // A int b = 3; // B int c = a*b; // C
在上述程序中,步骤C依赖于步骤A和B,但是步骤A和B之间没有依赖关系,依赖关系图长这样:
根据程序的顺序执行规则,由于C依赖于A和B,那么C的执行顺序不能排在A和B之前,但是A和B的顺序是可以互换的,也就是说,我们按照程序顺序执行的语义,看到的执行顺序是这样:A-->B-->C,但是编译器和处理器可能进行重排序成这样子:B-->A-->C,但是这个过程对我们来说是透明的,但是最终结果跟我们想要的是一样的。这就是看起来像 as-if-serial的语义。
2.锁规则
在并发编程中,锁保证了临界区的互斥访问,同时还可以让释放锁的线程向另一个线程发送消息。
我们举一例,先来段代码。
public class MonitorDemo { int a = 0; public synchronized void writer() { // 1 a++; // 2 } // 3 public synchronized void reader() { // 4 int i = a; // 5 …… } // 6 }
比如现在有两个线程A和B,线程A执行writer方法,线程B随后执行reader方法,根据happens-before原则,我们来梳理下这个过程包含的happens-before关系。
①依据程序顺序执行顺序原则,1-->2-->3;4-->5-->6
②根据监视器锁规则,锁的获取先于锁的释放,那么在A线程未执行完writer时,线程B是无法得到锁的。因此3-->4.
③根据传递性规则,那我们可以得到2-->5.
最后我们得到的happens-before关系图是这样子的:
这也就是说,当线程B获取到线程A释放的锁后,线程A操作过的共享变量的内容对B是可见的(线程A的步骤2改变了a的值,线程B的步骤5获得了同一把锁后立刻可以得到a的最新值)。
这里我们也对在并发编程中锁的语义进行总结:
-
线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
-
线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前共享变量所做修改的)消息。
-
线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
3.volatile规则
3.1 volatile语义
在并发编程中,单个volatile变量的读、写可以看成是使用同一把锁对单个变量读写操作进行了同步锁操作。
例如,线程A和线程B执行下列代码,线程A执行set()方法,线程B随后执行get()方法。使用volatile
变量和对普通变量进行操作加锁的执行效果是一致的。
下面两个代码执行效果是等价的:
public class VolatileDemo { volatile long vl = 0L; // 使用volatile声明64位的long型变量 public void set(long l) { vl = l; //1. 单个volatile变量的写 } public void getAndIncrement () { vl++; // 复合(多个)volatile变量的读/写 } public long get() { return vl; //2. 单个volatile变量的读 } } public class VolatileDemo2 { long vl = 0L; // 64位的long型普通变量 public synchronized void set(long l) { // 对单个的普通变量的写加同步锁 vl = l; } public void getAndIncrement () { // 普通方法调用 long temp = get(); // 调用已同步的读方法 temp += 1L; // 普通写操作 set(temp); // 调用已同步的写方法 } public synchronized long get() { // 对单个的普通变量的读加同步锁 return vl; } }换句话说,volatile变量的写与锁的获取有相同的内存语义,volatile变量的读与锁的释放有相同的内存语义,这也就证明了对单个volatile变量的读写操作是原子性的,但是对volatile变量进行复合操作不具有原子性的,这个一定要注意。
我们来梳理下使用volatile变量的happens-before关系图,可能对理解更有帮助。
来个例子,线程A和线程B执行下列代码,线程A执行set()方法,线程B随后执行get()方法:
public class VolatileDemo { volatile long vl = 0L; // 使用volatile声明64位的long型变量 public void set(long l) {//1 vl = l; // 2 } public long get() {//3 return vl; // 4 } }
①根据程序顺序执行原则,1-->2,;3-->4
②根据volatile规则,volatile变量的写先于读,所以2-->3
③根据传递性规则,1-->4
所以,我们最后得到的happens-before关系图是这样的:
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
- 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
3.2 volatile语义的实现
我们先来看看编译器制定的volatile重排序规则表。
当第一个操作是volatile读时,不管第二个操作是什么都不能重排序。
当第一个操作是volatile写时,第二个操作为volatile读、写时不能重排序。
为了实现volatile的语义,编译器在编译代码时候,会生成对应的内存屏障指令,来禁止特定类型操作的处理器重排序。JMM采用保守(认为每个都必须这么做)的内存屏障插入策略来实现volatile语义:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
举一例子体会下:
public class VolatileBarrierDemo { int c; volatile int a = 1; volatile int b = 2; void readAndWrite() { int i = a; // 第一个volatile读 int j = b; // 第二个volatile读 c = i + j; // 普通写 a = i + 1; // 第一个volatile写 b = j * 2; // 第二个 volatile写 } }
最后生成的指令执行示意图如下(红色部分的屏障可以省略掉,因为紧跟着的操作跨越不了已有的屏障):