深入理解Java虚拟机学习笔记——三、虚拟机类加载机制

时间:2021-08-04 21:01:18

1、概述

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成能够被虚拟机直接使用的数据类型,这就是虚拟机的类加载机制。在Java中,类的加载、校验、解析和初始化都是在运行期间完成的。

2、类加载的时机

类从被加载都虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载、准备、校验、解析、初始化、使用和卸载7个阶段。准备、校验、解析被统称为连接。
深入理解Java虚拟机学习笔记——三、虚拟机类加载机制
其中,加载、验证、准备、初始化和卸载这的顺序是确定的,类的加载过程必须按照这种顺序按部就班的开始。这里的开始并不是按部就班的进行或者完成,因为这些阶段都是相互交叉混合的进行的,通常会在一个阶段执行的过程中调用、激活其他阶段。
虚拟机规范严格的规定了有且只有5中情况必须立即对类进行初始化操作:
1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化操作。生成这4条字节码指令的场景:使用new关键字实例化对象时、获取或设置一个类的静态字段时(被final修饰,在编译期把结果放入常量池的静态字段除外)以及调用一个类的静态方法时。
2)调用java.lang.reflect包中的方法对类进行反射调用时,如果类还没有初始化,则先触发其初始化操作。
3)当初始化一个类时,如果发现其父类还没有初始化,则先触发其父类的初始化操作。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个主类。
5)当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个句柄所对应的类没有进行过初始化,则需要先触发其初始化。

3、类加载过程

(1)加载

“加载”是“类加载”(Class Loading)过程的一个阶段。在加载阶段,虚拟机需要完成以下3件事:
① 通过一个类的全限定名来获取定义此类的二进制字节流。
② 将这个字节流所代表的静态存储结构转换成方法区的运行时数据结构。
③ 在内存中生成一个代表该类的java.lang.Class对象,作为方法区该类的各种数据访问入口。
获取二进制字节流的方式主要有:
  • 从ZIP包中获取,例如:JAR、WAR、EAR等。
  • 从网络中获取,最典型的场景是Applet。
  • 运行时计算生成,使用最多的场景是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。
  • 由其他文件生成,典型场景是JSP应用,即由JSP文件生成对应的Class类。
  • 从数据库中读取,例如有些中间件(SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在数据库中的分发。
相对于类加载过程中的其他阶段而言,非数组类的加载阶段是开发人员可控性最强的,因为类加载阶段既可以使用系统提供的引导类加载器来完成,也可以使用用户自定义的类加载器完成。开发人员可以定义自己的类加载器来控制二进制字节流的读取方式,通过覆写loadClass()方法。
数组则不一样,数组的创建是由虚拟机来完成的。但是数组依然与类加载其有着紧密的关系,因为数组的元素类型(即去掉所有维度的类型)最终还是由类加载器创建的。一个数组类(以下简称为C)的创建遵循以下规则:
  • 如果数组的组件类型(Component Type,即数组去掉一个维度后的类型)是引用类型,则递归调用之前定义的类加载过程去加载这个组件类型,数组C将在加载该组件类型的类加载器的名称空间上被标识。
  • 如果数组的组件类型不是引用类型(如int[]数组),Java虚拟机将会把数组标记为引导类加载器关联。
  • 数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用类型,那么数组类的可见性默认为public。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中。然后在内存中实例化一个java.lang.Class对象(并没有明确规定放在Java堆中,在Hotsopt虚拟机中,Class对象比较特殊,虽然是对象,但是存放在方法区中),这个对象将作为方法区中访问这些类型数据的外部接口。
加载阶段与连接阶段的部分内容是交叉进行的,加载阶段还没完成,连接阶段可能已经开始了。但是夹杂在加载阶段的动作仍然属于连接阶段,这两个阶段的开始时间依然保持至固定的先后顺序。

(2)验证

验证是连接阶段的第一步,该阶段的目的是确保Class文件的字节流中所包含的信息符合当前虚拟机的要求,并且不会危害到虚拟机自身的安全。之前说过Class文件并不一定是由Java编译而来的,虚拟机如果不检查输入的字节流,很可能因为载入了有害的字节流而导致系统崩溃,所以,验证是虚拟机保护自身的一项重要工作。
验证阶段大致完成以下4个阶段的校验工作:
① 文件格式验证
第一阶段要验证字节流是否符合Class文件格式的规范,是否能被当前版本的虚拟机处理。这一阶段可能包括以下验证点:
  • 是否以魔数0xCAFEBABE开头。
  • 主、次版本号是否在当前版本虚拟机的处理范围之内。
  • 常量池中的常量是否有不被支持的常量类型(检查常量的tag标志)。
  • 指向常量的各种索引值中是否有只要不存在的常量或不符合类型的常量。
  • CONSTANT_Utf8_info型的常量中是否有不符合utf8编码的数据。
  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
以上的几种文件格式验证只是该验证阶段的一小部分而已,该阶段的主要目的是保证输入的字节流能够被正确地解析并存储于方法区,格式上符合描述一个Java类型信息的要求。这个阶段的验证是基于二进制字节流的,只有通过了该阶段的验证后字节流才会被存储在内存的方法区中。后面3个阶段的验证都是基于方法区的存储结构进行的,不会再操作字节流。
② 元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,该阶段可能包含的验证:
  • 这个类是否父类(除java.lang.Object外,所有的类都应该有父类)。
  • 这个类的父类是否继承了不允许被继承的类(被final所修饰的类)。
  • 如果这个类不是父类,是否实现了其父类或接口中要求实现的所有方法。
  • 类中的字段或方法是否与父类产生矛盾(覆盖了被final修饰的字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不相同等)。
③ 字节码验证
通过对数据流和控制流分析,确保程序语义是合法的,符合逻辑的。在元数据校验阶段对元数据中的信息校验完成后,这个阶段将对方法体进行校验,以保证类的方法在运行时不会发生危害到虚拟机事件,例如:
  • 保证任意时刻操作数栈的数据类型都能与指令代码序列配合工作,例如不会出现在操作数栈放了一个int类型的数据,使用时却按long类型来加载进本地变量表中。
  • 保证跳转指令不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换是有效的。
④ 符号引用验证
该阶段的验证发生在虚拟机将符号引用转换成直接引用的时候,这个转换的动作将在连接的第三阶段——解析阶段发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验一下内容:
  • 符号引用中通过字符串描述的全限定名是否能够找到对应的类。
  • 在指定的类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
  • 符号引用中的类、方法、字段的访问性(private、protected、default、public)是否可被当前类访问。
符号引用验证的目的是为了确保解析动作的正常执行

(3)准备

准备阶段是正式为类变量分配内存并设置初始值的阶段,这些变量所使用的内存都将在方法区中分配。在这一阶段进行内存分配的仅仅是指类变量,即被static修饰的变量。而实例变量将会在对象实例化时随着对象一起在Java堆中分配内存。“初始值”通常情况下是数据类型的零值,而真正的赋值是发生在初始化阶段的。下图罗列了Java中所有基本数据类型的零值:
深入理解Java虚拟机学习笔记——三、虚拟机类加载机制
除了“通常情况”以外的“特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那么在准备阶段,类变量就会被初始化为ConstantValue属性所指定的值,如public static finalint value = 123;

(4)解析

解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。在解析阶段中,符号引用与直接引用的关联:
  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用目标,符号可以是任意形式的字面常量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机的内存布局无关,引用的目标并不一定已经加载到内存中。
  • 直接引用(Direct References):直接引用可以是直接指向目标的指针、相对偏移量或是一个能够间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实现上翻译出来的直接引用一般不会相同。
虚拟机规范中并未规定解析阶段发生的具体时间,只要求在执行了anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokestatic、invokeinterface、invokespecial、invokevirtual、ldc、ldc_w、multianewarray、putfield和putstatic这16个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机可以根据实际需要来判断是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用在将要被使用前解析。
对同一个符号引用进行多次解析请求是很常见的,出来invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并将常量标识为已解析状态),从而避免重复解析。无论是否进行了多次解析,虚拟机需要保证在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的解析请求就应当一直成功;反之,如果第一次解析失败了,那么其他指令对这个符号引用的解析请求也应当收到相同的异常。
对于invokedynamic指令,当碰到前面某个已经由invoke触发过解析的符号引用时,并不意味着这个解析结果对其他的invokedynamic指令同样生效。因为invokedynamic指令的目的本来就是用于动态语言支持的,它所对应的引用被称为“动态调用点限定符”。
解析动作主要用于类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7中符号引用,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info等7中常量类型。

