Java内存模型

1、Java内存模型

  • JMM即Java Memory Model,它定义了主存(即JVM中的方法区和堆区)、工作内存(即JVM中的虚拟机栈)抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。
    • JMM体现在以下几个方面:
      • 原子性-保证指令不会受到线程上下文切换的影响。
      • 可见性-保证指令不会受cpu缓存的影响。
      • 有序性-保证指令不会受cpu指令并行优化的影响。

2、可见性

  • 先来看一个现象,main线程对run变量的修改对于t线程不可见,导致了t线程无法停止:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Slf4j
    public class TestVisibility {
    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
    while(run){

    }
    });
    t.start();

    Thread.sleep(1000);
    log.debug("停止t");
    run = false; // 线程t不会如预想的停下来
    }
    }
    • 分析:

      • ①初始状态,t线程刚开始从主内存读取了run的值到工作内存。

      • ②因为t线程要频繁从主内存中读取run的值,JIT 编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率。

      • ③1秒之后,main线程修改了run的值,并同步至主存,而t是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。

  • 解决方法:

    • volatile(易变关键字):它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存。volatile保证内存可见性和禁止指令重排,用于多线程环境下的单次操作(单次读或者单次写)。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @Slf4j
      public class TestVisibility {
      volatile static boolean run = true;

      public static void main(String[] args) throws InterruptedException {
      Thread t = new Thread(()->{
      while(run){

      }
      });
      t.start();

      Thread.sleep(1000);
      log.debug("停止t");
      run = false; // 线程t不会如预想的停下来
      }
      }
    • 使用synchronized()。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      @Slf4j
      public class TestVisibility {
      static boolean run = true;
      final static Object obj = new Object();

      public static void main(String[] args) throws InterruptedException {
      Thread t = new Thread(() -> {
      while (true) {
      synchronized (obj) {
      if (!run) {
      break;
      }
      }

      }
      });
      t.start();

      Thread.sleep(1000);
      log.debug("停止t");
      run = false;
      }
      }
  • 对比可见性和原子性。

    • 前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况,上例从字节码理解是这样的:

      1
      2
      3
      4
      5
      6
      getstatic run // 线程 t 获取 run true 
      getstatic run // 线程 t 获取 run true
      getstatic run // 线程 t 获取 run true
      getstatic run // 线程 t 获取 run true
      putstatic run // 线程 main 修改 run 为 false, 仅此一次
      getstatic run // 线程 t 获取 run false
    • 有线程安全问题的例子:两个线程一个 i++ 一个 i– ,只能保证看到最新值,不能解决指令交错:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      // 假设i的初始值为0 
      getstatic i // 线程2-获取静态变量i的值 线程内i=0
      getstatic i // 线程1-获取静态变量i的值 线程内i=0
      iconst_1 // 线程1-准备常量1
      iadd // 线程1-自增 线程内i=1
      putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
      iconst_1 // 线程2-准备常量1
      isub // 线程2-自减 线程内i=-1
      putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
    • 结论

      • synchronized语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是
        synchronized是属于重量级操作,性能相对更低。
  • happens-before规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的可见。

    • ①解锁m之前对变量的写,对于接下来对m加锁的其它线程对该变量的读可见。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      static int x;
      static Object m = new Object();
      // 假设t1线程先加锁运行
      new Thread(() -> {
      synchronized (m) {
      x = 10;
      }
      }, "t1").start();
      new Thread(() -> {
      synchronized (m) {
      System.out.println(x);
      }
      }, "t2").start();
  • ②线程对volatile变量的写,对接下来其它线程对该变量的读可见。

    1
    2
    3
    4
    5
    6
    7
    8
      volatile static int x;
    // 假设t1线程先运行
    new Thread(()->{
    x = 10;
    },"t1").start();
    new Thread(()->{
    System.out.println(x);
    },"t2").start();
    • ③线程start前对变量的写,对该线程开始后对该变量的读可见。

      1
      2
      3
      4
      5
        static int x;
      x = 10;
      new Thread(()->{
      System.out.println(x);
      },"t2").start();
    • ④线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用t1.isAlive()或t1.join()等待它结束)。

      1
      2
      3
      4
      5
      6
      7
        static int x;
      Thread t1 = new Thread(()->{
      x = 10;
      },"t1");
      t1.start();
      t1.join();
      System.out.println(x);
  • ⑤线程t1打断t2(interrupt)前对变量的写,对于其他线程得知t2被打断后对变量的读可见(通过
    t2.interrupted 或 t2.isInterrupted)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
      static int x;
    public static void main(String[] args) {
    Thread t2 = new Thread(()->{
    while(true) {
    if(Thread.currentThread().isInterrupted()) {
    System.out.println(x);
    break;
    }
    }
    },"t2");
    t2.start();
    new Thread(()->{
    sleep(1);
    x = 10;
    t2.interrupt();
    },"t1").start();
    while(!t2.isInterrupted()) {
    Thread.yield();
    }
    System.out.println(x);
    }
    • ⑥对变量默认值(0,false,null)的写,对其它线程对该变量的读可见。

    • ⑦具有传递性,配合volatile的防指令重排,有下面的例子:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
        volatile static int x;
      static int y;
      new Thread(()->{
      y = 10;
      x = 20;
      // 写屏障
      },"t1").start();
      new Thread(()->{
      // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
      System.out.println(x);
      },"t2").start();

