Java 26 深度拆解:当 JVM 决定「final 从此真的是 final」——从双卡表写屏障到惰性常量,一次为「可信性即性能」做的运行时手术
Java 26 于 2026 年 3 月 17 日 GA,10 个 JEP,非 LTS。
大多数版本速览文章会这样开头,然后按编号把 10 个 JEP 抄一遍,配上「值得关注」四个字收尾。读完你会觉得这是个平庸的过渡版本:Applet 终于删了、Vector API 又孵化了一轮(第十一轮)、结构化并发又预览了一次(第六次)。
我的看法不一样。
Java 26 是「完整性即性能」(integrity as performance)这条主线的收网之年。 过去十年,JVM 团队一直在做一件极不讨喜的事:把 Java 平台上那些「能绕过去」的口子一个一个焊死——JEP 396 强封装 JDK 内部 API、JEP 411 弃用 Security Manager、JEP 486 永久禁用 Security Manager、JEP 498 给 sun.misc.Unsafe 内存访问加警告、JEP 472 限制 JNI。每一步都被社区骂过。
而 JEP 500「Prepare to Make Final Mean Final」是这条线上最后、也是最硬的一块骨头。它焊死的不是某个内部 API,而是反射改 final 字段这个从 Java 1.5 就存在、被半个 Java 生态依赖的行为。
为什么非焊不可?因为只要 final 不可信,JIT 就不能做常量折叠;不能常量折叠,LazyConstant(JEP 526)就只是个花哨的 Supplier;不能常量折叠,Leyden 的 AOT 缓存(JEP 516)就守不住它预计算的那些值。JEP 500 不是一个安全特性,它是 JEP 526 和 JEP 516 的前置条件。
再加上 JEP 522(G1 写屏障双卡表)把吞吐从另一头拉起来,Java 26 实际上是一次三线并进的运行时手术:
| 手术台 | JEP | 目标 |
|---|---|---|
| 可信常量 | 500 + 526 | 让 JIT 敢把值当常量折进机器码 |
| 启动时间 | 516 | AOT 对象缓存脱离 GC 格式绑定 |
| 稳态吞吐 | 522 | 写屏障指令数砍掉四分之三 |
外加一条独立的网络主线:JEP 517 把 HTTP/3 焊进了 HttpClient。
这篇文章按这四条线走,每条线讲清楚原理、代码、量化方法和坑。最后给一份从 JDK 21/25 升到 26 的检查清单。
一、暗线一:完整性即性能 —— JEP 500 到底在防什么
1.1 为什么 JVM 十年来都不敢相信 final
先做一道题。下面这段代码,JIT 能不能把 MAX 折成立即数?
public class Config {
private static final int MAX = 1024;
public boolean check(int n) {
return n < MAX;
}
}
答案是:能,因为 static final 的基本类型字段是 JLS 定义的编译期常量(constant variable),javac 在编译阶段就把 MAX 内联进字节码了,Config.MAX 这个字段甚至在运行期都不会被读。
换个写法:
public class Config {
private static final int MAX = Integer.parseInt(System.getProperty("max", "1024"));
public boolean check(int n) {
return n < MAX;
}
}
现在 MAX 不是编译期常量了,值要到 <clinit> 执行完才确定。JIT 想优化只能这样赌:在类初始化完成之后,static final 字段的值不会再变——于是把它当作运行期常量折叠掉。HotSpot 确实这么干(static final 字段在 C2 里走 ciField::is_constant() 这条路)。
但对实例字段呢?
public class Order {
private final long id; // 实例 final 字段
private final String tenant;
Order(long id, String tenant) {
this.id = id;
this.tenant = tenant;
}
}
HotSpot 默认不会把实例 final 字段当常量折叠(除了 record、String、Integer 这类被特殊标记为 trusted final 的类)。原因就一个:
Field f = Order.class.getDeclaredField("tenant");
f.setAccessible(true);
f.set(order, "another-tenant"); // 合法,且十年来一直合法
只要这条路存在,JVM 就必须假设任何一个实例 final 字段随时可能被改。它不能把值缓存进机器码,不能消除重复读,不能基于该值做类型窄化和分支剪裁。
一个被生态滥用的后门,让所有人的所有 final 字段都失去了优化资格。 这就是 JEP 500 的动机。
1.2 常量折叠到底折掉了什么
值得把「常量折叠」说细一点,否则容易觉得这只是省一次内存读。
假设有这样一段热路径:
class Router {
private final boolean debugMode; // 启动时决定,运行期不变
private final int shardCount;
int route(String key) {
if (debugMode) { // ①
log(key);
}
return Math.floorMod(key.hashCode(), shardCount); // ②
}
}
如果 JIT 能信任这两个 final:
- ①
debugMode == false被折叠成常量 → 整个if分支连同log()的调用点被死代码消除。不是「分支预测猜对」,是这段代码根本不存在于生成的机器码里。 - ②
shardCount == 64被折叠成常量 →floorMod的除法(几十个周期)被强度削弱成& 63(一个周期)。如果shardCount不是 2 的幂,也能变成乘法+移位的魔数除法。
差距不是「省一次 load」,是一个数量级。这就是为什么 record 从一开始就被标记为 trusted final——JVM 敢信任它,因为 record 的 final 字段在规范上就不允许反射修改。
JEP 500 想做的,是把这个待遇推广到所有 final 字段。
1.3 JDK 26 的行为:警告,以及四档开关
JDK 26 还没有禁止反射改 final,它只是开始警告。这是标准的 OpenJDK 温水套路(参考 sun.misc.Unsafe 的三步走:警告 → 默认拒绝 → 移除)。
import java.lang.reflect.Field;
public class FinalMutationDemo {
static class Config {
private final String env = "prod";
String env() { return env; }
}
public static void main(String[] args) throws Exception {
Config cfg = new Config();
Field f = Config.class.getDeclaredField("env");
f.setAccessible(true);
f.set(cfg, "dev"); // ← JDK 26 起在运行期打印警告
System.out.println(cfg.env());
}
}
运行 java FinalMutationDemo,你会在 stderr 看到一条 final 字段修改的警告。关键点:这条警告无法用 --add-opens 消掉。 --add-opens 解决的是「模块可访问性」,而 final 字段可变性是另一个正交维度——这一点是升级时最容易踩的坑,很多团队的启动脚本里堆了一屏 --add-opens,会误以为已经覆盖。
控制开关有两个:
# ① 全局策略开关(四档)
java --illegal-final-field-mutation=warn -jar app.jar # 默认:每次违规打警告
java --illegal-final-field-mutation=allow -jar app.jar # 静默允许(临时止血用)
java --illegal-final-field-mutation=debug -jar app.jar # 打警告 + 完整堆栈(定位神器)
java --illegal-final-field-mutation=deny -jar app.jar # 直接抛异常(CI 用)
# ② 按模块开白名单(推荐的长期方案)
java --enable-final-field-mutation=ALL-UNNAMED -jar app.jar
java --enable-final-field-mutation=com.example.legacy -jar app.jar
我的建议是别用 allow。allow 会让你在下一个版本被打个措手不及。正确姿势是:
- 开发/测试环境用
debug把所有违规点的堆栈捞出来; - 生产环境短期用
--enable-final-field-mutation给具体模块开白名单(有明确的收敛目标); - CI 里对自己的代码用
deny卡死,防止新增违规。
1.4 谁会中招:一张受影响清单
这是升级前最该做的功课。按「改 final 字段」这个行为特征,高危区域如下:
| 类别 | 典型代表 | 为什么会改 final |
|---|---|---|
| Mock / 测试框架 | Mockito(@InjectMocks 注入 final 字段)、EasyMock、PowerMock | 往被测对象里塞替身 |
| 依赖注入 | 早期 Spring 的字段注入、Guice、CDI 实现 | 构造后回填字段 |
| 序列化 / 反序列化 | Kryo、FST、Jackson(ObjectMapper 走字段路径时)、Gson | 反序列化时无构造器地填字段 |
| ORM | Hibernate 的字节码增强 / 代理回填 | 延迟加载后回填关联字段 |
| 配置绑定 | 各类 @Value / properties 绑定工具 | 把外部配置写进不可变对象 |
| 单元测试工具 | ReflectionTestUtils、Whitebox、各种 setField() 工具类 | 直接改私有 final 状态 |
| 缓存 / 池化 | 对象复用时重置 final 字段 | 复用而非新建 |
| 热更新 / 补丁框架 | Agent 类的运行期打补丁 | 替换 final 引用 |
注意最后一行:你自己项目里那个 TestUtils.setField(obj, "name", value),几乎必然在名单上。 这是国内团队最普遍的中招点,比第三方库还常见。
1.5 实战:在 CI 里提前扫出所有违规点
只靠跑一遍应用是扫不全的——违规是运行期触发的,没走到的代码路径不会报警。所以要在测试阶段做,让覆盖率帮忙。
先写一个把警告变成硬失败的 Maven Surefire 配置:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>
--illegal-final-field-mutation=debug
-Xshare:off
</argLine>
<!-- 把 stderr 完整落盘,便于后续 grep -->
<redirectTestOutputToFile>true</redirectTestOutputToFile>
</configuration>
</plugin>
然后用一个脚本把违规点聚合成报告:
#!/usr/bin/env bash
# scan-final-mutation.sh —— 聚合 final 字段修改违规点,做「只降不升」基线
set -euo pipefail
REPORT_DIR="${1:-target/surefire-reports}"
OUT="target/final-mutation-report.txt"
grep -rh -A 15 -i "final field" "$REPORT_DIR" 2>/dev/null \
| grep -E '^\s+at ' \
| sed -E 's/^\s+at //; s/\(.*\)$//' \
| grep -vE '^(java|jdk|sun)\.' \
| sort | uniq -c | sort -rn > "$OUT"
COUNT=$(wc -l < "$OUT")
echo "=== 违规调用点 TOP 30 ==="; head -30 "$OUT"
echo "共 $COUNT 个不同调用点"
BASELINE=${FINAL_MUTATION_BASELINE:-0}
if [ "$COUNT" -gt "$BASELINE" ]; then
echo "[×] 违规点数量 $COUNT 超过基线 $BASELINE"; exit 1
fi
echo "[√] 未超过基线"
这个「基线只降不升」的套路(ratchet pattern)在做任何渐进式治理时都好用:不要求一次清零,但保证不再恶化。
对自己写的新模块,直接上 deny:
# 新模块的测试直接抛异常,杜绝新增
java --illegal-final-field-mutation=deny \
--enable-final-field-mutation=legacy.module \
-jar app.jar
1.6 序列化框架的合法通道
反序列化必须能填 final 字段,否则 readObject 根本没法工作。JEP 500 明确给序列化留了口子:走 sun.reflect.ReflectionFactory 拿到的 setter 不受此限制。
import sun.reflect.ReflectionFactory;
import java.lang.reflect.Field;
import java.lang.invoke.MethodHandle;
// 反序列化框架的正确姿势
public final class FinalFieldWriter {
private static final ReflectionFactory RF = ReflectionFactory.getReflectionFactory();
/**
* 为反序列化场景获取一个可写 final 字段的 MethodHandle。
* 通过 ReflectionFactory 获取的 setter 不会触发 JEP 500 警告。
*/
public static MethodHandle setterFor(Field f) {
f.setAccessible(true);
// override = true 表示跳过访问检查,用于反序列化
return RF.unreflectFieldSetter(f, true);
}
}
如果你在维护一个序列化库,这是升级 JDK 26 之后必须做的迁移;如果你只是使用者,去看看你依赖的 Kryo / Jackson 版本有没有跟进。
二、暗线二:合法的惰性 —— JEP 526 Lazy Constants
JEP 500 关上了一扇门,JEP 526 打开了一扇窗。
2.1 从 double-checked locking 说起
「延迟初始化 + 之后当常量用」是个极常见的需求。Java 里的经典写法:
class OrderController {
private volatile Logger logger; // 必须 volatile,否则有可见性问题
Logger logger() {
Logger l = logger;
if (l == null) {
synchronized (this) {
l = logger;
if (l == null) {
logger = l = Logger.create(OrderController.class);
}
}
}
return l;
}
}
这段代码的问题有三层:
- 写起来烦,而且很容易写错(漏掉
volatile、漏掉局部变量缓存)。 volatile读永远无法被折叠。 JIT 看到volatile就知道「这个值任何时候都可能变」,每次访问都必须真读内存,还带 acquire 语义的顺序约束。你为了「只初始化一次」的正确性,付出了「永远不能优化」的代价。- 它是个字段,不是常量。 上一节讲的死代码消除、强度削弱,一律享受不到。
Holder idiom(静态内部类)能解决第 2 点,但只适用于静态场景,且每个延迟初始化的对象都要写一个内部类。
2.2 从 StableValue 到 LazyConstant:不只是改名
JDK 25 的 JEP 502 引入了 StableValue。JDK 26 的 JEP 526 做了一次重新设计并改名为 LazyConstant。变化不是刷漆:
| 维度 | JDK 25 StableValue | JDK 26 LazyConstant |
|---|---|---|
| 类名/包 | java.lang.StableValue | java.lang.LazyConstant |
| 取值 | orElseSet(supplier) 等多个低阶方法 | 统一 get() |
| 聚合 | StableValue.list/map | List.ofLazy / Map.ofLazy |
| null | 允许 | 计算函数返回 null 抛 NPE |
| 心智模型 | 「一个可以被设置一次的槽」 | 「一个还没算出来的常量」 |
最后一行是关键。StableValue 的模型是「槽 + 写入」,天然引出「谁来写、什么时候写、写之前读到什么」这些问题。LazyConstant 的模型是「常量 + 它的计算方式」,构造时就把计算函数交出去,外界根本没有写入通道。API 表面更小,语义更强,JVM 也更好优化。
2.3 用法
import java.lang.LazyConstant;
import java.util.List;
import java.util.Map;
import java.util.Set;
class OrderController {
// 注意:LazyConstant 引用本身必须是 final,否则拿不到常量折叠红利
private final LazyConstant<Logger> logger =
LazyConstant.of(() -> Logger.create(OrderController.class));
void submit(User user, List<Product> products) {
logger.get().info("order started");
// ... 业务逻辑
logger.get().info("order submitted");
}
}
class Application {
// 应用级组件:按需初始化,不用时永不创建
static final LazyConstant<OrderController> ORDERS = LazyConstant.of(OrderController::new);
static final LazyConstant<ProductRepository> PRODUCTS = LazyConstant.of(ProductRepository::new);
static final LazyConstant<UserService> USERS = LazyConstant.of(UserService::new);
static OrderController orders() { return ORDERS.get(); }
// 惰性列表:10 个元素,访问哪个初始化哪个
static final List<Worker> WORKERS = List.ofLazy(10, i -> new Worker("worker-" + i));
// 惰性映射:按 key 延迟初始化,key 集合固定
static final Map<String, OrderController> BY_TENANT =
Map.ofLazy(Set.of("customers", "internal", "testing"), k -> new OrderController());
}
编译运行(预览特性):
javac --release 26 --enable-preview LazyDemo.java
java --enable-preview LazyDemo
2.4 为什么 LazyConstant 能折叠,而 volatile 字段不能
这是本节最值得理解的部分。
LazyConstant 内部当然也有一个字段存值,也需要保证线程安全。区别在于——JVM 知道这个字段的语义:一旦写入就永不改变(write-once,JVM 内部用 @Stable 注解表达)。
@Stable 对 JIT 的含义是:「如果你在编译时读到的值不是默认值(null/0),你可以把它当常量用」。所以:
static final LazyConstant<Config> CONFIG = LazyConstant.of(Config::load);
boolean isDebug() {
return CONFIG.get().debug(); // ①
}
第一次执行 ① 时走慢路径:加锁、调 Config::load、写字段。之后 JIT 编译 isDebug() 时,读到的 LazyConstant 内部字段已经非默认值,于是:
CONFIG本身是static final→ 折叠成常量对象;- 内部
@Stable字段已初始化 → 折叠成具体的Config实例; Config.debug如果也是 trusted final → 继续折叠成true/false;isDebug()整个方法被内联成一个立即数。
三层折叠一路打穿。 这是 volatile 字段永远做不到的——因为 volatile 的语义就是「随时可能变」。
而这一切成立的前提是什么?JVM 必须能相信 final 不会被反射改掉。 现在应该看清楚了:JEP 500 和 JEP 526 是同一件事的两面。
2.5 五个陷阱
① LazyConstant 引用本身没写 final,白干。
private LazyConstant<Logger> logger = LazyConstant.of(...); // [×] 少了 final
private final LazyConstant<Logger> logger = LazyConstant.of(...); // [√]
非 final 字段每次都要重新读,第一层折叠就断了,后面的连锁优化全部失效。
② 计算函数返回 null 直接 NPE。
static final LazyConstant<String> HOME =
LazyConstant.of(() -> System.getenv("APP_HOME")); // 环境变量没设 → NPE
// 正确写法
static final LazyConstant<String> HOME =
LazyConstant.of(() -> Objects.requireNonNullElse(System.getenv("APP_HOME"), "/opt/app"));
这是从 StableValue 迁移过来最容易踩的行为变更。如果你的值天然可能为空,用 Optional 包一层。
③ 计算函数抛异常,不会被缓存。
下一次 get() 会重新执行计算函数。这跟 Guava 的 Suppliers.memoize 行为一致,但和「静态内部类 Holder」不同(Holder 抛异常后类进入 Erroneous 状态,之后每次访问都抛 NoClassDefFoundError)。所以:计算函数里做网络 IO 要自己加退避,否则可能变成重试风暴。
static final LazyConstant<Client> CLIENT = LazyConstant.of(() -> {
// [×] 不要在这里直接 connect(),失败会被反复调用
// [√] 只做构造,把连接推迟到第一次真正使用
return new Client(Config.url());
});
④ 计算函数里递归调用同一个 LazyConstant → 死锁或异常。
static final LazyConstant<A> A_INST = LazyConstant.of(() -> new A(B_INST.get()));
static final LazyConstant<B> B_INST = LazyConstant.of(() -> new B(A_INST.get())); // [!] 循环依赖
跟 Spring 的循环依赖不同,这里没有三级缓存救你。设计时要保证依赖图无环。
⑤ 别拿它当可变缓存用。 它叫「常量」,就是常量。需要能失效的缓存,用 Caffeine。
2.6 和现有方案的边界
| 方案 | 线程安全 | 常量折叠 | 适用 |
|---|---|---|---|
static final 直接赋值 | [√] | [√] | 值在类初始化时就能算出来 |
| Holder 内部类 | [√] | [√] | 仅静态场景,一个值一个内部类 |
DCL + volatile | [√] | [×] | 实例字段,遗留代码 |
Guava Suppliers.memoize | [√] | [×] | 需要兼容老 JDK |
Spring @Lazy | [√] | [×] | 容器管理的 Bean,代理开销 |
LazyConstant | [√] | [√] | 静态 + 实例通吃,零代理 |
一句话:LazyConstant 是第一个既能延迟又能折叠的方案。 这个组合以前不存在。
三、暗线三:启动时间 —— JEP 516 让 AOT 缓存脱离 GC 绑定
3.1 Leyden 的三步走
Project Leyden 的核心思路是「把运行期才做的工作,挪到一个叫『训练』的离线阶段做完,把结果存起来」。落到 JDK 上是三步:
- JDK 24 / JEP 483:AOT 类加载与链接。把已读取、解析、加载、链接好的类存进缓存,启动时直接用。
- JDK 25 / JEP 514:命令行工效学。原来要两步(先 record 再 create),现在一步搞定。
- JDK 26 / JEP 516:AOT 对象缓存支持所有 GC。
3.2 为什么以前 ZGC 用不了
JDK 24/25 的 AOT 缓存里存的不只是类元数据,还有已经构造好的 Java 对象(Class 对象、字符串常量、各种不可变元数据)。这些对象在缓存文件里是按特定 GC 的堆布局序列化的——启动时 mmap 一下,整块内存直接变成可用的堆区域,快到近乎免费。
问题是 ZGC 用的是着色指针(colored pointers),引用里编码了 GC 元数据位,内存布局跟 G1 完全不同。缓存文件按 G1 格式写的,ZGC 读不了。
于是 JDK 25 的 ZGC 用户面临一个荒诞的二选一:要低延迟就没有快启动,要快启动就得回 G1。
3.3 JEP 516 的解法:逻辑索引 + 后台物化
JEP 516 引入了一个 GC 无关的对象缓存格式:
- 对象之间的引用不再存内存地址,改存逻辑索引(第 N 个对象);
- 启动时不再
mmap,而是由后台线程顺序物化:为每个对象分配堆内存、初始化字段、把逻辑索引翻译成真实引用; - 应用线程第一次用到某个类时,等待该类相关对象物化完成。
这是典型的用一点 CPU 换通用性:物化肯定比 mmap 慢,但它跑在后台线程上,和应用的类加载并行推进,实测下来大部分启动路径感知不到差异。
3.4 两阶段命令
# ===== 训练阶段 =====
# 用有代表性的负载跑一遍,生成 GC 无关(streamable)格式的缓存
java -XX:AOTCacheOutput=app.aot \
-XX:+AOTStreamableObjects \
-jar yourapp.jar --run-training-workload
# ===== 生产阶段 =====
# 可以用和训练时完全不同的 GC
java -XX:+UseZGC \
-XX:AOTCache=app.aot \
-jar yourapp.jar
训练集设计比命令本身重要得多。 几条经验:
# [×] 反面教材:只跑一个 --version 就退出,缓存里什么都没有
java -XX:AOTCacheOutput=app.aot -jar app.jar --version
# [√] 正面做法:跑一轮真实流量回放,覆盖主要接口
java -XX:AOTCacheOutput=app.aot -XX:+AOTStreamableObjects -jar app.jar &
APP_PID=$!
sleep 10 # 等应用起来
./replay-traffic.sh --duration 60s --qps 50 # 回放线上采样流量
curl -X POST localhost:8080/actuator/shutdown
wait $APP_PID
训练阶段跑到了哪些类,缓存里就有哪些类。冷接口不训练,第一次调用照样慢。
3.5 格式选型决策表
| 训练/生产环境 | 推荐格式 | 参数 |
|---|---|---|
| 用 ZGC | GC 无关(流式) | -XX:+AOTStreamableObjects |
| 堆 > 32GB(压缩 oops 关闭) | GC 无关(流式) | -XX:+AOTStreamableObjects |
显式 -XX:-UseCompressedOops | GC 无关(流式) | -XX:+AOTStreamableObjects |
| G1 + 堆 < 32GB(默认) | GC 特定(内存映射) | 默认,不加参数 |
| 训练和生产 GC 不一致 | GC 无关(流式) | -XX:+AOTStreamableObjects |
最后一行是 JEP 516 最大的实用价值:训练用 G1(构建机上省事),生产切 ZGC(低延迟),缓存照样生效。
3.6 横向定位:AOT 缓存 vs CRaC vs native-image
三者都在解「Java 启动慢」,但解的不是同一层:
| 方案 | 原理 | 启动改善 | 峰值性能 | 代价 |
|---|---|---|---|---|
| AOT 缓存(Leyden) | 缓存类加载/链接/对象 | 中等 | 无损(照样 JIT) | 训练阶段 + 缓存文件 |
| CRaC | 进程快照 checkpoint/restore | 极大 | 无损 | 需要处理外部资源(连接、文件句柄) |
| GraalVM native-image | 提前编译成本地可执行文件 | 极大 | 有损(无 JIT,峰值通常低于 C2) | 闭世界假设,反射要配置 |
选型逻辑很清楚:
- 常规微服务、Serverless 冷启动:AOT 缓存。改造成本最低(加两个参数),峰值性能零损失。
- 需要毫秒级冷启动、且能处理资源重连:CRaC。
- 函数计算、CLI 工具、对内存占用极度敏感:native-image,但要接受峰值性能和反射配置的代价。
AOT 缓存的最大优势是它不改变你的编程模型。 反射照用,动态代理照用,字节码增强照用——这是 native-image 给不了的。
四、吞吐:JEP 522 把 G1 写屏障从约 50 条指令压到约 12 条
4.1 卡表与写屏障 101
分代 GC 有个绕不开的问题:做 Young GC 时,怎么知道老年代里有哪些对象引用了年轻代对象?
全扫老年代显然不行(那就不叫分代了)。经典解法是卡表(Card Table):把堆按 512 字节切成一张张「卡」,用一个字节数组记录每张卡「脏不脏」。应用线程每次写引用字段时,顺手把对应的卡标脏——这段插进去的代码就是写屏障(Write Barrier)。
// 你写的代码
obj.field = other;
// JIT 实际生成的(概念上)
obj.field = other;
cardTable[addressToCardIndex(obj)] = DIRTY; // ← 写后屏障
G1 的写屏障比这复杂得多。因为 G1 是 region 化的、支持并发 refinement 的,它的 post-write barrier 要做的事包括:
- 判断新老引用是否跨 region(同 region 内无需记录);
- 判断目标是否为 null;
- 判断卡是否已经脏(避免重复入队);
- 标脏卡表;
- 插入内存屏障(StoreLoad fence),保证标脏对 refinement 线程可见;
- 把卡加入线程本地的 dirty card queue;
- 队列满了要交给 refinement 线程处理。
第 5 步是最贵的。x86 上 StoreLoad 需要 lock addl 或 mfence,一条就是几十个周期,而且会阻断流水线。
4.2 JEP 522 的双卡表方案
核心思路:引入第二张卡表,把「应用线程的标记」和「refinement 线程的消费」在物理上分开。
- 应用线程只往第一张表上写(纯 store,无需 fence);
- GC 在合适的安全点把第一张表的内容批量搬到第二张表;
- refinement 线程只读第二张表。
生产者和消费者不再共享同一块内存,那条昂贵的 StoreLoad fence 就没有存在的必要了。同时,队列管理、重复检查这些逻辑也从快路径挪走。
按 JEP 522 文档给出的数字:应用线程写屏障从约 50 条指令降到约 12 条。
用伪代码对比更直观:
// ===== 旧屏障(概念示意,约 50 指令) =====
if (region_of(obj) == region_of(new_val)) return; // 同 region,跳过
if (new_val == NULL) return;
card = card_for(obj);
if (*card == YOUNG_CARD) return; // 年轻代卡,跳过
StoreLoad_fence(); // ← [!] 最贵的一步
if (*card == DIRTY) return; // 已脏,跳过
*card = DIRTY;
enqueue(thread_local_dirty_queue, card); // 入队
if (queue_full()) handoff_to_refinement_thread(); // 慢路径
// ===== 新屏障(概念示意,约 12 指令) =====
if (region_of(obj) == region_of(new_val)) return;
if (new_val == NULL) return;
*card_table_1[card_for(obj)] = DIRTY; // 纯 store,无 fence,无队列
4.3 谁受益、谁不受益
明显受益:引用写密集型负载。
- 大量构造对象图(ORM 加载关联对象、JSON 反序列化成对象树)
- 频繁修改集合内元素引用(
List.set、Map.put大对象) - 事件驱动/Actor 模型里大量消息对象在代际间流转
- 图/树结构的原地更新
基本不受益:
- 纯数值计算(几乎没有引用写)
- 大对象数组的批量拷贝(走
arraycopy内建,屏障已优化) - 已经在用 ZGC / Shenandoah 的(它们不用卡表,用 load barrier / 转发指针)
按 JEP 描述,引用写密集场景吞吐提升 5–15%,x64 架构上还能额外拿到约 5%,同时停顿时间略降。
这个特性完全透明,升级即生效,不需要任何代码或参数改动。 对绝大多数团队来说,这是 Java 26 里性价比最高的一条。
4.4 怎么量化:别只看 QPS
QPS 受太多因素影响,噪声大。要精确测量写屏障的改善,用这套方法:
# 1. 记录 GC 详细日志,重点看 Young GC 的频率和耗时
java -Xlog:gc*,gc+phases=debug:file=gc-jdk26.log:time,uptime,level,tags \
-XX:+UseG1GC -Xmx4g -Xms4g \
-jar app.jar
// 2. 用 MemoryMXBean.getTotalGcCpuTime() 量化 GC 的 CPU 开销(JDK 26 新增,JDK-8368527)
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
long gcCpuStart = mem.getTotalGcCpuTime(); // 纳秒
long wallStart = System.nanoTime();
runWorkload(); // 你的压测负载
long gcCpu = mem.getTotalGcCpuTime() - gcCpuStart;
long wall = System.nanoTime() - wallStart;
int cpus = Runtime.getRuntime().availableProcessors();
System.out.printf("GC CPU: %.2fs, Wall: %.2fs, GC 占比: %.2f%%%n",
gcCpu / 1e9, wall / 1e9, 100.0 * gcCpu / (wall * cpus));
// 3. 写一个专门放大写屏障开销的 JMH 微基准
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Warmup(iterations = 5, time = 2)
@Measurement(iterations = 10, time = 2)
@Fork(value = 3, jvmArgsAppend = {"-XX:+UseG1GC", "-Xmx2g", "-Xms2g"})
public class WriteBarrierBench {
static class Node { Object ref; }
private Node[] oldGen; // 会被晋升到老年代
private Object[] youngObjs; // 年轻代对象
@Setup(Level.Trial)
public void setup() {
oldGen = new Node[100_000];
for (int i = 0; i < oldGen.length; i++) oldGen[i] = new Node();
for (int r = 0; r < 5; r++) { // 强制若干次 GC 把 oldGen 晋升上去
System.gc();
for (int i = 0; i < 1_000_000; i++) { Object o = new Object(); }
}
youngObjs = new Object[100_000];
for (int i = 0; i < youngObjs.length; i++) youngObjs[i] = new Object();
}
/** 每次赋值都是老->年轻的跨代引用写,必然触发写屏障 */
@Benchmark
public void crossGenerationalWrites() {
Node[] o = oldGen; Object[] y = youngObjs;
for (int i = 0; i < o.length; i++) o[i].ref = y[i]; // <- 写屏障热点
}
}
同一个 benchmark 在 JDK 25 和 JDK 26 上各跑一遍,差值就是写屏障改善的上界。实际业务收益会低于这个数,因为真实负载里写屏障只占一小部分。
五、网络:JEP 517 把 HTTP/3 焊进 HttpClient
5.1 为什么现在才做
JDK 11 的 JEP 321 定型了 HttpClient,支持 HTTP/1.1 和 HTTP/2。HTTP/3 拖到 JDK 26 才进来,原因是它不是「换个版本号」那么简单——HTTP/3 跑在 QUIC 上,QUIC 跑在 UDP 上,这意味着 JDK 得自己实现一个完整的可靠传输层。
TCP 的重传、拥塞控制、流控在内核里;QUIC 的这些全在用户态。JDK 要从零写一遍。
HTTP/3 的收益也确实值得:
- 握手更快:QUIC 把传输握手和 TLS 握手合并,1-RTT 建连,会话恢复时 0-RTT。
- 彻底消除队头阻塞:HTTP/2 的多路复用在应用层,一个 TCP 包丢了,所有流一起等重传。QUIC 的流在传输层独立,丢包只影响那一条流。
- 内置 TLS 1.3,没有明文版本。
- 连接迁移:网络切换(WiFi→4G)时连接 ID 不变,连接不中断。
在丢包率高、网络切换频繁的移动端和跨境链路上,差距非常明显。
5.2 三种协商策略,以及各自的坑
默认协议仍是 HTTP/2,你得主动开 HTTP/3。开的方式有三种,行为差别巨大,这是最容易踩坑的地方。
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
public class Http3Demo {
// ===== 策略一:在 HttpClient 级别指定 =====
// 行为:并行建立 HTTP/3 和 HTTP/2 连接,用最先成功的那个
// 适用:不确定服务端支不支持,要最低延迟
// 代价:会多开一条连接,服务端看到双份连接
static HttpClient racingClient() {
return HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3)
.connectTimeout(Duration.ofSeconds(5))
.build();
}
// ===== 策略二:在单个请求上指定 =====
// 行为:先尝试 HTTP/3,超时后回退到 HTTP/2 或 HTTP/1.1
// 适用:明确知道该端点支持 HTTP/3
// 代价:服务端不支持时,每个请求都要吃一次超时 [!]
static HttpRequest perRequest(URI uri) {
return HttpRequest.newBuilder(uri)
.version(HttpClient.Version.HTTP_3)
.timeout(Duration.ofSeconds(10))
.GET()
.build();
}
// ===== 策略三:ALT-SVC 发现(推荐用于公网) =====
// 行为:先用 HTTP/2,收到服务端 Alt-Svc 响应头后,后续请求自动切 HTTP/3
// 适用:生产环境默认选择——零超时风险,自动升级
// 代价:第一个请求走不到 HTTP/3
static HttpClient discoveryClient() {
return HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
// H3_DISCOVERY 选项:允许通过 Alt-Svc 升级
.build();
}
public static void main(String[] args) throws Exception {
HttpClient client = racingClient();
HttpRequest req = HttpRequest.newBuilder(URI.create("https://example.com/"))
.GET().build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println("status : " + resp.statusCode());
System.out.println("version: " + resp.version()); // HTTP_3 / HTTP_2 / HTTP_1_1
}
}
划重点:策略二在服务端不支持 HTTP/3 时是灾难。 每个请求都要等 UDP 探测超时才回退,P99 直接爆炸。如果你不能 100% 确定对端支持,就别用它。
生产环境的稳妥做法是策略三 + 显式健康检查:
/**
* 带协议降级熔断的 HTTP/3 客户端封装:
* 连续 N 次拿不到 HTTP/3 就彻底关掉尝试,避免持续吃 UDP 探测超时。
*/
public final class AdaptiveHttp3Client {
private static final int FAILURE_THRESHOLD = 3;
private final HttpClient h3 = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_3)
.connectTimeout(Duration.ofSeconds(3)).build();
private final HttpClient h2 = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(3)).build();
private volatile boolean h3Enabled = true;
private final AtomicInteger failures = new AtomicInteger();
public HttpResponse<String> get(URI uri) throws Exception {
HttpRequest req = HttpRequest.newBuilder(uri).GET().build();
if (h3Enabled) {
try {
HttpResponse<String> r = h3.send(req, HttpResponse.BodyHandlers.ofString());
if (r.version() == HttpClient.Version.HTTP_3) {
failures.set(0); // 成功,重置计数
} else if (failures.incrementAndGet() >= FAILURE_THRESHOLD) {
h3Enabled = false; // 熔断:对端大概率不支持
}
return r;
} catch (IOException e) {
if (failures.incrementAndGet() >= FAILURE_THRESHOLD) h3Enabled = false;
// 落到 HTTP/2 重试
}
}
return h2.send(req, HttpResponse.BodyHandlers.ofString());
}
}
5.3 生产环境六个坑
- UDP 443 被防火墙拦。 大量企业网络只放行 TCP 443。HTTP/3 全走 UDP,连接直接失败。上线前先在目标网络环境做连通性验证。
- 只支持 SunJSSE。 JEP 517 明确说了:HTTP/3 的首个实现里,除默认的 SunJSSE 之外,其他安全套接字提供者暂时不可用。如果你在用 BouncyCastle 或国密 SSL provider,HTTP/3 直接用不了。
- Kubernetes Service 默认只转发 TCP。 Service 的
protocol字段要显式加 UDP 条目,Ingress/LB 也得支持。 - UDP 缓冲区太小导致丢包。 Linux 默认的
net.core.rmem_max对 QUIC 偏小,高吞吐场景要调:sysctl -w net.core.rmem_max=8388608。 - 抓包工具链要换。 tcpdump 抓得到 UDP 包但解不开 QUIC 加密载荷。要用 Wireshark +
SSLKEYLOGFILE导出密钥才能看明文。 - UDP 通常比 TCP 更耗 CPU。 没有 TSO/GSO 硬件卸载加持时,QUIC 的用户态处理开销明显更高。低丢包的内网链路上,HTTP/3 可能比 HTTP/2 慢。内网服务间通信别盲目上 HTTP/3。
结论:HTTP/3 是给公网、移动端、跨境链路用的,不是给内网 RPC 用的。
六、语言层:原始类型模式与结构化并发
6.1 JEP 530:原始类型进入模式匹配(第四次预览)
模式匹配从 JDK 16 的 instanceof 一路走来,一直有个尴尬的缺口:只能匹配引用类型。JEP 530 把 int、long、float、double、boolean 等全部纳入。
// ① switch 里直接用原始类型模式
static String category(Object price) {
return switch (price) {
case int p when p < 100 -> "BUDGET";
case int p when p < 500 -> "MID";
case int p -> "PREMIUM";
case long l when l > 10_000 -> "PREMIUM";
case long l -> "BUSINESS";
default -> "UNKNOWN";
};
}
// ② 给 int 的 switch 补上「其余所有值」的分支
// 以前只能写 default,拿不到值;现在能绑定变量
String describe(int status) {
return switch (status) {
case 0 -> "okay";
case 1 -> "warning";
case 2 -> "error";
case int i -> "unknown status: " + i; // 捕获其余 int,且能用 i
};
}
// ③ boolean 的 switch 终于能穷尽了
String tuning(Guitar g) {
return switch (g.isInTune()) {
case true -> "Ready to play";
case false -> "Out of tune";
// 不需要 default,编译器知道 boolean 只有两个值
};
}
// ④ instanceof 做「无损容纳」检查——这个是真的好用
int i = 42;
System.out.println(i instanceof byte); // true:42 能无损放进 byte
int big = 1000;
System.out.println(big instanceof byte); // false:1000 超出 byte 范围
// ⑤ 带绑定变量的原始类型 instanceof
if (roomSize instanceof byte b) {
packInto(b); // b 已经是 byte,无需强转
}
第 ④ 条值得展开说。以前判断「这个 int 能不能安全塞进 byte」,你得手写:
// 旧写法:容易写错,边界处特别容易翻车
if (value >= Byte.MIN_VALUE && value <= Byte.MAX_VALUE) {
byte b = (byte) value;
}
现在一个 instanceof 搞定,而且语义由规范保证。这在协议编解码、数据压缩、类型窄化的场景里能消掉大量样板代码和潜在 bug。
JDK 26 相比第三次预览的主要改进是增强了模式支配性检查(dominance check):比如 long 模式可以支配 int 模式,编译器能在 switch 里检出更多「这个 case 永远走不到」的逻辑错误。
// 编译器现在能报错:long 模式在前,后面的 int 模式不可达
switch (n) {
case long l -> "long";
case int i -> "int"; // [×] 编译错误:被前面的 long 模式支配
}
6.2 JEP 525:结构化并发第六次预览,新增整组超时
结构化并发的核心主张是:把「一组并发任务」当成一个语法块来管理,一个失败全部取消,作用域退出时保证没有线程泄漏。
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
import java.util.concurrent.StructuredTaskScope.Joiner;
import java.time.Duration;
// ===== 基础用法:全部成功,否则抛异常 =====
Response handle() throws InterruptedException, ExecutionException {
try (var scope = StructuredTaskScope.open(Joiner.allSuccessfulOrThrow())) {
Subtask<String> user = scope.fork(this::findUser);
Subtask<Integer> order = scope.fork(this::fetchOrder);
scope.join(); // 等全部完成;任一失败则自动取消其余
return new Response(user.get(), order.get());
} // 作用域退出时保证所有子任务都已终止
}
// ===== JDK 26 新增:整组超时 =====
Response handleWithTimeout() throws InterruptedException, ExecutionException {
try (var scope = StructuredTaskScope.open(
Joiner.onTimeout(Duration.ofSeconds(2), () -> defaultResponse()))) {
var user = scope.fork(this::findUser);
var order = scope.fork(this::fetchOrder);
scope.join(); // 2 秒内没全部完成 → 取消所有子任务,走兜底
return new Response(user.get(), order.get());
}
}
Joiner.onTimeout() 补上的是一个非常实际的缺口。以前要给一组任务加超时,你得给每个子任务单独设超时——但**「每个子任务 2 秒」和「整组 2 秒」是完全不同的 SLA**。三个串行依赖的子任务各设 2 秒,最坏情况是 6 秒。整组超时才是调用方真正关心的那个数字。
对比传统写法的复杂度:
// ===== 传统 ExecutorService 写法 =====
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
try {
Future<String> userF = pool.submit(this::findUser);
Future<Integer> orderF = pool.submit(this::fetchOrder);
long deadline = System.nanoTime() + Duration.ofSeconds(2).toNanos();
try {
String user = userF.get(remaining(deadline), TimeUnit.NANOSECONDS);
Integer order = orderF.get(remaining(deadline), TimeUnit.NANOSECONDS);
return new Response(user, order);
} catch (TimeoutException | ExecutionException e) {
userF.cancel(true); // 手动取消,容易漏
orderF.cancel(true); // 顺序、异常路径都得自己管
return defaultResponse();
}
} finally {
pool.shutdown(); // 忘了这行就是线程泄漏
}
19 行 vs 10 行,而且传统写法有至少三个容易写错的地方。 这就是结构化并发的价值——它把「正确」变成了默认行为。
注意结构化并发已经预览到第六次了。这在 OpenJDK 里不常见,说明 API 设计上还有分歧(主要在 Joiner 的抽象上)。别在核心链路上大规模铺开,等它转正。
七、清理与等待
7.1 JEP 504:Applet API 彻底移除
一条走了 17 年的死刑判决终于执行:
- JDK 9(JEP 289):标记废弃
- JDK 17(JEP 398):标记为待移除
- JDK 26(JEP 504):删除
删掉的内容:整个 java.applet 包(Applet、AppletContext、AppletStub、AudioClip)、java.beans.AppletInitializer、javax.swing.JApplet,以及 java.beans.Beans、javax.swing.RepaintManager 里引用这些类的方法和字段。
理由充分到无需辩解:浏览器早就不支持 Java Applet 了;JDK 11 删了 appletviewer;JDK 24 永久禁用了 Security Manager,而 Applet 沙箱完全建立在 Security Manager 之上。这套 API 在 JDK 24 之后已经物理上无法工作了,留着只是占位。
迁移路径:
- UI 容器 → 改用 AWT 的其他组件或 Swing 的
JPanel/JFrame; - 音频播放 → 用 JDK 25 引入的
javax.sound.SoundClip。
实际影响:如果你的项目还在 import java.applet,问题远不止升级 JDK 这一个。
7.2 JEP 529:Vector API 第十一轮孵化,API 无变化
Vector API 从 JDK 16 孵化到现在,整整 11 轮,创下 OpenJDK 记录。JDK 26 这一轮 API 本身没有任何变化。
为什么一直不转正?因为它在等 Project Valhalla 的 Value Objects。
Vector API 的元素类型(Float16、各种 Vector<E>)本质上是值类型:只有值语义,没有身份(identity),不该有对象头,不该在堆上分配。没有 Valhalla,这些类型只能靠 HotSpot 内部的特殊处理(intrinsic + 逃逸分析)来消除装箱开销——能跑,但不能在语言层面保证。
所以 Vector API 的状态是:技术上早就可用,就等语言基础设施到位。
import jdk.incubator.vector.*;
public class VectorDemo {
// 让 JVM 按当前 CPU 选最优向量宽度(AVX-512 选 512 位,NEON 选 128 位)
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
/** 计算 c[i] = -(a[i]^2 + b[i]^2),向量化版本 */
static void compute(float[] a, float[] b, float[] c) {
int i = 0;
int upperBound = SPECIES.loopBound(a.length);
// 向量化主循环:一次处理 SPECIES.length() 个元素
for (; i < upperBound; i += SPECIES.length()) {
FloatVector va = FloatVector.fromArray(SPECIES, a, i);
FloatVector vb = FloatVector.fromArray(SPECIES, b, i);
FloatVector vc = va.mul(va).add(vb.mul(vb)).neg();
vc.intoArray(c, i);
}
// 标量尾部:处理剩余不足一个向量宽度的元素
for (; i < a.length; i++) {
c[i] = -(a[i] * a[i] + b[i] * b[i]);
}
}
}
javac --add-modules jdk.incubator.vector VectorDemo.java
java --add-modules jdk.incubator.vector VectorDemo
用它的前提是你能接受「每个 JDK 版本都可能改 API」。 目前主要使用者是向量数据库、机器学习推理、图像处理、压缩算法这些自己控制运行环境的场景。
7.3 JEP 524:PEM 编解码第二次预览
JDK 25 引入的 PEMEncoder/PEMDecoder 在 JDK 26 做第二次预览,新增了对 KeyPair 和 PKCS8EncodedKeySpec 的加解密支持。
这个 API 解决的是一个很实际的痛点:Java 处理 PEM 格式一直很难受。以前你得自己剥 Base64、处理 -----BEGIN xxx----- 头尾、判断类型,或者拉 BouncyCastle 进来。
KeyPair keyPair = KeyPairGenerator.getInstance("EC").generateKeyPair();
PEMEncoder encoder = PEMEncoder.of();
String publicKeyPEM = encoder.encodeToString(keyPair.getPublic());
// 私钥加密后再编码(JDK 26 新增对 KeyPair / PKCS8EncodedKeySpec 的加解密支持)
char[] password = "secret".toCharArray();
String encryptedPEM = PEMEncoder.of().withEncryption(password)
.encodeToString(keyPair.getPrivate());
// 解码
PublicKey pubKey = PEMDecoder.of().decode(publicKeyPEM, PublicKey.class);
PrivateKey privKey = PEMDecoder.of().withDecryption(password)
.decode(encryptedPEM, PrivateKey.class);
// X.509 证书同样支持
String certPEM = encoder.encodeToString(cert);
X509Certificate back = PEMDecoder.of().decode(certPEM, X509Certificate.class);
对做 JWT 签名、mTLS 双向认证、密钥管理的团队来说,这个 API 转正后能直接干掉一个 BouncyCastle 依赖。
八、小 API,大价值
版本速览文章通常把这部分一笔带过。但实话说,这里面有几个的日常价值超过一半的 JEP。
8.1 UUID.ofEpochMillis():终于有官方 UUIDv7 了
// JDK-8334015
public static UUID ofEpochMillis(long epochMilli, long seq)
这可能是 JDK 26 里最容易被低估的一个方法。要理解它的价值,得先讲清楚为什么 UUIDv4 做数据库主键是灾难。
InnoDB 的主键是聚簇索引,数据按主键顺序物理存放在 B+ 树里。
- 自增 ID:新记录永远插在树的最右侧。页写满了就开新页,顺序追加,缓冲池命中率高,页填充率接近 100%。
- UUIDv4:完全随机。每次插入落在树的随机位置,导致:
- 页分裂:目标页满了就得裂成两个,一次插入变成多次页写;
- 页填充率暴跌:分裂后两个页各占一半,磁盘占用几乎翻倍;
- 缓冲池污染:随机访问导致热点分散,命中率下降;
- 二级索引跟着膨胀:InnoDB 的二级索引叶子节点存的是主键值,16 字节的 UUID 比 8 字节的 bigint 大一倍。
UUIDv7 的解法:把毫秒时间戳放在最高 48 位。
UUIDv7 布局(RFC 9562):
bit 0..47 unix_ts_ms —— Unix 毫秒时间戳(48 位,单调递增)
bit 48..51 ver = 7 —— 版本号
bit 52..63 rand_a —— 12 位,可用作同毫秒内的递增序号
bit 64..65 var —— 变体位
bit 66..127 rand_b —— 62 位随机
时间戳在前 → 按字典序排序 ≈ 按时间排序 → 插入位置单调右移 → 页分裂几乎消失,同时保留 UUID 的分布式无中心生成能力。
实际用法:
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
/**
* 单调递增的 UUIDv7 生成器。
* 同一毫秒内用 seq 保证单调,跨毫秒自动重置。
*/
public final class UuidV7Generator {
private final AtomicLong lastState = new AtomicLong(); // 高 22 位存毫秒偏移,低位存 seq
public UUID next() {
long now = System.currentTimeMillis();
while (true) {
long prev = lastState.get();
long prevMillis = prev >>> 20;
long prevSeq = prev & 0xFFFFF;
long millis, seq;
if (now > prevMillis) {
millis = now;
seq = 0;
} else {
// 同一毫秒(或时钟回拨):序号递增
millis = prevMillis;
seq = prevSeq + 1;
if (seq > 0xFFFFF) { // 同毫秒内超过 100 万个,等下一毫秒
Thread.onSpinWait();
now = System.currentTimeMillis();
continue;
}
}
long state = (millis << 20) | seq;
if (lastState.compareAndSet(prev, state)) {
return UUID.ofEpochMillis(millis, seq); // ← JDK 26 新方法
}
}
}
}
配套的数据库建表:
-- [×] 不要这样:CHAR(36) 存 UUID 字符串,36 字节,比 bigint 大 4.5 倍
CREATE TABLE orders (
id CHAR(36) PRIMARY KEY,
...
);
-- [√] 用 BINARY(16),只占 16 字节,且保持字节序 = 时间序
CREATE TABLE orders (
id BINARY(16) PRIMARY KEY,
tenant_id BIGINT NOT NULL,
created_at DATETIME(3) NOT NULL,
INDEX idx_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB;
// Java 侧的 UUID ↔ BINARY(16) 转换
static byte[] toBytes(UUID uuid) {
return java.nio.ByteBuffer.allocate(16)
.putLong(uuid.getMostSignificantBits())
.putLong(uuid.getLeastSignificantBits())
.array();
}
static UUID fromBytes(byte[] bytes) {
var buf = java.nio.ByteBuffer.wrap(bytes);
return new UUID(buf.getLong(), buf.getLong());
}
从 UUIDv4 换到 UUIDv7,在写入量大的表上,插入吞吐提升往往是几倍量级,磁盘占用能降三成以上。 这个改动的投入产出比,比 JDK 26 里任何一个 JEP 都高。
8.2 Process 实现 AutoCloseable(JDK-8364361)
// 以前:忘了 destroy 就是进程泄漏
Process p = new ProcessBuilder("ffmpeg", "-i", "in.mp4", "out.webm").start();
try {
p.waitFor();
} finally {
p.destroy(); // 忘写这行 = 泄漏
}
// JDK 26:try-with-resources,close() 等价于 destroy()
try (Process p = new ProcessBuilder("ffmpeg", "-i", "in.mp4", "out.webm").start()) {
int exit = p.waitFor();
if (exit != 0) throw new IOException("ffmpeg failed: " + exit);
} // 自动 destroy
做视频处理、调用外部 CLI 工具的服务,这个改动能直接消掉一类线上事故。
8.3 Instant.plusSaturating()(JDK-8368856)
// 以前:接近边界时抛 DateTimeException / ArithmeticException
Instant far = Instant.MAX.minus(Duration.ofDays(1));
Instant boom = far.plus(Duration.ofDays(2)); // [!] 抛异常
// JDK 26:饱和加法,溢出时返回 Instant.MIN / Instant.MAX
Instant safe = far.plusSaturating(Duration.ofDays(2)); // → Instant.MAX
看着是边角料,但**「永不过期」这种业务语义,以前只能用 null 或者一个魔法日期(9999-12-31)表示**,处处要判空。现在可以统一用 Instant.MAX 加饱和运算,代码干净很多。
8.4 其余值得记一笔的
// Comparator.min / max(JDK-8356995,JetBrains 贡献)
Comparator<Order> byAmount = Comparator.comparing(Order::amount);
Order cheaper = byAmount.min(a, b); // 不用再写 byAmount.compare(a,b) <= 0 ? a : b
Order pricier = byAmount.max(a, b);
// Duration.MIN / MAX(JDK-8366829)
if (timeout.equals(Duration.MAX)) { /* 视为无超时 */ }
// HTTP Client 支持上传文件片段(JDK-8329829)
// 断点续传、分片上传终于不用先把内容读进内存
try (FileChannel fc = FileChannel.open(Path.of("big.bin"))) {
HttpRequest req = HttpRequest.newBuilder(uri)
.PUT(HttpRequest.BodyPublishers.ofFileChannel(fc, 1024 * 1024, 8 * 1024 * 1024))
.build(); // 上传第 1MB 开始的 8MB
}
// jdk.crypto.disabledAlgorithms 安全属性(JDK-8244336)
// 以前只能在 TLS 层禁算法,现在能在 JCE/JCA 层全局禁
还有两个面向未来的安全项:
- ML-DSA 签名 JAR(JDK-8349732):后量子数字签名进入
jarsigner。供应链安全合规要求越来越严的今天,这是提前布局。jarsigner -keystore ks -sigalg ML-DSA-65 -signedjar signed.jar test.jar mldsa - HPKE 混合公钥加密(JDK-8325448):RFC 9180 落地,通过现有
CipherAPI 配合HPKEParameterSpec使用,把 KEM + KDF + AEAD 三类算法组合起来。
九、升级实战:从 JDK 21/25 到 26 的检查清单
按风险从高到低排列,逐条过。
第 1 步:扫 final 字段修改(最高优先级)
# 用 debug 模式跑全量测试,抓所有违规堆栈
mvn test -DargLine="--illegal-final-field-mutation=debug" 2>&1 | tee /tmp/final-scan.log
# 提取非 JDK 的调用点
grep -A 20 -i "final field" /tmp/final-scan.log \
| grep -E '^\s+at ' \
| grep -vE 'at (java|jdk|sun)\.' \
| sed -E 's/^\s+at //; s/\(.*\)$//' \
| sort -u
按输出结果分三类处理:
| 来源 | 处理方式 |
|---|---|
| 自己的测试工具类 | 改用构造器注入或 @VisibleForTesting setter,直接改掉 |
| 第三方库(Mockito/Kryo/…) | 升级到已适配 JDK 26 的版本;升不了则加模块白名单 |
| 序列化框架 | 确认已迁移到 ReflectionFactory 路径 |
第 2 步:确认 Applet 引用清零
# 源码扫描
grep -rn "java\.applet\|javax\.swing\.JApplet\|AppletInitializer" --include="*.java" src/
# 依赖 jar 里也扫一遍(有些老库会引用)
find ~/.m2 -name "*.jar" -exec sh -c \
'unzip -l "$1" 2>/dev/null | grep -q "java/applet" && echo "$1"' _ {} \;
第 3 步:检查所有依赖的字节码增强/Agent
| 组件 | 检查点 |
|---|---|
| Lombok | 必须升到支持 JDK 26 的版本,否则编译直接炸 |
| MapStruct / 注解处理器 | 确认 --release 26 下能跑 |
| ByteBuddy / ASM / CGLIB | class 文件版本号 70(JDK 26),老版本 ASM 会抛 UnsupportedOperationException |
| SkyWalking / Arthas / 各类 APM Agent | Agent 用了大量深度反射,重点验证 |
| Mockito / PowerMock | 与 final 字段修改直接相关 |
这一步是最常见的升级翻车点。 不是 JDK 不兼容,是你的字节码工具链跟不上 class 文件版本。
第 4 步:GC 与 AOT 缓存配置
# JEP 522 是透明的,不用改配置。但要重新做一次基准,别把改善当成噪声
java -Xlog:gc*:file=gc-baseline.log -XX:+UseG1GC -jar app.jar
# 如果在用 ZGC,现在可以启用 AOT 缓存了
java -XX:AOTCacheOutput=app.aot -XX:+AOTStreamableObjects -jar app.jar --train
java -XX:+UseZGC -XX:AOTCache=app.aot -jar app.jar
第 5 步:预览特性策略
LazyConstant(JEP 526)、结构化并发(JEP 525)、原始类型模式(JEP 530)、PEM API(JEP 524)都是预览特性,要 --enable-preview。
生产环境用预览特性的三条铁律:
- 预览特性编译的 class 文件带版本锁——用 JDK 26
--enable-preview编出来的 class,在 JDK 27 上运行不了,必须重新编译。这意味着你的构建和运行环境版本必须严格同步。 - API 可能在下个版本改。
StableValue→LazyConstant就是活生生的例子:类名、包名、方法名全变了。 - 只在能快速回滚的模块里用。核心链路等转正。
第 6 步:完整的升级验证脚本
#!/usr/bin/env bash
# jdk26-upgrade-check.sh —— 一键升级体检
set -euo pipefail
java -version 2>&1 | grep -q '"26' || { echo "[×] 不是 JDK 26"; exit 1; }
echo "[1] Applet 引用扫描"
grep -rn "java\.applet\|javax\.swing\.JApplet" --include="*.java" src/ 2>/dev/null \
&& { echo "[×] 发现 Applet API 引用,JDK 26 已移除"; exit 1; } || echo "[√] 无引用"
echo "[2] final 字段修改扫描"
mvn -q test -DargLine="--illegal-final-field-mutation=debug" 2>&1 | tee /tmp/fm.log || true
V=$(grep -ci "final field" /tmp/fm.log || true); echo "违规次数: $V"
[ "$V" -gt "${FM_BASELINE:-0}" ] && echo "[注意] 超过基线,需要治理"
echo "[3] class 文件版本(JDK 26 应为 70)"
mvn -q clean compile
find target/classes -name "*.class" | head -1 | xargs javap -v 2>/dev/null | grep "major version"
echo "[4] AOT 缓存跨 GC 烟囱测试"
java -XX:AOTCacheOutput=/tmp/check.aot -XX:+AOTStreamableObjects -version
java -XX:+UseZGC -XX:AOTCache=/tmp/check.aot -version && echo "[√] 跨 GC 的 AOT 缓存可用"
echo "JDK 26 升级检查完成"
十、十条踩坑清单
--add-opens消不掉 final 字段修改的警告。 这是两个正交的维度:--add-opens管模块可访问性,--enable-final-field-mutation管字段可变性。别在启动脚本里堆--add-opens然后以为万事大吉。--illegal-final-field-mutation=allow是止血带不是解药。 它让你现在不痛,但下个版本默认值一变(大概率是 JDK 27 或 28 改成deny),痛感会加倍返还。用debug定位、用白名单过渡、用重构收敛。LazyConstant引用忘了写final,等于白用。 常量折叠的第一层就是「LazyConstant引用本身是常量」。非final字段每次都要重读,后面的连锁优化全断。LazyConstant的计算函数返回null直接 NPE。 这是从StableValue迁移过来的行为变更。可能为空的值用Optional包,或者用requireNonNullElse给默认值。LazyConstant计算函数抛异常不缓存,下次会重试。 别在里面做无退避的网络 IO,否则失败时变成重试风暴。构造和连接分开。AOT 缓存的训练负载必须有代表性。 跑一个
--version就退出的「训练」,缓存里什么都没有。要用真实流量回放,覆盖主要接口。HTTP/3 在请求级别设置
HTTP_3是 P99 杀手。 服务端不支持时,每个请求都要吃一次 UDP 探测超时。生产环境用 ALT-SVC 发现 + 熔断降级。HTTP/3 只支持 SunJSSE。 在用第三方 SSL provider(BouncyCastle、国密)的项目,HTTP/3 直接不可用。这个限制在 JEP 517 文档里写得很清楚,但很容易被忽略。
内网 RPC 别上 HTTP/3。 QUIC 是用户态协议,缺少 TCP 那些硬件卸载。低丢包的内网链路上,HTTP/3 的 CPU 开销可能让它比 HTTP/2 更慢。它是为公网和移动网络设计的。
预览特性编译的 class 文件锁死版本。 JDK 26
--enable-preview编出来的 class 在 JDK 27 上跑不了,必须重编。CI 的编译 JDK 和运行 JDK 版本必须严格一致,否则你会在生产环境收到UnsupportedClassVersionError。
十一、总结:Java 26 到底值不值得升
先说结论,按角色分:
| 你是谁 | 建议 |
|---|---|
| 跑在 JDK 21 LTS 上的稳态业务 | 不急。但现在就开始扫 final 字段违规,JDK 27 LTS 来的时候你会感谢自己 |
| 跑在 JDK 25 上、G1 + 引用写密集 | 值得升。JEP 522 是白送的 5–15% 吞吐 |
| ZGC 用户,且在意冷启动 | 强烈建议。JEP 516 解决了「低延迟 vs 快启动」的二选一 |
| 移动端/公网 API 网关 | 值得试。HTTP/3 在弱网环境的改善是实打实的 |
| 数据库主键还在用 UUIDv4 | 单为 UUID.ofEpochMillis() 就值 |
| 库/框架维护者 | 必须升,且现在就适配 JEP 500。你的用户会先撞上这堵墙 |
三条主线的收敛点
回到开头那个判断:Java 26 是「完整性即性能」的收网之年。
十年前 Java 平台的隐含契约是「什么都能反射改,出事自己负责」。这个契约给了生态巨大的灵活性——Spring、Hibernate、Mockito 这些框架能存在,很大程度上靠的就是这份灵活性。
代价是整个平台的优化天花板被按死了。JVM 不能相信任何 final,不能相信任何封装边界,不能相信任何不变量。它只能做保守优化。
从 JEP 396 到 JEP 500,OpenJDK 用了十年,一点一点把这些口子焊上。每焊一个都被骂一次。但焊到今天,回报开始兑现了:
final可信 →LazyConstant能被三层折叠(JEP 526)- 对象布局可信 → AOT 缓存能预计算并跨 GC 复用(JEP 516)
- 内存模型可简化 → 写屏障砍掉四分之三(JEP 522)
- 类型可精确 → 原始类型进入模式匹配(JEP 530)
这不是四个独立特性,是同一个方向上的四步。
往前看:JDK 27 与两个 Project
- JDK 27(2027 年 9 月,LTS):这是下一个真正需要认真对待的版本。JEP 500 的下一步大概率会落在这里——从「警告」升级到「默认拒绝」。现在开始治理 final 字段违规,就是在给 JDK 27 铺路。
- Project Valhalla:Value Objects 一旦落地,Vector API 立刻转正,
Optional、record、各种包装类型的装箱开销消失,Java 的数值计算性能会有一次跃升。这是过去十年 Java 最大的一块拼图。 - Project Leyden:AOT 缓存已经从「类加载」走到「对象缓存」,下一步是 AOT 编译代码缓存——把 JIT 编译好的机器码也存进去。真做成了,Java 的启动性能会直接对标 native-image,同时保留完整的动态能力。
Java 从来不是靠单个版本的爆点前进的。它靠的是每六个月推进一小步,然后在某个时刻,你回头发现这些小步已经拼成了一次范式转移。
Java 26 就是那种「单看平淡,连起来看是转折点」的版本。
它给你的最实际的建议只有一条:今天就去跑一遍 --illegal-final-field-mutation=debug,看看你的代码库里到底藏了多少个「JVM 不敢优化」的地方。
参考资料
- OpenJDK JDK 26 项目页:GA 日期与完整 JEP 列表
- JEP 原文(
https://openjdk.org/jeps/<编号>):500 final mean final、504 移除 Applet、516 AOT 对象缓存、517 HTTP/3、522 G1 写屏障、524 PEM、525 结构化并发、526 Lazy Constants、529 Vector API、530 原始类型模式 - JDK 26 Release Notes:小 API 变更,文中 JDK-xxxxxxx 编号可在 bugs.openjdk.org 逐条查证