报这个错的时候,你手头多半正攥着一个打好的JAR包,敲下java -jar xxx.jar,满心期待地等着程序跑起来,结果控制台淡淡地吐出一行:
no main manifest attribute, in xxx.jar老手看到这句话,三秒钟就能反应过来,这是打包时没有声明入口类。新手可能当场懵掉,代码明明在IDE里跑得好好的,怎么打成JAR就翻车了?这个问题在Java开发里出现频率极高,尤其在第一次接触“可执行JAR包”这个概念的阶段,基本人人都会撞上一次。这篇东西就围绕这个错误展开,把它背后的执行原理、三种主流构建工具的正确配置、以及我实际踩过的坑和排查手段全部梳理一遍,争取让你看完之后,不止会修这一次,以后遇到类似问题也能自己推断个七八成。
适合看这篇内容的读者,包括刚接触Java打包的初学者、从IDE直接运行转到命令行部署的开发者,以及那些被Maven、Gradle反复拷打过的业务开发同学。前置知识要求很低,只要你写过基本的Java类,知道main方法长什么样,就够了。
1. 先从错误本身说起,它到底在抱怨什么
1.1 JAR包不是文件夹,它带着一份“自我说明书”
JAR是Java Archive的缩写,本质上就是一个ZIP压缩包,里面装着编译后的.class文件、配置文件、资源文件,但它比普通压缩包多了一样东西,就是位于包内META-INF/目录下的MANIFEST.MF清单文件。这个文件是JAR包的“自我说明书”,记录了这个包的版本、依赖、入口类等元信息。
一个典型的MANIFEST.MF长这样:
Manifest-Version: 1.0 Created-By: 1.8.0_291 (Oracle Corporation) Main-Class: com.example.MainApplicationMain-Class这一行就是JVM运行这个JAR包时寻找入口的依据。它告诉Java运行时:“你要启动,就从这个类的main方法开始”。少了这一行,JVM拿着一整个JAR包却不知道从哪里进程序,就像一个没有目录的书,别人无法直接翻到正文第一页。
1.2 java -jar和java -cp的运行差异
很多人困惑的点在于:为什么在IDE里右键运行好好的,打成JAR就不行了?
因为这两个路径压根不是一回事。当你在IDE里直接运行,IDE做的事是帮你编译好.class文件,然后在命令行拼接出一串java -cp target/classes com.example.MainApplication。这条命令里,入口类是你显式写出来的,JVM收到指令后直接去加载这个类,完全不用看什么清单文件。
但java -jar xxx.jar不一样。这条命令的意图是“从JAR包自描述的信息里找到入口”,所以JVM会先去解析MANIFEST.MF,尝试读取Main-Class属性。一旦读不到,它就不知道该加载哪个类,干脆拒绝启动,抛出no main manifest attribute。
顺着这个逻辑,你也就能理解为什么“代码没毛病”却报错了,因为出问题的根本不在代码逻辑,而在打包环节的配置。
1.3 入口类清单需要满足的几项硬性要求
就算你给MANIFEST.MF写了Main-Class,也不代表一定能跑通,有具体情况是配了也报错。清单里的入口类必须满足几个条件:
- 必须是类的全限定名,即包含包名的完整类名,比如
com.example.MainApplication,而不是简写的MainApplication - 该类必须带有
public static void main(String[] args)方法 - 如果入口类依赖了第三方JAR包,还需要在
MANIFEST.MF中通过Class-Path属性显式声明依赖路径
第三个条件是后面最常见的扩展问题。java -jar执行时,JVM不会自动去扫当前目录下的所有JAR,它只会根据清单文件里的Class-Path去加载依赖。所以一旦你的程序引入了第三方库,仅仅写了Main-Class是不够的,依赖加载不出来,后续大概率还会碰到ClassNotFoundException或NoClassDefFoundError。这一点后面实操部分会展开说。
2. 市面三种主流打包方式的正确配置
2.1 最原始的jar命令手动指定入口
如果你用的是比较新的JDK(9及以上),jar命令自带--main-class参数,可以在创建JAR时直接指定入口类,一步到位:
jar --create --file app.jar --main-class com.example.Main -C build/classes .--main-class参数的作用就是替你生成Main-Class属性并写进清单文件,省去手动编辑的步骤。其中-C build/classes .的意思是进入build/classes目录,把当前目录下的内容打包进JAR。这个写法非常直观,适合快速验证。
如果是老版本JDK(8及以下),jar命令没有--main-class参数,你需要先手写一个清单文件,再通过--manifest参数打包:
Manifest-Version: 1.0 Main-Class: com.example.Mainjar --create --file app.jar --manifest mymanifest.mf -C build/classes .手写清单文件有个历史悠久的坑:文件末尾必须有一个空行。也就是说,Main-Class这行写完,要再按一下回车,让文件以一个换行符结尾,否则最后一项属性在解析时有可能被漏掉,这个坑我后面会再提。
2.2 Maven项目的标准配置
Maven项目里,最直接的做法是配置maven-jar-plugin,在插件配置里写入入口类:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <manifest> <mainClass>com.example.Main</mainClass> </manifest> </archive> </configuration> </plugin>在pom.xml里加上这段后执行mvn clean package,生成的JAR包清单中就会有Main-Class属性,这个JAR就能直接通过java -jar运行了。
这里有个潜在大坑:如果你的项目引入了第三方依赖,这样打出来的只是一个普通JAR,依赖并没有被打进去。运行时依然会报ClassNotFoundException。解决办法通常有两个方向,一是用maven-dependency-plugin把依赖复制到同级目录,并在清单中写清Class-Path;二是用Spring Boot的插件直接打成fat JAR,把依赖全部塞进去。
Spring Boot项目的配置要单独说明。它用的不是上面那个maven-jar-plugin,而是spring-boot-maven-plugin:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <mainClass>com.example.Application</mainClass> </configuration> </plugin>打出来的Spring Boot fat JAR内部结构特殊,依赖全部放在BOOT-INF/lib目录下,入口类并不会直接写在Main-Class上,而是写着org.springframework.boot.loader.JarLauncher,真正的主类写在Start-Class属性上。很多第一次拆开Spring Boot JAR的人都会吓一跳,以为自己打错了,其实这是正常设计,启动器会先被加载,再由它去启动你指定的主类。这部分我会在第三节实操里给一个对照。
2.3 Gradle项目的配置
Gradle项目相比Maven简洁一些,直接在build.gradle里配置jar任务的manifest属性:
jar { manifest { attributes 'Main-Class': 'com.example.Main' } }执行gradle build后得到的JAR就带上了入口声明。如果你的项目没有第三方依赖,这样一个JAR就可以直接运行。如果引入了依赖,Gradle这边常见的做法是用Shadow插件打fat JAR:
plugins { id 'com.github.johnrengelman.shadow' version '7.1.2' } shadowJar { manifest { attributes 'Main-Class': 'com.example.Main' } }配置好后执行gradle shadowJar,会生成一个xxx-all.jar的fat JAR,运行时用这个而不是原始JAR。
这里额外提醒一句,Shadow插件的JAR命名规则默认是在原有名字后面加-all,如果你在CI流水线里拿固定文件名去发布,记得检查一下产物名,或者手动改一下archiveFileName属性。
3. 实操记录:从零复现到彻底修复
3.1 用一个小样例完美还原报错现场
为了说清楚这个问题,我特意搭了一个最小样例,不引入任何框架,纯手写一个带main方法的类,然后故意用错误的方式打包,让报错当场复现。
先建一个Main.java:
package com.example; public class Main { public static void main(String[] args) { System.out.println("Hello from executable jar"); } }编译:
javac -d build/classes src/com/example/Main.java这一步在Windows下注意src目录结构要和包名对齐,com/example/Main.java放在src下面。编译完成后,直接执行以下命令打出JAR:
jar --create --file app.jar -C build/classes .这个命令没有加--main-class,打出来的JAR包清单里自然没有入口声明。然后运行:
java -jar app.jar控制台立刻给出熟悉的报错:
no main manifest attribute, in app.jar复现成功。这时我们拆开看JAR包的清单,验证一下到底是缺了什么:
unzip -p app.jar META-INF/MANIFEST.MF输出只有两行:
Manifest-Version: 1.0 Created-By: 1.8.0_291 (Oracle Corporation)果然没有Main-Class属性,问题定位闭环。
3.2 修复一步到位,以及验证方式
这个场景的修复方式非常直接,重新打包时加上--main-class:
jar --create --file app.jar --main-class com.example.Main -C build/classes .再次查看清单:
unzip -p app.jar META-INF/MANIFEST.MF输出变为:
Manifest-Version: 1.0 Created-By: 1.8.0_291 (Oracle Corporation) Main-Class: com.example.Main此时执行:
java -jar app.jar控制台打印Hello from executable jar,修复成功。
这个样例足够小,却完整走了一遍“复现、定位、修复、验证”全流程。项目中遇到这问题时,建议你也用类似方法先在一个最小的样例上验证思路,再回到真实业务里去改构建配置。不要在几百个类的项目里反复试错,那是在浪费生命。
3.3 带第三方依赖的项目怎么打
上面样例没有任何外部依赖,修复起来很简单。实际项目中几乎不可能这么干净,一旦main方法里import了第三方库,事情就复杂一层。
为了演示,我在上面的Main.java里加一行import org.apache.commons.lang3.StringUtils;,然后照旧用--main-class打包并运行。结果会变成:
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils这个错虽然和no main manifest attribute不一样,但根子在同一个地方:JAR包没有声明或包含依赖。解决方案就看你的构建工具。
在Maven里,如果不想引入Spring Boot那套“全家桶”,可以手动配置maven-dependency-plugin复制依赖 +maven-jar-plugin声明类路径:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-dependency-plugin</artifactId> <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> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <manifest> <mainClass>com.example.Main</mainClass> <addClasspath>true</addClasspath> <classpathPrefix>lib/</classpathPrefix> </manifest> </archive> </configuration> </plugin>这里的addClasspath和classpathPrefix是配合使用的,插件会自动在清单文件里生成一行Class-Path: lib/xxx.jar lib/yyy.jar,运行时JVM会根据相对路径去加载同目录下lib文件夹里的依赖。执行mvn clean package后,把target下的主JAR和lib目录一起解压到服务器上,两个都不要漏掉,就能正常跑。
如果你图省事,用fat JAR方案更好,Maven侧可以直接用spring-boot-maven-plugin,不过它限定Spring Boot项目。第三方库场景下还有一个通用方案是maven-shade-plugin,它会把依赖的.class文件直接并进你的JAR里,实现一个真正的“单JAR运行”方案。配置大体如下:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.1</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.Main</mainClass> </transformer> </transformers> </configuration> </execution> </executions> </plugin>两个方向我都实际用过,简单说下取舍:类路径方案适合那种“依赖体积大、不想每次上传大文件”的部署方式,反正服务器上要放一堆JAR;shade方案适合“打包一个文件拷贝到哪都能跑”的场景,JAR会比较大,但部署省心。你有运维洁癖的话优先类路径,想省事就shade。
4. 常见问题与排查技巧实录
4.1 错误现象与原因速查表
这个错误在实际生产中出现的形态不止一种,下面这张表是我平时排查时基本会对照的一张速查表,整理给大家:
| 现象 | 可能原因 | 排查手段 | 修复方向 |
|---|---|---|---|
| no main manifest attribute, in xxx.jar | 清单没有Main-Class | unzip -p xxx.jar META-INF/MANIFEST.MF | 构建工具中配置入口类 |
| 清单有Main-Class但报错 | 类名写错或类不存在 | 检查全限定名、确认类是否被打进去 | 修正全限定名或重新打包 |
| java.lang.ClassNotFoundException | 第三方依赖缺失 | 看清单里的Class-Path,看fat JAR内容 | 使用fat JAR插件或声明依赖路径 |
| NoClassDefFoundError | 依赖目录不对或名字不匹配 | 对照清单里的lib路径和实际文件名 | 调整classpathPrefix或依赖复制目录 |
| 修改配置后仍然报错 | 打到了旧JAR或清单文件编码问题 | 确认打包时间和实际清单内容 | 清理旧产物后重新构建 |
| Spring Boot项目Main-Class指向自己的类 | 使用了Spring Boot fat JAR结构 | 查看清单里的Start-Class | Spring Boot项目用专门插件构建 |
4.2 配置写了却不生效的几个隐藏原因
这一节是全文最有实践价值的部分,因为很多时候你明明加了配置,报错还是如期而至。我总结过几个反复出现的隐藏原因。
原因一:清单文件末尾没有空行
如果你用手写manifest文件的方式打包,文件最后必须有一个空行。这个要求听起来很玄学,但JAR规范里确实规定清单文件以新行结尾。写文件时,如果最后一个字符卡在Main-Class那行末尾,没有任何换行符,可能解析不出最后一项属性。排查的时候用hexdump或编辑器的“显示行尾字符”功能看一眼会非常直观。
原因二:打到了旧产物
这个坑在中大型项目里极其普遍。你改了pom.xml或build.gradle,然后直接java -jar target/xxx.jar运行,但target目录里躺着上一次构建的老JAR,内容根本没变。因为某些构建工具在配置变更后不会自动全量重新打包,或者你运行的JAR根本不在本次构建输出目录里。排查方法很简单:看一眼JAR文件的修改时间,如果早于你改配置的时间,十有八九就是旧文件。
原因三:多模块项目里配错了模块的构建插件
多模块Maven项目里,父子模块关系比较复杂。入口类在web模块,依赖在common模块,结果你在根pom.xml里配置了mainClass,子模块构建时未必继承到你想要的那份配置。更常见的是,你运行命令时只package了某个子模块,而这个子模块只是依赖库,本身不包含入口类。这种场景要先理清哪个模块真正生成了可执行JAR,再在对应模块里配置插件。
原因四:清单文件编码问题
Windows环境下手写清单文件时,有些编辑器默认使用GBK编码保存,而清单文件解析的默认编码是UTF-8。如果你的入口类包名里有中文注释或非ASCII字符,或者Main-Class里的包名使用了特殊字符(虽然少见),就会出现“看起来写对了、实际读出来是乱码”的情况。手写清单文件时,建议用支持编码选择的编辑器,明确保存为UTF-8 without BOM格式。
原因五:类名大小写或包名拼写错误
Main-Class: com.example.Main和com.example.main在Linux系的文件系统里是两码事,JAR包内的路径是大小写敏感的。类文件编译后生成的目录路径必须与包名匹配,你写的全限定名也必须和实际编译产物完全一致。排查时可以jar tf xxx.jar列出包内文件列表,确认com/example/Main.class确实存在,然后对照自己的配置一字不差地进行匹配。
原因六:JDK版本升级后的Multi-Release JAR
JDK 9开始引入了Multi-Release JAR机制,可以通过manifest里的Multi-Release: true属性实现一个JAR在不同版本JDK下加载不同代码。如果你的JAR被打成了Multi-Release模式,而入口类写了版本限定,某些轻量级工具解析清单属性时可能只读取部分字段,导致旧版本JDK运行时报错。这种情况在真实业务里相对少见,但一旦碰上,排查方向会比较隐蔽,好在报错信息和常规的no main manifest attribute一模一样的概率很高。
4.3 几个实用的排查命令
排查JAR相关问题时,我日常离不开三四个命令,都极其简单,但胜在效率高:
# 查看JAR包内的所有内容 jar tf app.jar # 只查看清单文件内容 unzip -p app.jar META-INF/MANIFEST.MF # Windows下没有unzip时,用jar命令读取 jar xf app.jar META-INF/MANIFEST.MF type META-INF\MANIFEST.MFunzip -p是最推荐的,不落地文件,直接打印内容到控制台,排查起来不污染当前目录。注意Windows环境下需要安装解压工具,某些轻量解压软件的-p支持可能不全,我用的是自带unzip的Git Bash环境,一直很稳。
排查时先看清单,确认Main-Class这一行存在且正确,再看依赖,确认Class-Path指向的文件真实存在且路径没拼错,最后跑一下java -jar验证。三步走下来,基本能解决九成以上的问题。
4.4 如何判断要不要换成fat JAR方案
写到最后,说点更接地气的建议。遇到no main manifest attribute问题,很多人第一反应是给maven-jar-plugin补一个mainClass配置,改完确实能解决报错,但过几天又会在服务器上碰到ClassNotFoundException。这就是只修了一半。
我的建议是,在项目早期就做好选择:
- 如果你的项目是Spring Boot应用,直接用
spring-boot-maven-plugin或者对应Gradle的BootJar插件,打fat JAR,不要自己折腾类路径 - 如果是一个普通Java命令行应用,依赖不多,用
maven-shade-plugin或Shadow,一包走天下,部署最省心 - 如果是一个内部服务,部署目录固定,你也可以用
maven-dependency-plugin的类路径方案,但要注意拷贝依赖文件的步骤不要漏掉
这几种方案没有绝对优劣,关键是别混着用。我见过有人同时配了maven-jar-plugin和maven-shade-plugin,两个插件都试图写清单,最终产物被后执行的插件覆盖,明明配了入口类却还是报错。构建配置这东西,够用就好,别叠那么多东西。
最后再分享一个小经验:把“检查JAR清单”这一步做成CI流水线里的一环。每次构建完成后自动打印一次MANIFEST.MF的内容,出口类是否匹配提前看到。镜像上传前多花十秒钟看一眼,省得部署后才发现问题。反正这个错误我见过太多次了,九成都是构建配置的细节问题,剩下的那一成,等你读到这行字的时候,应该也已经能自己解决了。