Java应用服务器线程数过多怎么办?线程池、阻塞与资源竞争排查

一台 Java 应用服务器,排查时用 ps -eLf | wc -l 一数,线程数居然上万了,随即出现响应变慢、CPU 上下跳动、内存占用居高不下。很多 Java 应用跑着跑着线程就"膨胀"了:线程池配置不合理、任务里又嵌套提交任务、外部依赖超时导致线程堆积,最后线程数失控把机器拖垮。

线程不是越多越好。每个线程要占内存(默认栈大小在 1MB 量级,经验值)、要做上下文切换、要抢锁和 CPU。线程数超过合理范围后,性能不升反降,这就是"线程过多"的典型表现。这篇文章从排查入手,讲清楚 Java 线程数异常的常见根因、用哪些工具定位、以及线程池应该怎么配才合理。

适合读者:做 Java 后端运维或开发、遇到过线程暴涨、或者想系统理解线程池配置的人。下面先讲怎么快速看清线程全貌,再讲根因分类,最后落到线程池参数设计的实践。

一、先量出来:Java 线程数怎么看

排查第一步是确认线程到底有多少、都是些什么线程。

# 看进程的总线程数(LWP 数)
ps -o nlwp,pid,comm -p 
# NLWP 就是线程数(number of light weight processes)

# 统计系统里所有 Java 进程的线程总数
ps -eLf | grep java | wc -l

# 单进程线程上限(max threads)受几方面限制,先看进程级限制
cat /proc//limits | grep -i "processes"
# 线程本质是轻量级进程,受 nproc 和 ulimit -u 限制
ulimit -u

进到 JVM 内部看线程详情,最直接的是 jstack:

# jstack 打印所有线程栈(需要和目标进程相同的 JDK)
jstack  > /tmp/threaddump.txt
# 统计各状态线程数量
grep -c "java.lang.Thread.State" /tmp/threaddump.txt

# 按状态分组统计
grep "java.lang.Thread.State" /tmp/threaddump.txt | sort | uniq -c
# 输出示例:
#   320 java.lang.Thread.State: RUNNABLE
#  1200 java.lang.Thread.State: WAITING (parking)
#   850 java.lang.Thread.State: TIMED_WAITING (sleeping)
#    40 java.lang.Thread.State: BLOCKED (on object monitor)

jstack 的输出里,每个线程有名字("pool-1-thread-1" 这种)、状态、调用栈。线程名是关键线索:名字里带 pool-N-thread-M 说明来自某个线程池,数一下每个池的规模就能看出是哪个池在膨胀。

除了 jstack,还有更友好的工具:

# jcmd 可以一次性拿到线程数统计
jcmd  Thread.print | grep -c "^\""      # 统计线程数量
jcmd  Thread.print > /tmp/dump.txt

# 用 jstat 看线程相关(GC 部分)不是线程,但能辅助判断 JVM 压力
jstat -gcutil  1000 5

二、线程状态与它揭示的问题

Java 线程状态分六种,排查时重点关注这几种:

状态 含义 数量多说明什么
RUNNABLE 正在运行或就绪 正常,但要警惕是否在忙等
BLOCKED 等待进入 synchronized 临界区 锁竞争严重,被其他线程占着锁
WAITING 无限期等待(如 wait()、park()) 常在等锁、等任务、等条件
TIMED_WAITING 有超时的等待(如 sleep、wait(n)) 常见于线程池空闲线程、定时任务
NEW 刚创建未启动 通常短暂
TERMINATED 已结束 通常短暂

一个健康的应用,绝大多数线程应该是 TIMED_WAITING(线程池里空闲待命的线程)或 WAITING(等待任务)。如果出现大量 BLOCKED,说明锁竞争严重;如果出现大量 RUNNABLE 且 CPU 很高,说明在做计算或有忙等。

需要特别注意的是:RUNNABLE 不等于占用 CPU。一个线程处于 RUNNABLE 可能是在等网络 IO(Java 的阻塞式 socket 读在 JVM 看来也是 RUNNABLE),也可能是在真正执行。要区分得结合 top -H 和线程栈。

# 找出占 CPU 最高的线程(-H 显示线程)
top -Hp 
# 记下高 CPU 的线程 ID(TID)