3、有序性

  • JVM会在不影响正确性的前提下,可以调整语句的执行顺序,如下面一段代码。

    1
    2
    3
    4
    5
    static int i;
    static int j;
    // 在某个线程内执行如下赋值操作
    i = ...;
    j = ...;
    • 可以看到,至于是先执行i还是先执行j,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是:

      1
      2
      i = ...; 
      j = ...;
    • 也可以是:

      1
      2
      j = ...;
      i = ...;
  • 这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。指令重排的前提是,重排指令不能影响结果,禁止指令重排可以使用volatile关键字实现。

4、volatile原理

  • volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
    • 对volatile变量的写指令后会加入写屏障。
    • 对volatile变量的读指令前会加入读屏障。

4.1 保证可见性

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中。
1
2
3
4
5
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
  • 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public void actor1(I_Result r) {
    // 读屏障
    // ready 是 volatile 读取值带读屏障
    if(ready) {
    r.r1 = num + num;
    } else {
    r.r1 = 1;
    }
    }
  • 内存间的交互操作如下:

    1651907505451

    1651907519892

    • 此时如果不加volatile关键字:

      1651907576162

    • 此时如果加了volatile关键字:

      1651907735924

    • 不加volatile的变量只会执行read、load、use、assign操作,即只会在当前线程的工作内存中改变值,而其它线程和主内存无法感知到(虚拟机栈是线程私有的),即不可见;而加了volatile的变量会进行lock、read、load、use、assign、store、write、unlock的操作将变量同步到主存中(lock指令能将缓存的数据写到主内存,并会使在其他线程里缓存了该内存地址的数据无效,即实现了缓存一致性协议;另外,lock指令还会锁总线,锁总线时,其它CPU对内存的读写请求都会被阻塞)

      1651907813392

4.2 保证有序性

  • 有序性定义: 指的是在代码顺序结构中,我们可以直观的指定代码的执行顺序, 即从上到下按序执行。但编译器和CPU处理器会根据自己的决策,对代码的执行顺序进行重新排序。优化指令的执行顺序,提升程序的性能和执行速度,使语句执行顺序发生改变,出现重排序,但最终结果看起来没什么变化(单核)。

  • 有序性问题指的是在多线程(多核)由于执行语句重排序后,重排序的这一部分没有一起执行完,就切换到了其它线程,导致的结果与预期不符的问题。这就是编译器的编译优化给并发编程带来的程序有序性问题。

  • 在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响单线程执行的结果,会影响到多线程并发执行结果的正确性。

  • 通过volatilesynchronizedLock保证一定的有序性,synchronizedLock保证每一时刻只有一个线程可以执行同步代码块,相当于让线程顺序执行同步代码,从而保证有序性。另外,JVM具备一些先天的有序性,即不需要额外的手段就能保证有序性,即Happens-before原则,如果两个操作的执行次序没有办法通过Happens-before原则推导出来,虚拟机进行随意的重排序,那么就不能保证有序性。

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。

    1
    2
    3
    4
    5
    public void actor2(I_Result r) {
    num = 2;
    ready = true; // ready 是 volatile 赋值带写屏障
    // 写屏障
    }
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public void actor1(I_Result r) {
    // 读屏障
    // ready 是 volatile 读取值带读屏障
    if(ready) {
    r.r1 = num + num;
    } else {
    r.r1 = 1;
    }
    }
  • 注意:

    • 不能解决指令交错。
    • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去。
    • 而有序性的保证也只是保证了本线程内相关代码不被重排序。

