Sivan
Sivan
Published on 2021-02-21 / 122 Visits
0
0

Java 内存模型

并发编程经常听到一个词,JMM - 即 Java 内存模型,在中文环境下很容易和另一个词搞混“JVM 内存区域”。

本篇文章将会与您一起了解,什么是JMM。

Java 内存模型是什么?

Java 的跨平台特性为开发者提供了无限的便利,Java 内存模型为 Java 屏蔽了各种硬件、操作系统的差异,使得同样的 Java 代码可以安全的运行在不同平台上。

JVM 是整个计算机的模型,所以这个模型包括一个内存模型——即 Java 内存模型(Java Memory Model)。Java 内存模型指定了 JVM 与计算机内存(RAM)的工作方式。

Java 内存模型 指定了 JVM 与计算机内存是如何协同工作的:规定了不同线程“如何”和“何时”可以看到其他线程修改的共享变量的值,以及在必要时“如何”同步的访问共享变量。

Java 内存模型 是一种抽象的概念,它描述的是通过各种操作来定义对变量的读/写操作、监视器的加锁和释放操作、以及线程的启动和合并操作。通过这组操作定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

Java 内存模型的结构

JVM 内部使用 Java 内存模型在线程栈和堆之间划分内存。下图从逻辑的角度解释 Java 内存模型:

线程栈、堆示意图

JVM 中每个线程都有自己的线程栈(Thread Stack)。线程只能访问自己的线程栈。线程栈中包含线程正在执行的方法(线程栈上的所有方法)的局部变量。若局部变量是基本类型(int,long,short...),那么存储于线程栈中。

所有线程共享(Heap)。堆包含在 Java 中创建的所有对象,与创建对象的线程无关。

下图说明线程栈、线程中的方法和堆的关系:

线程栈、堆中存储的数据

  1. 当局部变量(Local variable)是基本类型时,保存在线程栈中。
  2. 当局部变量是对象引用时,对象引用保存在线程栈中,对象本身保存在中。
  3. 对象可能包含方法,而这些方法包含局部变量。这些局部变量也存储在线程栈中,即使方法所属的对象存储在中。
  4. 对象的成员变量与对象本身存储在中,无论成员变量时基本类型还是对象引用。
  5. 静态变量与类定义一起存储在中。
  6. 对象可以被持有对象引用的线程访问。如果两个线程同时在同一个对象上调用同一个方法,它们都可以访问该对象的成员变量,但是每个线程都有自己的局部变量副本

图中值得注意的细节是:

  1. 局部变量 2(Local variable 2)都指向对象 3(Object 3),这两个线程对同一个对象引用都是局部变量,但对象本身存于上。
  2. 对象 3 有两个对象作为成员变量,它们都存储与上。
  3. 两个线程对同一个方法的同一个局部变量(Local variable 1)有不同的局部变量副本,分别指向对象 1 和对象 5。

以下代码构成与图一致的内存模型:

public class MyRunnable implements Runnable() {

    public void run() {
        methodOne();
    }

    public void methodOne() {
        int localVariable1 = 45;

        MySharedObject localVariable2 =
            MySharedObject.sharedInstance;

        //... do more with local variables.

        methodTwo();
    }

    public void methodTwo() {
        Integer localVariable1 = new Integer(99);

        //... do more with local variable.
    }
}
public class MySharedObject {

    //static variable pointing to instance of MySharedObject

    public static final MySharedObject sharedInstance =
        new MySharedObject();

    //member variables pointing to two objects on the heap

    public Integer object2 = new Integer(22);
    public Integer object4 = new Integer(44);

    public long member1 = 12345;
    public long member2 = 67890;
}


硬件存储结构

现代硬件内存体系结构与内部 Java 内存模型有所不同。了解硬件内存架构也很重要,了解 Java 内存模型是如何与它一起工作的。本节描述常用的硬件内存体系结构,后面的部分将描述 Java 内存模型是如何与它一起工作的。

存储结构

