JDK 8 · 17 · 21 · 25 · LTS · JEP · AMBER · LOOM · G1 / ZGC · 模块与强封装

从 8 到 25,Java 改了什么、为什么改

8、17、21、25 不是四个孤立的版本,而是一条线上的四个长期支持(LTS)站点:17 包含了 9 到 16 的全部改动,21 包含 18 到 20,25 又包含 22 到 24。这些改动可以归成四条主线:写代码更省事(record、模式匹配)、并发更便宜(虚拟线程)、GC 停顿更短(G1、ZGC)、平台更好维护(模块和强封装,删掉旧东西)。前三条是升级的好处,第四条是升级时踩坑的来源。21 到 25 这一段,主要是把虚拟线程补完整、让启动和内存更省。25 之后,27 是 2026 年 9 月的普通版本,下一个长期支持版计划是 2027 年 9 月的 29(③ 末尾)。

假设场景团队的老服务还跑在 JDK 8 + Spring Boot 2 上,新项目要上 Spring Boot 3,它最低要 JDK 17;新同事写的代码里出现了 record、var、switch 里的 ->;面试也常问“8 到 17 有什么变化”;2025 年 9 月又出了新的长期支持版 25。本页按你的要求没有在本机运行任何代码,版本和行为都按 JEP、OpenJDK 源码和发行商文档核对,代码示例标了“未在本机运行”。

①为什么学:四个版本,跨了十一年

8 是 2014 年的,25 是 2025 年的。中间 Java 换了发布节奏,每半年出一个版本,每两三年挑一个做长期支持。

2014201620182020202220242026 9121416192328 8 17 21 25 11 27最新,非 LTS 29计划中的下一个 LTS 8 → 17:隔了 9 个版本、7 年半 17 → 21:2 年 21 → 25:2 年 旧节奏:8 到 9 等了 3 年半 新节奏:从 10 起每年 3 月、9 月各一个版本 长期支持版:11、17、21、25(17 起每两年一个),下一个计划是 29
琥珀色是本页重点讲的四个长期支持版。灰色大圆点 11 也是长期支持版,是很多公司从 8 出来后的过渡站。27 是目前最新的版本,但不是长期支持版;虚线是还没发布的 28 和计划中的下一个长期支持版 29(③ 末尾)。

工作要用

Spring Boot 3 和 4 最低都要 JDK 17(Boot 4.1.2 的 class 文件版本是 61,即 17)。官方文档写明 Boot 3.5 支持到 25、Boot 4.1 支持到 27。

看得懂新代码

record、sealed、switch 模式匹配、文本块、var、虚拟线程,在 8 上全都不存在;25 又多了 ScopedValue、void main()。

升级不出事故

升级时 JVM 起不来、反射报错、类找不到,大多来自“平台治理”这条线。知道它为什么改,就知道去哪改。

②老办法:留在 JDK 8,靠第三方库补

很多团队就是这么过来的:语言缺什么,就引一个库补上。能做,但有几样补不上。

JDK 8(2014) Lombok @Data≈ record Guava ImmutableList≈ List.of Apache HttpClient≈ java.net.http WebFlux / 回调式异步≈ 虚拟线程,但代码要全改写 CMS + 一堆调参≈ 低停顿 GC,碎片要自己扛 一堆 if-else + instanceof + 强转 ≈ 模式匹配 补不上的 ✕ Spring Boot 3 / 4:最低要 JDK 17 ✕ 虚拟线程:要改 JVM 和 JDK 的线程实现 ✕ 分代 ZGC:亚毫秒停顿的 GC ✕ sealed + switch:编译器帮你查漏掉的分支 ✕ 压缩字符串、更好的 G1:白拿的内存和吞吐 ✕ 8 的更新只回移植安全和关键修复
疼在哪

库能补的是 API 和语法糖;补不上的是要改编译器、JVM、类库内部的东西:编译器检查、线程实现、GC、字符串的内存布局。还有一样最实际:框架生态已经往前走了。

所以升级的精华:拿到只有平台自己能做的改进,同时还清“平台治理”这条线欠下的账。

典型误用以为升级就是换个 JDK 安装包、改一下 JAVA_HOME。8 到 17 中间有一整段“删旧东西、关后门”的改动(模块强封装、删 Java EE 模块、删 CMS 和老 GC 参数),不知道它们为什么删,就只能见一个错改一个(⑥ 第一圈)。

③选谁:长期支持版怎么挑

生产只用长期支持版(LTS),中间版本只有半年的更新。LTS 不是语言规范里的概念,是各家发行商(Oracle、Eclipse Temurin、Amazon Corretto、阿里 Dragonwell 等)承诺给这个版本出几年补丁。

版本发布Temurin 至少支持到代表性的变化框架
82014-032030-12lambda、Stream、java.time、Optional、MetaspaceSpring Boot 2.x 的大本营
112018-092027-10模块化后的第一个 LTS、HttpClient、删 Java EE 模块多数公司当过渡站
172021-092027-10record、sealed、文本块、switch 表达式、默认强封装Spring Boot 3 / 4 的最低要求
212023-092029-12虚拟线程、模式匹配 switch、record 模式、分代 ZGCBoot 3.2 起可一键开虚拟线程
252025-092031-09synchronized 不再钉住虚拟线程(24)、ScopedValue、AOT 缓存、紧凑对象头、Stream Gatherers(24)、FFM(22)Boot 3.5 起官方支持;注意 23 起注解处理默认不跑
272026-09非 LTS,约半年G1 处处默认、紧凑对象头默认开(见下)Boot 4.1 官方支持到 27
292027-09(计划)计划中的 LTS特性列表还没公布—

LTS 和发布时间以 Oracle 的支持路线图为准(27 不是 LTS,下一个计划是 29);支持年限来自 Adoptium 官网的 Temurin 支持表,其他发行商的年限各不相同;Oracle 自家 JDK 8 的商用免费更新早在 2019 年就停了,生产上多用上面这些 OpenJDK 发行版。

选择规则:新项目优先 25(支持期最长,虚拟线程的钉住问题也解决了),某个关键依赖还不支持 25 就用 21;要用 Spring Boot 3 / 4 的至少 17;老项目卡在 8 的,跳过 11 直接规划到 17、21 或 25,因为 9 到 17 的坑反正都要过一遍。本页学 8 → 17 → 21 → 25 这一段改了什么、为什么,最后简单看一眼 25 之后的 27 和 29。

25 之后:27 和 29

27:最新版本,但不是 LTS

2026 年 9 月 15 日发布,只维护到 28 出来(约半年),生产一般不用。它和 26 加起来,值得知道的是这几样:

  • G1 在任何环境都是默认 GC(27,JEP 523):以前 1 个 CPU 或内存小于 1792 MB 时会自动选 Serial,小容器里尤其常见。
  • 紧凑对象头默认打开(27,JEP 534):25 里要手动开,27 起对象头默认就是 8 字节。
  • JDK 自带的 HttpClient 支持 HTTP/3(26,JEP 517);TLS 1.3 加了抗量子的混合密钥交换(27)。
  • 反射改 final 字段会打警告(26,JEP 500),以后会直接报错:强封装的下一步,靠反射改 final 字段的序列化、注入框架要注意。
  • 结构化并发到 27 还是预览(第 7 次),还没转正。

29:计划中的下一个 LTS

Oracle 的支持路线图把 29 标为下一个长期支持版,计划 2027 年 9 月发布,并注明 27~29 的 LTS 安排和日期都可能调整。目前 28 还在开发中,29 的特性列表还没有公布。

能确定的只有一点:29 会包含 26~28 的全部改动。上面 27 这几样到时都会进来;现在还在预览的结构化并发、Lazy Constants(原 Stable Values)、原始类型模式能不能在 29 转正,要等它的特性列表定下来,本页不做猜测。

④原理:一种发布方式,四条主线

先看 Java 现在怎么出版本(这决定了 8、17、21、25 的关系),再看 8 到 25 的改动分成哪四条线、每样新东西是为了解决什么问题,最后看几处“为什么非这样设计不可”,其中机制 5 专讲 21 到 25 这一段。

先知道这些

JDK、JRE、JVM

JVM 执行字节码;JRE = JVM + 类库;JDK = JRE + javac 等工具。9 起官方不再单独发 JRE,用 jlink 按需裁一个运行时。

JEP

JDK Enhancement Proposal,每个新特性的提案和编号,比如 JEP 444 是虚拟线程。本页的“哪个版本”都按 JEP 写的目标版本。

预览特性

先放进版本里让大家试,编译和运行都要加 --enable-preview,下个版本可能改、也可能删。转正之后才算数。

LTS

发行商对某个版本的长期补丁承诺,不是语言特性。11、17、21 和 8 在语言上没有“特殊”,只是补丁出得久。

地基8 之前:5、6、7 只记这几样今天还在起作用的

(堆、栈、元空间这些“内存放在哪”的问题见 JVM 内存结构,和这里说的“内存模型”是两回事。)6、7 不单独做对比:主流的免费发行版早就不再给它们出更新,Spring Boot 2.0 起最低就要 8。但 8 以后的很多东西是盖在下面这几样上的,Java 内存模型其实是 5 定下的。

