likes
comments
collection
share

万字长文:从计算机本源深入探寻volatile和Java内存模型

作者站长头像
站长
· 阅读数 20

前言

在本篇文章当中,主要给大家深入介绍Volatile关键字和Java内存模型。在文章当中首先先介绍volatile的作用和Java内存模型,然后层层递进介绍实现这些的具体原理、JVM底层是如何实现volatile的和JVM实现的汇编代码以及CPU内部结构,深入剖析各种计算机系统底层原理。本篇文章超级干,请大家坐稳扶好,发车了!!!本文的大致框架如下图所示: 万字长文:从计算机本源深入探寻volatile和Java内存模型

为什么我们需要volatile?

保证数据的可见性

假如现在有两个线程分别执行不同的代码,但是他们有同一个共享变量flag,其中线程updater会执行的代码是将flagfalse修改成true,而另外一个线程reader会进行while循环,当flagtrue的时候跳出循环,代码如下:

import java.util.concurrent.TimeUnit;

class Resource {
    public boolean flag;

    public void update() {
        flag = true;
    }
}

public class Visibility {

    public static void main(String[] args) throws InterruptedException {
        Resource resource = new Resource();
        Thread thread = new Thread(() -> {
            System.out.println(resource.flag);
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            resource.update();
        }, "updater");

        new Thread(() -> {
            System.out.println(resource.flag);
            while (!resource.flag) {

            }
            System.out.println("循环结束");
        }, "reader").start();

        thread.start();
    }
}

运行上面的代码你会发现,reader线程始终打印不出循环结束,也就是说它一直在进行while循环,而进行while循环的原因就是resouce.flag=false,但是线程updater在经过1秒之后会进行更新啊!为什么reader线程还读取不到呢?

这实际上就是一种可见性的问题,updater线程更新数据之后,reader线程看不到,在分析这个问题之间我们首先先来了解一下Java内存模型的逻辑布局:

万字长文:从计算机本源深入探寻volatile和Java内存模型

在上面的代码执行顺序大致如下:

  • reader线程从主内存当中拿到flag变量并且存储到线程的本地内存当中,进行while循环。
  • 在休眠一秒之后,Updater线程从主内存当中拷贝一份flag保存到本地内存当中,然后将flag改成true,将其写回到主内存当中。
  • 但是虽然updater线程将flag写回,但是reader线程使用的还是之前从主内存当中加载到工作内存的flag,也就是说还是false,因此reader线程才会一直陷入死循环当中。

现在我们稍微修改一下上面的代码,先让reader线程休眠一秒,然后再进行while循环,让updater线程直接修改。

import java.util.concurrent.TimeUnit;

class Resource {
    public boolean flag;

    public void update() {
        flag = true;
    }
}

public class Visibility {

    public static void main(String[] args) throws InterruptedException {
        Resource resource = new Resource();
        Thread thread = new Thread(() -> {
            System.out.println(resource.flag);
            resource.update();
        }, "updater");

        new Thread(() -> {
            System.out.println(resource.flag);
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            while (!resource.flag) {

            }
            System.out.println("循环结束");
        }, "reader").start();

        thread.start();
    }
}

上面的代码就不会产生死循环了,我们再来分析一下上面的代码的执行过程:

  • reader线程先休眠一秒。
  • updater线程直接修改flagtrue,然后将这个值写回主内存。
  • updater写回之后,reader线程从主内存获取flag,这个时候的值已经更新了,因此可以跳出while循环了,因此上面的代码不会出现死循环的情况。

像这种多个线程共享同一个变量的情况的时候,就会产生数据可见性的问题,如果在我们的程序当中忽略这种问题的话,很容易让我们的并发程序产生BUG。如果在我们的程序当中需要保持多个线程对某一个数据的可见性,即如果一个线程修改了共享变量,那么这个修改的结果要对其他线程可见,也就是其他线程再次访问这个共享变量的时候,得到的是共享变量最新的值,那么在Java当中就需要使用关键字volatile对变量进行修饰。

