一个服务在容器里配了 4G 内存上限,JVM 启动参数没设堆大小,按老版本行为它读到了宿主机的 64G,把最大堆设成了 16G。运行一段时间后容器被 OOM 杀掉,而 GC 日志显示堆只用了 3G。
问题不在堆用得多,而在 JVM 看到的"可用内存"与容器实际给的限额不是同一个数。容器感知这个能力直接决定 JVM 按哪个数去算堆、算线程、算 GC 目标。
1. 三个数先对齐
| 数 | 来源 | 用途 |
|---|---|---|
| 宿主机物理内存 | /proc/meminfo |
老版本 JVM 的默认依据 |
| cgroup 限制 | /sys/fs/cgroup/memory.max |
容器真实上限 |
| 进程实际占用 | RSS + 堆外 | OOM 判定 |
容器感知要做的就是把第一项换成第二项。 8u191 之后默认开启(-XX:+UseContainerSupport),但仍有三种情况会失效:显式设了 -Xmx、用了容器限额之外的 MaxRAM 覆盖、或者运行在限额未设置的环境里(此时按宿主机算,反而是对的)。
// 确认 JVM 看到的限额与堆设置
java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|UseContainerSupport|MaxRAM"
cat /sys/fs/cgroup/memory.max # 容器真实上限(v2)
// 若 MaxHeapSize 明显大于容器上限,容器感知没有生效或被人为覆盖
2. 堆之外还有四块内存
| 区域 | 典型量级 | 是否受 Xmx 约束 |
|---|---|---|
| Java 堆 | 由 Xmx 决定 |
是 |
| 元空间 | 数十到数百 MB | 否(有独立上限) |
| 直接内存(NIO) | 与网络/文件 IO 相关 | 否(需单独设) |
| 线程栈 | 线程数 × 1MB 级 | 否 |
| 代码缓存 | 数十 MB | 否(有独立上限) |
估算时有个容易漏的量:线程数乘栈大小。两百个线程各 1MB 就是 200MB,而多数人只盯着堆。线程一多,留给堆的实际空间就被压缩了。
容器限额管的是这五项之和,而 Xmx 只管第一项。 这是"堆只用了 3G 却被杀"的标准解释:剩下 1G 被元空间、直接内存、线程栈与代码缓存分掉了。
// 一个可用的组合:按容器限额算总账,而不是只设 Xmx
-XX:MaxRAMPercentage=65 // 堆 = 容器上限 × 65%
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=256m
-XX:ReservedCodeCacheSize=128m
-Xss=512k // 线程栈,按线程数估算
// 65% + 25% + 10% ≈ 容器上限,剩余作为浮动余量
3. CPU 感知与 GC 线程
| 参数 | 依据 | 未感知的后果 |
|---|---|---|
| GC 线程数 | 可用 CPU 数 | 线程过多,互相争抢 |
| JIT 编译线程数 | 可用 CPU 数 | 编译占用过多 CPU |
| ForkJoin 并行度 | 可用 CPU 数 | 并行任务实际串行 |
| 堆大小 | 可用内存 | 见上一节 |
这类问题的排查入口很明确:看 GC 日志里的线程数与停顿分布。如果 GC 线程多但每次停顿并不短,说明线程在争抢而不是并行工作。
在 0.5 核或 1 核的容器里,JVM 若按宿主机 32 核去设 GC 线程,会造成严重的线程争抢。 这类问题的表现是"GC 时间占比不高但应用很慢"。
// 小数核容器必须显式约束并行度
-XX:ActiveProcessorCount=2 // 显式告诉 JVM 有几个核
-XX:ParallelGCThreads=2
-XX:ConcGCThreads=1
-XX:ForkJoinPool.common.parallelism=2
// 注意:ActiveProcessorCount 会覆盖 cgroup 探测结果,需与限额一致
4. 容器内排查的三个入口
| 入口 | 能看到什么 |
|---|---|
-Xlog:gc* |
堆使用、GC 停顿、晋升 |
/sys/fs/cgroup/memory.events |
OOM 次数与限流事件 |
/sys/fs/cgroup/cpu.stat |
节流次数 |
第二项的 oom_kill 计数是判断"是否被 cgroup 杀"的直接证据,比看应用日志可靠。
排查顺序也有讲究:先看 memory.events 有没有 OOM,再看 cpu.stat 有没有节流,最后才看堆。先从"容器层"看,再进"JVM 层",能避免在堆上做无用调优。
// 一次看全三个入口,判断是"堆不够"还是"总内存不够"
grep -E "oom|max" /sys/fs/cgroup/memory.events
grep -E "nr_throttled|throttled_usec" /sys/fs/cgroup/cpu.stat
jcmd 1 GC.heap_info // 堆实际使用与容量
5. 什么时候不该用百分比
| 配置方式 | 适用 | 风险 |
|---|---|---|
MaxRAMPercentage |
容器限额稳定 | 限额变动时堆跟着变,行为漂移 |
固定 Xmx |
需要稳定行为 | 限额调小后没有余量 |
| 两者结合(百分比 + 上限) | 多环境统一 | 配置略复杂 |
| 完全不设 | 开发环境 | 生产不可控 |
可以用一条启动检查把这件事固化下来,避免"改限额忘改堆":
// 启动脚本里校验:堆 + 堆外预算 ≤ 容器限额
LIMIT=$(cat /sys/fs/cgroup/memory.max)
HEAP=$(java -XX:+PrintFlagsFinal -version 2>/dev/null | awk '/MaxHeapSize/{print $4}')
OVERHEAD=$((512*1024*1024)) // 元空间+直接内存+栈的估算值
[ $((HEAP+OVERHEAD)) -le "$LIMIT" ] || { echo "堆预算超过容器限额"; exit 1; }
第二项的教训很具体:容器限额从 8G 调到 4G,而 Xmx 还是 6G,直接起不来。所以固定 Xmx 的环境必须有"限额 ≥ 堆 + 余量"的校验。
第三种是多数生产系统的合理选择:百分比给默认值,同时设一个绝对上限,避免大机型上堆被设得离谱。
还有一个常被忽略的量:page cache。容器内读写文件会在 cgroup 内存里计入页缓存,频繁读大文件的服务会因此提前触碰限额,而堆看起来毫无异常。
6. 排错:五种"容器里跑得不好"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 堆用得不多却被 OOM | 堆外内存未计入预算 | 设直接内存与元空间上限 |
| GC 停顿长但堆不大 | 堆过小或 GC 选型不当 | 调堆下限或换收集器 |
| 应用慢而 CPU 不高 | 被 CPU 限额节流 | 查 nr_throttled |
| 启动慢 | 类加载与 JIT 受 CPU 限制 | 减少启动期工作或提高限额 |
| 同一镜像不同环境行为不同 | 限额不同导致堆不同 | 固定 Xmx 或固定限额 |
节省空间的另一种做法是把线程数与栈大小一起算:容器里 512k 的栈够用,而默认 1M 在两百线程时就多吃 100MB。这类调整的收益是确定的,风险也低。
第五项说明百分比配置的副作用:同一个镜像在不同环境表现不同,是因为堆跟着限额变。这在"测试环境小、生产环境大"的场景里会造成性能差异,需要显式认知。
7. 边界:什么情况不必细调
| 场景 | 为什么 | 做法 |
|---|---|---|
| 物理机独占部署 | 无 cgroup 限额 | 按物理内存设堆 |
| 开发环境 | 无稳定性要求 | 不设,用默认 |
| 极短生命周期任务(批处理) | 启动即结束 | 只保证不 OOM |
| 堆需求远小于限额 | 无压力 | 设一个安全上限即可 |
还有一条与版本有关的边界:不同 JDK 版本的默认行为不同。老版本不感知容器、新版本默认感知、部分发行版改了默认值。所以同一份参数在不同版本上可能表现不一致——升级 JDK 时把它当作一次行为变更来评估,而不是顺手升级。
判断口径一句话:容器的限额会不会小于 JVM 默认推断的值。会,容器感知与显式参数就是必需的;不会,默认行为反而是对的。
8. 自测
- 容器感知要做的事用一句话怎么说?哪三种情况会让它失效?
- 堆之外还有哪四块内存?为什么"堆只用了 3G 却被杀"是正常的?
- 小数核容器为什么必须显式约束并行度?典型症状是什么?
- 判断"是否被 cgroup 杀掉"最直接的证据在哪个文件?
- 固定
Xmx与百分比配置各自的代价是什么?两者结合解决了什么问题? - 为什么"同一镜像在不同环境行为不同"可能是堆配置造成的?