☰
24 面试官问双亲委派,其实是想看你知不知道它为什么会被“打破“
2026/10/10 2:51:11 网站建设 项目流程

面试现场,面试官先抛了个热身题。

面试官:"一个.class文件,从磁盘到能被new出来,中间经历了什么?"

候选人A:"先加载,然后……然后就可以用了?"——中间那几步全跳过了。

面试官:"那你听说过双亲委派模型吗?"

候选人A:"听说过,就是加载一个类的时候,先让父加载器去加载,父的父再去加载,一直往上,直到轮到自己。"

面试官接着问:"背得挺熟。那我问你,Tomcat 为什么要打破它?JDBC 驱动为什么要打破它?"

候选人A:"打破?……这个……是要让子加载器先加载吗?"——答得含糊,明显只知道结论。

候选人B接过话:"委派的逻辑在ClassLoader.loadClass里,核心就四步:查缓存、找父亲、父亲不行自己findClass、确定用哪个后再按需resolveClass。至于'打破',其实有三种不同的原因,不是一回事——第一种是 JDK 1.2 之前的历史遗留,第二种是 SPI 场景没办法(比如 JDBC),第三种是 Tomcat 这种要隔离的需求。"

面试官眼睛一亮:"来,一个个说。"

今天就把"打破"这件事,掰开揉碎讲清楚。


一、类加载的五个阶段

JVM 把一个类从字节码变成能用的对象,要经过五个阶段:

加载 → 验证 → 准备 → 解析 → 初始化

五个阶段之后,才是"使用",用完了还能"卸载"。

注意,这五个阶段是按顺序开始的,但不是严格串行。比如解析阶段,有可能在初始化之后才发生——这是为了支持 Java 的动态绑定。所以面试时别一口咬死"必须前一个完成才能开始下一个"。

下面一个个拆。


二、加载:把字节流请进内存

加载阶段,JVM 要做三件事:

一,通过一个类的全限定名,去获取定义这个类的二进制字节流。注意,规范里没说这个字节流必须来自.class文件——它可以来自 zip 包(jar)、来自网络(Applet 时代就是这么干的)、运行时计算生成(动态代理),甚至来自一个加密文件。你写多少种"读字节码"的方式,就有多少种加载来源。

二,把这个字节流所代表的静态存储结构,转化成方法区里的运行时数据结构。

三,在内存中生成一个代表这个类的java.lang.Class对象。这个对象是方法区里这份类数据的访问入口——你在代码里拿到的Xxx.class,指的就是它。

有一个细节值得记:数组类不通过类加载器创建,它是 JVM 直接在内存里动态构造的。但数组的元素类型(就是去掉[]的那个类),还是得靠类加载器去加载。


三、验证、准备、解析:三个容易被忽略的阶段

这三个阶段经常被面试者一句话带过,但里面全是考点。

验证:确保 Class 文件的字节流里的信息符合规范,不会危害虚拟机本身。要验四个东西——文件格式、元数据、字节码、符号引用。为什么要验?因为字节流不一定来自你的javac,可能被人改过,如果虚拟机直接执行一段恶意字节码,后果很严重。

准备:给类变量(就是static变量)分配内存并设置零值。这里的关键词是"类变量",不是实例变量——实例变量要等对象new出来才分配,那是堆上的事。还有一个反直觉的点:public static int value = 123;这句,准备阶段value是0,不是123,123要等到初始化阶段才赋上去。但如果是public static final int value = 123;(常量),准备阶段就直接赋成123了——因为编译期 javac 就给它生成了ConstantValue属性。

解析:把常量池里的符号引用替换成直接引用。符号引用就是一组字面的描述符,比如 "java/lang/Object";直接引用就是指向内存地址的指针或者偏移量。解析动作主要针对类、接口、字段、方法这几类。


四、初始化:<clinit>()到底干了啥

初始化阶段,才是真正执行类里那些赋值语句的地方。

JVM 会执行一个叫<clinit>()的方法——注意,这是编译器自动生成的"类构造器",名字里带尖括号,跟你在代码里写的构造方法<init>()不是一回事。

<clinit>()由编译器自动收集类里所有类变量的赋值动作和静态代码块里的语句合并而成。所以这两块的执行顺序,就是你写在源文件里的顺序。看个经典例子:

public class Father { static { System.out.print("父类静态块 "); } public static int value = 1; } public class Son extends Father { static { System.out.print("子类静态块 "); } public static int age = 2; }

Son 初始化时,先初始化父类 Father 的<clinit>(),再执行自己的。输出是"父类静态块 子类静态块"。顺序搞反了,很多初始化相关的 bug 就解释不通了。

<clinit>()还有三个特点:

- 如果类里没有静态变量赋值、也没有静态代码块,编译器可以不给它生成<clinit>()。

- 接口自己不会先执行父接口的<clinit>(),只有用到父接口里定义的变量时,父接口才会初始化。这和类是反的。

- JVM 保证一个类的<clinit>()在多线程环境中被正确地加锁同步。所以多个线程同时初始化一个类,只有一个线程会执行,其他线程阻塞等它执行完——这是 JVM 帮你做的同步。

这一条特别有用。单例模式里的静态内部类写法,靠的就是"类加载只执行一次<clinit>()"这个 JVM 保证来实现线程安全的。


五、什么时候才会触发初始化

初始化阶段只有六种情况会被触发,这六种叫"主动引用",其余都叫"被动引用",被动引用不会触发初始化。六种里最需要记住的是前四种:

一,遇到new、getstatic、putstatic、invokestatic四条字节码指令中的任意一条。

二,用java.lang.reflect包的方法对类做反射调用。

三,初始化一个类时,如果发现它的父类还没初始化,先初始化父类。

四,JVM 启动时,用户指定的主类(含main方法的那个)会先被初始化。

被动引用的几个经典反例,面试常考:

// 场景一:通过子类引用父类的静态字段,不会触发子类初始化 System.out.println(Son.value); // Son 不会被初始化,只有 Father 被初始化 // 场景二:通过数组定义引用类,不会触发初始化 Father[] arr = new Father[10]; // 只是分配了数组空间,Father 没被初始化 // 场景三:常量在编译期进了调用方的常量池,不会触发定义常量的类初始化 System.out.println(Father.CONST); // Father 没被初始化

场景三原理是:static final的常量在编译期就被 javac 塞进了调用类的常量池,运行的时候根本不会去"找"定义它的那个类,所以触发不了它的初始化。


六、双亲委派模型:不急着加载,先问爸爸

进入正题。

Java 有三层类加载器(JDK 8 的经典结构):

-启动类加载器(Bootstrap ClassLoader):由 C++ 实现,是 JVM 自身的一部分,负责加载JAVA_HOME/lib下(比如rt.jar)的核心类。

-扩展类加载器(Extension ClassLoader):由 Java 实现,负责加载JAVA_HOME/lib/ext目录下的类。

-应用程序类加载器(Application ClassLoader):也由 Java 实现,负责加载用户类路径(-classpath)上的类。你平时写的main所在的类,通常是它加载的。

除了最顶上的启动类加载器,其余都由 Java 实现,都继承java.lang.ClassLoader。

双亲委派模型的逻辑:一个类加载器收到类加载请求,它不会自己先去加载,而是把请求往上委派给父加载器;每一层都这样。只有当父加载器说自己搞不定(搜索范围里没找到这个类),子加载器才会自己去加载。

这看着像"踢皮球",其实是为了安全和去重。因为加载一个类时,先让爸爸的爸爸那一级去找,找到就用了——这样能保证像java.lang.Object这种核心类,不管你是哪个加载器请求的,最终都是同一个启动类加载器加载的那一份。否则,如果每个加载器都各自加载一份Object,你就能自己写一个假的java.lang.Object塞进去,整个类型系统就乱了。

看真源码。以下源码来自 JDK 8java.lang.ClassLoader#loadClass(java.lang.String, boolean)(有删减):

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 1. 先检查这个类是不是已经被加载过 Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { // 2. 有父加载器就委派给父加载器 if (parent != null) { c = parent.loadClass(name, false); } else { // 3. 没有父加载器,说明自己就是 Bootstrap 那一级 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器加载不了,抛异常,什么都不做,继续往下走 } if (c == null) { // 4. 父加载器都搞不定,才轮到自己加载 long t1 = System.nanoTime(); c = findClass(name); // 记录统计信息…… } } if (resolve) { resolveClass(c); } return c; } }

一行行看:

第一行synchronized (getClassLoadingLock(name))——getClassLoadingLock拿的是每个类名对应的一把锁(并发的类加载可能同时来请求,得保证同一个类不会被并发加载出两份)。

findLoadedClass(name)——先查缓存,加载过的直接返回,这是性能优化的第一道关。

if (parent != null) { c = parent.loadClass(name, false); }——这就是"委派"两个字的本体。先交给父加载器,注意第二个参数传的是false,意思是先不解析,等确定用这个类了再解析。递归上去,一层层问。

c = findBootstrapClassOrNull(name);——走到这里说明没有父加载器了,那当前的就是最顶层,去调用启动类加载器。

c = findClass(name);——父加载器抛了ClassNotFoundException,说明它也找不到,这时候才轮到自己findClass去本地找。

所以整个loadClass的骨架就一句话:查缓存 → 问父亲 → 父亲不行自己来。想自定义类加载器,规范推荐你重写findClass而不是loadClass,这样能保留双亲委派的逻辑。


七、双亲委派被"打破"的三次

"打破"这个词听着吓人,其实是三次目的完全不同的例外。面试官问这个,就是在看你知不知道它们的区别。

第一次:JDK 1.2 之前的历史遗留。

双亲委派模型是 JDK 1.2 才引进的。在这之前,用户自定义类加载器都是直接重写loadClass方法的。为了让兼容,JDK 1.2 之后,ClassLoader新增了一个protected的findClass方法——规范建议大家重写findClass,把委派逻辑留给loadClass。但你要是不守规矩,硬去重写loadClass,那委派逻辑就被你绕过去了。这是"被迫"的兼容。

第二次:基础类要调用用户的代码(SPI 场景)。

这是最经典、最该会的一个。模型本身有个死角——越底层的类加载器,加载的类越基础,它不认识上层加载器加载的类。

拿 JDBC 举例。java.sql.Driver这个接口,在rt.jar里,由启动类加载器加载。而 MySQL 的具体驱动实现com.mysql.cj.jdbc.Driver,是你打在应用依赖里的,由应用程序类加载器加载。问题来了:启动类加载器加载的基础代码想去 new 一个驱动实现,但它根本不认识应用类加载器那边的类,怎么做?

答案就是线程上下文类加载器(Thread Context ClassLoader)。你可以通过:

Thread.currentThread().setContextClassLoader(someLoader); Thread.currentThread().getContextClassLoader();

把子加载器"挂"到当前线程上。这样,父加载器加载的基础代码,虽然在类型上不认识子加载器的类,但可以通过getContextClassLoader()拿到子加载器,再让它去加载。相当于父加载器反过来请求子加载器干活,委派的方向被"倒置"了。JDBC 的DriverManager、JNDI、以及ServiceLoader(SPI 的标准实现)都是这么干的。这类场景,你重写loadClass是解决不了的,必须靠上下文类加载器。

第三次:为了热部署和隔离(Tomcat 是主角)。

第三次是用户主动的追求——想要同一个服务器上跑多个应用,应用之间互相隔离,还要能热部署。

Tomcat 为什么必须打破双亲委派?因为一个 Tomcat 上可能部署着十几个 web 应用:应用 A 依赖 Spring 4,应用 B 依赖 Spring 5;两个应用都有自己的commons-lang,版本还不一样。要是全走标准委派,大家都用同一个类加载器,这些同名不同版本的类就只能活一份,必然打架。

Tomcat 的做法是给每个 web 应用配一个自己的WebappClassLoader,并且改变加载顺序:

标准委派是"先问父亲,父亲不行自己来";而 Tomcat 的 web 应用类加载器,先在本地仓库(应用的WEB-INF/classes和WEB-INF/lib)里找,找不到再去委派给父加载器。顺序反过来了。

这一反转,就让每个应用优先加载自己的类,实现了应用之间的隔离:A 的 Spring 4 和 B 的 Spring 5 各活各的,井水不犯河水。同时 Tomcat 也留了例外——像java.*这种核心类,依然不能自己加载,还是要交给启动类加载器,不然又会引发类型混乱。

你可以这么理解:标准双亲委派是"听爸爸的",Tomcat 是"先看自己兜里有没有,没有才问爸爸"。前者保证安全,后者保证隔离。


🎯 面试官真正想听的答案

问他双亲委派,他想听的不是"先给父加载器加载"这一句结论。这一句谁都会背。他想知道你能不能把"打破"讲明白,因为"打破"才是把类加载机制从八股文拉回真实世界的地方。

第一层,先给结论:"类加载分加载、验证、准备、解析、初始化五个阶段,中间会执行编译器自动生成的<clinit>()。双亲委派是ClassLoader.loadClass里的逻辑——查缓存、委派父加载器、父加载器不行自己findClass。它存在的意义是安全(保证核心类只有一份)和去重。"

第二层,把三个容易错的点讲透:一是准备阶段给静态变量设的是零值,只有static final常量例外,准备阶段就赋值;二是被动引用不触发初始化,比如通过子类引用父类的静态字段、通过数组定义引用类、引用编译期常量;三是<clinit>()由 JVM 保证线程安全,单例的静态内部类写法就是靠这个。

第三层,把"打破"的三种原因分清:JDK 1.2 之前重写loadClass是历史兼容;SPI(JDBC、JNDI、ServiceLoader)是因为基础类要调用用户的实现,靠线程上下文类加载器倒置委派方向;Tomcat 是为了应用隔离和热部署,把加载顺序改成"先本地、后父加载器"。能把这三种区分开,还知道各自的动机,面试官基本就认可你懂类加载了。


下篇预告

下一篇聊 GC,而且是"演进史":从最早的 Serial,到 ParNew、Parallel,再到搞出并发标记的 CMS,然后是被当成默认的 G1,再到今天能处理超大堆的 ZGC。为什么 CMS 会被淘汰、G1 又解决了什么、ZGC 凭什么能把停顿做到毫秒级。

还有那块绕不过去的硬骨头——三色标记,以及并发标记中"对象消失"的问题。CMS 用增量更新,G1 用原始快照,这俩到底怎么堵住漏标的,下篇配图讲清楚。

想看源码的话,先翻翻G1CollectedHeap和 CMS 里那个incremental update相关的处理,思路其实很清晰。


唠点键盘之外的 · 第 24 篇

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询