并发编程 02 - 可见性问题分析

并发编程 02 - 可见性问题分析,第1张

为什么会产生线程并发的三大问题 CPU、内存、io 三者存在速度差异

CPU 执行一条普通指令需要一天,CPU 读写内存得等待一年的时间;

内存是天上一天,I/O 设备是地上十年;

计算机硬件生态↓

如何平衡速度差异,高效利用CPU性能
  1. 等待时是阻塞状态,出现资源利用的问题,不能让CPU处于闲置状态。所以CPU 增加了缓存,以均衡与内存的速度差异; (可见性问题)
  2. *** 作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;(原子性问题)
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。但JVM深度优化会造成活性失效,是导致线程安全问题的源头。(有序性问题)
有序性

在 Java 领域一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:

在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例。

为什么要这样 *** 作?

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

因为 高级语言 下,创建对象的方式,实际上也是由多条CPU指令实现的;要避免多线程创建对象时的线程切换导致创建了多个对象,不满足单例。所以采用了synchronized加锁的方式,一次只有一个线程能访问到创建的语句,切换线程后,线程B会先重新判断该对象是否被创建,而不是直接创建单例。

这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new *** 作上,我们以为的 new *** 作应该是:

  • 分配一块内存 M;
  • 在内存 M 上初始化 Singleton 对象;
  • 然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

  • 分配一块内存 M;
  • 将 M 的地址赋值给 instance 变量;
  • 最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

疑问? 为什么synchronized加锁了,还会产生切换呢?实际上的切换是,时间片的切换,A此时并没有释放锁,B也不是获取到锁。B只是去判断instance是否为null,还没有去抢占到锁。但此时,B已经判断出对象被创建了,那它就拿着对象去直接用了,结果发现因为指令的重排序,instance并未被初始化。

Volatile 关键字:源码在Native;

缓存导致的可见性问题 什么是可见性问题

一个线程对共享变量的修改,另外一个线程能够立刻看到,称为可见性。

类似于数据库的脏读;

多核时代,每个CPU都有自己的缓存,CPU缓存与内存的数据一致性没有解决;

假设A B对V修改,两线程同时执行,各自CPU缓存中都有V的值,基于CPU缓存中V的值进行计算,导致值不正确,这就是缓存的可见性问题;(现实不会是同时启动的,又引出另一个问题的讨论,原子性问题。)

为什么属性修改会在线程彼此之间不可见?

这个案例比较简单,就是t1线程中用到了stop这个属性,接在在main线程中修改了 stop 这个属性的值来使得t1线程结束,但是t1线程并没有按照期望的结果执行。

public class VolatileDemo {
    public static boolean stop=false;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            int i=0;
            while(!stop){
                i++;
            }
        });
        t1.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop=true;
    }
}

首先,引入深度编译的概念,JVM中有即时编译器JIT,它可以做深度优化。通过深度优化,会产生

while(!stop) = while(true) ;

如何解决这种问题?

  1. 可以增加 volatile 这个关键字来解决;
  2. idea禁止深度优化

Name : VolatileExample;

VM Options:-Djava.compiler = NONE

通过这样使JVM禁止JIT即时编译器深度优化;

名词:活性失效 → JVM深度优化结果。

  1. sleep(0):使CPU发生切换,值会重新加载;
  2. syso(””); 因为它是IO *** 作,会触发底层Synchronized *** 作;
CPU缓存模型

CPU在做计算时,和内存的IO *** 作是无法避免的,而这个IO过程相对于CPU的计算速度来说是非常耗
时,基于这样一个问题,所以在CPU层面设计了高速缓存。

这个缓存行可以缓存存储在内存中的数据,CPU每次会先从缓存行中读取需要运算的数据,如果缓存行中不存在该数据,才会从内存中加载,通过这样一个机制可以减少CPU和内存的交互开销从而提升CPU的利用率。

对于主流的x86平台,CPU的高速缓存,是三级缓存,每级大小不一样。

L3是共享缓存,L1 I 指令缓存,L1 D数据缓存。

离CPU越近的缓存越快,L1最快,指令计算会放在其中。

L1 → L2 → L3 → 内存,L1无则查L2,依次读取。

CPU1 什么时候读最新的值是不确定的,因为会进行优化,所以产生了缓存一致性问题。

缓存一致性问题

在多线程环境中,当多个线程并行执行加载同一块内存数据时,由于每个CPU都有自己独立的L1、L2缓存,所以每个CPU的这部分缓存空间都会缓存到相同的数据,并且每个CPU执行相关指令时,彼此之间不可见,就会导致缓存的一致性问题,据图流程如下图所示:

L1 L2 里的数据 = 我不是实时可见的。

Bus总线:CPU 和内存(以及其他芯片组等等外界所有的通信)做数据交互的时候,会通过总线做处理。

那么如何解决缓存一致性问题?

首先,加锁是根本的解决方法,不加锁是无法解决互斥问题的;

