JVM参数设置依据:从堆内存预算到GC停顿与容器落地

发布时间:2026/10/19 16:42:10
JVM参数设置依据:从堆内存预算到GC停顿与容器落地
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 线程 × 512K100M - CodeCache240M 堆最多可用约 3072M减法做完堆最多还能分 3G 左右。但我不会真的给-Xmx3g因为那意味着整个容器没有余量应对突发的原生内存增长。实际我会给 2.5G 左右留出 500M 给不确定因素。如果不想硬编码JDK 8u191 支持-XX:MaxRAMPercentage60在 4G 容器里正好是 2.4G 左右和上面算出来的结果非常接近。所以我的习惯是先做预算减法再用百分比参数或者显式 Xmx 落数值。注意-Xmx设得越大不代表性能越好。堆太大 GC 扫描范围也大某些场景下反而让单次停顿更久。堆大小的最终依据是压测峰值不是内存反正还有剩。2.2 新生代和老年代的比例如何定默认NewRatio2意味着年轻代只占堆的 1/3。对典型的 Web 请求对象绝大多数在一个请求内就死掉年轻代理论上越大越好可以减少对象过早晋升到老年代。但年轻代过大老年代就小Full GC 风险更高。取舍的依据是你业务里的对象存活曲线和压测数据。拿 4G 堆举例NewRatio2时年轻代约 1.33G老年代约 2.67G。再看SurvivorRatio8Eden 和两个 Survivor 的比例是 8:1:1也就是 Eden 大约 1.07G每个 Survivor 大约 133M。如果每次 Minor GC 只有 20M 对象存活133M 的 Survivor 完全够用如果经常溢出对象就会提前晋升到老年代老年代曲线就会往上走。常见做法是对象生命周期很短的业务可以把年轻代比例调大比如NewRatio1如果老年代持续增长大概率不是年轻代太小而是大对象或缓存数据的问题不要只顾着调比例。需要提醒的是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 默认是 ParallelJDK 9 默认是 G1。Parallel 的思路是吞吐优先GC 停顿偏长G1 把堆切成 Region追求可预测的停顿。选型依据很简单在线接口服务对延迟敏感就用 G1离线批量任务追求单位时间吞吐Parallel 往往更合适CMS 在 JDK 14 已经被移除老项目如果还停在 JDK 8 CMS能迁就尽早迁。回收器特点适用场景关键参数Parallel吞吐优先STW 停顿偏长离线计算、批量任务-XX:UseParallelGCCMS并发标记老年代碎片化JDK 8 遗留项目-XX:UseConcMarkSweepGC已废弃G1Region 化可配置停顿目标在线服务默认选择-XX:UseG1GC、MaxGCPauseMillis、IHOPZGC超低停顿CPU 开销高超大堆、超低延迟-XX:UseZGC注意版本支持3.2 MaxGCPauseMillis怎么定按SLA倒推MaxGCPauseMillis是 G1 挑选回收 Region 数量时的输入不是硬性承诺。设得越小G1 越想缩短单次停顿结果可能是回收频率上升吞吐下降设得太大停顿又会不可控。怎么定依据假设接口 P99 延迟 SLA 是 200msGC 停顿只是其中一部分网络、数据库、业务代码都要吃掉延迟预算。我会把 GC 停顿预算设在 50~100ms也就是-XX:MaxGCPauseMillis100然后跑压测看是否达标。见过有人把目标设成 1ms结果 G1 每次都只回收很少的 RegionGC 频率高到爆炸吞吐掉了一半。调这个参数目的不是让 GC 越少越好而是让停顿落在 SLA 允许范围内同时不牺牲太多吞吐。3.3 IHOP、RegionSize和HumongousG1最容易忽视的联动关系G1 里有个参数叫InitiatingHeapOccupancyPercent默认 45。意思是堆占用达到 45% 时G1 启动并发标记为后续回收获取存活信息。如果存活对象占比高45% 可能偏早会增加并发标记频率如果设得太高并发标记跟不上分配速度可能憋成 Full GC。这个参数的依据是全局存活对象占比需要配合 GC 日志里的存活数据来判断不是拍脑袋填的。G1HeapRegionSize则是 G1 把堆切成 Region 的大小默认按堆大小自动推算目标是让 Region 数量在 2048 左右。Region 大小会影响大对象的处理方式单个对象超过 Region 的一半就会被当作 Humongous 大对象直接占用整块 RegionGC 时按整块回收。如果堆只有 2GRegion 大约 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:gcmetaspace观察实际占用如果容器持续上涨那才是真正的类加载器泄漏。注意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:MaxDirectMemorySize512m并在前面 1.2 的内存账本里给它单列一行。这样内存预算里有多少是堆外一目了然。CodeCache 存放的是 JIT 编译产物。JDK 8 分层编译开启时默认约 240M。高频动态代理、CGLIB、Groovy、JSP 重编译容易把 CodeCache 打满打满后 JIT 会被禁用性能突然下滑日志里出现CodeCache is full。预防方式设-XX:ReservedCodeCacheSize256m或 512M并定期用jcmd pid Compiler.codecache看占用。注意CodeCache 大不代表好它只影响可以缓存多少编译产物实际调优还是要减少不必要的动态生成类。5. 容器时代RAM百分比与日志诊断参数现在大多数应用跑在容器里JVM 参数如果不适配容器会出现各种看起来堆没满进程却被杀了的怪问题。这部分的重点一个是让 JVM 看懂容器的内存限制一个是把诊断参数配齐。5.1 MaxRAMPercentage家族和版本差异JDK 8 早期版本不识别 cgroup直接读宿主机内存。负载机器有 96G 内存容器限制 4GJVM 按默认最大堆约为物理内存 1/4 的规则算出 MaxHeap 高达 24G。虽然初始用不到那么多但堆会随着业务压力逐步增长一旦超过容器 4G 限制就会被内核 OOM Killer 杀掉而且不会留下任何 Java 堆转储。JDK 8u191 之后和 JDK 9 支持容器感知默认开启UseContainerSupport可以用-XX:MaxRAMPercentage让堆上限跟着容器限制走。容器 4G 时-XX:MaxRAMPercentage60等于 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:NumberOfGCLogFiles7 -XX:GCLogFileSize16M -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprofJDK 9-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags:filecount7,filesize16m -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:MaxRAMPercentage60。之后观察两周内存曲线稳定在 2.5G 以内服务再没出现过神秘消失。6. 常见坑位与一份可落地的参数底稿把前面的内容浓缩成一份自查清单和一套模板方便实际用。记住任何模板都要用压测验证不要无脑抄。6.1 高频问题速查表现象优先检查惯用解法启动后频繁 Full GC堆实际很小Xms/Xmx 不一致、MetaspaceSize 默认阈值固定堆调大 MetaspaceSize老年代持续增长大对象、缓存数据、Humongous 分配检查对象大小分布调整 RegionSize 或修代码G1 下 GC 频率高MaxGCPauseMillis 设太小、IHOP 不合适调大停顿目标按存活对象占比调 IHOP高并发线程多、内存曲线高Xss 默认 1M降到 512K 并压测验证日志出现 CodeCache is fullReservedCodeCacheSize 偏小、动态生成类多调大 CodeCache减少动态代理/脚本编译容器内进程被 OOM Killer 杀JDK 版本、MaxRAMPercentage升级 JDK 或显式设置 Xmx6.2 我的基线参数模板JDK 8-Xms2g -Xmx2g -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:MaxMetaspaceSize512m -XX:MetaspaceSize512m -Xss512k -XX:MaxDirectMemorySize512m -XX:ReservedCodeCacheSize256m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof -Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:UseGCLogFileRotation -XX:NumberOfGCLogFiles7 -XX:GCLogFileSize16MJDK 11-Xms2g -Xmx2g -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:MaxMetaspaceSize512m -XX:MetaspaceSize512m -Xss512k -XX:MaxDirectMemorySize512m -XX:ReservedCodeCacheSize256m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof -Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags:filecount7,filesize16m如果希望用容器比例而不是显式堆大小就把-Xms和-Xmx去掉替换成-XX:InitialRAMPercentage60 -XX:MaxRAMPercentage60。这两套模板是起点不是终点。6.3 最后的小建议调参这件事我一直的建议是一次只改一个参数记录改动前后的 GC 日志和压测曲线。GC 日志比调参更值得先看你如果在日志里都看不出当前瓶颈加了参数也只是安慰自己。另外每次升级 JDK默认行为都可能不同。从 JDK 8 到 JDK 17G1 的参数行为、ZGC 的支持状态、容器感知的默认开启都有差异别拿旧启动参数直接套新运行环境。我踩过最典型的坑就是低版本 JDK 的启动参数直接搬到新容器平台结果 GC 完全不是预期表现最后才发现是默认回收器和 Region 行为变了。如果你的服务目前跑得好好的不要为了调优而调优。JVM 参数设置的最终依据永远是业务指标吞吐达标、停顿在 SLA 内、内存有预留、故障有日志。满足这四条就比绝大多数复制粘贴式调优强得多。