登录后台

页面导航

本文编写于 846 天前,最后修改于 264 天前,其中某些信息可能已经过时。

看到这个题目,很多人会觉得我写我的java代码,至于类,JVM爱怎么加载就怎么加载,博主有很长一段时间也是这么认为的。随着编程经验的日积月累,越来越感觉到了解虚拟机相关要领的重要性。闲话不多说,老规矩,先来一段代码吊吊胃口。

public class SSClass {
    static {
        System.out.println("SSClass");
    }
}
public class SuperClass extends SSClass {
    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123;

    public SuperClass() {
        System.out.println("init SuperClass");
    }
}
public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init");
    }

    static int a;

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

运行结果:

SSClass
SuperClass init!
123

答案答对了嚒?
也许有人会疑问:为什么没有输出SubClass init。ok~解释一下:对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
上面就牵涉到了虚拟机类加载机制。如果有兴趣,可以继续看下去。

类加载过程

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

class_loader_step

加载验证准备初始化卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。以下陈述的内容都已HotSpot为基准。

加载

在加载阶段(可以参考java.lang.ClassLoaderloadClass()方法),虚拟机需要完成以下 3 件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流(并没有指明要从一个Class文件中获取,可以从其他渠道,譬如:网络、动态生成、数据库等);
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;

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

验证

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

  1. 文件格式验证:验证字节流是否符合 Class 文件格式的规范;例如:是否以魔术 0xCAFEBABE 开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
  2. 元数据验证:对字节码描述的信息进行语义分析(注意:对比 javac 编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;例如:这个类是否有父类,除了 java.lang.Object 之外。
  3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  4. 符号引用验证:确保解析动作能正确执行。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用 -Xverifynone 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:public static int value = 123;

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

至于“特殊情况”是指:public static final int value = 123,即当类字段的字段属性是 ConstantValue 时,会在准备阶段初始化为指定的值,所以标注为 final 之后,value 的值在准备阶段初始化为 123 而非 0.

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对接口字段类方法接口方法方法类型方法句柄调用点限定符 7 类符号引用进行。

初始化

类初始化阶段是类加载过程的最后一步,到了初始化阶段,才真正开始执行类中定义的 java 程序代码。在准备阶段,变量已经付过一次系统要求的初始值,而在初始化阶段,则根据程序猿通过程序制定的主管计划去初始化类变量和其他资源,或者说:初始化阶段是执行类构造器 <clinit>() 方法的过程.

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

public class Test {
    static {
        i=0;
        System.out.println(i);//这句编译器会报错:Cannot reference a field before it is defined(非法向前应用)
    }
    static int i=1;
}

<clinit>() 方法与实例构造器 <init>() 方法不同,它不需要显示地调用父类构造器,虚拟机会保证在子类 <init>() 方法执行之前,父类的 <clinit>() 方法方法已经执行完毕,回到本文开篇的举例代码中,结果会打印输出:SSClass 就是这个道理。

由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

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

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

虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有好事很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的。

package jvm.classload;

public class DealLoopTest {
    static class DeadLoopClass {
        static {
            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();
    }
}

运行结果:(即一条线程在死循环以模拟长时间操作,另一条线程在阻塞等待)

Thread[Thread-0,5,main] start
Thread[Thread-1,5,main] start
Thread[Thread-0,5,main]init DeadLoopClass

需要注意的是,其他线程虽然会被阻塞,但如果执行 <clinit>() 方法的那条线程退出 <clinit>() 方法后,其他线程唤醒之后不会再次进入 <clinit>() 方法。同一个类加载器下,一个类型只会初始化一次。

将上面代码中的静态块替换如下:

        static {
            System.out.println(Thread.currentThread() + "init DeadLoopClass");
            try
            {
                TimeUnit.SECONDS.sleep(10);
            }
            catch (InterruptedException e)
            {
                e.printStackTrace();
            }
        }

运行结果:

Thread[Thread-0,5,main] start
Thread[Thread-1,5,main] start
Thread[Thread-1,5,main]init DeadLoopClass (之后sleep 10s)
Thread[Thread-1,5,main] run over
Thread[Thread-0,5,main] run over

虚拟机规范严格规定了有且只有 5 种情况(jdk1.7)必须对类进行“初始化”(而加载验证准备自然需要在此之前开始):

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

开篇已经举了一个范例:通过子类引用付了的静态字段,不会导致子类初始化。

这里再举两个例子。

  • 通过数组定义来引用类,不会触发此类的初始化:(SuperClass类已在本文开篇定义)
public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
}

运行结果:(无)

  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化:
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);
    }
}

运行结果:hello world

附:昨天从论坛上看到一个例子,很有意思,如下:

package jvm.classload;

public class StaticTest {
    public static void main(String[] args) {
        staticFunction();
    }

    static StaticTest st = new StaticTest();

    static {
        System.out.println("1");
    }

    {
        System.out.println("2");
    }

    StaticTest() {
        System.out.println("3");
        System.out.println("a="+a+",b="+b);
    }

    public static void staticFunction(){
        System.out.println("4");
    }

    int a=110;
    static int b =112;
}

类加载器

类加载器虽然只用于实现类的加载动作,但它在java程序中起到作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一起确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。(比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类肯定不会相等)

这里说的相等,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

双亲委派模型

类加载器分类

在虚拟机的角度上,只存在两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;
  • 其它所有的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全部继承自 java.lang.ClassLoader

从Java开发人员的角度看,类加载器还可以划分得更细一些,如下:

  • 启动类加载器(Bootstrap ClassLoader)

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

  • 扩展类加载器(Extension ClassLoader)
    这个类加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 <JAVA_HOME>\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader)
    这个类加载器由 sum.misc.Launcher.$AppClassLoader 来实现。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也被称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

应用程序由这三种类加载器互相配合进行加载的,如果有必须,还可以加入自己定义的类加载器。这些类加载器之间的关系一般如下图。

class_loader

双亲委派模型概念

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

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

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

双亲委派模型优点

Java类随着它的类加载器一起具备了一种带有优先级的层次关系,例如类 java.lang.Object,它存在在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都会委派给出于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类(该类具有系统的 Object 类一样的功能,只是在某个函数稍作修改。比如 equals 函数,这个函数经常使用,如果在这这个函数中,黑客加入一些“病毒代码”。并且通过自定义类加载器加入到 JVM 中,哈哈,那就热闹了),并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,java 类型体系中最基础的行为也就无法保证了,应用程序也将变得一片混乱。

双亲委派模型实现

双亲委派模型对于保证 Java 程序的稳定运作很重要,但它的实现却非常简单,实现代码都集中在 ClassLoader 类默认的 loadClass 方法中。

loadClass 默认实现如下:

public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
}

再看看 loadClass(String name, boolean resolve) 函数:

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 1、检查请求的类是否已经被加载过了
        Class c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 如果父类加载器抛出ClassNotFoundException,说明父类加载器无法完成加载请求
            }
            if (c == null) {
                // 在父类加载器无法加载的时候,再调用本身的findClass方法来进行类加载
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}
  1. 检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
  2. 如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用 parent.loadClass(name, false);).或者是调用 bootstrap 类加载器来加载。
  3. 如果父加载器及 bootstrap 类加载器都没有找到指定的类,那么调用当前类加载器的 findClass 方法来完成类加载

换句话说,如果自定义类加载器,就必须重写findClass方法

findClass 的默认实现如下:

protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
}

可以看出,抽象类 ClassLoader 的 findClass 函数默认是抛出异常的。而前面我们知道,loadClass 在父加载器无法加载类的时候,就会调用我们自定义的类加载器中的 findeClass 函数,因此我们必须要在 loadClass 这个函数里面实现将一个指定类名称转换为 Class 对象.

如果是读取一个指定的名称的类为字节数组的话,这很好办。但是如何将字节数组转为 Class 对象呢?很简单,Java 提供了 defineClass 方法,通过这个方法,就可以把一个字节数组转为 Class 对象啦~

defineClass主要的功能是:

将一个字节数组转为Class对象,这个字节数组是class文件读取后最终的字节数组。如,假设class文件是加密过的,则需要解密后作为形参传入defineClass函数。

defineClass默认实现如下:

protected final Class<?> defineClass(String name, byte[] b, int off, int len)
        throws ClassFormatError  {
        return defineClass(name, b, off, len, null);

}

函数调用过程:

class_loader_function_step

示例

首先,我们定义一个待加载的普通 Java 类: Test.java 。放在 com.xdwang.demo 包下:

package com.xdwang.demo;

public class Test {
    public void hello() {
        System.out.println("恩,是的,我是由 " + getClass().getClassLoader().getClass() + " 加载进来的");
    }
}

如果你是直接在当前项目里面创建,待 Test.java 编译后,请把 Test.class 文件拷贝走,再将 Test.java 删除。因为如果 Test.class 存放在当前项目中,根据双亲委派模型可知,会通过 sun.misc.Launcher$AppClassLoader 类加载器加载。为了让我们自定义的类加载器加载,我们把 Test.class 文件放入到其他目录。

接下来就是自定义我们的类加载器:

import java.io.FileInputStream;
import java.lang.reflect.Method;
 
public class Main {
    static class MyClassLoader extends ClassLoader {
        private String classPath;
        public MyClassLoader(String classPath) {
            this.classPath = classPath;
        }
        private byte[] loadByte(String name) throws Exception {
            name = name.replaceAll("\\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name
                    + ".class");
            int len = fis.available();
            byte[] data = new byte[len];
            fis.read(data);
            fis.close();
            return data;
        }
 
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] data = loadByte(name);
                return defineClass(name, data, 0, data.length);
            } catch (Exception e) {
                e.printStackTrace();
                throw new ClassNotFoundException();
            }
        }
 
    };
 
    public static void main(String args[]) throws Exception {
        MyClassLoader classLoader = new MyClassLoader("D:/test");
        //Test.class目录在D:/test/com/xdwang/demo下
        Class clazz = classLoader.loadClass("com.xdwang.demo.Test");
        Object obj = clazz.newInstance();
        Method helloMethod = clazz.getDeclaredMethod("hello", null);
        helloMethod.invoke(obj, null);
    }
}

运行结果:

恩,是的,我是由 class Main$MyClassLoader 加载进来的

破坏双亲委派模型

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

  • 双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前--即JDK1.2发布之前。由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则是JDK1.0时候就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK1.2之后的java.lang.ClassLoader添加了一个新的proceted方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是重写loadClass()方法,因为虚拟在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。JDK1.2之后已不再提倡用户再去覆盖loadClass()方法,应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型的。
  • 双亲委派模型的第二次“被破坏”是这个模型自身的缺陷所导致的,双亲委派模型很好地解决了各个类加载器的基础类统一问题(越基础的类由越上层的加载器进行加载),基础类之所以被称为“基础”,是因为它们总是作为被调用代码调用的API。但是,如果基础类又要调用用户的代码,那该怎么办呢?
    这并非是不可能的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(在JDK1.3时放进rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识”之些代码,该怎么办?

为了解决这个困境,Java设计团队只好引入了一个不太优雅的设计:线程上下文件类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。有了线程上下文类加载器,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等。

  • 双亲委派模型的第三次“被破坏”是由于用户对程序的动态性的追求导致的,例如OSGi的出现。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构。

Class.forName() 和 ClassLoader.loadClass() 的区别

  • Class.forName(className) 方法,内部实际调用的方法是 Class.forName(className,true,classloader);第 2 个 boolean 参数表示类是否需要初始化,Class.forName(className) 默认是需要初始化。一旦初始化,就会触发目标对象的 static 块代码执行,static 参数也也会被再次初始化。
  • ClassLoader.loadClass(className) 方法,内部实际调用的方法是 ClassLoader.loadClass(className,false);第 2 个 boolean 参数,表示目标对象是否进行链接,false 表示不进行链接,由上面介绍可以得知,不进行链接意味着不进行包括初始化等一些列步骤,那么静态块和静态对象就不会得到执行。

参考与扩展

  1. 《深入理解Java虚拟机》
  2. Java类的加载、链接和初始化-HollisChuang's Blog
  3. 深度分析Java的ClassLoader机制(源码级别)-HollisChuang's Blog
  4. 双亲委派模型与自定义类加载器 - ImportNew
  5. Java双亲委派模型及破坏 - CSDN博客

本文内容来自:

  1. https://blog.csdn.net/u013256816/article/details/50829596
  2. https://blog.csdn.net/w372426096/article/details/81901482