2026 年 2 月一个周四早上 9 点 14 分,我在地铁上被运维群里连续 11 条 @ 炸醒:核心订单查询服务 order-query 又 OOM 了,这是连续第 7 周的同一天同一时段。前 6 周我们都靠"周三晚上预防性 rolling restart"扛过去,这次有人忘了发起 cron,周四高峰直接被 8 个 Pod 一起 OOMKilled,P99 拉到 30 秒,客服群炸了 40 分钟。这一次必须把根因挖到底——日志里反复出现的 java.lang.OutOfMemoryError: Metaspace 不再是"调大一点就过去了"的小事。
后面 5 天,我们带着两个 SRE 和一个 JVM 专家把这个跑了 3 年多的 Spring Boot 服务从 ClassLoader 层翻了个底朝天,定位到的是一个所有人都忽略的细节:一个公司内部的数据脱敏 SDK,每次反射调用都用 CGLIB 动态生成一个新的代理类,生成的 Class 被一个匿名 ClassLoader 持有,这个 ClassLoader 又被 Spring 的 Bean 强引用——三层引用链锁死,Metaspace 里的类只增不减。这篇是完整复盘,包含我们走过的弯路、用过的工具链、5 种修法的取舍、以及最后立下的"Java 动态字节码使用纪律"。
服务背景:这个看起来人畜无害的查询服务
先把上下文交代清楚,后面所有判断都建立在这套环境上。这个服务从 2023 年 4 月上线后跑了将近 3 年,前两年没人注意到这个"每周重启"的细节——大家以为是"Java 服务嘛,定期重启正常"。直到 2025 年 11 月有个新来的 SRE 在交接文档里看到这条 cron,问了一句"为什么必须每周重启",才把这个被忽略的"窗户纸"捅破。
| 维度 | 数值 |
|---|---|
| 业务 | SaaS 订单查询服务,对 B 端商户提供订单列表/详情/导出 API |
| 规模 | 日均请求 2400 万,P99 80ms,峰值 QPS 6500 |
| 技术栈 | Java 17 + Spring Boot 3.1.5 + MyBatis 3.5 + Tomcat 10 |
| 部署 | K8s,12 个 Pod,每个 Pod 4 vCPU + 6GB 堆 + 384MB Metaspace |
| 依赖 | MySQL 8.0 主从、Redis 7.0、内部脱敏 SDK sec-mask-sdk:2.4.1 |
| JVM 参数 | -Xmx6g -Xms6g -XX:MaxMetaspaceSize=384m -XX:+UseG1GC |
| 事故前现象 | 启动 6 天后稳定 OOM Metaspace,每周三晚上手动 rolling restart 续命 |
事故时间线:从被踢醒到根因落地的 5 天
第 1 天救火,第 2-4 天定位,第 5 天验证收尾。时间线非常典型的"长期被掩盖的慢性病急性发作":
| 时刻 | 事件 |
|---|---|
| 02-12 02:00 | 每周三深夜的 rolling restart cron 因为运维同事请假忘了启动 |
| 02-12 ~ 02-14 | 服务持续运行 60 小时,Metaspace 缓慢上涨 |
| 02-15 09:14 | 高峰来临,12 个 Pod 中 8 个连续 OOM Metaspace,K8s 触发重建,但新 Pod 启动期间无法分担流量,P99 雪崩到 30s |
| 02-15 09:38 | 我从地铁赶到工位,临时把 MaxMetaspaceSize 从 384MB 调到 768MB,12 Pod 强制滚动重启,服务恢复 |
| 02-15 11:00 | 开线上故障会,决定不能再用"调大 + 定期重启"续命,启动根因排查 |
| 02-15 下午 | 挂上 jstat 和 jcmd 持续观测,GC log 加 -Xlog:gc*,classunload |
| 02-16 | 确认 Metaspace 增长是类只增不减,8 小时内加载 4800 个新 Class |
| 02-17 | 用 jcmd VM.classloader_stats 锁定到一个匿名 ClassLoader 持续暴涨 |
| 02-18 上午 | Heap dump + Eclipse MAT,定位到是 sec-mask-sdk 的 CGLIB Enhancer 在每次 reflective call 都新建 Class |
| 02-18 下午 | 翻 SDK 源码,找到 SDK 2.4.1 的一个已知 bug:Method 缓存 key 用了对象 hashCode,导致每次都 miss |
| 02-19 上午 | 升级 sec-mask-sdk 到 2.6.0(修了 Method 缓存),同时把脱敏调用包了一层手写的 invoker 兜底 |
| 02-19 下午 | 预发跑 24 小时压测,Metaspace 稳定在 220MB 不再涨,确认问题修复 |
| 02-20 | 分批灰度上线,移除 cron,事后落地"Java 字节码使用纪律"文档 |
第一反应:"Metaspace 不就是调大点的事吗"
必须承认,事故当天上午我自己心里也闪过这个念头。Java 8 之前我们对 PermGen 也是"调大点"的态度,移到 Metaspace 之后默认是"无上限,只受 native memory 限制",大多数人对 Metaspace 的理解还停留在"它存的就是 Class 的元数据,服务跑起来后该加载的都加载了,基本就不再涨"。这个心智模型在 80% 的服务上是对的,但对剩下 20% 大量使用动态代理/字节码增强的服务,完全不成立。
正是因为这个误解,我们做了三件错事:
| 错误决策 | 当时的理由 | 实际后果 |
|---|---|---|
| 2024 年 6 月把 Metaspace 从 256MB 调到 384MB | "调大一点不就行了" | 把 OOM 周期从 4 天延长到 7 天,问题没解决,只是延后 |
| 加了"周三晚上 rolling restart" cron | "反正凌晨没流量,重启不影响" | 掩盖了真正的内存泄漏 22 个月 |
| 事故告警把 Metaspace 阈值设到 90% | "Metaspace 用满才是问题" | 没人看到"缓慢上涨"这个早期信号 |
事后复盘最大的教训是:任何"需要定期重启才能维持运行"的 Java 服务,99% 是有未发现的内存泄漏。这不是工程上的妥协,这是技术债的利息。
真凶 1:Metaspace 里的"类"到底是什么
排查的第一步是搞清楚 Metaspace 里到底装了什么。很多人以为 Metaspace = Class 对象,其实 JVM 里跟一个 Class 相关的元数据分散在好几个地方。关键点:Metaspace 的回收单元不是单个类,而是整个 ClassLoader。只有当一个 ClassLoader 加载的所有类都没有任何 GC root 引用时,这个 ClassLoader 才能被卸载,它装在 Metaspace 里的所有元数据才会一起释放。这就解释了为什么"调大 Metaspace"治标不治本——真正的问题是 ClassLoader 卸载不掉,Metaspace 里的类只进不出。
验证这个推论的命令很简单:
jstat -gc <pid> 5s
# 关注 MC(Metaspace Capacity)和 MU(Metaspace Used)两列
# 如果 MU 持续单调上涨,无论 Full GC 多少次都不下降,基本就是 ClassLoader 泄漏
我们抓到的曲线非常典型:服务启动后 MU 从 130MB 涨到 240MB,中间触发了 4 次 Full GC,曲线只有几次微小的下挫(单字符串常量被回收的量),整体趋势单调向上。如果是正常的"启动期加载完成后稳定",MU 应该是一条进入平台期的水平线。
真凶 2:jcmd 帮我们看到了"4800 个匿名 ClassLoader"
有了"ClassLoader 泄漏"的假设,下一步是找出谁在持续创建 ClassLoader。Java 17 自带的 jcmd 工具有一组非常好用的命令,日常排查 Metaspace 问题几乎够用:
jcmd <pid> VM.metaspace # 整体使用 + 按 ClassLoader 分组
jcmd <pid> VM.classloader_stats # 列出所有 ClassLoader 及加载类数
jcmd <pid> VM.classloaders show-classes # 展开具体类名(信息量大)
jcmd <pid> GC.class_histogram # 按类统计实例数,反推热点
jcmd <pid> VM.native_memory summary # native 内存分布(需 NMT 开启)
我们在 Pod 启动后 8 小时和 24 小时各抓了一次 VM.classloader_stats,做了一个 diff:
启动后 8 小时:
- ClassLoader 总数:412
- 其中 "+++ unnamed" (匿名,通常是动态生成的) 数量:1840
- Metaspace 已用:178 MB
启动后 24 小时:
- ClassLoader 总数:414(仅多了 2 个,可能是 JDK 内部的)
- 其中 "+++ unnamed" 数量:5460
- Metaspace 已用:294 MB
结论:命名 ClassLoader 几乎不变,匿名 ClassLoader 每小时新增约 230 个
这个 diff 直接锁定了方向:有人在持续创建匿名 ClassLoader,而且和业务请求量大致成正比。Java 里能批量产生匿名 ClassLoader 的场景就那么几个:动态代理(JDK Proxy / CGLIB / ByteBuddy)、Groovy/JRuby 等脚本引擎、热部署框架(JRebel/spring-boot-devtools)、Lambda 表达式(早期 JDK 版本有泄漏 bug)。剩下唯一可疑的就是动态代理。
问题本质:三层强引用锁死
翻 SDK 源码 + Eclipse MAT 看引用链,定位到的是 CGLIB Enhancer 每次新建 + Method 缓存 hashCode 不稳定的双重作用。三层引用关系如下:
关键之处:每次脱敏拦截都因为 Method 的 declaringClass 不稳定(MyBatis 一二级缓存命中与否会影响)导致 ConcurrentHashMap key 不一致,缓存 miss → 新建一个 CGLIB Enhancer → 新生成一个 Class → 挂在新的匿名 ClassLoader 上。这套引用链是 Bean → Map → Method → CGLIB Class → ClassLoader,Spring 容器是单例不会回收,整条链一直挂着,Metaspace 慢慢被吃光。
修法 1:升级 SDK 到 2.6.0
主方案是升级 sec-mask-sdk:2.6.0 修复了 Method 缓存 key 的问题(改用 method.toString() 作为 key,稳定):
<dependency>
<groupId>com.company</groupId>
<artifactId>sec-mask-sdk</artifactId>
<version>2.6.0</version>
</dependency>
升级后 SDK 内部缓存命中,只在启动期生成有限的几个 CGLIB Class(每种 Method 一个),之后稳定。
修法 2:手写 MethodHandle invoker 作 plan B
升级 SDK 是主方案,但万一某个边缘 case 又冒出 bug,需要 plan B。我们额外写了一层基于 MethodHandle 的 invoker,完全绕开 CGLIB:
public class StableMaskInvoker {
// 注意:cache key 用 (Class, methodName, paramTypes) 三元组
// 不依赖 Method 实例的 hashCode
private static final ConcurrentHashMap<CacheKey, MethodHandle> HANDLE_CACHE
= new ConcurrentHashMap<>();
public Object invoke(Object target, String methodName,
Class<?>[] paramTypes, Object[] args) throws Throwable {
CacheKey key = new CacheKey(target.getClass(), methodName, paramTypes);
MethodHandle handle = HANDLE_CACHE.computeIfAbsent(key, k -> {
try {
Method m = k.targetClass.getDeclaredMethod(k.methodName, k.paramTypes);
m.setAccessible(true);
return MethodHandles.lookup().unreflect(m);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
return handle.bindTo(target).invokeWithArguments(args);
}
private record CacheKey(Class<?> targetClass, String methodName, Class<?>[] paramTypes) {
@Override public int hashCode() {
return Objects.hash(targetClass, methodName, Arrays.hashCode(paramTypes));
}
@Override public boolean equals(Object o) {
if (!(o instanceof CacheKey k)) return false;
return targetClass == k.targetClass
&& methodName.equals(k.methodName)
&& Arrays.equals(paramTypes, k.paramTypes);
}
}
}
这个 wrapper 在预发跑了 12 小时,Metaspace 完全平稳(MU 维持在 218 MB ± 2MB)。即使 sec-mask-sdk 升级后又冒出新 bug,这一层兜底也能让线上不至于失控。
修法 3:加 Metaspace 监控 + 趋势告警
原来我们告警只盯绝对值(> 90%),太晚。事故后改成三层告警体系:
# Prometheus 告警规则
groups:
- name: jvm-metaspace
rules:
- alert: MetaspaceSlowGrowth
expr: increase(jvm_memory_used_bytes{area="nonheap",pool="Metaspace"}[6h]) > 30 * 1024 * 1024
for: 1m
labels: { severity: P3 }
annotations: { description: "Metaspace 6h 增长 > 30MB,疑似 ClassLoader 泄漏" }
- alert: MetaspaceHigh
expr: jvm_memory_used_bytes{pool="Metaspace"} / jvm_memory_max_bytes{pool="Metaspace"} > 0.7
for: 30m
labels: { severity: P2 }
- alert: MetaspaceCritical
expr: jvm_memory_used_bytes{pool="Metaspace"} / jvm_memory_max_bytes{pool="Metaspace"} > 0.85
for: 5m
labels: { severity: P1 }
P3 这一层是新加的"早期信号"——它不是为了让人去处理,而是为了让人知道有这么个变化。我们 02-15 复盘时拉了过去 6 个月的 Metaspace 监控,事后看曲线一直在缓慢上涨,如果当时有 P3 告警,完全可以在第一周就发现。
5 种修法的取舍
定位到根因后,摆在桌面上的修复选项有 5 种,每个都有自己的代价:
| 方案 | 改动范围 | 风险 | 解决程度 | 我们的选择 |
|---|---|---|---|---|
| ① 升级 sec-mask-sdk 到 2.6.0(官方已修) | 1 行 pom.xml | 中(2.6 改了 API 签名,需要适配 4 个调用点) | 根本解决 | ✅ 主方案 |
| ② 自己 fork SDK,把 Enhancer 改成复用 | SDK 源码,发内部包 | 高(维护 fork,合并官方升级麻烦) | 根本解决 | ❌ 维护成本高 |
| ③ 把 sec-mask-sdk 整体换成 ByteBuddy | SDK 重写 | 极高(3 周工作量) | 根本解决,性能更好 | ❌ 不值得为一个 bug 重写 |
| ④ 手写 invoker 兜底,绕开 SDK 的 Enhancer 路径 | 新增 1 个 wrapper | 中(改了热路径) | 绕开问题 | ✅ 辅助方案(plan B) |
| ⑤ 继续调大 Metaspace + 定时重启 | 0 | 0(短期) | 不解决,长期累积 | ❌ 已经这么干了 22 个月 |
最终选了 ① + ④ 的组合:升级 SDK 解决根因,同时手写一层 invoker 作为 plan B——一旦升级出问题可以快速切回。
决策树:你的服务该用哪种动态代理方案
事后整理:不同场景适合不同方案,选错了就是"提前埋雷"。流程图给到读者参考:
沿途收集到的 6 类"类型爆炸"反模式
修这次 bug 的过程里,我们顺便扫描了整个 monorepo,把所有可能引起 Metaspace 问题的模式分类整理:
| 反模式 | 问题 | 修法 |
|---|---|---|
| 每次反射调用都 new Enhancer | 每次生成新 CGLIB Class,匿名 ClassLoader 堆积 | 用 WeakHashMap<Class,Enhancer> 复用 |
| 把动态生成 Class 缓存在 static field | Class 永远不被 GC,泄漏 | 用 ClassValue 或 WeakReference |
| Spring AOP 大量动态切入 | 每个切入点一个代理类 | 显式启用 spring.aop.proxy-target-class 复用 |
| 用 Groovy/Nashorn 当 DSL 引擎 | 每个 script 都新建 ClassLoader | 限制 script 数 + 定期销毁 |
| JDK 8 Lambda 在某些路径下泄漏 | JDK 8u91 之前的 bug | 升级 JDK 8u201+ 或 JDK 17 |
| 用 Mockito 在生产代码里 | 测试库不该上生产 | 移除生产依赖 |
我们立的 8 条 Java 字节码使用纪律
- 生产代码默认禁用 Enhancer.create() 直接调用。需要动态代理,优先用 JDK Proxy;确实需要继承式增强,必须封装到统一 ProxyFactory,内部用 WeakHashMap 复用 Enhancer。
- 反射调用的 Method 必须缓存,缓存 key 不允许直接用 Method 实例,要用 (Class, methodName, paramTypes) 三元组。
- 任何新引入的依赖如果包含字节码生成(pom.xml 引入 cglib / byte-buddy / asm),走架构评审 + 7 天压测,确认 Metaspace 平稳。
- JVM 参数模板:所有 Spring Boot 服务必须配 -XX:MaxMetaspaceSize(强制上限,出问题快速 fail)+ -Xlog:gc*,classunload + -XX:NativeMemoryTracking=summary。
- 监控必须覆盖三层:绝对值告警 + 趋势告警(6 小时增长 30MB)+ ClassLoader 数量告警。
- 禁止用"定时重启"掩盖内存问题。任何 cron 重启都需要在 wiki 标注理由 + 找根因 deadline,3 个月不解决要走故障升级。
- 核心服务定期跑 NMT diff(每周),对比 baseline 和当前 native memory 分布,异常增长立刻排查。
- Heap dump 工具链常备:线上每个 Pod 配好 jcmd + Eclipse MAT,事故时不用临时找。
事故对团队的二阶影响
修完技术问题之后,这次事故在公司层面引发了一连串的二阶讨论。第一个是"还有多少类似的'定期重启续命'藏在其他服务里"。我们组织运维同事拉了过去 90 天所有定时重启 cron 的清单,合计 47 个微服务里有 11 个有"周期性重启"的脚本——绝大多数是"前任工程师离职前留下的临时方案",当时的理由早就没人记得。我们把这 11 个服务标为"3 个月内必须找到根因或证明无需重启",立 SLA 跟进。3 个月后的回顾结果让人惊讶:7 个是真实的内存泄漏(类似本次)、2 个是连接池泄漏、1 个是日志文件无清理导致磁盘满、还有 1 个真的是"启动期占用资源最低,跑久了高"但属于业务正常行为——我们也据此清理了 cron。
第二个二阶讨论是"我们的故障告警体系是不是太迟"。复盘时我们拉了过去 6 个月的 Metaspace 趋势图,事后看是一条非常缓慢但单调上涨的曲线——平均每天涨 30-40MB。如果有"30 天增长趋势"的告警,任何一个月都能提前发现,而不是等到 OOM 才被动响应。从此我们把所有关键 JVM 指标都加了"趋势型告警"(7 天 / 30 天的回归斜率),作为"绝对值型告警"(到达阈值才报)的补充。
第三个是"团队对 Java 内存模型的理解是不是足够"。复盘后我组织了一次内部分享,主题是"Java 内存的非堆角落",讲 Metaspace / CodeCache / DirectBuffer / Mapped Files / Thread Stack 这 5 块的行为差异和监控方式。30 多个后端工程师参加,会后做的小测验里,关于"Metaspace 回收单元是什么"这一题正确率只有 35%——这是个集体的知识盲区。我们把这块内容补到新人入职 onboarding 的必修材料里,后面新人都不至于踩同样的坑。
同类事故在业内其他公司的版本
把这个根因总结出来之后,我顺手在几个技术 Slack channel 和我们的同行群里问了下"你们有没有类似经历",收到了不少回应,模式高度类似:
| 公司类型 | 触发场景 | 根因 | 暴露方式 |
|---|---|---|---|
| 大型电商 | 促销活动新增促销规则引擎 | 每条规则一个 Groovy 脚本,每个脚本一个 ClassLoader | 大促当晚 Metaspace OOM |
| 金融风控 | 新增"动态决策树"模块 | 每条决策树编译成新 Class | 3 个月后第一次见 Metaspace 不够 |
| SaaS 配置中心 | 支持租户自定义脚本 | 每个租户一个 ScriptEngine 实例,共享 SharedClassLoader 失效 | 租户数增长后 Metaspace 累积 |
| 大数据平台 | Spark on Yarn 长期不重启 driver | driver 内部累积大量动态 Class | driver OOM 跑不完任务 |
| 移动游戏后端 | 用 Drools 引擎做实时奖励规则 | Drools KieBase 重建每次产生新 ClassLoader | 每周固定时段服务卡顿 |
共同点都是:"为了灵活性引入了字节码动态生成",而"灵活性"的代价就是 Metaspace 累积。这不是某个特定 SDK 的 bug,是一类设计的固有问题。如果你的项目里出现"动态生成 Class"这个特征,基本就要提前规划 Metaspace 监控。
事故后我们对所有 Java 服务做的 7 步自检
把这套排查方法论沉淀成一份 wiki 文档,任何团队都可以照着对自己的 Java 服务做一遍自检。这 7 步从"5 分钟快速检查"到"半天深度排查"递进:
- 看运维 cron 列表:有没有针对这个服务的"定期重启"任务?有就基本可以怀疑有泄漏。
- 看 Pod 启动后 24 小时的 Metaspace 曲线(jstat -gc 60s):应该是"陡升然后平稳",如果是"持续缓慢上涨"就是泄漏。
- 看 ClassLoader 数量(jcmd VM.classloader_stats):应该和服务启动后稳定,任何"持续增长"都是问题。
- 看 "+++ unnamed" 数量(jcmd VM.classloader_stats 输出里):匿名 ClassLoader 数量持续增长是动态生成 Class 的强信号。
- 跑 Heap dump + MAT:看 ClassLoader 实例的 path to GC roots,定位是谁持有它们。
- 跑 NMT diff(VM.native_memory baseline 然后 summary.diff):看 native 各块的真实增长。
- 翻 GC 日志(-Xlog:gc*,classunload):看有没有"Class unloaded"事件——正常服务应该有 (因为有些 ClassLoader 在 GC 后能被卸载),完全没有就是说明所有 ClassLoader 都被强引用着。
给读者的具体行动清单
看完这篇的读者,如果想立刻在自己项目里做一遍排查,可以按下面的顺序跑。每一步耗时几分钟到几小时,但能有效暴露隐藏的 Metaspace 风险:
第一步:打开你团队的运维 wiki 或 cron 配置,搜索关键字"restart"、"重启"、"recycle"。把所有"定期重启服务"的任务列出来。每一条都问相关负责人:"这条 cron 是为了解决什么问题?根因找到了吗?" 大多数人答不上来——这些就是技术债的清单。
第二步:在测试环境上一个 Java 服务,装好 jcmd + Eclipse MAT(或 VisualVM),让它跑 24 小时。每 6 小时跑一次 jcmd <pid> VM.classloader_stats > cl_stats_${HOUR}.txt,然后 diff 看 ClassLoader 总数和 "+++ unnamed" 数量的变化。如果稳定就是健康的,如果有增长就是有泄漏。
第三步:看你 Spring 项目里都用了哪些"字节码增强"类的库。常见的有 Spring AOP / Hibernate / MyBatis-Plus / Mockito / Lombok。前面几个用得好都没问题,但有些不知名的内部 SDK或第三方组件可能就有坑。grep pom.xml(或 build.gradle)里有没有 cglib / byte-buddy / asm / javassist 这些字节码处理库的直接或间接依赖,有的话单独 review 它们的用法。
第四步:把 JVM 启动参数加上 -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation -Xlog:gc*,classunload:file=/var/log/gc.log:time,uptime,level,tags,然后跑一段时间。看 gc.log 里有没有 "Class unloaded" 的日志。完全没有是说明所有 ClassLoader 都被强引用着,大概率有泄漏(正常情况下,JIT 编译过期 / dynamic class 销毁等场景都会偶尔产生 Class unload 事件)。
第五步:测试环境模拟"长时间运行 + 真实流量",至少跑 72 小时(覆盖一个完整业务周期),记录 Metaspace 曲线。如果是单调上涨,即使 72 小时没 OOM,生产上跑几周也会出问题。
什么是"工程化的可观测性"
这次事故让我想起一个老话题:"可观测性"和"监控"的区别。监控是知道"系统 down 了",可观测性是知道"系统为什么会 down"。Java 服务的"标准监控"(CPU / 内存 / QPS / 延迟)在这次事故里全部绿色,完全没有提前预警——因为 Metaspace 这块从未被纳入"标准监控"清单。可观测性的本质是能问出有意义的问题:"过去 30 天 ClassLoader 数量怎么变化?"、"哪些 Class 是动态生成的?"、"为什么这个 ClassLoader 不能被回收?"——这些问题需要更深层的 metrics + traces + logs 配合才能回答。
过去我们的可观测性建设主要在应用层(打 trace、加 log),JVM 层基本就是"看几个 GC 大盘"。事故之后我们补了一整套JVM 深度可观测性体系:Metaspace 趋势 / ClassLoader 数量趋势 / Native Memory 分布 / Thread 状态分布 / GC pause 分布 / CodeCache 使用率 / DirectBuffer 使用 — 8 个维度,做了 Grafana 面板,每个值班同事入职时都要学会看。这套面板上线后的 6 个月里,我们提前发现了另外 4 个类似的"潜在泄漏"问题,在 P1 故障之前修了。
总结
"周三晚上 cron 续命"持续了 22 个月,代价是真相一直被掩盖。事故那天产品同事问了一个让我刺痛的问题:"为什么这种问题能存在这么久没人查?"答案是:cron 续命的成本是分散在每周一晚上的运维同事,而真正修复的成本是集中的、有名字的一周——前者没人付,后者没人愿意付。这次的 P0 故障终于让"修"成为了 less painful 的选项。
这次事故让我对 Java 内存模型有了刻骨的认识:JVM 内存不只是堆。Metaspace / CodeCache / DirectBuffer / Mapped Files 这些"角落"在大型项目里都可能成为崩溃点,而它们的监控和心智模型在大多数团队是缺失的。建立完整的 JVM 内存可观测性是每个 Java 后端团队的基础能力,不是 P1 故障后才补的功课。
下次再有人在群里说"我们的 Java 服务好像需要定期重启",别再点头说"正常",一起去找根因——多半你能挖到一个有意思的故事。
读者最常问的几个问题
这篇内部分享出去之后,收到读者最多的几个问题,统一在这里答一下:
问:Metaspace 设多少合理? 我们的经验值是:启动后稳定状态的 1.5-2 倍。比如服务跑稳后 Metaspace 用 200MB,设 400MB 上限合理。设太小容易触发 OOM,设太大掩盖问题——动态 Class 泄漏的服务即使设 8GB Metaspace,几个月内一样会满。
问:Java 21 / Java 25 在 ClassLoader 这块有什么改进? Java 21 对 ZGC 和 G1 都增强了 Class unloading 的并发性,理论上能更快回收;Java 25 的 JEP 491(synchronized 不再 pin 虚拟线程)也间接缓解了某些场景下的 ClassLoader 持有问题。但"持续创建匿名 ClassLoader" 这个反模式在任何 Java 版本上都是问题——再快的 GC 也追不上无限增长的速度。
问:CGLIB 和 ByteBuddy 在 ClassLoader 处理上有什么区别? CGLIB 默认每个 Enhancer.create() 都新建 InternalClassLoader(除非显式 setClassLoader 强制复用);ByteBuddy 默认是"重用 target class 的 ClassLoader",不会创建新的。所以新项目优先选 ByteBuddy。但是 Spring 5.x 内部仍然广泛用 CGLIB,因为历史原因短期不会切。
问:除了 Metaspace,Java 还有哪些"隐藏"的内存区域? CodeCache(JIT 编译后的机器码,默认 240MB)、DirectByteBuffer(NIO 堆外缓冲)、Mapped Files(mmap 文件)、Thread Stack(每线程 1MB)、JNI 分配的内存(C 库 malloc)。这 5 块加上堆和 Metaspace,构成 Java 进程的完整内存图景。Native Memory Tracking(NMT)可以看到全部分布。
问:如果我们公司没有"找 SDK 作者改"的能力(用了第三方闭源 SDK)怎么办? 这种情况下,plan B(手写 wrapper 绕开问题路径)是最现实的方案。我们文章里的 StableMaskInvoker 就是这个思路——业务层包一层"自己控制"的接口,出问题不依赖外部 SDK 更新。这种"防御性 wrapper"在使用大量第三方依赖的项目里非常有价值。
欢迎在评论区留下你们的类似经历——尤其是那些"被定期重启掩盖了几个月才找到根因"的故事。这种知识只能靠工程师之间互相分享才能传承。
JVM Metaspace 历史小考据
顺便讲个有意思的背景:Metaspace 是 Java 8(2014 年)引入的,替代 Java 7 及之前的 PermGen(Permanent Generation,永久代)。引入的动机是PermGen 是堆的一部分,大小固定且必须预设;移到 Metaspace 后变成 native memory,默认无上限,理论上"再也不会 PermGen OOM 了"。这个改变在大多数应用上确实是巨大的进步——开发者不再需要"猜 PermGen 该设多大"。
但代价是:"不会 OOM"反过来掩盖了真正的泄漏。PermGen 时代,任何 Class 持续加载都会很快 PermGenOOM,问题立即暴露;Metaspace 时代,同样的泄漏要长达几周到几个月才表现出来(直到 native memory 耗尽或者你显式设了上限触发 Metaspace OOM)。我们这次事故的 22 个月延迟暴露,部分原因正是 Metaspace 的"宽容性"——它太"懒"提醒你出问题了。
这是一个有趣的工程教训:"消除特定错误模式"和"提供更好的可观测性"是两件事,前者做了未必能做后者。Metaspace 解决了"PermGen 大小怎么设"的问题,但没有同时引入"Metaspace 持续增长的早期信号"。这种"治标不治本"的设计在工程里反复出现——下次你看到一个"它再也不会出 X 错了"的新机制,问一句:"那如果它仍然在路上出错,我们怎么知道?"
团队复盘时的争论
这次事故的线上复盘会很有意思,争论焦点不是技术,是责任归属。三方意见:
SDK 维护团队认为:"我们 2.6 已经修复了,你们用 2.4 是你们的版本管理问题。" — 站在他们立场没错,但 SDK 升级要业务团队主动 review changelog 并验证,工作量不小。
业务团队认为:"我们引入 SDK 时它是稳定版本,后来 SDK 团队修了 bug 没通知我们,该 SDK 团队主动推广升级。" — 也有道理,内部 SDK 应该有主动的"breaking fix 通知"机制。
平台团队认为:"两边都没错。问题是我们没有'JVM Metaspace 增长'的自动告警,任何团队都可能踩这个坑。应该补的是平台层面的统一告警 + 自动 audit。" — 这是最有建设性的观点。
最后落地的"修补方案"也是按平台团队的思路:在统一观测平台加 Metaspace 趋势监控,所有 Java 服务自动接入;架构组定期 audit 哪些服务用了字节码生成库;SDK 团队加 changelog 强制 notification 流程。个人责任归属永远是事后讨论,工程层面的修补应该让"个人不犯错"成为不可能。这是好团队和差团队的分水岭——差团队事故后找罪人,好团队事故后立机制,这次复盘后整个公司的工程文化又往前推进了一小步。
—— 别看了 · 2026