JVM成神之路(1): Class 文件解析
前言
今天开展 JVM 的深度学习之路,来到第一站,Class 的字节码篇。
为什么从 Class 字节码开始起,原因是我们在学习 JVM 的时候,其实脑海中就会很自然的想到下面这张图
这就是 JVM 虚拟机的整体架构,经过编译后的 Java Class 文件被类加载器子系统加载到运行时数据区中,运行时数据区中有五个区域,堆、栈、本地方法栈、程序计数器、方法区,然后调用本地方法接口,执行引擎里面有垃圾回收器回收运行时数据区内的垃圾。
所以,class 文件是作为 JVM 虚拟机的输入源头,理解了这个,那后续的一系列围绕 Class 字节码的JVM工作流程就的理解就会变的比较顺畅。
一、Class 文件概述
学习 Java 语言的时候,常会听到这样的话,java 是一门跨平台的语言,即一次编译,就可以在不同的平台上运行。
一次编译指的就是编译成 class 字节码的过程。
而如今,jvm 不和任何语言包括 java 在内的语言绑定,它只与 class 文件这种特定的二进制文件格式关联。
所以,例如 Kotiln、Groovy 只需经过编译器编译后,也可以运行在 JVM 上。
经过编译后的 class 字节码文件,我们可以使用 Notepad++ 查看十六进制形式看到以 cafebabe 开头的二进制字节码文件。
也可以使用 jclasslib 工具进行查看
Class 字节码文件是一组以 8个字节为基础单位的二进制流,因为都是 0-1 的数据,所以没有任何分割符号,其中的字节顺序和数量都是按照 class 的规范进行排列的,这就像一本没有标点符号的书,存储着 class 文件的所有信息。
Class 文件格式采用一种类似于 C 语言的伪结构来存储数据,这种伪结构只有两种数据类型:
- 无符号数
- 表
无符号数属于基本数据类型,以 u1、u2、u4、u8 分别代表1个字节、2个字节、4个字节和8个字节。无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性的以 "_info" 结尾。
表用来描述有层次关系的复合结构的数据,整个 class 文件本质上就是一张表
Class 的文件结构的基本结构和框架都是保持不变的 ,如下所示
二、字节码文件解析
2.1 字节码文件准备
public class jvmClass {
private String name;
public jvmClass() {
}
public String getName() {
return this.name;
}
}
经过编译后的二进制如下:
CA FE BA BE 00 00 00 34 00 16 0A 00 04 00 12 09
00 03 00 13 07 00 14 07 00 15 01 00 04 6E 61 6D
65 01 00 12 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53
74 72 69 6E 67 3B 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
12 4C 6F 63 61 6C 56 61 72 69 61 62 6C 65 54 61
62 6C 65 01 00 04 74 68 69 73 01 00 29 4C 63 6F
6D 2F 78 69 61 6F 6C 65 69 2F 6A 76 6D 2F 74 65
73 74 2F 63 6C 61 73 73 66 69 6C 65 2F 6A 76 6D
43 6C 61 73 73 3B 01 00 07 67 65 74 4E 61 6D 65
01 00 14 28 29 4C 6A 61 76 61 2F 6C 61 6E 67 2F
53 74 72 69 6E 67 3B 01 00 0A 53 6F 75 72 63 65
46 69 6C 65 01 00 0D 6A 76 6D 43 6C 61 73 73 2E
6A 61 76 61 0C 00 07 00 08 0C 00 05 00 06 01 00
27 63 6F 6D 2F 78 69 61 6F 6C 65 69 2F 6A 76 6D
2F 74 65 73 74 2F 63 6C 61 73 73 66 69 6C 65 2F
6A 76 6D 43 6C 61 73 73 01 00 10 6A 61 76 61 2F
6C 61 6E 67 2F 4F 62 6A 65 63 74 00 21 00 03 00
04 00 00 00 01 00 02 00 05 00 06 00 00 00 02 00
01 00 07 00 08 00 01 00 09 00 00 00 2F 00 01 00
01 00 00 00 05 2A B7 00 01 B1 00 00 00 02 00 0A
00 00 00 06 00 01 00 00 00 08 00 0B 00 00 00 0C
00 01 00 00 00 05 00 0C 00 0D 00 00 00 01 00 0E
00 0F 00 01 00 09 00 00 00 2F 00 01 00 01 00 00
00 05 2A B4 00 02 B0 00 00 00 02 00 0A 00 00 00
06 00 01 00 00 00 0B 00 0B 00 00 00 0C 00 01 00
00 00 05 00 0C 00 0D 00 00 00 01 00 10 00 00 00
02 00 11
2.2 魔数与主次版本号
每个 Class 文件开头的4个字节被称为魔数(Magic Number)。
魔数的唯一作用是确定这个文件能否被虚拟机接收,很多文件格式标准中都有使用魔数来进行身份识别的习惯。
譬如图片格式,如 GIT 和 JPEG 等在文件头都存在魔数,使用魔数来区别主要是基于安全考虑,
Java class 的魔数就是 cafebabe。
如果一个 class 不以 oxcafebabe 开头,jvm 在文件校验的时候就会抛出下面的异常。
紧接着魔数的 4个字节存储的是 Class 文件的版本号:
- 第5 和第6个字节是次版本号
- 第7 和 第8个字节是主版本号
Java 的版本号从45开始,每发布一个大版本,主版本就加1。因此,看到 52 就是 java8 。
上面的字节码文件经过本小节分析可得
CA FE BA BE # 魔数
00 00 # 次版本号
00 34 # 主版本号 34 换成10进制就是 52
#以下省略
2.3 常量池计数器与常量池
紧接着 次主版本号之后就是常量池入口。
常量池可以比喻为 class 文件里的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据,通常也是占用 class 文件空间最大的数据项目之一,常量池常用于存放编译时期生成的各种字面量(Literal)和符号引用(Symbolic References)。
由于常量池大小不确定,所以,需要在常量池入口处放置一个 u2 类型的数据,代表常量池容量计数值(constant_pool_count)。
与 java 语言不同,这个容量计数是从1开始,而不是从0开始,切记。
为什么这样设计?
设计者将第 0 项常量空出来是考虑到某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况就可以用0来表示。
例子1 :Object类没有父类,他的父类索引指向哪里呢?指向 00 00 (指向常量池里的第 0 个常量,第0 个常量什么都没有,这个第 0 个,就是为了给所有无法指向的情况提供的一个空常量指向)
例子2: 匿名内部类。 (类名称指向哪里?指向 00 00)
CA FE BA BE # 魔数
00 00 # 次版本号
00 34 # 主版本号 34 换成10进制就是 52
00 16 # 常量池个数,换成10进制就是22个
#以下省略
同时用工具查看,确实是从1开始的。
常量池包含了class文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量。常量池中的每一项常量的结构都具备相同的特征,那就是每一项常量入口都是一个u1类型的标识,该标识用于确定该项的类型,这个字节称为tag byte(标识字节)
一旦JVM获取并解析这个标识,JVM就会知道在标识后的常量类型是什么。常量池中的每一项都是一个表,其项目类型共有14种,表17-4列出了所有常量项的类型和对应标识的值,比如当标识值为1时,表示该常量的类型为CONSTANT_utf8_info。
常量池中主要存放两大类常量:字面量(Literal)和 符号引用(Symbolic References)(类加载会提到一个解析过程,符号引用->直接引用)
字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。
符号引用则属于编译原理方面的概念,主要包括下面几类常量:
-
被模块导出或者开放的包(Package)
-
类和接口的全限定名(Fully Qualified Name)
-
字段的名称和描述符(Descriptor)
-
方法的名称和描述符
-
方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
-
动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
CA FE BA BE
00 00
00 34
00 16
0A // 第一个常量 0A,对应十进制 10 => 类中方法的符号引用
00 04 // 指向第4个常量
00 12 // 指向第18个常量
// 第2个常量
09 字段的符号引用
00 03 指向第3个常量
00 13 指向第19个常量
// 第3个常量
07 // 类或接口的符号引用
00 14 // 指向第20个常量
// 第4个常量
07
00 15 // 指向第21常量
// 第5个常量
01
00 04 长度
6E 61 6D 65 name
// 以下省略
可以看到分析完5个常量后,与 jclasslib 解析的一模一样。第五个常量 6E 61 6D 65 转换成字符串就是 name 。
2.4 访问标志
常量池后紧跟着的2个字节代表访问标志(access_flas)。
描述当前类或接口的访问修饰符,如 public、private ,该类是否抽象,是否 final 等。
在访问标识后,会指定该类的类别,父类列表以及实现的接口,这三项数据来确定这个类的继承关系,格式如表所示
类索引用于确定这个类的全限定名,
父类索引用于确定这个类的父类的全限定名。
- 由于 Java 语言不允许多重继承,所以父类索引只有一个,注意 java.lang.Object 类除外。
接口索引集合用来描述这个类实现了哪些接口,这些被实现的接口按 implement 语句后面接口的顺序从左到右排列在接口索引集合中。如果这个类本身是接口类型,则应当是按 extends 语句后面接口的顺序从左到右边排列在接口索引集合中。
对应字节码
00 21 # 访问标志 public
00 03 # 本类索引 <com/xiaolei/jvm/test/classfile/jvmClass>
00 04 # 父类索引 <java/lang/Object>
没有接口的话就是为空了。
2.5 字段表集合
类里面有很多字段,字段用集合来表示。
字段表(field_info)用于描述接口或者类中声明的变量。
Java 语言中的字段包括 "类级变量以及实例级变量",但不包括在方法内部声明的局部变量。
字段可以包括的修饰符有字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。
而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
字段表作为一个表,同样有自己的结构
2.6 方法表集合
字段表之后就是方法表信息了,它指向常量池索引集合,完整描述了每个方法的信息,在class 文件中,一个方法表与类或者接口中方法一一对应。
方法信息包含方法的访问修饰符(public、private、protected)、方法的返回值类型以及方法的参数信息等。
方法表结构如下
2.7 属性表集合
方法表集合之后的属性表集合,指的是class文件所携带的辅助信息,比如该class文件的源文件的名称。
属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但JVM运行时会忽略掉它不认识的属性。前面我们看到的属性表都是Code属性。Code属性就是存放在方法体里面的代码,像接口或者抽象方法,它们没有具体的方法体,因此也就不会有Code属性了。和常量池计数器以及常量池的设计一样,属性表同样设计了属性计数器和属性表。
本节对字节码class文件进行了比较深入的了解,class 字节码文件是一串二进制的字节流,内部包含很多属性,可以使用 javap命令或 jclasslib插件进行查看。
转载自:https://juejin.cn/post/7341408996597612563