☰
Java元空间泄漏排查:从jstat到Arthas的三重定位法
2026/10/8 3:49:57 网站建设 项目流程

一个Java服务刚上线时表现得很正常,跑了三天之后开始频繁Full GC,第六天凌晨直接抛出java.lang.OutOfMemoryError: Metaspace。运维第一反应是调大-XX:MaxMetaspaceSize,结果第二天照样躺平。后来我接手排查,发现真正的问题根本不在参数大小上,而是元空间被某个自定义类加载器悄悄吃干了。这篇把当时的完整定位过程拆开来讲:jstat看趋势,jcmd挖类加载器,Arthas钉死到代码行,三个阶段互相印证,基本可以把 Metaspace 泄漏从“玄学”变成“确定性”。如果你手头正好有台老出毛病的 Java 服务,或者想提前储备一套排查思路,这篇值得花十分钟看完。

1. 先搞清楚 Metaspace 泄漏“漏”的到底是什么

1.1 元空间和永久代最大的不同

Java 8 之前,类的元数据存放在永久代(PermGen),大小受-XX:PermSize控制,位置在堆内,回收逻辑跟老年代纠缠在一起。Java 8 开始,HotSpot 用元空间(Metaspace)把它挪到了本地内存,不再占用堆空间,默认上限是无限或由-XX:MaxMetaspaceSize限制。

这个改动带来了一个关键变化:类元数据的生命周期和它所对应的类加载器(ClassLoader)强绑定。只要某个类加载器还存活于引用图中,它加载过的所有类的元数据就不会被回收,哪怕你手动调用System.gc()也一样。反过来,只要类加载器可以被回收,它加载的类元数据就自然被拎出去了,不需要你手工清理。

所以 Metaspace 泄漏本质上不是“内存碎片”问题,而是“类加载器泄漏”:应用在长期运行中不断创建新的类加载器,旧加载器又被某个长生命周期对象一直引用着,导致元空间只增不减。理解了这一点,后面所有命令只是换角度找同一件事——谁在制造无法回收的类加载器。

1.2 三个容易踩进去的误区

我见过不少团队在 Metaspace 泄漏上兜圈子,基本都是被这三个误区带偏的:

  • 误区一:调大-XX:MaxMetaspaceSize就能解决问题。如果你的应用就是不断生成新类且不释放,你把这个上限从 256MB 调到 2GB,也只是把 OOM 从第六天延后到第二十天,同时 Full GC 频率会越来越夸张,应用吞吐量暴跌。参数是安全阀,不是修复手段。
  • 误区二:只有 OOM 了才算泄漏。实际上更隐蔽的表现是:Metaspace 使用率在高水位徘徊,触发频繁 Full GC,但堆内存并没有满。很多团队先去看堆、看老年代、看线程,完全想不到是 Metaspace 在拖后腿。
  • 误区三:以为只有热部署才有类加载器问题。反射生成访问类、动态代理、Groovy/GraalJS 这类脚本引擎、SPI 扫描,甚至某些 ORM 框架在运行期也可能动态生成类。任何一类“动态类生成”配合一个“不合理缓存”,都可能变成 Metaspace 泄漏。

所谓的三重定位法,就是按“宏观趋势 -> 对象级统计 -> 方法级调用链”逐层收窄范围。先用jstat判断是不是真的在泄漏、泄漏节奏是什么;再用jcmd定位是哪一种类加载器在膨胀;最后上Arthas把创建它的那行代码揪出来。这样的好处是每一步都有证据,全程不重启,不靠猜。

2. 第一重定位:用 jstat 把 Metaspace 的增长曲线画出来

2.1 jstat 输出项里真正该盯住的两组数字

jstat是 JDK 自带的监控工具,在生产环境直接用没问题。先找到 Java 进程号,可以用jps -l或者pgrep -f java:

jps -l

然后每隔一秒采一次样,连续采样 60 次:

jstat -gc 25432 1000 60

输出列很多,但跟 Metaspace 相关的主要是这几列:

| 列名 | 含义 | 单位 | | MC | Metaspace 当前提交容量 | KB | | MU | Metaspace 当前使用量 | KB | | CCSC | 压缩类空间容量 | KB | | CCSU | 压缩类空间使用量 | KB |

如果是普通 Java 应用,还需要留意旁边的FGC列,Full GC 次数;我会把同一时刻的MU和FGC对应起来看。

刚开始接触的时候,大家容易只盯着 MC(容量),这不对。容量是系统根据使用量和管理策略动态扩展的。真正要看的是 MU(使用量)的走势。正常情况下,MU 会在某个水平线上轻微波动;业务高峰期可能涨一点,闲时会回落。如果 MU 一路上涨,MC 也跟着不断扩容,而且在大FGC之后 MU 几乎不掉,那就要高度警惕了。

