面时莫慌 | 你好,请谈谈volatile关键字?(全篇)

2,006 阅读20分钟

说起Volatile关键字,很多人那叫一个气,这个不起眼的Java关键字为难了太多的英雄好汉。初看它时,觉得格外的简单,仔细看它,才发现打扰了。这篇文章带大家由深及浅的分析这个关键字。

一、灵魂拷问

那一年,在某个你想去奋斗的地方,你可曾被问到以下的问题。

  • volatile是什么,它与synchronized的区别?你在什么地方看到过,用到过?
  • 哪些场景下适合使用Volatile
  • volatile是为了解决什么问题?它的优缺点是什么?
  • 谈谈volatile底层原理?再说说MESI吧?
  • 你知道happen-before原则吗?

COMBO连击,完全招架不住,我是谁?我为什么在这?时候不早了,我是不是该回去休息了。

二、实例分析

我们先以实例走进这个关键字。

/**
     * thread safely
     */
    //public static volatile  boolean run = true;
    /**
     * thread not safely
     */
    public static  boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            int i=0;
            while (run){
                i++;
                Thread.yield();
                System.out.println(i+" loading...");
            }
        }).start();
        System.out.println("Main Thread ready to sleep...");
        TimeUnit.SECONDS.sleep(10);
        System.out.println("Main Thread finish sleep...");
        run= false;
    }

以上的例子演示了使用Volatile关键字和不使用的区别。

#线程不安全
public static  boolean run = true;

使用线程不安全的写法,结果表明,会存在相当小的概率让子线程一直处于RUNNABLE的状态,永远停下来。需要注意的是,真的概率非常的小,我运行了上万次,才能出现一次子线程无法停下来。但也就这一次可能就会引发一次严重的生产事故。

#线程安全
public static volatile  boolean run = true;

上面的代码是线程安全的,可以从根本上解决死循环的问题。那么为什么呢?

先简单的解释一下,核心问题就是虽然主线程修改了值,子线程并没有感知到主线程修改run变量为false。Why,后面看我细细分析。

三、什么是volatile

volatile关键字用于多线程修改同一变量值的场景,它使线程能安全的访问、操作共享变量。这意味着多个线程能同时使用一个方法和实例,而不会出任何问题。这个关键字既能修饰Java的基本类型,也能修改引用类型。

上面的描述都是在说现象,真正的底层原理是,基于JMM( Java Memory Model),volatile 关键字用于将 Java 变量标记为“存储在主内存中”。更加准确地说,每一次 volatile变量的读取都将从计算机的主存中读取,而不是从CPU高速缓存中读取,每一次写入 volatile变量将被写入主存中,而不仅仅是写入 CPU 缓存中。

众所周知,当多个线程要同时访问共享变量的时,需要考虑三个方面,包括原子性、可见性、顺序性。原子性代表当另一个线程对共享数据执行某些操作时,不应该有线程干扰;可见性代表行为对线程共享数据的影响应该可以被其他线程感知到;顺序性代表指令执行顺序应该与源代码中表达的顺序相同。

分析volatile,它具有:

  • 可见性,对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性,对任意单个volatile变量的读/写具有原子性。但是对于i++这种复合操作不具有原子性。
  • 顺序性,volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。

四、从硬件层面分析可见性

volatile核心诠释的是可见性,那么这个可见性的本质的是什么呢,我们从硬件层面细细分析。

我们知道,CPU内存I/O设备等是计算机的核心组件。硬件产商会通过不断提高这三个组件性能来提升计算机的整体性能。这三者在在处理速度上差异很大,CPU>内存>I/O设备。根据木桶原理,影响整体性能最重要的因素是最短板。为了平衡三者的性能,达到最大效率利用计算资源,前面的文章《并发编程之进程、线程、协程》有讲到聪慧的一群人研究出了操作系统、进程、线程通过时间片切换最大化的利用CPU的资源。

但,这还不够,有没有一种可能CPU执行指令的时候,访问的资源是直接存储在CPU上的,而不是去访问内存,当然有的,那就是CPU高速缓存, 在高速缓存之后,为了更加合理的利用高速缓存,人们又对编译器的指令进行了优化。虽然实实切切的不断榨干了CPU资源,但正因为这些优化,也带了各种各样的问题。勤劳的人们又开始解决这样问题,将并发编程思想带入了井喷时代。

