Java并发编程——Java内存模型

本文最后更新于:4 年前

引言

在多线程并发环境中,如何确保线程对共享数据的访问和修改是正确而一致的,是一个核心且复杂的问题。Java 内存模型(JMM) 就是为了解决这一问题而引入的抽象约定。本文将带你从 JMM 的基本概念出发,逐步理解并掌握 volatile 关键字的语义,以及 happens-before 规则对并发程序正确性的重大意义。

JMM 概述

JMM,全称为 Java 内存模型(Java Memory Model),是 Java 虚拟机(JVM)中的一种抽象模型,规定了线程如何与内存交互。

主内存与工作内存

JMM 定义了两种内存区域:

  • 主内存(Main Memory)
    • 这是所有线程共享的内存区域。
    • 所有线程的共享变量都存储在主内存中,线程需要从主内存中读取共享变量,并将修改后的值写回主内存。
  • 工作内存(Working Memory)
    • 每个线程有自己独立的工作内存(线程本地内存)。
    • 线程的工作内存存储该线程使用的变量副本(包括共享变量的副本)。线程执行时对变量的操作首先在工作内存中进行,最终通过同步机制与主内存交换数据。
    • 线程的工作内存并不是一个物理内存区域,而是一个抽象的概念,表示线程在执行时对数据的本地副本。

内存之间的交互

  • 读取和写入:线程通过以下操作与主内存交互:
    • 写入(write):将工作内存中的数据写回到主内存。
    • 读取(read):从主内存读取数据到工作内存。
  • 每个线程对共享变量的操作(如读取、写入)最终会通过工作内存与主内存之间的交换来影响其他线程。

主要目标

  • 可见性:确保一个线程对共享变量的修改能够及时被其他线程看到,避免“脏读”。
  • 原子性:保证某些操作是不可中断的,要么完全执行,要么完全不执行,避免竞态条件。
  • 有序性:控制线程执行操作的顺序,避免由于CPU和JVM优化造成的指令重排序问题。

设计哲学

  • JMM 的设计并不关注如何实现内存管理,而是提供了一种高层的抽象视图来规范线程间的内存交互行为。
  • 它的设计旨在保证并发程序的正确性,同时尽可能让程序的执行效率得到优化。

共享变量

问题

多个线程可以访问和修改的变量。Java 中的实例字段、静态字段和数组元素都可以作为共享变量。

问题:多个线程同时访问共享变量时,可能会出现数据一致性问题,导致线程间的操作相互影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SharedVariablesTestClient {

private static boolean b = true;

public static void main(String[] args) throws InterruptedException {

new Thread(() -> {
while (b) {
}
}, "t1").start();

Thread.sleep(3000);
b = false;

System.out.println("main: b -> " + b);

}

}

运行程序后,程序应该会在 3 秒后打印

1
main: b -> false

但之后程序并没有停止,即线程 t1 中的 while 条件仍然成立,线程 t1 中的变量 b 似乎与主线程的变量 b 值不相同。

原因分析

  1. 工作内存与主内存的隔离
    • 每个线程都有自己的工作内存(缓存),主内存是共享的。线程 t1 在执行时可能会从工作内存中读取 b 的值,而不是每次都从主内存中获取最新的值。如果主线程修改了 b,线程 t1 的工作内存并不会立刻被更新。
  2. 缺乏可见性保证
    • 默认情况下,Java 中的普通变量(非 volatile)在多个线程间并没有同步更新机制。虽然主线程修改了 b 的值,但是 t1 线程并没有强制去主内存中读取这个值,它可能一直在使用缓存中的 b 值。由于没有同步机制,这个修改对于线程 t1 是不可见的。
  3. JMM(Java内存模型)
    • JMM 规定了如何在多线程中读取和写入共享变量,但默认情况下,它没有保证线程之间的立即可见性。也就是说,线程 t1 对共享变量 b 的读取可能不会反映主线程对 b 的修改,直到线程 t1 再次从主内存读取该变量。
sequenceDiagram
    participant t1 as 线程 t1
    participant main as 主线程
    participant main_mem as 主内存
    participant t1_mem as t1 工作内存

    main->>main_mem: 线程主内存中的 b = true
    main->>t1_mem: 线程 t1 工作内存中的 b = true
    t1->>t1_mem: t1 读取 b (b = true)
    t1->>t1: t1 执行 while 循环 (b = true)
    main->>main: 主线程阻塞 3 秒
    main->>main_mem: 主线程将 b 修改为 false
    main->>main: 打印 b = false
    t1->>t1_mem: t1 检查工作内存中的 b (b = true)
    t1->>t1: t1 继续执行 while 循环 (b = true)
    note right of t1: 直到下一次读取主内存中的 b,t1 才能看到主线程的修改

