ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ReentrantLock与AQS底层原理:从CAS自旋到CLH队列的并发锁实战

ReentrantLock与AQS底层原理:从CAS自旋到CLH队列的并发锁实战 1. 一个锁的诞生ReentrantLock 到底在解决什么问题在 Java 并发编程里ReentrantLock可能是我们用得最多的显式锁而它背后站着的AQSAbstractQueuedSynchronizer几乎撑起了 JUC 同步工具的半壁江山。面试的时候人人都能说几句CLH 队列state 状态CAS 自旋可真把ReentrantLock的源码丢过来能逐行讲清楚的人并不多。这篇文章我不打算做那种贴一段源码然后翻译一遍的注水讲解而是把整个加锁、排队、解锁、唤醒的链路放在抢座位这个场景里走一遍。你想象一下这样一个画面一群人要进一个只能容纳一人的房间谁先进去谁干活其他人必须在门口排队。ReentrantLock管理的不只是谁进房间还包括谁插队谁排在哪个位置前面的人走了怎么通知后面的人这些细节全部封装在 AQS 的队列机制里。把这套逻辑啃下来你不仅能彻底搞懂ReentrantLock连Semaphore、CountDownLatch、ReentrantReadWriteLock这些 JUC 里的老熟人看源码都会像开了透视一样。这篇内容适合谁适合那些已经会用synchronized和ReentrantLock但一直想搞清楚底层原理的 Java 工程师也适合准备面试、不想只背八股文的同学。我会带着你把源码关键路径一行行拆开讲清楚每个判断为什么存在每个状态为什么这么设计最后再分享一些我在实际排查锁竞争问题时踩过的坑。2. 整体设计拆解为什么 ReentrantLock 要基于 AQS 做2.1 从一个 int 变量开始的同步思路先想一个最朴素的问题要实现一个互斥锁核心需要什么答案是两样东西一个当前是否被占用的标记一个排队等待的机制。ReentrantLock的解决方案非常巧妙。它内部有一个Sync抽象类继承自AQS这个Sync又分出FairSync和NonfairSync两个子类分别对应公平锁和非公平锁。AQS 这个基类提供了一整套排队、阻塞、唤醒的模板方法子类只需要实现两个方法tryAcquire和tryRelease。也就是说锁的语义由子类定义排队等待的机制由 AQS 统一搞定。这种设计最直接的好处是代码复用。你去看看 JUC 包里的CountDownLatch它的Sync实现的是tryAcquireShared和tryReleaseSharedSemaphore也是同样的套路。它们共享的正是 AQS 里那套复杂的等待队列和阻塞唤醒逻辑。所以读懂 AQS等于一次性读懂大半个 JUC 同步工具族。AQS 内部维护了一个volatile int state字段这个字段就是锁状态的标记中心。对于ReentrantLock来说state 0表示锁空闲state 1表示锁被占用而且state的值就是当前线程的重入次数。注意这个细节它是理解可重入的钥匙。2.2 生活化拆解AQS 队列就是一张排队座位表AQS 内部的同步队列是一个双向链表每个节点Node代表一个正在等待锁的线程。我觉得把它想象成奶茶店门口那种人工叫号排队最合适。队首head不是排队的人而是当前正在制作奶茶的窗口。它是个哑元节点代表已经拿到锁的线程或者曾经拿到锁的线程留下的空位。队尾tail最后一个到的人新来的人通过 CAS 把自己挂在 tail 后面。每个节点除了保存线程引用还保存了前驱、后继以及一个waitStatus状态位用来告诉别人我现在什么情况。这个队列的入队操作用的是CAS 自旋先试着把新节点直接接到 tail 上如果失败并发下 tail 被别的线程抢先改了就进入自旋重试。这个细节在源码里体现为enq方法它是整个 AQS 并发安全的第一道闸门。2.3 为什么还需要队列CAS 只管抢不管等CAScompareAndSet解决的只是多个线程同时抢一个值的原子性问题但它解决不了抢不到的人怎么办。如果所有抢锁失败的线程都在一个 while 循环里疯狂 CAS那就是自旋锁。自旋锁在低并发下性能很好一旦线程多了CPU 直接被打满因为每个线程都在空转。AQS 的做法是抢失败的线程先进队列然后调用LockSupport.park把自己挂起释放 CPU。等前一个线程释放锁时再通过LockSupport.unpark精准唤醒队列中的下一个线程。这样既避免了自旋的 CPU 浪费又能保证唤醒的顺序性。这一套排队 阻塞 唤醒的设计才是 AQS 真正值钱的地方。3. 核心数据结构Node 节点与 state 状态机3.1 Node 节点一个线程在这里留下全部信息先看 AQS 内部类Node的几个关键字段static final class Node { // 共享模式 / 独占模式 static final Node SHARED new Node(); static final Node EXCLUSIVE null; // 节点状态 static final int CANCELLED 1; // 线程已取消超时或中断 static final int SIGNAL -1; // 后继线程需要被唤醒 static final int CONDITION -2; // 线程在条件队列中等待 static final int PROPAGATE -3; // 共享锁场景向后传播 volatile int waitStatus; volatile Node prev; volatile Node next; volatile Thread thread; Node nextWaiter; // 条件队列中的后继 }waitStatus是节点状态的灵魂字段我们重点说前三个CANCELLED (1)节点对应的线程因为中断或超时放弃了等待这个节点会被标记为取消后续在队列清理时会被踢出去。SIGNAL (-1)这个状态代表我承诺会唤醒我的后继节点。当一个线程释放锁时它会检查头节点的waitStatus如果发现是SIGNAL就会unpark头节点的后继线程。这是整个唤醒机制能传递下去的关键。CONDITION (-2)这个节点不在同步队列里而是在 Condition 的条件队列里等待 signal。这里有一个非常容易产生误区的点head节点的waitStatus通常不是0。当一个线程入队时它会把自己的前驱节点的waitStatus从0改成SIGNAL通过compareAndSetWaitStatus(prev, 0, Node.SIGNAL)这样才能保证前驱释放锁时一定会来唤醒我。这个前驱为我传话的机制就是整条唤醒链能成立的基础。3.2 为什么 tail 要用 volatile CAS而不是 synchronizedAQS 的并发安全不依赖synchronized而是volatile CAS。以入队操作为例private Node enq(final Node node) { for (;;) { Node t tail; if (t null) { if (compareAndSetHead(new Node())) tail head; } else { node.prev t; if (compareAndSetTail(t, node)) { t.next node; return t; } } } }这个方法的关键在于compareAndSetTail(t, node)它保证多个线程同时往队尾挂节点时只有一个线程能成功把tail从t更新为node。CAS 失败的那些线程会在下一次循环里重新读取最新的tail再次尝试。这比synchronized更合适的原因是入队操作是非常高频的路径如果每次入队都要抢一把 JVM 级的重量级锁那锁本身还没开始干活排队系统就先成了性能瓶颈。而 CAS 只需要一条 CPU 原子指令在竞争不激烈时开销极低。不过注意一个细节node.prev t是在 CAS 之前执行的这意味着即使 CAS 失败node.prev可能指向了一个过期的 tail。但这不会出问题因为在acquireQueued里线程在挂起前会检查自己的prev节点如果prev是头节点就尝试再抢一次锁如果不是就把前驱状态改为SIGNAL然后park。真正需要保证的是我能通过prev一路找到头节点这条链路不能断。3.3 state 的 volatile 语义锁的可见性是怎么保证的state是volatile的这意味着一个线程对它赋值后其他线程能够立刻看到新值。这个可见性在锁的语境里极其重要线程 A 释放锁setState(0)之后线程 B 获取锁CAS(0, 1)成功B 必须能看到 A 在临界区里写的所有共享变量。这里涉及的其实是 Java 内存模型JMM里的happens-before规则volatile 写操作 happens-before 后续对这个变量的读操作。A 释放锁时的setState(0)是一个 volatile 写B 获取锁时的 CAS 读取到state 0是一个 volatile 读所以 A 在临界区里的所有写入对 B 都可见。这也是为什么ReentrantLock能像synchronized一样保证线程安全的原因。我见过一些初学者问为什么ReentrantLock不用AtomicInteger当状态其实用AtomicInteger也能实现抢占逻辑但 AQS 需要的不只是原子性还有等待队列管理、条件变量支持、中断响应这些能力一个AtomicInteger显然做不到。 这个问题的答案正好也是 AQS 存在的价值。4. 加锁全程走读从 lock() 到 acquireQueued()4.1 非公平锁的 lock()先抢一次再说ReentrantLock默认是非公平锁它的lock()实现如下final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }看第一行先直接 CAS 把state从0改成1。如果成功说明锁本来是空闲的而且没有任何排队等待者当前线程直接拿到锁把自己设为exclusiveOwnerThread。这就是非公平的第一层含义新来的线程不管队尾有没有人等着先试一次抢锁。如果恰好锁被释放了它就能插队成功。这就是非公平的来源。很多人觉得非公平锁不好但从吞吐量的角度来看它减少了一次线程挂起和唤醒的开销整体性能往往更高。如果 CAS 失败说明锁已经被占用或者刚刚被别的线程抢占就进入acquire(1)。acquire是 AQS 里的模板方法public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }这段代码只有三行但每一行都是一个独立的关键环节。4.2 addWaiter(): 排队的第一步——把自己挂到队尾addWaiter的逻辑很直白创建一个Node.EXCLUSIVE模式的节点节点里封装当前线程然后把它挂到队尾。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; }它先尝试一次快速入队如果tail不为空试着把自己的prev指向旧 tail然后 CAS 更新 tail 为自己。成功的话把旧 tail 的next指向自己入队完成。如果tail为空队列还没初始化或者 CAS 失败就走enq方法用自旋的方式入队。这个先试一次失败再自旋的设计模式在 AQS 里很常见目的是在低竞争情况下用最小开销完成任务只有高竞争时才切入慢路径。4.3 acquireQueued(): 排队的核心——阻塞与重试的循环acquireQueued是整个加锁流程里最核心的方法它做的事情可以概括为循环做两件事检查自己是不是队首正在等待的人头节点的下一个节点如果是就再试着抢一次锁如果不是就检查并调整前驱节点的状态然后把自己挂起。final 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; // help GC failed false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; } } finally { if (failed) cancelAcquire(node); } }逐行拆解node.predecessor()拿到前驱节点p。如果p head说明当前节点是队列里第一个真正在排队的线程head 是哑元节点。这时再调用tryAcquire(arg)抢一次锁。抢到锁后把自己设为新的head自己的thread置为null因为已经拿到锁了不再需要保留线程引用然后把旧 head 的next置空帮助 GC 回收。注意setHead方法里没有 CAS因为此刻队列里只有当前线程能修改 head 的前驱关系。如果前驱不是 head或者抢锁失败就调用shouldParkAfterFailedAcquire判断我应不应该挂起。如果确定要挂起就调用parkAndCheckInterrupt内部执行LockSupport.park(this)真正阻塞当前线程。这里的精髓在shouldParkAfterFailedAcquire它把前驱节点的waitStatus调整为SIGNAL逻辑是这样的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 { // 前驱节点状态是 0 或 PROPAGATECAS 改成 SIGNAL compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }前驱状态是SIGNAL说明前驱承诺会唤醒当前节点可以放心挂起前驱状态是CANCELLED说明前驱已经放弃等待了要跳过它重新连接链表前驱状态是0或其他就把它改成SIGNAL然后循环回去再试一次抢锁。这也是一个最后一次尝试的机会万一前驱刚好释放了锁就能避免一次没必要的 park/unpark。4.4 抢锁失败的三种选择阻塞、中断还是超时acquireQueued是 AQS 的默认模式支持中断响应和超时控制。lock()用的是不可中断的方式lockInterruptibly()用的是doAcquireInterruptiblytryLock(timeout, unit)用的是doAcquireNanos。这三个方法的核心循环几乎一样区别只在于对中断和超时的处理doAcquireInterruptibly在parkAndCheckInterrupt返回 true说明被中断了时直接抛出InterruptedException而不是把中断状态记录下来。doAcquireNanos每次循环都会计算剩余时间如果剩余时间小于等于 0就执行cancelAcquire把节点从队列里移除并返回 false。tryLock()还有一个无超时版本它的实现更简单只调用一次tryAcquire完全不进队列。这也是tryLock的一个特点它不排队抢不到就立刻返回 false。如果你在业务里用tryLock做互斥要注意这种不排队的语义它并不适合高竞争下需要保证顺序的场景。5. 解锁与唤醒公平与否在这里体现5.1 unlock() 到 release()释放锁的完整链路解锁方法的入口是ReentrantLock.unlock()它调用Sync.release(1)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; }这段源码有三个重点第一必须先检查当前线程是不是锁的持有者不是就抛IllegalMonitorStateException。这解释了为什么别的线程不能释放你的锁。第二state减去releases后如果还大于 0说明当前线程还有重入的层数锁还没完全释放直接setState(c)返回 false。只有减到 0 时才真正释放锁把持有者置空返回 true。第三用setState(c)而不是 CAS是因为当前线程是锁的持有者对state的修改不存在竞争没必要用 CAS。这也体现了 AQS 的一个性能原则在无竞争或准无竞争路径上尽量用普通赋值而不是原子操作。5.2 unparkSuccessor(): 唤醒的不是头节点而是它的下一个有效节点tryRelease返回 true 后release会检查 head 的状态如果head.waitStatus ! 0说明有节点在等待被唤醒执行unparkSuccessorprivate 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可能为 null或者node.next对应的节点已经超时/被中断取消了waitStatus 0。所以不能简单地从 head 向后遍历那样可能会撞上断链或取消节点。源码的做法是从 tail 往前找找到离 head 最近的那个waitStatus 0的节点。这种方法牺牲了一点遍历性能但保证了一定能找到有效的后继。这种从尾向前回退查找的技巧在并发队列设计里非常常见ConcurrentLinkedQueue里也有类似的处理。5.3 公平锁与非公平锁的差异一次插队 vs 全程排队公平锁的lock()不走先抢一次的捷径直接进acquire(1)然后在tryAcquire里先检查队列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; }核心差异就是多了一个hasQueuedPredecessors()的检查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说明队列里至少有两个节点head 哑元和至少一个等待者。h.next null表示 head 的后继还没被设置好这发生在入队瞬间的极端并发窗口里s.thread ! Thread.currentThread()表示排在最前面的线程不是当前线程。这个判断为什么这么繁琐因为公平锁不是自己看队列里有没有人而是如果有人排在我前面我就不能抢。如果队列为空h t可以立即抢如果自己已经排在队首了s.thread Thread.currentThread()也不必放弃。这个方法的正确性直接决定了公平锁的顺序保证。非公平锁的tryAcquire里没有这个判定所以新线程可以在队列已被占满的情况下趁着state刚好变成 0 的瞬间抢走锁让排队的人继续等。从实际的测试数据来看非公平锁的吞吐量通常比公平锁高 10 到 30 倍左右因为公平锁的每一个唤醒动作都有线程上下文切换的开销。如果业务对线程执行的先后顺序没有严格要求我建议优先用非公平锁。6. 重入与中断容易被忽略的两个能力6.1 重入计数一个线程能连续拿同一把锁吗ReentrantLock的名字里带Reentrant它天然支持重入。重入的计数是放在state里的而不是单独用一个int字段。看非公平锁tryAcquire里的这段逻辑else if (current getExclusiveOwnerThread()) { int nextc c acquires; if (nextc 0) throw new Error(Maximum lock count exceeded); setState(nextc); return true; }当state 0且持有者是自己时直接把state加上acquires通常是 1返回 true 表示获取成功。整个过程既没有 JVM 锁参与也没有 CAS 参与因为持有者只有一个线程能进入。释放时每越过一层临界区就调一次unlock直到state减到 0 才真正释放锁。这里要注意一个极限情况state是int类型重入次数不能超过Integer.MAX_VALUE超过会溢出变成负数。源码里的nextc 0检查就是为了兜住这个极端情况一旦发现溢出直接抛Error。这个概率极小但源码里确实考虑了。6.2 中断响应lock() 和 lockInterruptibly() 的本质区别lock()默认不响应中断但它会把中断状态记录下来。回顾acquireQueued的实现如果在park过程中线程被中断parkAndCheckInterrupt会返回 trueacquireQueued把中断标志存入局部变量interrupted然后继续循环。拿到锁后如果interrupted true就在返回时调用selfInterrupt()static void selfInterrupt() { Thread.currentThread().interrupt(); }这相当于把中断状态补发给自己。为什么不在被中断时立刻退出因为lock()的语义是要么拿到锁要么一直等它不允许通过中断悄悄放弃获取锁。lockInterruptibly()就不一样了它走doAcquireInterruptibly被中断时会抛出InterruptedException并触发cancelAcquire把自己从队列中移除。这种锁适合那些等待锁的过程中希望响应取消信号的场景。比如你写了一个任务调度框架用户取消任务时要能快速释放掉在等待锁的线程而不是让它一直阻塞到拿到锁为止。这一块是很多线上问题的高发区——用了lock()的接口线程无法通过interrupt()提前脱离锁等待任务取消就变成了假取消。排查的时候看线程 dump会看到线程停在park状态怎么喊都喊不醒。6.3 超时获取锁doAcquireNanos 的时间账tryLock(timeout, unit)提供了带超时的获取方式。它的核心逻辑在doAcquireNanosprivate boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { if (nanosTimeout 0L) return false; final long deadline System.nanoTime() nanosTimeout; final Node node addWaiter(Node.EXCLUSIVE); boolean failed true; try { for (;;) { final Node p node.predecessor(); if (p head tryAcquire(arg)) { setHead(node); p.next null; failed false; return true; } nanosTimeout deadline - System.nanoTime(); if (nanosTimeout 0L) { cancelAcquire(node); return false; } if (shouldParkAfterFailedAcquire(p, node) nanosTimeout spinForTimeoutThreshold) LockSupport.parkNanos(this, nanosTimeout); if (Thread.interrupted()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }这里有一个非常值得学习的工程细节spinForTimeoutThreshold这个常量默认是 1000 纳秒。如果剩余时间小于 1000 纳秒就不再 park 了而是继续自旋。原因是park/unpark本身有上下文切换的开销可能比再等 900 纳秒还要慢。与其 pay 一个昂贵的挂起/唤醒成本不如让线程在用户态自旋熬过这最后一小段时间。这种小超时用自旋、大超时才 park的策略在很多高性能中间件里都能看到。7. Condition 条件队列从抢座位到会议室等通知7.1 一个锁对应多个条件队列Condition是ReentrantLock的另一块重要拼图它解决的是当锁不满足某个条件时主动让出锁并等待的问题。synchronized的wait/notify只有一个等待集合而ReentrantLock.newCondition()可以创建多个Condition实例也就是说一个锁可以对应多组等待线程。这在生产者-消费者场景里特别有用一组线程等缓冲区有空位另一组线程等缓冲区有数据两组互不干扰。AQS 内部的ConditionObject维护了一个单向链表firstWaiter和lastWaiter每个节点用nextWaiter连接。注意这个条件队列用的正是 Node 内部的nextWaiter字段它不会影响同步队列的双向链表结构。这是理清 AQS 队列关系的关键同步队列是双向链表条件队列是单向链表一个线程同一时刻只能在一个队列里。7.2 await() 的三个步骤入条件队列、释放锁、挂起await()的流程粗略可以分为三步第一步把当前线程封装成CONDITION状态的节点追加到条件队列末尾。这一步不需要 CAS因为持有锁的线程只有一个对条件队列的修改是单线程的。第二步调用fullyRelease把锁完整释放掉。为什么是fullyRelease而不是release因为当前线程可能重入了多次state可能是 3必须一次性把state清零否则在等待条件的过程中锁没有真正释放其他线程进不来就会死锁。fullyRelease的返回值就是原来的state值。第三步进入一个循环反复判断自己是否收到了 signal、是否被转移回同步队列。如果没有就一直park阻塞final int fullyRelease(Node node) { boolean failed true; try { int savedState getState(); if (release(savedState)) { failed false; return savedState; } throw new IllegalMonitorStateException(); } finally { if (failed) node.waitStatus Node.CANCELLED; } }这个方法的细节在于如果当前线程不是锁的持有者release(savedState)会走tryRelease的线程检查并抛异常如果释放失败节点会被标记为CANCELLED避免它一直挂在条件队列里被人唤醒后产生诡异行为。7.3 signal() 的转移逻辑从条件队列到同步队列signal()做的事情不是直接唤醒线程而是把条件队列的头节点转移到同步队列的队尾然后设置SIGNAL状态。真正的唤醒由同步队列的释放来触发。看transferForSignal的实现final boolean transferForSignal(Node node) { if (!compareAndSetWaitStatus(node, Node.CONDITION, 0)) return false; Node p enq(node); int ws p.waitStatus; if (ws 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL)) LockSupport.unpark(node.thread); return true; }第一段 CAS 把节点的CONDITION状态改成0目的是如果这个节点被取消等待了状态已不是 CONDITION直接返回 false。然后把这个节点像普通线程一样enq进同步队列。注意enq返回的是新的前驱节点p。如果p已经被取消或者p的状态无法成功改成SIGNAL那就直接unpark当前节点让它在acquireQueued里自行处理被取消的前驱节点。这里有一个非常经典的信号丢失场景值得展开说线程 A 调用await()进入条件队列线程 B 调用signal()把 A 转移到同步队列但此时 A 还没从await()的循环里醒过来它并不知道自己被转移了。await()里实际上有个循环条件isOnSyncQueue(node)它会反复检查自己是否已经从条件队列转移到同步队列。源码中的逻辑是while (!isOnSyncQueue(node)) { LockSupport.park(this); if ((interruptMode checkInterruptWhileWaiting(node)) ! 0) break; }这样就保证了即使 signal 在 park 之前执行线程也不会错过信号。8. 排查与避坑从源码思维到实战经验8.1 线上问题排查怎么判断一个线程卡在锁等待上我实际排查死锁和锁竞争问题时最常用的手段是jstack抓线程 dump。看到java.util.concurrent.locks.AbstractQueuedSynchronizer.park这样的栈就说明线程在等锁。关键是要看它在等哪一把锁。ReentrantLock不像synchronized那样在 dump 里直接显示locked xxx你需要顺着线程栈找到ReentrantLock.lock()的调用点再定位是哪把锁的实例。如果 dump 里出现大量线程都卡在同一个ReentrantLock的park上大概率是锁竞争激烈而不是死锁。这种时候先看临界区里干了什么是不是有耗时的 I/O 操作是不是锁的粒度太大了要不要换成ReadWriteLock或StampedLock有一次我排查一个订单服务的性能问题发现一个领取优惠券的接口用了一把大锁锁住了整个领取流程里面还查了两次数据库高峰期 50 多个线程全部阻塞在 AQS 队列上接口 P99 直接飙到 3 秒。改成先 CAS 预扣库存再异步落库之后锁竞争立刻消失了。如果是死锁dump 里会出现两个以上的线程互相持有对方需要的锁。注意ReentrantLock的死锁栈信息和synchronized不一样不会标出Found one Java-level deadlock因为它不是 JVM 管制的锁。你要自己在 AQS 队列里找环形等待关系。这块我建议用jstack -l带上行号能省很多事。8.2 最容易被忽略的坑unlock 放到 finally 里但别再嵌套 try-catchReentrantLock不像synchronized那样能自动释放锁它要求开发者手动在finally里调用unlock。这个基本常识不用多讲但我想提醒一个更隐蔽的坑不要在finally里再对unlock本身做异常吞掉处理否则一旦锁状态混乱后面所有等锁的线程都会受影响。正常情况下unlock只有当前线程不是持有者时才会抛IllegalMonitorStateException而这个异常本身就是 bug 的信号不该被默默吞掉。还有一种常见错误是在临界区里调用Condition.await()时忘了检查条件。正确的模式是lock.lock(); try { while (!conditionReady) { condition.await(); } // 真正的业务逻辑 } finally { lock.unlock(); }必须用while而不是if来检查条件因为await()可以被假唤醒打断也可能在条件还未满足时因为其他线程的signalAll而被换醒。这一点《Java 并发编程实战》里专门讲过但实际代码里还是经常见到用if的写法。8.3 性能对比公平锁并不是更好的锁很多业务方点单时直接要求用公平锁因为更公平但从实践来看绝大多数场景根本不需要公平保证。公平锁带来的顺序确定性代价是每次锁释放都要唤醒下一个等待线程这个过程涉及线程状态切换开销巨大。而非公平锁 合适的临界区在大多数读多写少或短临界区的场景下性能表现远好于公平锁。我自己有个经验法则除非你明确需要先到先得的顺序语义比如票务系统按请求顺序出票否则默认非公平锁。如果要控制竞争优先优化临界区大小而不是纠结公平性。比如把锁内的 I/O 操作移到锁外、用CopyOnWriteArrayList之类的并发容器替代锁内遍历这些优化的收益通常比换锁类型大一个数量级。8.4 一个小技巧如何通过 AQS 的队列状态判断锁的健康度在排查性能问题时我会写一个小的监控逻辑定期获取锁的getQueueLength()观察等待线程数是否在持续增长。如果等待队列长度始终在高位说明锁竞争已经很严重了。注意getQueueLength()只是当前排队等待的线程数估计值同一时刻可能因为并发入队而出入几个线程但它已经足够用来做趋势判断了。更细一点的排查可以用ThreadMXBean抓线程阻塞统计或者干脆在测试环境用-XX:PrintLockStatistics之类的 JVM 参数看锁使用情况。不过这种参数在 JDK 8 里默认是关闭的需要配合诊断参数开启生产环境不建议开。用 getQueueLength 做日常监控已经够用了。9. 从 ReentrantLock 延伸到整个 JUC 体系读完了ReentrantLock的排队机制你会发现 AQS 的抽象能力远比想象中强大。CountDownLatch的await用的是acquireSharedInterruptiblySemaphore的tryAcquire本身就在操作一个许可池ReentrantReadWriteLock的读写锁是两个state高位和低位的拆分。它们通通复用 AQS 的一整套等待队列和阻塞唤醒机制。我个人在读完ReentrantLock之后最大的收获不是记住了几个方法的调用链而是建立了一种同步工具 状态变量 排队机制 唤醒策略的分析框架。之后再看任何并发工具我第一反应不是去搜博客而是打开源码找它的tryAcquire和tryRelease是怎么定义的。这两个方法的关系决定了这个工具锁的语义。如果你想把 AQS 彻底搞清楚我建议按这个顺序读源码先读Node类和state字段理解队列的基本结构再读acquire和release理解加锁解锁的主流程然后读ConditionObject理解条件等待和 signal 的转移逻辑最后读ReentrantLock的FairSync和NonfairSync把锁的语义和排队机制之间那层抽象打通。这个过程不需要一次完成但每读一遍收获都会不一样。最后分享一个我自己的小习惯每次读完一段并发源码我会用文字把它翻译成一个生活场景比如抢座位食堂打饭会议室等通知然后在脑子里把每个方法调用对应到生活场景的每一个动作上。这个方法听起来有点土但对记忆源码流程和理解设计意图特别有用。AQS 这套代码已经经过十几年的并发考验每一行都不是多余的只有真正理解为什么这么设计才能在出问题的时候不慌。
返回列表