☰
Linux高CPU线程定位:从top -Hp到jstack/perf完整套路
2026/9/29 15:43:55 网站建设 项目流程

做服务器维护和后台开发的人,几乎都遇到过这种场景:一台Linux机器负载突然飙到十几,业务方火急火燎地说接口超时,你登上服务器敲了一个top,看到某个进程的CPU占用已经200%多,然后呢?然后大家就只能盯着进程名反复猜。说实话,光知道进程名在绝大多数情况下一点用都没有。CPU是进程里的线程消耗的,不看到线程级别,你连方向都找不准。这篇就记录一下我平时定位Linux高CPU线程的完整套路,从一条命令快速锁定线程ID,到配合jstack、perf这些工具把问题钉死在代码行,希望能帮刚开始接触Linux性能排查的朋友少走点弯路。

1. 排查前先理清两个基本概念

1.1 进程和线程在Linux里是怎么表示的

Linux内核不像Windows那样把进程和线程完全分开管理,它统一用task_struct表示调度单位。线程本质上是clone()创建出来的特殊进程,只不过和同组的进程共享了地址空间、文件描述符、信号处理等资源。所以你会发现,ps命令里既能看PID,也能看TID,这两个数字在同一个进程里经常不一样。

用一个生活化的比喻:进程是公司,线程是员工。公司对外只有一个统一名称,但公司内部真正在干的活是由一个个员工完成的。CPU调度器去分配时间片,盯的是“员工”而不是“公司”。top默认状态下把所有员工打包成一个公司来显示,所以你知道某个进程CPU高,但不知道是哪个员工在摸鱼;只有切到线程模式,才能把内部的员工挨个抓出来。

具体到内核参数,线程组有一个tgid概念,进程的主线程的TID等于进程PID,后续创建的线程会得到新的TID。这也是为什么排查线程时,千万不要拿线程ID直接当进程ID用。理解了这一点,后面所有命令都不会绕晕。

1.2 先分清CPU时间到底花在哪儿

在动手抓线程之前,建议先看一眼top头部那行统计。us、sy、ni、id、wa、hi、si、st这串缩写,看起来很难记,其实只需要重点关注前两个,顺手扫一眼wa和st。

指标含义常见方向
us用户态CPU占用业务代码死循环、计算密集、GC频繁
sy内核态CPU占用系统调用过多、锁竞争、内核模块异常
wa等待I/O的CPU占比磁盘慢、存储抖动,优先查I/O而非线程
st被虚拟机偷走的时间云主机/容器宿主机超卖,先排查宿主机

us高,说明CPU在跑用户态代码,九成是业务逻辑问题,比如循环没退出、反序列化过重、线程池被错误使用。sy高,说明CPU花在内核态,常见的是系统调用频繁、futex锁竞争、网络中断打满,这时候光去抓业务线程栈是不够的,反而要辅助strace或者perf去看内核路径。wa高则是CPU在等I/O,通常先查磁盘读写和存储负载,而不是急着找线程。st高说明你的虚拟机在排队等宿主机调度,这种情况单看云服务器内部意义不大。先定性再往下钻,能省下大量时间。

2. 最快锁死线程ID:用top -Hp和ps组合

2.1 用top -Hp直接看线程CPU占用

最直接的方式就两步。第一步,先执行top -bn1或者直接top,找出出问题的进程PID,按P键让进程按CPU占用排序。第二步,拿到PID之后执行下面这条命令:

top -Hp 12345

-H表示Threads模式,-p指定进程。执行完你会发现画面变了,标题行从Tasks变成了Threads,列表里每一行都是一个线程。这时候再按一下P键,让线程按CPU使用率从大到小排,第一眼看到的那一行,就是嫌疑最大的线程。

注意一个特别容易踩的坑:在top -Hp的输出里,第一列的标题仍然是PID,但它实际上已经是TID了。比如你看到某个线程的PID是23456,这个23456并不是一个真实存在的进程,拿ps -p 23456去查大概率什么都查不到,别拿着这个号到处找进程。另外COMMAND列默认只显示线程名,一些Java线程名字很长,按c键可以切换显示完整命令行。

如果不想交互式操作,也可以直接用批处理模式:

top -H -b -n 1 -p 12345 | head -25

这个用法非常适合写脚本,一次性采样,输出一行行线程数据。

2.2 用ps做线程级快照

top是交互式的,在脚本里解析它要多费不少功夫。这时候用ps更稳定,格式也更容易控制。我最常用的两条命令如下:

ps -eLo pid,tid,pcpu,comm --sort=-pcpu | head -20 ps -p 12345 -L -o tid,pcpu,comm --sort=-pcpu | head -20

第一条看的是全系统里所有线程的CPU占用排序,适合一开始只知道机器整体CPU高、但不知道是哪个进程出了问题的情况。第二条针对指定进程,列出其所有线程并按CPU占用排序。pcpu字段就是线程的CPU使用百分比,脚本里直接拿这一列排序就行。

