JVM⑥Java内存模型(JMM)


Java 内存模型

JMM:Java Memory Model

与内存结构(运行时数据区等)是不同的两个概念

作用让 Java 程序在所有平台具有一致的内存访问效果(而不受硬件和操作系统影响)

  • 让 Java 的并发内存访问操作不会产生歧义。
  • JVM 能自由利用硬件的特性,获取更好的执行速度。

主内存与工作内存

JMM 定义了程序中各种变量的访问规则

这里的变量不包括局部变量和方法参数

主内存所有变量都存储在主内存

工作内存每个线程有自己的工作内存,其中保存了该线程所用变量的主内存副本

  • 线程对变量的操作(读取、赋值)只在工作内存中进行,不能直接读写主内存中的数据。

  • 线程之间也无法直接访问对方工作内存的变量,需要通过主内存来传递。

    image-20220306150024740

说明:主内存与工作内存的概念,与运行时数据区中的堆栈等结构,是不同层次的概念。

两个概念之间基本没有任何关系,如果一定要勉强对应

  • 主内存:对应堆中的对象实例数据部分
  • 工作内存:对应虚拟机栈的部分区域

三大特性

JMM 围绕并发过程中这三个特性来建立:原子性(Atomicity)、可见性(Visibility)、有序性(Ordering)

原子性

举个例子

两个线程,对一个初值为 0 的静态变量,分别作自增和自减,结果是 0 吗?

分析字节码

  • getstatic:获取静态变量 i 的值

  • iconst_1:从常量池加载一个常量 1

  • iadd / isub:加法/减法

  • putstatic:将修改后的值,赋值给静态变量 i

    // i++ 对应字节码
    getstatic i
    iconst_1
    iadd
    putstatic i
        
    // i-- 对应字节码
    getstatic i
    iconst_1
    isub
    putstatic i
    

结果可能是正数、负数、0。

在多线程情况下,对静态变量 i 的操作不是原子性。

假如 8 行字节码交错执行,就可能出现结果非 0 的情况。

解决方法

同步关键字synchronized

synchronized(Object) {
    // 要求原子性操作的代码
}

可见性

举个例子:退不出的循环

线程 t 由于 run == true 进入循环。

现象:主线程将 run 设为 false,但线程 t 没有退出循环。

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(()->{
        while(run){
            // ....
        }
    });
    t.start();
    Thread.sleep(1000);
    run = false;
}

浅分析一下

  • 线程 t 开始时,从主内存中将 run 的值读到工作内存。
  • 由于线程对变量的操作只在工作内存中进行,不能直接读写主内存中的数据。
  • 主内存中的数据改变,而线程 t 只是读取本地内存中的 run,没有从主内存中再次读取 run 值

解决方法

易变关键字volatile

  • 修饰成员变量和静态成员变量
  • 要求线程从主内存中读取变量值,而不是从工作缓存

注意synchronized 也可以保证原子性,但效率低

有序性

举个例子:诡异的结果

两个线程分别执行以下两个方法,问 result 的最终结果?

int result = 0;

int num = 1;
boolean ready = false;

// 线程t1执行
public void m1() {
    if(ready) {
        // ①
        result = num + num;
    } else {
        // ②
        result = 1;
    }
}
// 线程t2执行
public void m2() {
    // ③
    num = 2;
    // ④
    ready = true;
}

一共有以下三种情况:结果可能是 1 或 4。

  1. 先进入 m1():条件假,进入 else 执行 ②

    image-20220306154058047

  2. 先进入 m2():执行③,④还未执行;此时进入 m1():条件假,执行②

    image-20220306154156466

  3. 先进入 m2():执行③④;再进入 m1():条件真,执行①

    image-20220306154347866

还有一种情况:result == 2

指令重排:运行期优化的策略,JVM 会将容易执行的代码优先执行。

  • 先进入 m2():执行④,③未执行

  • 此时进入 m1():条件真,执行①

    image-20220306154849717

解决方法

volatile:可以禁用指令重排。

经典应用:——双重检查加锁(double-checked locking)

分析:如果不加 volatile 关键字

分析字节码instance = new DoubleChecked();

  • new:创建对象实例。分配堆内存,将对象引用压入操作数栈

  • dup:复制操作数栈的栈顶数据,用于调用初始化(不理解的可以复习)

  • invokespecial:初始化

  • putstatic:将对象引用赋值给变量 instance。

    0: new #2
    3: dup
    4: invokespecial #3
    7: putstatic #4
    

如果出现指令重排,将 4 和 7 的顺序调换。

导致赋值给 instance 的对象实例尚未初始化(null)。

假设线程 t1 执行方法时发生指令重排,线程 t2 再调用该方法时,判断 instance != null,方法返回一个未完成初始化的 instance 。

到这里,就可以体会到 volatile 关键字的作用了。

----

对 JVM 的入门学习暂时告一段落。

复习的时候会对 JVM 进行更系统性的学习,对所有笔记进行补充和改进。

JVM