JVM扫盲-2:虚拟机执行子系统

1,178 阅读11分钟

1、类文件结构

Java虚拟机只与Class文件相关联,它规定了Class文件应该具有的格式,而不论该文件是由什么语言编写并编译而来。所以,任何语言只要能够最终编译成符合Java虚拟机要求的Class文件,就可以运行在Java虚拟机上面。就是说,不论是使用Java, Scala, Kotlin, Groovy还是其他语言,只要编译出的Class文件符合虚拟机规范,那么都可以被虚拟机执行。所以,实际上Java规范就是由Java语言规范和Java虚拟机规范两个独立的部分组成。

Class类文件是一种二进制文件,它包含了Java虚拟机指令集和符号表以及若干其他辅助信息。Class文件格式采用类似于C的伪结构来存储数据,这种结构只有两种数据类型:无符号数和表。无符号数属于基本数据类型,以u1,u2,u4,u8分别代表1字节、2字节、4字节和8字节的无符号数,无符号数可以用来描述数字、索引、数量值或者按照utf-8编码构成的字符串。

表是由多个无符号数或者其他作为表作为数据项构成的复合数据结构,所有表习惯性地以"_info"结尾,整个Class文件本质上是一张表。而所谓的表就对应于C++中的一个结构体,比如整个Class文件对应的结构体就是:

struct ClassFile {
    u4 magic;                // 识别Class文件格式,具体值为0xCAFEBABE,
    u2 minor_version;        // Class文件格式副版本号,
    u2 major_version;        // Class文件格式主版本号,
    u2 constant_pool_count;  // 常量表项个数,
    cp_info **constant_pool; // 常量表,又称变长符号表,
    u2 access_flags;         // Class的声明中使用的修饰符掩码,
    u2 this_class;           // 常数表索引,索引内保存类名或接口名,
    u2 super_class;          // 常数表索引,索引内保存父类名,
    u2 interfaces_count;     // 超接口个数,
    u2 *interfaces;          // 常数表索引,各超接口名称,
    u2 fields_count;         // 类的域个数,
    field_info **fields;     // 域数据,包括属性名称索引,
    u2 methods_count;        // 方法个数,
    method_info **methods;   // 方法数据,包括方法名称索引,方法修饰符掩码等,
    u2 attributes_count;         // 类附加属性个数,
    attribute_info **attributes; // 类附加属性数据,包括源文件名等。
};

上面结构体的各个变量的定义的顺序与Class文件中的存储顺序是一致的。下面我们用一个二进制编辑器打开Class文件并简单看下,Class文件中的存储如何与上面的结构体对应的。

Class二进制文件

根据结构体的定义,首先是magic字段,它是u4类型的,即4字节,对应于上图中的CAFEBABE;紧接着两个u2类型的字段,minor_versionmajor_version,用来表示Class文件的版本号,对应于上图中的00000031;然后是一个u2类型的constant_pool_count,用来表示常数表项个数,这里是0027,也就是有38个常量,因此常量从1开始计数的;接着常量个数的是变长符号表,这个符号表的长度就是之前常量的长度,即38,然后我们要按照常量的规则找到38个常量之后就是u2类型的访问标志。

那这38个常量又如何寻找呢?实际上,Class文件中总计规定了14种常量结构体,每种结构体都包含一个tag字段,它是u1类型的,即1个字节,并且存储在该结构体的首位。我们需要先根据该tag字段找到该常量的定义,然后才能确定接下来的几个字节是属于该常量的。比如上图中的第一个常量的tag0A,我们可以查询相关的表知道它是CONSTANT_Fleddef_info类型的常量,该常量有3个字段:u1类型的tag, u2类型的index, u2类型的index,故我们可以确定常量长度之后的5个字节是属于第一个常量的。依次,分析下去我们可以得到第二个,第三个等常量的信息。得到了常量的信息之后,就可以获取上面结构体中其他的字段的信息。

其实,按照上面的分析,我们可以看出分析Class文件时一个非常机械的过程,因为它有固定的规则在里面,所以我们可以使用命令行工具javap来获取以上的信息。在实际开发过程中从字节码的角度分析问题的情形可能并不多,但是了解字节码中的一些指令,尤其是并发相关的指令,对学习和分析问题都大有裨益。

2、类加载机制

2.1 类加载过程

Java程序中的类加载是在运行期间完成的,我们可以使用Java预定义的加载器或者自定义加载器动态从各种渠道加载类并使用。最初将类加载器从虚拟机中分离出来是为了Applet等的动态加载,但是后来随着技术发展,动态加载被应用到各种场景中,比如移动端的插件化、热补丁、Tomcat的类加载等各种场景中,所以类加载是非常重要的一块内容。

一个类从被加载到虚拟机内存到卸载的整个生命周期包括:加载-验证-准备-解析-初始化-使用-卸载7个阶段。其中验证-准备-解析3个阶段称为连接。

加载发生在类被使用的时候,如果一个类之前没有被加载,那么就会执行加载逻辑,比如当使用new创建类、调用静态类对象和使用反射的时候等。加载过程主要工作包括:1).从磁盘或者网络中获取类的二进制字节流;2).将该字节流的静态存储结构转换为方法取的运行时数据结构;3).在内存中生成表示这个类的Class对象,作为方法区访问该类的各种数据结构的入口。

