JVM CDS 动态归档在 Spring Boot 多层 JAR 结构中 Class-Path 通配符未展开:JarLauncher 与 Archive.getNestedArchives()
大家好,今天我们来深入探讨一个在 Spring Boot 多层 JAR 结构中使用 JVM CDS (Class Data Sharing) 动态归档时可能遇到的问题:Class-Path 通配符未展开,以及这与 JarLauncher 和 Archive.getNestedArchives() 的行为之间的关系。这个问题会直接影响 CDS 动态归档的效率,甚至可能导致归档失败。
1. 背景知识:Spring Boot 多层 JAR 结构与 CDS
首先,我们需要了解 Spring Boot 的多层 JAR 结构以及 CDS 的基本概念。
1.1 Spring Boot 多层 JAR 结构
Spring Boot 为了方便模块化和增量更新,通常会将应用程序打包成一个可执行 JAR,其内部采用嵌套 JAR 的结构。这种结构一般包含以下几个部分:
- BOOT-INF/classes: 存放应用程序自身的
.class文件。 - BOOT-INF/lib: 存放应用程序依赖的第三方 JAR 包。
- META-INF/MANIFEST.MF: 清单文件,包含应用程序的元数据,例如 Main-Class 和 Class-Path。
MANIFEST.MF 中的 Class-Path 属性用于指定应用程序运行时需要加载的 JAR 包路径。在多层 JAR 结构中,Class-Path 通常会使用相对路径,指向 BOOT-INF/lib 目录下的 JAR 包。
1.2 JVM CDS (Class Data Sharing)
CDS 是一种 JVM 优化技术,旨在减少应用程序的启动时间和内存占用。它通过将已经加载的类数据预先存储在一个归档文件中,在下次启动时直接从归档文件中加载类数据,从而避免了重新解析和验证类的过程。
CDS 分为静态 CDS 和动态 CDS 两种。
- 静态 CDS: 在应用程序构建时,通过
jlink工具生成包含系统类和应用程序类的归档文件。 - 动态 CDS: 在应用程序运行时,通过 JVM 参数
-XX:DumpLoadedClassList=<listfile>和-Xshare:dump生成包含应用程序类的归档文件。然后,在下次启动时使用-Xshare:on或-Xshare:auto加载归档文件。
动态 CDS 的优势在于它可以只归档应用程序实际使用的类,避免了静态 CDS 可能包含大量未使用类的缺点。
2. 问题描述:Class-Path 通配符未展开
在使用动态 CDS 时,可能会遇到一个问题:MANIFEST.MF 文件中的 Class-Path 属性使用通配符时,JVM 在进行动态归档时可能无法正确展开通配符,导致只有部分类被归档,或者根本无法进行归档。
例如,MANIFEST.MF 文件中可能包含以下内容:
Manifest-Version: 1.0
Main-Class: com.example.Application
Class-Path: BOOT-INF/lib/*.jar
在这种情况下,JVM 在执行 -Xshare:dump 时,期望能将 BOOT-INF/lib 目录下所有的 JAR 包都加入到类路径中,并归档这些 JAR 包中的类。然而,实际情况是,JVM 可能只将 BOOT-INF/lib/*.jar 作为一个字面字符串添加到类路径中,而不是展开成 BOOT-INF/lib 目录下所有 JAR 包的列表。
这会导致以下问题:
- 只有部分类被归档: 只有应用程序直接依赖的类(即不在
BOOT-INF/lib下的类)会被归档,而BOOT-INF/lib下的类则不会被归档。 - 归档失败: 如果应用程序依赖的类都在
BOOT-INF/lib下,那么 JVM 可能会因为找不到应用程序的主类而导致归档失败。 - 后续启动速度慢: 因为没有充分利用 CDS,后续启动时仍然需要重新加载和验证大量的类,启动速度无法得到有效提升。
3. 原因分析:JarLauncher 与 Archive.getNestedArchives()
要理解这个问题的原因,我们需要了解 Spring Boot 的 JarLauncher 以及 Archive.getNestedArchives() 方法的作用。
3.1 JarLauncher
JarLauncher 是 Spring Boot 用于启动可执行 JAR 的一个类。它的主要作用是:
- 解析
MANIFEST.MF文件,获取应用程序的元数据,例如 Main-Class 和 Class-Path。 - 根据 Class-Path 构建类加载器。
- 启动应用程序的主类。
JarLauncher 在构建类加载器时,会解析 MANIFEST.MF 中的 Class-Path 属性,并将 Class-Path 中指定的 JAR 包添加到类加载器的搜索路径中。 但是,JarLauncher 自身并不会展开 Class-Path 中的通配符。
3.2 Archive.getNestedArchives()
Archive.getNestedArchives() 是 Spring Boot Archive 接口的一个方法,用于获取嵌套的 JAR 包。Spring Boot 在解析可执行 JAR 时,会将 BOOT-INF/lib 目录下的 JAR 包视为嵌套的 JAR 包。
public interface Archive extends Iterable<Entry> {
String getUrl();
String getName();
Iterator<Entry> iterator();
// 获取嵌套的 Archives
List<Archive> getNestedArchives(EntryFilter filter) throws IOException;
interface Entry {
boolean isDirectory();
String getName();
}
interface EntryFilter {
boolean matches(Entry entry);
}
}
JarFileArchive 是 Archive 接口的一个实现,用于表示 JAR 文件。JarFileArchive 的 getNestedArchives() 方法会遍历 JAR 文件中的所有条目,并根据指定的 EntryFilter 过滤出嵌套的 JAR 包。
3.3 问题的原因
问题的根源在于 JVM 在执行动态 CDS 时,并没有像 JarLauncher 那样解析 MANIFEST.MF 文件,并展开 Class-Path 中的通配符。而是直接使用 MANIFEST.MF 中字面字符串作为类路径。
Spring Boot 使用 JarLauncher 启动应用程序时,会调用 Archive.getNestedArchives() 方法来获取嵌套的 JAR 包,并添加到类加载器中。但这个过程发生在 JVM 动态 CDS 归档之前。 因此,JVM 动态 CDS 只能看到 MANIFEST.MF 文件中的 Class-Path: BOOT-INF/lib/*.jar,而无法看到 BOOT-INF/lib 目录下所有 JAR 包的列表。
4. 解决方案
解决这个问题的方法主要有两种:
4.1 修改 MANIFEST.MF 文件
最直接的解决方案是修改 MANIFEST.MF 文件,将 Class-Path 中的通配符替换为 BOOT-INF/lib 目录下所有 JAR 包的完整列表。
例如,如果 BOOT-INF/lib 目录下包含 a.jar、b.jar 和 c.jar 三个 JAR 包,那么可以将 MANIFEST.MF 文件修改为:
Manifest-Version: 1.0
Main-Class: com.example.Application
Class-Path: BOOT-INF/lib/a.jar BOOT-INF/lib/b.jar BOOT-INF/lib/c.jar
这种方法的优点是简单直接,但缺点是需要手动维护 MANIFEST.MF 文件,当 BOOT-INF/lib 目录下的 JAR 包发生变化时,需要手动更新 MANIFEST.MF 文件。
可以使用 Maven 或 Gradle 插件来自动生成包含完整 JAR 包列表的 MANIFEST.MF 文件。
例如,使用 Maven 的 maven-jar-plugin 插件可以实现这个功能:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<classpathPrefix>BOOT-INF/lib/</classpathPrefix>
<mainClass>com.example.Application</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
这个配置会自动将 BOOT-INF/lib 目录下的所有 JAR 包添加到 MANIFEST.MF 文件的 Class-Path 属性中。
4.2 使用 -cp 或 -classpath 参数
另一种解决方案是在执行 java 命令时,使用 -cp 或 -classpath 参数显式指定类路径。
例如:
java -cp "app.jar:BOOT-INF/lib/*" -XX:DumpLoadedClassList=classes.lst -Xshare:dump
其中,app.jar 是 Spring Boot 可执行 JAR 的名称,BOOT-INF/lib/* 表示 BOOT-INF/lib 目录下的所有 JAR 包。
这种方法的优点是无需修改 MANIFEST.MF 文件,但缺点是需要在每次执行 java 命令时都显式指定类路径。 此外,需要注意的是,在某些操作系统上,* 通配符可能无法正确展开,需要使用更具体的通配符或者手动列出所有 JAR 包。
更进一步的优化:编写脚本自动化
可以将这个过程封装成一个脚本,例如 Bash 脚本:
#!/bin/bash
APP_JAR="your-app.jar" # 你的 Spring Boot 可执行 JAR 名称
LIB_DIR="BOOT-INF/lib" # BOOT-INF/lib 目录
# 构建 classpath
CLASSPATH="$APP_JAR"
for jar in "$LIB_DIR"/*.jar; do
CLASSPATH="$CLASSPATH:$jar"
done
# 执行 dump
java -cp "$CLASSPATH" -XX:DumpLoadedClassList=classes.lst -Xshare:dump
echo "CDS dump 完成,classes.lst 已生成."
使用方法:
- 将
your-app.jar替换成你的 Spring Boot JAR 包的名字。 - 保存脚本为
dump_cds.sh,并赋予执行权限 (chmod +x dump_cds.sh)。 - 运行脚本:
./dump_cds.sh
这个脚本会自动构建包含所有 BOOT-INF/lib 下 JAR 包的 Classpath,并执行 java -cp 命令来进行 CDS dump。 后续的启动命令也需要使用相同的 classpath 构建方式。
5. 代码示例:验证 Class-Path 是否正确展开
为了验证 Class-Path 是否正确展开,我们可以编写一个简单的 Java 程序,打印出类加载器的搜索路径。
import java.net.URL;
import java.net.URLClassLoader;
public class ClasspathChecker {
public static void main(String[] args) {
ClassLoader cl = ClasspathChecker.class.getClassLoader();
if (cl instanceof URLClassLoader) {
URL[] urls = ((URLClassLoader) cl).getURLs();
System.out.println("Classpath URLs:");
for (URL url : urls) {
System.out.println(url.toString());
}
} else {
System.out.println("ClassLoader is not a URLClassLoader.");
}
}
}
将这个程序打包成一个 JAR 包,并添加到 Spring Boot 应用程序中。然后,在应用程序启动时,运行这个程序,查看输出的类路径是否包含 BOOT-INF/lib 目录下的所有 JAR 包。
5.1 如何集成到 Spring Boot 应用?
- 创建 Java 类: 将上面的
ClasspathChecker.java放到你的 Spring Boot 项目的源代码目录下,例如src/main/java/com/example/ClasspathChecker.java。 - 修改 Application 类 (或者创建一个 CommandLineRunner): 在你的 Spring Boot 应用的主类 (
Application.java) 中,或者创建一个实现了CommandLineRunner接口的类,加入以下代码来运行ClasspathChecker。
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.net.URL;
import java.net.URLClassLoader;
@Component
public class ClasspathCheckerRunner implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
ClassLoader cl = getClass().getClassLoader();
if (cl instanceof URLClassLoader) {
URL[] urls = ((URLClassLoader) cl).getURLs();
System.out.println("Classpath URLs:");
for (URL url : urls) {
System.out.println(url.toString());
}
} else {
System.out.println("ClassLoader is not a URLClassLoader.");
}
}
}
解释:
@Component: 将这个类注册为 Spring Bean,Spring Boot 会自动管理它的生命周期。CommandLineRunner: Spring Boot 应用启动后,run方法会被自动执行。getClass().getClassLoader(): 获取当前类的类加载器。- 剩下的代码和之前的
ClasspathChecker是一样的,用于打印 Classpath。
- 重新构建 Spring Boot 应用: 使用 Maven 或 Gradle 重新构建你的 Spring Boot 应用。
- 运行 Spring Boot 应用: 运行 Spring Boot 应用,查看控制台输出。你应该能看到
Classpath URLs:后面跟着一系列 URL,这些 URL 代表了你的 Classpath。 检查其中是否包含你期望的BOOT-INF/lib下的 JAR 包。
通过这个例子,我们可以明确地看到 Spring Boot 应用运行时使用的 Classpath,从而验证我们的配置是否正确,以及 JVM 在 CDS 归档时是否能正确识别这些 Classpath。
6. 注意事项
- 在使用动态 CDS 时,建议始终显式指定类路径,避免依赖
MANIFEST.MF文件中的Class-Path属性。 - 在 Linux/Unix 系统上,可以使用
find命令和xargs命令来生成包含所有 JAR 包的类路径。 - 在 Windows 系统上,可以使用 PowerShell 脚本来生成包含所有 JAR 包的类路径。
- 在测试 CDS 效果时,建议多次启动应用程序,并观察启动时间的变化。
7. CDS 归档之外的考量
除了 Classpath 问题,成功进行 CDS 归档还需要考虑其他因素,例如:
- JVM 版本: 不同版本的 JVM 对 CDS 的支持程度可能不同。 确保你使用的 JVM 版本支持动态 CDS。
- 内存设置: CDS 归档需要一定的内存空间。 如果内存不足,可能会导致归档失败。
- 类加载器: 自定义类加载器可能会影响 CDS 的效果。 尽量使用默认的类加载器。
- AOT 编译: 如果使用了 GraalVM Native Image 等 AOT 编译技术,CDS 的效果可能会受到影响。
概括一下
本文深入探讨了 Spring Boot 多层 JAR 结构中使用 JVM CDS 动态归档时可能遇到的 Class-Path 通配符未展开的问题,分析了问题的原因,并提供了两种解决方案。 显式指定类路径或者使用Maven插件更新MANIFEST.MF是可行的办法。
希望本文能够帮助大家更好地理解 JVM CDS 动态归档的原理和使用方法,并在实际项目中避免类似的问题。