Java ReentrantLock 实战——公平锁、可中断锁、条件变量完整使用手册
Java ReentrantLock 实战——公平锁、可中断锁、条件变量完整使用手册
适读人群:熟悉synchronized、想提升并发编程能力的Java开发者 | 阅读时长:约16分钟 | 核心价值:掌握ReentrantLock的高级特性,在synchronized搞不定的场景里游刃有余
一次让我彻底换锁的经历
2022年春节前夕,我们做了个秒杀活动,QPS峰值要扛到8000+。我信心满满地用synchronized做了库存扣减,测试环境跑得好好的,上线前压测也没问题。
结果活动一开始,客服电话就炸了:用户点击秒杀按钮,页面一直转圈,最后提示"系统繁忙请重试"。我看着监控,线程数暴涨到了500+,CPU却只有30%,大量线程处于BLOCKED状态。
用Arthas分析了一下,核心问题是:某些用户的网络特别差,拿到锁之后处理时间很长,后面等待的线程全部傻等,等不了就超时报错。而且synchronized对这种情况完全没有处理手段——你没法设置等待超时,没法中断等待中的线程,更没法知道到底是谁长期持有锁。
那次之后,我把这块逻辑全部换成了ReentrantLock。今天这篇文章,我来完整地讲清楚ReentrantLock的每一个特性,以及什么时候该用哪个。
ReentrantLock 的基本结构
ReentrantLock实现了Lock接口,底层基于AbstractQueuedSynchronizer(AQS)。AQS是Java并发包的核心基础设施,后面专门写一篇讲它,这里先聚焦ReentrantLock的使用。
标准使用模板(必须背下来):
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockTemplate {
private final ReentrantLock lock = new ReentrantLock();
public void doSomething() {
lock.lock(); // 获取锁
try {
// 业务逻辑
doBusinessLogic();
} finally {
lock.unlock(); // 必须在 finally 里释放!
}
}
}finally里释放锁这条是铁律,违反了就可能在业务代码抛异常时永久持有锁,整个服务死锁。
公平锁与非公平锁
什么是公平锁
公平锁保证线程按照请求锁的顺序获取锁,先来先得,不会有线程被"插队"饿死。
非公平锁(默认)允许新来的线程在锁释放瞬间直接抢锁,不管等待队列里有没有线程。
// 非公平锁(默认)
ReentrantLock unfairLock = new ReentrantLock();
ReentrantLock unfairLock2 = new ReentrantLock(false);
// 公平锁
ReentrantLock fairLock = new ReentrantLock(true);公平锁的代价
我做过一个JMH测试,8线程高竞争场景,100万次lock/unlock:
- 非公平锁:吞吐量 约850万次/秒
- 公平锁:吞吐量 约230万次/秒
公平锁的吞吐量大约是非公平锁的27%。原因是公平锁每次获取都要检查等待队列,而非公平锁的新来线程可能直接用上CPU缓存热数据,减少了上下文切换。
什么时候用公平锁
我的经验是,绝大多数场景都不需要公平锁。只有在以下情况才考虑:
- 对响应时间的公平性有严格要求(不能让某些请求永远等下去)
- 系统线程数量可控,且你明确知道公平性比吞吐量更重要
可中断锁:lockInterruptibly
这是synchronized完全做不到的事情。
lockInterruptibly()允许线程在等待锁的过程中响应中断信号,被中断的线程会抛出InterruptedException。
/**
* 带超时取消的任务执行器
* 使用 lockInterruptibly 实现:等待锁超过指定时间就放弃
*/
public class CancellableTask {
private final ReentrantLock lock = new ReentrantLock();
public void executeWithCancellation(long timeoutMs) {
Thread currentThread = Thread.currentThread();
// 启动一个定时中断线程
Thread interrupter = new Thread(() -> {
try {
Thread.sleep(timeoutMs);
currentThread.interrupt(); // 超时后中断当前线程
} catch (InterruptedException ignored) {}
});
interrupter.setDaemon(true);
interrupter.start();
try {
lock.lockInterruptibly(); // 可被中断的锁等待
try {
System.out.println("获取锁成功,执行业务逻辑");
doLongRunningTask();
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
System.out.println("等待锁超时,任务取消,不再等待");
// 注意:不要吞掉中断,重新设置中断标志
Thread.currentThread().interrupt();
}
}
private void doLongRunningTask() {
// 模拟耗时操作
try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}tryLock:超时等待与非阻塞尝试
这是我用得最多的特性,也是解决那次秒杀事故的关键。
// tryLock() 非阻塞,立刻返回是否获取成功
boolean acquired = lock.tryLock();
// tryLock(time, unit) 等待指定时间
boolean acquired = lock.tryLock(100, TimeUnit.MILLISECONDS);秒杀场景的完整实现:
@Service
public class SeckillService {
// 每个商品一把锁,避免商品间互相阻塞
private final ConcurrentHashMap<Long, ReentrantLock> lockMap = new ConcurrentHashMap<>();
public SeckillResult doSeckill(Long skuId, Long userId) {
// 获取或创建该商品的锁
ReentrantLock lock = lockMap.computeIfAbsent(skuId, k -> new ReentrantLock());
boolean acquired = false;
try {
// 最多等 50ms,超时直接告知用户稍后重试
acquired = lock.tryLock(50, TimeUnit.MILLISECONDS);
if (!acquired) {
return SeckillResult.fail("系统繁忙,请稍后重试");
}
// 拿到锁之后再查库存,防止超卖
int stock = inventoryDao.getStock(skuId);
if (stock <= 0) {
return SeckillResult.fail("很遗憾,商品已售罄");
}
// 扣减库存
inventoryDao.decreaseStock(skuId, 1);
// 创建订单
Long orderId = orderDao.createOrder(userId, skuId);
return SeckillResult.success(orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return SeckillResult.fail("请求被取消");
} finally {
if (acquired) {
lock.unlock();
}
}
}
}注意if (acquired)这个判断:只有成功获取锁才能释放。如果没获取到锁就调用unlock(),会抛IllegalMonitorStateException。
条件变量 Condition:精确唤醒
synchronized配套的是wait()/notify(),只有一个等待队列,无法精确控制唤醒哪一类线程。
ReentrantLock的Condition对象可以创建多个等待队列,实现精确唤醒。
经典案例:有界阻塞队列
/**
* 用 ReentrantLock + Condition 实现有界阻塞队列
* 这是理解 Condition 最好的例子
*/
public class BoundedBlockingQueue<T> {
private final ReentrantLock lock = new ReentrantLock();
// 队列不满时通知生产者可以放入
private final Condition notFull = lock.newCondition();
// 队列不空时通知消费者可以取出
private final Condition notEmpty = lock.newCondition();
private final Object[] items;
private int putIndex; // 下次放入的位置
private int takeIndex; // 下次取出的位置
private int count; // 当前元素数量
public BoundedBlockingQueue(int capacity) {
this.items = new Object[capacity];
}
/**
* 放入元素,队列满时阻塞等待
*/
public void put(T item) throws InterruptedException {
lock.lock();
try {
// 队列满了,等待消费者取走元素
while (count == items.length) {
notFull.await(); // 释放锁并等待,被唤醒后重新获取锁
}
items[putIndex] = item;
putIndex = (putIndex + 1) % items.length;
count++;
// 精确唤醒等待"不空"条件的消费者线程
notEmpty.signal();
} finally {
lock.unlock();
}
}
/**
* 取出元素,队列空时阻塞等待
*/
@SuppressWarnings("unchecked")
public T take() throws InterruptedException {
lock.lock();
try {
// 队列空了,等待生产者放入元素
while (count == 0) {
notEmpty.await(); // 释放锁并等待
}
T item = (T) items[takeIndex];
items[takeIndex] = null; // help GC
takeIndex = (takeIndex + 1) % items.length;
count--;
// 精确唤醒等待"不满"条件的生产者线程
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
/**
* 非阻塞放入,队列满时直接返回false
*/
public boolean offer(T item) {
lock.lock();
try {
if (count == items.length) return false;
items[putIndex] = item;
putIndex = (putIndex + 1) % items.length;
count++;
notEmpty.signal();
return true;
} finally {
lock.unlock();
}
}
public int size() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}这个实现里,生产者和消费者各自等在不同的Condition上,notFull.signal()只唤醒生产者,notEmpty.signal()只唤醒消费者,不会出现synchronized那种"唤醒了错误的线程"问题。
三个踩坑实录
坑一:tryLock 后忘记判断,直接 unlock
现象: 代码上线后偶发IllegalMonitorStateException,日志里是unlock那行报错。
原因: 写了lock.tryLock()但没判断返回值,无论是否成功都调用了unlock()。
// 错误写法
lock.tryLock(50, TimeUnit.MILLISECONDS);
try {
doWork();
} finally {
lock.unlock(); // tryLock失败时,这里会抛 IllegalMonitorStateException
}
// 正确写法
boolean acquired = lock.tryLock(50, TimeUnit.MILLISECONDS);
try {
if (!acquired) return;
doWork();
} finally {
if (acquired) lock.unlock(); // 只有获取成功才释放
}解法: 必须用布尔变量记录获取结果,在finally里根据结果决定是否释放。
坑二:Condition.await() 用 if 而不是 while
现象: 有界队列偶发NPE,取出来的元素是null。
原因: 把while (count == 0)写成了if (count == 0)。await()被唤醒后可能是虚假唤醒(spurious wakeup),用if的话不会再次检查条件,直接往下走,结果取到了不存在的元素。
// 错误写法
if (count == 0) {
notEmpty.await();
}
// 被唤醒后直接往下走,但此时count可能还是0
// 正确写法:必须用 while,被唤醒后重新检查条件
while (count == 0) {
notEmpty.await();
}这是Java并发编程里最经典的坑之一,wait/await必须配合while使用。
坑三:ReentrantLock 忘记重入次数,导致死锁
现象: 某个接口在特定调用路径下必然死锁,线程hang住不释放。
原因: ReentrantLock支持重入,但每次lock必须对应一次unlock。如果方法A获取锁后调用方法B,方法B里又获取了同一把锁(重入),那么必须释放两次。
private final ReentrantLock lock = new ReentrantLock();
public void methodA() {
lock.lock(); // 第1次获取,holdCount=1
try {
methodB();
} finally {
lock.unlock(); // holdCount=0,正确释放
}
}
public void methodB() {
lock.lock(); // 第2次获取(重入),holdCount=2
try {
doWork();
} finally {
lock.unlock(); // holdCount=1,还没完全释放
// 这里是methodB的finally,没问题
}
}重入本身没问题,但如果在methodB的finally里因为某种原因多调用了一次unlock,holdCount会变成0,锁被意外释放。可以用lock.getHoldCount()调试重入次数。
ReentrantLock vs synchronized 选型指南
| 特性 | synchronized | ReentrantLock |
|---|---|---|
| 使用便捷性 | 高,语言层面支持 | 较低,需要手动加解锁 |
| 可中断等待 | 不支持 | 支持(lockInterruptibly) |
| 超时获取 | 不支持 | 支持(tryLock) |
| 公平锁 | 不支持 | 支持 |
| 多条件变量 | 不支持(只有一个wait队列) | 支持(Condition) |
| 锁状态查询 | 不支持 | 支持(isLocked等) |
| 性能 | JDK 6后接近 | 高竞争略好 |
我的选型原则:
- 简单场景、代码逻辑清晰:用synchronized,少出错
- 需要超时/可中断/公平性/多条件变量:用ReentrantLock
- 性能极致要求:两者差异不大,优先看业务逻辑复杂度
小结
ReentrantLock是synchronized的功能增强版,核心优势在于:
- tryLock超时:避免无限等待,实现优雅降级
- lockInterruptibly:支持取消,提升响应性
- 公平锁:必要时保证请求顺序
- Condition精确唤醒:生产消费模型的最佳搭档
但记住,能力越大责任越大——手动管理锁就必须保证finally里一定释放,这是ReentrantLock最容易出错的地方。
