编程 Java 26 深度拆解:当 JVM 决定「final 从此真的是 final」——从双卡表写屏障到惰性常量,一次为「可信性即性能」做的运行时手术

2026-08-09 05:53:32 +0800 CST views 9

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 敢把值当常量折进机器码
启动时间516AOT 对象缓存脱离 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 字段当常量折叠(除了 recordStringInteger 这类被特殊标记为 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 敢信任它,因为 recordfinal 字段在规范上就不允许反射修改。

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

我的建议是别用 allowallow 会让你在下一个版本被打个措手不及。正确姿势是:

  1. 开发/测试环境用 debug 把所有违规点的堆栈捞出来;
  2. 生产环境短期用 --enable-final-field-mutation 给具体模块开白名单(有明确的收敛目标);
  3. CI 里对自己的代码deny 卡死,防止新增违规。

1.4 谁会中招:一张受影响清单

这是升级前最该做的功课。按「改 final 字段」这个行为特征,高危区域如下:

类别典型代表为什么会改 final
Mock / 测试框架Mockito(@InjectMocks 注入 final 字段)、EasyMock、PowerMock往被测对象里塞替身
依赖注入早期 Spring 的字段注入、Guice、CDI 实现构造后回填字段
序列化 / 反序列化Kryo、FST、Jackson(ObjectMapper 走字段路径时)、Gson反序列化时无构造器地填字段
ORMHibernate 的字节码增强 / 代理回填延迟加载后回填关联字段
配置绑定各类 @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;
    }
}

这段代码的问题有三层:

  1. 写起来烦,而且很容易写错(漏掉 volatile、漏掉局部变量缓存)。
  2. volatile 读永远无法被折叠。 JIT 看到 volatile 就知道「这个值任何时候都可能变」,每次访问都必须真读内存,还带 acquire 语义的顺序约束。你为了「只初始化一次」的正确性,付出了「永远不能优化」的代价。
  3. 它是个字段,不是常量。 上一节讲的死代码消除、强度削弱,一律享受不到。

Holder idiom(静态内部类)能解决第 2 点,但只适用于静态场景,且每个延迟初始化的对象都要写一个内部类。

2.2 从 StableValue 到 LazyConstant:不只是改名

JDK 25 的 JEP 502 引入了 StableValue。JDK 26 的 JEP 526 做了一次重新设计并改名为 LazyConstant。变化不是刷漆:

维度JDK 25 StableValueJDK 26 LazyConstant
类名/包java.lang.StableValuejava.lang.LazyConstant
取值orElseSet(supplier) 等多个低阶方法统一 get()
聚合StableValue.list/mapList.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 内部字段已经非默认值,于是:

  1. CONFIG 本身是 static final → 折叠成常量对象;
  2. 内部 @Stable 字段已初始化 → 折叠成具体的 Config 实例;
  3. Config.debug 如果也是 trusted final → 继续折叠成 true/false
  4. 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 516AOT 对象缓存支持所有 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 格式选型决策表

训练/生产环境推荐格式参数
用 ZGCGC 无关(流式)-XX:+AOTStreamableObjects
堆 > 32GB(压缩 oops 关闭)GC 无关(流式)-XX:+AOTStreamableObjects
显式 -XX:-UseCompressedOopsGC 无关(流式)-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 要做的事包括:

  1. 判断新老引用是否跨 region(同 region 内无需记录);
  2. 判断目标是否为 null;
  3. 判断卡是否已经脏(避免重复入队);
  4. 标脏卡表;
  5. 插入内存屏障(StoreLoad fence),保证标脏对 refinement 线程可见;
  6. 把卡加入线程本地的 dirty card queue;
  7. 队列满了要交给 refinement 线程处理。

第 5 步是最贵的。x86 上 StoreLoad 需要 lock addlmfence,一条就是几十个周期,而且会阻断流水线。

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.setMap.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 生产环境六个坑

  1. UDP 443 被防火墙拦。 大量企业网络只放行 TCP 443。HTTP/3 全走 UDP,连接直接失败。上线前先在目标网络环境做连通性验证。
  2. 只支持 SunJSSE。 JEP 517 明确说了:HTTP/3 的首个实现里,除默认的 SunJSSE 之外,其他安全套接字提供者暂时不可用。如果你在用 BouncyCastle 或国密 SSL provider,HTTP/3 直接用不了
  3. Kubernetes Service 默认只转发 TCP。 Service 的 protocol 字段要显式加 UDP 条目,Ingress/LB 也得支持。
  4. UDP 缓冲区太小导致丢包。 Linux 默认的 net.core.rmem_max 对 QUIC 偏小,高吞吐场景要调:sysctl -w net.core.rmem_max=8388608
  5. 抓包工具链要换。 tcpdump 抓得到 UDP 包但解不开 QUIC 加密载荷。要用 Wireshark + SSLKEYLOGFILE 导出密钥才能看明文。
  6. UDP 通常比 TCP 更耗 CPU。 没有 TSO/GSO 硬件卸载加持时,QUIC 的用户态处理开销明显更高。低丢包的内网链路上,HTTP/3 可能比 HTTP/2 慢。内网服务间通信别盲目上 HTTP/3。

