Day84-Arthas诊断实战:CPU飙高/死锁/内存泄漏排查三板斧

上篇回顾:Day 83 用 DDD 限界上下文把复杂业务边界切清楚、代码物理隔离,新人三天能上手。但架构再清晰,运行起来也可能半夜崩------而生产机器默认不开 debug 端口,jstack 都用不了。这篇把 Arthas 这把"凌晨救命"手术刀讲透。

生产诊断的第一个现实是:很多 Spring Boot 服务默认不开 JMX、不开 debug 端口 ,IDEA Debug、VisualVM、JProfiler 这些开发环境手段远程全用不了。top -Hp <pid> 看到线程吃满 CPU,接下来却无从下手------重启要 20 分钟、回滚 40 分钟,赶上客户投诉就是 P0。

Arthas 是阿里开源的 Java 在线诊断工具,能在不重启进程、不改代码的情况下 attach 到运行中的 JVM 上做"手术":thread -n 3 一行定位吃 CPU 的线程、jad 反编译看实现、watch 监控调用栈------五分钟定位、十五分钟改完走紧急发布,全程不重启服务。今天把这套工具真正用进生产。


一、为什么 JVM 自带工具不够用

很多新人以为线上诊断靠 jstack、jmap、jstat 这三板斧就够了。确实够用,但不好用------它们的痛点有三:

  1. 看到线程名却看不到方法 :jstack 输出是线程堆栈,但代码经过多层框架封装,从 AbstractHandlerMethodAdapter.invoke 这种方法名里看不出是谁调的业务方法

  2. dump 太重:jmap -dump 一执行,几十秒到几分钟内进程几乎停摆,对线上是二次打击

  3. 改不动代码:发现问题想立刻修?对不起,重启走流程,发版回滚搞定是 30 分钟后的事了

Arthas 解决的就是这些。它通过 Java Agent 机制 attach 到运行中的 JVM 上,提供 100+ 命令,常见诊断需求都能在不重启的情况下完成。核心依赖如下:

java 复制代码
<!-- pom.xml:只引入 arthas-core,让 attach 启动器自动下载最新版本 -->
<dependency>
    <groupId>com.taobao.arthas</groupId>
    <artifactId>arthas-spring-boot-starter</artifactId>
    <version>4.0.5</version>
</dependency>

启动后默认监听 3658 端口,你可以通过 http://localhost:3658/ 在浏览器里看交互式控制台,也可以用 curl 远程调用(生产配合 VPN/Spring Boot Actuator 做权限管控)。

二、三板斧实战:watch / trace / thread

这三板斧是 Arthas 最常用的命令,对应"看方法被调用情况 / 看调用链路 / 看线程状态"。组合使用能解决 80% 线上问题。

2.1 thread 命令:5 秒定位 CPU 飙高根因

CPU 飙高第一步:哪个线程吃满了?Arthas 的 thread -n 3 直接给你 CPU 占用前 3 的线程堆栈:

java 复制代码
# attach 到进程后(在 arthas 控制台里执行)
$ thread -n 3
"http-nio-8080-exec-127" Id=237 cpuUsage=89.7% deltaTime=1248ms time=8923ms
    at com.example.order.service.OrderService.calculateCartesianCoupon(Ljava/util/List;)V
    at com.example.order.service.OrderService.applyBestCoupon(OrderDTO)
    at com.example.order.service.OrderService.createOrder(OrderDTO)
    ...
​
"GC-thread-7" Id=87 cpuUsage=12.3% ...
"http-nio-8080-exec-130" Id=240 cpuUsage=8.1% ...

看到没?直接给出业务方法名 calculateCartesianCoupon------笛卡尔积优惠券计算,一下子就知道哪个方法出问题了。

更进一步,定位到具体方法后,你想知道这个方法是哪个用户请求触发的,参数是啥 ------用 trace 命令:

java 复制代码
# 监控 calculateCartesianCoupon 方法的调用耗时与方法入参
$ trace com.example.order.service.OrderService calculateCartesianCoupon \
    '#cost > 50' \
    -n 5
