☰
Maven依赖分离打包实战:告别Fat Jar,提升部署效率
2026/10/17 11:26:10 网站建设 项目流程

1. 为什么要把依赖和程序Jar拆开

做过Java后端项目的人多半都经历过这种场景:本地调试一切正常,往服务器上一扔,一个几十兆甚至上百兆的可执行Jar包,改一行代码就得重新上传一次。网络稍微差一点,传一次十分钟,改一个bug传十次,一天时间全耗在等上传进度条上了。这个痛点催生了一个非常实用的打包思路——把第三方依赖库和业务代码编译出来的程序Jar分开存放,程序Jar只保留自己的类文件,依赖统一放到一个lib目录里,通过启动脚本指定classpath来运行。

这个方案的核心价值在于:业务代码的Jar通常只有几百KB到几MB,而依赖库动辄几十MB。分离之后,日常迭代只需要替换那个小Jar,依赖库除非有版本变更否则完全不用动。对于需要频繁发布的项目来说,这个改动带来的效率提升是立竿见影的。

适合阅读这篇内容的人包括:正在使用Maven构建Java项目的后端开发、需要频繁部署到远程服务器的运维人员、对项目打包结构有优化需求的技术负责人,以及刚接触Maven打包、想搞清楚maven-jar-plugin和maven-dependency-plugin怎么配合使用的新手。下面我会从整体设计思路开始,一步步拆解配置细节、实操流程和踩坑经验,所有配置都可以直接复制到你的项目里用。

2. 整体设计思路与方案选型

2.1 传统Fat Jar的问题到底在哪

Maven默认的打包方式,如果不做特殊配置,打出来的就是一个普通的Jar,只包含项目自己的class文件,不含依赖。但Spring Boot项目通常会用spring-boot-maven-plugin打成一个可执行的Fat Jar,把所有依赖都塞进去。这个Fat Jar用起来确实方便,java -jar一条命令就能跑,但它的问题也很明显。

第一,体积大。一个中等规模的Spring Boot项目,依赖加起来轻松超过50MB,有些用到大数据或者深度学习库的项目甚至能到200MB以上。第二,每次修改代码都要重新打包整个Fat Jar,构建时间长,传输时间更长。第三,多个项目如果依赖相同的库,每个Fat Jar里都存了一份,服务器上重复占用空间。第四,Fat Jar内部的依赖版本排查起来麻烦,想单独升级某个依赖的版本,必须重新打包整个应用。

我见过不少团队在项目初期图省事直接用Fat Jar,等到项目稳定、需要频繁修bug的时候才开始后悔。所以如果你的项目已经进入维护期,或者部署环境网络条件一般,尽早改成依赖分离的方案会省很多事。

2.2 分离方案的核心原理

Maven的生命周期里,打包阶段由maven-jar-plugin负责生成项目Jar,而依赖的复制可以通过maven-dependency-plugin来完成。具体来说,我们需要做三件事:

  • 配置maven-jar-plugin,在生成的Jar的MANIFEST.MF文件里写入Main-Class(程序入口)和Class-Path(依赖库路径),这样java -jar才能找到入口类和依赖。
  • 配置maven-dependency-plugin,在打包阶段把所有依赖复制到一个指定目录(通常是lib),并且排除掉项目自身的Jar。
  • 编写启动脚本,用java -cp或者java -jar配合Class-Path来启动程序。

这里有个关键点:Class-Path里配置的路径是相对于程序Jar所在位置的相对路径。比如程序Jar在/app/myapp.jar,依赖在/app/lib/目录下,那么Class-Path里写lib/xxx.jar就能被正确找到。这个相对路径的规则是Java类加载器规定的,不是Maven决定的,所以配置的时候要特别注意目录结构。

2.3 为什么不用Assembly插件或者Shade插件

Maven生态里还有maven-assembly-plugin和maven-shade-plugin这两个打包插件,它们也能实现类似的效果。Assembly插件可以自定义打包结构,把依赖放到指定目录,但它的问题是配置相对复杂,而且默认行为容易把依赖和程序混在一起。Shade插件主要用于打Fat Jar和重定位包名,不太适合做依赖分离。

相比之下,maven-jar-plugin加maven-dependency-plugin的组合更加轻量、配置直观、可控性强。你完全清楚每个依赖被复制到了哪里,MANIFEST.MF里写了什么,出问题的时候排查起来也方便。对于绝大多数中小型项目来说,这个组合是最优解。

