news 2026/10/24 1:33:02

JVM线程数触顶导致OutOfMemoryError的排查与治理实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JVM线程数触顶导致OutOfMemoryError的排查与治理实战

凌晨三点,监控大屏突然开始刷红,某电商系统的会员积分服务批量超时,Error日志里疯狂滚动同一行异常——java.lang.OutOfMemoryError: unable to create new native thread。服务像被掐住脖子一样假死,请求越积越多,最后不得不连夜重启。这个场景我遇到过不止一次,如果你也正在被这个错误折磨,这篇实战总结应该能帮你少走很多弯路。

先说个反直觉的结论:这个错误的名字里带着“OutOfMemoryError”,但绝大多数时候根本不是堆内存不够,而是线程数触顶。很多人第一反应就是调大-Xmx,结果不仅没用,反而把服务压垮得更快。要根治它,得从JVM、操作系统、代码三层一起下手。

1. 故障初现:一次线程崩溃引发的连环宕机

1.1 事故现场:从批量超时到服务假死的全过程

当时的情况是这样的:会员积分服务承接了大量凌晨的异步对账任务,正常状态下线程数维持在200左右。某天上线了一个新的营销活动,大量用户同时触发积分变更,系统瞬间涌入海量异步请求。核心业务的处理线程池被打满,于是任务开始排队,排队又导致上游不断加重试,重试请求再次涌入线程池——典型的线程风暴。

从日志看,先是出现大量RejectedExecutionException,因为线程池队列满了;接着开始出现OutOfMemoryError: unable to create new native thread;然后GC日志显示老年代占用率直线飙升,原因不是业务数据太多,而是线程对象和线程中的局部引用堆积。最终服务进程直接假死,HealthCheck失败,被负载均衡摘掉。

这个事故给了我三个教训:

  • 线程问题往往被掩盖在内存问题里,GC异常只是表象
  • 线程池参数不合理比代码Bug更致命,因为它不会立刻报错,而是慢慢拖垮整个系统
  • 错误日志骗人,OutOfMemoryError只是最终结果,不是原因

1.2 常见元凶:哪些代码最容易把线程搞爆

根据我自己的踩坑经历和帮同事排查过的案例,最容易引爆unable to create new native thread的代码模式就这么几类:

  • 无边界异步任务:每来一个请求就new Thread()去执行,没有任何复用和限流,这是最原始的坑
  • 线程池配置不计后果:maximumPoolSize设置成几千甚至上万,配合无界队列,等于放任线程无限增长
  • 阻塞链路嵌套:线程A调用线程B,B又同步等待C,一层一层阻塞下去,线程全卡在等待上,新的任务只能再开新线程
  • 第三方SDK内部线程池设计不当:某个OCR识别SDK对每张图片都创建一个独立线程,压测时直接打爆系统
  • 定时任务线程失控:Spring的@Scheduled默认线程池是单线程的,如果任务执行时间太长,会不断堆积调度,有些框架的调度线程池是可配置的,有人图省事直接调到几千

上面这些场景有一个共同特征:线程只增不减,或者增长速度快于销毁速度。搞清楚这一点,排查方向就很明确了。

2. 追根溯源:一条Java代码如何走向原生线程的绝路

2.1 从Java线程到操作系统内核的完整链条

要理解这个错误,必须先明白一个Java线程的一生。你在代码里写new Thread(),实际发生的事情远比字面上复杂:

  1. JVM创建一个Java层的Thread对象
  2. JVM内部调用底层平台线程创建接口,在Linux上就是pthread_create()
  3. pthread_create()触发内核创建task_struct,分配栈空间和TCB(线程控制块)
  4. 操作系统将此线程调度到CPU核心上开始执行

所以“unable to create new native thread”不是Java层的“我懒了”,而是JVM向操作系统申请创建原生线程时,操作系统说**“不行,我已经没有资源给你创建新线程了”**。这个“没有资源”具体有两种含义:

  • 系统线程总数达到了上限
  • 进程地址空间无法再为新线程的栈分配内存映射段

2.2 真正的资源瓶颈:线程栈与虚拟内存的算术

