☰
Spring Boot可执行JAR依赖加载机制拆解
2026/10/8 3:49:13 网站建设 项目流程

我曾经遇到一个特别典型的问题:项目打完包,java -jar app.jar跑得飞起,但一旦把 jar 里的内容解压,想用老办法java -cp app.jar com.example.DemoApplication启动,立刻报ClassNotFoundException。同样是这一个文件,为什么启动方式差一点,结果天差地别?Spring Boot 打成的可执行 JAR 在运行时究竟是怎么把 BOOT-INF/lib 下那一堆依赖 JAR 加载起来的?

这篇文章不是讲怎么打包,也不是讲怎么部署,而是把这个“可执行 JAR”的里子拆开,专门说说依赖加载机制。弄懂这一层,后面你再遇到启动异常、资源文件读不到、Docker 分层后启动变慢这类问题,基本都能第一时间定位到方向。

1. 先拆开可执行 JAR:BOOT-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 和 resources,BOOT-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-Class,JVM 启动java -jar时只看Main-Class,它看到的是JarLauncher,一个 Spring Boot 提供的引导类。

换句话说,可执行 JAR 的入口是故意分了两层的:

  1. JVM 加载JarLauncher,它是一个真实存在于 JAR 根目录下的类,系统类加载器能直接找到。
  2. 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 就自己造了轮子。这个轮子分两块:一个是自定义类加载器LaunchedClassLoader(Spring Boot 3.2 之前叫LaunchedURLClassLoader),另一个是对嵌套 JAR 的读取能力。

3.1 LaunchedClassLoader 的构造:一个带自定义协议的 URLClassLoader

LaunchedClassLoader本质上还是一个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 自带类时,会先交给 parent,parent 有就返回,这样不会破坏 JDK 自身的类体系。

当业务代码里第一次触发Class.forName("com.example.Foo")时,LaunchedClassLoader先问 parent,parent 说没有,然后它才遍历自己的 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 的路径后,会做三件核心事情:

  1. 读取Start-Class配置。
  2. 读取BOOT-INF/classpath.idx或扫描BOOT-INF/lib,拿到依赖列表,构造出所有嵌套 URL。
  3. 用这些 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-Class:

Class<?> 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 和 PropertiesLauncher

JAR 场景用的是JarLauncher,War 包场景用的是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 类加载机制的痛点上,理解起来也就不是死记硬背了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询