Tomcat-热加载和相关Class的垃圾回收探讨
本文深入分析了 WebappClassLoader 的热加载整体流程,并对热加载过程中遗留的垃圾回收问题进行了系统性拆解。重点涵盖了:垃圾的分类、核心引用关系的梳理、各类资源的清理策略。最后详细解析了 Tomcat 如何通过兜底机制清理仍存活的线程,并结合实践给出了线程资源管理的最佳建议
热加载
WebappLoader#backgroundProcess是负责检测和触发热加载的方法入口,其被standardEngine的backgroundThread线程周期性触发,被调用路径为:ContainerBackgroundProcessor#run -> ContainerBackgroundProcessor#processChildren -> StandardContext.backgroundProcess -> Loader#backgroundProcess
当web可触发context的资源重加载时,backgroundThread线程会销毁(stop)当前context内部的所有资源,并进行重启context(start),start过程中使用了新的WebappClassLoader加载项目相关的class资源,因此可以在不重启整个 Tomcat 进程的前提下,以实现应用级别的热部署与资源更新
WebappLoader#backgroundProcess
public void backgroundProcess() {
// reloadable由StandardContext.reloadable设置,默认为false,也就表示需要在context.xml里主动配置reloadable=true才开启热加载检测资格
if (reloadable && modified()) { // Context里有class或jar改变了,需要进行热加载
try {
// 临时切换到Tomcat的commonLoader,避免使用即将被卸载的WebappClassLoader
// 确保reload过程中使用的是稳定的类加载器环境
Thread.currentThread().setContextClassLoader(WebappLoader.class.getClassLoader());
if (context != null) {
context.reload();
}
} finally {
if (context != null && context.getLoader() != null) {
// 热加载完毕,将context里新的WebappClassLoader绑定到线程上下文中
Thread.currentThread().setContextClassLoader(context.getLoader().getClassLoader());
}
}
}
}
class资源变化监测
WebappClassLoaderBase#modified()
负责监测当前 Web 应用中的 class 资源是否发生变更,用于触发 Context 重载。当以下任一变化发生时,Tomcat 会认为需要重新加载当前 Context:
- /WEB-INF/classes 目录下已加载的 class 文件被修改
- /WEB-INF/lib目录下的jar包有修改、新增和删除
public boolean modified() {
// 1. 遍历所有已加载的 class 文件,判断是否有被修改
for (Entry<String, ResourceEntry> entry : resourceEntries.entrySet()) {
long cachedLastModified = entry.getValue().lastModified;
long lastModified = resources.getClassLoaderResource(
entry.getKey()).getLastModified();
if (lastModified != cachedLastModified) {
// class 文件修改时间变化,说明 class 被更新,需要重载 Context
return true;
}
}
// 2. 遍历 /WEB-INF/lib 下的 jar 包,判断是否有变动
WebResource[] jars = resources.listResources("/WEB-INF/lib");
int jarCount = 0;
for (WebResource jar : jars) {
if (jar.getName().endsWith(".jar") && jar.isFile() && jar.canRead()) {
jarCount++;
Long recordedLastModified = jarModificationTimes.get(jar.getName());
if (recordedLastModified == null) {
// jar包没有记录上次修改时间,代表这个jar是新增的,也需要重载context
return true;
}
if (recordedLastModified.longValue() != jar.getLastModified()) {
// 时间变了,表示被修改过,也要重载context
return true;
}
}
}
// 3. 检查是否有 jar 包被删除(jar 数量变少)
if (jarCount < jarModificationTimes.size()) {
return true;
}
// 4. 无任何资源变化,则不需要重载Context
return false;
}
context重加载
Context 重加载的本质流程就是:先执行 stop()
,再重新 start()
。start流程可以看这里,stop则负责关闭和清理已有资源,避免内存泄露。我这里简要梳理 下stop()
过程中各核心组件的销毁逻辑:
- 子容器 Servlet (StandardWrapper#stop)
- 依次销毁所有已加载的 Servlet 实例,调用其
javax.servlet.Servlet#destroy()
方法,完成业务资源释放
- 依次销毁所有已加载的 Servlet 实例,调用其
- Filter组件销毁
- 调用所有已注册的
javax.servlet.Filter#destroy()
方法,清理过滤器资源。
- 调用所有已注册的
- Session 管理器 (StandardManager#stop)
- 触发 Session 持久化(等待context重启后再加载进来),同时清理过期的 Session 实例
- Listener组件销毁
- 执行所有注册的
javax.servlet.ServletContextListener#contextDestroyed()
方法,通知应用关闭,释放监听资源。
- 执行所有注册的
- 类加载器资源清理 (WebappLoader#stop)
- 销毁 WebappClassLoader,清理已加载的 class 缓存、jar 缓存、资源引用
- 停止并回收与类加载器相关的线程,断开对外部资源的强引用,避免内存泄漏
垃圾回收
前面的都是铺垫,现在来探讨一个最重要的问题(当最终理解了这个问题后,任何java热加载工具如何垃圾回收探讨都不是难题):
Tomcat 触发 Context 热加载后,系统中会产生哪些需要回收的垃圾?这些垃圾又该如何被回收?(如果回收不彻底,多次热加载最终可能引发 OOM 问题)
资源分类
首先我们可以将需要回收的资源粗分为如下两大类
堆区
堆区由GC自动回收,记住一点,这个区内的对象能被回收的唯一方法是对象不可达(软引用和弱引用这些具有特定回收限制的实例不再考虑范围内)
- Context 内部的对象实例:比如Servlet、Filter、Session、Spring容器里的bean等等,这部分是最多的
- 线程相关对象:Context 内部自行启动的 Thread和线程池
- Class 对象:由 WebappClassLoader 加载的所有 Class 实例
- WebappClassLoader 实例本身
非堆区
- 元空间 (Metaspace) 中的 klass 元数据:回收依赖JVM的class卸载机制
回收探讨
堆区垃圾回收的核心逻辑只有一点:对象是否可达(Reachable)。所以只要搞清楚对象的引用关系,就能推导出其回收条件。我们基于这个核心点,对上诉堆对内所有的对象进行追溯,看看到底它被哪些对象给持有
Context 内部的对象实例
这部分是最简单的,这些对象都是应用程序中显示创建并赋值的对象。在context#stop期间,触发了各种内部组件的stop后,再对字段赋值为null就是在清除引用。Spring 容器则会在 ApplicationContext#close()
里统一销毁 Bean 并清理所有容器内引用。
线程相关对象
Thread可作为GCRoot对象,所以对它的回收必须等Thread终结,否则任意一个存活的线程是不可能被回收的(其内部持有的contextClassLoader和其所有加载的class都不会被回收)
Class 对象
Class对象稍复杂,它不仅有显示的引用(应用程序中显示使用),还有隐式的使用
引用链路 | 描述 |
---|---|
A对象 → A.class | 所有基于 A.class 创建的对象,其对象头中都隐式持有指向 A.class 的指针。 |
类加载器 → A.class | 加载 A.class 的类加载器持有一份强引用。 |
其他显式引用 | 例如缓存、反射、Class.forName() 结果等都可能让其他对象显示持有 A.class 引用。 |
所以要回收Class对象,可以得出如下结论(以A.class为例):
- A.class创建的A对象都不可达,那么A对象头里的A.class指针也不可达
- 加载A.class的类加载器对象不可达
- 显示持有A.class的其他对象也不可达
WebappClassLoader 实例
引用链路 | 描述 |
---|---|
所有其加载的 Class → WebappClassLoader | 每个 Class 实例反向持有其加载器引用。 |
线程的 contextClassLoader → WebappClassLoader | 活跃线程若未清理 contextClassLoader 也持有加载器引用。 |
WebappClassLoader回收前提:
- 所有由其加载的 Class 都已不可达
- 没有任何活跃线程仍在使用该类加载器作为 contextClassLoader
- 显示持有此类加载器的其他对象不可达
总结
class相关对象引用链
所以垃圾能别回收的本质,是让持有A.class和WebappClassLoader对象的链路断掉,在这里我们只要做好如下清理条件,相关垃圾即可被回收
- context下创建的所有Thread需要终结
- context内部强引用链全部断开(置为null)
tomcat释放资源
在正式进入分析之前,先明确一点:子线程默认继承父线程的 contextClassLoader
(Thread的构造方法)
通过前面文章的分析,可知在Context内部创建的线程都会使用Context的WebappClassLoadLoader作为其contextClassLoader,所以,只要线程的 Thread.contextClassLoader
是当前 Context 对应的 WebappClassLoader
,就可以判断它属于当前 Context 内部创建的线程。
线程清理
核心方法在WebappClassLoaderBase#clearReferencesThreads,调用链为:
- WebappLoader#stop
- WebappClassLoaderBase#stop
- WebappClassLoaderBase#clearReferences
- WebappClassLoaderBase#clearReferencesThreads
逻辑总结:
- 获取当前JVM所有线程:通过遍历根 ThreadGroup进行所有线程的枚举
- 遍历并筛选出当前Context创建的线程:判断依据为Thread.contextClassLoader == Context.WebappClassLoader
- 根据是否是线程池创建的线程来分开处理
- 线程池创建的Thread:使用线程池的shutdownNow来优雅的关闭线程
- 非线程池线程:先发送中断信号,若无法中断则再直接调用Thread.stop方法来强制终结线程
private void clearReferencesThreads() {
// 获取系统内所有活动线程
Thread[] threads = getThreads();
List<Thread> threadsToStop = new ArrayList<>();
for (Thread thread : threads) {
if (thread != null) {
ClassLoader ccl = thread.getContextClassLoader();
// 只处理 contextClassLoader 是当前 WebappClassLoader 的线程
if (ccl == this) {
// 跳过当前线程自身,防止自杀
if (thread == Thread.currentThread()) {
continue;
}
final String threadName = thread.getName();
ThreadGroup tg = thread.getThreadGroup();
if (tg != null && JVM_THREAD_GROUP_NAMES.contains(tg.getName())) {
if (clearReferencesHttpClientKeepAliveThread &&
threadName.equals("Keep-Alive-Timer")) {
// Keep-Alive-Timer 特殊处理,仅替换其 contextClassLoader,避免泄露
thread.setContextClassLoader(parent);
}
// Don't warn about remaining JVM controlled threads
continue;
}
// // 已经死亡的线程,则不需要再清理了
if (!thread.isAlive()) {
continue;
}
// TimerThread 可以被安全关闭
if (thread.getClass().getName().startsWith("java.util.Timer") &&
clearReferencesStopTimerThreads) {
clearReferencesStopTimerThread(thread);
continue;
}
// 仅当配置允许强制停止线程时才处理(clearReferencesStopThreads默认为false)
if (!clearReferencesStopThreads) {
continue;
}
// 尝试检测是否为 ThreadPoolExecutor 的 worker 线程,优先关闭线程池本身
boolean usingExecutor = false;
try {
// 兼容不同 JDK 实现,寻找包装的 runnable 字段
Object target = null;
for (String fieldName : new String[] { "target", "runnable", "action" }) {
try {
Field targetField = thread.getClass().getDeclaredField(fieldName);
targetField.setAccessible(true);
target = targetField.get(thread);
break;
} catch (NoSuchFieldException nfe) {
continue;
}
}
// 判断是否为 ThreadPoolExecutor.Worker,如果是则使用线程池的shutdownNow方法来优雅关闭线程
if (target != null && target.getClass().getCanonicalName() != null &&
target.getClass().getCanonicalName().equals(
"java.util.concurrent.ThreadPoolExecutor.Worker")) {
Field executorField = target.getClass().getDeclaredField("this$0");
executorField.setAccessible(true);
Object executor = executorField.get(target);
if (executor instanceof ThreadPoolExecutor) {
((ThreadPoolExecutor) executor).shutdownNow();
usingExecutor = true;
}
}
} catch (NoSuchFieldException | IllegalAccessException | RuntimeException e) {
log.warn(sm.getString("webappClassLoader.stopThreadFail",
thread.getName(), getContextName()), e);
}
// 非线程池线程,直接尝试 interrupt 终止
if (!usingExecutor && !thread.isInterrupted()) {
thread.interrupt();
}
// 记录仍需等待确认终止的线程
threadsToStop.add(thread);
}
}
}
int count = 0;
// 对需要stop的线程都直接终结
for (Thread t : threadsToStop) {
while (t.isAlive() && count < 100) {
try {
Thread.sleep(20);
} catch (InterruptedException e) {
// Quit the while loop
break;
}
count++;
}
// 存活才终结
if (t.isAlive()) {
t.stop();
}
}
}
private Thread[] getThreads() {
// 获取当前线程所在的线程组
ThreadGroup tg = Thread.currentThread().getThreadGroup();
// 向上遍历,找到根线程组(整个 JVM 的顶级线程组)
try {
while (tg.getParent() != null) {
tg = tg.getParent();
}
} catch (SecurityException se) {
}
int threadCountGuess = tg.activeCount() + 50;
Thread[] threads = new Thread[threadCountGuess];
// 枚举所有的线程
int threadCountActual = tg.enumerate(threads);
// enumerate 方法在数组空间不足时会默默丢弃多余的线程,
// 因此当实际数量等于猜测数量时,说明数组可能装不下,需要扩容重试
while (threadCountActual == threadCountGuess) {
threadCountGuess *= 2;
threads = new Thread[threadCountGuess];
// 继续枚举
threadCountActual = tg.enumerate(threads);
}
return threads;
}
总结
通过上述对 Context 内线程资源的兜底清理(这一部分其实是热加载内存泄漏中最核心、最容易被忽略的风险点 —— 因为实际项目中,大量使用了自定义线程或线程池,且它们往往没能纳入 Spring.ApplicationContext 的生命周期管理),再加上在 Context#stop
阶段,对各个强引用对象统一置 null
,主动断开引用链,基本可以保证 Context 在热加载时不会产生内存泄漏问题。
最后需要特别强调一点:
项目中使用线程池时,强烈建议将其纳入 Context 生命周期统一管理(例如通过 Spring 的 ThreadPoolExecutorFactoryBean
来创建线程池)。
因为 Tomcat 在兜底清理时,如果无法识别线程归属,最终可能会直接调用 Thread.stop()
强行终止线程 —— 这种方式极其不安全,容易导致数据不一致等问题。让线程池受控于 Spring 容器,借助 shutdown()
或 shutdownNow()
做平滑关闭,是最稳妥、安全的方案。