资讯动态

volatile与synchronized实现原理及本质区别详解

发布时间:2026/10/14 1:15:37 来源:尧图企业网站定制
先讲一个我几乎每个月都会碰到的问题有人用 volatile 修饰一个 int 计数器开十个线程各累加一万次最后结果不是十万。他很困惑地问我volatile 不是保证可见性吗所有线程都应该能看到最新值啊为什么还会丢更新我的第一反应永远是反问一句volatile 什么时候说过自己保证原子性了这个误解在 Java 并发编程里太经典了几乎每个从入门走向进阶的开发者都会在这里栽跟头。不只是新手我见过不少写了三五年 Java 的同学能把volatile 保证可见性、synchronized 保证原子性这个口诀背得滚瓜烂熟但真正遇到线上问题还是会用错。原因很简单大家只背了结论没搞明白这两个关键字各自是怎么实现这些语义的。如果你不知道 volatile 靠什么才可见、不知道 synchronized 背后的 Monitor 和锁升级是怎么回事那你在选型时永远只能靠猜。这篇文章我就把 synchronized 和 volatile 的实现原理完整拆一遍再把它们俩的本质区别一次说清楚。1. 先把 JMM 的三块基石立起来再谈实现原理1.1 我们真正要防的是什么可见性与重排序Java 并发编程里讨论的所有问题本质上都源自一个现实线程并不是真的在直接操作内存。现代 CPU 为了性能每个核心都有自己的高速缓存L1、L2部分还有 L3 共享缓存处理器在计算时优先读写缓存并通过缓存一致性协议x86 上典型的是 MESI 协议在多个核心之间同步数据。Java 虚拟机在这个硬件模型之上又抽象了一层把内存分成了主内存和每个线程自己的工作内存。线程对变量的所有读写名义上都要先经过工作内存再通过主内存交换数据。于是问题就来了线程 A 在自己的工作内存里改了某个变量线程 B 可能还在读自己工作内存里的旧副本——这就是可见性问题的根源。它跟公司制度没关系纯粹是你改了但你还没来得及告诉别人。另一个问题是指令重排序。CPU 和 JIT 编译器为了流水线效率会在保证单线程语义不变的前提下对指令顺序进行调整。单线程下这没什么问题但多线程环境下一个线程看到另一个线程半执行完的状态是完全可能的。比如你先把引用发布出去了构造器里的字段还没初始化完——这就是 DCL 单例里那个著名 bug 的来源。1.2 原子性、可见性、有序性的定位JMMJava 内存模型要解决的核心问题就是围绕三个性质展开的原子性一段操作要么全部执行完要么不执行不会被其他线程插队打断。可见性一个线程对共享变量的修改能及时被其他线程看到。有序性程序执行的顺序不能因为重排序而在多线程环境下产生让人意想不到的结果。这里最容易被忽略的一点是这三个性质并不是捆绑出现的。有的机制只保证可见性比如 volatile有的机制主要保证原子性同时捎带解决了可见性比如 synchronized还有的机制三者都不保证那就是普通变量的默认行为。先把这三个概念的位置摆正后面看原理才不会绕晕。2. volatile 的实现原理可见性交给硬件协议有序性交给内存屏障2.1 volatile 的读写路径为什么它比普通变量慢半拍教科书上通常会这样描述 volatile写 volatile 变量时会立即写回主内存读 volatile 变量时会从主内存读取。从 Java 程序员的视角这样理解完全够用但严格说起来现代 CPU 并不会真的每次都去总线往返一趟主内存——那慢得没法接受。更精确的机制是这样的在 x86 这种典型架构上volatile 写在编译后会带一个 lock 前缀指令。这个 lock 前缀的作用是让当前核心对这个共享变量的写入具备总线锁定/缓存锁定的语义配合 MESI 缓存一致性协议把这条缓存行标记为 Modified同时让其他核心上对应的缓存行变成 Invalid。其他线程再读这个变量时发现自己的缓存已经失效不得不重新从内存拉取最新值。所以 volatile 的可见性本质是依赖 CPU 的缓存一致性协议 指令执行屏障共同完成的不是每次读写都慢悠悠地走一遍主存那么简单。不过日常做技术判断时你可以用教科书式的简化为准强制可见、强制最新。代价就是 volatile 读写的开销比普通变量要高在极端高频访问的场景下这个差距是能感觉到的。2.2 内存屏障与 JVM 在不同架构上的落地差异可见性还不够volatile 还要保证有序性。JMM 抽象出了四类内存屏障LoadLoad禁止上面的读和下面的读重排。LoadStore禁止上面的读和下面的写重排。StoreStore禁止上面的写和下面的写重排。StoreLoad禁止上面的写和下面的读重排是全屏障。按 JMM 的规定volatile 写之前要插入 StoreStore 屏障写之后要插入 StoreLoad 屏障volatile 读之后要插入 LoadLoad 和 LoadStore 屏障。但这里有一个非常重要的细节这是 JMM 在抽象层面给出的最保守要求真正落地到具体 CPU 架构时JVM 会做裁剪。x86 是强内存模型TSO本身就几乎不会出现 LoadLoad、LoadStore、StoreStore 三种重排所以 HotSpot 在 x86 上实现 volatile 写时实际只需要一个 StoreLoad 屏障常常用lock addl $0, 0(%rsp)或者mfence指令来充当全屏障。而在 ARM、POWER 这类弱内存模型上内存屏障就要老老实实按规范铺满。这也是同一个 Java 程序在不同硬件环境下性能表现不一样的根源之一你要是写过 JNI 或者研究过 JIT 生成的汇编对这个会有更直观的感受。2.3 volatile 的边界i 为什么怎么改都不对回到开头那个问题。count这一行代码在字节码层面拆开是三步读值、加一、写回。volatile 保证了这三步里每一步的可见性但保证不了这三步作为一个整体不被插队。线程 A 读到 count 5线程 B 也读到 count 5两者各自加一后再写回结果都是 6而不是 7。哪怕 volatile 能让 A 的写立刻对 B 可见也救不了已经基于旧值算出来的结果。这就是丢了更新的本质可见性解决的是看没看到原子性解决的是做没做成一个整体。这个案例建议所有读者亲自跑一遍。开八个线程每个循环一万次你大概率会得到一个小于八万的结果而且每次跑出来的值还不同。这种不确定本身就说明了问题volatile 在复合操作面前是无能为力的。3. synchronized 的实现原理从字节码指令到 Monitor再到一套锁升级机制3.1 字节码层面的加锁解锁monitorenter / monitorexit先说同步代码块。把一个加了 synchronized 的代码块用javap -v反编译你能看到两处关键的字节码指令进入代码块时有一条monitorenter退出时有一条monitorexit。而且要注意正常退出和异常退出各有一条monitorexitJVM 会保证锁一定被释放——否则异常路径上锁就漏掉了这会导致永远死锁。同步方法则走的是另一条路。方法会带一个ACC_SYNCHRONIZED标志JVM 在方法调用时根据这个标志隐式地获取对象的 Monitor方法返回或抛出异常时隐式释放。所以同步方法没有显式的 monitorenter 指令这句话是对的但它的同步语义一点也不比代码块弱。monitorenter和monitorexit背后的核心对象叫Monitor监视器/管程。在 HotSpot 里每个 Java 对象都能成为 Monitor因为它天生就在对象头里留了一部分空间Mark Word用来记录锁相关的信息。Monitor 内部维护了几个关键集合_owner记录当前持有锁的线程_EntryList是等待获取锁的线程队列_WaitSet是执行了 wait() 之后进入等待的线程集合。你平时用的 wait/notify底层就是操作这些集合。3.2 对象头 Mark Word 里的锁状态机每个 Java 对象的对象头由 Mark Word 和类型指针组成。Mark Word 里存的不只是 hashCode 和 GC 分代年龄它还充当了一把多功能锁状态牌。在不同锁状态下Mark Word 的位会被重新解读无锁状态存储 hashCode、分代年龄、偏向位 0、锁标志位 01。偏向锁状态存储持有线程的 ID、偏向位 1、锁标志位 01。轻量级锁状态存储指向线程栈中锁记录Lock Record的指针锁标志位 00。重量级锁状态存储指向 ObjectMonitor 的指针锁标志位 10。也就是说同一个对象的 Mark Word在不同的竞争阶段存的东西完全不同。JVM 会根据竞争激烈程度决定这个对象的 Mark Word 该呈现哪种形态这就是锁升级的物理基础。3.3 偏向锁、轻量级锁、重量级锁的升级链JDK 1.6 之后 synchronized 的性能大幅提升靠的就是引入了一套分阶段的锁升级机制。名义上叫锁升级实际可以理解成按竞争程度动态选择开销最小的方案第一阶段是无锁直接变偏向锁。当某个线程第一次进入同步块时JVM 通过一次 CAS 操作把该线程的 ID 写入 Mark Word 的偏向线程字段。之后这个线程再次进入同一把锁时只需要比对线程 ID 就完事了连 CAS 都不用做。这是无竞争的极速路径。第二阶段是偏向锁升级为轻量级锁。一旦有第二个线程来竞争这个锁偏向锁模式就被破坏。JVM 需要在安全点safepoint暂停所有线程去撤销偏向锁然后进入轻量级锁流程竞争线程在自己栈帧里创建 Lock Record用 CAS 尝试把 Mark Word 替换为指向自己 Lock Record 的指针成功则持有锁失败说明锁正被占用线程就开始自旋等待。自旋是空转 CPU但避免了操作系统线程切换的巨大开销适合锁持有时间极短的场景。JDK 6 里把自旋改成了自适应自旋JVM 会参考上一次竞争结果动态决定转几圈。第三阶段是轻量级锁膨胀为重量级锁。如果锁竞争一直很激烈自旋解决不了问题JVM 就把这个对象直接升级为重量级锁也就是挂到 ObjectMonitor 上。没抢到锁的线程会被操作系统真正阻塞线程状态变为 BLOCKED。这一步涉及用户态与内核态的切换代价最高。整套升级是单向不可回退的一旦到了重量级锁再激烈的竞争结束后也不会降级回轻量级锁这个对象会一直以重量级锁的身份存在。另外一个值得提的动向是从 JDK 15 开始JVM 默认不再启用偏向锁。原因也不复杂现代应用普遍多线程竞争偏向锁的撤销本身要暂停线程收益越来越不划算。你现在去看 JDK 15 以上版本的虚拟机参数-XX:UseBiasedLocking默认是关的。3.4 注意synchronized 为什么不主动禁重排也能保证安全很多人有个误区认为 synchronized 和 volatile 一样是靠禁止重排序来保证安全的。其实不是。synchronized 块内的指令在持有锁期间仍然可能被重排序但这是安全的因为临界区是互斥的其他线程根本进不来看不到重排产生的那部分中间状态。synchronized 的安全屏障在于两点一是进入临界区时会从主内存重新读取共享变量二是退出临界区时会把当前线程修改过的共享变量刷新回主内存。配合 JMM 的管程锁定规则对一个锁的解锁 happens-before 于后续对这个锁的加锁就能保证释放锁的线程做了什么修改拿到这把锁的下一个线程一定看得见。所以 synchronized 其实是原子性为主可见性和有序性一并附带的机制和 volatile 那种专门为可见性有序性服务的机制底层设计思路完全不同。4. synchronized 与 volatile 的本质区别不是一个强一个弱这么简单4.1 原子性互斥与不互斥的根本差异这是两者最核心的分水岭。synchronized 通过互斥让多个线程串行进入临界区天然把读-改-写这样的复合操作变成了一个不可分割的整体volatile 则完全没有互斥概念它只保证单个变量读写那一刻的可见性对先读后写这种组合无能为力。记住这个判断公式你在做任何基于当前值计算新值的操作时单靠 volatile 一定错synchronized 或 Atomic 系列才是你需要的东西。4.2 可见性与有序性的保证方式不同两者都保证可见性但方式不一样。volatile 是直接对单个变量的读写加了内存屏障和缓存一致性约束synchronized 则是通过进入清空、退出刷新的 Monitor 语义顺带把整个临界区涉及的变量都覆盖到了。所以从可见性覆盖范围说synchronized 反而更广只是它必须付出互斥的代价。在有序性上volatile 是显式禁止特定组合的重排synchronized 更像把重排结果锁在门里不让别人看。一个靠规则拦截一个靠隔离掩盖思路完全不同。4.3 开销模型与性能画像volatile 的开销很稳定它不阻塞、不涉及线程上下文切换代价只是内存屏障带来的指令级成本在弱内存模型架构上更明显。synchronized 的开销则高度动态无竞争时它可能只是 CAS 一次就完事性能接近无锁竞争加剧后可能触发自旋白白烧 CPU最坏情况下膨胀为重量级锁线程阻塞唤醒要走操作系统内核每次切换的成本可能在微秒量级。这也解释了为什么 synchronized 的优化方向永远是尽量别走到重量级。4.4 使用形式上的限制volatile 只能修饰变量且不能修饰引用指向对象内部的普通字段synchronized 可以修饰方法或代码块也可以配合任意 Java 对象作为锁。在 Java 里任何对象都可以是锁对象这本身就是 volatile 做不到的——它没有锁的概念。两个放在一起我想用一张表把对比说清楚对比维度volatilesynchronized保证原子性不保证保证保证可见性保证保证防护有序性内存屏障禁止重排互斥让重排结果不可见作用对象单个变量方法或代码块粒度变量级块级是否阻塞从不阻塞竞争时可自旋、可阻塞底层机制缓存一致性协议 内存屏障Monitor CAS 锁升级典型问题i 丢更新锁粗粒度导致吞吐下降5. 实战选型哪些场景 volatile 就够用哪些必须 synchronized5.1 volatile 的四个经典适用场景先给结论volatile 适合的是读多写少、写入单一且不依赖旧值、不涉及多个变量联合一致性的场景。我实际工作中用得最顺手的四类第一状态标志位。最典型的就是 shutdown 标志主线程把volatile boolean running置为 false工作线程在循环里读取它一旦看到 false 就退出。这里只有一个线程写、多个线程读写的动作是独立的不需要任何复合操作volatile 完美胜任。第二安全发布不可变对象。你有一个对象构建完成后内容不再变化你只需要让其他线程尽快拿到最新引用。把引用声明为 volatile 即可。这里的关键是你要确保不再变化——对象一旦发布内部字段就不能再被随便改了否则 volatile 覆盖不到对象内部的字段。第三DCL 单例里的 instance 字段。这是 volatile 保有序性的经典应用后面我单独展开。第四轻量级的配置开关或版本号。比如配置刷新后递增一个版本号下游线程通过版本号判断是否需要重新加载缓存。这里尺寸足够小语义足够单一volatile 的代价也足够低。5.2 必须交给 synchronized 的场景反过来下面这几类场景用 volatile 就是给自己埋坑复合操作。计数、累加、队列长度维护清一色是读-改-写没有原子性保护必然丢数据。这时候用 synchronized 保护临界区或者改用 AtomicInteger 这类并发原子类。多变量联合一致。比如账户余额和流水版本必须一起更新不能让别的线程看到余额新了但版本还是旧的中间态。volatile 一个一个变量声明根本解决不了跨变量原子发布的问题必须用锁把整套更新圈起来。涉及 wait/notify。只要用了 wait/notify就必须在 synchronized 块里因为这两个方法运行时要求当前线程持有该对象的 Monitor。这不是可选优化是语法层面的强制要求。5.3 组合使用的经典案例DCL 单例与并发容器的实现思路DCL双重检查锁定单例是理解两者配合的最好范本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(); } } } return instance; } }为什么要加 volatile因为new Singleton()在底层拆成三步分配内存、调用构造器初始化、把引用赋给 instance。CPU/编译器可能将后两步重排导致另一个线程在第一个 if 判断时看到 instance 不为 null拿到的却是一个构造器还没跑完的半成品对象。volatile 写屏障的存在保证了构造器里的初始化动作在 volatile 写之前完成从而对读线程可见。这就是 volatile 保有序性的最直观价值。synchronized 在这里负责的是互斥确保创建实例的代码同一时刻只有一个线程在执行。两个关键字一个管别拿到半成品一个管别重复创建各司其职。并发容器里也是同样的互补思路。我印象很深的是 JDK 里那个经典并发哈希表的实现数组引用和每个节点的 next、value 字段用 volatile 修饰保证读写可见性但插入元素时需要维护链表结构、存在复合更新所以它选择对单个桶的头节点做 synchronized 加锁。这就是volatile 负责可见性、synchronized 负责原子性在工业级代码里的真实落地非常值得借鉴。5.4 一张决策速查表我把自己平时做技术评审时用的决策思路整理成了这张表可以直接当参考场景描述推荐方案原因单一状态开关一写多读volatile无需原子性成本最低安全发布不可变快照volatile引用可见性就够DCL 单例volatile synchronized防指令重排 防重复创建计数器、累加器synchronized / 原子类读-改-写必须原子多字段联合更新synchronizedvolatile 无法覆盖跨变量一致性使用 wait/notifysynchronizedMonitor 语义强制要求读远多于写且写不依赖旧值优先 volatile减少加锁开销6. 一次线上误用 volatile 的排查复盘原理知识如何救命最后分享一个我实际经历过的案例正好把整篇文章的知识串起来。某项目组线上突然出现一个偶发问题配置更新之后有的线程读到的是新配置有的线程读到的还是旧配置而且不是慢一拍是持续不一致。一开始大家怀疑缓存后来怀疑复制延迟查了老半天没头绪。我去翻了代码发现他们把配置对象config声明成了volatile Config但更新配置时却是另一个线程直接对这个 config 对象的内部字段逐个 set。问题就出在这volatile 修饰的是引用本身它保证的是config 这个引用变了大家能看到新引用但根本管不了引用指向的同一个对象内部字段被改了其他线程要重新读才拿得到。线上的多个线程早就通过旧引用把对象缓存进了自己的工作内存内部字段的修改对它们来说就是透明的。后来把方案改成不可变快照 volatile 引用替换每次更新都构建一个新的配置对象把所有字段填好之后再用 volatile 引用一次性替换掉旧的。由于所有字段在 volatile 写之前已经填完了写屏障恰好覆盖了这些普通字段的初始化其他线程通过 volatile 读拿到的必然是完整的新快照。这个问题最让我印象深刻的不是修复方案有多精巧而是排查过程里的顿悟时刻——只要把 volatile 内存屏障的作用范围理解透了看到有人改对象内部字段的那一刻答案几乎就是条件反射。原理知识在平时可能看不出用处但遇到疑难杂症时它就是救命的。你如果也遇到过类似用对了关键字但结果还是不对的场景建议回头先看看你的 volatile 到底修饰的是什么、synchronized 到底圈住了哪段临界区。大多数并发 bug本质上都是对这两个关键字实现原理理解得不够彻底。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