1. 可重入锁的本质与核心特性
可重入锁(ReentrantLock)是Java并发包中一个比synchronized更灵活、功能更丰富的线程同步机制。我第一次在实际项目中使用它是在处理一个金融交易系统时,当时需要更细粒度的锁控制,而synchronized关键字已经无法满足需求。
可重入锁之所以被称为"可重入",是因为同一个线程可以多次获取同一把锁而不会导致死锁。这与我们日常生活中的门锁机制截然不同——想象一下如果你家的门锁允许用同一把钥匙反复上锁,而每次上锁都需要另一把不同的钥匙来解开,那将会多么荒谬。在编程世界里,这种设计却显得异常精妙。
java复制ReentrantLock lock = new ReentrantLock();
void methodA() {
lock.lock();
try {
methodB(); // 可重入的关键点
} finally {
lock.unlock();
}
}
void methodB() {
lock.lock(); // 同一个线程可以再次获取锁
try {
// 临界区代码
} finally {
lock.unlock();
}
}
可重入锁的实现原理是通过维护一个计数器来记录锁被重入的次数。当计数器为0时表示锁未被任何线程持有,当线程第一次获取锁时计数器变为1,同一线程每次重入获取都会使计数器加1,而每次释放锁则减1,直到计数器归零才真正释放锁。
提示:使用ReentrantLock时必须用try-finally块确保锁的释放,否则一旦临界区代码抛出异常,可能导致锁无法释放。
2. 与synchronized的关键差异对比
在面试中,这个问题经常被拿来考察候选人对Java并发理解的深度。我整理了一张对比表,这来自于我多次技术评审和面试的经验总结:
| 特性 | ReentrantLock | synchronized |
|---|---|---|
| 锁的获取方式 | 显式调用lock()/unlock() | 隐式通过代码块或方法 |
| 尝试非阻塞获取锁 | 支持tryLock() | 不支持 |
| 可中断性 | lockInterruptibly()支持 | 不支持 |
| 公平锁实现 | 构造函数可指定公平策略 | 非公平 |
| 条件变量 | 可创建多个Condition对象 | 单一wait/notify机制 |
| 性能 | Java 6+优化后两者接近 | Java 6+优化后性能接近 |
| 锁的细粒度 | 更细粒度控制 | 相对粗粒度 |
在实际项目中,我倾向于在以下场景选择ReentrantLock:
- 需要尝试获取锁(tryLock)的场景,比如避免死锁
- 需要可中断的锁获取操作
- 需要公平锁策略
- 需要绑定多个条件变量(Condition)
- 需要获取锁的详细信息(如getHoldCount())
3. 可重入锁的高级用法与陷阱
3.1 公平锁与非公平锁的抉择
ReentrantLock的构造函数接受一个boolean参数指定是否为公平锁:
java复制// 公平锁
ReentrantLock fairLock = new ReentrantLock(true);
// 非公平锁(默认)
ReentrantLock unfairLock = new ReentrantLock();
公平锁保证等待时间最长的线程优先获取锁,但会带来显著的性能开销。根据我的压力测试结果,在高并发场景下,非公平锁的吞吐量比公平锁高出40%以上。这是因为公平锁需要维护一个有序队列,而线程唤醒和阻塞的开销很大。
经验法则:除非有明确的顺序性需求,否则优先使用非公平锁。我在电商秒杀系统中就吃过这个亏——错误地使用了公平锁导致TPS上不去。
3.2 条件变量的灵活运用
Condition接口提供了比Object.wait/notify更灵活的线程通信机制。一个ReentrantLock可以关联多个Condition:
java复制ReentrantLock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
Condition notFull = lock.newCondition();
// 生产者
lock.lock();
try {
while (queue.isFull()) {
notFull.await(); // 释放锁并等待不满信号
}
queue.put(item);
notEmpty.signal(); // 发送不空信号
} finally {
lock.unlock();
}
// 消费者
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 释放锁并等待不空信号
}
item = queue.take();
notFull.signal(); // 发送不满信号
} finally {
lock.unlock();
}
这种模式在实现有界阻塞队列时特别有用。我曾在消息中间件的开发中,用这种方式实现了比ArrayBlockingQueue更高性能的队列。
3.3 锁的可见性保证
很多开发者容易忽略的一点是:ReentrantLock和synchronized一样,都提供了内存可见性保证。这是因为锁的获取与释放会建立happens-before关系,确保临界区内的修改对后续获取锁的线程可见。
我曾经遇到过一个棘手的bug:在没有同步的情况下,一个线程修改了共享变量,而另一个线程始终读取到旧值。改用ReentrantLock后问题立即解决,这就是因为锁保证了内存可见性。
4. 性能优化与最佳实践
4.1 锁分段技术
在高并发场景下,我经常使用锁分段(Lock Striping)技术来减少锁竞争。比如在实现一个线程安全的HashMap时:
java复制class StripedMap {
private final ReentrantLock[] locks;
private final Map<String, Object>[] segments;
public StripedMap(int concurrencyLevel) {
locks = new ReentrantLock[concurrencyLevel];
segments = new Map[concurrencyLevel];
for (int i = 0; i < concurrencyLevel; i++) {
locks[i] = new ReentrantLock();
segments[i] = new HashMap<>();
}
}
private int hash(Object key) {
return Math.abs(key.hashCode() % locks.length);
}
public void put(String key, Object value) {
int hash = hash(key);
locks[hash].lock();
try {
segments[hash].put(key, value);
} finally {
locks[hash].unlock();
}
}
// 其他方法类似
}
这种技术将数据分成多个段,每个段由独立的锁保护。根据我的测试,在16核服务器上,相比全局锁,锁分段可以将吞吐量提升8-10倍。
4.2 避免锁泄漏
锁泄漏(Lock Leak)是指线程获取锁后由于异常或逻辑错误未能释放锁。我在代码审查中最常看到的反模式是:
java复制// 错误示范!
lock.lock();
// 这里可能抛出异常
doSomething();
lock.unlock(); // 如果上面抛出异常,这行不会执行
正确的做法总是使用try-finally:
java复制lock.lock();
try {
doSomething();
} finally {
lock.unlock();
}
4.3 锁的粒度控制
锁的粒度太粗会降低并发性,太细会增加复杂性。我的经验法则是:
- 锁保护的应该是共享资源,而非业务逻辑
- 锁的持有时间应尽可能短
- 避免在锁内执行IO操作等耗时任务
在电商系统中,我曾将商品库存的锁从整个商品对象细化到SKU级别,使订单处理能力提升了3倍。
5. 常见面试问题深度解析
作为面试官,我发现很多候选人对可重入锁的理解停留在表面。以下是几个我常问的深度问题及其背后的原理:
5.1 为什么需要可重入性?
考虑这个递归场景:
java复制public synchronized void recursiveMethod(int n) {
if (n <= 0) return;
// 一些操作
recursiveMethod(n - 1);
}
如果没有可重入性,线程在递归调用时会阻塞在自己持有的锁上,导致死锁。可重入性使得这种递归调用成为可能。
5.2 tryLock()与lock()的区别
tryLock()是非阻塞尝试获取锁,立即返回成功或失败状态。我在实现超时控制时经常使用它的变体:
java复制if (lock.tryLock(3, TimeUnit.SECONDS)) {
try {
// 临界区
} finally {
lock.unlock();
}
} else {
// 超时处理
}
这种模式在避免死锁方面非常有用,比如在银行转账系统中实现死锁检测和恢复。
5.3 可重入锁的内存语义
ReentrantLock的内存语义与synchronized相同,都遵循Java内存模型中对锁的规定:
- 获取锁时,会清空工作内存,从主内存重新加载共享变量
- 释放锁时,会把工作内存中的修改刷新到主内存
这解释了为什么锁能保证可见性。我在多核环境下调试过很多可见性问题,理解这一点至关重要。
6. 真实案例:分布式锁的本地实现
虽然ReentrantLock是本地锁,但我在一些特殊场景下用它实现了简单的分布式锁模拟。比如在开发阶段,当完整的分布式锁服务还未就绪时:
java复制class DistributedLockMock {
private final ReentrantLock localLock = new ReentrantLock();
private final Map<String, Thread> lockOwners = new HashMap<>();
public boolean tryLock(String resourceId, long timeout) {
localLock.lock();
try {
long endTime = System.currentTimeMillis() + timeout;
while (true) {
if (!lockOwners.containsKey(resourceId)) {
lockOwners.put(resourceId, Thread.currentThread());
return true;
}
if (System.currentTimeMillis() >= endTime) {
return false;
}
TimeUnit.MILLISECONDS.sleep(100); // 轮询间隔
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
} finally {
localLock.unlock();
}
}
public void unlock(String resourceId) {
localLock.lock();
try {
if (Thread.currentThread().equals(lockOwners.get(resourceId))) {
lockOwners.remove(resourceId);
}
} finally {
localLock.unlock();
}
}
}
这个实现虽然简单,但在开发初期帮助我们快速验证了业务逻辑。当然,生产环境应该使用Redis或Zookeeper等真正的分布式锁实现。
7. 性能调优实战经验
在压力测试中,我发现ReentrantLock的几个性能关键点:
- 锁竞争热点识别:使用JMX或Arthas监控锁的getQueueLength()方法,发现竞争激烈的锁
java复制if (lock.getQueueLength() > 10) {
// 警告:该锁可能存在竞争问题
}
-
避免过度同步:我重构过一个系统,将"先查缓存再查数据库"的双重检查逻辑中的锁范围缩小,使QPS从200提升到1200
-
偏向锁优化:虽然这是JVM层面的优化,但了解它有助于理解锁的性能特征。偏向锁适合几乎没有竞争的场景,而ReentrantLock在竞争激烈时表现更好
-
上下文切换成本:在高并发下,我通过减少锁持有时间,将线程上下文切换次数从5000次/秒降到800次/秒,CPU使用率从90%降到60%
8. 源码层面的实现窥探
理解AQS(AbstractQueuedSynchronizer)是掌握ReentrantLock的关键。虽然面试通常不要求深入到这个程度,但了解这些有助于解决复杂问题:
-
状态变量:AQS使用一个volatile int state表示锁的状态,对ReentrantLock来说,state=0表示未锁定,>0表示锁定次数
-
CLH队列:AQS使用CLH变体的FIFO队列管理等待线程,这是实现公平性的基础
-
模板方法模式:AQS定义了acquire/release的骨架,子类实现tryAcquire/tryRelease等特定操作
我曾通过重写AQS实现过一个特殊的读写锁,允许写锁降级为读锁但不允许升级,这在某些金融场景很有用。这种深度定制是synchronized无法实现的。
