浅谈JMM和并发三大特性(volatile、MESI、内存屏障)

JMM是Java并发编程的基础模型,包含了逻辑架构和CPU物理架构的知识,JMM是并发专题的难点,加油。

内容大纲

  • 并行和并发
  • 并发三大特性
  • CPU硬件架构和缓存一致性
  • JMM
  • 可见性和volatile、lock前缀指令
  • 有序性和指令重排、内存屏障
  • 原子性

1.并发和并行

image.png 现代计算机模型中,CPU的最小调度单元就是线程,在OS中,县城管就是进程中的一条执行流程,同一个进程中多个线程可以共享代码片段、数据段、打开的文件等,但是每个线程都有专属自己的寄存器、栈结构。

1.1 并行

并行:在同一时刻,有多条指令在多个CPU核心上同时执行。 image.png

1.2 并发

并发:在同一时刻,同一个CPU核心上只能执行一条指令,多个线程被快速的调度切换来占用CPU核心的计算资源,在微观尺度上并不是同时执行,但在人类感触宏观表现是同时执行。并发的概念可以在单核架构存在,多核和多CPU同样也存在线程并发的概念。 image.png

2.并发三大特性

2.1 可见性

当一个线程修改了某个共享变量的值,其它线程应当能够立即看到修改后的值。Java使用共享内存模式来刷新内存方式来保证可见性。并发可见性问题的解决手段有:

        - volatile关键字(Java关键字)
        - 内存屏障
        - synchronized关键字保证(Java关键字)
        - CPU的lock前缀指令
        - final关键字保证(Java关键字)

2.2 有序性

程序执行代码指令的顺序应当保证按照程序指定的顺序执行,即便是编译优化,也应当保证程序源语一致。并发有序性问题的解决手段有:

        - volatile关键字
        - 内存屏障
        - synchronized关键字
        - lock前缀指令

2.3 原子性

一个或多个程序指令,要么全部正确执行完毕不能被打断,或者全部不执行。Java中,对基本数据类型的变量读取和赋值都是原子性操作(64位处理器),但是在32位处理器上对64位数据不能保证,例如Long/Double的计算。并发的原子性问题解决手段有:

        - synchronized关键字加锁
        - lock前缀指令
        - CAS无锁机制

3.CPU硬件架构和缓存一致性

image.png CPU计算因为速度很快,为了解决CPU读取内存指令和数据效率问题,诞生了CPU高速缓存的概念。现代常规CPU都有3级缓存架构,高速缓存有两个特性: 时间局部性:如果一个变量正在被访问,那么近期很有可能会再次被访问。 空间局部性:如果一个变量正在被访问,那么这个变量附近周围的变量可能会近期访问。

3.1 多核CPU缓存架构(简易视图)

image.png

3.2 总线事务

CPU执行的数据和指令都是通过系统总线和内存总线来和内存交互,这一系列步骤称之为总线事务,总线事务分为两类:

        - `读事务`:数据从内存到CPU。
        - `写事务`:数据从CPU刷回内存。

3.3 缓存一致性(Cache Coherence)

在多核心架构模式下,程序执行的指令和数据会跟随多个线程缓存到各个核心高速缓存中,那么同一个变量将会有多个副本,这些副本如果没有进行共享或者同步,那么最终的执行结果肯定是会存在线程安全性问题的,所以CPU提供了一些机制来保证这些缓存副本的一致性,例如总线锁定和缓存锁定这两种方式,其中每一种方式的实现原理都不一样,效率也不一样。数据在缓存中都是以**缓存行 Cache Line**的形式存在,一个缓存行可能同时存在多个变量(伪共享就是这原因)。 image.png 如图,期望结果肯定是15。但是由于线程1执行完毕以后并没有立即刷回主存,导致线程2在核心2上读取的仍是5,然后对5进行计算,导致最后结果不准确。

扫描二维码关注公众号,回复: 14351550 查看本文章

3.4 缓存一致性解决方案一:总线锁定(简单粗暴、效率低下)

总线锁定:处理器提供lock#信号,当其中一个核心在总线上输出lock#时,其它处理器的请求将会被阻塞,该处理器独占内存。总线锁这种做法锁定的范围太大了,导致CPU利用率急剧下降,因为使用LOCK#是把CPU和内存之间的通信锁住了,这使得锁定时期间,其它处理器不能操作其内存地址的数据 ,所以总线锁的开销比较大。

