Java 基础体系 · 第 77/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

JVM ClassLoader 泄漏:线程、缓存、Driver、热部署和诊断

ClassLoader 泄漏通常不是“类加载器没有调用 close”这么简单,而是一个对象可达性问题:旧应用的类加载器仍然被某条强引用路径连接到 GC Root,因此它定义的类、静态字段以及相关元数据无法被回收。热部署反复发生后,表现为 Metaspace 持续增长、旧应用对象残留、线程数量增加,最终可能触发 OutOfMemoryError: Metaspace

本文以 Java 25 LTS 的 JVM 语义和常见 HotSpot 行为为范围,重点分析以下完整链路:

  • ClassLoader 如何参与类身份和类卸载;
  • 线程、线程上下文类加载器(TCCL)和 ThreadLocal 如何保留旧应用;
  • 父加载器、共享组件和静态缓存如何形成反向引用;
  • JDBC DriverManager 为什么是经典泄漏来源;
  • 热部署时资源关闭、并发停止和清理顺序为何决定结果;
  • 如何使用堆转储、类加载统计、JFR 和 JMC 定位实际引用链;
  • 哪些现象是类加载器泄漏,哪些只是正常的类加载或 Metaspace 波动。

1. 先建立模型:类、类加载器和类卸载

1.1 类的身份不只有二进制名称

在 JVM 中,一个类的身份可以抽象为:

ClassIdentity=(binary name,defining loader)\text{ClassIdentity} = (\text{binary name}, \text{defining loader})

其中:

  • binary name 是类的二进制名称,例如 com.example.Plugin
  • defining loader 是最终定义该类的类加载器,而不只是发起加载请求的加载器。

因此,下面两个类通常不是同一个类:

com.example.Plugin 由 AppClassLoader 定义
com.example.Plugin 由 ChildClassLoader-1 定义

即使它们的字节码完全相同,也不能互相赋值:

ClassCastException:
class com.example.Plugin cannot be cast to class com.example.Plugin

这也是热部署能够使用多个版本的基础:每个部署版本可以由不同的子类加载器定义。

1.2 加载、链接和初始化不是同一个阶段

JVM 规范将类或接口处理区分为多个阶段:

  1. Loading:根据二进制名称获得类的二进制表示,并创建 Class 对象;
  2. Linking
    • Verification:验证字节码;
    • Preparation:为静态字段分配并初始化默认值;
    • Resolution:解析符号引用,可延迟进行;
  3. Initialization:执行类初始化方法 <clinit>,例如静态字段初始化和静态代码块。

ClassLoader#defineClass 通常对应“定义”动作,但类的初始化可能在首次主动使用时才发生。比如:

Class<?> type = loader.loadClass("com.example.Plugin");

这通常只保证类被加载和链接,不一定立即执行其静态初始化代码。

而下面的代码可能触发初始化:

Class.forName("com.example.Plugin", true, loader);

如果热部署应用在静态初始化阶段注册线程、Driver、MBean 或缓存,则泄漏可能在“加载”之后很早就已经产生。

1.3 类卸载的实际条件

Java 语言和 JVM 规范主要规定类加载和运行语义,并不要求每次 System.gc() 都卸载类。类卸载是具体 JVM 的垃圾回收实现能力。

以常见 HotSpot 行为为例,一个由 L 定义的类加载器要具备被回收的可能性,至少需要满足:

¬Reachable(L,R)\neg \operatorname{Reachable}(L, R)

其中:

  • L 是目标类加载器;
  • R 是所有 GC Roots;
  • Reachable(L, R) 表示存在一条从某个 GC Root 到 L 的强可达路径。

此外,由 L 定义的类、这些类对应的 Class 对象以及相关运行时元数据也不能再被活跃对象或其他可保留结构使用。常见实现通常以“整个类加载器不可达”为类卸载的重要前提,而不是逐个卸载同一加载器定义的类。

典型 GC Root 包括:

  • 活跃 Java 线程;
  • 静态字段;
  • JNI 全局引用;
  • JVM 内部持有的系统对象;
  • 活跃监视器;
  • 某些运行时服务保留的对象。

因此,下面这条路径足以阻止卸载:

GC Root
  -> Live Thread
      -> contextClassLoader
          -> WebAppClassLoader