# 把十进制 TID 转成十六进制,去 jstack 里找对应 nid
printf "%x\n" 
# 例如 TID 12345 转成 3039,在 jstack 里找 nid=0x3039 的行
grep -A 20 "nid=0x3039" /tmp/threaddump.txt

这套"top -H + printf + jstack"的方法,是定位"哪个线程在烧 CPU"的标准动作。

三、线程数过多的常见根因

根因一:线程池核心参数配得离谱。
比如把核心线程数设成几千、队列容量设成 Integer.MAX_VALUE、或者用了 Executors.newCachedThreadPool()。缓存线程池在任务堆积时会无限创建线程,是最容易导致线程暴涨的写法。生产环境建议手动 new ThreadPoolExecutor,明确核心数、最大数、队列和拒绝策略。

根因二:任务里嵌套提交任务,形成死锁式堆积。
一个线程池里的任务又往同一个池提交子任务并等待结果。如果池被父任务占满,子任务永远排不上,父任务一直等,线程只增不减。这类问题的特征是有任务在 WAITING 且栈里能看到 Future.get()、CountDownLatch.await()。

根因三:外部依赖没有超时,线程被长时间占住。
调用下游 HTTP、数据库、第三方接口时没设 timeout,下游一慢,线程就无限等,池很快被占满,新请求只能排队或创建新线程,线程数飙升。这是生产里最常见的"线程雪崩"来源。所有外部调用都必须显式设置连接超时和读超时。

根因四:定时任务重复创建线程。
用了 new Thread()、new Timer() 却没复用,或者定时器每次都新建线程池。长跑之后线程越堆越多。

根因五:线程泄漏(ThreadLocal 或未关闭的资源)。
自定义线程或线程池没被正确 shutdown,或者 ThreadLocal 里的对象引用了大对象不放,都会导致线程和内存无法回收。

根因 典型特征 定位手段 修复方向
池参数过大/缓存池 线程多且多在空闲 看线程名统计池规模 改用手工线程池,限核心数
任务嵌套提交 线程多且多 WAITING 栈里有 Future.get/await 拆分线程池,避免同池嵌套
外部依赖无超时 某类线程长时间阻塞 栈里停在 socket 读 加超时、熔断、隔离
定时任务重复创建 线程名带 Timer 且持续增长 观察线程数随时间增长 复用线程池
线程泄漏 线程数只增不减 多次 dump 对比线程数 修 close/shutdown 逻辑

四、线程池参数怎么配才合理

这是最常被问的问题。给一套可操作的方法:

// 推荐的显式线程池写法(Java 8+)
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    8,                                  // corePoolSize 核心线程数
    16,                                 // maximumPoolSize 最大线程数
    60L, TimeUnit.SECONDS,              // 空闲线程存活时间
    new ArrayBlockingQueue<>(200),      // 有界队列,务必设上界
    new ThreadFactory() {               // 自定义线程名,便于排查
        private final AtomicInteger n = new AtomicInteger(1);
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r, "biz-pool-" + n.getAndIncrement());
            t.setDaemon(false);
            return t;
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略:由调用线程执行,形成背压
);

核心线程数怎么估:

  • CPU 密集型(计算、序列化、加解密):一般是 CPU 核数加 1 到 2。核数多、任务多的机器取 nproc 或 nproc + 1。
  • IO 密集型(调数据库、调接口、读写文件):可以用"核数 × 目标利用率 ×(1 + 等待时间/计算时间)"来估算。比如 8 核、等待占比 90%、计算占 10%,则大约是 8 × 0.9 × (1 + 9) 级别,但实际不要一次拉满,取几十的量级,配合压测微调。
  • 混合型:拆成不同的池,别用一个池干所有事。

队列一定要有界。无界队列会让任务无限堆积、内存暴涨,而且永远触发不到拒绝策略,掩盖了真实的过载信号。有界队列加 CallerRunsPolicy 能把压力反压到上游,是更健康的做法。

五、排查与治理的实操流程

# 步骤1:确认线程总数和增长趋势
ps -o nlwp,pid,comm -p 
# 隔几分钟再看一次,判断是稳定还是持续增长

# 步骤2:dump 线程栈并统计状态
jstack  > /tmp/dump1.txt
grep "java.lang.Thread.State" /tmp/dump1.txt | sort | uniq -c

# 步骤3:按线程名统计各池规模
grep '^"' /tmp/dump1.txt | sed 's/".*//' | sort | uniq -c | sort -rn | head -20
# 能直观看到哪个池最大

