线上 Java CPU 飙高怎么定位?从 `top`、线程 ID 到 `jstack` 的完整实战

2026-09-14面试7 分钟76 次阅读
AI 摘要鱼粮不足,AI 今天先歇着 · 正文在下面

线上服务 CPU 突然飙高时,第一反应往往是登录机器执行 top。但仅仅找到高 CPU 的 Java 进程还不够,真正关键的是继续定位到:

  1. 哪个 Java 进程占用了 CPU;
  2. 该进程中的哪个线程在持续消耗 CPU;
  3. 该线程对应哪段 Java 代码;
  4. 到底是业务死循环、频繁 GC、锁竞争,还是 native 层问题。

本文以 Linux 生产环境为主,完整演示从 top 到 jstack 的排查链路,并提供一个不会明显影响笔记本的本地模拟程序。


一、先明确:CPU 高不一定是业务代码的问题

Java 服务 CPU 高,常见原因包括:

  • 死循环、递归未退出、错误的重试逻辑;
  • 大量数据计算、排序、JSON 序列化或复杂正则;
  • 高并发请求导致业务计算放大;
  • Full GC 或频繁 Young GC;
  • 线程池配置不当,任务堆积后持续抢占 CPU;
  • 加解密、压缩、网络编解码等计算密集型操作;
  • JNI、第三方 native 库或操作系统层面的消耗。

因此,排查的目标不是“CPU 降下来”,而是找到持续消耗 CPU 的执行路径。


二、完整排查流程

text 复制代码
CPU 告警
  ↓
找到高 CPU 的 Java 进程 PID
  ↓
找到该进程中 CPU 最高的线程 TID
  ↓
把十进制 TID 转为十六进制
  ↓
在 jstack / jcmd 输出中匹配 nid
  ↓
连续抓取多次线程栈,确认热点是否稳定
  ↓
定位代码或 JVM 问题并修复

以下命令默认在 Linux 上执行。


三、第一步:找到 CPU 最高的 Java 进程

使用 top:

bash 复制代码
top -c

-c 会展示完整启动命令,方便确认是不是目标 Java 服务。

也可以使用 ps,更适合复制和脚本化:

bash 复制代码
ps -eo pid,ppid,pcpu,pmem,cmd --sort=-pcpu | head -20

输出示例:

text 复制代码
  PID  PPID %CPU %MEM CMD
12345     1 385.2 12.1 java -jar order-service.jar

假设目标 Java 进程的 PID 是:

bash 复制代码
PID=12345

这里的 385.2% 并不代表异常。多核机器上,一个进程可以同时占用多个 CPU 核,因此 CPU 使用率可能超过 100%。


四、第二步:找到进程内 CPU 最高的线程

执行:

bash 复制代码
top -H -p "$PID"

参数说明:

  • -H:显示线程;
  • -p:只看指定进程。

输出中,线程列表可能类似:

text 复制代码
  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
67890 app       20   0   10.2g   1.5g  30000 R  99.7  10.1   3:20.31 java
67891 app       20   0   10.2g   1.5g  30000 S   0.3  10.1   0:01.22 java

第一列的 67890 就是 CPU 最高线程的 TID。

也可以使用 ps 查看并排序:

bash 复制代码
ps -L -p "$PID" -o pid,tid,pcpu,comm --sort=-pcpu

输出示例:

text 复制代码
  PID   TID %CPU COMMAND
12345 67890 99.7 java
12345 67891  0.3 java

为了后续复用,记录这个线程 ID:

bash 复制代码
TID=67890

五、第三步:为什么要把线程 ID 转为十六进制?

这里是很多排查教程最容易让人困惑的地方。

top -H 或 ps -L 输出的线程 ID 是十进制,例如:

text 复制代码
67890

而 jstack 输出中用于关联操作系统线程的 nid 是十六进制,例如:

text 复制代码
nid=0x10932

因此需要转换:

bash 复制代码
printf '0x%x\n' "$TID"

示例:

bash 复制代码
TID=67890
printf '0x%x\n' "$TID"

输出:

text 复制代码
0x10932

保存为变量:

bash 复制代码
NID=$(printf '0x%x' "$TID")
echo "$NID"

注意:这里匹配的是 nid,不是 Java 中 Thread.currentThread().getId() 返回的线程 ID。Java 线程 ID、Linux 线程 ID 和线程栈中的 nid 并不是同一套编号体系。


六、第四步:导出线程栈并匹配热点线程

传统命令是:

bash 复制代码
jstack -l "$PID" > "/tmp/jstack-$PID.log"

更推荐使用 jcmd:

bash 复制代码
jcmd "$PID" Thread.print -l > "/tmp/jstack-$PID.log"