在64位Linux系统上,JVM给每个线程栈默认分配大约1MB的虚拟内存(通过-Xss可以调整,但默认值就是1MB)。注意,这里是虚拟内存,不是物理内存。一个进程的虚拟地址空间虽然理论上很大,但内核针对每个进程有max_map_count限制——也就是进程最多能创建多少个内存映射区域。

每个线程的栈对应一个mmap区域,所以vm.max_map_count某种程度上也是一个线程数上限。这个值默认是65530,如果代码里线程动辄几万,就会撞上这堵墙。

算一笔实际账,假设一台机器内存16GB,JVM堆配置4GB,元空间256MB,剩下的物理资源还需要支撑操作系统页缓存和系统进程。如果-Xss是1MB,那么:

可用物理内存 16GB - 堆 4GB - 元空间 256MB = 约11GB 理论上最大线程数 = 11GB / 1MB ≈ 11000+

这只是物理内存的逻辑上限,实际上还有三个更早触顶的限制:

限制层检查命令默认值(参考)说明
系统级线程总数cat /proc/sys/kernel/threads-max约8万~40万(取决于内存)整个操作系统能创建的线程总数上限
单个用户进程数上限ulimit -u1024或4096(需要root调整)单个用户的所有线程/进程总和上限
进程内存映射区上限cat /proc/sys/vm/max_map_count65530线程栈mmap映射数量上限
容器pids上限cat /sys/fs/cgroup/pids/pids.max不限制或自定义Docker容器内线程总数上限

所以你会看到一种诡异的情况:物理内存明明还有一半空闲,但new Thread()就是失败。原因就是撞上了ulimit -u或者vm.max_map_count这堵软墙。

2.3 谁在暗地里吃掉线程名额

除了明面上的线程数,还有一种隐蔽的资源消耗容易被忽略:GC线程和编译器线程。JVM自身为了运行,会创建一些后台线程:

  • GC垃圾回收线程,数量跟CPU核心数相关
  • JIT编译线程(C1、C2编译器)
  • RMI、JMX、Attach Listener等监控线程

这些线程也算在用户进程数上限里。我见过一个奇葩案例:某开发把ulimit -u设成4096,JVM堆调得特别大,结果GC线程就占了32个,JIT线程占16个,业务线程还没起跑,用户配额已经用掉快50个。这种环境怎么调都是死路。

所以排查时不能只盯着业务线程,还要把JVM后台线程的份额一并计算。

3. 完整排查链路:五步定位线程失控的根因

3.1 第一步:确认错误分布与触发场景

收到告警后,第一件事是在ELK或日志平台里搜索unable to create new native thread,统计以下几个维度:

  • 错误分布在哪些服务实例上
  • 是否集中在某台机器(若集中在某台,可能是单机资源问题)
  • 出现的时段是否与业务高峰吻合
  • 异常之前是否有其他报错(比如连接超时、Connection pool exhausted)

我当时就是把三个维度的日志拉到一起对比,发现异常只在晚上10点到凌晨2点出现,而这个时段正好是积分对账批量任务的高峰——线索立刻明朗起来。

这一步看起来很基础,但很多人跳过直接看jstack,结果抓了一堆无关线索,白白浪费时间。

3.2 第二步:快速查看系统与JVM指标

登录出问题的服务器,按顺序执行这些命令:

# 查看当前系统总线程数 ps -eLf | wc -l # 查看某个Java进程的线程数 ps -L -p <PID> -o pid,tid,comm | wc -l # 或者 cat /proc/<PID>/status | grep Threads

接着看内存和负载:

# 观察物理内存和交换分区 free -m # 看进程CPU占用 top -H -p <PID>

如果free -m显示内存还有空闲,但进程线线程数已经破万,那基本可以判定是线程数触顶而不是内存触顶。如果内存确实吃紧,再往下看jmap -heap确认堆占用情况。

这里有个细节:ps -eLf输出里每行是一个线程,有人习惯用ps -ef查线程数,会漏掉多线程信息,导致看到的结果少了一个数量级。

3.3 第三步:抓取线程栈,观察线程状态分布

线程数爆掉之后,最关键的一步是搞清楚这些线程从哪里来、都在干什么。

