JMM - 玩转 happens-before

要玩转 happens-before 我们需要先简单介绍下几个基本概念

高速缓存

随着 CPU 的快速发展它的计算速度和内存的读写速度差距越来越大,如果还是去读写内存的话那么 CPU 的处理速度就会收到内存读写速度的限制,为了弥补这种差距,为了保证 CPU 的快速处理就出现了高速缓存。

高速缓存特点是读写速度快,容量小,照价昂贵。

随着 CPU 的快速发展,所依赖的高速缓存的读写速度也在不断提升,为了满足更高的要求就发展出了工艺更好也更加快速的缓存,它的照价也更加昂贵。

对于 CPU 来说按照读写速度和紧密程度来说依次分为 L1(一级缓存)、L2(二级缓存)、L3(三级缓存)他们之间的处理速度依次递减,对于现代的计算机来说至少会存在一个 L1 缓存。

Java 内存模型

Java 线程之间的通信是由 Java 内存模型(JMM)来控制的,JMM 定义了多个线程之间的共享变量存储在主内存中,每个线程私有的数据则存储在线程的本地内存当中,本地内存中又存储了多线程共享变量在主内存中的副本(本地内存是一个虚拟的概念并不存在,指的是缓存区,寄存器等概念)。抽象模型图如下:

什么是 happens-before

happens-before 的概念最初是由 Leslie Lamport 在一篇影响深远的论文 (《Time,Clocks and thhe Ordering of Events in a Distributed System》)中提出。它用 happens-before 来描述分布式系统中事件的偏序关系。

从 JDK5 开始,Java 使用 JSR-133 内存模型,JSR-133 使用了 happens-before 的概念来为单线程或者多线程提供内存可见性保证。

happens-before 为程序员提供了多线程之间的内存可见性

happens-before 的规则如下

  • 程序顺序规则:一个线程中的每个操作 happens-before 该线程中任意的后续操作
  • 监视器锁规则:对一个线程的解锁 happens-before 于随后该线程或者其它线程对这个对象的加锁
  • volatile 变量规则:对一个 volatile 域的写 happens-before 于任意后续对这个 volatile 域的读
  • 传递性规则:如果 A happens-before B, B happens-before C 那么 A happens-before C

根据这个规则我们就能够保证线程之间的内存可见性,后面会详细分析,这里先将定义放出来

内存可见性

上面说了 happens-before 主要是为单线程或者多线程提供内存可见性保证,那么内存可见性又是什么呢,我们先看下下面的定义

堆内存是线程之间共享的,栈内存是线程私有的。堆内存中的数据存在内存可见性问题,栈内存不受内存可见性影响。

内存可见性:其实就是一种多线程能够看到的共享内存的数据状态,这个状态有可能是正确的也有可能是错误的(当然我们的目的就是为了保证内存可见性正确)。

下面我们来分析说明下什么时候会出现内存可见性问题(也就是在什么情况下,不正确的内存可见性状态会导致多线程程序访问错误)

高速缓存导致的内存可见性问题

我们知道每个 CPU 都有自己的高速缓存,那么在有多个 CPU 的计算机上,读写一个数据的时候,因为处理器会往高速缓存中写数据(对应的就是 JMM 中的线程私有内存),而高速缓存不会立马刷到内存中(JMM 抽象模型中的主内存),这样就会造成多个 CPU 之间的读写数据不一致,如下

class Test {
    int val = 0;
    void f() {
        val = val + 1;
        // ...
    }
}
复制代码

上图只是其中一种可能出错的状态,也有可能是正确的,多线程未同步就存在不确定性

  • T1 时刻,线程 A 运行,将主内存中 val = 0 装入私有的工作内存,然后再 T2 时刻处理器 + 1 处理完毕,T3 时刻写入了本地缓存中,T6 时刻才将本地缓存刷新到主内存中
  • T4 时刻,线程 B 运行,发现主内存还是 val = 0 (因为线程 A 还没有将数据刷如主内存),然后继续处理 + 1 后返回,线程 B 的工作内存中 val = 1
  • 最终刷入主内存中的数据 val = 1

