synchronized 日常够用了——简单、自动加锁释放,JDK 1.6 之后性能也优化得很好。但它有几个天生的限制:拿不到锁就一直傻等、等的时候不能被中断、所有线程抢锁不排队、也没有办法让线程"分拨"等待。这篇把 ReentrantLock 系统梳理一遍:它补齐了哪些能力、每个能力解决什么场景,以及什么时候其实还是 synchronized 更合适。
这是"锁"系列的一篇。各种锁概念的全景(本地锁/分布式锁/悲观乐观锁)在《锁的分类与实现原理》,这里只深挖
ReentrantLock本身。
一、synchronized 的四个"做不到"
先看 synchronized 用起来没毛病、但真遇到具体需求时会卡住的四个点:
| 场景 | synchronized 的表现 | 想要的效果 |
|---|---|---|
| 试探性拿锁 | lock() 进了同步块才知道等不等得到 | 拿不到就算了,或者等 500ms 就放弃 |
| 排队时被叫醒 | 拿不到锁就阻塞,中断不了(不响应 interrupt) | 等太久可以取消,别干耗着 |
| 先来后到 | 不排队,谁抢到是谁的(非公平) | 可以选公平模式,按排队顺序给 |
| 分拨等待 | 只有一个"大门",所有线程等在一起 | 读写分离等、按条件分组等(Condition) |
ReentrantLock 就是把这四件事补齐的显式锁。注意它不是"更强的 synchronized 所以要替代它"——它提供的是"可控性",代价是用法更繁琐。
二、基本用法:lock 和 unlock 必须成对
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock(); // 加锁
try {
count++;
} finally {
lock.unlock(); // 必须放 finally!
}
}
}和 synchronized 最大的区别:释放锁要自己负责。synchronized 出了同步块自动释放,ReentrantLock 如果忘了 unlock(或异常时没释放),这把锁就永远被人占着——其他线程全部卡死。所以铁律是 unlock 一定放 finally 里。
三、可重入:同一线程可以重复加锁
"可重入"的意思是:同一个线程,拿到锁之后再次进入这把锁,不会被自己卡死。锁内部维护一个计数器(holdCount),加一次加 1,释放一次减 1,减到 0 才真正释放。
ReentrantLock lock = new ReentrantLock();
public void outer() {
lock.lock();
try {
inner(); // inner 里还要再 lock,没问题
} finally {
lock.unlock();
}
}
public void inner() {
lock.lock(); // 同一线程重复加锁,直接进
try {
// ...
} finally {
lock.unlock();
}
}为什么需要这个特性?如果没有可重入,上面的 outer() 调 inner() 就死锁了——自己等自己的锁,永远等不到。synchronized 天生就是可重入的,这一点两者一致。
四、tryLock:拿不到就算了,或等一会儿
这是 synchronized 完全做不到的能力。两个版本:
// 版本 1:立即返回,抢不到马上走人
if (lock.tryLock()) {
try { /* 拿到了,干活 */ }
finally { lock.unlock(); }
} else {
// 没抢到,干别的事去(比如记条日志、返回提示)
}
// 版本 2:最多等 500 毫秒,超时放弃
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try { /* ... */ }
finally { lock.unlock(); }
} else {
// 等 500ms 还没轮到我,放弃
}典型场景:任务调度去重。定时任务每分钟跑一次,集群里多台机器都会触发,但同一时刻只想让一台执行——tryLock() 抢到的机器干活,没抢到的直接返回。这种情况就不该傻等,等着毫无意义。
还有一个隐藏用法:detect 死锁。tryLock(1, TimeUnit.SECONDS) 都拿不到,大概率不是"忙",而是死锁了,可以放弃并记录现场。
五、lockInterruptibly:排队时可以被叫醒
普通 lock() 排队等锁时,别的线程 interrupt 它,它不理睬,继续等。lockInterruptibly() 则会在等待中被中断,抛出 InterruptedException:
Thread worker = new Thread(() -> {
try {
lock.lockInterruptibly(); // 排队,但随时可以被叫醒
try { /* 拿到锁,干活 */ }
finally { lock.unlock(); }
} catch (InterruptedException e) {
// 排队时被中断,可以清理资源、退出
Thread.currentThread().interrupt();
}
});这个能力在需要可取消的任务里很关键。比如线程池要 shutdownNow(),正在排队的线程如果能响应中断就能快速退出;用 lock() 排队的话,它们会一直等到拿到锁、干完活才能停。
对照记忆:synchronized 等锁阶段完全不响应中断——这也是它"不可控"的一部分。
六、公平锁:先来后到
构造时传 true 就开了公平模式:
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
ReentrantLock lock = new ReentrantLock(); // 默认非公平- 非公平锁(默认):锁一释放,谁抢到是谁的。刚来的线程可能"插队"——它正好空闲,CAS 一下就拿到了,而队列里排了半天的线程还在睡。好处是吞吐量高(省去了唤醒排队线程的开销),坏处是可能有线程长时间抢不到。
- 公平锁:严格按排队顺序给锁,先来先得,不会饿死。代价是每次释放都要唤醒队头线程,多一次线程切换,吞吐量明显低。
实践中默认用非公平就好。真遇到"某些线程总抢不到"的饥饿问题,先怀疑是不是锁粒度太粗,再考虑公平锁。
七、Condition:让线程"分拨"等待
synchronized 配 wait()/notify() 只有一个等待集合——所有等待的线程挤在一起,notify() 随机叫醒一个,叫醒的可能不是想要的那个。Condition 把"等待"拆成多个条件队列,每拨线程等各自的门。
经典的生产者-消费者:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class BoundedBuffer {
private final Object[] items = new Object[10];
private int putIdx, takeIdx, count;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // "不满"这拨
private final Condition notEmpty = lock.newCondition(); // "不空"这拨
public void put(Object x) throws InterruptedException {
lock.lock();
try {
while (count == items.length) {
notFull.await(); // 满了,去"不满"队列等
}
items[putIdx] = x;
if (++putIdx == items.length) putIdx = 0;
count++;
notEmpty.signal(); // 放了数据,叫醒"不空"队列里的消费者
} finally {
lock.unlock();
}
}
public Object take() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
notEmpty.await(); // 空了,去"不空"队列等
}
Object x = items[takeIdx];
if (++takeIdx == items.length) takeIdx = 0;
count--;
notFull.signal(); // 腾出位置,叫醒"不满"队列里的生产者
return x;
} finally {
lock.unlock();
}
}
}看清楚这个配合:生产者只叫醒消费者(notEmpty.signal()),消费者只叫醒生产者(notFull.signal())。如果用 wait()/notifyAll(),每次都得全员叫醒、全员重新检查条件,白白多一轮空转。等的人和被等的人分开,就是 Condition 的价值。
两个细节:
await()必须写在while循环里而不是if——被唤醒后条件可能又被别的线程改掉了,醒来要再检查一遍。signal()只叫醒一个,signalAll()叫醒该队列全部。拿不准就用signalAll(),代价只是多做几次条件检查。
这张图把两个队列的等待/唤醒关系画出来:
八、什么时候还是用 synchronized
ReentrantLock 不是"更高级所以更好",它有明确的使用代价:
| synchronized | ReentrantLock | |
|---|---|---|
| 加锁/释放 | 自动,出了同步块就放 | 手动,忘 unlock 就出事 |
| 可重入 | 是 | 是 |
| tryLock / 可中断 / 公平 | 都不支持 | 都支持 |
| Condition | 无(只有单一 wait/notify) | 多条件队列 |
| 代码直观性 | 好 | 差一截 |
判断很简单:需要 tryLock、可中断、公平、Condition 这四个能力中的任何一个,用 ReentrantLock;四个都不需要,用 synchronized。 大多数业务代码是后者。
小结
ReentrantLock补的是 synchronized 的四个"做不到":tryLock(试探/限时)、lockInterruptibly(排队可中断)、公平锁(先来后到)、Condition(分拨等待)- 代价是手动释放:
unlock必须放finally,忘放就是死锁 - 可重入靠内部计数器,同线程重复加锁不死锁(和 synchronized 一致)
- 公平锁防饿死但吞吐量低,默认非公平
- Condition 的价值是"等的人和被等的人分开",
await要写在while里 - 四个能力都不需要时,
synchronized仍是首选
ReentrantLock 底层是怎么做到这一切的?CAS 改 state、排队、挂起唤醒——这套机制叫 AQS,单独写成了一篇:《AQS 原理详解》。读多写少的场景还有一把更合适的锁:《读写锁 ReentrantReadWriteLock 与 StampedLock》。并发的整体地图看《Java 并发编程》。
