Java 21 特性:深入了解新 LTS 版本中最重要的变化

自从Java平台采用六个月的发布周期以来,我们已经超越了“Java会在今年死去吗?”或“值得迁移到新版本吗?”等永恒的问题。尽管自首次发布以来已经过去28年,Java依然蓬勃发展,并且仍然是许多新项目的主要编程语言的热门选择。

Java 17是一个重要的里程碑,但Java 21现在已经取代17成为下一个长期支持版本(LTS)。对于Java开发者来说,了解这个版本带来的变化和新特性是至关重要的。在我的同事Darek的启发下,他在文章中详细介绍了Java 17特性,我决定以类似的方式讨论JDK 21(我还在后续文章中分析了Java 23特性,也请查看)。

JDK 21包含总共15个JEP(JDK增强提案)。您可以在官方Java网站上查看完整列表。在本文中,我将强调几个我认为特别值得注意的Java 21 JEP。即:

  1. 字符串模板
  2. 有序集合
  3. 用于 switch 的模式匹配记录模式
  4. 虚拟线程

让我们立即深入代码,探索这些更新。

字符串模板(预览)

Spring 模板功能仍处于预览模式。要使用它,您必须将 --enable-preview 标志添加到编译器参数中。但是,我决定提到它,尽管它仍处于预览状态。为什么呢?因为每次我不得不编写包含许多参数或解密将用给定参数替换的占位符的日志消息或 SQL 语句时,我都会感到非常恼火。而 Spring 模板承诺帮助我(也帮助您)。

正如 JEP 文档所述,Spring 模板的目的是“通过简化在运行时计算值的字符串的编写,使得编写 Java 程序变得容易”。

让我们看看它是否真的更简单。

“旧方法”是在 String 对象上使用 formatted() 方法:

var msg = "Log message param1: %s, pram2: %s".formatted(p1, p2);

现在,使用 StringTemplate.Processor(STR),它看起来像这样:

var interpolated = STR."Log message param1: \{p1}, param2: \{p2}";

对于像上面这样的短文本,收益可能并不那么明显 — 但相信我,当涉及大文本块(JSON、SQL 语句等)时,命名参数将帮助您很多。

序列化集合

Java 21 引入了新的 Java 集合层次结构。请查看下面的图表,并将其与您在编程课上学到的内容进行比较。您会注意到有三个新的结构被添加进来(以绿色突出显示)。

图片来源: JEP 431

顺序集合引入了一个新的内置 Java API,增强了对有序数据集的操作。该 API 不仅允许方便地访问集合的第一个和最后一个元素,还支持高效的遍历、在特定位置插入以及检索子序列。这些增强使依赖于元素顺序的操作变得更加简单和直观,提高了在处理列表和类似数据结构时的性能和代码可读性。

这是 SequencedCollection 接口的完整列表:

Java

 

public interface SequencedCollection<E> extends Collection<E> {
   SequencedCollection<E> reversed();
   default void addFirst(E e) {
       throw new UnsupportedOperationException();
   }
   default void addLast(E e) {
       throw new UnsupportedOperationException();
   }
   default E getFirst() {
       return this.iterator().next();
   }
   default E getLast() {
       return this.reversed().iterator().next();
   }
   default E removeFirst() {
       var it = this.iterator();
       E e = it.next();
       it.remove();
       return e;
   }
   default E removeLast() {
       var it = this.reversed().iterator();
       E e = it.next();
       it.remove();
       return e;
   }
}

所以,现在,不再是:

Java

 

var first = myList.stream().findFirst().get();
var anotherFirst = myList.get(0);
var last = myList.get(myList.size() - 1);

我们可以直接写:

Java

 

var first = sequencedCollection.getFirst();
var last = sequencedCollection.getLast();
var reversed = sequencedCollection.reversed();

这是一个小变化,但在我看来,这是一个如此方便和实用的特性。

模式匹配和记录模式

由于switch和记录模式的相似性,我将它们一起描述。记录模式是一个新特性:它们在Java 19中被引入(作为预览)。另一方面,switch的模式匹配有点像扩展的instanceof表达式的延续。它为switch语句带来了新的可能语法,让您更轻松地表达复杂的面向数据的查询。

为了这个例子的缘故,让我们忘掉面向对象编程的基础,手动拆解员工对象(employee是一个POJO类)。

在Java 21之前,它看起来像这样:

Java

 

