线程堆栈吃光 16GB 容器——一个 NMT 查不到的坑

场景:16GB 容器 RSS 涨到 15.7GB、堆只用了 2.3GB,进程被 OOM killer 反复杀掉。不是堆外内存,是线程栈把整台机器撑爆了。 路径:dmesg → jstat → /proc/status 查线程数 → NMT 盲区 → jstack 定位 → CachedThreadPool

14:22,飞书告警机器人弹窗:「订单处理服务 Pod 健康检查失败,正在重启」。这不是第一次——最近 2 小时已经重启了 3 次,每次稳定运行 40 分钟左右就挂。

第一反应——看堆。jstat 一看:老年代 58%,FullGC 0 次,堆远没满。这是我最怕的情况:堆没满,服务死了。

上次 RSS 2.8GB 我开了 NMT 追到了 JNI 的 malloc 泄漏。这次也试试——但 NMT 说 Thread 才 184MB,跟 15.7GB RSS 比起来根本不值一提。

排查到这里,常规路线走不通了。NMT 上次是救命稻草,这次却给不了答案。


告警

截图类型: metric — K8s Pod 重启次数曲线,2 小时 3 次

Pod 反复重启

Pod 重启监控曲线

14:22,飞书告警:

【P1 告警】 订单处理服务 Pod 健康检查失败,状态 CrashLoopBackOff。 当前重启次数:3 次 / 2 小时。上次存活时间:42 分钟。 建议操作:检查 Pod 日志 kubectl logs --previous

2 小时 3 次重启,每次存活不到 45 分钟。不是偶发抖动——是持续性地被杀死。K8s 的 Liveness 探针连续 3 次失败就会重启 Pod,所以问题出在探针超时,不是探针配置太灵敏。

看 Liveness 探针的具体日志——探针是 HTTP GET /actuator/health,超时 5s。最近几次探针响应 >5s 超时,之后 Pod 被 kill。服务不是瞬间挂的,是变慢了之后被探针标记为不可用。

但为什么变慢?不是流量尖刺——同时段的请求量曲线平稳。不是 GC 停顿——G1 Young GC 平均 18ms,Mixed GC 60ms,没有 Full GC。服务变慢一定有其他原因。

第一步排除了"流量冲击"和"GC 停顿"两个最常见的 Pod 重启原因。问题在别处。


起手

截图类型: server — dmesg OOM killer 日志 + jstat 堆状态 + /proc/status 线程数

dmesg:谁杀了进程

既然 Pod 是被 kill 的,先确认是 K8s 杀的还是操作系统杀的。这里用的工具是 dmesg(系统日志查看器,追踪内核级事件如 OOM killer)。

$ dmesg -T | grep -E "oom|killed"
[Sun Jul 12 14:35:12 2026] oom-killer: gfp_mask=0x... of memory cgroup
[Sun Jul 12 14:35:12 2026] java invoked oom-killer: order=0, oom_score_adj=0
[Sun Jul 12 14:35:12 2026] Memory cgroup out of memory: Killed process 27421 (java)

日志显示这不是第一次——本次之前已经有过 2 次 OOM Kill,分别在 12:15 和 11:33,reboot 间隔约 40 分钟。total-vm:16452368kB, anon-rss:15768012kB 表明 RSS 15.7GB 已接近容器 limit 16GB。

dmesg OOM killer 日志

是 OOM killer 杀的,不是 K8s 主动 kill。 这是一个重要的区分:K8s Liveness 探针只是检测到服务无响应后的结果,JVM 进程在探针超时前就已经被 OOM killer 干掉了。

OOM killer 杀进程的依据是 memory cgroup 的 limit——容器 limit 16GB,进程 RSS 已经吃到了 15.7GB。但奇怪的是,堆设置是 -Xms4g -Xmx4g,堆不可能吃到 15.7GB。

这一步确认了排查方向:问题不在应用逻辑层面(代码抛异常或死循环),在 OS 层面(内存耗尽导致进程被强杀)。

jstat:堆没满

看堆的实际使用量:

$ jstat -gcutil 27421 1000 5
 S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT    CGC    CGCT     GCT
 0.00  12.50  38.21  58.34  92.17  87.46    124    2.341    0    0.000    8    0.482    2.823

jstat 堆使用率

老年代 58.34%,Eden 区 38.21%。从 jstat 看堆完全正常,没有 FullGC,老年代还有 40% 以上的空间。排除了"堆内存泄漏"。

到这里和上篇(RSS 2.8GB native memory)的症状完全一样:堆没问题,RSS 爆了。上次我接着开 NMT 追到了 JNI。但这次有点不对劲——RSS 15.7GB 对堆 2.3GB,缺口是 13.4GB,比上次大了一个数量级。

