
刚开始写底层代码的朋友十有八九都问过同一个问题一个被 volatile 标记的变量每次访问的时候是不是真的会绕开所有缓存直接去主存里读一次这个问题我在嵌入式项目里被新同事当面问过在技术交流群的投票里看到过半数的是也经常出现在候选人面试反问环节。坦率讲这个说法从编译器模型的角度能勉强解释为什么要加 volatile但从现代 CPU 硬件的角度它基本是错的。真正读懂 volatile 的实现原理需要把三层东西同时搞清楚编译器怎么生成代码、缓存一致性协议怎么搬运数据、语言规范到底承诺了什么。这篇文章我从这三条线展开讲最后用一道经典的16 位计算机按字节编址真题演示 volatile 的能力边界到底在哪里。适合搞嵌入式、写中间件、做 Java 并发排查的读者也适合正在复习计算机基础的朋友。1. 先泼盆冷水volatile 从不承诺直接读主存1.1 这句话错在哪现代 CPU 的存储层次是寄存器 → L1 → L2 → L3 → 主存这样一条链。一次普通的 load 指令绝大多数情况由 L1 缓存直接满足少数情况由 L2/L3 满足真正穿透到 DRAM 主存的只有缓存未命中cache miss。volatile 是一个语言关键字它管不到 L1、L2 还是 DRAM——那个决定权掌握在硬件手里。编译器能做到的只是每次访问都老老实实发出一对 load/store 指令而硬件服务这条指令的数据可能来自缓存也可能来自主存这不是语言层面能指定的事情。所以volatile 每次访问都读主存这句话的错误在于把编译器必须重新读取目标位置偷换成了CPU 必须去 DRAM 取数。前者是语言层面的保证后者是硬件层面的细节两者根本不是一回事。一个很直观的反例一个 volatile 变量在循环里被读一万次。按每次都读主存的说法这应该产生上万次 DRAM 访问程序会慢得离谱。但实际上你会发现只要循环里没有任何写入缓存行一直处于有效状态每次 load 都能在 L1 命中DRAM 的访问次数可以是 0执行速度和普通变量差不了太多。1.2 为什么读主存的说法还是流行这个说法能长期流行有它的合理内核只是它针对的是另一层场景。如果你把视角放在单核 CPU 无缓存的简化模型上编译器不优化、每次都取内存那 volatile 确实约等于每次访问内存。很多单片机入门教程、早期的操作系统教材就是在这样的简化模型下讲课的。八位/十六位单片机上没有复杂的缓存层级寄存器堆就是全部编译器要么把变量放寄存器、要么放内存此时 volatile 的作用被简化为防止编译器把变量优化进寄存器这是成立的。问题在于今天大家手里真正在跑的 x86/ARM 平台都带着完整缓存体系。这个简化说法一旦被搬到现代硬件上会带来两个误解第一个误解是加了 volatile 就无法优化、性能巨大损失。实际上 volatile 只损失编译器层面的优化机会缓存加速依然生效第二个误解更致命加了 volatile 就能保证多线程安全。这句话把缓存一致性协议能帮你做的事错误地算到了 volatile 头上。这里先记住一个结论volatile 解决的是编译器可能拿旧值顶替的问题多核之间数据到底怎么同步是另一套机制——缓存一致性协议和内存屏障。下面我把这两条线的原理分别拆开。2. 数据其实都住在缓存层一次 load 指令的旅行路线2.1 存储体系结构的现实要理解 volatile 背后发生了什么先得知道一次普通 load 指令的真实开销。先看一组数量级参考不同型号差异很大但量级关系是稳定的存储层级典型容量访问延迟量级说明寄存器百字节级别约 0.3ns编译器可以直接操作的变量高速缓存L1 缓存32KB - 64KB约 1ns命中时延迟最低L2 缓存256KB - 2MB约 3 - 5ns通常每个核私有L3 缓存8MB - 32MB约 10 - 40ns多核共享主存 DRAM8GB - 64GB约 60 - 100ns只有缓存未命中才到这里注意到没有主存在这条链的末端而且缓存和主存之间以缓存行为单位交换数据每个缓存行通常是 64 字节。也就是说哪怕你只读一个 int 变量硬件拿到手的是一整条 64 字节的缓存行。这个结构决定了一次读主存代价极高所以硬件设计者想尽一切办法让数据在缓存层级里被反复利用。2.2 缓存一致性协议MESI在干什么现在假设两个核 core0 和 core1 同时高频访问同一个变量。如果每个核都在自己私有 L1 里藏了一份副本双方各改各的程序早就乱套了。所以现代 CPU 用缓存一致性协议协调这些副本最典型的是 MESI 四状态Modified这一行的数据被本核改过主存里的副本已经过期且只有本核持有Exclusive本核独占这一行还没改过和主存一致Shared多个核都持有同一行的副本内容一致Invalid本核持有的副本已失效下次访问必须重新获取。关键动作是写失效当 core0 要写某一个缓存行时协议会先向其他核广播失效消息让它们的副本变成 Invalid然后 core0 才可以把这行数据标记为 Modified。之后 core1 再读这个地址L1 命中不了被迫从缓存体系里重新拉一次。整个过程中的数据来源可能是 core0 的 L1/L2也可能是 L3甚至是主存具体走哪条路取决于数据此刻在全局缓存体系中处于什么位置。这就是可见性的硬件基础一个核的写入要想被另一个核看到靠的不是 DRAM 的读写而是这个失效-重取的传播过程。2.3 volatile 可见性的准确描述如果现在你身边真有一个 volatile 变量被 core0 频繁写、被 core1 频繁读core1 每次拿到的新值并不一定要从 DRAM 来大部分时候是核心之间直接通过缓存层级传递的。甚至有一种极端情况一行数据长期待在某一核的私有 L2 里被频繁修改、频繁读取DRAM 从头到尾没被碰过。所以按现代体系结构volatile 可见性的准确说法是volatile 让编译器每次都发出真实的 load 指令而这条 load 指令通过缓存一致性协议拿到的是整个缓存体系里当前最新的值。这个值可能来自主存也可能来自另一个核的缓存。单凭这一点其实还没解释完多核可见性。x86 上还有一个存储缓冲store bufferCPU 做写操作时有时候会先把结果放进一个写缓冲再慢慢刷进缓存。如果写方还没把 store buffer 刷出去读方就算缓存一致性协议再完善也看不到。这就是为什么很多场景需要内存屏障mfence 或 lock 前缀把缓冲强制排空。C 语言标准里的 volatile 并不会自动帮你插入这些硬件屏障它只解决读方每次都真的去读这一件事。这也是 C 和 Java 里 volatile 语义最大的分岔点我放到第四节细说。3. C/C 视角volatile 约束的从来都是编译器不是硬件3.1 C 标准里 volatile 到底承诺了什么C11 / C11 中volatile 的核心语义是对象可能被编译器不可见的方式修改或者访问它会产生未知的副作用。编译器要满足的要求很直接每次访问都要真正执行一次 load 或 store不得消除、不得合并volatile 访问之间要保持顺序不能随便重排但不承诺原子性不承诺多线程可见性也不自动插入内存屏障。换句话说C 里的 volatile 是给编译器立规矩的工具。它告诉编译器别以为你看到了全部代码这个变量可能被中断处理函数、硬件外设、另一个线程或者调试探针改掉你别自作主张把两次访问合成一次也别把循环里的读取提到循环外面。3.2 反汇编对比优化提升 vs 每次真实加载空口无凭直接看差异。代码很简单// test.c int flag 0; // volatile int flag 0; void wait_flag(void) { while (flag 0) { // 忙等 } } void set_flag(void) { flag 1; }用gcc -O2 -S test.c编译不加 volatile 时生成的核心循环大概是wait_flag: movl flag(%rip), %eax .Lloop: testl %eax, %eax je .Lloop ret注意movl flag(%rip), %eax出现在循环之前只执行了一次。编译器认为 flag 在循环体里没有写操作于是把读取提升到了循环外面直接用寄存器里的值死循环。这种情况下哪怕另一个线程已经把 flag 改成了 1循环也永远感知不到——因为它根本不读内存了。加上 volatile 之后编译结果变成wait_flag: .Lloop: movl flag(%rip), %eax testl %eax, %eax je .Lloop ret每次迭代都有一条真正的movl flag(%rip), %eax从内存位置重新加载。这就是 volatile 在编译器层面最直观的实现原理保证每次访问都产生真实的存取指令。类似的还有冗余读消除。不加 volatile 时int a flag 1; int b flag 2;编译器大概率只 load 一次 flag再复用到两次计算里加了 volatile 之后上面两行代码会生成两条独立的 load。因为在编译器看来两次 load 之间 flag 完全可能被别的东西改掉它不能合并。3.3 volatile 与原子操作、内存屏障的边界回到开头的那个问题C 里的 volatile 能保证多线程安全吗答案是不能。它只是让读的动作每次都发生至于读到的是不是一致的最新值、写的过程有没有被打断都是另一回事。举一个嵌入式最常见的坑ISR 里更新一个 32 位计数变量主循环里判断这个变量。单看编译器层面加了 volatileISR 的写一定发生、主循环的读一定每次发生。但很多 16/32 位 MCU 的一次总线事务只能搬运部分数据如果变量宽度超过一次总线事务读写本身就不是原子的可能在两次 load 之间被 ISR 插入导致一半新一半旧。这时候 volatile 帮不上忙得靠关中断、原子指令或者加锁。C11/C11 引入了std::atomic带 memory_order 参数这才是标准层面为多线程数据同步准备的方案。它既保证编译器不优化又保证硬件层面插入合适的屏障。我项目里的经验是多线程共享数据一律先用 atomic 或锁volatile 只留给外设寄存器、信号处理器和被中断修改的标志位这三类场景。4. Java 视角JMM 把 volatile 改造成了重型武器4.1 Java volatile 比 C volatile 多承诺了什么同一个关键词 volatile到了 Java 里完全变了身。Java 内存模型JMMJSR-133给 volatile 定义了非常强硬的规则写一个 volatile 变量与后续任何线程读同一个 volatile 变量的动作构成 happens-before 关系volatile 字段的读写整体上表现为顺序一致sequential consistency所有线程观察到的 volatile 访问顺序是一致的编译器和 JIT 不能随便重排 volatile 读写的相对顺序。这三点合起来意味着T1 线程写入 volatile 变量 v 之前修改的普通变量只要 T2 线程之后读到 v 的新值那么 T1 写的所有普通变量对 T2 都是可见的。这就是经典的发布publish语义——volatile 写像一扇门把此前的写操作刷出去volatile 读像另一扇门把此后的读操作拉进来。这也是为什么实现 Thread 中断标志、双重检查锁的单例、状态标志数据发布这类场景Java 程序员可以放心使用 volatile而 C 程序员不行。两门语言的 volatile 只是长得像语义质量完全不同。4.2 HotSpot 在 x86 和 ARM 上的落地实现Java volatile 是语言层面的承诺到了 JIT 编译阶段HotSpot 针对 x86 平台的 TSOTotal Store Order内存模型做了非常精简的实现volatile 读就是一条普通movload。x86 的 TSO 模型保证 load 不与后续的 load/store 重排天然满足 acquire 语义所以 Java volatile 读在 x86 上几乎零开销。volatile 写普通movstore 之后紧跟着一条 lock 前缀的无操作指令常见形式是lock addl $0, 0(%rsp)或者直接用带 lock 语义的xchg指令完成写入。lock 前缀的作用是把写缓冲排空让写入真正落到缓存层级并立刻对其他核可见同时作为一条全屏障禁止后续访存指令越过这次写入。对 ARM 这类弱内存模型就没有这么优雅了。ARMv8 上 Java volatile 读写会插入dmb ish这类显式屏障或使用ldar/stlr这类带屏障语义的指令开销比 x86 大一截。这也是Java 并发性能 x86 比 ARM 好的一个底层原因。4.3 一个能肉眼复现的可见性 Demo这段代码是经典的多线程不可见演示public class VolatileVisibility { static boolean stop false; // static volatile boolean stop false; public static void main(String[] args) throws Exception { Thread worker new Thread(() - { long count 0; while (!stop) { count; } System.out.println(worker exited, count count); }); worker.start(); Thread.sleep(1000); stop true; worker.join(); } }用默认参数跑即开启 JIT不加 volatile 时worker 线程很可能永远不退出。原因和 C 里一模一样JIT 在编译这个热循环时发现 stop 在循环体内没有被赋值把读取提升到了循环外于是主线程修改 stop 之后worker 永远在读一个寄存器里的旧值。有人会误以为是硬件缓存导致的不可见。其实不是。现代 x86 缓存的 MESI 协议本身会把主线程的写入传播过去真正的元凶是 JIT 的寄存器缓存和指令重排。加上 volatile 之后JIT 无法做这个提升每次迭代都真实访问内存修改就立刻可见了。我做这类验证时踩过两个坑一是必须给 JIT 留预热时间。循环跑得不够热JIT 不会触发优化现象可能一直不出现二是别用-Xint纯解释执行来复现。解释执行会掩盖 JIT 的优化给你一种错误的安全感。想确认根因用-XX:PrintAssembly看汇编能看到 stop 的 load 是否被提升到了循环外。5. 一道16 位计算机真题背后的残酷真相读一次还不够5.1 按字节编址、存取 16 位到底意味着什么如果把 volatile 和主存放在一起考计算机组成原理里有一道非常经典的题某 16 位计算机的主存按字节编址存取单位为 16 位。这道题的陷阱在于按字节编址和存取单位为 16 位是两个不同维度——地址的最小单位是字节每个字节都有一个独立地址但一次总线事务能搬运的最小数据单元是 16 位两个字节。这意味着char 变量虽然只有 8 位访问它时总线照样按 16 位事务处理靠字节使能byte enable信号屏蔽掉另一半一个 32 位 int 变量无论地址是否对齐一次总线事务都搬不完必须拆成两次 16 位访问而这两次访问之间发生了什么volatile 完全管不到。5.2 撕裂读是怎么发生的设想一个 32 位计数器由中断服务程序更新主循环读取用于判断。变量声明成 volatile int。每次主循环读它编译器都会老实生成两条 load 指令各取 16 位。但问题来了如果第一条 load 取到低 16 位之后、第二条 load 取高 16 位之前中断到来并更新了计数器的高 16 位主循环最终拿到的结果是低半部分是旧值、高半部分是新值——一个拼凑出来的数字既不是修改前的值也不是修改后的值。这种现象叫撕裂读torn read。volatile 保证了每次都读但保证不了一次读完整。语言标准层面它从来就没有承诺过原子性。5.3 volatile 解决陈旧不解决撕裂这里可以给 volatile 做一次很清晰的责任划分问题表现解决手段陈旧stale拿到的值可能不是最新的volatile禁止编译器消除/提升 load撕裂torn一次读得到几个不同时刻的值拼起来关中断、硬件原子指令、或拆成不超过总线宽度的子变量并配合同步很多初学者把这两者混在一起最后写出来的代码往往就是加了 volatile 依然有 bug、不加 volatile 也有 bug的夹生状态。我的建议是先在纸上把编译器优化总线宽度/原子性缓存一致性/内存屏障三个层面的风险分别列出来再决定每个变量用哪种手段排查问题会清晰很多。6. 在自己的机器上验证反汇编与并发实测6.1 C 反汇编验证从 objdump 看 load 指令第一步很简单gcc -O2 -S test.c看 wait_flag 循环体里有没有movl flag(%rip), %eax或者objdump -d a.out | grep -A20 wait_flag效果一样。对比加不加 volatile 两份汇编一眼就能看出编译器是否做了提升。如果不想动源码还有一个常用招数用空 asm 做编译器屏障。int flag 0; int read_and_inc(void) { asm volatile( ::: memory); int tmp flag; asm volatile( ::: memory); return tmp 1; }asm volatile( ::: memory)不产生任何机器指令但会告诉编译器内存可能被外部修改了别跨过这里做优化。注意这依然只是编译器屏障不影响 CPU 层面的重排如果需要硬件屏障得用__sync_synchronize()或 C11 atomic 的 fence。6.2 Java 并发 Demo从 PrintAssembly 看提升把 4.3 节的循环退出 Demo 编译好分别用不加 volatile和加 volatile跑。建议用默认 server 模式给足 JIT 预热时间循环至少跑几百万次。如果现象不出现可以用-XX:PrintAssembly确认 load 是否被提升到循环外。想进阶一点可以引入 jcstress 做黑盒验证它能枚举交叉执行的时序给出哪些结果被 JMM 允许、哪些被禁止的结论比手写多线程测试可靠得多。6.3 用 perf 观测主存访问量的意外结果如果想亲眼看到volatile 并不是每次都读主存可以在 Linux 上用perf stat观察一段反复读 volatile 变量的循环重点看cache-misses和cycles两个计数器。你会发现即使变量是 volatilecache-misses 也可能依然是 0——因为访问一直命中缓存根本没有触及 DRAM。真正变高的是指令数多了很多 load 指令这是编译器层面放弃优化带来的成本。这个实验顺便帮我纠正过一位同事的认知他担心给外设寄存器加 volatile 后系统会卡成幻灯片。实测发现外设寄存器访问频率远低于处理器缓存命中的范围volatile 的开销基本可以忽略真正火烧眉毛的性能问题从来不是有没有多读内存而是有没有做无意义的锁竞争和内存屏障。最后分享一个小技巧。排查加不加 volatile 有区别的 bug 时我习惯先分两步第一步看编译产物确认是不是编译器把读取提升或消除掉了第二步看硬件层面确认是不是缓存一致性或 store buffer 的问题。多数并发 bug 都死在第一步也就是 JIT 或编译器干的好事极少数才需要动用内存屏障、原子指令这些重型武器。理解 volatile 的实现原理就是学会在语言承诺和硬件机制之间划清那条线——这条线画清楚了很多看似玄学的并发问题都能变成可以推理的正常工程问题。