现在我们将第一个程序的共享变量flag加上volatile进行修饰:

import java.util.concurrent.TimeUnit;

class Resource {
    public volatile boolean flag; // 这里使用 volatile 进行修饰

    public void update() {
        flag = true;
    }
}

public class Visibility {

    public static void main(String[] args) throws InterruptedException {
        Resource resource = new Resource();
        Thread thread = new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(resource.flag);
            resource.update();
        }, "updater");

        new Thread(() -> {
            System.out.println(resource.flag);
            while (!resource.flag) {

            }
            System.out.println("循环结束");
        }, "reader").start();

        thread.start();
    }
}

上面的代码是可以执行完成的,reader线程不会产生死循环,因为volatile保证了数据的可见性。即每一个线程对volatile修饰的变量的修改,对其他的线程是可见的,只要有线程修改了值,那么其他线程就可以发现。

禁止指令重排序

指令重排序介绍

首先我们需要了解一下什么是指令重排序:

int a = 0;
int b = 1;
int c = 1;
a++;
b--;

比如对于上面的代码我们正常的执行流程是:

  • 定义一个变量a,并且赋值为0。

  • 定义一个变量b,并且赋值为1。

  • 定义一个变量c,并且赋值为1。

  • 变量a进行自增操作。

  • 变量b进行自减操作。

而当编译器去编译上面的程序时,可能不是安装上面的流程一步步进行操作的,编译器可能在编译优化之后进行如下操作:

  • 定义一个变量c,并且赋值为1。

  • 定义一个变量a,并且赋值为1。

  • 定义一个变量b,并且赋值为0。

从上面来看代码的最终结果是没有发生变化的,但是指令执行的流程和指令的数目是发生变化的,编译器帮助我们省略了一些操作,这可以让CPU执行更少的指令,加快程序的执行速度。

上面就是一个比较简单的在编译优化当中指令重排和优化的例子。

但是如果我们在语句int c = 1前面加上volatile时,上面的代码执行顺序就会保证ab的定义在语句volatile int c = 1;之前,变量a和变量b的操作在语句volatile int c = 1;之后。

int a = 0;
int b = 1;
volatile int c = 1;
a++;
b--;

但是volatile并不限制到底是a先定义还是b先定义,它只保证这两个变量的定义发生在用volatile修饰的语句之前

volatile关键字会禁止JVM和处理器(CPU)对含有volatile关键字修饰的变量的指令进行重排序,但是对于volatile前后没有依赖关系的指令没有禁止,也就是说编译器只需要保证编译之后的代码的顺序语义和正常的逻辑一样,它可以尽可能的对代码进行编译优化和重排序!

Volatile禁止重排序使用——双重检查单例模式

在单例模式当中,有一种单例模式的写法就双重检查单例模式,其代码如下:

public class DCL {
	// 这里没有使用 volatile 进行修饰
  public static DCL INSTANCE;

  public static DCL getInstance() {
		// 如果单例还没有生成
    if (null == INSTANCE) {
      // 进入同步代码块
      synchronized (DCL.class) {
        // 因为如果两个线程同时进入上一个 if 语句
        // 的话,那么第一个线程会 new 一个对象
        // 第二个线程也会进入这个代码块,因此需要重新
        // 判断是否为 null 如果不判断的话 第二个线程
        // 也会 new 一个对象,那么就破坏单例模式了
        if (null == INSTANCE) {
          INSTANCE = new DCL();
        }
      }
    }
    return INSTANCE;
  }
}

上面的代码当中INSTANCE是没有使用volatile进行修饰的,这会导致上面的代码存在问题。在分析这其中的问题之前,我们首先需要明白,在Java当中new一个对象会经历以下三步:

  • 步骤1:申请对象所需要的内存空间。
  • 步骤2:在对应的内存空间当中,对对象进行初始化。
  • 步骤3:对INSTANCE进行赋值。

但是因为变量INSTANCE没有使用volatile进行修饰,就可能存在指令重排序,上面的三个步骤的执行顺序变成:

  • 步骤1。
  • 步骤3。
  • 步骤2。

