CPU术语定义
内存屏障: 英文翻译为memory barriers
,它是一组处理器指令,用于实现对内存操作的顺序限制。
缓冲行: 英文翻译为cache line
,缓存中可以分配的最小的存储单位,处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期
原子操作: 英文翻译为 atomic operation
,不可中断的一个或一系列的操作。
缓存行填充: 英文翻译为 cache fill line
,当处理器识别到从内存中读取操作数是可以缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或所有)
缓存命中: 英文翻译为 cache hit
,如果进行高速缓存行填充操作的内存位置仍然是下一次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存中读取。
写命中: 英文翻译为write hit
,当处理器将操作数写回到一个内存缓仔的区域时,匕目元公巴旦心个缓存的内存地址是否在缓存行中,如果存在一个有效的缓仔仃,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称写命中。
写缺失: 英文翻译为 write miss the cache
, 一个有效的缓存行被写入到不存在的内存区域。
volatile的两条实现原则
1、Lock前缀指令会引起处理器缓存写入内存: 它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
2、一个处理器的缓存回写到内存中会导致其他处理器的缓存无效: 处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。
volatile关键字的定义和实现原理
volatile
可以看作是轻量级的synchronized
,java的volatile
关键字在多处理器开发中保证了共享变量的“可见性”
,什么是可见性?可见性是当一个线程修改当前的共享变量时,另外一个线程可以看到和读取到这个共享变量变化后的值。而且volatile
关键字的使用恰当,可以不引起线程的上下文切换和调度
。这可比synchronized的使用和执行成本更低
。
volatile的可见性实现原理
volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现:
- 内存屏障,又称内存栅栏,是一个 CPU 指令。用于实现对内存操作的顺序限制。
- 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止+ 特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。
内存屏障
内存屏障是被插入两个指令之间进行使用,其作用是禁止处理器编译器进行指令重排序从而保证了指令的有序性。
按照可见性保障划分: 内存屏障可分为加载屏障(Load Barrier)
和存储屏障(Store Barrier)。
加载屏障的作用是刷新处理器缓存,存储屏障的作用冲刷处理器缓存。Java虚拟机会在MonitorExit(释放锁)
对应的机器码指令之后插入一个存储屏障
,这就保障了写线程在释放锁之前在临界区中对共享变量所做的更新对读线程的执行处理器来说是可同步的。JAVA虚拟机会在MonitorEnter(申请锁)
对应的机器码指令之后临界区开始之前的地方插入一个加载屏障
,这使得读程序的执行处理器能够将写线程对相应共享变量所作的更新从其他处理器同步到该处理的高速缓存中。
按照有序性保障划分: 可分为获取屏障(Acquire Barrier)
和释放屏障(Release Barrier)
。获取屏障
的作用是在一个读操作之后插入该内存屏障
,其作用是禁止该读操作与其后的任何读写操作之间进行重排序,这相当于进行的后续操作之前先要获得相应共享数据的所有权。释放屏障
的作用是在写操作之前插入该内存屏障
,其作用是禁止该写操纵与其前面的任何读操作进行重排序,这相当于在对应共享数据操作结束后释放所有权。Java虚拟机会在MonitorEnter对应的机器码指令之后临界区开始之前的地方插入一个获取屏障,并在临界区结束之后MonitorExit对应的机器码指令之前的地方插入一个释放屏障。
Lock指令
在 Pentium 和早期的 IA-32 处理器中,lock 前缀指令会使处理器执行当前指令时产生一个 LOCK# 信号
,会对总线进行锁定
,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放
。后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证。
缓存一致性协议
缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。LOCK# 因为锁总线效率太低,因此使用了多组缓存。为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议。缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " 嗅探(snooping)" 协议。所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
CPU高速缓存
CPU 在执行指令时需要从内存获取指令和所需的数据,但是 CPU 的速度要远大于内存速度,所以 CPU 直接从内存中存取数据要等待一定时间周期,造成资源的浪费并且影响性能。
这个时候就需要引入CPU高速缓存器,CPU高速缓存(Cache Memory)是用于减少处理器访问内存时所需要平均时间的部件,位于CPU与内存之间,它的容量远远小于内存。
当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(命中),则不经访问内存直接返回该数据;如果不存在(失效),则要先把内存中的相应数据载入缓存,再将其返回处理器。缓存之所以有效,主要是因为程序运行时对内存的访问呈现局部性(Locality)特征。这种局部性既包括空间局部性(Spatial Locality),也包括时间局部性(Temporal Locality)。有效利用这种局部性,缓存可以达到极高的命中率。
时间局部性:如果某个数据被访问,那么不久的将来他很可能被再次访问。
空间局部性:如果某个数据被访问,那么与他相邻的数据很快也可能被问。
CPU高速缓存构造图:
实例代码:
public class Demo {
private volatile int i;
public void update(){
i = 100;
}
public static void main(String[] args) {
Demo demo = new Demo();
demo.update();
System.out.println(demo.i);
}
}
结果:
使用hsdis和JITWatch可以得到汇编代码:hsdis和JITWatch的资源可以从我的百度网盘中下载:提取码:vbnn
0x000002620a035723: and $0xffffffffffffff87,%rdi
0x000002620a035727: je 0x000002620a0357b8
0x000002620a03572d: test $0x7,%rdi
0x000002620a035734: jne 0x000002620a03577d
0x000002620a035736: test $0x300,%rdi
0x000002620a03573d: jne 0x000002620a03575c
0x000002620a03573f: and $0x37f,%rax
0x000002620a035746: mov %rax,%rdi
0x000002620a035749: or %r15,%rdi
0x000002620a03574c: lock cmpxchg %rdi,(%rdx) // 在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
0x000002620a035751: jne 0x000002620a035bd5
0x000002620a035757: jmpq 0x000002620a0357b8
0x000002620a03575c: mov 0x8(%rdx),%edi
0x000002620a03575f: shl $0x3,%rdi
0x000002620a035763: mov 0xa8(%rdi),%rdi
0x000002620a03576a: or %r15,%rdi
volatile的有序性实现原理
happens-before 的定义
happens-before的基本作用是指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happensbefore关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。
禁止指令重排序
为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。 Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。 JMM 会针对编译器制定 volatile 重排序规则表(No表示禁止重排序)。
为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。 对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile读操作的后面插入一个 LoadStore 屏障。
常见volatile关键字问题
1、volatile关键字的作用是什么?
2、volatile能保证原子性吗??
3、之前32位机器上共享的long和double变量的为什么要用volatile?
4、现在64位机器上是否也要设置呢?
5、i++为什么不能保证原子性?
6、volatile是如何实现可见性的?
7、volatile是如何实现有序性的?
8、说下volatile的应用场景?
9、volatile 变量和 atomic 变量有什么不同?
以上是volatile关键字的常见问题,后续会对这些问题进行详细解答。
问题解析
禁止指令重排序
首先来一个很经典的重排序问题,那就是如何实现单例模式,那么在并发的环境下实现单例模式,那么通常可以通过双重加锁的方式(DCL)来实现。
/**
* @author: 随风飘的云
* @describe:
* @date 2022/03/28 0:13
*/
public class Reordering {
public static volatile Reordering instance;
/**
* 私有构造函数,防止外部实例化
*/
private Reordering(){
}
public static Reordering getInstance(){
if(instance == null){
synchronized (Reordering.class){
if(instance == null){
instance = new Reordering();
}
}
}
return instance;
}
}
构造一个对象需要三个步骤
1、分配内存空间
2、初始化对象
3、将内存空间的地址赋值给对应的地址引用。
假如这个时候发送了指令重排序,那么构造一个对象的三个步骤可能变成这样子:
1、分配内存空间
2、将内存空间的地址赋值给对应的地址引用。
3、初始化对象
如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。
实现可见性
volatile 关键字是如何保证可见性的?首先来看下面的代码:
instance = new Singleton(); // instance是volatile变量
可以查看使用JIT编译器生成的汇编指令来查看对volatile关键字修饰的变量进行写操作是,CPU会做什么?转变成汇编代码,如下:
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
可以看出有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,Lock前缀的指令在多核处理器下会引发了两件事情:
1、将当前处理器缓存行的数据写回系统内存中
2、这个写会内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
为了提高运行速度,处理器不直接与内存通信,而是将系统内存中的数据写入到CPU的内部缓存中再进行其他的操作。如果对volatile关键字修饰了的变量进行写操作,JVM虚拟机就会向处理器发送一条Lock指令,将变量所在的缓存行写入到系统内存中,每个处理器需要检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。这样就实行了缓存一致性协议。
实例代码:
/**
* @author: 随风飘的云
* @describe:
* @date 2022/03/28 0:13
*/
public class Reordering {
private int a = 1;
private int b = 2;
public void change(){
a = 3;
b = a;
}
public void print(){
System.out.println("a: " + a + " b: " + b);
}
public static void main(String[] args) {
while (true){
final Reordering reordering = new Reordering();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(2000);
}catch (InterruptedException exception){
exception.printStackTrace();
}
reordering.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(2000);
}catch (InterruptedException exception){
exception.printStackTrace();
}
reordering.print();
}
}).start();
}
}
}
结果:
代码分析:
正常来说,上面的代码只会出现 a = 1,b = 2或者是 a = 3, b = 3,因为如果先执行change方法,再执行print方法,输出结果应该为b=3,a=3。相反,如果先执行的print方法,再执行change方法,结果应该是 b=2, a=1。不应该出现其他的结果了,奇怪的是上面会出现 a = 1,b = 3的结果,这是因为第一个线程修改了 a 的值,这个值对于第二个线程来说是不可见的。如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。
保证原子性
严格意义上来说,volatile
关键字仅是实现变量的修改可见性,并不能完全保证原子性,仅仅只是可以保证读写操作的单次操作具有原子性。可以这般理解,volatile
关键字实现可见性的本质是告诉jvm虚拟机
使用volatile关键字修饰的当前变量在寄存器(工作内存)中的值是不确定
的,需要从主存中读取,而synchronized则是锁定当前变量,只有当前变量可以访问改变量,其他线程则被阻塞,这样子,synchronized可以实现可见性和原子性,这也是为什么上文中说到volatile是轻量级的synchronized了。
问题1:i ++为什么不可以保证原子性
volatile变量的单次读/写操作是可以保证原子性的,如long
和double
类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。
实例代码:
/**
* @author: 随风飘的云
* @describe:
* @date 2022/03/28 1:17
*/
public class VolatileTest {
private volatile int a = 0;
public void change(){
a ++;
}
public static void main(String[] args) throws InterruptedException {
final VolatileTest test = new VolatileTest();
for (int i = 0; i < 1000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(2000);
}catch (InterruptedException exception){
exception.printStackTrace();
}
test.change();
}
}).start();
}
Thread.sleep(10000);
System.out.println(test.a);
}
}
结果:
代码分析:
上面的结果有点强差人意,正常来说a的值应当是1000,因为使用了volatile关键字修饰了a变量。但是a的值仅仅是967。这就存在了一个问题了,volatile关键字仅仅只是可以保证单次的读写操作具有原子性,但是不保证类似于 i ++这种复合操作具有原子性。i ++ 的操作步骤可以划分为三个:
1、读取 i 的值
2、将 i 的值加1
3、将 i 写回内存中
volatile是无法保证这三个操作是具有原子性的,但是可以通过AtomicInteger或者Synchronized来保证+1操作的原子性。
共享的long和double变量的需要用volatile
首先对于long类型或者是double类型的变量具有高32位或是低32位的操作,那么对于这两种类型的共享变量的读写操作是不能保证具有原子性的,因此需要使用volatile关键字修饰该变量,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。
volatile和synchronized的区别
文章参考:
- https://www.pdai.tech/md/java/thread/java-thread-x-key-volatile.html#volatile-%E6%9C%89%E5%BA%8F%E6%80%A7%E5%AE%9E%E7%8E%B0
- https://javaguide.cn/java/jvm/class-loading-process.html#%E7%B1%BB%E7%9A%84%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F
- https://zhuanlan.zhihu.com/p/375706879