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、java、javac 和 jar
1.1 JDK 是开发工具集合
JDK(Java Development Kit)是开发、测试和运行 Java 程序所需的一组工具。典型组成包括:
java:启动 JVM,运行类或 JAR;javac:将 Java 源代码编译为字节码;jar:创建、查看和解包 JAR;javadoc:生成 API 文档;jdb:调试器;jdeps:分析类和模块依赖;jlink:根据模块构建定制运行时;jcmd、jstack、jmap、jstat:诊断运行中的 JVM。
JVM(Java Virtual Machine)只是执行字节码的虚拟机。JDK 包含 JVM,但 JDK 不等于 JVM。
现代 JDK 发行版通常可以直接用于生产运行,因此“编译镜像”和“运行镜像”可以分别使用 JDK 与更精简的运行时。不过,不能把“JDK”理解成“只有编译器”:它同时提供运行时和大量诊断工具。
1.2 java、javac、jar 的职责不同
可以用下面的命令检查当前命令实际来自哪个目录:
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
这通常说明:
- 只安装了运行时环境;
PATH指向了另一个旧 Java;JAVA_HOME与PATH指向了不同 JDK;- 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 大致经过以下阶段:
- 词法和语法分析:判断字符是否能组成合法的 Java 程序;
- 名称解析和类型检查:解析类、方法、字段、泛型和重载;
- 注解处理:执行 annotation processor,可能生成新的源文件或资源;
- 字节码生成:写出
.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:自动提供构造器、访问器、equals、hashCode和toString;sealed interface:限制直接实现者只能是Circle和Rectangle;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 不是纯粹的目录写法差异。模块系统会检查模块名、requires、exports 和可读性关系。将同一个依赖随意放到两种路径上,可能导致不同的解析结果和封装行为。
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 的职责是归档,不是依赖解析器。
因此一个应用可能存在三种部署方式:
- 应用 JAR 与依赖 JAR 分离,启动时设置 class path;
- 生成包含依赖的 uber JAR 或 fat JAR;
- 使用模块化运行时、容器镜像或发行版目录分别放置应用和依赖。
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 通常经历三个阶段:
- 初始化(Initialization):确定参与构建的项目;
- 配置(Configuration):读取构建脚本,创建和配置任务;
- 执行(Execution):根据请求的任务计算任务图并执行必要任务。
因此:
./gradlew tasks
主要用于配置和列出任务,而:
./gradlew clean build
会实际执行编译、测试、检查和打包等任务。
Gradle Wrapper 是项目中的 gradlew、gradlew.bat 和 gradle/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 sourceCompatibility、targetCompatibility 与 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]
关键路径如下:
- 构建工具读取项目描述;
- 解析直接依赖和传递依赖;
- 建立编译 class path 或 module path;
- 用指定 JDK 调用
javac; - 启动测试 JVM 执行测试;
- 将 class、资源和 manifest 写入 JAR;
- 将 JAR 发布到仓库或复制到容器;
- 生产 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)要求:
对相同的源代码、构建配置、依赖内容、工具链和环境输入,构建过程产生逐字节相同的产物。
可以将产物抽象为:
其中:
- :最终产物,例如 JAR;
- :源代码和构建脚本;
- :编译器、插件和构建工具配置;
- :JDK、Maven、Gradle 等工具版本;
- :依赖及其准确内容;
- :编码、时区、文件顺序、环境变量等环境输入。
要得到相同的 ,不能只保证 相同。若 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 NoSuchMethodError 或 NoClassDefFoundError
这类错误常见于依赖图和运行 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 构建成功但第二次哈希不同
建议按以下顺序排查:
- 比较 JAR 内部条目列表;
- 检查条目时间戳;
- 解压后比较文件内容;
- 检查生成源码和资源;
- 检查依赖文件哈希;
- 检查默认编码、时区、区域设置;
- 检查构建插件和 JDK 的精确版本;
- 检查是否把 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 规范定义:
- 合法语言语法;
- 类型检查规则;
record、sealed和模式匹配的语言语义;- 标准类库 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 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 下一篇:Java 25 类型与控制流:基本类型、引用、Record、sealed 和模式匹配
- 延伸:Java 生产交付:容器、JVM 参数、探针、灰度、容量和回滚
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论