【并发编程】--- volatile底层原理

源码地址:https://github.com/nieandsun/concurrent-study.git


1 volatile如何保证可见性

上篇文章《【并发编程】— 并发编程中的可见性、原子性、有序性问题》复现了两个线程修改相同共享变量的不可见性问题。相信大家肯定都知道,解决该问题的方法之一就是:在声明共享变量时加上volatile关键字。
那底层原理是什么呢???

首先应该知道在不加volatile关键字时,即使线程2已经将共享变量的值进行了修改,但是由于线程1一直在使用着该共享变量(并不会在某个时刻去同步一下主内存),所以线程1无法感知到线程2对共享变量的修改(可以参照上文中的图1去理解)。

这时候你肯定会想,如果线程2把共享变量修改了之后,直接能通知线程1就好了。。。

volatile关键字就做到了这一点,它具体的实现原理可以用下图进行解释:

注意: 有很多网友告诉我说,其实现在的CPU已经不使用总线了,这一点我不是很确定,但按照我查阅的资料以及自己的理解,即使不使用总线,这一块的底层原理也是一样的。。。

在这里插入图片描述
其实在主内存和各个线程之间还存在着一条总线,volatile关键字之所以能保证线程间的可见性,有三点至关重要:

  • (1)线程2修改了本工作内存中共享变量的副本后,在写回主内存之前对总线进行了lock操作
  • (2)各个线程会通过总线嗅探机制对总线进行监控,一旦发现有lock操作,且lock的变量涉及到本线程工作内存中的数据副本,就将这些数据清空,并重新从主内存中读取

当然如果只满足了前面两点还不行,因为有可能线程1刚一监测到共享变量变了,就立刻去主内存中拉取数据,而此时线程2还没将修改后的变量同步到主内存,那线程1拿到的共享变量将还是之前的值,这相当于线程2对共享变量的修改,线程1还是没监测到,也就无法保证线程间的可见性了。所以这里还有至关重要的一点:

  • (3) 在线程2将修改后的变量写回到主内存之前,其他线程从主内存中读取该共享变量会被阻塞,线程2将变量写回主内存后,会进行unlock,然后其他线程才能读取到。 — 当然这个时间是很快的。

正是基于以上三点,volatile才可以保证线程间的可见性。

顺便提一下,上诉只是理论,其具体实现是利用汇编语言的Lock前缀指令


2 volatile为什么不能保证原子性问题

例子可参照上文《【并发编程】— 并发编程中的可见性、原子性、有序性问题》中的第二个例子,这里我以两个线程来说明原因。

volatile不能保证原子性问题的原因可用下图进行解释:
在这里插入图片描述
如图所示,假如线程1和线程2都循环执行num++操作,当线程1执行了一次循环进行了num++操作后,会进行assign —> store—> 并尝试进行lock,但是此时突然嗅探到线程2已经对共享变量num进行了lock操作,那线程1就不得不从主内存中拉取num的最新值,再进行新的循环,也就是说相当于线程1浪费了一次循环。 所以即使加上volatile也不能保证多线程程序的原子性。


3 volatile可以保证有序性的原因

上篇文章《【并发编程】— 并发编程中的可见性、原子性、有序性问题》重现了因代码重排序引发的多线程间的有序性问题,并叙述了重排序的好处和类型。

但是试想如果各个线程的代码都可以肆无忌惮,任意的进行重排序,那我们程序员要想写出符合自己意愿的代码,那得考虑多少种情况 —> 这将严重增加程序猿的负担!!! —> 因此规定某些情况下可以重排序,而有些情况下绝对不能重排序 就成了势在必行的事

在了解多线程情况下volatile可以保证有序性的原理之前,先来了解一下单线程情况下什么时候不能进行重排序。


3.1 单线程禁止重排序的规则 as-if-serial

单线程情况下之所以我们写的代码可以按照我们自己的意愿进行执行,是因为在单线程情况下有一个as-if-serial规则 — as-if-serial翻译成中文就是看起来貌似是有序执行的,即看似我们的代码是从上到下一行一行进行执行的,没有进行过重排序的。

as-if-serial语义的意思是:不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的。

以下数据有依赖关系,不能重排序。

  • 写后读:
int a = 1;
int b = a;
  • 写后写
int a = 1;
int a = 2;
  • 读后写
int a = 1;
int b = a;
int a = 2;

编译器和处理器不能对存在数据依赖关系的操作进行重排序 — 其实这就是as-if-serial的具体规则。因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。举例如下:

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

a和c之间存在数据依赖关系,同时b和c之间也存在数据依赖关系。因此在最终执行的指令序列中,c不能被重排序到a和b的前面。但a和b之间没有数据依赖关系,编译器和处理器可以重排序a和b之间的执行顺序。下面是该程序的两种可能的执行顺序:

可以这样:
int a = 1;
int b = 2;
int c = a + b;
也可以重排序成这样:
int b = 2;
int a = 1;
int c = a + b;

通过上面的例子可以看到as-if-serial规则把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器可以让我们感觉到:单线程程序看起来是按程序书写的顺序来一行一行进行执行的。—》 因此我们不必担心单线程情况下程序的重排序问题。

但是不同处理器之间和不同线程之间的数据依赖性编译器和处理器不会考虑 — 》 这就是为什么多线程程序会出现因程序重排序问题引发的有序性问题的原因。


3.1 多线程禁止重排序的规则 happens-before


3.1.1 happens-before规则的定义和进一步理解

happens-before规则其实是多个操作之间的内存可见性规则,其定义如下:

happens-before用来阐述多个操作之间的内存可见性规则。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系 。


但是两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)

进一步理解:

上面的定义看起来很矛盾,其实它是站在不同的角度来说的。

  • (1) 站在Java程序员的角度来说:JMM保证,如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • (2) 站在编译器和处理器的角度来说:JMM允许,两个操作之间存在happens-before关系,不要求Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序是允许的。

3.1.2 happens-before规则的具体内容

读完3.1.1,我猜很多人还是懵逼的。。。 都啥玩意啊。。。

我想了很久觉得还是从禁止重排序的角度去理解比较好理解。

接下来我们来看一下 happens-before的具体规则,并从禁止重排序的角度对这些规则进行解读一下:

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

其实就是对as-if-serial规则的另一种说法,即单线程情况下编译器和处理器不能对存在数据依赖关系的操作做重排序

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

其实就是说一个线程必须等到另一个线程把锁释放了,它才能抢到这把锁 —> 这两个操作之间不能重排序

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

请看3.1.2.1

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

这个没有什么好说的。。。

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

其实就是说线程B中执行的操作,肯定发生在线程B开启之后 —> 两者不能颠倒,即不能重排序

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

其实就是说ThreadB.join()这句代码之后的操作,必须发生在线程B执行完之后 —> 两者不能重排序,这里可以参看我的另一篇文章《【并发编程】— Thread类中的join方法》

  • (7)线程中断规则:对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。

其实就是说只有对线程发起了interrupt操作,该线程才能感知到有中断线程的请求 —> 两者不能重排序,这里可以参看我的另一篇文章《【并发编程】— interrupt、interrupted和isInterrupted使用详解》

相信按照蓝色字体来看 happens-before规则你肯定会更容易理解它,当然或许你会觉得里面好多规则都像废话一样,尤其是(2)、(4)、(5)、(6)、(7)这几条规则。 —> 但是你要知道我们所谓的因果关系,计算机可并不懂 —>正是有了这些规则才保证了我们写的代码,能按照我们的意愿来执行 。


3.1.2.1 volatile变量规则再理解 + 为何volatile可以保证有序性 ★★★

我觉得光看3.1.2中描述的volatile变量规则,你是看不出个四五六的。。。

因此这里直接从volatile变量的重排序规则说起。
volatile变量的重排序规则可以参看下表:
在这里插入图片描述
总结起来就是:

  • (1)当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • (2)当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • (3)当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

上面的规则其实可以用下图进行表示:
在这里插入图片描述

通过该图,我觉得还可以得到一个结论: 就是只要你前面对这个volatile共享变量写了,我后面无论写还是读,都可以感知到你前面写的内容 —>其实从这个角度也可以解释为什么volatile可以保证线程的可见性 ★★★


在这里从纯理论方面分析一下volatile可以保证有序性的原因:

首先从上文的两个例子来看,之所以会出现有序性问题,就是因为其他变量的修改(或者说写)与目标共享变量的修改发生了重排序,而对目标共享变量加上volatile关键字之后, 其他变量的修改,就不能与加上volatile关键字的目标共享变量的修改进行重排序了,因此也就不会出现由于重排序导致的我们写的代码和实际运行生成的结果不一致的问题了。

