类文件的结构、JVM 的类加载过程、类加载机制、类加载器、双亲委
一、类文件的结构
我们都知道,各种不同平台的虚拟机,都支持 “字节码 Byte Code” 这种程序存储格式,这构成了 Java 平台无关性的基石。甚至现在平台无关性也开始演变出 “语言无关性” ,就是其他语言也可以运行在 Java 虚拟机之上,比如现在的 Kotlin、Scala 等。
实现语言无关性的基础仍然是虚拟机和字节码存储格式,Java 虚拟机<typo id="typo-181" data-origin="步" ignoretag="true">步</typo>包括 Java 语言在内的任何语言绑定,他只和 “Class 文件” 这种特定的二进制文件格式所关联,Class 文件中包含了 Java 虚拟机指令集、符号表以及其他若干辅助信息。
Java 的各种语法、关键字、常量变量和运算符号的语义最终都会由多条字节码指令组合来表达,这决定了字节码指令所能提供的语言描述能力必须比 Java 语言本身更强大才行。
jvm 提供的语言无关性如下图所示:
Java 技术能够一直保持着非常良好的向后兼容性,Class文件结构的稳定功不可没。JDK1.2 时代的 Java 虚拟机中就定义好的 Class 文件格式的各项细节,到今天几乎没有出现任何改变。Class文件格式进行了几次更新,但基本上只是在原有结构基础上新增内容、扩充功能。
类文件格式
Class 文件格式采用一种类似 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”和“表”。后面的解析都要以这两种数据类型为基础。
- 无符号数属于基本数据类型,以 u1、u2、u4、u8分别代表1、2、4、8个字节的无符号数,无符号数可以描述数字、索引引用、数量值或者utf-8 编码构成字符串值。
- 表是多个无符号数或者其他表组成的复合类型,为了便于区分,所有表的命名都是习惯性地以 “_info” 结尾。整个 Class 文件本质上也可以视为一张表。
Class 文件格式的数据项如下所示:
这里面可以看到,一个 Class 文件的有些数据项是固定的 数量 × 长度,有些则不是。如果一个类型的数据数量不定,会采用多一个数据项来实现,一个前置的数据项作为容量计数器,后面连续的数据项,而数量就是前面的容量计数器的值,这时候这一系列连续的某一类型的数据称为某一类型的 “集合”。
比如上面的这个,从字面意思也看得出来,因为常量池本身就是很多常量复合组成的,数量就会先用一个 u2 类型的数据项来表示,也就是我们刚说过的容量计数器,然后接着这个常量池集合本身就有了数量。
这么严格要求的原因是,Class 文件没有任何分隔符,所以整个 Class 文件的格式,顺序、数量这样的细节,都是严格限定的,全都不允许改变。
接下来,我们来看各个数据项的含义。总共分为 7 项,按照上面的那张图的颜色框划分也很容易看出来,并且表示的信息也是见名知意的。
1.1 魔数与 Class 文件的版本
通常常量池是占用 Class 文件空间最大的数据项之一。
Class 文件的魔数是 0xCAFEBABE,咖啡宝贝。是因为 java 开发小组最初的关键成员觉得他象征著名咖啡品牌最受欢迎的咖啡,似乎对 java 的商标也有预示
- 魔数后面的 4 个字节是 Class 文件的版本号:5、6 字节是次版本号,7、8字节是主版本号。
1.2 常量池
通常常量池是占用 Class 文件空间最大的数据项之一。
分为两个部分,一个2字节的数据代表常量池容量计数值;下面是常量池的内容,可以看到这里使用容量的大小用的是 constan_pool_count-1 ,因为常量池的容量计数是 1 开始,而不是 0,比如这个 constan_pool_count 值翻译成十进制是 22,那么代表常量池有 21 项常量。
除了常量池,剩下的数据项表示都是从 0 开始计数的。
常量池中存放两大类常量:字面量和符号引用,具体含义和分类很复杂,这里不介绍了。
1.3 访问标志
2 个字节,用于识别一些类或者接口层次的访问信息,包括 “这个 Class 是类还是接口” ,“是否定义为 public 类型”;“是否定义为 abstract 类型”,“如果是的话,是否被声明为final”。
2 个字节总共有 16 个标志位,目前只定义了 9 个,没有使用的标志位一律置为 0。
1.4 类索引、父类索引和接口索引集合
Class 文件中由这三项数据来确定该类的继承关系,显然因为 java 是单继承,却可以实现多个接口,所以有了 super_class 是一个 u2 的数据,而 interfaces 则需要一个 interfaces_count 。
类索引+父类索引这两项的值,就指向的是一个 类描述符常量,通过这个索引值就能找到对应的类。
1.5 字段表集合
1.7 属性表集合
- 类级变量;
- 实例级变量。
但是不包括在方法内部声明的局部变量。
因为 field_info 本身也是一个表,具体的这里就不说明。
1.6 方法表集合
和字段表集合类似。
但是放发表的结构有一个特点,就是里面并没有方法体里的代码,方法体的代码在下一个属性表里。
1.7 属性表集合
Class 文件,字段表,方法表,三个集合内部都可以嵌套携带属性表集合。
具体属性表的格式之类的,也是很复杂,这里不赘述。
1.8 字节码指令
Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字以及跟随其后的零至多个代表此操作所需的参数构成。
由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。
在Java虚拟机的指令集中,大多数指令都包含其操作所对应的数据类型信息。
举个例子,iload指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在 Class文件中它们必须拥有各自独立的操作码。
对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i <typo id="typo-2577" data-origin="代表对" ignoretag="true">代表对</typo> int 类型的数据操作, l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference。
字节码指令可以分为:
- 加载和存储指令:讲数据在栈帧中的局部变量表和操作数栈之间来回传输;
- 运算指令:对两个操作数栈上的值进行运算,并把结果重新存入操作数栈顶;
- 类型转换指令:将不同数值类型相互转换;
- 对象创建与访问指令;
- 操作数栈管理指令:直接操作操作数栈的指令,出栈入栈等;
- 控制转移指令:让jvm从指定位置的下一条指令继续执行程序,可以认为是在修改PC寄存器的值;
- 方法调用和返回指令;
- 异常处理指令;
- 同步指令:Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程( Monitor,更常见的是直接将它称为“锁”) 来实现的。方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否被声明为同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成 (无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。同步一段指令集序列通常是由 Java 语言中的 synchronized 语句块来表示的,Java虚拟机的指令集中有 monitor enter 和 monitor exit 两条指令来支持 synchronized 关键字的语义。正确实现 synchronized 关键字需要 Javac 编译器与 Java 虚拟机两者共同协作支持。
二、类加载机制
定义:
Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制。
从定义里就可以看出来,java 和哪些编译时要进行连接的语言不同,java 的类型的加载、连接、初始化都是程序运行期间完成的,这给 java 应用提供了极高的扩展性,java 的可动态扩展的语言特性就是依赖于运行期动态加载和动态连接这个特点实现的。
例如,编写一个面向接口的程序,可以等到运行时再指定其实际的实现类,用户可以通过 java 预置或自定义类加载器,让某个本地应用程序在运行时从网络或者其他地方加载一个二进制流作为其程序代码的一部分。
(后面说的类加载的“类”,实际上可能是接口或者类)
2.1 一个类的生命周期
如上图所示,一个类从被加载到虚拟机的内存中开始,到卸载出内存为止,生命周期分为 7 个阶段:
- 加载;
- 连接:验证;准备;解析;
- 初始化;
- 使用;
- 卸载。
其中验证、准备、解析三个阶段可以合起来称为连接。
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。
请注意“开始”,而不是按部就班地“进行“,或按部就班地“完成”,强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
2.2 什么时候类会被加载
关于什么时候需要开始类加载过程的第一个阶段“加载”,虚拟机规范没有强制约束,可以交给虚拟机的具体实现。但是初始化阶段,严格规定了有且只有 6 种情况必须立即对类进行初始化(这就意味着,加载验证准备都必须在此之前开始):
- 遇到 new、 getstatic、 putstatic 或 invokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型 Java 代码场景有:
- 使用new关键字实例化对象的时候。
- 读取或设置一个类型的静态字段(被 final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
- 调用一个类型的静态方法的时候。
- 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类 (包含 main方法的那个类),虚拟机会先初始化这个主类。
- 使用 JDK7 新加入的动态语言支持时,如果一个 java.lang.invoke.Methodhandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newinvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
- 当一个接口中定义了 JDK8 新加入的默认方法 (被 default 关键字修饰的接口方法) 时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
上面的六种场景中的行为,叫做对一个类型进行主动引用。除了这六种外的引用类型的方式都不会触发初始化,被称为被动引用。
2.3 类加载的过程
接下来看详细过程。
2.3.1 加载
注意啊,“加载”只是整个“类加载”中的一个阶段。
加载阶段,虚拟机主要做三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流;
- 将这个字节流代表的静态存储结构转化为方法区的运行时数据结构;
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
其中,第一点的来源可以是各种各样,zip包,网络中,也可以利用动态代理技术在运行时计算生成。
第二点是要用到类加载器的,相对于五个阶段的其他阶段:
- 非数组类型的加载阶段(就是当前阶段)是可控性最强的,可以通过 jvm 内置的引导类加载器,也可以自定义类加载器,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的 findClass() 或 loadClass() 方法)。
- 数组类型来说,数组类不通过类加载器创建,而是由 jvm 直接在内存中动态构造出来,但是数组的元素类本身最终还是要靠类加载器来完成加载,这个过程就是 jvm 把数组降一个维度,然后决定继续递归还是直接可以和类加载器关联。
第三点就是如上面所说。
2.3.2 验证(连接之 1 )
验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
为什么要验证?
结合上一个步骤,就是因为 Class 文件不一定就是 java 源码编译来的,可能是各种途径,甚至是自己手敲的 01 码,所以有必要验证字节码。
一般验证的内容分为四个:
- 文件格式验证:检查字节流是否符合 Class 文件格式的规范,就是魔数啊、版本号之类的;
- 元数据验证:上一步格式没问题,然后对字节码描述的信息进行语义分析,看类关系、字段方法有没有矛盾之类;
- 字节码验证:最复杂的一步,通过数据流分析和控制流分析,确定程序语义合法、合逻辑,上一步数据类型等到没问题,这一步就要进入方法体的逻辑分析;
- 符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候,转化动作本身实在连接之3阶段——解析阶段发生的。(所以前面说这些顺序只是开始顺序,执行的时候是互相切换的),目的是验证引用的类、字段等内容是否能找到并访问之类的。
验证阶段很重要,却不一定必须执行,因为通过了验证阶段,后面对程序执行就没有影响了,如果程序反复被验证和使用过就可以用参数关闭大部分的类验证措施:
-Xverify: none
2.3.3 准备(连接之 2 )
准备阶段是正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存、并设置类变量初始值的阶段。
从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但方法区本身是一个逻辑上的区域。
在上一篇,jvm 的内存结构里多次强调。在 JDK7及之前, HotSpot 使用永久代来实现方法区,所以还可以勉强把方法区这个概念保留;而在 JDK8 及之后,永久代也没有了,所以类变量随着 Class 对象一起存放在 Java 堆中,这时候 “类变量在方法区” 就有点牵强。
注意:
- 这个阶段进行内存分配的仅包括类变量,不包括实例变量。现在讲的整个过程都只是类加载的过程,实例变量会在对象实例化的时候随对象一起分配在 java 堆中。
- 设置初始值通常指的是数据类型的 0 值。
比如:
public static int value = 123;
经过这里的准备阶段,初始值 value 是 0,因为这个时候任何 java 方法都没有执行,初始化的指令是 putstatic ,这个指令是在类构造器的 <clinit>() 方法里的。
所以 value 变成 123 是在类的初始化阶段才会执行的,就是 2.3.5 。
2.3.4 解析(连接之 3 )
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。
前面的 Class 文件格式部分提过一次,那解析阶段中所说的直接引用与符号引用又有什么关联呢?
- 符号引用(Symbolic References):一组符号来描述引用目标,任何字面量,只要能定位就行。
- 直接引用(Direct References):直接指向目标的指针、相对偏移量或者一个间接定位到目标的句柄。
解析动作主要针对 7 类符号引用进行转换:
- 类或接口;
- 字段;
- 类方法;
- 接口方法;
- 方法类型;
- 方法句柄;
- 调用点限定符。
2.3.5 初始化
类的初始化阶段是类加载过程的最后一个步骤。
之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。
2.3.3 的准备阶段,已经给变量赋过值了,是初始 0 值,而初始化阶段,会根据代码初始化类变量和其他资源,另一种更直接的形式来表达这个过程:
初始化阶段就是执行类构造器的 <clinit>() 方法的过程,这个方法不是程序员自己写的,是 javac 编译器的自动生成物。
2.4 类加载器
Java虚拟机设计团队有意把类加载阶段中的:
“通过一个类的全限定名来获取描述该类的二进制字节流”
这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。(就是上面讲的类加载过程的第一个步骤)
实现这个动作的代码被称为 “类加载器” ( Class loader)。
2.4.1 类与类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。
对于任意一个类,都必须由加载它的类加载器、和这个类本身一起共同确立其在Java虚拟机中的唯一性,每个类加载器,都拥有一个独立的类名称空间。
这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里的相等,包括比较对象的 equals() 方法,isInstance() 方法、isAssignableFrom() 等的返回结果,以及 instanceof 关键字的判定结果。
2.4.2 双亲委派模型
站在Java虚拟机的角度来看,只存在两种不同的类加载器:
- 一种是启动类加载器( BootstrapClassloader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;
- 另外一种就是其他所有类加载器,这些类加载器都由 Java 语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader 。
站在Java开发人员的角度来看,类加载器就应当划分得更细致一些。自 JDK12 以来,Java 一直保着三层类加载器、双亲委派的类加载架构。
2.4.2.1 三层类加载器
注意:下面提及的源码目录在JDK9之后,因为模块化的改变,所以按照这些目录大概率自己的 jdk 文件里找不到的。
- 启动类加载器 ( Bootstrap Class Loader)
前面已经介绍过,这个类加载器负责加载存放在 <JAVA_HOME>\lib 目录,或者被 -Xbootclasspath 参数所指定的路径中存放的,而且是 Java 虚拟机能够识别的(按照文件名) 类库加载到虚拟机的内存中。
启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用 null 代替即可。
- 扩展类加载器(Extension Class Loader)
这个类加载器是在类 sun.misc.Launcher$ExtClassLoader 中以 Java 代码的形式实现的。
它负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库。
根据 “扩展类加载器” 这个名称,就可以推断出这是一种 Java 系统类库的扩展机制,JDK 的开发团队允许用户将具有通用性的类库放置在 ext 目录里以扩展 JavaSe 的功能,在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由 Java 代码实现的,开发者可以直接在程序中使用扩展类加载器来加载 Class 文件。
- 应用程序类加载器(Application Class Loader)
这个类加载器由 sun.misc.LaunchersappClassloader 来实现。由于这个类加载器是 Classloader 类中的 getSystemClassloader() 方法的返回值,所以有些场合中也称它为“系统类加载器”。
它负责加载用户类路径 (ClassPath) 上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
除了这三种外,如果用户有必要,还可以自定义来进行扩展:
image2.4.2.2 双亲委派模型
上面的图画出来的关系,就被称为类加载器的 “双亲委派模型( Parents DelegationModel)”。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以类继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。
双亲委派模型的工作过程:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。
因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求 (它的搜索范围中没有找到所需的类) 时,子加载器才会尝试自己去完成加载。
使用双亲委派模型来组织加载器之间的关系,一个显而易见的好处就是:java 类随着类加载器就具有了一种层级关系,比如 Object 类,不论哪个类加载器加载他,都会委派给模型最顶端的启动类加载器纪念性加载,因此 Object 类在各种类加载器环境里都能保证是同一个类,这样 java 整个体系的最基础行为就得到了保证。
双亲委派模型的实现,可以在 java.lang.ClassLoader 的 loadClass() 方法里看到: