Docker 与容器排查 列表全部文章
Docker 与容器排查

Java 搬进 Docker 就“水土不服”:容器 2G 内存,JVM 却以为自己有 64G

老 JDK 不识别 cgroup,容器限 2G 却按宿主机 64G 算堆,还按 32 核起线程,结果被 OOMKilled 或越跑越慢。讲清 UseContainerSupport、MaxRAMPercentage、堆外开销、cgroup v1/v2 文件,以及容器里 jstack/jmap 的正确姿势。

更多

公司推“容器化改造”,我负责的一个 Spring Boot 服务成了第一批小白鼠。迁移当天很顺利:镜像构建、compose 编排、灰度切流,一气呵成。我甚至还有心情给自己点了杯奶茶。

奶茶喝到一半,告警来了:容器每隔几小时就 OOMKilled 一次。而且比较气人的是,堆内存监控(Micrometer 的 jvm_memory_used_bytes)显示堆才用了 900MB,离容器限制的 2G 还远得很。第二天更糟:我把 -Xmx 手动设了之后倒是不被杀了,但接口 P99 反而比虚拟机时代慢了一倍。

这类“Java 进容器后的水土不服”其实是个系统性的问题:JVM 看到的世界和容器实际的世界不是同一个。这篇记录怎么查、为什么、怎么配,以及在容器里怎么给 JVM 做 jstack / jmap(这是很多人被卡住的另一个坑)。

一、现象:堆没满,容器却被杀;限了 2 核,线程却一堆

症状 1:OOMKilled,但 JVM 自己没报 OutOfMemoryError

$ docker inspect -f '{{.State.OOMKilled}} {{.State.ExitCode}} {{.HostConfig.Memory}}' order-svc
true 137 2147483648

OOMKilled=true、137、limit 是 2147483648 字节(2 GiB)。应用日志里没有任何 java.lang.OutOfMemoryError——因为 JVM 还没来得及觉得自己内存不够,容器的 cgroup 先动手了。这个现象是“容器内存 ≠ JVM 堆”的最典型信号。

症状 2:线程数和 CPU 数对不上

$ docker exec order-svc sh -c 'cat /proc/cpuinfo | grep -c processor; nproc'
32
32
$ docker inspect -f '{{.HostConfig.NanoCpus}}' order-svc
2000000000

宿主机 32 核,容器被限制成 2 个 CPU(NanoCpus=2000000000 就是 --cpus=2)。注意:nproc 和 /proc/cpuinfo 在容器里看到的依然是宿主机的 32 核,因为它们不读 cgroup 的 CPU 限制。JVM 是靠自己读 cgroup 才知道真实额度的——读不对,就以为自己有 32 核。

二、排查路线图

先用一张图把“JVM 视角”与“cgroup 真实额度”的错位画出来:

老 JDK(8u131 及更早 / 关闭 ContainerSupport) JVM 读取/proc/meminfo: 64G/proc/cpuinfo: 32 核 推导结果默认堆 ≈ 16G (1/4)GC/线程池按 32 核 容器感知 JDK(8u191+ / 10+) JVM 读取cgroup 内存上限: 2Gcgroup CPU 配额: 2 核 推导结果默认堆 = 2G × 25%availableProcessors=2 排查顺序 ① java -version:版本够不够(8u191+) ② -XX:+PrintFlagsFinal:MaxHeapSize / UseContainerSupport ③ 容器内 Runtime.availableProcessors / maxMemory ④ cgroup 文件:memory.max、cpu.max ⑤ 算账:堆 + 堆外 + 线程栈 ≤ 容器 limit,留 25% 以上余量

三、动手:一步步核对 JVM 到底“看到”了什么

第 1 步:JDK 版本对不对

$ docker exec order-svc java -version
openjdk version "1.8.0_161"
OpenJDK Runtime Environment (build 1.8.0_161-b12)
OpenJDK 64-Bit Server VM (build 25.161-b12, mixed mode)

