IntelliJ IDEA 能跑,打包成 jar 就炸:聊聊 IDE classpath 和 fat jar classpath 的那些坑

IntelliJ IDEA 能跑,打包成 jar 就炸:聊聊 IDE classpath 和 fat jar classpath 的那些坑 一个经典场景你写完代码在 IntelliJ IDEA 里点绿色三角运行一切正常。信心满满地mvn clean package把 fat jar 扔到测试环境/生产环境启动瞬间报错Caused by: java.lang.NoClassDefFoundError: com/sun/jersey/client/apache4/ApacheHttpClient4 at com.netflix.discovery.shared.transport.jersey.Jersey1TransportClientFactories.newTransportClientFactory(...) ... Caused by: java.lang.ClassNotFoundException: com.sun.jersey.client.apache4.ApacheHttpClient4你盯着屏幕愣住明明本地跑得好好的怎么打包就不行了类去哪了这不是个例而是 Spring Boot 项目里最常见的一类本地能跑、生产炸问题。根子在于IntelliJ IDEA 运行时的 classpath和spring-boot-maven-plugin:repackage打出来的 fat jar 的 classpath根本不是一回事。先搞清楚什么是 fat jar在聊差异之前得先明白 fat jar 是什么。普通 jar只包含你自己的编译产物target/classes下的.class文件和资源文件不包含任何第三方依赖。运行时你得自己用-cp把所有依赖 jar 拼到 classpath 里像这样java-cpmy-app.jar:lib/spring-boot-2.7.18.jar:lib/eureka-client-1.10.18.jar:... com.example.Main依赖一多这条命令能写几屏长部署噩梦。fat jar也叫 uber jar / executable jar把你自己的代码和所有第三方依赖打成一个单独的 jar 文件。运行时只需要java-jarmy-app.jar一个文件搞定部署、分发都方便。Spring Boot 通过spring-boot-maven-plugin的repackagegoal 生成 fat jar。fat jar 的内部结构Spring Boot 的 fat jar 不是简单地把所有.class混在一起那样会有同名类冲突而是采用嵌套 jar结构recommend-1.1.0-RC1.jar ├── BOOT-INF/ │ ├── classes/ # 你自己的代码target/classes 的内容 │ │ └── com/yumchina/recommend/ApiServerApplication.class │ └── lib/ # 所有第三方依赖每个保持原 jar 形态 │ ├── spring-boot-2.7.18.jar │ ├── spring-cloud-netflix-eureka-client-3.1.8.jar │ ├── eureka-client-1.10.18.jar │ ├── jersey-apache-client4-1.19.4.jar │ └── ... (几十上百个 jar) ├── org/springframework/boot/loader/ # Spring Boot 启动器代码 │ ├── JarLauncher.class │ ├── LaunchedURLClassLoader.class │ └── ... └── META-INF/ ├── MANIFEST.MF # 指定 Main-Class 为 JarLauncher └── spring.factoriesMANIFEST.MF里的Main-Class指向org.springframework.boot.loader.JarLauncher而不是你的业务 main 类。启动流程是java -jar执行JarLauncher.main()JarLauncher创建LaunchedURLClassLoader这个类加载器能从嵌套 jar 里读.class标准URLClassLoader做不到这点JarLauncher用这个类加载器反射调用你的业务 main 类com.yumchina.recommend.ApiServerApplication为什么用嵌套 jar 而不是直接展开如果把所有依赖的.class直接解压到 jar 根目录会有几个问题同名类冲突两个 jar 里都有module-info.class、META-INF/services/xxx合并后互相覆盖签名失效签名 jar 的META-INF/*.SF文件被打散后校验失败资源覆盖application.properties、schema.sql这类资源文件同名会互相覆盖嵌套 jar 保持每个依赖的完整性避免这些问题代价是需要自定义类加载器LaunchedURLClassLoader来读取嵌套结构。fat jar 和普通 jar 的关键区别维度普通 jarfat jar包含依赖否只有自己的代码是所有依赖打进BOOT-INF/lib运行方式java -cp app.jar:lib/* Mainjava -jar app.jar部署产物app.jar lib/ 目录 启动脚本单个 jar 文件类加载器标准URLClassLoaderLaunchedURLClassLoader能读嵌套 jar自包含性否依赖外部 lib 目录是一个文件即可运行理解了 fat jar 的结构下面就能看懂为什么它的 classpath 和 IDEA 不一样了。两者的 classpath 到底差在哪IntelliJ IDEA 的 classpathMaven 依赖求和IDEA 跑 main 方法时构建的 classpath 大致是这样的target/classes src/main/resources ~/.m2/repository/.../依赖A-1.0.jar ~/.m2/repository/.../依赖B-2.0.jar ~/.m2/repository/.../依赖C-3.0.jar ...关键点IDEA 用的是 Maven 解析后的完整传递依赖图。Maven 解析依赖时是个求和过程——把所有依赖、传递依赖都拉进来按 mediation 规则就近原则、声明顺序选版本最后汇总成一个 classpath。这个 classpath 里有些 jar 可能来自你没直接声明的传递依赖甚至是provided/testscope 的取决于 IDEA 的运行配置。只要 Maven 能解析到IDEA 就能塞进 classpath。fat jar 的 classpathrepackage的精确产物spring-boot-maven-plugin:repackage干的事是把项目编译产物 所有compile/runtimescope 的依赖按 Maven 解析后的最终依赖树塞进BOOT-INF/lib/目录然后打成一个可执行 jarrecommend-1.1.0-RC1.jar ├── BOOT-INF/ │ ├── classes/ # 你自己的代码 │ └── lib/ │ ├── spring-boot-2.7.18.jar │ ├── eureka-client-1.10.18.jar │ ├── jersey-apache-client4-1.19.4.jar # 必须在依赖树里才会进 │ └── ... ├── org/springframework/boot/loader/ # 启动器 └── META-INF/运行时Spring Boot 的LaunchedURLClassLoader从BOOT-INF/lib/*.jar这些嵌套 jar里加载类。关键点fat jar 的内容完全由 Maven 最终依赖树决定。如果某个 jar 被某处exclusion排掉了它就不在依赖树里repackage不会把它塞进BOOT-INF/libfat jar 里就没有它。核心差异维度IntelliJ IDEAfat jar (repackage)classpath 构建方IDEA基于 Maven 解析结果spring-boot-maven-plugin基于 Maven 解析结果classpath 形态本地文件系统的 jar 列表BOOT-INF/lib/下的嵌套 jar类加载器AppClassLoader / IDEA 自定义LaunchedURLClassLoader能读嵌套 jar依赖来源可能包含 devtools、IDE 注入的库严格按 Maven 依赖树compile/runtimescopeexclusion 的影响可能被其他传递路径补回来一旦从依赖树排除彻底没了版本仲裁同 Maven 仲裁同 Maven 仲裁理论依据来自 Spring Boot 官方文档对 executable jar 的说明fat jar 用嵌套结构保证依赖自包含但这也意味着 classpath 严格依赖打包时的依赖树。为什么这种差异会咬人差异本身不可怕可怕的是某些类在 IDEA 里碰巧能找到但在 fat jar 里彻底没有。常见场景场景一传递依赖被 exclude但 IDE 里还有别的路径补回来这是本文开头那个 Eureka/Jersey 例子的本质。下面详细展开。场景二spring-boot-devtools的幽灵依赖spring-boot-devtools在开发期很方便热重启但它会带入一堆传递依赖比如某些日志、监控库。IDEA 跑的时候这些库在 classpath 里你的代码可能无意中用到了它们。一打 fat jardevtools 默认不进生产包那些幽灵依赖没了NoClassDefFoundError就来了。场景三providedscope 的库在生产环境存在servlet-api这类provided库IDEA 里能加载因为 IDEA 把它放进 classpath打包不进 fat jar——但生产容器Tomcat会提供所以通常没事。但如果你在 main 方法里直接用了provided库的类且部署到非 servlet 容器比如纯 jar 启动就会炸。场景四版本不一致导致类不存在Maven 的就近原则仲裁依赖 A 拉jersey-core:1.19.1依赖 B 拉jersey-core:1.19.4最终选哪个看声明顺序和层级深度。IDEA 和repackage用的是同一套仲裁但如果你本地.m2缓存和 CI 构建机的缓存不一致比如本地有 1.19.4 缓存CI 没有只拉到 1.19.1可能出现本地跑 1.19.4 的 APICI 打出来是 1.19.1 缺方法。一个真实例子Eureka 的 Jersey1 fallback这是我实际踩过的坑完美诠释了上面的差异。背景项目是 Spring Boot 2.7 Spring Cloud 2021.0.9用 Eureka 做服务注册。pom 里引了dependencygroupIdorg.springframework.cloud/groupIdartifactIdspring-cloud-starter-netflix-eureka-client/artifactId/dependency本地 IDEA 跑application-dev.ymlEureka 注册正常。打包部署到生产 Pod启动报Caused by: java.lang.NoClassDefFoundError: com/sun/jersey/client/apache4/ApacheHttpClient4 at com.netflix.discovery.shared.transport.jersey.Jersey1TransportClientFactories.newTransportClientFactory(Jersey1TransportClientFactories.java:59)根因spring-cloud-starter-netflix-eureka-client-3.1.8.pom在引用eureka-client时显式 exclude 了 jersey 三件套dependencygroupIdcom.netflix.eureka/groupIdartifactIdeureka-client/artifactIdversion1.10.18/versionexclusionsexclusionartifactIdjersey-client/artifactIdgroupIdcom.sun.jersey/groupId/exclusionexclusionartifactIdjersey-core/artifactIdgroupIdcom.sun.jersey/groupId/exclusionexclusionartifactIdjersey-apache-client4/artifactIdgroupIdcom.sun.jersey.contribs/groupId/exclusion/exclusions/dependencySpring Cloud 这么做是有设计意图的它希望你用RestTemplateTransportClientFactoriesSpring Cloud 自己提供的 transport而不是 Netflix 自带的 Jersey1 transport。但 Netflix 的DiscoveryClient在拿不到 Spring 注入的TransportClientFactories时会fallback 到Jersey1TransportClientFactories——这一行栈就是 fallbackat Jersey1TransportClientFactories.newTransportClientFactory(Jersey1TransportClientFactories.java:59)fallback 一执行就强依赖ApacheHttpClient4。为什么本地能跑本地 IDEA 跑的时候jersey-apache-client4通过某条别的传递路径devtools、corporate starter 等进了 IDEA 的 classpath。Jersey1 fallback 能加载到类没炸——但实际上 Eureka 根本没在用 Jersey只是类碰巧能找到把这个隐藏问题盖住了。fat jar 就没这么宽容了repackage严格按依赖树来jersey 被 exclude 了BOOT-INF/lib里没有fallback 一执行直接NoClassDefFoundError。修复显式把 jersey 补回来止血dependencygroupIdcom.sun.jersey.contribs/groupIdartifactIdjersey-apache-client4/artifactIdversion1.19.4/version/dependencydependencygroupIdcom.sun.jersey/groupIdartifactIdjersey-client/artifactIdversion1.19.4/version/dependencydependencygroupIdcom.sun.jersey/groupIdartifactIdjersey-core/artifactIdversion1.19.4/version/dependency根治方向是排查为什么没走RestTemplateTransportClientFactories——检查有没有spring.autoconfigure.exclude排掉了DiscoveryClientOptionalArgsConfiguration或者 corporate starter 注册了冲突的 bean。这个例子为什么典型它同时命中了上面说的场景一exclusion 把依赖排掉和场景二devtools/其他传递路径在 IDE 里补回来。本地能跑完全是假象问题早就埋下了只是 fat jar 把它逼出来。怎么排查这类问题1. 永远以 fat jar 为准别信 IDE本地 IDEA 跑通≠没问题。开发期就要养成习惯重要改动用java -jar跑一次 fat jar而不是只点 IDEA 的绿色三角。尤其是改了 pom、加了新依赖之后。2. 用dependency:tree看真实依赖树mvn dependency:tree-Dincludescom.sun.jersey*看 jersey 到底在不在最终依赖树里、从哪条路径来。IDEA 里的 “Maven” 面板也能看但命令行输出更直观、可分享。3. 直接看 fat jar 里有什么$jartarget/your-app.jarAdd-Type-AssemblyName System.IO.Compression.FileSystem$z[System.IO.Compression.ZipFile]::OpenRead($jar)$z.Entries|Where-Object{$_.FullName-matchBOOT-INF/lib/.*jersey.*\.jar}|Select-Object-ExpandProperty FullName$z.Dispose()Linux/Mac 下更简单unzip-ltarget/your-app.jar|grepjersey# 或jar tf target/your-app.jar|grepjersey这是最权威的验证方式——fat jar 里有没有一查便知不用猜。4. 验证嵌套 jar 里的类注意 Spring Boot fat jar 的依赖是嵌套 jar直接在 fat jar 顶层搜.class搜不到。要把嵌套 jar 解出来$jartarget/your-app.jarAdd-Type-AssemblyName System.IO.Compression.FileSystem$z[System.IO.Compression.ZipFile]::OpenRead($jar)$nested$z.GetEntry(BOOT-INF/lib/jersey-apache-client4-1.19.4.jar)$tmp$env:TEMP\nested.jar[System.IO.Compression.ZipFileExtensions]::ExtractToFile($nested,$tmp,$true)$z.Dispose()$z2[System.IO.Compression.ZipFile]::OpenRead($tmp)$found$z2.Entries|Where-Object{$_.FullName-eqcom/sun/jersey/client/apache4/ApacheHttpClient4.class}$z2.Dispose()if($found){OK}else{MISSING}Remove-Item$tmp-ForceLinux/Macunzip-ptarget/your-app.jar BOOT-INF/lib/jersey-apache-client4-1.19.4.jar/tmp/nested.jarunzip-l/tmp/nested.jar|grepApacheHttpClient45. 对比 IDEA 和 fat jar 的 classpathIDEA 里可以在运行配置里勾选 “Show command line”它会把完整的 classpath 打印出来。或者用 Maven 命令mvn dependency:build-classpath-Dmdep.outputFilecp.txt拿到 classpath 后和 fat jar 的BOOT-INF/lib对比差异一目了然。怎么预防CI 里跑 fat jar 启动测试mvn package后加一步java -jar target/*.jar看能不能起来。这是最有效的防线把本地能跑生产炸提前到 CI 阶段暴露。别依赖传递依赖代码里用到了某个库的类就在 pom 里显式声明这个依赖别指望它从别的依赖里顺便过来。传递依赖随时可能被 exclude 或升版本。devtools 加 scopespring-boot-devtools加scopeprovided/scope或确保它不进生产包的同时也别让它的传递依赖污染你的代码。exclusion 要想清楚看到 starter 里有大段 exclusion比如 Spring Cloud 排 jersey要理解背后的设计意图而不是无脑补回来。止血可以补但根治要想清楚。版本对齐pom 里的版本、构建产物的版本、CI 构建的分支三者要对齐。否则本地改了 A生产还是老版本的鬼故事会反复发生。总结IDEA 和 fat jar 的 classpath 差异本质是**求和的、宽松的开发期 classpath** vs精确的、严格的打包期 classpath的差异。这种差异天然存在不可消除但可以识别、可以验证、可以预防。核心心法就一句本地能跑不代表没问题fat jar 能跑才算数。养成改完 pom 就用java -jar验证的习惯能少踩一半这类坑。那个 Eureka/Jersey 的例子本地跑得好好的实际上 Eureka 根本没在用 Jersey——只是类恰好能找到问题被盖住了。等 fat jar 把它逼出来你才发现原来一直走在错误的 fallback 路径上。这种假性正常才是最危险的值得每个 Spring Boot 开发者警惕。