尧图精选

Java并发三要素:原子性、可见性、有序性详解

🕒 发布时间:2026/9/15 5:02:49 📁 来源:尧图网络
1. 并发三要素到底是什么先建立全局认识1.1 为什么单线程时代没有这些问题做 Java 后端久了你会发现并发编程里翻来覆去就三个词原子性、可见性、有序性。不管面试被问“volatile 和 synchronized 的区别”还是线上遇到“一个变量改了另一个线程怎么就是读不到”到最后都能归到这三个词上。Java 线程之间通过共享内存协作但共享内存并不是“我改了你立刻能看到”这么简单中间隔着一层 CPU 缓存又隔着一层 JIT 编译优化再叠加线程调度各种诡异问题就冒出来了。单线程时代完全不存在这些问题因为程序从头到尾只有一条执行路径。a 1 执行完下一步读 a得到的一定是 1。多线程就不同两个线程同时读同一个变量各自在本地缓存里操作然后写回主内存时间节点不一致数据就“丢”了两个线程一前一后修改同一个计数器最后一次覆盖会让中间结果彻底消失。更麻烦的是编译器和 CPU 为了性能会重排指令单线程下重排不影响结果多线程下就可能把“先判断后初始化”变成“先初始化后判断”对象还没造好就被人拿走了。这三个词与其说是三个独立的知识点不如说是并发安全的三大支柱。你学的 synchronized、volatile、Lock、AtomicInteger、ConcurrentHashMap底层都在围绕这三件事做文章。我见过不少同学把并发问题当成玄学其实每个线上怪现象都能归因到其中一个或多个要素上。搞懂了它们各类并发工具在你眼里就不再是孤立 API而是一套有逻辑的解决方案。1.2 三要素各自解决哪一类问题我用一句话概括三要素原子性是“不可分割”可见性是“改完大家能看到”有序性是“指令执行顺序不乱来”。原子性把多个操作绑成一个整体要么全部执行完要么一个都不执行。比如银行转账的“扣钱 加钱”必须绑定不能只扣不加。可见性一个线程修改了共享变量另一个线程在随后的读取中必须能看到这个修改而不是读到旧值或者“过期缓存”。有序性程序代码在编译和运行过程中可能被重排序多线程环境下必须保证最终的“逻辑顺序”不会导致错误结果。如果你想象一个多人协作的团队会更直观原子性就是任务不能拆散要么你干完要么别人别插手可见性就是你更新了共享文档团队其他人打开文档必须是最新版有序性就是工作流要分步骤先准备素材再发布不能颠倒。1.3 三要素不是孤立存在的需要特别注意三要素经常交叉出现。一个 i 既涉及原子性读、加、写三步也依赖可见性加完写回主内存还可能因为重排序导致后续逻辑出问题。所以看一个方案怎么起作用要看它覆盖了几件事volatile保证可见性禁止某些重排序但不保证复合操作的原子性synchronized / Lock通过锁的互斥让临界区代码“原子”执行同时锁的释放和获取自带“把工作内存刷回主内存”的语义所以也保证可见性锁还天然建立顺序性AtomicInteger 等原子类用 CAS 无锁方案保证单个读改写操作的原子性底层字段本身是 volatile所以也保证可见性但它不管多个原子操作之间的整体顺序。这块别死记先用上面的分类理解后面第 5 章会有一个速查表面试前扫一眼就能回忆起来。我在实际带人时发现只要把三要素之间的覆盖关系理清楚很多并发题根本不用背靠推理就能答得七七八八。2. 原子性线程安全的“第一根底线”2.1 什么是原子操作什么不是原子性学术一点叫 Atomicity一个操作或者一系列操作从外部看是不可分割的整体。这个操作执行过程中不允许其他线程插进来操作同一个共享变量。Java 里只有很有限的场景天生就是原子的比如 int、long、float、double 这些基本类型的普通赋值只要不涉及多个字段联动一般可以看作原子操作。但“原子操作”不等于“原子业务流程”一旦你把几个操作组合在一起比如“判断余额是否足够 - 扣款 - 转账”这个组合就不是原子的哪怕每一步本身都很简单。最容易翻车的操作是 i它不是一条 Java 指令而是 READ - MODIFY - WRITE 三个动作。用 javap -c 看编译后的字节码至少是 getstatic、iconst_1、iadd、putstatic 这么几条。三个步骤之间完全可能被其他线程插入于是两个线程同时读到同一个旧值各自加一后写回结果只加了一次。复杂一点的还有 check-then-act比如“先检查 map 里有没有 key没有就 put”。这两个操作中间空了很长一段窗口两个线程可能同时通过检查然后重复写入。这类问题比 i 更隐蔽因为线上数据量大时偶尔重复插入一次两次不仔细看根本发现不了积累到一定量才会触发报警。2.2 经典案例用 100 个线程做 count我直接给你一个可以复制到本地跑的案例。定义一个共享的静态变量 count开 100 个线程每个线程循环 10000 次 count最后打印结果public class AtomicityDemo { private static int count 0; public static void main(String[] args) throws InterruptedException { int threads 100; int loops 10000; Thread[] workers new Thread[threads]; for (int i 0; i threads; i) { workers[i] new Thread(() - { for (int j 0; j loops; j) { count; } }); workers[i].start(); } for (Thread t : workers) { t.join(); } System.out.println(result count); } }理论上应该是 1,000,000实际情况却是不确定的可能是 982,xxx可能是 999,xxx多跑几次每次都不一样。这不是编译器问题也不是代码写错了而是 count 本身就不是原子操作。线程 A 读到 count10还没写回去线程 B 也读到 count10两个人都把 11 写回去最终 count 只加了 1。这个例子特别适合用来验证原子性的概念我在面试时也经常让候选人事先跑一遍。绝大多数人第一反应是“啊Java 里 count 不是原子的吗”对不是。这也解释了为什么网上所有 Java 并发八股文都会拿 i 开刀。2.3 原子性靠什么保证synchronized、Lock、Atomic 类要解决 count 的原子性有两条路线。一条是互斥锁另一条是 CAS 无锁。互斥锁最简单直接在 increment 方法上加上 synchronizedpublic synchronized void increment() { count; }同步块内部的代码同一时刻只能有一个线程进入其他线程全部阻塞在门口。这样本来应该拆成三步的 count 就被“锁”成了一个整体从外部看它不会被打断。Lock 和 ReentrantLock 原理类似只是提供了更灵活的控制比如 tryLock 带超时、可中断地等待锁。另一条路线是用原子类。AtomicInteger 的 incrementAndGet() 内部走的是 CASCompare And Swap循环读取当前值 expected执行加一然后调用底层 compareAndSet 检查当前值是不是还是 expected如果是就替换为新值否则重试。整个循环里没有加锁没有阻塞并发能力强很多。AtomicInteger count new AtomicInteger(0); count.incrementAndGet();注意一个限制原子类只保护“单个方法”的原子性。如果你写成if (atomicBalance.get() money) { atomicBalance.addAndGet(-money); }这个“检查再扣款”的组合操作就不是原子的两个线程可能同时通过余额检查。要保证复合操作原子要么用 synchronized 把整个 if 包起来要么在 CAS 循环里组合判断要么引入版本号看业务复杂程度。2.4 原子性的实操心得不要靠运气写并发我的第一个建议是写并发代码之前先问自己这个共享数据是不是真的需要共享。如果每次请求都可以自己 copy 一份完全不需要共享并发问题就少一半。第二个建议是优先考虑不可变对象。字段用 final 修饰对象发布之后不再改变那么原子性问题天然消失因为没人改它。第三个建议是如果必须要用原子类高并发场景从 AtomicLong 换成 LongAdder。LongAdder 内部把计数分散到多个 cell在线程多、写操作非常频繁时吞吐量比 AtomicLong 高不少。读的时候需要 sum() 汇总一下但读多写少场景下效果很好。最后还有一个坑虽然现代 64 位 JVM 上 volatile long/double 的赋值几乎都是原子操作但 JMM 规范严谨的说法是非 volatile 的 long/double 写操作允许被拆成两个 32 位写。规范说“允许”不代表你用的 JVM 一定会拆。可一旦遇到 32 位虚拟机或者某些特殊硬件就会读到撕裂的数据。稳妥的做法是跨线程共享的 long/double要么加 volatile要么用 AtomicLong别赌实现细节。3. 可见性你改的变量别的线程真的能看到吗3.1 JMM 与主内存/工作内存可见性比原子性更隐蔽。原子性问题至少能靠计数器多跑几遍复现可见性问题可能几天才出现一次而且切换到 debug 模式或者修改一下日志问题就消失了。Java 内存模型JMM用一套抽象规则解释可见性所有共享变量存在主内存中每个线程又有一份自己的工作内存。线程读变量时先到工作内存里找找不到或认为过期才从主内存加载线程写变量时先写到工作内存再刷回主内存。这个“什么时候刷”没有强制保证于是可能发生线程 A 写了一下午线程 B 还在用自己工作内存里的旧副本。实际硬件上对应的是 CPU 多级缓存。核心 0 改了变量变量可能还留在自己的 L1/L2 Cache 里核心 1 读到的还是主内存旧值。现代 CPU 有缓存一致性协议比如 MESI听起来很美好但写缓冲store buffer等机制仍然可能导致短暂的不一致。在 Java 层面你不需要精确掌握 MESI只要记住没有同步手段跨线程读到旧值是“合法”的问题不是玄学是 JMM 允许的行为。3.2 经典案例一个布尔变量引发的死循环我用一个最简单也最吓人的案例说明可见性public class VisibilityDemo { private static boolean running true; public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { while (running) { // 空循环 } System.out.println(worker stopped); }); worker.start(); Thread.sleep(1000); running false; System.out.println(main set runningfalse); } }看起来主线程 1 秒后把 running 改成 falseworker 线程的 while 循环条件不满足应该退出。但实测时有的机器立刻退出有的机器直接卡死还有的机器用不同 JVM 参数跑结果都不一样。核心原因是worker 线程可能把 running 读取进了 CPU 寄存器或工作内存当 JIT 发现这个循环里没有修改 running还可能把读取优化成“一次性加载”于是循环永远读旧值。解决办法很简单给 running 加 volatileprivate static volatile boolean running true;加上之后worker 线程每次循环都必须重新从主内存读取 running这个问题就消失了。我给非技术背景的同事解释时常用一个比喻volatile 相当于把每个线程的小纸条扔了强制去公告板上看最新消息。代价是多一点内存屏障开销但换来的是正确的可见性。3.3 volatile 到底做了什么volatile 的语义可以拆成三句话写一个 volatile 变量时JVM 会把这个写操作“推”到主内存而不是留在工作内存读一个 volatile 变量时JVM 会直接从主内存读而不是用本地缓存读和写之间会插入内存屏障Memory Barrier禁止某些指令重排序避免把不该越过的读写顺序调乱。内存屏障听起来很深但你可以把它当成一道“栅栏”栅栏前后的指令不允许随便跨到对面去。volatile 在写操作后会插入写屏障防止把之前的普通写操作重排到 volatile 写之后在读操作前会插入读屏障防止把之后的普通读操作重排到 volatile 读之前。但一定要记住volatile 不解决原子性。就算 running 是 volatile如果改成 running 这种读-改-写两个线程依旧可能同时读到同一个旧值然后各自改了写回结果丢更新。所以 volatile 适合“一个线程写其他线程读”的状态标志位不适合“多个线程同时写”的计数器。3.4 volatile 之外的可见性手段与实操心得除了 volatilesynchronized 和 Lock 也保证可见性。锁的释放会把线程工作内存强制刷新到主内存锁的获取会让工作内存失效从而不得不重新从主内存加载。所以你在 synchronized 块里读共享变量看到的一定是最新值退出 synchronized 前改的共享变量对后续获取同一把锁的线程可见。final 字段也值得说。某个对象安全发布后它的 final 字段对所有读到该对象的线程可见不需要额外加 volatile。前提是对象构造时不能让 this 逃逸也就是不能在构造函数里把 this 发布给其他线程否则发布顺序被打乱final 的保证也会失效。日常开发中我最常见的 volatile 使用场景就是“开关类状态”服务关闭标记、缓存加载完成标志、任务暂停开关。判断要点是变量只有一个线程负责改其他线程只读。如果你发现自己试图用 volatile 去“锁”住一段业务逻辑基本说明思路不对应该考虑换 synchronized 或者并发容器。还有一个细节volatile 修饰数组时volatile 只对数组引用本身生效数组里的元素仍然没有 volatile 语义。如果多线程要操作共享数组的元素可以用 AtomicIntegerArray、AtomicLongArray 或者干脆换成并发容器直接避免在裸数组上做并发操作。4. 有序性重排序是如何把代码“悄悄换顺序”的4.1 编译器、CPU、指令重排序有序性问题最难理解因为它违反了我们的直觉代码写成一二三四凭什么执行顺序变成一三四二为了提升性能编译器和 CPU 会做指令重排序。编译器在生成字节码或机器码时认为只要不影响单线程的执行结果就可以调整某些无关指令的顺序CPU 执行时又可能通过乱序执行管道把指令顺序再调一次甚至内存系统也可能把写入刷回的顺序凑一凑。这一切在单线程下都是“优化”多线程下就可能是灾难。例如int a 0; int b 0; void write() { a 1; // 操作1 b 2; // 操作2 } int read() { if (b 2) { return a; } return -1; }如果线程 1 执行 write线程 2 执行 read操作 1 和操作 2 没有数据依赖编译器可能把 b2 提到 a1 前面。结果线程 2 看到 b2 时a 可能还是 0于是 read 返回 0。如果没有重排序read 应该返回 1。这个例子看着很做作实际工程里类似问题在“发布对象”时非常常见。4.2 经典案例双重检查锁单例为什么需要 volatile最经典的实战案例是双重检查锁DCL单例。我第一次看这段代码时也觉得奇怪外层判空、内层加锁、再次判空已经很完美了为什么非要加 volatilepublic 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; } }new Singleton() 在字节码层面大概是三步分配内存空间、执行构造函数完成初始化、把对象的引用赋值给 instance。第三步和第二步之间JVM 在不改变单线程语义的前提下可能把顺序调成分配内存、把引用先赋给 instance、再执行构造函数。如果这个重排发生了线程 A 执行到“把引用赋给 instance”构造函数还没跑完线程 B 正好进来看到 instance 非空直接返回并开始使用。B 拿到的是一个内存空间分配了但构造函数尚未执行完的“半初始化对象”在构造函数里初始化的字段全部是默认值接下来就是各种不可预期的空指针和业务错误。加 volatile 后写 volatile 变量会插入 StoreStore 屏障禁止把构造函数中的普通写操作重排到 volatile 写之后也就是禁止“提前发布引用”。这样 instance 被赋值时构造函数一定已经完成其他线程读到的一定是完整对象。如果不想用 volatile可以用静态内部类懒加载方式或者直接在枚举单例里写Java 会保证枚举常量的初始化安全连双重检查锁都省了。4.3 happens-before 规则判断可见性与有序性的依据面试时如果只背“volatile 禁止重排序”其实不够真正能帮你推理一切并发代码的是 happens-before先行发生原则。只要两个操作满足 happens-before前一个操作的结果对后一个操作可见且前一个操作不会“跑到”后一个操作后面去。我整理下最常用的几条程序次序规则同一个线程内写在前面的代码先行发生于后面的代码监视器锁规则解锁操作先行发生于同一个锁后续的加锁操作volatile 变量规则对一个 volatile 变量的写操作先行发生于之后对同一个变量的读操作线程启动规则线程对象的 start() 方法先行发生于该线程内部的所有动作线程终止规则线程中的所有操作先行发生于其他线程对该线程的 join() 成功返回传递性如果 A 先行发生于 BB 先行发生于 C那么 A 先行发生于 C。为什么 synchronized 能同时保证原子性、可见性、有序性因为它满足监视器锁规则锁内代码执行完退出锁对后续拿锁的线程可见同时锁又保证同一时刻只有一个线程进入临界区临界区内的操作天然不会被并发拆散。volatile 则靠 volatile 变量规则保证“先写后读”的有序和可见。我建议你在设计并发代码时不要凭感觉说“这里应该没问题”而是找一条 happens-before 路径是否满足某一规则如果找不出来说明这段数据流还没有被正确同步出了问题不要惊讶。4.4 关于有序性的实操心得别把希望放在“概率”重排序问题最难办的是“概率不可控”。你写了一个看起来有点问题的并发代码跑了几百次都正常投放线上后过了两周突然炸了这种“幽灵 bug”最消耗精力。所以我实际操作时有一个习惯任何涉及共享变量初始化和发布的代码一律按严格的安全发布方案写。要么用 final 字段配合安全发布要么用并发容器要么在最坏情况下都加上 volatile 或者锁。这看起来很保守但能避免半夜被手机震醒。此外想研究重排序底层可以用 OpenJDK 的 jcstress 并发压力测试框架。它能生成专门的并发测试用例用很多线程反复压同一个操作把那些低概率的乱序行为暴露出来。普通项目里不会让你写 jcstress 测试但看几个官方示例会对“重排序居然真的存在”这件事有非常直观的认知。如果你排查线上问题抓到可疑堆栈后怀疑是重排序不看汇编往往很难定论。可以加上 -XX:PrintAssembly 看 JIT 生成的汇编指令或者用 -XX:-TieredCompilation 之类的参数改变编译策略然后对比问题是否复现。对多数开发者而言重点是先把正确的同步手段写好而不是去证明“到底哪一步重排了”。5. 面试与实战速查避坑清单和记忆方法5.1 面试官最爱问的问题现在网上都是“Java 并发面试题”这类内容其实翻来覆去核心就那么几个。真正能拉开差距的不是背答案而是理解为什么。Q1volatile 能保证原子性吗 不能。volatile 只保证可见性和有序性不保证复合操作的原子性。计数器场景要用 AtomicInteger 或者加锁。Q2synchronized 和 volatile 有什么区别 volatile 是轻量级的“变量级同步”无锁、不阻塞修饰变量synchronized 是重量级的“代码块级同步”有锁竞争和线程阻塞但性能并不一定差。synchronized 可以保证原子性、可见性、有序性volatile 只能保证后两个。Q3为什么双重检查锁单例要加 volatile 因为 new 一个对象不是原子的可能发生重排序导致引用被提前发布其他线程拿到“半初始化”对象。volatile 禁止构造步骤重排保证了对象的完整发布。Q4有哪些 happens-before 规则 程序次序、监视器锁、volatile 变量、线程启动、线程终止、传递性。面试时说出前五条再补一句传递性基本就够了。Q5多线程都要读一个状态标志位改了之后要立刻看到用什么 用 volatile。只要写入操作不依赖当前值并且只有一个线程去写volatile 是最省事的方案。5.2 三要素对应技术选型速查表场景推荐方案背后原因计数器 / 累加操作AtomicInteger、LongAdderCAS 无锁单个操作原子状态开关一写多读volatile轻量保证可见性不阻塞check-then-act 复合操作synchronized / Lock / 分布式锁需要把检查和执行绑成原子整体懒加载单例静态内部类 / 枚举 / volatileDCL保证安全发布且延迟加载共享集合ConcurrentHashMap、CopyOnWriteArrayListJDK 已封装并发细节跨线程共享 long/doublevolatile long/double 或 AtomicLong防止撕裂写不可变配置final 安全发布发布后不变天然线程安全这张表我面试前必扫一眼不是因为能直接背答案而是它帮我快速定位“当前场景主要缺哪个要素”选型就不容易乱。5.3 记忆口诀原子不拆、可见即达、有序不乱给想快速记忆的朋友一个口诀原子性“一个操作要么全做要么全不做”像数据库事务一样可见性“改完了其他线程马上能看到”像群里发通知一样有序性“逻辑先后别乱套”像先洗菜再下锅一样。严格来说口诀无法覆盖所有边界情况但面试时用来开场特别有用。说完口诀再补一句“实际上这三者经常交叉比如 volatile 保证可见性和有序性但不保证原子性”对方就知道你真的理解而不是背了段顺口溜。5.4 最后的实操提醒从设计上减少并发问题聊了这么多最后想说的其实是并发三要素很重要但最好的并发代码是“没有共享”的代码。能不用共享变量就不用共享变量能用不可变对象就用不可变对象能交给现成并发容器就交给并发容器。自己做同步意味着要同时管理原子性、可见性、有序性三个维度任何一个疏漏都是线上事故。我自己做并发改造时的顺序是先梳理数据是怎么在线程之间流动的找到所有读和写的点然后看能不能把写去掉或收敛到一个线程减少共享如果必须共享再选用对应的同步原语最后用代码审查和并发测试验证。这个过程没有捷径但比靠“跑一次没出错”来判断靠谱得多。最后再分享一个小技巧我给团队定过一条很土但很有效的规则新增任何跨线程共享变量Code Review 里必须写清楚它靠什么保证原子性、可见性、有序性。写不出来就不要合并。这个方法很死板但真的比事后对着玄学 bug 熬夜排查划算太多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →