ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java volatile 深度解析:从JMM内存模型到可见性与内存屏障的实战全解

Java volatile 深度解析:从JMM内存模型到可见性与内存屏障的实战全解 第一次在生产环境被 volatile 坑到是接手一个跑了半年的订单轮询服务。服务重启后偶发卡死日志不报错线程堆栈停在某个while循环上CPU 却不高。折腾到凌晨才发现是某个 Java 开发者在多线程共享状态里漏了一个 volatile导致可见性失守工作线程永远读到的是自己工作内存里的旧副本。那次之后我把 JMM、内存屏障、MESI 协议翻了个底朝天。这篇东西就是这些年的一个完整整理volatile 这个 Java 关键字到底做了什么为什么它能守住可见性、又怎么充当有序性的调节器它在实际项目里的四种典型用法还有我踩过的坑和量过的性能数据。如果你正在准备 Java 面试八股文或者写并发代码时总觉得加了 volatile 心里没底再或者是刚接触 Java 并发、搞不清 synchronized 和 volatile 该用哪个这篇应该能省你不少翻文档的时间。1. 从两个线上故障切入volatile 到底在守护什么聊原理之前先把问题摆出来。很多人背得滚瓜烂熟的是volatile 保证可见性和有序性不保证原子性但真到写代码那一刻脑子里没有具体画面就容易漏加或者乱加。我挑两个最典型的场景它们分别对应可见性和有序性看完你再回头看定义感受会完全不一样。1.1 停不下来的 while 循环可见性缺失的典型现场先看一段几乎每个并发教程都会写的代码但它的坑比想象中深public class VisibilityDemo { private static boolean running true; // 注意没有 volatile public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { long count 0; while (running) { count; } System.out.println(循环退出count count); }); worker.start(); Thread.sleep(1000); running false; System.out.println(主线程已把 running 置为 false); } }在大多数机器上跑这段代码你会看到主线程打印了已把 running 置为 false但 worker 线程的循环就是不退出程序一直不结束。原因不复杂主线程改的是自己工作内存里的running还没来得及或者说没必要刷回主内存worker 线程也没意识到主内存里的值变了继续用它自己缓存的那份true做判断。这里有个特别容易被忽略的细节——JIT 编译器的优化会放大这个问题。当while(running)循环体足够简单时JIT 可能直接把running提升成一个寄存器变量循环里连内存都不读了那主内存怎么改都没用。所以你会看到一个诡异现象加-Xint解释模式跑程序正常退出一开 JIT 就卡死。这其实侧面说明了可见性问题不只是内存慢这么简单而是编译器、CPU、缓存三层都可能各干各的。加上 volatile 之后running false这个写操作会立刻对其他线程可见worker 线程每次读都会老老实实去主内存或者经过缓存一致性协议同步后的最新副本取值循环如期退出。这就是可见性的守护者最直白的解释。我踩过的坑是这种 bug 在开发机上往往复现不了。因为你的机器核心数多、负载轻、JIT 优化路径不一样可能刚好每次都读到了新值。等到上了生产几十个线程抢 CPU、JIT 充分预热之后问题才冒出来而且概率极低、极难复现。所以这类代码一旦写错排查成本极高这也是我后来坚持共享可变状态必须显式声明可见性这个习惯的原因。1.2 拿到半成品对象有序性问题的隐蔽杀伤如果说可见性问题还算显性——至少程序会卡住、会出错那有序性问题更阴。它不报错不卡死只是在某个概率下给你一个字段不完整的对象。看经典的延迟初始化场景public class LazyInit { private static Resource resource; // 少了 volatile public static Resource getInstance() { if (resource null) { synchronized (LazyInit.class) { if (resource null) { resource new Resource(); // 问题出在这一行 } } } return resource; } }resource new Resource()这一行在字节码层面其实是三步在堆上分配内存得到一个未初始化对象的引用调用构造方法填充对象字段把这个引用赋给静态变量resource。问题在于步骤 2 和步骤 3 之间没有数据依赖编译器和 CPU 都可能把它们重排序。重排之后变成 1 → 3 → 2对象引用先被发布出去了但构造方法还没执行完。此时另一个线程进来判断resource ! null直接拿走这个引用去用读到的字段全是默认值0、null、false也就是拿到了一个半成品。这种 bug 的可怕之处在于它需要特定的时序、特定的线程调度、特定 CPU 的内存模型才会触发。你在 x86 上可能一年都遇不到一次换个架构或者换个 JDK 版本就冒出来了。而且它表现为业务逻辑错误——比如一个配置对象里 timeout 是 0一个连接池对象里 capacity 是 0——你很难第一时间联想到是并发发布问题。顺带说一句这个场景在 Java 里有个专门的名字叫不安全发布与之对应的安全发布手段有四种静态初始化器、final 字段、volatile 字段、以及锁保护。volatile 是其中最轻量的一种因为它只要求写操作发布、读操作获取不涉及互斥阻塞。1.3 能力边界清单volatile 能做什么做不到什么聊完两个场景把 volatile 的能力边界一次性列清楚这是后面一切讨论的基础。我习惯用一张表来记比背定义快得多能力项volatilesynchronized说明保证可见性是是写后立即可见读前必须取最新值禁止指令重排序是是通过内存屏障实现保证原子性否是volatile 的i依然不安全阻塞线程否是volatile 无锁不会挂起线程适用场景一写多读、状态标志、安全发布复合操作、临界区互斥选错代价很大有个常见误区值得单独说volatile 比 synchronized 快所以能用 volatile 就用 volatile。这个推论有一半是对的——volatile 确实没有锁竞争、线程挂起、上下文切换的开销但它只在单个变量的读或写这个粒度上成立。一旦你的操作涉及读-改-写比如计数器自增或者多个变量之间的一致性约束volatile 就完全不够用了这时候必须上锁或者用原子类。我见过有人在库存扣减上用 volatile int结果超卖了十几件就是这么来的。还有个边界容易忽略volatile 保证的是单次读或单次写的原子性。也就是说volatile long的读写在 32 位 JVM 上也是原子的不会读到中间 32 位的高低位撕裂但两个 volatile 变量之间的组合操作不构成原子volatile也不能让多个字段的修改对别的线程同时生效。2. 往下挖三层JMM、CPU 缓存与内存屏障知道volatile 管什么之后很自然的问题是它怎么做到的。这一节我从 Java 内存模型讲到 CPU 缓存一致性再讲到内存屏障把这条链路打通。理解这条链路最大的好处是你以后看任何关于并发可见性的问题脑子里都有一张清晰的地图不用再靠背结论。2.1 主内存与工作内存JMM 画的抽象地图《Java 虚拟机规范》里定义的 Java 内存模型JMM是一套抽象规则它规定所有变量存在主内存每条线程有自己的工作内存线程对变量的所有操作都必须先在工作内存里进行不能直接读写主内存。线程之间要传递变量值只能靠工作内存 → 主内存 → 另一个线程的工作内存这条路径。注意关键词是抽象。JMM 里的工作内存并不对应某一块具体的硬件内存它是对 CPU 寄存器、L1/L2 缓存、写缓冲区的一层统一抽象。做这层抽象的目的是为了屏蔽不同硬件平台的差异让 Java 程序在各种机器上表现出一致的并发语义。JMM 定义了 8 种原子操作来约束主内存和工作内存的交互lock、unlock、read、load、use、assign、store、write。volatile 的特殊之处在于它给这些操作额外加了两条约束写对 volatile 变量的写必须立即同步回主内存对应 store write且不能被延迟读对 volatile 变量的读必须从主内存重新读取最新值对应 read load且不能使用工作内存里的缓存副本。用一句话概括普通变量的读写是松散同步的volatile 变量的读写是强制同步的。这解释了为什么 volatile 能解决 1.1 里的循环问题代价是每次访问都要付出同步成本。我在看源码或调优时有个习惯看到 volatile 字段先在脑子里给它贴个标签——这是个跨线程通信点。凡是跨线程通信点都值得多想一层谁写、谁读、写完到读之间有没有依赖关系。这个习惯帮我提前发现过好几次潜在的可见性 bug。2.2 CPU 缓存与 MESI硬件层面的可见性原罪为什么看到旧值这件事在硬件上会发生根因是现代 CPU 的多级缓存架构。CPU 访问主内存的速度和它执行指令的速度差了两个数量级所以每个核心都有自己的 L1、L2 缓存多个核心共享 L3 缓存再往下才是主内存。当核心 A 修改了一份数据它改的是自己缓存里的副本核心 B 缓存里的那份副本还是旧的。如果 CPU 不做任何处理两个核心就会对同一份数据产生不同的认知。硬件层面的解决方案是缓存一致性协议MESI 是其中最广为人知的一种。它给每个缓存行标记四种状态MModified本核修改过和主内存不一致EExclusive独占和主内存一致其他核没有副本SShared多个核都有副本和主内存一致IInvalid本核的副本已失效。核心 B 要读一个处于 M 状态的数据时会先探测总线发现核心 A 持有修改过的副本触发一次写回 失效操作核心 A 把数据刷回主内存并把缓存行标记为 S之后核心 B 才能从主内存重新加载。整个流程会在总线上产生大量的嗅探流量。关键点来了MESI 保证的是最终一致不是立即一致。核心 A 的写操作可能先进入写缓冲区Store Buffer排队等着刷出去核心 B 的读操作也可能先走失效队列Invalidate Queue延迟处理失效通知。这些缓冲机制本来是拿来提升吞吐的但对程序员就意味着在两个操作之间内存状态存在一个短暂的不确定窗口。JMM 里的内存屏障本质上就是给 CPU 下清空写缓冲区处理失效队列这类指令把这个不确定窗口关掉。所以你可以把 volatile 理解成在 JMM 这一层用内存屏障去约束 CPU 的缓存行为从而让多核之间达成一致。注意不要试图用 MESI 去推导所有并发问题。不同的 CPU 架构x86 的 TSO、ARM 的弱内存模型在重排序规则上差异很大JMM 的作用就是给你一套统一的顶层规则让你不必关心底层差异。实际写代码时按 JMM 的 happens-before 规则来推比按硬件细节去猜靠谱得多。2.3 四种内存屏障volatile 的实现底座JMM 定义了四种内存屏障volatile 的读写就是靠它们插入到指令序列里来实现语义的。这是整个 volatile 实现里最硬核的部分也是面试里区分深度的地方屏障类型作用volatile 里的插入位置LoadLoad禁止前面的读和后面的读重排序volatile 读之后StoreStore禁止前面的写和后面的写重排序volatile 写之前LoadStore禁止前面的读和后面的写重排序volatile 读之后、写之前StoreLoad禁止前面的写和后面的读重排序volatile 写之后开销最大看这张表可能有点抽象我换个说法volatile 写前面插 StoreStore后面插 StoreLoad。效果是我这次写之前前面的普通写都已经刷出去了我这次写之后后面的读写都不能越过我到前面去。volatile 读前面插 LoadLoad后面插 LoadStore。效果是我这次读之后后面的读写都不能越过我到前面去。落实到最常见的 x86 平台上情况会简单一些。x86 本身是强内存模型只允许读-读读-写写-写之间做有限的存储转发优化真正需要显式屏障的只有 StoreLoad 这一种。所以 HotSpot 在 x86 上编译 volatile 写时会用一条lock addl $0x0,(%rsp)指令来实现而不是真的去调用mfence。这条 lock 前缀指令会锁住缓存行触发写回和失效天然满足 StoreLoad 的要求同时对寄存器和缓存影响相对可控。这也是为什么很多资料说volatile 在 x86 上比想象的便宜。但我必须提醒这只在 x86 成立。如果你跑在 ARM 服务器上现在云厂商的 ARM 实例越来越多弱内存模型意味着更多屏障会被真正执行volatile 的代价会明显上升。同一个服务从 x86 迁到 ARM 后某些高频 volatile 自旋的代码出现性能下降这在工业界是有过真实案例的。2.4 happens-before 里的 volatile 规则内存屏障是从实现角度看 volatile而 happens-before 规则是从语义角度看它——后者才是你写代码时真正应该依赖的东西。JMM 定义的 happens-before 规则里与 volatile 直接相关的一条是对一个 volatile 变量的写操作happens-before 于后续对同一个 volatile 变量的读操作。这条规则的威力在于传递性。举例来说// 线程 A data 42; // 1. 普通写 ready true; // 2. volatile 写 // 线程 B if (ready) { // 3. volatile 读 int x data; // 4. 普通读 }根据规则1 happens-before 2程序顺序内2 happens-before 3volatile 规则3 happens-before 4程序顺序内。由传递性可得 1 happens-before 4所以线程 B 读到的data一定是 42不可能是默认值 0。这就是用 volatile 发布对象合法性的全部依据。1.2 里的 DCL 单例加了 volatile 之后就正确原因就在这里resource new Resource()是一个 volatile 写构造方法的完成 happens-before 这个写后续线程的 volatile 读 happens-before 对它读字段的操作链条闭合。这里有个实操上的思考方式我一直在用别去关注屏障插在哪、JIT 怎么编译只在纸面上列出哪些操作是 volatile 的写哪些是 volatile 的读然后用 happens-before 连成串看你要保护的数据是否落在这条链的保护范围内。这个方法在处理复杂并发代码时特别有效比去猜底层指令可靠得多。顺带补一句happens-before 是保证可见性的充分条件但不是物理上一定按这个顺序执行。JIT 和 CPU 依然可以自由重排只要最终语义满足 happens-before。这层抽象的存在就是为了让性能和正确性能同时保住。3. 四种典型用法与三个反面教材原理讲完落到代码上。volatile 在实际项目里的用法其实就那么几种我用得最多的有四种同时也有三种翻车姿势反复出现在代码评审里。这一节我把它们都摆出来包括完整可跑的代码。3.1 状态标志位最稳的用法这是 volatile 的标准答案用法也是唯一一个几乎零争议的场景一个线程写、多个线程读的布尔标志位。public class ServerStatus { private volatile boolean shutdownRequested false; public void shutdown() { shutdownRequested true; } public void doWork() { while (!shutdownRequested) { // 处理任务 } // 收尾清理 } }为什么它稳因为它满足三个条件只有一个线程写shutdown 由主控线程调用、没有任何读-改-写复合操作、状态本身是一个独立变量没有多字段一致性要求。我一般会把这类标志位的写法规范成几条标志位只从false变到true单向避免反复翻转带来的额外心智负担标志位命名带语义比如shutdownRequested、initialized、paused不要叫flag不要用Boolean包装类型用基本类型boolean避免自动装箱引入的对象引用语义问题主动声明默认值虽然boolean默认就是false但写出来可读性更好。有个变体值得提一下多状态标志位。如果你需要的不是一个布尔而是运行中/暂停/停止三态可以用volatile int配合常量或者用volatile引用类型指向一个不可变枚举。但状态一多就要警惕多个 volatile 变量之间的一致性volatile 是保不住的这时候老老实实上锁更省心。3.2 双重检查锁单例为什么 volatile 一个都不能少DCLDouble-Checked Locking是面试和实际代码里出现频率最高的 volatile 应用场景也是最能体现有序性价值的地方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(); // volatile 写 } } } return instance; // volatile 读 } }这段代码里三层设计各有各的用意我逐个说明第一次检查if (instance null)解决的是性能问题。绝大多数调用发生在实例已经创建之后无锁判断能避免所有后续线程进入同步块这是 DCL 存在的根本理由。如果去掉它每次getInstance()都要抢锁那还不如直接用静态初始化。第二次检查解决的是竞态问题。两个线程同时通过了第一次检查一个拿到锁开始创建另一个在锁外等待。等锁释放后后者如果不重新判断就会再创建一个新实例那就不是单例了。volatile解决的是有序性问题。没有它instance new Singleton()可能和构造过程发生重排序导致别的线程拿到未初始化完的对象。这一点在 1.2 里已经详细拆过这里不再重复。有个细节很多人第一次看会疑惑return instance这一行算不算 volatile 读严格来说它是一次普通的字段读取但因为前面的if (instance null)已经完成了一次 volatile 读整段逻辑在 happens-before 层面已经成立。不过在 JDK 5 之后只要字段声明成 volatile任何一次读取都会走 volatile 语义所以不用担心。补一句现代 Java 的实践建议能用静态内部类或枚举实现单例的尽量不用 DCL。静态内部类的写法利用类加载机制天然保证线程安全代码更短也不容易出错public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }DCL 的存在价值更多在于需要延迟初始化且初始化代价大、又要处理一些复杂的初始化参数的场景。纯粹为了单例静态内部类是更省心的选择。3.3 一次性安全发布与一写多读一次性发布是 volatile 的另一种高频用法核心模式和 2.4 里的示例一样这里展开讲一个真实场景。假设你有一个配置对象服务启动时从数据库或配置中心加载加载耗时几百毫秒之后被大量请求线程频繁读取public class ConfigHolder { private static volatile Config current; public static void refresh() { Config fresh new Config(); fresh.loadAll(); // 耗时的加载过程 fresh.freeze(); // 转成不可变 current fresh; // volatile 发布 } public static Config get() { return current; // volatile 读 } }这个模式的关键在于发布的对象必须是不可变的。fresh在赋给current之前已经完成了所有字段填充并且不再被修改。这样一来读者拿到的永远是一个完整、一致的对象快照不会看到一半新一半旧的中间状态。如果你实在做不到不可变那至少要保证阅读者只读不改。一旦有人开始改这个共享对象volatile 就不够了得换CopyOnWriteArrayList或者加锁。我用这个模式处理过灰度配置下发、字典表刷新、限流规则热更新这几类需求效果都挺好。唯一要注意的是别写成边加载边可见——也就是别把current fresh放在加载过程中间那样就白搭了。3.4 作为 CAS 的地基AtomicInteger 源码里的 volatile最后一个用法有点幕后java.util.concurrent.atomic包里所有的原子类底层都靠 volatile CAS 撑着。看一下AtomicInteger最核心的自增实现JDK 8 及之后的简化版public final class AtomicInteger extends Number implements Serializable { private volatile int value; // 可见性由它保证 public final int getAndIncrement() { return U.getAndAddInt(this, VALUE, 1); } // Unsafe 里的实现 public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v getIntVolatile(o, offset); // 读最新值 } while (!weakCompareAndSetInt(o, offset, v, v delta)); // CAS 尝试 return v; } }拆解一下这套组合拳getIntVolatile是 volatile 读保证每次拿到的都是最新值weakCompareAndSetInt是 CAS 操作底层是一条cmpxchg指令保证比较并替换这个动作是原子的失败就重试直到成功。volatile 和 CAS 是两种能力互补的机制volatile 解决看到的是不是最新的CAS 解决改的时候有没有被别人插队。缺任何一个原子类都不成立。这也解释了一个常见问题——为什么AtomicInteger在高竞争下会退化成自旋炸弹因为 CAS 失败率高重试次数暴涨CPU 空转严重。这时候就该换LongAdder它的思路是把热点变量拆成多个 cell 分散竞争。理解这层关系之后你看synchronized、ReentrantLock、AtomicXXX这三类工具的定位就清楚了前两者是悲观路线靠阻塞避免竞争原子类是乐观路线靠重试容忍竞争。它们都建立在 volatile 的可见性基础之上只是到了不同层次。3.5 三个反面教材volatile 保证不了原子性的翻车现场说了这么多正确用法也得讲讲我见过最多的三种错误它们都是同一个根因把 volatile 当成了万能的线程安全开关。错误一用 volatile 做计数器。private static volatile int count 0; // 10 个线程各执行 10000 次 count;跑出来的结果几乎不可能等于 100000。道理很简单count是读值 → 加一 → 写回三步。两个线程可能同时读到 100各自加一后都写回 101一次自增就丢了。volatile 只保证每次读和每次写的可见性管不了这三步组成的整体。正确做法AtomicInteger或者在高竞争下用LongAdder或者干脆加锁。错误二用 volatile 做复合条件判断。private volatile int a 0; private volatile int b 0; // 线程 A a 1; b 1; // 线程 B if (a 1 b 1) { ... } // 可能不成立两个写操作是分别进行的中间存在窗口期。线程 B 完全可能看到 a 已经是 1、b 还是 0。volatile 无法让两个独立写变成原子生效。解决办法是把两个字段合并成一个不可变对象用一次 volatile 发布或者加锁。错误三把 volatile 当成锁用试图保护临界区。private volatile boolean locked false; public void doSomething() { while (locked) { } // 自旋等待 locked true; try { // 临界区 } finally { locked false; } }这是典型的手搓自旋锁问题一箩筐检查locked和设置locked true之间存在竞态两个线程可能同时通过检查、没有内存屏障保证临界区内操作不越界、没有可重入、没有公平性。真要用锁就用ReentrantLock要用自旋就用AtomicBoolean的compareAndSet。这三个错误的共同点是volatile 保护的是单个变量的单次读写任何涉及多个操作多个变量的场景它都力不从心。每次做代码评审我看到 volatile 修饰的变量出现在复合表达式里都会多问一句这个操作是原子的吗4. 实测volatile 的性能开销与 JIT 优化观察理论讲了这么多最后一个绕不开的问题是volatile 到底有多贵值不值得为它付出性能代价这里我给出一组我在自己机器上跑出来的数据重点是理解量级和趋势具体的绝对数字跟硬件、JDK 版本关系很大不要直接照搬。4.1 测试环境与测试代码环境信息项目配置CPU8 核 x86_64JDKOpenJDK 17堆内存2G测试框架JMH 1.36避免手写计时的坑测试代码精简版State(Scope.Thread) BenchmarkMode(Mode.Throughput) OutputTimeUnit(TimeUnit.MILLISECONDS) Warmup(iterations 5, time 1) Measurement(iterations 5, time 1) Fork(2) public class VolatileBenchmark { private long plainCounter 0L; private volatile long volCounter 0L; Benchmark public void plainWrite() { plainCounter 1L; } Benchmark public void volatileWrite() { volCounter 1L; } Benchmark public long plainRead() { return plainCounter; } Benchmark public long volatileRead() { return volCounter; } }用 JMH 是有原因的。手写System.currentTimeMillis()计时在单线程循环里几乎测不出差别因为 JIT 会把整个循环优化掉或者把 volatile 操作优化成一次性的。JMH 通过 Fork、Warmup、死代码消除等手段保证测量结果有意义。4.2 结果解读什么时候该担心性能结果趋势大致是这样操作相对吞吐量普通写 100 基准普通写100volatile 写约 55-70普通读100volatile 读约 75-90结论分三层看第一单次开销确实存在但不夸张。volatile 写的吞吐量大约是普通写的六成到七成volatile 读在八到九成。换算到绝对时间一次 volatile 写大约是几纳秒到十几纳秒的量级。对比一下一次synchronized进入和退出在无竞争下也要二十纳秒上下一有竞争就上微秒一次网络调用是毫秒级。所以对于每次业务操作里有一两次 volatile 读写这种场景这点开销完全可以忽略。第二真正贵的是多核之间的缓存行争抢。上面测的是单线程。换成多线程同时写同一个 volatile 变量性能会断崖式下跌因为每次写都要触发其他核心的缓存行失效这就是经典的伪共享问题。如果你的代码里有多个线程高频写同一个 volatile那才是需要警惕的性能热点解决手段是用Contended或者填充把变量拉到不同的缓存行。第三不要过早优化。我见过有人为了省一次 volatile 读把代码写成各种曲折的本地缓存结果引入了可见性 bug得不偿失。正确的判断顺序是先用正确的语义把代码写对再用 profiler 找到真正的热点最后才考虑优化 volatile 的读写频率。顺带分享一个观测技巧用-XX:PrintAssembly或者 JITWatch 看汇编输出。你能直接看到 volatile 写被编译成什么样的指令。在 x86 上你会看到带 lock 前缀的指令一眼就能确认屏障插在哪。这个方法我用来给团队讲课时特别有效比画图直观。5. 排查与避坑volatile 问题速查表与实战心得写了这么多最后落到最容易出价值的部分怎么排查这类问题以及一些不太会写在文档里的经验。这一节我尽量把踩过的坑都倒出来。5.1 常见问题速查表现象可能原因排查手段处理方式线程卡在 while 循环不退出循环条件变量缺 volatile加-Xint跑一遍看是否复现给标志位加 volatile偶发读到对象字段为默认值不安全发布导致重排序检查初始化是否加了 volatile 或 final改用 volatile 发布或静态内部类计数器结果偏小用 volatile 做自增检查是否有、复合操作换 AtomicInteger / LongAdder加 volatile 后性能下降高频写引发缓存行争抢用 async-profiler 看热点减少写频率或用 Contended双检锁单例偶发异常漏加 volatile代码评审补上 volatile多字段状态不一致多个 volatile 无法组合原子列出字段依赖关系合并成不可变对象一次发布5.2 排查思路判断是不是可见性/有序性问题这类问题最难的不是修是意识到它可能是并发问题。我总结了一套判断顺序第一步看有没有共享可变状态。如果一段代码里所有变量都是方法局部变量那它跟并发可见性无关可以直接排除。只有静态变量实例字段并且跨线程访问才需要往这个方向想。第二步看现象是不是偶发、无规律、重启后可能消失。可见性和有序性问题的典型特征是概率性出现、和机器负载相关、在开发机上难复现。如果 bug 是稳定必现的那多半是逻辑错误而不是并发错误。第三步做降级验证。两个手段一是加-Xint关掉 JIT 跑一遍如果问题消失基本坐实是 JIT 优化放大的可见性问题二是临时把共享变量的读写都放进synchronized块如果问题消失那锁提供了 volatile 同等的可见性保证也就验证了方向。第四步上工具。async-profiler 可以看 CPU 时间花在哪、有没有线程一直空转JFRJava Flight Recorder能记录线程状态变化和锁竞争还有 JOLJava Object Layout看内存布局排查伪共享。工具不能直接告诉你这里少了个 volatile但能帮你缩小范围。有个偏门技巧值得记一下用Thread.onSpinWait()替换纯空转。它在自旋循环里给 CPU 一个提示让它进入更省电的状态同时在某些架构上能改善内存同步行为。虽然不是万能药但在写自旋等待逻辑时是个好习惯。5.3 使用禁忌与实操心得最后把这些年的经验浓缩成几条都是我在代码评审里反复强调的心得一宁可多写一个 synchronized也不要漏一个 volatile。这两者的正确性代价是不对称的。加了不必要的 synchronized你损失的是性能而且性能问题容易发现、容易量化漏了 volatile你面对的是一个随机出现、极难复现、可能只影响千分之一请求的 bug。在拿不准的时候选更保守的方案。心得二给共享变量做准入清单。我在团队里推过一个约定任何类的实例字段或静态字段如果会被两个以上线程访问必须在注释里标出谁写、谁读、靠什么保证可见性。写清楚靠 volatile 的 happens-before或者靠 getter/setter 的锁这个习惯帮我们提前拦下了好几次隐患。心得三final 和 volatile 要配合着用。一个对象如果发布之后不再修改把它的字段全部声明成final可以让 JMM 提供额外的初始化安全保证final 字段的写不会被重排序到构造方法之外。这样一来哪怕发布环节用了别的方式而不是 volatile安全性也更有保障。心得四别在 getter/setter 里加 volatile 就以为万事大吉。我见过把整个实体类的每个字段都加上 volatile 的操作觉得这样就线程安全了。结果呢一性能白白损失二多个字段之间完全没有一致性保证读到一半新一半旧的对象照样发生。正确的思路是先想清楚这个对象是不可变共享还是可变共享前者用 final 安全发布后者用锁或者干脆不共享。心得五面试和实战的差距在于边界。面试八股文里问 volatile标准答案就是可见性、有序性、不保证原子性三句话。但实战里真正的分水岭是你能不能一眼判断出当前这个场景落在 volatile 的能力边界之内还是之外。这个判断力没有捷径只能靠多写、多看、多复盘。后面如果要把这块内容再往下延伸我的建议是顺着两条线走一条是往 JMM 的更细节走比如 final 的内存语义、安全发布的各种手段对比另一条是往性能走把伪共享、Contended、LongAdder 的分段思想吃透。这两条路走通了Java 并发这一块基本就没有让你措手不及的地方了。
返回列表