JDK 5(2004)

  • Java 内存模型(JSR 133):规定多线程下一个线程的写,另一个线程什么时候、按什么顺序能看到(happens-before),volatile、synchronized、final 的语义都由它定义。AQS、ConcurrentHashMap、虚拟线程都建立在它上面;9 的 VarHandle 又补了更细的内存顺序。详见 Java 内存模型。
  • java.util.concurrent(JSR 166):线程池、Lock、原子类、并发集合。见 AQS、线程池。
  • 泛型、注解、枚举、自动装箱:Spring、MyBatis 的注解驱动都从这来。

JDK 6(2006)

  • synchronized 的锁优化:HotSpot 加入偏向锁和自适应自旋,和已有的轻量级锁一起,构成面试常问的“锁升级”,让没有竞争的 synchronized 几乎不花钱。偏向锁在 15 默认关闭,18 起参数作废,19 起再写这个参数 JVM 就起不来(⑥ 坑 1)。
  • JDBC 4.0:数据库驱动靠 ServiceLoader 自动注册,不用再写 Class.forName。
  • 脚本引擎 API(JSR 223):后来的 Nashorn 就挂在它下面,15 被删。

JDK 7(2011)

  • invokedynamic(JSR 292):把“调用哪个方法”推迟到运行时再决定的字节码指令。8 的 lambda 就是靠它实现的,不再为每个 lambda 生成一个匿名内部类的 class;9 的字符串拼接也改用了它。
  • Fork/Join:带工作窃取的 ForkJoinPool。8 的 parallelStream、CompletableFuture 的默认线程池、21 虚拟线程的调度器,用的都是它。
  • try-with-resources、<> 菱形、multi-catch、switch 字符串;NIO.2 的 Path、Files。字符串常量池从永久代挪到堆,为 8 删永久代铺了路。

机制 1发布方式:从“等大特性”到“半年一班车”8、17、21、25 的关系由它决定

以前:版本等最大的那个特性到 JDK 9 为止

82014.3 92017.9 模块系统(一再延期) 早就做好的小特性,只能干等 一个大特性拖住整个版本:三年半

现在:每半年发一班车从 JDK 10 起

3 月9 月3 月9 月3 月 预览 再预览 转正 做好就上车,没做好等下一班 每两年挑一班做长期支持:17、21、25

决定这个设计的约束:大特性的完成时间没法预测。绑在一起发,小改进跟着等;拆成定期发车,每个特性单独上车,语言新语法还能先以“预览”身份收反馈。后果有两个:一是 17、21、25 只是其中几班车,17 = 8 + 9~16 所有班车的内容,21 = 17 + 18~20,25 = 21 + 22~24;二是不少特性在中间版本里已经出现过预览,到 LTS 才转正(record 14、15 预览,16 转正)。

机制 28 到 25 的四条主线点在哪一列,就是在哪个版本转正

8910111213141516171819202122232425 语言Amber类库 API并发LoomGC · 性能平台治理模块 · 删旧 lambda · Stream var switch 表达式 文本块 record sealed 模式匹配 switch 未命名变量 _ 简化 main java.time · Optional List.of HttpClient Stream.toList SequencedCollection FFM Gatherers CompletableFuture 虚拟线程 不再钉住 ScopedValue 19、20 预览 Metaspace G1 默认 · 压缩字符串 容器感知 默认 CDS 删 CMS ZGC 转正 分代 ZGC AOT 缓存 紧凑对象头 模块系统 删 Java EE 模块 删 Nashorn 默认强封装 强封装 默认 UTF-8 agent 警告 注解处理默认关 禁用 SecurityManager
琥珀色的四列是 8、17、21、25,灰色是 11;虚线圆圈是预览。前三条泳道是“得到”,最后一条是“要还的账”:它让 JDK 内部能放心重构,代价是升级时依赖 JDK 内部的老代码会坏。容器感知最早在 10,后来回移植到了 8u191。

每样新东西:是什么、为什么加、怎么用

每行是“特性 · 版本 · 解决了什么”,点开看概念、以前的痛点和一段示例。示例未在本机运行,写法按 JEP 和 JDK API 文档核对。
语言(Project Amber:让常见写法更短、让编译器多帮你检查)
lambda、Stream8行为可以当参数传,集合操作能链式写,还能 parallelStream
是什么
lambda 是一段能当参数传的代码;Stream 是对一批数据的流水线操作:过滤、转换、排序、汇总,一步接一步写下去。
以前
传个行为要写匿名内部类;集合处理全靠手写循环
List<String> adults = users.stream()
    .filter(u -> u.getAge() >= 18)      // lambda:判断条件当参数传进去
    .map(User::getName)                 // 方法引用,等价于 u -> u.getName()
    .sorted()
    .collect(Collectors.toList());
var10局部变量类型推断。只限局部变量,字段和方法签名仍要写明,API 的可读性不受影响
是什么
让编译器按等号右边推断局部变量的类型。变量仍然是静态类型,只是不用你手写。
以前
Map<String, List<Order>> m = new HashMap<>() 类型写两遍
var map = new HashMap<String, List<Order>>();   // 类型就是 HashMap<String, List<Order>>
for (var e : map.entrySet()) { ... }
// var x;           编译不过:必须有初始值
// var n = null;    编译不过:推断不出类型
// 字段、方法参数、返回值都不能用 var
有帮助的 NPE14异常消息直接写出是哪个表达式为 null(JEP 358,15 起默认开)
是什么
NullPointerException 的消息里直接写出是哪个表达式为 null,不用再一段段加日志猜。
以前
a.b().c().d() 报 NPE,不知道哪个是 null
String city = order.getUser().getAddress().getCity();
// 以前:java.lang.NullPointerException
// 现在:Cannot invoke "Address.getCity()" because
//       the return value of "User.getAddress()" is null
switch 表达式14case X -> 不穿透,整个 switch 可以当表达式用
是什么
switch 可以当表达式返回一个值;case X -> 只执行这一个分支,不会穿透到下一个。
以前
忘写 break 就穿透到下一个 case;switch 不能返回值
int days = switch (month) {
    case FEBRUARY -> 28;
    case APRIL, JUNE, SEPTEMBER, NOVEMBER -> 30;
    default -> {
        log.debug("31 天");
        yield 31;                  // 分支里写多行时,用 yield 返回值
    }
};
文本块15""" 包起来原样写
是什么
用三个双引号包起来的多行字符串,换行和引号原样保留,公共的缩进会自动去掉。
以前
多行 SQL、JSON 要拼字符串、加 \n 和转义
String sql = """
    SELECT id, name
    FROM orders
    WHERE status = ?
    """;                           // 结束的 """ 放在哪一列,决定去掉多少缩进
String json = """
    {"name": "%s"}
    """.formatted(name);
record16一行声明不可变的数据载体,语言层面的语义,后面的模式匹配能直接解构它
是什么
一种专门装数据的类:声明一行字段,编译器自动生成构造器、访问器、equals、hashCode、toString。字段都是 final,对象不可变。
以前
数据类要手写或靠 Lombok 生成 equals、hashCode、toString
record Point(int x, int y) {
    Point {                                    // 紧凑构造器:只写校验
        if (x < 0) throw new IllegalArgumentException("x < 0");
    }
}
var p = new Point(1, 2);
p.x();                                 // 访问器叫 x(),不是 getX()
p.equals(new Point(1, 2));             // true
instanceof 模式16if (o instanceof A a) 一步到位
是什么
instanceof 判断成功时,顺便声明一个已经转好类型的变量。
以前
if (o instanceof A) { A a = (A) o; … } 判断完还要强转
if (obj instanceof String s && !s.isEmpty()) {
    System.out.println(s.length());    // s 已经是 String,不用强转
}
sealed17列出允许的子类,编译器知道一共就这几种,为穷尽检查打基础
是什么
在类或接口上列出“只允许这几个子类”。别人不能再随便继承,编译器也就知道一共有几种情况。
以前
没法规定“这个接口只有这几种实现”
public sealed interface Shape permits Circle, Square {}
public record Circle(double r) implements Shape {}
public record Square(double side) implements Shape {}
// 子类必须是 final、sealed 或 non-sealed 三者之一;record 默认就是 final
record 模式、switch 模式21switch 直接按类型和结构匹配;配合 sealed,漏掉一种就编译不过
是什么
switch 的 case 可以写类型、可以把 record 拆开取字段、可以加 when 条件;对 sealed 类型,漏掉一种情况就编译不过。
以前
按类型分支要写一串 if-else instanceof,漏一种没人提醒
double area(Shape s) {
    return switch (s) {
        case Circle(double r) -> Math.PI * r * r;     // 拆开 record 直接拿字段
        case Square(double side) -> side * side;
    };                                                // 不用 default
}
String desc = switch (obj) {
    case null -> "空";                                // switch 也能处理 null 了
    case Integer i when i > 0 -> "正整数";
    case Integer i -> "整数";
    default -> "其他";
};
未命名变量 _22用 _ 明说“不用它”
是什么
用下划线 _ 表示“这个变量我不用”,不用再硬起一个名字。
以前
catch (Exception e)、解构时用不到的变量也得起名
try { return Integer.parseInt(s); }
catch (NumberFormatException _) { return 0; }