其中:

  • Thread.print:输出全部线程栈;
  • -l:额外输出锁信息,对排查锁竞争很有帮助。

然后搜索刚才转换出的十六进制 nid:

bash 复制代码
rg -n -C 12 "nid=$NID" "/tmp/jstack-$PID.log"

如果机器没有安装 rg,可以使用 grep:

bash 复制代码
grep -n -A 12 -B 4 "nid=$NID" "/tmp/jstack-$PID.log"

可能看到这样的结果:

text 复制代码
"cpu-hot-demo" #21 prio=5 os_prio=0 cpu=58234.12ms elapsed=60.03s tid=0x00007f... nid=0x10932 runnable
   java.lang.Thread.State: RUNNABLE
        at com.example.OrderService.calculate(OrderService.java:86)
        at com.example.OrderService.handleRequest(OrderService.java:52)
        at com.example.OrderController.query(OrderController.java:31)

重点关注:

  • 线程状态是否为 RUNNABLE;
  • 栈顶方法是什么;
  • 是否停留在自己的业务代码;
  • 是否出现大量重复的递归、循环、序列化、正则、集合计算或 RPC 重试逻辑。

jcmd Thread.print 是 JDK 官方提供的 JVM 诊断命令,线程较多时会有一定开销,因此生产环境不建议高频、无限制执行。 Oracle jcmd 文档


七、不要只抓一次:连续抓 3 次线程栈

单次线程栈只能说明“这一瞬间线程在做什么”,不能证明它就是持续消耗 CPU 的原因。

推荐连续抓取 3 次,每次间隔 5 秒:

bash 复制代码
for i in 1 2 3; do
  printf '\n===== %s =====\n' "$(date '+%F %T')"
  jcmd "$PID" Thread.print -l
  sleep 5
done > "/tmp/thread-dump-$PID-$(date +%s).log"

如果同一个高 CPU 线程,在连续多份线程栈中都停留在同一段代码,例如:

text 复制代码
OrderService.calculate()
OrderService.calculate()
OrderService.calculate()

那么基本可以确认问题就在这段调用链中。

如果每次线程栈都不同,可能是高并发请求导致 CPU 整体升高,而不是某一处代码进入死循环。


八、只查看匹配线程的前 20 行

有时日志很大,只想快速展示某个线程后的前 20 行,可以使用:

bash 复制代码
jcmd "$PID" Thread.print -l |
awk -v nid="$NID" '
  index($0, "nid=" nid) { printing=1; count=0 }
  printing { print; count++ }
  printing && count >= 20 { exit }
'

这里的“20 条”是指线程栈输出的前 20 行。

需要注意,jstack 并不存在“最近 20 条调用栈”的概念。更推荐的方式是抓取 3~5 次完整线程栈,确认同一个热点方法是否持续出现。


九、本地如何安全模拟 CPU 飙高?

不需要安装完整虚拟机。

最简单的方案是运行一个只占用一个逻辑 CPU、并且会自动停止的 Java 程序。它会让一个核心短时间处于高负载,但不会把整台笔记本跑满。

创建文件 CpuSpikeDemo.java:

java 复制代码
public class CpuSpikeDemo {
    private static volatile double sink;

    public static void main(String[] args) throws InterruptedException {
        int seconds = args.length > 0 ? Integer.parseInt(args[0]) : 60;
        long deadline = System.nanoTime() + seconds * 1_000_000_000L;

        Thread hotThread = new Thread(() -> {
            double value = 1.0;

            while (System.nanoTime() < deadline) {
                value += Math.sqrt(value + 1.0);

                if (value > 1_000_000_000_000D) {
                    value = 1.0;
                }

                sink = value;
            }
        }, "cpu-hot-demo");

        hotThread.start();
        hotThread.join();
    }
}

编译并运行 60 秒:

bash 复制代码
javac CpuSpikeDemo.java

java CpuSpikeDemo 60 &
PID=$!

echo "Java PID: $PID"

此时可以在另一个终端中执行:

bash 复制代码
top -H -p "$PID"

找到高 CPU 线程后,再执行:

bash 复制代码
TID=替换为实际线程ID
NID=$(printf '0x%x' "$TID")

jcmd "$PID" Thread.print -l | grep -A 12 -B 4 "nid=$NID"

正常情况下,你会看到线程名:

text 复制代码
"cpu-hot-demo"

以及持续运行在 Math.sqrt 附近的调用栈。

程序默认 60 秒后自动结束。如果需要手动停止:

bash 复制代码
kill "$PID"

十、macOS 本地调试说明

线上排查通常以 Linux 为准。macOS 可以运行上面的 Java 示例和 jcmd,但线程查看命令略有区别:

bash 复制代码
ps -M -p "$PID" -o pid,tid,pcpu,command

