03_解析class文件
解析class文件
Java 虚拟机规范中所指的 class 文件,并非特指位于磁盘中的 .class 文件,而是泛指任何格式符合规范的 class 数据。它实际上可以通过网络下载,从数据库加载,甚至是在运行中直接生成等方式来获取 class 文件。
- 构成 class 文件的基本数据单位是字节,可以把整个 class 文件当成一个字节流来处理
- 数据由连续多个字节构成,这些数据在 class 文件中以大端(big-endian)方式存储
为了描述 class 文件格式,Java 虚拟机规范定义了 u1、u2 和 u4 三种数据类型来表示1、2和4字节无符号整数。
- 相同类型的多条数据一般按表(table)的形式存储在 class 文件中
- 表由表头和表项(item)构成,表头是 u2 或 u4 整数
整个 class 文件被描述为一个 ClassFile 结构,如下:
1 | ClassFile { |
一、前期准备
1.1 自定义数据类型
Java 虚拟机规范定义了 u1、u2 和 u4 三种数据类型来表示1、2和4字节无符号整数。
但是 Java 中都是有符号整数,没有无符号整数,所以这里先定义几种无符号整数类型,实际上它们是由更大范围的整数值来保存的:
1 | /** |
使用自定义类型的原因是,方便在定义 ClassFile 类时,各个成员变量的类型能更清晰一些,不然都是 int、long 这些类型的话,都不知道 ClassFile 中实际保存的字节数量。
这样增加自定义类型后,下面的 ClassFile 结构就稍微好看一点了:
1 | public class ClassFile { |
1.2 类型数据读取
现在是把 class 文件当成字节流来处理,但是如果直接操作字节是很不方便的。而且前面增加了自定义数据类型,把数据读出来后,还要再转成对应的数据类型,相当麻烦。
所以定义了一个工具类 ClassReader
来帮助读取数据:
1 | public class ClassReader { |
工具类底层是用 ByteBuffer
来实现的,它提供了很多有用的方法来读取指定类型的数据,基本上可以直接使用。
二、类文件数据结构
2.1 魔数
很多文件格式都会规定满足该格式的文件必须以某几个固定字节开头,这几个字节主要起标识作用,叫作魔数(magic number)。
class 文件的魔数是 0xCAFEBABE
。
1 | magic = reader.readUint32(); |
Java 虚拟机规范规定,如果加载的 class 文件不符合要求的格式,Java 虚拟机实现就抛出 java.lang.ClassFormatError
异常。
2.2 版本号
魔数之后是 class 文件的次版本号和主版本号,都是u2类型。
1 | minorVersion = reader.readUint16(); |
次版本号只在 J2SE 1.2 之前用过,从1.2开始基本上就没什么用了(都是0)。
主版本号在 J2SE 1.2 之前是45,从1.2开始,每次有大的 Java 版本发布,都会加1。
Java 版本 | class 文件版本号 |
---|---|
JDK 1.0.2 | 45.0 ~ 45.3 |
JDK 1.1 | 45.0 ~ 45.65535 |
J2SE 1.2 | 46.0 |
J2SE 1.3 | 47.0 |
J2SE 1.4 | 48.0 |
Java SE 5.0 | 49.0 |
Java SE 6 | 50.0 |
Java SE 7 | 51.0 |
Java SE 8 | 52.0 |
特定的 Java 虚拟机实现只能支持版本号在某个范围内的 class 文件。
例如,Java 8 支持版本号为 45.0 ~ 52.0 的 class 文件。
如果版本号不在支持的范围内,Java 虚拟机实现就抛出 java.lang.UnsupportedClassVersionError
异常。
2.3 类访问标志
类访问标志,这是一个16位的 bitmask
,用于指出 class 文件定义的是类还是接口,访问级别是 public
还是 private
等。
1 | accessFlags = reader.readUint16(); |
2.4 类和超类
类访问标志之后是两个u2类型的常量池索引,分别给出类名和超类名。
1 | className = reader.readUint16(); |
class 文件存储的类名类似完全限定名,但是把点(.
)换成了斜线(/
),这种名字叫作二进制名(binary names)。
比如,java.lang.Object
在 class 文件中存储的名称为 java/lang/Object
。
每个类都要有名字,所以 className
必须是有效的常量池索引。
除 java.lang.Object
之外,其他类都有超类,所以除了 Object.class
以外,其他 class 文件中的 superClassName
必须是有效的常量池索引。
而 java.lang.Object
的 class 文件中,superClassName
的值是0。
2.5 类接口
类和超类索引后面是接口索引表,表中存放的也是常量池索引,给出该类实现的所有接口的名字。
1 | interfaceCount = reader.readUint16(); |
2.6 字段、方法
接口索引表之后是字段表和方法表,分别存储字段和方法信息。字段和方法的基本结构大致相同,差别仅在于属性表。
1 | field_info { |
和类一样,字段和方法也有自己的访问标志。
访问标志之后是一个常量池索引,给出字段名或方法名。
然后又是一个常量池索引,给出字段或方法的描述符。
最后是属性表。
字段和方法的结构基本一致,所以它们的解析过程也差不多:
1 | // 成员变量 |
三、常量池
常量池里面存放着各式各样的常量信息,包括数字和字符串常量、类和接口名、字段和方法名等等。
常量池实际上也是一个表。表头给出的常量池大小比实际大1。
假设表头给出的值是n,那么常量池的实际大小是n–1。也就是说,常量池的有效的常量池索引是1~n–1。0是无效索引,表示不指向任何常量。
CONSTANT_Long_info
和 CONSTANT_Double_info
各占两个位置。也就是说,如果常量池中存在这两种常量,实际的常量数量比n–1还要少,而且1~n–1的某些数也会变成无效索引。
1 | constantCount = reader.readUint16(); |
常量的第一个字节是 tag 值,用于指明常量的类型。
Java 虚拟机规范定义了14种常量,各个常量值对应的 tag 如下:
1 | public class Constant { |
按照结构,常量池的常量可以分为2种,一种是存放有数据的字面常量,一种是存放索引的引用常量。
像整数、浮点数、UTF8字节等,都属于字面常量;而像字符串、类型、方法等都是引用常量。
就比如,整数常量的结构是这样的:
1 | CONSTANT_Integer_info { |
整数常量的 u4 Integer
的值 101 就是这个整数常量的值,也就是它是直接保存数据的。
而字符串常量的结构是这样的:
1 | CONSTANT_String_info { |
字符串常量里面的 string_index
就只是一个索引,指向了另外的常量,并不直接保存数据。
下面分别详细介绍这些常量的结构定义。
3.1 字面常量
字面常量,和 Java 中的基本类型概念差不多,是实际拥有数据的常量。
3.1.1 CONSTANT_Utf8
CONSTANT_Utf8
是一个变长的数据结构,里面存放的是 MUTF-8 编码的字符串,结构如下:
1 | CONSTANT_Utf8_info { |
tag
等于 1。
length
是它的长度,u2 是2个字节,所以它的最大值为 65536。
这也就意味着,Java 中允许的最大字符串长度为 65536 字节。也就是能够放 65536 个 ASCII 字符,或者 65536/3 个中文字符。
它的读取比较直接,按照字节长度读取即可:
1 | public void readFrom(ClassReader reader) { |
3.1.2 CONSTANT_Integer
CONSTANT_Integer
是整型常量,使用4字节存储数值,结构如下:
1 | CONSTANT_Integer_info { |
tag
等于 3。
整型常量,对应的就是 Java 中的 Integer 整型,所以直接按照读取 Integer 的方式读取即可。
刚好 ByteBuffer
也提供了相应的基本类型读取方法:
1 | public int readInt() { |
所以在解析整型常量时,可以直接解析:
1 | public void readFrom(ClassReader reader) { |
3.1.3 CONSTANT_Float
CONSTANT_Float
是 IEEE754 单精度浮点数常量,使用4字节存储数值,结构如下:
1 | CONSTANT_Float_info { |
tag
等于 4。
单精度浮点数常量,对应的就是 Java 中的 Float 单精度浮点数。
ByteBuffer
也提供了相应的读取方法:
1 | public float readFloat() { |
然后就可以直接解析浮点数了:
1 | public void readFrom(ClassReader reader) { |
3.1.4 CONSTANT_Long
CONSTANT_Long
是8字节长整型常量,结构如下:
1 | CONSTANT_Long_info { |
tag
等于 5。
注意,在长整型常量定义中,8字节是被拆分成高位4字节和低位4字节来存放的。
长整型常量,对应的就是 Java 中的 Long 长整型。
ByteBuffer
也提供了相应的读取方法:
1 | public long readLong() { |
直接解析即可:
1 | public void readFrom(ClassReader reader) { |
3.1.5 CONSTANT_Double
CONSTANT_Double
是 IEEE754 双精度浮点数常量,使用8字节存储数值,结构如下:
1 | CONSTANT_Double_info { |
tag
等于 6。
注意,在双精度浮点数常量定义中,8字节也是被拆分成高位4字节和低位4字节来存放的。
双精度浮点数常量,对应的是 Java 中的 Double 双精度浮点数。
ByteBuffer
也提供了相应的读取方法:
1 | public double readDouble() { |
直接解析即可:
1 | public void readFrom(ClassReader reader) { |
3.2 引用常量
引用常量比较简单,它们不直接保存数据,只保存了索引,所以结构上可能会比字面常量稍微复杂一些。
下面简单介绍其中几种常见的引用常量。
3.2.1 CONSTANT_String
CONSTANT_String_info
常量表示 java.lang.String
字面量,结构如下:
1 | CONSTANT_String_info { |
tag
等于 8。
string_index
是常量池索引,指向一个 CONSTANT_Utf8_info
常量。
3.2.2 CONSTANT_Class
CONSTANT_Class
常量表示类或者接口的符号引用,结构如下:
1 | CONSTANT_Class_info { |
tag
等于 7。
name_index
是常量池索引,指向一个 CONSTANT_Utf8_info
常量。
3.2.3 CONSTANT_Fieldref
CONSTANT_Fieldref_info
表示字段符号引用,结构如下:
1 | CONSTANT_Fieldref_info { |
tag
等于 9。
class_index
是所在类索引,指向一个 CONSTANT_Class_info
常量。
name_and_type_index
是名称和类型定义索引,指向一个 CONSTANT_NameAndType_info
常量。
3.2.4 CONSTANT_Methodref
CONSTANT_Methodref_info
表示普通(非接口)方法符号引用,结构如下:
1 | CONSTANT_Methodref_info { |
tag
等于 10。
class_index
是所在类索引,指向一个 CONSTANT_Class_info
常量。
name_and_type_index
是名称和类型定义索引,指向一个 CONSTANT_NameAndType_info
常量。
3.2.5 CONSTANT_InterfaceMethodref
CONSTANT_InterfaceMethodref_info
表示接口方法符号引用,结构如下:
1 | CONSTANT_InterfaceMethodref_info { |
tag
等于 11。
class_index
是所在接口索引,指向一个 CONSTANT_Class_info
常量。
name_and_type_index
是名称和类型定义索引,指向一个 CONSTANT_NameAndType_info
常量。
3.2.6 CONSTANT_NameAndType
CONSTANT_NameAndType_info
给出字段或方法的名称和描述符,结构如下:
1 | CONSTANT_NameAndType_info { |
tag
等于 12。
name_index
是名称索引,指向一个 CONSTANT_Utf8_info
常量。
descriptor_index
是字段或方法的描述符索引,也是指向一个 CONSTANT_Utf8_info
常量。
CONSTANT_Class_info
和 CONSTANT_NameAndType_info
加在一起可以唯一确定一个字段或者方法。
描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。
不过这里的描述符,不是常见的完整的字段或者函数定义,而是一种缩写形式,它的规则有以下几个:
- 类型描述符
- 基本类型byte、short、char、int、long、float和double的描述符是单个字母,分别对应B、S、C、I、J、F和D(注意,long的描述符是J而不是L)
- 引用类型的描述符是“L + 类的完全限定名 + 分号”
- 数组类型的描述符是“[ + 数组元素类型描述符”
- 字段描述符
- 字段描述符就是字段类型的描述符
- 方法描述符
- 方法描述符是“(分号分隔的参数类型描述符)+ 返回值类型描述符”,其中void返回值由单个字母V表示
这里直接举几个例子来说明:
描述符 | 类型 |
---|---|
B | byte |
S | short |
Ljava/lang/Object; | java.lang.Object |
[I | int[] |
[[Ljava/lang/String; | String[][] |
()V | void method() |
(Ljava/lang/String;)Ljava/lang/String; | String method(String) |
([JJ)J | long method(long[], long) |
(Ljava/lang/String;Ljava/lang/String;)V | void method(String, String) |
为了减少描述符在 ClassFile 文件中占用的空间,它只保留了必要的属性,一些不必要的属性如方法名称,并没有直接保存在描述符中。
Java语言支持方法重载(override),不同的方法可以有相同的名字,只要参数列表不同即可。这就是为什么CONSTANT_NameAndType_info
结构要同时包含名称和描述符的原因。
那么字段呢?Java是不能定义多个同名字段的,哪怕它们的类型各不相同。这只是Java语法的限制而已,从class文件的层面来看,是完全可以支持这点的。
小结
常量池中的常量分为两类:字面量(literal)和符号引用(symbolic reference)。
字面量包括数字常量和字符串常量,符号引用包括类和接口名、字段和方法信息等。
除了字面量,其他引用常量都是通过索引直接或间接指向 CONSTANT_Utf8_info
常量。
四、属性表
属性表可谓是个大杂烩,里面存储了各式各样的信息,如方法的字节码等。
常量是由Java虚拟机规范严格定义的,共有14种。但属性是可以扩展的,不同的虚拟机实现可以定义自己的属性类型。
由于这个原因,Java虚拟机规范没有使用tag,而是使用属性名来区别不同的属性。
属性数据放在属性名之后的u1表中,这样Java虚拟机实现就可以跳过自己无法识别的属性。
属性的结构定义如下:
1 | attribute_info { |
属性表中存放的属性名实际上并不是编码后的字符串,而是常量池索引 attribute_name_index
,指向常量池中的 CONSTANT_Utf8_info
常量。
按照这个定义,定义了一个属性接口:
1 | public interface AttributeInfo { |
具体属性只要实现 readFrom
方法即可。
4.1 属性类型
Java虚拟机规范预定义了23种属性,按照用途可以分为三组:
- 实现Java虚拟机所必需的,共有5种
- Java类库所必需的,共有12种
- 提供给工具使用,共有6种,这组属性是可选的
属性名 | 所在位置 | 分组 | 增加版本 |
---|---|---|---|
ConstantValue | field_info | 1 | 1.0.2 |
Code | meghod_info | 1 | 1.0.2 |
Exceptions | method_info | 1 | 1.0.2 |
SourceFile | ClassFile | 3 | 1.0.2 |
LineNumberTable | Code | 3 | 1.0.2 |
LocalVariableTable | Code | 3 | 1.0.2 |
InnerClasses | ClassFile | 2 | 1.1 |
Synthetic | ClassFile,field_info,method_info | 2 | 1.1 |
Deprecated | ClassFile,field_info,method_info | 3 | 1.1 |
EnclosingMethod | ClassFile | 2 | 5.0 |
Signature | ClassFile,field_info,method_info | 2 | 5.0 |
SourceDebugExtension | ClassFile | 3 | 5.0 |
LocalVariableTypeTable | Code | 3 | 5.0 |
RunttimeVisibleAnnotations | ClassFile,field_info,method_info | 2 | 5.0 |
RunttimeInvisibleAnnotations | ClassFile,field_info,method_info | 2 | 5.0 |
RunttimeVisibleParameterAnnotations | method_info | 2 | 5.0 |
RunttimeInvisibleParameterAnnotations | method_info | 2 | 5.0 |
AnnotationDefault | method_info | 2 | 5.0 |
StackMapTable | Code | 1 | 6 |
BootstrapMethods | ClassFile | 1 | 7 |
RunttimeVisibleTypeAnnotations | ClassFile,field_info,method_info,Code | 2 | 8 |
RunttimeInvisibleTypeAnnotations | ClassFile,field_info,method_info,Code | 2 | 8 |
MethodParameters | method_info | 2 | 8 |
4.2 属性定义
4.2.1 Deprecated和Synthetic属性
Deprecated
是最简单的两种属性之一,仅起标记作用,不包含任何数据。
它的结构定义如下:
1 | Deprecated_attribute { |
由于不包含任何数据,所以attribute_length的值必须是0。
Deprecated
属性用于指出类、接口、字段或方法已经不建议使用,编译器等工具可以根据Deprecated属性输出警告信息。
J2SE 5.0之前,可以使用 Javadoc 提供的 @deprecated
标签指示编译器给类、接口、字段或方法添加 Deprecated
属性。
从J2SE 5.0开始,可以使用 @Deprecated
注解。
Deprecated
属性不包含数据,所以它的 readFrom
实现为空就行了:
1 |
|
4.2.2 Synthetic
Synthetic
是最简单的两种属性之一,仅起标记作用,不包含任何数据。
它的结构定义如下:
1 | Synthetic_attribute { |
Synthetic
属性用来标记源文件中不存在、由编译器生成的类成员,引入 Synthetic
属性主要是为了支持嵌套类和嵌套接口。
Synthetic
属性不包含数据,所以它的 readFrom
也是为空:
1 |
|
4.2.3 SourceFile
SourceFile
是可选定长属性,只会出现在 ClassFile
结构中,用于指出源文件名。
其结构定义如下:
1 | SourceFile_attribute { |
attribute_length
的值必须是2。
sourcefile_index
是常量池索引,指向一个 CONSTANT_Utf8_info
常量。
SourceFile
属性读取很简单,直接读就行了:
1 |
|
4.2.4 ConstantValue
ConstantValue
是定长属性,只会出现在 field_info
结构中,用于表示常量表达式的值。
其结构定义如下:
1 | ConstantValue_attribute { |
attribute_length
的值必须是2。
constantvalue_index
是常量池索引,指向某一个类型定义,但具体指向哪种常量因字段类型而异。
ConstantValue
属性读取也很简单,也是直接读就行了:
1 |
|
4.2.5 Code
Code
是变长属性,只存在于 method_info
结构中。Code
属性中存放字节码等方法相关信息。
其结构定义如下:
1 | Code_attribute { |
max_stack
给出操作数栈的最大深度;max_locals
给出局部变量表大小;
接着是字节码,存在u1表中;最后是异常处理表和属性表。
Code
属性结构相对复杂一些,有几层结构,读取起来有点麻烦:
1 |
|
4.2.6 Exceptions
Exceptions
是变长属性,记录方法抛出的异常表。
其结构定义如下:
1 | Exceptions_attribute { |
Exceptions
属性简单,直接读取即可:
1 |
|
4.2.7 LineNumberTable
LineNumberTable
属性表存放方法的行号信息。
结构定义如下:
1 | LineNumberTable_attribute { |
LineNumberTable
属性表不算复杂,可以直接读取:
1 |
|
小结
相对于常量来说,属性的结构要稍微复杂一些,毕竟是可以扩展的,不像常量池常量那样基本都是固定的。
虽然属性的机构稍微复杂一些,但是层次还是比较清晰的。
五、单元测试
1 | public class ClassFileStructureTest { |
1 | public class ClassFileTest { |
总结
class 文件:
- Java 虚拟机规范中所指的 class 文件,并非特指位于磁盘中的 .class 文件,而是泛指任何格式符合规范的 class 数据。它实际上可以通过网络下载,从数据库加载,甚至是在运行中直接生成等方式来获取 class 文件
- 构成 class 文件的基本数据单位是字节,可以把整个 class 文件当成一个字节流来处理
- 数据由连续多个字节构成,这些数据在 class 文件中以大端(big-endian)方式存储
- 为了描述 class 文件格式,Java 虚拟机规范定义了 u1、u2 和 u4 三种数据类型来表示1、2和4字节无符号整数
- 相同类型的多条数据一般按表(table)的形式存储在 class 文件中
- 表由表头和表项(item)构成,表头是 u2 或 u4 整数
常量池:
- 常量池里面存放着各式各样的常量信息,包括数字和字符串常量、类和接口名、字段和方法名等等
- 常量池实际上也是一个表。表头给出的常量池大小比实际大1
- 假设表头给出的值是n,那么常量池的实际大小是n–1。也就是说,常量池的有效的常量池索引是1~n–1。0是无效索引,表示不指向任何常量
long
和double
各占两个位置。也就是说,如果常量池中存在这两种常量,实际的常量数量比n–1还要少,而且1~n–1的某些数也会变成无效索引
- 按照结构,常量池的常量可以分为2种,一种是存放有数据的字面常量(literal),一种是存放索引的符号引用常量(symbolic reference)
- 字面量包括数字常量和字符串常量,符号引用包括类和接口名、字段和方法信息等
- 像整数、浮点数、UTF8字节等,都属于字面常量,直接存放数据
- 而像字符串、类型、方法等都是引用常量,不直接存放数据,只保存指向数据的索引
- 除了字面量,其他引用常量都是通过索引直接或间接指向
CONSTANT_Utf8_info
常量
属性:
- Java虚拟机规范没有使用tag,而是使用属性名来区别不同的属性
- 常量是由Java虚拟机规范严格定义的,共有14种。但属性是可以扩展的,不同的虚拟机实现可以定义自己的属性类型