Arthas实战:三分钟定位Java服务CPU飙高与死循环

发布时间:2026/10/10 3:13:30
Arthas实战:三分钟定位Java服务CPU飙高与死循环
凌晨两点二十三分某同事在群里甩了一条监控告警截图订单服务CPU使用率已经从 5% 一路飙升到 100%持续时间超过 15 分钟。第一反应是流量突增结果看网关入口的QPS稳稳的没有波动。再看JVM监控堆内存占用正常老年代也没涨GC 频率反而还低了不少。所有常规指标都是“太平”的状态只有 CPU 在发疯。这种“指标一切正常但是CPU爆表”的现场往往说明问题不在流量侧而在代码路径上——有少数的请求线程正在干重活或者陷入某种死循环把核都跑满了。这个时候再去看日志、查监控基本是在大海捞针。正确的姿势是用诊断工具直接抓线程现场把 CPU 花在哪个方法上给挖出来。这篇文章就复盘一次典型的 CPU 飙高定位过程主角是 Arthas。从拿到告警到定位到具体那一行代码实际操作下来就是三分钟出头。我会把整个排查思路、命令细节、常见的坑都拆开讲一遍希望下次你再遇到 CPU 飙到 100%不用再一个个点线程 ID 翻 jstack。1. 事故现场与排查思路先画一张“现象到代码”的定位地图CPU 冲高这种问题难点不在于看监控而在于把“CPU 占用高”这个结果映射到“某一段代码”上。这中间隔了好几层进程、线程、方法调用栈。脑子里必须有一张清晰的定位地图才知道每一步在做什么。1.1 CPU 飙高的两类常见形态第一类是持续型CPU 跑满后一整天都下不来。这种通常是某个线程死循环或者是某个线程池被耗尽后不断重试每个线程都在空转。比如 while(true) 里少了条件更新或者自旋锁没拿到就一直在原地打转。这类问题相对好抓因为热点线程是持续存在的只要把线程栈拍下来通常一眼就能看到问题。第二类是间歇型CPU 呈锯齿状波动一会儿冲到 90%一会儿掉到 20%。这种比持续型更阴间因为热点线程的生命周期很短等你抓到线程栈的时候它可能已经跑完了。比如定时任务每五分钟跑一次全表扫描或者某个缓存失效后集体去回源数据库都是一阵一阵的。这种场景靠 jstack 抓一次往往不够需要在 CPU 冲高的时候连续多抓几次或者用 Arthas 的 profiler 命令采集火焰图看整段时间的 CPU 分布。实战里我遇到的大多数 CPU 飙高都属于第一种持续型这也是本篇要重点讲的场景。1.2 为什么不用“top jstack”的老办法很多人第一反应是登录服务器top 先看一下记住 CPU 占用最高的那个进程 PID然后 top -Hp 进程ID 去看线程拿到最耗 CPU 的线程号转成十六进制再 jstack 进程ID | grep -A 30 “nid0x...”最后从日志里面找到对应的线程栈。这套流程没有任何错误但它有几个痛点步骤长且手忙脚乱。现场在告警中人在紧张状态下很容易忘了十六进制转换这一步。jstack 默认是打全量线程快照。一个服务几百个线程grep 出来的上下文就那么一小段可读性很差。jstack 是抓瞬时快照没法按 CPU 占用排序。你得先自己判断哪个线程是嫌疑犯而不是让工具告诉你。如果服务器上装了不只一个 Java 进程你还得先确认哪个 PID 是目标服务。说白了老办法是“手工作坊”级别的定位方式能用但效率不高尤其不适合出现了问题需要快速恢复的场景。1.3 为什么选择 ArthasArthas 是阿里开源的一款 Java 诊断工具它的核心价值在于直接 attach 到目标 JVM 上让我们用命令交互式地检查线程、方法调用、类加载信息甚至可以现场反编译代码、执行方法全程不需要重启服务不需要加启动参数也不需要提前埋点.它在定位 CPU 飙高问题上的三个独特优势thread -n 3 这样的命令可以直接输出 CPU 占用最高的几个线程并且直接显示它们的栈信息省去了手工转换 nid 的过程。自带 profiler 命令可以生成火焰图而不额外加 agent。watch/trace 可以直接观察方法的入参、出参、耗时、调用路径甚至反编译线上类直接看问题代码。对于“CPU 突然飙到 100%”这种诉求是“快点找到代码”的场景Arthas 是比 jstack 更加顺手的工具。下面我按真实排查的推进过程把命令一个一个过一遍。2. 三分钟定位实操路线Arthas 三板斧把嫌疑代码揪出来进入正经排查环节。假设服务还在跑CPU 照样是 100%端口还占着只是业务响应开始变慢。这时候我们要做的第一件事不是重启而是把一个诊断工具挂上去把现场保留下来。2.1 启动 Arthas 并连接到目标进程Arthas 的启动非常简单下载好 arthas-boot.jar 之后执行java -jar arthas-boot.jar它会把当前机器上跑着的 Java 进程列出来我们输入序号选目标服务对应的进程回车就能完成 attach。如果只有唯一一个 Java 进程也可以一行命令直达java -jar arthas-boot.jar PID提示attach 本身是安全的不会重启服务也不会 stop 应用线程线上可以直接执行。但生产环境建议搞清楚是谁、哪个团队在操作至少要在群里知会一声避免另一拨人看到服务日志里多了一堆莫名的数据结构还以为被入侵了。进去之后会看到 Artahs 的交互式命令行左上角出现$提示符。到这里耗时也就十几秒到半分钟。重头戏从线程分析开始。2.2 thread -n 3先盯着 CPU 占用最高的三个线程thread 命令在 Arthas 里专门用来查看线程信息最常用的参数是 -n意思是按照 CPU 占用率排序打印前 n 个线程。这里我们想快速看最忙的三个线程于是thread -n 3实际输出的样子大致是order-thread-pool-12 Id128 cpuUsage98.2% TIMED_WAITING at java.lang.Thread.sleep(Native Method) at com.example.OrderProcessService.process(OrderProcessService.java:88) ...这一下信息量就很大了直接跳过了 top 和 jstack 的中间步骤。看三段重点线程名称、CPU 占用率、以及它停在的调用栈。我当时的输出里最靠前的是一个业务线程CPU 占用率 98.2%栈顶停在某个 Redis 客户端的读取逻辑上但再往上层看走的又是订单处理的那个 service 方法。这让我觉得不对劲如果是正常等待 Redis 返回线程应该是 WAITING 或 BLOCKED 状态CPU 占用率不会这么高。一个 CPU 接近 100% 的线程停在“读”操作上大概率是在无限循环地重试读取或者是自旋等待某一个条件满足。这里就涉及到一个很关键的习惯不能只看栈顶要往前翻几层看清楚整条调用链。很多死循环不会直接体现在栈顶而是在业务代码段落的某个 while 循环里不断调用外部方法栈顶看起来像是在等什么实际情况是立刻重试、立刻返回、再立刻重试。如果三个线程里面出现了好几个 GCTaskThreadCPU 占用还都很高那就要先考虑是不是 GC 压力引发的 CPU 飙高。这种场景下线程栈看着没有业务代码全是 gc 内部函数需要切到内存分析方向去查。后面常见问题章节里我会专门讲这种最容易误判的情况。2.3 锁定可疑方法后用 trace 观察耗时分布thread -n 3 帮你确定了“是哪个线程在烧 CPU”但还不够——它给的是一个调用栈快照。CPU 到底花在这个方法内部的哪个子调用上还需要更进一步的方法级排查。这时候 trace 命令派上用场。比如调用栈里出现了 OrderProcessService.process 这个方法那么可以trace com.example.OrderProcessService process执行之后Arthas 会动态织入字节码增强逻辑当这个方法被调用时自动输出方法内部各个子调用的耗时情况。正常情况下一个线上服务请求不断过几秒就能看到 trace 输出。输出的结构大致是这样---ts2025-01-01 00:00:00 thread_nameorder-thread-pool-12 total1234ms ---[100%] com.example.OrderProcessService.process ---[98%] com.example.OrderProcessService.tryAcquireLock ---[97%] com.example.RedisClient.get第 1 个字段 total 是整个方法总耗时后面的百分号是每个子调用占父调用的耗时比例。哪个方法烧时间一眼就挑出来了。不过 trace 有个小特点它默认是只输出调用链上耗时较长的节点不是把所有子调用都打印出来。如果某个内联方法很快、调用次数却极多trace 的总耗时看着不高这时可以考虑用 --skipJDKMethod false 参数把 JDK 方法也展开或者用 -n 限制采样次数避免输出刷屏。2.4 watch / jad确认这代码到底在干什么定位到 tryAcquireLock - RedisClient.get 这个调用链之后我们心里的怀疑已经有方向了大量的锁获取尝试走的是同一个 Redis key而且每次都是失败后立刻重来。但这只是基于栈结构的推断要看实际代码逻辑还得让 Arthas 把反编译的源代码或者方法执行现场贴出来。先看方法入参和返回值用 watchwatch com.example.OrderProcessService tryAcquireLock {params, returnObj} -x 2它会实时监听这个方法打印每次调用的参数和返回值。如果某个 key 的参数极其集中且返回值一直是 false获取锁失败那“锁竞争导致自旋”的猜想就得到了数据支撑。再确认线上代码是不是“预期的那版”用 jad 命令反编译jad com.example.OrderProcessServiceArthas 会把该类的字节码直接还原成 Java 源码打印出来。我见过不少次反编译出来之后发现 createTime 字段在 equals 方法里居然被加进去了一旦同 ID 的订单持续冲到同一台机器上equals 永远不相等相当于缓存永远不生效——这种代码逻辑在 code review 时很难发现因为字面上写得很合理。2.5 profiler 火焰图兜底方案与联想扩展有些时候问题的线程栈并不在业务代码上或者在多个线程之间来回切换光看 thread 快照很难定性。比如多条异步链路并发跑同样一段逻辑每个线程都占一点 CPU单个线程的占用率不够突出thread -n 3 排在前面的可能也只是一些空闲线程。这种场景下最好的工具是火焰图。Arthas 的 profiler 命令可以采集一段采样时段内的 CPU 调用栈profiler start跑个 60 秒左右再 stop 退出profiler stop --format htmlArthas 会生成一个 HTML 格式的火焰图文件默认路径在 /tmp 下输出末尾有路径提示。火焰图的横向宽度是 CPU 采样占比哪一段横条越宽就说明这段时间 CPU 老在那里忙活。让火焰图跑上几分钟再去对比横向最宽的几条链定位效率反而比盯着单线程更快。其实对很多老手来说thread 命令解决的场景已经覆盖了七八成profiler 更多是用来验证结论或者从更宏观的视角看看是否存在“每个线程都在干活、但没有哪个线程特别突出”的 CPU 争抢问题。3. 深挖根因的思考框架为什么代码会烧 CPU 烧到 100%工具用完之后话题还是要回到那几行代码本身。只有理解了“为什么这段代码能把 CPU 跑满”才能在下次遇到相似问题时快速联想。按我的经验CPU 飙 100% 的场景反复出现的就那么几类。3.1 死循环与无限重试最常见的一种某段代码的在循环内没有推进条件或者循环跳出的条件永远不成立。比如while (!lock.tryLock()) { // 没有等多久也没有退避立刻进入下一次尝试 }这种写法 CPU 接近 100% 是必然的。tryLock 失败后立刻重试相当于空转式自旋。哪怕每一次的失败判定只有几十微秒循环频率一高核都被吃满。正确的做法是加退避比如 CAS 自旋带随机时间片或者用 Redis 的订阅通知机制来等待锁释放而不是硬轮询。Arthas 在这里的作用就是让你看到tryLock 返回值一直是 false 却还在不停被调用这个动态过程。3.2 数据量大导致的常规计算爆炸也有一些情况不是死循环而是“某一笔请求恰好撞上一个超大集合把 O(1) 的代码直接干成 O(n²)”。比较典型的就是两层 for 循环去重、contains 方法放在循环体里对链表反复扫描、List.remove 在大列表上反复移动元素。平时数据量小时感觉不到一旦线上流量把某个大 key 的缓存击穿所有请求同时算同一个巨大的 listCPU 就爆炸了。这种情况在火焰图上会表现为一条非常宽的横条底层是 List.contains 或者 HashMap 扩容这类高频方法但调用链是唯一的。定位的方式还是 trace jad 组合先看热点方法再看代码是不是存在循环嵌套大集合的操作。3.3 JDK 版本、锁升级与偏向锁的隐性开销严格说这类问题不是程序员的“锅”而是 JDK 变更后行为差异导致的。比如偏向锁在高版本里已经被逐步废除大量无竞争的 synchronized 代码反而会走轻量级锁 CAS 路径在某些 JVM 参数组合下会带来额外的性能损耗。再比如 JDK 17 之后 G1 的某些行为变了字符串去重、代理类生成、动态字节码加载都可能让 GC 线程把 CPU 吃掉。这种场景下光看业务代码是找不到问题的反而要结合 Arthas 的logger、sysprop、getstatic等命令确认当前 JVM 启动参数、运行时系统属性再对比是不是最近升级了 JDK 或换了 GC 策略。CPU 飙高的瞬间刚好对应发布窗口就有理由怀疑到 JDK 或启动参数头上。4. 常见误判与坑位实录排查 CPU 问题时最容易踩的五个坎老实说工具本身不难难的是在告警、领导、业务受损的压力下依然保持思路清晰。这里整理几个我在实战和多年的交流里见过最多、最容易导致方向跑偏的坑。4.1 以为 CPU 100% 就是幂等看到一堆 GC 线程才意识到是内存问题thread -n 3排在最前面的全是 GC 线程时说明 CPU 并不是被业务代码烧掉的而是 GC 在疯狂做内存清理或者频繁 Full GC 导致业务线程全部阻塞。这种情况下直接用heap dump 分析 查看老年代占用才是正确方向。Arthas 里有现成的命令可以快速判断memory它会输出当前堆内存各个分区的使用率。如果老年代已经跑满同时 eden 区和 survivor 区疯狂增长那就是对象分配压力过大跟代码 while 循环没多大关系。定位方向要从“CPU 使用”切到“内存分配”去看到底什么对象在高速创建。4.2 单核的 100% 被误读为整体 100%云服务器上一般默认显示的是整机 CPU 平均占用率但 Arthas 的 thread -n 3 里显示的 cpuUsage 是单线程在单个 CPU 核上的占用率。如果机器是 8 核的某个线程占用 98% 的 CPU整机指标其实也就 12%左右。所以告警平台上看到“CPU 100%”其实有两种可能的含义一种是单核打满导致的利用率看起来很高另一种是整体多核全部打满。前者一段线程占满一个核后者往往意味着多个线程同时高占用或者存在大量线程竞争。先分清这个再选择不同的命令组合节省不少时间。4.3 trace 想抓的只是几次调用结果输出量直接撑爆屏幕线上 trace 一个高频方法时一定要想清楚它的调用量。有些核心方法的调用链路极深每秒要被调用上万次如果直接trace com.example.OrderProcessService process输出会像机关枪一样刷屏加上方法内部可能连了很多子调用完全没法看。建议加条件过滤或者限制次数trace com.example.OrderProcessService process #cost 1000只展示耗时超过 1000 毫秒的调用。或者用trace com.example.OrderProcessService process -n 5只采样前 5 次。这样不仅输出干净对目标服务的影响也小得多。4.4 jad 反编译失败或者内容非常简短jad 反编译遇到内部类、lambda、动态代理时经常会出现提示“请使用 --source 参数指定源码路径”。它不是命令有问题而是类文件里没有行号调试信息或者目标类是从字节码增强里生成的。这种情况下可以看堆栈里提示的类名再配合sc -d 类名查找类的实际路径切换到 jar 包内的原始 class 文件做反编译定位。还有种情况是线上跑的类和本地代码库对不上反编译出来是旧版逻辑误导排查方向。所以拿到结果之后一定要先和当前的发布版本做 diff确认是不是同一个版本。4.5 attach 不上目标进程用 Arthas 前要确保当前用户有完全权限或者以同属用户运行。attach 失败常见的报错是 “Unable to open socket file”这种情况多半是 service 进程属于其他用户解决方式是切换到同样的用户去执行 arthas-boot或者使用 sudo 执行。另一个常见场景是容器化部署中要在容器内部执行命令而不是在宿主机上对 docker 的进程做 attach。某些基础镜像里没有 java 环境或缺少必要依赖也会导致启动不了。日常建议在基础镜像里就把 arthas 放进去或者改造成运维平台一键接入省得 CPU 告警时还要临时拉镜像、装命令。5. 事后复盘与预防手段跑出来一个问题不如按死一类问题定位问题只完成了 60%剩下 40% 是怎么防止它再次发生。每次 CPU 飙高复盘我都会盯着两个方向一个是能不能工具化、自动化另一个是能不能从代码规范上把死循环这一类问题直接干死。5.1 等待策略的规范化自旋退避口诀对轮询和自旋场景团队作业规范里最好写死几条规则自旋等待必须带最大次数限制没有最大次数的自旋就是事故温床。每次循环之间最少加 1ms 的多线程等待sleep/yield。能用事件通知的地方坚决不用轮询能使用现成锁的地方不要自己写死循环。这类逻辑的代码里必须写清楚“为什么会走到这个循环”为后来者续命。5.2 给关键方法做动态观测的权限预留在生产环境悄悄埋点是很多公司不愿意做的但 Arthas 这类工具的价值就是“不埋点也能动态诊断”。所以部署时最好只是保证基础镜像里能正常执行 arthas-boot并记录好每个服务的启动方式和运行用户。一旦线上有问题三分钟内就能 attach 进去。也可以考虑把 Arthas 的能力接入内部的运维诊断平台点一下按钮就给目标服务放行一个临时诊断会话。这样即便值班的同学没接触过 Arthas也能按标准故障预案操作。5.3 监控指标要联动不能只盯 CPU 一个维度CPU 飙升时同时去看 Blocked 线程数、等待线程数、GC 次数、Full GC 耗时、网络 IO、数据库慢查询这些指标全放一起可以快速排除掉不少方向。我自己的经验是先看 thread -n 3 守一守如果全是 GC 线程切 memory 看堆内存如果有业务线程看 trace/jad 定位代码如果所有线程都不突出上 profiler 抓火焰图。整个过程像看病一样不是一上来就开刀而是先做基本判断再上精密检查。6. 个人经验补充把 Arthas 的“临时诊断”变成常规武器最后说一点个人的看法Arthas 并非只能用于 CPU 告警它其实是一把偏移性强、几乎没有副作用的探针。attach 到线上 JVM动态观察方法调用、修改变量、重新加载类听起来很“黑科技”但实际操作层面非常安全生产环境也完全允许做只读类的诊断操作。我用 Arthas 最多的三件事依次是线上偶发超时用 trace 确认耗时的罪魁是外部调用还是自身逻辑。排查依赖的某个内部类加载问题用 classloader 和 sc 看类是谁加载的。热更新某个线上临时改动用 redefine 快速打补丁避免等到第二天发布窗口。如果拿它和一把瑞士军刀做类比CPU 定位只是它最锋利的刀刃之一。真正躲不开的是掌握这种“动态诊断”的思维不是每次出问题都要加日志重新发布而是让线上服务自己开口告诉你哪里不对劲。再分享一个小技巧如果每次踩坑都要手动敲命令久了肯定还是会嫌麻烦。可以按服务裂变场景写一份 arthas 命令速查脚本进交互命令行后直接按编号执行。我是把“thread -n 3”“memory”“profiler start”这三条默认绑在一个应急指令里出问题先跑一轮再把输出贴到群里省下不少来回询问的时间。