@PreDestroy
定位:JSR-250 标准注解,标记在容器销毁 Bean 之前执行的清理方法。它是 Spring 销毁回调的首选推荐方式,与 Spring API 零耦合,确保应用在关闭时优雅释放资源。
定义与作用
@PreDestroy 是 Java 通用注解规范(JSR-250)定义的注解,位于 javax.annotation 包下(Spring 6 / Jakarta EE 9+ 后迁移至 jakarta.annotation)。
其核心语义是:在 Spring 容器关闭、Bean 实例被销毁之前,执行被标注的方法。该方法用于执行资源清理逻辑,例如:
- 关闭数据库连接池、释放文件句柄
- 停止后台线程、注销定时任务
- 清理临时文件、刷新缓冲区到磁盘
- 向外部服务发送下线通知(如 Eureka 注销、Nacos 下线)
与 DisposableBean.destroy() 相比,@PreDestroy 不强制实现 Spring 接口,代码可在 Jakarta EE 应用服务器等非 Spring 容器中复用。
适用位置与常用属性
| 项目 | 说明 |
|---|---|
| 标注位置 | 方法上(实例方法,不能是静态方法) |
| 方法签名 | public / protected / package-private,返回类型必须为 void,无参数 |
| 执行次数 | 每个 Bean 实例生命周期内仅执行一次 |
| 执行时机 | 容器关闭时、DisposableBean.destroy() 之前 |
| 属性 | 该注解本身无属性,纯标记注解 |
约束:一个类中只能有一个方法被
@PreDestroy标注。若存在多个,Spring 容器关闭时将抛出BeanCreationException。
作用域限制:
@PreDestroy仅对 singleton 作用域有效。prototype 作用域的 Bean 创建后容器不再跟踪,销毁回调永远不会执行。
核心原理
Spring 容器在 ApplicationContext.close() 或 JVM 关闭钩子触发时,遍历所有 singleton Bean 的销毁回调。CommonAnnotationBeanPostProcessor 负责识别 @PreDestroy 注解,通过反射调用被标注的方法。
与 DisposableBean 的执行顺序
在同一个 Bean 中,如果同时存在 @PreDestroy 和 DisposableBean:
@PreDestroy方法先执行destroy()后执行
这个顺序与初始化阶段的 @PostConstruct / InitializingBean 顺序一致,确保 JSR-250 标准注解优先于 Spring 专有接口。
完整示例:飞翔科技学生管理系统的资源优雅释放
场景简述
飞翔科技后端开发小崔负责学生管理系统的成绩导入模块。该模块使用 ExecutorService 处理批量成绩 Excel 文件的异步解析。CTO 大翔在架构评审会上强调:"系统重启或下线时,正在处理的任务必须完成或安全中断,线程池不能暴力关闭,否则可能丢失学生成绩数据。"
操作前:无销毁回调的代码
小崔最初的实现没有资源清理逻辑,依赖 JVM 退出时强制终止线程:
@Service
public class GradeImportService {
private final ExecutorService executor = Executors.newFixedThreadPool(4);
public void importGradesAsync(String filePath) {
executor.submit(() -> {
// 解析 Excel、写入数据库
System.out.println("[GradeImport] 正在导入:" + filePath);
});
}
// 问题:没有清理方法!
// JVM 退出时线程池中的任务可能被强制中断,导致数据不完整
}
运行结果:
[GradeImport] 正在导入:/uploads/2024_spring_grades.xlsx
[GradeImport] 正在导入:/uploads/2024_autumn_grades.xlsx
# 此时 Tomcat 热部署或应用重启
# 线程被强制中断,数据库中可能出现部分导入的成绩记录
问题分析:
- 线程池未优雅关闭,正在执行的任务被
Thread.interrupt()强制中断 - 已解析但未写入数据库的成绩记录丢失
- 临时文件未清理,磁盘空间持续增长
- MySQL 连接池连接未显式释放,可能导致连接泄漏
操作后:使用 @PreDestroy 的完整代码
小崔重构代码,添加 @PreDestroy 方法实现优雅关闭:
import org.springframework.stereotype.Service;
import javax.annotation.PreDestroy;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.io.File;
import java.util.Arrays;
@Service
public class GradeImportService {
private final ExecutorService executor = Executors.newFixedThreadPool(4);
private final String tempDir = System.getProperty("java.io.tmpdir") + "/grade_import";
public GradeImportService() {
// 确保临时目录存在
File dir = new File(tempDir);
if (!dir.exists()) {
dir.mkdirs();
}
System.out.println("[GradeImportService] 线程池初始化完成,临时目录:" + tempDir);
}
public void importGradesAsync(String filePath) {
executor.submit(() -> {
try {
System.out.println("[GradeImport] 开始导入:" + filePath);
// 模拟解析和写入耗时
Thread.sleep(2000);
System.out.println("[GradeImport] 导入完成:" + filePath);
} catch (InterruptedException e) {
System.out.println("[GradeImport] 导入任务被中断:" + filePath);
Thread.currentThread().interrupt();
}
});
}
/**
* 容器关闭前,优雅释放资源
* 1. 停止接收新任务
* 2. 等待已有任务完成(最多 30 秒)
* 3. 强制关闭未完成的任务
* 4. 清理临时文件
*/
@PreDestroy
public void shutdownGracefully() {
System.out.println("[GradeImportService] @PreDestroy 开始执行优雅关闭...");
// 步骤1:停止接收新任务
executor.shutdown();
System.out.println("[GradeImportService] 线程池已停止接收新任务");
try {
// 步骤2:等待已有任务完成
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
System.out.println("[GradeImportService] 部分任务未在 30 秒内完成,强制关闭");
executor.shutdownNow();
} else {
System.out.println("[GradeImportService] 所有任务已完成");
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
// 步骤3:清理临时文件
File dir = new File(tempDir);
if (dir.exists()) {
File[] files = dir.listFiles();
if (files != null) {
Arrays.stream(files).forEach(File::delete);
}
dir.delete();
System.out.println("[GradeImportService] 临时文件已清理");
}
System.out.println("[GradeImportService] @PreDestroy 优雅关闭完成");
}
}
Spring Boot 启动验证:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class StudentManagementApplication {
public static void main(String[] args) throws InterruptedException {
ConfigurableApplicationContext context =
SpringApplication.run(StudentManagementApplication.class, args);
GradeImportService service = context.getBean(GradeImportService.class);
// 提交两个异步导入任务
service.importGradesAsync("/uploads/2024_spring_grades.xlsx");
service.importGradesAsync("/uploads/2024_autumn_grades.xlsx");
// 模拟业务运行 1 秒后系统关闭
Thread.sleep(1000);
System.out.println("\n[Main] 系统即将关闭...\n");
context.close();
}
}
运行结果及分析:
[GradeImportService] 线程池初始化完成,临时目录:/tmp/grade_import
[GradeImport] 开始导入:/uploads/2024_spring_grades.xlsx
[GradeImport] 开始导入:/uploads/2024_autumn_grades.xlsx
[Main] 系统即将关闭...
[GradeImportService] @PreDestroy 开始执行优雅关闭...
[GradeImportService] 线程池已停止接收新任务
[GradeImport] 导入完成:/uploads/2024_spring_grades.xlsx
[GradeImport] 导入完成:/uploads/2024_autumn_grades.xlsx
[GradeImportService] 所有任务已完成
[GradeImportService] 临时文件已清理
[GradeImportService] @PreDestroy 优雅关闭完成
执行顺序验证:
- 容器启动:
GradeImportService实例化,线程池创建 - 业务运行:两个导入任务提交到线程池
- 容器关闭:
context.close()触发销毁流程 @PreDestroy执行:停止新任务提交 → 等待已有任务完成 → 清理临时文件- 所有成绩数据完整导入,无资源泄漏
易错场景与面试考点
易错场景一:prototype 作用域 Bean 的 @PreDestroy 不执行
小崔曾将成绩解析器配置为 prototype,期望每次导入都使用新实例,并在导入完成后自动清理:
@Component
@Scope("prototype")
public class GradeParser {
private final List<String> tempFiles = new ArrayList<>();
public void parse(String filePath) {
String tempFile = createTempCopy(filePath);
tempFiles.add(tempFile);
// 解析逻辑...
}
@PreDestroy
public void cleanup() {
// 错误!这个方法永远不会被调用
tempFiles.forEach(path -> new File(path).delete());
}
}
问题:prototype Bean 创建后容器不再持有其引用,@PreDestroy 永远不会执行。tempFiles 中的临时文件将持续占用磁盘空间,直到 JVM 退出或手动清理。
解决方案:
// 方案1:改回 singleton,由调用方显式清理
@Component
public class GradeParser {
public void parse(String filePath) {
String tempFile = createTempCopy(filePath);
try {
// 解析逻辑...
} finally {
new File(tempFile).delete(); // 立即清理
}
}
}
// 方案2:使用 try-with-resources 或 finally 块在业务方法内清理
// 方案3:自定义作用域管理器(高级用法,不在本章范围)
易错场景二:在 @PreDestroy 中调用可能已销毁的依赖
@Service
public class GradeExportService {
private final DataSource dataSource;
public GradeExportService(DataSource dataSource) {
this.dataSource = dataSource;
}
@PreDestroy
public void flushBuffer() {
// 危险!DataSource 可能已先被销毁
try (Connection conn = dataSource.getConnection()) {
// 刷新缓冲区...
} catch (SQLException e) {
System.out.println("[GradeExportService] 刷新失败:" + e.getMessage());
}
}
}
问题分析:Spring 销毁 Bean 的顺序与依赖关系相反,但同一层级内的销毁顺序不保证。若 DataSource 在 GradeExportService 之前销毁,dataSource.getConnection() 将抛出异常。
正确做法:在 @PreDestroy 中仅操作当前 Bean 自身的资源,不依赖其他 Bean 的状态。若必须依赖,使用 @DependsOn 显式控制销毁顺序:
@Service
@DependsOn("studentDataSource") // 确保 DataSource 在本 Bean 之后销毁
public class GradeExportService {
// ...
}
易错场景三:@PreDestroy 方法执行时间过长阻塞关闭流程
@Service
public class ReportService {
@PreDestroy
public void generateFinalReport() {
// 错误!生成报表可能需要数分钟,阻塞容器关闭
generateMonthlyReport();
generateYearlyReport();
}
}
问题:@PreDestroy 在容器关闭的主线程中同步执行。若方法耗时过长,将导致应用停止时间延长,在 Kubernetes 等环境中可能触发强制终止(SIGKILL)。
正确做法:@PreDestroy 中只做轻量级清理(关闭连接、释放句柄)。耗时操作应在应用关闭前通过业务逻辑或独立钩子完成。
面试考点
Q1:@PreDestroy 对 prototype 作用域有效吗?
无效。prototype Bean 创建后容器不再跟踪其生命周期,销毁回调永远不会执行。若需清理 prototype Bean 的资源,应在业务方法内使用
try-finally或try-with-resources即时清理。
Q2:@PreDestroy 和 DisposableBean.destroy() 的执行顺序?
@PreDestroy先执行,destroy()后执行。标准注解优先于 Spring 专有接口,与初始化阶段的顺序规则一致。
Q3:Spring Boot 应用如何确保 @PreDestroy 在 JVM 异常退出时也能执行?
注册 JVM Shutdown Hook:
Runtime.getRuntime().addShutdownHook(...)。Spring 的ConfigurableApplicationContext.registerShutdownHook()已默认注册,确保context.close()在 JVM 退出时被调用。但 SIGKILL(kill -9)无法捕获,此时@PreDestroy不会执行。
Q4:@PreDestroy 方法可以抛出异常吗?
可以,但不建议。
@PreDestroy方法抛出的异常会被 Spring 捕获并记录,不会阻止其他 Bean 的销毁流程继续执行。但异常可能导致当前 Bean 的后续清理逻辑中断。
本文边界说明
本文档仅讲解 @PreDestroy 注解。关于初始化阶段的对应注解 @PostConstruct、Spring 接口 InitializingBean / DisposableBean、以及容器扩展点 BeanPostProcessor / BeanFactoryPostProcessor,请分别参阅本章其他独立文档。严禁在讲解 @PreDestroy 时混入 @PostConstruct 或 AOP 代理的详细实现,以保持知识点的原子性和教学清晰度。