1. 为什么需要ReentrantLock?
在Java并发编程中,synchronized关键字是最基础的线程同步机制,但它存在几个明显的局限性。首先,synchronized的锁获取和释放是隐式的,容易造成锁泄露;其次,它不支持中断等待、超时获取锁等高级功能;最重要的是,当多个线程竞争同一个锁时,synchronized无法提供公平性保证。
ReentrantLock作为java.util.concurrent.locks包下的显式锁实现,解决了这些问题。它提供了:
- 可重入性:同一个线程可以多次获取同一把锁
- 公平性选择:支持公平和非公平两种模式
- 锁等待可中断:线程在等待锁的过程中可以响应中断
- 超时获取:可以设置获取锁的超时时间
- 条件变量:支持多个等待条件
提示:在Java 5之前,synchronized的性能较差,但随着Java 6对synchronized的优化,两者性能差距已经不大。选择ReentrantLock更多是看中其功能特性而非性能。
2. ReentrantLock核心API详解
2.1 基础用法
ReentrantLock的标准使用模式如下:
java复制ReentrantLock lock = new ReentrantLock();
//...
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock();
}
这种将unlock放在finally块中的做法确保了锁一定会被释放,避免了锁泄露。这是与synchronized最大的不同之一 - 锁的释放需要显式控制。
2.2 高级特性API
ReentrantLock提供了丰富的方法:
java复制// 尝试获取锁,立即返回获取结果
boolean tryLock();
// 带超时的尝试获取锁
boolean tryLock(long timeout, TimeUnit unit);
// 可中断地获取锁
void lockInterruptibly();
// 查询当前线程持有锁的次数
int getHoldCount();
// 查询等待获取锁的线程数
int getQueueLength();
// 是否有线程在等待获取锁
boolean hasQueuedThreads();
这些方法为复杂的并发控制提供了可能。例如,在死锁检测中,getQueueLength()可以帮助判断锁竞争情况。
3. 公平锁与非公平锁
3.1 实现差异
ReentrantLock在构造时可以指定公平性:
java复制// 公平锁
ReentrantLock fairLock = new ReentrantLock(true);
// 非公平锁
ReentrantLock unfairLock = new ReentrantLock(false);
公平锁的实现核心在于hasQueuedPredecessors()方法,它会检查当前线程是否是等待队列中的第一个:
java复制public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
而非公平锁会直接尝试CAS获取锁,不检查等待队列。
3.2 性能对比
公平锁保证了先到先得的原则,但带来了显著的性能开销:
- 上下文切换更多:每次锁释放都需要唤醒等待队列中的第一个线程
- 吞吐量较低:CPU不能充分利用,可能出现CPU空闲等待
实测数据显示,在高竞争场景下,非公平锁的吞吐量可以是公平锁的5-10倍。因此,除非有严格的公平性要求,否则建议使用非公平锁。
4. 条件变量(Condition)的应用
4.1 基本概念
ReentrantLock通过newCondition()方法可以创建多个Condition对象,每个Condition维护一个等待队列。这与Object的wait()/notify()不同,后者只有一个等待队列。
典型的生产者-消费者模式实现:
java复制class BoundedBuffer {
final ReentrantLock lock = new ReentrantLock();
final Condition notFull = lock.newCondition();
final Condition notEmpty = lock.newCondition();
final Object[] items = new Object[100];
int putptr, takeptr, count;
public void put(Object x) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await();
items[putptr] = x;
if (++putptr == items.length) putptr = 0;
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}
public Object take() throws InterruptedException {
lock.lock();
try {
while (count == 0)
notEmpty.await();
Object x = items[takeptr];
if (++takeptr == items.length) takeptr = 0;
--count;
notFull.signal();
return x;
} finally {
lock.unlock();
}
}
}
4.2 与wait/notify的对比
- 一个ReentrantLock可以创建多个Condition,而每个Object只有一个等待队列
- Condition的await()会自动释放锁,与wait()相同
- Condition的signal()比notify()更精确,可以指定唤醒哪个等待队列
- Condition提供了更丰富的等待方法,如awaitUninterruptibly()
注意:调用Condition的await()/signal()前必须持有对应的锁,否则会抛出IllegalMonitorStateException。
5. 实战中的注意事项
5.1 锁泄露的预防
虽然ReentrantLock比synchronized更灵活,但也更容易出现锁泄露。以下情况需要特别注意:
- 在lock()和unlock()之间有return语句
- 临界区代码抛出异常
- 忘记在finally块中调用unlock()
建议的防御性编程实践:
- 将unlock()放在finally块中
- 使用tryLock()替代lock(),设置合理的超时时间
- 对锁的使用进行封装,采用模板方法模式
5.2 性能调优技巧
- 减小锁粒度:将一个大的临界区拆分为多个小的临界区
- 减少锁持有时间:将不需要同步的代码移出临界区
- 读写分离:读多写少的场景使用ReadWriteLock
- 避免嵌套锁:容易导致死锁
- 使用锁的层次结构:按照固定顺序获取多个锁
5.3 调试与监控
ReentrantLock提供了一些监控方法:
- getOwner():获取当前持有锁的线程
- isHeldByCurrentThread():当前线程是否持有锁
- isLocked():锁是否被持有
在开发中,可以通过这些方法实现:
- 死锁检测
- 锁竞争分析
- 线程转储分析
6. 与synchronized的深度对比
6.1 功能对比
| 特性 | ReentrantLock | synchronized |
|---|---|---|
| 锁获取方式 | 显式 | 隐式 |
| 可中断 | 支持 | 不支持 |
| 超时获取 | 支持 | 不支持 |
| 公平性 | 可配置 | 非公平 |
| 条件变量 | 支持多个 | 单个 |
| 锁释放 | 必须显式调用unlock() | 自动释放 |
| 性能 | Java 5之前更好 | Java 6后优化,差距不大 |
6.2 选择建议
选择synchronized当:
- 简单的同步需求
- 不需要高级功能
- 代码简洁性更重要
选择ReentrantLock当:
- 需要可中断、超时等高级功能
- 需要公平性控制
- 需要多个条件变量
- 需要尝试获取锁的能力
在实际项目中,我倾向于在简单场景使用synchronized,在复杂并发控制中使用ReentrantLock。特别是在需要实现特定等待条件(如连接池的空闲连接等待)时,ReentrantLock的条件变量非常有用。
7. 常见问题与解决方案
7.1 死锁问题
虽然ReentrantLock本身不会导致死锁,但不正确的使用方式仍然可能引发死锁。典型场景:
java复制// 线程1
lock1.lock();
try {
lock2.lock();
try {
// ...
} finally {
lock2.unlock();
}
} finally {
lock1.unlock();
}
// 线程2
lock2.lock();
try {
lock1.lock();
try {
// ...
} finally {
lock1.unlock();
}
} finally {
lock2.unlock();
}
解决方案:
- 使用tryLock()设置超时
- 按照固定顺序获取锁
- 使用锁的层次结构
7.2 性能问题
在高并发场景下,不合理的锁使用会导致性能瓶颈。我曾经遇到一个案例:一个简单的计数器,使用ReentrantLock保护,QPS只能达到2000左右。改用AtomicLong后,QPS提升到20万+。
经验法则:
- 读多写少:考虑ReadWriteLock或乐观锁
- 简单原子操作:优先使用Atomic变量
- 尽量减少临界区代码
7.3 内存可见性
与synchronized类似,ReentrantLock也保证了内存可见性。锁的获取会强制从主内存读取最新值,锁的释放会将修改刷新到主内存。但要注意:
- 所有共享变量的访问都应该在锁的保护下
- volatile变量不能替代锁,它只保证可见性不保证原子性
- 对于复杂操作,即使单个变量是volatile的,仍然需要锁保护
8. 最佳实践总结
经过多个项目的实践,我总结了以下ReentrantLock的最佳实践:
- 总是将unlock()放在finally块中
- 使用有意义的锁名称(Java 7+支持)
- 避免在持有锁时调用外部方法(容易导致死锁)
- 锁的粒度要尽可能小
- 考虑使用tryLock()替代lock(),特别是对于可能阻塞的操作
- 在高竞争场景,优先考虑非公平锁
- 对于复杂条件等待,使用Condition比wait/notify更清晰
- 考虑使用工具类封装锁操作,如Guava的Monitor
在最近的一个分布式任务调度系统中,我们使用ReentrantLock配合Condition实现了精确的任务触发控制。通过多个条件变量,能够高效地处理任务依赖、超时和取消等复杂场景,这是synchronized难以实现的。
