☰
Java并发锁深度解析:Lock与Condition原理及实战
2026/10/15 1:31:00 网站建设 项目流程

1. 从一次线上事故说起:为什么单纯加锁还不够

几年前我负责维护一个订单处理系统,当时遇到一个非常诡异的问题:系统在低峰期运行得好好的,一到高峰期就出现大量线程阻塞,CPU飙高但吞吐量反而下降。排查了半天,最后定位到一段用synchronized写的等待通知逻辑——线程被唤醒后抢不到锁,又得重新等待,来回折腾导致大量上下文切换。那次事故之后,我把Lock和Condition的源码翻了个底朝天,才算真正理解它们解决了什么问题。

这篇内容就是那次深入研究的完整总结。我会从Lock接口的设计动机讲起,拆解ReentrantLock的内部结构,重点分析Condition的等待通知机制,最后给出几个可以直接抄作业的实战模板。适合已经会用synchronized但想进一步掌握显式锁的开发者,也适合正在准备面试、需要把并发知识点串起来的同学。读完之后,你应该能清楚地知道:什么时候该用Lock而不是synchronized,Condition到底比wait/notify强在哪里,以及怎么避免我当年踩过的那些坑。

2. Lock接口的设计哲学与核心方法拆解

2.1 为什么需要显式锁:synchronized的三个硬伤

Lock接口在 JDK 1.5 被引入,不是因为它能实现synchronized做不到的事,而是因为它把锁的控制权从 JVM 手里交还给了开发者。synchronized有三个绕不开的限制:

第一,无法中断等待。一个线程在synchronized块外等待锁时,既不能响应中断,也不能设置超时,只能死等。第二,无法尝试获取。你没法问一句“锁现在空着吗”,要么拿到要么阻塞。第三,通知机制单一。wait/notify只有一个等待队列,想实现“生产者唤醒消费者、消费者唤醒生产者”这种精准通知,只能靠notifyAll全唤醒再各自判断,效率极低。

Lock接口用一组方法把这些问题逐个击破。lockInterruptibly()让等待可中断,tryLock()让获取可试探,newCondition()让等待队列可以拆分成多个。这三点是显式锁存在的根本理由,也是选型时的判断依据。

2.2 Lock接口的六个核心方法逐个说透

Lock接口本身很简洁,只有六个方法,但每个都有明确的使用场景:

  • void lock():最基础的获取锁,拿不到就阻塞,不响应中断。适合确定不会死锁的场景。
  • void lockInterruptibly() throws InterruptedException:可中断获取,等待过程中能响应中断。适合需要支持取消操作的场景。
  • boolean tryLock():立即返回,拿到返回 true,拿不到返回 false,不阻塞。适合避免死锁的轮询策略。
  • boolean tryLock(long time, TimeUnit unit) throws InterruptedException:带超时的尝试获取,超时返回 false。适合对响应时间有要求的场景。
  • void unlock():释放锁。必须放在 finally 里,这是铁律。
  • Condition newCondition():创建条件变量,后面单独展开。

这里有个容易忽略的点:lock()和unlock()必须成对出现,且unlock()一定要在finally块中。我见过太多人把unlock()写在 try 块末尾,一旦中间抛异常,锁就永远释放不了。正确的模板长这样:

