线程堆栈吃光 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 反复重启

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。

是 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

老年代 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 个线程。正常的 Java 服务线程数在 200-500 之间。8432 是什么概念——每个线程默认栈大小 1MB(Linux x64 + JDK 11 默认值),光线程栈就吃了约 8GB 的虚拟内存。
/proc/PID/status 的 Threads 字段是 /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 说 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)

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>());
}

核心参数:
- 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/status 的 Threads 字段和线程数监控告警。
排查场景回顾
- 系统背景:订单处理服务,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拆代码的曹操」