ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java ClassNotFoundException 排查与根治:从类加载机制到打包部署全解析

Java ClassNotFoundException 排查与根治:从类加载机制到打包部署全解析 写过 Java 的兄弟基本都经历过这种深夜噩梦线上告警响个不停手忙脚乱地去翻日志一眼看到java.lang.ClassNotFoundException瞬间睡意全无。尤其是那种前面几秒还好好的突然就找不到类的报错比直接告诉你写错了代码还折磨人因为你根本不知道它是在哪一步、哪个环节丢的类。我最近就刚处理完一起典型的java -jar启动报错问题。一个内部工具打成 JAR 包发给运维去部署结果在测试环境跑得好好的换到生产环境一执行直接来了个ClassNotFoundException。关键是代码里明明引了这个类编译期也没报错JAR 包也确实是新打的。这种问题影响面特别广——从刚入门写第一个 Maven 项目的新手到维护大型微服务的老工程师都会在日常开发和部署中跟这个异常打照面。它不是那种看一眼就会的语法问题而是涉及 Java 类加载机制、打包方式、依赖管理等一堆底层细节。这篇文章我就完整拆解一次这个问题的排查思路和解决方案。从异常产生的底层原理到实操的排查命令再到能直接“抄作业”的修复办法一次性讲透让你以后再遇到ClassNotFoundException的时候能直接按图索骥而不是靠百度碰运气。1. ClassNotFoundException 到底是什么玩意儿1.1 三个类加载器在背后干了什么要搞清楚为什么报找不到类首先得理解 JVM 是怎么找到这个类的。Java 的类加载机制说白了就是一个“按需加载”的过程JVM 不会一次性把所有类都塞进内存而是等运行到某个字节码指令需要某个类的时候才通过类加载器去指定的位置找这个类。默认情况下JVM 内置了三个主要的类加载器启动类加载器Bootstrap ClassLoader负责加载 JDK 自带的类比如java.lang.*、java.util.*这些最基础的类库。它是最顶层的加载器用 C 实现在 Java 层面看不到它。平台类加载器Platform ClassLoader负责加载 JDK 扩展的一些模块类。在 JDK 9 之前叫扩展类加载器Extension ClassLoader。应用类加载器App ClassLoader负责加载你写在classpath下的所有类包括你项目里的代码和你依赖的那些第三方 JAR 包。另外还有一个很关键的概念叫双亲委派模型。简单说当一个类需要被加载时子加载器不会自己先去加载而是先委托给父加载器加载一层层往上抛如果父加载器加载不了再返回给子加载器自己加载。这样做的主要目的是保证 Java 核心类的安全防止你写个java.lang.String把 JDK 自带的给替换掉。当 JVM 按照双亲委派模型去加载类找遍了所有该找的地方都没发现目标类的字节码时就会抛出ClassNotFoundException。看到这个异常的第一反应不应该是“卧槽代码变了”而应该是“加载器没找到这个类的字节码”。1.2 为什么 JAR 包会丢类JAR 文件本质上就是一个 ZIP 压缩包里面除了编译后的.class文件还包含一个关键的META-INF/MANIFEST.MF文件。当你执行java -jar命令时JVM 只会读取这个 MANIFEST 文件里指定的入口类和Class-Path属性以此来确定去哪找依赖的类。这里有个很常见的思维误区。有热搜词说“java是静态链接的”这是完全错误的。Java 是动态链接、动态加载的语言类的解析大多发生在运行期。也就是说你编译的时候引用了一个类编译器会把它记录在常量池里但并不会把类的字节码复制进你的 JAR 包里除非你用了特殊打包插件。等到运行时JVM 才根据你的依赖配置去加载它。这就解释了为什么同一个 JAR 包在 A 机器上跑得好好的到了 B 机器上就报ClassNotFoundException——因为 B 机器的classpath里没有那个类或者依赖的 JAR 版本不对再或者 MANIFEST 里的 Class-Path 压根就没写全。另外还有一个很容易被忽视的点ClassNotFoundException和NoClassDefFoundError是两个不同的东西。前者通常是因为你显式地通过Class.forName()或者运行时依赖缺失导致的后者是编译期这个类存在但运行期加载失败比如静态初始化抛异常或者依赖它的父类没找到。这两个异常经常被混着说但在面试和实操中它们是两个不同的排查方向。2. 从根上分析到底是哪些环节在丢类2.1 环境变量这个老六很多新手遇到ClassNotFoundException第一反应就是去查环境变量。这没错但往往查不到点子上。JAVA_HOME和CLASSPATH确实会严重影响 JAR 包的运行结果尤其是当你用java -cp而不是java -jar启动的时候。我见过最典型的案例是项目的依赖包统一放在服务器的/opt/app/lib目录下启动脚本里是通过CLASSPATH环境变量引入的。结果运维在部署新版本时没有把新增的依赖包上传到那个目录启动脚本还是老的于是一运行就报找不到类。这种问题表面上是ClassNotFoundException根因其实是环境变量指向的类路径下根本没有这个文件。还有人会在多版本 JDK 混装的服务器上踩坑。比如系统默认java指向 JDK 8但你项目里用了 JDK 11 才有的 API编译期用的是 JDK 11运行时却被环境变量带到了 JDK 8 上类库都不一样了自然找不到类。2.2 打包阶段埋下的定时炸弹这才是绝大多数java -jar运行时报找不到类的真正元凶。默认情况下你用 IDE 直接打包或者用 Maven 默认的jar插件打出来的 JAR 包是不包含第三方依赖的。也就是说你项目里 import 了一堆第三方类但打包的时候这些第三方类并没有被塞进 JAR 包内部。如果启动时用的是java -jar xxx.jarJVM 只会在 JAR 包内部和 MANIFEST 指定的目录里找类。第三方依赖不在里面那必然报ClassNotFoundException。这不是个别现象是所有新手都会踩的坑。还有 JAR 包内部结构问题。我之前排查过一个案例反编译 JAR 包之后发现类的包路径不对——源码里写的是com.example.portal.entity.User但实际打进 JAR 包的路径变成了com/example/portal/entity/User.class的全限定名前缀不对导致运行时报错。这种情况多见于某些打包插件配置了sourceDirectory或者做了一层目录映射把包结构搞乱了。2.3 第三方依赖库的特殊性有些第三方依赖本身就不是“纯 Java”的比如热搜词里提到的gdal jar包这类库通常还依赖本地的 DLL/SO 动态链接库文件。编译期你可能只需要一个gdal.jar就能编译通过但运行期它还需要调用libgdal.so如果你没把本地库路径放进java.library.pathJVM 在加载 GDAL 相关类时就可能抛UnsatisfiedLinkError如果这个错误没被处理后续依赖它的类就会演变成ClassNotFoundException。又比如richtextfx jar 包这类 GUI 组件库它对 JavaFX 的版本很敏感。JavaFX 从 JDK 11 开始被从 JDK 中剥离如果你用的是 OpenJDK 11又没引入 JavaFX 的独立依赖运行时加载 RichTextFX 的类就会失败。这就是典型的“非 Maven 中央仓库直接引入”的坑不考虑依赖的传递性。2.4 类加载器冲突与依赖版本打架类加载器冲突是一个更隐蔽的场景。它的表现往往是同一个类在多个 JAR 包中都存在版本还不一样。JVM 的类加载器按顺序扫描 classpath谁在前就先用谁如果前端 JAR 里的类版本太老缺少新类才有的方法运行到那个方法时会抛NoSuchMethodError但如果缺失的是整个类就会抛ClassNotFoundException。这在大型项目里太常见了。比如项目里同时引入了 A 框架和 B 框架A 依赖于common-lang3:3.8B 依赖于common-lang3:3.5Maven 依赖仲裁帮你选了 3.5结果 A 框架里某些 3.8 才有的类就全部“蒸发”了。运行时只要一触达那些类立刻报找不到。还有一种情况是自定义类加载器造成的。一些容器类应用比如 Tomcat、OSGi为了做到应用隔离会自己实现类加载器。如果你在一个子加载器里加载了一个类这个类又引用了父加载器中的类而父加载器里没有也可能抛出ClassNotFoundException。这种问题排查起来最费时因为普通命令行工具根本看不出来。3. 实战排查一个案例让我从入门到精通3.1 先看完整堆栈别急着搜报错遇到ClassNotFoundException第一步绝对不是去百度复制粘贴报错文案而是把完整堆栈日志CtrlC下来重点关注 Caused by 部分。很多人只看第一行Exception in thread main java.lang.ClassNotFoundException: com.xxx.yyy然后就去搜这个类名。但这往往只是表象真正的根源可能在后面的Caused by: java.lang.ClassNotFoundException: ...里。举个例子某次排查一个 Spring Boot 应用启动失败第一行报的是找不到org.springframework.boot.SpringApplication看着像是 Spring 依赖没引全。但往下一看Caused by发现实际是java.nio.charset.MalformedInputException——因为启动参数里加了-Dfile.encodingGBK而某些配置文件是 UTF-8 编码读配置时直接挂了后续类加载过程被中断才导致连环的找不到类。所以先看堆栈不要被表象迷惑。3.2 用 JAR 自带命令和反编译工具验证类是否存在堆栈信息里拿到了完整的类名之后接下来要确认这个类到底存不存在于你的 JAR 包里。这一步操作很简单但很多人跳过它导致排查方向从一开始就错了。假设报错的类名是com.example.utils.EncryptUtil运行中的 JAR 包叫app.jar你可以直接在服务器上执行jar tf app.jar | grep EncryptUtil或者用unzip查看unzip -l app.jar | grep EncryptUtil注意JAR 包里的类路径是用/分隔的你要把类名里的.替换成/还要加上.class后缀。比如com.example.utils.EncryptUtil在 JAR 包里的路径是com/example/utils/EncryptUtil.class。如果这个类没出现在 JAR 包里那问题基本锁定在打包环节上了。如果类确实存在但运行时报找不到那就要考虑是不是类加载器的问题——比如这个 JAR 包处在某个被隔离的类加载器里而运行时是从另一个类加载器加载的。反编译工具在排查时也很有用。像JD-GUI、CFR、Procyon这类工具可以直接把 JAR 包拖进去看里面到底有什么类、包的路径是什么。我遇到过一种情况打包时用了某些插件对字节码做了混淆或者 Shade 处理类名被改了但配置文件和反射代码里还引用着原来的类名于是运行到用反射创建实例的地方就报ClassNotFoundException。用反编译工具一看全明白了。3.3 用 Maven 依赖树定位版本冲突如果你的项目是用 Maven 管理的那ClassNotFoundException很可能是依赖冲突引发的。这时候用mvn dependency:tree能看到所有依赖的完整树型结构包括每个依赖的版本号和传递性依赖。mvn dependency:tree -Dverbose比如你想查某个类到底来自哪个 JAR可以先在本地把 JAR 包全部解压出来再用find命令搜find ~/.m2/repository -name *.jar -exec sh -c jar tf $1 | grep -q com/example/EncryptUtil.class echo $1 _ {} \;虽然命令有点笨重但实测下来非常有效。它能把所有包含这个类的 JAR 全部列出来然后你就知道是不是因为同时存在多个版本才导致的冲突了。3.4 检查运行环境和启动参数这一步很多人会忽略但我建议在动用反编译等高级手段之前先看一眼三个最基础的东西java -version确认实际运行的 JDK 版本和编译期是否一致。echo $CLASSPATH看看有没有设置过全局 CLASSPATH以及它指向的目录里是否真的有需要的 JAR 包。启动命令里有没有-cp参数-cp和-jar是否混用了。这里有个非常容易踩的坑-jar参数和-cp参数是互斥的。你写了java -cp your.jar -jar app.jarJVM 会忽略-cp只按照-jar后面的 MANIFEST 去找类。有些新手以为两个都写上会更加保险实际上等于白写。后面的排查就可能出现“我明明在 classpath 里放了那个 jar怎么还是找不到类”的困惑。4. 根治方案四种能直接落地的做法4.1 修正 MANIFEST.MF指定 Class-Path如果你不想改变打包方式还就想用java -jar启动那就要把依赖信息写进 JAR 包的META-INF/MANIFEST.MF文件里。这里说的不是让你手工去改这个文件而是通过构建插件在打包时自动生成。用 Maven 的maven-jar-plugin手工配置Class-Path是一种方式plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId version3.3.0/version configuration archive manifest mainClasscom.example.MainApplication/mainClass addClasspathtrue/addClasspath classpathPrefixlib//classpathPrefix /manifest /archive /configuration /plugin这个配置的意思是JAR 包的 MANIFEST 里会生成一个Class-Path: lib/xxx.jar lib/yyy.jar这样的属性告诉 JVM 去 JAR 包同级的lib目录下找依赖。所以你部署的时候要把lib目录和 JAR 包放在同一个层级下。这种方式最大的好处是灵活——JAR 包很小依赖在外部升级某个依赖时只要替换lib目录下的单个 JAR 就行不用重新打整个包。缺点是目录结构不能乱少了一个都启动不了。4.2 打成 Fat Jar一包走天下如果你希望部署时只上传一个 JAR 文件什么都不用管就能跑起来那就得把第三方依赖全部“塞”进最终的 JAR 包里。这种 JAR 包叫 Fat Jar 或者 Uber Jar。Maven 项目可以用maven-assembly-pluginplugin artifactIdmaven-assembly-plugin/artifactId configuration archive manifest mainClasscom.example.MainApplication/mainClass /manifest /archive descriptorRefs descriptorRefjar-with-dependencies/descriptorRef /descriptorRefs /configuration executions execution idmake-assembly/id phasepackage/phase goals goalsingle/goal /goals /execution /executions /pluginSpring Boot 项目则直接使用spring-boot-maven-plugin它会自动把依赖和配置都打包进去生成的可执行 JAR 用的是一种特殊的嵌套 JAR 结构。用java -jar启动时Spring Boot 自己的LaunchedURLClassLoader会负责解析嵌套的BOOT-INF/lib/目录。所以如果你看到 Spring Boot 项目里报ClassNotFoundException先确认一下是不是有人用普通方式把它解压后再运行了或者把BOOT-INF/classes路径给截断了。用 Fat Jar 值得注意的是如果多个依赖里存在同名文件比如META-INF/services下的 SPI 配置文件打包时会把后面的覆盖掉可能导致有些功能运行时出现奇怪的现象但类找不到的问题反而很少见。如果你的应用没用到 SPI可以放心使用。4.3 启动时手动指定 classpath如果应用已经上线了来不及重新打包或者打包配置一时半会儿改不了最快速的处理方式是改用java -cp启动而非java -jar。启动命令格式如下java -cp app.jar:lib/* com.example.MainApplication或者 Windows 下java -cp app.jar;lib/* com.example.MainApplication这里有个细节lib/*的星号是通配符用来匹配lib目录下所有的.jar文件。但是这个通配符只能匹配 JAR 文件不匹配子目录。如果你把 JAR 包放在lib/something/这种二级目录下lib/*是匹配不到的要写lib/*/*才行。这种方式不需要 MANIFEST 里的 Class-Path也不需要打成 Fat Jar是最灵活但也最容易出错的一种方式——因为你的启动命令写多长完全取决于你引了多少依赖。我建议在项目启动脚本里生成一个CLASSPATH变量动态拼接目录下所有 JARfor jar in ./lib/*.jar; do CLASSPATH$CLASSPATH:$jar done java -cp $CLASSPATH:app.jar com.example.MainApplication4.4 IDEA 引入本地 JAR 的场景怎么处理很多项目会在本地手动下载一个第三方 JAR 包比如某银行提供的 SDK、内部组件直接甩到项目lib目录下就完事了。这种开发环境下跑得好好的代码打包到服务器就报ClassNotFoundException原因是你虽然在 IDEA 里通过Project Structure - Modules - Dependencies添加了本地 JAR但 Maven 构建的时候并没有把这个 JAR 自动打包进最后生成的产物里。正确的做法是把这个本地 JAR 通过 Maven 安装到本地仓库然后在pom.xml里正常声明依赖mvn install:install-file -Dfilelib/mylocal.jar \ -DgroupIdcom.example \ -DartifactIdmylocal \ -Dversion1.0.0 \ -Dpackagingjar然后在pom.xml里引用dependency groupIdcom.example/groupId artifactIdmylocal/artifactId version1.0.0/version /dependency这一种方式是给项目“正规化”用的。如果你只是临时在 IDEA 里跑一跑不涉及外发部署那把本地 JAR 加入 IDEA 的依赖库就够了不用走 Maven 这套流程。但只要你需要打可执行包发给别人就一定需要让 Maven 明确感知到这个依赖的存在否则最终产品里不会有它的位置。4.5 类加载器冲突的规避如果确认项目里存在多个版本的相同 JAR 包使用 Maven 的exclusion标签排除旧版本是标准做法dependency groupIdcom.example/groupId artifactIdbigframework/artifactId version2.0.0/version exclusions exclusion groupIdcommons-lang/groupId artifactIdcommons-lang3/artifactId /exclusion /exclusions /dependency排除掉旧版本之后为了让项目编译和运行统一用新版本再显式声明一份最新版依赖就可以了。如果冲突发生在容器类应用或者说你需要隔离的环境里那就要考虑自定义类加载器的方案。比如你可以写一个继承URLClassLoader的类加载器指定一个单独的目录来加载特定版本的 JAR然后通过反射调用隔离环境里的类。这种做法能解决很棘手的版本冲突但代码复杂度会明显上升非必要不建议在生产环境里搞。5. 常见问题速查表与我的避坑技巧5.1 不同场景的解决路径我把这几年踩坑经验总结成了一个速查表格遇到问题可以直接查典型场景报错特征首选排查方向推荐解法java -jar启动普通 JAR 报错报错类名是自己项目的类检查 MANIFEST 和打包插件改为 Fat Jar 打包java -jar启动报错第三方库类报错类名是com.fasterxml之类的检查是否引入依赖、依赖是否被打进包用 Maven 依赖树查传递性依赖同一个 JAR 本机能跑服务器不能跑服务器上只有 JAR 包没带lib目录查看服务器文件结构统一使用 Fat Jar 或部署完整目录环境变量导致找不到类启动脚本用的CLASSPATH配置echo $CLASSPATH逐个检查路径把依赖库归档到一个固定目录本地 Idea 能编译能跑打包后报错引用了本地 JAR 但未同步到包查看最终 JAR 是否包含该类使用mvn install:install-file升级依赖后突然报找不到类类名带版本后缀或包名完全变了检查新旧版本的包名变化同步升级代码或兼容方案同一个类存在于多个 JAR 导致冲突运行期类加载顺序不同表现不同mvn dependency:tree查询重复依赖排除旧版本或升级统一版本5.2 独家经验分享第一点优先怀疑打包配置而不是代码问题。我处理过的ClassNotFoundException里真正是代码写错的连 10% 都不到。剩下 90% 都是打包丢依赖、环境变量配置错误、服务器目录缺文件这三板斧。排查顺序应该是打包配置优先于环境配置环境配置优先于代码本身。第二点不要在服务器上直接解压 JAR 去找类。有些运维为了快速定位会把 JAR 包用unzip解压出来然后在解压后的目录里手动启动应用。这种方式非常容易引发两个问题一是解压后目录结构可能和 JAR 内部的逻辑不一致二是把META-INF目录弄丢了会导致 MANIFEST 失效。如果你非要解压查看看完就删启动永远用 JAR 包本体。第三点写一个类路径打印工具类常驻项目。我习惯在项目里保留一个ClasspathPrinter工具类它会打印出当前应用的完整类路径以及某个关键类是从哪个 JAR 加载的import java.net.URL; import java.net.URLClassLoader; public class ClasspathPrinter { public static void printClassLocation(String className) { try { Class? clazz Class.forName(className); URL location clazz.getProtectionDomain().getCodeSource().getLocation(); System.out.println(className - location); } catch (ClassNotFoundException e) { System.out.println(className - NOT FOUND); } } }启动出问题的时候先跑这个工具它能明确告诉你某个类到底加载到了没有、是从哪个位置加载的直接把问题范围缩小十倍的。比如你怀疑com.mysql.cj.jdbc.Driver没被加载调用一下printClassLocation(com.mysql.cj.jdbc.Driver)如果输出NOT FOUND那基本能断定是依赖缺失。5.3 从源码编译的角度看问题有些ClassNotFoundException的根子其实出在编译阶段。比如代码里用了某个 API但这个依赖在pom.xml中被标成了optionaltrue/optional这个依赖就不会被传递到下游项目。你在当前项目里编译时一切正常一旦作为依赖被你同事的项目引用他的项目运行时就会报缺少类。这种问题特别坑人因为当前项目自己测试完全没问题你根本意识不到别人用不起来。同样地如果你的代码里用了scopeprovided的依赖比如lombok、servlet-api那么打包的时候默认不会打进去。如果你项目里某个类是依赖于 provided 依赖提供的方法比如直接调用javax.servlet.http.HttpServletRequest而运行环境又是一个纯净的 JRE那必然报ClassNotFoundException。这时候你得干预打包配置让 provided 依赖不被排除或者把具备这些类的运行时环境比如 Tomcat一起带上。一个真实的踩坑记录GJAL 库的本地库问题前面提到的gdal jar包那类依赖是最容易让人怀疑人生的。那一次我自己被搞了整整一个下午代码里引入了gdal.jar本地开发环境跑得飞起一上服务器就报ClassNotFoundException: org.gdal.gdal.gdalJNI。我看这个类名带着 JNI 后缀第一反应是本地库没配好但服务器上的LD_LIBRARY_PATH我已经检查过好几遍了路径没错。最后定位到原因gdal.jar里的gdalJNI.class是通过 JNI 调用的本地方法而这个类本身是 Java 代码生成的它在加载时会去尝试加载libgdal.so。如果本地库加载失败抛的是一个UnsatisfiedLinkError但这个错误被上层框架捕获后包装成了ClassNotFoundException导致报错信息完全走样。这种问题光靠修改 classpath 是解决不了的必须把 JDK 的本地库路径配进去java -Djava.library.path/usr/local/gdal/lib -jar app.jar或者是把.so文件放到系统默认的库搜索路径下比如/usr/lib或者/usr/local/lib。所以在排查ClassNotFoundException的时候看到包含 JNI、native、gdal 这类关键字的类名多留个心眼它可能不是类本身找不到而是它背后依赖的本地库加载失败了。最后的最后在实际排查中我最大的体会是ClassNotFoundException 这个异常本身并不是一个难点难的是它背后涉及的依赖管理意识和类加载机制理解。这种问题不像语法错误那样有明确的提示它需要你把整个 Java 运行链路串起来看——从编译期到打包期到部署期每个环节都有可能把类弄丢。如果你刚接触这个问题我的建议是先不要急着去复制报错堆栈到处问人。拿一张纸把报错类名写在最上面然后依次检查这个类是否在最终产物里是否在启动命令的可搜索路径里是否存在版本冲突是否依赖了本地库90% 的概率你能在三十分钟内自己解出来。最后再分享一个小技巧我会在项目的启动脚本里加上一段“启动自检”就是用java -cp的方式先加载一遍核心类不存在就立刻报错并退出而不是让应用跑到一半才崩。这样能把问题暴露在启动阶段排查成本会小很多。希望这篇经验对你有些帮助。
返回列表