4.5 JMM详解

148 阅读15分钟

    JMM(Java Memory Model,Java内存模型)并不像JVM内存结构一样是真实存在的运行实体,更多体现为一种规范和规则。

4.5.1 什么是Java内存模型

    JMM最初由JSR-133(Java Memory Model and Thread Specification)文档描述,JMM定义了一组规则或规范,该规范定义了一个线程对共享变量写入时,如何确保对另一个线程时可见的。实际上,JMM提供了合理的禁用缓存以及禁止重排序的方法,所以其核心的价值在于解决可见性和有序性。

JMM的另一大价值在于能屏蔽各种硬件和操作系统的访问差异,保证Java程序在各种平台下对内存的访问最终都是一致。

    Java内存模型规定所有的变量都存储在主存中,JMM的主存类似于物理内存,但有区别,还能包含部分共享缓存。每个Java线程都有自己的工作内存(类似于CPU高速缓存,但也有区别)。

    Java内存模型的定义的两个概念:

  1. 主存:主要存储的是Java实例对象,所有线程创建的实例对象都存放在主存中,无论是该实例对象的成员变量还是方法中的本地变量(也称局部变量),当然也包括共享的类信息、常量、静态变量。由于是共享数据区域,因此多条线程对同一个变量进行访问可能会发现线程安全问题。
  2. 工作内存:主要存储当前方法的所有本地变量信息(工作内存中存储着主存的变量副本),每个线程只能访问自己的工作内存,即线程中的本地变量对其他线程时不可见的,即使两个线程执行同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括字节码指示器、相关Native方法的信息。注意,由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

    Java内存模型的规定如下:

  1. 所以变量存储在主存中
  2. 每个线程都有自己的工作内存,且对变量的操作都是在工作内存中进行的。
  3. 不同线程之间无法直接访问彼此工作内存中的变量,要想访问只能通过主存来传递。

在JMM中,Java线程、工作内存、主存之间的关系大致如图4-11所示

    JMM将所有的变量都存放在公共主存中,当线程使用变量时,会把公共主存中的变量复制到自己的工作内存(或者叫作私有内存)中,线程对变量的读写操作是自己的工作内存中的变量副本。因此,JMM模型也需要解决代码重排序和缓存可见性问题。JMM提供了一套自己的方案去禁用缓存以及禁止重排序来解决这些可见性和有序性问题。JMM提供的方案包括大家都熟悉的volatile、synchronized、final等。JMM定义了一些内存操作的抽象指令集,然后将这些抽象指令包含到Java的volatile、synchronized等关键字的语义中,并要求JVM在实现这些关键字时必须具备其包含的JMM抽象指令的能力。

4.5.2 JMM与JVM物理内存的区别

    JMM(Java内存模型)看上去和JVM(Java内存结构)差不多,很多人会误以为两者是一回事,这也就导致面试过程中经常答非所问。

    JMM属于语言级别的内存模型,它确保了在不同的编译器和不同的CPU平台上为Java程序员提供一致的内存可见性保证和指令并发执行的有序性。

以Java为例,一个i++方法编译成字节码后,在JVM中是分成以下三个步骤运行的:

(1)从主存中复制i的值并复制到CPU的工作内存中。

(2)CPU取工作内存中的值,然后执行i++操作,完成后刷新到工

作内存。

(3)将工作内存中的值更新到主存。

    这就是多线程并发访问共享变量所造成的结果不一致问题,该问题属于JMM需要解决的问题。

    JMM属于概念和规范维度的模型,是一个参考性质的模型。JVM模型定义了一个指令集、一个虚拟计算机架构和一个执行模型。具体的JVM实现需要遵循JVM的模型,它能够运行根据JVM模型指令集编写的代码,就像真机可以运行机器代码一样。

虽然JVM也是一个概念和规范维度的模型,但是大家常常将JVM理解为实体的、实现维度的虚拟机,通常是指HotSpot VM。

说明:HotSpot VM是JVM模型的一个开源实现,最初由Sun开发,现在由Oracle拥有。JVM规范还有其他实现,例如JRockit、IBM J9等。如果没有特殊说明,本书的JVM特指HotSpot JVM。

    Java代码是要运行在虚拟机上的,而虚拟机在执行Java程序的过程中会把所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。其中,有些区域随着虚拟机进程的启动而存在,而有些区域则依赖用户线程的启动和结束而建立和销毁。在《Java虚拟机规范(Java SE 8)》中描述了JVM运行时内存区域的结构,如图4-12所示。

    Java虚拟机规范定义了JVM内存结构,JVM内存结构中各个区域有各自的功能。由于JVM的功能不是重点,因此就不在这里详细介绍了。

    这里简单介绍几个需要特别注意的JVM知识点:

(1)JVM模型定义了Java虚拟机规范,但是不同的JVM虚拟机的实现各不相同,一般会遵守规范。

(2)JVM模型定义中定义的方法区只是一种概念上的区域,并说明了其应该具有什么功能。但是并没有规定这个区域到底应该处于何处。所以,对于不同的JVM实现来说,是有一定的自由度的。不同版本的方法区所处的位置不同,方法区并不是绝对意义上的物理区域。在某些版本的JVM实现中,方法区其实是在堆中实现的。

(3)运行时常量池用于存放编译期生成的各种字面量和符号应用。但是,Java语言并不要求常量只有在编译期才能产生。比如在运行期,String.intern也会把新的常量放入池中。

(4)除了以上介绍的JVM运行时内存外,还有一块内存区域可供使用,那就是直接内存。Java虚拟机规范并没有定义这块内存区域,所以它并不由JVM管理,是利用本地方法库直接在堆外申请的内存区域。

(5)堆和栈的数据划分也不是绝对的,如HotSpot的JIT会针对对象分配进行相应的优化。

    下面介绍JMM与硬件内存架构的关系。

    通过对硬件缓存架构、Java内存模型以及Java多线程原理的了解,大家应该已经意识到,多线程的执行最终都会映射到CPU上执行,但是Java内存模型和硬件内存架构并不完全一致。

    JMM与硬件内存架构是什么样的关系呢?

    对于硬件内存来说只有寄存器、缓存内存、主存的概念,并没有工作内存(线程私有数据区域)和主存(堆内存)之分。也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,无论是工作内存的数据还是主存的数据,对于计算机硬件来说都会存储在计算机主存中,当然也有可能存储到CPU高速缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。

    JMM与硬件内存架构的对应关系如图4-13所示。

4.5.3 JMM的8个操作

    JMM定义了一套自己的主存与工作内存之间的交互协议,即一个变量如何从主存拷贝到工作内存,又如何从工作内存写入主存,该协议包含8种操作,并且要求JVM具体实现必须保证其中每一种操作都是原子的、不可再分的。

    JMM主存与工作内存之间的交互协议的8种操作如表4-5所示。

    如果要把一个变量从主存复制到工作内存,就要按顺序执行Read和Load操作;如果要把变量从工作内存同步回主存,就要按顺序执行Store和Write操作。

说明:JMM要求Read和Load、Store和Write必须按顺序执行,但不要求连续执行。也就是说,Read和Load之间、Store和Write之间可插入其他指令。

    JMM主存与工作内存之间的交互协议的8个操作之间的关系如图4-14所示。

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

(1)不允许read和load、store和write操作之一单独出现,以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。不允许read和load、store和write操作之一单独出现,意味着有read就有load,不能读取了变量值而不予加载到工作内存中;有store就有write,也不能存储了变量值而不写到主存中。

(2)不允许一个线程丢弃它最近的assign操作,也就是说当线程使用assign操作对私有内存的变量副本进行变更时,它必须使用write操作将其同步到主存中。

(3)不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主存中。

(4)一个新的变量只能从主存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是对一个变量实施use和store操作之前,必须先执行assign和load操作。

(5)一个变量在同一个时刻只允许一个线程对其执行lock操作,但lock操作可以被同一个个线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

(6)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

(7)如果一个变量实现没有被lock操作锁定,就不允许对它执行unlock操作,也不允许unlock一个被其他线程锁定的变量。

(8)对一个变量执行unlock操作之前,必须先把此变量同步回主存(执行store和write操作)。

以上JMM的8大操作规范定义相当严谨,也极为烦琐,JVM实现起来也非常复杂。Java设计团队大概也意识到了这个问题,新的JMM版本不断地对这些操作进行简化,比如将8个操作简化为Read、Write、Lock和Unlock四个操作。虽然进行了简化,但是JMM的基础设计并未改变。

说明:JMM的规范细节是JVM开发人员需要掌握的内容,对于普通的Java应用工程师、应用架构师来说,只需要了解其基本的原理即可。

4.5.4 JMM如何解决有序性问题

    JMM如何解决顺序一致性问题?JMM提供了自己的内存屏障指令,要求JVM编译器实现这些指令,禁止特定类型的编译器和CPU重排序(不是所有的编译器重排序都要禁止)。

