大家好,我是程序员天天困。
Arthas 系列最后一篇,聊字节码增强。前面七篇从 watch 到 ognl,命令用法全部过完。还剩一个悬而未决的问题:Arthas 到底怎么做到不重启就改代码的? mc+retransform 背后,watch 把监控逻辑"插入"运行中的方法,靠的都是字节码增强这套底层机制。
今天不聊命令,打开引擎盖看看齿轮怎么转。
一、字节码增强的三种思路,为什么 Java Agent 是终极方案
要理解字节码增强,得先回答一个问题:如果让你在 Java 程序跑起来之后修改一个类的方法,你会怎么干?
字节码增强(Bytecode Enhancement):在 Java 字节码层面修改 .class 文件的内容,从而改变类的行为。你可以把它理解为「直接改编译后的机器码」,而不是在源码层包装一层。
业界摸索出三条路,按出现时间排列:
| 思路 | 代表技术 | 时机 | 核心做法 | 能改运行中的类吗 |
|---|---|---|---|---|
| 编译期增强 | AspectJ、Lombok | 编译阶段 | 在 javac 编译时修改 AST / 字节码 | ❌ 代码写完那一刻就定了 |
| 运行时代理 | JDK 动态代理、CGLIB | 运行时 | 生成新的代理类,间接调用原对象 | ⚠️ 能拦截,但原类没变 |
| 运行时字节码替换 | Java Agent + ASM | 运行时 | 直接修改已加载类的字节码 | ✅ 原类当场被替换 |

编译期增强和运行时代理大家都很熟了,不再赘述。真正值得关注的是第三种——Java Agent 的 Instrumentation API 是目前 JVM 唯一能在运行时直接修改已加载类的官方机制,而 ASM 是操作字节码最高效的工具库之一。没有它,Arthas、SkyWalking、Pinpoint 这些工具全都跑不起来。
这条路的原理说起来也直观:JVM 加载类之后,类的字节码存在内存里。Java Agent 技术允许你拿到这些字节码,改完之后再塞回去,JVM 下次调用这个方法时走的就是新逻辑。
二、Java Agent:JVM 留的"后门"
做字节码增强,Java Agent 是你绕不开的东西。
Java Agent(Java 代理技术):JDK 1.5 引入的机制,允许在 JVM 启动时或运行时挂载一个外部 jar 包,通过 Instrumentation API 修改已加载类的字节码。你可以把它理解为「JVM 在设计上留的一个官方插件接口」——它不是漏洞,是官方允许的。
Java Agent 分两种模式:
- 静态 Agent(premain):JVM 启动时通过
-javaagent参数指定,在 main 方法执行前加载。适合做 APM 监控、启动时织入。 - 动态 Agent(agentmain):JDK 1.6 引入,JVM 跑起来之后再 Attach 进去。这个才是 Arthas 依赖的机制——线上系统你不可能每次排查问题都重启一遍。
agentmain(运行时代理入口方法):动态 Agent 的固定入口,签名为 public static void agentmain(String agentArgs, Instrumentation inst)。这个名字和参数一个字母都不能改——JVM 按约定名字来调,写错了不会报编译错,但加载时会静默失败,连一行日志都没有。
动态 Agent 的完整流程分三步:
- 写好一个 jar,里面包含
agentmain方法,MANIFEST.MF 指定Agent-Class - 通过
VirtualMachine.attach(pid)Attach 到目标 JVM 进程 - 调用
loadAgent(jarPath)把 jar 喂给目标进程,目标 JVM 自动执行你的agentmain
这中间最关键的一步是第二步——Attach。它的底层走的是 JVM 的 Attach API,本质上是通过进程间通信把一个 jar 路径告诉目标 JVM,目标 JVM 自己加载并执行。jar 的执行环境是目标进程的 JVM 实例,所以你能访问目标进程里所有的类、对象、ClassLoader,就像你的代码本来就跑在那个进程里一样。
三、ASM 字节码修改:在方法里"插代码"没那么玄
有了 Java Agent,你拿到了字节码。但怎么改?字节码是二进制的 .class 文件,人类没法直接写 0xCAFEBABE。这时候 ASM 出场了。
ASM(字节码操作框架):一个事件驱动的 Java 字节码解析和生成库,按「访问者模式」逐个读取 class 中的字段、方法、指令,让你在读取过程中修改或插入字节码。你可以把它理解成「字节码的 SAX 解析器」——不是一次性把整个 class 加载成对象树(那样太重),而是一条一条事件流过,你在每个事件回调里决定要不要改。

