volatile
解决可见性
什么是可见性
可见性又叫读写可见。即一个共享变量N,当有两个线程T1、T2同时获取了N的值(放在各自cache中),T1修改N的值(往cache中写),而T2读取N的值(从cache中读),可见性规范要求T2读取到的值必须是T1修改后的值。
在JVM的内存模型中,每个线程有自己的工作内存,实际上JAVA线程借助了底层操作系统线程实现,一个JVM线程对应一个操作系统线程,线程的工作内存其实是cpu寄存器和高速缓存的抽象
现代处理器的缓存一般分为三级,由每一个核心独享的L1、L2 Cache,以及所有的核心共享L3 Cache组成,具体每个cache,实际上是有很多缓存行组成:
代码示例
|
|
内存屏障
编译器和CPU可以保证输出结果一样的情况下对指令重排序,使性能得到优化,插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。
内存屏障的另一个作用是强制更新一次不同CPU的缓存,这意味着如果你对一个volatile字段进行写操作,你必须知道:
一旦你完成写入,任何访问这个字段的线程将会得到最新的值; 在你写入之前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到主存
volatile如何保证可见性
加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,它有三个功能:
任何带有lock前缀的指令以及CPUID等指令都有内存屏障的作用。
MESI缓存一致性协议底层是通过#Lock的指令进行触发的,而volatile关键词修饰之后的变量,编译为指令集执行时,会加上#Lock进行修饰,用来触发缓存一致性协议,而且#Lock指令修饰之后,置为M(修改状态)的变量,会强制立刻写入主内存中,并且发送消息至总线,其他加载此变量的线程,就会将工作内存中变量的使用状态修改为I(无效状态),此时线程就被迫需要重新从主内存中读取该变量的值,这就是volatile关键词保证可见性的原因。
- 确保指令重排序时不会把其后面的指令重排到内存屏障之前的位置,也不会把前面的指令排到内存屏障后面,即在执行到内存屏障这句指令时,前面的操作已经全部完成;
- 将当前处理器缓存行的数据立即写回系统内存(由volatile先行发生原则保证);
- 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效。写回操作时要经过总线传播数据,而每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器要对这个值进行修改的时候,会强制重新从系统内存里把数据读到处理器缓存(也是由volatile先行发生原则保证); 缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于”嗅探(snooping)”协议,它的基本思想是: 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。 CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。
反复思考IA-32手册对lock指令作用的这几段描述,可以得出lock指令的几个作用:
- 锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放,不过实际后来的处理器都采用锁缓存替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存
- lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据
- 不是内存屏障却能完成类似内存屏障的功能,阻止屏障两遍的指令重排序 由于效率问题,实际后来的处理器都采用锁缓存来替代锁总线,这种场景下多缓存的数据一致是通过缓存一致性协议来保证的
为什么有了mesi还要加volatile
既然CPU有了MESI协议可以保证cache的一致性,那么为什么还需要volatile这个关键词来保证可见性(内存屏障)?或者是只有加了volatile的变量在多核cpu执行的时候才会触发缓存一致性协议?
多核情况下,所有的cpu操作都会涉及缓存一致性的校验,只不过该协议是弱一致性,不能保证一个线程修改变量后,其他线程立马可见,也就是说虽然其他CPU状态已经置为无效,但是当前CPU可能将数据修改之后又去做其他事情,没有来得及将修改后的变量刷新回主存,而如果此时其他CPU需要使用该变量,则又会从主存中读取到旧的值s。而volatile则可以保证可见性,即立即刷新回主存,修改操作和写回操作必须是一个原子操作。
使用volatile的好处
从底层实现原理我们可以发现,volatile是一种非锁机制,这种机制可以避免锁机制引起的线程上下文切换和调度问题。因此,volatile的执行成本比synchronized更低。 volatile的不足:使用volatile关键字,可以保证可见性,但是却不能保证原子操作
volatile为什么不能实现原子性
volatile保证了读写一致性。但是当线程2已经使用旧值完成了运算指令,且将要回写到内存时,是不能保证原子性的。
具体化:使用git或svn开发项目时存在主干和分支,有一个全项目都使用的枚举类,所以小A修改了该类立即提交主干,并通知组内成员:“你们使用这个类时需要在主干上拉取一下”,但是此时小B在旧版本开发完毕并且正在提交这个类,导致了冲突。
解决指令重排序
相关知识
- 缓存行对齐
缓存行64个字节是CPU同步的基本单位,缓存行隔离会比伪共享效率要高
-
MESI
-
伪共享
-
合并写 storeBuffer
CPU内部的4个字节的Buffer
利用合并写的一个案例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
package com.mashibing.juc.c_029_WriteCombining; 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; } }
重排序案例
|
|
实现原理
-
java源码 volatile i
-
字节码:ACC_VOLATILE
-
JVM的内存屏障
屏障两边的指令不可以重排
在JSR规范中定义了4种内存屏障:
-
LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
-
LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
-
StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
-
StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
StoreLoad屏障是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。
对于volatile关键字,按照规范会有下面的操作:
- 在每个volatile写入(执行写)之前,插入一个StoreStore,写入之后,插入一个StoreLoad
- 在每个volatile读取(执行读)之前,插入LoadLoad,之后插入LoadStore
具体到X86来看,其实没那么多指令,只有StoreLoad:
结合上面的【一】和【二】的内容,内存屏障首先阻止了指令的重排,另外也和MESI协议结合,确保了内存的可见性
在volatile写入指令之后插入一个storeload barrier,保证了内存的可见性,因为storeload内存屏障保证了没写完之前就不能读,一旦写完立马会触发其他cpu的invalidate操作从而使其他cpu访问该地址时重新从主内存读。
- hotspot实现
内存屏障原语 有的cpu有有的cpu没有,不具有可移植性, 所以直接使用lock指令,锁总线。
被volatile修饰的变量,会加一个lock前缀的汇编指令。若变量被修改后,会立刻将变量由工作内存回写到主存中,那么意味了之前的操作已经执行完毕。这就是内存屏障。
bytecodeinterpreter.cpp
|
|
orderaccess_linux_x86.inline.hpp
|
|
-
cpu级别
-
mesi
-
原语支持
-
总线锁
-
浅谈原子性、可见性、有序性
原子性
我们大致可以认为基本数据类型的访问读写时具有原子性的(例外的就是long和double的非原子性协议)。如果应用场景需要一个更大范围的原子性保证。那么就需要synchronized关键字保证了。
可见性
可见性是指当一个线程修改了共享变量的值,其他线程能够立即感知这个修改。Java内存模型通常在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,**依赖主内存作为传递媒介的方式实现可见性的。**无论是普通变量还是volatile变量都是如此,**普通变量和volatile变量的唯一区别就是:volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。**因此volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
除了volatile之外,Java还有两个关键字能实现可见性,即synchronized
和final
。同步块的可见性是由**“对一个变量执行unlock操作之前,必须先把此变量同步回主内存这条规则获得的”,**而final关键字的可见性是指:**被final修饰的字段在构造器中一旦初始化,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件危险的事情,其他线程很可能通过这个引用访问到“初始化一半”的对象),**那么其他线程就能看见final字段的值。
说到这,不得不聊一下引用逃逸 Java分配在堆上的对象都是靠引用操作的,当对象在某个方法中被定义好之后。那么就将其引用作为其他方法的参数传递出去,这就叫做对象的引用逃逸。
如果原本的对象在当前方法结束后就会被垃圾回收器标记回收,但由于其引用被传递出去。被一个长期存活的对象所持有,那么对于GC Roots
来说,他就是可达对象。声明周期就是持有对象的生命周期。可能造成内存泄露。
this逃逸是指构造函数返回之前其他对象就持有该对象的引用调用尚未构造完成的对象方法可能引起错误。 this逃逸经常发生在构造函数中启动线程或注册监听器,如:
|
|
解决办法:
|
|
有序性
Java程序天然的有序性可以概括为:**如果在本线程内观察,所有的操作都是有序的;如果在一个线程观察另一个线程,所以的操作都是无序的。**前半句指的是“线程内表现为串行的语义”,后半句指的是“指令重排序”现象和“工作内存与主内存的同步延迟”现象。
Java语言提供了volatile
和synchronized
两个关键字保证线程之间操作的有序性。volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获取的。
总结
系统底层如何实现一致性
-
MESI如果能解决,就使用MESI
结合volatile 内存屏障使用
强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
-
如果不能,就是用锁总线
缓存行放不下 或者 不支持mesi
系统底层如何保证有序性
- 内存屏障 sfence(save) mfence(multi 全屏障) lfence(load) 等系统原语
- 锁总线