Skip to content

结合JVM的内存结构讲解多线程为什么可能发生错误

JVM的内存结构: * 线程共享区域:Java堆、方法区。这里存放着对象实例和静态变量,是所有线程都能访问的共享数据区,也是并发问题的“策源地”。 * 线程私有区域:虚拟机栈、本地方法栈、程序计数器。每个线程独有一份。

问题的关键在于Java内存模型(JMM)的规定:线程对共享变量的所有操作,都必须在自己的工作内存中进行,不能直接读写主内存中的变量。

这个“工作内存”是一个抽象概念,它涵盖了CPU的高速缓存、寄存器等。一个线程修改共享变量的流程是: 1. 从主内存(堆)中复制变量到自己的工作内存。 2. 在工作内存中执行修改操作。 3. 将修改后的值写回主内存。

正是这个“复制-修改-写回”的过程,以及CPU和编译器的优化,导致了以下三大问题:

问题一:原子性(Atomicity)问题

原子性指的是一个操作或多个操作,要么全部执行完成并且执行的过程不被任何因素打断,要么就都不执行。

  • 为什么会出错? 在Java中,很多我们看似一步完成的操作,在底层其实是由多个CPU指令构成的。例如 count++ 这个操作,它至少包含三个步骤:
    1. 从主内存读取count的值。
    2. 在工作内存中对count进行加1操作。
    3. 将新值写回主内存。 在多线程环境下,CPU的上下文切换可能发生在这三个步骤的任何一个中间。比如,线程A读取了count=10,还没来得及写回,CPU就切换到了线程B。线程B也读取了count=10,执行了加1,并写回了11。然后CPU又切回线程A,它基于自己工作内存中的旧值10,也执行加1,再写回11。两个线程都执行了加1,结果却是11而不是12,这就是原子性被破坏了。

问题二:可见性(Visibility)问题

可见性指的是当一个线程修改了共享变量的值,新值对于其他线程来说是立即可见的。

  • 为什么会出错? 这正是“办公室比喻”最直接的体现。
    1. 线程A修改了共享变量flag的值,但这个修改可能只发生在它自己的工作内存(CPU缓存)中。
    2. 由于CPU缓存和主内存的同步存在延迟,线程A可能迟迟没有将新值写回主内存。
    3. 此时,线程B去读取flag变量,它很可能会从主内存中读取到那个旧的、未被修改的值,导致它无法“看到”线程A的修改。

一个经典的例子是一个线程通过循环检查一个标志位来决定是否退出:

// 线程A
while (!stop) {
    // do something
}

// 线程B
stop = true;

线程A可能因为将stop=false缓存了起来,导致永远无法看到线程B对stop的修改,从而陷入死循环。

问题三:有序性(Ordering)问题

有序性指的是程序执行的顺序,应该按照代码的先后顺序执行。

  • 为什么会出错? 为了提高性能,编译器和处理器可能会对输入的代码进行指令重排序(Instruction Reordering)。这种重排序在单线程环境下,能保证最终执行结果与代码顺序执行的结果一致(as-if-serial语义)。但在多线程环境下,这种“自作主张”的优化就可能导致严重问题。

最经典的例子是双重检查锁定(Double-Checked Locking)实现的单例模式:

instance = new Singleton(); // 这一行代码不是原子的

它在底层大致可以分为三步: 1. 分配内存空间。 2. 初始化对象。 3. 将instance引用指向分配的内存地址。

编译器可能会将顺序优化为1-3-2。这样,线程A执行到第3步时,instance已经不为null了,但对象还没有被初始化。此时如果线程B进来判断instance != null,它会直接返回一个尚未初始化的“半成品”对象,后续使用就会抛出空指针异常。

解决方案

总而言之,多线程错误的根源,在于JVM为了性能而设计的内存模型(主内存与工作内存的分离),以及编译器和CPU为了效率而进行的指令重排序。这些优化在单线程时是安全的,但在多线程并发访问共享数据时,就暴露出了原子性、可见性和有序性这三大问题。

为了解决这些问题,Java提供了相应的同步机制: * synchronized关键字:它是一个重量级的解决方案,可以同时保证原子性、可见性和有序性。 * volatile关键字:它是一个轻量级的解决方案,主要用于保证可见性和防止指令重排序,但不能保证原子性。 * java.util.concurrent包下的工具类(如LockAtomic系列类):提供了更灵活、更细粒度的并发控制工具。