前言

volatileJava 并发编程中一个非常重要,也是面试常问的一个技能点,用起来很简单直接润饰在变量前面即可,可是咱们真的懂这个关键字吗?它在 JVM 底层,甚至在 CPU 层面究竟是怎么发挥效果的?

为了彻底弄清楚这个关键字,衍生出了一系列问题,真的折磨了我好几天,由于这东西往底层涉及到的常识太多了,而网上许多材料也说法不一,根本不知道哪个是正确的……

volatile 的效果

volatile 用于润饰一个成员变量,能够操控变量的可见性,即一个线程修正了这个变量之后,其他线程能够绝对当即可见。

static  boolean flag = true;
public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
        while (flag){
        }
        System.out.println("验证了可见性");
    },"Thread A").start();
    Thread.sleep(1000);
    flag = false;
}

会发现在主线程中对 flag 变量进行修正,可是 Thread A 中无法监听到最新值,导致 Thread A 中无法跳出循环。当咱们给 flagvolatile 润饰之后

static volatile  boolean flag = true;

再履行上面的比方发现 Thread A 中就能够监听到主线程关于 flag 的修正,及时结束循环。这阐明 volatile 完成了多线程之间变量的可见性。

许多人喜爱用上面的比方来阐明 volatile ,真的这么简单吗?这个定论正确吗?

古怪的现象

上面的比方是极点状况,假如咱们在 Thread A 的循环中加一行代码

Thread.sleep(1);
//或许 System.out.println("test");

你会发现即便咱们不给变量 flag 润饰 volatile ,也能结束循环,监听到主线程关于 flag 变量的修正,嘿奇不古怪?,关于这个问题我找了许多材料,看了许多文章和视频,总算有了正确答案。可是在说这个正确答案之前,我想同享一下我的剖析进程。

进程一:JMM 内存模型

Java 内存模型 (Java Memory Model) 简称 JMM,是 JSR133 标准中界说的一种抽象概念,它界说了一系列标准,标准了 Java 程序中线程怎么拜访内存。

  • 一切的同享变量都存在于主内存,每个线程有自己独立的作业内存,作业内存中的变量是主内存的拷贝
  • 线程不能直接操作主内存中的变量,只能经过自己的作业内存去和主内存交互
  • 主内存是多线程同享的,作业内存是线程私有的,线程之间的通讯都是经过主内存直接交互。

一句话概括,便是关于 Java 程序来说,你要依照它定的标准去拜访内存。

进程二:究竟什么是作业内存

这儿的作业内存其实一个逻辑概念,或许仅仅寄存器, 或许是寄存器+缓存, 也或许是多级缓存。咱们能够依次打开 使命管理器 → 性能 → CPU 看到下面这张图

volatile 关键字与计算机底层的一些杂谈

这儿我框出来的便是 CPU 的三级缓存,作业内存通常是这一块内存,这儿的计算速度大约是一般运行时内存的 100 倍。为什么会有缓存呢,这是由于寄存器和内存读取速度相差太大,直接操作内存的话 CPU 要等内存呼应,浪费了 CPU 贵重的资源,所以有了缓存来进步性能,其实这原理就和咱们开发中运用缓存中间件是一样的。

volatile 关键字与计算机底层的一些杂谈

那么有意思的问题就来了,已然三级缓存速度这么快为什么不直接把内存条都用这个三级缓存呢?由于贵嘛~~~

进程三:Java 程序内存交互进程

在 Java 程序运行时, CPU 和内存(常说的堆)是按下图的流程进行交互的,从同享内存将数据读到自己的作业内存然后操作自己作业内存中的副本,完了再同步到主内存。

volatile 关键字与计算机底层的一些杂谈

那么现在就会有一个问题,假定主内存有个变量 x = 0 ,两个线程并行修正数据自增,然后都要写回主内存,预期成果是 2,CPU0 和 CPU1 无法相互感知对方的改动,都把自己的成果写回主内存。预期成果是 2 现在变成了 1。于是为了处理多核 CPU 的数据不一致问题,出现了根据总线嗅探的缓存一致性协议技能。

进程四:缓存一致性协议

总线嗅探这个策略,本质上便是把一切的读写恳求都经过总线广播给一切的 CPU 中心,然后让各个中心去“嗅探”这些恳求,再根据本地的状况进行呼应,相当于音讯队列广播音讯。

