战损版JavaAgent方法耗时统计工具实现

新来的实习生妹妹故意刁难我,
说想让我实现一个方法耗时统计工具,
不能用切面,
这能难倒我嘛,Java Agent安排上。
前言
本篇文章将实现一个超绝战损版的基于Java Agent的方法耗时统计工具。
整体内容分为:
Java Agent原理简析;
方法耗时统计工具实现;
方法耗时工具的Springboot的starter包实现。
正文
一. Java Agent原理简析
理解啥是Java Agent前,需要先介绍一下JVM TI(JVM Tool Interface)。
JVM TI是JVM提供的用于访问JVM各种状态的一套编程接口。基于JVM TI可以注册各种JVM事件钩子函数,当JVM事件发生时,触发钩子函数以对相应的JVM事件进行处理。关于JVM TI的详细文档,可以参考JVMTM Tool Interface。
那么Java Agent可以理解为就是JVM TI的一种具体实现。关于Java Agent,可以概括其特性如下。
是一个jar包;
无法独立运行;
(JDK1.5)可以在程序运行前被加载,加载后会调用到Java Agent提供的入口函数premain(String agentArgs, Instrumentation inst);
(JDK1.6开始)可以在程序运行中被加载,加载后会调用到Java Agent提供的入口函数agentmain(String agentArgs, Instrumentation inst)。
如果想要agentmain() 方法被调用,则需要将Agent程序attach到主进程的JVM上,这时就需要使用到com.sun.tools.attach包里提供的Attach API,Agent被attach到JVM后,agent的agentmain() 方法就会被调用。
最后说明一下Java Agent的入口函数中的类型为Instrumentation的参数。Instrument是JVM提供的一套能够对Java代码进行插桩操作的服务能力,JDK1.5的Instrument支持在JVM启动并加载类时修改类,Instrument从JDK1.6开始支持在程序运行时修改类。Instrument提供的重要方法如下所示。
也就是可以向Instrumentation注册ClassFileTransformer。
JDK1.5时只能通过addTransformer(ClassFileTransformer) 方法注册ClassFileTransformer,此时每个类被加载到JVM中之前会调用到注册的ClassFileTransformer的transform() 方法,并可以在其中先改变类定义后再将类加载到JVM中。
JDK1.6开始提供了addTransformer(ClassFileTransformer, boolean) 方法,当第二个参数传入false时,效果与addTransformer(ClassFileTransformer) 方法一样,当第二个参数传入true时,那么此时注册的ClassFileTransformer除了在类被加载到JVM中之前会调用到,还会在retransformClasses(Class<?>... classes) 方法调用时被调用到,也就是此时注册的ClassFileTransformer支持对通过retransformClasses(Class<?>... classes) 方法传入的类进行重定义然后再重加载到JVM中。
二. 整体构思
首先,因为是超绝战损版,所以我们的方法耗时统计,伪代码可以表示如下。
其次,我们需要编写一个Java Agent,且希望能够在程序运行时加载这个Java Agent,所以编写的Java Agent需要提供入口函数agentmain(String agentArgs, Instrumentation inst),此时Java Agent需要通过com.sun.tools.attach包里提供的Attach API来加载并附加到主进程JVM上。
然后,在Java Agent中,我们需要初始化ClassFileTransformer,然后将ClassFileTransformer注册到Instrumentation,再然后获取到需要重定义的类并通过Instrumentation的retransformClasses(Class<?>... classes) 方法将这些类传递到注册的ClassFileTransformer中。
接着,在我们自定义的ClassFileTransformer中,需要借助Javassist的能力,为相应的类添加方法耗时统计的代码片段,并完成重加载。
最后,还需要编写一个测试程序来验证我们的超绝战损版方法耗时打印工具的功能。
整体的一个流程示意图如下。

三. 方法耗时统计工具实现
现在开始代码实现。首先创建一个Maven工程,命名为myagent-core,POM文件如下所示。
POM文件中主要就是引入必须的javassist的依赖,以及通过打包插件将依赖打入jar包。
然后需要创建
src/main/resources/META-INF目录,然后在其中创建MANIFEST.MF文件,内容如下所示。
Agent-Class: com.lee.learn.agent.core.MethodAgentCan-Redefine-Classes: trueCan-Retransform-Classes: true
特别注意最后有一个空行。
现在开始编写Java Agent的代码。首先是自定义一个转换器,命名为MTransformer,并实现ClassFileTransformer接口,代码现如下。
在MTransformer的构造函数中,需要传入目标类的类对象的集合,目的就是做到动态的控制对哪些类添加方法耗时统计的逻辑。
最后定义Java Agent的主体类,命名为MethodAgent,代码如下所示。
至此Java Agent就编写完毕,整个的工程目录如下所示。

