47. 【Java】Java内存模型(JMM)与可见性

摘要: 本文深入探讨Java并发编程中的内存可见性问题,从CPU缓存与指令重排序的硬件原理出发,解析Java内存模型(JMM)的核心机制。通过具体代码示例展示volatile关键字如何解决可见性问题,对比volatilesynchronized的适用场景,并详细解释原子性、可见性、有序性三大并发概念的区别。最后提供Happens-Before规则解析和常见误区澄清,帮助开发者编写正确高效的并发代码。

关键词: Java内存模型, volatile关键字, 内存可见性, 并发编程, Happens-Before规则


在前面的几篇文章中,我们一直在讨论"线程安全"的问题。我们用 synchronizedLock 解决了多个线程同时修改共享数据的冲突。你可能会有一种感觉:只要我把所有共享数据的访问都用 synchronized 包起来,就万事大吉了。

但事实并没有那么简单。即使你用了 synchronized,有些诡异的问题还是会出现------比如,一个线程修改了一个变量的值,另一个线程却"看不见"这个修改,仍然在使用旧值。这种"看不见"的问题,就是今天我们要聊的核心:可见性(Visibility)

要理解可见性,我们就不能只停留在"线程"和"锁"的层面了,而是要再往下走一层,看看 Java 程序在 JVM 和 CPU 层面到底是怎么执行的。这就是 Java 内存模型(Java Memory Model, JMM)


1. 一个"看不见"的例子

我们先来看一个令人困惑的程序:

java 复制代码
public class VisibilityProblem {
    private static boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            System.out.println("工作线程启动...");
            while (running) {
                // 忙等待
            }
            System.out.println("工作线程退出!");
        });

        worker.start();

        // 主线程休眠 1 秒,让工作线程先跑起来
        Thread.sleep(1000);

        System.out.println("主线程准备停止工作线程...");
        running = false;   // 修改 running 为 false
        System.out.println("主线程已将 running 设为 false");

        worker.join();
        System.out.println("主线程结束");
    }
}

你可能会觉得:主线程把 running 设成了 false,工作线程的 while (running) 循环条件变成 false,它就应该退出了。

但实际运行结果是------工作线程可能永远不会退出 !即使主线程已经把 running 设成了 false,工作线程仍然在死循环里转。

为什么会这样?难道 running = false 这个修改没有生效吗?

不,它生效了,只是工作线程看不到这个修改。


2. 为什么看不见?------ CPU 缓存与指令重排序

要解释这个问题,我们需要了解计算机的硬件架构。

2.1 CPU 与主存的速度差距

CPU 执行指令的速度极快,而主内存(RAM)的读写速度相对较慢,两者之间差了若干个数量级。为了弥补这个差距,现代 CPU 在 CPU 核心和主存之间加入了多级高速缓存(Cache)

复制代码
CPU 核心 1 → L1 Cache → L2 Cache → L3 Cache → 主内存
CPU 核心 2 → L1 Cache → L2 Cache → L3 Cache → 主内存

当一个 CPU 核心需要读取一个变量时,它会先从缓存中找,如果缓存中没有,再从主存加载到缓存中。当它修改变量时,修改会先写在缓存里,然后再"同步"回主存。

问题就在这里:不同的 CPU 核心有自己的缓存,它们之间的数据可能不一致。

在我们的例子中:

  • 主线程运行在 CPU 核心 1 上,它把 runningtrue 改成了 false。但这个修改可能只写到了核心 1 的缓存中,还没有同步回主存。
  • 工作线程运行在 CPU 核心 2 上,它一直从自己的缓存中读取 running。由于缓存没有及时同步,它看到的仍然是旧的 true

这就是 可见性问题:一个线程对共享变量的修改,另一个线程可能看不到。

2.2 指令重排序

除了缓存的问题,编译器和 CPU 还可能会对指令进行重排序(Reordering)------在不改变单线程执行结果的前提下,调整指令的执行顺序,以提高性能。

在单线程环境下,重排序不会产生问题,因为最终结果是一样的。但在多线程环境下,重排序可能会导致意料之外的结果。

java 复制代码
// 示例:假设 a 和 b 是共享变量
a = 1;      // 语句1
b = 2;      // 语句2

编译器和 CPU 可能会把语句2 放在语句1 之前执行(如果它们没有依赖关系)。在单线程中,这没问题。但在多线程中,如果另一个线程观察到了 b = 2 但还没有观察到 a = 1,就可能产生逻辑错误。


3. Java 内存模型(JMM)------ 一套"约定"

为了在不同的硬件平台上保证 Java 程序的正确性,Java 定义了一套抽象的内存模型------Java 内存模型(JMM)

JMM 的核心思想是:它规定了在多线程环境下,一个线程对共享变量的修改何时对另一个线程可见。它定义了一套规则(Happens-Before 规则),只要遵循这些规则,JVM 就会保证内存可见性。

JMM 的抽象结构如下:

复制代码
            Java 线程
                |
        工作内存(线程私有)
         /     |     \
     读取    使用    赋值
         \     |     /
        主内存(所有线程共享)
  • 主内存:所有线程共享的内存区域,存储所有的共享变量(实例字段、静态字段、数组元素)。
  • 工作内存 :每个线程私有的内存区域,存储该线程使用的共享变量的本地副本。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存。工作内存中的变量是主内存的一个"快照"。

线程 A 修改变量后,需要把新值"写回"主内存;线程 B 读取变量时,需要从主内存中"获取"最新值。JMM 定义了什么时候必须"写回"和"刷新"。


4. volatile 关键字 ------ "轻量级同步"

volatile 是 Java 提供的一个轻量级同步机制。它可以保证两件事:

  1. 可见性 :对一个 volatile 变量的写操作,会立刻刷新到主内存;对一个 volatile 变量的读操作,会从主内存中读取最新的值。
  2. 禁止指令重排序 :编译器和 CPU 不会对 volatile 变量的读写操作进行重排序。

我们把之前的例子加上 volatile,问题就解决了:

java 复制代码
public class VisibilitySolution {
    // 加上 volatile,保证可见性
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            System.out.println("工作线程启动...");
            while (running) {
                // 每次读取 running,都会从主内存获取最新值
            }
            System.out.println("工作线程退出!");
        });

        worker.start();
        Thread.sleep(1000);
        System.out.println("主线程准备停止工作线程...");
        running = false;
        System.out.println("主线程已将 running 设为 false");

        worker.join();
        System.out.println("主线程结束");
    }
}

加上 volatile 后,工作线程的 while (running) 每次都会从主内存重新读取 running 的值,因此能立即看到主线程的修改,正常退出。

volatile 的典型使用场景
  • 状态标志:用于控制线程的启动、停止(如上例)。

  • 双重检查锁(DCL)中的单例模式

    java 复制代码
    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;
        }
    }

    这里的 volatile 保证了 instance 在构造完成之前,其他线程不会看到它。

  • volatile 不适合用于"计数"或"累加"等需要原子性的场景 :因为 count++ 不是原子操作,volatile 只保证可见性,不保证原子性。计数场景请使用 AtomicIntegersynchronized


5. 原子性 vs 可见性 vs 有序性

这三个概念是并发编程的核心:

概念 说明 解决方案
原子性 一个操作或多个操作要么全部执行且不被中断,要么全不执行 synchronizedLockAtomic*
可见性 一个线程对共享变量的修改,另一个线程能立即看到 volatilesynchronizedLock(同步块退出时会刷新到主内存)
有序性 代码的执行顺序与编写顺序一致(不被重排序) volatilesynchronized(同步块内代码不会与外部重排序)

volatile 只保证可见性有序性 ,不保证原子性


6. Happens-Before 规则 ------ JMM 的"法律条文"

JMM 通过 Happens-Before 规则来定义何时一个操作对另一个操作可见。如果操作 A happens-before 操作 B,那么 A 的结果对 B 是可见的。

