OOM 排查复盘:Hutool 序列化 Request 导致 Java Heap Space
1. 问题现象
生产日志中出现堆内存溢出:
text
java.lang.OutOfMemoryError: Java heap space
主要异常时间点:
text
2026-06-02 09:46:21
2026-06-02 09:46:40
2026-06-02 09:56:59
2026-06-02 10:01:56
2026-06-02 10:04:32
2026-06-02 10:10:01
2026-06-02 10:12:00
2026-06-02 11:11:09
2026-06-02 11:12:44
日志中多次出现接口:
text
/xxxxx
其中第一次明确 OOM 栈如下:
text
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3689)
at java.util.ArrayList.grow(ArrayList.java:238)
at java.util.ArrayList.add(ArrayList.java:486)
at cn.hutool.json.JSONArray.add(JSONArray.java:388)
at cn.hutool.json.JSONArray.init(JSONArray.java:633)
at cn.hutool.json.JSONUtil.wrap(JSONUtil.java:787)
at cn.hutool.json.JSONObject.set(JSONObject.java:359)
at cn.hutool.core.bean.copier.BeanToMapCopier.copy(BeanToMapCopier.java:49)
at cn.hutool.core.bean.BeanUtil.beanToMap(BeanUtil.java:689)
这说明堆溢出发生在 Hutool JSON 序列化对象时。
2. 涉及代码
问题代码位于操作日志切面:
java
private void setRequestValue(JoinPoint joinPoint, OperLog operLog) {
Map<String, String[]> paramsMap = httpServletRequest.getParameterMap();
if (paramsMap.isEmpty()) {
Object[] args = joinPoint.getArgs();
String params = JSONUtil.toJsonStr(args);
operLog.setOperParam(params);
} else {
operLog.setOperParam(JSONUtil.toJsonStr(paramsMap));
}
}
登录接口是 @RequestBody 请求:
java
public Response<LoginVo> login(@Validated @RequestBody LoginDto loginDto,
HttpServletRequest servletRequest) {
return sysUserService.login(loginDto);
}
由于 @RequestBody JSON 参数不会进入:
java
httpServletRequest.getParameterMap()
所以 paramsMap.isEmpty() 为 true,代码会执行:
java
Object[] args = joinPoint.getArgs();
JSONUtil.toJsonStr(args);
而 args 中包含:
text
LoginDto
HttpServletRequest
Hutool 会把 HttpServletRequest / TongWeb 内部 Request 当成普通 Bean 递归展开,最终创建大量 JSONObject、JSONArray、ArrayList、Byte 对象,导致堆被撑爆。
3. HPROF 分析结果
使用 heap dump:
text
D:\xxxxx\doc\heap202606021028.hprof
对象统计中发现:
text
java.lang.Byte 87,997,414 个
java.lang.Object[] retained heap 约 862.6 MB
byte[] payload 约 123.3 MB
cn.hutool.json.JSONObject 10,331 个
cn.hutool.json.JSONArray 3,814 个
关键不是 JSONObject 数量特别大,而是 Hutool 在展开 Request 内部字节缓冲、请求结构时,把大量 byte 数据包装成了 java.lang.Byte 对象,并放进 JSONArray / ArrayList。
4. MAT 中的关键证据
在 MAT 的 Leak Suspects 或对象引用链中,找到可疑对象:
text
cn.hutool.json.JSONObject @ 0xa004b4e0
查看 Incoming References 后发现它被 Hutool copier 持有:
text
target cn.hutool.core.bean.copier.BeanToMapCopier
再对 BeanToMapCopier 查看:
text
List Objects -> with outgoing references
看到关键字段:
text
source com.tongweb.coyote.Request
target cn.hutool.json.JSONObject
targetType class cn.hutool.json.JSONObject
copyOptions cn.hutool.core.bean.copier.CopyOptions
这就是最关键证据。
它说明 Hutool 正在执行:
text
com.tongweb.coyote.Request -> cn.hutool.json.JSONObject
也就是把 TongWeb 的底层 Request 对象转换成 JSON。
这与 LogAspect 中的:
java
JSONUtil.toJsonStr(joinPoint.getArgs())
完全吻合。
5. Incoming References 和 Outgoing References 怎么看
Incoming References
含义:
text
谁引用了当前对象
用于回答:
text
这个对象为什么还没有被 GC?
是谁持有了它?
例如:
text
JSONObject <- BeanToMapCopier.target <- Java Local TaskThread
表示该 JSONObject 被 BeanToMapCopier 持有,而 BeanToMapCopier 又被请求线程局部变量持有。
Outgoing References
含义:
text
当前对象引用了谁
用于回答:
text
这个对象内部装了什么?
它正在处理哪个 source?
例如对 BeanToMapCopier 查看 outgoing references:
text
source com.tongweb.coyote.Request
target cn.hutool.json.JSONObject
copyOptions ...
这能直接说明 Hutool 正在把 Request 转成 JSONObject。
6. 标准排查步骤
步骤 1:先看日志中的 OOM 栈
搜索:
text
OutOfMemoryError
Java heap space
JSONUtil
JSONArray
JSONObject
BeanToMapCopier
如果看到:
text
cn.hutool.json.JSONArray
cn.hutool.json.JSONObject
cn.hutool.core.bean.copier.BeanToMapCopier
说明 OOM 与 Hutool JSON 对象转换高度相关。
步骤 2:打开 MAT 的 Leak Suspects
查看 Problem Suspect 中的大对象和线程栈。
如果看到请求线程、Hutool JSON、BeanToMapCopier,继续展开。
步骤 3:看 Dominator Tree
按 Retained Heap 排序,重点看:
text
java.lang.Object[]
java.util.ArrayList
java.lang.Byte
cn.hutool.json.JSONArray
cn.hutool.json.JSONObject
不要只盯单个 Byte,应优先找承载它们的 Object[]、ArrayList、JSONArray。
步骤 4:看 Incoming References
对大对象右键:
text
List Objects -> with incoming references
找到是谁持有该对象。
步骤 5:看 Outgoing References
如果发现上游是:
text
BeanToMapCopier
则对它右键:
text
List Objects -> with outgoing references
重点查看:
text
source
target
targetType
copyOptions
如果 source 是:
text
com.tongweb.coyote.Request
com.tongweb.catalina.connector.Request
HttpServletRequest
ServletRequest
即可确认是请求对象被序列化。
步骤 6:回源码搜索触发点
搜索:
text
JSONUtil.toJsonStr
new JSONObject
JSONUtil.parseObj
BeanUtil.beanToMap
joinPoint.getArgs
本次最终定位到:
java
JSONUtil.toJsonStr(joinPoint.getArgs())
7. 根因结论
本次 OOM 的直接原因是:
text
操作日志切面在记录请求参数时,直接序列化 joinPoint.getArgs()。
当 Controller 方法参数中包含 HttpServletRequest 时,Hutool 会递归展开 TongWeb Request 对象。
Request 内部包含 headers、parameters、inputBuffer、cookies、response、socket wrapper 等复杂结构。
Hutool 将其中部分字节数据展开成大量 Byte 对象和 JSONArray/ArrayList,最终导致 Java heap space。
简化链路:
text
LogAspect.setRequestValue
-> joinPoint.getArgs()
-> args 包含 HttpServletRequest
-> JSONUtil.toJsonStr(args)
-> BeanToMapCopier
-> source = com.tongweb.coyote.Request
-> target = JSONObject
-> JSONArray / ArrayList / Byte 大量创建
-> Java heap space
8. 修复
方案 1:过滤不可序列化的 Servlet 对象
java
private void setRequestValue(JoinPoint joinPoint, OperLog operLog) {
Map<String, String[]> paramsMap = httpServletRequest.getParameterMap();
if (!paramsMap.isEmpty()) {
operLog.setOperParam(JSONUtil.toJsonStr(paramsMap));
return;
}
Object[] args = Arrays.stream(joinPoint.getArgs())
.filter(this::isLoggableArg)
.toArray();
operLog.setOperParam(JSONUtil.toJsonStr(args));
}
private boolean isLoggableArg(Object arg) {
return arg != null
&& !(arg instanceof ServletRequest)
&& !(arg instanceof ServletResponse)
&& !(arg instanceof MultipartFile)
&& !(arg instanceof InputStream)
&& !(arg instanceof OutputStream);
}