1、计算机的硬件内存架构

在介绍 Java 内存模型之前,我们很有必要的了解的一个知识点就是计算机的硬件内存架构。‘

1.1、CPU 高速缓存

对计算机知识有最基础了解的同学都会知道,大多数计算机都是由四大要素组成,即 CPU、内存、I/O 设备和总线。而对于存储硬件来说速度快的成本高、容量小,速度慢的成本低、容量大。

Java 并发编程:一文了解 Java 内存模型(处理器优化、指令重排序与内存屏障的深层解析)-LMLPHP

其中 CPU 寄存器的速度和内存的速度差异可以非常大,具体倍数取决于多种因素,包括 CPU 的型号、内存的类型以及系统的整体架构等。但一般来说,CPU 寄存器访问速度远快于内存访问速度,这个速度差异可以达到几个数量级(几百倍甚至上千倍)。所以在传统计算机内存架构中会引入高速缓存来作为主存和处理器之间的缓冲,CPU 将常用的数据放在高速缓存中,运算结束后 CPU 再将运算结果同步到主存中。
处理器 ( C P U ) < − > 高速缓存 ( C P U C a c h e M e n o r y ) < − > 主内存 ( M a i n M e m o r y ) 处理器(CPU) <-> 高速缓存(CPU Cache Menory) <-> 主内存(Main Memory) 处理器(CPU)<>高速缓存(CPUCacheMenory)<>主内存(MainMemory)
高速缓存如今的实现是多级缓存的形式,不同级别的缓存具有不同的容量、速度和访问延迟。一般来说,缓存层次越接近 CPU,其速度越快,但容量越小;反之,层次越远离CPU,其速度越慢,但容量越大。

Java 并发编程:一文了解 Java 内存模型(处理器优化、指令重排序与内存屏障的深层解析)-LMLPHP

  • 一级缓存(L1 Cache):最接近 CPU 的缓存,通常分为数据缓存(D-Cache)和指令缓存(I-Cache)。L1 缓存的容量较小,但访问速度极快,几乎与 CPU 同频运作。每个 CPU 核心通常都有自己的L1缓存。
  • 二级缓存(L2 Cache):位于 L1 缓存和主内存之间,容量比 L1 缓存大,但速度稍慢。在早期的 CPU 设计中,L2 缓存可能独立于 CPU 核心存在,但现代 CPU 通常将 L2 缓存集成到核心内部。每个CPU核心可能有一个独立的L2缓存,或者多个核心共享一个L2缓存。
  • 三级缓存(L3 Cache):位于 L2 缓存和主内存之间,是 CPU 缓存中最大的一级。L3 缓存的容量远大于 L1 和 L2 缓存,但访问速度相对较慢。在多核 CPU 中,L3 缓存通常由所有核心共享,以减少核心间访问共享数据的延迟。

使用高速缓存解决了 CPU 和主存速率不匹配的问题,但同时又引入另外一个新问题:缓存一致性问题。

1.2、缓存一致性问题

缓存一致性问题,是在多处理器或多核处理器系统中面临的一个重要挑战。在多处理器系统中,每个处理器都可能拥有自己的高速缓存,用于加速对常用数据的访问。然而,它们是共享同一主内存(Main Memory),当多个处理器同时访问和修改同一数据块时,就可能出现数据不一致的情况。如果每个处理器的高速缓存中都存储了该数据块的副本,并且这些副本之间没有得到适当的同步,那么它们之间就可能产生差异。

