Java 基础体系 · 第 2/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 25 工具链:JDK、javac、jar、Maven、Gradle 与可重复构建

Java 程序从源代码到可部署产物,通常要经过这些阶段:

.java 源文件
   │
   ├─ javac:语法分析、类型检查、字节码生成
   │
   ├─ jar:将 .class、资源和元数据组织成归档
   │
   ├─ Maven / Gradle:解析依赖、编排任务、执行测试、发布产物
   │
   └─ 容器或 JVM:加载并运行 .class 或 .jar

这里的“工具链”不是某一个命令,而是多个具有不同职责的组件。理解这些边界,能够解释许多常见问题:为什么安装了 Java 却没有 javac,为什么 javac 能编译但 java -jar 不能启动,为什么本机构建成功而 CI 失败,以及为什么两次构建得到的 JAR 内容相同但 SHA-256 不同。

本文以 Java 25 LTS 为目标版本。Java 25 的语言和标准库规范以 JDK 25 提供的实现为基础;生产项目仍应根据所使用的 Maven、Gradle、插件和构建镜像的兼容矩阵进行版本确认。


1. 先区分 JDK、JVM、javajavacjar

1.1 JDK 是开发工具集合

JDK(Java Development Kit)是开发、测试和运行 Java 程序所需的一组工具。典型组成包括:

  • java:启动 JVM,运行类或 JAR;
  • javac:将 Java 源代码编译为字节码;
  • jar:创建、查看和解包 JAR;
  • javadoc:生成 API 文档;
  • jdb:调试器;
  • jdeps:分析类和模块依赖;
  • jlink:根据模块构建定制运行时;
  • jcmdjstackjmapjstat:诊断运行中的 JVM。

JVM(Java Virtual Machine)只是执行字节码的虚拟机。JDK 包含 JVM,但 JDK 不等于 JVM。

现代 JDK 发行版通常可以直接用于生产运行,因此“编译镜像”和“运行镜像”可以分别使用 JDK 与更精简的运行时。不过,不能把“JDK”理解成“只有编译器”:它同时提供运行时和大量诊断工具。

1.2 javajavacjar 的职责不同

可以用下面的命令检查当前命令实际来自哪个目录:

java -version
javac -version
jar --version

# Linux / macOS
which java
which javac
which jar

# Windows PowerShell
Get-Command java
Get-Command javac
Get-Command jar

一个正确安装的 JDK 25 环境,版本输出应当指向 25 系列,例如:

java 25 ...
javac 25
jar 25

具体补丁版本、供应商名称和构建信息可能不同,不能把某一行完整输出当作跨发行版的固定格式。

常见错误是:

'javac' is not recognized ...

或者:

javac: command not found

这通常说明:

  1. 只安装了运行时环境;
  2. PATH 指向了另一个旧 Java;
  3. JAVA_HOMEPATH 指向了不同 JDK;
  4. CI 容器中只有 JRE 或精简运行时。

java -version 成功并不能证明 javac 存在。编译必须使用 JDK。

1.3 Java 版本、类文件版本和运行时兼容性

javac 的输出不是 Java 源代码,而是 class 文件。class 文件包含一个主版本号(major version),JVM 根据该版本判断自己是否能够加载它。

Java 25 产生的 class 文件主版本是 69。因此:

Java 25 编译结果 → class major version 69

如果用 Java 24 或更早的 JVM 运行,通常会看到类似:

UnsupportedClassVersionError:
... has been compiled by a more recent version of the Java Runtime
(class file version 69.0), this version of the Java Runtime
only recognizes class file versions up to 68.0

这不是依赖缺失,而是“运行时太旧”。

反方向也不同:较新的 JVM 通常可以运行较旧版本的 class 文件,但这不代表应用所依赖的旧 Java API 一定仍然存在,也不代表框架会支持该组合。因此工程上应明确区分:

  • 编译 JDK:执行 javac
  • 构建工具启动 JDK:启动 Maven 或 Gradle;
  • 运行 JDK:执行应用;
  • 目标 Java 版本:class 文件和可使用 API 的上限。

它们可以相同,也可以不同,但差异必须被显式配置和验证。