假设一个线程的执行顺序就是上面提到的那样,如果线程在执行完成步骤3之后在执行完步骤2之前,另外一个线程进入getInstance,这个时候INSTANCE != null,因此这个线程会直接返回这个对象进行使用,但是此时第一个线程还在执行步骤2,也就是说对象还没有初始化完成,这个时候使用对象是不合法的,因此上面的代码存在问题,而当我们使用volatile进行修饰就可以禁止这种重排序,从而让他按照正常的指令去执行。

不保证原子性

原子性:一个操作要么不做要么全做,而且在做这个操作的时候其他线程不能够插入破坏这个操作的完整性

public class AtomicTest {

  public static volatile int data;

  public static void add() {
    for (int i = 0; i < 10000; i++) {
      data++;
    }
  }

  public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(AtomicTest::add);
    Thread t2 = new Thread(AtomicTest::add);

    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(data);
  }
}

上面的代码就是两个线程不断的进行data++操作,一共会进行20000次,但是我们会发现最终的结果不等于20000,因此这个可以验证volatile不保证原子性,如果volatile能够保证原子性,那么出现的结果会等于20000。

Java内存模型(JMM)

JMM下的内存逻辑结构

我们都知道Java程序可以跨平台运行,之所以可以跨平台,是因为JVM帮助我们屏蔽了这些不同的平台和操作系统的差异,而内存模型也是一样,各个平台是不一样的,Java为了保证程序可以跨平台使用,Java虚拟机规范就定义了“Java内存模型”,规定Java应该如何并发的访问内存,每一个平台实现的JVM都需要遵循这个规则,这样就可以保证程序在不同的平台执行的结果都是一样的。

下图当中的绿色部分就是由JMM进行控制的 万字长文:从计算机本源深入探寻volatile和Java内存模型

JMM对Java线程和线程的工作内存还有主内存的规定如下:

  • 共享变量存储在主内存当中,每个线程都可以进行访问。
  • 每个线程都有自己的工作内存,叫做线程的本地内存。
  • 线程如果想操作共享内存必须首先将共享变量拷贝一份到自己的本地内存。
  • 线程不能直接对主内存当中的数据进行修改,只能直接修改自己本地内存当中的数据,然后通过JMM的控制,将修改后的值写回到主内存当中。

这里区分一下主内存和工作内存(线程本地内存):

  • 主内存:主要是Java堆当中的对象数据。
  • 工作内存:Java虚拟机栈中存储数据的某些区域、CPU的缓存(Cache)和寄存器。

因此线程、线程的工作内存和主内存的交互方式的逻辑结构大致如下图所示:

万字长文:从计算机本源深入探寻volatile和Java内存模型

内存交互的操作

JMM规定了线程的工作内存应该如何和主内存进行交互,即共享变量如何从内存拷贝到工作内存、工作内存如何同步回主内存,为了实现这些操作,JMM定义了下面8个操作,而且这8个操作都是原子的、不可再分的,如果下面的操作不是原子的话,程序的执行就会出错,比如说在锁定的时候不是原子的,那么很可能出现两个线程同时锁定一个变量的情况,这显然是不对的!!

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

如果需要将主内存的变量拷贝到工作内存,就需要顺序执行readload操作,如果需要将工作内存的值更新回主内存,就需要顺序执行storewriter操作。

JMM定义了上述8条规则,但是在使用这8条规则的时候,还需要遵循下面的规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。

  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存 中。·

  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。

  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。

  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。

  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

重排序

重排序介绍

我们在上文当中已经谈到了,编译器为了更好的优化程序的性能,会对程序进行进行编译优化,在优化的过程当中可能会对指令进行重排序。我们这里谈到的编译器是JIT(即时编译器)。它JVM当中的一个组件,它可以通过分析Java程序当中的热点代码(经常执行的代码),然后会对这段代码进行分析然后进行编译优化,将其直接编译成机器代码,也就是CPU能够直接执行的机器码,然后用这段代码代替字节码,通过这种方式来优化程序的性能,让程序执行的更快。

