Java Agent字节码插桩术
关于JVMTI(jvm tool interface)
JVMTI是⽤来开发和监控JVM所使⽤的程序接⼝,可以探查JVM内部状态,并控制JVM应⽤程序的执⾏,JVMTI的客户端,或称为代理(agent),提供了很多函数,可以监听感兴趣的事件,以便来查询或控制应⽤程序,进而实现控制JVM应用程序目标。但是需要注意的是,并⾮所有的JVM实现都⽀持JVMTI。
Idea的Debug功能就是通过JDWP协议定制化JVMTI的功能实现与JVM的双向通信连接,在获取断点位置的代码信息同时向IDEA客户端进行推送信息,实现监听功能!
如果要自己实现定制化的JVMTI功能的话,需要编写dll文件 更多资料参考 。

javaagent是java1.5之后引⼊的特性,基于JVMTI实现,⽀持JVMTI部分功能。主要应⽤场景是对类加载进⾏拦截修改和对已加载的类进⾏重定义。此外还⽀持获取已加载的类,以及实例内存占⽤计算等。
javaagent的启动方式
javaagent的表现形式是监听一个JAR包,用于监听目标应用,它有两种启动方式:
- 加载时启动:agent的功能随着⽬标应⽤⼀起启动,通过设置⽬标应⽤的jvm参数:"-javaagent:",即可加载javaagent监听目标jar包。
- 这种方式是在类加载之前进行拦截的,在符合JVM的规范下可以任意类的结构。比如修改类的名称,加入方法等等
- 运行时附着:借助jvmtools⼯具包将javaagent包注⼊到⽬标应⽤中,实现运行过程的监听。
- 类加载之后进行拦截,可以对方法内部增加逻辑,不能增加方法修改类的结构。
代码演示
加载时启动(premain)
与运行时JAR包的启动区别
| 运⾏时JAR包 | javaagentjar包 | |
| 启动类 | Main-class | Premain-class |
| 启动⽅法 | main | premain |
| 启动⽅式 | java-jarxxx.jar | jvm参数设置:-javaagent:xxx.jar |
编写启动类(Premain)
public class MyAgent {
/**
*
* @param arg 启动参数
* @param instrumentation 类的加载和定义都是通过它来实现的
*/
// 加载时启动
public static void premain(String arg, Instrumentation instrumentation) {
System.out.println("hello premain");
}
}
maven插件设置然后进行编译打包
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>2.2</version>
<configuration>
<archive>
<manifestEntries>
<Project-name>${project.name}</Project-name>
<Project-version>${project.version}</Project-version>
<!--设置启动类的位置-->
<Premain-Class>javaagent.MyAgent</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
<skip>true</skip>
</configuration>
</plugin>
目标类
public class MyApp {
public static void main(String[] args) throws IOException {
System.out.println("访问主程序");
}
}
将打包好的javaagent的jar包位置作为参数设置到JVM参数中

可以看到在类加载之前就已经被拦截了