ASM 的核心操作链就四个组件:
- ClassReader:读取
.class文件的字节数组,按 class 结构解析成一系列事件 - ClassVisitor:接收每个事件——访问到类名时回调
visit(),访问到方法时回调visitMethod(),访问到字段时回调visitField() - MethodVisitor:在
visitMethod返回的访问器里,进一步处理方法的指令——每条 JVM 字节码指令都是一个事件 - AdviceAdapter:MethodVisitor 的子类,提供了
onMethodEnter()和onMethodExit()两个便捷钩子,让你在方法入口和出口插代码,不需要自己算栈帧
判断要不要改某个方法,靠的是 visitMethod 里的 name 和 desc 参数。方法描述符(Method Descriptor)是 JVM 内部的方法签名表示法:括号里是参数类型,括号外是返回类型。比如 (II)I 表示两个 int 参数、返回 int,(D)D 表示一个 double 参数、返回 double——I=int、J=long、D=double、V=void、L 开头是对象类型。
可能有人会问:方法描述符里 (II)I 和 (DD)D 这些缩写到底怎么记?
其实不用全背,记住最常用的就行:I=int、J=long(因为 I 被 int 占了)、D=double、F=float、V=void(方法不返回值的时候括号外面就是 V)、Z=boolean。对象类型用 L 开头加分号结尾,比如
Ljava/lang/String;。数组在前面加[。碰上不认识的去查一下就行,写多了自然熟了。
那 AdviceAdapter 到底是怎么在方法返回前插代码的?这里涉及到 JVM 操作数栈的工作方式。你可以把操作数栈想象成一叠盘子:方法执行过程中,各种计算结果像盘子一样往上叠;每条字节码指令要么往上面放盘子,要么从顶上取盘子。
打个比方——餐厅后厨的传菜窗口。做完一道菜,服务员把盘子放到窗口(压栈),下一道菜也放上去,叠成一摞。取菜的服务员每次从最顶上拿(弹栈)。IADD 这条指令就是:「把最顶上两个盘子拿走,把它们的菜合并到一盘,再放回去」。所以在方法退出时,栈顶已经是返回值;你在 onMethodExit 里插入 visitLdcInsn(3000) 压一个新盘子,再 visitInsn(IADD) 把最顶上两个盘子合并——返回值就被改掉了。
说真的,如果你没接触过 JVM 字节码指令集,第一次看到这些 visitInsn、visitLdcInsn 会觉得很抽象。这很正常,汇编程序员看这些会觉得亲切,因为 JVM 指令集本身就是一套面向栈的汇编——只不过它的「CPU」是 JVM 解释器。

JVM 操作数栈示意:IADD 指令将栈顶两个 int 弹出相加,结果压回栈顶。

四、手写实战:从零实现一个迷你 Arthas 热更新
讲完原理,我们来真的。假设有这样一个场景:某电商系统的支付模块里,单笔最大限额硬编码在 PaymentConfig 类里。运营临时要求从 5000 提到 8000,但代码改完走发版至少半天。这时候如果知道怎么手写 Agent,几分钟就能搞定。
我们按四个步骤来。
4.1 被增强的目标程序
先看需要热更新的类,它就是一个死循环不断打印当前限额:
package com.ttk.demo;
public class PaymentConfig {
public static void main(String[] args) throws InterruptedException {
while (true) {
int maxAmount = getMaxSinglePayment();
System.out.println("[PaymentConfig] 单笔限额: " + maxAmount);
Thread.sleep(3_000);
}
}
public static int getMaxSinglePayment() {
return 5000; // ← 需要热更新为 8000
}
}
编译、启动,控制台每 3 秒刷一行 单笔限额: 5000。现在目标程序已经在跑了,我们要在不重启的前提下把 getMaxSinglePayment() 的返回值从 5000 改成 8000。
4.2 Agent 模块:agentmain + ASM 增强逻辑
新建一个 Maven 模块 agent,需要两个依赖:
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.7</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm-commons</artifactId>
<version>9.7</version>
</dependency>
核心类 AgentMain 包含两部分:ClassFileTransformer 实现(负责字节码增强),以及 agentmain 入口(负责注册 Transformer 并触发 retransform):
package com.ttk.agent;
import org.objectweb.asm.*;
import org.objectweb.asm.commons.*;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
public class AgentMain {
public static class PaymentTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
// 只处理目标类,其余原样返回
if (!"com/ttk/demo/PaymentConfig".equals(className)) {
return classfileBuffer;
}
ClassReader reader = new ClassReader(classfileBuffer);
ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES);
ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) {
@Override
public MethodVisitor visitMethod(int access, String name,
String desc, String signature,
String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name,
desc, signature, exceptions);
// 只增强 getMaxSinglePayment() —— 无参、返回 int: ()I
if ("getMaxSinglePayment".equals(name) && "()I".equals(desc)) {
return new AdviceAdapter(Opcodes.ASM9, mv, access, name, desc) {
@Override
protected void onMethodExit(int opcode) {
if (opcode == IRETURN) {
// 栈顶现在是 5000,压入 3000 再 IADD → 8000
mv.visitLdcInsn(3000);
mv.visitInsn(Opcodes.IADD);
}
}
};
}
return mv;
}
};
reader.accept(visitor, ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG);
return writer.toByteArray();
}
}
// JVM 加载 Agent 时调用的固定入口方法
public static void agentmain(String agentArgs, Instrumentation inst) {
System.out.println("[Agent] agentmain 已加载,开始注册 Transformer...");
inst.addTransformer(new PaymentTransformer(), true);
try {
for (Class<?> clazz : inst.getAllLoadedClasses()) {
if ("com.ttk.demo.PaymentConfig".equals(clazz.getName())) {
inst.retransformClasses(clazz);
System.out.println("[Agent] retransform 完成: " + clazz.getName());
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
代码量看着不少,核心逻辑拆开就四步:
transform()被 JVM 回调,传进来classfileBuffer(目标类当前的字节码)ClassReader解析这些字节,ClassVisitor遍历到方法时检查名字和描述符- 匹配到
getMaxSinglePayment()I,AdviceAdapter.onMethodExit()在方法返回前插入ldc 3000+iadd两条指令 agentmain()注册 Transformer 后调用retransformClasses(),JVM 立即用新字节码替换旧方法体
4.3 配置 MANIFEST.MF 和 Maven 打包
写完代码还差关键一步——告诉 JVM 这个 jar 里有 agentmain。在 src/main/resources/META-INF/MANIFEST.MF 中写:
Manifest-Version: 1.0
Agent-Class: com.ttk.agent.AgentMain
Can-Retransform-Classes: true
Can-Redefine-Classes: true
三个字段各有用途:Agent-Class 告诉 JVM 找哪个类里的 agentmain;Can-Retransform-Classes: true 是让 retransformClasses() 可用的关键开关,不加这一行运行时调用 retransform 不会生效——这也是最容易踩的坑。
Maven 打包时要确保 MANIFEST.MF 被用到,且 ASM 依赖也打进去:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
maven-shade-plugin 把 ASM 的 class 一并打进 Agent jar,否则目标进程加载 jar 时找不到 ASM 类会直接报 ClassNotFoundException。
可能有人会问:MANIFEST.MF 写错了或者没指定 Agent-Class,会怎么样?
编译阶段完全不报错,jar 也能正常打出来。但
virtualMachine.loadAgent(jarPath)调用后,目标 JVM 找不到Agent-Class指定的类,agent 静默加载失败——目标程序继续照常跑,就像什么都没发生过。排查时可以看目标进程的标准输出有没有打印 agent 里的日志,或者在 Attach 客户端里捕获loadAgent抛的异常(通常是AgentLoadException)。
4.4 Attach 客户端:把 Agent 送进目标进程
最后一步,启动一个独立的客户端程序,通过 pid 找到目标 JVM,把 jar 灌进去:
package com.ttk.demo;
import com.sun.tools.attach.VirtualMachine;
public class AgentAttacher {
public static void main(String[] args) throws Exception {
String targetPid = "12345"; // 替换为目标进程的 pid
String agentJar = "/path/to/agent-1.0-SNAPSHOT.jar";
VirtualMachine vm = VirtualMachine.attach(targetPid);
try {
vm.loadAgent(agentJar);
System.out.println("[Attacher] Agent 已加载到进程 " + targetPid);
} finally {
vm.detach();
}
}
}
到这里,我们已经从零走完了一整套 Java Agent + ASM 字节码增强的流程:写 Agent → 打包 → Attach → retransform → 生效。不需要重启,不需要重新部署。

ClassFileTransformer(类文件转换器):java.lang.instrument 包中的接口,Agent 通过实现它的 transform() 方法来拦截和修改目标类的字节码。你可以把它理解为「JVM 在加载或重定义每个类之前必经的一道检查站」——通过了,但字节码可能已经被你改了。
五、回到 Arthas:100 行的手写 Agent,它一行命令全干了
现在回过头看你已经在 Arthas 里用了很久的 mc + retransform:
mc /path/to/FixedClass.java -d /path/to/output
retransform /path/to/output/com/ttk/demo/FixedClass.class
两行命令。而我们刚才手写了:
- 一个
ClassFileTransformer实现类(约 40 行) - 一个
agentmain入口方法(约 15 行) - MANIFEST.MF 配置 + Maven shade 插件配置(约 20 行)
- 一个
VirtualMachine.attach客户端(约 10 行)
总共接近 100 行代码和配置。
Arthas 帮你省掉了什么:
| 手写 Agent 要做的事 | Arthas 怎么做 |
|---|---|
| 写 ClassFileTransformer,用 ASM 逐方法匹配+修改指令 | 你改 .java 源码就行,mc 内部帮你走 javac 编译生成正确字节码 |
| 配 MANIFEST.MF,指定 Agent-Class | 不需要,Arthas 启动时已内置 Agent |
| maven-shade-plugin 打 fat jar | 不需要,mc 直接在目标进程内存里编译 |
| 写 Attach 客户端(VirtualMachine) | java -jar arthas-boot.jar 就是 Attach 客户端 |
手动调用 retransformClasses() |
retransform 命令一行搞定 |
| 处理 ClassLoader 依赖 | mc -c 指定 classLoaderHash |
Arthas 本质上就是一个精心封装的 Java Agent + ASM 字节码增强工具——它把 Agent 机制、字节码改写、Attach 通信全包进了那一个 arthas-boot.jar,然后暴露出你熟悉的 watch / trace / mc / retransform 这些命令。底层活儿脏,Arthas 替你干了。
这也是为什么 Arthas 能动态 watch 任何方法——它的原理和我们刚才手写的一样:在目标方法入口和出口插入监控的字节码指令,调用完成时把参数和返回值收集起来输出。magic 不在魔法,在 ASM 的 AdviceAdapter。
总结
字节码增强不是什么黑魔法——Java Agent 是 JVM 留的官方插件接口,ASM 是操作字节码的工具箱,两者加在一起就是你用了这么久的 mc+retransform、watch、trace 的底层。
Arthas 系列八篇到这里就全部完结了。从 watch 到 ognl,从 mc+retransform 到今天手写 Agent,我们走完了诊断→定位→热修→字节码增强底层原理的完整链路。掌握了前面七篇,你是一个高效的线上问题定位者;看完今天这篇,你能理解每一个 Arthas 命令背后的齿轮是怎么转的。
我是程序员天天困,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你有没有在生产上用过热更新?用的是 Arthas 还是自己写的 Agent?