Java AQS 实战:用 AbstractQueuedSynchronizer 手写可重入锁

Java AQS(AbstractQueuedSynchronizer)是 JDK 并发包的隐形基石:ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 全都建立在它之上。很多人会用锁,却说不清为什么加锁就能互斥、抢不到锁的线程去哪了。本文从零手写一把可重入锁,把 AQS 的 state、CLH 队列与独占模式彻底讲透,让你以后看任何并发工具源码都不发怵。

一、AQS 的核心三件套:state、队列、CAS

AQS 的设计极其精简,它把”同步状态管理”和”线程排队阻塞”两件事拆开:子类只关心状态怎么变,AQS 负责谁来排队、怎么唤醒。理解下面三件套,就理解了 AQS 的骨架。

1.1 state:一个被 volatile 保护的 int

state 是 AQS 唯一关心的”资源数量”。对独占锁来说,0 表示未被占用、1 表示被占用;对信号量来说,它表示剩余许可数。读它用 getState(),改它必须用 CAS,不能普通赋值。

// AQS 内部关键字段(简化)
private volatile int state;

protected final int getState() { return state; }
protected final void setState(int newState) { state = newState; }

// 原子 CAS 改 state,这是 AQS 并发安全的根基
protected final boolean compareAndSetState(int expect, int update) {
    return U.compareAndSwapInt(this, STATE_OFFSET, expect, update);
}

1.2 等待队列:CLH 队列的变体

抢不到锁的线程不会忙等,而是被包装成节点(Node)进入一条双向 FIFO 队列(AQS 借鉴了 CLH 自旋锁的思路,但改成阻塞等待)。队首是”持有锁或正在尝试获取”的节点,其余节点排队并 park 挂起,等前驱释放后被唤醒。

1.3 模板方法:AQS 搭台,子类唱戏

AQS 用模板方法模式把”获取/释放”的核心钩子留给子类实现,自己实现排队、入队、阻塞、唤醒。下面的表说清了分工:

类别方法谁来实现 / 提供
需要子类实现tryAcquire / tryRelease(独占)你(自定义同步器)
需要子类实现tryAcquireShared / tryReleaseShared(共享)你
需要子类实现isHeldExclusively你
AQS 提供acquire / release / acquireShared框架(含排队与 park)
AQS 提供getState / setState / compareAndSetState框架

二、动手:用 AQS 写一把可重入锁

理论说完直接上代码。我们继承 AbstractQueuedSynchronizer,实现一个支持可重入的独占锁:同一个线程可以多次加锁,必须对应次数释放。

2.1 自定义 Sync

import java.util.concurrent.locks.AbstractQueuedSynchronizer;

class ReentrantLockSync extends AbstractQueuedSynchronizer {

    // 是否处于独占(被锁住)状态
    @Override
    protected boolean isHeldExclusively() {
        return getState() > 0;
    }

    // 尝试获取:state 0 -> 1(首次),或当前线程已持有则重入 +1
    @Override
    protected boolean tryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current); // 记录持有线程
                return true;
            }
        } else if (getExclusiveOwnerThread() == current) {
            int next = c + acquires;          // 重入计数 +1
            if (next < 0) throw new Error("超出最大重入次数");
            setState(next);
            return true;
        }
        return false; // 被别的线程持有,入队等待
    }

    // 尝试释放:重入计数 -1,归零才真正释放
    @Override
    protected boolean tryRelease(int releases) {
        int c = getState() - releases;
        if (getExclusiveOwnerThread() != Thread.currentThread())
            throw new IllegalMonitorStateException();
        boolean free = false;
        if (c == 0) {
            free = true;
            setExclusiveOwnerThread(null);
        }
        setState(c);
        return free; // 只有完全释放才唤醒后继
    }
}

关键点有两个:① 用 setExclusiveOwnerThread 记录持有者,这是可重入判断的依据;② tryRelease 只有计数归零才返回 true,否则只是把重入层数减一,锁依然被持有。

2.2 封装成 Lock 接口

import java.util.concurrent.locks.Lock;

public class MyReentrantLock implements Lock {
    private final ReentrantLockSync sync = new ReentrantLockSync();

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

    @Override public boolean tryLock()  { return sync.tryAcquire(1); }

    // 为简洁省略 interruptibly / 超时 / Condition 实现
    @Override public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
    }
    @Override public Condition newCondition() { return sync.newCondition(); }
    @Override public boolean tryLock(long t, TimeUnit u) throws InterruptedException {
        return sync.tryAcquireNanos(1, u.toNanos(t));
    }
}

注意 lock() 调的是 AQS 的 acquire(1) 而不是 tryAcquire:当 tryAcquire 返回 false 时,AQS 会自动把线程入队、park 挂起,等锁释放再唤醒重试。排队逻辑我们一行都没写,全交给框架。

2.3 并发自测