重排序通常有以下几种重排序方式:

  • JIT编译器对字节码进行优化重排序生成机器指令。
  • CPU在执行指令的时候,CPU会在保证指令执行时的语义不发生变化的情况下(与单线程执行的结果相同),可以通过调整指令之间的顺序,让指令并行执行,加快指令执行的速度。
  • 还有一种不是显式的重排序方式,这种方式就是内存系统的重排序。这是由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。这并不是显式的将指令进行重排序,只是因为缓存的原因,让指令的执行看起来像乱序。

as-if-serial规则

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、处理器都必须遵守as-if-serial语义,因为如果连这都不遵守,在单线程下执行的结果都不正确,那我们写的程序执行的结果都不是我们想要的,这显然是不正确的。

1. int a = 1;
2. int b = 2;
3. int c = a + b;

比如上面三条语句,编译器和处理器可以对第一条和第二条语句进行重排序,但是必须保证第三条语句必须执行在第一和第二条语句之后,因为第三条语句依赖于第一和第二条语句,重排序必须保证这种存在数据依赖关系的语句在重排序之后执行的结果和顺序执行的结果是一样的。

happer-before规则

重排序除了需要遵循as-if-serial规则,还需要遵循下面几条规则,也就是说不管是编译优化还是处理器重排序必须遵循下面的原则:

  • 程序顺序原则 :线程当中的每一个操作,happen-before线程当中的后续操作。

  • 锁规则 :解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前。

  • volatile规则 :volatile变量的写,先发生于读。

  • 线程启动规则 :线程的start()方法,happen-before它的每一个后续操作。

  • 线程终止规则 :线程的所有操作先于线程的终结,Thread.join()方法的作用是等待 当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的 join方法成功返回后,线程B对共享变量的修改将对线程A可见。

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

  • 对象终结规则 :对象的构造函数执行,需要先于finalize()方法的执行。

  • 传递性 :A先于B ,B先于C 那么A必然先于C。

总而言之,重排序必须遵循下面两条基本规则:

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

Volatile重排序规则

下表是JMM为了实现volatile的内存语义制定的volatile重排序规则,列表示第一个操作,行表示第二个操作:

是否可以重排序第二个操作第二个操作第二个操作
第一个操作普通读/写volatile读volatile写
普通读/写YesYesNo
volatile读NoNoNo
volatile写YesNoNo

说明:

  • 比如在上表当中说明,当第二个操作是volatile写的时候,那么这个指令不能和前面的普通读写和volatile读写进行重排序。
  • 当第一个操作是volatile读的时候,这个指令不能和后面的普通读写和volatile读写重排序。

Volatile实现原理

禁止重排序实现原理

内存屏障

在了解禁止重排序如何实现的之前,我们首先需要了解一下内存屏障。所谓内存屏障就是为了保证内存的可见性而设计的,因为重排序的存在可能会造成内存的不可见,因此Java编译器(JIT编译器)在生成指令的时候为了禁止指令重排序就会在生成的指令当中插入一些内存屏障指令,禁止指令重排序,从而保证内存的可见性。

屏障类型指令例子解释
LoadLoad BarrierLoad1;LoadLoad;Load2确保Load1数据的加载先于Load2和后面的Load指令
StoreStore BarrierStore1;StoreStore;Store2确保Store1操作的数据对其他处理器可见(将Cache刷新到内存),即这个指令的执行要先于Store2和后面的存储指令
LoadStore BarrierLoad1;LoadStore;Store2确保Load1数据加载先于Store2以及后面所有存储指令
StoreLoad BarrierStore1;StoreLoad;Load2确保Store1数据对其他处理器可见,也就是将这个数据从CPU的Cache刷新到内存当中,这个内存屏障会让StoreLoad前面的所有的内存访问指令(不管是Store还是Load)全部完成之后,才执行Store Load后面的Load指令

X86当中内存屏障指令

