引言: 在并发编程的世界里,synchronized 就像是一把自带的“傻瓜相机”,虽然好用,但在面对需要“超时放弃”、“响应中断”或“公平排队”的复杂高并发场景时,往往显得力不从心。那么,Java 大神 Doug Lea 是如何用纯 Java 代码在应用层构建出超越底层 synchronized 的超强锁机制的呢?答案就在 JUC 的“心脏”—— AQS(抽象队列同步器)。本文我们将从专治海量分治任务的 ForkJoin 框架热身,随后直击 AQS 核心,带你手搓自定义锁,最后在源码级别深度推演 ReentrantLock 与条件变量的每一次状态流转,彻底打通并发协作的任督二脉!


一、并发计算利器:ForkJoin 框架与工作窃取

在正式一头扎进极其烧脑的 AQS 源码之前,我们先用一个相对独立、但将“分治思想”发挥到极致的并发框架来热热身——ForkJoin

当遇到海量数据的计算时,单线程跑太慢,普通的线程池又难以处理任务之间的父子依赖关系,这时候就轮到 ForkJoin 登场了。


🟡 ForkJoin 的基本使用与防坑优化

通俗理解:ForkJoin 的逻辑,像极了大厂里的“项目派发机制”。技术总监拿到一个史诗级需求(大任务),发现一个人搞不定,就把它拆分(Fork)给几个业务线组长;组长再拆给一线开发。等最底层的兄弟们写完代码,再一层层向上合并(Join)成果,最终完成上线。

极简核心代码:

Java

// 继承 RecursiveTask 用于有返回值的任务
class CalcTask extends RecursiveTask<Integer> {
    private int start, end;
    private static final int THRESHOLD = 100; // 拆分阈值

    protected Integer compute() {
        if (end - start <= THRESHOLD) {
            // 粒度足够小,直接执行真实计算...
            return doCalculate();
        }
        // 粒度太大,一分为二继续拆 (Fork)
        int mid = (start + end) / 2;
        CalcTask left = new CalcTask(start, mid);
        CalcTask right = new CalcTask(mid + 1, end);
        left.fork(); 
        right.fork();
        // 合并结果 (Join)
        return left.join() + right.join();
    }
}

实战防坑优化:在写业务代码时,千万控制好 THRESHOLD(拆分粒度)。如果你要计算 10 万个数字相加,绝不能拆到“每个任务只算 2 个数字”。因为频繁创建 RecursiveTask 对象的内存分配开销,以及海量微小任务的线程调度成本,会远大于并行计算带来的收益,甚至让性能倒退回单线程水平。


🔴 核心机制之“工作窃取(Work Stealing)算法”

掌握了 API 只是入门,在准备冲击头部互联网大厂时,面试官绝对会问到底层:ForkJoin 凭什么能把多核 CPU 压榨到极致? 核心答案全在“工作窃取”这四个字上。

在 ForkJoinPool 中,每一个 Worker 线程都拥有一个自己专属的双端队列(Deque)

  1. 本职工作(LIFO):当线程正常处理自己队列里的任务时,它把队列当成一个“栈”。无论是往里塞新任务(push),还是取任务执行(pop),它永远只操作队列的头部(Head)
  2. 工作窃取(FIFO):当某个“卷王”线程提前把自己的活干完了,它不会闲着,而是去**别的线程队列的尾部(Tail)**去偷任务(poll)来干。

🔥 面试官:大家都去别的队列里“抢”任务,难道不会引发严重的锁竞争,反而拖慢性能吗?

- **回答**:绝妙之处就在于双端队列的**“头尾分离”设计**!主人线程始终在“头部”折腾,而窃取线程始终从“尾部”拿活。两者各干各的,在绝大多数时间里物理隔离,完全没有交集,这极大地避免了锁竞争。只有当队列里只剩最后一个任务,“头尾相撞”时,才会触发一次极其轻量级的 CAS 操作来决出胜负。这种设计真正实现了无锁化的高效窃取。

🔥 真实业务防坑指南:

在我们熟悉的类似苍穹外卖这种包含复杂商户端报表统计的系统里,如果是跑月底的海量历史订单金额汇总,ForkJoin 是个并行计算的好帮手。但绝对不要在 ForkJoinPool 中执行阻塞式的 I/O 操作(如查 MySQL、请求第三方支付接口)! 它的设计初衷是极速处理“纯 CPU 密集型任务”。一旦某个窃取线程因为等待网络 I/O 陷入阻塞,它不仅自己干不了活,还会导致工作窃取机制的运转出现卡顿。对于 I/O 密集型的任务,请老老实实用回我们之前讲过的普通 ThreadPoolExecutor 并配合合理的队列隔离机制。

二、JUC 的心脏:AQS 抽象队列同步器

在上一章搞定了 ForkJoin 之后,我们终于要直面 JUC 的终极大 Boss——AQS(AbstractQueuedSynchronizer,抽象队列同步器)

如果直接去翻 ReentrantLock 的源码,绝对会被绕晕。因为所有的锁、信号量、倒计时器,它们的核心排队和阻塞逻辑并没有写在自己类里,而是全部交给了背后这个叫 AQS 的“底层基座”。懂了 AQS,整个并发包的源码对你来说就是降维打击。

今天,我们先不看极其复杂的重入机制,就单刀直入,剖开 AQS 的最核心骨架。


🔴 AQS 核心设计理念(State 与 CLH 队列)

AQS 的本质,其实只为了解决两个最核心的问题:资源状态怎么记录?抢不到资源的线程怎么排队? Doug Lea 大神用极其精炼的两个结构给出了完美答案:volatile int stateCLH 双向等待队列

1. 资源的看门人:**state**** 变量** AQS 内部维护了一个 volatile int state 变量。你可以把它当成一把锁的“指示灯”:

  • state == 0 时,表示绿灯,资源空闲。
  • state == 1(或大于1)时,表示红灯,资源已被占用。

为了保证多线程同时抢灯时的绝对安全,AQS 强制要求使用底层的 CAS(Compare-And-Swap) 操作来修改它,确保修改过程的原子性。

2. 失败者的收容所:CLH 双向队列 当一个线程通过 CAS 抢锁失败了怎么办?总不能让它一直死循环耗费 CPU 吧。 这时候,AQS 会把这个倒霉的线程封装成一个 Node 节点,放到自己的 FIFO(先进先出)双向链表 的尾部,并调用底层的 LockSupport.park() 方法让这个线程直接挂起(睡觉)。直到拿到锁的线程释放资源时,才会去队列头部把下一个节点 unpark() 唤醒。

🔥 面试官:为什么 AQS 的等待队列必须被设计成“双向”链表(有 prev 和 next 指针),而不用简单的“单向”链表?

- **回答**:如果是严格的 FIFO 排队,单向链表确实够了。但在真实的高并发业务场景下(比如外卖抢单、大促秒杀),很多线程在排队时可能会因为**“等待超时”或者被“外部中断”**而放弃抢锁。 如果用单向链表,当中间某个节点要放弃时,必须从头节点开始遍历(时间复杂度 O(N))才能找到它的前驱节点来完成删除。而使用双向链表,因为自带 `prev` 指针,节点可以直接通过 `prev` 修改前一个节点的指向,时间复杂度瞬间降为 O(1)。AQS 为了极致的性能和处理中断,必须采用双向链表!

🟡 基于 AQS 手写自定义不可重入锁

理解了原理,我们来看看 AQS 的代码怎么写。

AQS 在设计上极其优雅地使用了“模板方法模式(Template Method)”。你可以把它想象成一套“全自动餐厅排号系统”:系统已经帮你写好了怎么取号、怎么在大厅排队、怎么叫号(复杂的入队和挂起逻辑)。你作为餐厅老板,只需要配置两个最简单的规则:“怎么算有空桌(尝试加锁)”“怎么算吃完走人(尝试解锁)”。

我们只需继承 AQS,重写 tryAcquiretryRelease 两个方法,就能白嫖它强大的排队机制,手搓一把自定义锁:

极简核心代码:

Java

// 自定义一把不可重入的独占锁
public class MyLock implements Lock {
    
    // 内部继承 AQS,实现自己的业务规则
    private class Sync extends AbstractQueuedSynchronizer {
        // 尝试加锁规则:把 state 从 0 改为 1 就代表抢到了
        @Override
        protected boolean tryAcquire(int arg) {
            if (compareAndSetState(0, 1)) {  // 本质是 CAS(0 -> 1)只有当 state 为 0 的时候才能改成1,否则失败
                setExclusiveOwnerThread(Thread.currentThread()); // 标记当前线程占有锁
                return true;
            }
            return false;
        }

        // 尝试解锁规则:把 state 改回 0,清空占有线程
        @Override
        protected boolean tryRelease(int arg) {
            if (getState() == 0) throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0); // 必须放在最后,因为 state 是 volatile,能利用 happen-before 原则保证可见性
            return true;
        }
        
        // 是否处于独占状态
        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }
    }

    private final Sync sync = new Sync();

    // 暴露给外界的 lock() 接口,直接调用 AQS 帮我们写好的模板方法 acquire()
    @Override
    public void lock() {
        sync.acquire(1); // 抢不到?AQS 会自动帮你把线程塞进 CLH 队列并 park 挂起!
    }

    @Override
    public void unlock() {
        sync.release(1); // 解锁时,AQS 会自动帮你唤醒队列里的下一个倒霉蛋!
    }
    
    // ... 省略其他 Lock 接口需实现的方法
}

通过这几十行代码你就拥有了一把工业级的锁。这就是 AQS 的魅力所在。看懂了这个极简版的骨架,接下来我们再去啃 ReentrantLock 庞大的源码,就不会迷失方向了。

三、工业级锁王:ReentrantLock 源码深度推演

站在 AQS 这个强大的底层基座上,我们终于要面对日常开发中最高频使用的“锁王”——ReentrantLock

今天,我将带你直接扒开源码的外衣,看透它最核心的状态流转。


🔴 非公平锁的加锁与解锁流转

默认情况下,ReentrantLock 使用的是非公平锁。假设此时资源被占用,一个线程尝试 compareAndSetState(0, 1) 抢锁失败,接下来它将面临 AQS 精心设计的“夺命三连”入队阻塞流程:

  1. **addWaiter**(入队):线程抢锁失败,会被包装成一个 Node 节点。这里 AQS 采用的是尾插法。注意一个细节,如果队列是空的,AQS 会极其巧妙地先塞入一个 Dummy 节点(哨兵节点) 作为头节点,然后再把当前线程接在后面。
  2. **acquireQueued**(自旋与挂起准备):入队后,线程不会立刻沉睡。它会进行最后一次挣扎(自旋),看看前驱节点是不是 Head,是的话再试着抢一次锁。如果还是失败,它会把前驱节点的 waitStatus 改为 -1(表示“我准备睡了,你走的时候记得叫醒我”)。
  3. **parkAndCheckInterrupt**(陷入阻塞):前驱节点的状态打好招呼后,当前线程安心调用 LockSupport.park(),彻底挂起,让出 CPU。

解锁流转(**unparkSuccessor**): 当占有锁的线程调用 unlock 时,会把 state 减为 0。然后它需要去唤醒排在后面的兄弟。

🔥 面试官:在 **unparkSuccessor** 唤醒下一个节点时,如果发现下一个节点为空或者被取消(**waitStatus > 0**),AQS 为什么要【从尾到头】反向遍历寻找真正需要被唤醒的节点?为什么不顺着链表从头往后找?

- **回答**:这是 AQS 源码中最经典的并发细节!在节点入队(`addWaiter`)时,尾插法分为三步:1. `node.prev = tail`; 2. `CAS(tail, node)`; 3. `pred.next = node`。 在高并发下,这三步**不是原子操作**!有可能执行完第 2 步,还没来得及执行第 3 步(此时 `pred.next` 还是 `null`),正好碰到解锁线程去唤醒。如果从头往后找,遇到 `next == null` 线索就断了。但是,节点插入时 `prev` 指针是早就绑好的,所以**从尾往头找,是一条绝对安全、连贯的路径**!

🔴 公平锁与非公平锁的本质源码差异

“公平”意味着严格按照先来后到的顺序排队;“非公平”意味着可以插队。这两者在底层几千行的代码里,到底差在哪?

其实,一行源码定乾坤

极简核心代码:

Java

// 公平锁的 tryAcquire 实现
protected final boolean tryAcquire(int acquires) {
    // ...
    if (c == 0) {
        // 就比非公平锁多了下面这个 hasQueuedPredecessors() 判断!
        if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // ...
}

公平锁在尝试 CAS 抢锁之前,强制调用了 hasQueuedPredecessors() 去看一眼 AQS 队列里有没有人在排队。如果有,它会立刻放弃抵抗,乖乖去队尾排队。而非公平锁则是上来不管三七二十一,直接执行 CAS 试图抢占,抢不到再去排队。

🔥 真实业务防坑指南:

在我们开发类似“苍穹外卖”这种对吞吐量要求极高的高并发派单系统时,除非业务有极其严格的顺序要求,否则永远使用默认的非公平锁! 为什么?假设骑手 A 刚交出锁,此时排在队首的骑手 B 被唤醒。注意,被唤醒到获得 CPU 执行权是需要发生上下文切换的,这需要几毫秒的时间。恰好在这时,骑手 C 带着新任务进来了。如果是公平锁,C 只能去排队,CPU 眼睁睁看着资源空闲几毫秒等 B 醒来。如果是排他非公平锁,C 瞬间抢到锁并执行完毕,等 B 真正醒来时接着执行。非公平锁通过压榨这几毫秒的上下文切换时间,极大地提升了系统的极致吞吐量。


🟡 锁重入与可打断机制

最后,我们用大白话快速过一下另外两个重要特性:

  • 锁重入(Reentrant):说白了就是“自己人别防自己人”。当一个线程已经拿到了锁,如果它调用的其他方法里又需要这把锁,它能直接进去吗?当然能。底层逻辑非常简单:只需判断一下 current == getExclusiveOwnerThread(),如果是自己,就把 state 的值累加(比如变成 2、3)。当然,出去的时候也要调用对应次数的 unlock,直到 state 减回 0 才算真正释放。
  • 可打断(**lockInterruptibly**:你可以把普通 lock() 想象成去网红店排队,无论等多久、天塌下来也死等,别人给你打电话(发送 Interrupt 中断信号)你也假装没听见,把中断标记吞掉。而 lockInterruptibly() 则是带着手机排队,一旦排队期间接到紧急电话(被中断),它会立刻停止排队,直接抛出 InterruptedException,让你有机会在业务代码里执行应急处理逻辑。

四、 精准协同:条件变量 Condition 底层揭秘

学完 ReentrantLock 这个大门锁,我们再来看看与它配套的“休息室”——Condition

在实际开发中,我们经常会遇到这样的场景:线程虽然抢到了锁,但发现执行条件还不满足(比如外卖小哥拿到了出餐台的锁,却发现后厨还没做完菜)。这时候,它不能占着茅坑不拉屎,必须把锁让出来,去旁边挂起休息,等条件满足了再被叫醒。这就是 Condition 的核心作用。


🔴 Await 与 Signal 的双队列流转机制

很多同学以为 AQS 里只有一个排队队列,这是极其致命的误解!面试官非常喜欢考查线程在 await()signal() 时的底层流转图。

澄清一个核心概念:在 AQS 的完整体系中,存在两种完全不同的队列:

  1. 同步阻塞队列(Sync Queue):我们在上一章讲的,由 AQS 掌管的双向链表,里面排队的都是渴望抢到锁的线程。
  2. 条件等待队列(Condition Queue):由 ConditionObject 掌管的单向链表,里面排队的都是主动放弃锁、在休息室里等信号的线程。

1. **await()** 的流转(从同步队列到条件队列): 当一个已经拿到锁的线程调用 condition.await() 时,它会经历以下三步:

  • 进休息室:把自己包装成一个状态为 CONDITION (-2) 的 Node 节点,加入到条件队列(单向链表)的尾部。
  • 彻底放锁:释放当前持有的所有锁资源(把 state 降为 0),并唤醒同步队列里的下一个老哥。
  • 沉睡挂起:调用 LockSupport.park() 挂起自己。

2. **signal()** 的流转(从条件队列回同步队列): 当另一个拿到锁的线程调用 condition.signal() 时:

  • 它会从条件队列的头部摘下第一个节点。
  • 转移阵地:将这个节点的状态改回 0,并强行把它塞回同步队列(双向链表)的尾部
  • 注意:signal 只是把线程从“休息室”挪到了“抢锁排队区”,此时被唤醒的线程还需要老老实实地在同步队列里排队,等前面的大哥们释放锁,它才能真正恢复执行!

极简核心代码:

Java

// await 底层核心逻辑
public final void await() throws InterruptedException {
    // 1.把自己加入条件队列(单向链表)
    Node node = addConditionWaiter(); 
    // 2.【核心】彻底释放当前持有的锁(包括重入次数),如果不放锁,其他线程根本进不来!
    int savedState = fullyRelease(node); 
    // 3. 只要自己还在条件队列里,就一直睡
    while (!isOnSyncQueue(node)) { 
        LockSupport.park(this);
    }
    // 4. 被 signal 唤醒并转移到同步队列后,重新参与抢锁!并恢复当初释放的重入次数
    acquireQueued(node, savedState); 
}

🔥 面试官:调用 **await()** 时,底层代码里那个 **fullyRelease(node)** 是什么意思?假如当前线程之前触发了 3 次锁重入(state = 3),调用 **await()** 会发生什么?被唤醒时又会怎样?

- **回答**:这是考查对锁重入机制与 Condition 协同的深度理解。如果当前线程重入了 3 次锁,调用 `await()` 时绝不能只释放 1 次,否则锁依然被占用,其他线程永远拿不到锁去发送 `signal`,直接引发死锁!因此 `fullyRelease` 会**一次性将 state 清零**(无论重入多少次都彻底放锁),并将当初的重入次数(3)保存下来。等到该线程被 `signal` 唤醒并重新在同步队列中抢到锁时,它会再次拿着当初保存的数字 3,把 state 直接恢复到 3。这就是极致严谨的状态现场保护!

🟢 与传统 Wait/Notify 的对比

既然 Object 类自带了 wait()notify(),为什么还要造一个 Condition

打个生活化的比方:传统的 wait/notify 就像是一个餐厅只有一个公共等位区,当厨师喊一嗓子 notifyAll()(菜做好了)时,无论等面条的还是等米饭的顾客全都会被惊醒跑过来抢,这就是严重的“惊群效应”。

Condition 的最大优势在于:一把 ReentrantLock 可以衍生出多个 **Condition**(多个 VIP 休息室)。你可以让等面条的在 A 休息室,等米饭的在 B 休息室。厨师做好面条后,只需要精准调用 conditionA.signal(),只唤醒等面条的线程,极大减少了无效的 CPU 竞争。

五、总结

纵观 ForkJoin 的工作窃取机制,再到 AQS 与 ReentrantLock 的精妙设计,我们学到的绝不仅仅是“如何保证线程安全”,而是**“对系统资源的极致压榨与精准调度”**。

这些底层原理带来的核心指导意义是:不要把并发编程简化为无脑加锁。 当你面对真实的高并发业务时,应该像架构师一样去权衡——是用“非公平锁”来省下上下文切换的时间换取极致吞吐量?还是用“双向链表+中断响应”来防止海量请求在队列中死等?又或者是用 Condition 替代 notifyAll 来避免“惊群效应”?懂透了 AQS 这套基座,你写下的每一次 lock()await() 才会做到真正的胸有成竹、游刃有余。

下一战,打破独占的僵局

尽管 ReentrantLock 性能强悍,但它终究是一把**“独占排他锁”**。试想一个极其常见的业务痛点:在一个配置中心或缓存系统中,每天有十万次“读”操作和寥寥几十次“写”操作。如果依然用排他锁,所有的“读”请求也会被迫串行排队,系统性能必将原地爆炸!能不能让“读”操作并发执行,只在“写”的时候互斥?

此外,如果我们不需要严格的互斥,而是想要限制某接口的并发访问上限(比如限流 100 人),又或者需要协同 5 个线程全部准备就绪后再同时发车,单靠一把锁又该如何破局?

下一篇博客**《并发协作工具箱:读写锁、信号量与循环屏障实战》**,我们将继续基于 AQS 变阵,带你彻底拔掉“死锁/活锁”的毒瘤,揭秘 ReentrantReadWriteLock 的锁降级机制,并实战操纵 SemaphoreCountDownLatch 等神兵利器。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