公司推“容器化改造”,我负责的一个 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 2147483648OOMKilled=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 真实额度”的错位画出来:
三、动手:一步步核对 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 903214333nr_throttled / nr_periods = 129447 / 412330 ≈ 31%,也就是近三分之一的调度周期里,容器因为用完了配额被强制“歇着”。这直接体现在 P99 抖动上。
第 4 步:直接读 cgroup 文件,确认 limit 是多少(v1 与 v2 不同)
先判断宿主机用的是哪个版本的 cgroup:
$ stat -fc %T /sys/fs/cgroup/
cgroup2fs # v2;如果是 tmpfs 则是 v1然后对照读取(在容器里执行):
| 信息 | cgroup v1 | cgroup 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 3cpu.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 的内存远不止堆:
把示例数字加一下: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。
五、止血与修复
止血
- 先把容器内存 limit 临时拉高到 3g,配合
docker update --memory 3g --memory-swap 3g order-svc让服务先稳住(注意别只调--memory,swap 要一起设,否则 swap 也随之放宽了); - 保存证据:
docker inspect、docker stats、cgroup 的memory.events输出留档。
根治:升级 JDK + 用比例参数 + 显式控制线程
- 升 JDK:至少 8u191,推荐 8u372+ 或者直接 11/17/21(这样连 cgroup v2 都覆盖了)。镜像基础版本固定并记录在 Dockerfile 里,别用
latest。 - 用
MaxRAMPercentage代替写死的-Xmx:容器 limit 变化时堆自动跟随。 关于取值:官方默认 25% 偏保守(1G 的容器只有 256MB 堆),但也不要拉太高。我的经验是容器 ≥ 2G 时取 60%~75%,1G 以下取 50%~60%,线程多、用 Netty 直接内存多的服务取低值。网上有的示例把 512MB 容器配到 90%,那是给空闲服务用的,生产别学。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"] - 覆盖 CPU 数(可选):某些场景(CPU 份额
--cpu-shares而非配额)JVM 识别出的核数不理想,可用-XX:ActiveProcessorCount=2显式指定。同时不要依赖默认线程池大小,业务线程池按实际配额配置。 - 限制堆外:
-XX:MaxMetaspaceSize=256m、-XX:MaxDirectMemorySize=256m、-Xss512k(评估后),让堆外有上限,超过时 JVM 自己抛异常(有日志可查),而不是被 cgroup 一刀带走。 - 让 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这条报错的常见原因,按出现概率排序:
- UID 不一致:工具与 JVM 不是同一用户;
- /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; - PID 命名空间不同:在宿主机上直接
jstack <宿主机视角的 PID>,老版本 JDK 会失败——JDK-8228343 记录了这一现象:jcmd -l能列出容器内进程,但jcmd PID help等命令会超时,要求用户名与 UID 匹配; - JVM 开了
-XX:+DisableAttachMechanism,主动禁止了 attach; - 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_throttled | CPU 被限流不会体现在 CPU 使用率上,只体现在延迟抖动 |
| 升级宿主 OS / Docker 后检查 cgroup v1/v2 与 JDK 是否匹配 | v2 在 JDK 15+(及 8u372、11.0.16 等回移版本)才被识别 |
容器 jstack 三件套:UID 一致、/tmp 可用、PID 别搞错;不行就 kill -3 | attach 机制依赖 /tmp 下的 socket 与用户权限 |
一句话备忘:Java 进容器,先查 JDK 版本(8u191+)→
PrintFlagsFinal看MaxHeapSize→ 堆按 limit 的 60% 左右配(MaxRAMPercentage)→ 堆外另算 → 排查用docker exec -u <同UID> jcmd 1,不行就kill -3。
回头想想,JVM 就像一个刚搬进合租房的室友:明明只分到一个次卧,却因为没看清合同,坚持认为自己拥有整套别墅。你不写清楚边界,它就会一路“开疆拓土”,直到房东(cgroup)敲门。
参考资料
- OpenJDK JDK-8196595:Release Note: Java Improvements for Docker Containers——官方发行说明:容器感知、UseContainerSupport、ActiveProcessorCount、*RAMPercentage 系列参数,以及 attach 机制对容器的修复。
- Merikan Blog:JVM in a Container——8u131/9 的实验参数、8u191 起的容器支持、cgroup v2 需要 JDK 15 及 RAMPercentage 默认值。
- OpenJDK JDK-8228343:JCMD and attach fail to work across Linux Container boundary——跨容器边界执行 jcmd 失败的现象:能列出进程但命令超时,需用户名与 UID 匹配。
- Docker 官方文档:Resource constraints——
--memory、--memory-swap、--cpus等资源限制参数的语义。 - Netdata:Docker exit code 137——OOMKilled 判断,以及 cgroup v1/v2 下 memory.events、memory.oom_control 的位置。
一起聊聊