少女祈祷中...
一言即诺
踩坑笔记

Spring Boot 启动报 NoSuchMethodError:对账产物而不是改源码

阅读 -- 次

背景:启动直接炸在类初始化上

有一次把一个 Spring Boot 服务的新包部署上去,进程起来就挂,日志里最刺眼的是这两行:

java.lang.NoSuchMethodError: org.springframework.util.ObjectUtils.isEmpty(Ljava/lang/Object;)Z
    at org.springframework.aop.aspectj.AspectJExpressionPointcut.<clinit>(AspectJExpressionPointcut.java:XX)
    ...
Caused by: java.lang.NoClassDefFoundError: Could not initialize class org.springframework.aop.aspectj.AspectJExpressionPointcut

这个工作区里各服务都钉着同一组 spring 版本,还共享一个 SNAPSHOT 的父 BOM 统一管理传递依赖。按依赖树解析出来的版本明明是自洽的——spring-aop 调 ObjectUtils.isEmpty(...),这个签名在当前解析出的 spring-core 里应该存在——可运行时就是报找不到方法。

NoSuchMethodError 常打在 xxx.<clinit>(静态初始化块)上,本质只有一句话:运行时 classpath 里两个 jar 版本混血——调用方来自 A 版,被调方来自 B 版。编译期看不出任何异常,链接错误只在类真正被加载的那一刻爆出来。

这次我没有急着改源码,而是把整件事当成一次取证来做。下面按我实际走的顺序,把每个坑的现象、排查、解法和复盘记下来。

心智模型:先立住两条再动手

排查之前,我在纸上写了两条给自己定调子:

第一条:挂掉的 jar 是运行时事实,本地重打的是源码事实。 本地 pom 解析自洽但线上仍报错,只能说明「按当前源码 + 当前解析状态」打出来的产物没问题,而出事的那个产物根本不是在当前状态下构建的。这时候去改源码、改 pom 都是南辕北辙,正确动作是对账产物——把线上炸的那个 jar 和本地重打的 jar 逐个版本比对。

第二条:Could not initialize class 是余烬,不是首因。 JVM 对一个静态初始化失败过的类永不重试:第一次失败后,后续所有触碰这个类的地方都只会报 NoClassDefFoundError: Could not initialize class ...,不再带任何原因。所以线上日志里满屏的 Could not initialize class 很可能是余烬,真实首因在更早的日志里,或者要靠重启复现去抓。先重启抓首因,别拿着余烬修。

这两条决定了后面所有步骤的方向:一切围绕「产物对账」,而不是「源码修改」。

坑 1:共享 SNAPSHOT 刚 bump 过——先核对爆炸半径

排查最容易犯的错是直接跳上远程机器翻日志。这次的时间线里,嫌疑最大的是最近刚 bump 的一个共享 SNAPSHOT 依赖库,于是我先做了一件零成本的事:核对爆炸半径。

# 在各兄弟服务的 pom 里 grep 共享库的版本声明,对比谁在用同一个版本
for d in ../service-a ../service-b ../service-c; do
  grep -A1 '<shared-lib-artifactId>' "$d/pom.xml" | grep version
done

结果:其他用同版本快照的兄弟服务全部健康,只有我这一个服务炸。

这一步的结论价值极大——只有你一个炸,说明不是共享库本身坏了,而是你自己这个产物里的版本组合混血了。火力从「库的锅」转向「这个服务产物里的版本对」,直接省掉了去远程机器翻共享库变更的时间。这步必须在跳远程机器之前完成。

坑 2:报错说方法不存在——用 javap 给「缺失方法」做版本指纹

NoSuchMethodError 说的「方法不存在」未必真的不存在,很可能只是在你产物里那个版本上不存在。所以我遍历本地仓库里涉事 artifact 的所有历史版本,把报错涉及的类抽出来逐个反编译比对:

# 对 ~/.m2 里每个版本的 spring-core,extract 出 ObjectUtils.class 后 javap
for v in ~/.m2/repository/org/springframework/spring-core/*/; do
  mkdir -p /tmp/cpprobe
  unzip -o -q "$v"/spring-core-*.jar 'org/springframework/util/ObjectUtils.class' -d /tmp/cpprobe
  echo "== $v"
  javap -p -classpath /tmp/cpprobe org.springframework.util.ObjectUtils | grep 'isEmpty'
done

输出摘要:某个较新版本里 isEmpty(Object) 存在,而我产物里钉死的老版本 core 只有 isEmpty(Object[])——被调方法根本是更高版本才引入的。结论形如「被调方法 5.3.15+ 才有,而产物 core 是 5.0.4」,混血 jar 实锤,矛头正式指向构建产物本身:调用方 jar(spring-aop)来自新版,被调方 jar(spring-core)来自老版,两个版本被塞进了同一个 classpath。

坑 3:本地解析自洽≠产物干净——对账 dependency:tree 与 BOOT-INF/lib

拿到实锤后,要排除「本地pom本身有问题(比如阴影类)」,再证明「线上产物与本地解析不一致」。两条命令:

mvn dependency:tree -pl <module> -Dverbose -Dincludes='org.springframework:spring-*'
mvn -q dependency:build-classpath -pl <module> -Dmdep.outputFile=cp.txt -DincludeScope=runtime

对 cp.txt 里每个 jar,用脚本扫报错类的精确路径,确认没有第二个 jar 也带着同名类:

import zipfile
target = 'org/springframework/util/ObjectUtils.class'
for path in open('cp.txt').read().strip().split(':'):
    with zipfile.ZipFile(path) as z:
        if target in z.namelist():
            print(path)

结果是唯一提供方、版本成对——本地解析无罪,不存在 fat jar 混入同类阴影类的情况(这类国产 fat jar 客户端 SDK 是这个环节惯犯,专门扫一遍不亏)。

然后把线上炸的那个 jar 和本地重打的 jar 分别打开,对账 BOOT-INF/lib 里的版本对:

unzip -l broken-app.jar  | grep -E 'spring-(aop|core|beans|context)-'
unzip -l rebuilt-app.jar | grep -E 'spring-(aop|core|beans|context)-'

两份输出并排一看,坏产物里 aop 和 core 版本不成对,重打的产物里成对。版本不成对的那个 jar 就是坏产物——事到这里已经从「玄学报错」收敛成「这一个 jar 的构建输入有问题」。

坑 4:闭环验证——本地重新起 jar(macOS 没有 timeout)

对账只能证明版本组成,还得证明「按当前源码重打出来的包真的能起来」。本地:

mvn clean package -DskipTests
java -jar target/app.jar --spring.profiles.active=dev > /tmp/app.log 2>&1 &
sleep 40; grep -E 'Started|APPLICATION FAILED' /tmp/app.log

看到 Started <App> 才算过。这里有个环境坑:macOS 上没有 Linux 的 timeout 命令,别写 timeout 60 java -jar ...,改用 background + sleep + grep 日志的方式;如果是用终端工具操作,起进程要走后台方式,不能前台直接 &。这一步闭环后,「源码路线可用」成立,问题彻底锁定在坏产物的来源上。

坑 5:追快照漂移的源头——坏产物是怎么混进来的

最后一个问题:坏产物里的新版调用方 jar 是从哪来的?本地仓库的元数据可以作证:

cat ~/.m2/repository/<group>/<shared-lib>/<version>/resolver-status.properties
ls -l ~/.m2/repository/<group>/<shared-lib>/<version>/

resolver-status.properties 和带时间戳的远程快照 jar 文件名,能证明某次构建时从私服实际拉到了哪个具体时刻的快照。这类事故的典型机制是:共享父 BOM 里 Spring Boot 与 Spring Cloud 版本线本身不匹配(比如 Boot 2.x 早期版本混搭很新的 Cloud 版本线),远程快照 BOM 更新后把某些传递依赖的管理版本拉高了,而子服务 pom 里钉死的是老版本——构建时快照漂移,一新一旧被混进同一个 fat jar。对 CI 缓存和私服快照时间线做交叉比对,坏产物的来源即可锁定。

变体:不是启动期炸,而是 feign 解码失败——缺传递依赖

同一家族里还有一个非启动期的变种,值得单独记:调用远程接口时报

feign.codec.DecodeException: no suitable HttpMessageConverter found for response type [...]

接口签名检查过没问题,这时根因常是响应 VO 的泛型参数类不在 classpath 上:Jackson 内省泛型时抛 TypeNotPresentException,被 converter 的 canRead() 吞成 false,于是表现为「找不到转换器」。定性探针:

boolean ok = new MappingJackson2HttpMessageConverter()
        .canRead(theGenericType, null, MediaType.APPLICATION_JSON_UTF8);
// false 即坐实缺类

缺类的惯犯来源很隐蔽:私服上某个 client jar 的 pom 无效(依赖不写 version、父 pom 又没有对应管理),Maven 只 WARN 一句 POM is invalid, transitive dependencies will not be available,然后静默丢掉它的全部传递依赖。消费方 compile 期完全无感——因为编译用到的类被别的包顺带掩盖了——运行时才炸。排查方法是双向对账:报错 VO import 的类在 BOOT-INF/lib 各 jar 里扫一遍确认是否存在;再去私服看那个 client 的 pom,检查它声明的依赖有没有缺 version。另一个前兆信号:如果启动日志里伴随一批 springfox 的 TypeNotPresentException WARN,那和 feign 解码失败多半是同一个根因。解法和启动期同法对账产物,把缺失的传递依赖显式补进依赖管理。

复盘

回头看整条线,我把这次取证沉淀成几条纪律:

  • 先核爆炸半径,再上机器翻日志:共享 SNAPSHOT 被怀疑时,grep 兄弟服务的 pom 对比版本,只有你一个炸就是你产物混血,不是库的锅。这一步最便宜,放最前面。
  • Could not initialize class 先看是不是余烬:真实首因在更早日志或重启复现里,别修余烬。
  • 对账产物而不是改源码:挂掉的 jar 是运行时事实,本地 pom 解析自洽恰恰证明不了线上产物干净。javap 做版本指纹 → dependency:tree / build-classpath 证明本地无罪 → BOOT-INF/lib 对账版本对,三步走完结论就从猜测变成实锤。
  • 闭环标准是 Started <App>:本地 clean package 后真的把 jar 起来,看到启动成功才算验证过。
  • 每一步都要实测证据:每个结论附上命令和输出摘要,不许拿「大概率是 X」当结论;根因没闭环到坏产物来源时,如实说明,把处置选项(部署机验 jar 内版本 / 重新 CI 构建 / pom 里 dependencyManagement 显式钉死冲突 artifact)摆出来让人拍板,而不是擅自改 pom。

类版本混血这种问题,报错信息永远指向「方法不存在」,但它真正想说的是「你运行的那个 jar 不是你以为的那个 jar」。对账产物,顺时间线抓快照漂移,它就无处可藏。

评论

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人