携程面试:深入理解双亲委派模型及其在Java类加载中的应用与重要性

这是携程一面的一个关于Java虚拟机(JVM)的面试真题。对于参加过校园招聘面试的同学来说,这个问题应该并不陌生。当询问关于JVM的知识点时,常常会涉及到双亲委派模型(Parent Delegation Model),尽管这个翻译略显别扭。

图片

学习双亲委派模型不仅对面试准备有帮助,也对我们理解Java的类加载机制至关重要。以Tomcat服务器为例,它为了实现Web应用的隔离,自定义了类加载器并打破了双亲委派模型。

在本文中,我将首先介绍类加载器的概念,然后深入探讨双亲委派模型,以便更好地理解这一机制。

目录概览:

图片

类加载过程回顾

在详细讲解类加载器和双亲委派模型之前,我们先简单回顾类加载的过程:

  • 类加载过程:加载 -> 连接 -> 初始化
  • 连接过程进一步分为三步:验证 -> 准备 -> 解析

在类加载的第一步中,我们主要完成以下三项工作:

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

类加载器的概述

类加载器介绍

类加载器自JDK 1.0就已存在,最初是为了满足Java Applet(已淘汰)的需求,后来逐渐发展成Java程序的重要组成部分,使Java类能够动态加载并执行。

根据官方API文档的定义:

类加载器是负责加载类的对象。ClassLoader是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。一个典型的策略是将名称转换为文件名,然后从文件系统中读取相应的“类文件”。

每个Class对象都包含一个指向定义它的ClassLoader的引用。

数组类的Class对象不是由类加载器创建,而是由Java运行时在需要时自动生成的。如果元素类型是基本类型,则数组类没有类加载器。

总结以上内容:

  • 类加载器负责加载类,是类加载过程中的关键环节。
  • 每个Java类都关联有其加载器。
  • 数组类由JVM直接生成,而非由类加载器创建。

简单来说,类加载器的主要功能是将Java类的字节码(.class 文件)加载到JVM中,生成内存中的 Class 对象。字节码来源于Java源程序(.java 文件)经过 javac 编译,或通过其他工具动态生成或网络下载。

除了加载类,类加载器还负责加载Java应用所需的资源,如文本、图像、配置文件等。本文将主要讨论加载类的核心功能。

类加载器加载规则

JVM启动时不会一次性加载所有类,而是根据需求动态加载。大部分类在实际使用时才会被加载,这在内存管理上更为高效。

对于已加载的类,会存放在 ClassLoader 中。在加载类时,系统会先检查该类是否已被加载,如果已加载则直接返回,否则再尝试加载。每个类加载器只会加载同一二进制名称的类一次。

类加载器总结

JVM内置了以下三种重要的 ClassLoader

  1. BootstrapClassLoader(启动类加载器):最顶层的加载器,由C++实现,通常表示为null,主要用于加载JDK内部核心类库(如 %JAVA_HOME%/lib 目录下的 rt.jarresources.jarcharsets.jar等)及由-Xbootclasspath参数指定的路径下的类。
  2. ExtensionClassLoader(扩展类加载器):负责加载 %JRE_HOME%/lib/ext 目录下的jar包和类,以及由 java.ext.dirs 系统变量指定路径的所有类。
  3. AppClassLoader(应用程序类加载器):面向用户的加载器,负责加载当前应用classpath下的所有jar包和类。

🌈 拓展:

  • rt.jar:Java的基础类库,包含我们在Java文档中看到的所有类文件,常用的内置库 java.xxx.* 都在其中,如 java.util.*java.io.*java.nio.*java.lang.*java.sql.*java.math.*
  • Java 9引入了模块系统,对类加载器的架构稍作调整,扩展类加载器被称为平台类加载器(platform class loader),大多数模块除 java.base 由启动类加载器加载外,其他模块均由平台类加载器加载。

用户可自定义类加载器以满足特定需求,如加密类字节码并在加载时解密。

除了 BootstrapClassLoader 是JVM的一部分外,其他类加载器均在JVM外部实现,并继承自 ClassLoader 抽象类。这种设计允许用户自定义类加载器,让应用程序决定如何获取所需的类。

每个 ClassLoader 可以通过 getParent() 方法获取其父类加载器,若返回null,则说明该类是通过 BootstrapClassLoader 加载的。

为何获取到的 ClassLoader 为null则是 BootstrapClassLoader 加载的? 因为 BootstrapClassLoader 是用C++实现的,因此在Java中没有对应的类表示它,返回结果为null。

接下来,我们来看一个获取 ClassLoader 的简单示例:

public class PrintClassLoaderTree {

    public static void main(String[] args) {
        ClassLoader classLoader = PrintClassLoaderTree.class.getClassLoader();
        StringBuilder split = new StringBuilder("|--");
        boolean needContinue = true;
        while (needContinue) {
            System.out.println(split.toString() + classLoader);
            if (classLoader == null) {
                needContinue = false;
            } else {
                classLoader = classLoader.getParent();
                split.insert(0, "\t");
            }
        }
    }
}

输出结果(JDK 8):

|--sun.misc.Launcher$AppClassLoader@18b4aac2  
    |--sun.misc.Launcher$ExtClassLoader@53bd815b  
        |--null  

从输出结果中我们可以得知:

  • 我们编写的Java类 PrintClassLoaderTreeClassLoaderAppClassLoader
  • AppClassLoader的父加载器是 ExtClassLoader
  • ExtClassLoader的父加载器是 Bootstrap ClassLoader,因此输出结果为null。

自定义类加载器

如前所述,除了 BootstrapClassLoader,其他类加载器均由Java实现,且全部继承自 java.lang.ClassLoader。若需自定义类加载器,需继承 ClassLoader 抽象类。

ClassLoader 类包含两个关键方法:

  • protected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,遵循双亲委派机制。name为类的二进制名称,resolve为true时,会在加载时调用 resolveClass(Class<?> c) 方法解析该类。
  • protected Class findClass(String name):根据类的二进制名称查找类,默认实现为空。

官方API文档建议:

ClassLoader的子类应重写 findClass(String name) 方法而非 loadClass(String name, boolean resolve) 方法。

如果希望保持双亲委派模型,只需重写 findClass() 方法。相反,若需打破双亲委派模型,则需重写 loadClass() 方法。

双亲委派模型介绍

类加载器有多种,当我们想加载一个类时,具体由哪个类加载器负责加载,就需要涉及到双亲委派模型。

根据官网说明:

ClassLoader 类使用委托模型来搜索类和资源。每个 ClassLoader 实例都有一个相关的父类加载器。当请求查找类或资源时,ClassLoader 实例会在亲自查找类或资源之前,将搜索任务委托给其父类加载器。虚拟机的内置类加载器,称为“启动类加载器”,没有自己的父类,但可以作为 ClassLoader 实例的父类加载器。

翻译成中文的意思是:

ClassLoader 类使用一种委托模型来查找类和资源。每个 ClassLoader 实例都与一个父类加载器相关联。当请求查找类或资源时,ClassLoader 实例会在尝试自己查找之前,先将查找任务委托给其父类加载器。虚拟机中的内置类加载器称为“启动类加载器”,它本身没有父类,但可以作为其他 ClassLoader 实例的父类加载器。

从上述解释中可以看出:

  • ClassLoader 类使用委托模型来搜索类和资源。
  • 双亲委派模型要求除了顶层的启动类加载器外,其余类加载器均应有父类加载器。
  • ClassLoader 实例在亲自查找类或资源之前,会先将任务委托给父类加载器。

以下图展示的各类加载器之间的层次关系,被称为类加载器的“双亲委派模型(Parent Delegation Model)”。

需要注意的是⚠️:双亲委派模型并不是强制性的约束,而是JDK官方推荐的方式。如果由于特殊需求需要打破双亲委派模型,也是可以的。对于翻译上,双亲的理解很容易让人误解。这里的双亲更多指的是“父辈”的概念,而不是真正的“父母”。我个人认为将其理解为单亲委派模型更加恰当,但既然国内普遍称为双亲委派模型,那么继续使用这一称谓即可,要避免误解即可。

此外,类加载器之间的父子关系并不是通过继承实现的,而是通常通过组合关系来复用父加载器的代码。

public abstract class ClassLoader {
    ...
    // 组合
    private final ClassLoader parent;
    protected ClassLoader(ClassLoader parent) {
        this(checkCreateClassLoader(), parent);
    }
    ...
}

在面向对象编程中,有一条经典设计原则:组合优于继承,多用组合少用继承。