以下是 Happens-Before 的关键规则:

  1. 程序顺序规则:在一个线程内,按照代码顺序,前面的操作 happens-before 后面的操作。
  2. volatile 变量规则 :对一个 volatile 变量的写操作,happens-before 对同一个变量的读操作。
  3. 锁规则 :对一个锁的 unlock() 操作,happens-before 对同一个锁的 lock() 操作(即释放锁之前的所有操作,在获取锁之后可见)。
  4. 线程启动规则Thread.start() happens-before 该线程内的所有操作。
  5. 线程终止规则 :线程内的所有操作 happens-before Thread.join() 返回。
  6. 传递性:如果 A happens-before B,且 B happens-before C,则 A happens-before C。

这些规则决定了哪些内存操作需要"刷新"和"可见"。


7. volatile vs synchronized ------ 什么时候用哪个?

维度 volatile synchronized
保证原子性 ❌ 不保证 ✅ 保证
保证可见性 ✅ 保证 ✅ 保证(释放锁时刷新到主存)
保证有序性 ✅ 禁止重排序 ✅ 保证(同步块内代码不会与外部分析排序)
性能开销 很小(轻量级) 较大(涉及锁获取和释放)
适用场景 简单的状态标志、一写多读的场景 复杂的读写操作、需要原子性的场景

选择原则

  • 如果只是需要一个简单的"开关"变量来控制线程的运行状态,用 volatile
  • 如果需要对多个变量进行复合操作(如 count++),或者需要保证一组操作的原子性,用 synchronizedLock

8. 更深入的可见性:synchronized 的可见性保证

你可能已经注意到了:synchronized 也保证可见性。当一个线程退出 synchronized 代码块时,它会将工作内存中的所有修改刷新到主内存 ;当另一个线程进入 synchronized 代码块时,它会从主内存中刷新工作内存

所以,即使不用 volatile,用 synchronized 包裹读写操作也能保证可见性:

java 复制代码
public class SynchronizedVisibility {
    private static boolean running = true;

    public static synchronized boolean isRunning() {
        return running;
    }

    public static synchronized void setRunning(boolean value) {
        running = value;
    }

    public static void main(String[] args) throws InterruptedException {
        // 工作线程通过同步方法读取
        Thread worker = new Thread(() -> {
            System.out.println("工作线程启动...");
            while (isRunning()) { }
            System.out.println("工作线程退出!");
        });
        worker.start();
        Thread.sleep(1000);
        setRunning(false);
        worker.join();
    }
}

但用 synchronized 来实现一个简单的状态标志,明显是"杀鸡用牛刀"了------性能开销大,代码也繁琐。这就是为什么 volatile 存在的意义。


9. 综合示例 ------ 一个"限时任务"的实现

我们来写一个实用的小例子:一个任务最多运行 5 秒,超时后自动取消。

java 复制代码
public class TimedTask {
    // 用 volatile 控制任务是否继续执行
    private static volatile boolean cancelled = false;

    public static void main(String[] args) throws InterruptedException {
        // 任务线程
        Thread task = new Thread(() -> {
            System.out.println("任务开始执行...");
            int count = 0;
            // 每秒输出一次进度,直到被取消
            while (!cancelled && count < 10) {
                System.out.println("任务执行中..." + (count + 1) + "/10");
                count++;
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
            if (cancelled) {
                System.out.println("任务被取消!已执行 " + count + " 步");
            } else {
                System.out.println("任务正常完成!");
            }
        });

        task.start();

        // 主线程等待 3 秒后取消任务
        Thread.sleep(3000);
        System.out.println("超时,取消任务...");
        cancelled = true;  // volatile 写,对任务线程立即可见

        task.join();
        System.out.println("主线程结束");
    }
}

这个例子里,volatile boolean cancelled 是典型的"状态标志"用法。任务线程在每次循环中检查 cancelled,主线程修改它后,任务线程立刻就能看到。


10. 常见的误解与陷阱

误解①:volatile 可以保证原子性

❌ 错误。volatile 不保证原子性。volatile int count; count++ 仍然不是线程安全的。count++ 是"读-改-写"三步,这三步之间可能被其他线程打断。

误解②:没有 volatile 就一定看不见

❌ 不一定。在极端情况下,缓存同步可能刚好发生,程序偶尔会正确运行。但"偶尔正确"比"总是错误"更危险------它让 bug 难以复现和调试。必须用正确的方式保证可见性。

误解③:volatile 会导致性能大幅下降

大多数情况下,volatile 的性能开销很小,和普通变量访问的差别在可接受范围内。只有在极其高频率(每秒百万次以上)的访问中,才需要考虑优化。不要为了微小的性能顾虑而牺牲正确性。

陷阱:volatile 变量写操作不能依赖当前值

因为 volatile 不保证原子性,所以 volatile 变量的写操作不应该依赖于当前值(即不能写 x = x + 1 这种代码)。如果有这种需求,使用 AtomicIntegersynchronized


11. 今天的总结

今天我们深入了 Java 内存模型(JMM):