3.5 缓存一致性解决方案二:缓存锁定(现代CPU默认模式)

缓存锁定:通过缓存一致性协议来保证多核心的缓存副本的一致性问题,当其它核心更新了数据写回被核心1锁定的缓存行时,缓存将会失效。但是并不是所有情况缓存锁定都会有效,有2种情况例外:

        - 数据跨多个缓存行的情况,缓存锁定将会失败,转而降级为总线锁定。
        - 老的处理器不支持缓存锁定。

3.5.1 缓存锁定实现手段:总线窥探(总线嗅探 Bus Snooping)

总线窥探:CPU的写事务会被窥探器在总线上嗅探到,窥探器会检查该变量的副本是否在其他核心缓存上也有一份,如果有该副本,则窥探器会执行策略来保证副本的一致性,这个策略可以使刷新缓存或者让缓存失效,这取决于缓存一致性协议的实现。

3.5.1.1 总线窥探手段一:写失效

当CPU写入一个缓存副本时,其它缓存中的副本将置为失效,这种方式能够确保CPU只能读写一个数据的副本,其它核心的副本都是无效的,这种手段也是现代CPU最常见的手段之一,MSI、MESI、MOSI、MOESI、MESIF协议都属于这类。

3.5.1.2 总线窥探手段二:写更新

当CPU写入一个缓存副本时,其它缓存中的副本将会通过CPU内部总线进行更新,相当于数据更新的一次广播,这种手段会引起总线的流量增大,所以比较少见,Dragon、firefly协议属于这类。

3.6 常见的缓存一致性协议 Coherence protocol

常见的缓存一致性协议有:MSI、MESI、MOSI、MOESI、MERSI、MESIF等等,最常见的和通用的是MESI。

3.7 通用缓存一致性协议 - MESI

MESI协议:是一个基于写嗅探-写失效的缓存一致性协议,它一共是缓存行的4个状态的缩写: M 已修改Modified:表示与主存中的的值不一致,被修改过。 E 独占Exclusive:缓存行仅仅只在一个核心的高速缓存中独占,当另外核心线程读 取时状态变为共享。 S 共享Shared:缓存行在多个核心的高速缓存中都存在副本。 I 无效Invalid:缓存行无效。 MESI的大体流程就是:A线程首先读取数据到核心的高速缓存中,此时缓存行状态为独占。如果此时有B线程也读取了数据到另一个核心的高速缓存中,那么总线嗅探器就会将缓存行状态变为共享。如果此时A线程修改了值,那么嗅探器就将A的缓存行置为修改,B的缓存行将置为失效,B再进行运算时将会抛弃缓存行的副本,进而去主存中读取。

3.8 伪共享问题 False Sharing

伪共享:如果多个核的线程在操作同一份缓存行中的不同变量数据,就会发生频繁的缓存失效,CPU频繁去主存中取值,造成性能问题。 image.png 在Linux中,可以通过执行cat/proc/cpuinfo命令查看缓存行大小,默认是64字节,根据空间局部性原理,读取某个变量时,将其内存地址附近的变量一起读入缓存行中。 Java避免伪共享有2种方案:

3.8.1 方案一:缓存行填充

class Test {
    volatile long x;
    // 缓存行64字节填充
    long l1,l2,l3,l4,l5,l6,l7;
    volatile long y;
}

3.8.2 方案二:@Sum.misc.Contended 注解(JDK8) (-XX:RestrictContended)

注解可使用在类上,也可以使用在变量上。

3.9 总线风暴

当lock前缀指令十分频繁,或者volatile关键字使用的非常频繁,那么CPU的缓存一致性协议所带来的的嗅探机制会在总线上发生大量的总线事务,这些消息统称为总线一致性流量。如果大量使用volatile并且在多线程高并发环境下遇到CAS无锁自旋机制,那么CPU总线上的一致性流量将会激增,总线的带宽和速率是固定的,这一部分流量占满了总线,CPU的利用率自然下降,系统吞吐量自然下降,所以volatile或者lock前缀指令在高并发环境下尽量少用和CAS自旋操作这类的组合。

4.Java内存模型(JMM)

Java虚拟机中定义了Java内存模型Java memory model,简称JMM,JMM中规定了Java程序和计算机内存是如何协调工作的,规定了一个线程如何、何时看到由其它线程修改后的共享变量的值,以及在必须时如何同步的访问共享变量一系列问题。 image.png

