一次 JVM 降配评估:内存长期 99% 的实例,能不能降到 4 核 8G?

封面:JVM 降配评估

公司推行降本增效,要求把一批 8 核 16G 的虚拟机降配成 4 核 8G。监控上这批机器的内存长期停在 99%,我本来以为只是应用占得多。翻完启动脚本才发现,这笔账不是这么算的。

本篇你能学到 ​

  • 用总内存、堆、非堆与 OS 三块把一台机器的内存账算平,判断还有没有降配空间
  • 从 -Xms 和 -XX:MaxNewSize 两个参数快速识别被配置堵死降配的实例
  • CMS 切 G1 时要删哪些参数,以及为什么不能手设新生代大小
  • 用内存水位和 Full GC 间隔两个指标做灰度验收

一、先把账算清楚:99% 的内存被谁占了 ​

1. 监控只给结果,不给构成 ​

降配评估最容易犯的错,是盯着监控曲线下结论。CPU 低就说可以降,内存高就说不能降,这两种判断都跳过了关键一步:内存到底被谁占着。

所以第一步不是改配置,而是把三样东西固定下来:实例规格、CPU 曲线、内存曲线。这三样决定了后面所有推演的地基。

虚拟机规格 8 核 16G。从监控看,CPU 使用率长期偏低,这是 CPU 可以立即降配的依据。内存使用率长期维持在 99% 高位,这是内存降配危险的核心信号。

2. 打开启动脚本,看 JVM 锁了多少 ​

为什么先看启动参数?因为 JVM 对内存的占用,在进程启动那一刻就定下了大头。监控上的水位,只是这份配置在运行时的投影。

怎么做?找到服务的启动脚本,把 SERVER_JAVA_OPTS 完整打印出来。

bash
export SERVER_JAVA_OPTS="
  -javaagent:/path/to/apm-agent.jar
  -DLog4jContextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector
  -Xms8192m
  -Xmx8192m
  -XX:MaxNewSize=128m
  -XX:+UseConcMarkSweepGC
  -XX:+PrintGCDetails
  -XX:+PrintGCTimeStamps
  -XX:+HeapDumpOnOutOfMemoryError
  -verbose:gc
  -Xloggc:$LOG_PATH/gc.log
"

javaagent 等 APM 探针参数与本文分析无关,已简化。真正要紧的是下面这几个参数。

参数含义
-Xms 8192m / -Xmx 8192m堆内存初始/最大大小,启动即锁定 8GB
-XX:MaxNewSize 128m新生代最大仅 128MB(占堆 1.5%)
-XX:+UseConcMarkSweepGCCMS 收集器
-XX:+PrintGCDetails 等打印 GC 详情与时间戳
-XX:+HeapDumpOnOutOfMemoryErrorOOM 时自动生成堆 dump

有两个数字很刺眼。-Xms 8192m 让堆在启动瞬间锁定 8GB,-XX:MaxNewSize 128m 让 8GB 的堆里新生代只剩 128MB。

3. 剩下 8GB 去哪了 ​

16GB 总内存减去 8GB 堆,还剩 8GB。这 8GB 要装下非堆内存,包括 Metaspace、线程栈、直接缓冲区等,还要留给操作系统自身开销。

实测下来,这部分实际占用了约 8GB。也就是说整机内存几乎没有余量,这正是监控里 99% 使用率的来源。降到 8G 总内存后,连 JVM 自己都装不下。

把账摊开看:堆 8GB 加非堆与 OS 8GB,正好等于 16GB 的总内存。

配图

图 1:内存账本,16G 里堆与非堆各占 8G,整机水位 99%

图 1:内存账本,16G 里堆与非堆各占 8G,整机水位 99%

预期结果:账算完,结论已经很清楚。这台机器没有可压缩的空间,降配到 8G 会在启动阶段直接失败,与业务代码写得好不好无关。

二、两个核心问题:账不平,新生代还畸形 ​

1. 内存账算不平,8G 机器装不下 8G 堆 ​

为什么强调这一点?因为很多人降配时只改机器规格,JVM 参数原样带过去,结果服务根本起不来。

推演过程很简单,算式只有一行:8GB 堆 + 非堆与 OS > 8GB 总内存。-Xms 8192m 会在启动瞬间锁定 8GB 堆内存,而实例总内存只有 8GB。操作系统连自身运行所需的内存都没有,进程会被 OOM Killer 直接杀掉。

这类故障在监控上的表现不是慢,而是断。进程起来几分钟后失联,日志里看不到业务异常。预期结果就是直接降配必挂,没有任何侥幸空间。

2. 新生代 128MB,Full GC 每 20 小时一次 ​

第二个问题更隐蔽。8GB 的堆里,新生代只有 128MB,算下来是 128MB / 8192MB ≈ 1.5%,这个比例小得离谱。

新生代太小会引发三步连锁反应。对象在 Eden 区活不了几轮就被挤到老年代,因为 Eden 太小、Minor GC 太频繁,对象稍微多活一会就超过晋升阈值。大量本应朝生夕死的临时对象因此被晋升到老年代。老年代快速填满,触发 CMS Full GC。

