虚拟机类加载机制

Posted wiljm

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了虚拟机类加载机制相关的知识,希望对你有一定的参考价值。

摘自《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》(第二版)

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

        与那些在编译时需要进行连接工作的语言不通,在 Java 语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为 Java 应用程序提供高度的灵活性,Java 里天生可以动态扩展的语言特性就是依赖于运行期动态加载动态连接这个特点实现的。例如,如果编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类;用户可以通过 Java 预定义的和自定义类加载器,让一个本地的应用程序可以在运行时从网络或其他地方加载一个二进制流作为程序代码的一部分,这种组装应用程序的方式目前已广泛应用于 Java 程序之中。从最基础的 Applet、JSP 到相对复杂的 OSGI 技术,都使用了 Java 语言运行期类加载的特性。

类加载的时机

        类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialzation)、使用(Using)和卸载(Unloading)7 个阶段。其中验证、准备、解析 3 个部分统称为连接(Linking),这 7 个阶段的发生顺序如图 7-1 所示。

图 7-1  类的生命周期

        图 7-1 中,加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定(也称为动态绑定或晚期绑定)。注意,这里笔者写的是按部就班地 “开始”,而不是按部就班地 “进行” 或 “完成”,强调这点是因为这些阶段通常都是互相交叉地混合式进行的,通常会在一个阶段执行的过程中调用、激活另外一个阶段。

        什么情况下需要开始类加载过程的第一个阶段:加载?Java 虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有 5 种情况必须立即对类进行 “初始化”(而加载、验证、准备自然需要在此之前开始):

  1.   遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:使用 new 关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  2.   使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3.   当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4.   当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个类。
  5.   当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结构 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先触发其初始化。

        对于这 5 种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:“有且只有”,这 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。下面举 3 个例子来说明何为被动引用,分别见代码清单 7-1 ~ 代码清单 7-3。

代码清单 7-1  被动引用的例子之一

package org.fenixsoft.classloading;

/**
 * 被动使用类字段演示一:
 * 通过子类引用父类的静态字段,不会导致子类初始化
 *
 */
public class SuperClass 
	
	static 
		System.out.println("SuperClass init!");
	
	
	public static int value = 123;


public class SubClass extends SuperClass

    static 
        System.out.println("SubClass init!");
    


/**
 * 非主动使用类字段演示
 *
 */
public class NotInitialization 
    
    public static void main(String[] args) 
        System.out.println(SubClass.value);
    

        上述代码运行之后,只会输出“SuperClass init!”,而不会输出“SubClass init!”。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证,在虚拟机规范中并未明确规定,这点取决于虚拟机的具体实现。对于 Sun HotSpot 虚拟机来说,可通过-XX:+TraceClassLoading 参数观察到此操作会导致子类的加载。

代码清单 7-2  被动引用的例子之二

/**
 * 非主动使用类字段演示
 *
 */
public class NotInitialization 
	
	public static void main(String[] args) 
		SuperClass[] sca = new SuperClass[10];
	

        这段代码复用了代码清单 7-1 中的 SuperClass,运行之后发现没有输出“SuperClass init!”,说明并没有触发类 org.fenixsoft.classloading.SuperClass 的初始化阶段。但是这段代码里面触发了另外一个名为 “[Lorg.fenixsoft.classloading.SuperClass” 的类初始化阶段,对于用户代码来说,这并不是一个合法的类名称,它是一个由虚拟机自动生成的、直接继承于 java.lang.Object 的子类,创建动作由字节码指令 newarray 触发。

        这个类代表了一个元素类型为 org.fenixsoft.classloading.SuperClass 的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为 public 的 length 属性和 clone() 方法)都实现在这个类里。Java 语言中对数组的访问比 C/C++ 相对安全是因为这个类封装了数组元素的访问方法(注:准确地说,越界检查不是封装在数组元素访问的类中,而是封装在数组访问的 xaload、xastore 字节码指令中),而 C/C++ 直接翻译为对数组指针的移动,在 Java 语言中,当检查到发生数组越界时会抛出 java.lang.ArrayIndexOutOfBoundsException 异常。

