Java 并发核心:从 AQS 到线程池与虚拟线程

举报
yd_212140359 发表于 2026/11/13 23:31:50 2026/11/13
【摘要】 Java 并发核心:从 AQS 到线程池与虚拟线程synchronized 和 ReentrantLock 的区别,背过面试题的人都能说出几条:前者是 JVM 关键字,后者是 JDK 类;前者自动释放,后者要手动 unlock。但这些答案解释不了一个更实际的问题:为什么 JDK 要在 synchronized 已经优化得不错的情况下,仍然把整套并发工具建在 AQS 上?以及 JDK 21 ...

Java 并发核心:从 AQS 到线程池与虚拟线程

synchronized 和 ReentrantLock 的区别,背过面试题的人都能说出几条:前者是 JVM 关键字,后者是 JDK 类;前者自动释放,后者要手动 unlock。但这些答案解释不了一个更实际的问题:为什么 JDK 要在 synchronized 已经优化得不错的情况下,仍然把整套并发工具建在 AQS 上?以及 JDK 21 引入虚拟线程之后,这套基于平台线程的池化模型又该放在什么位置?这篇文章沿着"锁 → 同步器 → 线程池 → 虚拟线程"这条线,把 Java 并发的执行模型串起来。

synchronized 到底锁了什么

synchronized 的锁信息不在你的代码里,而在对象头里。HotSpot 的对象头由 Mark Word 和 Klass Pointer 组成,Mark Word 在 64 位下占 8 字节,里面塞了 hashcode、GC 分代年龄、锁标志位。锁状态变化时,Mark Word 的内容会跟着变:

状态 标志位 Mark Word 主要存什么
无锁 01 hashcode、分代年龄
偏向锁 01 偏向的线程 ID(JDK 15 起默认禁用,18 起废弃)
轻量级锁 00 指向栈上 Lock Record 的指针
重量级锁 10 指向 ObjectMonitor 的指针
GC 标记 11 空

所谓"锁升级",是对象在不同竞争程度下切换实现:

  • 偏向锁:假设只有一个线程反复进入,把线程 ID 记在对象头里,再次进入只需比较 ID,连 CAS 都不用。问题是撤销偏向需要 STW(安全点),高并发下撤销开销反而大,所以 JDK 15 用 JEP 374 默认关掉了它,JDK 18 彻底废弃。
  • 轻量级锁:多个线程交替进入、但没有同时竞争时,线程在自己的栈帧里建 Lock Record,用 CAS 把对象头指向它。自旋几次能拿到就拿到,拿不到就膨胀。
  • 重量级锁:真正竞争时,膨胀成 ObjectMonitor,线程进入 entry list 阻塞,由操作系统调度。这时线程切换的成本就上来了,一次阻塞唤醒通常是微秒到毫秒级。

需要纠正一个常见误解:“锁升级不可逆”。从偏向到轻量到重量确实不会降级(除了 GC 时的一些特殊情况),但这不代表"用一次 synchronized 就永远慢"。绝大多数业务代码根本走不到重量级锁。

另外,synchronized 的锁是对象,不是代码块。synchronized (this) 锁的是当前实例,synchronized (Foo.class) 锁的是 Class 对象。用 String 常量或者包装类型做锁对象是典型的坑:Integer 在 -128~127 之间有缓存,两个看起来不同的锁可能是同一个对象。

为什么还需要 AQS

synchronized 有两个硬限制:不能中断等待、不能超时、不能尝试非阻塞获取,而且只有一种"独占"语义。但并发编程需要的是更丰富的能力:

  • 可中断的等待(lockInterruptibly)。
  • 带超时的尝试(tryLock(timeout))。
  • 多个条件队列(Condition,类似 wait/notify 但有多个队列)。
  • 共享语义(信号量、闭锁、读写锁)。

这些能力如果每个都单独实现,会写出大量重复代码。Doug Lea 的做法是抽象出一个同步器框架:把"如何管理 state"和"如何排队等待"这两件事固定下来,子类只需要实现"state 的含义"和"获取/释放时怎么改 state"。

这就是 AbstractQueuedSynchronizer(AQS)。

