[BUG]ObjectWriterProvider.getObjectWriterInternal() 对 Guava Multimap 类型的 ObjectWriter 未写入缓存,高频序列化导致 Metaspace 泄漏
- Dominant language
- Java
- Stars
- 4.4k
- Forks
- 613
- Avg merge
- 1d 22h
- Merged PRs (30d)
- 6
Description
### 问题描述
`ObjectWriterProvider.getObjectWriterInternal()` 方法中,switch/case 块为 Guava Multimap 类型(`LinkedListMultimap`、`ArrayListMultimap`、`HashMultimap`、`LinkedHashMultimap`、`TreeMultimap`)通过 `GuavaSupport.createAsMapWriter()` 创建了 `ObjectWriter`,但创建后**没有调用 `cache.putIfAbsent()` 写入缓存**。
缓存写入 `cache.putIfAbsent()` 位于后续的 `if (objectWriter == null)` 分支中(line 718),而此时 `objectWriter` 已被 switch 赋值为非 null,因此该分支永远不会执行。
这导致每次调用 `JSON.toJSONString(guavaMultimap)` 都会重新创建 `AsMapWriter`,其构造函数通过 `LambdaMiscCodec.createFunction()` → `LambdaMetafactory.metafactory()`(`invokestatic` 调用,无 JVM 级 `ConstantCallSite` 缓存)生成新的 Lambda Hidden Class。这些 Hidden Class 被 ClassLoader 持有,无法被 GC 回收,导致 Metaspace 持续线性增长。
在我们的生产环境中(MQ 驱动的订单刷新流程,每分钟调用 10~ 15 次 `JSON.toJSONString(LinkedListMultimap)`),Metaspace 以约 200MB/天的速度增长,2~3 天耗尽 1024MB 上限。
### 环境信息
- OS信息:Linux 4C8G 容器 / macOS Darwin 24.1.0(本地复现)
- JDK信息:OpenJDK 21
- 版本信息:Fastjson2 2.0.57(生产)/ 2.0.61(main 分支最新代码仍存在相同问题)
### 重现步骤
1. 对 Guava `LinkedListMultimap` 对象高频调用 `JSON.toJSONString()`
2. 通过 `jstat -class 1000` 观察 Loaded 类数
```java
import com.alibaba.fastjson2.JSON;
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.Multimap;
import java.util.concurrent.TimeUnit;
public class MetaspaceLeak {
public static void main(String[] args) throws Exception {
for (int i = 0; i < 100; i++) {
for (int j = 0; j < 100; j++) {
Multimap map = LinkedListMultimap.create();
map.put("key", "value");
JSON.toJSONString(map);
}
TimeUnit.SECONDS.sleep(1);
}
}
}
```
`jstat -class 1000` 输出:
```
Loaded Bytes Unloaded Bytes Time
1709 3906.5 0 0.0 0.49
1809 3991.7 0 0.0 0.49
1909 4076.8 0 0.0 0.50
2009 4162.0 0 0.0 0.50
2109 4247.1 0 0.0 0.50
...
```
**Loaded 每秒稳定增长 100 个类(与每秒调用 `JSON.toJSONString` 100 次一致),Unloaded 始终为 0。**
### 期待的正确结果
switch/case 块创建的 `ObjectWriter` 应被写入缓存。后续调用时,`getObjectWriter()` 的外层 `cache.get()` 能直接命中,不再进入 `getObjectWriterInternal()` 重复创建 `AsMapWriter` 和 Lambda Hidden Class。`jstat -class` 应在预热完成后趋于稳定,不再持续增长。
### 相关日志输出
开启 `jcmd VM.log what=class+load=info` 后,持续加载的类全部为:
```
[13:50:06.414] com.google.common.collect.LinkedListMultimap$$Lambda/0x00007f1553494b48 source: com.google.common.collect.LinkedListMultimap
[13:50:06.442] com.google.common.collect.LinkedListMultimap$$Lambda/0x00007f1553494d90 source: com.google.common.collect.LinkedListMultimap
[13:50:06.549] com.google.common.collect.LinkedListMultimap$$Lambda/0x00007f1553494fd8 source: com.google.common.collect.LinkedListMultimap
```
每条日志中的 Lambda 类地址各不相同,说明每次调用都生成了全新的 Hidden Class 定义。
#### 附加信息
**根因定位**:`ObjectWriterProvider.getObjectWriterInternal()` 中 switch 块(处理 Guava Multimap 等特殊类型)与后续通用路径的缓存写入逻辑不一致:
```java
// line 686-709: switch 块创建 objectWriter 但没有缓存写入
switch (className) {
case "com.google.common.collect.LinkedListMultimap":
objectWriter = GuavaSupport.createAsMapWriter(objectClass);
break;
// ...
}
// line 718-731: 缓存写入在这里,但 objectWriter 已非 null,永远不会执行
if (objectWriter == null) {
objectWriter = creator.createObjectWriter(...);
cache.putIfAbsent(objectType, objectWriter); // 只有这里写缓存
}
```
**修复建议**:在 switch 块之后、ExtendedMap 检查之前,增加缓存写入逻辑:
```java
if (objectWriter != null) {
ObjectWriter previous = fieldBased
? cacheFieldBased.putIfAbsent(objectType, objectWriter)
: cache.putIfAbsent(objectType, objectWriter);
if (previous != null) {
objectWriter = previous;
}
return objectWriter;
}
```
**受影响类型**:switch 块中的所有类型均受此 bug 影响,其中 5 种 Guava Multimap 类型(`LinkedListMultimap`、`ArrayListMultimap`、`HashMultimap`、`LinkedHashMultimap`、`TreeMultimap`)因 `LambdaMetafactory` 生成 Hidden Class 会导致 Metaspace 泄漏;`fastjson.JSONObject`、ClickHouse `UnsignedLong` 不会产生 Metaspace 泄漏但存在不必要的重复对象创建开销。
Contributor guide
Research direction
Start at ObjectWriterProvider.getObjectWriterInternal() and inspect the Guava Multimap cases alongside the later cache.putIfAbsent() path. Run the supplied LinkedListMultimap serialization loop and observe loaded classes with jstat -class. Done means the relevant writers are cached, repeated serialization reuses them, and class loading stabilizes after warm-up.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100