map.computeIfAbsent(key, _ -> new ArrayList<>());
if (obj instanceof Point(var x, _)) { ... }      // 只关心 x
Markdown 文档注释23/// 开头直接写 Markdown
是什么
javadoc 可以用 /// 开头直接写 Markdown,不用再写 HTML 标签。
以前
javadoc 里要手写 HTML 标签
/// 计算订单总价。
///
/// - 单位是**分**
/// - 优惠见 [Order#discount()]
long total(Order order) { ... }
灵活的构造器25调用 super(…) 之前可以先校验、先算参数
是什么
构造器里,super(…) 或 this(…) 之前可以先写语句,比如校验参数;只是这时还不能使用 this。
以前
super(…) 必须是第一句,参数校验只能塞进静态辅助方法
class PositiveRange extends Range {
    PositiveRange(int lo, int hi) {
        if (lo < 0) throw new IllegalArgumentException("lo < 0");   // 25:先校验
        super(lo, hi);                                                // 再调父类构造器
    }
}
模块导入、紧凑源文件25import module java.base; 一次导入整个模块;直接写 void main(),用 IO.println
是什么
写小程序可以不声明类、不写 static,直接一个 void main();import module 一行导入一个模块导出的所有包。
以前
写个几行的小工具也要 public class、public static void main(String[] args) 和一串 import
// Hello.java,直接 java Hello.java 运行
void main() {
    IO.println(List.of("a", "b"));    // 紧凑源文件自动导入 java.base 模块
}

// 普通类里:
import module java.sql;               // 等于导入 java.sql 模块导出的所有包
类库 API
java.time8不可变、线程安全,日期、时间、时区、时长分开建模
是什么
一套新的日期时间 API:日期、时间、带时区的时间、时长分开建模,对象都不可变、线程安全。
以前
Date 可变、SimpleDateFormat 线程不安全、月份从 0 开始
LocalDate d = LocalDate.of(2026, 11, 7);                  // 月份从 1 开始
LocalDateTime t = LocalDateTime.now().plusDays(3);
static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
String s = t.format(FMT);                                  // 格式器线程安全,可以做常量
Duration cost = Duration.between(start, Instant.now());
Optional8用类型提醒“可能没有”,配合 map、orElse 处理
是什么
一个“可能有值、也可能没有”的盒子,用来做返回值,提醒调用方处理“没有”的情况。
以前
方法返回 null 表示“没有”,调用方忘了判断就 NPE
Optional<User> u = userRepo.findById(id);
String name = u.map(User::getName).orElse("匿名");
User must = u.orElseThrow(() -> new NotFoundException(id));
// 不建议用作字段和方法参数
List.of、Map.of9一行建不可变集合(不允许 null 元素)
是什么
一行创建不可变集合。创建后不能增删,也不允许 null 元素。
以前
Collections.unmodifiableList(Arrays.asList(…))
var ids = List.of(1, 2, 3);           // ids.add(4) 抛 UnsupportedOperationException
var m = Map.of("a", 1, "b", 2);       // 最多 10 对,再多用 Map.ofEntries
var copy = List.copyOf(mutableList);  // 10 起:拿一份不可变副本
// List.of(1, null) 抛 NullPointerException
HttpClient11JDK 自带,支持 HTTP/2、同步和异步
是什么
JDK 自带的 HTTP 客户端,支持 HTTP/2(26 起还支持 HTTP/3)、同步和异步调用。
以前
HttpURLConnection 难用、不支持 HTTP/2,只好引 Apache HttpClient
var client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(2)).build();      // 客户端全局复用一个
var req = HttpRequest.newBuilder(URI.create("https://example.com/api"))
        .timeout(Duration.ofSeconds(3)).GET().build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
client.sendAsync(req, HttpResponse.BodyHandlers.ofString())
      .thenApply(HttpResponse::body);                      // 异步版本
String 新方法11isBlank、strip、repeat、lines 直接用
是什么
String 补了几个天天要用的方法,以前多半靠 Apache Commons 或 Guava。
以前
判空白、去空白、重复、按行拆分都要靠工具类
"  ".isBlank();          // true:只有空白字符
" a ".strip();          // "a":和 trim 不同,能识别全角空格等 Unicode 空白
"ab".repeat(3);         // "ababab"
text.lines().count();   // 按行拆成 Stream
Stream.toList()16更短,返回不可修改的 List
是什么
Stream 直接收集成一个不可修改的 List。
以前
.collect(Collectors.toList())
List<String> names = users.stream().map(User::getName).toList();
// 和 collect(Collectors.toList()) 的区别:返回的 List 不能 add / remove
SequencedCollection21有顺序的集合统一有 getFirst、getLast、reversed
是什么
给“有顺序”的集合(List、Deque、LinkedHashSet、LinkedHashMap 等)一套统一的首尾操作。
以前
取最后一个元素:List 要 get(size()-1),Deque 有 getLast,LinkedHashSet 没办法
List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
list.getFirst();        // 1
list.getLast();         // 3,以前要 list.get(list.size() - 1)
list.reversed();        // 反序视图 [3, 2, 1]
linkedHashSet.getLast();                 // 以前只能遍历到最后
linkedHashMap.firstEntry();              // SequencedMap:首尾的键值对
FFM API22纯 Java 调本地函数、安全地管理堆外内存;也是收紧 Unsafe 和 JNI 的前提
是什么
Foreign Function & Memory API:不写 C 代码,直接从 Java 调本地库的函数,并安全地分配、释放堆外内存。
以前
调本地库要写 JNI 的 C 胶水代码,堆外内存只能靠 ByteBuffer 或 Unsafe
Linker linker = Linker.nativeLinker();
MethodHandle strlen = linker.downcallHandle(
        linker.defaultLookup().find("strlen").orElseThrow(),
        FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));
try (Arena arena = Arena.ofConfined()) {
    MemorySegment s = arena.allocateFrom("Hello");   // 堆外内存,离开 try 自动释放
    long len = (long) strlen.invokeExact(s);         // 5
}
Stream Gatherers24gather(Gatherers.windowFixed(100)),也能自定义中间操作
是什么
gather(…) 是可以自定义的中间操作。JDK 先内置了分批、滑动窗口、累加这几种常用的。
以前
Stream 的中间操作就那几个,按 100 个一批、滑动窗口只能自己写循环或 Collector
Stream.of(1, 2, 3, 4, 5).gather(Gatherers.windowFixed(2)).toList();
// [[1, 2], [3, 4], [5]]          每 2 个一批
Stream.of(1, 2, 3, 4).gather(Gatherers.windowSliding(2)).toList();
// [[1, 2], [2, 3], [3, 4]]       滑动窗口
Stream.of(1, 2, 3).gather(Gatherers.scan(() -> 0, Integer::sum)).toList();
// [1, 3, 6]                      逐个累加
Class-File API24JDK 自带读写 class 文件的 API,跟着 JDK 一起升级
是什么
JDK 自带的读、写、改 class 文件的 API(包名 java.lang.classfile),给字节码框架用,业务代码一般用不到。
以前
框架各自带一份 ASM,JDK 一升 class 文件版本号,旧 ASM 就读不了
ClassModel cm = ClassFile.of().parse(Files.readAllBytes(Path.of("Foo.class")));
for (MethodModel m : cm.methods()) {
    System.out.println(m.methodName().stringValue());   // 列出所有方法名
}
抗量子密码、KDF24、25ML-KEM、ML-DSA(24)和密钥派生 API(25)
是什么
新的公钥算法 ML-KEM、ML-DSA(24)能抵抗量子计算机;KDF API(25)用来从一个主密钥派生出多把密钥。
以前
现有的公钥算法将来可能被量子计算机破解
KeyPairGenerator g = KeyPairGenerator.getInstance("ML-KEM");   // 24
KeyPair kp = g.generateKeyPair();

KDF hkdf = KDF.getInstance("HKDF-SHA256");                       // 25
SecretKey key = hkdf.deriveKey("AES",
        HKDFParameterSpec.ofExtract().addIKM(ikm).addSalt(salt).thenExpand(info, 32));
并发(Project Loom)
CompletableFuture8多个异步结果能串联、合并、处理异常
是什么
一个“将来会有结果”的对象,可以在结果出来后接着处理、和别的结果合并、统一处理异常,不用阻塞等。
以前
Future.get() 只能阻塞等,没法组合
var user = CompletableFuture.supplyAsync(() -> userApi.get(id), pool);
var orders = CompletableFuture.supplyAsync(() -> orderApi.list(id), pool);
CompletableFuture<Page> page = user
        .thenCombine(orders, Page::new)        // 两个都回来再合并
        .orTimeout(2, TimeUnit.SECONDS)        // 9 起有超时
        .exceptionally(e -> Page.empty());     // 出错给个兜底
