1. 这个报错到底在说什么
如果你用 Spring Boot 以外的普通 Java 项目打过可执行 Jar 包,或者刚从 IDE 里导出一个 Jar 想试试java -jar xxx.jar能不能跑,大概率见过这样一行红字:
no main manifest attribute, in xxx.jar第一次见到这行报错的人,第一反应基本都是:我明明写了main方法啊,怎么还说没有主类?这个错误信息其实拆开看就明白,它是 JVM 在告诉你,这个 Jar 包里的META-INF/MANIFEST.MF清单文件缺少一个叫Main-Class的属性。也就是说,JVM 知道你要执行这个 Jar,但不知道该从哪个类进去,所以直接拒绝启动。
这个问题的触发场景非常集中,基本就三种:
- 用 IDE 自带的“导出 Jar”功能,但没填主类,或者填了没生效
- 用
jar命令手工打包,默认不会写入Main-Class - 配置了构建工具(Maven / Gradle),但打包插件少了关键配置,打出来的只是一个普通依赖包,根本没法直接
java -jar执行
这篇文章会从底层原理到具体解决的各个方案完整过一遍,同时覆盖临时应急、Maven 配置、Gradle 配置,以及 Spring Boot 这类特殊场景,把每个做法背后的原理也一并讲清楚,免得你这次照着配好了,下次换个项目又一头雾水。适合刚接触 Jar 打包的新手,也适合被这个问题反复折磨、想彻底弄明白根因的人。
提示:如果你只需要快速解决问题而不想深究原理,可以直接跳到第 3 章看 Maven 配置,那里给了完整的可直接复制的配置。
2. 先搞懂 Jar 包和 MANIFEST.MF 的关系
2.1 Jar 包本质上只是一个带特殊目录结构的 Zip
Jar 的全称是 Java Archive,本质上就是 Zip 压缩包,只不过META-INF/MANIFEST.MF这个文件被放在了固定位置,而且里面用特定格式的键值对描述这个包的元信息。
把任何一个 Jar 包解压出来,你都会看到类似这样的结构:
xxx.jar ├── META-INF/ │ ├── MANIFEST.MF │ └── xxx.SF (可选) ├── com/ │ └── example/ │ └── MainClass.class └── ...MANIFEST.MF是一个纯文本文件,格式上每行写一个属性,用“冒号加空格”分隔键和值。
Manifest-Version: 1.0 Created-By: 1.8.0_202 Main-Class: com.example.MainClass如果这个文件里没有Main-Class行,或者对应的类名写错了,那么当你执行java -jar时,JVM 就找不到启动入口,于是抛出no main manifest attribute错误。
2.2 为什么 IDE 导出的 Jar 也会出现这个错误
很多刚从 IDE 里点了几下就完成打包的人,遇到这个报错会觉得非常困惑:明明 IDE 里让填了主类,怎么导出来就丢了?
核心原因在于,有些 IDE 的导出向导会把“主类”这个信息存在项目配置里,但在生成 Jar 的清单文件时没有正确写进去。用某知名 Java IDE 导出时,如果选择的是“Existing MANIFEST file”而不是“Generate the MANIFEST file”,且项目里没有一个现成的清单文件,那么生成出来的MANIFEST.MF就只有Manifest-Version和Created-By两行,主类信息完全缺失。
还有一种容易踩的情况:手动填写了Main-Class,但写的类名带了.class后缀,比如com.example.MainClass.class。这种写法在 IDE 的输入框里可能不会当场报错,但启动时一定失败,因为 JVM 严格按照Main-Class: com.example.MainClass这种格式去反射加载类,多了.class就会类型找不到甚至直接报错。
2.3java -jar和java -cp的执行差异
明白了 Jar 包结构之后,可以顺便把java -jar与java -cp的区别弄清楚,因为网上不少人给的解决方案里会出现这两种命令,但很多人并不明白它们之间的本质差异。
java -jar xxx.jar:完全依赖 Jar 包里的MANIFEST.MF来找Main-Class,此时-classpath参数会被忽略,也就是说类路径只能从MANIFEST.MF的Class-Path属性中读取。java -cp xxx.jar com.example.MainClass:显式告诉 JVM 主类完整类名,JVM 再去xxx.jar里搜索这个类并加载执行,不需要Main-Class属性。
所以,如果你想临时验证一下一个没有主类信息的 Jar 包是否能运行,用java -cp显式指定主类是可以的。但这个办法对依赖了很多第三方库的普通 Jar 并不友好,因为第三方依赖不在同一个 Jar 里时,-cp后面要写一串非常长的依赖路径,操作起来相当痛苦,所以才需要借助构建工具把依赖打进去,并生成正确的清单。
3. 临时应急方案:不重新打包也能立刻跑起来
3.1 最快速验证:直接用 java -cp 指定主类
假如你在设备上拿到一个别人给的 Jar,里面没有Main-Class属性,但你知道入口类的完整类名,可以用下面的命令先跑起来:
java -cp your-application.jar com.example.MainClass注意,-cp后面不管写几个 Jar 都行,Windows 上多个路径用分号分隔,Linux / macOS 上用冒号分隔:
java -cp your-application.jar:lib/dependency1.jar:lib/dependency2.jar com.example.MainClass这个方案的局限很明显:如果你的程序用到了超过十几个依赖包,手写这一长串路径非常容易出错,而且没有任何可持续性。但作为一种临时验证手段,它不用重新构建任何东西,拿来排查“我代码里到底有没有写对主类”特别方便。
3.2 手工修改 MANIFEST.MF 后重新打 Jar
还有一种不借助构建工具、完全手工操作的方案,适合资源受限或者没有安装 Maven / Gradle 的环境。
先解压原来的 Jar 包:
mkdir temp && cd temp jar xf ../your-application.jar然后用任意文本编辑器打开META-INF/MANIFEST.MF,确认最后几行,并在末尾补上主类信息:
Main-Class: com.example.MainClass保存后回到 temp 目录的上层,重新打包:
jar cfm new-application.jar temp/META-INF/MANIFEST.MF -C temp .这里有个关键区别:jar cfe可以在打包时直接指定入口类:
jar cfe new-application.jar com.example.MainClass -C temp .e参数就是“指定入口类”,它会自动帮你把Main-Class写进MANIFEST.MF,不需要手工编辑。但要注意,如果你用jar cf不带e,又没在MANIFEST.MF里写主类,打出来的包依然会报同样的错误。
手动改清单文件这个方法确实有用,但有一点需要特别说明:它只能解决主类缺失问题。如果原 Jar 包本身就不是可执行包,依赖的第三方库都没有打进去,或者MANIFEST.MF里的Class-Path没有指向这些依赖,那修改主类也只能让 JVM 把它们加载进来,不会自动帮你处理依赖问题。换句话说,这个方法本质上是一种“修补”,不是一种“构建”。
注意:
jar命令是 JDK 自带的工具,不需要额外安装。如果你连jar命令都没有,说明 JDK 环境没配好,先回去检查JAVA_HOME和 PATH 环境变量,这是比本问题更基础的问题。
4. 根治方案:让 Maven 每次都打出可执行 Jar
4.1 最常规做法:maven-jar-plugin 配 manifestEntries
其实对于大多数非 Spring Boot 的普通 Maven 项目,产生这个错误的最大原因就是很多人只用了maven-jar-plugin的默认配置。默认情况下,这个插件确实会把MANIFEST.MF生成出来,但里面只有Manifest-Version和Created-By,没有Main-Class,于是打出来的包就变成了“能放进 classpath 但不能直接执行”的包。
在pom.xml里加上这样的配置:
<build> <finalName>my-app</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.3.0</version> <configuration> <archive> <manifest> <mainClass>com.example.MainClass</mainClass> </manifest> </archive> </configuration> </plugin> </plugins> </build>然后执行:
mvn clean package这样打出来的target/my-app.jar打开MANIFEST.MF会看到:
Main-Class: com.example.MainClass这是最小可用配置。但要注意一个令人意外的事实:Maven 项目里官方推荐的方式并不是给maven-jar-plugin配主类,而是用maven-assembly-plugin或者maven-shade-plugin。后面这两个插件会打出一个“胖 Jar”或者说“uber-Jar”,把项目依赖的所有第三方类都塞进同一个包里。如果只配maven-jar-plugin,你的程序依赖了其他 Jar 的话,直接java -jar一样会报ClassNotFoundException。
4.2 一键打出可执行胖 Jar:maven-assembly-plugin
对于第三方依赖较多的普通 Maven 项目,最合适的方案是maven-assembly-plugin。它能生成一个包含所有依赖类文件的超大 Jar,打出来的包本身就是完整的,可以直接java -jar。
在pom.xml的<plugins>里加入:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>3.7.1</version> <configuration> <archive> <manifest> <mainClass>com.example.MainClass</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>配置完成后运行:
mvn clean package在target目录下会生成两个 Jar,一个是原来的xxx.jar,另一个是xxx-jar-with-dependencies.jar,后者才是包含所有依赖的可执行 Jar。
这里存在的坑:如果你只配置了 plugin 而忘了加<executions>,那么打包时不会自动执行,你需要手动运行mvn assembly:assembly才能生成胖 Jar。新手很容易踩到这个,复制配置的时候一定要把<executions>那一整段也带上。
顺带解释一下为什么这里不推荐maven-shade-plugin。两者都能打胖 Jar,区别在细节:shade会对依赖做重定位和合并META-INF下的配置文件,功能更强但配置也更复杂;assembly简单直接,适合大多数普通场景。如果你的项目里有多个依赖同时包含同名配置文件(比如多个spring.factories),那assembly默认策略会产生覆盖,不建议使用;否则用assembly就够了。
4.3 Spring Boot 项目实际上是一个特例
如果你用的是 Spring Boot,情况不太一样。Spring Boot 项目里通常并不会直接配置maven-jar-plugin的Main-Class,因为 Spring Boot 官方插件spring-boot-maven-plugin在 repackage 阶段已经把启动类写入了MANIFEST.MF。
Spring Boot 打包核心配置只需要做到这一点:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <!-- 不要加 <executions> 的跳过配置 --> </plugin> </plugins> </build>执行mvn package之后,你能从target下看到一个原始 Jar 和一个最终可执行的 Jar。Spring Boot 在 repackage 时会改写MANIFEST.MF,写上类似这样的内容:
Main-Class: org.springframework.boot.loader.JarLauncher Start-Class: com.example.YourSpringBootApplication看到这里可能有人会疑惑,为什么Main-Class是JarLauncher而不是自己的启动类?因为这个机制就是要让JarLauncher先运行,再由它去加载嵌套在BOOT-INF/lib下的所有依赖类,最终执行Start-Class指定的那个类。如果你在这个基础上叠加配了maven-jar-plugin的mainClass,有时反而会出问题。我曾经见过有人用 Spring Boot 项目,同时配了maven-jar-plugin的mainClass,结果 Spring Boot repackage 之后把Main-Class写成了自己的业务主类,导致嵌套 Jar 加载机制失效,启动报了一堆根本看不懂的类加载异常。
所以如果你的是 Spring Boot 项目,第一反应应该是检查spring-boot-maven-plugin是否存在,而不是去配置其它打包插件。
5. Gradle 项目怎么处理同类问题
5.1 用 Gradle Jar 任务的 manifest 属性
Gradle 项目跟 Maven 不同,它默认打包逻辑在jar任务里,如果build.gradle里没有任何配置,打出来的包同样没有Main-Class。在build.gradle里加这么一段:
jar { manifest { attributes 'Main-Class': 'com.example.MainClass' } }然后执行:
gradle build或者:
gradle jar编译后到build/libs目录下能看到生成的 Jar 包。但同样的问题也会发生:如果项目有第三方依赖,那么这个简单配置打出来的包还是跑不起来,因为依赖不在里面。Gradle 里可以把所有依赖一起打进去,最简单的方式是使用shadow插件。
5.2 实用方案:引入 Shadow 插件打胖 Jar
在build.gradle中加:
plugins { id 'java' id 'com.github.johnrengelman.shadow' version '8.1.1' } jar { manifest { attributes 'Main-Class': 'com.example.MainClass' } } shadowJar { archiveBaseName = 'my-fat-jar' archiveClassifier = '' mergeServiceFiles() }配置之后执行:
gradle shadowJarbuild/libs/my-fat-jar.jar就是可直接执行的全量包。Shadow 插件本质上做的事情跟 Maven 的 shade 插件类似,都能把依赖打进去。这里mergeServiceFiles()的调用是为了处理META-INF/services下的服务描述文件,如果你的项目用到了java.util.ServiceLoader机制,不合并的话会有服务加载不到的问题。
如果你对一个项目的构建方式感到不放心,可以在执行完打包后直接用 unzip 命令查看一个关键文件的 last modified 时间,再去打开META-INF/MANIFEST.MF确认。
Main-Class: com.example.MainClass这两步能快速确认你的配置确实生效了,不用等到抛出异常才知道结果。
6. 深入 MANIFEST.MF 文件格式的细枝末节
6.1 文件格式中容易踩坑的三个细节
很多人以为写完主类就万事大吉了,实际上MANIFEST.MF文件有几个非常阴险的细节,任何一个粗心大意都会让 JVM 报同样的no main manifest attribute错误。
第一,行尾必须使用回车换行。在 Windows 上如果你用记事本编辑MANIFEST.MF,默认的 ANSI 编码加 CRLF 行尾通常没事;但如果你用 Linux 工具链,却用了 CR-only 或者 LF 的行尾,JVM 解析时可能把后面几行都吞掉,从而读不到Main-Class属性。规范里写的是“每行以 CRLF 结束”,实际处理中 LF 也基本能通过,但 CRLF 是最保险的。
第二,冒号后面必须跟一个空格。Main-Class: com.example.MainClass如果写成Main-Class:com.example.MainClass,冒号后没有空格,JVM 解析时会把整个com.example.MainClass前面的字符都当成属性名的一部分,导致Main-Class属性缺失。
第三,值不能太长,也不能在行中间随意换行。规范规定MANIFEST.MF的物理行不能超过 72 字节,超过时要通过“续行”的方式处理,续行以空格开头。手动编辑的时候,尽量把主类名控制得很短,不触发续行。一旦触发了续行,你必须保证续行行首是一个空格,否则 JVM 会解析失败。不过,正常情况下你的完整类名很少会超过 72 字节,除非你的包名非常臃肿。
6.2 多主类项目要怎么指定启动类
很多时候一个项目里有多个类都写了main方法。你可能用某个类做过本地测试,又用另一个类做真正的入口。打包的时候如果配错了主类,java -jar报的可能是“找不到或无法加载主类”而不是no main manifest attribute。
如何在构建工具里切换主类?在 Maven 中只需要修改一处:
<mainClass>com.example.AnotherMain</mainClass>但如果要同时打出两个不同主类的 Jar,那就需要额外增加执行配置或者使用 profile 来控制。实际项目中更常见的做法是,一个项目只保留一个对外的主入口类,其余带main的类都定位为测试入口,不应参与正式打包。
6.3 给 Jar 加依赖路径:Class-Path 属性
有时你不想打胖 Jar,而是希望保持依赖在外部,但希望java -jar能把它们都加载进来,那么Class-Path属性就有用了。在MANIFEST.MF里写入:
Main-Class: com.example.MainClass Class-Path: lib/commons-lang3.jar lib/okhttp.jar执行时 JVM 会在启动目录下寻找lib/目录中的这些依赖。要注意Class-Path里的路径是相对于你执行java -jar时的当前目录,而不是 Jar 包所在目录,这个细节非常容易害人。有人把 Jar 换了个目录执行,就报ClassNotFoundException,排查半天才发现是相对路径的基准目录变了。
7. 常见问题排查与处理速查表
7.1 按现象快速定位问题
遇到no main manifest attribute时,先不要慌,按下面几条路径依次排查:
| 现象 | 可能原因 | 验证方法 |
|---|---|---|
MANIFEST.MF里没有Main-Class行 | 打包插件没配主类 | 解压 Jar 查看清单 |
有Main-Class但类名带.class后缀 | 填错了类名格式 | 直接用 unzip 看清单内容 |
有主类但运行时报ClassNotFoundException | 依赖没打进 Jar | 使用胖 Jar 方案或者检查 Class-Path |
| 有主类但运行时报 “无法找到主类” | 类名不完整或者包名不对 | 用java -cp尝试显式加载 |
| Spring Boot 项目报此错误 | spring-boot-maven-plugin 缺失或被跳过 | 确认插件存在且未配置 skip |
| 手动修改过清单后报解析错误 | 编码或者行尾问题 | 使用file命令查看编码,重新保存为 UTF-8 无 BOM |
最直接的验证命令:
unzip -p your-application.jar META-INF/MANIFEST.MF在 Windows 上如果没有 unzip,可以用jar xf解压,或者用压缩软件直接查看清单文件内容。看到Main-Class一行是否存在,问题就瞬间明确了。
7.2 排查问题的四个实操心得
第一个心得,遇到问题不要重新打包十次,先看清单文件。很多时候一次unzip -p省下来的时间,比你改十次 pom 配置还要多。因为编译和打包本身很快,但反复试错后人会变得麻木,容易把一些显而易见的错误视而不见,比如主类名少写了一个字母。
第二个心得,注意你的项目里有没有多个构建工具混用。比如同时存在 Maven 和 Gradle 的配置文件,你在 IDE 里点了 Gradle 的打包按钮,却在命令行执行 Maven 命令,两个工具打出的包行为可能完全不同。处理方法是统一一种构建方式,不要把两边的输出混在一起。
第三个心得,对于手动编辑MANIFEST.MF的场景,保存编码一律选择 UTF-8 无 BOM。带 BOM 的 UTF-8 会在文件开头多出三个不可见字节,有些情况下 JVM 解析器会把这些字节跟Manifest-Version挤在一起,导致整个清单解析错乱。Windows 记事本的“UTF-8”在旧版本上其实带 BOM,用 VS Code 或 Notepad++ 选择“UTF-8 without BOM”保存更稳妥。
第四个心得,如果你还在用 IDE 的导出功能而不是构建工具,建议尽早把项目迁移到 Maven 或 Gradle 上。一方面 IDE 的导出方式不好自动化、不方便团队共用,另一方面 IDE 导出的 Jar 在依赖处理上通常很原始,极容易踩各种奇奇怪怪的坑。用构建工具管理打包,主类配置是显式的、可审查的,团队协作时也不会出现“我这边能跑你那边不能跑”的分叉。
7.3 为什么改了 MANIFEST.MF 还是报错
这是一个容易让人产生“明明改了却无效”感觉的情况。最典型的原因是,项目里存在多个 Jar 包,你改了其中一个,但实际运行的是另外一个。尤其是在target目录下有多个产物时,比如既有普通包又有jar-with-dependencies包,手一滑运行了错误的那一个,就会陷入“白改”的困境。
处理方法是切换到输出目录,用ls -lt按时间排序,或者说运行之前先用 unzip 命令确认你将要运行的那个 Jar 确实包含了改动后的主类信息。不依赖自己的记忆,直接验证文件内容,是排查这类问题最有效的方式。
8. 最后的经验总结
我自己遇到这个错误的经历挺曲折,第一次是在给别人交付工具时,辛辛苦苦打了 Jar,结果对方一执行就报no main manifest attribute。当时的反应是重新去网上翻各种配置,但始终没解决。后来冷静下来,用解压工具打开 Jar 看了一眼MANIFEST.MF,才意识到 IDE 导出时根本没写主类。从那次之后,我只要遇到这类启动类问题,第一动作永远都是先看清单文件,而不是盲目改构建配置。
在这个过程里,我开始对构建工具保持一种审慎的态度:任何打包配置更新之后,都要顺手确认一下产物里的清单文件内容是不是符合预期。我见过很多同事,反复调整pom.xml里的主类配置,结果一直没发现自己的程序是 Spring Boot,这个项目的MANIFEST.MF根本不应该由自己手动去定义主类。针对不同项目类型去了解对应的打包机制,比记住一堆配置模板更有用。
最后,再分享一个实用小技巧。如果你看不到MANIFEST.MF里的非打印字符,可以在 Linux / macOS 上用cat -e查看:
unzip -p your.jar META-INF/MANIFEST.MF | cat -e$符号表示行尾,如果哪一行末尾看不到$,那大概率是行尾符有问题。这些看似微小的细节,往往就是一个人从“刚解决了这个错误”到“彻底理解了 Java 打包机制”的分水岭。希望这篇内容能帮你在下一次遇到 Jar 启动问题时少走弯路,直接看到问题本质。