2. javac:从源代码到字节码的真实过程

2.1 javac 至少完成四类工作

对一个 Java 源文件,javac 大致经过以下阶段:

  1. 词法和语法分析:判断字符是否能组成合法的 Java 程序;
  2. 名称解析和类型检查:解析类、方法、字段、泛型和重载;
  3. 注解处理:执行 annotation processor,可能生成新的源文件或资源;
  4. 字节码生成:写出 .class 文件。

编译成功只说明这些阶段通过,不说明程序在运行时一定正确。比如依赖版本不匹配可能在运行时产生 NoSuchMethodError,而不是编译错误。

2.2 一个可运行的 Java 25 示例

创建目录:

mkdir -p demo/src/com/example
cd demo

创建 src/com/example/App.java

package com.example;

public class App {
    public static void main(String[] args) {
        Shape shape = new Circle(2.0);

        String description = switch (shape) {
            case Circle c -> "circle, area=" + (Math.PI * c.radius() * c.radius());
            case Rectangle r -> "rectangle, area=" + (r.width() * r.height());
        };

        System.out.println(description);
    }
}

sealed interface Shape permits Circle, Rectangle {}

record Circle(double radius) implements Shape {}

record Rectangle(double width, double height) implements Shape {}

这个例子同时使用了:

  • record:自动提供构造器、访问器、equalshashCodetoString
  • sealed interface:限制直接实现者只能是 CircleRectangle
  • switch 类型模式匹配:根据运行时类型选择分支。

由于 Shape 是 sealed 类型,并且许可的实现只有两个,switch 不需要 default 分支即可通过穷尽性检查。这里使用的是已标准化的语言能力,不依赖预览开关。

使用 JDK 25 编译:

mkdir -p out
javac --release 25 -d out src/com/example/App.java

参数含义:

  • --release 25:要求使用 Java 25 的语言规则、标准 API 和目标 class 文件;
  • -d out:将输出写入 out,并按照包名创建目录;
  • 源文件路径:待编译的 Java 文件。

目录结果类似:

out/
└── com/
    └── example/
        ├── App.class
        ├── Circle.class
        ├── Rectangle.class
        └── Shape.class

运行:

java -cp out com.example.App

输出中的面积值类似:

circle, area=12.566370614359172

2.3 为什么优先使用 --release

javac 有三个容易混淆的选项:

  • --source N:允许使用哪个版本的语言语法;
  • --target N:生成哪个版本的 class 文件;
  • --release N:同时约束语言级别、class 文件版本和 Java SE API。

例如,只写:

javac -source 17 -target 17 Example.java

并不充分。若编译器运行在 JDK 25 上,源代码仍可能引用 Java 18 以后新增的 API;生成的 class 文件虽然目标版本是 17,但运行在真正的 Java 17 上时可能因缺少方法而失败。

--release 17 会使用 Java 17 的公开 API 视图,阻止这类引用:

javac --release 17 Example.java

对 Java 25 项目:

javac --release 25 ...

主要价值是把“源码语法版本”“API 使用范围”和“字节码目标版本”绑定为一个约束。它不能保证第三方库支持 Java 25,也不能替代运行时测试。

2.4 类路径与模块路径

传统 Java 应用使用 class path:

javac -cp lib/a.jar:lib/b.jar -d out src/com/example/App.java
java  -cp out:lib/a.jar:lib/b.jar com.example.App

Windows 的路径分隔符是 ;,Linux 和 macOS 通常是 :

如果项目使用 JPMS 模块,则会使用 module path,并在源代码中包含 module-info.java

module com.example.app {
    requires java.net.http;
    exports com.example;
}

编译方式类似:

javac -d out \
  --module-source-path src \
  -m com.example.app

运行:

java --module-path out \
     --module com.example.app/com.example.App

class path 和 module path 不是纯粹的目录写法差异。模块系统会检查模块名、requiresexports 和可读性关系。将同一个依赖随意放到两种路径上,可能导致不同的解析结果和封装行为。


3. jar:归档、入口和依赖边界

3.1 JAR 本质上是 ZIP 归档

JAR(Java ARchive)是带有约定目录和元数据的 ZIP 归档。它可以包含:

com/example/App.class
META-INF/MANIFEST.MF
application.properties

创建 JAR:

jar --create \
    --file app.jar \
    --main-class com.example.App \
    -C out .

参数含义:

  • --create:创建归档;
  • --file app.jar:输出文件名;
  • --main-class com.example.App:写入启动入口;
  • -C out .:切换到 out,将其内容加入归档,而不是把 out 目录本身作为顶层目录。

查看内容:

jar --list --file app.jar

应能看到:

META-INF/MANIFEST.MF
com/example/App.class
com/example/Circle.class
com/example/Rectangle.class
com/example/Shape.class

启动:

java -jar app.jar

java -jar 会读取 META-INF/MANIFEST.MF 中的 Main-Class,然后调用该类的:

public static void main(String[] args)

如果缺少入口,常见错误是:

no main manifest attribute, in app.jar

如果入口类存在但依赖缺少,则可能是:

NoClassDefFoundError

3.2 JAR 默认不包含第三方依赖

下面的命令只把当前编译输出放进 JAR:

jar --create --file app.jar -C out .

它不会自动把 lib/*.jar 里的类复制进去。JAR 的职责是归档,不是依赖解析器。

因此一个应用可能存在三种部署方式:

  1. 应用 JAR 与依赖 JAR 分离,启动时设置 class path;
  2. 生成包含依赖的 uber JAR 或 fat JAR;
  3. 使用模块化运行时、容器镜像或发行版目录分别放置应用和依赖。

fat JAR 需要处理重复资源,例如:

  • META-INF/services/*
  • 签名文件;
  • 多个依赖中的配置文件;
  • 相同路径的类;
    -许可证和 NOTICE 文件。

简单地把 ZIP 内容合并,可能静默覆盖类或资源;因此 fat JAR 应由构建插件明确配置,并对最终内容进行检查。

3.3 查看 JAR 与模块信息

检查 manifest:

unzip -p app.jar META-INF/MANIFEST.MF

检查模块信息:

jar --describe-module --file app.jar

如果 JAR 中包含 module-info.class,它可能是模块化 JAR;如果没有,通常仍然可以作为 class path 上的普通 JAR 使用。模块化与否会影响封装、依赖解析和启动参数,不能仅根据文件扩展名判断。


4. Maven:以生命周期和坐标组织构建

4.1 Maven 的核心对象

Maven 以项目对象模型(POM)描述构建。一个依赖通常由坐标唯一标识:

groupId:artifactId:version

例如:

com.example:demo:1.0.0

其中:

  • groupId:组织或命名空间;
  • artifactId:项目名称;
  • version:版本;
  • packaging:产物类型,如 jar

Maven 根据 POM 解析依赖,并将依赖放入本地仓库、远程仓库和构建 class path。它不是简单执行一个 shell 脚本,而是通过插件实现生命周期阶段。

常用生命周期阶段的因果关系是:

validate
  → compile
  → test
  → package
  → verify
  → install
  → deploy

执行后面的阶段,会先执行前面的相关阶段。例如:

mvn package

通常会完成验证、编译、测试并生成 JAR,但不会安装到本地 Maven 仓库,也不会发布到远程仓库。

4.2 一个最小 Maven 项目

目录结构:

maven-demo/
├── pom.xml
└── src/
    ├── main/java/com/example/App.java
    └── test/java/com/example/AppTest.java

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="
           http://maven.apache.org/POM/4.0.0
           https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>maven-demo</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <project.build.outputTimestamp>2025-01-01T00:00:00Z</project.build.outputTimestamp>
    </properties>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.14.1</version>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.4.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>com.example.App</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

这里的插件版本只是一个示例固定值。实际使用时应确认该版本、Maven 版本和 JDK 25 的兼容性,并通过 Maven Wrapper 固定 Maven 本身。

执行:

mvn clean verify

典型产物:

target/maven-demo-1.0.0.jar

运行:

java -jar target/maven-demo-1.0.0.jar

检查 Maven 实际使用的 Java:

mvn -version

不要只检查:

java -version

因为 shell 中的 java 与 Maven 启动时使用的 Java 可能不是同一个 JDK。

4.3 Maven 编译器、插件和 JDK 的关系

<maven.compiler.release>25</maven.compiler.release> 最终需要传递给 javac。因此:

  • Maven 启动 JDK 太旧,可能无法启动 Maven 或不支持 Java 25;
  • Maven 启动 JDK 足够新,但使用的 javac 可能来自另一个 JDK;
  • 编译器插件版本过旧,可能不认识 release 25
  • 依赖或插件本身可能要求更高或不同的 Java 版本。

Maven Toolchains 可以把“构建 Maven 的 JDK”和“编译项目的 JDK”分开。典型配置由 ~/.m2/toolchains.xml 提供:

<?xml version="1.0" encoding="UTF-8"?>
<toolchains>
    <toolchain>
        <type>jdk</type>
        <provides>
            <version>25</version>
            <vendor>any</vendor>
        </provides>
        <configuration>
            <jdkHome>/opt/jdk-25</jdkHome>
        </configuration>
    </toolchain>
</toolchains>

这不是把任意旧 JDK 变成 JDK 25,而是要求 Maven 找到声明的 JDK,并让支持 toolchain 的插件使用它。路径必须替换为真实安装路径;CI 中应通过镜像或受控安装保证该路径存在。


5. Gradle:以任务图、插件和 Toolchain 组织构建

5.1 Gradle 的执行模型

Gradle 构建由任务(task)组成。任务之间通过输入、输出和显式依赖形成有向无环图:

compileJava ──┐
              ├─> test ─> jar ─> check
compileTest ──┘

Gradle 通常经历三个阶段:

  1. 初始化(Initialization):确定参与构建的项目;
  2. 配置(Configuration):读取构建脚本,创建和配置任务;
  3. 执行(Execution):根据请求的任务计算任务图并执行必要任务。

因此:

./gradlew tasks

主要用于配置和列出任务,而:

./gradlew clean build

会实际执行编译、测试、检查和打包等任务。

Gradle Wrapper 是项目中的 gradlewgradlew.batgradle/wrapper 文件。它固定 Gradle 版本,避免开发机和 CI 使用不同的 Gradle。Wrapper 固定的是 Gradle,不是 JDK;JDK 仍应通过 CI 镜像、环境管理器或 Gradle Toolchain 固定。

5.2 一个 Java 25 Gradle 项目

settings.gradle

pluginManagement {
    repositories {
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
}

rootProject.name = 'gradle-demo'

build.gradle

plugins {
    id 'application'
    id 'java'
}

group = 'com.example'
version = '1.0.0'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

application {
    mainClass = 'com.example.App'
}

tasks.withType(JavaCompile).configureEach {
    options.release = 25
    options.encoding = 'UTF-8'
}

tasks.withType(Jar).configureEach {
    preserveFileTimestamps = false
    reproducibleFileOrder = true
}

其中:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

表达的是“Java 编译工具链需要版本 25”,而不是简单读取当前 shell 的 JAVA_HOME。Gradle 可以使用本机已安装的 JDK,也可以根据配置和插件从工具链供应方获取 JDK;在受限 CI 环境中,通常更适合预先把 JDK 25 放入构建镜像,并验证 Gradle 的探测结果。

执行:

./gradlew clean build

查看 Gradle 和 JVM:

./gradlew --version

运行应用:

./gradlew run

查看 JAR:

jar --list --file build/libs/gradle-demo-1.0.0.jar

application 插件提供了 run 任务和启动脚本,但普通 jar 任务默认仍不等于“包含所有第三方依赖的 fat JAR”。如果应用需要单文件分发,应明确选择并配置相应打包方案,而不是假设 build/libs/*.jar 自动包含全部依赖。

5.3 sourceCompatibilitytargetCompatibility 与 Toolchain

旧式 Gradle 配置常见:

sourceCompatibility = '25'
targetCompatibility = '25'

这主要表达源码和 class 文件目标版本,但对“实际使用哪个 JDK”表达不够完整。更明确的方式是 Java Toolchain:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

再配合:

tasks.withType(JavaCompile).configureEach {
    options.release = 25
}

options.release = 25 对应 javac --release 25 的 API 和字节码约束;Toolchain 决定编译器来自哪个 JDK。两者解决的是不同问题,不能互相替代。

Gradle 本身运行也受 JDK 支持范围限制。Java 25 的支持取决于 Gradle 具体版本,应选择官方兼容矩阵中明确支持 Java 25 的版本,并在 Wrapper 中固定,而不是只在 build.gradle 中写 languageVersion = 25


6. Maven 与 Gradle 的共同数据流和差异

两者都需要完成以下数据流:

flowchart LR
    A[源代码与资源] --> B[依赖解析]
    B --> C[编译器 javac]
    C --> D[class 文件]
    A --> E[资源处理]
    D --> F[测试 JVM]
    E --> F
    D --> G[JAR 打包]
    E --> G
    B --> F
    G --> H[发布仓库或容器镜像]
    H --> I[生产 JVM]

关键路径如下:

  1. 构建工具读取项目描述;
  2. 解析直接依赖和传递依赖;
  3. 建立编译 class path 或 module path;
  4. 用指定 JDK 调用 javac
  5. 启动测试 JVM 执行测试;
  6. 将 class、资源和 manifest 写入 JAR;
  7. 将 JAR 发布到仓库或复制到容器;
  8. 生产 JVM 加载 JAR 中的 class。

Maven 和 Gradle 的主要差异不是“一个能编译、一个不能编译”,而是构建模型不同:

方面 Maven Gradle
核心抽象 生命周期阶段和插件 任务图和任务输入输出
配置形式 XML POM Groovy/Kotlin DSL
复用方式 parent、dependencyManagement、插件 convention plugin、插件、版本目录
增量执行 依赖插件和生命周期 任务输入输出模型
守护进程 通常不以常驻守护进程为核心 Gradle Daemon 常驻
版本固定 Maven Wrapper Gradle Wrapper

Maven 中执行 package 会沿生命周期推进;Gradle 中执行 build 会选择满足任务依赖和输入输出关系的任务。两者都可以得到相同的 class 文件和 JAR,但只有在编译器、依赖、资源处理和归档策略都一致时,二进制结果才有可能一致。


7. 可重复构建:从“构建成功”到“字节相同”

7.1 可重复构建的定义

可重复构建(reproducible build)要求:

对相同的源代码、构建配置、依赖内容、工具链和环境输入,构建过程产生逐字节相同的产物。

可以将产物抽象为:

A=F(S,C,T,D,E)A = F(S, C, T, D, E)

其中:

  • AA:最终产物,例如 JAR;
  • SS:源代码和构建脚本;
  • CC:编译器、插件和构建工具配置;
  • TT:JDK、Maven、Gradle 等工具版本;
  • DD:依赖及其准确内容;
  • EE:编码、时区、文件顺序、环境变量等环境输入。

要得到相同的 AA,不能只保证 SS 相同。若 D 中的某个 SNAPSHOT 更新了,或者 JAR 条目时间戳不同,即使 Java 源代码完全不变,SHA-256 也会变化。

“可重复”与“可增量”也不同:

  • 可重复构建:两次完整构建的输出字节相同;
  • 增量构建:没有变化的任务可以跳过;
  • 构建缓存:复用以前任务的输出。

增量构建可以提高速度,但并不自动保证字节级一致;可重复构建也不要求一定使用缓存。

7.2 JAR 时间戳是最常见的非确定性来源

ZIP/JAR 条目可以携带修改时间。如果每次打包都使用当前时间,则:

mvn package
sha256sum target/*.jar
sleep 2
mvn package
sha256sum target/*.jar

即使 .class 内容不变,JAR 的 manifest 或条目元数据也可能不同。

Maven 可通过统一的输出时间戳控制归档时间:

<properties>
    <project.build.outputTimestamp>2025-01-01T00:00:00Z</project.build.outputTimestamp>
</properties>

同时应使用支持该属性的较新归档插件,并实际比较两次构建结果。属性本身不是魔法:如果某个插件自行写入当前时间,仍可能破坏重复性。

Gradle 的 JAR 任务可以设置:

tasks.withType(Jar).configureEach {
    preserveFileTimestamps = false
    reproducibleFileOrder = true
}

含义是:

  • 不保留源文件系统时间戳;
  • 以确定顺序写入归档条目。

7.3 文件顺序、生成文件和编码也会改变哈希

JAR 是有序字节流。以下两个归档即使包含相同文件,条目顺序不同,也会得到不同哈希:

A.class, B.class, config.properties

和:

config.properties, B.class, A.class

其他常见来源包括:

  • 从无序集合遍历文件;
  • 使用文件系统返回顺序而未排序;
  • 生成包含当前时间、随机 UUID 或主机名的资源;
  • 使用默认字符集读取或写入文件;
  • 使用默认时区格式化时间;
  • 将绝对路径写入调试信息或生成文件;
  • 依赖外部网络服务生成版本信息;
  • 注解处理器根据机器环境生成不同源码;
  • 前端资源、OpenAPI 文档或代码生成器未固定版本。

Java 编译本身也可能受环境影响。工程上应至少固定:

-Dfile.encoding=UTF-8
-Duser.timezone=UTC

更稳妥的做法是让构建代码显式指定编码和时区,而不是依赖 JVM 默认值。例如 Gradle 中设置 options.encoding = 'UTF-8';Maven 中设置 project.build.sourceEncoding

7.4 依赖版本必须指向不可变内容

以下声明不利于重复构建:

<version>[1.0,2.0)</version>

或:

<version>1.0-SNAPSHOT</version>

范围版本允许解析结果随仓库内容变化;SNAPSHOT 允许同一版本标识指向不同内容。

更可靠的做法是:

  • 使用固定版本;
  • 提交 Maven Wrapper 或 Gradle Wrapper;
  • 固定插件版本;
  • 固定基础镜像摘要,而不是只写标签;
  • 使用依赖锁定或生成依赖清单;
  • 在 CI 中使用受控仓库镜像;
  • 校验下载依赖的校验和;
  • 不允许构建期间依赖未经审查的实时网络结果。

“版本号固定”仍不一定等于“内容固定”。远程仓库配置错误、镜像被替换或本地缓存污染,都可能导致同一坐标得到不同文件。因此生产发布应保留依赖清单、工具链版本和产物哈希。

Maven 可以用:

mvn dependency:tree

检查实际依赖图;Gradle 可以用:

./gradlew dependencies

或针对某一配置:

./gradlew dependencyInsight \
    --dependency some-library \
    --configuration runtimeClasspath

这些命令用于解释“为什么这个版本被选中”,而不是直接证明构建可重复。证明仍需要两次构建并比较产物。


8. 一个可验证的重复构建流程

8.1 Maven

先清理工作区:

mvn clean
rm -rf target
mvn -B -Duser.timezone=UTC -Dfile.encoding=UTF-8 package
cp target/maven-demo-1.0.0.jar /tmp/maven-demo-1.jar
sha256sum /tmp/maven-demo-1.jar

再次清理和构建:

rm -rf target
mvn -B -Duser.timezone=UTC -Dfile.encoding=UTF-8 package
sha256sum target/maven-demo-1.0.0.jar
cmp /tmp/maven-demo-1.jar target/maven-demo-1.0.0.jar

cmp 没有输出且返回码为 0,表示两个文件逐字节相同。若哈希不同,不要只看最终哈希,应进一步拆解:

mkdir -p /tmp/jar-a /tmp/jar-b
unzip -q /tmp/maven-demo-1.jar -d /tmp/jar-a
unzip -q target/maven-demo-1.0.0.jar -d /tmp/jar-b
diff -ru /tmp/jar-a /tmp/jar-b

如果解压后的文件内容相同但 JAR 哈希不同,优先检查归档顺序、时间戳和压缩参数;如果解压后的文件也不同,则检查生成资源、编译器输入、依赖或编码。

8.2 Gradle

使用 Wrapper,并关闭可能引入环境差异的参数:

./gradlew clean
rm -rf build
./gradlew --no-daemon \
    -Duser.timezone=UTC \
    -Dfile.encoding=UTF-8 \
    build
cp build/libs/gradle-demo-1.0.0.jar /tmp/gradle-demo-1.jar
sha256sum /tmp/gradle-demo-1.jar

再次构建:

rm -rf build
./gradlew --no-daemon \
    -Duser.timezone=UTC \
    -Dfile.encoding=UTF-8 \
    build
sha256sum build/libs/gradle-demo-1.0.0.jar
cmp /tmp/gradle-demo-1.0.0.jar \
    build/libs/gradle-demo-1.0.0.jar

--no-daemon 不是可重复构建的必要条件,但有助于排除旧守护进程、不同 JVM 参数或残留状态造成的诊断干扰。真正稳定后,生产 CI 是否使用 Daemon 应根据构建隔离和性能需求决定。


9. 常见失败表现与诊断路径

9.1 release version 25 not supported

例如:

error: release version 25 not supported

含义是当前实际执行的 javac 不支持 --release 25。诊断顺序:

java -version
javac -version
mvn -version
./gradlew --version

如果 java 是 25 而 javac 不是,说明 PATH 混用了多个 JDK。若 Maven 或 Gradle 显示的 JVM 不是 25,则需要检查 JAVA_HOME、Wrapper 和 Toolchain。

9.2 UnsupportedClassVersionError

这表示运行 JVM 低于编译目标。例如 class major version 69 需要 Java 25 运行。确认:

java -version
javap -verbose out/com/example/App.class | grep 'major version'

修复路径有两种:

  • 用 Java 25 或更高版本运行;
  • 使用目标运行时版本的 --release 重新编译,例如目标是 Java 21:
javac --release 21 ...

后一种情况下,源码不能使用 Java 25 才提供的语言能力,且不能调用 Java 22 以后新增的标准 API。

9.3 NoSuchMethodErrorNoClassDefFoundError

这类错误常见于依赖图和运行 class path 不一致:

java.lang.NoSuchMethodError
java.lang.NoClassDefFoundError

可能原因:

  • 编译时使用了依赖 A 的新版本;
  • 运行时 class path 先加载了依赖 A 的旧版本;
  • fat JAR 合并时覆盖了类;
  • 只发布了应用 JAR,遗漏了第三方依赖;
  • Maven 与 Gradle 的依赖范围或配置不同。

诊断时应同时检查:

mvn dependency:tree
./gradlew dependencies --configuration runtimeClasspath
jar --list --file app.jar

关键不是“依赖是否在本地存在”,而是“运行时最终加载了哪个文件”。

9.4 Maven 和 Gradle 使用了不同 JDK

下面四条命令分别检查不同层次:

java -version
javac -version
mvn -version
./gradlew --version

可能出现:

java    → 25
javac   → 25
maven   → Java 17
gradle  → JVM 21

这并非一定错误,但如果项目要求由 Java 25 编译,就必须确认构建工具是否通过 Toolchain 使用了 JDK 25。否则“命令行看起来是 Java 25”并不能证明构建使用了 Java 25。

9.5 构建成功但第二次哈希不同

建议按以下顺序排查:

  1. 比较 JAR 内部条目列表;
  2. 检查条目时间戳;
  3. 解压后比较文件内容;
  4. 检查生成源码和资源;
  5. 检查依赖文件哈希;
  6. 检查默认编码、时区、区域设置;
  7. 检查构建插件和 JDK 的精确版本;
  8. 检查是否把 Git 提交时间、工作区路径或机器名写入产物。

对于 JAR 条目,可以查看详细元数据:

unzip -l app.jar

如果需要进一步分析 ZIP 中的时间和顺序,可使用专门的 ZIP/JAR 检查工具;不要仅凭文件修改时间判断,因为文件系统时间和归档内部时间是两个不同层次。


10. 与生产交付的连接:构建产物必须可识别、可验证、可回滚

可重复构建的价值不只是“哈希好看”。生产发布通常会经历:

源码提交
  → CI 构建
  → 测试和安全扫描
  → 生成 JAR / 镜像
  → 记录 SHA-256 和依赖清单
  → 灰度发布
  → 观察探针、错误率和容量
  → 扩大流量或回滚

如果同一个版本号在不同时间生成不同 JAR,回滚就可能不是回到原来的程序,而是重新构建出另一个程序。更可靠的发布标识应至少包含:

  • Git 提交标识;
  • JDK、Maven 或 Gradle 的精确版本;
  • 依赖解析结果;
  • JAR 或容器镜像摘要;
  • 构建时间等信息是否参与运行行为的说明。

这里要区分两类时间:

  • 构建元数据时间:可以固定或从产物中移除,以保证字节重复;
  • 运行时业务时间:应用读取系统时钟,不能因为构建可重复而被固定。

在容器中,常见的多阶段构建是:

FROM <含 JDK 25 和构建工具的固定镜像> AS build
WORKDIR /src
COPY . .
RUN ./gradlew --no-daemon clean build

FROM <与生产要求匹配的 Java 25 运行镜像>
WORKDIR /app
COPY --from=build /src/build/libs/gradle-demo-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

实际项目应将基础镜像固定到不可变摘要,并通过启动探针、就绪探针和应用版本信息确认运行的确实是预期产物。探针失败时,平台可能重启实例或停止导流;如果镜像标签与 JAR 内容没有稳定对应关系,就很难准确判断是代码问题、配置问题还是发布内容漂移。


11. 工具链版本应如何固定

一个可审计的 Java 25 项目通常至少固定以下内容:

JDK 25 的发行版和补丁版本
Maven 或 Gradle 版本
Maven Wrapper 或 Gradle Wrapper
编译器插件版本
打包插件版本
依赖版本及锁定结果
容器基础镜像摘要
文件编码和时区

其中“Java 25”只描述特性和主版本,不一定唯一确定供应商、补丁级别、字体、CA 证书、系统库和压缩实现。对二进制完全重复有要求的场景,还需要固定构建镜像和操作系统层。

可以把构建输入看成一份清单:

source revision = S
jdk = T
build tool = C
dependencies = D
environment policy = E
artifact hash = H

只有当 S、T、C、D、E 都可追溯时,H 才具有审计意义。单独记录一个 JAR 哈希,可以验证文件是否被改变,但不能说明它是由什么源代码和工具生成的。


12. 规范保证、实现行为与工程建议的边界

需要明确三种不同层次:

12.1 Java 规范保证

Java 语言规范和 Java SE API 规范定义:

  • 合法语言语法;
  • 类型检查规则;
  • recordsealed 和模式匹配的语言语义;
  • 标准类库 API;
  • class 文件和运行时行为的相关约束。

javac --release 25 的目标是使用 Java 25 的语言和标准 API 视图进行编译。

12.2 JDK 和构建工具实现

以下行为可能由具体实现、版本或插件决定:

  • JAR 条目默认顺序;
  • 是否默认保留文件时间戳;
  • 编译器生成的调试信息细节;
  • Maven 或 Gradle 对工具链的发现方式;
  • 依赖缓存和远程仓库访问策略;
  • fat JAR 对重复资源的合并规则。

不能因为某个版本的 Maven 或 Gradle 默认表现稳定,就把它当作 Java 规范保证。要实现可重复构建,应显式配置并验证。

12.3 工程经验

以下属于工程取舍:

  • 是否使用 class path 还是模块路径;
  • 是否生成 fat JAR;
  • 是否允许在线下载工具链;
  • 是否在 CI 禁用守护进程;
  • 是否构建后立即生成容器镜像;
  • 是否要求字节级重复,还是只要求源码和依赖可追溯。

这些选择应由部署方式、合规要求、启动性能、镜像大小和运维能力共同决定,而不是由 Maven 或 Gradle 的默认行为自动决定。

Java 25 工具链的核心边界可以归纳为:

JDK       提供 JVM 和开发工具
javac     将源代码编译为受版本约束的字节码
jar       将字节码、资源和元数据归档
Maven     通过生命周期和插件编排构建
Gradle    通过任务图和输入输出编排构建
可重复构建 让相同且可追溯的输入产生逐字节相同的产物

当这些职责被分别验证后,Java 程序从 src/main/java 到生产 JVM 的过程就不再是“执行一个构建命令”,而是一条可解释、可诊断、可审计的交付链。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。