5、double-checked locking问题

  • 以著名的double-checked locking单例模式为例。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
    if(INSTANCE == null) { // t2
    // 首次访问会同步,而之后的使用没有synchronized
    // synchronized可以解决可见性、原子性问题,而解决有序性问题必须要建立在变量完全在synchronized同步代码块里的情况,这里的synchronized外层的if语句判断条件处使用了INSTANCE变量,所以这里的synchronized并不能完全解决指令重排问题。
    synchronized(Singleton.class) {
    if (INSTANCE == null) { // t1
    INSTANCE = new Singleton();
    }
    }
    }
    return INSTANCE;
    }
    }
    • 以上的实现特点是:

      • 懒惰实例化。

      • 首次使用getInstance()才使用synchronized加锁,后续使用时无需加锁。

      • 有隐含的,但很关键的一点:第一个if使用了INSTANCE变量,是在同步块之外,但在多线程环境下,上面的代码是有问题的,getInstance方法对应的字节码为:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
         0 getstatic #2 <org/example/Singleton.INSTANCE>
        3 ifnonnull 37 (+34)
        6 ldc #3 <org/example/Singleton>
        8 dup
        9 astore_0
        10 monitorenter
        11 getstatic #2 <org/example/Singleton.INSTANCE>
        14 ifnonnull 27 (+13)
        17 new #3 <org/example/Singleton> // 创建对象,将对象引用入栈
        20 dup // 复制一份对象引用入栈
        21 invokespecial #4 <org/example/Singleton.<init>> // 将其中一个对象引用出栈并调用构造方法
        24 putstatic #2 <org/example/Singleton.INSTANCE> // 将另一个对象引用出栈并给Singleton.INSTANCE赋值
        27 aload_0
        28 monitorexit
        29 goto 37 (+8)
        32 astore_1
        33 aload_0
        34 monitorexit
        35 aload_1
        36 athrow
        37 getstatic #2 <org/example/Singleton.INSTANCE>
        40 areturn
      • 重点看上方的第17、20、21和24条指令,也许jvm会优化为:先执行24,再执行21。如果两个线程t1,t2按如下时间序列执行:

        • 关键在于0:getstatic这行代码在monitor控制之外,它就像之前举例中不守规则的人,可以越过monitor读取INSTANCE变量的值。
        • 这时t1还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么t2拿到的是将是一个未初始化完毕的单例。
        • 对INSTANCE使用volatile修饰即可,可以禁用指令重排,但要注意在JDK 5以上的版本的volatile才会真正有效。
  • 解决后的代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public final class Singleton {
    private Singleton() { }
    private static volatile Singleton INSTANCE = null;
    public static Singleton getInstance() {
    // 实例没创建,才会进入内部的 synchronized代码块
    if (INSTANCE == null) {
    synchronized (Singleton.class) { // t2
    // 也许有其它线程已经创建实例,所以再判断一次
    if (INSTANCE == null) { // t1
    INSTANCE = new Singleton();
    }
    }
    }
    return INSTANCE;
    }
    }

    getInstance()的字节码指令为:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
     // -------------------------------------> 加入对 INSTANCE 变量的读屏障
    0 getstatic #2 <org/example/Singleton.INSTANCE>
    3 ifnonnull 37 (+34)
    6 ldc #3 <org/example/Singleton>
    8 dup
    9 astore_0
    10 monitorenter -----------------------> 保证原子性、可见性
    11 getstatic #2 <org/example/Singleton.INSTANCE>
    14 ifnonnull 27 (+13)
    17 new #3 <org/example/Singleton>
    20 dup
    21 invokespecial #4 <org/example/Singleton.<init>>
    24 putstatic #2 <org/example/Singleton.INSTANCE>
    // -------------------------------------> 加入对 INSTANCE 变量的写屏障
    27 aload_0
    28 monitorexit ------------------------> 保证原子性、可见性
    29 goto 37 (+8)
    32 astore_1
    33 aload_0
    34 monitorexit
    35 aload_1
    36 athrow
    37 getstatic #2 <org/example/Singleton.INSTANCE>
    40 areturn
    • 如上面的注释内容所示,读写volatile变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

      • 可见性:
        • 写屏障(sfence)保证在该屏障之前的t1对共享变量的改动,都同步到主存当中。
        • 而读屏障(lfence)保证在该屏障之后t2对共享变量的读取,加载的是主存中最新数据。
      • 有序性:
        • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
        • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前。
      • 更底层是读写变量时使用lock指令来多核CPU之间的可见性与有序性。