多线程与 JUC
并发问题最麻烦的地方,不是代码看不懂,而是单线程测试全都通过,一到线上高峰就开始偶发超时、重复提交、队列堆积、日志上下文丢失、死锁和 CPU 飙高。
所以学多线程和 JUC,不能只背 Thread、synchronized、ReentrantLock、AQS、CAS 的定义。真正要掌握的是:什么时候该开线程,线程池怎么有边界,队列满了怎么办,锁等待怎么定位,异步任务怎么带上下文,线上怎么证明是线程池问题而不是下游问题。
这篇按项目里最常见的顺序来:先从 Thread 和线程状态开始,再补 JMM、volatile、happens-before、synchronized 和 CAS,然后讲 AQS、ReentrantLock、Condition、BlockingQueue、ThreadPoolExecutor、拒绝策略、并发容器、ThreadLocal、CompletableFuture 和虚拟线程,最后进入线程池隔离、队列堆积、死锁、上下文丢失和线上排查。
本文以 JDK 17、JDK 21、JDK 25 这些生产常见基线为主,同时用 JDK 26 官方文档校准 API 入口和迁移风险。JDK 21 之后虚拟线程已经可用于很多阻塞 IO 场景,但它不是“无限并发”的许可证,数据库连接池、下游容量、队列、限流和超时预算仍然要治理。
本文范围
本文覆盖的是 Java 应用内部并发开发和线上排障:Thread 怎么启动、线程状态怎么读、JMM 如何约束可见性和有序性、锁和 AQS 如何排队、线程池如何接住任务、队列和拒绝策略如何表达过载、异步任务如何传上下文、虚拟线程该怎么谨慎引入。
本文不展开操作系统线程调度细节,不讲 Netty 事件循环和网络 IO 模型,不讲数据库锁的完整体系,也不把分布式锁、限流熔断、消息补偿写成架构工程长篇。这些内容会在 JVM、Netty、MySQL、MQ 和高并发架构文章里分别展开。本篇只讲后端开发在 Java 进程里每天会直接写到、调到、排到的并发链路。
前置条件
准备实验时,建议至少有一个 JDK 17 或更高版本的本地环境;如果要验证虚拟线程,使用 JDK 21 或更高版本。线上排查命令以 Linux 服务器为主,Windows 本地可以用 IDE、JFR、JDK Mission Control 或 jcmd 替代一部分命令。
建议准备一个最小 Spring Boot 或普通 Java 工程,用于验证线程池、ThreadLocal、CompletableFuture、死锁和线程 dump。生产排障时需要能拿到 Java 进程 PID,并确认当前用户有权限执行 jcmd、jstack 或读取容器内线程栈。
一、完整知识树
完整知识树先放在这里,后面的正文会一段段落地:
Thread -> start / run / join / interrupt -> Java 线程状态 -> jcmd / jstack dump
JMM -> shared variables -> synchronization actions -> happens-before -> volatile -> synchronized
synchronized -> monitor -> wait set -> interrupt / notify -> 死锁风险
CAS -> AtomicInteger / AtomicReference / LongAdder -> ABA / 自旋成本
AQS -> state -> FIFO wait queue -> exclusive / shared -> ReentrantLock / Condition / Semaphore / CountDownLatch
BlockingQueue -> bounded / unbounded / direct handoff -> producer-consumer -> memory consistency
ThreadPoolExecutor -> execute -> ctl(runState + workerCount) -> addWorker -> workQueue -> runWorker -> getTask -> reject
拒绝策略 -> Abort / CallerRuns / Discard / DiscardOldest -> 业务过载语义
并发容器 -> ConcurrentHashMap / CopyOnWriteArrayList / ConcurrentLinkedQueue
ThreadLocal -> get / set / remove -> 线程池复用 -> 上下文传递与清理
CompletableFuture -> supplyAsync / thenCombine / allOf / orTimeout -> executor / exception / cancellation
虚拟线程 -> Thread.ofVirtual / newVirtualThreadPerTaskExecutor -> 阻塞 IO 扩展 -> 下游容量约束
线上反推 -> 队列堆积 -> 死锁 -> CPU 飙高 -> 上下文丢失 -> 线程池隔离 -> 告警和降级二、先把线程当成有成本的资源
场景说明:很多新手第一次做异步,喜欢在业务代码里直接 new Thread()。这在演示里没问题,在线上就会失控:线程没有名字、没有上限、异常没人接、上下文丢失、服务关闭时也不好收尾。
最小例子可以这样写,但只适合理解线程生命周期:
Thread worker = new Thread(() -> {
System.out.println("do async work");
}, "demo-worker-1");
worker.start();
worker.join(1000);这里要注意两件事:
- 调用
start()才会创建新线程执行任务;直接调用run()只是普通方法调用。 join(1000)表示最多等待 1 秒,不要在请求线程里无边界等待。
生产代码里更常见的是把任务交给线程池。线程池至少要提供:线程上限、队列上限、线程命名、拒绝策略、异常处理、指标和关闭逻辑。
验证线程状态:
jcmd "$PID" Thread.print -l > thread-dump.txt
grep -E "java.lang.Thread.State" thread-dump.txt | sort | uniq -c常见线程状态这样看:
| 状态 | 常见含义 | 排查重点 |
|---|---|---|
| RUNNABLE | 正在运行或可运行,也可能在 native/system call | 是否真消耗 CPU,要结合 top -Hp |
| BLOCKED | 等 monitor 锁 | 是否大量线程卡同一把锁 |
| WAITING | 无限等待,如 LockSupport.park、Object.wait | 是否线程池空闲等待,还是业务永远等不到结果 |
| TIMED_WAITING | 带超时等待,如 sleep、poll、网络等待 | 超时是否合理,是否大量下游调用卡住 |
| TERMINATED | 已结束 | 一般不是问题 |
常见坑:
Thread.stop()、suspend()、resume()这类旧式强制控制线程的方式已经是迁移风险:旧版本里长期废弃,现代 JDK 中要么抛异常,要么已经移除;生产取消任务要走interrupt、超时、取消令牌和业务补偿。- 捕获异常后不记录,异步任务失败就像没发生过。
- 请求线程等待异步结果时没有超时,异步反而把入口线程拖死。
生产建议:业务代码不要散落 new Thread()。需要异步就进入统一线程池治理:命名、容量、队列、拒绝、监控、关闭和排障模板都要固定。
三、线程先看 JMM:可见性、有序性和 happens-before
场景说明:很多并发 bug 看起来像“偶发没更新”“标志位明明改了却不退出”“单例偶尔空指针”。这类问题不一定是 CPU 太慢,而是多个线程读写共享变量时没有建立正确的同步关系。
先记住一条主线:JMM 不保证所有线程随时看到同一个“最新值”,它保证的是正确同步后的可见性和有序性。你要让一个线程的写入被另一个线程可靠看见,就要建立 happens-before 关系。
常见 happens-before 入口可以直接用:
| 入口 | 语义 | 适用场景 |
|---|---|---|
volatile 写 -> 后续 volatile 读 | 写入对后续读取可见,并约束相关重排序 | 开关、状态位、发布轻量配置 |
synchronized 解锁 -> 后续同一 monitor 加锁 | 临界区内写入对后续持锁线程可见 | 保护共享状态 |
Thread.start() -> 新线程动作 | 启动前准备对新线程可见 | 线程启动参数发布 |
线程结束 -> 其他线程 join() 返回 | 被等待线程的动作对等待方可见 | 等待任务完成 |
BlockingQueue.put() 前动作 -> 另一个线程 take() 后动作 | 任务对象安全交接 | 生产者消费者 |
这条链可以画成一个排查图:先找写入,再找同步边,最后判断读取线程是否真的有证据看到它。
线上判断 JMM 问题时,不要用“我 sleep 了一秒,应该更新了”当证据。真正的证据是上图里的同步边,或者把共享状态改成不可变对象、队列消息、数据库约束这类天然有交接语义的设计。
直接做法:如果只是一个停止标志,用 volatile 足够清晰。
class Worker implements Runnable {
private volatile boolean running = true;
public void stop() {
running = false;
}
@Override
public void run() {
while (running) {
doOneRound();
}
}
}但是 volatile 不是锁。下面这个自增仍然不是线程安全的:
private volatile int count = 0;
public void increase() {
count++; // 读、加、写是三个动作,不是一个原子动作
}验证思路:用两个线程各自循环 increase() 十万次,最后结果经常小于二十万。修复方式不是“再加几个 volatile”,而是用 AtomicInteger、LongAdder、锁,或者把状态更新交给数据库版本号和唯一约束。
常见坑:
- 用
Thread.sleep()等另一个线程更新变量。sleep没有同步语义,只是让当前线程暂停一会儿。 - 把
volatile当成事务。它只能解决单变量可见性和有序性,不解决多个字段的一致更新。 - 双重检查锁忘记
volatile,对象引用可能先暴露,构造内部字段还没对其他线程可见。
生产建议:共享可变状态越少,JMM 问题越少。能用不可变对象、局部变量、消息队列、任务队列和数据库约束表达的,就不要把一堆业务状态放在内存里让多个线程抢。
四、线程池用 ThreadPoolExecutor 显式建
场景说明:接口里要并行查多个下游,消息消费要异步处理,导出任务要后台运行,这些都需要线程池。但线程池不是越大越好,也不是 Executors 工厂方法随手一写就完事。
生产上推荐显式创建 ThreadPoolExecutor:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public final class ThreadPools {
public static ThreadPoolExecutor newOrderQueryPool() {
AtomicInteger index = new AtomicInteger(1);
ThreadFactory factory = task -> {
Thread thread = new Thread(task);
thread.setName("order-query-" + index.getAndIncrement());
thread.setUncaughtExceptionHandler((t, e) ->
System.err.println("uncaught exception in " + t.getName() + ": " + e.getMessage()));
return thread;
};
return new ThreadPoolExecutor(
8,
16,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
factory,
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
}这几个参数要按顺序理解:
corePoolSize:核心线程数,常驻工作能力。maximumPoolSize:队列满以后还能扩到的最大线程数。keepAliveTime:非核心线程空闲多久回收。workQueue:任务排队区,必须明确容量。ThreadFactory:线程命名、异常处理、是否 daemon。RejectedExecutionHandler:线程和队列都满时怎么处理。
线程池执行逻辑可以这样记:
验证结果:
System.out.println("poolSize=" + pool.getPoolSize());
System.out.println("active=" + pool.getActiveCount());
System.out.println("queue=" + pool.getQueue().size());
System.out.println("completed=" + pool.getCompletedTaskCount());线上更推荐把这些指标接入监控:活跃线程、队列长度、任务提交数、完成数、拒绝次数、任务执行耗时、任务等待耗时。
常见坑:
Executors.newFixedThreadPool默认使用无界队列,队列堆积时可能先打爆内存。Executors.newCachedThreadPool最大线程数很大,依赖慢时可能创建大量线程。- 最大线程数不是永远生效。如果队列是无界的,任务会一直排队,不会扩到最大线程。
- 只监控线程数不监控队列长度,等接口超时时才发现任务早就堆满了。
生产建议:每个线程池都写一份小契约:用途、任务类型、核心线程、最大线程、队列长度、拒绝策略、超时、监控指标、降级方式和负责人。线程池是资源边界,不是工具类。
五、ThreadPoolExecutor 的 ctl 状态位要会反推
场景说明:线上线程池问题通常不是一句“线程不够”能解释清楚。你要能回答:线程池还在运行吗,工作线程数是多少,任务是卡在队列、创建线程、执行任务,还是已经触发拒绝。
ThreadPoolExecutor 源码入口可以按这条链读:
execute(Runnable)
-> addWorker(command, true)
-> workQueue.offer(command)
-> addWorker(command, false)
-> reject(command)
worker thread
-> runWorker(Worker)
-> getTask()
-> beforeExecute()
-> task.run()
-> afterExecute()内部核心状态是一个 AtomicInteger ctl,高位保存线程池运行状态,低位保存 worker 数量。理解它不是为了背源码,而是为了把现象反推回线程池状态。
ctl 可以按“运行状态 + worker 数”拆开看:
所以线程池排障要同时看两件事:高位状态决定“还能不能接任务”,低位 worker 数决定“还有没有执行能力”。队列长度、拒绝次数和 worker 栈要放在一起看,不能只看配置里的 maximumPoolSize。
| 状态 | 含义 | 线上现象 |
|---|---|---|
RUNNING | 接收新任务,也处理队列任务 | 正常工作 |
SHUTDOWN | 不接收新任务,但继续处理队列任务 | 发布或关闭期间仍在清队列 |
STOP | 不接收新任务,不处理队列任务,并中断运行任务 | 强制关闭或异常收缩 |
TIDYING | 任务和 worker 已清完,准备执行 terminated 钩子 | 生命周期尾部 |
TERMINATED | 完全终止 | 再提交必然拒绝 |
一次 execute 提交大致会这样走:
- 当前 worker 数少于
corePoolSize,优先创建核心线程执行。 - 否则尝试把任务放入
workQueue。 - 入队成功后还要二次检查线程池是否仍在运行。
- 队列放不下,再尝试创建非核心线程。
- 线程数也到
maximumPoolSize,触发拒绝策略。
最小验证可以写一个小线程池,把核心线程设为 1,最大线程设为 2,队列设为 1,然后提交 4 个长任务:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
1,
2,
30,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1),
namedThreadFactory("ctl-demo"),
new ThreadPoolExecutor.AbortPolicy()
);
for (int i = 1; i <= 4; i++) {
int index = i;
pool.execute(() -> {
try {
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
System.out.println(index);
});
}预期结果:
- 第 1 个任务创建核心线程。
- 第 2 个任务进入队列。
- 第 3 个任务创建非核心线程。
- 第 4 个任务被拒绝。
运行时再抓一次线程和指标:
jcmd "$PID" Thread.print -l > ctl-demo.txt
grep -n "ctl-demo" -A20 ctl-demo.txt同时在应用里打印:
System.out.printf(
"pool=%d active=%d queue=%d task=%d completed=%d shutdown=%s%n",
pool.getPoolSize(),
pool.getActiveCount(),
pool.getQueue().size(),
pool.getTaskCount(),
pool.getCompletedTaskCount(),
pool.isShutdown()
);runWorker 的核心循环是“取一个任务、执行、再去 getTask() 取下一个任务”。线上看到线程池线程停在 ThreadPoolExecutor.getTask 或队列的 take/poll,通常表示 worker 空闲等待;看到线程停在你的业务方法、HTTP 客户端、JDBC 驱动或锁等待,才说明 worker 被任务占住。ctl 的高位运行状态决定是否接收新任务,低位 worker 数决定还剩多少执行能力;反推时不要只看一个“线程池最大线程数”配置。
如果你把队列换成无界 LinkedBlockingQueue,第 3 个任务也会入队,maximumPoolSize 基本不会发挥作用。这就是很多线上线程池“最大线程数明明配了 200,实际一直只有 20 个线程,队列却堆爆”的根因。
生产建议:线程池排查要同时看 poolSize、activeCount、queue.size()、taskCount、completedTaskCount、拒绝次数和关闭状态。只看线程数,容易漏掉队列这个真正的故障现场。
六、线程数和队列容量怎么估
场景说明:线程池参数没有万能公式。CPU 密集型、IO 密集型、下游调用、数据库写入、导出文件、消息消费,适合的线程数完全不同。
先粗分任务类型:
| 任务类型 | 关注点 | 起步建议 |
|---|---|---|
| CPU 密集型 | CPU 核心、上下文切换 | 接近 CPU 核数,再压测微调 |
| IO 密集型 | 等待时间、下游容量 | 可以多于核心数,但必须受下游限流约束 |
| 数据库任务 | 连接池、事务耗时、锁等待 | 不要超过数据库连接池和 SQL 容量 |
| 导出/批处理 | 内存、磁盘、网络、用户等待 | 后台队列、限速、错峰、可取消 |
| 消息消费 | 消费速率、幂等、重试、积压 | 和 broker 拉取、业务处理、下游容量一起算 |
一个常用估算思路:
线程数 ≈ CPU 核数 × 目标 CPU 利用率 × (1 + 等待时间 / 计算时间)这不是公式答案,只是提醒你:IO 等待越多,线程可以比 CPU 核数多;计算越重,线程越不能乱加。真正上线前要压测验证。
容器里还要确认 JVM 识别到的 CPU:
jcmd "$PID" VM.info | grep -i cpu
jcmd "$PID" VM.flags | grep ActiveProcessorCount
cat /sys/fs/cgroup/cpu.max 2>/dev/null || true如果容器限制 2 核,但代码按宿主机 32 核创建线程池,压测时会出现上下文切换和延迟抖动。必要时给 JVM 显式设置:
-XX:ActiveProcessorCount=2常见坑:
- 队列容量按“越大越保险”设置。大队列只是把失败延后,用户早已超时。
- 线程数按机器核数算,却没看数据库连接池、HTTP 连接池和下游 QPS 限制。
- 批处理和在线请求共用线程池,低优先级任务拖慢核心链路。
生产建议:队列容量要和超时预算一起设计。比如接口总超时 2 秒,任务排队 5 秒才执行,即使最终执行成功,对用户也已经失败。线程池设计要承认系统会过载,并且在过载时快速失败、降级或背压。
七、阻塞队列决定压力怎么堆
场景说明:线程池很多事故不是线程数本身,而是队列选择错了。队列是压力缓冲区,也是故障放大器。
常用队列这样选:
| 队列 | 特点 | 适用场景 | 风险 |
|---|---|---|---|
ArrayBlockingQueue | 有界数组队列 | 生产线程池常用,容量明确 | 容量太小会频繁拒绝,太大会延迟失败 |
LinkedBlockingQueue | 可设容量,不设就是很大 | 任务量可控、需要较好吞吐 | 忘记容量容易内存堆积 |
SynchronousQueue | 不存任务,直接交接 | 快速扩容、直接交付 | 下游慢时线程可能暴涨 |
PriorityBlockingQueue | 优先级队列 | 任务有明确优先级 | 默认无界,低优先级可能饥饿 |
DelayQueue | 延迟队列 | 定时、延迟执行 | 不适合高精度调度和无限积压 |
直接做法:在线请求线程池优先用有界队列。容量从业务 SLA 和压测结果反推,不从“估个大数”开始。
new ArrayBlockingQueue<>(500)验证结果:压测时要同时看:
- 活跃线程是否接近
maximumPoolSize。 - 队列长度是否持续增长。
- 拒绝次数是否出现。
- 任务等待时间是否已经超过接口超时预算。
常见坑:
- 无界队列让
maximumPoolSize失效。 - 队列堆积时任务携带大对象、用户上下文或请求参数,会额外放大内存。
- 队列里旧任务已经没业务意义,但仍然继续执行,造成无效下游压力。
生产建议:关键线程池队列长度达到 50% 就要预警,达到 80% 要告警并触发降级预案。队列持续增长说明消费能力不足,不是“系统还扛得住”。
八、拒绝策略不是异常处理,是过载设计
场景说明:线程池满了以后怎么处理,决定故障是局部快速失败,还是扩散到整个系统。很多团队等到第一次 RejectedExecutionException 才讨论业务要不要丢任务,这已经晚了。
JDK 内置拒绝策略:
| 策略 | 行为 | 适用 |
|---|---|---|
AbortPolicy | 抛 RejectedExecutionException | 核心链路快速失败,调用方明确处理 |
CallerRunsPolicy | 调用方线程自己执行 | 轻量背压,降低提交速度 |
DiscardPolicy | 直接丢弃 | 低价值任务,但必须配监控 |
DiscardOldestPolicy | 丢最旧任务再提交 | 很少直接使用,容易误丢关键任务 |
生产上更常见的是自定义拒绝策略,把日志、指标和降级接起来:
new ThreadPoolExecutor.AbortPolicy()或者:
(task, executor) -> {
// 这里要打指标、记录线程池名称、队列长度和业务类型
throw new RejectedExecutionException("order-query pool overloaded");
}验证结果:
- 拒绝发生时,调用方能得到明确异常或降级结果。
- 监控里能看到拒绝次数和线程池名称。
- 日志里能定位是哪个业务任务被拒绝,而不是只有一行泛泛异常。
常见坑:
DiscardPolicy静默丢任务,业务以为异步已经执行。CallerRunsPolicy用在入口请求线程,可能把请求线程拖慢,但它能形成背压;是否可接受要看业务。- 拒绝策略里做复杂逻辑,又反过来阻塞提交线程。
生产建议:拒绝策略要写进接口契约。核心查询可以快速失败或返回降级数据;支付、库存、状态流转不能静默丢;通知、埋点、低优先级刷新可以允许丢弃但必须可观测。
九、锁:先用清楚,再谈高级
场景说明:并发写共享资源时,不加锁会错,加锁太粗会慢,加锁顺序错会死锁。锁不是越多越安全,关键是锁住哪段临界区、锁多久、失败后怎么办。
最基础的 synchronized 适合简单临界区:
public synchronized void updateStatus() {
// 修改共享状态
}需要可中断、可超时、多个条件队列时,用 ReentrantLock 更清晰:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
private final ReentrantLock lock = new ReentrantLock();
public boolean updateWithTimeout() throws InterruptedException {
if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
return false;
}
try {
// 修改共享状态
return true;
} finally {
lock.unlock();
}
}验证结果:
tryLock超时返回时,业务能降级或重试。unlock()放在finally,异常也能释放锁。- 线程 dump 里能看到锁等待位置。
常见坑:
- 锁里做远程调用、SQL、文件 IO,临界区被拉长。
- 多把锁没有固定顺序,偶发死锁。
- 读写锁以为一定更快。写多读少、锁竞争强时,读写锁反而更复杂。
- 公平锁减少饥饿,但吞吐通常有代价,不要默认开启。
生产建议:锁只保护最小共享状态,不保护完整业务流程。跨进程、跨服务、跨数据库的并发控制,不要指望 JVM 内锁解决,要回到数据库约束、分布式锁、幂等和状态机。
synchronized monitor 和 AQS 的现场差异
synchronized 使用对象 monitor;ReentrantLock、Semaphore、CountDownLatch 这类同步器通常基于 AQS。二者都能让线程等待,但线程 dump 里的形态不同。
| 对比项 | synchronized monitor | AQS 同步器 |
|---|---|---|
| 入口 | monitorenter / monitorexit | AbstractQueuedSynchronizer.acquire* |
| 等待形态 | 竞争锁时常见 BLOCKED (on object monitor) | 竞争失败后常见 WAITING / TIMED_WAITING,栈里有 LockSupport.park |
| 超时/中断 | 进入 monitor 本身不能 tryLock(timeout) | 多数同步器提供可中断、可超时 API |
| 条件等待 | Object.wait/notify,一个 wait set | ConditionObject.await/signal,可拆多个条件队列 |
| 排障重点 | 看 waiting to lock、locked 的 monitor 地址和持有线程 | 看 AQS 组件类型、队列原因、谁持有锁或许可是否归还 |
典型 monitor 等待:
"http-nio-8080-exec-21" BLOCKED (on object monitor)
at com.example.OrderStateMachine.transition(OrderStateMachine.java:88)
- waiting to lock <0x000000070123abcd> (a com.example.Order)典型 AQS 等待:
"order-query-17" WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:...)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(...)生产建议:看到 BLOCKED 优先查 monitor 持有者和锁顺序;看到 LockSupport.park 不要立刻认定死锁,先判断它是在等 ReentrantLock、Condition、Semaphore、线程池队列,还是正常空闲。
十、AQS 是很多同步器的共同骨架
场景说明:理解 AQS 不是为了手写同步器,而是为了看懂 ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 的行为边界。
AQS 可以粗略理解为三件事:
- 一个
state:表示同步状态,比如锁是否被占用、信号量剩余许可数。 - 一个等待队列:竞争失败的线程排队等待。
- 两种模式:独占模式和共享模式。
独占锁的核心过程可以这样看:
线程 dump 里看到 AbstractQueuedSynchronizer.acquire 和 LockSupport.park,就可以按上图反推:它不是普通睡眠,而是竞争同步器失败后在队列里等唤醒。下一步要查的是同步器类型、state 含义、持有者在哪里、许可是否归还。
常见组件:
| 组件 | AQS 模式 | 使用场景 |
|---|---|---|
ReentrantLock | 独占 | 同一时刻一个线程进入临界区 |
Semaphore | 共享 | 限制并发许可数 |
CountDownLatch | 共享 | 等待多个任务完成 |
ReentrantReadWriteLock | 独占 + 共享 | 读多写少场景 |
一个很实用的限流写法是用 Semaphore 保护下游:
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
private final Semaphore dbQueryPermits = new Semaphore(50);
public String query() throws InterruptedException {
if (!dbQueryPermits.tryAcquire(100, TimeUnit.MILLISECONDS)) {
return "fallback";
}
try {
return doQuery();
} finally {
dbQueryPermits.release();
}
}验证结果:当下游变慢时,最多只有 50 个请求进入查询,其余请求在 100ms 内失败或降级,不会无限占满线程。
常见坑:
CountDownLatch计数没有在异常路径countDown(),调用方永久等待。Semaphoreacquire 后异常没有 release,许可数越来越少。- 手写 AQS 同步器,没有充分测试中断、超时、取消和并发边界。
生产建议:业务开发一般优先使用 JDK 已提供的同步器。只有在通用组件层,并且现有同步器确实表达不了需求时,才考虑基于 AQS 扩展。
LockSupport park/unpark 在线程栈里怎么认
LockSupport.park() 是很多 JUC 同步器的底层阻塞入口,unpark() 用来发放一次许可。它不是业务层“睡眠”,而是同步器让线程让出 CPU 的方式。线程 dump 里大量出现 LockSupport.park 时,要先给它分类:
| 栈形态 | 常见含义 | 是否异常 |
|---|---|---|
ThreadPoolExecutor.getTask -> 队列 take -> LockSupport.park | 线程池 worker 空闲等任务 | 通常正常 |
AbstractQueuedSynchronizer.acquire -> LockSupport.park | 等 ReentrantLock、Semaphore 等同步器 | 看等待时长和持有者 |
ConditionObject.await -> LockSupport.park | 等某个业务条件,如队列不空、连接可用 | 看谁应该 signal |
CompletableFuture.waitingGet -> LockSupport.park | 等异步结果完成 | 看异步任务是否卡住或超时缺失 |
ForkJoinPool.awaitWork -> LockSupport.park | ForkJoin worker 等任务 | 通常正常 |
排查命令:
jcmd "$PID" Thread.print -l > locks.txt
grep -n -E "LockSupport|AbstractQueuedSynchronizer|ConditionObject|waiting to lock|parking" locks.txt生产建议:WAITING 只是状态,不是结论。要把线程名、栈顶、等待对象、业务耗时和指标串起来,才能判断是空闲、背压、缺少唤醒、死锁,还是下游慢。
十一、Condition:把等待队列拆清楚
场景说明:Object.wait() / notify() 只有一个等待集合,复杂一点的生产者消费者很快会混乱。ReentrantLock 配合 Condition 可以把“队列不空”和“队列不满”拆成两个条件队列,代码语义更清楚。
一个简化的有界缓冲区可以这样写:
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
class BoundedBuffer<T> {
private final int capacity;
private final Queue<T> queue = new ArrayDeque<>();
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
BoundedBuffer(int capacity) {
this.capacity = capacity;
}
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await();
}
queue.add(item);
notEmpty.signal();
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
T item = queue.remove();
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}这里的关键不是“比 synchronized 高级”,而是等待条件更精确:
notFull只唤醒等待放入的生产者。notEmpty只唤醒等待取出的消费者。await()必须放在循环里,防止虚假唤醒和条件被其他线程抢先改变。signal()前必须已经持有对应的锁。
AQS 里 ConditionObject 有自己的条件队列。线程 await() 时会释放当前锁并进入条件队列;被 signal() 后并不是立刻执行,而是转移回 AQS 同步队列,重新竞争锁。线上看到线程停在 ConditionObject.await,要继续看它等待的条件到底是队列空、队列满、连接不可用,还是线程池暂停。
这个“先去条件队列,再回同步队列”的过程非常关键:
所以 signal() 不是“把线程立刻叫醒并继续跑”,它只是把等待条件的线程送回锁竞争队列。生产事故里如果只看见 await,还要继续查谁应该 signal、信号是否丢失、被唤醒后是不是又因为条件不满足回去等待。
生产建议:多数业务不需要自己写条件队列,优先使用 BlockingQueue。只有当你在通用组件里要表达多个等待条件,并且需要可中断、可超时、精准唤醒时,再直接使用 Condition。
十二、CAS 和原子类:适合小状态,不适合所有并发
场景说明:CAS 适合更新简单共享变量,比如计数器、状态位、引用替换。它的优势是无锁,风险是自旋消耗 CPU、ABA 问题和复杂状态难维护。
计数器可以这样写:
import java.util.concurrent.atomic.AtomicInteger;
private final AtomicInteger retryCount = new AtomicInteger();
public int increase() {
return retryCount.incrementAndGet();
}高并发热点计数可以考虑 LongAdder:
import java.util.concurrent.atomic.LongAdder;
private final LongAdder qpsCounter = new LongAdder();
public void mark() {
qpsCounter.increment();
}
public long snapshot() {
return qpsCounter.sum();
}验证结果:AtomicInteger 适合精确更新;LongAdder 在高竞争计数下吞吐更好,但 sum() 是并发瞬时汇总,不要拿它做强一致余额。
常见坑:
- CAS 循环里做复杂计算,失败后反复重算,CPU 可能飙高。
- 只保护一个字段,业务上却需要多个字段一致。
- ABA 问题被忽略。需要识别中间变化时,用版本号、
AtomicStampedReference或业务版本字段。
生产建议:CAS 适合小而清晰的状态更新。订单状态、库存扣减、账户余额这类业务一致性,不能只靠内存原子类,要结合数据库约束、版本号、事务和幂等。
十三、并发容器要按读写模式选
场景说明:并发容器不是把 HashMap 换成 ConcurrentHashMap 就结束。不同容器适合不同读写模式,错用会带来性能问题或一致性误判。
常见选择:
| 容器 | 适合 | 注意 |
|---|---|---|
ConcurrentHashMap | 高频并发读写 map | 复合操作用 compute、putIfAbsent,不要先查后放 |
CopyOnWriteArrayList | 读多写少,如监听器列表 | 写入会复制数组,不适合频繁写 |
ConcurrentLinkedQueue | 非阻塞队列 | 无界,必须自己控制增长 |
BlockingQueue | 生产者消费者 | 选容量和阻塞策略 |
LongAdder | 热点计数 | 不是强一致余额 |
ConcurrentHashMap 常见正确写法:
ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();
Object value = cache.computeIfAbsent(key, this::loadValue);常见坑:
computeIfAbsent的加载函数里做很慢的远程调用,会让局部竞争变重。- 先
containsKey再put不是原子复合操作。 - 用无界队列做缓存,实际上没有淘汰策略。
生产建议:缓存不要只靠并发容器,还要有容量、过期、淘汰、刷新、穿透保护和监控。JUC 容器解决线程安全,不自动解决业务生命周期。
十四、ThreadLocal:线程隔离不是自动安全
场景说明:日志 traceId、用户身份、租户、语言环境、数据源标识、事务上下文,经常通过 ThreadLocal 放在当前线程里。它的便利性很高,风险也很隐蔽:线程池会复用线程,如果不清理,上一个请求的上下文可能串到下一个请求。
最小写法:
public final class RequestContext {
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
public static void setTraceId(String traceId) {
TRACE_ID.set(traceId);
}
public static String traceId() {
return TRACE_ID.get();
}
public static void clear() {
TRACE_ID.remove();
}
}请求入口必须用 try/finally 清理:
try {
RequestContext.setTraceId(traceId);
doBusiness();
} finally {
RequestContext.clear();
}线程池复用下,泄漏路径通常长这样:
ThreadLocal 的 key 是弱引用并不等于 value 会自动安全消失。线程池线程长期存活时,最可靠的治理仍然是入口统一设置、跨线程显式传递、出口 finally remove。
线程池里还要传递上下文。最稳的做法是提交任务时复制当前上下文,任务执行结束后恢复旧上下文:
Runnable wrapTrace(String traceId, Runnable task) {
return () -> {
String old = RequestContext.traceId();
try {
RequestContext.setTraceId(traceId);
task.run();
} finally {
if (old == null) {
RequestContext.clear();
} else {
RequestContext.setTraceId(old);
}
}
};
}验证结果:
- 同一个线程连续处理两个请求时,第二个请求不会读到第一个请求的 traceId。
- 异步任务日志能打印入口 traceId。
- 异常路径也会执行
remove()。
常见坑:
- 使用
InheritableThreadLocal试图解决线程池上下文传递。线程池线程通常早就创建好了,不会每次提交任务都重新继承父线程上下文。 - ThreadLocal 保存大对象、用户权限集合或请求体,线程长期存活后增加内存滞留风险。
- 异步任务跨线程后只传 traceId,不传租户或数据权限,导致日志看起来连上了,数据却查错。
- 用虚拟线程后仍然无限制创建 ThreadLocal 大对象,数量一上来内存成本会非常直观。
生产建议:ThreadLocal 要收口到基础设施层,不要让业务随处定义。至少固定四件事:上下文对象字段、入口设置、异步传递、finally 清理。能用方法参数显式传递的核心业务数据,不要为了省参数全部塞进 ThreadLocal。
十五、异步任务:CompletableFuture 要带线程池和超时
场景说明:CompletableFuture 很适合并行编排多个任务,但默认线程池、异常处理和上下文传递经常被忽略。线上很多“偶发 traceId 丢失”“异步任务吃满 CPU”“接口等不到结果”,都从这里来。
推荐显式传入线程池:
CompletableFuture<String> userFuture =
CompletableFuture.supplyAsync(() -> queryUser(userId), queryPool)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> "user-fallback");
CompletableFuture<String> orderFuture =
CompletableFuture.supplyAsync(() -> queryOrder(userId), orderPool)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> "order-fallback");
String result = userFuture.thenCombine(orderFuture, (user, order) -> user + ":" + order)
.get(1000, TimeUnit.MILLISECONDS);一条稳的异步链路,至少要把 executor、超时、异常和上下文四件事画清楚:
get(timeout) 只限制等待方,不保证底层任务已经停止。真正能控制故障扩散的是每个任务自己的超时、专用线程池、取消/降级语义和上下文清理。
验证结果:
- 每类任务进入自己的线程池。
- 每个异步调用有单独超时。
- 总等待也有超时。
- 异常有降级路径,不会卡住入口线程。
上下文传递要显式做。比如日志 MDC、用户身份、租户、数据源标识,不会因为进了线程池就自动可靠传过去。可以用任务包装器:
Runnable wrapWithContext(Runnable task, Map<String, String> context) {
return () -> {
Map<String, String> old = MDC.getCopyOfContextMap();
try {
MDC.setContextMap(context);
task.run();
} finally {
if (old == null) {
MDC.clear();
} else {
MDC.setContextMap(old);
}
}
};
}常见坑:
- 默认使用
ForkJoinPool.commonPool(),阻塞任务把公共池占满,影响其他组件。 allOf()等全部完成,但某个任务已经超时无意义。get(timeout)只是等待方超时返回,不等于底层任务已经停止;需要配合orTimeout、completeOnTimeout、取消和业务中断点。- 异步任务里抛异常,调用方没有
exceptionally、handle或whenComplete。 - ThreadLocal 在线程池复用后没清理,下一次任务串上下文。
公共池污染最小复现:
System.out.println(ForkJoinPool.getCommonPoolParallelism());
List<CompletableFuture<Void>> futures = IntStream.range(0, 200)
.mapToObj(i -> CompletableFuture.runAsync(() -> {
try {
TimeUnit.SECONDS.sleep(30);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}))
.toList();
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();没有传 executor 时,runAsync / supplyAsync 默认进入 ForkJoinPool.commonPool()。如果里面做阻塞 IO、睡眠、等待锁或等待其他异步结果,公共池 worker 会被占住,parallelStream、第三方库和其他默认异步任务都会被拖慢。
排查命令:
jcmd "$PID" Thread.print -l > cf-common-pool.txt
grep -n -E "ForkJoinPool.commonPool|CompletableFuture|parallelStream|waitingGet" cf-common-pool.txt生产建议:异步任务要按依赖隔离线程池。数据库、HTTP 下游、文件导出、通知、日志补偿不要混在一个池。上下文必须在 finally 里清理,尤其是租户、数据源、权限和 traceId。
十六、虚拟线程能解决什么,不能解决什么
场景说明:JDK 21 之后,虚拟线程让“每请求一个线程”的写法重新变得可行,特别适合大量阻塞 IO 的服务。JDK 26 文档仍然强调一个判断:虚拟线程不是更快的线程,它提供的是更高吞吐规模,不是降低单个任务延迟的魔法。
最小写法:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> future = executor.submit(() -> callRemote());
String result = future.get(1, TimeUnit.SECONDS);
}适合:
- 大量阻塞 IO。
- 请求数多但单个任务计算不重。
- 想保留同步代码风格,同时提升并发等待能力。
不适合直接解决:
- CPU 密集计算。
- 数据库连接池太小。
- 下游 QPS 太低。
- 没有超时、限流、熔断、幂等和背压的系统。
虚拟线程要和平台线程、载体线程、下游容量分开看:
因此虚拟线程不是“把所有并发边界删掉”。连接池、下游 QPS、超时预算、幂等和背压仍然要保留,只是很多阻塞 IO 场景可以用更简单的同步代码写法承载更高并发等待。
常见坑:
- 把虚拟线程当成无限线程池,结果数据库连接池先被打满。
- 忘记设置 HTTP、SQL、Redis、RPC 超时。
- 用虚拟线程替代所有线程池隔离,导致不同业务任务仍然互相影响。
- 在虚拟线程里做长时间 CPU 密集计算,载体线程仍然会被占用,吞吐不会凭空提升。
验证入口:
jcmd "$PID" Thread.print -l > virtual-threads.txt
jcmd "$PID" VM.system_properties | grep -E 'jdk.virtualThreadScheduler|java.version'JDK 参考实现中,虚拟线程调度器可以通过 jdk.virtualThreadScheduler.parallelism 和 jdk.virtualThreadScheduler.maxPoolSize 这类系统属性影响载体线程规模。一般业务不要一上来就调这些参数,先把下游连接池、超时、限流和任务边界整理清楚。
pinning 和 JDK 版本边界
虚拟线程的关键优势是阻塞等待时可以从载体线程卸载,但不是所有阻塞点都一定能卸载。JDK 21 中,虚拟线程在 synchronized 临界区内执行阻塞操作、或进入部分 native/外部调用时,可能 pin 住载体线程;pinning 会让载体线程被占用,虚拟线程的扩展性下降。JEP 491 在 JDK 24 交付,JDK 26 已包含这类改进,所以不应再用 JDK 21 的 monitor pinning 案例直接判断 JDK 26,但 native、驱动、框架和下游容量仍然要实测。
版本口径:
| JDK | 虚拟线程口径 | 排查重点 |
|---|---|---|
| 17 | 没有正式虚拟线程 API | 仍以平台线程、线程池、AQS、CompletableFuture 为主 |
| 21 | 虚拟线程正式可用 | 关注 pinning、载体线程、连接池、ThreadLocal、监控工具兼容 |
| 26 | 已包含 JDK 24 交付的 JEP 491 改进 | 不照搬 21 的 monitor pinning 结论,重新用 JFR 和线程转储验证 |
JDK 21 排查 pinning 可以先打开:
java -Djdk.tracePinnedThreads=full -jar app.jar
jcmd "$PID" JFR.start name=vt settings=profile duration=120s filename=vt.jfr在包含 JEP 491 的 JDK 版本中,jdk.tracePinnedThreads 不再用于定位 synchronized 导致的 pinning;排查应回到 JFR、线程转储、native 调用和下游等待证据。
JDK 21+ 还可以用更适合虚拟线程的转储入口:
jcmd "$PID" Thread.dump_to_file -format=json virtual-threads.json生产建议:迁移虚拟线程时,先选一个阻塞 IO 明确、下游容量清楚、超时完整的接口灰度。不要用“线程数上去了”证明成功,要看吞吐、P99、数据库连接池等待、载体线程、JFR 事件和错误率。
生产建议:虚拟线程可以减少线程资源成本,但不能替代容量治理。引入前先确认 JDK 版本、框架兼容、监控可观测性、下游连接池和限流策略。
十七、线上排查:队列堆积、死锁、CPU 飙高、上下文丢失
1. 队列堆积
现象:接口 P99 升高,线程池活跃线程接近上限,队列长度持续增长,拒绝次数开始出现。
直接做法:
jcmd "$PID" Thread.print -l > thread-pool.txt
grep -E 'order-query|payment-call|export-worker' thread-pool.txt | head -n 50同时看应用暴露的线程池指标:
active threads
queue size
task wait time
task execution time
rejected count判断:
- 队列涨、下游耗时涨:先隔离下游、缩短超时、降级。
- 队列涨、CPU 高:看任务是否 CPU 密集,是否线程过多导致切换。
- 队列涨、拒绝为 0:可能队列过大,错误被延后。
修复建议:先止血降级或限流,再拆线程池、缩短超时、加队列告警,最后压测重估容量。
2. 死锁
现象:接口卡死,CPU 不一定高,线程 dump 里出现互相等待。
直接做法:
jcmd "$PID" Thread.print -l > deadlock.txt
grep -n -A80 "Found one Java-level deadlock" deadlock.txt如果没有自动提示,也要看大量 BLOCKED 线程是否卡同一批锁。
常见原因:
- 多把锁获取顺序不一致。
- 锁内调用外部系统,回调又拿锁。
- 数据库事务锁和 JVM 锁交叉等待。
生产建议:锁顺序要固定,锁内不要做远程调用。涉及数据库更新时,SQL 加锁顺序也要固定,不要只看 Java 代码。
3. CPU 飙高
现象:单机 CPU 突然升高,接口耗时变长,线程数不一定明显增加。
直接做法:
top -Hp "$PID"
printf "%x\n" "$TID"
jcmd "$PID" Thread.print -l > cpu-hot.txt
grep -n "nid=0x$HEX_TID" -A60 -B5 cpu-hot.txt判断:
- 栈顶长期停在业务循环、JSON 序列化、正则、集合遍历:优先看代码热点和数据量。
- 大量线程 RUNNABLE 但都在网络或文件 native 调用:结合下游耗时、磁盘 IO 和系统调用看。
ForkJoinPool.commonPool热:看是否有CompletableFuture、parallel stream 或公共池被阻塞任务污染。- CAS 循环热点:看是否高竞争原子更新、无退避自旋或热点计数设计不合理。
修复建议:先限制入口流量或降级热点功能,再用 JFR/async-profiler 做采样,不要只靠一次线程 dump 下结论。CPU 问题要用“热点线程 + 多次栈快照 + 业务流量 + 下游指标”一起证明。
4. 上下文丢失
现象:异步任务日志没有 traceId,租户信息为空,数据源切换失败,权限上下文丢失。
排查:
grep -E 'traceId=|tenantId=|dataSource=' /var/log/myapp/app.log | tail -n 100
jcmd "$PID" Thread.print -l | grep -E 'async|pool|ForkJoinPool|CompletableFuture' | head -n 100常见原因:
- 使用 ThreadLocal,但进入线程池后没有传递。
- 传递了上下文,但没有 finally 清理。
- 异步任务默认进 commonPool,绕过了统一包装器。
生产建议:统一封装异步提交入口。禁止业务随手 CompletableFuture.supplyAsync() 不传 executor。上下文传递、清理、异常和指标都放在统一包装层。
5. JFR 和线程转储取证
一次线程 dump 只能说明“这一刻线程在哪里”,不能证明趋势。并发问题排查最好同时拿三类证据:多次线程转储、JFR 采样、业务指标。
# 连续抓三次线程栈,观察同一批线程是否卡在同一位置
jcmd "$PID" Thread.print -l > threads-1.txt
sleep 10
jcmd "$PID" Thread.print -l > threads-2.txt
sleep 10
jcmd "$PID" Thread.print -l > threads-3.txt
# 采一段 JFR,适合 CPU、锁、线程、虚拟线程和阻塞 IO 综合分析
jcmd "$PID" JFR.start name=concurrency settings=profile duration=120s filename=concurrency.jfr
# JDK 21+,需要观察大量虚拟线程时使用结构化转储
jcmd "$PID" Thread.dump_to_file -format=json threads.json判断:
- 同一批线程连续三次停在同一业务栈,优先查下游、锁或等待条件。
- JFR 里 CPU 热点、锁事件、线程 park、Socket/文件 IO 和虚拟线程事件要和接口 P99 对齐。
- 虚拟线程场景下,平台线程数量不一定等于并发任务数量,要看虚拟线程转储和下游连接池等待。
生产建议:事故复盘不要只贴一张线程栈截图。要记录采样时间、流量、错误率、线程池指标、下游耗时、JDK 版本和启动参数。
十八、落地工程深水区
1. 线程池隔离边界要提前定
至少按这几类拆:
- 核心在线请求。
- 下游 HTTP/RPC 调用。
- 数据库密集任务。
- 消息消费。
- 导出/报表/批处理。
- 低优先级通知、埋点、刷新。
不要为了省事共用一个 asyncExecutor。共享线程池最大的问题是故障互相污染:导出任务堆积会拖慢核心接口,下游慢会占住消息消费,低优先级通知会挤占支付链路。
2. 队列容量就是业务等待预算
队列不是越大越好。接口 2 秒超时,任务在队列里等 10 秒才执行,执行成功也没有价值。队列容量要结合任务耗时和 SLA 设计。
上线前压测:
提交速率 > 处理速率时,队列增长斜率是多少?
队列达到 50% 容量时,接口 P99 是多少?
队列满后,拒绝策略是否符合业务预期?
降级是否可观测?3. 拒绝策略必须有业务语义
拒绝不是系统异常,而是容量边界生效。要提前写清:
- 哪些任务可以丢。
- 哪些任务必须落库补偿。
- 哪些任务应该快速失败。
- 哪些任务可以调用方执行形成背压。
- 拒绝后用户看到什么。
4. 死锁治理不只看 Java 锁
真实死锁经常跨越 JVM 和数据库:
- Java A 锁后更新表 1,再更新表 2。
- Java B 锁后更新表 2,再更新表 1。
- 外层事务迟迟不提交,JVM 锁等待和数据库锁等待互相放大。
生产建议:业务资源要定义统一加锁顺序,数据库更新顺序也要统一。复杂状态流转优先靠状态机和幂等约束,而不是临时加锁。
5. 上下文传递要成为基础设施
traceId、用户、租户、数据权限、数据源、语言环境都属于上下文。线程池复用会让 ThreadLocal 变成隐蔽风险。
基础设施层要提供:
- 统一 TaskDecorator 或任务包装器。
- finally 清理上下文。
- 禁止绕过统一 executor。
- 异步链路追踪覆盖线程池、消息、定时任务。
6. 监控告警要看“等待”
只看活跃线程不够。线程池真正危险的信号是等待:
- 队列长度。
- 任务排队时间。
- 任务执行时间。
- 拒绝次数。
- 线程池满载持续时间。
- 下游耗时和错误率。
生产建议:队列等待时间超过接口超时预算的 30% 就应该预警。拒绝次数哪怕很少,也要能定位到线程池和业务类型。
7. commonPool 要当共享基础设施治理
ForkJoinPool.commonPool() 不是某个业务模块的私有线程池。CompletableFuture 默认异步、parallel stream、部分第三方库都可能使用它。一个模块把阻塞任务丢进 commonPool,可能让完全无关的模块变慢。
治理要求:
- 业务代码禁止裸写
CompletableFuture.supplyAsync()/runAsync()。 - 阻塞 IO 必须传入隔离 executor。
- 代码扫描或 review 检查默认异步入口。
- 线程 dump 中出现
ForkJoinPool.commonPool-worker热点时,要追到调用方。
生产建议:commonPool 只适合短小、CPU 可控、不会阻塞等待的任务。线上核心链路要用显式命名线程池承载。
8. 虚拟线程迁移要按接口灰度
虚拟线程迁移不是把所有 executor 换成 newVirtualThreadPerTaskExecutor()。一个接口是否适合迁移,至少要满足:
- 主要耗时来自可阻塞卸载的 IO 等待,而不是 CPU 计算。
- SQL、HTTP、Redis、RPC 都有超时。
- 数据库连接池、HTTP 连接池和下游 QPS 有明确上限。
- ThreadLocal 大对象、MDC、租户上下文已经收口。
- JDK 版本、框架版本和监控工具能识别虚拟线程。
生产建议:先灰度低风险读接口,记录迁移前后的吞吐、P99、错误率、连接池等待、载体线程和 JFR 事件。没有数据对照的虚拟线程迁移,很容易把资源瓶颈从线程换到数据库或下游。
十九、检查清单和落地模板
线程池上线检查
- 线程池名称能在日志和线程 dump 里识别。
- 核心线程、最大线程、队列容量有压测依据。
- 拒绝策略有业务语义,不是默认糊上去。
- 任务有超时,等待异步结果也有总超时。
- 异常会记录并进入指标。
- 线程池指标已接入监控。
- 下游容量、数据库连接池、HTTP 连接池和线程池容量匹配。
- 关闭服务时会
shutdown,并限制等待时间。 - ThreadLocal 上下文会传递,也会清理。
- 没有裸用
CompletableFuture.supplyAsync()/runAsync()默认进入 commonPool。 - 线程 dump 中能通过线程名前缀定位业务归属。
- JDK 21+ 虚拟线程场景已验证 pinning、载体线程、JFR 和结构化线程转储。
线程池模板
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
static ThreadFactory namedThreadFactory(String prefix) {
AtomicInteger index = new AtomicInteger(1);
return task -> {
Thread thread = new Thread(task);
thread.setName(prefix + "-" + index.getAndIncrement());
thread.setUncaughtExceptionHandler((t, e) ->
System.err.println("uncaught exception in " + t.getName() + ": " + e.getMessage()));
return thread;
};
}
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8,
16,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
namedThreadFactory("order-query"),
new ThreadPoolExecutor.AbortPolicy()
);排障命令速查
# 线程状态分布
jcmd "$PID" Thread.print -l > threads.txt
grep -E "java.lang.Thread.State" threads.txt | sort | uniq -c
# LockSupport / AQS / monitor
grep -n -E "LockSupport|AbstractQueuedSynchronizer|ConditionObject|waiting to lock|parking" threads.txt
# 死锁
grep -n -A80 "Found one Java-level deadlock" threads.txt
# CPU 热点线程
top -Hp "$PID"
printf "%x\n" "$TID"
grep -n "nid=0x$HEX_TID" -A40 -B5 threads.txt
# 线程数
cat /proc/"$PID"/status | grep Threads
ps -L -p "$PID" | wc -l
# JFR 采样
jcmd "$PID" JFR.start name=concurrency settings=profile duration=120s filename=concurrency.jfr
# JDK 21+ 虚拟线程结构化转储
jcmd "$PID" Thread.dump_to_file -format=json threads.json
# JDK 版本和虚拟线程调度参数
jcmd "$PID" VM.system_properties | grep -E 'java.version|jdk.virtualThreadScheduler'二十、源码入口和版本校准
读源码不用从整包开始翻,先盯这些入口就够日常排障:
| 主题 | 入口 |
|---|---|
| 线程启动 | java.lang.Thread.start、run、join、interrupt、threadId |
| 内存模型 | Java Language Specification 第 17 章 Threads and Locks |
| 线程池 | ThreadPoolExecutor.execute、addWorker、runWorker、getTask、reject |
| 队列 | BlockingQueue 及 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue |
| AQS | AbstractQueuedSynchronizer.acquire、release、acquireShared、ConditionObject |
| 锁 | ReentrantLock.lock、tryLock、unlock、newCondition |
| 原子类 | AtomicInteger、AtomicReference、LongAdder |
| 并发容器 | ConcurrentHashMap.computeIfAbsent、putIfAbsent、CopyOnWriteArrayList |
| 上下文 | ThreadLocal.get、set、remove |
| 异步编排 | CompletableFuture.supplyAsync、thenCombine、allOf、orTimeout、completeOnTimeout |
| 虚拟线程 | Thread.ofVirtual、Executors.newVirtualThreadPerTaskExecutor、Thread.dump_to_file |
版本口径:
- JDK 17 仍是大量企业系统的稳定基线,适合传统线程池、AQS、CompletableFuture 的生产落地。
- JDK 21 把虚拟线程正式带入生产可用范围,也是很多团队迁移虚拟线程的最低建议版本;排查时重点关注 pinning、载体线程和工具链兼容。
- JDK 25 属于新的 LTS 基线,保守团队可以围绕它规划长期升级。
- JDK 26 是当前官方文档校准入口,排查迁移风险时要看 release notes,尤其是废弃、移除和行为变化;它已包含 JDK 24 交付的 JEP 491 改进,虚拟线程与
synchronized的边界要重新验证。
官方文档入口
- JDK 26 文档首页
- JDK 26 Release Notes
- Java SE 26 JLS 第 17 章:Threads and Locks
- Java 26
Thread - Java 26
ThreadLocal - Java 26
java.util.concurrent包 - Java 26
ThreadPoolExecutor - Java 26
BlockingQueue - Java 26
AbstractQueuedSynchronizer - Java 26
Executors - Java 26
CompletableFuture - JDK 26 Virtual Threads 指南
- Oracle:Java Thread Primitive Deprecation
- OpenJDK JEP 444:Virtual Threads
- OpenJDK JEP 491:Synchronize Virtual Threads without Pinning
