一、事故背景
一个周五下午,线上订单服务开始出现间歇性超时:
- 每 3
5 分钟出现一次 STW(Stop The World),持续 23 秒 - 接口耗时 P99 从 200ms 飙升到 5s+
- 部分健康检查超时,K8s 将 Pod 标记为不健康重启
环境信息
| 项目 | 值 |
|---|---|
| JDK | 17.0.6 |
| GC | G1GC(默认) |
| 堆内存 | -Xmx4g -Xms4g |
| 容器 | K8s Pod 2C4G |
| 框架 | Spring Boot 3.1 |
二、现场分析
第一步:查看 GC 日志
# 启动参数添加 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 堆内存
# 触发 Full GC 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heap.hprof
# 或者手动触发
jmap -dump:live,format=b,file=heap.hprof <pid>
用 Eclipse MAT 打开 dump 文件,查看 Leak Suspects:
One instance of "java.util.HashMap$Node[]" loaded by "<system class loader>"
occupies 1,842,376,512 (87.32%) bytes.
The memory is accumulated in one instance of
"java.util.HashMap$Node[]" loaded by "<system class loader>".
第三步:追踪大对象
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!
追踪代码发现:
@Component
public class LocalCache {
// ❌ 做了一个"缓存所有商品信息"的愚蠢操作
private static final Map<Long, Product> PRODUCT_CACHE = new ConcurrentHashMap<>();
@Scheduled(fixedDelay = 600_000) // 每 10 分钟全量刷新
public void refreshCache() {
List<Product> 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)直接分配到老年代 |
四、修复方案
紧急修复(立即上线)
@Component
public class LocalCache {
// ✅ 使用 Caffeine 本地缓存,限定容量 + 过期
private final Cache<Long, Product> 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 参数优化
# 优化后的参数
-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) |
六、日常监控建议
# 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 是没有垃圾需要收集。
