news 2026/10/1 18:39:13

Java并发编程:详解ReentrantLock与AQS底层原理及实战避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java并发编程:详解ReentrantLock与AQS底层原理及实战避坑

做 Java 并发这块,我见过太多“会用但说不清”的开发者。问 ReentrantLock 怎么加锁解锁,都能写出来,但一问“它跟 synchronized 到底差在哪”“AQS 是怎么让它排队的”,就卡壳了。这篇文章不打算给你背八股文,而是想把它当成一个完整的工具来拆解:什么时候该用、API 怎么用才算规范、底层 AQS 怎么工作、面试官到底在考什么,以及我实际写代码时踩过哪些坑。如果你正在准备 Java 面试,或者要在生产项目里做锁选型,这篇内容应该能直接拿去用。

1. 为什么有了 synchronized,还要用 ReentrantLock

先说个真实场景。几年前我接手一个订单处理服务,某个下游接口突然变慢,结果所有请求线程全部阻塞在 synchronized 上,线程池被打满,日志里看不到任何异常。手动 dump 线程的时候,一堆java.lang.Thread.State: BLOCKED (on object monitor)。关键是,我们根本没办法让这些线程“等一段时间就放弃”。优雅关闭的时候,线程中断信号发过去,synchronized 也不响应。最后只能重启服务才恢复。

这就是 synchronized 的天然短板:一个线程在等待锁的时候,既不能超时,也不能被中断。

1.1 synchronized 的三块天花板

synchronized 是 JVM 语言级的锁,简单可靠,但它有三个绕不过去的限制:

  • 等待锁不可中断。线程进入 BLOCKED 状态后,只有拿到锁才能继续,外部无法打断它。这在处理故障时非常被动。
  • 无法超时获取。锁被某线程长期持有时,其他线程只能无限等下去。没有“等 3 秒拿不到就算了”这种操作。
  • 一个锁只有一个等待集合。wait/notify只能在一个隐式的条件队列上操作。生产者-消费者场景里,你想分别唤醒“等待写入”的线程和“等待读取”的线程,用 synchronized 做不到细粒度控制。

此外,synchronized 的加锁和解锁由 JVM 字节码指令(monitorenter/monitorexit)管理,加锁和解锁必须发生在同一个代码块结构内。ReentrantLock 本质上就是一个带状态的 Java 类,lock()和unlock()可以在不同方法里调用。这一点带来了灵活性,但也是双刃剑,后面我会专门说它带来的坑。

1.2 两把锁的能力对比

我把两个锁的关键能力拉了一张表,方便你快速对比:

能力synchronizedReentrantLock
等待锁时响应中断不支持lockInterruptibly()支持
超时获取锁不支持tryLock(3, TimeUnit.SECONDS)支持
公平性非公平默认非公平,可指定公平
条件变量只有一个 wait/notify 集合newCondition()可创建多个
加锁/解锁位置必须同一代码块方法级任意位置(不推荐,但允许)
锁状态监控不支持isHeldByCurrentThread()、getQueueLength()等
可重入支持支持,且可以查询持有次数

面试里问到“为什么有了 synchronized 还要 ReentrantLock”,本质上就是让你说出后面那几行能力,而不是背个定义就完事。

1.3 非块结构锁:灵活是把双刃剑

synchronized的语义很像“自动门禁”,进去、出来都由系统管着,永远不会忘记关门。而ReentrantLock更像“手动门禁”,进门刷卡、出门刷卡,漏一次就会出大问题。

跨方法加解锁在写框架底层时偶尔会用到,但普通业务代码我强烈建议保持“同一层方法内 lock / unlock”,最好再加 try/finally。否则一个分支漏掉 unlock,锁就永远不释放,比 synchronized 定死在同一代码块还要难排查。记住一个原则:用 ReentrantLock 的前提是你能管住自己每一次 unlock。

2. 核心 API 与生产级写法:从 lock() 到 Condition

ReentrantLock 的 API 不多,但每个方法背后都有明确的使用场景。我按“从最常用到最进阶”的顺序逐个拆。

2.1 lock() / unlock() 标准模板:lock 必须在 try 外面

最基础的用法是三件套:

Lock lock = new ReentrantLock(); lock.lock(); try { // 临界区:只有这里需要互斥 } finally { lock.unlock(); }

注意lock()必须放在try之前。新手最容易写错的是下面这样:

try { lock.lock(); // 业务 } finally { lock.unlock(); }

这写法有隐患:一旦lock()之前或本身抛出异常(比如之后用lockInterruptibly()被中断),线程根本没有持有锁,却会执行finally里的unlock(),直接抛出IllegalMonitorStateException。所以标准模板就是:lock 在外,try 在里,finally 永远 unlock。

另外,加锁后临界区要尽量短。锁里只放必须互斥的代码,别把整个方法都包进去。我见过有人把远程调用、数据库查询都放进临界区,那等于直接把并发性能做成串行,还容易因为网络超时把锁持有几分钟。

2.2 lockInterruptibly():可中断等待锁

lockInterruptibly()与lock()的唯一区别:线程在等待锁的过程中,如果收到中断信号,会抛出InterruptedException,从而退出等待。

典型使用场景:线程池 shutdown 时,想中断那些正在等锁的任务。如果线程还在lock()上傻等,中断信号对它没有效果,任务就退不掉;换成可中断等待,才能保证线程池优雅关闭。

try { lock.lockInterruptibly(); try { // 业务 } finally { lock.unlock(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 响应中断后,重新设置中断标记 // 做降级或清理 }

这里有个小细节:捕获InterruptedException后,一定要重新Thread.currentThread().interrupt()设置中断标志,不然上层调用方感知不到这次中断。很多代码踩过这个坑,表面的响应中断做了,实际把中断状态“吃掉了”。

2.3 tryLock():抢锁与超时放弃

tryLock()是 ReentrantLock 比 synchronized 最实用的能力之一。它有两种形式:

  • tryLock():尝试抢锁,拿到返回 true,拿不到立即返回 false,不阻塞。
  • tryLock(long timeout, TimeUnit unit):等最多 timeout 时间,期间可响应中断。

安全的用法是先判断返回值,再进临界区:

if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 拿到锁,处理业务 } finally { lock.unlock(); } } else { // 超时没拿到锁,走降级逻辑 }

这个能力对破坏死锁特别有用。当代码里存在多个锁嵌套时,两个线程各自持有一把锁,又在等对方释放,就形成了循环等待,这是死锁的经典条件之一。如果所有人约定“拿第二把锁用 tryLock,超过 2 秒就释放第一把锁”,循环等待就能被打破,死锁也就不存在了。

2.4 公平锁与非公平锁:构造器的那个 true

new ReentrantLock()默认是非公平锁;new ReentrantLock(true)创建公平锁。

我直接说结论:

  • 非公平锁:新来的线程在锁刚释放时,可以“插队”抢到锁,不一定要排到队尾。
  • 公平锁:基本按照请求锁的顺序(FIFO)来,先来先得。
  • 公平锁在底层会调用hasQueuedPredecessors()检查队列里有没有排在自己前面的线程,有了就让位。

面试高频问题“为什么非公平锁性能更好”,有两点原因:

  1. 减少上下文切换。锁刚释放时,如果让持有锁的线程立刻重新拿到锁并继续执行,新线程的那段计算往往还在旧线程栈或缓存里,直接继续跑比唤醒队列里的另一条线程成本低得多。
  2. 避免唤醒开销。公平锁每次释放都要唤醒队尾或者队头的阻塞线程,唤起一个 BLOCKED 线程需要操作系统介入,耗时远大于一次 CAS。

但非公平锁的代价是,极端情况下队尾线程可能长时间吃不到锁,也就是“饥饿”。实际生产里,这种饥饿概率很低,所以默认大家都用非公平锁。只有业务上对处理顺序敏感,比如交易撮合、任务分配严格按提交顺序来执行时,才考虑公平锁。

2.5 newCondition():一锁多条件

这是 ReentrantLock 区别于 synchronized 的另一个大杀器。synchronized一个锁只有一个隐式条件队列,所以wait/notify唤醒时没法区分“因为队列满而等待的生产者”和“因为队列空而等待的消费者”。

newCondition()可以创建任意多个条件队列,每个 Condition 都有自己的await()和signal():

Lock lock = new ReentrantLock(); Condition notEmpty = lock.newCondition(); // 队列非空条件 Condition notFull = lock.newCondition(); // 队列未满条件

生产者等待notFull,消费者等待notEmpty。队列有位置时,只signal()唤醒生产者;队列有元素时,只signal()唤醒消费者。精准唤醒,不会像notifyAll那样把不相关的线程全部叫起来抢一遍锁。

3. 深入 AQS:ReentrantLock 的底层引擎

ReentrantLock 的代码本身不复杂,真正的复杂度全在 AbstractQueuedSynchronizer(AQS)里。你搜“aqs java”,网上分析很多,但核心其实就三件事:状态、队列、阻塞唤醒。

3.1 AQS 是什么:一套被复用无数次的同步器骨架

AQS 不是一个锁,而是一个用来构建锁和同步器的抽象框架。ReentrantLock 的同步逻辑,CountDownLatch 的计数逻辑,Semaphore 的信号量逻辑,底层全是 AQS。

它的设计思路很简单:把“占用/释放”这个动作抽象成修改一个 int 状态,把不能立刻获取资源的线程放进一个队列里排队等待,等占用者释放时再按规则唤醒来。

3.2 state:那个决定谁有资格执行关键代码的数字

AQS 内部有一个volatile int state。对 ReentrantLock 来说,这个就是锁的持有次数:

  • state == 0:锁没有人持有。
  • state == 1:某个线程持有锁一次。
  • state > 1:持有锁的线程又多次重入,每重入一次加一。

为什么要用int而不是布尔值?两个原因。一是可重入计数需要支持累加;二是 AQS 要被共享模式使用(比如信号量、CountDownLatch),它们需要计数语义。volatile保证这个状态在多个线程之间的可见性,配合 CAS 操作来实现原子修改。

线程拿锁的核心动作就是:CAS 把 state 从 0 改成 1,成功就说明抢到了,之后把“当前持有锁的线程”记成自己。就这么简单。

3.3 CLH 变体队列:线程怎么排队

抢锁失败的线程不会直接死循环,而是被封装成一个Node节点,进入一个 FIFO 双向链表,然后调用LockSupport.park()挂起自己。这个队列是 CLH 锁的一个变体,在 AQS 里是内部类Node组成的双向队列。

每个 Node 有四个关键信息:

  • thread:排队的线程对象。
  • prev/next:前驱和后继节点。
  • waitStatus:当前节点的等待状态,常见的有:
    • CANCELLED = 1:线程被取消,不再等待。
    • SIGNAL = -1:当前节点的后继线程需要被唤醒。
    • CONDITION = -2:线程在条件队列中等待。

整个流程可以这样理解:线程 A 抢锁失败,被放进队列尾部并挂起;线程 B 释放锁时,会找到队列里第一个有效的节点,调用LockSupport.unpark()唤醒它。被唤醒的线程醒来后再次尝试抢锁,成功则退出队列,失败则继续挂起。

这里最妙的设计是把“排队”和“阻塞唤醒”组合在一起:不需要用户态自旋消耗 CPU,也不需要每次都做全系统调用,平均性能非常均衡。

3.4 公平与非公平:就差了一个 hasQueuedPredecessors

公平锁和非公平锁在 AQS 里的实现差异小到惊人。

非公平锁的tryAcquire核心逻辑:

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; setState(nextc); return true; } return false; }

公平锁在此基础上多了一行判断:

if (c == 0) { // 如果队列里有排在自己前面的线程,就放弃抢锁 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } }

hasQueuedPredecessors(),翻译成人话就是:我的前面还有没有其他线程在排队?有就让,没有才抢。所以公平锁不是复杂,而是多了一个“检查队首”的动作。这个动作本身也有开销,这就是公平锁吞吐更低的直接原因。

3.5 一次完整的加锁解锁旅程

我用非公平锁举例,把整个链路串起来:

  1. 线程 A 执行lock(),调用 AQS 的acquire(1)。
  2. 执行tryAcquire(1),此时 state=0,CAS 成功,设置exclusiveOwnerThread = A,线程 A 进入临界区。
  3. 线程 B 执行lock(),此时 state=1,CAS 失败,tryAcquire返回 false。
  4. B 被addWaiter()封装成 Node,加入队列尾部,随后acquireQueued()里把自己挂起(LockSupport.park())。
  5. 线程 A 执行unlock(),调用release(1),执行tryRelease(1):state 从 1 减到 0,清空exclusiveOwnerThread,返回 true。
  6. release继续调用unparkSuccessor(),唤醒队列头部的 Node,也就是线程 B。
  7. 线程 B 从 park 返回,再次执行tryAcquire,CAS 成功,进入临界区。

一条线下来,你会发现 ReentrantLock 的全部魔法其实就是state + CAS + LockSupport + 双向队列四样东西的组合。

4. 面试高频拷问与工程决策

到了这一节,我把面试里针对 ReentrantLock 最常见的几个问题,以及生产里我实际踩过的错误放在一起说。

4.1 为什么 state 要设计成 int 而不是 boolean

面试官喜欢从一个细节切入。前面说了,int 能支持可重入计数,这是直接原因。但更深一层是 AQS 本身的通用性:CountDownLatch需要把 state 看成“剩余倒数次数”,Semaphore需要把 state 看成“剩余许可证数”,这些场景全部需要计数能力。如果用 boolean,AQS 就做不成这些工具了。

所以回答时不要只说“为了可重入”,要把“AQS 作为通用同步器框架,需要支持计数语义”这个维度补上,面试官会觉得你理解了设计目的。

4.2 非公平锁为什么性能反而更好

前面第 2 节提过,这里再往深补一层。非公平锁的代码路径最短:新线程到达时,如果锁正好空闲,直接 CAS 改 state 结束,不需要操作队列,不涉及唤醒线程。而公平锁必须经历“检查队列→入队→park→unpark→再次抢锁”的完整流程,线程挂起和唤醒都是重量级操作。

这也是为什么 Java 官方把 ReentrantLock 的默认实现做成非公平的——与 synchronized 的 monitor 行为保持一致,也符合大多数并发场景的性能预期。公平是需求,非公平是默认。

4.3 synchronized 与 ReentrantLock:现在到底怎么选

到 JDK 8 之后,synchronized 经过锁升级(偏向锁、轻量级锁、重量级锁)优化,简单互斥场景下性能已经和 ReentrantLock 没有明显差距,而且代码更简洁、不会忘记解锁。所以我的选型清单是:

  • 默认优先 synchronized:没有特殊需求,直接用,简洁且安全。
  • 必须用 ReentrantLock 的场景:需要超时获取锁、需要等待锁可中断、需要多个条件队列、需要公平锁、需要锁状态监控。
  • 读多写少场景:考虑ReentrantReadWriteLock或StampedLock,读读不互斥能明显提高吞吐。
  • 能不用锁就不用锁:优先考虑原子类、并发集合、不可变对象设计。

你可以把 synchronized 想象成公交车,到站就停,谁都能上;ReentrantLock 更像打车,能指定路线(公平性、超时、中断),但需要你自己记着付钱(unlock)。

4.4 三个典型的 Lock 生产错误

第一类:把 lock() 放进 try。前面讲过,这在lockInterruptibly()时会直接引发IllegalMonitorStateException。

第二类:在 Condition 上面用 if 而不是 while。await()可能发生虚假唤醒,也可能被多个线程同时唤醒,条件可能再次不满足。正确姿势永远是:

while (条件不满足) { condition.await(); }

第三类:忘记在提前 return 时 unlock。比如临界区里写了多个分支,某个分支直接return,如果没统一在 finally 里 unlock,锁就泄漏了。锁泄漏比线程泄漏更难发现,因为代码还能跑,只是性能越来越差、最终线程池耗尽。所以必须无条件使用 try/finally,不要相信自己的记忆力。

5. 实战:手写一个双条件阻塞队列

最后用一个完整案例收尾:基于 ReentrantLock 的双 Condition 实现一个简单的阻塞队列。这个例子几乎覆盖了前面所有知识点,面试里也经常让你手写。

5.1 需求与设计思路

阻塞队列需要两个动作:

  • put():如果队列已满,生产者阻塞等待,直到有空位。
  • take():如果队列为空,消费者阻塞等待,直到有元素出现。

如果用 synchronized +wait/notifyAll,每次唤醒会把所有等待线程都叫起来,然后各自检查条件,存在大量无效竞争。用两个 Condition 就可以做到精准唤醒:队列满时生产者 await,队列空时消费者 await;写入成功后唤醒“队列非空”条件上的消费者,取出元素后唤醒“队列未满”条件上的生产者。

5.2 代码实现与逐段解读

import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class SimpleBlockingQueue<T> { private final Queue<T> items = new LinkedList<>(); private final int capacity; private final Lock lock = new ReentrantLock(); private final Condition notEmpty = lock.newCondition(); private final Condition notFull = lock.newCondition(); public SimpleBlockingQueue(int capacity) { this.capacity = capacity; } public void put(T item) throws InterruptedException { lock.lockInterruptibly(); try { while (items.size() >= capacity) { notFull.await(); // 队列满了,生产者挂起 } items.add(item); notEmpty.signal(); // 通知消费者:有东西可以取了 } finally { lock.unlock(); } } public T take() throws InterruptedException { lock.lockInterruptibly(); try { while (items.isEmpty()) { notEmpty.await(); // 队列空了,消费者挂起 } T item = items.poll(); notFull.signal(); // 通知生产者:有位置可以放了 return item; } finally { lock.unlock(); } } }

代码核心点:

  • lockInterruptibly()放在 try 外,然后 finally 里无条件 unlock。这是标准姿势。
  • 两个 Condition 分别服务生产者和消费者的等待,信号互不混淆。
  • 条件判断用while,防止虚假唤醒,也防止多消费者同时被唤醒后“抢到空队列”。
  • signal()是精准唤醒一个线程。多个生产者或消费者时建议用signalAll(),否则可能出现“我只唤醒了一个生产者,但它又因为条件不满足继续等,导致所有生产者都睡着”的情况,这叫信号丢失。

5.3 这个案例说明了什么

对比一下 synchronized 版本,你会发现 ReentrantLock 的两个核心价值在这段代码里全部体现了:

  • 多条件变量:一个锁配两个 Condition,实现生产者和消费者互不干扰的精准唤醒。
  • 可中断等待:await()期间收到中断会抛出异常,线程可以退出阻塞;换成wait()虽然也能响应中断,但条件控制和信号精度差一截。

如果只是把代码跑通,这个例子很简单。但真正把它想透,你对 AQS、Condition 和锁规范的理解就已经达到生产级了。

最后说点个人经验。我给团队定过一个规矩:默认写 synchronized,只有遇到超时获取、可中断等待、多条件队列、公平锁、锁监控这五类需求才换成 ReentrantLock。换的时候,代码评审上重点盯三件事:lock 是否在 try 外、finally 是否无条件 unlock、Condition 是否用了 while 循环判断。守住这三条,Lock 基本不会给你惹麻烦。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 18:38:13

Tecplot论文配图完全指南:从数据准备到投稿级导出全流程

1. 先说结论&#xff1a;一张投稿级配图&#xff0c;卡点往往不在软件而在流程前阵子帮师弟改论文配图&#xff0c;他拿着Tecplot导出的PNG直接贴进Word&#xff0c;结果被导师批了一句"这图投出去会被编辑直接退回来"。问题不在数据&#xff0c;也不在他不会操作Tec…

作者头像 李华
网站建设 2026/10/1 18:38:10

YOLO网球目标检测数据集实战:从标注格式到训练避坑指南

简介&#xff1a;面向YOLO系列目标检测实践者的网球与球员检测数据集&#xff0c;包含551张已标注的JPG图像&#xff0c;可支撑目标检测模型从训练到验证的完整流程。数据集围绕网球、球员两类目标构建&#xff0c;专门用于解决体育场景下小目标遮挡、动态捕捉等标注难题&#…

作者头像 李华
网站建设 2026/10/1 18:37:29

mongoose搭建mqtt客户端:从Makefile到CC编译的完整实践与TaoToken配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 18:37:27

C语言二分查找:从PTA错题到嵌入式工业级实现

1. 为什么二分查找值得花十分钟彻底搞懂 你是不是也遇到过这样的场景&#xff1a;在PTA刷题时看到“二分查找”四个字&#xff0c;心里一松——这不就是个基础算法嘛&#xff1b;结果提交后报错“段错误”或“答案错误”&#xff0c;调试半小时才发现是边界条件写反了&#xff…

作者头像 李华
网站建设 2026/10/1 18:37:07

BqLog压缩日志执行路径优化:环形缓冲与异步压缩实战

1. 从“日志也要追求极致”说起先问一个问题&#xff1a;你做游戏客户端开发多久了&#xff1f;有没有被日志拖过后腿&#xff1f;其实很多团队都栽过这个跟头&#xff1a;线上局内出问题&#xff0c;需要日志定位&#xff0c;结果发现日志系统本身因为频繁格式化、锁竞争、IO写…

作者头像 李华