线程都在 RUNNABLE,服务却慢如蜗牛?JVM 线程状态认知陷阱

本文是线上问题实战录系列的第 4 篇
叙事框架:现象 → 排查过程 → 根因 → 修复 → 预防


问题现象

2026 年 6 月 19 日上午 9 点,订单服务 order-service 的告警群突然炸了:

  • 接口超时/api/v1/orders/enriched p99 响应时间从 30ms 飙升到 823ms
  • 错误率上升:错误率从 0.1% 上升到 2.1%,部分请求 504
  • CPU 不高:CPU 使用率只有 25%,远未打满

生产告警群讨论

排查过程

第一步:top —— CPU 不高,但 load average 很高

登录生产服务器,执行 top,结果让人困惑:

top 命令输出

CPU 空闲 68%,但 load average 高达 8.9。load average 远高于 CPU 使用率——说明有很多线程处于可运行状态,但因为某种原因没有被调度执行,或者在等待其他资源。

第二步:top -Hp —— 大量线程在 R 状态

top -Hp 17892

输出显示 42 个线程处于 R 状态(在 Linux 中对应 TASK_RUNNING),但每个线程的 CPU 使用率不到 1.2%:

top -Hp 定位线程

42 个线程都在 R 状态,但几乎不消耗 CPU——这本身就很不正常。

第三步:jstack —— 全都在 SocketInputStream,状态全是 RUNNABLE

jstack 17892 > /tmp/jstack-17892.txt
grep -A 25 'http-nio-8080-exec-97' /tmp/jstack-17892.txt

jstack 线程栈

157 个线程的状态都是 RUNNABLE,没有一个 BLOCKED 或 WAITING 的。

更诡异的是——所有线程都卡在 java.net.SocketInputStream.socketRead0(Native Method)。明明是在等网络 I/O,JVM 却告诉你是 RUNNABLE。

这就是第一个认知陷阱:Java 里线程做网络 I/O(Socket.read())时,状态依然是 RUNNABLE,不会变成 BLOCKED 或 WAITING。

这是 HotSpot JVM 的实现细节:SocketInputStream.read() 底层调用的是 interruptible I/O 系统调用,在 JVM 的线程状态模型中,这种状态被映射为 RUNNABLE。也就是说,JVM 的 RUNNABLE 不等于在 CPU 上执行,它包括了在等待网络/磁盘 I/O 完成的情况。

第四步:perf top —— 从内核视角看真相

既然 jstack 给不了答案,切换到 OS 级别的分析工具:

sudo perf top -p 17892

perf top 内核分析

结果一目了然:

符号 占比 含义
tcp_recvmsg 42.32% 内核 TCP 接收数据
sock_read 15.18% socket 读取

57.5% 的 CPU 时间花在内核态的网络收包上——线程根本不是在跑业务逻辑,而是在等远程 socket 响应。

第五步:strace —— 精准确认

sudo strace -f -p 17892 -e trace=network -c

strace 确认

78% 的系统调用时间花在 recvfrom 上——所有线程都在做 socket 读操作。

证据链闭合了: 1. jstack 说线程在 SocketInputStream.socketRead0 → 做网络 I/O 2. perf top 说内核在 tcp_recvmsg → 等 TCP 数据 3. strace 说系统调用在 recvfrom → 读 socket

根因分析

排查到这一步,问题变成了:为什么所有线程都堆积在等一个网络响应上?

查代码发现,昨天上线的新版本引入了一个功能——调用外部评分服务 score-api 来丰富订单数据。实现使用的是 Java 11 的 HttpClient

// OrderServiceV1.java
private final HttpClient client = HttpClient.newHttpClient();

有问题的代码 V1

问题出在 HttpClient.newHttpClient() 这个默认构造方法上。

Java 11 的 HttpClient.newHttpClient() 使用的是内置的 SimpleAsyncHttpClient默认每个路由只有 1 个连接。当 100 个请求同时进来:

  • 只有 1 个请求能拿到连接,正常发送
  • 其余 99 个请求排队等这个连接释放
  • 外部评分服务响应慢(100~300ms),连接被长时间占用
  • 队列越来越长,响应越来越慢

更糟糕的是,请求超时设了 30 秒,没有熔断机制——外部服务慢的时候,线程就这样全部挂住。

第二个认知陷阱: 很多人以为 HttpClient.newHttpClient() 是"轻量级"的,但它的默认实现没有连接池,在高并发场景下等于隐形的共享瓶颈

修复方案

修复方案很明确:

  1. 自定义线程池:为 HttpClient 配置独立的线程池
  2. 连接超时:设置 connectTimeout,快速拒绝不可达的服务
  3. 请求超时:从 30 秒缩短到 5 秒
// OrderServiceV2.java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    4, 8, 30, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(100)
);
this.client = HttpClient.newBuilder()
    .executor(executor)
    .connectTimeout(Duration.ofSeconds(3))
    .build();