4.1 JMM如何保证可见性问题?

4.1.1 方案0:让CPU高速缓存行失效

让CPU的高速缓存失效,CPU不得不去主存拿最新的值,CPU高速缓存同样有LRU的内存淘汰策略。或者发生了上下文切换,线程的副本信息会被保存到主存。 这个方案很难控制,不保证及时性,不推荐。

4.1.2 方案一:加锁

当共享变量或者某一段程序加了锁以后,线程要想访问它必须获得锁,如果获取不到只有阻塞等待,获得锁的线程执行完毕以后就会将其刷会主存中,所以加锁的方式是能够保证可见性的,甚至能够保证原子性,加锁大法好,真香。

4.1.3 方案二:volatile关键字

volatile关键字有3大特性:

           - 可见性:对于一个修饰了volatile的共享变量,任意线程都能看到它修改后的值。
           - 原子性:对于单个变量的读写是原子性的,但是对于i++、i--这样操作不具有原子性。
           - 有序性:修饰了volatile的变量,JMM通过内存屏障来实现指令重排的问题。

volatile的读写语义: volatile 写:当写一个volatile变量时,JMM会将本地内存值立即刷回主存。 volatile 读:当读一个volatile变量时,JMM会将本地副本失效,去主存读。

4.1.3.1 volatile保证可见性在JMM层面原理

volatile修饰的共享变量在执行写操作后,会立即刷回到主存,以供其它线程读取到最新的记录。

4.1.3.2 volatile保证可见性在CPU层面原理

volatile关键字底层通过lock前缀指令,进行缓存一致性的缓存锁定方案,通过总线嗅探和MESI协议来保证多核缓存的一致性问题,保证多个线程读取到最新内容。 lock前缀指令除了具有缓存锁定这样的原子操作,它还具有类似内存屏障的功能,能够保证指令重排的问题。

4.2 JMM如何保证有序性问题?

4.2.1 指令重排带来的有序性问题

Java语言规定JVM线程内部维持顺序话语义,只要程序结果不受影响,那么执行的指令是可以优化的,可以和编写的代码顺序不一致,这就是指令重排。指令重排可能发生在多个阶段,例如Java源代码编译阶段、内存系统重排序等。但是指令重排有一个原则: as-if-seiral:不管怎么重排序,单线程的程序执行结果不能够被改变,编译器、处理器等都得遵循这个规范和准则。

4.2.2 解决方案:volatile关键字(JMM内存屏障)

volatile修饰的变量,在读写操作的前后都会进行屏障的插入来保证执行执行的顺序,不被编译器等优化器所重排序。内存屏障的功能是:阻止屏障两边的指令重排、刷新处理器缓存。 这些屏障JVM提供了4种类型的内存屏障: 1.LoadLoad:在load2读取之前,保证load1读取的数据全部读取完毕。 2.LoadStore:在load2写回之前,保证load1读取的数据全部读取完毕。 3.StoreStore:在store2写入之前,保证store1的写入对其操作可见。 4.StoreLoad:在store2写入之前,保证store1的写入对其操作可见。这个StoreLoad是一个万能的,它包含了前三种屏障的功能。 由于X86架构处理器只有StoreLoad可能会重排序,所以JVM的其他三个屏障都是空操作。 其实JVM是屏蔽了不同处理器架构的差异,提供了统一化的内存屏障,在CPU硬件层面不同处理器架构有不同的内存屏障,例如X86架构的内存屏障有4种: ifence、sfence、mfence、lock前缀指令。

4.2.3 引申概念:Happens-Before

从JDK1.5开始,JMM使用happens-before概念来阐述多线程之间的内存可见性。如果一个操作的结果要对另一个线程操作可见,那么这两个操作之间存在happens-before原则。 happens-before原则规则举例:

              - 单线程的程序指令按照源代码编写顺序执行。
              - 一个锁操作,解锁必须发生在加锁之后。
              - 对于volatile变量的写操作发生在变量读之后。
              - 线程的start()方法执行必须发生在线程操作之前。
              - 对象的初始化一定早于对象的finalize()方法执行。

4.3 JMM如何保证原子性问题?

     - 内置锁
     - 显式锁
     - CAS
  

猜你喜欢

转载自juejin.im/post/7115665196957302820
今日推荐