前言
了解Java基础的同学都知道Java的运行是需要先将Java代码编译成.class文件,而.class文件中存储的就是Java的字节码,类加载器通过加载字节码也就是.class文件才能最终运行Java代码。通常一个.class文件生成之后就无法修改,因为字节码比较难看懂,无法或很难对其进行修改。但是如果想要在.class文件生成之后对程序逻辑进行扩展有什么好办法吗?最简单的就是修改.java代码,然后重新编译,这一种最简单。但是如果想扩展一个公共逻辑,比如统一日志打印等,就需要对每个类源代码进行修改,且会耦合业务代码,那么此时就可以采用字节码增强的技术来实现。
一、字节码
1、字节码简述
Java和C语言很大的不同是C语言不是跨平台的,C语言编译后就是对应CPU的汇编指令,不同操作系统的汇编指令有差异,所以无法跨平台。而Java语言编译之后是字节码,字节码需要通过Java虚拟机来运行,而不同操作系统的Java虚拟机是定制的,所以针对不同的操作系统,JVM会将相同格式的字节码翻译成对应操作系统的汇编指令运行,所以JVM不是跨平台的,但是Java代码是可以跨平台运行的。所以Java跨平台最大原因就是因为编译成了字节码,从而实现“一次编译,到处运行”的特点。
字节码文件需要通过javac命令来将.java文件编译成.class文件,.class文件中存储的就是字节码
字节码的格式是十六进制数字组成,JVM每次读取一个字节也就是两个十六进制数字来加载字节码,最终将字节码在转换成对应操作系统的命令去执行。
Java代码到最终运行的完整流程如下图示:
完成步骤如下:
- java代码通过Javac命令由java编译器编译成字节码.class文件
- JVM的类加载器加载.class文件并解析
- 字节码完整性和合法性校验
- 字节码文件运行,实际是转换成操作系统的指令
- 调用对应的操作系统的指令实现java代码的运行
2、字节码文件格式
字节码格式固定,通常包含十个步骤,从前往后依次如下图示:
- 魔数(magic):魔数是.class文件的开头,占据4个字节固定值不变,值为0xCAFEBABE,由于Java之父James Gosling定的,为Cafe Babe(咖啡宝贝),而对应了Java的标志是一杯咖啡
- 版本号(version): 魔数后面4个字节是Java版本号,前两个字节是次版本号;后两个字节是主版本号
- 常量池(constant_pool): 常量池用于存储类中的常量,前两个字节存储计数器,表示常量的个数;后面的字节依次存储常量的具体数据
- 访问标志(access_flag): 用于标记当前类是否被public、abstract、final、static等关键字修饰,采用位运算来存储,每一位代表一种标志
- 类索引(this_class):类的全名存储在常量池中,类索引通过索引指向常量池中的地址
- 父类索引(super_class): 父类全名存储在常量池中,父类索引指向常量池中的地址
- 接口索引(interfaces): 接口索引前两个字节存储接口数量,后面依次存储各个接口对应的常量池中的地址
- 字段表(fields): 存储类或接口定义的静态和非静态变量,前两个字节存储字段个数,后面依次存储各个字段的详细信息
- 方法表(methods): 存储类或接口定义的方法,前两个字段存储方法个数,后面依次存储各个方法的详细信息
- 附件属性(attributes): 存放在该文件中类或接口所定义属性的基本信息
2、字节码增强
字节码增强技术就是对现有的字节码文件进行功能增强,一般方式有两种,一种是直接修改字节码文件,一种是动态创建一个新的字节码文件。
目前使用到了字节码增强技术有ASM、Javassist、AspectJ和Java Proxy等
2.1、ASM
ASM可以直接生成字节码文件,也可以动态修改字节码文件,Spring的AOP的cglib就是基于ASM实现的。
由于字节码的格式是固定的,所以ASM可以根据字节码数据结构读取对应位置的数据,然后再修改对应位置的数据即可。
ASM框架涉及的方法都和字节码指令级相关,因此对于指令不熟悉的话很难适应ASM框架。所以最好是有直接基于Java编码的方式对字节码文件进行修改,对于字节码文件的内容进行抽象,javasisst框架就是很好的选择。
2.2、javasisst
javasisst进行字节码增强时,使用者无需关心字节码的结构和指令语法,分别采用ctClass、ctMethod、ctField对类、方法和属性进行抽象,分别调用对应的方法即可完成字节码增强逻辑。
如针对方法进行增强,那么简单增强逻辑代码如下案例:
CtClass ctClass = ClassPool.getDefault().getCtClass("com.lucky.test.User");
CtMethod method = ctClass.getDeclaredMethod("getUserName");
method.insertBefore("System.out.println(\"start\")");
method.insertBefore("System.out.println(\"end\")");
Class clazz = ctClass.toClass();
ctClass.writeFile("/xxx/xxx/xxxx");
逻辑比较简单先根据全路径类名获取CtClass对象,然后根据方法名获取CtMethod对象,在调用对应的插入逻辑方法分别在对应方法前后插入增强逻辑,最好在调用CtClass的写入文件方法将当前类逻辑写入文件生成字节码文件。
2.3、动态字节码增强
ASM和Javasisst都可以创建字节码文件和修改字节码文件,对应的目标都是字节码文件。也就是说在JVM加载字节码文件之前就需要对字节码文件进行修改,而一旦JVM类加载器将目标类加载了,然后此时就无法通过修改字节码文件的方式来实现动态的字节码增强了。
所以如果想要在JVM加载字节码文件之后还进行字节码增强,就需要适应到JDK提供的Instrument。Instrument通常可以配合Javaagent一块使用。
三、Javaagent
Javaagent相当于一个插件,在JVM启动的时候可以添加 javaagent配置指定启动之前需要启动的agent jar包 这个agent包中需要有MANIFEST.MF文件必须指定Premain-Class配置,且Premain-Class配置指定的Class必须实现premain()方法
在JVM启动的时候,会从agent包中找到MAINIFEST.MF中配置的Class,执行其实现的premain方法,而且这一步是在main方法之前执行的。 这样就可以在JVM启动执行main方法之前做一些其他而外的操作了。
premain方法有两种
public static void premain(String agentArgs, Instrumentation inst){
//执行main方法之前的操作
}
public static void premain(String agentArgs){
//执行main方法之前的操作
}
agent会优先执行第一个方法,如果第一个方法不存在则才会执行第二个方法。
javaagent使用的步骤主要如下:
1、新建agent项目,新建自定义agent的入口类,如下
public class MyAgent
{
/**
* 参数args是启动参数
* 参数inst是JVM启动时传入的Instrumentation实现
* */
public static void premain(String args,Instrumentation inst)
{
System.out.println("premain方法会在main方法之前执行......");
}
}
2、编辑MANIFEST.MF文件,内容如下:
Mainfest-version: 1.0
Premain-class: cn.lucky.test.agent.MyAgent
3、将agent项目打包成自定义的名字,如 myagent.jar
4、在目标项目启动的时候添加JVM参数
-javaagent: myagent.jar
简单的四步就实现了一个自定义的javaagent,agent的具体实现功能就看自定义的时候如何实现premain(),可以premain方法中添加任何想要在main方法执行之前的逻辑。premain方法中有一个参数,Instrumentation,这个是才是agent实现更强大的功能都核心所在
四、Instrumentation
接口位于jdk1.6包java.lang.instrument包下,Instrumentation指的是可以独立于应用程序之外的代理程序,可以用来监控和扩展JVM上运行的应用程序,相当于是JVM层面的AOP。可以监控和扩展JVM上的运行程序,替换和修改java类定义,提供一套代理机制,支持独立于JVM应用程序之外的程序以代理的方式连接和访问JVM。
比如说一个Java程序在JVM上运行,这时如果需要监控JVM的状态,除了使用JDK自带的jps等命令之外,就可以通过instrument来更直观的获取JVM的运行情况;
或者一个Java方法在JVM中执行,如果我想获取这个方法的执行时间又不想改代码,常用的做法是通过Spring的AOP来实现,而AOP通过面向切面编程,实际上编译出来的类中代码也是被改动的,而instrument是在JVM层面上直接改动java方法来实现
一、Instrumentation接口源码
public interface Instrumentation
{
//添加ClassFileTransformer
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
//添加ClassFileTransformer
void addTransformer(ClassFileTransformer transformer);
//移除ClassFileTransformer
boolean removeTransformer(ClassFileTransformer transformer);
//是否可以被重新定义
boolean isRetransformClassesSupported();
//重新定义Class文件
void redefineClasses(ClassDefinition... definitions)
throws ClassNotFoundException, UnmodifiableClassException;
//是否可以修改Class文件
boolean isModifiableClass(Class<?> theClass);
//获取所有加载的Class
@SuppressWarnings("rawtypes")
Class[] getAllLoadedClasses();
//获取指定类加载器已经初始化的类
@SuppressWarnings("rawtypes")
Class[] getInitiatedClasses(ClassLoader loader);
//获取某个对象的大小
long getObjectSize(Object objectToSize);
//添加指定jar包到启动类加载器检索路径
void appendToBootstrapClassLoaderSearch(JarFile jarfile);
//添加指定jar包到系统类加载检索路径
void appendToSystemClassLoaderSearch(JarFile jarfile);
//本地方法是否支持前缀
boolean isNativeMethodPrefixSupported();
//设置本地方法前缀,一般用于按前缀做匹配操作
void setNativeMethodPrefix(ClassFileTransformer transformer, String prefix);
}
主要是定义了操作java类的class文件方法,这里又涉及到了ClassFileTransformer接口,这个接口的作用是改变Class文件的字节码,返回新的字节码数组,源码如下:
public interface ClassFileTransformer
{
byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer)
throws IllegalClassFormatException;
}
ClassFileTransformer接口只有一个方法,就是改变指定类的Class文件,该接口没有默认实现,很显然如果需要改变Class文件的内容,需要改成什么样需要使用者自己来实现。
二、Instrumentation接口的使用案例
Instrumentation可以在带有main方法的应用程序之前运行,通过-javaagent参数来指定一个指定的jar文件(包含Instrumentation代理)来启动Instrumentation的代理程序,所以首先需要编写一个Instrumentation的代理程序,案例如下:
public class MyAgent
{
/**
* 参数args是启动参数
* 参数inst是JVM启动时传入的Instrumentation实现
* */
public static void premain(String args,Instrumentation inst)
{
System.out.println("premain方法会在main方法之前执行......");
inst.addTransformer(new MyTransformClass());
}
}
------------------------------------------------------------------------
public class MyTransformClass implements ClassFileTransformer
{
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer)
throws IllegalClassFormatException
{
// 定义重新编译之后的字符流数组
byte[] newClassFileBuffer = new byte[classfileBuffer.length];
String transClassName = "com.mrhu.opin.controller.TestController";//重定义指定类,也可以重定义指定package下的类,使用者自由发挥
if (className.equals(transClassName))
{
System.out.println("监控到目标类,重新编辑Class文件字符流...");
// TODO 对目标类的Class文件字节流进行重新编辑
// 对byte[]重新编译可以使用第三方工具如javassist,感兴趣的可自行研究
// 本文图方便,直接返回旧的字节数组
newClassFileBuffer = classfileBuffer;
}
return newClassFileBuffer;
}
}
编译打包项目为 instrumentdemo.jar,然后其他在需要被监控的项目启动参数中添加如下参数:
-javaagent:instrumentdemo.jar
然后在被监控应用程序执行main方法之前就会先执行premain方法,走instrumentation代理程序,那么在应用程序加载类的时候就会进入到自定义的ClassFileTransformer中
Instrumentation还可以添加多个代理,按照代理指定的顺序依次调用
所以Instrumentation接口相当于一个代理,当执行premain方法时,通过Instrumentation提供的API可以动态的添加管理JVM加载的Class文件,Instrumentation管理着ClassFileTransformer。
ClassFileTransformer接口可以动态的改变Class文件的字节码,在加载字节码的时候可以将字节码进行动态修改,具体实现需要自定义实现类来实现ClassFileTransformer接口
那么premain方法中的Instrumentation对象是如何传入的呢?答案是JVM传入的。
四、Instrumentation的实现原理
说起Instrumentation的原理,就不得不先提起JVMTI,全称是JVM Tool Interface顾名思义是JVM提供的工具接口,也就是JVM提供给用户的扩展接口集合。
JVMTI是基于事件驱动的,JVM每执行到一定的逻辑就会调用一些事件的回调接口,这些接口可以供开发者扩展自行的逻辑。
比如我想监听JVM加载某个类的事件,那么我们就可以实现一个回调函数赋给jvmtiEnv的回调方法集合里的ClassFileLoadHook(Class类加载事件),那么当JVM进行类加载时就会触发回调函数,我们就可以在JVM加载类的时候做一些扩展操作,
比如上面提到的更改这个类的Class文件信息来增强这个类的方法。
JVMTI运行时,一个JVMTIAgent对应一个jvmtiEnv或者是多个,JVMTIAgent是一个动态库,利用JVMTI暴露出来的接口来进行扩展。
主要有三个函数:
- Agent_ VirtualMachine.attach("pid");
JVM进程有两个用于attach机制的线程,一个Signal Dispatcher线程,用于处理信号;一个Attach Listener线程,用于JVM进程之间的通信,可以通过配置启动参数java -XX:+StartAttachListener mainClass
开启Attach Listener线程。
另外如果一个JVM被其他进程attach,那么该JVM的Signal Dispatcher线程会处理信号并启动Attach Listener线程。
Attach Listener线程启动之后,会创建监听socket,并创建了一个文件/tmp/.java_pid,这个就是LinuxVirtualMachine构造函数中一直尝试获取的socketFile。随着这个socketFile创建,也就意味着客户端那边的attach成功了。之后客户端和目标JVM进程就通过这个socketFile进行通信。客户端可以通过这个socketFile发送相关命令。Attach Listener线程做的事情就是监听这个socketFile,发现有请求就解析,然后根据命令执行不同的方法,最后将结果返回。通过Attach机制,就可以很好的实现JDK自带的jstack、jmap等命令
注意:本文归作者所有,未经作者允许,不得转载