记一次 JVM 内存泄露排查:Metaspace OOM 的元凶竟是 Lambda
Debugging a JVM Memory Leak: Lambda Expressions as the Unexpected Metaspace OOM Culprit
| David Wang | 2026-08-11T08:38:43
线上服务每隔几天就 OOM 重启,排查发现是动态生成的 Lambda 类把 Metaspace 撑爆了。
Production service OOM every few days - investigation revealed dynamically generated Lambda classes were exhausting Metaspace.
## 症状 线上的订单服务每隔 3-5 天就会因为 OOM 被 K8s 杀掉重启。日志里的错误信息: ``` java.lang.OutOfMemoryError: Metaspace ``` Metaspace OOM?不是堆内存泄露,而是方法区满了。这就有意思了。 ## 初步排查 先看 JVM 参数: ``` -Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m ``` 256MB 的 Metaspace 对于一个普通 Spring Boot 应用来说绰绰有余。正常情况下 Metaspace 使用量在 100-150MB 左右。 加了监控后发现,Metaspace 使用量在缓慢而持续地增长,每天大约增长 30-40MB。3-5 天后就会达到 256MB 的上限。 ## 深入分析 用 `jcmd` 查看类加载情况: ```bash jcmd VM.classloader_stats ``` 发现已加载的类数量在持续增长: ``` 启动时: 12,345 个类 运行 1 天: 15,678 个类 运行 3 天: 23,456 个类 ``` 每天新增约 3000 个类!是什么在不断生成新类? 用 `jcmd GC.class_stats` 进一步分析: ``` 大量名为 $Lambda$xxx 的类,编号从 1 一直到 10000+ ``` Lambda 类!JVM 在运行时为每个 Lambda 表达式动态生成一个类。正常情况下这些类会被缓存和复用,但我们的场景不正常。 ## 定位元凶 排查代码后找到了罪魁祸首: ```java // 一个通用的重试工具 public T retry(Supplier action, int maxRetries) { for (int i = 0; i {}, 1, TimeUnit.SECONDS); // 新的 Lambda! scheduler.shutdown(); } } return null; } ``` 每次调用 `retry()` 方法并触发重试时: 1. 创建一个新的 `ScheduledExecutorService` 2. 用 Lambda `() -> {}` 注册一个空任务 3. JVM 为这个 Lambda 生成一个新类 更要命的是,这个 `retry()` 方法被用在了所有外部 API 调用中,每天调用量达数十万次。每次重试都会生成新的 Lambda 类,这些类不会被 GC 回收(因为 ClassLoader 引用链没有断),最终把 Metaspace 撑爆。 ## 修复方案 ```java // 修复:将 Lambda 提取为静态常量,全局复用 private static final Runnable NOOP = () -> {}; public T retry(Supplier action, int maxRetries) { for (int i = 0; i <= maxRetries; i++) { try { return action.get(); } catch (Exception e) { if (i == maxRetries) throw e; try { Thread.sleep(1000); // 简化延迟逻辑 } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } return null; } ``` 修复点: 1. 去掉了每次重试都创建 `ScheduledExecutorService` 的反模式 2. 用简单的 `Thread.sleep` 替代(这个场景不需要调度器) 3. 如果确实需要 Lambda,提取为 `static final` 常量 ## 修复后效果 | 指标 | 修复前 | 修复后 | |------|--------|--------| | Metaspace 增速 | ~35MB/天 | 0(稳定在 120MB) | | 类加载数量 | 持续增长 | 稳定在 ~13,000 | | OOM 频率 | 每 3-5 天 | 无 | ## 教训 1. **Lambda 不是没有成本的**:每个 Lambda 在首次执行时都会生成一个匿名类。如果在热路径上不断创建新的 Lambda 实例,会导致类爆炸 2. **Metaspace OOM 排查思路**:先看类加载数量是否在增长 → 找出持续生成的类 → 定位代码中的类生成来源 3. **热路径上避免创建短生命周期的线程池**:`Executors.newXxx()` 每次都会创建新的线程工厂,间接导致新类生成 4. **监控类加载数量**:在 Grafana 里加一个 `jvm_classes_loaded` 的面板,异常增长时告警
Production service OOM'd every 3-5 days due to Metaspace exhaustion. Root cause: retry utility creating new ScheduledExecutorService with a new Lambda on every retry attempt in hot API call paths. Each Lambda generates a new anonymous class that's never GC'd. Fix: extract Lambda to static final constant and replace scheduler with simple Thread.sleep. Metaspace growth dropped from 35MB/day to zero.