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

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 出发

ruby 复制代码
$ 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 的解法是把"让自己跑起来"这件事分两步:

yaml 复制代码
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 等一堆细节。

先看一个最小事实,确认这条路真的存在:

bash 复制代码
$ 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 个包

bash 复制代码
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

gradle 复制代码
// 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 依赖点只有一处:

java 复制代码
// 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 插件在打包时调用:

css 复制代码
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 的第二种身份

bash 复制代码
org/springframework/boot/jarmode/tools/
├── ToolsJarMode      ← 实现 JarMode,accepts("tools")
├── Runner / Command / Context
├── ExtractCommand / ExtractLayersCommand / ListCommand / ListLayersCommand
├── IndexedJarStructure / IndexedLayers / JarStructure / Layers
└── MissingValueException / UnknownOptionException

它让同一个 fat jar 既能"运行"也能"被拆开":

ini 复制代码
$ 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():整个模块的入口

java 复制代码
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 时。

java 复制代码
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 步:决定"到底运行哪个类"。

java 复制代码
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)。

java 复制代码
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 方法"的保险。
java 复制代码
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 抽象:从"我是哪个类"反推"我在哪个文件"

java 复制代码
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() 这条链:

java 复制代码
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 无参构造函数里的那一行:

java 复制代码
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 驱动的启动

java 复制代码
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() 的错误信息值得单独看:

java 复制代码
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() 传了两个谓词:

java 复制代码
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:只差三个字符串

java 复制代码
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 才需要覆写:

java 复制代码
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:

java 复制代码
@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 在构建期就把它们标记为需要解包。

解包的细节:

java 复制代码
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 机制。

临时目录的创建:

java 复制代码
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 目录,方便排查。

权限的处理很讲究:

java 复制代码
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 上不传这些属性(否则会抛异常)。

java 复制代码
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:目录形态的三个细节

java 复制代码
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 语法是:

xml 复制代码
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 的真实形状

java 复制代码
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 = "" 代进去:

bash 复制代码
jar:nested:/opt/app.jar/!BOOT-INF/lib/foo.jar!/

对比普通 jar URL:

javascript 复制代码
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 级别的"劫持"

java 复制代码
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 类上都写了同样的注释:

java 复制代码
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

java 复制代码
@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 ------ 从后往前找

java 复制代码
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) ------ 分段求哈希

java 复制代码
@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 ------ 分段比较

java 复制代码
@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

java 复制代码
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 是解析的核心:

java 复制代码
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 路径修正是这段代码的精华:

java 复制代码
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 风格的头:

java 复制代码
@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:

java 复制代码
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() 里主动清理:

java 复制代码
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 开关,把"读两次"变"读一次"

java 复制代码
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 的做法是在调用方打个标记:

java 复制代码
// JarUrlClassLoader.findResource
Optimizations.enable(false);      // ← 我只要知道"在不在",不读内容
try {
	return super.findResource(name);
}
finally {
	Optimizations.disable();
}

// JarUrlClassLoader.loadClass
Optimizations.enable(true);       // ← 我要真内容

然后 JarUrlConnection 据此短路:

java 复制代码
@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 已缓存、且条目存在,就返回一个空流------ 调用方(只关心"存在吗")拿到非空流就满意了。

第二个优化在异常路径上:

java 复制代码
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 早退

go 复制代码
URLClassLoader
  └── JarUrlClassLoader          (抽象,处理 jar URL 的优化)
        └── LaunchedClassLoader   (处理 exploded / jar mode / package 定义)

注意 JarUrlClassLoader 是 abstract ,且它的 Javadoc 写着 "Create a new LaunchedClassLoader instance"------ 这是从 LaunchedClassLoader 复制构造参数注释时留下的痕迹(我列进 §10)。

java 复制代码
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 包:唯一一处"反父委托"

java 复制代码
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);
	}
}

这是全模块唯一一处打破双亲委派的地方。

为什么必须打破?场景是这样的:

ini 复制代码
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 生效

java 复制代码
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 与"容忍竞态"

java 复制代码
static {
	ClassLoader.registerAsParallelCapable();
}

(LaunchedClassLoader 和 JarUrlClassLoader 都有这个静态块。)

registerAsParallelCapable() 告诉 JVM: "我这个类加载器支持并行加载,你可以用 finer-grained 锁"。 没有它,loadClass 会对整个 classloader 加锁,多线程加载类时会串行化。

但并行带来竞态:两个线程同时为同一个包调 definePackage, 一个成功,另一个抛 IllegalArgumentException("package already defined")。

java 复制代码
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:一个带引用计数语义的缓存

java 复制代码
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:一次反射式的跨模块桥

java 复制代码
/**
 * 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 里那个监听器:

java 复制代码
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:负缓存

java 复制代码
private final Set<String> undefinablePackages = ConcurrentHashMap.newKeySet();

definePackage 遍历所有 URL 都没找到时,把包名加进这个集合:

java 复制代码
this.undefinablePackages.add(packageName);

下次再遇到这个包,第一行就 return:

java 复制代码
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 的类注释给出了答案:

java 复制代码
/**
 * 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

java 复制代码
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 要求名字以 / 结尾:

java 复制代码
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 的核心字段:

java 复制代码
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 手写快排:一次排序四个数组

java 复制代码
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 不常见(通常推荐对象数组),但在极致省内存的场景下合理。

排序之后建立反向索引:

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 二分查找与哈希冲突

java 复制代码
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:

java 复制代码
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()

java 复制代码
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 s0*31^(n-1) + s1*31^(n-2) + ... "), 所以它是规范的一部分 ,可以安全依赖。 这和 §3.3 依赖 URL.setURLStreamHandlerFactory 的实现细节形成对比------ 一个是规范、一个是实现。

addEndSlash 参数(末尾加 /)配合 EMPTY_SLASH_HASH = "/".hashCode(), 服务于"查目录条目"(com/example/ 而不是 com/example)。

5.7 嵌套条目必须 STORED

java 复制代码
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 与"前面可以有额外字节"

java 复制代码
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:从后往前定位。

java 复制代码
/**
 * 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:嵌套目录的前缀跳过

java 复制代码
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 没有用它:

java 复制代码
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 能覆盖上百条记录,对"顺序遍历中央目录"命中率很高。

引用计数是这里的核心机制:

java 复制代码
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 里印象最深的一段代码:

java 复制代码
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,被所有线程用)。 所以:

css 复制代码
线程 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。
java 复制代码
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 抽象:为什么要自己包一层

java 复制代码
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:收益与代价

java 复制代码
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 参数的语义:

java 复制代码
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 里同时有:

bash 复制代码
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:

java 复制代码
@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 做映射:

java 复制代码
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()

java 复制代码
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 而不是值判断

java 复制代码
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:扫描一次,预生成目录名

java 复制代码
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:只缓存一个

java 复制代码
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() 也复用它:

java 复制代码
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 模式为什么需要额外一个文件

java 复制代码
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:

arduino 复制代码
- "BOOT-INF/lib/a.jar"
- "BOOT-INF/lib/b.jar"
- "BOOT-INF/lib/c.jar"

运行时按文件里的顺序追加:

java 复制代码
@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);
}

解析逻辑:

java 复制代码
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 保序 + 去重:

java 复制代码
this.lines = lines.stream().map(this::extractName).collect(Collectors.toCollection(LinkedHashSet::new));

isIncludedOnClassPathAndNotIndexed 用这个集合避免重复:

java 复制代码
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 倍)。 它的类注释本身就是完整的文档:

less 复制代码
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:

java 复制代码
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 特判:

java 复制代码
connection.setUseCaches(connection.getClass().getSimpleName().startsWith("JNLP"));

按类名前缀判断是不是 Java Web Start 连接,是则启用缓存。 Java Web Start 早已从 JDK 11 移除,这段代码可能是纯粹遗留(列 §10)。

7.3 JarMode:一个 jar 的第二种身份

java 复制代码
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。

java 复制代码
// 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 其它。

java 复制代码
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 提供两个命令:

java 复制代码
@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));
}

用法:

ini 复制代码
$ 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:

java 复制代码
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():

java 复制代码
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 的完整流程

java 复制代码
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() 的判断依据:

java 复制代码
return (manifest != null && manifest.getMainAttributes().getValue(BOOT_VERSION_ATTRIBUTE) != null);

看 manifest 里有没有 Spring-Boot-Version 属性。 有就说明已经打包过,直接返回(幂等)。

source.equals(destination) 时(原地打包)要先把源文件改名成备份文件, 再从备份读、写到原名。这样即使中途失败,源文件还在备份里。 backupSource 默认 true(保留 .original 备份)。

核心写入:

java 复制代码
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:四种布局决定一切

java 复制代码
public static Layout forFile(File file) {
	String lowerCaseFileName = file.getName().toLowerCase(Locale.ENGLISH);
	if (lowerCaseFileName.endsWith(".jar")) {
		return new Jar();
	}
	if (lowerCaseFileName.endsWith(".war")) {
		return new War();
	}
	if (file.isDirectory() || lowerCaseFileName.endsWith(".zip")) {
		return new Expanded();
	}
	throw new IllegalStateException("Unable to deduce layout for '" + file + "'");
}
布局 Launcher 库位置 classpath.idx
Jar JarLauncher BOOT-INF/lib/ BOOT-INF/classpath.idx
War WarLauncher WEB-INF/lib/ + WEB-INF/lib-provided/ WEB-INF/classpath.idx
Expanded PropertiesLauncher 同 Jar 同 Jar
None 无(getLauncherClassName() 返回 null) 同 Jar 同 Jar

Jar 布局里两个路径的区分很关键:

java 复制代码
@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)干的事:

java 复制代码
return this.layout.getRepackagedClassesLocation() + name;

War 布局按 Maven scope 分目录:

java 复制代码
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 注释识别需要解包的库。 构建期:

java 复制代码
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 的必然代价,无法避免(除非先落盘)。

触发条件:

java 复制代码
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:

java 复制代码
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

java 复制代码
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 镜像优化:

java 复制代码
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 的兼容处理

java 复制代码
@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 参数文件

java 复制代码
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 里有什么"

ini 复制代码
$ 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):

shell 复制代码
$ 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 里的文件"

java 复制代码
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 下序列化解析结果而非原始输入")的完整实现。

相关推荐
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 28 章 AOT 与 GraalVM Native与序列化模型
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 25 章 · Actuator 与健康:端点机制、`HealthIndicator`、管理端口子上下文
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 16 章 · 排序、元数据与报告:`AutoConfigurationSorter`、配置元数据、条件报告
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 14 章 · 自动配置引擎:`AutoConfigurationImportSelector` 全流程
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 24 章 · 日志系统:`LoggingApplicationListener` 与结构化日志
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 22 章 · 数据源、连接池与事务
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 17 章 应用类型推断与上下文工厂:`WebApplicationType`
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 23 章 · JPA / Hibernate 与持久化模块
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 12 章 · 事件总线与 Bootstrap 上下文
java·后端·开源