
1. 类加载器到底是什么先搞懂加载阶段在干什么很多Java开发者写了好几年代码遇到ClassNotFoundException还是只会在搜索引擎里复制粘贴异常信息看到NoClassDefFoundError就直接怀疑是依赖没导对。其实这两个问题背后的核心都指向同一个JVM底层机制——类加载器。理解类加载器不只是为了应付面试题更是排查线上问题、解决依赖冲突、实现热部署等场景的基本功。我把类加载器用一个生活化的类比讲清楚JVM本身就像一个大型加工厂.java源文件是设计图纸.class文件是标准零件而类加载器就是负责把零件从仓库搬运到生产线上的传送带系统。这套传送带不是简单地把Class对象丢进内存就完事它要完成三个动作加载、连接、初始化。“加载”阶段干的事情最直观——根据类的全限定名找到对应的.class字节码文件读进来转换成Class对象。你可以理解为传送带先把零件从仓库取出来。“连接”阶段又拆成三步验证、准备、解析。验证是检查字节码文件符不符合JVM规范防止恶意或损坏的class文件混进来准备是为类的静态变量分配内存并设置默认零值解析是把类中的符号引用替换成直接引用也就是把图纸上写着“找张三”的标签换成张家具体门牌号。“初始化”阶段才真正执行类构造器clinit方法给静态变量赋上你写的初始值执行静态代码块。这里有一个容易被忽略的关键点**类加载器在“加载”阶段做的事情虽然叫“加载”但它并不负责把.class文件从磁盘读出来这个IO行为本身而是委托给defineClass方法把字节数组转换为Class对象。**读文件、走网络、解压jar包这些动作都是你或者各种框架自己实现的。这就意味着只要你能拿到字节数组类加载器就能帮你把它变成可用的Class——这也是后面讲自定义类加载器时最核心的“抓手”。类加载器在整个Java运行时中承担的角色比很多人想象中更重要。它不只是“找class文件”这么简单它决定了三个层面的东西可见性一个类加载器加载的类能否被另一个类加载器加载的类引用、唯一性不同类加载器加载同一个class文件会产生不同的Class对象即使内容一样也不相等、安全边界Java标准库的类必须由特定加载器加载防止用户代码伪造核心类。理解了这三个层面的含义后面看双亲委派模型和各类加载器之间的边界就会顺畅很多。2. JVM内置了哪些类加载器JDK8和JDK9的差异都要清楚2.1 JDK8版本启动类加载器、扩展类加载器、应用类加载器在JDK8及之前的版本里JVM内置了三个类加载器层级关系很清晰。**启动类加载器Bootstrap ClassLoader**是最底层的那个负责加载JAVA_HOME/lib目录下的核心类库比如rt.jar、tools.jar里的所有类还有-Xbootclasspath参数指定的类。这个加载器有点特殊——它不是用Java代码实现的而是由C/C编写、在JVM启动时就内嵌在JVM内部的。所以在Java代码里你拿不到它的引用String.class.getClassLoader()返回的是null就是因为它的加载器是底层原生的Java层面无可引用对象。很多新手看到null会以为String没有类加载器其实是Bootstrap加载器“没有Java对象形态”而已。**扩展类加载器ExtClassLoader**负责加载JAVA_HOME/lib/ext目录下的类库或者被-Djava.ext.dirs指定的目录中的类。它的父加载器是Bootstrap。在早期的JDK版本里很多第三方增强库喜欢把自己打进ext目录这样所有应用都能共享但是这样做风险很大一旦某个jar包里的类出现问题会影响这台机器上的所有Java应用而且依赖冲突在ext目录里比应用内部更难排查。所以后来官方也不推荐扩展目录放公共类库了这个加载器的主要存在感逐渐降低。应用类加载器AppClassLoader也叫系统类加载器负责加载classpath环境变量、-classpath或-cp参数指定的路径下的所有类。你自己的项目代码、第三方依赖jar包默认都是由它加载的。它的父加载器是ExtClassLoader。在Java代码中可以通过ClassLoader.getSystemClassLoader()拿到它这也是main方法运行时的默认上下文类加载器。三个加载器在内存中形成了一个“父子链”但这里的“父”不是继承关系而是组合关系每个加载器内部保存着一个parent字段指向父加载器这个parent本身也是一个ClassLoader实例。整个链条是App → Ext → BootstrapBootstrap没有Java对象所以它的“父”显示为null。看清楚这一点理解双亲委派模型就只剩下一步之遥了。2.2 JDK9版本模块化之后的变化JDK9引入Java模块系统JPMS之后类加载器的结构发生了调整最直观的变化是扩展类加载器被平台类加载器Platform ClassLoader取代。名字变了位置和作用范围也有变化平台类加载器负责加载lib/modules里的一部分模块化类库以及通过--limit-modules等参数暴露的模块。原来放在ext目录下的扩展机制被替换成模块化方式java.ext.dirs参数也被移除了。应用类加载器在模块化之后基本角色不变仍然是加载classpath和模块路径下的用户代码。只是现在JVM内部的类库都按模块组织模块的边界本身也承担了一层“可见性控制”职责。很多老的资料在讲类加载器时还是只提JDK8的三个如果你所在项目用的是JDK11、JDK17面试和排查问题时要记得概念已经更新了。下面是JDK8与JDK9的对照表用一张表看明白差异比较维度JDK8JDK9加载器名称启动 / 扩展 / 应用启动 / 平台 / 应用扩展类库加载方式lib/ext目录、java.ext.dirs模块化方式按模块边界控制核心类库rt.jar等lib/modules中模块化后的类库命令行参数-Xbootclasspath--patch-module、--add-modules等模块参数双亲委派三层模型仍为三层但平台加载器替代扩展加载器还在维护老项目的人比如生产环境跑在JDK8上那ExtClassLoader和rt.jar这些概念依然是日常排查依赖问题时必须关心的。已经迁移到JDK11、JDK17的团队需要花点时间重新熟悉模块化后的加载边界。我自己经历过一个典型的坑项目从JDK8升级到JDK11之后某个老旧的第三方加密库在ext目录下注册了JCE安全提供者升级后直接失效应用启动时一直报“无法找到算法实现”。这就是版本差异带来的实打实的影响。2.3 三个加载器之外的“隐形参与者”自定义类加载器JVM内置的三个类加载器覆盖的是最常规的加载场景但实际框架里大量存在第四个角色——自定义类加载器。像Tomcat为每个Web应用创建独立的类加载器就是为了实现应用之间类隔离像Spring Boot的LaunchedURLClassLoader加载BOOT-INF/lib里的嵌套jar包像热部署框架会在类文件变更后重新new一个类加载器来加载新版本类。从源码设计的角度看ClassLoader本身就是一个开放扩展的抽象类只要继承它、重写findClass或者loadClass方法就可以定义自己的加载逻辑。所以“类加载器有哪些”这个问题严谨的答案是两层的**内置层面有启动、平台/扩展、应用三类使用层面还有大量由框架或开发者自己实现的加载器。**面试时能把这层也说清楚比单纯背三个名字要有深度得多。后面第四章我会专门带大家手写一个自定义类加载器把加密class文件解密加载、实现类替换这类场景跑通到那时候你对“类加载器有哪些”的答案会有一个质的提升。3. 双亲委派模型为什么这么设计又该如何理解它的边界3.1 委派流程与源码级解析在JVM的类加载机制里最核心的运行规则就是双亲委派模型。我用一个市政务窗口的例子来解释应用类加载器要加载一个类它不会自己先动手去找而是先把请求“逐级上抛”给父加载器——应用交给扩展/平台扩展/平台再交给启动。每一级只要发现自己负责的范围里有这个类就直接加载并返回如果一直往上到Bootstrap都找不到才由最开始的加载器自己尝试加载。这个流程的源码集中在ClassLoader#loadClass里。JDK8的实现逻辑大致是protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 先检查该类是否已经被当前加载器加载过 Class? c findLoadedClass(name); if (c null) { try { if (parent ! null) { // 父加载器不为null就交给父加载器加载 c parent.loadClass(name, false); } else { // 父加载器为null说明是顶层交给Bootstrap c findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器找不到不做处理等会自己尝试 } if (c null) { // 父类都没找到调用findClass自己找 c findClass(name); } } if (resolve) { resolveClass(c); } return c; } }注意看这段逻辑findLoadedClass其实是JVM层面的一个缓存查询已经被加载过的类不会再被重复加载parent.loadClass是实现委派的关键调用而真正干活找字节码的是findClass它默认实现是抛ClassNotFoundException子类只需要重写这个方法就够了。这里有个很重要的设计原则**loadClass控制委派流程findClass负责实际查找两者职责分离。**自定义类加载器时大多数场景只需要动findClass不需要动loadClass这样就能保留双亲委派机制。findClass在URLClassLoader中有现成的实现这也是为什么扩展/平台/应用加载器都继承了URLClassLoader——它们本质上都是从一个或多个URL路径中去加载class文件。如果你自己写类加载器最简单的方式就是继承URLClassLoader然后传入指定URL几乎不需要写任何代码就能得到一个能加载指定路径类的加载器。3.2 设计初衷安全、一致性与核心类保护双亲委派模型解决的关键问题归根结底是保证Java类型体系的一致性和安全性。从安全角度看如果没有双亲委派用户自己写一个java.lang.String放到classpath里应用加载器自己加载了它内存里就会出现两个String类Bootstrap加载的原版和App加载的伪造版。用户在代码里写的new String()到底用哪一个这种混乱直接破坏了Java类型系统的根基。有了委派机制所有java.*开头的类都会被上抛到Bootstrap由它统一加载——你写的那个伪造String根本没有机会被App加载器加载因为App加载器收到请求后第一反应是找爸爸等爸爸加载完了就轮到它返回父类加载的结果。从一致性角度看同一个类在全应用中应该只被加载一次并且所有引用它的地方拿到的必须是同一个Class对象。试想两个类加载器各自加载同一个User.class那User.class的这个对象和那个对象在instanceof判断时是不相等的强转直接抛ClassCastException这在实际开发中就是典型的“类隔离冲突”。从性能角度看委派模型也让加载结果得到最大化复用。父加载器加载过的结果子加载器永远不会重复扫描类文件这在大型应用尤其是启动分析时意义很大。3.3 什么时候需要打破双亲委派典型场景与正确姿势规则设计得再完美也总有例外场景需要“打破双亲委派”。最经典的三个例子能帮你建立起边界感。**场景一SPI机制。**JDBC的DriverManager是启动类加载器加载的但具体数据库驱动jar包比如MySQL的com.mysql.cj.jdbc.Driver却在应用的classpath下由App加载器加载。按双亲委派模型的逻辑Bootstrap发现请求的Driver类不在自己的管辖范围内会返回NotFound但这会导致JDBC完全不可用。所以DriverManager在加载驱动时用的不是默认委派逻辑而是通过Thread.currentThread().getContextClassLoader()拿到应用类加载器去加载驱动程序。这就是一个“父加载器需要请求子加载器干活”的典型场景也是线程上下文类加载器出现的原因。**场景二Tomcat等Web容器的类隔离。**Tomcat里每个Web应用都应该有独立的类加载环境——两个应用可以同时使用不同版本的Spring互不干扰。如果所有应用都挂在同一个App类加载器下A应用升级Spring版本就会影响B应用。Tomcat的做法是自定义WebAppClassLoader重写了loadClass方法对java.*、javax.*核心包仍然委派给父加载器对于Web应用WEB-INF/classes下的类优先自己加载不交给父加载器对于WEB-INF/lib下的jar包也是应用自己加载从而实现了应用间的类隔离和版本独立。**场景三热部署/热替换。**热部署的本质是销毁旧的类加载器、创建新的类加载器重新加载变更后的类。因为在JVM里同一个类加载器对于同一个类全限定名只会加载一次所以只能靠“换一个新的加载器”来触发重新加载。最典型的案例是IDE里的Java热更新、某些规则的动态加载引擎。打破双亲委派不是目的而是手段。需要打破的唯一充分条件是**如果一个类必须由父加载器加载但它的实际实现却位于子加载器的搜索范围内或者不同子加载器需要加载同一个类的不同版本这时默认的委派逻辑就无法工作必须自定义。**反过来记住一个原则不需要打破的时候别乱动loadClass保持默认的findClass扩展方式是最稳妥、最不容易出诡异的LinkageError的做法。4. 动手写一个自定义类加载器从源码到实战验证4.1 一个能用5分钟上手的类加载器理论聊得再多不写代码总是隔层纱。我带着你做一个最简单的自定义类加载器目标很明确加载指定路径下的class文件并且能让它正常工作。先准备一个要加载的类假设放在/tmp/cl-demo/目录下public class Sample { public void sayHello() { System.out.println(Hello, I am loaded by: this.getClass().getClassLoader()); } }手工编译后得到一个Sample.class这是待加载目标。接下来写加载器本体——继承ClassLoader重写findClass方法从磁盘读取字节数组调用defineClass生成类对象import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; public class DiskClassLoader extends ClassLoader { private final String classPath; public DiskClassLoader(String classPath) { // 设置父加载器为系统类加载器 super(ClassLoader.getSystemClassLoader()); this.classPath classPath; } Override protected Class? findClass(String name) throws ClassNotFoundException { // 将包名中的点替换为路径分隔符 String fileName name.replace(., /) .class; Path path Paths.get(classPath, fileName); try { byte[] bytes Files.readAllBytes(path); // defineClass 将字节数组转换为 Class 对象 return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { throw new ClassNotFoundException(未找到类: name, e); } } }注意这里为什么要设置系统类加载器作为父加载器因为我们希望Sample不依赖核心库时先把能加载的委托给系统加载器只有系统加载器找不到时才走我们自己的findClass。保留双亲委派模型是绝大多数场景下的正确默认值。调用起来很简单public class DemoMain { public static void main(String[] args) throws Exception { DiskClassLoader loader new DiskClassLoader(/tmp/cl-demo); Class? clazz loader.loadClass(Sample); Object obj clazz.getDeclaredConstructor().newInstance(); obj.getClass().getMethod(sayHello).invoke(obj); } }运行结果会打印类似这样的内容Hello, I am loaded by: DiskClassLoader1b6d3586看到输出中的加载器名字不是系统默认的AppClassLoader说明这个类确实被我们自己的加载器接管了。这就是整个自定义类加载器的最小可用闭环。4.2 进阶实战先加密再解密的class文件加载上面那个例子还只是“读文件”价值有限。类加载器真正的威力在于它不在乎字节码的来源只关心字节数组本身。我举一个生产环境中常见的安全加固场景——对class字节码做加密防止别人直接解压jar包反编译出你的核心逻辑。实现思路分两步。第一步写一个加密工具把编译好的class文件用AES之类的算法加密得到.class的密文文件部署时替换掉明文。第二步写一个自定义加载器在findClass里读入密文、解密得到明文字节码再交给defineClass。核心代码片段如下import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; import java.nio.file.Files; import java.nio.file.Path; public class EncryptedClassLoader extends ClassLoader { private final String classPath; private final byte[] key; public EncryptedClassLoader(String classPath, byte[] key) { super(ClassLoader.getSystemClassLoader()); this.classPath classPath; this.key key.clone(); } Override protected Class? findClass(String name) throws ClassNotFoundException { try { String fileName name.replace(., /) .class; byte[] encryptedBytes Files.readAllBytes(Path.of(classPath, fileName)); byte[] decryptedBytes decrypt(encryptedBytes); return defineClass(name, decryptedBytes, 0, decryptedBytes.length); } catch (Exception e) { throw new ClassNotFoundException(解密加载失败: name, e); } } private byte[] decrypt(byte[] data) throws Exception { SecretKeySpec keySpec new SecretKeySpec(key, AES); Cipher cipher Cipher.getInstance(AES); cipher.init(Cipher.DECRYPT_MODE, keySpec); return cipher.doFinal(data); } }密钥的管理是关键。不要把密钥直接写死在代码里更别明文放在classpath下解密密钥的存储本身就是另一个安全课题但这已经超出了类加载器本身的范围属于密钥管理系统要考虑的事。我只看加载链路的话这样做确实能达到“字节码不落地、反编译只能看到密文”的效果。这里有一个在实际项目中容易踩的坑**加密/解密环节只保护了findClass的输入但类一旦被defineClass加载进JVM字节码在内存里仍然是明文形态通过堆转储、JVM TI接口等手段依然能拿到类字节码。**所以严格来说自定义加载器做加密并不能提供绝对的保护它提高了逆向门槛但不要以为上线之后核心算法就绝对安全了。4.3 实战中的关键选择重写loadClass还是只重写findClass写自定义类加载器时一个高频疑问是到底重写loadClass还是重写findClass我用一句话给出结论默认万事先只动findClass只有在需要打破双亲委派时才去重写loadClass。原因在于loadClass里的委派逻辑是经过多年实践验证的“安全默认值”一旦你重写它就必须自己处理所有边界情况父加载器找不到怎么办、重复加载怎么办、类的解析时机怎么控制、多线程并发加载同一类会不会有竞争问题。这些坑一个接一个排查起来远比多写几行findClass麻烦得多。举个具体例子。假设你只想让某个加载器优先查找指定目录下的类那就别重写loadClass直接继承URLClassLoader把目录转成URL丢进去就行它已经在内部重写了findClass你要做的只是配置。如果某个场景确实需要打破委派比如Tomcat的WebAppClassLoader那就老老实实参照官方实现对核心java.*包保留委派只对自己责任范围内的用户代码做“优先本地加载”。还有一个实践心得所有自定义类加载器都要实现getClassLoadingLock的锁语义。JDK8之后loadClass里用了synchronized块加锁但实际上JVM层面的类加载过程本身是线程安全的同一类名在同一加载器中重复调用loadClass最终也只会生成一份Class对象。如果你自己重写loadClass时忽略了同步控制高并发下可能出现同一个类被加载两次的诡异问题。这是很多人踩了坑还摸不着头脑的地方。5. 类加载器相关的常见问题排查与避坑技巧5.1 ClassNotFoundException 与 NoClassDefFoundError 有什么区别这两个异常在类加载问题中出现的频率最高但很多人分不清它们的本质区别。ClassNotFoundException是一个受检异常抛出它的场景是**显式地通过Class.forName、ClassLoader.loadClass、反射等方式去按名字加载一个类但加载链路中都找不到这个类。**也就是说代码想主动加载某个类但类并不存在。典型场景Class.forName(com.example.Driver)而Driver.class不在任何classpath目录里。NoClassDefFoundError是一个Error不是Exception它的触发场景是**某个类在编译期或运行期已经被其他代码引用但真正要用到这个类时类加载器找不到它。**听起来和上面的场景差不多但有一个关键区别前者是“主动加载发现没有”后者是“被动引用时发现没有”。还有一种更隐蔽的情况——第一次加载某个类时抛了异常比如静态初始化块抛错JVM会记录“加载失败”状态后续其他地方再引用这个类时直接抛NoClassDefFoundError即使class文件其实还在。排查顺序我建议按下述三步走第一确认类所在jar包是否真的在运行时classpath上用java -verbose:class启动能看到每个类的实际加载来源第二检查是否存在多个jar包包含同名类用jar tf逐一查看第三检查这个类是否被某个自定义类加载器隔离了应用里看不到。5.2 同名类冲突为什么依赖里有两个版本就会出诡异问题实际项目里“jar包冲突”是个出场率极高的话题但很多人不知道底层的机制就是类加载器。假如A依赖和B依赖各自传递了不同版本的fastjson这两个版本在classpath上都存在。类加载器在加载com.alibaba.fastjson.JSON时按classpath的先后顺序谁排在前面就用谁。这在类加载器层面并不报错但一旦运行时代码用到了一个版本里有、另一个版本里没有的方法就会抛出NoSuchMethodError或ClassFormatError。这里的排查工具最有效的就是mvn dependency:tree看依赖树或者直接用jar tf xxx.jar | grep 类名找出每个jar包里同名的类。找到后最稳妥的解决方式是用exclusion排除掉不需要的版本或者在自己的构建配置中显式指定统一版本。我见过很多线上故障的根源其实就是某个中间件强依赖的旧版本类被新版本的相同类顶掉了导致反射调用的方法签名对不上。5.3 类加载器内存泄漏排查思路与代码特征类加载器泄漏是长期运行的应用中比较麻烦的内存问题。它的机制并不复杂自定义类加载器加载的类对象如果被某个生命周期更长的对象持有引用那么这个类加载器以及它加载的所有类都永远无法被GC回收。每做一次热部署就new一个新的类加载器时间久了PermGen或者Metaspace就被撑爆。典型泄漏路径有三条。第一条静态变量持有了本应由当前类加载器管理的实例比如把某个对象放进了全局缓存Map第二条线程上下文类加载器被设置为自定义加载器但线程本身是长生命周期线程线程退出前没有还原第三条第三方框架注册了自己的监听器、回调接口这些注册信息的强引用里包含了类加载器。排查这类问题用jmap导出堆转储然后分析Metaspace中加载器数量最多的有哪些。jcmd pid GC.class_histogram能快速查看不同类加载器加载了多少类。常见解决思路包括代码中勤用弱引用、软引用框架的注册机制在使用完后显式反注册热部署框架本身要保留卸载能力不能只做加载不做清理。5.4 面试与工作中都适用的小技巧类加载器这块的知识面试和工作中有一个高频结合点值得大家专门准备“如何判断两个类是否相同”。很多人能讲出“类名相同就相同”但忽略了类全限定名 定义类加载器两个条件同时满足才算同一个类。不同加载器加载的同一个class文件得到的Class对象在equals、instanceof判断上都不等价。这是我上面反复强调的“唯一性”在实践中的体现也是Tomcat类隔离能成立的根基。另外再分享一个命令行的小技巧启动时加上-verbose:class参数JVM会把每个类加载的来源打印到控制台。有次同事排查一个诡异行为“明明我改了代码重新编译了运行结果却是旧逻辑”加上-verbose:class一看发现他改的类被两个位置同时覆盖加载到的根本是另一个目录下的旧class。这类问题用肉眼很难看出来交给类加载日志十几秒就能定位。排查依赖加载顺序、确认类是否从预期jar包加载时这个参数可以说是最高效的调试利器。还有一个容易被忽略但实战价值很高的点**JVM参数-XbootclasspathJDK8和--patch-moduleJDK9可以用来自定义核心类库的扩展但在生产环境乱加这类参数非常危险。**我曾见过有人为了图省事把一个低版本的第三方jar包挂到bootclasspath里结果整个应用出现各种奇怪的LinkageError原因就是部分核心API在加载阶段被旧版本干扰了。核心类的加载路径能不动就不要动这是类加载器领域里最值得遵守的一条“安全红线”。写在最后的一点体会类加载器这件事我接触了这么多年最大的感触是它是那种“理解了就很简单不理解就到处碰壁”的底层机制。很多看起来花里胡哨的问题——jar冲突、热部署失效、反射调用失败、内存溢出——追根溯源最后都会绕回到类加载器身上。如果你刚接触这个概念我建议你找一个周末的下午按照第四章的例子亲手写一个加载器、跑通一次加载流程再动手打断点看看loadClass的调用链比死记十倍定义都管用。这个知识点的门槛不在理解而在亲手验证一次之后的融会贯通。