一个服务在压力上来后报 unable to create new native thread,而堆内存只用了一半。运维加了堆,问题更严重了——因为这个错误与堆无关,它报的是"线程栈或系统资源不够",这个"反而"有个直白的机制:容器或机器的总内存固定,堆占得越多,留给栈与直接内存的部分越少。所以"加堆治线程问题"是把原因之一当成了解药。
而堆越大,可用于线程栈的空间反而越少。
线程数上限是 JVM 里唯一同时受"JVM 参数、操作系统、容器限制"三者约束的资源。任何一处到达上限,表现都是同一个异常,但修法完全不同。
1. 四个上限,谁先到
| 上限 | 来源 |
|---|---|
| 堆外可用内存 / 线程栈大小 | JVM 参数与物理内存 |
ulimit -u(进程数) |
操作系统 |
kernel.pid_max / cgroup pids |
系统 / 容器 |
| 线程池的自身容量 | 应用配置 |
第一项最容易被误解:-Xss 默认 512KB–1MB,1000 个线程就是 0.5–1GB,而这部分内存不在堆里。所以"堆加到 8G"会让线程栈可用的空间更少。
第二项在容器里常被忽略:cgroup 的 pids 限制会让"线程创建"被拒绝,而报错仍然是同一个异常。
2. 报错文案与真实原因
| 文案 | 含义 |
|---|---|
unable to create new native thread |
线程栈/进程数/系统资源 |
OutOfMemoryError: Java heap space |
堆不够 |
OutOfMemoryError: Metaspace |
类元数据 |
pthread_create failed (EAGAIN) |
系统级限制(底层日志) |
第一项与第二项完全不同,但很多人看到 OutOfMemoryError 就以为是堆问题。区分方式看冒号后面那一段。第二项(堆)与第三项(Metaspace)也有类似错位:Metaspace 不足时加堆无效,而它的表现是"频繁 Full GC 但堆占用不高"。
第三项(Metaspace)的常见触发是"动态生成类":大量反射代理、脚本引擎、热部署都会持续占用 Metaspace,而它默认没有上限(或上限很大),于是"看起来无限制但最终撞墙"。
第四项才是真正的根因信号,而它往往只出现在 stderr 或 GC 日志的尾部。
// 三步定位:线程数、ulimit、容器 pids 限制
jcmd <pid> Thread.print | grep -c '^"' # 当前线程数
cat /proc/<pid>/limits | grep -i 'processes' # 进程/线程数上限
cat /sys/fs/cgroup/pids.max 2>/dev/null # 容器 pids 上限
3. 线程数怎么估
| 依据 | 说明 |
|---|---|
| 线程池配置之和 | 确定的数 |
| 每请求一线程的框架 | 并发数相关 |
| 连接器线程 | Tomcat/Netty worker |
| 定时任务与后台线程 | 固定但常被忽略 |
第一项要做的事很简单:把应用里所有线程池的 max 加起来,再加连接器与定时任务。很多人第一次算会发现"配置了 2000 个线程",而机器只允许 1500。
第三项在 -Xss 调大后更吃紧:Tomcat 的 maxThreads 默认 200 不变,但每个线程的栈变大,总占用上升。第四项(定时任务)最容易被算漏:每个定时任务一个线程,加上调度框架自己的线程池,一个"有 20 个定时任务"的服务可能额外占 40–60 个线程。
// 一次真实算账(示例)
tomcat worker 200
业务线程池 8 × 32 = 256
定时任务 16
MQ 消费者 4 × 8 = 32
GC/JIT/其它 ~40
合计 ~544 线程
× 1MB 栈 = 544MB 堆外内存 + ulimit/pids 约束
4. 该调哪一个
| 修法 | 代价 |
|---|---|
减小 -Xss |
深递归可能 StackOverflow |
| 增大 ulimit / pids | 需容器与宿主配置 |
| 减少线程数(改异步) | 改动大,但根治 |
| 拆分服务 | 部署变复杂 |
第一项(减小 -Xss)有个硬下限:递归深度大的代码会先 StackOverflow。所以"减栈"只适合"确认没有深递归"的服务,而这一点很难验证——于是它常被当成最后一招。
第三项最根治:大量线程通常意味着"每个请求占一个线程"的模型,而改成异步(或虚拟线程)能从根上解决。
第四项在"单服务线程太多"时值得:把线程密集的模块拆出去,让上限问题分摊。
// JDK 21:虚拟线程把"线程"从稀缺资源变成廉价的
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
// 可以创建数十万虚拟线程,占用的是堆而不是栈
}
// 前提:阻塞调用需支持虚拟线程(文件 IO 的兼容性需实测)
还有一个"每次请求建线程"的隐性写法,在代码里几乎看不出来:
// 反例:每个任务新建线程(线程数随并发增长,且无法回收)
for (var task : tasks) { new Thread(() -> handle(task)).start(); }
// 正例:固定池 + 队列上限(线程数可控,且有背压)
var pool = new ThreadPoolExecutor(8, 8, 60, SECONDS,
new ArrayBlockingQueue<>(200), new CallerRunsPolicy());
// CallerRuns 让"队列满"变成"调用方等待",而不是无限建线程
5. 容器里的两个隐形上限
| 限制 | 表现 |
|---|---|
| cgroup pids.max | 创建失败(同异常) |
ulimit -u 继承自宿主/运行时 |
同上 |
第一项是容器编排时代的常见坑:默认 pids 限制可能是 512 或 1024,而 JVM 自己就要几十个线程,加上应用很容易撞到。
第二项取决于容器运行时(Docker 默认继承,K8s 可能按配置)。所以"本地好好的,容器里起不来"是典型症状。还有一个"双重限制"的坑:容器 pids 与宿主 ulimit 都可能生效,取更小者。于是"调了容器配置还是不行",因为宿主的限制更低。
// K8s 里显式放开 pids 限制(否则默认值可能很低)
resources:
limits:
pid: "4096" # 或 securityContext 里配
// 检查:cat /sys/fs/cgroup/pids.max
线程数"正常范围"要先建立一个基线,否则看数字无法判断:
// 一个 Java Web 服务的典型线程分布(基线参考)
http-nio-8080-exec 20–200(随并发)
pool-business 8–64(固定上限)
sched-tasks 10–20(定时)
mq-consumer 4–8
JVM 内部(GC/JIT) 20–40
合计 ~100–350
// 若某天"合计"从 300 涨到 1200 且不回落 → 泄漏,不是容量
6. 排错:五种"线程创建失败"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 堆只用一半就 OOM | 线程栈/系统限制 | 看报错后缀 |
| 加堆后更严重 | 线程栈空间被挤占 | 别只加堆 |
| 本地正常、容器报错 | pids/ulimit | 显式配置 |
| 压力上来就报 | 线程池 max 过大 | 算总账 |
| 偶发、重启后正常 | 泄漏(线程未回收) | 查线程增长曲线 |
第四项(压力上来就报)的判据是"报错是否与并发量线性相关":相关说明线程池 max 太大;不相关说明有别的线程在创建(如每次请求 new Thread)。
第五项最隐蔽:线程泄漏的表现为"线程数随时间单调上升",而它在低峰期完全正常。所以"线程数"应当是一个被监控的指标。
最后一条"选型"边界:线程数与 I/O 模型强相关。同步阻塞模型下"线程数 = 并发数",而异步模型下"线程数 = CPU 核数"。所以同一个容量目标,两种模型的线程需求差两个数量级——这也是"线程上限"问题在异步框架里几乎不存在的原因。
// 同一目标(1 万并发)的两种模型
同步阻塞:约 1 万线程(或排队)→ 栈内存 1 万 × 1MB = 10GB(不可行)
异步/虚拟线程:几十个平台线程(或 1 万虚拟线程,占用在堆)
// 结论:上限问题往往不是"调参",而是"选错了 I/O 模型"
7. 边界:什么情况不必关心线程上限
| 场景 | 为什么 | 做法 |
|---|---|---|
| 小并发内部服务 | 线程数远低于上限 | 默认即可 |
| 纯异步/事件驱动框架 | 线程数固定 | 默认即可 |
| 已用虚拟线程 | 不占栈 | 关注堆 |
| 短生命周期批处理 | 线程数可控 | 算一次即可 |
判断口径一句话:"线程数 × 栈大小"是否接近可用内存,或线程数是否接近进程上限。最后一条与"监控"有关的边界:线程数应当作为一等指标上报(按前缀分组:http-/pool-/sched-)。没有它,"线程泄漏"只能靠事故发现,而它本来可以靠趋势提前两周看见。
接近就该算账;远低于上限,不必操心。
8. 自测
- 四个上限分别来自哪里?为什么"加堆"会让线程问题更严重?
- 如何从报错文案区分"堆不够"与"线程创建失败"?
- 估算线程数要加哪几类?为什么
-Xss调大后 Tomcat 的默认配置会变紧? - 为什么"改异步/用虚拟线程"是最根治的修法?它的前提是什么?
- 容器里两个隐形上限是什么?"本地正常、容器报错"的典型症状指向哪个?
- 判断"是否需要关心线程上限"的那个问题是什么?