Java Agent技术
JVMTI (JVM Tool Interface)是Java虚拟机对外提供的Native编程接口,通过JVMTI,外部进程可以获取到运行时JVM的诸多信息,比如线程、GC等。
Agent是一个运行在目标JVM的特定程序,它的职责是负责从目标JVM中获取数据,然后将数据传递给外部进程。
Agent用来监测和协助 JVM ,甚至能够替换和修改某些类的定义。有了这样的功能,开发者就可以实现更为灵活的运行时虚拟机监控和 Java 类操作了,这样的特性实际上提供了一种虚拟机级别支持的 AOP 实现方式,使得开发者无需对 JDK 做任何升级和改动,就可以实现某些 AOP 的功能了。
常见的使用场景如下:
- 无侵入统计方法耗时
- 无侵入进行类增强
- 动态修改类,以临时修复线上漏洞(如log4j2的漏洞)
- 动态调试(如 Java-debug-tool,Arthas)
Agent的两种实现方式
基于C来实现
JVMTI的官方文档,介绍了基于C或者C++来实现jvm agent。
1 | Writing Agents |
写好的agent,通过在jvm增加启动参数来生效,或者通过 JVMTI的attach机制去依附到目标jvm。
windows下,agent的表现形式为一个ddl文件;linux下则为.so文件
我们进行本地调试的时候,就会用到agent。
1 | java -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=8000 -jar test.jar |
等同于下面的命令
1 | java -Xdebug -agentlib:jdwp:transport=dt_socket,server=y,suspend=n,address=8000 -jar test.jar |
jdwp.dll作为一个jvm内置的agent,不需要上文说的-agentlib来启动agent。这里通过-Xrunjdwp来启动该agent。
以下文章都是讨论基于java实现的agent,如何开发C或者C++的agent本文不做说明,可以参考JVMTI官方文档(文末)。
基于Java实现
Java Agent 是从 JDK1.5 开始引入的,允许用户通过java开发JVM的agent。
java.lang.instrument包是开发Agent的核心模块,使用 Instrument,开发者可以构建一个独立于应用程序的代理程序(Agent)。
1 | java -javaagent:xxAgent.jar -jar test.jar |
agent相关的jvm参数
-agentlib:libname[=options]
用于装载本地lib包;
其中libname为本地代理库文件名,默认搜索路径为环境变量PATH中的路径,options为传给本地库启动时的参数,多个参数之间用逗号分隔。
在Windows平台上jvm搜索本地库名为libname.dll的文件,在linux上jvm搜索本地库名为libname.so的文件,搜索路径环境变量在不同系统上有所不同。
-agentpath:pathname[=options]
按全路径装载本地库,不再搜索PATH中的路径;其他功能和agentlib相同
-javaagent:jarpath[=options]
指定jvm启动时装入java语言基础设施代理。jarpath文件中的mainfest文件必须有Premain-Class(启动前捆绑时需要), Agent-Class(运行时捆绑时需要)属性。
Java Agent的实现
Instrument是JVM提供的一个可以修改已加载类的类库,专门为Java语言编写的插桩服务提供支持。它需要依赖JVMTI的Attach API机制实现。
在JDK 1.6以前,Instrument只能在JVM刚启动开始加载类时生效,而在JDK 1.6之后,Instrument支持了在运行时对类定义的修改。
Instrument整体流程


启动时修改

运行时修改