或者:

GC Root
  -> SystemClassLoader 的静态缓存
      -> 某个旧应用对象
          -> 旧应用 Class
              -> defining ClassLoader

这解释了为什么泄漏的关键不是“是否曾经加载过类”,而是“部署结束后是否还存在从 GC Root 到旧类加载器的强引用”。

2. ClassLoader 的委派关系与泄漏方向

2.1 父加载器委派

常见类加载器结构可以简化为:

Bootstrap ClassLoader
        |
Platform ClassLoader
        |
System / Application ClassLoader
        |
WebAppClassLoader-1
WebAppClassLoader-2

按照常见的双亲委派模型,子加载器收到加载请求后通常先询问父加载器,父加载器无法加载时,子加载器才尝试自己定义。

这个关系带来一个重要事实:

父加载器可以访问由自己或更高层定义的类,但父加载器中的共享对象如果反向持有子加载器定义的对象,就可能把子应用保留下来。

父加载器天然长寿,通常至少和 JVM 进程同寿命;子加载器代表一次应用部署。因此,下面的引用方向危险:

父加载器中的静态缓存
    -> 旧应用对象
        -> 旧应用 Class
            -> 旧应用 ClassLoader

反过来,子应用自己的静态字段引用父加载器对象通常不是泄漏父加载器,因为父加载器本来就不会随着应用卸载。

2.2 缓存泄漏的基本形式

假设共享库由父加载器加载:

public final class GlobalCache {
    private static final Map<String, Object> CACHE = new ConcurrentHashMap<>();

    public static void put(String key, Object value) {
        CACHE.put(key, value);
    }
}

旧应用由子加载器加载,并执行:

GlobalCache.put("plugin", new Plugin());

引用关系变成:

GlobalCache.class
  -> static CACHE
      -> Plugin 实例
          -> Plugin.class
              -> WebAppClassLoader

GlobalCache.class 由父加载器定义,通常一直存活。于是旧的 WebAppClassLoader 也一直可达。

下面这些缓存都可能形成同类问题:

  • 静态 MapListSet
  • 单例注册表;
  • 事件总线的监听器列表;
  • 反射元数据缓存;
  • 序列化、代理、表达式引擎的类型缓存;
  • 日志框架中的上下文、Appender 或自定义对象;
  • JMX、指标系统和监控组件的注册表;
  • 定时任务、回调和异步队列。

缓存的危险点不在于“用了缓存”,而在于缓存的生命周期长于被缓存对象所属的类加载器生命周期

2.3 弱引用不能自动解决所有问题

把缓存改成 WeakReference 可能有帮助,但不能作为无条件修复。

例如:

Map<String, WeakReference<Object>> cache;

这只意味着缓存对 value 不再形成强引用。若还有另一条强引用路径,例如线程 TCCL、ThreadLocal、Driver 注册表或任务队列,类加载器仍然不会回收。

此外,弱引用本身还可能引入:

  • value 被过早回收;
  • 清理队列处理不及时;
  • 并发下缓存命中逻辑复杂;
  • key 仍然是旧应用对象,依旧保留类加载器。

应先找出完整引用链,再决定使用显式清理、弱引用、按部署隔离缓存还是改变组件生命周期。

3. 线程:最容易被忽略的 GC Root

3.1 活跃线程本身是强保留者

一个尚未结束的 Java 线程通常由 JVM 或线程组等运行时结构保持可达。线程对象又可能持有:

  • contextClassLoader
  • ThreadLocalMap
  • InheritableThreadLocal 继承值;
  • 当前执行的任务;
  • 栈帧中的局部变量和参数;
  • 未完成的阻塞操作关联对象。

因此,应用创建线程后,如果热部署只卸载类、清空业务集合,却没有停止线程,线程就可能继续保留旧应用。

最直接的路径是:

Live Thread
  -> Thread.contextClassLoader
      -> WebAppClassLoader

3.2 TCCL 的作用

线程上下文类加载器是:

Thread.currentThread().getContextClassLoader()

它不是线程“定义类的加载器”,而是供当前代码在运行时选择类加载器使用的上下文。很多 API 会读取它,例如:

  • ServiceLoader
  • JDBC、日志、JNDI 等 SPI 机制;
  • 容器和框架的插件发现逻辑;
  • 资源文件和配置加载。

