线上Java服务如果反反复复出现重启,重启前日志里飘着一句java.lang.OutOfMemoryError: Metaspace,监控面板上Metaspace的committed一路爬升,used却低得像在嘲笑你,那基本可以断定:元空间正在泄漏。这种事我遇到不止一次,每次排查都感觉像在拆一个嵌套的盲盒。用jstat看现象、用jcmd挖对象、用Arthas锁代码,这套"三重定位法"是我这几年排查Metaspace泄漏用得最顺手的组合,今天把完整思路和实操过程展开讲清楚,适合正在被Java进程内存问题折磨的后端开发、运维和SRE同学参考。
先说结论:Metaspace泄漏不是堆内存泄漏,它藏在本地内存(Native Memory)里,常规的堆dump分析经常失效,必须从"类加载器回收"这个根子上入手。全文不堆概念,直接按真实排查顺序来,你跟着操作就能复现并解决同类问题。
1. 先搞清楚Metaspace为什么会泄漏
1.1 元空间的内存模型:committed与used的差距从哪来
JDK 8之后,永久代(PermGen)被移除,类元数据搬到了Metaspace。它使用的是进程本地内存,不再受-Xmx管理,默认情况下最大容量只受物理内存和操作系统限制。这带来一个很直观的后果:堆内存不够了,JVM会按部就班地触发GC,但Metaspace如果无节制增长,进程可能直接被操作系统杀掉,连OOM日志都来不及写完整。
Metaspace里存的是什么?类结构元信息(Klass)、方法字节码、常量池、注解、字段描述、方法签名等,说白了就是JVM用来描述"这个类长什么样"的C++对象。每一个被加载的类,都要在这块内存里占一份位置。类卸载之后,这块内存才可能被回收。
关键点在于Metaspace的内存分配方式是chunk级别的。JVM会为类加载器按需分配chunk,chunk内部再用free block来管理。类加载器释放之后,部分chunk并不会立即归还操作系统:大的chunk会返还给虚拟内存,小的chunk则被放进Metaspace的全局空闲列表里,供后续分配复用。这个机制带来的直接现象就是committed内存居高不下,而used只占其中一小部分。所以你在监控里看到"committed远大于used"时,先别急着下结论说是泄漏,它可能是正常的碎片化,也可能确实是类加载器没有被回收,需要进一步区分。
另外还有一个容易忽略的CCS(Compressed Class Space),也就是压缩类空间。启用-XX:+UseCompressedClassPointers之后,Klass指针被压缩成32位,最大容量默认1GB。CCS的committed和used同样值得关注,它和Metaspace主空间是分开统计的,排查时两个都要看。
1.2 泄漏的本质:类加载器无法回收
Metaspace里的类元数据要想被回收,前提是它对应的类加载器变得不可达,同时该类加载器加载的所有类都不存在存活实例和引用。这两个条件只要有一个不满足,类的元数据就会一直挂在Metaspace里。
实际生产中最常见的泄漏原因,就是自定义类加载器被"长持有"。典型场景包括:
- 每个请求或每次任务都new一个自定义ClassLoader,加载完类之后丢进静态列表、缓存或者ThreadLocal里不清理。
- 频繁使用CGLIB、ASM、ByteBuddy生成代理类或增强类,尤其是配合Spring、Hibernate这类框架做缓存时。
- 脚本引擎反复编译脚本,例如Groovy每次执行都生成新的类。
- Web容器热部署,老应用的老类加载器没有被完全释放,多个版本叠加。
- 反射调用配合自定义类加载器,在一个循环里不断加载新类。
这一类泄漏和堆内存泄漏有个明显差异:堆里对象多,GC日志能看到Old区涨,dump文件直接分析引用链就行;Metaspace泄漏的堆往往是干净的,Old区甚至很空闲,但本地内存和类加载器数量一直在涨。所以排查工具和方法论必须换个思路,不能再指望一份heap dump解决所有问题。
2. 工具选型:jstat、jcmd、Arthas怎么分工
2.1 为什么要用这三个组合
单个工具都有局限。jstat属于"速览型",能快速给出Metaspace使用量、类加载数量和GC频率,但它看不到代码层面,也看不出是哪个类加载器在作祟。jcmd属于"对象型",是JDK自带的诊断命令,能深入Metaspace内部查看chunk管理,也能输出类统计信息,但它操作起来偏静态,没法动态追踪热点代码。Arthas属于"在线手术刀",能挂载到运行中的Java进程,实时看类加载器树形结构、反编译字节码、抓CPU火焰图,是定位到具体代码行的关键。
我把这套组合定位成三个递进阶段:先用jstat确认"是不是"在泄漏,再用jcmd锁定"是哪一类对象"在增长,最后用Arthas揪出"是哪段代码"在反复创建类加载器。每走一步,问题范围就从进程级缩小到对象级,再到代码级,排查效率高很多。
2.2 jstat:盯住MC、MU和加载数量
jstat是JDK自带的,零成本,直接对目标进程执行即可。用它干两件事:第一件是看Metaspace的使用量和容量,第二件是看类加载数量。
jstat -gc <pid> 1s 10这条命令每秒输出一次,连续10次。重点看MC和MU两列,MC是Metaspace当前容量(committed),MU是Metaspace实际使用量,单位都是KB。CCSC和CCSU对应压缩类空间的容量和使用量,同样需要关注。
判定趋势的时候不能只看单次值,要看曲线。如果MU持续增长,比如每隔几秒刷新就上一个台阶,而且YGC/FGC还回收不下来,说明类元数据在被持续创建且无法释放。这时候再配合:
jstat -class <pid>看一下Loaded和Unloaded的数量。正常稳定运行的Java进程,Loaded数量应该基本持平;如果Loaded每秒都在涨,Unloaded几乎为0,基本可以判定类加载器泄漏了。
这里有个实操细节:jstat的PID要和你自己在命令行top里看到的Java进程PID对应,如果是容器部署,先找到宿主机上对应的Java进程,别对着Pod的PID去执行。
2.3 jcmd:深入Metaspace内部和类加载器统计
jcmd同样是JDK自带的命令,比jstat更细。需要先解锁诊断选项,最好在JVM启动时就加上:
-XX:+UnlockDiagnosticVMOptions排查时依次执行两条命令。第一条是看Metaspace的整体内存分配情况:
jcmd <pid> VM.metaspace输出里能看到Usage、Committed、Virtual space reserved、Chunk manager、GC threshold这些信息。重点看Committed和Used的比例,以及Chunk manager里的chunk数量。如果chunk数量非常多、碎片很严重,说明存在大量短命类加载器。
第二条是看类的统计信息:
jcmd <pid> GC.class_stats这命令会输出所有类的元数据统计,包含类名、加载器、字节数、方法数等。执行之后最好再按加载器聚合一下,看看哪个类加载器实例占用的元空间最多。通常你会看到某些自定义ClassLoader出现几百上千个实例,每个实例都带着一批类,这就是泄漏的最直接证据。
注意GC.class_stats在某些JDK版本上会触发较长的安全点停顿,生产环境如果比较敏感,尽量在低峰期执行,或者先确认是否开启诊断选项。负载过高的核心交易链路不建议贸然使用。
2.4 Arthas:在线反编译、类加载器树和火焰图
Arthas的威力在"动态"和"在线"。你不需要重启进程,不需要预先埋点,直接attach上去就能看。
启动Arthas非常简单:
java -jar arthas-boot.jar选择目标Java进程编号后,先执行dashboard看一眼全局内存,里面会显示Metaspace的使用情况。然后重点用三个能力。
第一个是classloader命令。执行:
classloader -t会按树形结构展示当前进程里所有类加载器的父子关系和实例数量。如果你看到某个类加载器几十上百个实例平铺在树里,而它们的parent都是同一个系统类加载器,那基本就是从同一个代码路径new出来没有被回收的。还可以指定classloader -l看每个类加载器的URLClassPath,判断它是加载了哪个目录或jar包下的类。
第二个是sc/jad命令。用sc -d <类名>可以查看类的详细信息,包括类加载器、类路径、注解等。用jad <全限定类名>可以直接反编译目标类的字节码,看到现场跑的实际代码,这对于确认是不是某个框架生成的动态代理类特别有用。
第三个是profiler命令。执行:
profiler start profiler stop --format html会生成一份火焰图,能看到CPU热点。Metaspace泄漏往往伴随着类加载和反射调用的高CPU开销,火焰图里会有一个高频调用链,顺着调用链就能找到反复创建类加载器的方法。新版Arthas内置的async-profiler多数支持--event alloc之类的事件采样,可以用来观察内存分配热点,不过不同版本支持度不一样,用之前先查一下当前版本的profiler help。
3. 实战复现:构造一个Metaspace泄漏现场
3.1 写一个可复现的泄漏Demo
理论说再多不如直接跑一遍。我准备了一个非常简单的Demo,模拟线上最常见的"循环创建类加载器并持有引用"的泄漏模式。新建两个Java文件。
package com.demo; /** * 一个简单的业务类,用来被自定义类加载器反复加载。 */ public class SampleLogic { public static void hello() { System.out.println("sample logic executed"); } }package com.demo; import java.lang.reflect.Method; import java.net.URL; import java.net.URLClassLoader; import java.util.ArrayList; import java.util.List; /** * 模拟Metaspace内存泄漏: * 每个循环都new一个URLClassLoader,加载同一个类,并把类加载器保存到静态List中, * 使类加载器永远无法被GC回收,类元数据也就一直留在Metaspace里。 */ public class MetaSpaceLeakDemo { // 关键:用静态List持有类加载器引用,阻止回收 private static final List<ClassLoader> HOLDER = new ArrayList<>(); public static void main(String[] args) throws Exception { URL url = MetaSpaceLeakDemo.class.getProtectionDomain() .getCodeSource().getLocation(); while (true) { URLClassLoader cl = new URLClassLoader(new URL[]{url}, null); Class<?> cls = cl.loadClass("com.demo.SampleLogic"); // 反射调用,模拟真实业务场景里的反射热点 Method method = cls.getMethod("hello"); method.invoke(null); // 持有类加载器引用,模拟线上长期缓存 HOLDER.add(cl); Thread.sleep(1); if (HOLDER.size() % 1000 == 0) { System.out.println("holder size: " + HOLDER.size()); } } } }启动时加上这些JVM参数:
java -XX:+UnlockDiagnosticVMOptions \ -XX:MaxMetaspaceSize=256m \ -XX:MetaspaceSize=32m \ -XX:MaxMetaspaceExpandRatio=5 \ -Xmx256m -Xms256m \ -Xloggc:gc.log \ com.demo.MetaSpaceLeakDemo这里的-XX:MaxMetaspaceSize=256m很关键。如果不设上限,泄漏会一直吞噬本地内存直到进程被OS杀掉,排查窗口很短。设了上限后,Metaspace增长到256m时会触发频繁Full GC,最后抛出OOM,给你留出观察空间。-XX:MetaspaceSize=32m是初始阈值,意思是Metaspace用到32m先触发第一次GC,方便早点看到趋势。
3.2 第一重定位:jstat快速确认泄漏趋势
Demo启动后,找到它的PID:
ps -ef | grep MetaSpaceLeakDemo然后执行:
jstat -gc <pid> 1s 10几秒后你会看到类似下面的输出:
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT 5120.0 5120.0 0.0 0.0 31744.0 31744.0 174848.0 13744.2 33792.0 33088.0 4096.0 3560.0 5 0.123 3 0.456 0.579注意MC和MU在持续变大。刚启动时MC可能是几MB,几十秒后到了几十MB,再过一会儿逼近-XX:MaxMetaspaceSize=256m的限制。同时FGC的次数在不断增加,但Metaspace的使用量并不下降,说明GC回收不了这些类元数据。
再执行:
jstat -class <pid>会看到Loaded数字不断上升,比如从1000涨到5000、10000,而Unloaded数量几乎不动。到这一步,已经可以明确:存在类加载器泄漏,Metaspace正在被持续消耗。第一阶段的目标达成,接下来要找具体的"元凶对象"。
3.3 第二重定位:jcmd锁定类加载器层级
继续对同一个进程执行jcmd,先看Metaspace内部细节:
jcmd <pid> VM.metaspace输出中能看到类似下面的信息:
Metaspace: Usage: 96.00 MB [Used: 92.40 MB, Committed: 96.00 MB] Virtual space: reserved: 512.00 MB committed: 96.00 MB Chunk manager: blocks: 224 KB, chunks: 1102 free chunks: ...重点看chunks数量。如果chunks数量在反复增长,并且有很多free chunks无法被合并使用,说明存在大量短命类加载器留下的碎片。这里你会体会到"committed远大于used"是怎么来的——很多chunk明明已经空了,但因为碎片化严重,无法整体归还给操作系统。
接着用类统计命令看类加载器:
jcmd <pid> GC.class_stats输出非常长,建议在本地先输出到文件再分析:
jcmd <pid> GC.class_stats > class_stats.txt然后按类加载器名称聚合统计:
awk '{print $3}' class_stats.txt | sort | uniq -c | sort -nr由于不同JDK版本输出列顺序有差异,实际执行时先head看下字段含义再调整。不过你大概率会看到两个特征:一是java.net.URLClassLoader类名大量出现,二是对应的地址各不相同,代表多个实例。每个URLClassLoader下面都挂着一批com.demo.SampleLogic的类元数据。
还可以用jmap的堆直方图做交叉验证。运行:
jmap -histo <pid> | head -30会看到类似:
num #instances #bytes class name 1: 10315 412600 java.net.URLClassLoader 2: 10315 288820 java.net.URLClassLoader$1 3: 10315 165040 com.demo.SampleLogic实例数量破万,而且还在增加,这就是"类加载器层层叠叠"的实锤。到这步,问题已经缩小到了:代码里某处在不断new URLClassLoader并持有不释放。剩下的工作就是找出那行代码。
3.4 第三重定位:Arthas揪出创建类加载器的代码
attach Arthas到目标进程:
java -jar arthas-boot.jar输入进程编号进入交互控制台。第一步执行:
classloader -t你会看到输出里出现大量重复的URLClassLoader节点,实例数量还在跳动。这个结果和jcmd里看到的一致,但Arthas的优势在于可以直接操作对象。
第二步是直接搜类加载器对应的类路径。执行:
classloader -l查看到URLClassLoader的URLClassPath指向的路径,如果指向的都是同一个地方,说明这些实例是从同一份代码路径new出来的。
第三步是抓CPU火焰图。执行:
profiler start让它跑几十秒,然后:
profiler stop --format html浏览器打开生成的火焰图,你会看到一条很宽的调用栈:MetaSpaceLeakDemo.main一路调用URLClassLoader的构造方法和loadClass、getMethod。火焰图上反复出现的"高塔"就是泄漏代码的入口。
第四步,在线反编译确认代码。执行:
jad com.demo.MetaSpaceLeakDemoArthas会直接把当前运行的字节码反编译出来,你会看到new URLClassLoader这一行的真实上下文,甚至能看到HOLDER这个静态List。现场代码和Demo代码一一对上之后,修复方案就变得很直接了。
到这里,三重定位的全部链路已经走通:jstat给出现象,jcmd给出对象证据,Arthas给出代码位置。生产环境排查时,这套流程完全可以平移,只是把Demo换成真实业务而已。
4. 常见问题与排查技巧实录
4.1 线上疑似Metaspace泄漏,先做这4件事
遇到线上Metaspace OOM或指标异常,我建议先花5分钟完成四步快速检查,再去跑上面的三重定位。
第一件事,确认是不是真泄漏。看Metaspace的used曲线是否"只涨不落"。如果涨到某个水位就稳定,可能只是业务高峰期类加载多,而不是泄漏;如果持续爬坡且每次Full GC都兜不住,就是泄漏。
第二件事,看FGC频率和回收效果。执行jstat -gc <pid>,观察FGC和FGCT的变化。Metaspace接近-XX:MaxMetaspaceSize时,JVM会频繁Full GC,这时候的GC日志会有大量Metaspace标记,需要注意区分。
第三件事,确认MaxMetaspaceSize有没有设置。如果没设置,泄漏进程不会报OutOfMemoryError: Metaspace,而是直接耗尽本地内存,表现为容器OOMKilled或宿主机负载异常,排查难度更大。所以线上必须显式设置Metaspace上限,宁可设大一点再监控,也不要完全不设。
第四件事,检查是否存在热部署或动态脚本。询问业务方最近有没有频繁发布、回滚、动态加载脚本或配置。基于经验,这类操作是Metaspace泄漏最高发的来源,能在排查前锁定重点方向。
4.2 三个必看监控指标和它们的预警阈值
监控系统里建议对每个Java进程都放上Metaspace相关指标,至少包含四个:Metaspace Used、Metaspace Committed、Loaded类数量、Unloaded类数量。
预警阈值可以参考:Metaspace Used持续超过-XX:MaxMetaspaceSize的70%,或者连续多个GC周期未见下降,就触发P2告警;Loaded类数量在小时级别增长超过20%且无稳定趋势,就触发检查;Unloaded数量长期为0,结合Loaded增长就是类加载器泄漏的典型信号。
另外要区分GC后used下降不等于回收了Metaspace。类卸载本身是延迟的,卸载后的类元数据要等对应的chunk被清理或复用后,committed才会下降。所以不能用一次GC后的committed变化来判断是否泄漏,至少要看10个GC周期的趋势。
4.3 常见问题速查表
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| Metaspace OOM,堆内存正常 | 类加载器泄漏,元数据无法回收 | 用jstat看趋势,jcmd看类加载器,Arthas定位代码 |
| committed远大于used,且chunk碎片多 | 大量短命类加载器,chunk无法合并归还 | 检查创建类加载器的调用链,修复持有引用问题 |
| FGC频繁,Metaspace使用量不降 | 动态代理/脚本引擎/热部署 | 检查CGLIB、Groovy、容器热加载类 |
| 容器OOMKilled,Java堆还有余量 | Metaspace或NativeMemory超限 | 设置MaxMetaspaceSize,开启NMT分析 |
| 类加载器数量正常,Metaspace仍涨 | 单个大类的元数据体积异常 | jcmd GC.class_stats查哪个类占字节数最大 |
4.4 修复方案与预防建议
定位到代码之后,修复方向通常有四个层次。
第一层是"别持有类加载器"。把静态List、缓存、ThreadLocal里的ClassLoader引用清掉,让GC能回收它。这里是治本的思路,也是绝大多数问题的根源。
第二层是"复用而非重建"。如果业务必须反复加载同一批类,不要每次new ClassLoader,把已经加载好的Class放到缓存里,按类名或版本复用。像Groovy这类脚本引擎,官方都建议用GroovyClassLoader的缓存机制,而不是每次独立创建。
第三层是"限制和预警"。设置合理的-XX:MaxMetaspaceSize,打开-XX:+TraceClassLoading -XX:+TraceClassUnloading,配合监控告警,在泄漏刚冒头时就发现。脚本引擎和动态代理库升级到能控制类缓存和回收的版本。
第四层是"隔离和快速恢复"。对于短时间内无法完全修复的历史遗留服务,可以通过调整-XX:MaxMetaspaceExpandRatio降低扩容幅度,或者配置JVM参数在Metaspace接近上限时自动dump现场:
-XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/tmp/metaspace_oom.hprof不过要注意,Metaspace OOM的hprof文件主要包含堆对象,dump意义有限,真正有价值的还是GC日志和类加载日志。
4.5 实际排查中容易踩的坑
这个部分是我最想说的。排查Metaspace泄漏时,几个坑几乎每次都会遇到,提前避开会省下大量时间。
第一个坑是GC.class_stats在生产上造成的停顿。低版本JDK执行这条命令非常容易触发长STW,我见过一次线上执行直接卡了十几秒的情况。所以一定要带到低峰期执行,并且确认业务能接受短暂暂停。实在担心,可以只用jcmd VM.metaspace和jstat做初步判断,把GC.class_stats放到最后确认环节。
第二个坑是Arthas attach不上。常见原因是对目标进程没有权限,或/tmp目录空间不足。Arthas默认会写临时文件到/tmp,空间满了一连串功能都不可用。先清理/tmp,或用-C指定临时目录。另外容器环境要确认你是否和Java进程在同一个PID namespace。
第三个坑是火焰图只给出CPU热点,没给出分配热点。Metaspace泄漏的链路往往伴随着类加载和反射调用,CPU火焰图上确实能看到。但如果你的Metaspace增长是由定时任务批量加载造成的,CPU热点可能不明显。这时候别死磕profiler,回到classloader命令和sc命令上去找增长点。
第四个坑是误把正常的Metaspace增长当泄漏。Java的类加载是按需的,很多大型系统在运行初期类数量会持续攀升一段时间,直到业务路径都走一遍才稳定。判断泄漏至少要观察两三个小时,而不是看几分钟就下结论。
第五个坑是只修代码不清理残留。线上已经泄漏过的进程,哪怕你修复了代码,Metaspace碎片和已加载的类数据也不会自动消失。修复后需要通过重启或灰度发布来释放残留内存,别指望Metaspace会在下次GC里自动降到正常水位。
我在实际排查里还有一个深有体会的点:Metaspace泄漏的根因往往不难找,难的是把那一堆看似正常的框架代码和业务代码区分开。比如CGLIB代理和反射调用这类机制本身没错,但配合"每次请求都生成新代理"的写法就变成了泄漏。排查时多问一句"这段代码的调用频率是什么",比单纯盯内存指标更有用。希望这套三重定位法,能帮你少走几步弯路。