☰
Java项目打包全解析:IDEA与Maven打包原理、插件选型及踩坑指南
2026/9/30 20:04:18 网站建设 项目流程

在IDEA里把项目打成JAR包,这事看着简单——右上角点两下鼠标,Build Artifacts,等进度条走完,一个jar文件出现在out目录里。但等你高高兴兴java -jar跑起来,等待你的往往是一屏幕ClassNotFoundException。问题出在哪?出在很多人根本没搞懂“打JAR包”这个动作到底包含了几件事。这篇文章围绕IDEA+Maven打JAR包的两种主流方式,把IDEA自带打包和Maven插件打包的原理、操作、选型、坑位一次讲清楚。不管你是刚踩进Java开发的门槛,还是已经被jar问题搞得头大的中级开发,照着文中的步骤走,基本能少走一半弯路。

1. 先说透:为什么IDEA直接打的包经常跑不起来

1.1 JAR包的结构本质:一个带清单的Zip

JAR包这个名字听起来有点神秘,拆开看本质就是一个Zip压缩包,只是里面装的不是散文,而是编译后的.class字节码、资源文件,以及最关键的一个META-INF/MANIFEST.MF清单文件。

启动一个可执行JAR时,JVM并不是自动就知道该从哪个类进去,它首先要读MANIFEST.MF里的Main-Class,找到那个包含public static void main(String[] args)的入口类,然后再沿着Class-Path等条目去查找依赖。

所以“打JAR包”这个动作背后其实包含三件事:编译项目自身的代码、收集第三方依赖、生成正确的清单文件。这三件事里任何一件掉链子,最终的产物就会在运行时以各种异常的方式给你颜色看。

1.2 两条路线的分野:IDE输出与构建工具产物

IDEA自带的Build Artifacts,本质上是把当前工程里已经编译好的class文件和依赖资源,按用户手动配置的方式复制到一个目录,再压缩成JAR。它依赖的是IDE内部的状态和用户的Artifacts配置,直观是直观,但可复现性很差——换一台机器、换一个搭伙的同事,他未必能复现出你的配置。

Maven则不同,它通过pom.xml里的插件配置,从clean、compile、test、package一步步执行生命周期,最终产出JAR。它完全独立于IDE存在,你在命令行里执行mvn clean package能打出同样的结果,所以它天然适合团队协作、持续集成和自动化部署。

很多人听到“IDEA+Maven打JAR包”这个说法会疑惑:我到底该用IDEA的按钮,还是用Maven的命令?答案是,这句话正确的理解方式是“在IDEA里配置并使用Maven来打包”,而不是“用IDEA自带的Artifacts按钮”。

1.3 场景选型:什么情况用哪种方案

如果你只是自己一个人做学习验证,依赖就两三个,不追求可复现,那IDEA自带Artifacts怎么点都行。但凡项目要提交到Git、要和别人协作、要部署到服务器,或者依赖数量开始超过五六个,我劝你直接上Maven。

判断标准就一条,判断标准就一条——如果打包过程不能被命令行完整复现,以后迟早会在某台机器上吃大亏。我自己就在这上面栽过跟头,以前用Artifacts打好的包在本地跑得好好的,拿到服务器上就报依赖缺失,最后排查半天,发现是Artifacts把某个依赖以绝对路径写进了Class-Path,服务器上哪有那个路径。

2. 路线一:IDEA Artifacts打包的完整操作与依赖处理

2.1 Artifacts配置流程

虽然我本人已经不太用Artifacts打包生产项目,但既然要讲两条路线,Artifacts的完整流程还是要说清楚,毕竟很多初学者的第一个可执行JAR就是从这里来的。

打开IDEA,按Ctrl+Alt+Shift+S进入Project Structure,左边选中Artifacts,点左上角的+,选择JAR,然后从子菜单里选“From modules with dependencies”。这里一定要选对主模块。接着在Main Class那一栏点文件夹图标,选择项目里真正包含main方法的类。

下面还有两个关键的依赖处理方式选项:一个是“extract to the target JAR”,另一个是“copy to the output directory and link via manifest”。选完之后点确定,再回到主界面,执行Build → Build Artifacts → Build,就能在out/artifacts目录下看到产物。

