并发编程专题-基础-8、Volatile
1. 简要回答
2. 乱序问题
并发基础-11、乱序问题3. 前置知识
3.1. JMM 原子操作
3.1.1. JMM 及 MESI
Volatile 保证了 JMM 三大特性的可见性和有序性,但无法保证原子性 (Synchronized 保证了可见性、有序性和原子性)。
3.2. 可见性
3.2.1. 可见性原理
1、在对变量进行 assign 操作时,加了 Volatile 修饰的变量,计算机底层会加一个 lock 前缀指令(这个操作利用了 MESI 协议对 M 状态变量的处理逻辑,向总线发送 Invalid 信息,并立即写回主内存)。
2、同时,lock 前缀指令 有内存屏障的作用,在 assign 的时候就对变量施加了 缓存锁,防止读取未修改的数据。
3.2.2. JVM 源码实现
bytecodeinterpreter.cpp
1 |
|
orderaccess_linux_x86.inline.hpp
1 |
|
3.3. 有序性
3.3.1. 指令重排序
https://www.jianshu.com/p/40cb45484f1e
举例:
3.3.2. volatile 禁止重排的原理
https://juejin.im/post/5ae9b41b518825670b33e6c4
3.3.2.1. 读操作
3.3.2.2. 写操作
3.3.3. JVM 实现
3.4. 不保证原子性
3.4.1. 为什么
1、诸葛结合了 volatile,把原因归结为写无效,比如 2 个线程都 use 后计算了新值,线程 2 出了问题没来得及 assign,没有触发 MESI 协议,但是第一个线程正常触发 MESI 协议,线程 2 也通过总线嗅探机制,收到了已经变化的信号,那么第 2 个线程没来得及 assign 的新值就失效了,那么也就白加了,导致了丢失 ++ 操作
2、尚硅谷 - 周阳讲把原因归结为写覆盖,比如 2 个线程都 use 后计算出了新值,线程 2 出了问题没来得及 assign 触发 MESI 协议,但是第一个线程正常触发 MESI 协议,但是线程 2 由于某种原因没有收到了已经变化的信号,那么第 2 个线程 assign 的新值也执行了 store-write 操作,那么第一个线程的加操作被覆盖了,导致了丢失 ++ 操作
一句话:将要 assign-store-write 的这个原子性操作,被挂起或者其他原因,没有收到其他线程的 M 消息,也就没有正常去失效自己之前将要的 assign-store-write 的新变量值,线程恢复时去更改主内存,那么就覆盖其他线程的操作。
我们看到 jvm 通过 lock 实现了 volatile 的内存屏障,但是 volatile 并不具有原子性。原因很简单,不同 CPU 依旧可以对同一个缓存行持有,一个 CPU 对同一个缓存行的修改不能让另一个 CPU 及时感知,因此出现并发冲突。线程安全还是需要用锁来保障,锁能有效的让 CPU 在同一个时刻独占某个缓存行,执行完并释放锁后,其他 CPU 才能访问该缓存行。
L1 缓存中的变量有两种赋值方式, 一种是从内存加载进来, 另一种是从寄存器回写过来的.
因为缓存一致性协议只能失效缓存行的数据, 而不能失效寄存器的数据, 导致 volatile 不能做到原子性.
volatile 和 cas 都是基于 lock 前缀实现,但 volatile 却无法保证原子性这是因为:Lock 前缀只能保证缓存一致性,但不能保证寄存器中数据的一致性,如果指令在 lock 的缓存刷新生效之前把数据写入了寄存器,那么寄存器中的数据不会因此失效而是继续被使用,就好像数据库中的事务执行失败却没有回滚,原子性就被破坏了。以被 volatile 修饰的 i 作 i++ 为例,实际上分为 4 个步骤:
mov 0xc(%r10),%r8d ; 把 i 的值赋给寄存器
inc %r8d ; 寄存器的值 +1
mov %r8d,0xc(%r10) ; 把寄存器的值写回
lock addl $0x0,(%rsp) ; 内存屏障,禁止指令重排序,并同步所有缓存
如果两个线程 AB 同时把 i 读进自己的寄存器,此时 B 线程等待,A 线程继续工作,把 i++ 后放回内存。按照原子性的性质,此时 B 应该回滚,重新从内存中读取 i,但因为此时 i 已经拷贝到寄存器中,所以 B 线程会继续运行,原子性被破坏。
而 cas 没有这个问题,因为 cas 操作对应指令只有一个:lock cmpxchg dword ptr [edx], ecx ;
该指令确保了直接从内存拿数据(ptr [edx]),然后放回内存这一系列操作都在 lock 状态下,所以是原子性的。
总结:volatile 之所以不是原子性的原因是 jvm 对 volatile 语义的实现只是在 volatile 写后面加一个内存屏障,而内存屏障前的操作不在 lock 状态下,这些操作可能会把数据放入寄存器从而导致无法有效同步;cas 能保证原子性是因为 cas 指令只有一个,这个指令从头到尾都是在 lock 状态下而且从内存到内存,所以它是原子性的。
3.4.2. 如何保证
使用原子包装的整形类,比如 AtomicInteger,原子类的底层是 CAS 原理
3.5. Happens-Before
https://www.bilibili.com/video/BV1yE411Z7AP?p=174
HappenBefore,规定了哪些写操作对其他线程的读操作可见,它是可见性和有序性一些规则的总和,解决的是可见性问题
定义:前一个操作的结果对于后续操作是可见的。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在 happens-before 关系。这两个操作可以是同一个线程,也可以是不同的线程。
JMM 中有哪些方法建立 happen-before 规则:
- 1、as-if-serial 规则(程序顺序执行):单个线程中的代码顺序不管怎么重排序,对于结果来说是不变的。
- 2、volatile 变量规则,对于 volatile 修饰的变量的写的操作, 一定 happen-before 后续对于 volatile 变量的读操作;
- 3、监视器锁规则(monitor lock rule):对一个监视器的解锁,happens-before 于随后对这个监视器的加锁。
- 4、传递性规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
- 5、start 规则:如果线程 A 执行操作 ThreadB.start(),那么线程 A 的 ThreadB.start() 操作 happens-before 线程 B 中的任意操作。
- 6、join 规则:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功返回。
3.5.1. 具体案例
线程之间解锁、加锁传递时,之前的写是可见的
开始前的自己可以看,结束后的别人可以看
这里主线程 (或其他线程) 得知 t2 被打断之后,读取 x 的值是修改后的,当然 while 循环中 t2 被打断后,t2 自己的读也是可以读到的。
3.6. 与 MESI 的关系
在多核 cpu 中通过缓存一致性协议保证了每个缓存中使用的共享变量的副本是一致的。
当一个 CPU 进行写入时,首先会给其它 CPU 发送 Invalid 消息,然后把当前写入的数据写入到 Store Buffer 中。然后异步在某个时刻真正的写入到 Cache 中。当前 CPU 核如果要读 Cache 中的数据,需要先扫描 Store Buffer 之后再读取 Cache。但是此时其它 CPU 核是看不到当前核的 Store Buffer 中的数据的,要等到 Store Buffer 中的数据被刷到了 Cache 之后才会触发失效操作。而当一个 CPU 核收到 Invalid 消息时,会把消息写入自身的 Invalidate Queue 中,随后异步将其设为 Invalid 状态。和 Store Buffer 不同的是,当前 CPU 核心使用 Cache 时并不扫描 Invalidate Queue 部分,所以可能会有极短时间的脏读问题。MESI 协议,可以保证缓存的一致性,但是无法保证实时性。所以我们需要通过内存屏障在执行到某些指令的时候强制刷新缓存来达到一致性。
但是 MESI 只是一种抽象的协议规范,在不同的 cpu 上都会有不同的实现,对于 x86 架构来说,store buffer 是 FIFO,写入顺序就是刷入 cache 的顺序。但是对于 ARM/Power 架构来说,store buffer 并未保证 FIFO,因此先写入 store buffer 的数据,是有可能比后写入 store buffer 的数据晚刷入 cache 的
而对于 JAVA 而言,他必须要屏蔽各个处理器的差异,所以才有了 java 内存模型 (JMM),volatile 只是内存模型的一小部分,实现了变量的可见性和禁止指令重排序优化的功能。整个内存模型必须要实现可见性,原子性,和有序性。而 volatile 实现了其中的可见性和有序性。
在我的理解中,MESI 协议是实现 volatile 的所有语义的基础,在我们对一个变量加上 volitile 之后,该变量的操作的指令前就会带有 LOCK#前缀,该前缀在 intel 的文档里面说的很清楚,可以通过上面的链接查看,这里只列举出部分
Lock 前缀具有如下作用:
带有 lock 前缀的指令在执行的时候会锁住总线或者利用 MESI 协议这两种方式来保证指令执行的原子性,
禁止该指令,与之前和之后的读和写指令重排序
把缓冲区的所有数据刷新到内存中
1 |
|
看出来了什么没有,即使是加了 volatile 之后的变量,对应到的读取和写入指令都没有加上 Lock#前缀,从汇编语言中可以看到在对 volatile 变量赋值后会加一条 lock addl $0x0,(%rsp)
指令,lock 指令具有内存屏障的作用,lock 前后的指令不会重排序,addl $0x0,(%rsp)
是一条无意义的指令。所以说我们对 volatile 变量的操作其实还是不具有原子性,因为只是利用了#Lock 前缀保证了写操作会被马上刷新到内存而已,并没有保证 读写改
三个操作的原子性。
为什么是在写操作后面插入一条带有 Lock#的指令?
这一条指令其实是起到内存屏障的所用,LOCK 前缀虽然不是内存屏障指令,但是他能起到内存屏障的效果。因为我的测试环境是 X86 平台,在 X86 平台上,只会存在 StoreLoad 重排序,所以说 java 编译器在编译 volatile 变量的操作的时候,只需要在所有的 volatile 写的后面插入一个 StoreLoad 屏障,以此来实现可见性。lock 前缀让本核操作内存时锁定其他核,addl xxx 是个无意义的内存操作,可令 CPU 清空 WB,也起到了内存屏障的作用了。
CAS 就能保证原子性,CAS 也是加 LOCK#前缀啊,这又是为什么?因为 CAS 操作是在一条单独的指令 cmpxchg 前加上了#Lock 前缀 ,所以它具有原子性,LOCK#能保证一条指令执行的原子性。
原文链接:https://blog.csdn.net/P19777/article/details/103120433
[[../../../../cubox/006-ChromeCapture/CAS 原理和缺陷 huzb的博客]]
4. 深层剖析
4.1. 字节码层面
查看字节码我们发现,在 volatile 读写的前后并没有内存屏障信息的生成。即一个属性有没有加 volatile 进行修饰,对 java 代码编译成字节码指令没有影响,生成的字节码指令都一样的。这一点同时也说明前端编译 (即 javac 编译) 不会产生乱序,java 的编译期优化是发生在后端编译 (即 JIT 编译器)。
虽然生成的字节码指令是一样的。但是我们还是能发现属性描述的不同。
当属性被修饰为 volatile 时,在生成的字节码的 class 内属性对应 access_flags 是不一样的(比如上文字节码的代码行数 59 和 63 的地方)。添加了 volatile 的属性,对应的字节码属性描述中,access_flag 会多了一个 ACC_VOLATILE
的标记。
4.2. JDK 源码层面
4.2.1. 运行逻辑
通过 javap 可以看到 volatile 字节码层面有个关键字 ACC_VOLATILE
,如上图所示👆🏻,通过这个关键字定位到 accessFlags.hpp
文件 accessFlags.hpp
再搜索关键字 is_volatile
,在bytecodeInterpreter.cpp可以看到如下代码
bytecodeInterpreter.cpp
在这段代码中,会先判断 tos_type,后面分别有不同的基础类型的实现,比如 int 就调用 release_int_field_put
,byte 就调用 release_byte_field_put 等等。以 int 类型为例,继续搜索方法 release_int_field_put
,在oop.hpp可以看到如下代码:
oop.hpp
1 |
|
这段代码实际是内联oop.inline.hpp,具体的实现是这样的:
oop.inline.hpp
1 |
|
继续看 OrderAccess::release_store
,可以在orderAccess.hpp找到对应的实现方法:
orderAccess.hpp
1 |
|
实际上这个方法的实现又有很多内联的针对不同的 CPU 有不同的实现的,在 src/os_cpu 目录下可以看到不同的实现,以orderAccess.inline.hpp为例,是这么实现的:
orderAccess.inline.hpp
1 |
|
可以看到其实 Java 的 volatile 操作,在 JVM 实现层面第一步是给予了 C++ 的原语实现
releasestore 只在 store 之前插入了 release 函数.而并没有插入 storeload,storeload 是在最下面加上去的。位置
上面的分析是 volatile 写的分支逻辑,我们再把 bytecodeInterpreter.cpp 里面读的整体逻辑加上,读的逻辑调用的是 load_acquire,整体逻辑简化概括一下可得:
bytecodeInterpreter.cpp(int_field_acquire)
–> oop.hpp(int_field_acquire)
–> oop.inline.hpp(int_field_acquire)
–> orderAccess.hpp(load_acquire)
–> orderAccess.inline.hpp(load_acquire)
–> orderAccess.inline.hpp(specialized_load_acquire)
–> orderAccess.inline.hpp(
–> orderAccess.inline.hpp(OrderAccess::acquire())
至此我们可以看出运行逻辑是 JVM 运行时访问方法区中的变量时,如果发现 flag 是 volatile 修饰,则会分别给读写方法加上相应的内存屏障
1 |
|
参考鸣谢:https://blog.csdn.net/u013291050/article/details/117335477
https://blog.csdn.net/w329636271/article/details/54616543
4.2.2. 源码跟踪
在 hotSpot 中对 volatile 的实现的地方有多处,这里主要看的是从 oops 中的实现
https://github.com/openjdk/jdk/blob/master/src/hotspot/share/oops/oop.hpp
1 |
|
方法实现
1 |
|
可以得出下图第二部分,正是内存屏障层面的实现原理,在下面章节中有具体介绍
https://blog.csdn.net/w329636271/article/details/54616543
https://github.com/AdoptOpenJDK/openjdk-jdk9/blob/master/hotspot/src/share/vm/oops/oop.inline.hpp
4.3. 内存屏障层面
acquire 等价于 LoadLoad 屏障或 LoadStore 屏障。
release 等价于 LoadStore 屏障或 StoreStore 屏障。
4.3.1. Acquire 和 Release
如上,关键涉及 OrderAccess::load_acquire,OrderAccess::release_store,OrderAccess::storeload 这三个方法。
很明显,OrderAccess::storeload 就对应 java 虚拟机抽象出来的 StoreLoad 屏障指令。
而 OrderAccess::release_store, OrderAccess::load_acquire 又是什么东西呢。
OrderAccess 就是 openjdk8 路径/hotspot/src/share/vm/runtime 下的 orderAccess.hpp 文件。在 orderAccess.hpp 的代码头部注释里有着对这些方法的详细描述。
获取屏障(Acquire Barrier):相当于上面的 LoadLoadBarrier 或者 LoadStoreBarrier
释放屏障(Release Barrier):相当于 LoadStoreBarrier 或者 StoreStoreBarrier
Java 中又定义了 release 和 acquire,fence 三种不同的语境的内存栅栏.
如上图, loadLoad 和 loadStore 两种栅栏对应的都是 acquire 语境,acquire 语境一般定义在 java 的读之前; 在编译器阶段和 cpu 执行的时候, acquire 之后的所有的 (读和写) 操作不能越过 acquire, 重排到 acquire 之前, acquire 指令之后所有的读都是具有可见性的.
如上图, StoreStore 和 LoadStore 对应的是 release 语境, release 语境一般定义在 java 的写之后, 在编译器和 cpu 执行的时候, 所有 release 之前的所有的 (读和写) 操作都不能越过 release, 重排到 release 之后, release 指令之前所有的写都会刷新到主存中去, 其他核的 cpu 可以看到刷新的最新值.
对于 fence, 是由 storeload 栅栏组成的, 比较消耗性能. 在编译器阶段和 cpu 执行时候, 保证 fence 之前的任何操作不能重排到屏障之后, fence 之后的任何操作不能重排到屏障之前. fence 具有 acquire 和 release 这两个都有的语境, 即可以将 fence 之前的写刷新到内存中, fence 之后的读都是具有可见性的.
经上面分析可得 volatile 的 JVM 屏障逻辑如下图所示:
4.3.2. 读操作
如上图,加载屏障(LoadStore屏障
)除了使 volatile 读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存,使 volatile 变量读取的为最新值。
从代码中没有看到该屏障,暂且持怀疑态度!
Acquire Barrier: 获取屏障(LoadLoad屏障
或 LoadStore屏障
)禁止了 volatile 读操作与其之后的任何读写操作进行重排序。保障了 volatile 变量读操作之后的任何读写操作,volatile 的写线程的更新已经对其可见。
4.3.3. 写操作
如上图,Release Barrier: 释放屏障(LoadStore屏障
或 StoreStore屏障
)保证了 volatile 写操作与该操作之前的任何读、写操作都不会进行重排序。从而保证了 volatile 写操作之前,任何的读写操作都会先于 volatile 被提交。
而 Store Barrier: 存储屏障(StoreLoad屏障
)除了使 volatile 写操作不会与之后的读操作重排序外,还会冲刷处理器缓存,使 volatile 变量的写更新对其他线程可见,该内存屏障与读操作的加载屏障一起保障了可见性。
参考鸣谢: https://www.jianshu.com/p/43af2cc32f90
内存屏障详细内容请看 并发编程专题-基础-10、内存屏障
4.4. 汇编实现层面
分析到 orderAccess.hpp,OrderAccess 可以理解为是一个接口,根据不同操作系统不同 CPU 对应不同的实现。JSR-133 Cookbook 里定义了一堆 Barrier,但 JVM 虚拟机上实际还会定义更多一些 Barrier 在 src/hotspot/share/runtime/orderAccess.hpp
orderAccess_linux_x86.inline.hpp
此处 asm volatile(“” : : : “memory”); 是内嵌汇编.
解释:
asm : 代表汇编代码开始.
volatile: 禁止编译器对代码进行某些优化.
memory: memory 代表是内存; 这边用”memory”,来通知编译器内存的内容已经发生了修改,要重新生成加载指令 (不可以从缓存寄存器中取).因为存在着内存的改变,不可以将前后的代码进行乱序.
asm volatile(“” :::”memory”),这句内嵌汇编作为编译器屏障,可以防止编译器对相邻指令进行乱序,但是它无法阻止 CPU 的乱序;也就是说它仅仅禁止了编译器的乱序优化,不会阻止 CPU 的乱序执行。
compiler_barrier() 只是为了不做指令重排,但是对应的是空操作。看到上面只有 StoreLoad 是实际有效的,对应的是 fence(),看到 fence() 的实现是用 lock。
多核处理器才会执行处理,单核就不需要内存屏障了。实际指令是 lock; addl $0,0(%%esp)
。rsp 是 esp 对应的 64 位指令。
addl $0,0(%%rsp)
的意思就是把寄存器里的值加 0,也就是说这是个空操作。重点在于前缀
我们再回想起来上一篇文章中 MESI 中的内容。加了 lock 前缀的指令会严格保证 MESI 协议中的数据一致性,保证对某个内存的独占使用,保证该 CPU 对应缓存行为独占,其他 CPU 的缓存行则失效。在 x86 上,任何带 lock 前缀的指令都可以可以当成一个 StoreLoad 屏障。
IA32
中对 lock 的说明是
The LOCK # signal is asserted during execution of the instruction following the lock prefix. This signal can be used in a multiprocessor system to ensure exclusive use of shared memory while LOCK # is asserted
LOCK 用于在多处理器中执行指令时对共享内存的独占使用。它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效。另外还提供了有序的指令无法越过这个内存屏障的作用。
正是 lock 实现了 volatile 的「防止指令重排」「内存可见」的特性
5. 使用场景
链接: https://www.jianshu.com/p/9abb4a23ab05
必须具备以下两个条件(其实就是先保证原子性):
- 对变量的写 不依赖当前值(比如 ++ 操作)
- 该变量没有包含在 具有其他变量的不等式 中
比如:
- 状态标记(while(flag){})
- double check(单例模式)
- 读写锁的缓存中
- 其他一写多读的场景
5.1. DCL
5.1.1. 二次验证的单例
加了二次判断,严谨了一些,但是仍然会有问题,就是初始化过程和引用赋值过程可能发生重排序,那么如果不加 volatile,就可能第二个非空判断跳过,但是实例对象有地址,但无内容,是一个空壳子对象。
所以要更加严谨一些,像 AQS 中的 hasQueuedPredecessors 一样,涉及多线程,就要判断任何有过程的中间过程,比如 初始化未完成的情况
5.1.2. 加 volatile 必要性⭐️🔴
❕❕

解析:因为步骤 2、3 不存在数据依赖关系,所以多线程下可能出现指令重拍。那么就可能导致 2 在 3 之前执行,而此时如果另一个线程的第一次 null 判断会得到不为空的结论,但是 instance 的内容却是空的,从而导致了线程安全问题。所以需要 volatile 来修饰 instance 对象,禁止 instance 对象操作的重排序
5.2. 读写锁缓存
【参考】volatile 解决多线程内存不可见问题对于一写多读,是可以解决变量同步问题,但是如果多
写,同样无法解决线程安全问题。
说明:如果是 count++ 操作,使用如下类实现:
AtomicInteger count = new AtomicInteger();
count.addAndGet(1);
如果是 JDK8,推荐使用 LongAdder 对象,比 AtomicLong 性能更好(减少乐观锁的重试次数)。
5.3. JUC
- 面试中单例模式有几种写法?:“饱汉 - 变种 3”在 DCL 的基础上,使用 volatile 修饰单例,以保证单例的可见性。
复杂的利用 volatile 变量规则(结合了程序顺序规则、传递性)保证变量本身及周围其他变量的偏序:
- 源码|并发一枝花之ReentrantLock与AQS(1):lock、unlock:exclusiveOwnerThread 借助于 volatile 变量 state 保证其相对于 state 的偏序。
- 源码|并发一枝花之CopyOnWriteArrayList:CopyOnWriteArrayList 借助于 volatile 变量 array,对外提供偏序语义。
6. synchronized 与 volatile 区别
- Volatile 保证线程可见性,当工作内存中副本数据无效之后,主动读取主内存中数据
- Volatile 可以禁止重排序的问题,底层使用内存屏障
- Volatile 不会导致线程阻塞,不能够保证线程安全问题 (不能保证原子性),synchronized 会导致线程阻塞,能够保证线程安全问题
7. 参考与感谢
[[../../../../cubox/006-ChromeCapture/计算机与服务器底层世界-CPU架构篇-Core - 知乎]]
[[../../../../cubox/006-ChromeCapture/x86-TSO 适用于x86体系架构并发编程的内存模型 - 执生 - 博客园]]
https://blog.csdn.net/it_lihongmin/article/details/109169260
https://github.com/JetBrains/jdk8u_hotspot/blob/master/src/share/vm/runtime/orderAccess.hpp