4.1 CPU高速缓存

CPU高速缓存是是位于CPU和内存之间的临时存储器,它的作用主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾。更直白的说我们使用计算机最终使用到的是计算机的计算能力,但是完成计算这件事情,是很复杂的,不仅仅有CPU参与就行了,CPU需要和内存交互进行读取、写入操作。所以为了尽可能消除在CPU执行过程中对这种IO的耗时,人们便研究出高速缓存来解决这样的问题。

Cache.png

如图,

  • L1 Cache指的是一级缓存,主要是缓存指令和缓存数据,Core独享。因为其结构相当的复杂,造价成本高,所以其在高速缓存中容量最小,普遍容量范围为32KB~512KB
  • L2 Cache 指的是二级缓存,直接影响CPU性能,Core独享。普遍容量范围为128KB~24MB
  • L3 Cache 指的是三级缓存,它的作用是进一步降低内存的延迟,提升数据更多场景计算时的性能,Core共享。普遍容量范围为2MB~32MB

总的来说,越接近CPU 核心的缓存,容量就越小,同时访问延迟就越低 (速度越快)。

通过高速缓存的存储交互很好的解决了处理器与内存速度的差异性矛盾,但它有引入一个新的难题,那就是缓存一致性

4.2 缓存一致性问题

一个运算的过程是,运算开始时将运算要使用到的数据移动到缓存中,当运算结束时再将运算结果同步到内存中。 这个最大的问题是在多核CPU运行环境下,主存上的共享数据可能会被多个核同时缓存,同时进行读写操作,写回的数据第一时间是放在高速缓存中,然后再进一步同步到主存中,但其它的核心的缓存可能感知不到变化,这样就会产生缓存不一致的问题。

为了解决这个问题,保证缓存一致性,可以采取两种方式解决。

  • 总线锁
  • 缓存锁

总线锁,简单点说,在总线层面,局部锁定CPU和内存之间的通信,在锁定期间,其它线程不能访问锁定的内存地址。这种方式开销很大,影响面广,锁控制的粒度粗了,不太适合解决缓存不一致的问题。

所以为了优化总线锁,大佬们又提出了缓存锁。这个缓存锁的核心机制就是缓存一致性协议,这个协议,降低了锁的粒度,同时对于主存上同一份共享数据,保证了CPU每个核上缓存的内容是一致的。

4.3 CPU Cache 与 Cache Line

上面讲到使用缓存一致性协议的缓存锁,能保证缓存一致。这种协议有很多,最为常见的是MESI。 因为MESI协议在缓存中实现是每个Cache line的4个状态,讲协议之前,有一个硬件的知识需提前说一下,那就是CPU CacheCahce Line的区别。

Cache Line 可以理解为CPU Cache中的最小缓存单位,在主流的CPU Cache 中,一般大小为64KB,也就是说如果L1 Cache的大小为512KB,那么此Cache 一共有8个(512/64=8)Cache Line,具体参照下图示意。所以MESI产生效用的粒度是Cache Line,这样最好的作用,当发生主存的数据和高速缓存的数据需要同步的时候,影响的程度只是在Cache Line,而不至于要操作整个CPU Cache

Cache Line.png

4.4 缓存一致性协议MESI

现在来正式谈谈MESI,上面说MESI协议具有四个状态,这四种状态指的是4个单词的首字母,具体包括ModifiedExclusiveSharedInvalid,用2个bit表示,几种状态解释如下。

  • Modified 表示Cache Line有效,数据只存在当前Cache中,并且数据是已经被修改了,与主存中的是不一致的。
  • Exclusive 表示Cache Line有效,数据只存在当前的Cache中,数据和主存保持一致的。
  • Shared 表示Cache Line有效,数据并不只是存在当前的Cache中,被多个Cache共享,各个Cache 与主存数据都一致。
  • Invalid 表示当前缓存行已经失效。

MESI协议中,每个高速缓存的控制器不单单知道自己的读写操作,而且还监听其它高速缓存的读写操作,这个就是嗅探技术,控制器针对当前Cache Line处于的状态进行不同的监听任务。

4.4.1 状态变化流程

到底是如何监听任务的,我们通过一个简单的例子来分析一下。假设我们现在有一个双核的CPU,主存里面存储着一个i的变量,值为1,现在CPU要做一些运算操作,需要将i读取到缓存中。

image.png

步骤1:图上CPU1从主存中读取数据到缓存,当前缓存的存储的变量i=1,缓存行的状态时E,也就是独占,时刻监听着有没有其它缓存也要从主存中加载该变量。

image.png

步骤2:图上CPU2也试图从主存中读取变量i,加载到缓存中,CPU1监听到这个事件,于是CPU1立刻做出变化,更改状态为SCPU2也同时读取到数据,状态也为S。此时两个CPU Cache Line 存储的变量i=1,都在监听有没有事件要使缓存自己置为I无效态,或者其它缓存要独享变量的请求。

image.png

步骤3:图上CPU1计算完成后,需要修改变量i=2,缓存管理器先设置Cache Line 的状态为M修改态,然后发起事件通知其它CPUCPU2收到事件通知,设置Cache Line的状态为I无效态。CPU1 监听着其它缓存要读取主内存的事件。CPU2的缓存行因为状态时无效的,所以缓存行失效。

image.png

步骤4:图上,CPU2运算要用到变量i,因为存储i的缓存行失效,去主动同步主内存。CPU1收到有其它CPU要读取主存的请求,赶在读取之前,先把修改后的变量同步到主存,同步完以后,主存上的变量i=2,然后CPU1缓存管理器设置缓存行的状态为E。然后按照步骤4,两个CPUCache Line最后状态都变为S

image.png

4.4.2 状态变化原则

总的来说,对于CPU读写操作缓存行,MESI协议遵循以下的原则:

  • CPU读请求:缓存行当前状态处于M E S状态都可以被读取,处于I状态下,CPU只能从主存中读取数据。
  • CPU写请求:缓存行当前状态处于M E 状态才可以被直接写,处于I状态下,缓存行已经失效,无法进行读取操作;处于S状态,能写的前提条件是将其它缓存行设置为无效。
4.4.3 MESI 带来的问题

虽然通过MESI协议的四种状态和嗅探技术,实现了缓存的一致性,但也带来一些问题。

上面我们谈到,如果CPU要将计算后的结果写入Cache Line 中,需要发送一个失效的通知给其它存储了相同数据的CPU,并且必须等到他们的状态变更完成后才能进行相应的写入操作,在整个期间,该CPU在同步地阻塞的等待,十分影响CPU的性能。

为了解决阻塞等待的问题,在CPU中又引入了Store Buffer,通过这个buffer,CPU要修改缓存中的值时,只需要将数据写入这个buffer,就可以去执行其它指令了。然后当收到其它CPU修改指定缓存行的状态为I无效态以后,再将buffer的数据存储到Cache Line,然后必要时,再同步到主存中。

这种方案是异步的,解决了CPU同步等待阻塞的问题。但同时也引入了新的问题。

  • 因为是一个异步操作,具体什么时候收到其它CPU状态变更的通知是不明确的,所以导致Store Buffer的数据什么时候写入Cache Line也是不确定。
  • 当未收到其它CPU状态变更之前CPU有可能会来读取数据,首先会从Store Buffer中读,如果没有,再读Cache Line,如果还没有,再读主存。

新的问题,带来的巨大的影响就是指令重排序

我们通过一个例子分析具体是什么问题。

int value =1;
bool finish = false;

void runOnCPU1(){
  value = 2;
  finish = true;
}

void runOnCPU2(){
  if(finish){
	assert value == 2;
  }
}

我们假设#runOnCPU1#runOnCPU2 两个方法分别运行在两个独立的CPU上。 我们很容易想到肯定不会有断言执行。当事实真的如此吗,以下是一种可能的场景。

CPU1 缓存行上缓存了两个关键变量,状态如下:

valuefinish
CacheLine状态SE

CPU1在执行#runOnCPU1方法时,会先把value=2写入到Store Buffer中,继续执行finish=true这条指令,与此同时,也通知了其它存储相同变量的CPU 设置缓存行的状态为I无效态,并异步的等待执行结果回执。

