2171 字
约 7 分钟
20
S05-A26 · 线程栈:`-Xss` 调大之后,为什么"线程起不来"
2024-12-31
2026-11-10

一个服务为"深递归"把 -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. 自测

  1. 线程栈的"会计口径"为什么反直觉?它与 -Xmx 的关系是什么?
  2. "线程起不来"的三种成因里,哪一种"是自己造成的"?
  3. "深递归"的三种正解分别有什么代价?为什么"改迭代"是根本解?
  4. 容器里"2G 内存、-Xss 1M"能起多少线程?为什么"堆还想用 1.5G"会冲突?
  5. "池 500 × -Xss 2M"为什么会"双重放大"?
  6. 判断"该不该调大 -Xss"的那个问题是什么?
S05-A26 · 线程栈:`-Xss` 调大之后,为什么"线程起不来"
https://www.clxhxhhr.top/posts/6694/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。