上次 540MB 的 JNI 泄漏已经要 strace 追了。13GB——这个量级不可能走 Unsafe 或 malloc,只能是操作系统直接分配的东西。操作系统直接分配的、不经过 JVM 的、大块的内存——线程栈。

cat /proc/status:线程数 8000+

缺口 13GB 不可能是 JNI malloc 能产生的(上次 540MB 的 JNI leak 已经算大了)。需要先确认一个最容易被忽略的关键指标——线程数

$ cat /proc/27421/status | grep -E "Threads|VmRSS"
Threads:    8432
VmRSS:     15768012 kB

线程数 8432

8432 个线程。正常的 Java 服务线程数在 200-500 之间。8432 是什么概念——每个线程默认栈大小 1MB(Linux x64 + JDK 11 默认值),光线程栈就吃了约 8GB 的虚拟内存。

/proc/PID/statusThreads 字段是 /proc 文件系统中唯一一个能精确给出进程当前存活线程数的接口。top 的 Threads 列和 ps -eLf | wc -l 也行,但 /proc/status 不需要管道、不 fork 子进程,在容器内存紧张时更可靠。

为什么确认线程栈占了 8GB?因为 /proc/PID/maps 里每个线程栈对应一个 [stack:TID] 映射,每个大小 1MB 左右。8432 个线程栈映射 × 1MB ≈ 8GB 虚拟地址空间。部分栈的 RSS 取决于实际使用深度,但在大量线程存活的场景下,大部分栈页都已经 touch 过了。

RSS 15.7GB 的构成大致是: - Java heap: ~2.3GB - 线程栈(实际驻留): ~6GB(8432 线程 × ~700KB 平均驻留) - Metaspace + Code Cache: ~0.5GB - Direct Buffer + 其他: ~1GB - 其他 mmap/anon: ~6GB(含线程栈的虚拟映射保留区和容器共享库)

线程数才是根因,不是 JNI。


收敛

截图类型: diagram — 排查路径决策树:OOM killer → jstat → /proc/status → NMT 盲区 → jstack

NMT 查不到的线程栈

到这里有一个很自然的想法:上次 NMT 帮我定位了堆外泄漏,这次用 NMT 看看 Thread 分类就能确认。

$ jcmd 27421 VM.native_memory summary scale=MB
Total: reserved=6142MB, committed=4286MB
- Java Heap (reserved=4096MB, committed=2384MB)
- Thread (reserved=387MB, committed=184MB)
  (stack: reserved=0MB, committed=0MB)
- Class (reserved=386MB, committed=198MB)
- Code (reserved=128MB, committed=64MB)
- GC (reserved=256MB, committed=128MB)
- Internal (reserved=88MB, committed=62MB)
- Other (reserved=800MB, committed=1266MB)

NMT summary 输出

NMT 说 Thread reserved=387MB、committed=184MB。但 /proc/status 告诉我 8432 个线程。8432 × 1MB 栈 ≈ 8GB,NMT 只报了 387MB——差了 20 倍

这不是 bug。NMT 不统计线程栈。 原因:线程栈是通过 mmap(而不是 malloc)分配的,NMT 的追踪机制基于 JVM 内部的 malloc/free 钩子——而 mmap 走的是操作系统级别的内存映射,JVM 的 NMT 不知道它。

这个话题我在之前的文章里提过,但这里需要再明确一次:

内存类型 分配路径 NMT 追踪 占内存
Java Heap malloc by JVM -Xmx 设定
Metaspace malloc by JVM Class 分类
Thread Stack mmap by OS ❌ NMT 看不到 线程数 × Xss
DirectBuffer malloc by JVM ⚠️ 部分 -XX:MaxDirectMemorySize
JNI malloc libc malloc 取决于代码

上次查 native memory,NMT + pmap 配合追到了 JNI。因为那次的泄漏走的是 JVM 内部的 Unsafe 分配和 libc malloc,NMT 至少能部分看到。这次线程栈走 mmap——NMT 完全看不到。上次的排查得力工具这次成了误导来源:如果你信了 NMT 的 Thread=184MB,你会错过真正的线索。

排查路径决策树

jstack:线程都在排队

8432 个线程总得有个来处。jstack(JVM 线程快照工具,列出所有线程的当前执行栈)看看都在干什么:

$ jstack 27421 | grep "java.lang.Thread.State" | sort | uniq -c
   8321 java.lang.Thread.State: TIMED_WAITING (parking)
     86 java.lang.Thread.State: WAITING (parking)
     25 java.lang.Thread.State: RUNNABLE

8321 个线程在 TIMED_WAITING (parking)parking 意味着它们都在 LockSupport.parkNanos 上等着——这是 java.util.concurrent 包的标准等待机制。大量线程在等,说明它们都在同一个线程池里。

