凌晨两点,告警群里一条消息把我炸醒:生产环境某服务频繁Full GC,内存占用飙到92%,已经触发熔断。这种场景,做过几年后端的兄弟应该都不陌生。今天把这次排查的完整过程复盘一下,希望能帮到遇到类似问题的朋友。
问题现场
先说下背景。这个服务是一个订单聚合网关,Spring Boot 2.7 + JDK 11,部署在K8s集群里,4个Pod,每个Pod分配了2G堆内存。平时QPS大概3000左右,内存一直很稳定,堆使用率在40%-50%之间波动。
那天晚上8点开始,某个Pod的堆内存突然开始线性上涨,大概40分钟就顶到了1.8G,然后就开始疯狂Full GC。
2026-08-20 22:43:12 [GC (Allocation Failure) [PSYoungGen: 524288K->65536K(611328K)] 1048576K->892416K(2006016K), [Metaspace: 198234K->198234K(1245184K)], 0.1234567 secs] [Times: user=0.45 sys=0.02, real=0.12 secs]
2026-08-20 22:43:15 [Full GC (Ergonomics) [PSYoungGen: 65536K->65536K(611328K)] [ParOldGen: 826880K->826880K(1394688K)] 892416K->892416K(2006016K), [Metaspace: 198234K->198234K(1245184K)], [PSPermCap: 32768K->32768K(32768K)], 8.2345678 secs] [Times: user=15.67 sys=0.12, real=8.23 secs]
注意看这个Full GC的耗时------8.23秒,而且回收完堆内存几乎没降下来。这说明老年代里有大量对象是GC Root可达的,根本回收不掉。
第一反应:先抓现场
很多人遇到OOM第一反应是去翻代码,觉得哪里写错了。说实话,这个思路效率很低。正确的做法是先保留现场。
我当时的操作:
# 进入Pod
kubectl exec -it order-gateway-7d8f9c6b5-x2k4m -- /bin/bash
# 先dump堆内存(注意:这一步会让服务短暂STW,生产环境慎用)
jmap -dump:format=b,file=/tmp/heap.hprof 1
# 同时抓一份线程快照
jstack 1 > /tmp/thread_dump.txt
这里有个细节要说一下:jmap -dump 在生产环境是有风险的,它会触发一次全量堆遍历,如果你的堆很大(比如8G以上),STW时间可能达到几十秒甚至几分钟。所以一般建议提前在JVM启动参数里加上:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heap.hprof
这样OOM的时候会自动dump,不用手动操作。
分析heap dump
把hprof文件拉到本地,用MAT(Memory Analyzer Tool)打开。
先看Overview页的饼图,一眼就能看到最大的那个扇区------byte[] 占了堆内存的67%,大概1.2G。
然后点进去看Dominator Tree,发现这些byte数组的GC Root指向了同一个地方:
java.util.concurrent.ConcurrentHashMap$Node
└── java.util.ArrayList
└── com.xxx.gateway.cache.ResponseCache$CacheEntry
看到 ResponseCache 这个类名,我心里就有数了------这是一个本地缓存,而且没有设置过期策略。
根因定位
翻了下代码,果然找到了问题。这个服务有一个接口是用来聚合下游多个微服务的数据,为了减少重复调用,加了一个本地缓存:
@Component
public class ResponseCache {
// 问题就出在这里
private static final Map<String, CacheEntry> CACHE = new ConcurrentHashMap<>();
public void put(String key, Object value) {
CACHE.put(key, new CacheEntry(value, System.currentTimeMillis()));
}
public Object get(String key) {
CacheEntry entry = CACHE.get(key);
if (entry == null) return null;
return entry.getValue();
}
@Data
@AllArgsConstructor
static class CacheEntry {
private Object value;
private long timestamp;
}
}
看到问题了吗?这个缓存只有put,没有evict。key是请求参数的MD5值,每个不同的请求参数都会生成一个新的key,缓存只增不减。
而且这个接口的参数里包含一个时间戳字段,客户端每次请求传的时间戳都不一样,导致缓存key几乎不会重复。也就是说,这个缓存实际上从来没被命中过,只是一直在往里塞数据。
一个永远不被命中的缓存,却一直在吃内存。 这就是典型的内存泄漏。
修复方案
修复方案有两个思路:
方案一:加过期淘汰(快速止血)
private static final Map<String, CacheEntry> CACHE = new ConcurrentHashMap<>();
// 定时清理过期缓存,每5分钟执行一次
@Scheduled(fixedRate = 300000)
public void evictExpiredCache() {
long now = System.currentTimeMillis();
CACHE.entrySet().removeIf(entry ->
now - entry.getValue().getTimestamp() > 600000 // 10分钟过期
);
}
这个方案简单粗暴,但有个问题:ConcurrentHashMap 的 removeIf 在数据量大的时候性能不太好,而且定时任务本身也有延迟。
方案二:换用Caffeine(推荐)
private final Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(10_000) // 最多缓存1万条
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
.recordStats() // 开启统计,方便监控命中率
.build();
public void put(String key, Object value) {
cache.put(key, value);
}
public Object get(String key) {
return cache.getIfPresent(key);
}
Caffeine底层用的是 Window TinyLFU 淘汰算法,相比Guava Cache的LRU,在缓存命中率上有明显优势。而且它自带过期淘汰和容量上限,不会出现内存无限增长的问题。
最终我们选了方案二,上线后内存使用率稳定在35%-45%,Full GC再也没有出现过。
几点反思
回过头看这次事故,有几个点值得注意:
1. 本地缓存不是万能的
很多开发者觉得加个Map就是缓存了,但本地缓存天然有局限性:多实例部署时数据不一致、内存占用不可控、没有统一的监控手段。如果不是对延迟极度敏感的场景,优先考虑Redis。
2. 缓存一定要设上限
不管用什么缓存方案,maximumSize 和 expireAfterWrite 这两个参数一定要设。不设上限的缓存,迟早会吃掉你所有的堆内存。
3. 监控比排查更重要
如果我们在上线时就给这个缓存加了监控(比如缓存大小、命中率),在内存涨到1G的时候就能发现问题,而不是等到Full GC了才被动告警。
// Caffeine的统计信息可以直接暴露给Prometheus
cache.stats().hitRate(); // 命中率
cache.stats().evictionCount(); // 淘汰次数
cache.estimatedSize(); // 当前缓存大小
4. 生产环境的JVM参数要配好
-XX:+HeapDumpOnOutOfMemoryError 这个参数,我见过太多项目没加。等到OOM了才发现没有dump文件,只能对着日志干瞪眼。
这次排查前后花了大概两个小时,但复盘下来收获还是挺大的。其实大部分线上问题,根因都不复杂,难的是快速定位 和保留现场的意识。
如果你也遇到过类似的内存问题,或者有更好的排查思路,欢迎在评论区交流。