Java内存模型(JMM)

1,501 阅读20分钟

JMM 之 happens-before

在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。

happens-before 原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题。下面我们就一个简单的例子稍微了解下happens-before ;

i = 1; // 线程 A 执行
j = i;  //线程 B 执行

j 是否等于 1 呢?

假定线程 A 的操作(i = 1) happens-before 线程 B 的操作(j = i),那么可以确定,线程 B 执行后 j = 1 一定成立。

如果他们不存在 happens-before 原则,那么 j = 1 不一定成立。这就是happens-before原则的威力。

定义

happens-before 原则【定义】如下:

如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果,将对第二个操作可见,而且第一个操作的执行顺序,排在第二个操作之前。

两个操作之间存在 happens-before 关系,并不意味着一定要按照 happens-before 原则制定的顺序来执行。如果重排序之后的执行结果与按照 happens-before 关系来执行的结果一致,那么这种重排序并不非法

规则

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作,happens-before 于书写在后面的操作。
  2. 锁定规则:一个 unLock 操作,happens-before 于后面对同一个锁的 lock 操作。
  3. volatile 变量规则:对一个volatile变量的写操作,happens-before 于后面对这个变量的读操作。注意是后面的.
  4. 传递规则:如果操作 A happens-before 操作 B,而操作 B happens-before 操作C,则可以得出,操作 A happens-before 操作C
  5. 线程启动规则:Thread 对象的 start 方法,happens-before 此线程的每个一个动作。
  6. 线程中断规则:对线程 interrupt 方法的调用,happens-before 被中断线程的代码检测到中断事件的发生。
  7. 线程终结规则:线程中所有的操作,都 happens-before 线程的终止检测,我们可以通过Thread.join() 方法结束、Thread.isAlive() 的返回值手段,检测到线程已经终止执行。
  8. 对象终结规则:一个对象的初始化完成,happens-before 它的 finalize() 方法的开始

上面八条是原生 Java 满足 happens-before 关系的规则,但是我们可以对他们进行推导出其他满足 happens-before 的规则:

  1. 将一个元素放入一个线程安全的队列的操作,happens-before 从队列中取出这个元素的操作。
  2. 将一个元素放入一个线程安全容器的操作,happens-before 从容器中取出这个元素的操作。
  3. 在 CountDownLatch 上的 countDown 操作,happens-before CountDownLatch 上的 await 操作。
  4. 释放 Semaphore 上的 release 的操作,happens-before Semaphore 上的 acquire 操作。
  5. Future 表示的任务的所有操作,happens-before Future 上的 get 操作。
  6. 向 Executor 提交一个 Runnable 或 Callable 的操作,happens-before 任务开始执行操作。

这里再说一遍 happens-before 的概念:

  1. 如果两个操作不存在上述(前面8条 + 后面6条)任一一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。

  2. 如果操作 A happens-before 操作 B,那么操作A在内存上所做的操作对操作B都是可见的。

下面就用一个简单的例子,来描述下 happens-before 的原则:

private int i = 0;

public void write(int j ) {
	i = j;
}

public int read() {
	return i;
}

我们约定线程 A 执行 #write(int j),线程 B 执行 #read(),且线程 A 优先于线程 B 执行,那么线程 B 获得结果是什么?

就这段简单的代码,我们来基于 happens-before 的规则做一次分析:

由于两个方法是由不同的线程调用,所以肯定不满足程序次序规则。

两个方法都没有使用锁,所以不满足锁定规则。

变量 i 不是用volatile修饰的,所以 volatile 变量规则不满足。

传递规则肯定不满足。

规则 5、6、7、8 + 推导的 6 条可以忽略,因为他们和这段代码毫无关系。

所以,我们无法通过 happens-before 原则,推导出线程 A happens-before 线程 B 。

虽然,可以确认在时间上,线程 A 优先于线程 B 执行,但是就是无法确认线程B获得的结果是什么,所以这段代码不是线程安全的。

那么怎么修复这段代码呢?满足规则 2、3 任一即可。

happen-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。

happen-before 总结

JMM 之分析 volatile

我们知道volatile 的特性:

  1. volatile 可见性:对一个 volatile 的读,总可以看到对这个变量最终的写。
  2. volatile 原子性:volatile 对单个读 / 写具有原子性(32 位 Long、Double),但是复合操作除外,例如 i++ 。
  3. JVM 底层采用“内存屏障”来实现 volatile 语义。

下面 通过 happens-before 原则和 volatile 的内存语义,两个方向分析 volatile 。

volatile 与 happens-before

我们知道happens-before 是用来判断是否存在数据竞争、线程是否安全的主要依据,它保证了多线程环境下的可见性。下面我们就那个经典的例子,来分析 volatile 变量的读写,如何建立的 happens-before 关系。

public class VolatileTest {

    int i = 0;
    volatile boolean flag = false;

    // Thread A
    public void write(){
        i = 2;              // 1
        flag = true;        // 2
    }

    // Thread B
    public void read(){
        if(flag) {                                   // 3
            System.out.println("---i = " + i);      // 4
        }
    }
}

依据 happens-before 原则,就上面程序得到如下关系:

  1. 程序顺序原则:操作 1 happens-before 操作 2 ,操作 3 happens-before 操作 4 。

  2. volatile 原则:操作 2 happens-before 操作 3 。

    • 2 happens-before 操作 3的前提是2操作比 3操作要早
    • 如果1操作后面插入
      for (int j = 0; j < 100; j++) {
          System.out.println(13213123);
      }
      

    那么3会先执行完

  3. 传递性原则:操作 1 happens-before 操作 4 。

  4. 操作 1、操作 4 存在 happens-before 关系,那么操作 1 一定是对 操作 4 是可见的。

可能有人就会问,操作 1、操作 2 可能会发生重排序啊,会吗?volatile 除了保证可见性外,还有就是禁止重排序。所以 A 线程在写 volatile 变量之前所有可见的共享变量,在线程 B 读同一个 volatile 变量后,将立即变得对线程 B 可见。

volataile 的内存语义及其实现

  1. 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值,立即刷新到主内存中。
  2. 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

所以 volatile 的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。

那么 volatile 的内存语义是如何实现的呢?对于一般的变量则会被重排序,而对于 volatile 的变量则不能。这样会影响其内存语义,所以为了实现 volatile 的内存语义,JMM 会限制重排序。其重排序规则如下:

  1. 如果第一个操作为 volatile 读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile 读之后的操作,不会被编译器重排序到 volatile 读之前;
  2. 如果第二个操作为 volatile 写,则不管第一个操作是啥,都不能重排序。这个操作确保volatile 写之前的操作,不会被编译器重排序到 volatile 写之后;
  3. 当第一个操作 volatile 写,第二个操作为 volatile 读时,不能重排序。

volatile 的底层实现,是通过插入内存屏障。但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM 采用了保守策略。

策略如下:

  1. 在每一个 volatile 写操作前面,插入一个 StoreStore 屏障
  2. 在每一个 volatile 写操作后面,插入一个 StoreLoad 屏障
  3. 在每一个 volatile 读操作后面,插入一个 LoadLoad 屏障
  4. 在每一个 volatile 读操作后面,插入一个 LoadStore 屏障

原因如下:

StoreStore 屏障:保证在 volatile 写之前,其前面的所有普通写操作,都已经刷新到主内存中。
StoreLoad 屏障:避免 volatile 写,与后面可能有的 volatile 读 / 写操作重排序。
LoadLoad 屏障:禁止处理器把上面的 volatile读,与下面的普通读重排序。
LoadStore 屏障:禁止处理器把上面的 volatile读,与下面的普通写重排序。

案例 1:VolatileTest

下面我们就上面 VolatileTest 例子重新分析下:

public class VolatileTest {
    
    int i = 0;
    volatile boolean flag = false;
    
    public void write() {
        i = 2;
        flag = true;
    }

    public void read() {
        if (flag){
            System.out.println("---i = " + i);
        }
    }
    
}

内存屏障图例

案例 2:VolatileBarrierExample

volatile 的内存屏障插入策略非常保守,其实在实际中,只要不改变 volatile 写-读的内存语义,编译器可以根据具体情况优化,省略不必要的屏障。

