上篇回顾: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 这三板斧就够了。确实够用,但不好用------它们的痛点有三:
-
看到线程名却看不到方法 :jstack 输出是线程堆栈,但代码经过多层框架封装,从
AbstractHandlerMethodAdapter.invoke这种方法名里看不出是谁调的业务方法 -
dump 太重:jmap -dump 一执行,几十秒到几分钟内进程几乎停摆,对线上是二次打击
-
改不动代码:发现问题想立刻修?对不起,重启走流程,发版回滚搞定是 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 小时搞定。
七、建议
这几条是从无数次"凌晨两点告警"里总结出来的:
-
生产环境 预装 Arthas**:不要等出问题了再装 arthas-spring-boot-starter。把它加进基础镜像,
<exclusions>排除 arthas-boot(避免污染主应用启动),通过环境变量ARTHAS_ENABLED=true控制启动。出问题直接 attach,别在凌晨两点 clone 代码打包。 -
敏感信息 红线**:Arthas 的
jad命令能看到所有业务代码,包括注释、字段值。生产环境部署时一定要做访问控制:或者封装到 Spring Boot Actuator 后只对内网 VPN 暴露,或者用arthas.properties配白名单 IP。**别拿裸 Arthas 端口直接挂公网**------这是生产事故的常见导火索。 -
热修复不是 银弹**:把 Arthas 热修复当作"** 止血**"工具------临时规避问题、降低影响面,真正的解决方案还是走代码评审 + 自动化测试 + 灰度发布。把热修复代码**当天**沉淀成 P0 issue 跟进,避免"先用 redefine 顶着,回头再修"变成"三个月都没修"。
下一篇 Day85,我们要解决"线上容量评估"问题------你怎么在不影响生产的情况下,验证你的服务能扛住双十一的 10 倍流量?JMeter 压测平台怎么搭?影子库影子表怎么搞?压测报告怎么写才不被领导挑刺?这些问题我们下篇见。
Arthas 不是用来替代代码审查和单元测试的,而是用来在代码还没修好时,让你也能在生产环境活下来的工具。每个 Java 后端工程师的电脑里都应该有一份------不是因为你天天用,而是因为你永远不知道凌晨两点它能救你一命。