当其他 CPU 中心嗅探到缓存中的数据被其他 CPU 修正了,会将这份数据置为失效状态,根据这个失效的操作完成了一些缓存一致性协议例如 MSI、MESI、DragonProtocol 等,其间以 intel CPU 为代表的是 MESI。

进程五:MESI

MESI 缓存一致性协议动画展示 这个动画很生动的展示了 MESI 的作业进程。关于 MESI 的更多细节这儿不过多谈论,有兴趣能够去找一些材料深入了解。

进程六:Store Buffer

Store Buffer 是这个问题的终究一个常识点。CPU 在写入同享数据时,为了防止等待其他 CPU 中心的失效呼应(由于这个进程需要经过总线发信号曩昔再接收回来的成果,关于 CPU 来说这个进程太长了),直接把数据写入到 Store Buffer 中,一起发送 Invalidate 音讯,然后持续去处理其他指令。当收到其他一切CPU 发送的 Invalidate Acknowledge 音讯时,再将 Store Buffere 中的数据写到缓存中,然后再从缓存同步到主内存。

进程七:我的认知

Store Buffer 咱们能够知道,只有当收到其他 CPU 的 Invalidate Acknowledge 之后,当时 CPU 才能把自己做的修正写到缓存,咱们的示例中 while(flag){} 死的循环体没有给 Thread A 的 CPU 让出任何时刻,所以 它无法做出 Invalidate 呼应,也便是说关于 Thread A 这个线程的CPU 来说,高速缓存一向没有失效。所以它读取的一向都是旧值。

也便是说只需咱们给 CPU 让出一点点时刻片,默认的缓存一致性协议就能帮咱们完成可见性,比方休眠,哪怕是 1ms,或许加一行输出句子(由于输出句子涉及到 IO 操作,IO 操作是 CPU 托付给 DMA 去做的),

进程八:逝世证明

假如上述认知是正确的,那么根据缓存一致性协议就能够以为一开端的代码示例中,主线程并没有将 flag = false 写到主内存,由于它一向收不到 Thread A 这个线程的 Invalidate Acknowledge

咱们写代码证明,在主线程中再敞开一个线程,假如这个线程能够履行到打印句子,那么阐明 flag 的值被主线程写回到了主内存。

static  boolean flag = true;
public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
        while (flag){
            //Thread.sleep(1);
        }
        System.out.println("验证了可见性1");
    },"Thread A").start();
    Thread.sleep(1000);
    flag = false;
    Thread.sleep(10);
    new Thread(() -> {
        while (flag){
            //Thread.sleep(1);
        }
        System.out.println("验证了可见性2"); //假如打印这句话,则阐明 flag 的值现已写回主内存,或许说至少现已写到主线程那颗 CPU 的高速缓存中
    },"Thread B").start();
}

成果打印了 验证了可见性2,这阐明 flag = false 现已被写到了主内存中,然后我就溃散了,这他妈究竟啥原因啊!!!

volatile 关键字与计算机底层的一些杂谈

进程九:正确答案-Java 即时编译器(JIT)

经过我的不懈努力,总算在某篇文章找到了本质原因,居然是在于 Java即时编译器(JIT) 将 while 这部分代码做了优化。while(flag)true 的次数过多时,JIT 直接将 while(flag) 默以为 while(true)

volatile 关键字与计算机底层的一些杂谈

咱们能够经过添加 JVM 参数 -Xint 关闭 JIT 优化。这样不加 volatile 也能使程序停止下来。于是我验证了一下确实是这样,至此纠结我几天的问题总算处理了。

这个问题我请教过许多朋友,他们都不知道,还说我研究的太深了……可是我这个人啊,遇到问题就非得搞清楚,否则就浑身难受啊~~~ 折磨了我几天的问题总算搞懂了,柳暗花明。

不过新的问题又出现了,已然这样,volatile 的效果又是什么?……由于这儿本质上加不加 volatile 其实都能完成可见性啊?这个等会说

volatile 关键字与计算机底层的一些杂谈

进程十:我错在哪了

volatile 关键字与计算机底层的一些杂谈

其实缓存失效的交互都是经过总线发信号的,当 CPU1 要告诉 CPU0 你的缓存要失效了,经过总线发一个信号曩昔修正 CPU0 的缓存行地址, CPU0 经过总线嗅探感知到缓存行地址被修正就会再经过总线回一个 Invalidate Acknowledge 音讯,这个进程不会占用 CPU0 的时刻片。