进一步看是哪段代码:

$ jstack 27421 | grep -A 5 "parking" | grep "at " | sort | uniq -c | head -10
   8321     at jdk.internal.misc.Unsafe.park(Native Method)
   8321     at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:234)
    8321    at java.util.concurrent.SynchronousQueue$TransferStack.awaitFollower(SynchronousQueue.java:460)
   8321     at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1070)
   8321     at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)
   8321     at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)

jstack 调用栈

SynchronousQueue + ThreadPoolExecutor。SynchronousQueue 是零容量的阻塞队列——一个 put 必须等一个 take,反之亦然。用 SynchronousQueue 作为线程池工作队列的是谁?

Executors.newCachedThreadPool()

同症状不同病

把这次排查和上篇做对比:

维度 上篇:JNI native memory 本篇:线程栈泄露
症状 RSS 涨、堆没满、进程趋近 OOM RSS 涨、堆没满、进程反复 Crash
排查起点 pmap → NMT dmesg → /proc/status
定位工具 NMT detail + strace jstack
根因 JNI malloc 不 free newCachedThreadPool 无限创建线程
泄漏物 匿名映射 (mmap) 线程栈 (mmap)
NMT 盲区 JNI 的 libc malloc 所有线程栈内存

上次的 NMT + strace 工具链在这次没用——不是因为它们不强,是因为内存的分配路径不同。同症状不同病:RSS 涨、堆没满只是"发烧",具体是什么类型的 native memory 泄漏需要不同工具层来诊断。


定位

截图类型: code — newCachedThreadPool 源码 + 业务代码

Executors.newCachedThreadPool()

// java/util/concurrent/Executors.java
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

newCachedThreadPool 源码

核心参数: - corePoolSize = 0:没有常驻线程 - maximumPoolSize = Integer.MAX_VALUE最大线程数无上限 - keepAliveTime = 60s:空闲线程 60s 后回收 - workQueue = SynchronousQueue:零容量队列,来一个任务就必须创建一个线程

第三个参数(keepAliveTime)写了 60s。60s 的空闲回收听起来合理对吧?但问题出在每个任务的处理时间 > 60s——如果业务代码里每个异步任务处理耗时 90s,那么新任务来的速度 > 空闲线程回收的速度,线程数就会持续增长,永不回落。

Integer.MAX_VALUE 是 21 亿——约等于无限。线程池会一直创建新的 Worker 线程,直到操作系统资源耗尽。在 8C16G 的容器里,这个"天花板"大约在 8000-10000 个线程时被 OOM killer 敲掉。

业务代码

项目里的异步订单处理:

// OrderAsyncService.java
private final ExecutorService orderExecutor = Executors.newCachedThreadPool();

public CompletableFuture<OrderResult> processAsync(Order order) {
    return CompletableFuture.supplyAsync(() -> {
        // 调用三方支付 + 库存 + 物流
        // 平均耗时 90-120s
        return processOrder(order);
    }, orderExecutor);
}

业务代码

每个订单进来丢进 cached thread pool,任务跑 90-120s。如果订单请求速率为 100 QPS,那么每秒新增 100 个线程,90s 后同时有 9000 个线程在跑。60s 的空闲回收时间在这里根本不够——因为新线程增长的速度远超回收速度。

为什么上线后才出问题?回顾上线记录——这次发布增加了一个三方信用卡支付渠道,单个订单的处理时间从原来的 20-30s 延长到了 90-120s。而 cached thread pool 的 keepAliveTime(60s)基于旧的处理时间设定,在新场景下从来就没有空闲线程可回收。


复盘

截图类型: diff — 修复前后线程数 + RSS 对比

修复方案

