Skip to content

Latest commit

 

History

History
458 lines (342 loc) · 30.6 KB

2018-01-04.虚拟机类加载机制.md

File metadata and controls

458 lines (342 loc) · 30.6 KB

一 类加载时机

1.1 类生命周期的七个阶段

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

如图所示,加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照步骤按部就班的开始,但是解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定。这里需要注意的是,这几个阶段是按部就班的开始,并不是按部就班的“进行”,因为这些阶段通常都是互相交叉地混合式的进行的,通常会在某一个阶段的执行过程中激活另一个阶段。

1.2 类初始化的五个时机

对于什么时候执行“加载”阶段,Java 虚拟机规范中并没有进行强制约束,这点可以交给虚拟机自己把握。但是对于初始化阶段,虚拟机规范则严格规定了有且只有 5 中情况必须立即对类“初始化”。

  1. 对于 new 关键字实例化对象、读取或设置一个类的静态字段 (编译期常量除外)、调用一个类的静态方法,在这时如果没有对类初始化过,则先对其进行初始化。
  2. 使用反射机制的时候,如果类没有进行过初始化,则先触发其初始化。
  3. 在初始化一个子类的时候,如果发现其父类没有初始化,则先触发其父类的初始化。反过来如果在对父类进行初始化的时候,不会触发对其子类的初始化。
  4. 虚拟机启动时,用户需要指定一个一个要执行的主类 (包含 main 方法的类),虚拟机会先初始化这个主类。
  5. 在使用 jdk1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REP_getStatic、REP_putStatic、REP_invokeStatic 的方法句柄,并且这个方法句柄对应的类没有初始化过,则先触发对其进行初始化。

对于这 5 种会触发进行初始化的场景,虚拟机规范中使用了一个强烈的限定域:“有且只有”这 5 中场景中的行为称为对一个类进行主动引用。除此之外,所有类的引用类的方式都不会触发其初始化,因此也被称为对一个类的被动引用。下面是关于被动引用的几个例子。

通过子类引用父类的静态字段,不会导致子类初始化。下面的代码运行后并不会输出"SubClass init",对于静态字段,在调用时只有直接定义这个字段的类会被初始化。

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

class SuperClass{
    static {
        System.out.println("SuperClass init");
    }
    static int value = 1;
}
class SubClass extends SuperClass{
    static {
        System.out.println("SubClass init!");
    }
}

//输出
//SuperClass init
//1

对于编译期常量的调用不会触发其类的初始化。下面的代码并没有输出“ConstClass init!”,因为字段 VALUE 是一个恒定初始值 (即编译期常量)。如果调用的是 value2 ,则会初始化类 ConstClass。

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(ConstClass.VALUE );
    }
}

class ConstClass{
    public static final int VALUE = 1;
    //static int value2 = 2;
    static {
        System.out.println("ConstClass init!");
    }
}

//输出
//1

通过数组定义来引用类,不会触发此类的初始化。

public class NotInitialization {
    public static void main(String[] args) {
        ArrayClass[] array = new ArrayClass[10];
    }
}

class ArrayClass{
   static {
       System.out.println("ArrayClass init!");
   }
}

//输出
//

二、类加载过程

2.1 加载

“加载”是“类加载"(Class Loading) 过程中的一个阶段。在类加载阶段,虚拟机主要完成下面 3 件事情:

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

虚拟机规范中的这 3 点要求并不算具体,因此为虚拟机提供了更大的灵活度,所以想要获取一个 Class 的二进制字节流方法是多样的。比如:从 ZIP 包中获取、从网络中获取、从使用代理产生的代理类获取、从数据库中获取、从其他文件中获取 (典型的 JSP 文件对应的 Class 类) 等。

相对于数组而言,由于数组本身不通过类加载器创建,它是由虚拟机直接创建的。所以数组在加载过程中情况会与普通类不同。但是数组与类加载器仍然有着密切的关系,因为数组的元素类型最终要靠类加载器创建。所以一个数组类在创建过程中就要遵循以下规则:

  1. 如果数组的组件类型是引用类型,那么会以递归的方式去加载这个数组的组件类型,数组将在加载该组件类型的类加载器的类名称空间上被标识。
  2. 如果数组的组件类型不是引用数据类型,Java 虚拟机会把数组标记为与引导类加载器相关联。
  3. 数组类的可见性与它的组件类型的可见性一致,如果数组类型不是引用类型,那么该数组类的可见性将默认为 public。

类加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自定义,虚拟机规范中并未规定此区域的具体类型结构。然后在内存中实例化出一个 java.lang.Class 对象 (对于 HotSpot 虚拟机,该 Class 对象存储在方法区),这个对象将作为程序访问方法区中这些数据类型的外部接口。

加载阶段与连接阶段的部分内容 (如字节码格式的验证动作) 是交叉进行的,加载阶段可能尚未完成,连接阶段就已经开始,但使这些夹杂在加载阶段的动作,仍然是连接阶段的内容,在时间顺序上,仍然是加载阶段先于连接阶段。

2.2 验证

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

验证阶段是非常重要的,这个阶段是否严谨,直接决定了 Java 虚拟机是否能够承受恶意代码的攻击,从执行性能角度上,验证阶段的工作量在虚拟机的类加载子系统中占据了相当大的一部分。从整体上将,验证阶段大致上会完成 4 个阶段的验证动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

  1. 文件格式验证 第一阶段要验证字节流是否符合 Class 文件格式的规范,并能被当前版本的虚拟机处理。这个阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区进行存储,后面的 3 个验证动作全部是基于方法区的存储结构进行的,而不再是操作字节流。由于这一阶段验证点很多,下面列出其中一部分供大家参考 (其中如果有不理解的可以对应了解一下 Class 的类文件结构)。如下: 是否以魔数 0xCAFEBABE 开头。 主、次版本号是否在虚拟机的允许范围内。 常量池的常量中是否有不被支持的数据类型。 ............................

  2. 元数据验证 第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。这个阶段可能包括的一些验证点如下: 这个类是否有父类 (Object 类除外)。 这个类是否继承了不能被继承的类 (final 修饰的类)。 类中的方法、字段是否与父类相冲突。 ............................

  3. 字节码验证 第三阶段是整个验证过程中最复杂的一个阶段,主要是通过数据流与控制流分析,确定程序语义是合法的、符合逻辑的。在对第二阶段的元数据信息中的数据类型校验后,这个阶段将对类的方法进行校验分析,保证被校验类的方法在运行时不会危害虚拟机。比如: 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作。 保证跳转指令不会跳转到方法体外的字节码指令上。 保证方法体中的类型转换是有效的。 ............................

如果一类的字节码没有通过字节码验证,那肯定是有问题的;但是一个方法体通过了字节码验证,也不能说明一定是安全的。即使是通过了大量的检查,也不能完全保证是安全的。这涉及到了一个关于离散数学中的问题"Halting Problem":通过程序去校验程序逻辑是无法做到绝对准确的——不能通过程序准确检查出程序是否能在有效的时间内运行完。

  1. 符号引用验证 最后一个阶段的校验发生在虚拟机符号引用转变为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段发生。符号引用校验可以看作是对类自身以外的信息进行匹配性校验 (常量池中的各种符号引用),主要有: 符号引用中通过字符串描述的全限定名是否能够找到具体的类。 在指定类中是否存在符合方法的字段描述以及简单名称所描述的方法和字段。 符号引用中的类、字段、方法的访问性是否可以被当前类访问。 ............................

符号引用检验的目的是确保解析动作的正常执行,如果无法通过符号校验将会抛出 Java.lang.NoSuchAccessError、java.lang.NoSuchFieldError 等。

对于虚拟机的类加载机制而言,验证阶段是一个非常重要的,但并不是必要的阶段。如果所有的代码都已经反复使用和验证过,那么可以使用-Xverify:none 参数来关闭验证措施,以缩短虚拟机加载的时间。

2.3 准备

准备阶段是正式为变量分配内存并设置默认初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易混淆的概念,首先这时候进行内存分配的仅包括类变量 (被 static 修饰的变量),并不包括实例变量,实例变量将在对象实例化的时候随着对象一起在 Java 堆中分配内存。其次,这里所说的初始值是指数据类型的零值 (对应数据类型的基本初始值),比如:

public static int value = 1;

变量 value 在准备阶段过后的初始值是 0 并不是 1,因为这时候还未开始执行 Java 的任何方法,把 value 赋值为 1 的指令是在程序被编译后,存放于类构造器 < clinit>() 方法中。所以把 value 赋值为 1 的动作在类初始化阶段才会执行。这点要特别注意。

有一些特殊情况:如果这个变量时编译期常量 (被 static final 修饰),那么再准备阶段 value 就会被赋予一开始所指定的值,如下。编译时将为 VALUE 生成 ConstantValue 属性,所以虚拟机将在准备阶段就将 VALUE 的值设置为 1,而不是 0。

public static final int VALUE = 1;

2.4 解析

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

虚拟机规范中并未规定解析阶段发生的时间,虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用多次才去解析它。

对同一个符号引用进行多次解析请求时很常见的事情,除 invokedynamic 指令以外,虚拟机可以对第一次解析的结果进行缓存 (在运行时常量池中记录直接引用,并把常量标识为以解析状态) 从而避免重复进行。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行 (这里不作详细的介绍,有兴趣的可以查阅相关资料)。

2.5 初始化

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

在准备阶段,变量已经赋值过一次系统要求的初始值,而在初始化阶段将按照程序员的意愿去初始化类变量和其他资源。初始化过程是执行类构造器 < clinit>() 方法的过程。

< clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。如下:

class Test{
   static {
       value = 2;    //在编译期间可以通过
       System.out.println(value);    //编译器报"非法向前引用错误" Illegal forward reference
   }
   static int value = 1;
}

< clinit>() 方法与类的构造函数 (实例构造器 < init>() 方法) 不同,它不需要显示地调用父类的构造器。虚拟机会保证在子类的 < clinit>() 方法执行之前,父类的 < clinit>() 方法 已经执行完毕。因为在虚拟机中第一个被执行的 < clinit>() 方法肯定是 Object。 由于父类的 < clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值动作。如下:

public class Test{
    public static void main(String[] args) {
        System.out.println(SubClass.value2);
    }
}

class SuperClass{ 
    static int value = 1;
    static {
       value = 2;
   }
}
class SubClass extends SuperClass{
    public static int value2 = value;
}

//输出
//2

< clinit>() 方法对于类或接口来说并不是必要的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 < clinit>() 方法。

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 < clinit>() 方法。但是接口与类不同的是,执行接口的 < clinit>() 方法不会先执行父接口中的 < clinit>() 犯法。只有当父接口中的变量被使用时,父接口才会初始化。

虚拟机会保证一个类的 < clinit>() 方法 在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 < clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行 < clinit>() 方法结束。当其他线程被唤醒后,不会在进入 < clinit>() 方法,所以在同一个类加载器下,一个类型只会初始化一次。

相信看到这里的小伙伴一定会感到很亲切,因为我们在写单例模式的懒汉式时通常会将对象定义为 static 的,来保证这个对象只会被加载一次,还有在加载一些资源的时候我们通常也将代码写在静态代码块里。

public class Test{
    public static void main(String[] args) {
       Runnable runnable = () -> {
           System.out.println(Thread.currentThread().getName() + " start");
           DeadLoopClass deadLoopClass = new DeadLoopClass();
           System.out.println(Thread.currentThread().getName() + " end");
       };
       new Thread(runnable).start();
       new Thread(runnable).start();
    }
}

class DeadLoopClass{ 
    static {    //静态代码块只会被初始化一次
       System.out.println(Thread.currentThread().getName() + " init static block");
   }
}

//输出
//Thread-0 start
//Thread-0 init static block
//Thread-0 end
//Thread-1 start
//Thread-1 end

2.6 小总结

到这里我们已经知道了类加载的时机以及加载过程中的执行步骤,下面我们来看一个有意思的题目 (据说是一个很多 Java 程序员都会犯的一个错误)。这个题目能够很好的总结上面的知识。

public class Test{
    public static void main(String[] args) {
        //调用 Singleton 的静态方法触发对 Singleton 的初始化
        Singleton singleton = Singleton.getSampleInstance();    
        System.out.println("num1 = " + singleton.num1);
        System.out.println("num2 = " + singleton.num2);
    }
}

class Singleton {
//①    private static Singleton singleton = new Singleton();
    public static int num1;
    public static int num2 = 0;
//②    private static Singleton singleton = new Singleton();
    
    private Singleton() {
        num1 ++;
        num2 ++;
    }
    public static Singleton getSampleInstance() {
        return singleton;
    }
}

//输出
//①
//num1 = 1
//num2 = 0

//②
//num1 = 1
//num2 = 1

接着就来分析一下这个题目,在 main 方法中调用 Singleton 中的静态方法会触发 Singleton 类初始化。

对于①而言,在准备阶段 singleton 为默认初始值 null,num1 与 num2 都是默认的初始值 0(num2 为 0 并不是将等号右边的 0 的值赋给了 num2),在初始化阶段,会先根据顺序先初始化 singleton,这时候会调用 Singleton 的构造函数将 singleton 初始化为一个实例对象,这时 num1 与 num2 的值为 1,接着继续接着执行初始化,由于 num1 没有被赋予任何值,所以 num1 仍然为 1,但是 num2 却由原来的 1 被初始化为 0。

对于②而言,在准备阶段 singleton 为 null,num1 与 num2 为 0,后来在进行初始化的时候,num1 仍然为默认初始值的 0,num1 也被初始化成了 0。接着开始初始化 singleton,这时调用 Singleton 的构造函数,将 num1 与 num2 的值都自增了 1,所以 num1 与 num2 的值都变成了 1。

三、类加载器

3.1 类加载器概述

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

类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器都拥有一个独立的类名称空间。

比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下比较才有意义,否则,即使这两个类来自同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类也必定不相等。 这里指的“相等”,包括代表类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果。如下演示了不同的类加载器加载同一个 Class 与同一个加载器加载同一个 Class 的结果。

public class ClassLoaderTest {
    public static void main(String[] args) throws ClassNotFoundException {
        ClassLoader myloader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.indexOf(".") + 1) + ".class";
                    InputStream in = getClass().getResourceAsStream(fileName);
                    if(null == in)
                        return super.loadClass(name);
                    byte[] bytes = new byte[in.available()];
                    in.read(bytes);
                    return defineClass(name, bytes, 0, bytes.length);
                }catch (IOException e){
                    throw new ClassNotFoundException(name);
                }
            }
        };
        
        //**使用自定义的类加载器去加载 com.jas.classloader.ClassLoaderTest 类**
        Object obj = myloader.loadClass("com.jas.classloader.ClassLoaderTest");
        System.out.println(obj);
        System.out.println(obj instanceof com.jas.classloader.ClassLoaderTest);

		//**使用系统默认的应用类加载器加载 com.jas.classloader.ClassLoaderTest 类**
        ClassLoaderTest classLoaderTest = new ClassLoaderTest();
        System.out.println(classLoaderTest instanceof com.jas.classloader.ClassLoaderTest);
    }
}

//输出
//class com.jas.classloader.ClassLoaderTest
//false
//true

上面的代码中构造了一个简单的类加载器,它可以加载同一路径下的 Class 文件。我们让这个类加载器去加载“com.jas.classloader.ClassLoaderTest”类,并实例化了这个对象。从输出结果可以对比出,obj 这个类确实是 com.jas.classloader.ClassLoaderTest 实例化出来的类,但是在对这个对象与类 com.jas.classloader.ClassLoaderTest 做所属类型检查的时候却返回了 false,这是因为虚拟机中存在两个 ClassLoaderTest 类,一个是由系统引用程序类加载器加载的,另一个是自定义类加载器加载的,尽管它们都来自同一个 Class 文件,但完全是两个独立的类。

3.2 类加载器分类

从 Java 虚拟机角度讲,只存在两种不同的类加载器:一种是启动类加载器 (Bootstrap ClassLoader),这个类加载器使用 c++ 语言实现,是虚拟机的一部分;另一种是所有其他非启动类加载器的所有加载器,这些加载器由 Java 语言实现,独立于虚拟机外部,并且都继承自抽象类 java.lang.ClassLoader。

启动类加载器 (Bootstrap ClassLoader):这个类加载器负责将存放在 < JAVA_HOME>\lib 目录的,或者被-Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义的类加载器时,需要把加载请求委派给引导类加载器,那直接使用 null 代替即可。

扩展类加载器 (Extension ClassLoader):这个加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 < JAVA_HOME>\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

应用程序类加载器 (Application ClassLoader):这个加载器由 sun.misc.Launcher$AppClassLoader 实现。这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也被称为系统类加载器。它负责加载用户类路径上的 (classPath) 上所指定的类库,开发者可以直接使用这个类加载器,如果引用程序中没有自定义过类加载器,一般情况下就是程序种默认的类加载器。

下面我们通过程序来感受一下这 3 种类加载器,来加深理解。

import sun.nio.cs.ext.Big5;
import sun.security.ec.ECDHKeyAgreement;

public class ClassLoaderTest {
    public static void main(String[] args) throws ClassNotFoundException {
        //**是 <JAVA_HOME>\lib 中的一个类**
        Big5 big5 = new Big5();
        System.out.println(big5.getClass().getClassLoader());  
        
        //**是 <JAVA_HOME>\lib\ext 中的一个类,我也不晓得这个类是干嘛的~**
        ECDHKeyAgreement eCDHKeyAgreement = new ECDHKeyAgreement();
        System.out.println(eCDHKeyAgreement.getClass().getClassLoader());
        
        //**自定义的类,默认的加载器一般是应用程序类加载器**
        ClassLoaderTest classLoaderTest = new ClassLoaderTest();
        System.out.println(classLoaderTest.getClass().getClassLoader());
    }
}

//输出
//null
//sun.misc.Launcher$ExtClassLoader@5cad8086
//sun.misc.Launcher$AppClassLoader@18b4aac2

从上面输出结果我们可以知道:由于启动类加载器无法被 Java 程序直接引用,所以输出的类加载器的引用为 null;对于 <JAVA_HOME>\lib\ext 下的类确实使用的是扩展类加载器;ClassLoaderTest 是我们自定义的一个类,它使用的默认加载器是应用程序类加载器。

四、双亲委派模型

4.1 双亲委派模型概述

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

上图中所展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型 (Parents Delegation Model)。类加载器的双亲委派模型在 jdk1.2 期间被引用并广泛应用于几乎所有的 Java 代码中。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合 (Composition) 关系来复用类加载器的代码。类加载器的双亲委派模型并不是一个强制性的约束模型,而是 Java 推荐给开发者的一种类加载器实现方式。

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

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如 Object,它存放在 rt.jar 中,无论哪一个类加载器去加载这个类,最终都会委派给最顶端的启动类加载器去加载,因此 Object 类在程序的各个类加载器环境中表达的都是同一个类。这样可以保证 Java 类型体系中最基础的行为,从而使程序变得统一。

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

 protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            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 thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    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;
        }
    }

4.2 破坏双亲委派模型

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

第一次“被破坏”出现在 jdk1. 2 之前 (当时双亲委派模型还没有出现)。但是类加载器和抽象类 java.lang.ClassLoader 则在 jdk1.0 时代就已经存在,所以在 jdk1.2 之前是不存在双亲委派模型的。

第二次“破坏”是由这个模型本身的缺陷所引起的。一个典型的例子就是 JNDI 服务,它的启动类由类加载器去加载 (jdk1.3 时放进去的 rt.ajr),但是 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的 ClassPath 下的 JNDI 接口提供者的代码,但是类加载器并不认识这些代码。后来 Java 设计团队引入了一个不太优雅的设计:线程上下文类加载器 (Thread Context ClassLoader) 来解决问题。

第三次“被破坏”是由于用户对程序动态性的追求导致的,这里的动态性指的是:代码热替换 ()HotSwap、模块热部署 (Hot Deployment) 等。

参考

参考书籍:《深入理解 Java 虚拟机》周志明 著