java agent探究
目前我们的业务遇到线上问题时经常需要加log调试。从加一行log到push代码再到jekens编译、打包、最后再部署,这个过程时间消耗非常长,而且各个环节都可能出现其他因素干扰(如如多人同时提交导致代码冲突编译不过等),造成的时间消耗就更长了。甚至有时候需要逐步加日志排查问题,重复很多次改代码再打包部署的操作,实在是费心费力。。。
可不可以在服务器上直接改代码使之实时生效?结论是可以的。
一、java Instrumentation
从java5开始,jdk中新增了一个java.lang.instrument.Instrumentation 类,它提供在运行时重新加载某个类的的class文件的api。下面是它的一些主要api
public interface Instrumentation {
/**
* 加入一个转换器Transformer,之后的所有的类加载都会被Transformer拦截。
* ClassFileTransformer类是一个接口,使用时需要实现它,该类只有一个方法,该方法传递类的信息,返回值是转换后的类的字节码文件。
*/
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
/**
* 对JVM已经加载的类重新触发类加载。使用的就是上面注册的Transformer。
* 该方法可以修改方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
*/
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
/**
*此方法用于替换类的定义,而不引用现有的类文件字节,就像从源代码重新编译以进行修复和继续调试时所做的那样。
*在要转换现有类文件字节的地方(例如在字节码插装中),应该使用retransformClasses。
*该方法可以修改方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
*/
void redefineClasses(ClassDefinition... definitions)throws ClassNotFoundException, UnmodifiableClassException;
/**
* 获取一个对象的大小
*/
long getObjectSize(Object objectToSize);
/**
* 将一个jar加入到bootstrap classloader的 classpath里
*/
void appendToBootstrapClassLoaderSearch(JarFile jarfile);
/**
* 获取当前被JVM加载的所有类对象
*/
Class[] getAllLoadedClasses();
}
通过addTransformer可以加入一个转换器,转换器可以实现对类加载的事件进行拦截并返回转换后新的字节码,通过redefineClasses或retransformClasses都可以触发类的重新加载事件。通过这几个方法的组合,就可以实现文章开头提到的不修改代码使之实时生效的目的了。
二、JAVA Agent
通过操作Instrumentation的api就可以实现不重启服务对单个类进行简单的修改。Instrumentation是一个interface,它的实现类InstrumentationImpl只有一个private的构造方法。
怎么拿到这个对象呢?下面是Instrumentation类的一段注释说明:
有两种方式拿到Instrumentation对象:
在jvm启动时指定agent,Instrumentation对象会通过agent的premain方法传递。
在jvm启动后通过jvm提供的机制加载agent,Instrumentation对象会通过agent的agentmain方法传递。
三、实践java启动时加载agent 获取Instrumentation对象
编写agent类并编译成.class文件,之后把它打成jar包,然后在jvm启动参数中指定jar包位置,具体操作步骤:
1、创建一个agent类,并创建premain方法,premain方法的参数是固定的。
public class preMainAgentClz {
private static Instrumentation instrumentation;
public static void premain(String agentArgs, Instrumentation inst) {
instrumentation = inst;
System.err.println("com.hexuan.agent.demo1.preMainAgentClz 我在main启动之前启动");
}
}
2、指定premain方法的位置(两种指定方式,设置一种就行)
方式1)创建并编辑 resources/META-INF/MANIFEST.MF 文件,当打jar包时将该文件一并打包
Premain-Class: com.hexuan.agent.demo1.preMainAgentClz #premain方法所在类的位置
方式2)如果是maven项目,在pom.xml加入
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>com.hexuan.agent.demo1.preMainAgentClz</Premain-Class>
<Agent-Class>com.hexuan.agent.demo1.agentMainAgentClz</Agent-Class>
</manifestEntries>
</archive>
</configuration>
</plugin>
3、如果是在pom中配置的,直接maven package就好了。如果是MANIFEST.MF文件指定的方式,将包含premain的类编译成class文件,并和MANIFEST.MF一起文件打包jar。
4、启动时指定agent位置,在jvm启动参数中加入-javaagent参数并指定jar文件位置。
-javaagent:/Users/hexuan/IdeaProjects/acfun_WorkSpace/java-agent-demo/target/java-agent-demo-1.0-SNAPSHOT.jar
5、启动java,agent的premain方法会在main方法之前执行。
四、在java启动后以attach的方式加载agent
上文介绍了java进程启动时加载agent的方式和步骤,通过它在启动之前将指定的类进行替换。但如果要实现文章开头提到的调试线上代码,我们需要在修改了class文件后重启jvm并且设置-javaagent参数,显然这种方式不是我们最想要的。上文提到过我们可以在jvm启动后通过jvm提供的机制加载agent,也就是说我们能够在任何时候去加载agent,然后替换类文件。这个机制就是jdk的attach api。
Attach API是Sun公司提供的一套扩展API,用来向目标JVM"附着"(Attach)代理工具程序的。有了它,开发者可以方便的监控一个JVM,运行一个外加的代理程序,Sun JVM Attach API功能上非常简单,仅提供了如下几个功能:
- 列出当前所有的JVM实例描述
- Attach到其中一个JVM上,建立通信管道
- 让目标JVM加载Agent
Attach Api 对应的代码位置在 com.sun.tools.attach 包,包里边有一个类VirtualMachine,它有两个比较重要方法:
/**
*传递一个进程号作为参数,返回目标jvm进程的vm对象。
*该方法其实是JVM进程之间指令传递的桥梁,底层通过socket进行通信。
*JVM A可以发送一些指令给JVM B,B收到指令之后,可以执行对应的逻辑
* 比如在命令行中经常使用的jstack、jcmd、jps等,很多都是基于这种机制实现的
**/
public static VirtualMachine attach(String var0) throws AttachNotSupportedException, IOException
/**
*该方法允许我们将agent对应的jar文件地址作为参数传递目标jvm
*目标jvm收到该命令后会加载这个agent
**/
public void loadAgent(String var1) throws AgentLoadException, AgentInitializationException, IOException
显然,我们可以创建一个java进程,用它attach到对应的jvm,并加载agent,agent加载后我们的类也就被成功替换了。
五、怎么得到新的类文件
Instrumentation操作的是.class文件,对于我们开发人员来讲,我们看不懂.class文件,更无法直接修改它了。还是考虑文章一开始提到的线上改代码调试的场景,我们知道了如何去替换类,但是如何得到新的.class类文件呢?
方式1:线下修改.java文件 -->编译成.class文件 -->上传到线上机器-->instrument
方式2:线上.class旧文件 -->反编译成.java文件 -->修改java文件 -->编译成.class文件 -->instrument
方式3:通过ASM或其他操作字节码的组件直接修改.class文件-->instrument
...
无论哪种方式,流程太复杂容易出错,有成熟的组件吗?有,Arthas和Btrace
六、Arthas&Btrace
BTrace 是基于动态字节码修改技术(Instrumentation)来实现运行时 java 程序的跟踪和替换。大体的原理可以用下面的公式描述:Client(Java compile api + attach api) + Agent(脚本解析引擎 + ASM + JDK6 Instumentation) + Socket
其实 BTrace 就是使用了 java attach api 附加 agent.jar ,然后使用脚本解析引擎+asm来重写指定类的字节码,再使用 instrument 实现对原有类的替换。
但是BTrace脚本在使用上有一定的学习成本,如果能把一些常用的功能封装起来,对外直接提供简单的命令即可操作的话,那就再好不过了。2018年9月份阿里开源了自己的Java诊断工具Arthas。Arthas功能非常强大,通过简单的命令行操作即可完成对应功能。究其背后的技术原理,和本文中提到的大致无二。
Btrace开源地址:https://github.com/btraceio/btrace
Arthas开源地址:https://github.com/alibaba/arthas
七、总结
java instrument在很多应用领域都发挥着重要的作用,比如:
- apm:(Application Performance Management)应用性能管理。pinpoint、cat、skywalking等都基于Instrumentation实现
- idea的HotSwap、Jrebel等热部署工具
- 应用级故障演练
- Java诊断工具Arthas、Btrace等
java agent加载的时序图:
image附1:java Instrumentation的redefineClasses 和retransformClasses 的补充说明:
- 二者的区别:都是替换已经存在的class文件,redefineClasses是自己提供字节码文件替换掉已存在的class文件,retransformClasses是在已存在的字节码文件上修改后再替换之。
- 相互依赖的类加载: 允许传类集合,以满足类之间相互依赖的情况,加载顺序为集合顺序
- 替换后生效时机:如果一个被修改的方法已经在栈桢中存在,则栈桢中的会使用旧字节码定义的方法继续运行,新字节码会在新栈桢中执行
- 不修改变量值:该方法不会导致类的一些初始化方法执行、不会修改静态变量的值
- 只改变方法体:该方法可以改变类的方法体、常量池和属性值,但不能新增、删除、重命名属性或方法,也不能修改方法的签名
- 字节码有问题时不加载:在类转化前该方法不会check字节码文件,如果结果字节码出错了,该方法将抛出异常。如果该方法抛出异常,则不会重新定义任何类
附:2、使用Arthas实现加log调试
#下载arthas agent
wget https://alibaba.github.io/arthas/arthas-boot.jar
#启动agent
java -jar arthas-boot.jar --target-ip 0.0.0.0
#sc:search class 查找类文件
sc *SelectionController
#jad 反编译class 并输出到文件
jad --source-only com.acfun.controller.SelectionController > /tmp/SelectionController.java
#修改源代码
vi /tmp/SelectionController.java
#sc查找加载UserController的ClassLoader -d参数可以打印出类加载的具体信息
sc -d *SelectionController |grep classLoaderHash
#编译源代码 使用mc(Memory Compiler)命令来编译,并且通过-c参数指定ClassLoader
mc -c 3787f831 /tmp/SelectionController.java -d /tmp
#使用redefine命令重新加载新编译好的class
redefine /tmp/com/acfun/controller/SelectionController.class
#redefine成功之后,访问controller,观察代码是否生效