Spring Boot 线程池关闭:destroyMethod 的作用与最佳实践

一、问题背景

在 Spring Boot 项目中,我们经常会用到线程池来执行后台任务。但如果线程池没有正确关闭,会导致进程无法退出、K8s 滚动更新超时等问题。

先看一段代码:

java 复制代码
@Bean(destroyMethod = "shutdown")
public LockManager lockManager() {
    return new LockManager();
}

这个 destroyMethod = "shutdown" 到底做了什么?为什么需要它?

二、destroyMethod 的作用

2.1 Spring Bean 的生命周期

复制代码
启动 → 实例化 → 依赖注入 → 初始化 → 使用 → 销毁

destroyMethod 就是告诉 Spring:容器关闭时,调用这个 Bean 实例上的哪个方法来做资源清理。

2.2 调用链路

复制代码
Spring 容器关闭
  └─ 发现 @Bean(destroyMethod = "shutdown")
       └─ 反射调用 instance.shutdown()   ← 调用的是实例对象的方法
            └─ 方法体内:scheduler.shutdownNow()  ← 你自己写的清理逻辑

对应代码:

java 复制代码
public class LockManager {
    private final ScheduledExecutorService scheduler = 
        Executors.newSingleThreadScheduledExecutor();

    public LockManager() {
        startCleanupTask();  // 启动后台清理线程
    }

    // Spring 关闭时调用这个方法
    public void shutdown() {
        scheduler.shutdownNow();  // 关闭线程池
    }
}

2.3 关键区分

名字 来源 作用
lockManager() @Bean 方法名 Bean 的注册名,工厂方法,只调用一次
shutdown destroyMethod 属性 Bean 实例上要调用的销毁方法名

Spring 只负责"找到并调用 shutdown()",不关心方法内部做什么。你在 shutdown() 里写什么,它就执行什么。

三、为什么线程池需要手动关闭

3.1 核心原因:非守护线程

Executors 工厂方法创建的线程池,默认都是非守护线程:

java 复制代码
// Executors.defaultThreadFactory() 源码
public Thread newThread(Runnable r) {
    Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0);
    t.setDaemon(false);   // ← 写死了非守护
    t.setPriority(Thread.NORM_PRIORITY);
    return t;
}
线程类型 JVM 行为
守护线程(daemon=true) JVM 不等它,直接退出
非守护线程(daemon=false) JVM 会等它执行完才退出 ← 默认是这个

所有 Executors 工厂方法默认都是非守护线程:

方法 默认 daemon
newSingleThreadScheduledExecutor() false
newScheduledThreadPool(n) false
newFixedThreadPool(n) false
newCachedThreadPool() false
newSingleThreadExecutor() false

3.2 不关闭会怎样

情况一:正常关闭(kill -15 / Ctrl+C)

复制代码
JVM 收到信号
  └─ Spring ShutdownHook 执行,容器关闭
       └─ 但 scheduler 线程还在跑(非守护线程)
            └─ Spring 容器已关闭,但 JVM 还在等这个线程
                 └─ 结果:进程卡住几秒 → 超时后强制终止

情况二:代码中主动调用 context.close()

复制代码
main 方法执行完
  └─ 但 scheduler 是非守护线程
       └─ JVM 不退出,进程挂住,永远不返回

情况三:kill -9

无论配不配 destroyMethod,都一样:

  • JVM 直接被杀,什么都不执行

3.3 实际生产影响

影响项 严重程度 说明
进程无法退出 嵌入式/单元测试场景下进程挂死
关闭变慢 正常 kill 时多卡几秒
资源泄漏 线程未释放,端口/文件句柄未关闭
K8s 滚动更新 旧 Pod 关不掉 → 超过 terminationGracePeriodSeconds → SIGKILL
任务执行不完整 清理任务可能被中途打断

四、项目中的三种关闭方式

4.1 @Bean(destroyMethod = "shutdown")

适用于:自定义 Bean 内部持有线程池

java 复制代码
@Configuration
public class AppConfig {

    @Bean(destroyMethod = "shutdown")
    public LockManager lockManager() {
        return new LockManager();
    }
}

public class LockManager {
    private final ScheduledExecutorService scheduler = 
        Executors.newSingleThreadScheduledExecutor();

    public LockManager() {
        startCleanupTask();
    }

    public void shutdown() {
        scheduler.shutdownNow();
    }
}

4.2 @PreDestroy 注解

适用于:Spring 组件(@Component)内部持有线程池

java 复制代码
@Component
public class DataToolMethod {

