关于java:深入汇编指令理解Java关键字volatile

4次阅读

共计 4584 个字符,预计需要花费 12 分钟才能阅读完成。

volatile 是什么

volatile 关键字是 Java 提供的一种轻量级同步机制。它可能保障可见性和有序性,然而不能保障原子性

可见性

对于 volatile 的可见性,先看看这段代码的执行

  • flag默认为true
  • 创立一个线程 A 去判断 flag 是否为 true,如果为true 循环执行 i++ 操作
  • 两秒后,创立另一个线程 B 将 flag 批改为false
  • 线程 A 没有感知到 flag 曾经被批改成 false 了,不能跳出循环

这相当于啥呢?相当于你的女神和你说,你好好致力,年薪百万了就嫁给你,你听了之后,致力赚钱。3 年之后,你年薪百万了,回去找你女神,后果发现你女神结婚了,她结婚的音讯基本没有通知你!难不好受?

女神结婚能够不通知你,可是 Java 代码中的属性都是存在内存中,一个线程的批改为什么另一个线程为什么不可见呢?这就不得不提到 Java 中的内存模型了,Java 中的内存模型,简称 JMM,JMM 定义了线程和主内存之间的形象关系,定义了线程之间的共享变量存储在主内存中,每个线程都有一个公有的本地内存,本地内存中存储了该线程以读 / 写共享变量的正本,它涵盖了缓存、写缓冲区、寄存器以及其余的硬件和编译器优化。
留神!JMM 是一个屏蔽了不同操作系统架构的差别的抽象概念,只是一组 Java 标准。

理解了 JMM,当初咱们再回顾一下文章结尾的那段代码,为什么线程 B 批改了 flag 线程 A 看到的还是原来的值呢?

  • 因为线程 A 复制了一份刚开始的 flage=true 到本地内存,之后线程 A 应用的 flag 都是这个复制到本地内存的 flag。
  • 线程 B 批改了 flag 之后,将 flag 的值刷新到主内存,此时主内存的 flag 值变成了false
  • 线程 A 是不晓得线程 B 批改了flag,始终用的是本地内存的flag = true

那么,如何能力让线程 A 晓得 flag 被批改了呢?或者说怎么让线程 A 本地内存中缓存的 flag 有效,实现线程间可见呢?用 volatile 润饰 flag 就能够做到:

咱们能够看到,用 volatile 润饰 flag 之后,线程 B 批改 flag 之后线程 A 是能感知到的,阐明了 volatile 保障了线程同步之间的可见性。

重排序

在论述 volatile 有序性之前,须要先补充一些对于重排序的常识。
重排序是指编译器和处理器为了优化程序性能而对指令序列进行从新排序的一种伎俩。
为什么要有重排序呢?简略来说,就是为了晋升执行效率。为什么能晋升执行效率呢?咱们看上面这个例子:

能够看到重排序之后 CPU 理论执行省略了一个读取和写回的操作,也就间接的晋升了执行效率。
有一点必须强调的是,上图的例子只是为了让读者更好的了解为什么重排序能晋升执行效率,实际上 Java 外面的重排序并不是基于代码级别的,从代码到 CPU 执行之间还有很多个阶段,CPU 底层还有一些优化,实际上的执行流程可能并不是上图的说的那样。不用过于纠结于此。
重排序能够进步程序的运行效率,然而必须遵循 as-if-serial 语义。as-if-serial 语义是什么呢?简略来说,就是不论你怎么重排序,你必须保障不管怎么重排序,单线程下程序的执行后果不能被扭转。

有序性

下面咱们曾经介绍了 Java 有重排序状况,当初咱们再来聊一聊 volatile 的有序性。
先看一个经典的面试题:为什么 DDL(double check lock)单例模式须要加 volatile 关键字?

因为 singleton = new Singleton() 不是一个原子操作,大略要通过这几个步骤:

  • 调配一块内存空间
  • 调用结构器,初始化实例
  • singleton指向调配的内存空间

