上篇博文从内存可见性看Volatile、原子变量和CAS算法提到了volatile保证内存可见性和CAS算法。本篇博文着重学习volatile的底层实现原理。
【1】回顾volatile
volatile是Java虚拟机提供的轻量级的同步机制
,volatile相当于是轻量级的synchronized。如果一个变量使用volatile,则它比使用synchronized的成本更加低,因为它不会引起线程上下文的切换和调度
。
通俗点讲就是说一个变量如果用volatile修饰了,则Java可以确保所有线程看到这个变量的值是一致的,如果某个线程对volatile修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是所谓的线程可见性(内存可见性)
。
volatile关键字有如下两个作用:
- 保证被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
- 禁止指令重排序优化(通过内存屏障实现)。
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
【2】计算机的CPU,主存,和高速缓存
计算机在运行程序时,每条指令都是在CPU中执行的,在执行过程中势必会涉及到数据的读写。
我们知道程序运行的数据是存储在主存
中,这时就会有一个问题,读写主存中的数据没有CPU中执行指令的速度快,如果任何的交互都需要与主存打交道则会大大影响效率,所以就有了CPU高速缓存(实际上是一个多级寄存器)。
CPU高速缓存为某个CPU独有,只与在该CPU运行的线程有关。如图:
有了CPU高速缓存虽然解决了效率问题,但是它会带来一个新的问题:数据一致性(缓存一致性)。
在程序运行中,会将运行所需要的数据复制一份到CPU高速缓存中,在进行运算时CPU不再与主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后才会将数据刷新到主存中。
举一个简单的例子:i++操作。参考博文从内存可见性看Volatile、原子变量和CAS算法。
这种操作在单CPU是没问题的,但是现在都是多核多CPU多线程的时代,那么当他们都同时进行i++操作,那么就有问题了。所以硬件厂商要想办法解决这个问题,所以有了两个解决缓存一致性的方案(即在多CPU下,如何保证原子性
):
- 通过在总线加LOCK#锁的方式
- 通过缓存一致性协议
抽象图示如下:
注意:这部分内容可类比java内存模型JMM。
但是方案1存在一个问题,它是采用一种独占的方式来实现的,即总线加LOCK#锁的话,只能有一个CPU能够运行,其他CPU都得阻塞,效率较为低下。
第二种方案,缓存一致性协议(MESI协议)。它确保每个缓存中使用的共享变量的副本是一致的。
其核心思想如下: 当某个CPU在写数据时,如果发现操作的变量是共享变量
,则会通知其他CPU告知该变量的缓存行是无效的。因此其他CPU在读取该变量时,发现其无效会重新从主存中加载数据
。
如何告知CPU操作的变量是共享变量呢?变量使用volatile修饰。
【3】volatile原理–内存屏障指令
加了volatile修饰符的共享变量之所以能在线程之间保证可见性,是因为该变量加了基于CPU的内存屏障指令
,被JSR-133的java内存模型抽象为happens-before
原则。
happens-before规则中的volatile变量规则规定了一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
具体表现为(JMM指java内存模型):
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
从而保证了,如果某个线程对volatile修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是所谓的线程可见性。
volatile关键字另一个作用就是禁止指令重排优化
,从而避免多线程环境下程序出现乱序执行的现象。
① 内存屏障
内存屏障(memory barrier)是一个CPU指令。这条指令可以确保一些特定指令的执行顺序,影响一些数据的可见性(可能是某些指令执行后的结果)。
插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。
重排序屏障类型有四种:
- StoreStore屏障,(写写)
- StoreLoad屏障,(写读)
- LoadLoad屏障,(读读)
- LoadStore屏障。(读写)
① LoadLoad屏障
Load1;
LoadLoad屏障;
Load2;
Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
② StoreStore屏障
Store1;
StoreStore屏障;
Store2;
Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见。
③ LoadStore屏障
Load1;
LoadStore屏障;
Store2;
在Store2被写入前,保证Load1要读取的数据被读取完毕。
④ StoreLoad屏障
Store1;
StoreLoad屏障;
Load2;
在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的
。
简单来说,只要volatile变量与普通变量之间的重排序可能破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
② 实现机制
从代码的层面我们看不到volatile的实现机制,因此我们需要从汇编指令的层次进行研究。
观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令
。这个指令就相当于一个内存屏障
。
参考博文:查看java文件汇编代码。
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它会提供3个功能:
- 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 强制将对缓存的修改操作立即写入主存,利用缓存一致性机制,并且缓存一致性机制会阻止同时修改由两个以上CPU缓存的内存区域数据。
- 如果是写操作,它会导致其他CPU中对应的缓存行无效。
volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM采用了保守策略。如下:
- 在每一个volatile写操作前面插入一个StoreStore屏障:保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中
- 在每一个volatile写操作后面插入一个StoreLoad屏障:避免volatile写与后面可能有的volatile读/写操作重排序
- 在每一个volatile读操作前面插入一个LoadLoad屏障:禁止处理器把上面的volatile读与下面的普通读重排序
- 在每一个volatile读操作后面插入一个LoadStore屏障:禁止处理器把上面的volatile读与下面的普通写重排序
③ 可见性与有序性
① 可见性
如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存(主存)。
这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据
。
但这时候其他处理器的缓存还是旧的
。所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查自己的缓存是否过期。
当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态。当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。
这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的
。
② 有序性
Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面。
即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
【4】volatile与synchronized区别和使用场景
volatile是一种轻量级的锁,synchronized是重量级锁(悲观锁),volatile与synchronized区别如下:
-
对于多线程,volatile不是一种互斥关系;
-
synchronized是保证互斥的,即拿到锁的线程可以执行,其他线程过来只能等待;volatile不能保证这种关系。
-
volatile不能保证变量状态的“原子性操作”。
不要将volatile用在getAndOperate场合(这种场合不原子,需要再加锁),仅仅set或者get的场景是适合volatile的。
由于volatile只能保证变量的可见性和屏蔽指令重排序
,只有满足下面2条规则时,才能使用volatile来保证并发安全,否则就需要加锁(使用synchronized、lock或者java.util.concurrent中的Atomic原子类)来保证并发中的原子性。
- 运算结果不存在数据依赖(重排序的数据依赖性),或者只有单一的线程修改变量的值(重排序的as-if-serial语义)
- 变量不需要与其他的状态变量共同参与不变约束
因为需要在本地代码中插入许多内存屏蔽指令在屏蔽特定条件下的重排序,volatile变量的写操作与读操作相比慢一些,但是其性能开销比锁低很多。
【5】volatile与java内存模型
volatile还是需要懂得java内存模型,如果不了解java内存模型可能对主存、内存以及高速缓存没有个清晰概念。
上图与CPU,高速缓存,主存的结构很类似。关于JMM的更多详情参考博文深入学习Java内存模型JMM
参考博文:
https://mp.weixin.qq.com/s/D6bQDmNJrXvksO0WsHla6g