并发

【Java 并发笔记】并发机制底层实现整理

1. 缓存一致性问题硬件内存架构现代计算机一般都有2个以上CPU,而且每个CPU还有可能包含多个核心。因此,如果应用是多线程的话,这些线程可能会在各个CPU核心中并行运行。在CPU内部有一组CPU寄存器,也就是CPU的储存器。CPU操作寄存器的速度要比操作计算机主存快的多。在主存和CPU寄存器之间还存在一个CPU缓存,CPU操作CPU缓存的速度快于主存但慢于CPU寄存器。某些CPU可能有

1.  缓存一致性问题

硬件内存架构

  • 现代计算机一般都有 2 个以上 CPU,而且每个 CPU 还有可能包含多个核心。因此,如果应用是多线程的话,这些线程可能会在各个 CPU 核心中并行运行。

  • 在 CPU 内部有一组 CPU 寄存器,也就是 CPU 的储存器。

    • CPU 操作寄存器的速度要比操作计算机主存快的多。

    • 在主存和 CPU 寄存器之间还存在一个 CPU 缓存,CPU 操作 CPU 缓存的速度快于主存但慢于 CPU 寄存器。某些 CPU 可能有多个缓存层(一级缓存和二级缓存)。计算机的主存也称作 RAM,所有的 CPU 都能够访问主存,而且主存比上面提到的缓存和寄存器大很多。

    • 当一个 CPU 需要访问主存时,会先读取一部分主存数据到 CPU 缓存,进而在读取 CPU 缓存到寄存器。当 CPU 需要写数据到主存时,同样会先 flush 寄存器到
      CPU 缓存,然后再在某些节点把缓存数据 flush 到主存。

缓存架构

  • 缓存大大缩小了高速 CPU 与低速内存之间的差距。以三层缓存架构为例。

    • Core0 与 Core1 命中了内存中的同一个地址,那么各自的 L1 Cache 会缓存同一份数据的副本。

    • Core0 修改了数据,两份缓存中的数据不同了,Core1 L1 Cache 中的数据相当于失效了。

  • 除三级缓存外,各厂商实现的硬件架构中还存在多种多样的缓存,都存在类似的可见性问题。例如,寄存器就相当于 CPU 与 L1 Cache 之间的缓存。

1.2 MESI 协议

  • MESI(Modified Exclusive Shared Or Invalid,缓存的四种状态)协议的基本原理。

    • Core0 修改数据 v 后,发送一个信号,将 Core1 缓存的数据 v 标记为失效,并将修改值写回内存。

    • Core0 可能会多次修改数据 v,每次修改都只发送一个信号(发信号时会锁住缓存间的总线),Core1 缓存的数据 v 保持着失效标记。

    • Core1 使用数据 v 前,发现缓存中的数据 v 已经失效了,得知数据 v 已经被修改,于是重新从其他缓存或内存中加载数据 v。

  • MESI 协议可以解决 CPU 缓存层面的一致性问题。

MESI 协议缓存状态

状态说明
M(修改,Modified)本地处理器已经修改缓存行, 即是脏行, 它的内容与内存中的内容不一样. 并且此 cache 只有本地一个拷贝(专有)。
E(专有,Exclusive)缓存行内容和内存中的一样, 而且其它处理器都没有这行数据。
S(共享,Shared)缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝。
I(无效,Invalid)缓存行失效, 不能使用。

2. 优化重排序问题

  • 在执行程序时,为了提高性能,处理器和编译器会对指令做重排序。

    • 指令级并行的重排序。如果不存在数据依赖性,处理器 可以改变语句对应机器指令的执行顺序。

    • 编译器优化的重排序。编译器 在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

    • 内存系统的重排序。处理器使用 缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。(导致的可见性问题也可以通过 MESI 协议解决)

重排序

  • 重排序不是随意重排序,它需要满足以下两个条件。

数据依赖性

  • 如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。

as-if-serial

  • 所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身(单线程下的执行)的应有结果是一致的,编译器、runtime 和处理器都必须遵守 as-if-serial 语义。

2.1 指令级并行的重排序(处理器)

指令级并行的重排序

  • 只要不影响程序单线程、顺序执行的结果,就可以对两个指令重排序。

  • 乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化。

不优化时的执行过程优化时的执行过程
指令获取。指令获取。
如果输入的运算对象是可以获取的(比如已经存在于寄存器中),这条指令会被发送到合适的功能单元。如果一个或者更多的运算对象在当前的时钟周期中是不可获取的(通常需要从主内存获取),处理器会开始等待直到它们是可以获取的。指令被发送到一个指令序列(也称执行缓冲区或者保留站)中。
指令在合适的功能单元中被执行。指令将在序列中等待,直到它的数据运算对象是可以获取的。然后,指令被允许在先进入的、旧的指令之前离开序列缓冲区。(此处表现为乱序)
功能单元将运算结果写回寄存器。指令被分配给一个合适的功能单元并由之执行。

结果被放到一个序列中。

仅当所有在该指令之前的指令都将他们的结果写入寄存器后,这条指令的结果才会被写入寄存器中。(重整乱序结果)

2.2 编译器优化的重排序

  • 和处理器乱序执行的目的是一样的,与其等待阻塞指令(如等待缓存刷入)完成,不如先执行其他指令。与处理器乱序执行相比,编译器重排序能够完成更大范围、效果更好的乱序优化。

  • 编译器层面的重排序,自然可以由编译器控制。使用 volatile 做标记,就可以禁用编译器层面的重排序。

  • JVM 自己维护的 内存模型 中也有可见性问题,使用 volatile 做标记,取消 volatile 变量的缓存,就解决了 JVM 层面的可见性问题。

