JVM学习笔记(四):类加载过程、类加载器、双亲委派

虚拟机类加载机制

简介

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

特点:与编译时进行连接的语言不同,Java语言的类型加载、连接和初始化过程都在程序运行期间完成。这样做的会让类加载时稍微增加一些性能开销,但好处是提供了极高的扩展性和灵活性。例如提供了接口与实现的动态绑定(动态多态)。

类加载的时机

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

JVM学习笔记(四):类加载过程、类加载器、双亲委派_第1张图片

其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定,而解析阶段则不一定,它也可以发生在初始化之后,这是为了支持运行时绑定(动态绑定或者晚期绑定)。

类加载过程的第一阶段“加载”的时机,《Java虚拟机规范》没有指定,可以由虚拟机自由实现。但是初始化阶段规定了有且只有六种情况,如果类还没有初始化需要进行初始化(而加载、验证、准备自然需要在此之前):

  • 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时。这四条指令的Java代码场景是:
    • 使用new关键字实例化对象
    • 读取或者设置一个类型的静态字段(被final修饰,已在编译器放入常量池的字段除外)时
    • 调用一个类型的静态方法时
  • 使用java.lang.reflect包的方法对类型进行反射调用时
  • 当初始化类时,如果其父类还没初始化,则先对其父类进行初始化
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个主类
  • 当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial、四种类型的方法句柄,并且这个类还没初始化
  • 当一个接口定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果没有初始胡需要先进行初始化

需要注意的是类与接口在第三种情况下有所不同,当一个类初始化时必须要求他的所有父类都必须初始化,而初始化一个接口时,并不要求其父接口全部都完成初始化,只有用到其父接口时,才进行初始化。

上面这六种场景的行为对一个类型的主动引用。除此之外所有引用类型的方式都不会触发初始化,被称为被动引用。下面使用三个例子来说明何为被动引用:

1、通过子类引用父类的静态字段,不会导致子类的初始化

代码:

package jvm.classloading;

/**
 * 被动使用字段演示一:
 * 通过子类引用父类的静态字段,不会导致子类初始化
 */
public class SuperClass {

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

    public static int value = 123;
}

class SubClass extends SuperClass {

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

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

结果:
JVM学习笔记(四):类加载过程、类加载器、双亲委派_第2张图片
从上面的结果我们可以看出对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化

2、通过数组定义来引用类,不会触发初始化

代码:

/**
 * 被动使用字段演示二:
 * 通过数组定义来引用类,不会触发初始化
 */
public class SuperClass {

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

    public static int value = 123;
}

class NotInitially{
    public static void main(String[] args) {
        SuperClass[] superClasses = new SuperClass[10];
    }
}

结果:

在这里插入图片描述

什么都没有输出,说明没有初始化SuperClass。但其实这段代码里初始化另外一个由虚拟机自动生成的、直接继承与java.lang.Object的子类,创建动作由字节码指令newarray触发。这个类中实现了数组应有的属性和方法(length属性和clone方法等)。

3、常量在编译阶段会存储调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发该类的初始化

代码:

package jvm.classloading;

/**
 * 被动使用字段演示二:
 * 常量在编译阶段会存储调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发该类的初始化代码
 */
public class ConstClass {

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

    public static final String s = "Hello world";

}

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

结果:

JVM学习笔记(四):类加载过程、类加载器、双亲委派_第3张图片

没有出现“ConstClass init!”,说明没有初始化ConstClass 。这是因为在编译期间已经将常量"Hello world"的值直接存储到了NotInitialization的常量池中。此时NotInitialization已经没有了对ConstClass 的引用。通过下面的ConstClass.class的内容也可以看出:

JVM学习笔记(四):类加载过程、类加载器、双亲委派_第4张图片

讲完了类加载的时机,下面对类加载的每个过程进行详细介绍:

类加载的过程

一、加载(Loading)

在加载阶段,Java虚拟机需要完成以下三件事情:

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

在第一步中,《Java虚拟机规范》并没指明二进制字节流文件必须从Class文件获取,由此为Java虚拟机实现加载提供了很大的灵活度。后来很多技术都建立在这个基础之上,例如:

  • 从ZIP压缩包中读取,最终成为了日后JAR、EAR、WAR格式的基础
  • 从网络中获取,最典型的应用就是Web Applet
  • 运行时动态生成,这种场景时用的最多的就是动态代理技术
  • 有其他文件生成,典型场景JSP应用

同时在第二步中,方法区中数据存储格式完全由虚拟机实现自行定义。

加载阶段和连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的。但是这个动作属于连接阶段,两个阶段仍保持固定的开始先后顺序

加载阶段的具体实现

将字节码载入到方法区中,内部采用C++的instanceKlass描述Java类,他的重要field有:

  • _java_mirror即Java的类镜像,例如对String来说,就是String.class,作用是把klass暴漏给Java使用
  • _super 即父类
  • _field 即成员变量
  • _methods 即方法
  • _constants 即常量池
  • _class_loader 即类加载器
  • _vtable 虚方法表
  • _itable 接口方法表

其中instanceKlass这样的【元数据】是存储在方法区(1.8后的元空间内),但是_java_mirror指向存储在堆中的class类型。

JVM学习笔记(四):类加载过程、类加载器、双亲委派_第5张图片

二、验证(Verification)

验证是连接的第一步,这个阶段的目的是保证Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。验证阶段占据类加载工作量相当大的比重。验证阶段大致上分为四个阶段:文件格式验证、元数据验证、字节码验证和符号引用验证。

1、文件格式验证

第一阶段要验证二进制字节流是否符合Class文件格式的规范,保证输入的字节流能正确地解析并存储到方法区,格式上符合描述一个Java类型信息的要求。这一阶段可能包含以下验证点:

  • 是否以魔数0xCAFEBABE开头
  • 主、次版本号是否能够在当前的虚拟机接受范围内
  • 常量池中的常量是否有不被支持的常量类型(检查常量tag标志)
  • 指向常量池的各种索引值是否有指向不存在的常量或者不符合类型的常量
  • CONSTANT_Utf8_info型常量是否不符合utf-8编码格式
  • Class文件各个部分及文件本身是否有被删除或者附加的信息

上面仅仅是文件格式验证的一部分。同时这个阶段的验证是基于二进制字节流的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中存储,之后的三个阶段都是基于方法去的存储结构进行的。

2、元数据验证

第二阶段元数据验证的主要目的是对类的元数据信息进行语义校验,保证其描述信息符合《Java虚拟机规范》的要求,这个阶段的验证点可能包括:

  • 这个类是否有父类(除了java.lang.Object之外,所有类都应该有父类)
  • 这个类是否继承了不允许被继承的类(被final修饰的类)
  • 如果这个类不是抽象类,是否实现了父类或者接口的抽象方法
  • 类中的字段、方法是否与父类产生矛盾

3、字节码验证

第三阶段是最复杂的阶段,第二阶段元数据信息中的数据类型校验完毕之后,这一阶段就是对的**方法体(Class文件中的Code属性)**进行校验分析,主要目的是通过数据流分析和控制流分析,确定程序语义是否合法,符合逻辑。保证验证的方法不会做出危害虚拟机完全的行为,例如:

  • 保证任何时候操作数栈的数据类型与指令代码都能配合执行,例如不会出现类似于“在操作数栈放置一个int类型数据,使用时却按long类型来载入本地变量表”的情况
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
  • 保证方法内部的类型转换总是有效的,例如可以把一个子类对象赋值给他的父类对象,这样是安全的,但是反过来就是不合法的,甚至把对象赋值为与他毫不相干的数据类型都是不安全的

由于该阶段过于耗时,在JDK6之后Javac编译器和Java虚拟机进行联合优化,将尽可能多的校验辅助措施转移到Javac编译器中进行。具体做法是给方法体Code属性的属性表新增一项名为“StackMapTable”的新属性,它描述了方法体所有基本块(Basic Block,值按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java虚拟机就不需要根据程序推导出这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。

4、符号引用验证

符合引用验证可以看作是对类自身(常量池中的各种符号引用)以外的各类信息进行匹配行的校验,也就是:该类是否缺少或者禁止访问它依赖的某些外部类、方法、字段等资源。可能包括:

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

三、准备(Preparation)

准备阶段是正式为**类中定义的变量(即静态变量,被static修饰的变量)**分配内存并设置类变量的默认值。这个阶段有几个值得注意地方:

  • 这个时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量会在对象实例化时随着对象一起分配在java堆中
  • 这里所说的初始值通常情况下是数据类型的默认值,例如public static int value = 123;此时value的值为0而不是123。对于静态常量,例如public static final int value = 123;此时就会赋值为123。
  • static变量分配空间和赋值是两个阶段,分配空间在准备阶段完成,赋值在初始化阶段完成
  • 如果static变量是final的基本类型以及String,那么编译阶段值就确定了,赋值在准备阶段
  • 如果static变量是final的,但属于引用类型,那么赋值也会在初始化阶段完成

四、解析(Resolution)

解析阶段是Java虚拟机将常量池内的符号引用转化为直接引用的过程,下面介绍下两者的含义:

  • 符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能毫无歧义地地位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容
  • 直接引用(Direct Reference):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能简介定位到目标句柄。直接引用和虚拟机实现的内存布局直接相关。如果有了直接引用,那引用的目标必定已经在虚拟机内存中存在。

到底是在类被加载器加载时就对常量池中的符号进行引用解析,还是等到一个符号引用将要被使用之前进行解析,根据虚拟机实现可以自行决定。另外解析阶段也会对方法或者字段的可访问性进行验证。

解析动作主要针对类或者接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethonType_info、CONSTANT_MethonHandle_info、CONSTANT_Dynamic_info、CONSTANT_InvokeDynamic_info这8种常量类型。下面讲解前4种,之后讲解后4种:

1、类或者接口信息

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

  • 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
  • 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似[Ljava/lang/Integer的形式,那将会按照第一点的规则加载数组元素类型。 如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.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异常

如果成功查找到字段,之后还要对该字段进行权限验证。

3、方法解析

要解析一个未被解析过的方法符号引用,首先将会获取方法表内class_index项索引的方法所在类或者接口的符号引用,设其为C,接下来要按照如下步骤堆C进行后续字段的搜索:

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

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

4、接口方法解析

要解析一个未被解析过的接口方法符号引用,首先将会获取方法表内class_index项索引的接口方法所在类或者接口的符号引用,设其为C,接下来要按照如下步骤堆C进行后续字段的搜索:

  • 与类的方法解析相反.如果在接口方法表中发观class_index 中的索引C是个类雨不是接口,那么就直接抛出java.lang.IncompatibleClassChagcEroror异常。
  • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则|返回这个方法的直接引用,查找结束。
  • 否则,在接口C的父接口中递归查找,直到java.lang.Objet类(接口方法的查找范围也会包括Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  • 对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中并没有进一一步规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的Javac编译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。
  • 否则,宣告方法查找失败,抛出java.lang. NoSuchMethodEror异常。

五、初始化(Initialization)

进行准备阶段时,变量已经赋过一次初始零值,而在初始化阶段,则会根据程序员通过程序编码指定的主观计划去初始化类变量和其他资源。初始化阶段就是执行类构造器()方法的过程:

  • ()方法是由编译器自动收集类中的所有类变量的赋值动作静态代码块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序,静态语句块只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问
public class Test{
	static{
		i = 0;	//给变量赋值可以正常编译通过
		System.out.print(i);	//这句编译器会提示“非法前向引用”
	}
	static int i = 1;
}
  • ()方法与类的构造器(即在虚拟机视角中的实例构造器()方法)不同,它不需要显示的调用父类构造器,java虚拟机会保证在子类的()方法执行之前,父类的()方法已经被执行
  • 由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作
  • ()方法对于类或者接口,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器就不会给这个类生成()方法
  • 接口中不能使用静态语句块,但是仍有变量初始化的赋值操作,因此接口一样会生成()方法。但是与类不同的是,执行接口的()方法,不需要先执行父接口的()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化Java虚拟机必须保证一个类的()方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只有一个线程会去执行这个类的()方法,其他线程都会阻塞等待。这个过程中如果()方法耗时很长,会导致多个线程阻塞,这种阻塞往往很隐蔽。

总结初始化的发生时机:

1、会导致初始化的情况

  • main方法所在的类,总会被有先初始化
  • 首次访问这个类的静态变量和静态方法时
  • 子类初始化,如果父类还没初始化,会导致父类的初始化
  • 子类访问父类的静态变量,只会导致父类的初始化
  • Class.forName
  • new会导致初始化

2、不会导致初始化的情况

  • 访问类的static final静态变量(基本数据类和字符串类型)不会触发初始化
  • 类对象.class不会触发初始化
  • 创建该类的数组不会触发初始化
  • 类加载器的loadClass方法
  • Class.forName的第二个参数为false时不会初始化

典型应用-编写懒惰初始化单例模式

package jvm.classloading;

public class LazySingletonTest {

    public static void main(String[] args) {
//        Singleton singleton = new Singleton();  //不能直接new

        Singleton singleton = Singleton.getInstance(); //通过懒惰单例获取对象
        singleton.test();
    }

}

class Singleton{

    //私有化构造函数实现单例
    private Singleton(){}

    private static class LazyHolder{
        private static final Singleton singleton = new Singleton();
        static {
            System.out.println(" lazy holder init!");
        }
    }

    public static Singleton getInstance(){
        //只有当调用此方法时,才会触发LazyHolder的初始化,从而创建Singleton对象
        return LazyHolder.singleton;
    }

    public void test(){
        System.out.println("test");
    }

}

以上单例模式的特点

  • 懒惰实例化
  • 初始化时线程安全是有保证的,因为虚拟机保证了()方法的线程安全

类加载器

“通过一个类的全限定类名来获取到描述该类的二进制字节流”,实现这个动作的代码就被称为类加载器。

类与类加载

对于任意一个类,都必须由加载它的类加载和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的命名空间。简单来说,就是即使两个类来自同一个Class文件,被统一虚拟机加载,只要它们的类加载器不同,那么这两个类必定不同。

双亲委派模型

介绍双亲委派之前,先来介绍以下三层类加载器:

  • 启动类加载器(Bootstrap Class Loader):启动类加载器使用C++语言实现,是虚拟机的一部分。主要负责加载放在\lib目录,或者被-Xbootclasspath参数所指定路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,如果名字不符合的类库即使放入lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器去处理,直接使用null代理即可
  • 扩展类加载器(Extension Class Loader):这个类加载时在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载\lib\ext目录中,或者被java.ext.dirs系统变量指派的路径中所有的类库。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件
  • 应用程序类加载(Application Class Loader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。它负责加载用户类路径下**(ClassPath)**所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序用没有定义过自己的类加载器,一般情况下这个就是应用程序的默认类加载器

JVM学习笔记(四):类加载过程、类加载器、双亲委派_第6张图片

上面图中展示的各种类加载器之间的层次关系被称为类加载器的双亲委派模型。其中类加载器之间的父子关系使用的是组合,而不是继承来实现的

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

双亲委派模型的好处是Java中的类随着他的类加载器一起具备了一种带有优先级的层次关系,很好的解决了各个类加载器协作时基础类的一致性问题(越基础的类由越上层的加载器进行加载),防止了核心API库被随意篡改。

双亲委派模型的实现原理:双亲委派的实现代码只有10余行,全部集中在java.lang.ClassLoader的loadClass()方法中:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先检测这个类是否已经被加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        //如果由上级,则委派给上级加载
                        c = parent.loadClass(name, false);
                    } else {
                        //如果没有上级(ExtClassLoader),则委派给BootstrapClassLoader
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    //如果父类加载器抛出ClassNotFoundException异常
                    //说明父亲加载器无法完成加载请求
                }

                if (c == null) {
                    //在父类加载器无法加载时
                    //再调用本身的findClass方法进行类加载
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

上面代码的整个流程是:先检查请求加载的类型是否已经被加载过,若没有则调用父类加载器的loadClass请求,若父类加载器为空则默认使用启动加载器作为父加载器。假如父类加载器加载失败,抛出lassNotFoundException异常,才调用调用自己的findClass方法尝试进行加载。

破坏双亲委派模型

1.第一次破坏

由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则在JDK1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。在此之前,用户去继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法唯一逻辑就是去调用自己的loadClass()。

2.第二次破坏

双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的同一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美。

如果基础类又要调用回用户的代码,那该么办?

一个典型的例子就是JNDI服务,JNDI现在已经是Java的标准服务, 它的代码由启动类加载器去加载(在JDK1.3时放进去的rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者的代码,但启动类加载器不可能“认识”这些代码。

为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

3.第三次破坏

双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换、模块热部署等,简答的说就是机器不用重启,只要部署上就能用。 OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。在OSGi幻境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当受到类加载请求时,OSGi将按照下面的顺序进行类搜索:
1)将java.*开头的类委派给父类加载器加载。
2)否则,将委派列表名单内的类委派给父类加载器加载。
3)否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。
4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
7)否则,类加载器失败。

线程上下文类加载器

JDBC例子

自定义类加载器

什么时候需要自定义类加载器:

  • 需要加载非classpath任意其他路径中的类文件时
  • 通过接口来使用实现,希望解耦时,常用于框架设计
  • 希望对类进行隔离,不同应用的同名类都可以加载,不会发生冲突,常见于tomcat容器

步骤:

  • 继承ClassLoader父类
  • 要遵从双亲委派机制,重写findClass方法
    • 注意不是重写loadClass,否则不会走双亲委派
  • 读取类文件的字节码
  • 调用父类的defineClass方法来加载类
  • 使用者调用该类加载的loadClass方法

示例:

public class Load7 {
    public static void main(String[] args) throws Exception {
        MyClassLoader classLoader = new MyClassLoader();
        Class<?> c1 = classLoader.loadClass("MapImpl1");
        Class<?> c2 = classLoader.loadClass("MapImpl1");
        System.out.println(c1 == c2); //true

        MyClassLoader classLoader2 = new MyClassLoader();
        Class<?> c3 = classLoader2.loadClass("MapImpl1");
        System.out.println(c1 == c3); //fasle

        c1.newInstance();
    }
}

//自定义类加载器
class MyClassLoader extends ClassLoader {

    @Override // name 就是类名称
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path = "e:\\myclasspath\\" + name + ".class";

        try {
            ByteArrayOutputStream os = new ByteArrayOutputStream();
            Files.copy(Paths.get(path), os);

            // 得到字节数组
            byte[] bytes = os.toByteArray();

            // byte[] -> *.class
            return defineClass(name, bytes, 0, bytes.length);

        } catch (IOException e) {
            e.printStackTrace();
            throw new ClassNotFoundException("类文件未找到", e);
        }
    }
}

FoundException {
String path = “e:\myclasspath\” + name + “.class”;

    try {
        ByteArrayOutputStream os = new ByteArrayOutputStream();
        Files.copy(Paths.get(path), os);

        // 得到字节数组
        byte[] bytes = os.toByteArray();

        // byte[] -> *.class
        return defineClass(name, bytes, 0, bytes.length);

    } catch (IOException e) {
        e.printStackTrace();
        throw new ClassNotFoundException("类文件未找到", e);
    }
}

}


你可能感兴趣的:(JVM,后端)