ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

应用类加载器全解析:从双亲委派到依赖冲突排查

应用类加载器全解析:从双亲委派到依赖冲突排查 先从一个很常见的现象说起。不知道你有没有遇到过这种情况一个依赖明明已经放进去了ClassNotFoundException却还是无情地砸下来或者两个同名的类在项目里都存在程序却“诡异地”加载了其中某一个你翻遍代码也找不到是谁干的。这些问题如果只看报错堆栈往往一头雾水但只要把思路切换到“类加载器”这个维度很多诡异现象瞬间就有了答案。类加载器ClassLoader是JVM运行时最基础也最容易被忽视的机制之一而应用类加载器AppClassLoader又是我们日常写的代码里打交道最多的那个加载器。这篇文章是类加载器分析系列的第一篇我会把应用类加载器的定位、加载路径、双亲委派模型里的实际表现以及它在依赖冲突、热部署这些真实场景中扮演的角色全部掰开揉碎讲一遍。内容不挑基础刚入门的能把概念理清有经验的能对照自己的实操经验查漏补缺。1. 三层类加载体系里应用类加载器到底站在哪理解应用类加载器之前必须先把它放进JVM的类加载体系里看。很多文章喜欢直接背“双亲委派”四个字但如果不理解每一层加载器的职责边界和类路径来源后面排查问题的时候会非常被动。1.1 三个内建加载器的分工不是凭空的JDK自带的类加载器有三个从顶层到底层分别是启动类加载器Bootstrap ClassLoader、平台类加载器Platform ClassLoaderJDK 8及以前叫扩展类加载器Extension ClassLoader、应用类加载器Application ClassLoader也叫系统类加载器System ClassLoader。注意我这里是按“顶层到底层”说的但双亲委派的实际方向是反过来的一个类加载器收到加载请求后不会自己先去加载而是先把请求往上抛给父加载器父加载器处理不了再往下传。先记住下面这个表后面所有分析都围绕它展开。加载器实现位置负责加载的路径常见对应场景启动类加载器JVM自身C实现jre/lib下的核心类库如rt.jar、java.lang.*String、Object、Integer这些JDK内部类平台/扩展类加载器sun.misc.Launcher$ExtClassLoaderJDK 8及以前JDK 9开始模块化后负责java.base之外的内置模块一些扩展库JDK 8或平台模块JDK 9应用类加载器sun.misc.Launcher$AppClassLoaderjava -cp、CLASSPATH环境变量指定的路径你自己写的业务代码、引用的第三方依赖应用类加载器在JVM里的完整类名是jdk.internal.loader.ClassLoaders$AppClassLoaderJDK 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$AppClassLoader512ddf17 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说明被引导类加载器加载了如果是一个自定义加载器那你就要关注这个加载器的搜索路径。第二步打印加载器的搜索路径。如果类加载器能向下转成URLClassLoaderJDK 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 抗混淆与日常防护建议说白了类加载器层面的问题大多出在“依赖管理混乱”上。我给几条防御性建议。第一统一依赖版本尽量用BOMBill 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的加载和类卸载实验到时候见。
返回列表