Java线程创建失败:Unable to Create New Native Thread的排查与修复全指南

发布时间:2026/10/24 5:09:53
Java线程创建失败:Unable to Create New Native Thread的排查与修复全指南

干 Java 的兄弟应该都见过这个报错:unable to create new native thread。它通常挂着一串 atjava.lang.Thread.start的调用栈冒出来,而且每次都挑在最忙的时候——线上业务正压着峰值,突然一批任务全部失败,日志里整整齐齐刷出这个异常。我先说结论:这不是"JVM 坏了",而是 JVM 尝试向操作系统申请新的线程资源时被系统拒绝了。至于为什么拒绝,可能是线程栈内存不够、进程线程数触顶、系统线程总量超限,或者容器限制卡住了你。这篇文章把我实际排查和修复这个问题的完整思路、命令、参数以及藏得比较深的坑全部写出来,不讲空话,照着做就行。

1. 先搞懂一件事:这个报错本质上是操作系统在拒绝你

1.1 Java 每个线程到底是怎么来的

很多写 Java 的人会把线程和 JVM 内部概念搞混,觉得 new Thread() 就是在 JVM 里"画"一个对象出来。实际上,HotSpot 虚拟机里每个 Java 线程都对应一个原生线程(native thread),调用Thread.start()之后,JVM 会通过pthread_create这类系统调用,让操作系统去真正创建一个内核线程。

这个过程需要向操作系统申请两样东西:线程控制块(内核资源)和一块栈内存。64 位 Linux 上 HotSpot 默认给每个线程的栈大小是 1MB,加上 JVM 自身要维护线程相关的元数据,实际上每个线程在进程虚拟内存里的占位会略大于 1MB。

所以问题来了:操作系统不是无限资源的提供方,它给每个用户、每个进程、整个系统都设了各种上限。一旦任何一层限制被顶到,pthread_create就会失败,JVM 拿到返回的错误码,就抛出了那个著名的java.lang.OutOfMemoryError: unable to create new native thread。

1.2 哪些场景最容易触发这个错误

根据我这几年的排查经验,遇到这个报错大致逃不出下面这几类情况:

第一类是线程池被占满后还在大量创建新线程。比如某支付网关项目用的框架默认线程池只有 200 个线程,但是下游服务响应特别慢,导致任务全部堆积在线程池队列里,请求还在不断进来,线程池按配置持续创建新线程,很快就撞上系统限制。

第二类是代码里到处 new Thread。后台任务调度平台最典型,定时任务里每来一批任务就 new 一批线程,任务处理不完又开新任务,线程只增不减,最终进程直接宕掉。

第三类是部署环境本身就限制了线程数量。有些基础镜像会把 nproc(用户最大进程数)设得很低,JVM 还没跑满业务量就先触顶了。

第四类是容器内存配置不合理。这种情况最隐蔽:容器设置了内存上限,但 JVM 启动时识别到的可用内存比实际能用的多,导致线程栈内存占用远超预算,内存先耗光再报无法创建线程。

这里我统一强调一下:遇到这个错,优先当"操作系统资源问题"处理,别一头扎进业务代码里去翻。

2. 排查这种问题,我建议你按这个顺序来

2.1 先确认线程数到底是不是真的失控了

拿到报错之后第一件事,不是改参数,而是搞清楚 JVM 当前到底创建了多少线程。如果连数据都没有,改完参数也是瞎蒙。

最快速的办法是直接数进程里的线程:

# 先找到 Java 进程 PID ps -ef | grep java # 统计该进程下线程数量 ps -eLf | grep <PID> | wc -l # 或者用 jcmd 直接打印线程信息 jcmd <PID> Thread.print | grep -c "^\""

我自己常用的是jcmd Thread.print,因为它能顺带看到线程名和栈状态。如果发现线程数已经上千,并且日志里大量任务被阻塞,那基本可以肯定线程已经失控了。这时候别急着杀进程,先把线程转储留下来:

jstack <PID> > /tmp/jstack_$(date +%s).log

这个文件后面排查线程大头时非常有用。你可能还会看到很多RUNNABLE状态的线程卡在同一个 socket read 上,那基本就是下游依赖响应慢把线程拖死了。

2.2 系统层的三重限制逐一核对

线程数失控之后,要顺着"操作系统为什么拒绝创建线程"这条路往下查。这里有三层限制,缺一不可。

第一层是用户级限制。Linux 对单个用户可以创建的进程/线程总数有限制,用ulimit -u查看:

ulimit -u

需要注意,这个值在 Linux 下同时限制进程数和线程数。默认值通常是 1024 或 65535,但很多环境会被调小。

第二层是系统全局限制。整个 Linux 系统能创建的线程总数受threads-max限制:

cat /proc/sys/kernel/threads-max

第三层是进程 PID 数量上限,也就是pid_max:

cat /proc/sys/kernel/pid_max

还有一个容易被忽略的:进程虚拟内存映射区数量上限max_map_count。每个线程栈在进程里都是一个内存映射区域,创建大量线程时,映射区数量也可能触顶:

cat /proc/sys/vm/max_map_count

三层限制的关系可以用一个比较土的类比:线程就是新住户,ulimit -u是这个小区的总户数限制,threads-max是整个城市的户口总量,max_map_count是小区里能划的地块数。任何一个满了都住不进去。

2.3 JVM 自身的内存参数也要盯

线程创建失败还有一个非常常见的原因是进程的虚拟内存不够了。每个线程栈默认 1MB,这个值在 64 位 Linux 上看起来不大,但线程数量上去之后非常可观。1000 个线程就是 1GB 虚拟内存,5000 个线程就是 5GB。

如果你启动 JVM 时设置了-Xmx很大,同时系统虚拟内存本身不够,操作系统就会在创建线程时直接拒绝内存分配。

另外还有一个冷门但很关键的点:操作系统内存 overcommit 策略。运行cat /proc/sys/vm/overcommit_memory,如果值是 2,内核会按"承诺不能超过总量"来严格限制虚拟内存分配。这种情况下即使物理内存充足,虚拟内存超额也可能导致线程创建失败。

我自己踩过一次:某后台系统-Xmx设了 8G,-Xss没动,默认 1MB,高峰线程数到了 6000 左右,再加上堆内存、元空间、直接内存,进程虚拟内存直接冲到了 15G 以上,而机器的 overcommit 策略是 2。结果就是每次线程数冲到 6000 多一点就报这个错,不早不晚刚刚好。

3. 修复方案就三板斧:系统层、JVM层、代码层

3.1 操作系统层参数调整

如果确认是用户级限制导致的,可以用两种方式调整。临时生效:

ulimit -u 65535

持久生效,修改/etc/security/limits.conf,加入:

* soft nproc 65535 * hard nproc 65535

注意,nproc限制的是"用户进程数",包含线程。修改完需要重新登录会话或者重启进程才生效。

如果是系统级threads-max不够,可以用 sysctl 调整:

sysctl -w kernel.threads-max=200000

持久生效就写进/etc/sysctl.conf:

kernel.threads-max=200000

max_map_count同理:

sysctl -w vm.max_map_count=262144

这里有个重要的坑:盲目调大系统参数只是拖延问题,不是解决根因。如果代码层无限创建线程,调再大也会再次触顶,而且线程数量过大时 CPU 上下文切换开销会拖垮整个服务。所以这三板斧要连起来用,不能只用一把。

3.2 JVM 参数的调整逻辑

JVM 层面最常见的手段是调小线程栈大小,也就是-Xss参数。

java -Xss256k -jar app.jar

把默认 1MB 改成 256k 或 512k,能让同样内存下容纳更多线程。但注意:栈变小,递归深度会受限,如果代码里有较深的递归调用,可能出现StackOverflowError。所以在调整-Xss之前,先确认系统里有没有递归或者超大局部变量的代码路径。

