跳转至

Java Pod 报 OOMKilled

01 前置理论与核心机制

1. 退出码 137 与 OOMKilled 的本质

  • 系统级强制干预:Exit Code 137 等于 128 + 9,代表进程收到了系统的 SIGKILL(强制终止)信号。在 K8S 环境下,当 Pod 内部所有进程占用的物理内存(RSS)总和触碰或超过了 resources.limits.memory 设定的硬限制时,Linux 内核的 cgroup OOM Killer 会直接强制结束占用内存最大的进程。
  • JVM OOM 的区别:java.lang.OutOfMemoryError 是 JVM 层面自己检测到堆或元空间不足抛出的异常。此时进程未必会立刻退出(除非配置了特定崩溃脚本)。而 K8S 的 OOMKilled 是操作系统层面的“拔电源”行为,不会给 JVM 留出执行优雅停机(Graceful Shutdown)的时间。

2. 容器视角下的 JVM 内存组成

容器的物理内存(RSS)并不等于 JVM 的堆内存。Java 进程的总内存开销公式为:

  • 容器内存 (RSS) = 堆内存 (Heap) + 堆外内存 (Direct Memory) + 线程栈 (Thread Stacks) + 元空间 (Metaspace) + JVM 自身运行开销 (Native/Code Cache)
  • 典型误区:如果将 JVM 堆内存(-Xmx)设置得与 Pod Limit 一样大,一旦程序开始运行,元空间、线程栈和堆外内存的额外开销必然会导致总物理内存超标,最终引发 137 OOMKilled。

02 故障排查与恢复闭环 SOP

1. 现象确认与死因定性

# 1. 查看 Pod 退出原因,确认是否为 cgroup 触发的 OOMKilled
kubectl describe pod  -n  | grep -E "Exit Code|OOMKilled"

# 2. 检查 Pod 重启次数及当前资源水位
kubectl top pod  -n

2. 在线现场排查链路(区分内存泄漏与配额过小)

如果 Pod 当前正在运行且内存持续爬升,需立即进入容器捕获现场:

步骤一:查看 GC 状态(确认是否堆内打满)

观察老年代(O 列)与 Full GC 次数(FGC 列),每秒刷新一次,共打印 3 次。 注意:如果是 tini 或脚本启动,Java 进程可能不是 1,可通过 jps 确认真实 <pid>。

kubectl exec -it  -n  -- jstat -gcutil  1000 3
  • 判定为内存泄漏:老年代(O 列)长期处于 90% 以上,Full GC 执行频繁(FGC 递增),但回收后老年代水位无法下降。
  • 判定为配额过小/堆外溢出:老年代(O 列)仅占 30%~60%,GC 频次平稳,但容器整体内存(Pod RSS)已逼近 Limit,随时可能暴毙。

步骤二:抓取活体大对象分布(针对内存泄漏场景)快速查看存活大对象 Top 20,定位吃内存的核心类。

kubectl exec  -n  -- jmap -histo:live  | head -n 20
步骤三:导出 Heap Dump 留证在容器内生成全量堆快照:
kubectl exec  -n  -- jmap -dump:live,format=b,file=/tmp/heap.hprof
将快照拷贝至宿主机或本地用于 MAT 分析:
kubectl cp /:/tmp/heap.hprof ./heap.hprof

3. 根因修复与防暴毙防御配置

生产环境中,为了防止容器瞬间被杀无法保留现场,需配置标准化防御参数: - 容器内存感知兜底:确保 JVM 能感知 cgroup 限制,按比例自动分配堆大小,避免绝对值溢出。

-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0
  • 崩溃自动导出快照:配置 JVM 内部抛出 OOM 时的自动 dump,并将路径挂载为外挂卷(如 emptyDir 或 hostPath)。
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/

03 真实生产故障现象与案例库

案例 1:业务代码导致的堆内内存泄漏

  • 监控现象:JVM 老年代使用率随时间呈爬坡趋势,到达 95% 后触发密集的 Full GC,应用响应变慢,最终 Pod 被内核 137 杀掉。
  • 排查结果:通过 jmap -histo:live 发现某业务的 DTO 对象和 byte[] 占据了 80% 的堆内存。
  • 根因与处置:开发在处理超大 Excel 导出或批量查询数据库时未做分页,一次性将数百万条数据加载至内存,导致堆被撑爆。处置方案为修改代码逻辑,强制增加分页或使用流式读取。