AQS 的三块拼图

1. 一个 volatile int state

state 是同步器的核心状态,语义由子类定义:

  • ReentrantLock:0 表示未锁定,大于 0 表示重入次数。
  • Semaphore:可用许可数。
  • CountDownLatch:剩余计数。
  • ReentrantReadWriteLock:高 16 位读锁数,低 16 位写锁数。

修改 state 用 CAS(compareAndSetState),保证原子性;读取直接读 volatile,保证可见性。

2. 一个 CLH 变体的双向等待队列

拿不到锁的线程不会盲目自旋,而是被包装成 Node 挂到队列尾部,然后 park 挂起。队列是双向链表,头节点是当前持有锁的线程(或一个哑节点)。

Node 有两个关键字段:

  • waitStatus:SIGNAL(-1) 表示"我释放时会唤醒后继",CANCELLED(1) 表示已取消,CONDITION(-2) 表示在条件队列里。
  • prev/next:前后指针。

入队和唤醒的细节是 AQS 最容易讲错的地方,抓住两条就够:

  • 新节点通过 CAS 挂到队尾(enq),如果队列还没初始化,会先 CAS 建一个哑头节点。
  • 释放时,如果头节点的 waitStatus 是 SIGNAL,就唤醒后继;后继醒来后重新尝试获取,失败则重新挂起。

3. 模板方法

AQS 把流程写死,把判断交给子类:

protected boolean tryAcquire(int arg);      // 独占获取,返回是否成功
protected boolean tryRelease(int arg);      // 独占释放
protected int tryAcquireShared(int arg);    // 共享获取,负数表示失败
protected boolean tryReleaseShared(int arg);
protected boolean isHeldExclusively();

acquire() 的骨架是:先 tryAcquire,失败就入队、挂起、循环重试。子类只需要回答"现在能不能拿到"。

一个最小实现可以帮助理解——用 AQS 写一个不可重入的互斥锁:

class SimpleLock extends AbstractQueuedSynchronizer {
    @Override
    protected boolean tryAcquire(int arg) {
        if (compareAndSetState(0, 1)) {
            setExclusiveOwnerThread(Thread.currentThread());
            return true;
        }
        return false;
    }

    @Override
    protected boolean tryRelease(int arg) {
        setState(0);                 // 简化版:不考虑重入
        setExclusiveOwnerThread(null);
        return true;
    }

    @Override
    protected boolean isHeldExclusively() {
        return getState() == 1;
    }

    void lock()   { acquire(1); }
    void unlock() { release(1); }
}

真正的 ReentrantLock 在 tryAcquire 里多了一段:如果当前线程已经是持有者,就 state + 1,这就是可重入。这也是为什么 synchronized 的"重入"能力在 AQS 里要自己实现——AQS 本身不关心重入。

CLH 队列的一个常见疑问:为什么不用自旋锁

纯自旋在低竞争、短临界区下效率很高,但线程数一多,所有线程一起 CAS 同一个 state,缓存行来回弹,性能反而崩(这就是 AtomicInteger 在高竞争下不如 LongAdder 的原因)。AQS 选择"自旋一两次 + park",把竞争转移到队列上,适合临界区可能较长的场景。

基于 AQS 的几把锁,能力差别在哪

ReentrantLock:

  • 公平锁:tryAcquire 里先检查队列有没有前驱,有就不抢,保证 FIFO。代价是吞吐下降,因为每次都要排队唤醒。
  • 非公平锁:直接 CAS 抢,抢不到再排队。允许"插队",吞吐更高,但可能饿死排队线程。
  • lockInterruptibly 让等待可被中断,适合需要响应取消的任务。
  • tryLock(timeout) 避免无限等待,是防止死锁拖垮服务的实用手段。

ReentrantReadWriteLock:读读不互斥、读写互斥、写写互斥。适合读多写少,但要注意锁降级(写锁 → 读锁可以,反过来不行)和写饥饿(读请求源源不断时写线程可能一直拿不到锁)。JDK 8 的 StampedLock 提供了乐观读,读不加锁、校验版本号,读多写少场景性能更好,但它不可重入,且不支持条件变量,用错会死锁。

