Java 内存模型

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

内存模型

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

JVM 上运行的每一个线程都有本身的线程栈, 且只能访问本身的线程栈。并发

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

对于 int, boolean 等 built-in 类型变量自己保存在栈中;对于各类类对象,对象自己保存在堆中,若其引用做为局部变量则会保存在栈中。函数

对象中的域不管是 built-in 类型仍是类对象都会保存在堆中,built-in 类型的域会在对象中存储域自己,而类对象只保存其引用。就是说 Java 中全部类对象都是以引用的形式进行访问的。性能

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

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

指令重排序与 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域的访问是不安全的。

相关文章
相关标签/搜索