阅读 152

JMM Java内存模型的概念以及happens-before原则

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

详细介绍了JMM Java内存模型的概念、由来,以及happens-before原则的具体规则。

Java内存模型(Java Memory Model,JMM)是java虚拟机规范定义的一组规范以及机制,本身是一种抽象的概念,并不真实存在。JMM的目标是通过控制主内存与每个线程的本地内存(工作内存)之间的交互,来为 Java 程序员提供内存可见性保证,以求多个线程能够正确的访问共享变量。Java是使用具体、改良的更好理解的happens-before原则来实现这一目标。

1 并发编程的两个问题

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

1.1 通信

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

  1. 命令式编程中,线程之间的通信机制有两种,是 共享内存 和 消息传递。
  2. 共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的 公共状态 来隐式进行通信。典型的共享内存通信方式就是通过共享对象进行通信。

消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的 发送消息 来显式进行通信。在java中典型的消息传递方式就是wait()和notify()。

1.2 同步

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

  1. 共享内存的并发模型里,同步是 显式 进行的。程序员必须显式指定某个方法或某段代码需要在线程之间 互斥执行。
  2. 消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是 隐式 进行的。

Java 的并发采用的是 共享内存模型,线程之间的通信对程序员完全透明。同步的底层使用的是临界区对象,是指当使用某个线程访问共享资源时,必须使代码段独享该资源,不允许其他线程访问该资源。

2 Java内存模型

2.1 内存模型的抽象

“内存模型”一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象,它定义了共享内存系统中多线程程序读写操作行为的规则,通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。 它与处理器有关、与缓存有关、与并发有关、与编译器也有关。它解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下共享内存数据的正确性(一致性、原子性和有序性)。

不同架构的物理计算机可以有不一样的内存模型,Java虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为Java内存模型,并且它的内存访问操作与硬件的缓存访问操作具有很高的可比性。

Java内存模型(Java Memory Model,JMM)是java虚拟机规范定义的一组规范以及机制 ,本身是一种抽象的概念,并不真实存在。它屏蔽掉了各种硬件和操作系统的内存访问差异,让Java程序在各种平台下都能达到内存访问的一致性(可见性、有序性、原子性),不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。

原始的Java内存模型效率并不是很理想,因此Java1.5版本对其进行了重构,现在的Java8仍沿用了Java1.5的版本。

2.2 主内存与工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节,比如一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数。

JMM定义了线程和主内存之间的抽象关系:所有的共享变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时的主内存名字一样,两者也可以互相类比,但此处仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比,但只是一个抽象概念,物理上不存在),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝(对于引用类型,可能拷贝对象的引用或者某个字段,但不会把这个对象整个拷贝一次),线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(对于volatile变量依然有工作内存的拷贝,但是由于它特殊的操作顺序的规定,所以看起来如同直接在内存中读写一般)。

不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。JMM通过控制主内存与每个线程的本地内存(工作内存)之间的交互,来为 Java 程序员提供内存可见性保证。

线程、主内存、工作内存三者的交互关系如图:

在这里插入图片描述

