Java接口响应变慢与Tomcat线程耗尽的排查方法(max-threads、accept-count参数与jstack定位http-nio线程)

接口响应时间突然从几十毫秒涨到十几秒,nginx那边先冒出504,登上机器看CPU不高、内存也正常、数据库连接数不算离谱。这种「资源看着都够、就是慢」的情况,八成卡在Tomcat的请求线程池上。线程被慢请求占住不放,新请求连被处理的机会都没有,只能排队或者超时。

之前遇到过一台跑了三年的订单服务,现象一模一样:压测QPS刚过200就断崖式变慢,横向加机器也没用。最后定位到是一个报表导出接口,单次执行要40秒,占住线程不放。这类问题的排查有固定路数,按现场顺序写一遍。

先确认是不是线程打满

别上来就改参数。先看三个数,能对上基本就是它。

第一个数看Actuator。开了actuator的应用直接读这两个指标,busy应该远小于max,如果busy长期贴着max跑,线程池就是瓶颈:

curl -s localhost:8080/actuator/metrics/tomcat.threads.busy | python -m json.tool
curl -s localhost:8080/actuator/metrics/tomcat.threads.config.max | python -m json.tool

第二个数看access log。Tomcat的访问日志默认不开,Spring Boot里加一行server.tomcat.accesslog.enabled=true就有。重点看有没有某几个URI的耗时明显高于其余,长尾的那几个往往就是占着线程的那几个。实测过一次,单个URI占了八成请求耗时,其它接口全被它拖住。

第三个数看jstack。抓两次,间隔五秒,然后按线程名前缀统计,http-nio-8080-exec-开头的就是请求线程:

jstack -l <pid> > /tmp/d1.txt
sleep 5
jstack -l <pid> > /tmp/d2.txt
grep -c 'http-nio-8080-exec-' /tmp/d1.txt
grep -A 3 'http-nio-8080-exec-' /tmp/d1.txt | grep 'java.lang.Thread.State' | sort | uniq -c

统计出来的条数接近200,说明默认上限已经跑满。再往前一步,看这些线程停在哪一行。两次快照都停在同一个方法上、状态稳定不变的,那就是真凶。

三个上限的数字关系

Tomcat不是「线程满了就拒绝」这么简单,中间夹着三层,很多人的参数调错就是没搞清这三层谁先触发。

最外层是max-connections,默认8192,表示服务端accept进来的socket总数上限,包含正在处理的、排队等待的、还有keep-alive空闲的。中间层是accept-count,默认100,是所有工作线程都忙的时候,操作系统那边还能排多少个连接请求。最内层才是threads.max,默认200,也就是真正干活的请求线程数。请求进来先数连接数有没有到8192,然后看有没有空闲线程,没有就进那100个位置的队列,队列也满了新连接就被丢弃或者等超时。

这里有个容易被误会的点:NIO模式下keep-alive的空闲连接不占请求线程,它挂在轮询线程上等下一个请求。所以「连接数8000、线程数200」不是矛盾,是正常的。真正决定并发处理能力的是最内层那个200。maxKeepAliveRequests默认是100,表示一个长连接上最多跑100个请求就关掉重连。

老版本Tomcat在线程池满的时候会自己打日志,Tomcat 6.0时代是这么一条:

org.apache.tomcat.util.threads.ThreadPool logFull
SEVERE: All threads (200) are currently busy, waiting. Increase maxThreads (200) or check the servlet status

换成Spring Boot内嵌之后,这个ThreadPool类早就不在了,日志里不会出现上面这句,只能靠指标和jstack自己判。这也是为什么很多人线程打满了还不知道。

配置名写错是静默失效

Spring Boot 2.3.0、2.7.18、3.2.0、3.3.0、3.4.0、3.5.0,这几个版本里配置项的写法不一样。2.3.0起server.tomcat.max-threads被弃用,换成server.tomcat.threads.max;min-spare-threads换成threads.min-spare。旧名不是报错,是不生效。

现场见过一次更麻烦的:配置文件里写着server.tomcat.max-threads=20想限流,压测照样能起200个线程,排查了半天才发现属性名早就换了。要确认到底生效没有,启动时看一眼日志里的实际线程名编号,或者直接用Actuator那个config.max指标对一下。

server:
  tomcat:
    threads:
      max: 400
      min-spare: 20
    accept-count: 500
    max-connections: 8192
    connection-timeout: 60s
    keep-alive-timeout: 60s
    max-keep-alive-requests: 100

注意connection-timeout这个值:Tomcat自身的默认值是60秒,含义是accept之后等多久拿到请求行,不是整个请求的总耗时。想限制接口总耗时得在应用侧或者nginx侧做,靠这个参数没用。

线程被谁占住的五种常见情况

1、慢SQL。HikariCP的maximumPoolSize默认10,而请求线程有200个,比例是20比1。SQL一慢,线程全堵在getConnection上,jstack里会看到大量线程停在HikariPool.getConnection。这种情况调大threads.max只会让堵得更整齐,加再多也没用。

2、外部HTTP调用没设超时。Java自带的HttpURLConnection默认不超时,对方服务挂起,这边线程就一直等着。RestTemplate、OkHttp都一样要显式设connect和read两个超时。当初那台订单服务就是这么栽的,一个短信接口挂了,连带整个应用不可用。

3、事务里干重活。@Transactional包着的逻辑只要没提交,数据库连接和行锁都不释放。事务里调外部接口、发消息、做文件导出,都会把持锁时间拉长几十倍。

4、同步块或者行锁竞争。jstack里线程状态是BLOCKED并且waiting to lock后面跟着同一个地址,就是这一类。数量多的时候会看到一长串。

5、大结果集一次读进内存。几百万行fetch进List,GC跟着暴涨,所有线程一起变慢。这种会表现为线程状态RUNNABLE但GC时间占比极高,容易误判成代码慢。

改的顺序

调参数是最省事的一步,也是最容易掩盖问题的一步。正确顺序是先治本再扩容:给外部调用加超时、给慢SQL加索引或者改分页、把长任务改成提交后返回任务号再轮询。这几件事做完,threads.max往往都不用改。

确实要调的时候,IO密集型的应用按CPU核数的2到4倍给threads.max,accept-count给到max的1到2倍。不要直接把threads.max拉到1000,线程栈默认1MB,上下文切换也会跟着涨,机器反而更慢。容器里跑的还要注意CPU是限额的,核数按配额算。

Spring Boot 3.2.0起多了个选项:spring.threads.virtual.enabled=true,开了虚拟线程之后并发不再受threads.max约束,server.tomcat.threads.max这一项按官方文档说明就不起作用了。但要清楚虚拟线程解决的是「线程不够」,解决不了「连接池只有10个」,慢SQL该堵还是堵。

最后把监控补上。Actuator配上Prometheus,看tomcat.threads.busy、tomcat.threads.current、还有HikariCP的pending这三个指标,趋势对上了就能在打满之前收到告警,不用等到用户先发现。

版本方面,Tomcat 9.0.123、10.1.61、11.0.27,这三个都是2026年10月12日发布的,9.0.x系列官方公告的支持截止时间是2027年3月31日,还在用9的话早点规划迁移。数据截至2026年10月。