1. 从 synchronized 到 Lock:为什么需要另一把锁
刚接触 JUC 并发包那会儿,我脑子里一直有个疑问:既然synchronized已经能解决线程安全问题,为什么还要搞出一个Lock接口?后来在一个订单扣减库存的模拟场景里踩了坑,才真正理解这俩不是替代关系,而是互补关系。
Lock是java.util.concurrent.locks包下的核心接口,最常用的实现是ReentrantLock。它解决的问题很具体:synchronized是隐式锁,加锁释放锁全由 JVM 自动完成,灵活度低;而Lock把锁的控制权交到开发者手里,可以手动加锁、手动释放,还能实现可中断、可超时、公平锁这些synchronized做不到的事情。
这篇文章适合已经会写多线程、用过synchronized,但想进一步掌握Lock体系的人。我会从设计思路讲到实操代码,把ReentrantLock、ReentrantReadWriteLock、Condition这几个核心组件拆开揉碎,再补上我实际调试中遇到的坑。看完你应该能判断:什么场景该用Lock,什么场景老老实实用synchronized就行。
2. Lock 接口的整体设计与选型逻辑
2.1 为什么 Lock 要设计成接口而不是类
很多人第一次看Lock源码会疑惑:它就定义了几个方法,为什么不做成抽象类?这背后是 Java 集合框架一贯的设计哲学——面向接口编程,把"锁的行为"和"锁的实现"彻底解耦。
Lock接口只规定了"能加锁、能解锁、能尝试加锁、能中断加锁"这几件事,至于底层是用 AQS(AbstractQueuedSynchronizer)队列实现,还是用别的机制,接口不关心。这样一来,ReentrantLock、ReentrantReadWriteLock.ReadLock、ReentrantReadWriteLock.WriteLock都能实现同一套接口,上层代码调用方式统一。
我个人的理解是:synchronized像是家里的防盗门,锁上就锁上,你没法中途改主意;Lock像是带遥控的智能锁,你可以设定"等 3 秒没人开门就放弃"、"有人按门铃就中断等待"、"优先让排队最久的人进"。灵活性的代价就是你必须记得手动关门,忘了就是灾难。
2.2 Lock 与 synchronized 的核心差异对照
选型之前,先把两者的差异摆清楚。我整理了一张表,这些点在实际开发中都会直接影响你的决策:
| 对比维度 | synchronized | Lock(以 ReentrantLock 为例) |
|---|---|---|
| 锁的获取释放 | 隐式,JVM 自动管理 | 显式,必须手动 unlock |
| 是否可中断 | 不可中断 | lockInterruptibly 可中断 |
| 是否可超时 | 不支持 | tryLock(timeout) 支持 |
| 是否公平锁 | 只能非公平 | 可选公平/非公平 |
| 条件变量 | 单一 wait/notify | 多个 Condition 精准唤醒 |
| 锁信息获取 | 无法查询 | 可查询持有线程、等待队列 |
| 代码简洁度 | 简洁,不易出错 | 繁琐,易漏 unlock |
从表里能看出来,synchronized的优势是简单安全,Lock的优势是灵活可控。我的一般原则是:能用synchronized解决的就别上Lock,只有当确实需要超时、中断、公平性或者多条件唤醒时,才切换到Lock。
2.3 ReentrantLock 的可重入性到底意味着什么
"可重入"这个词初学者容易懵。说白了就是:同一个线程已经拿到了锁,再进一次被同一把锁保护的代码,还能直接拿到,不会被自己卡死。
举个生活化的例子:你拿着自家钥匙进了门,进门后想进卧室,不需要再掏一次钥匙重新开大门。如果锁不可重入,线程第二次请求自己已持有的锁时就会死等,直接把自己锁死。
ReentrantLock内部维护了一个持有计数,每重入一次计数加一,每 unlock 一次计数减一,减到零才真正释放锁。这一点和synchronized行为一致,但Lock要求你 unlock 的次数必须和 lock 次数严格匹配,多一次少一次都会出问题。
注意:可重入计数是
ReentrantLock内部状态,你无法直接读取,但可以通过getHoldCount()方法查看当前线程的重入次数,调试死锁时很有用。
3. ReentrantLock 核心方法逐个拆解
3.1 lock 与 unlock 的标准写法
最基础的用法就是lock()加锁、unlock()释放。但这里有个铁律:unlock()必须放在finally块里。我见过太多新手把 unlock 写在业务代码后面,一旦中间抛异常,锁永远释放不了,整个系统就卡死了。
标准模板长这样:
ReentrantLock lock = new ReentrantLock(); public void doSomething() { lock.lock(); try { // 临界区业务逻辑 processData(); } finally { lock.unlock(); } }为什么lock()要放在 try 外面?因为如果lock()本身抛异常(虽然极少),你还没拿到锁,这时候去 unlock 会抛IllegalMonitorStateException。所以正确顺序是:先 lock,再进 try,finally 里 unlock。
3.2 tryLock 的两种形态与实战价值
tryLock()是Lock相比synchronized最实用的能力之一。它有两个重载:
tryLock():立即返回,拿到锁返回 true,拿不到返回 false,不等待。tryLock(long timeout, TimeUnit unit):在指定时间内尝试,超时返回 false,期间可被中断。
这个能力在什么场景下救命?比如秒杀系统里,多个线程抢同一件商品的库存锁,如果用lock(),抢不到的线程会一直排队阻塞,线程池很快被占满。用tryLock()就能让抢不到的线程立刻返回"系统繁忙",把资源让给其他请求。
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) { try { // 抢到锁,处理库存扣减 deductStock(); } finally { lock.unlock(); } } else { // 500ms 没抢到,直接降级返回 return "当前请求过多,请稍后重试"; }实测下来,这种"快速失败"策略在高并发下比无脑阻塞的吞吐量高出一大截,因为它避免了大量线程堆积在锁队列里空耗 CPU。
3.3 lockInterruptibly 与线程中断的配合
lockInterruptibly()和lock()的区别在于:等待锁的过程中,如果线程被interrupt(),它会抛InterruptedException并放弃等待。而lock()是忽略中断的,会一直等到拿到锁为止。
这个特性在需要优雅关闭线程池的场景里特别重要。比如你有一个后台任务线程在等锁,主线程要关闭它,调用interrupt()后,如果用的是lockInterruptibly(),任务能立刻响应中断退出;如果用的是lock(),它可能还在傻等,导致关闭流程卡住。
public void taskWithInterrupt() throws InterruptedException { lock.lockInterruptibly(); try { // 业务逻辑 } finally { lock.unlock(); } }提示:
lockInterruptibly()抛出的InterruptedException必须处理,要么往上抛,要么捕获后恢复中断状态Thread.currentThread().interrupt(),别默默吞掉。
3.4 公平锁与非公平锁的选择
ReentrantLock构造时可以传一个 boolean 参数,true 表示公平锁,false(默认)表示非公平锁。
公平锁的意思是:线程按请求锁的先后顺序排队获取,先到先得。非公平锁则允许"插队"——新来的线程如果刚好赶上锁释放,可以直接抢,不用管队列里有没有人在等。
那是不是公平锁更好?不是。公平锁虽然公平,但吞吐量明显低于非公平锁,因为每次唤醒排队线程都有上下文切换开销,而且严格排队会让 CPU 利用率下降。非公平锁虽然可能让某些线程"饿死",但实际中概率很低,性能却好得多。
我的经验是:绝大多数场景用默认的非公平锁就够了。只有当你明确需要避免线程饥饿,比如某些任务对响应顺序有严格要求时,才考虑公平锁。
// 非公平锁(默认,推荐) ReentrantLock unfairLock = new ReentrantLock(); // 公平锁(按需使用) ReentrantLock fairLock = new ReentrantLock(true);4. Condition 条件变量:精准唤醒的艺术
4.1 为什么需要多个 Condition
synchronized配合wait/notify有个硬伤:一个锁对象只有一个等待队列,notify()唤醒的是随机一个等待线程,你没法指定唤醒"生产者"还是"消费者"。这在生产者-消费者模型里会导致"唤醒错人"的问题——生产者唤醒了另一个生产者,结果大家都空转。
Condition就是来解决这个问题的。一个ReentrantLock可以创建多个Condition,每个 Condition 维护自己的等待队列。生产者等在一个 Condition 上,消费者等在另一个上,唤醒时各叫各的,精准高效。
4.2 await 与 signal 的正确使用姿势
Condition的核心方法就三个:await()让当前线程等待并释放锁,signal()唤醒一个等待线程,signalAll()唤醒全部。
关键点在于:await()会释放锁,被唤醒后会重新竞争锁,拿到锁才从 await 返回。所以调用 await/signal 之前必须持有对应的锁,否则抛IllegalMonitorStateException。
ReentrantLock lock = new ReentrantLock(); Condition notFull = lock.newCondition(); Condition notEmpty = lock.newCondition(); Queue<String> queue = new LinkedList<>(); int capacity = 10; public void produce(String item) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { notFull.await(); // 队列满,生产者等待 } queue.add(item); notEmpty.signal(); // 唤醒一个消费者 } finally { lock.unlock(); } } public String consume() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); // 队列空,消费者等待 } String item = queue.poll(); notFull.signal(); // 唤醒一个生产者 return item; } finally { lock.unlock(); } }4.3 为什么 await 必须放在 while 循环里
这是并发编程里最经典的坑之一。很多人写成if (条件不满足) await();,结果出现虚假唤醒(spurious wakeup)时逻辑就崩了。
虚假唤醒是指线程在没有被 signal 的情况下也可能从 await 返回,这是操作系统层面的正常现象。用 while 循环重新检查条件,被唤醒后如果条件还是不满足就继续等,才能保证正确性。
// 错误写法 if (queue.isEmpty()) { notEmpty.await(); } // 正确写法 while (queue.isEmpty()) { notEmpty.await(); }注意:这个 while 规则和
synchronized里的wait()完全一致,是并发编程的铁律,别偷懒用 if。
5. 读写锁 ReentrantReadWriteLock 的适用场景
5.1 读读不互斥的设计初衷
ReentrantLock是排他锁,同一时刻只允许一个线程访问。但现实中很多场景是"读多写少",比如缓存、配置中心。如果读操作也互斥,性能浪费严重。
ReentrantReadWriteLock把锁拆成读锁和写锁:读锁之间共享,多个线程可以同时读;写锁排他,写的时候不允许任何其他读写。这样在读多写少的场景下,并发度能大幅提升。
| 锁组合 | 是否互斥 |
|---|---|
| 读锁 + 读锁 | 不互斥,可并发 |
| 读锁 + 写锁 | 互斥 |
| 写锁 + 写锁 | 互斥 |
5.2 读写锁的降级与升级规则
读写锁有个容易搞混的点:锁降级可以,锁升级不行。
锁降级是指:持有写锁的线程可以再获取读锁,然后释放写锁,最后持有读锁。这个过程是允许的,用于保证数据可见性。
锁升级是指:持有读锁的线程去获取写锁。这个会直接死锁,因为读锁还没释放,写锁永远等不到。所以千万别在持有读锁时去申请写锁。
// 锁降级示例 readWriteLock.writeLock().lock(); try { // 修改数据 updateData(); readWriteLock.readLock().lock(); // 获取读锁 } finally { readWriteLock.writeLock().unlock(); // 释放写锁,此时仍持有读锁 } try { // 读取数据,保证看到自己刚写的 readData(); } finally { readWriteLock.readLock().unlock(); }5.3 读写锁的实战选型建议
读写锁不是万能的。如果写操作占比很高,读写锁反而比普通排他锁慢,因为它内部维护了更复杂的状态。我一般这样判断:读操作占比超过 70% 且读操作耗时较长时,才考虑读写锁;否则直接用ReentrantLock或synchronized更省心。
另外,ReentrantReadWriteLock也支持公平和非公平模式,默认非公平。非公平模式下,读锁可能被后来的写锁插队,导致读线程饥饿,这点在高并发读场景下要留意。
6. 实操中踩过的坑与排查技巧
6.1 unlock 漏写导致的死锁排查
我遇到过一次线上事故:某个接口偶尔超时,重启后恢复,过一阵又出现。用jstack抓线程栈,发现大量线程卡在WAITING状态,持有锁的线程却已经执行完了业务逻辑。
排查后发现,代码里有个分支提前 return 了,而 unlock 写在 return 之后,导致这条路径锁没释放。这就是典型的"unlock 没放 finally"问题。
排查这类问题的套路:
- 用
jstack <pid>导出线程栈,搜索waiting to lock关键字。 - 找到持有锁的线程,看它在干什么。
- 如果持有锁的线程已经"消失"或卡在别处,基本就是漏 unlock。
提示:
ReentrantLock提供了getOwner()、getQueuedThreads()、isLocked()等方法,可以在监控代码里定期打印锁状态,提前发现异常。
6.2 锁粒度过大引发的性能问题
另一个常见坑是锁粒度过大。比如把整个方法都用锁包起来,里面还包含数据库查询、远程调用这些耗时操作,结果所有线程都堵在锁上,QPS 上不去。
解决办法是缩小临界区:只把真正需要互斥的代码放进锁里,耗时操作挪到锁外面。比如先查数据库拿到数据,再进锁更新内存状态。
// 优化前:锁包住整个方法 lock.lock(); try { Data data = queryFromDb(); // 耗时 updateCache(data); } finally { lock.unlock(); } // 优化后:只锁关键部分 Data data = queryFromDb(); // 锁外执行 lock.lock(); try { updateCache(data); } finally { lock.unlock(); }6.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 线程一直 WAITING | 漏 unlock 或死锁 | jstack 看锁持有者 |
| 抛 IllegalMonitorStateException | 未持锁调用 await/unlock | 检查 lock 与 unlock 配对 |
| 性能突然下降 | 锁粒度过大或公平锁 | 缩小临界区,改非公平 |
| 读线程饥饿 | 非公平读写锁写优先 | 考虑公平模式或调整策略 |
| 虚假唤醒导致逻辑错乱 | await 用了 if 而非 while | 改为 while 循环检查 |
6.4 几个容易被忽略的细节
第一,ReentrantLock的newCondition()可以调用多次,每次返回新的 Condition 对象,别重复用同一个 Condition 去管不同条件。
第二,tryLock()不带超时的版本,即使当前线程已经持有锁,也会返回 true(可重入),这点和lock()一致。
第三,读写锁的读锁不支持 Condition,只有写锁能创建 Condition,因为读锁是共享的,没法做排他等待。
第四,Lock的锁信息查询方法(如getQueueLength())返回的是估计值,不保证精确,用于监控可以,用于业务判断不行。
7. 我的实操体会与选型建议
用了几年Lock之后,我总结出一条朴素的原则:默认用 synchronized,需要高级特性时才上 Lock。这不是保守,而是因为synchronized在 JDK 后续版本里做了大量优化(偏向锁、轻量级锁、锁消除),性能已经不比ReentrantLock差多少,而且它不会因为漏 unlock 出事故。
真正让我切换到Lock的场景就那么几类:需要tryLock做快速失败降级、需要lockInterruptibly支持任务取消、需要多个Condition做精准唤醒、需要读写分离提升并发。除此之外,synchronized足够。
最后分享一个小技巧:如果你在调试并发问题,可以在关键锁的 lock 和 unlock 前后加上线程名和时间的日志,配合jstack一起看,定位死锁和锁竞争的效率会高很多。日志别打太密,否则本身就成了性能瓶颈。这个度需要根据实际压测结果来调,我一般只在排查阶段临时开启,问题定位后立刻关掉。