可重入锁深入解析:synchronized与ReentrantLock底层原理及实践

发布时间:2026/9/13 9:46:02
可重入锁深入解析:synchronized与ReentrantLock底层原理及实践 搞Java这么多年JUC几乎成了面试必考的“钉子户”而可重入锁又是JUC里最基础、最高频的一个考点。我见过不少同学聊到可重入锁能说出“ReentrantLock是可重入的synchronized也是可重入的”但再往深问一句“为什么可重入”“底层是怎么实现的”就卡住了。这其实很可惜因为可重入锁不仅是面试题更是日常写并发代码时天天打交道的东西。这篇文章想把可重入锁这件事彻底讲透。从最直观的“重复加锁”现象讲起深入到synchronized的锁升级机制、ReentrantLock底层的AQS实现再到真实项目里怎么用好它、有哪些容易踩的坑。不管你是在准备面试还是在工作中被并发问题折磨过这篇内容应该都能帮上忙。我会尽量少说废话直接用源码和实例说话。1. 可重入锁是什么为什么Java并发里绕不开它1.1 从一个“自己锁死自己”的例子说起先看一个很经典的场景。假设有一个订单服务类里有两个方法都加了synchronized一个方法内部调用了另一个方法public class OrderService { public synchronized void processOrder() { System.out.println(开始处理订单); confirmOrder(); } public synchronized void confirmOrder() { System.out.println(确认订单); } }此时线程A进入processOrder方法已经持有了OrderService实例的锁。接着在方法内部调用confirmOrder它同样需要获取这把锁。如果synchronized不是可重入的线程A在这里就会和“自己”发生锁竞争——自己等自己释放锁结果永远等不到直接死锁。但实际跑一下能发现程序正常执行没有任何问题。这就说明JVM的synchronized在底层实现了可重入的能力。同理ReentrantLock这个类名本身就是Reentrant加Lock的组合意思是“可以重复进入的锁”。1.2 可重入的核心语义可重入锁的完整定义是同一个线程在已经持有锁的情况下可以重复地获取同一把锁而不会被阻塞。每获取一次锁的持有计数加1每释放一次计数减1直到计数归零锁才真正被释放。这里面有一个非常关键的概念就是“锁的持有者”和“锁的持有计数”。锁不是简单的一个“开/关”状态而是要记录两样东西当前这把锁被哪个线程持有。当前线程重入了多少次。用一个生活化的类比来理解酒店的房门客人刷卡进门后门不会自动上锁。这个客人出去倒垃圾、去楼下取快递再回来时不用再办一次房卡直接推门就进。但如果是另一个陌生人想进门就不行了。这里的“客人身份”相当于持有锁的线程“反复进出”相当于重入“次数”就是每次进门刷一下卡计数加一。我遇到过一些初学者问“可重入会不会导致锁失效”其实恰恰相反可重入让线程在持锁情况下访问同一把锁保护的资源时不用自己等自己既避免了死锁也不需要反复做无意义的加锁解锁操作。1.3 可重入解决的三个核心问题第一是避免自身死锁。递归调用、方法链式调用、事务内调用事务这些场景下如果没有可重入机制线程会在第二次获取同一把锁时永远阻塞。第二是简化并发API的设计。Java里synchronized方法、synchronized块可以随意组合不用担心嵌套调用造成死锁。ReentrantLock也提供了getHoldCount()方法来查看当前线程的重入次数这在调试锁问题时非常有用。第三是统一了“线程级”和“操作级”的锁语义。锁的粒度以线程为单位而不是以方法调用为单位。持有锁的线程可以做任意次数的嵌套加锁只要配对释放即可。2. synchronized的可重入机制是怎么实现的2.1 锁升级路线JVM对synchronized的优化在JDK 1.5之前synchronized是重量级锁每次加锁解锁都要通过操作系统层面的monitor机制性能很差。但从JDK 1.6开始JVM对synchronized做了大量优化引入了锁升级机制锁会按照“无锁 → 偏向锁 → 轻量级锁 → 重量级锁”的路线进行升级。很多面试题喜欢问这条升级链路其实这条链路也和可重入的实现密切相关。理解这条链路就理解了synchronized为什么能可重入以及不同竞争程度下它是怎么保证可重入的。2.2 对象头和Mark Word讲到synchronized的底层实现必须提到对象头。Java对象在内存中由三部分组成对象头、实例数据、对齐填充。对象头里有一个Mark Word它存储了对象自身的运行时数据包括哈希码、GC分代年龄、锁状态标志等信息。Mark Word在64位虚拟机下占64个bit不同锁状态下这64个bit的布局完全不同。锁升级的本质就是改变Mark Word里的锁状态标志位并写入与当前锁状态相关的信息。可以看一下不同状态下Mark Word的关键内容锁状态存储内容锁标志位无锁对象哈希码、分代年龄01偏向锁持有偏向锁的线程ID、epoch、分代年龄01轻量级锁指向栈中锁记录的指针00重量级锁指向堆内monitor对象的指针102.3 三种锁状态下的可重入逻辑偏向锁阶段的可重入是最“廉价”的。当线程A第一次获取锁时JVM通过CAS操作将线程A的ID写入Mark Word锁进入偏向模式。之后线程A再次尝试获取这把锁只需要检查Mark Word中的线程ID是否是自己。如果是直接进入不需要做任何同步操作。这个判断过程是一个纯内存比较操作耗时极低。所以在低竞争场景下偏向锁可以大幅度提升性能。这就像公司门口刷门禁卡第一次录入指纹以后每次进门系统一看到你的指纹就直接放行不用再走一遍审批流程。当有线程B来竞争这把锁时偏向锁就要被撤销了。线程B发现Mark Word里的线程ID不是自己说明产生了竞争此时JVM会等待一个安全点然后撤销偏向锁升级到轻量级锁。轻量级锁阶段的可重入很有意思。线程A持有锁时再次进入同步代码块JVM会在当前线程的栈帧中创建一个名为Lock Record的空间然后尝试通过CAS把对象头中的Mark Word复制到Lock Record中。如果成功说明获取到锁并将Mark Word更新为指向Lock Record的指针。如果这个线程已经持有锁了再获取锁时CAS肯定是失败的因为Mark Word里已经是指向第一个Lock Record的指针了。那怎么办JVM的处理方式是在线程栈中再创建一个Lock Record但这次Lock Record里保存的displaced mark word为null标识这是一个“重入计数”。每次重入都会新增一条Lock Record解锁时每出一次同步代码块就撤销一条Lock Record。如果发现displaced mark word为null就直接重置Lock Record不修改对象头。当最后一条Lock Record撤销时才真正把对象头还原。这个重入计数本质上就是靠Lock Record的“条数”来体现的。重量级锁的可重入就更好理解了。重量级锁对应的是ObjectMonitor对象它内部有一个_recursions字段。每次加锁时如果当前线程就是持有者_recursions就加1。解锁时每释放一次_recursions减1减到0才真正释放锁。这就是synchronized可重入的完整底层逻辑。从偏向锁到重量级锁虽然实现方式各不相同但核心思路是一致的记录当前持有线程并且要么通过线程ID比对、要么通过Lock Record数量、要么通过_recursions计数来表示重入次数。2.4 为什么锁需要升级而不是一步到位很多人问过“既然重量级锁功能最完整为什么不一开始就用重量级锁”其实答案很简单重量级锁的性能损耗太大了。线程获取重量级锁时如果竞争失败会从用户态切换到内核态进行阻塞这个切换成本非常高。而实际项目中大多数锁的竞争并不激烈甚至大部分时间只有一个线程在访问。如果一上来就是重量级锁等于为所有场景承担了最昂贵的代价。所以JVM设计了偏向锁和轻量级锁让锁在低竞争时保持轻量只有真正出现高竞争时才升级为重量级锁。这项优化也让synchronized在现代Java项目中很多场景下性能并不输给ReentrantLock。这也是为什么很多团队默认推荐用synchronized而不是一上来就用ReentrantLock的原因之一。3. ReentrantLock与AQS从源码级别看可重入3.1 AQS到底是什么ReentrantLock的核心实现基于AbstractQueuedSynchronizer也就是面试中经常被提到的AQS。AQS是Java并发包的基础框架ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock这些工具类底层全部依赖AQS。AQS的设计思路非常巧妙。它内部维护了一个volatile int state字段这个字段就是锁状态的核心。同时维护了一个FIFO的等待队列用来存放竞争失败被阻塞的线程。对ReentrantLock而言state有明确的含义state 0锁没有被任何线程持有。state 0锁被某个线程持有数值就是该线程的重入次数。如果线程A第一次拿到锁state变成1。线程A再次加锁state变成2。线程A的锁释放一次state减1。当state从1减到0时锁才真正释放等待队列里的下一个线程才有机会获取锁。这个设计简单且高效一个字段同时解决了“谁持有锁”和“重入次数”两个问题。持有线程通过exclusiveOwnerThread字段单独记录。3.2 非公平锁的加锁源码解析ReentrantLock默认是非公平锁。非公平的含义是新来的线程在尝试加锁时会先插队抢一次不会老老实实去排队。看NonfairSync的lock方法源码final void lock() { // 第一步先尝试CAS如果state从0变成1成功直接获得锁 if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); } else { // 第二步CAS失败进入acquire流程 acquire(1); } }compareAndSetState(0, 1)的含义是如果state当前值为0说明锁空闲把state改成1这个操作是原子的。改成功后把当前线程记录为锁的持有者加锁完成。如果CAS失败说明锁被占用或者这一刻正好有人抢锁成功。这时候进入acquire(1)方法这是AQS的模板方法public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) { Thread.currentThread().interrupt(); } }这里tryAcquire就是子类实现的可重入逻辑。NonfairSync继承的Sync类中nonfairTryAcquire方法是最关键的一段代码final boolean nonfairTryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { // 锁空闲直接CAS获取 if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } // 当前线程已经是持有者说明是重入 else if (current getExclusiveOwnerThread()) { int nextc c acquires; if (nextc 0) // 溢出检查 throw new Error(Maximum lock count exceeded); setState(nextc); return true; } return false; }留意这段逻辑先检查state如果为0说明锁空闲CAS尝试获取如果state不为0就判断当前线程是不是锁的持有者如果是state加上acquires后重新设置这就是重入。所以ReentrantLock的可重入实现核心就是这句话同一线程获取锁时state累加而不是阻塞。3.3 解锁和重入次数的关系解锁走的是unlock()方法最终会走到Sync的tryReleaseprotected final boolean tryRelease(int releases) { int c getState() - releases; // 只有持有锁的线程才能解锁 if (Thread.currentThread() ! getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free false; if (c 0) { free true; setExclusiveOwnerThread(null); } setState(c); return free; }注意这里有个细节getState() - releases当前state减去释放的数量得到剩余的重入次数。如果剩余值不为0说明锁还被当前线程持有只是重入次数减少了。如果剩余值为0说明锁完全释放清空持有者线程。如果加锁3次却只需要解锁2次锁不会释放因为第三层的外部方法还在使用锁保护的资源必须等最外层的锁也释放才行。这就是“配对释放”的严格规则少一次解锁锁就永远不释放多一次解锁会抛出IllegalMonitorStateException。3.4 公平锁与非公平锁的差异公平锁和可重入锁是两个容易混淆的概念。可重入说的是“同一个线程能不能重复获取”公平性说的是“等待中的线程能不能按顺序获取”。ReentrantLock的公平模式和非公平模式在可重入逻辑上是一样的差别只在获取锁的时机上。看FairSync的tryAcquire源码protected final boolean tryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { // 公平锁先检查队列里有没有线程在等待 if (!hasQueuedPredecessors() compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current getExclusiveOwnerThread()) { int nextc c acquires; if (nextc 0) throw new Error(Maximum lock count exceeded); setState(nextc); return true; } return false; }公平锁在c 0时多了一个hasQueuedPredecessors()判断。这个方法会检查等待队列里是否已经有线程在排队。如果有人排队新来的线程不能插队必须排到队尾。非公平锁则直接CAS抢一次抢不到才去排队。非公平锁的性能通常会好一些因为新线程直接抢锁可能一次就成功省去了线程阻塞和唤醒的开销。但代价是在高并发下等待队列里的线程可能会“饿肚子”迟迟得不到执行机会。公平锁正好相反性能略低但每个人的等待时间更可预测。实际项目中我默认使用非公平锁。只有在明确要求请求顺序和实际执行顺序一致时比如一些需要严格时间顺序的系统才会选择公平锁。3.5 ReentrantLock优于synchronized的三个能力既然synchronized经过优化后性能已经不差为什么还要用ReentrantLock主要是因为它提供了几个synchronized没有的能力。第一个是可中断的加锁lockInterruptibly()方法支持在等待锁的过程中响应中断。如果线程卡在lockInterruptibly上其他线程可以调用该线程的interrupt()方法线程会抛出InterruptedException并退出等待。而synchronized在等待锁时是不能被中断的线程只能一直等下去。第二个是可超时的加锁tryLock(long timeout, TimeUnit unit)方法可以设置一个最长等待时间超时后自动放弃。这个特性在避免死锁上非常有用。比如线程A拿到锁1要在超时时间内拿锁2线程B拿到锁2要在超时时间内拿锁1。如果synchronized这就是死锁用tryLock设置超时时间某个线程等不到锁会自动回退打破死锁。第三个是支持多个条件队列synchronized的wait/notify只有一个条件队列而ReentrantLock可以通过newCondition()创建多个Condition实现更精细的线程协作。类似场景是生产者-消费者模型里用两个条件分别通知生产和消费避免无谓的唤醒。4. 可重入锁在真实项目里的几个关键实践4.1 锁的释放必须是“铁律”finally里unlock讲一个我真实遇到过的事故。有一次排查线上bug发现某个接口偶尔会卡死最终定位到问题是因为某段代码在获取ReentrantLock之后中间调用的第三方接口抛了异常导致lock.unlock()这行代码没有执行。从此这把锁的state一直为1所有后续线程卡在lock.lock()上整个服务可用性直接降级。用ReentrantLock必须遵守一条铁律加锁之后立即在finally块里解锁。ReentrantLock lock new ReentrantLock(); lock.lock(); try { // 业务逻辑 } finally { lock.unlock(); }有人可能觉得synchronized就省心因为锁的释放由JVM自动管理异常时也会自动释放。但synchronized也因此在灵活性上不如ReentrantLock。两者各有定位但只要是手动的锁就必须用标准的try-finally模板。4.2 锁粒度决定并发上限可重入锁有一个很容易被忽视的性能问题锁的粒度太大会导致并发能力急剧下降。我用过一段非常烂的代码有人把整个批量处理流程都放进一个synchronized块里从数据库查询、计算、到远程接口调用全部串行执行。结果就是20个请求进来只能一个一个处理性能惨不忍睹。锁的粒度控制有几个基本原则只对需要同步的代码块加锁不要对付整个方法加锁。锁不包含耗时操作特别是IO、网络调用、数据库操作尽量不要放在锁内。能用读写锁的场景用ReentrantReadWriteLock或StampedLock读多写少时能大幅提升并发度。4.3 避免锁内嵌套请求外部资源导致的死锁死锁不止发生在锁与锁之间还容易发生在锁内等待外部资源时。场景是这样的线程A持有锁去调远程服务远程服务响应慢其他线程都在等这把锁爆炸的线程数会把线程池打满。表面上看是“服务不稳定”本质上就是锁内做了不该做的事。我踩过一次坑之后总结出的排查方法特别实用拿到线程栈找到waiting for monitor的线程再找到拥有monitor的线程看它卡在哪里。如果发现持有锁的线程卡在HTTP调用、数据库慢查询上那说明锁粒度设计有问题。需要把锁内代码尽量瘦身必要时可以用tryLock加超时限制避免无限等待。4.4 用Condition实现精确唤醒synchronized的wait/notify有个问题notify()是随机唤醒一个线程notifyAll()是唤醒所有线程。这些唤醒都是按“类”来组织的不够精细。而ReentrantLock的Condition可以创建多个等待条件精确控制唤醒方向。举一个典型的例子一个缓存容器写入线程往里面放数据读取线程从里面取数据。缓冲区满了写入线程要等待缓冲区空了读取线程要等待。用两个Condition管理代码会清晰很多ReentrantLock lock new ReentrantLock(); Condition notFull lock.newCondition(); Condition notEmpty lock.newCondition(); int capacity 10; int count 0; public void put(Object item) throws InterruptedException { lock.lock(); try { while (count capacity) { notFull.await(); // 缓冲区满写入线程等待 } // 放入数据... count; notEmpty.signal(); // 唤醒一个读取线程 } finally { lock.unlock(); } } public Object take() throws InterruptedException { lock.lock(); try { while (count 0) { notEmpty.await(); // 缓冲区空读取线程等待 } // 取出数据... count--; notFull.signal(); // 唤醒一个写入线程 } finally { lock.unlock(); } }这样写的好处是唤醒方明确知道自己唤醒的是什么类型的线程不会出现“写入线程把其他写入线程唤醒了结果还是满的继续睡”这种低效情况。4.5 自旋等待不能用可重入锁盲目替代有一类场景比如分布式锁的本地实现有人想用ReentrantLock来模拟“自旋”效果。这个思路其实不对。ReentrantLock在锁竞争失败时线程会被挂起进入等待队列而不是自旋。CPU时间片会被释放给其他线程。如果需要短时间内的自旋重试比如一些轻量级缓存更新场景更好的选择是SpinLock的短暂CAS循环或者使用StampedLock的乐观读模式。不是所有“加锁”需求都适合用可重入锁。判断依据很简单如果临界区很短比如只是更新一个变量的值用CAS自旋可能更快如果临界区较长比如要处理一批数据用ReentrantLock让线程挂起等待反而更能节省CPU。5. 可重入锁相关的JUC面试题高频考点5.1 “synchronized是可重入的吗底层怎么保证的”这是所有JUC面试里问得最多的基础题。标准答法是是synchronized是可重入的。底层的保证依赖于锁升级的不同阶段。偏向锁通过比较Mark Word中的线程ID判断是否重入轻量级锁通过新增Lock Record记录重入重量级锁通过ObjectMonitor的_recursions计数记录重入。加分回答可以补充偏向锁在低竞争场景下可以做到零CAS重入轻量级锁的重入不会修改对象头重量级锁的_recursions支持计数器累加这些设计目的是在不同竞争强度下尽可能减少无谓的同步操作。5.2 “ReentrantLock的可重入是怎么实现的”这个问题的核心在AQS的state字段。state记录锁的持有次数同一个线程再次加锁时不进入等待队列而是直接将state加1。可以用getHoldCount()方法验证当前线程重入了几次。回答时可以顺便讲一下源码nonfairTryAcquire(int acquires)方法中如果current getExclusiveOwnerThread()执行setState(c acquires)这就是可重入的实现。tryRelease中state减到0才释放锁。5.3 “公平锁和非公平锁选哪个”默认使用非公平锁。原因是非公平锁的性能通常更好因为它允许新线程直接抢占锁减少了线程阻塞和唤醒开销。如果对公平性有明确要求比如要保证线程按申请顺序获取锁才选择公平锁。公平锁的实现核心是hasQueuedPredecessors()它检查等待队列中是否有前置节点有则直接排队不参与CAS竞争。这个判断需要遍历队列所以公平锁在大量线程竞争时开销也更大。5.4 “tryLock和lock()的区别是什么”lock()获取不到锁时会一直阻塞等待而且不响应中断。tryLock()获取不到锁时会立即返回false不会阻塞。tryLock(long timeout, TimeUnit unit)则是在指定时间内等待超时仍未获取到锁则返回false。我一般建议在需要快速失败的场景中使用tryLock。比如两个锁的获取可以用tryLock获取第一个锁再tryLock获取第二个锁拿不到就释放第一个锁降低死锁概率。5.5 “lockInterruptibly()有什么作用”lockInterruptibly()允许线程在等待锁的过程中响应中断。如果等待的线程被interrupt()了它会抛出InterruptedException并停止等待。这个特性在实现可取消任务时非常有用比如任务取消后阻塞在锁上的线程也能快速退出。5.6 “synchronized和ReentrantLock到底怎么选”我的经验是需求能用synchronized搞定就用synchronized。代码写起来简单锁由JVM自动管理不容易出“忘了解锁”的问题。只有确实需要可中断、可超时、多个条件队列、公平性选择这些高级特性时才考虑ReentrantLock。JDK 1.6以后synchronized经过锁升级优化性能和ReentrantLock的差距已经不大。在简单互斥场景下synchronized反而维护成本更低。5.7 追问“AQS是什么CLH锁知道吗”AQS全称是AbstractQueuedSynchronizer它是一个提供给并发工具类使用的同步框架。state是核心状态CLH队列用于管理等待线程。CLH队列本质上是一个FIFO的双向链表每个节点代表一个想要获取锁的线程。这里的亮点是每个节点会不断检查前一个节点的状态前一个节点释放锁后后一个节点就会被唤醒。通过这种“前驱节点状态”的传递保证锁最终会被按顺序释放给等待线程。完整回答时最好能画出这个递进关系ReentrantLock→ 内部类Sync继承AQS→ AQS的state表示锁状态 → 线程竞争失败后进入CLH队列等待 → 前驱节点释放锁后唤醒后继节点。6. 写在最后的排查经验关于可重入锁最后想分享一个我在实际排查时的技巧如果遇到线程卡住的问题不要急着看业务代码先抓线程栈。jstack输出里ReentrantLock等待的线程名称特征很明显会带parking to wait for字样synchronized等待的线程会显示waiting to monitor。通过线程栈能快速分辨线程到底是在等锁、在等Condition、还是在执行代码过程中卡住了。另一个经验是业务里判断锁是否有问题可以先用jstack看看持锁线程在干什么。如果持锁线程已经跑到业务外部了说明锁没释放大概率是unlock没执行如果持锁线程卡在某个远程调用上说明锁内包含了耗时操作需要考虑锁粒度。这两个问题排查增加一点经验后续再遇到类似线上问题就会快很多。可重入锁本身并不复杂但把synchronized的锁升级、ReentrantLock的AQS实现、公平/非公平的取舍这些都串起来理解你对JUC并发控制的整体把握会上一个台阶。平时写代码时多留意锁的设计面试时也就有话可说了。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询