2441 字
约 8 分钟
15
S05-X13 · 类卸载的条件与元空间回收
2024-12-31
2026-11-08

服务跑了一周,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. 自测

  1. 类卸载的三个条件分别是什么,为什么它们必须同时满足?
  2. 为什么说卸载的单位是 ClassLoader 而不是单个类?
  3. -XX:MetaspaceSize 的语义是初始容量还是触发阈值?搞错会导致什么调参错误?
  4. VM.metaspace 输出里哪几个字段能区分"加载器泄漏"与"单加载器类爆炸"?
  5. 热部署场景里最常见的四条引用链是什么,为什么 ThreadLocal 排第一?
  6. 为什么 MaxMetaspaceSize 没到也会抛 OutOfMemoryError: Metaspace?
S05-X13 · 类卸载的条件与元空间回收
https://www.clxhxhhr.top/posts/5764/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。