volatile
JMM
JMM的抽象:主内存和本地内存
JMM有以下规定:
所有变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝;
线程不能直接读写主内存中的变量,而是只操作自己工作内存中的变量,然后再同步到主内存中;
主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成;
为什么会有可见性问题
CPU有多级缓存,导致读的数据过期;
高速缓存的容量比主内存小,但是速度仅次于寄存器,所以在CPU和主内存之间就多了Cache层;
线程间的对于共享变量的可见性问题不是直接由多核引起的,而是由多缓存引起的;
如果所有核心都只用一个缓存,那么就不存在内存可见性问题了;
每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入主存中。所以会导致有些核心取到的值是一个过期的值;
重排序与happens-before
为什么存在重排序?为什么可以提高性能?
a = b + c
d = e - f先加载b、c(注意,即有可能先加载b,也有可能先加载c),但是在执行add(b,c)的时候,需要等待b、c装载结束才能继续执行,也就是增加了停顿,那么后面的指令也会依次有停顿,这降低了计算机的执行效率。
为了减少这个停顿,我们可以先加载e和f,然后再去加载add(b,c),这样做对程序(串行)是没有影响的,但却减少了停顿。既然add(b,c)需要停顿,那还不如去做一些有意义的事情。
指令重排对于提高CPU处理性能十分必要。虽然由此带来了乱序的问题,但是这点牺牲是值得的。
指令重排的类型
- 编译器优化重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
- 指令并行重排:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序;
- 内存系统重排:由于处理器使用缓存和读写缓冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差;
happens-before
- happens-before原则
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前;
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序;
- 天然的happens-before关系
- 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作;
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁;
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读;
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C;
- start规则:如果线程A执行操作ThreadB.start()启动线程B,那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作;
- join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回;
作用
- 保证变量的内存可见性;
- 禁止volatile变量与普通变量重排序(JSR133提出,Java 5 开始才有这个“增强的volatile内存语义”);
原理
可见性实现:
- 修改volatile变量时会强制将修改后的值刷新的主内存中;
- 修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值;
禁止重排序实现
JVM限制处理器的重排序 —— 内存屏障
硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。
内存屏障有两个作用:
阻止屏障两侧的指令重排序;
强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效;
(注意这里的缓存主要指的是CPU缓存,如L1,L2等)
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个比较保守的JMM内存屏障插入策略,这样可以保证在任何处理器平台,任何程序中都能得到正确的volatile内存语义。这个策略是:
- 在每个volatile写操作前插入一个StoreStore屏障;
- 在每个volatile写操作后插入一个StoreLoad屏障;
- 在每个volatile读操作后插入一个LoadLoad屏障;
- 在每个volatile读操作后再插入一个LoadStore屏障;
再介绍一下volatile与普通变量的重排序规则:
- 如果第一个操作是volatile读,那无论第二个操作是什么,都不能重排序;
- 如果第二个操作是volatile写,那无论第一个操作是什么,都不能重排序;
- 如果第一个操作是volatile写,第二个操作是volatile读,那不能重排序;
应用
1)单例模式 —— 双重锁检查
public class Singleton { |
如果这里的变量声明不使用volatile关键字,则可能会发生错误的。它可能会被重排序:
instance = new Singleton();
// 可以分解为以下三个步骤 |
而一旦假设发生了这样的重排序,比如线程A执行了步骤1和步骤3,但是步骤2还没有执行完。这个时候另一个线程B执行到了if (instance == null)
,它会判定instance不为空,然后直接返回了一个未初始化完成的instance!
常见问题
除了在volatile中使用了内存屏障,Java还有哪里使用了内存屏障?
Oracle的JDK中提供了Unsafe. putOrderedObject,Unsafe. putOrderedInt,Unsafe. putOrderedLong这三个方法,JDK会在执行这三个方法时插入StoreStore内存屏障,避免发生写操作重排序;
初次读包含final域的对象引用和读取这个final域,这两个操作不能重排序,使用了LoadLoad屏障;
原理
- 将当前内核高速缓存行的数据立刻回写到内存;
- 使在其他内核里缓存了该内存地址的数据无效;
MESI协议:该缓存一致性思路:当CPU写数据时,如果发现操作的变量时共享变量,即其他线程的工作内存也存在该变量,会通过CPU 总线嗅探机制告知其他线程该变量副本已经失效,需要重新从主内存中读取。当其他线程需要使用这个变量时,如内存地址失效,那么它们会在主存中重新读取该值。