这个并发关键字我猜十有八九你都在面试题里被问过synchronized 和 volatile 到底有啥区别底层又怎么实现的说实话这俩货是目前 Java 并发编程里面出镜率最高的一对组合拳一个管锁一个管可见性。但你要是没真正搞懂它们俩底层的原理面试的时候只能背点八股文糊弄过去真到排查线上问题的时候照样抓瞎。我这些年写过不少并发组件踩过 volatile 的不原子坑也经历过 synchronized 锁粗化带来的性能抖动今天就把这两个关键字的实现原理彻底掰开揉碎了讲清楚。这篇文章既适合准备面试的同学也适合在实际开发中想搞清楚为什么这么写的工程师。提示文章没有废话全干货建议收藏后配合 IDE 一起看遇到不清楚的直接去翻 JDK 源码和字节码。1. synchronized 的底层实现从字节码到锁升级一次讲透很多人只知道 synchronized 是 Java 的关键字但并不知道它在 JVM 层面到底做了什么。最简单的办法是先把一段用了 synchronized 的代码编译成字节码看看。1.1 从字节码看 monitorenter 与 monitorexit先写一段最普通的同步代码块public class SyncDemo { public void test() { synchronized (this) { System.out.println(hello sync); } } }用javap -verbose SyncDemo.class反编译之后你会看到 test 方法的字节码关键部分长这样0: aload_0 1: dup 2: astore_1 3: monitorenter 4: getstatic #7 // Field java/lang/System.out 7: ldc #13 // String hello sync 9: invokevirtual #15 // Method java/io/PrintStream.println 12: aload_1 13: monitorexit 14: goto 22 17: astore_2 18: aload_1 19: monitorexit 20: aload_2 21: athrow 22: return注意看monitorenter在进入同步代码块的时候执行monitorexit在正常退出和异常退出的时候各执行一次。这不是什么魔法JVM 就是靠这一对指令来完成锁的获取和释放。那么 monitorenter 内部做的是什么它会尝试获取当前对象关联的 Monitor管程/监视器锁。Java 里每个对象天生就带一个 Monitor它由 ObjectMonitor 结构实现在 JVM 的 HotSpot 源码里能看到它的核心字段// HotSpot 源码对象头监视器结构简化版 ObjectMonitor() { _header NULL; _count 0; // 记录竞争线程数量 _waiters 0; // 等待线程数 _recursions 0; // 锁重入次数 _object NULL; // 关联的对象 _owner NULL; // 持有锁的线程 _WaitSet NULL; // 处于 wait 状态的线程集合 _EntryList NULL; // 处于等待锁 block 状态的线程集合 }说白了** synchronized 就是拿对象的 Monitor 做互斥**同一个时刻只有一个线程能成为 _owner其他线程只能乖乖进 _EntryList 等着。1.2 对象头里的 Mark Word 是怎么记录锁状态的要理解锁升级必须先看懂对象在内存里的布局。HotSpot 里一个 Java 对象在堆内存中由三部分组成对象头Header实例数据Instance Data对齐填充Padding对象头里最关键的是 Mark Word它里面存的东西会根据锁状态复用。32 位虚拟机里 Mark Word 的布局大概是这样锁状态存储内容无锁对象 hashCode、分代年龄、偏向标记位 0偏向锁偏向线程 ID、偏向时间戳、分代年龄、偏向标记位 1轻量级锁指向栈中锁记录的指针重量级锁指向堆中 Monitor 对象的指针这就是锁升级的物质基础。JVM 不会一上来就给 synchronized 上重量级锁而是根据竞争激烈程度不断升级这个过程从 JDK 6 开始默认开启也是面试最喜欢追问的细节。1.3 锁升级的完整链路无锁到偏向锁再到重量级锁我用一个实际场景来演示锁升级的过程。假设你写了一个缓存服务内部用 HashMap 加 synchronized 保护public class CacheService { private final MapString, Object cache new HashMap(); public void put(String key, Object value) { synchronized (this) { cache.put(key, value); } } public Object get(String key) { synchronized (this) { return cache.get(key); } } }偏向锁阶段当这个缓存服务只有一个线程在访问 get/put 时JVM 检测到没有竞争就会在 Mark Word 里记录这个线程的 ID也就是偏袒这个线程。之后这个线程每次进入 synchronized 都只需要做个 CAS 检查看看 Mark Word 里的线程 ID 是不是自己是就直接通行不需要再加锁解锁性能开销几乎为零。轻量级锁阶段当第二个线程开始访问时偏向锁就会撤销。新线程来抢锁时JVM 会先在当前线程的栈帧中创建锁记录Lock Record然后通过 CAS 把对象头的 Mark Word 替换成指向这个锁记录的指针。CAS 成功说明抢到锁了失败就说明有竞争这个时候锁会膨胀为重量级锁。这里有个关键点轻量级锁在竞争不激烈时线程会自旋等待而不是立刻阻塞。自旋就是线程在原地空转循环反复尝试获取锁避免线程上下文切换的开销。不过我实测下来自旋是有次数上限的默认 10 次可以通过-XX:PreBlockSpin调整超过上限就把线程挂起进入重量级锁模式。重量级锁阶段到了这个阶段锁就是真正的重量级了。线程抢不到锁会被操作系统挂起blocked涉及到用户态和内核态的切换性能开销最大。但是优点也很明显底层用操作系统的互斥量mutex实现等待的线程不会白白消耗 CPU。整个过程用一个场景来形容刚开始只有一个服务员在窗口干活偏向锁来了第二个客户后两个服务员一起干活但要商量轻量级锁来了一堆客户后只能排队取号重量级锁。1.4 JDK 6 之后对 synchronized 的三项优化锁升级只是基础JDK 6 之后还有三个优化值得了解我在调优的时候经常用到锁消除JVM 通过逃逸分析发现某个对象不可能逃逸出当前线程就认为 synchronized 是多余的直接把加锁代码优化掉。比如 StringBuffer 的 append 方法加了 synchronized但如果你只在单线程里用局部变量拼接字符串JVM 就可能把锁消除掉。锁粗化如果 JVM 发现你在一段连续的代码里反复加锁解锁同一个对象它会自动把锁的范围扩大到整个操作序列。我之前见过有人写循环里套 synchronized比如for (int i 0; i 1000; i) { synchronized (lock) { // 每次循环都加锁/解锁 count i; } }这其实没有任何意义JVM 会直接把锁粗化到整个循环外面。我在代码评审时看到这种写法会建议直接手动把锁移到循环外部依赖 JVM 优化不如自己写清楚。适应性自旋JVM 会根据上一次自旋获取锁的成功率动态调整自旋次数。前一次自旋成功了就多自旋几次失败了就少自旋甚至不自旋直接挂起线程。到这里大家应该发现** synchronized 并不是死板的锁它有一套完整的自适应机制**。我在实际项目里测过低竞争场景下 synchronized 的性能和 ReentrantLock 差距已经非常小早期版本那种synchronized 性能差的说法早就过时了。2. volatile 的实现原理从内存屏障到缓存一致性volatile 在 Java 里被称作轻量级的同步机制听起来简单但底层的实现细节其实非常反直觉。它的核心是两个语义保证可见性、禁止指令重排序。但唯独不保证原子性。2.1 为什么需要 volatile从 CPU 缓存到主内存先回顾一下计算机的内存模型。CPU 在计算的时候不会每次都直接读写主内存也就是 RAM而是先把数据加载到自己的缓存L1/L2/L3里算完了再把结果写回主内存。在一个多核 CPU 的机器上每个核都有自己的缓存。如果两个线程同时读同一个变量它们各自缓存里都有一份副本。线程 A 在自己的核上改了变量的值线程 B 用到的还是自己核上那份旧值——这就是典型的可见性问题。Java 内存模型JMM规定了所有变量存在主内存中每个线程有自己的工作内存线程对变量的读写都在工作内存中进行。这个工作内存可以理解为 CPU 缓存和寄存器的一个抽象。如果不加 volatileJVM 不知道你有多个线程在共享这个变量它就允许线程优先从工作内存读值。加了 volatile 之后JVM 就会强制线程在每次读的时候都去主内存看一眼最新值。2.2 可见性背后的硬件机制缓存一致性协议光靠 JVM 嘴上说去主内存读不够底层还得硬件支持。Intel 的 CPU 实现了一套叫 MESI 的缓存一致性协议它把缓存行的状态分成四种状态含义场景MModified已被修改和主内存不一致但仅当前缓存持有当前核改了数据没写回主内存EExclusive独占和主内存一致仅当前缓存持有当前核刚加载数据没改过SShared共享多个核都有副本且一致多个线程都在读同一份数据IInvalid失效此缓存行不能使用其他核修改了数据本核缓存作废当一个核修改了 volatile 变量的值它会通过总线嗅探机制广播给其他核其他核看到自己的缓存行已经是 I 状态后就会强制重新从主内存加载最新值。这就是 volatile 可见性的硬件基石。2.3 禁止重排序的内存屏障机制光有 MESI 还不够CPU 和编译器会为了效率对指令进行重排序。比如下面的代码int a 1; // 操作1 boolean flag true; // 操作2从源码逻辑看操作 2 依赖操作 1 吗不依赖。那编译器就可能把操作 2 提前执行这叫指令重排序。在单线程下重排序没问题但多线程下如果 flag 是 volatile另一个线程看到 flag 为 true 时可能期望 a 也已经赋值为 1结果却被重排序破坏了。volatile 解决这个问题靠的是内存屏障。JVM 在 volatile 变量的读写前后插入特定指令阻止这些指令被跨越重排。常见的四种屏障屏障类型作用LoadLoad禁止前面的读操作和后面的读操作重排LoadStore禁止前面的读操作和后面的写操作重排StoreStore禁止前面的写操作和后面的写操作重排StoreLoad禁止前面的写操作和后面的读操作重排全能屏障2.4 volatile 在汇编层面的实际表现我在本地写了一个简单的 volatile 测试类然后用 JIT 编译后查看了生成的汇编指令需要开启-XX:UnlockDiagnosticVMOptions -XX:PrintAssembly。一个 volatile 变量的写操作后面的汇编指令里通常会看到一条lock前缀的指令大概长这样mov $0x0,%eax lock addl $0x0,(%rsp) # 内存屏障的底层实现lock前缀指令的作用有两个一是会锁住总线或缓存行防止其他核在这条指令执行期间修改数据二是相当于一个全屏障前面的读写操作都完成后才会执行后面的操作。所以 volatile 的禁止重排序不是 JVM 意淫出来的它是实实在在下沉到了 CPU 指令级别的。我测试的时候特意留意过这一点看到lock addl $0x0这条指令就说明 volatile 的可见性和有序性确实生效了。这里补充一个 Java 内存模型对 volatile 的完整定义写 volatile 变量时JMM 会插入 StoreStore StoreLoad 两个屏障确保前面的普通写操作先完成并且 volatile 本身的新值对其他线程可见。读 volatile 变量时JMM 会插入 LoadLoad LoadStore 两个屏障确保后面的普通读操作不会读到旧值。2.5 volatile 的原子性缺陷经典 count 问题说了这么多 volatile 的好处有一个绝对不能做的操作得单独强调对 volatile 变量做自增操作不是原子的。public class Counter { private volatile int count 0; public void increment() { count; // 这里不是原子的 } }count在底层至少拆成三步读 count 的值 → 加 1 → 把新值写回去。volatile 保证了第一步读到的永远是最新值但从读到旧值到写入新值这中间其他线程同样可以插入一次自增操作最后就会丢失更新。我自己做过一个实验开 10 个线程每个线程对 count 执行 10000 次自增跑完之后 count 经常不到 100000而且每次结果都不一样。所以 volatile 的正确使用姿势是** 只在不依赖当前值进行复合操作的场景下使用 **比如状态开关、事件通知这种。3. synchronized 和 volatile 的核心区别与选择原则这两个关键字经常在一起被提及但它们解决的问题层面完全不同。我梳理了几个维度的对比直接列成表格对比维度synchronizedvolatile底层机制Monitor 监视器锁内存屏障 缓存一致性协议锁属性独占锁互斥无锁保证的原子性完整保证不保证复合操作原子性保证的可见性保证保证保证的有序性通过加锁保证临界区串行禁止指令重排序是否能修饰字段/方法/代码块可以修饰方法、代码块、静态方法只能修饰实例字段和静态字段是否阻塞线程会阻塞重量锁阶段不阻塞是否触发线程上下文切换重量锁阶段会不会性能开销高锁升级后低3.1 从代码层面看两者的使用边界我见过不少项目里用 volatile 扛大梁结果线上出了诡异的数据不一致问题。比如有个同事用 volatile 修饰一个 List 的引用public class ConfigHolder { public static volatile ListString configList new ArrayList(); }他想着我保证引用永远可见总没问题吧。但问题是** volatile 保证的是引用的可见性而不是 List 内部元素的可见性**。如果两个线程同时往这个 List 里 add 数据没有 synchronized 保护内部结构照样会乱。反过来如果滥用 synchronized 也会有问题。我之前在一个高并发读取的场景里给 get 方法也加了 synchronized结果吞吐量掉了一半。其实读多写少的场景用 volatile 标记一个不可变对象引用就够了public class FileVersionHolder { // 配置内容是不可变的替换引用用 volatile 就够 private static volatile Config currentConfig new Config(); public static void updateConfig(String content) { currentConfig new Config(content); } public static Config getConfig() { return currentConfig; } }这里 Config 设计成不可变类修改时直接替换整个引用。volatile 保证所有线程能看到最新的引用又不需要加锁读性能非常高。3.2 什么时候必须用 synchronized有几种场景是 volatile 永远替代不了 synchronized 的第一复合操作场景。比如计数器自增、先检查后执行这种读改写系列操作。单靠 volatile 没法保证复合操作原子性必须用锁public class IdGenerator { private int sequence 0; public synchronized int next() { // 先读再写复合操作必须加锁 return sequence; } }第二多个变量之间有逻辑约束关系。比如一个转账操作要同时改两个账户的余额这两次修改必须在一个事务里完成。volatile 保证不了这种整体性synchronized 才能把整个临界区包起来。第三需要用到 wait/notify 时。wait 和 notify 方法必须持有对象的 Monitor 锁才能调用也就是必须在 synchronized 方法或代码块里。这本质上是需要一个协调者角色volatile 这种无锁的机制没法提供等待队列这样的结构。3.3 双检锁中的 volatile 到底在防什么双重检查锁DCL是 volatile 最经典的应用也是把 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(); } } } return instance; } }为什么 instance 必须加 volatile因为new Singleton()这个操作不是原子的。它在底层分为三步分配内存 → 初始化对象 → 把引用指向内存地址。如果不加 volatile第二步和第三步可能被重排序导致其他线程在第一层检查时看到 instance 不为 null于是直接返回但此时对象还没完成初始化。拿到一个半初始化的单例对象后续调用方法直接空指针。volatile 在这里的作用就是禁止new Singleton()的第三步重排序到第二步之前确保对象引用对外可见时一定已经初始化完毕。我见过有些同学为了省事直接不写 volatile本地测试没问题上线后在高并发下偶发 NPE这种 bug 最难排查。我个人的建议是DCL 单例就把 volatile 写全别依赖那点性能差异去冒风险。4. 实操过程中我最常用到的几个排查技巧理论说多了容易飘最后分享几个我在实际开发中踩过坑后总结出来的排查方法。4.1 线程转储看锁状态快速定位死锁如果线上出现了线程长时间不跑的情况先别急着重启抓一把线程 dump 看看jstack pid thread_dump.txt然后在线程 dump 文件里搜索 Found one Java-level deadlock 关键字就能直接看到哪两个线程互相持有对方需要的锁。我见过一次典型的死锁Thread-1 waiting to lock 0x000000076b537d58 (a java.lang.Integer) Thread-2 waiting to lock 0x000000076b537d20 (a java.lang.String)顺着这两行往上看就能找到各自在哪个代码块里持锁等锁。排查 synchronized 相关的问题jstack 基本是首选工具比盲猜代码快得多。4.2 用 jol 查看对象头实际锁状态想确认一个对象到底处于哪个锁状态可以用 OpenJDK 的 jolJava Object Layout工具System.out.println(ClassLayout.parseInstance(obj).toPrintable());输出里能看到 Mark Word 的十六进制值结合前面对锁状态的分析就能判断当前锁是否升级了。我调试锁升级的时候就是靠 jol 一步一步观察对象 Mark Word 的变化比纯理论空想要直观太多。4.3 压测时注意偏向锁延迟很多人不知道 HotSpot 默认开启偏向锁但存在一个延迟JVM 启动后 4 秒内的对象不会启用偏向锁这是为了给 JVM 自身的初始化阶段留时间。这也是为什么很多人本地写 demo 时发现 synchronized 性能不错一到线上高并发场景就拉胯。因为在竞争高的场景偏向锁本来就很快会撤销升级为轻量级锁。做压测时如果想模拟偏向锁的完整效果可以加参数-XX:BiasedLockingStartupDelay0把偏向锁延迟设为 0让 JVM 启动后立即启用偏向锁。但说实话线上高竞争场景下这个优化帮不上太多忙压测时更多是用来验证自己写的锁逻辑是否存在性能瓶颈。4.4 volatile 使用的三个硬性约束写 volatile 的时候我都会提醒自己遵守这三条规则第一写入不依赖当前值。如果一个操作是读然后写而且写入依赖读到的值那 volatile 不适用除了直接赋值的简单场景比如volatile boolean flag true。第二写入不依赖其他共享变量。比如你要把 A 的当前值和 B 的当前值加起来然后赋给 volatile 变量 C这个操作就不是原子的其他线程可能在读取 A 和 B 的间隙修改了它们的值。第三读取不依赖其他共享变量。比如你在读 volatile 变量的同时还要读另一个普通变量来判断是否继续循环普通变量的旧值可能导致死循环或漏读数据。我在项目的关键路径上用到 volatile 时都会写注释说明为什么这个操作不需要加锁也能保证线程安全方便后来人快速理解设计意图。5. 常见问题速查下面是我整理的一个高频 QA基本都是我平时在群里或者面试现场聊到的问题直接列出来供大家参考。5.1 synchronized 加了锁为啥还会读到旧值理论上 synchronized 保证串行访问临界区同一个时刻只有一个线程能进入所以在临界区内部不会读到其他线程的中间状态。但如果某个变量没有在 synchronized 保护的范围内被读写也就是存在漏出临界区的访问那么 JMM 允许该线程读到旧值。比如private int notProtected 0; public synchronized void set() { notProtected 1; } public int get() { return notProtected; // 没有 synchronized 保护可能读到旧值 }解决办法是把所有涉及共享变量的读写都包括在同一个锁保护范围内。5.2 volatile 变量能不能用 判断二段判断会不会有问题volatile boolean flag true这种比较是原子的因为布尔比较不会拆成多步。但如果 flag 联动其他变量比如如果 flag 为 true 且 value 10这个判断就不是原子的。在多线程下可能判断到一半 value 被改了结果出现悖论。稳妥的做法是把这个判断放到 synchronized 块里。5.3 锁升级之后还能降回无锁状态吗不能锁只能升级不允许降级。偏向锁可能被撤销并降级为无锁批量撤销偏向锁但一旦升级为轻量级锁就不会降回偏向锁重量级锁也不会降级回轻量级锁。这个单向设计是为了让 JVM 避免频繁在多种锁状态间切换导致更大的开销。5.4 synchronized 和 Lock比如 ReentrantLock现在怎么选我的看法是能用内置锁就不用显式锁。JDK 8 之后 synchronized 经过锁升级优化在低竞争场景下性能已经和 ReentrantLock 差不多了而且 synchronized 是 JVM 自动管理锁的释放不会出现忘记解锁的问题。只有当需要可中断等待、超时获取、多个条件变量等高级特性时才值得用 ReentrantLock。5.5 volatile 会被 JIT 优化掉吗?如果 JIT 通过逃逸分析发现一个 volatile 变量根本不会被其他线程访问理论上它会把这个 volatile 变量当普通变量处理去掉内存屏障。但这种优化只发生在确认单线程的场景和锁消除原理一样。在多线程代码里只要变量确实被共享JIT 不会去掉 volatile 语义。我在实际开发里最深刻的体会是并发问题不是写对了代码就没有了而是看起来对和真正对之间隔着一层底层原理的鸿沟。synchronized 和 volatile 这两兄弟一个解决互斥访问一个解决可见与有序恰好覆盖了并发编程里最容易犯错的三个维度。把它们的原理搞清楚之后回头再看那些为什么必须这么写的代码你会发现自己突然能看懂设计者的意图了。这个能力比背任何面试题答案都值钱。