volatile原理解析与cpu内存架构

Volatile详解

一、Intel硬件提供了一系列的内存屏障:
  1. lfence,是一种Load Barrier 读屏障
  2. sfence, 是一种Store Barrier 写屏障
  3. mfence, 是一种全能型的屏障,具备ifence和sfence的能力
  4. Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。

《Java虚拟机规范》[1]中曾试图定义一种“Java内存模型”[2](Java Memory Model,JMM)来屏 蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效 果。在此之前,主流程序语言(如C和C++等)直接使用物理硬件和操作系统的内存模型。因此,由于 不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发 访问却经常出错,所以在某些场景下必须针对不同的平台来编写程序。

二、Votile保证了有序性、可见性
1.为什么会发生不可见这种情况

​ 那要从计算机硬件讲起,现代计算机cpu和内存之间都会存在着缓存,一般分为L1,L2,L3三级缓存,设计成这个样子主要是因为CPU的运行速度较快,但是内存的运行速度是远低于CPU的,所以为了减小这种速度的差距提高小效率就设计了三级缓存的概念。如图:

在这里插入图片描述

因为这样的结构就导致了如果某个cpu从内存中读取数据(一个缓存行大小的数据)就会缓存到高速缓存中,如果cpu的两个核心同时操作了同一块数据,那么这一块数据同时会被缓存两份,一个核心操作一份,如果说某个核心对数据进行了修改,并且写回主内存,但是另外一个核心操作的还是旧的数据,这样就导致了数据的不可见。而加上了volitale就保证了可见性。这种情况下如何保证缓存数据的一致性,其实cpu采用了MESI 及其变种协议去做缓存一致性维护。我们主要来理解下什么是MESI:

状态描述监听任务
M 修改 (Modified)该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive)该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared)该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid)该Cache line无效。

通过上边表格我们可以发现,MESI其实就是四个字母的缩写,Modified,Exclusive,Shared,Invalid这四个单词分别代表了四种状态。

这四种状态之间可以相互转换:

​ 一开始线程A将一个缓存行的数据读入Cache中,这时候的数据是Exclusive状态,数据只存在于本Cache中,同时它监听着其他缓存读取主存中该缓存行的操作,如果此时有某个线程B读取该缓存行,那么此时就将两个Cache中的缓存行置为Shared状态,此时缓存行在监听其他缓存使该缓存无效或者独享的请求,假设线程A对该缓存行的数据进行了修改,这时候总线嗅探机制嗅探到该缓存行的变化,则置线程B的缓存为Invalid无效状态,同时将线程A的缓存置为Modified状态。恰好线程B想再次读取该缓存行,那么线程B所在核心会通知线程A所在核心,将线程A修改后的数据同步到主内存,并且将所在Cache的缓存行设置为Exclusive状态。然后线程B从主存中读取数据到Cache中,同时将该缓存行设置为Shared状态。

​ 同时为了解决切换状态时候的堵塞问题还引入了Store Bufferes来解决这个问题,但是这种缓存一致性协议并不能够解决指令重排的问题,于是引入了内存屏障这个概念,也就是我们在上边讲到的各种内存屏障的实现。这种内存屏障的实现和硬件有很大的关系,java中的内存屏障其实就是借助于上边的第四种方式来实现的,也就是使用了Lock前缀。

2.什么是缓存行(cache line)

​ 刚才我们一直在提缓存行(Cache Line),到底什么是缓存行,缓存行可以理解成读取到高速缓冲区的最小单位,将数据从内存读取到缓存中的时候如果一个字节一个字节的读肯定是比较耗时的操作,所以根据程序的局部性原理就采取了按照缓存行的大小往高速缓冲区中读取,如果是64位的电脑我们的缓存行大小一般是64Bytes。

3.伪共享(flash sharing)问题

​ 我们了解了缓存行,再来解析另外一个概念–伪共享,如果当两个缓存同时缓存了同一个缓存行的数据,但是呢并没有操作同一个数据,只是操作的数据都存在于同一个缓存行中,那么当线程A读取一个数据的时候,线程B修改处在缓存行中的另外一个数据,此时缓存A中的数据就要失效,并且将缓存B中的数据写回到主内存,这样频繁的往主存写重新从主存读取,会极大地消耗性能,这就是伪共享问题。一般我们在程序开发中并不会处理这种伪共享问题,但是在某些框架中会对伪共享问题进行处理,比如Disruptor在定义成员变量的时候会故意拼接无用的字节,使其成员变量不处在同一个缓存行中,以此来提高性能。同时JDK 8提供了一个 sun.misc.Contended注解,用来解决伪共享问题。

4.volatile保证可见性

​ 他是通过缓存一致性协议以及内存屏障来实现的,无论是volatile 还是普通变量在读写操作本身方面完全是一样的,即读写操作都交给 Cache,Cache 通过 MESI 及其变种协议去做缓存一致性维护。这两种变量的区别就只在于 内存屏障的使用上。

class字节码层面:

​ volatile 对代码生成的字节码本身没有影响,即 Java Method 生成的字节码无论里面操作的变量是不是 volatile 声明的,生成的字节码都是一样的。volatile 在字节码层面影响的是 Class 内 Field 的 access_flags我们可以看到下边代码的 flags: ACC_VOLATILE。

volatile int v1;
    descriptor: I
    flags: ACC_VOLATILE
   .....
void readAndWrite();
    descriptor: ()V
    flags:
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: getfield      #52                 // Field v1:I
         4: istore_1
         5: aload_0
         6: getfield      #54                 // Field v2:I
         9: istore_2
        10: aload_0
        11: iload_1
        12: iload_2