因此需要每个 CPU 访问缓存时遵循一定的协议,这类定义了高速缓存行(Cache Line)的状态和状态之间的转换规则,以及处理器之间如何通过消息传递来协调对这些缓存行的访问和修改,高速缓存在读写数据时根据协议进行操作,共同来维护缓存的一致性。这类协议有 MSI、MESI、MOSI、和 Dragon Protocol 等
多个处理器 ( C P U ) < − > 多个高速缓存 ( C P U C a c h e M e n o r y ) < − > 缓存一致性协议 < − > 主内存 ( M a i n M e m o r y ) 多个处理器(CPU) <-> 多个高速缓存(CPU Cache Menory)<-> 缓存一致性协议<-> 主内存(Main Memory) 多个处理器(CPU)<>多个高速缓存(CPUCacheMenory)<>缓存一致性协议<>主内存(MainMemory)
以 MESI 协议为例,它定义了四种缓存状态:

  1. Modified(修改):缓存行中的数据已被本地处理器修改,并且与内存中的数据不同步,且其他处理器的缓存中不存在该缓存行的最新副本。此时,该缓存行是最新的;
  2. Exclusive(独占):缓存行中的数据没有被修改,且只有本地处理器拥有该数据的缓存副本。该缓存行与内存中的数据保持一致;
  3. Shared(共享):缓存行中的数据没有被修改,且可能被其他处理器缓存。此时,多个处理器的缓存中存在同一份数据的副本,并且这些副本与内存中的数据保持一致。
  4. Invalid(无效):缓存行中的数据是无效的,即该缓存行中的数据不再代表内存中的最新数据。处理器在访问无效缓存行时,需要从内存中重新加载数据。

缓存一致性协议通过定义状态之间的转换规则和处理器之间的消息传递机制来保持缓存一致性。当处理器对缓存行进行操作时(如读取、写入、无效化等),它会根据当前状态和操作类型来更新缓存行的状态,并向其他处理器发送相应的消息。

  • 当一个处理器想要读取一个共享状态的缓存行时,它可以直接从自己的缓存中读取数据,无需与内存或其他处理器交互;
  • 当一个处理器想要修改一个共享状态的缓存行时,它必须首先将缓存行的状态转换为修改状态,并向其他处理器发送 Invalidate 消息来使它们的缓存行无效化。只有在收到所有相关处理器的 Invalidate Acknowledge 消息后,该处理器才能开始修改数据;
  • 当一个处理器执行写回操作时(如缓存行被替换出缓存时),如果缓存行处于修改状态,它需要将修改后的数据写回内存,并可能向其他处理器发送相应的消息来更新它们的缓存状态。
1.3、处理器优化和指令重排序

除了在 CPU 和主内存之间增加高速缓存,还有什么办法可以进一步提升 CPU 的执行效率呢?答案是:处理器优化和指令重排序。

处理器优化使处理器内部的运算单元能够最大化被充分利用,处理器会对输入代码进行乱序执行处理。其工作原理:

  1. 指令分派(Instruction Dispatch): 处理器从指令队列中取出多条指令,并分派到多个执行单元。每个执行单元可以独立处理不同的指令;
  2. 指令窗口(Instruction Window): 处理器维护一个指令窗口,其中包含了即将执行的指令。指令可以在这个窗口中进行重新排序,以便尽可能地避免资源冲突;
  3. 数据依赖性分析(Data Dependency Analysis): 处理器分析指令之间的依赖关系。如果某条指令的执行依赖于另一条指令的结果,那么这条指令必须等到依赖的指令执行完毕后才能执行;
  4. 执行单元调度(Execution Unit Scheduling): 处理器根据指令的资源需求和依赖关系,动态地调度指令到可用的执行单元上。只要某条指令所需的资源和数据都准备好了,就可以立即执行,而不必等待前面的指令全部执行完毕;
  5. 结果重排序(Reorder Buffer): 处理器使用重排序缓冲区来保存指令的执行结果。虽然指令是乱序执行的,但它们的结果会按照程序代码的顺序提交给寄存器或存储器,以确保程序的最终结果正确。

2、Java 并发编程中存在的问题

上面讲了计算机的硬件内存架构相关的相关知识,可能会有一些同学开始好奇了,绕了这么一大圈,这些和 Java 内存模型有什么关系么?

当时是有关系的,Java 并发编程领域最常提到的三个问题:“可见性问题”、“原子性问题”、“有序性问题”,其实就是上面提到的 “缓存一致性”、“处理器优化” 和 “指令重排序” 造成的。