1.8.0_161——小于 191。这是重点。JDK 10 引入了容器感知,之后向后移植到了 JDK 8u191。8u131~8u190 只有一个实验性的、需要手工开的 -XX:+UseCGroupMemoryLimitForHeap,8u131 之前根本没有。OpenJDK 官方的 JDK-8196595 发行说明写得很清楚:JVM 会从 cgroup 的 cpu sets / shares / quotas 推导可用 CPU 数,这个特性默认开启,可用 -XX:-UseContainerSupport 关闭,也可以用 -XX:ActiveProcessorCount=N 手动覆盖,并新增了 InitialRAMPercentage / MaxRAMPercentage / MinRAMPercentage 三个按比例设置堆的参数。

第 2 步:让 JVM 亲口说出它算出来的堆

$ docker exec order-svc java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E 'MaxHeapSize|MaxRAMPercentage|UseContainerSupport|ActiveProcessorCount|ParallelGCThreads|ConcGCThreads'
    uintx MaxHeapSize                              := 17179869184  {product}
     intx ParallelGCThreads                         = 23           {product}
     uintx ConcGCThreads                            = 6            {product}

这就是问题现场:MaxHeapSize = 17179869184,即 16 GiB——JVM 默认最大堆是“物理内存的 1/4”,而它看到的物理内存是宿主机的 64G。容器 limit 是 2G,堆想涨到 16G,还没涨到那儿,2G 早被 cgroup 卡死了。并且老版本里 UseContainerSupport 这个 flag 根本不存在(所以 grep 不到)。

换成 8u191+ 之后的输出大致如下:

$ docker exec order-svc java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E 'MaxHeapSize|MaxRAMPercentage|UseContainerSupport|ParallelGCThreads'
    uintx MaxHeapSize                              := 536870912    {product}
   double MaxRAMPercentage                          = 25.000000    {product}
     bool UseContainerSupport                       = true         {product}
     uint ParallelGCThreads                         = 2            {product}

MaxRAMPercentage 默认 25%,2G × 25% = 512MiB(536870912),刚好。ParallelGCThreads 也降到 2。

第 3 步:从 Java 代码里确认 availableProcessors 与 maxMemory

写一个最小的探针,比看文档靠谱:

public class Probe {
    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();
        System.out.println("availableProcessors = " + rt.availableProcessors());
        System.out.println("maxMemory(MB)       = " + rt.maxMemory() / 1024 / 1024);
    }
}
$ docker run --rm --cpus=2 -m 2g -v $PWD:/w -w /w eclipse-temurin:8u151-jdk sh -c 'javac Probe.java && java Probe'
availableProcessors = 32
maxMemory(MB)       = 14563

$ docker run --rm --cpus=2 -m 2g -v $PWD:/w -w /w eclipse-temurin:17-jdk sh -c 'javac Probe.java && java Probe'
availableProcessors = 2
maxMemory(MB)       = 512

(镜像 tag 仅示意,请换成你机器上能拉到的老版本 JDK 镜像验证;重点是对比现象。)availableProcessors 从 32 变成 2,意义比看起来大:Tomcat/Netty/ForkJoinPool/CompletableFuture/并行流/GC 线程数都是按它推导的。老 JDK 里 32 核 × 各种线程池,容器只有 2 个 CPU 配额,线程互相抢时间片,上下文切换暴涨,还会被 CFS 限流(throttling)——这就是“限了 2 核反而更慢”的原因。

顺便看看 CPU 是否被限流,cgroup 会记账:

$ docker exec order-svc cat /sys/fs/cgroup/cpu.stat        # cgroup v2
usage_usec 8423912310
nr_periods 412330
nr_throttled 129447
throttled_usec 903214333

nr_throttled / nr_periods = 129447 / 412330 ≈ 31%,也就是近三分之一的调度周期里,容器因为用完了配额被强制“歇着”。这直接体现在 P99 抖动上。

第 4 步:直接读 cgroup 文件,确认 limit 是多少(v1 与 v2 不同)

先判断宿主机用的是哪个版本的 cgroup:

$ stat -fc %T /sys/fs/cgroup/
cgroup2fs        # v2;如果是 tmpfs 则是 v1

然后对照读取(在容器里执行):