(5)初始化

初始化阶段是类加载过程的最后一步,在这一阶段才真正开始执行类中定义的Java代码。在准备阶段,类变量已经赋值过一次系统要求的初始值,而在初始化阶段则是根据程序员通过程序制定的主观计划去初始化类变量及其它资源。

4、类加载器

实现“通过一个类的全限定名来获取此类的二进制字节流”的动作的代码模块被称为类加载器。

(1)类与类加载器

对于任意一个类,都需要通过加载该类的类加载器与该类本身一同来确定其在Java虚拟机中的唯一性,每一个类加载器都拥有一个独立的类名称空间。也就是说,比较两个类是否“相等”,只有在这两个类都是由同一个类加载器加载的前提下才有意义。这里所指的“相等”,包括Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结构,也包括使用instanceof关键字做对象所有关系判定等情况。

(2)双亲委派模型

从Java虚拟机角度来看,只存在两种类加载器:① 启动类加载器(Bootstrap ClassLoader),这个类加载器由C++实现,是虚拟机的一部分。② 所有其它的类加载器,这些类加载器由Java语言实现,独立于虚拟机外部,并且全部都是ClassLoader的子类。
从Java开发人员角度来看,类加载器可以分为:
① 启动类加载器(Bootstrap ClassLoader):这个类加载器负载将<JAVA_HOME>\lib目录中或被-Xbootclasspath参数所指定的路径中的,并且是被虚拟机识别的(仅按照文件名识别,如:rt.jar)类库加载到虚拟机内存中。启动类加载器无法被Java程序员直接引用,如果需要把加载请求委派给引导类加载器,那么直接使用null代替即可。
② 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库。
③ 应用程序类加载器(Application ClassLoader):这个加载器由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也被称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过类加载器,那么这个就是程序中默认的类加载器。
类加载器之间的层次结构:
深入理解Java虚拟机学习笔记——三、虚拟机类加载机制
上图所展示的层次结构被称为双亲委派模型。双亲委派模型要求,除了顶层的启动类加载器外,所有的类加载器都应当有自己的父类加载。这里类加载器之间的父子关系是通过组合关系实现的。
双亲委派模型的工作过程:如果一个类加载器收到了类加载请求,它首先并不会自己去加载这个类,而是把这个请求委派给父加载器去完成,每一个层次的类加载器都是如此,因此所有的类加载请求最后都会传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时(它的搜索范围中没有找到所需的类),子加载器才会尝试自己去加载。