Java新特性(五):Java 18 ~ 21(LTS)
Java新特性(五):Java 18 ~ 21(LTS)
导语:Java 21 是近十年最重要的 LTS 之一,因为它带来了虚拟线程——一次对「Java 并发模型」的根本性重构;同时模式匹配 switch / record 模式双双转正,让 Java 第一次有了接近函数式语言的表达能力。本篇把虚拟线程的八个高频追问(要不要线程池、会不会 pin、和 ThreadLocal 的关系)讲透,并附 Java 18 ~ 20 的铺垫特性。
一、Java 18 ~ 20:为 Java 21 铺路
1. Java 18 有哪些值得记的?
答:
- 默认字符集为 UTF-8(JEP 400):这是最容易被忽略的「大变更」。以前
Charset.defaultCharset()取决于操作系统区域设置(Windows 中文版是 GBK),file.encoding 不一致导致的乱码与「本地正常、服务器乱码」问题终于根除。可用-Dfile.encoding=COMPAT恢复旧行为。 - 简易 Web 服务器(JEP 408):一行命令起静态文件服务,用于本地调试与教学:
jwebserver -p 8000 -d /tmp/www # 静态文件服务器,仅支持 HTTP/1.1 GET/HEAD- JavaDoc 代码片段(JEP 413):
{@snippet ...}让文档里的示例代码可被编译检查,避免文档与代码脱节。 - 核心反射改用方法句柄实现(JEP 416):
java.lang.reflect底层重写,减少MethodHandle与Unsafe的重复实现。 - Internet 地址解析 SPI(JEP 418):
InetAddressResolverProvider允许自定义 DNS 解析(适配 servicemesh、自定义 DNS 缓存等)。 - 弃用
finalization(JEP 421):Object.finalize()正式走向废弃(Java 18 起默认关闭-XX:+FinalizeOnExit之类的依赖),官方替代方案是Cleaner+try-with-resources(Cleaner自 Java 9 提供)。
// finalize 的替代:显式清理 / Cleaner
public class Resource implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private static class State implements Runnable {
public void run() { System.out.println("释放本地资源"); }
}
private final Cleaner.Cleanable cleanable;
Resource() { this.cleanable = CLEANER.register(this, new State()); }
@Override
public void close() { cleanable.clean(); } // 配合 try-with-resources
}2. Java 19 / 20 有什么铺垫?
答: 这两个非 LTS 版本几乎全部在为 Java 21 试水,重点只有一件事——虚拟线程:
| 特性 | Java 19 | Java 20 | Java 21 |
|---|---|---|---|
| 虚拟线程(JEP 425/436/444) | 首次预览 | 第二次预览 | ✅ 转正 |
| 结构化并发(JEP 428/437/453) | 孵化 | 孵化 | 预览 |
| 作用域值 Scoped Values(JEP 429/446) | —— | 首次孵化 | 预览 |
| record 模式(JEP 405/432/440) | 预览 | 第二次预览 | ✅ 转正 |
| switch 模式匹配(JEP 427/433/441) | 第三次预览 | 第四次预览 | ✅ 转正 |
| FFM API(JEP 424/434/442) | 预览 | 第二次预览 | 第三次预览(22 转正) |
其他值得一提的:Java 19 把 JDK 移植到 Linux/RISC-V(JEP 422)。
二、虚拟线程(Virtual Threads)—— 面试重灾区
3. 虚拟线程和平台线程有什么区别?(必问)
答:
| 维度 | 平台线程(Platform Thread) | 虚拟线程(Virtual Thread) |
|---|---|---|
| 实现者 | 操作系统(1:1 映射到内核线程) | JVM(M:N,多个虚拟线程复用少量载体线程) |
| 创建成本 | 昂贵(内核态栈,约 1MB 栈空间),线程数受限于内存 | 极廉价(堆上对象,初始栈仅几百字节,按需增长) |
| 数量级 | 几千到几万(再多调度开销爆炸) | 百万级都是常规操作 |
| 调度者 | OS 抢占式调度 | JVM 调度,协作式:遇到阻塞 I/O 自动挂起并让出载体线程 |
| 池化 | 必须池化(创建昂贵) | 不要池化(创建极便宜,用完即弃) |
| 线程局部变量 | ThreadLocal | 可用,但推荐 ScopedValue |
| 底层 | Thread 对象 + 内核线程 | 仍是 java.lang.Thread 的子类型(Thread.isVirtual() 为 true) |
关键认知:虚拟线程不是「让 CPU 计算更快」,而是「让阻塞变得更便宜」。
- 传统模型下,线程在等 I/O 时会占着内核线程白白等待,所以要提高吞吐只能靠线程池复用有限线程,或者改用异步/响应式(Reactor、CompletableFuture 回调地狱)。
- 虚拟线程把「等待」的成本降到接近零:阻塞时 JVM 会把它从载体线程上卸载(unmount),载体线程立刻去跑别的虚拟线程。于是 「用同步的代码,写异步的性能」。
- 代价:CPU 密集型任务不会变快(甚至因为调度略慢一点),因为真正的并行度依旧受限于 CPU 核数。
import java.time.Duration;
import java.util.concurrent.*;
public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
// ---------- 创建方式 1:Thread.ofVirtual() ----------
Thread t1 = Thread.ofVirtual().name("vt-1").start(() -> {
System.out.println("running in " + Thread.currentThread());
});
t1.join();
// ---------- 创建方式 2:静态方法直接启动 ----------
Thread t2 = Thread.startVirtualThread(() -> System.out.println("vt-2"));
t2.join();
// ---------- 创建方式 3(最推荐):每个任务一个虚拟线程的执行器 ----------
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> f1 = executor.submit(() -> "task-1");
Future<String> f2 = executor.submit(() -> "task-2");
System.out.println(f1.get() + ", " + f2.get());
} // try-with-resources 自动关闭,ExecutorService 现在是 AutoCloseable
// ---------- 创建方式 4:ThreadFactory ----------
ThreadFactory factory = Thread.ofVirtual().name("worker-", 0).factory();
Thread t3 = factory.newThread(() -> System.out.println("vt-3"));
t3.start();
t3.join();
// ---------- 百万级并发的直观演示(模拟 I/O 阻塞) ----------
long start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(100)); // 阻塞时自动让出载体线程
return null;
});
}
}
System.out.println("100k 任务耗时 " + (System.currentTimeMillis() - start) + " ms");
}
}4. 使用虚拟线程需要注意什么?(高频追问)
答: 有八条「军规」:
① 不要池化
// ❌ 错误:虚拟线程不需要池化,池化反而丧失了「一任务一线程」的模型优势
ExecutorService wrong = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// ✅ 正确:每个任务一个新虚拟线程
ExecutorService right = Executors.newVirtualThreadPerTaskExecutor();② 限制并发要用信号量,而不是线程池
虚拟线程廉价不代表下游资源无限(数据库连接池只有 20 个连接,开 100 万个虚拟线程只会把 DB 打挂)。
private static final Semaphore DB_PERMITS = new Semaphore(20); // 对齐连接池大小
String query(String sql) throws InterruptedException {
DB_PERMITS.acquire();
try {
return jdbcTemplate.queryForObject(sql, String.class); // 阻塞时让出载体线程
} finally {
DB_PERMITS.release();
}
}③ 适合 I/O 密集型,不适合 CPU 密集型
- ✅ HTTP/RPC 调用、数据库访问、消息收发、文件 I/O、
Thread.sleep; - ❌ 大数组计算、加解密、图像处理——这类任务直接
ForkJoinPool/ 平台线程池更合适,还能避免占用载体线程。
④ synchronized 的 pinning 问题(21~23 与 24 的分水岭)
- JDK 21 ~ 23:虚拟线程在
synchronized块中阻塞时会被固定在(pin)载体线程上,无法卸载——如果此时载体线程极少或被占满,整个应用的吞吐会塌陷。规避方式是把synchronized换成ReentrantLock。 - JDK 24(JEP 491):JVM 已让虚拟线程在
synchronized中阻塞时也能正常卸载载体线程,现有代码无需修改即可受益。所以「虚拟线程不能用synchronized」在 JDK 24+ 已经是过时结论,面试时能说出这一点是明显的加分项。
// JDK 21~23 的推荐写法:用 ReentrantLock 替代 synchronized,避免 pinning
private final ReentrantLock lock = new ReentrantLock();
void doWork() throws InterruptedException {
lock.lock();
try {
blockingIo(); // 这里阻塞时不会 pin 载体线程
} finally {
lock.unlock();
}
}⑤ ThreadLocal 可用,但要克制
每个虚拟线程都持有自己的 ThreadLocalMap。百万虚拟线程 × 每个几 KB 的 ThreadLocal 就是 GB 级内存;而且 ThreadLocal 是可变、双向可见的,容易在父子线程间泄漏。Java 21 的 ScopedValue(Java 25 转正)就是为「大量虚拟线程下共享不可变上下文」设计的,推荐作为替代(见《Java新特性(六)》)。
⑥ 不是所有阻塞都能让出载体线程:Object.wait、synchronized(21~23)等少数场景会 pin;而 Thread.sleep、Socket I/O、java.util.concurrent 的阻塞队列等都是可卸载的。
⑦ 线程局部状态不要依赖 threadId:虚拟线程的 getId() 返回的是无意义的序号,ThreadGroup 也不适用于虚拟线程。
⑧ 不要用 Thread.stop/ThreadLocal 传递请求上下文:Web 框架(Spring Boot 3.2+ 的 spring.threads.virtual.enabled=true、Tomcat 的 virtual thread executor)已经适配,业务侧建议用显式参数或 ScopedValue。
5. 虚拟线程还需要线程池吗?一句话怎么回答?
答: 不需要,但需要限流。
- 「不需要池」是因为虚拟线程创建成本极低,池化只会限制并发度、浪费其最大优势;
- 「需要限流」是因为下游资源(DB 连接、HTTP 连接、文件句柄、CPU)是有限的,应该用
Semaphore、连接池自身容量或「固定大小平台线程池作为信号量」来控制。 - Spring Boot 实践:配置
spring.threads.virtual.enabled=true后,Tomcat/Jetty 用虚拟线程处理请求,HikariCP 依然限制 DB 并发,二者配合才是正确姿势。
6. 结构化并发(Structured Concurrency)解决了什么?
答: 传统并发里,一个方法里 fork 出去的子任务与父方法没有生命周期关系:父方法抛异常了子任务还在跑、子任务异常了父方法不知道、线程泄漏难以排查。结构化并发(JEP 453,Java 21 首次预览)把「一组子任务」当成一个工作单元——子任务必须在父任务结束前结束,由此获得:
- 错误传播:任一子任务失败,其余子任务被自动取消并抛出统一异常;
- 可观测性:线程转储能清楚看到任务的父子层级(不像传统线程池是一堆无关联线程);
- 无泄漏:作用域关闭即资源回收。
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
// 需要 --enable-preview(Java 21 为预览特性)
record User(String name, int orders) { }
public class StructuredConcurrencyDemo {
public static void main(String[] args) throws Exception {
System.out.println(handle());
}
static User handle() throws Exception {
// ShutdownOnFailure:任一子任务失败 → 取消其余 → 抛出异常
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> userTask = scope.fork(
StructuredConcurrencyDemo::findUser); // 子任务 1
Subtask<Integer> orderTask = scope.fork(
StructuredConcurrencyDemo::countOrders); // 子任务 2
scope.join() // 等待所有子任务结束
.throwIfFailed(); // 任一失败则抛出(异常会被聚合,保留原始栈)
return new User(userTask.get(), orderTask.get());
}
// 走出 try:作用域自动关闭,所有子任务必然已结束——不会有线程泄漏
}
static String findUser() throws InterruptedException { Thread.sleep(100); return "张三"; }
static int countOrders() throws InterruptedException { Thread.sleep(80); return 3; }
}其他 Joiner 策略:
ShutdownOnSuccess(Java 21)/anySuccessfulOrThrow(Java 25):竞速取最快,其余取消,适合「多个镜像源/多个数据源谁先返回用谁」;Joiner.onTimeout(Java 26 加入超时支持):超时后返回默认值。
三、Java 21 的其他必会特性
7. 模式匹配 switch 与 record 模式(双双转正)
答: 这是 Java 21 语言层面最重要的变化(JEP 440 + JEP 441)。
sealed interface Shape permits Circle, Rectangle, Triangle { }
record Circle(double r) implements Shape { }
record Rectangle(double w, double h) implements Shape { }
record Triangle(double base, double height) implements Shape { }
public class PatternSwitchDemo {
public static void main(String[] args) {
System.out.println(area(new Circle(2)));
System.out.println(describe(null));
System.out.println(describe(42));
}
// 1. 类型模式 + 穷尽性检查(sealed 无需 default)
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.r() * c.r();
case Rectangle r -> r.w() * r.h();
case Triangle t -> 0.5 * t.base() * t.height();
};
}
// 2. record 模式:直接解构嵌套对象
static double area2(Shape shape) {
return switch (shape) {
case Circle(double r) -> Math.PI * r * r;
case Rectangle(double w, double h) -> w * h;
case Triangle(double b, double h) -> 0.5 * b * h;
};
}
// 3. 守卫条件 when + case null(以前 switch 遇 null 直接 NPE)
static String describe(Object obj) {
return switch (obj) {
case null -> "null";
case String s when s.isEmpty() -> "空字符串";
case String s -> "字符串长度 " + s.length();
case Integer i when i > 0 -> "正整数 " + i;
case Integer i -> "非正整数 " + i;
default -> "其它类型";
};
}
// 4. 嵌套解构:一次性拆到最里层
record Point(int x, int y) { }
record Line(Point start, Point end) { }
static String fmt(Object obj) {
return switch (obj) {
case Line(Point(var x1, var y1), Point(var x2, var y2)) ->
"线段 (" + x1 + "," + y1 + ") -> (" + x2 + "," + y2 + ")";
default -> "未知";
};
}
}考点小结:
switch支持null分支(解决历史 NPE 痛点),case null必须显式写出;- 守卫用
when(预览阶段曾用&&,最终定为when); - 穷尽性:sealed 类型或 enum 全覆盖时无需
default,新增子类型会编译期报错——这正是「密封类 + 模式匹配」组合的威力; - 模式标签按顺序匹配,子类型要写在父类型前面,否则编译报错(dominance 检查);
- switch 不再贯穿,无需
break。
8. record 模式:解构
答: record 模式(JEP 440)让 record 的组件可以被直接解构为变量,可嵌套、可与 instanceof 或 switch 搭配:
record Point(int x, int y) { }
Object obj = new Point(3, 4);
// 配合 instanceof
if (obj instanceof Point(int x, int y)) {
System.out.println(x + y); // 7
}
// 配合泛型
record Box<T>(T content) { }
if (obj instanceof Box<String>(String s)) {
System.out.println(s.toUpperCase());
}
// 只想取一部分:未命名模式 _
if (obj instanceof Point(int x, _)) {
System.out.println("x = " + x);
}注意:record 模式不能单独使用,必须作为 instanceof 或 switch 的模式出现;它解构的是访问器返回值,因此如果访问器被重写,解构结果也随之改变。
9. Sequenced Collections(JEP 431)
答: 解决了 Java 集合「首尾操作接口不统一」的历史遗留问题。过去取首元素有 list.get(0)、deque.getFirst()、sortedSet.first()、linkedHashMap 得靠迭代器……完全没有统一抽象。Java 21 引入三个接口:
| 接口 | 继承关系 | 关键方法 |
|---|---|---|
SequencedCollection<E> | Collection 的子接口 | addFirst、addLast、getFirst、getLast、removeFirst、removeLast、reversed() |
SequencedSet<E> | SequencedCollection + Set | 同上,reversed() 返回 SequencedSet |
SequencedMap<K,V> | Map 的子接口 | firstEntry、lastEntry、pollFirstEntry、pollLastEntry、putFirst、putLast、reversed() |
实现类:ArrayList、LinkedList、ArrayDeque、LinkedHashSet、LinkedHashMap、TreeSet、TreeMap、List 系等(HashSet/HashMap 无序,不实现)。
import java.util.*;
public class SequencedDemo {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
list.addFirst(0); // 以前要 list.add(0, 0)
list.addLast(4);
System.out.println(list.getFirst()); // 0
System.out.println(list.getLast()); // 4
System.out.println(list.reversed()); // [4, 3, 2, 1, 0]:反向视图,不产生拷贝
LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
map.put("a", 1);
map.put("b", 2);
System.out.println(map.firstEntry()); // a=1
System.out.println(map.lastEntry()); // b=2
map.reversed().forEach((k, v) -> System.out.println(k + "=" + v)); // b=2 a=1
}
}易错点:
reversed()返回的是视图(对原集合的修改会反映到视图上),不是拷贝;对无序集合(HashSet)调用reversed()会抛UnsupportedOperationException。
10. 分代 ZGC(JEP 439)
答: 传统 ZGC 是「全堆并发回收」,不区分新生代/老年代,导致短命对象混杂其中,吞吐量偏低。分代 ZGC 引入年轻代/老年代分代回收,能更频繁、更廉价地回收「朝生夕死」的对象,在保持亚毫秒停顿的同时大幅提升吞吐(官方基准中提升常在 10% ~ 40% 量级,视应用而定)。
# Java 21 开启分代 ZGC
java -XX:+UseZGC -XX:+ZGenerational -jar app.jar
# Java 23 起分代模式成为默认(JEP 474),非分代模式被弃用
java -XX:+UseZGC -jar app.jar延伸对比(GC 这张表面试常考):
| GC | 关注点 | 典型停顿 | 适用场景 |
|---|---|---|---|
| Parallel | 吞吐量优先 | 数百 ms ~ 秒 | 批处理、离线计算 |
| CMS(14 已移除) | 低延迟(已淘汰) | 数十 ms | 历史 |
| G1(9+ 默认) | 可预测停顿 + 吞吐平衡 | 数十 ~ 200ms(可设 -XX:MaxGCPauseMillis) | 通用 Web 服务 |
| Shenandoah(15 转正) | 低延迟,与堆大小无关 | < 10ms 级 | 大堆、低延迟 |
| ZGC(15 转正,21 分代) | 超低延迟 + 大堆 | 亚毫秒 | 超大堆(TB 级)、金融/实时 |
| Epsilon | 不回收 | —— | 性能测试、短命进程 |
11. Java 21 的其他要点
答:
- 未命名模式与变量(JEP 443,预览):用
_表示「不需要这个变量」,出现在 catch、lambda、for、模式中:
try { ... } catch (Exception _) { } // 不关心异常对象
list.forEach(_ -> System.out.println("tick")); // 不关心元素
if (obj instanceof Point(int x, _)) { } // 只解构 x- 字符串模板(JEP 430,预览,后续在 Java 23 被移除并重新设计):
STR."Hello \{name}"语法,最终因设计争议未在 21/22/23 转正,面试若问到,回答「该特性已被重新设计(String Templates 在 JDK 23 后回炉),当前更推荐formatted或模板引擎」即可。 - 虚拟线程转正(JEP 444):见前文。
- KEM API(JEP 452):密钥封装机制 API,为后量子密码迁移打基础。
- 未命名类与实例 main 方法(JEP 445,预览):为初学者简化
public static void main,Java 25 转正。 - 动态加载代理警告(JEP 451):运行时动态
attach的 agent 会打印警告(为将来默认禁止做准备,常见于 Arthas、SkyWalking 等)。 - 弃用 32 位 Windows 端口(JEP 449)。
- FFM API 第三次预览(JEP 442)、Vector API 第六次孵化(JEP 448)。
四、小结
| 版本 | 必须记住 |
|---|---|
| Java 18 | 默认 UTF-8(JEP 400)、jwebserver、@snippet、弃用 finalize |
| Java 19 | 虚拟线程首次预览、结构化并发孵化、FFM 预览 |
| Java 20 | Scoped Values 孵化、虚拟线程第二次预览 |
| Java 21 | 虚拟线程转正、模式匹配 switch + record 模式转正、Sequenced Collections、分代 ZGC、未命名变量 _ |
