Java 并发核心:从 AQS 到线程池与虚拟线程
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 // 拒绝策略
);
执行流程的顺序很重要,而且和直觉相反:
- 线程数 < core,新建线程执行。
- 线程数 ≥ core,入队。
- 队列满了,线程数 < max,新建非核心线程。
- 队列满且线程数 = 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 支持虚拟线程)需要适配。数据库连接池仍然是有限资源,虚拟线程再多,连接数上限不变,这一点不会因为换模型而改变。
排查并发的三个实用手段
- jstack 抓线程栈。连续抓 3 次,间隔 5 秒,对比同一批线程的栈顶。如果大量线程卡在同一个
park或wait上,基本就是锁竞争或下游超时。jstack -l会自动检测死锁。 - 看线程名。默认
pool-1-thread-1这种名字在 500 个线程的 dump 里毫无用处。自定义ThreadFactory,把业务名和序号带上,排查效率差好几倍。 - 用
ThreadMXBean做运行时检测。findDeadlockedThreads()可以定期巡检,把死锁检测做成定时任务,比等用户报障强。
一份压缩版建议
- 临界区短、无中断/超时需求:
synchronized足够,别为了"显得高级"换ReentrantLock。 - 需要超时、可中断、多条件队列:用
ReentrantLock+Condition。 - 读多写少且能接受不可重入:考虑
StampedLock,但要写清楚约束。 - 线程池:手动 new,有界队列,显式拒绝策略,自定义线程名,接监控。四条缺一不可。
- 线程数:公式给起点,压测定终值,同时受下游连接池约束。
- 高并发 I/O:JDK 21+ 优先考虑虚拟线程;注意
synchronized钉住、别池化、别塞 CPU 任务。 - 所有并发代码都要能被
jstack看懂。看不懂的并发代码,等于没有维护者。
并发工具的演进方向很清晰:从"让线程更便宜"到"让等待更便宜"。AQS 把等待排队做成了通用框架,线程池把线程复用做成了标准件,虚拟线程则直接把"阻塞"这件事的成本降到了接近零。理解这条主线,比记住每个类的 API 更有用。
- 点赞
- 收藏
- 关注作者
评论(0)