案例 2:Netty 堆外内存 (Direct Memory) 泄漏

  • 监控现象:Pod 内存持续告警并最终 OOMKilled,但通过 jstat 查看 JVM 老年代仅使用 40%,且几乎没有 Full GC 发生。
  • 排查结果:开启 NMT (-XX:NativeMemoryTracking=detail) 后,通过 jcmd VM.native_memory 发现 Direct 区域内存占用高达数 GB。
  • 根因与处置:使用了 Netty 等基于 NIO 的框架,ByteBuf 申请后未调用 release() 释放堆外内存。处置方案为在拦截器或 finally 块中增加堆外内存释放逻辑,同时可通过 -XX:MaxDirectMemorySize 硬性限制堆外大小。

案例 3:线程池失控撑爆容器物理内存

  • 监控现象:业务低峰期正常,遇到高并发突发流量时,Pod 内存顺时针飙升直接 137 重启。
  • 排查结果:通过 jstack 导出线程栈并统计,发现容器内存活了 3000+ 个线程。
  • 根因与处置:每个 Java 线程默认占用 1MB 的 Thread Stack 内存,3000 个线程直接吃掉 3GB 物理内存。代码中错误使用了 Executors.newCachedThreadPool(),导致无节制创建线程。处置方案为改用 ThreadPoolExecutor 并严格限制最大核心线程数与队列长度。

04 生产排障速查表(OOMKilled 137)

遇到 Java Pod OOMKilled 请按序单向排查:顺序排查层级执行命令 / 观测点异常基线与定性处置动作01死因确认kubectl describe pod 查 Exit Code命中 137 且 OOMKilled 为 true确认为 cgroup 级别物理内存越界被杀02堆内/GCjstat -gcutil <pid> 1000 3老年代(O)>90% 且 FGC 居高不下定性为 堆内泄漏,进入第 03 步抓快照03堆内排查jmap -histo:live <pid> 查前20名特定类或大数组断崖式占据空间记下嫌疑类,提供给开发检查引用链04现场固化jmap -dump:... && kubectl cp生成 .hprof 快照文件将快照导入 MAT/VisualVM 分析05堆外/配置O < 50%,无FGC,但 Pod 依然 OOM定性为 堆外溢出/配额过小进入第 06 步查参数与 NMT06线程数jstack <pid> 统计总线程数线程数异常飙升(几千至上万)排查代码中无界线程池,限制最大并发数07参数核对检查 JVM 启动参数配置未开启容器感知或 Xmx 设置过大配置 MaxRAMPercentage=75.0,留出堆外余量08临时止血修改 Deployment 资源清单内存泄漏未修复前业务持续阻断临时调大 limits.memory,争取排障时间

05 面试应答参考话术

面试官问:“K8s 里跑 Java 的 Pod 报 OOMKilled(Exit Code 137),怎么判断是内存泄露还是配额给小了?”

可以这样回答:如果遇到 Java Pod 报 137 退出,我不会上来就直接调大 Limit,因为如果是代码泄露,调大配额只能延缓死亡时间,治标不治本。我会先用 kubectl describe pod 确认退出的 Exit Code 是不是 137。如果是,说明是 cgroup 内存超标被操作系统强杀了。这个时候到底是泄露还是配额小,直接进容器看 GC 现场就知道了。第一步:通过 jstat 看 GC 状态定性

我会执行 jstat -gcutil <pid> 1000 3 看一秒刷新一次的 GC 监控。重点看两列:o(老年代)和 FGC(Full GC)。 如果发现老年代已经飙到 90% 以上,而且 Full GC 执行了多次,老年代水位根本降不下来,那基本可以锁定是代码层面的内存泄漏。 反过来,如果挂掉前老年代才占 30%~40%,GC 表现非常健康,但 Pod 却突然暴毙,那大概率就是配额给小了或者堆外内存溢出了。第二步:抓真凶与保留证据