还有一个选择是直接用spring-boot-maven-plugin的layout配置,但Spring Boot的打包机制有自己的类加载器体系,分离依赖后启动方式跟标准Java应用不太一样,反而增加了理解成本。如果你的项目不是强绑定Spring Boot,建议用标准方案。

3. 核心配置细节与实操要点

3.1 maven-jar-plugin的完整配置

先看maven-jar-plugin的配置。这个插件负责生成程序Jar,并在MANIFEST.MF里写入关键信息。下面是一份可以直接用的配置:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.3.0</version> <configuration> <archive> <manifest> <addClasspath>true</addClasspath> <classpathPrefix>lib/</classpathPrefix> <mainClass>com.example.Application</mainClass> </manifest> <manifestEntries> <Class-Path>./</Class-Path> </manifestEntries> </archive> <excludes> <exclude>**/*.properties</exclude> <exclude>**/*.yml</exclude> </excludes> </configuration> </plugin>

逐项解释一下。addClasspath设为true,插件会自动把所有依赖的Jar文件名写入Class-Path。classpathPrefix设为lib/,表示每个依赖前面都加上lib/前缀,这样Java就能在lib目录下找到它们。mainClass填你的启动类全限定名,这个必须准确,否则java -jar会报找不到主类。

manifestEntries里的Class-Path设为./,这是为了让程序Jar所在目录也加入类路径,方便读取同目录下的配置文件。excludes部分根据你的项目情况调整,如果配置文件是外置的,可以把它们从Jar里排除掉,部署时单独放到程序Jar旁边。

注意:classpathPrefix的值必须以斜杠结尾,写成lib而不是lib/的话,生成的Class-Path会变成libxxx.jar,Java找不到依赖。这个细节很容易被忽略,我第一次配置的时候就踩过这个坑。

3.2 maven-dependency-plugin的复制策略

接下来是maven-dependency-plugin,它的任务是把依赖复制到lib目录:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-dependency-plugin</artifactId> <version>3.6.0</version> <executions> <execution> <id>copy-dependencies</id> <phase>package</phase> <goals> <goal>copy-dependencies</goal> </goals> <configuration> <outputDirectory>${project.build.directory}/lib</outputDirectory> <overWriteReleases>false</overWriteReleases> <overWriteSnapshots>false</overWriteSnapshots> <overWriteIfNewer>true</overWriteIfNewer> <includeScope>runtime</includeScope> <excludeTransitive>false</excludeTransitive> </configuration> </execution> </executions> </plugin>

outputDirectory指定依赖复制到target/lib目录。includeScope设为runtime,表示只复制运行时需要的依赖,provided和test范围的依赖不会被复制进去。这个设置很重要,比如servlet-api通常是provided范围,部署到容器里运行时容器会提供,不需要打包进去。

overWriteIfNewer设为true,表示如果依赖有更新才覆盖,避免每次打包都重新复制所有依赖,能节省不少构建时间。excludeTransitive设为false,表示传递性依赖也会被复制,这个通常保持默认即可。

提示:如果你的项目依赖数量很多,target/lib目录下会有几百个Jar文件。建议在.gitignore里把target目录排除掉,不要把这些依赖提交到代码仓库。

3.3 启动脚本的编写要点

打包完成后,目录结构是这样的:

target/ ├── myapp.jar └── lib/ ├── spring-core-6.0.0.jar ├── spring-context-6.0.0.jar └── ...其他依赖

启动脚本可以这样写:

#!/bin/bash APP_HOME=$(cd "$(dirname "$0")" && pwd) JAVA_CMD="java" JVM_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC" MAIN_CLASS="com.example.Application" CLASS_PATH="$APP_HOME/myapp.jar:$APP_HOME/lib/*" nohup $JAVA_CMD $JVM_OPTS -cp "$CLASS_PATH" $MAIN_CLASS > /dev/null 2>&1 & echo "Application started with PID $!"

