Java 内存模型的知识点总结

81 阅读14分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情

1 前言:CPU 的缓存一致性和乱序执行优化

我们知道 CPU 中有高速缓存(Cache),CPU 从高速缓存读取数据的速度远远大于从内存中读取数据。

因此,在 CPU 工作过程中,CPU 会将需要计算的数据保存到 Cache 中,减少从内存中读取数据的次数,从而提升运算速度。

这种做法虽然带来了运算速度上的提升,但却带来了新的问题——“缓存一致性”。在 CPU 工作过程中,缓存的数据可能已经失效了,或者缓存的数据更新后,内存中的数据没有即时得到更新,被另一个 CPU 读取了。

因此为了解决“缓存一致性”的问题,需要引入其他协议,诸如:MSI、MESI 等等。但这不是本文的重点,我们只需要知道,计算机中,为了提升计算速度,解决 CPU 与 IO 之间的速度,CPU 利用 Cache 缓存数据,同时为了解决“缓存一致性”,CPU 访问缓存需要遵循协议,确保数据一致。

此外,CPU 还有一个特性,为了使得 CPU 的运算单元得到充分利用,CPU 可能会对指令进行乱序执行优化,但同时也确保了结果与顺序执行是一致的。

例如:

a = 1
b = 3
a = a+2

乱序执行后,代码的顺序可能变为
a = 1
a = a+2		
b = 3

在 JVM 中也存在类似的特性,如即时编译器会对虚拟机指令进行“重排序”。

2 什么是 Java 内存模型

Java 内存模型定义了程序中各种变量的访问规则:

  • 如何将变量值存储到内存
  • 如何从内存中取出变量值
  • 这里的变量值包括实例字段、静态字段、数组对象元素等共享变量,不包括线程私有的方法参数、局部变量

Java 有一个特性,就是一次编写,到处运行,因为 Java 代码是运行于虚拟机上的,而不是具体的机器上的。

也因此,JVM 虚拟机在不同的机器平台上,相同的字节码指令所对应的字节码指令也就不同,向 Java 程序员屏蔽了机器指令的细节。

在多线程情况下,如果没有 Java 内存模型的规定,一个程序在某个平台运行正常,在另一个平台上运行却频繁出错。

💡 即 Java 内存模型定义了程序中访问变量的规则,帮助我们屏蔽了各种硬件和操作系统访问内存的差异,实现了 Java 程序在各种平台下都能达到一致的内存访问效果,确保多线程的运行结果是可预期的

3 主内存&工作内存

Java 内存模型引入了两个概念主内存和工作内存,是对底层硬件的抽象,屏蔽了底层硬件细节。

  • 主内存:所有的变量都保存在主内存中
  • 工作内存:每个线程都有自己的工作内存,保存主内存中的变量副本
    • 线程只能在工作内存中操作变量,而不能在主内存中对变量直接修改读写
    • 从主内存中获取变量并保存为副本

主内存可以大致理解为 Java 内存区域的共享区域:堆、方法区,但实际上它对应到物理内存上。

工作内存可以大致理解为线程私有的虚拟机栈、本地方法栈,但实际上它对应到处理器的缓存、寄存器上。

也正因为 Java 内存模型引入主内存和工作内存,因此 JVM 中也同样会出现类似前言所提到“缓存一致性”问题。

多个线程使用同一个共享数据,但由于工作内存中的修改没有及时同步到主内存中,其他线程因此获取到过时数据,导致出错,这就是“可见性”问题。

4 指令重排序

JVM 会对指令进行优化,改变指令的执行顺序,从而优化执行速度。

指令的执行顺序发生了改变,但是确保了最终的结果是与顺序执行的结果是一致的。

通过以下例子查看指令重排序带来的好处

// 程序代码如下
a = 3;
b = 2;
a = a+1;
// 假设对应的指令如下
load a;
set to 3;
store a;

load b;
set to 2;
store b;

load a;
set to 4;
store a;

// 指令重排序后,可能为
a = 3;
a = a+1;
b = 2;
// 假设对应的指令如下,可以发现指令的执行次数少了
load a;
set to 3;
set to 4;
store a;

load b;
set to 2;
store b;

在单线程情况下,这样子并没有什么不妥,但在多线程情况下,这会带来难以预料的错误。

关于 Java 中的指令重排序在多线程下可能引发的问题,可以用下面的代码演示。

代码的主要功能如下:

a = b = x = y = 0;
// 线程 1 执行
b = 1;
x = a;
    
// 线程 2 执行
a = 1;
y = b;

//在多线程、没有加锁的情况下,由于线程执行顺序不同可能发生的情况如下:
1. x = 0, y = 1
2. x = 1, y = 0
3. x = 1, y = 1
    
// 在发生了指令重排序的时候,可能出现如下情况:
4. x = 0, y = 0
//即 第 4 行代码被优化到第 3 行代码前面,第 8 行代码被优化到第 7 行代码前面

Java 代码演示上面的第 4 种情况,即指令重排序带来的问题


public class InstructionReorder {

    private static int a = 0, b = 0, x = 0, y = 0;

