JMM - 玩转 happens-before

要玩转 happens-before 咱们须要先简单介绍下几个基本概念java

高速缓存

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

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

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

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

Java 内存模型

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

什么是 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 的规则以下app

  • 程序顺序规则:一个线程中的每一个操做 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 读写这些屏障并不必定非得所有按照要求插入,编译器会进行优化发现不须要插入的时候就不会去插入内存屏障,可是它可以保证和咱们这种插入屏障方式获得同样的正确的结果。

好比咱们最经常使用的服务 Linux_x86 架构下它禁止了大量的重排序,它只会在 volatile 写后面插入一个 StoreLoad 屏障,而这个屏障就能保证 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 并发编程的艺术
相关文章
相关标签/搜索