三种方式打 Java 可执行 JAR 包,你用对了吗?

作者:php是最好的2026.11.07 02:16浏览量:0

简介:本文围绕三种主流的可执行 JAR 打包方案展开对比:轻量外置依赖方案、全量聚合方案与重定位防冲突方案。通过配置示例、目录结构与运行方式逐一拆解,帮助开发者理解各自适用边界,避开 ClassNotFoundException、依赖冲突等常见陷阱,并给出可落地的选型建议。

三种方式打 Java 可执行 JAR 包,你用对了吗?

不少开发者都经历过这样的场景:本地单元测试全部通过,IDE 里点一下运行按钮程序跑得飞快,可一旦打成 JAR 包分发到测试环境或生产机器上,启动瞬间就抛出 ClassNotFoundException 或 NoClassDefFoundError。第一反应往往是怀疑代码逻辑,反复排查后才发现,问题根源在于打包环节——依赖没有被正确纳入或正确引用。

这个问题的本质是:Java 的 java -jar 启动方式只认 JAR 包内部结构,而标准的 mvn package 产物默认并不包含第三方依赖。要让一个 JAR 成为真正意义上的”可执行 JAR”,需要解决两件事:入口类声明(Main-Class)与依赖可见性(Class-Path 或依赖内联)。

围绕这两个目标,业界常见的技术方案可以归为三类,分别对应不同的取舍:轻量外置依赖、全量聚合、重定位防冲突。下面逐一展开。

一、先理解可执行 JAR 的基本契约

在展开三种方案之前,有必要先厘清 JAR 规范中与”可执行”相关的几个关键元数据。它们都写在 META-INF/MANIFEST.MF 中:

  • Main-Class:指定 java -jar 时的启动入口类,必须包含 public static void main(String[]) 方法。
  • Class-Path:声明额外的类路径条目,通常是相对于该 JAR 所在目录的相对路径,多个条目以空格分隔。注意它不支持通配符通配整个目录,因此需要显式列出每个依赖 JAR,或者由构建工具生成完整列表。
  • Multi-Release、Automatic-Module-Name 等:与模块化相关,此处不展开。

理解这两项元数据后,三种方案的差异就非常直观了:要么把依赖”放在外面并告诉 JAR 去哪里找”,要么把依赖”塞进 JAR 内部”。而塞进内部的方式,又分为简单合并与重定位合并两种。

二、方案一:轻量外置依赖——结构清晰,便于分层

这种方案的思路是:构建产物只包含业务代码,第三方依赖由构建过程复制到独立的目录中,再通过 Class-Path 清单属性建立引用关系。

配置示例

  1. <build>
  2. <plugins>
  3. <plugin>
  4. <groupId>org.apache.maven.plugins</groupId>
  5. <artifactId>maven-jar-plugin</artifactId>
  6. <version>3.2.0</version>
  7. <configuration>
  8. <archive>
  9. <manifest>
  10. <mainClass>org.example.App</mainClass>
  11. <addClasspath>true</addClasspath>
  12. <classpathPrefix>dependencies/</classpathPrefix>
  13. </manifest>
  14. </archive>
  15. </configuration>
  16. </plugin>
  17. <plugin>
  18. <groupId>org.apache.maven.plugins</groupId>
  19. <artifactId>maven-dependency-plugin</artifactId>
  20. <version>3.1.1</version>
  21. <executions>
  22. <execution>
  23. <phase>package</phase>
  24. <goals>
  25. <goal>copy-dependencies</goal>
  26. </goals>
  27. <configuration>
  28. <outputDirectory>${project.build.directory}/dependencies/</outputDirectory>
  29. <includeScope>runtime</includeScope>
  30. </configuration>
  31. </execution>
  32. </executions>
  33. </plugin>
  34. </plugins>
  35. </build>

产物结构

执行 mvn clean package 后,目标目录大致如下:

  1. target/
  2. ├── app-1.0.0.jar # 仅含业务代码
  3. └── dependencies/ # 运行期依赖
  4. ├── lib-a-2.1.jar
  5. └── lib-b-3.4.jar