CPU 的各部分简介:

  • CPU 现代 CPU 通常有两个或更多的 CPU,一些 CPU 可能有多核心。关键在于,在拥有多个 CPU 的现代计算机上,可以同时运行多个线程。每个 CPU 能在任意时间运行一个线程,这意味着,如果 Java 程序是多线程的,那么在 CPU 中有些线程是同时运行的(并发的)。
  • CPU 寄存器 每个 CPU 都有一组寄存器,这些寄存器本质上是 CPU 内存。CPU 访问寄存器的速度远快于访问主内存的速度。
  • 多级高速缓存(CPU 缓存) 大多数现代 CPU 都有一定规模的缓存内存层。CPU 的运算速度比主内存快的多,所以现代计算机都不得不加入一层运算速度极快的高速缓存,作为内存与 CPU 之间的缓冲。当 CPU 需要访问主内存时,它会将部分主内存读到高速缓存,甚至是读到寄存器中,然后再对其进行操作。当 CPU 需要将结果写回主内存时,它会将值从寄存器刷新到高速缓存,并在某个时刻将值刷新到主内存。高速缓存可以一次将数据写入部分内存,并一次刷新部分内存。它不必在每次更新时都完整的读/写缓存。通常情况下,缓存在更小内存的“缓存行”中更新。一条或多条缓存行可读入缓存内存,一条或多条缓存行可再次刷新回主内存
  • 主内存 所有 CPU 都可以访问主内存。主内存通常比 CPU 的缓存大得多。

Java 内存模型与硬件存储结构之间的关联

Java 内存模型和硬件内存体系结构是不同的。**Java 内存模型只是一种抽象的概念,是一组规则,并不实际存在。**硬件内存架构不区分线程栈、堆。在硬件上,线程栈和堆都位于主存中。线程栈和堆某部分有时可能出现在 CPU 缓存和寄存器中。如下图

Java内存模型与硬件存储结构之间的关联

当对象和变量可以出现在不同的内存区域时,可能会出现某些问题:

  • 共享对象的可见性 如果多个线程共享一个对象,那么某个线程对共享对象的更新对其他线程而言不可见。假设共享对象在主内存中,某个 CPU 上的线程读取共享对象到 CPU 缓存中,此时线程对共享对象进行了修改,只要 CPU 缓存没有被刷新回主内存,修改后的共享对象对其他 CPU 上的线程是不可见的。这样,每个线程可能最终都会拥有自己的对象副本,每个副本位于不同的 CPU 缓存中。

    共享对象的可见性

  • 竞态条件 如果多个线程共享一个对象,并且多个线程更新该共享对象中的变量,则可能出现竞态条件。假设线程 A 将共享变量读入 CPU 缓存中,同时,线程 B 也读取了共享变量到其 CPU 缓存中。线程 A、线程 B 做同样的递增操作,此时变量已经递增了两次,但在不同的 CPU 缓存中。这两个递增在没有适当同步的情况下同时执行的。无论线程 A 或线程 B 将计数写回主内存,尽管有两个递增,更新后的值将只比原始值高 1。

    竞态条件

为了解决上面的问题,Java 内存模型定义了一组规则,这组规则保证对变量的写入操作在何时对其他线程可见。

指令重排序

处理器重排序

由于处理器的时钟频率越来越难以提升,因此许多处理器厂商都开始堆叠处理器的核心数,因为能提高的只有硬件并行性。

在多核处理器中,指令集并行的重排序是对 CPU 的性能优化,CPU 在工作时,需要将指令分为多个步骤依次执行,由于每一步会使用到不同的硬件操作,比如取指时只有 PC 寄存器和存储器,译码时执行到指令寄存器组,执行时执行 ALU(算术逻辑单元)、写回时使用到寄存器组。为了提高硬件利用率,CPU 指令是按流水线技术来执行的。

流水线(Pipeline):是 CPU 指令并行运算的一种技术。在 CPU 中由 5—6 个不同功能的电路单元组成一条指令处理流水线,然后将一条指令分成 5—6 步后再由这些电路单元分别执行,这样就能实现在一个CPU 时钟周期完成一条指令,因此提高 CPU 的运算速度。简单讲就是,在第一条指令译码的时候,取第二条指令,在执行第一条指令的时候,对第二条指令进行译码,如此往复。处理器指令冲排序

