先从一个很常见的现象说起。不知道你有没有遇到过这种情况:一个依赖明明已经放进去了,ClassNotFoundException却还是无情地砸下来;或者两个同名的类在项目里都存在,程序却“诡异地”加载了其中某一个,你翻遍代码也找不到是谁干的。这些问题如果只看报错堆栈,往往一头雾水,但只要把思路切换到“类加载器”这个维度,很多诡异现象瞬间就有了答案。类加载器(ClassLoader)是JVM运行时最基础也最容易被忽视的机制之一,而应用类加载器(AppClassLoader)又是我们日常写的代码里打交道最多的那个加载器。这篇文章是类加载器分析系列的第一篇,我会把应用类加载器的定位、加载路径、双亲委派模型里的实际表现,以及它在依赖冲突、热部署这些真实场景中扮演的角色,全部掰开揉碎讲一遍。内容不挑基础,刚入门的能把概念理清,有经验的能对照自己的实操经验查漏补缺。
1. 三层类加载体系里,应用类加载器到底站在哪
理解应用类加载器之前,必须先把它放进JVM的类加载体系里看。很多文章喜欢直接背“双亲委派”四个字,但如果不理解每一层加载器的职责边界和类路径来源,后面排查问题的时候会非常被动。
1.1 三个内建加载器的分工不是凭空的
JDK自带的类加载器有三个,从顶层到底层分别是启动类加载器(Bootstrap ClassLoader)、平台类加载器(Platform ClassLoader,JDK 8及以前叫扩展类加载器Extension ClassLoader)、应用类加载器(Application ClassLoader,也叫系统类加载器System ClassLoader)。注意我这里是按“顶层到底层”说的,但双亲委派的实际方向是反过来的:一个类加载器收到加载请求后,不会自己先去加载,而是先把请求往上抛给父加载器,父加载器处理不了再往下传。
先记住下面这个表,后面所有分析都围绕它展开。
| 加载器 | 实现位置 | 负责加载的路径 | 常见对应场景 |
|---|---|---|---|
| 启动类加载器 | JVM自身(C++实现) | jre/lib下的核心类库,如rt.jar、java.lang.* | String、Object、Integer这些JDK内部类 |
| 平台/扩展类加载器 | sun.misc.Launcher$ExtClassLoader(JDK 8及以前) | JDK 9开始模块化后负责java.base之外的内置模块 | 一些扩展库(JDK 8)或平台模块(JDK 9+) |
| 应用类加载器 | sun.misc.Launcher$AppClassLoader | java -cp、CLASSPATH环境变量指定的路径 | 你自己写的业务代码、引用的第三方依赖 |
应用类加载器在JVM里的完整类名是jdk.internal.loader.ClassLoaders$AppClassLoader(JDK 9模块化之后),老版本JDK里则是sun.misc.Launcher$AppClassLoader。这个类名本身并不重要,但记住它有两个好处:一是排查问题时你能一眼认出堆栈里来自应用类加载器的加载记录,二是面试或者写底层工具时提到这个类名能显示出你真看过源码,而不是只背概念。
1.2 应用类加载器的“地盘”取决于ClassPath
代码里这个类加载器到底是干什么的?先说结论:你写的所有代码,只要不是以java.、javax.等固定前缀开头的核心类,默认情况下都由应用类加载器加载。前提是这些类出现在ClassPath上。
这里有个容易误解的点,ClassPath不是只有命令行里的-cp参数。在一个典型项目里,构建工具(Maven、Gradle)会把所有依赖jar包的路径拼成一条classpath传给JVM;在IDE里运行时,IDE也会生成一条包含编译输出目录和依赖库的classpath。这些最终都会成为应用类加载器的搜索范围。所以你可以把应用类加载器理解成“负责你项目里一切业务类库的装卸工”——它真正知道你的项目里有哪些类,也知道这些类对应哪个jar包路径。
为了验证这一点,随便写一行代码:
public class ClassLoaderDemo { public static void main(String[] args) { System.out.println(ClassLoaderDemo.class.getClassLoader()); System.out.println(String.class.getClassLoader()); } }输出大概是这样的:
jdk.internal.loader.ClassLoaders$AppClassLoader@512ddf17 null第二行是null不是bug。引导类加载器是JVM内部实现的,在Java代码里拿不到引用,所以访问它的结果就是null。这个细节经常被初学者误以为“没加载”,实际上它是加载所有核心类的最顶层。
1.3 应用类加载器的父加载器是谁
应用类加载器的父加载器在JDK 8及以前是扩展类加载器,JDK 9以后是平台类加载器。很多人会误解“父加载器”一定是继承关系,实际上这里不是Java继承,而是组合关系。每个ClassLoader实例里有一个parent字段,指向另一个ClassLoader实例。
构造的时候大概是这样:
ClassLoader appClassLoader = new AppClassLoader(platformClassLoader);双亲委派模型里提到的“向上委托”,沿着这条parent链一路往上走,走到头就轮到引导类加载器。如果引导类加载器说“我没这个类”,请求再一层层往下返。JDK 9之后引入模块化,平台类加载器变成ClassLoaders$PlatformClassLoader,但委托关系的大逻辑没有变,只是搜索范围从“路径集合”变成了“模块集合”。
2. 双亲委派模型:应用类加载器为什么每次都先“认怂”
这一章是重点中的重点。很多人能背出“一个类加载器收到加载请求后先委派给父加载器”,但并不知道这个模型在应用类加载器这里具体是怎么一步步展开的。我直接用源码级逻辑配合一个实际加载案例来讲。
2.1loadClass方法里的完整流程
应用类加载器本身没有重写loadClass,真正控制双亲委派逻辑的是抽象类ClassLoader里的loadClass(String name)方法。流程可以拆成四步。
第一步,调用findLoadedClass(name)检查该类是否已经被当前加载器加载过。JVM对每个类加载器维护了一个已加载类表,同一个类在全JVM范围内允许被多个不同的类加载器各自独立加载,所以这一步必须按加载器隔离地检查。
第二步,如果当前加载器没加载过,就走parent.loadClass(name)向上委派。这一步是递归的,也就是说应用类加载器会先问平台扩展类加载器,平台扩展类加载器再问启动类加载器。启动类加载器没有父加载器,如果它也找不到,就返回加载失败。
第三步,如果父加载器找不到,当前加载器才调用findClass(name)自己找。应用类加载器的findClass本质上就是扫描ClassPath上的每一个jar包和目录,按包名路径找到对应的.class文件字节流,然后定义这个类。
第四步,如果findClass也找不到,抛出ClassNotFoundException。
用伪代码描述就是:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class<?> c = findLoadedClass(name); if (c == null) { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } if (c == null) { c = findClass(name); } } return c; } }记住这套流程之后,你会发现应用类加载器的“认怂”是刻在骨子里的:不是它能不加载就不加载,而是它必须优先保证核心类库和平台库的利益。这样设计的根本目的只有一个,避免你随随便便写一个java.lang.String就把JDK自己的String给顶掉,那是安全灾难。
2.2 一个类的完整加载链路实测
我拿一个最常见的场景做演示。假设你有一个类com.example.demo.OrderService,在main方法里首次触发对它的引用,完整链路是这样的:
- JVM启动时用
AppClassLoader加载入口类com.example.demo.DemoApplication。 - 执行到
new OrderService()时,JVM向“当前的类加载上下文”发起加载请求,这个上下文默认就是应用类加载器。 - 应用类加载器收到请求后,自己不找,先检查
OrderService类是否已经被加载,没有,那就问平台扩展类加载器。 - 平台扩展类加载器也问了问启动类加载器,
com.example.demo.OrderService显然不在核心库模块里,返回找不到。 - 一路回到应用类加载器,它才开始扫描项目目录下的
com/example/demo/OrderService.class文件,读取字节流并defineClass。 - 类加载完成后,才进入链接(验证、准备、解析)和初始化阶段。
这个过程中有一步容易被忽略:JVM在解析类的符号引用时,使用的是“定义这个类的加载器”来查找所有被引用的类。也就是说,如果OrderService被应用类加载器加载了,那么它内部依赖的OrderDao、UserService这些类默认也会由同一个加载器加载,而不是由发起调用的那个类加载器决定。这个事实放在后面排查依赖冲突时非常有用。
2.3 “双亲委派”看起来绕路,其实省了很多事
有人会问,既然应用类加载器早晚要自己加载,那绕一圈向上一问是不是纯属浪费?还真不是。
第一,避免了核心类库被用户代码覆盖。如果应用类加载器不先问启动类加载器,而自己从classpath里找到一个java.lang.Long,那整个JVM的Long行为就被篡改了。这是绝对不允许的。
第二,保证同一个类在全JVM中只有一份字节码定义。一个类在同一个加载器里只会被加载一次,但如果每个加载器都先自己找,那么可能出现两个不同加载器各自加载了同一个类和它的静态状态,造成数据错乱。双亲委派先把最可能的父级问过一遍,大部分情况下能保证最终定义到类的只有唯一的那个加载器。
第三,内存角度也合算。核心库的实现可以共享,不用每个应用类加载器都去重复读取rt.jar中的类文件。
Java 9之后引入了模块化,应用类加载器除了遵循双亲委派,还会检查模块边界。如果你写的代码依赖了某个没有requires的模块,在编译期可能正常,运行期会因为模块不可见而报错,这类问题在升级JDK后偶尔会冒出来。
3. 从URLClassLoader视角看应用类加载器的“家底”
应用类加载器在实现上继承自URLClassLoader,至少在JDK 8及以前是这样。JDK 9模块化之后改用了内置的BuiltinClassLoader,但搜索逻辑依然是“把一组URL路径作为候选,逐个查找”。理解这点,对后面排查jar包冲突至关重要。
3.1 应用类加载器到底从哪里找类
应用类加载器本质上维持了一个URL数组。这个数组里每一元素要么是一个jar包路径(file:/path/to/xxx.jar),要么是一个目录路径(file:/path/to/classes/)。加载某个类com.foo.Bar时,它会把这个类的二进制名称转成相对路径com/foo/Bar.class,然后按顺序遍历URL数组成员进行匹配。
我分享一个自己踩过的坑。曾经有一次线上服务启动极慢,查了很久发现依赖里有几十个大jar包,每个jar里都有大量类文件,而classpath顺序偏偏把一些很少用到的工具包排在了前头,导致启动阶段扫描路径特别长。这提醒我在做性能优化时,classpath的排列顺序也能成为一种隐性成本。正常项目里不用太在意,但如果你做的是超大型应用,启动时间是个敏感指标,那就值得关注了。
3.2ClassLoader.getResources与“同名资源冲突”
与类加载对应的还有资源加载。ClassLoader.getResource("application.yml")、ServiceLoader这些机制都依赖加载器扫描URL数组。如果一个配置文件在多个jar包里都存在,应用类加载器按顺序返回第一个找到的。而getResources则会把所有路径都找出来。
这里最常见的面试题是:为什么ServiceLoader经常加载到旧版本的实现类?答案就在这里——它按classpath顺序找到第一个META-INF/services里的描述文件就返回了,后面的根本没机会。想解决,要么调整依赖顺序,要么在代码层面过滤版本。我见过很多团队在SPI冲突上排查半天,最后发现只是pom引入顺序的问题。
3.3 自定义类加载器该不该继承URLClassLoader
这是一个实战决策。老项目中很多人会直接继承URLClassLoader来做一个“能从额外jar包路径加载类”的加载器。这个做法简单,但如果类加载路径复杂、还要和容器集成,自带的URLClassLoader能力往往不够,建议还是继承ClassLoader并重写findClass。
不过这里必须说清楚一个区别:继承ClassLoader并且重写findClass,并不会破坏双亲委派模型。破坏双亲委派的方法是重写loadClass并且不调用super.loadClass。所以你在网上看到很多“打破双亲委派”的教程,核心就是在loadClass里不走父加载器而直接findClass。应用类加载器本身不会去干这种事,它就是老老实实的标准委派执行者。
4. 应用类加载器在“类隔离”与“热部署”里的真实角色
很多场景下,应用类加载器只是一个背景板角色,但一旦你对它有了清晰认知,就能看懂那些复杂框架的架构思路。这一章我讲两个最典型的场景:Java的SPI机制和Tomcat的类加载结构。
4.1 SPI的“上下文类加载器”是在救谁
先看一段历史问题。JDK的DriverManager是引导类加载器加载的,它要动态加载MySQL驱动,而驱动类通常在你的classpath里,由应用类加载器加载。按照双亲委派,引导类加载器类要加载业务驱动?不可能,引导类加载器压根不知道你项目里的jar包路径。
为了解决这个“上层类调用下层类实现”的矛盾,JDK引入了Thread.currentThread().getContextClassLoader()。它默认是应用类加载器,DriverManager拿到这个加载器之后,就能用它去加载classpath里的驱动类。本质上就是给了核心库一个临时调用用户代码类加载器的入口。
这个机制很多人理解成“打破双亲委派”,我觉得更准确的说法是:双亲委派只解决从上往下的委派,而SPI需要从下往上的回调,于是必须借助上下文类加载器作为桥。如果你自己写框架并且要对用户程序做插件化扩展,就一定要设置好上下文类加载器,否则就会遇到“明明类在classpath上,但加载不到”的怪问题。
4.2 Tomcat为什么要“反着来”
Tomcat的类加载器体系没有完全遵循双亲委派,典型的有WebAppClassLoader。它的特点是:先尝试自己加载classpath下的类和WEB-INF/classes下的类,如果找不到,再把请求交给父加载器。这和标准双亲委派的方向是反的。
为什么要反着来?因为一个Tomcat容器里通常会跑多个Web应用,每个Web应用都可能有自己的Spring版本、自己的工具类版本。如果严格照着双亲委派走,容器级加载器会先把某个版本的Spring加载掉,另一个Web应用再想用不同版本就没机会了,类就被污染了。所以让每个Web应用自己的加载器优先加载自己WEB-INF/lib下的依赖,实现应用间的类隔离。
理解Tomcat这个设计,你就会意识到类加载器不只是个“加载机制”,更是一种隔离工具。一个类被谁加载,决定了它能访问哪些静态资源、能和其他哪些类协作。这也是为什么后来像OSGi、Java模块系统最终都把“类归属”作为核心问题来设计。
这里我给一个表,方便你对比不同容器场景下的委派策略差异。
| 场景 | 加载方向 | 目的 |
|---|---|---|
| 标准JDK应用 | 先父后子 | 保证核心类优先、安全 |
| JDBC等SPI场景 | 使用上下文类加载器 | 让核心库能回调用户代码类 |
| Tomcat Web应用 | 先子后父 | 实现Web应用之间的类隔离 |
| 热部署框架(如JRebel) | 自定义加载逻辑 | 让新版本类覆盖旧版本类,避免重复定义 |
4.3 热部署到底靠什么“换掉”一个类
热部署的核心不是修改类文件,而是重新定义同一个类。但JVM规定:同一个类加载器只能加载一个com.foo.Bar一次,不能重新定义同名类。所以所有热部署方案的基本思路都是:当一个类需要更新时,创建一个新的类加载器,让新加载器重新加载新版本的类。
应用类加载器在热部署里通常被当成“老加载器”保留,而新的类加载器往往以它为父,加载更新后的类。由于新类和旧类由不同加载器加载,它们在JVM里天然是两个不同类型,你甚至无法直接强转。这也是为什么很多热部署框架费尽心思做对象拷贝、替代引用。理解了这一层,你再看那些框架的内部实现就不会觉得神奇。
这里要插一个实际问题:频繁创建类加载器很容易造成Metaspace内存泄漏,尤其在一些反复部署的应用里。因为旧的类加载器可能被一些上下文引用着没法回收,它加载过的所有类也都没法回收。部署几次老年代就满了,最终触发OutOfMemoryError: Metaspace。
5. 依赖冲突排查实战:应用类加载器视角的五步定位法
标题里反复出现“应用类加载器”,这篇博文如果只讲概念不提实际排错场景,价值就砍了一半。这一节我用自己的真实经历总结一个排查链路,很多问题最后都归到“一个类被两个加载器加载了”或者“classpath里有两个版本”上。
5.1 排查入口:先分清ClassNotFoundException和NoClassDefFoundError
这两个错误长得像,根源完全不一样。
ClassNotFoundException是显式的,代表某个加载器在找某个类时找不到。最常见原因是jar包没引入、classpath漏了路径、或者父加载器已经加载了但当前加载器因委派问题没找到。
NoClassDefFoundError则非常迷惑。它的完整意思是:一个类在编译期是存在的,运行期第一次加载它也成功了,但之后在初始化阶段某个静态块或者某个字段引用的其他类加载失败,JVM会把这个类标记为“不可用”,等后续再引用时直接抛NoClassDefFoundError。
举个非常典型的例子。你有一个StaticInitClass,它的静态块里写了:
static { SomeDependency dep = new SomeDependency(); }如果SomeDependency类不在classpath里,StaticInitClass初始化失败,后续每次new StaticInitClass()都会抛NoClassDefFoundError,而不是ClassNotFoundException。很多人看到这个报错就直觉认为“缺了这个类”,其实真正缺的可能是另一个类。排查时一定要看原始Caused by链条。
5.2 五步定位法的落地过程
我通常按这五个步骤来定位类加载相关的疑难杂症,每一步都配合具体的命令或者工具。
第一步,获取当前类的实际加载器。入门先打印,SomeClass.class.getClassLoader()可以直接给你答案。如果答案是null说明被引导类加载器加载了,如果是一个自定义加载器,那你就要关注这个加载器的搜索路径。
第二步,打印加载器的搜索路径。如果类加载器能向下转成URLClassLoader(JDK 8环境),可以直接调用getURLs()把路径打出来。不让转就用Arthas、JDK Flight Recorder这类工具,或者直接在启动命令里加上-verbose:class看JVM加载了哪些类、来自哪些jar包。-verbose:class是排查验证期的万能工具,缺点是日志量非常大,建议在测试环境先试。
第三步,确认classpath里的同名类来自哪里。Maven项目用mvn dependency:tree看依赖树,找到同名类出现在哪个jar里,对比版本号。Gradle项目用gradle dependencies或者构建报告。这一步的核心是确定“竞争中谁赢了”。
第四步,检查是不是被上层容器加载器抢先加载了。典型场景是Tomcat里部署的Lib包和WEB-INF/lib里出现了同一个类,容器级加载器加载了旧版本,导致你的新版本压根没机会。解决方式是删除多余依赖或者调整加载顺序。
第五步,验证能不能复现,以及改动后的实际效果。改classpath、删旧包之后,再次查看加载器和加载源,确认已经切换到预期版本。这个验证步骤很多人会跳过,结果改完发现根本没生效。
5.3 抗混淆与日常防护建议
说白了,类加载器层面的问题大多出在“依赖管理混乱”上。我给几条防御性建议。
第一,统一依赖版本,尽量用BOM(Bill of Materials)管理整个项目的三方依赖版本,减少重复类出现的概率。
第二,Java项目里尽量别手动拷贝jar包到lib目录,这会造成肉眼看不见的重复类。用构建工具统一打fat jar或按需打包,一劳永逸。
第三,遇到ClassCastException的时候,不要只看异常信息,去打印两侧类的加载器是谁。这个异常一个很经典的特征就是“类名完全一样,但它和它自己转不了型”,原因就是两个加载器各加载了一份同类。
第四,如果你做一个平台类产品要开放插件机制,建议内部约定插件必须使用独立的类加载器加载,并且在文档里明确提示“不能依赖容器内已经存在的类”。这个约定能阻拦大量低级故障。
6. 顺着应用类加载器往下还能挖掘什么
第一篇既然讲清楚了应用类加载器的定位和行为,下一篇就可以顺理成章地进入更多高级话题。这里提前预告几个方向,也算是对这一章做个收束。
第一个方向,自定义类加载器。实际工作中很多框架都在做自定义加载器,比如热部署、加密class文件、隔离插件。搞明白应用类加载器的委派流程之后,自定义加载器最大的坎往往不是findClass怎么写,而是“我到底该不该重写loadClass”。这个话题我计划在后一篇里详细展开,包括怎么实现“同一个二进制类名在不同加载器下共存”以及对象传递时为什么必须用接口来桥接。
第二个方向,Java模块系统(JPMS)对类加载器的影响。JDK 9后应用类加载器从URLClassLoader演化为BuiltinClassLoader,模块封装边界和类加载路径都变了。老项目的--add-exports、--add-opens这类参数背后的原理也值得深挖,因为它们本质上是把模块边界打开,让应用类加载器能访问到JDK内部类。
第三个方向,线程上下文类加载器与JNDI、JPA、JDBC等SPI框架的协作细节。很多框架的“找不到实现类”问题,最后都会追溯到上下文类加载器被某些异步线程改掉了。这个点是我自己调试过很多次才彻底搞明白的,值得单独写一篇踩坑实录。
第四个方向,类加载器与内存泄漏的关联。自定义加载器相对复杂,如果哪次线上频繁Full GC查不出真凶,就多怀疑一下Metaspace区域,再结合类加载器回收的日志去定位。这个方向比拼写代码有意思得多,属于真正的底层调试能力范畴。
回去看这篇文章,其实没有一个技巧是“背下来就能用”的,但把应用类加载器这层概念彻底吃透,你以后排查依赖冲突、理解框架设计、甚至写自定义加载器,都会有一种“原来如此”的通透感。我个人的体会是:在Java世界待得越久,越能感觉到类加载器是一个承上启下的核心抽象——往上连着JVM规范,往下连着你项目里每一个真实运行的类。下一篇我会直接讲自定义类加载器的实战写法,包括加密class的加载和类卸载实验,到时候见。