Semaphore:共享模式,state 是许可数。适合限流、连接池大小控制。注意 acquire 和 release 必须成对,异常路径漏掉 release 会导致许可永久泄漏。

CountDownLatch:共享模式,计数到 0 唤醒所有等待者。一次性使用,不能重置。替代品是 CyclicBarrier(可重用,等所有线程到齐)和 Phaser(分阶段)。

线程池:参数、队列和拒绝

线程池是 Java 并发里最容易配错的东西。

new ThreadPoolExecutor(
    corePoolSize,      // 常驻线程数
    maximumPoolSize,   // 最大线程数
    keepAliveTime,     // 非核心线程空闲存活时间
    unit,
    workQueue,         // 任务队列
    threadFactory,     // 线程工厂(建议自定义命名)
    handler            // 拒绝策略
);

执行流程的顺序很重要,而且和直觉相反:

  1. 线程数 < core,新建线程执行。
  2. 线程数 ≥ core,入队。
  3. 队列满了,线程数 < max,新建非核心线程。
  4. 队列满且线程数 = max,执行拒绝策略。

也就是说,队列容量决定了 maximumPoolSize 是否会被用到。如果用的是无界队列(LinkedBlockingQueue 默认容量 Integer.MAX_VALUE),队列永远不满,线程数永远停在 core,maximumPoolSize 形同虚设,任务会无限堆积直到 OOM。这是 Executors.newFixedThreadPool 最被诟病的地方。

Executors 的几个工厂方法都有类似问题:

  • newFixedThreadPool / newSingleThreadExecutor:无界队列,堆积风险。
  • newCachedThreadPool:maximumPoolSize = Integer.MAX_VALUE,可能创建海量线程。
  • newScheduledThreadPool:同样没有队列上限。

所以生产上应该直接 new ThreadPoolExecutor,显式指定队列容量和拒绝策略。

线程数怎么定

经典公式:

  • CPU 密集:N + 1(N 为核数),多出来的 1 用于偶尔的页缺失或停顿。
  • I/O 密集:N × (1 + 等待时间 / 计算时间)。比如等待占 90%,就是 N × 10。

这个公式只能作为起点。真实系统里线程数受下游容量、连接池大小、内存(每个线程栈默认 1MB)共同约束。更靠谱的做法是:先按公式给个初始值,再用压测找拐点。拐点的特征是"再加线程,QPS 不涨,P99 开始涨"。

还有一个容易被忽视的上限:线程数不能超过下游连接池和数据库连接数的总和。200 个线程抢 20 个数据库连接,多出来的 180 个只是在队列里等。

队列和拒绝策略的选择

  • 队列用有界的 ArrayBlockingQueue 或指定容量的 LinkedBlockingQueue。
  • 拒绝策略:
    • AbortPolicy:直接抛 RejectedExecutionException,适合"必须让调用方知道失败"的场景。
    • CallerRunsPolicy:由提交任务的线程自己执行,天然形成背压,适合不希望丢任务的场景,但要注意它会阻塞调用方(比如 Netty 的 I/O 线程,绝对不能这么用)。
    • DiscardPolicy / DiscardOldestPolicy:静默丢弃,除非你明确知道可以丢,否则不要用。
    • 自定义:打点 + 告警 + 降级返回,通常是生产首选。

线程池必须监控

线程池出问题的表现往往不是报错,而是延迟变高、任务堆积。至少要暴露:

  • getActiveCount():活跃线程数。
  • getQueue().size():队列长度(最关键,持续增长就是出问题了)。
  • getCompletedTaskCount():累计完成任务数,用来算吞吐。
  • 拒绝次数:需要自己包装 handler 计数。

这些指标接到监控上,配一条"队列长度 > 阈值持续 1 分钟"的告警,比事后看日志有效得多。

虚拟线程:JDK 21 改变了什么

虚拟线程(JEP 444,JDK 21 正式)解决的是"平台线程太贵"这个问题。一个平台线程对应一个 OS 线程,栈默认 1MB 起,创建成本高,数量上限通常在几千。所以过去做高并发 I/O 只能选异步回调或响应式编程,代码可读性很差。