查证方法是看 GC 日志。启动参数里的 -Xloggc:$LOG_PATH/gc.log 已经把日志落盘,GC 日志显示,平均 20 小时一次 Full GC。

预期结果是频繁 Full GC 带来的停顿直接反映到业务响应时间上。内存水位高不是根因,配置畸形才是。

三、优化方案:堆降到 4GB,收集器换 G1 ​

1. 先定参数,再动机器 ​

为什么先改 JVM 再谈降配?因为降配目标是 4 核 8G,而 8G 机器装不下 8G 堆。只有把堆降下来,机器规格才有调整的余地。

怎么做?按下面的对照表逐项替换。不要只改 -Xmx 留下 -Xms,也不要保留任何 CMS 相关参数。

配置项调整前调整后理由
堆内存-Xms8192m -Xmx8192m-Xms4096m -Xmx4096m堆降到 4GB,给非堆和 OS 留出空间
新生代-XX:MaxNewSize=128m删除参数,交给 JVM 自动管理128m 太小;自动管理约为堆的 1/3
GC 收集器-XX:+UseConcMarkSweepGC-XX:+UseG1GCG1 更适合大堆与高吞吐场景
停顿目标无-XX:MaxGCPauseMillis=200G1 的目标最大停顿时间

2. 调整后的启动参数 ​

最终使用的关键参数是这几个。

bash
-Xms4096m
-Xmx4096m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-Xloggc:$LOG_PATH/gc.log

CMS 相关参数全部移除。G1 下不需要也不建议手动设置新生代大小,-XX:MaxNewSize 应当删除,否则会干扰 G1 的暂停预测模型。

还有一个容易忽略的点:-XX:MaxGCPauseMillis=200 是软目标。它让收集器按 200 毫秒去规划回收节奏,不是硬性保证。预期结果是单次停顿可控,而不是每次都不超过 200 毫秒。

3. 预期结果 ​

堆降到 4GB 后,16GB 机器上非堆与 OS 仍占约 8GB,整机水位应从 99% 回落到 70% 左右。

新生代交给 JVM 自动管理,大约是堆的三分之一。临时对象能在 Eden 里正常消亡,晋升到老年代的数量明显减少。

Full GC 的频率随之下降,配合 G1 的停顿目标,业务 RT 的毛刺会收敛。这些预期需要在灰度阶段逐项验证,不能只看一条曲线。

四、灰度验证:先跑一台,再谈降配 ​

1. 为什么必须灰度 ​

参数变更同时影响内存、GC 和 RT。一次全量推开,出问题时分不清是参数、流量还是机器本身。灰度一台,等于给这次变更留一个可对照的样本。

怎么做?先拿 1 台实例替换启动参数,观察一周。这一周要覆盖完整的业务周期,包括高峰和跑批。

2. 三个验收指标 ​

观察期只看三个指标,其他曲线作为辅助。

  1. 内存使用率是否从 99% 回落到 70% 左右的健康水位;
  2. GC 频率和耗时是否明显下降,尤其 Full GC 是否消失、单次停顿是否可控;
  3. 业务响应时间是否保持稳定。

三项都达标,才说明参数调整生效。如果内存水位降了但 Full GC 依旧频繁,要回头检查是否有大对象直接进入老年代。

3. 按批次推进降配 ​

灰度通过后,再按批次推进降配。整条路径是 灰度 1 台 → 观察 7 天 → 分批降配。每批保留旧的启动参数和回滚脚本,出现异常时能在一台机器上快速还原。

预期结果是参数先落地、规格后调整,两步之间留出观察期。这样即使降配后出现问题,也能判断是 JVM 配置还是机器资源导致的。

常见错误与排错 ​

  • 现象:按 8G 规格直接起服务,进程秒退或几分钟后失联。原因:-Xms8192m 在启动瞬间锁定 8GB 堆,8G 机器没有内存留给操作系统,进程被 OOM Killer 杀掉。解法:先把堆降到 -Xms4096m -Xmx4096m,再调整机器规格。
  • 现象:切到 G1 后停顿目标形同虚设,Young GC 次数异常。原因:-XX:MaxNewSize=128m 还留着,手设新生代干扰了 G1 的暂停预测模型。解法:删除该参数,新生代交给 JVM 自动管理。
  • 现象:参数改完内存水位还在高位,以为配置没生效。原因:只看水位没看 GC,老年代仍在被临时对象快速填满。解法:对比 -Xloggc 日志里的 Full GC 间隔,确认老年代增长是否放缓。
  • 现象:灰度机器正常,全量推开后部分实例 RT 抖动。原因:灰度时间太短,没覆盖业务高峰。解法:至少观察一周,覆盖完整业务周期后再按批次推进。

本篇小结 ​

  • -Xms 是硬承诺:只要它等于机器总内存的一大半,这台机器就没有降配的可能性。
  • 内存 99% 不一定是用得多,可能是配得差:新生代 128m 的畸形配置才是 Full GC 频繁的根因。
  • 换 G1 要连带删参数:CMS 相关参数和 -XX:MaxNewSize 都要清掉,否则新收集器跑不出预期。
  • 先灰度一台,再按批次降配:一周观察期换来的是可回滚的节奏。