Java 内存模型

731 阅读7分钟

为什么要有内存模型

计算机的 CPU 和内存之间一直有一个核心矛盾,就是它们之间的运算速度有好几个数量级的差距,为了平衡它们的差异,主要做了以下:

  • CPU 增加了高速缓存,以均衡与内存的速度差异;
  • 操作系统增加了进程、线程,以分时复用 CPU,均衡 CPUI/O 设备的差异;
  • 编译器优化指令执行次序,使得缓存能得到更加充分的利用。

虽然高速缓存很好地解决了处理器与内存的速度矛盾,但是又出现了一个新的问题。在多核处理机中,每个处理器都有自己的高速缓存,它们共享同一主内存。当多个处理器的任务涉及到同一块主内存区域时,可能导致缓存的数据不一致的情况,这就是可见性问题,可见性是指一个线程对共享变量的修改,另外一个线程能够立刻看到。

操作系统基于线程来进行任务调度。高级语言的一条语句往往需要多条指令完成,但是任务切换可以发生在任何一条 CPU 指令后,在多线程环境下这就可能导致数据与预期的不一致,即原子性问题。原子性是指一个或多个操作在 CPU 执行过程中不被中断。

编译器的指令重排序优化同样不能保证最终的结果与预期的一致。这里的重排序会满足以下两个条件:

  • as-if-serial:在单线程环境下不管怎么重排序,不能改变程序运行的结果。
  • 数据依赖性:存在数据依赖关系的不能重排序。

需要注意的是:虽然重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。也就是有序性问题,有序性指的是程序按照代码的先后顺序(逻辑先后)执行。

所以,JVM 试图虚拟机定义了一种 Java 内存模型(Java Memory ModelJMM)来屏蔽掉各层硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,也就是解决以上三个问题。

Java 内存模型

Java 内存模型主要是为了定义程序中各个变量的访问规则,此处的变量指的是实例字段、静态字段和构成数组对象的元素等共享变量。

Java 内存模型规定了所有的变量都存储在主内存中。每个线程还有自己的工作内存,其中保存了该线程使用的变量的主内存副本拷贝,线程对变量的所有操作必须在工作内存中进行。不同线程之间也无法直接访问对方工作内存中的变量,线程之间的变量值传递需要通过主内存来完成。

线程、工作内存、主内存三者的关系如下:

内存间交互操作

对于主内存与工作内存之间交互的实现细节,Java 内存模型中定义了 8 种操作来实现,虚拟机实现时必须保证这些操作是原子性的。

  • read(读取):把一个变量的值从主内存传输到工作内存中;
  • load(载入):把 read 操作从主内存得到的变量放入工作内存的变量副本中;
  • use(使用);把工作内存中一个变量的值传递给执行引擎;
  • assign(使用):把一个从执行引擎接收到的值赋给工作内存的变量;
  • store(存储):把工作内存中一个变量的值传送到主内存中;
  • write(写入):把 store 操作从工作内存中得到的变量放入主内存的变量中。

volatile 型变量

关键字 volatileJVM 提供的轻量级的同步机制。当一个变量被定义为 volatile 后,它可以保证内存的可见性。

使用 volatile 还可以禁止指令重排序优化。它是 Java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

内存屏障(Memory Barrier)是一组处理器指令,用于实现对内存访问操作的顺序限制。在重排序时不能把后面的指令重排序到内存屏障之前的位置。

原子性、可见性、有序性

Java 内存模型保证了并发的三个特性:原子性、可见性、有序性,下面学习一下哪些操作实现了这三个特性:

原子性

Java 内存模型保证了内存间交互的 8 个操作的原子性,但对于 64 位的数据类型(longdouble),允许虚拟机的实现可以不保证 64 位数据类型的 loadstorereadwrite4 个操作的原子性。但目前虚拟机几乎都把 64 位数据的读写操纵作为原子性来对待。也就是说可以认为基本类型的读写访问是具备原子性的。

JMM 还提供了 lockunlock 操作来保证更大范围的原子性,尽管虚拟机并未将其开放给用户,但可使用 monitorentermonitorexit 字节码指令来隐式地使用这两个操作,对应到 Java 代码中就是 synchronized 关键字,所以 synchronized 同步块也是原子性的。

可见性

主要有三种方式实现可见性:

  • volatilevolatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
  • synchronized:对同步块加锁解锁,在执行 unlock 操作前必须把此变量值同步到主内存中。
  • final:被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 引用逃逸(其它线程可能通过引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。

有序性

Java 中有两种方式保证线程之间操作的有序性;

  • volatile 关键字通过添加内存屏障的方式来禁止指令重排。
  • 通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,即让线程串行地执行同步代码。

Happends-Before 原则

前面说的保证并发安全的定义实践起来比较麻烦,有一个等效判断原则——Happens-Before 原则,来确定一个访问在并发环境下是否安全。

Happens-Before 的含义就是前面一个操作的结果对后续操作是可见的。要想保证执行操作 B 的线程看到线程 A 的结果,那么 AB 之间必须满足 Happens-Before 原则。如果两个操作之间缺乏 Happens-Before 原则,那么 JVM 就可以对它们任意地重排序,那么就会产生数据竞争问题。

Happens-Before 原则包括:

  • 程序顺序规则:一个线程内按照控制流顺序,前面的操作 Happens-Before 于后面的操作。
  • 管程锁定规则:一个 unlock 操作 Happens-Before 于后面对同一个锁的 lock 操作。
  • volatile 变量规则:对一个 volatile 变量的写操作 Happens-Before 于对该变量的读操作。
  • 线程启动规则:Thread 对象的 start 方法 Happens-Before 于此线程的每一个动作。
  • 线程终止规则:线程中的所有操作都 Happens-Before 于对该线程的终止检测,可通过 Thread.join 方法结束,或 Thread.isAlive 方法的返回值,检测到线程已经终止执行。
  • 线程中断规则:对线程 interrupt 方法的调用 Happens-Before 于被中断线程的代码检测到中断事件的发生。
  • 对象终结规则:一个对象的初始化完成 Happens-Before 于它的 finalize 方法的开始。
  • 传递性:如果操作 A Happens-Before 于操作 B,操作 B Happens-Before 于操作 C,那么操作 AHappens-Before 于操作 C

参考资料