public class VolatileBarrierExample {
    int a = 0;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite(){
        int i = v1;     //volatile读
        int j = v2;     //volatile读
        a = i + j;      //普通读
        v1 = i + 1;     //volatile写
        v2 = j * 2;     //volatile写
    }
}

没有优化的示例图如下:

我们来分析,上图有哪些内存屏障指令是多余的:

1:这个肯定要保留了

2:禁止下面所有的普通写与上面的 volatile 读重排序,但是由于存在第二个 volatile读,那个普通的读根本无法越过第二个 volatile 读。所以可以省略。

3:下面已经不存在普通读了,可以省略。

4:保留

5:保留

6:下面跟着一个 volatile 写,所以可以省略

7:保留

8:保留

所以 2、3、6 可以省略,其示意图如下:

总结

JMM 之重排序

在执行程序时,为了提高性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件:

  1. 在单线程环境下,不能改变程序运行的结果。
  2. 存在数据依赖关系的情况下,不允许重排序。

其实这两点可以归结于一点:无法通过 happens-before 原则推导出来的,JMM 允许任意的排序。

as-if-serial 语义

as-if-serial 语义的意思是:所有的操作均可以为了优化而被重排序,但是你必须要保证重排序后执行的结果不能被改变,编译器、runtime、处理器都必须遵守 as-if-serial 语义。注意,as-if-serial 只保证单线程环境,多线程环境下无效。

下面我们用一个简单的示例来说明:

int a = 1 ;      // A
int b = 2 ;      // B
int c = a + b;   // C

A、B、C 三个操作存在如下关系:

A、B 不存在数据依赖关系,
A和C、B和C存在数据依赖关系,

因此在进行重排序的时候,A、B 可以随意排序,但是必须位于 C 的前面,执行顺序可以是 A –> B –> C 或者 B –> A –> C 。但是无论是何种执行顺序最终的结果 C 总是等于 3 。

as-if-serail 语义把单线程程序保护起来了,它可以保证在重排序的前提下程序的最终结果始终都是一致的。

其实,对于上段代码,他们存在这样的 happen-before 关系:

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

1、2 是程序顺序次序规则,3 是传递性。但是,不是说通过重排序,B 可能会排在 A 之前执行么,为何还会存在存在 A happens-before B 呢?这里再次申明 A happens-before B 不是 A 一定会在 B 之前执行,而是 A 的执行结果对 B 可见,但是相对于这个程序 A 的执行结果不需要对 B 可见,且他们重排序后不会影响结果,所以 JMM 不会认为这种重排序非法。

我们需要明白这点:在不改变程序执行结果的前提下,尽可能提高程序的运行效率。

下面我们在看一段有意思的代码:

public class RecordExample1 {

    public static void main(String[] args){
        int a = 1;
        int b = 2;

        try {
            a = 3;           // A
            b = 1 / 0;       // B
        } catch (Exception e) {

        } finally {
            System.out.println("a = " + a);
        }
    }
    
}

按照重排序的规则,操作 A 与操作 B 有可能会进行重排序,如果重排序了,B 会抛出异常( / by zero),此时A语句一定会执行不到,那么 a 还会等于 3 么?

如果按照 as-if-serial 原则它就改变了程序的结果。

其实,JVM 对异常做了一种特殊的处理,为了保证 as-if-serial 语义,Java 异常处理机制对重排序做了一种特殊的处理:JIT 在重排序时,会在catch 语句中插入错误代偿代码(a = 3),这样做虽然会导致 catch 里面的逻辑变得复杂,但是 JIT 优化原则是:尽可能地优化程序正常运行下的逻辑,哪怕以 catch 块逻辑变得复杂为代价。

重排序对多线程的影响

在单线程环境下,由于 as-if-serial 语义,重排序无法影响最终的结果,但是对于多线程环境呢?

如下代码(volatile的经典用法):

public class RecordExample2 {
    
    int a = 0;
    boolean flag = false;

    /**
     * A线程执行
     */
    public void writer() {
        a = 1;                  // 1
        flag = true;            // 2
    }