信息cgroup v1cgroup v2
内存上限/sys/fs/cgroup/memory/memory.limit_in_bytes/sys/fs/cgroup/memory.max
当前内存使用/sys/fs/cgroup/memory/memory.usage_in_bytes/sys/fs/cgroup/memory.current
CPU 配额cpu/cpu.cfs_quota_us 与 cpu.cfs_period_us/sys/fs/cgroup/cpu.max(如 200000 100000)
OOM 计数memory/memory.oom_control/sys/fs/cgroup/memory.events(oom_kill 字段)
$ docker exec order-svc cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max /sys/fs/cgroup/memory.current
2147483648
200000 100000
1987321856
$ docker exec order-svc cat /sys/fs/cgroup/memory.events
low 0
high 0
max 4127
oom 3
oom_kill 3

cpu.max 的 200000 100000 表示每 100000 微秒周期内最多用 200000 微秒 CPU 时间,即 2 核。memory.current 是 1.98G——离 2G 的上限只剩 100MB,而此刻 JVM 堆才用了不到 1G,这多出来的一个 G 是谁?下一节回答。

这里还有一个和版本相关的坑:cgroup v2 的支持是 JDK 15 才加入的(后来才被移植到 8u372、11.0.16 等)。Docker 20.10 起,很多新发行版默认启用 cgroup v2,而你的镜像里可能是不认识 v2 的老 JDK(比如 8u191~8u362),此时 JVM 读不到限制,又回到“以为自己有整台机器”的状态。所以升级宿主 OS 也可能引发这个问题——“我没改代码啊”。

四、根因:堆只是内存的一部分

再回答“堆用了 900MB,为什么容器用了 1.98G”。容器的 cgroup 统计的是整个进程树的内存,而 JVM 的内存远不止堆:

容器 limit = 2048 MiB 的构成(示例) 堆 -Xmx ≈ 1024 MiB(50%) 元空间~128 线程栈200×1M 直接内存~256 JIT/CodeCache~120 GC/其它~100 能被 -Xmx 控制的部分 堆外,-Xmx 管不到;线程越多、Netty 越吃、越涨 公式(估算) 容器占用 ≈ 堆 + 元空间 + 线程数×栈大小(默认约 1MB) + 直接内存 + CodeCache + GC 数据结构 + 本地库 因此 -Xmx 等于容器 limit 几乎必然被杀;经验值:堆占 limit 的 50%~75%

把示例数字加一下:1024 + 128 + 200 + 256 + 120 + 100 ≈ 1828 MiB,接近 2G。如果此时我们把 -Xmx 改到 2g(“既然限制是 2G,堆就设 2G 吧”),堆一旦涨满,光堆就占满整个 limit,堆外再要一点,cgroup 就立刻 OOM Kill,JVM 连 OutOfMemoryError 都来不及抛。这是新手最常见的配置错误。

用 JVM 自己的原生内存追踪(NMT)可以看到堆外的构成:

# 启动参数加:-XX:NativeMemoryTracking=summary
$ docker exec order-svc jcmd 1 VM.native_memory summary | head -20
Native Memory Tracking:
Total: reserved=3512840KB, committed=1876322KB
-                 Java Heap (reserved=1048576KB, committed=1048576KB)
-                     Class (reserved=1146880KB, committed=112640KB)
-                    Thread (reserved=230224KB, committed=230224KB)
-                      Code (reserved=251662KB, committed=118432KB)
-                        GC (reserved=61240KB, committed=61240KB)
-                  Internal (reserved=262500KB, committed=262500KB)

NMT 有额外的性能开销(约 5%~10%),只在排查时开,看完关掉。Thread 一行 230MB,就是几百个线程的栈;Internal 包含直接内存等。“堆之外”一共占了 800MB 以上,这才是那消失的 G。

五、止血与修复

止血

  1. 先把容器内存 limit 临时拉高到 3g,配合 docker update --memory 3g --memory-swap 3g order-svc 让服务先稳住(注意别只调 --memory,swap 要一起设,否则 swap 也随之放宽了);
  2. 保存证据:docker inspect、docker stats、cgroup 的 memory.events 输出留档。

