本节摘要:几百个 -XX 参数可归为内存、收集器、诊断三类,处方的逻辑永远是"先定内存框架、再选收集器、最后补诊断开关"。容器环境下要按"堆加四笔堆外账"算清进程总内存,用好容器感知与 MaxRAMPercentage。本节给出一套可解释的基础处方模板与容器账目算法。
前几节完成了诊断,本节把结果翻译成启动参数。先立一条纪律:参数是诊断的下游,脱离诊断的调参是抽卡。同一组参数在 A 服务是优化、在 B 服务是事故,差别只在负载特征。所以本节不讲"神参数清单",讲开处方的思考顺序。
HotSpot 参数数量上千,但日常打交道的可归为三层。内存框架层:-Xms 与 -Xmx 定堆,-XX:MetaspaceSize 与 MaxMetaspaceSize 定元数据,-Xss 定线程栈,-XX:MaxDirectMemorySize 定直接内存——这一层决定"进程的骨架尺寸"。收集器层:-XX:+UseG1GC、UseZGC 等选择引擎,配套 MaxGCPauseMillis 等目标参数——这一层决定"保养策略"。诊断层:-Xlog:gc、HeapDumpOnOutOfMemoryError、JFR 相关——这一层决定"下次事故有没有现场"。
默认值观也很重要:现代 JDK 的默认值(G1、分层编译、TLAB)是大量生产验证的产物,多数服务不需要偏离。调参的正确姿势是"默认加例外":默认全收,只对诊断出的具体问题开例外,每个例外都写得出来由。背口诀式的"必调参数清单"是上一时代的遗产。
# 内存框架:堆给容器的约一半到七成,视堆外占用而定(下文有算法) -Xms4g -Xmx4g -XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=1g # 收集器:通用默认 G1,停顿目标按业务 SLA -XX:+UseG1GC -XX:MaxGCPauseMillis=200 # 诊断:事故现场必备三件 -Xlog:gc:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/
逐项说清理由:Xms 与 Xmx 设同值,省掉堆伸缩的抖动,也让容量评估确定(动态伸缩适合内存紧张的环境,那是另一个取舍);Metaspace 显式封顶,防止动态类失控吃光容器配额(第二章 2.2 的教训);直接内存显式封顶同理(2.4 的教训);G1 加两百毫秒停顿目标是一般 Web 服务的合理起点,批处理任务换成 Parallel、大堆低停顿换 ZGC(5.3 的选型表);诊断三件让"出事自带现场",是 6.3 复盘模板的直接落地。这套模板的价值不在参数本身,而在每一项都能回答"为什么"。
容器时代最大的调参陷阱是"按堆大小设配额"。第二章早已铺好账本,现在正式合拢——一个 Java 进程的常驻内存至少包括五笔:
进程 RSS ≈ 堆(Xmx) + Metaspace(含压缩类空间) + 线程数 × Xss(每根线程的栈) + 直接内存(MaxDirectMemorySize) + JIT 代码缓存与 GC 自身开销、符号区等杂项
举例:容器配额四 GB,堆给四 GB,应用有一百多根线程(每栈一兆)、元数据几百兆、直接内存一 GB——账目轻松超过配额,然后被操作系统 OOM killer 干掉,日志里连 OutOfMemoryError 都没有(这不是 JVM 的 OOM,是容器层面的杀进程,症状是容器无故重启、退出码一百三十七)。很多"K8s 里 Java 无故死亡"的事故,本质都是这本账没算。
JDK 8u191 与 JDK 10 之后具备了容器感知(UseContainerSupport 默认开启):JVM 能读到 cgroups 的配额,基于它推断 CPU 数与可用内存——按核数定 GC 线程与编译线程、按内存比例定堆。默认规则是堆取配额的四分之一(老版本),更可控的姿势是显式给比例:
-XX:MaxRAMPercentage=60.0 # 堆最多占可用内存的六成 -XX:InitialRAMPercentage=60.0
六成是个实用起点:留四成给元数据、线程栈、直接内存与杂项。若直接内存大户(网关、消息类服务),把比例再往下压,或用 MaxDirectMemorySize 明确圈走一块。
动手验证容器感知是否生效:
$ java -XX:+PrintFlagsFinal -version 2>&1 | grep -E "ActiveProcessorCount|MaxHeapSize" uintx ActiveProcessorCount = 4 {product} size_t MaxHeapSize = 1073741824 {product} (宿主机核数远多于四核,说明 JVM 读到了 cgroups 的配额)
再验证实际配比,容器里执行 java -XX:+PrintCommandLineFlags -version,确认 MaxRAMPercentage 已被翻译进 MaxHeapSize。cpuset 与 cpu share 两类限制对 GC 线程数的影响也不同:share 模式下老版本可能高估核数、开出一堆 GC 线程互相争抢,显式 ActiveProcessorCount 是兜底开关。
⚠️ 常见坑:容器里看到"JVM 参数设了但没生效"。一处常见原因是 JDK 8 老版本(8u191 之前)没有容器感知,堆被按宿主机内存推断,配额被瞬间击穿。版本是容器 JVM 问题的第一排查项,很多"玄学"在新版里早已是已知修复项。
⚠️ 常见坑:把退出码一百三十七当 JVM 崩溃处理,重启了事。一百三十七是容器杀进程(OOM kill)的标记,重启只是重演。正确动作是核对容器配额与五笔账目,压堆比例或提配额,并用 HeapDumpOnOutOfMemoryError 之外的容器层监控(memory working set)盯水位。
💡 关键直觉:调参的本质是"给不确定的负载划定确定的资源边界"。堆多大、收集器怎么选、堆外每笔记多少,全是把第二章到第五章的机制知识换算成数字。能写出来由的参数才值得改——说不出理由的调整,只是把问题搬运到下周。
处方学收尾,第六章全部完成。最后一章把病灶清单里最顽固的一类——锁竞争——从机制层面讲透:线程怎么被调度、内存可见性凭什么规则、锁三级升级的完整路径,以及虚拟线程带来的范式变化。