修复后的代码 V2

修复前后代码对比

同时,对于外部服务调用增加熔断机制(使用 Resilience4j 或 Sentinel),在外部服务故障时快速失败,避免线程池被拖垮。

验证结果

修复后的效果立竿见影:

修复后确认

指标 修复前 修复后
Load Average 8.9 1.2
CPU 使用率 25% 14%
CPU Idle 68% 84%
RUNNABLE 线程数 157 24
p99 响应时间 823ms 42ms

避坑建议

1. 理解 JVM 线程状态的真实含义

JVM 的线程状态和操作系统线程状态不是一一对应的:

JVM 状态 对应 OS 状态 典型场景
RUNNABLE TASK_RUNNING 或 TASK_INTERRUPTIBLE 执行 CPU 计算 等待网络/磁盘 I/O
BLOCKED TASK_RUNNING 等待进入 synchronized 块
WAITING TASK_RUNNING 或 TASK_INTERRUPTIBLE Object.wait()、LockSupport.park()

RUNNABLE 不意味着在跑代码,它只意味着 JVM 认为这个线程"还能继续工作"。

2. 诊断工具配合使用

场景 首选工具 补充工具
CPU 高 top -Hpjstack arthas thread -n 3
CPU 不高但服务慢 perf top strace -casync-profiler
怀疑网络 I/O perf top(找 tcp_recvmsg) sar -n TCPss -s
线程阻塞 jstack Arthas thread -b

3. HttpClient 使用规范

  • 禁止使用 HttpClient.newHttpClient() 默认构造
  • 必须使用 HttpClient.newBuilder().executor(executor) 自定义线程池
  • 必须设置 connectTimeout(建议 3s)
  • 请求 timeout 不要超过业务容忍时间(建议 5s)

4. 外部调用三件套

任何对外部服务的 HTTP 调用都必须有:

  1. 连接池 — 避免连接成为共享瓶颈
  2. 超时控制 — connectTimeout + readTimeout + requestTimeout
  3. 熔断降级 — 外部服务故障时快速失败,保护自身

5. 代码审查 Checklist 补充

在高并发路径上引入外部网络调用时,代码审查 checklist 中增加:

  • [ ] 是否使用 HttpClient.newBuilder() 而不是 newHttpClient()
  • [ ] 是否配置了连接池和超时
  • [ ] 外部服务故障是否有熔断/降级策略
  • [ ] 是否有 fallback 兜底逻辑

附:完整命令清单

进程与线程级 CPU 排查

top -b -n 1 | head -30                                        # 进程 CPU 排行
top -b -n 1 -Hp <pid> | head -40                              # 线程 CPU 排行
cat /proc/<pid>/status | grep -E '^(Name|Pid|Threads|VmRSS|State)'  # 进程状态概览

线程堆栈分析

jstack <pid> > /tmp/jstack-<pid>.txt                           # dump 线程堆栈
grep 'Thread.State' /tmp/jstack-<pid>.txt | sort | uniq -c | sort -rn  # 线程状态统计
grep -A 30 'http-nio-8080-exec-97' /tmp/jstack-<pid>.txt | head -35    # 查看业务线程堆栈
grep -c 'socketRead0' /tmp/jstack-<pid>.txt                    # 统计 socketRead0 线程数
cat /tmp/jstack-<pid>.txt | grep -B 2 'socketRead0' | grep 'Thread.State' | sort | uniq -c  # 统计特定方法状态

内核级性能分析

sudo perf top -p <pid> --stdio 2>/dev/null                    # CPU 热点分析(内核态)
perf stat -e tcp:tcp_rcv_space_adjust,tcp:tcp_receive_collapsed -p <pid> -- sleep 3  # 内核 tracepoint 统计

系统调用分析

sudo strace -f -p <pid> -e trace=network -c 2>&1              # 网络系统调用耗时统计

网络连接排查

lsof -p <pid> | grep -c ESTABLISHED                           # 统计 ESTABLISHED 连接数
ss -tnp | grep <pid> | awk '{print $4}' | sed 's/.*://' | sort -n | uniq -c | sort -rn | head -5  # 连接端口分布
ss -tnp | grep <pid> | grep score-api | head -3               # 按服务名过滤连接

修复验证

jstack <pid> | grep 'Thread.State' | sort | uniq -c | sort -rn        # 修复后线程状态验证
jstack <pid> | grep -c socketRead0                                     # 修复后排队线程数
curl -w '\n' -o /dev/null -s 'http://localhost:8080/api/v1/orders/enriched/v2?orderId=test001'  # 接口验证

📖 完整版带可复现 Demo 和排查截图:https://opencao.cn
📺 公众号「Ai拆代码的曹操」
🌟 知识星球「源阅会」(82877104)