    /**
     * B线程执行
     */
    public void read(){
        if (flag) {                 // 3
           int i = a + a;          // 4
        }
    }

}

A 线程先执行 #writer(),线程 B 后执行 #read(),线程 B 在执行时能否读到 a = 1 呢?

答案是不一定(注:x86 CPU 不支持写写重排序,如果是在 x86 上面操作,这个一定会是 a = 1 )。

由于操作 1 和操作 2 之间没有数据依赖性,所以可以进行重排序处理。 操作 3 和操作 4 之间也没有数据依赖性,他们亦可以进行重排序,但是操作 3 和操作 4 之间存在控制依赖性。

假如操作1 和操作2 之间重排序:

按照这种执行顺序线程 B 肯定读不到线程 A 设置的 a 值,在这里多线程的语义就已经被重排序破坏了。

实际上,操作 3 和操作 4 之间也可以重排序,虽然他们之间存在一个控制依赖的关系,只有操作 3 成立操作 4 才会执行

当代码中存在控制依赖性时,会影响指令序列的执行的并行度,所以编译器和处理器会采用猜测执行来克服控制依赖对并行度的影响。

假如操作 3 和操作 4 重排序了,操作 4 先执行,则先会把计算结果临时保存到重排序缓冲中,当操作 3 为真时,才会将计算结果写入变量 i 中。

通过上面的分析,重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。

重排序总结

JMM 角度分析 DCL

DCL ,即 Double Check Lock ,中文称为“双重检查锁定”。

其实 DCL 很多人在单例模式中用过,但是有很多人都会写错。他们为什么会写错呢?其错误根源在哪里?有什么解决方案?下面就一起来分析。

问题分析

我们先看单例模式里面的懒汉式:

public class Singleton {
    
    private static Singleton singleton;

    private Singleton(){}

    public static Singleton getInstance(){
        if (singleton == null) {
            singleton = new Singleton();
        }

        return singleton;
    }
    
}

我们都知道这种写法是错误的,因为它无法保证线程的安全性。优化如下:

public class Singleton {

    private static Singleton singleton;

    private Singleton(){}

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

        return singleton;
    }
    
}

优化非常简单,就是在 #getInstance() 方法上面做了同步,但是 synchronized 就会导致这个方法比较低效,导致程序性能下降,那么怎么解决呢?聪明的人们想到了双重检查 DCL:

public class Singleton {

    private static Singleton singleton;

    private Singleton() {}

    public static Singleton getInstance(){
        if(singleton == null){                              // 1
            synchronized (Singleton.class){                 // 2
                if(singleton == null){                      // 3
                    singleton = new Singleton();            // 4
                }
            }
        }
        return singleton;
    }
}

就如上面所示,这个代码看起来很完美,理由如下:

  1. 如果检查第一个 singleton 不为 null ,则不需要执行下面的加锁动作,极大提高了程序的性能。
  2. 如果第一个 singleton 为 null ,即使有多个线程同一时间判断,但是由于 synchronized 的存在,只会有一个线程能够创建对象。
  3. 当第一个获取锁的线程创建完成后 singleton 对象后,其他的在第二次判断 singleton 一定不会为 null ,则直接返回已经创建好的 singleton 对象。

通过上面的分析,DCL 看起确实是非常完美,但是可以明确地告诉你,这个错误的。上面的逻辑确实是没有问题,分析也对,但是就是有问题,那么问题出在哪里呢?在回答这个问题之前,我们先来复习一下创建对象过程,实例化一个对象要分为三个步骤:

memory = allocate();   //1:分配内存空间
ctorInstance(memory);  //2:初始化对象
instance = memory;     //3:将内存空间的地址赋值给对应的引用

但是由于重排序的原因,步骤 2、3 可能会发生重排序,其过程如下:

memory = allocate();   // 1:分配内存空间
instance = memory;     // 3:将内存空间的地址赋值给对应的引用
                                    // 注意,此时对象还没有被初始化!
ctorInstance(memory);  // 2:初始化对象

