ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入理解AQS:从ReentrantLock到JUC并发工具的核心原理

深入理解AQS:从ReentrantLock到JUC并发工具的核心原理 1. 为什么并发编程绕不开AQS——从一把可重入锁说起先抛一个很多人都有的疑惑Java里synchronized用了这么多年用得好好的为什么JUC包还要搞出个ReentrantLock更关键的是ReentrantLock的实现核心——AbstractQueuedSynchronizerAQS——凭什么能成为整个Java并发工具的基石连CountDownLatch、Semaphore、ThreadPoolExecutor都离不开它这个问题的答案恰恰藏在一个最容易被忽视的事实里synchronized是JVM层面实现的隐式锁而ReentrantLock是JDK层面基于AQS实现的显式锁。两者不是替代关系但AQS代表的这套状态位等待队列模板方法的设计思想才是理解现代Java并发编程的一把万能钥匙。我没法回避一个事实这些年我面试过的候选人里能讲清楚ReentrantLock用法的至少有一半但能说明白AQS内部获取锁失败后线程到底去了哪里、被谁唤醒、唤醒后干什么的可能两成都不到。我自己第一次硬啃AQS源码的时候也是一头雾水tryAcquire、acquireQueued、hasQueuedPredecessors……这些方法名一个比一个抽象看三遍都记不住。后来我换了个思路把ReentrantLock当成一个现成的例子反推AQS的设计意图一切才豁然开朗。所以这篇文章我不想上来就贴一堆源码让你背而是带着你从一把锁应该具备哪些能力出发一点一点把AQS的骨架搭起来再用源码级别的细节去验证这套骨架长什么样。在正式进入源码之前先约定一下这篇文章的阅读路径先看ReentrantLock的基本使用和它抛出的三个核心问题再看AQS的内部资产state、CLH队列、模板方法然后逐行拆解lock/unlock的完整链路接着讲Condition的实现怎么把锁和条件等待缝在一起最后用AQS视角重新审视整个JUC工具族并分享一些我踩过的坑。适合谁来看如果你正在准备Java面试这篇文章能帮你把八股文变成真正理解后的表达如果你已经在写并发代码但总感觉心里没底这篇文章能帮你建立一套完整的锁运作心智模型如果你纯粹是想提升源码阅读能力那跟着走一遍AQS的核心路径比看一百篇源码分析帖都管用。2. ReentrantLock抛出的三个问题正好是AQS要回答的三件事很多人学ReentrantLock只知道它可以替代synchronized支持公平锁响应中断还可以超时。但往深了问一句它是怎么做到的就卡壳了。其实你只要把ReentrantLock当成一个黑盒观察它的行为就能反推出AQS必须解决的三个核心问题。2.1 问题一怎么安全地记录锁被谁持有ReentrantLock是互斥锁同一时刻只有一个线程能持有锁。这就要求有一个数据结构来记录两件事锁当前被哪个线程持有以及这个线程重入了几次。你会不会觉得这太简单了一个Thread类型的字段指向持有者再加一个int计数器不就完了但问题是多个线程同时来抢锁的时候这个字段的修改必须是原子的。从锁的语义上讲第一个抢到锁的线程应该记录持有者正在持有锁的线程再次加锁应该让计数器加一而不是阻塞自己持有锁的线程解锁一次计数器减一减到零才真正释放锁。所以这个状态必须具备两个特性一是原子可见性所有线程都能读到最新的合法值二是数值语义在互斥锁这里它表示重入次数在其他同步器里它可以表示剩余许可数或计数器的当前值。这引出了AQS的第一个答案用volatile int state字段配合compareAndSetCAS操作来原子更新。CAS是CPU指令级别的原子操作保证比较并交换这一个动作不可被中断线程在执行CAS时不会出现读到一个中间状态的情况。2.2 问题二抢不到锁的线程该排到哪里去互斥锁必须保证拿到锁的线程只有一个其余线程不能乱跑。那拿不到锁的线程应该做什么几件事不能做不能直接自旋死等。如果持锁线程要执行很重的业务逻辑其他线程全部在那儿空转CPU会被白白烧光在大量线程竞争的场景下直接拖垮整个应用不能简单地sleep。你不知道持锁线程何时释放锁睡多久都不合适睡少了浪费CPU睡多了增加响应延迟不能直接挂起然后不管。持锁线程释放锁后必须有一种机制能把等待线程精准地唤醒而且是按照某种合理的顺序唤醒比如先进先出或优先级。最稳妥的方案就是把抢锁失败的线程封装成一个节点放进一个线程安全的队列里让线程进入阻塞状态等待前驱节点释放锁之后再被通知。这引出了AQS的第二个答案CLH变体队列。AQS内部维护了一个由Node节点组成的FIFO双向队列每个节点包着一个等待中的线程。注意经典的CLH锁原始版本是自旋锁AQS并没有照搬原始设计而是改造成了自旋CAS阻塞唤醒的组合方案专门为LockSupport.park/unpark这类显式阻塞原语服务。2.3 问题三这些逻辑怎么让不同同步器共用同样一套原子状态等待队列阻塞唤醒的骨架ReentrantLock可以做成互斥锁Semaphore可以做成共享锁CountDownLatch可以用它做倒计数门闩。区别只在于什么条件下允许通过这个判断逻辑不同。如果AQS把所有逻辑都写死成一个具体类那这些并发工具就没法复用了。AQS给出的解法是模板方法模式把获取同步状态释放同步状态判断是否独占/共享这些步骤定义为模板方法让子类去覆盖对应的tryAcquire/tryRelease独占模式或tryAcquireShared/tryReleaseShared共享模式。子类只需要回答这个状态下我能不能通过至于不能通过时入队入队后阻塞被唤醒后重新尝试队列如何维护这些通用流程AQS全部帮你搞定。打个比方AQS就像一家酒店的中央厨房把买菜、切菜、炒菜、洗碗的通用流程全部标准化了。每个同步器只是往中央厨房提交一份菜单tryAcquire等钩子方法的实现厨房就按统一流程给你出菜。有的菜单是只有第一个顾客能吃饭独占锁有的是最多十个人能同时吃饭信号量有的是人齐了才能开席门闩但后厨的流水线完全一样。这三个问题对应的答案——volatile state、CLH变体队列、模板方法——构成了AQS的全部骨架。接下来的几节我们就围绕这三个答案逐个深入。3. 深入AQS骨架state字段、CLH变体队列与模板方法的精妙配合如果你打开AQS的源码第一个让你注意到的成员变量就是private volatile int state。别小看这个字段它是整个同步器的灵魂。我一直跟同事说看懂AQS只需要弄明白三件事state怎么变、队列怎么排、钩子怎么调。这三个东西配合起来就是一套完整的并发同步机制。3.1 state字段既是锁状态也是资源数量state在AQS里是一个32位的有符号整数用volatile修饰保证多线程可见性。它本身没有任何业务含义具体表示什么完全由子类决定在ReentrantLock里state表示重入次数。0表示没有线程持有锁1表示某个线程持有了锁一次N表示同一个线程重入了N次在Semaphore里state表示剩余许可数量每次acquire获取许可时减一每次release释放许可时加一在CountDownLatch里state表示还要等待的计数在ReentrantReadWriteLock里state被拆成了高16位和低16位分别表示读锁数量与写锁状态。修改state必须走AQS提供的getState、setState和compareAndSetState方法。其中前两个方法读和写是分离的在修改时必须用compareAndSetState来原子更新如果子类使用的是setState直接赋值必须保证没有并发竞争比如释放锁时因为只有持锁线程才有资格释放就可以安全地用非原子的setState。这里有一个工程上的细节CAS失败的线程不会重试成功。CAS比较的是内存中的实际值与预期值预期值变了就失败调用方需要自行决定下一步。AQS的模板方法acquire里tryAcquire是一个返回boolean的钩子如果返回falseAQS就把当前线程入队——所以CAS失败的全部代价只是多走了一次入队流程没有死循环风险。3.2 CLH变体队列等待线程是怎么排队的先讲一个历史背景。AQS的前身是AbstractQueuedSynchronizer它的队列模型脱胎于CLH锁全称是Craig-Landin-Hagersten由三位计算机科学家提出。原始的CLH锁是一个隐式链表结构的自旋锁每个等待线程通过轮询前驱节点的状态来自旋等待。AQS引入了显式的双向队列和Nodestatus思想把前驱节点变成阻塞唤醒而不是自旋轮询这是它与原始CLH最大的区别。AQS内部的Node对象主要有这些字段字段作用关键值thread当前节点包装的线程null表示该节点可能是头节点或已取消节点waitStatus节点等待状态CANCELLED(1)、SIGNAL(-1)、CONDITION(-2)、PROPAGATE(-3)、0(初始)prev/next双向链表的前驱与后继用于插入、删除、唤醒传播nextWaiter条件队列中的下一个节点在条件队列中指向下一个等待节点在同步队列中标记独占/共享模式你需要注意一个大方向同步队列是FIFO双向队列新来的竞争线程统一addWaiter到队尾然后头节点是当前持有锁的线程虽然持锁线程在释放后不会立即从队列里摘除但逻辑上它代表正在持锁后继节点才是排队等待的线程。CLH变体队列最让人困惑的是waitStatusSIGNAL的语义。它不是说当前节点被signal了而是说当前节点的后继节点应该被唤醒。也就是说当一个节点释放锁或取消等待时它要检查自己的waitStatus是不是SIGNAL如果是就唤醒自己的后继节点。这个约定避免了释放锁时不知道要唤醒谁的问题。3.3 模板方法AQS怎么把判断和执行分开看AQS源码时你会看到两类方法模板方法已实现一般不用重写acquire、acquireInterruptibly、tryAcquireNanos、release、acquireShared、acquireSharedInterruptibly、tryAcquireSharedNanos、releaseShared这些方法实现了尝试获取/释放、入队、park/unpark的通用流程钩子方法未实现或提供默认实现需要子类覆盖tryAcquire(int arg)、tryRelease(int arg)、tryAcquireShared(int args)、tryReleaseShared(int args)、isHeldExclusively()。拿acquire方法来看这是独占模式获取状态的顶层入口public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }这段代码只有三行却浓缩了完整的获取流程先调用子类的tryAcquire尝试立即获取同步状态成功就完事失败则通过addWaiter(Node.EXCLUSIVE)把当前线程封装成独占节点放入队列尾部再通过acquireQueued让节点在队列中循环地尝试获取-阻塞-被唤醒-再尝试获取如果acquireQueued返回true说明线程在等待过程中被中断过则补设中断标志位selfInterrupt()。这种设计的精妙之处在于AQS不关心你的同步器是锁还是计数器它只负责搭建一套通用的等待/唤醒流水线。你实现tryAcquire时该怎么判断能不能拿到锁AQS全部让给你这保证了框架的灵活度。为了让你更直观地理解这套配合我画出一个简化版的执行流程纯文字描述用户线程调用 lock() └─ acquire(1) ├─ tryAcquire(1) 成功? → 持有锁返回 └─ 失败 → addWaiter(Node.EXCLUSIVE) 入队尾 └─ acquireQueued(node, 1) ├─ 前驱是头节点且 tryAcquire(1) 成功? │ ├─ 是 → 设置自身为头节点返回中断状态 │ └─ 否 → shouldParkAfterFailedAcquire 判断能否park │ ├─ 能 → LockSupport.park 阻塞 │ └─ 被唤醒 → 继续循环 └─ 中断检查 → 若中断则返回true4. 源码级拆解一次lock/unlock的完整旅行有了前面的整体认知我们现在把ReentrantLock的代码一块一块抠开。先申明一下我下面的源码描述基于JDK 8以后的实现不同小版本可能略有微调但核心逻辑十年没变过。4.1 lock()非公平锁的抢与公平锁的让ReentrantLock默认构造是非公平锁直接看它的lockfinal void lock() { // 非公平锁的第一步不管队列里有没有人在排队先CAS抢一次 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }非公平锁的非公平体现在第一行它不会先检查等待队列里有没有人排队直接CAS抢锁。如果抢成功了哪怕队列里有个线程已经等了快一小时你也插队领先了。这看起来对老线程不友好但它减少了一次线程阻塞/唤醒的上下文切换开销吞吐量通常更高。公平锁就温和多了它的lock直接走acquire(1)而在tryAcquire中会调用hasQueuedPredecessors来检查前面有没有等待者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; }这段代码就是ReentrantLock自己实现的钩子方法跟AQS框架解耦得很清楚。逻辑分两支当前无锁c 0公平锁要求先确认队列中没有等待者hasQueuedPredecessors返回false然后CAS把状态从0改成acquires通常就是1成功后把当前线程记为持有者当前线程已持有锁current getExclusiveOwnerThread()说明是一次重入直接setState(c acquires)。这里不需要CAS因为只有持锁线程自己才能走到这个分支不存在竞争。注意一个细节重入时检查nextc 0防止重入次数溢出成负数。实际中你几乎遇不到这种溢出但源码考虑到了面试时能说出来绝对是加分项。4.2 acquireQueued入队之后线程的排队人生假设tryAcquire失败线程被包装成节点丢进队列尾部接着进入acquireQueuedfinal boolean acquireQueued(final Node node, int arg) { boolean failed true; try { boolean interrupted false; for (;;) { final Node p node.predecessor(); if (p head tryAcquire(arg)) { setHead(node); p.next null; failed false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; } } finally { if (failed) cancelAcquire(node); } }这个循环是核心中的核心。每次循环做两件事检查当前节点的前驱是不是头节点。如果是说明轮到我尝试了再调一次tryAcquire成功就让自己成为新的头节点setHead会把节点里的thread清空表示这个节点现在代表持锁线程不再是一个排队线程旧的头节点的next指向null帮助GC如果前驱不是头节点或者尝试获取失败就进入判断是不是该把自己挂起了。shouldParkAfterFailedAcquire会前驱节点的waitStatus改成SIGNAL这样当前驱释放时就会唤醒自己。如果前驱节点已被取消CANCELLED就跳过这些失效节点找到前面第一个存活的有效节点。parkAndCheckInterrupt是真正让线程睡眠的地方private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); }LockSupport.park会让当前线程进入WAITING状态。它不像Object.wait那样需要先持有某个对象的监视器锁这就是ReentrantLock能在没有synchronized块的情况下实现阻塞的原因。当持有锁的线程释放锁后会调用LockSupport.unpark唤醒头节点的后继节点。被唤醒的节点从park返回Thread.interrupted()会清掉线程的中断状态并返回是否被中断过如果被中断就把interrupted标记为true但循环还会继续线程仍然会继续尝试获取锁——这就是lock不响应中断的体现。你要用lockInterruptibly()才能真正在阻塞期间响应中断并抛异常。4.3 unlock()释放锁与唤醒后继的完整链路unlock走到Release方法public final boolean release(int arg) { if (tryRelease(arg)) { Node h head; if (h ! null h.waitStatus ! 0) unparkSuccessor(h); return true; } return false; }先看tryRelease这是ReentrantLock实现的钩子protected final boolean tryRelease(int releases) { int c getState() - releases; if (Thread.currentThread() ! getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free false; if (c 0) { free true; setExclusiveOwnerThread(null); } setState(c); return free; }注意一个关键点tryRelease只工作到state减到0为止。如果一个线程重入了3次它必须调用3次unlock才能把state减到0并真正释放锁。在state变0之前tryRelease返回falserelease方法不会去唤醒队列中的等待线程。只有当真正释放锁时free true才会走到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); }这里你可能会问为什么正常应该唤醒node.next但代码还要考虑node.next为null或已取消的情况因为入队操作并非严格保证next指针的一步到位——addWaiter在CAS设置队尾节点时如果CAS失败会走enq自旋在新节点插入过程中偶尔会出现next指针暂时没指向最新尾节点的情况。所以要从tail向前遍历找到离头节点最近的可用节点来唤醒。这个从尾部向前找的逻辑是CLH队列设计里的一个经典细节。4.4 中断、超时与公平性的源码佐证AQS还提供了acquireInterruptibly和tryAcquireNanos这两个变体。两者的核心逻辑跟acquire很像但有区别acquireInterruptibly在尝试获取失败后一旦检测到线程中断会立刻抛出InterruptedException而不是像acquire那样记录中断状态后继续等tryAcquireNanos额外支持超时。它把等待时间换算成System.nanoTime的deadline每次循环都会检查是否超时。如果超时就直接返回false不再入队等待。这个机制很适合限时获取锁的业务场景比如支付回调里最多等锁200毫秒拿不到就快速失败。至于公平锁的公平前面说了核心是hasQueuedPredecessors。它的实现是public final boolean hasQueuedPredecessors() { Node t tail; Node h head; Node s; return h ! t ((s h.next) null || s.thread ! Thread.currentThread()); }这段代码连注释都在强调它可能短暂地返回误判但设计上是有意为之公平锁并不要求绝对精确只要大致保证先来后到即可。两个线程同时调用lock一个正在入队还没完成另一个CAS抢到了锁这在公平锁下也偶尔会发生但这种瑕疵换来的是性能和正确性之间的平衡。5. Condition的实现哲学让锁学会等待与通知如果说state和CLH队列解决了互斥与排队的问题那Condition就是解决条件不满足时线程如何让出锁并等待的问题。它很像synchronized里的wait/notify但功能更强一个ReentrantLock上可以创建多个Condition对象各自维护一条独立的等待队列这在生产者-消费者场景里特别有用。5.1 await与signal如何与锁联动你可能觉得await就是让我睡一会儿但如果真这么简单就错了。Condition.await必须原子地完成三件事把当前线程放入条件队列、释放当前持有的锁、让线程阻塞。如果不释放锁其他线程永远拿不到锁也就永远无法唤醒它会直接死锁。AQS里对应的实现是ConditionObject它内部维护了一个单一的等待队列单向链表通过nextWaiter连接。每一次await调用线程会被封装成一个Node节点waitStatus置为CONDITION然后挂在条件队列尾部。signal方法做的事是把条件队列头部的节点转移到同步队列尾部然后根据情况唤醒。这个转移动作很重要节点从条件队列出来重新变成同步队列里的一个排队节点并继续走尝试获取锁的流程。所以signal并不直接让线程拿到锁它只是告诉同步队列有个节点醒了要重新排队抢锁。5.2 条件队列和同步队列的双队列结构理解AQS的条件机制关键是理解它同时有两个队列同步队列所有渴望获取锁但暂时没抢到的线程排队的地方条件队列已经持有锁但业务条件不满足于是主动让出锁并等待的线程排队的地方。一个线程从await中返回必须经历signal或signalAll/中断/超时→ 从条件队列转移到同步队列 → 在同步队列中不断tryAcquire→ 成功抢到锁 → 返回。这就是为什么await必须在循环里使用的根本原因即使被signal唤醒也有可能被其他线程提前抢走锁唤醒不等于条件已满足。5.3 使用Condition最常见的大坑条件谓词失调下面这个代码片段是我见过好多人在生产环境写出来的错误写法// 错误示范 while (!conditionMet) { condition.await(); } // 正确心态await要放在循环里且循环条件要基于共享状态有人觉得signal返回后就说明条件满足了直接往下走。但想象这个场景队列里有多个消费者在等待生产者调用signal唤醒了一个消费者但恰好另一个消费者插队抢到了锁并消费掉了唯一的消息——被唤醒的线程抢到锁后再看条件发现条件不满足了。如果它不检查条件就往下走就会读到空数据或者重复处理。正确的写法是永远把await放在while循环中lock.lock(); try { while (!isReady()) { condition.await(); } // 条件满足继续业务 } finally { lock.unlock(); }另外还有几个实战要点都是从坑里爬出来的await方法会自动释放锁并在返回前重新获取锁但它只能在没有中断的情况下正常返回。如果你用了await而不是awaitUninterruptibly线程在等待中被中断会抛出InterruptedExceptionsignal和signalAll的区别signal只唤醒条件队列的第一个节点如果有多个线程等待同一个条件用signal可能唤醒了一个检查条件后依然不满足的线程导致它再次阻塞而真正符合条件的线程却被留在队列里。除非你能确定唤醒任意一个都无所谓否则优先用signalAllCondition必须在持有锁的情况下调用await/signal否则抛IllegalMonitorStateException。这个设计跟synchronized要求持锁调用wait一样都是为了确保共享状态的操作与等待/通知之间建立内存屏障。6. 从ReentrantLock一跃而起的JUC工具族state语义的百变魔法一旦你看懂了AQS你会发现ReentrantLock只是它的一个玩具示例。整个java.util.concurrent包的核心同步器几乎都长在AQS的骨架上。6.1 CountDownLatch与Semaphore共享模式的两副面孔CountDownLatch的用法是初始化一个计数NN个线程各自完成后countDown主线程await等待计数归零。它在AQS里的实现非常简洁state初始化为NtryAcquireShared的实现是return (getState() 0) ? 1 : -1;。返回值大于等于0表示获取成功负数表示失败tryReleaseShared的实现是CAS自旋把state减一减到0返回true。所以CountDownLatch不是释放锁而是减少计数。当计数到0时所有在await中阻塞的线程会在releaseShared的传播机制下被逐个唤醒。这个传播机制是AQS共享模式下独有的它通过PROPAGATE状态保证在复杂并发下即使后唤醒的线程还没配置好前驱状态也不会丢唤醒信号。Semaphore则把state当成剩余许可数tryAcquireShared(int acquires)尝试用CAS把state减掉acquires失败返回负数tryReleaseShared(int releases)尝试用CAS把state加上releases。它的等待逻辑跟锁一模一样区别只是获取许可和释放许可的判断对象不同。理解了AQS你根本不用去背Semaphore的源码。6.2 ReentrantReadWriteLock一分为二的state读写锁的实现更是把state的位语义玩到了极致。ReentrantReadWriteLock把32位的state拆成两部分高16位表示读锁持有数低16位表示写锁重入数。一个int字段同时表达两种状态节省空间的同时还保证了读锁和写锁的状态之间的原子可见性。写锁获取的条件是写锁没被持有低16位为0或当前线程已经持有写锁并且读锁没有被持有高16位为0。为什么读锁存在时写锁不能获取因为写锁必须是独占的如果允许读锁还没释放就获取写锁写线程修改的数据可能正在被读线程读取造成数据不一致。这个类还引入了ThreadLocalHoldCounter来记录每个线程的重入读锁次数避免频繁CAS更新高16位造成性能损耗。第一次看到这种位运算拆分时我是真的佩服设计者的极致追求——一个int字段被做出了两个字段的效果。6.3 ThreadPoolExecutor中的WorkerAQS的影武者很多人不知道线程池里的Worker也继承自AQS。它实现了一个不可重入的互斥锁用来保证线程池在shutdown时能安全地中断空闲线程。为什么不用ReentrantLock因为Worker需要的锁非常特殊不可重入。如果任务是重入的那么一个任务在运行中再提交另一个任务给自己执行时就可能重新获得锁干扰线程池的运行状态。Worker自己覆盖了tryAcquire直接用compareAndSetState(0, 1)实现非0即1的互斥语义不记录持有者不需要重入所以state在这里只是一个是否被占用的布尔标记。这再次证明了AQS的灵活性同样的模板方法只要你愿意都能按自己的语义实现。6.4 用AQS手写一个限流器入门到融会贯通如果上面所有的分析还让你觉得哦但都是人家写好的那我建议你亲自动手用AQS实现一个简单的限流器。这个练习能让你真正掌握AQS的两大模式。下面是一个简易同步许可证的实现它内部维护一个最大许可数同一时间只允许N个线程通过import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class SimpleLimiter { private static class Sync extends AbstractQueuedSynchronizer { private final int maxPermits; Sync(int maxPermits) { this.maxPermits maxPermits; setState(maxPermits); } Override protected int tryAcquireShared(int acquires) { for (;;) { int available getState(); int remaining available - acquires; if (remaining 0) { return remaining; } if (compareAndSetState(available, remaining)) { return remaining; } } } Override protected boolean tryReleaseShared(int releases) { for (;;) { int current getState(); int next current releases; if (next maxPermits) { return false; } if (compareAndSetState(current, next)) { return true; } } } } private final Sync sync; public SimpleLimiter(int maxPermits) { sync new Sync(maxPermits); } public void acquire() { sync.acquireShared(1); } public void release() { sync.releaseShared(1); } }你只需要实现tryAcquireShared和tryReleaseShared两个钩子AQS就帮你搞定了许可不足时线程排队释放时唤醒等待线程的所有底层逻辑。写一遍之后再回头去看Semaphore的源码就跟看自己写的东西一样亲切。7. 面试与实战那些让人栽跟头的细节最后一部分我想把眼光从源码抽回来聊一些更接地气的话题面试怎么答、源码怎么读、实际开发怎么避开AQS相关的坑。7.1 面试官真正想听到的AQS答题路径很多人在面试里被问到AQS第一反应就是背一段volatile int state CLH队列 CAS然后面试官追问两句就卡住。我建议你把AQS的回答组织成一个先总后分、由外到内的结构先定义AQS是一个用于构建锁和其他同步器的框架核心是一个volatile int state和一个等待队列配合模板方法模式把同步状态的管理和线程的排队阻塞解耦再讲获取流程以ReentrantLock.lock()为例先说tryAcquire尝试CAS修改state失败后addWaiter入队acquireQueued里循环执行前驱是头节点再次尝试获取的逻辑否则park阻塞讲释放流程tryRelease把state减到0后unparkSuccessor唤醒头节点的后继节点延伸到公平与非公平非公平在lock()开头直接CAS抢一次公平锁则通过hasQueuedPredecessors检查队列是否有等待者最后升华同样的骨架CountDownLatch把state当计数Semaphore把state当许可数ReentrantReadWriteLock把state按位拆成读锁和写锁这就是AQS的设计哲学。按这个路径回答基本能覆盖面试官八成以上的追问。如果面试官问AQS为什么用双向队列、 为什么waitStatus用0作为初始值 、cancelAcquire发生什么就是加分项了。7.2 源码阅读方法论不要从头读到尾要按路径读我见过太多人打开AQS源码就想从第1行读到第1000行结果读一半就放弃。正确的打开方式是按调用路径读第一条路径acquire→tryAcquire→addWaiter→acquireQueued→shouldParkAfterFailedAcquire→parkAndCheckInterrupt第二条路径release→tryRelease→unparkSuccessor第三条路径acquireInterruptibly和tryAcquireNanos观察它们与acquire的差异第四条路径ConditionObject.await和signal重点看条件队列到同步队列的转移过程第五条路径共享模式相关方法acquireShared和releaseShared找一个共享同步器去验证。每次只追一条路径把每条路径上的为什么这么做记下来比读十遍全源码都有效。我迄今还保留了当年手写的AQS调用路径图那段经历对我后来看Netty、RocketMQ里大量的AQS应用都有很大的帮助。7.3 实战避坑清单写并发代码时最容易犯的错这里列几个我在实际项目里踩过或帮别人排查过的坑每一个都能自我介绍我经历过lock()后忘了解锁。老生常谈但永远有人犯。必须lock.lock()和try { ... } finally { lock.unlock(); }配套中间绝对不能有return跳过finally用await()判断条件时不用while。前面已经说过signal唤醒不等于条件满足必须用循环检查共享状态否则会出现伪唤醒或睡过头混淆lockInterruptibly()和lock()的中断响应。lock()在等待锁的过程中不会响应中断它只会记录中断状态。如果你需要被中断就放弃等待要用lockInterruptibly在持锁的情况下调用耗时操作。如果锁内执行了远程调用、大查库、循环计算等耗时操作其他线程都被卡住。AQS队列再优秀也扛不住锁内代码写得烂条件队列与同步队列混为一谈。signal并不是立即唤醒并让线程继续跑它只是把节点从条件队列搬到同步队列。真正的继续跑还要经历抢锁。所以不要假设signal之后await立刻返回对state的理解固定在锁重入次数。很多读者看AQS只见过ReentrantLock遇到CountDownLatch就认不出来了。其实state只是一个同步状态语义由子类自由定义。思维越灵活读源码越轻松。7.4 一个可以进一步探索的方向AbstractOwnableSynchronizer很多人在学AQS时忽略了一个小细节——ReentrantLock的记录持锁线程能力并不是AQS自带的而是来自它的父类AbstractOwnableSynchronizer。这个类提供了setExclusiveOwnerThread和getExclusiveOwnerThread两个方法专门用于跟踪独占模式同步器当前的持有线程。为什么单独拆一个父类出来因为很多同步器如Semaphore根本不需要记录持有者是谁把它放在AQS里面就浪费了。这个拆分也再次体现了JDK作者对职责单一的极致追求。如果你想继续深挖我建议下一步去看ReentrantReadWriteLock的tryAcquire和tryRelease完整实现然后再去看StampedLock注意它不是基于AQS实现的而是基于CLH自旋状态位的自定义实现对比两者的设计异同。这个对比会让你对什么时候该用AQS什么时候该自己控制并发有一个全新的认识。8. 写在最后从背八股到真正理解我始终认为类面试题的关键在于把知识变成理解。AQS不难难的是你愿不愿意花一个下午的时间把ReentrantLock当作一道数学题顺着代码路径一步一步走完。走完这个过程后你会发现原来lock和unlock之间有一个完整的世界有队列、有状态、有阻塞唤醒、有信号传递。如果你现在正卡在源码阅读上我的建议是别贪多就从ReentrantLock的lock/unlock开始配合这篇文章的路径图把每一行代码都搞懂然后在IDE里打个断点跑一个多线程Demo亲眼看一看线程的阻塞和唤醒。最后分享一个小技巧给AQS类的state字段和waitStatus字段分别加一个条件断点比如state ! 0或waitStatus -1在调试窗口里观察队列的节点关系和状态值变化。这种可视化的观察方式比单纯读代码来的深刻得多很可能读一遍源码时想不通的问题在断点下瞬间就通了。
返回列表