Java class 文件简介
# Java Class 文件简介
作为 一个通用的、机器无关的执行平台,任何其他语言的实现者都可以将 Java 虚拟机作为语言的产品交付媒介。Java 虚拟机不和包括 Java 在内的任何语言绑定,它只与“Class 文件”这种特定的二进制文件格式所关联,Class 文件中包含了 Java 虚拟机指令集和符号表以及若干其他辅助信息
当前使用 JVM 的语言有 Java、JRuby、Groovy 等等
Class 文件结构
class 文件可能由类加载器生成,所以并不是所有类或者接口都定义在已有的 class 文件中
为了解决大端小端的问题,class 文件规定高位字节放在文件靠前的位置
class 文件中主要包含两种数据:
- 无符号整型,u1、u2、u3 和 u4,一般用于计数。u 后面的数字表示整型所占用的字节数,例如:u1 即为 C 中的 unsigned char
- 表,表用来存储实际的数据和信息,class 文件中表的类型个数是固定的且常以
_info
作为后缀
大致结构
class 文件的大致结构如下,后面详细介绍:
- class 文件起始四字节使用 16 进制表示为
0xcafebabe
- 第 5 和第 6 字节是次版本号
- 第 7 和第 8 字节是主版本号
- 版本号之后即为常量池入口,常量池中的每一项都是表,常量池主要保存两大类常量
- 字面值
- 符号引用
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
- 常量池之后有两个字节:访问标志,用于表示这个 class 是类还是接口;是否定义为 public 类型等等
- 父类索引(u2)、类索引(u2),接口索引数组,用于指明类或接口的继承关系
- 索引数组之后一般是字段表、方法表、属性表等等,这些区段用于保存实际的代码信息
示例
使用 javac
命令编译下面的代码(我所使用的 javac 版本:javac 1.8.0_65
,win7x64)
package xyz.yearn;
public class TestClass {
private int m;
public int inc(){ return m+1;}
}
class 文件的二进制内容为(可以使用 Free Hex Editor Neo 查看,为了方便阅读,讲解的部分使用双下划线标出)
class 文件的二进制内容和下面的 表1 是对应的
// 主版本号 0x34;常量池中有 0x13-1=18 个表项;第一个表项为 0xa,即 `methodref_info`
ca fe ba be 00 00 __00 34 00 13 0a__ 00 04 00 0f 09
00 03 00 10 07 00 11 07 00 12 01 00 01 6d 01 00
01 49 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e
75 6d 62 65 72 54 61 62 6c 65 01 00 03 69 6e 63
01 00 03 28 29 49 01 00 0a 53 6f 75 72 63 65 46
69 6c 65 01 00 0e 54 65 73 74 43 6c 61 73 73 2e
6a 61 76 61 0c 00 07 00 08 0c 00 05 00 06 01 00
13 78 79 7a 2f 79 65 61 72 6e 2f 54 65 73 74 43
6c 61 73 73 01 00 10 6a 61 76 61 2f 6c 61 6e 67
// 常量池之后为访问标志位,即下面的 0x0021
// 将上面类前的 public 删除(或者改为 private),0x0021 将变为 0x0020(private)
// 访问标志后为类索引(u2,即 0x0003)、父类索引(u2,即 0x0004)和接口索引
// 接口索引第一个 u2 为实现接口的个数,0x0000,表示当前类未实现任何接口
2f 4f 62 6a 65 63 74 __00 21 00 03 00 04 00 00__
// 接口索引后为字段表,第一个 u2 用于计数;余下的 3 个 u2 表示变量 m 的访问标志和两个索引
// 每一个字段都可以有额外的描述信息,m第三个u2后为这些信息的计数,不过 m 没有,所以值为 0x0000
__00 01 00 02 00 05 00 06 00 00__
// 字段表后为方法表集合,第一个 u2(0x0002) 为方法计数,一个为构造函数 init,一个为 inc
// 0x0001 表示 public;0x0007 为函数名索引;0x0008 为函数类型描述
__00 02 00 01 00 07 00 08 00 01 00 09__ 00 00 00 1d 00 01 00 01 00 00 00
05 2a b7 00 01 b1 00 00 00 01 00 0a 00 00 00 06
00 01 00 00 00 03 00 01 00 0b 00 0c 00 01 00 09
00 00 00 1f 00 02 00 01 00 00 00 07 2a b4 00 02
04 60 ac 00 00 00 01 00 0a 00 00 00 06 00 01 00
00 00 05 00 01 00 0d 00 00 00 02 00 0e
获得 class 文件后执行命令:javap -verbose TestClass
,可以得到下面的输出(有删减,全文使用表 1引用下面输出)
原始的 class 文件是二进制的,使用上面的命令可以将其解析为便于阅读的文本格式(不同版本的 Java,解析出来的格式不同)
// class 文件前 4 个字节为魔数 0xcafebabe,用于标识当前文件为 Java 所用的 class 文件
public class xyz.yearn.TestClass
minor version: 0 // 第 5 和第 6 个字节用于标识次版本号,class 使用大端法,故靠左的字节为高位字节
major version: 52 // 第 7 和第 8 字节用于标识主版本号
flags: ACC_PUBLIC, ACC_SUPER // 这是解析 访问标志位 获得的信息
Constant pool: // 主版本号之后即为常量池入口
#1 = Methodref #4.#15 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#16 // xyz/yearn/TestClass.m:I
#3 = Class #17 // xyz/yearn/TestClass
#4 = Class #18 // java/lang/Object
#5 = Utf8 m // 变量 m 的变量名
#6 = Utf8 I // 变量类型,I 表示 int,数组使用[、[[等描述
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 inc
#12 = Utf8 ()I
#13 = Utf8 SourceFile
#14 = Utf8 TestClass.java // 在二进制文件中,这句后面为访问标志
#15 = NameAndType #7:#8 // "<init>":()V
#16 = NameAndType #5:#6 // m:I
#17 = Utf8 xyz/yearn/TestClass
#18 = Utf8 java/lang/Object
{
public xyz.yearn.TestClass();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1 // init 没有参数,args_size 和 locals 中的 1 表示 this 指针
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 5: 0
public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
// 栈帧所需的局部变量表、操作数个数在编译时已经确定并写在 code 属性中
// Java 的指令面向操作数栈而非寄存器,所以需要一块内存(stack) 保存指令的操作数
// 操作数栈最大深度编译时已确定,因为 Java 指令操作数的个数是确定的
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
}
class 文件常量池
常量池中保存两大类数据:字面值与表,表的类型个数是有限的,JDK 7 中有 14 种,常量池的索引值从 1 开始
表的第一个字节(u1)标识表的类型,下面简要介绍几种表
CONSTANT_Methodref_info
用于保存类中方法的符号引用
类型 | 名称 | 数量 | 备注 |
---|---|---|---|
u1 | tag | 1 | 标识类型,当前值为 10 |
u2 | index | 1 | 索引,指向声明方法的类描述符 Class_info |
u2 | index | 1 | 索引,指向名称及类型描述符 |
CONSTANT_Class_info
此类型的常量代表一个类或者接口的符号引用,其结构如下
类型 | 名称 | 数量 | 备注 |
---|---|---|---|
u1 | tag | 1 | 标识类型,当前值为 7 |
u2 | name_index | 1 | 索引,执行一个 utf8 表,指向的表保存类或接口的全限定名 |
表 1 中的 #3
即为一个 class_info
表,其中的 name_index
索引指向了 #17
,#17
即为一个 utf8_info
表
CONSTANT_Utf8_info
此类型用于保存 utf8 编码的字符串,其结构如下。Java 中变量名由当前表保存,故Java中符号名最长为 64K
类型 | 名称 | 数量 | 备注 |
---|---|---|---|
u1 | tag | 1 | 当前值为 1 |
u2 | length | 1 | 表示后续占用字节数,length 最大值为64K |
u1 | bytes | length | 实际的字符串内容 |
描述符
Java 使用字符串描述变量与方法的类型
Java 使用指定字符表示变量的基本类型,例如 B 表示 byte、C 表示 char:
B:byte, C:char, D:double, F:float, I:int, J:long, S:short, Z:boolean, V:void, L:object
对于数组,每一维使用一个前置 [
来描述,例如:java.lang.String[][]
,其描述为:[[Ljava/lang/String;
用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()” 之内。如方法 void inc()
的描述符为()V
,方法 java.lang.String toString()
的描述符为()Ljava/lang/String;
,方法 int indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex)
的描述符为([CII[CIII)I
class 文件访问标志
在常量池结束之后,紧接着的两个字节代表访问标志(access_flags
),这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final 等
class 文件(父)类索引和接口索引
父类索引、类索引与接口索引用于指明当前类或接口的继承关系
由于 Java 语言不允许多重继承,所以父类索引只有一个,除了 java.lang.Object
之外,所有的 Java 类都有父类,因此除了 java.lang.Object
外,所有 Java 类的父类索引都不为 0
接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类本身是一个接口,则应当是 extends 语句)后的接口顺序从左到右排列在接口索引集合中
字段表集合
字段表(field_info
)用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量,但不包括在方法内部 声明的局部变量
字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本 Java 代码之中不存在的字段,譬如在内部类中为 了保持对外部类的访问性,会自动添加指向外部类实例的字段
类型 | 名称 | 数量 | 备注 |
---|---|---|---|
u2 | access_flags | 1 | 当前字段的访问标志 |
u2 | name_index | 1 | 字段名称 |
u2 | descriptor_index | 1 | 字段和方法的描述 |
方法表集合
class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的结构如同字段表一样,依次包括了访问 标志(access_flags
)、名称索引(name_index
)、描述符索引(descriptor_index
)、 属性表集合(attributes)几项
方法的具体代码保存在下面的属性表集合中
属性表集合
属性表用于保存其他字段中的一些属性,例如 Code 属性用于保存方法的代码
字节码指令简介
Java 虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数的指令都不包含操作数,只有一个操作码
Java 虚拟机指令操作码长度只有一个字节,所以指令集的操作码总数不超过 256 条
因为 Java 操作码个数有限,故不可能每一种类型的数据都对应独立的操作码,所以很多不同类型的数据所使用的操作码是相同的
JVM 中已有指令详细介绍可参看其他资料,本文只介绍部分后续会用到的指令
与常见的 CPU 指令相比, JVM 指令有一个异常抛出命令:athrow
对象创建与访问
虽然类实例和数组都是对象,但 Java 虚拟机对类实例和数组的创建与操作使用了不同的字节码指令
-
类创建指令:new
-
数组创建指令:newarray、anewarray、multianewarray
-
访问字段:getfield、putfield、getstatic、putstatic
- getstatic: Get
static
field from class ,参考。静态初始化块只会在第一次初始化类时执行
- getstatic: Get
方法调用与返回
- invokestatic,调用类中 static 方法