什么是Java内存模型(JMM)?
什么是Java内存模型(JMM)?
概要
Java内存模型(JMM)是Java虚拟机并发知识中很重要的一部分,为了更好的理解它。我们先花费一点时间去了解物理计算机中的并发问题。物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义。
一、硬件内存架构
1. 传统计算机内存架构
随着技术的发展,CPU也在按照摩尔定律快速发展,而内存即主存(Main Memory)发展却十分缓慢,所以CPU与主存间产生了一种因发展速度带来的矛盾,CPU发展太快导致主存跟不上CPU的发展速度,所以出现了三级缓存(不一定都是三级),一种比主存读写速度更高的存储,三级缓存的出现暂缓了这种矛盾。从三级缓存的CPU架构看看现代计算机的内存模型。
传统计算机内存架构如下图:
2. 缓存一致性问题
由于主存与 CPU 处理器的运算能力之间有数量级的差距,所以在传统计算机内存架构中会引入高速缓存来作为主存和处理器之间的缓冲,CPU 将常用的数据放在高速缓存中,运算结束后 CPU 再将运算结果同步到主存中。
使用高速缓存解决了 CPU 和主存速率不匹配的问题,但同时又引入另外一个新问题:缓存一致性问题。
如下图:
在多CPU的系统中(或者单CPU多核的系统),每个CPU内核都有自己的高速缓存,它们共享同一主内存(Main Memory)。每个CPU都有着自己独立的缓存,它们各自之间是不可见的,这就会导致对应CPU读取的数据都是自己缓存的,无法看到别人对共享数据的修改,从而导致并发BUG。
导致缓存一致性问题的核心主要是两个问题:
1)问题一: 在一个CPU修改了内存数据的时候,其它CPU是不知道的,所以导致一个CPU改了,另外一个CPU看不见,从而使用了旧的数据,导致了程序不正确的结果。
2)问题二: 在多个CPU同时读取和修改CPU的时候,如何保证这几个CPU操作的顺序性,一旦不能保证整个修改操作的顺序,那么就可能导致先写后读的两个请求,结果反映到内存就成了先读后写的结果,从而没有读取到最新的数据,又或者两个写数据的请求顺序被调换了,那么就可能会造成脏写。
3. 基于总线的一致性解决方案
CPU要和存储设备进行交互,必须要通过总线设备(BUS),在获取到总线控制权后才能启动数据信息的传输,而CPU要想从主存读写数据,那么就必须向总线发起一个总线事务(读事务或写事务)来从主存读取或者写入数据。
4. 总线嗅探机制
缓存一致性的第一个问题在于,在多CPU缓存的情况下,一个CPU修改了主存的共享变量,其它CPU是不知道的,所以解决这个问题最直接的办法就是使用通知机制,当一个CPU修改了主存的数据时,其它CPU都会收到相应的数据变更通知,收到通知的CPU如果发现自己也缓存了对应的数据,那么就会将自己缓存的数据所在缓存行标记为失效,当下次读取该数据时发现自己的缓存行已过期,那么就会选择从主存加载最新的数据。 而实现这个功能的机制就叫“总线嗅探”,总线嗅探是通过CPU侦听总线上发生的数据交换操作,当总线上发生了数据操作,那么总线就会广播对应的通知。
5. 处理器优化和指令重排序
为了提升性能在 CPU 和主内存之间增加了高速缓存,但在多线程并发场景可能会遇到缓存一致性问题。那还有没有办法进一步提升 CPU 的执行效率呢?答案是:处理器优化。
为了使处理器内部的运算单元能够最大化被充分利用,处理器会对输入代码进行乱序执行处理,这就是处理器优化。
除了处理器会对代码进行优化处理,很多现代编程语言的编译器也会做类似的优化,比如像 Java 的即时编译器(JIT)会做指令重排序。
如下图:
重排序分为3个类型:
1)编译器优化的重排序
编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序
由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
6. 内存屏障
既然经过了优化过后的缓存一致性协议无法达到数据的强一致,那么我们为什么还要去优化呢?
因为大多数情况都不存在并发问题,只有少数场景才会导致这种问题,我们不能因为极少数场景的问题而放弃了大多数场景的性能提成。当然虽然是极少数场景的问题,但是也不能放任不管,所以针对这种少数场景就必须要有一套处理机制来保证我们程序不出问题,所以这就是内存屏障的职责了。
二、硬件内存结构跟JMM的关系是什么?
上面讲的都是硬件相关的东西,这些跟Java内存模型到底有什么关系呢?我们继续往下看。
我们知道并发编程有3个特征,分别是:原子性,可见性,有序性。这三个特征可谓是整个Java并发的基础。而整个Java内存模型实际上是围绕着三个特征建立起来的。
从更深层次来看这三个特征(并发要解决的问题),就是上面讲的缓存一致性、处理器优化、指令重排序造成的。缓存一致性问题其实就是可见性问题,处理器优化可能会造成原子性问题,指令重排序会造成有序性问题。
那么这些问题要如何解决呢?所以技术前辈们想到了在物理机器上定义出一套内存模型, 规范内存的读写操作。内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
1. 原子性 (Atomicity)
原子性指的是一个操作是不可分割,不可中断的,要么全部执行成功要么全部执行失败。在多线程环境中,原子性保证了一个线程在执行操作时不会被其他线程干扰,从而确保了操作的完整性和一致性。
下面的这几句代码能保证原子性吗?我们一起来看下
1 int i = 2; 2 3 int j = i; 4 5 i++; 6 7 i = i + 1;
第一句是基本类型赋值操作,必定是原子性操作。
第二句先读取i的值,再赋值到j,两步操作,不能保证原子性。
第三和第四句其实是等效的,先读取i的值,再+1,最后赋值到i,三步操作了,不能保证原子性。
实现方式:在 Java 中, JMM只能保证基本的原子性。如果要保证一个代码块的原子性,可以借助synchronized(提供了monitorenter 和 moniterexit 两个字节码指令)、各种 Lock 以及各种原子类实现原子性。synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap)
2. 可见性(Visibility)
可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。
实现方式:在 Java 中,可以借助synchronized、volatile 以及各种 Lock 实现可见性。
1) volatile
如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主内存中进行读取。
2) synchronized
synchronized的原理是,在执行完,进入unlock之前,必须将共享变量同步到主内存中。
3) final
final修饰的字段,一旦初始化完成,如果没有对象逸出(指对象未初始化完成就可以被别的线程使用),那么对于其他线程都是可见的。
3. 有序性(Ordering)
有序性即程序执行的顺序按照代码的先后顺序执行。
说明:由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
实现方式:在 Java 中,可以使用synchronized或者volatile保证多线程之间操作的有序性。
1) volatile
volatile 关键字是使用内存屏障达到禁止指令重排序,以保证有序性。
2) synchronized
synchronized的原理是,一个线程lock之后,必须unlock后,其他线程才可以重新lock,使得被synchronized包住的代码块在多线程之间是串行执行的。
三、 Java内存模型(JMM)
Java内存模型简称JMM(Java Memory Model),是Java虚拟机所定义的一种抽象规范,用来屏蔽不同硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的内存访问效果。
这里要注意两点:
1)JMM是一个抽象的概念,并不是物理上的内存划分。
2)JMM-JVM-硬件的关系
Java内存模型(JMM)定义了Java虚拟机(JVM)在计算机内存(RAM)中的工作规范。在硬件内存模型中,各种CPU架构的实现是不尽相同的,Java作为跨平台的语言,为了屏蔽底层硬件差异,定义了Java内存模型(JMM)。JMM作用于JVM和底层硬件之间,屏蔽了下游不同硬件模型带来的差异,为上游开发者提供了统一的使用接口。
总之一句话:JMM是JVM的内存使用规范,是一个抽象的概念。
Java内存模型的抽象示意图:
如上图在JMM中,内存划分为两个区域,主内存和工作内存。
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。
此处的变量与Java编程中所说的变量有所区别,它包括实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
1. 主内存(Main Memory)
主内存被所有的线程所共享,用于存储共享变量的值,包括实例变量,静态变量,但是不包括局部变量和方法参数。
说明:局部变量和方法参数不存储在主内存中,它们属于线程私有的内存区域,存储在每个线程的栈帧(Stack Frame)中。这些变量的生命周期仅限于方法的执行过程,方法调用结束后就会被销毁,不会存在于主内存中。
2. 工作内存(Working Memory)
每一个线程拥有自己的工作内存(本地内存),线程的工作内存保存了该线程用到的变量和主内存的副本拷贝。
说明:
1) 每个线程的工作内存都是独立的,线程操作数据只能在工作内存中进行,然后刷回到主内存。这是 Java 内存模型定义的线程基本工作方式。
2)线程对共享变量的所有操作(读取、赋值等)都必须在工作内存进行,不能直接读写主内存中的变量。不同线程之间也无法访问彼此的工作内存,变量值的传递只能通过主内存来进行。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
- 工作内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。工作内存中存储了该线程读/写共享变量的拷贝副本。
- 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
- Java内存模型中的线程的工作内存(working memory)是 CPU的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。
四、内存间交互操作
关于主内存和工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。虚拟机必须保证每一个操作都是原子的、不可再分的。
如下图:
1. lock(锁定)
作用于主内存的变量,把一个变量标识为线程独占的状态。
2. read(读取)
作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便下一步的load操作使用。
3. load(载入)
作用于工作内存的变量,它把read操作从主内存中读取的变量值放入工作内存的变量副本中(副本是相对于主内存的变量而言的)。
4. use(使用)
作用于工作内存中的变量,表示把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作。
5. assign(赋值)
作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。
6. store(存储)
作用于工作内存的变量,它把工作内存中变量的值传送到主内存中,以便后续的write的操作。
7. wirte(写入)
作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。
8. unlock(解锁)
作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
说明:
1)如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行 read 和 load 操作。
2)如果把变量从工作内存中同步到主内存中,就需要按顺序地执行 store 和 write 操作。
但Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
下面继续补充一下JMM对8种内存交互操作制定的规则:
1)不允许read、load、store、write操作之一单独出现,也就是read操作后必须load,store操作后必须write。
2)不允许线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步到主内存中。
3)不允许线程将没有assign的数据从工作内存同步到主内存。
4)一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过load和assign操作。
5)一个变量同一时间只能有一个线程对其进行lock操作。多次lock之后,必须执行相同次数unlock才可以解锁。
6)如果对一个变量进行lock操作,会清空所有工作内存中此变量的值。在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值。
7)如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量。
8)一个线程对一个变量进行unlock操作之前,必须先把此变量同步回主内存。
四、JMM中的重要原则
1. happens-before(先行发生原则)
happens-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。
happens-before原则定义如下:
1) 如果一个操作发生在另一个操作之前,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2) 两个操作之间存在先行发生的关系,并不意味着一定要按照先行发生原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
我们无法就所有场景来规定某个线程修改的变量何时对其他线程可见,但是我们可以指定某些规则,这规则就是happens-before,从JDK 5 开始,JMM就使用happens-before的概念来阐述多线程之间的内存可见性。
说明:
1)这里的操作,实际上就是各种指令操作,比如读操作、写操作、初始化操作、锁操作等等。
2)先行发生原则作用于很多场景下,包括同步锁、线程启动、线程终止、volatile。
五、Java 运行时内存区域和 JMM 有何区别?
这是一个比较常见的问题,很多初学者非常容易搞混。
Java 内存区域和内存模型是完全不一样的两个东西。
1. Java内存区域
Java 内存区域和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
2. Java内存模型(JMM)
Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
可以说,JMM主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。
六、总结
1. 由于CPU 和主内存间存在数量级的速率差,想到了引入了多级高速缓存的传统硬件内存架构来解决,多级高速缓存作为 CPU 和主内存的缓冲提升了整体性能。解决了速率差的问题,却又带来了缓存一致性问题。
2. 数据同时存在于高速缓存和主内存中,如果不加以规范势必造成灾难,因此在传统机器上又抽象出了内存模型。
3. Java 语言在遵循内存模型的基础上推出了 JMM 规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
4. JMM定义了线程和主内存之间的交互规则,确保了在多线程环境中对共享变量的访问和更新的一致性。
5. 为了更精准控制工作内存和主内存间的交互,JMM 还定义了八种操作:lock, unlock, read, load, use, assign, store, write。
参考资料:《深入理解Java虚拟机》
https://zhuanlan.zhihu.com/p/258393139
https://zhuanlan.zhihu.com/p/84500221