根治:升级 JDK + 用比例参数 + 显式控制线程

  1. 升 JDK:至少 8u191,推荐 8u372+ 或者直接 11/17/21(这样连 cgroup v2 都覆盖了)。镜像基础版本固定并记录在 Dockerfile 里,别用 latest。
  2. 用 MaxRAMPercentage 代替写死的 -Xmx:容器 limit 变化时堆自动跟随。
    FROM eclipse-temurin:17-jre
    COPY app.jar /app/app.jar
    ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=60.0 -XX:InitialRAMPercentage=30.0 \
     -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps"
    ENTRYPOINT ["java", "-jar", "/app/app.jar"]
    关于取值:官方默认 25% 偏保守(1G 的容器只有 256MB 堆),但也不要拉太高。我的经验是容器 ≥ 2G 时取 60%~75%,1G 以下取 50%~60%,线程多、用 Netty 直接内存多的服务取低值。网上有的示例把 512MB 容器配到 90%,那是给空闲服务用的,生产别学。
  3. 覆盖 CPU 数(可选):某些场景(CPU 份额 --cpu-shares 而非配额)JVM 识别出的核数不理想,可用 -XX:ActiveProcessorCount=2 显式指定。同时不要依赖默认线程池大小,业务线程池按实际配额配置。
  4. 限制堆外:-XX:MaxMetaspaceSize=256m、-XX:MaxDirectMemorySize=256m、-Xss512k(评估后),让堆外有上限,超过时 JVM 自己抛异常(有日志可查),而不是被 cgroup 一刀带走。
  5. 让 OOM 留下证据:-XX:+HeapDumpOnOutOfMemoryError + 挂载卷 /dumps;-XX:+ExitOnOutOfMemoryError 让 JVM 在 OOM 时直接退出,交给 restart 策略重启,避免“半死不活”。

改完以后核对:

$ docker exec order-svc java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E 'MaxHeapSize|MaxRAMPercentage'
    size_t MaxHeapSize                              = 1288490188   {product}
    double MaxRAMPercentage                         = 60.000000    {product}
$ docker stats order-svc --no-stream --format '{{.Name}} {{.MemUsage}} {{.CPUPerc}}'
order-svc 1.42GiB / 2GiB 4.31%

堆上限 ≈ 1.2G(2G × 60%),整体使用稳定在 1.4G 左右,留出了 600MB 余量。运行一天,OOMKilled 归零。

六、容器里怎么做 jstack / jmap / jcmd

线上定位问题,最后总要做线程栈和堆 dump。容器里有几个常见的坑,这里一并记下。

方法 1:docker exec 直接在容器里跑(首选)

$ docker exec order-svc jcmd 1 Thread.print > /tmp/threads.txt         # 等价 jstack
$ docker exec order-svc jcmd 1 GC.heap_dump /tmp/heap.hprof           # 等价 jmap -dump
$ docker cp order-svc:/tmp/heap.hprof ./heap.hprof

三个前提:

  • 镜像里要有 JDK 工具:-jre 镜像没有 jcmd/jstack/jmap。这也是为什么很多人 docker exec 后看到 jstack: command not found;
  • PID 不一定是 1:如果 ENTRYPOINT 是 shell 脚本且没用 exec java ...,Java 进程就不是 1 号,先 docker exec order-svc ps -ef | grep java 确认;
  • 执行用户要与 JVM 进程用户一致:JVM 的 attach 机制要求工具与目标进程 UID 相同。如果应用以 USER app(UID 1000)运行,而你 docker exec 默认是 root,可能失败,需要 docker exec -u 1000 order-svc jcmd 1 ...。

方法 2:镜像里没有 JDK 工具 —— 共享 PID 命名空间的“调试容器”

生产镜像为了瘦身只带 JRE,这时可以起一个带 JDK 的临时容器,与目标容器共享 PID 命名空间:

$ docker run --rm -it --pid=container:order-svc --user 1000:1000 \
    --volumes-from order-svc eclipse-temurin:17-jdk \
    jcmd 1 Thread.print
2026-09-27 14:12:33
Full thread dump OpenJDK 64-Bit Server VM (17.0.9+9 mixed mode, sharing):
"http-nio-8080-exec-1" #41 [211] daemon prio=5 os_prio=0 cpu=812.31ms ...

