一、事故背景
一个周五下午,线上订单服务开始出现间歇性超时:
- 每 3~5 分钟出现一次 STW(Stop The World),持续 2~3 秒
- 接口耗时 P99 从 200ms 飙升到 5s+
- 部分健康检查超时,K8s 将 Pod 标记为不健康重启
环境信息
二、现场分析
第一步:查看 GC 日志
``bash
`启动参数添加 GC 日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/app/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=10M
日志片段:
`
[2026-06-15T14:32:18.123+0800] GC(1234) Pause Young (Normal) (G1 Evacuation Pause) 3800M->2100M(4096M) 45.2ms
[2026-06-15T14:32:18.452+0800] GC(1235) Pause Full (G1 Compaction Pause) 4000M->1800M(4096M) 2150.3ms
[2026-06-15T14:32:20.890+0800] GC(1236) Pause Full (G1 Compaction Pause) 3950M->1920M(4096M) 2380.5ms
`
Full GC 持续超过 2 秒,而且间隔只有几秒——典型的"频繁 Full GC"。
第二步:dump 堆内存
`bash
触发 Full GC 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heap.hprof
或者手动触发
jmap -dump:live,format=b,file=heap.hprof用 Eclipse MAT 打开 dump 文件,查看 Leak Suspects:
`
One instance of "java.util.HashMap$Node[]" loaded by ""
occupies 1,842,376,512 (87.32%) bytes.The memory is accumulated in one instance of
"java.util.HashMap$Node[]" loaded by "".
`第三步:追踪大对象
MAT 的 Dominator Tree 显示:
`
Class Name | Shallow Heap | Retained Heap
-------------------------------------------------------------------------------
java.util.HashMap$Node[] @ 0x7a3f2e000 | 24 B | 1.84 GB
├── com.example.cache.LocalCache @ 0x7a3f2e010| 48 B | 1.83 GB
│ └── byte[] @ 0x7a3f30000 | 1.82 GB | 1.82 GB
`罪魁祸首:
LocalCache 中的一个巨大的 byte[],占用了 1.8GB!追踪代码发现:
`java
@Component
public class LocalCache {
// ❌ 做了一个"缓存所有商品信息"的愚蠢操作
private static final Map PRODUCT_CACHE = new ConcurrentHashMap<>(); @Scheduled(fixedDelay = 600_000) // 每 10 分钟全量刷新
public void refreshCache() {
List all = productMapper.selectAll(); // 120 万条商品
all.forEach(p -> PRODUCT_CACHE.put(p.getId(), p));
}
}
`三、问题根源
根因 | 详情 缓存无界增长 | Map 没有容量限制,内存持续膨胀
全量加载 | 每 10 分钟加载 120 万条数据到堆内存
大对象 | Product 包含 BLOB 字段(商品详情 HTML),平均 1.5KB/条
G1 无力回天 | 巨型对象(Humongous Object > 50% Region)直接分配到老年代 四、修复方案
紧急修复(立即上线)
`java
@Component
public class LocalCache {
// ✅ 使用 Caffeine 本地缓存,限定容量 + 过期
private final Cache PRODUCT_CACHE = Caffeine.newBuilder()
.maximumSize(10000) // 最多 1 万条
.expireAfterWrite(5, TimeUnit.MINUTES) // 5 分钟后过期
.recordStats()
.build(); @Cacheable(value = "product", key = "#id")
public Product getById(Long id) {
// ✅ 按需加载,用 Redis 做二级缓存
Product product = redisTemplate.opsForValue().get("product:" + id);
if (product != null) return product;
product = productMapper.selectById(id);
redisTemplate.opsForValue().set("product:" + id, product, 30, TimeUnit.MINUTES);
return product;
}
}
`JVM 参数优化
`bash
优化后的参数
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标最大停顿 200ms
-XX:G1HeapRegionSize=8m # Region 大小 8MB(避免过大的 Humongous 对象)
-XX:ConcGCThreads=2 # 并发 GC 线程数
-XX:ParallelGCThreads=4 # 并行 GC 线程数
-XX:InitiatingHeapOccupancyPercent=45 # 堆占用 45% 触发并发标记(降低默认 45% 有助于提前回收)
-XX:G1ReservePercent=10 # 预留 10% 给存活对象晋升
-XX:+UseStringDeduplication # 字符串去重
`五、改造效果
指标 | 优化前 | 优化后 Full GC 次数/小时 | 12+ | 0
最大暂停时间 | 2.4s | 180ms
堆内存使用 | 3.8GB | 1.6GB
接口 P99 | 5.2s | 320ms
缓存命中率 | 100%(问题所在) | 87%(Caffeine)+ 12%(Redis) 六、日常监控建议
`yaml
Spring Boot Actuator + Prometheus
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
metrics:
tags:
application: order-service # 暴露 GC 指标
prometheus:
metrics:
export:
enabled: true
`搭配 Grafana 面板监控关键指标:
jvm_memory_used_bytes — 堆内存使用jvm_gc_pause_seconds_sum — GC 累计暂停时间jvm_gc_memory_promoted_bytes_total` — 晋升老年代字节数七、总结
这次事故的教训:
- 不要用无界 Map 做缓存 — 本地缓存必须设置容量上限和过期策略
- 全量加载是大忌 — 用按需加载 + 多级缓存代替
- 开启 GC 日志 — 没有 GC 日志的 JVM 调优等于盲人摸象
- 建立监控告警 — 堆内存 > 80% 连续 5 分钟就告警,别等 Full GC 才排查
GC 调优的本质不是调参数,而是定位并消灭那些不该在堆里存在的对象。最好的 GC 是没有垃圾需要收集。