Spring Boot 4.1.1 深度源码解析-第 27 章 · 可执行归档与 Loader:`JarLauncher` 与嵌套 jar

8 阅读11分钟

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 只做三件事:

  1. 打开这个 jar;
  2. 读 META-INF/MANIFEST.MF 的 Main-Class 属性;
  3. 用系统类加载器(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 });
	}
}

三处细节:

  1. setContextClassLoader(classLoader) 在 Class.forName 之前。 顺序很重要:你的 main 方法里第一件事可能就是 Thread.currentThread().getContextClassLoader(), 比如 JPA 的 Persistence、JNDI 的 InitialContext 都依赖它。
  2. Class.forName(name, false, classLoader) 的第二个参数 initialize = false—— 不触发静态初始化。因为真正的初始化应该由 JVM 在调用 main 时按 JLS 语义完成。 提前初始化会改变类的初始化顺序,可能导致静态块里的副作用提前发生。
  3. 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();
}

三个决策:

  1. 只取文件名,丢掉目录部分。 BOOT-INF/lib/foo.jar → foo.jar。 所以如果两个不同目录下有同名 jar,会互相覆盖—— 这是一个真实存在的限制(BOOT-INF/lib/ 下同名 jar 本来也不合法)。
  2. 用"大小"判断是否需要重新解包,而不是"存在即跳过"。 防止上一次运行留下了半个文件。 注意它没校验 CRC 或时间戳——只用大小,这是一个务实而非严谨的选择。
  3. 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);
	}
}

三点:

  1. Arrays.sort(files, entryComparator):按绝对路径排序。 File.listFiles() 的返回顺序在 JDK 里是未定义的(依赖文件系统实现)。 如果不排序,同一台机器上两次启动的 classpath 顺序可能不同—— 这在排查"为什么这次启动报 NoSuchMethodError 而上次没有"时是灾难。 显式排序 = 确定性,这是第 11 定律(TreeMap/Comparable = 构建确定性)的又一个实例。
  2. files.addAll(0, ...) 头插:这是 DFS 而不是 BFS。 子目录被插到队首,下一个就遍历它们。结合排序,得到的是确定的深度优先序。
  3. 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"与"它里面的条目"。

两处细节:

  1. replace("!", "%21"):外层 jar 的文件名里可能含 ! (Linux 下完全合法,比如 /opt/my!app.jar)。 如果不转义,解析时会把文件名的 ! 误当成分隔符。 转义成 %21 后,解析器按 URL 解码规则还原。 "转义输入以避免与语法分隔符冲突"是所有解析器都要做的事, 这里做得很干净:只在构造时转义,解析时由 UrlDecoder 还原。
  2. 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'

三条约定缺一不可:

  1. public —— JDK 用反射 newInstance 创建它;
  2. 类名必须是 Handler —— 不是 NestedHandler,就是 Handler;
  3. 包名必须以协议名结尾 —— .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——很多库(尤其是排查版本冲突时)依赖它。

三个条件必须同时满足才定义:

  1. hasEntry(jarFile, classEntryName) —— 这个类在这个 jar 里;
  2. hasEntry(jarFile, packageEntryName) —— 这个 jar 里有对应的包目录条目;
  3. 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.
 */

四层信息,每一层都是设计决策:

  1. 能读嵌套 zip,但要求不压缩(STORED)——java.util.zip.ZipFile 做不到;
  2. 只存哈希 + 偏移量 + 位置,不存名字字符串——省内存;
  3. 典型 fat jar 约 10,500 个条目,占约 122K——实测数据,也是设计目标;
  4. 结果被缓存、条目与字符串不被缓存、不用 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?前两条是我的推断、第三条是代码里的证据:

  1. Windows 上 mmap 会锁住文件,开发期反复重新打包时很痛苦;
  2. mmap 的清理依赖 GC(不可控),且 32 位 JVM 上地址空间稀缺;
  3. 代码证据: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 的三层防御:

  1. 预防:Thread.currentThread().isInterrupted() 为真时, 根本不用 FileChannel,改用 RandomAccessFile(不受中断影响);
  2. 补救:捕获 ClosedByInterruptException, 标记 fileChannelInterrupted = true,这次用 RandomAccessFile 兜住;
  3. 修复:下次 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,而实现可以被替换。

为什么要包一层?三点:

  1. tracker 钩子:测试要验证"资源真的被注册/清理了", static BiConsumer<Object, Cleanable> tracker 是可被测试塞入的可变静态字段;
  2. 可替换性:将来某个环境(比如 Native)需要不同清理策略时换实现即可;
  3. 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
JarJarLauncherBOOT-INF/lib/BOOT-INF/classpath.idx
WarWarLauncherWEB-INF/lib/ + WEB-INF/lib-provided/WEB-INF/classpath.idx
ExpandedPropertiesLauncher同 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。三种可能:

  1. 你没用 Spring Boot 的构建插件打包(普通 jar 任务不会写 Start-Class);
  2. 打包了但被后续步骤覆盖(比如 shadowJar 之类);
  3. 你手动改了 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. 本章未逐行核实 / 待你实测的项

  1. ★ META-INF/BOOT.SF 的具体作用机制(§8.5)。 我确认了"有签名依赖时写一个空的 BOOT.SF"这个行为, 但没追通它如何改变 JDK 的签名验证路径。 自测:构造一个含签名依赖的 fat jar,对比有/无 BOOT.SF 时 JarFile 的验证行为与异常信息。

  2. indexOfSeparator 为什么必须倒序查找(§3.5)。 我从 NestedLocation 的 lastIndexOf("/!") 推断两者是同一思路 ("最靠后的分隔符才是真正的分界"), 但没有构造出一个正序会出错的真实 URL。 自测:构造嵌套 jar 路径中同时含 /! 和 !/ 的 URL 观察行为。

  3. mmap 未被使用的真实理由(§5.10)。 "Windows 上 mmap 锁文件"与"地址空间/清理成本"是我的推断, 源码里没有给出说明。allocateDirect(10KB) + 引用计数是确凿的事实。

  4. ManifestInfo.isMultiRelease() 用 containsKey 而非值判断(§6.4)。 与 JDK 的 JarFile.isMultiRelease() 行为不同(Multi-Release: false 时结果相反)。 是刻意的宽松还是遗漏?自测:构造 Multi-Release: false 的嵌套 jar 对比两者。

  5. getVersionedContentEntry 里 versionNumberStartIndex != -1 恒真(§6.2)。 该变量刚被赋值为 META_INF_VERSIONS.length()(常量 ≥ 0),判断永远成立。 疑似重构残留。

  6. JarUrlClassLoader 构造参数的 Javadoc 写着 "Create a new LaunchedClassLoader"(§4.1)。 复制注释时的残留(JarUrlClassLoader 是抽象类,不可能创建 LaunchedClassLoader)。

  7. ZipContent 的全局 cache 没有失效机制(§5.1)。 Javadoc 说"assumed that zip content will not change once loaded"。 如果应用在运行中替换了 fat jar 文件(或某个嵌套 jar 被重写), 缓存不会感知。这在容器里通常不是问题,但在开发期热替换场景要注意。

  8. Optimizations 的 ThreadLocal 与虚拟线程(§3.8)。 虚拟线程数量可能极多,每个线程一个 ThreadLocal 条目是否构成压力? JDK 21+ 的 ThreadLocal 对虚拟线程有优化,但我未实测 loader 在大规模虚拟线程下的表现。

  9. Handlers.register() 的 setURLStreamHandlerFactory(null) 在未来的 JDK 上是否仍然有效(§3.3)。 它依赖 URL.setURLStreamHandlerFactory 清空 handler 缓存这一实现细节。 如果 JDK 改变这个行为(或加更多校验),catch (Error ex) 会静默吞掉失败, 导致"注册了但没生效"——且没有任何日志。这是一个静默失效风险点。

  10. jar mode 下 LaunchedClassLoader 强制本地定义 jarmode 包的真实触发场景(§4.2)。 我推断是"父加载器里也有同名类导致版本冲突", 但没有构造出具体的冲突案例。自测:把 spring-boot-jarmode-tools 同时放到系统 classpath 和 fat jar 里,看加载到的类是否不同。

  11. ClassPathIndexFile 与解包逻辑对同名 jar 的处理不一致(§7.1 / §2.5)。 idx 里不同目录的同名 jar 是不同行(不覆盖), 但 §2.5 解包时只取文件名,会互相覆盖。未实测。

  12. 临时解包目录的 deleteOnExit 只覆盖文件(§2.5)。 path.toFile().deleteOnExit() 只注册了文件, 目录 <jar>-spring-boot-libs-<UUID> 不会被删,长期运行会积累空目录。

  13. NestedJarFile.super(file) 的开销是否被正确释放(§6.1)。 super(file) 会真的打开外层 zip,而实际用的是 NestedJarFileResources。 我未追通 close() 是否把这个"白开的" zip 关掉。

  14. PropertiesLauncher 里 JNLP 特判是否还有意义(§7.2)。 connection.setUseCaches(className.startsWith("JNLP")) —— Java Web Start 已从 JDK 11 移除,这段代码可能是纯粹遗留。


12. 小结与下一章

本章读完了 Spring Boot 4.1.1 的可执行归档整条链路。四条最值得记住的结构性事实:

  1. loader 的全部意义是"把 jar 里的 jar 变成 classpath"。 为此它自己实现了一个 URL 协议(nested:)、覆写了 JDK 的 jar: 协议、 写了一套 zip 解析器、一个 ClassLoader 子类。 而这一切的驱动力是一条约束:spring-core 是 compileOnly, loader 只能用 JDK 原生 API。

  2. ZipContent 用"只存哈希"换内存。 10,500 个条目只占 122K,代价是每次 getEntry 重新读名字比对、产生垃圾。 这是一个明确的取舍:启动期慢一点、垃圾多一点,换运行期内存干净。 配合 ContextRefreshedEvent 时的 clearCache() 完成闭环。

  3. 运行期的每个约束都在构建期有对应产出。 STORED ↔ StoredEntryPreparator、UNPACK 注释 ↔ setComment("UNPACK")、 classpath.idx 格式 ↔ extractName 解析、多版本 jar ↔ MetaInfVersionsInfo。 读运行期代码时,养成"去 loader-tools 里找它的另一半"的习惯。

  4. 整套机制是可绕开的。 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 下序列化解析结果而非原始输入")的完整实现。