代码清单 7-3  被动引用的例子之三

package org.fenixsoft.classloading;

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 init!”,这是因为虽然在 Java 源码中引用了 ConstClass 类中的常量 HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值 “hello world” 存储到了 NotInitialization 类的常量池中,以后 NotInitialization 对常量 ConstClass.HELLOWORLD 的引用实际都被转化为 NotInitialization 类对自身常量池的引用了。也就是说,实际上 NotInitialization 的 Class 文件之中并没有 ConstClass 类的符号引用入口,这里两个类在编译成 Class 之后就不存在任何联系了。

        接口的加载过程与类加载过程稍有一些不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点与类是一致的,上面的代码都是用静态语句块 “static ” 来输出初始化信息的,而接口中不能使用 “static ” 语句块,但编译器仍然会为接口生成 “<clinit>()” 类构造器,用于初始化接口中所定义的成员变量。接口与类真正有所区别的是前面讲述的 5 种 “有且仅有” 需要开始初始化场景中的第 3 种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

类加载的过程

        接下来详细讲解一下 Java 虚拟机中类加载的全过程,也即是加载验证准备解析初始化这 5 个阶段所执行的具体动作。

加载

        “加载” 是 “类加载”(Class Loading)过程的一个阶段,希望读者没有混淆这两个看起来很相似的名词。在加载阶段,虚拟机需要完成以下 3 件事情:

  1.   通过一个类的全限定名来获取定义此类的二进制字节流。
  2.   将这个字节流所代表的静态存储结构转换为方法区运行时数据结构
  3.   在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

        虚拟机规范的这 3 点要求其实并不算具体,因此虚拟机实现与具体应用的灵活度都是相当大的。例如 “通过一个类的全限定名来获取定义此类的二进制字节流” 这条,它没有指明二进制字节流要从一个 Class 文件中获取,准确地说是根本没有指明要从哪里获取、怎样获取。虚拟机设计团队在加载阶段搭建了一个相当开放的、广阔的 “舞台”,Java 发展历程中,充满创造力的开发人员则再这个 “舞台” 上玩出了各种花样,许多举足轻重的 Java 技术都建立在这一基础之上,例如:

  • 从 ZIP 包中读取,这很常见,最终成为日后 JAR、EAR、WAR 格式的基础。
  • 从网络中获取,这种常见最典型的应用就是 Applet。
  • 运行时计算生成,这种常见使用得最多的就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 来为特定接口生成形式为 “*$Proxy” 的代理类的二进制字节流。
  • 由其他文件生成,典型场景是 JSP 应用,即由 JSP 文件生成对应的 Class 类。
  • 从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

……

        相对于类加载过程的其他阶段,一个非数组类的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass() 方法)。

        对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element Type,指的是数组去掉所有纬度的类型)最终是要考类加载器去创建,一个数组类(下面简称为 C)创建过程要遵循以下规则:

  • 如果数组的组件类型(Component Type,指的是数组去掉一个纬度的类型)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组 C 将在加载该组件类型的类加载器的类名称空间上被标识(这点很重要,一个类必须与类加载器一起确定唯一性)。
  • 如果数组的组件类型不是引用类型(例如 int[] 数组),Java 虚拟机将会把数组 C 标记为与引导类加载器关联。
  • 数组类的可见性与它的组件类型的可见性一直,如果组件类型不是引用类型,那数组类的可见性将默认为 public。

        加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个 java.lang.Class 类的对象(并没有明确规定是在 Java 堆中,对于 HotSpot 虚拟机而言,Class 对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。

        加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

        验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

        Java 语言本身是相对安全的语言(依然是相对于 C/C++ 来说),使用纯粹的 Java 代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果这样做了,编译器将拒绝编译。但前面已经说过,Class 文件并不一定要求用 Java 源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生 Class 文件。在字节码语言层面上,上述 Java 代码无法做到的事情都是可以实现的,至少语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作

        验证阶段是非常重要的,这个阶段是否严谨,直接决定了 Java 虚拟机是否能承受恶意代码的攻击,从执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载子系统中又占了相当大的一部分。《Java 虚拟机规范(第 2 版)》对这个阶段的限制、知道还是比较笼统的,规范中列举了一些 Class 文件格式中的静态和结构化约束,如果验证到输入的字节流不符合 Class 文件格式的约束,虚拟机就应抛出一个 java.lang.VerifyError 异常或其子类异常,但具体应当检查哪些方面,如何检查,何时检查,都没有足够具体的要求和明确的说明。知道 2011 年发布的《Java 虚拟机规范(Java SE 7 版)》,大幅增加了描述验证过程的篇幅(从不到 10 页增加到 130 页),这时约束和验证规则才变得具体起来。受篇幅所限,无法逐条规则去讲解,但从整体上看,验证阶段大致上会完成下面 4 个阶段的检验动作:文件格式验证元数据验证字节码验证符号引用验证

1. 文件格式验证

        第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:

  • 是否以魔数 0xCAFEBABE 开头。
  • 主、次版本号是否在当前虚拟机处理范围之内。
  • 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)。
  • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据。
  • Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。