另外,JDK 1.8 之后还可以用-XX:ThreadStackSize来设置,效果和-Xss等同。不过一般写-Xss就够了。

还需要注意-Xss只影响原生线程栈的初始大小,不代表线程内存占用就是精确的 256k。实际每个线程还会有 JVM 内部结构,所以只能说"大约降低了不少"。

如果是容器环境,还需要检查-Xmx设置是否合理。常用做法是配合-XX:MaxRAMPercentage:

java -XX:MaxRAMPercentage=75.0 -Xss256k -jar app.jar

这个参数告诉 JVM,在容器限制的物理内存里,最多拿 75% 给堆。这样能避免 JVM 以为内存很多、实际一跑就爆的情况。

3.3 代码层治理才是治本手段

系统参数和 JVM 参数都是"拓宽道路",代码层要做的才是"减少车流量"。

先说最常见的病根:直接 new Thread。这种代码一旦出现在任务处理逻辑里,基本是定时炸弹。正确做法是全部收敛到线程池:

ExecutorService executor = new ThreadPoolExecutor( 10, // 核心线程数 200, // 最大线程数 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new ThreadFactoryBuilder().setNameFormat("task-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() );

这里的参数不是随便拍的。核心线程数和最大线程数要根据任务类型来定:CPU 密集型任务线程数大约等于 CPU 核数 + 1,IO 密集型可以根据"核数 * (1 + 等待时间/计算时间)"估算。队列长度需要根据任务堆积时长上限来倒推。

还有一个不可省的设置:拒绝策略。默认的AbortPolicy会在队列满时直接抛异常,导致任务丢失。实际操作里我更推荐CallerRunsPolicy,它让提交任务的线程自己执行任务,起到天然限流的作用,至少不会让任务静默消失。

另外,如果系统里有多组业务在共用线程资源,应该做线程池隔离,别让一个慢服务拖垮所有业务线程。这一点在微服务架构里尤其重要:一个下游超时,不能把上游的线程池全部占完。

4. 用一个小 Demo 完整复现从报错到恢复的全过程

4.1 构造一个可控的复现环境

纸上谈兵没意思,我用一个简单 Demo 来模拟线上场景。假设有一个后台任务系统,任务处理用了无界线程池:

public class ThreadLeakDemo { public static void main(String[] args) { while (true) { new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(Long.MAX_VALUE); } catch (InterruptedException e) { // ignore } } }).start(); } } }

这个代码会无限创建线程,每个线程都 sleep,不释放。在 Linux 下跑一小会儿就会把系统限制顶穿。

为了能安全复现,先把用户限制调小一点:

ulimit -u 512

然后启动这个 Demo:

java -Xmx256m ThreadLeakDemo

很快就能看到:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread at java.lang.Thread.start0(Native Method) at java.lang.Thread.start(Thread.java:717)

4.2 一步步处理完整个流程

第一步先看进程状态:

ps -eLf | grep ThreadLeakDemo | wc -l

你会发现线程数卡在 500 多就上不去了。同时查看系统限制:

ulimit -u cat /proc/sys/kernel/threads-max

到这里问题已经很清晰:ulimit -u 512卡住了线程数。接下来我演示三种修法:

修法一:调大用户限制

ulimit -u 4096

重新启动 Demo,线程数能继续往上走。但这不是治本,因为内存总会被耗完。

修法二:修改 JVM 栈大小

java -Xmx256m -Xss128k ThreadLeakDemo

同样的限制下,线程创建数量明显变多了。因为每个线程占用的虚拟内存变小了,但这样做也只是把触顶的位置往后推。

修法三:改造代码

把无限new Thread改成有界线程池:

ExecutorService executor = new ThreadPoolExecutor( 10, 10, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(100) ); while (true) { executor.submit(() -> { try { Thread.sleep(Long.MAX_VALUE); } catch (InterruptedException e) { // ignore } }); }

这种写法下线程数最多 10 个,队列堆满后会触发拒绝策略,不会再无脑创建线程。这才是在生产环境里真正需要的姿态。

跑完这个 Demo 之后,记得把ulimit -u恢复原值,别留一个受限的 shell 环境给自己。

5. 容器环境才是最考验人的地方

5.1 JVM 认不准容器内存限制

现在很多服务跑在容器里,问题往往不在宿主机,而在容器这一层。

JDK 8 在早期版本里并不能自动识别 cgroup 的内存限制。如果你用-Xmx没显式设置,JVM 会拿宿主机的物理内存来算默认值。结果就是 JVM 以为有 64G 内存可用,实际上容器只给了 4G 配额,堆内存 + 线程栈 + 元空间一叠加,容器内存直接被打满,然后线程创建失败。

这个问题在 JDK 8u131 之后有改善,引入了-XX:+UseCgroupMemoryLimitForHeap,到 8u191 之后默认开启了容器识别。但如果你还在用比较老的 JDK 8 版本,或者用的是自定义基础镜像,建议显式设置:

java -XX:MaxRAMPercentage=75.0 -Xss256k -jar app.jar

MaxRAMPercentage会按容器限制的物理内存来计算堆大小,比手写-Xmx更稳妥,至少不会因为机器内存大小不同而出现配置漂移。

5.2 cgroup 的 pids 限制是另一个隐形天花板

内存之外,容器还有线程数限制。现代容器运行时会往/sys/fs/cgroup/pids/pids.max写一个值,这个值限定了容器内所有进程加起来的线程总数。

查看当前容器的限制:

cat /sys/fs/cgroup/pids/pids.max # 查看已经用了多少 cat /sys/fs/cgroup/pids/pids.current

如果pids.current已经非常接近pids.max,并且系统日志里出现找不到 PID 资源之类的错误,那就基本能确定了。

这个限制在 Kubernetes 环境里通常是由 Pod 的资源申请间接映射过来的,一般不会直接暴露给应用。但一旦线程数触顶,Java 进程照样报unable to create new native thread。

处理方式是在启动参数层面保证线程数可控,比如调小-Xss、限制线程池最大线程数。同时给 Pod 配一个合理的进程/线程配额,别让所有服务共享一个过于紧张的上限。

5.3 ulimit 改完不生效的原因

容器里还有一个特别搞心态的情况:你在容器里执行ulimit -u怎么改都改不动,或者改了立即失效。

这是因为容器进程的RLIMIT_NPROC是在启动时由容器运行时设置的,shell 里再改通常会被限制,甚至没有权限。正确做法是在启动容器时通过参数设置,比如:

docker run --ulimit nofile=65535:65535 --ulimit nproc=65535:65535 ...

在 Kubernetes 中则要调整 Pod 级别的配置,或者依赖治理平台把对应参数注入到启动命令里。我更推荐的方式还是代码层控制线程数 + JVM 参数控制栈内存,不要依赖在容器里改系统参数,因为这种改动不可靠、难追踪。

6. 别等出了事再救火:线程数的日常治理

6.1 线程池参数设计要算过,不能靠感觉

线程池的核心线程数、最大值、队列长度,这三个值必须根据业务量级算一遍,而不是网上抄一个"最佳实践"。

一个比较靠谱的估算思路:先分析任务的类型。

  • CPU 密集型任务,按 CPU 核数来定,通常核数 + 1。
  • IO 密集型任务,可以按核数乘以(1 + 等待时间/计算时间)估算。比如发起一次远程调用耗时 200ms,CPU 计算只占 10ms,等待比大约是 20,那么双核机器上核心线程数取 42 以上理论上是合理的,但还要留足队列空间,别让线程数直接打到最大值,否则内存压力照样大。

实际操作中,我会把核心线程数控制在常规流量水位以下,最大线程数留 10%~20% 的缓冲,队列长度按照"允许任务排队多少秒"来反推。

配置文件里也可以用占位符管理:

task: pool: core-size: 16 max-size: 64 queue-capacity: 512 thread-name-prefix: async-task- keep-alive-seconds: 60

然后通过配置中心去动态调整,方便大促前临时扩大容量。

6.2 监控系统要有线程数的影子

线程数这种指标,平时没人看,出事了才觉得重要。但真等报错才去看就晚了。早期预警完全可以通过监控实现。

JVM 自身就提供了线程数的获取接口:

ThreadMXBean bean = ManagementFactory.getThreadMXBean(); int threadCount = bean.getThreadCount(); long peakThreadCount = bean.getPeakThreadCount();

把这些数据暴露给监控系统,比如定时上报到指标平台,配上阈值告警:

  • 线程总数超过某个基线持续 5 分钟,提醒。
  • 线程数增长曲线斜率过高,提醒。
  • 活跃线程数长期接近最大线程数,提醒。

这些告警能在unable to create new native thread出现之前就把问题暴露出来。我在维护某后台任务平台时,就是靠这条曲线在凌晨 3 点抓住了线程泄漏的苗头,比等到早上业务投诉再排查舒服太多了。

6.3 定期抓线程转储,别等现场丢了才后悔

线程泄漏排查最怕的就是现场没了。进程重启之后什么都看不到,只能靠重新复现。所以建议定时、主动地抓线程转储。

简单的方式是写个定时任务:

jcmd <PID> Thread.print > /data/logs/jstack_$(date +%Y%m%d_%H%M%S).log

留存最近 7 天的转储文件。出问题时可以直接对比同一个业务线程在不同时间点上的数量变化,定位到是哪个调用链在无限创建线程。

在 Java 9 之后,也可以直接用jcmd Thread.dump_to_file生成.jfr格式的转储,配合分析工具看线程的锁等待关系,比纯文本直观一些。不过日常快速排查,jstack已经够用了。

7. 常见问题与快速处理速查表

这里把我这几年遇到过的场景整理成一张表,方便直接对照:

症状常见原因快速排查命令处理手段
线程数到某个值就报错ulimit -u 限制ulimit -u调大 nproc 限制
线程数持续增长最终爆掉代码无限创建线程jstack看线程名和栈改线程池,设置拒绝策略
设置大 -Xss 后内存暴涨线程栈内存占用过高ps -eLf对比线程数调小 -Xss,控制线程数量
容器内线程数受限cgroup pids.max 限制cat /sys/fs/cgroup/pids/pids.max调整 Pod 配额或减少线程
JVM 内存超预期占用老 JDK 不识别容器内存java -XX:+PrintFlagsFinal -version查看 MaxHeapSize显式设 MaxRAMPercentage
报错伴随内存溢出堆内存 + 线程栈超预算jstat -gcutil <PID>查看 GC 趋势降低 -Xmx,配合 -Xss 调整
修改 ulimit 不生效容器内无法修改系统限制直接看容器运行时参数从容器启动配置调整

这张表不是标准答案,但是它覆盖了绝大多数线上场景。遇到报错时,逐行对照做一遍,基本能定位出来。

8. 一点个人实操体会

这个错误我前前后后处理过不下十次,印象最深的不是技术层面的复杂,而是很多人习惯性地把它当成"JVM 内存问题",一上来就调-Xmx,结果越调越糟。

我自己现在排查这类问题的固定套路是:先数线程,再看限制,再查代码,最后才动参数。方向对了,问题往往比想象中简单。

还有一个小技巧:修复之后别急着收工,把 JVM 的线程数峰值记下来,和系统限制对比一下,留出安全余量。比如系统允许 5000 个线程,业务峰值只用到 2000,那这个配置基本是稳的。如果峰值占比超过 80%,就得考虑优化代码或者扩容了。

如果这篇文章能让你少加一次班,那就值了。