高频八股 · JVM(2):类加载器与双亲委派,为什么 String 永远只有一份
写在前面
上一期讲了类加载的五个阶段。
但有个问题没回答:"加载"这一步,是谁在做?
答案就是类加载器(ClassLoader)。这一期把它讲透,包括那个面试必问的"双亲委派"。
一、类加载器是什么,有哪几种
它的职责:根据类的全限定名(比如 com.foo.Bar),找到对应的字节码,转成 JVM 里的 Class 对象。
JDK 里主要有三种(再加上自定义):
| 加载器 | 加载什么 | 怎么拿到 |
|---|---|---|
| Bootstrap(启动) | JAVA_HOME/lib 下的核心类(rt.jar / java.base) | getClassLoader() 返回 null |
| Platform(平台,JDK 9 前叫 Extension) | JAVA_HOME/lib/ext 或 jre/lib/ext | ClassLoader.getPlatformClassLoader() |
| Application(应用 / 系统) | classpath 上的类,也就是你自己写的 | ClassLoader.getSystemClassLoader() |
| 自定义 | 你指定的位置(网络、加密文件、动态生成) | 继承 ClassLoader |
一个小细节:启动类加载器是用 C++ 实现的,在 Java 里拿不到它的引用,所以
String.class.getClassLoader()返回的是null——看到 null,就说明是 Bootstrap 加载的。
还有一个容易搞错的点:这三个加载器之间不是继承关系,而是组合(包含)关系——每个加载器内部有一个 parent 字段指向上一级。所以它叫"双亲委派",其实是"父委派",只是名字这么叫。
二、判断两个类"相等",光看名字不够
这是面试高频陷阱。
JVM 判断两个类是否"相等"(equals、isAssignableFrom、instanceof),要同时满足两个条件:
- 全限定名相同;
- 由同一个类加载器加载。
所以——同一个 .class 文件,被两个不同的类加载器加载,得到的两个 Class 对象是不相等的。
Class<?> a = loaderA.loadClass("com.foo.Bar");
Class<?> b = loaderB.loadClass("com.foo.Bar");
System.out.println(a == b); // false
System.out.println(a.isAssignableFrom(b)); // false
这在实际中会引发很诡异的问题:明明"是同一个类",但互相 cast 就是报 ClassCastException。
这也是 Tomcat 做应用隔离的基础——不同 webapp 用不同的加载器,各加载各的,互不干扰。
三、双亲委派:它到底怎么走
一句话定义:加载一个类时,先委派给父加载器;父加载器加载不了,才自己加载。
流程图:
收到加载请求
│
▼
查自己有没有加载过?──有──▶ 直接返回
│没有
▼
有 parent 吗?
│ │
有 没有(=Bootstrap)
│ │
▼ ▼
parent.loadClass 交给启动类加载器
│ │
└────────┬───────────┘
▼
父加载成功?──是──▶ 返回
│否
▼
自己 findClass() 加载
看源码更清楚(ClassLoader.loadClass,简化版):
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 先看自己是否已经加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 2. 有父加载器 → 委派上去
c = parent.loadClass(name, false);
} else {
// 3. 没有父 → 说明到头了,找启动类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器没找到,忽略,继续往下
}
if (c == null) {
// 4. 父都加载不了,才自己动手
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
关键就在这个顺序:findLoadedClass → parent.loadClass → findClass。
记住一点:
loadClass是"委派逻辑",findClass才是"真正加载"。
想自定义加载器,推荐只重写findClass,别去动loadClass——动了就等于把双亲委派给改了。
四、为什么要双亲委派?
两个理由,都很硬:
① 安全:防止核心类被篡改
假设你自己写了一个 java.lang.String,里面藏了恶意代码。
因为双亲委派,加载请求会一路委派到 Bootstrap,而 Bootstrap 在核心库里已经找到了真正的 String——所以你写的那个永远不会被加载。
这就是"
String永远只有一份"的原因。
② 避免重复加载
父加载器加载过的类,子加载器直接复用,不会重复加载、不会产生多份 Class 对象,节省内存。
五、双亲委派被"打破"的三种情况
面试问到"双亲委派有什么缺陷"或者"怎么打破双亲委派",就在考这里。
5.1 第一次打破:JDK 1.2 之前的历史遗留
在 JDK 1.2 引入双亲委派之前,自定义加载器是直接重写 loadClass 的。为了兼容老代码,这个口子一直留着。
现在如果你自己重写
loadClass且不调super.loadClass(),就等于打破了委派。
5.2 第二次打破:SPI 与线程上下文类加载器
问题场景——JDBC:
java.sql.DriverManager在rt.jar里,由 Bootstrap 加载;- 你用的 MySQL 驱动
com.mysql.cj.jdbc.Driver在 classpath,要由 Application 加载。
矛盾来了:Bootstrap 加载 DriverManager 时,需要加载各厂商的 Driver 实现——但 Bootstrap 看不到 classpath(父看不到子)。
怎么破? 用线程上下文类加载器(TCCL):
Thread.currentThread().setContextClassLoader(classLoader);
ClassLoader cl = Thread.currentThread().getContextClassLoader();
父加载器"借"子加载器的手去加载——委派方向反过来了,这就是"打破"。
类似的还有 JNDI、JDBC 4.0 的
ServiceLoader、Spring 的spring.factories。
5.3 第三次打破:Tomcat 的 WebApp 隔离
Tomcat 里,每个 webapp 都有自己的 WebAppClassLoader,而且它的加载顺序是反的:
先自己加载(webapp/WEB-INF/classes、lib)
↓ 加载不到
再委派给父加载器
为什么? 因为不同 webapp 可能依赖同一个库的不同版本(一个用 Spring 4、一个用 Spring 6)。如果按双亲委派,先到先得,第二个应用就拿不到自己的版本了。
所以 Tomcat 是"先自己、后父类",实现应用间的类隔离。
5.4 补充:OSGi(网状结构)
OSGi 更激进——不是树形,是网状:每个 Bundle 有自己的加载器,按导入导出规则互相委派。这算第四种打破,了解即可。
六、类加载器的"隐藏价值"
除了加载,它还有三个实际用途,面试聊到"有什么用"可以加分:
| 用途 | 说明 |
|---|---|
| 隔离 | Tomcat 的多应用隔离、中间件插件隔离 |
| 热部署 / 热替换 | 换一个新加载器重新加载类,旧加载器连同旧类一起被回收 |
| 加密 / 动态生成 | 加载加密的 class,或运行时生成字节码(如动态代理、ASM) |
七、高频面试题速答
Q1:JVM 有哪几种类加载器?
Bootstrap(启动,C++ 实现)、Platform(JDK 9 前是 Extension)、Application(系统),以及自定义加载器。
Q2:getClassLoader() 返回 null 说明什么?
说明这个类由 Bootstrap ClassLoader 加载。
Q3:怎么判断两个类相等?
全限定名相同 且 由同一个类加载器加载。缺一不可。
Q4:双亲委派的工作流程?
findLoadedClass 查缓存 → parent.loadClass 委派父加载器 → 父加载不了才 findClass 自己加载。
Q5:为什么要双亲委派?
一是安全(防止核心类被篡改),二是避免重复加载。
Q6:怎么打破双亲委派?
三种典型:重写 loadClass 不调 super;用线程上下文类加载器(SPI / JDBC);Tomcat 的 WebAppClassLoader 先自己后父类。
Q7:Class.forName 和 ClassLoader.loadClass 有什么区别?
Class.forName 默认会执行初始化(走完 <clinit>);loadClass 只到"加载",不初始化(除非 resolve=true 且后续主动使用)。
所以 JDBC 老代码用 Class.forName("com.mysql.jdbc.Driver") 就是为了触发静态块注册驱动。
八、一句话总结
双亲委派就是"先问爹、爹不行再自己上":保证核心类只有一份、防止被篡改;而 SPI 和 Tomcat 之所以要打破它,是因为"爹看不见儿子的东西"和"儿子们需要各用各的版本"。
(本系列持续更新中,共 10 期,敬请关注~)
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2401_88601046/article/details/169168742