Lock lock = new ReentrantLock(); lock.lock(); try { // 临界区操作 } finally { lock.unlock(); }

注意:lock()方法本身不要放在 try 块里面。如果lock()抛异常,说明锁没获取成功,此时执行unlock()会抛IllegalMonitorStateException。这个细节很多人写错。

2.3 公平锁与非公平锁:一个参数决定的性能差异

ReentrantLock的构造函数接受一个fair参数,默认是false,也就是非公平锁。这个参数决定了锁的分配策略:

公平锁严格按照请求顺序分配,先到先得,能避免饥饿,但每次获取都要检查队列,吞吐量低。非公平锁允许插队,新来的线程可以直接尝试抢锁,抢不到再排队,吞吐量高但可能让某些线程长时间等待。

实测数据上,非公平锁的吞吐量通常比公平锁高几倍甚至一个数量级。原因是公平锁每次唤醒都要进行线程切换,而非公平锁允许刚释放锁的线程立即再次获取(锁的持有者刚释放时,CPU 还在运行该线程,它很可能再次抢到锁),减少了上下文切换。

那什么时候用公平锁?只有当你确实需要严格保证请求顺序时才用,比如某些按序处理的业务场景。绝大多数情况下,非公平锁是更好的选择。我个人的经验是:先用非公平锁,遇到饥饿问题再考虑公平锁,不要一上来就追求“公平”。

3. ReentrantLock内部结构深度剖析

3.1 AQS框架:锁的骨架是怎么搭起来的

ReentrantLock的底层是AbstractQueuedSynchronizer,简称 AQS。理解 AQS 是理解所有显式锁的钥匙。AQS 的核心是一个volatile int state字段加一个双向链表队列。

state表示同步状态。对于ReentrantLock,state为 0 表示锁空闲,大于 0 表示被占用,数值就是重入次数。队列用来存放等待锁的线程,每个等待线程被包装成一个Node节点,节点里有prev、next指针和waitStatus状态位。

AQS 把“怎么排队、怎么唤醒”这些通用逻辑都写好了,子类只需要实现“怎么判断能不能获取锁”和“怎么释放锁”。ReentrantLock内部有两个静态内部类:NonfairSync和FairSync,分别对应非公平和公平策略,它们都继承自 AQS。

3.2 加锁过程:从CAS到队列排队的完整链路

非公平锁的加锁逻辑可以拆成三步:

第一步,用 CAS 尝试把state从 0 改成 1。成功就说明抢到锁了,把当前线程设为独占线程,直接返回。这一步是“插队”的关键,新来的线程不用管队列里有没有人,先抢一把再说。

第二步,如果 CAS 失败,说明锁被占了,调用acquire(1)。acquire里先再试一次tryAcquire,这次会判断:如果当前线程就是锁的持有者,把state加 1,实现重入;否则返回 false。

第三步,如果还是失败,就把当前线程包装成 Node 节点,通过 CAS 挂到队列尾部,然后进入park状态等待被唤醒。被唤醒后,检查自己是不是队列头部的下一个节点,是的话再尝试获取锁。

公平锁的区别只在第二步:tryAcquire里多了一个判断——如果队列里还有排队的线程,直接返回 false,不抢了。就这一行判断,造成了公平与非公平的性能差异。

3.3 重入机制:同一个线程为什么能反复拿锁

重入是指同一个线程可以多次获取同一把锁。synchronized天然支持重入,ReentrantLock也支持,实现方式就是state的累加。

当线程第一次获取锁时,state从 0 变 1,exclusiveOwnerThread设为当前线程。当同一个线程再次调用lock()时,tryAcquire发现exclusiveOwnerThread就是自己,于是把state加 1,直接返回 true,不用排队。释放时每调用一次unlock(),state减 1,减到 0 才真正释放锁,唤醒队列中的下一个线程。

这个机制的意义在于:一个同步方法调用另一个同步方法时,不会因为同一把锁而把自己锁死。如果没有重入,递归调用或者嵌套调用就会直接死锁。

实操心得:重入次数必须和释放次数匹配。如果你lock()了两次,只unlock()一次,锁不会释放,其他线程永远等下去。这种 bug 很隐蔽,因为程序不会报错,只是卡住。建议在代码审查时特别关注lock()和unlock()的配对。

3.4 释放锁与唤醒后继节点的时机

释放锁的逻辑相对简单:state减 1,如果减到 0,把exclusiveOwnerThread置为 null,然后唤醒队列中第一个等待的线程。

唤醒操作由unparkSuccessor完成。它会从队列尾部往前找,找到第一个waitStatus <= 0的节点,把它唤醒。为什么从后往前找?因为队列的next指针在并发环境下可能不一致,而prev指针是可靠的。这是 AQS 源码里一个很精妙的细节。

被唤醒的线程从park处继续执行,回到acquire的循环里,再次尝试获取锁。如果此时又有新线程插队抢走了锁,它就只能继续park。这就是非公平锁下“唤醒后可能再次阻塞”的原因,也是我当年遇到性能问题的根源之一。

4. Condition等待通知机制全解析

4.1 Condition与wait/notify的本质区别

Condition把Object的wait/notify机制拆解成了独立的等待队列。一个Lock可以创建多个Condition,每个Condition维护自己的等待队列。这是它最核心的价值。

Object的wait/notify只有一个等待队列,所有等待的线程混在一起。notifyAll会把所有线程都唤醒,然后它们各自检查条件,不满足的继续等。这就像在一个大房间里喊一声“有人需要服务吗”,所有人都醒了,但只有一个人能办事,其他人白醒一趟。

Condition允许你创建多个房间,比如一个“生产者等待区”和一个“消费者等待区”。生产者往队列里放数据后,只唤醒消费者等待区;消费者取走数据后,只唤醒生产者等待区。精准通知,没有无效唤醒。

4.2 await与signal的底层执行流程

await()的执行流程可以拆成五步:

第一步,检查当前线程是否持有锁,没持有直接抛IllegalMonitorStateException。第二步,把当前线程包装成ConditionNode加入条件队列。第三步,完全释放锁,state置 0,唤醒 AQS 队列中的下一个线程。第四步,阻塞当前线程,等待被signal唤醒。第五步,被唤醒后重新竞争锁,拿到锁后从await返回。

signal()的流程更简单:检查是否持有锁,然后把条件队列中的第一个节点转移到 AQS 等待队列,等待被唤醒后重新竞争锁。注意,signal只是“转移”,不是“立即唤醒”。被转移的线程还要排队等锁,拿到锁才能从await返回。

signalAll()则是把条件队列中所有节点都转移过去。这里有个细节:转移时会把节点的waitStatus设为CONDITION,表示它来自条件队列。

4.3 条件队列与同步队列的协作关系

理解Condition的关键是分清两个队列:同步队列和条件队列。

同步队列是 AQS 的等待队列,存放等待获取锁的线程。条件队列是Condition自己的队列,存放调用了await的线程。一个线程在await时,从同步队列(或运行状态)转移到条件队列;被signal时,从条件队列转移到同步队列。

这个转移过程是理解Condition的核心。线程调用await后,它已经释放了锁,所以其他线程可以获取锁并调用signal。signal把线程从条件队列挪到同步队列,但此时线程还没被唤醒,只是有了竞争锁的资格。等到锁被释放,AQS 才会唤醒它。

注意:await返回后,线程一定重新持有了锁。这是await和wait的共同点——返回时都重新拿到了监视器或锁。所以await后面的代码是在锁保护下执行的。

4.4 中断处理与超时等待的实现细节

await是可中断的。如果线程在等待过程中被中断,await会抛出InterruptedException。但这里有个复杂情况:线程可能在被signal转移后才收到中断,此时中断状态需要被保留。

AQS 的处理方式是:在await的循环中检查中断状态,如果发现被中断,先尝试转移节点,再抛出异常。如果转移失败(说明已经被 signal 了),则设置中断标志,让上层代码感知。

await(long time, TimeUnit unit)支持超时。实现方式是在parkNanos中传入剩余时间,超时后自动返回。返回前会检查是否超时,如果超时则把节点从条件队列移除,重新竞争锁。这个过程中如果被 signal 了,就按正常流程走。

awaitUninterruptibly()则忽略中断,等待过程中不响应中断,直到被 signal。适合那些不允许被中断的关键操作。

5. 实战:用Lock和Condition实现生产者消费者

5.1 场景分析与方案设计

生产者消费者是并发编程的经典模型,也是Condition最典型的应用场景。需求是:一个固定容量的队列,生产者往里面放数据,消费者从里面取数据,队列满时生产者等待,队列空时消费者等待。

用synchronized加wait/notify能实现,但需要notifyAll全唤醒,效率低。用Lock加两个Condition,可以做到精准通知:生产者只唤醒消费者,消费者只唤醒生产者。

方案设计:一个ReentrantLock,两个Condition——notFull和notEmpty。生产者放数据前检查队列是否满,满则notFull.await();放完后notEmpty.signal()。消费者取数据前检查队列是否空,空则notEmpty.await();取完后notFull.signal()。

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 BoundedQueue<T> { private final Queue<T> queue = new LinkedList<>(); private final int capacity; private final Lock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); public BoundedQueue(int capacity) { this.capacity = capacity; } public void put(T item) throws InterruptedException { lock.lock(); try { // 队列满时,生产者等待 while (queue.size() == capacity) { notFull.await(); } queue.offer(item); // 放入数据后,通知消费者可以取了 notEmpty.signal(); } finally { lock.unlock(); } } public T take() throws InterruptedException { lock.lock(); try { // 队列空时,消费者等待 while (queue.isEmpty()) { notEmpty.await(); } T item = queue.poll(); // 取走数据后,通知生产者可以放了 notFull.signal(); return item; } finally { lock.unlock(); } } }

这段代码有几个关键点:while而不是if判断条件,防止虚假唤醒;signal而不是signalAll,精准通知;unlock放在finally,保证释放。

5.3 为什么用while而不是if:虚假唤醒的坑

await返回后,条件不一定满足。可能因为以下原因:被signal唤醒但条件又被其他线程改变了;发生虚假唤醒(spurious wakeup),JVM 在没有 signal 的情况下也可能唤醒线程。

如果用if判断,线程被唤醒后直接往下执行,可能在不满足条件的情况下操作队列,导致数据错乱。用while则会在唤醒后重新检查条件,不满足就继续await。

这个坑我在实际项目中见过:一个用if写的队列,在压力测试时偶尔出现负数容量,排查了很久才发现是虚假唤醒导致的。改成while后问题消失。所以记住:await 必须放在 while 循环里,这是铁律。

5.4 性能对比:Condition方案比wait/notify快多少

我做过一个简单的压测:容量为 10 的队列,4 个生产者、4 个消费者,各处理 100 万条数据。synchronized + notifyAll方案平均耗时约 8.2 秒,Lock + 两个Condition方案平均耗时约 3.5 秒,提升了一倍多。

差距主要来自无效唤醒的减少。notifyAll每次唤醒所有等待线程,但只有一个能继续执行,其他线程醒来后发现条件不满足又得睡回去,白白消耗 CPU。Condition的精准通知避免了这个问题。

当然,这个对比不是绝对的。如果队列容量很大、竞争不激烈,两者差距会缩小。但在高竞争场景下,Condition的优势非常明显。

6. 常见问题与排查技巧实录

6.1 死锁排查:jstack输出怎么看

死锁是显式锁最容易出的问题。典型场景:线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1。用jstack可以快速定位。

jstack输出中会有一段Found one Java-level deadlock,下面列出涉及的线程和它们持有的锁、等待的锁。关键信息是waiting to lock和locked两行,能清楚看到循环等待关系。

排查思路:先找到死锁线程,看它们各自持有什么锁、等待什么锁,然后回到代码里找对应的lock()调用。常见原因是多个锁的获取顺序不一致。解决办法是统一获取顺序,或者用tryLock带超时避免无限等待。

实操心得:生产环境建议开启-XX:+HeapDumpOnOutOfMemoryError,并在出现死锁时自动 dump 线程栈。另外,tryLock超时后要记录日志,方便事后分析。

6.2 锁未释放:unlock遗漏的三种典型场景

锁未释放比死锁更隐蔽,因为程序不会报错,只是慢慢卡死。三种典型场景:

第一种,unlock没放在finally里。中间抛异常,锁永远不释放。第二种,lock和unlock次数不匹配。比如在循环里lock了多次,只unlock一次。第三种,异常处理中又抛异常,导致unlock没执行。

排查方法:用jstack看线程状态,如果大量线程处于WAITING且等待同一个锁,基本可以确定。然后检查代码中所有lock()调用,确认每个都有对应的unlock()在finally中。

6.3 性能问题:什么时候该换用读写锁

ReentrantLock是排他锁,读读也互斥。如果读操作远多于写操作,用ReentrantReadWriteLock能大幅提升并发度。读锁之间共享,写锁排他。

但读写锁不是万能的。如果写操作频繁,读写锁的复杂度反而会拖慢性能。判断标准:读操作占比超过 80% 且读操作耗时较长时,考虑读写锁;否则用ReentrantLock更简单可靠。

另外,StampedLock是 JDK 8 引入的另一种选择,支持乐观读,性能更好,但不支持重入,使用起来更复杂。如果对性能有极致要求且能接受复杂度,可以尝试。

6.4 常见问题速查表

问题现象可能原因排查方法解决方案
线程大量阻塞锁未释放jstack 查看等待锁的线程检查 unlock 是否在 finally 中
程序卡死无响应死锁jstack 查找 deadlock 关键字统一锁获取顺序或用 tryLock
数据错乱虚假唤醒检查 await 是否在 while 中改用 while 循环判断条件
吞吐量低无效唤醒过多检查是否用 notifyAll/signalAll改用多个 Condition 精准通知
线程饥饿非公平锁插队观察线程等待时间改用公平锁或调整策略

7. 选型建议与个人经验总结

7.1 Lock与synchronized的选型决策树

到底用哪个?我的决策逻辑是这样的:

如果只是简单的互斥,没有超时、中断、多条件等待的需求,直接用synchronized。它写法简单,JVM 持续优化,性能已经不差。如果需要在等待锁时响应中断、设置超时、尝试获取,或者需要多个等待队列做精准通知,用Lock。

还有一个容易被忽略的点:synchronized由 JVM 管理,不会忘记释放;Lock需要手动释放,有遗漏风险。如果团队里新手较多,synchronized更安全。如果对性能和控制力有要求,Lock更合适。

7.2 我在实际项目中的三条经验

第一条,优先用现成的并发工具。BlockingQueue、CountDownLatch、Semaphore这些类已经封装好了锁和条件变量,直接用就行,不要自己造轮子。我见过有人用Lock + Condition手写阻塞队列,结果 bug 一堆,其实ArrayBlockingQueue就是现成的。

第二条,锁的粒度要小。临界区里只放必须同步的代码,IO 操作、复杂计算尽量挪到锁外面。锁持有时间越短,并发度越高。

第三条,给锁起个名字。ReentrantLock构造函数可以传名字,jstack输出里会显示。排查问题时,看到"OrderLock"比看到"ReentrantLock$NonfairSync@1a2b3c"直观得多。这个小技巧能省不少排查时间。

7.3 后续可以深入的方向

如果这篇内容你都消化了,接下来可以看这几个方向:AQS 源码的完整阅读,理解acquire、release、acquireShared的每个分支;StampedLock的乐观读实现;ForkJoinPool里的工作窃取算法。这些都是在Lock和Condition基础上的延伸。

最后分享一个我调试并发问题的习惯:在关键路径上加日志,记录线程名、锁获取时间、等待时间。出问题时,这些日志比jstack快照更有价值,因为能看到时间线上的变化。并发问题往往不是瞬间发生的,而是慢慢累积的,有时间维度的数据才能看清全貌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询