CH07-类加载
7.1 概述
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
与那些在编译期执行连接过程的语言不同,Java 中类型的加载、连接、初始化过程均在运行时进行。这种类加载机制会为运行时增加一些性能开销,但也给 Java 提供了高度的灵活性:基于这种运行时动态加载和连接的能力,Java 中天生就可以动态扩展语言的特性。
7.2 类加载的时机
类的完整生命周期:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析这三个部分被合称为连接。
- 大致固定的“开始顺序”:加载、验证、准备、初始化、卸载。
- 各个阶段开始后会以交叉混合方式进行,即在一个阶段的执行过程中调用、激活另一个阶段。
- 解析阶段的开始时机不一定,它在某些情况下可以在初始化节点之后进行,这是为了支持 Java 的运行时绑定(或称动态绑定、晚期绑定)。
JVM 规范并没有定义开始加载一个类的时机,因此取决于具体的 JVM 实现。但对于初始化阶段,JVM 规范严格规定了 有且仅有 5 种情况 必须立即对类执行“初始化”(因此初始化的前置阶段必须在之前开始):
- 遇到 new、getstatic、putstatic、invokestatic 这四种字节码指令时,如果没有对类进行初始化,则需要先触发对应类的初始化。
- 使用
java.lang.reflect
中的方法对类进行反射调用。 - 当初始化一个类的时候,发现其父类尚未被初始化。
- 当虚拟机启动时,用户需要制定一个将要执行的主类(包含 main 方法的类)。
- (JDK 7)如果一个
MethodHandle
实例最后的解析结果是REF_getStatic
、REF_putStatic
、REF_invikeStatic
的方法句柄对应类类没有被初始化。
这 5 种场景中的行为成为对一个类进行主动引用。除此之外,所有引用类的行为都不会触发初始化,称为被动引用。
被动引用场景
通过子类引用父类的静态字段
不会导致子类初始化。会引起父类的初始化和子类的加载。
通过数组定义来引用类
不会触发此类的初始化。
public class NotInitialization{
public static void main(String[]args){
SuperClass[]sca=new SuperClass[10];
}
}
这段代码不会触发 SuperClass 类的初始化,但会触发一个名为 [Lorg.fenixsoft.classloading.SuperClass
类的初始化,这是一个由 JVM 自动生成的、直接继承于 java.lang.Object
的子类,创建动作由字节码指令 newarray 触发。
在 JVM 内部,自动生成了一个类来封装数组数据(因此数组操作在 Java 中比在 C/C++ 中安全,后者是直接移动指针),该类值暴露了共有的 length 属性和 clone 方法。
应用类的常量字段
public class ConstClass{
static{
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD="hello world";
}
public class NotInitialization{
public static void main(String[]args){
System.out.println(ConstClass.HELLOWORLD);
}
}
该示例中并不会引起类 ConstClass 的加载。因为在编译阶段通过常量传播优化,已经将此常量的值存储到了类 NotInitialization 常量池中,以后 NotInitialization 对该常量的引用实质上是对自身常量池的引用。即,NotInitialization 的 Class 文件中并没有对类 ConstClass 的符号引用入口,这两个类在编译成 Class 之后便不存在任何关联了。
接口初始化
接口也有初始化过程。在类中一般使用静态块来显示初始化信息,而接口中不能使用静态块,但编译器仍然会为接口生成 <clinit>()
类构造器,用于初始化接口中所定义的成员变量。
接口与类真正有所区别的地方在于上面所述 5 种主动引用情况的第 3 种:当一个类在初始化时,要求其父类全部都已经初始化过了;但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口时(如引用父接口中的常量)才会将其初始化。
7.3 类的加载过程
7.3.1 加载
加载阶段,虚拟机要完成的 3 件事情:
- 通过一个类的完全限定名来获取该类的二进制字节流。
- 将二进制字节流表示的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
java.lang.Class
对象,作为在方法区中该类的各种信息的访问入口。
在 JVM 规范中并没有具体规定一定要从哪获取、怎样获取二进制字节流,因此具有很大的灵活性。基于这一点有很多有意义的实现:
- 从 ZIP 包中获取,这最终成为了日后 JAR/EAR/WAR 格式的基础。
- 从网络中获取,典型应用是 Applet。
- 运行时计算生成,应用最多的就是动态代理技术。
- 由其他文件生成,典型应用是 JSP。
- 从数据库中获取。
- 等等。
相对于类加载过程的其他阶段,一个非数组类在加载阶段的获取二进制字节流操作是可控性最强的,因为既可以使用系统提供的“引导类加载器”来加载,也可以由用户自定义的类加载器来加载,开发人员可以通过自定义的类加载器来控制字节流的获取方式。
数组类本省不能通过类加载器创建,它是由 JVM 直接创建的。但数组类仍与类加载器有着密切的联系,因为数组类的元素类型最终需要靠类加载器来创建。一个数组类(简称 C)的创建过程遵循以下规则:
- 如果数组的元素类型是引用类型,就递归采用本节中定义的加载过程来加载该元素类型,数组 C 将在加载元素类型的类加载器的名称空间上被标识(一个类必须与其加载器来确定唯一性)。
- 如果数组的元素类型不是引用类型,JVM 将会把数组 C 标记位与引导类加载关联。
- 数组类的可见性与元素类型的可见性一致,如果元素类型不是引用类型,则数组类的可见性为 public。
加载阶段与连接阶段是交叉进行的(如一部分字节码文件格式校验动作),加载阶段尚未完成,连接阶段可能已经开始,但这些夹杂在加载阶段的动作仍然属于连接阶段,这两个阶段的开始时间依然保持着固定的先后顺序。
7.3.2 验证
连接节点的第一步,为了确保 Class 文件的字节码中包含的信息符合当前虚拟机的要求,且不会危害虚拟机自身的安全。
Class 文件并不一定由 Java 源码编译而来。在字节码语言层面上,Java 代码无法做到的(比较危险的)事情都是可以实现的。虚拟机如果不检查输入的字节流而对其完全信任的话,很有可能因为载入了有害的字节流而导致系统崩溃。
验证阶段是非常重要的,该阶段是否严谨,决定了 JVM 是否能够承受恶意代码的攻击。从执行性能的角度来看,验证阶段的工作量在类加载子系统中占有相当大一部分。
验证阶段主要可以分为以下 4 个检验动作:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
详细细节可以参考虚拟机规范。
7.3.3 准备
正式为类变量(静态变量)分配内存设置初始值(零值),这些变量所使用的内存将被分配在方法区。
假设一个类变量的定义为 public static int value=123;
,那么该字段在准备阶段之后的值仍然为 int 类型的零值,即 0。因为在该阶段尚未开始执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令会在程序被编译后放在类构造器的 <clinit>()
方法中,并在初始化节点执行。
一种特殊情况:如果类字段的字段属性表存在 ConstantValue 属性,那么在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值。
假设将上面的定义修改为 public static final int value=123;
,这是 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段 JVM 就会根据 ConstantValue 的设置将 value 赋值为 123。
基本数据类型的零值:
7.3.4 解析
将常量池中的符号引用替换为直接引用的过程。
符号引用:以一组符号来描述所引用的目标,符合可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
JVM 规范中并未规定解析阶段发生的具体时间,只要求在执行 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield、putstatic 这 16 个用于操作符号引用的字节码指令之前,先对其使用的符号引用进行解析。所有虚拟机实现可以根据需要来判断是要在类加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用时再去解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点,这 7 类符号进行,分别对应于常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 这 7 种常量类型。
这里介绍前 4 种引用的解析过程,后 3 种与动态语言的支持相关,会在第 8 章介绍完 invokedynamic 指令的语义之后再做介绍。
1. 类或接口
假设当前类为 D,如果要把一个未解析过的符号引用 N 解析为一个类或接口 C 的直接引用,虚拟机要完成的解析过程包含以下 3 个步骤:
- 如果 C 不是一个数组类型,虚拟机将会把代表 N 的完全限定名传递给 D 的类加载器来加载这个类 C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,如该类的父类或父接口。一旦该过程出现任何异常,解析过程宣告失败。
- 如果 C 是一个数组类型,并且数组的元素类型为对象,即 N 的描述符会以类似
[Ljava/lang/Integer
的形式,那将会按照第一步的规则首先加载素组元素的类型。完成后由虚拟机生成一个代表次数组维度和元素的数组对象。 - 如果上述步骤没有出现任何异常,那么 C 在虚拟机中实际已经称为了一个有效的类或接口了,但在解析完成之前要进行符号引用验证,以确认 D 是否具备对 C 的访问权限。如果不具备访问权限,将抛出
java.lang.IllegalAccessError
异常。
2. 字段解析
首先将会对字段表内 class_index 项中索引的 CONSTANT_Class_info 符号引用进行解析,也就是字段所属的类或接口的符号引用。如果该过程中出现异常将会导致本解析过程失败;如果解析成功完成,那么将该字段所属的类或接口用 C 表示,虚拟机规范中要求按照如下步骤对 C 进行后续字段的搜索:
- 如果 C 本身包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
- 否则,如果在 C 中实现了接口,将会安装继承关系从下往上递归搜索各个接口及父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
- 否则,如果 C 不是
java.lang.Object
的话,将会安装继承关系递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。 - 否则,查找是被,抛出
java.lang.NoSuchFieldError
异常。
如果查找过程成功返回了引用,将会对该字段进行权限验证,如果发现不具备对该字段的访问权限,将抛出 java.lang.IllegalAccessError
异常。
在实际应用中,虚拟机的编译器实现可能会比上述规范更加严格,如果一个字段同时出现在 C 的接口和父类中,或者同时出现在自己或父类的多个实现接口中,那么编译器可能会直接拒绝编译。
3. 类方法解析
类方法解析的第一个步骤与字段解析一样,也需要解析出类方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用 C 表示该类,接下来虚拟机会按照如下步骤进行后续的类方法搜索:
- 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法中发现 class_index 中索引的 C 是个接口,那就直接抛出
java.lang.IncompatibleClassChangeError
异常。 - 如果通过了第 1 步,在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,结束查找。
- 否则,在类 C 的父类中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 否则,在类 C 实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类 C 是一个抽象类,这时查找结束,抛出
java.lang.AbstractMethodError
异常。 - 否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
异常。
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出 java.lang.IllegalAccessError
异常。
4. 接口方法解析
接口方法也需要解析出接口方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用 C 表示这个接口,接下来虚拟机会按照如下步骤进行后续的接口方法搜索:
- 与类解析不同,如果在接口方法表中发现 class_index 中的索引 C 是一个类而不是接口,将直接抛出
java.lang.IncompatibleClassChangeError
异常。 - 否则,在接口 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,结束查找。
- 否则,在接口 C 的父接口中递归查找,直到
java.lang.Object
类为止(包括该类),查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,结束查找。 - 否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
异常。
由于接口中所有的方法默认都是 public 访问权限,因此不存在访问权限问题,即也不会抛出 java.lang.IllegalAccessError
异常。
7.3.5 初始化
这是类加载过程的最后一步。前面已介绍的过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才开始真正执行类中定义的 Java 程序字节码。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器 <clinit>()
方法的过程。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,收集顺序取决于语句在代码源文件中出现的顺序,静态语句块只能访问到定义在静态语句块之前的变量,但可以为定义在其后的变量赋值而不能访问。
<clinit>()
方法与类的构造函数(类实例构造器) <init>()
方法不同,它不需要显式的调用父类构造器,虚拟机会保证在子类的 <clinit>()
方法执行之前,父类的 <clinit>()
方法已经执行完毕。因此在虚拟机中第一个被执行的 <clinit>()
方法一定属于 java.lang.Object
。
如果一个类中没有静态语句块会对变量的赋值操作,那么编译器就不会为其生成该方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>()
方法。但与类不同的是,执行接口的 <clinit>()
方法不需要首先执行父接口的 <clinit>()
方法。因为当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也不会执行父接口的 <clinit>()
方法。
虚拟机会保证一个类的 <clinit>()
方法在多线程环境中能够被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行该类的 <clinit>()
方法,其他线程都有阻塞等待,直到活动线程执行 <clinit>()
方法完毕。如果在一个类的 <clinit>()
方法中有耗时很长的操作,就可能会造成多个进程阻塞。
7.4 类加载器
虚拟机设计团队把类加载阶段中:“通过一个类的完全限定名来获取描述此类的二进制字节流”,这样一个动作放到了 JVM 外部来实现,以便让应用程序自己决定如何获取需要的类。实现这个动作的代码模块被称为“类加载器”。
7.4.1 类与类加载器
在一个 JVM 实例内,对于任意一个类,都需要由加载它的类加载器和这个类本身来共同确立其唯一性,每个类加载器都有一个独立的类名称空间。
两个相等的类,意味着由同一个虚拟机加载。相等性包括 Class 对象的 equals 方法、isAssignableFrom 方法、isInstance 方法返回的结果,也包括使用 isinstanceof 关键字对对象所属的类进行关系判定等情况。
7.4.2 双亲委派模型
从 JVM 的角度来看,只有两种不同的类加载器:一种是启动类加载器,由 C++ 语言实现,是 JVM 的一部分;另一种就是所有其他的加载器,均由 Java 语言实现,独立于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader
。
从应用开发的角度看,绝大部分的 Java 程序都会用到以下 3 种类加载器:
启动类加载器:负责将存放在 <JAVA_HOME>/lib
目录中、或者被 -Xbootclasspath
参数指定的路径中的、并且是虚拟机识别的(以文件名识别,如 rt.jar
)类库加载到虚拟机内存中。该加载器无法被 Java 程序直接引用,如果在编写自定义加载器时需要将加载请求委派给引导类加载器,可以直接使用 null 作为自定义加载器的父加载器。
扩展类加载器:由 sun.misc.Launcher$ExtClassLoader
实现,负责加载 <JAVA_HOME>/lib/ext
目录中的、或者被 java.ext.dirs
系统变量指定的路径中的所有类库,可以被开发者直接使用。
应用类加载器:由 sun.misc.Launcher$AppClassloader
实现。是 ClassLoader.getSystemClassLoader
的返回值,一般也称为系统类加载器。负责加载用户类路径(ClassPath)中所有的类库,可以被开发者直接使用。如果应用中没有自定义实现任何加载器,一般情况下就是程序中默认的加载器。
上图展示了这几种加载器之间的关系,称为类加载器的“双亲委派模型”。该模型要求除了顶层的启动类加载器外,其余的类加载器都应当有字节的父类加载器。这里所指的类加载器之间的父子关系不使用类继承形式来实现,而是使用组合关系来将加载请求为派给父类加载器。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,他首先不会自己去尝试执行加载,而是把该请求委派给父加载器去完成加载,每一个层次的加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成该加载请求时(在搜索范围内无法找到需要的类),子加载器才会尝试自己去加载。
这种模型的好处就是,Java 类随着其类加载器一起具备了一种优先级层次关系。比如 Objcet 类,它被存放在 rt.jar
中,无论哪个加载器要加载该类,最终都是委派给处于模型最顶层的启动类加载器进行加载,因此 Object 类在程序的各个类加载器中都是同一个类。如果没有使用双亲委派模型,而是由各个类加载器自己去加载的话,如果用户字节编写了一个名为 java.lang.Object
的类,Java 类型体系中的最基础行为就无法得到保证。
7.4.3 破坏双亲委派模型
双亲委派模型并非一个强制性的约束模型,而 Java 设计者推荐给开发者的类加载器实现方式。
下面是种对这种模型的“破坏(创新)”形式。
1. 历史遗留
第一次“被破坏”其实发生在双亲委派模型出现之前,即 JDK 1.2 发布之前。由于双亲委派模型在 JDK 1.2 之后才被引入,而类加载器和抽象类 java.lang.ClassLoader
在早在 JDK 1.0 时代就已经存在,面对已经存在的用户自定义加载器实现,Java 设计者在引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK 1.2 之后的 java.lang.ClassLoader
添加了一个新的 protected 方法 findClass。在此之前,用户继承 ClassLoader 时唯一的目的就是重新 loadClass 方法,因为虚拟机在进行类加载时会调用加载器的私有方法 loadClassInternal,而该方法的唯一逻辑就是调用 loadClass 方法。
在 JDK 1.2 之后已经不提倡覆写 loadClass 方法,而应当把自己的类加载逻辑编写在 findClass 方法中,在 loadClass 方法的逻辑里如果父类记载失败,则会调用自己的 findClass 方法来完成加载,这样就可以保证新编写的类加载器都符合双亲委派模型。
2. 模型缺陷
双亲委派模型很好的解决了各个加载器对基础类的统一问题(越基础的类越由上层的加载器完成加载),基础类之所以被称为“基础”,是因为它们总是作为被用户代码调用的 API。但有时候基础类也需要调用用户代码。
如 JNDI 服务,它的代码由启动类加载器完成加载(rt.jar),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序 ClassPath 下的 JNDI 接口提供者(SPI)代码,但启动类加载器并不认识这些代码。
为了解决该问题,Java 设计团队只好引入了一个不太优雅的设计:线程上下文加载器。该加载器可以通过 Thread 类的 setContextClassLoader 方法进行设置,如果线程创建时还未设置,它将会从父线程中继承一个加载器,如果在应用程序的全局范围内都没有设置过,那么就使用应用程序类加载器。
JDNI 使用该加载器来加载所需的 SPI 代码,也就是父类加载器请求子类加载器来完成对类的加载操作,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,也违背了双亲委派模型的一般性原则。Java 中的所有 SPI 的加载动作基本都采用这种方式。
3. 动态性
这里所说的动态性指的是:代码热替换、模块热部署等。OSGI 已经成为了业界“事实上”的 Java 模块化标准,而 OSGI 实现模块化热部署的关键就是它自已定义的类加载器机制。每个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起替换掉以实现代码的热替换。
在 OSGI 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGI 将按照下面的顺序来搜索类:
- 将以
java.*
开头的类委派给父类加载器。 - 否则,将委派列表名单内的类委派给父类加载器。
- 否则,将 import 列表中的类委派给 Export 这个类所属的 Bundle 的类加载器。
- 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器。
- 否则,查找类师傅在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器。
- 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器。
- 否则,类查找失败。
以上查找顺序中只有开头两点符合双亲委派模型,其余的类查找都在平级的类加载器中机进行。
7.5 本章小结
本章介绍了类加载过程的“加载”、“验证”、“准备”、“解析”和“初始化”5个阶段中虚拟机进行了哪些动作,还介绍了类加载器的工作原理及其对虚拟机的意义。
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.