Spring Boot可执行JAR依赖加载机制拆解
发布时间:2026/10/8 3:49:18 锦皓数字建站

我曾经遇到一个特别典型的问题项目打完包java -jar app.jar跑得飞起但一旦把 jar 里的内容解压想用老办法java -cp app.jar com.example.DemoApplication启动立刻报ClassNotFoundException。同样是这一个文件为什么启动方式差一点结果天差地别Spring Boot 打成的可执行 JAR 在运行时究竟是怎么把 BOOT-INF/lib 下那一堆依赖 JAR 加载起来的这篇文章不是讲怎么打包也不是讲怎么部署而是把这个“可执行 JAR”的里子拆开专门说说依赖加载机制。弄懂这一层后面你再遇到启动异常、资源文件读不到、Docker 分层后启动变慢这类问题基本都能第一时间定位到方向。1. 先拆开可执行 JARBOOT-INF 里藏着的那套目录结构先把话题落到地面上。用unzip -l app.jar或者jar tf app.jar看一眼Spring Boot 的可执行 JAR 和普通 JAR 最大的区别就是多了一个BOOT-INF目录目录结构大概是这个样子app.jar ├─ META-INF/ │ ├─ MANIFEST.MF │ └─ maven/... ├─ org/springframework/boot/loader/ │ ├─ JarLauncher.class │ └─ ... └─ BOOT-INF/ ├─ classes/ │ └─ com/example/DemoApplication.class ├─ classpath.idx └─ lib/ ├─ spring-core-6.1.0.jar ├─ spring-boot-3.2.0.jar └─ ...这个结构不是随便定的每个目录都有明确的职责。BOOT-INF/classes放的是你自己项目编译出来的业务 class 和 resourcesBOOT-INF/lib放的是所有第三方依赖 JAR而且是一个个原封不动的 JAR 文件。org/springframework/boot/loader放在 JAR 根目录下这是 Spring Boot 自己的启动引导类。1.1 为什么业务类不放在 JAR 根目录我一开始非常困惑既然是 JAR按传统习惯把 class 直接扔根目录不就行了为什么非要套一层 BOOT-INF/classes原因在于依赖隔离和元信息冲突。如果直接把所有依赖 JAR 解压合并成一个打包 JAR你会立刻遇到META-INF/services互相覆盖的问题。Java 的 SPI 机制靠的就是这个目录下的文件名比如META-INF/services/javax.sql.DataSource。两个 JAR 里如果都有同名服务文件解压合并的时候后写的会把先写的覆盖掉结果某个实现类直接静默丢失。Spring Boot 的自动装配也一样META-INF/spring.factories或者META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports是多个依赖 JAR 都存在的文件一旦覆盖整个自动装配就废了。保持每个依赖 JAR 独立让自定义类加载器在枚举META-INF/services时能拿到所有 JAR 里的同名路径这才是BOOT-INF/lib保留一整个 JAR 的意义。1.2 MANIFEST.MF 里藏着两个 main 入口再打开META-INF/MANIFEST.MF你会看到这样几行关键信息Main-Class: org.springframework.boot.loader.launch.JarLauncher Start-Class: com.example.DemoApplication Spring-Boot-Version: 3.2.0 Spring-Boot-Classes: BOOT-INF/classes/ Spring-Boot-Lib: BOOT-INF/lib/ Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx很多新手会拿着Start-Class去对说“这就是我的 main 类啊”。没错但 JVM 并不知道Start-ClassJVM 启动java -jar时只看Main-Class它看到的是JarLauncher一个 Spring Boot 提供的引导类。换句话说可执行 JAR 的入口是故意分了两层的JVM 加载JarLauncher它是一个真实存在于 JAR 根目录下的类系统类加载器能直接找到。JarLauncher负责把BOOT-INF/classes和BOOT-INF/lib/*.jar构建成真正的类加载器环境再反射调用Start-Class里配置的那个业务 main 方法。如果你直接执行java -cp app.jar com.example.DemoApplication系统类加载器只会从 JAR 根目录按com/example/DemoApplication.class去找而实际路径是BOOT-INF/classes/com/example/DemoApplication.class完全对不上所以立刻ClassNotFoundException。1.3 BOOT-INF/classpath.idx给启动加速的清单BOOT-INF/classpath.idx是 Spring Boot 2.5 以后默认生成的索引文件内容大概长这样- BOOT-INF/classes/ - BOOT-INF/lib/spring-boot-3.2.0.jar - BOOT-INF/lib/spring-core-6.1.0.jar这个文件的作用很直接启动时不用再扫整个外层 JAR 的几百 MB 数据去找BOOT-INF/lib下到底有哪些依赖直接读这个小文本文件就拿到了完整的类路径清单。老版本的 Spring Boot 没有这个索引loader 启动时要把外层 JAR 的所有条目都扫一遍然后筛出BOOT-INF/lib/下的 JAR大项目那一下还是很可观的。2. 为什么 java -jar 能跑java -cp 却完全找不到依赖理解了目录结构下一个问题自然冒出来java -jar app.jar的时候JVM 到底是怎么把这些依赖类加载出来的为什么不能像普通 JAR 那样直接加进 classpath 用2.1 普通 classpath 只会“看”指定位置要理解这个瓶颈得先想清楚URLClassLoader加载类的基本逻辑。系统类加载器AppClassLoader本质上是一个URLClassLoader它只负责到你给它的 URL 列表里去“按类名找文件”。给它一个app.jar它就会把app.jar当做一个 zip 包查找com/example/DemoApplication.class是否存在。问题是com/example/DemoApplication.class并不在 JAR 根目录它在BOOT-INF/classes/子目录里。系统类加载器不会自己去猜“要不要再往里走一层”——它不是智能爬虫只是按 URL 后缀路径去 zip 里找。所以java -cp app.jar com.example.DemoApplication失败并不是“类本身没有了”而是“类路径没对上”。依赖就更是同理。BOOT-INF/lib/spring-core-6.1.0.jar作为一个文件存在于app.jar内部但它没有出现在 classpath 里JVM 不会主动发现它。对普通 classpath 来说外层 JAR 内部嵌套的若干 JAR在类加载层面是“不可见的”。2.2 JAR 中的 JAR标准 URL 解析搞不定的死结有人会说那我把嵌套 JAR 也作为一个 URL 传进去不就行了问题的确可以这样抽象但实操起来JVM 标准实现过不了“两层 JAR”这一关。假设我们要加载spring-core里的org.springframework.util.Assert如果手动构造完整的 URL它长成这样jar:file:/opt/app/app.jar!/BOOT-INF/lib/spring-core-6.1.0.jar!/org/springframework/util/Assert.class仔细数一数这里面有两个!/。标准 JDK 的JarURLConnection在处理jar:URL 时会把jar:file:/opt/app/app.jar!/BOOT-INF/lib/spring-core-6.1.0.jar解析为“打开/opt/app/app.jar这个 zip找到BOOT-INF/lib/spring-core-6.1.0.jar这个 entry”到这里它是能处理的。但再接上后半段!/org/springframework/util/Assert.class标准实现就不知道该怎么处理了——它不认识“在一个 JAR 的 entry 内部再继续找 class”这种递归操作。这就是“JAR 中的 JAR”的死结。JDK 本身没有为这种嵌套加载提供标准方案所以 Spring Boot 必须自己动手。2.3 依赖不是整体加载而是按需查字典这里顺便纠正一个常见的误解。很多人以为java -jar启动时会把几百个依赖 JAR 整个读进内存所以启动才慢。其实完全不是。类加载器的工作方式是“按名查字典”JVM 执行到某段代码遇到了org.springframework.util.Assert这个类符号才让类加载器去查这个类在哪个 JAR 里查到对应字节流后defineClass到内存后续再遇到同类就直接返回缓存。整个启动过程真正加载进内存的类可能只有依赖 JAR 里很小的一部分。这也解释了为什么明明BOOT-INF/lib塞了几百个 JAR你java -jar启动时内存并不会直接爆表——它只是准备了一个“字典”需要哪个查哪个。3. LaunchedClassLoader 和嵌套 JAR 读取破解两层 JAR 的关键既然标准 JDK 做不到Spring Boot 就自己造了轮子。这个轮子分两块一个是自定义类加载器LaunchedClassLoaderSpring Boot 3.2 之前叫LaunchedURLClassLoader另一个是对嵌套 JAR 的读取能力。3.1 LaunchedClassLoader 的构造一个带自定义协议的 URLClassLoaderLaunchedClassLoader本质上还是一个URLClassLoader只不过它手里的 URL 列表长这样jar:file:/opt/app/app.jar!/BOOT-INF/classes/ jar:file:/opt/app/app.jar!/BOOT-INF/lib/spring-core-6.1.0.jar!/ jar:file:/opt/app/app.jar!/BOOT-INF/lib/spring-boot-3.2.0.jar!/ jar:file:/opt/app/app.jar!/BOOT-INF/lib/xxx.jar!/ ...也就是说它把BOOT-INF/classes目录和BOOT-INF/lib下的每一个依赖 JAR都转换成嵌套 URL塞进自己的搜索路径。同时它的 parent 还是系统类加载器遵循双亲委派。比如加载java.lang.String这类 JDK 自带类时会先交给 parentparent 有就返回这样不会破坏 JDK 自身的类体系。当业务代码里第一次触发Class.forName(com.example.Foo)时LaunchedClassLoader先问 parentparent 说没有然后它才遍历自己的 URL 列表逐个尝试读取。到了jar:file:/app.jar!/BOOT-INF/lib/spring-core-6.1.0.jar!/这个 URL能不能真正读出 class 字节就取决于 Spring Boot 对嵌套 JAR 的处理了。3.2 嵌套 JAR 怎么被“打开”自定义 JarFile 和 URL 连接Spring Boot 2.x 时代的做法比较重它自己实现了一套org.springframework.boot.loader.jar.JarFile从 zip 的目录头开始自己解析目的就是支持“包中包”的读取。你可以把它理解为先打开外层 JAR读到BOOT-INF/lib/spring-core-6.1.0.jar这个 entry再从这段数据里解析内层 JAR 的中央目录最后在内层目录里找 class。这样做的本质是读取嵌套 JAR 里某个 class 的时候不是把整个内层 JAR 先解压出来而是直接按“外层 zip 定位 entry - 解析内层 zip 中央目录 - 读取目标条目字节流”的链路一步到位。Spring Boot 3.2 之后loader 逻辑被重写迁到了org.springframework.boot.loader.launch包下面引入了NestedJarFile等新的实现底层尽量复用 JDK 标准 JAR 处理能力同时也额外处理多版本 JAR 这类细节。对外表现是一样的你依然能通过jar:file:/app.jar!/BOOT-INF/lib/xxx.jar!/...这样的 URL 拿到类字节流。3.3 为什么这套机制不会解压整个 JAR一个常见疑问是既然嵌套 JAR 在另一个 JAR 里面直接把它解压到临时目录再加载不是更省事unzip 确实能解开但 Spring Boot 设计上刻意避免“运行时解压整包”。原因有两个第一解压整个几百 MB 的 fat JAR 需要耗费大量磁盘 IO 和临时空间每次启动都要做一遍慢且浪费。第二应用关闭后临时目录需要清理否则磁盘迟早被/tmp里的垃圾填满。所以 Spring Boot 选择了“按需读取”路线只要某个类字节流还没被请求过就永远不去碰那个内层 JAR。代价是每找一个 class 都要经历两层 zip 定位比直接读文件系统慢一些这也是 fat JAR 启动比解压运行略慢的根源之一。4. 从 JarLauncher 到你的 main 方法完整启动链路前面把原理拆开了现在把整个启动链路从头到尾走一遍你就能明白中间每个环节在干什么。4.1 JarLauncher 是第一个被 JVM 加载的类执行java -jar app.jar时JVM 读取 MANIFEST.MF 中的Main-Class也就是org.springframework.boot.loader.launch.JarLauncher。该类在 JAR 根目录下系统类加载器可以直接加载它的main方法启动public static void main(String[] args) throws Exception { new JarLauncher().launch(args); }这里没走业务 main业务 main 对 JVM 来说还“不存在”。4.2 构造类加载器并设置线程上下文类加载器JarLauncher拿到自身所在 JAR 的路径后会做三件核心事情读取Start-Class配置。读取BOOT-INF/classpath.idx或扫描BOOT-INF/lib拿到依赖列表构造出所有嵌套 URL。用这些 URL 创建LaunchedClassLoader。紧接着launcher 会把新建的LaunchedClassLoader设置为当前线程的上下文类加载器Thread.currentThread().setContextClassLoader(classLoader)。这一步很多人会忽略但它极其重要。JDBC 驱动、ServiceLoader、部分反射框架以及 Spring 自己的SpringFactoriesLoader在加载 SPI 实现的时候都是优先用线程上下文类加载器。如果不手动设置这个值默认是系统类加载器那么它连BOOT-INF/classes里的业务类都找不到更别提BOOT-INF/lib下的依赖。4.3 反射调用业务 main线程上下文类加载器设置好之后launcher 用LaunchedClassLoader去加载Start-ClassClass? mainClass Class.forName(startClass, false, classLoader); Method mainMethod mainClass.getDeclaredMethod(main, String[].class); mainMethod.invoke(null, new Object[] { args });到这里com.example.DemoApplication才第一次被加载它的main才开始执行。之后 SpringApplication 启动过程中所有关于“这个类加载器”“那个依赖加载不上”的问题都是在这个LaunchedClassLoader的上下文里发生的。4.4 顺带说一下 WarLauncher 和 PropertiesLauncherJAR 场景用的是JarLauncherWar 包场景用的是WarLauncher原理基本一样只是归档目录从BOOT-INF换成了WEB-INF/classes和WEB-INF/lib。还有一个PropertiesLauncher它允许通过loader.path外部指定依赖目录。比如你想把依赖放到/opt/app/lib外面不塞进 fat JAR就可以把Main-Class改成PropertiesLauncher然后配置loader.path/opt/app/lib。这个场景在多环境部署里偶尔用但日常开发很少碰知道有这么一个灵活入口就够了。5. 这套加载机制在日常使用里会带来哪些坑和排查方法原理清楚了关键是要能用上。我在实际项目里遇到过好几类因为 fat JAR 依赖加载机制产生的坑都值得单独拿出来说。5.1 读取 classpath 资源时别把 URL 当 File最常见的坑就是这个。开发环境在 IDE 里跑得好好的一打成可执行 JAR 部署到服务器就报错且报错集中在读取配置文件、模板、证书时。典型的错误写法是这样的URL url getClass().getClassLoader().getResource(templates/xxx.html); File file new File(url.toURI());在 IDE 里classpath 下的资源是文件系统里的真实文件这段代码没问题。但在 fat JAR 里同一个资源的 URL 可能是jar:file:/opt/app/app.jar!/BOOT-INF/classes/templates/xxx.html这不是一个物理文件路径new File(url.toURI())会直接抛异常。正确做法是一律用getResourceAsStream读取字节流try (InputStream in getClass().getClassLoader().getResourceAsStream(templates/xxx.html)) { // 处理输入流 }如果业务上必须要一个真正的临时文件那就先复制到Files.createTempFile再传路径。5.2 启动慢能解压运行就别硬扛fat JAR 模式每加载一个类都要经过两层 zip 定位项目越大依赖越多启动越慢。如果你的应用对启动时间很敏感尤其是容器频繁重启、K8s 探活场景可以考虑在部署阶段解压后用 classpath 方式启动。解压方式很简单unzip app.jar -d app-unzipped java -cp app-unzipped/BOOT-INF/classes:app-unzipped/BOOT-INF/lib/* com.example.DemoApplication注意 Windows 下 classpath 分隔符是分号Linux 下是冒号。这样启动时 JVM 直接访问文件系统少了一层嵌套 JAR 解析启动速度在依赖多的项目上差别很明显。代价是部署目录从“一个 jar 文件”变成了“一整个目录”看你怎么取舍。5.3 没有万能的包名命令但有三个排查手段可以快速定位遇到 fat JAR 加载相关的问题我通常按顺序做三件事。第一先看MANIFEST.MF和classpath.idx确认Main-Class、Start-Class和依赖列表是否符合预期unzip -p app.jar META-INF/MANIFEST.MF unzip -p app.jar BOOT-INF/classpath.idx第二启动时加 JVM 参数-verbose:class观察每个类实际从哪个 URL 加载输出大概类似[Loaded com.example.DemoApplication from file:/deploy/app.jar] [Loaded org.springframework.util.Assert from jar:file:/deploy/app.jar!/BOOT-INF/lib/spring-core-6.1.0.jar!/org/springframework/util/Assert.class]看到jar:file:...!/BOOT-INF/lib/...!/这种来源就说明加载链路没问题如果某个类一直没出现或者来源不是你预期的 JAR问题就锁定到了依赖冲突或加载顺序上。第三在允许用 arthas 的环境里用sc -d看目标类的 ClassLoader 和 CodeSource。通过classLoaderHash判断是不是同一个类加载器加载通过 CodeSource 判断类到底来自哪个嵌套 JAR比反复改 pom 试错高效得多。5.4 依赖组件的同名类冲突先看 classpath 顺序再改 pom最后说一个比较隐蔽的坑。fat JAR 里面几百个依赖 JAR难免出现两个库传递依赖了同一个第三方类但版本不同。LaunchedClassLoader加载同名类时谁先出现在 URL 列表里谁就赢了。如果你unzip -p app.jar BOOT-INF/classpath.idx发现某个不该出现的旧版本 JAR 排在了前面那么运行到那个类时就会NoSuchMethodError。这种问题不要去改 Spring Boot 的启动机制而是回到 Maven 依赖管理上去处理。用mvn dependency:tree找出传递依赖链再用dependencyManagement或exclusion把多余的旧版本排掉。改完之后重新打包再看一遍classpath.idx里的顺序确认版本对上了再上线。我自己最喜欢的一个验证方式是把-verbose:class输出重定向到日志文件里跑一次冒烟用例哪个类从哪个 JAR 来一目了然。等到这套机制彻底熟悉了你再看 Spring Boot loader 那些源码会发现它其实没多少类但每一行都踩在 JDK 类加载机制的痛点上理解起来也就不是死记硬背了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。