当前位置:Java -> Java 17与Java 21之间的新特性是什么?

Java 17与Java 21之间的新特性是什么?

2023年9月19日,发布了Java 21。是时候仔细看一下自上一个LTS版本Java 17以来的变化了。在这篇博客中,通过一些示例,重点介绍了Java 17和Java 21之间的一些变化。尽情享受吧!

介绍

首先,简短的介绍并不完全正确,因为Java 21在一句话中与LTS版本提到。Nicolai Parlog在 这篇博客 中做出了详细的解释。简言之,Java 21是一组规范,定义了Java语言、API、虚拟机等的行为。Java 21的参考实现由 OpenJDK 实现。参考实现的更新在此OpenJDK存储库中进行。发布后,会创建一个jdk21u的分支。这个jdk21u分支是维护的,将会比常规的6个月发布频率获得更长时间的更新。即便有了jdk21u,也不能保证会在更长的时间段内进行修复。这就是不同供应商的实现有所不同的地方。他们会构建自己的JDK,并提供免费支持。因此,最好说“JDK21是一个版本,许多供应商提供支持。”

Java 17和Java 21之间有什么变化?可以在 OpenJDK 网站找到JEPs(Java Enhancement Proposals)的完整列表。在这里,你可以阅读每个JEP的详细细节。要了解自Java 17以来每个版本的完整变化列表,可以在 Oracle发行说明 中找到很好的概述。

在接下来的章节中,通过示例解释了一些变化,但主要还是要你自己去尝试这些新特性,以便熟悉它们。请注意,这里没有考虑预览或孵化器JEP。本文中使用的源代码可在 GitHub 上找到。

如果你想知道Java 11和Java 17之间有哪些变化,可以查看 之前的博客

在介绍的最后,还要提一下有一个 Java沙箱 可供使用,你可以在其中尝试使用Java 。

先决条件

本博客的先决条件是:

  • 必须安装JDK21;
  • 需要一些基本的Java知识。

JEP444:虚拟线程

让我们从JDK 21中最重要的新功能开始:虚拟线程。虚拟线程是轻量级线程,极大地减少了编写、维护和观察高吞吐量并发应用程序的工作量。到目前为止,线程是作为操作系统(OS)线程的包装器实现的。OS线程是昂贵的,如果你向另一个服务器发送http请求,你将阻塞此线程,直到你收到服务器的响应。处理部分(创建请求和处理响应)只是线程被阻塞的整个时间中的一个小部分。发送请求并等待响应要耗费比处理部分要多得多的时间。一种规避的方法是使用异步风格。这种方法的缺点是实现更复杂。这就是虚拟线程派上用场的地方。你可以像以前一样保持实现简单,并仍然具有异步风格的可伸缩性。

Java应用程序 PlatformThreads.java 展示了同时创建1,000、10,000、100,000和1,000,000线程时所发生的情况。这些线程只等待一秒。根据你的机器不同,你将得到不同的结果,因为这些线程绑定到了OS线程。

public class PlatformThreads {
 
    public static void main(String[] args) {
        testPlatformThreads(1000);
        testPlatformThreads(10_000);
        testPlatformThreads(100_000);
        testPlatformThreads(1_000_000);
    }
 
    private static void testPlatformThreads(int maximum) {
        long time = System.currentTimeMillis();
 
        try (var executor = Executors.newCachedThreadPool()) {
            IntStream.range(0, maximum).forEach(i -> {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofSeconds(1));
                    return i;
                });
            });
        }
 
        time = System.currentTimeMillis() - time;
        System.out.println("Number of threads = " + maximum + ", Duration(ms) = " + time);
    }
 
}


运行该应用程序的输出如下:

Number of threads = 1000, Duration(ms) = 1094
Number of threads = 10000, Duration(ms) = 1625
Number of threads = 100000, Duration(ms) = 5292
[21,945s][warning][os,thread] Attempt to protect stack guard pages failed (0x00007f8525d00000-0x00007f8525d04000).
#
# A fatal error has been detected by the Java Runtime Environment:
# Native memory allocation (mprotect) failed to protect 16384 bytes for memory to guard stack pages
# An error report file with more information is saved as:
# /home/<user_dir>/MyJava21Planet/hs_err_pid8277.log
[21,945s][warning][os,thread] Attempt to protect stack guard pages failed (0x00007f8525c00000-0x00007f8525c04000).
[thread 82370 also had an error]
[thread 82371 also had an error]
[21,946s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 1024k, guardsize: 0k, detached.
[21,946s][warning][os,thread] Failed to start the native thread for java.lang.Thread "pool-4-thread-32577"
...


你在这里看到了什么?应用程序对于1,000个线程需要约 1 秒,对于10,000个线程需要约1.6秒,对于100,000个线程需要约5.3秒,当线程数达到1,000,000时会崩溃。在我的机器上,OS线程的最大数目界限在100,000和1,000,000线程之间。

通过用 Executors.newVirtualThreadPerTaskExecutor 替换 Executors.newCachedThreadPool 来改变应用程序(VirtualThreads.java)。

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, maximum).forEach(i -> {
                executor.submit(() -> {
                    Thread.sleep(Duration.ofSeconds(1));
                    return i;
                });
            });
        }


再次运行应用程序。输出如下:

Number of threads = 1000, Duration(ms) = 1020
Number of threads = 10000, Duration(ms) = 1056
Number of threads = 100000, Duration(ms) = 1106
Number of threads = 1000000, Duration(ms) = 1806
Number of threads = 10000000, Duration(ms) = 22010


该应用程序对于1,000个线程需要约1秒(与OS线程相似),对于10,000个线程需要约1秒(优于OS线程),对于100,000个线程需要约1.1秒(也更好),对于1,000,000个线程需要约1.8秒(不会崩溃),甚至1,000,000个线程也没有问题,执行大约需要22秒。这相当令人惊人和不可思议,对吧?

JEP431:序列化集合

序列化集合填补了表示具有定义的遇到顺序的元素序列的集合类型的缺失。除此之外,缺乏应用这些集合的统一操作。社区对这个问题的投诉相当多,现在引入了一些新的集合接口来解决这个问题。基于Stuart Marks 创建的 概述,以下是可用的概览。

顺序图

除了新增的接口之外,现在还提供了一些不可修改的包装器。

Collections.unmodifiableSequencedCollection(sequencedCollection)
Collections.unmodifiableSequencedSet(sequencedSet)
Collections.unmodifiableSequencedMap(sequencedMap)


接下来的章节将基于应用程序SequencedCollections.java展示这些新接口。

SequencedCollection

序列集合是具有预定义遇到顺序的集合。新接口SequencedCollection如下:

interface SequencedCollection<E> extends Collection<E> {
    // new method
    SequencedCollection<E> reversed();
    // methods promoted from Deque
    void addFirst(E);
    void addLast(E);
    E getFirst();
    E getLast();
    E removeFirst();
    E removeLast();
}


在以下示例中,创建了一个列表并对其进行了反转。检索了第一个和最后一个项目,并添加了一个新的第一个和最后一个项目。

private static void sequencedCollection() {
    List<String> sc = Stream.of("Alpha", "Bravo", "Charlie", "Delta").collect(Collectors.toCollection(ArrayList::new));
    System.out.println("Initial list: " + sc);
    System.out.println("Reversed list: " + sc.reversed());
    System.out.println("First item: " + sc.getFirst());
    System.out.println("Last item: " + sc.getLast());
    sc.addFirst("Before Alpha");
    sc.addLast("After Delta");
    System.out.println("Added new first and last item: " + sc);
}


输出为:

Initial list: [Alpha, Bravo, Charlie, Delta]
Reversed list: [Delta, Charlie, Bravo, Alpha]
First item: Alpha
Last item: Delta
Added new first and last item: [Before Alpha, Alpha, Bravo, Charlie, Delta, After Delta]


正如你所见,这里没有什么意外,它只是起作用。

SequencedSet

序列集是一个包含不重复元素的SequencedCollection的集。新接口如下:

interface SequencedSet<E> extends Set<E>, SequencedCollection<E> {
    SequencedSet<E> reversed();    // covariant override
}