# jstack抓取线程快照,通常要抓两次,间隔5秒 jstack <PID> > /tmp/thread_dump_1.txt sleep 5 jstack <PID> > /tmp/thread_dump_2.txt # 或者用jcmd,效果类似 jcmd <PID> Thread.print > /tmp/thread_dump_1.txt

拿到dump文件后,重点统计线程状态:

# 统计各种状态的线程数量 grep "java.lang.Thread.State" /tmp/thread_dump_1.txt | sort | uniq -c

我那次看到的结果非常典型:

  • 3000+线程处于WAITING状态,集中在DefaultThreadPool跑批任务队列上
  • 800+线程处于RUNNABLE,大量堆积在HTTP调用客户端
  • 500+线程处于TIMED_WAITING,是HTTP连接池的保活线程

这个分布说明:业务线程池的队列塞满了任务,执行线程的HTTP调用又被下游拖住不返回,新的请求继续进入队列,线程池不断扩张。

3.4 第四步:查系统线程限制,排除最隐蔽的那堵墙

如果线程数还没到内存极限,但创建仍然失败,十有八九是撞上了系统限制。依次检查:

# 当前用户能创建的最大进程/线程数 ulimit -u # 系统全局线程数上限 cat /proc/sys/kernel/threads-max # 当前系统PID号上限(超过这个数字也创建不了PID,就创建不了线程) cat /proc/sys/kernel/pid_max # vm.max_map_count,每个进程的内存映射区上限 cat /proc/sys/vm/max_map_count # 如果是容器环境,看cgroup限制 cat /sys/fs/cgroup/pids/pids.max

如果ulimit -u是1024但你的业务线程就需要2000个,那不用查别的,原因直接锁定。这是最常见且最容易被忽视的坑。

还有一个隐藏检查点:/proc/<PID>/limits能查到实际生效的软/硬限制。因为有些人改了/etc/security/limits.conf但忘记注销重登,导致shell会话里的限制没有刷新,应用启动时的限制依然是最初的值。

3.5 第五步:用公式反推系统应有的线程水位

当所有数据都拿到手后,可以做一次反向估算,判断问题到底出在代码还是出在机器配置上:

系统理论最大线程数 ≈ min( 物理内存可用量 / 单线程栈大小, vm.max_map_count, ulimit -u, 系统全局线程数余量 )

假设:

  • 机器物理内存32GB,堆配置8GB,可用内存约24GB
  • 单线程栈1MB
  • vm.max_map_count= 65530
  • ulimit -u= 10240

那么理论极限约10240(被ulimit -u卡住)。如果业务代码中最坏情况需要1.5万线程,那么这个系统无论怎么压测都必挂。

这种反推可以在故障发生前就提前预判系统的承载上限,而不是等线程真飙上去了才手忙脚乱。

4. 分级解决:止血、治理与基础设施改造三套组合

4.1 临时止血:重启与扩容的救急艺术

线上故障第一要务是抢救业务,请务必按这个顺序操作:

  1. 摘流量:把故障实例从负载均衡摘掉,避免新请求继续涌入
  2. 预留现场:在重启之前,用jstack抓取线程快照,保存日志,做docker inspect记录容器参数,防止重启后现场消失无法复盘
  3. 重启服务:启动前临时调整启动参数(下面详谈)
  4. 扩容:如果集群有其他实例,先横向扩容把流量分摊出去,而不是硬扛单机

临时调整启动参数时,有两个方向可以互相配合:

# 降低单线程栈大小,为更多线程腾出虚拟内存空间 -Xss512k # 适当调低最大线程数,避免无限膨胀 -XX:ActiveProcessorCount=4

-Xss调小确实可以变相抬高线程数上限,但要小心:如果业务中有深度递归或者大栈帧方法,会把真正的StackOverflowError引出来。所以-Xss512k这种值建议只在紧急止血时用,不适合长期生产配置。

修改ulimit这种系统参数也可以作为临时手段:

# 临时修改当前会话的软/硬限制(只在当前shell生效) ulimit -u unlimited

但要记住,这种方式对Docker容器内的应用无效,容器内的限制由cgroup决定,Shell层改了也白改。容器场景得在启动时指定:

docker run --pids-limit 8192 your-image:latest

--pids-limit是容器内所有进程/线程的总数上限,按照业务预估峰值再留20%余量来设就行。