缓存一致性问题其实就是可见性问题,处理器优化可能会造成原子性问题,指令重排序会造成有序性问题。这便是其中的关联!

出了问题定然是需要解决的,那有什么办法呢?一个简单粗暴的办法就是,直接干掉缓存让 CPU 直接与主内存交互就解决了可见性问题,禁止处理器优化和指令重排序就解决了原子性和有序性问题,但这样相当于整个否定了现代计算机的硬件内存架构,显然不可取的。

所以技术前辈们想到了在物理机器上定义出一套内存模型, 规范内存的读写操作。内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。


3、Java 内存模型

Java 内存模型(JMM,Java Memory Model)用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的并发效果,JMM 规范了Java 虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

3.1、Java 内存划分

在 JMM 中规定了内存主要划分为主内存和工作内存两种。此处的主内存和工作内存跟 JVM 内存划分(堆、栈、方法区)是在不同的层次上进行的。如果非要对应起来,主内存对应的是 Java 堆中的对象实例部分,工作内存对应的是栈中的部分区域。从更底层的来说,主内存对应的是硬件的物理内存,工作内存对应的是寄存器和高速缓存。

Java 并发编程:一文了解 Java 内存模型(处理器优化、指令重排序与内存屏障的深层解析)-LMLPHP

同样的在 JMM 定义的访问规则中,所有变量都存储在主内存,线程均有自己的工作内存。工作内存中保存被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作空间进行,不能直接读写主内存数据。操作完成后,线程的工作内存通过缓存一致性协议将操作完的数据刷回主存。

3.2、Java 内存交互

线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。

但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因此 JMM 制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程。

JMM 中规定了 8 种线程、主内存和工作内存的交互关系,每种操作都有自己作用的的区域,具体操作如下:

  1. lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态;
  2. unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  3. read 读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用;
  4. load(载入):作用于工作内存的变量,它把 read 操作从主存中变量放入工作内存中;
  5. use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令;
  6. assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中;
  7. store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的 write 使用;
  8. write(写入):作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
3.3、Java 线程通信

JMM 中的 8 种操作规定了线程对主内存的操作过程,隐式的规定:线程之间要通信必须通过主内存,JMM 的线程通信如下图所示:

Java 并发编程:一文了解 Java 内存模型(处理器优化、指令重排序与内存屏障的深层解析)-LMLPHP

从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:

  1. 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去;
  2. 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。

要把一个变量从主内存中复制到工作内存,就需要按顺序地执行 readload 操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行 storewrite 操作。

Java 内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是 read load 之间,storewrite 之间是可以插入其他指令的。

Java 内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  1. 不允许 readloadstorewrite 操作之一单独出现;
  2. 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中;
  3. 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中;
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(loadassign)的变量。即就是对一个变量实施 usestore 操作之前,必须先执行过了assignload 操作;
  5. 一个变量在同一时刻只允许一条线程对其进行 lock 操作,lockunlock 必须成对出现;
  6. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 loadassign 操作初始化变量的值;
  7. 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量;
  8. 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 storewrite 操作)。

4、处理器重排序与内存屏障指令

Java 使用内存屏障来解决指令重排序带来的问题,从而保证程序在多线程环境下的正确性。

4.1、顺序性与可见性问题

除了处理器会对代码进行优化处理外,很多现代编程语言的编译器也会做类似的优化,比如像 Java 的即时编译器(JIT)也会做指令重排序。‘
源代码 > > 编译器优化冲排序 > > 指令级并行的重排序 > > 内存系统的重排序 > > 最终执行指令序列 源代码>>编译器优化冲排序>>指令级并行的重排序>>内存系统的重排序>>最终执行指令序列 源代码>>编译器优化冲排序>>指令级并行的重排序>>内存系统的重排序>>最终执行指令序列

从 Java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

  1. 编译器优化冲排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  2. 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
  3. 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