结论:HTTP/3 是给公网、移动端、跨境链路用的,不是给内网 RPC 用的。


六、语言层:原始类型模式与结构化并发

6.1 JEP 530:原始类型进入模式匹配(第四次预览)

模式匹配从 JDK 16 的 instanceof 一路走来,一直有个尴尬的缺口:只能匹配引用类型。JEP 530 把 intlongfloatdoubleboolean 等全部纳入。

// ① 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 包(AppletAppletContextAppletStubAudioClip)、java.beans.AppletInitializerjavax.swing.JApplet,以及 java.beans.Beansjavax.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 做第二次预览,新增了对 KeyPairPKCS8EncodedKeySpec 的加解密支持。

这个 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:完全随机。每次插入落在树的随机位置,导致:
    1. 页分裂:目标页满了就得裂成两个,一次插入变成多次页写;
    2. 页填充率暴跌:分裂后两个页各占一半,磁盘占用几乎翻倍;
    3. 缓冲池污染:随机访问导致热点分散,命中率下降;
    4. 二级索引跟着膨胀: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 落地,通过现有 Cipher API 配合 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 / CGLIBclass 文件版本号 70(JDK 26),老版本 ASM 会抛 UnsupportedOperationException
SkyWalking / Arthas / 各类 APM AgentAgent 用了大量深度反射,重点验证
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

生产环境用预览特性的三条铁律:

  1. 预览特性编译的 class 文件带版本锁——用 JDK 26 --enable-preview 编出来的 class,在 JDK 27 上运行不了,必须重新编译。这意味着你的构建和运行环境版本必须严格同步。
  2. API 可能在下个版本改StableValueLazyConstant 就是活生生的例子:类名、包名、方法名全变了。
  3. 只在能快速回滚的模块里用。核心链路等转正。

第 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 升级检查完成"

十、十条踩坑清单

  1. --add-opens 消不掉 final 字段修改的警告。 这是两个正交的维度:--add-opens 管模块可访问性,--enable-final-field-mutation 管字段可变性。别在启动脚本里堆 --add-opens 然后以为万事大吉。

  2. --illegal-final-field-mutation=allow 是止血带不是解药。 它让你现在不痛,但下个版本默认值一变(大概率是 JDK 27 或 28 改成 deny),痛感会加倍返还。用 debug 定位、用白名单过渡、用重构收敛。

  3. LazyConstant 引用忘了写 final,等于白用。 常量折叠的第一层就是「LazyConstant 引用本身是常量」。非 final 字段每次都要重读,后面的连锁优化全断。

  4. LazyConstant 的计算函数返回 null 直接 NPE。 这是从 StableValue 迁移过来的行为变更。可能为空的值用 Optional 包,或者用 requireNonNullElse 给默认值。

  5. LazyConstant 计算函数抛异常不缓存,下次会重试。 别在里面做无退避的网络 IO,否则失败时变成重试风暴。构造和连接分开。

  6. AOT 缓存的训练负载必须有代表性。 跑一个 --version 就退出的「训练」,缓存里什么都没有。要用真实流量回放,覆盖主要接口。

  7. HTTP/3 在请求级别设置 HTTP_3 是 P99 杀手。 服务端不支持时,每个请求都要吃一次 UDP 探测超时。生产环境用 ALT-SVC 发现 + 熔断降级。

  8. HTTP/3 只支持 SunJSSE。 在用第三方 SSL provider(BouncyCastle、国密)的项目,HTTP/3 直接不可用。这个限制在 JEP 517 文档里写得很清楚,但很容易被忽略。

  9. 内网 RPC 别上 HTTP/3。 QUIC 是用户态协议,缺少 TCP 那些硬件卸载。低丢包的内网链路上,HTTP/3 的 CPU 开销可能让它比 HTTP/2 更慢。它是为公网和移动网络设计的。

  10. 预览特性编译的 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 立刻转正,Optionalrecord、各种包装类型的装箱开销消失,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 逐条查证

推荐文章

Go 接口:从入门到精通
2024-11-18 07:10:00 +0800 CST
Python上下文管理器:with语句
2024-11-19 06:25:31 +0800 CST
程序员茄子在线接单