​
Press Q or Ctrl+C to abort.
Affect(class-cnt:1 , method-cnt:1) cost in 87 ms.
`---ts=2026-08-30 02:14:23;thread_name=http-nio-8080-exec-127;id=237;is_daemon=true;priority=5;TCCL=...
        `---[39.3ms] com.example.order.service.OrderService:calculateCartesianCoupon()
            +---[2.1ms] queryUserCoupons() #38   # 第一次 SQL 查询
            +---[37.2ms] cartesianProduct() #52   # 罪魁祸首!笛卡尔积
            `---[0.0ms] filterBestCoupon() #67

输出里 thread_name + cost 都有了,配合 Spring 的 MDC(日志里加 traceId),你就能精准关联到日志系统看具体哪个用户 / 哪个请求 / 触发原因。生产中这一套链非常值钱。

想看调用栈的"上下文" ------方法被谁调、调用方传了啥参数------用 stack:

java 复制代码
# 看 calculateCartesianCoupon 被谁调用、调用栈完整链路
$ stack com.example.order.service.OrderService calculateCartesianCoupon \
    -n 3 \
    'params[0].size > 50'  # 只看商品/优惠券超过 50 个的请求
​
ts=02:18:11;thread_name=http-nio-8080-exec-127;...
    @OrderController.createOrder()
        @OrderService.applyBestCoupon()
            @OrderService.calculateCartesianCoupon()

2.2 watch 命令:实时监控方法入参返回值

trace 帮你看耗时 ,watch 帮你看数据------调用一次方法,看它实际接收了什么参数、返回了什么结果。这是排查"参数错乱""返参被改""MQ 消费假阴性"问题的利器:

java 复制代码
# 监控 MQ 消费者每次消费消息的 payload 与处理时长
$ watch com.example.mq.OrderConsumer onMessage \
    "{params, returnObj, throwExp, cost}" \
    -x 2 \
    -f  # -f 表示方法结束后再 watch,-n 限制次数(默认无限)
​
Press Q or Ctrl+C to abort.
Affect(class-cnt:1 , method-cnt:1) cost in 56 ms.
ts=2026-08-30 02:24:33; cost=1245ms; 
  params=@JSON[
    {"orderId":"202608300001","amount":89.50,"couponId":["C001","C002","C003"]}
  ]
  returnObj=null  # 注意!返回 null,消费失败
  throwExp=org.springframework.dao.DataIntegrityViolationException

这个输出瞬间告诉你三件事:消息内容是什么、为什么报错、错误类型是啥------拿到这些信息你甚至不用看代码就能猜到是优惠券 ID 重复导致唯一索引冲突。

2.3 反编译 + 上下文:jad 命令的杀手用法

watch/trace 都给出了方法,但你看不懂业务逻辑 ?用 jad 反编译生产代码,方法级实时看:

java 复制代码
# 反编译 OrderService 类完整源码(默认输出到 console)
$ jad com.example.order.service.OrderService
​
ClassLoader: 
  +-org.springframework.boot.loader.LaunchedURLClassLoader@17f4dba
  +-sun.misc.Launcher$AppClassLoader@4b9af9
​
Location: 
/BOOT-INF/classes/com/example/order/service/OrderService.class
​
public OrderService createOrder(OrderDTO order) {
    // 反编译拿到的真实代码片段
    List<Coupon> coupons = couponService.queryByUser(order.getUserId());
    if (CollectionUtils.isEmpty(coupons)) return null;
    return calculateCartesianCoupon(coupons);  // 看,就是这一行
}

配合 watch 时加上 -x 2,能展开两层参数对象,配合 jad 出的代码,你就像在本地 IDE 单步调试一样看清问题。这是 jstack 永远做不到的事情------因为 jstack 只能告诉你栈帧在哪个方法,不能告诉你那个方法现在长什么样。

三、死锁排查:thread -b 一行定位

死锁是最难复现的问题之一------开发环境一切正常,线上偶发,雪崩后大家看日志里没有 ERROR 也找不到头绪。

Arthas 的 thread -b 直接给你当前正在等待锁的线程链:

java 复制代码
$ thread -b
"http-nio-8080-exec-127" Id=237 BLOCKED on java.util.concurrent.locks.ReentrantLock@1f3e2a1 held by:
    "http-nio-8080-exec-130" Id=240
        at com.example.order.service.InventoryService.lockStock(InventoryService.java:85)
        - waiting to lock <0x00000006c1f3a1b0> (a ReentrantLock)
        ...
​
"http-nio-8080-exec-130" Id=240 BLOCKED on java.util.concurrent.locks.ReentrantLock@1f3e2b2 held by:
    "http-nio-8080-exec-127" Id=237
        at com.example.order.service.CouponService.markUsed(CouponService.java:42)
        - waiting to lock <0x00000006c1f3a0a0> (a ReentrantLock)
        ...

一眼看出线程 127 和 130 互相等对方的锁 ------经典的 A 等 B、B 等 A 环。配合 jad 反编译两个类的方法,定位到锁顺序不一致的代码段。

生产实践要点:

  • 先用 thread 看是否真有大量 BLOCKED 线程(>10 个就该警觉)

  • thread --state BLOCKED 单独看所有阻塞线程的分布

  • thread -b 只对当前活跃的死锁链有效;如果死锁已经解开就看不到了,所以告警收到要立刻 attach

四、内存泄漏排查:dashboard + vmtool 黄金组合

CPU 飙高有 watch/trace 治,死锁有 thread -b 治,最难的是内存泄漏------它可能是几个小时内慢慢爬升,直到老年代被打满触发 Full GC 才告警。

第一步:用 dashboard 命令看整体内存水位:

bash 复制代码
$ dashboard
ID    NAME                          GROUP          PRIORI STATE      %CPU    TIME     INTERRUP DAEMON
237   http-nio-8080-exec-127         main           5      RUNNABLE  68.7%   12:48    false    true
...
Memory                    used    total    max    usage     GC
heap                      3456M   4096M    4096M  84.36%    gc.ps_mark...
ps_eden_space             512M    640M     -      80.00%    gc.ps_scavenge.count 1247
ps_old_gen                2800M   2816M    -      99.43%    gc.ps_marksweep.count 24  <-- 老年代 99.43% 满了!
non_heap                  420M    -1       -1     89.83%    code_cache
...

老年代 99.43%,明显异常。第二步用 vmtool 看具体哪个类的实例数最多:

bash 复制代码
# vmtool 是 4.0+ 的新命令,功能类似 jmap -histo 但能远程调用
$ vmtool --action getInstances \
    --className com.example.order.domain.Order \
    --limit 10 \
    --express 'instances.{#this.getClass().getSimpleName() + ":" + #this.id}'
​
@Order[]=[
    @Order[id=ORD20260830001234,userId=U8821,amount=120.5],
    @Order[id=ORD20260830001233,userId=U7712,amount=89.0],
    ...
]

配合 Java 业务侧的"可疑类监控"代码,可以很快锁定内存膨胀的对象:

java 复制代码
// MemoryLeakDetector.java --- JDK 17 + Spring Boot 3.3
// 周期性输出可疑的内存膨胀对象,配合 vmtool 加快排查
@Component
@Slf4j
public class MemoryLeakDetector {
​
    private final MeterRegistry registry;
    private final AtomicLong orderCacheSize = new AtomicLong(0);
    private final AtomicLong unfinishedOrderCount = new AtomicLong(0);
​
    // 提供一个 ConcurrentHashMap 模拟问题代码(实际工作里可能是 Spring Cache / Session)
    private final ConcurrentHashMap<String, Order> suspiciousCache = new ConcurrentHashMap<>();
​
    @PostConstruct
    public void startMonitor() {
        // 每 30 秒打印一次可疑对象大小
        Executors.newSingleThreadScheduledExecutor()
            .scheduleAtFixedRate(this::sampleMemory, 30, 30, TimeUnit.SECONDS);
    }
​
    // 业务方法里调用(实际排查时要找到这个类的位置)
    public void putToCache(String key, Order value) {
        suspiciousCache.put(key, value);
        orderCacheSize.incrementAndGet();
    }
​
    private void sampleMemory() {
        // 1. 报警阈值:超过 5000 个订单就告警
        int size = suspiciousCache.size();
        if (size > 5000) {
            log.warn("[内存监控] suspiciousCache.size={}, 可能存在内存泄漏", size);
        }
​
        // 2. 上报 Micrometer 指标,方便 Grafana 看板查看趋势
        registry.gauge("cache.suspicious.order.size",
            Tags.of("type", "order"), suspiciousCache, Map::size);
        // 3. 触发 dump 阈值------老年代使用 > 90% 时主动 dump heap
        MemoryUsage oldGen = ManagementFactory.getMemoryMXBean()
            .getNonHeapMemoryUsage();
        long usedBytes = ManagementFactory.getMemoryMXBean()
            .getHeapMemoryUsage().getUsed();
        long maxBytes = ManagementFactory.getMemoryMXBean()
            .getHeapMemoryUsage().getMax();
        if (maxBytes > 0 && (double) usedBytes / maxBytes > 0.90) {
            log.error("[内存告警] 老年代使用率 {}%, 触发自动 dump", 
                (double) usedBytes / maxBytes * 100);
            // dump 到 /tmp/heapdump-{时间戳}.hprof
            triggerHeapDump();
        }
    }
​
    private void triggerHeapDump() {
        // 用 HotSpotDiagnosticMXBean 触发主动 dump
        try {
            MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
            HotSpotDiagnosticMXBean hotSpot = ManagementFactory.newPlatformMXBeanProxy(
                mbs, "com.sun.management:type=HotSpotDiagnostic",
                HotSpotDiagnosticMXBean.class);
            hotSpot.dumpHeap("/tmp/heapdump-" + System.currentTimeMillis() + ".hprof", true);
        } catch (Exception e) {
            log.error("dump heap 失败", e);
        }
    }
}

上面这个类有两个用法:

  • 线上埋点 :把可疑的缓存对象(如 Spring @Cacheable 装饰的方法)的 size 上报到 Prometheus

  • 触发 dump :老年代使用 > 90% 时主动 dump,配合 dashboard 输出你就不用手动盯了

拿到 dump 文件后用 MAT(Memory Analyzer Tool)分析 Leak Suspects 报告,看哪些对象持有 GC Root------这是教科书式的内存泄漏排查流程。

五、热修复:jad + mc + redefine 不停机改 Bug

诊断完了是修复。但生产环境重启一次要 20 分钟,回滚兜底又要 40 分钟------这个窗口里服务可用性直接掉 5 个点。

Arthas 提供不停机热修复能力,三步搞定:

java 复制代码
# 1. 反编译生产代码(jad)
$ jad --source-only com.example.order.service.OrderService \
    > /tmp/OrderService.java
​
# 2. 在反编译出来的代码里改一行------比如把 NPE 风险的地方加上空判断
$ vim /tmp/OrderService.java
# 修改 calculateCartesianCoupon 方法:
# 原来:return cartesianProduct(coupons);
# 改成:if (CollectionUtils.isEmpty(coupons)) return Collections.emptyList();
#       return cartesianProduct(coupons);
​
# 3. 用 Arthas 自带的内存编译器(mc)编译
$ mc -d /tmp /tmp/OrderService.java
Memory compiler output: /tmp/com/example/order/service/OrderService.class
​
# 4. 用 redefine 把新 class 加载到运行中的 JVM 里替换原 class
$ redefine /tmp/com/example/order/service/OrderService.class
redefine success, size: 1

整个过程 5 分钟搞定,业务线程零中断 ------下个请求来的时候 OrderService 就已经是新逻辑了。

但这条路有三道魔鬼细节:

坑 表现 解决方案
反编译丢失注释 改完的代码看起来不一样 jad --source-only 只输出方法体,手动加 import
mc 编译失败 提示缺 jar 加 --classLoaderClass 参数指定 Spring Boot ClassLoader
redefine 不生效 接口增删方法做不到 redefine 只能修改方法体,不能增减字段/方法/注解
ClassLoader 隔离 多个同名类不在一个 ClassLoader 用 sc -d com.example.order.service.OrderService 看真实 ClassLoader

重要限制:redefine 不是万能的。你不能新增字段、不能改方法签名、不能改注解。能改的只有"方法体内的语句"。大型功能修复就老老实实走发版流程,热修复适合"小修小补"场景------比如修个 NPE、调整下单限流阈值、加段日志。

六、和 JProfiler / async-profiler 的组合拳

Arthas 的短板是火焰图------trace 命令虽然能看出方法耗时,但不像火焰图那样直观看到调用链路上"哪一段占大头"。生产里我通常是这么组合的:

  • Arthas 定位:找到问题线程 / 问题方法 / 问题入参

  • async-profiler 出火焰图:看完整的 CPU 时间分布

  • JProfiler 出内存:用 Heap Walker 看对象引用链

async-profiler 接入很简单:

java 复制代码
# 启动 async-profiler,attach 到进程 ID
$ ./profiler.sh -d 30 -f /tmp/flamegraph.html <pid>
# 30 秒采样,输出 HTML 火焰图
# 用浏览器打开查看 CPU 时间分布

进阶用法:把 async-profiler 通过 Arthas 的 profiler 命令直接启动,无须外部工具:

bash 复制代码
$ profiler start --event cpu --interval 10000000  # 10us 采样
$ profiler stop --format html --file /tmp/arthas-flame.html

火焰图里"平顶山"就是 CPU 瓶颈的根因------你看那一块调用链越平、越宽,就是热点代码段。我们上次生产 OOM,最终定位到这个调用:

bash 复制代码
...
computeCartesianProduct  [95.3%]
  - hashSet.contains()    [42.1%]
  - list.toArray()         [15.6%]

找到是用 HashSet 反复 contains 判断有 42% 的时间花在 hashCode 计算上------这一查发现 Order#hashCode 里包含了一个读数据库的字段 。修复方案:把 Order 的内存视图和持久化视图分开,问题立刻解决。整个排查到修复 1 小时搞定。

七、建议

这几条是从无数次"凌晨两点告警"里总结出来的:

  1. 生产环境 预装 Arthas**:不要等出问题了再装 arthas-spring-boot-starter。把它加进基础镜像,<exclusions> 排除 arthas-boot(避免污染主应用启动),通过环境变量 ARTHAS_ENABLED=true 控制启动。出问题直接 attach,别在凌晨两点 clone 代码打包。

  2. 敏感信息 红线**:Arthas 的 jad 命令能看到所有业务代码,包括注释、字段值。生产环境部署时一定要做访问控制:或者封装到 Spring Boot Actuator 后只对内网 VPN 暴露,或者用 arthas.properties 配白名单 IP。**别拿裸 Arthas 端口直接挂公网**------这是生产事故的常见导火索。

  3. 热修复不是 银弹**:把 Arthas 热修复当作"** 止血**"工具------临时规避问题、降低影响面,真正的解决方案还是走代码评审 + 自动化测试 + 灰度发布。把热修复代码**当天**沉淀成 P0 issue 跟进,避免"先用 redefine 顶着,回头再修"变成"三个月都没修"。

下一篇 Day85,我们要解决"线上容量评估"问题------你怎么在不影响生产的情况下,验证你的服务能扛住双十一的 10 倍流量?JMeter 压测平台怎么搭?影子库影子表怎么搞?压测报告怎么写才不被领导挑刺?这些问题我们下篇见。


Arthas 不是用来替代代码审查和单元测试的,而是用来在代码还没修好时,让你也能在生产环境活下来的工具。每个 Java 后端工程师的电脑里都应该有一份------不是因为你天天用,而是因为你永远不知道凌晨两点它能救你一命。

相关推荐
for_ever_love__5 小时前
MySQL 锁与死锁讲透:行锁、间隙锁、Next-Key Lock 与排查方法
mysql·lock·行锁·死锁·间隙锁·排查·next-key
万年咸鱼6 小时前
C语言内功心法篇——变量与数据类型
c语言·jvm·算法
rannn_1116 小时前
JVM 垃圾回收面试题全解析:从基础到进阶
java·开发语言·jvm·后端·面试
rannn_1118 小时前
JVM 面试题:类加载过程详解(附高频考点)
java·jvm·后端
Shaoshing18 小时前
ThreadLocal
java·jvm·threadlocal
写后端的胖头鱼19 小时前
一文详解CAS
java·jvm·spring·cas·乐观锁·自旋
估值探索者21 小时前
【Python量化系统工程实战 #08】从脚本到生产:量化系统上线 checklist 的最小闭环
java·开发语言·jvm·python·数据挖掘·数据·股票数据api接口
~木雨1 天前
Java 线程从生到死:创建三式、六态流转、中断协作与死锁活锁饥饿,一篇讲透
java·java并发·虚拟线程·死锁·线程状态·中断机制
Chase_______1 天前
【杂项知识点】一文搞懂 JVM、JRE 与 JDK:从概念混淆到生产部署
java·开发语言·jvm