Spring AOP大家都很熟悉,它允许我们在目标方法的前后织入想要执行的逻辑,而今天要介绍的Java Agent技术,在思想上与aop比较类似,翻译过来可以被称为Java代理、Java探针技术。Java Agent出现在JDK1.5版本以后,它允许程序员利用agent技术构建一个独立于应用程序的代理程序,用途也非常广泛,可以协助监测、运行、甚至替换其他JVM上的程序,先从下面这张图直观的看一下它都被应用在哪些场景:
那今天就来挖掘一下,看看Java Agent是如何工作在底层,能够应用在这么多场景下。回到文章开头的类比,还是用和aop比较的方式,来先对Java Agent有一个大致的了解:
- 作用级别:aop运行于应用程序内的方法级别,而agent能够作用于虚拟机级别
- 组成部分:aop的实现需要目标方法和逻辑增强部分的方法,而Java Agent要生效需要两个工程,一个是agent代理,另一个是需要被代理的主程序
- 执行场合:aop可以运行在切面的前后或环绕等场合,而Java Agent的执行只有两种方式,jdk1.5提供的
preMain
模式在主程序运行前执行,jdk1.6提供的agentMain
在主程序运行后执行
JavaAgent有两种模式:
Premain模式:允许在主程序执行前执行一个agent代理,premain
方法允许以下面两种方式定义
public static void premain(String agentArgs)
public static void premain(String agentArgs, Instrumentation inst)
Agentmain模式:允许代理的目标主程序的jvm先行启动,再通过attach
机制连接两个jvm,agentmain
方法允许以下面两种方式定义
public static void agentmain(String agentArgs)
public static void agentmain(String agentArgs, Instrumentation inst)
下面我们就分别看一下在两种模式下,如何动手实现一个agent代理程序。
Premain模式
Premain模式允许在主程序执行前执行一个agent代理,实现起来非常简单,下面我们分别实现两个组成部分。
agent
先写一个简单的功能,在主程序执行前打印一句话,并打印传递给代理的参数:
public class MyPreMainAgent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("premain start");
System.out.println("args:"+agentArgs);
}
}
在写完了agent的逻辑后,需要把它打包成jar
文件,这里我们直接使用maven插件打包的方式,在打包前进行一些配置。
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>com.cn.agent.MyPreMainAgent</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
<Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
配置的打包参数中,通过manifestEntries
的方式添加属性到MANIFEST.MF
文件中,解释一下里面的几个参数:
Premain-Class
:包含premain
方法的类,需要配置为类的全路径Can-Redefine-Classes
:为true
时表示能够重新定义classCan-Retransform-Classes
:为true
时表示能够重新转换class,实现字节码替换Can-Set-Native-Method-Prefix
: 为true
时表示能够设置native方法的前缀
其中Premain-Class
为必须配置,其余几项是非必须选项,默认情况下都为false
,通常也建议加入,这几个功能我们会在后面具体介绍。在配置完成后,使用mvn
命令打包:
mvn clean package
打包完成后生成myAgent-1.0.jar
文件,我们可以解压jar
文件,看一下生成的MANIFEST.MF
文件:
Manifest-Version: 1.0
Premain-Class: com.cn.agent.MyPreMainAgent
Built-By: nicaisheng
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Class-Path: cglib-3.2.7.jar asm-6.2.jar ant-1.10.3.jar ant-launcher-1.
10.3.jar
Can-Set-Native-Method-Prefix: true
Created-By: Apache Maven 3.5.4
Build-Jdk: 1.8.0_91
可以看到,添加的属性已经被加入到了文件中。到这里,agent代理部分就完成了,因为代理不能够直接运行,需要附着于其他程序,所以下面新建一个工程来实现主程序。
主程序
在主程序的工程中,只需要一个能够执行的main
方法的入口就可以了。
public class AgentTest {
public static void main(String[] args) {
System.out.println("main project start");
}
}
在主程序完成后,要考虑的就是应该如何将主程序与agent工程连接起来。这里可以通过-javaagent
参数来指定运行的代理,命令格式如下:
java -javaagent:myAgent.jar -jar AgentTest.jar
并且,可以指定的代理的数量是没有限制的,会根据指定的顺序先后依次执行各个代理,如果要同时运行两个代理,就可以按照下面的命令执行:
java -javaagent:myAgent1.jar -javaagent:myAgent2.jar -jar AgentTest.jar
以我们在idea中执行程序为例,在VM options
中加入添加启动参数:
-javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Hydra
-javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Trunks
执行main
方法,查看输出结果:
premain start
args:Hydra
premain start
args:Trunks
main project start
根据执行结果的打印语句可以看出,在执行主程序前,依次执行了两次我们的agent代理。执行代理与主程序的执行顺序:premain->主程序main。
缺陷
在提供便利的同时,premain模式也有一些缺陷,例如如果agent在运行过程中出现异常,那么也会导致主程序的启动失败。我们对上面例子中agent的代码进行一下改造,手动抛出一个异常。
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("premain start");
System.out.println("args:"+agentArgs);
throw new RuntimeException("error");
}
再次运行主程序:
可以看到,在agent抛出异常后主程序也没有启动。针对premain模式的一些缺陷,在jdk1.6之后引入了agentmain模式。
Agentmain模式
agentmain模式可以说是premain的升级版本,它允许代理的目标主程序的jvm先行启动,再通过attach
机制连接两个jvm,下面我们分3个部分实现。
agent
agent部分和上面一样,实现简单的打印功能:
public class MyAgentMain {
public static void agentmain(String agentArgs, Instrumentation instrumentation) {
System.out.println("agent main start");
System.out.println("args:"+agentArgs);
}
}
修改maven插件配置,指定Agent-Class
:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Agent-Class>com.cn.agent.MyAgentMain</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
主程序
这里我们直接启动主程序等待代理被载入,在主程序中使用了System.in
进行阻塞,防止主进程提前结束。
public class AgentmainTest {
public static void main(String[] args) throws IOException {
System.in.read();
}
}
attach机制
和premain模式不同,我们不能再通过添加启动参数的方式来连接agent和主程序了,这里需要借助com.sun.tools.attach
包下的VirtualMachine
工具类,需要注意该类不是jvm标准规范,是由Sun公司自己实现的,使用前需要引入依赖:
<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8</version>
<scope>system</scope>
<systemPath>${JAVA_HOME}\lib\tools.jar</systemPath>
</dependency>
VirtualMachine
代表了一个要被附着的java虚拟机,也就是程序中需要监控的目标虚拟机,外部进程可以使用VirtualMachine
的实例将agent加载到目标虚拟机中。先看一下它的静态方法attach
:
public static VirtualMachine attach(String var0);
通过attach
方法可以获取一个jvm的对象实例,这里传入的参数是目标虚拟机运行时的进程号pid
。也就是说,我们在使用attach
前,需要先获取刚才启动的主程序的pid
,使用jps
命令查看线程pid
:
11140
16372 RemoteMavenServer36
16392 AgentmainTest
20204 Jps
2460 Launcher
获取到主程序AgentmainTest
运行时pid
是16392,将它应用于虚拟机的连接。
public class AttachTest {
public static void main(String[] args) {
try {
VirtualMachine vm= VirtualMachine.attach("16392");
vm.loadAgent("F:\\Workspace\\MyAgent\\target\\myAgent-1.0.jar","param");
} catch (Exception e) {
e.printStackTrace();
}
}
}
在获取到VirtualMachine
实例后,就可以通过loadAgent
方法可以实现注入agent代理类的操作,方法的第一个参数是代理的本地路径,第二个参数是传给代理的参数。执行AttachTest
,再回到主程序AgentmainTest
的控制台,可以看到执行了了agent中的代码:
这样,一个简单的agentMain模式代理就实现完成了。
Instrumentation
先大体介绍一下Instrumentation
接口,其中的方法允许在运行时操作java程序,提供了诸如改变字节码,新增jar包,替换class等功能,而通过这些功能使Java具有了更强的动态控制和解释能力。在我们编写agent代理的过程中,Instrumentation
中下面3个方法比较重要和常用,我们来着重看一下。
addTransformer
addTransformer
方法允许我们在类加载之前,重新定义Class,先看一下方法的定义:
void addTransformer(ClassFileTransformer transformer);
ClassFileTransformer
是一个接口,只有一个transform
方法,它在主程序的main
方法执行前,装载的每个类都要经过transform
执行一次,可以将它称为转换器。我们可以实现这个方法来重新定义Class。
redefineClasses
我们可以直观地从方法的名字上来理解它的作用,重定义class,通俗点来讲的话就是实现指定类的替换。方法定义如下:
void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException;
它的参数是可变长的ClassDefinition
数组,再看一下ClassDefinition
的构造方法:
public ClassDefinition(Class<?> theClass,byte[] theClassFile) {...}
ClassDefinition
中指定了的Class对象和修改后的字节码数组,简单来说,就是使用提供的类文件字节,替换了原有的类。并且,在redefineClasses
方法重定义的过程中,传入的是ClassDefinition
的数组,它会按照这个数组顺序进行加载,以便满足在类之间相互依赖的情况下进行更改。
retransformClasses
retransformClasses
应用于agentmain模式,可以在类加载之后重新定义Class,即触发类的重新加载。首先看一下该方法的定义:
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
它的参数classes
是需要转换的类数组,可变长参数也说明了它和redefineClasses
方法一样,也可以批量转换类的定义。
其他
除了这几个主要的方法外,Instrumentation
中还有一些其他方法,这里仅简单列举一下常用方法的功能:
removeTransformer
:删除一个ClassFileTransformer
类转换器getAllLoadedClasses
:获取当前已经被加载的ClassgetInitiatedClasses
:获取由指定的ClassLoader
加载的ClassgetObjectSize
:获取一个对象占用空间的大小appendToBootstrapClassLoaderSearch
:添加jar包到启动类加载器appendToSystemClassLoaderSearch
:添加jar包到系统类加载器isNativeMethodPrefixSupported
:判断是否能给native方法添加前缀,即是否能够拦截native方法setNativeMethodPrefix
:设置native方法的前缀
应用
到这里,我们就已经简单地了解了两种模式的实现方法,但是作为高质量程序员,我们肯定不能满足于只用代理单纯地打印语句,在实际的工作环境中,可能更多的是去动态的修改class文件的字节码,这时候就可以借助Javassist或者ASM等字节码操作框架来更简单的修改字节码文件。
下面,我们就通过一个简单的例子,看看如何将Java agent和Javassist结合在一起使用。首前先引入javassist的依赖:
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.20.0-GA</version>
</dependency>
我们要实现的功能是通过代理,来计算方法执行的时间。premain代理部分和之前基本一致,先添加一个转换器:
public class Fruit {
public void getFruit(){
System.out.println("banana");
}
}
public class FruitPreMainAgent {
public static void premain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new LogTransformer());
}
static class LogTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer)
throws IllegalClassFormatException {
if (!className.equals("javaagent/Fruit"))
return null;
try {
return calculate();
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
byte[] calculate() throws Exception {
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = pool.get("javaagent.Fruit");
CtMethod ctMethod = ctClass.getDeclaredMethod("getFruit");
CtMethod copyMethod = CtNewMethod.copy(ctMethod, ctClass, new ClassMap());
ctMethod.setName("getFruit$agent");
StringBuffer body = new StringBuffer("{\n")
.append("long begin = System.nanoTime();\n")
.append("getFruit$agent($$);\n")
.append("System.out.println(\"use \"+(System.nanoTime() - begin) +\" ns\");\n")
.append("}");
copyMethod.setBody(body.toString());
ctClass.addMethod(copyMethod);
return ctClass.toBytecode();
}
}
}
在上面的代码中,主要实现了这些功能:
- 利用全限定名获取类
CtClass
- 根据方法名获取方法
CtMethod
,并通过CtNewMethod.copy
方法复制一个新的方法 - 修改旧方法的方法名为
getFruit$agent
- 通过
setBody
方法修改复制出来方法的内容,在新方法中进行了逻辑增强并调用了旧方法,最后将新方法添加到类中
主程序通过反射看一下:
public class FruiltTest {
public static void main(String[] args) throws InvocationTargetException, IllegalAccessException {
for (Method method : Fruit.class.getDeclaredMethods()) {
System.out.println(method.getName());
method.invoke(new Fruit());
System.out.println("-------");
}
}
}
查看结果,可以看到类中确实已经新增了一个方法:
总结
虽然我们在平常的工作中,直接用到Java Agent的场景可能并不是很多,但是在热部署、监控、性能分析等工具中,它们可能隐藏在业务系统的角落里,一直在默默发挥着巨大的作用。