Metaspace 不断增长不一定是参数太小,更可能是类加载器无法被回收。本文从 JVM 类卸载条件出发,结合可复现代码与 jcmd、jmap、GC 日志,梳理一套定位类加载器泄漏的完整方法。

问题背景

线上 Java 服务运行几天后出现 java.lang.OutOfMemoryError: Metaspace,第一反应往往是增大 -XX:MaxMetaspaceSize。这可能暂时延后故障,却没有回答最重要的问题:服务为什么还在加载新类,以及旧类为什么没有被卸载?

Metaspace 异常尤其容易出现在支持热部署、脚本执行、动态代理、字节码增强或插件化的系统中。这些功能通常会创建自定义类加载器。只要类加载器仍然可达,它加载的类及相关元数据就很难被回收。

本文以 JDK 8 及之后版本为范围,讨论一个具体问题:如何确认 Metaspace 持续增长是类加载器泄漏,并找到持有它的引用。

Metaspace 存放了什么

JDK 8 移除了永久代,类元数据主要进入本地内存中的 Metaspace。它与 Java 堆不是同一块区域,因此即使 -Xmx 仍有余量,进程也可能因为 Metaspace 达到上限而失败。

Metaspace 中主要包含类的运行时元数据,例如类型结构、方法信息和运行时常量池等。Class 对象本身仍位于 Java 堆中。两者有关联,但不能简单理解为“类全部放在堆外”。

Metaspace 的提交量会随类加载增加。类被卸载后,相关空间可供对应 Metaspace 分配器复用,但进程占用的本地内存不一定立即、等量归还给操作系统。因此排查时不能只看 RSS,应同时观察已加载类数量、类加载器数量和 Metaspace 使用量。

类为什么没有被卸载

类卸载通常以类加载器为单位。一个自定义类加载器要具备回收条件,至少需要满足:

  1. 加载器实例没有任何强引用链可以从 GC Roots 到达;
  2. 它加载的类不再被存活对象使用;
  3. 对应的 Class 对象不再可达;
  4. JVM 在支持类卸载的垃圾收集周期中完成处理。

换句话说,不是执行一次 Full GC 就必然卸载类。如果线程上下文类加载器、静态集合、ThreadLocal、JMX 注册对象或框架缓存仍然引用自定义加载器,GC 只能保留它。

生产环境一般不要为了“清理 Metaspace”频繁调用 System.gc()。它既不能修复强引用,也可能引入明显停顿。

一个可复现的类加载器泄漏

下面的示例不断创建新的 URLClassLoader,加载同一个目录中的类,并把加载器存入静态列表。即使每次加载的是同名类,只要定义它们的类加载器不同,JVM 就会把它们视为不同的类型。

package demo;

import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

public final class MetaspaceLeakDemo {
    private static final List<ClassLoader> LEAK = new ArrayList<>();

    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            throw new IllegalArgumentException("参数应为插件 classes 目录");
        }

        URL pluginUrl = Path.of(args[0]).toUri().toURL();
        for (int round = 1; ; round++) {
            URLClassLoader loader = new URLClassLoader(
                    new URL[]{pluginUrl},
                    ClassLoader.getPlatformClassLoader());

            Class<?> type = Class.forName("plugin.Task", true, loader);
            Object task = type.getDeclaredConstructor().newInstance();
            type.getMethod("run").invoke(task);

            LEAK.add(loader);
            if (round % 100 == 0) {
                System.out.println("created loaders: " + round);
                Thread.sleep(200);
            }
        }
    }
}

插件类可以保持简单:

package plugin;

public final class Task {
    public void run() {
        // 模拟插件入口
    }
}

这里把父加载器设为平台类加载器,是为了避免应用类加载器提前找到 plugin.Task,确保每轮都由新的加载器定义插件类。真正的泄漏点是静态集合 LEAK。它通过应用类的静态字段长期持有所有加载器。

URLClassLoader 实现了 Closeable。实际插件卸载时应调用 close() 释放它打开的文件或 JAR 资源,但需要注意:close() 不等于类卸载。只要仍有强引用,关闭加载器也不会让其加载的类消失。

第一步:确认类与加载器是否持续增加

先找到 Java 进程 PID,再执行:

jcmd <pid> VM.classloader_stats
jcmd <pid> GC.class_histogram

VM.classloader_stats 可以观察类加载器及其加载类数量。不同 JDK 版本的输出列可能略有差异,不应让脚本过度依赖固定文本格式。若业务流量已经稳定,而自定义加载器数量仍单调增长,就值得重点检查。

还可以使用:

jstat -class <pid> 1000

它会周期性显示累计加载和卸载的类数量。累计加载量上升本身不一定异常;动态代理、Lambda、JSP 或监控增强都可能生成类。真正危险的信号是加载量长期增长、卸载量几乎不变,并且 Metaspace 同步攀升。

第二步:结合 GC 日志判断是否发生类卸载

在 JDK 9 及之后可以通过统一日志记录 GC 和类卸载信息,例如:

-Xlog:gc*,class+unload=info:file=gc.log:time,uptime,level,tags

JDK 8 的日志参数体系不同,常见配置包括 -XX:+PrintGCDetails-XX:+PrintGCDateStamps-XX:+TraceClassUnloading。参数是否受当前 JVM 支持,应先在对应发行版和版本中验证。

如果日志中发生了具备类卸载能力的收集周期,但目标加载器依旧存在,通常说明它仍被引用。不要仅凭“出现 Full GC”就认定 JVM 的卸载机制失效。

第三步:从堆转储寻找引用链

在确认加载器异常增长后,可以导出存活对象的堆转储:

jcmd <pid> GC.heap_dump /tmp/service.hprof

也可使用:

jmap -dump:live,format=b,file=/tmp/service.hprof <pid>

live 转储通常会触发垃圾收集并造成停顿,堆较大时还会带来显著磁盘和 I/O 压力。生产环境应提前确认剩余空间,在低峰期执行,并优先使用当前 JDK 推荐的诊断工具。

用 Eclipse MAT 等工具打开转储后,可以按以下顺序检查:

  1. 在 Class Loader Explorer 中按加载类数量或 retained heap 排序;
  2. 找到数量异常的自定义类加载器;
  3. 对其中一个实例执行 Path to GC Roots;
  4. 排除弱引用后,检查最近的一条强引用链。

在示例中,引用链最终会指向 MetaspaceLeakDemo.LEAK。真实系统中常见根因则包括静态缓存的键或值含有 Class、线程的 context class loader 未恢复、线程池中的 ThreadLocal 未清理,以及插件注册的驱动、MBean、日志回调或定时任务没有注销。

正确管理加载器生命周期

如果加载器只服务于一次任务,应使用明确的作用域,并避免把加载器或其定义的类型存入长生命周期容器:

URL pluginUrl = Path.of(pluginDirectory).toUri().toURL();
ClassLoader previous = Thread.currentThread().getContextClassLoader();

try (URLClassLoader loader = new URLClassLoader(
        new URL[]{pluginUrl}, ClassLoader.getPlatformClassLoader())) {
    try {
        Thread.currentThread().setContextClassLoader(loader);
        Class<?> type = Class.forName("plugin.Task", true, loader);
        Object task = type.getDeclaredConstructor().newInstance();
        type.getMethod("run").invoke(task);
    } finally {
        Thread.currentThread().setContextClassLoader(previous);
    }
}

这段代码解决了资源关闭和上下文类加载器恢复,但它仍不是完整的插件框架。若插件启动了线程、注册了回调或把对象交给全局缓存,框架必须提供对称的停止、注销和清理协议。插件 API 最好由稳定的父加载器加载,跨边界只传递这些公共接口和基础数据类型,减少父子加载器之间的意外引用。

常见误区

只增大 MaxMetaspaceSize 这可以降低正常类加载带来的扩容频率,也能为排查争取时间,但泄漏存在时只会推迟 OOM。

只看堆内存曲线。 Metaspace 使用的是本地内存。堆曲线平稳并不代表进程没有内存风险,还要结合 Native Memory Tracking、进程 RSS 和容器限制判断。需要 NMT 时,应在启动阶段配置 -XX:NativeMemoryTracking=summarydetail,之后再用 jcmd <pid> VM.native_memory summary 查看;它不能在任意进程上事后完整开启。

认为同名类只加载一次。 JVM 中类型身份由类的全限定名和定义它的类加载器共同决定。两个加载器分别定义的 plugin.Task 不是同一个类型。

看到 Metaspace 未明显下降就断定没有卸载。 JVM 可能保留已提交空间供后续复用。应把卸载日志、加载器实例数量和类数量放在一起判断。

实践建议

为长期运行且包含动态加载能力的服务建立基线:记录启动稳定后的已加载类数量、类加载器数量、Metaspace 使用量及其增长速率。监控告警应关注一段时间内的趋势,而不是只设置单一容量阈值。

在代码层面,把“卸载”设计成显式生命周期:停止插件线程、取消定时任务、移除监听器、清理 ThreadLocal、注销外部注册项、恢复线程上下文类加载器,最后关闭加载器并删除框架侧引用。测试中可以反复执行加载与卸载,借助 WeakReference<ClassLoader> 观察加载器在多轮 GC 后是否仍然可达;但 GC 时机没有强保证,因此这类测试应作为泄漏检测线索,而不是依赖一次 GC 的绝对断言。

总结

Metaspace OOM 的排查核心不是先调大参数,而是回答三个问题:是否持续创建新类或新加载器,旧加载器是否具备回收条件,以及哪条 GC Roots 引用链阻止了回收。

一条可靠的排查路径是:先用 jcmdjstat 和 GC 日志确认增长模式,再通过堆转储定位加载器的强引用链,最后回到插件、代理或热部署组件的生命周期设计中修复。只有引用关系被解除,类卸载和 Metaspace 复用才真正有机会发生。

最后修改:2026 年 08 月 07 日
如果觉得我的文章对你有用,请随意赞赏