在以下示例中,创建了一个SortedSet并对其进行了反转。检索了第一个和最后一个项目,并尝试添加新的第一个和最后一个项目。

private static void sequencedSet() {
    SortedSet<String> sortedSet = new TreeSet<>(Set.of("Charlie", "Alpha", "Delta", "Bravo"));
    System.out.println("Initial list: " + sortedSet);
    System.out.println("Reversed list: " + sortedSet.reversed());
    System.out.println("First item: " + sortedSet.getFirst());
    System.out.println("Last item: " + sortedSet.getLast());
    try {
        sortedSet.addFirst("Before Alpha");
    } catch (UnsupportedOperationException uoe) {
        System.out.println("addFirst is not supported");
    }
    try {
        sortedSet.addLast("After Delta");
    } catch (UnsupportedOperationException uoe) {
        System.out.println("addLast is not supported");
    }
}


输出为:

Initial list: [Alpha, Bravo, Charlie, Delta]
Reversed list: [Delta, Charlie, Bravo, Alpha]
First item: Alpha
Last item: Delta
addFirst is not supported
addLast is not supported


SequencedCollection的唯一区别是,初始列表中的元素按字母顺序排序,并且不支持addFirstaddLast方法。这是显而易见的,因为当将元素添加到列表时,无法保证第一个元素仍将保持为第一个元素(它将再次被排序)。

SequencedMap

序列映射是具有定义遇到顺序的条目的映射。新接口如下:

interface SequencedMap<K,V> extends Map<K,V> {
    // new methods
    SequencedMap<K,V> reversed();
    SequencedSet<K> sequencedKeySet();
    SequencedCollection<V> sequencedValues();
    SequencedSet<Entry<K,V>> sequencedEntrySet();
    V putFirst(K, V);
    V putLast(K, V);
    // methods promoted from NavigableMap
    Entry<K, V> firstEntry();
    Entry<K, V> lastEntry();
    Entry<K, V> pollFirstEntry();
    Entry<K, V> pollLastEntry();
}


在以下示例中,创建了一个LinkedHashMap,并添加了一些元素并反转了列表。检索了第一个和最后一个元素,并添加了新的第一个和最后一个项目。

private static void sequencedMap() {
    LinkedHashMap<Integer,String> hm = new LinkedHashMap<Integer,String>();
    hm.put(1, "Alpha");
    hm.put(2, "Bravo");
    hm.put(3, "Charlie");
    hm.put(4, "Delta");
    System.out.println("== Initial List ==");
    printMap(hm);
    System.out.println("== Reversed List ==");
    printMap(hm.reversed());
    System.out.println("First item: " + hm.firstEntry());
    System.out.println("Last item: " + hm.lastEntry());
    System.out.println(" == Added new first and last item ==");
    hm.putFirst(5, "Before Alpha");
    hm.putLast(3, "After Delta");
    printMap(hm);
}


输出为:

== Initial List ==
1 Alpha
2 Bravo
3 Charlie
4 Delta
== Reversed List ==
4 Delta
3 Charlie
2 Bravo
1 Alpha
First item: 1=Alpha
Last item: 4=Delta
 == Added new first and last item ==
5 Before Alpha
1 Alpha
2 Bravo
4 Delta
3 After Delta


这里也没有什么意外。

JEP440:记录模式

记录模式增强了Java编程语言,以便解构记录值。这将使浏览数据更加方便。让我们看看如何在应用程序RecordPatterns.java中使用它。

假设以下GrapeRecord由颜色和核数组成。

record GrapeRecord(Color color, Integer nbrOfPits) {}


当你需要访问核数时,你必须隐式地转换GrapeRecord,然后可以使用grape变量访问成员。

private static void singleRecordPatternOldStyle() {
    Object o = new GrapeRecord(Color.BLUE, 2);
    if (o instanceof GrapeRecord grape) {
        System.out.println("This grape has " + grape.nbrOfPits() + " pits.");
    }
}


有了记录模式,你可以将记录成员作为instanceof检查的一部分,并直接访问它们。

private static void singleRecordPattern() {
    Object o = new GrapeRecord(Color.BLUE, 2);
    if (o instanceof GrapeRecord(Color color, Integer nbrOfPits)) {
        System.out.println("This grape has " + nbrOfPits + " pits.");
    }
}


