《Java 编程入门》17.2 可执行 jar 与模块化 jar

把 library 的 class 文件打成可直接运行的 jar:读懂 jar 与 MANIFEST.MF,用 maven-jar-plugin 写 Main-Class,比较依赖复制与 maven-shade-plugin 的取舍,再补上 module-info、jar --describe-module、jdeps 与 jlink 的完整命令和真实输出。

本节目标:能把 library 打成 java -jar 直接运行的产物,知道 thin jar、shade jar、模块化 jar、jlink 镜像各自解决什么问题,并能用 jar、jdeps 验证产物。
适用版本:JDK 25 LTS(Temurin 25.0.5)、Maven 3.9.12。

从一堆 class 到一个文件

第 17.1 节结束时,library 的依赖已经清楚了:主代码只依赖 JDK,测试依赖不进入产物。但构建目录里仍然是一堆 .class:

target/classes/com/plume/library/LibraryApp.class
target/classes/com/plume/library/domain/Book.class
target/classes/com/plume/library/service/LibraryService.class
...

交付这样的目录,意味着对方要自己拼 classpath、自己记住主类名、自己处理目录层级。jar 文件把这些问题收进一个文件:它是 ZIP 格式,里面按包结构存放 class 和资源,再加一份 META-INF/MANIFEST.MF 描述元数据。

jar 里到底有什么

先看 library 打出来的 jar:

jar tf target/library.jar | head -14

本机输出:

META-INF/
META-INF/MANIFEST.MF
com/
com/plume/
com/plume/library/
com/plume/library/cli/
com/plume/library/domain/
com/plume/library/http/
com/plume/library/repository/
com/plume/library/service/
META-INF/maven/
META-INF/maven/com.plume/
META-INF/maven/com.plume/library/
com/plume/library/LibraryApp.class

目录项之后是 class 文件。META-INF/maven/ 下是 Maven 自动写入的 pom.xml 和 pom.properties,它们让使用方可以追溯这个 jar 是用哪份 POM 构建的。再往下是模块描述符和各个包。

真正让 jar「可执行」的是清单文件:

unzip -p target/library.jar META-INF/MANIFEST.MF
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.2
Build-Jdk-Spec: 25
Main-Class: com.plume.library.LibraryApp

Main-Class 告诉 java -jar 从哪个类开始执行。没有这一行,jar 仍然是一个合法的归档,只是 JVM 不知道入口在哪里。Build-Jdk-Spec: 25 记录构建 JDK 的版本,它也是 17.3 节讲可复现构建时要注意的一个变量。

用 maven-jar-plugin 生成可执行 jar

library 的 pom.xml 里,打包配置只有一段:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-jar-plugin</artifactId>
  <version>3.4.2</version>
  <configuration>
    <archive>
      <manifest>
        <mainClass>com.plume.library.LibraryApp</mainClass>
      </manifest>
    </archive>
  </configuration>
</plugin>

<mainClass> 会被写进 Main-Class。执行:

mvn -q clean package
java -jar target/library.jar

本机输出(节选):

library 0.1.0 (type help for commands)

输入 quit 退出:

bye

这个 jar 只有 32 KB 左右,因为 library 的主代码没有第三方依赖。测试用的 JUnit 虽然出现在 mvn dependency:tree 里,但 scope 是 test,不会被 maven-jar-plugin 打进产物。17.1 节用 unzip -l target/library.jar | grep -ci junit 验证过,结果是 0。

这种只含本项目 class 的 jar 通常叫 thin jar。它足够小,但要求运行时 classpath 上已经有全部依赖。library 卷一恰好没有运行时依赖,所以 thin jar 就是最终形态。

依赖多了以后:复制依赖还是 shade

卷二引入 JSON 库、JDBC 驱动之后,java -jar library.jar 会报 NoClassDefFoundError,因为依赖不在 jar 里。常见做法有两种。

做法一:把依赖复制到 lib/,在清单里写 Class-Path。 用 maven-dependency-plugin 的 copy-dependencies 把运行时依赖复制到 target/lib,再用 maven-jar-plugin 的 <manifest><addClasspath>true</addClasspath> 生成相对路径。产物是一个目录:

target/
├── library.jar
└── lib/
    ├── jackson-databind-2.x.jar
    └── ...

优点是依赖原样保留,排查版本容易;缺点是不能只发一个文件,目录结构必须一起拷贝。

做法二:用 maven-shade-plugin 打成 uber jar。 shade 把所有依赖的 class 解压、合并、重新打包进一个 jar。配置如下:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-shade-plugin</artifactId>
  <version>3.6.0</version>
  <executions>
    <execution>
      <phase>package</phase>
      <goals><goal>shade</goal></goals>
      <configuration>
        <createDependencyReducedPom>false</createDependencyReducedPom>
        <transformers>
          <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
            <mainClass>com.plume.library.LibraryApp</mainClass>
          </transformer>
        </transformers>
      </configuration>
    </execution>
  </executions>
</plugin>

在一份带 shade 配置的 library 副本上执行 mvn package,本机输出里有一行值得注意:

[WARNING] Discovered module-info.class. Shading will break its strong encapsulation.

library 有 module-info.java,shade 会把模块描述符合并进一个没有模块边界的大 jar,强封装随之失效。产物目录里同时出现两个文件:

target/library.jar
target/original-library.jar

library.jar 是 shade 后的版本,original-library.jar 是 shade 之前的 thin jar。createDependencyReducedPom=false 避免 shade 改写 POM 后影响其他构建步骤。

选择标准很直接:需要单文件分发、依赖不多、没有模块化需求,用 shade;需要保留依赖边界、排查方便,用 lib/ 目录;有 module-info 且在意封装,优先模块化 jar。

模块化 jar:把边界写进文件

library 在 src/main/java/module-info.java 里声明了模块:

module com.plume.library {
    requires jdk.httpserver;
    exports com.plume.library.domain;
    exports com.plume.library.service;
}

这三行分别做三件事:

  • requires jdk.httpserver:声明依赖 JDK 的 HTTP 服务器模块。com.sun.net.httpserver 不在 java.base 里,不写这行编译不过。
  • exports com.plume.library.domain:允许其他模块访问领域类型。
  • exports com.plume.library.service:允许其他模块访问服务层。

cli、http、repository 三个包没有 export,其他模块即使拿到这个 jar,也不能直接 import 里面的类。这是模块系统比 classpath 更强的地方:边界由模块描述符声明,而不是靠包名约定。

查看 jar 的模块信息:

jar --describe-module --file target/library.jar

本机输出:

com.plume.library@0.1.0 jar:file:///private/tmp/java_agent_f/library/target/library.jar!/module-info.class
exports com.plume.library.domain
exports com.plume.library.service
requires java.base mandated
requires jdk.httpserver
contains com.plume.library
contains com.plume.library.cli
contains com.plume.library.http
contains com.plume.library.repository
main-class com.plume.library.LibraryApp

requires java.base mandated 是每个模块都有的隐式依赖。contains 列出模块内未被 export 的包,它们仍然可以互相访问,只是不对外开放。

在模块路径上运行:

java --module-path target/library.jar \
  -m com.plume.library/com.plume.library.LibraryApp

本机输出(节选):

library 0.1.0 (type help for commands)
(no books)
bye

注意 -m 的写法是「模块名/主类名」。也可以省略主类名,只写 -m com.plume.library;这时 JVM 读取 module-info.class 里的 ModuleMainClass 属性。maven-jar-plugin 的 <mainClass> 会同时写进清单的 Main-Class 和模块描述符的 ModuleMainClass,所以两种写法都能启动。区别在解析路径:

命令模块处理适用场景
java -jar app.jarjar 放在 classpath,module-info 被忽略简单分发、老工具链
java -p app.jar -m module/mainjar 放在模块路径,强封装生效模块化应用、jlink

这也是一个常见困惑:同一个 jar,java -jar 能跑,不代表模块系统认它;反过来,模块路径能跑,也不代表 java -jar 会自动读取模块描述符。

jlink:连运行时一起交付

模块化的另一个收益是可以用 jlink 裁运行时。先用 jdeps 看 library 到底需要哪些 JDK 模块:

jdeps --print-module-deps --ignore-missing-deps target/classes
java.base,jdk.httpserver

只有两个模块。裁一个最小运行时:

jlink --add-modules java.base,jdk.httpserver \
  --output /tmp/java_agent_f/minjre \
  --strip-debug --no-header-files --no-man-pages
du -sh /tmp/java_agent_f/minjre

本机输出:

 45M	/tmp/java_agent_f/minjre

45 MB 仍然不算小,因为 java.base 本身就包含大量类。但相比完整 JDK 已经小了一个量级,而且这个目录可以随应用一起分发:

/tmp/java_agent_f/minjre/bin/java -jar target/library.jar

本机输出(节选):

library 0.1.0 (type help for commands)
(no books)
bye

jlink 的产物是平台相关的:在 macOS 上裁出来的镜像不能直接拿到 Linux 上运行。跨平台分发时,每个目标平台都要各自构建,这一点和「字节码跨平台」不是一回事。

怎么选

把四种形态放在一起:

形态产物依赖处理适合
thin jar一个 jar运行时自备 classpath库、内部工具、无第三方依赖
jar + lib/jar + 目录依赖原样复制依赖多、需要排查版本
shade jar一个 jar依赖合并进 jar单文件分发、无模块化需求
模块化 jar + jlinkjar + 运行时镜像模块路径解析需要封装、可控运行时

library 卷一选 thin jar,因为主代码没有第三方依赖,java -jar 已经足够。卷二引入依赖后,再根据是否需要单文件分发决定是否上 shade。模块化 jar 则留到需要强封装或裁剪运行时的场景。

常见错误与边界

「jar 里没有 Main-Class。」 用 jar --create 手工打一个不带清单的包,执行 java -jar:

/tmp/java_agent_f/no-main.jar中没有主清单属性

英文环境的提示是 no main manifest attribute。这不是 jar 损坏,而是清单里缺 Main-Class。

「shade 之后模块信息还在。」 shade 会合并 module-info.class,但合并后的模块边界通常不再完整。上面的警告已经说明「strong encapsulation 会被破坏」。如果项目依赖模块封装,不要把 shade 当成默认选项。

「java -jar 会自动用模块路径。」 不会。-jar 模式下 jar 在 classpath 上,module-info.class 被忽略。要验证模块声明,必须显式用 --module-path 和 -m。

「jlink 镜像是跨平台的。」 不是。jlink 产出的是当前平台的运行时,包含本地库和启动器。跨平台要分别构建,或者回到「装一个完整 JDK」的方案。

小结

  • jar 是 ZIP 加 META-INF/MANIFEST.MF;Main-Class 决定 java -jar 的入口。
  • maven-jar-plugin 生成 thin jar,library 的产物约 32 KB,测试依赖不会进入。
  • 依赖变多后,可以选择 lib/ 目录加 Class-Path,或用 maven-shade-plugin 打 uber jar。
  • shade 遇到 module-info.class 会警告强封装被破坏,模块化项目要谨慎。
  • module-info.java 用 requires 和 exports 声明边界;jar --describe-module 可以验证。
  • jdeps 能列出所需 JDK 模块,jlink 据此裁出约 45 MB 的运行时镜像。
  • java -jar 与 java -p ... -m ... 走的是两条不同的解析路径,不能用前者验证后者。

产物能运行之后,还有一个更隐蔽的问题:同样的源码,在不同时间、不同机器上构建出来的 jar,字节是否完全一样?下一节用 outputTimestamp、固定依赖和校验和,把「可复现」落到命令上。

阅读导航:上一节:17.1 Maven 依赖、作用域与冲突 · 下一节:17.3 版本、发布与可复现构建 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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