JUC并发编程系列详解篇十七(happens-before原则)

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C

A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

为什么要理解Happens-before原则

在前面几篇文章中提到可以用 volatile 和 synchronized 来保证有序性。除此之外,JVM 还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成,这就是Happens-before原则。

关于 Happens-before,《Java 并发编程的艺术》书中是这样介绍的:
happens-before是JMM最核心的概念。对应Java程序员来说,理解happens-before是理解JMM的关键。

关于Happens-before,《深入理解 Java 虚拟机 - 第 四 版》书中是这样介绍的:
Happens-before 是 JMM 的灵魂,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。

JMM的设计

从JMM设计者的角度,在设计JMM时,需要考虑两个关键因素:

  • 一方面,对于程序员来说,我们希望内存模型易于理解、易于编程,为此JMM 的设计者要为程序员提供足够强的内存可见性保证,专业术语称之为 “强内存模型”。
  • 而另一方面,编译器和处理器则希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化(比如重排序)来提高性能,因此 JMM 的设计者对编译器和处理器的限制要尽可能地放松,专业术语称之为 “弱内存模型”。

Happens-before的定义:

对于这个问题,从 JDK 5 开始,也就是在 JSR-133 内存模型中,终于给出了一套完美的解决方案,那就是 Happens-before 原则,Happens-before 直译为 “先行发生”,《JSR-133:Java Memory Model and Thread Specification》对 Happens-before 关系的定义如下

  • 如果一个操作 Happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 Happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 Happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 Happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)

关于Happens-before的解析:

第 1 条定义: 这是 JMM 对程序员强内存模型的承诺。从程序员的角度来说,可以这样理解 Happens-before 关系:如果 A Happens-before B,那么 JMM 将向程序员保证 — A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。注意,这只是 Java内存模型向程序员做出的保证!

需要注意的是,不同于 as-if-serial 语义只能作用在单线程,这里提到的两个操作 A 和 B 既可以是在一个线程之内,也可以是在不同线程之间。也就是说,Happens-before 提供跨线程的内存可见性保证。

举个例子:

// 以下操作在线程 A 中执行
i = 1; // a

// 以下操作在线程 B 中执行
j = i; // b

// 以下操作在线程 C 中执行
i = 2; // c

假设线程 A 中的操作 a Happens-before 线程 B 的操作 b,那我们就可以确定操作 b 执行后,变量 j 的值一定是等于 1。

得出这个结论的依据有两个:一是根据 Happens-before 原则,a 操作的结果对 b 可见,即 “i=1” 的结果可以被观察到;二是线程 C 还没运行,线程 A 操作结束之后没有其他线程会修改变量 i 的值。

现在再来考虑线程 C,我们依然保持 a Happens-before b ,而 c 出现在 a 和 b 的操作之间,但是 c 与 b 没有 Happens-before 关系,也就是说 b 并不一定能看到 c 的操作结果。那么 b 操作的结果也就是 j 的值就不确定了,可能是 1 也可能是 2,那这段代码就是线程不安全的。

第2条定义: 再来看 Happens-before 的第 2 条定义,这是 JMM 对编译器和处理器弱内存模型的保证,在给予充分的可操作空间下,对编译器和处理器的重排序进行一定的约束。也就是说,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

文字可能不是很好理解,我们举个例子,来解释下第 2 条定义:虽然两个操作之间存在 Happens-before 关系,但不意味着 Java 平台的具体实现必须要按照 Happens-before 关系指定的顺序来执行。

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C

上面计算圆的面积的示例代码存在3个happens-before关系,如下:

  • A happens-before B
  • B happens-before C
  • A happens-before C

可以看出来,在 3 个 Happens-before 关系中,第 2 个和第 3 个是必需的,但第 1 个是不必要的。因此,JMM把happens-before要求禁止的重排序分为了下面两类:

  • 会改变程序执行结果的重排序。
  • 不会改变程序执行结果的重排序。

JMM对这两种不同性质的重排序,采取了不同的策略,如下:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。

也就是说,虽然 A Happens-before B,但是 A 和 B 之间的重排序完全不会改变程序的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。

再来看下面这张 JMM 的设计图更直观:
在这里插入图片描述
从上面的分析可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

其实可以这么理解: 为了避免 Java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法,JMM 就出了这么一个简单易懂的 Happens-before 原则,一个 Happens-before 规则就对应于一个或多个编译器和处理器的重排序规则,这样,我们只需要弄明白 Happens-before 就行了:
在这里插入图片描述

Happens-before的8个规则

《JSR-133:Java Memory Model and Thread Specification》定义了如下 Happens-before 规则, 这些就是 JMM 中“天然的” Happens-before 关系,这些 Happens-before 关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,JVM 可以对它们随意地进行重排序:

程序次序规则(Program Order Rule)

在一个线程内,按照控制流顺序,书写在前面的操作先行发生(Happens-before)于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。这个非常好理解,就好比上面提到的例子:

double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C

上面计算圆的面积的示例代码存在3个happens-before关系,如下:

  • A happens-before B
  • B happens-before C
  • A happens-before C

管程锁定规则(Monitor Lock Rule)

一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是 “同一个锁”,而 “后面” 是指时间上的先后。

这个规则其实就是针对 synchronized 的。JVM 并没有把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作。这两个字节码指令反映到 Java 代码中就是同步块 — synchronized。举个例子:

synchronized (this) { // 此处自动加锁
 if (x < 1) {
        x = 1;
    }      
} // 此处自动解锁

根据管程锁定规则,假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 1,执行完自动释放锁,线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x == 1。

volatile 变量规则(Volatile Variable Rule)