--pid=container:order-svc 让调试容器看到目标容器里的进程;--user 与目标进程 UID 一致;--volumes-from 共享卷,让 /tmp 相关的 socket 文件可见(前提是 /tmp 是卷)。这个方法的成败与 JDK 版本、attach 的 /proc/<pid>/root/tmp 路径修复有关(JDK 10 起才修复了跨容器 attach 路径,见 JDK-8196595 说明中的“Container Attachment Fix”),JDK 8 老版本容易失败;失败就回到方法 1,或者退而求其次用后面的 kill -3(它不依赖 attach)。

常见报错与排查

$ jstack 1
1: Unable to open socket file: target process not responding or HotSpot VM not loaded
The -F option can be used when the target process is not responding

这条报错的常见原因,按出现概率排序:

  1. UID 不一致:工具与 JVM 不是同一用户;
  2. /tmp 有问题:attach 依赖 JVM 在 /tmp 下创建 .java_pid<pid> 的 UNIX socket。如果 /tmp 被定时清理(tmpwatch / systemd-tmpfiles)、被挂成只读(--read-only 根文件系统又没挂 tmpfs)或者 JVM 设置了 -Djava.io.tmpdir,工具就找不到 socket。对策:--read-only 时加 --tmpfs /tmp;
  3. PID 命名空间不同:在宿主机上直接 jstack <宿主机视角的 PID>,老版本 JDK 会失败——JDK-8228343 记录了这一现象:jcmd -l 能列出容器内进程,但 jcmd PID help 等命令会超时,要求用户名与 UID 匹配;
  4. JVM 开了 -XX:+DisableAttachMechanism,主动禁止了 attach;
  5. JVM 真的卡死(比如 Full GC 长时间停顿、正则回溯把线程全占满):这时 attach 需要 JVM 响应,可以试 jstack -F 或者直接发 kill -3 <pid>,线程栈会打印到容器的 stdout,用 docker logs 看。
$ docker exec order-svc kill -3 1
$ docker logs --tail 30 order-svc
Full thread dump OpenJDK 64-Bit Server VM (25.161-b12 mixed mode):
"http-nio-8080-exec-1" #41 daemon prio=5 os_prio=0 tid=0x00007f3c2c0a8800 nid=0x2a runnable ...

kill -3(SIGQUIT)是 HotSpot 打印线程栈的经典方式,不依赖 attach 机制,是“最后一根救命稻草”。

七、复盘

做法为什么
Java 容器化前先确认 JDK ≥ 8u191(推荐 11/17/21)之前的版本不认 cgroup,堆和线程数按宿主机算
用 MaxRAMPercentage(50%~75%)代替写死 -Xmx随容器 limit 自动伸缩,同时给堆外留余量
永远不要 -Xmx = 容器 limit元空间、线程栈、直接内存、CodeCache 都在 limit 里,容器会先于 JVM 爆
限制 MaxMetaspaceSize / MaxDirectMemorySize,需要时开 NMT堆外有上限,超限由 JVM 报错并可追踪,而不是被 OOM Kill
业务线程池显式配置,不依赖 availableProcessors 默认值老版本或 CPU 份额场景下识别的核数不可靠
关注 cpu.stat 的 nr_throttledCPU 被限流不会体现在 CPU 使用率上,只体现在延迟抖动
升级宿主 OS / Docker 后检查 cgroup v1/v2 与 JDK 是否匹配v2 在 JDK 15+(及 8u372、11.0.16 等回移版本)才被识别
容器 jstack 三件套:UID 一致、/tmp 可用、PID 别搞错;不行就 kill -3attach 机制依赖 /tmp 下的 socket 与用户权限

一句话备忘:Java 进容器,先查 JDK 版本(8u191+)→ PrintFlagsFinal 看 MaxHeapSize → 堆按 limit 的 60% 左右配(MaxRAMPercentage)→ 堆外另算 → 排查用 docker exec -u <同UID> jcmd 1,不行就 kill -3。

回头想想,JVM 就像一个刚搬进合租房的室友:明明只分到一个次卧,却因为没看清合同,坚持认为自己拥有整套别墅。你不写清楚边界,它就会一路“开疆拓土”,直到房东(cgroup)敲门。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人