前言
本文将以一个java代码的可见性问题作为引子,一步步从硬件层面推导到软件层面,最后引出volatile的作用。
文章篇幅较长,需耐心观看。这是作者学习完这块后自己做的整理,若存在描述有误、不清晰和混淆的情况,欢迎评论区及时指正批评!
1. 存在可见性问题的java代码
public class VisableDemo {
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
}
System.out.println("退出循环:" + i);
});
thread.start();
System.out.println("线程开始运行...");
Thread.sleep(1000);
stop = true;
}
}
这段代码中,先启动子线程,子线程通过标志位stop控制循环的运行。我们期望在主线程睡眠一秒后修改标志位,结束子线程的运行。
但是在实际运行中,我们会发现子线程根本停不下来,这说明了我们在主线程修改标志位这个操作,对子线程是不可见
的。
为什么会有这个问题?这个问题该如何解决?这里不着急给出答案,我们先进行一番推导,最后再找到问题本质和解决方案。
2. 可见性问题硬件
层面推导过程
2.1 cpu高速缓存
在算机系统中,cpu资源非常宝贵,它会从主内存或IO设备中加载数据并执行指令。然而,cpu
的执行速度远远大于
从主内存
读取数据的速度,主内存存取数据的速度又远远大于IO设备。这里就出现了木桶效应,系统运行的速度取决于最慢的IO设备,这导致cpu资源无法得到充分的利用。
为了解决上述问题,引入了高速缓存
来平衡CPU和主内存之间的速度差异;引入了进程、线程、时间片调度等机制来平衡cpu和io设备之间的速度差异。
引入高速缓存
是为了提升性能和cpu利用率
,但是却带来了数据一致性
问题。不同的cpu不再是直接把数据写到主内存,而是优先写入
自己的高速缓存行
,在合适的时机同步到主内存。
假设cpu0修改了数据,但还没同步到主内存,此时主内存的数据其实就是过期的。而cpu1从主内存读取了数据并进行操作,这时候cpu1操作的数据并不是最新的,就出现了数据一致性的问题。
那么,如何解决引入高速缓存带来的数据一致性问题呢?
2.2 总线锁
1.什么是总线锁?
总线可以理解为是用于联通cpu和主内存的桥梁,它是一种处理器级别
的同步机制
。
通过在总线上加锁(即总线锁),可以保证各个cpu访问主内存时的互斥特性,从而解决数据一致性问题。
2.总线锁是怎么解决数据一致性问题的?
当CPU需要读取或写入某个共享数据时,它会通过总线发送一个锁定信号。其他的CPU在接收到该信号后会停止对该共享数据的访问,并等待锁的释放。这个操作将CPU的读写操作变成了串行执行
,以确保数据的一致性。
然而,CPU架构从单核发展到多核,主要目的是为了实现并发执行以提高程序的执行效率。这样一来,引入锁机制后的串行化操作会带来性能问题,降低了CPU的利用率。为了解决这个问题,在x86架构的CPU中引入了缓存锁的概念来进行优化。
2.3 缓存锁
1.什么是缓存锁?
首先,缓存锁不是锁,可以把它理解为是一种实现缓存一致性协议
的机制,通过某些规则控制缓存的状态来保证缓存一致性
。缓存锁只在数据被缓存在高速缓存时起作用
,相较于总线锁而言粒度更小。
常见的缓存一致性协议:MESI、MSI、MOSI等,下面以MESI为例展开论述。
2.什么是MESI协议?
MESI协议是使用最广泛的缓存一致性协议
,基于总线嗅探
实现。
3.什么是总线嗅探?
总线嗅探是多处理器系统
中的一种通信机制
,用于处理多个处理器的共享数据
。每个处理器都可以监视总线上的数据传输,如果传输的数据和本处理器相关,则可以进行相应操作。
总线嗅探机制能够减少数据冲突和锁竞争
,提高系统的并行性
和效率
。但是总线嗅探也会引发总线风暴
的问题,即多个处理器同时竞争总线上的资源时,会产生大量的总线通信。总线风暴会降低系统的性能,并可能导致系统崩溃。
4.MESI的四种状态含义?
1.Modified(M):修改状态,表示缓存行数据已经被修改,并且与主内存中的数据不一致,这意味着该数据只存在于当前缓存行;
2.Exclusive(E):独占状态,表示数据被当前缓存行独占,与主内存中的数据一致,并且数据只存在于当前缓存行中,其它缓存行没有该数据;
3.Shared(S):共享状态,表示当前缓存行的数据和主内存一致,并且其它缓存行也有这个数据;
4.Invalid(I):无效状态,表示当前缓存行无效
缓存锁实际上是通过类似MESI等缓存一致性协议来解决缓存一致性问题的。如果想了解各个CPU缓存行之间状态切换的情况,可以通过下面的链接进行实际尝试,需要记住以下两点:
- CPU首先会尝试从自己的缓存行读取数据,并根据缓存行中数据的状态来确定下一步的操作。为了更好地理解这一点,你可以参考链接中的动画演示并向ChatGPT提问;
- 总线嗅探是通过使用总线上的信号来实现的。各个处理器可以同时嗅探总线上的信号,这一现象在操作过程中是可见的。
5.MESI中存在的问题
在cpu修改缓存行数据时,会去通知其它缓存行修改状态为Invalid。其它缓存行收到通知并修改缓存行状态后,给该cpu一个ack响应。
在此期间该cpu需要等待ack响应,等待的这段时间虽然很短,但却是阻塞状态
的,这浪费了cpu资源,降低了cpu利用率,因此又引入了写缓存Store Buffer和无效化队列Invalidate Queue。
2.4 优化MESI,引入Store Buffer和Invalidate Queue
在引入Store Buffer和Invalidate Queue之后,cpu先把数据写到Store Buffer。此时cpu不再需要等待ack响应,而是可以继续往下执行指令,由Store Buffer
完成异步通知
其它cpu的操作,并接收其它cpu返回的ack。Invalidate Queue
用于记录状态变更的通知
,即其它cpu在接收到状态修改通知时,会先放到Invalidate Queue,等待cpu空闲后再去更新queue里记录的状态变更操作。
引入Store Buffer和Invalidate Queue后解决了cpu的短暂阻塞问题,提升了cpu利用率和系统处理性能,但是又引出了指令重排序问题
。而这种异步化的处理,又会带来数据的一致性问题
,即可见性问题
。
所以,一致性和可见性问题的本质
,是因为底层硬件层面引入了Store Buffer和Invalidate Queue
的异步化操作。
如上图所示,cpu0的缓存行中有a=0(S状态)和b=0(E状态),cpu1的缓存行中有a=0(S状态)。cpu0和cpu1中分别执行图中描述的一段指令,理想中cpu1执行assert(a==1)应该为true,因为我们认为cpu0中的a=1的赋值操作应该是先执行的。但实际上cpu1是有可能出现assert(a==1)为false的,即b=1执行完了,但是a=1还没执行完,这就出现了指令重排序问题,过程如下:
1.cpu0执行a=1操作,先把这个操作写入Store Buffer,然后cpu0继续往下执行指令;
2.Store Buffer异步通知cpu1将a变为invalid状态。cpu1接收到通知后,把invalidate a操作放入Invalidate Queue,等待cpu1空闲后执行,然后返回cpu0一个ack;
3.cpu0收到ack后,把a=1写入缓存行,并修改状态为Modify;
4.cpu0继续往下执行指令,执行b=1,将缓存行的b变为Modify状态;
5.cpu1执行while(b==1),但缓存行中没有b,因此去主内存和其它cpu缓存行读取b;
6.cpu1读取到了cpu0中b=1的值,将b=1写到自己的缓存行,且各缓存行的状态变为shared,即多个缓存行共享数据,且和主内存一致。此时b执行while(b==1)为true;
7.cpu1执行assert(a==1),发现自己的缓存行中有a=0(S状态),于是返回false;
8.cpu1执行执行完了,cpu空闲了,去处理Invalidate Queue中的操作,将a=0变为Invalid状态
从上述过程可以发现,引入Store Buffer和Invalidate Queue后,确实解决了cpu短暂阻塞的问题,但是又引入了指令重排序问题,并且也存在数据的可见性问题(cpu0的a=1操作对cpu0不可见)。
为了解决指令重排序问题,操作系统层面引入了内存屏障。
2.5 内存屏障
1.什么是内存屏障?
内存屏障是硬件层面
提供的同步屏障指令
,可以选择在合适的时机插入屏障,避免底层优化带来的重排序问题
。
2.内存屏障哪几种类型?
有三种,即读屏障(Store)、写屏障(Load)、全屏障(Fence)
下面简单介绍一下读屏障、写屏障如何解决上面重排序问题的例子:
a.读屏障
引入读屏障后,a的值会去主内存读取最新数据
,而不是读取自身缓存行。
while(b==1){
// 读屏障
assert(a==1);
}
b.写屏障
下面位置加了写屏障后,a一定优先于b执行,a的结果对b可见。因为a的写操作不再是异步写入Store Buffer,而是直接同步写入主内存,两个指令之间不允许重排序
。
a = 1;
// 写屏障
b = 1;
注意
:不同的操作系统和cpu架构之间会有差异,对于同一概念的具体实现可能不同。下图是linux_x86架构下提供的四种内存屏障指令:
2.6 lock汇编指令
上面提及的内存屏障指令,在使用时会用到lock这个汇编指令,让编译器和cpu的优化失效,并且这里我们也看见了volatile的身影。在Hotspot源码中可以印证:
lock是个汇编指令
,它可以通过内存屏障
来禁用Store Buffer和Invalidate Queue,从而禁止指令重排序,保证数据的有序性和可见性。
3. 可见性问题软件
层面推导过程
3.1 Java内存模型--JMM
1.什么是JMM?
Java Memory Model(JMM),即Java内存模型。它是一种抽象模型,是一种描述多线程并发访问共享内存的行为规范,它屏蔽了底层各种硬件和操作系统的访问差异。在Java层面提供了volatile、synchronized、final等关键字,通过使用这些关键字,JMM就能自动调用底层的相关指令解决原子性、可见性、有序性问题。
补充
:JMM和JVM的区别
两者的概念并不相同。
JVM(Java Virtual Machine)是Java程序的运行环境,是Java程序运行的基础,负责将Java程序编译后的字节码文件解释成机器码并执行。
JMM(Java Memory Model)是Java程序中用来描述多线程并发访问共享内存的行为规范,它屏蔽了底层硬件和操作系统的各种细节,通过使用JMM提供的高级指令,它就可以帮我们自动调用底层相关指令处理原子性、有序性、可见性问题。
3.2 volatile
volatile是Java层面的一个关键字,常用于修饰共享变量
,能够保证变量的可见性和有序性。
通过上面的分析,我们知道volatile修饰的变量在处理时会用到lock
汇编指令,会通过内存屏障禁用Store Buffer和Invalidate Queue,从而禁止异步操作和指令重排序,保证可见性和有序性问题。
3.3 Happens-Before模型
并不是所有情况下都要用volatile保证可见性和有序性,有些情况下是天生能够解决这些问题的。因此有了Happens-Before模型,它描述的六种规则
之下,不需要使用volatile关键字就可以保证可见性和有序性。
3.3.1 程序顺序规则
一个线程中的每个操作,都happens-before线程中后续的任意操作,可以理解为是as-if-serial。即允许指令重排序,但是不论怎么排序,在单线程环境下的程序执行结果不能改变。
int a = 1;
int b = 1;
int c = a*b;
比如上面这段代码,a和b的顺序允许重排序,因为不会影响到最后的结果,但是c不允许重排序。注意
:a happens-before b,并不代表a一定在b之前执行,它指的是a的结果对b可见,如果b用到了a的值,那么b中看到的a一定是最新的。
3.3.2 传递性规则
如果a happens-before b,b happens-before c,则a happens-before c。即a对b可见,b对c可见,则a一定对c可见
3.3.3 volatile变量规则
volatile修饰变量的写操作,一定happens-before后续对volatile变量的读操作。因为volatile通过内存屏障防止了指令重排序,详情见上面的推导和描述,这里不再赘述。
具体的volatile重排序规则参见下图:
下面用一段示例代码来加深理解:
public class VolatileDemo{
int a = 0;
volatile boolean flag = false;
public void writer(){
a = 1; 1
flag = true; // volatile变量的修改操作 2
}
public void reader(){
if(flag){ // 一定为true 3
int i = a; // 一定为1 4
}
}
}
分析:
a. 1 happens-before 2 成立。因为volatile重排序规则,第一个操作是普通变量a的读写,第二个操作是volatile变量的写,不允许重排序,所以1对2是可见的,参见上面的volatile重排序规则表。这里也可以理解为满足程序顺序规则;
b. 3 happens-before 4 成立。因为满足程序顺序规则,在单线程环境下要保证逻辑正确;
c.2 happens-before 3 成立。因为满足volatile变量规则,2是volatile的写,3是volatile的读,不允许重排序,所以2对3可见;
d.综上,因为传递性规则,所以1 happens-before 4 成立,a改为1后,4的位置一定是1。
3.3.4 监视器锁规则
一个线程对于一个锁的释放操作一定happens-before后续对这个线程的加锁操作,意味着后续的线程获取锁的时候,读取到的一定是上次锁释放中修改的值。
int x = 1;
synchronized(this){
if(x<10){
x = 12;
}
}
上面这段代码中,第一个线程拿到锁并操作后,x变为12。后续线程再次拿到这个锁时,x的值一定是12。
3.3.5 start规则
一个线程在start之前的其它操作,一定happens-before这个线程中的任意操作。
public class StartDemo{
int x = 1;
Thread t1 = new Thread(()->{
//这里做读取x的操作,读取到的一定是最新的10
});
x = 10;
t1.start();
}
上面代码中,因为x=10在线程start()之前执行,因此线程内部读取x的值一定是10。
3.3.6 join规则
如果线程A执行ThreadB.join()并成功返回,那么线程B中执行的任意操作happens-before于线程A从ThreadB.join()操作成功返回后的其它操作。
可能有点绕,看看下面的代码就明白了:
public class JoinDemo {
private static int x = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
x = 200;
});
t1.start();
t1.join(); // join方法在这里会阻塞主线程,让t1线程先执行
System.out.println(x); // 在此处读取x一定是200
}
}
在main线程中调用t1.join(),则t1中的操作执行完毕后,对main线程后面的操作是可见的,因此最后一句输出的x是200。
4. 总结
回顾前面的推导过程,我们会发现资源的一致性
和性能
之间是矛盾
的:
- cpu和主内存之间速度差距过大,引入高速缓存平衡性能,但是却带来了数据一致性问题;
- 使用总线锁串行执行,解决了数据一致性问题,但却导致性能下降;
- 使用缓存锁优化总线锁,锁的粒度变小了,性能提高了,但是会出现cpu短暂阻塞的性能问题,还有优化的空间;
- 引入Store Buffer和Invalidate Queue,异步操作解决了性能问题,但是又带来了指令重排序问题和可见性问题;
- 提供了内存屏障来解决指令重排序问题。
可见性和有序性的本质在于引入Store Buffer和Invalidate Queue,从上面的示例中可以看到效果。而volatile修饰变量之所以能解决,是因为会加上lock汇编指令,通过内存屏障禁用了Store Buffer和Invalidate Queue,从而解决了有序性和可见性问题。
所以,最开始提到的存在可见性问题代码,我们使用volatile修饰stop这个变量即可解决问题。
简述一下,还有两个骚操作可以解决这个问题,这两个操作都是在while (!stop) {i++;}的i++后跟上操作:
1.使用sout:while (!stop) {i++;System.out.println();}
因为sout中用到了IO阻塞和synchronized,破坏了深度优化;
2.使用sleep:while (!stop) {i++;Thread.sleep();}
因为sleep()会触发操作系统的重新调度,让线程原本的缓存行失效。