线程都在 RUNNABLE,服务却慢如蜗牛?JVM 线程状态认知陷阱
本文是线上问题实战录系列的第 4 篇
叙事框架:现象 → 排查过程 → 根因 → 修复 → 预防
问题现象
2026 年 6 月 19 日上午 9 点,订单服务 order-service 的告警群突然炸了:
- 接口超时:
/api/v1/orders/enrichedp99 响应时间从 30ms 飙升到 823ms - 错误率上升:错误率从 0.1% 上升到 2.1%,部分请求 504
- CPU 不高:CPU 使用率只有 25%,远未打满

排查过程
第一步:top —— CPU 不高,但 load average 很高
登录生产服务器,执行 top,结果让人困惑:

CPU 空闲 68%,但 load average 高达 8.9。load average 远高于 CPU 使用率——说明有很多线程处于可运行状态,但因为某种原因没有被调度执行,或者在等待其他资源。
第二步:top -Hp —— 大量线程在 R 状态
top -Hp 17892
输出显示 42 个线程处于 R 状态(在 Linux 中对应 TASK_RUNNING),但每个线程的 CPU 使用率不到 1.2%:

42 个线程都在 R 状态,但几乎不消耗 CPU——这本身就很不正常。
第三步:jstack —— 全都在 SocketInputStream,状态全是 RUNNABLE
jstack 17892 > /tmp/jstack-17892.txt
grep -A 25 'http-nio-8080-exec-97' /tmp/jstack-17892.txt

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

结果一目了然:
| 符号 | 占比 | 含义 |
|---|---|---|
tcp_recvmsg |
42.32% | 内核 TCP 接收数据 |
sock_read |
15.18% | socket 读取 |
57.5% 的 CPU 时间花在内核态的网络收包上——线程根本不是在跑业务逻辑,而是在等远程 socket 响应。
第五步:strace —— 精准确认
sudo strace -f -p 17892 -e trace=network -c

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();

问题出在 HttpClient.newHttpClient() 这个默认构造方法上。
Java 11 的 HttpClient.newHttpClient() 使用的是内置的 SimpleAsyncHttpClient,默认每个路由只有 1 个连接。当 100 个请求同时进来:
- 只有 1 个请求能拿到连接,正常发送
- 其余 99 个请求排队等这个连接释放
- 外部评分服务响应慢(100~300ms),连接被长时间占用
- 队列越来越长,响应越来越慢
更糟糕的是,请求超时设了 30 秒,没有熔断机制——外部服务慢的时候,线程就这样全部挂住。
第二个认知陷阱: 很多人以为 HttpClient.newHttpClient() 是"轻量级"的,但它的默认实现没有连接池,在高并发场景下等于隐形的共享瓶颈。
修复方案
修复方案很明确:
- 自定义线程池:为 HttpClient 配置独立的线程池
- 连接超时:设置 connectTimeout,快速拒绝不可达的服务
- 请求超时:从 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();


同时,对于外部服务调用增加熔断机制(使用 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 -Hp → jstack |
arthas thread -n 3 |
| CPU 不高但服务慢 | perf top |
strace -c、async-profiler |
| 怀疑网络 I/O | perf top(找 tcp_recvmsg) |
sar -n TCP、ss -s |
| 线程阻塞 | jstack |
Arthas thread -b |
3. HttpClient 使用规范
- 禁止使用
HttpClient.newHttpClient()默认构造 - 必须使用
HttpClient.newBuilder().executor(executor)自定义线程池 - 必须设置
connectTimeout(建议 3s) - 请求
timeout不要超过业务容忍时间(建议 5s)
4. 外部调用三件套
任何对外部服务的 HTTP 调用都必须有:
- 连接池 — 避免连接成为共享瓶颈
- 超时控制 — connectTimeout + readTimeout + requestTimeout
- 熔断降级 — 外部服务故障时快速失败,保护自身
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)