现在处理器一般可能不会支持上面屏障指令当中的所有指令,但是一般都会支持Store Load屏障指令,因为这个指令可以达到其他三个指令的效果,因此在实际的机器指令当中如果想达到上面的四种指令的效果,可能不需要四个指令,像在X86当中就主要有三个内存屏障指令:

  • lfence,这是一种Load Barrier,一种读屏障指令,这个指令可以让高速缓存(CPU的Cache)失效,如果需要加载数据,那么就需要从内存当中重新加载(这样可以加载最新的数据,因为如果其他处理器修改了缓存当中的数据的时候,这个缓存当中的值已经不对了,去内存当中重新加载就可以拿到最新的数据),这个指令其实可以达到上面指令当中LoadLoad和指令的效果。同时这条指令不会让这条指令之后读操作被调度到lfence指令之前执行。
  • sfence,这是一种Store Barrier,一种写屏障指令,这个指令可以将写入高速缓存的数据刷新到内存当中,这样内存当中的数据就是最新的了,数据就可以全局可见了,其他处理器就可以加载内存当中最新的数据。这条指令有StoreStore的效果。同时这条指令不会让在其之后的写操作调度到其之前执行。
  • 关于以上两点的描述是稍微有点不够准确的,在下文我们在讨论Store Buffer和Invalid Queue时我们会重新修正,这里这么写是为了能够帮助大家理解。
  • mfence,这是一种全能型的屏障,相当于上面lfencesfence两个指令的效果,除此之外这条指令可以达到StoreLoad指令的效果,这条指令可以保证mfence操作之前的写操作对mfence之后的操作全局可见。

Volatile需要的内存屏障

为了实现Volatile的内存语义,Java编译器(JIT编译器)在进行编译的时候,会进行如下指令的插入操作(这里你可以对照前面的volatile重排序规则,然后你就理解为什么要插入下面的内存屏障了):

  • 在每个volatile写操作的前面插入一个StoreStore屏障。

  • 在每个volatile写操作的后面插入一个StoreLoad屏障。

  • 在每个volatile读操作的后面插入一个LoadLoad屏障。

  • 在每个volatile读操作的后面插入一个LoadStore屏障。

Volatile读内存屏障指令插入情况如下: 万字长文:从计算机本源深入探寻volatile和Java内存模型

Volatile写内存屏障指令插入情况如下: 万字长文:从计算机本源深入探寻volatile和Java内存模型

其实上面插入内存屏障只是理论上所需要的,但是因为不同的处理器重排序的规则不一样,因此在插入内存屏障指令的时候需要具体问题具体分析。比如X86处理器只会对读-写这样的操作进行重排序,不会对读-读、读-写和写-写这样的操作进行重排序,因此在X86处理器进行内存屏障指令的插入的时候可以省略这三种情况。

根据volatile重排序的规则表,我们可以发现在写-读的情况下,只禁止了volatile写-volatile读的情况: 万字长文:从计算机本源深入探寻volatile和Java内存模型

而X86仅仅只会对写-读的情况进行重排序,因此我们在插入内存屏障的时候只需要关心volatile写-volatile读这一种情况,这种情况下我们需要使用的内存屏障指令为StoreLoad,即volatile写-StoreLoad-volatile读,因此在X86当中我们只需要在volatile写后面加入StoreLoad内存屏障指令即可,在X86当中Store Load对应的具体的指令为mfence

Java虚拟机源码实现Volatile语义

在Java虚拟机当中,当对一个被volatile修饰的变量进行写操作的时候,在操作进行完成之后,在X86体系结构下,JVM会执行下面一段代码,从而保证volatile的内存语义:(下面代码来自于:hotspot/src/os_cpu/linux_x86/vm/orderAccess_linux_x86.inline.hpp)

