Fat Jar 原理与分层构建
1. 概述
Spring Boot 的可执行 JAR(Fat Jar)是框架的核心创新之一。它将应用代码、第三方依赖和内嵌容器打包为一个独立的 JAR 文件,实现 java -jar 一键启动。本文将深入分析其实现原理,并探讨 Spring Boot 2.3+ 引入的分层构建(Layered JAR)机制及其在 Docker 镜像优化中的实践。
2. spring-boot-maven-plugin 与可执行 JAR 结构
2.1 插件配置
spring-boot-maven-plugin 在打包阶段拦截标准 Maven 构建流程,将普通 JAR 重写为可执行 JAR:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.example.demo.DemoApplication</mainClass>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
</plugins>
</build>2.2 可执行 JAR 的文件结构
解压一个典型的 Spring Boot Fat Jar 后,其目录结构如下:
my-app-1.0.0.jar
├── META-INF/
│ ├── MANIFEST.MF # 入口配置
│ └── maven/ # Maven 元数据
├── BOOT-INF/
│ ├── classes/ # 应用编译后的 class 文件与资源
│ ├── lib/ # 所有第三方依赖 JAR
│ └── classpath.idx # 类路径索引(3.2+)
├── org/
│ └── springframework/
│ └── boot/
│ └── loader/ # Spring Boot Loader 字节码
│ ├── JarLauncher.class
│ ├── Launcher.class
│ ├── MainMethodRunner.class
│ ├── archive/ # 归档文件处理
│ ├── jar/ # 嵌套 JAR 处理
│ └── util/ # 工具类
└── loader.properties # 加载器配置(可选)2.3 MANIFEST.MF 关键条目
Manifest-Version: 1.0
Spring-Boot-Version: 3.3.0
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: com.example.demo.DemoApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Layers-Index: BOOT-INF/layers.idx- Main-Class:JVM 启动时加载的入口,指向 Spring Boot 提供的
JarLauncher - Start-Class:开发者的实际应用主类,由
JarLauncher反射加载 - Spring-Boot-Classes / Spring-Boot-Lib:标记类文件和依赖库在 JAR 中的路径
3. JarLauncher.main() 类加载机制源码分析
3.1 启动入口
当执行 java -jar my-app.jar 时,JVM 读取 MANIFEST.MF 中的 Main-Class,调用 JarLauncher.main():
// JarLauncher.java(Spring Boot 3.3.x)
package org.springframework.boot.loader.launch;
public class JarLauncher extends Launcher {
@Override
protected ClassPathArchives getClassPathArchives(Archive archive) throws Exception {
return archive.getClassPathArchives(
Archive.EntryFilter.ALL,
this::isNestedArchive
);
}
private boolean isNestedArchive(Archive.Entry entry) {
if (entry.isDirectory()) {
return entry.getName().equals("BOOT-INF/classes/");
}
return entry.getName().startsWith("BOOT-INF/lib/");
}
public static void main(String[] args) throws Exception {
new JarLauncher().launch(args);
}
}3.2 启动流程时序
整个启动过程分为三个关键阶段:
JVM 入口 → JarLauncher.main()
│
▼
创建 Archive 对象 ← ① 解析 JAR 文件结构
│
▼
创建 ClassLoader ← ② 定制类加载器,加载 BOOT-INF/lib 与 BOOT-INF/classes
│
▼
反射调用 Start-Class ← ③ 调用应用主类的 main()3.3 核心源码分析
阶段①:构建 Archive
// Launcher.java(简化)
public abstract class Launcher {
protected void launch(String[] args) throws Exception {
// 1. 创建 JAR 归档对象
JarArchive archive = new JarArchive(new File(
Launcher.class.getProtectionDomain()
.getCodeSource().getLocation().toURI()
));
// 2. 获取类路径归档(BOOT-INF/classes + BOOT-INF/lib/*)
ClassPathArchives classPathArchives = getClassPathArchives(archive);
// 3. 创建类加载器
ClassLoader classLoader = createClassLoader(classPathArchives);
// 4. 启动应用
launch(args, classLoader);
}
}JarArchive 使用 RandomAccessData 读取嵌套在 JAR 中的 JAR 文件。这是关键:标准 java.util.jar.JarFile 无法识别嵌套 JAR,因此 Spring Boot 实现了自定义的 NestedJarFile:
// NestedJarFile.java(简化)
public class NestedJarFile extends JarFile {
private final RandomAccessData data;
public NestedJarFile(RandomAccessData data) throws IOException {
// 基于 RandomAccessData 解析嵌套 JAR 的 Central Directory
this.data = data;
}
@Override
public Enumeration<JarEntry> entries() {
// 读取嵌套 JAR 内部的条目
return parseCentralDirectory(data);
}
}阶段②:创建 ClassLoader
// Launcher.java
protected ClassLoader createClassLoader(ClassPathArchives classPathArchives)
throws Exception {
List<URL> urls = new ArrayList<>();
// 添加 BOOT-INF/classes/
for (Archive archive : classPathArchives.getClasses()) {
urls.add(archive.getUrl());
}
// 添加 BOOT-INF/lib/ 下的每个 JAR
for (Archive archive : classPathArchives.getLib()) {
urls.add(archive.getUrl());
}
URL[] urlsArray = urls.toArray(new URL[0]);
// 使用 LaunchedURLClassLoader(自定义 URLClassLoader)
return new LaunchedURLClassLoader(
urlsArray, getClass().getClassLoader()
);
}LaunchedURLClassLoader 重写 loadClass() 方法,优先从 BOOT-INF/ 中的路径查找类,遵循双亲委派模型但允许反转特定场景下的委派顺序,确保内嵌 JAR 中的类能够被正确加载。
阶段③:启动应用
// Launcher.java
protected void launch(String[] args, ClassLoader classLoader)
throws Exception {
Thread.currentThread().setContextClassLoader(classLoader);
// 从 MANIFEST.MF 读取 Start-Class
String startClass = getMainClass();
// 反射创建应用主类并调用 main 方法
Class<?> mainClass = Class.forName(startClass, false, classLoader);
Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
mainMethod.invoke(null, new Object[] { args });
}此时控制权完全交给应用,Spring Boot 的自动配置、内嵌 Tomcat 等机制随之启动。
4. BOOT-INF 目录结构详解
4.1 设计动机
传统 Maven 打包生成的 JAR 中,依赖库被解压后以 .class 文件形式存在。这种方式导致 JAR 膨胀且无法保留依赖的版本信息。BOOT-INF 结构通过保留嵌套 JAR 的完整性解决了此问题。
4.2 各目录职责
| 目录 | 用途 | 打包阶段 |
|---|---|---|
BOOT-INF/classes/ | 项目自身的编译产物(.class + 资源文件) | compile + process-resources |
BOOT-INF/lib/ | 所有 Maven 运行时依赖 JAR | package(dependency:copy) |
BOOT-INF/classpath.idx | 类路径索引文件(3.2+ 引入) | package |
4.3 classpath.idx
Spring Boot 3.2+ 引入 classpath.idx 以加速类路径构建:
- "BOOT-INF/classes/"
- "BOOT-INF/lib/spring-boot-3.3.0.jar"
- "BOOT-INF/lib/spring-core-6.1.0.jar"
- "BOOT-INF/lib/spring-web-6.1.0.jar"该索引文件使 JarLauncher 无需扫描整个 JAR 即可快速定位所有依赖,减少启动延迟。
5. Layered JAR 分层构建(Spring Boot 2.3+)
5.1 为什么需要分层
在 Docker 化部署中,每次代码修改都重新构建一个数百 MB 的 Fat Jar 效率极低。Docker 利用镜像层缓存机制——若某层未变化则可复用。传统 Fat Jar 将所有内容放入单层,任何代码变更都会使整个镜像缓存失效。
5.2 默认分层结构
Spring Boot 2.3+ 引入分层 JAR 机制,默认分为四层:
layers.idx
- "dependencies":
- "BOOT-INF/lib/"
- "spring-boot-loader":
- "org/"
- "snapshot-dependencies":
- "BOOT-INF/lib/*-SNAPSHOT.jar"
- "application":
- "BOOT-INF/classes/"
- "BOOT-INF/classpath.idx"
- "BOOT-INF/layers.idx"
- "META-INF/"分层依据文件路径匹配规则:
| 层名 | 匹配规则 | 变更频率 |
|---|---|---|
dependencies | 非 SNAPSHOT 的依赖 JAR | 极低 |
spring-boot-loader | org/ 目录下的加载器类 | 几乎不变 |
snapshot-dependencies | 版本号包含 SNAPSHOT 的 JAR | 中等 |
application | 应用类与资源 | 最高 |
5.3 自定义分层
通过 layers.xml 自定义分层策略:
<?xml version="1.0" encoding="UTF-8"?>
<layers xmlns="http://www.springframework.org/schema/boot/layers">
<application>
<into layer="app">
<include>com/example/**</include>
</into>
</application>
<dependencies>
<into layer="framework">
<include groupId="org.springframework.boot" />
</into>
<into layer="third-party">
<exclude groupId="org.springframework.boot" />
</into>
</dependencies>
<layerOrder>
<layer>dependencies</layer>
<layer>framework</layer>
<layer>third-party</layer>
<layer>app</layer>
</layerOrder>
</layers>5.4 分层提取机制
spring-boot-jarmode-layertools 提供了提取分层的能力:
# 查看分层信息
java -Djarmode=layertools -jar my-app.jar list
# 提取分层到目录
java -Djarmode=layertools -jar my-app.jar extract提取后的目录结构:
extracted/
├── dependencies/
│ └── BOOT-INF/lib/*.jar
├── snapshot-dependencies/
│ └── BOOT-INF/lib/*-SNAPSHOT.jar
├── spring-boot-loader/
│ └── org/
└── application/
└── BOOT-INF/6. Docker 镜像优化
6.1 分层构建原理
Docker 镜像由只读层叠加组成。每一层对应 Dockerfile 中的一条指令。当层内容未变化时,Docker 直接复用缓存层。分层 JAR 的核心思路是:将不同变更频率的资源放入不同 Docker 层,最大化缓存命中率。
分层映射关系:
Fat Jar 层 → Docker 镜像层
─────────────────────────────────
dependencies → base + dependencies 层
spring-boot-loader → loader 层(与依赖合并)
snapshot-dependencies → snapshot-deps 层(可选)
application → application 层6.2 传统 Dockerfile 的问题
# ❌ 非分层构建:任何变更都导致全部缓存失效
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY target/my-app-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]只要应用代码改动,COPY 指令的缓存即失效,下一次构建需重新下载所有依赖。
6.3 优化后 Dockerfile(依赖在前)
利用分层 JAR 和 Docker 的多阶段构建实现精细化层控制。
7. 实战:Dockerfile 分层构建
7.1 多阶段构建方案
# === 第一阶段:提取分层 ===
FROM eclipse-temurin:17-jre AS builder
WORKDIR /builder
COPY target/my-app-1.0.0.jar application.jar
RUN java -Djarmode=layertools -jar application.jar extract
# === 第二阶段:组装运行镜像 ===
FROM eclipse-temurin:17-jre
WORKDIR /app
# 按变更频率从低到高依次 COPY
# 基础依赖层(几乎不变)
COPY --from=builder /builder/dependencies/ ./
# Spring Boot 加载器层(几乎不变)
COPY --from=builder /builder/spring-boot-loader/ ./
# 快照依赖层(偶尔变化)
COPY --from=builder /builder/snapshot-dependencies/ ./
# 应用层(频繁变化)
COPY --from=builder /builder/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]7.2 构建与验证
# 构建镜像
docker build -t my-app:layered .
# 查看镜像历史,确认各层大小
docker history my-app:layered
# 输出示例:
# IMAGE CREATED SIZE
# <latest> 2 seconds ago 15.3kB ← 应用层(仅应用代码)
# <sha:xxx> 2 seconds ago 20.1MB ← snapshot-dependencies
# <sha:xxx> 2 seconds ago 245kB ← spring-boot-loader
# <sha:xxx> 2 seconds ago 89.5MB ← dependencies
# <sha:xxx> 3 months ago 142MB ← base JDK image7.3 缓存效果验证
场景:仅修改一行 Java 代码后重新构建
# 第一次构建(完整)
docker build -t my-app:v1 .
# real 2m30s(下载依赖 + 编译 + 构建)
# 仅修改应用代码后第二次构建
docker build -t my-app:v2 .
# real 15s(仅重新复制 application 层)利用 Docker 的 --cache-from 在 CI/CD 管道中可进一步加速:
# CI 中拉取上次构建的镜像作为缓存源
docker pull registry.example.com/my-app:latest || true
docker build \
--cache-from registry.example.com/my-app:latest \
-t registry.example.com/my-app:$CI_COMMIT_SHA .7.4 分层构建的最佳实践
# 专业级分层 Dockerfile —— 适配 Spring Boot 3.x
# ==============================================
# --- 构建阶段 ---
FROM eclipse-temurin:17-jre AS builder
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract
# --- 运行阶段 ---
FROM eclipse-temurin:17-jre
# 安全建议:以非 root 用户运行
RUN groupadd --system --gid 1000 app && \
useradd --system --gid app --uid 1000 --shell /sbin/nologin app
WORKDIR /app
# Layer 1: 第三方依赖(最稳定)
COPY --from=builder /builder/dependencies/ ./
# Layer 2: Spring Boot 加载器
COPY --from=builder /builder/spring-boot-loader/ ./
# Layer 3: SNAPSHOT 依赖(可选层)
COPY --from=builder /builder/snapshot-dependencies/ ./
# Layer 4: 应用代码(最易变)
COPY --from=builder /builder/application/ ./
RUN chown -R app:app /app
USER app
# 启用 JMX 与远程调试(按需)
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", \
"-Djava.security.egd=file:/dev/./urandom", \
"-jar", "application.jar"]7.5 Maven/Gradle 插件配置对比
Maven(pom.xml):
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>Gradle(build.gradle):
bootJar {
layered {
enabled = true
}
}8. 常见问题与排查
8.1 启动报错 "No such file or directory"
java.io.IOException: No such file or directory
at java.util.zip.ZipFile.open(Native Method)原因:Fat Jar 文件损坏或不完整。常见于 CI 构建产物传输过程中被截断。
解决:校验 JAR 文件完整性:
# 检查 JAR 是否为有效的 ZIP 格式
unzip -t my-app.jar
# 或用 Java 内置工具
jar -tf my-app.jar | head -208.2 类冲突(ClassNotFoundException / NoSuchMethodError)
原因:BOOT-INF/lib/ 中存在多个版本的同一依赖。
诊断:使用 Maven Enforcer 插件排查冲突:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<id>enforce-dependency-convergence</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<dependencyConvergence />
</rules>
</configuration>
</execution>
</executions>
</plugin>8.3 分层提取失败
# 确认 JAR 是分层构建的
jar -tf my-app.jar | grep layers.idx
# 输出包含 layers.idx 则支持分层提取
# 若未启用分层,重新运行 mvn package 并确认插件配置了 <layers><enabled>true</enabled></layers>9. 总结
Spring Boot Fat Jar 通过自定义类加载器和嵌套 JAR 解析机制,实现了"打包即运行"的极致体验。理解其运行原理有助于:
- 排查启动问题:掌握类加载顺序和嵌套 JAR 的读写机制
- 优化构建速度:利用分层构建将 CI/CD 构建时间从分钟级降至秒级
- 降低镜像体积:合理的分层策略使 Docker 镜像复用基础层,减少存储与传输开销
- 提升部署效率:在 Kubernetes 环境中,小尺寸的镜像层更新可大幅降低 Pod 启动延迟
从 JarLauncher 的类加载源码到 Docker 镜像的多阶段构建,Spring Boot 的工程化设计贯穿了从开发到部署的全生命周期。分层构建立足于 Docker 镜像分层缓存这一底层特性,巧妙地将 JAR 内部的资源分组与镜像层一一对应,是云原生时代 Java 应用部署的最佳实践。