前导说明:
本文基于《深入理解Java虚拟机》第二版
和个人理解完成,
以大白话的形式期望能用大家都看懂的描述讲清楚虚拟机内幕,
后续会增加基于《深入理解Java虚拟机》第三版
内容,并进行二个版本的对比
。
class文件是一组以8字节为基础单位的二进制流,各个数据项严格按照顺序紧凑排列在class文件中,中间没有任何分隔符,class文件中存储的几乎是全部程序运行的程序。JVM规范规定,class文件格式采用类似C语言结构体的伪结构来存储数据,这种结构只有两种数据类型:无符号数和表。
无符号数:
属于基本数据类型,主要可以用来描述数字、索引符号、数量值或者按照UTF-8编码构成的字符串值,大小使用u1、u2、u4、u8分别表示1字节、2字节、4字节和8字节。
表:
由多个无符号数或者其他表作为数据项构成的复合数据类型,所有的表都习惯以"_info"结尾。
表主要用于描述,比如方法、字段等数据项。需要注意的是class文件是没有分隔符的,所以每个的二进制数据类型都是严格定义的。
class文件中用到的所有的数据类型的定义如下图:[其中"_info"结尾的是"表"类型的定义]
每个class文件的头4个字节称为魔数,唯一的作用就是用来确认该文件是否能被JVM识别,class文件的魔数为0xCAFEBABE,和文件后缀名基本一个意义,只不过优于后缀名,因为后缀名可以随便修改,这个却不容易修改,所以更能用于唯一标识一类文件。
紧挨魔数的2个字节是次版本号。
紧挨次版本号的2个字节是主版本号。
作用:当前文件中的版本号用来与执行该文件的JVM用的JDK版本匹配,如果不能兼容将拒绝执行。如下:
0X0034(对应十进制的50):JDK1.8
0X0033(对应十进制的50):JDK1.7
0X0032(对应十进制的50):JDK1.6
0X0031(对应十进制的49):JDK1.5
0X0030(对应十进制的48):JDK1.4
0X002F(对应十进制的47):JDK1.3
0X002E(对应十进制的46):JDK1.2
紧挨版本号,是一个表的类型,存放的都是class文件的重要资源数据,java文件被编译后将会把一些数据存于此结构。
常量池的入口开始有一个u2即2个字节来表示常量池的数据项的个数,索引值从1开始,比如如果是大小=21,即共有常量池数据项20个。
第0项索引的常量具有特殊意义,如果某些指向常量池索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可以将索引值置为0来表示。
字面量
:比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。
如:
int i = 1;把整数1赋值给int型变量i,整数1就是Java字面量,
同样String s = "abc"中的abc也是字面量。
符号引用
:符号引用属于编译原理方面的概念,包括了下面三类常量:
- 类和接口的全限定名
- 字段的名称和
描述符
- 方法的名称和
描述符
即在常量池中符号引用的表现形式就是以这3中方式出现的。
描述符又是什么:
1.成员变量(包括静态成员变量和实例变量) 和方法都有各自的描述符。
2.对于字段(就是成员变量的意思)而言,描述符用于描述字段的数据类型;
3.对于方法而言,描述符用于描述字段的数据类型、参数列表、返回值。
描述符的表示方法:即在class字节码文件中描述符的写法
1.基本数据类型用大写字母表示,如int,用I表示。
2.对象类型用“L对象类型的全限定名”表示,如String name,用Ljava/lang/String表示。
3.数组用“[数组类型的全限定名”表示。如String[] arr,用[java/lang/String表示。
4.描述方法时,将参数根据上述规则放在()中,()右侧按照上述方法放置返回值。而且参数之间无需任何符号。如public String name(int i),用(I)Ljava/lang/String表示。
初识常量池解析:
Java代码在运行Javac时,进行编译成class文件的动作,编译完成后常量池中的内容就已经生成了。而此时常量池中的字段、方法是没有在内存中的最终分布情况的,因为类还没有被加载到内存。所以此时内部叫做符号引用。即这些字段、方法不经过运行期的转换(常量池解析)是无法得到真正的内存入口地址,也就无法被JVM使用。
而当JVM装载该class文件后会需要进行一系列的连接/解析(见《类加载器装载过程》篇)以及后续的对象创建(装在进入方法区,创建对象会存至堆),此过程就会产生内存的真实分配,然后保存在方法区的class文件信息将会被重新翻译、解析成真正的内存地址,这就是把符号引用替换成了直接引用,也即常量池的解析过程。
初识直接引用:
常量池中可以直接分配内存的数据:有一些特殊数据,比如private、static、final修饰的变量或方法和构造方法,这些就是跟静态绑定相关的变量或方法,会在编译时就确定内存的分配,所以可以称为是字面量的范畴,而只有符号引用才需要去动态的经过常量池解析后指向真正的内存空间,即变为直接引用。
注意:符号引用转为直接引用,在类加载阶段只做了一部分的转换,而另一部分需要在运行时做转换,这部分是动态绑定的内容。后边章节讲解方法区时会详细讲该部分内容。
常量池中每一项常量都是一个表,即常量池是一个复合结构。共有14种不同的表结构类型,不过他们有一个共同点,都是由一个u1类型的标志位开始,可以通过这个标志位来判断这个常量属于哪种常量类型。
我们知道Class字节码文件中的数据结构是无符号数和表,而常量池又是一个表的数据结构,这就意味着它内部是由多个表或无符号数共同组成的结构,从上图我们知道他内部是多个表组成的复杂表结构,只不过该表结构内部又对不同的数据类型进行了区分,他将基本类型全部定义为字面量,而其他表对应的数据类型则叫做符号引用。我们可以通过java提供的命令自行查看字节码内容
命令javap可以查看class字节码内容:
javap -v -c -s -l Main.class
常量池后面紧跟着访问标志(access_flags),一个u2的2个字节类型。
主要标志当前类或接口的访问信息。一共有16个标志位可以使用,
当前只定义了其中8个(JDK1.5增加后面3种),没有使用到的标志位一律为0。
- 这三项数据主要用于确定这个类的继承关系。
- 类索引this_class:占用一个u2类型,2个字节。
- 父类索引super_class:占用2个字节,因为是单继承所以只会有一个索引值。
- 接口索引interfaces
- 因为接口是多实现,所以可能有多个接口
- 接口索引计数器(interfaces_count):占用2个字节,如果没有接口则此处是0,后边的接口索引集合也不占用位置。
- 接口索引集合(interfaces):一组u2类型数据的集合。用来描述这个类实现了哪些接口。
注意:它们中保存的索引值均指向常量池中一个CONSTANT_Class_info类型的常量。也即均在常量池中能找到对应的值,注意他们的关系(即都是指向常量池中的引用)。
字段计数器(fields_count):不包含局部变量,只有成员变量加入计算范围。
字段表集合(fields):一组字段表类型数据的集合。字段表用于描述接口或类中声明的变量,包括类级别(static)和实例级别变量,不包括在方法内部声明的局部变量。且不会显示父类继承到的字段。
1.标志位:字段作用域(public、protected、private修饰符)、是类级别变量还是实例级别变量(static修饰符)、可变性(final修饰符)、并发可见性(volatile修饰符)、可序列化与否(transient修饰符)、字段数据类型(基本类型、对象、数组)以及字段名称。这些都是boolean值,所以使用标志位比较合适。
2.字段数据类型:通过引用常量池的值表示。
3.字段名称:通过引用常量池的值表示。
注意:2和3的引用值都是指的指向常量池的Constant_fields_info类型的值,具体见常量池的类别图。
1.方发表计数器(methods_count):2个字节,方法的个数。
2.方法表集合(methods):结构同字段表集合一样。记录所有的本类中的方法,如果没有重写继承的父类的方法则不会显示在此。同样也会有一些编译器自动添加的方法,比如:类构造器和实例构造器方法。
3.方发表不同于字段表的就是方法内会有很多代码,那么编译后的代码跑哪去了呢?
因为此处保存的只有标志位、方法名索引(指向常量池)、方法描述索引(指向常量池)。不要急,因为编译后的代码都存到了下边要讲的"属性表集合"中的"code"数据项中。
属性表集合是一个可以作用在class文件中的字段表、方法表中并携带相关属性的一个集合,用来扩展描述额外的属性信息,比如方发表中会携带属性表集合,用来描述方法内部的代码被编译后的字节码指令,这些内容将会保存在对应的属性表集合的code属性中。
此处简单了解,后续会详细讲解。
方法调用的指令:
- 1.
invokevirtual
:该指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最常见的方法分派方式。这里说的其实就是调用虚方法,而虚方法就是体现了虚这个字,就是需要动态绑定的方法,即需要经过常量池解析后将符号引用转换成直接引用的方法,说白了就是可以被覆写的方法都可以称作虚方法,因此虚方法并不需要做特殊的声明,也可以理解为除了用private、static、final修饰之外的所有方法都是虚方法(构造方法也要除外)。- 2.
invokeinterface
:该指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。- 3.
invokespecial
:该指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(就是构造方法)、私有方法和父类方法。- 4.
invokestatic
:该指令用于调用类方法(static 方法)。- 5.
invokedynamic
:该指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前4条调用指令的分派逻辑都固化在java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
调用时机:这些指令在线程要调用方法时就会被运行,将方法压入java栈的栈帧,并进行运算,最后将结果弹出栈帧。(暂时了解即可,后续讲栈、线程部分会详解)
方法调用解析:常量池中我们说过,class被编译后常量池中有大量的符号引用,而方法调用并不是方法执行,而是有一个解析的过程,也即确定方法调用哪个版本,这一过程就会将一部分符号引用替换成直接引用,这个步骤将包括调用private、static、final和构造方法的方法直接确认,因为他们在执行引擎执行调用指令时,会先进行解析,此时就会确定调用版本,但是不包含虚方法哦。
方法调用分派:此过程就会涉及到调用虚方法,即虚方法的调用怎么确认版本和调用目标的,也即继承关系、多态的解释。这部分后续章节详解。
同步指令(synchronized语义):
- monitorenter:被同步语义作用的代码编译后会在代码前生成monitorenter指令,用来获取锁的语义。
- monitorexit :编译后monitorexit将出现在代码后,用来确保退出锁。
其他指令大家可到《深入理解Java虚拟机原理》书中的附录查看,此处不一一列举。
总结:综上所述,我们发现不能确定具体数值的结构(主要指表),比如字段集合、方法集合等,这些结构的内容保存的都是指向常量池中某一个数据类型的引用,而常量池中的对应的引用也是符号引用,而只有class字节码文件被JVM类加载器加载后才会进行对这些引用的翻译,即常量池解析,解析成直接引用,即真正分配的内存地址入口的引用。所以对于字节码文件来说常量池很重要。