volatile 的可见性

首先能够确定的一点是 JIT 不会对加了 volatile 关键字的变量的相关代码进行优化。加了 volatile 之后咱们运用 javap -v 的指令反编译之后能够看到这个变量的 flags 中多了一个标识 ACC_VOLATILE 。这个东西在调用到 C++ 代码是这样的

inline void OrderAccess::fence() {
  if (os::is_MP()) {
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
}

__asm__ 代表这是汇编指令

终究到 CPU 汇编指令之后是一个 lock; addl $0,0(%%rsp),经过查阅

8.1.4 Effects of a LOCK Operation on Internal Processor Caches For the Intel486 and Pentium processors, the LOCK# signal is always asserted on the bus during a LOCK operation, even if the area of memory being locked is cached in the processor. For the P6 and more recent processor families, if the area of memory being locked during a LOCK operation is cached in the processor that is performing the LOCK operation as write-back memory and is completely contained in a cache line, the processor may not assert the LOCK# signal on the bus. Instead, it will modify the memory location internally and allow it’s cache coherency mechanism to ensure that the operation is carried out atomically. This operation is called “cache locking.” The cache coherency mechanism automatically prevents two or more processors that have cached the same area of memory from simultaneously modifying data in that area.

翻译过来

LOCK 操刁难处理器内部缓存的影响关于Intel486和Pentium处理器,在LOCK操作期间,LOCK信号总是在总线上被断语,即便被确定的内存区域缓存在处理器中。关于P6和最新的处理器系列,假如在LOCK操作期间被确定的内存区域作为回写内存缓存在正在履行LOCK操作的处理器中,而且彻底包含在缓存线中,处理器或许不会在总线上断语LOCK信号。相反,它将在内部修正内存位置,并答应它的缓存一致性机制,以确保操作是原子地履行的。这种操作称为“缓存确定”。缓存一致性机制主动防止两个或多个缓存了相同内存区域的处理器一起修正该区域的数据

也便是说在新的 CPU 中这个指令操控的是确定缓存行。LOCK 指令的效果

  • 当 CPU 看到这条指令,会强制确定缓存即将数据直接从 Store Buffer 写进缓存,不需要发送 Invalidate 音讯给其他 CPU。而且经过总线强制使其他 CPU 的该数据缓存当即失效。在确定期间,其他CPU不能一起缓存此数据
  • 提供了内存屏障功能,LOCK 前后的指令不能够重排序。

所以 volatile 终究完成可见性的原理是汇编指令 LOCK 。然后咱们说 volatile 完成可见性这个定论肯定是没错的,可是它完成的是根据 JMM 内存标准上的可见性,便是说这是 JMM 层面的,在 CPU、高速缓存、主内存之间的可见性是经过缓存一致性协议去完成的。

那么说到这儿咱们能够得出一个定论,volatile 和缓存一致性协议有联系吗?由于许多文章和视频在说 volatile 的时分都会说到 MESI ,更有甚者说 volatile 关键字底层触发了缓存一致性协议 MESI 。。。

这个要说有也有,说没有也没有,缓存一致性协议是硬件层面保障多核 CPU 之前缓存数据一致性的技能,volatile 是完成 JMM 层面可见性的技能,只能说 volatile 底层经过汇编指令 LOCK 用到了本来就客观存在的缓存一致性协议技能。

CPU 内存屏障

内存屏障, 是一类同步屏障指令,是CPU或编译器在对内存随机拜访的操作中的一个同步点,使得此点之前的一切读写操作都履行后才能够开端履行此点之后的操作。

volatile 关键字与计算机底层的一些杂谈

其实便是在两个指令之间刺进了一个东西限制了上面的指令无法跑到下面这条指令后边,下面这条指令也无法跑到上面这条指令前面,如上图。

内存屏障的出现是为了处理多个 CPU 之间的乱序履行问题,同一个 CPU 的指令现现已过 Store Buffer 确保次序履行了,但 Store Buffer 无法确保多个 CPU 存在同享数据时指令的次序履行

咱们能够看一个经典的示例来验证重排序

static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
    for (; ; ) {
        x = 0;y = 0;a = 0;b = 0;
        Thread one = new Thread(() -> {
            a = 1;
            x = b;
        });
        Thread two = new Thread(() -> {
            b = 1;
            y = a;
        });
        one.start();two.start();one.join();two.join();
        System.out.println("x:" + x + ",y:" + y);
        //0,1    1,0  1,1
        if (x == 0 && y == 0) {
            break;
        }
    }
    System.out.println("验证了重排序");
}

