深入剖析:为什么多线程下变量会看不见、乱序、不安全?

在 Java 并发编程里,变量看不见、执行乱序、数据不安全是三大经典噩梦,也是面试必问、开发必踩的坑。

很多人只知道用synchronizedvolatileLock,却不知道底层为什么会出现这些问题。一旦理解了本质,你写并发代码会豁然开朗。

这篇文章我会用最通俗、最详细、最底层的方式,把这三个问题一次性讲透:

  1. 为什么多线程下,一个线程修改了变量,另一个线程看不见?(可见性问题)
  2. 为什么代码执行顺序和我写的不一样?(重排序问题)
  3. 为什么多线程同时修改变量,结果会错乱?(原子性 / 线程不安全问题)

一、先铺垫:Java 内存模型(JMM)到底是什么?

要理解三大问题,必须先懂JMM(Java Memory Model)

你可以把它理解成:Java 定义的一套线程与内存交互的规则。

它规定:

  1. 所有变量都存在主内存(Main Memory)
  2. 每个线程有自己的工作内存(Working Memory)
  3. 线程不能直接读写主内存,只能操作自己工作内存里的变量副本

结构如下:

php 复制代码
主内存
↑ ↓
线程1工作内存  ←→ 线程2工作内存

这就埋下了所有并发问题的根源:线程之间不共享工作内存,彼此看不见对方的修改。


二、问题 1:多线程下变量看不见(可见性问题)

现象

线程 A 修改了变量,线程 B永远读不到最新值,甚至陷入死循环。

根本原因

CPU 缓存 + 不及时刷新主存 → 线程之间数据不同步

详细解释:

  1. 线程读取变量 → 复制到线程工作内存(CPU 缓存)
  2. 线程修改变量 → 只改自己缓存里的副本
  3. 不会立刻同步回主内存
  4. 其他线程依旧读自己缓存里的旧值

这就是可见性(Visibility)丢失

结合底层:MESI 协议为什么不能完全解决?

之前我们讲过 MESI 缓存一致性协议,它能让多核 CPU 感知缓存失效。

但 CPU 做了优化:

  • Store Buffer(存储缓冲)
  • Invalidate Queue(失效队列)

为了快,CPU 不会立刻同步缓存,导致短暂时间内,线程依然看不见最新值

代码示例(看不见的经典场景)

java 复制代码
public class VisibilityProblem {
    private static boolean flag = false;

    public static void main(String[] args) {
        // 线程1 等待 flag 变成 true
        new Thread(() -> {
            while (!flag) {
                // 无限循环
            }
            System.out.println("线程1退出");
        }).start();

        // 线程2 修改 flag
        new Thread(() -> {
            try { Thread.sleep(1000); } catch (Exception ignored) {}
            flag = true;
            System.out.println("线程2已修改flag");
        }).start();
    }
}

结果:线程 1 永远死循环,看不见 flag 变成 true。

如何解决?

  • volatile(强制读写主存 + 禁用缓存优化)
  • synchronized
  • Lock

三、问题 2:代码执行乱序(重排序问题)

现象

你写的代码顺序是 A → B → C但 JVM / CPU 实际执行是 B → A → C

为什么要重排序?

为了快! CPU 和 JVM 会在不影响单线程结果的前提下,乱序执行提高效率。

多线程下的灾难

单线程没问题,多线程会直接导致逻辑崩溃

最经典案例:双重检查锁单例(DCL)

java 复制代码
public class Singleton {
    private static Singleton instance; // 没有 volatile

    private Singleton() {}

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

new Singleton() 会被拆成 3 步:

  1. 分配内存
  2. 初始化对象
  3. instance 指向内存

重排序后可能变成:

  1. 分配内存
  2. instance 指向内存
  3. 初始化对象

这就会出现:instance != null,但对象还没初始化 → 线程使用时直接报错!

重排序的本质

  • 编译器重排序
  • CPU 指令重排序
  • 内存系统重排序

JMM 无法禁止,但可以用 volatile 强制禁止。

volatile 如何禁止重排序?

通过内存屏障(Memory Barrier)

  • 写屏障
  • 读屏障

屏障前后的指令不能跨越屏障重排序


四、问题 3:多线程修改变量不安全(原子性问题)

现象

i++ 看起来是一行代码多线程同时执行,结果永远少算

根本原因

i++ 不是一步,而是三步

  1. 读取 i
  2. 计算 i+1
  3. 写回 i

这三步不是原子操作,线程随时可能切换。

例子

  • 线程 A 读取 i=10
  • 线程 B 也读取 i=10
  • 线程 A 加 1 → 11
  • 线程 B 加 1 → 11
  • 最终结果:11,而不是 12

丢失更新 = 线程不安全

什么是原子性?

一个操作不可分割,要么全部完成,要么全部不做。

为什么 volatile 不能解决原子性?

因为 volatile 只解决:

  • 可见性
  • 禁止重排序

不保证操作不可分割!

正确解决方案

  • synchronized
  • Lock
  • AtomicInteger(CAS 无锁原子)

五、一张图总结三大问题

问题 学名 根本原因 解决方案
变量看不见 可见性问题 CPU 缓存 + 不及时同步主存 volatile、锁
执行乱序 有序性问题 指令重排序 volatile、锁
计算错乱 原子性问题 操作非原子,线程切换 锁、原子类

Java 并发三大特性:可见性、有序性、原子性只要缺一个,就会出 bug。


六、最关键的总结(面试必背)

1. 变量为什么看不见?

  • 线程读写的是自己的缓存副本
  • 修改后没有立刻同步到主内存
  • 其他线程读的是旧副本

2. 为什么代码会乱序?

  • JVM/CPU 为了性能重排序指令
  • 单线程没问题
  • 多线程破坏依赖关系,导致逻辑错误

3. 为什么多线程不安全?

  • 很多操作不是一步完成(如 i++)
  • 线程切换会导致中间状态被覆盖
  • 最终结果丢失、错乱

七、最终结论(最重要)

volatile 只能保证:可见性 + 有序性 不能保证:原子性

要真正安全,必须保证三大特性全部满足:

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

能同时保证三者的是:

  • synchronized
  • Lock
  • 原子类(AtomicXxx)
相关推荐
一名优秀的码农2 小时前
vulhub系列-41-DerpNStink: 1(超详细)
安全·web安全·网络安全·网络攻击模型·安全威胁分析
国冶机电安装2 小时前
电气安全保护装置:从设计选型到安装验收的全流程解析
服务器·网络·安全
开开心心就好2 小时前
进程启动瞬间暂停工具,适合调试多开
linux·运维·安全·pdf·智能音箱·智能手表·1024程序员节
金士镧(厦门)新材料有限公司3 小时前
稀土化合物:科技世界的隐形英雄
人工智能·科技·安全·全文检索·生活
不一样的故事1263 小时前
测试的核心本质是风险管控
大数据·网络·人工智能·安全
LlNingyu4 小时前
API安全的命题:逻辑缺陷的范式转移
安全·web安全
XDHCOM6 小时前
Redis远程连接命令详解,分享高效配置与安全实践技巧
前端·redis·安全
软件供应链安全指南12 小时前
以AI治理AI|问境AIST首家通过信通院大模型安全扫描产品能力评估!
人工智能·安全·ai安全·问境aist·aist·智能体安全
kang0x015 小时前
这是什么编码 - writeup by AI
安全