一、先搞懂核心基础:ReentrantLock和AQS的关系
你可以把AQS(AbstractQueuedSynchronizer)理解成一个“同步组件的通用骨架”——它提前写好了同步队列管理、线程阻塞/唤醒的核心逻辑,只留了几个“钩子方法”(比如tryAcquire、tryRelease)让子类自己实现。
一、钩子方法的通俗理解
钩子方法的核心可以用一个生活化的比喻:
你去餐厅吃“套餐”,餐厅(父类)已经定好了套餐的固定流程:先上开胃菜 → 上主菜 → 上甜品。但“主菜选什么”(比如选牛排还是烤鱼)这个步骤,餐厅留了一个“钩子”让你自己选——流程不变,但关键细节由你定制。
对应到编程中:
钩子方法是模板方法模式的核心,父类(通常是抽象类)定义了一个算法的整体骨架(模板方法),其中把算法中“需要子类定制的步骤”设计成空方法/抽象方法(这就是钩子方法),子类通过重写这些钩子方法,就能在不改变整体流程的前提下,定制算法的具体细节。
二、钩子方法的正式定义
钩子方法(Hook Method):
- 由父类声明(可以是抽象方法、空实现方法,甚至带默认实现的方法);
- 父类的“模板方法”会调用这些钩子方法,但不关心钩子方法的具体实现;
- 子类通过重写钩子方法,实现自己的业务逻辑,从而“钩入”父类的固定流程中。
三、结合AQS的例子(你最熟悉的场景)
这是理解钩子方法最好的例子,因为AQS就是模板方法模式的经典应用:
1. AQS的“模板方法”(固定流程)
AQS定义了加锁/解锁的整体流程(模板方法),比如:
- 加锁流程:
acquire(int arg)→ 先调用tryAcquire→ 失败则入队 → 自旋/阻塞; - 解锁流程:
release(int arg)→ 先调用tryRelease→ 成功则唤醒队列线程。
这些acquire、release是AQS的模板方法,流程完全固定,子类不能改。
2. AQS的“钩子方法”(子类定制)
AQS把“抢锁/释放锁的具体逻辑”设计成钩子方法,留给子类(比如ReentrantLock的FairSync/NonfairSync)实现:
| AQS的钩子方法 | 作用 | AQS的默认实现 | 子类(ReentrantLock)的定制实现 |
|---|---|---|---|
tryAcquire(int) | 尝试获取独占锁 | 直接抛UnsupportedOperationException | 公平锁:排队抢锁;非公平锁:允许插队抢锁 |
tryRelease(int) | 尝试释放独占锁 | 直接抛异常 | 减少state重入次数,清空锁持有者 |
isHeldExclusively() | 判断当前线程是否持有独占锁 | 返回false | 判断exclusiveOwnerThread是否为当前线程 |
3. 具体例子:tryAcquire
AQS中tryAcquire的默认实现(钩子方法):
// AQS中的钩子方法(抽象模板里的“空钩子”)protectedbooleantryAcquire(intarg){thrownewUnsupportedOperationException();}ReentrantLock的NonfairSync重写这个钩子方法(定制抢锁逻辑):
// 非公平锁定制的钩子方法实现protectedfinalbooleantryAcquire(intacquires){returnnonfairTryAcquire(acquires);// 非公平抢锁逻辑}👉 核心逻辑:AQS的acquire流程不变,但tryAcquire的具体逻辑由ReentrantLock的子类决定——这就是钩子方法的核心价值。
四、钩子方法的核心特点
- 父类控流程,子类填细节:父类的模板方法定死了“做什么步骤”,子类的钩子方法决定“步骤怎么做”;
- 灵活性高:同一套父类流程,不同子类重写不同钩子方法,就能实现不同功能(比如AQS既可以实现ReentrantLock,也能实现CountDownLatch);
- 访问修饰符:钩子方法通常是
protected(便于子类重写),模板方法通常是public final(防止子类修改流程)。
五、钩子方法 vs 普通重写方法
很多新手会混淆,核心区别:
| 维度 | 钩子方法 | 普通重写方法 |
|---|---|---|
| 调用方 | 父类的模板方法主动调用 | 通常由外部直接调用 |
| 作用 | 定制父类流程中的某个步骤 | 完全替换父类方法的逻辑 |
| 与父类的耦合度 | 强耦合(依赖父类的流程) | 弱耦合(仅继承关系) |
总结
- 钩子方法的核心:父类定“流程骨架”,子类通过重写钩子方法定制“流程中的细节”,是模板方法模式的核心;
- AQS中的典型应用:
tryAcquire/tryRelease是钩子方法,acquire/release是模板方法,ReentrantLock通过重写钩子方法实现公平/非公平锁; - 本质价值:在不改变整体流程的前提下,让子类灵活定制核心逻辑,兼顾流程统一和细节灵活。
结合你之前看的ReentrantLock源码,钩子方法就是AQS留给FairSync/NonfairSync“定制抢锁逻辑”的入口,这也是为什么ReentrantLock能同时支持公平和非公平锁——只需要重写tryAcquire这个钩子方法即可。
而ReentrantLock(重入锁)就是基于这个骨架做的“定制化产品”:
- ReentrantLock内部有两个核心子类:
FairSync(公平锁)、NonfairSync(非公平锁),它们都继承自AQS; - 你创建ReentrantLock时传
true就是公平锁,传false就是非公平锁,本质是初始化不同的sync对象(FairSync/NonfairSync); - ReentrantLock是“独占锁”——同一时间只有一个线程能持有锁。
二、AQS的核心设计:支撑ReentrantLock的底层结构
AQS的核心是“同步队列+状态管理”,先把这两个基础搞懂,才能理解加锁解锁的源码。
1. 同步队列:线程排队的“双向链表”
当线程抢锁失败时,会被封装成一个“结点(Node)”加入队列等待,这个队列是带头尾指针的双向链表:
head:队列头结点(代表当前正在持有锁的线程);tail:队列尾结点(新抢锁失败的线程会加到尾部);- 队列遵循FIFO(先进先出)——正常情况下先排队的线程先抢锁。
2. Node结点:排队线程的“身份证”
每个抢锁失败的线程都会被包装成一个Node对象,里面存了关键信息:
| 属性 | 作用 |
|---|---|
prev | 指向前一个结点(双向链表的“前向指针”) |
next | 指向后一个结点(双向链表的“后向指针”) |
thread | 当前结点对应的线程(比如线程A抢锁失败,这里就存A) |
waitStatus | 结点状态(ReentrantLock中主要用3个值): 0:初始化状态 -1(SIGNAL):当前结点的前驱释放锁后,要唤醒当前结点的线程 1(CANCELLED):线程等待超时/被中断,放弃抢锁 |
3. state:锁的“重入次数计数器”
AQS里有个volatile int state(加了volatile保证可见性),在ReentrantLock里它的含义是“锁被线程持有的重入次数”:
state=0:锁是空闲的,没有线程持有;state=1:锁被某个线程持有(未重入);state>1:锁被同一个线程多次重入(比如线程A拿到锁后,又调用了一次lock(),state就变成2)。
4. exclusiveOwnerThread:锁的“持有者标记”
这个属性存在AQS的父类里,专门用来标记“当前哪个线程持有独占锁”——比如线程A拿到锁,这个属性就存A;A释放锁后,这个属性清空。
三、非公平锁的加锁流程(重点!实际开发用得最多)
我们以你笔记里的测试代码为入口,一步步拆解lock.lock()的底层逻辑:
ReentrantLocklock=newReentrantLock(false);// 非公平锁lock.lock();// 核心加锁方法步骤1:NonfairSync的lock()方法——第一次“插队”抢锁
非公平锁的lock()方法会先做一个“快速抢锁”:
// NonfairSync的lock()核心逻辑finalvoidlock(){// 第一步:CAS尝试把state从0改成1(只有锁空闲时能成功)if(compareAndSetState(0,1))// 抢锁成功:标记当前线程为锁的持有者setExclusiveOwnerThread(Thread.currentThread());else// 抢锁失败:走AQS的acquire方法acquire(1);}👉 这里的“非公平”体现:哪怕队列里有线程在排队,新线程也会先试试能不能直接抢锁,不按排队顺序。
步骤2:acquire(1)——抢锁失败后的“正式流程”
acquire是AQS的模板方法,核心逻辑如下(伪代码):
publicfinalvoidacquire(intarg){// 三个核心方法:tryAcquire(再抢一次锁)、addWaiter(加队列)、acquireQueued(阻塞等待)if(!tryAcquire(arg)&&addWaiter(Node.EXCLUSIVE)!=null&&acquireQueued(addWaiter(Node.EXCLUSIVE),arg)){// 处理中断(非核心,暂时忽略)}}我们拆解这三个核心方法:
子步骤2.1:tryAcquire(1)——第二次“插队”抢锁
tryAcquire是AQS的钩子方法,NonfairSync重写了它,底层调用nonfairTryAcquire(1),这是“通用抢锁逻辑”,覆盖所有场景:
finalbooleannonfairTryAcquire(intacquires){finalThreadcurrent=Thread.currentThread();intc=getState();// 获取当前锁的状态// 场景1:锁空闲(state=0)if(c==0){// 再CAS抢一次锁(第二次插队机会)if(compareAndSetState(0,acquires)){setExclusiveOwnerThread(current);returntrue;// 抢锁成功}}// 场景2:锁已经被当前线程持有(重入)elseif(current==getExclusiveOwnerThread()){intnextc=c+acquires;// 重入次数+1(比如state从1变2)setState(nextc);returntrue;// 重入成功}// 场景3:锁被其他线程持有returnfalse;// 抢锁失败}👉 为什么lock()里先做一次CAS,又在tryAcquire里做一次?—— 为了性能!对“锁空闲”这个高频场景做提前处理,减少后续复杂逻辑的调用。
子步骤2.2:addWaiter(Node.EXCLUSIVE)——抢锁失败,线程入队
如果tryAcquire失败,线程会被封装成Node结点,加入同步队列尾部。这个方法的核心是enq(node)(保证线程安全入队):
privateNodeaddWaiter(Nodemode){// 1. 创建新Node,封装当前线程(独占模式)Nodenode=newNode(Thread.currentThread(),mode);// 2. 先尝试快速入队(性能优化)Nodepred=tail;if(pred!=null){node.prev=pred;if(compareAndSetTail(pred,node)){pred.next=node;returnnode;}}// 3. 快速入队失败(比如队列为空/有竞争),走enq方法enq(node);returnnode;}// 核心:无锁化保证线程安全入队(CAS+循环)privateNodeenq(finalNodenode){for(;;){// 死循环,直到入队成功Nodet=tail;// 场景1:队列为空(head/tail都是null)if(t==null){// CAS创建空的头结点(这个结点不存线程,只是“占位符”)if(compareAndSetHead(newNode()))tail=head;// 尾指针也指向头结点}// 场景2:队列非空else{node.prev=t;// CAS把尾指针改成当前结点if(compareAndSetTail(t,node)){t.next=node;// 原尾结点的next指向当前结点returnt;}}}}👉 关键理解:
- 队列为空时,先创建一个“空的头结点”(没有线程),再把当前线程的Node加到尾部;
- 用“CAS+死循环”实现无锁化入队——不用锁也能保证多线程下结点安全加到尾部;
- 快速入队是性能优化,避免每次都走循环。
子步骤2.3:acquireQueued(Node, arg)——入队后,线程阻塞等待
线程入队后,不会立刻阻塞,而是进入“自旋抢锁+阻塞”的循环:
finalbooleanacquireQueued(finalNodenode,intarg){booleanfailed=true;try{booleaninterrupted=false;for(;;){// 死循环,直到抢锁成功finalNodep=node.predecessor();// 获取前驱结点// 场景1:前驱是头结点(说明轮到自己抢锁了)if(p==head&&tryAcquire(arg)){setHead(node);// 把当前结点设为新的头结点p.next=null;// 原头结点出队(垃圾回收)failed=false;returninterrupted;// 抢锁成功,退出循环}// 场景2:前驱不是头结点,或抢锁失败——准备阻塞if(shouldParkAfterFailedAcquire(p,node)&&parkAndCheckInterrupt()){interrupted=true;}}}finally{if(failed)cancelAcquire(node);}}这里又拆两个核心方法:
① shouldParkAfterFailedAcquire:确保自己能被唤醒
线程阻塞前,必须确保“前驱结点会唤醒自己”,否则阻塞后永远醒不过来:
privatestaticbooleanshouldParkAfterFailedAcquire(Nodepred,Nodenode){intws=pred.waitStatus;// 获取前驱结点的状态// 场景1:前驱状态是SIGNAL(-1)——前驱释放锁后会唤醒当前线程if(ws==Node.SIGNAL)returntrue;// 可以放心阻塞// 场景2:前驱状态是CANCELLED(1)——前驱放弃抢锁了if(ws>0){// 往前找,直到找到一个状态不是CANCELLED的结点do{node.prev=pred=pred.prev;}while(pred.waitStatus>0);pred.next=node;// 把当前结点挂到正常结点后面}// 场景3:前驱状态是0(初始化)else{// CAS把前驱状态改成SIGNAL(-1)compareAndSetWaitStatus(pred,ws,Node.SIGNAL);}returnfalse;// 暂时不阻塞,继续循环}👉 核心目的:确保当前结点的前驱状态是SIGNAL,这样前驱释放锁时会唤醒自己。
② parkAndCheckInterrupt:阻塞当前线程
如果shouldParkAfterFailedAcquire返回true,就调用这个方法阻塞线程:
privatefinalbooleanparkAndCheckInterrupt(){LockSupport.park(this);// 阻塞当前线程(线程进入WAITING状态)returnThread.interrupted();// 检查线程是否被中断(重置中断标记)}👉LockSupport.park()是JDK底层的阻塞方法,比Object.wait()更灵活,不用依赖synchronized。
非公平锁加锁流程总结(一句话):
线程先两次“插队”抢锁(lock()的快速CAS + tryAcquire的通用CAS),都失败后封装成Node入队;入队后自旋检查是否轮到自己抢锁,没轮到就确保前驱会唤醒自己,然后阻塞;被唤醒后继续自旋抢锁,直到成功。
四、解锁流程:lock.unlock()
解锁比加锁简单,核心是“减少重入次数 + 唤醒队列里的线程”。
步骤1:unlock()调用release(1)
publicvoidunlock(){sync.release(1);// 调用AQS的release方法}publicfinalbooleanrelease(intarg){// 第一步:尝试释放锁if(tryRelease(arg)){Nodeh=head;// 第二步:队列非空,且头结点状态不是0(说明有线程在等待)if(h!=null&&h.waitStatus!=0)unparkSuccessor(h);// 唤醒后继线程returntrue;}returnfalse;}步骤2:tryRelease(1)——释放锁(减少重入次数)
tryRelease是AQS的钩子方法,ReentrantLock重写了它:
protectedfinalbooleantryRelease(intreleases){intc=getState()-releases;// 重入次数-1(比如state从2变1)// 校验:只有持有锁的线程能释放锁if(Thread.currentThread()!=getExclusiveOwnerThread())thrownewIllegalMonitorStateException();booleanfree=false;// 场景1:重入次数减到0(完全释放锁)if(c==0){free=true;setExclusiveOwnerThread(null);// 清空锁的持有者}// 场景2:重入次数还没到0(只是减少一次重入)setState(c);returnfree;// 只有完全释放锁才返回true}👉 为什么不用CAS?—— 因为只有持有锁的线程能调用unlock(),是单线程操作,不需要同步。
步骤3:unparkSuccessor(h)——唤醒队列里的线程
如果完全释放了锁,就唤醒队列里的下一个线程:
privatevoidunparkSuccessor(Nodenode){intws=node.waitStatus;// 把头结点状态重置为0if(ws<0)compareAndSetWaitStatus(node,ws,0);Nodes=node.next;// 头结点的后继结点// 场景1:后继结点被取消(状态=1),或为空if(s==null||s.waitStatus>0){s=null;// 从队列尾部往前找,找到离头结点最近的正常结点for(Nodet=tail;t!=null&&t!=node;t=t.prev)if(t.waitStatus<=0)s=t;}// 场景2:找到正常的后继结点,唤醒它if(s!=null)LockSupport.unpark(s.thread);// 唤醒线程}👉 为什么从尾部往前找?—— 因为结点的next指针可能被修改(比如线程取消等待),而prev指针更稳定,能确保找到正确的线程。
五、非公平锁的“不公平”本质 + 为什么用它?
1. 不公平的原因
同步队列是FIFO(先进先出),正常情况下先排队的线程该先拿锁,但非公平锁允许“插队”:
- 抢锁阶段:新线程在入队前有两次抢锁机会,哪怕队列里有线程在等;
- 释放阶段:线程A释放锁时,先把state改成0(锁空闲),再去唤醒队列里的线程C;这中间的“时间差”里,新线程B可能直接抢锁成功,导致队列里的C继续等待。
👉 极端情况:队列里的线程可能一直抢不到锁(饥饿问题)。
2. 为什么实际开发多用非公平锁?
因为性能更高:
- 减少线程切换:如果新线程能直接抢到锁,不用入队、阻塞、唤醒,避免了线程上下文切换的开销;
- 高并发下优势明显:虽然有饥饿风险,但实际业务中锁持有时间短,饥饿概率极低,性能收益远大于风险。
六、公平锁的核心差异(补充)
公平锁和非公平锁的核心区别只有两点:
- 公平锁的
lock()方法没有“快速CAS抢锁”这一步,直接走acquire(1); - 公平锁的
tryAcquire方法里,除了判断state,还会检查“队列里有没有线程在等”——如果有,哪怕state=0,也不会抢锁,直接排队。
👉 公平锁严格按FIFO顺序抢锁,没有插队,但性能低(线程频繁阻塞/唤醒)。
总结(核心要点回顾)
- ReentrantLock的底层是AQS:AQS提供同步队列(双向链表)、state(重入次数)、线程阻塞/唤醒能力,ReentrantLock通过FairSync/NonfairSync重写AQS的钩子方法实现公平/非公平锁;
- 非公平锁加锁流程:两次插队抢锁(lock()快速CAS + tryAcquire通用CAS)→ 抢失败则入队 → 自旋检查前驱是否为头结点 → 确保能被唤醒后阻塞 → 被唤醒后继续自旋抢锁;
- 解锁流程:减少state重入次数 → 完全释放锁后(state=0)→ 从队列尾部往前找正常结点并唤醒;
- 非公平锁的取舍:牺牲“公平性”(可能饥饿)换取高性能,这是实际开发中选择它的核心原因;
- 同步队列的核心设计:用CAS+循环实现无锁化入队,Node的SIGNAL状态保证线程能被唤醒,FIFO特性是公平锁的基础。