应用线程经常把 TCCL 设置为自己的 WebAppClassLoader:

Thread.currentThread().setContextClassLoader(appLoader);

如果线程是容器线程且会长期存活,部署结束后必须恢复原来的 TCCL;如果线程属于应用,则应该让线程结束。

安全的临时切换方式是:

ClassLoader original = Thread.currentThread().getContextClassLoader();
try {
    Thread.currentThread().setContextClassLoader(appLoader);
    ServiceLoader<MyPlugin> loader =
            ServiceLoader.load(MyPlugin.class);
    // 使用 SPI
} finally {
    Thread.currentThread().setContextClassLoader(original);
}

这里的 finally 很重要。异常、超时或提前返回都不能跳过恢复操作。

3.3 自建线程的完整生命周期

应用不应只写:

new Thread(this::runLoop).start();

然后在卸载时假定 JVM 会替它终止。至少需要保存线程引用,并设计停止协议:

public final class Worker implements AutoCloseable {
    private final Thread thread;
    private volatile boolean running = true;

    public Worker() {
        ClassLoader loader = getClass().getClassLoader();

        thread = new Thread(() -> {
            ClassLoader original =
                    Thread.currentThread().getContextClassLoader();
            try {
                Thread.currentThread().setContextClassLoader(loader);

                while (running && !Thread.currentThread().isInterrupted()) {
                    // 执行工作
                    Thread.sleep(100);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                Thread.currentThread().setContextClassLoader(original);
            }
        }, "plugin-worker");

        thread.start();
    }

    @Override
    public void close() throws InterruptedException {
        running = false;
        thread.interrupt();
        thread.join(5_000);

        if (thread.isAlive()) {
            throw new IllegalStateException(
                    "worker did not stop within timeout");
        }
    }
}

这个示例中的因果关系是:

  1. 线程启动时可能把 TCCL 设置为应用类加载器;
  2. close() 通过共享状态和中断请求停止循环;
  3. join() 等待线程真正退出,而不是仅仅发出停止请求;
  4. finally 恢复 TCCL;
  5. 若线程仍存活,应将其视为卸载失败,而不是继续创建新部署。

Thread.stop() 不适合作为常规清理方案,因为它可能在任意执行点抛出异步异常,破坏锁、不变量和资源状态。Thread.interrupt() 也不是强制终止:目标代码可能忽略中断,或者在不可中断的本地调用中继续运行。

3.4 线程池与任务队列

线程池比裸线程更容易泄漏,因为线程池的线程通常具有长生命周期:

容器线程池
  -> Worker Thread
      -> ThreadLocalMap
      -> TCCL
      -> 当前任务 / 队列任务

如果提交到共享线程池的 Runnable 是旧应用类的实例,那么即使任务尚未执行,队列也可能保留它:

ThreadPoolExecutor
  -> workQueue
      -> FutureTask
          -> Callable
              -> PluginObject
                  -> PluginClass
                      -> OldClassLoader

卸载前应当:

  1. 停止接受新任务;
  2. 取消或排空旧任务;
  3. 等待正在执行的任务结束;
  4. 清理任务中使用的上下文;
  5. 恢复提交线程或工作线程的 TCCL。

仅调用 shutdown() 不代表任务立即消失。shutdownNow() 也只尝试中断,并返回尚未开始的任务;它不保证任务已经终止。

3.5 ThreadLocal 的弱 key 陷阱

ThreadLocalMap 的 key 通常是对 ThreadLocal 对象的弱引用,但 value 是强引用。概念上可以表示为:

Thread
  -> ThreadLocalMap
      -> Entry
          -> weak key: ThreadLocal
          -> strong value: AppContext

如果 ThreadLocal 类本身由旧应用加载器定义,部署后 key 可能被回收;但 value 仍然存在:

ThreadLocal key 被清除
ThreadLocal value 仍被线程强引用
    -> AppContext
        -> OldClassLoader

这称为 stale entry。它通常会在线程后续访问 ThreadLocal 时由内部清理逻辑逐步清除,但没有“访问”就不保证立即清除,长寿命线程因此可能长期保留 value。

正确做法是:

private static final ThreadLocal<RequestContext> CONTEXT =
        new ThreadLocal<>();

void handle() {
    try {
        CONTEXT.set(new RequestContext());
        // 处理请求
    } finally {
        CONTEXT.remove();
    }
}

remove() 必须放在 finally 中,因为业务异常同样不能跳过清理。

还要注意 InheritableThreadLocal:创建子线程时,值可能被复制到子线程。如果子线程随后成为线程池中的长寿命线程,旧应用对象就可能被间接保留。线程池环境中通常不应随意使用它传递请求上下文。

4. JDBC Driver:注册表为何能保留旧应用

4.1 JDBC 4 自动发现改变了加载方式,但没有消除生命周期问题

JDBC 驱动通常通过以下机制被发现:

应用或框架
  -> DriverManager
      -> ServiceLoader / META-INF/services/java.sql.Driver
          -> Driver 实现类

JDBC 4 之后,驱动可以通过服务提供者配置自动加载,不再要求应用显式调用:

Class.forName("com.example.Driver");

但“自动发现”只解决发现和初始化问题,不代表驱动会在应用卸载时自动注销。

DriverManager 维护已注册驱动的全局管理结构。若旧应用加载的 Driver 实例仍在其中,则可能形成:

系统级 DriverManager
  -> Registered Driver
      -> Driver 实例
          -> Driver.class
              -> OldClassLoader

不同容器和 JDK 实现对 Driver 可见性的处理有细节差异,DriverManager 也会根据调用者类加载器限制驱动访问,但不能把这些访问限制当作生命周期清理。只要旧驱动注册表项或其他驱动内部对象仍然存活,就可能阻止旧类加载器卸载。

4.2 正确注销 Driver

应用停止时应注销由本应用加载的 Driver:

public static void deregisterDrivers(ClassLoader appLoader) {
    for (Enumeration<Driver> e = DriverManager.getDrivers();
         e.hasMoreElements();) {

        Driver driver = e.nextElement();
        ClassLoader driverLoader =
                driver.getClass().getClassLoader();

        if (driverLoader == appLoader) {
            try {
                DriverManager.deregisterDriver(driver);
            } catch (SQLException ex) {
                throw new IllegalStateException(
                        "Failed to deregister JDBC driver: " + driver, ex);
            }
        }
    }
}

这个代码有三个关键点:

  1. 只注销由当前应用类加载器定义的 Driver;
  2. 不要无条件注销整个 JVM 中所有驱动;
  3. 注销失败必须暴露出来,而不是静默忽略。

实际容器中,调用者类加载器检查可能导致“看得见但不能注销”。例如,容器代码使用父加载器执行注销,而驱动类由子加载器定义,DriverManager.deregisterDriver 可能因为调用者无权操作该 Driver 而抛出 SQLException。因此,驱动清理最好由应用自己的停止钩子执行,或者由容器提供明确支持的清理机制。

4.3 只注销 Driver 仍然可能泄漏

Driver 可能创建其他长寿命对象:

  • 连接池;
  • 定时线程;
  • Timer;
  • MBean;
  • 本地资源;
  • 线程上下文中的连接或事务对象;
  • 驱动自己的全局缓存。

所以 JDBC 清理至少应覆盖:

连接池关闭
  -> 连接关闭
  -> 驱动注销
  -> 驱动线程停止
  -> MBean / 监控对象注销
  -> ThreadLocal 清理

只执行:

DriverManager.deregisterDriver(driver);

并不能证明整个应用已经可卸载。

5. 热部署:从“替换类加载器”到完整状态转换

5.1 热部署的基本结构

一个典型的热部署系统可能为每个版本创建一个新的子加载器:

ParentLoader
   |
   +-- AppLoader-v1 -> old application classes
   |
   +-- AppLoader-v2 -> new application classes

部署 v2 时,系统不能直接把 v1 的类文件“替换掉”,因为已经定义的类身份不会因为文件变化而改变。正确的逻辑是:

  1. 创建 AppLoader-v2
  2. 用 v2 加载入口类;
  3. 启动 v2;
  4. 停止 v1;
  5. 断开 v1 的所有外部引用;
  6. 等待 GC 和类卸载。

如果第 4 或第 5 步不完整,v1 和 v2 会同时驻留。

5.2 热部署状态机

可以把一次部署抽象成以下状态:

stateDiagram-v2
    [*] --> Loaded
    Loaded --> Started: 创建线程/注册资源
    Started --> Draining: 停止接收新请求
    Draining --> Stopped: 任务和线程结束
    Stopped --> Detached: 注销 Driver/MBean/监听器并清理缓存
    Detached --> Collectible: 无外部强引用
    Collectible --> Unloaded: JVM 完成类卸载
    Draining --> Failed: 超时或线程拒绝停止
    Stopped --> Failed: 清理动作失败
    Failed --> [*]: 隔离实例并告警

这里“Collectible”和“Unloaded”必须区分:

  • Collectible:从应用角度看,已经没有预期的强引用;
  • Unloaded:JVM 的垃圾回收实现实际完成了类卸载。

调用 System.gc() 不能把第一个状态强行变成第二个状态。GC 请求可能被忽略、延迟,类卸载也可能等待特定的回收周期。

5.3 推荐的停止顺序及其原因

一个较可靠的顺序是:

第一步:停止入口

停止新请求、新消息和新任务进入旧应用。

否则,清理进行时仍可能有新对象被放入缓存、线程池或 ThreadLocal。

第二步:排空正在执行的工作

等待请求、异步任务和事务结束;对超时任务执行取消和中断。

如果先清理静态对象,而任务仍在运行,任务可能重新访问已经关闭的资源,或者继续把旧对象发布到共享组件中。

第三步:停止应用线程和调度器

关闭:

  • ExecutorService
  • ScheduledExecutorService
  • Timer
  • 自建线程;
  • 消费者和监听器线程。

必须验证线程已经结束,而不能只发出停止信号。

第四步:关闭外部资源

关闭连接池、文件、Socket、客户端、消息消费者和本地资源。资源关闭往往会触发回调,因此应在应用线程仍可控时完成。

第五步:注销全局注册

清理:

  • JDBC Driver;
  • JMX MBean;
  • 事件监听器;
  • SPI 注册;
  • 监控回调;
  • 全局缓存;
  • 框架注册表。

第六步:恢复线程上下文

清理或恢复相关线程的 TCCL,并确保 ThreadLocal 在每个请求边界被 remove()

第七步:解除部署管理器引用

部署管理器自身不能保留:

  • 旧入口对象;
  • 旧类的 Class 对象;
  • 旧类加载器;
  • 旧版本配置对象中包含的应用实例。

只有当所有外部引用断开,旧类加载器才进入可回收状态。

5.4 并发竞态:清理与重新启动同时发生

热部署清理不是单线程问题。例如:

停止线程 A              新请求线程 B
    |                       |
注销监听器                 提交旧应用任务
    |                       |
清空缓存                   任务进入共享队列
    |                       |
认为已完成                 旧对象重新可达

因此,部署管理器需要一个明确的并发协议,例如:

  • 先将实例标记为 DRAINING
  • 入口检查状态,拒绝新请求;
  • 使用计数器或闩锁等待活动请求归零;
  • 冻结任务提交;
  • 再执行线程和资源清理;
  • 清理后才允许新版本接管。

如果没有“禁止新引用产生”的阶段,单纯增加清理代码只能降低概率,不能证明没有泄漏。

6. 一个最小可复现的 TCCL 泄漏示例

下面的示例不依赖 Web 容器,演示一个旧类加载器被活跃线程保留的情况。

6.1 子加载器加载的类

目录结构:

plugin-classes/
└── demo/
    └── PluginEntry.java

PluginEntry.java

package demo;

public final class PluginEntry {
    public static Runnable createTask() {
        return () -> {
            try {
                Thread.sleep(Long.MAX_VALUE);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        };
    }
}

编译:

javac -d plugin-classes plugin-classes/demo/PluginEntry.java

6.2 泄漏版本

import java.net.URL;
import java.net.URLClassLoader;

public class LeakingLoaderDemo {
    public static void main(String[] args) throws Exception {
        URL pluginDir = new java.io.File("plugin-classes")
                .toURI()
                .toURL();

        URLClassLoader appLoader =
                new URLClassLoader(new URL[]{pluginDir},
                                   ClassLoader.getSystemClassLoader());

        Class<?> entry = Class.forName(
                "demo.PluginEntry", true, appLoader);

        Runnable task = (Runnable) entry
                .getMethod("createTask")
                .invoke(null);

        Thread worker = new Thread(task, "old-plugin-worker");

        // 关键:长寿命线程持有旧应用 ClassLoader
        worker.setContextClassLoader(appLoader);
        worker.start();

        // 即使 main 不再使用 appLoader,worker 仍然存活
        appLoader = null;
        entry = null;
        task = null;

        Thread.sleep(60_000);
    }
}

这里的引用链大致是:

JVM / Thread 管理结构
  -> old-plugin-worker
      -> contextClassLoader
          -> URLClassLoader

main 中的局部变量被清空没有意义,因为活跃线程仍然持有类加载器。

6.3 修复版本

import java.net.URL;
import java.net.URLClassLoader;

public class CleanLoaderDemo {
    public static void main(String[] args) throws Exception {
        URL pluginDir = new java.io.File("plugin-classes")
                .toURI()
                .toURL();

        URLClassLoader appLoader =
                new URLClassLoader(new URL[]{pluginDir},
                                   ClassLoader.getSystemClassLoader());

        Class<?> entry = Class.forName(
                "demo.PluginEntry", true, appLoader);

        Runnable task = (Runnable) entry
                .getMethod("createTask")
                .invoke(null);

        Thread worker = new Thread(task, "old-plugin-worker");
        ClassLoader original =
                worker.getContextClassLoader();

        worker.setContextClassLoader(appLoader);
        worker.start();

        worker.interrupt();
        worker.join(5_000);

        if (worker.isAlive()) {
            throw new IllegalStateException("worker did not stop");
        }

        worker.setContextClassLoader(original);
        appLoader.close();
    }
}

URLClassLoader.close() 主要关闭它打开的资源,例如 JAR 文件句柄;它不会停止线程,也不会强制卸载类。这个区别非常重要:

close ClassLoader
≠
停止线程
≠
清除缓存
≠
完成类卸载

7. 诊断:先证明“加载了多个版本”,再找保留路径

诊断不能只看 Metaspace 使用量。应回答三个问题:

  1. 旧版本的类是否被重复加载?
  2. 旧类加载器是否仍然存活?
  3. 谁通过强引用保留了它?

7.1 查看类加载和卸载趋势

可以使用 jcmd 获取 HotSpot 进程信息:

jcmd <pid> VM.classloaders

该命令用于查看类加载器层次及相关信息,但它属于 HotSpot 诊断能力,不是 Java SE 跨 JVM 实现的统一保证。

还可以查看类加载统计:

jcmd <pid> VM.classloader_stats

具体命令是否可用、输出字段如何组织,取决于 JDK 发行版和 HotSpot 诊断命令版本。生产环境应先执行:

jcmd <pid> help

确认目标 JVM 支持的命令。

关注的不是某个绝对数字,而是同一应用类在反复部署后是否出现:

demo.PluginEntry
demo.PluginEntry
demo.PluginEntry

并且这些类分别由不同的类加载器定义。

7.2 类直方图能说明什么

jcmd <pid> GC.class_histogram

这能查看堆中对象按类聚合的数量和占用。常用验证方式是:

  1. 在部署前记录一次;
  2. 部署并停止旧版本;
  3. 执行一次符合生产风险要求的 GC 观察;
  4. 再部署若干次;
  5. 比较旧版本类实例和 ClassLoader 相关对象数量。

但类直方图有边界:

  • 看到旧应用类实例增加,只能说明对象还在;
  • 看不到完整的引用路径;
  • 类卸载后的元数据不一定直接以普通堆对象形式呈现;
  • System.gc() 不保证执行完整回收和类卸载。

7.3 堆转储:寻找从 GC Root 到旧 ClassLoader 的路径

生成 live dump 的示例:

jcmd <pid> GC.heap_dump /tmp/app-live.hprof

GC.heap_dump 可能触发较重的停顿,并产生较大的磁盘文件。应确认磁盘空间、执行窗口和对延迟的影响。

在 Eclipse MAT、JDK Mission Control 配套工具或其他堆分析器中,重点查找:

  • 旧的 URLClassLoader 或容器类加载器;
  • 旧应用入口类;
  • Thread
  • ThreadLocalMap
  • ThreadPoolExecutor 队列;
  • DriverManager 相关对象;
  • MBeanServer 注册对象;
  • 全局缓存和监听器列表。

最有价值的结果不是“有 100 个类加载器”,而是类似这样的保留路径:

GC Root: Java Local / Thread
  -> Thread "scheduler-1"
  -> contextClassLoader
  -> WebAppClassLoader @ 0x123

或:

GC Root: static field
  -> GlobalCache.CACHE
  -> OldService
  -> OldService.class
  -> WebAppClassLoader @ 0x456

MAT 的“Path to GC Roots”和支配树(Dominator Tree)分别回答:

  • 哪条路径使对象保持可达;
  • 哪个对象支配了大量 retained heap。

类加载器本身可能很小,但它支配的类、静态字段和对象总量可能很大。

7.4 查看线程和 TCCL

先导出线程信息:

jcmd <pid> Thread.print -l > /tmp/threads.txt

这可以发现:

  • 应用线程是否仍在运行;
  • 线程名是否每次部署都新增;
  • 是否有线程卡在 sleep、队列、Socket、锁或本地调用;
  • 是否存在未关闭的调度器或连接池。

标准线程转储通常不会直接打印每个线程的 TCCL。需要在代码、容器诊断接口或堆转储中检查:

Thread.getAllStackTraces().keySet().forEach(thread -> {
    System.out.printf("%s, state=%s, tccl=%s%n",
            thread.getName(),
            thread.getState(),
            thread.getContextClassLoader());
});

这段代码应谨慎使用:遍历线程会产生诊断开销,而且应用代码未必有权限或能力访问所有容器内部状态。它适合受控诊断,不应作为高频监控逻辑。

7.5 使用 JFR 和 JMC 看时间维度

JFR 适合回答“什么时候加载了多少类、哪个阶段出现线程或资源增长”。可以启动一段短期录制:

jcmd <pid> JFR.start \
  name=classloader-investigation \
  settings=profile \
  duration=10m \
  filename=/tmp/classloader-investigation.jfr

随后使用 JDK Mission Control 打开 .jfr 文件,按时间查看类加载、线程、锁、执行和系统事件。

JFR/JMC 的价值在于关联时间线:

部署开始
  -> 新类加载增加
  -> 新线程创建
  -> Driver 或资源初始化
  -> 旧版本停止
  -> 旧线程是否结束
  -> 类卸载事件是否出现

如果每次部署都有类加载,但停止阶段没有对应的类卸载,说明需要继续寻找保留者。不过,JFR 事件和可见字段会随 JDK、配置和实现变化;不能仅凭一次录制推断某类必然泄漏,也不能把“没有看到类卸载事件”直接等同于“必然存在引用链”。

JDK Mission Control 的作用主要是分析记录,而不是替代堆转储。JFR 更适合时序和运行行为,堆转储更适合精确的强引用路径。

8. 常见误解与反例

8.1 误解:调用 URLClassLoader.close() 就完成卸载

反例:

URLClassLoader loader = ...;
loader.close();

这不会:

  • 停止由应用创建的线程;
  • 清除线程池队列;
  • 注销 JDBC Driver;
  • 清除 ThreadLocal;
  • 从父加载器缓存删除对象;
  • 强制 GC;
  • 强制类卸载。

它解决的是类加载器拥有的可关闭资源问题,而不是对象可达性问题。

8.2 误解:只要没有业务对象引用,类加载器就一定能回收

旧类加载器可能通过 Class 对象、反射缓存、代理、线程、JMX 或 Driver 注册表存活。业务入口对象不再被引用,只能排除其中一条路径。

8.3 误解:ThreadLocal key 是弱引用,所以不会泄漏

真正危险的是强 value:

弱 key 被回收
  -> stale entry 仍有强 value
      -> 旧应用对象

在长寿命线程上,必须显式 remove()

8.4 误解:System.gc() 可以验证修复

System.gc() 只是向 JVM 发出建议,不能作为规范级强制命令。即使一次 GC 后对象数量下降,也不代表所有部署路径都已清理;如果存在并发任务、缓存再发布或延迟处理,后续仍可能重新保留旧加载器。

更可靠的验证是:

  1. 执行多轮部署;
  2. 每轮记录类加载器数量和线程数量;
  3. 旧版本停止后确认线程消失;
  4. 用堆转储检查旧加载器的 GC Root 路径;
  5. 观察长期趋势,而不是单次 GC 后的瞬时值。

8.5 误解:Metaspace 增长就是 ClassLoader 泄漏

Metaspace 保存类元数据。增长可能来自:

  • 正常首次加载更多类;
  • 代理类或动态生成类增加;
  • 编译器、框架缓存扩张;
  • 类卸载尚未发生;
  • 真正的旧类加载器泄漏。

判断泄漏需要结合:

部署次数
+ 同一类的 defining loader 数量
+ 旧 ClassLoader 是否可达
+ 旧线程/缓存/Driver 是否存活
+ Metaspace 长期趋势

不能仅凭 MemoryPoolMXBean 的某次高水位作结论。

9. 生产环境中的验证和恢复

9.1 验证清理是否生效

可以在应用管理层为每次部署记录:

deploymentId
ClassLoader identity
启动线程集合
注册 Driver 集合
注册 MBean 集合
创建的 Executor 集合

停止时逐项比对:

停止前线程数:N
停止后旧部署线程数:0
停止前 Driver 数:D
停止后旧部署 Driver 数:0
旧 ClassLoader:无 GC Root 路径

其中 ClassLoader identity 不能只记录类名,因为不同部署可能有同名的加载器类。应记录对象身份或稳定的部署标识。

9.2 超时后的处理

如果线程无法停止,继续加载新版本会形成叠加:

v1 线程未结束
v2 启动
v2 线程未结束
v3 启动

生产恢复策略通常应包括:

  • 将旧实例标记为失败并阻止重复重载;
  • 保留线程转储和堆转储;
  • 停止继续创建新类加载器;
  • 将流量切换到健康实例;
  • 在无法安全终止泄漏线程时重启进程。

进程重启是粗粒度手段,但它能清除整个 JVM 的线程、静态注册表、类加载器和本地运行时状态。它不能替代修复,因为下一次热部署仍会重复同一问题。

9.3 容器的清理机制不能被当作通用 JVM 保证

Servlet 容器、应用服务器和框架可能提供:

  • 应用停止回调;
    -线程清理器;
  • Driver 注销;
  • TCCL 恢复;
  • JAR 扫描和注册表清理;
  • 防止常见内存泄漏的容器钩子。

这些是容器或实现层面的工程能力,不是 JVM 规范对所有应用的自动承诺。更换容器、嵌入式运行方式或框架版本后,清理行为可能变化。应用仍应拥有明确的资源生命周期,并在停止回调中关闭自己创建的资源。

10. 从引用图得到的最终判断标准

ClassLoader 泄漏可以用一个简单的判定过程表达:

  1. 找到某次旧部署的类加载器 L_old
  2. 确认它定义的类在后续部署后仍存在;
  3. L_old 反向查找 GC Root;
  4. 识别保留者属于哪一类:
    • 活跃线程或 TCCL;
    • ThreadLocal 或 InheritableThreadLocal;
    • 线程池任务或队列;
    • 父加载器静态缓存;
    • DriverManager;
    • MBean、监听器或 SPI 注册表;
    • 框架内部缓存;
  5. 修复产生引用的生命周期,而不是只处理类加载器对象;
  6. 通过多轮部署、线程检查、堆路径和长期趋势验证。

形式上,只要存在:

Rstrong referencesLoldR \xrightarrow{\text{strong references}} L_{\text{old}}

旧加载器就仍然可达;只要旧加载器仍然可达,它定义的类通常就没有进入可卸载状态。线程、缓存、Driver 和热部署并不是四个彼此独立的问题,而是同一个生命周期错误在不同全局组件中的表现:

短生命周期应用对象
    被发布到
长生命周期 JVM 组件
    且没有撤销引用
        -> 旧 ClassLoader 可达
            -> 类和元数据无法正常回收

诊断时应围绕这条因果链展开:先确认旧加载器是否仍存活,再沿 GC Root 找到真正的保留者,最后修复创建、发布和撤销资源的完整生命周期。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。