![图片[1]-33 深入理解Java Agent与字节码注入:原理与实践-速优课](http://www.suyouke.com/wp-content/uploads/2026/07/doubao_img_2304x1728_20260722_174942-1024x768.png)
本文导读
你可能听说过 Java Agent,也可能用过 Arthas、SkyWalking 这类基于字节码注入的诊断或监控工具。它们是怎么做到在不修改源代码的情况下,给程序动态添加功能的呢?答案就是 Java Agent 和字节码注入技术。
本文将带你深入了解 Java Agent 的工作原理,以及基于它的字节码注入技术。我们会从最简单的 premain 方法开始,一步步讲到 ClassFileTransformer、ASM 字节码操作,以及基于字节码注入的 Profiler 实现。
通过阅读本文,你将了解:
- Java Agent 的两种启动方式:premain 与 agentmain
- 如何通过 ClassFileTransformer 拦截类加载事件
- 如何使用 ASM 框架进行字节码注入
- redefine 与 retransform 的区别与应用场景
- 基于字节码注入的 Profiler 会遇到哪些技术挑战
- 什么是观察者效应,它如何影响性能数据的准确性
一、Java Agent 基础
1.1 premain:在 main 之前执行
说到 Java Agent,大家最先想到的应该就是 premain 方法了。顾名思义,premain 就是在 main 方法之前执行的方法。
package org.example;
public class MyAgent {
public static void premain(String args) {
System.out.println("premain");
}
}
注意,premain 方法接收的是一个字符串参数,而不是像 main 方法那样的字符串数组。
要让这个方法以 Java Agent 的方式运行,我们需要把它打包成 jar 包,并在 MANIFEST.MF 中指定 Premain-Class:
# 注意第一条命令会写入两行,最后有一个空行
$ echo 'Premain-Class: org.example.MyAgent
' > manifest.txt
$ jar cvmf manifest.txt myagent.jar org/
$ java -javaagent:myagent.jar HelloWorld
premain
Hello, World
可以看到,premain 方法确实在 Hello World 输出之前执行了。
1.2 agentmain:运行时动态加载
除了在启动时通过 -javaagent 参数指定,Java Agent 还可以通过 Attach API 在程序运行过程中动态加载。
import java.io.IOException;
import com.sun.tools.attach.*;
public class AttachTest {
public static void main(String[] args)
throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {
if (args.length <= 1) {
System.out.println("Usage: java AttachTest <PID> /PATH/TO/AGENT.jar");
return;
}
VirtualMachine vm = VirtualMachine.attach(args[0]);
vm.loadAgent(args[1]);
}
}
通过 Attach API 加载的 Agent,执行的不再是 premain 方法,而是 agentmain 方法:
public class MyAgent {
public static void agentmain(String args) {
System.out.println("agentmain");
}
}
相应地,manifest 文件中需要配置 Agent-Class:
$ echo 'Agent-Class: org.example.MyAgent
' > manifest.txt
$ jar cvmf manifest.txt myagent.jar org/
运行效果:
$ java HelloWorld
Hello, World
$ jps
$ java AttachTest <pid> myagent.jar
agentmain
# 最后一句输出来自运行 HelloWorld 的 Java 进程
1.3 多个 Agent 的执行顺序
JVM 并不限制 Java Agent 的数量。你可以:
- 在命令行中指定多个
-javaagent参数 - 通过 Attach API 加载多个 Agent
JVM 会按照定义顺序或 attach 的顺序逐个执行这些 Agent。
二、字节码注入详解
2.1 ClassFileTransformer:类加载拦截器
如果 Java Agent 只是在 main 前后打印一些字符串,那它也没什么特别的——这些逻辑完全可以写到 main 方法里。Java Agent 真正强大的地方在于它提供了 instrumentation 机制,允许我们拦截类加载事件,并修改类的字节码。
我们先来看一个简单的例子:
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.printf("Loaded %s: 0x%X%X%X%X\n", className, classfileBuffer[0], classfileBuffer[1],
classfileBuffer[2], classfileBuffer[3]);
return null;
}
}
}
注意看,premain 方法多了一个 Instrumentation 类型的参数。通过它,我们可以注册类加载事件的拦截器。拦截器需要实现 ClassFileTransformer 接口,重写 transform 方法。
transform 方法的参数:
classfileBuffer:正在加载的类的原始字节码(byte 数组)- 返回值:修改后的字节码
在上面的例子中,我们只是打印了 class 文件的前四个字节——也就是 Java class 文件的魔数 0xCAFEBABE。
如果 transform 方法返回 null 或者抛出异常,JVM 会使用原始的字节码继续加载。如果返回了新的 byte 数组,JVM 就会用这个修改后的字节码来加载类。
基于这个机制,我们可以实现字节码注入(bytecode instrumentation)——往正在加载的类中插入额外的字节码。
2.2 使用 ASM 注入字节码
直接操作字节码数组太麻烦了,通常我们会用字节码操作库来完成。最常用的就是 ASM 框架。
下面我们用 ASM 的 Tree API(面向对象的方式)来演示如何在方法入口注入代码:
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;
import org.objectweb.asm.tree.*;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer, Opcodes {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
ClassReader cr = new ClassReader(classfileBuffer);
ClassNode classNode = new ClassNode(ASM7);
cr.accept(classNode, ClassReader.SKIP_FRAMES);
for (MethodNode methodNode : classNode.methods) {
if ("main".equals(methodNode.name)) {
InsnList instrumentation = new InsnList();
instrumentation.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"));
instrumentation.add(new LdcInsnNode("Hello, Instrumentation!"));
instrumentation.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false));
methodNode.instructions.insert(instrumentation);
}
}
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
classNode.accept(cw);
return cw.toByteArray();
}
}
}
这段代码的逻辑很清晰:
- 用
ClassReader读取原始字节码,转换成ClassNode对象 - 遍历类中的所有方法(
MethodNode) - 当遇到名为
main的方法时,在方法入口处注入System.out.println("Hello, Instrumentation!"); - 用
ClassWriter把修改后的ClassNode写回 byte 数组
运行效果:
$ java -javaagent:myagent.jar -cp .:/PATH/TO/asm-7.0-beta.jar:/PATH/TO/asm-tree-7.0-beta.jar HelloWorld
Hello, Instrumentation!
Hello, World!
可以看到,注入的代码在 Hello, World! 之前执行了。
2.3 redefine 与 retransform
除了在类加载时拦截,Java Agent 还提供了两个针对已加载类的操作:redefine 和 retransform。
redefine(重定义):
- 舍弃类原本的字节码,替换成用户提供的新字节码
- 比较危险,一般用于修复出错的字节码
retransform(重转换):
- 重新调用所有已注册的
ClassFileTransformer的transform方法 - 主要应用场景有两个:
- 处理提前加载的类:在
premain或agentmain执行之前,JVM 已经加载了不少类。这些类没有经过 transformer 处理,也就没有被注入。使用retransform可以对这些已加载的类重新进行注入。 - 移除部分注入:在有多个 Agent、多层注入的情况下,如果移除了某个 transformer,可以通过
retransform从原始字节码开始重新注入,从而去掉被移除 transformer 的影响。
- 处理提前加载的类:在
2.4 底层实现:JVMTI
Java Agent 的这些功能都是通过 JVMTI Agent(也就是 C Agent)来实现的。
JVMTI(JVM Tool Interface)是一个事件驱动的工具接口。通常我们会在 C Agent 的入口方法 Agent_OnLoad 处注册各种事件的钩子(hook)方法。当 JVM 触发这些事件时,就会调用对应的钩子方法。
JNIEXPORT jint JNICALL
Agent_OnLoad(JavaVM *vm, char *options, void *reserved);
比如,我们可以为 ClassFileLoadHook 事件设置钩子,在 C 层面拦截所有的类加载事件。Java Agent 本质上就是对 JVMTI 的 Java 层封装。
三、基于字节码注入的 Profiler
3.1 基本思路
字节码注入的应用非常广泛,比如代码覆盖率工具、各种性能分析工具(Profiler)都是基于它实现的。
基本思路是:定义一个运行时类,然后在特定的程序行为前后,注入对这个运行时类方法的调用,用来记录该行为的发生。
举个例子,我们来实现一个简单的对象分配统计工具。
首先是运行时统计类:
package org.example;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class MyProfiler {
public static ConcurrentHashMap<Class<?>, AtomicInteger> data = new ConcurrentHashMap<>();
public static void fireAllocationEvent(Class<?> klass) {
data.computeIfAbsent(klass, kls -> new AtomicInteger())
.incrementAndGet();
}
public static void dump() {
data.forEach((kls, counter) -> {
System.err.printf("%s: %d\n", kls.getName(), counter.get());
});
}
static {
Runtime.getRuntime().addShutdownHook(new Thread(MyProfiler::dump));
}
}
这个类维护了一个 ConcurrentHashMap,用来统计每个类创建了多少个实例。程序退出时通过 ShutdownHook 打印统计结果。
然后是 Java Agent 的注入逻辑:
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;
import org.objectweb.asm.tree.*;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer, Opcodes {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (className.startsWith("java") ||
className.startsWith("javax") ||
className.startsWith("jdk") ||
className.startsWith("sun") ||
className.startsWith("com/sun") ||
className.startsWith("org/example")) {
// 跳过 JDK 类和 Profiler 自身的类
return null;
}
ClassReader cr = new ClassReader(classfileBuffer);
ClassNode classNode = new ClassNode(ASM7);
cr.accept(classNode, ClassReader.SKIP_FRAMES);
for (MethodNode methodNode : classNode.methods) {
for (AbstractInsnNode node : methodNode.instructions.toArray()) {
if (node.getOpcode() == NEW) {
TypeInsnNode typeInsnNode = (TypeInsnNode) node;
InsnList instrumentation = new InsnList();
instrumentation.add(new LdcInsnNode(Type.getObjectType(typeInsnNode.desc)));
instrumentation.add(new MethodInsnNode(INVOKESTATIC, "org/example/MyProfiler", "fireAllocationEvent",
"(Ljava/lang/Class;)V", false));
methodNode.instructions.insert(node, instrumentation);
}
}
}
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
classNode.accept(cw);
return cw.toByteArray();
}
}
}
这段代码的逻辑是:
- 跳过 JDK 类和 Profiler 自身的类
- 遍历每个方法的每条指令
- 遇到
NEW字节码时,在它后面插入对MyProfiler.fireAllocationEvent()的调用
3.2 技术难点一:避免无限递归
你可能注意到了,上面的代码特意跳过了 JDK 类和 Profiler 自身的类。为什么要这么做?
因为如果不跳过,很容易造成死循环调用,最终抛出 StackOverflowError。
举个例子:假设我们在 PrintStream.println 方法入口注入了 System.out.println("blahblah"),而 out 本身就是 PrintStream 的实例。执行注入代码时又会调用 println,然后又触发注入,然后又调用 println…… 无限递归就这样发生了。
怎么解决这个问题?关键是设置一个线程私有的标识位,用来区分当前是在执行应用代码还是注入代码。
当即将执行注入代码时:
- 先检查标识位,如果已经是注入代码上下文,就直接返回,不执行注入逻辑
- 如果不是,就设置标识位,执行注入代码,执行完再恢复标识位
这样就能避免无限递归了。
3.3 技术难点二:命名空间隔离
字节码注入的另一个难点是命名空间问题。
很多应用程序本身就依赖 ASM 这样的字节码库。如果我们的注入逻辑也依赖 ASM,就可能出现版本冲突——注入逻辑用的是新版本的 ASM,而应用程序用的是旧版本。
JDK 本身也使用了 ASM(比如用来生成 Lambda 表达式的适配器类)。JDK 的做法是把整个 ASM 库重命名,给所有类加上 jdk.internal 包名前缀。
但我们不好直接改 ASM 的包名,所以通常的做法是用自定义类加载器来隔离命名空间,让注入逻辑使用自己的类加载器加载自己的依赖,和应用程序的类加载器隔离开。
3.4 技术难点三:观察者效应
基于字节码注入的工具还有一个根本性的问题——观察者效应(Observer Effect):观测行为本身会影响被观测对象的状态。
举几个例子:
例 1:方法执行时间统计
假设 A 方法调用了 B 方法,两个方法都被注入了计时逻辑。那么统计 B 方法执行时间的注入代码本身的耗时,会被算进 A 方法的执行时间里。最终 A 方法的统计数据会比实际偏大。
例 2:对象分配统计与逃逸分析
我们知道,JIT 的逃逸分析可能会优化掉一些不会逃逸的对象创建。但字节码注入不会优化掉统计逻辑——即使对象创建被优化了,fireAllocationEvent 的调用依然存在。结果就是:我们统计到了一些实际上并没有发生的对象分配。
例 3:注入代码影响内联决策
注入的代码还可能影响方法内联的决策。比如某个构造器原本会被内联,对象不会逃逸;但注入了统计代码后,构造器可能就不满足内联条件了,导致对象逃逸。结果就是:原本能被逃逸分析优化掉的对象创建现在无法优化了,我们又统计到了原本不会发生的对象分配。
总而言之,使用字节码注入开发 Profiler 时,要辩证地看待收集到的数据。它反映的是被注入情况下程序的执行状态,而不是程序原本的真实执行状态。
四、面向方面编程(AOP)
说到字节码注入,就不得不提面向方面编程(Aspect-Oriented Programming,AOP)。
AOP 的核心理念是:
- 定义切入点(Pointcut):在哪些地方插入额外逻辑
- 定义通知(Advice):插入什么额外逻辑
- 程序控制流中所有匹配切入点的连接点(Joinpoint)都会执行通知代码
举个例子:定义一个”所有方法入口”的切入点,然后指定”打印方法名”的通知。那么每个具体的方法入口就是一个连接点,方法执行时都会打印自己的名字。
字节码注入是实现 AOP 的常用方式之一。比如 Spring AOP 的运行时增强、AspectJ 的编译时和类加载时增强,底层都是字节码操作。
前面我们实现的对象分配统计,其实也可以看作一种 AOP:
- 切入点:所有
new字节码之后 - 通知:调用
MyProfiler.fireAllocationEvent(<Target>.class)
总结与思考
本文要点回顾
- Java Agent 有两种启动方式:
premain:启动时通过-javaagent指定,在main之前执行agentmain:运行时通过 Attach API 动态加载
- 字节码注入的核心是 ClassFileTransformer:
- 通过
Instrumentation.addTransformer()注册拦截器 transform方法接收原始字节码,返回修改后的字节码- 返回 null 或抛异常表示不修改
- 通过
- ASM 是最常用的字节码操作库:
- Tree API 以面向对象的方式操作字节码
ClassReader读取、ClassNode表示、ClassWriter写出
- redefine 和 retransform 的区别:
- redefine 直接替换整个字节码
- retransform 重新运行所有 transformer
- 基于字节码注入的 Profiler 面临三大挑战:
- 无限递归:需要用线程私有标识位避免
- 命名空间冲突:需要用自定义类加载器隔离
- 观察者效应:注入本身会影响程序行为,数据仅供参考
思考与实践
- 你使用过哪些基于 Java Agent 或字节码注入的工具?它们分别解决了什么问题?
- 思考一下,如果要在方法出口处注入代码(包括正常返回和异常抛出两种情况),应该怎么实现?
- 观察者效应是字节码注入工具无法完全避免的问题,你觉得在实际使用中应该如何应对?
Java Agent 和字节码注入是一项非常强大的技术,掌握它不仅能帮助我们更好地理解各类诊断工具的工作原理,也能在需要时自己动手开发定制化的工具。








请登录后查看评论内容