Loading

了解java类加载机制与双亲委派

了解java类加载机制与双亲委派

一、JDK8 类加载机制基础梳理

1. JDK8 类加载体系
  • 核心层级:JDK8 类加载器通过 “parent 属性” 形成父子关系,自上而下为:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 查看类是否已经被加载过,从当前类加载器缓存中找
            Class<?> c = findLoadedClass(name);
            //没加载过
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        //那我就看看自己的父类有没有加载过喽
                        c = parent.loadClass(name, false);
                    } else {
                        //bootstrap类加载器
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
    			
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    // ⽗类加载起没有加载过,就⾃⾏解析class⽂件加载
                    c = findClass(name);
    
                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            //验证、准备、解析
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
    
  • 核心方法ClassLoader.loadClass(String name, boolean resolve),实现双亲委派逻辑,流程如下:

    1. 同步检查缓存(findLoadedClass()),若已加载直接返回;
    2. 未加载则委托父加载器(parent.loadClass()),Bootstrap 无父加载器时调用findBootstrapClassOrNull()
    3. 父加载器加载失败(抛ClassNotFoundException),自身调用findClass()加载 Class 文件;
    4. resolve=true,执行 Linking 链接过程(resolveClass(),native 方法)。
  • 类加载路径:通过系统属性查看各加载器的默认加载目录:

    类加载器 系统属性 加载内容
    Bootstrap sun.boot.class.path JDK 核心类(如 java.lang.String)
    ExtClassLoader java.ext.dirs 扩展类(如 javax.*)
    AppClassLoader java.class.path 应用类(CLASSPATH 下的 Jar/Class)
2. 沙箱保护机制
  • 核心逻辑:通过ClassLoader.preDefineClass()方法防止恶意类覆盖 JDK 核心类,当类名以 “java.” 开头时,直接抛出SecurityException(如Prohibited package name: java.lang)。
  • 设计背景:为 JDK 核心类(如java.lang.Object)增加额外防护,避免双亲委派机制被绕过,这也是 JDK 中存在 “javax.” 前缀包的原因(规避 “java.” 的限制)。
3. Linking 链接过程

image-20250923141204511

Linking 是类加载后的核心步骤,分为加载、验证、准备、解析、初始化5 个阶段,其中 “准备→解析→初始化” 属于 Linking,核心功能如下表:

阶段 核心功能 关键细节(文档案例)
加载 通过类加载器将 Class 二进制文件加载到 JVM 内存 应用可自定义类加载器干预此阶段(如加载加密 Class)
验证 检查 Class 文件是否符合 JDK 规范(如魔数 CAFEBABE、版本兼容性) 保障 JVM 安全,避免恶意二进制文件注入
准备 静态变量分配内存并赋默认值(半初始化状态) Apple 类中static double price=20.0,准备阶段 price 为 0.0,导致构造函数计算结果为 - 10
解析 将符号引用(如类名、方法名)转换为直接引用(内存地址) A 类引用 B 类时,初始化前 B 类地址未知(符号引用),初始化后转为实际地址(直接引用)
初始化 为静态变量赋指定值,执行静态代码块,初始化父类 触发时机:调用静态属性 / 方法、new 对象、反射,遵循 “父类优先” 原则

二、类加载机制的实际业务场景(老王加薪故事)

1. 通过类加载器引入外部 Jar 包
  • 核心需求:将工资计算逻辑(SalaryCaler类)从 OA 系统抽离至外部 Jar,避免经理查看源码。
  • 实现方案:使用 JDK 自带的URLClassLoader,从本地 / 远程 Jar 包加载类:
    1. 构建 Jar 包 URL(如file:/xxx/SalaryCaler.jar);
    2. 创建URLClassLoader实例,指定 Jar 路径;
    3. 调用loadClass("com.roy.oa.SalaryCaler")加载类,通过反射调用cal()方法。
  • 拓展思考
    • 适合场景:规则引擎、审批流程等 “逻辑统一但实现易变” 的模块;
    • 加载来源:支持远程 Web 服务器(如http://xxx/SalaryCaler.jar),类似 Drools 规则引擎从 Maven 仓库加载规则文件。
2. 自定义类加载器实现 Class 代码混淆
  • 核心需求:防止外部 Jar 被反编译,隐藏工资计算逻辑(如cal()方法的 1.4 倍系数)。

  • 实现方案

    • 方案 1:修改 Class 后缀(.class→.myclass),自定义类加载器(如SalaryClassLoader)重写findClass(),读取.myclass 文件并调用defineClass()生成类;
    • 方案 2:加密 Class 内容(如在二进制开头加 “1”),类加载时忽略加密位(如读取时跳过第一个字节)。
  • 代码思路

// 自定义类加载器读取.myclass文件
  protected Class<?> findClass(String fullClassName) {
  String filePath = classPath + fullClassName.replace(".", "/") + ".myclass";
    // 读取文件流→转换为字节数组→调用defineClass()
  byte[] data = readMyClassFile(filePath); 
    return defineClass(fullClassName, data, 0, data.length);
}
  • 拓展思考

    • 安全性提升:使用对称加密(如 AES)或非对称加密(如 RSA)替代简单前缀,避免加密逻辑被轻易破解;
    • Jar 包适配:自定义SalaryJARLoader,从 Jar 包中读取加密 Class 文件(通过jar:file:URL 访问 Jar 内资源)。
3. 自定义类加载器实现热加载
  • 核心痛点:修改外部 Jar 后,类加载器缓存导致新逻辑不生效,需重启 OA 系统(暴露操作痕迹)。

  • 实现原理:类加载器会缓存已加载的类,每次新建类加载器实例可清空缓存,强制从 Jar 包重新加载类:

    // 每次调用calSalary()时新建SalaryJARLoader
    private static Double calSalary(Double salary) {
      SalaryJARLoader loader = new SalaryJARLoader("/xxx/SalaryCaler.jar"); // 新实例→空缓存
      Class<?> clazz = loader.loadClass("com.roy.oa.SalaryCaler");
      // 反射调用cal()方法
    }
    
  • 拓展思考

    • 弊端:频繁创建类加载器会导致元空间(MetaSpace)中积累大量无用类缓存,增加 GC 线程压力,开源项目(如 IDEA JRebel、Arthas)通过优化类卸载机制缓解此问题;
    • 懒加载机制:JVM 加载SalaryCaler时,会顺带加载DoubleObject等依赖类(用到时才加载,提升启动速度)。
4. 打破双亲委派,实现同类多版本共存
  • 问题场景:小王在 OA 系统中提交SalaryCaler类,AppClassLoader优先加载此类(双亲委派机制),导致外部 Jar 的SalaryCaler无法被加载,热加载失效。

  • 实现方案:重写loadClass()方法,反转双亲委派逻辑 ——优先自身加载,失败再委托父加载器

    public Class<?> loadClass(String name, boolean resolve) {
      synchronized (getClassLoadingLock(name)) {
        Class<?> c = findLoadedClass(name);
        if (c == null) {
          c = findClass(name); // 先自身加载(外部Jar)
          if (c == null) {
            c = super.loadClass(name, resolve); // 再委托父加载器(OA系统内类)
          }
        }
        if (resolve) resolveClass(c);
        return c;
      }
    }
    
  • 实际应用:Tomcat 的类加载器设计(打破双亲委派):

    Tomcat 类加载器 作用 打破双亲委派逻辑
    CommonClassLoader 加载 Tomcat 与 Webapp 共享类 遵循双亲委派
    CatalinaClassLoader 加载 Tomcat 私有类(Webapp 不可见) 优先加载 Tomcat 私有目录,再委托父加载器
    SharedClassLoader 加载 Webapp 共享类(Tomcat 不可见) 优先加载共享目录,再委托父加载器
    WebappClassLoader 每个 Webapp 私有,加载 WAR 包内类 优先加载 WAR 包,再委托父加载器,实现多版本隔离(如不同 Spring 版本)
    JspClassLoader 每个 JSP 独立加载器 支持 JSP 热更新(修改后新建加载器)
5. 使用 SPI 机制摆脱反射依赖
  • 核心痛点:自定义类加载器加载的SalaryCaler需通过反射调用(如clazz.getMethod("cal").invoke()),无法直接类型转换(如SalaryCaler caler=(SalaryCaler)obj会抛ClassCastException)。

  • 解决方案:JDK SPI(Service Provider Interface)扩展机制,通过 “接口 + 配置文件” 实现无反射调用:

    1. 定义统一接口:SalaryCalService(含cal(Double salary)方法);
    2. 外部 Jar 中实现接口:SalaryCaler implements SalaryCalService
    3. 配置文件:在META-INF/services/com.roy.oa.SalaryCalService中写入实现类全类名;
    4. 加载逻辑:通过ServiceLoader.load(SalaryCalService.class, classLoader)加载实现类,结合线程上下文类加载器(Thread.currentThread().setContextClassLoader())切换加载器。
  • 代码思路

private static SalaryCalService getSalaryService(ClassLoader classloader) {
ClassLoader oldCl = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(classloader); // 切换类加载器
ServiceLoader services = ServiceLoader.load(SalaryCalService.class);
Thread.currentThread().setContextClassLoader(oldCl); // 恢复
return services.iterator().next(); // 获取实现类实例
}




- **框架延伸**:SpringBoot 的`SpringFactoriesLoader`(如`loadFactoryNames(ApplicationContextInitializer.class)`)本质是自定义 SPI 机制,通过`META-INF/spring.factories`配置实现类加载,无需反射即可实例化扩展组件。



### 关键问题

#### 问题 1:JDK8 双亲委派机制的核心作用是什么?为什么 Tomcat 需要打破双亲委派机制?

**答案**:

1. 双亲委派机制的核心作用:

 - **保护 JDK 核心类**:通过 “向上委托查找”,JDK 核心类(如`java.lang.String`)优先由 Bootstrap ClassLoader 加载,避免应用自定义类覆盖核心类(如自定义`java.lang.String`会被沙箱机制拦截);
 - **避免类重复加载**:父加载器已加载的类,子加载器无需重复加载(如 ExtClassLoader 加载的`javax.servlet`,AppClassLoader 无需再加载)。

2. Tomcat 打破双亲委派的原因:

 Tomcat 需部署多个 Web 应用(如 A 应用用 Spring 5,B 应用用 Spring 6),若遵循双亲委派,AppClassLoader 会优先加载某个版本的 Spring 类(如 Spring 5),导致 B 应用的 Spring 6 类无法加载。

 因此 Tomcat 自定义`WebappClassLoader`,重写`loadClass()`方法:**优先加载当前 Web 应用 WAR 包内的类**,仅当加载失败时才委托父加载器(Shared→Common→Ext→Bootstrap),实现不同 Web 应用的类隔离,支持多版本框架共存。

#### 问题 2:文档中 “热加载” 功能的实现原理是什么?为什么开源项目中较少直接使用这种方式?

**答案**:

1. 热加载的实现原理:

 类加载器会缓存已加载的类(`findLoadedClass()`优先检查缓存),若需更新类逻辑,**每次加载时新建类加载器实例**(如`new SalaryJARLoader(jarPath)`),新实例的缓存为空,会强制从外部 Jar 包重新读取 Class 文件,实现 “修改 Jar 后无需重启应用即可生效”。

2. 开源项目较少使用的原因:

 - **元空间压力大**:每次新建类加载器会在元空间(MetaSpace)中积累对应的类元数据(如类模板、静态变量),且类的回收效率极低(仅自定义类加载器加载的类可能被回收),导致元空间内存占用持续增长;
 - **GC 负担重**:大量无用的类加载器与类元数据成为垃圾,会增加 GC 线程的回收压力,甚至触发 FullGC,影响应用性能;
 - **替代方案成熟**:开源工具(如 IDEA JRebel、Arthas)通过优化类卸载机制(如复用类加载器、清理元数据缓存)实现高效热加载,无需频繁创建类加载器。

#### 问题 3:JDK 的 SPI 机制如何解决 “自定义类加载器需反射调用” 的问题?

**答案**:

SPI 机制通过 “**接口定义 + 配置文件 + 线程上下文类加载器**”,实现无反射调用自定义类加载器加载的类,核心逻辑如下:

1. 解决反射的核心思路:

 反射调用的根源是 “不同类加载器加载的同类(如`com.roy.oa.SalaryCaler`)不兼容”(JVM 判定类相等的条件:全类名相同 + 类加载器相同),SPI 通过 “面向接口编程” 规避此问题 —— 定义统一接口(如`SalaryCalService`),不同类加载器加载的实现类均视为接口的实例,可直接调用接口方法。

2. 案例中的核心步骤:

 - 步骤 1:定义接口`SalaryCalService`(含`cal(Double salary)`方法);
 - 步骤 2:外部 Jar 中实现接口(`SalaryCaler implements SalaryCalService`),并在`META-INF/services/com.roy.oa.SalaryCalService`中写入`com.roy.oa.SalaryCaler`;
 - 步骤 3:切换线程上下文类加载器(`Thread.currentThread().setContextClassLoader(自定义加载器)`),通过`ServiceLoader.load(SalaryCalService.class)`自动加载配置文件中的实现类;
 - 步骤 4:直接调用接口方法(`salaryService.cal(salary)`),无需反射,因为`salaryService`是`SalaryCalService`类型,与类加载器无关。

3. 关键技术点:

 线程上下文类加载器打破了 “双亲委派的类加载方向”(默认从下至上),允许`ServiceLoader`使用自定义类加载器(而非系统类加载器)加载实现类,确保外部 Jar 的类能被正确实例化。
posted @ 2025-09-23 18:59  流火无心  阅读(18)  评论(0)    收藏  举报