携程面试:深入理解双亲委派模型及其在Java类加载中的应用与重要性
这是携程一面的一个关于Java虚拟机(JVM)的面试真题。对于参加过校园招聘面试的同学来说,这个问题应该并不陌生。当询问关于JVM的知识点时,常常会涉及到双亲委派模型(Parent Delegation Model),尽管这个翻译略显别扭。
学习双亲委派模型不仅对面试准备有帮助,也对我们理解Java的类加载机制至关重要。以Tomcat服务器为例,它为了实现Web应用的隔离,自定义了类加载器并打破了双亲委派模型。
在本文中,我将首先介绍类加载器的概念,然后深入探讨双亲委派模型,以便更好地理解这一机制。
目录概览:
类加载过程回顾
在详细讲解类加载器和双亲委派模型之前,我们先简单回顾类加载的过程:
- 类加载过程:加载 -> 连接 -> 初始化
- 连接过程进一步分为三步:验证 -> 准备 -> 解析
在类加载的第一步中,我们主要完成以下三项工作:
- 通过全类名获取定义此类的二进制字节流。
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
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
:
BootstrapClassLoader
(启动类加载器):最顶层的加载器,由C++实现,通常表示为null,主要用于加载JDK内部核心类库(如%JAVA_HOME%/lib
目录下的rt.jar
、resources.jar
、charsets.jar
等)及由-Xbootclasspath
参数指定的路径下的类。ExtensionClassLoader
(扩展类加载器):负责加载%JRE_HOME%/lib/ext
目录下的jar包和类,以及由java.ext.dirs
系统变量指定路径的所有类。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类
PrintClassLoaderTree
的ClassLoader
是AppClassLoader
; 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.ClassLoader
的 loadClass()
方法中,相关代码如下:
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》。
推荐阅读
- 深入分析Java ClassLoader原理:https://blog.csdn.net/xyang81/article/details/7292380
- Java类加载器(ClassLoader):http://gityuan.com/2016/01/24/java-classloader/
- Java中的类加载器:https://www.baeldung.com/java-classloaders
- Class ClassLoader - Oracle 官方文档:https://docs.oracle.com/javase/8/docs/api/java/lang/ClassLoader.html
- 老大难的Java ClassLoader再不理解就老了:https://zhuanlan.zhihu.com/p/51374915