理论执行的时候,可能产生重排序,导致理论执行步骤是这样的:

  • 申请一块内存空间
  • singleton指向调配的内存空间
  • 调用结构器,初始化实例

singleton 指向调配的内存空间之后,singleton就不为空了。然而在没有调用结构器初始化实例之前,这个对象还处于 半初始化状态 ,在这个状态下,实例的属性都还是默认属性,这个时候如果有另一个线程调用getSingleton() 办法时,会拿到这个半初始化的对象,导致出错。
而加 volatile 润饰之后,就会禁止重排序,这样就能保障在对象初始化完了之后才把 singleton 指向调配的内存空间,杜绝了一些不可控谬误的产生。volatile 提供了 happens-before 保障,对 volatile 变量的写入 happens-before 所有其余线程后续对的读操作。

原理

从下面的 DDL 单例用例来看,在并发状况下,重排序的存在会导致一些未知的谬误。而加上 volatile 之后会避免重排序,那 volatile 是如何禁止重排序呢?
为了实现 volatile 的内存语义,JMM 会限度特定类型的编译器和处理器重排序,JMM 会针对编译器制订 volatile 重排序规定表:

总结来说就是:

  • 第二个操作是 volatile 写,不论第一个操作是什么都不会重排序
  • 第一个操作是 volatile 读,不论第二个操作是什么都不会重排序
  • 第一个操作是 volatile 写,第二个操作是 volatile 读,也不会产生重排序

如何保障这些操作不会发送重排序呢?就是通过插入内存屏障保障的,JMM 层面的内存屏障分为读(load)屏障和写(Store)屏障,排列组合就有了四种屏障。对于 volatile 操作,JMM 内存屏障插入策略:

  • 在每个 volatile 写操作的后面插入一个 StoreStore 屏障
  • 在每个 volatile 写操作的前面插入一个 StoreLoad 屏障
  • 在每个 volatile 读操作的前面插入一个 LoadLoad 屏障
  • 在每个 volatile 读操作的前面插入一个 LoadStore 屏障


下面的屏障都是 JMM 标准级别的,意思是,依照这个标准写 JDK 能保障 volatile 润饰的内存区域的操作不会发送重排序。
在硬件层面上,也提供了一系列的内存屏障来提供一致性的能力。拿 X86 平台来说,次要提供了这几种内存屏障指令:

  • lfence 指令:在 lfence 指令前的读操作当必须在 lfence 指令后的读操作前实现,相似于读屏障
  • sfence 指令:在 sfence 指令前的写操作当必须在 sfence 指令后的写操作前实现,相似于写屏障
  • mfence 指令:在 mfence 指令前的读写操作当必须在 mfence 指令后的读写操作前实现,相似读写屏障。

JMM 标准须要加这么多内存屏障,但理论状况并不需要加这么多内存屏障。以咱们常见的 X86 处理器为例,X86 处理器不会对 读 - 读 读 - 写 写 - 写 操作做重排序,会省略掉这 3 种操作类型对应的内存屏障,仅会对 写 - 读 操作做重排序。所以 volatile写 - 读 操作只须要在 volatile 写后插入 StoreLoad 屏障。在《The JSR-133 Cookbook for Compiler Writers》中,也很明确的指出了这一点:

而在 x86 处理器中,有三种办法能够实现实现 StoreLoad 屏障的成果,别离为:

  • mfence 指令:上文提到过,能实现全能型屏障,具备 lfence 和 sfence 的能力。
  • cpuid 指令:cpuid 操作码是一个面向 x86 架构的处理器补充指令,它的名称派生自 CPU 辨认,作用是容许软件发现处理器的详细信息。
  • lock 指令前缀:总线锁。lock 前缀只能加在一些非凡的指令后面。

实际上 HotSpot 对于 volatile 的实现就是应用的 lock 指令,只在 volatile 标记的中央加上带 lock 前缀指令操作,并没有参照 JMM 标准的屏障设计而应用对应的 mfence 指令。
加上 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XcompJVM 参数再次执行 main 办法,在打印的汇编码中,咱们也能够看到有一个lock addl $0x0,(%rsp) 的操作。