总结:在多线程程序中,线程间的共享数据可能会因为工作内存和主内存之间的交互机制而出现数据不一致的情况。例如:

  • 一个线程修改了某个共享变量的值,但由于缓存和优化机制,其他线程可能并不能立即看到该修改。
  • 线程执行过程中,CPU 可能对指令进行重排序,导致线程执行的顺序与代码的书写顺序不一致,可能引发错误。

volatile

概述

volatile 是 Java 中的一个关键字,用于修饰变量,表示该变量在多个线程中是共享的。使用 volatile 修饰的变量具有特殊的内存语义,可以在一定程度上保证变量的可见性和有序性,但不保证操作的原子性。

作用和特性

保证可见性

可见性指的是当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。默认情况下,线程可能会将变量缓存在自己的工作内存中,导致其他线程无法及时感知到这个变化。使用 volatile 关键字可以确保对变量的写操作对所有线程立即可见。

保证有序性

有序性指的是程序执行的顺序与代码书写的顺序是否一致。编译器和处理器可能会对指令进行重排序以优化性能,这可能导致程序执行的顺序与预期不符。volatile 关键字禁止特定类型的指令重排序,确保变量的读写操作按照代码的顺序执行,从而增强程序的可预测性。

不保证原子性

原子性指的是一个操作在执行过程中不可被中断,要么全部执行,要么完全不执行。volatile 并不保证操作的原子性,例如自增操作(i++)不是原子性的,即使 i 被声明为 volatile,多个线程同时对其进行操作仍然可能导致竞态条件。

性能较好

volatile 是一种轻量级的同步机制,如果仅需可见性,而无需保证复合操作的原子性,相比于 synchronized,它对性能的开销较小。它不需要获取和释放锁,因此适用于需要保证可见性的简单场景。

工作原理

影响Java 内存模型 (JMM)

volatile 关键字通过以下方式影响 JMM 的行为:

  1. 可见性保证:对 volatile 变量的写操作会立即刷新到主内存,读操作则会从主内存中重新读取。
  2. 禁止指令重排序:在 volatile 变量前后的指令不会被重排序,以确保操作的有序性。

内存屏障 (Memory Barriers)

内存屏障是一种低级别的指令,用于限制 CPU 和编译器对指令的重排序。volatile 关键字在实现上依赖于内存屏障,通过在读写 volatile 变量时插入内存屏障,确保操作的顺序性和可见性。

具体来说:

  • 写屏障(Write Barrier):在写 volatile 变量之前,会插入一个写屏障,确保在此屏障之前的所有写操作在此屏障之后的写操作之前完成。
  • 读屏障(Read Barrier):在读 volatile 变量之后,会插入一个读屏障,确保在此屏障之前的所有读操作在此屏障之后的读操作之前完成。

这种屏障机制有效地防止了编译器和 CPU 对 volatile 变量的相关操作进行重排序。

应用场景

标志位

volatile 常用于定义标志位,控制线程的执行。例如,使用 volatile 来控制线程的停止与启动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class VolatileFlagExample {

private static volatile boolean running = true;

public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (running) {
// 执行任务
}
System.out.println("Worker 线程停止。");
});
worker.start();

Thread.sleep(3000);
running = false;
System.out.println("主线程设置 running 为 false。");
}
}

代码解析

  1. 定义静态变量 running,作为 worker 线程的运行条件。
  2. 主线程睡眠 3 秒后,将 worker 线程的运行条件 running 置为 false
  3. 由于 running 使用了 volatile 修饰,保证了可见性,所以 worker 线程的读操作会从主内存中读取到最新的值。

双重检查锁定

volatile 关键字在双重检查锁定(Double-Checked Locking)模式中至关重要,确保实例在初始化时的可见性和有序性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton {

private static volatile Singleton instance;

private Singleton() {
}

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

}

代码解析

  1. 定义静态变量 instance
  2. 私有化 Singleton 类的构造器。
  3. 第一次检查 instance == null,判断是否已有实例,有则返回,同时避免每次都进入同步块。
  4. synchronized 代码块确保在多线程环境下只有一个线程可以执行实例的创建逻辑。
  5. 第二次检查 instance == null,判断其他线程未创建实例(通过 volatile 保证线程之间的可见性)。

对比

volatile synchronized
可见性 保证 锁机制保证
原子性 不保证 可保证
性能 轻量,开销小 涉及锁,开销大
适用场景 状态标志位和单一变量的简单同步 对多个变量或复杂操作进行原子性同步

3种关键特性保证

原子性(Atomicity)

  • synchronized:同步机制保证了在同一时间只有一个线程能够执行同步块中的代码,从而保证了操作的原子性。

可见性(Visibility)

  • volatile:对 volatile 变量的写操作会立即对其他线程可见。volatile 变量的读取总是从主内存中获取,而不是从线程的本地缓存中获取,写操作会直接写回到主内存,保证了修改的及时可见性。
  • synchronized:当一个线程获取了锁并修改了共享变量,其他线程在释放锁之前无法读取该变量,从而确保了变量对其他线程的可见性。
  • final:对于 final 修饰的变量,特别是对象的引用,保证了构造完成后它们的可见性。final 变量的初始化具有特别的内存访问语义,确保了它在构造函数完成后,对其他线程可见。

有序性(Ordering)

  • volatilevolatile 关键字禁止特定类型的指令重排序,确保变量的读写操作按照代码的顺序执行。

  • synchronizedsynchronized 语句中的操作会在锁的获取和释放时加上内存屏障,保证同步块中的代码按顺序执行。

happens-before原则

基本概念

在 Java 内存模型中,happens-before 是用来描述操作之间的执行顺序的一种规则。它是为了保证多线程环境中操作的有序性,并确保正确的可见性和同步。具体来说,happens-before 原则表示在一个线程中的某个操作(例如变量的写入)与另一个线程中的某个操作(例如变量的读取)之间有一个明确的先后关系,前者“发生在”后者之前。

  • 如果操作A happens-before 操作B,则可以确保操作A对其他线程的可见性以及操作B在执行时的正确性。简单来说,操作A的结果会在操作B之前对其他线程可见。

规则

1. 程序顺序规则(Program Order Rule)

  • 在单个线程内,代码执行顺序是线性的,即一个操作在另一个操作之前发生。
  • 举例:在同一线程内,如果你执行 a = 1; b = 2;,那么 a = 1 发生在 b = 2 之前。

2. 监视器锁规则(Monitor Lock Rule)

  • 一个线程释放的锁 happens-before 另一个线程获取该锁。
  • 举例:如果线程A执行 lock.lock() 获取锁,并且线程B执行 lock.lock() 获取同一个锁,则线程A释放锁的操作发生在线程B获取锁的操作之前。

3. volatile 变量规则(Volatile Variable Rule)

  • volatile 变量的写操作 happens-before 任何后续对该变量的读操作。
  • 举例:如果线程A将 volatile 变量 x 设置为 true,那么在线程B读取 x 时,线程B能看到线程A的最新值。

4. 传递性规则(Transitivity Rule)

  • 如果 A happens-before B,且 B happens-before C,则 A happens-before C
  • 举例:如果 a = 1 happens-before b = 2,并且 b = 2 happens-before c = 3,则 a = 1 happens-before c = 3

5. 线程启动规则(Thread Start Rule)

  • 线程的 start() 操作 happens-before 线程中的任何后续操作。
  • 举例:如果线程A调用 threadB.start() 启动线程B,那么线程A在 start() 之后的操作会 happens-before 线程B中的操作。

6. 线程中断规则(Thread Interrupt Rule)

  • 对线程的中断操作 happens-before 线程检测到中断的操作。
  • 举例:如果线程A执行 threadB.interrupt() 中断线程B,那么线程B中的 InterruptedExceptionThread.interrupted() 操作会在 interrupt() 操作之后发生。

7. 线程结束规则(Thread Join Rule)

  • 线程的 join() 操作 happens-before 线程结束的任何后续操作。
  • 举例:如果线程A调用 threadB.join() 等待线程B执行完成,那么线程B的结束会 happens-before 线程A接下来执行的操作。

8. 写-读规则(Write-Read Rule)

  • 对一个变量的写操作 happens-before 对同一个变量的后续读操作,如果写和读操作之间有同步机制(如 synchronizedvolatile)。
  • 举例:线程A对 volatile 变量 x 写入值后,线程B读取 x 时,线程B一定能看到线程A的最新写入值。

重要性

  • 保证线程间的可见性: 在多线程程序中,线程可能会在自己的工作内存中缓存变量,导致一个线程对变量的修改对另一个线程不可见。happens-before 的原则保证了某些操作的可见性,避免了线程之间的数据不一致。
  • 避免数据竞争和竞态条件happens-before 确保线程间操作的顺序性和同步,避免了由于不确定的操作顺序而产生的数据竞争问题。
  • 保证程序的正确性: 通过 happens-before 规则,JMM确保了在并发执行时,不同线程之间的执行顺序是正确的,从而避免了执行结果不一致的问题。

总结

Java 内存模型是并发编程的基础,它将复杂的 CPU 缓存、编译器优化和指令重排序等底层细节,抽象出可见性、原子性和有序性等关键问题,并以 happens-before 规则给予统一的解决方案。通过为共享变量加上 volatile 关键字,可以确保线程间的可见性,部分禁止指令重排序,但无法满足复杂场景对原子性的需求;而像 synchronized 或其它锁机制则进一步强化了原子性,但也带来了更高的性能开销。在实际开发中,需要结合程序对并发程度、性能和安全的要求,来选择合适的同步手段。理解好 JMM,以及如何合理利用 volatilehappens-before 规则,将是写出高质量并发程序的关键。