要注意不同版本的ps对字段名的支持略有差异,个别老发行版上如果报pcpu不可用,改成%cpu试试。另外ps默认输出的COMMAND字段也可能被截断,可以先加上-c参数强制展示进程可执行名,或者干脆用comm字段,简单场景下足够用了。

2.3 把线程ID转成十六进制,给Java栈用

很多人排查Java进程卡在最后一步,就是拿着十进制的TID去grep jstack半天查不到,因为jstack输出里的nid是十六进制。转换非常简单:

printf '0x%x\n' 23456

比如23456转出来是0x5ba0。拿到这个值,后面jstack里直接grep nid=0x5ba0就可以了。如果你习惯用Python,也可以执行python3 -c "print(hex(23456))"。这个动作虽然基础,但确实是排查Java线程CPU问题时最高频的桥接步骤,顺手记下来能节省很多时间。

3. 从线程ID到代码栈,三种场景的进阶打法

3.1 Java应用:jstack一把梭

Java进程是最常碰到CPU飙升问题的场景。常规流程是:先用top -Hp抓出占用最高的TID,转成十六进制,然后执行jstack把线程栈dump下来分析:

jstack -l 12345 > /tmp/jstack_12345_$(date +%s).txt grep -A 30 'nid=0x5ba0' /tmp/jstack_12345_*.txt

如果jstack报错Unable to open socket file,大概率是进程身份或JDK版本问题。竞争JDK9以后,更推荐用jcmd:

jcmd 12345 Thread.print -l > /tmp/thread_12345.txt

注意jstack执行时会让JVM短暂停顿,线上大压力下要快速执行,不要反复折腾。dump下来之后,重点看nid对应的线程当前栈顶和线程状态。同一个线程多次dump栈顶都落在某个用户方法上,基本就是循环或计算热点;栈顶在native方法里,就要继续上升到perf层面看是哪个本地函数。

如果发现线程名是G1 Young RemSet Sampling Thread或者CMS的并发标记线程,CPU高不一定就是问题,可能只是GC设置不合理。我之前遇到过一台服务CPU飙到接近300%,定位下来是G1的Remember Set线程在疯狂执行,最后通过调大堆内Region大小和调整GC参数解决。这类问题要结合GC日志,别一上来就拍脑袋调堆。

3.2 C/C++及原生线程:读/proc和用pstack/gdb

非Java进程,线程名一般可以直接从/proc下读:

cat /proc/12345/task/23456/comm cat /proc/12345/task/23456/status

status里的Name字段就是线程名。很多服务会把业务线程设置成有意义的名字,比如nginx的worker线程、MySQL的page cleaner线程,一看名字就能猜到大概职责。如果线程名看不出问题,就得抓调用栈。

轻量一点的方式是用pstack,但pstack显示的是进程内所有线程的调用栈,输出量很大,需要配合grep去捞目标线程附近的内容。更可靠的是gdb:

gdb -p 12345 -batch -ex 'thread apply all bt' > /tmp/gdb_stack.txt

gdb输出里每个线程会带LWP号,这个LWP就是TID,可以直接和top -Hp里的数字对照。但gdb attach一个正扛大流量的进程,会让进程短暂停顿,甚至触发超时报警,生产环境使用前一定要评估风险。还有一种低侵入的方式,读/proc/PID/task/TID/wchan和syscall文件,能快速看到线程当前在内核里等什么,适合判断线程是不是卡在某个系统调用上。

3.3 热点函数:perf和strace辅助定位

如果栈顶在native方法,或者C程序里线程名和调用栈都有了,还是不清楚CPU到底消耗在哪,我会请出perf。最常用的是实时热点:

perf top -p 12345

这个命令能直接看到进程内CPU占比最高的函数符号。如果提示权限不足,多半是kernel.perf_event_paranoid设置得比较严格,需要root或者临时调小。如果要留证据,用perf record加perf report:

perf record -p 12345 -g -- sleep 30 perf report

-g参数会记录调用关系,非常适合分析C/C++复杂项目里CPU热点到底是从哪个函数一路调过来的。另一个工具strace主要跟踪系统调用:

strace -p 23456 -c -f

-c参数是在结束时打印系统调用统计,-f表示跟踪子线程。不过strace会明显拖慢目标进程,我一般只在进程CPU高且伴随大量系统调用时才用,而且最多跑几十秒。遇到sy高的情况,strace能很快告诉你它到底是在read、write、epoll_wait还是futex上折腾,方向一下就出来了。

4. 把定位动作脚本化,防止现场一闪而过

4.1 一屏看全:一行命令打出TOP线程

CPU问题往往不是稳定复现的,等你在终端慢悠悠敲命令,现场可能已经变了。我习惯把定位动作固化成脚本,随手一敲就出结果。一个简化版脚本是这样:

#!/bin/bash PID=$1 if [ -z "$PID" ]; then echo "Usage: $0 <pid>" exit 1 fi echo "===== top threads by CPU =====" top -H -b -n 1 -p "$PID" | tail -n +8 | awk '{print $1, $9, $NF}' | sort -k2 -nr | head -20 echo "===== ps detail =====" ps -p "$PID" -L -o tid,pcpu,comm --sort=-pcpu | head -20

top的批处理模式-b,一次采样就退出,-n 1控制只执行一次。tail -n +8是为了跳过头部那些统计信息。不同Linux发行版的top头部行数可能不一样,如果脚本里输出对不齐,建议直接信任ps那一行结果。实际排查中,ps的pcpu排序已经足够用了。

4.2 循环记录和定时保留现场

脚本只解决单次定位,不解决持续追踪。遇到CPU反复飙高的情况,把采样放到循环里,每几秒记一次:

while true; do echo "====== $(date '+%F %T') ======" top -H -b -n 1 -p 12345 | tail -n +8 | sort -k9 -rn | head -15 sleep 5 done > /tmp/cpu_thread_12345.log

这里有两点要提醒。采样间隔别太短,1秒一次持续跑,会给本来就不宽裕的机器增加额外负担。日志文件别一直写不滚动,最好配合logrotate或者自己定期清理。如果你已经定位到目标TID,还应该在同一时间戳下把线程栈也dump一份,比如每10秒执行一次jstack。采样日志和栈文件对应上之后,分析时才能还原出CPU高和代码路径之间的因果关系,而不是靠猜。

5. 躲过这些坑,你就是排查老手

5.1 线程ID和进程ID分不清,白忙一场

最常见的问题就是把top -Hp输出的PID当成进程ID去处理。这里再次强调:线程模式下第一列是TID,不是PID。另外,top默认显示的进程CPU占用是把进程内所有线程CPU时间加总后的结果,所以一个进程可能显示300%,但没有一个单线程能超过100%。反过来说,如果只看进程总CPU,在机器核数很多时,负载高不一定代表某一个线程异常,也可能是整体流量涨了。排查时先和监控历史曲线对一下,确认CPU是持续升高还是突发尖峰,再决定要不要深入线程。

5.2 线程跑得太快抓不到?换个姿势采样

CPU占用是时间片采样出来的,一个线程可能一会儿99%、一会儿0%,恰好在你敲top那一刻落在低点上,就被排到后面去了。这种情况我改用pidstat做持续统计:

pidstat -t -p 12345 1 10

这个命令每秒输出一次,连续10次,并显示每个TID的用户态CPU和内核态CPU占用。看累计值比看单次快照可靠得多。如果系统没有pidstat,装一下sysstat包即可,CentOS和Ubuntu都能直接用yum或者apt安装。还有个偏方,把top的刷新间隔调大一点,比如top -d 3 -Hp,让它每3秒刷新一次,多盯几轮,线程的规律也会慢慢浮出来。

5.3 容器场景和权限问题

容器里排查比宿主机麻烦不少。首先,容器内执行top看到的是容器自己的PID namespace,和宿主机看到的PID可能完全不同。所以要在容器内用容器内PID执行jstack;如果容器里没有JDK工具,就要回到宿主机找到容器进程在宿主机里的PID,再结合nsenter或者docker top去处理。其次,attach操作经常会遇到权限限制,比如kernel.yama.ptrace_scope设为1时,普通用户不能attach其他进程,执行jstack或gdb会直接报Permission denied。临时放开需要root,但改内核参数属于高危操作,最好只在一次性诊断时使用,用完立刻改回。

5.4 定位完成之后的几个处理方向

找到元凶线程后,别急着kill。线程不是一个独立进程,kill线程的办法通常是把整个进程杀掉,线上这么干基本就是事故。正确的做法是区分问题类型。如果是业务线程死循环或者异常重试,保留dump让代码owner去修,临时处理可以平移流量后重启服务。如果是GC线程,调GC参数前先看GC日志,确认是对象分配压力大还是内存碎片问题。如果是线程池里的活跃线程被下游依赖拖住,下一步应该去查线程池大小、队列积压和下游超时配置。定位到这一步,排查工作才算真正结束,剩下的交给修复和验证。

最后说一点我自己实操累积下来的体会。我现在的排查习惯是:先用top看整体,再用top -Hp锁定嫌疑TID,同时起一个pidstat持续记录现场,最后根据语言栈选择jstack或perf收尾。这套组合动作在绝大多数CPU尖峰场景里都能扛住。还有个小技巧,抓到TID后先存一条带时间和CPU数值的记录,别急着分析节奏太快,多采几轮。CPU问题很会变脸,手头有完整的数据链,比什么都强。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询