Java 内存模型

Java基础

浏览数:237

2019-5-20

Java 内存模型(Java Memory Model, JMM) 定义了 JVM 操作内存的行为模式。

内存模型

JVM 将进程内存分为线程栈区(Thread Stack)和堆区(Heap)。

JVM 上运行的每个线程都有自己的线程栈, 且只能访问自己的线程栈。

栈的每一帧是一层方法调用,即调用栈。栈帧中保存了方法的局部变量、下一条指令的指针等运行时信息。

对于 int, boolean 等 built-in 类型变量本身保存在栈中;对于各种类对象,对象本身保存在堆中,若其引用作为局部变量则会保存在栈中。

对象中的域不论是 built-in 类型还是类对象都会保存在堆中,built-in 类型的域会在对象中存储域本身,而类对象只保存其引用。就是说 Java 中所有类对象都是以引用的形式进行访问的。

JMM 定义了堆与线程栈之间6种交互行为: load, save, read, write, assign 和 use。这些交互行为具有原子性,且相互依赖。

我们可以简单的认为线程在在修改堆区某对象的值时会先将其拷贝到线程工作内存中修改完成后再将其写入回主内存。

指令重排序与 happens-before

为了充分发挥多核 CPU 的性能, Java 规范规定 JVM 线程中程序的执行结果只要等同于严格顺序执行的结果,JVM 可以对指令进行重新排序或并行执行,即 as-is-serial 语义。

我们来看一个经典的示例:

public class Main {
    
    private int a = 1;
    
    private int b = 2;

    public void foo() {
        a = 3;
        b = 4;
    }
}

在线程 A 执行 main.foo 方法时线程 B 试图访问 main.amain.b 可能产生 4 种结果:

  • a = 1, b = 2: 均未改变
  • a = 3, b = 2: a 已改变, b 未改变
  • a = 3, b = 4: a、b 均已改变
  • a = 1, b = 4: a 未改变, b 已改变

根据 as-is-serial 语义, a = 3b = 4 两条语句无论谁先执行均不影响结果,因此 JVM 可以先执行任意语句。

a = 1 语句并不是原子性的,包含将对象从堆拷贝线程工作内存,修改,写回堆操作。 JVM 不保证两条语句的内存操作是有序的, 可能 a 先修改,但 b 先写回堆区。

综上两点,JVM 不保证其它线程看到 a、b 修改的顺序。

JMM 为了解决不同线程访问对象状态的顺序一致性定义了 happens-before 规则。即想要保证执行动作 B 时可以看到动作 A 的结果(不论 A、B 是否在同一个线程中), 动作 A 必须 happens-before 于动作 B。

  • 程序次序规则: 线程中每个动作A 都happens-before 于该线程中的每一个动作B。那么在程序中,所有的动作B都能出现在A之后。(即要求同一个线程中满足 as-is-serial 语义)
  • 监视器锁法则: 对一个监视器锁的解锁 happens-before 于每一个后续对同一监视器锁的加锁。(包括 synchronized 或 ReentrantLock 等)
  • volatile 变量法则: 对 volatile 域的写入操作 happens-before 于每一个后续对同一域的读操作。 即 volatile 域的写入对其它线程立即可见。原子性变量同样拥有 volatile 语义。
  • 线程启动法则: Thread.start 的调用会 happens-before 于线程中其它所有动作
  • 线程终止法则: 线程中的任何动作都 happens-before 于其他线程检测到这个线程已终结(包括从 Thread.join 方法调用中成功返回; thread.isAlive() == false)
  • 线程中断法则: 一个线程调用另一个线程的 interrupt 方法 happens-before 于被中断线程发现中断(包括抛出InterruptedException、thread.isInterrupted() == true)
  • 对象终结法则: 对象构造器返回 happens-before 于对象终结过程开始

happens-before 是一个标准的偏序关系,具有传递性。

volatile 与 synchronized

synchronized 关键字会阻止其它线程获得对象的监视器锁,被保护的代码块无法被其它线程访问也就无法并发执行。

synchronized 也会创建一个内存屏障,保证所有操作结果会被直接写入主存中。也就是说,synchronized 会保证操作的原子性和可见性。

举例来说,在多个线程同时尝试更新同一个计数器时, 更新操作需要进行 读取-修改-写入 操作, 若无法保证更新操作i++的原子性则可能出现异常执行顺序: 线程A读取旧值i=0 -> 线程B读取旧值i=0 -> 线程A在线程工作内存中修改i, 并写入结果 i=1 -> 线程B写入结果i=1。 最终导致两个线程调用i++最终i只加1的情况。

上文已经提到, volatile 关键字保证变量可见性,即对 volatile 域修改对于其它线程立即可见。

volatile 关键字可以达到保证部分 built-in 类型(如 int、 short、 byte)操作原子性的效果, 但对于 double、 long 类型无法保证原子性。即使对于 int 类型 i++ 这类需要读取-修改-写入操作的语句也无法保证原子性。

因此, 不要使用 volatile 关键字来保证操作的原子性。请使用AtomicInteger等原子数据类或synchronized等锁机制来保证。

volatile 同时会禁止指令重排序。我们通过经典的单例模式双重检查锁实现来分析 volatile 禁止指令重排序的意义:

public class Singleton {
    
    volatile private static Singleton instance;
    
    private Singleton (){}

    public static Singleton getInstance() {
      if (instance == null) {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
      }
      return instance;
    }
}

分析若 instance 域不使用 volatile 关键字修饰时可能出现的状况。

instance = new Singleton() 可以分为三步:

  1. 为 instance 引用分配内存
  2. 调用 Singleton 的构造函数进行初始化
  3. 将 instance 引用指向分配的内存空间

在不禁止指令重排序的情况下可能出现1-2-3或1-3-2两种执行顺序。 若执行顺序为 1-3-2, 在线程A执行3后 instance 引用指向尚未初始化的对象。

此时线程B调用 getInstance 方法, 判断instance != null 于是访问了未初始化的对象造成错误。因此,需要使用 volatile 关键字禁止指令重排序。

final

使用不可变对象是保证线程安全最简单可靠的办法(笑

Java 对 final 域的重排序有如下约束:

  • 在构造函数内对一个final域的写入, 与将一个引用指向被构造对象的操作之间不能重排序。

  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

就是说,JMM 对 final 域的保证可理解为它只会在构造函数返回之前插入一个存储屏障,保证构造函数内对 final 域的赋值在构造函数返回之前写到主存。

因此,为了保证对 final 域访问的安全性需要防止在构造函数返回前将被构造对象的引用暴露出去。

public class Escape {
       
  private final int a; 

  public Escape (int a, List<Escape> list) {
    this.a = a;
    list.add(this);
  }
}

其它线程可能通过构造器中的 list 列表在构造器返回前获得 this 指针, 此时对final域的访问是不安全的。

作者:-Finley-