也就是说,即使指令的执行没有重排序,是按顺序执行的,但由于缓存的存在,仍然会出现数据的非一致性的情况。我们把这种 普通读普通写 可以理解为是有延迟的 延迟读延迟写 , 因此即使读在前、写在后,因为有延迟,仍然会出现写在前、读在后的情况。

为了解决上述重排带来的问题,提出了 as-if-serial 原则,即不管怎么重排序,程序执行的结果在单线程里保持不变。

4.2、As-if-serial 原则

重排序也不能毫无规则,否则语义就变得不可读, as-if-serial 原则给重排序戴上紧箍咒,起到约束作用。

as-if-serial 原则规定重排序要满足以下两个规则:

  • 在单线程环境下不能改变程序执行的结果;
  • 存在数据依赖关系代码(指令)片段的不允许重排序。

as-if-serial 原则下重排序既没有改变单线程下程序运行的结果,又没有对存在依赖关系的指令进行重排序。

4.3、Java 内存屏障的使用

为了遵守 as-if-serial 原则,我们需要一种特殊的指令来阻止特定的重排,使其保持结果一致,这种指令就是内存屏障 (内存屏障是一个 CPU 的指令,它可以保证特定操作的执行顺序)。

内存屏障有两个效果:

  1. 阻止指令重排序:在插入内存屏障指令后,不管前面与后面任何指令,都不能与内存屏障指令进行重排,保证前后的指令按顺序执行,即保证了顺序性;
  2. 全局可见:插入的内存屏障,保证了其对内存操作的读写结果会立即写入内存,并对其他 CPU 核可见,即保证了可见性 ,解决了普通读写的延迟问题。例如,插入读屏障后,能够删除缓存,后续的读能够立刻读到内存中最新数据(至少当时看起来是最新)。插入写屏障后,能够立刻将缓存中的数据刷新入内存中,使其对其他 CPU 核可见。

因此,在 CPU 的物理世界里,内存屏障通常有三种:

  1. lfence: 读屏障(load fence),即立刻让 CPU Cache 失效,从内存中读取数据,并装载入 Cache 中;
  2. sfence: 写屏障(write fence), 即立刻进行 flush,把缓存中的数据刷入内存中;
  3. mfence: 全屏障 (memory fence),即读写屏障,保证读写都串行化,确保数据都写入内存并清除缓存。

由于物理世界中的 CPU 屏障指令和效果各不一样,为了实现跨平台的效果,针对读操作 load 和写操作 store,Java 在 JMM 内存模型里提出了针对这两个操作的四种组合来覆盖读写的所有情况,即:读读 LoadLoad、读写 LoadStore、写写 StoreStore、写读 StoreLoad

  1. LoadLoad 屏障:对于这样的语句 Load1; LoadLoad; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕;
  2. StoreStore 屏障:对于这样的语句 Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,保证 Store1的写入操作对其它处理器可见;
  3. LoadStore 屏障:对于这样的语句 Load1; LoadStore; Store2,在 Store2 及后续写入操作被刷出前,保证 Load1 要读取的数据被读取完毕;
  4. StoreLoad 屏障:对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
4.4、Java 内存屏障的实现

Java 内存模型(Java Memory Model, JMM)在编译器和处理器层面上使用内存屏障来实现其内存可见性和指令重排序的规则。以下是 Java 中的一些具体实现:

volatile 关键字:

  • volatile 变量:会插入一个 LoadLoad Barrier 和一个 LoadStore Barrier
  • volatile 变量:会插入一个 StoreStore Barrier 和一个 StoreLoad Barrier

synchronized 关键字:

  • 进入同步块:会插入一个 LoadLoad Barrier 和一个 StoreLoad Barrier
  • 退出同步块:会插入一个 StoreStore Barrier 和一个 LoadStore Barrier

Ps:虽然二者都是用了内存屏障,但 synchronizedvolatile 更重,是因为 synchronized 不仅需要插入内存屏障,还需要管理锁的获取和释放,以及保证同步块内操作的有序性和排他性。volatile 仅用于确保单个变量的可见性和有序性,开销相对较低。因此,选择 synchronized 还是 volatile 需要根据具体的并发控制需求来决定。


