1、volatile 的应用

volatile 是轻量级的 synchronized,他在多处理器开发中保证了共享变量的可见性。可见性的意思是当一个线程修改一个共享变量时,另外一个线程读到这个修改的值。如果 volatile 变量使用恰当的话,它比 synchronized 的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

1.1、保证可见性,不保证原子性

这里的可见性是指当写一个线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的,即

  1. 当写一个 volatile 变量时,JMM 会把线程本地内存中的变量强制刷新到主内存中去
  2. 这个写操作会导致其他线程中的 volatile 变量缓存无效

下面的代码可以验证可见性

  1. package com.yj.volatile_;
  2. import java.util.concurrent.TimeUnit;
  3. /**
  4. * @description: volatile 测试
  5. * 这里可以分别运行 flag 加 volatile 关键字和不加,看看有什么区别
  6. * @author: erlang
  7. * @since: 2021-01-02 19:06
  8. */
  9. public class VolatileTest {
  10. private volatile boolean flag = true;
  11. public void invoke() {
  12. System.out.println("invoke start");
  13. while (flag) {
  14. }
  15. System.out.println("invoke end");
  16. }
  17. public static void main(String[] args) {
  18. final VolatileTest test = new VolatileTest();
  19. new Thread(test::invoke, "volatile test").start();
  20. try {
  21. // 暂停 1s 中,是为了的等线程启动
  22. TimeUnit.SECONDS.sleep(1);
  23. } catch (InterruptedException e) {
  24. e.printStackTrace();
  25. }
  26. // 这里的 flag 不加 volatile 关键字时,线程会一直等待 flag 写回主内存,等待时间会很长
  27. test.flag = false;
  28. }
  29. }

1.2、禁止指令重排序

Java 程序中天然的有序性可以总结为一句话,如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。 前半句是线程内表现为串行的语义(Within-Thread-As-If-Serial Semantic),后半句是指令重排序工作内存与主内存同步延迟现象

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重排序的一种手段。普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。 volatile 修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。指令重排序会遵循下面两个规则

  1. 不会对存在数据依赖关系的操作进行重排序
  2. 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

1.3、volatile 不适用的场景

volatile 变量只能保证可见性,不适合复合操作,在不符合以下两条规则的运算场景中,我们仍然要通过加锁来保证原子性

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  2. 变量不需要与其他的状态变量共同参与不变约束

2、volatile 的定义和实现原理

2.1、测试代码

  1. package com.yj.volatile_;
  2. /**
  3. * @description: 单例
  4. * @author: erlang
  5. * @since: 2021-01-02 23:09
  6. */
  7. public class Singleton {
  8. public static volatile Singleton instance;
  9. public static Singleton getInstance() {
  10. if (instance == null) {
  11. synchronized (Singleton.class) {
  12. if (instance == null) {
  13. instance = new Singleton();
  14. }
  15. }
  16. }
  17. return instance;
  18. }
  19. public static void main(String[] args) {
  20. Singleton.getInstance();
  21. }
  22. }

2.2、JITWatch 汇编日志分析工具

JITWatch 是 GitHub 上的一个开源项目:AdoptOpenJDK/jitwatch,一个用于分析汇编日志的图形界面工具,JITWatch mac 安装使用步骤如下:

  1. clone 项目:git clone https://github.com/AdoptOpenJDK/jitwatch.git
  2. 编译:mvn clean compile test exec:java
  3. 启动:在 JITWatch 项目下直接执行 sh launchUI.sh 启动 JITWatch
  4. 在 ~/.bash_profile 文件中增加下面配置后,终端输入 jitwatch 回车,即可启动 jitwatch

alias jitwatch=’current=(pwd) && cd /usr/local/workspace/jitwatch && ./launchUI.sh && cd ${current}’ 其中 /usr/local/workspace/jitwatch 路径是 JITWatch 安装目录

2.3、idea 需配置如下

四、volatile 关键字详解 - 图1

  1. VM options: -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:CompileCommand=compileonly,*Singleton -XX:+LogCompilation -XX:LogFile=/usr/local/workspace/log/hotspot.log
  2. Enviroment variable: LD_LIBRARY_PATH=/Users/HappyFeet/tools/hsdis

配置完之后,点击运行即可,然后利用 JITWatch 工具对/usr/local/workspace/log/hotspot.log文件进行分析,结果如下:

四、volatile 关键字详解 - 图2

  1. 0x00000001058e792f: movb $0x0,(%rsi,%rax,1)
  2. 0x00000001058e7933: lock addl $0x0,(%rsp) ;*putstatic instance
  3. ; - com.yj.vola.Singleton::getInstance@24 (line 16)

2.4、volatile 定义

Java 语言规范第三版中对 volatile 的定义如下:Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。如果一个字段被声明成 volatile,Java 线程内存模型确保所有线程看到这个变量的值是一致的。但这个关键字不是线程安全的。

volatile 实现原理相关的 CPU 术语与说明如下

四、volatile 关键字详解 - 图3

2.5、volatile 实现原理

volatile 是如何来保证可见性的呢?字节码层面在变量前面的 flags 加了 ACC_VOLATILE 标志。通过 JITWatch 工具分析,可以看到由 volatile 修饰的共享变量进行写操作时会出现第二行汇编代码,通过 IA32 手册可知,lock 前缀的指令在多核处理器下的作用,主要是:

  1. 确保指令重排序时,不能把后面的指令重排序到内存屏障之前的位置,也不能把前面的指令重排序到内存屏障之后的位置
  2. 确保在执行到内存屏障修饰的指令时,前面的代码全部执行完成
  3. 将当前处理器缓存行的数据写回到系统内存
  4. 如果是写操作,则会导致其他 CPU 里缓存了该内存地址的数据无效

为了提高处理速度,处理器不直接和内存通信,而是将系统内存的数据读到内部缓存(L1、L2 或其他)后再进行操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,在执行计算操作就会有问题。所以多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

2.5.1、lock 前缀指令会引起处理器回写到内存

lock 前缀指令导致在执行执行指令期间,声言处理器的 lock# 信号。在多处理器环境中,lock#信号确保在声言该信号期间,处理器可以独占任何共享内存(这里锁的是总线)。但是,在最近的处理器里,lock# 信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。

  1. 锁住总线会导致其他处理器不能访问总线,不能访问总线就意味着不能访问系统内存
  2. 锁缓存,如果访问的内存区域已经缓存在处理器内部,则不会声言 lock# 信号;相反会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为缓存锁定,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

2.5.2、一个处理器的缓存回写到内存会导致其他处理器

IA-32 处理器和 Intel64 处理器使用 MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32 和 Intel64 处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存在数据总线上保持一致。

3、Java 内存模型中对 volatile 变量定义的规则

假定 T 表示一个线程,V 和 W 分别表示两个 volatile 型变量,那么在进行 read、load、use、assign、store 和 write 操作时,需要满足如下规则:

  1. 只有当线程 T 对变量 V 执行的前一个动作是 load 的时候,线程 T 才能对变量 V 执行 use 动作;并且,只有当线程 T 对变量 V 执行的后一个动作是 use 的时候,线程 T 才能对变量 V 执行 load 动作。线程 T 对变量 V 的 use 动作可以认为是和线程 T 对变量 v 的 load、read 动作相关联,必须连续一起出现。这条规则要求在工作内存中,每次使用 V 前都必须先主动从主内存刷新最新的值,用于保证看见其他线程对变量 V 所做的修改后的值。
  2. 只有当前 T 对变量 V 执行的前一个动作是 assign 的时候,线程 T 才能对变量 V 执行 store 动作;并且,只有当线程 T 对变量 V 执行的后一个动作是 store 的时候,线程 T 才能对变量 V 执行 assign 动作。线程 T 对变量 V 的 assign 动作可以认为是和线程 T 对变量 V 的 store、write 动作相关联,必须连续一起出现。这条规则要求在工作内存中,每次修改 V 后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量 V 所做的修改。
  3. 假定动作 A 是线程 T 对变量 V 实施的 use 或 assign 动作,假定动作 F 是和动作 A 相关联的 load 或 store 动作,假定动作 P 是和动作 F 相应的对变量 V 的 read 或 write 动作;类似的,假定动作 B 是线程 T 对变量 W 实施的 use 或 assign 动作,假定动作 G 和动作 B 相关联的 load 或 store 动作,假定动作 Q 是和动作 G 相应的的对变量 W 的 read 或 write 动作。如果 A 先于 B,那么 P 先于 Q。这条规则要求 volatile 修饰的变量不会被指令重排序优化,保证代码的执行顺序和程序的顺序一致。