关于java:java并发之可见性问题引发的思考

6次阅读

共计 7223 个字符,预计需要花费 19 分钟才能阅读完成。

前言

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

正文完
 0