资讯动态

CPU 使用率低但接口延迟高?一套完整的 Java 性能排查实战指南

发布时间:2026/9/8 3:00:21 来源:尧图企业网站定制
先来还原一个很典型的线上场景某天下午接口监控报警某核心接口的 TP99 从平时的 50ms 一下涨到 2s 以上局部甚至出现超时。你登录服务器一看CPU 才用了 15%内存也没满磁盘看起来也正常。这时候很多人会困惑CPU 这么空闲为什么接口会慢这么多如果你也遇到过类似问题这篇文章就是为你准备的。本文会从一个完整的排障视角分析 CPU 使用率不高但接口延迟飙升的原因梳理从现象到根因的排查思路并给出每一阶段可以用到的命令、输出解读和解决方向。内容偏实战建议收藏后对照操作。1. 问题现象与常见认知误区1.1 典型现象描述这种问题的常规现象是某个接口或某几个接口的响应时间RT突然上升可能是平时的几倍甚至几十倍。应用没有重启日志里也没有明显异常堆栈。服务器 CPU 整体占用不高15% 左右甚至更低。内存、磁盘空间看起来都充足。没有明显的慢 SQL 或大批量任务在执行。也就是说从最直观的指标看服务器“看起来”很空闲但业务确实变慢了。这种矛盾恰恰说明性能瓶颈不在 CPU 计算能力上而是在某个被阻塞的环节上。1.2 误区CPU 不高 系统没有问题很多刚接触后端排障的同学会把 CPU 使用率当成衡量系统是否健康的“第一指标”。CPU 不高就觉得系统很闲CPU 高就赶紧找 CPU 热点。这个思路在 CPU 密集型场景下没错但在 IO 密集型、锁竞争密集型和外部依赖阻塞型场景下CPU 使用率会严重失真。一个很简单的道理CPU 使用率衡量的是 CPU 被指令占用的比例它不反映线程在“等待什么”。当线程阻塞在锁、数据库连接、远程调用、磁盘 IO 或网络 IO 上时它不消耗 CPU但请求时间依然在增长。所以CPU 15% 不代表系统健康只代表 CPU 暂时不是最忙的资源。真正的问题隐藏在“等待链”上。1.3 排查目标本文要做的不是给你一个万能脚本而是帮你建立一套排障思路通过系统层指标定位瓶颈方向CPU、IO、内存、网络、锁。通过 Java 层工具定位线程状态与调用栈。通过中间件指标定位外部依赖耗时。最后给出针对性的修复方案和预防措施。这套方法也适用于非 Java 技术栈比如 Go、Python核心思路是相通的只是具体的线程转储工具不同。2. CPU 低但接口慢先建立原因全景图2.1 可能原因清单从线上经验来看CPU 使用率不高但接口延迟飙升常见原因可以分成以下几类原因分类典型场景特征锁竞争synchronized、ReentrantLock、分布式锁线程大量处于 BLOCKED 或 WAITING连接池耗尽数据库连接池、HTTP 连接池、Redis 连接池线程等待获取连接线程池耗尽Tomcat 线程池、业务线程池队列堆积任务排队线程数到达上限GC 停顿Full GC 频繁或单次 GC 时间过长CPU 可能不高但应用停顿明显磁盘 IO日志写入、临时文件、云盘性能问题iowait 高或磁盘 util 接近 100%网络问题带宽打满、丢包、跨机房延迟网络重传率高TCP 队列堆积CPU steal容器/虚拟机 CPU 被抢占steal 指标高宿主机超卖外部依赖变慢Redis、数据库、第三方接口变慢调用下游耗时增加内存问题swap 使用、内存回收si/so 指标非零或 swap 占用升高锁表/慢 SQL数据库锁等待、SQL 未走索引数据库侧活跃会话增加这张清单基本覆盖了大多数“CPU 不高但接口慢”的场景。整个排查过程本质上就是逐个排除这些可能性的过程。2.2 几个关键指标要分清在正式排障前有必要把几个容易混淆的指标解释清楚。CPU 使用率CPU 使用率是 CPU 处于非空闲状态的时间占比。它包含用户态us、内核态sy、等待 IOwa、硬中断hi、软中断si等。我们常说的“CPU 15%”通常是总体使用率。Load Average系统负载表示处于可运行状态和不可中断睡眠状态的进程平均数量。它是对“有多少任务在等待 CPU 或 IO”的一种度量。这里有个常见误区Load Average 高不等于 CPU 高。如果 CPU 看着不高但 load 很高通常说明有大量线程在等待 IO 或锁而不是等 CPU。上下文切换CPU 在多个线程之间切换的过程。每秒上下文切换次数过高比如超过几万甚至几十万说明系统里线程频繁切换通常伴随锁竞争或线程数过多。iowaitCPU 等待磁盘 IO 完成的时间占比。如果 wa 偏高比如超过 30%说明磁盘 IO 可能成为瓶颈。steal在虚拟机或容器环境中hypervisor 抢占物理 CPU 的时间占比。如果 steal 长期偏高说明宿主机的 CPU 资源超卖严重你的应用即使看着空闲实际运行也会变慢。2.3 为什么 CPU 不高请求依然会慢为了更直观地理解可以打个比方接口处理一个请求就像开车送一份文件。CPU 使用率相当于“发动机的转速”。发动机转速低不代表车跑得快。如果路上被堵住了锁竞争或者送货地址写错了来回绕外部依赖慢或者路被封了只能等连接池耗尽发动机转速再低文件也送不到。在技术层面线程处于阻塞状态时大致有几种情况BLOCKED等待进入 synchronized 同步块或方法。WAITING调用 wait、join、park 等方法后等待被唤醒。TIMED_WAITING带超时时间的等待例如 sleep、wait(timeout)。这三种状态都有一个共同点线程不占用 CPU也不会让 CPU 使用率上升但请求就是完不成。这正好解释了为什么 CPU 才 15%接口延迟却飙升。3. 环境准备与排障工具清单3.1 环境说明本文的命令示例以 Linux 系统为主JDK 8 环境应用以 Java/Spring Boot 为例。不同的发行版和 JDK 版本输出的字段会有细微差异但核心指标含义基本一致。版本需要根据你的项目实际情况调整。如果你使用的是容器环境Docker、Kubernetes还需要额外关注容器内看到的指标和宿主机指标的差异。建议准备一个测试环境或者用演练环境复现问题不要直接在生产环境反复执行有风险的操作。所有诊断类命令大多是只读的一般不会影响业务但像 jstack 这种命令在生产环境高频率执行会占用一定资源最好按需执行。3.2 常用工具速查下面这张表列出排障过程会用到的核心工具工具用途典型命令top查看系统负载、CPU、内存、进程top -cvmstat查看系统整体运行状态vmstat 1 5pidstat按进程/线程查看 CPU、IO 指标pidstat -p PID 1sar历史性能数据采集sar -u -r -w 1 5mpstat查看每个 CPU 核心使用率mpstat -P ALL 1jstack打印 Java 线程堆栈jstack PIDjstat查看 JVM GC 情况jstat -gcutil PID 1000jmap查看堆内存信息慎用jmap -heap PIDlsof查看进程打开的文件与端口lsof -p PIDss / netstat查看网络连接状态ss -antstrace跟踪系统调用谨慎strace -p PIDdstat综合性能工具dstat -tcdrmyn 1arthasJava 在线诊断工具dashboard / thread工具不需要全部掌握但至少 top、vmstat、jstack、jstat 这四样要熟练。实际排障中90% 的问题靠它们就能定位。3.3 先跑一条综合命令登录服务器后不要急着抓线程栈先看系统整体情况。推荐先跑top -c然后再跑vmstat 1 5两次输出的信息组合起来基本能判断瓶颈是在 CPU、内存、磁盘 IO 还是上下文切换上。后面会详细讲怎么看输出。4. 完整排障流程从现象到根因4.1 第一步确认 CPU 使用率的数据是否完整首先确认“CPU 才 15%”到底是谁的 15%。有两种情况容易造成误判看的是整个宿主机的 CPU而不是容器分配的 CPU 限额。看的是单核百分比而不是整体平均值。在容器环境中top默认显示的是容器视角的 CPU 使用率但有时因为共享内核看到的字段可能是宿主机层面的整体负载。建议先确认当前进程可以使用的 CPU 核心数nproc再配合下面的命令查看进程的 CPU 占用top -c -p PID如果nproc返回 4但进程实际只能使用 2 个核心的额度那 CPU 使用率 15% 的含义就和物理机完全不同了。在容器和虚拟化场景下先确认配额再谈使用率。同时还要看 CPU 是否被绑核、是否有 cgroup cpu 限制cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us cat /sys/fs/cgroup/cpu/cpu.cfs_period_us如果 quota/period 的比例小于 nproc说明 CPU 被限流了。这种情况下即使 CPU 使用率不高应用也可能因为限流而变慢特别是瞬间流量上来的时候。4.2 第二步看系统负载和运行队列确认完 CPU 使用率本身没问题后下一步看系统负载。uptime输出类似14:23:45 up 21 days, 3:12, 1 user, load average: 8.36, 6.21, 4.05虽然 CPU 只有 15%但如果 1 分钟负载达到 8.36说明系统里有大量任务处于等待状态。再结合vmstat看细节vmstat 1 5重点关注以下字段r运行队列中的进程数。如果长期大于 CPU 核数说明 CPU 不够用。但如果 CPU 使用率又很低往往意味着有大量线程处于不可中断睡眠。b不可中断睡眠的进程数通常和 IO 等待有关。waCPU 等待 IO 的时间比例。如果持续高于 30%优先排查磁盘。cs每秒上下文切换次数。如果非常高比如 10 万以上排查锁竞争和线程数。si/soswap 换入换出。非 0 说明内存可能不足。举一个典型输出procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 12 1 0 128456 2097152 4194304 0 0 0 0 12345 67890 5 10 70 15 0这里最有价值的信号是cs每秒 67890 次r为 12wa为 15%CPU 使用率极低。这说明系统大量的时间花在上下文切换和等待上。此时应该继续往下查确认是锁竞争还是 IO 导致的。4.3 第三步定位线程状态与锁竞争系统级指标只能告诉我们“可能有问题”不能告诉我们“哪个线程有问题”。这一步需要进入应用内部。先用top -H查看进程内线程的 CPU 占用情况top -H -p PID注意记录下 CPU 占用最高的几个线程的 TID线程 ID然后将其转换为十六进制printf %x\n TID接着用 jstack 抓取线程栈jstack PID /tmp/thread_dump_$(date %s).txt然后根据第一步转换出的十六进制线程号在线程栈里搜索线程的 nidgrep -A 20 nid0x /tmp/thread_dump_xxx.txt在输出里重点看线程状态java.lang.Thread.State: BLOCKED线程正在等待锁。java.lang.Thread.State: WAITING线程在等待被唤醒。RUNNABLE且栈顶在网络或 IO 相关方法上可能在等待网络读取或磁盘 IO。常见输出示例http-nio-8080-exec-37 #57 daemon prio5 os_prio0 tid0x00007f nid0x2a1d waiting for monitor entry [0x00007f] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.service.OrderService.deductStock(OrderService.java:85) - waiting to lock 0x000000076b3a4d20 (a java.lang.Object) at com.example.service.OrderService.createOrder(OrderService.java:40) at com.example.controller.OrderController.create(OrderController.java:22)这个输出明确告诉我们大量线程阻塞在deductStock方法的锁上。接下来需要看谁持有这把锁。再抓几次线程栈如果同一把锁的地址反复出现且持有锁的线程长期不释放基本可以确认锁竞争严重。常见原因包括锁粒度太大整个方法加了 synchronized。多个线程同时操作同一个共享对象。分布式锁的 Redis 操作过慢。同一个锁内嵌了远程调用或数据库操作。4.4 第四步分析 GC 停顿如果锁竞争不是主要原因下一步排查 JVM 的 GC 情况。GC 停顿的特点是GC 期间所有业务线程都可能被暂停但 GC 线程本身消耗的 CPU 可能并不高特别是 Parallel GC 和 CMS 在某些阶段。用 jstat 查看 GC 情况jstat -gcutil PID 1000 10输出示例S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 50.00 82.14 75.36 92.15 88.22 12845 45.23 12 180.45 225.68重点关注FGCFull GC 次数。如果短时间内快速增长说明老年代一直在满。FGCTFull GC 总耗时。180 秒的总耗时意味着平均每次 Full GC 15 秒这在线上是灾难性的。O老年代使用率。如果长期在 90% 以上内存压力很大。GC 停顿导致接口延迟飙升的典型现象是接口耗时分布出现明显的周期性尖刺或者 TP99 远高于 TP50。如果确认 GC 异常需要结合堆转储分析这一步可以借助 jmap 生成堆 dumpjmap -dump:live,formatb,file/tmp/heap.hprof PID注意jmap -dump在执行时会对 JVM 产生影响生产环境需要谨慎操作最好在低峰期或者直接使用 Arthas 的 heapdump 功能。4.5 第五步排查磁盘 IOGC 和锁都查完之后还需要确认磁盘 IO 的情况。业务日志、临时文件、数据库落盘等场景都可能拖慢接口。用 iostat 查看磁盘状态iostat -x 1 5重点看%util磁盘繁忙程度。接近 100% 说明磁盘一直有 IO 请求在处理。awaitIO 请求平均处理时间。如果明显偏高数百毫秒说明磁盘性能差或 IO 队列积压。w_await/r_await写和读的等待时间。如果发现磁盘 IO 成为瓶颈再结合 lsof 或 strace 定位是哪些文件被频繁读写lsof -p PID | grep -E log|tmp常见原因包括日志框架同步刷盘。临时文件反复创建、删除。云盘性能下降。数据库 binlog 或 redo log 刷盘策略过于激进。4.6 第六步检查网络连接和连接池如果系统内部没问题对外部依赖的排查就成了重点。先看基础网络状态通过 ss 查看 TCP 连接ss -ant重点关注SYN-SENT、TIME-WAIT、CLOSE-WAIT状态的连接数。如果CLOSE-WAIT大量堆积说明应用没有正确关闭连接可能导致连接池被占满。再看连接池。以 HTTP 连接池Apache HttpClient / OkHttp为例常见参数有最大连接数每个路由最大连接数连接空闲时间获取连接超时时间如果连接池耗尽日志里通常会出现类似“Connection pool timeout”或“Timeout waiting for connection”的报错。数据库连接池也一样。以 HikariCP 为例日志中如果出现Connection is not available, request timed out after 30000ms说明线程在等待数据库连接真正执行 SQL 的时间反而很短。这个时候数据库侧也许慢 SQL 不多但应用侧的请求全部卡在获取连接这一环。4.7 第七步检查 CPU steal 与宿主机情况这一步对容器和虚拟机环境尤为重要。如果前面几步都排查完没有发现明确瓶颈但接口依然很慢建议用 mpstat 或 vmstat 看一下 steal 指标mpstat -P ALL 1输出示例Linux 4.18.0-305.el8.x86_64 (host-001) 2025-01-15 _x86_64_ (4 CPU) 14:30:01 CPU %usr %nice %sys %iowait %irq %soft %steal %guest %gnice %idle 14:30:02 all 5.00 0.00 2.00 1.00 0.00 0.00 22.00 0.00 0.00 70.00 14:30:02 0 4.00 0.00 2.00 0.00 0.00 0.00 20.00 0.00 0.00 74.00注意看%steal。如果持续超过 10%就说明宿主机 CPU 资源超卖严重你的容器在等待物理 CPU 调度。面对这种情况应用自身无法彻底解决只能考虑扩容实例数。迁移到资源更充足的节点。向容器平台反馈宿主机资源竞争问题。这一环节容易被人忽略因为大部分监控面板不会显示 steal 指标。如果你的服务部署在云服务器或 Kubernetes 集群中建议把 steal 采集纳入监控范围。5. 模拟案例一次典型的锁等待排障为了把上面的排查思路串起来这里用一个模拟案例演示完整过程。假设环境为 Spring Boot MySQL接口为订单创建接口。5.1 现象订单创建接口 TP99 从 80ms 涨到 1800ms。服务器 CPU 使用率 15%。应用没有报错请求也没有全部失败只是非常慢。5.2 排障过程执行系统级命令top -c vmstat 1 5vmstat 输出显示cs非常高每秒约 5 万次上下文切换r为 8wa很低。初步判断不是磁盘 IO重点怀疑线程竞争。接下来打印线程堆栈jstack 12345 /tmp/jstack_$(date %s).txt连续采集 3 次每次间隔 5 秒for i in 1 2 3; do jstack 12345 /tmp/jstack_$i.txt; sleep 5; done统计线程状态grep java.lang.Thread.State /tmp/jstack_1.txt | sort | uniq -c输出45 java.lang.Thread.State: BLOCKED 12 java.lang.Thread.State: RUNNABLE 8 java.lang.Thread.State: WAITING45 个线程处于 BLOCKED 状态明显异常。继续查看阻塞在哪里grep -B 2 -A 10 BLOCKED /tmp/jstack_1.txt | head -80发现大量线程阻塞在同一个锁地址- waiting to lock 0x000000076b3a4d20 (a java.lang.Object) at com.example.service.StockService.deduct(StockService.java:58)继续找谁持有锁grep -A 10 locked 0x000000076b3a4d20 /tmp/jstack_1.txt发现一个线程持有锁并且在这个锁内执行了数据库更新操作- locked 0x000000076b3a4d20 (a java.lang.Object) at com.example.service.StockService.deduct(StockService.java:60) at com.example.dao.StockMapper.updateStock(StockMapper.java:23)5.3 根因分析与修复问题根源浮现deduct方法在锁内执行数据库更新锁的持有时间被拉长。高并发下其他线程全部阻塞在锁上。核心代码类似// 修复前的写法 public synchronized void deduct(Long skuId, Integer count) { // 数据库操作在锁内执行 stockMapper.updateStock(skuId, count); // 可能还有远程调用或者复杂计算 remoteService.notifyStockChange(skuId); }这个写法的缺点是锁粒度太大把数据库操作和远程调用都包进去了。修复思路是缩小锁范围将对共享内存的修改和数据库操作分开避免在锁内执行耗时操作。// 修复思路缩小锁粒度将耗时操作移出锁 public void deduct(Long skuId, Integer count) { // 1. 锁内只做内存校验和状态标记 synchronized (this) { if (!stockCache.containsKey(skuId)) { stockCache.put(skuId, loadStock(skuId)); } } // 2. 锁外执行数据库更新 int rows stockMapper.updateStock(skuId, count); if (rows 0) { throw new BusinessException(库存不足); } // 3. 异步通知外部不要阻塞主流程 asyncExecutor.execute(() - remoteService.notifyStockChange(skuId)); }不同业务对库存准确性的要求不同这里给出的是一种通用设计思路实际落地需要结合业务约束调整。核心原则是锁内代码要快耗时操作必须移出锁。5.4 修复后验证修复后重启应用重新压测订单接口观察指标TP99 回落到 100ms 以内。上下文切换次数从每秒 5 万次降到 1 万次以下。BLOCKED 线程数明显减少。再用 vmstat 和 jstack 复查确认问题消失。6. 常见问题与排查对照表为了方便以后快速定位这里整理一张对照表。实际排障时可以按表格逐项排查。问题现象可能原因核心排查命令解决思路CPU 低、load 高、cs 高锁竞争或线程阻塞vmstat、jstack缩小锁粒度减少共享竞争接口耗时周期性尖刺GC 停顿jstat -gcutil优化堆参数排查内存泄漏iowait 高、磁盘 util 100%磁盘 IO 瓶颈iostat -x优化日志写入迁移高性能磁盘日志出现连接池超时连接池耗尽查看异常堆栈调整连接池参数排查连接泄漏steal 指标持续偏高宿主机 CPU 超卖mpstat -P ALL迁移实例扩容节点容器 CPU 限流cgroup 配额不足cat /sys/fs/cgroup/cpu/调整 CPU limit大量 CLOSE-WAIT连接未释放ss -ant修复代码中的连接关闭逻辑线程数暴涨线程池参数不合理jstack、top -H调整线程池核心/最大线程数6.1 排查建议按这个顺序走遇到“CPU 不高但接口慢”的问题建议按以下顺序操作先看整体top、vmstat、uptime。再确认容器和 CPU 配额nproc、cgroup。抓线程堆栈jstack 连续抓 3 次。查 GCjstat -gcutil。查 IOiostat -x。查网络和连接池ss -ant、应用日志。查 stealmpstat -P ALL。如果过程中发现某一项指标异常就顺着那条线深挖不用把每一步都走完。7. 最佳实践与工程建议7.1 监控必须多维不要只盯 CPUCPU 使用率只是众多指标中的一个建议重点采集以下指标CPU 使用率、load average、上下文切换。磁盘 IO 的 util、await、iowait。网络连接数和重传率。JVM 的 GC 次数、GC 耗时、线程状态分布。应用线程池的活跃线程数、队列长度、拒绝次数。数据库连接池的活跃连接数、等待获取连接的时间。只有多维监控才能在问题发生时快速缩小范围而不是登录服务器后大海捞针。7.2 锁和同步代码要定期评审以下代码模式是接口延迟飙升的高危雷区在 synchronized 方法内执行数据库操作。在锁内调用远程接口。用分布式锁包裹大事务。用全局锁保护非共享数据。建议在代码评审时明确约定锁内只能执行纯内存操作耗时操作一律移到锁外。如果确实需要在锁内执行数据库操作必须评估锁的持有时间。7.3 线程池和连接池参数要压测验证不要照搬网上的参数。线程池大小、连接池大小需要结合业务场景压测后确定。一个常见的经验是IO 密集型任务的线程数可以设置得大一些CPU 密集型任务的线程数接近 CPU 核数即可。但这只是起点最终的参数要以压测结果为准。连接池方面要关注的不只是“最大连接数”还有“获取连接超时时间”“连接最大空闲时间”等参数。日志中的连接池超时往往不是简单调大连接数就能解决的还要排查是否有连接泄漏。7.4 容器环境要单独关注 steal 和 CPU 限流如果你使用 Docker 或 Kubernetes建议把以下几点纳入日常检查确定容器 CPU 配额与实际请求量是否匹配。监控%steal指标判断宿主机是否超卖。容器内nproc看到的核数与配额的关系。频繁的 CPU 限流会导致线程调度延迟进而表现为接口变慢、CPU 却不高的假象。7.5 建立排障工具集和 SOP线上的问题往往发生得很突然现场能用的工具越多定位越快。建议做两件事第一把常用诊断命令封装成脚本放到跳板机或运维平台上。例如一键采集线程堆栈、GC 信息、系统负载。第二把这次排障的经验沉淀为团队的 SOP 文档。下次遇到类似问题团队可以按文档快速执行不用每个人从头摸索。7.6 不要忽视外部依赖的链路追踪很多接口变慢根因不在应用自身而在下游。生产环境强烈建议接入链路追踪工具如 SkyWalking、Zipkin、OpenTelemetry把接口耗时拆解为各个 Span 的耗时。这样可以快速判断慢在哪个远程调用、哪个数据库操作、哪个缓存操作。如果没有链路追踪就只能靠逐层打印耗时日志去排查效率会低很多。8. 总结接口延迟飙升、CPU 却只有 15%表面看是矛盾的现象实际上指向的是系统中被阻塞的等待链路。CPU 使用率只能反映计算资源的使用情况无法反映锁等待、连接池耗尽、IO 阻塞、GC 停顿等问题。排障的关键是先分清瓶颈维度再用系统命令和 JVM 工具逐步逼近根因。建议把本文中的工具清单和排查顺序保存下来下次线上遇到类似问题按步骤执行一遍大多数情况下都能快速定位。如果你最近也被这类问题困扰不妨先从 jstack 和 vmstat 入手看看你的系统里到底有多少线程在等待锁、等待连接、等待 IO。很多时候答案就藏在这些等待里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价