JMM详解

12/31/2023 Java

目录


参考:


# JMM详解

# 什么是 Java 内存模型(JMM)?

Java 内存模型(Java Memory Model,简称 JMM)是一种抽象的概念,并不真实存在,它描述的一组规则或者规范。通过这些规则、规范定义了程序中各个变量的访问方式。

Java 内存模型抽象了主内存和工作内存的概念,规定所有的变量都存储在主内存中,主内存中的变量是所有线程都可以共享的,但所有线程都无法直接接触到主内存,只能够直接接触到工作内存。对主内存中的变量进行操作时,必须首先将主内存的变量复制到工作内存,进行操作后,再将变量刷回到主内存中。因此,所有线程只有通过主内存来进行通信,主内存和工作内存之间的通信由 Java 内存模型控制。

667853-20220929154840221-1901432985

Java 内存模型与硬件内存架构的关系

多线程的执行最终是映射到硬件层面,通过硬件上的处理器进行执行,但 java 内存模型跟硬件内存架构并不完全一致。对于硬件内存架构来说,只有寄存器、缓存行、主内存的概念,并没有工作内存(私有内存区域)、主内存(堆内存)之分。也就是说 JMM 的内存划分对硬件内存架构并没有什么影响,因为 JMM是一种抽象的概念,是一种规范,并不实际存在。对于硬件内存来说,不管是工作内存,还是主内存,都是储存在寄存器、缓存行、主内存中,JMM与硬件内存架构是一种相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。

v2-52b3cbafcb1ab6ca8868600b9ea6912e_r

主内存,线程不安全

所有线程创建的实例对象都存放在主内存中,属于所有线程共享,所以存在线程之间安全问题。

  • 存储实例对象,包括成员变量、类信息、常量、静态变量等,但是不包括局部变量和方法参数。
  • 主内存属于数据共享区域,多线程并发操作时会引发线程安全问题(原子性、可见性、有序性)。

工作内存,线程安全

主要是存储主内存中变量的副本、局部变量(方法内部的变量),每个线程只能在自己的工作内存中操作变量副本,对其他线程是不可见的。就算两个线程同时执行同一段代码,也是都在自己的工作内存中对变量进行操作。由于线程的工作内存是私有,所以线程之间是不可见的,同时也是线程安全。

  • 存储主内存中变量的副本、当前方法的变量信息,每个线程只能访问自己的工作内存,每个线程工作内存的本地变量对其他线程不可见。
  • 属于线程私有数据区域,不存在线程安全问题。

主内存和工作内存的关系

  • 所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;
  • 线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;
  • 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。

主内存与工作内存的数据存储类型以及操作方式

  • 对于实例对象中的成员方法,方法里的基本数据类型的局部变量将直接存储在工作内存的栈帧结构中。方法里引用类型的局部变量的引用在工作内存中的栈帧结构中,对象实例存储在主内存(堆)中。
  • 对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。
  • 对于实例对象中的静态变量以及类信息都会被存储在主内存中。
  • 需要注意的是,在主内存中的实例对象可以被多个线程共享,如果两个线程调用了同一个对象的同一个方法,两个线程会将数据拷贝到自己的工作内存中,执行完成后刷新回主内存。
667853-20220929154903403-1660358642

主内存与工作内存变量同步的八大原子操作

Java 内存模型定义了一套读写内存变量的原子操作

667853-20220929155016252-2050195088
  • lock(锁定):作用于主内存中的变量,把变量标识为线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,把变量的值从主内存传输到线程的工作内存中,以便下一步的 load 操作使用。
  • load(载入):作用于工作内存的变量,把 read 操作主存的变量放入到工作内存的变量副本中。
  • use(使用),作用于工作内存的变量,把工作内存中的变量传输到执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值),作用于工作内存的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。
  • store(存储),作用于工作内存的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的 write 使用。
  • write(写入):作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

Java 内存模型对八种内存交互操作制定的规则

  • 不允许 read 和 load 、store 和 write 操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 readload 操作,如果要把变量从工作内存同步回主内存,就要按顺序执行 storewrite 操作。(注意,JMM 只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说 readload之间、storewrite之间是可插入其他指令的。如对主内存中的变量 a、b 进行访问时,一种可能出现的顺序是 read aread bload bload a)。
  • 不允许线程丢弃它最近的 assign 操作,即工作内存中的变量数据改变了之后,必须告知主存。
  • 不允许线程将没有 assign 的数据从工作内存同步到主内存。
  • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施 use、store 操作之前,必须经过 load 和 assign 操作。
  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量 前,需要重新执行 load 或 assign 操作以初始化变量的值。
  • 如果一个变量没有被 lock,就不能对其进行 unlock 操作。也不能 unlock 一个被其他线程锁住的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write 操作)。

# Java 内存模型(JMM)如何解决原子性&可见性&有序性

可见性、原子性、有序性

  • 原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程给打断。

​ 在java中,对基本的数据类型的操作都是原子性的操作,但是要注意的是对于32位系统的操作对于long、double类型的并不是原子性操作(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作)。因为对于32位的操作系统来说,每次读写都是32位,而doubel、long则是64位存储单位。就会导致一个线程操作完前面32位后,另一个线程刚好读到后面的32位,这样一来一个64位被两个线程分别读取。

  • 可见性

可见性指的是当一个共享变量被一个线程修改后,其他线程是否能够立即感知到。

​ 对于串行执行的程序是不存在可见性,当一个线程修改了共享变量后,后续的线程都能感知到共享变量的变化,也能读取到最新的值,所以对于串行程序来讲是不存在可见性问题。对于多线程程序,就不一定了,前面分析过对于共享变量的操作,线程都是将主内存的变量copy到工作内存进行操作后,在赋值到主内存中。这样就会导致,一个线程改了之后还未回写到主内存,其余线程就无法感知到变量的更新,线程之间的工作内存是不可见的。另外指令重排序以及编译器优化也会导致可见性的问题。

  • 有序性

​ 有序性是指对于单线程的代码,我们总是认为程序是按照代码的顺序进行执行,对于单线程的场景这样理解是没有问题,但是在多线程情况下, 程序就会可能发生乱序的情况,编译器编译成机器码指令后,指令可能会被重排序,重排序的指令并不能保证与没有排序前的保持一致。

​ 在java程序中,倘若在本线程内,所有的操作都可视为有序性,在多线程环境下,一个线程观察另外一个线程,都视为无顺序可言。

JMM 如何解决原子性&可见性&有序性

原子性问题

除了jvm自身提供的对基本类型的原子性操作以外,可以通过synchronized和Lock实现原子性。synchronized与lock在同一时刻始终只会存在一个线程访问对应的代码块。

可见性问题

volatile关键字保证了可见性。当一个共享变量被volatile修饰时,它会保证共享变量修改的值立即被其他线程可见,即修改的值立即刷新到主内存,当其它线程去需要读取变量时,从主内存中读取。synchronized和Lock也保证了可见性。因为同一时刻只有一个线程能访问同步代码块,所以是能保证可见性。

有序性问题

volatile关键字保证了有序性,synchronized和Lock也保证了有序性(因为同一时刻只允许一个线程访问同步代码块,自然保证了线程之间在同步代码块的有序执行)。

**JMM 规范中定义了 as-if-serial 原则,**不管怎么重排序,单线程程序的执行结果不能被改变。

JMM 规范中定义了 happens-before 原则,基于 happens-before 原则来编程,保证了多线程的有序性。

JDK 5开始,Java 使用新的JSR-133内存模型,JSR-133使用happens-before的概念来阐述操作之间的内存可见性:在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。(两个操作既可以是在一个线程之内,也可以是在不同线程之间)

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

# 8 条 Happens-before 规则

(1)程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行发生(Happens-before)于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。

(2)管程锁定规则(Monitor Lock Rule):一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是 「同一个锁」,而 「后面」是指时间上的先后。

(3)volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的「后面」同样是指时间上的先后。

这就代表了如果变量被 volatile 修饰,那么每次修改之后,其他线程在读取这个变量的时候一定能读取到该变量最新的值。

(4)线程启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。

(5)线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread 对象的 join() 方法是否结束、Thread 对象的 isAlive() 的返回值等手段检测线程是否已经终止执行。

(6)线程中断规则(Thread Interruption Rule):对线程interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread 对象的 interrupt() 方法检测到是否有中断发生。

(7)对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

(8)传递性(Transitivity):如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那就可以得出操作 A 先行发生于操作 C 的结论。

22586965-9fb34a4b65cda0d5

22586965-6ddac087cecfd9da

指令序列的重排序

我们在编写代码的时候,通常自上而下编写,那么希望执行的顺序也是逐步串行执行,但是为了最大的限度发挥计算机的性能,编译器和处理器常常会对指令做重排序。这些重排序可能会导致多线程程序出现内存可见性问题。从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:

22586965-e918c5fd3e29ffa6

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

综上:不管是字节码的原因,还是硬件的原因,在粗粒度上简化来看,程序并发执行结果一致性问题是由多线程的共享变量问题导致的。

JVM 试图定义一种统一的 Java 内存模型,能将各种底层硬件,以及操作系统的内存访问差异进行封装,使 Java 程序在不同硬件及操作系统上都能达到相同的并发效果。JMM 呼之欲出:JMM 就是 Java 内存模型(java memory model),JMM是一个抽象的概念,它描述了一系列的规则或者规范,用来解决多线程的共享变量问题。此处的变量与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。JMM 与并发、处理器、缓存、编译器有关,解决了程序并发执行时因 CPU 多级缓存、处理器优化、指令重排等导致的结果不可预期的问题。需要每个 JVM 的实现都要遵守 JMM 规范,开发者可以根据 JMM 规范来开发多线程程序。有了 JMM 规范的保障,即便同一个程序在不同的虚拟机上运行,得到的程序结果也是一致的。如果没有 JMM 来规范,就可能会出现,经过不同 JVM 翻译之后,程序运行的结果不一致。

所以说,Java 内存模型描述的是多线程对共享内存修改后彼此之间的可见性,另外,还确保正确同步的 Java 代码可以在不同体系结构的处理器上正确运行。

上次更新时间: 9/25/2024, 9:17:45 AM