1. 类加载的完整过程?(五阶段)

  1. 加载(Loading):通过全限定名获取二进制字节流(class 文件/网络/jar),转化为方法区的运行时数据结构,堆中生成 java.lang.Class 对象作为入口
  2. 验证(Verification):文件格式、元数据、字节码语义验证,保证不会危害 JVM(ClassFormatError/VerifyError 都在这里)
  3. 准备(Preparation):为静态变量分配内存并赋零值(注意:是零值,不是代码里的初始值!static int a = 10 此时 a=0;真正的 10 在初始化阶段赋)
  4. 解析(Resolution):常量池的符号引用 → 直接引用(定位到实际内存地址)。解析可延迟到首次使用
  5. 初始化(Initialization):执行 <clinit>()(静态变量赋值 + 静态代码块)。只有主动使用类才会触发初始化

什么时候会触发初始化(主动使用):new/getstatic/putstatic/invokestatic、反射、初始化子类前先初始化父类、main 类、JDK7 动态语言支持。被动使用不触发:通过子类引用父类静态字段(只初始化父类)、定义数组引用、final 常量(编译期已入常量池)。

🎯 面试要点

  • 准备阶段赋零值 vs 初始化赋真实值的区分是经典考点
  • 初始化是线程安全的:JVM 保证 clinit 加锁执行,多线程同时触发只执行一次
  • 加载、验证、准备、初始化的顺序是确定的,解析可推迟

2. 什么是双亲委派模型?为什么要这么设计?

三个内置类加载器:

工作流程:一个类加载请求,先委派给父加载器,父加载器逐级向上,只有父加载器加载不了(找不到类)才回落到子加载器自己加载。

设计目的:

  1. 避免重复加载:类只被加载一次
  2. 安全性:核心类必须由 Bootstrap 加载。防止你自己写一个 java.lang.String 替换 JDK 的 String(自定义 String 无法加载,因为请求委派到 Bootstrap,它找不到就抛,不会让应用加载器加载同名核心类)
源码核心:loadClass 递归委派
protected Class<?> loadClass(String name) {
    synchronized (getClassLoadingLock(name)) {
        Class<?> c = findLoadedClass(name);        // 1. 已加载过?
        if (c == null) {
            try {
                if (parent != null)
                    c = parent.loadClass(name);          // 2. 先让父加载器加载
                else
                    c = findBootstrapClassOrNull(name);
            } catch (ClassNotFoundException e) {
                // 父加载器抛异常说明它加载不了
            }
            if (c == null)
                c = findClass(name);                     // 3. 父加载不了,自己加载
        }
        return c;
    }
}

🎯 面试要点

  • 类相等判断:同一个类,不同加载器加载 = 两个不同的 Class(可用 ObjectInputStream 序列化报 ClassCastException 引出)
  • 判断类是否由同一加载器加载:obj.getClass().getClassLoader()
  • JDK9+ 模块化后内置加载器层级仍在,只是扩展类加载器变为平台类加载器

3. 什么场景会破坏双亲委派?

双亲委派要求"核心类不被应用类加载",但框架需要反向使用应用类加载器加载自己的实现类,于是出现了三次破坏:

  1. 第一次:JDBC(SPI 机制)。DriverManager 在 rt.jar 中由 Bootstrap 加载,但 MySQL 驱动在 classpath。JDBC 用 Thread.currentThread().getContextClassLoader()(线程上下文类加载器)加载驱动实现——变相破坏"只向上委派"。
  2. 第二次:Tomcat 容器。每个 WebApp 需要隔离(两个应用可以加载不同版本的 Spring)+ 共享基础类。Tomcat 自定义 WebAppClassLoader 先加载 WEB-INF/classes,找不到再向上委派——顺序反转。
  3. 第三次:OSGi / 热部署。追求模块化和动态替换,类加载器网络化,没有父子关系。

注意:SPI 不是推翻双亲委派,而是补充了一条"由内向外"的加载路径。

🎯 面试要点

  • JDBC 的 4 步驱动加载流程:DriverManager 初始化 → 加载 META-INF/services → ServiceLoader → 线程上下文类加载器
  • Tomcat 的 WebAppClassLoader 先自己找 WEB-INF/classes、WEB-INF/lib(JRE 核心类除外),找不到才向上委派——顺序与标准双亲委派相反
  • Arthas 能挂到进程上靠的是动态修改类(Instrumentation),与类加载顺序无关

4. 如何自定义类加载器?典型应用场景?

继承 ClassLoader,重写 findClass()(JDK 推荐,保持双亲委派)或 loadClass()(完全接管)。核心就是读取字节码 → defineClass() → 返回 Class。

从自定义路径加载 class
class DiskClassLoader extends ClassLoader {
    private final String path;

    DiskClassLoader(String path, ClassLoader parent) {
        super(parent);
        this.path = path;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            byte[] bytes = Files.readAllBytes(
                Path.of(path, name.replace('.', '/') + ".class"));
            return defineClass(name, bytes, 0, bytes.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }
}

应用场景:热部署(改代码不重启,重新 new 一个加载器加载新版本 class)、加密 class 文件(解密后再 defineClass)、模块隔离(不同插件不同加载器)。

🎯 面试要点

  • 重写 findClass 保持双亲委派;重写 loadClass 才会破坏委派
  • 热部署的代价:旧类加载器无法卸载 → 频繁热部署会 Metaspace 泄漏(JDK8 前是永久代泄漏)
  • 父子加载器的类可见性:子可见父的类,父不可见子的类

🎤 常见面试追问

  1. 双亲委派能防止什么?——防止核心类被篡改(自定义 java.lang.String 无法加载,请求最终被 Bootstrap 拒绝)和重复加载(同名字节码只加载一次)。
  2. 为什么 JDBC 要打破双亲委派?——驱动类在 classpath(应用加载器管的),但 DriverManager 由 Bootstrap 加载,看不到 classpath——用线程上下文类加载器反向加载驱动实现。
  3. Tomcat 为什么要自定义类加载器?——Web 应用隔离:两个应用可以加载不同版本的 Spring;先加载 WEB-INF/classes 再向上委派(顺序反转)。
  4. 热部署的代价?——每次热部署 new 一个类加载器加载新类,旧加载器无法卸载 → 元空间(JDK8 前永久代)慢慢泄漏。频繁热部署要监控 Metaspace。
  5. 什么情况下类不会初始化?——被动使用:通过子类引用父类静态字段(只初始化父类)、定义数组引用、引用 final 常量(编译期已入常量池)。

📖 名词解释(本页术语)

术语 大白话解释
类加载器(ClassLoader)负责把 .class 字节码读进内存并生成 Class 对象的"搬运工"。启动类(Bootstrap)/扩展类(Platform)/应用类(App)三层。
双亲委派模型类加载请求先交给父加载器,父加载不了才自己加载——保证核心类由核心加载器加载、安全且不重复。
加载 → 初始化类加载五大阶段:加载(读字节码)→ 验证(安全校验)→ 准备(静态变量赋零值)→ 解析(符号引用转直接引用)→ 初始化(执行静态块/赋值)。
符号引用 vs 直接引用符号引用是"名字"(类名.方法名),直接引用是"内存地址"——解析阶段把名字换成地址。
clinit 方法编译器生成的"类初始化方法"(静态赋值 + 静态块合并),由 JVM 加锁执行一次。
SPI / 线程上下文类加载器SPI = 服务提供接口(JDBC 驱动、日志实现);线程上下文类加载器 = 让核心类反向加载应用类的手段。
defineClassClassLoader 的核心方法:把字节数组变成 Class 对象。自定义类加载器最终都调它。
⚠️ 本页面由 AI 生成,内容仅供参考,请以官方文档和实际源码为准。