[Java 基础] 什么是 Java 内存模型?


#1

这个问题是面试中出镜率很高的一道问题。理解 Java 内存模型对我们日常工作也有很大的帮助。《深入理解 Java 虚拟机》这本书中对 Java 内存模型的描述如下。

Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

在 JDK 1.5 中的 JSR-133 的实现发布后,Java 内存模型已经成熟和完善起来了。本文对 Java 内存模型的介绍都指的是 JDK 1.5 及以后的内存模型。

Java 内存模型(以下简称为 JMM)描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。JMM 规定了所有的变量都存储在主内存(Main Memory)中。每个线程拥有自己的工作内存(Wokring Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

需要注意的是,JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开的(稍后会分析)。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。关于JMM中的主内存和工作内存说明如下

  • 主内存

    主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

  • 工作内存

    主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

那么 JMM 这组规则解决的是什么问题呢?解决的是主内存与工作内存中数据的一致性问题,也就是多个线程在操作同一主内存中的变量时数据的一致性问题。为了解决这个问题,JMM 定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(即JMM),JMM是围绕着程序执行的原子性、有序性、可见性展开的,下面我们看看这三个特性。

  • 原子性:原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
  • 可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。导致可见性问题的原因包括指令重排序和工作内存与主内存同步延迟两种。
  • 有序性:Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。其中后半句是指“指令冲排序“现象和“工作内存与主内存同步延迟“现象。

那么,Java 内存模型是如何保证以上三个特性的呢?Java 语言提供了一套解决方案供开发者使用以解决以上问题。对于原子性问题,Java 语言除了本身对大部分基本类型(long 和 double 除外)的读写操作天然具备原子性之外,还提供了 synchronized 关键字或者可重入锁 ReentrantLock 保证代码块执行的原子性。工作内存与主内存同步延迟现象导致的可见性问题,可以使用 synchronized 关键字或者 volatile 关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。对于指令重排导致的可见性问题和有序性问题,则可以利用 volatile 关键字解决,因为 volatile 的另外一个作用就是禁止重排序优化。

如果 JMM 中所有的有序性都需要依靠 volatilesynchronized 来完成,那么编写程序会变得很繁琐,但实际上我们并没有感觉到这一点,这是因为 Java 语言中有一个 happens-before 的原则。

happens-before 原则

Happens-before 是 Java 内存模型中定义的两项操作之间的偏序关系,如果说操作 A 先行发生于(happens-before)操作 B,其实就是说在发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到,“影响” 包括修改了内存中共享变量的值、发送了消息、调用了方法等。

下面是 JMM 中一些天然的 happens-before 关系,这些 happens-before 关系不需要任何外部的同步手段就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

  • 程序次序规则:在一个线程内,按照程序代码的顺序,编写在前面的操作 happens-before 后面的操作。
  • 对象监视器锁规则:一个 unlock 操作 happens-before 后面对同一个锁的 lock 操作。
  • volatile 变量规则:对一个 volatile 变量的写操作 happens-before 后面对这个变量的读操作。
  • 线程启动规则:Thread 对象的 start() 方法 happens-before 此线程的每一个动作。
  • 线程终止规则:线程中的所有操作都 happens-before 对此线程的终止检测。
  • 线程中断规则:对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)happens-before 它的 finalize() 方法的开始。
  • 传递性:如果操作 A happens-before 操作 B,操作 B happens-before 操作 C,那么可以得出 操作 A happens-before 操作 C。

volatile 关键字

上面提到 volatile 关键字的一些语义,这里再重新总结一下。

volatile 是 Java 虚拟机提供的轻量级的同步机制。volatile 关键字有如下两个作用:

  • 保证被 volatile 修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被 volatile 修饰共享变量的值,新值总是可以被其他线程立即看到。
  • 禁止指令重排序优化。

那么 JMM 是如何实现让 volatile 变量对其他线程立即可见的呢?实际上,当写一个 volatile 变量时,JMM 会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个 volatile 变量时,JMM 会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile 变量正是通过这种写-读方式实现对其他线程可见。

而对于禁止指令重排序优化,volatile 是如何实现的呢?先了解一个概念,内存屏障(Memory Barrier)。
内存屏障,又称内存栅栏,是一个 CPU 指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现 volatile 的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条 Memory Barrier 则会告诉编译器和 CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier 的另外一个作用是强制刷出各种 CPU 的缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本。总之,volatile 变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。