如果 2、3 发生了重排序,就会导致第二个判断会出错,singleton != null,但是它其实仅仅只是一个地址而已,此时对象还没有被初始化,所以 return 的 singleton 对象是一个没有被初始化的对象,如下:

按照上面图例所示,线程 B 访问的是一个没有被初始化的 singleton 对象。

知道问题根源所在,那么怎么解决呢?有两个解决办法:

不允许初始化阶段步骤 2、3 发生重排序。
允许初始化阶段步骤 2、3 发生重排序,但是不允许其他线程“看到”这个重排序。

解决方案

解决方案依据上面两个解决办法即可。

基于 volatile 解决方案

对于上面的DCL其实只需要做一点点修改即可:将变量singleton生命为volatile即可:

public class Singleton {

    // 通过volatile关键字来确保安全
    private volatile static Singleton singleton;

    private Singleton(){}

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

当 singleton 声明为 volatile后,步骤 2、3 就不会被重排序了,也就可以解决上面那问题了。

基于类初始化的解决方案

该解决方案的根本就在于:利用 ClassLoder 的机制,保证初始化 instance 时只有一个线程。JVM 在类初始化阶段会获取一个锁,这个锁可以同步多个线程对同一个类的初始化。

public class Singleton {

    private static class SingletonHolder{
        public static Singleton singleton = new Singleton();
    }

    public static Singleton getInstance(){
        return SingletonHolder.singleton;
    }
}

Java 语言规定,对于每一个类或者接口 C ,都有一个唯一的初始化锁 LC 与之相对应。从C 到 LC 的映射,由 JVM 的具体实现去自由实现。JVM 在类初始化阶段期间会获取这个初始化锁,并且每一个线程至少获取一次锁来确保这个类已经被初始化过了。

DCL 总结

延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。

如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于 volatile 的延迟初始化的方案。 如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。

JMM 之总结

经过上面的讨论,现在对 JMM 做一个比较简单的总结。

  1. JMM 规定了线程的工作内存和主内存的交互关系,以及线程之间的可见性和程序的执行顺序。

  2. 一方面,要为程序员提供足够强的内存可见性保证。

  3. 另一方面,对编译器和处理器的限制要尽可能地放松。JMM 对程序员屏蔽了 CPU 以及 OS 内存的使用问题,能够使程序在不同的 CPU 和 OS 内存上都能够达到预期的效果。

  4. Java 采用内存共享的模式来实现线程之间的通信。编译器和处理器可以对程序进行重排序优化处理,但是需要遵守一些规则,不能随意重排序。

  5. 在并发编程模式中,势必会遇到上面三个概念:

     原子性:一个操作或者多个操作要么全部执行要么全部不执行。
     可见性:当多个线程同时访问一个共享变量时,如果其中某个线程更改了该共享变量,其他线程应该可以立刻看到这个改变。
     有序性:程序的执行要按照代码的先后顺序执行。
    
  6. JMM 对原子性并没有提供确切的解决方案,但是 JMM 解决了可见性和有序性,至于原子性则需要通过锁或者 synchronized 来解决了。

  7. 如果一个操作 A 的操作结果需要对操作 B 可见,那么我们就认为操作 A 和操作 B 之间存在happens-before 关系,即 A happens-before B 。

  8. happens-before 原则,是 JMM 中非常重要的一个原则,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以解决在并发环境下两个操作之间是否存在冲突的所有问题。JMM 规定,两个操作存在 happens-before 关系并不一定要 A 操作先于B 操作执行,只要 A 操作的结果对 B 操作可见即可。

  9. 在程序运行过程中,为了执行的效率,编译器和处理器是可以对程序进行一定的重排序,但是他们必须要满足两个条件:

     执行的结果保持不变
     存在数据依赖的不能重排序。重排序是引起多线程不安全的一个重要因素。
    
  10. 同时,顺序一致性是一个比较理想化的参考模型,它为我们提供了强大而又有力的内存可见性保证,他主要有两个特征:

    一个线程中的所有操作必须按照程序的顺序来执行。
    所有线程都只能看到一个单一的操作执行顺序,在顺序一致性模型中,每个操作都必须原则执行且立刻对所有线程可见。