一次由 Maven 可重复构建引发的 Spring Boot 运行时类加载问题排查
背景
在一个 Spring Boot 3.x 项目中,为了满足可重复构建(Reproducible Build)的要求(可查看我另一篇文章了解可重复构建),我在 pom.xml 中新增了如下配置:
<project.build.outputTimestamp>2026-01-01T00:00:00Z</project.build.outputTimestamp>该配置用于固定构建产物的时间戳,确保多次构建生成的 JAR 文件在字节级别保持一致。
然而,加上该配置后,应用在运行时出现了异常:
- 启动正常
- 业务请求触发时抛出运行时错误
- 错误与 okhttp 相关
jakarta.servlet.ServletException: Handler dispatch failed: java.lang.NoClassDefFoundError: Could not initialize class com.xxx.common.util.HttpUtil
...
Caused by: java.lang.NoClassDefFoundError: Could not initialize class com.xxx.common.util.HttpUtil
...
Caused by: java.lang.ExceptionInInitializerError: Exception java.lang.NoSuchFieldError: Companion [in thread "http-nio-80-exec-9"]
at okhttp3.internal.Util.<clinit>(Util.kt:70) ~[okhttp-4.10.0.jar!/:?]而在未添加该配置时,应用运行完全正常。
问题初步分析
异常发生在运行期,而非编译期,表现为典型的 ABI 不兼容问题(如 NoSuchMethodError)。
通过分析依赖树,发现 okhttp 4.10.0 是通过 com.huaweicloud:esdk-obs-java:jar 依赖间接引入:
mvn dependency:tree[INFO] +- com.huaweicloud:esdk-obs-java:jar:3.20.6.1:compile
[INFO] | +- com.jamesmurty.utils:java-xmlbuilder:jar:1.2:compile
[INFO] | +- com.squareup.okhttp3:okhttp:jar:4.10.0:compile
[INFO] | | +- com.squareup.okio:okio-jvm:jar:3.0.0:compile
[INFO] | | | +- org.jetbrains.kotlin:kotlin-stdlib-jdk8:jar:1.7.22:compile
[INFO] | | | | - org.jetbrains.kotlin:kotlin-stdlib-jdk7:jar:1.7.22:compile
[INFO] | | | - org.jetbrains.kotlin:kotlin-stdlib-common:jar:1.7.22:compile
[INFO] | | - org.jetbrains.kotlin:kotlin-stdlib:jar:1.7.22:compile
[INFO] | | - org.jetbrains:annotations:jar:13.0:compile
[INFO] | +- com.squareup.okio:okio:jar:1.17.2:compile
同时,OBS SDK 又引入了一个较老版本的 okio:
okio 1.17.2Fat Jar 中的实际情况
通过查看 Spring Boot 打包后的 fat jar 内容:
jar tf app.jar | grep -Ei "okio|okhttp"未开启 project.build.outputTimestamp 时
BOOT-INF/lib/feign-okhttp-12.4.jar BOOT-INF/lib/okhttp-4.10.0.jar BOOT-INF/lib/okio-jvm-3.0.0.jar BOOT-INF/lib/okio-1.17.2.jar开启 project.build.outputTimestamp 后
BOOT-INF/lib/feign-okhttp-12.4.jar BOOT-INF/lib/okhttp-4.10.0.jar BOOT-INF/lib/okio-1.17.2.jar BOOT-INF/lib/okio-jvm-3.0.0.jar
可以看到,两个场景中 jar 的内容是相同的,但顺序发生了变化。这个顺序是关键线索,后面会有 JVM 的加载验证。
关键认知一:JVM 类加载的硬规则
JVM 对类加载有一个不可违背的规则:
同一个 ClassLoader 下,类的唯一性由「类的全限定名 + ClassLoader」决定
这意味着:
- 同一个类(如:okio.ByteString)只能被加载一次
- 不存在“版本共存”
- 先加载的实现会永久生效
关键认知二:Spring Boot fat jar 对顺序是敏感的
Spring Boot 的 LaunchedURLClassLoader 在启动时,会:
- 遍历 fat jar 中的 BOOT-INF/lib/*.jar
- 按 JAR 内的物理顺序 将它们加入 classpath
- 不做任何排序或版本判断
因此:
fat jar 中 BOOT-INF/lib 的顺序,直接决定了类加载优先级
JVM 类加载验证
为了确认是否是类加载顺序问题,启用 JVM 类加载日志:
-Xlog:class+load=info观察结果:
未启用 outputTimestamp → okio-jvm 被加载
[68.099s][info][class,load] okio.Source source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.099s][info][class,load] okio.BufferedSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.099s][info][class,load] okio.Sink source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.099s][info][class,load] okio.BufferedSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.100s][info][class,load] okio.Buffer source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.101s][info][class,load] okio._UtilKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.102s][info][class,load] okio.Buffer$UnsafeCursor source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.110s][info][class,load] okio.Options source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.111s][info][class,load] okio.Options$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.114s][info][class,load] okio.ByteString source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.116s][info][class,load] okio.ByteString$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.117s][info][class,load] okio.internal._ByteStringKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.197s][info][class,load] okio.SegmentPool source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.198s][info][class,load] okio.Segment source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.198s][info][class,load] okio.Segment$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.345s][info][class,load] okio.Timeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.345s][info][class,load] okio.AsyncTimeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.354s][info][class,load] okio.Timeout$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.355s][info][class,load] okio.Timeout$Companion$NONE$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.355s][info][class,load] okio.AsyncTimeout$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.396s][info][class,load] okio.Okio source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.396s][info][class,load] okio.Okio__JvmOkioKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.398s][info][class,load] okio.SocketAsyncTimeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.399s][info][class,load] okio.InputStreamSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.400s][info][class,load] okio.AsyncTimeout$source$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.400s][info][class,load] okio.Okio__OkioKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.402s][info][class,load] okio.RealBufferedSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.403s][info][class,load] okio.OutputStreamSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.404s][info][class,load] okio.AsyncTimeout$sink$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.405s][info][class,load] okio.RealBufferedSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.688s][info][class,load] okio.Utf8 source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.709s][info][class,load] okio._JvmPlatformKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.783s][info][class,load] okio.ForwardingSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.784s][info][class,load] okio.AsyncTimeout$Watchdog source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.860s][info][class,load] okio.ForwardingSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.863s][info][class,load] okio.GzipSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.865s][info][class,load] okio.InflaterSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.869s][info][class,load] okio.internal._BufferKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/启用 outputTimestamp → okio 被加载
[694.157s][info][class,load] okio.Source source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.157s][info][class,load] okio.BufferedSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.159s][info][class,load] okio.Sink source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.159s][info][class,load] okio.BufferedSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.159s][info][class,load] okio.Buffer source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.161s][info][class,load] okio.-Util source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.172s][info][class,load] okio.Options source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.174s][info][class,load] okio.Options$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.177s][info][class,load] okio.ByteString source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.179s][info][class,load] okio.ByteString$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.181s][info][class,load] okio.internal.ByteStringKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.276s][info][class,load] okio.SegmentPool source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.277s][info][class,load] okio.Segment source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.277s][info][class,load] okio.Segment$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.457s][info][class,load] okio.Timeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.457s][info][class,load] okio.AsyncTimeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.471s][info][class,load] okio.Timeout$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.472s][info][class,load] okio.Timeout$Companion$NONE$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.473s][info][class,load] okio.AsyncTimeout$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.508s][info][class,load] okio.Okio source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.508s][info][class,load] okio.Okio__JvmOkioKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.510s][info][class,load] okio.SocketAsyncTimeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.511s][info][class,load] okio.InputStreamSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.512s][info][class,load] okio.AsyncTimeout$source$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.513s][info][class,load] okio.Okio__OkioKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.515s][info][class,load] okio.RealBufferedSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.516s][info][class,load] okio.OutputStreamSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.517s][info][class,load] okio.AsyncTimeout$sink$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.519s][info][class,load] okio.RealBufferedSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.758s][info][class,load] okio.Utf8 source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.787s][info][class,load] okio.-Platform source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.811s][info][class,load] okio.ForwardingSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.812s][info][class,load] okio.AsyncTimeout$Watchdog source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [696.468s][info][class,load] okio.ForwardingSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [696.470s][info][class,load] okio.GzipSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [696.472s][info][class,load] okio.InflaterSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [696.475s][info][class,load] okio.internal.BufferKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/
且:
- JVM 只加载了一套 okio 类
- 并不存在“两个版本共存”
为什么加 outputTimestamp 才出问题?
要解释这个问题,首先要知道可重复构建做了什么?
- 固定 jar entry 顺序
- 固定 zip 时间戳
- 固定 BOOT-INF/lib 打包顺序
原因并不在于“引入了新问题”
而是:
可重复构建消除了原本的“非确定性”
未开启:
- jar entry 写入顺序依赖文件系统、构建时机、并发等因素
- okio / okio-jvm 的先后顺序是未定义的
- 在这个案例中,“刚好”先加载的是兼容的 okio-jvm
- 问题被掩盖
开启后:
- 确保多次构建生成的 JAR 文件在字节级别保持一致,Maven 强制使用确定性的 ZIP entry 顺序
- okio-1.17.2.jar 按字典序稳定排在 okio-jvm 前
- JVM 首次加载 okio.* 时命中旧版本
- okhttp 4.x 在运行期触发 ABI 冲突
- 潜在问题被暴露出来
为什么只加载了 okio,而没有加载 okio-jvm?
这是完全符合 JVM 预期的行为:
- okio 与 okio-jvm 提供的是相同包名、相同类名
- JVM 不会同时加载两个版本
- 后出现的 jar 不会覆盖 已加载的类
问题本质总结
这是一个被“非确定性构建顺序”掩盖的 classpath 冲突问题,
project.build.outputTimestamp 只是让潜在的ABI冲突问题“浮现”。
最终解决方案
统一 okio 的来源,确保 classpath 中只存在一个实现。
排除 com.huaweicloud:esdk-obs-java:jar 引入的不兼容版本的 okio:
<dependency>
<groupId>com.huaweicloud</groupId>
<artifactId>esdk-obs-java</artifactId>
<exclusions>
<exclusion>
<groupId>com.squareup.okio</groupId>
<artifactId>okio</artifactId>
</exclusion>
</exclusions>
</dependency>或显式指定与 okhttp 4.x 兼容的版本:
<dependency>
<groupId>com.squareup.okio</groupId>
<artifactId>okio</artifactId>
<version>2.10.0</version>
</dependency>