🔐怎么加?

  • 总线锁:在总线上加锁,当Processor2访问内存,只有其能访问内存,其他不可以。但是这本质就是单核串行了。(ps: 4核8线程,超线程技术。)
  • 缓存锁 :基于缓存一致性协议保证数据一致性,只针对缓存行加锁,锁定粒度的区别,降低锁的范围,从而提升性能。
    • 缓存一致性协议(不同CPU架构有不同的实现),通过这个协议来保证数据一致性;它怎么用?

      为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来 *** 作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。

      MESI表示缓存行的四种状态,分别是

      1. M(Modify[ˈmɒdɪfaɪ]) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的
        数据和主内存中的数据不一致\
      2. E(Exclusive[ɪkˈskluːsɪv]) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
      3. S(Shared[ʃerd]) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
      4. I(Invalid[ˈɪnvəlɪd]) 表示缓存已经失效

      在CPU的缓存行中,每一个Cache一定会处于以下三种状态之一

      • Shared
      • Exclusive
      • Invalid
        对于读请求,MES都能被读取,I的情况,缓存失效,只能从内存中读取。

写,S状态,需要先把其他CPU缓存设为失效才能做写 *** 作;

那么一致性如何通过协议实现的?

每个CPU都会有高速缓存,在其中标记缓存行的状态,通过

Snoopy 嗅探协议:每个CPU都会监听总线事件,当处理器发起请求会分配一个总线标记,其他CPU收到标记就会进行相应处理。这样的 *** 作使得缓存失效。

比如CPU2写V数据,先发信号,invalid到总线,其他CPU通过读取到总线上的这个标记,让这个数据失效。

重点就是,我要修改一个数据,得先让其他CPU核心上的该数据的缓存失效,失效之后我才能做同步。这样才能保证缓存一致性。

那缓存锁和总线锁怎么加:汇编指令#Lock,很根据CPU类型来选择加缓存🔐还是总线🔐,默认缓存🔐,不支持的话加总线🔐,总线锁是所有CPU都支持的。

反正就知道,一定会有人帮你在可能出缓存一致性问题的地方,加Lock指令,帮你解决可见性问题。这是由CPU去做的。

我们就只需要负责加 volatile boolean stop 意思就是,最终生成的汇编层面的指令中加Lock指令。

CPU层面的指令重排序

什么情况会出现(0,0),顺序重排序了;

什么是指令重排序?CPU层面、JVM层面优化指令执行顺序;

CPU层面如何导致指令重排序的?

CPU 0 在通过MESI协议保证缓存一致性的过程中,是阻塞状态。所以它引入了StoreBuffer解决 — 使用了异步回调的思想。

当一个CPU进行写入 *** 作的时候,首先会发送一个让其他缓存行失效的消息,把这个缓存行写入到StoreBuffer.

先写入,再发送消息让其他CPU失效,可以认为就是一个MQ,异步队列;它继续做其他指令的执行,当收到回复时,再执行 *** 作;

再这个过程中是可以一定程度上优化CPU的效率的,但它是造成指令重排序的源头会引发问题。

在什么情况下发生指令重排序?

重排序目的:不需要等待,可以继续往下执行。

本质:CPU0先写入SB,并且让CPU1的缓存失效,才能再写到Cache中。但是在这个过程中,CPU不阻塞,让CPU继续执行抢时间提高效率,所以会造成指令重排序的问题。

但是CPU又提出了一个优化方案 — StoreForwarding — CPU直接从SB里面加载数据,是不是不存在这个问题?

这种情况下会导致另外的问题↓
如果两个指令间没有依赖关系,那么是允许重排序的。所以会出现b==1 truea==1 false.

单线程的重排序不影响执行结果,这些都是针对多线程的。

题外话1:缓存行 - 伪共享 - 对齐填充

CPU的缓存是由多个缓存行组成的,每次读取数据的时候,会加载一段数据,缓存行是CPU和内存交互的最小工作单元。在X86的架构,每个缓存行的大小是64个字节;(和原子性的对其填充那部分相关。)

cpu一次会读取64个字节,以块的单位读取;为什么一定64,有一个空间局部性原理,接下来会用到的数据,所以一次加载避免多次交互。(和数据库的设计buffer pool的设计是一样的。)

会涉及到伪共享的问题。(缓存行失效)是什么?

缓存行64个字节,long 8个字节;一个缓存行可以缓存8个long;当加载一个,其他7个也会加载进去。CPU 0 只用到X,CPU 1只用到Y;存在缓存行竞争。如果CPU0 获得执行权限,缓存系统会使CPU1更新失效。来来回回的 *** 作会影响到性能。(缓存行没有到达64位会影响性能:多个CPU核心会同时读到同一段缓存,CPU本身缓存一致性的处理,会导致缓存互斥,使得缓存本身提高性能的目的这个场景下就达不到了。)

所以出现了对齐填充。读取X的时候,填满64个字节。— 空间换时间的概念。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YOuMftLE-1652220953824)(https://s3-us-west-2.amazonaws.com/secure.notion-static.com/232952d3-fae7-41e4-aad0-00cfb3928e98/Untitled.png)]

在native中会出现对其填充;

@Contended 是Java8提供的,对类提供的对其填充的功能,保证类实例占满64字节。要在VM options加一个参数-XX:-RestrictContended

以上案例就是,为什么要对齐填充,解决伪共享的问题;

对象实例化的时候,它自己本身会做对齐填充。

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/920446.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-16
下一篇 2022-05-16

发表评论

登录后才能评论

评论列表(0条)

保存