inline void OrderAccess::fence() {
  // 这里判断是不是多处理器的机器,如果是执行下面的代码
  if (os::is_MP()) {
    // 这里说明了使用 lock 指令的原因 有时候使用 mfence 代价很高
    // 相比起 lock 指令来说会降低程序的性能
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64 // 这个表示如果是 64 位机器
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else // 如果不是64位机器 s
  // 32位和64位主要区别就是 寄存器不同 在64 位当中是 rsp 在32位机器当中是 esp
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
}

上面代码主要是通过内联汇编代码去执行指令lock,如果你不熟悉C语言和内联汇编的形式也没有关系,你只需要知道JVM会执行lock指令,lock指令有mfence相同的作用,它可以实现StoreLoad内存屏障的作用,可以保证执行执行的顺序,在前文当中我们说mfence是用于实现StoreLoad内存屏障,因为lock指令也可以实现同样的效果,而且有时候mfence的指令可能对程序的性能影响比较大,因此JVM使用lock指令,这样可以提高程序的性能。如果你对X86的lock指令有所了解的话,你可能知道lock还可以保证使用lock的指令具有原子性,在X86的体系结构下就可以使用lock实现自旋锁(CAS)。

可见性实现原理

可见性存在的根本原因是一个线程读,一个线程写,一个线程写操作对另外一个线程的读不可见,因此我们主要分析volatile的写操作就行,因为如果都是进行读操作的话,数据就不会发生变化了,也就不存在可见性的问题了。

在上文当中我们已经谈到了Java虚拟机在执行volatile变量的写操作时候,会执行lock指令,而这个指令有mfence的效果:

  • 将执行lock指令的处理器的缓存行写回到内存当中,因为我们进行了volatile数据的更新,因此我们需要将这个更新的数据写回内存,好让其他处理器在访问内存的时候,能够看见被修改后的值。
  • 写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效,这些处理器如果想使用这些数据的话,就需要从内存当中重新加载。因为修改了volatile变量的值,但是现在其他处理器中的缓存(Cache)还是旧值,因此我们需要让其他处理器缓存了这个用volatile修饰的变量的缓存行失效,那么其他处理器想要再使用这个数据的话就需要重新去内存当中加载,而最新的数据已经更新到内存当中了。

深入内存屏障——Store Buffer和Invalid Queue

在前面我们提到了lock指令,lock指令可保证其他CPU当中缓存了volatile变量的缓存行无效。这是因为当处理器修改数据之后会在总线上发送消息说改动了这个数据,而其他处理器会通过总线嗅探的方式在总线上发现这个改动消息,然后将对应的缓存行置为无效。

这其实是处理器在处理共享数据时保证缓存数据一致性(Cache coherence)的协议,比如说Intel的MESI协议,在这个协议之下缓存行有以下四种状态:

  • 已修改Modified (M) 缓存行是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S).

  • 独占Exclusive (E)缓存行只在当前缓存中,但是干净的(clean)缓存数据和主存数据相同。当别的缓存读取它时,状态变为共享;当写数据时,变为已修改状态。

  • 共享Shared (S)缓存行也存在于其它缓存中且是干净的。缓存行可以在任意时刻抛弃。

  • 无效Invalid (I)缓存行是无效的。

  • 因为MESI协议涉及的内容还是比较多的,如果你想仔细了解MESI协议,请看文末,这里就不详细说明了!

假设在某个时刻,CPU的多个核心共享一个内存数据,其中一个一个核心想要修改这个数据,那么他就会通过总线给其他核心发送消息表示想要修改这个数据,然后其他核心将这个数据修改为Invalid状态,再给修改数据的核心发送一个消息,表示已经收到这个消息,然后这个修改数据的核心就会将这个数据的状态设置为Modified。

在上面的例子当中当一个核心给其他CPU发送消息时需要等待其他CPU给他返回确认消息,这显然会降低CPU的性能,为了能够提高CPU处理数据的性能,硬件工程师做了一层优化,在CPU当中加了一个部分,叫做“Store Buffer”,当CPU写数据之后,需要等待其他处理器返回确认消息,因此处理器先不将数据写入缓存(Cache)当中,而时写入到Store Buffer当中,然后继续执行指令不进行等待,当其他处理器返回确认消息之后,再将Store Buffer当中的消息写入缓存,以后如果CPU需要数据就会先从Store Buffer当中去查找,如果找不到才回去缓存当中找,这个过程也叫做Store Forwarding。

处理器在接受到其他处理器发来的修改数据的消息的时候,需要将被修改的数据对应的缓存行进行失效处理,然后再返回确认消息,为了提高处理器的性能,CPU会在接到消息之后立即返回,然后将这个Invalid的消息放入到Invalid Queue当中,这就可以降低处理器响应Invalid消息的时间。其实这样做还有一个好处,因为处理器的Store Buffer是有限的,如果发出Invalid消息的处理器迟迟接受不到响应信息的话,那么Store Buffer就可以写满,这个时候处理器还会卡住,然后等待其他处理器的响应消息,因此处理器在接受到Invalid的消息的时候立马返回也可以提升发出Invalid消息的处理器的性能,会减少处理器卡住的时间,从而提升处理器的性能。

Store Buffer、Valid Queue、CPU、CPU缓存以及内存的逻辑结构大致如下:

万字长文:从计算机本源深入探寻volatile和Java内存模型

还记得前面的两条指令lfencesfence吗,现在我们重新回顾一下这两条指令:

  • lfence,在前面的内容当中,这个屏障能够让高速缓存失效,事实上是,它扫描Invalid Queue中的消息,然后让对应数据的缓存行失效,这样的话就可以更新到内存当中最新的数据了。这里的失效并不是L1缓存失效,而是L2和L3中的缓存行失效,读取数据也不一定从内存当中读取,因为L1Cache当中可能有最新的数据,如果有的话就可以从L1Cache当中读取。
  • sfence,在前面的内容当中,我们谈到这个屏障时,说它可以将写入高速缓存的数据刷新到内存当中,这样内存当中的数据就是最新的了,数据就可以全局可见了。事实上这个内存屏障是将StoreBuffer当中的数据刷行到L1Cache当中,这样其他的处理器就可以看到变化了,因为多个处理器是共享同一个L1Cache的,比如下图当中的CPU结构。当然它也是可以被刷新到内存当中的。

(下面图片来源于网络) 万字长文:从计算机本源深入探寻volatile和Java内存模型

MESI协议

在前面的文章当中我们已经提到了在MESI协议当中缓存行的四种状态:

  • 已修改Modified (M) 缓存行是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S).

  • 独占Exclusive (E)缓存行只在当前缓存中,但是干净的(clean)缓存数据和主存数据相同。当别的缓存读取它时,状态变为共享;当写数据时,变为已修改状态。

  • 共享Shared (S)缓存行也存在于其它缓存中且是干净的。缓存行可以在任意时刻抛弃。

  • 无效Invalid (I)缓存行是无效的。

下图表示不同处理器缓存同一个数据的缓存行的状态是否相容: 万字长文:从计算机本源深入探寻volatile和Java内存模型

  • 比如说“I”那一行,处理器A的缓存行H包含数据data,而且这个缓存行的状态是Invalid,那么其他处理器包含数据data的缓存行的状态可以是“M、E、S、I”当中的任意一个。

  • 再比如说包含数据data的缓存行是“Shared”的状态,说明这个数据是各个处理器共享的,因此其他的缓存行不可能是“Exclusive”状态,因为不可能既共享也独占。当然肯定也不是“Modified”,如果是“Modified”状态,那么其他缓存行只能是“Invalid”的状态,而不会是“Shared”状态

在介绍MESI协议之前,我们先介绍一些基本操作:

处理器对缓存的请求:

  1. PrRd: 处理器请求一个缓存块。
  2. PrWr: 处理器请求一个缓存块。

总线对缓存的请求:

  1. BusRd: 总线上有一个消息:其他处理器请求一个缓存块。
  2. BusRdX: 总线上有一个消息:其他处理器请求一个自己不拥有的缓存块。
  3. BusUpgr: 总线上有一个消息:其他处理器请求一个自己拥有的缓存块。
  4. Flush:总线上有一个消息:请求回写整个缓存到主存。
  5. FlushOpt: 总线上有一个消息:整个缓存块被发到总线,然后通过总线送给另外一个处理器(缓存到缓存的复制)。

