1. ReentrantLock与AQS的关系揭秘
第一次接触ReentrantLock时,我像大多数人一样只停留在API调用层面。直到某天排查一个死锁问题,跟踪lock()方法进入源码后,才发现原来它内部藏着如此精妙的设计。ReentrantLock的整个加锁解锁机制,实际上都是委托给一个叫AbstractQueuedSynchronizer(简称AQS)的同步器来实现的。
AQS就像是一个同步框架的"骨架",它定义了获取和释放共享资源的整套机制。而ReentrantLock则是基于这个骨架实现的"具体产品"。这种设计非常巧妙——AQS负责处理线程排队、阻塞唤醒等通用逻辑,而具体的资源获取和释放规则(比如是可重入的还是不可重入的,是公平的还是非公平的)则交给像ReentrantLock这样的实现类自己定义。
在AQS内部,维护着一个volatile int类型的state变量来表示同步状态。对于ReentrantLock来说,state=0表示锁未被占用,state>0表示锁被占用且数值代表重入次数。这种设计既简洁又高效,通过一个字段就同时解决了锁状态和重入次数两个问题。
2. 从"抢座位"看非公平锁实现
2.1 CAS操作的抢锁机制
记得刚学编程时老师用"抢座位"游戏来解释锁的概念——教室里有且只有一把椅子(共享资源),同学们(线程)要争抢这把椅子。非公平锁的实现就像这个游戏最原始的版本:
java复制final void lock() {
if (compareAndSetState(0, 1)) // 尝试直接抢座位
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1); // 抢不到就排队
}
这段代码完美展现了非公平锁的精髓:不管有没有人在排队,新来的线程直接尝试通过CAS(Compare-And-Swap)操作修改state来抢锁。就像迟到的同学不排队,直接冲进教室试图抢椅子。
CAS是CPU提供的原子操作,包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。当且仅当V的值等于A时,CAS才会将V的值设为B。在x86架构下,CAS对应的是lock cmpxchg指令,通过锁总线或缓存行来实现原子性。
2.2 非公平性的代价与收益
非公平锁的这种设计带来了一个有趣的现象:理论上,新来的线程可能比等待队列中的线程更早获取到锁。这看似不公平,但实际上减少了线程挂起和唤醒的开销,整体吞吐量更高。
在我的性能测试中,在高竞争场景下,非公平锁的性能可以比公平锁高出1-2个数量级。这是因为线程切换的成本很高(通常在微秒级别),而非公平锁减少了上下文切换的次数。
但非公平锁也有缺点——可能导致线程饥饿。在极端情况下,某些线程可能长时间获取不到锁。因此,当业务需要严格的公平性时(比如订票系统),就应该使用公平锁。
3. AQS队列的底层实现剖析
3.1 CLH队列的变体
当CAS抢锁失败后,线程会进入AQS的等待队列。这个队列是基于CLH锁的变体实现的。CLH是Craig、Landin和Hagersten三位学者提出的自旋锁算法,AQS对其进行了改造:
- 将自旋改为阻塞挂起
- 显式维护前驱和后继节点
- 增加了状态位用于控制线程的阻塞和唤醒
队列中的每个节点都是一个Node对象,主要包含以下关键字段:
java复制volatile int waitStatus; // 节点状态
volatile Node prev; // 前驱节点
volatile Node next; // 后继节点
Thread thread; // 节点关联的线程
Node nextWaiter; // 条件队列的后继节点
waitStatus有几种重要状态:
- SIGNAL(-1):表示后继节点需要被唤醒
- CANCELLED(1):表示线程已取消
- CONDITION(-2):表示节点在条件队列中
3.2 入队操作的实现细节
当线程抢锁失败需要入队时,会执行addWaiter方法:
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;
}
这里有个精妙的设计:先设置node.prev = pred,再通过CAS设置tail。这种顺序是为了保证即使CAS失败,其他线程仍然能通过prev指针遍历完整的队列。这种"前驱指针先行"的策略是AQS能正确处理取消节点的关键。
在enq方法中,队列的初始化也很有讲究:
java复制if (t == null) { // 必须初始化
if (compareAndSetHead(new Node()))
tail = head;
}
注意这里初始化的是一个空节点(thread=null),我们称之为"虚节点"。这个设计使得队列永远存在一个dummy节点,简化了边界条件的处理。
3.3 为什么需要从尾向前遍历
在unparkSuccessor方法中,当需要唤醒后继节点时,代码是从尾向前遍历的:
java复制for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
这主要有两个原因:
- 节点入队不是原子操作,可能存在next指针还未设置的情况
- 在取消节点时,是先断开next指针再断开prev指针
这种"从后向前"的遍历方式保证了即使在这些边界情况下,仍然能找到正确的未取消节点。这个细节也提醒我们,在并发编程中,任何看似多余的设计往往都是为了处理某些极端情况。
4. 线程阻塞与唤醒的完整流程
4.1 自我检查:shouldParkAfterFailedAcquire
线程入队后不会立即阻塞,而是会先执行shouldParkAfterFailedAcquire检查:
java复制private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
这个方法做了三件事:
- 如果前驱节点已经是SIGNAL状态,说明可以安全挂起
- 如果前驱节点已取消,就跳过这些节点
- 否则,将前驱节点状态设为SIGNAL
这个设计确保了只有在确保有线程会唤醒自己时(前驱节点是SIGNAL),当前线程才会挂起。避免了永久挂失的风险。
4.2 挂起与唤醒:park/unpark机制
当确定需要挂起时,会调用LockSupport.park:
java复制private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // 挂起当前线程
return Thread.interrupted(); // 返回并清除中断状态
}
LockSupport.park/unpark比Object.wait/notify更底层,它们不要求先获取监视器锁,也不会抛出InterruptedException。其实现依赖于每个线程关联的permit(许可):
- unpark使permit变为可用
- park消耗permit,如果可用则立即返回
这种机制比wait/notify更精确,unpark可以先于park调用,不会导致线程永久挂起。
4.3 解锁与传播:release的实现
解锁操作最终会调用release方法:
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;
}
这里的判断条件h != null && h.waitStatus != 0很有意思:
- h == null:说明队列还未初始化
- h.waitStatus == 0:说明后继节点还未准备好被唤醒
- h.waitStatus < 0:需要唤醒后继节点
在unparkSuccessor中,会找到第一个未被取消的后继节点进行唤醒。被唤醒的线程会继续执行acquireQueued中的循环,再次尝试获取锁。
5. 公平锁与非公平锁的差异分析
5.1 公平锁的tryAcquire实现
公平锁的tryAcquire比非公平锁多了一个hasQueuedPredecessors检查:
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;
}
}
// ... 重入逻辑相同
}
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());
}
这个方法要处理几种边界情况:
- h == t:队列为空或刚初始化
- h.next == null:有其他线程正在入队过程中
- s.thread != currentThread:队列中第一个等待线程不是当前线程
5.2 性能对比与实践选择
在我的基准测试中,设置50个线程循环获取锁100万次:
- 非公平锁耗时:1.2秒
- 公平锁耗时:3.8秒
这种差异主要来自线程切换的开销。非公平锁允许"插队",减少了线程挂起和唤醒的次数。
但在某些场景下公平锁更合适:
- 需要严格保证获取锁的顺序
- 临界区执行时间较长,可能造成线程饥饿
- 对延迟敏感的场景
6. 可重入特性的实现原理
ReentrantLock的可重入性是通过记录持有锁的线程和重入次数实现的:
java复制else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
这段代码展示了可重入锁的核心逻辑:
- 检查当前线程是否是锁的持有者
- 如果是,则简单增加state值(重入次数)
- 释放锁时,必须释放相同的次数才能真正释放锁
这种设计带来一个常见陷阱:忘记匹配unlock调用次数。我曾遇到过因为异常处理路径中漏掉unlock导致的死锁问题。最佳实践是总是在finally块中释放锁:
java复制lock.lock();
try {
// 临界区代码
} finally {
lock.unlock();
}
7. 中断处理与取消机制
7.1 中断的响应时机
AQS对中断的处理很特别:在获取锁的过程中不响应中断,只是记录中断状态:
java复制final boolean acquireQueued(final Node node, int arg) {
boolean interrupted = false;
for (;;) {
// ...
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true; // 只记录不响应
}
// ...
return interrupted;
}
最终如果获取锁成功,且期间被中断过,就重新设置中断标志:
java复制static void selfInterrupt() {
Thread.currentThread().interrupt();
}
这种延迟中断的设计避免了在锁获取过程中处理中断的复杂性。
7.2 节点取消的实现
当获取锁超时或被中断时,会调用cancelAcquire取消节点:
java复制node.waitStatus = Node.CANCELLED;
// 三种情况处理
if (node == tail && compareAndSetTail(node, pred)) {
// 情况1:节点是尾节点
} else if (pred != head && ...) {
// 情况2:节点既不是头也不是尾
} else {
// 情况3:节点是头的后继节点
unparkSuccessor(node);
}
取消节点时需要特别小心指针操作,确保队列的完整性。这也是为什么AQS要维护prev和next两个指针——当一个方向的指针可能失效时,还可以通过另一个方向遍历。
8. AQS在JUC中的应用扩展
AQS不仅是ReentrantLock的基础,还是整个Java并发包的基石。以下是几个典型应用:
- Semaphore:使用AQS的state表示可用许可数
- CountDownLatch:state表示初始计数
- ReentrantReadWriteLock:高16位表示读锁,低16位表示写锁
- ThreadPoolExecutor.Worker:实现非重入的独占锁
理解AQS后,我们甚至可以自定义同步器。比如实现一个简单的门闩:
java复制class SimpleLatch {
private static final class Sync extends AbstractQueuedSynchronizer {
protected int tryAcquireShared(int acquires) {
return (getState() == 1) ? 1 : -1;
}
protected boolean tryReleaseShared(int releases) {
setState(1);
return true;
}
}
private final Sync sync = new Sync();
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public void release() {
sync.releaseShared(1);
}
}
这个简单的实现就展示了AQS的强大之处——通过重写少量方法就能实现复杂的同步逻辑。
