ARTICLE DETAIL

资讯详情

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

ReentrantLock与AQS源码解析:独占锁加锁解锁全流程

ReentrantLock与AQS源码解析:独占锁加锁解锁全流程 我早期啃JUC源码的时候第一个反复读了又读的就是ReentrantLock和AQS这对组合。当时的感觉是AQS这套东西单独看抽象类头皮发麻但跟着ReentrantLock走一遍加锁、解锁、排队、唤醒的完整流程顿时就通了。ReentrantLock是AQS最标准、最纯粹的使用范例没有读锁写锁的复杂状态拆分也没有Semaphore那种共享计数的额外花样就是最简单的独占模式。这篇文章我不打算把源码逐行抄一遍再翻译那样还不如直接打开IDE。我想做的是把ReentrantLock在JDK 8后续小版本差异不大里的加锁、解锁流程从入口方法开始一层层追到AQS的acquire、release把每个关键判断、每个状态的流转、每处唤醒逻辑都拆开讲明白。适合已经能用synchronized和ReentrantLock写并发程序、但对底层原理始终觉得隔了一层纱的人也适合准备面试被问到“AQS怎么实现阻塞和唤醒”时想有个清晰答案的人。1. 先搞清楚ReentrantLock和AQS到底是什么关系1.1 AQS是全套同步机制的底座AbstractQueuedSynchronizer也就是AQS是整个java.util.concurrent包下面锁和同步器共同依赖的一个抽象基类。CountDownLatch、Semaphore、ReentrantReadWriteLock、ThreadPoolExecutor的Worker底层全是它。AQS干了两件核心的事一是用一个volatile的int类型state变量来记录同步状态二是用内置的FIFO双向队列来管理所有竞争锁失败的线程让这些线程能够安全地阻塞和唤醒。state的具体含义由子类自己定义。在ReentrantLock里state为0表示锁空闲大于0表示已经有线程持有锁而且每重入一次state就加1。这一点是理解整个ReentrantLock源码的起点后面所有的获取和释放逻辑本质都是在操作这个state。CLH队列这个名字很多人听说过但AQS里的队列是CLH锁的一个变种。CLH锁原本是自旋锁的一种优化形式线程通过不断检查前驱节点的状态来感知自己是否获得了锁AQS在此基础上改成了阻塞式线程不再自旋等待而是通过LockSupport.park把自己挂起等待前驱节点释放锁之后主动来unpark唤醒它。1.2 为什么拿ReentrantLock当AQS的入门案例最合适JUC里基于AQS实现的同步器不少但各有各的复杂度。ReentrantReadWriteLock里读锁和写锁共用同一个AQS状态需要把state拆成高16位和低16位分别计数CountDownLatch和Semaphore走的是共享模式和独占模式的申请释放流程有明显差异。而ReentrantLock是最朴素的独占锁没有中断状态的特殊处理没有超时时间的边界问题也没有多个线程同时获得锁的共享场景。这就意味着只要把ReentrantLock的lock、unlock两条主线走完AQS独占模式的核心骨架就全部覆盖了。等以后再去看ReentrantReadWriteLock、Semaphore只需要在此基础上补充共享模式、条件队列这些增量差异学习成本会成倍下降。这也是为什么网上几乎所有并发进阶教程都把ReentrantLock作为解剖AQS的第一个素材。ReentrantLock内部有一个继承AQS的Sync抽象内部类再往下分公平锁FairSync和非公平锁NonfairSync两个实现。默认构造方法使用的是非公平锁这个选择本身就值得说说。非公平锁的意思是当一个线程来争抢锁时不检查队列里有没有人在排队先直接CAScompareAndSet抢一轮抢到了就直接拿到锁。表面上看这对排队的线程不公平但换来的是更低的上下文切换开销在绝大多数高并发短临界区的场景下整体吞吐量反而更高。我们平时写业务代码如果没有特殊理由默认用非公平锁就够了。2. 从lock()入口追踪非公平锁的加锁全流程2.1 非公平锁为什么能“插队”先看lock方法的第一层调用。ReentrantLock的lock方法实际调用的是内部Sync实例的lock非公平锁的实现是这样的final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }这段代码是理解非公平锁的钥匙。线程进来后不管AQS队列里有没有排队的线程先直接尝试一次CAS把state从0改成1。这是个极其轻量的操作CPU指令级别比后面要走完的整个acquire流程快得多。如果CAS成功再调用setExclusiveOwnerThread把当前线程记录为独占线程加锁过程就结束了。如果CAS失败说明state不是0锁正被占用于是进入acquire(1)。注意即使CAS失败进入队列非公平的地方依然还在无论是新来的线程还是即将被唤醒的队首线程都还要再参与一次对锁的竞争谁抢到算谁的。这就是“非公平”的完整含义不是说进队列后就老实排队了。这段代码的另一个细节是利用了AQS的模板方法模式。compareAndSetState本身是AQS提供的final方法用于基于volatile读写的CAS操作而setExclusiveOwnerThread是AbstractOwnableSynchronizer提供的方法用来记录当前持锁线程。ReentrantLock的lock只需把这个入口的“快速抢锁”逻辑写好剩下的交给AQS的acquire方法。2.2 acquire内部如何完成队列的入队与自旋acquire是AQS的模板方法用final修饰子类不能修改整体流程public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }这里有三步核心动作每一步都值得单独拆开。首先是tryAcquire这个方法是抽象的由子类实现。非公平锁的tryAcquire实现直接调用nonfairTryAcquire后面讲公平锁时我会对比差异。如果tryAcquire返回true说明拿到了锁整个acquire就直接返回了当前线程可以继续往下执行临界区代码。如果tryAcquire返回false说明再次尝试抢锁也失败了这时调用addWaiter把当前线程包装成一个Node节点加入AQS队列尾部。addWaiter的逻辑很关键先尝试用CAS在队尾快速追加节点如果失败则进入enq方法用自旋CAS的方式确保节点一定能被加到队尾。最后是acquireQueued这一步是个for死循环新节点在前驱节点是新头节点时再次尝试获取锁。如果获取失败就检查是否能安全地park阻塞能阻塞则调用LockSupport.park挂起自己。整个循环一直在做两件事要么拿到锁返回true要么确认自己可以被安全挂起后暂停执行等待前驱线程释放锁后来唤醒它。2.3 addWaiter用CAS在队尾挂节点时为什么可能失败addWaiter并不复杂但它是理解CLH队列入队逻辑的好样本。代码大体是这样的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.EXCLUSIVE独占模式的新节点。第二步读取当前队尾tail如果队列不为空就把新节点的prev指向当前队尾然后通过CAS把队尾改成新节点。CAS成功后再把原队尾节点的next指向新节点完成双向链表的挂接。这里有个隐藏的并发问题为什么node.prev pred这行赋值在CAS之前因为如果多个线程同时来入队它们读到同一个tail都会尝试把自己设为新的tailCAS机制保证只有一个线程能成功。失败的线程需要重新读取tail再来一次而这一步是通过enq方法里的自旋来完成的。另外把一个节点的prev设置为某个值并不会影响其他线程对tail的CAS操作所以这个先赋值后CAS的顺序是安全的这也是AQS作者刻意设计的结果为了减少CAS成功后的赋值步骤缩短关键临界区的长度。为什么CAS设置tail成功后还要再设pred.next node因为队列里的线程从前往后找后继节点时依赖的是next指针。如果next没有正确设置释放锁的线程就无法找到需要被唤醒的节点。CAS插入新节点时如果用CAS方式来设置前驱节点的next成本更高而且更难保证一致性所以AQS选择先通过CAS确定tail再通过普通的volatile写入设置next即使此时有线程读到null也会在后面的循环中重新遍历修正。2.4 acquireQueued的自旋为什么不是死循环acquireQueued是加锁流程里最精妙、也最容易被误读的一段。它确实有一个无限for循环但这不是无意义的空转而是一个“先试试不行就睡”的状态机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; failed false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) parkAndCheckInterrupt()) interrupted true; } } finally { if (failed) cancelAcquire(node); } }每次循环先获取当前节点的前驱节点p。如果p恰好是head说明这个节点已经是排队序列里的第一名了它再次尝试tryAcquire去争抢锁。抢成功了就把这个节点设置为新的head并且把原head的next置为null帮助GC回收。这里的setHead逻辑是把当前节点的线程引用清空把prev也清空因为作为新的队首它不再需要保存前驱信息。如果前驱不是head或者tryAcquire失败就进入shouldParkAfterFailedAcquire。这个方法会检查前驱节点的waitStatus状态。waitStatus是Node节点上的一个volatile int属性用来标记节点当前所处的状态初始值为0代表既不是取消也不是需要唤醒。如果前驱节点的状态是SIGNAL代表前驱释放锁后有义务唤醒当前节点那当前线程就可以放心地调用parkAndCheckInterrupt挂起自己。如果前驱节点的status是CANCELLED说明前驱线程已经放弃了等待那当前节点就会跳过这个前驱往前寻找一个有效的节点并把它作为新的前驱同时把无效节点的next修改掉。如果前驱节点的status是0或者传播状态PROPAGATE就把前驱的状态CAS改为SIGNAL然后下一轮循环再判断。这样做的好处是只有在前驱节点确定会唤醒自己的前提下线程才真正进入park挂起状态避免出现线程已经睡了、却永远没人来叫醒的尴尬局面。parkAndCheckInterrupt本质只有两行LockSupport.park(this)然后返回Thread.interrupted()。park让线程进入等待状态直到被unpark或被中断。如果线程是被中断唤醒的返回trueacquireQueued会记录这个中断标记但不立即处理中断而是继续循环尝试获取锁。这个设计也就是“可中断获取”语义的基础后面讲lockInterruptibly的时候要回头再看这段。3. 公平锁与非公平锁在tryAcquire里的细节差异3.1 FairSync的tryAcquire微妙的排队检查公平锁的lock入口没有非公平锁那个先CAS抢一把的逻辑而是直接acquire(1)。acquire会调tryAcquireFairSync的实现有一个明显不同的地方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()方法。这个方法用来判断队列里是否存在比当前线程等待更久的其他线程。如果队列里有等待者当前线程哪怕是第一个来抢锁的也只能乖乖入队真正做到先来后到。spurious wakeup这个术语在这里也有体现后文会详细展开说明。hasQueuedPredecessors不是简单地判断队列是否为空那不够精确。它的实现是这样的public final boolean hasQueuedPredecessors() { Node t tail; Node h head; Node s; return h ! t ((s h.next) null || s.thread ! Thread.currentThread()); }它的判断分成两个分支如果head和tail指向同一个节点说明队列是空的直接返回false也就是没有排队者。如果队列非空则看head.next是否为null为null说明可能有线程正在执行入队操作但还没完成如果不是null则判断head.next中的线程是不是当前线程本身如果正好是当前线程说明线程已经排到队首了可以再尝试一次获取锁。这里有一个很细节的地方为什么head为null也认为没有前驱因为AQS的队列初始化是惰性的第一个入队的节点会被设置为head和tail指向同一个哨兵节点哨兵节点的线程引用是null不代表任何线程。所以当head等于tail时队列里实际上没有真正的等待线程当前线程可以试着去抢锁。3.2 公平锁为什么性能通常更差公平锁保证先来后到听起来更合理但在高并发场景下它的吞吐量通常不如非公平锁。原因在于公平锁的设计迫使每个新来的线程必须进入队列经历完整的入队、休眠、被唤醒、再次抢锁的流程线程的park和unpark都是重量级操作涉及操作系统层面的线程状态切换。非公平锁的场景下当一个线程释放锁时如果正好有新线程来抢锁新线程通过一次CAS就直接拿到了锁省去了排队、park、unpark等一系列开销。这种少数线程“插队”的行为实际相当于把锁的交接直接在当前临界区结束后立即完成减少了上下文切换次数。这让我想起一个实际业务场景。我在公司做过一个高并发短任务调度系统临界区只有几十条指令级别的工作最初用的是公平锁压测时线程切换频繁TP99明显偏高。换成非公平锁后TPS提升了将近20%。因为任务本身太短线程在锁上的等待时间远小于线程切换带来的代价让一部分线程自旋式地“插队”反而更划算。后来换用LongAdder彻底消灭锁竞争那是另一个话题了。4. unlock释放锁唤醒后继节点的完整链路4.1 release里tryRelease和unparkSuccessor的配合ReentrantLock的unlock调用AQS的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的Sync实现用来减少state计数并更新持锁线程。需要注意的是tryRelease返回true说明state已经减到0锁被完全释放了返回false说明state仍然大于0只是重入层数减少了一层此时不需要唤醒队列中的线程因为锁还在被当前线程持有。当锁被完全释放后会从head节点开始唤醒等待者。head不等于null说明队列里有等待线程head.waitStatus不等于0说明后面确实有线程需要被唤醒。SIGNAL这个状态在前面已经出现过如果head状态是0可能是设置SIGNAL之前的中间状态没必要执行唤醒。unparkSuccessor是AQS里第二个值得逐行看的核心方法。它会从head开始先找到head.next如果这个直接的后续节点为空或者状态是CANCELLED就说明它已经放弃了等待需要从队列尾部向头部遍历找到最靠近head的一个状态不是CANCELLED的节点。为什么从尾部向前找而不是从head向后找这是因为在并发入队场景下next指针的赋值在CAS设置tail之后才发生某个节点的next可能还没来得及指向真正的后继节点而prev指针的赋值更早所以从尾部往前遍历一定能找到所有在队列中的有效节点。这个细节充分体现了AQS对无锁并发临界条件的严谨把握。找到需要唤醒的节点后调用LockSupport.unpark(node.thread)。这一步让被阻塞的线程恢复到可运行状态被唤醒的线程会从之前parkAndCheckInterrupt的位置继续执行回到acquireQueued的循环里再次尝试获取锁。4.2 为什么解锁时只唤醒一个节点而不是全部一个很常见的疑问是锁被释放时为什么不唤醒队列里所有线程答案在于互斥锁的语义。即使唤醒所有线程最终同一时刻也只能有一个线程能够通过CAS成功获取锁其余被唤醒的线程只能再次park白白增加线程调度开销。所以AQS采用链式唤醒的方式释放锁的线程只负责唤醒队列中第一个有效的节点被唤醒的线程获取锁成功后会把自己设为新的head然后当它自己释放锁时再唤醒它的后继节点。这就是“接力式”的唤醒机制整个队列像一个流水线一个接一个地传递锁。这种设计将唤醒次数降到最低同时保证了按照队列顺序依次获得锁的机会。我在分析线上线程dump时曾经见到过一种情况多个线程Blocked在一个ReentrantLock上但队列看起来只有两三个节点。这是因为许多线程在tryAcquire失败后还没有完全入队或者入队后的节点状态还没稳定dump只是某个瞬间的快照。要确认锁竞争的激烈程度更靠谱的方法是看lock的等待耗时或者直接用jstack多次采样对比线程栈中的位置。5. 可重入、中断与超时——ReentrantLock的三大附加能力5.1 state的一数两用既当锁标记又当重入计数器可重入是ReentrantLock和synchronized共有的特性但ReentrantLock的可重入实现更透明。每次tryAcquire时如果发现当前线程就是持有锁的线程直接state state 1。每解锁一次state减1减到0才真正释放锁。这就意味着一次lock必须对应一次unlock否则state永远减不到0队列里的线程就永远等不到唤醒。这个特性也带来最常见的业务bug忘记在finally里释放锁或者加锁次数比释放次数多。我在Code Review里见过好几次这样的代码有的线程只进去一次临界区却执行了两次lock后一次lock实际上是自己重入解锁时只解了一次锁就泄漏到线程上下文之外了。排查这类问题最直接的手段是把ReentrantLock换成一个带日志注解的包装锁在lock和unlock时输出线程名和state值基本上一轮就可以定位到问题。有个细节要补充JDK里state是int类型所以最大重入次数是Integer.MAX_VALUE。虽然实际代码里不可能重入这么多次但tryAcquire里有一段检查nextc 0后抛Error的防御性代码说明作者对这种极端情况也有考虑。5.2 lockInterruptibly如何实现“优先响应中断”ReentrantLock支持可中断地获取锁这是synchronized没有的能力。lockInterruptibly的入口并不直接调用acquire而是调用acquireInterruptiblypublic final void acquireInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (!tryAcquire(arg)) doAcquireInterruptibly(arg); }doAcquireInterruptibly和acquireQueued非常相似唯一的差异在于parkAndCheckInterrupt返回true即检测到中断时不设置interrupted标记继续循环而是直接抛出InterruptedException。这个差异理解起来很关键普通的lock方法在阻塞期间被中断时不会立刻抛异常而是“记下这笔账”等到线程真正获得锁之后再通过Thread.currentThread().interrupt()重新设置中断标志让上层代码处理。这是为什么lock()方法签名上没有throws InterruptedException而lockInterruptibly()有。实际使用中lockInterruptibly特别适合希望快速响应取消指令的场景。比如一个线程池任务在等待锁期间被shutdownNow中断如果使用的是普通lock中断信息会被暂时记录下来线程必须一直等到锁到手才能响应取消可能造成任务延迟。如果用的是lockInterruptibly线程可以在等待期间直接通过异常退出及时释放资源。tryLock带超时版本也是类似思路它会走doAcquireNanos在循环里判断剩余时间如果超时则返回false。这里有一个常见误区tryLock(long timeout, TimeUnit unit)在等待过程中被中断时同样会抛出InterruptedException而tryLock()无参版本则根本不会排队等待失败立即返回false所以两个方法对锁竞争的友好程度完全不同底层调用的分别是tryAcquireNanos和tryAcquire的结合。5.3 条件队列Condition是对AQS队列机制的又一次复用除了锁本身ReentrantLock还有个高频用法是newCondition()。Condition的await和signal底层是AQS的ConditionObject实现的。这个ConditionObject内部维护了一个独立的单向等待队列而不是CLH锁队列。线程调用await时会释放当前持有的锁并把自身节点从锁队列转移到条件等待队列中挂起。signal时则会从条件队列中取出一个节点重新加入到锁队列的队尾等待重新获得锁。我在实践中习惯用下面的模板来规范Condition的使用await方法必须放在循环条件里而不是if分支。因为线程被signal唤醒后还需要重新竞争获取锁等到获取锁成功后再判断等待条件是否真正满足。如果单纯用if判断很可能出现多个线程被唤醒后发现条件仍然不满足或者条件已经被其他线程满足而当前线程基于过期的判断继续执行导致业务逻辑出错。官方文档里要求“在循环中等待”本质就是应对这种虚假唤醒和条件竞争问题。6. 源码之外常见故障现象与排查心得6.1 锁泄漏、饥饿和异常中断的现场特征场景一服务线程池里的任务偶发性堆积jstack发现大量线程Blocked在同一个ReentrantLock中head节点后面排了很长的队但持锁线程却早就从线程池里消失了。这种情况通常是持锁线程在try块里抛出运行时异常而finally里unlock被错误地放在了try块末尾导致释放锁的代码没有执行。排查手段很简单直接看持锁线程的栈找到它最后执行的代码路径几乎立刻就能定位。场景二非公平锁下长期线程饥饿。表现在业务上是个别请求耗时非常高但整体吞吐量又没有明显下降。原因是锁一直被新来的线程“插队”抢走队列里的老线程迟迟得不到唤醒。遇到这类问题可以考虑切换成公平锁或者调整临界区粒度用读写锁、分段锁甚至原子变量来替代互斥锁。场景三死锁。两个线程互相持有对方需要的锁并且都通过ReentrantLock等待对方释放。jstack能直接检测到deadlock但如果是通过Condition等待dump可能只显示两个线程在park不一定直接标成deadlock。我的惯用方法是分析线程栈里AQS的队列状态结合业务代码里的加锁顺序判断看是否存在环路等待。6.2 性能调优时从哪里下手很多人一遇到并发瓶颈就想到把synchronized换成ReentrantLock但实际收益可能远低于预期。锁竞争的本质开销在线程的park/unpark和上下文切换上而不是锁对象本身的CAS操作。如果临界区很大让持锁时间变长排队线程增多再换什么锁都一样。先缩小临界区把不必要的计算和IO挪到锁外往往立竿见影。如果确实需要使用ReentrantLock优先用非公平锁。除非业务强依赖先来先到的公平性否则不要为“看上去公平”的语义付出性能代价。对锁的争用频率做一次监控记录lock和unlock的耗时分布通常都能发现大部分锁的持有时长只有几十微秒这个量级下非公平锁的插队式获取能明显减少无效唤醒。另外读多写少的场景优先考虑ReentrantReadWriteLock或更细粒度的StampedLock但一定要实测。ReentrantReadWriteLock的读锁是共享模式写锁是独占模式实现上比ReentrantLock复杂某些场景下因为内部计数和状态转换的开销反而没有简单的原子变量快。6.3 面试高频问题快查AQS的state为什么用int而不是long因为同步状态的变化在绝大多数场景下不会超过int范围而且int在CAS指令上有最成熟的实现路径long的CAS在某些平台上速度略慢。head节点为什么要设置成哨兵节点而不直接持有第一个等待线程因为获得锁的线程会把自己设为head如果head直接持有线程引用当head节点出队时Thread引用必须置null否则可能造成短暂的内存泄漏。哨兵节点天然规避了这个问题。为什么unparkSuccessor要从尾部向前找后继节点因为next指针的赋值时机晚于prev和tail设置删除节点时可能造成中间节点的next为null从尾部倒序遍历可以保证不遗漏。ReentrantLock和synchronized应该怎么选JDK 6之后synchronized经过锁升级优化无竞争状态下性能已经和ReentrantLock非常接近而且不会产生锁泄漏。ReentrantLock的优势在于可中断、可超时、多条件队列和更细粒度的公平性控制适合功能需求明确的场景。从lock入口一路看到unparkSuccessor这条主线基本把AQS独占模式的骨架摸透了。回过头你会发现很多看似深奥的并发问题落到源码上就是state状态、volatile读、CAS更新、链表入队出队、LockSupport阻塞唤醒这几板斧的组合。下次再看到别人分析ReentrantReadWriteLock或者Semaphore时你已经有足够的地基去理解它们和ReentrantLock的差异了。我自己的感觉是源码这种东西第一遍读很痛苦但只要完整走通一条主线后续再看其他同步器就像读一份已经见过框架的表单只剩下填细节。
返回列表