然后将高 CPU 的 tid 转为十六进制:

bash 复制代码
printf '0x%x\n' 线程ID

再使用:

bash 复制代码
jcmd "$PID" Thread.print -l

不同 JDK、不同 macOS 版本下,线程 ID 的显示方式可能略有差异。因此,博客中的“top -H 获取 TID,再转换为 nid”流程应明确标注为 Linux 生产环境方案。


十一、想严格限制本机资源,可以使用 Docker

如果担心本地模拟影响工作,可以用 Docker 限制 CPU 和内存:

bash 复制代码
docker run --rm \
  --cpus=0.25 \
  --memory=256m \
  --pids-limit=128 \
  -v "$PWD":/app \
  -w /app \
  eclipse-temurin:21-jdk \
  sh -c 'javac CpuSpikeDemo.java && java CpuSpikeDemo 60'

参数含义:

  • --cpus=0.25:最多使用四分之一个 CPU 核;
  • --memory=256m:最多使用 256 MB 内存;
  • --pids-limit=128:限制容器最多创建的进程和线程数;
  • --rm:结束后自动清理容器。

macOS 上的 Docker Desktop 内部会使用轻量虚拟化,但不需要额外安装和维护传统虚拟机。


十二、除了 top + jstack,还有哪些排查手段?

1. pidstat:持续观察线程 CPU

如果机器安装了 sysstat,可以执行:

bash 复制代码
pidstat -t -p "$PID" 1

它每秒输出一次线程级 CPU 使用率,适合观察热点线程是否稳定。

2. Arthas:快速找到最耗 CPU 的线程

Arthas 可以直接输出 CPU 最高线程的堆栈:

bash 复制代码
thread -n 3

查看指定线程:

bash 复制代码
thread 线程ID

它适合紧急线上排障,但生产环境 attach 前仍应遵守权限、审计和变更规范。

3. Java Flight Recorder(JFR)

JFR 适合分析一段时间内的 CPU、GC、锁竞争、线程状态和方法热点。

bash 复制代码
jcmd "$PID" JFR.start name=cpu-demo settings=profile duration=60s filename=/tmp/cpu-demo.jfr

60 秒后可用 Java Mission Control 打开 .jfr 文件分析。

4. async-profiler

async-profiler 可以生成 CPU 火焰图,尤其适合定位“到底哪些方法占用 CPU 最多”。

它的优势是比单次 jstack 更接近真实的 CPU 时间分布。对于复杂问题,火焰图通常比人工阅读几十份线程栈更直观。

5. perf

如果怀疑 CPU 消耗在 JVM 外部、JNI、系统调用或 native 库中,可以使用:

bash 复制代码
perf top -p "$PID"

或者:

bash 复制代码
perf record -F 99 -p "$PID" -g -- sleep 30
perf report

这类命令通常需要额外权限,生产环境应提前确认可用性。


十三、常见问题与判断方向

如果热点线程栈顶是业务代码,优先检查:

  • while、for 循环退出条件;
  • 递归是否可能无限调用;
  • 是否存在失败后立即重试;
  • 大集合排序、嵌套循环、全量扫描;
  • 复杂正则表达式;
  • 大对象 JSON 序列化或反序列化;
  • 是否在请求线程中执行了不该同步执行的大任务。

如果线程栈中大量出现 GC 相关内容,优先检查:

  • 堆内存是否过小;
  • 是否存在对象创建过快;
  • 是否出现内存泄漏;
  • GC 日志中是否有频繁 Full GC;
  • 缓存、集合或批处理逻辑是否持续制造垃圾对象。

如果 CPU 很高但 Java 栈不明显,重点考虑:

  • JNI 或 native 库;
  • 压缩、加密、网络库;
  • 容器 CPU 限制导致的“看起来很高”;
  • 其他进程抢占 CPU;
  • 内核态 CPU 占比高。

十四、总结

定位 Java CPU 飙高的关键,不是只执行一次 top,而是建立完整关联:

bash 复制代码
# 找高 CPU 进程
top -c

# 找该进程高 CPU 线程
top -H -p PID

# 十进制 TID 转十六进制 nid
printf '0x%x\n' TID

# 导出线程栈
jcmd PID Thread.print -l > /tmp/jstack.log

# 根据 nid 查找对应 Java 栈
grep -A 12 -B 4 "nid=0x十六进制线程ID" /tmp/jstack.log

最后一定要连续抓取多次线程栈,并结合 GC、线程池、监控指标和业务日志交叉判断。只有确认热点调用栈持续稳定地出现,才能把问题准确归因到具体代码。

觉得有用就点个赞,不用登录也能读

评论

0 条
登录 之后才能评论
—
还没有评论,来说第一句