引入一个记录SpecialGrapeRecord,它由一个记录GrapeRecord和一个布尔值组成。

record SpecialGrapeRecord(GrapeRecord grape, boolean special) {}


你已经创建了一个嵌套记录。如下例所示,记录模式还支持嵌套记录:


private static void nestedRecordPattern() {
    Object o = new SpecialGrapeRecord(new GrapeRecord(Color.BLUE, 2), true);
    if (o instanceof SpecialGrapeRecord(GrapeRecord grape, boolean special)) {
        System.out.println("This grape has " + grape.nbrOfPits() + " pits.");
    }
    if (o instanceof SpecialGrapeRecord(GrapeRecord(Color color, Integer nbrOfPits), boolean special)) {
        System.out.println("This grape has " + nbrOfPits + " pits.");
    }
}


JEP441:Switch的模式匹配

Java 17引入了instanceof的模式匹配。 switch表达式的模式匹配将允许测试表达式与多个模式匹配。这带来了许多新的有趣的可能性,正如在应用程序PatternMatchingSwitch.java中所示。

模式匹配开关

当你想要验证一个对象是否是特定类型的实例时,你需要编写类似于以下内容:

private static void oldStylePatternMatching(Object obj) {
    if (obj instanceof Integer i) {
        System.out.println("Object is an integer:" + i);
    } else if (obj instanceof String s) {
        System.out.println("Object is a string:" + s);
    } else if (obj instanceof FruitType f) {
        System.out.println("Object is a fruit: " + f);
    } else {
        System.out.println("Object is not recognized");
    }
}


private static void patternMatchingSwitch(Object obj) {
    switch(obj) {
        case Integer i   -> System.out.println("Object is an integer:" + i);
        case String s    -> System.out.println("Object is a string:" + s);
        case FruitType f -> System.out.println("Object is a fruit: " + f);
        default -> System.out.println("Object is not recognized");
    }
}


开关和空值

null,将抛出NullPointerException。因此,在评估switch表达式之前,您需要检查null值。下面的代码使用了switch的模式匹配,但如果objnull,则会抛出NullPointerException
private static void oldStyleSwitchNull(Object obj) {
    try {
        switch (obj) {
            case Integer i -> System.out.println("Object is an integer:" + i);
            case String s -> System.out.println("Object is a string:" + s);
            case FruitType f -> System.out.println("Object is a fruit: " + f);
            default -> System.out.println("Object is not recognized");
        }
    } catch (NullPointerException npe) {
        System.out.println("NullPointerException thrown");
    }
}


null进行测试,并在switch中确定当值为null时应该做什么。
private static void switchNull(Object obj) {
    switch (obj) {
        case Integer i -> System.out.println("Object is an integer:" + i);
        case String s -> System.out.println("Object is a string:" + s);
        case FruitType f -> System.out.println("Object is a fruit: " + f);
        case null -> System.out.println("Object is null");
        default -> System.out.println("Object is not recognized");
    }
}


Case精化

FruitType添加额外检查,这将导致额外的if语句来确定应该做什么。
private static void inefficientCaseRefinement(Object obj) {
    switch (obj) {
        case String s -> System.out.println("Object is a string:" + s);
        case FruitType f -> {
            if (f == FruitType.APPLE) {
                System.out.println("Object is an apple");
            }
            if (f == FruitType.AVOCADO) {
                System.out.println("Object is an avocado");
            }
            if (f == FruitType.PEAR) {
                System.out.println("Object is a pear");
            }
            if (f == FruitType.ORANGE) {
                System.out.println("Object is an orange");
            }
        }
        case null -> System.out.println("Object is null");
        default -> System.out.println("Object is not recognized");
    }
}


受保护的case标签,布尔表达式称为保护程序。以上代码将变为以下代码,更加可读。
private static void caseRefinement(Object obj) {
    switch (obj) {
        case String s -> System.out.println("Object is a string:" + s);
        case FruitType f when (f == FruitType.APPLE) -> {
            System.out.println("Object is an apple");
        }
        case FruitType f when (f == FruitType.AVOCADO) -> {
            System.out.println("Object is an avocado");
        }
        case FruitType f when (f == FruitType.PEAR) -> {
            System.out.println("Object is a pear");
        }
        case FruitType f when (f == FruitType.ORANGE) -> {
            System.out.println("Object is an orange");
        }
        case null -> System.out.println("Object is null");
        default -> System.out.println("Object is not recognized");
    }
}


枚举常量

引入一个新的枚举CarType

public enum CarType { SUV, CABRIO, EV
}


private static void inefficientEnumConstants(Object obj) {
    switch (obj) {
        case String s -> System.out.println("Object is a string:" + s);
        case FruitType f when (f == FruitType.APPLE) -> System.out.println("Object is an apple");
        case FruitType f when (f == FruitType.AVOCADO) -> System.out.println("Object is an avocado");
        case FruitType f when (f == FruitType.PEAR) -> System.out.println("Object is a pear");
        case FruitType f when (f == FruitType.ORANGE) -> System.out.println("Object is an orange");
        case CarType c when (c == CarType.CABRIO) -> System.out.println("Object is a cabrio");
        case null -> System.out.println("Object is null");
        default -> System.out.println("Object is not recognized");
    }
}


private static void enumConstants(Object obj) {
    switch (obj) {
        case String s -> System.out.println("Object is a string:" + s);
        case FruitType.APPLE -> System.out.println("Object is an apple");
        case FruitType.AVOCADO -> System.out.println("Object is an avocado");
        case FruitType.PEAR -> System.out.println("Object is a pear");
        case FruitType.ORANGE -> System.out.println("Object is an orange");
        case CarType.CABRIO -> System.out.println("Object is a cabrio");
        case null -> System.out.println("Object is null");
        default -> System.out.println("Object is not recognized");
    }
}


JEP413:代码片段

<pre> HTML标签添加的。查看应用程序Snippets.java以获取完整的源代码。
/**
 * this is an example in Java 17
 * <pre>{@code
 *    if (success) {
 *        System.out.println("This is a success!");
 *    } else {
 *        System.out.println("This is a failure");
 *    }
 * }
 * </pre>
 * @param success
 */
public void example1(boolean success) {
    if (success) {
        System.out.println("This is a success!");
    } else {
        System.out.println("This is a failure");
    }
}


$ javadoc src/com/mydeveloperplanet/myjava21planet/Snippets.java -d javadoc


javadoc的目录。使用您喜欢的浏览器打开index.html文件,然后单击代码片段URL。以上代码具有以下javadoc。

example1

这种方法存在一些缺点:

  • 没有源代码验证;
  • 无法添加注释,因为片段已位于注释块中;
  • 没有代码语法高亮显示;
  • 等。

内联片段

@snippet标签。上面的代码可以重写如下。
/**
 * this is an example for inline snippets
 * {@snippet :
 *    if (success) {
 *        System.out.println("This is a success!");
 *    } else {
 *        System.out.println("This is a failure");
 *    }
 * }
 *
 * @param success
 */
public void example2(boolean success) {
    if (success) {
        System.out.println("This is a success!");
    } else {
        System.out.println("This is a failure");
    }
}


example2

你会发现这里的代码片段以源代码的形式可见,并添加了一个复制源代码的图标。 作为额外的测试,你可以在方法的javadoc example1example2中删除一个分号,引入一个编译错误。 在example1中,IDE只接受了这个编译错误。 但是,在example2中,IDE会提示你这个编译错误。

外部代码片段

一个有趣的功能是将代码片段移动到外部文件。 在包com.mydeveloperplanet.myjava21planet中创建一个snippet-files目录。

在这个目录中创建一个SnippetsExternal类,并通过@start标签和@end标签标记代码片段。 使用region参数,你可以为代码片段指定一个名称以供引用。 example4方法还包含@highlight标签,它允许你在代码中突出显示特定的元素。 还有许多其他格式化和高亮选项可用,我们无法一一介绍它们。

public class SnippetsExternal {
 
    public void example3(boolean success) {
        // @start region=example3
        if (success) {
            System.out.println("This is a success!");
        } else {
            System.out.println("This is a failure");
        }
        // @end
    }
 
