主题
Java Agent 与字节码增强
一句话:Java Agent 在类加载或重定义的那一刻把探针织进字节码,让业务代码不改一行就能被观测;它换来的是零侵入,代价是类加载可见性、栈帧合法性和失败降级三条线必须同时守住。
适用 JDK 17 / JDK 21,ASM 9.x。Attach 的连接过程见 10-Attach机制与运行时注入,本篇从拿到 Instrumentation 之后讲起。
为什么需要
APM、链路追踪、覆盖率、故障注入都要求「不改业务代码」。源码埋点要改代码、走发布;Spring AOP 只能拦住 bean 的外部调用;编译期插件要求改构建。Agent 是少数能在类进入 JVM 的那一刻统一改写字节码的位置。
代价是:你的代码跑在别人的类加载流程里。改错一条指令,业务类就 VerifyError;放错一个 jar,业务就 NoClassDefFoundError;异常没兜住,进程可能直接起不来。难点从来不是「插代码」,而是「插完还能正常跑」。
核心机制
两个入口
| 入口 | 触发方式 | 时机 | manifest 键 |
|---|---|---|---|
premain(String, Instrumentation) | -javaagent:agent.jar | main 之前 | Premain-Class |
agentmain(String, Instrumentation) | Attach 后 loadAgent | JVM 运行中 | Agent-Class |
manifest 至少声明 Premain-Class(或 Agent-Class)和 Can-Retransform-Classes: true;后者是 addTransformer(t, true) 和 retransformClasses 的前提,Can-Redefine-Classes: true 对应 redefineClasses,少了对应键 API 直接抛 UnsupportedOperationException。
premain 在应用主线程启动前同步执行;agentmain 在 Attach 的加载线程上同步执行,长耗时初始化会挡住后续 attach。两者的异常都要自己 catch:premain 抛出可能导致启动失败,agentmain 抛出则让这次注入静默失效。
Instrumentation 的能力边界
addTransformer(t)只作用于之后加载的类;addTransformer(t, true)额外允许它在重转换时再次被调用。retransformClasses(Class...)把类退回原始字节码,再依次跑所有可重转换的 transformer。能改方法体、常量池、属性;不能增删字段/方法、不能改签名、不能改继承。redefineClasses直接提供字节码、绕过 transformer 链,限制同上。它没有废弃,但通常用 retransform,因为后者复用转换链、可重复执行。- 两者都是 VM 操作,会进安全点;只对
isModifiableClass为 true 的类有效,数组、基本类型、hidden class 不行。
一个容易混淆的点:首次加载时 transformer 返回什么合法类都行,加字段、加方法都可以,因为那是这个类第一次被定义;retransform/redefine 才受「结构不变」约束。不少运行期 attach 的方案因此不在字节码里加字段,而把探针数组放到外部映射。
ClassFileTransformer
transform(loader, className, classBeingRedefined, pd, bytes) 的约定:
className是内部格式com/example/Foo,不是点分名;初始加载时classBeingRedefined为null,重转换时是被重定义的Class。返回null表示不修改;要改就返回新数组,不得原地修改入参 buffer。- 多个 transformer 按注册顺序串联,前一个的输出是后一个的输入。
- transformer 抛异常等价于返回
null:类照常加载,增强悄悄丢失。所以自己 catch 并记日志,别指望异常能拦住什么。
类加载器隔离
被增强的业务类里会出现 INVOKESTATIC demo/Probe.hit 这样的直接引用,它由定义业务类的加载器解析。如果 Probe 只在 agent 自己的加载器里,业务类就找不到它,运行到探针时 NoClassDefFoundError。
因此通常拆两层:探针壳(无依赖,几十行)用 appendToBootstrapClassLoaderSearch 放进 bootstrap,所有加载器可见;agent 核心(ASM、配置、上报)用自定义 URLClassLoader 以 platform loader 为父、child-first 加载,与业务依赖隔离。premain 里先放探针壳,再用反射调核心的 start,这样 agent 入口类不直接依赖核心。把整个 agent(含 ASM)塞进 bootstrap 是常见的过度操作:bootstrap 加载的类不会被卸载,ASM 对所有业务可见,业务自带同名库时直接打架。探针壳只做转发,通过静态 holder 或反射把调用交给核心。Spring Boot fat jar 由 LaunchedURLClassLoader 加载、父加载器是 system loader,appendToSystemClassLoaderSearch 在纯 -cp 场景够用,但不是所有容器都成立;要覆盖所有加载器,用 bootstrap。
ASM 流水线
ClassReader 把 class 文件解析成事件流,ClassVisitor 边收边转发,MethodVisitor 在方法事件里插指令,ClassWriter 再把事件写成字节。全程不建 AST,所以快;代价是栈、局部变量和帧都要你自己保证一致。
new ClassWriter(cr, flags)传入 reader 会复用常量池,未访问的方法体原样拷贝;不传则全量重写,慢且容易丢属性。COMPUTE_MAXS只重算max_stack/max_locals;COMPUTE_FRAMES还重算栈映射帧,更贵,且默认的getCommonSuperClass会Class.forName加载类——在类加载锁里加载别的类,可能死锁或拿到错误版本。需要时重写它,用目标类的加载器解析,失败时退到java/lang/Object。- Java 7(class 版本 51)起,每个跳转目标都要有栈映射帧(stack map frame)。只在方法入口插一条不产生新跳转的调用,
COMPUTE_MAXS就够;一旦新增分支目标,必须COMPUTE_FRAMES或手工visitFrame。 - 新增局部变量槽要同步
max_locals;LocalVariableTable是调试信息,缺失不会VerifyError,但排障和报告会丢名字。ASM 版本要跟得上 class 文件版本,报Unsupported class file major version就是 ASM 落后于 JDK,升级 ASM,不要绕过。
探针设计
探针要满足三条:不改语义、不分配对象、不吞异常。
- 只写常量到数组,不动栈上的返回值:
GETSTATIC Probe.HIT : [Z+BIPUSH id+ICONST_1+BASTORE,几条指令、无锁、无分配;IRETURN前栈顶是返回值,探针只能压入并消费自己的常量,净效果为 0。反射、字符串拼接、Map查找放进热点方法都是灾难。 - 异常路径也要记:只做行覆盖时,入口和行号标签各写一次即可;要做方法耗时,得用 try/finally 或 catch-all handler,否则
ATHROW路径漏记。 - 跳过不该碰的方法:
abstract/native没有 Code 属性;bridge 和 synthetic 是编译器生成的转发;lambda 体是lambda$...的 synthetic 方法,增强它通常只是重复计数。 - 构造器与幂等:
super()/this()之前this未初始化,不能读写实例成员,静态数组写入本身合法但插在super()之后更稳;retransform 从原始字节重新开始,天然不叠加,但多个 transformer 串联或同一 agent 注册两次仍会插两遍,可用常量标记跳过。
性能与稳定性
增强发生在类加载线程上,通常在类加载锁内:单个类 transform 慢,会拖住同一加载器上的其他类;COMPUTE_FRAMES 在锁内加载父类,是最典型的死锁来源;类被反复 retransform 还会带来元空间抖动。运行时开销来自每次方法调用的几次数组写,热点方法上还要关注同一 cache line 的写争用。失败策略只有一条:降级为不增强。transform 里 catch Throwable 返回 null,premain/agentmain 整体 try-catch,任何异常都不冒泡到 JVM。业务少一份监控可以接受,起不来不可以。
最小上手
JDK 17 + ASM 9.7。Agent、Probe、App 都编进同一个目录,App 由应用加载器加载,因此能直接看到 Probe;生产里探针壳不可见时再放 bootstrap。
java
// Agent.java:省略 import 与错误处理
public class Agent {
public static void premain(String args, Instrumentation inst) {
inst.addTransformer((l, name, redef, pd, bytes) -> {
if (!"demo/App".equals(name)) return null;
ClassReader cr = new ClassReader(bytes);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);
cr.accept(new ClassVisitor(Opcodes.ASM9, cw) {
public MethodVisitor visitMethod(int acc, String n, String d, String s, String[] e) {
MethodVisitor mv = super.visitMethod(acc, n, d, s, e);
if ((acc & Opcodes.ACC_ABSTRACT) != 0 || n.equals("<init>")) return mv;
return new MethodVisitor(Opcodes.ASM9, mv) {
public void visitCode() {
mv.visitLdcInsn(1);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "demo/Probe", "hit", "(I)V", false);
}
};
}
}, 0);
return cw.toByteArray();
});
}
}bash
# Probe.hit(int) 在示例里打印,生产探针只写数组
javac -cp asm-9.7.jar -d out Agent.java demo/Probe.java demo/App.java
jar --create --file agent.jar --manifest MANIFEST.MF -C out .
java -javaagent:agent.jar -cp out demo.App
# probe 1
# helloProbe(demo 包)只有一行 public static void hit(int id) { System.out.println("probe " + id); }。看到 probe 1 说明方法入口已被改写;javap -c -p -cp out demo.App 能看到 invokestatic demo/Probe.hit。
生产动作
- 按需开启、能力最小化:
-javaagent只加在需要采集的实例上,增强范围用配置中心开关控制;不需要重转换就不写Can-Retransform-Classes,也不开Can-Redefine-Classes。 - 过滤先便宜后昂贵:先
startsWith前缀判断,再跑预编译的正则;不要遍历getAllLoadedClasses()全量 retransform。 - 元空间兜底:设
-XX:MaxMetaspaceSize;用jcmd <pid> VM.metaspace和jcmd <pid> VM.classloader_stats看类数量。 - 可回滚:
removeTransformer后对目标类retransformClasses能退回原始字节码;但 agent 的类、线程、连接不会消失,彻底回滚要重启。 - JDK 21 动态加载:JEP 451 对运行期 attach agent 告警,需要时显式加
-XX:+EnableDynamicAgentLoading,并当作受控变更。
| 现象 | 证据 | 判断 |
|---|---|---|
NoClassDefFoundError: demo/Probe | -Xlog:class+load=info 看 Probe 由谁加载 | 探针壳不在业务加载器可见范围,改放 bootstrap |
VerifyError: Expecting a stackmap frame | 异常中的类名与方法 | 新增了跳转却没重算帧,改用 COMPUTE_FRAMES 或去掉跳转 |
| 启动变慢、类加载卡住 | jcmd <pid> Thread.print 是否停在 ClassLoader.loadClass | transform 在锁内做了重活或加载了别的类 |
| 元空间持续上涨 | jcmd <pid> VM.metaspace | 反复 retransform 或增强类过多 |
边界
- native 方法改不了:没有 Code 属性,transformer 无从下手;JDK 核心类即使
isModifiableClass为 true 也不建议增强,它们的假设写在 JVM 和运行库里。 - 已加载类不会自动生效:不调
retransformClasses,新注册的 transformer 只影响之后的加载;运行期也改不了字段、方法和继承结构。 - JPMS 与安全管理器:JDK 9+ agent 在无名模块,读不到其他模块的非导出包,需要
--add-opens/--add-exports或 manifest 的Add-Opens;SecurityManager 在 JDK 17 已废弃待移除,不能当隔离手段。 - agent 不是插件框架,也不该承载业务逻辑:能摘 transformer、能回滚字节码,但 agent 的类、线程和连接不会卸载;诊断可以,核心逻辑必须回到代码和发布流程,否则一次加载失败就是一次线上事故。
- 测试环境的无侵入 Mock:通过 retransform 在被 Mock 方法入口插入提前返回(或抛异常)的字节码,业务代码零改动,适合联调时替换外部依赖。它只适合测试/联调环境,生产必须禁用或强鉴权后按类/方法白名单临时开启,并且开关要能单独回滚。
- GraalVM native image 不适用:没有传统类加载和 Instrumentation。
讲清楚
Agent 是在类加载那一刻改字节码的钩子:
premain管启动前,agentmain管运行中。拿到Instrumentation后能注册 transformer、能重转换已加载的类,但重转换只能改方法体,不能加字段方法或改继承;首次加载不受这个限制。它适合诊断和观测,不适合承载业务逻辑。工程难点在类加载器:探针壳要放进 bootstrap 才能被业务类看见,agent 依赖要用独立加载器隔离。ASM 把字节码读成事件流再写回,插了跳转就必须重算栈帧。最后一条原则:增强失败一律降级为不增强,绝不让业务起不来。