Java 内存模型与 JVM 调优:堆、栈与 GC 参数怎么配
学完这篇,你能分清 JVM 里堆、栈、方法区各自装什么,并拿到一套可以直接抄改的内存与 GC 参数模板。
第一步:先把运行时数据区对号入座
调优调错地方的根源,往往是没分清这几个区:
- 堆(Heap):线程共享,存对象实例和数组,是 GC 主战场。参数 `-Xms`(初始)、`-Xmx`(最大)。
- 虚拟机栈:线程私有,每个方法调用压一个栈帧,放局部变量表、操作数栈、返回地址。深度由 `-Xss` 决定。
- 方法区 / 元空间:JDK 8 起改用本地内存,存类元信息、运行时常量池。参数 `-XX:MetaspaceSize`、`-XX:MaxMetaspaceSize`。
- 程序计数器:线程私有,记录当前字节码行号,是唯一不会 OOM 的区域。
- 本地方法栈:给 native 方法用。
注意:JDK 8 之后没有永久代(PermGen),继续写 `-XX:PermSize`、`-XX:MaxPermSize` 不会生效,JDK 9+ 甚至直接启动报错,要改用 `-XX:MetaspaceSize`。
第二步:搞清对象在堆里的流转路径
新对象先在 Eden 分配(走 TLAB 快速分配);Minor GC 后存活对象进 Survivor,年龄 +1;年龄到 `-XX:MaxTenuringThreshold`(默认 15),或者 Survivor 中同年龄对象总和超过 `-XX:TargetSurvivorRatio`(默认 50%),就晋升老年代。超大对象直接进老年代,门槛是 `-XX:PretenureSizeThreshold`(单位字节,只对 Serial / ParNew 有效)。
理解这条路径,你才知道为什么"老年代涨得快"通常意味着新生代太小或对象晋升太早,而不是该去调老年代。
第三步:栈参数怎么定
`-Xss` 决定单线程栈大小,Linux x64 默认约 1M。经验公式:最大线程数 ≈ 可用内存 / Xss。
- 递归深、栈帧大 → 适当调大,如 `-Xss1m` 或 `-Xss2m`;
- 需要开几千个线程 → 调小到 `-Xss256k` 或 `-Xss512k`。
注意:`-Xss` 调太小会让正常递归提前抛 `StackOverflowError`;调太大则在创建大量线程时更容易抛 `OutOfMemoryError: unable to create native thread`——这个错误不是堆不够,别去加 `-Xmx`。
第四步:堆参数这样配
核心三条原则:
- `-Xms` 和 `-Xmx` 设成同一个值,避免运行期反复扩容收缩带来的抖动。
- 新生代显式指定:`-Xmn` 直接给大小,或用 `-XX:NewRatio=2`(老年代:新生代 = 2:1)。
- `-XX:SurvivorRatio=8` 表示 Eden:S0:S1 = 8:1:1,一般不用改。
新生代占堆比例的经验值:低延迟 Web 服务取 1/2 左右,批处理/吞吐型取 1/3。
一份 4G 堆的模板:
java -Xms4g -Xmx4g -Xmn1536m -Xss512k \
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m \
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump \
-jar app.jar
第五步:挑一个匹配场景的回收器
| 回收器 | 开启参数 | 适用场景 |
|---|---|---|
| Serial / Serial Old | `-XX:+UseSerialGC` | 单核、小内存、客户端 |
| Parallel Scavenge + Parallel Old | `-XX:+UseParallelGC` | 批处理、离线计算,吞吐优先 |
| CMS | `-XX:+UseConcMarkSweepGC` | JDK 8 老项目,JDK 14 已移除 |
| G1 | `-XX:+UseG1GC` | JDK 9+ 默认,堆 4G 以上 Web 服务 |
| ZGC | `-XX:+UseZGC` | 超大堆、要求停顿 < 10ms |
常用细化参数:
- Parallel:`-XX:MaxGCPauseMillis=200`、`-XX:GCTimeRatio=99`
- CMS:`-XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly`(防碎片可加 `-XX:+UseCMSCompactAtFullCollection`)
- G1:`-XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=16m -XX:InitiatingHeapOccupancyPercent=45`
注意:别迷信"越新越好"。堆小于 4G 的普通服务,Parallel 的吞吐经常比 G1 更好;CMS 在 JDK 9 已废弃、JDK 14 移除,新项目不要选。
第六步:打开 GC 日志,用数据说话
JDK 8:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/gc.log \
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M
JDK 9+:
-Xlog:gc*,gc+heap=info:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=20M
日志重点看四件事:Minor GC 频率、单次停顿毫秒数、Full GC 次数是否持续增长、老年代回收后残留是否越来越高(后者是内存泄漏信号)。
排查工具:
jstat -gcutil <pid> 1000 # 各区使用率与 GC 次数
jmap -histo:live <pid> | head -30 # 对象占用排行
jstack <pid> > stack.txt # 线程栈,查死锁/卡顿
注意:`jmap -dump:live` 会触发一次 STW,大堆生产环境慎用。更稳妥的做法是提前挂上 `-XX:+HeapDumpOnOutOfMemoryError`,让 JVM 在 OOM 时自动落盘。
第七步:容器里别让 JVM 看错内存
JDK 8u191+ 默认开启 `UseContainerSupport`,容器内建议别再硬编码 `-Xmx`:
-XX:MaxRAMPercentage=70.0 -XX:InitialRAMPercentage=70.0
更早的 JDK 8 版本需要 `-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap`,否则 JVM 会读到宿主机总内存,直接把容器撑爆 OOMKilled。
注意:堆只占进程内存的一部分,元空间、线程栈、直接内存、JVM 自身开销都要留出空间,`MaxRAMPercentage` 给 70% 左右比较安全,别给到 90%。
小结
- 堆存对象、栈存方法调用、元空间存类信息,`-Xss` 管栈、`-Xms/-Xmx` 管堆、`MetaspaceSize` 管元空间,别串台。
- `-Xms = -Xmx`,新生代显式指定,`SurvivorRatio` 默认即可。
- 回收器按场景选:批处理 Parallel,Web 低延迟 G1 或 ZGC,老项目 CMS 只做维护。
- 先开 GC 日志 + `jstat` 观察,再动参数;不观测就调参等于蒙。
- 容器环境用 `MaxRAMPercentage`,并给非堆内存留 30% 余量。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