// 修复后:用有界线程池替代 cached thread pool
private final ExecutorService orderExecutor = new ThreadPoolExecutor(
    50, 200,
    60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(1000),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

修复前后对比

变更: | | 改前 | 改后 | |--|------|------| | 线程池类型 | newCachedThreadPool | new ThreadPoolExecutor(50, 200) | | 最大线程数 | 无上限 (Integer.MAX_VALUE) | 200 | | 工作队列 | SynchronousQueue (零容量) | LinkedBlockingQueue (1000) | | 拒绝策略 | AbortPolicy(几乎不触发,因 max 无上限) | CallerRunsPolicy (调用方执行) | | 线程数稳定值 | 8000+ | 150-200 | | 容器 RSS | 15.7GB | 4.5GB |

CallerRunsPolicy 在这里是有意为之:当队列满时,不让任务抛异常丢失,而是让提交任务的 Tomcat 线程同步执行。这虽然会拖慢接口响应,但保证了任务不丢、系统不崩——比 AbortPolicy 好。

NMT 盲区的本质

这里有一个更深刻的教训——NMT 是一个"白名单"系统,它只报告它知道的内存。它知道 Java Heap、Metaspace、Code Cache、GC 结构——这些都是 JVM 自己分配的。但它不知道线程栈,因为线程栈是操作系统在 clone 线程时自动分配的 mmap 内存,JVM 没有经过自己的内存分配器。

JDK 11 的 Thread 分类只统计了线程自身的数据结构(java.lang.Thread 对象 + 对应的 OSThread 结构体 + 一些 native metadata),大约 48KB 一个线程。8432 × 48KB ≈ 387MB——正好就是 NMT 报的 Thread reserved=387MB。但这 48KB 只是线程的"元数据",线程栈的 1MB(默认 Xss)完全不在此列

NMT 不是用来查线程栈内存的,它是用来查 JVM 自身 malloc 分配的内存的——mmap 的线程栈它根本看不见。

你怎么知道你的线程池是安全的?

这条排查经历给了一个残酷的教训:你在代码里写一行 newCachedThreadPool(),半年后它会在 OS 里长成 8000 个线程

我见过太多生产事故,根因就是一行看起来人畜无害的 Executors.newCachedThreadPool()。在生产代码里,永远不要用 Executors 工厂方法的默认配置——不管是 newCachedThreadPool()(无界线程数)还是 newFixedThreadPool()(无界队列),它们的默认行为都不适合生产环境。

工厂方法 风险 推荐替代
newCachedThreadPool() 线程数无上限 → 线程栈 OOM new ThreadPoolExecutor() 指定 max
newFixedThreadPool(n) LinkedBlockingQueue 无界 → 任务堆积 OOM new ArrayBlockingQueue<>(有限)
newSingleThreadExecutor() 同上 + 单点瓶颈 仅在需要串行化时用
newScheduledThreadPool(n) 调度任务异常不抛 ScheduledExecutorService 包装 try-catch

复盘要点

排查线:

dmesg OOM killer → jstat 排 heap → /proc/status 线程数 8432
    → NMT Thread 才 184MB(确认盲区)
    → jstack 状态分布 → 全部在 SynchronousQueue 等
    → 代码审查 → Executors.newCachedThreadPool()

"有鬼"时刻复盘:NMT Thread 分类和实际线程数对不上。

上次 NMT 帮忙找到了 JNI 泄漏,这次它却几乎成了"误导源"——如果你的排查路径是"打开 NMT → 看到 Thread 正常 → 排除线程问题",你就错过了真正的根因。

线程栈是 native memory 排查中最容易被忽视的一块。 它不在 NMT 视野内,不触发堆 OOM,不体现在 GC 日志中——唯一能提早发现它的途径是 /proc/PID/statusThreads 字段和线程数监控告警。

排查场景回顾

  • 系统背景:订单处理服务,Spring Boot 2.7,JDK 11,8C16G 容器,-Xms4g -Xmx4g。新增三方支付渠道后 Pod 反复重启
  • 排查时间线:飞书告警(14:22) → dmesg(14:30) → jstat(14:33) → /proc/status(14:36) → NMT(14:40) → jstack(14:45) → 代码定位(14:55) → 修复上线(15:10)

排查全流程时间线

附:完整命令清单

# 1. 确认是否被 OOM killer 杀掉
dmesg -T | grep -E "oom|killed"

# 2. 确认堆使用率
jstat -gcutil <pid> 1000 5

# 3. 查线程数(最快的途径)
cat /proc/<pid>/status | grep Threads

# 4. 查每个线程的栈占用
grep 'stack' /proc/<pid>/maps | awk '{sum+=$2} END{print sum/1024" MB"}'

# 5. NMT 确认 Thread 分类
jcmd <pid> VM.native_memory summary scale=MB

# 6. jstack 看线程在干什么
jstack <pid> | grep "java.lang.Thread.State" | sort | uniq -c | sort -rn

# 7. 深入分析:线程按调用栈分组
jstack <pid> | grep -A 5 "parking" | grep "at " | sort | uniq -c | sort -rn | head -20

# 8. 动态调 Xss 验证(不停机,仅影响新线程)
jcmd <pid> VM.set_option ThreadStackSize 512

下篇我们聊一次诡异的 Safepoint 机制导致应用无响应——和线程栈一样,它也是 JVM 内部一个容易被忽视的性能杀手,但排查思路不是看线程数,而是看 Safepoint 日志中的停顿耗时+TTSP(Time To Safepoint)指标

线程栈只是 NMT 盲区的冰山一角。DirectBuffer、malloc、mmap、mlock——JVM 里哪些内存 NMT 看不到、哪些看得到但容易误读,我整理了一份完整的《NMT 内存可见性全景图》,下期发在公众号。关注接收推送。

📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」