ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

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() 到 ConditionReentrantLock 的 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 公平锁与非公平锁构造器的那个 truenew ReentrantLock()默认是非公平锁new ReentrantLock(true)创建公平锁。我直接说结论非公平锁新来的线程在锁刚释放时可以“插队”抢到锁不一定要排到队尾。公平锁基本按照请求锁的顺序FIFO来先来先得。公平锁在底层会调用hasQueuedPredecessors()检查队列里有没有排在自己前面的线程有了就让位。面试高频问题“为什么非公平锁性能更好”有两点原因减少上下文切换。锁刚释放时如果让持有锁的线程立刻重新拿到锁并继续执行新线程的那段计算往往还在旧线程栈或缓存里直接继续跑比唤醒队列里的另一条线程成本低得多。避免唤醒开销。公平锁每次释放都要唤醒队尾或者队头的阻塞线程唤起一个 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. 深入 AQSReentrantLock 的底层引擎ReentrantLock 的代码本身不复杂真正的复杂度全在 AbstractQueuedSynchronizerAQS里。你搜“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 一次完整的加锁解锁旅程我用非公平锁举例把整个链路串起来线程 A 执行lock()调用 AQS 的acquire(1)。执行tryAcquire(1)此时 state0CAS 成功设置exclusiveOwnerThread A线程 A 进入临界区。线程 B 执行lock()此时 state1CAS 失败tryAcquire返回 false。B 被addWaiter()封装成 Node加入队列尾部随后acquireQueued()里把自己挂起LockSupport.park()。线程 A 执行unlock()调用release(1)执行tryRelease(1)state 从 1 减到 0清空exclusiveOwnerThread返回 true。release继续调用unparkSuccessor()唤醒队列头部的 Node也就是线程 B。线程 B 从 park 返回再次执行tryAcquireCAS 成功进入临界区。一条线下来你会发现 ReentrantLock 的全部魔法其实就是state CAS LockSupport 双向队列四样东西的组合。4. 面试高频拷问与工程决策到了这一节我把面试里针对 ReentrantLock 最常见的几个问题以及生产里我实际踩过的错误放在一起说。4.1 为什么 state 要设计成 int 而不是 boolean面试官喜欢从一个细节切入。前面说了int 能支持可重入计数这是直接原因。但更深一层是 AQS 本身的通用性CountDownLatch需要把 state 看成“剩余倒数次数”Semaphore需要把 state 看成“剩余许可证数”这些场景全部需要计数能力。如果用 booleanAQS 就做不成这些工具了。所以回答时不要只说“为了可重入”要把“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 SimpleBlockingQueueT { private final QueueT 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 基本不会给你惹麻烦。
返回列表