附着启动(agentmain)
如果想要在应⽤运⾏之后去监听它,⽽⼜不去重启它,就可以采⽤另⼀种⽅式附着启动。其相关属性通过以表来⽐对:
| 运⾏时JAR包 | javaagentjar包 | |
| 启动类 | Main-class | Agent-class |
| 启动⽅法 | main | agentmain |
| 启动⽅式 | java-jarxxx.jar | tools⼯具附着 |
编写启动类
public class MyAgent {
/**
*
* @param arg 启动参数
* @param instrumentation 类的加载和定义都是通过它来实现的
*/
// 运行时启动
public static void agentmain(String arg, Instrumentation instrumentation) {
System.out.println("hello agentmain");
}
}
maven插件设置然后进行编译打包
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>2.2</version>
<configuration>
<archive>
<manifestEntries>
<Project-name>${project.name}</Project-name>
<Project-version>${project.version}</Project-version>
<!--设置启动类的位置-->
<Agent-Class>javaagent.MyAgent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
<skip>true</skip>
</configuration>
</plugin>
目标类
public class MyApp {
public static void main(String[] args) throws IOException {
System.out.println("访问主程序");
// 设置运行时
System.in.read();
}
}
前置设置和加载时启动没有什么区别,运行时附着的启动方式是必须通过jvm/lib/tools.jar中的API注⼊⾄⽬标应⽤:
导入依赖
<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8.0</version>
<scope>system</scope>
<systemPath>${JAVA_HOME}/lib/tools.jar</systemPath>
</dependency>
public class AttachStart {
public static void main(String[] args) throws Exception {
// 获取jvm进程列表 借用tool工具实现进程交互
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for (int i = 0; i < list.size(); i++) {
System.out.println(String.format("[%s] %s", i, list.get(i).displayName()));
}
System.out.println("输入数字指定要attach的进程");
// 选择jvm进程
BufferedReader read = new BufferedReader(new InputStreamReader(System.in));
String line = read.readLine();
int i = Integer.parseInt(line);
// 附着agent
VirtualMachine virtualMachine = VirtualMachine.attach(list.get(i));
// 将对应的agent功能附着到指定进程进去实现运行时启动
virtualMachine.loadAgent("E:/javaCode/javaagent/target/javaagent-1.0-SNAPSHOT.jar", "111");
virtualMachine.detach();
System.out.println("加载成功");
}
}
选择要附着的jvm进程,需要注意的是一旦附着成功,agent的功能实现是在对方的进程上实现的。

结果很明显,agent的功能是在目标类之后才显现出来

核心功能
addTransformer(变压器): 添加类加载拦截器,可重定义加载的类,类如果在这之前加载会失效。
retransformClasses(类重新变更):将类进行二次加载,重新被变压器所拦截,注意加载的类是有限制的,仅可对运⾏指令码进⾏修改:不可修改类结构如继承、接⼝、类符、变更属性、变更⽅法等。可以新增privatestatic/final的方法;必须maven插件添加Can-Retransform-Classes=true该⽅法执⾏才有效,且addTransformer⽅法的canRetransform参数也为true。

redefineClasses(重新定义类):在经过变压器之前进行重定义,逻辑上跟类重新变更没太大的区别
getAllLoadedClasses(获取所有类)
getInitiatedClasses(获取已实例化的类)
getObjectSize(计算对象大小)
示例
public class MyApp {
public static void main(String[] args) throws IOException {
System.out.println("访问主程序");
new HelloWorld().hello();
}
}
public class HelloWorld {
public void hello() {
System.out.println("hello!!");
}
}
// 加载时启动
public static void premain(String arg, Instrumentation instrumentation) throws UnmodifiableClassException, ClassNotFoundException {
System.out.println("hello premain");
// 如果类在变压器之前加载那么就不能修改类(运行时加载不能用)
HelloWorld helloWorld = new HelloWorld();
instrumentation.addTransformer(new ClassFileTransformer() {
/**
* @param loader
* @param className 动态加载的类名
* @param classBeingRedefined 这个类重新加载之前的类
* @param protectionDomain 类的基本信息
* @param classfileBuffer 这个类的字节码,如果返回null就按照原有的进行加载覆盖
* @return 返回指令码
* @throws IllegalClassFormatException
*/
@Override
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
if (!"javaagent/HelloWorld".equals(className)) {
return null;
}
// 配合javassist修改指令码
ClassPool pool = new ClassPool();
pool.appendSystemPath();
try {
CtClass ctClass = pool.get("javaagent.HelloWorld");
CtMethod method = ctClass.getDeclaredMethod("hello");
method.insertBefore("System.out.println(\"插入前置逻辑\");");
return ctClass.toBytecode();
} catch (NotFoundException | CannotCompileException | IOException e) {
e.printStackTrace();
}
return null;
}
},true);
// 重新触发指定类的加载但是对字节码的修改有一定的限制,只是重走变压器的逻辑
instrumentation.retransformClasses(HelloWorld.class);
// 这里的访问的方法内部逻辑已经被修改了
// 原理有点像是spring热部署:(单例)类只加载一次它所指向的方法地址不变,但是方法自身指令码发生了改变(钥匙还是原来的钥匙但是房间里的东西可能就不一样了!)!
helloWorld.hello();
}
结果


浙公网安备 33010602011771号