《Java 编程实战》16.1 打包:fat jar、jlink 与运行时镜像

mvn package 默认只产出几十 KB 的瘦 jar,离开 Maven 就找不到依赖。本节用 Shade 3.6.2 生成可执行的 libraryd fat jar,处理 SPI 与签名文件,再用 jdeps 和 jlink 裁出 53 MB 运行时,并给出真实的 jar 大小与启动耗时。

本节目标:把 libraryd 打成一台没有 Maven、没有源码的机器也能直接运行的产物,并算清 jar 大小和启动成本。
适用版本:JDK 25(Temurin 25.0.5)、Maven Shade 3.6.2、Maven JAR Plugin 3.4.1、jlink/jdeps(JDK 25 自带)。

承接的问题

在 IDE 里点运行,libraryd 能启动;用 mvn exec:java 也能启动。但交付给运维时,这些方式都不成立。运维机器上只有 JRE,没有 Maven,也没有项目的 ~/.m2 仓库。

先看默认的 mvn package 产出什么。libraryd-app 模块自己的代码只有几个类,打出来的 jar 很小,依赖全部在外部:

libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar    约 20 KB

直接运行会得到:

no main manifest attribute, in libraryd-app-1.0.0-SNAPSHOT.jar

即使补上 Main-Class,下一步也会变成:

Exception in thread "main" java.lang.NoClassDefFoundError: io/javalin/Javalin

因为 Javalin、Jackson、HikariCP、Flyway、Logback 都不在 jar 里。要让它独立运行,有两条路:

  • 把所有依赖打进一个 fat jar(uber jar),用 java -jar 启动。
  • 保留模块化 classpath,用 jlink 裁一个运行时镜像,再把应用和依赖放进去。

libraryd 两条都做:Shade 负责可执行 fat jar,jlink 负责更小的 JRE。先看 Shade。

核心机制一:Shade 合并依赖并写 Main-Class

libraryd-app/pom.xml 里的 Shade 配置是:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-shade-plugin</artifactId>
  <executions>
    <execution>
      <phase>package</phase>
      <goals>
        <goal>shade</goal>
      </goals>
      <configuration>
        <createDependencyReducedPom>false</createDependencyReducedPom>
        <shadedArtifactAttached>false</shadedArtifactAttached>
        <transformers>
          <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
            <mainClass>com.plume.library.AppMain</mainClass>
          </transformer>
          <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
        </transformers>
        <filters>
          <filter>
            <artifact>*:*</artifact>
            <excludes>
              <exclude>META-INF/*.SF</exclude>
              <exclude>META-INF/*.DSA</exclude>
              <exclude>META-INF/*.RSA</exclude>
              <exclude>module-info.class</exclude>
            </excludes>
          </filter>
        </filters>
      </configuration>
    </execution>
  </executions>
</plugin>

几个参数的作用:

  • phase=package:在 jar 打完之后执行,输入是 libraryd-app 的原始 jar 和它的运行时依赖。
  • createDependencyReducedPom=false:不生成 dependency-reduced-pom.xml。生成它会让 reactor 里的依赖关系变复杂,特别是 libraryd-integration-tests 依赖 libraryd-app 时。保持 false,POM 不变。
  • shadedArtifactAttached=false:用 shaded jar 替换主 artifact,最终路径就是 libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar。如果设为 true,会多出一个带 -shaded 后缀的 jar,部署时容易拿错。
  • ManifestResourceTransformer:写 Main-Class: com.plume.library.AppMain。没有它,java -jar 仍然不知道入口。
  • ServicesResourceTransformer:合并 META-INF/services 下的 SPI 文件,下一节展开。
  • filters:排除签名文件和 module-info.class。签名文件留在 fat jar 里会让 JVM 校验失败;module-info.class 是模块描述符,放在 classpath fat jar 里没有意义,还可能让某些工具误判。

构建命令和本机输出:

mvn -q -DskipTests package
real 18.70
user 14.18
sys 1.15

18.7 秒包含了编译五个模块、打包和 Shade 合并。这个仓库把 Checkstyle、SpotBugs、JaCoCo 只放在父 POM 的 pluginManagement 里,没有绑定到默认生命周期,所以这个数字不含静态分析;如果团队在 verify 阶段绑定了它们,构建时间会更长。产物大小:

-rw-r--r--  1 user  staff  25532403  libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar

25,532,403 字节,约 24 MB。这个大小来自 Javalin/Jetty、Jackson、HikariCP、Flyway、PostgreSQL JDBC、Logback、Micrometer、OpenTelemetry、Caffeine、Jedis 等依赖。fat jar 的优点是“一个文件拿走就能跑”,缺点是“每次改一行代码,整个 24 MB 都要重新传输”。容器化会在第 16.2 节解决传输问题。

核心机制二:SPI 文件不能简单覆盖

fat jar 最容易踩的坑不是 Main-Class,而是 META-INF/services。

JDK 的 ServiceLoader 通过 META-INF/services/<接口全限定名> 找实现。很多库靠它工作:

  • JDBC 驱动通过 java.sql.Driver 注册。
  • SLF4J 通过 org.slf4j.spi.SLF4JServiceProvider 找 Logback。
  • Jackson 通过 com.fasterxml.jackson.databind.Module 找扩展模块。
  • Flyway 通过 org.flywaydb.core.extensibility.Plugin 找数据库支持。
  • Jakarta Validation 通过 jakarta.validation.spi.ValidationProvider 找 Hibernate Validator。

如果 Shade 只是把多个 jar 的文件简单合并,同名文件会互相覆盖,最后只剩一个。表现可能是:

ServiceConfigurationError: jakarta.validation.NoProviderFoundException:
Unable to create a Configuration, because no Jakarta Bean Validation provider could be found.

或者 Flyway 启动时报“不支持的数据库”,因为 PostgreSQL 的插件文件被覆盖了。

ServicesResourceTransformer 的做法是读取所有同名 SPI 文件,把行合并后写回一个文件。它不关心接口名,按路径合并,所以对 JDBC、SLF4J、Jackson、Flyway 都有效。

可以用 unzip -l 检查合并结果:

unzip -l libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar \
  | grep 'META-INF/services'

本机输出里能看到 org.flywaydb.core.extensibility.Plugin 有 3102 字节,说明多个 Flyway 插件的服务声明被合并到了一起;如果只有几十字节,通常意味着覆盖而不是合并。

签名文件是另一个坑。某些依赖 jar 带 META-INF/*.SF、*.DSA、*.RSA。Shade 把这些文件带进 fat jar 后,JVM 会认为整个 jar 被签名,但合并后的内容已经和签名不匹配,启动时报:

java.lang.SecurityException: Invalid signature file digest for Manifest main attributes

过滤器里的三个 exclude 就是为此准备的。它们只影响 fat jar,不影响依赖本身。

核心机制三:Multi-Release 与 module-info

JDK 9 之后,很多 jar 是 multi-release jar:同一个类在 META-INF/versions/9/、META-INF/versions/17/ 下有不同实现,JVM 根据运行版本选择。要启用这个行为,jar 的 MANIFEST.MF 必须有:

Multi-Release: true

libraryd 当前的 fat jar manifest 是:

Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.1
Build-Jdk-Spec: 25
Main-Class: com.plume.library.AppMain

没有 Multi-Release: true。unzip -l 却能看到 META-INF/versions/9/module-info.class 和 META-INF/versions/17/module-info.class。这两件事并不矛盾:Shade 把版本化目录带进来了,但 manifest 没声明,JVM 会忽略这些版本化条目。

对 libraryd 来说,这没有实际影响,因为被带进来的版本化文件主要是 module-info.class,不是业务类。但如果某个依赖真的靠 META-INF/versions/11/ 提供 Java 11+ 的实现,缺少 Multi-Release: true 就会悄悄退回旧实现。要显式声明,可以在 ManifestResourceTransformer 里加:

<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
  <mainClass>com.plume.library.AppMain</mainClass>
  <manifestEntries>
    <Multi-Release>true</Multi-Release>
  </manifestEntries>
</transformer>

ManifestResourceTransformer 在 Shade 3.6.2 里支持 manifestEntries,它会把条目写进最终 manifest。另一个位置是 maven-jar-plugin 的 <archive><manifestEntries>,但 Shade 会重新生成 manifest,两个地方都配时要确认最终值。对 fat jar,优先在 Shade 的 transformer 里配。

module-info.class 的处理也要分清:

  • 根目录的 module-info.class 被过滤器排除,因为它描述的是某个依赖的模块,不是 fat jar 的模块。
  • META-INF/versions/*/module-info.class 没有被排除,因为过滤器只写了 module-info.class,匹配不到带路径的版本化文件。它们留在 jar 里,但在没有 Multi-Release: true 时不会生效。

