线程的安全性之可见性及指令重排

404 阅读9分钟

线程的安全性之可见性及指令重排

先来看一个关于可见性问题的案例

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),

基本就成了如下图:

image-20220315183630726

这里的L3Cahce是共享缓存。引入了高速缓存后就会出现当一个共享值(共享值指的是多个CPU使用同一个值),当CPU-0修改了缓存内的这个共享值,另外CPU-1感知不到这个值被修改了,这就是缓存不一致的问题。

如何解决缓存不一致

可以通过两种方式解决缓存不一致的问题(主要说的是缓存锁):

  • 总线锁
  • 缓存锁

缓存锁可以通过MESI协议来解决这一个问题。

MESI表示缓存的四种状态

  • M: 修改,当一个值被修改后当前值会变成M状态
  • E:独占,当一个值只存在一个CPU缓存里面时,它就是E状态,因为它只存在一个CPU里面,其它CPU里面没有,所以就是E状态
  • S:共享,在多个CPU里面有同一个值,该数据就是共享状态
  • I: 失效:当一个值变成失效状态后,CPU想要使用该值的时候只能从内存中读取

当一个CPU修改了一个为S状态的数据后,其它CPU里面的数据如何修改的呢?

image-20220312094428856

每个CPU里面都会有高速缓存,它们会标记缓存行的状态,这里面会有一个叫Snoopy的嗅探协议,可以通过这个Snoopy去监听总线上的事件,当处理器发起请求的时候,它会分配一个总线标识,其它的CPU接收到这个标识后进行相应处理,这个是使缓存失效的一个方式

比如当一个CPU-A修改本地缓存的数据的时候(i = 0),这个时它会发出一个失效的事件到总线上,CPU-B接收到这个事件后会将自已缓存中的数据置为失效状态,然后CPU-A将数据写入到主存,CPU-B发现自已的缓存是失效状态,便重新去主存加载数据,这个是解决缓存一致性问题的方式

总结:在修改数据的时候,会让处于S状态(共享状态)下其它CPU中的数据缓存失效,然后缓存失效的数据重新去主存中读取修改后的数据

image-20220312100108332

这里通过缓存锁或总线锁来避免多个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

image-20220315082520601

这四个结果中上面三个还好理解,唯独第四个为什么会出现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。如下:

image-20220315183938357

引入了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的状态(单线程不会,本文所说的都是在多线程环境下),来看下图

image-20220316074436470

步骤一:此时 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

先来看下引入失效队列后的图

image-20220316174710373

对比下面这些代码看

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,就在内存中加载

整理不易,如有错误请留下言。

感谢指出问题所在。