4.2 结构治理:线程池参数重配与业务限流

单纯靠重启和调参永远治标不治本,真正的解决思路是把线程数变成可控的资源,而不是放任增长。

拿我们当时的会员积分服务举例,原来的线程池长这样:

ThreadPoolExecutor workerPool = new ThreadPoolExecutor( 200, // corePoolSize 8000, // maximumPoolSize,这个值太高了 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(10000) // 无界队列 );

这个配置存在三个硬伤:

  1. maximumPoolSize=8000,等于允许线程无限膨胀
  2. 队列容量1万,两者叠加,任务积压到天上去了
  3. 拒绝策略用的是默认的AbortPolicy,一旦队列满就抛异常,抛异常后又触发上游重试,重试再次进入队列——恶性循环

正确的重构思路是:

  • 先算合理的最大线程数:用“核心并发量 * 平均响应时间 / 1000”公式估算,或者直接压测得到服务能稳定承载的最大QPS,再反推线程数
  • 队列必须有界:无界队列是线程风暴的帮凶
  • 拒绝策略要选对:业务场景优先用CallerRunsPolicy,让提交任务的线程自己跑这个任务,天然形成背压,而不是直接丢弃或抛异常

改后的配置长这样:

ThreadPoolExecutor workerPool = new ThreadPoolExecutor( 100, 300, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(500), new ThreadPoolExecutor.CallerRunsPolicy() );

这个例子里的具体数值以你们压测结果为准,但原则是通用的:在线程数爆发之前,让流量先在队列处排队,排不下了再触发拒绝策略形成背压,而不是无限开线程。

另外一个容易被忽略的治理项是线程统一命名。业务线程池创建时一定要加上ThreadFactory,给线程起有业务含义的名字:

ThreadFactory namedThreadFactory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "member-integral-worker-" + seq.getAndIncrement()); t.setDaemon(false); return t; } };

这样出现线程异常时,jstack里一眼就能看出是哪个业务线程在捣乱。默认的pool-1-thread-1这种名字在大型系统里毫无区分度。

4.3 基础设施改造:系统参数与容器配置的最终防线

当代码侧已经做到“线程可控”,还要确保基础设施层别成为压垮骆驼的最后一根稻草。重点检查并调整这几个系统参数:

# 临时生效(重启后失效) sysctl -w vm.max_map_count=262144 sysctl -w kernel.threads-max=1024000 sysctl -w kernel.pid_max=4194304

如果要永久生效,在/etc/sysctl.conf里追加:

vm.max_map_count=262144 kernel.threads-max=1024000 kernel.pid_max=4194304

执行sysctl -p使其生效。

改动这些参数前记得先确认内核版本和安全策略,比如降低vm.max_map_count能缓解内核内存碎片问题,改大了在某些发行版上可能小幅增加内存占用——但这些代价相比线程数触顶导致的业务故障,往往是可以接受的。

对于容器化平台,需要在部署平台侧配置Pids限制和内存限制:

resources: limits: memory: "4Gi" pids: "1024"

Kubernetes里pids: 1024就是限制这个Pod内所有进程/线程总数。按经验,Java应用每个Pod的PIDs限制建议设为预估线程峰值的1.5~2倍,太小会频繁触顶,太大则失去限制意义。

5. 防患未然:线程治理的制度化经验与后续演进

5.1 建立线程数与成功率的关联监控

经历过一次事故后,我最大的体会是:线程数暴跌或暴涨往往不是瞬时发生的,它一定有趋势可循。如果没有监控,只能在业务打盹的那一刻被动发现。

建议在指标监控平台上添加以下内容:

  • JVM线程数曲线(java.lang.threads.live)
  • 线程池活跃线程数(通过ThreadPoolExecutor的getActiveCount()暴露为JMX指标)
  • 线程池队列深度(getQueue().size())
  • 拒绝策略触发的次数
  • 系统级线程数(node_native_threads)

可以引入现成的Java监控库,给业务线程池注册指标。只要在创建线程池时做一个小封装:

@Bean public ThreadPoolExecutor memberWorkerPool(MeterRegistry registry) { ThreadPoolExecutor workerPool = new ThreadPoolExecutor(...); Gauge.builder("member.worker.pool.active", workerPool, ThreadPoolExecutor::getActiveCount) .description("member worker active count") .register(registry); Gauge.builder("member.worker.pool.queue.size", workerPool, pool -> pool.getQueue().size()) .description("member worker queue size") .register(registry); return workerPool; }

有了这些曲线,每次发版后如果线程数缓慢爬升,就能提前发现,而不是等它撑到天花板。

5.2 压测三板斧:没有预演就永远不知道自己能扛多少

线程问题最怕“平时看着没事,一到峰值就崩溃”。所以每个核心服务在发版前都得做一轮系统化的压测:

  1. 跑满线程池:打流量打到线程池活跃线程数稳定在maximumPoolSize,观察队列是否有积压,确认这个水位下各接口的RT和错误率
  2. 耗尽系统线程配额:用脚本持续发起高并发请求,直到Threads字段、系统线程数接近上限值,记录极端崩溃时的表现
  3. 恢复演练:压测结束后手动摘掉实例、重启服务,验证集群其他节点的支撑能力

实战中我们做第2步时发现了一个隐蔽问题:某一台测试机的vm.max_map_count是默认的65530,线程数到5万时就不再增长,但Linux系统日志里没有明显报错,只有JVM日志在输出unable to create new native thread。如果不做这种极端压测,根本发现不了生产环境其实有一层隐形玻璃天花板。

5.3 代码审查清单:七条线程安全底线

在团队内部推行代码Review时,我习惯把下面几条整理成检查清单,命中任何一条都必须打回修改:

  • 是否直接new Thread()?如果是,改写为线程池
  • 线程池的队列是否无界?无界队列必须加容量限制
  • maximumPoolSize是否能被业务峰值触发?触发后的拒绝策略是否明确?
  • 线程名是否可识别?能否在jstack中快速定位业务归属?
  • 是否存在同步等待下游调用的链路?是否可能用回调或异步替代?
  • 定时任务是否用了框架自带的单线程调度器?高峰期任务是否会排队积压?
  • 是否有内部Thread.sleep()配合循环重试的操作?

我发现最后一条特别容易踩坑。有些开发为应对下游偶发超时,会在while循环里Thread.sleep(1000)重试,如果并发量高,这些线程会全部堆积在sleep上,白白占据线程水位。建议把这种循环重试改成退避策略+线程数受限的异步重试队列。

5.4 从“出事救火”到“容量规划”的一点体会

经历过这次线程事故之后,我们团队把线程治理从“遇到Bug修Bug”提升到了容量规划的层面。每次设计新的业务系统,都会先估算核心链路的最大并发线程数,再倒推应该准备的节点数、线程池参数、系统参数和容器Pids限制。

具体操作上,我们会在设计阶段就建一张“线程容量台账”:

业务模块预估峰值请求数单请求耗时所需活跃线程数线程池上限队列容量容器Pids限制
会员积分变更5000/s80ms4006008001500
积分对账批处理200任务2s4005003001200
外部通知推送3000/s150ms45070010001800

这张表的价值在于:它让线程数从“模糊的担忧”变成了“确定的预算”。每次新需求提交时,只需要对照表里现有的预算看是否超出负荷,就能提前发现风险。

到了这一步,unable to create new native thread这个错误基本就从我们的监控告警里绝迹了。

最后分享一条最实在的排错心得:遇到这个错误,顺序永远应该是“先看系统限制→再看线程状态→最后才怀疑内存”。很多人都栽在顺序上,一看到OutOfMemoryError就去加-Xmx,结果线程问题依旧,GC开销反而更大。先跑一遍ulimit -u确认软墙在哪,再打开线程dump看线程从哪里来,当你能回答“线程到底在干什么”这个问题时,离真正修复就不远了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 1:32:43

LTE测试入门:TM500 TMA用户手册核心要点与自动化实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:32:34

Agent沙箱技术选型与落地:从进程隔离到云原生安全基建

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:31:58

Java iText7实战:生成带中文字体与Excel样式的水印PDF表格

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:31:57

电阻抗层析成像EIT/ECT从仿真到重建的完整技术链路与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:30:58

AI对话模板:面向落地的结构化人机协作协议

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华