3. 内存模型

  • 可以把内存模型理解为在特定操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

  • 在并发编程需要处理的两个关键问题是:线程之间如何通信 和 线程之间如何同步。

通信

  • 通信是指线程之间以何种机制来交换信息。

  • 命令式编程中,线程之间的通信机制有两种,是 共享内存 和 消息传递。

    • 共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的 公共状态 来 隐式 进行通信。

    • 消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的 发送消息 来 显式 进行通信。

同步

  • 同步是指程序用于控制不同线程之间操作发生相对顺序的机制。

    • 共享内存的并发模型里,同步是 显式 进行的。程序员必须显式指定某个方法或某段代码需要在线程之间 互斥执行。

    • 消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是 隐式 进行的。

  • Java 的并发采用的是 共享内存模型,线程之间的通信对程序员完全透明。

3.1 顺序一致性内存模型

  • 顺序一致性内存模型有两大特性。

    • 一个线程中的所有操作必须按照程序的顺序来执行。

    • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

顺序一致性内存模型

  • 在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。

  • 在任意时间点最多只能有一个线程可以连接到内存。

  • 当多个线程并发执行时,开关装置能把所有线程的所有内存读/写操作串行化。

一致性模型执行效果

  • 假设这两个线程使用监视器锁来正确同步:A 线程的三个操作执行后释放监视器锁,随后 B 线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果。

未同步程序在一致性模型中执行效果

  • 未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。

  • 之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

    • 在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在还没有刷新到主内存之前,这个写操作仅对当前线程可见。

    • 从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。

    • 在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

  • 在顺序一致性模型中,所有操作完全按程序的顺序执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码 " 逸出 " 到临界区之外,那样会破坏监视器的语义)。JMM 会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。

  • JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。

  • JMM 中与在顺序一致性内存模型中的执行结果的异同。

JMM 中与在顺序一致性内存模型中的执行结果的异同

4. 抽象结构(JMM)

  • 不同架构的物理计算机可以有不一样的内存模型,Java 虚拟机也有自己的内存模型。

    • Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,简称 JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。

    • Java 内存模型提出目标在于,定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

  • Java 线程之间的通信由 Java 内存模型(JMM)控制,JMM 决定一个线程对共享变量(实例域、静态域和数组)的写入何时对其它线程可见。

    • 从抽象的角度来看,JMM 定义了线程和主内存 Main Memory(堆内存)之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有自己的本地内存(工作内存) Local Memory(只是一个抽象概念,物理上不存在),存储了该线程的共享变量副本。

    • 本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

    • JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的 Memory Barrier(内存屏障)来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。

Java 内存模型抽象

  • 线程 A 和线程 B 之间需要通信的话,必须经过两个步骤:

    • 线程 A 把本地内存(工作内存) A 中更新过的共享变量副本刷新到主内存中。

    • 线程 B 到主内存中读取线程 A 之前更新过的共享变量。

  • 两个步骤实质上是线程 A 再向线程 B 发送消息,而这个通信过程必须经过主内存。

    • JMM 通过控制主内存与每个线程的本地内存(工作内存)之间的交互,来为 Java 程序员提供内存可见性保证。

4.1 happens-before 关系(先行发生原则)

  • 一会是编译器重排序一会是处理器重排序,如果让程序员再去了解这些底层的实现以及具体规则,那么程序员的负担就太重了,严重影响了并发编程的效率。因此,JMM 为程序员在上层提供了 8 个规则,这样我们就可以根据规则去推论跨线程的内存可见性问题,而不用再去理解底层重排序的规则。

  • 从 jdk5 开始,Java 使用新的 JSR-133 内存模型,基于 happens-before 的概念来阐述操作之间的内存可见性。

  • 在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

  • JMM 的设计示意图。

JMM 的设计示意图

  • JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证(如果线程 A 的写操作 a 与 B 线程的读操作 b 之间存在 happens-before 关系,尽管 a 操作和 b 操作在不同的线程中执行,但 JMM 向程序员保证 a 操作将对 b 操作可见)。

  • 具体的定义为。

    • 这是 JMM 对编译器和处理器重排序的约束原则。JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before 关系本质上和 as-if-serial 语义是一回事。

    • 这是 JMM 对程序员的承诺。从程序员的角度来说,可以这样理解 happens-before 关系:如果 A happens-before B,那么 Java 内存模型将向程序员保证,A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。注意,这只是 Java 内存模型向程序员做出的保证。

    • 1)如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

    • 2)两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)。

  • happens-before 的具体规则如下。

规则说明
程序次序规则一个线程内,按照代码顺序,书写在前面的操作 happens-before 书写在后面的操作。
锁定规则一个 unLock 操作 happens-before 后面对同一个锁的 lock 操作。
volatile 变量规则对一个变量的写操作 happens-before 后面对这个变量的读操作。
传递规则如果操作 A happens-before 操作 B,而操作 B 又 happens-before 操作 C,则可以得出操作 A happens-before 操作 C。
线程启动规则Thread 对象的 start() 方法 ha

原创不易,完成人机校验,阅读全文

相关推荐

© 凯哥Java · KAIGE.DEV · 记录真实开发问题与工程实践