前言
前段时间在看算法这块的东西,看的我是头昏脑胀。所以这几天又捡起了 《深入理解Java虚拟机》 这本书,这次主要看的是书中的 第三部分,我一直崇尚知识是在不断的总结和不断地学习相互交叉,这样才能学以致用,用而有据。
概念
字节码
字节码指的是 Java
中的 .java
文件经过编译( javac
)后生成的固定格式文件 .class
文件以供 JVM
使用。
之所以被称为字节码文件是因为字节码文件是由十六进制值组成,JVM
以两个十六进制值为一组,即一个字节进行读取。同时 JVM
也针对不同操作系统和平台进行优化,这也就是 Java
号称 一次编译,到处运行 的根本原因。
由此又可以引出一个问题,由于 JVM
规范的存在,那么只要我们最终可以生成符合 JVM
规范的字节码文件那就可以在 JVM
上运行了,这也就产生了其他运行在 JVM
上的语言(如 Scale、Kotlin、Groovy ),可以通过其他语言可以扩展 Java
所没有的特性和语法糖。
字节码增强
字节码增强指的是在 Java
字节码生成之后,对其进行修改,增强其功能,这种方式相当于对应用程序的二进制文件进行修改。字节码增强的应用场景主要是减少冗余代码,对开发人员屏蔽底层的实现细节。
实现机制
- 通过创建原始类的一个子类,也就是动态创建这个类继承原有的类,从而扩展原有类的方法。
- 直接修改原有类生成的
Class
文件,在许多类的跟踪过程中都会用到(运行时修改、类加载时修改字节码信息)。
基础
字节码格式
一个 .java
文件经过编译( javac
)后就会生成 .class
文件。
如下所示,左边为原始代码,右边为编译后的字节码:
字节码文件解析:
魔数:每个
Class
文件头 4 个字节代表魔数,它代表了这个文件是否是一个能被虚拟机接受的Class
文件。魔数固定值为:CAFEBABE
。
有趣的是,魔数的固定值是Java
之父James Gosling
制定的,为CafeBabe
(咖啡宝贝),而Java
的图标为一杯咖啡。版本号:前两个字节代表次版本号
minorversion
,后两个字节代表主版本号majorversion
。将四个字节的十六进制值转换为十进制就是对应的版本号。常量池:常量池的大小是不固定的,会根据类中常量的多少来确定。其中首选由一个 2 个字节十六进制的数来定义常量池长度,计算出常量池的十进制是多少,然后减一得出常量池的数量。
常量池的类型:
| 常量 | 类型 | 描述 |
|———————————-|—————|———————————–|
| CONSTANT_Utf8_info | tag标志位为1 | UTF-8编码的字符串 |
| CONSTANT_Integer_info | tag标志位为3 | 整形字面量 |
| CONSTANT_Float_info | tag标志位为4 | 浮点型字面量 |
| CONSTANT_Long_info | tag标志位为5 | 长整形字面量 |
| CONSTANT_Double_info | tag标志位为6 | 双精度字面量 |
| CONSTANT_Class_info | tag标志位为7 | 类或接口的符号引用 |
| CONSTANT_String_info | tag标志位为8 | 字符串类型的字面量 |
| CONSTANT_Fieldref_info | tag标志位为9 | 字段的符号引用 |
| CONSTANT_Methodref_info | tag标志位为10 | 类中方法的符号引用 |
| CONSTANT_InterfaceMethodref_info | tag标志位为11 | 接口中方法的符号引用 |
| CONSTANT_NameAndType_info tag | 标志位为12 | 字段和方法的名称以及类型的符号引用 |
| CONSTANT_Method-Handle_info | tag标志位为15 | 方法句柄 |
| CONSTANT_Method-Type_info | tag标志位为16 | 方法类型 |
| CONSTANT_Invoke-Dynamic_info | tag标志位为18 | 动态方法调用点 |
常量池分布:
- 访问标志:常量池结束之后的两个字节,描述该
Class
是类还是接口,以及是否被Public、Abstract、Final
等修饰符修饰。
访问标志的类型:
| 标志名称 | 标志值 | 含义 |
|———————-|———–|——————————-|
| ACC_PUBLIC | 0X0001 | public
类型 |
| ACC_PRIVATE | 0X0002 | private
类型 |
| ACC_FINAL | 0X0010 | 声明为 final
,只有类可以设置 |
| ACC_SUPER | 0X0020 | 使用invokespecial字节码指令的新语意,invokespecial指令的语意在JDK1.0.2发生过改变,为了区别这条指令使用哪种语意,JDK1.0.2之后编译出来的类都为真 |
| ACC_INTERFACE | 0X0200 | 接口 |
| ACC_ABSTRACT | 0X0400 | abstract
类型,对于接口或者抽象类来说,此标志值为真,其他类为假 |
| ACC_SYNTHETIC | 0X1000 | 这个类并非由用户代码产生 |
| ACC_ANNOTATION | 0X2000 | 注解 |
| ACC_ENUM | 0X4000 | 枚举 |
- 当前类索引:访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
- 父类索引:当前类名后的两个字节,描述父类的全限定名,同上,保存的也是常量池中的索引值。
- 接口索引:父类名称后为两字节的接口计数器,描述了该类或父类实现的接口数量。紧接着的 N 个字节是所有接口名称的字符串常量的索引值。
- 字段表:字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。
- 方法表:字段表结束后为方法表,方法表也是由两部分组成,第一部分为两个字节描述方法的个数;第二部分为每个方法的详细信息。
- 属性表:字节码的最后一部分,该项存放了在该文件中类或接口所定义属性的基本信息。
字节码文件中格式释义:
u2、u4
分别代表有两个字节、四个字节。Class
类文件的伪结构中只有两种数据类型:无符号数(unsigned quantity)和表(table)。
无符号数属于基本的数据类型,u2、u4
分别代表两个字节和四个字节的无符号数。而其余的cp_info、field_info、method_info、attribute_info
就是表。
字节码常用工具
- 查看反编译后的字节码,Idea插件:jclasslib
- 查看源码到字节码的映射:Idea插件:ASM ByteCode Outline
字节码增强
字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术。
JDK 动态代理
JDK
动态代理是利用反射机制生成一个实现接口的匿名内部类,在调用具体方法之前调用 InvokeHandler
来处理。
接口类:
1 | public interface Demo { |
接口实现类:
1 | public class DemoImpl implements Demo { |
代理实现处理类:
1 | public class JdkProxyFactory implements InvocationHandler { |
缺点:
使用 JDK
动态代理,必须要求 target
目标类实现接口,如果没有实现接口,就不能使用 JDK
动态代理。这个在 Spring
中关于 AOP
的实现也是如此,如果没有该类继承接口就采用 Cglib
动态代理来实现。
ASM
ASM
可以直接修改 .class
字节码文件,也可以在类被加载入 JVM
之前动态修改类行为。
大致流程就是 ClassReader
读取原有的字节码文件,然后经过 Visitor
处理字节码文件,最后通过 ClassWriter
生产新的字节码文件并替换原有的字节码文件。
核心 API
ClassReader
: 用于读取已经编译好的.class
文件。ClassWriter
: 用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。Visitor
:CoreAPI
根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的Visitor
,比如用于访问方法的MethodVisitor
、用于访问类变量的FieldVisitor
、用于访问注解的AnnotationVisitor
等。
使用 ASM
实现 AOP
原有基础类:
1 | public class A { |
重写 Visitor
类:
1 | public class MyClassVisitor extends ClassVisitor implements Opcodes { |
Main 类:
1 | public class Generator { |
输出:
1 | start |
Javassist
ASM
是在指令层次上操作字节码的,在看完前面这些东西并上手操作后最直观感受是在指令层次上操作字节码的框架实现起来比较晦涩。那么就有了另外一类框架:强调源代码层次操作字节码的框架 Javassist
。
利用 Javassist
实现字节码增强时,可以无须关注字节码刻板的结构,优点就是在于编程简单。可以直接使用 Java
编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。
核心 API
CtClass
(compile-time class
) : 编译时类信息,它是一个Class
文件在代码中的抽象表现形式,可以通过一个类的全限定名来获取一个CtClass
对象,用来表示这个类文件。ClassPool
: 从开发视角来看,ClassPool
是一张保存CtClass
信息的HashMap
,key
为类名,value
为类名对应的CtClass
对象。CtMethod
: 类中的方法。CtField
: 类中的属性。
Demo
1 | public class JavassistTest { |
输出:
1 | start |
运行时类的重载
在上面我们解决了如何利用字节码文件来重写类中的方法,那么这就引出了另一个问题,修改后的字节码文件我们又当如何在运行中的 JVM
中重新加载修改后的字节码文件呢?
Instrument
Instrument
是 JVM
提供的一个可以修改已加载类的类库,专门为 Java
语言编写的插桩服务提供支持。它需要依赖 JVMTI
的 Attach API
机制实现。
若想使用 Instrument
的类修改功能,就需要实现它的 ClassFileTransformer
接口,重新定义一个类文件转换器。 接口中的 transform()
方法会在类文件被加载时调用,而这个方法中可以利用上文中的 ASM
或 Javassist
对传入的字节码进行改写或替换,最后生成新的字节码数组然后返回。
新的类文件转换器:
1 | public class TestTransformer implements ClassFileTransformer { |
在新的类文件转换器有了之后,我们还需要一个 Agent
,借助 Agent
的力量将 Instrument
注入到 JVM
中去。当 Agent
被 Attach
到一个 JVM
中时,就会执行类字节码替换并重载入 JVM
的操作。
1 | public class TestAgent { |
JVMTI
(JVM TOOL INTERFACE
,JVM
工具接口)是 JVM
提供的一套对 JVM
进行操作的工具接口。
通过 JVMTI
可以实现对 JVM
的多种操作。例如通过接口注册各种事件勾子函数,在 JVM
事件触发时,同时触发预定义的勾子函数,以实现对各个 JVM
事件的响应,事件包括类文件加载、 异常产生与捕获、线程启动和结束、进入和退出临界区、 成员变量修改、 GC 开始和结束、 方法调用进入和退出、 临界区竞争与等待、 VM 启动与退出等等。
Attach API
的作用是提供 JVM
进程间通信的能力。
这里并不是本章的重点,所以就不说明了,想看的可以自己去看看!
常用场景
- 热部署: 不部署服务而对线上服务做修改,可以做打点、增加日志等操作。
Mock
: 测试时候对某些服务做Mock
。- 性能诊断工具: 比如
bTrace
就是利用Instrument
,实现无侵入地跟踪一个正在运行的JVM
,监控到类和方法级别的状态信息。
总结
对于编程需要时刻 保持谦恭 之心,善于提出问题,也能根据问题找到对应的解决思路,形成 闭环 。这样不断地闭环就会构建出自己的知识体系。
还有一点就是需要善于总结自己已经掌握的知识,达到 产出产能平衡 。
参考
字节码增强技术探索
《深入理解Java虚拟机》 第三部分
Java字节码技术(二)字节码增强之ASM、JavaAssist、Agent、Instrumentation
个人备注
此博客内容均为作者学习所做笔记,侵删!
若转作其他用途,请注明来源!