虚拟线程21线程变便宜,照样写阻塞代码也能扛高并发(原理见 网络 IO 模型)
是什么
由 JVM 调度的轻量线程,创建几百万个也没问题;遇到阻塞 IO 时自动让出底层的平台线程。写法和普通线程一样。
以前
一个请求占一个平台线程,线程贵,并发上不去;改响应式又难写难调
Thread.startVirtualThread(() -> handle(request));

try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {   // 每个任务一个虚拟线程
    for (var id : ids) exec.submit(() -> callApi(id));
}                                                                 // 退出时等任务做完

// Spring Boot 3.2+:spring.threads.virtual.enabled=true
synchronized 不再钉住24重写了 synchronized 的实现,阻塞时照样能卸载(JEP 491)
是什么
虚拟线程在 synchronized 块里阻塞时,也能从载体线程上卸下来。不用改代码,升到 24 以上就生效。
以前
21 里虚拟线程在 synchronized 中阻塞会占住载体线程,高峰时可能全卡住
synchronized (lock) {
    rs = stmt.executeQuery();   // 21:阻塞时钉住载体线程;24 起会被卸载
}
// 在 21 上排查钉住:-Djdk.tracePinnedThreads=full,或 JFR 事件 jdk.VirtualThreadPinned
ScopedValue25只读、只在一段作用域内有效、子任务共享不复制(机制 5)
是什么
在一段作用域里绑定一个只读的值,调用链上任何地方都能读到;离开作用域自动失效。用来代替 ThreadLocal 传请求上下文。
以前
ThreadLocal 谁都能改、要记得 remove;百万虚拟线程各存一份,子线程继承还要整份复制
static final ScopedValue<String> TENANT = ScopedValue.newInstance();

ScopedValue.where(TENANT, "t-1").run(() -> service.handle());

// 调用链任意深处:
String t = TENANT.get();        // "t-1";不在 run 里会抛 NoSuchElementException
TENANT.isBound();               // 先判断有没有绑定
结构化并发25 仍预览把一组子任务当成一个整体管理;还没转正,生产先别用
是什么
把一组并发子任务当成一个整体:一起开始、一起结束,一个失败其余自动取消。25 还是预览,要加 --enable-preview,每个版本的 API 都可能变。
以前
并发起几个子任务,一个失败了其余的没人取消,线程转储里也看不出父子关系
// 25 的预览写法
try (var scope = StructuredTaskScope.open()) {
    var user = scope.fork(() -> findUser(id));
    var orders = scope.fork(() -> findOrders(id));
    scope.join();              // 任一失败:其余自动取消,join 抛异常
    return new Page(user.get(), orders.get());
}
GC 和性能(升级就白拿)
Metaspace8类元数据挪到本地内存,按需增长
是什么
类的元数据(类名、方法、字段等)放在本地内存里的元空间,取代了以前在堆里、大小固定的永久代。
以前
永久代大小难估,动不动 OutOfMemoryError: PermGen space
-XX:MaxMetaspaceSize=256m      # 默认不设上限,类加载泄漏时建议设一个
-XX:MaxPermSize=256m           # 8 起无效,只打警告,可以删了
G1 成为默认9服务端更在乎停顿,G1 能按目标停顿时间分批回收
是什么
G1 把堆切成很多小块(Region),每次挑垃圾最多的几块回收,尽量满足设定的停顿目标。
以前
8 默认 Parallel GC,吞吐高但大堆停顿长
-XX:+UseG1GC                  # 9 起不写也是它
-XX:MaxGCPauseMillis=200      # 停顿目标,默认 200 ms
-Xlog:gc*:file=gc.log:time,uptime   # 9 起的统一 GC 日志
压缩字符串9改成 byte[] 加编码标志,纯拉丁字符的字符串省一半内存
是什么
String 内部的数组按内容选编码:全是拉丁字符时每个字符 1 字节,有中文等字符时 2 字节。对代码透明。
以前
String 内部是 char[],每个字符 2 字节,而多数字符串只有拉丁字符
// 不用改代码,自动生效
-XX:-CompactStrings            # 需要时可以关掉,一般不用
容器感知10读 cgroup 限制;回移植到 8u191,配 MaxRAMPercentage
是什么
JVM 读取容器(cgroup)的 CPU 和内存限制,按容器的规格而不是宿主机的规格来算默认堆大小和线程数。
以前
JVM 在 Docker 里按宿主机的内存和 CPU 算堆大小和线程数,容易被杀
-XX:MaxRAMPercentage=75.0      # 堆最多占容器内存的 75%,比写死 -Xmx 灵活
-XX:ActiveProcessorCount=2      # 需要时手动指定 JVM 看到的 CPU 数
AppCDS、默认 CDS10、12把类数据存成归档共享,启动更快
是什么
把类加载、解析好的数据存成归档文件,下次启动直接映射进内存,省掉重复的解析和校验。
以前
每次启动都重新解析、校验同样的类
# 13 起:跑一次,退出时生成归档
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar
# 之后启动带上它
java -XX:SharedArchiveFile=app.jsa -jar app.jar
删掉 CMS14G1、ZGC 已能替代,删掉省维护
是什么
CMS 是一个以低停顿为目标、但不整理内存的老 GC,14 起从 JDK 里删除。
以前
CMS 不整理内存,碎片多了退化成长时间 Full GC,维护成本高
# 8 上的老参数,15 起会让 JVM 起不来:
# -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70
# 删掉,用默认的 G1;对停顿要求极高的再试 ZGC
ZGC 转正、分代 ZGC15、21停顿通常不到 1 毫秒,和堆大小基本无关;21 的分代版吞吐更好
是什么
ZGC 几乎所有回收工作都和业务线程并发进行,停顿通常不到 1 毫秒;分代版把新对象和老对象分开回收,吞吐更好。
以前
堆越大,停顿越长
-XX:+UseZGC                     # 15 起可用于生产
-XX:+UseZGC -XX:+ZGenerational  # 21 要用分代版得这样写
-Xmx16g                         # ZGC 主要靠堆大小调,堆要给够
分代 ZGC 成默认、删不分代23、24-XX:+UseZGC 直接就是分代的
是什么
从 23 起 ZGC 默认就是分代的,24 起不分代的模式被删掉,只剩一种 ZGC。
以前
同时维护两种 ZGC
-XX:+UseZGC                     # 23 起直接就是分代的
# -XX:-ZGenerational            24 起只打警告并被忽略
AOT 缓存24、25先做一次训练运行,把加载、链接好的类(24)和方法的运行画像(25)存成缓存,之后启动直接用
是什么
先用典型流量“训练”跑一次,把加载、链接好的类和方法的运行画像存成缓存文件,之后启动直接用,启动和预热都更快。
以前
Spring Boot 这类应用启动要加载、链接几千个类,刚启动还要慢慢预热
# 25:一步完成训练并生成缓存(JEP 514)
java -XX:AOTCacheOutput=app.aot -jar app.jar     # 跑一遍典型请求后退出
java -XX:AOTCache=app.aot -jar app.jar           # 之后带着缓存启动
# 24 要分 -XX:AOTMode=record 和 -XX:AOTMode=create 两步
紧凑对象头25压到 8 字节,对象多而小的应用堆明显变小;25 里要手动开 -XX:+UseCompactObjectHeaders,27 起默认打开(JEP 534)
是什么
把每个对象的对象头从通常的 12 字节压到 8 字节,小对象越多,省下的堆越多。
以前
64 位 JVM 上对象头通常 12 字节,小对象里头的占比很大
-XX:+UseCompactObjectHeaders    # 25 要手动打开
-XX:-UseCompactObjectHeaders    # 27 起默认打开,需要时用这个关掉
分代 Shenandoah25分代模式转为正式特性
是什么
Shenandoah 是另一种低停顿 GC(Oracle 自家构建不带,Temurin 等发行版带);25 起它的分代模式也转为正式特性。
以前
Shenandoah 只有不分代模式
-XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational
平台治理(升级的坑大多在这)
模块系统9用模块划清“公开的”和“内部的”,还能用 jlink 裁出小运行时
是什么
模块是比包大一级的单元:在 module-info.java 里声明依赖哪些模块、对外公开哪些包,没公开的包别的模块用不了。
以前
JDK 是一整块 rt.jar,sun.* 内部类被到处直接用,JDK 想改内部都怕打坏别人
// module-info.java
module com.shop.order {
    requires java.sql;
    exports com.shop.order.api;      // 只有这个包对外可见
}
// 大多数 Spring Boot 应用不写 module-info,跑在“未命名模块”里,照样能用

jlink --add-modules java.base,java.sql --output myjre   # 裁出一个小运行时
删 Java EE、CORBA 模块11移出 JDK,改成普通 Maven 依赖,各自演进
是什么
JAXB、JAX-WS、javax.annotation 等原本属于 Java EE 的模块,11 起不再随 JDK 提供,要自己加依赖。
以前
JAXB、JAX-WS 等属于 Java EE,跟着 JDK 发版太慢
<dependency>
    <groupId>jakarta.xml.bind</groupId>
    <artifactId>jakarta.xml.bind-api</artifactId>