在源码中也能够失去验证:

lock addl $0x0,(%rsp)前面的 addl $0x0,(%rsp) 其实是一个空操作。add 是加的意思,0x0 是 16 进制的 0,rsp 是一种类型寄存器,合起来就是把寄存器的值加 0,加 0 是不是等于什么都没有做?这段汇编码仅仅是 lock 指令的一个载体而已。其实上文也有提到过,lock 前缀只能加在一些非凡的指令后面,add 就是其中一个指令。
至于 Hotspot 为什么要应用 lock 指令而不是 mfence 指令,依照我的了解,其实就是省事,实现起来简略。因为 lock 性能过于弱小,不须要有太多的思考。而且 lock 指令优先锁缓存行,在性能上,lock 指令也没有设想中的那么差,mfence 指令更没有设想中的好。所以,应用 lock 是一个性价比十分高的一个抉择。而且,lock 也有对可见性的语义阐明。
在《IA-32 架构软件开发人员手册》的指令表中找到 lock:

我不打算在这里深刻论述 lock 指令的实现原理和细节,这很容易陷入堆砌技术术语中,而且也超出了本文的范畴,有趣味的能够去看看《IA-32 架构软件开发人员手册》。
咱们只须要晓得 lock 的这几个作用就能够了:

  • 确保后续指令执行的原子性。在 Pentium 及之前的处理器中,带有 lock 前缀的指令在执行期间会锁住总线,使得其它处理器临时无奈通过总线拜访内存,很显然,这个开销很大。在新的处理器中,Intel 应用缓存锁定来保障指令执行的原子性,缓存锁定将大大降低 lock 前缀指令的执行开销。
  • 禁止该指令与后面和前面的读写指令重排序。
  • 把写缓冲区的所有数据刷新到内存中。

总结来说,就是 lock 指令既保证了可见性也保障了原子性。
重要的事件再说一遍,是 lock 指令既保证了可见性也保障了原子性,和什么缓冲一致性协定啊,MESI 什么的没有一点关系。
为了不让你把缓存一致性协定和 JMM 混同,在后面的文章中,我特意没有提到过缓存一致性协定,因为这两者本不是一个维度的货色,存在的意义也不一样,这一部分,咱们下次再聊。

总结

全文重点是围绕 volatile 的可见性和有序性开展的,其中花了不少的局部篇幅形容了一些计算机底层的概念,对于读者来说可能过于无趣,但如果你能认真看完,我置信你或多或少也会有一点播种。
不去深究,volatile 只是一个一般的关键字。深入探讨,你会发现 volatile 是一个十分重要的知识点。volatile 能将软件和硬件联合起来,想要彻底弄懂,须要深刻到计算机的最底层。但如果你做到了。你对 Java 的认知肯定会有进一步的晋升。
只把眼光放在 Java 语言,仿佛显得十分局限。发散到其余语言,C 语言,C++ 外面也都有 volatile 关键字。我没有看过 C 语言,C++ 外面 volatile 关键字是如何实现的,但我置信底层的原理肯定是相通的。

写在最初

本着对每一篇收回去的文章负责的准则,文中波及常识实践,我都会尽量在官网文档和权威书籍找到并加以验证。但即便这样,我也不能保障文中每个点都是正确的,如果你发现错误之处,欢送指出,我会对其修改。
创作不易,你的正反馈对我来说十分重要!点个赞,点个再看,点个关注甚至评论区发送一条 666 都是对我最大的反对!
我是 CoderW,一个一般的程序员。
谢谢你的浏览,咱们下期再见!

集体公众号“CoderW”,欢送并非常感激你的关注


参考资料

  • JSR-133: http://gee.cs.oswego.edu/dl/j…
  • 《Java 并发编程的艺术》
  • 《深刻了解 Java 虚拟机》第三版
  • 《IA-32+ 架构软件开发人员手册》
正文完
 0