4.Java内存模型

98 阅读9分钟

一、并发编程模型

并发编程中,我们需要处理两个核心问题:线程间如何通信及线程间如何同步,以及三条性质:原子性有序性可见性

线程通信:指线程间以何种机制来交换信息。线程间通信机制有两种共享内存和消息传递

  • 共享内存并发模型:线程间共享程序的公共状态,线程间通过读-写内存中的公共状态来隐式进行通信
  • 消息传递并发模型:线程间没有公共状态,线程间必须通过明确的发送消息来显示进行通信。

线程同步:指程序用于控制不同线程间操作发生相对顺序的机制,可以理解为协同步调,按预定的先后次序运行。

二、Java内存模型抽象

  1. 局部变量(变量副本):存储在线程的本地内存中的变量,通常是线程拷贝主内存中的共享变量至自己的本地内存中。
  2. 共享变量:在java中所有实例域、静态域和数组元素存储在堆内存中,堆内存中的对象由所有线程共享,这些对象我们称之为共享变量。
  3. 主内存:JMM规定所有变量都存储在主内存中,主内存中的共享变量由所有线程共享
  4. 本地内存:本地内存是JMM抽象的概念,每个线程都有自己的本地内存,各线程间本地内存相互隔离,本地内存中存储了该线程读、写共享变量的拷贝副本

image.png 如图,线程A、B如果需要通信的话,必须要经历下面两个步骤:

  • 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中
  • 然后,线程B到主内存中读取线程A之前更新过的共享变量

整体上看,这两个步骤实质上是线程A向线程B发送消息,而且这个通信过程必须要经过主内存,JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序原提供内存可见性保证。

三、重排序

目的:不改变程序执行结果的前提下,尽可能提高程序并行度。

概念:在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序,编译器和处理器重排序时会遵守数据依赖性原则,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序。重排序分为三类:

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

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

image.png 上面这些重排序都可能导致多线程程序出现内存可见性问题

  • 对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)
  • 对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型处理器重排序

3.1 处理器重排序

现代处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,单每个处理器上的写缓冲区,仅仅对它所在的处理器可见,这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读写操作的执行顺序,不一定与内存实际发生的读写操作顺序一致!

举例:

image.png 假设处理器A和处理器B按程序的顺序并执行内存访问,最终却可能得到x=y=0。具体原因如下图所示:

image.png 处理器 A 和 B 同时把共享变量写入在写缓冲区中(A1、B1),然后再从内存中读取另一个共享变量(A2、B2),最后才把自己写缓冲区中保存的脏数据刷新到内存中(A3、B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。

从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1 -> A2,但内存操作实际发生的顺序却是:A2 -> A1。此时,处理器 A 的内存操作顺序被重排序了。

这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作重排序。

3.2 内存屏障指令

为了保证内存可见性,Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:

屏障类型指令示例说明
LoadLoad BarriersLoad1; LoadLoad; Load2确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。
StoreStore BarriersStore1; StoreStore; Store2确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。
LoadStore BarriersLoad1; LoadStore; Store2确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。
StoreLoad BarriersStore1; StoreLoad; Load2确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。StoreLoadBarriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令

StoreLoad Barriers 是一个“ 全能型” 的 屏障, 它同时具有其他 3 个屏障的效果。 现代的多处理器大多支持该屏障( 其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵, 因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中( Buffer Fully Flush)。

3.3 HAPPENS-BEFORE

概念:

如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见。这里提到的两个操作既可以是在同一个线程内,也可以在不同线程间。JMM在对编译器和处理器约束时,遵循的规则是:在不改变程序执行结果的前提下,编译器和处理器怎么优化都行。也就是说两个操作间存在happens-before规则Java平台并不一定按照规则定义的顺序执行。

目的: happens-before这么做的目的是为了在不改变程序执行结果的前提下,尽可能的提高程序的并行度,也避免了java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则及这些规则的具体实现。

happens-before规则:

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
  • 监视器锁规则:对一个监视器的解锁,happens-before于随后对这个监视器的加锁
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C

注意:两个操作间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行。happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

3.4 数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作间就存在数据依赖性。数据依赖性分下列三种类型:

image.png 上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的执行顺序。

注意这里所说的数据依赖性针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器间和不同线程间的数据依赖性不被编译器和处理器考虑。

3.5 as-if-serial

概念:不管怎么重排序(编译器和处理器为了提高并行度),程序的执行结果不能被改变,编译器和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

四、总结

这里我们介绍了JMM内存模型。简单回顾下, JMM内存模型数据可能存在脏读的情况,这就是数据可见性问题。出现可见性问题原因可能是线程缓存导致的,也可能是重排序导致的。为了解决重排序的问题JMM引入了happens-before和as-if-serial约束编译器和处理器,保证了不管怎么重排序,程序执行结果的一致性。总的来说,多线程开发时需要从原子性、有序性、可见性三个方面考虑。

参考: