小Cの已经记不起来的博客

在 Linux 容器中运行 Java 应用时的内存参数设置经验

一开始我把容器限制和 JVM 堆设成了一样大

说实话,我一开始真没觉得这事儿有多复杂。容器限制 2G,Java 启动参数里加个 -Xmx2g,看起来天经地义,堆最大就是 2G,容器也是 2G,刚好对上。结果跑了一段时间,低峰期没事,一到有批量任务的时候容器就被干掉了,docker inspect 一看退出码 137,宿主机 dmesg -T | grep -i oom 里能看到熟悉的 OOM killer 记录。

当时我还纳闷,JVM 堆不是限制在 2G 了吗,怎么还能超?后来才反应过来,容器里活着的又不只是 Java 堆。Metaspace、线程栈、GC 自身开销、code cache、直接内存、JNI、内存映射文件,这些都算在容器的 memory cgroup 里。堆顶到 2G,其他东西再吃几百 MB,容器限制 2G 根本兜不住。更坑的是,如果 Java 版本比较老,JVM 可能压根没正确识别容器限制,它看到的是宿主机的大内存,参数再没写对,出问题只是时间早晚。

先确认 JVM 到底看到了多少内存

遇到内存问题,我现在第一步不是瞎调参数,而是先看 JVM 眼里的内存到底是多少。容器里直接执行:

java -XshowSettings:vm -version

这个命令会把 JVM 识别到的最大堆、初始堆之类信息打出来。运行中的进程可以用:

jcmd 1 VM.flags
jinfo -flag MaxHeapSize 1

这里的 1 是容器里的 Java 进程 PID,具体看你的启动方式。顺便也看一眼 cgroup:

# cgroup v2
cat /sys/fs/cgroup/memory.max

# cgroup v1
cat /sys/fs/cgroup/memory/memory.limit_in_bytes

如果 JVM 显示的最大堆和容器限制明显对不上,那就要考虑 Java 版本和容器支持的问题。Java 10 以后默认支持容器感知,Java 8 的话得 8u191 之后的版本才比较靠谱,老版本需要加 -XX:+UseContainerSupport,再老的版本加参数也未必好使,能升级就升级吧,别在这种地方硬耗。

现在我的参数习惯

现在我在容器里跑 Java,基本不再直接写 -Xmx 顶满容器限制了。更常见的做法是用百分比参数,让 JVM 根据容器内存限制自己算:

JAVA_OPTS="-XX:MaxRAMPercentage=70.0 -XX:InitialRAMPercentage=40.0"

70% 不是啥铁律,只是一个比较稳的起点。普通 Web 服务、Spring Boot 这类,70% 到 75% 一般能留出 Metaspace、线程栈和堆外内存的空间。如果应用用了 Netty、直接内存比较多,或者会做大量文件映射,我会再往下压到 60% 到 65%。不要一上来就写 MaxRAMPercentage=90,容器里堆外内存翻车的时候,OOM killer 可不会跟你讲道理。

Docker 启动大概是这样:

docker run -d \
  --name my-java-app \
  --memory=2g \
  --memory-swap=2g \
  -e JAVA_OPTS="-XX:MaxRAMPercentage=70.0 -XX:InitialRAMPercentage=40.0" \
  my-java-app:latest

Kubernetes 里我会把 requests 和 limits 都写上,至少 limits 要写,不然 JVM 可能按节点内存来算,MaxRAMPercentage 算出来的堆能大得离谱:

resources:
  requests:
    memory: "1Gi"
  limits:
    memory: "2Gi"
env:
  - name: JAVA_OPTS
    value: "-XX:MaxRAMPercentage=70.0 -XX:InitialRAMPercentage=40.0"

还有一个常见坑是同时写 -Xmx 和 -XX:MaxRAMPercentage。这俩不是叠加关系,-Xmx 通常会直接覆盖百分比算出来的结果。要么用固定堆大小,要么用百分比,别两个都塞进去,最后自己都搞不清到底哪个生效。

别忽略堆外内存和容器里那些小限制

有一次堆内存看着很健康,GC 日志也正常,但容器 RSS 就是一直涨,最后还是被 OOM killer 带走了。后来用 Native Memory Tracking 看了一眼:

JAVA_OPTS="$JAVA_OPTS -XX:NativeMemoryTracking=summary"
jcmd 1 VM.native_memory summary

才发现直接内存和线程栈占了不少。像 Netty 这种框架,堆外内存最好也显式管一下,比如加 -XX:MaxDirectMemorySize=256m,具体给多少要看业务。线程数也别放飞,线程栈默认一个线程就是 1MB 左右,几百个线程就是几百 MB,容器内存小的时候这不是小数目。

排查的时候我一般会同时看容器整体和进程:

docker stats
ps -o pid,rss,vsz,cmd -p 1

如果容器退出码是 137,优先怀疑被 OOM killer 干了;如果是 Java 自己抛 OutOfMemoryError: Java heap space,那是堆内问题,方向不一样。最怕的就是堆内看着没事,堆外一直涨,最后容器直接没,日志里连个像样的报错都没有。

踩过的几个坑

第一个坑就是只设了 Kubernetes 的 requests 没设 limits,JVM 看到的是节点内存,MaxRAMPercentage 算出来的堆大得吓人。第二个坑是容器内存限制改了,但 Java 启动参数还是老的 -Xmx,等于白改。第三个坑是 -Xms 和 -Xmx 都设成容器限制,启动时堆直接占满,堆外内存一用就超。现在除非有明确压测数据,我一般不会把堆设到容器限制的 80% 以上。

说到底,容器里跑 Java,核心就一句话:容器限制是给整个进程的,不是只给 Java 堆的。先把 JVM 看到的限制确认清楚,再用 MaxRAMPercentage 留出余量,最后压测看 RSS,不要只看堆。参数这东西没有万能模板,但至少别让 -Xmx 和容器 limit 一样大,这个坑我踩过一次就够了。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放