Java AQS 原理与实战:手写一个可重入锁

Java AQS(AbstractQueuedSynchronizer,抽象队列同步器)是 JDK 并发包 java.util.concurrent.locks 的底层基石。你日常使用的 ReentrantLock、Semaphore、CountDownLatch 全都构建在它之上。很多开发者只会调用 lock()/unlock(),却说不清”拿不到锁的线程去哪了”。本文不堆术语,而是带你用约 150 行代码手写一个可重入锁,把 AQS 的状态机与等待队列彻底讲透。理解 AQS 之后,再看 Java 虚拟线程的协作式调度 与 synchronized 的锁升级 会更通透。

一、为什么需要 AQS

在 AQS 出现之前,Java 的同步原语主要靠 synchronized 关键字和 Object 的 wait()/notify()。它们能用,但有硬伤:无法中断地等待、无法超时、无法感知”公平还是非公平”、也无法方便地实现”多个线程同时通过”(比如信号量)。Doug Lea 在 JUC(java.util.concurrent)中抽象出 AQS,把”线程排队、阻塞、唤醒”这一套复杂逻辑收敛到一处,让上层的同步器只需关心自己的一套规则。这正是模板方法模式的经典应用。

AQS 只暴露几个需要子类实现的钩子方法(hook),其余的队列管理、线程挂起与唤醒全部由父类完成。换句话说:你定义”能不能拿锁”,AQS 负责”拿不到怎么办”。这种分工让 90% 的并发同步逻辑被复用,也大幅降低了出错概率。

二、AQS 核心思想:一个状态 + 一个队列

2.1 state:同步状态

AQS 内部维护一个 volatile int state,它代表”资源”的占用情况。语义由子类自由定义:在互斥锁里,state=0 表示无人持有,state=1 表示被占用;在可重入锁里,state>0 还记录了重入次数;在信号量里,state 表示剩余许可数量。对 state 的修改必须用 CAS(compareAndSetState),保证原子性。

2.2 CLH 队列:等待线程的双向链表

当线程抢锁失败时,AQS 会把它包装成一个 Node 节点,加入一条名为 CLH(Craig-Landin-Hagersten)的双向等待队列。队列头节点是”已持有锁”的虚拟节点,后续节点按 FIFO 顺序排队。每个节点有一个 waitStatus,标识它是否需要被唤醒。线程入队后通过 LockSupport.park() 挂起,避免空转消耗 CPU——这与 线程池的任务队列思路 异曲同工,都是”排队 + 等待调度”。

2.3 独占模式 vs 共享模式

AQS 支持两种资源获取方式:独占(Exclusive)如锁,同一时刻只有一个线程能持有;共享(Shared)如读锁或信号量,多个线程可同时持有。子类通过重写 tryAcquire/tryRelease(独占)或 tryAcquireShared/tryReleaseShared(共享)来声明自己的规则。

钩子方法模式你需要返回什么
tryAcquire(arg)独占true=获取成功,false=失败入队
tryRelease(arg)独占true=释放后应唤醒后继
tryAcquireShared(arg)共享正数=还有余量,0=刚好,负数=失败
tryReleaseShared(arg)共享true=释放后应传播唤醒
isHeldExclusively()独占当前线程是否独占资源

三、实战:用 AQS 手写一个可重入锁

3.1 先搭骨架:继承与钩子

最简单的可重入锁,只需重写 tryAcquire 和 tryRelease。下面先给出骨架,再补可重入细节。

import java.util.concurrent.locks.AbstractQueuedSynchronizer;

public class ReentrantLockByAqs {
    private final Sync sync = new Sync();

    private static class Sync extends AbstractQueuedSynchronizer {
        @Override
        protected boolean tryAcquire(int arg) {
            // 待实现:CAS 抢 state
            return false;
        }

        @Override
        protected boolean tryRelease(int arg) {
            // 待实现:释放并清 owner
            return false;
        }

        @Override
        protected boolean isHeldExclusively() {
            return getExclusiveOwnerThread() == Thread.currentThread();
        }
    }

    public void lock()    { sync.acquire(1); }
    public void unlock()  { sync.release(1); }
}

3.2 可重入的关键:记录持有线程与计数

可重入意味着”同一个线程多次加锁不会把自己锁死”。实现要点有两条:①用 getExclusiveOwnerThread() 记录当前持锁线程;②用 state 记录重入次数。第一次获取把 owner 设为自己、state 置 1;重入时仅 state+1;释放时 state-1,减到 0 才真正清 owner 并允许唤醒后继。