</dependency>
<dependency>
    <groupId>org.glassfish.jaxb</groupId>
    <artifactId>jaxb-runtime</artifactId>
</dependency>
删 Nashorn15需要的自己引独立的引擎
是什么
JDK 内置的 JavaScript 引擎 Nashorn 在 15 被删除,需要的话改用独立发布的版本。
以前
内置 JavaScript 引擎跟不上 JS 标准,维护成本高
<dependency>
    <groupId>org.openjdk.nashorn</groupId>
    <artifactId>nashorn-core</artifactId>
</dependency>
// 用法不变:new ScriptEngineManager().getEngineByName("nashorn")
强封装16、1716 默认拒绝,17 去掉整体放开的开关,只能按包 --add-opens
是什么
JDK 内部的包默认不再对反射开放:不能再用 setAccessible(true) 去改 JDK 类的私有字段。
以前
框架靠反射随便改 JDK 内部字段,JDK 一改实现就崩
# 过渡期:只对某个包放开
--add-opens java.base/java.lang=ALL-UNNAMED
# 先用 jdeps 找出谁在用 JDK 内部 API
jdeps --jdk-internals app.jar
弃用 SecurityManager17标记要删,18 起默认不允许运行时设置
是什么
SecurityManager 是早年为 Applet 设计的沙箱机制,17 标记为将要删除。
以前
很少有人用,却拖累 JDK 每处权限检查的维护
// 17:调用时打警告
System.setSecurityManager(new MySecurityManager());
// 18~23:默认抛异常,-Djava.security.manager=allow 还能打开
// 24 起:永远抛 UnsupportedOperationException
默认 UTF-818统一默认 UTF-8(21 里已生效)
是什么
不指定编码的 API(FileReader、String.getBytes() 等)默认一律用 UTF-8,不再跟操作系统走。
以前
不写编码时用操作系统默认编码,同一个程序在中文 Windows 和 Linux 上结果不同
new FileReader(file);                            // 8:系统默认编码;18 起:UTF-8
new FileReader(file, StandardCharsets.UTF_8);    // 11 起有这个构造器,显式写最稳
Files.readString(path);                          // 11 起,本来就是 UTF-8
// 需要旧行为:-Dfile.encoding=COMPAT
动态加载 agent 警告21先打警告,为将来默认禁止做准备
是什么
运行中通过 attach 往 JVM 里加载 agent 会打警告,将来会默认禁止;启动时用 -javaagent 加载的不受影响。
以前
运行中随时能往 JVM 里注入代码改行为
-javaagent:/opt/apm/agent.jar        # APM 推荐这样在启动时加载
-XX:+EnableDynamicAgentLoading       # 需要 Arthas 临时 attach 的服务,启动时加这个
删字符串模板23整个撤回重新设计(预览特性的风险,⑥ 坑 6)
是什么
21、22 预览过的字符串模板 STR."…" 在 23 被撤回,以后可能换一种设计再来。
以前
21、22 预览的 STR."…" 设计有争议
// 21、22 的预览写法,23 起编译不过:
// String s = STR."Hello \{name}";
String s = "Hello %s".formatted(name);   // 15 起就有的替代写法
注解处理默认不跑23必须显式指定处理器或加 -proc:full(JDK-8321314,⑥ 坑 9)
是什么
javac 不再自动执行类路径上找到的注解处理器(Lombok、MapStruct 等),要在构建里显式声明。
以前
类路径上任何 jar 里的注解处理器都会被 javac 自动执行,是供应链风险
javac -proc:full ...              # 临时恢复旧行为
# 推荐:Maven 的 maven-compiler-plugin 里配 annotationProcessorPaths(见 ⑤ 的 pom)
收紧 Unsafe 和 JNI23、2423 弃用 Unsafe 的内存访问方法,24 起首次调用打警告;24 起 JNI 也打警告,替代品是 FFM
是什么
sun.misc.Unsafe 的内存访问方法和 JNI 都会打警告,将来默认禁止;替代品是 VarHandle 和 FFM。
以前
库直接用 sun.misc.Unsafe 读写内存、用 JNI 调本地代码,一出错整个 JVM 崩
// 以前:UNSAFE.compareAndSwapInt(this, STATE_OFFSET, 0, 1);
// 现在用 VarHandle(在 static 块里初始化,findVarHandle 会抛受检异常):
static final VarHandle STATE;   // = MethodHandles.lookup().findVarHandle(Task.class, "state", int.class)
STATE.compareAndSet(this, 0, 1);

--enable-native-access=ALL-UNNAMED            # 用 JNI 的程序启动时加上,消掉警告
永久禁用 SecurityManager2424 起彻底打不开,setSecurityManager 一律抛异常
是什么
24 起 SecurityManager 彻底无法启用,所有相关的权限检查都不再生效。
以前
17 弃用后,18~23 还能用 -Djava.security.manager=allow 打开
System.setSecurityManager(sm);   // 24 起总是抛 UnsupportedOperationException
// 隔离不可信代码:改用独立进程、容器或操作系统权限

机制 3为什么是 record + sealed + 模式匹配,而不是 Lombok设计对比

sealed interface PayResult permits Paid, Failed, Pending record Paid record Failed record Pending record Refunded后来新加的 return switch (r) { case Paid p -> … case Failed f -> … case Pending p -> … }; // 不需要 default 编译器知道一共就这三种 ✓ 加了 Refunded:所有漏掉它的 switch 直接编译失败,而不是上线后走错分支
  • Lombok 的 @Data 是在编译时改语法树,生成的是普通类:编译器不知道它“只是数据”,没法拿它做解构,更没法做穷尽检查。它还要用 javac 的内部 API,所以每次升级 JDK 都要跟着升 Lombok(⑥ 第三圈)。
  • Visitor 模式也能保证“每种都处理到”,但要给每个类型加 accept 方法,新增一种操作要改所有类。
  • record 是语言层面的声明:字段就是全部状态、不可变,所以模式匹配能安全地把它拆开(case Paid(var id, var cents))。
  • sealed 告诉编译器“就这几种”,switch 模式负责按类型分支。三样拼在一起,就是函数式语言里的代数数据类型:新增一种情况时,编译器把所有该改的地方列出来。

机制 4为什么要强封装:让 JDK 能改自己的内部设计对比

JDK 8:类路径上一切可见

rt.jar(一整块) java.lang、java.util sun.misc、sun.nio … String 的私有字段 老框架 / Lombok JDK 一改内部,它就坏

JDK 17:只导出公开的包

模块 java.base exports java.lang … jdk.internal.*私有字段(不对外开放) 老框架 / Lombok ✕ InaccessibleObjectException 除非启动时 --add-opens 放开那个包

决定这个设计的约束:JDK 要持续重构内部(压缩字符串、虚拟线程都改了 String、Thread 的内部),而一大批库依赖着这些内部。不封起来,每次重构都会打坏别人,JDK 就只能停在原地。9 先给警告,16 默认拒绝,17 去掉一键放开的开关,前后给了生态四年多时间迁移。坏掉的老库,修法是升级库本身,--add-opens 只是过渡。

机制 521 → 25:有了虚拟线程,为什么还要 ScopedValue设计对比

21 让线程变便宜,一个请求开几十个子任务成了常态。ThreadLocal 是为“少量、长寿、池化”的平台线程设计的,放到“海量、短命、层层派生”的虚拟线程上,三个毛病一起放大。

ThreadLocal:每个线程一份可变副本

父线程 user = A 任何代码都能 set() 改它 用完要记得 remove() 子线程 1user = A(副本) 子线程 2user = A(副本) 子线程 …user = A(副本) 继承时整份复制 100 万个虚拟线程 = 100 万份可变副本

ScopedValue:一段作用域里的只读绑定

ScopedValue.where(USER, A).run(…) USER = A handle() 子任务 1 子任务 2 只读,子任务读同一份,不复制 离开 run() 自动失效,没有 remove 可忘

决定这个设计的约束:线程数从几百涨到上百万,而且父子任务层层派生。可变、无限寿命、继承时复制,这三点在少量平台线程上无所谓,在海量虚拟线程上就变成了内存和正确性问题。ScopedValue 的答案是只读、限定作用域、共享不复制。它和结构化并发(25 仍是预览)是一对:子任务的生命周期不超过父任务,父任务绑定的值就能安全地给所有子任务共享。注意只有 StructuredTaskScope 里 fork 出来的子任务会继承绑定,普通线程池、executor 开的线程读不到(⑥ 坑 12)。ThreadLocal 没被弃用,池化平台线程上的缓存类用途照样可以用。

语法21 和 25 的写法,最少要认得这些每行注明是哪个版本来的

PayResults.java未在本机运行;按 JEP 395 / 409 / 440 / 441 / 444 / 485 / 506 / 512 的写法核对
sealed interface PayResult permits Paid, Failed, Pending {}            // 17 sealed
record Paid(String orderId, long cents) implements PayResult {}         // 16 record
record Failed(String orderId, String reason) implements PayResult {}
record Pending(String orderId) implements PayResult {}

