
1. 打包之前先想清楚你要的到底是哪一种 jar在 IDEA 里对一个 Maven 项目点一次packagetarget目录下就会冒出一个 jar 文件这事儿看起来简单到没什么可讲的。但我见过太多次这样的场面jar 生成了文件名也对双击或者java -jar一跑报一行no main manifest attribute然后人就开始怀疑人生——明明编译没错、IDEA 里点绿三角跑得好好的怎么一打成 jar 就废了问题出在很多人把打包当成一个动词实际上它是三个不同的动词。普通 jar、可执行 jar、fat jar胖包是三种东西产出物长得几乎一样行为却完全不同。你要先回答一个问题这个 jar 是给谁用的给另一个项目当依赖引用用mvn install装进本地仓库被别的工程dependency引用 —— 这时候你要的是普通 jar里面只放自己写的 class 和资源依赖交给 Maven 的传递机制去解决。给运维或者自己拿去服务器上java -jar直接跑 —— 这时候你要的是可执行 jar而且大概率还得是fat jar也就是把spring-core、jackson、mysql-connector这些第三方依赖一起塞进去或者放到一个lib目录里并在清单文件里逐个声明路径。这个判断决定了后面所有的配置走向。选错了路径后面会一路拧巴要么 jar 打出来 3KB 一跑就ClassNotFoundException要么 jar 打出来 80MB 结果里面塞了两份同名依赖。1.1 三种 jar 的产出物对照我把这三种情况拉成一张表你在动手前先对号入座能省掉至少一轮返工。类型体积参考依赖处理典型使用方式宿主环境要求普通 jar几十 KB 到几百 KB不包含靠外部提供被其他工程依赖引用依赖必须由引用方提供可执行 jarthin与普通 jar 接近不包含靠Class-Path指向同目录 libjava -jar或java -cp机器上要有 lib 目录且路径对得上fat jar胖包十几 MB 到上百 MB全部内嵌java -jar直接跑只要有 JRE路径无关提示体积不是判断标准。判断标准是换一台干净的机器只把这个 jar 拷过去能不能直接跑起来。能跑就是 fat jar不能跑就得看看是不是漏了依赖。1.2 MANIFEST.MF 里那两个决定生死的字段jar 说到底是 zip解开之后META-INF/MANIFEST.MF才是灵魂所在。里面有两行东西几乎所有打包成功但跑不起来的问题都和它们有关Main-Class告诉 JVM 从哪个类的main方法进。没有它java -jar会直接报no main manifest attribute这不是你代码的问题是清单文件缺字段。Class-Path告诉 JVM 去哪些地方找其他 class。这个字段只在 thin jar 场景下有用而且路径是相对于 jar 所在目录的写错了就是静默的ClassNotFoundException。还有两个在实际项目里很容易踩的点。第一Class-Path一行放不下多个依赖时是要换行续写的Maven 插件会自动处理但手工改过清单文件的人经常在这里出错。第二清单文件必须以换行结尾而且每行不能超过 72 字节超了要续行——手工写清单基本就是在给自己挖坑所以下面讲的方式一本质上是让插件替你生成这一段。如果是 Spring Boot 项目清单里还会有Start-Class和Main-Class两行Main-Class指向的是启动器类而不是你的业务类这个后面细说。1.3 动手前的三项前置检查在 IDEA 里点任何按钮之前先把这三件事确认掉能挡掉大部分莫名其妙的问题。JDK 版本和 pom 里的编译级别要对齐。pom.xml里的maven.compiler.source/maven.compiler.target或者java.version如果写着 1.8而 IDEA 项目结构里 SDK 选的是 17编译能过但打出来的 class 是 17 的版本号拿到只装了 JRE 8 的服务器上跑就是UnsupportedClassVersionError。查版本号可以用javap -verbose看 major version52 是 Java 861 是 Java 17。编码要统一。中文字符串在 jar 里乱码十有八九是编译时用了平台默认编码。在properties里显式写project.build.sourceEncodingUTF-8/project.build.sourceEncoding别指望环境变量。依赖能不能拉下来。打包过程会走一次依赖解析如果本地仓库里缺货或者中央仓库拉不动打包会以一个看起来和打包无关的错误终止。这一步在国内网络环境下基本都要靠镜像仓库解决后面单独说。2. 方式一用 Maven 生命周期在 IDEA 里一把梭这是最正统也最推荐的方式核心逻辑就一句话把打包这件事交给 pom.xml 描述IDEA 只负责点按钮。好处是这套配置跟着代码走换台机器、换个 IDE、丢到流水线上都能复现。2.1 maven-jar-plugin 的默认行为藏了一个坑Maven 的package阶段默认绑定了maven-jar-plugin它会做一件很朴素的事把target/classes下的 class 和资源打成一个 jar。注意只有target/classes下的东西src/main/resources里的配置会被复制进去但pom.xml里声明的那一堆dependency一个都不会进。所以默认打包出来的东西在 IDEA 里点运行能跑通只是因为你 IDE 的 classpath 里帮你挂了依赖一旦离开 IDE 就原形毕露。这是新手最容易误解的地方。如果你要的是普通 jar给别的项目引用那默认行为就够了什么都不用配。如果你要能直接跑就必须往下加配置。2.2 给 jar 补上入口和依赖路径想让java -jar能跑缺两样清单里的Main-Class以及依赖。下面这段是我常用的组合maven-jar-plugin负责写清单maven-dependency-plugin负责把依赖复制到lib目录build finalNamemyapp/finalName plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId version3.4.1/version configuration archive manifest mainClasscom.example.demo.DemoApplication/mainClass addClasspathtrue/addClasspath classpathPrefixlib//classpathPrefix /manifest /archive /configuration /plugin plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-dependency-plugin/artifactId version3.6.1/version executions execution idcopy-deps/id phasepackage/phase goals goalcopy-dependencies/goal /goals configuration outputDirectory${project.build.directory}/lib/outputDirectory includeScoperuntime/includeScope /configuration /execution /executions /plugin /plugins /build三个参数值得解释一下为什么这么写。addClasspathtrue是让插件扫描依赖并生成Class-Path清单项不写这个就只有Main-Class一行。classpathPrefixlib/是给每个依赖统一加前缀这样生成的相对路径就是lib/xxx.jar而不是散落在当前目录里。includeScoperuntime是排除掉只在测试期用的依赖比如 JUnit能少拷一大半没用的 jar这点在依赖上百个的项目里体感很明显。跑完之后target目录下是这样target/ ├── myapp.jar └── lib/ ├── jackson-databind-2.17.1.jar ├── slf4j-api-2.0.13.jar └── ...拷走的时候myapp.jar和lib/必须在一起缺一不可。这是 thin jar 的代价体积小、依赖可替换但不能单独一个文件跑。2.3 Spring Boot 项目别自己折腾交给 repackage如果你用的是 Spring Boot上面那一套完全可以不写因为spring-boot-maven-plugin已经把这件事做完了而且做得更彻底——它是把依赖真的塞进 jar 内部而不是外挂一个 lib 目录。plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId configuration mainClasscom.example.demo.DemoApplication/mainClass /configuration executions execution goals goalrepackage/goal /goals /execution /executions /pluginrepackage这个 goal 名字很准确它先让maven-jar-plugin打出一个普通 jar然后再把这个 jar重新包装一遍。结果是target下会同时出现两个文件myapp.jar是最终可执行的胖包myapp.jar.original是原始的那个普通 jar。第一次看到.original文件的人经常以为打错了其实这是正常现象它是留给你排查用的——如果怀疑是插件包装环节出了问题可以直接对比两个文件的内容。胖包内部结构大致是这样的myapp.jar ├── META-INF/MANIFEST.MF ├── BOOT-INF/classes/ - 你自己的代码和 resources ├── BOOT-INF/lib/ - 所有依赖 jar └── org/springframework/boot/loader/ - 启动器BOOT-INF/classes这个层级是 Spring Boot 特有的它用自定义的类加载器来加载这也是为什么把胖包解压后直接改里面的 class 是没用的改动不会生效。清单里Main-Class指向的是启动器类Start-Class才是你的业务入口类。Spring Boot 3.2 之后启动器的包路径有调整所以别去手工写这两行让插件生成。2.4 IDEA 右侧 Maven 面板上的几个操作细节配置写完了实际操作在 IDEA 右侧的 Maven 工具窗口里。展开Lifecycle你能看到clean、validate、compile、test、package、install这一串。几个我用了很多年的习惯动作每次都先clean再package。增量编译偶尔会留下上一次的 class 残骸尤其是你删掉过某个类之后旧 class 可能还在target/classes里被打进 jar 之后行为诡异。双击package之前顺手双击一下clean或者直接用命令行一步到位。打包阶段别跑测试。package默认会执行test阶段如果项目里有连数据库或者起容器的集成测试打包时间会被拉长很多甚至直接失败。面板左上角有个带闪电图标的按钮鼠标悬停会显示Toggle Skip Tests Mode点一下再打包就是跳测试的。命令行对应的是mvn clean package -DskipTests如果要连编译都跳过用-Dmaven.test.skiptrue后者能再快一点。拉不到依赖的时候加-U。面板里双击是默认参数如果怀疑本地仓库里的快照版本过期了可以点面板上方的M图标Run Maven Build在弹窗里填clean package -U强制更新一次快照。离线模式慎用。面板上那个像插头一样的按钮是Toggle Offline Mode开着它 Maven 不会去远程仓库拉任何东西。偶尔网络不好时有人会打开它图个快结果打包报缺少依赖查半天才发现是这个开关没关。注意package和install的区别只有一个——install会在打包完成后把产物复制到本地仓库的~/.m2/repository对应目录里。你要给别的项目引用就必须install只是自己拿走跑就用package。3. 方式二走 IDEA 的 Artifacts不动 pom 也能出包有些场景不适合改pom.xml比如你接手的是一个别人维护的公共模块改打包配置会影响其他使用方或者你就是临时想导出一个包给别人演示不想污染版本库。这时候 IDEA 自带的构建产物Artifacts机制就是个不错的备选。它的定位和 Maven 完全不同。Maven 是声明式的配置写在 pom 里谁跑都一样Artifacts 是IDE 级的配置存在项目的.idea目录下属于本机工程配置。这决定了它适合临时用途不适合长期依赖。3.1 从零配一个 JAR 产物路径是File → Project Structure → Artifacts点左上角的选JAR → From modules with dependencies。弹窗里有几个关键项Main Class点右侧的文件夹图标选你的入口类。这一步的作用就是自动生成MANIFEST.MF里的Main-Class省得手写。JAR files from libraries这个选项决定依赖怎么处理后面细说。Directory for META-INF/MANIFEST.MF保持默认就行放在项目根目录下会生成一个src/META-INF/文件夹别手动改路径。Include tests默认不勾。勾了会把测试类和测试资源一起打进去除非你明确需要否则别勾。配好之后产物会出现在Build → Build Artifacts菜单里。这里要注意一个反直觉的点配置完不会自动构建必须手动触发一次。选中你的产物名子菜单里有Build、Rebuild、Clean三个选项。Build是增量的Rebuild是清空重来。我一般直接Rebuild省得被增量缓存坑。输出位置默认是out/artifacts/产物名_jar/和 Maven 的target目录完全是两套体系。这也是为什么两种方式混用时容易搞混——一个在target一个在out。3.2 依赖收进去的两种模式选错就是白干JAR files from libraries那里有两个选项差别很大extract to the target JAR把依赖里的 class 全部解压和自己的 class 混在一起打成一个 jar。听起来很美好但有个经典问题——如果两个依赖里有META-INF/services/下的同名文件SPI 配置文件后写的会覆盖先写的导致某些功能静默失效。Spring Boot 项目如果用这种方式打包几乎必然出问题因为自动配置的 SPI 文件就是靠这个机制加载的。copy to the output directory and link via manifest依赖不进去而是复制到输出目录下一个独立文件夹然后在清单的Class-Path里逐个声明。结果是out/artifacts/xxx_jar/下既有你的 jar又有一个或多个依赖目录。第二种更安全但代价是产物不是一个单独文件。如果你非要一个文件那还是老老实实用 Maven 的spring-boot-maven-plugin它在合并 SPI 文件这件事上是做了专门处理的。3.3 两种方式的结果差异对照我把实际用下来的体感整理成表方便你按场景选对比维度Maven packageIDEA Artifacts配置存放位置pom.xml随代码入库.idea/目录本机生效换机器/换 IDE 能否复现能不能需重新配能否接入自动化流水线能基本不能依赖合并的 SPI 处理Spring Boot 插件已处理容易覆盖需谨慎临时导包给别人演示略重很快产物默认路径target/out/artifacts/适合改公共模块的场景不合适会影响其他使用方合适我的实际选择长期项目、要上流水线的一律 Maven一次性演示、不能动 pom 的用 Artifacts。两者不要在同一台机器上对同一个模块交替使用target和out里的东西混在一起看会很容易判断错版本。4. 打完包之后三分钟验证这个 jar 到底能不能跑产出文件存在不等于产出文件可用。我养成的一个习惯是打包命令一结束立刻花三分钟做四步验证。这三分钟能挡掉后面在服务器上折腾半小时的尴尬。4.1 四步验证法第一步看内容清点。用 JDK 自带的jar命令列一下目录确认该在的东西都在jar tf target/myapp.jar | head -30 jar tf target/myapp.jar | wc -l重点看三件事入口类在不在、META-INF/MANIFEST.MF有没有、Spring Boot 项目的话BOOT-INF/lib/下依赖数量对不对。第二步读清单文件内容。这一步最容易被跳过但恰恰是问题高发区unzip -p target/myapp.jar META-INF/MANIFEST.MF在 Linux/macOS 上用unzip -p直接打印到标准输出Windows 上没有unzip的话可以先jar xf解出来再type。你要确认的是Main-Class或Start-Class的值和你的入口类全限定名完全一致大小写、包名一个字符都不能差。第三步看入口类的编译版本和签名。这一步用javapjavap -verbose -cp target/myapp.jar com.example.demo.DemoApplication | grep -E major|minormajor 版本号对应关系52 是 Java 855 是 Java 1161 是 Java 1765 是 Java 21。如果你打包用的 JDK 是 21 但目标服务器只有 17这一步就能提前发现问题不用等到UnsupportedClassVersionError。第四步真跑一次。这一步别省java -jar target/myapp.jar能起来看到启动日志才算真通过。如果项目需要参数最好也带上真实参数跑一次因为我见过不止一次不带参数能起来带参数就报参数解析失败的情况——原因是依赖里的某个命令行解析库被dependencyManagement降级了。4.2 反编译确认逻辑真的被打进去了偶尔会遇到一种很迷惑的情况jar 打出来了能启动但运行到某个功能点就报方法不存在。这通常意味着本地 class 被某个旧依赖里的同名类顶掉了。这时候反编译看一眼是最快的定位方式。javap就能反汇编看方法签名够用了javap -p -c -cp target/myapp.jar com.example.demo.service.DemoService-p是显示私有成员-c是反汇编字节码。想看得更舒服一点可以借助图形化的反编译工具打开这个 jar直接对照类路径层级。我通常的做法是把BOOT-INF/classes和解压出来的BOOT-INF/lib放在一起看确认某个包名下是不是出现了两份不同的类路径来源。这个技巧在排查本地改了代码但打包后行为没变的时候特别好使——因为十有八九是你改的 class 被打到了BOOT-INF/classes但类加载器优先加载了BOOT-INF/lib里那个依赖中的旧版本。提示反编译只用于排查自己的产物而且排查完就该去修依赖冲突用mvn dependency:tree找出来然后exclusions排除而不是长期依赖反编译结果去理解逻辑。4.3 资源、配置、编码这三处最容易漏资源文件的问题有个特点编译期完全不报错运行到那行代码才炸。常见的三类src/main/resources下的文件没进去。如果项目里配过resources覆盖了默认行为可能会把某些目录排除掉。用jar tf | grep 文件名确认一下最快。application.yml有多个 profile 版本只打进去了一个。检查pom.xml里有没有 profile 相关的资源过滤配置把其他文件过滤掉了。编码问题。中文乱码的排查顺序是先看project.build.sourceEncoding有没有写再看maven-compiler-plugin的encoding最后在启动参数里加-Dfile.encodingUTF-8兜底。三步都做了还乱码那就是终端本身的编码设置问题和 jar 无关了。5. 这几年我在打包环节踩过的坑以及对应的排查顺序前面讲的都是应该怎么做这一节讲的是出错了怎么查。我把四类问题的排查链路完整写出来你遇到类似报错可以照着重走一遍。5.1 报错先分类别一上来就翻代码java -jar之后报错信息只有那么几类先归类再动手效率能高好几倍。报错信息大概率原因第一步动作no main manifest attribute清单缺Main-Class读 MANIFEST.MF 确认字段ClassNotFoundException: com.xxx.Yyy依赖没进包或包名写错jar tf搜类是否存在NoClassDefFoundError类在但初始化失败通常是静态块抛异常看完整堆栈最下面的Caused byUnsupportedClassVersionError编译 JDK 版本高于运行 JDKjavap -verbose看 major 版本Error: A JNI error has occurred通常是 manifest 或 classpath 损坏检查清单格式和 jar 完整性ClassNotFoundException的排查顺序我固定成这样先jar tf确认依赖在不在在的话看清单里的Class-Path路径和实际目录结构对不对得上都对得上再考虑是不是同一个类的多个版本冲突最后才怀疑自己代码。顺序反了会在自己代码里白找半天。5.2 本地引入的外部 jar 打完包就消失了这个坑非常典型而且报错信息完全不指向原因。场景是手头有一个第三方提供的 jar 文件没有 Maven 坐标于是用system作用域引进来dependency groupIdcom.vendor/groupId artifactIdvendor-sdk/artifactId version1.0/version scopesystem/scope systemPath${project.basedir}/lib/vendor-sdk-1.0.jar/systemPath /dependency在 IDEA 里跑得好好的打包之后一跑就ClassNotFoundException: com.vendor.SdkClient。原因很直接system作用域的依赖默认不会被spring-boot-maven-plugin打包进去也不会被copy-dependencies复制因为它被当作运行环境已经提供的东西。三种解法按推荐程度排把 jar 装进本地仓库。用maven-install-plugin把文件按坐标安装进去然后把scope改成默认的compile之后一切正常。缺点是换台机器要重新装一次团队协作时要在文档里写清楚这一步。给spring-boot-maven-plugin开includeSystemScope。加上includeSystemScopetrue/includeSystemScope插件就会把system作用域的依赖也收进去。缺点是如果这个外部 jar 自己还有依赖那些传递依赖是带不进来的。用maven-dependency-plugin手工兜底。配置里加一段把lib目录下的所有 jar 复制到输出目录然后用清单指向它们。最灵活也最啰嗦。我一般选第一种因为它在团队里最好解释出问题也最容易定位。第二种适合那种就是个孤立的小工具 jar没依赖的场景。5.3 打包慢、仓库拉不动、测试挂掉这三件事经常一起出现都属于配置层面的问题不是打包本身有问题。打包慢的两个常见来源。一是每次都跑测试前面说过怎么跳过。二是依赖解析每次都去远程仓库问一遍如果配置里有SNAPSHOT依赖这个开销很明显。加-o走离线模式或者加-U强制更新一次之后再用本地缓存能改善不少。仓库拉不动是国内环境下最普遍的困扰。标准做法是在 Maven 的settings.xml一般在~/.m2/settings.xml没有就用 Maven 安装目录conf下的模板复制一份里配镜像mirrors mirror idpublic-mirror/id mirrorOf*/mirrorOf name公共仓库镜像/name urlhttps://maven.aliyun.com/repository/public/url /mirror /mirrorsmirrorOf写*是拦截所有仓库请求如果你只希望拦截中央仓库可以写central。这里有个新手常犯的错误只配了url忘了mirrorOf或者id和 settings 里已有的镜像重名导致配置静默不生效。改完之后一定要在 IDEA 里刷新一次 Maven 工程才生效改settings.xml不会自动触发刷新。还有一个细节IDEA 默认会用自己的内嵌 Maven它的配置目录和系统命令行 Maven 可能不是同一份。Settings → Build Tools → Maven里能看到User settings file和Local repository的实际路径。遇到过命令行能拉下来IDEA 拉不下来的情况基本都是这里指向了不同文件。测试挂掉分两种。一种是测试本身有问题比如连不上数据库跳过就好。另一种更麻烦测试代码编译不过这时候-DskipTests是没用的因为它只跳过执行不跳过编译得用-Dmaven.test.skiptrue。这条区别我记了很多年因为它救过我一次凌晨三点的发布。5.4 版本与 IDEA 内嵌 Maven 的隐性冲突最后一类问题最隐蔽表现是配置明明是对的但结果不对。常见的两个来源一是IDEA 用的 Maven 版本和你命令行用的不一致。IDEA 默认捆绑一个 Maven 版本可以在Settings → Build Tools → Maven里的Maven home path改成Bundled或者指定你自己的安装目录。如果插件行为在不同 Maven 版本上有差异maven-jar-plugin在旧版本上的默认 manifest 处理确实有区别就会出现两边结果不一致。团队协作时我倾向于统一指定一个版本别让每个人各用各的。二是JDK 版本的三处设置要一致Project Structure → Project里的 SDK、Project Structure → Modules里的 language level、以及Settings → Build Tools → Maven → Runner里的 JRE。这三处任何一处不匹配都可能出现IDEA 里编译通过但mvn命令行编译失败或者反过来的情况。打包前花十秒扫一眼这三处比出问题后查一小时划算得多。6. 从 jar 到镜像打包完成只是第一步jar 打好了很多时候下一步是要把它变成容器镜像。这件事本身不复杂写个Dockerfile就行但和打包环节有关联的地方值得提两句。第一别把target目录整个拷进镜像。target下有classes、generated-sources、maven-status一堆东西还有前面提到的.jar.original文件全拷进去镜像体积会莫名其妙大一圈。只COPY那个最终 jar 就够了FROM eclipse-temurin:17-jre WORKDIR /app COPY target/myapp.jar app.jar ENTRYPOINT [java, -jar, /app.jar]如果一定要写多阶段构建就把 Maven 打包阶段也放进Dockerfile这样镜像构建本身就是从源码到成品的完整流程不用依赖本机先跑一次打包。第二镜像里跑的是 fat jar就不要在Dockerfile里再挂 lib 目录。我见过把 thin jar 的产物结构jar 加 lib 目录原样搬到镜像里的做法容器里路径一变清单里的相对路径就失效了起来就报找不到类。要么镜像里用胖包要么把 lib 目录和清单的相对路径一起搬过去不要混着来。第三ENTRYPOINT用数组形式exec 形式而不是 shell 字符串形式。差别在于 shell 形式会多套一层 shell 进程容器收到停止信号时应用可能收不到导致优雅下线失效。这个差异在本地测不出来上了生产做滚动更新才会暴露。我个人在打包这件事上的体会是报错信息永远是对的只是它指的方向和你以为的方向不一样。no main manifest attribute指的不是你的代码有问题是清单文件缺字段ClassNotFoundException指的不是那个类不存在是它没被放进这个 jar 能看到的范围内。顺着报错分类往下查比凭经验猜快得多。最后再分享一个我一直在用的小技巧把mvn clean package -DskipTests存成一个 IDEA 的 Run Configuration名字就叫打胖包需要出包的时候点一下就走不用再去右侧面板里找。这个配置还能顺手把环境变量、工作目录、JDK 都固定下来比每次手点稳当。