2026/10/24 1:33:06

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

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调用线程BB又同步等待C一层一层阻塞下去线程全卡在等待上新的任务只能再开新线程第三方SDK内部线程池设计不当某个OCR识别SDK对每张图片都创建一个独立线程压测时直接打爆系统定时任务线程失控Spring的Scheduled默认线程池是单线程的如果任务执行时间太长会不断堆积调度有些框架的调度线程池是可配置的有人图省事直接调到几千上面这些场景有一个共同特征线程只增不减或者增长速度快于销毁速度。搞清楚这一点排查方向就很明确了。2. 追根溯源一条Java代码如何走向原生线程的绝路2.1 从Java线程到操作系统内核的完整链条要理解这个错误必须先明白一个Java线程的一生。你在代码里写new Thread()实际发生的事情远比字面上复杂JVM创建一个Java层的Thread对象JVM内部调用底层平台线程创建接口在Linux上就是pthread_create()pthread_create()触发内核创建task_struct分配栈空间和TCB线程控制块操作系统将此线程调度到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如果代码里线程动辄几万就会撞上这堵墙。算一笔实际账假设一台机器内存16GBJVM堆配置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设成4096JVM堆调得特别大结果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单线程栈1MBvm.max_map_count 65530ulimit -u 10240那么理论极限约10240被ulimit -u卡住。如果业务代码中最坏情况需要1.5万线程那么这个系统无论怎么压测都必挂。这种反推可以在故障发生前就提前预判系统的承载上限而不是等线程真飙上去了才手忙脚乱。4. 分级解决止血、治理与基础设施改造三套组合4.1 临时止血重启与扩容的救急艺术线上故障第一要务是抢救业务请务必按这个顺序操作摘流量把故障实例从负载均衡摘掉避免新请求继续涌入预留现场在重启之前用jstack抓取线程快照保存日志做docker inspect记录容器参数防止重启后现场消失无法复盘重启服务启动前临时调整启动参数下面详谈扩容如果集群有其他实例先横向扩容把流量分摊出去而不是硬扛单机临时调整启动参数时有两个方向可以互相配合# 降低单线程栈大小为更多线程腾出虚拟内存空间 -Xss512k # 适当调低最大线程数避免无限膨胀 -XX:ActiveProcessorCount4-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 LinkedBlockingQueueRunnable(10000) // 无界队列 );这个配置存在三个硬伤maximumPoolSize8000等于允许线程无限膨胀队列容量1万两者叠加任务积压到天上去了拒绝策略用的是默认的AbortPolicy一旦队列满就抛异常抛异常后又触发上游重试重试再次进入队列——恶性循环正确的重构思路是先算合理的最大线程数用“核心并发量 * 平均响应时间 / 1000”公式估算或者直接压测得到服务能稳定承载的最大QPS再反推线程数队列必须有界无界队列是线程风暴的帮凶拒绝策略要选对业务场景优先用CallerRunsPolicy让提交任务的线程自己跑这个任务天然形成背压而不是直接丢弃或抛异常改后的配置长这样ThreadPoolExecutor workerPool new ThreadPoolExecutor( 100, 300, 60L, TimeUnit.SECONDS, new ArrayBlockingQueueRunnable(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_count262144 sysctl -w kernel.threads-max1024000 sysctl -w kernel.pid_max4194304如果要永久生效在/etc/sysctl.conf里追加vm.max_map_count262144 kernel.threads-max1024000 kernel.pid_max4194304执行sysctl -p使其生效。改动这些参数前记得先确认内核版本和安全策略比如降低vm.max_map_count能缓解内核内存碎片问题改大了在某些发行版上可能小幅增加内存占用——但这些代价相比线程数触顶导致的业务故障往往是可以接受的。对于容器化平台需要在部署平台侧配置Pids限制和内存限制resources: limits: memory: 4Gi pids: 1024Kubernetes里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 压测三板斧没有预演就永远不知道自己能扛多少线程问题最怕“平时看着没事一到峰值就崩溃”。所以每个核心服务在发版前都得做一轮系统化的压测跑满线程池打流量打到线程池活跃线程数稳定在maximumPoolSize观察队列是否有积压确认这个水位下各接口的RT和错误率耗尽系统线程配额用脚本持续发起高并发请求直到Threads字段、系统线程数接近上限值记录极端崩溃时的表现恢复演练压测结束后手动摘掉实例、重启服务验证集群其他节点的支撑能力实战中我们做第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看线程从哪里来当你能回答“线程到底在干什么”这个问题时离真正修复就不远了。