前言

本文将以一个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发问;
  • 总线嗅探是通过应用总线上的信号来实现的。各个处理器能够同时嗅探总线上的信号,这一景象在操作过程中是可见的。

MESI过程演示网址

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架构下提供的四种内存屏障指令:


在Hotspot虚拟机源码中能够印证:

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