第四章:后台任务常见模式——线程池、异步任务、定时任务
后台任务一旦没释放引用,GC 抖动几乎是必然的。我排查过不少这类问题,归根结底,任务生命周期没管好,引用链断不掉。这一章咱们把三种常见模式捋一遍,看看各自容易在哪栽跟头。
线程池模式
线程池的核心是复用 Worker 线程。任务跑完后线程不销毁,回池等下一个任务。这时候如果任务里引用了大对象,Worker 线程会一直持有,GC 根本收不掉。
典型案例:每个任务里 new 了一个 100KB 的缓存对象,赋给了线程局部变量。任务结束变量没清,线程回池,对象跟着线程存活。几千个任务下去,堆里几百兆对象就是回收不了。
我在项目里遇到过更隐蔽的——线程池里跑了一个定时任务,任务里调用了外部的 DAO 类,DAO 类里 hold 了一个 Map 缓存。任务本身其实早结束了,但 Spring 的代理对象被 Worker 线程引用着,缓存一直不释放。YGC 频繁,老年代暴涨。
避坑指南(我自己的习惯):
- 任务内尽量用局部变量,不往线程局部变量或静态字段里塞大对象。
- 如果非要用 ThreadLocal,任务结束时手动 remove()。
- 线程池的 coreSize 别设太大,我一般线上控制在 8~16 之间,避免闲线程太多。
// 错误示例:任务内持有大对象引用
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(() -> {
byte[] large = new byte[10 * 1024 * 1024]; // 10MB
// 没有释放引用,线程回池后 large 依然可达
});
// 正确做法:任务结束前置 null
pool.submit(() -> {
byte[] large = new byte[10 * 1024 * 1024];
// 处理逻辑...
large = null; // 显式断开引用
});
异步任务模式
CompletableFuture、@Async 这类异步任务,本质是另起线程执行回调。回调闭包往往会捕获外层对象的引用。举个例子,在 Controller 里调了一个异步方法,异步方法内部使用了 Controller 实例的成员变量——糟糕,Controller 对象被回调线程强制引用,请求结束后 Controller 无法被回收。
我记得有一次查生产 GC 问题,发现一个商户服务的内存老在 2GB 以上降不下来。dump 一看,异步回调里引用了一个巨大的报表对象,那个报表对象又被一堆线程持着。排查下来,是 @Async 方法里的 lambda 表达式捕获了外围的 Service 实例,而 Service 实例里有个 List 存了所有报表数据。说白了,闭包让引用链从 Worker 线程一直连到业务对象,收不回来了。
我建议的做法:
- 在 lambda 或匿名内部类里只传递必要参数,别直接传 this。
- 如果必须用外部实例,用弱引用(WeakReference)包裹,或者用 Guava 的 WeakReferenceCache。
- 异步任务结束后,主动置空回调里的大对象引用。
// 问题代码:lambda 隐式持有外部实例
public class ReportService {
private byte[] hugeReport; // 一个大块头
public void asyncGenerate() {
CompletableFuture.runAsync(() -> {
// 这里隐式持有 ReportService.this,hugeReport 也被引用
processReport(hugeReport);
});
}
private void processReport(byte[] data) { ... }
}
// 改进:只传需要的数据,避免持有 this
public void asyncGenerate() {
byte[] localRef = hugeReport;
CompletableFuture.runAsync(() -> {
processReport(localRef);
localRef = null; // 任务结束断开
});
hugeReport = null; // 外部引用也主动断开
}
定时任务模式
Timer、ScheduledExecutorService、Quartz——这些任务长期存活,每隔几秒或几分钟执行一次。如果任务里引用了不该引的对象,那这个对象会跟着任务活到天荒地老。
我曾经排查过一个诡异的 Metaspace 泄漏。一个定时任务每隔 5 秒拉一次数据,拉完放到一个 List 里。开发说“我会 clear() 啊”,但我们发现每次 clear() 之后,List 内部数组其实没有被缩容,容量维持在最大峰值。GC 时遍历对象图,这个 List 的 elementData 数组一直活着,因为定时任务线程持着任务对象的引用,任务对象又持着 List。说白了,clear() 只是把引用置 null,数组本身没变小。
避坑提醒:
- 定时任务里不要持有单例 Bean 的集合字段长期引用。如果非要有缓存,设置最大容量或使用 Guava Cache 的弱引用。
- 任务结束后,把任务内部的大对象置 null,或者用新对象替换。
- 慎用 TimerTask —— 它继承了 TimerThread 的引用链,一个任务泄漏等于整个 Timer 线程池污染。
模式对比与预防
我把三种模式的典型泄漏场景和排查方法整理成一张表,排查时对着看会快很多。
| 模式 | 引用泄漏典型场景 | 排查关键 |
|---|---|---|
| 线程池 | 任务内使用 ThreadLocal 未清除 / 大对象赋值给静态字段 | dump 中关注 Worker 线程的堆栈,看局部变量表、线程局部变量 |
| 异步任务 | lambda 隐式捕获外部实例 / Future 未取消导致回调链阻塞 | 检查 CompletableFuture 的 callstack,看闭包引用了哪些外围对象 |
| 定时任务 | Timer 线程持有任务对象,任务内持有集合缓存 / 内部数组不缩容 | 用 jstack 看线程栈,配合 MAT 排查 TimerThread → TimerTask → 业务对象 的路径 |
思路小结:不管哪种模式,核心就一句话——任务的生命周期 = 引用的生命周期。任务跑完了,引用必须断。我习惯在任务结束时手动置空所有非必要引用,尤其那些超过几 KB 的对象。另外加一句,别滥用线程池,能用局部变量就别塞到类字段里。
画图时我特意把泄漏链条标红了,方便你对着排查。这三种模式在框架代码里经常混用,但泄漏原理一致——都是让本该回收的对象被后台线程持住。下回你再看到 GC 抖动,先看一眼这几个模式的引用路径,八成能快速锁定问题。