运行时需要保持两者的相对位置关系,执行 java -jar target/app-1.0.0.jar 即可,Class-Path 中的 dependencies/ 前缀正是相对于该 JAR 所在目录解析的。

适用场景与注意事项

这种方案的优势非常明确:业务代码与依赖分离,增量发布时可以只替换业务 JAR,镜像分层构建时也能让依赖层充分复用缓存。同时,产物体积小、构建快,依赖清单在文件系统中一目了然,排查版本问题相对容易。

它的约束也同样明显:分发时必须保持目录结构完整,不能只拷贝单个 JAR;如果依赖被放在多级目录或名称含空格,Class-Path 的解析容易出问题;此外,Class-Path 的长度在极端依赖数量下可能触及清单文件行长限制,需要对长行做折行处理(规范要求每行不超过 72 字节,续行以单个空格开头)。多数构建插件会自动处理折行,但自行编写清单时需留意。

三、方案二:全量聚合——一个文件搞定分发

全量聚合方案把业务代码与所有依赖解压后合并进同一个 JAR,形成”全家桶”式的单一产物。常见实现是 assembly 类插件配置 jar-with-dependencies 描述符。

配置示例

  1. <plugin>
  2. <groupId>org.apache.maven.plugins</groupId>
  3. <artifactId>maven-assembly-plugin</artifactId>
  4. <version>3.3.0</version>
  5. <configuration>
  6. <descriptorRefs>
  7. <descriptorRef>jar-with-dependencies</descriptorRef>
  8. </descriptorRefs>
  9. <archive>
  10. <manifest>
  11. <mainClass>org.example.App</mainClass>
  12. </manifest>
  13. </archive>
  14. </configuration>
  15. <executions>
  16. <execution>
  17. <id>make-assembly</id>
  18. <phase>package</phase>
  19. <goals>
  20. <goal>single</goal>
  21. </goals>
  22. </execution>
  23. </executions>
  24. </plugin>

产物结构

  1. target/
  2. ├── app-1.0.0.jar # 原始业务 JAR
  3. └── app-1.0.0-jar-with-dependencies.jar # 聚合后的可执行 JAR

后者可以直接 java -jar 运行,无需任何外部目录配合。

适用场景与注意事项

聚合方案最大的价值是分发极简:一个文件即可交付,适合命令行工具、批处理任务、一次性脚本化部署等场景。

但它有两个需要正视的问题。其一是同名资源覆盖:多个依赖中若存在路径相同的文件(如 META-INF/services/ 下的 SPI 声明、reference.conf、spring.factories 等),合并时后写入者会覆盖先写入者,导致部分服务发现机制失效。其二是签名失效:依赖 JAR 中若带有 META-INF/*.SF、*.DSA 等签名文件,合并后这些签名不再匹配,运行时会报安全异常,通常需要在打包时排除这些文件。此外,产物体积等于所有依赖之和,重复打包时传输成本较高。

四、方案三:重定位防冲突——面向复杂依赖树

重定位方案同样产出单一 JAR,但在合并过程中会对依赖的包路径做字节码级别的改写,把指定依赖的类迁移到带前缀的命名空间下,从而避免与业务代码或其他依赖的版本冲突。这类能力通常由 shade 类插件提供。

配置示例

  1. <plugin>
  2. <groupId>org.apache.maven.plugins</groupId>
  3. <artifactId>maven-shade-plugin</artifactId>
  4. <version>3.2.4</version>
  5. <executions>
  6. <execution>
  7. <phase>package</phase>
  8. <goals>
  9. <goal>shade</goal>
  10. </goals>
  11. <configuration>
  12. <relocations>
  13. <relocation>
  14. <pattern>com.example.lib</pattern>
  15. <shadedPattern>com.example.shaded.lib</shadedPattern>
  16. </relocation>
  17. </relocations>
  18. <transformers>
  19. <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
  20. <mainClass>org.example.App</mainClass>
  21. </transformer>
  22. <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
  23. </transformers>
  24. <filters>
  25. <filter>
  26. <artifact>*:*</artifact>
  27. <excludes>
  28. <exclude>META-INF/*.SF</exclude>
  29. <exclude>META-INF/*.DSA</exclude>
  30. <exclude>META-INF/*.RSA</exclude>
  31. </excludes>
  32. </filter>
  33. </filters>
  34. </configuration>
  35. </execution>
  36. </executions>
  37. </plugin>

产物结构与关键处理

  1. target/
  2. ├── app-1.0.0.jar # 原始 JAR
  3. └── app-1.0.0-shaded.jar # 重定位后的可执行 JAR(部分版本会直接替换主产物)

配置中有三处值得强调:

  1. relocation:将 com.example.lib 下的类整体迁移到 com.example.shaded.lib,业务代码中对原包的引用也会被同步改写。这适合依赖版本冲突频繁、或需要”内嵌”某个库而不污染宿主类路径的场景。
  2. ServicesResourceTransformer:合并 META-INF/services/ 下的 SPI 声明文件,避免聚合时相互覆盖导致服务加载失败。
  3. 签名文件过滤:与聚合方案同理,必须排除依赖自带的签名文件,否则运行期校验会失败。

适用场景与注意事项

重定位方案能力最强,代价也最高:构建耗时增加,字节码改写可能影响反射、序列化、注解处理器等依赖类名的逻辑,需要对相关依赖做充分回归测试。对于依赖树简单、无版本冲突的项目,使用它属于过度设计。

五、三种方案横向对比

维度 外置依赖 全量聚合 重定位防冲突
产物数量 主 JAR + 依赖目录 单一 JAR 单一 JAR
分发便利性 需保持目录结构 高 高
依赖冲突处理 不处理,靠外部协调 不处理,同名资源覆盖 通过命名空间隔离
构建速度 快 中等 较慢
增量发布友好度 高(依赖层可复用) 低(每次全量重打) 低
典型风险 路径失配、清单行长度 资源覆盖、签名失效 反射失效、测试成本高
推荐场景 服务端分层部署、容器镜像 命令行工具、简单应用 复杂依赖树、SDK 内嵌

六、选型建议与实践要点

选型不必追求”最强”,而应匹配分发方式与依赖复杂度。可以按下面的顺序做判断:

  1. 是否需要单文件交付? 如果部署环境不便维护多文件结构,直接排除外置依赖方案。
  2. 依赖树是否存在明显冲突或多版本共存需求? 若存在,优先考虑重定位;若依赖单一且版本统一,聚合方案足够。
  3. 是否需要频繁增量发布? 服务端场景下,外置依赖配合容器分层构建往往能显著降低发布体积与时间。

几个通用实践要点值得一并纳入构建规范:

  • 统一入口声明:无论哪种方案,都在构建配置中显式指定 Main-Class,避免手工维护清单。
  • 锁定插件版本:打包插件的行为在不同版本间存在差异(如产物命名、是否替换主 JAR),锁定版本可保证构建可重现。
  • 验证产物:打包后可用 unzip -l 或 jar tf 检查清单与目录结构,用 java -jar 做一次冒烟启动,把问题拦在交付前。
  • 排除签名文件:任何做依赖合并的方案,都应过滤 META-INF 下的签名文件。
  • 保留 SPI 合并器:涉及服务发现机制的依赖,务必启用对应的资源合并转换器。

结语

可执行 JAR 的打包并不是”配一个插件”那么简单,它本质上是在分发便利性、构建性能与依赖隔离性之间做权衡。外置依赖方案把复杂度留在部署结构上,聚合方案把复杂度压在构建阶段,重定位方案则用字节码改写换取更强的隔离能力。理解每种方案的产物结构与失效模式,才能在遇到 ClassNotFoundException 时快速定位到打包配置,而不是在业务代码里做无效排查。