    public static void main(String[] args) throws InterruptedException {
        while (true) {
            a = b = x = y = 0;
            // CountDownLatch 是并发工具包下的一个并发工具类,可以设置一个计数,当计数为零时,
            // 令被 CountDownLatch 阻塞的线程同时运行 
            CountDownLatch cdl = new CountDownLatch(3);
            Thread t1 = new Thread(() -> {
                try {
                    cdl.countDown();
                    cdl.await();
                    b = 1;
                    x = a;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            Thread t2 = new Thread(() -> {
                try {
                    cdl.countDown();
                    cdl.await();
                    a = 1;
                    y = b;
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t1.start();
            t2.start();
            cdl.countDown();
            t1.join();
            t2.join();
            System.out.println(x + "," + y);
            // 发生了指令重排序,退出循环
            if (x == 0 && y == 0) {
                break;
            }
        }
    }
}

关于如何解决这部分代码所带来的重排序问题,具体可看 6.3 有序性

5 volatile 关键字

volatile 是 Java 提供的最轻量级的同步机制,相比于 synchronizedLock 来说更轻量,没有上下文切换带来的开销。

volatile 只能修饰变量,而不能修饰代码块。

volatile 具有如下特性:

  1. 保证修饰的变量对所有线程的可见性

    1. 读取一个被 vlolatile 修饰的变量时,会先使工作内存中的副本失效,从而强制从主内存中读取数据
    2. 写一个被 vlolatile 修饰的变量,会立即刷新到主内存中
  2. 不保证原子性

    vlolatile 只能修饰变量,而 Java 中对于运算操作符并非原子性的,例如 i++,它由多个字节码指令完成

// 对一下代码使用 javap 进行反编译,查看字节码指令
public class Increment {
    private static int i = 0;
    public static void main(String[] args) {
        i++;
    }
}

// 反编译结果,i++ 由 0、3、4、5 这几个指令组成
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field i:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field i:I
         8: return

​ Java 中的赋值操作是原子性的,因此如果使用 volatile 变量直接赋值,这个操作也是可以保证原子性的

​ 除此之外的对 volatile 的操作,如果不满足以下条件,那么就要考虑通过 synchronized 或者 Lock 来保证原子性:

  1. 运算结果不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值,下面的情况需要考虑加锁保证原子性
volatile static int a = 10;

// 线程 A 的操作:
a += 3;		// 依赖变量的当前值,无法保证线程安全,a 可能为 13,也可能为 4

// 线程 B 的操作:
a = 1;		// 赋值操作,原子性的,线程 B 的结果一定是 a = 1

  1. 变量不需要其他的状态变量共同参与不变的约束,例如下面的情况就需要考虑加锁保证原子性:
volatile static int a = 1;
volatile static int b = 2;

// 线程 A 的操作:
while (a < b) {
    // do something
}

// 线程 B 的操作:
a += 1;		// 有这么一瞬间,使得 a == b,从而导致 线程 A 结束循环
b += 1;
  1. 禁止指令重排序,JVM 能够确保 volatile 变量修改后同步到主内存时,其前面的操作都已经完成,而不会被重排序到 volatile 变量修改操作的后面。

6 三个特性

6.1 原子性

原子性,即某个操作是不可分割的,要么全部完成,要么都不完成。

Java 中对于基本数据类型的这些操作是保证具备原子性的:

  • read:从主内存中读取变量值到工作内存中
  • load:将读取到变量值存储到工作内存中
  • assign:将一个值赋值给工作内存中变量
  • use:当遇到一个字节码指令需要使用变量值时,将变量值传递给执行引擎
  • store:将工作内存中的变量值传递到主内存中
  • write:将变量值写入到工作内存中

但是 long、double 两种基本数据类型比较特殊,JVM 规范中并没有规定这两种数据类型的操作一定要是原子性的,在 32 位系统中,它们的操作是非原子性的,在 64 位系统中,它们的操作是原子性的,如果在多线程下,为了确保对这两种数据类型的操作具备原子性,可以使用 volatile 修饰变量。

具体可以参阅这篇文章:long 和 double 的原子性问题 - 简书 (jianshu.com)

以上是 Java 的基本数据类型原本就具备的原子性操作,此外,引用类型的赋值操作也是原子性的,以及并发工具包下的原子类诸如:AtomicIntegerAtomicDouble 等的操作也是原子性的。

除此之外的操作,例如要保证一个代码块内的操作具备原子性,那么可以使用 synchronized 关键字,确保同一时间段只有一个线程在操作。

6.2 可见性

可见性即,当一个线程修改了一个变量,其他线程能够立即得知这个修改。

通过以下代码演示可见性问题,如果一个线程修改了变量,另一个线程没有得知这个修改,会带来什么问题:

public class Visibility {

    private static boolean keepRunning = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (keepRunning) ;
            System.out.println("keepRunning 是 false,线程停止运行");
        });
        t.start();
        Thread.sleep(2000);
        // 令子线程结束运行
        keepRunning = false;
        // 等待子线程运行结束
        t.join();
    }

}

运行以上代码,会发现修改了 keepRunning 的值后,子线程并不能正常结束,这是因为主线程的修改,子线程并不能立即得知。

解决办法,使用 volatile 修饰 keepRunning,利用 volatile 保证可见性的特性:

private volatile static boolean keepRunning = true;

再次运行代码,可以看到程序正常结束。

synchronized 也可以保证可见性,这是 JVM 保证的:

  • 对一个变量进行加锁操作之前,必须先从主内存中刷新获取变量值
  • 对一个变量进行解锁操作之前,必须将工作内存的变量同步回主内存

final 关键字也能保证可见性,但前提是构造方法执行过程中,没有将 this 实例传递出去(对象逸出)。因为在构造执行一半的过程中,一些实例字段还未初始化完成,此时传递出去,那么其他线程可能得到错误的数据。被 final 修饰的变量一旦初始化完成,那么其他线程都能看到这个常量。

6.3 有序性

  • 如果在本线程中观察,那么线程中的操作是有序的,串行执行的。
  • 如果在一个线程中观察另一个线程,那么所有操作都是无序的,这是因为:
    • 指令重排序,并发的结果无法预期
    • 工作内存和主内存同步延迟,两条线程的数据并不一致

为了解决在多线程下,保证有序性,我们可以分别使用 volatilesynchronized 来保证。

关于 volatile 是如何保证有序性的,来自于它的一个特性:禁止指令重排序,对 volatile 变量之前的操作都已完成,而其之后的操作不会被重排序到其前面。上面演示指令重排序带来的问题,可以如此解决:

private volatile static int a = 0, b = 0;
private static int x = 0, y = 0;

之后再去运行代码,便不会出现 x = 0, y = 0 的情况。

synchronized 能够保证有序性是因为,同步代码块同一时间只有一个线程执行,因此里面的操作都是串行的。

7 先行发生原则

先行发生原则(Happens-Before),是判断是否存在数据争用、线程是否安全的重要原则,对于编写多线程十分有帮助,可以帮助我们更轻松地编写多线程应用,而不用陷入 Java 内存模型的各种细节中。

所谓先行发生原则,即操作 A 在操作 B 之前完成,操作 B 能够得知操作 A 所做的修改。

先行发生原则有如下 8 条规则:

  • 程序次序规则:在一个线程内,按照控制流顺序(考虑上分支跳转、循环等,不仅仅是程序代码顺序),书写在前面的操作一定先行发生于后面的操作。
  • 管程锁定规则:一个 unlock 解锁操作一定先行发生于后面对同一个锁的 lock 加锁操作
  • volatile 变量规则:对一个 volatile 变量的写操作先行发生于对其的读操作
  • 线程启动规则:Thread 实例的 start() 方法先行发生于此线程的每一个动作,即先行发生于 run()
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,例如 run() 中的所有操作一定先行发生于 Thread.join()Thread.isAlive()
  • 线程中断规则:对线程 interrupted() 一定先行发生于被中断线程检测到中断通知(即捕获到中断异常,或者 Thread.isInterruped() 返回 true)
  • 对象终结规则:一个对象的构造方法执行结束一定先行发生于 finalize 方法的开始
  • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C

💡 如果两个操作不满足以上的规则,则它们的操作的顺序性是无法保障的,它们是无序的,我们无法预料它们会具体出现那种情况。

先行发生原则并不是强调时间上的顺序,例如:

// 有如下两个方法
methodA();
methodB();

// 线程 1 先调用 methodA()

// 线程 2 再调用 methodB()

// 线程 1 和线程 2 得到 CPU 的调度是无法确定的,因此虽然线程 1 先调用了 methodA(),但 methodA() 并不一定先比 methodB() 执行完毕
// 此外,借助上面的规则判断,这两个操作发生于两个线程,不符合程序次序规则;也没有加锁、解锁操作,因此也不符合管程锁定规则;其他规则也不符合,因此它上面的操作并不是安全的

8 总结

Java 内存模型有三个特性:

  • 原子性
  • 可见性
  • 有序性

Java 内存模型定义了一套规则去操作内存中的变量,如何取、如何写,确保在多种平台下,Java 程序的多线程操作是可预期的。

Java 抽象出了主内存和工作内存:

  • 主内存存放所有变量
  • 每个线程都有自己的工作内存
  • 线程只能在工作内存中修改变量值,再将变量值同步回主内存
  • 不能直接在主内存中操作变量值

Java 具有指令重排序,可以优化程序运行,但是多线程开发可能带来问题。

volatile 关键字可以修饰变量,是一种轻量级的同步机制:

  • 它确保了某个变量的可见性,其他线程能够观察到某个线程对它的修改
  • 同时禁止了指令重排序,在操作volatile变量的之前的操作,都能被操作volatile变量即之后的操作观察到。
  • volatile 能够确保 longdouble 类型的操作是原子性的

synchronized 关键字可以保证原子性、可见性、有序性。

编写程序时,可以借助先行发生原则判断两个操作是否线程安全,帮助我们开发多线程应用。