小王是一个刚来不久的妹子,啊呸,是一个刚来不久的程序媛,经常垂头丧气的~让我很是不解,终于有一天我怕小王哪天想不开离职了岂不是会增加我的工作量(部门为数不多的妹子 - 1)?于是乎,我主动找小王进行了谈心找到了问题所在,原来是小王编程经验不足,不知道如何巧妙的进行日志打印,那么因果关系就总结出来了:经验不足导致编码经常出错,编码出错由于日志未打印导致排查困难,排查困难导致开发抑郁。查到问题的原因,那么进行对症下药即可~
其实以上问题我相信很多小伙伴都遇到过,开发过程中未出现的错误在上线后就频频出现,那么只能不断的进行添加日志打印然后再打包上传进行问题跟踪,一天的时间绝大部分都浪费在了打包上传的上面。那么能不能直接进行bug跟踪,然后查看到问题出错的所在?这种需求不亚于给奔跑中的汽车更换轮胎,匪夷所思却又无可奈何~其实有开发经验的小伙伴已经想出来一个中间件,那就是 Arthas!但是这篇文章不是介绍如何使用 Archas,而是我们自己能不能实现这种动态调试的技能?那么就进入我们今天的整体 --- JAVA Agent 技术
Java Instrument
这个玩意并不是什么 Java 的新特性,早在 JDK 1.5 的时候就诞生了,位于
java.lang.instrument.Instrumentation 中,它的作用就是用来在运行的时候重新加载某个类的 calss 文件的 api。
这种类的实现方式其实是一种 Java Agent 技术,我们这里可以顺带了解一下什么是 Java Agent。
一、Java Agent
代理这个词对于我们开发人员来说并不默认,我们经常用到的 AOP 面向切面编程用到的就是代理方式。它可以动态切入某个面,进行代码增强 。这种不用重复补充轮子的方式大大增加了我们开发效率,那么这里捕获到了一个关键词 动态。那么 Java Agent 如何实现?那就可以说到 JVMTI(JVM Tool Interface) ,这是Java 虚拟机对外提供的 Native 编程接口,通过它我们可以获取运行时JVM的诸多信息,而 Agent 是一个运行在目标 JVM 的特定程序,它可以从目标 JVM 获取数据,然后将数据传递给外部进程,然后外部进程可以根据获取到的数据进行动态Enhance。
那么 Java Agent 什么时候能够加载?
- 目标 JVM 启动时
- 目标 JVM 运行时
那么我们关注的是 运行时 ,这样子就能满足我们动态加载的需求。
而 Java Agent看上去这么高大上,我们要如何编写?当然在 JDK 1.5 之前,实现起来是具有困难性的,我们需要编写 Native 代码来实现,那么 JDK 1.5 之后我们就可以利用上面说到的 Java Instrument 来实现了!
首先我们先了解一下 Instrumentation 这个接口,其中有几个方法:
- addTransformer(ClassFileTransformer transformer, boolean canRetransform)
加入一个转换器 Transformer ,之后所有的目标类加载都会被 Transformer 拦截,可自定义实现 ClassFileTransformer 接口,重写该接口的唯一方法 transform() 方法,返回值是转换后的类字节码文件
- retransformClasses(Class<?>... classes)
对 JVM 已经加载的类重新触发类加载,使用上面自定义的转换器进行处理。该方法可以修改方法体,常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
- redefineClasses(ClassDefinition... definitions)
此方法用于替换类的定义,而不引用现有类文件字节。
- getObjectSize(Object objectToSize)
获取一个对象的大小
- AppendToBootstrapClassLoaderSearch(JarFile jarfile)
将一个 jar 文件添加到 bootstrap classload 的 classPath 中
- getAllLoadedClasses()
获取当前被 JVM 加载的所有类对象
redefineClasses 和 retransformClasses 补充说明
两者区别:
redefineClasses 是自己提供字节码文件替换掉已存在的 class 文件
retransformClasses 是在已存在的字节码文件上修改后再进行替换
替换后生效的时机
如果一个被修改的方法已经在栈帧中存在,则栈帧中的方法会继续使用旧字节码运行,新字节码会在新栈帧中运行
注意点
两个方法都是只能改变类的方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
二、实现 Agent
1、编写方法
上面我们已经说到了有两处地方可以进行 Java Agent 的加载,分别是 目标JVM启动时加载 和 目标JVM运行时加载,这两种不同的加载模式使用不同的入口函数:
1、JVM 启动时加载
入口函数如下所示:
// 函数1
public static void premain(String agentArgs, Instrumentation inst);
// 函数2
public static void premain(String agentArgs);
JVM 首先寻找函数1,如果没有发现函数1,则会寻找函数2
2、JVM 运行时加载
入口函数如下所示:
// 函数1
public static void agentmain(String agentArgs, Instrumentation inst);
// 函数2
public static void agentmain(String agentArgs);
与上述一致,JVM 首先寻找函数1,如果没有发现函数1,则会寻找函数2
这两组方法的第一个参数 agentArgs 是随同 “-javaagent” 一起传入的程序参数,如果这个字符串代表了多个参数,就需要自己解析这参数,inst 是 Instrumentation 类型的对象,是 JVM 自己传入的,我们可以那这个参数进行参数的增强操作。
2、声明方法
当定义完这两组方法后,要使之生效还需要手动声明,声明方式有两种:
1、使用 MANIFEST.MF 文件
我们需要创建
resources/META-INF.MANIFEST.MF 文件,当 jar包打包时将文件一并打包,文件内容如下:
Manifest-Version: 1.0
Can-Redefine-Classes: true # true表示能重定义此代理所需的类,默认值为 false(可选)
Can-Retransform-Classes: true # true 表示能重转换此代理所需的类,默认值为 false (可选)
Premain-Class: cbuc.life.agent.MainAgentDemo #premain方法所在类的位置
Agentmain-Class: cbuc.life.agent.MainAgentDemo #agentmain方法所在类的位置
2、如果是maven项目,在pom.xml加入
3、指定 agent
要让目标JVM认你这个 Agent ,你就要给目标JVM介绍这个 Agent
1、JVM 启动时加载
我们直接在 JVM 启动参数中加入 -javaagent 参数并指定 jar 文件的位置
# 将该类编译成 class 文件
javac TargetJvm.java
# 指定agent程序并运行该类
java -javaagent:./java-agent.jar TargetJvm
2、JVM 运行时加载
要实现动态调试,我们就不能将目标JVM停机后再重新启动,这不符合我们的初衷,因此我们可以使用 JDK 的 Attach Api 来实现运行时挂载 Agent。
Attach Api 是 SUN 公司提供的一套扩展 API,用来向目标 JVM 附着(attach)在目标程序上,有了它我们可以很方便地监控一个 JVM。Attach Api 对应的代码位于 com.sun.tools.attach包下,提供的功能也非常简单:
- 列出当前所有的 JVM 实例描述
- Attach 到其中一个 JVM 上,建立通信管道
- 让目标JVM加载Agent
该包下有一个类 Virtualmachine,它提供了两个重要的方法:
- VirtualMachine attach(String var0)
传递一个进程号,返回目标 JVM 进程的 vm 对象,该方法是 JVM进程之间指令传递的桥梁,底层是通过 socket 进行通信
- void loadAgent(String var1)
该方法允许我们将 agent 对应的 jar 文件地址作为参数传递给目标 JVM,目标 JVM 收到该命令后会加载这个 Agent
有了 Attach Api ,我们就可以创建一个java进程,用它attach到对应的jvm,并加载agent。
以下是简单的 Attach 代码实现:
注意:在mac上安装了的jdk是能直接找到 VirtualMachine 类的,但是在windows中安装的jdk无法找到,如果你遇到这种情况,请手动将你jdk安装目录下:lib目录中的tools.jar添加进当前工程的Libraries中。
上面代码十分简易的实现了 Attach 的方式,通过寻找当前系统中所有运行的 JVM 进程,然后通过比对 PID 来筛选出目标JVM,然后让 Agent 附着在目标 JVM 上。当然这边已经简易到直接在代码中指定目标JVM的 PID,这种方式在实际生产中是十分不可取的,我们可以通过动态参数的方式传入 PID~!而 Attach 的执行原理也不复杂,简单流程如下:
三、案例说明
我们上述简单聊了下 Java Agent 的实现过程,那我们下面也简单写个案例来理解一下 Java Agent 的实现过程~
我们上面说到可以使用 Java Instrumentation 来完成动态类修改的功能,并且在 Instrumentation 接口中我们可以通过 addTransformer() 方法来增加一个类转换器,类转换器由类 ClassFileTransformer 接口实现。该接口中有一个唯一的方法 transform() 用于实现类的转换,也就是我们可以增强类处理的地方!当类被加载的时候就会调用 transform()方法,实现对类加载的事件进行拦截并返回转换后新的字节码,通过 redefineClasses()或retransformClasses()都可以触发类的重新加载事件。
实际操作
1)准备目标JVM
我们这里直接使用一个 SpringBoot 项目来试验,方便大家增强改造~ 项目结构如下:
target-jvm
├─src
├─main
├─java
└─cbuc
└─life
└─targetjvm
├─controller
| └─TestController.java
└─service
| └─SimpleService.java
└─TargetJvmApplication.java
其中 TestController 和 SimpleService 两个类的内容也很简单,直接贴代码
2)准备 Agent
1、编写方法
然后编写我们的Agent jar包。因为懒惰,所以我这边将 premain 和 agentmain 两个方法写在同一个 jar 包中,然后分别以 启动时 和 运行时 来模拟场景~
很简单,一个类中包含了我们需要的所有功能~ 防止图片内容过于拥挤,小菜贴心地分别粘贴出核心代码:
- premain
- agentmain
- ClassFileTransformer
2)声明方法
然后将 Agent 打包,打包的时候需要在 pom.xml 文件中添加以下内容
然后运行mvn assembly:assembly 既可
3)启动 Agent
当我们已经准备好了两个 jar 包便可以开始测试了!
1、启动时加载
nohup java -javaagent:./java-agent-jar-with-dependencies.jar -jar target-jvm.jar &
xxxxxxxxxxbr nohup java -javaagent:./java-agent-jar-with-dependencies.jar -jar target-jvm.jar &
我们直接启动时添加参数,带上我们的 Agent jar包
结果并没有让小菜太尴尬,成功的实现我们想要的功能,但是这只是启动时加载,明显不是我们想要的~ 我们来试下运行时如何加载
2、运行时加载
正常运行下,方法并没有做耗时统计,我们的需求就来了,我们想要统计该方法的耗时,首先获取该进程ID
然后通过 Attach 方式(调用controller 的 active() 方法)附着 Agent,我们可以实时查看控制台
已经可以看到 Agent 似乎已经成功附着了,然后我们继续请求 test 接口
可以发现 resolve 方法已经被我们增强了!
四、题外话
上面我们已经简单的实现了动态操作目标类文件,文章开头就说明了给奔跑中的汽车更换轮胎是一个匪夷所思却又无可奈何的需求,但是这个需求能不能让别人实现,其实是可以的,而这个就是小菜的主要目的,我们了解了如何实现动态换轮胎的原理后,当我们运用其成熟的中间件也能更加应手而不会不知所措,知识不能让我们只学会卧槽两个字,而是当别人实现的时候我们能默默思考,思考后再说出牛逼~!感兴趣的同学不妨拉取一下源码演练一番:Arthas gitee,已经使用过类似 Arthas 或 BTrace 的同学,看完相信会更加了解其工作运行原理,没使用过的同学下次用到的时候也不会那么战战兢兢!