bigywater.tech
Java · 271

可复现 JAR:相同源码如何得到相同产物

同样的源码两次构建,SHA-256 却不一样,问题出在哪?这篇逐层定位:javac 的输出、jar 工具的时间戳与条目顺序、Maven 的 outputTimestamp,以及换 JDK 版本带来的差异,每一层都用真实的哈希对比。

--date
jar 工具固定条目时间戳后,两次打包的 SHA-256 相同
outputTimestamp
Maven 里固定时间戳的属性,第二次构建改了源文件 mtime 也相同
Main.class
换 JDK 17 / 21 构建后唯一内容不同的条目

javac 的确定性

实测 · 本机执行输出
  同一份源码编译两次(间隔 1.2 秒),每个 .class 的 SHA-256 前 16 位: {'B.class': 'a61ab07b52b89ea4', 'A.class': 'f2615c98b0b29b28', 'Main.class': '514b3d88eaf6c4b0'}
  两次的 class 文件完全相同 = True
  JDK 17 编译(--release 17)与 JDK 21 编译(--release 17)的 class 文件相同 = False ; {'B.class': True, 'A.class': True, 'Main.class': False}
  Main.class 在 JDK 17 与 JDK 21(--release 17)下的 javap -v 差异(用同一个 javap 读,去掉路径与校验和行): ['-  #44 = MethodHandle       6:#45          // REF_invokeStatic java/lang/invoke/StringConcatFactory.', '-  #45 = Methodref          #46.#47        // java/lang/invoke/StringConcatFactory.makeConcatWithCon', '-  #46 = Class              #48            // java/lang/invoke/StringConcatFactory', '-  #47 = NameAndType        #27:#49        // makeConcatWithConstants:(Ljava/lang/invoke/MethodHandl', '-  #48 = Utf8               java/lang/invoke/StringConcatFactory', '-  #49 = Utf8               (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/inv', '-  #50 = String             #51            // \\u0001\\u0001', '-  #51 = Utf8               \\u0001\\u0001'] (共 20 行)
  两者的方法字节码(javap -c,常量池序号抹成 #N)是否一致 = True ;javap -v 的常量池条目数: 57 (JDK 17)vs 57 (JDK 21 --release 17)
  JDK 21 默认(--release 21)与 --release 17 的 class 文件相同 = False (版本号不同:A.class 主版本 65 vs 61)

jar 工具

实测 · 本机执行输出
  同一份 class、间隔 2.2 秒 jar 两次:a.jar 25f786137d36a91f,b.jar f8b708343dcb724f,相同 = False
  条目的时间戳(前 3 条):a.jar [('META-INF/', '2026-10-03 19:25:16'), ('META-INF/MANIFEST.MF', '2026-10-03 19:25:16'), ('demo/', '2026-10-03 19:25:12')] ;b.jar [('META-INF/', '2026-10-03 19:25:18'), ('META-INF/MANIFEST.MF', '2026-10-03 19:25:18'), ('demo/', '2026-10-03 19:25:12')]
  加 --date=2024-01-01T00:00:00Z 同样间隔 2.2 秒 jar 两次:c.jar 058dc0ec908ecff4,d.jar 058dc0ec908ecff4,相同 = True;时间戳 [('META-INF/', '2024-01-01 00:00:00'), ('META-INF/MANIFEST.MF', '2024-01-01 00:00:00')]
  加了 --date,但按不同的顺序把 class 加入:o1.jar bdc04d1e1b3c37af,o2.jar cc95c511f886a28a,相同 = False
  条目顺序 o1: ['demo/A.class', 'demo/B.class', 'demo/Main.class'] ;o2: ['demo/Main.class', 'demo/B.class', 'demo/A.class']
  manifest 里的 Created-By:JDK 21 的 jar 工具 = ['Created-By: 21.0.12.1 (Homebrew)'] ;JDK 17 的 jar 工具 = ['Created-By: 17.0.20.1 (Eclipse Adoptium)']

Maven 构建

实测 · 本机执行输出
  没有设置 outputTimestamp:两次构建(间隔 2.2 秒)jar 的 SHA-256 f6e05b5da5e33980 / 27b48565ed3e61e3,相同 = False
  设置 project.build.outputTimestamp=2024-01-01T00:00:00Z,第二次还把源文件的修改时间改成 1 小时后:d6fae28f279cbec1 / d6fae28f279cbec1,相同 = True
  条目: [('META-INF/', '2024-01-01 00:00:00'), ('META-INF/MANIFEST.MF', '2024-01-01 00:00:00'), ('demo/', '2024-01-01 00:00:00'), ('META-INF/maven/', '2024-01-01 00:00:00')]
  manifest: ['Manifest-Version: 1.0', 'Created-By: Maven JAR Plugin 3.4.2', 'Build-Jdk-Spec: 21', 'Main-Class: demo.Main']
  同样的配置改用 JDK 17 构建:c9bc771134dafc20,与 JDK 21 构建的相同 = False
  两个 manifest 的差异: ['Build-Jdk-Spec: 17'] ↔ ['Build-Jdk-Spec: 21']
  去掉 Maven 描述文件(addMavenDescriptor=false)后 JDK 17 / 21 构建:70ad388be293e62d / aeaee37181e6fa10,相同 = False
  再去掉默认 manifest 条目(addDefaultEntries=false)后 JDK 17 / 21 构建:261b2a0a4f3927ab / 5b025bfe7612e0ff,相同 = False
  内容不同的条目: ['demo/Main.class']

分步演示

可复现 JAR
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK(Temurin 17.0.20、Homebrew 21.0.12 与 27,每篇注明实际用的版本);用 javac 编译、java 直接运行,需要第三方库时用 Maven 解析依赖;计时用 System.nanoTime,数字每次运行会有小幅抖动。JDK 17.0.20 与 JDK 21.0.12;Maven maven-compiler-plugin 3.13.0、maven-jar-plugin 3.4.2
    • 没有验证:Gradle 的可复现配置(reproducibleFileOrder、preserveFileTimestamps)、依赖 jar 的重新打包(shade)与签名对可复现性的影响、不同操作系统(文件系统顺序、换行符)下的构建、diffoscope 之类的差异工具、JDK 17 与 21 的 Main.class 常量池排列为何不同、Maven 传递依赖版本漂移
    京ICP备2026067502号-1京公网安备11010502063487号