运行时修改主要是通过JVM的attach机制来请求目标JVM加载对应的agent,执行native函数的Agent_OnAttach方法,在方法执行时,执行如下步骤:
- 创建InstrumentationImpl对象
- 监听ClassFileLoadHook事件
- 调用InstrumentationImpl的loadClassAndCallAgentmain方法,在这个方法里会去调用javaagent里MANIFEST.MF里指定的Agentmain-Class类的agentmain方法
ClassFileLoadHook和TransFormClassFile
从前面可以看出整体流程中有两个部分是具有共性的,分别为:
- ClassFileLoadHook
- TranFormClassFile
ClassFileLoadHook是一个JVMTI事件,该事件是Instrument Agent的一个核心事件,主要是在读取字节码文件回调时调用,内部调用了TransFormClassFile函数。
TransFormClassFile的主要作用是调用java.lang.instrument.ClassFileTransformer的tranform方法,该方法由开发者实现,通过instrument的addTransformer方法进行注册。
通过以上描述可以看出在字节码文件加载的时候,会触发ClassFileLoadHook事件,该事件调用TransFormClassFile,通过经由instrument的addTransformer注册的方法完成整体的字节码修改。
对于已加载的类,需要调用retransformClass函数,然后经由redefineClasses函数,在读取已加载的字节码文件后,若该字节码文件对应的类关注了ClassFileLoadHook事件,则调用ClassFileLoadHook事件。后续流程与类加载时字节码替换一致。
开发Java Agent的流程
实现Agent启动方法
Java Agent支持目标JVM启动时加载,也支持在目标JVM运行时加载,这两种不同的加载模式会使用不同的入口函数,如果需要在目标JVM启动的同时加载Agent,那么可以选择实现下面的方法:
1 | [1] public static void premain(String agentArgs, Instrumentation inst); |
JVM将首先寻找[1],如果没有发现[1],再寻找[2]。
这两组方法的第一个参数AgentArgs是随同 “– javaagent”一起传入的程序参数,如果这个字符串代表了多个参数,就需要自己解析这些参数。inst是Instrumentation类型的对象,是JVM自动传入的,我们可以拿这个参数进行类增强等操作。
如果希望在目标JVM运行时加载Agent,则需要实现下面的方法:
1 | [1] public static void agentmain(String agentArgs, Instrumentation inst); |
指定Main-Class
Agent需要打包成一个jar包,在ManiFest属性中指定“Premain-Class”或者“Agent-Class”:
1 | Premain-Class: class |
挂载到目标JVM
将编写的Agent打成jar包后,就可以挂载到目标JVM上去了。
如果选择在目标JVM启动时加载Agent,则可以使用 “-javaagent:jarpath[=options] ”
如果想要在运行时挂载Agent到目标JVM,就需要做一些额外的开发了。
com.sun.tools.attach.VirtualMachine 这个类代表一个JVM抽象,可以通过这个类找到目标JVM,并且将Agent挂载到目标JVM上。下面是使用com.sun.tools.attach.VirtualMachine进行动态挂载Agent的一般实现:
1 | private void attachAgentToTargetJVM() throws Exception { |
首先通过指定的进程ID找到目标JVM,然后通过Attach挂载到目标JVM上,执行加载Agent操作。VirtualMachine的Attach方法就是用来将Agent挂载到目标JVM上去的,而Detach则是将Agent从目标JVM卸载。
字节码框架
Byte Buddy, cglib, Javassist and JDK Proxy 性能比较,括号内为标准差。
ASM
字节码老大哥,性能好,代码可读性差,使用成本很高。cglib、byte buddy等框架底层都是基于ASM实现的。
学习成本很高,需要了解Java虚拟机规范相关的知识。因为你的每一步操作,都是在操作字节码指令,但这种最接近底层的方式,也是最快的方式。
CGLIB
cglib是一种动态生成字节码的高级库,被大量框架使用,底层使用ASM实现。
javassist
能够在运行时定义、编译新类,并在JVM加载时修改类文件。相比ASM的缺点就是臃肿,优点就是生成新字节码非常方便,直接拼java源码就行了,学习成本比ASM大大降低,是比较通用的开发java agent的框架。
ByteBuddy
也是基于ASM的一种高级字节码生成方案,方法调用性能优秀。
框架对比总结
性能比较
ASM > ByteBuddy > javassist
学习成本
javassist = ByteBuddy > ASM
ps:byteBuddy进行增强的时候,会出现辅助类,当类进行重新加载的时候会报错,详见arthas和skywalking的冲突 。
1 | ByteBuddy generates auxiliary classes with different random names every time. When other javaagent executes retransform, it will trigger the SkyWalking agent to enhance the class again. The bytecode regenerated by ByteBuddy is changed, the fields and imports are modified, and the retransform fails. |
开发agent的一些问题
多个agent增强同一个程序,加载顺序
一个java程序中-javaagent参数的个数是没有限制的,所以可以添加任意多个javaagent。所有的java agent会按照你定义的顺序执行,例如:
java -javaagent:agent1.jar -javaagent:agent2.jar -jar MyProgram.jar
MyAgent1.premain -> MyAgent2.premain -> MyProgram.main
agent 导致的包冲突
javaagent的代码永远都是被应用类加载器( Application ClassLoader)所加载,和应用代码的真实加载器无关。
当agent包和应用包 使用的相同的三方包且版本不同时,会产出类冲突,出现一些类、方法找不到的case。
解决这种问题,一般有两种方法。
方法一
打包agent的时候把三方包都打到agent内部的一个shade package下。
比如 Uber 的 jvm-profiler,其在构建 Java Agent 的 Jar 时使用用 Maven Shade Plugin 将类进行重定位,将资源进行排除。
方法二
自定义类加载器,可以参考 elastic 的 Java Agent
AgentMain.java
ShadedClassLoader.java
其实现思路在 PR:Isolated agent classloader by felixbarny · Pull Request #2109 · elastic/apm-agent-java · GitHub 中有详细解释。
多agent冲突
- 因为agent有加载顺序,如果agentA先加载,并在代码里创建了线程池,这时候TTL agent对线程池的加载就失效了,如先加载skywalking agent再加载 TTL agent
- 使用了byteBuddy,修改了类的字段产生随机辅助字段,当出现 reTransform 的时候,会导致无法reTransform。参考arthas和skywalking的冲突
对开发agent的一些思考
公司基于agent技术,做了一套监控平台,叫做天眼系统。接入天眼系统,需要在启动脚本上增加天眼agent的启动配置,同时项目里需要引入天眼系统的 collector jar包。
监控的所有业务功能,都在 collector 包内,agent只是做一个拉起的作用,并把Instrumentation对象传入到collector包内,进行增强和监控。
刚开始一直不明白,为什么要这么设计,和同事交流后,这么做大概有以下几点好处
当有新的监控功能升级的时候,只要pom文件里面改一下 collector 的版本就可以了,不需要去修改启动脚本的参数,这样就降低了部署成本,否则要一个个应用去改脚本并且有问题要回滚的时候,无法快速回滚。加载和实现逻辑解耦分离,方便业务逻辑侧升级。
参考
优秀开源框架参考&agent冲突相关
TTL
[低版本skywalking与LinkAgent不兼容怎么办?记一次详细的解决过程]https://juejin.cn/post/7076384651556126757
Skywalking
Skywalking support class cache for ByteBuddy
when use skywalking agent ,arthas is can‘t work well
arthas
class redefinition failed: attempted to change the schema (add/remove fields) 分析排查