synchronized与volatile底层原理:从JMM到锁升级与DCL

发布时间:2026/10/11 6:09:00
synchronized与volatile底层原理:从JMM到锁升级与DCL 并发编程里synchronized和volatile是两个绕不开的关键字。Java面试要问线上排查要碰写个最简单的多线程计数器都要跟它们打交道。我带过不少团队面试过上百人发现大多数人把这两个关键字背得很熟但一追问“volatile到底能不能保证原子性”“synchronized的锁升级是怎么发生的”就含糊了。这篇文章我把它们彻底拆开从JMM内存模型出发讲清楚两者的特性边界、底层原理、锁升级链路还有双重检查锁这个经典配合。原理部分都配了可验证的代码和实测现象想拿去做实验或准备面试都行。1. 先搞清JMM这两把钥匙是给同一张图纸上的不同裂缝配的1.1 Java为什么要自己定义一套内存模型要理解synchronized和volatile绕不开Java内存模型Java Memory ModelJMM。很多人把JMM当成一个“抽象概念”背下来就完了但它其实是整个并发体系的施工图。你只有知道这张图纸上哪些地方容易开裂才能理解为什么要在这些位置打补丁。物理机层面CPU和内存之间隔着多级缓存L1、L2、L3。缓存的出现是为了解决CPU运算速度和内存访问速度之间的数量级差距但代价是同一份数据会同时存在于多个地方。当多个CPU核心各自缓存了同一个变量的副本其中一个核修改了它其他核看到的可能还是旧值——这就是缓存不一致问题。为了解决缓存不一致硬件层面有缓存一致性协议最有名的是MESIModified, Exclusive, Shared, Invalid协议。它让每个缓存行维护一个状态通过消息传递保证多核看到的共享数据一致。但注意这种硬件级别的方案有它自己的适用边界而且一旦和编译器优化、CPU指令重排搅在一起问题会变得非常复杂。JMM就是在这样的背景下诞生的。它不是真实的硬件模型而是一个抽象规范定义了主内存Main Memory和线程工作内存Working Memory。所有变量都保存在主内存中线程对变量的所有读写操作都必须在工作内存中进行不能直接读写主内存变量。线程的工作内存之间互相隔离跨线程传递数据只能通过主内存中转。这套抽象模型对应到真实硬件上主内存约等于物理内存工作内存约等于CPU寄存器和各级缓存。JMM的价值在于它为Java程序定义了“什么时候一个线程对变量的修改对另一个线程可见”的明确规则也就是happens-before规则。有了这套规则写并发代码时才有可推导的依据而不是靠直觉猜。提示JMM在JSR 133Java 5之后做了重大修订引入了更完善的volatile内存语义。老资料里很多关于volatile和final的结论拿到JDK 5以上的环境中不一定适用。后文所有讨论都基于现代JVM。1.2 并发三大特性可见性、原子性、有序性JMM围绕三个特性展开可见性、原子性、有序性。所有并发问题的本质都可以归结为这三个特性的缺失。可见性指一个线程修改了共享变量的值其他线程能否立即看到。由于工作内存的存在普通变量的写入在未同步的情况下不保证能及时刷新到主内存也不保证其他线程能感知到变化。我举个实际例子两个线程循环读同一个boolean标志位一个线程改了标志位想让另一个线程停下来如果这个变量没有同步机制另一个线程可能永远看不到新值在循环里空转到天荒地老。原子性指一个操作或者一组操作要么全部执行且执行过程中不被任何因素打断要么完全不执行。比如i它其实包含“读取、加一、写回”三步任何一步被其他线程插队结果就错了。volatile不保证原子性synchronized保证这是两者最本质的能力差异。有序性指程序执行的顺序符合代码的编写顺序。但编译器和CPU为了优化性能会做指令重排只要重排不影响单线程语义。问题在于单线程没问题不代表多线程没问题。一个线程的重排结果在另一个线程眼里可能就是“颠倒的”。这三个特性就是“施工图”上的三类裂缝两个关键字是两种补缝材料。volatile覆盖可见性和有序性synchronized覆盖全部三个特性。这是全篇文章的核心观点后面所有的原理细节都是围绕“为什么各自主管的部分不同”展开的。先把结论用一张表记住特性volatilesynchronized可见性保证写后刷主存读前取主存保证释放锁前flush加锁后refresh原子性不保证仅对单次读/写保证保证锁内临界区互斥执行有序性禁止相关指令重排内存屏障保证临界区串行化2. volatile的底层原理内存屏障撑起的两层承诺2.1 volatile的可见性是怎么落到硬件上的先看volatile的官方语义它承诺两件事第一对volatile变量的读写具有可见性第二禁止对volatile变量周围的普通指令进行重排序。先说可见性是怎么实现的。在JMM的抽象层面线程对volatile变量的写入会在写入后立即把工作内存中的值刷新到主内存线程读取volatile变量时会强制从主内存重新加载。在硬件层面volatile的写操作实际上是带着“缓存失效”语义的——处理器会让其他核心上对应的缓存行失效下次读取时必须从更高层次的共享缓存或主内存拿最新值。这种“写后刷主存、读前失效缓存”的机制依赖缓存一致性协议完成底层兜底。但要注意JMM规范并不强制要求具体实现方式它只规定语义。HotSpot VM在实际落地时主要靠内存屏障指令Memory Barrier / Memory Fence来保证这些语义。我见过不少开发者把volatile的可见性理解成“直接读写主内存”这并不准确。主内存访问很慢volatile变量的写入并不是每次都物理穿过缓存直达内存更多时候它依赖的是缓存一致性协议让其他核心感知到变化。理解到这一层你就知道volatile从来不是玄学它是有一套确定的硬件协作机制在后面的。2.2 四类内存屏障与volatile的插入规则内存屏障是一类特殊的CPU指令作用是禁止它两侧的指令跨越屏障重排序并协调缓存同步。常见的屏障有四类LoadLoad、LoadStore、StoreStore、StoreLoad。LoadLoad屏障前的读操作先于屏障后的读操作完成。LoadStore屏障前的读操作先于屏障后的写操作完成。StoreStore屏障前的写操作先于屏障后的写操作完成。StoreLoad屏障前的写操作先于屏障后的读操作完成这是最“重”的一道屏障因为它在等待写操作真正落地后才允许后续读开始。volatile的内存屏障插入规则是JMM要求编译器在生成字节码时就要安排好的。具体的约束是volatile写操作之前插入StoreStore屏障确保它之前的普通写操作对volatile写可见不会因为重排而落到volatile写之后volatile写操作之后插入StoreLoad屏障防止volatile写与后续的volatile读/写重排这是volatile语义里最关键的约束volatile读操作之后插入LoadLoad和LoadStore屏障确保它之后的普通读/写不会越过它提前执行。说得直白一点volatile写等于在代码里划了一条“分界线”它前面的写操作必须先落地它自己也要尽快同步出去后面的一切操作都不允许跑到它前面volatile读也划了一条线它之后要读的普通变量不能被提前读出来用旧值。如果你在JDK 8及以上的HotSpot上运行程序打开JIT编译日志是有机会看到编译器生成的屏障指令的。我建议对这块感兴趣的朋友自己写个单例类用-XX:PrintAssembly去看汇编你会发现“分界线”这个说法在汇编层面就是实实在在的一排lock前缀指令或fence指令。看完你就不慌了。2.3 volatile不保证原子性一段必考的代码很多人以为volatile能保证原子性这是最常见的认知错误。每次技术评审我都要纠正一次。想让对方理解最直接的办法是写一个多线程累加程序验证开10个线程每个线程对volatile int count执行10000次count最后的结果几乎不可能等于100000。原因很简单count不是一个原子操作它拆开是“读count、把count1、写回count”三步。volatile只保证了第一步能读到最新值第三步写入后其他线程立即可见但三步之间可以插入其他线程的操作。两个线程同时读到100各自加一写回都是101count实际上少加了一次。这就是读-改-写read-modify-write复合操作问题。这种场景必须用synchronized或者用AtomicInteger它靠CAS保证read-modify-write的原子性。AtomicInteger的底层CAS本身也和内存屏障、缓存一致性相关但那是另一个话题。那volatile到底什么时候用我总结了几类经典且实际项目里验证过的场景状态标志位比如布尔类型的shutdown标志一个线程修改其他线程检查退出循环。双重检查锁DCL里的单例引用这个后面专门讲。发布不可变对象对象本身不可变但引用被volatile修饰保证其他线程拿到的是构造完成后的对象。轻量级“最新值”需求比如配置项、开关项、最新进度的展示值。反过来说凡是涉及“先读后写”“累加累计”“多步复合判断”这类逻辑volatile单扛不住别硬上。3. synchronized的三种用法与锁升级完整链路3.1 三种用法对应到字节码的差异synchronized有三种写法修饰实例方法、修饰静态方法、修饰代码块。很多人分不清它们锁的到底是什么。修饰实例方法时锁住的是当前实例对象this。修饰静态方法时锁住的是该方法所属的Class对象——注意不是某个实例。所以静态同步方法和实例同步方法即使访问同一个静态变量用的也不是同一把锁它们之间没有任何互斥关系。修饰代码块时锁住的是括号里指定的任意对象。从字节码层面看同步代码块编译后有两条指令monitorenter和monitorexit。monitorenter在执行时尝试获取对象的监视器Monitor获取成功后计数器加一monitorexit对应释放计数器减一。这里有块关键设计一旦线程拿到锁它可以重复进入同一把锁每次进入计数器加一退出时减一直到减到0才算真正释放。这就是可重入Reentrant的底层机制。可重入的意义在于同一个线程在持有锁的情况下调用自己的同步方法不会被自己挡住。同步方法不在字节码里显式插入monitorenter/monitorexit而是在方法表里标记一个ACC_SYNCHRONIZED标志。JVM调用方法时检查这个标志自动完成加锁和解锁。两种方式的最终效果一样但同步代码块更灵活你可以精确控制锁的粒度只锁那些真正需要保护的代码行。我写代码的习惯是能用同步代码块就不要用同步方法。粒度更可控锁范围越小线程竞争概率越低。方法级同步往往把很多不需要保护的逻辑也圈进去白白增加阻塞时间还容易出现“我以为这个方法安全了”的错觉。3.2 Mark Word与无锁、偏向锁、轻量级锁、重量级锁要理解synchronized底层必须看对象头。HotSpot的对象布局分为对象头Header、实例数据Instance Data和填充Padding。对象头里有一块叫Mark Word的数据它用极小的空间记录了对象的哈希码、GC分代年龄、锁状态等信息。注意Mark Word的内容是复用的——不同的锁状态下它存储的字段含义完全不一样。64位JVM下Mark Word的典型布局如下表字段具体位数因JVM版本略有差异重点是锁标志位锁状态Mark Word存储内容无锁identity hashcode、分代年龄、biased_lock0、锁标志位01偏向锁线程ID、epoch、分代年龄、biased_lock1、锁标志位01轻量级锁指向栈中Lock Record的指针、锁标志位00重量级锁指向Monitor对象互斥量的指针、锁标志位10GC标记空、锁标志位11我实际做性能分析时发现很多人把锁升级理解成“一旦竞争就一路升到重量级”这是不对的。真正的升级链路是逐级推进的无锁状态第一次有线程进入同步块时如果JVM开启了偏向锁JDK 15之前默认开启会通过CAS把Mark Word改成偏向当前线程ID进入偏向锁状态。之后该线程再次进入同步块只需检查Mark Word里的线程ID是不是自己是的话直接通过不需要任何CAS或系统调用开销几乎为零。这也是偏向锁名字的由来——它偏向于第一个拿到它的线程。当另一个线程尝试获取这把锁时偏向模式结束。JVM会先撤销偏向锁然后锁升级为轻量级锁。轻量级锁的实现是用CAS在Mark Word里写入指向线程栈中Lock Record的指针。如果CAS失败说明有竞争JVM会先自旋spin等待一会儿——自旋就是空转忙等线程不阻塞目的是赌持锁线程很快释放。JDK 6之后自旋是自适应的JVM会根据历史自旋成功率动态调整自旋次数自旋过度的线程会被降级。如果自旋仍拿不到锁或竞争过于激烈锁就升级为重量级锁Mark Word变为指向Monitor对象的指针未抢到锁的线程会被阻塞进入内核态的等待队列涉及用户态到内核态的切换。这是代价最大的状态线程从挂起到被唤醒来回要经过操作系统的线程调度延迟可能落在微秒甚至毫秒级别。我在分析线上线程栈时只要看到大量线程卡在synchronized的entry上通常就意味着锁已经走到重量级了。3.3 锁升级在真实业务中的表现与调优理解了锁升级链路很多线上现象就解释得通了。第一锁升级是单向的不能倒回去。一个锁一旦因为高峰竞争升到重量级即使后面竞争降下来了它也会一直保持重量级状态。对长期运行的高并发系统这意味着成本居高不下。解决方案是缩小临界区、减少持锁时间让竞争发生的概率本身变小。别指望锁自己“降级”它是不会的。第二偏向锁在JDK 15开始默认禁用后续版本逐步废弃。原因是偏向锁的撤销逻辑引入了太多复杂度在高竞争、大量对象被并发访问的场景下偏向锁的撤销成本反而拖累性能。如果你在JDK 8上做调优可以用-XX:UseBiasedLocking开关控制但新版本项目就别指望这个优化了。第三重量级锁的阻塞唤醒涉及操作系统调度。我在一个实际项目里遇到过一个看似很小的同步块因为内部做了一次耗时IO导致所有线程排队系统吞吐量瞬间掉到原来的十分之一。后来把IO挪出临界区锁竞争立刻缓解。这也是为什么我一直强调——synchronized的性能问题大多数时候不是锁本身的问题而是临界区设计的问题。4. 双重检查锁DCL为什么偏偏要synchronized和volatile搭配4.1 new一个对象不是一步完成的双重检查锁Double-Checked Locking, DCL是单例模式里的经典写法也是synchronized和volatile协同工作的绝佳案例。先看它“臭名昭著”的问题。很多人以为new Singleton()是一个原子操作实际上在JIT编译后的指令层面它拆成三步1分配内存空间2在内存上初始化对象调用构造器3把引用变量指向这块内存。这里的坑在于第2步和第3步可能被重排——也就是说引用变量可能先被赋值为一个“半初始化对象”的地址对象的构造器还没执行完。为什么可以重排因为从单线程视角看这两步谁先谁后对最终结果没有影响普通字段默认值是0或者null构造器赋值完还是这个对象。但在多线程场景下另一个线程可能在构造器执行到一半时就读到了这个引用然后拿着一个残缺对象开始用后果不可预测。4.2 DCL的完整推导volatile是兜住最后一条缝的网如果没有volatileDCL的经典写法是这样的外层先判空为空则进入同步块同步块里再判一次空然后new。第二层判空前线程A进入同步块开始new另一个线程B同时进入方法外层判空——此时引用可能已经被赋了值但对象还没初始化完B直接返回了半初始化对象。B拿到的引用不是null但用它的字段时可能读到默认值甚至抛NPE。加volatile就解决了volatile的写操作在JMM里的语义禁止了“引用赋值”与“对象初始化”之间的重排。也就是说volatile写给instance赋值之前的所有普通写操作构造器里的字段赋值都必须先行完成其他线程读到instance非null时看到的必然是一个完整构造好的对象。完整写法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; } }注意第一层判空没有加锁这是为了减少无谓的加锁开销第二层判空在锁内是为了保证只有一个线程做初始化。这个设计里每一行都有它的位置步骤是否加锁作用第一次判空否避免初始化后的每次调用都进同步块synchronized是保证初始化过程的互斥第二次判空是防止两个线程同时进入初始化volatile否禁止new的重排保证引用发布的可见性我把DCL的检查顺序也当做一个经验分享先判空再进锁进去之后必须再判一次。少了第二层判空两个线程可能先后进入同步块各自初始化出一个实例单例就失效了。少了volatile可能出现半初始化对象。这两个问题单独看不明显组合起来就是最经典的并发bug。4.3 其他单例写法与DCL的取舍除了DCL还有两种不被重视但更省心的写法。静态内部类方式public class Singleton { private Singleton() {} private static class Holder { static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }它利用了类加载的线程安全性JVM保证类在初始化阶段多个线程对同一类的初始化是互斥的。Holder类只会在getInstance第一次被调用时被加载所以天然线程安全还没有显式加锁。这种写法几乎挑不出毛病而且实现成本极低。枚举方式public enum Singleton { INSTANCE; }这个更省事枚举常量本身是单例的序列化也安全反射也无法破坏现代JDK对枚举做了保护。一般我在新代码里优先推荐枚举或静态内部类DCL主要用来理解原理以及在一些需要显式控制锁对象、锁范围的场景下使用。记住理解DCL不等于必须用它能简单解决的事别故意复杂化。5. 面试高频题与实测排坑这些结论我都用代码验证过5.1 “volatile能替代synchronized吗”是个送分题还是送命题先给结论不能。关键差异在原子性和互斥性上。volatile不能保证复合操作的原子性前面那个count就是证据也不能让多个线程互斥进入临界区。而synchronized不仅提供了互斥还顺带提供了可见性和有序性。我在技术评审里见过一个真实的错误设计有人用volatile修饰一个队列对象认为这样就能让多线程安全地往队列里放数据。实际上队列本身的内部结构数组下标、链表指针在并发写时完全可能错乱。volatile只能保证“引用变了大家能看到”不能保证“两个线程同时对队列做操作不会被破坏”。记住这个判断标准如果一个共享变量是简单的状态标志读多写少用volatile完全够如果涉及读-改-写、复合判断、或者需要保护一组变量的操作老老实实用synchronized或者用java.util.concurrent包下的锁。这不是性能偏好问题是正确性问题。5.2 synchronized锁的是对象不是方法面试里经常问“synchronized修饰静态方法和实例方法的区别”。静态方法锁的是Class对象实例方法锁的是this。所以下面这个代码是错的——两个线程分别调用不同实例的同名同步实例方法它们可以同时执行因为锁的不是同一个对象。public class Counter { public synchronized void add() { ... } } // 线程1: counter1.add() // 线程2: counter2.add()这两个add没有任何互斥关系。反过来如果方法是static synchronized所有线程共享同一个Class对象的锁互斥关系是全局的。开发中很容易踩这个坑特别是当我们以为“方法名一样就互斥”的时候。我排查过一次线上计数错乱根因就是有人把实例方法写成了同步但每个请求都new了新实例锁形同虚设。顺便说一个进阶点synchronized是可重入的但ReentrantLock也是可重入的两者在重入语义上是一致的。区别在于ReentrantLock支持超时、可中断、公平锁、多条件队列synchronized则依赖JVM内置的Monitor和锁升级机制使用上更简单。选择哪个看场景需要不存在谁完全替代谁。5.3 锁消除与锁粗化JIT在背后做了什么synchronized在JIT编译器手里并不是死板的。HotSpot有锁消除Lock Elimination和锁粗化Lock Coarsening两项优化我实测过它的存在感。锁消除依赖逃逸分析Escape Analysis。如果一个对象只在线程内部使用不会逃逸出当前线程那么synchronized就是多余的JIT会直接去掉锁。最典型的是StringBuffer的append方法——它是同步方法但如果你在方法内部创建一个局部StringBuffer做字符串拼接这段对象不会逃逸出去JIT分析完就把锁干掉了。所以你在JDK 8上看到的字符串拼接性能往往和直接用StringBuilder没太大差别。锁粗化则相反如果JVM检测到连续的多次加锁/解锁都发生在同一个对象上而且中间没有竞争它会把这些操作合并成一次加锁。比如循环体里反复对一个对象加锁JIT可能把锁扩展到整个循环外层减少加锁解锁的频次。这两项优化带来的启示是写代码时不要因为这些优化而放松对锁语义的理解但也别过度焦虑“性能”。只要锁的粒度本身合理JIT会帮你处理很多。反过来如果临界区里放了大耗时操作再聪明的编译器也救不了你。5.4 happens-before规则推导并发代码的标尺synchronized和volatile的可见性最终都可以用happens-before规则来推导。JMM定义了八条happens-before规则与本篇强相关的有三条1监视器锁规则一个锁的解锁unlockhappens-before于后续对同一把锁的加锁lock。 2volatile变量规则对一个volatile变量的写happens-before于后续对同一变量的读。 3传递性如果A happens-before BB happens-before C那么A happens-before C。用这三条规则可以推演出DCL的正确性volatile写之前的构造器赋值通过volatile规则和传递性对后续读到的线程可见synchronized块里的所有写入在锁释放后对后续获取同一把锁的线程可见。我实际排查并发问题时很少直接抠缓存一致性协议细节而是先用happens-before规则画一张依赖图哪个写必须对哪个读可见中间靠什么同步操作连接画完基本就知道该上volatile还是synchronized了。这套方法比背结论可靠得多——因为并发bug往往藏在“看起来没问题”的代码路径里而happens-before图能把那条路径上的所有可见性链路摊开。遇到说不清的并发问题先画图再动手。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询