汇编源码:

  0x0000000003fb5656: mov    0x58(%r10),%r8d
  0x0000000003fb565a: mov    %r8d,%r11d
  0x0000000003fb565d: add    $0x2,%r11d
  0x0000000003fb5661: mov    %r11d,0x58(%r10)
  0x0000000003fb5665: add    $0x4,%r8d
  0x0000000003fb5669: mov    %r8d,0x58(%r10)
  0x0000000003fb566d: lock addl $0x0,(%rsp)     ;*putstatic a
                                                ; - 

​ 通过看class字节码和汇编源码我们发现当有了volatile修饰以后最终在汇编源码上会带上lock addl这个前缀,这也就呼应了我们开篇讲的第四种实现内存屏障的方式。

​ 在jvm层面一共提供了四种内存屏障,它们分别是:LoadLoad,StoreStore,LoadStore,StoreLoad

**LoadLoad:**操作序列 Load1, LoadLoad, Load2,用于保证访问 Load2 的读取操作一定不能重排到 Load1 之前。类似于前面说的 Read Barrier,需要先处理 Invalidate Queue 后再读 Load2;

**StoreStore:**操作序列 Store1, StoreStore, Store2,用于保证 Store1 及其之后写出的数据一定先于 Store2 写出,即别的 CPU 一定先看到 Store1 的数据,再看到 Store2 的数据。可能会有一次 Store Buffer 的刷写,也可能通过所有写操作都放入 Store Buffer 排序来保证;

**LoadStore:**操作序列 Load1, LoadStore, Store2,用于保证 Store2 及其之后写出的数据被其它 CPU 看到之前,Load1 读取的数据一定先读入缓存。甚至可能 Store2 的操作依赖于 Load1 的当前值。这个 Barrier 的使用场景可能和上一节讲的 Cache 架构模型很难对应,毕竟那是一个极简结构,并且只是一种具体的 Cache 架构,而 JVM 的 Barrier 要足够抽象去应付各种不同的 Cache 架构。如果跳出上一节的 Cache 架构来说,我理解用到这个 Barrier 的场景可能是说某种 CPU 在写 Store2 的时候,认为刷写 Store2 到内存,将其它 CPU 上 Store2 所在 Cache Line 设置为无效的速度要快于从内存读取 Load1,所以做了这种重排。

**StoreLoad:**操作序列 Store1, StoreLoad, Load2,用于保证 Store1 写出的数据被其它 CPU 看到后才能读取 Load2 的数据到缓存。如果 Store1 和 Load2 操作的是同一个地址,StoreLoad Barrier 需要保证 Load2 不能读 Store Buffer 内的数据,得是从内存上拉取到的某个别的 CPU 修改过的值。StoreLoad一般会认为是最重的 Barrier 也是能实现其它所有 Barrier 功能的 Barrier。

但是这四种内存屏障在jvm层面中都没有借助操作系统的内存屏障,而是借住了总线锁或者是缓存锁来实现了内存屏障的功能。并且在 x86 下除了 StoreLoad之外其它 Barrier 都是空操作。volatile并不是仅仅加入内存屏障这么简单,加入内存屏障只是volatile内核指令级别的内存语义,除此之外:volatile还可以禁止编译器的指令重排,因为JVM为了优化性能并且不违反happens-before原则的前提下也会进行指令重排。禁止编译器指令重排如下:

/* The “volatile” is due to gcc bugs */
#define barrier() asm volatile("": : :“memory”)

volatile的内存屏障策略非常严格保守:

在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;

总线锁

对于早期的CPU,总是采用的是锁总线的方式。具体方法是,一旦遇到了Lock指令,就由仲裁器选择一个核心独占总线。其余的CPU核心不能再通过总线与内存通讯。从而达到“原子性”的目的。具体做法是,某一个核心触发总线的“Lock#”那根线,让总线仲裁器工作,把总线完全分给某个核心。

缓存锁

如果访问的内存区域已经缓存在处理器的缓存行中,P6系统和之后系列的处理器则不会声明LOCK#信号,它会对CPU的缓存中的缓存行进行锁定,在锁定期间,其它 CPU 不能同时缓存此数据,在修改之后,通过缓存一致性协议来保证修改的原子性,这个操作被称为缓存锁。

锁的选择

如果是P6后的CPU,并且数据已经被CPU缓存了,并且是要写回到主存的,则可以用cache locking处理问题。否则还是得锁总线。因此,lock到底用锁总线,还是用cache locking,完全是看当时的情况。当然能用后者的就肯定用后者。

Intel P6是Intel第6代架构的CPU,其实也很老了,差不多1995年出的…… 比如Pentium Pro,Pentium II,Pentium III都隶属于P6架构“

总之,加上了volatile以后再CPU底层并没有采用系统的内存屏障,而是借用了总线锁或者是缓存锁来实现了内存屏障的功能。以此volatile保证了可见性。同时因为屏障的存在禁止了cpu的指令重排,也因此实现了有序性这个特性。

参考资料

https://www.zhihu.com/question/65372648

https://www.cnblogs.com/yanlong300/p/8986041.html

https://blog.csdn.net/breakout_alex/article/details/94379895

https://segmentfault.com/a/1190000014315651

https://juejin.im/post/6844904144273145863

https://blog.csdn.net/wll1228/article/details/107775976

windows下查看汇编指令:

https://blog.csdn.net/xiaomojun/article/details/94654616

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值