☰
深入解析ReentrantLock与AQS的并发实现原理
2026/10/27 8:18:35 网站建设 项目流程

1. ReentrantLock与AQS的关系解析

第一次接触Java并发编程时,很多人都会对ReentrantLock这个"加强版synchronized"产生好奇。但真正让我理解其精髓的,是发现它背后站着一位"隐形裁判"——AbstractQueuedSynchronizer(AQS)。这个隐藏在JDK并发包中的框架,才是支撑ReentrantLock强大功能的幕后英雄。

AQS本质上是一个用于构建锁和同步器的脚手架。它通过一个int成员变量表示同步状态,配合内置的FIFO队列管理竞争线程。当我在调试ReentrantLock的lock()方法时,发现它所有的加锁操作最终都委托给了内部Sync类(AQS的子类)。这种设计就像去餐厅吃饭:我们直接跟服务员(ReentrantLock)打交道,但真正的菜品调度是由后厨(AQS)完成的。

关键认知:ReentrantLock只是AQS的一个"皮肤",所有核心逻辑都在Sync这个内部类中实现。公平锁与非公平锁的区别也仅在于tryAcquire的实现方式不同。

2. 从"抢座位"看锁竞争的本质

想象一下图书馆自习室的场景:当座位充足时,同学们可以直接入座(相当于无竞争获取锁);但当座位紧张时,晚到的同学需要在登记表上排队(同步队列)。这就是ReentrantLock工作的真实写照。

在非公平锁模式下(默认实现),新来的线程会直接尝试"插队"获取锁:

final void lock() { if (compareAndSetState(0, 1)) // 直接尝试CAS抢锁 setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); // 抢锁失败进入AQS队列 }

这种设计虽然可能造成"后来者居上",但大幅减少了线程切换的开销。我在压测中发现,非公平锁在高并发场景下的吞吐量比公平锁高出30%以上。不过对于需要严格顺序的业务场景,就需要使用公平锁:

protected final boolean tryAcquire(int acquires) { // 检查队列中是否有等待线程 if (hasQueuedPredecessors()) return false; // ...后续获取逻辑 }

3. AQS队列的精密运作机制

当线程获取锁失败时,AQS会将线程包装成Node节点加入CLH队列。这个队列的实现充满精妙设计:

  1. 节点状态(waitStatus):不仅表示线程状态,还承担着通信作用。比如SIGNAL(-1)表示"后继节点需要被唤醒",CANCELLED(1)表示"线程已放弃等待"。

  2. 自旋入队:在enq()方法中,通过无限循环+CAS保证节点正确插入队尾。我在测试中故意制造竞争,观察到有时需要旋转5-6次才能成功入队。

  3. 头节点设计:队列的头节点是"虚节点",它代表的线程已经获取到锁。这个设计简化了很多边界条件的处理。

// 典型的获取锁失败处理流程 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. 条件变量的实现奥秘

除了基本的锁功能,ReentrantLock的条件等待机制也值得研究。每个ConditionObject都维护着自己的等待队列,与AQS主队列形成互动:

  1. await()时,线程会释放锁并进入条件队列;
  2. signal()时,将条件队列的头节点转移到AQS主队列;
  3. 只有回到AQS队列后,线程才有机会重新竞争锁。

这种设计完美解决了"等待通知"机制中的丢失唤醒问题。我在实现一个有界队列时,就利用这个特性实现了精确的"队列非满"和"队列非空"通知:

public 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(); // 释放锁并进入等待 // ...入队操作 notEmpty.signal(); } finally { lock.unlock(); } } }

5. 性能优化的关键细节

在阅读源码过程中,我发现了几个影响性能的关键点:

  1. 自旋优化:在shouldParkAfterFailedAcquire()方法中,线程在挂起前会短暂自旋,这能减少上下文切换。但在虚拟化环境中,这个自旋时间可能需要调整。

  2. 取消节点清理:取消竞争的线程会将自己的waitStatus改为CANCELLED,后续线程会帮忙清理这些"僵尸节点"。这解释了为什么我们有时会看到prev指针的跳跃。

  3. 头节点竞争:释放锁时,新老头节点的竞争可能导致不必要的唤醒。这也是为什么unparkSuccessor()方法要从尾部逆向查找有效节点。

// 释放锁后唤醒后继节点的典型流程 private void unparkSuccessor(Node node) { int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); Node s = node.next; if (s == null || s.waitStatus > 0) { s = null; for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) LockSupport.unpark(s.thread); }

6. 实战中的踩坑记录

在真实项目中使用ReentrantLock时,有几个容易忽略的问题:

  1. 锁泄漏:忘记在finally块中释放锁是常见错误。我曾遇到过一个生产事故,因为异常路径没有unlock(),导致系统逐渐失去响应。

  2. 条件误用:不同Condition对象有独立的等待队列。有次我错误地混用notify()和signal(),导致线程永远挂起。

  3. 死锁检测:JDK没有内置的死锁检测机制。后来我通过ThreadMXBean.findDeadlockedThreads()实现了监控:

ThreadMXBean bean = ManagementFactory.getThreadMXBean(); long[] threadIds = bean.findDeadlockedThreads(); if (threadIds != null) { // 处理死锁情况 }
  1. 性能陷阱:在锁粒度控制不当的情况下,使用ReentrantLock可能比synchronized更慢。通过JProfiler分析后,我发现是因为锁范围过大导致竞争加剧。

7. 从AQS看并发设计哲学

深入AQS源码后,我总结了几个并发设计原则:

  1. 状态最小化:AQS仅用32位的state变量就管理了所有同步状态,这种极简设计减少了内存争用。

  2. 乐观重试:几乎所有的状态变更都采用CAS+自旋的方式,这比直接挂起线程更高效。

  3. 责任分离:AQS处理队列管理,子类只需实现tryAcquire等模板方法。这种设计让ReadWriteLock、CountDownLatch等都能复用AQS。

  4. 失败快速:在非公平锁实现中,新请求会立即尝试获取锁,而不是直接排队。这种设计契合大多数实际场景的竞争特点。

在自研分布式锁时,我就借鉴了这些思想。比如使用Redis的WATCH/MULTI实现乐观锁,用Zookeeper的临时节点模拟AQS队列,取得了不错的效果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询