可以看到程序员本意是使用 2 个线程对 val 分别执行 + 1 操作,想要得到的结果 val = 2 结果程序运行完毕得到的结果是 val = 1

指令重排序导致的内存可见性问题

我们先来看下什么是指令重排序

    void f() {
        int a = 1;
        int b = 2;
        int c = a + b;
    }
复制代码

经过编译器或者处理器重排序后,执行的顺序可能变为先执行 b = 2 后执行 a = 1 而 C 是不可能排在上面 2 步之前的,下面会说明。

指令重排序又分为编译器指令重排序、处理器指令重排序。

编译器和处理器为了提高指令运行的并行度而进行指令重排序,它们的目的都是为了加速程序的运行速度,但是无论怎么重排序都必须保证单线程最终的执行结果不能改变,但是如果是在多线程情况下就无法保证了,所以就有可能出现执行结果不正确的情况。

为了保证单线程程序最终的正确性,有一点可以确定的是如果操作之间存在依赖性,那么无论是编译器还是处理器都不允许对其进行重排序,这一点现在的编译器和处理都是实现了的。如下

    void f() {
        int a = 1;
        // 这个操作依赖上一步操作 a = 1,所以他们不会被重排序
        int b = a + 1;
    }
复制代码

那么指令重排序又是如何导致了内存可见性问题的呢?我们来看一个例子

class Test {
    private static Instance instance;
    
    public static Instance getInstance() {
        if (instance != null) {
            synchronized(Test.class) {
                if (instance != null) {
                    // 错在这里
                    instance = new Instance();
                }
            }
        }
        return instance;
    }
}
复制代码

这是一个常见双重检查锁定的单列模型(错误的),它错就错在指令重排序可能导致返回未被初始化的 instance,我们来分析下为什么。

instance = new Instance(); 在处理器执行的时候其实是拆解为了几步执行的,伪代码如下

// 步骤1 分配内存空间
memory = allocate();
// 步骤2 初始化对象
ctorInstance(memory);
// 步骤3 设置对象的内存地址
instance = memory;
复制代码

我们可以看到上面这 3 步骤在单线程的场景下对于步骤 2 和步骤 3这两部是没有依赖性的,我们可以先设置了它的地址再给他初始化对象内容也可以,所以可能会指令重排序如下:

// 步骤1 分配内存空间
memory = allocate();
// 步骤2 设置对象的内存地址
instance = memory;
// 步骤3 初始化对象
ctorInstance(memory);
复制代码

那么在多线程场景下,线程 A 执行到了步骤 2(还没有初始化),并且正好将工作内存刷新到了主内存中,那么线程 B 就看到了 instance,认为已经创建初始化完毕,就直接 return 了,就导致线程 B 可能拿到的是未被初始化的对象,那么后续使用的时候就会出现问题。

解决内存可见性问题

正是由于这些原因导致了内存可见性问题,在多线程的场景下可能会出现意外的情况,我们要正确得到正确的多线程程序执行的结果,那么我们就要保证内存可见性的正确性。

内存可见性的正确性保证主要是通过以下一些技术来实现的

  • volatile
    • 解决内存可见性和指令重排序问题
  • final
  • 监视器锁
    • 解决内存可见性问题
    • 锁之间的互斥访问
  • happen-before
    • 采用 happen-before 规则结合上述 3 种或者多种技术,来保证多线程程序执行的正确性。我们也可以人为的用这个规则和对应的代码推算出多线程程序是否存在产生的结果是否和单线程执行的结果一致(也就是可以推算是否能得到正确的结果)

volatile

当写一个 volatile 变量的时候,JMM 会把线程对应的本地内存中的共享变量值刷新到主内存中去。

volatile 两大特性

  • 可见性:对一个 volatile 的读,总是能够看到任意线程对这个 volatile 最后的写
  • 原子性:对任意单个 volatile 变量读/写具有原子性,例如对 64 位的 long、double 等

