Java并发之:可见性问题引发的思考

前言

本文将以一个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设备之间的速度差异。

Java并发之:可见性问题引发的思考_第1张图片

引入高速缓存是为了提升性能和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提问;
  • 总线嗅探是通过使用总线上的信号来实现的。各个处理器可以同时嗅探总线上的信号,这一现象在操作过程中是可见的。

MESI过程演示网址
Java并发之:可见性问题引发的思考_第2张图片

5.MESI中存在的问题
在cpu修改缓存行数据时,会去通知其它缓存行修改状态为Invalid。其它缓存行收到通知并修改缓存行状态后,给该cpu一个ack响应。
在此期间该cpu需要等待ack响应,等待的这段时间虽然很短,但却是阻塞状态的,这浪费了cpu资源,降低了cpu利用率,因此又引入了写缓存Store Buffer和无效化队列Invalidate Queue。
Java并发之:可见性问题引发的思考_第3张图片

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的异步化操作。

清晰大图
Java并发之:可见性问题引发的思考_第4张图片

如上图所示,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架构下提供的四种内存屏障指令:

Java并发之:可见性问题引发的思考_第5张图片
在Hotspot虚拟机源码中可以印证:
Java并发之:可见性问题引发的思考_第6张图片

2.6 lock汇编指令

上面提及的内存屏障指令,在使用时会用到lock这个汇编指令,让编译器和cpu的优化失效,并且这里我们也看见了volatile的身影。在Hotspot源码中可以印证:
Java并发之:可见性问题引发的思考_第7张图片
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重排序规则参见下图:
Java并发之:可见性问题引发的思考_第8张图片
下面用一段示例代码来加深理解:

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()会触发操作系统的重新调度,让线程原本的缓存行失效。

你可能感兴趣的:(Java并发之:可见性问题引发的思考)