资讯详情

24 面试官问双亲委派,其实是想看你知不知道它为什么会被“打破“

📅 2026/10/10 2:51:27 | 华诺云谱 👁 阅读
24 面试官问双亲委派,其实是想看你知不知道它为什么会被“打破“
面试现场面试官先抛了个热身题。面试官一个.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不是123123要等到初始化阶段才赋上去。但如果是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、以及ServiceLoaderSPI 的标准实现都是这么干的。这类场景你重写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是历史兼容SPIJDBC、JNDI、ServiceLoader是因为基础类要调用用户的实现靠线程上下文类加载器倒置委派方向Tomcat 是为了应用隔离和热部署把加载顺序改成先本地、后父加载器。能把这三种区分开还知道各自的动机面试官基本就认可你懂类加载了。下篇预告下一篇聊 GC而且是演进史从最早的 Serial到 ParNew、Parallel再到搞出并发标记的 CMS然后是被当成默认的 G1再到今天能处理超大堆的 ZGC。为什么 CMS 会被淘汰、G1 又解决了什么、ZGC 凭什么能把停顿做到毫秒级。还有那块绕不过去的硬骨头——三色标记以及并发标记中对象消失的问题。CMS 用增量更新G1 用原始快照这俩到底怎么堵住漏标的下篇配图讲清楚。想看源码的话先翻翻G1CollectedHeap和 CMS 里那个incremental update相关的处理思路其实很清晰。唠点键盘之外的 · 第 24 篇
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