下面是我在一次线上采样时记录下来的典型异常片段:

MC MU CCSC CCSU YGC FGC GCT 40960 24510 5120 4420 123 2 1.234 51200 36880 5120 4688 134 4 1.512 61440 49302 6144 4810 151 8 2.003 76800 61234 7680 5120 176 15 2.812

每次采样之间 MU 增长几千到一万 KB,FGC 次数也在增加,而且全景 GC 后 MU 完全没有回落的意思,基本可以判定:有类加载器无法被回收,Metaspace 出现持续增长。

2.2 采样规范:别被瞬时抖动骗了

排查元空间问题,最忌讳只看一眼就下结论。有些应用启动阶段本身就要加载大量框架类,前十分钟 Metaspace 上涨很快,这是正常的。如果启动后 20 分钟就跑了一次jstat,看到 MC 高了就喊“泄漏”,容易被误判。

我建议的采样姿势是固定三个条件:

  • 至少持续 10 分钟以上,覆盖一个完整业务周期;
  • 每个业务周期的高峰和低峰各采一轮,把基线记录下来;
  • 如果最近有发布,在发布前后分别采样一次,能否看到“台阶式上涨”。

有一种很常见的形态是:每次发布后 Metaspace 涨一截,之后进入平台期,再发布再涨。这种“台阶式上涨”基本就是热部署/动态编译时旧类加载器没有被卸载。另一种形态是持续向上没有平台期,像呼吸一样均匀增长,这通常意味着某个动态脚本引擎在按请求量生成类加载器,比如每来一个请求就new GroovyShell。

当jstat给出的信息足够“异常”后,别急着去 dump 堆,先把对象层面的统计拿到手,这就是第二重定位。

3. 第二重定位:用 jcmd 把“谁的类元数据在膨胀”挖出来

3.1 jcmd GC.class_stats,直接按类加载器汇总元空间占用

jcmd是 JDK 自带的诊断命令,比jmap更温和,适合在线排查。在进程还活着的情况下,执行:

jcmd 25432 GC.class_stats -histo

这个命令会按类加载器维度统计:每个类加载器加载了多少类、这些类在元空间占了多大、产生了多少实例。加了-histo选项后,结果会比较直观,按占用空间从大到小排列。

我在实际里看到的输出类似这样:

Class LoaderBytesLoader classesInstances
groovy.lang.GroovyClassLoader2.3GB128,500128,512
com.example.plugin.IsolatedClassLoader340MB12,40012,408
sun.misc.Launcher$AppClassLoader80MB9,2009,202

看到GroovyClassLoader占了 2.3GB,问题基本有方向了。要知道一个普通类加载器加载几百个类是正常的,但积累到十几万个类,而且 Bytes 很大,那就是典型的“类加载器数量失控”。你还可以用grep过滤可疑前缀:

jcmd 25432 GC.class_stats -histo | grep -i groovy

如果某些 JDK 版本提示GC.class_stats不可用,或者需要额外解锁选项,我会立刻改用jcmd 25432 GC.class_histogram看堆里残留类加载器实例的数量和类型。Metaspace 里的类元数据不在堆内,但类加载器对象本身在堆里。一个GroovyClassLoader实例若在堆里大量残留,同样能说明问题。

3.2 配合 VM.native_memory 看整体 Native 占用

如果你在应用启动参数里加了-XX:NativeMemoryTracking=summary,那么可以用:

jcmd 25432 VM.native_memory summary

NMT 会把 Metaspace 的 reserved、committed 和 mmap 情况打印出来,能辅助确认是不是有 Native 层面的异常占用。但注意,这个参数必须在启动时加,运行时加不上去。如果没加,jcmd会直接提示不支持,不影响前面GC.class_stats的判断。

这一步的核心产出不是一份报告,而是拿到一个“类加载器类型”。比如GroovyClassLoader、URLClassLoader、sun.reflect.DelegatingClassLoader,每一个都对应不同的业务模块。DelegatingClassLoader一般是反射生成动态类导致的;GroovyClassLoader一般是脚本引擎;URLClassLoader一般跟热部署或插件隔离有关。

3.3 从加载器类型映射到业务模块

拿到类加载器类型后,再抽几个具体类名看一眼:

jcmd 25432 GC.class_stats -histo | grep ReportScript

如果看到类似com.example.rule.groovy.ReportScript1、ReportScript2这样的类名,并且数量爆炸,几乎可以断定是某个业务模块在执行 Groovy 脚本,并且每次脚本内容不同都会生成新的类。

到这一步,我们知道了“谁”在膨胀,但还不一定知道“哪一行代码”让它持续膨胀。接下来就需要 Arthas 这种交互式诊断工具,在运行中的 JVM 里直接做调用链追踪。

4. 第三重定位:Arthas 在线把泄漏点钉到方法粒度