咱们假定多 CPU 之间的指令不会发生重排序那么这个程序的成果只能有三个,x=0,y=1;x=1,y=0;x=1,y=1; 可是履行成果并不是,这阐明会发生指令重排序,假如想从 Store Buffer 层面深究能够看文末贴出的参考文章。

注意指令重排序是 CPU 层面的概念,不是 Java 层面的。

JVM 内存屏障

内存屏障本身是 CPU 指令层面的概念,不过在 JVM 层面,它定出了自己的标准去对 CPU 层面的内存屏障做出一层封装。

在 JVM 层面,JVM 标准要求完成者有必要完成下面四种逻辑上的内存屏障(Load 代表读取变量,Store 代表修正变量)

  • LoadLoad : 关于这样的句子 Load1;LoadLoad;Load2;,在 Load2 以及后续读取操作要读取的数据被拜访前,确保 Load1 要读取的数据被读取结束
  • StoreStore:关于这样的句子 Store1;StoreStore;Store2;,在 Store2 以及后续写入操作履行前,确保 Store1 的写入操刁难其他处理器可见
  • LoadStore :关于这样的句子 Load1;LoadStore;Store2;,在 Store2 以及后续写入操作被刷出前,确保 Load1 要读取的数据被读取结束
  • StoreLoad:关于这样的句子 Store1;StoreLoad;Load2;,在 Load2 以及后续一切读取操作履行前,确保Store1 的写入对一切处理器可见

volatile 制止指令重排序

这个就很简单了,上面咱们现已知道了 volatile 最底层是汇编指令 LOCK,该指令本身就提供了内存屏障的功能,所以只需要在 JVM 层面调用该指令完成内存屏障即可。

volatile 的完成中被它润饰的变量在读写的时分前后会加内存屏障,其规则是

读操作

操作 效果
在每个 volatile 读操作的后边刺进一个 LoadLoad 制止处理器把上面 volatile 读与下面的一般读重排序
在每个 volatile 读操作的后边刺进一个 LoadStore 制止处理器把上面 volatile 读与下面的一般写重排序

volatile 关键字与计算机底层的一些杂谈

写操作

操作 效果
在每个 volatile 写操作的前面刺进一个 StoreStore 确保 volatile写 之前,前面的一切一般写操作都现已刷新主内存
在每个 volatile 写操作的后边刺进一个 StoreLoad 防止 volatile写 与后边或许有的 volatile读/写操作重排序

volatile 关键字与计算机底层的一些杂谈

不管是哪个屏障,这都是 JVM 层面的逻辑完成,在最底层仍是经过 CPU 汇编指令 LOCK 去完成的。

为什么 volatile 不能确保原子性

其实这个问题挺搞笑的,假如你问一个东西为什么能到达某个效果,那倒是能够聊聊底层,你这问它为啥不能确保原子性,本来就不能有啥为什么呢。。。

从上面内存模型和内存交互的常识,咱们能知道,当对一个 volatile 变量进行写的时分会确定缓存行,将值立马刷新到主内存,可是这个操作又不会影响现已被读到其他 CPU 寄存器内的值。举个比方,

  • CPU0 先读取 volatile 变量 x 读到的值是 0,此时 CPU0 的高速缓存中缓存的值也是 0
  • CPU0 对 x 履行 +1 计算得到的值 x=1
  • CPU1 对 x 进行写,把值改为 1,然后将 CPU0 的缓存置为失效,将 x=1 写到主内存
  • CPU0 将 x=1 写到 Store Buffer,将 CPU1 的缓存失效,将 x=1 写到缓存,从而写到主内存

这不便是一个正常或许出现的进程么,所以本来便是不能确保原子性的。

结语

至此我花了一个星期去学习了许多硬件底层方面的常识,文章篇幅有限无法详细的说透每个常识点,尽管花了一个星期的时刻证明出来一个错误的定论,可是这个进程让我学到了许多底层技能,仍是很值得的。

总而言之,volatile 的效果

  • 确保变量修正的可见性
  • 制止指令重排序

假如这篇文章对你有帮助,记得点赞加关注!你的支撑便是我持续创作的动力!

参考文章 关于缓存一致性协议、MESI、StoreBuffer、InvalidateQueue、内存屏障、Lock指令和JMM的那点事