这里有个很多人不知道的点:Artifacts的默认输出目录是out/artifacts,和Maven的target目录完全独立。所以你用Artifacts打完包,去Maven的target目录里找是永远找不到的,这也是好几个同事跑过来问我“为什么IDEA点完打包没反应”的原因之一。

2.2 extract与copy两种依赖策略的判断

先说extract模式。这个选项会把所有依赖JAR内部的.class解压出来,和项目自己的class合并成一个“大杂烩”JAR。优点是最终只有一个文件,拷到哪个机器都能跑;缺点是如果依赖里有同名类,就会出现覆盖和冲突,而且项目依赖一多,这个JAR会膨胀得非常厉害。

再说copy模式。这个选项会把依赖JAR整体复制到输出目录下的一个子目录(默认是lib),JAR本身只放项目自己的class,MANIFEST.MF里通过Class-Path记录lib下所有依赖的相对路径。好处是结构清晰,依赖更新方便,但你搬运时必须把这个JAR和lib目录一起带上,只单独拷走一个JAR照样跑不起来。

我见过不少初学者在extract模式下打出来的包,运行时报java.lang.SecurityException: Invalid signature file digest,就是依赖里带了签名文件,合并时把签名文件也混进去了。这种问题看着莫名其妙,其实原理就是签名文件冲突。

2.3 验证产物正确性

打完包先别急着双击运行,先用jar tf命令看一下内部结构,确认是否存在META-INF/MANIFEST.MF,打开看看Main-Class是否写对。还可以用解压工具直接打开JAR,检查第三方依赖的class是不是真的在里面。

IDEA自带的Artifacts不会经过Maven生命周期,所以很多人第一次打包含糊的地方在于:明明pom里配了依赖,Artifacts里却找不到,最后发现第三方依赖压根没进包,还需要手动去Artifacts的可用元素里添加。所以说白了,Artifacts更适合做临时任务,不适合作为团队的统一构建方案。

3. 路线二:Maven插件的四套方案,按场景选

3.1 最小方案:maven-jar-plugin + maven-dependency-plugin

如果不想生成几百MB的“胖JAR”,而是希望保留“一个小JAR + 一个lib目录”的结构,我推荐maven-jar-plugin配合maven-dependency-plugin的copy-dependencies目标。

maven-jar-plugin负责在MANIFEST.MF里写入Main-Class和Class-Path,maven-dependency-plugin负责把项目所有依赖复制到target/lib目录下。Class-Path的前缀需要配置成lib/,这样运行时JVM就会在当前目录下找lib子目录。

这个方案优点很多:依赖不会互相覆盖,排查问题时能一眼看到具体的jar版本,部署时把JAR和lib目录一起传上服务器就行。缺点是部署包会有多个文件,不过在实际运维场景里,多了一个目录而已,根本不算问题。

<build> <finalName>myapp</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.4.1</version> <configuration> <archive> <manifest> <mainClass>com.example.MainApplication</mainClass> <addClasspath>true</addClasspath> <classpathPrefix>lib/</classpathPrefix> </manifest> </archive> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-dependency-plugin</artifactId> <version>3.6.1</version> <executions> <execution> <id>copy-dependencies</id> <phase>package</phase> <goals> <goal>copy-dependencies</goal> </goals> <configuration> <outputDirectory>${project.build.directory}/lib</outputDirectory> </configuration> </execution> </executions> </plugin> </plugins> </build>

跑一下mvn clean package,完成后target目录下会出现myapp.jar和lib目录,整个部署包就是这两个东西。

3.2 合并方案:maven-assembly-plugin

maven-assembly-plugin的常用描述符是jar-with-dependencies,它的工作方式是把所有依赖的class解压合并进一个JAR,相当于IDEA Artifacts的extract模式,但它是通过Maven生命周期可复现的。

如果你的项目是普通Java命令行工具、工具类库,依赖不算太巨型,用这个最省事。配置可以这样写:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>3.7.1</version> <configuration> <archive> <manifest> <mainClass>com.example.MainApplication</mainClass> </manifest> </archive> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> </configuration> <executions> <execution> <id>make-assembly</id> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> </plugin>