分析到这里以后其实可以对我上面画的图做如下注释了:★★★
在这里插入图片描述


3.1.2.2 volatile实现禁止重排序的底层原理(或者说理论) — 内存屏障

在Java中对于volatile修饰的变量,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序问题。
内存屏障有如下四种类型:
在这里插入图片描述
到底内存屏障是个啥,这里以LoadLoad内存屏障为例画个图来理解一下:
在这里插入图片描述
在读1和读2之间如果加了LoadLoad内存屏障,则读1和读2就不能再进行重排序了—》 这就是所谓的内存屏障。

对于volatile关键字,按照规范会有下面的操作(貌似每个资料都这么说):

在每个volatile写入之前,插入一个StoreStore,写入之后,插入一个StoreLoad
在每个volatile读取之后,插入LoadLoad和LoadStore

其实这里我有一个疑问: 既然前面3.1.2.1中说 :

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

那在volatile写之前插入StoreStore,是不是只保证了volatile本次写不能与前面的写进行重排序???而保证不了本次volatile写与前面的读不能重排序??? —> 画图如下:
在这里插入图片描述
欢迎并期待您的留言!!!
顺便多说一句 : 我们平常用处理器一般为X86,它其实没那么多指令,只有StoreLoad。


3.1.2.3 volatile的具体实现方式 — lock前缀指令

下面的内容来自于某公开课视频:

通过对OpenJDK中的unsafe.cpp源码的分析,会发现被volatile关键字修饰的变量会存在一个“lock:”的前缀。

Lock前缀指令并不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可
以理解为CPU指令级的一种锁。—> 也就是说真正实现内存屏障功能的其实是Lock指令!!!

同时该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。

在具体的执行上,它先对总线和缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的脏数据全部刷新回主内存。在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。

上面两段的内容其实和我在本文第1小节分析的一致。


顺便多说一句:今天看到另一个公开课,他说在win系统上volatile的底层实现是lock前缀指令
而在linux系统的底层实现是通过下面三条系统级指令来完成的:
在这里插入图片描述
具体如何欢迎并期待您的留言!!!


3.2 结合上文例子再聊一聊volatile是如何保证有序性的

上文例子1:

@Outcome(id = {"0, 1", "1, 0", "1, 1"}, expect = ACCEPTABLE, desc = "ok")
@Outcome(id = "0, 0", expect = ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class OrderProblem2 {

    int x, y;

    /****
     * 线程1 执行的代码
     * @param r
     */
    @Actor
    public void actor1(II_Result r) {
        x = 1;
        r.r2 = y;
    }


    /****
     * 线程2 执行的代码
     * @param r
     */
    @Actor
    public void actor2(II_Result r) {
        y = 1;
        r.r1 = x;
    }
}

r.r1和r.r2之所以出现全为0的情况,就是因为可能线程1和线程2都进行了重排序,然后刚好,线程1将执行为r.r2 = y ,然后线程2又执行了r.r1 = x ;这时候由于x 和 y均未赋值,所以他们的值都为0,因此也就出现了r.r1和r.r2全为0的情况。

而假设r.r1和r.r2都被volatile进行修饰的话,则由于r.r1 = x;r.r2 = y;都为写操作,则其前面的x =1;y=1;操作都不能与之进行重排序,也就保证了r.r1和r.r2不可能出现都为0的情况 —> 由此便解决了该种情况下由于重排序导致的我们写的代码和实际运行生成的结果不一致的问题。


上文例子2,代码如下,有兴趣的自己分析一下吧。

@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State
public class OrderProblem1 {
    int num = 0;
    boolean ready = false;

    /***
     * 线程1 执行的代码
     * @param r
     */
    @Actor
    public void actor1(I_Result r) {
        if (ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }

    /***
     * 线程2 执行的代码
     * @param r
     */
    @Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }
}

4 后记

我写本篇文章时翻阅了大量资料、观看了大量的公开课视频。。。但是由于没有哪份资料或哪个公开课视频能单独地完全让我信服,所以这篇文章综合了很多资料和视频的观点,当然也包含了大量自己的理解。

亲爱的读者朋友,如若发现哪里有误,非常欢迎您能给我留言指出!!!


发布了212 篇原创文章 · 获赞 266 · 访问量 48万+

猜你喜欢

转载自blog.csdn.net/nrsc272420199/article/details/104854629