if (employee instanceof Manager e) {
   System.out.printf("I’m dealing with manager of %s department%n", e.department);
} else if (employee instanceof Engineer e) {
   System.out.printf("I’m dealing with %s engineer.%n", e.speciality);
} else {
   throw new IllegalStateException("Unexpected value: " + employee);
}

如果我们能摆脱那个丑陋的instanceof会怎样?现在我们可以,多亏了Java 21的模式匹配的强大功能:

Java

 

switch (employee) {
   case Manager m -> printf("Manager of %s department%n", m.department);
   case Engineer e -> printf("I%s engineer.%n", e.speciality);
   default -> throw new IllegalStateException("Unexpected value: " + employee);
}

在谈论switch语句时,我们还可以讨论记录模式功能。当处理Java记录时,它允许我们做比标准Java类更多的事情:

Java

 

switch (shape) { // shape is a record
   case Rectangle(int a, int b) -> System.out.printf("Area of rectangle [%d, %d] is: %d.%n", a, b, shape.calculateArea());
   case Square(int a) -> System.out.printf("Area of square [%d] is: %d.%n", a, shape.calculateArea());
   default -> throw new IllegalStateException("Unexpected value: " + shape);
}

正如代码所示,使用该语法,记录字段很容易访问。此外,我们可以在case语句中加入一些额外的逻辑:

Java

 

switch (shape) {
   case Rectangle(int a, int b) when a < 0 || b < 0 -> System.out.printf("Incorrect values for rectangle [%d, %d].%n", a, b);
   case Square(int a) when a < 0 -> System.out.printf("Incorrect values for square [%d].%n", a);
   default -> System.out.println("Created shape is correct.%n");
}

我们可以使用类似的语法来处理if语句。此外,在下面的示例中,我们可以看到记录模式也适用于嵌套记录:

Java

 

if (r instanceof Rectangle(ColoredPoint(Point p, Color c),
                          ColoredPoint lr)) {
   //sth
}

虚拟线程

虚拟线程特性可能是Java 21中最受欢迎的功能之一——或者至少是Java开发者们等待时间最长的功能之一。正如JEP文档(在前一句中链接)所述,虚拟线程的目标之一是“使以简单的每个请求一个线程风格编写的服务器应用程序能够以接近最佳的硬件利用率进行扩展”。然而,这是否意味着我们应该迁移我们所有使用java.lang.Thread的代码?

首先,让我们来看看Java 21之前(实际上,自Java首次发布以来)的做法存在的问题。我们可以近似认为一个java.lang.Thread消耗(根据操作系统和配置)大约2到8 MB的内存。然而,这里重要的是,一个Java线程与一个内核线程是1:1映射的。对于使用“每个请求一个线程”方法的简单Web应用程序,我们可以轻松计算出,当流量增加时,要么我们的机器会被“击垮”(无法处理负载),要么我们会被迫购买一台内存更大的设备,因此我们的AWS账单也会随之增加。

当然,虚拟线程并不是解决这个问题的唯一方法。我们还有异步编程(如WebFlux这样的框架或像CompletableFuture这样的原生Java API)。然而,由于某种原因——也许是因为“用户不友好的API”或较高的入门门槛——这些解决方案并不是那么受欢迎。

虚拟线程并不由操作系统管理或调度,而是由JVM处理其调度。虽然真实任务必须在平台线程中执行,但JVM使用所谓的承载线程——本质上是平台线程——在虚拟线程准备执行时“承载”它们。虚拟线程被设计为轻量级,占用的内存远少于标准平台线程。

下面的图示显示了虚拟线程如何与平台线程和操作系统线程连接:

因此,为了查看虚拟线程如何被平台线程使用,让我们运行代码来启动(1 + 机器的CPU数量,在我的情况下是8个核心)虚拟线程。

Java

 

