Loading... # volatile关键字与内存屏障 ## 硬件层数据一致性 ![储存器层次结构.png][1] 如图,储存器的层次结构是一个金字塔形,越向上的速度越快成本越高,向下则反之。cpu处理计算时,需要从下层将数据load到高级的缓存中,增加处理速度,但是L1与L2缓存为每个cpu独占,多个cpu之间有可能造成数据不同步,在多核CPU中,L1、L2缓存为每个核心独有,L3为多核心共享。 早期的cpu为了解决这个问题,采用总线锁的方式,cpu可以发出lock信号,给整个缓存总线加锁,缓存总线被该cpu独占,其他cpu阻塞。该方式造成资源浪费,效率比较低。 现代cpu采用缓存一致性协议(缓存锁)加总线锁的方式来保证数据一致性。对通常数据采用缓存锁,对数据量比较大,无法load进缓存的数据采取总线锁。 缓存一致性协议的实现方式有很多,intel用的是MESI协议。 ### MESI协议 MESI(Modified Exclusive Shared Or Invalid)也称为伊利诺斯协议,因该协议由伊利诺斯州立大学提出,是一种广泛使用的支持写回策略的缓存一致性协议。 MESI将每个缓存行(cache line)使用4种状态进行标记。 **M:被修改(Modified)** 该缓存行被当前cpu修改过,与主存中数据不一致,需要在未来某个时间点(允许其他cpu读取主存中相应内存之前)写回主存。当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。 **E:独享的(Exclusive)** 该缓存行只存在于当前cpu的缓存中,未被修改过,与主存中的数据一致。该状态可以在任何时刻,当有其他cpu读取该内存时,变成共享状态(shared)。 **S:共享的(Shared)** 该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致,当有一个cpu修改该缓存行中数据后,其它cpu中该缓存行被作废(变成无效状态Invalid)。 **I:无效的(Invalid)** 该缓存是无效的(可能有其它cpu修改了该缓存行)。 ### cache line(缓存行) cpu从主存中load数据的最小单位是一个缓存行,大小是64字节。这样就会出现伪共享问题。 **伪共享** 若我有两个int类型的数x和y,他们处在同一缓存行中,有两个cpu都缓存了这个缓存行,其中一个对x进行操作,另一个对y进行操作,第一个cpu修改了x,状态变为M,另一个cpu中该缓存行的状态就变成I,必须重新load,同样的,第二个cpu对y进行修改,也会影响第一个cpu中的缓存行,但是实际上处理的并不是一个数据,两个cpu互相影响,导致效率降低,这就是伪共享问题。 **如何解决伪共享问题** 使用缓存行对齐。 下面是Disruptor中RingBuffer的定义 ``` public long p1, p2, p3, p4, p5, p6, p7; // cache line padding private volatile long cursor = INITIAL_CURSOR_VALUE; public long p8, p9, p10, p11, p12, p13, p14; // cache line padding ``` 我们知道一个cache line是64字节,而long类型占8字节。在INITIAL_CURSOR_VALUE前后各定义7个long,就保证了INITIAL_CURSOR_VALUE不会与其他数据共享缓存行,提升了效率,但是消耗了内存空间。在jdk1.8中,加入了`@Contended`注解,它可以作用在类或成员变量上,保证该变量独占一个缓存行,该注解需要配置jvm参数`-XX:-RestrictContended`。 ## 乱序问题 cpu为了提升效率,会在一条指令执行的过程中,去同时执行另一条指令,前提是两条指令没有依赖关系。写操作也可以进行合并,在L1与L2之间还有一个WCBuffer(合并写存储缓冲区),它只有4个字节的位置,当L1未命中时,cpu会将数据直接写入WCBuffer,当WCBuffer位置占满时,一次性更新到L2中。 ``` public final class WriteCombining { private static final int ITERATIONS = Integer.MAX_VALUE; private static final int ITEMS = 1 << 24; private static final int MASK = ITEMS - 1; private static final byte[] arrayA = new byte[ITEMS]; private static final byte[] arrayB = new byte[ITEMS]; private static final byte[] arrayC = new byte[ITEMS]; private static final byte[] arrayD = new byte[ITEMS]; private static final byte[] arrayE = new byte[ITEMS]; private static final byte[] arrayF = new byte[ITEMS]; public static void main(final String[] args) { for (int i = 1; i <= 3; i++) { System.out.println(i + " SingleLoop duration (ns) = " + runCaseOne()); System.out.println(i + " SplitLoop duration (ns) = " + runCaseTwo()); } } public static long runCaseOne() { long start = System.nanoTime(); int i = ITERATIONS; while (--i != 0) { int slot = i & MASK; byte b = (byte) i; arrayA[slot] = b; arrayB[slot] = b; arrayC[slot] = b; arrayD[slot] = b; arrayE[slot] = b; arrayF[slot] = b; } return System.nanoTime() - start; } public static long runCaseTwo() { long start = System.nanoTime(); int i = ITERATIONS; while (--i != 0) { int slot = i & MASK; byte b = (byte) i; arrayA[slot] = b; arrayB[slot] = b; arrayC[slot] = b; } i = ITERATIONS; while (--i != 0) { int slot = i & MASK; byte b = (byte) i; arrayD[slot] = b; arrayE[slot] = b; arrayF[slot] = b; } return System.nanoTime() - start; } } ``` 上面代码中定义了两个方法runCaseOne()与runCaseTwo(),他们分别去循环修改6个byte数组中的值,区别是runCaseOne一个循环修改6个数组,而runCaseTwo分成两个循环,每个循环修改三个数组,然后监控方法执行时间,看哪个更快,结论是runCaseTwo更快,因为runCaseTwo每个循环中都恰好占满WCBuffer的4个位置,直接被合并写入,而runCaseOne每循环一次,送走4个之后,都要等待下一次循环或者其他线程来填补一个位置的空缺。 **乱序执行的证明** ``` public class Disorder { private static int x = 0, y = 0; private static int a = 0, b =0; public static void main(String[] args) throws InterruptedException { int i = 0; for(;;) { i++; x = 0; y = 0; a = 0; b = 0; Thread one = new Thread(new Runnable() { public void run() { //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间. //shortWait(100000); a = 1; x = b; } }); Thread other = new Thread(new Runnable() { public void run() { b = 1; y = a; } }); one.start();other.start(); one.join();other.join(); String result = "第" + i + "次 (" + x + "," + y + ")"; if(x == 0 && y == 0) { System.err.println(result); break; } else { //System.out.println(result); } } } public static void shortWait(long interval){ long start = System.nanoTime(); long end; do{ end = System.nanoTime(); }while(start + interval >= end); } } ``` 这个demo中,两个线程分别去修改a,b,x,y的值,如果顺序执行,x和y的值可能是01,10,11就是不可能是00,因为每次给x和y赋值时,a和b都至少有一个已经提前被赋成1了,`a=1;x=b`两句代码没有依赖关系,`b=1;y=a`也是,如果不乱序,那么程序永远不会结束(`x == 0 && y == 0`才会break),实际上,运行了相当长的时间之后,程序确实结束了,说明代码被重排序了。 ## 如何保证特定情况下不乱序 在硬件级别,通过内存屏障或原子指令来解决 **硬件内存屏障x86** ``` sfence: store,在sfence指令前的写操作当必须在sfence指令后的写操作前完成。 lfence:load,在lfence指令前的读操作当必须在lfence指令后的读操作前完成。 mfence:mix,在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成。 ``` **原子指令** ``` 如x86上的”lock …” 指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。 ``` 在jvm级别,定义了4种屏障,它是一个规范,由具体的jvm开发者去实现,底层实现要依赖硬件级别。 ``` LoadLoad屏障: 对于这样的语句Load1; LoadLoad; Load2, 在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。 StoreStore屏障: 对于这样的语句Store1; StoreStore; Store2, 在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。 LoadStore屏障: 对于这样的语句Load1; LoadStore; Store2, 在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。 StoreLoad屏障: 对于这样的语句Store1; StoreLoad; Load2, 在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。 ``` ### volatile的实现细节 1. 字节码层面 access_flag 添加volatile标识 2. volatile内存区的读写 都加屏障 ``` StoreStoreBarrier volatile 写操作 StoreLoadBarrier LoadLoadBarrier volatile 读操作 LoadStoreBarrier ``` 写操作之前加写写屏障,之后加写读屏障,读操作之前加读读屏障,之后加读写屏障。 3. os和硬件层面 利用lock指令或MESI实现,在windows中是用lock指令实现的。 ### synchronized实现细节 1. 字节码层面 方法上的synchronized: access_flag 添加synchronized标识 synchronized代码块: monitorenter monitorexit,监视器进入 监视器退出 2. JVM层面 C C++ 调用了操作系统提供的同步机制(mutex lock) 3. OS和硬件层面 X86 : lock cmpxchg 比较并交换指令 [1]: https://www.princelei.club/usr/uploads/2021/04/138109108.png Last modification:April 21st, 2021 at 07:27 pm © 允许规范转载