来来来,看看这是你所了解的JAVA内存模型(JMM)吗

108 阅读10分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情

前言

本来这篇想写volatile关键字的内容回顾,回想了一下决定还是先写一篇 JAVA内存模型 的内容,了解JAVA内存模型,volatile关键字理解起来将容易得多。

我发现周围有些小伙伴聊天或者面试过程提起 JAVA内存模型 都会提到寄存器,堆,栈。。。,NO,NO,NO! 在这里重点强调一下,注意敲黑板了,这种想法是 完全错误 的。他们提到的其实是 运行时数据区,这两者千万不要混为一谈。

计算机效率与一致性

处理器的运算能力很强大,但是绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,读数据,存结果等,这个I/O操作是很难消除的。由于计算机存储设备与处理器的运算速度有着几个数量级的差距,所以现在计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的 高速缓存(Cache)来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后,再从缓存同步会内存之中,这样 处理器就无须等待缓慢的内存读写了。

但是,引入高速缓存,也为计算机系统带来更高的复杂度,引入一个新的问题:缓存一致性 (Cache Coherence)。

在多路处理系统中,每个处理器都有自己的 高速缓存,它们又共享同一 主存(Main Memory),这种系统称为 共享内存多核系统(Shared Memory Multiprocessors System)。

处理器、高速缓存、主存交互关系.jpg

当多个处理器的运算任务都涉及用一块主内存区域时,将可能导致各处理器的缓存数据不一致。为了解决这个问题,需要处理器访问缓存时遵循一些 协议,读写时要根据协议来进行操作,这类协议有 MSIMESIMOSIDragon Protocol 等。

除了增加高速缓存外,为了使处理器内部的运算单元尽量被充分利用,处理器可能会对输入代码进行 乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序的一致,因此如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的顺序来保证。与处理器的乱序优化类似, JAVA虚拟机的即时编译器中也有指令重排序(Instruction Reorder)优化

JAVA内存模型

内存模型

内存模型:可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不同的内存模型。

JAVA内存模型的发展

《Java虚拟机规范》中曾定义一种 “JAVA内存模型” 来屏蔽各种硬件和操作系统的内存访问差异,以实现让JAVA程序在各种平台下都能达到一致的内存访问效果。定义JAVA内存模型并不是一件容易的事情,这个模型必须足够严谨,才能让JAVA的并发内存访问操作不会产生歧义;但也必须定义的足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好的执行速度。经过长时间的验证和修补,直至 JDK5(实现了 JSR-133)发布后,JAVA内存模型才逐渐成熟、完善起来了。

到底什么是JAVA内存模型?

Java内存模型( Java Memory Model),简称JMM。Java内存模型的 主要目的定义程序中各种变量的访问规则 ,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。这里所说的变量与JAVA编程所说的变量有所区别,不包括局部变量和方法参数,因为这些是线程私有的,不会被共享,也就不会存在竞争问题。Java内存模型是一种抽象的规则或规范

Java内存模型 规定了所有的变量都存储在 主内存 (Main Memory)中(此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅仅是虚拟机内存的一部分)。每条线程还有自己的 工作内存 (Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的 主内存副本线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据

线程、主内存、工作内存的交互关系.jpg

内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,JAVA内存模型中定义了 8种 操作来完成。

JAVA虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,在某些平台上允许有例外,这里不做过多阐述)。

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

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

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了让而工作内存不接收,或者工作内存发起回写了但主内存不接受的情况。
  • 不允许一个线程 无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中
  • 一个新的变量只能在主内存种“诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use、store 操作之前,必须先执行 assign 和 load 操作。
  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但lock操作可以同时被同一条线程重复执行多次,每次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作锁定,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作以初始化变量的值。
  • 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write操作)。

仔细想想这些规则,是不是解释了JAVA中的很多现象?

volatile型变量的特殊规则,会单独出一篇讲解。

三大特性

JAVA内存模型是 围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,我们来看一下这三个特性。

原子性

JAVA内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store和 write 这6个,我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(例外就是 long 和 double的非原子协定,但这是几乎不会发生的事)。

如果应用场景需要一个更大范围的原子性保证,JAVA内存模型还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机没有把 lock 和 unlock 操作直接开放给用户使用,但是却提供了两个更高层次的字节码指令 monitorentermonitorexit 来隐式地使用这两个操作。这两个字节码指令反映到JAVA代码中就是-- synchronized 关键字,因此 synchronized 块之间的操作也具备原子性。关于 synchronized 关键字后续也会出一篇单独讲解。

可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得到这个修改。JAVA内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。JAVA语言中的volatilefinalsynchorized 关键字都能实现可见性。

有序性

由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行。JAVA语言提供了 volatilesynchorized 关键字来保证线程之间操作的有序性。当然如果仅靠这两个关键字来完成,有很多操作都将会变得非常啰嗦,但是我们编写JAVA代码时候并没有察觉这一点,这是因为JAVA语言中有一个“先行发生”(Happens-Before)的原则。这个原则非常重要,他是判断数据是否存在竞争,线程是否安全的非常有用的手段Happens-Before 我会安排在 volatile 关键字的内容里详细讲解。