引言
大家好呀!自从上次更新以来,已经有几个月没有更新了。这次给大家更新一个我在工作中看到老旧项目的雷点,这种问题没处理好,轻则导致内存占用逐渐升高,重则直接OOM,应用直接宕机🥲
背景
公司的定时任务是使用Quartz来调用的,而当初为了快速开发,赶工期进度,所以整个项目的代码规范和CR就不是做的很好。这为后期维护和继续开发埋下了雷点。
某位故人在开发定时任务模块的时候,喜欢把线程池放在整个定时任务类下面作为成员变量使用,但这样有非常大的隐患!!!
例如下面的代码
java
@DisallowConcurrentExecution
public class MyJob implements Job {
private final ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(
2 * Runtime.getRuntime().availableProcessors(),
5 * Runtime.getRuntime().availableProcessors(),
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
@Override
public void execute(JobExecutionContext context) {
// 使用 threadPoolExecutor
}
}
隐患分析
Quartz维度
Quartz 默认的 JobFactory (org.quartz.simpl.SimpleJobFactory
)在每次触发时,都会重新实例化一个新的 Job 对象 ,然后调用它的 execute
方法。
所以如果你不做特殊配置,那么每一次任务触发,都会创建一个新的任务类实例
为什么要这样设计?
- 避免线程安全问题。因为 Quartz 的任务可能并行执行,如果共享同一个 Job 实例会出现状态混乱(上下文与数据等)。
- Job 类默认是 无状态的(stateless) ,每次用完丢掉
特殊情况?如果你的 Job 类加了 @DisallowConcurrentExecution
注解,Quartz 会确保同一时间不会并发执行同一个 JobDetail 的实例,但它依然是每次触发都会创建新对象
如果Quartz 调用它的 execute
方法执行完之后,这个 Job 对象就不再被 Quartz 持有引用,没有引用 → 会被 JVM 的垃圾回收器回收,所以:Job 对象里定义的成员变量也会跟着消亡(只要你没有把它们存到静态变量、单例 bean、ThreadLocal 等地方)
放在我们这里的场景来说,我们这里的成员变量是一个线程池,但是如果每次都new一个任务实例出来的时候,如果它不是静态成员变量,就每次都会重新创建出来一个线程池来使用。那就完全没有使用池化这个思想,同时也是完全浪费了线程池的核心作用。最可怕的是每一次都会以线程池维度而不是单个线程增加线程,最严重的就会导致整个应用OOM! 😭
类的成员变量维度
java
private static final ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(
2 * Runtime.getRuntime().availableProcessors(),
5 * Runtime.getRuntime().availableProcessors(),
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
这串代码与刚才不同的就是给线程池这个成员变量加了一个static关键字。
static 在这里的作用
static
成员变量属于 类本身,而不是某个对象实例。- 因此无论 Quartz new 出多少个
Job
实例,它们都指向同一个threadPoolExecutor
对象。 - 也就是说,线程池只会在类加载时初始化一次,而不会随着 Job 实例的创建而重复 new
如果不加static关键字,每一次都new一个线程池出来,不复用同一个线程池,导致线程数蹭蹭上涨!
生命周期
-
这个线程池会一直存在,直到:
- JVM 进程结束,或者
- 你主动调用
threadPoolExecutor.shutdown()
。
-
所以它和
Job
实例的生命周期解耦,不会因为 Job 执行完而销毁
线程池维度
这个任务执行完了,并不代表这个线程池也会结束并回收。
当 MyJob
实例被 Quartz 调度器回收时,它持有的 threadPoolExecutor
成员变量引用会被清除,但线程池本身(ThreadPoolExecutor 对象)不会自动关闭或销毁,其内部的线程仍会继续存活,直到显式调用 shutdown()
或 JVM 退出
我们需要注意的是:对象回收 ≠ 资源释放
Java 的垃圾回收(GC)只负责回收堆内存中的对象实例 ,但不会自动关闭或释放该对象所持有的系统资源 ------ 如:
- 文件句柄
- 网络连接
- 线程池中的线程
- 数据库连接
- 锁、信号量等
每一个被回收的任务实例的线程池所创建的线程依旧还存活着,内存泄漏最终会演变成内存溢出。
为什么之前没有发现这个问题?
之前为了快速开发,每次应用都会重新发版重启,而且定时任务执行频率不是很高,所以问题不是很明显。随着公司的业务发展,定时任务的个数与执行频率也在升高,在grafana看到线程数一直飙升,这个问题势必要进行解决。
解决之策
短期缓解------在每一个成员变量线程池加static
避免了每次调度都 new 一个线程池,线程数不会无限膨胀。Job 实例之间共享资源,更高效,基本不会出现线程数蹭蹭往上涨的问题。
这个方法只是短期的解决方案,并不长久。
长期方案------线程池统一交给容器管理
好处
为什么要交给 Spring 管理?
- 生命周期可控:线程池由 Spring 容器创建和销毁,应用启动时初始化,关闭时自动优雅地 shutdown。
- 资源复用:所有需要用线程池的 Job、Service 都能从容器里注入同一个池,避免重复造轮子。
- 配置集中化:核心线程数、最大线程数、队列大小、拒绝策略等参数统一配置,方便调整。
- 监控和管理:可以通过 Spring Boot Actuator 或自定义监控暴露线程池运行状态(活跃线程数、队列长度等)
对比
- 非 static 成员变量:线程池随着 Job 实例频繁 new,泄漏风险大,线程数失控。
- static 成员变量:同一个 Job 类共用一个池,但分散在多个类里不好统一管理,生命周期不可控。
- 统一管理类(Manager) :集中管理,单例池,可控性好,最推荐
实现示例
可以根据不同的业务板块,线程池管理类可以有多个,单个管理类下面存放管理多个线程池,用于不同的任务执行。这样就可以统一管理了
java
@Component
public class ThreadPoolManager {
private final ThreadPoolExecutor executor;
public ThreadPoolManager() {
this.executor = new ThreadPoolExecutor(
2 * Runtime.getRuntime().availableProcessors(),
5 * Runtime.getRuntime().availableProcessors(),
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
public ThreadPoolExecutor getExecutor() {
return executor;
}
@PreDestroy
public void shutdown() {
executor.shutdown();
}
}
总结
线程池作为成员变量或者在方法内创建线程池总是会有各种问题,在初期代码规范时就应该保证不能出现这样的代码,否则后期维护则极其困难与烦心!最好的方法就是交给容器统一管理生命周期,同时也方便使用❤️