但这里有个隐藏坑:多个依赖里如果含有相同的META-INF/services文件,assembly不会帮你合并,只会输出其中一个。那些依赖ServiceLoader机制的框架,比如说某些数据库驱动、日志实现,可能会因此静默失效。这个坑排查起来特别费劲,因为你看到的依赖都在,但运行时就是找不到实现类。

所以我的建议是:普通项目assembly够用,一旦涉及SPI机制、签名文件、配置覆盖等问题,就上shade。

3.3 进阶合并方案:maven-shade-plugin

maven-shade-plugin是assembly的加强版。它引入了transformers机制,可以用ServicesResourceTransformer解决META-INF/services合并问题,用ManifestResourceTransformer写入Main-Class,甚至可以把某个包整体重定位到新的包名,避免多个依赖里出现同一个全限定类名的冲突。

配置稍微繁琐一点,但实际生产环境里,第三方SDK、Agent工具、依赖特别复杂的项目,基本都用shade而不是assembly。一个典型的配置长这样:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.3</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.MainApplication</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>

注意那个filters配置,它就是用来过滤签名文件的。很多人在网上搜“jar包怎么缝合”,搜到shade插件后一顿操作,结果运行时不断报签名错误,原因就是少了这段过滤。所谓“缝合”多个jar,本质上就是这么回事——把多个依赖的内容合并成一个可执行实体,同时解决资源冲突。

3.4 Spring Boot项目的专属插件

如果你的项目是Spring Boot,事情还会再绕一层。spring-boot-maven-plugin的repackage目标,会把maven-jar-plugin打出来的普通JAR再加工一遍,变成Spring Boot特有的可执行JAR。

这个可执行JAR的结构和传统JAR完全不一样:项目class放在BOOT-INF/classes,依赖jar放在BOOT-INF/lib,并通过Spring Boot自定义的类加载器把嵌套jar加载起来。也正因如此,Spring Boot的JAR在java -jar时可以正常运行,但不能被别的项目当作普通依赖直接引用。

<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>3.2.4</version> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin>

这里版本号只是示例,实际要和你项目里的Spring Boot版本匹配。还有一个使用时的禁忌:千万不能用JDK自带的jar命令或者普通解压工具对这个可执行JAR重新打包,否则嵌套加载机制会直接坏掉,运行时会报Unable to open nested entry之类的错误。

3.5 四种插件的选型对照

方案产物形式依赖处理适合场景常见坑
maven-jar + dependency核心JAR + lib目录依赖留在外部需要经常替换依赖、排查版本冲突部署时必须连同lib目录一起搬
maven-assembly单JAR(fat jar)依赖解压合并普通命令行、工具类项目META-INF/services被覆盖导致SPI失效
maven-shade单JAR(fat jar)合并 + 可重定位复杂依赖、SDK、Agent配置复杂,需要处理签名和服务文件
spring-boot-maven-plugin嵌套结构可执行JAR依赖放BOOT-INF/libSpring Boot项目不能被其他项目作为普通依赖引用

这个表格基本覆盖了我这些年的选型经验。简单记就是:结构清晰选第一种,普通小项目选assembly,复杂依赖就shade,Spring Boot无脑用专属插件。

4. 配置层的三个前置坑:Maven环境、仓库镜像与IDEA关联

4.1 Maven本身装不对,后面全白搭

聊完插件,回到最基础的环境。Maven本身是绿色软件,下载后解压就能用,但有几个细节一定要注意。

第一,路径不要带中文和空格,否则部分插件在解析路径时会出现诡异的错误。第二,配置环境变量时,Windows里要新增MAVEN_HOME,然后在PATH里追加%MAVEN_HOME%\bin;Linux或macOS则在~/.bashrc里配置export MAVEN_HOME=...和export PATH=$MAVEN_HOME/bin:$PATH。第三,配完之后一定要开新终端跑一次mvn -v,看到输出里有Java version和Maven home,才算真配好。

还有一个常见的坑:命令行里mvn -v正常,IDEA里却还是报错。原因通常是IDEA没指向你装的Maven,而是用了它内置的Bundled Maven。这种情况下,命令行能打包,IDEA里点package却报错。

