线程的安全性之可见性及指令重排
先来看一个关于可见性问题的案例
public class VolatileExample {
public static boolean stop = false;
/**
* 可见性问题:线程A修改某一变种线程B感知不到变量修改,称为可见性问题
* @param args
* @throws InterruptedException
*/
public static void main(String[] args)throws InterruptedException {
Thread thread1 = new Thread(() -> {
int i = 0;
//当前stop存在可见性的问题,当main线程里面的stop的值被修改后,thread1线程里面的感知不到stop被修改了
while(!stop){
i++;
}
});
thread1.start();
System.out.println("begin start thread");
Thread.sleep(1000);
stop = true;
}
}
当给stop加上了volatile后,这个程序可以正常的结束,也就是当main线程修改了stop后thread1线程感知到了变量的修改。
什么导致可见性的问题?
- CPU资源的利用问题
- CPU增加高速缓存(本文所讲)
- 操作系统增加进程、线程 -->通过CPU的时间片切换,提升CPU利用率
- 编译器(JVM的深度优化)
缓存一致性
如何产生的缓存不一致的问题
为啥会出现缓存不一致的问题?
在现的CPU运算速度越来越高,它的运行速度和内存读取数据的速度差距的越来越大,在读取内存数据的过程中CPU会处于空闲状态,为了解决CPU资源利用的问题,便引入了高速缓存(Cache),
基本就成了如下图:
这里的L3Cahce是共享缓存。引入了高速缓存后就会出现当一个共享值(共享值指的是多个CPU使用同一个值),当CPU-0修改了缓存内的这个共享值,另外CPU-1感知不到这个值被修改了,这就是缓存不一致的问题。
如何解决缓存不一致
可以通过两种方式解决缓存不一致的问题(主要说的是缓存锁):
- 总线锁
- 缓存锁
缓存锁可以通过MESI协议来解决这一个问题。
MESI表示缓存的四种状态
- M: 修改,当一个值被修改后当前值会变成M状态
- E:独占,当一个值只存在一个CPU缓存里面时,它就是E状态,因为它只存在一个CPU里面,其它CPU里面没有,所以就是E状态
- S:共享,在多个CPU里面有同一个值,该数据就是共享状态
- I: 失效:当一个值变成失效状态后,CPU想要使用该值的时候只能从内存中读取
当一个CPU修改了一个为S状态的数据后,其它CPU里面的数据如何修改的呢?
每个CPU里面都会有高速缓存,它们会标记缓存行的状态,这里面会有一个叫Snoopy的嗅探协议,可以通过这个Snoopy去监听总线上的事件,当处理器发起请求的时候,它会分配一个总线标识,其它的CPU接收到这个标识后进行相应处理,这个是使缓存失效的一个方式
比如当一个CPU-A修改本地缓存的数据的时候(i = 0),这个时它会发出一个失效的事件到总线上,CPU-B接收到这个事件后会将自已缓存中的数据置为失效状态,然后CPU-A将数据写入到主存,CPU-B发现自已的缓存是失效状态,便重新去主存加载数据,这个是解决缓存一致性问题的方式
总结:在修改数据的时候,会让处于S状态(共享状态)下其它CPU中的数据缓存失效,然后缓存失效的数据重新去主存中读取修改后的数据
这里通过缓存锁或总线锁来避免多个CPU同时对主存修改而产生的问题
这里的缓存锁或总线锁如何加呢?说到这里不得不提到一个指令Lock,这是一个汇编指令,Lock可以根据Cpu的架构来选择加缓存锁或总线锁,如果不支持缓存锁,那么就会加总线锁(总线锁CPU是都支持的),这个是CPU来选择加的,人为控制不了。
那我们可以控制什么呢?人为的可以控制是否需要加lock指令
有的同学问:我不记得在什么地方加过Lock呀?
在使用Volatile时就会在汇编层面加上Lock,加上Lock就会触发缓存锁,就会解决了可见性的问题
CPU层面的指令重排序
当在Java中使用volatile时你一定知道,它除了解决可见性问题,它还可以解决指令重排的问题。来看个代码例子
private static int x=0,y=0;
private static int a=0,b=0;
public static void main(String[] args) throws InterruptedException {
int i=0;
for(;;){
i++;
x=0;y=0;
a=0;b=0;
Thread t1=new Thread(()->{
a=1;
x=b;
});
Thread t2=new Thread(()->{
b=1;
y=a;
});
t1.start();
t2.start();
t1.join();
t2.join();
String result="第"+i+"次("+x+","+y+")";
if(x==0&&y==0){
System.out.println(result);
break;
}
}
}
正常看来上面的可能出现的结果是
1和1
0和1
1和0
0和0
这四个结果中上面三个还好理解,唯独第四个为什么会出现0和0的情况
如果t1和t2里面的代码发生了指令重排是不是就会出现这种情况
t1: t1:
a = 1; x = b;
x = b; 发生指令重排 a = 1;
t2: ------------> t2:
b = 1; y = a;
y = a; b = 1;
CPU指令重排出现的原因是什么
这里又要了解下CPU优化的问题了。
上面提到了如果要保证缓存的一致性,在一个CPU修改数据的时候,会通知其它CPU让缓存行失效,然后才会将数据写入内存中。而在这个通过其它CPU缓存失效的过程CPU会阻塞住。这无疑大大的降低了CPU的资源的利用率。
为了解决这一个问题,CPU中引入了StoreBuffer。如下:
引入了Store Buffer后,也不需要再等待其它CPU的失效响应了。
当一个CPU-A修改了S状态的数据的时候,直接把它写入Store Buffer里面,同时发送一个失效缓存行的指令,并将其它CPU缓存行的数据读取到,写入自已的缓存中(这里是为了缓存一致)并将状态修改成E状态,CPU也不需要同步等待它响应(这里是一个异步),就直接执行下面的代码(这里就是导致指令重排的原因)。当CPU-A接收到失效的响应后。将修改后的数据在StoreBuffer里面写到缓存,再写到主存中。
这里将等待其它CPU失效响应由同步修改成了异步,也提升了CPU的利用率。
再看导致指令重排的步骤
先来看段代码
int a = 0,b = 0;
func(){
a = 1;
b = a + 1;
assert(b == 2) //false
}
上面这段代码为啥有出现b == 2为false的时候?
如果这个func里面的代码出现了指令重排
func(){
b = a + 1;
a = 1;
}
如果出现上面的情况,是不是就可以出现b == 2为false的状态(单线程不会,本文所说的都是在多线程环境下),来看下图
步骤一:此时 a = 0存在CPU-B里面,CPU-A需要将a = 0 修改成 a = 1,这时CPU-A将a=1写入Store Buffer里面,并同时通知CPU-B让该缓存行失效,并将CPU-B缓存行里面的数据返回。
步骤二:当通知了CPU-B让缓存行失效(此过程是异步的,有没有失效完成不知道,如果失效完成CPU-B会通知CPU-A),CPU-A读取到返回的数据写入自已的缓存里面(保证缓存一致),并且其状态修改成E(独占状态)
步骤三:程序会接着执行 b = a + 1,它会先将b从内存中读取出来,因为只有CPU-A使用到了b,所以它是一个独占状态。
步骤四:将b 修改成a(此时的a还是等于0,因为并没有在Store Buffer里写入到缓存里面)的值加1(因为其它CPU里面没有b,所以也没有让其它CPU缓存失效的步骤。),将其修改成M状态。
步骤五:此时CPU-A接收到了其它CPU缓存行失效完成的通知,将a修改后的值在Store Buffer里面写入到缓存,写入到主存。
通过上述步骤得知 b 有可能会在多线程环境存在不等于2的情况。
接下来CPU还会有优化,引入失效队列
Invaildate Queue
先来看下引入失效队列后的图
对比下面这些代码看
func1(){
a = 1;
b = 1;
}
func2(){
while(b == 1){
assert(a == 1) //false
}
}
引入失效队列后,他优化了通过其它CPU失效的时间,和上面那幅图相比,这张图它并没有去通知CPU让其缓存行失效,而是将它放到失效队列中。返回一个invaildate ack,在CPU-A接收到后就将Strore Buffer里面的值写入到了主存。
在开始a值存在CPU-A和CPU-B中所以它的状态是S状态,b只存在于CPU-A里面所以它是一个E状态
步骤一:CPU-A要修改a的值(使a = 0修改成 a = 1),先将a = 0写入到StoreBuffer中
步骤二:这个时候CPU-B需要b的值,它去读取这个值
步骤三:CPU-B接收到了CPU-A的失效通知,并将其存入到了失效队列里面
步骤四:CPU-A接收收到了CPU-B发来的invaildate ack,将 a = 1在某一时间从StoreBuffer中写入主存
步骤五:CPU-A需要修改b的值,因为此时b是一个独占状态,所以不需要让其它CPU失效缓存行,并在某一时间写入主存
步骤六:因为CPU-B也要使用b的使,所以在CPU-A中b的状态改变成了S状态(共享态)
步骤七:这里代码执行到了 a == 1的时候,但是现在或许CPU-B还没有让a的缓存行失效,所以这里得到的结果是false
这里来到了最后一步,CPU-B消费到了让a所在缓存行失效的通知,a的缓存行失效。如果下一次需要a,就在内存中加载
整理不易,如有错误请留下言。
感谢指出问题所在。