1. 参数设置前先把"内存预算"想清楚
调 JVM 参数这种事,最怕两种状态:一是背了一堆默认值却不知道什么时候改,二是线上出了问题临时翻文档,查到一个试一个,最后把启动脚本改成了"彩票组合"。
我给自己定了个规矩:任何参数写进生产启动命令,都必须能回答三个问题——它控制哪块内存、它影响哪次 GC 停顿、它方便我排什么错。这篇文章就以"JVM 参数设置及依据"为主线,把堆内、堆外、GC、容器、日志这几块逐个拆开,讲清楚设置的逻辑和落地的方法。适合刚接触 JVM 调优的开发者,也适合团队里需要统一启动参数模板的技术负责人。
1.1 三个核心问题:多大、多停、怎么查
拿到一份启动命令,我不会先去翻每个参数的意思,而是先问三件事。
第一是这台机器或者容器到底给它多大内存。这是所有参数的起点,很多人一上来就纠结-XX:MaxGCPauseMillis设 50 还是 100,其实连-Xmx都没想清楚,后面的调优都是空中楼阁。
第二是业务对 GC 停顿的容忍度到底是多少。接口的 P99 延迟 SLA 是 200ms,还是 500ms?批处理任务是不是可以忍受 1 秒的 Full GC?这直接决定了选 G1 还是 Parallel,也决定了停顿目标参数的数值。
第三是出了问题之后,我靠什么日志来复盘。没有 GC 日志,没有堆转储,所谓的 JVM 调优基本就是盲调。诊断参数不是可选项,是生产环境的必选项。
这三个问题分别对应三组参数:容量参数、垃圾回收器参数、日志与转储参数。下面逐个展开。
1.2 先画一张账本,再谈参数
JVM 不只是堆内存。一台 4G 容器,你以为给它-Xmx3g很合理,实际进程还要放 Metaspace、线程栈、直接内存、CodeCache,以及 JIT 编译器和 GC 自己的一些原生内存。全加进去,3G 堆可能已经踩线。
这跟租房预算一个道理:月薪一万不代表你能租九千的房子,还要留水电燃气、吃饭交通和应急金。JVM 的内存也一样,堆只是其中一项,不是全部。
| 预算科目 | 常见参考值 | 风险点 |
|---|---|---|
| 堆内存 | 由 -Xmx 决定 | 堆设太大,挤掉其他内存 |
| Metaspace | 默认无上限 | 类加载器泄漏时 OOM |
| 线程栈 | 默认 1M/线程 | 线程多时积少成多 |
| 直接内存 | 默认取决于 JDK 策略 | NIO 用多时不显式设置容易失控 |
| CodeCache | 分层编译时约 240M | 动态生成类多时被打满 |
| 原生内存 / 系统 | 300~500M | 容器场景预留不足 |
所以调参从来不是单独看一个参数的默认值,而是看整套启动命令占总内存多少。先有这本账,再写具体数值。
2. 堆内参数:Xmx、Xms、NewRatio、SurvivorRatio
堆内存是 JVM 参数里最常被讨论的部分,但也是最容易被"一刀切"的部分。很多团队的堆参数就是-Xmx4g -Xms4g,至于为什么是 4G,为什么新生代老年代是这个比例,没人说得清。
2.1 Xmx怎么算:预算减法的完整推演
假设容器限制 4G,跑一个 Netty/Spring Boot 服务,线程池 200 个线程,NIO 用得多。我会这样估算:
容器总内存:4096M - 系统/页缓存预留:300M - Metaspace 上限:256M - 直接内存:128M - 200 线程 × 512K:100M - CodeCache:240M = 堆最多可用:约 3072M减法做完,堆最多还能分 3G 左右。但我不会真的给-Xmx3g,因为那意味着整个容器没有余量应对突发的原生内存增长。实际我会给 2.5G 左右,留出 500M 给不确定因素。
如果不想硬编码,JDK 8u191+ 支持-XX:MaxRAMPercentage=60,在 4G 容器里正好是 2.4G 左右,和上面算出来的结果非常接近。所以我的习惯是:先做预算减法,再用百分比参数或者显式 Xmx 落数值。
注意:
-Xmx设得越大,不代表性能越好。堆太大 GC 扫描范围也大,某些场景下反而让单次停顿更久。堆大小的最终依据是压测峰值,不是"内存反正还有剩"。
2.2 新生代和老年代的比例如何定
默认NewRatio=2意味着年轻代只占堆的 1/3。对典型的 Web 请求,对象绝大多数在一个请求内就死掉,年轻代理论上越大越好,可以减少对象过早晋升到老年代。但年轻代过大,老年代就小,Full GC 风险更高。取舍的依据,是你业务里的对象存活曲线和压测数据。
拿 4G 堆举例,NewRatio=2时年轻代约 1.33G,老年代约 2.67G。再看SurvivorRatio=8,Eden 和两个 Survivor 的比例是 8:1:1,也就是 Eden 大约 1.07G,每个 Survivor 大约 133M。如果每次 Minor GC 只有 20M 对象存活,133M 的 Survivor 完全够用;如果经常溢出,对象就会提前晋升到老年代,老年代曲线就会往上走。
常见做法是:对象生命周期很短的业务,可以把年轻代比例调大,比如NewRatio=1;如果老年代持续增长,大概率不是年轻代太小,而是大对象或缓存数据的问题,不要只顾着调比例。
需要提醒的是,NewRatio和SurvivorRatio对 Parallel 和 CMS 这类回收器作用最直接。如果已经切到 G1,新生代大小更多由自适应策略决定,手动大调SurvivorRatio基本是白费功夫。
2.3 为什么生产环境要把Xms和Xmx设为一样
JVM 默认会随着压力伸缩堆。这个伸缩在关键时刻不是免费的,可能触发额外的 GC 或者分配器锁竞争,带来不确定行为。生产环境最忌讳的就是这种"不确定性"。
所以我把-Xms和-Xmx设为一样,让 JVM 启动时一次性分配到位。对容器来说也更容易预判内存上限,不会出现明明压测过了,上线后堆还在慢慢扩的情况。
我见过一个案例,服务启动参数是-Xms256m -Xmx4g,刚启动时堆只有 256M,业务流量一上来堆急扩,结果那个时段频繁 GC,接口超时一大片。虽然本质原因不完全是堆伸缩,但固定堆确实能少一个变量。
3. 垃圾回收器参数:选型、目标值和G1细节
垃圾回收器的参数,是 JVM 调优里最容易被"玄学化"的部分。我先说结论:不要迷信某个回收器,也不要盲目追求极低的停顿目标。回收器选型要匹配业务场景,参数设置要基于 SLA 和压测。
3.1 从Parallel到G1,什么时候选谁
JDK 8 默认是 Parallel,JDK 9+ 默认是 G1。Parallel 的思路是吞吐优先,GC 停顿偏长;G1 把堆切成 Region,追求可预测的停顿。
选型依据很简单:在线接口服务,对延迟敏感,就用 G1;离线批量任务,追求单位时间吞吐,Parallel 往往更合适;CMS 在 JDK 14 已经被移除,老项目如果还停在 JDK 8 + CMS,能迁就尽早迁。
| 回收器 | 特点 | 适用场景 | 关键参数 |
|---|---|---|---|
| Parallel | 吞吐优先,STW 停顿偏长 | 离线计算、批量任务 | -XX:+UseParallelGC |
| CMS | 并发标记,老年代碎片化 | JDK 8 遗留项目 | -XX:+UseConcMarkSweepGC,已废弃 |
| G1 | Region 化,可配置停顿目标 | 在线服务默认选择 | -XX:+UseG1GC、MaxGCPauseMillis、IHOP |
| ZGC | 超低停顿,CPU 开销高 | 超大堆、超低延迟 | -XX:+UseZGC,注意版本支持 |
3.2 MaxGCPauseMillis怎么定:按SLA倒推
MaxGCPauseMillis是 G1 挑选回收 Region 数量时的输入,不是硬性承诺。设得越小,G1 越想缩短单次停顿,结果可能是回收频率上升,吞吐下降;设得太大,停顿又会不可控。
怎么定依据?假设接口 P99 延迟 SLA 是 200ms,GC 停顿只是其中一部分,网络、数据库、业务代码都要吃掉延迟预算。我会把 GC 停顿预算设在 50~100ms,也就是-XX:MaxGCPauseMillis=100,然后跑压测看是否达标。
见过有人把目标设成 1ms,结果 G1 每次都只回收很少的 Region,GC 频率高到爆炸,吞吐掉了一半。调这个参数,目的不是让 GC 越少越好,而是让停顿落在 SLA 允许范围内,同时不牺牲太多吞吐。
3.3 IHOP、RegionSize和Humongous:G1最容易忽视的联动关系
G1 里有个参数叫InitiatingHeapOccupancyPercent,默认 45。意思是堆占用达到 45% 时,G1 启动并发标记,为后续回收获取存活信息。如果存活对象占比高,45% 可能偏早,会增加并发标记频率;如果设得太高,并发标记跟不上分配速度,可能憋成 Full GC。
这个参数的依据是全局存活对象占比,需要配合 GC 日志里的存活数据来判断,不是拍脑袋填的。
G1HeapRegionSize则是 G1 把堆切成 Region 的大小,默认按堆大小自动推算,目标是让 Region 数量在 2048 左右。Region 大小会影响大对象的处理方式:单个对象超过 Region 的一半,就会被当作 Humongous 大对象,直接占用整块 Region,GC 时按整块回收。
如果堆只有 2G,Region 大约 1M,一个 3M 的 byte[] 就要占 3 块 Region。频繁出现大数组的缓存或者流处理服务,可以把 Region 设成 4M 或 8M,减少跨 Region 碎片;但 Region 太大,普通小对象占着一整块 Region,浪费空间。这个取舍,同样要由业务对象大小分布来决定。
4. 堆外参数:Metaspace、Xss、DirectMemory、CodeCache
堆外内存经常被忽略,但恰恰是很多线上事故的源头。Metaspace 洰出来是类加载器泄漏,线程栈洰出来是高并发线程数,直接内存洰出来是 NIO 用多,CodeCache 洰出来是动态生成类太多。每一笔都要算进内存账本。
4.1 MetaspaceSize的真正含义
很多人以为 MetaspaceSize 是初始大小,这是错的。它更像一个水位线:类元数据占用超过这个值,JVM 就会触发类卸载相关的 GC。JDK 8 默认水位大约 20.79M。
很多类库多的应用,启动阶段就会到这个值,于是出现频繁的 metadata GC。你以为是内存泄漏,其实只是默认阈值太低。正确做法是把MetaspaceSize和MaxMetaspaceSize都设成业务合理上限,比如 512M,避免水位一次次触发。再用-Xlog:gc+metaspace观察实际占用,如果容器持续上涨,那才是真正的类加载器泄漏。
注意:
MaxMetaspaceSize默认是无上限的,不设的话,类加载器泄漏时进程会一直涨到物理内存被榨干,系统层面把你杀掉。设一个上限,至少能在 Java 层面抛出OutOfMemoryError: Metaspace,留下排障线索。
4.2 Xss线程栈:每一根线程都是成本
Linux x64 上,JVM 默认每个线程栈 1M。一个 500 线程的接口服务,光线程栈就是 500M。如果你用的是普通线程池,这个数字是实实在在的进程内存,GC 管不到它,操作系统管。
将-Xss设成 512K 可以省一半。风险是递归深度大的代码可能爆栈。设置依据是调用深度:Spring Boot/Tomcat 这类框架的正常业务,512K 一般够用;如果代码里有很深的递归,比如目录遍历、复杂树操作,先压测验证,别直接上 256K。
还有一种思路是减少线程数而不是减小栈。很多服务线程池开得很大,但活跃线程远没那么多,与其 1000 个线程等任务,不如把核心线程控制在合理范围,内存和上下文切换都省了。
4.3 DirectMemory与CodeCache的隐形预算
直接内存的默认上限在不同 JDK 实现和版本里不完全一样,最好显式设置。Netty、Kafka、Spark 都会用堆外内存,不显式设置,可能等到 OOM 才察觉,而且堆外 OOM 的报错信息分布很广,定位成本高。
我的做法是给 NIO 服务设-XX:MaxDirectMemorySize=512m,并在前面 1.2 的内存账本里给它单列一行。这样内存预算里有多少是堆外,一目了然。
CodeCache 存放的是 JIT 编译产物。JDK 8 分层编译开启时默认约 240M。高频动态代理、CGLIB、Groovy、JSP 重编译容易把 CodeCache 打满,打满后 JIT 会被禁用,性能突然下滑,日志里出现CodeCache is full。
预防方式:设-XX:ReservedCodeCacheSize=256m或 512M,并定期用jcmd <pid> Compiler.codecache看占用。注意,CodeCache 大不代表好,它只影响可以缓存多少编译产物,实际调优还是要减少不必要的动态生成类。
5. 容器时代:RAM百分比与日志诊断参数
现在大多数应用跑在容器里,JVM 参数如果不适配容器,会出现各种"看起来堆没满,进程却被杀了"的怪问题。这部分的重点,一个是让 JVM 看懂容器的内存限制,一个是把诊断参数配齐。
5.1 MaxRAMPercentage家族和版本差异
JDK 8 早期版本不识别 cgroup,直接读宿主机内存。负载机器有 96G 内存,容器限制 4G,JVM 按默认最大堆约为物理内存 1/4 的规则,算出 MaxHeap 高达 24G。虽然初始用不到那么多,但堆会随着业务压力逐步增长,一旦超过容器 4G 限制,就会被内核 OOM Killer 杀掉,而且不会留下任何 Java 堆转储。
JDK 8u191 之后和 JDK 9+ 支持容器感知,默认开启UseContainerSupport,可以用-XX:MaxRAMPercentage让堆上限跟着容器限制走。容器 4G 时,-XX:MaxRAMPercentage=60等于 2.4G 堆。如果你同时设置了-Xmx,则以-Xmx为准。
老项目卡在旧 JDK 8 上不能升级的,也别裸奔,至少把-Xmx显式设置到容器能承受的范围。这不是调优问题,是能不能活下来的问题。
5.2 必配的诊断参数:GC日志与OOM快照
没有 GC 日志的 JVM 调优,等于闭眼开车。JDK 8 和 JDK 9+ 的日志参数语法不一样,分别记一下。
JDK 8:
-Xloggc:/data/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=7 -XX:GCLogFileSize=16M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heapdump.hprofJDK 9+:
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=7,filesize=16m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heapdump.hprofGC 日志里重点看三类信息:Minor GC 触发原因、Full GC 的 cause、Concurrent Mark 的进度。另外,GC 日志文件要放在独立磁盘或卷上,不然高并发下 GC 日志本身的写入 IO 会把应用拖下水。
5.3 实例:堆还没满进程被OOM Killer杀掉
几个月前我帮一个团队排查服务频繁重启的故障。监控显示容器内存从 1G 慢慢涨到 4G,然后进程消失,没有任何 Java 异常堆栈,也没有 Heap Dump——这是被内核 OOM Killer 干掉的典型特征。
检查启动参数发现:JDK 8u162,不支持容器感知,没设 Xmx,也没设 MaxRAMPercentage。JVM 按宿主机 96G 内存估算 MaxHeap 高达 24G,堆随着流量越涨越大,直到超过容器 4G 限制被杀。
解决方式很简单:JDK 升到 8u191+,或者显式设置-Xmx2g并配合-XX:MaxRAMPercentage=60。之后观察两周,内存曲线稳定在 2.5G 以内,服务再没出现过"神秘消失"。
6. 常见坑位与一份可落地的参数底稿
把前面的内容浓缩成一份自查清单和一套模板,方便实际用。记住,任何模板都要用压测验证,不要无脑抄。
6.1 高频问题速查表
| 现象 | 优先检查 | 惯用解法 |
|---|---|---|
| 启动后频繁 Full GC,堆实际很小 | Xms/Xmx 不一致、MetaspaceSize 默认阈值 | 固定堆,调大 MetaspaceSize |
| 老年代持续增长 | 大对象、缓存数据、Humongous 分配 | 检查对象大小分布,调整 RegionSize 或修代码 |
| G1 下 GC 频率高 | MaxGCPauseMillis 设太小、IHOP 不合适 | 调大停顿目标,按存活对象占比调 IHOP |
| 高并发线程多、内存曲线高 | Xss 默认 1M | 降到 512K 并压测验证 |
| 日志出现 CodeCache is full | ReservedCodeCacheSize 偏小、动态生成类多 | 调大 CodeCache,减少动态代理/脚本编译 |
| 容器内进程被 OOM Killer 杀 | JDK 版本、MaxRAMPercentage | 升级 JDK 或显式设置 Xmx |
6.2 我的基线参数模板
JDK 8:
-Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MaxMetaspaceSize=512m -XX:MetaspaceSize=512m -Xss512k -XX:MaxDirectMemorySize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heapdump.hprof -Xloggc:/data/logs/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=7 -XX:GCLogFileSize=16MJDK 11+:
-Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:MaxMetaspaceSize=512m -XX:MetaspaceSize=512m -Xss512k -XX:MaxDirectMemorySize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/heapdump.hprof -Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=7,filesize=16m如果希望用容器比例而不是显式堆大小,就把-Xms和-Xmx去掉,替换成-XX:InitialRAMPercentage=60 -XX:MaxRAMPercentage=60。这两套模板是起点,不是终点。
6.3 最后的小建议
调参这件事,我一直的建议是:一次只改一个参数,记录改动前后的 GC 日志和压测曲线。GC 日志比调参更值得先看,你如果在日志里都看不出当前瓶颈,加了参数也只是安慰自己。
另外,每次升级 JDK,默认行为都可能不同。从 JDK 8 到 JDK 17,G1 的参数行为、ZGC 的支持状态、容器感知的默认开启都有差异,别拿旧启动参数直接套新运行环境。我踩过最典型的坑就是低版本 JDK 的启动参数直接搬到新容器平台,结果 GC 完全不是预期表现,最后才发现是默认回收器和 Region 行为变了。
如果你的服务目前跑得好好的,不要为了调优而调优。JVM 参数设置的最终依据永远是业务指标:吞吐达标、停顿在 SLA 内、内存有预留、故障有日志。满足这四条,就比绝大多数"复制粘贴式调优"强得多。