3.3 完整实现

import java.util.concurrent.locks.AbstractQueuedSynchronizer;

public class ReentrantLockByAqs {
    private final Sync sync = new Sync();

    private static class Sync extends AbstractQueuedSynchronizer {
        @Override
        protected boolean tryAcquire(int arg) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                // 无人持有:CAS 抢占,并把 owner 设为当前线程
                if (compareAndSetState(0, arg)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            } else if (getExclusiveOwnerThread() == current) {
                // 已是自己持有:重入,计数累加(注意 int 溢出)
                int next = c + arg;
                if (next < 0) throw new Error("Maximum lock count exceeded");
                setState(next);
                return true;
            }
            return false; // 被别人持有,入队等待
        }

        @Override
        protected boolean tryRelease(int arg) {
            int c = getState() - arg;
            if (getExclusiveOwnerThread() != Thread.currentThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null); // 真正释放
            }
            setState(c);
            return free; // 仅当 free=true 时 AQS 才唤醒后继
        }

        @Override
        protected boolean isHeldExclusively() {
            return getExclusiveOwnerThread() == Thread.currentThread();
        }
    }

    public void lock()   { sync.acquire(1); }
    public void unlock() { sync.release(1); }
}

注意 tryRelease 返回的 free:只有 state 归零才返回 true,AQS 才会触发后继节点的 unpark。重入一次就 unlock 一次、但没释放干净时,锁依然被持有——这正是可重入锁"加几次就得解几次"的本质。

四、公平锁与非公平锁

上面的实现其实是非公平的:新来的线程会直接 CAS 抢锁,即便队列里已有排队的线程。这也是 ReentrantLock 默认非公平的原因——吞吐更高。若想要公平(先来先得),tryAcquire 里加一道闸门:队列里有前驱在等,就别抢。

// 公平版 tryAcquire 的差异片段
if (c == 0) {
    // hasQueuedPredecessors():队列中有更早的等待者就放弃抢占
    if (!hasQueuedPredecessors() && compareAndSetState(0, arg)) {
        setExclusiveOwnerThread(current);
        return true;
    }
}
维度非公平锁公平锁
抢占顺序后来居上,可能插队严格 FIFO
吞吐量高(减少上下文切换)略低
饥饿风险极端下有线程长期拿不到无
典型用途通用默认需保证顺序的场景

五、AQS 在 JDK 中的身影

学会手写之后,你会发现 JDK 里的同步器只是一个个 AQS 的"规则实现":

  • ReentrantLock:独占模式,state 即重入计数,公平/非公平两套 Sync 内部类。
  • Semaphore:共享模式,state 即剩余许可数,acquire 扣减、release 归还。
  • CountDownLatch:共享模式,state 即计数器,归零后所有等待线程被一次性唤醒。
  • ReentrantReadWriteLock:把一个 int 的 state 拆成高 16 位(读计数)和低 16 位(写计数),玩转位运算。

理解了这套骨架,当你需要定制同步语义(如"限流闸门""批量许可"),就能像 分布式锁的设计 那样,从底层重新组合规则,而不是硬套现成 API。

六、常见坑与排障

坑现象修复
重入计数与释放不匹配锁永远不释放 / 提前释放加几次锁必须解几次,state 归零才清 owner
在 tryAcquire 里阻塞线程卡死、队列错乱钩子方法只能 CAS 判断,不能 park
忘记 setExclusiveOwnerThreadisHeldExclusively 与重入失效获取成功立即设置,释放归零立即清空
用 int 计数溢出负数 state 引发异常累加前判断 next < 0 抛错

七、落地建议

生产环境绝大多数场景直接用 ReentrantLock 即可,不必重复造轮子;但读懂 AQS 的价值在于"遇事不慌":当你的锁出现死锁、线程堆积、唤醒不及时时,能顺着 state、CLH 队列、park/unpark 这条链路精准定位。建议在本地把本文的示例代码跑通,再用 jstack 观察不同状态下线程的 BLOCKED / WAITING 栈帧,把"队列""挂起""唤醒"从概念变成你脑中的动态画面。配合 Java NIO 零拷贝 这类底层文章一起读,你对"Java 如何与操作系统协作"的认知会更完整。

上一篇 MySQL 8.0 窗口函数与 CTE 实战:告别复杂子查询
下一篇 git-delta 实战:Git diff 语法高亮分页器
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]