JVM的类加载子系统(Class Loader Subsystem)是我每次排查线上诡异问题都会先怀疑的对象。很多人觉得它不过是“把.class文件读进内存”,但真正深入过一轮之后才会发现,几乎所有ClassNotFoundException、NoClassDefFoundError、热部署失效、Metaspace溢出,根子都埋在这条不起眼的链路上。这篇文章不打算按教科书式的顺序背一遍规范,而是把加载、链接、初始化三个阶段和类加载器体系串起来,结合我自己实际排查和改造的经验讲透,适合正在准备JVM面试的兄弟,也适合线上被各种类加载异常折磨的同行。理解了它,很多问题不需要靠猜。
1. 类加载子系统在JVM里到底扮演什么角色
1.1 没它,字节码只是一堆静态数据
.class文件说到底就是一组二进制字节流,里面有魔数、版本号、常量池、字段表、方法表,但这些东西本身不能被JVM直接执行。JVM运行时真正使用的是方法区里的类型信息、堆里的对象、栈里的栈帧。类加载子系统干的活,就是把这种静态的字节流翻译成运行时结构:把类的元数据放到方法区,在堆中生成一个java.lang.Class对象,作为这个类的门面。之后所有new、反射、静态方法调用、instanceof判断,都要通过这个门面入口才能触达真正的类型信息。
我经常用一个生活化的类比来理解它:把.class文件当成一张菜谱,加载就是有人把菜谱从书架上拿进厨房,链接阶段是检查食材新不新鲜、提前备菜,初始化才是起锅烧油、真正开始做菜。前两步没做好,后面再好的菜谱也炒不出一盘能上桌的菜。很多开发者在排查类加载问题时,往往盯着初始化阶段的代码看,却忽略了前置的加载和链接环节,这是定位方向上的第一误区。
1.2 这些你见过的报错,根都在这条链路上
平时遇到“加了依赖还是NoClassDefFoundError”“启动时明明没引用某个类却抛ClassNotFoundException”“升级JDK后启动直接UnsupportedClassVersionError”“热部署改了代码但不生效”,这些现象看起来毫无关联,本质都跟类加载子系统的某个环节有关。
- 依赖冲突:多个Jar里存在同名类,被不同层级的加载器加载,最终类型不一致,抛出ClassCastException或NoSuchMethodError。
- 版本不对:字节码版本号高于当前JVM支持的最大版本,在验证阶段直接拒绝加载。
- 触达路径不对:类加载器委托链走不到位,应用类被某个隔离的加载器“关在小黑屋”里,其他加载器看不到。
- 字节码损坏或被篡改:验证阶段发现结构非法,抛出ClassFormatError或VerifyError。
- 静态初始化失败:一个类的static块抛异常,第一次使用它时虽然能找到类,但初始化失败,后续全部报ExceptionInInitializerError,再后面可能出现NoClassDefFoundError。
从这些表现能看出来,类加载子系统不是一个只会“读文件”的搬运工,它是JVM安全模型、类型体系、组件隔离的基础设施。它的输入不仅是本地磁盘上的class文件,还可能是Jar/War包入口、网络流、数据库字段,甚至运行时动态生成的字节码。理解这一点,才是后续排查的正确起点。
2. 加载、链接、初始化三步的执行顺序与原理
2.1 加载阶段:字节码从哪来、进哪去
JVM规范对加载阶段的描述非常克制,只说了“通过类的全限定名获取此类的二进制字节流”,至于这个字节流从哪里来,JVM不管。这就是为什么类加载器可以做得那么灵活:从本地文件读、从ZIP读、从网络读、从内存里的byte[]读、运行时用字节码生成库动态拼装,都可以。规范把“获取字节流”这件事完全交给了ClassLoader实现,它只是定义了一个接口级别的约定。
拿到字节流之后,加载阶段要做三件事:把字节流按照class文件格式解析成方法区的运行时数据结构;在堆中生成java.lang.Class对象;建立Class对象与类加载器之间的关联。这个关联非常关键,因为后续判断两个类是否相等,必须要看它们是不是被同一个类加载器加载。同一个全限定名,被两个不同的加载器加载,在JVM看来就是两个完全不同的类型,强行做类型转换会抛ClassCastException。
数组类的情况比较特殊。数组类型不是由类加载器创建的,而是JVM根据元素类型直接构造出来的。比如String[],JVM在运行时直接生成一个代表数组类型的对象,不调用类加载器的loadClass。但如果是引用类型数组,比如User[],那么加载数组的同时,JVM会去递归加载数组元素User类,这时候User类的加载还是要走类加载器。这个细节容易忽略,但排查某些反射场景下的数组类加载异常时会有用。
HotSpot默认是懒加载,大部分类在首次主动使用时才被加载,而不是启动时一次性全加载。所以应用启动顺利,不代表所有类都健康,某个冷门路径上的类可能直到线上真正被触达时才爆炸。这也是为什么很多问题看起来“偶发”,其实只是触发时机的问题。
2.2 链接阶段:验证、准备、解析三连
链接阶段是关键中的关键,它要保证字节码是安全且可执行的。第一小步是验证,总共四层:文件格式验证、元数据验证、字节码验证、符号引用验证。文件格式验证检查的是最硬性的约束,比如文件是否以CAFEBABE开头、主次版本号是否在JVM可接受范围内,这一层失败会直接抛UnsupportedClassVersionError或ClassFormatError。元数据验证检查类型层面的是否合法,比如类是否有父类、是否继承了被final修饰的类、是否违反抽象类与接口的约束。字节码验证是最复杂的,早期JVM通过数据流分析逐条指令推导类型,后来引入了StackMapTable,用栈帧中记录的类型信息做快速校验,大幅提升了验证效率。符号引用验证则是确保常量池里的符号能匹配到实际存在的类、字段、方法。
第二小步是准备。这部分主要给static变量分配内存并设置默认值。比如static int count,在准备阶段count不是0吗?是0,但这个0不是代码里写的“if(count==0)”的那个0,而是JVM给基础类型默认的零值。如果是static final常量,并且编译期就能确定值,那准备阶段直接赋值,不走默认值流程。需要注意,实例变量不在这里处理,实例变量是在new对象分配内存时初始化的。很多人混淆类变量和实例变量,排查空指针时容易找错阶段。
第三小步是解析。解析是把常量池里的符号引用替换为直接引用的过程。符号引用就是一个字面约定,比如“java/lang/String”这个全限定名,直接引用则是JVM内部能直接定位到目标类、字段、方法的内存地址或偏移量。解析可以发生在链接阶段,也可以延迟到某个符号真正被使用的时候,HotSpot在实现上做了大量延迟解析来提升启动性能。正因如此,某个方法可能直到第一次被调用时才暴露出符号引用找不到的问题。
2.3 初始化阶段:静态逻辑真正执行
初始化阶段执行的是clinit方法,它不是我们在Java代码里能直接看到的方法,而是编译器收集静态字段赋值语句和static块之后生成的。static变量的赋值、static块的代码,都会按它们在源码中出现的顺序合并到这个方法里。初始化是类生命周期中真正“执行代码”的第一步,前面加载和链接更多是准备数据结构和做检查。
什么情况下会触发初始化?规范里有严格的主动引用场景:new一个类的实例、通过反射调用Class.forName、访问类的静态字段(不包括final编译期常量)、调用类的静态方法、初始化子类时会先触发父类的初始化、JVM启动时指定的main入口类、MethodHandle调用等。反过来,被动引用场景不会触发初始化:通过子类引用父类static字段时,只触发父类初始化,不触发子类;定义一个类数组,比如User[] users = new User[10],只触发数组类型的构造逻辑,不触发User初始化;访问final static编译期常量也不触发,因为这个值在编译期已经作为常量值写入了调用方的常量池。
clinit方法还有一个非常重要的语义:JVM会保证它在多线程环境下只执行一次。如果多个线程同时触发同一个类初始化,只有一个线程能执行clinit,其余线程必须阻塞等待。这个机制保证了静态数据的一致性,也带来一个隐患:如果clinit里有耗时的初始化逻辑或者相互等待的锁,就可能导致其他线程全部卡死。我见过最典型的问题是A类的静态块里初始化B的实例,B的静态块又去访问A的静态字段,两个类都在等待对方完成初始化,形成类初始化死锁。这种问题用jstack看线程栈非常隐蔽,不会直接显示“deadlock”,但一堆线程卡在类初始化相关的调用里。
3. 类加载器体系与双亲委派模型拆解
3.1 三层加载器,Bootstrap、Platform、App各管什么
JVM的类加载器不是只有一个,而是有层级关系的若干加载器。启动类加载器(Bootstrap ClassLoader)最特殊,它不是Java类,而是由JVM自身实现,在HotSpot里是通过C++实现的。它的职责是加载JVM最基础的核心类,比如java.lang、java.util、java.io这些包下的类。JDK8及以前它负责加载rt.jar,JDK9模块化之后,改成按模块加载java.base等核心模块里的类。因为它是C++写的,所以它的ClassLoader对象在Java代码里拿不到,打印出来会是null。
JDK8里有扩展类加载器(Extension ClassLoader),JDK9之后换成了平台类加载器(Platform ClassLoader),职责是加载一些扩展的、非核心的但又是平台级的类,比如JDK自带的一些技术模块。应用类加载器(Application ClassLoader)就是我们熟知的系统类加载器,它的加载路径是classpath,也就是我们配置的依赖路径和我们自己的业务类。绝大多数项目的业务代码,默认都是由应用类加载器加载的。
这三者之间的关系不是“互相独立”,而是“上层向下层委托”。应用类加载器的父加载器是平台类加载器,平台类加载器的父加载器是启动类加载器。注意这里的“父加载器”不是Java继承层面上的父类,而是一个指向更高层级加载器的引用。真正执行加载时,会按“应用→平台→启动”的顺序向上委托,启动加载器找一遍没有,再往下让平台加载器找,平台再没有,最终落到应用加载器自己找。这个过程就是双亲委派模型。
3.2 双亲委派:为什么JDK的String永远优先
双亲委派模型的核心逻辑很简单:一个类加载器收到加载请求后,不会自己先去加载,而是先把请求委托给父加载器,每一层都这么做,只有父加载器反馈“找不到这个类”,子加载器才会自己去尝试加载。这个设计的最大价值是保证了核心类库的安全和统一。设想一下,如果应用类加载器可以自行加载java.lang.String,那我们完全可以在classpath里放一个自己写的、篡改过的String类,把它替换进JVM核心库,那后果不堪设想。有了双亲委派,所有java.*核心类的加载请求都会被委托到启动类加载器,它会直接从JDK自带的核心模块里找到并加载那个真正的String,而我们自己写的同名类根本没有机会被执行。
这个机制同时还避免了类的重复加载。同一个全限定名,如果不同加载器都去加载一次,JVM里就会存在两份Class对象,类型就无法统一。双亲委派让同一个类尽量被同一个加载器加载,最大程度减少类型割裂。这一点在判断instanceof和做.class相等比较时特别重要。两个看似一模一样的对象,如果它们底层的Class对象来自不同的加载器,instanceof的结果就是false,这在插件化、热部署体系里是经典问题。
启动类加载器可以拿到,应用类加载器拿不到启动类加载器加载的类吗?拿不到,这是因为Java的安全体系不允许应用代码直接触达核心字节码,同时Bootstrap ClassLoader本身也不支持被Java代码调用。所以很多框架在需要访问核心库时,会用启动类加载器的null值做一个特殊判断,或者通过扩展目录、模块路径的方式迂回。
3.3 打破双亲委派的几种合法姿势
双亲委派是一个标准行为,但“标准”不代表“唯一”。有些场景必须打破它,最典型的就是SPI(服务提供者接口)机制。拿JDBC举例,核心代码在JDK里由启动类加载器加载,但具体驱动实现是第三方Jar里的类,启动类加载器根本看不到。如果严格遵循双亲委派,JDBC就永远加载不到驱动实现。所以JDK引入了线程上下文类加载器,由应用层把实现类的加载路径塞给核心库使用。简单说就是核心类用上下文类加载器去加载本该由应用加载器加载的类。这不是“推翻”双亲委派,而是在委托链之外开了一条回流的通道。
另一类打破双亲委派的是Web容器。常见的Web容器为了实现多个应用隔离,会自己创建一组子加载器,每个应用对应一个。这样同一份框架类可以被多个应用各自加载,互不干扰。它用的策略是“子优先”:先在应用自己的类加载器里找,找不到才委托上层。这样可以做到应用A可以引入一个版本的库,应用B引入另一个版本,彼此不冲突。热部署的实现也依赖这种机制,替换一个应用时整个世界换掉对应的类加载器,老类加载器连同它加载的类一起被丢弃,新加载器重新加载新版本的类。
那我们自己能不能写一个打破双亲委派的加载器?可以。ClassLoader的默认加载逻辑是模板方法模式,loadClass方法实现了双亲委派流程,findClass方法只负责真正查找字节码。正常情况下,我们只需要重写findClass,让父加载器先尝试,父加载器失败了再调用findClass自己找。如果想打破,就直接重写loadClass,改变整个委托顺序,甚至不委托给父加载器。但我不建议随便乱打破,没有绝对必要,双亲委派的统一性价值远大于那一点点灵活性。
4. 手写一个能热加载的自定义ClassLoader
4.1 最简实现:重写findClass而不是loadClass
理解了原理之后,写一个自定义类加载器其实不复杂。最标准的做法是继承java.lang.ClassLoader,然后重写findClass方法。findClass的职责就是“在本地找不到的情况下,由自己寻找字节流”,所以这里我们很自然地实现一个从指定目录读取.class文件的逻辑。
下面是我经常在最小示例里用的一段代码,逻辑很直接:把类的全限定名转成目录路径,读出字节数组,再调用defineClass完成从字节数组到Class对象的转换。
public class MyClassLoader extends ClassLoader { private final String baseDir; public MyClassLoader(String baseDir) { this.baseDir = baseDir; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { String path = name.replace('.', '/').concat(".class"); byte[] bytes = readBytes(baseDir + "/" + path); if (bytes == null || bytes.length == 0) { throw new ClassNotFoundException(name); } return defineClass(name, bytes, 0, bytes.length); } private byte[] readBytes(String filePath) { try (java.io.InputStream in = new java.io.FileInputStream(filePath)) { return in.readAllBytes(); } catch (Exception e) { return null; } } }为什么重写findClass就不算打破双亲委派?因为loadClass模板方法里仍然是“先委托父加载器,父加载器失败才调用findClass”。只有父加载器真的找不到时,我们自定义的读取逻辑才有机会执行。这样既满足安全要求,又不影响双亲委派。如果想做一个更“叛逆”的加载器,再考虑重写loadClass,那通常只出现在Web容器、插件框架等技术底层里,业务代码一般不碰。
用这个加载器加载类后,可以观察到一个现象:同一个class文件,用不同的MyClassLoader实例分别加载一次,得到的Class对象是不相同的,打印hashCode、做==比较都会发现它们不一样。这就是前面说的命名空间隔离。这个现象是理解类加载器机制最直观的实验。
4.2 实现一个类热替换的完整流程
有了自定义加载器,热加载的思路就清晰了:不修改JVM里已有的Class对象,而是创建新的类加载器,让新加载器重新加载class文件的新版本。因为类加载器有隔离性,新加载器加载出来的Class对象和旧加载器加载的Class对象互不干扰。
简单流程如下:
- 构建一个自定义加载器,指向编译输出目录。
- 用loadClass加载目标类,通过反射创建实例并调用方法。
- 修改源码,重新编译,新版class文件落到目录里。
- 再次new一个自定义加载器,重新loadClass同一个类名。
- 通过反射再次创建实例并调用方法,观察到的行为就是新版本逻辑。
示例代码如下。
String classDir = "/tmp/hotload"; // 第一轮加载 ClassLoader loader1 = new MyClassLoader(classDir); Class<?> clazz1 = loader1.loadClass("com.demo.HelloService"); Object obj1 = clazz1.getDeclaredConstructor().newInstance(); Method m1 = clazz1.getMethod("sayHello"); m1.invoke(obj1); // 模拟:重新编译HelloService,新的class文件已经覆盖旧文件 // 第二轮加载 ClassLoader loader2 = new MyClassLoader(classDir); Class<?> clazz2 = loader2.loadClass("com.demo.HelloService"); Object obj2 = clazz2.getDeclaredConstructor().newInstance(); Method m2 = clazz2.getMethod("sayHello"); m2.invoke(obj2);这个demo能跑通,但它有一个很容易被忽略的前提:目标类不能直接或间接被父加载器加载,否则loadClass会在委派过程中被父加载器截胡。比如HelloService如果是Application ClassLoader能找到的classpath里的类,那MyClassLoader会把请求委托给应用类加载器,应用类加载器直接返回已经加载过的版本,我们自己的findClass根本没机会执行。这就解释了为什么很多热部署框架要求把需要热加载的类单独放到一个父加载器看不着的目录里,并且使用的类加载器层级要经过特殊设计。
4.3 自定义加载器容易踩的坑
第一个坑是资源文件加载混乱。自定义加载器如果只重写findClass,那么getResource和getResourceAsStream还是默认的父优先策略。很多框架从classpath根目录读配置,如果配置文件是放在自定义目录里,用自定义加载器却读取不到。解决方式是同时重写findResource,保证资源的查找逻辑和类的查找逻辑一致。
第二个坑是defineClass的包名问题。如果同一个全限定名已经出现在父加载器的命名空间里,子加载器再定义同名类虽然技术上允许,但两个类型无法互操作,还会让调用方在反射时出现混淆。还有一个细节是defineClass本身不检查包是否已经存在,但安全相关的代码,如果类名带包名,需要关注包权限,必要时重写getPackage或调用definePackage。
第三个坑是类与类之间的可见关系。自定义加载器加载A类,A类里引用了B类,那B类怎么加载?答案是A的类加载器在解析B类时会尝试用同一个MyClassLoader去加载B。如果B类不在我们的目录里,而父加载器也没有,那么加载A类本身虽然成功,但一旦调用到A中引用B的方法,就会抛NoClassDefFoundError。这个错误不是加载A时抛的,而是运行到关联代码时才抛,排查时需要看完整调用栈。
第四个坑是安全问题。如果允许从网络或者不可信目录加载字节码,那么字节码验证环节一定要开着,不能为了性能关闭验证。自定义加载器本质上是在JVM里引出一条新的字节码通道,恶意字节码一旦绕过验证,可能破坏JVM的内存安全,这比应用层漏洞危险得多。实际项目中如果确实需要从远程加载类,至少要加上签名校验、哈希校验、来源白名单这几层防护。
5. 常见问题排查与调优经验
5.1 ClassNotFoundException与NoClassDefFoundError怎么区分
这两个异常是类加载领域被问得最多的问题,很多人把它们混为一谈。ClassNotFoundException是一个受检异常,抛出它的场景很明确:某个类在运行过程中,通过反射、ClassLoader.loadClass或类似方式触发加载,但JVM在完成“找到这个类”这个动作时失败了。它的线程栈上通常能看到具体的类名,以及是哪个方法触发的加载。
NoClassDefFoundError就不是这个意思了。它说的是:在编译和加载一个类的时候,这个类本身是有定义的,但运行时它的某个依赖类缺失了,或者它的静态初始化失败导致后续所有使用都失败。比如A类在编译期引用B,运行classpath里B被打包器移除了,JVM加载A成功,但第一次字节码验证或链接遇到B时找不到B,就会抛NoClassDefFoundError。另一种情况更常见:B类的static块抛了异常,第一次引用B时抛ExceptionInInitializerError,之后再有人引用B,JVM直接抛NoClassDefFoundError,因为B已经进入了“初始化失败”状态,不会再重试。
这里有一张我整理的速查表,方便现场对照。
| 维度 | ClassNotFoundException | NoClassDefFoundError |
|---|---|---|
| 类型 | 受检异常 | Error(非受检) |
| 触发语义 | 主动加载某个类时,类本身找不到 | 类定义存在,但依赖类缺失或初始化失败 |
| 典型场景 | Class.forName写错类名、加载器委派链上没有该类 | classpath被裁剪、static块抛异常、Jar包损坏 |
| 排查重点 | 检查类名、检查classpath、检查当前加载器 | 看完整堆栈找缺失的全限定名、看关联的ExceptionInInitializerError |
| 处理方式 | 修类名或补依赖 | 修依赖、修静态初始化逻辑、清理缓存 |
遇到NoClassDefFoundError,我的习惯是先去堆栈末尾找Caused by,看有没有更早的ExceptionInInitializerError。如果有,那真正的病灶在static初始化代码里,跟依赖缺失可能无关。如果没有Caused by,再顺着报错里的类名去检查它的依赖链,用jdeps或者javap查看这个类引用了哪些外部符号,再去classpath里确认是否存在。
5.2 Metaspace溢出与类加载器泄漏排查
JDK8以后,类的元数据存放在Metaspace,它并不在堆内存里,而是直接使用本地内存。很多人以为Metaspace是无限的,这是个误解。虽然默认情况下Metaspace大小会随着类加载增长,但可以设置上限,一旦超过就抛OutOfMemoryError: Metaspace。
Metaspace溢出的根因,绝大多数情况下不是“类太多”,而是“类加载器太多且没有被回收”。类本身是被类加载器持有的,只要类加载器还活得着,它加载过的所有类的元数据就无法释放。热部署场景是重灾区:每部署一次就new一个ClassLoader,如果旧应用对象、旧加载器还被全局Map持有,GC就永远无法回收这些ClassLoader,Metaspace就会像漏水的桶一样缓慢上涨,最终撑爆本地内存。
排查时我一般先做三件事:
- 用jmap -clstats 查看类加载器统计,看看有多少个存活加载器,是不是每个都加载了上千个类。
- 用jcmd GC.class_histogram确认Class对象数量,查找可疑的高数量类。
- 在线上的JVM参数里加上-XX:MaxMetaspaceSize,设置一个合理上限,让OOM早点暴露而不是拖垮整个机器。
还有一个很难察觉的问题:自定义类加载器如果重写了loadClass或者持有静态缓存,很容易在不知不觉中成为GC Root。一个ClassLoader只要还能被任何线程栈、静态变量、系统属性引用到,它和它所有的类就不会被卸载。排查时可以用heap dump配合MAT,打开“Path to GC Roots”,看看这个加载器到底是被谁拽住的。最常见的是某个框架把ClassLoader放进了ThreadLocal,而线程池里的线程又长时间存活,等于把这个ClassLoader焊死在了内存里。
5.3 实用排查参数速查表
实际工作中,类加载问题的排查需要JVM参数和工具配合。这里整理几个我每周都会用到的开关和工具。
| 参数/工具 | 作用 | 使用建议 |
|---|---|---|
| -verbose:class | 打印每个类的加载与卸载信息 | 启动时加上,适合看加载顺序 |
| -XX:+TraceClassLoading | 输出类加载日志 | 排查谁在什么时候加载了哪个类,定位加载时机 |
| -XX:+TraceClassUnloading | 输出类卸载日志 | 检查热部署后类有没有被回收,注意部分新版本JDK可能忽略它 |
| -XX:MaxMetaspaceSize | 限制Metaspace上限 | 给一个合理值,避免本地内存被拖垮 |
| -XX:+HeapDumpOnOutOfMemoryError | OOM时自动导出heap dump | 排查泄漏必备 |
| jcmd VM.classloader_stats | 查看类加载器统计 | 快速定位加载器数量异常 |
| jcmd GC.class_histogram | 查看Class对象直方图 | 统计分析类对象数量 |
| jmap -clstats | 打印类加载器详情 | 在JDK8上很常用 |
用-XX:+TraceClassLoading时,输出往往非常庞大,我建议通过日志重定向到文件再配合grep过滤类名关键字,不要直接看控制台输出。比如想确认自定义加载器有没有真的loadClass到目标类,直接grep目标类的全限定名。如果发现目标类被加载了十几次,且都是不同加载器触发的,那就找到了类型冲突的证据。
还有一个小技巧:在自定义ClassLoader的构造函数里加日志,或者用静态计数器统计实例数。这个方法比看JVM日志更直观,因为问题往往不是“类加载失败”,而是“类加载器实例创建了太多次且没被回收”。在类加载器里放一个构造日志,线上日志里扫一眼就知道这个加载器被new了多少次、在哪里被new的。
最后分享一个我自己养成的习惯:每次排查类加载问题,第一步不是看代码,而是先打印当前线程的上下文类加载器、目标类的加载器,还有加载器父链。这三行输出基本能定位九成问题。有一次我排查一个“明明加了依赖还是ClassNotFound”的问题,打印之后发现当前线程上下文类加载器是平台类加载器,业务类跑在应用类加载器里,SPI加载时走错了通道,改一下上下文加载器设置就恢复了。类加载子系统的问题,大多不是“缺类”,而是“类被谁加载”的问题。
另外提醒一句,做自定义类加载器实验时,不要在生产环境直接验证想法,先在隔离的环境里跑通每一步,确认加载器层级、资源路径、依赖可见性都符合预期,再考虑引入线上框架。类加载器的世界,很多东西是小实验里看不出来的,只有真实运行场景才会暴露。