  • 可见性问题:一个线程对共享变量的修改,另一个线程可能因为 CPU 缓存而看不到。
  • Java 内存模型:抽象了主内存和工作内存,定义了线程如何与内存交互。
  • volatile 关键字:保证可见性和禁止指令重排序。适合做状态标志、双重检查锁等场景。
  • 原子性 vs 可见性 vs 有序性:三个不同的概念,各自有不同的解决方案。
  • Happens-Before 规则:JMM 定义的一套规则,规定了哪些操作之间具有可见性保证。
  • volatile vs synchronizedvolatile 轻量,只保证可见性和有序性;synchronized 重量,同时保证原子性、可见性、有序性。

Java 内存模型是并发编程的"底层逻辑"。理解了它,你就能看懂为什么有些代码会出问题,为什么 volatile 能解决,为什么 synchronized 也能解决但代价更高。


动手试试

  1. 把第一个"可见性问题"的例子中的 volatile 去掉,在 while (running) 中加上 System.out.println,观察是否工作线程会退出(提示:System.out.println 内部有 synchronized,会触发内存刷新)。思考为什么加上打印后就能看见了。

  2. 写一个简单的 volatile 计数器,启动 10 个线程,每个线程对 volatile int count 执行 1000 次 count++。观察结果是否为 10000,并解释为什么。

  3. AtomicInteger 替代上面的 volatile int,观察结果是否正确。

  4. 写一个程序,演示指令重排序导致的问题(提示:用两个线程分别执行两段代码,一个线程观察另一个线程对两个变量的赋值顺序)。

  5. 阅读 java.util.concurrent.atomic 包下的 AtomicInteger 源码,看看它是如何在不使用 synchronized 的情况下保证原子性的(提示:Unsafe.compareAndSwapInt,即 CAS 操作)。

通过这一篇,你已经触及了 Java 并发编程的底层原理。理解了 JMM 和 volatile 的机制,你就能写出更正确、更高效的并发代码。

我们下一篇会学习 并发容器与原子类,看看 JUC 包里那些线程安全的集合和原子类是如何在 JMM 的基础上构建的。

我们下一篇见。😃

📌 获取本系列示例代码请访问 GitCode

相关推荐
m0_380743871 小时前
从零调用 Claude 教程
开发语言·python·node.js
起床学FPGA1 小时前
AI回答问题之后,会自动跳到最下面的问题
开发语言·javascript·ecmascript
zww89491112 小时前
校园跑腿系统开发实战指南:从需求分析到上线部署全流程解析
java
Fluxart.ai2 小时前
电商商品图审核怎么自动化?规则引擎、人工复核与发布门禁
java·前端·自动化
x_x-F3 小时前
网络数据从网卡到用户态:一场基于内存所有权转移的接力赛
开发语言·网络·php
名字还没想好☜3 小时前
Java 线上内存泄漏排查实战:jmap 导堆、MAT 找 GC Roots 与四类常见泄漏
java·开发语言·jvm·内存泄漏
码匠许师傅3 小时前
【C++ 面试真题】聊聊 C++ 的多继承与虚继承
开发语言·c++·面试
我不是疯子是傻子4 小时前
Qt CAN通信周期发送抖动?实测定时器精度校准与时间戳补偿方案
开发语言·数据库·qt
IT爱学堂4 小时前
尚硅谷 - 2025年3月Java+AI大模型应用开发
java·开发语言·人工智能