字节码插桩之Java Agent
Posted 风在哪
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了字节码插桩之Java Agent相关的知识,希望对你有一定的参考价值。
字节码插桩之Java Agent
本篇文章将详细讲解有关Java Agent的知识,揭开它神秘的面纱,帮助开发人员了解它的黑魔法,帮助我们完成更多业务需求
What is Java Agent
Java Agent又称为Java探针,它提供了向现有已编译的Java类添加字节码的功能,相当于字节码插桩的入口;通过使用Java Instrumentation API,它能够侵入运行在JVM上的应用程序,进而修改应用程序中各种类的字节码。
在Java程序运行环境中,instrumentation是一种用于更改现有应用程序并向其添加代码的技术,可以在编译期间或者运行时执行此操作。它的优点就是,我们无需编辑源代码文件就能修改代码、更改它的行为;这非常有效,但也非常危险。
我们可以用它来实现很多功能,例如AOP
Java Agent是Java Instrumentation API的一部分,Java Instrumentation API提供了一种可以动态或者静态地修改字节码的机制,这意味着我们可以在不修改源代码的情况下向Java类中添加代码,这对Java应用程序有着很重要的影响
简单来说,Java Agent是一种特殊的jar文件,但是它包含遵循特殊约定的Java类,并在jar文件中的MANIFEST.MF(该文件通常存放在src/main/resources/META-INF文件夹下)文件中指定遵循特殊约定的Java类;在该特殊的类中有如下两类方法:
-
premain:在JVM启动时加载的Java Agent,例如使用java -jar命令启动jar文件时,添加-javaagent命令添加Java Agent;通俗来说就是在JVM初始化之后&main方法之前运行该agent
其方法签名如下:
public static void premain(String agentArgs, Instrumentation inst)
如果不包含上述方法,可选方法如下:
public static void premain(String agentArgs)
-
agentmain:使用Java Attach API将Java Agent动态加载到JVM中,在JVM初始化之后运行,可以在我们的main方法中通过Attach API调用该agent
其方法签名如下:
public static void agentmain(String agentArgs, Instrumentation inst) public static void agentmain(String agentArgs)
应用程序是如何识别Java Agent的呢,答案就是MANIFEST.MF文件,该文件作为JAR文件的一部分包含jar文件的元数据信息,其中关于Java Agent的属性如下:
- Premain-Class:当JVM启动时,该属性指定代理类,该类为包含premain方法的类,其值为全限定类名;如果在JVM启动时指定Java Agent则必须定义该属性
- Agent-Class:如果实现支持在JVM启动后某个时间启动agent的机制,则此属性指定代理类;该类为包含agentmain方法的类,其值为全限定类名
- Can-Readefine-Classes:定义Java Agent是否能够重定义Java类,其值为true or false,默认false
- Can-Retransform-Classes:定义Java Agent能否重转换Java类,其值为true or false,默认false
- Can-Set-Native-Method-Prefix:定义Java Agent能否设置所需的本机方法前缀,默认false
- Boot-Class-Path:设置启动类加载器搜索的路径列表;查找类的特定于平台的机制失败后,引导类加载器会搜索这些路径;按列出的顺序搜索路径,列表中的路径由一个或多个空格分开;路径使用分层 URI 的路径组件语法;如果该路径以斜杠字符(“/”)开头,则为绝对路径,否则为相对路径;相对路径根据代理 JAR 文件的绝对路径解析,忽略格式不正确的路径和不存在的路径,如果代理是在 VM 启动之后某一时刻启动的,则忽略不表示 JAR 文件的路径
在使用maven构建项目的过程中,我们可以通过pom.xml中的build属性设置maven-jar-plugin插件来自动生成MANIFEST.MF文件,示例如下:
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<!--true代表自动添加MANIFEST.MF文件-->
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<!--设置MANIFEST.MF文件的相关属性,类文件使用全限定类名-->
<manifestEntries>
<Premain-Class>cn.wygandwdn.agent.TestAgent</Premain-Class>
<Agent-Class>cn.wygandwdn.agent.TestAgent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
Java Instrumentation API解析
官方文档:
java.instrument (Java SE 9 & JDK 9 )
java.lang.instrument (Java Platform SE 8 )
Java Instrumentation API主要提供了两个接口:Instrumentation、ClassFileTransformer:
- Instrumentation:该类提供了向Java中插入代码的服务
- ClassFileTransformer:顾名思义,该类就是类文件转换器接口,Java Agent提供该接口的实现,用于转换类文件
Instrumentation提供了一些添加&移除类转换器(ClassFileTransformer)、获取被JVM加载的类、重定义类、判断类是否支持重转换&重定义的接口,详细介绍如下:
- addTransformer(ClassFileTransformer transformer, boolean canRetransform):注册类转换器。除了注册类转换器依赖的类定义以外,所有的类定义都会经过类转换器;当类加载时、重定义时、重转换时(如果canRetransform为true),会调用类转换器;如果一个类转换器抛出异常,JVM会依次调用其他类转换器;同一个类转换器实例可以被添加多次,但是强烈建议不要这么做,可以通过创建新的实例来避免这种做法
- addTransformer(ClassFileTransformer transformer):注册类转换器,与addTransformer(transformer, false)作用相同
- removeTransformer(ClassFileTransformer transformer):移除已注册的transformer,类定义将不再经过已经移除的transformer;移除策略为移除最近添加的transformer实例;由于多线程的原因,一个被移除的transformer可能会被调用,我们实现的transformer应该避免这种情况
- isRetransformClassesSupported():判断JVM配置是否支持对类的重转换,该属性在jar包的MANIFEST.MF文件中定义,属性名为:Can-Retransform-Classes
- retransformClasses(Class<?>… classes):重转换参数中提供的类,此函数有助于修改已加载的类;原始的类文件字节可以被ClassFileTransformer转换;如果在重转换期间被转换类的方法正在运行,该方法还会运行原始方法的字节码,后续新调用才会运行新方法;该方法不会导致任何初始化,换句话说,重定义一个类不会导致其初始化方法运行,静态变量的值将保持调用之前的状态;重转换类的实例不受影响;重转换可能修改方法体、常量池和属性,但是不能添加、移除和重命名字段和方法,不能修改方法签名,不能修改继承关系(这些限制可能会在未来的版本取消)
- isRedefineClassesSupported():判断JVM配置是否支持对类的重定义,该属性在jar包的MANIFEST.MF文件中定义,属性名为:Can-Redefine-Classes
- redefineClasses(ClassDefinition… definitions):重定义参数提供的类定义;此方法用于在不引用现有类文件字节的情况下替换类的定义,就像从源代码重新编译以修复并继续调试时所做的那样,如果引用现有类字节文件则会调用retransformClasses方法;其余性质与retransform类似
- isModifiableClass(Class<?> theClass):判断类是否被修改过,例如被重转换或者重定义;如果修改过则返回true,否则返回false(原始类型和数组类型的类永远不会被修改)
- getAllLoadedClassed():返回所有被JVM加载的类
- getInitiatedClasses(ClassLoader loader):返回指定类加载器加载的全部类数组;如果loader为空,则返回启动类加载器加载的类数组
- getObjectSize(Object objectToSize):返回指定对象消耗的数组大小
Instrumentation的实现类为sun.instrument.InstrumentationImpl,有关接口的具体实现可以参考该类,这里不再赘述;掌握Instrumentation提供的大部分接口的使用方法,对我们完成特定的功能非常有帮助
ClassFileTransformer只提供了一个接口transform并提供了默认实现,用于转换类文件,返回byte数组:
/*
该类用于转换指定的类文件并返回转换后的新的类文件
参数详解:
loader-被转换类的类加载器,如果是启动类加载器加载的话则为null
className-被转换类的全限定类名
classBeingRedefined-如果这是由重定义或重传触发的,则为被重定义或重传的类;如果这是类加载,则为null
protectionDomain-正在定义或重定义的类的保护域
classfileBuffer-类文件格式的输入字节缓冲区-不得修改
*/
default byte[]
transform( ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException
return null;
在transform的实现中,我们通过字节码增强的方法对类进行修改,例如使用javassist or asm等方法修改类定义,来实现我们想要的功能;然后将ClassFileTransformer接口的实现类注册到Instrumentation中,如果JVM允许重转换,Instrumentation将会调用我们注册的transform来转换JVM加载的类
Instrumentation API原理
java.lang.instrument包的具体实现,依赖于JVMTI(Java Virtual Machine Tool Interface),JVMTI是一套由Java虚拟机提供的,为JVM相关工具提供的本地变成接口集合。JVMTI从Java SE5开始引入,整合和取代了之前使用的Java Virtual Machine Profiler Interface(JVMPI)和Java Virtual Machine Debug Interface(JVMDI),在Java SE6中,JVMPI和JVMDI已经消失了。JVMTI 提供了一套”代理”程序机制,可以支持第三方工具程序以代理的方式连接和访问 JVM,并利用 JVMTI 提供的丰富的编程接口,完成很多跟 JVM 相关的功能。
事实上,java.lang.instrument 包的实现,也就是基于这种机制的:在 Instrumentation 的实现当中,存在一个 JVMTI 的代理程序,通过调用 JVMTI 当中 Java 类相关的函数来完成 Java 类的动态操作。除Instrumentation功能外,JVMTI 还在虚拟机内存管理,线程控制,方法和变量操作等等方面提供了大量有价值的函数。
JVM官方文档地址:JVMTI
启动时加载instrument agent过程:
- 创建并初始化 JPLISAgent;
- 监听
VMInit
事件,在 JVM 初始化完成之后做下面的事情:- 创建 InstrumentationImpl 对象 ;
- 监听 ClassFileLoadHook 事件 ;
- 调用 InstrumentationImpl 的
loadClassAndCallPremain
方法,在这个方法里会去调用 javaagent 中 MANIFEST.MF 里指定的Premain-Class 类的 premain 方法 ;
- 解析 javaagent 中 MANIFEST.MF 文件的参数,并根据这些参数来设置 JPLISAgent 里的一些内容。
运行时加载instrument agent过程:
通过 JVM 的attach机制来请求目标 JVM 加载对应的agent,过程大致如下:
- 创建并初始化JPLISAgent;
- 解析 javaagent 里 MANIFEST.MF 里的参数;
- 创建 InstrumentationImpl 对象;
- 监听 ClassFileLoadHook 事件;
- 调用 InstrumentationImpl 的
loadClassAndCallAgentmain
方法,在这个方法里会去调用javaagent里 MANIFEST.MF 里指定的Agent-Class
类的agentmain
方法。
Example
实现Java Agent的大致流程如下:
- 编写Java Agent类,结合ClassFileTransformer添加要实现的功能
- 自定义resources/META-INF/MANIFEST.MF文件,或者通过pom.xml属性配置自动生成该文件
- 打jar包,并确定jar包的绝对路径,为后续使用做准备
- 通过JVM参数:-javaagent:jar包绝对路径=args 启动我们要运行的main方法或者jar包,从而运行指定Java Agent
上述为实现Java Agent的简单流程,可根据实际需要进行简单调整
premain
自定义transformer,用于转换类,起作用为计算每个方法的执行时间,实现类如下:
public class CalculateTime implements ClassFileTransformer
private String config;
private ClassPool pool;
public CalculateTime(String config, ClassPool pool)
this.config = config;
this.pool = pool;
private final static String source = "\\n"
+ " long begin = System.currentTimeMillis();\\n"
+ " Object result;\\n"
+ " try \\n"
+ " result = ($w) %s$agent($$);\\n"
+ " finally \\n"
+ " long end = System.currentTimeMillis();\\n"
+ " System.out.println(\\"%s方法执行时间为: \\" + (end - begin) + \\"ms\\");"
+ " \\n"
+ " return ($r) result;"
+ "\\n";
private final static String voidSource = "\\n" +
" long begin = System.currentTimeMillis();\\n" +
" try \\n" +
" %s$agent($$);\\n" +
" finally \\n" +
" long end = System.currentTimeMillis();\\n" +
" System.out.println(\\"%s方法执行时间为: \\" + (end - begin) + \\"ms\\");\\n" +
" \\n" +
" ";
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException
if (className == null || !className.replaceAll("/", ".").startsWith(this.config))
return null;
try
className = className.replaceAll("/", ".");
CtClass ctClass = pool.get(className);
// 获取所有非private的方法
CtMethod[] methods = ctClass.getDeclaredMethods();
for (CtMethod method : methods)
newMethod(method);
return ctClass.toBytecode();
catch (Exception e)
e.printStackTrace();
return null;
private static CtMethod newMethod(CtMethod oldMethod) throws CannotCompileException, NotFoundException
CtMethod copy = CtNewMethod.copy(oldMethod, oldMethod.getDeclaringClass(), null);
copy.setName(oldMethod.getName() + "$agent");
oldMethod.getDeclaringClass().addMethod(copy);
if (oldMethod.getReturnType().equals(CtClass.voidType))
oldMethod.setBody(String.format(voidSource, oldMethod.getName(), oldMethod.getName()));
else
oldMethod.setBody(String.format(source, oldMethod.getName(), oldMethod.getName()));
return copy;
这里不是直接通过如下方法修改原有的类:
method.insertBefore("long start = System.currentTimeMillis();");
method.insertAfter("System.out.println(System.currentTimeMillis() - start);");
因为javassist插入的代码为一个代码块,两个代码块之间的变量无法互通,所以使用上述方法会报错;故这里是在原有的方法之上copy一个方法名为(原方法名+$agent)的方法添加到CtClass中,然后修改原始方法的方法体调用这个新增的方法,并增加计时操作;此外,还要分为带返回值和不带返回值的两种情况,详情请看newMethod方法
自定义带有premain方法的代理类:
public class MyAgent
/**
* 主程序main方法执行前执行该方法
* @param agentArgs
* @param inst
*/
public static void premain(String agentArgs, Instrumentation inst)
System.out.println("premain start");
final String config = agentArgs;
final ClassPool pool = ClassPool.getDefault();
inst.addTransformer(new CalculateTime(config, pool));
/**
* 主程序main方法执行时可通过attach api调用该方法
* @param args
* @param inst
*/
public static void agentmain(String args, Instrumentation inst)
System.out.println("load agent after main run.args=" + args);
// 打印该应用程序中所有被类加载器加载的类
Class[] allLoadedClasses = inst.getAllLoadedClasses();
for (Class allLoadedClass : allLoadedClasses)
System.out.println(allLoadedClass.getName());
System.out.println("agent run completely.");
这里并未自定义resources/META-INF/MANIFEST.MF文件,而是在pom.xml中进行了如下配置:
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>cn.wygandwdn.agent.MyAgent</Premain-Class>
<Agent-Class>cn.wygandwdn.agent.MyAgent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
上述设置完成之后即可开始打包,打包完成之后,通过下面的方法测试该agent:
public class BlogService
public String getBlog()
try
Thread.sleep(1000);
catch (InterruptedException e)
e.printStackTrace();
return "blog's content";
public class TestAgentMain
public static void main(String[] args)
BlogService service = new BlogService();
service.getBlog();
在idea中配置启动参数:
1、配置main方法运行参数:
2、添加VM启动参数
3、设置VM启动参数,指定-javaagent启动参数
然后运行main方法即可,运行结果如下:
agentmain
attach api
agentmain为JVM启动后加载的Java Agent,主要通过attach api实现;attach api主要通过VirtualMachine和VirtualMachineDescriptor两个类来实现其加载Java Agent的功能,其官方api文档地址为:com.sun.tools.attach (Java SE 9 & JDK 9 )
-
VirtualMachine:该类代表当前JVM连接的目标JVM进程;应用程序通过VirtualMachine将Java Agent加载到目标JVM中;例如,用Java语言编写的探查器工具可能会连接到正在运行的应用程序,并加载其探查器代理来评测正在运行的应用程序。此外,VirtualMachine提供了访问目标JVM系统属性的权限。类的详细方法可查阅相关源码
该类允许我们通过给attach方法传入一个jvm的pid(进程id),远程连接到jvm上;代理类注入操作只是它众多功能中的一个,通过
loadAgent
方法向jvm注册一个代理程序agent,在该agent的代理程序中会得到一个Instrumentation实例,该实例可以在class加载前改变class的字节码,也可以在class加载后重新加载。在调用Instrumentation实例的方法时,这些方法会使用ClassFileTransformer接口中提供的方法进行处理。 -
VirtualMachineDescriptor:该类是一个用于描述JVM的容器类,配合VirtualMachine类完成各种功能
attach动态注入的原理则非常简单:
- 通过VirtualMachine.attach(pid)方法,可以远程连接一个正在运行的JVM进程
- 通过loadAgent(agent)将Java Agent的jar包注入到对应的进程中,然后对应的进程会调用agentmain方法
通过attach api和agentmain方法,我们可以很方便地在运行过程中动态设置加载代理类,在main函数开始运行之后再运行agent,以达到instrumentation的目的;相较于之前只能使用-javaagent参数在main方法之前运行的premain方法有了更大的扩展性,让我们修改正在运行的程序成为了可能
简单案例
在premain的agent例子中也有agentmain方法,该方法主要实现了打印该应用程序加载的所有类,我们需要通过attach api在main方法中加载Java Agent jar包,加载完成之后会运行agentmain方法,这里为了简单起见,在main方法中找到当前JVM并加载agent:
public class TestAgentMain
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException
// test agentmain
System.out.println("====start test agentmain====");
List<VirtualMachineDescriptor> list = VirtualMachine.list();
for (VirtualMachineDescriptor virtualMachineDescriptor : list)
// 要想连接当前jvm,需要添加jvm运行参数:-Djdk.attach.allowAttachSelf=true
if (virtualMachineDescriptor.displayName().endsWith("cn.wygandwdn.TestAgentMain"))
VirtualMachine virtualMachine = VirtualMachine.attach(virtualMachineDescriptor.id());
virtualMachine.loadAgent("D:/java_project/trace/learn-javaagent/target/learn-javaagent-1.0-SNAPSHOT.jar");
virtualMachine.detach();
如果此时依旧使用之前的参数会报错:
这是因为main方法不允许连接当前JVM,此时我们可以通过参数设置避免该问题,具体设置如下:
-javaagent:learn-javaagent-1.0-SNAPSHOT.jar
-Djdk.attach.allowAttachSelf=true
起作用的VM参数为-Djdk.attach.allowAttachSelf=true
main方法的运行结果如下:
reference
特别鸣谢:
JVM字节码插桩神技,不改一行代码就可以监控java线上系统。阿里鹰眼、华为SkyWalking用它实现
欢迎访问我的个人博客: 风在哪个人博客
以上是关于字节码插桩之Java Agent的主要内容,如果未能解决你的问题,请参考以下文章