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 groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId version3.3.0/version configuration archive manifest addClasspathtrue/addClasspath classpathPrefixlib//classpathPrefix mainClasscom.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.jarJava找不到依赖。这个细节很容易被忽略我第一次配置的时候就踩过这个坑。3.2 maven-dependency-plugin的复制策略接下来是maven-dependency-plugin它的任务是把依赖复制到lib目录plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-dependency-plugin/artifactId version3.6.0/version executions execution idcopy-dependencies/id phasepackage/phase goals goalcopy-dependencies/goal /goals configuration outputDirectory${project.build.directory}/lib/outputDirectory overWriteReleasesfalse/overWriteReleases overWriteSnapshotsfalse/overWriteSnapshots overWriteIfNewertrue/overWriteIfNewer includeScoperuntime/includeScope excludeTransitivefalse/excludeTransitive /configuration /execution /executions /pluginoutputDirectory指定依赖复制到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_CMDjava JVM_OPTS-Xms512m -Xmx1024m -XX:UseG1GC MAIN_CLASScom.example.Application CLASS_PATH$APP_HOME/myapp.jar:$APP_HOME/lib/* nohup $JAVA_CMD $JVM_OPTS -cp $CLASS_PATH $MAIN_CLASS /dev/null 21 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或者单独复制。excludeGroupIdscom.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为runtimeNoSuchMethodError依赖版本冲突mvn dependency:tree显式声明版本配置文件读取失败配置文件不在classpath检查启动命令和文件位置调整-cp或放置位置Class-Path过长被截断依赖数量过多查看MANIFEST.MF改用-cp方式启动启动慢依赖Jar过多检查lib目录大小排除无用依赖5.4 打包速度优化的几个技巧依赖分离后打包速度本身不会有太大变化因为maven-dependency-plugin复制依赖也需要时间。但可以通过以下方式优化overWriteIfNewer设为true避免重复复制未变更的依赖。includeScope设为runtime减少复制的依赖数量。在开发阶段可以用-Dmaven.test.skiptrue跳过测试加快构建。如果依赖非常多可以考虑用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运行时实际使用的类路径。排查类加载问题时这个信息非常关键。