JVM系列之Class文件解析
1、简介
机器是只认机器码的,所谓的机器码, 就是机器认的一堆有特殊意义的二进制指令。
这些二进制指令的语义是由具体机器CPU的指令集架构(Instruction Set Architecture)来规定的。这些指令集都是和具体的CPU系列是强相关的,比如x86的指令集换到arm上就玩不转。这对程序的可移植性和维护性都带来了很大的挑战,同一份逻辑要散落在各个平台, 很肯能就漏改了某一处逻辑。所以过去那个年代有人提出了write once, run anywhere的口号,然后java诞生了。java的跨平台性是靠jvm来提供的。
JVM是一层用来屏蔽平台无关性的抽象, JVM本身是和平台强相关的, JVM作为虚拟的机器,也抽象出了一套自己的“指令集”.
但是这些指令又不是机器码一样能直接执行,还需要一些额外的信息,例如常量,栈帧,异常等信息, 以满足java等高级语言的语法和语义,这些信息就都存在class文件里。
事实上,在java发展之初,设计者就考虑过让其他语言运行在jvm,<<Java虚拟机规范>>和<<Java语言规范>>是分开发布的,第一版jvm规范也说过:in the future, we will consider bounded extensions to the java virtual machine to provide better support for other languages.
class可能的设计思路: class文件作为实现平台无关性的基石,猜测要做到以下几点
1. 语义: 能完整的表达出Java的语义
2. 紧凑: 早期的计算机内存很小,性能很差,class文件需要尽量占少的空间, 尽可能小
3. 安全性: 既要保证java的语法语义不被破坏,又要确保错误的格式得到虚拟机正确的处理
4. 可扩展: java在发展,class也要跟上。
1.1、前端编译器:源代码到字节码
JDK 的安装目录里有一个 javac 工具,就是它将 Java 代码翻译成字节码,这个工具我们叫做编译器。
javac 编译器的处理过程可以分为下面四个阶段:
第一个阶段:词法、语法分析。在这个阶段,JVM 会对源代码的字符进行一次扫描,最终生成一个抽象的语法树。简单地说,在这个阶段 JVM 会搞懂我们的代码到底想要干嘛。就像我们分析一个句子一样,我们会对句子划分主谓宾,弄清楚这个句子要表达的意思一样。
第二个阶段:填充符号表。我们知道类之间是会互相引用的,但在编译阶段,我们无法确定其具体的地址,所以我们会使用一个符号来替代。在这个阶段做的就是类似的事情,即对抽象的类或接口进行符号填充。等到类加载阶段,JVM 会将符号替换成具体的内存地址。
第三个阶段:注解处理。我们知道 Java 是支持注解的,因此在这个阶段会对注解进行分析,根据注解的作用将其还原成具体的指令集。
第四个阶段:分析与字节码生成。到了这个阶段,JVM 便会根据上面几个阶段分析出来的结果,进行字节码的生成,最终输出为 class 文件。
我们一般称 javac 编译器为前端编译器,因为其发生在整个编译的前期。常见的前端编译器有 Sun 的 javac,Eclipse JDT 的增量式编译器(ECJ)。
1.2、JIT 编译器:从字节码到机器码
当源代码转化为字节码之后,其实要运行程序,有两种选择。一种是使用 Java 解释器解释执行字节码,另一种则是使用 JIT 编译器将字节码转化为本地机器代码。
这两种方式的区别在于,前者启动速度快但运行速度慢,而后者启动速度慢但运行速度快。至于为什么会这样,其原因很简单。因为解释器不需要像 JIT 编译器一样,将所有字节码都转化为机器码,自然就少去了优化的时间。而当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。所以在实际情况中,为了运行速度以及效率,我们通常采用两者相结合的方式进行 Java 代码的编译执行。
在 HotSpot 虚拟机内置了两个即时编译器,分别称为 Client Compiler 和Server Compiler。这两种不同的编译器衍生出两种不同的编译模式,我们分别称之为:C1 编译模式,C2 编译模式。
C1 编译模式会将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。而 C2 编译模式,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。简单地说 C1 编译模式做的优化相对比较保守,其编译速度相比 C2 较快。而 C2 编译模式会做一些激进的优化,并且会根据性能监控做针对性优化,所以其编译质量相对较好,但是耗时更长。
实际上对于 HotSpot 虚拟机来说,其一共有三种运行模式可选,分别是:
1. 混合模式(Mixed Mode) 。即 C1 和 C2 两种模式混合起来使用,这是默认的运行模式。如果你想单独使用 C1 模式或 C2 模式,使用 -client 或 -server 打开即可。
2. 解释模式(Interpreted Mode)。即所有代码都解释执行,使用 -Xint 参数可以打开这个模式。
3. 编译模式(Compiled Mode)。 此模式优先采用编译,但是无法编译时也会解释执行,使用 -Xcomp 打开这种模式。
1.3、AOT 编译器:源代码到机器码
AOT 编译器的基本思想是:在程序执行前生成 Java 方法的本地代码,以便在程序运行时直接使用本地代码。总的来说,AOT 编译器从编译质量上来看,肯定比不上 JIT 编译器。其存在的目的在于避免 JIT 编译器的运行时性能消耗或内存消耗,或者避免解释程序的早期性能开销。在运行速度上来说,AOT 编译器编译出来的代码比 JIT 编译出来的慢,但是比解释执行的快。而编译时间上,AOT 也是一个始终的速度。所以说,AOT 编译器的存在是 JVM 牺牲质量换取性能的一种策略。就如 JVM 其运行模式中选择 Mixed 混合模式一样,使用 C1 编译模式只进行简单的优化,而 C2 编译模式则进行较为激进的优化。充分利用两种模式的优点,从而达到最优的运行效率。
编译速度上:解释执行 > AOT 编译器 > JIT 编译器。
编译质量上:JIT 编译器 > AOT 编译器 > 解释执行
2、class文件的结构
基于以上原则,class文件是以二进制形式存储,并严格的规范了各个字段的语义, 兼容性和检查方法
每个class文件都只代表一个类或者接口,java中一个类的信息主要有字段(fields)和方法(method), 还有继承自哪个类,实现了哪些接口等等
class文件也对应的给出了存储的结构.
1、创建Demo.java文件
public class Demo{
public static void main(String args[]){
System.out.println("Hello World.");
}
}
2、命令行运行javac Demo.java命令编译这个类,生成Demo.class文件
cafe babe 0000 0034 001d 0a00 0600 0f09
0010 0011 0800 120a 0013 0014 0700 1507
0016 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 046d 6169
6e01 0016 285b 4c6a 6176 612f 6c61 6e67
2f53 7472 696e 673b 2956 0100 0a53 6f75
7263 6546 696c 6501 0009 4465 6d6f 2e6a
6176 610c 0007 0008 0700 170c 0018 0019
0100 0b48 656c 6c6f 2057 6f72 6c64 0700
1a0c 001b 001c 0100 0444 656d 6f01 0010
6a61 7661 2f6c 616e 672f 4f62 6a65 6374
0100 106a 6176 612f 6c61 6e67 2f53 7973
7465 6d01 0003 6f75 7401 0015 4c6a 6176
612f 696f 2f50 7269 6e74 5374 7265 616d
3b01 0013 6a61 7661 2f69 6f2f 5072 696e
7453 7472 6561 6d01 0007 7072 696e 746c
6e01 0015 284c 6a61 7661 2f6c 616e 672f
5374 7269 6e67 3b29 5600 2100 0500 0600
0000 0000 0200 0100 0700 0800 0100 0900
0000 1d00 0100 0100 0000 052a b700 01b1
0000 0001 000a 0000 0006 0001 0000 0001
0009 000b 000c 0001 0009 0000 0025 0002
0001 0000 0009 b200 0212 03b6 0004 b100
0000 0100 0a00 0000 0a00 0200 0000 0300
0800 0400 0100 0d00 0000 0200 0e
字节码文件结构是一组以 8 位字节为基础的二进制流,各数据项目严格按照顺序紧凑地排列在 Class 文件之中,中间没有添加任何分隔符。在字节码结构中,有两种最基本的数据类型来表示字节码文件格式,分别是:无符号数和表。
无符号数属于最基本的数据类型。 它以 u1、u2、u4、u8 六七分别代表 1 个字节、2 个字节、4 个字节、8 个字节的无符号数。无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成的字符串值。例如下表中第一行中的 u4 表示 Class 文件前 4 个字节表示该文件的魔数,第二行的 u2 表示该 Class 文件第 5-6 个字节表示该 JDK 的次版本号。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型。 所有表都习惯性地以_info结尾。表用于描述有层次关系的复合结构的数据,例如下表第 5 行表示其实一个类型为 cp_info 的表(常量池),这里面存储了该类的所有常量。
// u代表unsigned byte, 无符号字节, 占8位
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
2.1、魔数与Class文件版本
Class 文件的第 1 - 4 个字节代表了该文件的魔数(Magic Number)。它唯一的作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件,其值固定是:0xCAFEBABE(咖啡宝贝)。如果一个 Class 文件的魔数不是 0xCAFEBABE,那么虚拟机将拒绝运行这个文件。
Class 文件的第 5 - 6 个字节代表了 Class 文件的次版本号(Minor Version),即编译该 Class 文件的 JDK 次版本号。
Class 文件的第 7 - 8 个字节代表了 Class 文件的主版本号(Major Version),即编译该 Class 文件的 JDK 主版本号。
2.2、常量池
紧跟版本信息之后的是常量池信息,其中前 2 个字节表示常量池个数,其后的不定长数据则表示常量池的具体信息。
常量池的常量都是由cp_info这种表结构组成的,而且表结构不同其大小也不同。在 Java 虚拟机规范中一共有 14 种 cp_info 类型的表结构。
这些 cp_info 表结构又有不同的数据结构,其对应的数据结构如下图所示:
cp_info表结构一共有三个字段,第一个字段表示这个表结构的标示值,有一个字节大小,对应我们上一个表格中的数字。第二、三个字段表示其表结构的描述,不同字段其意思不太一样。
Hello World 文件字节码对应的内容是:00 1d,其值为 29,表示一共有 29 - 1 = 28 个常量。紧跟着常量池的就是 28 个常量了,因为每个常量都对应不同的类型,所以我们无法得知其具体大小,只能一个个分析。
第 1 个常量。 紧接着 001d 的后一个字节为 0A,为十进制数字 10,查表可知其为方法引用类型(CONSTANT_Methodref_info)的常量。
再查 cp_info 对应的表结构知道,该常量项第 2 - 3 个字节表示类信息,第 4 - 5 个字节表示名称及类描述符。
该常量项第 2 - 3 个字节,其值为 00 06,表示指向常量池第 6 个常量所表示的信息。根据后面我们分析的结果知道第 6 个常量是 java/lang/Object。第 4 - 5 个字节,其值为 000f,表示指向常量池第 15 个常量所表示的信息,根据 javap 反编译出来的信息可知第 10 个常量是 😦)V。将这两者组合起来就是:java/lang/Object.:V,即 Object 的 init 初始化方法。
大致就是按照上面的方式去分析每一个常量的值和意义,就能分析出来
2.3、访问标志
在常量池结束之后,紧接着的两个字节代表类或接口的访问标记(access_flags)。这里的数据为 00 21。这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口、是否定义为public类型、是否定义为abstract类型等。具体的标志位以及标志的含义见下表。
在这里这两个字节是 00 21,通过查看我们并没有发现有标志值是 00 21 的标志名称。这是因为这里的访问标志可能是由多个标志名称组成的,所以字节码文件中的标志值其实是多个值进行或运算的结果。通过查阅上述表格,我们可以知道,00 21 由 00 01(第1行)和 00 20(第3行)进行或运算得来。也就是说该类的访问标志是 public 并且允许使用 invokespecial 字节码指令的新语义。
2.4、类索引、父类索引、接口索引
在访问标记后,则是类索引、父类索引、接口索引的数据,这里数据为:00 05 00 06 00 00。类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据的集合,Class 文件中由这三项数据来确定这个类的继承关系。
类索引: 类索引用于确定这个类的全限定名,它用一个 u2 类型的数据表示。这里的类索引是 00 05 表示其指向了常量池中第 5 个常量,通过我们之前的分析,我们知道第 5 个常量其最终的信息是 Demo 类。
父类索引: 父类索引用于确定这个类的父类的全限定名,父类索引用一个u2类型的数据表示。这里的父类索引是 00 06 表示其指向了常量池中第 6 个常量,通过我们之前的分析,我们知道第 6 个常量其最终的信息是 Object 类。因为其并没有继承任何类,所以 Demo 类的父类就是默认的 Object 类。
接口索引: 接口索引集合就用来描述哪个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类本身就是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。对于接口索引集合,入口第一项是 u2 类型的数据为接口计数器(interfaces_count),表示索引表的容量,而在接口计数器后则紧跟着所有的接口信息。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。(这里 Demo 类的字节码文件中,因为并没有实现任何接口,所以紧跟着父类索引后的两个字节是0x0000,这表示该类没有实现任何接口。因此后面的接口索引表为空。)
2.5、字段表集合
字段表集合用于描述接口或者类中声明的变量,这里的数据为:00 00。这里说的字段包括类级变量和实例级变量,但不包括在方法内部声明的局部变量。在类接口集合后的2个字节是一个字段计数器,表示总有有几个属性字段。在字段计数器后,才是具体的属性数据。
字段表的每个字段用一个名为 field_info 的表来表示,field_info 表的数据结构如下所示:
因为我们并没有声明任何的类成员变量或类变量,所以在 Demo 的字节码文件中,字段计数器为 00 00,表示没有属性字段。
2.6、方法表集合
在字段表后的 2 个字节是一个方法计数器,表示类中总有有几个方法,在字段计数器后,才是具体的方法数据。这里数据为:00 02 。
方法表中的每个方法都用一个 method_info 表示,其数据结构如下:
Demo 类的字节码文件中,方法计数器的值为 00 02,表示一共有 2 个方法。
第 1 个方法,这里数据为:00 01 00 07 00 08 00 01 00 09 00 0000 1d 00 01 00 01 00 0000 05 2a b7 00 01 b1 0000 0001 000a 0000 0006 0001 0000 0001。方法计数器后 2 个字节表示方法访问标识,这里是 00 01,表示其实 ACC_PUBLIC 标识,对比上面的图表可知其表示 public 访问标识。紧接着 2 个字节表示方法名称的索引,这里是 00 07 表示指向了常量池第 7 个常量,查阅可知其指向了。紧接着的 2 个字节表示方法描述符索引项,这里是 00 08 表示指向了常量池第 8 个常量,查阅可知其指向了()V。
紧接着 2 个字节表示属性表计数器,这里是 00 01 表示该方法的属性表一共有 1 个属性。属性表的表结构如下:
前两个字节是名字索引、接着 4 个字节是属性长度、接着是属性的值。这里前两个字节为 0009,指向了常量池第9个常量,查询可知其值为Code,说明此属性是方法的字节码描述。 Code 属性的表结构如下:
根据 Code 属性对应表结构知道,前 2 个字节为 0009,即常量池第 9 个常量,查询知道是字符串常量Code。接着 4 个字节表示属性长度,这里值为 1D,即 29 的长度。下面我们继续分析 Code 属性的数据内容。
紧接着 2 个字节为 max_stack 属性。这里数据为 00 01,表示操作数栈深度的最大值。
紧接着 2 个字节为 max_locals属性。这里是数据为 00 01,表示局部变量表所需的存储空间为 1 个 Slot。在这里 max_locals的单位是Slot,Slot是虚拟机为局部变量分配内存所使用的最小单位。
接着 4 个字节为 code_length,表示生成字节码这里给的长度。这里数据为 00 00 00 05,表示生成字节码长度为 5 个字节。那么紧接着 5 个自己就是对应的数据,这里数据为 2a b7 00 01 b1,这一串数据其实就是字节码指令。通过查询字节码指令表,可知其对应的字节码指令:
- 读入2A,查表得0x2A对应的指令为aload_0,这个指令的含义是将第0个Slot中为reference类型的本地变量推送到操作数栈顶。
- 读入B7,查表得0xB7对应的指令为invokespecial,这条指令的作用是以栈顶的reference类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private方法或者它的父类的方法。这个方法有一个u2类型的参数说明具体调用哪一个方法,它指向常量池中的一个CONSTANT_Methodref_info类型常量,即此方法的方法符号引用。
- 读入00 01,这是invokespecial的参数,查常量池得0x0001对应的常量为实例构造器“”方法的符号引用。
- 读入B1,查表得0xB1对应的指令为return,含义是返回此方法,并且返回值为void。这条指令执行后,当前方法结束。
接着 2 个字节为异常表长度,这里数据为 00 00,表示没有异常表数据。那么接下来也就不会有异常表的值。
紧接着 2 个字节是属性表的长度,这里数据为 00 01,表示有一个属性。该属性长度为一个 attribute_info 那么长。attribute_info 属性表的表结构如下。
首先,前两个字节表示属性名称索引,这里数据为:00 0A。指向了第 10 个常量,查阅可知值为:LineNumberTable。LineNumberTable 表的表结构如下图所示。
其前两个字节是属性名称索引,就是上面已经分析过的 00 0A。
接着 4 个字节是属性长度,这里数据为 00 00 00 06,表示有 6 个字节的数据。接着 2 个字节是 LineNumberTable 的长度,这里数据是 00 01,表示长度为 1。接着跟着 1 个 line_number_info 类型的数据,下面是 line_number_info 表的结构,其包含了 start_pc 和 line_number 两个 u2 类型的数据项。前者是字节码行号,后者是 Java 源码行号。
那么接下来 2 个字节为 00 00,即 start_pc 表示的字节码行号为第 0 行。接着 00 01,即 line_number 表示 Java 源码行号为第 1 行。
到此,我们方法表集合的第一个方法分析结束。我们通过 javap 反编译查看,可以看到 Code 和 LineNumberTable 都是完全正确的。
2.7、属性表集合
紧接着我们剩下的数据为:00 0100 0d00 0000 0200 0e,这些就是属性表集合的数据了。
根据上面的表格我们知道,紧跟着的 2 个字节数据是属性表属性数量,这里数据为 00 01,表示有 1 个属性。后面紧跟着 1 个表结构为 attribute_info 的属性数据。attribute_info 表的结构如下图所示:
前两个字段为属性名称索引,这里数据为 00 0d,表示第 13 个常量池,查询可知这里的值是:SourceFile。SourceFile 属性的表结构如下图所示:
SourceFile 表结构前两个字节我们已经分析过,数据为 00 0d,表示第 13 个常量池,指的是SourceFile这个值。接着我们看后面 4 个字节,这里数据为 00 00 00 02,表示属性长度为 2 个字节。紧跟着的 2 个字节表示 SourceFile 的常量池索引,即该字节码文件的源文件名称,这里数据是 00 0e,即常量池的第 14 项,即Demo.java。所以这个属性项标识了该字节码文件的源文件名称为 Demo.java。
3、总结
4、往期佳文
4.1、面试系列
1、吊打面试官之一面自我介绍
2、吊打面试官之一面项目介绍
3、吊打面试官之一面系统架构设计
4、吊打面试官之一面你负责哪一块
5、吊打面试官之一面试官提问
6、吊打面试官之一面你有什么问题吗
······持续更新中······
4.2、技术系列
1、吊打面试官之分布式会话
2、吊打面试官之分布式锁
3、吊打面试官之乐观锁
4、吊打面试官之幂等性问题
5、吊打面试关之分布式事务
6、吊打面试官之项目线上问题排查
······持续更新中······
4.3、源码系列
1、源码分析之SpringBoot启动流程原理
2、源码分析之SpringBoot自动装配原理
3、源码分析之ArrayList容器
4、源码分析之LinkedList容器
5、源码分析之HashMap容器
6、源码分析之ConcurrentHashMap容器
7、源码分析之五种Map容器的区别
······持续更新中······
4.4、数据结构和算法系列
1、数据结构之八大数据结构
2、数据结构之动态查找树(二叉查找树,平衡二叉树,红黑树)
······持续更新中······
4.5、并发系列
1、并发系列之初识多线程
2、并发系列之JMM内存模型
3、并发系列之synchronized解析
4、并发系列之volatile解析
5、并发系列之synchronized与volatile的区别
6、并发系列之Lock解析
7、并发系列之synchronized与lock的区别
8、并发系列之CAS与原子操作
9、并发系列之AQS分析
10、并发系列之线程池解析
11、并发系列之锁的知识梳理
······持续更新中······
4.6、面试题系列
······持续更新中······