Java synchronized底层原理与锁升级机制深度解析
发布时间:2026/9/8 7:21:50 作者:尧图编辑部 阅读量:1,286

我先给你交个底如果你去面试Java岗位synchronized几乎是必考题。但很多人对它的理解停留在“加锁可以保证线程安全”这个层面再往深问一句“它是怎么保证的锁升级是怎么回事和Lock有什么区别”就开始含糊了。倒也不怪大家synchronized这个关键字从JDK 1.0就有经过这么多年迭代底层实现已经相当复杂。早期它是重量级锁性能不好网上很多老文章还在这么写但JDK 6之后做了大规模优化引入了偏向锁、轻量级锁、锁升级机制现在的synchronized早就不再是“性能差”的代名词了。这篇文章我想从一个实际项目里踩过的坑讲起把synchronized从用法到底层原理完整拆一遍。你会看到它解决什么问题、怎么用才是正确的、哪些写法看着对其实有坑、面试官最喜欢从哪个角度追问。内容不设门槛但越往后越深建议拿个笔记本边看边记。1. 曾经踩过的坑一个漏加锁引发的线上事故先说一段真实经历。之前做一个活动系统里面有个给用户发奖励的接口逻辑很简单先查用户当前的奖励次数如果没超过上限就加一次然后发奖。伪代码长这样public void grantReward(Long userId) { // 1. 查询用户已领取次数 int count rewardMapper.getCountByUser(userId); // 2. 判断是否超限 if (count MAX_COUNT) { throw new BusinessException(已达领取上限); } // 3. 发奖并增加计数 rewardMapper.addCount(userId); sendReward(userId); }单线程下这段代码没任何问题。但线上是Tomcat多线程并发调用同一个用户同时点了两次领取两个线程同时读到count9上限10都通过判断然后各自加了一次最终用户领了11次。这就是典型的竞态条件。解决方式有很多数据库乐观锁、Redis分布式锁、应用内synchronized都可以。当时这个场景是单机应用最简单可靠的就是给方法加synchronized。于是改成了public synchronized void grantReward(Long userId) { // 逻辑不变 }问题解决。但你以为这样就完了没有。没过多久新的问题出现了这个接口的QPS开始下降因为synchronized把整个方法锁住了。用户A和用户B是两个不同的人本来可以并行处理现在因为synchronized方法锁默认锁的是this对象导致所有用户的请求都串行化了。这就是synchronized用得不对的典型后果——锁的粒度太大。后来我们把锁拆细改成锁用户维度public void grantReward(Long userId) { synchronized (getLockByUserId(userId)) { // 查询、判断、发奖 } }把同一个用户的请求串行化不同用户之间互不影响性能一下就上来了。这段经历是我理解synchronized的起点。它让我意识到三件事第一synchronized能解决问题但要用对第二锁的粒度直接决定系统性能第三只靠关键字是不够的你得理解它底层到底怎么工作才能用好它。2. synchronized的三种用法锁的到底是什么synchronized在Java里有三种使用方式每一种锁定的目标都不同。很多初学者搞混其实记住一句话就够了synchronized锁的一定是一个对象不是一个方法也不是一段代码。2.1 修饰实例方法锁的是thispublic class Counter { private int count 0; public synchronized void increment() { count; } }当synchronized修饰实例方法时锁的是调用这个方法的对象也就是this。两个线程同时调用同一个Counter实例的increment方法会互相竞争同一把锁但如果是两个不同的Counter实例锁就是两把互不干扰。这带来一个隐蔽的坑如果你用synchronized修饰实例方法但实际使用中创建了多个实例锁就形同虚设。Spring默认单例所以还好但手动new出来的对象要格外小心。2.2 修饰静态方法锁的是Class对象public class Counter { private static int count 0; public static synchronized void increment() { count; } }静态方法锁的是当前类的Class对象比如Counter.class。Class对象在JVM里是全局唯一的所以即使是不同实例也会竞争同一把锁。要注意的是实例方法的锁和静态方法的锁不是同一把。如果一个类里既有synchronized实例方法又有synchronized静态方法它们可以并行执行因为锁的目标不同。这个细节面试也常考很多人以为都是“这个类的锁”其实不是。2.3 修饰代码块可精确指定锁对象public void increment() { synchronized (this) { count; } } public void incrementWithLock(Object lock) { synchronized (lock) { count; } }代码块方式最灵活。你可以锁this也可以锁一个专门的锁对象还可以根据业务维度动态选择锁对象。这是实际开发中用的最多的一种方式因为粒度可控。我习惯的做法是如果一个方法里有大量耗时操作远程调用、IO但只有一小段涉及共享资源一定不要直接锁整个方法而是锁住那一小段其他代码保持并行。这不是微优化在高并发下差别非常大。2.4 不同用法的锁对象对照表用法锁对象作用范围常见误区修饰实例方法this同一实例内串行以为不同实例也会互斥修饰静态方法Class对象全局唯一以为实例方法也受影响修饰代码块(synchronized(this))this同一实例内串行锁粒度偏大修饰代码块(synchronized(obj))指定的obj由obj决定锁对象选错导致失效最后补充一个很重要的点synchronized是可重入的。同一个线程已经持有了某把锁再次执行同一个锁保护的代码时不需要重新竞争直接就能进入。比如一个synchronized方法内部调用了另一个synchronized方法如果锁不可重入就会死锁。Java的synchronized天然支持重入这也是它比手动lock简单的原因之一。3. synchronized的底层原理从字节码到对象头“synchronized底层是怎么实现的”是面试官最爱深挖的问题。我尽量用大白话讲但该严谨的地方不会含糊。3.1 字节码层面的秘密写一个简单的同步代码块编译后用javap看字节码你会看到两条指令monitorenter和monitorexit。public void test() { synchronized (this) { System.out.println(hello); } }对应的字节码核心片段monitorenter ... 业务代码 ... monitorexit ... 如果发生异常走异常处理器 ... monitorexit // 异常时也会执行保证锁一定释放monitorenter表示尝试获取对象的monitor锁monitorexit表示释放锁。注意字节码里有两次monitorexit这是为了处理异常——即使代码块内抛异常也会走异常表路径执行第二次monitorexit确保锁能释放。这也是synchronized比手动lock更安全的原因你永远不会忘记解锁。如果是synchronized修饰方法字节码里并没有monitorenter/monitorexit而是在方法的访问标志里多了一个ACC_SYNCHRONIZED标记。JVM在调用方法时检查这个标志如果设置了就尝试获取锁。本质上和monitor机制是一回事只是表现形式不同。3.2 对象头和Mark Word锁信息存哪了Java对象在内存里的布局分三块对象头、实例数据、对齐填充。锁的信息存在对象头里。对象头里有一个关键部分叫Mark Word64位JVM下占8字节里面存储了对象的hashCode、分代年龄、锁状态标记等信息。最关键的是锁状态决定了Mark Word里存的到底是什么。不同锁状态下Mark Word的布局64位JVM锁状态存储内容无锁对象的hashCode、分代年龄、偏向锁标志位0、锁标志位01偏向锁线程ID、epoch、分代年龄、偏向锁标志位1、锁标志位01轻量级锁指向栈中锁记录的指针、锁标志位00重量级锁指向monitor对象的指针、锁标志位10你看锁升级的本质就是Mark Word里的内容在不断变化。偏向锁存持有线程的ID轻量级锁存栈帧中锁记录的指针重量级锁存monitor对象的地址。3.3 monitor机制重量级锁的核心monitor监视器锁是synchronized最底层的实现机制。每个Java对象都可以关联一个monitor对象这个monitor是C实现的在HotSpot里叫ObjectMonitor。ObjectMonitor里有几个关键字段_owner // 当前持有锁的线程 _EntryList // 等待获取锁的线程队列 _WaitSet // 调用wait()后等待的线程队列 _recursions // 锁重入次数当一个线程执行到monitorenter时JVM会尝试获取对象的monitor。如果_owner为null说明锁空闲线程直接获取成功_owner设置为当前线程如果_owner已经指向当前线程说明是重入_recursions加1如果_owner指向其他线程当前线程进入_EntryList阻塞等待。这就是synchronized重量级锁的工作模型。它依赖操作系统的互斥量mutex实现阻塞和唤醒所以涉及到用户态和内核态的切换开销比较大。这也是synchronized早期性能差的原因。不过别担心现代JVM已经不需要每次都走到重量级锁因为它有了一套完整的锁升级机制。4. 锁升级全流程从偏向锁到重量级锁JDK 6之后synchronized的锁有四种状态无锁、偏向锁、轻量级锁、重量级锁。锁只能升级不能降级偏向锁可以批量撤销但大方向是升级。这个设计思路很聪明大多数锁在大部分时间里没有竞争没必要一上来就搞重量级。4.1 偏向锁让同一个线程重复获取锁偏向锁的理念是如果一把锁从头到尾只有一个线程在获取那就没必要做同步操作在Mark Word里记录这个线程的ID就行了。下次这个线程再来直接判断Mark Word里存的线程ID是不是自己是就直接进入啥也不用做。这有点像你办公室工位的门锁如果一栋楼只有你用这扇门你把指纹录进去每次直接推开就行不需要每次都掏出钥匙锁门开锁。偏向锁默认是开启的但有延迟——JVM启动后4秒内创建的对象不会启用偏向锁。这是为了避免JVM启动时的竞争。这个参数可以用-XX:BiasedLockingStartupDelay0关闭延迟。如果另一个线程尝试获取偏向锁说明有竞争了。此时会先撤销偏向锁将锁升级为轻量级锁。如果大量线程频繁竞争偏向锁会被批量撤销。4.2 轻量级锁用CAS代替阻塞当第二个线程来竞争时偏向锁会被撤销升级为轻量级锁。轻量级锁的核心是自旋CAS。线程在自己的栈帧中创建一块锁记录空间然后尝试用CAS把对象头Mark Word替换成指向锁记录的指针。如果成功说明获取锁成功如果失败说明有竞争线程开始自旋等待。自旋就是让线程在一个循环里不断尝试获取锁而不是直接阻塞。线程在用户态自旋避免了内核态切换的开销。JDK 6之后自旋是自适应的JVM会根据历史情况动态调整自旋次数第一次自旋成功率高以后就多自旋几次一直失败就减少甚至不自旋。自旋的代价是占用CPU。如果锁持有时间很短自旋很划算锁持有时间很长自旋就是白白烧CPU。4.3 重量级锁进入真正的阻塞如果自旋超过一定次数或自适应自旋判定不应该再自旋锁升级为重量级锁。这时候走的就是前面说的monitor机制线程真正阻塞依赖操作系统调度。重量级锁的好处是不占CPU阻塞的线程不会参与调度。代价是线程阻塞和唤醒涉及到用户态和内核态的切换开销大。这就是为什么长时间持有锁的场景下重量级锁反而更合适——它的等待成本是可控的。4.4 一个完整的锁升级案例我把锁升级过程串起来结合场景讲假设一个计数器对象刚创建无锁状态。线程A第一次执行synchronized代码块JVM发现无锁判断当前线程ID为空直接把偏向锁指向线程AMark Word记录线程A的ID。此时A后续再来零成本通过。线程B尝试执行同一段代码发现Mark Word里的线程ID是A不是自己。说明出现竞争偏向锁撤销升级轻量级锁。B用CAS尝试替换Mark Word指向自己的锁记录。如果A还没执行完CAS失败B自旋等待。假设A很快执行完释放锁B自旋过程中CAS成功拿到锁。假设A持有锁时间很长B自旋多次失败锁升级为重量级锁B进入阻塞队列等待操作系统唤醒。这是synchronized最完整的生命周期。理解了这个流程你就理解了为什么现代synchronized性能不差——大多数场景下它根本不走到重量级锁那一步。5. synchronized保证的三个核心特性原子性、可见性、有序性面试官还会换个角度问synchronized是怎么保证线程安全的标准回答是它保证了三个特性。5.1 原子性锁保证了执行的不可分割原子性指的是一个操作或者多个操作要么全部执行要么全部不执行不能被线程调度机制打断。synchronized通过monitor机制保证了原子性。线程必须获得锁才能进入同步代码块没拿到锁的线程只能等待。整个同步代码块的执行过程对其它线程来说是不可分割的。但要注意synchronized保证的是代码块内的原子性不是方法内所有操作的原子性。如果你在同步代码块里又调用了别的不加锁的方法操作共享数据原子性一样会被破坏。5.2 可见性锁释放时刷新主内存可见性指的是一个线程对共享变量的修改对其他线程是可见的。这里牵涉Java内存模型JMM。JMM规定线程对共享变量的操作在自己的工作内存中不是直接操作主内存。所以一个线程改了变量值另一个线程可能看不到。synchronized的可见性保障机制是线程加锁时会清空工作内存中共享变量的值直接从主内存重新加载线程释放锁时会把工作内存中修改的变量值刷新到主内存。这就像开会时的白板大家进会议室先看白板上最新内容散会时谁修改了数据必须写到白板上给其他人看。5.3 有序性防止指令重排编译器和CPU为了优化性能可能会调整指令执行顺序这就是指令重排。在单线程下没问题多线程下就可能出幺蛾子。synchronized通过锁的互斥性保证同步代码块内的代码不会被其他线程干扰。一个线程在锁内执行的指令重排对其他线程不可见因为它们根本进不来。注意synchronized内部如果没有其他同步机制比如volatile它不能完全禁止代码块内部的指令重排但它保证了重排的结果对其它线程不可见因为其它线程要看到结果必须先拿到锁。这在逻辑上就保证了安全。5.4 三个特性之间的关系特性synchronized如何保证通俗理解原子性monitor互斥同一时刻只有一个线程执行不是你的回合你就进不了球场可见性加锁刷新工作内存解锁刷新主内存交接工作必须对账有序性互斥执行防止指令重排对其它线程可见别人看不到你内部的小动作日常开发中不需要刻意分这三个维度去写代码但面试时能讲清楚这三个维度的实现机制会显得你真有深入研究过。6. synchronized使用中的常见错误与正确姿势这部分内容多因为我在实际开发中见过太多用错synchronized的案例。挑几个典型的分享给你。6.1 错误一锁对象用了字符串常量public class LockService { private String lock LOCK; public void doSomething() { synchronized (lock) { // ... } } }字符串常量在JVM里可能被缓存字符串常量池。如果代码里有另一处也用LOCK做锁它们就是同一把锁会导致两个毫无关系的模块互相阻塞。正确的做法是使用私有静态final的Object实例作为锁private static final Object LOCK new Object();6.2 错误二锁对象在方法里创建public void doSomething() { Object lock new Object(); synchronized (lock) { // ... } }每次调用方法都新建一个锁对象锁就是全新的互斥效果完全失效。锁对象必须是被所有线程共享的同一个对象。6.3 错误三锁的粒度太大或太小粒度太大性能下降前面发奖接口的例子已经很典型。粒度太小可能锁不住完整的业务流程导致数据不一致。判断粒度是否合适的标准是锁保护的代码范围是否能覆盖共享资源从读、判断到写回的全过程。只锁写不锁读照样出问题。// 错误锁没有覆盖整个检查更新流程 public void grantReward(Long userId) { int count rewardMapper.getCountByUser(userId); // 无锁查询 if (count MAX_COUNT) { throw new BusinessException(已达领取上限); } synchronized (lock) { rewardMapper.addCount(userId); sendReward(userId); } }两个线程可能同时读到count9都通过判断然后串行进入锁内各自加一次。因为判断过程没有加锁锁保护的范围不够。正确做法是把“读count→判断→加count”放在同一把锁内。6.4 错误四在循环里使用synchronized做等待有些初学者会这样写synchronized (lock) { while (condition) { // 空转等待 } }如果condition由其他线程修改这个循环会锁死当前线程其他线程进不来永远无法修改condition形成死锁。正确做法是使用wait/notify机制让出锁并等待通知synchronized (lock) { while (condition) { lock.wait(); // 释放锁并等待 } }这是wait/notify必须放在synchronized里的根本原因必须先持有锁才能安全地检查条件并等待。6.5 synchronized和Lock怎么选除了synchronizedJava并发包里还有Lock体系ReentrantLock等。我给的选型建议需求推荐方案基本互斥同步synchronized简单可靠需要锁超时、可中断ReentrantLock需要公平锁ReentrantLock(true)需要多个条件变量ReentrantLock Condition需要非阻塞尝试获取锁ReentrantLock.tryLock()读写场景差异明显ReentrantReadWriteLockJDK 6之后synchronized经过优化性能上和ReentrantLock基本没有明显差距。日常开发我优先用synchronized只有在上面表格里的特殊需求时才上Lock体系。这也是Java官方文档的建议方向。7. 高频面试题一多线程同时调用同一个synchronized方法结果会怎样这是面试出现频率极高的一道题考察的就是对锁行为的理解。题目有一个类里面一个synchronized方法循环执行5秒。现在创建两个线程同时调用同一个实例的这个方法最后总耗时是多少答案是大约10秒。因为两个线程竞争同一把锁必须等第一个线程执行完释放锁第二个线程才能进入。但这个答案有几个变体面试官会逐步加深难度变体一如果调用的是两个不同实例的同一个synchronized方法耗时多少答案是大约5秒。因为实例方法锁的是this不同实例是不同锁线程并行执行。变体二如果一个线程调用synchronized实例方法另一个线程调用同一个类的synchronized静态方法会互斥吗答案是不会。因为前者锁this后者锁Class对象是两把锁可以并行。变体三如果一个synchronized方法内部又调用了同一个类的另一个synchronized方法会死锁吗答案是不会。因为synchronized可重入同一个线程已经持有锁可以直接再次进入。这几个变体覆盖了锁对象、锁类型、可重入性三个知识点面试官通过一道题就能摸清你理解到什么程度。8. 高频面试题二JVM是如何实现synchronized的这道题没有标准答案但优秀回答应该包含以下几个层面从浅入深第一层字节码层synchronized代码块会生成monitorenter和monitorexit两条字节码指令异常时也有对应的锁释放路径synchronized方法会在方法访问标志上增加ACC_SYNCHRONIZED。第二层对象头层锁的状态信息存储在对象头的Mark Word中不同锁状态下Mark Word的用途不同。从无锁到偏向锁到轻量级锁到重量级锁Mark Word的内容也在动态变化。第三层锁升级层JVM为了减少锁竞争开销引入了偏向锁单线程重复获取场景优化、轻量级锁CAS自旋获取、重量级锁monitor阻塞。锁会随着竞争程度逐步升级现代同步代码大多数在轻量级阶段就结束了。第四层重量级锁原理重量级锁依赖ObjectMonitor核心字段包括_owner持有线程、_EntryList等待队列、_WaitSet等待条件队列、_recursions重入计数。线程阻塞和唤醒依赖操作系统mutex需要用户态和内核态切换。如果能从字节码一路讲到ObjectMonitor面试官基本就会认可你确实研究过原理。9. 高频面试题三为什么wait和notify必须放在synchronized里这也是经典题考察点在于你有没有真正理解Java线程协作的机制。直接原因wait和notify是Object的方法它们操作的是对象的monitor监视器锁上的等待集合。一个线程必须持有对象的monitor锁才能调用该对象的wait方法释放锁并等待或者调用notify方法唤醒等待线程。如果在没有持有锁的情况下调用wait或notifyJVM会抛出IllegalMonitorStateException。根本原因需要重点答的部分这是为了避免丢失通知和竞态条件。假设wait不需要持有锁就能调用可能出现这样的场景线程A判断条件不满足正打算调用wait线程B此时修改了条件并把条件改为满足然后调用notify但A还没进入wait状态notify通知就丢失了A之后才进入wait永远等不到通知最后死锁。在synchronized内执行“检查条件→wait/notify→重新检查条件”整个流程是原子的不可能被其他线程打断所以通知不会丢失。这就是wait/notify必须和synchronized配套的根本原因。补充一点即使有了synchronizedwait之后也建议用while循环重新检查条件而不是用if。因为Java官方文档和Effective Java都指出线程被唤醒后条件可能已经变化必须重新校验这就是“虚假唤醒”的防护。10. 高频面试题四synchronized是公平锁还是非公平锁这是最近面试里出现频率比较高的一个新考点。synchronized是非公平锁。所谓公平锁是指多个线程按照申请锁的顺序来获取锁先来后到非公平锁则允许后来的线程直接竞争锁不一定按申请顺序。原因是synchronized的锁升级机制。在轻量级锁阶段线程通过CAS自旋抢锁谁能先CAS成功就是谁的不存在排队顺序的概念。升级为重量级锁后线程进入ObjectMonitor的_EntryList理论上按顺序唤醒但轻量级阶段已经“插队”了所以整体上是非公平的。ReentrantLock默认也是非公平锁但构造时可以传入true开启公平模式。非公平锁的优点是吞吐量更高——刚释放锁的线程还在CPU上运行让它立刻再次获取锁可以减少上下文切换。缺点是可能造成某些线程长时间获取不到锁也就是“饥饿”问题。不过在绝大部分业务场景下非公平带来的问题并不明显不用过度担心。11. 高频面试题五synchronized可以锁null吗这个问题看着简单真能答好的不多。synchronized不能锁null。如果synchronized的锁对象是null会抛出NullPointerException。原因是获取锁本质上是操作对象的monitornull没有monitor可操作。同理锁对象不能是基本类型比如int、boolean因为synchronized针对的是对象。实际开发中这容易踩坑如果锁对象是从外部传入参数或者从缓存里查回来的要加判空。否则某个请求正好传了空值直接NPE。顺便说一个进阶点如果锁对象被重新赋值重新new了一个对象锁也会变成新对象的锁原本的等待线程会等在原对象上导致锁失效。所以锁对象初始化后要避免再重新赋值。12. 一个完整的优化案例从方法锁到分段锁前面讲了理论最后用一个案例把正确思路串起来。假设现在有一个用户积分服务每次调用来给指定用户加分。最开始的实现public class PointService { private MapLong, Integer points new HashMap(); public synchronized void addPoint(Long userId, int point) { Integer old points.get(userId); if (old null) { old 0; } points.put(userId, old point); } }问题很明显全表锁所有用户加分都串行。优化方向是锁分段——不同用户不同锁减少竞争。方案一给每个用户一个锁对象public class PointService { private MapLong, Integer points new HashMap(); private MapLong, Object locks new ConcurrentHashMap(); public void addPoint(Long userId, int point) { Object lock locks.computeIfAbsent(userId, k - new Object()); synchronized (lock) { Integer old points.get(userId); if (old null) { old 0; } points.put(userId, old point); } } }每次操作只锁当前用户不同用户并行。ConcurrentHashMap保证锁对象的获取是线程安全的。方案二如果担心用户量太大导致锁对象过多可以做固定分段public class PointService { private MapLong, Integer points new HashMap(); private Object[] lockArray new Object[16]; public PointService() { for (int i 0; i lockArray.length; i) { lockArray[i] new Object(); } } public void addPoint(Long userId, int point) { int slot userId.hashCode() 15; // 取模16落到固定seg synchronized (lockArray[slot]) { Integer old points.get(userId); if (old null) { old 0; } points.put(userId, old point); } } }这是用数组实现的分段锁只创建16个锁不同用户根据hash落到不同槽位最多16个线程并行。空间固定不随用户数增长。如果数据量极大、并发极高可以考虑用LongAdder、ConcurrentHashMap的原子方法甚至引入Redis分布式锁。但从单体应用的视角看固定分段锁已经能应对绝大多数场景。这个案例的核心是先明确锁要保护什么再设计锁的粒度最后才考虑用哪种锁机制。顺序反了优化就是瞎折腾。13. 关于synchronized我建议你记住的几件事最后分享几个我在源码阅读和实际项目中总结的经验不一定每个都能在面试中直接用上但对理解JVM并发模型很有帮助。第一synchronized锁的信息存在对象头里这意味着锁的不是代码而是对象。任何时候问自己“这个锁锁的到底是哪个对象”答案清楚了用法就错不了。第二锁升级是synchronized设计的灵魂。理解了偏向锁、轻量级锁、重量级锁的适用场景你就理解了为什么现代Java并发编程中很多场景一个synchronized就够用不需要炫技似地引入各种复杂同步组件。第三在极低竞争场景下尽量不要随意加锁。如果共享数据本身是只读的或者可以通过ThreadLocal、不可变对象规避就没必要用synchronized。锁是有开销的即便优化过也一样。第四写并发代码时先人身安全后性能优化。先把锁加对保证没有数据竞争再去考虑减少锁竞争。很多事故不是代码写得不够高性能而是最基础的并发安全都没守住。synchronized是Java并发编程的第一课也是最值得反复咀嚼的一个知识点。把它吃透了再去学volatile、ReentrantLock、ConcurrentHashMap、AQS会顺畅很多。希望这篇文章能帮你把这块地基打牢。