既然查到是堆内泄露,不用猜,直接执行 jmap -histo:live <pid> | head -n 20 把大对象抓出来。排在第一行的往往就是一个几十上百兆的 byte[] 数组或者某个业务实体类,到这里证据链就闭环了。最后一步,用 jmap -dump:live 当场抓一个活体 Dump 快照,通过 kubectl cp 拷到宿主机丢给开发排查改代码就行了,对于超大堆文件,推荐使用 PVC 挂载目录或在容器内直接打包上传至云存储。第三步:针对配额过小与堆外场景的排查

如果 GC 正常却 OOM,就不需要找开发改业务代码。我会优先排查 JVM 是不是没开容器内存感知(导致把宿主机的内存当成了上限),或者是业务用了 Netty 导致直接内存(Direct Memory)溢出,或者是线程池没限制导致开了几千个线程撑爆了物理内存。确认堆外没有恶性泄漏后,我才会考虑把 Pod 的 Limit 调大。所以总结一句话:老年代打满且 Full GC 救不回来的,基本是内存泄露;堆内没满却被 137 杀掉的,优先查堆外内存与配额参数。

06 面试高频追问与应答预案

追问 1:如果生产环境瞬间暴毙,容器直接重启了,你根本来不及敲 jmap 怎么办?

  • 回答要点:
  • 生产环境确实经常遇到来不及手工抓现场的情况。所以我们必须在基础镜像或启动参数里做好兜底防护。
  • 核心是加两个 JVM 参数:-XX:+HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath=/data/dump/。
  • 配合 K8S 的持久化挂载,把 /data/dump/ 目录挂载到 hostPath 或 PVC 上。这样只要 JVM 内部触发了 OOM,挂掉前会自动生成快照持久化保存,事后随时可以拿出来分析。

追问 2:怎么确认是不是堆外内存 (Direct Memory) 造成的溢出?

  • 回答要点:
  • 如果 jstat 显示堆内存非常空闲,但容器内存使用率 top pod 却接近 100%,高度怀疑堆外内存。
  • 可以在 JVM 启动参数临时加上 -XX:NativeMemoryTracking=detail 开启本地内存追踪(NMT)。
  • 进程运行期间,进入容器执行 jcmd <pid> VM.native_memory detail,直接看 Direct 区域或 Internal 区域占用的实际内存量,如果数值严重超出预期,即可确诊为堆外内存泄漏。

追问 3:遇到 OOMKilled,为什么不建议直接调大 resources.limits.memory?

  • 回答要点:
  • 因为 Java 是一门自带垃圾回收的语言。如果是真实的代码级内存泄漏(例如把大量的业务对象存进了静态 HashMap 且从未清理),无论给多少物理内存,最终都会被吃满。
  • 盲目调大 Limit 只会增加该节点物理内存耗尽的风险,甚至可能引发 Kubernetes Node 级别的 OOM,导致同一宿主机上的其他核心组件(如 kubelet)崩溃,从而扩大故障面。

07 一键巡检脚本(check_java_oom.sh)

该脚本用于只读快速巡检宿主机的 dmesg 杀进程记录,并探测当前运行中 Java 进程的内存分配与存活指标。

#!/usr/bin/env bash
# ==============================================================================
# 脚本名称: check_java_oom.sh
# 功能描述: 节点级 OOMKilled 巡检与存活 Java 进程基础内存状态观测 (纯只读)
# 使用方式: 在 K8S Node 节点以 root 权限执行
# ==============================================================================

set -o pipefail

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
PLAIN='\033[0m'

echo -e "${BLUE}================================================================${PLAIN}"
echo -e "${BLUE}                Java Pod 内存与 OOM 状态巡检                    ${PLAIN}"
echo -e "${BLUE}================================================================${PLAIN}"

# 1. 检查内核 dmesg 中的 OOMKilled 记录
echo -e "\n${YELLOW}[1/3] 检查操作系统内核 OOM Killer 记录 (最近 20 行)...${PLAIN}"
if command -v dmesg &>/dev/null; then
    OOM_LOGS=$(dmesg -T 2>/dev/null | grep -iE "Killed process|Out of memory" | tail -n 5)
    if [ -n "$OOM_LOGS" ]; then
        echo -e "${RED}[WARN] 发现内核曾触发 OOM Killer 强杀进程:${PLAIN}"
        echo "$OOM_LOGS"
    else
        echo -e "${GREEN}[OK] 未在系统近期日志中发现 OOM Killer 活动记录。${PLAIN}"
    fi
else
    echo -e "${YELLOW}[INFO] 未找到 dmesg 命令,跳过系统 OOM 检查。${PLAIN}"
fi

# 2. 检查 containerd 运行中的 Java 容器
echo -e "\n${YELLOW}[2/3] 扫描本机运行中的 Java 进程状态...${PLAIN}"
if command -v crictl &>/dev/null; then
    # 获取运行中的容器
    CONTAINERS=$(crictl ps -q)
    JAVA_FOUND=0
    for cid in $CONTAINERS; do
        # 简单查看进程是否包含 java
        if crictl exec "$cid" ps -ef 2>/dev/null | grep -v grep | grep -q "java "; then
            JAVA_FOUND=1
            CNAME=$(crictl inspect "$cid" | grep -m1 '"name"' | cut -d '"' -f 4)
            echo -e "${GREEN}[INFO] 发现 Java 容器 [${CNAME}] (ID: ${cid:0:8})${PLAIN}"

            # 检查是否有 jstat 工具
            if crictl exec "$cid" command -v jstat &>/dev/null; then
                JAVA_PID=$(crictl exec "$cid" sh -c 'jps -q | head -n 1' 2>/dev/null)

                if [ -n "$JAVA_PID" ]; then
                    GC_STAT=$(crictl exec "$cid" jstat -gcutil "$JAVA_PID" 2>/dev/null | tail -n 1)
                    OLD_GEN=$(echo "$GC_STAT" | awk '{print $4}')
                    FGC_COUNT=$(echo "$GC_STAT" | awk '{print $8}')
                    echo -e "      -> 当前老年代(O)使用率: ${OLD_GEN}% | Full GC 累计次数: ${FGC_COUNT}"

                    if [ "$(awk -v og="$OLD_GEN" 'BEGIN{print (og > 90) ? 1 : 0}')" -eq 1 ]; then
                         echo -e "      ${RED}[WARN] 老年代使用率 > 90\%,存在严重堆满溢出风险!${PLAIN}"
                    fi
                fi
            else
                echo -e "      ${YELLOW}[WARN] 容器内未安装 jstat 工具 (可能是 JRE 环境),无法获取 GC 指标。${PLAIN}"
            fi
        fi
    done
    if [ "$JAVA_FOUND" -eq 0 ]; then
        echo -e "${GREEN}[OK] 本机当前未运行或未探测到基于 Java 启动的业务容器。${PLAIN}"
    fi
else
    echo -e "${YELLOW}[INFO] 宿主机无 crictl 命令行工具,跳过容器探测。${PLAIN}"
fi

# 3. 宿主机整体内存水位
echo -e "\n${YELLOW}[3/3] 检查宿主机当前内存使用率...${PLAIN}"
MEM_TOTAL=$(free -m | awk 'NR==2{print $2}')
MEM_USED=$(free -m | awk 'NR==2{print $3}')
MEM_PCT=$(awk "BEGIN {printf \"%.1f\", $MEM_USED/$MEM_TOTAL*100}")
if [ "$(awk -v pct="$MEM_PCT" 'BEGIN{print (pct > 85.0) ? 1 : 0}')" -eq 1 ]; then
    echo -e "${RED}[WARN] 宿主机内存总使用率达 ${MEM_PCT}\% (${MEM_USED}M/${MEM_TOTAL}M),节点级 OOM 风险较高。${PLAIN}"
else
    echo -e "${GREEN}[OK] 宿主机物理内存水位正常: ${MEM_PCT}\%。${PLAIN}"
fi

echo -e "\n${BLUE}================================================================${PLAIN}"
echo -e "${BLUE}巡检完毕。若老年代持续偏高,请结合文档 SOP 执行 jmap 抓取快照分析。${PLAIN}"
echo -e "${BLUE}================================================================${PLAIN}"