服务跑了一周,OutOfMemoryError: Metaspace 了。第一反应是调大 -XX:MaxMetaspaceSize,调完撑了三天又炸。这条路径走不通的原因很简单:元空间泄漏不是"空间不够",而是类加载器被谁攥住不撒手。类卸载这件事,条件写起来只有三条,但落地排查必须沿着 ClassLoader 的引用链走。
1. 类卸载的三个条件
JVM 规范对卸载的要求可以归成三条,且必须同时满足:
| 条件 | 含义 | 观察手段 |
|---|---|---|
| 该类所有实例已回收 | 堆上不再有该类的对象 | GC.class_histogram 里该类实例数为 0 |
其 ClassLoader 已回收 |
定义它的加载器本身不可达 | 加载器实例数不回落 |
对应 java.lang.Class 对象无引用 |
没有反射、缓存、Class 字段持有 |
hprof 里 Class 对象的引用链 |
实际实现还多一层:ClassLoader 实例本身不可达,且没有其他地方通过 getClassLoader()、Class 对象等方式反向引用它。卸载的单位是整个加载器,不是单个类——同一个 URLClassLoader 加载的一百个类,只要还有一个类的实例活着,整批都留着。
2. 为什么瓶颈几乎总是 ClassLoader
| 现象 | 真实机制 |
|---|---|
| 单个类无法单独卸载 | 类的元数据挂在加载器的数据结构上,只能整批释放 |
| 类数只涨不跌 | 加载器被持有 → 其所有类都无法卸载 |
| 卸载后元空间不立即归还 | 元空间以 chunk 为单位管理,回收后可能保留少量空闲块 |
| 重启即恢复 | 进程退出,所有加载器与元数据一起消失 |
这解释了排查顺序:先数加载器,再看类,最后看引用链。直接盯着类名找泄漏,往往找不到——因为持有者是加载器,而加载器被一个 ThreadLocal 或静态 Map 攥着。
$ jcmd <pid> GC.class_histogram | head -30
典型输出形态(按实例数降序):
num #instances #bytes class name (module)
-------------------------------------------------------
1: 184023 2944368 [B ([email protected])
2: 92110 2210640 java.lang.String ([email protected])
3: 4021 96304 com.example.Order (app)
它给的是实例数,不含类对象本身。要数类与加载器,用诊断命令:
$ jcmd <pid> VM.classloader_stats
$ jcmd <pid> VM.class_hierarchy -i -s java.lang.Object | head -40
3. 元空间的回收触发条件
关键认知:-XX:MetaspaceSize 不是初始容量,而是首次触发元空间 GC 的阈值(JDK 8 起改名后的语义,常被误读)。
| 参数 | 语义 | 默认(JDK 17) |
|---|---|---|
-XX:MetaspaceSize |
首次扩容/触发回收的水位 | 平台相关,通常约 20MB 量级 |
-XX:MaxMetaspaceSize |
上限,超了抛 OutOfMemoryError: Metaspace |
无上限(受物理内存约束) |
-XX:MinMetaspaceFreeRatio |
回收后最小空闲比例 | 40 |
-XX:MaxMetaspaceFreeRatio |
回收后最大空闲比例 | 70 |
-XX:CompressedClassSpaceSize |
压缩类空间预留 | 1G(开启压缩指针时) |
两个 FreeRatio 决定"回收后保留多少空闲",不是决定"何时回收"。触发回收的直接条件是已提交的元空间超过当前阈值,而阈值会随回收结果上下浮动。
$ jcmd <pid> VM.metaspace
JDK 17 输出截取,形态如下(字段固定):
Total Usage - 127 loaders, 1281 classes (1281 loaders total):
Non-Class: 38.42 MB capacity, 36.11 MB ( 94%) used, 2.05 MB ( 5%) free
Class: 14.06 MB capacity, 13.02 MB ( 93%) used, 0.98 MB ( 7%) free
Both: 52.48 MB capacity, 49.13 MB ( 94%) used, 3.03 MB ( 6%) free
MaxMetaspaceSize: unlimited
Metaspace GC threshold: 52.00 MB (updates on GC)
读数顺序:loaders 数量是第一个信号;Class 与 Non-Class 的 used 增长趋势是第二个;Metaspace GC threshold 是第三个——阈值涨了但 used 不降,就是泄漏。
$ jcmd <pid> VM.metaspace show-loaders
按加载器列出占用,能直接指出哪个加载器在膨胀。
4. 类爆炸的两个典型来源
| 来源 | 生成机制 | 特征类名 |
|---|---|---|
| JDK 动态代理 | Proxy.newProxyInstance 按接口组合生成 |
jdk.proxy2.$Proxy37 |
| CGLIB / ByteBuddy | 继承目标类生成子类 | com.example.Order$$EnhancerByCGLIB$$a1b2c3 |
| Lambda | LambdaMetafactory 生成调用点类 |
com.example.Svc$$Lambda$14/0x0000... |
| 脚本引擎 / 表达式 | 每次编译生成新类 | jdk.nashorn...、Groovy 脚本类 |
Lambda 那一条最隐蔽:它是每个调用点一个类,数量随代码里 lambda 出现次数增长,通常有限;但如果 lambda 在循环里被反复 metafactory 绑定(比如反射调用),就会爆炸。
$ jcmd <pid> GC.class_stats | awk 'NR==1 || /Proxy|Enhancer|\$\$Lambda/' | head -20
GC.class_stats 需要 -XX:+UnlockDiagnosticVMOptions,输出包含 ClassLoader 名称与类名,是定位"谁在造类"最快的手段。
5. 热部署泄漏的引用链
URLClassLoader 是热部署与插件化的标配,也是最常见的泄漏源。典型链条:
| 持有者 | 被持有 | 为什么难发现 |
|---|---|---|
ThreadLocal 里的业务对象 |
对象的 Class → 加载器 |
线程池线程长期存活 |
静态 Map/List 注册表 |
插件对象/监听器 | 静态字段天然 GC Root |
Thread.currentThread().getContextClassLoader() |
加载器本身 | 线程池线程记住了它 |
JDBC DriverManager |
驱动类 → 加载器 | 驱动注册后不移除 |
ShutdownHook |
插件里的 Runnable | 注册后到进程退出才执行 |
ThreadPoolExecutor 的 AccessControlContext |
创建时的上下文 | 隐式引用 |
排查用 hprof 的 Path to GC Roots,排除弱引用/软引用后看第一条强引用链。经典答案是这条:
Thread [pool-1-thread-3] -> threadLocals (ThreadLocalMap)
-> Entry.value -> com.example.PluginService
-> class com.example.PluginService -> ClassLoader (URLClassLoader@1a2b3c)
只要看到"线程 → ThreadLocal → 对象 → Class → ClassLoader",结论就定了:插件卸载时没清 ThreadLocal。
$ jcmd <pid> GC.heap_dump /tmp/heap.hprof
$ jcmd <pid> Thread.print -l | grep -A3 'pool-1'
6. 排查步骤
# 1) 看加载器与类总量,确认是否持续增长
$ jcmd <pid> VM.metaspace | head -8
# 2) 隔一段时间再取一次,比较 used 与 loaders
$ sleep 300 && jcmd <pid> VM.metaspace | head -8
# 3) 谁在造类
$ jcmd <pid> GC.class_stats | sort -k2 -nr | head -20
# 4) 抓堆,看引用链
$ jcmd <pid> GC.heap_dump /tmp/heap.hprof
步骤顺序不能反:先确认"是否真的在涨"(排除正常预热),再确认"是哪些加载器",最后才是"谁持有它"。反过来直接抓堆,往往会淹没在几十万对象里。
7. 常见坑
| 坑 | 表现 | 真实原因 | 处理 |
|---|---|---|---|
只调大 MaxMetaspaceSize |
拖延几天又炸 | 泄漏未消除,只是推迟 | 抓加载器与引用链 |
| 把 Metaspace OOM 当内存不足 | 加机器/加内存无效 | 加载器不可回收 | 查静态注册与 ThreadLocal |
以为 System.gc() 能卸载类 |
手动 GC 后类数不降 | 有强引用,GC 不解决可达性 | 找引用链 |
用 -XX:+CMSClassUnloadingEnabled |
JDK 17 报 Unrecognized | CMS 已在 JDK 14 移除,JDK 9 起弃用 | 删掉这类历史参数 |
| 认为类卸载与 GC 类型无关 | 以为任意 GC 都卸载 | 类卸载需并发类卸载阶段(G1/ZGC 均有) | 用 -Xlog:class+unload 确认 |
$ java -Xlog:class+unload=info -jar app.jar | head -20
JDK 17 输出截取会给出 Unloading class ... 与 Unloading nmethod ...,直接验证卸载是否真的发生。
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
VM.metaspace 里 loaders 持续上涨 |
每次请求/热部署新建加载器 | 复查加载器创建点,改复用 |
加载器数稳定但 Class used 上涨 |
同一加载器不断生成类(代理/CGLIB) | 缓存代理类,限制生成次数 |
| GC 后 used 不降、threshold 上涨 | 强引用持有加载器 | hprof 引用链 |
OutOfMemoryError: Metaspace 但 used 远小于上限 |
压缩类空间(CompressedClassSpaceSize)先耗尽 |
看 VM.metaspace 的 Class 分区 |
卸载日志有 Unloading 但内存不还 |
chunk 归还延迟 | 观察多个 GC 周期再判断 |
第四条值得强调:开启压缩指针后,类的元数据进 CompressedClassSpace,它有独立上限。MaxMetaspaceSize 没到但 Class 分区满了,一样会 OOM,且报错信息相同。
9. 边界
| 场景 | 是否需要关心类卸载 | 理由 |
|---|---|---|
| 单体应用、无热部署 | 少量关心 | 类数基本恒定,泄漏罕见 |
| 插件化 / OSGi / 热部署 | 高度关心 | 加载器生命周期管理是设计问题 |
| 大量动态代理的 RPC 框架 | 关心 | 代理类生成量随接口数增长 |
| 脚本引擎跑用户代码 | 关心 | 每次编译生成新类 |
| 短生命周期批处理 | 不关心 | 进程退出即释放 |
一句话判断:只要你的系统里存在"第二个 ClassLoader",类卸载就是必须设计的生命周期问题,而不是运维参数问题。
10. 自测
- 类卸载的三个条件分别是什么,为什么它们必须同时满足?
- 为什么说卸载的单位是 ClassLoader 而不是单个类?
-XX:MetaspaceSize的语义是初始容量还是触发阈值?搞错会导致什么调参错误?VM.metaspace输出里哪几个字段能区分"加载器泄漏"与"单加载器类爆炸"?- 热部署场景里最常见的四条引用链是什么,为什么
ThreadLocal排第一? - 为什么
MaxMetaspaceSize没到也会抛OutOfMemoryError: Metaspace?