static String describe(PayResult r) {
    return switch (r) {                                                   // 14 switch 表达式
        case Paid(var id, var cents) -> id + " 已付 " + cents / 100.0;    // 21 record 模式
        case Failed f when f.reason().isBlank() -> f.orderId() + " 失败";  // 21 带条件的 case
        case Failed f -> f.orderId() + " 失败:" + f.reason();
        case Pending p -> p.orderId() + " 处理中";
    };                                                                    // 没有 default:漏一种就编译不过
}

try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {          // 21 虚拟线程,10 var
    exec.submit(() -> query(orderId));
}

static final ScopedValue<String> USER = ScopedValue.newInstance();      // 25 ScopedValue
ScopedValue.where(USER, "u-42").run(() -> handle());                     // run 里这条调用链上 USER.get() 都是 u-42

ids.stream().gather(Gatherers.windowFixed(100))                          // 24 Gatherers:每 100 个一批
   .forEach(batch -> batchQuery(batch));

void main() { IO.println("hello"); }                                     // 25 紧凑源文件:小工具不用写 class

参数、GC 选择、各个特性的边角规则放到第⑥步。注意带 when 的 case 要写在同类型的普通 case 前面,否则会被它“盖住”而编译报错。

⑤跑起来:同一段代码,8、21、25 各写一遍,再升一次级

一个小场景:拿到一批支付结果,按类型生成描述,再并发查 1000 个订单。左边是 JDK 8 的写法,右边是 21 的写法,下面再用 25 补两样。代码未在本机运行,写法按 JEP 和 JLS 核对。

JDK 8Lombok + instanceof + 固定线程池
interface PayResult {}
@Value class Paid implements PayResult {      // Lombok 生成
    String orderId; long cents; }
@Value class Failed implements PayResult {
    String orderId; String reason; }
@Value class Pending implements PayResult {
    String orderId; }

static String describe(PayResult r) {
    if (r instanceof Paid) {
        Paid p = (Paid) r;                      // 判断完还要强转
        return p.getOrderId() + " 已付 " + p.getCents() / 100.0;
    } else if (r instanceof Failed) {
        Failed f = (Failed) r;
        return f.getOrderId() + " 失败:" + f.getReason();
    } else if (r instanceof Pending) {
        return ((Pending) r).getOrderId() + " 处理中";
    }
    throw new IllegalStateException();          // 漏了一种,运行时才知道
}

// 1000 个阻塞查询:池子 200,其余排队
ExecutorService pool = Executors.newFixedThreadPool(200);
List<Future<Order>> fs = new ArrayList<>();
for (String id : ids) fs.add(pool.submit(() -> query(id)));
for (Future<Order> f : fs) f.get();
pool.shutdown();
JDK 21record + sealed + switch 模式 + 虚拟线程
sealed interface PayResult permits Paid, Failed, Pending {}
record Paid(String orderId, long cents) implements PayResult {}
record Failed(String orderId, String reason) implements PayResult {}
record Pending(String orderId) implements PayResult {}

static String describe(PayResult r) {
    return switch (r) {
        case Paid(var id, var cents) -> id + " 已付 " + cents / 100.0;
        case Failed(var id, var reason) -> id + " 失败:" + reason;
        case Pending(var id) -> id + " 处理中";
    };                                // 漏了一种:编译不过
}





// 1000 个阻塞查询:每个一个虚拟线程,同时进行
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    var fs = ids.stream()
                .map(id -> exec.submit(() -> query(id)))
                .toList();            // 16 Stream.toList
    for (var f : fs) f.get();
}                                     // 19 起 ExecutorService 能用 try-with-resources,退出时等任务做完
JDK 25同一个场景再补两样:ScopedValue 传当前用户,Gatherers 分批;未在本机运行
static final ScopedValue<String> USER = ScopedValue.newInstance();

void handle(String user, List<String> ids) throws Exception {
    ScopedValue.where(USER, user).call(() -> queryAll(ids));   // 25:这段调用链里绑定 USER,只读
}

List<Order> queryAll(List<String> ids) throws Exception {
    String user = USER.get();                                    // 同一线程的调用链里读得到
    try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
        var fs = ids.stream()
                    .gather(Gatherers.windowFixed(100))           // 24:每 100 个 id 一批
                    .map(batch -> exec.submit(() -> batchQuery(user, batch)))   // 显式把 user 传下去
                    .toList();
        var all = new ArrayList<Order>();
        for (var f : fs) all.addAll(f.get());
        return all;
    }
}
// 注意:executor 开出来的线程不继承 USER,在 batchQuery 里调 USER.get() 会抛 NoSuchElementException。
// 只有结构化并发(25 仍是预览)的 StructuredTaskScope.fork 出来的子任务才继承。
pom.xml用 release,而不是 source / target
<properties>
    <maven.compiler.release>25</maven.compiler.release>   <!-- 用了 Spring Boot 父 POM 时写 <java.version>25</java.version> -->
</properties>
<!-- 23 起注解处理器要显式声明,否则 Lombok 不生效(⑥ 坑 9) -->
<plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths>
            <path><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>${lombok.version}</version></path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

release(9 起)同时限定语法和可用的 JDK API;只写 source/target 时,高版本 javac 会放过只有新 JDK 才有的方法,程序拿到老 JDK 上才报 NoSuchMethodError。ExecutorService 实现 AutoCloseable 是 JDK 19 加的,21 里能直接用。

老项目从 8 升到 17 / 21 / 25:五步

  1. 1先扫一遍。jdeps --jdk-internals app.jar 列出用到的 JDK 内部 API;jdeprscan --release 17 app.jar 列出用到的已弃用、已删除 API。两个工具 JDK 9 起自带。
  2. 2先升依赖,再换 JDK。Lombok、Spring、ASM / cglib、Mockito、字节码类 APM、Maven 编译插件都要升到支持目标 JDK 的版本,这一步解决大部分强封装问题;目标是 23 以上的,顺手把注解处理器写进 annotationProcessorPaths。
  3. 3换 JDK 编译。改成 release;补上被移出 JDK 的依赖(JAXB 等);内部类换成公开 API。
  4. 4改启动参数。删掉 CMS、PrintGCDateStamps、PermSize、ZGenerational 等老参数;用 JNI 的加 --enable-native-access;GC 日志改成 -Xlog:gc*:file=gc.log:time,uptime;容器里用 -XX:MaxRAMPercentage 代替写死的 -Xmx。
  5. 5测、压、灰度。跑全量测试,压测对比 GC 停顿和 RT(默认 GC 从 Parallel 变成了 G1),再灰度上线。
到这里已经能交差了。能说清 8、17、21、25 的关系(同一条线上的四个 LTS 站点),能看懂新写法、知道每样为什么加,能按五步把一个老服务升上去。
已知还没处理的细节

⑥细节:升级时会在哪出事

第一圈是升级时最常见的坑,前 8 条覆盖 8 → 21,后 4 条是升到 25 新增的,每条标了根源在 ④ 的哪一条;第二圈是团队规范;第三圈是几处更深的原因,最后是升级检查器。

第一圈

注意事项

