
线上服务一到晚高峰 RT 就飙升,查线程堆栈发现 Tomcat 线程全卡在等下游 RPC 返回。这种场景下,同步调用链直接成了吞吐量的瓶颈。Spring 提供的 @Async 接入门槛极低,很多团队随手加上注解就上线了。但"开箱即用"的代价往往是线上埋雷:默认线程池无上限狂建线程、上下文断链、异常被吞、内存打满 OOM...... 下面把这几年踩过的坑和线上治理经验捋一遍,直接给能落地的方案。
同步阻塞的痛点与异步的适用边界
同步请求慢,根子通常在 I/O 等待 或 外部依赖延迟 。Web 容器的工作线程(比如 Tomcat 的 http-nio-*)一旦发起 RPC、查慢 SQL 或调第三方接口,就会进入 WAITING 或 BLOCKED。线程占着不释放,新请求只能排队,池子打满直接抛 503 或 504。
异步不是银弹,它解决不了总耗时,只是把阻塞成本从主请求链路剥离出去,用独立线程池消化。主线程快速返回,整体 QPS 和 RT 自然就上去了。
什么时候该用,什么时候别碰:
适合的场景很明确:发通知类操作(短信、邮件、站内信)、日志埋点、独立且无依赖的子任务并行计算(比如报表拉多个维度的数据拼装)、纯 I/O 密集型且允许最终一致性的操作。
千万别碰的场景:强一致性事务(比如扣库存同时生成订单)、需要立刻拿到计算结果且没法拆解的任务、CPU 密集型计算(切线程反而增加上下文切换,纯属倒贴性能)。记住一个死理:异步是"空间换时间"和"资源解耦",它不加速业务逻辑,只保护主链路不被拖垮。
代理原理与默认线程池的致命陷阱
打开 @EnableAsync,Spring 会注册 AsyncAnnotationBeanPostProcessor。它通过 AOP 扫描带 @Async 的 Bean,生成代理对象(默认走 JDK 动态代理或 CGLIB)。方法调用被 AsyncExecutionInterceptor 拦截后,包装成 Callable 丢给 Executor,主线程立刻放行。
这里最容易翻车的是不配线程池 。Spring 找不到自定义的 Executor 时,会 fallback 到 SimpleAsyncTaskExecutor。这玩意儿每次提交任务直接 new Thread().start(),没池化、没复用、没上限。压测一上,线程数跟着并发量线性暴涨,没几秒就能触发三个连锁反应:
- 操作系统线程数触顶(Linux 默认
ulimit大概 3 万/进程左右) - 频繁上下文切换,CPU 使用率飙到 100% 但业务吞吐量纹丝不动
- 堆外内存被线程栈(默认 1MB)迅速抽干,直接抛
java.lang.OutOfMemoryError: unable to create native thread
线上铁律:生产环境必须显式声明 ThreadPoolTaskExecutor,并且覆盖默认 Bean 名,别指望自动装配能替你兜底。
ThreadPoolTaskExecutor 核心参数怎么调
Spring 官方推荐 ThreadPoolTaskExecutor,它封装了 JDK 的 ThreadPoolExecutor,生命周期管理也做得比较干净。参数别硬套公式,得结合业务特征压测微调,但底层逻辑必须清楚:
corePoolSize 是常驻线程数。队列没满的时候,哪怕线程空闲也不会回收。CPU 密集型一般设 N+1(N 是物理核数),IO 密集型可以适当放宽,通常 10~20 起步比较稳妥。别设太大,线程不是越多越好。
maxPoolSize 是队列满后允许创建的最大线程数。线上建议控制在 corePoolSize 的 1.5 到 2 倍。设大了线程切换开销会吃掉 CPU 收益,设小了突发流量一来直接走拒绝策略。
queueCapacity 是缓冲队列容量,底层默认用 LinkedBlockingQueue。这里必须设界,绝对不能用无界队列。 很多人踩的坑是:队列无限大,任务全塞进去,线程池根本不会扩容到 maxPoolSize,内存直接被打满。容量估算可以简单按 峰值 QPS × 平均处理耗时 来定,留点余量就行。注意一点,LinkedBlockingQueue 的容量在创建时就定死了,运行时没法动态改,后续扩容得换队列重建。
RejectedExecutionHandler 决定队列和线程都满了怎么办。生产上最稳的是 CallerRunsPolicy,让提交任务的主线程自己跑,相当于天然的背压机制,下游慢的时候主链路会自动减速。如果业务允许丢弃,可以自定义策略打点告警,或者直接丢消息队列异步重试。
最后两个生命周期参数别漏了:setWaitForTasksToCompleteOnShutdown(true) 配合 setAwaitTerminationSeconds(60)。Spring 容器销毁时会调用 shutdown(),如果不等任务跑完直接 shutdownNow(),正在落库或调接口的任务被粗暴中断,脏数据排查起来能折腾人几天。
上下文传递:MDC、TraceId 与 SecurityContext 怎么跨线程
子线程跑起来后,主线程绑定的 ThreadLocal 全丢了。日志里没有 TraceId 排不了错,SecurityContext 拿不到鉴权信息直接抛 NPE。
Spring 提供的 TaskDecorator 是标准解法,原理就是在任务提交前把主线程的上下文快照抓下来,在子线程执行前塞回去,执行完必须清掉,否则线程复用会导致上下文串扰:
java
public class ContextAwareTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 1. 主线程捕获上下文
String traceId = TraceContext.getCurrentId();
Map<String, String> mdcMap = MDC.getCopyOfContextMap();
SecurityContext secCtx = SecurityContextHolder.getContext();
return () -> {
try {
// 2. 子线程恢复上下文
if (mdcMap != null) MDC.setContextMap(mdcMap);
if (traceId != null) TraceContext.setCurrentId(traceId);
if (secCtx != null) SecurityContextHolder.setContext(secCtx);
runnable.run();
} finally {
// 3. 必须清理,防止线程池复用导致污染
MDC.clear();
TraceContext.remove();
SecurityContextHolder.clearContext();
}
};
}
}
挂到执行器上就一句:executor.setTaskDecorator(new ContextAwareTaskDecorator());
另外提一句老生常谈的坑:同类内部方法调用 @Async 不会生效。因为 AOP 代理绕过了,得把异步方法拆到另一个 Service,或者用 AopContext.currentProxy() 手动走代理。
异常兜底与 CompletableFuture 编排
异常静默怎么处理
@Async 方法如果返回 void,子线程抛出的异常会被拦截器吃掉,只打一行 ERROR 日志,业务方根本不知道任务失败了。
最直接的修复是注册全局异常处理器:
java
@Configuration
@EnableAsync
public class AsyncGlobalConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("[Async Exception] method: {}, params: {}", method.getName(), params, ex);
// 这里接告警或写失败重试表
};
}
}
或者把返回值改成 CompletableFuture<T>。调用方拿到 Future 后,可以通过 .exceptionally() 拦截异常,或者 .get() 阻塞获取结果。注意 .get() 会抛出受检异常,业务里通常包装成运行时异常处理。
多任务编排实战
聚合查询场景用 CompletableFuture 很顺手:
java
public CompletableFuture<DashboardDTO> buildDashboard(Long userId) {
CompletableFuture<OrderDTO> f1 = asyncService.getOrders(userId);
CompletableFuture<ProfileDTO> f2 = asyncService.getProfile(userId);
CompletableFuture<RecDTO> f3 = asyncService.getRecommend(userId);
return CompletableFuture.allOf(f1, f2, f3)
.thenApply(v -> DashboardDTO.builder()
.orders(f1.join())
.profile(f2.join())
.rec(f3.join())
.build())
.exceptionally(ex -> {
log.error("Dashboard build failed, userId={}", userId, ex);
return DashboardDTO.fallback();
});
}
这里有个致命细节:绝对不要在 @Async 线程池里调用 .get() 或 .join() 阻塞当前工作线程。 线程数有限,全卡住等结果,下一批任务连提交都进不去,死锁直接教做人。如果非要在子线程等,必须加超时:.get(2, TimeUnit.SECONDS)。
生产治理:监控、动态调参、泄漏排查与 OOM 防线
指标怎么接监控
ThreadPoolTaskExecutor 底层暴露了 getThreadPoolExecutor()。接 Micrometer 很直观,但拒绝策略的计数得自己包一层,原生没法直接当 Counter 注册:
java
@Bean
public MeterBinder asyncPoolMetrics(ThreadPoolTaskExecutor executor) {
return registry -> {
ThreadPoolExecutor tp = executor.getThreadPoolExecutor();
registry.gauge("async.pool.active", tp::getActiveCount);
registry.gauge("async.pool.queue.size", tp.getQueue()::size);
registry.gauge("async.pool.queue.remaining", tp.getQueue()::remainingCapacity);
// 拒绝次数建议封装自定义 Handler 递增 AtomicLong,或直接用 Actuator 的内置端点
registry.gauge("async.pool.completed", tp::getCompletedTaskCount);
};
}
Grafana 上盯死两个水位线:active / max > 70% 或者 queue remaining < 20%。一旦触线直接发告警,别等 504 了才去查日志。
动态参数调整
硬编码扛不住突发流量。配合 Nacos/Apollo 和 @RefreshScope 可以在不停机的情况下调整 corePoolSize 和 maxPoolSize。JDK 原生支持运行时修改这两个值,但要注意正在排队的任务会受影响。前面说了,queueCapacity 底层是 LinkedBlockingQueue,创建后改不了,真要动容量只能重建 Executor 把旧池子的任务迁移过去,线上操作得灰度。
线程泄漏排查与 OOM 防线
泄漏怎么查?定期 jstack -l <pid> | grep "biz-async-"。如果发现大量 RUNNABLE 或 BLOCKED 状态卡在同一行,90% 是下游 HTTP/DB 调用没设超时。把连接池和 HTTP Client 的超时时间补上,泄漏自然消失。
OOM 防线主要靠三板斧:
- 队列必须有界,配合拒绝策略形成背压,这是底线。
- 别在
Runnable里 new 大对象或持有一级缓存引用,任务跑完赶紧释放。 - JVM 参数
-Xss默认 1M,纯 IO 且调用栈不深的服务可以压到256k~512k省内存,但上线前必须压测防StackOverflowError。配合-XX:MaxRAMPercentage=75.0限制堆占比,给线程栈留出空间。
Spring Boot 容器关闭时会走 @PreDestroy 关闭自定义 Executor。务必把 waitForTasksToCompleteOnShutdown 打开,否则 shutdownNow() 会发中断信号,正在写 DB 的任务可能只写了一半,补数据能补到怀疑人生。
写在最后
@Async 用好了是利器,用不好就是线上炸弹。实际落地记住几条硬规则:
- 永远别用默认执行器,核心业务和非核心业务(比如发短信)必须拆池子隔离,非核心池可以直接配激进的丢弃策略保主流程。
- 异步不等于不可控。TraceId 透传、指标上报、异常落库是上线硬性门槛,缺一不可。
- 调参靠数据不靠猜。压测盯死
active、queue和 RT 拐点。当active逼近max且队列打满时,瓶颈通常在下游依赖或 SQL 索引,这时候盲目调大线程池只会加速系统雪崩。
异步编程的核心就是"用有限的计算资源调度无限的 I/O 等待"。把池子边界划清、上下文守好、监控闭环跑通,@Async 就能稳稳托住高并发。另外提一嘴,Java 21 的 Virtual Thread 已经在 Spring Boot 3.2+ 里落地,未来很多 IO 阻塞场景可以切到纤程,代码会更干净。但资源隔离、背压控制和全链路可观测的思路不会变,底层逻辑还是那一套。先把现有的线程池治理扎实,升级就是水到渠成的事。
🎁 福利时间
如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。
知识库地址:https://farerboy.com/