这里用的是-cp指定classpath的方式,而不是java -jar。两种方式都可以,但-cp方式更灵活,可以在classpath里用通配符lib/*,Java会自动加载lib目录下所有Jar。用java -jar的话,依赖路径由MANIFEST.MF里的Class-Path决定,修改起来不如脚本方便。

注意:-cp后面的路径在Linux下用冒号分隔,在Windows下用分号分隔。如果脚本需要跨平台,可以用变量判断操作系统类型。另外,lib/*这种通配符写法只在Java 6及以上版本支持,老项目要注意。

3.4 资源文件的处理策略

配置文件(如application.yml、logback.xml)的处理需要根据实际需求决定。有两种常见做法:

第一种是把配置文件打进程序Jar里,这样部署简单,但修改配置需要重新打包。第二种是把配置文件外置到程序Jar同目录,通过Class-Path里的./让Java能读到。第二种方式更适合需要频繁调整配置的环境。

如果选择外置,在maven-jar-plugin的excludes里排除这些文件,然后在部署时手动放到程序Jar旁边。Spring Boot默认会从classpath根目录读取application.yml,而./在classpath里,所以放在程序Jar同目录的配置文件能被自动识别。

实操心得:我一般会把logback.xml外置,因为日志级别经常需要临时调整,外置后改完重启即可,不用重新打包。而application.yml里的数据库连接等配置,如果环境固定,打进Jar里也没问题。

4. 完整实操流程与验证方法

4.1 从零开始配置的完整步骤

假设你有一个标准的Maven项目,pom.xml里已经配置了spring-boot-starter等依赖。下面是改造的完整步骤:

第一步,在pom.xml的build节点下添加maven-jar-plugin和maven-dependency-plugin的配置,内容参考上一节的示例。注意mainClass要改成你自己的启动类。

第二步,如果项目之前用了spring-boot-maven-plugin,需要把它移除或者调整配置。因为Spring Boot的打包插件会覆盖默认的Jar生成逻辑,两者同时存在会冲突。移除后,项目就变成标准的Java应用打包方式。

第三步,执行mvn clean package。构建完成后,检查target目录下是否生成了myapp.jar和lib目录。用unzip -l target/myapp.jar查看Jar内容,确认里面只有项目自己的class文件和资源文件,没有第三方依赖。

第四步,用unzip -p target/myapp.jar META-INF/MANIFEST.MF查看MANIFEST.MF内容,确认Main-Class和Class-Path是否正确。Class-Path应该是一长串lib/xxx.jar的列表。

第五步,把target/myapp.jar和target/lib目录一起复制到部署目录,编写启动脚本并执行。观察日志输出,确认应用正常启动。

4.2 验证依赖是否完整的方法

依赖分离后最常见的问题是启动时报ClassNotFoundException或NoClassDefFoundError,原因通常是某个依赖没有被正确复制到lib目录,或者Class-Path里没有包含它。

排查方法很简单:启动时报哪个类找不到,就去lib目录下找对应的Jar。比如报org.springframework.context.ApplicationContext找不到,就检查lib目录下有没有spring-context-xxx.jar。如果没有,说明该依赖的scope不是runtime,或者被exclude掉了。

还有一种情况是依赖存在但版本不对。比如项目里同时引入了两个版本的同一个库,Maven会做依赖调解,最终只有一个版本被复制到lib。如果运行时需要另一个版本,就会出问题。用mvn dependency:tree可以查看完整的依赖树,确认最终生效的版本。

提示:可以在启动脚本里加上-verbose:class参数,让Java打印每个类的加载来源,排查类冲突时非常有用。不过这个参数会产生大量日志,只在排查问题时临时开启。

4.3 增量更新的实际操作

依赖分离方案最大的优势体现在增量更新上。假设你修改了一个Service类的代码,重新构建后:

  • myapp.jar会重新生成,体积通常只有几百KB。
  • lib目录下的依赖Jar如果没有版本变更,overWriteIfNewer会跳过复制,保持原样。

部署时只需要上传myapp.jar覆盖旧文件,然后重启应用即可。相比上传整个Fat Jar,传输时间可能从几分钟缩短到几秒钟。如果配合CI/CD工具做自动化部署,效果更明显。

我实测过一个项目,Fat Jar体积约85MB,分离后程序Jar只有1.2MB。在带宽有限的环境下,单次部署时间从平均3分钟降到了10秒以内。对于需要频繁热修的场景,这个提升非常可观。

4.4 多模块项目的处理

如果项目是Maven多模块结构,比如有一个parent模块、一个common模块和一个web模块,处理方式略有不同。web模块是最终打包的模块,依赖了common模块。在web模块的pom.xml里配置上述插件时,common模块会被当作依赖复制到lib目录。

但这里有个问题:common模块是项目内部的模块,不是第三方依赖。如果把它复制到lib目录,每次修改common模块的代码,都需要重新复制。更好的做法是在maven-dependency-plugin里排除项目内部的模块,让它们跟web模块一起打进程序Jar,或者单独复制。

<excludeGroupIds>com.example</excludeGroupIds>

加上这行配置后,groupId为com.example的依赖不会被复制到lib目录。然后需要确保这些内部模块的类能被程序Jar加载到。一种做法是把它们也打进程序Jar,另一种是单独复制到lib并手动维护。

实操心得:多模块项目里,我通常会把所有内部模块的类都打进程序Jar,只把第三方依赖放到lib。这样程序Jar稍微大一点,但内部模块的修改不需要单独处理,部署逻辑更简单。

5. 常见问题与排查技巧实录

5.1 启动报ClassNotFoundException的排查思路

这是依赖分离后最高频的问题。排查步骤可以按下面的顺序来:

首先确认报错的类属于哪个Jar。可以用grep在lib目录下搜索,或者用jar tf命令列出每个Jar的内容。找到对应的Jar后,检查它是否在lib目录下。

如果Jar不在lib目录,检查pom.xml里该依赖的scope。provided和test范围的依赖不会被复制。如果确实需要,把scope改成runtime或compile。

如果Jar在lib目录但依然报错,检查MANIFEST.MF里的Class-Path是否包含了这个Jar。有时候依赖太多,Class-Path行太长会被截断,导致部分依赖加载不到。这种情况下建议改用-cp方式启动,避免Class-Path过长的问题。

还有一种隐蔽的情况:依赖的Jar文件名里包含空格或特殊字符,导致Class-Path解析出错。这种情况比较少见,但一旦遇到很难排查。建议检查lib目录下的文件名,确保都是标准的英文和数字组合。

5.2 配置文件读取失败的解决方法

配置文件读取失败通常表现为应用启动时报FileNotFoundException或配置项为null。原因一般是配置文件没有被正确加载到classpath。

如果用java -jar启动,Class-Path里的./表示程序Jar所在目录。确认配置文件确实放在这个目录下,并且文件名大小写正确。Linux系统对文件名大小写敏感,Application.yml和application.yml是两个不同的文件。

如果用-cp方式启动,classpath里要包含配置文件所在目录。比如配置文件在/app/config目录下,启动命令里要加上-cp "/app/myapp.jar:/app/lib/*:/app/config"。

注意:Spring Boot读取配置文件的顺序是有优先级的,外置配置文件优先级高于Jar内部。如果发现配置没生效,检查是否有多个同名配置文件同时存在,导致读取了错误的那一个。

5.3 依赖冲突的识别与处理

依赖冲突在Fat Jar和分离方案里都会遇到,但分离方案下排查起来更方便,因为可以直接看到lib目录下有哪些Jar。

常见的冲突表现是NoSuchMethodError或AbstractMethodError,通常是运行时加载了错误版本的类。用mvn dependency:tree -Dverbose可以查看依赖树,找出冲突的依赖。Maven默认采用“最近优先”策略,即离项目最近的依赖版本生效。

解决冲突的方法有两种:一是在pom.xml里显式声明需要的版本,覆盖传递性依赖的版本;二是用exclusions排除掉不需要的传递性依赖。推荐第一种,因为更直观,版本管理也更清晰。

下面是一个常见问题的速查表:

问题现象可能原因排查方法解决方案
ClassNotFoundException依赖未复制到lib检查lib目录和scope调整scope为runtime
NoSuchMethodError依赖版本冲突mvn dependency:tree显式声明版本
配置文件读取失败配置文件不在classpath检查启动命令和文件位置调整-cp或放置位置
Class-Path过长被截断依赖数量过多查看MANIFEST.MF改用-cp方式启动
启动慢依赖Jar过多检查lib目录大小排除无用依赖

5.4 打包速度优化的几个技巧

依赖分离后,打包速度本身不会有太大变化,因为maven-dependency-plugin复制依赖也需要时间。但可以通过以下方式优化:

overWriteIfNewer设为true,避免重复复制未变更的依赖。includeScope设为runtime,减少复制的依赖数量。在开发阶段可以用-Dmaven.test.skip=true跳过测试,加快构建。如果依赖非常多,可以考虑用maven-dependency-plugin的copy-dependencies的silent配置减少日志输出。

另外,如果项目依赖很少变更,可以把lib目录作为基础镜像的一部分,部署时只上传程序Jar。这种做法在容器化部署场景下很常见,能进一步减少传输量。

实操心得:我在一个依赖超过200个的项目里,把overWriteIfNewer打开后,增量构建时间从40秒降到了8秒左右。这个配置对构建效率的提升非常明显,建议默认开启。

6. 不同部署场景下的适配方案

6.1 传统物理机部署

物理机部署是最直接的场景。把myapp.jar和lib目录放到同一个部署目录下,启动脚本放在旁边。目录结构建议统一,比如/opt/apps/myapp/下放myapp.jar、lib/、start.sh、stop.sh、config/。

启动脚本里用nohup后台运行,日志输出到指定文件。停止脚本用ps命令找到进程号后kill。如果用了spring-boot-starter-actuator,还可以通过HTTP接口做优雅停机。

这种场景下要注意的是文件权限。启动脚本要有执行权限,日志目录要有写权限。如果应用以非root用户运行,确保该用户对部署目录有读写权限。

6.2 容器化部署的目录规划

容器化部署时,Dockerfile的写法需要调整。基础镜像里可以先复制lib目录,再复制程序Jar。这样利用Docker的层缓存机制,依赖不变时只重新构建最后一层。

FROM openjdk:17-jre-slim WORKDIR /app COPY target/lib /app/lib COPY target/myapp.jar /app/myapp.jar COPY start.sh /app/start.sh RUN chmod +x /app/start.sh EXPOSE 8080 ENTRYPOINT ["/app/start.sh"]

这种写法的好处是,修改代码后重新构建镜像时,COPY target/lib这一层如果没有变化会直接使用缓存,只有COPY target/myapp.jar这一层会重新执行。构建速度比每次复制整个Fat Jar快很多。

提示:容器里运行Java应用时,JVM的内存参数要根据容器限制来设置。用-XX:MaxRAMPercentage代替固定的-Xmx,让JVM自动根据容器内存限制调整堆大小,避免被OOM Killer杀掉。

6.3 多环境配置的管理

多环境(开发、测试、生产)部署时,配置文件的管理是个重点。推荐的做法是把配置文件从Jar里剥离出来,按环境放在不同目录,启动时通过-Dspring.config.location指定配置目录。

java -cp "/app/myapp.jar:/app/lib/*" \ -Dspring.config.location=/app/config/ \ com.example.Application

这样同一份程序Jar可以在不同环境使用,只需要替换配置目录即可。配置目录可以用配置管理工具同步,或者通过环境变量注入敏感配置。

如果不想用Spring Boot的配置加载机制,也可以自己写代码读取外部配置文件。核心思路是在启动时把配置目录加入classpath,让Java的类加载器能找到配置文件。

6.4 与CI/CD流水线的集成

在CI/CD流水线里,构建阶段执行mvn clean package,产物是myapp.jar和lib目录。部署阶段根据环境不同,选择上传程序Jar还是连同lib一起上传。

如果流水线支持增量部署,可以只上传程序Jar。如果不支持,就把lib目录打包成压缩文件一起上传,但解压时注意不要覆盖已有的依赖,除非依赖有更新。

我一般会在流水线里加一个判断:如果pom.xml有变更,说明依赖可能变了,就上传完整的lib目录;如果只有Java代码变更,就只上传程序Jar。这个判断用git diff就能实现,能进一步减少部署时间。

实操心得:在流水线里保留最近几个版本的lib目录作为缓存,部署时先对比依赖列表,只上传新增或变更的Jar。这个优化在依赖稳定的项目里效果很好,能把部署时间压缩到极致。

7. 一些容易忽略的细节和我的个人体会

打包配置里有个细节值得单独说:maven-jar-plugin的excludes配置。默认情况下,src/main/resources下的所有文件都会被打进程序Jar。如果项目里有大量静态资源或者模板文件,程序Jar会变大。把这些资源外置到lib同级目录,通过Class-Path的./加载,可以进一步减小程序Jar的体积。

另一个细节是MANIFEST.MF里的Class-Path有长度限制。虽然Java规范没有明确规定上限,但实际操作中超过72个字符就需要换行,换行后的续行要以空格开头。maven-jar-plugin会自动处理换行,但如果依赖特别多,生成的Class-Path可能非常长,某些老版本的Java解析时会出问题。这种情况下用-cp方式启动更稳妥。

我在多个项目里实践下来,依赖分离方案最适合的是那种依赖稳定、业务代码频繁变更的项目。如果项目依赖经常升级,或者依赖数量很少(比如只有几个Jar),分离带来的收益就不明显,反而增加了部署的复杂度。选择方案的时候要结合项目实际情况,不要为了分离而分离。

最后分享一个排查依赖问题的小技巧:在启动脚本里加上-Djava.class.path参数,启动后可以通过jinfo命令查看实际的classpath内容。这个信息比MANIFEST.MF里的Class-Path更准确,因为它是Java运行时实际使用的类路径。排查类加载问题时,这个信息非常关键。

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

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

立即咨询