共享报价缓存已经用锁保护起来,但产品又提出两个要求:等待锁超过预算就使用旧快照,请求被取消时尽快放弃等待。单纯的 synchronized 进入操作不提供这两个接口,ReentrantLock 因而成为一个合适的候选。
第五篇沿一次获取、排队和释放过程理解 AQS。目标是把公开 API 的行为与内部职责对应起来,而不是背诵所有私有方法。源码固定为 OpenJDK 的 jdk-21+35 标签,运行实验使用本机 Java 21.0.1;版本细节与对外契约分别标明。
从正确的使用方式进入源码
if (lock.tryLock(remainingNanos, TimeUnit.NANOSECONDS)) {
try {
return cache.snapshot();
} finally {
lock.unlock();
}
}
return staleSnapshot;
只有获取成功才进入释放路径。若改用 lockInterruptibly,同样应该在获取成功后才建立对应的 finally。否则,获取过程中因中断失败却仍然 unlock,可能释放一个自己根本没有持有的锁,掩盖原来的错误。
这段代码已经包含两个业务决策:等待多久,以及失败后返回什么。AQS 负责实现同步机制,不会替应用确定陈旧快照能否接受,也不会自动把旧价格标记为降级数据。源码分析应服务于这些决策,而不是取代它们。
ReentrantLock 与 AQS 分别负责什么
ReentrantLock 提供用户调用的锁接口,内部通过同步器子类实现具体策略。AQS 提供同步状态、等待节点以及获取失败后的排队、阻塞、取消和唤醒框架。两者是组合关系,不是所有锁都直接把同一种 state 解释为同一种业务含义。
在 ReentrantLock 中,state 可以表示重入持有次数,并配合拥有者信息判断当前线程是否可以再次获取。同一框架被其他同步器使用时,state 可能表示剩余许可或倒计时。阅读代码时应先找子类如何定义成功、失败和释放完成,再看通用框架怎样处理这些结果。
不要把 AQS 叫作整个 JUC 所有类的共同实现。它是构建多种同步器的重要基础,但 CompletableFuture、并发容器、StampedLock 等都有自己的设计。把工具包理解成一个单一继承树,会在后续源码阅读中形成错误预期。
没有竞争时尽早完成
对于一次新的获取,非公平策略允许线程先尝试直接取得空闲锁。成功时记录拥有者并进入临界区,无需先把每个调用都加入等待队列。对已经拥有锁的线程,则按可重入规则增加持有次数。
公平策略会考虑已有排队者,减少后来者直接抢占的机会,但公平不等于操作系统严格按线程到达时间调度,也不等于每个线程耗时完全相同。特别是某些 tryLock 调用有独立的公平性约定,不能只看构造函数传入了 true 就概括所有获取方式。ReentrantLock 文档需要与调用方法一起阅读。
快速路径对理解性能有帮助,却不能作为无限放大临界区的理由。线程最终仍要执行受保护代码,慢 I/O、复杂回调或长时间计算会延长占用,直接影响所有竞争者的等待预算。
获取失败后为什么还要重新尝试
第一次尝试失败,只能说明当时无法获取。线程准备节点、连接队列和真正阻塞之间,拥有者可能已经释放了锁。框架需要围绕当前状态再次检查,协调节点等待标记与唤醒,避免错过发生在边界上的释放。
OpenJDK 21 的 AQS 使用带前后关联的等待节点来管理竞争者,并在 acquire 的循环中处理重新尝试、节点连接、等待和取消。队列提供等待管理,但“已经排队”不代表“已经得到资源”,真正进入临界区仍以获取策略成功为准。
读源码时可以把变量分为三类:同步资源状态、节点在队列中的关联、节点的等待或取消状态。把三者都简单称为“锁状态”,容易误解某个 CAS 到底是在抢业务资源,还是仅仅在维护队列链接。
唤醒只意味着可以继续竞争
拥有者调用 unlock 时,重入计数减少。只有完全释放时,才满足唤醒后续等待者的相应条件。内部通过节点状态协调和 LockSupport 唤醒,让等待线程有机会重新运行;这不是在所有策略下都把锁的所有权直接转交给被叫醒的线程。
被唤醒后,线程还要重新检查并尝试获取。如果其他线程先成功,当前线程可能继续等待。这就是为什么理解 park 返回与条件成立的区别非常重要:低层唤醒事件不能直接等同于高层资源分配成功。
本文只用行为实验确认“持有者释放后,排队者最终完成”。没有通过一次日志顺序来证明公平性,也不读取内部字段强行匹配一个排队轨迹。源码负责解释机制,公开 API 和可观察结果负责验证实验的实际范围。
旧教程的方法名为什么可能对不上
导图中的 addWaiter、acquireQueued、shouldParkAfterFailedAcquire 与 unparkSuccessor 等调用链,来自较早的 AQS 实现组织方式。OpenJDK 21 的节点类型、状态处理和 acquire 循环已经不同,不能在一篇文章里把旧截图与新源码拼成同一条执行路径。
本篇以 OpenJDK 21 AQS 源码及同标签的 ReentrantLock 为依据。建议阅读顺序是公开获取方法、同步器策略、AQS acquire 循环、release 和 signalNext。只取解释当前问题所需片段,保留官方链接供完整追踪。
旧实现仍有学习价值,尤其能帮助理解演进动机,但方法名和字段常量并非对外 API。维护业务代码应依赖稳定契约;只有诊断特定 JDK 的问题时,才把对应补丁版本的实现作为直接证据。
中断等待与超时等待是不同出口
lockInterruptibly 允许等待获取的线程响应中断。实验先让主线程持有锁,再确认工作线程进入队列,随后发出中断;工作线程捕获 InterruptedException 并结束,而主线程始终保持持有,避免把正常获取误当作取消成功。
超时实验则让工作线程调用带时间参数的 tryLock,主线程在它返回前持续持有锁。断言是返回 false,不是“必须恰好五十毫秒返回”。操作系统调度和计时精度会影响观察时长,但不能改变获取未成功这一结果。
普通 lock 的中断处理不能直接套用 lockInterruptibly 的退出语义。应用如果要求请求取消后不再继续等锁,就应在接口层作出明确选择,而不是假设所有获取操作都会因 interrupt 立即抛异常。
读写锁解决的是访问模式
ReentrantReadWriteLock 允许多个读者共享读取,并让写入与读写互斥,适用于读操作需要一致状态、读多写少且临界区成本值得评估的场景。读锁并不是“没有任何成本”,写者等待、锁管理和访问粒度都会影响结果。
锁降级与升级也要分别分析:写者在仍持有写锁时获取读锁,再释放写锁,可以保持必要保护;普通读者不能靠直接请求写锁完成普遍安全的升级,否则可能与其他读者相互等待。需要升级时,应设计释放、重新获取与状态复核协议。
本篇不展开它的完整源码,而是把重点留在共同问题上:允许哪些访问同时进行,哪个阶段必须重新验证状态,以及超时或取消时怎样退出。没有真实读写比例与数据规模时,不预先宣称读写锁一定更快。
StampedLock 的乐观读要验证结果
StampedLock 的乐观读先取得戳记,把需要的数据读到局部变量,再检查期间是否发生写入。校验失败就丢弃候选结果,并通过读锁重新读取。乐观读期间可能观察到不一致的字段组合,因此不能在校验之前执行不可逆的业务副作用。
实验获得乐观读戳记后执行一次写锁获取与释放,确认旧戳记无效,再走读锁后备路径。它只验证戳记失效与后备获取,没有模拟完整缓存,也没有测量吞吐。StampedLock 不可重入,不能当作 ReentrantReadWriteLock 的直接替换品。StampedLock API
对于少量字段的快照,还可以比较不可变对象加原子引用的方案。选择同步组件应围绕状态规模、读写行为和失败策略,而不是根据名字判断哪一种更高级。
运行五项实验并解释边界
python3 run.py --work /tmp/java-concurrency-run --group AqsLab
本机五项通过:四线程可重入计数八万,内部持有次数为二;两个等待者在拥有者释放后完成;中断获取退出;超时获取返回 false;乐观读戳记失效并成功获取后备读锁。结果与参数见实测摘要。
测试中的队列观察有截止时间,所有任务都读取执行结果,所有释放都在清理路径中。没有要求两个竞争者以固定顺序完成,也没有通过反射读取 state。前者避免调度偶然性,后者让行为测试依赖公开契约而非私有布局。
用三个线程串起一条完整路径
把实验中的主线程看作拥有者,两个工作线程看作缓存读取者。主线程先持有锁,两个读取者尝试获取失败,进入等待管理。此时 state 表示拥有者仍持有资源,队列表示谁还在等待,两者并不是同一份信息的重复记录。
主线程释放后,一个或两个工作线程先后获得运行机会。某个线程成功获取、执行读取并释放,另一个再完成自己的获取。对于默认非公平锁,实验不规定甲乙次序;它要求每个成功进入者都遵守互斥,且在受控的有限持有条件下两者都能完成。测试日志是完成结果,不是对内部每次 CAS 的跟踪。
对应到固定版本源码,可以把路径压缩为四步:ReentrantLock 内部同步器先调用 initialTryLock;失败后进入 AQS 的获取框架;acquire 循环调用 tryAcquire 并协调节点等待;unlock 经 release 和 tryRelease 完成释放,必要时由 signalNext 唤醒后继。这个摘要只解释本篇的独占锁场景,不代表共享同步器使用完全相同的策略。
其中最容易漏掉的是取消路径。节点等待时可能被中断、超时或因异常退出,队列需要处理不再参与竞争的节点,防止后继一直依赖一个已离开的前驱。正因如此,自己写一个“CAS 失败就 park,释放就 unpark”的锁很难覆盖完整行为。业务通常应复用成熟组件,而不是把教学示意直接投入生产。
诊断时也应区分获取前超时、进入临界区后执行慢、以及忘记释放三类问题。它们可能都表现为请求迟迟不返回,修复方式却不同。记录等待时长、持有时长和具体锁保护的操作,比只增加线程池大小更有助于定位原因。
面试归纳
可以把 AQS 解释为“同步状态与等待管理的框架”,再用 ReentrantLock 说明子类定义获取和释放语义,框架处理竞争失败后的排队与唤醒。最后补充三个边界:FIFO 等待结构不等于所有 API 都严格公平,唤醒不等于立即得到锁,源码细节必须注明 JDK 版本。