背景

在一个 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.2

Fat 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 在启动时,会:

  1. 遍历 fat jar 中的 BOOT-INF/lib/*.jar
  2. 按 JAR 内的物理顺序 将它们加入 classpath
  3. 不做任何排序或版本判断

因此:

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>

标签: none

添加新评论