Arthas 4:thread排查Java卡死与CPU高占用

应用卡死时,最容易走错的一步,是先凭 CPU 曲线猜死循环,或者看见请求不返回就直接上 trace。更稳妥的排查路径是先执行 dashboard 判断 JVM 的整体状态,接着用 thread -n 3 找出高 CPU 线程,通过 thread <id> 查看堆栈。锁定业务方法后,再用 trace 追踪内部调用耗时,最后通过 watch 核对入参、返回值和异常。

dashboard
thread -n 3
thread <id>
trace com.example.DemoController testMethod '#cost > 100' -n 5
watch com.example.DemoController testMethod "{params, returnObj}" -x 2

其中 <id> 需要替换成 thread 输出里的 Arthas 线程 ID。应用卡死并不等于 CPU 一定高。当 CPU 高且线程长期处于运行态时,应继续追踪忙线程和内部循环;若出现 BLOCKED,需转查锁等待和阻塞者;CPU 不高但请求不返回时,应结合线程状态和堆栈检查外部 I/O、线程池等待或 GC 情况。

本文不讨论热榜文章中的趋势,只采用其中适合落地的排查思路:从现场证据开始,而不是从猜测开始。热点来源

Arthas 官方文档入口是 https://arthas.aliyun.com/doc。现有参考资料明确使用的是 Arthas 3.2.0,本文因此只把该版本的命令和排查路径作为参考,不补充未经核实的 Arthas 4 面板截图、版本号或输出。Arthas 4 的具体参数和结果应以目标环境及官方文档为准。

先选对进程,再用 dashboard 判断卡死类型

先确认要排查的 JVM 进程,再启动 Arthas。选错进程时,后面的线程排名、调用链和参数就无法对应目标应用。

curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar --repo-mirror aliyun --use-http

参考资料中的做法是,先用系统的 top 工具找到 CPU 占用较高的 JVM 进程 PID,再在 Arthas 启动器列出的 JVM 中选择对应进程。--repo-mirror aliyun --use-http 是资料里给出的启动参数;网络环境不允许时,应按官方文档核对下载和连接方式。

排查时要区分三个容易混淆的标识:操作系统 PID 用来定位和选择 JVM 进程,JVM 是被观测的应用进程,而 thread 输出中的线程 ID 才用于执行 thread <id>。把操作系统 PID 直接填到 thread <id> 中,通常查不到想要的结果。

进入目标 JVM 后,先用 dashboard 获取一张全局快照:

dashboard

需要连续观察变化时,可以使用五秒刷新间隔:

dashboard -i 5000

dashboard 适合查看 CPU、内存、线程、GC 和运行环境,也可以据此分流问题,但不能直接确定哪个业务方法导致了故障。

如果 CPU 明显升高,同时线程持续处于运行态,下一步是执行 thread -n 3,先根据 CPU 占用缩小线程范围,而不是立即猜测某个业务类。如果线程列表中出现 BLOCKED,则应执行 thread --state BLOCKED,并结合 thread -b 查找阻塞关系。当面板显示 GC 活动频繁时,需先执行 gc 查看 GC 情况,此时不宜直接把 CPU 升高归因于业务代码。如果 CPU 不高且大量线程都在等待,则需要查看具体线程栈,确认它们是在等待线程池任务、连接池资源,还是卡在外部依赖调用上。

线程状态和 CPU 占用不是一回事。线程显示为 RUNNABLE,也可能是在 native I/O 中等待;反过来,一条等待中的线程也不一定代表死锁。dashboard 给出的是方向,最终仍要回到具体堆栈。

CPU 高占用时,从 thread 追到 trace

先用 thread -n 3 列出当前 CPU 占用最高的三个线程:

thread -n 3

直接执行 thread 时,默认也会按照 CPU 占用排序,但线上线程很多时,-n 3 更容易快速缩小范围。找到可疑线程 ID 后,再查看该线程的堆栈:

thread <id>

一次堆栈只能反映一个瞬间。如果 CPU 占用是间歇性的,应间隔采样并比较线程 ID、调用位置和 CPU 趋势是否持续出现,再决定追哪条调用路径。

资料把无限循环和没有控制自旋次数的 CAS 列为常见的 CPU 占用来源。这种问题通常更适合从高 CPU 线程栈开始:先确认线程是否反复停在同一段业务逻辑,再判断循环为什么没有退出,或者自旋为什么一直无法得到预期结果。

另一个资料案例将问题定位到 Redis Stream 消费者的轮询配置。thread 输出中,线程 lettuce-nioEventLoop-4-1 的 CPU 占用达到 47.75%;继续查看线程 ID 22 的堆栈后,栈中出现了 sun.nio.ch.EPollArrayWrapper.epollWait,最终定位到消费者的 .pollTimeout(Duration.ZERO)。案例现场的原始配置为:

.pollTimeout(Duration.ZERO)

现场将其调整为:

.pollTimeout(Duration.ofMillis(10))

修改后 CPU 占用恢复正常。

这个案例可复用的是“线程名称和堆栈 → 可疑配置 → 修改后复测”的定位过程,而不是照抄 10ms。Duration.ofMillis(10) 是该案例的现场值,是否适合其他应用,要结合消费频率、框架版本和业务延迟自行验证。

当线程栈已经指向某个业务方法,而且这个方法能够完成调用时,就可以用 trace 查看内部耗时:

trace com.example.DemoController testMethod

线上流量较大时,应增加条件并限制执行次数:

trace com.example.DemoController testMethod '#cost > 100' -n 5

trace 会记录方法内部经过的调用路径,以及代码或方法调用的耗时;'#cost > 100' 用于筛选耗时较高的调用,-n 5 表示只匹配五次。具体输出形式可能随版本和目标类形态变化,需要在目标环境核对。

数据库访问、外部依赖和同步日志都可能出现在慢调用路径中。但 trace 并不是所有卡死场景的第一选择:如果线程陷在无限循环、锁等待或 native I/O 中,目标方法可能始终无法完成,trace 也无法给出完整有效的耗时结果。这时,高 CPU 线程栈或阻塞线程栈通常比 trace 更直接。

锁等待先看 thread,命中方法后再用 watch 验证

应用不响应但 CPU 不高时,先区分线程是在等待资源,还是已经发生锁阻塞:

thread --state BLOCKED
thread -b
thread <id>

thread --state BLOCKED 用于筛选处于阻塞状态的线程,thread -b 用于寻找当前正在阻塞其他线程的线程。找到相关 ID 后,再用 thread <id> 查看完整堆栈和阻塞关系。

资料中的真实终端片段如下:

[arthas@22366]$ thread --state BLOCKED
Threads Total: 29, NEW: 0, RUNNABLE: 7, BLOCKED: 1, WAITING: 11, TIMED_WAITING: 10, TERMINATED: 0
Affect(row-cnt:0) cost in 102 ms.

这段输出说明当时 29 个线程中有 1 个处于 BLOCKED,但它本身还没有回答“谁持有了锁”。下一步仍然需要 thread -b 和具体线程栈。

排查时要区分三种状态。BLOCKED 表示线程等待进入同步监视器,重点是查找阻塞者和锁竞争。WAITING 表示无限期等待某个对象、条件或任务,此时需检查线程池、连接池或组件间协作。TIMED_WAITING 表示带超时的等待,可能是正常的轮询或重试,也可能是连接建立、远程调用等待超时。单纯看到 WAITING 或 TIMED_WAITING 不能直接判定死锁,具体的 wait 对象和调用栈才是关键证据。

资料还记录了一个 Spring Boot 2.1.1.RELEASE 的案例:业务在数据库连接创建阶段停止输出日志,表象上看似卡死,最终通过 Arthas 查看线程状态和堆栈,定位到 JDBC 驱动相关阻塞问题。 这个案例说明数据库连接创建阶段值得检查线程栈,但不代表所有连接创建缓慢都是同一种根因,也不能仅凭“数据库卡住了”就直接修改连接池参数。

确认具体方法存在耗时问题后,再用 trace 观察调用耗时:

trace com.example.OrderService getOrderById '#cost > 1000' -n 5

资料中的订单查询案例通过这条命令观察慢请求,并把根因定位到同步写日志阻塞业务线程。这个结论只适用于该案例现场,不能泛化成“慢接口都是日志造成的”。实际排查时仍然要看 trace 中真正耗时的调用,以及它前后的调用关系。

trace 解决的是“哪里慢”,watch 解决的是“这次调用带了什么、返回了什么”。参数、返回值或异常需要验证时,可以这样使用:

watch com.example.DemoController testMethod "{params, returnObj}" -x 2
watch com.example.DemoController testMethod "{params, throwExp}" -e -x 2
watch com.example.DemoController testMethod "{params, returnObj}" "params[0]=='user1'" -x 2

params 表示方法入参,returnObj 表示返回对象,throwExp 用于观察异常,-e 开启异常观测,-x 控制对象遍历深度。条件表达式 params[0]=='user1' 可以把观测范围限制到特定请求,避免在流量高峰期输出所有调用。

资料中的订单状态案例还通过 watch 观察 updateStatus 的参数和返回值,用来验证不允许的订单状态回退:

watch com.service.OrderService updateStatus "{params,returnObj}"

这样可以同时核对“请求携带了错误状态”和“服务最终返回了什么”。但 watch 本身不能证明线程此前为什么等待,也不能替代锁阻塞分析。

线上观测时,应先限定类、方法、条件并限制执行次数,再逐步放宽条件。输出量过大既不利于阅读,也可能增加诊断开销;如果参数中包含业务数据,还需要先评估输出和留存风险。

各命令的诊断职责与主要输出如下:

命令 回答的问题 主要输出
dashboard 当前 JVM 哪里异常? CPU、内存、线程、GC、运行环境
thread 谁在忙、谁在阻塞? 线程列表、CPU 排名、状态、堆栈
trace 方法内部哪里慢? 调用路径、代码或方法耗时
watch 这次调用带了什么、返回了什么? 入参、返回值、异常

实际排查时,按全局状态、具体线程、内部耗时和输入输出逐层收窄证据,避免同时开启大量观测。

修复后重新执行相关检查,并进行多次采样:

dashboard
thread -n 3
thread --state BLOCKED

对比修复前后的 CPU、GC 和线程状态,才能区分瞬时尖峰与持续占用。单次采样正常,并不能证明问题已经消失。

排查结束后,先退出当前 Arthas 客户端:

quit

也可以使用:

exit

quit 和 exit 只退出当前客户端,不会关闭目标 Java 服务。确认不再需要 Arthas 服务后,再执行:

stop

stop 会关闭 Arthas 服务,应在排查结束后按需使用。

我的判断是,排查“应用卡死”时,应把线程状态、调用耗时和输入输出作为三层证据相互印证,不能只看面板数据。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容