4.1 不重启进程的接入方式

Arthas 是阿里开源的问题诊断工具,最大的优势是让生产者直接进入运行中的 JVM,不需要重启进程,不需要加一堆参数。连上之后,你可以实时查看类加载器、方法调用栈、甚至抓火焰图。

启动方式很简单:

java -jar arthas-boot.jar 25432

如果机器上有多个 JDK 实例,它会列出进程列表让你选。连接成功后,先跑一下dashboard,看看整体内存和线程状态;再跑classloader,直接看当前 JVM 里有哪些类加载器,以及各自加载了多少类。

Arthas 的classloader命令输出大致是:

NAME LOADED CLASSES HASH groovy.lang.GroovyClassLoader 132456 2ab3c8... sun.misc.Launcher$AppClassLoader 9210 3fe4d1...

如果LOADED CLASSES这个数字在持续增长,那跟jcmd看到的结论就对上了。

4.2 用 classloader + sc 锁死具体加载器

先看下这个可疑加载器都加载了哪些类:

classloader -c 2ab3c8...

然后可以用sc搜索业务相关的动态类名:

sc -d *ReportScript*

输出会包含类名、类加载器 hash、类文件路径。你可以根据这些信息确认:这些类不是来自固定的 Jar,而是运行期编译/生成的。到了这一步,已经能基本定位到脚本引擎模块,还差最后一步——找出是谁在频繁触发“动态编译”。

4.3 用 trace 和 profiler 抓到触发位置

找到可疑的业务入口类后,用trace追踪它的核心方法:

trace com.example.rule.RuleEngine evalRule

然后手动发几个请求刺激一下。Arthas 会实时打印方法内部调用路径,以及每一步的耗时。我之前在某现场看到的输出,反复出现同一个调用链:

com.example.rule.RuleEngine.evalRule └── groovy.lang.GroovyShell.parse └── org.codehaus.groovy.control.CompilationUnit.compile

这个调用链基本说明:RuleEngine的evalRule在每次请求时都会重新调用GroovyShell.parse解析一段脚本,而解析就意味着新的类元数据进入 Metaspace。如果再结合代码里有一个永不失效的缓存,那GroovyClassLoader被持续引用的证据链就完整了。

如果不想手动抓,可以试试 Arthas 内置的 profiler 功能,直接抓分配火焰图:

profiler start --event alloc

等几十秒或几分钟,然后停止并导出火焰图:

profiler stop --format html

把生成的 HTML 下载到本地,用浏览器打开,在火焰图里搜GroovyClassLoader或脚本类名,能看到该类加载器是在哪些调用栈里不断被分配的。这条链路对 Metaspace 泄漏尤其有用,因为alloc事件会追踪对象分配点,而元空间相关对象的分配往往伴随着类加载。

4.4 为什么这一步比 heap dump 更管用

很多人遇到内存问题时,第一反应是抓 heap dump 回本地分析。但 Metaspace 泄漏有一条特殊性:类元数据不在堆内存里,传统 heap dump 分析工具很难直接给你“哪个类加载器占了多大 Metaspace”的答案。而且堆转储文件动不动几个 GB,生产环境磁盘不一定扛得住,还原现场也很费劲。

Arthas 的优势是“在线取证”:进程不重启,命令直接下,证据链实时生成。它输出的调用栈能直接跳转到源码行号,这在修复阶段非常高效。相比离线 dump 再猜,这种方式更像是在手术台上直接做内镜。

5. 复盘一个小场景:Groovy 规则引擎的 Metaspace 泄漏

5.1 症状、定位链路、证据

这里用一个综合案例把完整过程串起来。假设某风控规则引擎,每天要跑大量规则,代码里用 Groovy 写规则,每次规则变更或请求命中时都会重新执行一段 Groovy 脚本。

上线一周后,Metaspace 占用一路涨到 2.5GB,Full GC 偶尔一分钟一次。当时按前面顺序排查:

  • jstat -gc看到 MU 每十分钟涨 50MB,且 FGC 后不回落;
  • jcmd GC.class_stats -histo显示groovy.lang.GroovyClassLoader占用元空间超过 1.8GB,加载了近 12 万个类;
  • Arthasclassloader看到同样数字还在涨;
  • 用trace跟踪evalRule方法,发现每次请求都新建GroovyShell并调用parse。

代码层面进一步检查,根因是“结果缓存 + 动态脚本”的组合:

private final Map<String, Object> resultCache = new ConcurrentHashMap<>(); public Object evalRule(String ruleScript) { // 误以为返回结果和脚本无关,把结果按规则 ID 永久缓存了 Object result = resultCache.computeIfAbsent(ruleId, id -> { GroovyShell shell = new GroovyShell(); Script script = shell.parse(ruleScript); return script.run(); }); return result; }

问题在于:缓存的Script或执行结果间接持有GroovyShell内部的GroovyClassLoader。规则脚本又不断变化(版本更新、参数拼接),每次变化都会在同一个请求里生成一个新的类加载器,并且这个加载器被缓存的Script对象一直引用着,永远不会被 GC 回收。于是 Metaspace 里的类元数据像滚雪球一样增长。

5.2 修复方案与验证

修复思路不是不要缓存,而是“让类加载器可回收”或者“复用类加载器并限制缓存规模”。

我当时做了两件事:

第一,复用同一个GroovyClassLoader,而不是每次请求都新建:

private static final GroovyShell SHELL = new GroovyShell(); public Object evalRule(String ruleScript) { Script script = SHELL.parse(ruleScript); return script.run(); }

这样可以避免类加载器数量无限增长,但注意:如果脚本内容本身无限变化,同一个 classloader 里加载的类数量仍会增长。所以第二件事是给脚本类加缓存,并按脚本内容指纹控制数量,同时配合有界缓存或弱引用:

private final ConcurrentHashMap<String, Script> scriptCache = new ConcurrentHashMap<>(); public Object evalRule(String scriptContent) { String fingerprint = sha256(scriptContent); Script script = scriptCache.computeIfAbsent(fingerprint, key -> SHELL.parse(scriptContent)); return InvokerHelper.invokeMethod(script, "run", null); }

如果规则之间确实需要类隔离,更规范的做法是用独立的GroovyClassLoader,但必须保证不再被引用时能释放。也就是说,保存动态类实例的缓存应该用WeakReference或带容量上限的淘汰 Map,而不是永久持有。

修复上线后,我让压测脚本持续跑了 48 小时,再用jstat采样,MU 曲线明显进入平台期,Full GC 频率也回到正常水平。观察三天后,Metaspace 使用量稳定在 800MB 左右,不再上涨。

5.3 复盘中的几个操作细节

  • jcmd GC.class_stats -histo在执行时可能会触发额外的内部统计,建议在低峰期使用;
  • Arthastrace默认输出所有子调用,如果方法吞吐量太高,会刷屏。可以先加#execution过滤,或者指定耗时阈值,比如trace com.example.rule.RuleEngine evalRule '#cost > 100';
  • 如果现场 JVM 版本跟应用运行时的 JDK 不一致,jcmd可能打印“attach”相关错误,排查前先确认命令行下的jcmd来自哪个 JDK;
  • 阿里云或容器环境里,直接启动 Arthas 可能因为权限或端口限制失败,可以先在本地用java -jar arthas-boot.jar连同一进程,或者把 ssh 代理打通后再操作。

6. 日常给 Metaspace 装上仪表盘和安全阀

6.1 最简单实用的三层监控

很多人只有 OOM 了才想起 Metaspace,这是成本最高的做法。我习惯在应用里常驻下面三个观测点:

观测项暴露方式预警阈值
MetaspaceUsedPrometheus + JMX Exporter超过 MaxMetaspaceSize 的 70%,持续 10 分钟
LoadedClassesCountJMX 的LoadedClassCount相对上次发布增长超过 50%
FullGC 次数及耗时GC MetricsFull GC 频率超过 1 次/分钟,且 MetaspaceUsed 不回落

另外,在自定义 ClassLoader 或脚本引擎封装层埋入一行日志:记录创建位置、类加载器标识、类数量。这样下次现场不用再从头排查,日志里直接能看到“谁在持续造类加载器”的线索。

6.2 代码审查时的三条红线

结合几个线上事故,现在我评审代码遇到类加载器相关逻辑,会格外留意下面这三条:

  • 动态生成的类和类加载器,绝不能进“永不失效”的缓存。如果你必须缓存结果,也要缓存到一个有淘汰机制的容器里,比如Caffeine、Guava Cache、WeakHashMap。
  • 自定义ClassLoader对外只能暴露短期引用。类加载器本身和被加载的类互相持有,一旦它进入某个全局静态字段,这个局根本没法解。务必要配合弱引用使用,并及时置空父引用。
  • 热更新、插件化机制必须有卸载验证。不要光卸载模块就完了,要用jcmd GC.class_stats对比卸载前后类加载器数量和 Metaspace 使用量,确认真的降下来了。

6.3 写在最后的一点体会

我遇到过好几次 Metaspace 泄漏,最后发现根因都可以浓缩成一句话:一个本不该被长期持有的 ClassLoader,被某个静默的缓存悄悄抱住了。三重定位法最有价值的地方,不是哪条命令多高级,而是让你从趋势、对象、调用链三个层面各拿到一份证据,再交叉印证。

如果下次再碰到“Java 进程越用越胖”,建议你从jstat开始,别一上来就 dump 堆。很多时候,你在 Metaspace 和类加载器上多花十分钟,能省下后面几天的折腾。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询