验证阶段会对加载的字节流中的信息进行各种校验以确保它符合JVM的要求。

准备阶段会正式为类变量分配内存并设置类变量的初始值。注意这里分配内存的只包括类变量,也就是静态的变量(实例变量会在对象实例化的时候分配在堆上),并且这里的设置初始值是指‘零值’,比如int类型的会被初始化为0,引用类型的会被初始化为null,即使你在代码中为其赋了值。

解析阶段是将常量池中的符号引用替换为直接引用的过程。符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,只要能正确定位到它们在内存中的位置就行。直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。

初始化是执行类构造器<client>方法的过程。<client>方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证<client>方法执行之前,父类的<client>方法已经执行完毕。

2.2 类加载机制 双亲委派模型

类加载器用来根据类的全限定名获取描述此类的二进制字节流。我们可以把类加载器分成以下4种:

  1. 启动类加载器:存在于虚拟机中,使用C++编写,负责将<Java_Home>/lib下面的类库加载到内存中(比如rt.jar);
  2. 拓展类加载器:它负责将<Java_Home>/lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中;
  3. 应用程序类加载器:它负责将用户类路径中指定的类库加载到内存中;
  4. 自定义类加载器:就是指用户自己定义的类加载器。

在Java种存在以上多种类加载器,它们之间通过一定的规则相互配合,这个规则就是双亲委派模型:每个类加载器收到类加载请求时,都会先将请求委派给父类加载器去完成,所以,加载请求会一直传递到最顶层的类加载器。只有当类父类加载器无法完成加载请求的时候,该加载器才会自己去加载。当然,这不是强制的,我们也可以完全使用自己的一套逻辑。但双清委派模型的好处就在于,假如你自定义了一个类java.lang.Object,那么当使用双亲委派模型来加载的时候,会由子加载器不断向上传递加载请求到启动类加载器进行加载,因此Object在各种类加载环境中都是同一个类。这保障了Java系统的稳定性。

3、虚拟机字节码执行引擎

虚拟机是相对于物理机而言的,它们的区别是物理机的执行引擎直接建立在处理器、硬件、指令集和操作系统层面上,而虚拟机的执行引擎是自己实现的。所以,执行引擎也是Java虚拟机最核心的组成部分之一。

执行引擎用来执行我们写的业务逻辑,而业务逻辑就是指一些方法,所以虚拟机执行引擎就是用来执行各个方法的。而方法是通过栈帧来描述的,方法的执行是用栈帧入栈和出栈来描述的,栈帧中存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。所以说,执行引擎就是用来执行各个栈帧的。在虚拟机执行的时候,只有最顶层的栈帧是有效的,与之关联的方法就称为当前方法,并且执行引擎运行的所有字节码都是针对当前栈帧的。

方法的信息存储在栈帧中,而栈帧中的方法信息是从Class文件中读取来的。回到之前的Class类文件结构部分,每个方法是通过结构体method_info来描述的:

struct method_info
{
    u2 access_flags;         //方法修饰符掩码
    u2 name_index;           //方法名在常数表内的索引
    u2 descriptor_index;     //方法描述符,其值是常数表内的索引
    u2 attributes_count;     //方法的属性个数
    attribute_info **attributes;    //方法的属性数据,
};

method_info中又包含了一个属性表集合attribute_info类型的attributes,方法的局部变量表需要的空间大小和操作栈的深度等就记录在其中。局部变量表用于存放方法参数和方法内的局部变量,当方法是实例方法的时候,局部变量表的第0位会被用来传递方法所属对象的引用,即this。Java虚拟机执行引擎是基于栈的执行引擎,这里的栈就是操作数栈。操作数栈的深度也是记录在方法属性集合的Code属性中的。

执行引擎的执行过程

我们可以用下面的一个程序来说明以下在实际的执行过程中,Java执行引擎是如何工作的。

public int cal() {
    int a = 100;
    int b = 200;
    int c = 300;
    return (a + b) * c; // 1
}

首先会在编译期确定方法的栈深度和局部变量表的长度,栈深度是由计算的过程得到的,而局部变量表的长度等于1个this加3个局部变量即4。当程序执行到1处时,局部变量表内会被填充为this100200300。程序计数器会随着代码当前执行到的位置而不断更新。而此时因为没有进行任何计算,所以栈还是空的。

接下来就开始进行计算:首先会把a压入栈中(其实时把局部变量表里的值压入栈中);然后把b压入栈中;接着将栈顶的两个元素先出栈,相加之后再入栈,此时栈中只有一个计算结果300;接下来再把c压入栈中;然后,把栈顶的两个元素出栈,执行完乘法之后再入栈,所以最终栈中只有一个90000;最后,使用ireturn指令结束方法并将栈顶的元素返回给方法的调用者。

总结

以上就是虚拟机执行子系统的一个过程,包含了从编译出的Class文件,到被加载到内存中、验证、初始化等,到最终在虚拟机中被执行等完整的过程。这里只是总结和梳理了相关的基础的知识点,在虚拟机中实际的执行过程肯定远比我们上述的内容更加复杂和精彩。