enyang
enyang
Published on 2025-12-05 / 0 Visits
0
0

Java 多线程<2>——内存模型与可见性

1 Java 内存模型(JMM)与可见性

Java 内存模型(Java Memory Model,简称 JMM)是 Java 为了保证多线程程序中线程间共享变量的可见性、原子性和有序性而设计的一套规范。JMM 定义了多个线程共享数据时如何在不同的线程之间传递变量的值,以及哪些操作可以在多线程环境下被重排序。它解决了并发编程中涉及的数据一致性、可见性和执行顺序的问题。

JMM 并不是一个具体的硬件实现,而是一个抽象的模型,它规定了多线程环境下对共享变量的操作规范。

1 计算机的内存层级与并发问题

在理解 JMM 之前,首先理解并发编程中可能出现的一些问题,尤其是内存可见性和指令重排序的问题。

1.1 CPU 缓存带来的内存可见性问题

为了提高效率,CPU 会将内存中的数据缓存在自己的高速缓存中,避免每次访问都直接从主内存读取。如果多个线程在不同的 CPU 核心上运行,每个 CPU 可能会有自己的一份数据副本,这就导致了内存可见性问题。

例如,假设线程 A 修改了一个共享变量的值,线程 B 可能不会立刻看到这个修改的结果,因为线程 B 可能读取的是该变量的本地缓存副本,而不是主内存中的最新值。

这种情况下,我们就需要通过某种机制确保线程间对共享变量的修改能够及时同步。

1.2 指令重排序引起的有序性问题

编译器和 CPU 为了优化性能,可能会对程序中的指令进行重排序。例如,编译器可能会将两个顺序执行的语句调整为不同的执行顺序,以提高执行效率。虽然这在单线程中不会有问题,但在多线程环境中,这种指令重排可能会导致意料之外的结果。

例如,以下代码中,a 和 b 的赋值操作可能会被编译器重排:

int a = 1;
int b = 2;

实际执行顺序可能是:

b = 2;
a = 1;

如果多个线程同时操作共享变量,指令的重排序可能导致数据不一致或程序行为异常。


2 JMM 的核心问题

Java 内存模型的目的是解决并发编程中常见的三大核心问题:可见性、原子性和有序性。这三者是多线程程序中最常遇到的并发问题。

2.2 可见性问题

在 Java 中,多个线程共享堆内存中的数据,但每个线程都有自己的工作内存(即线程的缓存)。线程对共享变量的操作首先会在其工作内存中进行,然后再写回主内存。这种机制可能导致线程 A 修改了共享变量,但线程 B 却读取到过时的数据,因为线程 B 可能从自己本地的工作内存中读取到旧值,而不是从主内存中读取最新的值。

例如,以下代码片段展示了一个典型的可见性问题:

boolean flag = false;

Thread A:
flag = true;

Thread B:
while (!flag) {
    // 一直循环等待
}

由于缺乏同步机制,线程 B 可能永远无法读取到线程 A 修改的 flag 值,导致死循环。

解决可见性问题的方式通常是通过 volatile 关键字或者其他同步机制(如 synchronized)来确保内存的可见性。

2.3 原子性问题

原子性是指一个操作不可分割,要么完全执行,要么完全不执行。在多线程环境下,原子性问题通常出现在复合操作中,例如 i++。尽管 i++ 看似只是一个简单的操作,但实际上它由三条指令组成:

  1. 读取 i 的值

  2. 对值进行加 1

  3. 将加 1 后的结果写回 i

如果多个线程同时执行 i++,就可能出现竞态条件,导致 i 的最终值不正确。为了解决原子性问题,我们通常使用 synchronized 或者其他同步机制来保证这些操作的原子性。

2.4 有序性问题

有序性是指程序中的执行顺序是否符合我们代码的顺序。为了提高执行效率,编译器、JVM 或 CPU 可能会对指令进行重排,这意味着执行顺序不一定和代码的书写顺序一致。

例如:

int a = 1;
int b = 2;

可能会被编译器优化为:

b = 2;
a = 1;

在单线程环境下,这种重排不会产生问题,但在多线程环境下,它可能导致不可预期的结果。

3 happens-before 规则

为了保证多线程程序的正确性,Java 内存模型定义了 happens-before 规则。这个规则用来确定程序中的操作顺序,确保在并发环境下,某些操作能够“看到”其他操作的结果。happens-before 规则是 JMM 中非常重要的一部分,它决定了线程之间的操作是否有顺序性和可见性。

3.1 程序次序规则

程序次序规则规定了单个线程内,程序代码执行的顺序必须按照代码的书写顺序执行。这是最基本的顺序规则,即在同一个线程内,代码的执行顺序不会被重排。

3.2 锁规则(synchronized)

对于 synchronized 关键字保护的代码块或者方法,JMM 确保锁的释放操作 happens-before 后续线程对该锁的加锁操作。换句话说,线程 A 在执行完 synchronized 代码块后释放锁,线程 B 需要等待获得该锁才能执行相应的代码,并且能够看到线程 A 对共享变量的修改。

3.3 volatile 变量规则

对于一个 volatile 变量的写操作,happens-before 所有后续对该变量的读操作。volatile 关键字确保了变量的可见性,意味着当一个线程写入 volatile 变量后,其他线程能立即看到这个变量的最新值。

3.4 线程启动规则

当一个线程调用 Thread.start() 方法时,start() 方法发生的操作 happens-before 该线程内部 run() 方法的执行。这意味着,线程的启动操作会确保其他线程在执行时,能够看到线程启动前的状态。

3.5 线程终止规则

线程的终止检测(如通过 join()isAlive() 方法) happens-before 线程内部所有操作结束。这保证了在一个线程终止后,其他线程能够看到其最终状态。

3.6 线程中断规则

调用 interrupt() 方法 happens-before 线程检测中断状态的操作。这意味着,在调用 interrupt() 后,目标线程能够感知到自己已被中断,并能够采取适当的响应措施。

3.7 对象初始化规则

对象构造函数的所有初始化写操作 happens-before 其他线程获取该对象引用。换句话说,在对象构造完成之前,其他线程不应访问该对象。

4 volatile 关键字详解

volatile 是 JMM 中非常重要的一个关键字,它确保了多线程之间对共享变量的可见性有序性

4.1 volatile 的可见性

volatile 保证了一个线程对 volatile 变量的写操作能立即对其他线程可见。当线程 A 修改一个 volatile 变量后,线程 B 会立刻看到线程 A 修改后的值。这是通过禁止线程缓存该变量的副本以及通过内存屏障来实现的。

示例:

volatile boolean flag = false;

Thread A:
flag = true;

Thread B:
while (!flag) {
    // 一直循环等待
}

在这个例子中,线程 B 会在 flag 被线程 A 修改后立刻看到该变化。

4.2 volatile 的有序性

volatile 还保证了有序性,避免了指令重排。具体来说,volatile 确保变量的写操作不会被重排序到读取操作之前,避免了在多线程中出现访问未初始化变量的问题。

volatile boolean initialized = false;
obj = new Object();  // 1
initialized = true;  // 2

在上述代码中,volatile 保证了 2 不会被重排到 1 之前,从而避免了在其他线程访问 obj 时,发现它还未初始化的情况。

4.3 volatile 不保证原子性

尽管 volatile 保证了变量的可见性和部分有序性,但它并不保证复合操作的原子性。例如:

volatile int count = 0;
count++;

这段代码不是线程安全的,因为 count++ 并不是原子操作,它实际包含了读取、增加和写回三个步骤,因此在多线程环境下可能会导致竞态条件。

5 指令重排序与内存屏障

指令重排序是为了提高程序执行效率,编译器、JVM 或 CPU 会在不影响程序语义的情况下对指令进行重排。然而,在多线程环境下,指令重排可能会导致不可预期的行为。

为了控制指令重排,JMM 在底层使用了内存屏障(Memory Barriers)。内存屏障是一种硬件机制,用于确保指令的执行顺序不被打乱,避免出现内存可见性问题。

6 JMM 如何实现可见性与有序性

Java 内存模型(JMM)通过一系列机制来确保在多线程程序中实现 可见性 和 有序性。在多线程环境下,线程对共享变量的修改不一定能及时被其他线程看到,因此我们需要 JMM 来保证线程之间的内存共享以及执行顺序的可预测性。

6.1 可见性

可见性问题源于多个线程对共享变量的修改可能不会立刻被其他线程看到。在 Java 中,JMM 通过以下方式保证共享变量的可见性:

  1. 工作内存与主内存的区别:每个线程都有自己的工作内存(即 CPU 缓存),线程对共享变量的读写操作首先发生在工作内存中,只有在必要时才会同步到主内存中。这就可能导致某个线程对共享变量的修改无法及时反映到其他线程的工作内存中,造成数据不一致的情况。

  2. volatile 关键字:JMM 中最常见的保证可见性的方法是 volatile 关键字。volatile 关键字确保每次对该变量的写操作都会立即刷新到主内存,并且每次读取该变量时,都会从主内存中获取最新的值,避免了工作内存中的缓存副本导致的数据不一致。

  3. synchronized 关键字:synchronized 锁机制除了保证原子性外,还保证了对共享变量修改后的可见性。当一个线程释放锁时,它会把共享变量的最新值写入主内存,其他线程在获取锁时会从主内存中读取最新的共享变量值。

6.2 有序性

有序性问题指的是程序中的执行顺序可能与代码的书写顺序不一致。为了提高程序的执行效率,编译器、JVM 和 CPU 会对指令进行重排序优化。在单线程程序中,这种优化不会带来问题,但在多线程环境下,指令重排可能导致并发问题。

6.2.1 指令重排的原因

  • 编译器优化:为了提高性能,编译器可以改变语句的执行顺序,而不影响程序的最终结果。

  • JVM 重排:JVM 也可能对代码进行优化,改变语句的执行顺序,以提高运行效率。

  • CPU 重排:CPU 为了提高效率,可能会重新排列指令的执行顺序,特别是在并行执行的情况下。

5.2.2 happens-before 规则

为了确保多线程程序中的有序性,Java 提供了 happens-before 规则。这个规则指定了哪些操作必须在其他操作之前发生,从而保证操作的有序性。

  • 程序次序规则:同一线程内,代码的执行顺序必须按照代码的书写顺序执行。

  • 锁规则:对于同一个锁,解锁操作必须发生在加锁操作之前,保证其他线程能看到该线程对共享变量的修改。

  • volatile 变量规则:对 volatile 变量的写操作必须发生在所有后续对该变量的读操作之前。

  • 线程启动规则:调用 Thread.start() 方法必须发生在线程内部 run() 方法执行之前。

  • 线程终止规则:当一个线程终止时,必须发生在该线程执行完毕后,其他线程能够看到该线程修改的共享变量的值。

通过 happens-before 规则,JMM 确保了线程间的执行顺序和共享变量的可见性。

7 总结

  1. JMM 解决了多线程环境下的可见性、原子性和有序性问题。

  2. 线程对共享变量的修改可能因缓存、指令重排等原因无法立即被其他线程看到。

  3. volatile 关键字解决了可见性问题,并部分解决了有序性问题,但不保证原子性。

  4. synchronized 提供了最强的同步机制,能够解决可见性、原子性和有序性问题。

  5. 编译器和 CPU 的指令重排机制可能导致多线程中的并发问题,因此需要通过内存屏障来进行控制。


Comment