线上服务一到晚高峰 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 是标准解法,原理就是在任务提交前把主线程的上下文快照抓下来,在子线程执行前塞回去,执行完必须清掉,否则线程复用会导致上下文串扰:
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 日志,业务方根本不知道任务失败了。
最直接的修复是注册全局异常处理器:
@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 很顺手:
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 注册:
@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 阻塞场景可以切到纤程,代码会更干净。但资源隔离、背压控制和全链路可观测的思路不会变,底层逻辑还是那一套。先把现有的线程池治理扎实,升级就是水到渠成的事。
到此这篇关于springboot中@async异步调用实战指南的文章就介绍到这了,更多相关springboot @async异步内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论