# 步骤4:找高 CPU 线程
top -Hp 
# 拿到 TID 转十六进制后回 jstack 定位

# 步骤5:间隔一段时间再 dump,对比线程数是否增长
jstack  > /tmp/dump2.txt
grep -c "^\"" /tmp/dump1.txt
grep -c "^\"" /tmp/dump2.txt

# 步骤6:看是否有大量线程卡在同一个调用点
grep -A 10 "java.lang.Thread.State" /tmp/dump1.txt | grep "at " | sort | uniq -c | sort -rn | head

如果发现某类线程都卡在 java.net.SocketInputStream.socketRead0,基本可以确定是外部调用没超时;如果都卡在 Unsafe.park 且栈里能看到业务线程池,是池被占满;如果都卡在 Object.wait 且是 BLOCKED,是锁竞争。

治理动作分轻重缓急:能改代码就改(加超时、限线程池、拆池),改不动就先加监控和限流(限制入口 QPS、加熔断),临时止血可以调大池或重启。重启只治标,如果不改根因,过一段时间还会复发。

风险提示:调大 maximumPoolSize 或队列长度只是把爆发点往后推,会掩盖问题、消耗更多内存,不建议作为长期方案。生产上调整线程池参数通常需要改配置并重启应用,重启会中断服务,务必安排在维护窗口并确认能回滚。

六、监控与预防

把线程数纳入日常监控,比出事再查有效得多。关键指标:每秒新建线程数、活跃线程数、队列积压长度、拒绝任务数、线程池饱和度。

# 用 jstat 定期看 GC 与线程(辅助判断 JVM 压力)
jstat -gcutil  1000 3

# JMX 暴露线程池指标(Tomcat、自定义池都可暴露),再用 Prometheus 采集
# 关键 MBean 属性:activeCount、queueSize、completedTaskCount、largestPoolSize

预防要点:所有外部调用设超时、用有界队列、给线程命名、拒绝策略要明确、定期做线程数基线对比(正常情况下线程数应该是稳定的,一旦持续增长就是预警信号)。

常见问题

Q:Java 进程线程数多少算正常?
没有统一标准,取决于业务和线程池配置。一般来说,一个中等规模的 Web 应用几百到一两千线程是常见的。关键是看趋势:稳定就是正常,持续增长就有问题。

Q:jstack 报错 "Unable to open socket file" 怎么办?
通常是权限问题,需要和目标进程相同的用户身份执行,或加 sudo -u 。也可能是 /tmp/hsperfdata_* 目录权限异常,检查该目录所属用户。

Q:为什么加了线程池最大线程数,问题反而更严重?
因为线程不是免费的,线程多了上下文切换、内存、锁竞争都加剧。在 CPU 已经饱和或存在锁竞争的场景下,加线程只会让情况恶化。要先判断瓶颈是 CPU、IO 还是锁。

Q:怎么判断线程是在真算还是假等?
top -Hp 看线程 CPU 占用:占 CPU 的是真算,不占但状态是 RUNNABLE 的多半在等 IO。再结合线程栈看停在哪个调用点即可确认。

Q:用了 newCachedThreadPool() 怎么改?
改成显式的 ThreadPoolExecutor,设置合理的核心数、最大数和有界队列,配上命名线程工厂和像 CallerRunsPolicy 这样的拒绝策略。这是最值得优先整改的一类写法。

总结

Java 线程数过多的本质是"线程被无谓地创建或长时间占用"。排查顺序是:先量线程总数和趋势,再 dump 线程栈按状态分组,用线程名定位是哪个池,最后用 top -H + printf + jstack 找高 CPU 线程。

五大根因:线程池参数不合理(尤其缓存池)、任务嵌套提交、外部依赖无超时、定时任务重复创建、线程泄漏。其中"外部调用没设超时"是生产环境最常见的一条,务必优先整改。

线程池配置要点:CPU 密集取核数附近,IO 密集按等待比例估算并压测微调;队列必须有界;线程要命名;拒绝策略首选带背压的 CallerRunsPolicy。

治理要标本兼治:改代码(加超时、限池、拆池)治本,加监控和限流防复发,重启和调大参数只治标。

把活跃线程数、队列长度、拒绝数纳入监控基线,线程数"只增不减"就是最早的预警信号。