可以先将Java Agent进行打包,并将得到的jar包放在磁盘的某个路径下,这里就放在D盘的根路径下(D:\
myagent-core-jar-with-dependencies.jar)。
下面编写测试工程。首先创建Maven工程,命名为myagent-local-test,POM文件如下所示。
主要就是引入sun的工具包。
然后创建两个测试目标类,InnerTask位于
com.lee.learn.agent.test.inner包路径下,OuterTask位于
com.lee.learn.agent.test.outter包路径下,实现如下所示。
然后就是主测试方法,如下所示。
因为事先已经将Java Agent的jar包放在了D盘根路径下,所以在测试程序中attach到主进程中后,直接通过jar包加载Java Agent。
测试工程目录结构如下所示。

运行测试程序,打印如下所示。

四. 方法耗时统计工具的Springboot的starter包实现
第三节中的方法耗时统计工具,功能是实现了,并且主要就是依靠一个Java Agent的jar包,但是实在是太简陋了,作为超绝战损版也完全不能看,缺点如下。
Java Agent的jar包需要通过某种手段才能让应用程序找得到。例如容器中的一个应用,要使用这个Java Agent,首先要做的事情就是下载jar包,然后拷贝到容器中的某个路径下;
对用户代码产生了侵入。在测试程序中,编写了代码并调用了com.sun.tools.attach包的VirtualMachine的相关API才实现了attach主进程以及加载Java Agent,这在实际使用中,大家肯定都是不愿意做这个事情的。
鉴于第三节中的做法实在是不优雅,所以本节会编写一个方法耗时统计的starter包,只需要在Springboot工程中引用这个包,然后做少量配置,就能够实现和第三节一样的方法耗时统计效果。
整体会创建三个工程,如下所示。
myagent-package工程。该工程仅需要做一件事情,就是存放Java Agent的jar包;
myagent-starter工程。starter包,主要完成的事情就是完成Java Agent的加载;
myagent-starter-test工程。测试工程。
主要的做法和部分代码,参考了Arthas的Springboot的starter包
arthas-spring-boot-starter的实现。
1. myagent-package工程
创建一个Maven工程,命名为myagent-package,然后将Java Agent的jar包打成zip包并放在myagent-package工程的src/main/resources目录下,如下所示。

最后将myagent-package通过install安装到本地仓库。
2. myagent-starter工程
创建一个Maven工程,POM文件如下所示。
上述POM文件中引入的zt-zip是一个zip包工具,然后最关键的就是需要引入myagent-package的依赖,Java Agent的jar包的压缩包就在这个依赖包中。
myagent-starter的核心思路就是基于Springboot的SPI机制注册一个ApplicationListener监听器,监听的事件是
ApplicationEnvironmentPreparedEvent,也就是在外部配置加载完毕后就开始加载Java Agent。
现在先创建
src/main/resources/META-INF目录,然后创建spring.factories文件,内容如下所示。
自定义的事件监听器
MyAgentApplicationListener实现如下所示。
监听到
ApplicationEnvironmentPreparedEvent事件后,就会创建一个MyAgentLoader加载器并调用其load() 方法。Java Agent的加载器MyAgentLoader实现如下。
MyAgentLoader#load方法的主要思路如下。
创建用于存放Java Agent的jar包的临时目录;
从classpath下找到Java Agent的zip包;
将Java Agent的zip包解压到刚创建出来的临时目录中;
拿到主进程Id;
从Environment中拿到配置的目标包路径;
基于VirtualMachine附加到主进程上;
加载Java Agent,并传入目标包路径。
至此starter包就编写完毕。myagent-starter工程的目录结构如下所示。

最后还需要将myagent-starter通过install安装到本地仓库。
3. myagent-starter-test工程
现在开始编写测试工程并完成测试,测试工程的目录结构如下所示。

是一个简单的三层架构,首先POM文件如下所示。
然后MyController,MyService和MyDao的实现如下。
也就是模拟每个方法会耗时2秒。最后编写配置文件,如下所示。
配置仅对controller和dao包下的类进行方法耗时统计。
最后启动Springboot程序,并调用MyController接口,打印如下。

方法耗时统计确实只针对controller和dao包生效了,至此测试完毕。
总结
Java Agent就是一个无法独立运行的jar包,其加载时机可以是程序运行前和程序运行中,也就是基于Java Agent可以实现在程序运行前和程序运行中来动态的修改类。
方法耗时统计,简单的思路就是使用切面去切,首先想到的就是使用Spring的AOP来切,但是Spring的AOP都知道是基于动态代理,但是无论是JDK动态代理,还是CGLIB动态代理,都有其局限性(貌似AspectJ可行,但这不是本文的重点),不是所有类都能切,所以本文采取的思路就是基于Java Agent再结合Javassist的能力,完成向目标类的方法插入方法耗时统计的逻辑。
一个Java Agent的jar包,是一个很精致的jar包,但是有些时候想要这个jar包被加载,还真有点头疼,主要是放哪里怎么解决,所以提供一个Springboot的starter包貌似是一个很好的解决思路,只需要在程序中引入提供的starter包,那么我们的程序最终无论是虚机部署,还是容器部署,我们都能拿到Java Agent并加载。
本文的方法耗时统计,之所以称为战损版,是因为仅仅做了耗时的一个打印,但是真正有用的是啥,那就是能够通过链路Id将方法调用链路以及耗时串起来,但是这也不是本文的重点。