这里所讲的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(甚至是硬件系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

2.3 内存间的交互操作

物理机高速缓存和主内存之间的交互有协议,同样的,Java也有关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步回主内存之类的实现细节,java内存模型中定义了8种操作来完成交互,虚拟机实现时必须保证这8种操作都是原子的、不可分割的(对于long和double类型的变量来说,load、store、read跟write在某些32位虚拟机平台上允许例外)。

  1. lock(锁定):作用于主存的变量,把一个变量标识为一条线程独占状态,一个变量在同一时间只能一个线程锁定。
  2. unlock(解锁):作用于主存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  3. read(读取):作用于主存变量,把一个变量的值从主存传输到工作内存,以便随后的load操作使用。
  4. load(载入):作用于工作内存变量,把 read 操作从主内存中读取的变量的值放入工作内存的变量副本中(副本是相对于主内存的变量而言的)。
  5. use(使用):作用于工作内存变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作。
  6. assign(赋值):作用于工作内存变量,它把一个从执行引擎接收到的值赋值给工作内存变量,每当引擎遇到一个给变量赋值的字节码指令的时候即执行此操作;
  7. store(存储):作用于工作内存变量,把工作内存中一个变量的值传送到主存,以便随后的write操作使用。
  8. write(写入):作用于主存变量,把store操作从工作内存中得到的变量的值放入主存的变量中。

在这里插入图片描述

在这里插入图片描述

2.4 交互操作的同步规则

JMM 在执行前面介绍的 8 种基本操作时,为了保证内存间数据一致性,JMM 中规定需要满足以下规则:

  1. 如果要把一个变量从主存复制到工作内存:顺序执行 read 和 load 操作。如果要把变量从工作内存同步会主存:顺序执行 store 和 write 操作。上述的操作是指jvm规定对一个主内存操作的时候需要进行的步骤,其中lock和unlock可以通过字节码指令和咱们的并发包,而对于lock和unlock对变量的操作底层涉及到内存屏障。JMM 只是规定了必须顺序执行,而没有保证是连续执行,其间可以插入其他指令。
  2. 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况。
  3. 不允许线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步到主内存中。
  4. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存。
  5. 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use、store之前,必须先执行过了assign和load操作。
  6. 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock之后,只有执行相同次数的unlock操作,变量才会被解锁。
  7. 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量之前,需要重新执行load或assign操作初始化变量的值。
  8. 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
  9. 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

如上9种内存访问操作以及规则限定,再加上对volatile的一些特殊规定,就已经完全确定了java程序中哪些内存访问操作是在并发下安全的,以上的规则等效于happens-before(先行发生)原则。

3 改良的happens-before原则

3.1 JMM的设计意图

从JMM设计者的角度,在设计JMM时,需要考虑两个关键因素:

  1. 程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。程序员希望基于一个强内存模型来编写代码。
  2. 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。

由于这两个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是找到一个好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。JSR-133使用改良的happens-before原则来实现这一目标。 关于Java内存模型和顺序一致性内存模型、原始happens-before内存模型的关系,可以参考:JMM与顺序一致模型和Happens-Before模型的关系介绍

如下一段代码:

double pi = 3.14;   // A
double r = 1.0;     // B
double area = pi * r * r;  // C
复制代码

上面计算圆的面积的示例代码存在3个happens-before关系,如下。

  1. A happens-before B。
  2. B happens-before C。
  3. A happens-before C。

在3个happens-before关系中,2和3是必需的,但1是不必要的。因此,JMM把happens-before要求禁止的重排序分为了下面两类。

  1. 会改变程序执行结果的重排序。
  2. 不会改变程序执行结果的重排序。

JMM对这两种不同性质的重排序,采取了不同的策略,如下。

  1. 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  2. 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。

下面是JMM的设计示意图:

在这里插入图片描述

从上图可以看出两点:

  1. JMM 向程序员提供的 happens-before 规则能满足程序员的需求。 JMM 的 happens-before规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens- before B)。
  2. JMM 对编译器和处理器的束缚已经尽可能的少。 从上面的分析我们可以看出,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。 比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再比如,如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。

3.2 改良的happens-before的定义

从JDK 1.5开始,Java使用新的JSR-133内存模型。现在的Java内存模型是建立在happens-before(先行发生)内存模型而不是顺序一致性内存模型之上的,并且再此基础上,增强了一些。因为happens-before(先行发生)内存模型是一个弱约束的内存模型,在多线程竞争访问共享数据的时候,会导致不可预期的结果,这些结果有一些是java内存模型可以接受的,有一些是java内存模型不可以接受的。

JSR-133使用happens-before的概念来指定两个操作之间的执行顺序,阐述操作之间的内存可见性。 由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。

《JSR-133:JavaTM内存模型与线程规范》对happens-before关系的定义如下:

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

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

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

3.3 as-if-serial 和 happens-before

as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不被改变。

as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before 关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 happens-before 指定的顺序来执行的。

as-if-serial 语义和 happens-before 这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的效率。

3.4 单线程和多线程的happens-before

单线程下的 happens-before,字节码的先后顺序天然包含 happens-before 关系:因为单线程内共享一份工作内存,不存在数据一致性的问题。

在程序控制流路径中靠前的字节码 happens-before 靠后的字节码,即靠前的字节码执行完之后操作结果对靠后的字节码可见。然而,这并不意味着前者一定在后者之前执行。实际上,如果后者不依赖前者的运行结果,那么它们可能会被重排序。

多线程下的 happens-before,多线程由于每个线程有共享变量的副本,如果没有对共享变量做同步处理,线程 1 更新执行操作 A 共享变量的值之后,线程 2 开始执行操作 B,此时操作 A 产生的结果对操作 B 不一定可见。也就不满足happens-before了。

3.5 Java内存模型中具体的happens-before原则

下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

  1. 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  2. 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
  3. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
  4. 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  5. 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  6. 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  8. 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。 根据上面的happens-before规则,显然,一般只需要使用volatile关键字,或者使用锁的机制,就能实现内存的可见性了。

注意:

  1. 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,即前一个操作(执行的结果)对后一个操作可见,那么这种重排序并不非法(也就是说,JMM 允许这种重排序)。
  2. JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before 关系本质上和 as-if-serial 语义是一回事。

3.6 happens-before与JMM的关系

在这里插入图片描述

如上图,一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。编译器和处理器遵从这一目标,从happens-before的定义我们可以看出,JMM同样遵从这一目标。

参考资料:

  1. JSR133规范
  2. 《深入理解java虚拟机》
  3. 《Java并发编程之美》
  4. 《Java并发编程的艺术》
  5. 《实战Java高并发程序设计》

如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!

文章分类
后端
文章标签