简介:本文围绕三种主流的可执行 JAR 打包方案展开对比:轻量外置依赖方案、全量聚合方案与重定位防冲突方案。通过配置示例、目录结构与运行方式逐一拆解,帮助开发者理解各自适用边界,避开 ClassNotFoundException、依赖冲突等常见陷阱,并给出可落地的选型建议。
不少开发者都经历过这样的场景:本地单元测试全部通过,IDE 里点一下运行按钮程序跑得飞快,可一旦打成 JAR 包分发到测试环境或生产机器上,启动瞬间就抛出 ClassNotFoundException 或 NoClassDefFoundError。第一反应往往是怀疑代码逻辑,反复排查后才发现,问题根源在于打包环节——依赖没有被正确纳入或正确引用。
这个问题的本质是:Java 的 java -jar 启动方式只认 JAR 包内部结构,而标准的 mvn package 产物默认并不包含第三方依赖。要让一个 JAR 成为真正意义上的”可执行 JAR”,需要解决两件事:入口类声明(Main-Class)与依赖可见性(Class-Path 或依赖内联)。
围绕这两个目标,业界常见的技术方案可以归为三类,分别对应不同的取舍:轻量外置依赖、全量聚合、重定位防冲突。下面逐一展开。
在展开三种方案之前,有必要先厘清 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 清单属性建立引用关系。
<build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-jar-plugin</artifactId><version>3.2.0</version><configuration><archive><manifest><mainClass>org.example.App</mainClass><addClasspath>true</addClasspath><classpathPrefix>dependencies/</classpathPrefix></manifest></archive></configuration></plugin><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.1.1</version><executions><execution><phase>package</phase><goals><goal>copy-dependencies</goal></goals><configuration><outputDirectory>${project.build.directory}/dependencies/</outputDirectory><includeScope>runtime</includeScope></configuration></execution></executions></plugin></plugins></build>
执行 mvn clean package 后,目标目录大致如下:
target/├── app-1.0.0.jar # 仅含业务代码└── dependencies/ # 运行期依赖├── lib-a-2.1.jar└── 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 描述符。
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-assembly-plugin</artifactId><version>3.3.0</version><configuration><descriptorRefs><descriptorRef>jar-with-dependencies</descriptorRef></descriptorRefs><archive><manifest><mainClass>org.example.App</mainClass></manifest></archive></configuration><executions><execution><id>make-assembly</id><phase>package</phase><goals><goal>single</goal></goals></execution></executions></plugin>
target/├── app-1.0.0.jar # 原始业务 JAR└── 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 类插件提供。
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-shade-plugin</artifactId><version>3.2.4</version><executions><execution><phase>package</phase><goals><goal>shade</goal></goals><configuration><relocations><relocation><pattern>com.example.lib</pattern><shadedPattern>com.example.shaded.lib</shadedPattern></relocation></relocations><transformers><transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"><mainClass>org.example.App</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></excludes></filter></filters></configuration></execution></executions></plugin>
target/├── app-1.0.0.jar # 原始 JAR└── app-1.0.0-shaded.jar # 重定位后的可执行 JAR(部分版本会直接替换主产物)
配置中有三处值得强调:
relocation:将 com.example.lib 下的类整体迁移到 com.example.shaded.lib,业务代码中对原包的引用也会被同步改写。这适合依赖版本冲突频繁、或需要”内嵌”某个库而不污染宿主类路径的场景。ServicesResourceTransformer:合并 META-INF/services/ 下的 SPI 声明文件,避免聚合时相互覆盖导致服务加载失败。重定位方案能力最强,代价也最高:构建耗时增加,字节码改写可能影响反射、序列化、注解处理器等依赖类名的逻辑,需要对相关依赖做充分回归测试。对于依赖树简单、无版本冲突的项目,使用它属于过度设计。
| 维度 | 外置依赖 | 全量聚合 | 重定位防冲突 |
|---|---|---|---|
| 产物数量 | 主 JAR + 依赖目录 | 单一 JAR | 单一 JAR |
| 分发便利性 | 需保持目录结构 | 高 | 高 |
| 依赖冲突处理 | 不处理,靠外部协调 | 不处理,同名资源覆盖 | 通过命名空间隔离 |
| 构建速度 | 快 | 中等 | 较慢 |
| 增量发布友好度 | 高(依赖层可复用) | 低(每次全量重打) | 低 |
| 典型风险 | 路径失配、清单行长度 | 资源覆盖、签名失效 | 反射失效、测试成本高 |
| 推荐场景 | 服务端分层部署、容器镜像 | 命令行工具、简单应用 | 复杂依赖树、SDK 内嵌 |
选型不必追求”最强”,而应匹配分发方式与依赖复杂度。可以按下面的顺序做判断:
几个通用实践要点值得一并纳入构建规范:
Main-Class,避免手工维护清单。unzip -l 或 jar tf 检查清单与目录结构,用 java -jar 做一次冒烟启动,把问题拦在交付前。META-INF 下的签名文件。可执行 JAR 的打包并不是”配一个插件”那么简单,它本质上是在分发便利性、构建性能与依赖隔离性之间做权衡。外置依赖方案把复杂度留在部署结构上,聚合方案把复杂度压在构建阶段,重定位方案则用字节码改写换取更强的隔离能力。理解每种方案的产物结构与失效模式,才能在遇到 ClassNotFoundException 时快速定位到打包配置,而不是在业务代码里做无效排查。