对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的 “后面” 同样是指时间上的先后。这个规则就是 JDK 1.5 版本对 volatile 语义的增强,其意义之重大,靠着这个规则搞定可见性易如反掌。

public class ReorderExample {
    int a = 0;
    volatile boolean flag = false;
    public void writer() {
        a = 1; // 1
        flag = true; // 2
    }
    public void reader() {
        if (flag) { // 3
            int i = a * a; // 4
            // ......
        }
    }
}

假设线程 A 执行 writer() 方法之后,线程 B 执行 reader() 方法。

根据程序次序规则:1 Happens-before 2;3 Happens-before 4。

根据 volatile 变量规则:2 Happens-before 3。对一个volatile变量的读,总是能看到(任意线程)之前对这个volatile变量最后的写入。因此,volatile的这个特性可以保证实现volatile规则。

根据传递性规则:1 Happens-before 3;1 Happens-before 4。这里的传递性是由volatile的内存屏障插入策略和volatile的编译器重排序规则共同来保证的。

也就是说,如果线程 B 读到了 “flag==true” 或者 “int i = a” 那么线程 A 设置的“a=42”对线程 B 是可见的:
在这里插入图片描述

线程启动规则(Thread Start Rule)

Thread 对象的 start() 方法先行发生于此线程的每一个动作。假设线程A在执行的过程中,通过执行ThreadB.start()来启动线程B;同时,假设线程A在执行ThreadB.start()之前修改了一些共享变量,线程B在开始执行后会读这些共享变量。下图是对应的happens-before关系图:
在这里插入图片描述
根据程序规则:1 happens-before 2。
根据线程启动规则(start规则):2 happens-before 4。
根据传递性规则:1 happens-before 4。这实意味着,线程A在执行ThreadB.start()之前对共享变量所做的修改,接下来在线程B开始执行后都将确保对线程B可见。

线程终止规则(Thread Termination Rule)

线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread 对象的 join() 方法判断是否结束、Thread 对象的 isAlive() 的返回值等手段检测线程是否已经终止执行。

假设线程A在执行的过程中,通过执行ThreadB.join()来等待线程B终止;同时,假设线程B在终止之前修改了一些共享变量,线程A从ThreadB.join()返回后会读这些共享变量。下图是对应的happens-before关系图:
在这里插入图片描述
根据线程终止规则(join规则):2 happens-before 4。
根据程序顺序规则:4 happens-before 5。
根据传递性规则:2 happens-before 5。这意味着,线程A执行操作ThreadB.join()并成功返回后,线程B中的任意操作都将对线程A可见。

线程中断规则(Thread Interruption Rule)

对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread 对象的 interrupted() 方法检测到是否有中断发生。

对象终结规则(Finalizer Rule)

一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

传递性(Transitivity)

如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。

“时间上的先发生”与“先行发生”

上述 8 种规则中,还不断提到了时间上的先后,那么,“时间上的先发生” 与 “先行发生(Happens-before)” 到底有啥区别?

一个操作 “时间上的先发生” 是否就代表这个操作会是“先行发生” 呢?一个操作 “先行发生” 是否就能推导出这个操作必定是“时间上的先发生”呢?

很遗憾,这两个推论都是不成立的。下面举个例子:

private int value = 0;

// 线程 A 调用
pubilc void setValue(int value){    
    this.value = value;
}

// 线程 B 调用
public int getValue(){
    return value;
}

假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了 setValue(1),然后线程 B 调用了同一个对象的 getValue() ,那么线程 B 收到的返回值是什么?

下面根据上述 Happens-before 的 8 大规则依次分析一下:

由于两个方法分别由线程 A 和 B 调用,不在同一个线程中,所以程序次序规则在这里不适用;

由于没有 synchronized 同步块,自然就不会发生 lock 和 unlock 操作,所以管程锁定规则在这里不适用;

同样的,volatile 变量规则,线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。

因为没有一个适用的 Happens-before 规则,所以第 8 条规则传递性也无从谈起。

因此我们可以判定,尽管线程 A 在操作时间上来看是先于线程 B 的,但是并不能说 A Happens-before B,也就是 A 线程操作的结果 B 不一定能看到。所以,这段代码是线程不安全的。

想要修复这个问题也很简单?既然不满足 Happens-before 原则,那我修改下让它满足不就行了。比如说把 Getter/Setter 方法都用 synchronized 修饰,这样就可以套用管程锁定规则;再比如把 value 定义为 volatile 变量,这样就可以套用 volatile 变量规则等。

再来看一个例子:

// 以下操作在同一个线程中执行
int i = 1;
int j = 2;

假设这段代码中的两条赋值语句在同一个线程之中,那么根据程序次序规则,“int i = 1” 的操作先行发生(Happens-before)于 “int j = 2”,但是,还记得 Happens-before 的第 2 条定义吗?还记得上文说过 JMM 实际上是遵守这样的一条原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

所以,“int j=2” 这句代码完全可能优先被处理器执行,因为这并不影响程序的最终运行结果。

那么,这个例子,就论证了一个操作 “先行发生(Happens-before)” 不代表这个操作一定是“时间上的先发生”。

综上两例,我们可以得出这样一个结论:Happens-before 原则与时间先后顺序之间基本没有因果关系,所以我们在衡量并发安全问题的时候,尽量不要受时间顺序的干扰,一切必须以 Happens-before 原则为准。

Happens-before 与 as-if-serial

在上文中出现过几次这句话:JMM 其实是在遵循一个基本原则,即只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。

JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。

as-if-serial语义:

  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。

as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

文章参考

  • 《Java 并发编程的艺术》
  • 《深入理解 Java 虚拟机 - 第 四 版》
  • 微信公众号(飞天小牛肉)的精选文章 - JMM 最最最核心的概念:Happens-before 原则
  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值