一个服务为"深递归"把 -Xss 从 1M 调到 8M,问题解决了——而一周后"线程数"到 600 就报 unable to create native thread。因为"每个线程的栈是"预留的虚拟内存",600 × 8M = 4.8G,加上堆与元空间,超过了"进程可用地址空间(32 位或容器配额)。
线程栈有三个"反直觉":它是"预留"不是"用多少算多少"、它与"线程数"乘积决定"能不能起线程"、这个"不归 -Xmx 管"是排查的起点:堆溢出报 OutOfMemoryError: Java heap space,而"栈"溢出报 unable to create native thread——两个错误名不同,却指向同一本账(进程总内存).
它不归 -Xmx 管。
1. 线程栈的"会计口径"
| 项 | 归属 |
|---|---|
堆(-Xmx) |
JVM 堆 |
线程栈(-Xss) |
每线程"预留" |
| 元空间 | 类元数据 |
| 直接内存 | 堆外 |
第二项是"最容易算漏"的:"线程数 × -Xss"是"虚拟内存"的固定开支这个"固定开支"在"容器里"会变成"硬撞墙":容器按"进程虚拟内存"或"RSS"限额,而"栈的预留"在两者里都可能被计入——于是"堆还很空"时,线程已经起不来.
——而"容器配额"按"实际占用"算,某些环境按"虚拟内存"算.
2. "线程起不来"的三种成因
| 成因 | 现象 |
|---|---|
栈太大(-Xss 过大) |
线程数上不去 |
| 线程泄漏(只增不减) | 慢慢耗尽 |
系统限制(ulimit/容器) |
起线程即失败 |
第一项的"典型时间线"是:"某次 StackOverflowError → 加 -Xss → 一周后线程数上不去"——两次故障"看起来无关",其实是"同一个决定"的两个后果.
第一项是"自己造成的":为解决"深递归"而调大 -Xss——代价是"并发能力下降".
"栈溢出"要先"分清"是"递归深"还是"单帧大",因为修法不同:
// 两种"StackOverflowError"
1 递归太深:栈帧数多(如"无限递归"或"树的深度")→ 改迭代/限深度
2 单帧太大:方法里"局部变量/大数组" → 拆方法(把大对象放堆上)
// 判据:异常栈里"同一方法重复出现" → 类型 1;"深度不深但一调用就炸" → 类型 2
3. "深递归"的三种正解
| 解法 | 代价 |
|---|---|
调大 -Xss |
线程数下降 |
| 改"迭代"(显式栈) | 代码改动 |
| 限制"递归深度" | 业务约束 |
第二项是"根本解":"把递归改成"用 Deque 的迭代"第二项的"改动量"取决于"递归的形状":"树遍历"改迭代要"显式栈",而"尾递归"往往能"直接改循环"——后者改动小一半.
——栈空间变成"堆上的对象",而堆可以"几个 G".
"能起多少线程"可以"先算再调",而不是"起不来再猜":
// 三步"线程预算"(示例:容器 2G,堆 1.2G)
1 可用:2G − 1.2G(堆)− 0.3G(元空间+直接)− 0.1G(余量)= 0.4G
2 每线程 `-Xss` 1M → 上限 ≈ 400 个(还要留"GC/JIT 线程")
3 若"业务需要 800 线程" → 降 `-Xss` 到 512k,或"减线程/改异步"
// 判据:线程预算"先算",别等"unable to create native thread"
4. "线程数上限"的三个约束
| 约束 | 典型值 |
|---|---|
ulimit -u(进程/线程数) |
数千 |
| 虚拟内存(线程栈总和) | 由 -Xss × 线程数 决定 |
| 容器配额(内存/cpu) | 由编排定 |
第二项的"算法"可以写成一句:"能起的线程数 ≈ (进程可用内存 − 堆 − 元空间 − 直接内存) / -Xss"——于是"栈"与"堆"是"同一块蛋糕上的两刀".
第二项在"容器里"最容易被撞:"内存 2G 的容器,-Xss 1M,线程数上限 ≈ 1000(预留 1G)"——而"堆还想用 1.5G".
"线程泄漏"与"栈"叠加时,故障会"提前到来":
// 两种"只增不减"
1 池"新建而不回收":core=max 且"队列满后继续新建"(配置错)
2 任务"卡住":线程都阻塞在 IO,池"不断扩容"
// 判据:线程数"随时间线性涨" → 先查"卡在哪个栈"(jstack 看同一处)
5. "线程池"与"线程栈"的三个关系
| 关系 | 说明 |
|---|---|
池上限 × -Xss = 预留 |
池越大,预留越多 |
| 虚拟线程(Loom) | 栈"按需增长"(不预留) |
| 大池 + 大栈 | 双重放大 |
第三项是"最坏组合":"池 500 × -Xss 2M = 1G 预留"第三项的"识别"是"看配置的两处":"线程池上限"与"-Xss"——它们由"不同的人"设置(业务 vs 运维),于是"乘积"没人算.
——而它"只为偶尔的深递归".
"容器 vs 裸机"的差别,常在"RSS 与实际占用"上体现:
// 三处"看内存"
1 RSS(/proc/<pid>/status):实际占用(含栈的"已用页")
2 虚拟内存(VmSize):含"栈的预留"(可能远大于 RSS)
3 容器 limit:按"哪种"计,取决于运行时与内核参数
// 判据:VmSize 远大于 limit 而起不来 → 是"预留"撞墙,不是"真的用了"
6. 排错:五种"线程栈问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
unable to create native thread |
栈 × 线程数超限 | 降 -Xss/降线程 |
StackOverflowError |
递归太深 | 改迭代/限制深度 |
| 线程数只增不减 | 泄漏 | 查池与任务 |
| 容器 OOM 而堆不满 | 栈预留 + 直接内存 | 算总账 |
| 虚拟线程也报错 | 载体线程数限制 | 调 carrier 数 |
"隔离进程"是"改不动第三方库"时的兜底,而它很实用:
// 三种"隔离"方式
1 单独进程 + 大 `-Xss`(最彻底,IPC 通信)
2 单独线程 + 大栈(同进程,风险仍在)
3 用"虚拟线程"跑它(栈进堆,但阻塞会占载体)
// 判据:能"改代码"就改;改不动,用 1,别用 2
7. 边界:什么情况"就该大栈"
| 场景 | 为什么 | 做法 |
|---|---|---|
| 深递归算法(解析器、树遍历) | 天然需要 | 大栈 + 少线程 |
| 单线程批处理 | 并发不是目标 | 大栈无害 |
| 第三方库"栈深度不可控" | 改不动 | 大栈 + 隔离进程 |
| 虚拟线程(Loom) | 栈按需 | 不必调 |
最后一条与"虚拟线程"有关的边界:"虚拟线程"的栈"存在堆上、按需增长"——于是"深递归"在虚拟线程里"消耗堆"而不是"预留栈";而"载体线程(carrier)数"仍受"平台线程"限制,于是"阻塞式调用"仍要"算线程".
判断口径一句话:"这个 JVM 是"少线程深栈"还是"多线程浅栈"。前者(批处理、解析),-Xss 调大没问题;后者(Web 服务、线程池),-Xss 每加 1M,能起的线程就少一批——而"深递归"应当"改成迭代",不是"加栈".
再补一条"默认值"常识:-Xss 的默认值随平台与 JDK 版本变化(常见 512k–1M),而"容器里"还可能被 -XX:ThreadStackSize 覆盖——于是"本地能起 2000 线程、容器只能起 400"这类差异,往往不是"容器弱",而是"默认值不同"。所以"线程预算"这一步,应当在"容器里"做一次,而不是"照抄本地的数";而"-Xss 写进启动参数"比"依赖默认值"更可控,代价只是"一行配置"。
而"一行配置"这件事,恰好是"减少环境差异"里最便宜的一类。
同样的道理也适用于"堆、元空间、GC 参数"——写死比默认可靠。
而"可靠"这个词,在"跨环境"的运维里就是"可预期"。
8. 自测
- 线程栈的"会计口径"为什么反直觉?它与
-Xmx的关系是什么? - "线程起不来"的三种成因里,哪一种"是自己造成的"?
- "深递归"的三种正解分别有什么代价?为什么"改迭代"是根本解?
- 容器里"2G 内存、
-Xss 1M"能起多少线程?为什么"堆还想用 1.5G"会冲突? - "池 500 ×
-Xss2M"为什么会"双重放大"? - 判断"该不该调大
-Xss"的那个问题是什么?