深入理解Java虚拟机-第七章 虚拟机类加载机制

第七章 虚拟机类加载机制

7.1 概述

在 Java 语言里面,类型的加载、链接和初始化过程都是在程序运行期间完成的,这种策略虽然会使类加载增加一些性能开销,但是会提供高度的灵活性。例如编写一个接口,可以等到运行的时候再指定其实际的实现类。用户可以通过 Java 预定义的和自定义类加载器,让一个本地的应用程序可以再运行时从网络或其他地方加载一个二进制流作为程序代码的一部分。

7.2 类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括以下几步:加载、验证、准备、解析、初始化、使用、卸载。其中检查、准备、解析三大步骤被称为连接(所谓动态连接说的也是这一部分的东西):
类的生命周期
图中,加载、验证、准备、初始化和卸载这 5 个步骤的顺序是确定的,类的加载过程必须按照这五个步骤按部就班的开始。而解析不一定,它在某些情况下可以在初始化后开始(即动态绑定或晚期绑定)。这里作者说的是按部就班的开始而不是进行,这是因为这些阶段通常都是相互交叉的混合式进行的。通常会在一个阶段执行的过程中调用、激活另外一个阶段(后面的文章应该会提到,这里留个疑点)。
那么加载、验证等 5 个步骤一定是以加载为开始。那么加载什么时候开始呢,虚拟机并没有强制约束,有规定了有且只有 5 种情况必须立即执行类的初始化(而加载、验证、准备自然在他之前):

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

对于这 5 类能触发初始化的操作,虚拟机规范使用了一个强限定词“有且只有”,这 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,叫被动引用。下面有几个例子,以此证明:

public class SuperClass {
    public static String value = "3";

    static {
        System.out.println("Super Class Init!");
    }
}

public class SubClass extends SuperClass {
    static {
        System.out.println("Sub Class Init!");
    }
}

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

}

加上 -XX:+TraceClassLoading 参数并执行,执行结果在预期之中:
执行结果
细心的朋友可能发现了,这里 SubClass 不是已经加载了吗,为什么没打印 init 呢,你代码肯定出 bug 了!不是哦,static 块中的方法,只有在类进行到 初始化 步骤时才会执行,而且我们实验的也是 初始化 步骤,老铁没毛病哦~
也许还有朋友发现,我的代码跟书上的不太一致(谁特么看你的博客还去跟书对比去,不要脸),书上的 value 变量是 int 型,而我这是 String 型。不知道大家对上文提到过的 被final修饰、已在编译期把结果放入常量池的静态字段除外 这句话有没有印象,我起了兴趣证实了一下并找到了原理,为什么是 String 去验证后面再说,先上代码。

public class SuperClass {
    public static final String value = "3";

    static {
        System.out.println("Super Class Init!");
    }
}

其实就是给父类变量加了个 final 修饰词,期望证明被 final 关键字修饰后的常量可直接使用,不再调用类加载。执行结果依旧在意料之中:
执行结果
这里不知道大家有没有发现,不光父类没有初始化,父类和子类连加载也没加载。无奖问答,有没有人知道原理~
这里其实通过 javap -verbose 看一眼反编译代码就懂了:
反编译
ldc 的意思是将 int、float 或 String 类型常量从常量池推至栈顶,这里直接引用了一个常量 3 (如果这里是 int 的话,会是 iconst_3 ,不太直观,所以此处用 String )。什么意思呢,就是说在编译的时候,这个 final 修饰的常量就会被编译到调用类(InvokeClass)的常量池中,所以调用的时候不需要加载定义类(SuperClass)。
这里会有一个坑大家可能会踩,就是说当更新 定义类 的时候,调用类也要同步更新,否则会造成数据不一致的问题。举个例子:A 类里面有个 final 修饰的整形变量 value 值为1 。B 类打印了 A.value;这时打印出来的是 1 。但是,将 A 类中的 value 修改为 2 后,仅编译更新 A 类后再次执行 B 时,打印出来的还是 1 。这就是仅更新定义类未更新调用类的坑。原理同上,B 在编译的时候就把 1 编译进去了,A 再怎么修改只要不重新编译 B,B 打印出来的 A.value 永远是 1。

类和接口真正有所区别的是 当一个类在初始化时要求其父类全部都已经初始化了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(例如引用接口中定义的常量)才会初始化。

7.3 类加载的过程

7.3.1 加载

加载 是类加载的一个过程,两者不可混为一谈。加载大致需要完成下列三件事情:

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

