亲宝软件园·资讯

展开

Java内存模型 volatile

一无是处的研究僧 人气:0

前言

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

为什么我们需要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内存模型的逻辑布局:

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

现在我们稍微修改一下上面的代码,先让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();
    }
}

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

像这种多个线程共享同一个变量的情况的时候,就会产生数据可见性的问题,如果在我们的程序当中忽略这种问题的话,很容易让我们的并发程序产生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--;

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

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

从上面来看代码的最终结果是没有发生变化的,但是指令执行的流程和指令的数目是发生变化的,编译器帮助我们省略了一些操作,这可以让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一个对象会经历以下三步:

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

假设一个线程的执行顺序就是上面提到的那样,如果线程在执行完成步骤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进行控制的

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

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

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

内存交互的操作

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

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

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

重排序

重排序介绍

我们在上文当中已经谈到了,编译器为了更好的优化程序的性能,会对程序进行进行编译优化,在优化的过程当中可能会对指令进行重排序。我们这里谈到的编译器是JIT(即时编译器)。它JVM当中的一个组件,它可以通过分析Java程序当中的热点代码(经常执行的代码),然后会对这段代码进行分析然后进行编译优化,将其直接编译成机器代码,也就是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规则,还需要遵循下面几条规则,也就是说不管是编译优化还是处理器重排序必须遵循下面的原则:

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

Volatile重排序规则

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

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

说明:

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当中就主要有三个内存屏障指令:

Volatile需要的内存屏障

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

Volatile读内存屏障指令插入情况如下:

Volatile写内存屏障指令插入情况如下:

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

根据volatile重排序的规则表,我们可以发现在写-读的情况下,只禁止了volatile写-volatile读的情况:

而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的效果:

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

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

这其实是处理器在处理共享数据时保证缓存数据一致性(Cache coherence)的协议,比如说Intel的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缓存以及内存的逻辑结构大致如下:

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

(下面图片来源于网络)

MESI协议

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

下图表示不同处理器缓存同一个数据的缓存行的状态是否相容:

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

处理器对缓存的请求:

总线对缓存的请求:

下图是MESI这四种状态在不同的操作之下的转换图(红色表示总线事务,黑色表示处理器事务):(图片来自维基百科)

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

初始状态操作响应
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当中的具体实现,这一部分的内容比较抽象,可能难以理解本篇文章涉及的内容比较多,可能需要大家慢慢的仔细思考才能理解。

加载全部内容

相关教程
猜你喜欢
用户评论