Java最黑科技的玩法就是字节码编程,也就是动态修改或是动态生成 Java 字节码。使用字节码可以玩出很多高级的玩法,最高级的还是在 Java 程序运行时进行字节码修改和代码注入。听起来是不是一些很黑客,也很黑科技的事?是的,这个方式使用 Java 这门静态语言在运行时可以进行各种动态的代码修改,而且可以进行无侵入的编程。
比如,我们不需要在代码中埋点做统计或监控,可以使用这种技术把我们的监控代码直接以字节码的方式注入到别人的代码中,从而实现对实际程序运行情况进行统计和监控。但是要做到这个事,还需要学习一个叫 Java Agent 的技术(可以参考我的这篇文章:Java Agent)。
根据 Java 虚拟机规范的规定,Class 文件格式采用一种类似 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。
无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上就是一张表,它由下表所示的数据项构成。
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u4 | magic | 1 | 魔数,占用4个字节,offset 0-3 |
u2 | minor_version | 1 | 次版本号,offset 4-5 |
u2 | major_version | 1 | 主版本号 ,offset 7-8 |
u2 | constant_pool_count | 1 | 常量池数量,offset 8-9 |
cp_info | constant_poll | constant_pool_count-1 | 常量池,Class 文件之中的资源仓库。数量不固定 |
u2 | access_flags | 1 | 访问标志,用于识别一些类或者接口层次的访问信息 |
u2 | this_class | 1 | 类索引,用于确定这个类的全限定名 |
u2 | super_class | 1 | 父类索引,用于确定这个类的父类的全限定名 |
u2 | interfaces_count | 1 | 接口计数器,表示索引表的容量 |
u2 | interfaces | interfaces_count | 接口索引集合,用来描述这个类实现了哪些接口 |
u2 | fields_count | 1 | 字段容量计数器,记录这个表含有多少个字段 |
field_info | fields | fields_count | 字段表集合,用于描述接口或者类中声明的变量 |
u2 | methods_count | 1 | 方法表计数器,记录这个表含有多少个方法 |
method_info | methods | methods_count | 方法表集合,存放 Class 的方法集合 |
u2 | attributes_count | 1 | 属性表计数器,表示字段表或方法表有多少个属性 |
attribute | attributes | attributes_count | 属性表集合,在 Class 文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息 |
为方便讲解,在这里准备了一段最简单的代码,也希望大家能跟着实际操作一遍:
package org.clazz;
public class TestClazz {
private int m;
public int inc() {
return m + 1;
}
}
使用 javac 将这个文件转换成 Class,然后用十六进制编辑器 WinHex 打开这个 Class 文件:
有了以上的知识准备,现在我们一起分析上面的 Class 分别代表什么意思。揭开这层神秘的面纱!
每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。
很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如 gif 或者 jpeg 等在文件头中都存有魔数。
文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。
我们看到 TestClazz.class 的魔数也就是头 4 个字节为 CA FE BA BE,用十六进制表示是 0xCAFEBABE(咖啡宝贝?这个名称也太浪漫了吧)。这也意味着每个 Class 文件的魔数值都必须为 0xCAFEBABE。
紧接着魔数的 4 个字节存储的是 Class 文件的版本号:5-6 个字节是次版本号(Minor Version),7-8 个字节是主版本号(Major Version)。Java 的版本号是从 45 开始的,JDK 1.1 之后的每个JDK 大版本发布主版本号加 1,高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过起把那本号的 Class 文件。
Class 文件版本号:
JDK版本号 | 10进制版本号 | 16进制版本号 |
---|---|---|
1.1 | 45.0 | 00 00 00 2D |
1.2 | 46.0 | 00 00 00 2E |
1.3 | 47.0 | 00 00 00 2F |
1.4 | 48.0 | 00 00 00 30 |
1.5 | 49.0 | 00 00 00 31 |
1.6 | 50.0 | 00 00 00 32 |
1.7 | 51.0 | 00 00 00 33 |
1.8 | 52.0 | 00 00 00 34 |
再看看文件对应的值:
我们看到代表主版本号的 7-8 个字节的值为 0x0034,也即十进制的 52,该版本号说明这个文件是可以被 JDK 1.8 或以上版本虚拟机执行的 Class 文件。
紧接着主次版本号之后的是常量池入口,常量池可以理解为 Class 文件之中的资源仓库,它是占用 Class 文件空间最大的数据项目之一。
由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的数据,代表常量池容量计数值(constant_pool_count)。
如上图所示,常量池容量为十六进制数 0x0016,即十进制的 19,结合上面的 Class 表,我们能知道常量池中有 19 - 1 = 18 项常量。
常量池容量计数值之后就是常量池,常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。
字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。
符号引用则属于编译原理方面的概念,包括了下面三类常量:
常量池中每一项常量都是一个表,总共 14 种表:
类型 | 标志 | 描述 |
---|---|---|
CONSTANT_Utf8_info | 1 | UTF-8 编码的字符串 |
CONSTANT_Integer_info | 3 | 整型字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methidref_info | 10 | 类中方法的符号引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的部分符号引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MethodType_info | 16 | 标识方法类型 |
CONSTANT_InvokeDynamic_info | 18 | 标识一个动态方法调用点 |
之所以说常量池是最烦琐的数据,是因为这 14 中常量类型各自均有自己的结构。
我们再来看图中常量池的第一项常量,它的标志位(偏移地hi:0x0000000A)是 0x0A,转换为十进制的值为 10,查常量表中对应的标志为 10 的常量属于 CONSTANT_Methodref_info 类型。
我们看一下 CONSTANT_Methodref_info 类型常量的结构:
名称 | 类型 | 描述 |
---|---|---|
tag | u1 | 值为10 |
index | u2 | 指向声明方法的类描述符 CONSTANT_Class_info 的索引项 |
index | u2 | 指向名称及类型描述符 CONSTANT_NameAndType 的索引项 |
上图中的第一个 index 十六进制为 0x0004,即十进制的 4,表示指向常量池中第 4 个常量。
第二个 index 十六进制为 0x000F,即十进制的 15,表示指向常量吃中的第 15 个常量。
(先不管第4、15 常量表示什么)
上面分析的是第一个常量值,接着分析第二个常量值,它的标志位(地址:0x0000000F)是 0x09,即十进制的 9,表示这个常量属于 CONSTANT_Fieldref_info 类型,此常量代表字段的符号引用。
CONSTANT_Fieldref_info 型常量的结构:
名称 | 类型 | 描述 |
---|---|---|
tag | u1 | 值为9 |
index | u2 | 指向声明字段的类或者接口描述符 CONSTANT_Class_info 的索引项 |
index | u2 | 指向字段描述符 CONSTANT_NameAndType 的索引项 |
以上分析了 TestClazz.class 常量池中 18 个常量中的前两个,其余的 16 个依次类推:
需要注意的是第 18 个常量,tag 标志为 0x01 表示 CONSTANT_Utf8_info :
名称 | 类型 | 描述 |
---|---|---|
tag | u1 | 数量 1 |
length | u2 | 长度,表示占用几个字节 |
bytes | u1 | 占用 length 个字节 |
注意 bytes 字段的长度,是根据 length 计算的,length 为 0x0010 转换十进制为 16,所以后面的 bytes 占用 16 个字节。
最后将 14 中常量项的结构定义总结为下表,供大家参考:
在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final 等。具体标志位以及标志的含义见下表:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为 public 类型 |
ACC_FINAL | 0x0010 | 是否被声明为 final,只有类可设置 |
ACC_SUPER | 0x0020 | 是否允许使用 invokespecial 字节码指令的新语意,invokespecial指令的语意在 JDK 1.0.2 发生过改变,为了区别这条指令使用哪种语意,JDK 1.0.2 之后编译出来的类的这个标志都必须为真 |
ACC_INTERFACE | 0x0200 | 标识这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为 abstract 类型,对于接口或者抽象类来说,此标志值为真,其他类值为假 |
ACC_SYNTHETIC | 0x1000 | 标识这个类并非由用户代码产生的 |
ACC_ANNOTATION | 0x2000 | 标识这是一个注解 |
ACC_ENUM | 0x4000 | 标识这是一个枚举 |
TestClazz.class 是一个普通的 java 类,不是接口、枚举,因此它的ACC_PUBLIC、ACC_SUPER标志为真,其他标志为假,因此它的 access_flags 的值为:0x0001|0x0020 = 0x0021。
类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据,而接口索引结合(interfaces)是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定这个类的继承关系。
类索引和父类索引都是指向一个类型为 CONSTANT_Class_info 的类描述符常量。
图中看到,TestClazz中的类索引指向的是第 3 个常量,父类索引指向的是第 4 个常量。
对于接口索引集合,入口的第一项——u2 类型的数据为接口计数器(interfaces_count),表示索引表的容量。如果该类没有实现任何接口,则该计数器值为 0 ,后面接口的索引表不再占用任何字节。
字段表用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。
字段表结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
字段修饰符放在 access_flags 项目中,它与类中的 access_flags 项目是非常类似的,都是一个 u2 的数据类型,其中可以设置的标志位和含义见表:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 字段是否为 public |
ACC_PRIVATE | 0x0002 | 字段是否为 private |
ACC_PROTECTED | 0x0004 | 字段是否为 proctected |
ACC_STATIC | 0x0008 | 字段是否为 static |
ACC_FINAL | 0x0010 | 字段是否为 final |
ACC_VOLATILE | 0x0040 | 字段是否为 volatile |
ACC_TRANSIENT | 0x0080 | 字段是否为 transient |
ACC_SYNTHETIC | 0x1000 | 字段是否由编译器自动产生的 |
ACC_ENUM | 0x4000 | 字段是否为 enum |
跟随 access_flags 标志的是两项索引值:name_index 和 descriptor_index。它们都是对常量池的引用,分别代表着字段的简单名称以及字段和方法的描述符。
方法表的内容和字段表几乎采用了完全一致的方式,方法表结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
access_flags 方法访问标志:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 方法是否为 public |
ACC_PRIVATE | 0x0002 | 方法是否为 private |
ACC_PROTECTED | 0x0004 | 方法是否为 proctected |
ACC_STATIC | 0x0008 | 方法是否为 static |
ACC_FINAL | 0x0010 | 方法是否为 final |
ACC_SYNCHRONIZED | 0x0020 | 方法是否为 synchronized |
ACC_BRIDGE | 0x0040 | 方法是否是由编译器产生的桥接方法 |
ACC_VARARGS | 0x0080 | 方法是否接受不定参数 |
ACC_NATIVE | 0x0100 | 方法是否为 native |
ACC_ABSTRACT | 0x0400 | 方法是否为 abstract |
ACC_STRICTFP | 0x0800 | 方法是否为 strictfp |
ACC_SYNTHETIC | 0x1000 | 方法是否由编译器自动产生的 |
TestClazz对应的位置:
注意,方法表集合只存放了方法名称,索引等,方法里的代码存放在方法属性表集合中一个名为“Code”的属性里面,这就是下面需要将到的属性表集合。
属性表(attribute_info)在 Class 文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。
与 Class 文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松些,不再要求各个属性表具有严格顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性。
虚拟机规范预定义的属性:
属性名称 | 使用位置 | 含义 |
---|---|---|
Code | 方法表 | Java 代码编译成的字节码指令 |
ConstantValue | 字段表 | final 关键字定义的常量值 |
Deprecated | 类、方法表、字段表 | 被声明为 deprecated 的方法和字段 |
Exceptions | 方法表 | 方法抛出的异常 |
EnclosingMethod | 类文件 | 仅当一个类为局部类或者匿名类时才能拥有这个属性,这个属性用于标识这个类所在的外围方法 |
InnerClasses | 类文件 | 内部类列表 |
LineNumberTable | Code 属性 | Java 源码的行号与字节码指令的对应关系 |
LocalVariableTable | Code 属性 | 方法的局部变量描述 |
StackMapTable | Code 属性 | JDK 1.6 中新增的属性,供新的类型检查验证器(Tyoe Checker)检查和处理目标方法的局部变量和操作数栈所需要的类型是否匹配 |
Signature | 类、方法表、字段表 | 这个属性用于支持泛型情况下的方法签名,在 Java 语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则 Signature 属性会为它记录泛型签名信息。由于 Java 的泛型采用擦除法实现,在为了避免类型信息被擦除后导致签名混乱,需要这个属性记录泛型中的相关信息 |
SourceFile | 类文件 | 记录源文件名称 |
SourceDebugExtension | 类文件 | JDK 1.6 中新增的属性,SourceDebugExtension 属性用于存储额外的调试信息。譬如在进行 JSP 文件调试时,无法通过 Java 堆栈来定位到 JSP 文件的行号,JSR-45 规范为这些非 Java 语言编写,却需要编异常字节码并运行在 Java 虚拟机中的程序提供了一个进行调试的标准机制,使用 SourceDebugExtension 属性就可以用于存储这个标准所新加入的调试信息 |
Synthetic | 类、方法表、字段表 | 标识方法或字段为编译器自动生成的 |
LocalVariableTypeTable | 类 | 它使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加 |
RuntimeVisibleAnnotations | 类、方法表、字段表 | 为动态注解提供支持。RuntimeVisibleAnnotations 属性用于指明哪些注解是运行时(实际上运行时就是进行反射调用)可见的 |
RuntimeInvisibleAnnotations | 类、方法表、字段表 | 与 RuntimeVisibaleAnnotations 属性作用刚好相反,用于指明哪些注解是运行时不可见的 |
RuntimeVisibleParameterAnnotations | 方法表 | 作用与 RuntimeVisibleAnnotations 属性类似,只不过作用对象为方法参数 |
RuntimeInvisibleParameterAnnotations | 方法表 | 作用与 RuntimeInvisibleAnnotations 属性类似,只不过作用对象为方法参数 |
AnnotationDefault | 方法表 | 用于记录注解类元素的默认值 |
BootstrapMethods | 类文件 | JDK 1.7 中新增的属性,用于保存 invokedynamic 指令引用的引导方法限定符 |
对于每个属性,它的名称需要从常量池中引用一个 CONSTANT_Utf8_info 类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个 u4 的长度属性去说明属性值所占用的位数即可,一个符合规则的属性表应该满足下表所定义的结构。
属性表结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | attribute_length |
Java 程序方法体中的代码经过 Javac 编译器处理后,最终变为字节码指令存储在 Code 属性内。
Code 属性表结构:
类型 | 名称 | 数量 | 描述 |
---|---|---|---|
u2 | attibute_name_index | 1 | 属性名称,指向 CONSTANT_Utf8_info 型常量的索引,常量值固定为“Code” |
u4 | attribute_length | 1 | 属性值长度,由于属性名称索引与属性长度一共为 6 字节,所以属性值的长度固定为整个属性表长度减去 6 个字节 |
u2 | max_stack | 1 | 操作数栈深度最大值,在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度 |
u2 | max_locals | 1 | 局部变量表所需的存储空间,max_locals 的单位是 Slot,Slot 是虚拟机为局部变量分配内存所使用的最小单位 |
u4 | code_length | 1 | 字节码长度, |
u1 | code | code_length | 字节码指令字节流,用于存储字节码指令的一系列字节流。文章的末尾会给出“虚拟机字节码指令表”。 |
u2 | exception_table_length | 1 | |
exception_info | exception_table | exception_table_length | |
u2 | attributes_count | 1 | |
attribute_info | attributes | attributes_count |
Code 属性是 Class 文件中最重要的一个属性,如果把一个 Java 程序中的信息分为代码(java代码)和元数据(类、字段、方法定义及其他信息)两部分,那么在整个 Class 文件中,Code 属性用于描述代码,所有其他数据项目都用于描述元数据。
继续以 TestClazz.class 文件为例
它的操作数栈的最大深度和本地变量表的容量都为 0x0001,字节码区域所占空间的长度为 0x0005。
虚拟机读取到字节码长度后,按照顺序依次读入紧随的 5 个字节,并根据字节码指令表翻译出所对应的字节码指令。
翻译 “2A B7 00 0A B1” 的过程:
1.读入 2A,查表得 0x2A 对应得指令为 aload_0,这个指令得含义是将第 0 个 Slot 中为 reference 类型得本地变量推送到操作数栈顶。
2.读入 B7,查表得 0xB7 对应得指令为 invokespecial,这条指令的作用是以栈顶的 reference 类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private 方法或者它的父类的方法。
3.读入 00 0A,这是 invokespecial 的参数,查常量池得 0x000A 对应的常量为实例构造器“”方法的符号引用。
4.读入 B1,查表得 0xB1 对应得指令为 return,含义是返回此方法,这条指令执行后,当前方法结束。
属性表集合除了 Code 属性,还有 Exceptions 属性、LineNumberTable 属性等等,这里就不一一介绍了。有兴趣得童鞋可以自行了解。
在 JDK 的 bin 目录中,Oracle 公司已经为我们准备好一个专门用于分析 Class 文件字节码的工具:javap
使用命令:
javap -verbose TestClazz.class
代码清单:
Last modified 2019-1-14; size 285 bytes
MD5 checksum c434da45f0fff84f21348a725448f2f5
Compiled from "TestClazz.java"
public class org.clazz.TestClazz
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#15 // java/lang/Object."":()V
#2 = Fieldref #3.#16 // org/clazz/TestClazz.m:I
#3 = Class #17 // org/clazz/TestClazz
#4 = Class #18 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 inc
#12 = Utf8 ()I
#13 = Utf8 SourceFile
#14 = Utf8 TestClazz.java
#15 = NameAndType #7:#8 // "":()V
#16 = NameAndType #5:#6 // m:I
#17 = Utf8 org/clazz/TestClazz
#18 = Utf8 java/lang/Object
{
public org.clazz.TestClazz();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 3: 0
public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 7: 0
}
SourceFile: "TestClazz.java"
到此,相信大家能对字节码有一个较深的认识,Java 语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比 Java 语言本身更强大。