……

        实际上,第一阶段的验证点还远不止这些,上面这些知识从 HotSpot 虚拟机中摘抄的一小部分内容,该验证阶段的主要目的保证输入的字节流能正确地解析并存储于方法区,格式上符合描述一个 Java 类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面 3 个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

2.元数据验证

        第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求,这个阶段可能包括的验证点如下:

  • 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)。
  • 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。

……

        第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java 语言规范的元数据信息

3.字节码验证

        第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。例如:

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中。
  • 保证跳转指令不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。

……

        如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。即使字节码验证之中进行了大量的检查,也不能保证这一点。这里涉及了离散数学中一个很著名的问题 “Halting Problem”(停机问题):通俗一点的说法就是,通过程序去校验程序逻辑是无法做到绝对准确的——不能通过程序准确地检查出程序是否能在有限的时间之内结束运行

        由于数据流验证的高复杂性,虚拟机设计团队为了避免过多的时间消耗在字节码验证阶段,在 JDK 1.6 之后的 javac 编译器和 Java 虚拟机中进行了一项优化,给方法体的 Code 属性的属性表中增加了一项名为 “StackMapTable” 的属性,这项属性描述了方法体中所有的基本快(Basic Block,按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,就不需要根据程序推导这些状态的合法性,只需要检查 StackMapTable 属性中的记录是否合法即可。这样讲字节码验证的类型推导转变为类型检查从而节省一些时间。

        理论上 StackMapTable 属性也存在错误或被篡改的可能,所以是否有可能在恶意篡改了 Code 属性的同时,也生成相应的 StackMapTable 属性来骗过虚拟机的类型校验则是虚拟机设计者值得思考的问题。

        在 JDK 1.6 的 HotSpot 虚拟机中提供了 -XX:-UseSplitVerifier 选项来关闭这项优化,或者使用参数 -XX:+FailOverToOldVerifier 要求在类型校验失败的时候退回到旧的类型推导方式进行校验。而在 JDK 1.7 之后,对于主版本大于 50 的 Class 文件,使用类型检查来完成数据流分析则是唯一的选择,不允许再退回到类型推导的校验方式。

4.符号引用验证

        最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转换动作将在连接的第三个阶段——解析阶段中发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,通常需要校验下列内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
  • 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。

……

        符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个 java.lang.IncompatibleClassChangeError 异常的子类,如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等。

        对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要(因为对程序运行期没有影响)的阶段。如果所运行的全部代码(包括自己编写的及第三方包中的代码)都已经被反复使用和验证过,那么在实施阶段就可以考虑使用-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间

准备

        准备阶段是正式为类变量分配内存设置类变量初始值的阶段,这些变量所使用的内存都将在方法区进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。其次,这里所说的初始值 “通常情况” 下是数据类型的零值,假设一个类变量的定义为:

public static int value = 123;

那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器 <clinit>() 方法之中,所以把 value 赋值为 123 的动作将在初始化阶段才会执行。表 7-1 列出了 Java 中所有基本数据类型的零值。

        上面提到,在 “通常情况” 下初始值是零值,那相对的会有一些 “特殊情况”:如果类字段属性表中存在 ConstantValue 属性,那在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值,假设上面类变量 value 的定义变为:

public static <strong>final </strong>int value = 123;

编译时 javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 复制为 123。

解析

        解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,在 Class 文件中符号引用它以 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。
  • 直接引用(Direct References):直接引用可以是直接指向目标的指针相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

        虚拟机规范之中并未规定解析阶段发生的具体时间,只要求了在执行 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield 和 putstatic 这 16 个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

        对同一个符号引用进行多次解析请求是很常见的事情,除 invokedynamic 指令以外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。无论是否真正执行了多次解析动作,虚拟机需要保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直成功;同样的,如果第一次解析失败了,那么其他指令对这个符号的解析请求也应该受到相同的异常。

        对于 invokedynamic 指令,上面规则则不成立。当碰到某个前面已经由 invokedynamic 指令触发过解析的符号引用时,并不意味着这个解析结果对于其他 invokedynamic 指令也同样生效。因为 invokedynamic 指令的目的本来就是用于动态语言支持(目前仅使用 Java 语言不会生成这条字节码指令),它所对应的引用称为 “动态调用点限定符”(Dynamic Call Site Specifier),这里 “动态” 的含义就是必须等到程序实际运行到这条指令的时候,解析动作才能进行。相对的,其余可触发解析的指令都是 “静态” 的,可以在刚刚完成加载阶段,还没有开始执行代码时就进行解析。

        解析动作主要针对类或接口字段类方法接口方法方法类型方法句柄调用点限定符 7 类符号引用进行,分别对应于常量池的 CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_infoCONSTANT_InterfaceMethodref_infoCONSTANT_MethodType_infoCONSTANT_MethodHandle_infoCONSTANT_InvokeDynamic_info7 种常量类型。下面将讲解前面 4 种应用的解析过程。

1.类或接口的解析

        假设当前代码所处的类为 D,如果要把一个从未解析过的符号引用 N 解析为一个类或接口 C 的直接引用,那虚拟机完成整个解析的过程需要以下 3 个步骤:

  1.   如果 C 不是一个数组类型,那虚拟机将会把代表 N 的全限定名传递给 D 的类加载器去加载这个类 C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就宣告失败。
  2.   如果 C 是一个数组类型,并且数组的元素类型为对象,也就是 N 的描述符会是类似 “[Ljava/lang/Integer” 的形式,那将会按照第 1 点的规则加载数组元素类型。如果 N 的描述符如前面所假设的形式,需要加载的元素类型就是 “java.lang.Integer”,接着由虚拟机生成一个代表此数组纬度和元素的数组对象。
  3.   如果上面的步骤没有出现任何异常,那么 C 在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认 D 是否具备对 C 的访问权限。如果发现不具备访问权限,将抛出 java.lang.IllegalAccessError 异常。

2.字段解析

        要解析一个未被解析过的字段符号引用,首先将会对字段表内 class_index 项中索引的 CONSTANT_Class_info 符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用 C 表示,虚拟机规范要求按照如下步骤对 C 进行后续字段的搜索。

  1.   如果 C 本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  2.   否则,如果在 C 中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  3.   否则,如果 C 不是 java.lang.Object 的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  4.   否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。

        如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出 java.lang.IllegalAccessError 异常。

        在实际应用中,虚拟机的编译器实现可能会比上述规范要求得更加严格一些,如果有一个同名字段同时出现在 C 的接口和父类中,或者同时在自己或父类的多个接口中出现,那编译器将可能拒绝编译。在代码清单 7-4 中,如果注释了 Sub 类中的 “public static int A=4;”,接口与父类同时存在字段 A,那编译器将提示 “The field Sub.A is ambiguous”,并且拒绝编译这段代码。

代码清单 7-4  字段解析

package org.fenixsoft.classloading;

public class FieldResoulution 
	
	interface Interface0 
		int A = 0;
	
	
	interface Interface1 extends Interface0 
		int A = 1;
	
	
	interface Interface2 
		int A = 2;
	
	
	static class Parent implements Interface1 
		public static int A = 3;
	
	
	static class Sub extends Parent implements Interface2 
		public static int A = 4;
	
	
	public static void main(String[] args) 
		System.out.println(Sub.A);
	

3.类方法解析

        类方法解析的第一个步骤与字段解析一样,也需要先解析出类方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用 C 表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索。

  1.   类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现 class_index 中索引的 C 是个接口,那就直接抛出 java.lang.IncompatibleClassChangeError 异常。
  2.   如果通过了第 1 步,在类 C 中查找是否有简单名称和描述符都有目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  3.   否则,在类 C 的父类中递归查找是否有简单名称和描述符都有目标想匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  4.   否则,在类 C 实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都有与目标相匹配的方法,如果存在匹配的方法,说明类 C 是一个抽象类,这时查找结束,抛出 java.lang.AbstractMethodError 异常。
  5.   否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError。

        最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出 java.lang.IllegalAccessError 异常。

4.接口方法解析

        接口方法也需要先解析出接口方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用 C 表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。

  1.   与类方法解析不同,如果在接口方法表中发现 class_index 中索引 C 是个类而不是接口,那就直接抛出 java.lang.IncompatibleClassChangeError 异常。
  2.   否则,在接口 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  3.   否则,在接口 C 的父接口中递归查找,知道 java.lang.Object 类(查找范围会包括 Object 类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  4.   否则,宣告方法查找失败,抛出 java.lang.NoSuchMethodError 异常。

        由于接口中的所有方法默认都是 public 的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出 java.lang.IllegalAccessError 异常。

初始化

        类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 程序代码(或者说是字节码)。

        在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器 <clinit>() 方法的过程。我们在下文会讲解 <clinit>() 方法是怎么生成的,在这里,我们先看一下 <clinit>() 方法执行过程中一些可能会影响程序运行行为的特点和细节,这部分相对于更贴近普通的程序开发人员。

  • <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如代码清单 7-5 中的例子所示。
代码清单 7-5  非法向前引用变量
public class Test 

	static 
		i = 0;			// 给变量赋值可以正常编译通过
		System.out.println(i);	// 这句编译器会提示“非法向前引用”
	
	static int i = 1;
  • <clinit>() 方法与类的构造函数(或者说实例构造器 <init>() 方法)不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕。因此在虚拟机中第一个被执行的 <clinit>() 方法的类肯定是 java.lang.Object。
  • 由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如在代码清单 7-6 中,字段 B 的值将会是 2 而不是 1。
代码清单 7-6  <clinit>() 方法执行顺序

static class Parent 
	public static int A = 1;
	static 
		A = 2;
	


static class Sub extends Parent 
	public static int B = A;


public static void main(String[] args) 
	System.out.println(Sub.B);
  • <clinit>() 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。
  • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,知道活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时很长的操作,就可能造成多个进程阻塞(需要注意的是,其他线程虽然会被阻塞,但如果执行 <clinit>() 方法的那条线程退出 <clinit>() 方法后,其他线程唤醒之后不会再次进入 <clinit>() 方法。同一个类型只会初始化一次),在实际引用中这种阻塞往往是很隐蔽的。代码清单 7-7 演示了这种场景。
代码清单 7-7  字段解析
static class DeadLoopClass 
	static 
		/* 如果不加上这个 if 语句,编译器将提示 "Initializer does not complete normally"
		 并拒绝编译 */
		if (true) 
			System.out.println(Thread.currentThread() + "init DeadLoopClass");
			while (true) 
			
		
	


public static void main(String[] args) 
	Runnable script = new Runnable() 
		public void run() 
			System.out.println(Thread.currentThread() + "start");
			DeadLoopClass dlc = new DeadLoopClass();
			System.out.println(Thread.currentThread() + " run over");
		
	;
	
	Thread thread1 = new Thread(script);
	Thread thread2 = new Thread(script);
	
	thread1.start();
	thread2.start();
        运行结果如下,即一条线程在死循环以模拟长时间操作,另外一条线程在阻塞等待。


类加载器

        虚拟机设计团队把类加载阶段中的 “通过一个类的全限定名来获取描述此类的二进制字节流” 这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

        类加载器可以说是 Java 语言的一项创新,也是 Java 语言流行的重要原因之一,它最初是为了满足 Java Applet 的需求而开发出来的。虽然目前 Java Applet 技术基本上已经“死掉”,但类加载器却在类层次划分OSGI热部署代码加密等领域大放异彩,成为了 Java 技术体系中一块重要的基石,可谓是失之桑榆,收之东隅。

类与类加载器

        类加载器虽然只用于实现类的加载动作,但它在 Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否 “相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

        这里所指的 “相等”,包括代表类的 Class 对象的 equals() 方法isAssignableFrom() 方法isInstance() 方法的返回结果,也包括使用instanceof 关键字做对象所属关系判定等情况。如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果,代码清单 7-8 中演示了不同的类加载器对 instanceof 关键字运算的结果的影响。

代码清单 7-8  不同的类加载器对 instanceof 关键字运算的结果的影响

/**
 * 类加载器与 instanceof 关键字演示
 *
 */
public class ClassLoaderTest 

	public static void main(String[] args) throws Exception 
		
		ClassLoader myLoader = new ClassLoader() 

			@Override
			public Class<?> loadClass(String name) throws ClassNotFoundException 
				try 
					String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
					
					InputStream is = getClass().getResourceAsStream(fileName);
					
					if (is == null) 
						return super.loadClass(name);
					
					byte[] b = new byte[is.available()];
					
					is.read(b);
					return defineClass(name, b, 0, b.length);
				 catch (IOException e) 
					throw new ClassNotFoundException(name);
				
			
		;
		
		Object obj = myLoader.loadClass("org.fenixsoft.classloading.ClassLoaderTest").newInstance();
		
		System.out.println(obj.getClass());
		System.out.println(obj instanceof org.fenixsoft.classloading.ClassLoaderTest);
	


        运行结果:

        代码清单 7-8 中构造了一个简单的类加载器,尽管很简单,但是对于这个演示来说还是够用了。它可以加载与自己在同一路径下的 Class 文件。我们使用这个类加载器去加载了一个名为 “org.fenixsoft.classloading.ClassLoaderTest” 的类,并实例化了这个类的对象。两行输出结果中,从第一句可以看出,这个对象确实是类 org.fenixsoft.classloading.ClassLoaderTest 实例化出来的对象,但从第二句可以发现,这个对象与类 org.fenixsoft.classloading.ClassLoaderTest 做所属类型检查的时候却返回了 false,这是因为虚拟机中存在了两个 ClassLoaderTest 类,一个是由系统应用程序类加载器加载的,另外一个是由我们自定义的类加载器加载的,虽然都来自同一个 Class 文件,但依然是两个独立的类,做对象所属类型检查时结果自然为 false。

双亲委派模型

        从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++ 语言实现注:这里只限于 HotSpot,像MRP、Maxnie 等虚拟机,整个虚拟机本身都是由 Java 编写的,自然 Bootstrap ClassLoader 也是由 Java 语言而不是 C++ 实现的。退一步讲,除了 HotSpot 以外的其他两个高性能虚拟机 JRockit 和 J9 都有一个代表 Bootstrap ClassLoader 的 Java 类存在,但是关键方法的实现仍然是使用 JNI 回调到 C(注意不是C++)的实现上,这个 Bootstrap ClassClassLoader 的实例也无法被用户获取到。),是虚拟机自身的一部分另一种就是所有其他的类加载器,这些类加载器都由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader

        从 Java 开发人员的角度来看,类加载器还可以划分得更细致一些,绝大多数 Java 程序都会使用到以下 3 种系统提供的类加载器。

  • 启动类加载器(Bootstrap ClassClassLoader):这个类加载器负责将存放在 <JAVA_HOME>\\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用 null 代替即可,如代码清单 7-9 所示为 java.lang.ClassLoader.getClassLoader() 方法的代码片段。
代码清单 7-9  ClassClassLoader.getClassLoader() 方法的代码片段

/**
<pre name="code" class="java">     * Returns the class loader for the class.  Some implementations may use
     * null to represent the bootstrap class loader. This method will return
     * null in such implementations if this class was loaded by the bootstrap
     * class loader.
     */
    @CallerSensitive
    public ClassLoader getClassLoader() 
        ClassLoader cl = getClassLoader0();
        if (cl == null)
            return null;
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) 
            ClassLoader.checkClassLoaderPermission(cl, Reflection.getCallerClass());
        
        return cl;
    
 
  • 扩展类加载器(Extension ClassLoader):这个加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载<JAVA_HOME>\\lib\\ext 目录中的,或者被java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):这个类加载器由 sun.misc.Launcher$AppClassLoader 实现。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

        我们的应用程序都是由这 3 种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。这些类加载器之间的关系一般如图 7-2 所示。

图 7-2  类加载器双亲委派模型

        图 7-2 中展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

        类加载器的双亲委派模型在 JDK 1.2 期间被引入并被广泛应用于之后几乎所有的 Java 程序中,但它并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的一种类加载器实现方式。

        双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载

        使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java 类随着它的类加载器一起具备了一种带有优先级层次关系。例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。如果有兴趣的话,可以尝试去编写一个与 rt.jar 类库中已有类重名的 Java 类,将会发现可以正常编译,但永远无法被加载运行(注:即使自定义了自己的类加载器,强行用 defineClass() 方法去加载一个以 “java.lang” 开头的类也不会成功。如果尝试这样做的话,将会收到一个由虚拟机自己抛出的 “java.lang.SecurityException: Prohibited package name:java.lang” 异常)。

        双亲委派模型对于保证 Java 程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在 java.lang.ClassLoader 的 loadClass() 方法之中,如代码清单 7-10 所示。逻辑清晰易懂:先检查是否已经被加载过,若没有加载则调用父加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出 ClassNotFoundException 异常后,再调用自己的 findClass() 方法进行加载。

代码清单 7-10  双亲委派模型的实现

 protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    
        synchronized (getClassLoadingLock(name)) 
            // 首先,检查请求的类是否已经被加载过了
            Class c = findLoadedClass(name);
            if (c == null) 
                long t0 = System.nanoTime();
                try 
                    if (parent != null) 
                        c = parent.loadClass(name, false);
                     else 
                        c = findBootstrapClassOrNull(name);
                    
                 catch (ClassNotFoundException e) 
                    // 如果父类加载器抛出 ClassNotFoundException
                    // 说明父类加载器无法完成加载请求
                

                if (c == null) 
                    // 在父类加载器无法加载的时候
                    // 再调用本身的 findClass 方法来进行类加载
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                
            
            if (resolve) 
                resolveClass(c);
            
            return c;
        
    

破坏双亲委派模型

        上文提到过双亲委派模型并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的类加载器实现方式。在 Java 的世界中大部分的类加载器都遵循这个模型,但也有例外,到目前为止,双亲委派模型主要出现过 3 次较大规模的 “被破坏” 情况。

        双亲委派模型的第一次 “被破坏” 其实发送在双亲委派模型出现之前——即 JDK 1.2 发布之前。由于双亲委派模型在 JDK 1.2 之后才被引入,而类加载器和抽象类 java.lang.ClassLoader 则再 JDK 1.0 时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java 设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK 1.2 之后的 java.lang.ClassLoader 添加了一个新的 protected 方法 findClass(),在此之前,用户去继承 java.lang.ClassLoader 的唯一目的就是为了重写 loadClass() 方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法 loadClassInternal() ,而这个方法的唯一逻辑就是取调用自己的 loadClass()。

        上一节我们已经看过 loadClass() 方法的代码&#

以上是关于虚拟机类加载机制的主要内容,如果未能解决你的问题,请参考以下文章

虚拟机类加载机制

《深入理解JVM——虚拟机类加载机制》

《深入理解JVM——虚拟机类加载机制》

虚拟机类加载机制详解

虚拟机类加载机制

Java虚拟机类加载机制