虚拟线程的思路是把线程的实现从 OS 层搬到 JVM 层:

  • Continuation:一个可暂停/恢复的执行单元,保存的是 JVM 堆上的栈帧,不是 OS 栈。
  • 调度器:虚拟线程跑在 ForkJoinPool(默认并行度 = CPU 核数)上,数量可以到百万级。
  • 挂起:遇到阻塞 I/O(JDK 21 里 Socket、FileChannel、Thread.sleep 等已改造)时,虚拟线程把栈卸载到堆上,释放载体线程,载体去跑别的虚拟线程。

所以写代码的方式回到了"一个请求一个线程"的同步风格,但吞吐接近异步模型:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = urls.stream()
        .map(u -> executor.submit(() -> httpGet(u)))
        .toList();
    for (var f : futures) {
        System.out.println(f.get());
    }
}

几个必须知道的点:

不要池化虚拟线程。虚拟线程创建成本极低,用完即弃是设计意图。用固定大小的虚拟线程池反而会退化——池里的线程被复用时,之前挂起的栈还在,失去了轻量的意义。要限流就用 Semaphore,而不是限制线程数。

pinning(钉住)。虚拟线程在以下情况无法卸载,会一直占着载体线程:

  • 在 synchronized 块里阻塞(JDK 21/22 的已知限制,JDK 24 的 JEP 491 已解决)。
  • 调用 native 方法或 JNI。
  • 在某些老版本里 Object.wait 也会。

载体线程数只有 CPU 核数,一旦大量虚拟线程被钉住,整个调度器就卡死。排查方式:-Djdk.tracePinnedThreads=full 会打印钉住的栈。

CPU 密集任务不适合。虚拟线程的优势是"等待时让出",纯计算任务用它没有任何收益,还会因为调度多一层开销。

ThreadLocal 要谨慎。百万级虚拟线程配 ThreadLocal,内存会爆。JDK 21 提供了 ScopedValue(预览)作为替代,但在它稳定之前,至少要把 ThreadLocal 的清理做干净。

生态兼容性。JDBC 驱动、连接池(HikariCP 已支持)、Servlet 容器(Tomcat 支持虚拟线程)需要适配。数据库连接池仍然是有限资源,虚拟线程再多,连接数上限不变,这一点不会因为换模型而改变。

排查并发的三个实用手段

  1. jstack 抓线程栈。连续抓 3 次,间隔 5 秒,对比同一批线程的栈顶。如果大量线程卡在同一个 park 或 wait 上,基本就是锁竞争或下游超时。jstack -l 会自动检测死锁。
  2. 看线程名。默认 pool-1-thread-1 这种名字在 500 个线程的 dump 里毫无用处。自定义 ThreadFactory,把业务名和序号带上,排查效率差好几倍。
  3. 用 ThreadMXBean 做运行时检测。findDeadlockedThreads() 可以定期巡检,把死锁检测做成定时任务,比等用户报障强。

一份压缩版建议

  • 临界区短、无中断/超时需求:synchronized 足够,别为了"显得高级"换 ReentrantLock。
  • 需要超时、可中断、多条件队列:用 ReentrantLock + Condition。
  • 读多写少且能接受不可重入:考虑 StampedLock,但要写清楚约束。
  • 线程池:手动 new,有界队列,显式拒绝策略,自定义线程名,接监控。四条缺一不可。
  • 线程数:公式给起点,压测定终值,同时受下游连接池约束。
  • 高并发 I/O:JDK 21+ 优先考虑虚拟线程;注意 synchronized 钉住、别池化、别塞 CPU 任务。
  • 所有并发代码都要能被 jstack 看懂。看不懂的并发代码,等于没有维护者。

并发工具的演进方向很清晰:从"让线程更便宜"到"让等待更便宜"。AQS 把等待排队做成了通用框架,线程池把线程复用做成了标准件,虚拟线程则直接把"阻塞"这件事的成本降到了接近零。理解这条主线,比记住每个类的 API 更有用。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。