这不是“配置漏了”,而是 fat jar 和 JPMS 本身的冲突:fat jar 是 classpath 产物,不是模块化产物。下一节的 jlink 也只解决运行时,不解决应用模块化。

fat jar 需要一台装了 JDK/JRE 的机器。完整 JDK 25 在本机是 294 MB,而 libraryd 只用其中一部分模块。jdeps 可以分析 fat jar 依赖哪些 JDK 模块:

jdeps --ignore-missing-deps --print-module-deps --multi-release 25 \
  libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar

本机输出:

java.base,java.desktop,java.instrument,java.naming,java.rmi,java.scripting,java.security.jgss,java.sql,jdk.management,jdk.unsupported

这十个模块覆盖了 JDBC、Javalin/Jetty 的命名与安全、Jackson 的桌面类型、Micrometer 的管理接口等。然后用 jlink 生成运行时镜像:

jlink \
  --add-modules "$(jdeps --ignore-missing-deps --print-module-deps --multi-release 25 \
      libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar)" \
  --output /tmp/libraryd-runtime \
  --strip-debug \
  --no-header-files \
  --no-man-pages \
  --compress=zip-6

本机结果:

53M     /tmp/libraryd-runtime
17      # /tmp/libraryd-runtime/bin/java --list-modules | wc -l
294M    # 完整 JDK 25
69      # 完整 JDK 25 的模块数

运行时从 294 MB 降到 53 MB,模块数从 69 降到 17。用这个运行时启动同一个 fat jar:

/tmp/libraryd-runtime/bin/java -version
openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, mixed mode)

再用它跑 libraryd,/health 返回 {"status":"UP","checks":{"db":"UP"}}。这说明裁出来的运行时足够支撑应用,不是“能启动但一连数据库就缺类”。

jlink 的限制要说清楚:

  • jlink 生成的是 运行时镜像,不是应用镜像。它不会把 libraryd 变成一个模块,也不会把 fat jar 模块化。应用仍然以 classpath 方式运行。
  • 如果依赖是非模块化 jar(automatic module),不能直接把应用 --module-path 丢给 jlink 做应用链接。automatic module 的名字不稳定,split package 也会让链接失败。
  • jdeps --ignore-missing-deps 会隐藏缺失依赖。它能算出“当前 classpath 上能看到的模块”,但反射、ServiceLoader、JDBC 驱动注册可能在运行时才加载模块。比如 PostgreSQL 使用 SSL 时可能需要 jdk.crypto.ec,jdeps 不一定能推断出来。遇到 NoSuchAlgorithmException 或 TLS 握手失败,先补 --add-modules jdk.crypto.ec。
  • 需要非英语 locale 或完整时区数据时,加 jdk.localedata;需要字体或桌面特性时,java.desktop 已经被包含,但 jdk.localedata 不会自动进来。

如果目标是“连 JRE 都不用装”,下一步是 jpackage:它可以把运行时镜像和 fat jar 打成一个平台安装包(macOS 的 .pkg/.dmg、Linux 的 .deb/.rpm)。jpackage 的平台绑定很强,跨平台构建仍然要靠对应平台的工具链。容器化在第 16.2 节提供另一种跨平台交付方式。

在 libraryd 里落地:24 MB jar 的真实启动成本

打包后的完整启动命令:

export LIBRARYD_DB_URL=jdbc:postgresql://127.0.0.1:55436/libraryd
export LIBRARYD_DB_USER=libraryd
export LIBRARYD_DB_PASSWORD=libraryd
export LIBRARYD_HTTP_PORT=18081
export LIBRARYD_POOL_SIZE=5
export LIBRARYD_LOG_LEVEL=INFO

java -jar libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar

本机第一次启动(数据库为空,Flyway 执行 V1、V2)从进程启动到 /health 返回 200 用了 2.55 秒。日志里的关键行:

HikariPool - Start completed.
Flyway ... Successfully applied 2 migrations to schema "public", now at version v2
Javalin started in 176ms
libraryd started on port 18081 (db=jdbc:postgresql://127.0.0.1:55436/libraryd, poolSize=5)
libraryd is ready: http://localhost:18081

第二次启动(迁移已应用)在本机测得 2.325 秒和 1.976 秒。这个数字包含 JVM 启动、Hikari 建池、Flyway 校验、Javalin 启动和扫描器调度。它不是一个跨机器可比的基准,但能说明:24 MB 的 fat jar 在普通开发机上启动是秒级,不是几十秒。

如果只关心“jar 能不能独立跑”,到这里就够了。下一节把这个 jar 放进容器,解决“目标机器连 JRE 都没有”的问题,并比较 Dockerfile 与 Jib 两种构建方式。

常见错误与边界

错误一:只配了 Main-Class,没配 ServicesResourceTransformer。 应用能启动,但一用 Flyway 就报找不到 PostgreSQL 插件,或者 Hibernate Validator 报 NoProviderFoundException。fat jar 的 SPI 文件必须合并,不能覆盖。

错误二:把签名文件留在 fat jar 里。 启动时报 Invalid signature file digest for Manifest main attributes。过滤器要排除 META-INF/*.SF、*.DSA、*.RSA,不要试图重新签名整个 fat jar。

错误三:用 java -cp libraryd-app.jar com.plume.library.AppMain 代替 java -jar。 这样能绕过 manifest 问题,但会把 -cp 的写法带进启动脚本,运维容易漏掉依赖或写错路径。既然已经生成了 fat jar,就用 java -jar。

错误四:以为 jlink 会把应用也模块化。 jlink 只裁 JDK 模块。应用仍然是 classpath 上的 fat jar。要把应用做成模块,需要给每个模块写 module-info.java,处理所有依赖的 automatic module 名和 split package,成本和收益要单独评估。

错误五:jdeps 输出少了模块就直接上线。 --ignore-missing-deps 会让分析继续,但结果是“在缺失依赖的前提下能看到的模块”。运行时才加载的驱动、加密提供者、字符集可能不在列表里。上线前用裁出的运行时跑一遍完整端到端测试,比只看 jdeps 输出可靠。

边界:jar 大小不等于镜像大小。 24 MB 是压缩后的 jar;解压后类文件更多。容器镜像还包含基础 JRE、操作系统层和元数据。16.2 节会看到,基于 eclipse-temurin:25-jre-alpine 的镜像在本机是 352 MB,jar 只占其中很小一部分。

边界:可复现构建。 Shade 默认会把时间戳写进 jar,两次构建的字节可能不同。需要可复现产物时,配置 project.build.outputTimestamp,并确认 Shade 是否尊重它。libraryd 当前没有开启可复现构建,CI 只校验“能构建、能启动”,不校验字节一致。

小结

  • 默认 mvn package 产出的是瘦 jar,离开 Maven 仓库无法运行。
  • Shade 在 package 阶段合并依赖,ManifestResourceTransformer 写 Main-Class,ServicesResourceTransformer 合并 SPI。
  • 过滤器要排除签名文件和根目录 module-info.class;版本化 META-INF/versions/*/module-info.class 仍会保留,但没有 Multi-Release: true 时不会生效。
  • libraryd 的 fat jar 是 25,532,403 字节(约 24 MB),本机首次启动约 2.55 秒,迁移已应用后约 2 秒。
  • jdeps 给出十个 JDK 模块,jlink 裁出的运行时是 53 MB / 17 个模块,完整 JDK 是 294 MB / 69 个模块。
  • jlink 只裁运行时,不模块化应用;反射和 ServiceLoader 加载的模块可能漏掉,要用完整测试兜底。

fat jar 解决了“依赖在哪”,但没有解决“目标机器有没有 JRE、用什么用户跑、怎么探活”。下一节把 jar 放进多阶段 Dockerfile,并用 Jib 在不依赖 Docker daemon 的情况下构建镜像。

阅读导航:上一节:15.3 端到端与 CI:把服务跑起来验证 · 下一节:16.2 容器化:多阶段 Dockerfile 与 Jib 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发