1、JMM内存屏障

    由于不同CPU硬件实现内存屏障的方式不同,JMM屏蔽了这种底层CPU硬件平台的差异,定义了不对应任何CPU的JMM逻辑层内存屏障,由JVM在不同的硬件平台生成对应的内存屏障机器码。

  **  JMM内存屏障主要由Load和Store两类,具体如下:**

(1) Load Barrier(读屏障)

在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主存加载数据。

(2) Store Barrier(写屏障)

在写指令之后插入写屏障,能让写入缓存的最新数据写回主存。

   在实际使用时,会对以上JMM的Load Barrier和Store Barrier两类屏障进行组合,组合成LoadLoad(LL)、StoreStore(SS)、LoadStore(LS)、StoreLoad(SL)四个屏障,用于禁止特定类型的CPU重排序。

(1)LoadLoad(LL)屏障

    在执行预加载(或支持乱序处理)的指令序列中,通常需要显式地声明LoadLoad屏障,因为这些Load指令可能会依赖其他CPU执行的Load指令的结果。

    一段使用LoadLoad(LL)屏障的伪代码示例如下:

Load1; LoadLoad; Load2;

    该示例的含义为:在Load2要读取的数据被访问前,使用LoadLoad屏障保证Load1要读取的数据被读取完毕。

(2)StoreStore(SS)屏障

    通常情况下,如果CPU不能保证从高速缓冲向主存(或其他CPU)按顺序刷新数据,那么它需要使用StoreStore屏障。

    一段使用StoreStore(SS)屏障的伪代码示例如下:

Store1; StoreStore; Store2;

该示例的含义为:在Store2及后续写入操作执行前,使StoreStore屏障保证Store1的写入结果对其他CPU可见。

(3)LoadStore(LS)屏障

    该屏障用于在数据写入操作执行前确保完成数据的读取。一段使用LoadStore(LS)屏障的伪代码示例如下:

Load1; LoadStore; Store2;

    该示例的含义为:在Store2及后续写入操作执行前,使LoadStore屏障保证Load1要读取的数据被读取完毕。

(4)StoreLoad(SL)屏障

    该屏障用于在数据读取操作执行前,确保完成数据的写入。使用LoadStore(LS)屏障的伪代码示例如下:

Store1; StoreLoad; Load2;

    该示例的含义为:在Load2及后续所有读取操作执行前,使StoreLoad屏障保证Store1的写入 对所有CPU可见。

    StoreLoad(SL)屏障的开销是4种屏障中最大的,但是此屏障是一个“全能型”的屏障,兼具其他3个屏障的效果,现代的多核CPU大多支持该屏障。

2、主要处理器对JMM四个内存屏障的支持

    目前的主要处理器对JMM四个内存屏障的支持具体如表4-6所示。

4.5.5 volatile语义中的内存屏障

    在Java代码中,volatile关键字主要有两层语义:

  1. 不同线程对volatile变量的值具有内存可见性,即一个线程修改了某个volatile变量的值,该值对其他线程立即可见。
  2. 禁止进行指令重排序。

    volatile语义中的有序性是通过内存屏障指令来确保的。为了实现volatile关键字语义的有序性,JVM编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。JMM建议JVM采取保守策略对重排序进行严格禁止。下面是基于保守策略的volatile操作的内存屏障插入策略。

  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。
  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。

    volatile写操作的内存屏障插入策略为:在每个volatile写操作前插入StoreStore(SS)屏障,在写操作后面插入StoreLoad屏障,具体如图4-15所示。

    volatile读操作的内存屏障插入策略为:在每个volatile写操作后插入LoadLoad(LL)屏障和LoadStore屏障,禁止后面的普通读、普通写和前面的volatile读操作发生重排序,具体如图4-16所示。

    上述JMM建议的volatile写和volatile读的内存屏障插入策略是针对任意处理器平台的,所以非常保守。不同的处理器有不同“松紧度”的处理器内存模型,只要不改变volatile读写操作的内存语义,不同JVM编译器可以根据具体情况省略不必要的JMM屏障。以X86处理器为例,该平台的JVM实现仅仅在volatile写操作后面插入一个StoreLoad屏障,其他的JMM屏障都会被省略。由于StoreLoad屏障的开销大,因此在X86处理器中,volatile写操作比volatile读操作的开销会大很多。