这段时间在交流群里看到一位同学问了个很有意思的问题:某个项目的pom.xml里只声明了maven-shade-plugin,没有配maven-jar-plugin,但mvn package之后却打出了一个带全部依赖、能直接java -jar跑起来的 Fat JAR。他百思不解:Fat JAR 不是通常要maven-jar-plugin配 manifest、再用maven-dependency-plugin复制 classpath 才能做出来吗?怎么一个插件就搞定了?
答案其实不复杂,但背后牵扯到 Maven 生命周期、插件绑定关系、以及“默认隐藏插件”这几个容易被忽略的机制。这篇内容不聊花哨的进阶玩法,就是想把“单个插件出 Fat JAR”这件事的来龙去脉讲透,帮助刚开始接触 Maven 打包的人建立正确的心智模型。看完你能理解普通 JAR 与 Fat JAR 差在哪、shade/assembly/spring-boot 这些插件之间到底怎么协作,以后看别人的 pom 也不会再被“只看到一个插件”的表象搞晕。
1. 你看到的“一个插件”,其实背后还有两个隐藏参与者
1.1 phase、goal、binding 这三件事必须先分开
在 Maven 里,构建过程不是一个插件从头干到尾,而是把不同插件的不同“目标(goal)”按顺序挂到“生命周期阶段(phase)”上面。validate、compile、test、package、verify、install、deploy这些是阶段,阶段只是时间节点;jar:jar、shade:shade、dependency:copy-dependencies这些才是具体动作。
你可以把 phase 理解成流水线上的某个工位,goal 是工位上执行的操作。一个工位可以站好几个操作员,也就是说同一个 phase 下可以挂多个插件的多个 goal。所以你在 pom 里写<plugin>并不是“我这次构建只用这一个插件”,而是“我再往流水线对应工位加一个操作员”。
一旦建立起这个认知,“为什么只有一个插件也能打出 Fat JAR”这个问题就已经有一半答案了:因为 pom 中显式声明的插件,和 Maven 核心生命周期默认绑定的插件,是两套来源。前者看得见,后者经常被忽略。
1.2 packaging=jar 的时候,jar 插件一直都在
只要项目的<packaging>是jar,Maven 默认就会在package阶段绑定maven-jar-plugin的default-jar执行。这个绑定属于 Maven 核心生命周期映射,不需要你写进 pom,也不受你是否引入其他插件影响。
想验证很简单:执行mvn help:effective-pom,你会看到大量 pom 里根本没写却真实存在的配置;或者执行mvn package -X,日志里一定会出现类似:
[INFO] --- maven-jar-plugin:3.3.0:jar (default-jar) @ demo --- [INFO] Building jar: /path/to/target/demo.jar这就说明,你所谓“只用一个插件”,实际构建过程中至少有两个插件参与:一个是不请自来的maven-jar-plugin,另一个才是你写的 shade 插件。只是前者默认执行、不需要显式声明,所以 pom 里看不到。
提示:很多人以为给
mainClass配置maven-jar-plugin是“额外加了一个打包步骤”,其实不是。你改的只是这个本来就存在、默认绑定好的插件参数而已。
1.3 默认 jar 到底打了什么,为什么它不算 Fat JAR
把 target 下的普通 jar 解压开看,里面基本只有三样东西:项目自己编译出来的 class、resources 目录下的资源文件、以及META-INF/MANIFEST.MF。第三方依赖的 class 并不在里面,它们还躺在本地仓库~/.m2/repository里。
如果你只配了 manifest 的Main-Class,然后在没有依赖目录的情况下直接java -jar,大概率是这样:
- 主类能被 JVM 找到,因为它在 jar 里;
- 但主类里引用的第三方类一个都找不到,立刻抛
NoClassDefFoundError。
普通 jar 和 Fat JAR 的根本差异就一句话:普通 jar 只包含本项目自己的类,Fat JAR 把运行时需要的第三方依赖类也装进同一个归档文件里。Maven 默认的 jar 插件不会做后者,所以才需要其他插件来补上这个缺口。
2. Shade 插件不是“凭空打 Fat JAR”,而是“接管产物再加工”
2.1 Shade 的实际执行链路:先让 jar 插件干活,再自己重做一遍
maven-shade-plugin的shadegoal 默认绑定在package阶段,但它几乎总是在maven-jar-plugin的default-jar之后执行。因此真正的执行顺序是:
maven-jar-plugin先把 target/classes 编译结果打成普通demo.jar;shadegoal 读取这个demo.jar,再读取项目的所有依赖 jar;- shade 把所有依赖 jar 解压开,把其中的 class 与
demo.jar里的 class 合并; - 根据配置过滤掉签名文件、重复文件,执行 relocation 重定位;
- 最后生成一个新的
demo.jar,并把原有普通 jar 重命名为original-demo.jar或类似名字。
所以你最后在 target 下看到的那个“巨大”的demo.jar,已经不是 jar 插件打出来的原始产物,而是 shade 插件在 jar 插件产物的基础上重新合成出来的成品。一个插件操作完,另一个插件命令覆盖了结果,这就是“单插件出 Fat JAR”最核心的机制。
2.2 Shade 内部的关键动作:合并、过滤、改写
合并步骤没那么简单,不是把所有 jar 解压后堆一起就行。真实场景里坑很多,shade 插件正是为了处理这些坑而存在的:
- 过滤签名文件:很多依赖 jar 的
META-INF下有.SF、.RSA、.DSA签名文件。如果原样合进 Fat JAR,运行时 JVM 可能认为 jar 被篡改并抛出SecurityException。所以默认建议过滤。 - 合并服务文件:JDK 的 SPI 机制依赖
META-INF/services/xxx这类文件。两个 jar 里如果有同名文件,直接合并会把内容覆盖掉,服务就失效了。shade 需要把内容做拼接而不是覆盖。 - 处理 MANIFEST.MF:要在 fat jar 里正常
java -jar,Main-Class必须写进去。这个工作由 shade 的ManifestResourceTransformer完成。 - 包重定位:把依赖里的
com.google.common改成shadow.com.google.common这类操作,能避免 fat jar 被集成到其他应用时与外部 Guava 发生冲突。这个能力是 shade 相对其他 Fat JAR 插件的独有优势。
这些步骤本来零散分布在多个插件里,shade 把它们全部集中到shade这一个 goal 内完成,自然就显得“一个插件就够”。
2.3 为什么传统做法要两三个插件,shade 却只要一个
传统可执行 Fat JAR 的做法大概是这样的:用maven-jar-plugin配置 manifest 的Class-Path,再用maven-dependency-plugin的copy-dependencies把依赖复制到lib目录,最后让运行时去找lib下的依赖。这里至少涉及 jar 插件和 dependency 插件两个参与者,有时还要额外配置maven-antrun-plugin做收尾。
而 shade 的路线是“不要 classpath 了,直接把依赖塞进同一个 jar”。它把复制依赖、解压、合并、生成清单的工作全部收敛到一个插件内部。所以“一个插件 vs 多个插件”的对比,本质上是两种 Fat JAR 实现路线的对比,而不是 Maven 某个神秘机制在起作用。
3. 插件关系地图:谁在 package 阶段接管主产物
3.1 常见 Fat JAR 相关插件的身份对比
用一个表格看这些插件的角色会更清楚:
| 插件 | 默认绑定目标 | 是否接管主 artifact | 产物特征 | 典型使用场景 |
|---|---|---|---|---|
| maven-jar-plugin | package 阶段的 default-jar | 是,产出普通主 jar | xxx.jar | 所有 jar 项目默认存在 |
| maven-shade-plugin | package 阶段的 shade | 默认接管,替换主 artifact | xxx.jar+original-xxx.jar | 合并依赖、包重定位、可执行 fat jar |
| maven-assembly-plugin | package 阶段的 single | 不接管,追加附件 | xxx-jar-with-dependencies.jar | 生成附带依赖的大包 |
| spring-boot-maven-plugin | package 阶段的 repackage | 接管,重打包主 artifact | xxx.jar+xxx.jar.original | Spring Boot 可执行 jar |
| maven-dependency-plugin | 按配置绑定 | 不接管 | 复制依赖到 lib 目录 | 传统外部 classpath 方案 |
这张表你看一眼就能抓住重点:shade 和 spring-boot 是“直接改写最终主 jar”的接管型插件,assembly 是“额外再生成一个胖包”的附加型插件。都是 Fat JAR,出来的文件形态和部署行为完全不同。
3.2 同阶段多个插件的执行顺序,决定了谁笑到最后
同一个package阶段可以有多个插件执行。默认生命周期映射里的default-jar绑定在前,用户自定义的插件执行通常排在后。所以 shade 和 spring-boot 才有机会在 jar 插件已经生成普通 jar 之后把它替换掉。
实际判断谁是最终产物,最简单的办法是看 target 目录:
- 看到
xxx.jar和original-xxx.jar,说明 shade 接管了; - 看到
xxx.jar和xxx.jar.original,说明 spring-boot 接管了; - 看到
xxx.jar和xxx-jar-with-dependencies.jar,说明 assembly 是作为附件追加的。
这三种情况的 pom 可能都“只显式写了一个插件”,但背后都有默认 jar 插件在打底。这个顺序关系就是“插件关系”里的关键一环。
3.3 别忽略父 POM 里的 pluginManagement
还有一种常见的“只看到一个插件”场景来自父 POM。比如引用了 Spring Boot 的 parent,父 pom 的<pluginManagement>里早就预置了maven-jar-plugin、maven-surefire-plugin、maven-failsafe-plugin等一堆构建插件的默认配置。子模块的 pom 里只需要加一个spring-boot-maven-plugin的引用,就能打出可执行 Fat JAR。
所以排查任何构建问题,第一步都建议看 effective pom,而不是只看当前模块的 pom。很多“为什么没配置它却执行了”的疑惑,都是因为父级把底座铺好了。
4. 三种“最少配置”打出 Fat JAR 的典型落地 pom
4.1 方案一:只用 maven-shade-plugin,标准接管式
这是最常见、最符合“一个插件出 Fat JAR”说法的配置。下面是一个带Main-Class、过滤签名文件、合并 SPI 文件的完整例子:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.2</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.example.MainKt</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>执行mvn clean package后你会发现 target 里有:
demo.jar original-demo.jardemo.jar是最终 Fat JAR,里面已经包含所有依赖 class;original-demo.jar是 jar 插件原始产物,方便你有需要时对比。这个方案适合绝大多数普通 Java/Kotlin 命令行应用。
4.2 方案二:spring-boot-maven-plugin,优雅的重新打包
Spring Boot 项目通常长这样:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>这个 pom 里显式的构建插件确实只有一个。但它的底下一层是 Spring Boot parent 里大段的 pluginManagement,上面又叠了一层 Maven 默认生命周期绑定。spring-boot-maven-plugin的repackage目标会在 package 阶段对 jar 插件产出的普通 jar 做二次打包:把所有依赖放进BOOT-INF/lib,再用 Spring Boot 自己的 Launcher 作为入口。
要注意,Spring Boot Fat JAR 的内部结构和 shade 完全不同,它用的是嵌套目录加载机制,而不是把依赖 class 平铺进 jar 根目录。所以这两个插件之间没有真正的替代关系,只是“都能产出可执行 Fat JAR”而已。
4.3 方案三:maven-assembly-plugin,追加式胖包
assembly 也是单插件配置,但产出行为和前两者有微妙差别:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>3.7.1</version> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>com.example.MainKt</mainClass> </manifest> </archive> </configuration> <executions> <execution> <id>make-assembly</id> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> </plugin>执行后 target 里会出现普通demo.jar和demo-jar-with-dependencies.jar。前者是主 artifact,后者是带依赖的附件。也就是说“一个插件打 Fat JAR”确实成立,但最终 fat 包并没有成为主 artifact。如果你接着执行mvn install,本地仓库里也会同时安装这两个文件。
这种方式适合希望在保留给依赖库使用者普通 jar 的同时,额外提供一个完整可执行包的项目。
4.4 三种方案怎么选
| 对比项 | shade | spring-boot | assembly |
|---|---|---|---|
| 是否替换主 artifact | 替换 | 替换 | 不替换 |
| 胖包文件名 | demo.jar | demo.jar | demo-jar-with-dependencies.jar |
| 依赖存放形式 | 解压后平铺进 jar | 放进BOOT-INF/lib | 解压后平铺进 jar |
| 是否支持重定位 | 支持 | 不支持 | 不支持 |
| 原 jar 保留方式 | original-demo.jar | demo.jar.original | 原样保留demo.jar |
| 典型项目 | 普通 Java/Kotlin 应用 | Spring Boot 应用 | 需要双产物的工具包 |
选哪个主要看项目类型。如果用的是 Spring Boot,自然选 spring-boot 插件;如果只是普通可执行程序,shade 功能最全,尤其是重定位能力是其他插件替代不了的;如果团队里有人需要普通 jar 作为库依赖、有人需要 fat jar 直接运行,assembly 这种“主产物 + 附件”的模式最省事。
5. 如果“一个插件”没按你想的产出 Fat JAR,检查这些点
5.1 可执行 jar 报 no main manifest attribute
最容易踩的坑就是 shade 只配了插件,没配ManifestResourceTransformer,或者忘了写mainClass。此时java -jar会报:
no main manifest attribute, in demo.jar修复方式就是在 shade 的 transformers 里补上主类配置。注意,如果你项目里还有其他 transformer,ManifestResourceTransformer最好保留,不要删。
5.2 启动后有类,但报 ServiceConfigurationError 或策略失效
这通常是因为META-INF/services下的文件在合并时被覆盖了。多个依赖 jar 里如果有同名服务描述文件,不做处理的话,后合并的那个会把前面的内容整个盖掉。
用ServicesResourceTransformer可以把多个文件内容按行合并。凡是做 SPI 的依赖,比如各种javax.xml.parsers.SAXParserFactory、数据库驱动、日志实现,都需要这个 transformer,否则 fat jar 运行时功能会莫名消失。
5.3 涉及签名 jar 时出现 SecurityException
某些依赖 jar 在META-INF下带了签名文件。shade 合并后,JVM 验签可能会认为包被篡改,抛SecurityException。统一过滤掉签名文件是最直接的解决办法:
<filters> <filter> <artifact>*:*</artifact> <excludes> <exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.DSA</exclude> <exclude>META-INF/*.RSA</exclude> </excludes> </filter> </filters>如果依赖里还有module-info.class这类 JPMS 文件,也可能导致 duplicate class 报错,可以根据实际情况在 filter 里排除,或者开启插件的多版本 jar 处理开关。
5.4 胖包到底是主 artifact 还是附件,直接决定安装行为
如果你用 shade 但设置了:
<shadedArtifactAttached>true</shadedArtifactAttached>行为会发生大变化:shade 不再替换主 artifact,而是生成一个带shadedclassifier 的附加 jar。也就是说你会同时得到普通demo.jar和demo-shaded.jar,install 时两个都会进本地仓库。
很多人对“只用一个插件为什么能打出 Fat JAR”这个问题的困惑,可能就来自某一方用了shadedArtifactAttached=false(默认),另一方用了true。两者在 pom 里看起来都只是 shade 插件,但最终的 artifact 结构和部署方式完全不同。所以判断项目构建行为时,不能只看插件名,还要看它的配置参数。
5.5 快速自检清单
以后遇到“单插件打胖包”的可疑构建,按这个顺序排查:
- 跑
mvn help:effective-pom,确认还有哪些插件被父 POM 或默认生命周期注入; - 跑
mvn package -X,看 package 阶段的执行顺序和日志; - 看 target 目录里文件名,判断是替换式还是附加式;
- 解压 final jar,看
META-INF/MANIFEST.MF和依赖 class 的位置; - 如果
java -jar失败,优先检查 transformer 和 filter 配置。
这几步走完,绝大多数“为什么只有一个插件”的疑惑都能落地。
我个人在实际排查中的体会是:Maven 的 pom 从来不是“全部真相”,它只是显式那部分。生命周期默认绑定、父级 pluginManagement、插件在同阶段的前后顺序,才是真正决定产物的三块拼图。把 effective-pom 当作第一参考资料之后,很多奇怪的构建问题都能快速找到答案,比对着 pom 猜半天有效得多。