4.2 settings.xml配置镜像与本地仓库

Maven默认从中央仓库下载依赖,网络不好的时候那个速度能急死人。解决办法是在settings.xml里配置镜像,把中央仓库请求转发到国内镜像。阿里云仓库是比较常见的选择,配置如下:

<mirror> <id>aliyunmaven</id> <name>aliyun central</name> <url>https://maven.aliyun.com/repository/public</url> <mirrorOf>central</mirrorOf> </mirror>

mirrorOf这里写了central,意思是只有中央仓库的请求走这个镜像,其它仓库还能保持原样。如果你希望所有仓库都走镜像,也可以写*,不过我自己一般不这么干,因为私服仓库如果也走了镜像,反而会造成困扰。

settings.xml放在两个位置:一是Maven安装目录下的conf/settings.xml,全局生效;二是用户目录下的~/.m2/settings.xml,只对当前用户生效。我习惯的做法是复制一份到用户目录下再修改,这样以后升级Maven版本时配置不会丢。同时,本地仓库localRepository也要改,不要放在C盘的系统盘里,依赖数量一多,C盘迟早爆掉。

改完settings.xml后,IDEA里如果发现依赖全部标红,点一下Maven工具窗的刷新按钮重新导入项目,大多数问题都能解决。

4.3 IDEA关联Maven的常见失误

IDEA中的Maven配置在Settings → Build, Execution, Deployment → Build Tools → Maven。这里需要确认三个东西:Maven home path要指向你本机的Maven目录,User settings file要指向你刚修改过的settings.xml,Local repository会自动读取settings里的配置。

如果你用了IDEA社区版,其实完全够用,不需要额外折腾。Maven工具窗在IDEA右侧,展开后能看到生命周期、插件、依赖树等视图。Lifecycle里的clean、package、install可以直接双击执行,但要注意,deploy会把项目上传到远程仓库,本地测试时别乱点。

很多人遇到“IDEA里Maven插件显示红色报错”,十有八九是Maven版本和IDEA版本不兼容,或者本地仓库里插件下载不完整。遇到这种情况,先把settings里的镜像配上,然后删掉本地仓库里对应的损坏目录,重新刷新。

5. 启动报错的排查链路:从命令行反推打包阶段的问题

5.1 报错信息与根因对照

打包之后跑不起来,是每个Java开发都会遇到的事。我把最常见的报错和它们的根因整理成一张表:

报错信息根因
no main manifest attribute, in xxx.jar清单里没有Main-Class
Could not find or load main class com.example.MainMain-Class写错或类不存在
ClassNotFoundException: xxx运行期找不到某个类
NoClassDefFoundError: xxx类在编译期存在,运行期缺失
Invalid signature file digest for Manifest main attributes依赖的签名文件冲突

这里特别说一下NoClassDefFoundError和ClassNotFoundException的区别。前者通常意味着这个类加载过程中又被别的类引用了,但自身加载失败,常见于依赖缺失或版本冲突;后者更直接,就是ClassLoader压根找不到这个名字。看到报错先别慌,按表对照一下,能少走很多弯路。

5.2 一个典型的ClassNotFoundException排查全过程

举个例子。我用assembly打出一个fat jar,执行java -jar时疯狂报ClassNotFoundException: com.fasterxml.jackson.databind.ObjectMapper。

第一反应不是去pom里加依赖,而是先验证这个类到底进没进包。执行jar tf app.jar | grep ObjectMapper,如果没结果,说明依赖没打进去;如果有结果,就要怀疑是不是多个依赖里存在不同版本的jackson-databind。

真实情况往往是后者。项目A依赖jackson-databind 2.9,项目B又依赖2.11,fat jar里两个版本都进去了,ClassLoader加载的是先出现的那一个,方法签名和另一个版本的代码对不上,运行时就是各种找不到方法、找不到字段。

排查办法是执行mvn dependency:tree看依赖树,找出重复的依赖,在pom里用exclusion把冲突方排除掉。这个排查过程其实比写代码还费时间,所以我的习惯是打包前先跑依赖树看看,别等运行时报错再往回查。

