volatile关键字原理分析

·  阅读 134

Volatile关键字

  1. 对于volatile关键字我们大家都很熟悉,它的可见性,禁止重排序都很了解,但它是如何做到的,jvm是如何保证这些特性的呢?
  2. 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
    复制代码
  1. 查看Hotspot对字节码的执行过程:字节码解释器(BytecodeInterpreter) :没有使用编译优化,在运行期就是纯粹地以解释方式执行
    • A write to a volatile field (§8.3.1.4) happens-before every subsequent read of that field.
    复制代码
BytecodeInterpreter字节码分析
  • 函数调用逻辑分析:
  1. 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有没有印象?
  1. 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(); 
    复制代码
    image
  2. 查看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:一条空语句
  3. 以上分析可知,volatile关键字在汇编层代码中使用了lock指令保持其在java层可见性,禁止重排序等特性 汇编代码
  4. 以上调用的流程图 image

volatile特性

  • volatile关键字特性为: 可见性,禁止重排序,部分原子性,为何都这么说,是怎么实现的呢?
  • 均是通过lock指令实现可见性,同时相当于插入内存屏障禁止重排序
内存屏障
  1. 对于CPU的写,目前主流策略有两种:内存屏障今生之Store Buffer, Invalid Queue
    1. write back:即CPU向内存写数据时,先把真实数据放入store buffer中,待到某个合适的时间点,才会将store buffer中的数据刷到内存中
    2. write through:即CPU向内存写数据时,同步完成写store buffer与内存。
  2. CPU大多数采用的是write back策略:CPU异步完成写内存产生的延迟是可以接受的,而且这个延迟极短。只有在多线程环境下需要严格保证内存可见等极少数特殊情况下才需要保证CPU的写在外界看来是同步完成的,但可以借助CPU提供的内存屏障(lock指令)来实现
  3. 编译器和CPU可以保证输出结果一样的情况下对指令重排序,使性能得到优化,插入一个内存屏障,相当于告诉CPU和编译器限于这个命令的必须先执行,后于这个命令的必须后执行
  4. 内存屏障的另一个作用是强制更新一次不同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格式的表格,只能使用截图代替了

image image

  • MESI的状态转移主要是通过CPU的嗅探协议实现的;

image

CPU嗅探协议
  • 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。
  • CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,从而跟踪其他缓存在做了什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步;
Lock指令作用
  1. 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存
  2. lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据
  3. 内存屏障,阻止屏障两边的指令重排序:DSL中使用volatile原因
思考问题
  • 既然CPU有了MESI协议可以保证cache的一致性,那么为什么还需要volatile这个关键词来保证可见性(内存屏障)?或者是只有加了volatile的变量在多核cpu执行的时候才会触发缓存一致性协议?
  1. 多核情况下,所有的cpu操作都会涉及缓存一致性的校验,只不过该协议是弱一致性,不能保证一个线程修改变量后,其他线程立马可见,也就是说虽然其他CPU状态已经置为无效,但是当前CPU可能将数据修改之后又去做其他事情,没有来得及将修改后的变量刷新回主存,而如果此时其他CPU需要使用该变量,则又会从主存中读取到旧的值。而volatile则可以保证可见性,即立即刷新回主存,修改操作和写回操作必须是一个原子操作;
  2. 正常情况下,系统操作并不会进行缓存一致性的校验,只有变量被volatile修饰了,该变量所在的缓存行才被赋予缓存一致性的校验功能。
应用
  • 了解了volatile的实现原理,可是对我们java编程有什么用呢?
  1. DCL会导致什么问题?为何添加volatile关键字可避免?

image 2. 伪共享导致性能问题的解决方式?java的concurrentHashMap 和Rxjava中的QueueDrainSubscriber * 何为伪共享,就是不同的线程的独立字段却操作了同一个缓存行 image

  • 如何避免伪共享: 1. 手动补齐缓存行大小; 2: 使用@sun.misc.Contended注解
  • Rxjava2中使用第一种
    // QueueDrainSubscriber: 订阅队列的抽象基类
    public abstract class QueueDrainSubscriber<T, U, V> 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;
    }
    复制代码
    • 无竞争时使用baseCount,有竞争在添加时CAS添加到CounterCell中,根据hash & 数组大小(2^n - 1)确定在数组中的位置,扩容原理与HashMap一致
    • 数组在内存中是连续的,CounterCell只有一个long类型参数而且多线程竞争激烈,即时不是同一个Hash值更新数组下标不同,比如0,与1依然会导致伪共享问题,因此添加Contended解决是每个value位于单独的一个缓存行中。
  • 注意: 并不是所有的场景都需要解决伪共享问题,因为CPU缓存是有限的,填充会牺牲掉一部分缓存,所以Android中 @jdk.internal.vm.annotation.Contended : Android-removed

参考

  1. 周志明 - 《深入理解java虚拟机》
  2. java反汇编工具使用
  3. JSR133中文版
分类:
后端
标签: