尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

volatile和synchronized区别:Java与C语言关键字详解

发布时间:2026/9/29 1:01:39

资讯中心
01
ARTICLE

volatile和synchronized区别:Java与C语言关键字详解

volatile和synchronized区别:Java与C语言关键字详解
1. 先把这两个关键字的定位钉死我这些年带过不少刚入行的同学发现一个特别有意思的现象几乎所有人第一次被问「volatile 和 synchronized 有什么区别」时都会背出那句标准答案——「volatile 保证可见性不保证原子性synchronized 两个都保证」。背是背对了但真到写代码的时候该踩的坑一个都不少。有人用 volatile 修饰一个计数器然后开十个线程去自增跑完发现结果每次都不到十万也有人听说 synchronized 性能差硬是把一段需要原子性的代码改成 volatile线上跑了两周才被对账任务揪出来。所以我打算换个讲法。不从定义开始而是从「这两个东西各自在解决谁的麻烦」这个角度切进去。你把它们当成两件工具volatile 是一张贴在墙上的公告synchronized 是一间只能进一个人的房间。公告的作用是让所有人都能看到最新的内容但它阻止不了两个人同时改黑板上的字房间的作用是同一时刻只有一个人能进去操作其他人只能在门口排队。这个类比基本能覆盖八成的使用场景。volatile 关键字的作用本质上是处理「一个线程改了值另一个线程看不见」这类问题而 synchronized 处理的是「多个线程同时改改乱了」这类问题。前者是沟通问题后者是秩序问题。两件事听起来像实际上差得很远。这篇文章会覆盖 Java 里的 volatile 和 synchronized也会把 C 语言里那个同名的 volatile 一起讲清楚——毕竟热搜上「volatile关键字的作用c」和「volatile c语言」这两个词一直很热很多嵌入式方向的朋友其实更需要的是 C 那一套。两类语言的 volatile 只是名字撞了语义上关联很小混着理解一定会出事。不管你是刚开始学多线程还是已经写了几年代码但一直没系统梳理过我都建议按顺序读下去。我会用能直接跑的代码、能直接抄的配置、以及我自己踩过的那些坑把这两个关键字从头到尾拆一遍。2. 内存模型一切混乱的源头2.1 为什么一个线程改了值另一个线程看不见现代 CPU 和编译器都在疯狂做优化。CPU 有多级缓存L1 通常只有几十 KBL2 几百 KBL3 几 MB 共享。线程 A 在核心 0 上跑把变量 x 读到自己的 L1 缓存里改成 1这个修改可能停留在 L1 里好一会儿才往主存回写。线程 B 在核心 1 上跑读 x 的时候如果自己 L1 里还有一份旧值 0那它读到的就是 0。这不是 bug这是硬件为了性能做的正常设计只是它不符合我们对「一个变量只有一份值」的直觉。Java 把这个问题抽象成了 JMM也就是 Java 内存模型。JMM 规定每个线程有自己的工作内存变量存在主内存里线程要读写变量得先复制到工作内存再操作。这个模型的真实目的是屏蔽掉各种硬件差异让同一份代码在不同平台上表现一致代价就是开发者必须显式地处理可见性问题。C 语言这边情况更复杂。C 标准从头到尾没有提过多线程内存模型直到 C11 才引入_Atomic和内存序的概念。在那之前多线程共享变量的行为基本是「编译器实现说了算」。这也是为什么很多老 C 代码里到处是volatile加上各种memory barrier那是一种在标准缺位下的自救手段。2.2 内存屏障与 happens-before要解决可见性绕不开内存屏障。屏障就是一条指令告诉 CPU 和编译器这条线前后的内存操作不许随便调换顺序。Java 里 volatile 写操作前会插入 StoreStore 屏障写操作后插入 StoreLoad 屏障volatile 读操作后插入 LoadLoad 和 LoadStore 屏障。落到 x86 上写 volatile 变量之后对应的是一条带lock前缀的指令这条指令会把写缓冲刷进缓存并触发缓存一致性协议让其他核心上对应的缓存行失效。落到 ARM 上就是dmb ish这类显式的屏障指令。JMM 用 happens-before 这套规则来描述这些约束其中有一条最实用对一个 volatile 变量的写操作happens-before 于后续对同一个变量的读操作。这句话的工程含义是如果你在写 volatile 之前改了一堆普通变量读这个 volatile 的线程在看到新值之后也能看到那些普通变量的最新值。这就是所谓的「顺带把整批数据带过去」。这一点在实战中非常好用。比如做配置热更新我用一个 volatile 的configVersion做版本号真正的配置对象是个普通引用写线程先更新配置对象再自增版本号读线程先读版本号再读配置。只要看到版本号变了配置内容一定是新的不需要给配置对象本身加锁。2.3 volatile 在字节码和汇编层面长什么样Java 里 volatile 修饰的字段在 class 文件的字段访问标志里会带一个ACC_VOLATILE标志位。JVM 读到这个标志就明白该字段的读写不能走普通的优化路径。想看汇编的话可以用-XX:UnlockDiagnosticVMOptions -XX:PrintAssembly配合 hsdis 插件打印。在一台 x86 的机器上一个 volatile 写基本上会看到lock addl或者lock cmpxchg之类的指令。这个lock前缀做的事包括把当前处理器缓存行的数据写回主内存并使其他处理器里对应的缓存行失效。这是硬件层面的可见性保障比自己手动刷缓存靠谱得多。顺带说一句很多人以为加了 volatile 就是「每次都去主存读」这个说法不准确。真实情况是 CPU 完全可以从缓存里读只是缓存一致性协议保证了它读到的一定是最新值。理解这一点有助于你判断性能——volatile 的开销主要来自屏障带来的乱序限制而不是「访问主存慢」。3. 可见性实验亲手看见差异3.1 一段注定死循环的代码先复现最经典的场景。public class VisibilityDemo { private static boolean running true; 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); } }在多数 JIT 已经生效的环境下这段代码会一直卡住running false这句执行了但工作线程永远看不到。原因有两层JIT 把running提升成了一个寄存器变量循环条件根本不再去内存里读即使 JIT 不优化工作线程的缓存里也可能一直是旧值。把private static boolean running true;改成private static volatile boolean running true;问题立刻消失。这就是 volatile 最直接的用途也是最容易被面试官拿来当开场白的例子。3.2 volatile 不保证原子性一个残酷的验证现在换个场景用 volatile 修饰一个计数器。public class AtomicityDemo { private static volatile int counter 0; public static void main(String[] args) throws InterruptedException { int threadCount 10; int perThread 10000; Thread[] threads new Thread[threadCount]; for (int i 0; i threadCount; i) { threads[i] new Thread(() - { for (int j 0; j perThread; j) { counter; } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(期望值 (threadCount * perThread)); System.out.println(实际值 counter); } }跑下来期望十万实际通常在九万出头而且每次都不一样。原因很直白counter不是一条指令它拆开是三步读 counter 到栈顶、加一、写回。volatile 保证了每一步读到的都是最新值保证不了这三步之间不被打断。两个线程可能同时读到 100各自加一写回 101一次自增就这样消失了。换成private static AtomicInteger counter new AtomicInteger(0);然后counter.incrementAndGet()结果就准了。AtomicInteger 底层用的是 CAS 加 volatile 字段是「volatile 加原子操作」的组合拳这个组合才是并发计数的正解。3.3 加上 synchronized 之后的差别同样的代码把自增那行换成同步块private static int counter 0; private static final Object LOCK new Object(); // 在循环里 synchronized (LOCK) { counter; }结果一定是十万。这里甚至不需要给 counter 加 volatile因为 synchronized 的进入和退出本身就带有内存语义解锁时会把工作内存同步回主内存加锁时会清空工作内存从主内存重新读。所以同步块内的变量读写天然具备可见性。实测下来十个线程各做一万次带锁自增在我的机器上大概几十毫秒比想象中快得多。现代 JVM 对无竞争的同步做了大量优化单线程反复获取同一把锁的开销被压得很低。所以「synchronized 很慢」这个印象在 JDK 6 之后就已经过时了不该成为你绕开它的理由。4. C 语言里的 volatile另一个世界4.1 从热搜里那个宏说起热搜词里有个东西挺扎眼#define clkcon_uni ((volatile clkcon *) (sfr_base 0x00 * 4))。这是嵌入式里非常典型的寄存器映射写法值得单独拿出来讲。这一行做的是把某个片内外设的时钟控制寄存器映射成一个 C 结构体指针并且加上 volatile 修饰。为什么必须加 volatile因为这类寄存器的值会被硬件自己改变。你可能写了一个轮询循环等待某个状态位翻转while (!(clkcon_uni-pll_lock)) { /* 等待锁相环锁定 */ }如果clkcon_uni指向的内容没有 volatile 修饰编译器完全有理由认为这个循环体里没人改过pll_lock于是把它优化成「读一次如果为假就死循环」甚至直接判定循环不可达而删掉。加了 volatile编译器就必须老老实实每次都从那个地址重新读。4.2 C 的 volatile 能做什么不能做什么C 语言 volatile 解决的问题是「这个值可能会在本程序之外被改变所以不要缓存它、不要优化掉对它的访问」。它管的是编译器优化不管 CPU 乱序也不管原子性。三个典型场景一是硬件寄存器值由外设改二是中断服务程序里修改的全局变量主循环里读三是setjmp与longjmp之间的局部变量。这三个场景的共同特点都是「当前执行流的视角之外有人会改它」。反过来说C 的 volatile 不管什么不保证原子性。volatile int x; x;在单核上是安全的在多核上照样会丢更新。不保证多核之间的顺序。在 ARM、PowerPC 这类弱内存序架构上两个 volatile 写之间的顺序可能被硬件调换需要atomic_thread_fence或者平台提供的屏障指令。不阻止 CPU 层面的缓存。它只约束编译器。4.3 别把 C 的 volatile 当 Java 的用刚说的这些和 Java 的 volatile 是两套东西。Java 的 volatile 由 JVM 保证内存屏障天然覆盖了可见性和有序性C 的 volatile 只是编译器层面的一个标记。如果你从 Java 转到写 C 的多线程代码把 volatile 当成 Java 那个用迟早出事。C 这边要做并发正确做法是用 C11 的_Atomic类型或者用平台线程库提供的锁和原子操作。我在一个项目里就见过类似的误用。一位同事从 Java 转过来写驱动习惯性地用 volatile 变量做多线程标志位代码在 x86 上跑得好好的移植到 ARM 上偶发卡死。x86 是强内存序架构很多乱序问题天然被硬件挡住了一到弱内存序平台就全暴露出来。5. synchronized 的里子锁升级与字节码5.1 monitorenter 和 monitorexitsynchronized 编译成字节码后同步块是monitorenter和monitorexit成对出现的指令同步方法则是方法访问标志里加一个ACC_SYNCHRONIZED。JVM 在执行到monitorenter时会尝试获取对象的监视器拿到就往下走拿不到就阻塞。monitorexit会释放监视器。有个细节值得注意编译器会生成两份monitorexit一份在正常执行路径上一份在异常表里。所以同步块里抛异常锁一定会释放。这一点比 C 里的互斥量省心C 里忘了在异常路径解锁是很常见的疏漏。5.2 对象头里的 Mark Word 与锁状态每个 Java 对象头部都有一段 Mark Word用来存哈希码、GC 分代年龄和锁状态。锁状态在这段信息里有几种编码无锁、偏向锁、轻量级锁、重量级锁。JDK 6 引入锁升级机制目的是让无竞争和轻度竞争的同步尽量少走系统调用。偏向锁的思路是如果一个锁一直被同一个线程获取那干脆把线程 ID 记在对象头里下次这个线程再来直接放行连 CAS 都省了。轻量级锁则是在有轻微竞争时用 CAS 自旋抢锁避免立马挂起线程。竞争的线程数量上来之后才膨胀成内核态的互斥量也就是重量级锁。需要说清楚的是偏向锁在 JDK 15 里已经被默认禁用后续版本直接移除了。这是一个很重要的信号随着 JVM 各种优化的成熟偏向锁带来的收益已经抵不上它维护状态的开销。所以现在讨论锁升级重点应该放在轻量级锁和重量级锁的转换上。5.3 锁的选择与粒度控制同步块写起来简单坑主要在选哪个对象当锁。用一个private static final Object专门做锁对象是最稳妥的做法因为它是私有的外部代码碰不到不可能出现意外的锁竞争。用this当锁要小心因为外部可能也拿这个对象加锁。用字符串常量或者包装类当锁更危险这些对象可能有缓存或者被复用你以为锁的是自己的对象实际上锁的是全局共享的东西。锁粒度也值得琢磨。我见过把整个方法都加上 synchronized 的写法方法里有大量不涉及共享状态的耗时计算结果是并发度被打到地板上。正确做法是把同步范围收窄到真正读写共享变量的那几行。public void process(Order order) { // 前面这段纯计算不需要同步 BigDecimal amount calcAmount(order); String risk assessRisk(order); synchronized (LOCK) { // 只有这里碰到共享状态 stats.record(amount, risk); } }收窄同步范围这个动作往往比换用更高级的并发工具收益更大而且风险极低。我的习惯是先写正确再用压测数据决定要不要收窄而不是一上来就为了性能把锁去掉。6. 常见问题与排查速查表6.1 问题速查表现象可能原因排查动作线程读不到另一个线程写的标志位标志位没加 volatileJIT 提升成寄存器变量加 volatile或用 AtomicBooleanvolatile 计数器结果偏小i非原子换 AtomicInteger 或加锁加了 synchronized 依然出现脏数据锁对象不一致不同代码路径锁的是不同对象打印锁对象的 identityHashCode 对齐单例双重检查里拿到未初始化对象实例字段没加 volatile给 instance 加 volatile压测 QPS 上不去同步块范围过大或者锁粒度过粗用 JFR 或者 async-profiler 看锁竞争并发问题只在生产环境偶发开发机核数少竞争不明显用 JCStress 或者自建压测复现C 代码在 x86 正常、ARM 异常依赖了强内存序没加屏障加 atomic_thread_fence 或换 _Atomic6.2 双重检查锁定的正确写法这个模式错得太多单独拿出来说。public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { // 第一次检查避免每次加锁 synchronized (Singleton.class) { if (instance null) { // 第二次检查防止重复创建 instance new Singleton(); } } } return instance; } }instance上的 volatile 绝对不能省。new Singleton()在字节码层面分成三步分配内存、初始化对象、把引用指向内存。如果没有 volatileJIT 或者 CPU 可能把第二步和第三步调换顺序。另一个线程在第一次检查时看到引用非空直接返回拿到的却是个还没初始化完的对象。这个 bug 极其难查因为它只在特定时序下出现加了 volatile 之后屏障会禁止这个重排。6.3 volatile 数组的陷阱volatile int[] arr里的 volatile 修饰的是引用不是数组元素。也就是说arr new int[10]这个赋值有 volatile 语义但arr[0] 1没有。想让元素也有 volatile 语义得用AtomicIntegerArray之类的工具类。这个坑我自己踩过当时排查了半天才发现问题不在同步逻辑而在修饰目标搞错了。6.4 高频追问的回答思路被问到「volatile 能不能替代 synchronized」回答思路是这样的要看具体场景。如果只是单个变量的读写通知volatile 完全够用而且比加锁轻。如果涉及多个变量的复合操作比如先判断再修改、多个变量之间要保持一致那必须加锁或者用原子类。一句话概括volatile 解决的是「看得见」synchronized 解决的是「不打架」两者覆盖的问题域本来就不重叠。被问到「synchronized 和 ReentrantLock 怎么选」我的判断依据是三点需不需要可中断获取锁、需不需要超时获取、需不需要多个条件队列。三个都不需要就用 synchronized代码更简洁也不会忘记释放。有一个需要就换 ReentrantLock。7. 我在实际项目里的选择依据写了这么多年代码我选同步手段的判断顺序基本固定下来了。先看是不是只有一个变量需要在多个线程之间传递状态是的话优先 volatile最轻。如果涉及计数、累加、比较再更新这类复合操作直接上原子类别犹豫。如果操作的是一批变量并且它们之间有一致性要求那就老老实实加锁锁对象用私有 final Object。有一个经验特别想分享不要为了性能提前去掉锁。我见过团队为了追求 QPS把一个统计模块的锁拆成了几段每段用一个 volatile 标志位串联。上线之后对账数据一直对不上查了两天才发现是复合操作的可见性出了问题。后来改回一把锁性能损失不到百分之三而这次故障的排查成本远超这点收益。另一个习惯是凡是看到volatile出现在代码里我都会在注释里写清楚它在这里防的是哪一种重排或者哪条可见性路径。因为 volatile 的语义不像锁那么直观半年后回头看很容易搞不清当初为什么加它。注释写清楚下次重构的时候才不会手抖删掉。至于 C 那边的 volatile我的原则更简单只用在硬件寄存器、中断共享变量、setjmp 这三个场景其他一律不用。多线程共享数据就走平台提供的原子操作和锁别指望 volatile 帮你兜底。C 和 Java 的 volatile 虽然拼写一样但把它们当成两个完全无关的关键字来记反而不会出错。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。