☰
Maven单插件打出Fat JAR?Shade插件与默认生命周期揭秘
2026/10/17 12:57:31 网站建设 项目流程

这段时间在交流群里看到一位同学问了个很有意思的问题:某个项目的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之后执行。因此真正的执行顺序是:

  1. maven-jar-plugin先把 target/classes 编译结果打成普通demo.jar;
  2. shadegoal 读取这个demo.jar,再读取项目的所有依赖 jar;
  3. shade 把所有依赖 jar 解压开,把其中的 class 与demo.jar里的 class 合并;
  4. 根据配置过滤掉签名文件、重复文件,执行 relocation 重定位;
  5. 最后生成一个新的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-pluginpackage 阶段的 default-jar是,产出普通主 jarxxx.jar所有 jar 项目默认存在
maven-shade-pluginpackage 阶段的 shade默认接管,替换主 artifactxxx.jar+original-xxx.jar合并依赖、包重定位、可执行 fat jar
maven-assembly-pluginpackage 阶段的 single不接管,追加附件xxx-jar-with-dependencies.jar生成附带依赖的大包
spring-boot-maven-pluginpackage 阶段的 repackage接管,重打包主 artifactxxx.jar+xxx.jar.originalSpring 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.jar

demo.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 三种方案怎么选

对比项shadespring-bootassembly
是否替换主 artifact替换替换不替换
胖包文件名demo.jardemo.jardemo-jar-with-dependencies.jar
依赖存放形式解压后平铺进 jar放进BOOT-INF/lib解压后平铺进 jar
是否支持重定位支持不支持不支持
原 jar 保留方式original-demo.jardemo.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 快速自检清单

以后遇到“单插件打胖包”的可疑构建,按这个顺序排查:

  1. 跑mvn help:effective-pom,确认还有哪些插件被父 POM 或默认生命周期注入;
  2. 跑mvn package -X,看 package 阶段的执行顺序和日志;
  3. 看 target 目录里文件名,判断是替换式还是附加式;
  4. 解压 final jar,看META-INF/MANIFEST.MF和依赖 class 的位置;
  5. 如果java -jar失败,优先检查 transformer 和 filter 配置。

这几步走完,绝大多数“为什么只有一个插件”的疑惑都能落地。

我个人在实际排查中的体会是:Maven 的 pom 从来不是“全部真相”,它只是显式那部分。生命周期默认绑定、父级 pluginManagement、插件在同阶段的前后顺序,才是真正决定产物的三块拼图。把 effective-pom 当作第一参考资料之后,很多奇怪的构建问题都能快速找到答案,比对着 pom 猜半天有效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询