Spring Boot 4.1.1 深度源码解析-第 27 章 · 可执行归档与 Loader:JarLauncher 与嵌套 jar
本章全程阅读
spring-boot-4.1.1/loader/下的三个子模块 (spring-boot-loader/spring-boot-loader-tools/spring-boot-jarmode-tools), 共 139 + 42 + 16 个主源码文件。所有类名、常量、路径均来自真实源码。
0. 从一个 java -jar 出发
$ java -jar myapp.jar
这条命令背后,JVM 只做三件事:
- 打开这个 jar;
- 读
META-INF/MANIFEST.MF的Main-Class属性; - 用系统类加载器(
AppClassLoader)加载那个类,调它的main(String[])。
关键点在第 2 步:JVM 只认 Main-Class,它不知道 Start-Class 是什么,
也不知道 BOOT-INF/lib/ 里的 jar 该怎么进 classpath。
java -jar 的 classpath 就是这个 jar 本身,而 BOOT-INF/lib/*.jar
在 JVM 眼里只是一些普通的 zip 条目,不会被当作 classpath。
Spring Boot 的解法是把"让自己跑起来"这件事分两步:
MANIFEST.MF:
Main-Class: org.springframework.boot.loader.launch.JarLauncher ← JVM 认的
Start-Class: com.example.MyApplication ← Spring Boot 认的
JarLauncher 是 JVM 加载的唯一一个类。它跑起来之后:
- 找到自己所在的 jar("我是谁");
- 把
BOOT-INF/classes/和BOOT-INF/lib/*.jar变成一组 URL; - 用这组 URL 建一个自己的
ClassLoader; - 在这个 classloader 里加载
Start-Class,反射调它的main。
整个 loader 的存在意义就是"把嵌套在 jar 里的 jar 变成 classpath"。
这句话听起来简单,但它是本章 10 万字符要讲完的东西——
因为要让"jar 里的 jar"能被 JVM 的类加载机制消费,需要自己实现
一个 URL 协议、两个 URLStreamHandler、一套 zip 解析器、一个 ClassLoader 子类,
还要处理 Zip64、多版本 jar、线程中断、Windows 路径、System.exit 等一堆细节。
先看一个最小事实,确认这条路真的存在:
$ unzip -l myapp.jar | head -20
0 BOOT-INF/
0 BOOT-INF/classes/
... BOOT-INF/classes/com/example/MyApplication.class
0 BOOT-INF/lib/
###### BOOT-INF/lib/spring-core-7.0.9.jar
###### BOOT-INF/lib/spring-boot-4.1.1.jar
0 org/
0 org/springframework/boot/loader/launch/
... org/springframework/boot/loader/launch/JarLauncher.class
org/springframework/boot/loader/** 就是在普通 classpath 上的(JVM 能直接加载),
而 BOOT-INF/** 是给 JarLauncher 自己消费的。这个目录划分就是全部设计的起点。
1. 模块拓扑:三个子模块,两条时间线
Spring Boot 的 loader 不在 module/ 下,而在仓库根部的 loader/ 目录。
这不是随意的——spring-boot-loader 必须能被"打进"用户的 jar 里,
它的构建产物是一个普通的、不依赖 Spring 的库。
loader/
├── spring-boot-loader/ ← 运行时(打进你的 fat jar)
├── spring-boot-loader-tools/ ← 构建时(Gradle/Maven 插件调用,不进 fat jar)
└── spring-boot-jarmode-tools/ ← jar 模式工具(extract / list-layers)
1.1 spring-boot-loader 的 11 个包
org/springframework/boot/loader/
├── launch/ 12 个类 ← Launcher / Archive / LaunchedClassLoader
├── jar/ 7 个类 ← NestedJarFile / ManifestInfo / MetaInfVersionsInfo
├── zip/ 16 个类 ← ZipContent / DataBlock / 各种 zip record
├── net/protocol/ 1 个类 ← Handlers(注册 java.protocol.handler.pkgs)
│ ├── jar/ 12 个类 ← 覆写 JDK 的 jar: 协议
│ └── nested/ 4 个类 ← 新增的 nested: 协议
├── nio/file/ 6 个类 ← NIO.2 FileSystemProvider 集成
├── ref/ 2 个类 ← Cleaner 抽象
├── log/ 1 个类 ← DebugLogger
└── jarmode/ 2 个类 ← JarMode 接口
这个包结构本身就在讲故事。zip/ 是最底层(纯 zip 字节解析,完全不知道 jar 是什么);
jar/ 建在它之上(理解 MANIFEST.MF、多版本 jar、签名);
net/protocol/ 把这两个能力包装成 JVM 认识的 URL;
launch/ 是最上层,负责"启动"这个动作。
1.2 一个必须注意的依赖事实:spring-core 是 compileOnly
// loader/spring-boot-loader/build.gradle
dependencies {
compileOnly("org.springframework:spring-core")
testImplementation(project(":test-support:spring-boot-test-support"))
testRuntimeOnly("ch.qos.logback:logback-classic")
testRuntimeOnly("org.bouncycastle:bcprov-jdk18on:1.78.1")
testRuntimeOnly("org.springframework:spring-webmvc")
}
spring-core 只在编译期可见,运行时不打包进去。
所以 loader 里凡是用到 Spring 的地方都必须"可选"——
否则一个不带 Spring 的 fat jar 会在启动时 NoClassDefFoundError。
我在全模块里找到的 Spring 依赖点只有一处:
// JarModeRunner.runJarMode
List<JarMode> candidates = SpringFactoriesLoader.loadFactories(JarMode.class,
ClassUtils.getDefaultClassLoader());
只在 -Djarmode=... 这条路径上用到 SpringFactoriesLoader。
普通启动路径 java -jar app.jar 完全不碰 Spring——
这也解释了为什么 JarLauncher 能在一个"还没加载任何 Spring 类"的 JVM 里跑起来。
这条依赖约束是理解整个 loader 设计的钥匙: 它不能用 Spring 的任何东西(
@Component、ResourceLoader、Environment全都不行), 只能用 JDK 原生 API + 自己的实现。你在 loader 里看到的所有"重复造轮子" (自己的占位符解析SystemPropertyUtils、自己的日志DebugLogger、 自己的 Cleaner 抽象)都是这个约束的直接后果。
1.3 spring-boot-loader-tools:构建侧的 42 个类
这一半不打进 fat jar,而是被 Gradle/Maven 插件在打包时调用:
org/springframework/boot/loader/tools/
├── Repackager / Packager / ImagePackager ← 三种打包器
├── JarWriter / AbstractJarWriter ← 写 jar
├── Layout / Layouts / CustomLoaderLayout ← 布局(JAR / WAR / ZIP / NONE)
├── MainClassFinder ← 找 main 方法
├── Layers / LayersIndex / StandardLayers ← 分层(Docker 镜像优化)
├── NativeImageArgFile / ReachabilityMetadataProperties ← GraalVM 元数据
├── JavaExecutable / RunProcess / SignalUtils ← 进程与信号
└── DefaultTimeZoneOffset / DefaultLibraryCoordinates ← 可复现构建
注意 DefaultTimeZoneOffset 和 Repackager.repackage(..., FileTime lastModifiedTime):
它们服务于可复现构建(reproducible build)——同一个输入必须产出字节相同的 jar,
所以时间戳不能取"现在",而要用固定值。
1.4 spring-boot-jarmode-tools:一个 jar 的第二种身份
org/springframework/boot/jarmode/tools/
├── ToolsJarMode ← 实现 JarMode,accepts("tools")
├── Runner / Command / Context
├── ExtractCommand / ExtractLayersCommand / ListCommand / ListLayersCommand
├── IndexedJarStructure / IndexedLayers / JarStructure / Layers
└── MissingValueException / UnknownOptionException
它让同一个 fat jar 既能"运行"也能"被拆开":
$ java -jar myapp.jar # 运行
$ java -Djarmode=tools -jar myapp.jar extract # 拆开
$ java -Djarmode=tools -jar myapp.jar list-layers # 看分层
这个能力是 Docker 镜像分层优化的基础(§7.3 会展开)。
2. Launcher:启动的五步
2.1 Launcher.launch():整个模块的入口
protected void launch(String[] args) throws Exception {
if (!isExploded()) {
Handlers.register();
}
try {
ClassLoader classLoader = createClassLoader(getClassPathUrls());
String jarMode = System.getProperty("jarmode");
String mainClassName = hasLength(jarMode) ? JAR_MODE_RUNNER_CLASS_NAME : getMainClass();
launch(classLoader, mainClassName, args);
}
catch (UncheckedIOException ex) {
throw ex.getCause();
}
}
五步,每步都有值得停下来的地方:
第 1 步:Handlers.register(),但只在非 exploded 时。
protected boolean isExploded() {
Archive archive = getArchive();
return (archive != null) && archive.isExploded();
}
如果你的应用是展开成目录运行的(ExplodedArchive),
那 BOOT-INF/lib/*.jar 就是磁盘上的真实文件,用 JDK 自带的 file: URL 就够了,
不需要嵌套 jar 协议。所以 Spring Boot 在这里做了一个早退优化:
不注册协议处理器,后面所有 URL 都是普通的 file: URL,
整套 nested: / ZipContent 基础设施都不会被用到。
第 2 步:getClassPathUrls() —— 由子类实现(Jar/War 有不同的前缀规则)。
第 3 步:createClassLoader(...) —— 建 LaunchedClassLoader。
第 4 步:决定"到底运行哪个类"。
String jarMode = System.getProperty("jarmode");
String mainClassName = hasLength(jarMode) ? JAR_MODE_RUNNER_CLASS_NAME : getMainClass();
这里出现了本章的第一个"岔路":jarmode 系统属性会劫持入口。
只要 -Djarmode= 有值(哪怕是个非法值),
运行的就是 JarModeRunner 而不是你的 Start-Class。
hasLength 只判断非空字符串,所以 -Djarmode= (空值)不算。
第 5 步:launch(classLoader, mainClassName, args)。
protected void launch(ClassLoader classLoader, String mainClassName, String[] args) throws Exception {
Thread.currentThread().setContextClassLoader(classLoader);
Class<?> mainClass = Class.forName(mainClassName, false, classLoader);
Method mainMethod = getMainMethod(mainClass);
mainMethod.setAccessible(true);
if (mainMethod.getParameterCount() == 0) {
mainMethod.invoke(null);
}
else {
mainMethod.invoke(null, new Object[] { args });
}
}
三处细节:
setContextClassLoader(classLoader)在Class.forName之前。 顺序很重要:你的main方法里第一件事可能就是Thread.currentThread().getContextClassLoader(), 比如 JPA 的Persistence、JNDI 的InitialContext都依赖它。Class.forName(name, false, classLoader)的第二个参数initialize = false—— 不触发静态初始化。因为真正的初始化应该由 JVM 在调用main时按 JLS 语义完成。 提前初始化会改变类的初始化顺序,可能导致静态块里的副作用提前发生。mainMethod.setAccessible(true)—— 因为你的main可能是private吗?不是。main必须 public 才能被 JVM 调用,但这里是反射调用, 而JarLauncher所在的模块(org.springframework.boot.loader.launch) 与你的类不在同一个模块/包里,Java 9 模块系统下需要这个调用。 即使不强模块化,setAccessible也是"我要调用非导出包里的 public 方法"的保险。
private Method getMainMethod(Class<?> mainClass) throws Exception {
try {
return mainClass.getDeclaredMethod("main", String[].class);
}
catch (NoSuchMethodException ex) {
return mainClass.getDeclaredMethod("main");
}
}
支持无参的 main()!这是 JDK 21 之后 JEP 445( unnamed classes / flexible main methods)
带来的形态。Spring Boot 4.x 已经开始兼容它。
注意顺序:先找 main(String[]),找不到再找 main()——
所以两者都存在时用 main(String[])。
2.2 Archive 抽象:从"我是哪个类"反推"我在哪个文件"
public interface Archive extends AutoCloseable {
Predicate<Entry> ALL_ENTRIES = (entry) -> true;
Manifest getManifest() throws IOException;
Set<URL> getClassPathUrls(Predicate<Entry> includeFilter, Predicate<Entry> directorySearchFilter)
throws IOException;
default boolean isExploded() {
return getRootDirectory() != null;
}
default File getRootDirectory() {
return null;
}
@Override
default void close() throws Exception {
}
interface Entry {
String name();
boolean isDirectory();
}
}
isExploded() 的默认实现是 getRootDirectory() != null——
"是不是目录"和"有没有根目录"被当成同一件事。
这个简化是合理的:jar 归档没有"根目录"(只有条目),目录归档才有。
最有意思的是 Archive.create() 这条链:
static Archive create(Class<?> target) throws Exception {
return create(target.getProtectionDomain());
}
static Archive create(ProtectionDomain protectionDomain) throws Exception {
CodeSource codeSource = protectionDomain.getCodeSource();
URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null;
if (location == null) {
throw new IllegalStateException("Unable to determine code source archive");
}
return create(Path.of(location).toFile());
}
static Archive create(File target) throws Exception {
if (!target.exists()) {
throw new IllegalStateException("Unable to determine code source archive from " + target);
}
return (target.isDirectory() ? new ExplodedArchive(target) : new JarFileArchive(target));
}
Class → ProtectionDomain → CodeSource → URI → File → Archive。
这就是 ExecutableArchiveLauncher 无参构造函数里的那一行:
public ExecutableArchiveLauncher() throws Exception {
this(Archive.create(Launcher.class));
}
注意它用的是 Launcher.class 而不是 getClass()。
JarLauncher 继承 ExecutableArchiveLauncher 时若用 getClass(),
得到的是 JarLauncher.class——两者在同一个 jar 里,结果一样。
但用 Launcher.class 更稳:因为 Launcher 一定在 loader 里,
而子类可能被用户继承后仍在同一 jar。这是一个"用基类锚定位置"的小技巧。
Path.of(location).toFile() 这一步值得注意:
CodeSource.getLocation() 返回的是 URL(通常是 file:/xxx),
转 URI 再 Path.of 再 toFile()。
这里有个隐含要求:code source 必须是 file: URL。
如果你的 jar 在一个 http: 或自定义协议的位置上,
Path.of 会抛 FileSystemNotFoundException。
实践中 java -jar 总是本地文件,所以不成问题——
但这也意味着 Spring Boot 的 fat jar 不能从网络直接启动。
2.3 ExecutableArchiveLauncher:manifest 驱动的启动
public abstract class ExecutableArchiveLauncher extends Launcher {
private static final String START_CLASS_ATTRIBUTE = "Start-Class";
private final Archive archive;
public ExecutableArchiveLauncher() throws Exception {
this(Archive.create(Launcher.class));
}
protected ExecutableArchiveLauncher(Archive archive) throws Exception {
this.archive = archive;
this.classPathIndex = getClassPathIndex(this.archive);
}
@Override
protected String getMainClass() throws Exception {
Manifest manifest = this.archive.getManifest();
String mainClass = (manifest != null) ? manifest.getMainAttributes().getValue(START_CLASS_ATTRIBUTE) : null;
if (mainClass == null) {
throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this);
}
return mainClass;
}
@Override
protected Set<URL> getClassPathUrls() throws Exception {
return this.archive.getClassPathUrls(this::isIncludedOnClassPathAndNotIndexed, this::isSearchedDirectory);
}
}
getMainClass() 的错误信息值得单独看:
throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this);
"in " + this —— this 是 launcher 实例,toString() 默认输出
org.springframework.boot.loader.launch.JarLauncher@1b6d3586。
这个错误信息其实没什么用(没说到底是哪个 jar 缺 Start-Class)。
这是一个可以改进的地方,我把它列进 §10 的观察项。
getClassPathUrls() 传了两个谓词:
protected boolean isIncludedOnClassPath(Archive.Entry entry) {
return isLibraryFileOrClassesDirectory(entry);
}
protected boolean isLibraryFileOrClassesDirectory(Archive.Entry entry) {
String name = entry.name();
if (entry.isDirectory()) {
return name.equals("BOOT-INF/classes/");
}
return name.startsWith("BOOT-INF/lib/");
}
protected boolean isSearchedDirectory(Archive.Entry entry) {
return ((getEntryPathPrefix() == null) || entry.name().startsWith(getEntryPathPrefix()))
&& !isIncludedOnClassPath(entry);
}
注意 name.equals("BOOT-INF/classes/") 用的是 equals 而不是 startsWith,
且带结尾斜杠。原因:BOOT-INF/classes/ 自己要进 classpath(作为 classes 目录),
但它的子目录不进。用 equals 精确匹配"就是这一层"。
而 BOOT-INF/lib/ 用 startsWith——因为要的是它下面的每个 jar。
isSearchedDirectory 是给 ExplodedArchive 用的剪枝条件:
遍历目录树时,只有"在 BOOT-INF/ 下 且 自己不是 classpath 项"的目录才继续往下走。
对 JarFileArchive 这个谓词完全没用(jar 是平铺的 stream,没有目录树可剪)。
2.4 Jar vs War:只差三个字符串
public class JarLauncher extends ExecutableArchiveLauncher {
public JarLauncher() throws Exception {
}
protected JarLauncher(Archive archive) throws Exception {
super(archive);
}
public static void main(String[] args) throws Exception {
new JarLauncher().launch(args);
}
}
JarLauncher 是空的——全部行为都在基类,getEntryPathPrefix() 和
isLibraryFileOrClassesDirectory() 都用默认值(BOOT-INF/)。
WarLauncher 才需要覆写:
public class WarLauncher extends ExecutableArchiveLauncher {
@Override
protected String getEntryPathPrefix() {
return "WEB-INF/";
}
@Override
protected boolean isLibraryFileOrClassesDirectory(Archive.Entry entry) {
String name = entry.name();
if (entry.isDirectory()) {
return name.equals("WEB-INF/classes/");
}
return name.startsWith("WEB-INF/lib/") || name.startsWith("WEB-INF/lib-provided/");
}
public static void main(String[] args) throws Exception {
new WarLauncher().launch(args);
}
}
多出来的 WEB-INF/lib-provided/ 是 WAR 的特有概念:
Maven 的 provided 作用域依赖——编译需要、运行时由容器提供。
但一个可执行 war(java -jar app.war)没有容器,所以要把
lib-provided 也加进 classpath。而打包侧(§8)会把
LibraryScope.PROVIDED 的库放到 WEB-INF/lib-provided/ 而不是 WEB-INF/lib/。
这就是 Spring Boot 的"可执行 war"能同时被 java -jar 和 外部 Tomcat 部署的原因:
java -jar:WarLauncher把lib和lib-provided都加进 classpath;- 外部 Tomcat:Servlet 容器只认
WEB-INF/lib/,lib-provided被忽略(容器自己有)。
**同一个归档,两种 classpath 视图。**这是"约定优于配置"的一个绝佳例子。
2.5 JarFileArchive:UNPACK 标记与临时目录
JarFileArchive 是"jar 形态"的 Archive 实现,它最核心的逻辑是把条目变成 URL:
@Override
public Set<URL> getClassPathUrls(Predicate<Entry> includeFilter, Predicate<Entry> directorySearchFilter)
throws IOException {
return this.jarFile.stream()
.map(JarArchiveEntry::new)
.filter(includeFilter)
.map(this::getNestedJarUrl)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
private URL getNestedJarUrl(JarArchiveEntry archiveEntry) {
try {
JarEntry jarEntry = archiveEntry.jarEntry();
String comment = jarEntry.getComment();
if (comment != null && comment.startsWith(UNPACK_MARKER)) {
return getUnpackedNestedJarUrl(jarEntry);
}
return JarUrl.create(this.file, jarEntry);
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
LinkedHashSet 而不是 HashSet:保持顺序。
classpath 的顺序在 Java 里是有语义的(先出现的优先)。
UNPACK_MARKER = "UNPACK":一个条目的 zip comment 是 UNPACK 开头时,
这个嵌套 jar 不能被当作嵌套 jar 读,必须解包到临时目录再用。
为什么要这样?因为嵌套 jar 必须 STORED(不压缩)才能被随机读取(§5.5)。
但有些库(比如包含 native .so 的库、或者某些 JDK 相关的库)
需要真实的文件系统路径才能工作——它们会调 File API、
或者 System.loadLibrary、或者用 MappedByteBuffer。
对这些库,Spring Boot 在构建期就把它们标记为需要解包。
解包的细节:
private URL getUnpackedNestedJarUrl(JarEntry jarEntry) throws IOException {
String name = jarEntry.getName();
if (name.lastIndexOf('/') != -1) {
name = name.substring(name.lastIndexOf('/') + 1);
}
Path path = getTempUnpackDirectory().resolve(name);
if (!Files.exists(path) || Files.size(path) != jarEntry.getSize()) {
unpack(jarEntry, path);
}
return path.toUri().toURL();
}
三个决策:
- 只取文件名,丢掉目录部分。
BOOT-INF/lib/foo.jar→foo.jar。 所以如果两个不同目录下有同名 jar,会互相覆盖—— 这是一个真实存在的限制(BOOT-INF/lib/下同名 jar 本来也不合法)。 - 用"大小"判断是否需要重新解包,而不是"存在即跳过"。 防止上一次运行留下了半个文件。 注意它没校验 CRC 或时间戳——只用大小,这是一个务实而非严谨的选择。
path.toUri().toURL()—— 解包后就是普通file:URL, 完全绕开了整套 nested 机制。
临时目录的创建:
private Path createUnpackDirectory(Path parent) {
int attempts = 0;
String fileName = Paths.get(this.jarFile.getName()).getFileName().toString();
while (attempts++ < 100) {
Path unpackDirectory = parent.resolve(fileName + "-spring-boot-libs-" + UUID.randomUUID());
try {
createDirectory(unpackDirectory);
return unpackDirectory;
}
catch (IOException ex) {
// Ignore
}
}
throw new IllegalStateException("Failed to create unpack directory in directory '" + parent + "'");
}
最多重试 100 次,每次用不同的 UUID。为什么是 100 而不是 1?
因为 Files.createDirectory 失败的原因通常是"已存在",
而 UUID 碰撞的概率极低——100 次是为了在极端情况(比如 tmp 目录被塞满、
或者有并发)下仍能成功。失败后的异常信息包含了 parent 目录,方便排查。
权限的处理很讲究:
private static final FileAttribute<?>[] DIRECTORY_PERMISSION_ATTRIBUTES = asFileAttributes(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE);
private static final FileAttribute<?>[] FILE_PERMISSION_ATTRIBUTES = asFileAttributes(
PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
private FileAttribute<?>[] getFileAttributes(Path path, FileAttribute<?>[] permissionAttributes) {
return (!supportsPosix(path.getFileSystem())) ? NO_FILE_ATTRIBUTES : permissionAttributes;
}
目录 rwx------(700),文件 rw-------(600)。
这是有意的安全加固:解包出来的 jar 可能被别的用户读取,
而它里面可能有敏感内容。supportsPosix 判断让 Windows 上不传这些属性(否则会抛异常)。
private void unpack(JarEntry entry, Path path) throws IOException {
createFile(path);
path.toFile().deleteOnExit();
try (InputStream in = this.jarFile.getInputStream(entry)) {
Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING);
}
}
deleteOnExit() —— 但注意目录本身没有 deleteOnExit,
只有文件有。所以每次运行都会在 /tmp 留下一个目录(里面文件被删了)。
这是一个小的资源泄漏,但在容器化环境里影响不大。
2.6 ExplodedArchive:目录形态的三个细节
class ExplodedArchive implements Archive {
private static final Object NO_MANIFEST = new Object();
private static final Set<String> SKIPPED_NAMES = Set.of(".", "..");
private static final Comparator<File> entryComparator = Comparator.comparing(File::getAbsolutePath);
...
@Override
public Set<URL> getClassPathUrls(Predicate<Entry> includeFilter, Predicate<Entry> directorySearchFilter)
throws IOException {
Set<URL> urls = new LinkedHashSet<>();
LinkedList<File> files = new LinkedList<>(listFiles(this.rootDirectory));
while (!files.isEmpty()) {
File file = files.poll();
if (SKIPPED_NAMES.contains(file.getName())) {
continue;
}
String entryName = file.toURI().getPath().substring(this.rootUriPath.length());
Entry entry = new FileArchiveEntry(entryName, file);
if (entry.isDirectory() && directorySearchFilter.test(entry)) {
files.addAll(0, listFiles(file));
}
if (includeFilter.test(entry)) {
urls.add(file.toURI().toURL());
}
}
return urls;
}
private List<File> listFiles(File file) {
File[] files = file.listFiles();
if (files == null) {
return Collections.emptyList();
}
Arrays.sort(files, entryComparator);
return Arrays.asList(files);
}
}
三点:
Arrays.sort(files, entryComparator):按绝对路径排序。File.listFiles()的返回顺序在 JDK 里是未定义的(依赖文件系统实现)。 如果不排序,同一台机器上两次启动的 classpath 顺序可能不同—— 这在排查"为什么这次启动报 NoSuchMethodError 而上次没有"时是灾难。 显式排序 = 确定性,这是第 11 定律(TreeMap/Comparable= 构建确定性)的又一个实例。files.addAll(0, ...)头插:这是 DFS 而不是 BFS。 子目录被插到队首,下一个就遍历它们。结合排序,得到的是确定的深度优先序。NO_MANIFEST哨兵对象:区分"还没加载过 manifest"和"加载过,结果是 null"。private volatile Object manifest;的初始值是null, 如果直接用null表示"没有 manifest",每次getManifest()都会重新读文件。 用一个静态哨兵对象 +instanceof判断,是"缓存 null 结果"的标准手法。
3. 嵌套 jar 的 URL:jar:nested: 协议
上一节末尾 JarUrl.create(this.file, jarEntry) 是整套机制的转折点。
从这一行开始,"jar 里的 jar"才真正变成 JVM 能消费的东西。
3.1 为什么标准的 jar:file: 不够用
JDK 的 jar URL 语法是:
jar:<外层 jar 的 URL>!/<内部条目名>
例如 jar:file:/opt/app.jar!/BOOT-INF/lib/foo.jar。
看起来能表达"app.jar 里的 foo.jar",但问题是——
!/ 后面是"条目名",不是"另一个 jar"。
JDK 的实现里,!/ 之后的部分只会被当作"要读取的字节",
它不会再被当作一个 zip 去打开。JDK 明确不支持 jar:jar:file:...!/a.jar!/b.class
这种"两层 jar"的 URL——这一点在 Spring Boot 自己的代码里也被断言了(见 §3.5)。
所以 Spring Boot 需要自己造一层协议来表达"这个 URL 指向的 jar 本身是另一个文件里的条目"。
3.2 JarUrl.create:URL 的真实形状
public static URL create(File file, String nestedEntryName, String path) {
try {
path = (path != null) ? path : "";
return new URL(null, "jar:" + getJarReference(file, nestedEntryName) + "!/" + path, Handler.INSTANCE);
}
catch (MalformedURLException ex) {
throw new IllegalStateException("Unable to create JarFileArchive URL", ex);
}
}
private static String getJarReference(File file, String nestedEntryName) {
String jarFilePath = file.toURI().getRawPath().replace("!", "%21");
return (nestedEntryName != null) ? "nested:" + jarFilePath + "/!" + nestedEntryName : "file:" + jarFilePath;
}
把 file = /opt/app.jar、nestedEntryName = BOOT-INF/lib/foo.jar、path = "" 代进去:
jar:nested:/opt/app.jar/!BOOT-INF/lib/foo.jar!/
对比普通 jar URL:
jar:file:/opt/foo.jar!/
四个字符的差异,含义完全不同:
| 部分 | 普通 | Spring Boot 嵌套 |
|---|---|---|
| 外层协议 | file: | nested: |
| 外层定位 | /opt/foo.jar | /opt/app.jar/!BOOT-INF/lib/foo.jar |
| 分隔符 | !(紧跟路径) | /!(路径与嵌套条目名之间) |
| 内层分隔 | !/ | !/ |
注意 /! 与 !/ 这两个方向相反的分隔符:
- 第一个
/!分隔"外层 jar 路径"与"嵌套条目名"; - 第二个
!/分隔"嵌套 jar"与"它里面的条目"。
两处细节:
replace("!", "%21"):外层 jar 的文件名里可能含!(Linux 下完全合法,比如/opt/my!app.jar)。 如果不转义,解析时会把文件名的!误当成分隔符。 转义成%21后,解析器按 URL 解码规则还原。 "转义输入以避免与语法分隔符冲突"是所有解析器都要做的事, 这里做得很干净:只在构造时转义,解析时由UrlDecoder还原。new URL(null, spec, Handler.INSTANCE):第一个参数是null协议, 第三个参数直接传入 handler 实例。 这样就不依赖java.protocol.handler.pkgs查找——JarUrl.create明确知道自己要用的是 Spring Boot 的 jar handler。 (Handlers.register()仍然要调,因为后来由 JDK 内部构造的 URL—— 比如URLClassLoader用new URL(base, "./foo.class")派生出来的—— 需要能被 JDK 自己找到 handler。)
3.3 Handlers.register:一次 JDK 级别的"劫持"
public final class Handlers {
private static final String PROTOCOL_HANDLER_PACKAGES = "java.protocol.handler.pkgs";
private static final String PACKAGE = Handlers.class.getPackageName();
public static void register() {
String packages = System.getProperty(PROTOCOL_HANDLER_PACKAGES, "");
packages = (!packages.isEmpty() && !packages.contains(PACKAGE)) ? packages + "|" + PACKAGE : PACKAGE;
System.setProperty(PROTOCOL_HANDLER_PACKAGES, packages);
resetCachedUrlHandlers();
}
private static void resetCachedUrlHandlers() {
try {
URL.setURLStreamHandlerFactory(null);
}
catch (Error ex) {
// Ignore
}
}
}
这段代码在做一件 JDK 并不鼓励、但确实可行的事:把自己注册为 jar 协议的 handler。
第 1 步:追加系统属性。 java.protocol.handler.pkgs 是 JDK 标准扩展点:
需要协议 xxx 的 handler 时,JDK 依次尝试 <pkg>.xxx.Handler,
<pkg> 来自这个以 | 分隔的属性。所以 jar 会找到
org.springframework.boot.loader.net.protocol.jar.Handler,
nested 会找到 ...nested.Handler。
!packages.contains(PACKAGE) 防止重复追加。
第 2 步:URL.setURLStreamHandlerFactory(null)——一个精心设计的 hack。
注释写得很坦白:"We reset the handler by trying to set a null
URLStreamHandlerFactory which should have no effect other than
clearing the handlers cache"。
问题在于 JDK 的 URL 类内部有静态 handler 缓存:
若 Handlers.register() 之前就用过 jar: URL,缓存里是 JDK 默认 handler,
之后再改系统属性也不生效。而 setURLStreamHandlerFactory 的实现是
先 handlers.clear() 再赋值,且 factory != null 时抛 Error。传 null 时:
- 之前没设过 factory → 不抛错,缓存被清空;
- 之前设过(比如应用服务器)→ 抛
Error,被catch (Error ex)忽略。
成功时白赚一次缓存清空,失败时什么都不损失。 代价是依赖 JDK 的这个实现细节(列 §10 的静默失效风险)。
3.4 URLStreamHandler 的三条命名约定
Spring Boot 在两个 handler 类上都写了同样的注释:
public class Handler extends URLStreamHandler {
// NOTE: in order to be found as a URL protocol handler, this class must be public,
// must be named Handler and must be in a package ending '.nested'
三条约定缺一不可:
public—— JDK 用反射newInstance创建它;- 类名必须是
Handler—— 不是NestedHandler,就是Handler; - 包名必须以协议名结尾 ——
.nested/.jar。
这三条决定了包名必须长这样:org.springframework.boot.loader.net.protocol.nested。
所以 Handlers.PACKAGE 是 ...net.protocol(不含协议名),
JDK 会自己拼上 .jar.Handler / .nested.Handler。
这是一个"约定优于配置"走到极致的地方:你无法通过注解或接口声明 "我是 nested 协议的 handler",只能通过类名和包名。 好处是零配置、零反射开销;坏处是重构工具改名就会静默失效, 所以 Spring Boot 在类上写了那行注释——把约定固化在代码里。
3.5 jar Handler 覆写的四个方法
Spring Boot 覆写的是 JDK 的 jar 协议,所以要尽可能保持行为兼容。
它覆写了四个方法:
(1)parseURL —— 明确禁止嵌套 jar URL
@Override
protected void parseURL(URL url, String spec, int start, int limit) {
if (spec.regionMatches(true, start, "jar:", 0, 4)) {
throw new IllegalStateException("Nested JAR URLs are not supported");
}
...
}
第一件事就是拒绝 jar:jar:...。
这不是"我们做不到",而是"JDK 的语法不支持,我们也不打算支持"——
Spring Boot 用 jar:nested:... 达到了同样的目的,不需要 jar:jar:。
regionMatches(true, ...) 的 true 表示忽略大小写。
所以 JAR: / Jar: 都会被拒。
parseURL 剩下的工作是解析相对路径与锚点。
关键在于 Canonicalizer.canonicalizeAfter(path, indexOfSeparator(path) + 1)——
只在 !/ 之后做路径规范化。左边 nested:/opt/app.jar/!BOOT-INF/lib/foo.jar
是"jar 的定位",不能规范化(会破坏 /!);
右边 com/example/Foo.class 是"jar 内的路径",需要处理 .. 和 .。
(2)indexOfSeparator —— 从后往前找
static int indexOfSeparator(String spec, int start, int limit) {
for (int i = limit - 1; i >= start; i--) {
if (spec.charAt(i) == '!' && (i + 1) < limit && spec.charAt(i + 1) == '/') {
return i;
}
}
return -1;
}
倒着找 !/。用意是取最后一个 !/ 作为"jar 与条目的分界",
这与 NestedLocation 的 lastIndexOf("/!") 是同一个思路:
最靠后的那个分隔符才是真正的分界。
(我没有构造出一个正序会出错的真实 URL,这是从代码对称性上的推断,列进 §10。)
(3)hashCode(URL) —— 分段求哈希
@Override
protected int hashCode(URL url) {
String protocol = url.getProtocol();
int hash = (protocol != null) ? protocol.hashCode() : 0;
String file = url.getFile();
int indexOfSeparator = file.indexOf(SEPARATOR);
if (indexOfSeparator == -1) {
return hash + file.hashCode();
}
String fileWithoutEntry = file.substring(0, indexOfSeparator);
try {
hash += new URL(fileWithoutEntry).hashCode();
}
catch (MalformedURLException ex) {
hash += fileWithoutEntry.hashCode();
}
String entry = file.substring(indexOfSeparator + 2);
return hash + entry.hashCode();
}
为什么不直接 file.hashCode()?因为 file 含外层 jar 路径,
同一个内层 jar 可能有多种等价写法(%21 与 !、相对与绝对路径)。
拆成两段后:外层用 new URL(...).hashCode()(JDK 的 URL 哈希会做规范化),
条目名用字符串哈希——让等价的 URL 得到相等的哈希。
catch (MalformedURLException) 兜底用字符串哈希:宁可不精确,也不能抛异常。
(4)sameFile —— 分段比较
@Override
protected boolean sameFile(URL url1, URL url2) {
if (!url1.getProtocol().equals(PROTOCOL) || !url2.getProtocol().equals(PROTOCOL)) {
return false;
}
...
String entry1 = file1.substring(indexOfSeparator1 + 2);
String entry2 = file2.substring(indexOfSeparator2 + 2);
if (!entry1.equals(entry2)) {
return false;
}
try {
URL innerUrl1 = new URL(file1.substring(0, indexOfSeparator1));
URL innerUrl2 = new URL(file2.substring(0, indexOfSeparator2));
if (!super.sameFile(innerUrl1, innerUrl2)) {
return false;
}
}
catch (MalformedURLException unused) {
return super.sameFile(url1, url2);
}
return true;
}
与 hashCode 对称:先比条目名(快速失败),再比外层 URL。
URLClassLoader 和 JarFile 的缓存大量依赖 equals/hashCode,
这两个方法决定了"两个指向同一个嵌套 jar 的 URL 会不会被当成同一个东西"。
3.6 nested Handler 与 NestedLocation
public class Handler extends URLStreamHandler {
private static final String PREFIX = "nested:";
@Override
protected InetAddress getHostAddress(URL url) {
// Some Windows users have reported that calls to java.net.URL.getHostAddress()
// can be slow. Since we only deal with local files we always return null here.
return null;
}
@Override
protected URLConnection openConnection(URL url) throws IOException {
return new NestedUrlConnection(url);
}
...
}
getHostAddress 直接返回 null 并附上了原因:
有 Windows 用户报告 getHostAddress() 很慢(它内部可能触发 DNS 反查)。
因为 nested: 只用于本地文件,永远不会需要 host。
这是一个"用领域知识剪掉通用逻辑"的优化,
注释把"为什么敢这么写"说清楚了。
NestedLocation 是解析的核心:
public record NestedLocation(Path path, String nestedEntryName) {
private static final Map<String, NestedLocation> locationCache = new ConcurrentHashMap<>();
private static final Map<String, Path> pathCache = new ConcurrentHashMap<>();
public static NestedLocation fromUrl(URL url) {
if (url == null || !"nested".equalsIgnoreCase(url.getProtocol())) {
throw new IllegalArgumentException("'url' must not be null and must use 'nested' protocol");
}
return parse(UrlDecoder.decode(url.toString().substring(7)));
}
static NestedLocation parse(String location) {
if (location == null || location.isEmpty()) {
throw new IllegalArgumentException("'location' must not be empty");
}
return locationCache.computeIfAbsent(location, (key) -> create(location));
}
private static NestedLocation create(String location) {
int index = location.lastIndexOf("/!");
String locationPath = (index != -1) ? location.substring(0, index) : location;
String nestedEntryName = (index != -1) ? location.substring(index + 2) : null;
return new NestedLocation((!locationPath.isEmpty()) ? asPath(locationPath) : null, nestedEntryName);
}
...
}
几个点:
url.toString().substring(7)—— 硬编码 7 是"nested:".length()。 (用PREFIX.length()会更稳,但这是包内私有的解析路径,影响不大。)UrlDecoder.decode(...)—— 还原 §3.2 里转义的%21等。lastIndexOf("/!")—— 与 §3.5 的indexOfSeparator倒序一致。- 两个
ConcurrentHashMap缓存:locationCache(完整 location → NestedLocation) 和pathCache(路径字符串 → Path)。computeIfAbsent保证同一个 location 只解析一次。 为什么需要缓存?因为URLClassLoader每次找资源都会重新解析 same URL, 而Path.of(...)和字符串切分开销不小。
Windows 路径修正是这段代码的精华:
private static String fixWindowsLocationPath(String locationPath) {
// Same logic as Java's internal WindowsUriSupport class
if (locationPath.length() > 2 && locationPath.charAt(2) == ':') {
return locationPath.substring(1);
}
// Deal with Jetty's org.eclipse.jetty.util.URIUtil#correctURI(URI)
if (locationPath.startsWith("///") && locationPath.charAt(4) == ':') {
return locationPath.substring(3);
}
return locationPath;
}
- 第一种:
/C:/opt/app.jar→ 去掉前导/得C:/opt/app.jar。 因为 URI 规范里file:/C:/...的盘符前面有个斜杠,而Path.of不认。 注释说"Same logic as Java's internal WindowsUriSupport class"——对齐 JDK 内部行为。 - 第二种:
///C:/opt/app.jar→ 去掉三个斜杠得C:/...。 注释点名了 Jetty 的URIUtil.correctURI(URI)会产生这种形式。 这是一条"兼容第三方库怪异输出"的代码,注释把来源写清楚了—— 这正是第 26 章"第六十定律:把规范链接写进代码"的同类做法。
3.7 NestedUrlConnection:把自己伪装成 HTTP 响应
nested: 是一个"假协议",但 JDK 的 URLConnection 契约要求它像回事。
所以 NestedUrlConnection 实现了一整套 HTTP 风格的头:
@Override
public Map<String, List<String>> getHeaderFields() {
try {
connect();
}
catch (IOException ex) {
return Collections.emptyMap();
}
Map<String, List<String>> headerFields = this.headerFields;
if (headerFields == null) {
headerFields = new LinkedHashMap<>();
long contentLength = getContentLengthLong();
long lastModified = getLastModified();
if (contentLength > 0) {
headerFields.put("content-length", List.of(String.valueOf(contentLength)));
}
if (getLastModified() > 0) {
headerFields.put("last-modified",
List.of(RFC_1123_DATE_TIME.format(Instant.ofEpochMilli(lastModified))));
}
headerFields = Collections.unmodifiableMap(headerFields);
this.headerFields = headerFields;
}
return headerFields;
}
@Override
public String getContentType() {
return CONTENT_TYPE; // "x-java/jar"
}
@Override
public Permission getPermission() throws IOException {
if (this.permission == null) {
File file = this.resources.getLocation().path().toFile();
this.permission = new FilePermission(file.getCanonicalPath(), "read");
}
return this.permission;
}
content-length/last-modified从文件真实属性算出, 后者用DateTimeFormatter.RFC_1123_DATE_TIME.withZone(GMT)格式化—— 消费方(如JarURLConnection)按 HTTP 约定解析;content-type是x-java/jar,与 JDK 的 jar URL 一致;getPermission()返回FilePermission(path, "read"): 开了SecurityManager时也能正确授权——它本质上就是读本地文件。
资源释放用了 Cleaner:
NestedUrlConnection(URL url, Cleaner cleaner) throws MalformedURLException {
super(url);
NestedLocation location = parseNestedLocation(url);
this.resources = new NestedUrlConnectionResources(location);
this.cleanup = cleaner.register(this, this.resources);
}
以及在流的 close() 里主动清理:
class ConnectionInputStream extends FilterInputStream {
private volatile boolean closing;
@Override
public void close() throws IOException {
if (this.closing) {
return;
}
this.closing = true;
try {
super.close();
}
finally {
try {
NestedUrlConnection.this.cleanup.clean();
}
catch (UncheckedIOException ex) {
throw ex.getCause();
}
}
}
}
双重保险:Cleaner 兜底(GC 时)+"流关掉就立刻释放"(确定性)。
this.closing 的 volatile 防重入——close() 被调用两次是常态。
3.8 Optimizations:一个 ThreadLocal 开关,把"读两次"变"读一次"
final class Optimizations {
private static final ThreadLocal<Boolean> status = new ThreadLocal<>();
static void enable(boolean readContents) {
status.set(readContents);
}
static void disable() {
status.remove();
}
static boolean isEnabled() {
return status.get() != null;
}
static boolean isEnabled(boolean readContents) {
return Boolean.valueOf(readContents).equals(status.get());
}
}
一个只有 20 行的类,但它解决的是一个真实的性能问题。
URLClassLoader 查找资源的经典流程是"先 getInputStream() 探一下在不在,
成功后再真正读内容"——同一个资源被读了两遍。
对 BOOT-INF/lib/ 下几十个 jar、每次遍历数千个类的启动过程,
这个浪费是数量级的。
Spring Boot 的做法是在调用方打个标记:
// JarUrlClassLoader.findResource
Optimizations.enable(false); // ← 我只要知道"在不在",不读内容
try {
return super.findResource(name);
}
finally {
Optimizations.disable();
}
// JarUrlClassLoader.loadClass
Optimizations.enable(true); // ← 我要真内容
然后 JarUrlConnection 据此短路:
@Override
public InputStream getInputStream() throws IOException {
...
if (!getUseCaches() && Optimizations.isEnabled(false) && this.entryName != null) {
JarFile cached = jarFiles.getCached(jarFileURL);
if (cached != null) {
if (cached.getEntry(this.entryName) != null) {
return emptyInputStream; // ← 直接返回空流
}
}
}
connect();
...
}
Optimizations.isEnabled(false) 表示"当前处于『不读内容』模式"。
此时如果 jar 已缓存、且条目存在,就返回一个空流——
调用方(只关心"存在吗")拿到非空流就满意了。
第二个优化在异常路径上:
private void throwFileNotFound() throws FileNotFoundException {
if (Optimizations.isEnabled()) {
throw FILE_NOT_FOUND_EXCEPTION; // ← 静态共享的异常实例
}
...
throw new FileNotFoundException("JAR entry " + this.entryName + " not found in " + this.jarFile.getName());
}
URLClassLoader 遍历所有 URL 找资源时,绝大多数 URL 是"找不到"的。
每次 new FileNotFoundException(...) 都意味着 fillInStackTrace()
(JVM 里最贵的操作之一,要遍历整个栈)。
用一个预先构造好的静态异常实例抛出,省掉全部开销
(代价是没有栈追踪——但调用方本来也不会打印它)。
同理 notFoundConnection() 在优化模式下返回静态的 NOT_FOUND_CONNECTION。
两个优化的共同点:先识别调用方的真实意图("要存在性"还是"要内容"), 再据此短路。传递意图的方式是
ThreadLocal—— 因为调用链(URLClassLoader→URL→URLConnection)没有参数可传。ThreadLocal在这里是"跨层传递上下文"的无奈之举, 但作用域被try/finally严格限制,用完一定remove()。
4. 类加载:LaunchedClassLoader 与 JarUrlClassLoader
4.1 继承链与 hasJarUrls 早退
URLClassLoader
└── JarUrlClassLoader (抽象,处理 jar URL 的优化)
└── LaunchedClassLoader (处理 exploded / jar mode / package 定义)
注意 JarUrlClassLoader 是 abstract,且它的 Javadoc 写着
"Create a new LaunchedClassLoader instance"——
这是从 LaunchedClassLoader 复制构造参数注释时留下的痕迹(我列进 §10)。
public JarUrlClassLoader(URL[] urls, ClassLoader parent) {
super(urls, parent);
this.urls = urls;
this.hasJarUrls = Arrays.stream(urls).anyMatch(this::isJarUrl);
}
@Override
public URL findResource(String name) {
if (!this.hasJarUrls) {
return super.findResource(name);
}
Optimizations.enable(false);
try {
return super.findResource(name);
}
finally {
Optimizations.disable();
}
}
hasJarUrls 在构造时算一次。
如果 classpath 里一个 jar URL 都没有(纯 exploded 模式,全是 file: URL),
三个覆写方法全部退化成 super.xxx()——零开销。
这与 §2.1 里 if (!isExploded()) Handlers.register(); 是同一个思路:
在最早的地方判断"我需不需要这套机制",不需要就完全绕开。
4.2 jar mode 包:唯一一处"反父委托"
private static final String JAR_MODE_PACKAGE_PREFIX = "org.springframework.boot.loader.jarmode.";
private static final String JAR_MODE_RUNNER_CLASS_NAME = JarModeRunner.class.getName();
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (name.startsWith(JAR_MODE_PACKAGE_PREFIX) || name.equals(JAR_MODE_RUNNER_CLASS_NAME)) {
try {
Class<?> result = loadClassInLaunchedClassLoader(name);
if (resolve) {
resolveClass(result);
}
return result;
}
catch (ClassNotFoundException ex) {
// Ignore
}
}
return super.loadClass(name, resolve);
}
private Class<?> loadClassInLaunchedClassLoader(String name) throws ClassNotFoundException {
try {
String internalName = name.replace('.', '/') + ".class";
try (InputStream inputStream = getParent().getResourceAsStream(internalName);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
if (inputStream == null) {
throw new ClassNotFoundException(name);
}
inputStream.transferTo(outputStream);
byte[] bytes = outputStream.toByteArray();
Class<?> definedClass = defineClass(name, bytes, 0, bytes.length);
definePackageIfNecessary(name);
return definedClass;
}
}
catch (IOException ex) {
throw new ClassNotFoundException("Cannot load resource for class [" + name + "]", ex);
}
}
这是全模块唯一一处打破双亲委派的地方。
为什么必须打破?场景是这样的:
java -Djarmode=tools -jar app.jar
此时 Launcher.launch() 会去加载 JarModeRunner(在 loader 里,父加载器可见),
而 JarModeRunner 要用 SpringFactoriesLoader 加载 ToolsJarMode。
ToolsJarMode 在 spring-boot-jarmode-tools 里,
它是用户的 fat jar 的一部分(在 BOOT-INF/lib/ 下),父加载器看不到。
但问题在于:LaunchedClassLoader 的类加载顺序是
"父优先"(super.loadClass),如果 org.springframework.boot.loader.jarmode.*
这个类恰好也能被父加载器找到(比如从系统 classpath),
就会加载到一个与 fat jar 里版本不同的类。
所以这里强制:jar mode 相关的类,一律在本 classloader 里重新定义。
loadClassInLaunchedClassLoader 的实现细节:
getParent().getResourceAsStream(internalName)—— 从父加载器读字节;defineClass(name, bytes, 0, bytes.length)—— 在自己这里定义。
也就是"用父加载器的字节,在我这里生成 Class 对象"。
结果是这些类由 LaunchedClassLoader 定义,
它们的 getClassLoader() 返回 LaunchedClassLoader,
而不是系统类加载器。这让它们能看到 BOOT-INF/lib/。
catch (ClassNotFoundException ex) { // Ignore } ——
如果自己定义不了(父加载器里也没有),就退回 super.loadClass。
这是一个"先试特殊路径,失败则回退"的安全网。
4.3 definePackageIfNecessary:让嵌套 jar 的 manifest 生效
protected final void definePackageIfNecessary(String className) {
if (className.startsWith("java.")) {
return;
}
int lastDot = className.lastIndexOf('.');
if (lastDot >= 0) {
String packageName = className.substring(0, lastDot);
if (getDefinedPackage(packageName) == null) {
try {
definePackage(className, packageName);
}
catch (IllegalArgumentException ex) {
tolerateRaceConditionDueToBeingParallelCapable(ex, packageName);
}
}
}
}
private void definePackage(String className, String packageName) {
if (this.undefinablePackages.contains(packageName)) {
return;
}
String packageEntryName = packageName.replace('.', '/') + "/";
String classEntryName = className.replace('.', '/') + ".class";
for (URL url : this.urls) {
try {
JarFile jarFile = getJarFile(url);
if (jarFile != null) {
if (hasEntry(jarFile, classEntryName) && hasEntry(jarFile, packageEntryName)
&& jarFile.getManifest() != null) {
definePackage(packageName, jarFile.getManifest(), url);
return;
}
}
}
catch (IOException ex) {
// Ignore
}
}
this.undefinablePackages.add(packageName);
}
为什么需要这个?因为 Package 对象携带
Implementation-Title / Implementation-Version / Specification-Version
等信息,这些信息来自 jar 的 MANIFEST.MF。
JDK 的 URLClassLoader 只在"从这个 jar 里定义类"的时候才顺带定义 package,
而 Spring Boot 的嵌套 jar 走的是自定义路径,
如果不主动 definePackage,SomeClass.class.getPackage().getImplementationVersion()
会返回 null——很多库(尤其是排查版本冲突时)依赖它。
三个条件必须同时满足才定义:
hasEntry(jarFile, classEntryName)—— 这个类在这个 jar 里;hasEntry(jarFile, packageEntryName)—— 这个 jar 里有对应的包目录条目;jarFile.getManifest() != null—— 有 manifest 可读取属性。
第 2 个条件最容易被忽略:不是所有 jar 都包含目录条目
(jar 命令默认会写,某些构建工具不会)。
没有 com/example/ 条目,package 就不会被定义。
className.startsWith("java.") 的早退:JDK 的核心包由 bootstrap 加载器定义,
不能也不需要在应用层定义。
4.4 parallel capable 与"容忍竞态"
static {
ClassLoader.registerAsParallelCapable();
}
(LaunchedClassLoader 和 JarUrlClassLoader 都有这个静态块。)
registerAsParallelCapable() 告诉 JVM:
"我这个类加载器支持并行加载,你可以用 finer-grained 锁"。
没有它,loadClass 会对整个 classloader 加锁,多线程加载类时会串行化。
但并行带来竞态:两个线程同时为同一个包调 definePackage,
一个成功,另一个抛 IllegalArgumentException("package already defined")。
private void tolerateRaceConditionDueToBeingParallelCapable(IllegalArgumentException ex, String packageName)
throws AssertionError {
if (getDefinedPackage(packageName) == null) {
// This should never happen as the IllegalArgumentException indicates that the
// package has already been defined and, therefore, getDefinedPackage(name)
// should not have returned null.
throw new AssertionError(
"Package %s has already been defined but it could not be found".formatted(packageName), ex);
}
}
"容忍"的逻辑:拿到 IllegalArgumentException 后,
再查一次 getDefinedPackage(packageName):
- 不为 null → 说明是正常的并发(别人已经定义了),吞掉异常;
- 为 null → 说明有别的問題(异常说"已定义",但查不到),抛
AssertionError。
这是一个"用一次重试区分『预期竞态』与『真正错误』"的模式。
AssertionError 而不是 IllegalStateException,
因为作者认为这是"不该发生"的情况——用最刺眼的异常类型表达"这是 bug"。
注意这个方法的返回类型是 void 但声明 throws AssertionError——
它在被调用处是 catch (...) { tolerateRaceConditionDueToBeingParallelCapable(ex, name); },
方法内部决定"吞掉还是抛出"。
4.5 getJarFile:一个带引用计数语义的缓存
private JarFile getJarFile(URL url) throws IOException {
JarFile jarFile = this.jarFiles.get(url);
if (jarFile != null) {
return jarFile;
}
URLConnection connection = url.openConnection();
if (!(connection instanceof JarURLConnection)) {
return null;
}
connection.setUseCaches(false);
jarFile = ((JarURLConnection) connection).getJarFile();
synchronized (this.jarFiles) {
JarFile previous = this.jarFiles.putIfAbsent(url, jarFile);
if (previous != null) {
jarFile.close();
jarFile = previous;
}
}
return jarFile;
}
标准的"双检查 + putIfAbsent + 关闭多余实例":
- 先无锁读(快路径);
- 未命中则创建;
synchronized下putIfAbsent;- 如果别人已经放了,把自己刚创建的关掉,用别人的。
最后一步很关键:不关的话,两个线程各自持有一个 JarFile,
而其中一个从此无人引用——资源泄漏 + 文件句柄泄漏。
connection.setUseCaches(false) —— 不用 URLConnection 的缓存,
因为我们自己管(this.jarFiles)。用两层缓存会导致
clearCache() 清不干净。
4.6 clearCache 与 ClearCachesApplicationListener:一次反射式的跨模块桥
/**
* Clear any caches. This method is called reflectively by
* {@code ClearCachesApplicationListener}.
*/
public void clearCache() {
Handler.clearCache();
org.springframework.boot.loader.net.protocol.nested.Handler.clearCache();
try {
clearJarFiles();
}
catch (IOException ex) {
// Ignore
}
for (URL url : this.urls) {
if (isJarUrl(url)) {
clearCache(url);
}
}
}
注释直接点名了调用方。去看 core/spring-boot 里那个监听器:
class ClearCachesApplicationListener implements ApplicationListener<ContextRefreshedEvent> {
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
ReflectionUtils.clearCache();
clearClassLoaderCaches(Thread.currentThread().getContextClassLoader());
}
private void clearClassLoaderCaches(@Nullable ClassLoader classLoader) {
if (classLoader == null) {
return;
}
try {
Method clearCacheMethod = classLoader.getClass().getDeclaredMethod("clearCache");
clearCacheMethod.invoke(classLoader);
}
catch (Exception ex) {
// Ignore
}
clearClassLoaderCaches(classLoader.getParent());
}
}
core 模块不依赖 loader 模块(loader 是可选的:
你完全可以用 java -cp 或 war 部署的方式跑 Spring Boot,那时没有 LaunchedClassLoader)。
所以 core 不能写 if (classLoader instanceof JarUrlClassLoader)。
解法是反射 + 按名查找:getDeclaredMethod("clearCache")。
找到就调,找不到(或调用失败)就 catch (Exception ex) { // Ignore }。
然后沿 classloader 链向上递归——
因为 LaunchedClassLoader 可能在链上的任意一层。
这构成一个"鸭子类型"的约定:任何实现了无参 clearCache() 方法的 ClassLoader
都会被 Spring Boot 清理。这个约定没有接口、没有注解,只存在于
JarUrlClassLoader.clearCache() 上方的那行注释里。
为什么要清理? 启动完成后,zip 的中央目录索引、jar 文件句柄、
NestedLocation 缓存等已经不再需要(所有类都加载完了)。
它们占着内存(Javadoc 说典型 fat jar 有 ~10,500 个条目、约 122K)
和文件句柄。清理掉可以让运行时内存更干净。
时序选在 ContextRefreshedEvent——容器刷新完成,
但还没到 ApplicationReadyEvent。
也就是说"所有该加载的类都加载完了"这个判断是准确的。
4.7 undefinablePackages:负缓存
private final Set<String> undefinablePackages = ConcurrentHashMap.newKeySet();
definePackage 遍历所有 URL 都没找到时,把包名加进这个集合:
this.undefinablePackages.add(packageName);
下次再遇到这个包,第一行就 return:
if (this.undefinablePackages.contains(packageName)) {
return;
}
这是负缓存(缓存"失败"的结果)。
必要性:一个应用有几千个包,每次 definePackageIfNecessary
都要遍历几十个 URL × 几次 I/O,开销可观。
而"这个包定义不了"是一个稳定结论(classpath 不变,结论就不变)。
ConcurrentHashMap.newKeySet() —— 并发安全的 Set,
配合 registerAsParallelCapable 使用。
5. zip 包:从字节到条目索引
zip/ 是 loader 里最底层的包,16 个类、约 1,800 行。
它完全不知道 jar 是什么——不认识 MANIFEST.MF、不认识多版本 jar、不认识签名。
它只做一件事:把一段字节解析成"名字 → 内容位置"的索引。
5.1 为什么不能直接用 java.util.zip.ZipFile
ZipContent 的类注释给出了答案:
/**
* Provides raw access to content from a regular or nested zip file. This class performs
* the low level parsing of a zip file and provide access to raw entry data that it
* contains. Unlike {@link java.util.zip.ZipFile}, this implementation can load content
* from a zip file nested inside another file as long as the entry is not compressed.
* <p>
* In order to reduce memory consumption, this implementation stores only the hash of the
* entry names, the central directory offsets and the original positions. Entries are
* stored internally in {@code hashCode} order so that a binary search can be used to
* quickly find an entry by name or determine if the zip file doesn't have a given entry.
* <p>
* {@link ZipContent} for a typical Spring Boot application JAR will have somewhere in the
* region of 10,500 entries which should consume about 122K.
* <p>
* {@link ZipContent} results are cached and it is assumed that zip content will not
* change once loaded. Entries and Strings are not cached and will be recreated on each
* access which may produce a lot of garbage.
* <p>
* This implementation does not use {@link Cleanable} so care must be taken to release
* {@link ZipContent} resources. The {@link #close()} method should be called explicitly
* or by try-with-resources. Care must be take to only call close once.
*/
四层信息,每一层都是设计决策:
- 能读嵌套 zip,但要求不压缩(STORED)——
java.util.zip.ZipFile做不到; - 只存哈希 + 偏移量 + 位置,不存名字字符串——省内存;
- 典型 fat jar 约 10,500 个条目,占约 122K——实测数据,也是设计目标;
- 结果被缓存、条目与字符串不被缓存、不用 Cleanable——三条生命周期声明。
第 2 点最反直觉:它不保存条目名。
每次 getEntry("com/example/Foo.class") 都要重新去字节里读名字比对。
代价是"每次访问产生垃圾",收益是省掉 10,500 个 String 对象(每个 String 至少 40+ 字节 + char 数组,
10,500 个就是 1MB 以上,而 ZipContent 只要 122K)。
这是一个明确的取舍:用"重复计算"换"常驻内存"。
启动阶段(大量查找)会慢一点、产生垃圾,
启动完成后(类都加载完了)内存很干净——尤其配合 §4.6 的 clearCache()。
5.2 三个 Kind
public enum Kind {
/**
* Content from a standard zip file.
*/
ZIP,
/**
* Content from nested zip content.
*/
NESTED_ZIP,
/**
* Content from a nested zip directory.
*/
NESTED_DIRECTORY
}
ZIP:/opt/foo.jar这种普通文件;NESTED_ZIP:app.jar里的BOOT-INF/lib/foo.jar(一个真实的 zip 条目);NESTED_DIRECTORY:app.jar里的BOOT-INF/classes/(一个目录条目, 它下面的所有条目被当成一个虚拟 zip)。
第三种最容易忽略:它服务于 exploded 场景与某些打包布局——
BOOT-INF/classes/ 下的 .class 并不是 zip,
但 ZipContent 能对它们建立索引,让上层用同一套 API 访问。
加载 NESTED_DIRECTORY 要求名字以 / 结尾:
private static ZipContent loadNestedDirectory(Source source, ZipContent zip, Entry directoryEntry)
throws IOException {
if (!source.nestedEntryName().endsWith("/")) {
throw new IllegalArgumentException("Nested entry name must end with '/'");
}
...
}
5.3 只存哈希:三个数组与一个反向索引
ZipContent 的核心字段:
private final long centralDirectoryPos;
private final int[] lookupIndexes;
private final int[] nameHashLookups;
private final int[] relativeCentralDirectoryOffsetLookups;
private final NameOffsetLookups nameOffsetLookups;
nameHashLookups[]:每个条目名的哈希,已排序,用于二分查找;relativeCentralDirectoryOffsetLookups[]:中央目录记录的相对偏移,用于定位;lookupIndexes[]:反向索引(lookupIndexes[原始顺序] = 哈希序位置), 因为哈希排序打乱了原始顺序,而entries()/stream()要按 zip 里的原始顺序返回。
三个都是 int[](原始类型、无对象开销),
10,500 元素的三个 int 数组 = 126KB —— 与 Javadoc 说的 122K 吻合。
5.4 手写快排:一次排序四个数组
private void sort(int left, int right) {
// Quick sort algorithm, uses nameHashCode as the source but sorts all arrays
if (left < right) {
int pivot = this.nameHashLookups[left + (right - left) / 2];
int i = left;
int j = right;
while (i <= j) {
while (this.nameHashLookups[i] < pivot) {
i++;
}
while (this.nameHashLookups[j] > pivot) {
j--;
}
if (i <= j) {
swap(i, j);
i++;
j--;
}
}
if (left < j) {
sort(left, j);
}
if (right > i) {
sort(i, right);
}
}
}
private void swap(int i, int j) {
swap(this.index, i, j);
swap(this.nameHashLookups, i, j);
swap(this.relativeCentralDirectoryOffsetLookups, i, j);
this.nameOffsetLookups.swap(i, j);
}
手写的快排,不用 Arrays.sort。因为要同时按 nameHashLookups
的顺序排序四个数组:用 Arrays.sort 要么定义一堆"索引对"对象
(对象开销大,违背 §5.1 的省内存目标),要么先排索引数组再重排所有数组(多一轮拷贝)。
手写快排 + swap 同步交换四个数组,是原地、零分配的做法
(pivot 取中点,while (i <= j) 的 Hoare 分区变体)。
这是"并行数组"(parallel arrays)的经典操作——
现代 Java 不常见(通常推荐对象数组),但在极致省内存的场景下合理。
排序之后建立反向索引:
int[] lookupIndexes = new int[size];
for (int i = 0; i < size; i++) {
lookupIndexes[this.index[i]] = i;
}
this.index[i] 是"第 i 个哈希序位置对应的原始位置",
所以 lookupIndexes[原始位置] = 哈希序位置。
5.5 二分查找与哈希冲突
public Entry getEntry(CharSequence namePrefix, CharSequence name) {
int nameHash = nameHash(namePrefix, name);
int lookupIndex = getFirstLookupIndex(nameHash);
int size = size();
while (lookupIndex >= 0 && lookupIndex < size && this.nameHashLookups[lookupIndex] == nameHash) {
long pos = getCentralDirectoryFileHeaderRecordPos(lookupIndex);
ZipCentralDirectoryFileHeaderRecord centralRecord = loadZipCentralDirectoryFileHeaderRecord(pos);
if (hasName(lookupIndex, centralRecord, pos, namePrefix, name)) {
return new Entry(lookupIndex, centralRecord);
}
lookupIndex++;
}
return null;
}
private int getFirstLookupIndex(int nameHash) {
int lookupIndex = Arrays.binarySearch(this.nameHashLookups, 0, this.nameHashLookups.length, nameHash);
if (lookupIndex < 0) {
return -1;
}
while (lookupIndex > 0 && this.nameHashLookups[lookupIndex - 1] == nameHash) {
lookupIndex--;
}
return lookupIndex;
}
Arrays.binarySearch 找到的是任意一个匹配位置(不一定是第一个),
所以 getFirstLookupIndex 往前回溯到同哈希的第一个。
然后 getEntry 线性扫描所有同哈希的条目,逐个比对真名。
哈希冲突因此被正确处理:先按哈希二分(O(log n)), 再在小范围内按真名比对(冲突通常是 1–2 个)。
hasName 里的比对不创建 String:
private boolean hasName(int lookupIndex, ZipCentralDirectoryFileHeaderRecord centralRecord, long pos,
CharSequence namePrefix, CharSequence name) {
int offset = this.nameOffsetLookups.get(lookupIndex);
pos += ZipCentralDirectoryFileHeaderRecord.FILE_NAME_OFFSET + offset;
int len = centralRecord.fileNameLength() - offset;
ByteBuffer buffer = ByteBuffer.allocate(ZipString.BUFFER_SIZE);
if (namePrefix != null) {
int startsWithNamePrefix = ZipString.startsWith(buffer, this.data, pos, len, namePrefix);
if (startsWithNamePrefix == -1) {
return false;
}
pos += startsWithNamePrefix;
len -= startsWithNamePrefix;
}
return ZipString.matches(buffer, this.data, pos, len, name, true);
}
ZipString.matches(buffer, data, pos, len, name, true) 直接从 DataBlock 的字节
与 CharSequence 逐字符比较,不构造 String。
代价是每次 getEntry 要 ByteBuffer.allocate(256)——
这也是 Javadoc 说"会产生垃圾"的来源之一。
5.6 ZipString.hash:复刻 String.hashCode()
static int hash(ByteBuffer buffer, DataBlock dataBlock, long pos, int len, boolean addEndSlash) throws IOException {
if (len == 0) {
return (!addEndSlash) ? EMPTY_HASH : EMPTY_SLASH_HASH;
}
buffer = (buffer != null) ? buffer : ByteBuffer.allocate(BUFFER_SIZE);
byte[] bytes = buffer.array();
int hash = 0;
...
while (len > 0) {
int count = readInBuffer(dataBlock, pos, buffer, len, codePointSize);
for (int byteIndex = 0; byteIndex < count;) {
codePointSize = getCodePointSize(bytes, byteIndex);
...
int codePoint = getCodePoint(bytes, byteIndex, codePointSize);
if (codePoint <= 0xFFFF) {
lastChar = (char) (codePoint & 0xFFFF);
hash = 31 * hash + lastChar;
}
else {
lastChar = 0;
hash = 31 * hash + Character.highSurrogate(codePoint);
hash = 31 * hash + Character.lowSurrogate(codePoint);
}
...
}
...
}
hash = 31 * hash + c —— 这就是 String.hashCode() 的算法。
BMP 外的字符用 surrogate pair 分别参与,也与 String.hashCode() 一致。
为什么必须一致? 因为调用方是 getEntry(name),
name 是调用者手里的 String,
nameHash(namePrefix, name) 要算出"如果这个字符串存在,它的哈希是多少"。
只有算法完全一致,才能用 nameHashLookups[] 里存的哈希去匹配。
这带来一个有意思的推论:ZipContent 依赖 JDK 的 String.hashCode() 实现细节。
String.hashCode() 的算法是写在 Javadoc 里的("The hash code for a String object is
computed as s[0]*31^(n-1) + s[1]*31^(n-2) + ... "),
所以它是规范的一部分,可以安全依赖。
这和 §3.3 依赖 URL.setURLStreamHandlerFactory 的实现细节形成对比——
一个是规范、一个是实现。
addEndSlash 参数(末尾加 /)配合 EMPTY_SLASH_HASH = "/".hashCode(),
服务于"查目录条目"(com/example/ 而不是 com/example)。
5.7 嵌套条目必须 STORED
private static ZipContent loadNestedZip(Source source, Entry entry) throws IOException {
if (entry.centralRecord.compressionMethod() != ZipEntry.STORED) {
throw new IOException("Nested entry '%s' in container zip '%s' must not be compressed"
.formatted(source.nestedEntryName(), source.path()));
}
debug.log("Loading nested zip entry '%s' from '%s'", source.nestedEntryName(), source.path());
return openAndLoad(source, Kind.NESTED_ZIP, entry.getContent());
}
这是整套嵌套 jar 机制的核心约束。
原因:zip 的中央目录在文件末尾,
要读 BOOT-INF/lib/foo.jar 的中央目录,
必须能随机访问这个条目的任意字节位置。
如果这个条目是 DEFLATE 压缩的,
就必须从头解压一遍(流式的,无法随机定位),
这对"只读一个条目的索引"来说代价太大。
所以 Spring Boot 在打包时把嵌套 jar 写成 STORED(§8 会看到 StoredEntryPreparator),
运行时在这里强制校验。
这个约束的代价是 fat jar 会更大(嵌套 jar 不被压缩)。 但收益是:
- 启动时不需要解压(快);
- 可以随机读(支持
getEntry); - 外层 jar 的压缩对已经压缩过的 jar 收益本来就很小(jar 里绝大部分是已压缩的 class 和 jar)。
注意错误信息里同时给出了嵌套条目名和容器 jar 路径—— 这是给用户排查用的:你能立刻知道是哪个库没被正确打包。
5.8 Zip64 与"前面可以有额外字节"
private static ZipContent loadContent(Source source, Kind kind, FileDataBlock data) throws IOException {
ZipEndOfCentralDirectoryRecord.Located locatedEocd = ZipEndOfCentralDirectoryRecord.load(data);
ZipEndOfCentralDirectoryRecord eocd = locatedEocd.endOfCentralDirectoryRecord();
long eocdPos = locatedEocd.pos();
Zip64EndOfCentralDirectoryLocator zip64Locator = Zip64EndOfCentralDirectoryLocator.find(data, eocdPos);
Zip64EndOfCentralDirectoryRecord zip64Eocd = Zip64EndOfCentralDirectoryRecord.load(data, zip64Locator);
data = data.slice(getStartOfZipContent(data, eocd, zip64Eocd));
long centralDirectoryPos = (zip64Eocd != null) ? zip64Eocd.offsetToStartOfCentralDirectory()
: Integer.toUnsignedLong(eocd.offsetToStartOfCentralDirectory());
long numberOfEntries = (zip64Eocd != null) ? zip64Eocd.totalNumberOfCentralDirectoryEntries()
: Short.toUnsignedInt(eocd.totalNumberOfCentralDirectoryEntries());
...
}
三个细节:
(1)Integer.toUnsignedLong 与 Short.toUnsignedInt。
zip 规范里 EOCD 的"中央目录偏移"是 4 字节、"条目总数"是 2 字节,
且都是无符号的。Java 没有无符号类型,
所以 int 要转 long、short 要转 int 才能表示 65535 以上的条目数。
漏掉 toUnsignedXxx 会把 65536 个条目的 jar 读成 0 个——这是 zip 解析的经典 bug。
(2)Zip64 是"可选扩展"。
Zip64EndOfCentralDirectoryLocator.find(data, eocdPos) 返回 null 表示不是 Zip64,
此时回退到经典 EOCD 的字段。
Zip64 用于 >4GB 或 >65535 条目的归档。
(3)getStartOfZipContent:从后往前定位。
/**
* Returns the location in the data that the archive actually starts. For most
* files the archive data will start at 0, however, it is possible to have
* prefixed bytes (often used for startup scripts) at the beginning of the data.
*/
private static long getStartOfZipContent(FileDataBlock data, ZipEndOfCentralDirectoryRecord eocd,
Zip64EndOfCentralDirectoryRecord zip64Eocd) throws IOException {
long specifiedOffsetToStartOfCentralDirectory = (zip64Eocd != null)
? zip64Eocd.offsetToStartOfCentralDirectory()
: Integer.toUnsignedLong(eocd.offsetToStartOfCentralDirectory());
long sizeOfCentralDirectoryAndEndRecords = getSizeOfCentralDirectoryAndEndRecords(eocd, zip64Eocd);
long actualOffsetToStartOfCentralDirectory = data.size() - sizeOfCentralDirectoryAndEndRecords;
return actualOffsetToStartOfCentralDirectory - specifiedOffsetToStartOfCentralDirectory;
}
zip 文件开头可以有额外字节(自解压脚本、shell shebang)。
此时 zip 内部记录的所有偏移都是"相对于 zip 起始"的,
而 data 是从文件 0 位置开始的,两者差一个"前缀长度"。
计算方法:
- 从文件末尾反推:CD 的实际起始位置 =
data.size() - (EOCD + CD 的大小); - 减去 zip 内部记录的相对偏移 = 前缀长度。
data.slice(prefixLength) 之后,所有偏移就对上了。
这是一个"用文件末尾的确定性结构反推文件开头的未知偏移"的技巧——
因为 EOCD 一定在最后(除注释外),且它的大小固定。
5.9 NameOffsetLookups:嵌套目录的前缀跳过
class NameOffsetLookups {
public static final NameOffsetLookups NONE = new NameOffsetLookups(0, 0);
private final int offset;
private final BitSet enabled;
...
int get(int index) {
return isEnabled(index) ? this.offset : 0;
}
}
用于 NESTED_DIRECTORY:所有条目名都以 BOOT-INF/classes/ 开头,
查 com/example/Foo.class 时要跳过这个前缀。
BitSet 而不是 boolean[]:一个条目 1 bit,
10,500 个条目只要 1.3KB(boolean[] 要 10.5KB)。
这是 §5.1 省内存目标的延续。
NONE 是一个共享的空实例(offset = 0, enabled = null),
避免为每个非嵌套 zip 分配对象。get() 里的
isEnabled(index) 在 enabled == null 时返回 false → 返回 0。
5.10 FileDataBlock 与 FileAccess:为什么不用 mmap
内存映射(MappedByteBuffer)是读大文件的常见优化,
但 FileDataBlock 没有用它:
void open() throws IOException {
synchronized (this.lock) {
if (this.referenceCount == 0) {
debug.log("Opening '%s'", this.path);
this.fileChannel = FileChannel.open(this.path, StandardOpenOption.READ);
this.buffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
tracker.openedFileChannel(this.path);
}
this.referenceCount++;
debug.log("Reference count for '%s' incremented to %s", this.path, this.referenceCount);
}
}
ByteBuffer.allocateDirect(BUFFER_SIZE) —— 一个 10KB 的直接缓冲区,
配 FileChannel.read(buffer, position) 做带缓冲的随机读。
为什么不用 mmap?前两条是我的推断、第三条是代码里的证据:
- Windows 上 mmap 会锁住文件,开发期反复重新打包时很痛苦;
- mmap 的清理依赖 GC(不可控),且 32 位 JVM 上地址空间稀缺;
- 代码证据:
close()里this.buffer = null+fileChannel.close(), 缓冲区随引用计数确定性释放——mmap 做不到。
BUFFER_SIZE = 1024 * 10 = 10KB 是精心选的:
zip 中央目录记录是 46 字节固定 + 名字,
一次读 10KB 能覆盖上百条记录,对"顺序遍历中央目录"命中率很高。
引用计数是这里的核心机制:
void open() throws IOException {
synchronized (this.lock) {
if (this.referenceCount == 0) {
// 真正打开
}
this.referenceCount++;
}
}
void close() throws IOException {
synchronized (this.lock) {
if (this.referenceCount == 0) {
return; // ← 防止多关
}
this.referenceCount--;
if (this.referenceCount == 0) {
// 真正关闭
}
}
}
为什么要引用计数?因为同一个 jar 文件会被多个 ZipContent 共享:
ZipContent 有全局缓存(cache),
而 JarUrlClassLoader.jarFiles 也缓存 JarFile。
两个路径可能拿到同一个底层文件。
引用计数保证"最后一个使用者关闭时才真正关文件句柄"。
close() 里的 if (this.referenceCount == 0) return; 是防御性的:
多关一次不会把计数变成 -1 导致后续行为错乱。
5.11 线程中断会毁掉 FileChannel
这是我在整个 loader 里印象最深的一段代码:
private void fillBuffer(long position) throws IOException {
if (Thread.currentThread().isInterrupted()) {
fillBufferUsingRandomAccessFile(position);
return;
}
try {
if (this.fileChannelInterrupted) {
repairFileChannel();
this.fileChannelInterrupted = false;
}
this.buffer.clear();
this.bufferSize = this.fileChannel.read(this.buffer, position);
this.bufferPosition = position;
}
catch (ClosedByInterruptException ex) {
this.fileChannelInterrupted = true;
fillBufferUsingRandomAccessFile(position);
}
}
private void fillBufferUsingRandomAccessFile(long position) throws IOException {
if (this.randomAccessFile == null) {
this.randomAccessFile = new RandomAccessFile(this.path.toFile(), "r");
tracker.openedFileChannel(this.path);
}
byte[] bytes = new byte[BUFFER_SIZE];
this.randomAccessFile.seek(position);
int len = this.randomAccessFile.read(bytes);
...
}
背景:Java NIO 有一个著名陷阱——
当一个线程在 FileChannel 上做阻塞 I/O 时被中断,
FileChannel 会被关闭(这是 InterruptibleChannel 的规范行为),
并抛 ClosedByInterruptException。
而 FileChannel 是共享的(一个 jar 一个 channel,被所有线程用)。
所以:
线程 A 在读 app.jar
线程 B 被中断(比如 shutdown 时 interrupt 了某个线程)
→ 如果 B 也在用这个 channel,channel 被关闭
→ 线程 A 的后续读全部失败
Spring Boot 的三层防御:
- 预防:
Thread.currentThread().isInterrupted()为真时, 根本不用FileChannel,改用RandomAccessFile(不受中断影响); - 补救:捕获
ClosedByInterruptException, 标记fileChannelInterrupted = true,这次用RandomAccessFile兜住; - 修复:下次
fillBuffer时调repairFileChannel()重新打开 channel。
private void repairFileChannel() throws IOException {
tracker.closedFileChannel(this.path);
this.fileChannel = FileChannel.open(this.path, StandardOpenOption.READ);
tracker.openedFileChannel(this.path);
}
RandomAccessFile 为什么不受影响?
因为它用的是老式的 FileInputStream/FileDescriptor 读,
不是 InterruptibleChannel,没有中断语义。
这三行 if (isInterrupted) + catch + repair
就是"与 JDK 的坑共存"的完整方案——
不是绕开问题,而是把问题的影响限制住并自动恢复。
值得注意它没有做任何"清除中断状态"的操作
(没有 Thread.interrupted() 调用)——
因为中断状态属于调用方,loader 不该吞掉它。
5.12 Cleaner 抽象:为什么要自己包一层
public interface Cleaner {
Cleaner instance = DefaultCleaner.instance;
Cleanable register(Object obj, Runnable action);
}
class DefaultCleaner implements Cleaner {
static final DefaultCleaner instance = new DefaultCleaner();
static BiConsumer<Object, Cleanable> tracker;
private final java.lang.ref.Cleaner cleaner = java.lang.ref.Cleaner.create();
@Override
public Cleanable register(Object obj, Runnable action) {
Cleanable cleanable = (action != null) ? this.cleaner.register(obj, action) : null;
if (tracker != null) {
tracker.accept(obj, cleanable);
}
return cleanable;
}
}
Cleaner.instance 是接口上的静态字段——这在 Java 里是允许的(接口可以有 static final 字段)。
好处是调用方写 Cleaner.instance,而实现可以被替换。
为什么要包一层?三点:
tracker钩子:测试要验证"资源真的被注册/清理了",static BiConsumer<Object, Cleanable> tracker是可被测试塞入的可变静态字段;- 可替换性:将来某个环境(比如 Native)需要不同清理策略时换实现即可;
action != null判断:允许注册 null action(返回 null cleanable)。
Cleaner.instance 是接口上的静态字段(Java 允许接口有 static final 字段),
调用方写 Cleaner.instance,实现可换。DefaultCleaner.instance 是单例,
所以全局只有 java.lang.ref.Cleaner.create() 创建的一个清理线程。
6. NestedJarFile:多版本 jar 与 JarFile 的 final 方法
6.1 继承 JarFile:收益与代价
public class NestedJarFile extends JarFile {
...
NestedJarFile(File file, String nestedEntryName, Runtime.Version version, boolean onlyNestedJars, Cleaner cleaner)
throws IOException {
super(file);
...
this.resources = new NestedJarFileResources(file, nestedEntryName);
this.cleanup = cleaner.register(this, this.resources);
this.name = file.getPath() + ((nestedEntryName != null) ? "!/" + nestedEntryName : "");
this.version = (version != null) ? version.feature() : baseVersion().feature();
}
}
收益:JarFile 是 JDK 标准 API,所有依赖它的代码
(URLClassLoader、JarURLConnection、Tomcat 的 TLD 扫描……)
都能直接消费 NestedJarFile,无需改造。
这是"让嵌套 jar 融入生态"的关键——Spring Boot 不需要说服每个框架支持新 API。
代价:JarFile 有很多 final 方法(§6.3),
且 super(file) 会真的打开这个文件(JDK 的 ZipFile 构造),
而 NestedJarFile 实际用的是 this.resources(NestedJarFileResources)。
super(file) 的开销是白白付出的,换来的是类型兼容。
onlyNestedJars 参数的语义:
if (onlyNestedJars && (nestedEntryName == null || nestedEntryName.isEmpty())) {
throw new IllegalArgumentException("nestedEntryName must not be empty");
}
public 的构造函数传 true(必须给嵌套条目名),
包私有的传 false(可以打开普通 jar)。
6.2 多版本 jar 的两套视图
多版本 jar(JEP 238)允许一个 jar 里同时有:
com/example/Foo.class ← 基础版本
META-INF/versions/17/com/example/Foo.class ← Java 17+ 用的版本
META-INF/versions/21/com/example/Foo.class ← Java 21+ 用的版本
NestedJarFile 提供了两个 stream:
@Override
public Stream<JarEntry> stream() {
synchronized (this) {
ensureOpen();
return streamContentEntries().map(NestedJarEntry::new);
}
}
@Override
public Stream<JarEntry> versionedStream() {
synchronized (this) {
ensureOpen();
return streamContentEntries().map(this::getBaseName)
.filter(Objects::nonNull)
.distinct()
.map(this::getJarEntry)
.filter(Objects::nonNull);
}
}
stream():物理视图,所有条目,META-INF/versions/17/...原样出现;versionedStream():逻辑视图,把META-INF/versions/N/x映射成x, 过滤掉高于当前版本的,distinct()去重。
getBaseName 做映射:
private String getBaseName(ZipContent.Entry contentEntry) {
String name = contentEntry.getName();
if (!name.startsWith(META_INF_VERSIONS)) {
return name;
}
int versionNumberStartIndex = META_INF_VERSIONS.length();
int versionNumberEndIndex = (versionNumberStartIndex != -1) ? name.indexOf('/', versionNumberStartIndex) : -1;
if (versionNumberEndIndex == -1 || versionNumberEndIndex == (name.length() - 1)) {
return null;
}
try {
int versionNumber = Integer.parseInt(name, versionNumberStartIndex, versionNumberEndIndex, DECIMAL);
if (versionNumber > this.version) {
return null;
}
}
catch (NumberFormatException ex) {
return null;
}
return name.substring(versionNumberEndIndex + 1);
}
Integer.parseInt(name, start, end, radix) —— Java 9 引入的带范围的 parseInt。
它避免了 substring 创建中间字符串。
(versionNumberStartIndex != -1 这个判断是恒真的——
它刚被赋值为 META_INF_VERSIONS.length(),一定 ≥ 0。
这是一处冗余代码,我列进 §10。)
versionNumberEndIndex == (name.length() - 1) 的检查:
META-INF/versions/17/ 这种(目录条目、没有后续路径)返回 null 过滤掉。
6.3 getVersionedContentEntry:为什么不能调 isMultiRelease()
private ZipContent.Entry getVersionedContentEntry(String name) {
// NOTE: we can't call isMultiRelease() directly because it's a final method and
// it inspects the container jar. We use ManifestInfo instead.
if (BASE_VERSION >= this.version || name.startsWith(META_INF) || !getManifestInfo().isMultiRelease()) {
return null;
}
MetaInfVersionsInfo metaInfVersionsInfo = getMetaInfVersionsInfo();
int[] versions = metaInfVersionsInfo.versions();
String[] directories = metaInfVersionsInfo.directories();
for (int i = versions.length - 1; i >= 0; i--) {
if (versions[i] <= this.version) {
ZipContent.Entry entry = getContentEntry(directories[i], name);
if (entry != null) {
return entry;
}
}
}
return null;
}
注释点出了继承 JDK 类的经典困境:
"we can't call
isMultiRelease()directly because it's a final method and it inspects the container jar"
JarFile.isMultiRelease() 是 final 的,不能覆写;
而且它的实现检查的是外层 jar(super(file) 打开的那个),
不是我们要读的嵌套条目。所以必须自己实现——这就是 ManifestInfo 的存在理由。
"从高版本往低版本找"(for (int i = versions.length - 1; i >= 0; i--)):
versions 是升序的,倒序遍历即"优先用能用的最高版本"。
找到第一个存在的就返回。
三个短路条件:
BASE_VERSION >= this.version:当前 JVM 版本不高于基础版本,没有版本化的必要;name.startsWith(META_INF):META-INF/下的资源不参与多版本(这是 JEP 238 的规定);!getManifestInfo().isMultiRelease():manifest 里没有Multi-Release属性。
6.4 ManifestInfo:containsKey 而不是值判断
class ManifestInfo {
private static final Name MULTI_RELEASE = new Name("Multi-Release");
static final ManifestInfo NONE = new ManifestInfo(null, false);
...
boolean isMultiRelease() {
if (this.manifest == null) {
return false;
}
Boolean multiRelease = this.multiRelease;
if (multiRelease != null) {
return multiRelease;
}
Attributes attributes = this.manifest.getMainAttributes();
multiRelease = attributes.containsKey(MULTI_RELEASE);
this.multiRelease = multiRelease;
return multiRelease;
}
}
attributes.containsKey(MULTI_RELEASE) —— 只看键是否存在,不看值。
而 JDK 的 JarFile.isMultiRelease() 会检查值:
(JDK 的实现是读 Multi-Release: true 并解析布尔值。)
两者的差异:Multi-Release: false 时
- JDK → false;
- Spring Boot → true。
这是有意的宽松还是遗漏?我倾向于是"实践中没人写 Multi-Release: false,
所以宽松处理更简单",但我没有找到明确依据,列进 §10。
multiRelease 字段是 Boolean(可为 null)+ volatile,
实现"惰性计算 + 缓存 + 无锁读"。
NONE 共享实例表示"没有 manifest"。
6.5 MetaInfVersionsInfo:扫描一次,预生成目录名
static MetaInfVersionsInfo get(int size, IntFunction<ZipContent.Entry> entries) {
Set<Integer> versions = new TreeSet<>();
for (int i = 0; i < size; i++) {
ZipContent.Entry contentEntry = entries.apply(i);
if (contentEntry.hasNameStartingWith(META_INF_VERSIONS) && !contentEntry.isDirectory()) {
String name = contentEntry.getName();
int slash = name.indexOf('/', META_INF_VERSIONS.length());
if (slash > -1) {
String version = name.substring(META_INF_VERSIONS.length(), slash);
try {
int versionNumber = Integer.parseInt(version);
if (versionNumber >= NestedJarFile.BASE_VERSION) {
versions.add(versionNumber);
}
}
catch (NumberFormatException ex) {
// Ignore
}
}
}
}
return (!versions.isEmpty()) ? new MetaInfVersionsInfo(versions) : NONE;
}
TreeSet→ 自然升序(配合 §6.3 的倒序遍历 = 优先用可用的最高版本);versionNumber >= BASE_VERSION过滤掉比基础版本还低的;catch (NumberFormatException)忽略META-INF/versions/abc/...这种非法目录;- 结果通过
ZipContent.getInfo缓存。
构造时把版本号预生成成目录名(两个平行数组 int[] versions 与 String[] directories,
与 §5.3 的并行数组思路一致),让 §6.3 的循环里不必每次拼字符串。
6.6 lastEntry:只缓存一个
private volatile NestedJarEntry lastEntry;
private NestedJarEntry getNestedJarEntry(String name) {
Objects.requireNonNull(name, "name");
NestedJarEntry lastEntry = this.lastEntry;
if (lastEntry != null && name.equals(lastEntry.getName())) {
return lastEntry;
}
ZipContent.Entry entry = getVersionedContentEntry(name);
entry = (entry != null) ? entry : getContentEntry(null, name);
if (entry == null) {
return null;
}
NestedJarEntry nestedJarEntry = new NestedJarEntry(entry, name);
this.lastEntry = nestedJarEntry;
return nestedJarEntry;
}
缓存大小为 1,且是 volatile(不是 synchronized)。
为什么大小为 1 就够?因为类加载的访问模式有极强的局部性:
URLClassLoader 加载 com.example.Foo 时,
会依次查 com/example/Foo.class,找到后立刻要它的 JarEntry;
接下来加载 com.example.Bar,又查一次。
但常见的情况是"同一个名字被连续查多次"
(findClass 先查、definePackage 再查一次)。
volatile 而不是锁:因为这是单条缓存,
写丢一次(两个线程同时写不同名字)最多导致一次缓存未命中,
不会有正确性问题。这是"用弱一致性换无锁"的典型。
hasEntry() 也复用它:
public boolean hasEntry(String name) {
NestedJarEntry lastEntry = this.lastEntry;
if (lastEntry != null && name.equals(lastEntry.getName())) {
return true;
}
...
}
注意 hasEntry 的短路返回 true(因为 lastEntry 存在就说明这个条目存在),
而 getNestedJarEntry 返回的是对象。语义一致,返回值不同。
6.7 Cleaner 只做兜底
NestedJarFile 构造时 cleaner.register(this, this.resources),
但 §5.1 的 Javadoc 明确说 "This implementation does not use Cleanable
so care must be taken to release ZipContent resources"。
不要把 Cleaner 当主要机制:正常路径靠 close()(§4.6 的 clearCache 也走这条路),
Cleaner 只在"忘了 close"时于 GC 后兜底——而 GC 时机不可控。
7. 另外三种入口:exploded、PropertiesLauncher、JarMode、NIO.2
7.1 classpath.idx:exploded 模式为什么需要额外一个文件
ClassPathIndexFile getClassPathIndex(Archive archive) throws IOException {
if (!archive.isExploded()) {
return null; // Regular archives already have a defined order
}
String location = getClassPathIndexFileLocation(archive);
return ClassPathIndexFile.loadIfPossible(archive.getRootDirectory(), location);
}
注释一句话说清了原因:"Regular archives already have a defined order"。
jar 里的条目顺序是写入时决定的、固定的——jarFile.stream() 按中央目录顺序返回,
所以 JarFileArchive.getClassPathUrls() 天然有确定顺序。
但目录形态(ExplodedArchive)不同:
§2.6 里虽然做了 Arrays.sort(files, entryComparator) 按绝对路径排序,
但这个顺序未必是构建时想要的 classpath 顺序。
比如你依赖 a.jar 里的类覆盖 b.jar 里的同名类,
按字母序 b 可能在 a 前面。
于是构建期生成 BOOT-INF/classpath.idx:
- "BOOT-INF/lib/a.jar"
- "BOOT-INF/lib/b.jar"
- "BOOT-INF/lib/c.jar"
运行时按文件里的顺序追加:
@Override
protected ClassLoader createClassLoader(Collection<URL> urls) throws Exception {
if (this.classPathIndex != null) {
urls = new ArrayList<>(urls);
urls.addAll(this.classPathIndex.getUrls());
}
return super.createClassLoader(urls);
}
解析逻辑:
private String extractName(String line) {
if (line.startsWith("- \"") && line.endsWith("\"")) {
return line.substring(3, line.length() - 1);
}
throw new IllegalStateException("Malformed classpath index line [" + line + "]");
}
格式校验严格:必须 - " 开头 + " 结尾。
任何一行不合规就抛异常——宁可启动失败,也不要静默用错 classpath。
这与第 24 章"宽容解析"的做法相反,因为这里是构建工具生成的文件,
不存在"人手写错"的合理场景。
LinkedHashSet 保序 + 去重:
this.lines = lines.stream().map(this::extractName).collect(Collectors.toCollection(LinkedHashSet::new));
isIncludedOnClassPathAndNotIndexed 用这个集合避免重复:
protected boolean isIncludedOnClassPathAndNotIndexed(Entry entry) {
if (!isIncludedOnClassPath(entry)) {
return false;
}
return (this.classPathIndex == null) || !this.classPathIndex.containsEntry(entry.name());
}
即:扫描目录找到的 jar 会被跳过(因为它们已经由 idx 以正确顺序提供), 只保留 idx 里没有的(比如用户后加的)。
7.2 PropertiesLauncher:用属性文件驱动的启动
PropertiesLauncher 是 loader 里最"可配置"的一个 launcher(614 行,是 JarLauncher 的 30 倍)。
它的类注释本身就是完整的文档:
Looks in various places for a properties file to extract loader settings, defaulting to
{@code loader.properties} either on the current classpath or in the current working
directory. The name of the properties file can be changed by setting a System property
{@code loader.config.name} (e.g. {@code -Dloader.config.name=my} will look for
{@code my.properties}. If that file doesn't exist then tries
{@code loader.config.location} (with allowed prefixes {@code classpath:} and
{@code file:} or any valid URL).
七个可配项:loader.main(主类,也可由 Start-Class 给)、
loader.path(追加到 classpath 的目录/归档,逗号分隔)、
loader.home(相对路径基目录,默认 ${user.dir})、
loader.args(默认参数,前置到实际参数前)、
loader.config.name(配置文件名,可逗号分隔多个)、
loader.config.location(位置,支持 classpath: / file: / URL)、
loader.system(为 true 时把属性拷进 System properties)。
查找顺序是 file:${loader.home}/xxx.properties → classpath:xxx.properties
→ classpath:BOOT-INF/classes/xxx.properties,找到第一个就 return:
for (String config : configs) {
try (InputStream resource = getResource(config)) {
if (resource == null) {
debug.log("Not found: %s", config);
continue;
}
debug.log("Found: %s", config);
loadResource(resource);
return; // Load the first one we find
}
}
file: 优先于 classpath: —— 外部配置覆盖内部配置,
与 Spring Boot 主体的属性优先级(第 6 章)哲学一致。
加载后做占位符解析——用的是 SystemPropertyUtils,
loader 自己的类(不能用 Spring 的 PropertyPlaceholderHelper,见 §1.2)。
这是"不能依赖 Spring"这条约束最直接的体现。
loader.path 的解析有四个规矩(cleanupPath):
./xxx → xxx;xxx/* → xxx/(通配目录下所有 jar);
目录统一补 /;parsePathsProperty 里 "" → "/"(归档根),空列表 → ["lib"]。
另外有一处 JNLP 特判:
connection.setUseCaches(connection.getClass().getSimpleName().startsWith("JNLP"));
按类名前缀判断是不是 Java Web Start 连接,是则启用缓存。 Java Web Start 早已从 JDK 11 移除,这段代码可能是纯粹遗留(列 §10)。
7.3 JarMode:一个 jar 的第二种身份
final class JarModeRunner {
static final String DISABLE_SYSTEM_EXIT = JarModeRunner.class.getName() + ".DISABLE_SYSTEM_EXIT";
static final String SUPPRESSED_SYSTEM_EXIT_CODE = JarModeRunner.class.getName() + ".SUPPRESSED_SYSTEM_EXIT_CODE";
static void main(String[] args) {
String mode = System.getProperty("jarmode");
boolean disableSystemExit = Boolean.getBoolean(DISABLE_SYSTEM_EXIT);
try {
runJarMode(mode, args);
if (disableSystemExit) {
System.setProperty(SUPPRESSED_SYSTEM_EXIT_CODE, "0");
}
}
catch (Throwable ex) {
printError(ex);
if (disableSystemExit) {
System.setProperty(SUPPRESSED_SYSTEM_EXIT_CODE, "1");
return;
}
System.exit(1);
}
}
private static void runJarMode(String mode, String[] args) {
List<JarMode> candidates = SpringFactoriesLoader.loadFactories(JarMode.class,
ClassUtils.getDefaultClassLoader());
for (JarMode candidate : candidates) {
if (candidate.accepts(mode)) {
candidate.run(mode, args);
return;
}
}
throw new JarModeErrorException("Unsupported jarmode '" + mode + "'");
}
}
三个点:
(1)DISABLE_SYSTEM_EXIT 是为测试准备的。
System.exit(1) 会让测试进程直接退出,无法断言。
所以提供两个系统属性:开了 DISABLE_SYSTEM_EXIT 后不真退出,
而是把退出码写进 SUPPRESSED_SYSTEM_EXIT_CODE 属性供测试读取。
0 成功、1 失败。这是"让不可测的东西可测"的标准手法。
(2)SpringFactoriesLoader 而不是 *.imports。
// loader/spring-boot-jarmode-tools/src/main/resources/META-INF/spring.factories
org.springframework.boot.loader.jarmode.JarMode=\
org.springframework.boot.jarmode.tools.ToolsJarMode
第 3 章讲过 Spring Boot 4 把自动配置迁移到了 META-INF/spring/*.imports,
但这里仍然用 spring.factories——
因为 SpringFactoriesLoader 是 spring-core 的通用 SPI 机制(读 spring.factories),
而 *.imports 是 Spring Boot 自动配置专用的。
JarMode 不是自动配置,它是普通 SPI。
(3)错误分类:JarModeErrorException vs 其它。
private static void printError(Throwable ex) {
if (ex instanceof JarModeErrorException) {
String message = ex.getMessage();
System.err.println("Error: " + message);
System.err.println();
return;
}
ex.printStackTrace();
}
JarModeErrorException 是"用户错误"(比如给了不支持的 mode),
只打印一行消息;其它异常是"程序错误",打印完整栈。
区分"用户的错"和"我的错",给不同的诊断信息——
这是第 16 章 FailureAnalyzer 体系的同类思路,在 loader 里手工实现了一遍。
ToolsJarMode 提供两个命令:
@Override
public boolean accepts(String mode) {
return "tools".equalsIgnoreCase(mode);
}
static List<Command> getCommands(Context context) {
return List.of(new ExtractCommand(context), new ListLayersCommand(context));
}
用法:
$ java -Djarmode=tools -jar myapp.jar extract --destination=./extracted
$ java -Djarmode=tools -jar myapp.jar list-layers
extract 支撑了 Docker 镜像的"分层提取"(§8.4)。
7.4 NIO.2:nested 文件系统与一个堆栈技巧
nio/file/ 包让你可以用标准 NIO.2 API 访问嵌套 jar:
public class NestedFileSystemProvider extends FileSystemProvider {
private final Map<Path, NestedFileSystem> fileSystems = new HashMap<>();
@Override
public String getScheme() {
return "nested";
}
@Override
public Path getPath(URI uri) {
NestedLocation location = NestedLocation.fromUri(uri);
synchronized (this.fileSystems) {
NestedFileSystem fileSystem = this.fileSystems.computeIfAbsent(location.path(),
(path) -> new NestedFileSystem(this, path));
fileSystem.installZipFileSystemIfNecessary(location.nestedEntryName());
return fileSystem.getPath(location.nestedEntryName());
}
}
@Override
public void createDirectory(Path dir, FileAttribute<?>... attrs) throws IOException {
throw new ReadOnlyFileSystemException();
}
@Override
public void delete(Path path) throws IOException {
throw new ReadOnlyFileSystemException();
}
// copy / move 同样抛 ReadOnlyFileSystemException
}
只读是核心约束:createDirectory / delete / copy / move
全部抛 ReadOnlyFileSystemException,newDirectoryStream 一律抛
NotDirectoryException——它只是"读取 fat jar 里的 jar"的适配层。
有意思的是 NestedFileSystem.isCreatingNewFileSystem():
void installZipFileSystemIfNecessary(String nestedEntryName) {
try {
boolean seen;
synchronized (this.zipFileSystems) {
seen = this.zipFileSystems.putIfAbsent(nestedEntryName, EXISTING_FILE_SYSTEM) != null;
}
if (!seen) {
URI uri = new URI("jar:nested:" + this.jarPath.toUri().getPath() + "/!" + nestedEntryName);
if (!hasFileSystem(uri)) {
FileSystem zipFileSystem = FileSystems.newFileSystem(uri, Collections.emptyMap());
synchronized (this.zipFileSystems) {
this.zipFileSystems.put(nestedEntryName, zipFileSystem);
}
}
}
}
catch (Exception ex) {
// Ignore
}
}
private boolean hasFileSystem(URI uri) {
try {
FileSystems.getFileSystem(uri);
return true;
}
catch (FileSystemNotFoundException ex) {
return isCreatingNewFileSystem();
}
}
private boolean isCreatingNewFileSystem() {
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
for (StackTraceElement element : stack) {
if (FILE_SYSTEMS_CLASS_NAME.equals(element.getClassName())) {
return "newFileSystem".equals(element.getMethodName());
}
}
return false;
}
用栈追踪解决递归重入问题。
场景:FileSystems.newFileSystem(uri, env) 内部会回调 provider,
而后者可能又问"这个 URI 的文件系统存在吗?"——
此时 FileSystems.getFileSystem(uri) 会抛 FileSystemNotFoundException,
但我们正在创建它。
解法:检查当前线程调用栈里有没有 java.nio.file.FileSystems.newFileSystem,
有则说明"不存在"是正常的,返回 true。
这是"用运行时自省解决 API 设计缺陷"的技巧;代价是 getStackTrace() 的开销
(只在 catch 路径上)与依赖方法名字符串(重构会失效)。
在无法改造 JDK API 的前提下,这是一个能工作的解法。
EXISTING_FILE_SYSTEM 是一个 static final Object 哨兵:
putIfAbsent(nestedEntryName, EXISTING_FILE_SYSTEM) 先占位,
创建成功后再替换,保证并发下只有一个线程真正 newFileSystem。
8. 构建侧:从 Repackager 到 STORED
8.1 repackage 的完整流程
public void repackage(File destination, Libraries libraries, @Nullable FileTime lastModifiedTime)
throws IOException {
Assert.isTrue(destination != null && !destination.isDirectory(), "Invalid destination");
getLayout(); // get layout early
destination = destination.getAbsoluteFile();
File source = getSource();
if (isAlreadyPackaged() && source.equals(destination)) {
return;
}
File workingSource = source;
if (source.equals(destination)) {
workingSource = getBackupFile();
workingSource.delete();
renameFile(source, workingSource);
}
destination.delete();
try {
try (JarFile sourceJar = new JarFile(workingSource)) {
repackage(sourceJar, destination, libraries, lastModifiedTime);
}
}
finally {
if (!this.backupSource && !source.equals(workingSource)) {
deleteFile(workingSource);
}
}
}
isAlreadyPackaged() 的判断依据:
return (manifest != null && manifest.getMainAttributes().getValue(BOOT_VERSION_ATTRIBUTE) != null);
看 manifest 里有没有 Spring-Boot-Version 属性。
有就说明已经打包过,直接返回(幂等)。
source.equals(destination) 时(原地打包)要先把源文件改名成备份文件,
再从备份读、写到原名。这样即使中途失败,源文件还在备份里。
backupSource 默认 true(保留 .original 备份)。
核心写入:
private void write(JarFile sourceJar, AbstractJarWriter writer, PackagedLibraries libraries) throws IOException {
if (isLayered()) {
writer.useLayers(this.layers, this.layersIndex);
}
writer.writeManifest(buildManifest(sourceJar));
writeLoaderClasses(writer);
writer.writeEntries(sourceJar, getEntityTransformer(), libraries.getUnpackHandler(),
libraries.getLibraryLookup());
Map<String, Library> writtenLibraries = libraries.write(writer);
writeNativeImageArgFile(writer, sourceJar, writtenLibraries);
if (isLayered()) {
writeLayerIndex(writer);
}
writeSignatureFileIfNecessary(writtenLibraries, writer);
}
八个步骤,顺序有意义:useLayers(必须在任何写入前)→ writeManifest
(manifest 通常是第一个条目)→ writeLoaderClasses(写入
org/springframework/boot/loader/**)→ writeEntries(复制原 jar 条目,
EntryTransformer 把 classes 移到 BOOT-INF/classes/)→ libraries.write
(写 BOOT-INF/lib/*.jar)→ writeNativeImageArgFile → writeLayerIndex →
writeSignatureFileIfNecessary。
8.2 Layouts:四种布局决定一切
public static Layout forFile(File file) {
String lowerCaseFileName = file.getName().toLowerCase(Locale.ENGLISH);
if (lowerCaseFileName.endsWith(".jar")) {
return new Jar();
}
if (lowerCaseFileName.endsWith(".war")) {
return new War();
}
if (file.isDirectory() || lowerCaseFileName.endsWith(".zip")) {
return new Expanded();
}
throw new IllegalStateException("Unable to deduce layout for '" + file + "'");
}
| 布局 | Launcher | 库位置 | classpath.idx |
|---|---|---|---|
Jar | JarLauncher | BOOT-INF/lib/ | BOOT-INF/classpath.idx |
War | WarLauncher | WEB-INF/lib/ + WEB-INF/lib-provided/ | WEB-INF/classpath.idx |
Expanded | PropertiesLauncher | 同 Jar | 同 Jar |
None | 无(getLauncherClassName() 返回 null) | 同 Jar | 同 Jar |
Jar 布局里两个路径的区分很关键:
@Override
public String getClassesLocation() {
return ""; // 源 jar 里 classes 在根
}
@Override
public String getRepackagedClassesLocation() {
return "BOOT-INF/classes/"; // 打包后移到 BOOT-INF/classes/
}
源 jar 的 classes 在根目录,repackage 后被移到 BOOT-INF/classes/。
这就是 EntryTransformer(RepackagingEntryTransformer)干的事:
return this.layout.getRepackagedClassesLocation() + name;
War 布局按 Maven scope 分目录:
static {
Map<LibraryScope, String> locations = new HashMap<>();
locations.put(LibraryScope.COMPILE, "WEB-INF/lib/");
locations.put(LibraryScope.CUSTOM, "WEB-INF/lib/");
locations.put(LibraryScope.RUNTIME, "WEB-INF/lib/");
locations.put(LibraryScope.PROVIDED, "WEB-INF/lib-provided/");
SCOPE_LOCATION = Collections.unmodifiableMap(locations);
}
与 §2.4 的 WarLauncher.isLibraryFileOrClassesDirectory 精确对应:
PROVIDED → lib-provided/,其它 → lib/。
8.3 StoredEntryPreparator:运行期约束的构建期闭环
这是本章我最想让你看到的一处"两个模块隔空握手"。
运行期(§5.7)要求嵌套 jar 必须 STORED,§2.5 靠 UNPACK 注释识别需要解包的库。
构建期:
private static class StoredEntryPreparator {
private static final int BUFFER_SIZE = 32 * 1024;
private final boolean unpack;
private final CRC32 crc = new CRC32();
private long size;
StoredEntryPreparator(InputStream inputStream, boolean unpack) throws IOException {
this.unpack = unpack;
try (inputStream) {
load(inputStream);
}
}
private void load(InputStream inputStream) throws IOException {
byte[] buffer = new byte[BUFFER_SIZE];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
this.crc.update(buffer, 0, bytesRead);
this.size += bytesRead;
}
}
void prepareStoredEntry(ZipArchiveEntry entry) {
entry.setSize(this.size);
entry.setCompressedSize(this.size);
entry.setCrc(this.crc.getValue());
entry.setMethod(ZipEntry.STORED);
if (this.unpack) {
entry.setComment("UNPACK");
}
}
}
STORED 条目必须在写入前知道 size 和 CRC(zip 规范:local file header 里就有这两个字段)。
所以 load() 必须先完整读一遍流。
然后 writeEntry 时又 library.openStream() 再读一遍——每个依赖 jar 被读两遍。
这是 STORED 的必然代价,无法避免(除非先落盘)。
触发条件:
private void setUpEntry(JarFile jarFile, JarArchiveEntry entry, UnpackHandler unpackHandler) throws IOException {
try (ZipHeaderPeekInputStream inputStream = new ZipHeaderPeekInputStream(jarFile.getInputStream(entry))) {
if (inputStream.hasZipHeader() && entry.getMethod() != ZipEntry.STORED) {
new StoredEntryPreparator(inputStream, unpackHandler.requiresUnpack(entry.getName()))
.prepareStoredEntry(entry);
}
else {
entry.setCompressedSize(-1);
}
}
}
ZipHeaderPeekInputStream.hasZipHeader() —— 窥探前几字节判断是不是 zip
(判断 PK\x03\x04 魔数)。
只对"是 zip 且当前不是 STORED"的条目做转换——
普通 class 文件不需要 STORED,保持 DEFLATE 压缩以减小体积。
writeNestedLibrary 则无条件 STORED:
public void writeNestedLibrary(String location, Library library) throws IOException {
JarArchiveEntry entry = new JarArchiveEntry(location + library.getName());
entry.setTime(getNestedLibraryTime(library));
new StoredEntryPreparator(library.openStream(), new LibraryUnpackHandler(library).requiresUnpack(location))
.prepareStoredEntry(entry);
try (InputStream inputStream = library.openStream()) {
writeEntry(entry, library, new InputStreamEntryWriter(inputStream));
}
}
entry.setTime(getNestedLibraryTime(library)) —— 时间戳不是"现在"(可复现构建,§1.3)。
8.4 classpath.idx 与 layers.idx
private void writeClasspathIndexIfNecessary(Collection<String> paths, Layout layout, AbstractJarWriter writer)
throws IOException {
if (layout.getClasspathIndexFileLocation() != null) {
List<String> names = paths.stream().map((path) -> "- \"" + path + "\"").toList();
writer.writeIndexFile(layout.getClasspathIndexFileLocation(), names);
}
}
生成的行格式 - "BOOT-INF/lib/foo.jar" 与 §7.1 的 extractName 解析严格对应。
构建期的写格式与运行期的读格式是一对,改动时必须同步。
layers.idx 服务于 Docker 镜像优化:
private void writeLayerIndex(AbstractJarWriter writer) throws IOException {
String name = this.layout.getLayersIndexFileLocation();
if (StringUtils.hasLength(name)) {
Layer layer = this.layers.getLayer(name);
this.layersIndex.add(layer, name);
writer.writeEntry(name, this.layersIndex::writeTo);
}
}
分层把 fat jar 按"变更频率"拆开:
dependencies(第三方依赖)—— 最稳定,放底层;spring-boot-loader—— 随 Boot 版本变;snapshot-dependencies—— 快照依赖;application—— 你的代码,每次都变。
Docker 构建时按层提取(extract --layers),
只有变化的层需要重新推送,大幅加速镜像分发。
8.5 META-INF/BOOT.SF:签名 jar 的兼容处理
@Override
protected void writeSignatureFileIfNecessary(Map<String, Library> writtenLibraries, AbstractJarWriter writer)
throws IOException {
String sourceName = getSource().getName().toLowerCase(Locale.ROOT);
if ((sourceName.endsWith(".jar") || sourceName.endsWith(".war")) && hasSignedLibrary(writtenLibraries)) {
writer.writeEntry("META-INF/BOOT.SF", (entryWriter) -> {
});
}
}
private boolean hasSignedLibrary(Map<String, Library> writtenLibraries) throws IOException {
for (Library library : writtenLibraries.values()) {
if (!(library instanceof JarModeLibrary) && FileUtils.isSignedJarFile(library.getFile())) {
return true;
}
}
return false;
}
如果任何一个依赖是签名 jar,就往 fat jar 里写一个空的 META-INF/BOOT.SF。
为什么?签名 jar 的签名覆盖 jar 内所有字节,
塞进 fat jar 后 JDK 的签名验证会发现"条目不在签名范围内"。
SF 是 jar 签名文件的标准后缀(META-INF/*.SF),
写这个文件是为了让 JDK 走"有签名文件"的分支。
说实话,具体机制我没有完全追通(列 §10)。 能确定的是:这是针对签名 jar 的兼容处理,文件名是刻意选的,内容是空的。
8.6 Native image 参数文件
private void writeNativeImageArgFile(AbstractJarWriter writer, JarFile sourceJar,
Map<String, Library> writtenLibraries) throws IOException {
Set<String> excludes = new LinkedHashSet<>();
for (Map.Entry<String, Library> entry : writtenLibraries.entrySet()) {
LibraryCoordinates coordinates = entry.getValue().getCoordinates();
ZipEntry zipEntry = (coordinates != null)
? sourceJar.getEntry(ReachabilityMetadataProperties.getLocation(coordinates)) : null;
if (zipEntry != null) {
try (InputStream inputStream = sourceJar.getInputStream(zipEntry)) {
ReachabilityMetadataProperties properties = ReachabilityMetadataProperties
.fromInputStream(inputStream);
if (properties.isOverridden()) {
excludes.add(entry.getKey());
}
}
}
}
NativeImageArgFile argFile = new NativeImageArgFile(excludes);
argFile.writeIfNecessary((lines) -> {
String contents = String.join("\n", lines) + "\n";
writer.writeEntry(NativeImageArgFile.LOCATION,
new ByteArrayInputStream(contents.getBytes(StandardCharsets.UTF_8)));
});
}
这是第 28 章(AOT 与 GraalVM)的伏笔:
fat jar 里会带一个 native-image 参数文件,
列出哪些依赖需要排除 reachability metadata。
ReachabilityMetadataProperties 从依赖里读
META-INF/spring/reachability-metadata.properties,
isOverridden() 为 true 时排除。
也就是说:可执行 jar 本身就是 AOT 的输入, 构建期已经把 native 构建需要的信息算好并写进 jar。
9. 决策速查表
9.1 "我的 java -jar 报 No 'Start-Class' manifest entry"
说明 MANIFEST.MF 里没有 Start-Class。三种可能:
- 你没用 Spring Boot 的构建插件打包(普通
jar任务不会写Start-Class); - 打包了但被后续步骤覆盖(比如
shadowJar之类); - 你手动改了 MANIFEST.MF。
自测:unzip -p myapp.jar META-INF/MANIFEST.MF。
9.2 "我要看 fat jar 里有什么"
$ java -Djarmode=tools -jar myapp.jar extract --destination=./out
$ java -Djarmode=tools -jar myapp.jar list-layers
不要用 unzip + 再解压 BOOT-INF/lib/*.jar——
用官方工具能保证一致性。
9.3 "某个库必须解包才能工作"
构建期标记(Gradle):bootJar { requiresUnpack '**/some-library-*.jar' }。
对应 §8.3 的 entry.setComment("UNPACK"),
运行期 §2.5 会把它解到 /tmp/<jar>-spring-boot-libs-<UUID>/。
注意解包目录权限 700、文件 600,且只有文件 deleteOnExit()。
9.4 "我要自定义 classpath 顺序(exploded 模式)"
编辑 BOOT-INF/classpath.idx,每行必须是 - "BOOT-INF/lib/a.jar" 这个格式。
任何一行不合规就启动失败(§7.1 的 extractName 严格校验)。
9.5 "我要外部配置覆盖 fat jar 内的配置"
用 PropertiesLauncher(loader.config.name / loader.config.location):
$ java -cp myapp.jar org.springframework.boot.loader.launch.PropertiesLauncher
查找顺序:file:${loader.home}/xxx.properties → classpath:xxx.properties
→ classpath:BOOT-INF/classes/xxx.properties,找到第一个就停。
9.6 "启动后内存里有 zip 索引,怎么释放"
ContextRefreshedEvent 时 Spring Boot 会反射调用 ClassLoader.clearCache()
(§4.6)。如果你自己在用 ZipContent,必须显式 close()——
Javadoc 明确说它不用 Cleanable(§5.1)。
9.7 "Windows 上 nested URL 报路径错误"
NestedLocation.fixWindowsLocationPath 只处理 /C:/... 与 ///C:/... 两种形式(§3.6)。
如果是别的形式(盘符小写、file: 前缀未剥离),
先检查 URL 是怎么构造的——应当用 JarUrl.create(...) 而不是手工拼字符串。
9.8 "多线程加载类时报 AssertionError: Package X has already been defined"
这是 §4.4 的"不该发生"分支:definePackage 抛了 IllegalArgumentException
(说包已定义),但随后 getDefinedPackage 返回 null。
自测:检查 LaunchedClassLoader 的父加载器链上是否有重复/自定义加载器。
9.9 "我的 fat jar 特别大"
嵌套 jar 是 STORED(不压缩) 的(§5.7、§8.3)—— 这是随机读的必要代价。外层 jar 对已压缩的 jar 再压缩收益本来就很小。
9.10 "我想用 NIO.2 读 fat jar 里的文件"
URI uri = URI.create("nested:/path/to/app.jar/!BOOT-INF/lib/foo.jar");
Path path = Paths.get(uri); // 需要 NestedFileSystemProvider 被注册
注意它只读(copy/move/delete 抛 ReadOnlyFileSystemException)。
10. 从本章提炼的定律
延续前 26 章的编号(README 收录)。
第六十一定律:把"能不能不用"的判断放在最早的地方
Launcher.launch() 第一件事是 if (!isExploded()) Handlers.register();;
JarUrlClassLoader 构造时算一次 hasJarUrls,三个覆写方法据此退化成 super.xxx()。
一套复杂机制应有一个总开关,且开关在最外层求值一次——
不要让每个方法都自己判断,也不要"反正也不贵就都注册吧"。
第六十二定律:约定优于配置走到极致时,把约定写进注释
URLStreamHandler 的实现必须 public、必须叫 Handler、包必须以协议名结尾。
三条约定无法用类型系统表达,所以 Spring Boot 在每个 handler 类上写了同样的
"NOTE: ..." 注释。当约束只存在于框架的实现里时,注释是唯一的传递方式。
第六十三定律:跨层传递"调用方意图"用 ThreadLocal,但必须 try/finally 限定
Optimizations.enable(true/false) 把"我要存在性还是要内容"从 JarUrlClassLoader
传到 JarUrlConnection——中间隔着 URL、URLConnection 两层 JDK 代码,没有参数可传。
ThreadLocal 是唯一选择,但两处调用都用 try/finally 严格 disable()。
ThreadLocal 不是"全局变量的替代品",而是"受限的上下文传递通道"。
第六十四定律:高频失败路径上用共享异常实例
URLClassLoader 遍历 URL 找资源,绝大多数是"找不到"。
throwFileNotFound() 在优化模式下抛静态共享的 FILE_NOT_FOUND_EXCEPTION,
省掉每次 fillInStackTrace() 的开销。代价是异常没有栈追踪——
在"异常是控制流而非错误"的场景下,这是正确的取舍。
第六十五定律:负缓存与正缓存同样重要
undefinablePackages 缓存"这个包定义不了",避免每次都遍历几十个 URL × 多次 I/O。
"查过了,没有"是稳定结论,值得和"查到了"一样被缓存
(前提是结论真稳定——classpath 不变时才成立)。
第六十六定律:可选依赖之间用"反射按名调用"建立约定
ClearCachesApplicationListener 用 getDeclaredMethod("clearCache") 调用
loader 的清理方法,因为 core 不依赖 loader。
约定没有接口、没有注解,只存在于 JarUrlClassLoader.clearCache() 上方的注释里。
两个模块不能互相引用时,方法名就是接口,注释就是契约。
第六十七定律:并行化的代价是必须写"容忍竞态"的代码
registerAsParallelCapable() 让类加载并行,但两个线程可能同时 definePackage,
一个成功一个抛 IllegalArgumentException。
tolerateRaceConditionDueToBeingParallelCapable 通过"再查一次"区分
"预期竞态"与"真正错误",后者抛 AssertionError。
引入并发处,都要配一段"如何区分正常竞态与 bug"的逻辑。
第六十八定律:与 JDK 的坑共存要三层——预防、补救、恢复
线程中断会关闭共享的 FileChannel(ClosedByInterruptException)。
FileAccess.fillBuffer 的解法:中断时改用 RandomAccessFile(预防)、
捕获后兜住并重打标记(补救)、下次 repairFileChannel() 重开(恢复)。
不要试图绕开 JDK 的行为,把它限制住并自动恢复。
注意它没有清除中断状态——那是调用方的,不该被吞。
第六十九定律:复刻 JDK 的算法可以换掉对象分配
ZipString.hash 用 31 * hash + c 复刻 String.hashCode(),
于是可直接从 DataBlock 的字节算哈希,不必创建 String。
String.hashCode() 的算法写在 Javadoc 里(是规范),可安全依赖;
而 §3.3 依赖 URL.setURLStreamHandlerFactory 的清空副作用是实现细节——
两者性质不同,前者可长期依赖,后者要有失败兜底。
第七十定律:运行期约束必须在构建期有对应的产出
运行期要求嵌套 jar 是 STORED(§5.7),构建期 StoredEntryPreparator
就先读一遍算 CRC 再 setMethod(STORED)(§8.3);
运行期认 UNPACK 注释(§2.5),构建期就 setComment("UNPACK");
运行期解析 - "path"(§7.1),构建期就生成同样格式(§8.4)。
成对出现的约束要成对出现的代码,两边都要有注释指向对方。
11. 本章未逐行核实 / 待你实测的项
-
★
META-INF/BOOT.SF的具体作用机制(§8.5)。 我确认了"有签名依赖时写一个空的BOOT.SF"这个行为, 但没追通它如何改变 JDK 的签名验证路径。 自测:构造一个含签名依赖的 fat jar,对比有/无BOOT.SF时JarFile的验证行为与异常信息。 -
indexOfSeparator为什么必须倒序查找(§3.5)。 我从NestedLocation的lastIndexOf("/!")推断两者是同一思路 ("最靠后的分隔符才是真正的分界"), 但没有构造出一个正序会出错的真实 URL。 自测:构造嵌套 jar 路径中同时含/!和!/的 URL 观察行为。 -
mmap 未被使用的真实理由(§5.10)。 "Windows 上 mmap 锁文件"与"地址空间/清理成本"是我的推断, 源码里没有给出说明。
allocateDirect(10KB)+ 引用计数是确凿的事实。 -
ManifestInfo.isMultiRelease()用containsKey而非值判断(§6.4)。 与 JDK 的JarFile.isMultiRelease()行为不同(Multi-Release: false时结果相反)。 是刻意的宽松还是遗漏?自测:构造Multi-Release: false的嵌套 jar 对比两者。 -
getVersionedContentEntry里versionNumberStartIndex != -1恒真(§6.2)。 该变量刚被赋值为META_INF_VERSIONS.length()(常量 ≥ 0),判断永远成立。 疑似重构残留。 -
JarUrlClassLoader构造参数的 Javadoc 写着 "Create a newLaunchedClassLoader"(§4.1)。 复制注释时的残留(JarUrlClassLoader是抽象类,不可能创建LaunchedClassLoader)。 -
ZipContent的全局cache没有失效机制(§5.1)。 Javadoc 说"assumed that zip content will not change once loaded"。 如果应用在运行中替换了 fat jar 文件(或某个嵌套 jar 被重写), 缓存不会感知。这在容器里通常不是问题,但在开发期热替换场景要注意。 -
Optimizations的 ThreadLocal 与虚拟线程(§3.8)。 虚拟线程数量可能极多,每个线程一个ThreadLocal条目是否构成压力? JDK 21+ 的ThreadLocal对虚拟线程有优化,但我未实测 loader 在大规模虚拟线程下的表现。 -
Handlers.register()的setURLStreamHandlerFactory(null)在未来的 JDK 上是否仍然有效(§3.3)。 它依赖URL.setURLStreamHandlerFactory清空 handler 缓存这一实现细节。 如果 JDK 改变这个行为(或加更多校验),catch (Error ex)会静默吞掉失败, 导致"注册了但没生效"——且没有任何日志。这是一个静默失效风险点。 -
jar mode 下
LaunchedClassLoader强制本地定义jarmode包的真实触发场景(§4.2)。 我推断是"父加载器里也有同名类导致版本冲突", 但没有构造出具体的冲突案例。自测:把spring-boot-jarmode-tools同时放到系统 classpath 和 fat jar 里,看加载到的类是否不同。 -
ClassPathIndexFile与解包逻辑对同名 jar 的处理不一致(§7.1 / §2.5)。 idx 里不同目录的同名 jar 是不同行(不覆盖), 但 §2.5 解包时只取文件名,会互相覆盖。未实测。 -
临时解包目录的
deleteOnExit只覆盖文件(§2.5)。path.toFile().deleteOnExit()只注册了文件, 目录<jar>-spring-boot-libs-<UUID>不会被删,长期运行会积累空目录。 -
NestedJarFile.super(file)的开销是否被正确释放(§6.1)。super(file)会真的打开外层 zip,而实际用的是NestedJarFileResources。 我未追通close()是否把这个"白开的" zip 关掉。 -
PropertiesLauncher里 JNLP 特判是否还有意义(§7.2)。connection.setUseCaches(className.startsWith("JNLP"))—— Java Web Start 已从 JDK 11 移除,这段代码可能是纯粹遗留。
12. 小结与下一章
本章读完了 Spring Boot 4.1.1 的可执行归档整条链路。四条最值得记住的结构性事实:
-
loader 的全部意义是"把 jar 里的 jar 变成 classpath"。 为此它自己实现了一个 URL 协议(
nested:)、覆写了 JDK 的jar:协议、 写了一套 zip 解析器、一个ClassLoader子类。 而这一切的驱动力是一条约束:spring-core是compileOnly, loader 只能用 JDK 原生 API。 -
ZipContent用"只存哈希"换内存。 10,500 个条目只占 122K,代价是每次getEntry重新读名字比对、产生垃圾。 这是一个明确的取舍:启动期慢一点、垃圾多一点,换运行期内存干净。 配合ContextRefreshedEvent时的clearCache()完成闭环。 -
运行期的每个约束都在构建期有对应产出。 STORED ↔
StoredEntryPreparator、UNPACK 注释 ↔setComment("UNPACK")、classpath.idx格式 ↔extractName解析、多版本 jar ↔MetaInfVersionsInfo。 读运行期代码时,养成"去 loader-tools 里找它的另一半"的习惯。 -
整套机制是可绕开的。 exploded 模式不注册协议、无 jar URL 时三个覆写方法全走
super、PropertiesLauncher提供属性驱动、jarmode提供第二种身份。 "不需要时零开销"和"需要时能力完整"同时成立。
下一章(第 28 章)进入 AOT 与 GraalVM Native。
本章 §8.6 已埋下伏笔:Repackager.writeNativeImageArgFile
把 native 构建需要的信息写进了 fat jar。
第 28 章会读 SpringApplicationAotProcessor、RuntimeHints 的收集与序列化、
META-INF/spring/aot.factories 的生成与消费,
以及第四十一定律("AOT 下序列化解析结果而非原始输入")的完整实现。