Java 提供了动态修改字节码的能力,而 Java Agent 提供了外挂修改的能力,能不动已有的 jar 包,在运行时动态修改 jar 内的字节码。
本文会从零构建一个 Java Agent,让Jar 包在运行时打印每一个调用的方法名,其中涉及到 Java Agent 的整体结构,ASM 库的基础操作,文章较长,建议跟着走一遍。
Java Agent 项目结构
先创建如下目录结构:
. ├── pom.xml └── src └── main ├── java │ └── me │ └── lotabout │ └── Launcher.java └── resources └── META-INF └── MANIFEST.MF
|
premain 与 agentmain
我们知道常规 Java 程序的入口是 main
函数,而 Java Agent 在不同的架构模式下有不同的入口:
- 静态加载入口为
premain
:如 java -javaagent:my-agent.jar -jar app.jar
,在启动 Jar 包时指定要加载的 agent,权限较高。
- 动态加载入口为
agentmain
:已经通过 java -jar app.jar
等方式运行的 JVM,可以动态 Attach 后加载 Agent,权限较低,如无法新增属性、方法等。
两个方法定义如下(定义放在哪个类中都可以,下面会在 MANIFEST.MF
文件中声明):
Launcher.javapackage me.lotabout; public class Launcher { public static void premain(String agentArgs, Instrumentation inst) {} public static void agentmain(String agentArgs, Instrumentation inst) {} }
|
- 参数中的
agentArgs
是传递给 Agent 的参数。例如这样调用 java -javaagent:my-agent.jar=my-agent-args app.jar
,则 my-agent.jar
中的
premain
函数中的 agentArgs
参数的值,就是字符串 "my-agent-args"
。
- 参数中的
Instrumentation
是 Java 提供的修改字节码的 API. 通常 Java Agent
作者的任务,就是利用 Instrumentation
定位到希望修改的类并做出修改。
另外容易踩坑的一点是,调用 Instrumentation.addTransformer
添加的 transformer
默认只对“ 未来加载的类 ”才会生效。而动态加载(agentmain
)通常是在应用程序启动后才加载,就会出现添加的 transformer 不生效的情况。对静态加载(premain
)则一般不会有这个问题,因为它是在 main
函数之前加载的,
动态加载(agentmain
) 如果想修改 main
中就已经加载的类,则需要在添加
transformer 再调用Instrumentation#retransformClasses
对已加载的类执行转换才能生效。
MANIFEST
上面提到 premain
和 agentmain
可以定义在任何类中,那 JVM 怎么知道去哪找呢?我们需要在 jar
包的 MANIFEST.MF
文件 中指定 agent 的入口类是什么,以及 agent
会有哪些能力:
MANIFEST.MFPremain-Class: me.lotabout.Launcher # 静态加载(premain) Agent 时的入口类 Agent-Class: me.lotabout.Launcher # 动态加载(agentmain) Agent 时的入口类 Can-Redefine-Classes: true # 该 Agent 能否重新定义类 Can-Retransform-Classes: true # 该 Agent 能否修改已有类 Can-Set-Native-Method-Prefix: true # 是否允许修改 Native 方法的前缀
|
- Premain-Class: 静态加载(premain) Agent 时的入口类
- Agent-Class: 动态加载(agentmain) Agent 时的入口类
- Can-Redefine-Classes: 该 Agent 能否重新定义类
- Can-Retransform-Classes: 该 Agent 能否修改已有类
- Can-Set-Native-Method-Prefix: 是否允许修改 Native 方法的前缀。Native 方法不是字节码实现的,Agent 修改不了它的逻辑。通常修改 Native 是Proxy 的做法,把原有的 Native 方法重命名,新建同名的 Java 方法来调用老方法。此时需要修改
Native 方法前缀的能力。
pom.xml
打包本身也比较烦,比如 maven 打包时需要指定 MANIFEST.MF
路径,示例如下:
pom.xml<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion>
<groupId>me.lotabout</groupId> <artifactId>my-agent</artifactId> <version>1.0-SNAPSHOT</version>
<properties> <maven.compiler.source>11</maven.compiler.source> <maven.compiler.target>11</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>
<dependencies> <dependency> <groupId>org.ow2.asm</groupId> <artifactId>asm</artifactId> <version>9.4</version> </dependency> <dependency> <groupId>org.ow2.asm</groupId> <artifactId>asm-tree</artifactId> <version>9.4</version> </dependency> </dependencies>
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>3.6.0</version> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile> </archive> </configuration> <executions> <execution> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> </plugin> </plugins> </build> </project>
|
我们额外引入了 asm
与 asm-tree
库,我们后面要用它们来操作字节码。
mvn clean package
后得到 target/my-agent-1.0-SNAPSHOT-jar-with-dependencies.jar
,之后就可以用
java -javaagent:target/my-agent-1.0-SNAPSHOT-jar-with-dependencies.jar -jar app.jar
来调用了。这个名字太长了,后面我们写命令时会简写成 my-agent.jar
。
动态加载 Attach
假设我们已经执行了 java -jar app.jar
,希望加载 my-agent.jar
,要怎么做?需要利用 Attach API。
- 先得到
app.jar
进程的 PID,并 attach 得到 app.jar
的 VirtualMachine
实例:VirtualMachine vm = VirtualMachine.attach(PID);
- 调用
VirtualMachine#loadAgent("my-agent.jar")
让 app.jar
进程加载 agent
为了方便上述操作,我们可以把这段逻辑写到 Launcher
的 main
函数中:
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException { String pid = args[0]; String path = Launcher.class.getProtectionDomain().getCodeSource().getLocation().getPath(); VirtualMachine vm = VirtualMachine.attach(pid); try { vm.loadAgent(path); } finally { vm.detach(); } }
|
再在 MANIFEST.MF
中增加一行:
Main-Class: me.lotabout.Launcher
|
现在就可以使用 java -jar my-agent.jar <目标 PID>
来动态加载 agent 了。注意此时调用的是 agent 的 agentmain
方法。
Instrumentation
上节中的内容是建立 java agent 项目结构,目标是产出一个能被 JVM 识别的Agent。接下来的任务是找到 app.jar
中感兴趣的类并修改这些类的字节码。这些工作都要基于
JDK 提供的 Instrumentation API。
Instrumentation API
Instrumentation
的核心抽象是 ClassFileTransformer
,对字节码的修改逻辑都在这个接口中实现,而 Instrumentation 接口则是用来添加、删除 transformer 的。Instrumentation 常见的使用流程(伪代码)为:
instrument.addTransformer(myTransformer, true);
for (Class clazz: instrument.getAllLoadedClasses()) { if (needToTransform(clazz)) { instrument.retransformClasses(clazz); } }
|
Instrumentation
的一些常用接口定义如下:
getAllLoadedClasses()
获取所有加载的类,得到数组后我们可以自己筛选出关心的类
redefineClasses(ClassDefinition... definitions)
使用参数中的类定义重新定义类
retransformClasses(Class<?>... classes)
使用添加的 transformers 修改指定的类
addTransformer(ClassFileTransformer transformer)
注册 transformer
removeTransformer(ClassFileTransformer transformer)
注销 transformer
对字节码的修改逻辑需要定义在
ClassFileTransformer
的 transform
方法中,方法的签名如下:
byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { }
|
- 通常我们会使用各种信息来过滤掉不感兴趣的类(不想修改就直接直接返回原字节码)。
- 核心输入输出是
class
二进制流(byte[]
),即 transformer 假定字节码的修改是在二进制层面进行的。
直接修改类的二进制不是人能干的事,于是通常会使用一些库把 byte[]
转成一些库定义的结构,操作后再转回 byte[]
返回。下面是常用的一些库:
- asm JDK 内部也用了它,性能好,但 API 的抽象层度很低
- javaassist API 的抽象比 ASM 更高,更适合普通用户,支持直接写 Java 源码
- bytebuddy API 抽象度更高,例如有专门的 builder 来创建 Agent
ASM API 简介
ASM 核心 API
ASM 有两套 API: Event-Based 和 Tree-Based。简单来说 Event-Based 就是 visitor
模式,用户需要定义各种元素的 visitor,扫描字节码中过程中遇到什么元素就调用对应元素的 visitor;Tree-Based 可以理解成先扫一遍字节码组装成一棵树,再对这棵树做后续编辑、修改等操作。Event-Based API 性能更好但 Tree-Based API 更容易理解和使用。
ASM 的整体流程是 byte[] -> ClassNode -> (修改) -> byte[]
,其中ClassNode
是
Tree-Based API 对“类”的抽象。基于 Tree-Based API 来修改字节码的 pattern 如下:
ClassNode cn = new ClassNode(ASM4); ClassReader cr = new ClassReader(origin_classfile_bytes); cr.accept(cn, 0); ... ClassWriter cw = new ClassWriter(0); cn.accept(cw); byte[] b = cw.toByteArray();
|
对 ClassNode
的操作,最常见的是遍历其中的 cn.methods
属性来遍历该类的所有方法,之后通过修改 method.instructions
来修改字节码。
类型描述符(Type Descriptor)
ASM 中对于类型的描述有自己的一套规则,严格来说也不是 ASM 自创的,而是 JVM Spec
中定义的,定义如下:
Java Type |
Type Descriptor |
boolean |
Z |
byte |
B |
char |
C |
double |
D |
float |
F |
int |
I |
long |
J |
short |
S |
Object |
Ljava/lang/Object; |
int[] |
[I |
Object[][] |
[[Ljava/lang/Object; |
基本类型的描述符就是对应的大写字母(除了 boolean
用 Z
代替,因为字母冲突);其中类的描述符是 L<classname>;
的格式,数组的描述符是 [<array_type>
,如果多维就以此类推。
方法描述符(Method Descriptor)
方法描述符是一个字符串,格式为 (<参数类型1><参数类型2>...)<返回类型>
,其中参数类型就是上节的类型描述符,如果返回 void
则写 V
,例如:
源文件中类的定义 |
类型描述符 |
void m(int i, float f) |
(IF)V |
int m(Object o) |
(Ljava/lang/Object;)I |
int[] m(int i, String s) |
(ILjava/lang/String;)[I |
Object m(int[] i) |
([I)Ljava/lang/Object; |
示例-打印每个调用的方法
由于 ASM 的 API 基本是直接添加字节码,但如果对字节码不熟悉其实很难直接写出,于是一种方法是先用 javap 等工具把一个类的字节码反编译出来,再根据反编译的结果来写。
println 字节码
例如我们想在方法被调用时执行如下代码:
System.out.println(">> calling Method: <my_method>");
|
于是我们先写一个类,然后用 javap -c
来反编译:
class Hello { public static void main(String[] args) { System.out.println(">> calling Method: <my_method>"); } }
|
执行如下命令:
$ javac Hello.java $ javap -c Hello Compiled from "Hello.java" class Hello { Hello(); Code: 0: aload_0 1: invokespecial 4: return
public static void main(java.lang.String[]); Code: 0: getstatic 3: ldc 5: invokevirtual 8: return }
|
比较关键的是 getstatic
, ldc
, invokevirtual
这三个指令,分别代表先获取
System.out
,再加载常量 ">> calling Method: <my_method>"
,最后调用
println
三个操作。
接下来我们定义一个 ClassFileTransformer
来实现上述逻辑:
MyTransformer.java1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| public class MyTransformer implements ClassFileTransformer {
private String prefixOfclassToPrint = "";
public MyTransformer(String prefixOfclassToPrint) { this.prefixOfclassToPrint = prefixOfclassToPrint.replace(".", "/"); }
@Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { if (!className.startsWith(this.prefixOfclassToPrint)) { return classfileBuffer; }
System.out.println("transforming class: " + className); ClassNode cn = new ClassNode(Opcodes.ASM4); ClassReader cr = new ClassReader(classfileBuffer); cr.accept(cn, 0);
for (var method : cn.methods) { System.out.println("patching Method: " + method.name); var list = new InsnList(); list.add(new FieldInsnNode(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;")); list.add(new LdcInsnNode(">> calling Method: " + method.name)); list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false)); method.instructions.insert(list); }
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); cn.accept(cw); return cw.toByteArray(); } }
|
- 第 6 行要注意在 transformer 中拿到的类,包名是以
/
分隔的,而
Instrument.getAllLoadedClasses()
中拿到的类名是以 .
分隔的我们允许传入的参数是以 .
分隔的,所以需要转换一下。
- 第 12~14 行是过滤掉不感兴趣的类,不感兴趣的类直接返回原字节码。
- 第 16~19, 32~34 行是上文所说的 ASM 框架代码,反序列化二进制和序列化二进制的过程。
- 第 21 行开始遍历该类的所有方法,每个方法都插入我们的逻辑
- 第 24~28 行是插入字节码的逻辑,对应上小节说的
getstatic
, ldc
,
invokevirtual
三个指令。其中也看到了类型描述符、方法描述符的使用。
- 第 29 行是把生成的字节码 “insert” 到方法的字节码中,“insert” 是在最前面插入
组装与测试
最后,我们在 premain
和 agentmain
中注册我们的 MyTransformer
,最终 Launcher
类如下:
Launcher.java1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| public class Launcher {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException { String pid = args[0]; String prefix = args[1]; String path = Launcher.class.getProtectionDomain().getCodeSource().getLocation().getPath(); VirtualMachine vm = VirtualMachine.attach(pid); try { vm.loadAgent(path, prefix); } finally { vm.detach(); } }
public static void premain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException { inst.addTransformer(new MyTransformer(agentArgs), true); for (var clazz : inst.getAllLoadedClasses()) { if (inst.isModifiableClass(clazz) && clazz.getName().startsWith(agentArgs)) { inst.retransformClasses(clazz); } } }
public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException { premain(agentArgs, inst); }
private static class MyTransformer implements ClassFileTransformer {
private String prefixOfclassToPrint = "";
public MyTransformer(String prefixOfclassToPrint) { this.prefixOfclassToPrint = prefixOfclassToPrint.replace(".", "/"); }
@Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { if (!className.startsWith(this.prefixOfclassToPrint)) { return classfileBuffer; }
System.out.println("transforming class: " + className); ClassNode cn = new ClassNode(Opcodes.ASM4); ClassReader cr = new ClassReader(classfileBuffer); cr.accept(cn, 0);
for (var method : cn.methods) { System.out.println("patching Method: " + method.name); var list = new InsnList(); list.add(new FieldInsnNode(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;")); list.add(new LdcInsnNode(">> calling Method: " + method.name)); list.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false)); method.instructions.insert(list); }
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); cn.accept(cw); return cw.toByteArray(); } } }
|
之后有两种调用方式(类名前缀的包名以 .
分隔):
$ java -javaagent:my-agent-1.0-SNAPSHOT-jar-with-dependencies.jar=<类名前缀> -jar app.jar $ java -jar my-agent-1.0-SNAPSHOT-jar-with-dependencies.jar <PID> <类名前缀>
|
对于如下的示例 Hello
类:
public class Hello {
public static void main(String[] args) throws Exception { for (int i = 0; i < 2; i++) { outer(); } }
public static void outer() { test(); }
public static void test() { System.out.println("Hello world!"); } }
|
可以在运行时挂上 agent 来看到输出:
$ javac Hello.java $ java -javaagent:my-agent-1.0-SNAPSHOT-jar-with-dependencies.jar=Hello Hello transforming class: Hello patching Method: <init> patching Method: main patching Method: outer patching Method: test >> calling Method: main >> calling Method: outer >> calling Method: test Hello world! >> calling Method: outer >> calling Method: test Hello world!
|
可以看到我们成功的在每个方法调用时打印了一行信息。
小结
本文介绍了 Java Agent 的基本代码结构,简单介绍了 ASM 库来修改字节码的方法,最后给出了示例,让 Agent 能动态修改类的方法,在方法开始处打印一行信息。
另外一些常见的字节码修改场景可以参考 ASM 的文档或使用其它字节码修改库。例如希望打印每个方法的返回值,理论上需要遍历每个方法的字节码,找到 return
指令,然后在该指令前插入打印指令,这种常见 pattern 通常都有库封装好,可以直接使用。