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() 过程中各核心组件的销毁逻辑:

  1. 子容器 Servlet (StandardWrapper#stop)
    • 依次销毁所有已加载的 Servlet 实例,调用其 javax.servlet.Servlet#destroy() 方法,完成业务资源释放
  2. Filter组件销毁
    • 调用所有已注册的 javax.servlet.Filter#destroy() 方法,清理过滤器资源。
  3. Session 管理器 (StandardManager#stop)
    • 触发 Session 持久化(等待context重启后再加载进来),同时清理过期的 Session 实例
  4. Listener组件销毁
    • 执行所有注册的 javax.servlet.ServletContextListener#contextDestroyed() 方法,通知应用关闭,释放监听资源。
  5. 类加载器资源清理 (WebappLoader#stop)
    • 销毁 WebappClassLoader,清理已加载的 class 缓存、jar 缓存、资源引用
    • 停止并回收与类加载器相关的线程,断开对外部资源的强引用,避免内存泄漏

垃圾回收

​ 前面的都是铺垫,现在来探讨一个最重要的问题(当最终理解了这个问题后,任何java热加载工具如何垃圾回收探讨都不是难题):

Tomcat 触发 Context 热加载后,系统中会产生哪些需要回收的垃圾?这些垃圾又该如何被回收?(如果回收不彻底,多次热加载最终可能引发 OOM 问题)

资源分类

首先我们可以将需要回收的资源粗分为如下两大类

堆区

堆区由GC自动回收,记住一点,这个区内的对象能被回收的唯一方法是对象不可达(软引用和弱引用这些具有特定回收限制的实例不再考虑范围内)

  1. Context 内部的对象实例:比如Servlet、Filter、Session、Spring容器里的bean等等,这部分是最多的
  2. 线程相关对象:Context 内部自行启动的 Thread和线程池
  3. Class 对象:由 WebappClassLoader 加载的所有 Class 实例
  4. WebappClassLoader 实例本身

非堆区

  1. 元空间 (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为例):

  1. A.class创建的A对象都不可达,那么A对象头里的A.class指针也不可达
  2. 加载A.class的类加载器对象不可达
  3. 显示持有A.class的其他对象也不可达

WebappClassLoader 实例

引用链路 描述
所有其加载的 Class → WebappClassLoader 每个 Class 实例反向持有其加载器引用。
线程的 contextClassLoader → WebappClassLoader 活跃线程若未清理 contextClassLoader 也持有加载器引用。

WebappClassLoader回收前提:

  1. 所有由其加载的 Class 都已不可达
  2. 没有任何活跃线程仍在使用该类加载器作为 contextClassLoader
  3. 显示持有此类加载器的其他对象不可达

总结

class-reference.png

class相关对象引用链

所以垃圾能别回收的本质,是让持有A.class和WebappClassLoader对象的链路断掉,在这里我们只要做好如下清理条件,相关垃圾即可被回收

  1. context下创建的所有Thread需要终结
  2. context内部强引用链全部断开(置为null)

tomcat释放资源

​ 在正式进入分析之前,先明确一点:子线程默认继承父线程的 contextClassLoader(Thread的构造方法)

​ 通过前面文章的分析,可知在Context内部创建的线程都会使用Context的WebappClassLoadLoader作为其contextClassLoader,所以,只要线程的 Thread.contextClassLoader 是当前 Context 对应的 WebappClassLoader,就可以判断它属于当前 Context 内部创建的线程。

线程清理

核心方法在WebappClassLoaderBase#clearReferencesThreads,调用链为:

  1. WebappLoader#stop
  2. WebappClassLoaderBase#stop
  3. WebappClassLoaderBase#clearReferences
  4. WebappClassLoaderBase#clearReferencesThreads

逻辑总结:

  1. 获取当前JVM所有线程:通过遍历根 ThreadGroup进行所有线程的枚举
  2. 遍历并筛选出当前Context创建的线程:判断依据为Thread.contextClassLoader == Context.WebappClassLoader
  3. 根据是否是线程池创建的线程来分开处理
    1. 线程池创建的Thread:使用线程池的shutdownNow来优雅的关闭线程
    2. 非线程池线程:先发送中断信号,若无法中断则再直接调用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() 做平滑关闭,是最稳妥、安全的方案。