双亲委派模型的执行流程

双亲委派模型的实现逻辑简单明了,集中在 java.lang.ClassLoaderloadClass() 方法中,相关代码如下:

protected Class<?> loadClass(String name, boolean resolve) 
    throws ClassNotFoundException { 
    synchronized (getClassLoadingLock(name)) { 
        // 首先,检查该类是否已经加载过 
        Class c = findLoadedClass(name); 
        if (c == null) { 
            // 如果 c 为 null,则说明该类没有被加载过 
            long t0 = System.nanoTime(); 
            try { 
                if (parent != null) { 
                    // 当父类加载器不为空时,通过父类的 loadClass 来加载该类 
                    c = parent.loadClass(name, false); 
                } else { 
                    // 当父类加载器为空时,调用启动类加载器来加载该类 
                    c = findBootstrapClassOrNull(name); 
                } 
            } catch (ClassNotFoundException e) { 
                // 非空父类的类加载器无法找到相应的类,则抛出异常 
            } 

            if (c == null) { 
                // 当父类加载器无法加载时,调用 findClass 方法来加载该类 
                long t1 = System.nanoTime(); 
                c = findClass(name); 

                // 统计类加载器相关的信息 
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); 
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); 
                sun.misc.PerfCounter.getFindClasses().increment(); 
            } 
        } 
        if (resolve) { 
            // 对类进行 link 操作 
            resolveClass(c); 
        } 
        return c; 
    } 
}

每当一个类加载器接收到加载请求时,它会首先将请求转发给其父类加载器。只有在父类加载器未能找到请求的类时,该加载器才会尝试自己加载。

根据上述源码,我们可以简单总结双亲委派模型的执行流程如下:

  • 在类加载时,系统首先判断当前类是否已经被加载,若已加载则直接返回,否则尝试加载。每个父类加载器都会经历这个流程。
  • 类加载器在加载类时,通常不会直接尝试加载,而是将该请求委托给父类加载器(调用父加载器的 loadClass() 方法)。这样,所有请求最终都会传递到顶层的启动类加载器 BootstrapClassLoader
  • 仅当父加载器反馈无法完成加载请求(在其搜索范围内没有找到所需类)时,子加载器才会尝试调用自己的 findClass() 方法进行加载。

🌈 拓展一下:

JVM 判定两个Java类是否相同的规则:JVM不仅会检查类的全名,还会检查加载此类的类加载器是否相同。只有在两者都相同的情况下,才认为两个类是相同的。即使两个类来源于同一个 .class 文件,且被同一虚拟机加载,只要加载它们的类加载器不同,这两个类就必定不同。

双亲委派模型的优点

双亲委派模型保证了Java程序的稳定运行,避免了类的重复加载(JVM区分不同类的不仅依据类名,相同的类文件被不同的类加载器加载会产生两个不同的类),同时确保了Java的核心API不被篡改。

如果不使用双亲委派模型,而是让每个类加载器自行加载,那么可能会出现多个不同的 java.lang.Object 类。双亲委派模型确保加载的是JRE中的 Object 类,而不是用户自定义的 Object 类。这是因为 AppClassLoader 在加载用户的 Object 类时,会委托给 ExtClassLoader,而 ExtClassLoader 会继续委托给 BootstrapClassLoader。后者发现自己已加载过 Object 类,因此直接返回,不会加载用户定义的版本。

打破双亲委派模型的方法

若要自定义加载器,需继承 ClassLoader。如果希望保持双亲委派模型,只需重写 ClassLoader 类中的 findClass() 方法。无法由父类加载器加载的类最终将通过此方法被加载。若想打破双亲委派模型,则需重写 loadClass() 方法。

为何重写 loadClass() 方法会打破双亲委派模型呢?如前所述,双亲委派模型的执行流程表明:

类加载器在进行类加载时,首先不会尝试自己加载,而是把请求委派给父类加载器。

例如,Tomcat服务器自定义了 WebAppClassLoader,以便优先加载Web应用目录下的类,从而打破双亲委派机制。这也是Tomcat实现Web应用之间类隔离的具体原理。

对Tomcat的类加载器层次结构感兴趣的朋友可以自行研究,这将有助于更好地理解Tomcat中Web应用的隔离原理。推荐阅读《深入拆解Tomcat & Jetty》。

推荐阅读