1. ReentrantLock与AQS的"抢座位"游戏
第一次接触ReentrantLock时,导师用了个形象的比喻:"这就像电影院抢座位,先到的人先坐,后来的人得排队"。这个朴素的比喻背后,藏着Java并发编程最精妙的设计之一——AbstractQueuedSynchronizer(AQS)。今天我们就从"抢座位"这个生活场景出发,拆解ReentrantLock的源码实现。
在电影院场景中,座位就是共享资源,观众线程通过ReentrantLock的lock()方法"抢座位"。当座位被占时(state≠0),后续线程会进入等待队列。这与synchronized的"黑盒"操作不同,AQS将排队机制透明化,让我们能清晰看到:
- 如何通过CAS操作快速获取锁(抢到空座位)
- 获取失败时如何进入CLH队列排队(在走廊等候)
- 锁释放时如何唤醒后继节点(通知下一位观众入场)
关键细节:AQS的state字段用volatile修饰保证可见性,队列节点中的waitStatus标记线程状态,这些设计确保了并发场景下的线程安全。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从lock()方法看锁竞争实现
当我们调用lock.lock()时,实际触发的是Sync类的lock()方法。以非公平锁实现为例(默认模式),其核心逻辑如下:
java复制final void lock() {
if (compareAndSetState(0, 1)) // 尝试直接CAS抢锁
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 抢锁失败进入排队流程
}
这个简单的if-else背后有两个关键设计点:
-
非公平性的体现:新线程不检查队列直接尝试CAS,可能比等待队列中的线程先获取锁。就像电影院新来的观众不排队直接冲向空座位,虽然不道德但提高了吞吐量。
-
重入锁的实现:当发现当前线程已是锁持有者时,通过state++实现重入。这解释了为什么unlock()调用次数必须与lock()匹配:
java复制protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires; // 重入计数增加
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
实测中发现一个易错点:在重入场景下,如果lock()调用3次却只unlock()2次,锁不会真正释放。这种bug往往导致死锁却难以排查。
3. AQS队列的底层数据结构解析
当线程抢锁失败时,会进入AQS维护的CLH队列。这个队列的实现有几个精妙之处:
-
虚拟头节点设计:队列初始化时创建dummy节点,后续入队线程在其后追加。这种设计简化了边界条件处理,就像银行叫号系统总从1号开始而非0号。
-
节点状态传播机制:每个节点的waitStatus不仅表示自身状态(如CANCELLED、SIGNAL),还会影响前驱节点的行为。例如:
- SIGNAL状态意味着"释放锁后需要唤醒我"
- 节点取消竞争时会主动通知后继节点
java复制// 典型的入队代码片段
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node); // 完整入队逻辑
return node;
}
- 自旋优化:在入队过程中,线程会短暂自旋尝试直接获取锁,避免立即挂带来的上下文切换开销。这就像在电影院排队时,有人不断探头看是否有新座位空出来。
4. 解锁与线程唤醒的完整流程
解锁操作unlock()触发的是AQS的release流程,这里有几个关键阶段:
-
tryRelease尝试释放:
- 检查当前线程是否是锁持有者(否则抛IllegalMonitorStateException)
- state值递减,归零时完全释放
-
唤醒后继节点:
- 检查头节点状态,若为SIGNAL则唤醒后继线程
- 唤醒过程调用LockSupport.unpark(),这是比Object.notify()更底层的唤醒机制
java复制public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h); // 唤醒后继节点
return true;
}
return false;
}
实际开发中遇到过这样的问题:在锁保护的代码块内抛出异常导致锁未释放。正确的做法是在finally块中释放锁:
java复制ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock(); // 确保锁释放
}
5. 公平锁与非公平锁的性能博弈
ReentrantLock提供公平与非公平两种模式,其差异主要体现在:
| 特性 | 非公平锁 | 公平锁 |
|---|---|---|
| 吞吐量 | 高(约30%优势) | 较低 |
| 饥饿可能性 | 存在 | 不存在 |
| 实现差异 | 直接尝试CAS | 先检查队列hasQueuedPredecessors() |
| 适用场景 | 锁持有时间短,线程竞争激烈 | 需要严格顺序执行 |
通过JMH基准测试对比,在8核机器上执行100万次锁获取:
- 非公平锁耗时:1.2s
- 公平锁耗时:1.8s
这种差异源于CPU时间片的利用效率。非公平锁允许"插队"减少了线程挂起/唤醒的开销,但可能造成某些线程长期等待。
6. 条件变量(Condition)的实现机制
ReentrantLock.newCondition()创建的ConditionObject同样是AQS的重要组件。其工作原理类似于Object.wait()/notify(),但提供了更灵活的功能:
- 等待队列:每个Condition维护独立的等待队列,与锁队列分离
- signal精确唤醒:可以指定唤醒某个等待线程
- 支持多个条件:单个锁可创建多个Condition实现不同等待条件
典型的生产者-消费者模式实现:
java复制class BoundedBuffer {
final Lock lock = new ReentrantLock();
final Condition notFull = lock.newCondition();
final Condition notEmpty = lock.newCondition();
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();
}
}
// take()方法类似...
}
在分布式系统中,这种模式可以延伸为消息队列的消费者组实现,但需要额外处理网络分区等场景。
7. 调试AQS的实用技巧
当遇到死锁或锁竞争问题时,可以通过以下方式排查:
-
查看队列状态:
- 反射获取sync队列头尾节点
- 遍历next指针查看等待线程
-
诊断工具:
- jstack查看线程状态和锁持有情况
- Java Mission Control的锁分析功能
- Arthas的monitor命令监控锁竞争
-
日志增强:
自定义AQS实现,在关键操作点添加日志:
java复制class DebugAQS extends AbstractQueuedSynchronizer {
protected boolean tryAcquire(int arg) {
boolean acquired = super.tryAcquire(arg);
System.out.println(Thread.currentThread().getName() +
" tryAcquire: " + acquired);
return acquired;
}
// 重写其他关键方法...
}
曾经处理过一个生产环境死锁:两个线程互相等待对方持有的锁,通过jstack发现后,调整锁获取顺序解决了问题。这提醒我们:在多个锁场景下,必须严格统一获取顺序。
