
一个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 javajps -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 增长几千到一万 KBFGC 次数也在增加而且全景 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 classesInstancesgroovy.lang.GroovyClassLoader2.3GB128,500128,512com.example.plugin.IsolatedClassLoader340MB12,40012,408sun.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:NativeMemoryTrackingsummary那么可以用jcmd 25432 VM.native_memory summaryNMT 会把 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.5GBFull GC 偶尔一分钟一次。当时按前面顺序排查jstat -gc看到 MU 每十分钟涨 50MB且 FGC 后不回落jcmd GC.class_stats -histo显示groovy.lang.GroovyClassLoader占用元空间超过 1.8GB加载了近 12 万个类Arthasclassloader看到同样数字还在涨用trace跟踪evalRule方法发现每次请求都新建GroovyShell并调用parse。代码层面进一步检查根因是“结果缓存 动态脚本”的组合private final MapString, 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 ConcurrentHashMapString, 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 和类加载器上多花十分钟能省下后面几天的折腾。