Java 并发编程之 happens-before 规则

1,035 阅读6分钟

这是我参与更文挑战的第13天,活动详情查看: 更文挑战

本文正在参加「Java主题月 - Java 开发实战」,详情查看 活动链接

我是陈皮,一个在互联网 Coding 的 ITer,微信搜索「陈皮的JavaLib」第一时间阅读最新文章,回复【资料】,即可获得我精心整理的技术资料,电子书籍,一线大厂面试资料和优秀简历模板。

引言

happens-before 字面意思就是先行发生,你可以理解为 A happens before B,就是 A 发生在 B 之前。

happens-before(HB) 是在 JMM 中的一个很重要的规则,即一个操作的结果对于另一个操作是可见的,用来指定两个操作之间的执行顺序。

那为什么要有这个规则呢?其一是为了解决多线程的共享数据的可见性问题;其二是为了解决一些指令重排序问题,JMM 对编译器和处理器指令重排序的约束原则。唯有如此,才能保证我们写的程序按我们预想的方式执行,得到想要的结果。

假设程序有两个操作 A 和 B,B 操作需要 A 操作的结果。这两个操作可以在在同一线程中完成,或者在两个不同的线程中完成,happens-before 能向我们保证 A 操作的结果对 B 操作是可见的。A 和 B 之间存在 happens-before 关系。

JMM

JMM 即 Java 内存模型,它是对共享内存的并发模型。我们知道,Java 中的共享变量是存储在主内存中的,而线程有自己的工作内存,如果一个线程要操作一个共享变量,它会将共享变量赋值一份到自己的工作内存,进行操作后,再将最新的变量值回写到主内存中。

image-20210622222202303.png

假设有线程 a 对共享变量 x 读取进行更新后及时回写到主内存,然后线程 b 再读取共享变量 x 的值进行操作,这是正常没问题的。但是如果线程 a 对共享变量 x 更新后没有及时回写到主内存,这时线程 b 读取到共享变量 x 的值进行操作,这就出现脏读的现象。

要处理以上并发脏读的问题也简单,可以使用同步机制控制多线程之间操作的顺序,例如使用 synchronzied 关键字;或者使用 volatitle 关键字强制将线程更新后的最新值回写到主内存,以便其他线程可见。这都是 JMM 中 HB 的体现。

指令重排序

我们知道,JVM 会对我们写的代码进行优化,其中一个优化点就是编译器和处理器对指令进行重排序。虽然这些优化能提高程序性能,但是有可能会出现优化后执行的结果不是我们预想的结果,所以需要 happens-before 规则来禁止一些编译优化的场景,保证并发编程的正确性。

JMM 并不是全部禁止指令重排序,对于不会改变程序执行结果的重排序,JMM 允许编译器和处理器这样做。而对于会改变程序执行结果的重排序,JMM 会禁止这种重排序。

以我们最熟悉的 double-check 懒汉式单例模式为例,网上会告知如下程序是没问题的。

package com.chenpi;

/**
 * @Description
 * @Author Mr.nobody
 * @Date 2021/6/22
 * @Version 1.0
 */
public class Singleton {

    private static Singleton singleton = null;

    private Singleton() {}

    public static Singleton getInstance() {
        if (null == singleton) {
            synchronized (Singleton.class) {
                if (null == singleton) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

如果分析底层的话,以下一行代码并不是原子操作。在 JVM 指令执行来看,一般会有如下步骤:

  1. 分配内存给对象
  2. 初始化对象,即生成实例
  3. 将分配的内存地址赋值给对象,此时对象不为null

如果按上面的步骤执行的话,并发情况不会有问题。但是因为由于 JVM 编译优化的影响,有可能导致2和3步骤颠倒位置,即发生指令重排序。如果执行了第3步骤而还没执行第2步骤,在并发情况下,如果有另一个线程判断此时变量不为null,则返回没有初始化的对象进行使用,就有可能会出现报错。

singleton = new Singleton();

解决上述的问题也简单,用 volatitle 关键字修饰 singleton 变量即可,这也是 happens-before 规则之一。

private static volatile Singleton singleton = null;

为何要有 happens-before

其实 happens-before 更像一个发挥中间层的作用。向我们程序员保证一些操作之间的执行顺序,例如 A happens-before B,就保证 A 一定先行发生于 B。它让我们能以简单易懂的方式去写代码,而不用去学习底层比较复杂的例如内存模型,指令重排序等知识。其二是对编译器和处理器做一些约束,它们可以做任何代码优化,但是得保证优化后不能改变原有程序的执行结果,这里主要指单线程程序和正确同步的多线程程序,不然禁止它们优化。

何为理解在 happens-before 规则的情况下,也允许编译器和处理器进行优化呢?例如程序有一个加锁的操作,但是编译器分析发现这个锁只能被单线程访问,那其实就没必要加锁操作了,就可以优化消除这个锁。这样能提高程序的执行效率,还不会改变原有的执行结果。

happens-before 规则

  1. 程序次序规则:同一个线程内的一段代码的执行顺序是有序的,即前面的操作 happens-before 后面的操作。但还是有可能发生指令重排序,不过重排序后的结果还是跟顺序执行的结果一致。
  2. 管程锁定规则:对一个锁的解锁操作 happens-before 后续对这个锁的加锁操作。即后续的加锁操作能够感知到前面解锁的变化,synchronized 就是管程的实现。
  3. volatile 变量规则:对 valatile 修饰的变量的更新操作 happens-before 后续对此变量的任意操作。可以了解下内存屏障和缓存一致性协议。
  4. 传递性规则:A happens-before B,B happens-before C,则 A happens-before C。学过离散数学的都知道偏序关系,偏序关系具有传递性。
  5. 线程启动规则:在主线程启动子线程,那么主线程启动子线程之前的操作对于子线程是可见的。即 start() happens-before 子线程中的操作。
  6. 线程终止规则:在主线程执行过程中,子线程终止,那么子线程终止之前的操作在主线程中是可见。例如在主线程中执行子线程的 join 方法等待子线程完成,当子线程执行完毕后,主线程可以看到子线程的所有操作。
  7. 线程中断规则:对线程 interrupt 方法的调用 happens-before 被中断线程代码检测到中断事件。
  8. 对象终结规则:一个对象的构造函数执行的结束 happens-before 它的 finalize()方法。