5、Java 内存模型的相关概念

5.1、happens-before 规则

Happens-Before 规则是 Java 内存模型的一部分,用于定义多线程环境下操作的可见性和有序性规则。从 JDK5 开始,Java 使用新的 JSR-133 内存模型。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。换句话说,操作1 happens-before 操作2,那么 操作1 的结果是对 操作2 可见的。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。如果不满足这个要求那就不允许这两个操作进行重排序。

这些规则确保了某些操作(如变量的读/写)在并发执行时可以预测和正确地工作。

happens-before 规则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作;
  2. 监视器锁规则:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁;
  3. volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读;
  4. 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
  5. 线程启动(start) 规则:如果 线程A 执行操作 ThreadB.start()(启动线程B),那么 线程A 的 ThreadB.start() 操作 happens-before 于 线程B 中的任意操作;
  6. 线程终结(join)规则:如果 线程A 执行操作 ThreadB.join() 并成功返回,那么 线程B 中的任意操作 happens-before 于 线程A 从 ThreadB.join() 操作成功返回;

Ps:JSR-133 规则中只有以上 6 条,但是网上目前流传最多的则是 8 条的版本,即包括下面 2 条:

  1. 线程中断操作:对线程 interrupt() 方法的调用,happens-before 于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到线程是否有中断发生。
  2. 对象终结规则:一个对象的初始化完成,happens-before 于这个对象的 finalize() 方法的开始。

Ps:JDK(Java Development Kit)已经在其实现中完成了这些规则的支持。具体来说:JVM 确保在运行时遵循这些规则;Java 类库(如 java.util.concurrent 包中的类)实现了各种并发工具和机制,这些工具和机制内部已经遵循了 Happens-Before 规则。

5.2、Java 内存模型三大特征

在 Java 中提供了一系列和并发处理相关的关键字,比如 volatilesynchronizedfinalconcurrent 包等解决原子性、有序性和可见性三大问题。

Ps:其实这些就是 Java 内存模型封装了底层的实现后提供给程序员使用的一些关键字。在开发多线程的代码的时候,我们可以直接使用 synchronized 等关键字来控制并发,从而就不需要关心底层的编译器优化、缓存一致性等问题。

5.2.1、原子性

线程切换带来的原子性问题:我们把一个或者多个操作在 CPU 执行的过程中不能被中断的特性称之为原子性,这里说的是 CPU 指令级别的原子性。

Java 并发编程:一文了解 Java 内存模型(处理器优化、指令重排序与内存屏障的深层解析)-LMLPHP

在 Java 中,为了保证原子性,还提供了两个高级的字节码指令 monitorentermonitorexit。这两个字节码,在 Java 中对应的关键字就是 synchronized。因此,在 Java 中可以使用 synchronized 来保证方法和代码块内的操作是原子性的。

5.2.2、可见性

缓存导致的可见性问题:一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称之为可见性。

Java 并发编程:一文了解 Java 内存模型(处理器优化、指令重排序与内存屏障的深层解析)-LMLPHP

JMM 是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的 volatile 关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用 volatile 来保证多线程操作时变量的可见性。

除了 volatile,Java 中的 synchronizedfinal 两个关键字也可以实现可见性。

5.2.3、有序性

编译优化带来的有序性问题:有序性指的是程序要按照代码的先后顺序执行,编译器为了优化性能,有时候会改变程序中语句的先后顺序。

在 Java 中,可以使用 synchronizedvolatile 来保证多线程之间操作的有序性。实现方式有所区别:

volatile 关键字会禁止指令重排。synchronized 关键字保证同一时刻只允许一条线程操作。

好了,这里简单的介绍完了 Java 并发编程中解决原子性、可见性以及有序性可以使用的关键字。同学们可能也发现了,好像 synchronized 关键字是万能的,它可以同时满足以上三种特性,这其实也是很多人滥用 synchronized 的原因。但是 synchronized 是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。

07-27 00:21