(IF:读取指令,ID:指令解码,EX:运行,MEM:存储器访问,WB:写回寄存器)

在流水线技术下,一条指令被拆分为多个独立的运行阶段,串行下 8 个总线周期只能完成两条指令,现在 8 个总线周期已经执行了五条指令。

但在流水线执行过程中,每条指令所处理的任务是不同的,流水线会被以下情况影响:

  • 后面指令需要前面指令的计算结果
  • 两条指令使用了相同的寄存器或存储单元
  • 程序流向需要根据分支指令的执行结果来确定

此时会导致流水线阻塞,而消除部分阻塞的办法就是指令重排序,调整指令的位置,使得流水线能够高效的运行。

处理器指令重排序,参考图

处理器指令重排序,参考图

编译器重排序

在 Java 中编译时也存在着重排序,以下代码可能由重排序导致交替执行,输出(0,0):

public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
    Thread one = new Thread(new Runnable() {
        public void run() {
            a = 1;
            x = b;
        }
    });

    Thread other = new Thread(new Runnable() {
        public void run() {
            b = 1;
            y = a;
        }
    });
    one.start(); other.start();
    one.join(); other.join();
    System.out.println(“(” + x + “,” + y + “)”);
}

在指令重排序下,对 x,y 的赋值操作可能重排序为下面的情况导致出现(0,0):

x = b;      y = a;
a = 1;      b = 1;

即使这个例子十分简单,但重排序导致的所有结果依然很复杂。这也能说明在多线程环境下,由于编译器重排序的存在两个线程中变量的一致性是无法确定。

避免重排序

在 Java 中如果想保证操作 B 能够看到操作 A 的操作结果(无论是否处于同一个线程),那么 A 和 B 之间必须满足 Happens-Before 关系。如果两个操作之间没有 Happens-Before 关系,那么 JVM 可以对它们进行任意重排序。

Happens-Before 原则包括:

  • 程序顺序规则:如果程序中操作 A 在操作 B 之前,那么线程中 A 操作将在 B 操作之前执行。
  • 监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁的加锁操作之前。
  • volatile 变量法则:对 volatile 变量的写入操作必须在对该变量的读操作之前。
  • 线程启动法则:在线程上对 Thread.start 的调用必须在该线程中执行的任何操作之前执行。
  • 线程终结法则:线程中的任何操作都必须在其他线程检测到这个线程结束之前执行,或者从 Thread.join 中成功返回,或者在调用 Thread.isAlive 时返回 false。
  • 中断法则:一个线程调用另一个线程的 interrupt 时,必须在被中断线程检测到 interrupt 调用之前执行(通过抛出 InterruptedException,或者调用 isInterrupted 和 interrupted)。
  • 终结法则:对象的构造函数必须在调用该对象的 finalizer 之前完成。
  • 传递性:如果 A 在 B 之前执行,并且 B 在 C 之前执行,则 A 必须在 C 之前执行。

可以理解为:Happens-Before 原则保证了前后的两个操作不会被重排序。

volatile 关键字

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

Java 内存模型对 volatile 的语义做了扩展。对 volatile 语义的扩展保证了 volatile 变量在一些情况下不会重排序,volatile 修饰的 64 位变量 double 和 long 的读取和赋值操作都是原子的。

long、double 在 32 位 CPU 上执行写操作会被拆分成两次写操作写高 32 位和写低 32 位。在并发环境下可能会出现高低位被其他线程修改的隐患。

final 关键字

当构造函数完成之后,构造函数对 final 域的所有写入操作,以及对通过这些域可以达到的任何变量的写入操作,都将被冻结。并且任何获得该对象引用的线程都至少能看到被冻结的值。对于通过 finel 域可到达的初始变量的写入操作,将不会与构造过程后的操作一起被重排序。

推荐阅读

  1. 《Java 并发编程实战》

  2. Java 内存访问重排序的研究

  3. 指令重排序

  4. Java Memory Model

  5. 全面理解 Java 内存模型(JMM)及 volatile 关键字

  6. Java 内存模型(JMM)总结


Comment