Volatile关键字
- 对于volatile关键字我们大家都很熟悉,它的可见性,禁止重排序都很了解,但它是如何做到的,jvm是如何保证这些特性的呢?
- DCL单例为何需要加volatile?
- 下面我们一起一点点拨开云雾见它实现的底层原理
字节码层面
- 了解volatile关键字最好的方式是查看字节码及反汇编代码,
- 这里会使用到HSDIS(Hotspot disassembler)和JITWatch-JIT编译日志分析:工具使用介绍
public class VolatileDemo { private static volatile int i = 0; public static void n(){ i++; } public static synchronized void m(){} public static void main(String[] args) { //热点代码,编译成本地代码 for (int j = 0; j < 1_000_000; j++) { n(); m(); } } } //对于方法n的字节码对于i是否加volatile均相同表示为: 0: getstatic #2 // Field i:I 3: iconst_1 4: iadd 5: putstatic #2 // Field i:I 8: return
- 以上字节码相同,valotile关键字JVM是如何知晓的呢: 通过常量池#2的flags
- 使用jclassLib获取字段标志为:0x004a [private:0x0002 static:0x0008 volatile:0x0040]
private static volatile int i; descriptor: I //字段定义在Const flags: ACC_PRIVATE, ACC_STATIC, ACC_VOLATILE
- 查看Hotspot对字节码的执行过程:字节码解释器(BytecodeInterpreter) :没有使用编译优化,在运行期就是纯粹地以解释方式执行
- volatile只是Java层的关键字,真正是由各个虚拟机实现:具体可查看JVM虚拟机规范文档
• A write to a volatile field (§8.3.1.4) happens-before every subsequent read of that field.
BytecodeInterpreter字节码分析
- 函数调用逻辑分析:
- BytecodeInterpreter调用case(_putstatic)解析putStatic字节码
CASE(_putfield):
CASE(_putstatic):
......
if (cache->is_volatile()) { //1: i字段如果带有 ACC_VOLATILE标记
......
OrderAccess::storeload();
}
//1 的调用规则accessFlags.hpp
bool is_volatile() const{
return (_flags & JVM_ACC_VOLATILE) != 0; } //i标记为Acc_volatile为true
- 熟悉内存屏障的您对storeload有没有印象?
- OrderAccess是父类,根据不同的系统实现类不同
class OrderAccess : AllStatic { public: //内存屏障相关方法 static void loadload(); static void storestore(); static void loadstore(); static void storeload(); static void acquire(); static void release(); static void fence();
- 查看orderAccess_linux_x86.inline.hpp的实现类中对各个方法的实现
- 只有storeLoad调用了fence()方法,其他三种方式没有
inline void OrderAccess::loadload() { acquire(); } inline void OrderAccess::storestore() { release(); } inline void OrderAccess::loadstore() { acquire(); } inline void OrderAccess::storeload() { fence(); } inline void OrderAccess::fence() { if (os::is_MP()) { //如果是多处理器 os.hpp中定义 // return (_processor_count != 1) #ifdef AMD64 __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory"); #else __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory"); #endif } }
- fence函数中调用了asm (assembler:汇编代码),调用了指令lock addl $0,0(%%rsp) => 调用了lock指令,addl 0:一条空语句
-
以上分析可知,volatile关键字在汇编层代码中使用了lock指令保持其在java层可见性,禁止重排序等特性
-
以上调用的流程图
volatile特性
- volatile关键字特性为: 可见性,禁止重排序,部分原子性,为何都这么说,是怎么实现的呢?
- 均是通过lock指令实现可见性,同时相当于插入内存屏障禁止重排序
内存屏障
- 对于CPU的写,目前主流策略有两种:内存屏障今生之Store Buffer, Invalid Queue
- write back:即CPU向内存写数据时,先把真实数据放入store buffer中,待到某个合适的时间点,才会将store buffer中的数据刷到内存中
- store buffer 是硬件实现的缓冲区,它的读写速度比缓存的速度更快,所有面向缓存的写操作都会先经过 store buffer,而右它收集多次写操作,然后在合适的时机进行统一提交;
- CPU 的某个核再要对一个变量进行赋值,它就不必等到所有的同伴都确认完,而是直接把新的值放入 store buffer,然后再由 store buffer 慢慢地去做核间同步,并且将新的值刷入到 cache 中去就好了。而且,每个核的 store buffer 都是私有的,其他核不可见;
- 问题1: 解决了一个核修改,不必等待其他核给予反馈,只需添加到store buffer中,但多核之间同时机不在可控,导致MESI变成了弱缓存一致性 ,
- 问题2:同时各个核之间消息同步即当一个 CPU 向同伴发出 Invalid 消息的时候,它的同伴要先把自己的缓存置为 Invalid,然后再发出 acknowledgement。这个从“把缓存置为 Invalid”到“发出 acknowledgement”的过程所需要的时间也是比较长的,如果不能很快处理,则store buffer将很快被填满;
- 为此处理失效消息添加一个 invalid queue 用于收到 Invalid 消息的 CPU,比如我们称它为 CPU1,在收到 Invalid 消息时立即向 CPU0 发回确认消息,但这个时候 CPU1 并没有把自己的 cache 由 Share 置为 Invalid,而是把这个失效的消息放到一个队列中,等到空闲的时候再去处理失效消息;
- 注意: mesi是一个协议,cpu的设计者若完全遵守协议就一定能保证数据一致性;但是完全遵守协议性能低,这时候就有人想,我放松一点要求,性能就可以得到很大的提升,但这需要软件工程师帮忙,总的看来这种放松是有利的。
- write through:即CPU向内存写数据时,同步完成写store buffer与内存。
- write back:即CPU向内存写数据时,先把真实数据放入store buffer中,待到某个合适的时间点,才会将store buffer中的数据刷到内存中
- CPU大多数采用的是write back策略:CPU异步完成写内存产生的延迟是可以接受的,而且这个延迟极短。只有在多线程环境下需要严格保证内存可见等极少数特殊情况下才需要保证CPU的写在外界看来是同步完成的,但可以借助CPU提供的内存屏障(lock指令)来实现
- 编译器和CPU可以保证输出结果一样的情况下对指令重排序,使性能得到优化,插入一个内存屏障,相当于告诉CPU和编译器限于这个命令的必须先执行,后于这个命令的必须后执行
- 内存屏障的另一个作用是强制更新一次不同CPU的缓存,这意味着如果你对一个volatile字段进行写操作
- 一旦你完成写入,任何访问这个字段的线程将会得到最新的值;
- 在你写入之前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。
Lock指令
- Lock指令: 所有的X86的CPU上都具有锁定一个特定内存地址的能力,当这个特定内存地址被锁定之后,它就可以阻止其他的系统总线读取或修改这个内存地址。
- 当使用Lock前缀时,它会使CPU宣告一个Lock#信号,这样就能确保在多处理器系统或多线程竞争的环境下互斥地使用这个内存地址。当指令执行完毕,这个锁定动作也就会消失。
- 修改时需要其他CPU知道该段内存已被修改,就需要缓存一致性原则
缓存一致性原则
- 在多核中某一个核心发生修改操作,就会产生数据不一致,而一致性协议正是用于保证多个CPU cache之间缓存共享数据的一致性
- Cache line : 是cache与内存数据交换的最小单位,根据操作系统一般是32或64字节
[图片上传失败...(image-42d569-1614774355189)]
Cache line状态
- cache line状态分成modify(修改)、exclusive(独占)、shared(共享)、invalid(失效)
状态 | 描述 |
---|---|
M(modify) | 该缓存行中的内容被修改了,并且该缓存行只缓存在该CPU中,而且和主存数据不一致 |
E(exclusive) | 只有当前CPU中有数据,其他CPU中没有该数据,当前CPU和主存的数据一致 |
S(shared) | 当前CPU和其他CPU中都有共同的数据,并且和主存中的数据一致 |
I(invalid) | 当前CPU中的数据失效,数据应该从主存中获取 |
- M和E状态下的Cache Line数据是独有的,不同点在于M状态的数据和内存的不一致,E状态下数据和内存是一致的
状态迁移
- 每个CPU不仅知道自己的状态,同时通过嗅探监听其他Cache的读写操作,每个Cache line所处的状态根据本核和其他核的读写操作在4个状态之间进行迁移。
- 读写状态分为四种: localRead(本地读),localWrite(本地写),remoteRead(远程读),remoteWrite(远程写): CPU的状态同读写监听状态合并总共有16种状态转移: 不支持html格式的表格,只能使用截图代替了
-
MESI的状态转移主要是通过CPU的嗅探协议实现的;
CPU嗅探协议
- 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。
- CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,从而跟踪其他缓存在做了什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步;
Lock指令作用
- 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存
- lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据
- 内存屏障,阻止屏障两边的指令重排序:DSL中使用volatile原因
思考问题
- 既然CPU有了MESI协议可以保证cache的一致性,那么为什么还需要volatile这个关键词来保证可见性(内存屏障)?或者是只有加了volatile的变量在多核cpu执行的时候才会触发缓存一致性协议?
- 多核情况下,所有的cpu操作都会涉及缓存一致性的校验,只不过该协议是弱一致性,不能保证一个线程修改变量后,其他线程立马可见,也就是说虽然其他CPU状态已经置为无效,但是当前CPU可能将数据修改之后又去做其他事情,没有来得及将修改后的变量刷新回主存,而如果此时其他CPU需要使用该变量,则又会从主存中读取到旧的值。而volatile则可以保证可见性,即立即刷新回主存,修改操作和写回操作必须是一个原子操作;
- 正常情况下,系统操作并不会进行缓存一致性的校验,只有变量被volatile修饰了,该变量所在的缓存行才被赋予缓存一致性的校验功能。
- 重要: CPU 从单核发展为多核,增加缓存,导致出现了多个核间的缓存一致性问题 --> 为了解决缓存一致性问题,提出了 MESI 协议 --> 完全遵守 MESI 又会给 CPU 带来性能问题 --> CPU 设计者又增加 store buffer 和 invalid queue --> 又导致了缓存的顺序一致性变为了弱缓存一致性 --> 需要缓存的顺序一致性的,就需要软件工程师自己在合适的地方添加内存屏障,volatile做的事情就是保证顺序一致性的
应用
- 了解了volatile的实现原理,可是对我们java编程有什么用呢?
-
DCL会导致什么问题?为何添加volatile关键字可避免?
- 伪共享导致性能问题的解决方式?java的concurrentHashMap 和Rxjava中的QueueDrainSubscriber
-
何为伪共享,就是不同的线程的独立字段却操作了同一个缓存行
-
如何避免伪共享: 1. 手动补齐缓存行大小; 2: 使用@sun.misc.Contended注解
-
Rxjava2中使用第一种
// QueueDrainSubscriber: 订阅队列的抽象基类 public abstract class QueueDrainSubscriber
extends QueueDrainSubscriberPad4 class QueueDrainSubscriberPad0 { volatile long p1, p2, p3, p4, p5, p6, p7; volatile long p8, p9, p10, p11, p12, p13, p14, p15; } /** The WIP counter. */ class QueueDrainSubscriberWip extends QueueDrainSubscriberPad0 { final AtomicInteger wip = new AtomicInteger(); } /** Pads away the wip from the other fields. */ class QueueDrainSubscriberPad2 extends QueueDrainSubscriberWip { volatile long p1a, p2a, p3a, p4a, p5a, p6a, p7a; volatile long p8a, p9a, p10a, p11a, p12a, p13a, p14a, p15a; } /** Contains the requested field. */ class QueueDrainSubscriberPad3 extends QueueDrainSubscriberPad2 { final AtomicLong requested = new AtomicLong(); } /** Pads away the requested from the other fields. */ class QueueDrainSubscriberPad4 extends QueueDrainSubscriberPad3 { volatile long q1, q2, q3, q4, q5, q6, q7; volatile long q8, q9, q10, q11, q12, q13, q14, q15; } -
ConcurrentHashMap中使用size()第二种注解方式
//size() => sumCount @sun.misc.Contended static final class CounterCell { volatile long value; CounterCell(long x) { value = x; } } final long sumCount() { CounterCell[] as = counterCells; CounterCell a; long sum = baseCount; //无竞争时使用该字段 if (as != null) { for (int i = 0; i < as.length; ++i) { if ((a = as[i]) != null) sum += a.value; } } return sum; }
- size() 方法通过数组在多线程中分段计算后统一汇总获取值
- 无竞争时使用baseCount,有竞争在添加时CAS添加到CounterCell中,根据hash & 数组大小(2^n - 1)确定在数组中的位置,扩容原理与HashMap一致
- 数组在内存中是连续的,CounterCell只有一个long类型参数而且多线程竞争激烈,即时不是同一个Hash值更新数组下标不同,比如0,与1依然会导致伪共享问题,因此添加Contended解决是每个value位于单独的一个缓存行中。
注意: 并不是所有的场景都需要解决伪共享问题,因为CPU缓存是有限的,填充会牺牲掉一部分缓存,所以Android中 @jdk.internal.vm.annotation.Contended : Android-removed
- CAS即可以保证原子性,也可以保证可见性,那为何还使用volatile呢?
- CAS是Java通过JNI调用底层汇编指令实现原子性。同时在多核情况下,汇编指令会加锁前缀,从而保证可以读到变量的当前值。这和volatile的内存屏障、解决线程工作内存缓存是一个效果。你是想问例如Atomic类为什么还要用volatile修饰value嘛?它的出发点更多是解决update(CAS)和get方法并行下的可见性问题,比如AtomicLong中的value。
参考
- 周志明 - 《深入理解java虚拟机》
- java反汇编工具使用
- JSR133中文版