因为当前存储finish变量的Cache Line的状态为E独占,所以无需通知其它CPU,立刻就能将finish=true写入Cache Line。这个时候CPU2开始执行#runOnCPU2方法,会从主存中读取finish,按照文章上面介绍的状态变化步骤,会轻松读到finish=true,此时两个CPU存储finishCache Line状态都为S,并且主存的finish=trueCPU2继续执行assert value == 2;这条指令,首先要去从主存中获取value的值,因为CPU1修改value的值还放在Store Buffer,所以CPU2取到的值会是1。

也就是说,我们能看到的现象是,在方法#runOnCPU1中,finish赋值早于value的赋值,跟我们预期有差异,这个就是指令重排序带来的可见性问题

这种可见性问题,可以基于JMM(Java 内存模型)内存屏障去解决,恰恰好,这个就是volatile保证多线程环境下可见性的杀手锏。

4.5 内存屏障

4.5.1 借助Hsdis工具分析内存屏障

Hsdis是一个反汇编的库,用在Java运行时分析JIT编译器生成的代码。它是一个dll(动态链接库)文件,放在${JAVA_HOME}/jre/bin/server目录下使用。我们借助这个工具对比变量前加volatile关键字和不加关键字的汇编指令的差异。

首先在Java安装目录配置Hsdis环境,其实就是把两个文件放到指定目录。

hsdis.png

在配置好Hsdis环境后,在运行程序的VM Options加入以下参数。

-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileExample.*
  • -XX:+UnlockDiagnosticVMOptions 参数代表开启诊断模式,以方便打印PrintAssembly一类的信息。
  • -XX:+PrintAssembly 参数代表打印即时编译的二进制信息
  • XX:CompileCommand=compileonly,*VolatileExample.* 这个表示过滤仅显示满足*VolatileExample.*正则表达式的信息。

然后我们运行第二章节的例子VolatileExample

发现加volatile修饰的变量run汇编指令为

0x0000000003565073: lock add dword ptr [rsp],0h  ;*putstatic run

未加volatile修饰的变量run汇编指令为

  0x00000000030e5ceb: push    0ffffffffc4834801h  ;*putstatic run

可以很清晰的看到差异,有volatile修饰的变量,多了一个lock指令。lock是一个汇编控制指令,这个指令相当于实现了一种内存屏障。

4.5.2 详解内存屏障

对于Java语言,内存屏障不是直接由JVM暴露出来提供使用的,而是由JVM根据代码语义,插入到底层运行指令中的。比如上文实践分析出,加入volatile关键字修饰变量,汇编指令上就会多出lock指令。

不同硬件暴露出来供外界使用内存屏障指令是不一样的。比如我们熟知的X86计算机设备,它提供了Ifence(读屏障)Sfence(写屏障)mfence(全屏障)这几种执行内存屏障的指令。

  • 读屏障(Load Memory Barrier)就是在读屏障之后的读操作,都在读屏障之后执行。这个指令要配合写屏障一起完成,达到的效果是,写屏障之前的内存更新在读屏障之后的读操作是可见的。其实质是,在读屏障之前,先应用所有已经在失效队列中的失效操作的指令。
  • 写屏障(Store Memory Barrier)就是在写屏障之前的写操作,都必须要由Store Buffer同步刷新到主存中。达到的效果是,写屏障之后的读写操作都能看到写屏障之前的内存更新。
  • 全屏障(Full Memory Barrier)就是在全屏障之前的读写操作,其产生的内存的更新,对该屏障之后的读写操作都可见。

所以对于4.4.3章节的例子,我们简单修改一下,加入内存屏障,实现可见性。

int value =1;
bool finish = false;

void runOnCPU1(){
  value = 2;
  storeMemoryBarrier();//伪代码,写屏障,强制变量刷新到主存
  finish = true;
}

void runOnCPU2(){
  if(finish){
    loadMemoryBarrier();//伪代码,读屏障,获取多线程环境下最新的变量值
	assert value == 2;
  }
}

所以总结下来,Java 编译器在 volatile 字段的读写操作前后各插入一些内存屏障,解决了指令重排序的问题。这里要特别提到的是,对于Java语言,解决指令重排序的真正的功臣是JMM(Java Memory Model)
JMM作为沟通复杂硬件底层实现的桥梁,通过提供了一些合理的禁用缓存以及禁止重排序的方法,然后在编译时转换成具体的CPU指令,解决了可见性有序性问题,具体的方法包括synchronizedvolatilefinal。这一章节,不做深入,后续进行详细分析。

