恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

JVM类加载器子系统深度拆解:双亲委派、自定义加载器与故障排查

  • 首页
  • 资讯中心
  • /
  • JVM类加载器子系统深度拆解:双亲委派、自定义加载器与故障排查

相关资讯

探索策略的进化:从调参到调策略的优化新维度 2026/10/13 4:55:54
OpenClaw ACP后端不可用排查:锁文件与子进程实战修复 2026/10/13 4:55:54
OpenCV虚拟线圈车流量检测:从原理到落地避坑指南 2026/10/13 4:55:54

最新资讯

ECU休眠唤醒与上下电时序的耦合机制解析
51单片机流水灯:软硬协同的嵌入式入门硬核实践
Windows 本地部署 Dify + Ollama + DeepSeek 知识库实战指南
Spring Boot整合JSP部署外部Tomcat实战避坑指南
GLASS数据+TIMESAT物候提取:高信噪比长时序的稳健建模方法
Java编译器源码解析:手写javac的完整流程与避坑指南

今日推荐

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析
CAN总线错误处理机制深度解析:从位错误到Bus Off的硬件级调试

本周热门

UE动画修改实战:从资产编辑到重定向与蒙太奇驱动
统计随机数生成器攻击下的KLJN安全密钥交换协议Matlab仿真
政务API安全治理:资产测绘、低代码编排与行标对标实践

本月精选

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证
2026 大模型集体涨价:用 Python 做企业 Token 成本测算与选型避坑(附配置)

JVM类加载器子系统深度拆解:双亲委派、自定义加载器与故障排查

发布时间:2026/10/13 4:55:54
JVM类加载器子系统深度拆解:双亲委派、自定义加载器与故障排查 如果你准备把 JVM 搞透类加载器子系统是我认为第二个值得啃的硬骨头。上一篇聊完整体的 JVM 宏观结构之后我记得自己原计划直接去写运行时数据区但整理素材时发现一个很尴尬的情况不先讲清楚类加载器后面讲对象创建、方法调用、动态代理、SPI 扩展全都要回头补课。类加载器子系统负责把字节码装进虚拟机也决定了程序里第一行代码是以什么身份、由谁、什么时候出现在内存里。这一块如果只是背概念过两天就忘所以我这篇会用我习惯的 ds 学习法来拆先 Debug拿到第一手的运行现场再 Source回到 JDK 源码里对着实现逐行验证。适合正在学 JVM 的人也适合那种类加载器好像听过但说不清链路的 Java 开发者。先说清楚一个容易引起争议的点标题里的 ds在我这里不是某个 AI 工具的缩写而是 Debug Source 的组合动作。它的核心思路很简单任何抽象机制先在运行期打断点看调用链再回到源码里找对应逻辑两头一碰很多传说就会原形毕露。下面这套内容就是我用这个方式把类加载器子系统重新过了一遍之后沉淀下来的完整笔记。1. 为什么我会把类加载器子系统放在学 JVM 的第二站1.1 一个让你后面少走弯路的顺序问题很多初学者学 JVM第一件事就是去看垃圾回收算法或者直接背运行时数据区。这当然没错但我在带人时更倾向于把类加载器往前放。原因很简单JVM 启动后做的第一件正经事就是把主类加载进来。主类进不来后面什么分代回收、什么 JIT 编译、什么堆外内存全都无从谈起。类加载器子系统还有一个特殊的位置它处于字节码和运行时数据区之间。类加载过程中读到的类元信息最终要被放进方法区或者元空间类里定义的静态变量准备阶段就要在这个区域里完成内存分配对象实例要用的字段布局也跟加载阶段解析出来的信息强相关。换句话说只要你后面要学习对象在内存里长什么样就绕不开这个对象属于哪个类以及这个类是被谁加载进来的。我自己的经验是先搞懂类加载器再去看运行时数据区和对象创建大脑里会多出一条非常清晰的主线。否则你去读new字节码相关的资料看到分配内存、指针碰撞、TLAB 这些概念总会忍不住问一句等等这个类到底是从哪来的它的 Class 对象又是什么时候生成的1.2 ds 学习法在这一篇里到底怎么落地ds 学习法执行起来只有两步。第一步是 Debug任何概念都不要直接信结论先在一个最小程序里打断点观察真实的方法调用栈。第二步是 Source拿到调用链之后打开 JDK 自带源码找到java.lang.ClassLoader里的对应方法一行一行看实现。两个动作缺一不可只 Debug 会停留在现象工程师的层面只读 Source 又会陷入源码背诵的误区。比如双亲委派机制很多人能背出委托父加载器父加载器加载不了再由自己加载但为什么是这个顺序核心逻辑到底写在哪个方法里你今天用 ds 方法跑一遍在ClassLoader.loadClass里断点调用栈会告诉你答案再打开loadClass源码你会发现整个判断顺序清清楚楚。这样学一遍比背十遍面试题都牢。这篇各个章节我会先给结论然后尽量补上你怎么自己验证的动作。下面先从最基础的三个内置类加载器开始把版本差异也一并说清。2. 三个内置类加载器先认清谁在管哪块地2.1 启动类加载器、平台类加载器和应用类加载器的分工在 Java 8 及以前大家习惯说三个加载器启动类加载器、扩展类加载器、应用类加载器。Java 9 引入模块化之后扩展类加载器被移除了变成了平台类加载器。如果你看网上年龄比较大的帖子会发现很多人还在讲lib/ext目录那套体系在新版本里已经不适用了。我们以主流的新版本 JDK 为准三种内置加载器大概长这样类加载器主要职责对 Java 代码可见启动类加载器加载 JDK 最核心的基础类比如java.lang、java.util里的核心部分不可见getClassLoader()可能返回 null平台类加载器负责加载 JDK 中面向平台的模块类替代原来的扩展类加载器可见通常是平台模块相关的加载器实例应用类加载器负责加载 classpath 上的用户类和第三方依赖也叫系统类加载器可见日常最多遇到的就是它这里有个细节很容易踩坑String.class.getClassLoader()打印出来是null很多人会以为 String 没有被加载。其实不是它恰恰是被启动类加载器加载的只是启动类加载器在 Java 里没有对外暴露一个普通的ClassLoader对象所以返回null。这算是一种没有对象代表它存在的特殊情况面试题也爱考。2.2 用一段代码验证类加载器的父子关系父子关系这个概念用嘴说很容易晕。我建议你直接运行下面这段代码把每个类加载器都打出来看看。public class LoaderProbe { public static void main(String[] args) { ClassLoader cl LoaderProbe.class.getClassLoader(); System.out.println(应用类加载器 cl); System.out.println(它的父加载器 cl.getParent()); System.out.println(它的父加载器的父加载器 cl.getParent().getParent()); System.out.println(String 的类加载器 String.class.getClassLoader()); } }默认情况下LoaderProbe.class由应用类加载器加载。所以cl第一行是应用类加载器的引用第二行cl.getParent()是平台类加载器第三行cl.getParent().getParent()是null。你可能会奇怪第三行不应该是启动类加载器吗这里又撞上了刚才说的特殊情况启动类加载器在 Java 层没有公开类型所以父加载器指针直接是 null。跑完这段代码你会对父子关系有个直观印象。但注意这里说的父和子不是 Java 继承里的父子类而是委派链上的上级与下级。这也是下一节要拆的核心机制。3. 双亲委派机制别把双亲理解成两个爹3.1 委派顺序先问父亲问到要不到才自己动手双亲委派这个翻译挺容易误导人因为双亲听起来像是有两个父类。实际上是parents或者说上级委派链每个加载器只有一个 parent 指针。它规定了一个很朴素的顺序当某个类加载器需要加载一个类时不会直接自己加载而是先把这个请求交给它的父加载器。父加载器也继续往上交直到顶层启动类加载器。只有在每一级父加载器都明确表示我找不到这个类之后子加载器才启动自己的findClass逻辑。这个机制我经常用一个生活里的类比小 D 想借一本很冷门的书他不直接去自己常去的小书店找而是先问爸爸。爸爸不知道就继续问爷爷。爷爷如果家附近有就先拿爷爷那本的如果爷爷也没有消息一层层传回来小 D 才自己跑腿去找。这么做最直接的好处就是保证同一个全限定名的类在标准库里只出现一份避免核心类被用户自定义的同名类顶替。3.2 loadClass 源码逐行看原来判断顺序藏在这里用 ds 方法看这个机制最该打开的就是java.lang.ClassLoader里的loadClass(String name, boolean resolve)方法。我把关键逻辑简化一下核心结构长这样protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 第一步先查这个类是不是已经被加载过了 Class? c findLoadedClass(name); if (c null) { try { // 第二步如果父加载器不为 null先让父加载器加载 if (parent ! null) { c parent.loadClass(name, false); } else { // 父加载器为 null说明当前已经很接近顶层直接找启动类加载器 c findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器找不到异常在这里被吞掉不往上抛 } if (c null) { // 第三步父加载器没能加载才轮到自己 c findClass(name); } } // 如果需要解析则触发连接阶段里的解析 if (resolve) { resolveClass(c); } return c; } }这段代码有几个容易被忽略的点。第一整个loadClass是加了锁的锁对象由getClassLoadingLock(name)决定目的是防止同一个类在同一加载器下被并发加载出多份。第二父加载器加载失败时是捕获并吞掉ClassNotFoundException不会直接把异常抛给上层这样才有机会最后执行自己的findClass。第三真正的扩展点是findClass不是loadClass。自定义类加载器一般只需要重写findClass不要去动loadClass的整体流程。很多网上的自定义加载器例子喜欢重写loadClass这恰恰是最容易破坏委派机制的地方。如果你只是想从别的目录加载类正确的做法是继承ClassLoader重写findClass把读取字节和调用defineClass的逻辑放在里面对应的位置。3.3 为什么 java.lang.String 不会被冒名顶替理解了上面的源码顺序你就能回答一个经典问题为什么用户自己写一个java.lang.String不会生效因为应用类加载器收到加载请求后会先委派给上层平台类加载器再委派给启动类加载器。启动类加载器手里已经有标准库的java.lang.String直接返回现成的 Class 对象。用户手写的同名类根本没机会进入加载流程。另外标准 JDK 对java.开头的包名还有一道更硬的防线。即使你绕过loadClass直接调用defineClass底层也会拒绝让非启动类加载器定义这个包下的类。所以不是你不写而是写了也过不了这个检查。这个保护机制让 Java 核心运行时保持稳定不会因为 classpath 里多了一个同名类就全线崩盘。4. 一个类的完整生命周期从二进制流到可以被 new 的对象4.1 加载阶段Class 文件到底从哪来一个类从无到有第一步是加载。这个阶段要做的事很直白通过类的全限定名获取对应的二进制字节流把字节流解析成 JVM 内部的数据结构最后生成一个java.lang.Class对象。很多人以为二进制流只能来自.class文件其实不止。同一个虚拟机的规范并没有规定二进制流必须来自磁盘文件它可以是 ZIP/JAR 包里的条目可以从网络下载也可以由运行时动态生成。最典型的动态生成例子就是动态代理。Proxy.newProxyInstance内部生成的代理类本质上是字节码生成工具在内存里拼出来的一段字节流再交给某个类加载器去定义。如果你把日志打开能看到代理类的类加载器和名称但它在磁盘上并不存在独立文件。这个特性也让类加载器子系统和运行时动态扩展紧密绑定在一起。4.2 验证和准备连接阶段里真正在做的事加载完成之后紧接着是连接阶段。连接阶段不是一步而是三个步骤的合称验证、准备、解析。其中验证负责检查字节流是否符合 JVM 规范比如文件格式对不对、元数据有没有冲突、字节码指令是否安全、符号引用能不能找到对应定义。这一步是 Java 安全模型的重要基础它避免了恶意字节码直接绕过类型系统乱搞。准备阶段做的事是给类变量分配内存并设置初始值。注意这里的初始值不是代码里写的那个初值而是类型默认值。比如你写private static int age 18;在准备阶段age会被先置为 0真正的 18 要等到后面初始化阶段执行clinit时才赋值。对于static final修饰的常量情况又不一样如果它是编译期常量直接能在准备阶段就写死。很多人搞不清 0 和 18 出现在哪个阶段这里需要反复提醒。4.3 解析符号引用换成直接引用的意义解析阶段把字节码里的符号引用替换为直接引用。这句话初学者听了会发懵。简单说代码里很多引用在编译阶段只是个字面符号比如类名、方法名、字段名它们还没有真正指向内存里的某个地址或某个句柄。到了解析阶段JVM 才去确认这个符号到底对应哪个类、哪个方法、哪个字段并把后续执行要用的信息准备好。这里有个重要的思想解析不一定是类加载完成后立刻全部做完。JVM 允许懒解析也就是等到某个指令第一次真正执行到的时候再触发对应符号的解析。这么做是为了避免加载一个类时把一堆永远不会用到的依赖也全部解析掉白白浪费资源。这种延迟策略在整个 JVM 执行模型里到处都能看到影子。4.4 初始化static 代码块什么时候触发什么时候不触发初始化阶段是真正执行类构造器clinit的阶段。简单说这个阶段会执行静态变量的赋值语句和静态代码块。它不是你new一个对象就必然马上触发的触发条件有一套明确规则遇到new、getstatic、putstatic、invokestatic这些字节码指令时如果对应的类还没初始化就要先触发调用反射 API 时如果目标类没初始化也要触发初始化子类时如果父类没初始化先初始化父类程序的主类会在 JVM 启动时初始化。反过来有些写法不会触发初始化。比如用static final编译期常量去引用一个类类不会初始化通过数组定义来引用一个类也不会初始化引用父类的静态字段只会触发父类初始化不会触发子类初始化。不少人踩过坑以为访问Child.name会初始化 Child但检查完之后发现只初始化了 Parent。这其实是 Java 语言规范里就已经写好的被动引用场景。5. 实战写一个自定义类加载器用断点完整观察双亲委派5.1 场景设计给项目造一个独立的类目录单纯读源码容易走神这一节我用一个最小实验把整个委派过程变成看得见的调用链。假设你写了一个demo.Greeting类里面只有一个方法package demo; public class Greeting { public void hello() { System.out.println(Hello from custom dir!); } }把这个类编译到某个独立目录比如/tmp/demo-classes然后保证它不在项目的 classpath 里。接下来写一个自定义类加载器让它从这个目录读取.class文件并加载。import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; public class DirClassLoader extends ClassLoader { private final Path baseDir; public DirClassLoader(Path baseDir, ClassLoader parent) { super(parent); this.baseDir baseDir; } Override protected Class? findClass(String name) throws ClassNotFoundException { Path file baseDir.resolve(name.replace(., /) .class); try { byte[] bytes Files.readAllBytes(file); return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } }这段代码的核心就是findClass。它根据类名找到对应 class 文件读成字节数组然后调用defineClass让 JVM 完成从字节流到 Class 对象的定义。这个加载器没有重写loadClass因此默认的委派流程还在。5.2 断点跟踪看到 parent 先跑自己殿后写一个测试入口加载上面那个demo.Greeting并调用方法import java.lang.reflect.Method; import java.nio.file.Paths; public class LoaderTest { public static void main(String[] args) throws Exception { DirClassLoader loader new DirClassLoader( Paths.get(/tmp/demo-classes), ClassLoader.getSystemClassLoader() ); Class? clazz loader.loadClass(demo.Greeting); Object instance clazz.getDeclaredConstructor().newInstance(); Method hello clazz.getMethod(hello); hello.invoke(instance); System.out.println(实际加载器 clazz.getClassLoader()); } }运行前在java.lang.ClassLoader.loadClass的入口以及DirClassLoader.findClass里各打一个断点。当程序触发loader.loadClass(demo.Greeting)时你会看到完整的调用链并不是直接进入DirClassLoader.findClass而是先进入ClassLoader.loadClass接着一路向上调用父加载器的loadClass。因为 classpath 上没有demo.Greeting应用类加载器找不到平台类加载器也找不到最后才回到DirClassLoader.findClass里由它完成读取和defineClass。最后打印出来的实际加载器也不是DirClassLoader而是DirClassLoader本身因为确实是由它定义的这个类。这个过程恰好说明委派是先借力借不到才自己动手。5.3 打破委派改成自己优先之后世界立刻不一样有时候我们确实需要打破默认顺序比如要加载两个不同目录下全限定名相同、实现不同的类。这时可以重写loadClass让自己先尝试加载实在找不到再交给父加载器。Override public Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class? c findLoadedClass(name); if (c null) { try { c findClass(name); } catch (ClassNotFoundException e) { c super.loadClass(name, resolve); } } if (resolve) { resolveClass(c); } return c; } }这种写法最大的变化是所有不在启动/平台加载器管辖范围内的类都会优先由当前加载器加载。再打断点时你会发现调用顺序反转了先进入findClass找不到才进入父加载器的loadClass。如果你同时用两个不同目录的DirClassLoader去加载同一个demo.Greeting就可以得到两个全限定名一样、但完全不属于同一个 Class 的类型。5.4 自定义类加载器最容易犯的三个错第一重写loadClass时忘了先调findLoadedClass。这样同一个类可能会在同一加载器里被尝试定义两次直接触发LinkageError。第二defineClass的字节数组被重复修改造成类格式验证失败。自定义加载器最好让字节数组只读不要复用同一个 buffer 反复改内容。第三只想着打破委派却没保留对核心库的放行。如果自定义的loadClass把java.lang.String也拉到自己这里尝试加载很容易踩到安全限制。稳妥的做法是先判断包名java.、javax.等核心模块开头的一律无条件交回父加载器处理。6. 类加载器子系统相关的日常故障现象、根因、排查顺序6.1 NoClassDefFoundError 和 ClassNotFoundException别再混为一谈这两个异常长得像本质完全不同。ClassNotFoundException是一个受检异常常出现在Class.forName、loadClass这类显式加载动作中意思是我主动去找这个类结果没找到。NoClassDefFoundError则是一个Error通常不是由你当前这行代码直接触发而是这个类之前加载时就已经失败或者它的某个依赖类缺失后续再被引用到的时候才抛出来。打个比方前者是你去学校接一个学生发现名单上根本没有这个人后者是你已经见到学生了正要发课本结果发现他书包里的练习册丢了于是当场整个活动报废。排查时遇到ClassNotFoundException先查类名拼写和 classpath遇到NoClassDefFoundError则要多看日志里它之前那次失败的根因比如静态初始化异常、依赖类加载失败。异常类型抛出时机常见原因ClassNotFoundExceptionClass.forName、loadClass 等显式加载时类名错误、classpath 缺失、加载器不可见NoClassDefFoundError已加载类在运行期引用缺失依赖时编译期有、运行期没有或之前的加载失败被缓存在 memory 里6.2 ClassCastException同一个全限定名却是两个完全不同的类型类加载器隔离性有一个很经典的副作用同一个全限定名可以被不同加载器加载成两份完全不同的 Class 对象。你写(Greeting) obj能成功前提是obj的 Class 对象和强转目标类型所属的 Class 对象必须来自同一个加载器。想象一个项目里应用类加载器已经加载了一份demo.Greeting你的自定义类加载器又加载了另一份demo.Greeting。这时你把第二份创建出的对象强转成GreetingJVM 会直接抛ClassCastException。原因不是你代码写错了而是 JVM 判断类型是否一致时看的是加载器 全限定名两个维度不只是类名。这类问题在 Web 容器、插件系统、热部署场景里特别常见。排查口诀是先打印对象的getClass().getClassLoader()和当前类Greeting.class.getClassLoader()只要两个类加载器不是同一个强转失败就是正常现象。解决方案通常是让两端共用同一个父加载器或者通过接口、反射解耦。6.3 静态变量会执行多次多加载器下的单例失效单例模式里最常见的写法是用静态字段持有唯一实例。这个写法在单类加载器环境下没任何问题但一旦同一个类被多个类加载器加载每个加载器都会有一份独立的静态字段副本。结果就是你的单例在应用服务里感觉失效了每次新加载器起来又初始化出一个新实例。如果再加上不断创建新的类加载器还会引入一个隐藏的内存问题被加载的类不会轻易被回收因为类加载器本身被某个长期存活的对象引用着。旧版本 JVM 里出现过频繁OutOfMemoryError: PermGen space很大程度上就是热更新或动态编译场景反复创建类加载器导致的。排查的时候不光要看堆里的大对象还要看有没有无意义存活的类加载器。6.4 一套能复用的排查顺序遇到类加载相关的问题我建议按下面的顺序排查。第一步打印出当前类的类加载器以及它的父加载器链先确认类到底是从哪条路径进来的。第二步对比异常发生点和类加载点之间的时间差区分是显式加载失败还是间接引用失败。第三步打开类加载相关的 verbose 日志看 JVM 实际上是从哪个 jar 或目录里找到了这个类。最后一步再决定是调整 classpath还是自定义加载兄弟隔离。如果你能熟练走完这套流程大部分类加载器问题在你眼里就不会再是玄学。我在实际调试中还发现一个很有用的小习惯每次写完自定义类加载器都会主动打印一层完整的父子链路并把findLoadedClass的结果一起打出来。这样一来同一个类在加载器里被命中的路径永远清清楚楚不会等到线上出了问题才靠猜。类加载器子系统学到这个程度再往后看对象创建、编译优化都会顺很多。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号