Fat Jar 构建与加载内部细节
概述
Spring Boot 的可执行 JAR(Fat Jar)通过 spring-boot-maven-plugin 重新打包实现,将应用类、第三方依赖、内嵌容器和 Spring Boot Loader 打包为一个独立 JAR 文件。java -jar 时通过自定义的 LaunchedURLClassLoader 加载 BOOT-INF/ 中的嵌套 JAR。
本文将深入拆解 Fat Jar 构建与加载的 12 个内部细节,涵盖重新打包流程、目录结构、类加载机制、分层构建、Docker 优化等核心内容。
本文基于 Spring Boot 3.2.5 +
spring-boot-maven-plugin3.x 源码分析。基础概念可参考 Fat Jar 原理与分层构建。
1. RepackageMojo.execute() 重新打包流程
RepackageMojo 是 spring-boot-maven-plugin 的 Mojo 实现,在 Maven package 阶段执行重新打包。
@Mojo(name = "repackage", defaultPhase = LifecyclePhase.PACKAGE, requiresDependencyResolution = ResolutionScope.RUNTIME)
public class RepackageMojo extends AbstractMojo {
@Override
public void execute() throws MojoExecutionException {
// 1. 定位原始 JAR
File sourceJar = getSourceJar(); // target/my-app-1.0.0.jar.original
// 2. 创建 Repackager
Repackager repackager = new Repackager(sourceJar);
// 3. 重新打包
repackager.repackage(this::findDependencies);
}
private Libraries findDependencies() {
// 从 Maven 项目依赖中收集所有运行时依赖 JAR
Set<Artifact> artifacts = this.project.getArtifacts();
return (libraryCallback) -> {
for (Artifact artifact : artifacts) {
if (isRuntimeDependency(artifact)) {
libraryCallback.library(new Library(
artifact.getFile(),
LibraryScope.COMPILE_RUNTIME));
}
}
};
}
}Repackager.repackage() 核心逻辑:
public final class Repackager {
public void repackage(Libraries libraries) throws MojoExecutionException {
// 1. 备份原始 JAR 为 xxx.jar.original
File sourceJar = getSourceJar();
File backupJar = new File(sourceJar.getParent(), sourceJar.getName() + ".original");
sourceJar.renameTo(backupJar);
try (JarWriter writer = new JarWriter(sourceJar)) {
// 2. 写入 MANIFEST.MF
writer.writeManifest(buildManifest());
// 3. 写入 Spring Boot Loader 类(org/springframework/boot/loader/ 下的字节码)
writer.writeLoaderClasses();
// 4. 写入 BOOT-INF/classes/(应用类)
writer.writeEntries(backupJar, (entry) -> {
// 排除 META-INF/MANIFEST.MF,其余写入 BOOT-INF/classes/
return !entry.getName().equals("META-INF/MANIFEST.MF");
}, "BOOT-INF/classes/");
// 5. 写入 BOOT-INF/lib/(依赖 JAR)
libraries.doWithLibraries((library) -> {
writer.writeNestedLibrary("BOOT-INF/lib/", library);
});
// 6. 写入 BOOT-INF/classpath.idx
writer.writeClassPathIndex(libraries);
// 7. 可选:写入 BOOT-INF/layers.idx
if (isLayersEnabled()) {
writer.writeLayersIndex(layersConfig);
}
}
}
}完整流程:
mvn package
↓
RepackageMojo.execute() [PACKAGE 阶段]
↓
Repackager.repackage()
├── 备份原始 JAR → xxx.jar.original
├── 写入 MANIFEST.MF(Main-Class=JarLauncher)
├── 写入 Loader 字节码(org/springframework/boot/loader/)
├── 写入 BOOT-INF/classes/(应用类 + 资源)
├── 写入 BOOT-INF/lib/(依赖 JAR)
├── 写入 BOOT-INF/classpath.idx
└── 可选:写入 BOOT-INF/layers.idx
↓
最终生成可执行 JAR2. BOOT-INF/classes/ 存放应用类
BOOT-INF/classes/ 存放项目自身的编译产物和资源文件:
BOOT-INF/classes/
├── com/example/demo/
│ ├── DemoApplication.class
│ ├── controller/
│ │ └── UserController.class
│ └── service/
│ └── UserService.class
├── application.yml
├── logback-spring.xml
└── static/
└── index.html写入过程:
// JarWriter.writeEntries()
// 从原始 JAR(xxx.jar.original)中读取所有条目
// 排除 META-INF/MANIFEST.MF,其余写入 BOOT-INF/classes/
writer.writeEntries(backupJar, (entry) -> {
String name = entry.getName();
return !name.equals("META-INF/MANIFEST.MF")
&& !name.startsWith("META-INF/maven/"); // 排除 Maven 元数据
}, "BOOT-INF/classes/");3. BOOT-INF/lib/ 存放所有依赖 jar
BOOT-INF/lib/ 存放所有运行时依赖的 JAR 文件,保持原始 JAR 的完整性(不解压):
BOOT-INF/lib/
├── spring-boot-3.2.5.jar
├── spring-core-6.1.6.jar
├── spring-web-6.1.6.jar
├── spring-webmvc-6.1.6.jar
├── tomcat-embed-core-10.1.20.jar
├── jackson-databind-2.15.4.jar
└── ...写入过程:
// JarWriter.writeNestedLibrary()
// 每个依赖 JAR 作为一个嵌套条目写入 BOOT-INF/lib/
public void writeNestedLibrary(String destination, Library library) throws IOException {
String entryName = destination + library.getFile().getName();
// 创建 ZipEntry,不压缩(STORED 模式)以提升启动性能
ZipEntry zipEntry = new ZipEntry(entryName);
zipEntry.setMethod(ZipEntry.STORED);
// 写入依赖 JAR 的字节
writeEntry(zipEntry, library.getFile());
}标准 JAR 文件大小参考:
| 依赖 | 大小 |
|---|---|
spring-boot-starter-web + 依赖 | ~25 MB |
| 应用自身类 + 资源 | ~1-5 MB |
| 总计 Fat Jar | ~15-30 MB |
4. BOOT-INF/classpath.idx 文件格式
Spring Boot 3.2+ 引入了 classpath.idx,用于加速类路径构建,避免 JarLauncher 扫描整个 JAR 文件。
- "BOOT-INF/classes/"
- "BOOT-INF/lib/spring-core-6.1.6.jar"
- "BOOT-INF/lib/spring-beans-6.1.6.jar"
- "BOOT-INF/lib/spring-context-6.1.6.jar"
- "BOOT-INF/lib/spring-boot-3.2.5.jar"
- "BOOT-INF/lib/spring-boot-autoconfigure-3.2.5.jar"
- "BOOT-INF/lib/tomcat-embed-core-10.1.20.jar"
...YAML 格式的索引文件,每行表示一个 classpath 条目:
BOOT-INF/classes/— 应用类目录BOOT-INF/lib/*.jar— 每个依赖 JAR 的路径
JarLauncher 读取 classpath.idx:
class ClassPathIndexFile {
private final List<String> entries;
static ClassPathIndexFile load(Archive archive) throws IOException {
// 从 JAR 中读取 BOOT-INF/classpath.idx
Entry entry = archive.getEntry("BOOT-INF/classpath.idx");
if (entry == null) return null;
// 逐行解析 YAML 格式
List<String> lines = archive.read(entry);
List<String> entries = new ArrayList<>();
for (String line : lines) {
// 格式: "- \"BOOT-INF/lib/xxx.jar\""
line = line.trim();
if (line.startsWith("- \"")) {
entries.add(line.substring(3, line.length() - 1));
}
}
return new ClassPathIndexFile(entries);
}
List<String> getEntries() {
return entries;
}
}性能优势:无需遍历 JAR 的 Central Directory 查找 BOOT-INF/lib/ 下的所有条目,直接按索引加载,启动速度提升 20-30%。
5. BOOT-INF/layers.idx 分层索引
分层索引(Layered JAR)是 Spring Boot 2.3+ 引入的机制,将 JAR 内容分为多个逻辑层,优化 Docker 镜像层缓存。
- "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/"LayersIndex 解析:
public class LayersIndex {
private final Map<String, List<String>> layers; // layer → entries
public static LayersIndex fromArchive(Archive archive) throws IOException {
Entry entry = archive.getEntry("BOOT-INF/layers.idx");
// 按 YAML 格式解析
// "- "layerName":" → new layer
// " - "path":" → add to layer
}
public String getLayer(String entryName) {
// 根据条目路径判断属于哪一层
for (Map.Entry<String, List<String>> layer : layers.entrySet()) {
for (String pattern : layer.getValue()) {
if (matches(entryName, pattern)) {
return layer.getKey();
}
}
}
return "application"; // 兜底
}
}各层变更频率:
| 层 | 变更频率 | 典型大小 |
|---|---|---|
dependencies | 极低(依赖升级才变) | ~15-20 MB |
spring-boot-loader | 几乎不变 | ~200 KB |
snapshot-dependencies | 中等(SNAPSHOT 频繁构建) | ~5-10 MB |
application | 高(每次代码修改) | ~1-5 MB |
6. JarLauncher.main() → launch() 创建 LaunchedURLClassLoader
JarLauncher 是执行 java -jar 时的入口类(由 MANIFEST.MF 的 Main-Class 指定)。
// MANIFEST.MF
// Main-Class: org.springframework.boot.loader.launch.JarLauncher
// Start-Class: com.example.demo.DemoApplication
public class JarLauncher extends Launcher {
@Override
protected ClassPathArchives getClassPathArchives(Archive archive) throws Exception {
// 从 classpath.idx 或扫描 BOOT-INF/ 获取类路径
ClassPathIndexFile index = ClassPathIndexFile.load(archive);
if (index != null) {
return ClassPathArchives.fromIndex(archive, index);
}
// 降级:扫描 BOOT-INF/classes/ 和 BOOT-INF/lib/
return archive.getClassPathArchives(this::isNestedArchive);
}
public static void main(String[] args) throws Exception {
new JarLauncher().launch(args);
}
}Launcher.launch() 全流程:
public abstract class Launcher {
protected void launch(String[] args) throws Exception {
// 步骤 1:创建 Archive(解析 JAR 结构)
Archive archive = createArchive();
// 步骤 2:获取 ClassPathArchives(BOOT-INF 类路径)
ClassPathArchives classPathArchives = getClassPathArchives(archive);
// 步骤 3:构建 URL[] → 每个条目是一个 URL
List<URL> urls = new ArrayList<>();
for (Archive classPathArchive : classPathArchives) {
urls.add(classPathArchive.getUrl());
}
// 步骤 4:创建 LaunchedURLClassLoader
LaunchedURLClassLoader classLoader = new LaunchedURLClassLoader(
urls.toArray(new URL[0]),
getClass().getClassLoader() // 父加载器 = 启动类加载器
);
// 步骤 5:设置线程上下文类加载器
Thread.currentThread().setContextClassLoader(classLoader);
// 步骤 6:反射调用 Start-Class
launch(args, classLoader);
}
protected void launch(String[] args, ClassLoader classLoader) throws Exception {
String startClass = getMainClass(); // 从 MANIFEST.MF 读取 Start-Class
Class<?> mainClass = Class.forName(startClass, false, classLoader);
Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
mainMethod.invoke(null, new Object[]{args});
}
}LaunchedURLClassLoader 创建时的 URL 列表:
URL[] = {
jar:file:my-app.jar!/BOOT-INF/classes!/, // 应用类
jar:file:my-app.jar!/BOOT-INF/lib/spring-core-6.1.6.jar!/, // 依赖
jar:file:my-app.jar!/BOOT-INF/lib/spring-boot-3.2.5.jar!/,
...
}7. LaunchedURLClassLoader 的 getResource() 代理
LaunchedURLClassLoader 继承自 URLClassLoader,重写了 getResource() 和 loadClass() 方法。
public class LaunchedURLClassLoader extends URLClassLoader {
private final BootLoader bootLoader;
public LaunchedURLClassLoader(URL[] urls, ClassLoader parent) {
super(urls, parent);
// BootLoader 负责从 BOOT-INF/classes/ 加载资源
this.bootLoader = new BootLoader(urls);
}
@Override
public URL getResource(String name) {
// 1. 优先从 BootLoader 查找
URL url = this.bootLoader.findResource(name);
if (url != null) {
return url;
}
// 2. 委托给父加载器
URL parentUrl = super.getResource(name);
if (parentUrl != null) {
return parentUrl;
}
// 3. 从 URLClassLoader 的 URL 数组中查找
return findResource(name);
}
@Override
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 1. 检查已加载的类
Class<?> loadedClass = findLoadedClass(name);
if (loadedClass != null) {
return loadedClass;
}
// 2. 优先从 BootLoader 加载
try {
Class<?> bootClass = this.bootLoader.loadClass(name);
if (bootClass != null) {
return bootClass;
}
} catch (ClassNotFoundException ignored) {}
// 3. 委托给双亲委派
try {
return super.loadClass(name, resolve);
} catch (ClassNotFoundException ignored) {}
// 4. 从嵌套 JAR URL 中查找
return findClass(name);
}
}BootLoader 实现:
public class BootLoader {
private final URL[] urls;
public BootLoader(URL[] urls) {
this.urls = urls;
}
public URL findResource(String name) {
for (URL url : urls) {
// BOOT-INF/classes/ 下的资源
if (url.getPath().contains("BOOT-INF/classes")) {
URL resource = findResourceInUrl(url, name);
if (resource != null) return resource;
}
}
return null;
}
}类加载顺序:
LaunchedURLClassLoader.loadClass("com.example.DemoApplication")
↓
① findLoadedClass() — 已加载?直接返回
↓
② BootLoader.loadClass() — BOOT-INF 中查找
↓
③ super.loadClass() — 双亲委派(Bootstrap → Platform → Application)
↓
④ findClass() — URLClassLoader 从嵌套 JAR URL 查找8. JarFileWrapper 嵌套 jar 读取
标准 java.util.jar.JarFile 无法直接读取嵌套在另一个 JAR 内部的 JAR 文件。Spring Boot 通过 JarFileWrapper 和 NestedJarFile 解决此问题。
public class JarFileWrapper extends JarFile {
private final NestedJarFile nestedJarFile;
public JarFileWrapper(NestedJarFile nestedJarFile) {
super(nestedJarFile.getName()); // 调用父类构造器
this.nestedJarFile = nestedJarFile;
}
@Override
public ZipEntry getEntry(String name) {
return nestedJarFile.getEntry(name);
}
@Override
public InputStream getInputStream(ZipEntry ze) throws IOException {
return nestedJarFile.getInputStream(ze);
}
@Override
public Enumeration<JarEntry> entries() {
return nestedJarFile.entries();
}
}LaunchedURLClassLoader 通过 JarFileWrapper 读取依赖 JAR 中的类:
LaunchedURLClassLoader.findClass("org.springframework.web.servlet.DispatcherServlet")
↓
URLClassLoader 从 jar:file:my-app.jar!/BOOT-INF/lib/spring-webmvc-6.1.6.jar!/ 查找
↓
创建 JarFileWrapper(NestedJarFile)
↓
NestedJarFile.getEntry("org/springframework/web/servlet/DispatcherServlet.class")
↓
读取字节 → 定义 Class9. NestedJarFile.getEntry() 的 VirtualFilesystem
NestedJarFile 通过 VirtualFilesystem 管理嵌套 JAR 的文件句柄,避免大量打开的文件描述符。
public class NestedJarFile extends JarFile {
private final VirtualFilesystem virtualFilesystem;
private final long nestedJarOffset; // 嵌套 JAR 在父 JAR 中的偏移
public NestedJarFile(File file, VirtualFilesystem virtualFilesystem) throws IOException {
super(file); // 父类关联到物理文件
this.virtualFilesystem = virtualFilesystem;
this.nestedJarOffset = findNestedJarOffset(file); // 计算偏移
}
@Override
public ZipEntry getEntry(String name) {
// 通过 VirtualFilesystem 从偏移位置读取条目
return virtualFilesystem.getEntry(this.nestedJarOffset, name);
}
}VirtualFilesystem 管理文件句柄:
public class VirtualFilesystem {
private final File file;
private final RandomAccessDataFile randomAccessData;
// 缓存已打开的 NestedJarFile
private final Map<String, NestedJarFile> openJarFiles = new ConcurrentHashMap<>();
// 缓存已读取的 Central Directory
private final Map<Long, CentralDirectory> centralDirectories = new ConcurrentHashMap<>();
public ZipEntry getEntry(long offset, String name) {
// 1. 从缓存中获取 Central Directory
CentralDirectory centralDir = centralDirectories.computeIfAbsent(offset,
key -> readCentralDirectory(offset));
// 2. 在 Central Directory 中查找条目
return centralDir.getEntry(name);
}
private CentralDirectory readCentralDirectory(long offset) {
// 读取嵌套 JAR 的 Central Directory(文件末尾的索引区)
// 随机访问,无需加载整个文件
RandomAccessData data = randomAccessData.getSubsection(offset);
return CentralDirectory.parse(data);
}
public void close() {
// 关闭所有资源
openJarFiles.clear();
centralDirectories.clear();
randomAccessData.close();
}
}关键设计:
VirtualFilesystem通过RandomAccessData实现随机访问,无需解压嵌套 JARCentralDirectory缓存避免重复解析- 每个 Fat Jar 只打开一个物理文件句柄,通过偏移量访问不同的嵌套 JAR
10. 3.x spring-boot-loader 的 PropertiesLauncher
PropertiesLauncher 是 JarLauncher 的替代方案,通过 loader.properties 自定义加载路径。
public class PropertiesLauncher extends Launcher {
public static void main(String[] args) throws Exception {
new PropertiesLauncher().launch(args);
}
@Override
protected ClassPathArchives getClassPathArchives(Archive archive) throws Exception {
// 从 loader.properties 读取自定义路径
Properties properties = loadProperties();
// 支持以下属性:
// loader.path = BOOT-INF/lib/,lib/,custom-libs/
// loader.main = com.example.MyApplication
// loader.home = /app/myapp/
String loaderPath = properties.getProperty("loader.path", "BOOT-INF/lib/");
return resolveClassPathArchives(loaderPath);
}
}loader.properties 配置示例:
# 默认值(等价于 JarLauncher)
loader.path = BOOT-INF/lib/,BOOT-INF/classes/
# 外部依赖目录(运行时从外部加载,不打包到 JAR 中)
loader.path = lib/,ext-libs/
# 指定主类(覆盖 MANIFEST.MF 的 Start-Class)
loader.main = com.example.MyApplication
# 指定归档根目录
loader.home = /opt/myapp/
# 是否归档(ZIP/目录)
loader.archive = true使用场景:
- 将部分依赖放在 JAR 外部,减小 Fat Jar 体积
- 动态加载运行时插件
- 非标准目录结构的部署环境
11. Docker 分层构建
利用 layers.idx 进行 Docker 分层构建,最大化层缓存命中率。
Dockerfile 示例:
# 第一阶段:构建
FROM eclipse-temurin:17-jdk AS builder
WORKDIR /builder
COPY target/my-app-1.0.0.jar app.jar
# 解压 Fat Jar
RUN java -Djarmode=layertools -jar app.jar extract
# 第二阶段:运行
FROM eclipse-temurin:17-jre
WORKDIR /app
# 分层复制(每层独立缓存)
COPY --from=builder builder/dependencies/ ./
COPY --from=builder builder/spring-boot-loader/ ./
COPY --from=builder builder/snapshot-dependencies/ ./
COPY --from=builder builder/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]jarmode=layertools extract 命令详解:
// Layertools 模式入口
public class Layertools {
public static void main(String[] args) {
if ("extract".equals(args[0])) {
// 读取 layers.idx
LayersIndex layersIndex = LayersIndex.fromArchive(archive);
// 按层提取到目录
for (String layer : layersIndex.getLayers()) {
File layerDir = new File(layer);
for (String entry : layersIndex.getEntries(layer)) {
extractEntry(entry, layerDir);
}
}
}
}
}构建效果:
# 第一次构建
Step 4/7 : COPY --from=builder builder/dependencies/ ./
→ 缓存未命中,复制 ~18 MB
# 第二次构建(只修改了代码)
Step 4/7 : COPY --from=builder builder/dependencies/ ./
→ 缓存命中(dependencies 层未变)
Step 5/7 : COPY --from=builder builder/spring-boot-loader/ ./
→ 缓存命中
Step 6/7 : COPY --from=builder builder/snapshot-dependencies/ ./
→ 缓存命中
Step 7/7 : COPY --from=builder builder/application/ ./
→ 缓存未命中,复制 ~2 MB只重新复制变更层的文件(通常只有
application层),大幅减少 Docker 构建时间和镜像推送量。
12. WarLauncher 与 JarLauncher 的差异
WarLauncher 用于可执行 WAR 包,与 JarLauncher 的结构和加载路径不同。
| 对比维度 | JarLauncher | WarLauncher |
|---|---|---|
| 适用格式 | Fat Jar | 可执行 WAR |
| 类路径 | BOOT-INF/classes/ + BOOT-INF/lib/ | WEB-INF/classes/ + WEB-INF/lib/ + WEB-INF/lib-provided/ |
| 入口目录 | BOOT-INF/ | WEB-INF/ |
| MANIFEST 属性 | Spring-Boot-Classes / Spring-Boot-Lib | 同左(值不同) |
| 外部容器部署 | 不适用 | 可部署到标准 Servlet 容器 |
WarLauncher 实现:
public class WarLauncher extends Launcher {
@Override
protected ClassPathArchives getClassPathArchives(Archive archive) throws Exception {
// JarLauncher 的路径
// BOOT-INF/classes/ + BOOT-INF/lib/
// WarLauncher 的路径
// WEB-INF/classes/ + WEB-INF/lib/ + WEB-INF/lib-provided/
return archive.getClassPathArchives(this::isNestedArchive);
}
private boolean isNestedArchive(Archive.Entry entry) {
if (entry.isDirectory()) {
return entry.getName().equals("WEB-INF/classes/");
}
return entry.getName().startsWith("WEB-INF/lib/");
}
public static void main(String[] args) throws Exception {
new WarLauncher().launch(args);
}
}可执行 WAR 结构:
my-app.war
├── META-INF/
│ └── MANIFEST.MF # Main-Class: WarLauncher
├── WEB-INF/
│ ├── classes/ # 应用类(同 BOOT-INF/classes/)
│ ├── lib/ # 运行时依赖(同 BOOT-INF/lib/)
│ ├── lib-provided/ # 外部容器提供的依赖(如 servlet-api)
│ └── web.xml # 可选,传统 Servlet 配置
├── org/springframework/boot/loader/ # Loader 字节码
└── loader.propertiesWEB-INF/lib-provided/ 说明:
- 存放部署到外部容器时由容器提供的依赖(如
servlet-api) java -jar执行时加载这些 JAR- 部署到外部 Servlet 容器时不加载(避免冲突)
总结
Fat Jar 构建与加载的 12 个细节点总结如下:
| # | 细节点 | 核心类/机制 |
|---|---|---|
| ① | RepackageMojo.execute() 重新打包流程 | Repackager.repackage() → JarWriter.writeEntries() → 原 jar 写入 BOOT-INF/lib |
| ② | BOOT-INF/classes/ 存放应用类 | target/classes/ 目录下所有类文件和资源 |
| ③ | BOOT-INF/lib/ 存放所有依赖 jar | 解析 pom.xml 的 classpath → 复制到 lib/ 目录 |
| ④ | BOOT-INF/classpath.idx 文件格式 | - "BOOT-INF/lib/spring-core-6.0.0.jar" 构建 classpath 索引 |
| ⑤ | BOOT-INF/layers.idx 分层索引 | - "dependencies": → BOOT-INF/lib/ + - "application": → BOOT-INF/classes/ |
| ⑥ | JarLauncher.main() → launch() 创建 LaunchedURLClassLoader | new LaunchedURLClassLoader(urls, classLoader) |
| ⑦ | LaunchedURLClassLoader 的 getResource() 代理 | 优先从 BootLoader 查找 → 委托给父加载器 → findResource() |
| ⑧ | JarFileWrapper 嵌套 jar 读取 | NestedJarFile 从 BOOT-INF/lib/xxx.jar 中读取类 |
| ⑨ | NestedJarFile.getEntry() 的 VirtualFilesystem | 通过 VirtualFilesystem 管理嵌套 jar 的文件句柄和 Central Directory 缓存 |
| ⑩ | 3.x spring-boot-loader 的 PropertiesLauncher | 通过 loader.properties 自定义 loader 路径 |
| ⑪ | Docker 分层构建 | 利用 layers.idx 实现 COPY --from=builder dependencies/ → dependencies/ 缓存命中 |
| ⑫ | WarLauncher 与 JarLauncher 的差异 | WEB-INF/lib/ + WEB-INF/lib-provided/ vs BOOT-INF/lib/ |