坑 1换上新 JDK,JVM 直接起不来← ④ 机制 2 GC 线
现象
Unrecognized VM option 'PrintGCDateStamps',接着 Error: Could not create the Java Virtual Machine.
根源
9 起 GC 日志统一成 -Xlog,PrintGCDateStamps 这类老参数被删;UseCGroupMemoryLimitForHeap 在 11 里已不认识;CMS 在 14 被删时只是警告并忽略,15 起 -XX:+UseConcMarkSweepGC 也成了不认识的参数(按 jdk11u、14u、15u 的 arguments.cpp 核对)。偏向锁的 -XX:+UseBiasedLocking、-XX:BiasedLockingStartupDelay=0 在老的调优脚本里很常见:15 弃用、18 作废、19 起不认识(jdk17u 的过期表)。而 MaxPermSize 只打警告,不影响启动。
做法
升级前先把启动脚本里的 GC 参数清一遍:日志改 -Xlog:gc*:file=gc.log:time,uptime,CMS 删掉用默认 G1,容器用 MaxRAMPercentage。
坑 2编译通过,一跑就报 InaccessibleObjectException← ④ 机制 4 强封装
现象
Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module
根源
老版本的 Lombok、cglib、Mockito、序列化库或自己的代码,用反射改 JDK 内部字段。9~15 只打警告,16 默认拒绝,17 删掉了 --illegal-access 总开关。
做法
升级这些库到支持目标 JDK 的版本;实在等不及,按包加 --add-opens java.base/java.lang=ALL-UNNAMED,并记一笔技术债。往后还会更严:26 起用反射改 final 字段也会打警告(JEP 500),以后会直接报错。
坑 3ClassNotFoundException: javax.xml.bind.JAXBContext← ④ 机制 2 治理线
现象
在 8 上好好的,换 11 以上编译不过,或者运行时找不到 JAXB、JAX-WS、javax.annotation 的类。
根源
JEP 320 在 11 把 Java EE 和 CORBA 模块移出了 JDK,它们本来就属于 Java EE。
做法
加对应的 Maven 依赖(如 jakarta.xml.bind-api 加一个实现)。要上 Spring Boot 3 的,直接用 jakarta 版本。
坑 4升 Spring Boot 3,一大片 import 报红历史原因:Java EE 改名
现象
javax.servlet、javax.persistence、javax.validation 全都找不到。
根源
这不是 JDK 的改动:Java EE 移交 Eclipse 基金会后改名 Jakarta EE,从 Jakarta EE 9 起包名从 javax.* 改成 jakarta.*。Spring Boot 3 基于 Jakarta EE 9+,又要求 JDK 17,两件事常被混成一件。
做法
先升到 Boot 2.7 最新版、清掉弃用警告,再按官方迁移指南批量改包名(IDE 和 OpenRewrite 都有现成的迁移规则)。
坑 5没报错,但中文乱码、日期格式变了← ④ 机制 2 治理线
现象
升到 21 后,中文 Windows 上读出来的文件乱码;或者升到 9 以上,某些语言的日期、数字格式化结果和以前不一样。
根源
18 起(JEP 400)不写编码时默认用 UTF-8,不再跟操作系统走,以前按 GBK 读写的代码行为就变了。9 起(JEP 252)默认的本地化数据换成了 CLDR。
做法
读写文件、转字节一律显式写 StandardCharsets.UTF_8;格式化写死 pattern 和 Locale。过渡期可用 -Dfile.encoding=COMPAT;-Djava.locale.providers=COMPAT 在 21 里还能兜底,23 起 COMPAT 被删,只打警告、照样用 CLDR。
坑 6用了预览特性,下个版本代码就废了← ④ 机制 1 预览
现象
21 上用 STR."…" 字符串模板写的代码,升到 23 编译不过。
根源
预览特性可以改、也可以删。字符串模板(JEP 430)在 21、22 是预览,23 里被整个撤掉。另外预览特性编出来的 class 只能在同一个 JDK 版本上跑。
做法
生产代码不用 --enable-preview。21 里的结构化并发、ScopedValue、未命名变量都是预览,等转正再用。
坑 721 上 Arthas 一 attach 就打警告← ④ 机制 2 治理线
现象
日志里出现 “A Java agent has been loaded dynamically … dynamic loading of agents will be disallowed by default in a future release”。
根源
JEP 451:运行中动态注入的 agent 能改任何代码,JDK 要逐步收紧,21 先打警告。
做法
APM 改成启动参数 -javaagent;需要临时 attach 排查的服务,启动时加 -XX:+EnableDynamicAgentLoading。
坑 8开了虚拟线程,压力一大全卡住← ④ 机制 2 并发线
现象
21 上打开 spring.threads.virtual.enabled,平时正常,高峰时请求全部不动。
根源
21 里虚拟线程在 synchronized 块里阻塞会钉住载体线程,载体线程默认和 CPU 核数一样多;24(JEP 491)才解决,所以升到 25 就没有这个问题了。但下游连接池会变成新瓶颈,这一点 25 也一样。
做法
用 JFR 事件 jdk.VirtualThreadPinned 找钉住点,换 ReentrantLock;对下游加并发上限。详见 网络 IO 模型 · 坑 7。
坑 9升到 23 以上,Lombok 突然不生效← ④ 机制 2 治理线
现象
Maven 项目换 JDK 25 编译,满屏 cannot find symbol: method getXxx()。21、22 上编译时其实已经打过一条 Note 提醒。
根源
23 起(JDK-8321314)javac 不再自动执行类路径上的注解处理器:任何依赖 jar 里的处理器都会被自动执行,是供应链风险。Lombok 只写在 dependencies 里,就是靠这种“自动发现”。
做法
在 maven-compiler-plugin 里把 Lombok、MapStruct 等写进 annotationProcessorPaths(⑤ 的 pom),或临时加 -proc:full。Gradle 用 annotationProcessor 配置,本来就是显式的,不受影响。
坑 1024 起 SecurityManager 彻底打不开← ④ 机制 2 治理线
现象
17 上只是警告,18~23 加 -Djava.security.manager=allow 还能跑;升到 25,System.setSecurityManager 一调就抛 UnsupportedOperationException。
根源
JEP 486 永久禁用了 SecurityManager,jdk25u 源码里 setSecurityManager 只剩一行抛异常。老的插件沙箱、脚本沙箱、部分测试框架会用到它。
做法
去掉对它的依赖:隔离不可信代码改用独立进程、容器或操作系统的权限控制。
坑 11升到 24 以上,日志里冒出 Unsafe 和 JNI 警告← ④ 机制 4 强封装
现象
启动后出现 “A terminally deprecated method in sun.misc.Unsafe has been called”,或者调用本地库时出现 restricted method 的警告。
根源
和强封装同一个思路:Unsafe 的内存访问和 JNI 都能绕过 Java 的安全保证。23 弃用 Unsafe 内存访问方法,24 起首次调用打警告(JEP 498);24 起 JNI 也打警告(JEP 472)。替代品分别是 VarHandle / FFM 和 FFM。
做法
先升级依赖(老版本 Netty 等底层库是常见来源);确实要用 JNI 的,启动时加 --enable-native-access=ALL-UNNAMED。现在只是警告,将来会变成报错。
坑 12线程池里的任务读不到 ScopedValue← ④ 机制 5
现象
把 ThreadLocal 换成 ScopedValue 后,提交到线程池或 executor 的任务里 USER.get() 抛 NoSuchElementException。
根源
绑定只在 where(…).run(…) 这条调用链上有效,只会被结构化并发里 fork 出来的子任务继承。普通线程池的线程活得比这段作用域还久,所以故意不继承。
做法
要传给线程池任务的值显式当参数传(⑤ 的写法);等结构化并发转正后,用 StructuredTaskScope 派生子任务。读之前不确定有没有绑定,用 isBound() 判断。
第二圈

使用规范

生产只用 LTS,跟着季度补丁升小版本

OpenJDK 每年 1、4、7、10 月出安全更新;选一个发行版(Temurin、Corretto、Dragonwell 等),镜像里固定小版本号。

已知漏洞长期不修

编译用 release,不用 source / target

Maven 写 maven.compiler.release。

用了高版本才有的 API,到低版本 JDK 上才报 NoSuchMethodError

record 用在 DTO、值对象,不用在 JPA 实体

record 是 final、字段不可变、没有无参构造器,JPA 的代理和懒加载用不了它。

实体映射在运行时报错

var 只在右边一眼能看出类型时用

var list = new ArrayList<Order>() 可以;var x = service.load() 别用。

读代码要跳进方法才知道类型

多分支的业务类型用 sealed + switch,不写 default

写了 default,编译器就不再提醒你漏掉了新类型。

新增一种状态,旧代码悄悄走进 default

构建里显式声明注解处理器

Maven 写 annotationProcessorPaths,不靠类路径自动发现。

升到 23 以上 Lombok、MapStruct 失效(坑 9)

上下文传递:同一调用链用 ScopedValue,跨线程池就显式传参

新代码里请求级的上下文(用户、租户、traceId)优先用 ScopedValue;要交给线程池的就当参数传。

ThreadLocal 忘了 remove 串数据、线程池里读不到绑定(坑 12)

升级 JDK 时,先在启动参数上过一遍

GC 选择、GC 日志、堆大小、--add-opens 都写进同一个文件里审。

坑 1、坑 2 在生产上才暴露

第三圈

更深一层

为什么每升一次 JDK,Lombok 都要跟着升

Lombok 不是普通的注解处理器:标准注解处理器只能生成新文件,不能改已有的类;Lombok 要在你的类里加 getter,只能拿到 javac 内部的语法树直接改。javac 的内部 API 不保证兼容,加上 16、17 的强封装,每个新 JDK 都可能让旧 Lombok 失效。record 是语言自己提供的,不存在这个问题。

默认 GC 换了,升级后要看什么

  • 8 默认 Parallel(吞吐优先),9 起默认 G1(停顿优先)。同样的堆,升级后吞吐可能略降、停顿明显变短,压测要两样都看。
  • 21 的 ZGC 要分代的话,得加 -XX:+UseZGC -XX:+ZGenerational;23 起分代成了默认,24 删了不分代的模式,25 上 ZGenerational 只打警告、被忽略(jdk25u 源码核对)。
  • 25 的紧凑对象头默认不开,要加 -XX:+UseCompactObjectHeaders,对象多而小的服务值得压测对比一下堆占用;27 起默认打开。
  • 25 及以前,容器里 JVM 只拿到 1 个 CPU、或内存小于 1792 MB 时,会自动选 Serial GC,规格太小的 Pod 要显式指定 GC;27 起(JEP 523)不管机器多小都默认 G1。

为什么 17 和 21 都要“删东西”