JMM 通过限制 volatile 读/写的重排序,针对编译器制定了如下 volatile 重排序规则

是否能重排序 第二个操作
第一个操作 普通读 / 写 volatile 读 volatile 写
普通读 / 写 NO
volatile 读 NO NO NO
volatile 写 NO NO

从表可以总结出:

  • 第二个操作为 volatile 写的时候,不论上一个操作是什么都不能重排序
  • 第二个操作为 volatile 读的时候,只有上一个操作为普通读写才能进行重排序
  • 当第二个操作为普通读写的时候,只有 volatile 读不能进行重排序

看完这几个规则脑子是不是有点晕,那是因为不知道为什么要这么做,我们先从一个方面去思考。

就是当写一个 volatile 变量的时候,会把线程对应的本地内存变量值刷新到内存中去,意味着如果 volatile 写之前有一个或者多个操作也写了共享变量,那么这个时候会将之前所有修改的共享变量全部刷新到主内存中去,这个特性是不是感觉特别重要!

看完后面的内容再来看这个表格就能沉底够理解为什么要这么做了。

我们现在再来看一下之前个单例错误的例子,是由于指令重排序导致的,但是我们把程序做如下更改就可以保证正确了

class Test {
    private static volatile Instance instance;
    
    public static Instance getInstance() {
        if (instance != null) {
            synchronized(Test.class) {
                if (instance != null) {
                    // 错在这里
                    instance = new Instance();
                }
            }
        }
        return instance;
    }
}
复制代码

可以看到加了个 volatile,加了它之后就能够保证下面这段带啊不能被重排序的了,意识就是只能以步骤 1 - > 2 - > 3 的顺序执行了,也就保证了这个单列模型的正确性了。

// 步骤1 分配内存空间
memory = allocate();
// 步骤2 初始化对象
ctorInstance(memory);
// 步骤3 设置对象的内存地址
instance = memory;
复制代码

那么编译器是如何实现这个规则的呢,也就说编译器是用什么技术实现的这样的重排序规则,来限制 volatile 的重排序的呢。

编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,由于插入最优屏障策略过于繁琐几乎难以做到,所以 JMM 采取保守策略插入内存屏障如下

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障

内存屏障解释如下

基于这个策略这个策略,就可以保证在任意处理器平台,任意程序都能得到正确的 volaile 内存语义了,看下图是 volatile 写的场景

StoreStore 能够保证上面所有的普通写在 volatile 写之前刷新到主内存中。

StoreLoad 如果上面这个 volatile 在方法末尾,它就很难确认调用它的方法是否有 volatile 读或者写所以,如果在方法末尾或者 volatile 写后面真的有 volatile 读写这两种情况下都会插入 StoreLoad 屏障。

总结个记忆方法

  • StoreStore 屏障:Store 有着存储的意思,StoreStore 意味着 2个都需要存储,Volatile 写前面的普通写要先一步刷入主内存,它自身也要刷入主内存这不就是 "Store 然后 Store" StoreStore 吗
  • StoreLoad 屏障:前一个是 Volatile 需要 Store,后续操作不确定可能是 Volatile 写或者读,假设是读,那么我们就插入一个 StoreLoad 屏障,防止上面的 volatile 写和下面的 volatile 读写重排序了

下面是 volatile 读的场景

总结个以及方法

  • LoadLoad 屏障:2 个 Load 意味着需要禁止 volatile 读和下面所有的普通读重排序
  • LoadStore 屏障:先 Load 后 Store 意味是需要禁止 volatile 读和下面所有的普通写

然后我们来看个代码用 volatile 和 happen-before 规则来分析一下

class Test {
    int num = 10;
    boolean volatile flag = false;
    
    void writer() {
        num = 100;     // 1
        flag = true;   // 2
    }
    
    void reader() {
        if (flag) {   // 3
            System.out.println(num);   // 4
        }
    }
}
复制代码

