香蕉大香蕉(06敬畏市场)头像
高频八股 · JVM(2):类加载器与双亲委派封面图

高频八股 · JVM(2):类加载器与双亲委派

高频八股 · 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/extClassLoader.getPlatformClassLoader()
Application(应用 / 系统)classpath 上的类,也就是你自己写的ClassLoader.getSystemClassLoader()
自定义你指定的位置(网络、加密文件、动态生成)继承 ClassLoader

一个小细节:启动类加载器是用 C++ 实现的,在 Java 里拿不到它的引用,所以 String.class.getClassLoader() 返回的是 null——看到 null,就说明是 Bootstrap 加载的。

还有一个容易搞错的点:这三个加载器之间不是继承关系,而是组合(包含)关系——每个加载器内部有一个 parent 字段指向上一级。所以它叫"双亲委派",其实是"父委派",只是名字这么叫。

二、判断两个类"相等",光看名字不够

这是面试高频陷阱。

JVM 判断两个类是否"相等"(equals、isAssignableFrom、instanceof),要同时满足两个条件:

  1. 全限定名相同;
  2. 由同一个类加载器加载。

所以——同一个 .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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--