    private final ScheduledExecutorService scheduler = 
        Executors.newSingleThreadScheduledExecutor();

    @PostConstruct
    public void init() {
        scheduler.scheduleAtFixedRate(() -> {
            // 定时刷新缓存
        }, 1, 1, TimeUnit.MINUTES);
    }

    @PreDestroy
    public void destroy() {
        if (scheduler != null && !scheduler.isShutdown()) {
            scheduler.shutdown();
        }
    }
}

4.3 finally 手动关闭

适用于:方法级线程池,用完即关

java 复制代码
private List<ReturnShoppingCard> add(...) {
    ExecutorService executor = null;
    try {
        executor = Executors.newSingleThreadExecutor();
        final Future<?> future = executor.submit(() -> {
            // 业务逻辑
        });
        future.get(4400, TimeUnit.MILLISECONDS);
    } catch (Exception e) {
        future.cancel(true);
    } finally {
        executor.shutdown();  // 用完即关
    }
    return list;
}

4.4 三种方式对比

方式 适用场景 优点 缺点
destroyMethod 自定义 Bean 配置简单 方法名写死在注解里
@PreDestroy Spring 组件 语义清晰 需要单独写方法
finally 方法级线程池 即时释放 每次创建销毁有开销

五、最佳实践

5.1 优先使用 Spring 管理的线程池

Spring 提供了 ThreadPoolTaskExecutor,自带优雅关闭,不需要手动 shutdown()

java 复制代码
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("taskExecutor")
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("task-");
        executor.setWaitForTasksToCompleteOnShutdown(true);  // 等待任务完成
        executor.setAwaitTerminationSeconds(30);             // 最多等30秒
        executor.initialize();
        return executor;
    }
}

5.2 如果用原生 ExecutorService,一定要关闭

java 复制代码
@Component
public class MyService {
    private final ScheduledExecutorService scheduler = 
        Executors.newSingleThreadScheduledExecutor();

    @PreDestroy
    public void destroy() {
        scheduler.shutdown();           // 停止接收新任务
        try {
            if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) {
                scheduler.shutdownNow();  // 强制停止
            }
        } catch (InterruptedException e) {
            scheduler.shutdownNow();
            Thread.currentThread().interrupt();
        }
    }
}

5.3 方法级线程池不要每次都 new

java 复制代码
// ❌ 不推荐:每次调用都创建销毁线程池
public void process() {
    ExecutorService executor = Executors.newSingleThreadExecutor();
    try {
        executor.submit(() -> { ... });
    } finally {
        executor.shutdown();
    }
}

// ✅ 推荐:复用类级共享线程池
@Service
public class MyService {
    private final ExecutorService executor = Executors.newSingleThreadExecutor();

    @PreDestroy
    public void destroy() {
        executor.shutdown();
    }

    public void process() {
        executor.submit(() -> { ... });
    }
}

六、总结

问题 答案
destroyMethod 调用的是谁? Bean 实例上的同名方法,不是 @Bean 工厂方法
为什么需要手动关闭线程池? Executors 默认创建非守护线程,会阻止 JVM 退出
不关闭有什么影响? 进程挂住、K8s 滚动更新超时、资源泄漏
关闭线程池的三种方式? destroyMethod、@PreDestroy、finally
最佳实践是什么? 优先用 Spring 的 ThreadPoolTaskExecutor,自带优雅关闭
相关推荐
程序员爱钓鱼9 分钟前
Rust Trait Object详解:dyn Trait与动态分发
后端·面试·rust
chuan.bai5 小时前
Java RAG 实战(第 11 篇):RAG 知识工作台网页
java·开发语言·人工智能
0x537 小时前
网站通信(一)
java
To_OC8 小时前
Next.js + Redis 构建笔记系统:Redis Hash 实战与性能优化
redis·后端·next.js
Terra.K8 小时前
Java异常学习[特殊字符]
java·开发语言·学习
葡萄城技术团队9 小时前
InfluxDB 2\.x 深度解析:核心架构、Flux 函数与制造业落地指南(三)
java·开发语言·架构
Super 含9 小时前
Android 启动优化(五):线程、GC 与 IO 为什么会拖慢启动?
java·服务器·数据库
counting money9 小时前
Java IO流详解:从InputStream到文件操作实战
java·开发语言·python
程序员小八77710 小时前
上海百度B端java后端日常实习一面
java·开发语言
北斗落凡尘10 小时前
LangGraph 入门实战(11)--输出模式
后端·python·langchain