Lock 接口与 AQS 核心原理:手写理解一把可重入锁是怎么运作的

「Java 进阶之路」系列 Day05

写在前面

前四篇一直围绕 synchronized 和 JMM 打转,从这篇开始切换到并发工具包(JUC)里更"高级"的同步手段------Lock 接口。

很多人会背"ReentrantLocksynchronized 功能更强,支持公平锁、可中断、超时获取",但问不出个所以然:这些能力到底是怎么实现的? 答案就是今天的主角------AbstractQueuedSynchronizer(AQS)。这是 JUC 几乎所有同步器共用的底层框架,理解了它,后面讲读写锁、SemaphoreCountDownLatch 都会轻松很多。


一、是什么:Lock 接口解决了 synchronized 的什么局限

先对比一下两者的能力差异:

特性 synchronized Lock 接口
加锁/释放 自动(JVM 管理) 手动(必须显式 unlock()
响应中断 lockInterruptibly()
超时获取 tryLock(time, unit)
非阻塞尝试 tryLock()
公平锁 ❌(只有非公平) ✅ 可选
多个等待队列 ❌(只有一个,靠 wait/notify) ✅ 支持多个 Condition(Day06 讲)

一句话理解:Lock 就是把 synchronized 那种"要么拿到锁执行,要么无限阻塞等"的粗放模式,拆解成了一组更精细可控的 API。

标准使用模板 (务必记牢,finally 里释放锁是铁律):

java 复制代码
Lock lock = new ReentrantLock();
lock.lock();
try {
    // 临界区代码
} finally {
    lock.unlock();   // ⚠️ 必须写在 finally 里,否则临界区抛异常会导致锁永远不释放
}

二、为什么需要 AQS:不想每个锁都重复造轮子

ReentrantLockReentrantReadWriteLockSemaphoreCountDownLatch------这些看起来功能完全不同的同步工具,底层其实都基于同一套框架实现:

flowchart TB AQS[AbstractQueuedSynchronizer] AQS --> RL[ReentrantLock<br/>独占模式] AQS --> RWL[ReentrantReadWriteLock<br/>独占+共享] AQS --> SEM[Semaphore<br/>共享模式] AQS --> CDL[CountDownLatch<br/>共享模式]

如果没有 AQS,每个同步器都要自己实现"排队、挂起、唤醒"这套逻辑,重复代码多不说,还容易各写各的 bug。AQS 把这套通用逻辑抽出来做成模板方法模式,子类只需要实现"怎么判断锁是否可用"这一小块逻辑,其余的排队/阻塞/唤醒全部交给父类。


三、AQS 核心数据结构:一个 state + 一条队列

AQS 内部只有两个核心组成部分:

objectivec 复制代码
AQS
├── volatile int state          // 同步状态(核心)
│     独占锁:0=未锁,>0=重入次数
│     读写锁:高16位=读锁数,低16位=写锁重入数(Day06 细讲)
│     Semaphore:剩余许可数
│
└── CLH 双向等待队列
      ┌──────┐   ┌──────┐   ┌──────┐
      │ head │ → │ Node │ → │ tail │
      └──────┘   └──────┘   └──────┘
      每个 Node 保存:线程引用、等待状态(waitStatus)、前驱/后继指针

statevolatile 修饰(还记得 Day03 讲的可见性吗?),配合 CAS 操作保证多线程读写这个状态时的原子性和可见性。AQS 支持两种共享模式:

模式 说明 代表实现
独占(Exclusive) 同时只有一个线程能持有 ReentrantLock
共享(Shared) 多个线程可以同时持有 Semaphore、读写锁的 ReadLock

子类需要实现的模板方法很少,独占模式只需要两个:

java 复制代码
protected boolean tryAcquire(int arg)  { ... }   // 尝试获取锁
protected boolean tryRelease(int arg)  { ... }   // 尝试释放锁

加锁的完整流程

flowchart LR A[&#34;调用 acquire&#34;] --> B{&#34;tryAcquire<br/>尝试获取&#34;} B -->|成功| C[&#34;直接返回,持有锁&#34;] B -->|失败| D[&#34;封装成 Node <br/> 加入 CLH 队列尾部&#34;] D --> E{&#34;前驱节点<br/>是 head?&#34;} E -->|是| F{&#34;再次<br/>tryAcquire&#34;} E -->|否| G[&#34;LockSupport.park<br/>挂起线程&#34;] F -->|成功| H[&#34;出队,持有锁&#34;] F -->|失败| G

释放锁的流程

flowchart LR A[&#34;调用 release&#34;] --> B[&#34;tryRelease: <br/>state 减一&#34;] B --> C{&#34;state<br/>归零?&#34;} C -->|是| D[&#34;unparkSuccessor<br/>唤醒队头后继节点&#34;] C -->|否| E[&#34;重入未完全释放,<br/>锁仍持有&#34;] D --> F[&#34;被唤醒线程重新执行<br/>acquireQueued 竞争锁&#34;]

一句话总结 AQS 的原理 :用一个 state 变量表示"锁的状态",用 CAS 去竞争修改这个状态,抢不到的线程封装成 Node 排队,靠 LockSupport.park/unpark 挂起和唤醒------排队和唤醒这套通用机制,就是 AQS 存在的全部意义。


四、公平锁 vs 非公平锁:一行代码的差异

java 复制代码
// 非公平锁:新来的线程直接 CAS 抢,完全不看队列里还有没有人在排队
if (compareAndSetState(0, 1)) { ... }

// 公平锁:先检查队列里是否已经有等待者,有就老实排队
if (!hasQueuedPredecessors() && compareAndSetState(0, 1)) { ... }

ReentrantLock 默认是非公平锁

java 复制代码
ReentrantLock fairLock   = new ReentrantLock(true);   // 公平锁
ReentrantLock unfairLock = new ReentrantLock();        // 非公平锁(默认)

为什么默认选非公平:公平锁必须严格按照排队顺序放行,每次都要走一遍挂起/唤醒的完整流程,上下文切换开销大;非公平锁允许"插队"------如果恰好在锁释放的瞬间有新线程杀过来,直接让它抢,不用走排队唤醒那一套,整体吞吐量更高。这是一个用"公平性"换"性能"的经典权衡。


五、ReentrantLock 的可重入实现

可重入的本质就是 state 计数器的累加和递减:

java 复制代码
lock.lock();   // state: 0 → 1
lock.lock();   // state: 1 → 2(同一线程再次加锁,不会死锁)
lock.unlock(); // state: 2 → 1
lock.unlock(); // state: 1 → 0,这时才真正释放锁

三种典型使用场景,也是 Lock 相比 synchronized 最大的加分项:

java 复制代码
// ① tryLock() 非阻塞尝试,配合双重 tryLock 可以避免经典的"两把锁互相等待"死锁
if (lock1.tryLock()) {
    try {
        if (lock2.tryLock()) {
            try { /* 同时持有两把锁 */ }
            finally { lock2.unlock(); }
        }
    } finally { lock1.unlock(); }
}

// ② lockInterruptibly() 响应中断,避免线程无限期卡在等锁上
try {
    lock.lockInterruptibly();
    try { /* 临界区 */ }
    finally { lock.unlock(); }
} catch (InterruptedException e) {
    // 等锁期间被中断,做清理逻辑
}

// ③ tryLock(timeout) 超时放弃,避免无限等待拖垮接口响应时间
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
    try { /* 临界区 */ }
    finally { lock.unlock(); }
} else {
    // 超时没拿到锁,走降级逻辑(比如返回缓存数据、快速失败)
}

六、面试追问

Q1:Lock 接口和 synchronized 的核心区别是什么?

synchronized 由 JVM 自动管理加锁和释放,功能相对固定;Lock 是 JDK 类库层面的接口,需要手动加锁/释放(必须写在 try-finally 里),但换来了更丰富的能力:可中断获取(lockInterruptibly)、超时获取(tryLock(time, unit))、非阻塞尝试(tryLock())、可选公平锁,以及支持多个 Condition 等待队列。

Q2:AQS 是什么,为什么这么多同步器都基于它实现?

AQS(AbstractQueuedSynchronizer)是 JUC 锁的公共底层框架,核心是一个 volatile int state + 一条 CLH 双向等待队列。它用模板方法模式把"排队、挂起、唤醒"这套通用逻辑固化在父类里,子类只需要实现 tryAcquire/tryRelease(独占)或 tryAcquireShared/tryReleaseShared(共享)这几个方法去定义"什么时候算获取/释放成功",避免每个同步器都要重复造一遍排队唤醒的轮子。

Q3:ReentrantLock 默认是公平锁还是非公平锁,为什么?

默认是非公平锁。因为公平锁要求严格按照排队顺序获取锁,每次都要走挂起/唤醒的完整流程,上下文切换开销大;非公平锁允许新来的线程直接抢锁(尤其是刚好赶上锁释放的瞬间),减少了不必要的挂起唤醒次数,整体吞吐量更高,这是用一定的"排队公平性"换性能的权衡。

Q4:ReentrantLock 的可重入是怎么实现的?

靠 AQS 里的 state 变量做计数:同一个线程每次成功获取锁,state 加一;每次调用 unlock()state 减一;只有当 state 归零时才真正释放锁、唤醒其他等待线程。AQS 在判断是否允许重入时,会检查当前持锁线程是不是发起请求的这个线程,是的话直接放行并给 state 加一。

Q5:为什么用 Lock 时一定要把 unlock() 写在 finally 里?

因为 Lock 的加锁/释放是完全手动的,不像 synchronized 那样由 JVM 保证"代码块退出(哪怕抛异常)一定会释放锁"。如果临界区代码抛出异常而 unlock() 又没有被执行到,这把锁就会永远处于持有状态,其他线程再也无法获取,相当于人为造成了死锁。写在 finally 里能保证无论正常返回还是异常退出,锁都一定会被释放。


下一篇预告

Day06 讲 ReentrantReadWriteLock(读写锁)和 Condition------读多写少的场景下怎么用读写锁提升并发度,以及 Condition 相比传统 wait/notify 强在哪里。

相关推荐
Full Stack Developme1 小时前
SpringBoot 整合 Druid 并列出参数清单
java·spring boot·后端
caishenzhibiao1 小时前
顺势交易矩阵主图 同花顺期货通指标
java·c语言·c#
我命由我123451 小时前
Android 开发 - 广播组件(标准广播、有序广播、静态注册广播、分钟到达广播、网络变更广播...)
android·java·开发语言·网络·java-ee·android studio·android-studio
周航宇JoeZhou1 小时前
JP3-2-1-MyItem项目简介
java·ai·springboot·环境搭建·项目·管理·springai
lemon_sjdk1 小时前
从 ServletRequest 到 Spring 抽象:Web 请求的底层基石与演化
java·前端·spring
明月_清风1 小时前
🕵️ MEV 与链上交易:理解区块链的"暗面"
后端·web3
明月_清风1 小时前
🤖 Web3 + AI 融合:去中心化智能的未来
后端·web3
swipe1 小时前
13|(前端转全栈)支付成功不等于结束:回调、幂等、超时关单和状态竞争
前端·后端·面试
唐青枫2 小时前
Java Reactor Netty 实战详解:从响应式 HTTP 到 TCP 长连接
java·netty