public static void main(String[] args) throws InterruptedException {
    MyReentrantLock lock = new MyReentrantLock();
    int[] counter = {0};
    int threads = 10, per = 1000;

    List<Thread> ts = new ArrayList<>();
    for (int i = 0; i < threads; i++) {
        ts.add(new Thread(() -> {
            for (int j = 0; j < per; j++) {
                lock.lock();
                try { counter[0]++; }      // 临界区
                finally { lock.unlock(); }
            }
        }));
    }
    ts.forEach(Thread::start);
    for (Thread t : ts) t.join();

    System.out.println("期望=" + (threads * per) + " 实际=" + counter[0]);
    // 输出:期望=10000 实际=10000 —— 锁保证了原子性
}

三、独占模式 vs 共享模式

AQS 同时支持两种资源语义。独占就像独卫:一个人用,别人必须等;共享像公共卫生间有 N 个坑位:state 表示剩余坑位数,多个线程可同时进入,直到耗尽。

维度独占模式共享模式
子类钩子tryAcquire / tryReleasetryAcquireShared / tryReleaseShared
state 含义0 空闲 / ≥1 被占剩余许可数(可为负)
典型实现ReentrantLockSemaphore、CountDownLatch、读锁
释放后只唤醒一个后继可能连锁唤醒整条队列

3.1 Semaphore 如何用共享模式

// Semaphore 内部 Sync 的简化逻辑(共享模式)
protected int tryAcquireShared(int acquires) {
    for (;;) {
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining; // 负数表示获取失败,将排队
    }
}

返回负数代表”资源不够,去排队”;返回非负数代表”拿到了,还能唤醒后面同样能拿到的人”。这就是共享模式会连锁传播唤醒的原因。

四、公平锁与非公平锁

ReentrantLock 默认非公平:新来的线程可以插队抢锁,性能好但可能让排队线程”饿死”。构造时传 true 则变公平:必须先看队列里有没有前驱在等。

对比非公平(默认)公平
抢锁策略直接 CAS 抢,失败再排队先 hasQueuedPredecessors() 判断
吞吐高(减少上下文切换)较低(严格排队)
饥饿风险有无
// 公平锁在 tryAcquire 里多一道检查
protected 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 (getExclusiveOwnerThread() == current) {
        setState(c + acquires);
        return true;
    }
    return false;
}

五、AQS 的阻塞与唤醒:park / unpark

队列里的线程不是自旋烧 CPU,而是用 LockSupport.park() 挂起,释放锁时用 unpark() 精准唤醒后继节点。相比 synchronized 的监视器,AQS 的唤醒粒度更细——只唤醒队列头部的下一个有效节点,而不是随机唤醒一个。

// AQS 内部(极度简化)的唤醒逻辑
private void unparkSuccessor(Node node) {
    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); // 只唤醒一个
}

六、常见误区与排查清单

误区 / 坑正确做法
用 setState 而非 CAS 改 state多线程下必须用 compareAndSetState
tryRelease 不判断持有线程非持有线程释放要抛 IllegalMonitorStateException
重入计数不配套加几次锁就要对应释放几次,否则死锁或泄漏
lock 后临界区抛异常必须在 finally 里 unlock()
共享模式忘记传播返回值符号决定要不要连锁唤醒后继

七、AQS 与虚拟线程的关系

很多人担心:Java 21 的Java 21 虚拟线程出来后,还要不要学 synchronized 和 AQS?答案是更要学。虚拟线程在 synchronized 块或原生 ReentrantLock 上会”钉住”(pin)载体线程,反而要求你更懂锁的粒度。理解 AQS 能帮你判断哪些临界区该用 ReentrantLock、哪些该拆小,避免把虚拟线程的优势耗在锁竞争上。相关的高并发范式演进也可以看虚拟线程(Project Loom)实战一文,以及强调不可变思想的Java 函数式编程。

八、落地清单

  • 需要互斥且要可重入、可超时、可中断 → 优先用现成 ReentrantLock,别重复造轮子。
  • 要限制并发数(如限流、连接池)→ 用 Semaphore(共享模式)。
  • 要等一组任务完成再继续 → 用 CountDownLatch / CyclicBarrier。
  • 确实需要自定义同步语义(如读写分离、状态机)→ 再继承 AQS 实现 tryAcquire/tryRelease。
  • 无论用哪种锁,unlock() 必须放在 finally;本地跑并发用例建议在容器或多核环境验证,并在 CI 里加并发测试。

九、小结

Java AQS 用”state + CLH 队列 + CAS”三件套,把并发同步的复杂度收敛到一个可扩展的骨架里。你写的不过几十行 tryAcquire/tryRelease,排队、阻塞、唤醒全由框架兜底。吃透它,不仅是能手写一把锁,更是拿到了阅读整个 JUC 并发包源码的钥匙。下回遇到诡异的并发 bug,你也能从”锁状态”和”等待队列”两个角度精准定位。

上一篇 Web 服务器日志分析实战:GoAccess 与命令行
下一篇 Web Worker 实战:把重计算移出主线程
最新评论
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 […]