每一样留在 JDK 里的东西都有维护成本:CMS 拖累 GC 代码重构,SecurityManager 让每个敏感操作都要多一层权限检查,Nashorn 要追 JS 标准。Java 的承诺是“公开 API 尽量兼容”,所以删之前都会先弃用、打警告,给几个版本的缓冲。升级时看一眼 jdeprscan 的输出,就能提前知道下一次会坏什么。

17 → 21、21 → 25,各值不值得升

语言上主要是模式匹配补齐了(record 模式、switch 模式),API 上是 SequencedCollection;真正的分量在运行时:虚拟线程和分代 ZGC。如果服务是“大量阻塞 IO + 线程池不够用”,或者大堆、对停顿敏感,21 的收益最直接;如果都不是,17 → 21 的升级风险也很小(主要是坑 5、7、8,外加用了 SecurityManager 的会在 18 起报错)。21 → 25 是“把虚拟线程补完整”:synchronized 不再钉住、ScopedValue 转正,外加 AOT 缓存和紧凑对象头省启动时间和内存,支持期也最长(到 2031 年);要处理的主要是坑 9~11。

升级检查器先猜,再检查

现在的 JDK
要升到
项目里有这些吗(可多选)
选一个剧情先猜;也可以直接勾选自己项目的情况再点检查。
点“检查”看结果。

规则来自 ④ 的那张表和第一圈的坑:每一项记着“从哪个版本开始出问题、出什么问题”,只有当现在的版本早于它、要升到的版本不早于它时才算进来;启动参数和 Spring Boot 2.x 这类“一直带着的东西”不管从哪个版本升都算。规则在 node 里逐项测过(例如 17 → 21 不会报 JAXB,8 → 17 不会报默认 UTF-8,升到 25 时虚拟线程钉住不再算问题)。它只是一份提醒清单,真实项目以 jdeps、jdeprscan 和测试为准。

下一圈

  • 结构化并发(25 仍预览):StructuredTaskScope 怎么管一组子任务、怎么和 ScopedValue 配合
  • AOT 缓存实战:给 Spring Boot 做训练运行,启动能快多少
  • FFM API 实战:不写 JNI,用 Java 直接调本地库
  • 25 里的预览:Stable Values、原始类型模式、PEM 编码
  • Project Valhalla:值类型,让对象像 int 一样不占对象头
  • G1 和 ZGC 调优:停顿从哪来、参数为什么有效
  • jlink + CDS / AOT:容器里的小镜像和快启动

↺回到第⑤步:改 PayResult 和 queryAll

三个练习各用一条 ⑥ 的内容。“做对了会看到”是按 JLS 和 JEP 推出来的,没有在本机跑过。

练习 1 · 加一种状态

给 21 版加 record Refunded(String orderId, long cents),并加进 permits,不改 describe,编译。

做对了会看到:编译失败,提示 switch 没有覆盖所有可能的输入。8 版加一个 Refunded 类则能编译通过,运行到它时才抛 IllegalStateException。

用到:④ 机制 3、第二圈“不写 default”

练习 2 · 扫自己的项目

在公司项目上跑 jdeps --jdk-internals 和 jdeprscan --release 25,把启动脚本里的 -XX 参数逐个对照坑 1,再看 pom 里有没有 annotationProcessorPaths。

做对了会看到:一份“内部 API → 建议替代”的清单;常见的是 sun.misc.Unsafe(多半来自依赖)和 sun.misc.BASE64Encoder(换 java.util.Base64)。

用到:⑤ 五步、坑 1、坑 2、坑 9

练习 3 · 先猜,再看线程数

把 8 版的 1000 个查询(每个阻塞 1 秒)分别用固定 200 线程池和 21 的虚拟线程跑,先猜两者大约各要几秒。

做对了会看到:线程池约 5 秒(1000 ÷ 200 批,每批 1 秒);虚拟线程约 1 秒,前提是 query 背后的连接池也撑得住 1000 个并发,否则瓶颈挪到连接池。

用到:坑 8、三高 · Little 定律

1. 用一句话说出 8、17、21、25 的关系和升级的精华。

它们是半年一班车里的四个长期支持站点,17 包含 9~16、21 包含 18~20、25 包含 22~24 的全部改动;升级拿到的是只有平台能做的改进(编译器检查、虚拟线程、GC、字符串布局),要还的是“平台治理”那条线的账(强封装、删模块、删旧参数)。

← 开头 + ②

2. 留在 JDK 8 靠第三方库补,痛在哪?

库只能补 API 和语法糖,补不了要改编译器、JVM、类库内部的东西(穷尽检查、虚拟线程、新 GC、压缩字符串);而且 Spring Boot 3 / 4 最低要 17,框架生态已经往前走了。

← ②

3. “编译通过、一跑就报 InaccessibleObjectException”,根源是 ④ 的哪一条?

机制 4 强封装:为了让 JDK 能改自己的内部,9 起用模块划清公开和内部,16 默认拒绝反射访问内部,17 去掉了总开关。老库用反射改 JDK 私有字段,就撞上了。

← ④ 机制 4 + 坑 2

4. 预测:JDK 8 的服务,启动脚本里有 -XX:+UseConcMarkSweepGC -XX:MaxPermSize=256m,直接换 JDK 14 和 JDK 17 各会怎样?

14:两个参数都只打“Ignoring option …”警告,JVM 照常启动,GC 悄悄变成默认的 G1。17:CMS 参数在 15 起已经不认识,JVM 报 Unrecognized VM option 起不来;MaxPermSize 仍只是警告。

← 坑 1(按 HotSpot 源码里的过期表推出)

5. 预测:sealed interface Shape permits Circle, Square,某处 switch 写了 default -> 0。后来加了 Triangle,这个 switch 会怎样?

照样编译通过,Triangle 走进 default 返回 0,悄悄算错。写了 default,编译器就认为你已经处理了“其他所有情况”,穷尽检查帮不上忙。这就是第二圈说“不写 default”的原因。

← ④ 机制 3

6. 预测:一个小工具在中文 Windows 上用 new FileReader("a.txt") 读 GBK 编码的文件,JDK 8 和 JDK 21 上结果一样吗?

不一样。8 上默认编码跟系统走(GBK),能正常读;21 上默认是 UTF-8(18 起),读 GBK 文件会乱码。显式指定编码就两边一致。

← ④ 表格“默认 UTF-8”+ 坑 5

7. 预测:17 上用 --enable-preview 编译的 class,拿到 21 上运行会怎样?

跑不起来。启用预览特性编出来的 class 文件带着版本标记,只能在编译它的那个 JDK 大版本上、并且同样加 --enable-preview 才能运行。

← ④ 机制 1 预览 + 坑 6

8. 预测:Maven 项目把 Lombok 只写在 dependencies 里(scope 是 provided),分别用 JDK 21 和 25 编译会怎样?

21:照常编译,只多一条 Note,提醒以后可能默认不跑类路径上的注解处理器。25:Lombok 不会被执行,所有靠它生成的 getter、构造器都不存在,编译报找不到符号。写进 annotationProcessorPaths 两边都正常。

← ④ 表格“注解处理默认不跑”+ 坑 9

9. 预测:在 ScopedValue.where(USER, "u-42").run(…) 里,用 newVirtualThreadPerTaskExecutor 提交一个任务,任务里调 USER.get() 会怎样?换成 21 的写法、用 InheritableThreadLocal 呢?

ScopedValue:抛 NoSuchElementException,executor 开的线程不继承绑定,只有结构化并发 fork 的子任务继承。InheritableThreadLocal:能读到,因为新建线程时把父线程的值整份复制了一份;这正是机制 5 说的“海量线程时代太贵、也难推理”的那种行为。

← ④ 机制 5 + 坑 12

10. 预测:同一个服务开着虚拟线程,代码里 synchronized 包着一次 JDBC 查询,分别跑在 21 和 25 上,高峰时有什么区别?

21:阻塞时载体线程被钉住,载体线程数等于 CPU 核数,几个慢查询就能让所有虚拟线程都跑不了。25:24 起 synchronized 里阻塞也能卸载,不会整体卡住;但并发查询数照样受连接池限制,排队还是会发生。

← ④ 表格“synchronized 不再钉住”+ 坑 8

11. 预测:一个 1 核、1 GB 内存的容器,启动参数里没指定 GC,跑在 JDK 21、25、27 上分别用的是哪个 GC?

21 和 25:Serial。JVM 判断这不是“服务器级”机器(只有 1 个 CPU,内存也小于 1792 MB),不选 G1。27:G1,JEP 523 去掉了这条判断,任何环境都默认 G1。三个版本上只要显式写了 -XX:+UseG1GC 或别的 GC,就都按你写的来。

← ⑥ 第三圈 GC + ③ 末尾 27

12. 预测:JDK 8 时代的调优脚本里有 -XX:BiasedLockingStartupDelay=0,拿到 17 和 21 上各会怎样?

17:打一行弃用警告,照常启动(偏向锁 15 起默认关,这个参数也没什么用了)。21:19 起这个参数已不认识,JVM 报 Unrecognized VM option 起不来。和 CMS 参数是同一个套路:弃用 → 作废只警告 → 过期就报错。

← ④ 地基 JDK 6 + 坑 1