Java内存模型

Java内存模型

1. Java 的共享内存模型

  • Java 线程之间的通信采用的是共享内存模型,这里提到的共享内存模型指的就是 Java 内存模型(简称 JMM),JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

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

    1. 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
    1. 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。
  • 下面通过示意图来说明这两个步骤:

  • 如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。

  • 从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。

  • 上面也说到了,Java 内存模型只是一个抽象概念,那么它在 Java 中具体是怎么工作的呢?为了更好的理解上 Java 内存模型工作方式,下面就 JVM 对 Java 内存模型的实现、硬件内存模型及它们之间的桥接做详细介绍。

2. JVM 对 Java 内存模型的实现

  • 在 JVM 内部,Java 内存模型把内存分成了两部分:线程栈区和堆区,下图展示了 Java 内存模型在 JVM 中的逻辑视图:

  • JVM 中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化。

  • 线程栈还包含了当前方法的所有本地变量信息。一个线程只能读取自己的线程栈,也就是说,线程中的本地变量对其它线程是不可见的。即使两个线程执行的是同一段代码,它们也会各自在自己的线程栈中创建本地变量,因此,每个线程中的本地变量都会有自己的版本。

  • 所有原始类型(boolean,byte,short,char,int,long,float,double)的本地变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的。对于原始类型的本地变量,一个线程可以传递一个副本给另一个线程,当它们之间是无法共享的。

  • 堆区包含了 Java 应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如 Byte、Integer、Long 等等)。不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆区。

  • 下图展示了调用栈和本地变量都存储在栈区,对象都存储在堆区:

  • 一个本地变量如果是原始类型,那么它会被完全存储到栈区。

  • 一个本地变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。

  • 对于一个对象的成员方法,这些方法中包含本地变量,仍需要存储在栈区,即使它们所属的对象在堆区。

  • 对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。

  • Static 类型的变量以及类本身相关信息都会随着类本身存储在堆区。

  • 堆中的对象可以被多线程共享。如果一个线程获得一个对象的应用,它便可访问这个对象的成员变量。如果两个线程同时调用了同一个对象的同一个方法,那么这两个线程便可同时访问这个对象的成员变量,但是对于本地变量,每个线程都会拷贝一份到自己的线程栈中。

  • 下图展示了上面描述的过程:

3. 硬件内存架构

  • 不管是什么内存模型,最终还是运行在计算机硬件上的,所以我们有必要了解计算机硬件内存架构,下图就简单描述了当代计算机硬件内存架构:
  • 现代计算机一般都有 2 个以上 CPU,而且每个 CPU 还有可能包含多个核心。因此,如果我们的应用是多线程的话,这些线程可能会在各个 CPU 核心中并行运行。

  • 在 CPU 内部有一组 CPU 寄存器,也就是 CPU 的储存器。CPU 操作寄存器的速度要比操作计算机主存快的多。在主存和 CPU 寄存器之间还存在一个 CPU 缓存,CPU 操作 CPU 缓存的速度快于主存但慢于 CPU 寄存器。某些 CPU 可能有多个缓存层(一级缓存和二级缓存)。计算机的主存也称作 RAM,所有的 CPU 都能够访问主存,而且主存比上面提到的缓存和寄存器大很多。

  • 当一个 CPU 需要访问主存时,会先读取一部分主存数据到 CPU 缓存,进而在读取 CPU 缓存到寄存器。当 CPU 需要写数据到主存时,同样会先 flush 寄存器到 CPU 缓存,然后再在某些节点把缓存数据 flush 到主存。

4. Java 内存模型和硬件架构之间的桥接

  • 正如上面讲到的,Java 内存模型和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆,从硬件上看,不管是栈还是堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到 CPU 寄存器中,如下图所示,Java 内存模型和计算机硬件内存架构是一个交叉关系:

  • 当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要的两个问题是:

    1. 共享对象对各个线程的可见性
    2. 共享对象的竞争现象

5. 共享对象的可见性

  • 当多个线程同时操作同一个共享对象时,如果没有合理的使用 volatile 和 synchronization 关键字,一个线程对共享对象的更新有可能导致其它线程不可见。

  • 想象一下我们的共享对象存储在主存,一个 CPU 中的线程读取主存数据到 CPU 缓存,然后对共享对象做了更改,但 CPU 缓存中的更改后的对象还没有 flush 到主存,此时线程对共享对象的更改对其它 CPU 中的线程是不可见的。最终就是每个线程最终都会拷贝共享对象,而且拷贝的对象位于不同的 CPU 缓存中。

  • 下图展示了上面描述的过程。左边 CPU 中运行的线程从主存中拷贝共享对象 obj 到它的 CPU 缓存,把对象 obj 的 count 变量改为 2。但这个变更对运行在右边 CPU 中的线程不可见,因为这个更改还没有 flush 到主存中:

  • 要解决共享对象可见性这个问题,我们可以使用 java volatile 关键字。 Java’s volatile keyword. volatile 关键字可以保证变量会直接从主存读取,而对变量的更新也会直接写到主存。volatile 原理是基于 CPU 内存屏障指令实现的,后面会讲到。

6. 竞争现象

  • 如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。

  • 如下图所示,线程 A 和线程 B 共享一个对象 obj。假设线程 A 从主存读取 Obj.count 变量到自己的 CPU 缓存,同时,线程 B 也读取了 Obj.count 变量到它的 CPU 缓存,并且这两个线程都对 Obj.count 做了加 1 操作。此时,Obj.count 加 1 操作被执行了两次,不过都在不同的 CPU 缓存中。

  • 如果这两个加 1 操作是串行执行的,那么 Obj.count 变量便会在原始值上加 2,最终主存中的 Obj.count 的值会是 3。然而下图中两个加 1 操作是并行的,不管是线程 A 还是线程 B 先 flush 计算结果到主存,最终主存中的 Obj.count 只会增加 1 次变成 2,尽管一共有两次加 1 操作。

  • 要解决上面的问题我们可以使用 java synchronized 代码块。synchronized 代码块可以保证同一个时刻只能有一个线程进入代码竞争区,synchronized 代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会 flush 到主存,不管这些变量是不是 volatile 类型的。