偷天换日,用 JavaAgent 欺骗你的 JVM
熟悉 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
在主程序运行后执行
下面我们就分别看一下在两种模式下,如何动手实现一个 agent 代理程序。
Premain 模式
Premain 模式允许在主程序执行前执行一个 agent 代理,实现起来非常简单,下面我们分别实现两个组成部分。
agent
先写一个简单的功能,在主程序执行前打印一句话,并打印传递给代理的参数:
在写完了 agent 的逻辑后,需要把它打包成jar
文件,这里我们直接使用 maven 插件打包的方式,在打包前进行一些配置。
配置的打包参数中,通过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
命令打包:
打包完成后生成myAgent-1.0.jar
文件,我们可以解压jar
文件,看一下生成的MANIFEST.MF
文件:
可以看到,添加的属性已经被加入到了文件中。到这里,agent 代理部分就完成了,因为代理不能够直接运行,需要附着于其他程序,所以下面新建一个工程来实现主程序。
主程序
在主程序的工程中,只需要一个能够执行的main
方法的入口就可以了。
在主程序完成后,要考虑的就是应该如何将主程序与 agent 工程连接起来。这里可以通过-javaagent
参数来指定运行的代理,命令格式如下:
并且,可以指定的代理的数量是没有限制的,会根据指定的顺序先后依次执行各个代理,如果要同时运行两个代理,就可以按照下面的命令执行:
以我们在 idea 中执行程序为例,在VM options
中加入添加启动参数:
执行main
方法,查看输出结果:
根据执行结果的打印语句可以看出,在执行主程序前,依次执行了两次我们的 agent 代理。可以通过下面的图来表示执行代理与主程序的执行顺序。
缺陷
在提供便利的同时,premain 模式也有一些缺陷,例如如果 agent 在运行过程中出现异常,那么也会导致主程序的启动失败。我们对上面例子中 agent 的代码进行一下改造,手动抛出一个异常。
再次运行主程序:
可以看到,在 agent 抛出异常后主程序也没有启动。针对 premain 模式的一些缺陷,在 jdk1.6 之后引入了 agentmain 模式。
Agentmain 模式
agentmain 模式可以说是 premain 的升级版本,它允许代理的目标主程序的 jvm 先行启动,再通过attach
机制连接两个 jvm,下面我们分 3 个部分实现。
agent
agent 部分和上面一样,实现简单的打印功能:
修改 maven 插件配置,指定Agent-Class
:
主程序
这里我们直接启动主程序等待代理被载入,在主程序中使用了System.in
进行阻塞,防止主进程提前结束。
attach 机制
和 premain 模式不同,我们不能再通过添加启动参数的方式来连接 agent 和主程序了,这里需要借助com.sun.tools.attach
包下的VirtualMachine
工具类,需要注意该类不是 jvm 标准规范,是由 Sun 公司自己实现的,使用前需要引入依赖:
VirtualMachine
代表了一个要被附着的 java 虚拟机,也就是程序中需要监控的目标虚拟机,外部进程可以使用VirtualMachine
的实例将 agent 加载到目标虚拟机中。先看一下它的静态方法attach
:
通过attach
方法可以获取一个 jvm 的对象实例,这里传入的参数是目标虚拟机运行时的进程号pid
。也就是说,我们在使用attach
前,需要先获取刚才启动的主程序的pid
,使用jps
命令查看线程pid
:
获取到主程序AgentmainTest
运行时pid
是 16392,将它应用于虚拟机的连接。
在获取到VirtualMachine
实例后,就可以通过loadAgent
方法可以实现注入 agent 代理类的操作,方法的第一个参数是代理的本地路径,第二个参数是传给代理的参数。执行AttachTest
,再回到主程序AgentmainTest
的控制台,可以看到执行了了 agent 中的代码:
这样,一个简单的 agentMain 模式代理就实现完成了,可以通过下面这张图再梳理一下三个模块之间的关系。
应用
到这里,我们就已经简单地了解了两种模式的实现方法,但是作为高质量程序员,我们肯定不能满足于只用代理单纯地打印语句,下面我们再来看看能怎么利用 Java Agent 搞点实用的东西。
在上面的两种模式中,agent 部分的逻辑分别是在premain
方法和agentmain
方法中实现的,并且,这两个方法在签名上对参数有严格的要求,premain
方法允许以下面两种方式定义:
agentmain
方法允许以下面两种方式定义:
如果在 agent 中同时存在两种签名的方法,带有Instrumentation
参数的方法优先级更高,会被 jvm 优先加载,它的实例inst
会由 jvm 自动注入,下面我们就看看能通过Instrumentation
实现什么功能。
Instrumentation
先大体介绍一下Instrumentation
接口,其中的方法允许在运行时操作 java 程序,提供了诸如改变字节码,新增 jar 包,替换 class 等功能,而通过这些功能使 Java 具有了更强的动态控制和解释能力。在我们编写 agent 代理的过程中,Instrumentation
中下面 3 个方法比较重要和常用,我们来着重看一下。
addTransformer
addTransformer
方法允许我们在类加载之前,重新定义 Class,先看一下方法的定义:
ClassFileTransformer
是一个接口,只有一个transform
方法,它在主程序的main
方法执行前,装载的每个类都要经过transform
执行一次,可以将它称为转换器。我们可以实现这个方法来重新定义 Class,下面就通过一个例子看看具体如何使用。
首先,在主程序工程创建一个Fruit
类:
编译完成后复制一份 class 文件,并将其重命名为Fruit2.class
,再修改Fruit
中的方法为:
创建主程序,在主程序中创建了一个Fruit
对象并调用了其getFruit
方法:
这时执行结果会打印apple
,接下来开始实现 premain 代理部分。
在代理的premain
方法中,使用Instrumentation
的addTransformer
方法拦截类的加载:
FruitTransformer
类实现了ClassFileTransformer
接口,转换 class 部分的逻辑都在transform
方法中:
在transform
方法中,主要做了两件事:
因为
addTransformer
方法不能指明需要转换的类,所以需要通过className
判断当前加载的 class 是否我们要拦截的目标 class,对于非目标 class 直接返回原字节数组,注意className
的格式,需要将类全限定名中的.
替换为/
读取我们之前复制出来的 class 文件,读入二进制字符流,替换原有
classfileBuffer
字节数组并返回,完成 class 定义的替换
将 agent 部分打包完成后,在主程序添加启动参数:
再次执行主程序,结果打印:
这样,就实现了在main
方法执行前 class 的替换。
redefineClasses
我们可以直观地从方法的名字上来理解它的作用,重定义 class,通俗点来讲的话就是实现指定类的替换。方法定义如下:
它的参数是可变长的ClassDefinition
数组,再看一下ClassDefinition
的构造方法:
ClassDefinition
中指定了的 Class 对象和修改后的字节码数组,简单来说,就是使用提供的类文件字节,替换了原有的类。并且,在redefineClasses
方法重定义的过程中,传入的是ClassDefinition
的数组,它会按照这个数组顺序进行加载,以便满足在类之间相互依赖的情况下进行更改。
下面通过一个例子来看一下它的生效过程,premain 代理部分:
主程序可以直接复用上面的,执行后打印:
可以看到,用我们指定的 class 文件的字节替换了原有类,即实现了指定类的替换。
retransformClasses
retransformClasses
应用于 agentmain 模式,可以在类加载之后重新定义 Class,即触发类的重新加载。首先看一下该方法的定义:
它的参数classes
是需要转换的类数组,可变长参数也说明了它和redefineClasses
方法一样,也可以批量转换类的定义。
下面,我们通过例子来看看如何使用retransformClasses
方法,agent 代理部分代码如下:
看一下这里调用的addTransformer
方法的定义,与上面略有不同:
ClassFileTransformer
转换器依旧复用了上面的FruitTransformer
,重点看一下新加的第二个参数,当canRetransform
为true
时,表示允许重新定义 class。这时,相当于调用了转换器ClassFileTransformer
中的transform
方法,会将转换后 class 的字节作为新类定义进行加载。
主程序部分代码,我们在死循环中不断的执行打印语句,来监控类是否发生了改变:
最后,使用 attach api 注入 agent 代理到主程序中:
回到主程序控制台,查看运行结果:
可以看到在注入代理后,打印语句发生变化,说明类的定义已经被改变并进行了重新加载。
其他
除了这几个主要的方法外,Instrumentation
中还有一些其他方法,这里仅简单列举一下常用方法的功能:
removeTransformer
:删除一个ClassFileTransformer
类转换器getAllLoadedClasses
:获取当前已经被加载的 ClassgetInitiatedClasses
:获取由指定的ClassLoader
加载的 ClassgetObjectSize
:获取一个对象占用空间的大小appendToBootstrapClassLoaderSearch
:添加 jar 包到启动类加载器appendToSystemClassLoaderSearch
:添加 jar 包到系统类加载器isNativeMethodPrefixSupported
:判断是否能给 native 方法添加前缀,即是否能够拦截 native 方法setNativeMethodPrefix
:设置 native 方法的前缀
Javassist
在上面的几个例子中,我们都是直接读取的 class 文件中的字节来进行 class 的重定义或转换,但是在实际的工作环境中,可能更多的是去动态的修改 class 文件的字节码,这时候就可以借助 javassist 来更简单的修改字节码文件。
简单来说,javassist 是一个分析、编辑和创建 java 字节码的类库,在使用时我们可以直接调用它提供的 api,以编码的形式动态改变或生成 class 的结构。相对于 ASM 等其他要求了解底层虚拟机指令的字节码框架,javassist 真的是非常简单和快捷。
下面,我们就通过一个简单的例子,看看如何将 Java agent 和 Javassist 结合在一起使用。首前先引入 javassist 的依赖:
我们要实现的功能是通过代理,来计算方法执行的时间。premain 代理部分和之前基本一致,先添加一个转换器:
在calculate
方法中,使用 javassist 动态的改变了方法的定义:
在上面的代码中,主要实现了这些功能:
利用全限定名获取类
CtClass
根据方法名获取方法
CtMethod
,并通过CtNewMethod.copy
方法复制一个新的方法修改旧方法的方法名为
getFruit$agent
通过
setBody
方法修改复制出来方法的内容,在新方法中进行了逻辑增强并调用了旧方法,最后将新方法添加到类中
主程序仍然复用之前的代码,执行查看结果,完成了代理中的执行时间统计功能:
这时候我们可以再通过反射看一下:
查看结果,可以看到类中确实已经新增了一个方法:
除此之外,javassist 还有很多其他的功能,例如新建 Class、设置父类、读取和写入字节码等等,大家可以在具体的场景中学习它的用法。
总结
虽然我们在平常的工作中,直接用到 Java Agent 的场景可能并不是很多,但是在热部署、监控、性能分析等工具中,它们可能隐藏在业务系统的角落里,一直在默默发挥着巨大的作用。
本文从 Java Agent 的两种模式入手,手动实现并简要分析了它们的工作流程,虽然在这里只利用它们完成了一些简单的功能,但是不得不说,正是 Java Agent 的出现,让程序的运行不再循规蹈矩,也为我们的代码提供了无限的可能性。
如果文章对您有所帮助,欢迎关注公众号
码农参上
。有趣、深入、与你聊聊技术。
版权声明: 本文为 InfoQ 作者【码农参上】的原创文章。
原文链接:【http://xie.infoq.cn/article/4cf90f8868c63b7e6d94fc2f8】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论