    public void example4(boolean success) {
        // @start region=example4
        if (success) {
            System.out.println("This is a success!"); // @highlight substring="println"
        } else {
            System.out.println("This is a failure");
        }
        // @end
    }
 
}


在你的代码中,你需要引用SnippetsExternal文件和你想要包含在javadoc中的区域。

/**
 * this is an example for external snippets
 * {@snippet file="SnippetsExternal.java" region="example3" }"
 *
 * @param success
 */
public void example3(boolean success) {
    if (success) {
        System.out.println("This is a success!");
    } else {
        System.out.println("This is a failure");
    }
}
 
/**
 * this is an example for highlighting
 * {@snippet file="SnippetsExternal.java" region="example4" }"
 *
 * @param success
 */
public void example4(boolean success) {
    if (success) {
        System.out.println("This is a success!");
    } else {
        System.out.println("This is a failure");
    }
}


当你像之前一样生成javadoc时,你会注意到在输出中javadoc工具找不到SnippetsExternal文件。

src/com/mydeveloperplanet/myjava21planet/Snippets.java:48: error: file not found on source path or snippet path: SnippetsExternal.java
     * {@snippet file="SnippetsExternal.java" region="example3" }"
                 ^
src/com/mydeveloperplanet/myjava21planet/Snippets.java:62: error: file not found on source path or snippet path: SnippetsExternal.java
     * {@snippet file="SnippetsExternal.java" region="example4" }"


你需要通过--snippet-path参数添加片段文件的路径。

$ javadoc src/com/mydeveloperplanet/myjava21planet/Snippets.java -d javadoc --snippet-path=./src/com/mydeveloperplanet/myjava21planet/snippet-files


方法example3的javadoc包含所定义的片段。

example3

方法example4的javadoc包含了被突出显示的部分。

example4

JEP408: 简单的网络服务器

简单的网络服务器是一个为了为计算机科学学生提供测试或原型开发目的而提供的最小化HTTP服务器。

在存储库的根目录中创建一个httpserver目录,其中包含一个简单的index.html文件。

Welcome to Simple Web Server


你可以按以下方式以编程方式启动网络服务器(参见SimpleWebServer.java)。 目录的路径必须指向目录的绝对路径。

private static void startFileServer() {
    var server = SimpleFileServer.createFileServer(new InetSocketAddress(8080),
            Path.of("/<absolute path>/MyJava21Planet/httpserver"),
            SimpleFileServer.OutputLevel.VERBOSE);
    server.start();
}


验证输出。

$ curl http://localhost:8080
Welcome to Simple Web Server


你可以实时更改index.html文件的内容,并且在页面刷新后将立即提供新的内容。

还可以创建自定义的HttpHandler以拦截响应并进行更改。

class MyHttpHandler implements com.sun.net.httpserver.HttpHandler {
 
    @Override
    public void handle(HttpExchange exchange) throws IOException {
        if ("GET".equals(exchange.getRequestMethod())) {
            OutputStream outputStream = exchange.getResponseBody();
            String response = "It works!";
            exchange.sendResponseHeaders(200, response.length());
            outputStream.write(response.getBytes());
            outputStream.flush();
            outputStream.close();
        }
    }
}


在不同的端口上启动网络服务器,并添加上下文路径和HttpHandler

private static void customFileServerHandler() {
    try {
        var server = HttpServer.create(new InetSocketAddress(8081), 0);
        server.createContext("/custom", new MyHttpHandler());
        server.start();
    } catch (IOException ioe) {
        System.out.println("IOException occured");
    }
}


运行此应用程序并验证输出。

$ curl http://localhost:8081/custom
It works!


结论

在本博客中,你快速了解了自上一次LTS发布(Java 17)以来新增的一些功能。 现在轮到你开始思考你的迁移计划到Java 21以及学习更多关于这些新功能以及如何将它们应用到你的日常编码习惯中的方法了。 提示:IntelliJ能够帮助你达成这一目标!

推荐阅读: 最近大火的ChatGpt到底是什么?

本文链接: Java 17与Java 21之间的新特性是什么?