下图是MESI这四种状态在不同的操作之下的转换图(红色表示总线事务,黑色表示处理器事务):(图片来自维基百科) 万字长文:从计算机本源深入探寻volatile和Java内存模型

  • 假如现在是“M”状态,现在如果有其他处理器想要读数据(BusRd)或者处理器想要将这个数据写回内存(flush),那么这个“M”状态就转变成“S”状态了。
  • 假如现在是“E”状态,如果有总线请求读(BusRd),那么这个状态就需要从独占(E)变成共享(S)。

不同的初始状态在不同的处理器操作下的状态变化:

初始状态操作响应
Invalid(I)PrRd给总线发BusRd信号其他处理器看到BusRd,检查自己是否有有效的数据副本,通知发出请求的缓存状态转换为(S)Shared, 如果其他缓存有有效的副本状态转换为(E)Exclusive, 如果其他缓存都没有有效的副本如果其他缓存有有效的副本, 其中一个缓存发出数据;否则从主存获得数据
Exclusive(E)PrRd无总线事务生成状态保持不变读操作为缓存命中
Shared(S)PrRd无总线事务生成状态保持不变读操作为缓存命中
Modified(M)PrRd无总线事务生成状态保持不变读操作为缓存命中
Invalid(I)PrWr给总线发BusRdX信号状态转换为(M)Modified如果其他缓存有有效的副本, 其中一个缓存发出数据;否则从主存获得数据如果其他缓存有有效的副本, 见到BusRdX信号后无效其副本向缓存块中写入修改后的值
Exclusive(E)PrWr无总线事务生成状态转换为(M)Modified向缓存块中写入修改后的值
Shared(S)PrWr发出总线事务BusUpgr信号状态转换为(M)Modified其他缓存看到BusUpgr总线信号,标记其副本为(I)Invalid.
Modified(M)PrWr无总线事务生成状态保持不变写操作为缓存命中

不同的初始状态在不同的总线消息下的状态变化:

初始状态操作响应
Invalid(I)BusRd状态保持不变,信号忽略
Exclusive(E)BusRd状态变为共享发出总线FlushOpt信号并发出块的内容
Shared(S)BusRd状态变为共享可能发出总线FlushOpt信号并发出块的内容(设计时决定那个共享的缓存发出数据)
Modified(M)BusRd状态变为共享发出总线FlushOpt信号并发出块的内容,接收者为最初发出BusRd的缓存与主存控制器(回写主存)
Exclusive(E)BusRdX状态变为无效发出总线FlushOpt信号并发出块的内容
Shared(S)BusRdX状态变为无效可能发出总线FlushOpt信号并发出块的内容(设计时决定那个共享的缓存发出数据)
Modified(M)BusRdX状态变为无效发出总线FlushOpt信号并发出块的内容,接收者为最初发出BusRd的缓存与主存控制器(回写主存)
Invalid(I)BusRdX/BusUpgr状态保持不变,信号忽略

总结

在本篇文章当中主要是介绍了volatile和JMM的具体作用和规则,然后仔细介绍了实现这些的底层原理,尤其是内存屏障以及它在X86当中的具体实现,这一部分的内容比较抽象,可能难以理解本篇文章涉及的内容比较多,可能需要大家慢慢的仔细思考才能理解。

以上就是本文所有的内容了,希望大家有所收获,我是LeHung,我们下期再见!!!(记得点赞收藏哦!)


更多精彩内容合集可访问项目:github.com/Chang-LeHun…

关注公众号:一无是处的研究僧,了解更多计算机(Java、Python、计算机系统基础、算法与数据结构)知识。

参考书籍和资料

《Java并发编程的艺术》

《深入理解Java虚拟机》

《Java高并发编程详解》

《JSR-133: Java™ Memory Model and Thread Specifification》

blog.the-pans.com/std-atomic-…

en.wikipedia.org/wiki/MESI_p…

www.felixcloutier.com/x86/index.h…