Tomcat 严重警告:JDBC 驱动未注销 + 工作线程泄漏 ------ 原因、影响与彻底修复(生产级终极指南)
摘要:本文深入解析 Tomcat 中常见的"JDBC 驱动未注销"及"工作线程泄漏"严重警告,揭示其导致 Metaspace 内存溢出和线程资源耗尽的真实风险。提供生产级解决方案:精确获取类加载器、按序关闭连接池与调度器、注销驱动,并针对 MySQL 特有线程给出清理方法。附带验证命令与最佳实践清单,帮助开发者彻底根治内存泄漏。
一、问题现象
在 Tomcat 停止或重新部署 Web 应用时,catalina.out 日志中频繁出现如下严重(severe) 警告:
text
严重: The web application [] registered the JDBC driver [oracle.jdbc.OracleDriver] but failed to unregister it when the web application was stopped. To prevent a memory leak, the JDBC Driver has been forcibly unregistered.
严重: The web application [] appears to have started a thread named [GLOBAL_SCHEDULER_Worker-1] but has failed to stop it. This is very likely to create a memory leak.
...(重复数十甚至上百次)
- 第一条:JDBC 驱动未主动注销,Tomcat 强制清理。
- 第二条:大量名称类似
GLOBAL_SCHEDULER_Worker-N的工作线程未被停止,Tomcat 警告内存泄漏。
二、根本原因分析
1. JDBC 驱动泄漏
- 应用启动时,通过
DriverManager.registerDriver()注册了数据库驱动(如oracle.jdbc.OracleDriver)。 - 应用停止时,没有 调用
DriverManager.deregisterDriver()注销该驱动。 - 导致
DriverManager仍然持有驱动的引用,进而阻止 Web 应用专用的类加载器(WebappClassLoader)被 GC 回收。
2. 工作线程泄漏(以 GLOBAL_SCHEDULER_Worker 为例)
- 线程命名
GLOBAL_SCHEDULER_Worker-N是 Quartz Scheduler 的默认线程池命名模式(也可能是其他基于 Quartz 的任务调度框架)。 - 应用启动了 Quartz 调度器(或其他定时任务线程池),但在应用停止时没有调用
scheduler.shutdown()。 - 这些工作线程是非守护线程,即使 Web 应用停止,它们依然存活,并且持有对
WebappClassLoader的强引用。
3. 连接池未关闭
- 现代连接池(HikariCP、Druid、Tomcat JDBC Pool)在关闭时不仅释放物理连接,还会停止内部的维护线程。如果只注销驱动而不关闭连接池,这些线程同样会泄漏。
4. MySQL 驱动独有的"幽灵线程"
- MySQL Connector/J 会启动一个 JVM 级别的守护线程
AbandonedConnectionCleanupThread,用于监控废弃连接。这个线程持有对旧类加载器的强引用,是导致 Metaspace 泄漏的常见元凶,且不会随 Web 应用停止而自动退出。
三、实际影响 ------ 并非"无害警告"
很多开发者认为"Tomcat 已经帮我清理了,不用管",这是严重的误区。
| 资源类型 | 短期影响 | 长期影响(多次热部署后) |
|---|---|---|
| JDBC 驱动 | Tomcat 强制注销,当前无明显问题 | 若驱动 JAR 放在 Tomcat 的 lib/ 下且多应用共用,强制注销会导致其他应用无法获取连接;代码存在资源管理缺陷 |
| 未停止的 Quartz 工作线程 | 应用停止后仍有几十个后台线程消耗 CPU/内存 | 1. Metaspace / PermGen 内存泄漏 :每个旧类加载器无法回收,每次部署累积,最终导致 OutOfMemoryError: Metaspace 2. 线程资源耗尽 :线程数无限制增长,最终 unable to create new native thread 3. 任务重复执行:旧调度器仍运行旧代码,可能造成数据错乱或资源冲突 |
| 未关闭的连接池 | 物理连接未释放,数据库端可能积压大量 idle in transaction 连接 |
数据库连接数耗尽,应用无法获取新连接;连接池内部线程泄漏,加剧类加载器膨胀 |
MySQL AbandonedConnectionCleanupThread |
看不见的后台线程 | 每个重新部署都会保留一个该线程的旧版本引用,最终导致 Metaspace OOM |
结论:这类警告是明确的内存泄漏信号,必须修复,否则 Tomcat 在频繁重新部署后必然崩溃。
四、彻底解决方案(含所有优化细节)
核心思路:在 Web 应用停止时,按正确顺序 释放所有全局资源。最佳实践是实现一个 ServletContextListener ,在 contextDestroyed() 中执行清理。
步骤 1:精确获取 Web 应用的类加载器(避免误伤)
java
// 不推荐:当前线程的上下文类加载器可能在复杂容器中指向 Tomcat 的公共类加载器
// ClassLoader cl = Thread.currentThread().getContextClassLoader();
// 推荐:直接从 ServletContext 获取本应用的类加载器
ClassLoader webappClassLoader = sce.getServletContext().getClassLoader();
步骤 2:关闭数据库连接池(必须在注销驱动之前)
java
// 以 HikariCP 为例
DataSource ds = (DataSource) sce.getServletContext().getAttribute("hikariDS");
if (ds instanceof HikariDataSource) {
HikariDataSource hds = (HikariDataSource) ds;
if (!hds.isClosed()) {
hds.close();
System.out.println("HikariCP connection pool closed.");
}
}
// 以 Druid 为例
if (ds instanceof DruidDataSource) {
DruidDataSource dds = (DruidDataSource) ds;
if (!dds.isClosed()) {
dds.close();
System.out.println("Druid connection pool closed.");
}
}
注意 :关闭连接池会释放所有物理连接并停止内部维护线程。如果您的连接池是在 Spring 容器中管理的,Spring 关闭时会自动调用
close(),但为了避免监听器执行顺序问题,显式调用更安全。
步骤 3:停止所有调度器(以 Quartz 为例)
java
// 停止 Quartz 调度器
try {
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
if (scheduler != null && !scheduler.isShutdown()) {
scheduler.shutdown(true); // true 表示等待当前任务执行完毕
System.out.println("Quartz Scheduler shut down.");
}
} catch (Exception e) {
e.printStackTrace();
}
如果使用 Spring 的 @Scheduled + ThreadPoolTaskScheduler:
java
ApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(sce.getServletContext());
if (ctx != null) {
Map<String, ThreadPoolTaskScheduler> schedulers = ctx.getBeansOfType(ThreadPoolTaskScheduler.class);
for (ThreadPoolTaskScheduler scheduler : schedulers.values()) {
scheduler.shutdown();
}
}
步骤 4:注销 JDBC 驱动(使用精确的类加载器)
java
Enumeration<Driver> drivers = DriverManager.getDrivers();
while (drivers.hasMoreElements()) {
Driver driver = drivers.nextElement();
if (driver.getClass().getClassLoader() == webappClassLoader) {
try {
DriverManager.deregisterDriver(driver);
System.out.println("Deregistered JDBC driver: " + driver);
} catch (SQLException e) {
e.printStackTrace();
}
}
}
步骤 5:清理 MySQL 特有的 AbandonedConnectionCleanupThread(如果使用 MySQL)
java
try {
// MySQL 8.0 及以后
Class.forName("com.mysql.cj.jdbc.AbandonedConnectionCleanupThread")
.getMethod("shutdown").invoke(null);
// MySQL 5.1 之前(可选)
// Class.forName("com.mysql.jdbc.AbandonedConnectionCleanupThread")...
System.out.println("MySQL AbandonedConnectionCleanupThread shut down.");
} catch (ClassNotFoundException e) {
// 未使用 MySQL 驱动,忽略
} catch (Exception e) {
e.printStackTrace();
}
重要 :该线程是 JVM 级别的,调用
shutdown()方法后,整个 JVM 中所有 MySQL 驱动的清理线程都会被停止。如果同一个 Tomcat 中部署了多个使用 MySQL 的应用,最后一个停止的应用执行这段代码会关闭共享线程,这是安全的(线程本身无需多实例)。
完整监听器代码示例(整合以上所有步骤)
java
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import javax.servlet.annotation.WebListener;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Enumeration;
@WebListener
public class ResourceCleanupListener implements ServletContextListener {
@Override
public void contextDestroyed(ServletContextEvent sce) {
System.out.println("Starting resource cleanup...");
// 1. 获取当前 Web 应用的类加载器
ClassLoader webappClassLoader = sce.getServletContext().getClassLoader();
// 2. 关闭连接池(HikariCP / Druid 等)
closeConnectionPool(sce);
// 3. 停止 Quartz 调度器
shutdownQuartzScheduler();
// 4. 注销 JDBC 驱动
deregisterJdbcDrivers(webappClassLoader);
// 5. 清理 MySQL 特殊线程
shutdownMySQLCleanupThread();
System.out.println("Resource cleanup completed.");
}
private void closeConnectionPool(ServletContextEvent sce) {
// 实现见上
}
private void shutdownQuartzScheduler() {
// 实现见上
}
private void deregisterJdbcDrivers(ClassLoader cl) {
Enumeration<Driver> drivers = DriverManager.getDrivers();
while (drivers.hasMoreElements()) {
Driver driver = drivers.nextElement();
if (driver.getClass().getClassLoader() == cl) {
try {
DriverManager.deregisterDriver(driver);
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
private void shutdownMySQLCleanupThread() {
try {
Class.forName("com.mysql.cj.jdbc.AbandonedConnectionCleanupThread")
.getMethod("shutdown").invoke(null);
} catch (ClassNotFoundException ignored) {
// 不是 MySQL 驱动
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void contextInitialized(ServletContextEvent sce) {
// 可留空或做初始化
}
}
步骤 6:注册监听器
在 web.xml 中配置(注意顺序,应放在所有初始化监听器之后):
xml
<listener>
<listener-class>com.yourpackage.ResourceCleanupListener</listener-class>
</listener>
或直接使用 @WebListener 注解(Servlet 3.0+)。
五、配置层面的防御手段(Tomcat server.xml)
除了代码层面的修复,还可以通过修改 Tomcat 的全局配置来预防部分泄漏。在 $CATALINA_HOME/conf/server.xml 中找到 JreMemoryLeakPreventionListener,并指定需要预初始化的驱动类,这能有效防止某些驱动在热部署时引发的类加载器绑定问题:
xml
<Listener className="org.apache.catalina.core.JreMemoryLeakPreventionListener"
classesToInitialize="com.mysql.cj.jdbc.NonRegisteringDriver" />
注意:这仅作为辅助手段,不能替代代码中的显式资源释放。
六、关于连接池选型的延伸建议
不同的连接池在资源生命周期管理上表现差异明显:
| 连接池 | 自动清理能力 | 推荐度 |
|---|---|---|
| HikariCP | 关闭 HikariDataSource 时自动注销驱动、停止内部线程 |
⭐⭐⭐⭐⭐ |
| Druid | 关闭 DruidDataSource 时自动释放资源;可配置 removeAbandoned=true 自动回收超时连接 |
⭐⭐⭐⭐⭐ |
| Tomcat JDBC Pool | 需显式调用 close(),且对驱动注销的支持不够完善 |
⭐⭐⭐ |
| DBCP / DBCP2 | 已知存在类加载器泄漏问题,不推荐生产环境使用 | ⭐ |
建议 :如果您尚未选择连接池,优先考虑 HikariCP 或 Druid 。对于 Druid,开启 removeAbandoned="true" 和 logAbandoned="true" 可以从根源上减少人为遗漏导致的连接泄漏。
七、进阶排查与验证工具
在"验证修复是否成功"一节中,除了使用 jconsole,还可结合 Linux 环境下的硬核排查命令:
1. 检查残留线程(确认清理线程是否还在运行)
bash
jstack -l <pid> | grep -A5 -B5 "AbandonedConnectionCleanupThread"
如果修复后该线程仍存在且由您的 Web 应用类加载器加载,说明 MySQL 清理代码未生效。
2. 监控 Metaspace 变化(观察多次重部署后是否持续上涨)
bash
jstat -gc <pid> 5s
关注 MU (Metaspace Used) 指标,如果每次部署后都增加且不回落,说明类加载器泄漏。
3. 验证类加载器卸载(确认旧的 WebAppClassLoader 实例数是否归零)
bash
sudo jmap -clstats <pid> | grep WebappClassLoader
正常情况:每次部署后,旧的类加载器应被垃圾回收,jmap 不应显示累积。
八、一个特殊现象:用户权限混乱导致的问题
有用户反馈:之前用 root 启动 Tomcat,后来用非 root 用户启动,发现进程仍是 root,杀进程后再用非 root 启动"一切正常"。这是怎么回事?
- 直接原因 :旧
root进程没有完全杀死(端口被占用、文件锁残留),导致新启动的 Tomcat 异常,警告可能来自旧进程。彻底杀死旧进程后,新环境干净,暂时看不到警告。 - 根本问题 :你的应用代码仍然没有做资源清理。只要你再次停止或重新部署应用,警告会再次出现。
- 最佳实践 :
- 始终使用专用非
root用户运行 Tomcat(安全且避免权限干扰)。 - 不要依赖"杀进程换用户"来解决问题,必须修复代码。
- 始终使用专用非
九、验证修复是否成功
- 部署修复后的 Web 应用。
- 通过 Tomcat Manager 或手动执行
shutdown.sh停止应用(不要 kill -9)。 - 检查
catalina.out:- 不应再出现
clearReferencesThreads和clearReferencesJdbc的 严重 日志。 - 应看到你自己打印的"Resource cleanup completed"等日志。
- 不应再出现
- 多次重新部署应用(建议 5 次以上),使用上述
jstat和jmap命令观察 Metaspace 和类加载器数量是否稳定。
十、总结与最佳实践清单
| 检查项 | 正确做法 |
|---|---|
| 类加载器获取 | 从 ServletContext.getClassLoader() 获取,避免使用当前线程上下文 |
| 数据库连接池 | 显式调用 dataSource.close(),顺序在注销驱动之前 |
| JDBC 驱动 | 遍历 DriverManager,仅注销本应用类加载器加载的驱动 |
| Quartz 调度器 | 调用 scheduler.shutdown(true) |
Spring @Scheduled |
手动关闭 ThreadPoolTaskScheduler 或配置 destroy-method="shutdown" |
| MySQL 专用线程 | 调用 AbandonedConnectionCleanupThread.shutdown() |
| 自定义线程池 | 调用 ExecutorService.shutdown() |
| Tomcat 运行用户 | 使用专用非 root 用户,避免权限残留 |
| 应用停止方式 | 使用 shutdown.sh 或 Manager 停止,避免暴力 kill -9 |
| 连接池选型 | 推荐 HikariCP / Druid,避免 DBCP |
| 监控验证 | 使用 jstack、jstat、jmap 确认无泄漏 |
最后强调 :Tomcat 输出的
clearReferencesThreads严重警告不是"善意提醒",而是明确的内存泄漏风险报告。忽略它的代价是生产环境频繁 OOM 或线程耗尽。请务必在代码中主动释放所有全局资源,这是 Java Web 应用优雅关闭的基本素养。