4.6 happens-before

原则上来说本应该在JMM章节讲,happens-before原则是JMM中核心的概念。但既然都讲到可见性和顺序性,那就先简单提提吧。

happens-before原则表示前一操作必定对后续操作可见。也就是说多线程环境下遵守此规则,能满足多操作对共享变量的可见性。

4.6.1 happens-before && as-if-serial

happens-before关系本质上和as-if-serial语义是同一回事。

  • happens-beforeas-if-serial分别保证多线程和单线程执行结果不被改变。
  • as-if-serial语义给使用者带来的表象是单线程按照程序的先后顺序执行的;happens-before语义带来的表象是正 确同步的多线程程序是按happens-before指定的顺序来执行的。

这两个规则的目的都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。

4.6.2 happeds-before 规则

**《JSR-133:Java Memory Model and Thread Specification》**定义了如下happens-before规则。

程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

程序顺序规则 简单的说,为了提供并行度,随便你怎么优化调整执行顺序,但对于结果总是不变的。算是总纲领吧。

监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

监视器锁规则

    static int y = 1;

     static void testMonitorRule(){
         synchronized(Object.class){
             if(y==1){
                 y=2;
             }
         }
    }

线程1先获取锁,修改y值为2,线程2获取锁以后,能直接看到y==2。

volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的 读。

volatile变量规则

    static int i = 1;
    static volatile boolean flag = false;
    static void testVolatileRuleReader(){
       i=2;//1
       flag=true;//2
    }
    static void testVolatileRuleWriter(){
        if(flag){//3
            assert i == 2;//4
        }
    }

所以步骤3是读,能看到2步骤的写。

传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

传递性规则,就着上面volatile规则的例子说,步骤1肯定是happens before 步骤4。

start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的 ThreadB.start()操作happens-before于线程B中的任意操作。

start()规则

    static void testStartRule(){
        AtomicInteger x = new AtomicInteger(1);
        final Thread t = new Thread(() -> {
            assert x.get() == 2;
        });
        x.set(2);
        t.start();
    }

主线程调用子线程t.start()之前所有对共享变量的操作,对子线程都可见。

join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作 happens-before于线程A从ThreadB.join()操作成功返回。

join()规则

static void testJoinRule() throws InterruptedException {
        AtomicInteger x = new AtomicInteger(1);
        final Thread t = new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            x.set(2);
        });
        t.start();
        t.join();
        assert  x.get() == 2;
    }

在主线程调用子线程t.join(),子线程所有对共享变量的修改主线程都可见。

5 总结

5.1 volatile用法场景

volatileJava 并发中用的很多,比如像 Atomic 包中的 value、以及 AbstractQueuedLongSynchronizer 中的 state 变量都是被 volatile 修饰来用于保证内存可见性。

在我们开发过程中,也有一些应用,比如。

通过修饰标志位,用一个线程去停止另一个线程

public static volatile   boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        final Thread t = new Thread(() -> {
            int i = 0;
            while (!run) {
                i++;
                Thread.yield();
            }
        });
        t.start();
        TimeUnit.SECONDS.sleep(10);
        run= true;
    }

双重检查锁实现单例模式

public class DoubleCheckingLock {
    private static volatile Instance instance;
    public static Instance getInstance(){
        if(instance == null){
            synchronized (DoubleCheckingLock.class){
                if(instance == null){
                    instance = new Instance();
                }
            }
        }
        return instance;
    };
    static class  Instance { }
}

具体怎么去实现的,请去看看**《Java并发编程的艺术》**这本好书。

5.2 收尾

这篇文章从实例入手,引出了多线程环境下操作共享变量会有可见性,顺序性等问题。结合硬件环境,分析为有效利用计算机资源,提高并发度,CPU又引入了高速缓存带来问题。为解决高速缓存带来的一致性问题,又分析了一波MESI协议,最后顺势分析了volatile最核心的原理内存屏障

文章开头提到的问题,看完这篇文章是已经能够回答的七七八八了。关于synchronized这一部分的问题,在synchronized章节再统一分析。


哥佬倌,莫慌到走!觉好留个赞,探讨上评论。欢迎关注面试专栏面时莫慌 | Java并发编程,面试加薪不用愁。也欢迎关注我,一定做一个长更的好男人。