
1. 那个停不下来的循环先看现象再看结论几年前我接手过一个采集程序主线程负责拉数据后台起了一个工作线程做落库。程序跑起来一切正常但关服务的时候出问题了——点了停止按钮主线程退出了工作线程还在那儿转日志一行行往外刷进程杀不掉只能 kill -9。当时我盯着代码看了半天逻辑上明明写了while (running)主线程里也把running false了怎么就不生效后来把private boolean running true;改成private volatile boolean running true;问题消失了。这件事让我第一次认真去抠Java volatile和synchronized这两个关键字到底在做什么。这两个词几乎所有 Java 面试题都会问但真正干活的时候能分清什么时候该用 volatile、什么时候必须上 synchronized的人比想象中少得多。这篇文章不打算复述教科书定义。我想把这两个关键字拆到能被验证的层面它们各自保证了什么、底层靠什么机制实现、在什么场景下会失效、以及最容易踩的那些坑。顺带还要说清楚一件经常被混淆的事——C 语言里的 volatile 和 Java 里的 volatile本质上不是同一个东西前者是给编译器看的后者是给运行时和 CPU 看的。如果你写过嵌入式、见过类似#define clkcon_uni ((volatile clkcon *) (sfr_base 0x00 * 4))这种寄存器映射宏这个区别会直接影响你对代码行为的预期。内容适合有 Java 基础、写过多线程代码、但没系统梳理过内存模型的读者。全篇的结论都会尽量给到为什么而不是只丢一个volatile 保证可见性不保证原子性就完事。2. volatile 的三张承诺只有两张是硬通货网上流传最广的一句话是volatile 保证可见性和有序性不保证原子性。这句话本身没错但它太压缩了压缩到看不出边界在哪。我把 volatile 的语义拆成三条来谈逐条看它到底承诺了什么。2.1 第一条写操作立刻对其他线程可见volatile 修饰的变量写完之后其他线程再读一定能读到最新值。这是它最核心的价值也是我上面那个running标志能生效的原因。之所以需要这个承诺是因为现代 CPU 有缓存、有写缓冲区Store Buffer、有乱序执行。一个普通变量的写可能先落在某个核心的私有写缓冲区里还没刷到主存另一个核心读的时候自然读到旧值。加了 volatileJIT 在生成的指令里插入屏障强制把写刷出去、把读的缓存失效掉。要理解这一点得先接受一个反直觉的事实多核 CPU 上内存并不是一块大家共享的、随时一致的黑板。每个核心都有自己的 L1/L2 缓存写操作先进 Store Buffer 而不是直接进缓存读操作可能命中本地缓存。所谓的可见性本质是让这些缓冲和缓存之间的状态达成一致而这个同步动作是有成本的所以 CPU 默认不做编译器也默认不做优化之外的额外同步。2.2 第二条禁止指令重排序但只针对这个变量所在的顺序volatile 会阻止编译器和 CPU 把 volatile 读写与周围的其他内存操作随意调换顺序。具体来说JMM 定义了四种屏障在 volatile 写的前后和读的前后按规则插入。这里有个细节很多人不知道volatile 的读和写在屏障插入上是不对称的。volatile 写之前插 StoreStore写之后插 StoreLoadvolatile 读之后插 LoadLoad 和 LoadStore。写后面的那个 StoreLoad 屏障是四类里开销最大的因为它要等所有之前的写都完成才能继续通常对应 x86 上那条带lock前缀的指令。JIT 在 x86 平台上生成 volatile 写时实际是往指令流里塞一条lock addl $0x0,(%rsp)——一个看起来毫无意义的加零操作但它带 lock 前缀能起到内存屏障的作用。这条指令的作用不是做加法而是借它的副作用清空 Store Buffer。这个实现技巧被讨论过很多年属于用一条无害指令换取屏障语义的典型做法。2.3 第三条不保证原子性——这条最容易被忽略volatile int count; count;这个组合是错的而且错得很隐蔽。count在字节码层面是四条指令取字段值、压栈、加一、写回字段。哪怕每一次读和每一次写都是原子的中间也可能被其他线程插进来。考虑两个线程同时执行count初始值为 0线程 A 读到 0线程 B 也读到 0A 写回 1B 写回 1。最后结果是 1而不是 2。volatile 在这里一点忙都帮不上因为它保护的是单次读或单次写的可见性保护不了读—改—写这个组合。要修这个问题路有三条用AtomicInteger的incrementAndGet()底层 CAS 自旋用synchronized把整个复合操作圈起来或者用LongAdder这类分段累加的方案。具体选哪个取决于竞争强度后面会细说。提示判断一个操作是否需要用锁先问自己这个操作是不是由多个步骤组成、且中间状态对别的线程可见。只要答案是肯定的volatile 就出局了。3. 内存屏障与 happens-before可见性凭什么被保证理解了 volatile 的承诺下一个问题是它凭什么能保证答案要从 JMMJava 内存模型和 CPU 内存模型两头看。3.1 happens-before 是规则屏障是实现手段JMM 给出的是一套抽象规则其中最相关的一条是对一个 volatile 变量的写happens-before 后续对同一个变量的读。happens-before 不是时间上先发生而是一种保证——如果 A happens-before B那么 A 的结果对 B 可见。这条规则有个容易被低估的延伸volatile 写之前的所有普通变量写也会因为这条规则对读线程可见。这就是所谓的传递性可见。比如下面这种写法// 线程 A config loadConfig(); // 普通变量 ready true; // volatile 变量 // 线程 B while (!ready) { /* 自旋 */ } use(config); // 这里能安全读到 loadConfig 的结果线程 A 里对config的写虽然在ready之前普通写本身不保证可见但因为ready是 volatile 且写入顺序在后JMM 会把ready写之前的所有操作一起捎带过去。线程 B 读到ready true时config一定已经是新值。这个模式在实践里非常有用它是用一个 volatile 标志位发布一组数据的基础。我见过有人把发布标志写成普通变量然后把数据字段全写成 volatile结果是数据字段各自可见了但读写顺序还是乱的——方向完全搞反了。3.2 四种屏障和它们真正拦住的东西JMM 定义的四种屏障作用可以这样理解屏障类型拦住的重排典型插入位置LoadLoad后面的读越过前面的读volatile 读之后StoreStore后面的写越过前面的写volatile 写之前LoadStore后面的写越过前面的读volatile 读之后StoreLoad后面的读写越过前面的写volatile 写之后StoreLoad 是唯一一个双向性质的屏障也是最贵的。它要求屏障前的所有写都完成并可见之后屏障后的读写才能开始。x86 处理器本身是 TSO 模型读读、读写、写写天然不会重排所以前三类屏障在 x86 上通常不生成实际指令只有 StoreLoad 必须显式实现——这就是那条lock addl的由来。而在 ARM 这类弱内存模型平台上四类屏障都可能生成对应指令开销更大这也是为什么同一份并发代码在不同架构上表现不同。注意不要把volatile 禁止重排序理解成编译器不会移动任何代码。它只是保证特定顺序关系不被破坏屏障之外的操作编译器和 CPU 依然可以自由优化。3.3 用 javap 把承诺变成眼睛能看见的东西说了这么多抽象规则验证方式其实很朴素把代码编译出来看字节码。volatile写对应的是putfield/putstatic指令加上访问标志里的 ACC_VOLATILE读对应getfield/getstatic。字段访问标志可以从javap -p -v的输出里看到标志位上会明确标出ACC_VOLATILE。真正体现屏障的是 JIT 之后的机器码。加上-XX:UnlockDiagnosticVMOptions -XX:PrintAssembly参数你能看到那条带 lock 前缀的指令明晃晃地出现在汇编输出里。这一步看起来折腾但当你怀疑volatile 到底有没有生效的时候看汇编是唯一能给出确凿答案的方式。我早期排查一个伪共享问题时就是靠这个确认了 volatile 写确实在刷缓存行。4. synchronized 的全能性来自哪里Monitor 与锁升级如果说 volatile 是一把精确的螺丝刀synchronized 就是一把瑞士军刀。它同时提供原子性、可见性、有序性代价是更重的开销和更粗的粒度。4.1 它一次解决三个问题synchronized 的语义建立在 Monitor监视器锁之上。进入同步块时执行 monitorenter退出时执行 monitorexit编译器会自动生成异常路径上的 monitorexit保证异常时也能释放锁。这把锁带来的保证是原子性同一时刻只有一个线程能进入被同一把锁保护的临界区复合操作不会被穿插。可见性锁释放时临界区内所有写操作都会刷新锁获取时会读取最新值。JMM 中对应的是对一个锁的解锁 happens-before 后续对同一个锁的加锁。有序性临界区内的代码相对于锁的获取和释放不会被重排到外面去。注意可见性这一条——synchronized 的可见性保证其实比 volatile 更强。volatile 只能让一个变量的读写可见而锁的释放会把整个临界区的写都刷出去。这也是为什么有时候你把代码从 volatile 改成 synchronized 之后莫名其妙就好了因为顺手把复合操作和可见性一起解决了。4.2 锁升级从无锁到重量级锁的四个阶段早期版本的 synchronized 效率很差因为每次都要走操作系统互斥量涉及用户态和内核态切换。后来 JVM 引入了锁升级机制JDK 15 之后偏向锁默认关闭JDK 18 直接移除了偏向锁实现所以现在实际是三级无锁状态对象刚创建Mark Word 里存的是哈希等信息。偏向锁已废弃同一个线程反复进入同一把锁就在对象头里记下这个线程 ID之后进入不需要任何 CAS。单线程访问同步块的场景下几乎零开销。因为撤销成本高、在现代应用里收益不明显最终被移除。轻量级锁出现竞争时线程在自己的栈帧里建一个 Lock Record用 CAS 尝试把对象头的 Mark Word 指向它。成功就拿到锁失败就自旋重试。适用于竞争不激烈、临界区执行很快的场景。重量级锁自旋到一定次数还拿不到就膨胀成真正的互斥量线程被挂起进入阻塞态等待唤醒。这时候就涉及内核态切换开销陡增。理解这条升级路径的实际意义在于synchronized 的性能不是恒定的它取决于竞争程度。无竞争或低竞争时它的开销可能和 volatile 差不了太多一旦竞争激烈导致锁膨胀开销就上去了还会带来上下文切换和线程调度的额外成本。4.3 编译器还会替你做一些事除了运行时优化JIT 还有两个编译期优化值得知道锁消除如果逃逸分析发现某个锁对象只在一个线程内使用根本不会被其他线程访问那这个同步块会被直接抹掉。锁粗化如果一段代码里连续对同一个对象加解锁多次编译器会把它们合成一个更大的临界区减少反复加解锁的开销。这两个优化是看起来代码写得不讲究但跑起来却不慢的常见原因。反过来说做性能分析的时候如果只盯着源码看容易得出错误结论——要看的是优化后的实际执行路径。提示StringBuffer的每个方法都是 synchronized 的但在单线程里拼接字符串并不慢原因就是逃逸分析把不可能逃逸的那些锁给消除了。这个例子经常被拿来解释为什么不能只看源码判断锁开销。5. C 的 volatile 和 Java 的 volatile 是两种动物这一节是我最想写的。搜索volatile 关键字的作用时出来的一半是 C 的、一半是 Java 的混着看特别容易建立错误的心智模型。这两者只是共享了同一个名字解决的问题完全不同。5.1 C 的 volatile 是给编译器下的命令在 C 语言里volatile的作用是告诉编译器这个变量的值可能在你不知道的时候被改变所以每次访问都必须真真切切地从内存里读、往内存里写不许优化掉不许缓存在寄存器里。它防的是编译器的优化行为。三种典型场景必须用它内存映射的硬件寄存器。像#define clkcon_uni ((volatile clkcon *) (sfr_base 0x00 * 4))这种写法把一个固定地址强转成结构体指针用来读写外设的控制寄存器。这个地址背后是硬件硬件会在任何时刻改变它的值。如果编译器发现你连续读两次这个变量、中间没写它可能把第二次读优化成复用第一次的结果——而在硬件场景里第二次读可能已经返回了全新的状态。中断服务程序与主程序共享的变量。中断可能在两条指令之间发生编译器的寄存器缓存假设在此失效。setjmp/longjmp场景下的局部变量。非 volatile 的局部变量在 longjmp 之后的值是不确定的。关键在于C 的 volatile 不提供任何线程同步语义、不保证原子性、不保证多核间的可见性、也不插入任何内存屏障C11 之前的标准里没有。它在多线程程序里几乎起不到同步作用。真正做线程间同步要用_Atomic、atomic_*系列或者平台提供的屏障和原子指令。5.2 Java 的 volatile 面向的是运行时的内存模型Java 的 volatile 从一开始就是为多线程设计的它绑定的是 JMM 里的 happens-before 规则实现上依赖 JIT 生成的屏障指令影响的是 CPU 层面的缓存一致性和指令顺序。换句话说C 的 volatile 管的是编译器的眼睛别瞎猜Java 的 volatile 管的是CPU 和缓存的账要对齐。一个在编译阶段起作用一个在运行时起作用。对比维度C 语言的 volatileJava 的 volatile面向对象编译器优化编译器 CPU 内存模型主要用途硬件寄存器、信号处理、中断共享变量线程间可见性、有序性原子性完全不保证单次读/写原子复合操作不保证内存屏障标准不要求C11 前JMM 规定JIT 插入多核可见性不保证保证正确替代方案_Atomic、原子指令、互斥量AtomicXxx、synchronized、Lock理解这个区别的实践价值在于不要把 C 里的经验搬到 Java也不要反过来。我见过从嵌入式转过来的同学觉得 volatile 是个轻量的同步手段在 Java 里拿它当锁用结果计数器丢数据也见过 Java 背景的同学写驱动代码以为 C 的 volatile 能保证多核一致结果读到的寄存器值一直不对。注意C 的 volatile 语义接近 C同样不提供同步保证C11 起有了std::atomic才补上这块。如果你在 C/C 里做多线程共享状态正确的工具是原子类型或互斥量不是 volatile。6. 双重检查锁与开关标志的写法对照理论说完了来看两个最能体现差异的实际场景。6.1 双重检查锁为什么那个 volatile 一个都不能少懒加载单例的经典写法public class ConfigHolder { private static volatile ConfigHolder instance; private Config data; public static ConfigHolder getInstance() { if (instance null) { // 第一次检查避免每次都加锁 synchronized (ConfigHolder.class) { if (instance null) { // 第二次检查避免重复创建 instance new ConfigHolder(); } } } return instance; } }这段代码里如果去掉 volatile会出现经典的部分初始化对象问题。new ConfigHolder()在字节码层面大致分三步给对象分配内存、执行构造方法初始化字段、把引用赋值给instance。第 2 步和第 3 步之间没有数据依赖编译器和 CPU 都可能把赋值提前到初始化完成之前。于是可能出现这样的情况线程 A 执行到引用已赋值、字段还没初始化完线程 B 在第一次检查时看到instance ! null直接返回了这个半成品对象然后拿着data字段去用得到 null 或者垃圾值。这种 bug 出现概率极低只在特定时序和特定硬件下才触发排查起来极其痛苦。加上 volatile 之后写操作后面的 StoreLoad 屏障保证了初始化完成一定发生在引用发布之前问题消失。那个锁的作用和 volatile 完全不一样synchronized 保证只有一个线程能创建实例volatile 保证发布出去的对象是初始化完整的。两者分工明确缺一个都不行。这也是我常用来解释两个关键字差异的最佳案例——它们在同一段代码里各司其职互不替代。顺便说一句如果单例的初始化不涉及复杂逻辑直接用静态内部类或者枚举更省心既不用加锁也不用 volatile靠类加载机制保证线程安全。双重检查锁更多是作为一种理解内存模型的练习。6.2 状态标志volatile 的主场反过来如果只是想让一个线程感知到另一个线程的信号volatile 是更合适的工具public class Worker implements Runnable { private volatile boolean stopped false; public void shutdown() { stopped true; } Override public void run() { while (!stopped) { doOneRound(); } cleanup(); } }这里用 synchronized 也能实现但为了一个布尔标志加锁纯粹是浪费——加解锁的开销全花在了本该由一条屏障指令解决的事情上。当然要用synchronized的话得注意while (!stopped)里不能出现长时间的操作否则感知延迟会很感人。这个模式的变体是配置热更新把配置对象声明为 volatile写线程构造好新的不可变配置对象后整体替换引用读线程每次用之前先拿一次引用。private volatile RouteTable table RouteTable.empty(); public RouteResult route(String path) { RouteTable snapshot table; // 只读一次保证整轮使用的是同一份 return snapshot.match(path); }注意RouteTable snapshot table;这一行。它不是为了省一次字段访问而是为了保证后续所有读用的是同一个快照。如果直接在方法里多次读table中间可能被替换导致逻辑不一致。这个写法叫先取快照再用在无锁编程里非常常见。6.3 什么时候必须换成锁判断标准很简单只要涉及读—判断—写或者读—计算—写的复合过程且中间状态不能被别的线程看到volatile 就不够用。// 错误检查再执行的复合操作 private volatile int balance 100; public boolean withdraw(int amount) { if (balance amount) { // 线程 A 和 B 可能同时通过检查 balance - amount; // 双双扣款余额变负 return true; } return false; }这段代码的问题在于检查和扣款之间存在间隙。修法有两种要么整段加锁要么用 CAS 循环private final AtomicInteger balance new AtomicInteger(100); public boolean withdraw(int amount) { while (true) { int current balance.get(); if (current amount) return false; if (balance.compareAndSet(current, current - amount)) return true; } }CAS 版本在低竞争时性能远好于锁但在高竞争下会疯狂自旋、白白消耗 CPU而且存在 ABA 问题值从 A 变 B 又变回 ACAS 检测不出来需要用版本号来解决。所以选型不是哪个快用哪个而是哪个更匹配当前的竞争强度和正确性要求。7. 选型判断表三种需求对应三种武器把前面的内容收敛成一张可对照的决策表。这张表是我自己排查并发问题时用得最多的工具每次遇到不确定的代码先对号入座。场景特征推荐方案理由单个标志位、状态开关只写一份、其他线程只读volatile只需求可见性和有序性成本最低发布不可变对象配置快照、单例volatile 不可变对象保证引用赋值与对象初始化不重排计数器、累加器AtomicInteger / LongAdder需要原子性CAS 或分段避免锁多个字段需要一起更新且保持一致synchronized / Lock复合操作需要整体原子性需要条件等待等某个条件成立再继续synchronized wait/notify 或 Condition涉及线程挂起与唤醒volatile 无法实现高竞争、临界区较长、需要公平性或超时ReentrantLock提供可中断、可超时、公平锁等能力读多写极少的共享映射ConcurrentHashMap / 读写锁通用并发容器已做充分优化几个容易被忽略的判断要点第一优先考虑能不能不改共享状态。很多并发问题的最优解是让数据不共享——用 ThreadLocal 隔离、把可变对象改成不可变对象、用消息传递代替共享内存。加锁和 volatile 都是在共享不可避免时的妥协。第二volatile 的开销并不总是零。读操作在 x86 上确实几乎无成本但写操作带着 lock 前缀会强制刷 Store Buffer在写密集的场景下比如一个高频更新的指标字段性能下降是能测出来的。如果读方根本不需要实时看到最新值换成周期性读取或者批量刷新反而更合适。第三synchronized 的重是相对而言的。在 JDK 6 之后经过锁升级、锁消除、自适应自旋等一系列优化低竞争场景下的 synchronized 性能已经相当可观。我做过一个粗糙的对比测试单线程反复进入同一个同步块和一次 volatile 写的耗时差距在个位数纳秒级别只有在竞争线程数上来之后差距才被拉开。所以不要因为怕慢就无脑避开 synchronized先测量再决定。第四别把 volatile 和 synchronized 当成二选一。它们经常同时出现各管一段——DCL 就是最典型的例子。正确的思路是问我这段代码需要保证什么而不是我该用哪个关键字。8. 反汇编与压测怎么证明你的判断是对的最后聊方法论。并发代码的特点是错的代码也能跑对很多次所以靠肉眼审查和跑一遍看看是远远不够的。8.1 我的排查顺序遇到并发相关的诡异现象我一般按这个顺序走先确认现象是否可复现。写一个最小的复现程序把线程数、循环次数放大到能在几秒内触发。看不到现象就谈不上验证修复。检查共享变量的声明方式。逐个过一遍哪些字段被多个线程读写、每个字段是怎么声明的。这一步能解决大部分问题因为大部分并发 bug 就是漏了 volatile 或者该加锁的地方没加。看字节码确认语义。javap -p -v看字段标志位有没有 ACC_VOLATILE看同步块有没有正确生成成对的 monitorenter / monitorexit。必要时看汇编。-XX:PrintAssembly确认屏障指令真的生成了。这一步在生产环境用不上只在怀疑 JIT 优化行为时作为最后手段。用压测工具验证。JMH 是标准选择它处理了预热、死代码消除、常量折叠这些坑。手写的System.currentTimeMillis()循环测出来的数字基本不可信我自己早期就吃过这个亏测出来的volatile 比锁慢十倍结论完全是 JIT 没预热导致的假象。8.2 专门的并发测试工具如果要验证一个并发数据结构的正确性可以考虑 JCStress。它是 OpenJDK 官方维护的压力测试工具能穷举各种交错执行顺序专门用来发现内存模型层面的问题。用法是先写一个带Actor注解的测试类然后用它提供的运行器跑最后看结果里有没有出现 FORBIDDEN 的观测值。这东西上手有点门槛但它是唯一能系统性地验证我的无锁代码在各种重排序下都正确的手段。我建议每个写过 CAS 循环的人都至少用它验证一次自己的代码看到自己写的东西在某个交错下真的翻车那种印象比读十篇文章都深刻。8.3 几个我踩过的坑坑一以为 volatile 数组是数组元素 volatile。volatile int[] arr只保证 arr 这个引用本身可见arr[0] 1完全不受保护。要让元素有 volatile 语义得用AtomicIntegerArray或者VarHandle的数组视图。坑二在 volatile 上做复合判断。if (flag other)这种flag 是 volatile 但 other 是普通变量判断结果依然可能不一致。要么两个都 volatile要么整段加锁。坑三把 volatile 写当成同步点指望它保护后续代码。volatile 写之后的代码不会被屏障拦住它只保证之前的写有序。方向搞反了是很常见的错误。坑四在 32 位 JVM 上读写 long/double。这两个类型是 64 位的非 volatile 情况下 JVM 允许拆成两次 32 位操作可能读到半个新值半个旧值。加了 volatile 之后才保证 64 位读写的原子性。现在虽然基本都是 64 位环境但如果你的代码要跑在特殊平台上这个点必须记住。坑五用 volatile 修饰对象字段来替代整体替换。想让一个对象的多个字段同时生效正确做法是把整个对象做成不可变对象然后替换引用volatile 修饰引用而不是给每个字段都加 volatile 然后逐个更新——后者会出现更新到一半被读到的中间状态。我最后想说的是volatile 和 synchronized 的区别说到底不是两个关键字的区别而是三种保证原子性、可见性、有序性的取舍问题。任何时候只要你能明确回答我这段代码需要这三种保证里的哪几个选型就是顺理成章的事。反过来如果只是背下了volatile 轻、synchronized 重这种口诀遇到真实场景还是会犹豫。