JVM CDS动态归档在Spring Boot多层JAR结构中Class-Path通配符未展开?JarLauncher与Archive.getNestedArchives()

JVM CDS 动态归档在 Spring Boot 多层 JAR 结构中 Class-Path 通配符未展开:JarLauncher 与 Archive.getNestedArchives()

大家好,今天我们来深入探讨一个在 Spring Boot 多层 JAR 结构中使用 JVM CDS (Class Data Sharing) 动态归档时可能遇到的问题:Class-Path 通配符未展开,以及这与 JarLauncherArchive.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 包的列表。

这会导致以下问题:

  1. 只有部分类被归档: 只有应用程序直接依赖的类(即不在 BOOT-INF/lib 下的类)会被归档,而 BOOT-INF/lib 下的类则不会被归档。
  2. 归档失败: 如果应用程序依赖的类都在 BOOT-INF/lib 下,那么 JVM 可能会因为找不到应用程序的主类而导致归档失败。
  3. 后续启动速度慢: 因为没有充分利用 CDS,后续启动时仍然需要重新加载和验证大量的类,启动速度无法得到有效提升。

3. 原因分析:JarLauncher 与 Archive.getNestedArchives()

要理解这个问题的原因,我们需要了解 Spring Boot 的 JarLauncher 以及 Archive.getNestedArchives() 方法的作用。

3.1 JarLauncher

JarLauncher 是 Spring Boot 用于启动可执行 JAR 的一个类。它的主要作用是:

  1. 解析 MANIFEST.MF 文件,获取应用程序的元数据,例如 Main-Class 和 Class-Path。
  2. 根据 Class-Path 构建类加载器。
  3. 启动应用程序的主类。

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);
    }
}

JarFileArchiveArchive 接口的一个实现,用于表示 JAR 文件。JarFileArchivegetNestedArchives() 方法会遍历 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.jarb.jarc.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 已生成."

使用方法:

  1. your-app.jar 替换成你的 Spring Boot JAR 包的名字。
  2. 保存脚本为 dump_cds.sh,并赋予执行权限 (chmod +x dump_cds.sh)。
  3. 运行脚本: ./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 应用?

  1. 创建 Java 类: 将上面的 ClasspathChecker.java 放到你的 Spring Boot 项目的源代码目录下,例如 src/main/java/com/example/ClasspathChecker.java
  2. 修改 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。
  1. 重新构建 Spring Boot 应用: 使用 Maven 或 Gradle 重新构建你的 Spring Boot 应用。
  2. 运行 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 动态归档的原理和使用方法,并在实际项目中避免类似的问题。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注