![图片[1]-JVM 内存调优踩坑实录:经历 3 次 OOM,才算真正吃透-速优课](https://www.suyouke.com/wp-content/uploads/2026/07/doubao_img_2848x1600_20260722_093209-1024x575.png)
JVM 内存调优踩坑实录:经历 3 次 OOM,才算真正吃透
做 Java 开发最怕的事就是线上 OutOfMemoryError。我第一次遇到的时候,看到日志里一片红色堆栈信息——然后服务挂了。运维重启了一下,又跑了。但没过几天又挂了。后来花了两周时间系统排查,发现了三个不同的问题。
这篇文章不说 JVM 内存区域的教科书知识,只说三次真实 OOM 的排查过程和我学到的配置方法。
第一次 OOM:堆内存参数设错了
那次是一个用户服务,每天几百万请求。部署的时候运维只配了 -Xmx512m。服务跑了一周后扛不住了,频繁 Full GC,最后还是 OOM。
排查方法很简单:先看监控,发现堆内存使用率每天涨一点,到 80% 左右触发 Full GC 可以降下去,但降下去的值比前一天高一点——典型的堆内存泄漏曲线。
但我首先做的事不是找泄漏,而是发现一个更基础的问题:-Xms 和 -Xmx 设了不一样的值。-Xms 是 256m,-Xmx 是 512m。JVM 启动时只分配 256m,随着使用量增加逐步扩容到 512m。这个扩容过程本身消耗性能——每次扩容都要触发 GC 去调整堆的大小。而且 -Xms 设 256m 导致堆太小,频繁触发 GC。
正确的做法是 -Xms 和 -Xmx 设成一样的值。这样 JVM 启动时就把堆预分配好,不需要动态调整。对于微服务来说,4GB-8GB 是比较合理的范围。
# 推荐的 JVM 配置
-Xms4g -Xmx4g # 堆大小 4GB,启动时预分配
-XX:MetaspaceSize=256m # 元空间初始大小
-XX:MaxMetaspaceSize=256m # 元空间最大大小,防止无限增长
-XX:+UseG1GC # 使用 G1 垃圾回收器
-XX:MaxGCPauseMillis=100 # GC 最大停顿时间目标
-XX:+PrintGCDetails # 打印 GC 详细信息
-XX:+PrintGCDateStamps # GC 日志带时间戳
-Xloggc:/logs/gc.log # GC 日志文件位置
还有几个容易忽略的参数。-XX:+HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath 一定要加——OOM 时自动生成堆转储文件,没有这个文件排查泄漏基本无从下手。很多人花了好几天查不到问题就是因为少配了这个。
# 必须配的参数——OOM 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps/
# 查看堆转储
jmap -dump:live,format=b,file=dump.hprof <pid>
第二次 OOM:Metaspace 泄漏
那次 OOM 和堆没关系——堆内存一切正常,但元空间一直在涨。元空间存的是类的元数据信息——类名、方法名、字段描述、字节码等。正常情况下元空间大小是稳定的,不会持续增长。但如果出了问题,它会一直涨到物理内存上限。
排查过程:先确认了是 Metaspace 的问题,然后通过 MAT 分析堆转储,发现了一个模式:org.springframework.cglib.proxy.Enhancer 创建的代理类越来越多。翻代码发现有一个地方在运行时动态生成类——用 CGLIB 创建代理对象,但这些代理类没有被回收。
问题出在 ThreadLocal 上。代码里用 ThreadLocal 缓存了代理对象,但线程池的线程不会销毁,导致 ThreadLocalMap 里的 entry 越积越多。每个 entry 持有 ClassLoader 的引用,ClassLoader 又引用了它加载的所有类——这些类就无法被 GC 回收了。Metaspace 就这样一点一点涨上去。
解决方案很简单:ThreadLocal 用完后一定要调 remove()。
// 错误写法
private static ThreadLocal<Proxy> proxyCache = new ThreadLocal<>();
public Proxy getProxy() {
Proxy p = proxyCache.get();
if (p == null) {
p = createProxy();
proxyCache.set(p); // 设进去了但从来不清理
}
return p;
}
// 正确写法——用完清理
public void cleanup() {
proxyCache.remove(); // 避免 ThreadLocalMap 泄漏
}
预防 Metaspace OOM 的配置:-XX:MaxMetaspaceSize 限制最大大小。设一个合理的上限——一般 256m 到 512m 对于大多数 Spring Boot 应用来说足够了。如果超过这个值还涨,说明有问题,不如让它 OOM 让你知道。
第三次 OOM:请求堆积
这次最诡异——堆内存正常、Metaspace 正常,但服务就是 OOM 了。排查发现是 Tomcat 的请求队列满了——上游调用方突然发起大量请求,Tomcat 的线程池处理不过来,请求堆积在线程池的任务队列里。每个请求占据一定内存,堆积多了内存就爆了。
这种问题不是 JVM 参数能解决的,需要从架构层面处理。限流是第一道防线——用 Sentinel 或者 Hystrix,在网关层限制入口流量。降级是第二道——如果下游服务挂了,上游要有超时和熔断,不要让请求一直等着。
我那次的问题就是下游数据库慢查询导致 API 响应时间从 20ms 涨到了 2s,Tomcat 线程全部卡住,新请求进不来,队列堆积到 OOM。解决方式是加了数据库连接池的超时配置和 API 的超时配置。
# Tomcat 连接器配置
server.tomcat.max-threads=200 # 最大线程数
server.tomcat.max-connections=10000 # 最大连接数
server.tomcat.accept-count=100 # 请求队列长度
server.tomcat.connection-timeout=5000 # 连接超时 5 秒
# 数据库连接池
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.connection-timeout=5000
spring.datasource.hikari.idle-timeout=300000
这几个参数的意义:max-threads 控制同时处理请求的线程数,设太大线程切换开销高,设太小请求排队。一般来说 200-500 比较合理,取决于每个请求的处理时长。accept-count 是请求队列的长度,超过这个值客户端会收到连接拒绝。connection-timeout 是最容易被忽略的——没有超时的话一个慢查询能卡住所有线程。
我现在的 JVM 标准配置
经历了三次 OOM 之后,我整理了一份标准的 JVM 启动参数,每个新项目都用这个模板。
| 参数 | 推荐值 | 说明 |
| -Xms -Xmx | 4g-8g | 设相同值避免动态调整 |
| MetaspaceSize | 256m | 元空间初始值 |
| MaxMetaspaceSize | 256m | 元空间上限 |
| UseG1GC | – | JDK 9+ 默认,4GB+ 堆推荐 |
| MaxGCPauseMillis | 100 | GC 停顿目标 |
| HeapDumpOnOOM | 必配 | OOM 时自动 Dump |
# 我的标准 JVM 配置模板
JAVA_OPTS="
-Xms4g -Xmx4g
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps/
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/logs/gc-$(date +%Y%m%d).log
-XX:+DisableExplicitGC
"
最后一项 -XX:+DisableExplicitGC 也很重要。有些框架会调用 System.gc() 触发 Full GC,这个配置禁掉显式 GC 调用,让 JVM 自己决定什么时候 GC。
排查工具和方法
遇到 JVM 问题不要慌,按下面这个顺序排查。
第一步看监控。如果堆内存使用率持续上涨没有下降趋势,大概率是内存泄漏。如果 GC 频率突然升高检查是不是请求量突然变大或者响应变慢导致对象存活时间变长。
第二步看 GC 日志。启用 PrintGCDetails 后,GC 日志里能看到每次 GC 的原因、耗时、回收前后的内存量。Full GC 频繁说明堆太小或者有大对象无法回收。我用过一个线上服务 Full GC 每小时一次,-Xmx 从 2g 改到 4g 后降到了每天一次。
# 用 jstat 实时查看 GC 情况
jstat -gcutil <pid> 1000 10
# 输出每 1 秒采样一次,共 10 次
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 重点关注 O(老年代使用率)和 FGC(Full GC 次数)
# 老年代使用率持续上升就要注意了
第三步生成堆转储分析。用 jmap -dump:live,format=b,file=dump.hprof pid 生成快照。注意 -live 参数只保留存活对象,Dump 文件会比较小。用 MAT 或者 VisualVM 打开,看 Leak Suspects 报告——它会告诉你哪个对象占了多少内存、谁引用了它。有次排查发现 java.util.HashMap 占了 40% 的堆——是一个缓存 map 没设最大大小导致无限增长。
第四步检查线程堆栈。用 jstack pid 看线程状态。大量 BLOCKED 或 WAITING 状态的线程说明有锁竞争或者线程池配置有问题。
第五步检查操作系统层面。top 看 CPU 和内存。如果进程占用的 RSS 远大于堆大小,可能是 Native Memory 泄漏——直接内存、NIO Buffer、或者 JNI 泄漏。这种情况 JVM 的 -Xmx 看不出来,需要看操作系统的内存占用。
几个真实案例
分享几个我遇到过的真实案例。第一个是 JSON 序列化泄漏——用 fastjson 的 JSON.toJSONString 序列化一个非常大的对象时,底层 char 数组占用了大量内存而且没有及时释放。后来改成分批序列化解决。
第二个是 Kafka 消费端 OOM。Kafka 消费者拉取的消息如果没及时处理会在内存里堆积。而默认的 max.poll.records 是 500,如果每条消息很大,一次拉取可能就是几十 MB。解决方案是调小 max.poll.records 或者加快消费速度。
第三个是日志框架引发的 OOM。Logback 的异步 Appender 有一个阻塞队列,如果日志产生的速度大于写入磁盘的速度,队列会越来越大最终 OOM。解决方案是限制队列大小或者调高日志级别减少日志量。
这些案例都有一个共同点:不是 JVM 本身的问题,而是应用层代码或者框架配置不当导致的。所以排查 JVM 问题不要只看 JVM 参数,要先看业务代码本身。
你遇到过什么样的 JVM OOM?怎么排查的?评论区说说。








请登录后查看评论内容