假设有线程 A 执行完了 writer 方法后,线程 B 执行去执行 reader 方法。(忘了规则的上面翻一下)

  • 程序顺序规则,因为是针对单线程的我们分别来看
    • 对于线程 A,1 happens-before 2
    • 对于线程 B,3 happens-before 4
  • volatile 变量规则,2 happens-before 3
    • 在 3 这里有一个 volatile 读,我们知道后面会插入 LoadLoad 和 LoadStore 屏障来保证下面的普通读写不会重排序到上面去
    • 这里就保证了 3 和 4 不会重排序,只有 flag = true 才能看到 num 的值
  • 传递性规则,1 happens-before 2,2 happens-before 3,3 happens-before 4,那么就能够得到 1 happens-before 4

最后强调一下就是,关于这些 volatile 读写这些屏障并不一定非得全部按照要求插入,编译器会进行优化发现不需要插入的时候就不会去插入内存屏障,但是它能够保证和我们这种插入屏障方式得到一样的正确的结果,这里就不展开了。

监视器锁

对于加锁了的代码块或者方法来说,他们是互斥执行的,一个线程释放了锁,另外一个线程获得了这个锁之后才能执行。

它有着和 volatile 相似的内存语义

当线程释放锁的时候会把该线程对应的本地内存共享变量刷新到主内存中去。

当线程获取锁的时候,JMM 会把当前线程对应的本地内存置位无效,从而使得被监视器保护的临界区的代码必须从新从主内存中获取共享变量。

我们来看一段代码

    int a = 0;
    
    public synchronized void writer() {  // 1
        a++;  // 2
    }  // 3
    
    public void synchronized reader() { // 4
        int i = a; // 5
    } // 6
复制代码

假设线程 A 执行了 writer() 方法后线程 B 执行了 reader() 方法,继续用 happens-before 来分析下

  • 程序顺序规则(由于监视器锁涉及到了临界区所以和上面的分析多了 2 步临界区的分析)
    • 线程 A,1 happens-before 2, 2 happens-before 3
    • 线程 B,4 happens-before 5,5 happens-before 6
  • 监视器锁规则,3 happens-before 4
    • 线程 A 释放锁的时候会把 a 刷新到主内存中去
    • 因为线程 B 在获取锁的时候,JMM 会把当前线程对应的本地内存置位无效,会从新去主内存中获取共享变量 a = 1
  • 传递性规则,1 happens-before 2, 2 happens-before 3,3 happens-before 4,4 happens-before 5,5 happens-before 6。最终得到 2 happens-before 5,所以 i 能够正确赋值。

顺序一致性模型

顺序一致性模型,JMM,在设计的时候就参考了顺序一致性模型。

我们来看下顺序一致性模型的定义

  • 一个线程中的所有操作必须按照顺序执行
  • 不管线程之间是否同步,所有线程都只能看到同一个执行顺序,并且每个操作都必须原子执行且立马对所有线程可见

第一点和 JMM 中的差别相信能很容易看出来,JMM 中是允许指令重排序的,他们的执行顺序有可能改变,只不过最终的得到的结果是一致的。

对于未同步的程序来说在顺序一致性模型中是这样的

顺序一致性模型要求对于未同步的模型必须达到这样的效果,这其实意义不大,为什么呢?因为就算达到了这种效果未同步的程序最终的结果也是不确定的。所以 JMM 从设计上来说并没有这么做。具体怎么做的我们之前已经经过详细的分析了。

而 JMM 对为同步的多线程最了最小化安全性,即线程看到的数据要么是默认值,要么是其它线程写入的值。

最后其实还有 final 的内存语义和 final 带来的内存可见性问题 由于篇幅太长了后面单独写。

每次看 <<Java 并发编程的艺术>> 都有不一样的感触,这次结合自己的思考写篇文章加深下自己的理解。

参考:

  • Java 并发编程的艺术

猜你喜欢

转载自juejin.im/post/5d92ad1be51d4577ec4eb955