# JVM 内存模型

# JMM(Java Memory Model)

# 什么是 JMM?为什么需要 JMM?

一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。

这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的

为什么要遵守这些并发相关的原则和规范呢? 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则来解决这个指令重排序问题。

JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatilesynchronized、各种 Lock)即可开发出并发安全的程序。

# JMM 是如何抽象线程和主内存之间的关系?

Java 内存模型(JMM) 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。

在 JDK1.2 之前,Java 的内存模型实现总是从 主存 (即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 本地内存 (比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。

什么是主内存?什么是本地内存?

  • 主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
  • 本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。(后续貌似也称本地内存为工作内存)

注意

这里的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区不是同一层次的内存划分,这两者基本上没有关系。

Java 内存模型的抽象示意图如下:

JMM(Java 内存模型)

从上图来看,线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:

  1. 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
  2. 线程 2 到主存中读取对应的共享变量的值。

也就是说,JMM 为共享变量提供了可见性的保障。

不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。举个例子:

  1. 线程 1 和线程 2 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。
  2. 线程 2 读取到的是线程 1 修改之前的值还是修改后的值并不确定,都有可能,因为线程 1 和线程 2 都是先将共享变量从主内存拷贝到对应线程的工作内存中。

关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解即可,无需死记硬背):

  • 锁定(lock): 作用于主内存的变量,将他标记为一个线程独享变量。
  • 解锁(unlock): 作用于主内存的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load(载入):作用于工作内存的变量,把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

# JMM 缓存不一致问题

解决 JMM 中的本地内存变量的缓存不一致问题有两种解决方案,分别是 总线加锁MESI缓存一致性协议

# 总线加锁

总线加锁是 CPU 从主内存读取数据到本地内存时,会先在总线对这个数据加锁,这样其它 CPU 就没法去读或者去写这个数据,直到这个 CPU 使用完数据释放锁后,,其它的 CPU 才能读取该数据。

alt text

总线加锁虽然能保证数据一致,但是它却严重降低了系统性能,因为当一个线程多总线加锁后,其它线程都只能等待,将原有的并行操作转成了串行操作。

通常情况下,我们不采用这种方法,而是使用性能较高的缓存一致性协议。

# MESI 缓存一致性协议

MESI 缓存一致性协议是多个 CPU 从主内存读取同一个数据到各自的高速缓存中,当其中的某个 CPU 修改了缓存里的数据,该数据会马上同步回主内存,其它 CPU 通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效

在并发编程中,如果多个线程对同一个共享变量进行操作是,我们通常会在变量名称前加上关键在 volatile ,因为它可以保证线程对变量的修改的可见性,保证可见性的基础是多个线程都会监听总线。即当一个线程修改了共享变量后,该变量会立马同步到主内存,其余线程监听到数据变化后会使得自己缓存的原数据失效,并触发 read 操作读取新修改的变量的值。进而保证了多个线程的数据一致性。事实上, volatile 的工作原理就是依赖于 MESI 缓存一致性协议实现的

# Java 内存区域和 JMM 有何区别?

这是一个比较常见的问题,很多初学者非常容易搞混。 Java 内存区域(结构)和内存模型是完全不一样的两个东西

  • JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
  • Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

# happens-before 原则是什么?

JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。

为什么需要 happens-before 原则?

happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:

  • 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。

下面这张是 《Java 并发编程的艺术》这本书中的一张 JMM 设计思想的示意图,非常清晰。

alt text

了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:

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

我们看下面这段代码:

int userNum = getUserNum();   // 1
int teacherNum = getTeacherNum();   // 2
int totalNum = userNum + teacherNum;  // 3
1
2
3
1
2
3
  • 1 happens-before 2
  • 2 happens-before 3
  • 1 happens-before 3

虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3 。

happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里

举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。

# happens-before 常见规则有哪些?谈谈你的理解?

happens-before 的规则就 8 条,说多不多,重点了解下面列举的 5 条即可。全记是不可能的,很快就忘记了,意义不大,随时查阅即可。

  • 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;
  • 解锁规则:解锁 happens-before 于加锁;
  • volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
  • 传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
  • 线程启动规则:Thread 对象的 start()方法 happens-before 于此线程的每一个动作。

如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。

# happens-before 和 JMM 什么关系?

happens-before 与 JMM 的关系用《Java 并发编程的艺术》这本书中的一张图就可以非常好的解释清楚。

happens-before 与 JMM 的关系

# 再看并发编程三个重要特性

在 Java 多线程中,Java 提供了一系列与并发处理相关的关键字,比如 volatilesynchronizedfinalconcurren 包等。其实这些就是 Java 内存模型封装了底层的实现后提供给程序员使用的一些关键字。

事实上,Java 内存模型的本质是围绕着 Java 并发过程中的如何处理 原子性可见性顺序性 这三个特征来设计的,这三大特性可以直接使用 Java 中提供的关键字实现。

# 原子性

一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。

在 Java 中,可以借助 synchronized、各种 Lock 以及各种原子类实现原子性。

synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile 或者 final 关键字)来保证原子操作。

# 可见性

Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。

在 Java 中,可以借助 synchronizedvolatile 以及各种 Lock 实现可见性。

如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

# 有序性

由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。

我们上面讲重排序的时候也提到过:

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

在 Java 中,volatile 关键字可以禁止指令进行重排序优化。

在 Java 中,可以使用 synchronizedvolatile 来保证多线程之间操作的有序性。实现方式有所区别:
volatile 关键字会禁止指令重排。synchronized 关键字保证同一时刻只允许一条线程操作。

# 总结

  • Java 是最早尝试提供内存模型的语言,其主要目的是为了简化多线程编程,增强程序可移植性的。
  • CPU 可以通过制定缓存一致协议(比如 MESI 协议)来解决内存缓存不一致性问题。
  • 为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
  • 你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
  • JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。