一次 Young GC 后,Eden 竟“跳回” 512 MB?
先看一段来自生产环境的 GC 日志(G1,JDK 17):
[gc,heap] GC(123) Pause Young (Normal) (G1 Evacuation Pause)
[gc,heap] Eden regions: 737->0(221)
[gc,heap] Survivor regions: 37->37(221)
[gc,heap] Heap: 2048M(4096M)->1224M(4096M) # 堆在 2 GB 与 4 GB 之间“蹦迪”
gc+ergo+heap 计算出的“最小堆” < 2 GB,于是把 已提交的 Eden/Survivor 全部归还给 OS。这就叫 JVM 级的空间震荡 —— 堆的动态收缩/扩张频率远高于业务流量变化频率,导致:
madvise(MADV_DONTNEED),系统调用开销 + TLB flush。Heap_lock)竞争。process_resident_memory_bytes 像心电图,告警规则瞬间爆炸。收集器 | 收缩触发点 | 关键参数 | 默认行为 |
|---|---|---|---|
G1 | Young/Mixed GC 后,在 G1CollectedHeap::shrink_helper() | -XX:G1PeriodicGCInterval, -XX:+G1UseAdaptiveIHOP | 默认 开启 收缩 |
ZGC | 无收缩(Region 大小固定,仅逻辑释放) | -XX:+ZUncommit | JDK 17 起 默认开启 |
Shenandoah | GC 后 ShenandoahHeap::shrink_heap() | -XX:+ShenandoahUncommit | 默认 开启 |
结论:只要你用 -Xms < -Xmx,三大低延迟收集器都可能“好心办坏事”。
用 perf 跟踪一次 G1 Young GC → shrink → expand:
3.25 ms : G1CollectedHeap::shrink_helper
0.83 ms : os::pretouch_memory # 重新提交时把整片内存写 0
1.40 ms : os::commit_memory # mmap(PROT_READ|PROT_WRITE)
5.48 ms : os::tlb_flush_all # 远程 CPU TLB shootdown
-Xms 设成 -Xmx最直接、最暴力、最有效:
java -Xms4g -Xmx4g -XX:+UseG1GC ...
代价:启动即占用 4 GB RSS,容器 OOM 风险前移;但在 K8s 已设置
resources.limits.memory的场景,反而是最可预测的方案。
若业务确实有 昼夜峰谷,又不想一次性占满内存,可保留弹性,但拉长决策周期:
# G1:空闲 Region 超过堆 30 % 且持续 10 min 才归还
-XX:G1PeriodicGCInterval=600000
-XX:G1UncommitDelay=600000 # JDK 21+
-XX:G1ReservePercent=30 # 保守一点
# Shenandoah:空闲 > 10 % 且 5 min 后才 uncommit
-XX:+UnlockExperimentalVMOptions
-XX:ShenandoahUncommitDelay=300000
实测:
-Xms/-Xmx,滚动重启 Pod,避免运行时抖动。-Xms1g -Xmx1g 固定;-Xms4g -Xmx4g 固定;JVM 抖动?
├─ RSS 锯齿 + GC 日志 shrink/expand → 空间震荡
│ ├─ 能否接受固定内存? → -Xms = -Xmx
│ ├─ 需要弹性? → 调大 UncommitDelay
│ └─ 需要分钟级弹性? → 上层 VPA/分池
└─ 非内存抖动 → 排查 code cache/metaspace/direct memory
动态堆 ≠ 弹性。 把伸缩决策从 JVM 内部移动到编排层,才能既享受云原生红利,又保住 P99。
下次有人问你:
“为啥 Young GC 后堆反而变小,下一秒又暴涨?”
你可以把这篇文章甩给他,并补一句:
“把
-Xms设成-Xmx,世界就安静了。”