var numberOfCores = 8; //
final ThreadFactory factory = Thread.ofVirtual().name("vt-", 0).factory();
try (var executor = Executors.newThreadPerTaskExecutor(factory)) {
   IntStream.range(0, numberOfCores + 1)
           .forEach(i -> executor.submit(() -> {
               var thread = Thread.currentThread();
               System.out.println(STR."[\{thread}]  VT number: \{i}");
               try {
                   sleep(Duration.ofSeconds(1L));
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
           }));
}

输出看起来像这样:

Plain Text

 

[VirtualThread[#29,vt-6]/runnable@ForkJoinPool-1-worker-7]  VT number: 6
[VirtualThread[#26,vt-4]/runnable@ForkJoinPool-1-worker-5]  VT number: 4
[VirtualThread[#30,vt-7]/runnable@ForkJoinPool-1-worker-8]  VT number: 7
[VirtualThread[#24,vt-2]/runnable@ForkJoinPool-1-worker-3]  VT number: 2
[VirtualThread[#23,vt-1]/runnable@ForkJoinPool-1-worker-2]  VT number: 1
[VirtualThread[#27,vt-5]/runnable@ForkJoinPool-1-worker-6]  VT number: 5
[VirtualThread[#31,vt-8]/runnable@ForkJoinPool-1-worker-6]  VT number: 8
[VirtualThread[#25,vt-3]/runnable@ForkJoinPool-1-worker-4]  VT number: 3
[VirtualThread[#21,vt-0]/runnable@ForkJoinPool-1-worker-1]  VT number: 0

因此,ForkJoinPool-1-worker-X 平台线程是我们的承载线程,管理着我们的虚拟线程。我们观察到虚拟线程5和8使用的是同一个承载线程编号6。

我想向您展示的关于虚拟线程的最后一件事是,它们如何帮助您处理阻塞的I/O操作。

每当虚拟线程遇到阻塞操作,如I/O任务,JVM会有效地将其从底层物理线程(承载线程)中分离出来。这种分离至关重要,因为它释放了承载线程,使其能够运行其他虚拟线程,而不是闲置等待阻塞操作完成。因此,单个承载线程可以多路复用多个虚拟线程,数量可以达到数千甚至数百万,具体取决于可用内存和执行任务的性质。

让我们尝试模拟这种行为。为此,我们将强制代码仅使用一个 CPU 核心,并且只有 2 个虚拟线程——以便更好地清晰度。

Java

 

System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");
System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", "1");
System.setProperty("jdk.virtualThreadScheduler.minRunnable", "1");

线程 1:

Java

 

Thread v1 = Thread.ofVirtual().name("long-running-thread").start(
       () -> {
           var thread = Thread.currentThread();
           while (true) {
               try {
                   Thread.sleep(250L);
                   System.out.println(STR."[\{thread}] - Handling http request ....");
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
           }
       }
);

线程 2:

Java

 

Thread v2 = Thread.ofVirtual().name("entertainment-thread").start(
       () -> {
           try {
               Thread.sleep(1000L);
           } catch (InterruptedException e) {
               throw new RuntimeException(e);
           }
           var thread = Thread.currentThread();
           System.out.println(STR."[\{thread}] - Executing when 'http-thread' hit 'sleep' function");
       }
);

执行:

Java

 

v1.join(); v2.join();

结果:

Plain Text

 

[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#23,entertainment-thread]/runnable@ForkJoinPool-1-worker-1] - Executing when 'http-thread' hit 'sleep' function
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....
[VirtualThread[#21,long-running-thread]/runnable@ForkJoinPool-1-worker-1] - Handling http request ....

我们观察到两个虚拟线程(long-running-threadentertainment-thread)仅由一个平台线程承载,即 ForkJoinPool-1-worker-1

总结来说,这种模型使得 Java 应用可以以远低于传统线程模型的开销实现高水平的并发和可扩展性,其中每个线程直接映射到一个操作系统线程。值得注意的是,虚拟线程是一个庞大的主题,我所描述的只是其中的一小部分。我强烈建议您进一步了解调度、固定线程和虚拟线程的内部机制。

总结:Java 编程语言的未来

上述特性是我认为 Java 21 中最重要的特性。它们中的大多数并不像 JDK 17 中引入的一些功能那样突破性,但它们仍然非常实用,并且是很好的生活质量(QOL)改进。

然而,你也不应忽视其他 JDK 21 的改进 — 我强烈建议你分析完整的列表,并进一步探索所有功能。例如,我认为特别值得注意的是矢量 API,它允许在一些支持的 CPU 架构上进行矢量计算 — 这在以前是不可能的。目前,它仍处于孵化器状态/实验阶段(这也是为什么我在这里没有更详细地强调它),但它对 Java 的未来充满了希望。

总的来说,Java 在各个领域取得的进步表明团队致力于提高高需求应用程序的效率和性能。

Source:
https://dzone.com/articles/java-21-features-a-detailed-look