虚拟机规范中这三条实际上要求并不算具体,因此虚拟机实现与具体应用的灵活度都是很大的,例如第一条,并没有限制从哪读。也可以从 .class 文件中,也可以在 jar 包中,甚至可以从网络中、运行时、数据库、其余类型文件中获取。
相对于累加在过程的其他阶段,一个非数组类的加载阶段是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器进行加载,也可以由开发人员自定义类加载器进行加载(重写 loadClass() 方法)。
对于数组类来说,数组类本身不通过类加载器创建,而是由 Java 虚拟机直接创建的。但是数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(指的是数组去掉所有维度的类型)最终是要靠类加载器去加载。一个数组类的创建过程遵循以下规则:

  • 如果数组的组件类型(指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程区加载这个组件类型。数组将在加载该组件类型的类加载器的类名称空间上被标识(7.4节详细介绍)
  • 数组的组件类型不是引用类型,Java 虚拟机将会把数组标记为与引导类加载器(Bootstrap ClassLoader)关联。
  • 数组类的可见性与他组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为 public 。

加载完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中。方法区中的数据存储格式由 虚拟机实现 自行 定义。然后再内存中实例化一个 java.lang.Class 对象(并不一定就在 Java 堆中,对于 HotSpot 虚拟机而言,它存在方法区中)
加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段未完成连接阶段已经开始,但是这些夹在加载阶段中进行的连接动作,仍属于连接阶段的内容。这两个阶段的开始时间仍然保持着固定的先后顺序。这就是最开始说的 按部就班的开始而不是进行

7.3.2 验证

验证是连接阶段的第一步骤,这一阶段的目的是保证 Class 文件的字节流中包含的信息符合当前虚拟机的需求,并且不会危害虚拟机自身的安全。Java 语言本身是相对安全的语言,使用纯粹的 Java 代码无法做到如访问数组边界以外的数据等事情,但是虚拟机加载的不只是纯粹的 Java 代码编译而成的 class 文件,他有可能是任何途径生成的字节流。虚拟机如果不检查输入的字节流,而是对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一种措施
验证阶段是非常重要的,大致会分为 4 个阶段:

  1. 文件格式验证:这个阶段主要是校验字节流是否是符合 Class 文件格式的规范。例如:

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

    实际上,第一阶段的验证点远不止这些,这只是摘取了部分,这个阶段的验证是基于二进制字节流进行的验证,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储。所以后面三个阶段的验证都是基于方法区的存储结构进行的,不再直接操作字节流。

  2. 元数据验证:这个阶段可能包括的验证点如下:

    • 这个类是否有父类(除了 java.lang.Object 之外,其余类都应当有父类)
    • 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)
    • 如果这个类不是抽象类,那么是否实现了其父类或接口之中要求实现的所有方法。
    • 类中的字段是否字段、方法等是否与父类产生矛盾(例如覆盖了父类的 final 字段,出现不和规则的方法重载等)。
      ……

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

  3. 字节码验证:这个阶段是整个验证过程中最复杂的一个步骤,主要目的是通过数据流和控制流分析,确定程序语义是合法、符合逻辑的,例如:

    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现类似操作栈中放了一个 int 类型的数据却在使用时按照 long 类型载入本地变量表的。
    • 保证跳转指令不会跳转到方法体以外的字节码指令上。
    • 保证方法体重的类型转换是有效的,例如上转型对象。

    如果一个类方法体没通过字节码验证,那么他一定是不安全的,但是通过了却不代表他一定安全(Halting Problem,有兴趣的同学可自行了解)。
    由于数据流验证的高复杂性,虚拟机设计团队为避免过多的时间消耗在字节码验证阶段,在 JDK 1.6 之后的 Javac 编译器和 Java 虚拟机中进行了一项优化,给方法体的 Code 属性的属性表中增加了一项名为 StackMapTable 的属性。这项属性描述了方法体中所有的基本块(Basic Block,按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,仅需检查 StackMapTable 属性中的记录是否合法即可。这样将字节码验证的类型推到转变为类型检查从而节省一些时间。
    为了说明这个 StackMapTable 是个啥,写了一个简单的 demo 我们来反编译看看:

    public class ClassCheck {
        public void demo(int param) {
            if (1 == param) {
                System.out.println(1);
            } else {
                System.out.println(2);
            }
        }
    }
    

    反编译的结果是:
    反编译
    详细说明他具体是个啥可能要再开篇博客才能讲清楚,这里先引用一篇别人的:《JVM StackMapTable 属性的作用及理解》

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

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

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这里强调下两个概念,类变量指的是被 static 修饰过的变量,而不是实例变量。初始值通常情况下指的是该数据类型的 0 值(如果是 final 修饰的,则编译的时候 Javac 会生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123),例如:

public static int value = 123;

变量 value 在准备阶段后的的初始值是 0 而不是 123,因为这个时候尚未开始执行任何的 Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器 <clinit>() 方法中,所以把 value 赋值为 123 的操作将在初始化阶段才会执行

7.3.4 解析

解析阶段就是将符号引用变为直接引用:

  • 符号引用:符号引用就是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要能无歧义的定位到目标即可。他跟内存布局无关,引用的目标不一定已经加载到内存中了
  • 直接引用:直接引用可以使直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄 ,直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机上翻译出来的直接引用一般不一样。如果有了直接引用,那么引用的目标一定已经在内存中存在。

虚拟机规范中没有规定解析阶段发生的具体时间,只要求了在执行 Java 虚拟机指令 anewarray、checkcast、getfield、getstatic、instanceof、nvokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield 和 putstatic 这 16 条指令前都需要对它的符号引用进行解析。
对于同一个符号引用进行多次解析请求是很常见的事情,除了 invokedynamic 指令外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用并把常量标注为已解析状态)从而避免解析动作重复进行。虚拟机需要保证如果一个符号引用已经成功解析过,那么后续的引用解析请求就应当一直成功;同样的,如果第一次失败了那么其他指令对这个符号的解析请求也应该受到相同的异常(此处对于 invokedynamic 指令不成立,而目前仅使用 Java 语言不会生成这条字节码指令)。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。

  1. 类或接口的解析:假设当前代码所处类为 D,如果要把一个从未解析过的符号引用 N 解析为一个接口 C 需要经过以下几个步骤:

    • 如果 C 不是一个数组,那么虚拟机就会将代表 N 的全限定名传给 D 类的类加载器去加载 C 类。
    • 如果 C 是一个数组(例如 [java.lang.Integer ),那么虚拟机先将数组的元素对象交由 D 类的类加载器去加载,然后由 JVM 生成一个代表此数组维度和元素的数组对象。
    • 如果上面的步骤都没报错,那么就算解析成功,不过还要经过符号引用验证,校验是否有访问权限
  2. 字段解析:如果要解析一个字段,首先需要对字段表中 class_index(详情可参看本栏文章 《深入理解Java虚拟机-附件1 常量池中的 14 种常量项的结构总表》中 CONSTANT_Fieldref_info 相关内容)项中索引的 CONSTANT_Class_info 符号引用进行解析,即字段所属类、接口的符号引用(下称 C ),解析成功后进行如下几步:

    • 如果 C 本身包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    • 否则,如果在 C 中实现了接口或继承了父类,则按照继承关系从下往上递归查找,如果找到包含了简单名称和字段描述符都与目标相匹配的字段,则返回此字段的直接引用
    • 如果都没找到,那么就直接抛出 java.lang.NoSuchFieldError 异常。
    • 查找到后,对此字段进行权限验证,看是否有资格访问。

    注意,如果父类(包括父类的父类等)和实现的接口中都有此字段,那么编译器会提示 The Field Sub.A is ambiguous 并拒绝编译这段代码。如下所示:

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

    到此为止可以正常编译,但是如果将 Sub 中的变量 A 删掉的话,编译器就会报错如下图所示
    在这里插入图片描述

  3. 类方法解析:类方法解析只比字段解析多了一步预检查,他会先看 class_index 中索引的 C 是个接口还是类,如果不是类的话,直接抛出 java.lang.IncompatibleClassChangeError 异常。

  4. 接口方法解析:跟类方法解析一致,先检查 class_index 重索引的 C 是不是个接口,不是的话直接抛出 java.lang.IncompatibleClassChangeError 异常。

剩余的三种解析,等学完 invokedynamic 指令再说。

7.3.5 初始化

类初始化是类加载过程的最后一步。前面的加载、验证、准备、解析四大过程,除了加载时可以通过用户自定义类加载器参与之外,其余的动作都是完全由虚拟机主导和控制的。到了初始化阶段才开始执行类中定义的 Java 程序代码(或者说字节码)。
初始化阶段是执行类构造器 <clinit>() 方法的过程。我们来看一下方法执行过程中的特点和细节:

  • <clinit>() 方法是由编译器自动收集类中的所有 类变量的复制动作 和 静态语句块(static{})中的语句 合并产生的。编译器收集的顺序是按照语句在原文件中出现的顺序决定的,所以静态语句块只能使用定义在他后面的静态变量,当然,这里赋值是可以给后面的变量赋值的,因为在解析期就把这些字段初始化好了,但是解析期还没赋值所以用不了。
  • <clinit>() 方法与构造方法(<init>())不同,他不需要显式的调用父类构造器,虚拟机会保证子类 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完毕。这里要特别说明一下接口类,因为接口类没有静态块语句,但是也有变量赋值,所以也有 <clinit>() 方法。但是与正常类不同的是,接口的 <clinit>() 方法执行不需要先执行其父类接口的 <clinit>() 方法,只有当用到父类接口中定义的变量的时候,父类接口的 <clinit>() 方法才会被执行。同理,接口实现类的 <clinit>() 方法执行的时候,也不会执行接口的 <clinit>() 方法。
  • 父类的 <clinit>() 方法先执行,所以父类的静态块要比子类先执行
  • <clinit>() 方法对类或接口不是必须的,如果没有静态方法块和对类变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。
  • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确的上锁、同步。如果多个线程同时去访问,只有一个线程可以进去执行 <clinit>() 方法,剩余的线程都会被阻塞直到活动线程将 <clinit>() 方法执行完毕。

7.4 类加载器

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

7.4.1 类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器都拥有一个独立的类名称空间。也就是说,即使两个类是来源于同一个 Class 文件,被同一个 JVM 加载,如果他们的类加载器不同,这两个类就必定不相等。
这里所指的相等包括了代表类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果。例如如下代码所示:

public class ClassLoaderDemo {
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                InputStream inputStream = null;
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    inputStream = getClass().getResourceAsStream(fileName);
                    if (null == inputStream) {
                        return super.loadClass(name);
                    }

                    byte[] bytes = new byte[inputStream.available()];
                    inputStream.read(bytes);
                    return defineClass(name, bytes, 0, bytes.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException();
                } finally {
                    if (null != inputStream) {
                        try {
                            inputStream.close();
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        };

        Object o = myLoader.loadClass("com.simon.classload.ClassLoaderDemo").newInstance();
        System.out.println(o.getClass());
        System.out.println(o instanceof com.simon.classload.ClassLoaderDemo);
        System.out.println(o.getClass().equals(com.simon.classload.ClassLoaderDemo.class));

    }
}

大家猜下结果是什么~~~~公布答案:
运行结果
虽然我们的实例确实是类 ClassLoaderDemo 实例化出来的,但是因为类加载器的不同,我们自写的类加载器和系统类加载加载进来的类对象就是不一样。虽然都是一个 class 文件加载进来的,但依然是两个独立的类。
对于 JVM 来说,只存在两种不同的类加载器:一种是启动类加载(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由 Java 语言是吸纳,独立于虚拟机外部并全部继承自 java.lang.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.lang.ClassLoader 接口并重写 loadClass() 方法。
7.4.2 双亲委派模型(Parents Delegation Model)

上文介绍了四种类加载器,我们的应用程序都是用这四种加载器互相配合进行加载的。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应带有自己的父类加载器。如图:
双亲委派模型
双亲委派模型的概念其实很简单:如果一个类加载器收到了类加载的过程,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,没一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器其中,只有当父类反应自己无法完成这个家在请求(它的搜索范围中没有找到所需的类)时,子加载器才会去尝试自己去加载。
可以用网上举的一个例子来简单概述:小明想买一个玩具车,他只能先去问爸爸(Application ClassLoader):“爸爸你有玩具车吗?”,爸爸回复:“等会儿,我去问问你爷爷”,然后转身问爷爷(Extension ClassLoader):“爸爸你有玩具车吗?”,爷爷回复:“等会儿,我去问问你爷爷”,然后转身问太爷爷(Bootstrap ClassLoader):“爸爸你有玩具车吗?”。太爷爷说:“我这没有,你自己买去吧!”,爷爷说:“好嘞” 然后转身对爸爸说:“我这没有,你自己买去吧!”,爸爸说:“好嘞” 然后转身对小明说:“我这没有,你自己买去吧!”。这时,小明才可以自己去买玩具车,但凡祖上三辈有一个人有玩具车,小明都只能玩旧的不能买新的。
使用双亲委派模型来阻止类加载器之间的关系有个显而易见的好处就是 Java 类随着他的类加载器一起具备了一种带优先级的层次关系。例如 java.lang.Object,它存放在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载。因此 Object 类在程序的各种累加载器环境中都是同一个类。如果没使用双亲委派模型,用户自己写了个 Object 类加载到 JVM 中,应用程序就乱套了。
双亲委派模型的代码都集中在 java.lang.ClassLoader 的 loadClass() 中,如下所示:

  /**
    * Loads the class with the specified <a href="#name">binary name</a>.  The
    * default implementation of this method searches for classes in the
    * following order:
    *
    * <ol>
    *
    *   <li><p> Invoke {@link #findLoadedClass(String)} to check if the class
    *   has already been loaded.  </p></li>
    *
    *   <li><p> Invoke the {@link #loadClass(String) <tt>loadClass</tt>} method
    *   on the parent class loader.  If the parent is <tt>null</tt> the class
    *   loader built-in to the virtual machine is used, instead.  </p></li>
    *
    *   <li><p> Invoke the {@link #findClass(String)} method to find the
    *   class.  </p></li>
    *
    * </ol>
    *
    * <p> If the class was found using the above steps, and the
    * <tt>resolve</tt> flag is true, this method will then invoke the {@link
    * #resolveClass(Class)} method on the resulting <tt>Class</tt> object.
    *
    * <p> Subclasses of <tt>ClassLoader</tt> are encouraged to override {@link
    * #findClass(String)}, rather than this method.  </p>
    *
    * <p> Unless overridden, this method synchronizes on the result of
    * {@link #getClassLoadingLock <tt>getClassLoadingLock</tt>} method
    * during the entire class loading process.
    *
    * @param  name
    *         The <a href="#name">binary name</a> of the class
    *
    * @param  resolve
    *         If <tt>true</tt> then resolve the class
    *
    * @return  The resulting <tt>Class</tt> object
    *
    * @throws  ClassNotFoundException
    *          If the class could not be found
    */
   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;
       }
   }

上述代码逻辑清晰易懂,先检查是否已被加载过,若没有加载则调用父加载器的 loadClass() 方法,如果父加载器为空则默认使用启动类加载器作为父加载器。如果父加载器加载失败,抛出 ClassNotFoundException 异常后,在调用自己的 findClass() 方法进行加载。

7.4.3 破坏双亲委派模型

书中讲了三个被破坏的阶段,分别是

  • JDK 1.2 发布之前
  • 类似 JNDI 这种底层服务调用用户代码
  • 用户对程序动态性的追求,为了实现热插拔,热部署,模块化,说白了就是添加一个功能或减去一个功能不用重启,只需要把这模块连同类加载器一起换掉就实现了代码的热替换。

比较常见的是 JNDI 这种 SPI 的加载动作(JNDI、JDBC、JCE、JAXB、JBI)。是怎么解决的呢,Java 设计团队引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoaser() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文类加载器,就可以做一些“舞弊”的事情了,JNDI 服务使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载的动作。
再补充一种比较常见的 - Tomcat 类的容器也是破坏双亲委派模型的经典例子:
我们想一下 Tomcat 或 Weblogic 等容器类的应用需要满足哪几点呢:

  • 可以加载不同应用的同一个版本或不同版本的同一个类
  • 公共的类应该可以只加载一次
  • 容器类应用本身的类应该与其他应用的类有隔离
  • JSP 等文件编译出来的类,应该有即时更新的能力

这时我们看双亲委派模型第一点就不符合。全限定名相同的类就只会被加载一次,那么是无法加载两个相同类库的不同版本的。
那么怎么办呢,只有破坏双亲委派模型了。于是 Tomcat 设计团队就设计了如下图所示的结构。
在这里插入图片描述
此处引用 @莫那一鲁道 的一篇博客《深入理解 Tomcat(四)Tomcat 类加载器之为何违背双亲委派模型》中的解释:

我们看到,前面3个类加载和默认的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader则是Tomcat自己定义的类加载器,它们分别加载/common/、/server/、/shared/*(在tomcat 6之后已经合并到根目录下的lib目录下)和/WebApp/WEB-INF/*中的Java类库。其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。

  • commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
  • catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
  • sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
  • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见

从图中的委派关系中可以看出:

  • CommonClassLoader能加载的类都可以被Catalina ClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和Shared ClassLoader自己能加载的类则与对方相互隔离。
  • WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。
  • 而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。

好了,至此,我们已经知道了tomcat为什么要这么设计,以及是如何设计的,那么,tomcat 违背了java 推荐的双亲委派模型了吗?答案是:违背了。 我们前面说过:
双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载。
很显然,tomcat 不是这样实现,tomcat 为了实现隔离性,没有遵守这个约定,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。

读书越多越发现自己的无知,Keep Fighting!

本文仅是在自我学习 《深入理解Java虚拟机》这本书后进行的自我总结,有错欢迎友善指正。

欢迎友善交流,不喜勿喷~
Hope can help~

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值