5.3 签名冲突与META-INF/services被覆盖

fat jar最隐蔽的两个问题是签名冲突和SPI文件覆盖。

签名冲突表现是运行时报SecurityException: Invalid signature file digest。原因是某些依赖jar自带了签名文件(META-INF/*.SF、*.DSA、*.RSA),合并后JVM会认为JAR包被篡改,直接拒绝加载。解法在shade的filters里已经写过,把签名文件排除掉就行。

SPI文件覆盖则是META-INF/services下同一文件名被多个依赖提供,assembly只会保留一个。这个问题在JDK的ServiceLoader机制下很致命:你想用的实现类注册文件被别的依赖覆盖了,运行时就找不到实现。用shade时加上ServicesResourceTransformer,把多个文件合并成一份,问题就解决了。

这两个坑平时不冒头,但换到某些框架或SDK场景时突然爆炸。建议打完fat jar后,用解压工具看一眼META-INF目录,如果发现一堆陌生的签名文件,基本就能断定接下来要踩签名冲突的坑。

6. 外部JAR包的打入方式与依赖本地化处理

6.1 install-file命令导入本地仓库

现实里经常遇到这样的事:一个第三方提供的JAR不在Maven中央仓库,比如某个厂商的数据库驱动或者不公开的SDK,但项目必须用它。

正确做法是用mvn install:install-file命令,把它手动装进本地Maven仓库。命令格式:

mvn install:install-file -Dfile=D:/lib/ojdbc8.jar -DgroupId=com.oracle -DartifactId=ojdbc8 -Dversion=12.2.0.1 -Dpackaging=jar

装完之后,pom里就可以用普通的依赖坐标来引用了:

<dependency> <groupId>com.oracle</groupId> <artifactId>ojdbc8</artifactId> <version>12.2.0.1</version> </dependency>

这个命令有几个小细节要提醒。-Dfile指向jar的绝对路径,建议用绝对路径,避免终端目录不同导致找不到文件;-DgroupId、-DartifactId、-Dversion这三个坐标自己定义时要慎重,因为一旦用同一套坐标装过不同内容的jar,后面再想改就麻烦了,需要手动去本地仓库删目录。

6.2 systemPath方式的利弊

还有一种在pom里直接指定本地jar的做法,用systemscope配合systemPath:

<dependency> <groupId>com.example</groupId> <artifactId>sdk-extra</artifactId> <version>1.0</version> <scope>system</scope> <systemPath>${project.basedir}/lib/sdk-extra.jar</systemPath> </dependency>

这个方案的好处是项目目录里自带jar文件,别人拉下代码就能编译,不用先装本地依赖。坏处也很明显:不符合Maven的依赖管理规范,很多打包插件默认不处理systemscope的依赖,导致编译能过,打包后运行却报缺类。我实际对比过很多次,systemscope基本属于历史遗留方案,除了临时在本机验证一下,正规项目尽量别用。只要条件允许,一律走install-file装进本地仓库。

6.3 查看JAR内部内容的实用手段

排查依赖问题的时候,工具用顺手能省大量时间。IDEA里直接展开External Libraries下面的jar,双击打开.class文件,IDEA会自动反编译,这也是最直观的方式。命令行场景下,jar tf可以快速查看jar的内容列表,javap -c可以看某个类的字节码结构,jdeps可以分析依赖关系。这些手段能帮你快速判断这个包到底有没有带上想要的class。

之前有人在论坛上问“jar包怎么缝合”,其实就是想把这些依赖合并到一个可执行实体里。合并工具就是前面讲的shade或assembly,合并完之后一定要记得检查META-INF目录,避免签名和SPI文件冲突。

我个人在实际项目里基本已经不用IDEA自带的Artifacts了。原因很简单,一旦项目到了要上服务器、要交给别人维护的层面,构建过程必须是可复现的。Maven插件配置在pom里一写,任何一台干净机器执行mvn clean package都能打出同样的结果,这才是工程化的做法。最后再分享一个小习惯:每天下班前我会跑一次mvn clean package -DskipTests,让构建问题尽早暴露出来,总比上线前才慌慌张张排错要强得多。

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

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

立即咨询