JVM 内存模型定义了 Java 程序运行时数据如何分布,而 GC 调优直接决定服务的吞吐量与停顿时间。很多性能问题不是代码写得差,而是堆分配不合理、回收器选型错位。本文从堆结构、分代回收讲起,给出一套可直接落地的 JVM 参数与排查方法。
一、JVM 内存模型:运行时数据区全景
JVM 把内存划分为线程私有和线程共享两大部分。理解每一块的职责,是定位 OOM、调优堆结构的前提。我们在Java 21 虚拟线程实战里讨论过高并发下的资源占用,其底层同样受内存模型约束。
线程私有区
程序计数器记录当前线程执行到的字节码行号;虚拟机栈存放栈帧(局部变量、操作数栈、方法返回地址),每调用一个方法就压入一帧,递归过深会触发 StackOverflowError;本地方法栈服务于 native 方法。这三块随线程生灭,不存在线程安全问题。
线程共享区
堆是对象实例与数组的栖息地,也是 GC 的主战场;方法区(JDK 8 之后称为元空间 Metaspace)存放类元数据、常量池、静态变量。元空间默认使用本地内存,不受堆大小限制,但失控的类加载仍会撑爆物理内存。
| 区域 | 线程 | 主要职责 | 典型溢出 |
|---|---|---|---|
| 程序计数器 | 私有 | 字节码行号指示 | 无 |
| 虚拟机栈 | 私有 | 方法调用栈帧 | StackOverflowError |
| 堆 Heap | 共享 | 对象实例存储 | OutOfMemoryError |
| 元空间 Metaspace | 共享 | 类元数据 | OutOfMemoryError |
二、堆内存分代与对象生命周期
分代假说认为:绝大多数对象朝生夕死,存活越久的对象越可能被长期引用。基于这一观察,堆被切分为新生代(Young)与老年代(Old)。
新生代:Eden 与 Survivor
新生代由一块 Eden 和两块 Survivor(S0、S1)组成,默认比例 8:1:1。新对象先在 Eden 分配,Minor GC 后存活对象被拷到 Survivor,并随着年龄计数逐步晋升到老年代(默认年龄阈值 15)。复制算法在这里效率极高,因为存活对象通常很少。
老年代与晋升
老年代存放长期存活对象与大对象。当老年代占满触发 Major/Full GC,停顿会明显拉长——这正是线上接口超时的常见元凶,后续章节的排查篇会直接呼应JVM Full GC 导致接口超时排查实录。
# 开启 GC 日志(JDK 9+ 统一用 -Xlog)
java -Xlog:gc*,gc+heap=debug:file=gc.log:time,uptime,level,tags -jar app.jar
# 典型 Minor GC 日志:Eden 回收、Survivor 提升
# [0.512s] Pause Young (Normal) (G1 Evacuation Pause)
# Eden: 256M(256M)->0B(256M) Survivors: 0B->32M Heap: 256M(512M)->48M(512M)
三、垃圾回收算法:三种基础思路
所有回收器都建立在三种基础算法之上。标记-清除先标记存活对象再清掉其余,简单但会产生内存碎片;复制算法把存活对象搬到另一块空间,无碎片但浪费一半内存,新生代正是其用武之地;标记-整理在标记后让存活对象向一端滑动,兼顾无碎片与空间利用率,老年代常采用。
四、主流垃圾回收器选型:G1 / ZGC / CMS
G1:可预测停顿的默认之选
G1 把堆划分为多个大小相等的 Region,优先回收价值最高(垃圾最多)的区域,因此能在设定的停顿目标内完成回收。JDK 9 起成为服务端默认回收器,4G~32G 堆场景下综合表现最均衡。
ZGC:亚毫秒级停顿
ZGC 通过染色指针与并发转移,把停顿控制在 10ms 以内,几乎与堆大小无关,适合大内存、低延迟交易系统。代价是更高的 CPU 与内存开销。CMS 已废弃,新项目不要再用。
| 回收器 | 最大停顿 | 适用堆 | JDK 版本 | 定位 |
|---|---|---|---|---|
| G1 | 百毫秒级 | 4G~32G | 9+ 默认 | 通用均衡 |
| ZGC | <10ms | 8G~数TB | 15+ 生产可用 | 低延迟 |
| Parallel | 秒级 | 任意 | 全版本 | 高吞吐 |
| CMS | 百毫秒级 | 中小 | 已废弃 | 不推荐 |
五、GC 调优实战:关键 JVM 参数
堆大小与分代比例
第一步永远是给堆一个明确上界,避免被 OS 内存撑爆。-Xms 与 -Xmx 设为一致可防止运行时扩容带来的抖动;-XX:NewRatio 控制新老代比例,吞吐型服务可放大新生代减少 Minor GC 频率。
停顿目标与日志开关
# G1 生产推荐参数模板
java -server \
-Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=16m \
-XX:InitiatingHeapOccupancyPercent=45 \
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=10,filesize=100M \
-jar app.jar
# 低延迟场景切换 ZGC(JDK 15+)
# -XX:+UseZGC -XX:MaxGCPauseMillis=10
调优原则:先用 -XX:MaxGCPauseMillis 设一个可接受的停顿上限,让回收器自适应,而不是手动去抠每一个 Region 参数。盲目调小 Young 区反而会增加晋升压力,诱发更频繁的 Full GC。
六、GC 问题排查:从日志到工具
jstat 实时监控
# 每 1 秒采样一次 GC 与堆容量,共 10 次
jstat -gcutil -h10 <pid> 1000 10
# 关键列:YGC/YGCT 年轻代次数与耗时,FGC/FGCT Full GC 次数与耗时
# FGC 持续增长 + FGCT 占比高 = 老年代回收困难,优先查内存泄漏
# 导出堆转储定位大对象
jmap -dump:live,format=b,file=heap.hprof <pid>
# 或 OOM 时自动落盘:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/
拿到 heap.hprof 后用 MAT 或 VisualVM 看 Dominator Tree,定位占用最高的类。若是字符串、缓存、或连接池无限增长,往往对应业务层的集合未设上限。Spring Boot 应用的上线姿势可参考Spring Boot 3 升级踩坑实录。
把 GC 指标接进监控
单机排查治标,持续观测治本。把 JVM 的 GC 时间、堆占用、各代容量通过 JMX Exporter 暴露给Prometheus + Grafana 监控面板,一旦出现 Full GC 频次异常即可第一时间告警,而不是等接口超时才被动发现。
七、常见误区与最佳实践
| 误区 | 后果 | 正确做法 |
|---|---|---|
| -Xmx 不设上限 | 容器 OOM 被杀 | 显式设 -Xms/-Xmx 一致 |
| 盲目调小 Young 区 | 晋升加速、Full GC 增多 | 用停顿目标让回收器自适应 |
| 追求零 GC | 过度优化徒增复杂度 | 关注 P99 停顿而非次数 |
| 忽略元空间 | 类加载泄漏拖垮节点 | 设 -XX:MaxMetaspaceSize |
GC 调优不是玄学,而是一条可验证的链路:先量(GC 日志与监控)→ 再定(停顿目标与回收器选型)→ 后验(压测对比吞吐与 P99)。把 JVM 参数标准化进GitHub Actions CI 流水线,每次发版沿用同一套基线,比凭经验手调稳得多。真正健康的服务,是让 Full GC 几乎不出现,而不是出现后再救火。




