Java-编程问题第二版-全-
Java 编程问题第二版(全)
原文:
zh.annas-archive.org/md5/84a398505c8c6545a941e007a8c150ae译者:飞龙
前言
JDK 在版本 12 到 21 之间的快速演变意味着两件事:首先,Java 现在有一系列强大的新功能,您可以采用这些功能来解决各种现代问题;其次,现代 Java 的学习曲线正在变得越来越陡峭。
本书通过解释您在处理复杂性、性能、可读性等方面需要做出的正确实践和决策,使您能够客观地解决常见问题。
《Java 编程问题,第二版》 将帮助您完成日常任务并按时完成任务,同时成为更熟练和自给自足的 Java 开发者。您可以依靠这本书中的 270 多个问题(本版全部全新),涵盖最常见和基础的兴趣领域:字符串、数字、数组、集合、外函数和内存 API、数据结构、日期和时间、模式匹配、密封/隐藏类、函数式编程、虚拟线程、结构化并发、垃圾收集器、动态 CDS 存档、套接字 API 和简单 Web 服务器。
通过精心设计的问题来增强您的技能,这些问题旨在突出和教授日常工作中所需的核心知识。换句话说(无论您的任务简单、中等还是复杂),在您的工具箱中拥有这些知识是必须的,而不是选择。
在阅读完这本书之后,您将对 Java 概念有深入的理解,并且您将自信地开发并选择解决所有 Java 问题的正确解决方案。
尽管这本书完全独立,您不需要其他任何东西就能充分利用它,但本书中涵盖的许多主题也在 《Java 编程问题,第一版》 中进行了探讨。如果您还没有读过它,并且希望获得更多的实践,那么请考虑购买那本书,以解决一组完全不同的 Java 问题。
这本书面向的对象
《Java 编程问题,第二版》 特别适用于希望通过解决实际问题来提升知识的初级到中级 Java 开发者。然而,这些页面中探讨的问题在任何 Java 开发者的日常工作中都会遇到,从初学者到高级实践者。
因此,建议您熟悉 Java 基础知识,并且至少具备语言的基础实践知识。
本书涵盖的内容
第一章,文本块、区域设置、数字和数学,包括 37 个问题,涵盖 4 个主要主题:文本块、区域设置、数字和数学运算。
第二章,对象、不可变性、switch 表达式和模式匹配,包括 30 个问题,涉及其他方面,例如 java.util.Objects 的某些不太为人所知的功能、不可变性的有趣方面、switch 表达式的最新功能以及对模式匹配表达式(instanceof 和 switch)的深入探讨。
第三章,处理日期和时间,包含 20 个问题,涵盖了不同的日期时间主题。这些问题主要关注 Calendar API 和 JDK 8 日期/时间 API。关于后者,我们将介绍一些不太研究的 API,如ChronoUnit、ChronoField、IsoFields和TemporalAdjusters。
第四章,记录和记录模式,包含 19 个问题,详细介绍了 JDK 16(JEP 395)中引入的 Java 记录和记录模式,这些模式在 JDK 19(JEP 405)中作为预览特性引入,在 JDK 20(JEP 432)中作为第二个预览特性引入,并在 JDK 21(JEP 440)中作为最终特性引入。
第五章,数组、集合和数据结构,包含 24 个问题,涵盖了三个主要主题。我们开始解决几个旨在覆盖针对数据并行处理的新 Vector API 的问题。然后,我们继续介绍包括 Rope、Skip List、K-D Tree、Zipper、Binomial Heap、Fibonacci Heap、Pairing Heap、Huffman 编码等在内的几个数据结构。最后,我们讨论了三种最流行的连接算法。
第六章,Java I/O:上下文特定的反序列化过滤器,包含 13 个与 Java 序列化/反序列化过程相关的问题。我们开始解决将对象序列化/反序列化为byte[]、String和 XML 格式的经典问题。然后,我们继续介绍旨在防止反序列化漏洞的 JDK 9 反序列化过滤器,并以 JDK 17 上下文特定的反序列化过滤器结束。
第七章,外国(函数)内存 API,包含 28 个问题,涵盖了外国函数内存 API 和外国链接器 API。我们首先介绍调用外国函数的经典方法,依赖于 JNI API 和开源的 JNA/JNR 库。接下来,我们介绍名为 Project Panama 的新方法。我们剖析了最相关的 API,如Arena、MemorySegment、MemoryLayout等。最后,我们关注外国链接器 API 和 Jextract 工具,用于调用具有不同签名类型的外国函数,包括回调函数。
第八章,密封和隐藏类,包含 13 个问题,涵盖了密封和隐藏类。前 11 个食谱将涵盖密封类,这是 JDK 17 引入的非常酷的特性,用于维持封闭层次结构。最后两个问题涵盖了隐藏类,这是 JDK 15 的一个特性,允许框架创建和使用对 JVM 隐藏的运行时(动态)类。
第九章,函数式风格编程 – 扩展 API,包含 24 个问题,涵盖了广泛的函数式编程主题。我们将从介绍 JDK 16 的mapMulti()开始,然后继续解决与谓词(Predicate)、函数和收集器一起工作的问题。
第十章,并发 – 虚拟线程和结构化并发,包含 16 个问题,简要介绍了虚拟线程和结构化并发。
第十一章,并发 – 虚拟线程和结构化并发:深入探讨,包括 18 个问题,旨在深入探讨虚拟线程和结构化并发的工作原理以及如何在应用程序中利用它们。
第十二章,垃圾收集器和动态 CDS 存档,包括 15 个问题,涵盖垃圾收集器和应用程序类数据共享(AppCDS)。
第十三章,Socket API 和简单 Web 服务器,包括 11 个问题涵盖 Socket API 和 8 个问题涵盖 JDK 18 的简单 Web 服务器。在前 11 个问题中,我们将讨论实现基于套接字的应用程序,如阻塞/非阻塞服务器/客户端应用程序、基于数据报的应用程序和多播应用程序。在本章的第二部分,我们将讨论简单 Web 服务器作为命令行工具。
要充分利用本书
您应该对 Java 语言有基本的了解。您还应该安装以下内容:
-
一个集成开发环境(推荐的但不是必需的选择是 Apache NetBeans 20.x:
netbeans.apache.org/front/main/). -
JDK 21 和 Maven 的最新版本。
-
为了完全跟随某些问题和章节,需要安装额外的第三方库(没有太困难或特殊)。
下载示例代码文件
书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Java-Coding-Problems-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781837633944。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。
以下是一个示例:“例如,让我们添加Patient和Appointment记录。”
代码块设置如下:
if (staff instanceof Resident(String name, Doctor dr)) {
return "Cabinet of " + dr.specialty() + ". Doctor: "
+ dr.name() + ", Resident: " + name;
}
当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将被突出显示:
if (staff instanceof Resident(String name, Doctor dr)) {
**return****"Cabinet of "** **+ dr.specialty() +** **". Doctor: "**
+ dr.name() + ", Resident: " + name;
}
任何命令行输入或输出都如下所示:
2023-02-07T05:26:17.374159500Z
2023-02-07T05:26:17.384811300Z
2023-02-07T05:26:17.384811300Z
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“编译器通过record关键字识别Java 记录。”
警告或重要注意事项如下所示。
技巧和窍门如下所示。
联系我们
我们欢迎读者的反馈。
一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍标题。如果你对本书的任何方面有疑问,请通过questions@packtpub.com与我们联系。
勘误表:尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们非常感谢你向我们报告。请访问www.packtpub.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果你在互联网上以任何形式遇到我们作品的非法副本,我们非常感谢你提供位置地址或网站名称。请通过copyright@packtpub.com与我们联系,并提供材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请访问authors.packtpub.com。
分享你的想法
一旦你阅读了《Java 编程问题,第二版》,我们非常乐意听到你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。
你的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
你喜欢在路上阅读,但无法携带你的印刷书籍到处走?
你的电子书购买是否与你的选择设备不兼容?
别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。
优惠不会就此停止,你还可以获得独家折扣、时事通讯和每天收件箱中的精彩免费内容。
按照以下简单步骤获取好处:
- 扫描二维码或访问以下链接

packt.link/free-ebook/9781837633944
-
提交您的购买证明
-
就这样!我们将直接将免费 PDF 和其他好处发送到你的电子邮件。
第一章:文本块、区域设置、数字和数学
本章包括 37 个问题,涵盖 4 个主要主题:文本块、区域设置、数字和数学运算。我们将从文本块开始(JDK 13(JEP 355,预览)/ JDK 15(JEP 378,最终)中引入的优雅的多行字符串),继续创建 Java Locale的问题,包括本地化区域(JDK 19 的ofLocalizedPattern()),并以关于数字和数学的问题结束,例如计算平方根的巴比伦方法和结果溢出的不同边界情况。本章的最后部分是关于 JDK 17(JEP 356,最终)的新伪随机生成器 API。
到本章结束时,您将了解所有与这四个主题相关的新酷 JDK 功能。
在整本书中,您将找到对第一版的引用。这些引用的作用是为您提供与某些主题相关的最佳进一步阅读资源。即使您没有阅读第一版,也不打算阅读,您也可以成功通过这一版。
问题
使用以下问题来测试您的字符串操作、Java 区域设置和数学边界情况编程能力。我强烈建议您在查看解决方案并下载示例程序之前尝试每个问题:
-
创建多行 SQL、JSON 和 HTML 字符串:编写一个程序,声明一个多行字符串(例如,SQL、JSON 和 HTML 字符串)。
-
演示文本块定界符的使用:编写一个程序,逐步演示文本块的定界符如何影响结果字符串。
-
在文本块中使用缩进:编写一个程序,演示不同的技术来缩进文本块。解释偶然和必要空白的意义。
-
在文本块中移除偶然的空白:突出显示编译器用于移除文本块偶然空白的算法的主要步骤。
-
仅为了可读性使用文本块:编写一个程序,创建一个看起来像文本块(多行字符串)但作为单行字符串字面量的字符串。
-
在文本块中转义引号和行终止符:编写一个程序,说明如何在文本块中处理 Java 转义序列(包括引号
\"和行终止符\\n和\\r)。 -
程序化地转换转义序列:编写一个程序,具有对文本块中转义序列进行程序化访问的功能。考虑您有一个包含嵌入转义序列的文本块,并且您必须将其传递给一个必须不包含此类序列的函数。
-
使用变量/表达式格式化文本块:编写一个程序,展示格式化文本块使用变量/表达式的几种技术。从可读性的角度对每种技术进行评论。同时,为这些技术提供Java Microbenchmark Harness(JMH)基准测试。
-
在文本块中添加注释:解释我们如何在文本块中添加注释。
-
混合普通字符串字面量和文本块:编写一个程序,混合普通字符串字面量和文本块——例如,通过连接。此外,如果它们的内容相同,普通字符串字面量和文本块是否相等?
-
将正则表达式与文本块混合:编写一个示例,展示将具有命名组的正则表达式与文本块混合。
-
检查两个文本块是否同构:编写一个程序,检查两个文本块是否同构。如果我们可以将第一个字符串的每个字符一对一地映射到第二个字符串的每个字符,则认为两个字符串是同构的(例如,“xxyznnxiz”和“aavurraqu”是同构的)。
-
字符串连接与 StringBuilder:编写一个 JMH 基准测试,比较通过“
+”运算符进行的字符串连接与StringBuilder方法。 -
将 int 转换为 String:编写一个程序,提供将
int转换为String的几种常见技术。同时,为提出的解决方案提供 JMH 基准测试。 -
介绍字符串模板:解释并举例说明 JDK 21 的(JEP 430,预览)字符串模板功能的用法。
-
编写自定义模板处理器:介绍编写用户定义模板处理器的 API。接下来,提供一些自定义模板处理器的示例。
-
创建 Locale:编写一个程序,展示创建
Locale的不同方法。同时,创建语言范围和语言优先级列表。 -
自定义本地化日期时间格式:编写一个程序,展示使用自定义本地化日期时间格式的用法。
-
恢复始终严格的浮点语义:解释
strictfp修饰符是什么,以及在 Java 应用程序中如何/在哪里使用它。 -
计算 int/long 的数学绝对值及其结果溢出:编写一个程序,展示应用数学绝对值到
int/long导致结果溢出的边界情况。同时,提供解决这个问题的方法。 -
计算参数商及其结果溢出:编写一个程序,展示计算参数商导致结果溢出的边界情况。同时,提供解决这个问题的方法。
-
计算小于/大于或等于代数商的最大/最小值:编写一个程序,该程序依赖于
java.util.Math方法来计算小于/大于或等于代数商的最大/最小值。别忘了也要涵盖结果溢出的边界情况。 -
从 double 中获取整数和分数部分:编写一个程序,展示获取
double的整数和分数部分的几种技术。 -
测试一个 double 数是否为整数:编写一个程序,展示测试
double数是否为整数的几种方法。此外,提供针对所提解决方案的 JMH 基准测试。 -
简要说明 Java(无)符号整数的使用:解释并举例说明 Java 中符号/无符号整数的用法。
-
返回地板/天花板模数:基于地板和天花板操作定义floor/ceil模数,并在代码行中举例说明结果。
-
收集给定数的所有质因数:质数是只能被自己和 1 整除的数(例如,2、3 和 5 是质数)。编写一个程序,收集给定正数的所有质因数。
-
使用巴比伦方法计算一个数的平方根:解释计算平方根的巴比伦方法,详细阐述该方法的逐步算法,并基于此算法编写代码。
-
将浮点数四舍五入到指定的小数位数:编写一个程序,包含将给定的
float数四舍五入到指定小数位数的几种方法。 -
将值夹在最小值和最大值之间:提供一个解决方案,将给定的值夹在给定的最小值和最大值之间。
-
不使用循环、乘法、位运算、除法和运算符来乘以两个整数:编写一个程序,在不使用循环、乘法、位运算、除法和运算符的情况下乘以两个整数。例如,从特殊二项式乘积公式开始。
-
使用 TAU:解释几何/三角学中 TAU 的含义,并编写一个程序解决以下问题:一个圆的周长为 21.33 厘米。圆的半径是多少?
-
选择伪随机数生成器:简要论述在 JDK 17 中引入的新 API(JEP 356,最终版)用于生成伪随机数。此外,举例说明选择伪随机数生成器的不同技术。
-
用伪随机数填充长数组:编写一个程序,以并行和非并行方式填充长数组的伪随机数。
-
创建伪随机数生成器的流:编写一个程序,创建一个伪随机数流和一个伪随机数生成器流。
-
从 JDK 17 的新伪随机生成器获取遗留的伪随机生成器:编写一个程序,实例化一个遗留的伪随机生成器(例如,
Random),它可以委托方法调用到 JDK 17 的RandomGenerator。 -
在多线程环境中安全地使用伪随机生成器:解释并举例说明在多线程环境中(例如,使用
ExecutorService)使用伪随机生成器的方法。
以下各节描述了解决上述问题的方法。请记住,通常没有解决特定问题的唯一正确方法。此外,请记住,这里所示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节,并实验程序,请访问 github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter01。
1. 创建多行 SQL、JSON 和 HTML 字符串
让我们考虑以下多行 SQL 字符串:
UPDATE "public"."office"
SET ("address_first", "address_second", "phone") =
(SELECT "public"."employee"."first_name",
"public"."employee"."last_name", ?
FROM "public"."employee"
WHERE "public"."employee"."job_title" = ?
如常识所知,在 JDK 8 之前,我们可以以几种方式将此 SQL 包装为 Java String(字符串字面量)。
在 JDK 8 之前
可能最常见的方法是直接通过众所周知的“+"运算符进行简单的连接。这样,我们得到一个多行字符串表示,如下所示:
String sql =
"UPDATE \"public\".\"office\"\n"
+ "SET (\"address_first\", \"address_second\", \"phone\") =\n"
+ " (SELECT \"public\".\"employee\".\"first_name\",\n"
+ " \"public\".\"employee\".\"last_name\", ?\n"
+ " FROM \"public\".\"employee\"\n"
+ " WHERE \"public\".\"employee\".\"job_title\" = ?";
编译器应该(并且通常确实如此)足够智能,能够将“+"操作内部转换为 StringBuilder/StringBuffer 实例,并使用 append() 方法构建最终的字符串。然而,我们可以直接使用 StringBuilder(非线程安全)或 StringBuffer(线程安全),如下例所示:
StringBuilder sql = new StringBuilder();
sql.append("UPDATE \"public\".\"office\"\n")
.append("SET ...\n")
.append(" (SELECT...\n")
...
另一种方法(通常不如前两种流行)是使用 String.concat() 方法。这是一个不可变操作,基本上是将给定的字符串追加到当前字符串的末尾。最后,它返回新的组合字符串。尝试追加 null 值会导致 NullPointerException(在前两个例子中,我们可以追加 null 值而不会抛出任何异常)。链式调用 concat() 允许我们表达多行字符串,如下例所示:
String sql = "UPDATE \"public\".\"office\"\n"
.concat("SET...\n")
.concat(" (SELECT...\n")
...
此外,我们还有 String.format() 方法。通过简单地使用 %s 格式说明符,我们可以在多行字符串中连接多个字符串(包括 null 值),如下所示:
String sql = String.format("%s%s%s%s%s%s",
"UPDATE \"public\".\"office\"\n",
"SET ...\n",
" (SELECT ...\n",
...
虽然这些方法在当今仍然很流行,但让我们看看 JDK 8 对这个话题有什么看法。
从 JDK 8 开始
从 JDK 8 开始,我们可以使用 String.join() 方法来表示多行字符串。此方法也专门用于字符串连接,并允许我们在示例中具有易于阅读的格式。如何做到这一点?此方法将分隔符作为第一个参数,并在将要连接的字符串之间使用它。因此,如果我们考虑 \n 是我们的行分隔符,那么它只能指定一次,如下所示:
String sql = String.join("\n"
,"UPDATE \"public\".\"office\""
,"SET (\"address_first\", \"address_second\", \"phone\") ="
," (SELECT \"public\".\"employee\".\"first_name\","
," \"public\".\"employee\".\"last_name\", ?"
," FROM \"public\".\"employee\""
," WHERE \"public\".\"employee\".\"job_title\" = ?;");
除了 String.join() 方法之外,JDK 8 还提供了 java.util.StringJoiner。StringJoiner 支持分隔符(如 String.join()),但也支持前缀和后缀。表达我们的多行 SQL 字符串不需要前缀/后缀;因此,分隔符仍然是我们的最爱功能:
StringJoiner sql = new StringJoiner("\n");
sql.add("UPDATE \"public\".\"office\"")
.add("SET (\"address_first\", ..., \"phone\") =")
.add(" (SELECT \"public\".\"employee\".\"first_name\",")
...
最后,我们无法提及 JDK 8 而不提及其强大的 Stream API。更确切地说,我们感兴趣的是Collectors.joining()收集器。这个收集器的作用类似于String.join(),在我们的情况下,它看起来如下:
String sql = Stream.of(
"UPDATE \"public\".\"office\"",
"SET (\"address_first\", \"address_second\", \"phone\") =",
" (SELECT \"public\".\"employee\".\"first_name\",",
" \"public\".\"employee\".\"last_name\", ?",
" FROM \"public\".\"employee\"",
" WHERE \"public\".\"employee\".\"job_title\" = ?;")
.collect(Collectors.joining(String.valueOf("\n")));
所有的前例都有一些共同的缺点。其中最重要的是,这些示例中没有哪一个真正表示了多行字符串字面量,而且可读性严重受到转义字符和每行分隔所需的额外引号的影响。幸运的是,从 JDK 13(作为一个未来预览)到 JDK 15(作为一个最终特性),新的文本块已经成为表示多行字符串字面量的标准。让我们看看它是如何做到的。
介绍文本块(JDK 13/15)
JDK 13(JEP 355)提供了一个预览功能,旨在添加对多行字符串字面量的支持。经过两个版本,在 JDK 15(JEP 378)中,文本块功能已成为最终和永久的使用特性。但历史就到这里;让我们快速看看文本块是如何塑造我们的多行 SQL 字符串的:
String sql="""
UPDATE "public"."office"
SET ("address_first", "address_second", "phone") =
(SELECT "public"."employee"."first_name",
"public"."employee"."last_name", ?
FROM "public"."employee"
WHERE "public"."employee"."job_title" = ?""";
这真是太酷了,对吧?!我们立即看到我们的 SQL 的可读性已经恢复,我们没有用分隔符、行终止符和连接操作破坏它。文本块简洁、易于更新、易于理解。在 SQL 字符串中的额外代码的足迹为零,Java 编译器将尽最大努力以最可预测的方式创建一个String。这里还有一个例子,它嵌入了一段 JSON 信息:
String json = """
{
"widget": {
"debug": "on",
"window": {
"title": "Sample Widget 1",
"name": "back_window"
},
"image": {
"src": "images\\sw.png"
},
"text": {
"data": "Click Me",
"size": 39
}
}
}""";
如何将一段 HTML 表示为一个文本块?当然可以,下面就是例子:
String html = """
<table>
<tr>
<thcolspan="2">Name</th>
<th>Age</th>
</tr>
<tr>
<td>John</td>
<td>Smith</td>
<td>22</td>
</tr>
<table>""";
那文本块的语法是什么呢?
钩子文本块语法
文本块的语法相当简单。没有花哨的东西,没有复杂的事情——只需记住两个方面:
-
一个文本块必须以
"""(即三个双引号)和一个换行符开始。我们称这种构造为开头分隔符。 -
一个文本块必须以
"""(即三个双引号)结束。"""可以单独在一行(作为一个新行)或者位于文本最后一行的末尾(就像我们的例子一样)。我们称这种构造为结尾分隔符。然而,这两种方法在语义上存在差异(将在下一个问题中分析)。
在这个上下文中,以下示例在语法上是正确的:
String tb = """
I'm a text block""";
String tb = """
I'm a text block
""";
String tb = """
I'm a text block""";
String tb = """
I'm a text block
""";
String tb = """
I'm a text block
""";
另一方面,以下示例是错误的,会导致编译器错误:
String tb = """I'm a text block""";
String tb = "I'm a text block""";
String tb = """I'm a text block";
String tb = ""I'm a text block""";
String tb = """I'm a text block"";
String tb = ""I'm a text block
""";
然而,请考虑以下最佳实践。
重要提示
通过查看之前的代码片段,我们可以为文本块制定一个最佳实践:只有在你有一个多行字符串时才使用文本块;如果字符串适合单行代码(就像之前的片段一样),那么使用普通的字符串字面量,因为使用文本块不会增加任何显著的价值。
在捆绑的代码中,你可以在 SQL、JSON 和 HTML 上练习这个问题的所有示例。
重要提示
对于第三方库支持,请考虑 Apache Commons、StringUtils.join() 和 Guava 的 Joiner.on()。
接下来,让我们专注于使用文本块分隔符。
2. 示例化文本块分隔符的使用
记住从上一个问题,创建多行 SQL、JSON 和 HTML 字符串,一个文本块在语法上由一个开头分隔符和一个结尾分隔符界定,分别由三个双引号表示,"""。
使用这些分隔符的最佳方法包括三个简单的步骤:考虑一个示例,检查输出,并提供结论。话虽如此,让我们从一个模仿一些 JEP 示例的示例开始:
String sql= """
UPDATE "public"."office"
SET ("address_first", "address_second", "phone") =
(SELECT "public"."employee"."first_name",
"public"."employee"."last_name", ?
FROM "public"."employee"
WHERE "public"."employee"."job_title" = ?)""";
所以,通过遵循 JEP 示例,我们必须将内容与开头分隔符对齐。很可能会发现这种对齐风格与我们的代码的其他部分不一致,并且并不是一个好的实践。如果我们重命名 sql 变量为 updateSql、updateOfficeByEmployeeJobTitle 或其他名称,文本块内容会发生什么?显然,为了保持对齐,这将使我们的内容进一步向右移动。幸运的是,我们可以将内容向左移动,而不会影响最终结果,如下所示:
String sql = """
UPDATE "public"."office"
SET ("address_first", "address_second", "phone") =
(SELECT "public"."employee"."first_name",
"public"."employee"."last_name", ?
FROM "public"."employee"
WHERE "public"."employee"."job_title" = ?""";
将开头/关闭分隔符本身向右移动不会影响生成的 String。你不太可能有很好的理由这样做,但为了完整性,以下示例产生的结果与前面的两个示例相同:
String sql = """
UPDATE "public"."office"
SET ("address_first", "address_second", "phone") =
(SELECT "public"."employee"."first_name",
"public"."employee"."last_name", ?
FROM "public"."employee"
WHERE "public"."employee"."job_title" = ? """;
现在,让我们看看一些更有趣的东西。开头分隔符不接受同一行上的内容,而结尾分隔符位于内容末尾的右侧。然而,如果我们像以下两个示例那样将结尾分隔符移动到自己的行,会发生什么?
String sql= """
UPDATE "public"."office"
SET ("address_first", "address_second", "phone") =
(SELECT "public"."employee"."first_name",
"public"."employee"."last_name", ?
FROM "public"."employee"
WHERE "public"."employee"."job_title" = ?
""";
String sql= """
UPDATE "public"."office"
SET ("address_first", "address_second", "phone") =
(SELECT "public"."employee"."first_name",
"public"."employee"."last_name", ?
FROM "public"."employee"
WHERE "public"."employee"."job_title" = ?
""";
这次,生成的字符串在内容末尾包含一个新行。检查以下图(文本 -- TEXT BLOCK 之前 -- 和 -- TEXT BLOCK 之后 -- 是通过 System.out.println() 添加的指南,以帮助您界定文本块本身;它们不是必需的,也不属于文本块的一部分):

图 1.1:将关闭分隔符移动到自己的行,垂直对齐到开头分隔符
在左边的图(A)中,关闭分隔符位于内容末尾。然而,在右边的图(B)中,我们将关闭分隔符移动到了自己的行,如您所见,生成的 String 在末尾增加了一个新行。
重要提示
将结尾分隔符放在自己的行上将在最终的 String 中追加一个新行。同时,请注意,将开头分隔符、内容和结尾分隔符垂直对齐到左边界可能会在以后导致额外的工作。如果变量名被修改,则需要手动重新缩进以保持这种对齐。
因此,请注意您放置结尾分隔符的方式。
你觉得这很奇怪吗?好吧,这还不是全部!在前面的例子中,结尾分隔符被放在了其自己的行,但垂直对齐于开分隔符。让我们再向前迈一步,让我们将结束分隔符向左移动,如下面的例子所示:
String sql= """
UPDATE "public"."office"
SET ("address_first", "address_second", "phone") =
(SELECT "public"."employee"."first_name",
"public"."employee"."last_name", ?
FROM "public"."employee"
WHERE "public"."employee"."job_title" = ?
""";
以下图显示了此动作的效果:

图 1.2:将结尾分隔符移到其自己的行并将它向左移动
在左边的图(A)中,我们有一个单独的行上的结尾分隔符,并且与开分隔符对齐。在右边的图(B)中,我们看到了前面代码的效果。将结尾分隔符向左移动会导致内容向右的额外缩进。额外的缩进取决于我们向左移动结尾分隔符的程度。
重要提示
将结尾分隔符放在其自己的行并将它向左移动将在最终的String后追加一个新行和额外的缩进。
另一方面,如果我们将结尾分隔符移到其自己的行并将它向右移动,它不会影响最终的String:
String sql= """
UPDATE "public"."office"
SET ("address_first", "address_second", "phone") =
(SELECT "public"."employee"."first_name",
"public"."employee"."last_name", ?
FROM "public"."employee"
WHERE "public"."employee"."job_title" = ?
""";
此代码向最终的String追加一个新行,但不会影响缩进。为了更好地理解开/闭分隔符的行为,你必须探索下一个问题。
3. 在文本块中处理缩进
如果我们对文本块中的缩进有一个清晰的了解,那么理解文本块中的缩进就很容易:
-
偶然(或非必要)空白 – 代表由代码格式化(通常由 IDE 添加的行首空白)或故意/意外地添加到文本末尾的空白(尾随空白)
-
必要空白 – 代表我们明确添加的空白,这对最终字符串是有意义的
在图 1.3中,你可以看到 JSON 文本块中的偶然空白与必要空白:

图 1.3:JSON 文本块中的偶然空白与必要空白
在左边的图中,你可以看到当结尾分隔符放置在内容末尾时的偶然空白与必要空白。在中间的图中,结尾分隔符被移到了其自己的行,而在右边的图中,我们也将其向左移动。
偶然(非必要)空白将由 Java 编译器自动删除。编译器会删除所有偶然的尾随空白(以确保在不同文本编辑器中的外观一致,这些编辑器可能会自动删除尾随空白),并使用一个特殊的内部算法(在下一个问题中会详细说明)来确定并删除偶然的行首空白。此外,重要的是要提到,包含结尾分隔符的行始终是此检查的一部分(这被称为重要尾行策略)。
本质空白在最终字符串中得以保留。基本上,正如你可以从之前的图中直观地看出,本质空白可以通过以下两种方式添加:
-
通过将关闭定界符左移(当此定界符在其自己的行上时)
-
通过将内容右移(通过显式添加空白或使用专门用于控制缩进的辅助方法)
移动关闭定界符和/或内容
让我们从以下代码开始:
String json = """
--------------{
--------------++"widget": {
--------------++++"debug": "on",
--------------++++"window": {
--------------++++++"title": "Sample Widget 1",
--------------++++++"name": "back_window"
--------------++++},
--------------++++"image": {
--------------++++++"src": "images\\sw.png"
--------------++++},
--------------++++"text": {
--------------++++++"data": "Click Me",
--------------++++++"size": 39
--------------++++}
--------------++}
--------------}""";
用“–”符号突出显示的空白表示偶然的行首空白(没有偶然的行尾空白),而用“+”符号突出显示的空白表示在结果 String 中会看到的本质空白。如果我们整个内容右移,而关闭定界符位于内容末尾,那么显式添加的空白被视为偶然的,并且会被编译器移除:
String json = """
----------------------{
----------------------++"widget": {
----------------------++++"debug": "on",
----------------------++++"window": {
----------------------++++++"title": "Sample Widget 1",
----------------------++++++"name": "back_window"
----------------------++++},
----------------------++++"image": {
----------------------++++++"src": "images\\sw.png"
----------------------++++},
----------------------++++"text": {
----------------------++++++"data": "Click Me",
----------------------++++++"size": 39
----------------------++++}
----------------------++}
----------------------}""";
然而,如果我们将关闭定界符移动到其自己的行(垂直对齐于打开定界符),并且仅将内容右移,那么我们就能获得在最终字符串中保留的基本空白:
String json = """
--------------++++++++{
--------------++++++++++"widget": {
--------------++++++++++++"debug": "on",
--------------++++++++++++"window": {
--------------++++++++++++++"title": "Sample Widget 1",
--------------++++++++++++++"name": "back_window"
--------------++++++++++++},
--------------++++++++++++"image": {
--------------++++++++++++++"src": "images\\sw.png"
--------------++++++++++++},
--------------++++++++++++"text": {
--------------++++++++++++++"data": "Click Me",
--------------++++++++++++++"size": 39
--------------++++++++++++}
--------------++++++++++}
--------------++++++++}
""";
当然,我们可以通过左移关闭定界符来添加相同的本质空白:
String json = """
-------+++++++{
-------++++++++++"widget": {
-------++++++++++++"debug": "on",
-------++++++++++++"window": {
-------++++++++++++++"title": "Sample Widget 1",
-------++++++++++++++"name": "back_window"
-------++++++++++++},
-------++++++++++++"image": {
-------++++++++++++++"src": "images\\sw.png"
-------++++++++++++},
-------++++++++++++"text": {
-------++++++++++++++"data": "Click Me",
-------++++++++++++++"size": 39
-------++++++++++++}
-------++++++++++}
-------++++++++}
""";
此外,我们可以通过手动添加空白来调整每一行文本,如下例所示:
String json = """
--------------{
--------------++++"widget": {
--------------++++++++"debug": "on",
--------------++++++++"window": {
--------------+++++++++++++++++++++"title": "Sample Widget 1",
--------------+++++++++++++++++++++"name": "back_window"
--------------++++++++},
--------------++++++++"image": {
--------------+++++++++++++++++++++"src": "images\\sw.png"
--------------++++++++},
--------------++++++++"text": {
--------------+++++++++++++++++++++"data": "Click Me",
--------------+++++++++++++++++++++"size": 39
--------------++++++++}
--------------++++}
--------------}""";
接下来,让我们看看一些有用的辅助方法,这些方法对于缩进目的很有用。
使用缩进方法
从 JDK 12 开始,我们可以通过 String.indent(int n) 方法向字面量字符串添加本质空白,其中 n 表示空白字符的数量。此方法还可以用于缩进文本块的全部内容,如下所示:
String json = """
--------------********{
--------------********++"widget": {
--------------********++++"debug": "on",
--------------********++++"window": {
--------------********++++++"title": "Sample Widget 1",
--------------********++++++"name": "back_window"
--------------********++++},
--------------********++++"image": {
--------------********++++++"src": "images\\sw.png"
--------------********++++},
--------------********++++"text": {
--------------********++++++"data": "Click Me",
--------------********++++++"size": 39
--------------********++++}
--------------********++}
--------------********}""".indent(8);
显然,通过 indent() 添加的空白在 IDE 的代码编辑器中是不可见的,但在这里通过“*”符号突出显示,只是为了说明对最终字符串的影响。然而,当使用 indent() 时,也会添加一个新行,即使关闭定界符位于内容末尾。在这种情况下,将关闭定界符移动到其自己的行会产生相同的效果,所以不要期望添加两个新行。当然,请随意练习捆绑的代码以获得真实体验。
indent() 方法可能对对齐包含放置在同一缩进级别的文本行的内容块很有用,如下面的诗歌所示:
String poem = """
I would want to establish strength; root-like,
anchored in the hopes of solidity.
Forsake the contamination of instability.
Prove I'm the poet of each line of prose.""";
如果我们在诗的每一行前面手动添加空格,编译器将删除它们,因此无法全局添加任何必要的空格。我们可以将关闭分隔符移动到自己的行并将其向左移动,或者将内容向右移动以获得所需的必要空格。然而,在这种情况下,你仍然需要删除由于将关闭分隔符移动到自己的行而添加的新行。最简单的方法是通过 JDK 14 的新转义序列\。通过在行尾添加此转义序列,我们指示编译器不要将该行的新行字符添加到该行:
String poem = """
I would want to establish strength; root-like,
anchored in the hopes of solidity.
Forsake the contamination of instability.
Prove I'm the poet of each line of prose.\
""";
虽然Problem 5中已经分析了这个转义序列(\),仅使用文本块以提高可读性,让我们看看基于字符串 API 的几种方法。
在 JDK 11 之前,我们可以通过简单的正则表达式如replaceFirst("\\s++$", "")来删除此行,或者依赖于第三方辅助工具,如 Apache Commons 的StringUtils.stripEnd()方法。然而,从 JDK 11 开始,我们可以通过String.stripTrailing()来实现这一目标,如下所示:
String poem = """
I would want to establish strength; root-like,
anchored in the hopes of solidity.
Forsake the contamination of instability.
Prove I'm the poet of each line of prose.
""".stripTrailing();
现在,由于将关闭分隔符向左移动,内容块被缩进,并且由于stripTrailing()方法,自动添加的新行被删除。
重要提示
除了stripTrailing()之外,JDK 11 还提供了stripLeading()和strip()。从 JDK 15 开始,我们还有stripIndent(),它正好像编译器那样删除首尾空格。
然而,从 JDK 12 开始,我们可以使用String.indent(int n),这使我们免去了手动添加空格的需要:
String poem = """
I would want to establish strength; root-like,
anchored in the hopes of solidity.
Forsake the contamination of instability.
Prove I'm the poet of each line of prose."""
.indent(6)
.stripTrailing();
现在,是时候向前推进并分析删除意外空格的算法了。
4. 删除文本块中的意外空格
删除文本块中的意外空格通常是编译器通过特殊算法完成的任务。为了理解这个算法的主要方面,让我们通过以下示例来回顾一下:
String json = """ |Compiler:
----{ |Line 01: 4 lws
----++"widget": { |Line 02: 6 lws
----++++"debug": "on", |Line 03: 8 lws
----++++"window": { |Line 04: 8 lws
----++++++"title": "Sample Widget 1", |Line 05: 10 lws
----++++++"name": "back_window" |Line 06: 10 lws
----++++}, |Line 07: 8 lws
----++++"image": { |Line 08: 8 lws
----++++++"src": "images\\sw.png" |Line 09: 10 lws
----++++}, |Line 10: 8 lws
----++++"text": { |Line 11: 8 lws
----++++++"data": "Click Me", |Line 12: 10 lws
----++++++"size": 39 |Line 13: 10 lws
----++++} |Line 14: 8 lws
----++} |Line 15: 6 lws
----} |Line 16: 4 lws
----"""; |Line 17: 4 lws
–" sign.
为了删除意外的首行空格,编译器必须检查所有非空白行(仅包含空白的行),因此在我们的例子中,它将检查 17 行。其中有 16 行 JSON 代码和关闭分隔符行。
编译器扫描这些 17 行中的每一行,并计算前导空格的数量。用于表示空格的字符在此计数中并不重要——它可以是简单的空格、制表符等等。它们都具有相同的权重 1,因此单个空格与单个制表符相同。这是必要的,因为编译器不知道制表符将在不同的文本编辑器中如何显示(例如,一个制表符可能是四个或八个字符)。一旦这个算法步骤完成,编译器就知道检查的每一行的确切前导空格数量。例如,第 1 行有 4 个前导空格(lws),第 2 行有 6 个 lws,第 3 行有 8 个 lws,依此类推(查看之前的代码片段以查看所有数字)。
重要提示
让我们快速看一下另一个文本块的最佳实践:不要在同一个文本块中混合空格和制表符。这样,你可以确保缩进的一致性,并避免任何潜在的缩进不规则性。
在这一点上,编译器计算这些数字的最小值,结果(在这种情况下,4)表示应该从每 17 行中移除的意外前导空格的数量。因此,在最终结果中,至少有一行没有前导空格。当然,必要的空格(通过“+”符号表示的额外缩进)保持不变。例如,在第 5 行,我们有 10 个 lws(leading white spaces,前导空格)- 4 个意外 lws = 6 个必要的 lws 保持不变。
在捆绑的代码中,你可以找到三个更多的 JSON 示例,你可以使用这些示例来练习这个算法。现在,我们将解决一些文本块可读性的方面。
5. 仅使用文本块以提高可读性
仅使用文本块以提高可读性可以翻译为将字符串看起来像文本块,但作为单行字符串字面量。这对于格式化长文本行特别有用。例如,我们可能希望以下 SQL 字符串看起来像文本块(为了可读性),但作为单行字符串字面量(在传递给数据库时紧凑的意义上):
SELECT "public"."employee"."first_name"
FROM "public"."employee"
WHERE "public"."employee"."job_title" = ?
从 JDK 14 开始,我们可以通过新的转义序列\(一个单独的反斜杠)来实现这个目标。通过在行尾添加这个转义序列,我们指示编译器抑制向该行追加换行符。因此,在我们的情况下,我们可以将 SQL 表达为单行字符串字面量,如下所示:
String sql = """
SELECT "public"."employee"."first_name" \
FROM "public"."employee" \
WHERE "public"."employee"."job_title" = ?\
""";
注意不要在\后添加任何空格,否则你会得到一个错误。
如果我们将这个文本块放入System.out.println()中,那么输出将揭示单行字符串字面量,如下所示:
SELECT "public"."employee"."first_name" FROM "public"."employee" WHERE "public"."employee"."job_title" = ?
接下来,让我们检查另一个示例,如下所示:
String sql = """
UPDATE "public"."office" \
SET ("address_first", "address_second", "phone") = \
(SELECT "public"."employee"."first_name", \
"public"."employee"."last_name", ? \
FROM "public"."employee" \
WHERE "public"."employee"."job_title" = ?\
""";
这次,结果字符串并不是我们想要的,因为必要的空格被保留了。这意味着单行字符串被散布着一系列空格,我们应该将其缩减为单个空格。这正是正则表达式可以发挥作用的地方:
sql.trim().replaceAll(" +", " ");
完成!现在,我们有一个看起来像 IDE 中文本块的单一行的 SQL 字符串。
接下来,假设我们想在文本块中打印以下诗歌,并添加一个漂亮的背景:
String poem = """
An old silent pond...
A frog jumps into the pond,
splash!! Silence again.
""";
为这首诗添加背景将得到如下所示的图形:

图 1.4:为诗歌添加背景
重要提示
彩色背景仅作为对齐的指南,因为白色背景上的白色将无法辨认。
由于编译器删除了尾随空格,我们将得到如图左侧所示的内容。显然,我们想要如图右侧所示的内容,因此我们需要找到一种方法来保留尾随空格作为必要部分。从 JDK 14 开始,我们可以通过新的转义序列 \s 来做到这一点。
我们可以为每个空格重复这个转义序列,如下所示(我们在第一行添加了三个空格,在最后一行添加了两个空格;这样,我们得到了一个对称的文本块):
String poem = """
An old silent pond...\s\s\s
A frog jumps into the pond,
splash!! Silence again.\s\s
""";
或者,我们可以在行尾手动添加空格和一个单个 \s。这是可能的,因为编译器会保留 \s 前面的任何空格:
String poem = """
An old silent pond... \s
A frog jumps into the pond,
splash!! Silence again. \s
""";
完成!现在,我们已经保留了空格,所以当应用背景颜色时,我们将获得如图 1.4 右侧所示的内容。
接下来,让我们专注于转义字符。
6. 在文本块中转义引号和行终止符
只有在我们想在文本块中嵌入三个双引号(""")序列时,转义双引号才是必要的,如下所示:
String txt = """
She told me
\"""I have no idea what's going on\"""
""";
使用 \""" 可以转义 """。没有必要写 \"\"\"。
生成的字符串将看起来像这样:
She told me
"""I have no idea what's going on"""
无论何时需要嵌入 " 或 "",只需按照以下方式操作:
String txt = """
She told me
"I have no idea what's going on"
""";
String txt = """
She told me
""I have no idea what's going on""
""";
即使它可行,也不要这样做,因为这不是必要的:
String txt = """
She told me
\"I have no idea what's going on\"
""";
String txt = """
She told me
\"\"I have no idea what's going on\"\"
""";
然而,像 """"(其中第一个 " 表示一个双引号,最后的 """ 表示文本块的结束分隔符)这样的结构将引发错误。在这种情况下,你可以放置一个空格作为 " """ 或将双引号转义为 \""""。
根据定义,文本块表示跨越多行的字符串字面量,因此不需要显式转义行终止符(换行符),如 \n、\r 或 \f。只需在文本块中添加新行文本,编译器就会处理行终止符。当然,这并不意味着使用它们不起作用。例如,可以通过 \n 获得交错空白行的文本块,如下所示:
String sql = """
SELECT "public"."employee"."first_name",\n
"public"."employee"."last_name", ?\n
FROM "public"."employee"\n
WHERE "public"."employee"."job_title" = ?
""";
在文本块中使用转义序列(例如,\b、\t、\r、\n、\f等)与在旧式字符串字面量中的使用方式完全相同。例如,这里没有问题:
String txt = """
\b\bShe told me\n
\t""I have no idea what's going on""
""";
然而,无需转义序列(例如,将 \t(制表符)视为八个空格)也可以得到相同的结果:
String txt = """
She told me
""I have no idea what's going on""
""";
你可以在捆绑的代码中练习所有这些示例。
重要提示
让我们快速看一下另一个文本块最佳实践:显式添加转义序列可能会负面影响文本块的可读性,因此请谨慎使用,并且仅在真正需要时使用。例如,显式的 \n 和 \" 对于文本块来说很少是必要的。
讨论到 \n 行终止符(换行符),重要的是要注意以下注意事项。
重要提示
在 Java 中,最常用的行终止符可能是 \n(Unix,换行(LF)),但我们也可以使用 \r(Windows,回车(CR))或 \n\r(Windows,回车换行(CRLF))。无论我们更喜欢哪一个,Java 文本块始终使用 \n(LF)。首先,编译器将所有未通过转义序列显式添加的行终止符规范化为 \n(LF)。其次,在行终止符规范化和管理的缩进之后,编译器将处理所有显式转义序列(\n(LF),\f(FF),\r(CR)等),就像任何字符串字面量一样。实际上,这允许我们将包含转义序列的旧版 Java 字符串复制到文本块中,无需进一步修改即可获得预期结果。
如果你需要使用特定于操作系统的行终止符,那么你必须通过 String.replaceAll() 显式替换文本块规范化后的行终止符,例如 String::replaceAll("\n", System.lineSeparator())。
在文本块中嵌入转义序列可以通过通常的 \\ 构造来完成。以下是将 \" 转义序列嵌入为 \\" 的示例:
String sql = """
SELECT \\"public\\".\\"employee\\".\\"first_name\\",
\\"public\\".\\"employee\\".\\"last_name\\", ?
FROM \\"public\\".\\"employee\\"
WHERE \\"public\\".\\"employee\\".\\"job_title\\" = ?
""";
您可以在捆绑的代码中检查输出。现在,让我们看看如何程序化地转换转义序列。
7. 程序化转换转义序列
我们已经知道编译器负责转义序列的转换,大多数时候,我们不需要显式地干预这个过程。但是,有些情况下我们可能需要以编程方式访问这个过程(例如,在将字符串传递给函数之前显式取消转义字符串)。
从 JDK 15 开始,我们可以通过 String.translateEscapes() 来完成这项任务,该方法能够取消转义序列,如 \t,\n,\b 等,以及八进制数(\0–\377)。然而,此方法不翻译 Unicode 转义序列(\uXXXX)。
我们可以通过进行等式测试来揭示 translateEscapes() 的工作方式:
String newline = "\\n".translateEscapes();
System.out.println(("\n".equals(newline)) ? "yes" : "no");
如您所预料的,结果是是的。
接下来,假设我们想要使用一个外部服务,该服务在包裹上打印地址。负责此任务的功能接收一个不包含转义序列的地址表示字符串。问题是我们的客户的地址在通过一个格式化过程时会被修补上转义序列,如下例所示:
String address = """
JASON MILLER (\\"BIGBOY\\")\\n
\\tMOUNT INC\\n
\\t104 SEAL AVE\\n
\\tMIAMI FL 55334 1200\\n
\\tUSA
""";
下图揭示了如果我们不翻译地址的转义字符(左侧)和如果我们翻译它们(右侧)时生成的字符串将如何看起来。当然,我们的目标是获取右侧的地址并将其发送到打印:

图 1.5:我们想要右侧的字符串
可以通过 String.translateEscapes() 方法程序化地翻译转义字符,在将结果发送到外部服务之前。以下是代码:
String translatedAddress = address.translateEscapes();
现在,translatedAddress 可以传递给外部打印服务。作为一个练习,你可以思考如何利用这种方法编写一个通过 Java 或其他编程语言提供的源代码解析器。
重要提示
可以通过 Apache Commons’ Lang 第三方库支持获得类似的结果(当然,阅读文档以获取更详细的信息)。请考虑使用 StringEscapeUtils.unescapeJava(String)。
接下来,让我们谈谈在文本块中嵌入表达式。
8. 使用变量/表达式格式化文本块
在 Java 中,使用变量/表达式格式化字符串字面量以获取动态字符串是一种常见的做法。例如,我们可以通过以下众所周知的连接创建一个动态的 XML 字符串:
String fn = "Jo";
String ln = "Kym";
String str = "<user><firstName>" + fn
+ "</firstName><lastName>" + ln + "</lastName></user>";
// output
<user><firstName>Jo</firstName><lastName>Kym</lastName></user>
当然,这个微小的结构在可读性方面存在严重问题。如果 XML 代码经过适当的格式化和缩进,它是可读的;否则,很难跟随其层次结构。那么,我们能否将这个 XML 表达得像以下图示一样?

图 1.6:格式化的 XML
当然可以!通过使用一些转义序列(例如,\n、\t 和 \s)、空白字符等,我们可以构建一个看起来像 Figure 1.6 的 String。然而,通过文本块表达这种连接可能更好。也许我们可以在 IDE 的代码编辑器和控制台(在运行时)中达到相同的可读性。一种可能的方法如下:
String xml = """
<user>
<firstName>\
"""
+ fn
+ """
</firstName>
<lastName>\
"""
+ ln
+ """
</lastName>
</user>
""";
因此,我们可以通过“+”运算符精确地连接文本块,就像字符串字面量一样。酷!这段代码的输出对应于 Figure 1.6 的左侧。另一方面,Figure 1.6 的右侧可以通过以下方式实现:
String xml = """
<user>
<firstName>
"""
+ fn.indent(4)
+ """
</firstName>
<lastName>
"""
+ ln.indent(4)
+ """
</lastName>
</user>
""";
好吧,虽然在这两种情况下生成的字符串看起来都很好,但我们不能对代码本身说同样的话。它的可读性仍然很低。
重要提示
通过查看前面的两个代码片段,我们可以很容易地得出文本块的最佳实践:仅在它们显著提高代码清晰度和多行字符串的可读性时使用它们。此外,避免在复杂表达式(例如 lambda 表达式)中声明文本块,因为它们可能会影响整个表达式的可读性。最好将文本块单独提取到静态变量中,并在复杂表达式中引用它们。
让我们尝试另一种方法。这次,让我们使用StringBuilder来获取图 1.6左侧的结果:
StringBuilder sbXml = new StringBuilder();
sbXml.append("""
<user>
<firstName>""")
.append(fn)
.append("""
</firstName>
<lastName>""")
.append(ln)
.append("""
</lastName>
</user>""");
然后,从图 1.6的右侧获取结果可以这样做:
StringBuilder sbXml = new StringBuilder();
sbXml.append("""
<user>
<firstName>
""")
.append(fn.indent(4))
.append("""
</firstName>
<lastName>
""")
.append(ln.indent(4))
.append("""
</lastName>
</user>
""");
因此,我们可以在StringBuilder/StringBuffer中使用文本块,就像我们使用字符串字面量一样。虽然生成的字符串对应于图 1.6中的示例,但从可读性的角度来看,代码本身仍然不尽如人意。
让我们再次尝试使用 JDK 1.4 的MessageFormat.format()。首先,让我们塑造图 1.6中的示例,左侧:
String xml = MessageFormat.format("""
<user>
<firstName>{0}</firstName>
<lastName>{1}</lastName>
</user>
""", fn, ln);
从图 1.6(右侧)获取结果可以这样做:
String xml = MessageFormat.format("""
<user>
<firstName>
{0}
</firstName>
<lastName>
{1}
</lastName>
</user>
""", fn, ln);
文本块和MessageFormat.format()的组合是一个成功的方案。代码的可读性显然更好。但是,让我们更进一步,让我们在 JDK 5 String.format()中尝试一下。像往常一样,图 1.6(左侧)是首先:
String xml = String.format("""
<user>
<firstName>%s</firstName>
<lastName>%s</lastName>
</user>
""", fn, ln);
从图 1.6(右侧)获取结果可以这样做:
String xml = String.format("""
<user>
<firstName>
%s
</firstName>
<lastName>
%s
</lastName>
</user>
""", fn, ln);
文本块和String.format()的组合是另一种成功的方案,但不是我们可以利用的最新特性。从 JDK 15 开始,String.format()有一个更方便的伴侣,名为formatted()。以下是String.formatted()的工作原理,用于重现图 1.6(左侧):
String xml = """
<user>
<firstName>%s</firstName>
<lastName>%s</lastName>
</user>
""".formatted(fn, ln);
从图 1.6(右侧)获取结果可以这样做:
String xml = """
<user>
<firstName>
%s
</firstName>
<lastName>
%s
</lastName>
</user>
""".formatted(fn, ln);
这就是我们能做的最好的了。我们成功地在 IDE 的代码编辑器和运行时中实现了包含动态部分(变量)的文本块的可读性水平。酷,不是吗?!
从性能角度来看,你可以在捆绑的代码中找到一个这些方法的基准。在下面的图中,你可以看到在 Intel^® Core^™ i7-3612QM CPU @ 2.10GHz 机器上(Windows 10)这个基准的结果,但你可以自由地在不同的机器上测试它,因为结果高度依赖于机器。

图 1.7:基准结果
符合这些结果,通过“+”运算符的连接是最快的,而MessageFormat.format()是最慢的。
9. 在文本块中添加注释
问题:我们能否在文本块中添加注释?
官方答案(根据 Java 语言规范):词法语法暗示注释不会出现在字符字面量、字符串字面量或文本块中。
你可能会想尝试一些类似的方法,认为这是一种快速的方法,但我真的不推荐这样做:
String txt = """
foo /* some comment */
buzz //another comment
""".replace("some_regex","");
简短回答:不,我们不能在文本块中添加注释。
让我们继续,谈谈混合普通字符串字面量和文本块。
10. 混合普通字符串字面量和文本块
在混合普通字符串字面量和文本块之前,让我们考虑以下声明:普通字符串字面量和文本块有多大的不同?我们可以通过以下代码片段来回答这个问题:
String str = "I love Java!";
String txt = """
I love Java!""";
System.out.println(str == txt); // true
System.out.println(str.equals(txt)); // true
true twice. This means that an ordinary string literal and a text block are similar at runtime. We can define text blocks as string literals that span across multiple lines of text and use triple quotes as their opening and closing delimiter. How so? First, the instance produced from an ordinary string literal and a text block is of type java.lang.String. Second, we have to look at the compiler internals. Basically, the compiler adds strings to a special cached pool named a String Constant Pool (SCP) (more details about SCP are available in *Java Coding Problems*, *First Edition*, Problem 48, *Immutable string*) to optimize the memory usage, and starting with JDK 13, text blocks can be found in the same pool as strings.
现在我们知道,在内部处理普通字符串字面量和文本块时没有重大差异,我们可以自信地将它们混合在简单的连接中(基本上,文本块可以在普通字符串字面量可以使用的任何地方使用):
String tom = "Tom";
String jerry = """
Jerry""";
System.out.println(tom + " and " + jerry); // Tom and Jerry
此外,由于文本块返回一个 String,我们可以使用我们用于普通字符串字面量的整个方法库。以下是一个示例:
System.out.println(tom.toUpperCase() + " AND "
+ jerry.toUpperCase()); // TOM AND JERRY
此外,正如你在 问题 8 中看到的,使用变量/表达式格式化文本块,文本块可以与普通字符串字面量一起在 StringBuilder(Buffer)、MessageFormat.format()、String.format() 和 String.formatted() 中使用和混合。
11. 将正则表达式与文本块混合
正则表达式可以与文本块一起使用。让我们考虑一个简单的字符串,如下所示:
String nameAndAddress
= "Mark Janson;243 West Main St;Louisville;40202;USA";
因此,这里有一个名字(Mark Janson)以及一些关于他地址的详细信息,由分号(;)分隔。将此类字符串通过正则表达式传递并提取信息作为命名组是一种常见场景。在这个例子中,我们可以考虑以下五个命名组:
-
name:应包含个人的姓名(Mark Janson) -
address:应包含个人的街道信息(243 West Main St) -
city:应包含个人的城市(Louisville) -
zip:应包含城市的邮政编码(40202) -
country:应包含国家的名称(USA)
可以匹配这些命名组的正则表达式可能看起来如下:
(?<name>[ a-zA-Z]+);(?<address>[ 0-9a-zA-Z]+);(?<city>[ a-zA-Z]+);(?<zip>[\\d]+);(?<country>[ a-zA-Z]+)$
这是一个单行字符串,因此我们可以通过 Pattern API 使用它,如下所示:
Pattern pattern = Pattern.compile("(?<name>[ a-zA-Z]+);(?<address>[ 0-9a-zA-Z]+);(?<city>[ a-zA-Z]+);(?<zip>[\\d]+);(?<country>[ a-zA-Z]+)$");
然而,正如你所看到的,这样编写我们的正则表达式会对可读性产生严重影响。幸运的是,我们可以使用文本块来解决这个问题,如下所示:
Pattern pattern = Pattern.compile("""
(?<name>[ a-zA-Z]+);\
(?<address>[ 0-9a-zA-Z]+);\
(?<city>[ a-zA-Z]+);\
(?<zip>[\\d]+);\
(?<country>[ a-zA-Z]+)$""");
这更易于阅读,对吧?我们唯一需要注意的事情是使用 JDK 14 的新转义序列 \(一个反斜杠),以删除每行末尾的换行符。
接下来,你可以简单地匹配地址并提取命名组,如下所示:
if (matcher.matches()) {
String name = matcher.group("name");
String address = matcher.group("address");
String city = matcher.group("city");
String zip = matcher.group("zip");
String country = matcher.group("country");
}
如果你只想提取组名,那么你可以依赖 JDK 20 的 namedGroups():
// {country=5, city=3, zip=4, name=1, address=2}
System.out.println(matcher.namedGroups());
实际上,namedGroups() 返回一个不可修改的 Map<String, Integer>,其中键是组名,值是组号。此外,JDK 20 还添加了 hasMatch() 方法,该方法在匹配器包含来自先前匹配或查找操作的有效匹配时返回 true:
if (matcher.hasMatch()) { ... }
注意,hasMatch() 不会像 matches() 那样尝试触发与模式的匹配。当你需要在代码的不同位置检查有效匹配时,hasMatch() 更可取,因为它不会执行匹配。因此,你可以先调用一次 matches(),然后在后续的有效匹配检查中,只需调用 hasMatch()。
此外,如果您只需要提取由给定分隔符捕获的每个命名组的输入子序列,则可以依赖 JDK 21 的 splitWithDelimiters(CharSequence input, int limit)。例如,我们的字符串可以通过分号(正则表达式,;+)分割,如下所示:
String[] result = Pattern.compile(";+")
.splitWithDelimiters(nameAndAddress, 0);
返回的数组包含提取的数据和分隔符,如下所示:
[Mark Janson, ;, 243 West Main St, ;,
Louisville, ;, 40202, ;, USA]
splitWithDelimiters() 函数的第二个参数是一个整数,表示应用正则表达式的次数。如果 limit 参数为 0,则模式将被尽可能多次地应用,并且尾随的空字符串(无论是子字符串还是分隔符)将被丢弃。如果它是正数,则模式将被应用,最多 limit - 1 次;如果是负数,则模式将被尽可能多次地应用。
12. 检查两个文本块是否同构
如果结果字符串是同构的,则两个文本块是同构的。如果我们可以以一对一的方式将第一个字符串的每个字符映射到第二个字符串的每个字符,则两个字符串字面量被认为是同构的。
例如,考虑第一个字符串是 “abbcdd" 和第二个字符串是 “qwwerr"。一对一字符映射如图 1.8 所示:

图 1.8:两个字符串之间的一对一字符映射
正如您在 图 1.8 中所看到的,第一个字符串中的字符 “a” 可以被第二个字符串中的字符 “q” 替换。此外,第一个字符串中的字符 “b” 可以被第二个字符串中的字符 “w” 替换,字符 “c” 被 “e” 替换,字符 “d” 被 “r” 替换。显然,反之亦然。换句话说,这两个字符串是同构的。
关于字符串 “aab” 和 “que”,这两个字符串不是同构的,因为 “a” 不能同时映射到 “q” 和 “u”。
如果我们将此逻辑外推到文本块,那么 图 1.9 正是我们所需要的:

图 1.9:两个同构的文本块
如果两个文本块的字符串行以一对一的方式同构,则两个文本块是同构的。此外,请注意,必要的空白和 换行符(LF)也应进行映射,而偶然的起始/结束空白被忽略。
普通字符串字面量和文本块的算法完全相同,并且它依赖于 哈希(关于此主题的更多详细信息可在 Java 完整编码面试指南 书中的 示例 6:哈希表 找到)并包括以下步骤:
-
检查两个文本块 (
s1和s2) 的长度是否相同。如果它们的长度不同,则文本块不是同构的。 -
创建一个空映射,该映射将存储从
s1(作为键)到s2(作为值)的字符映射。 -
从
s1(chs1) 和s2(chs2) 中取出第一个/下一个字符。 -
检查
chs1是否作为键存在于映射中。 -
如果
chs1作为键存在于映射中,那么它必须映射到s2中的一个等于chs2的值;否则,文本块不是同构的。 -
如果
chs1不作为键存在于映射中,那么映射不应该包含chs2作为值;否则,文本块不是同构的。 -
如果
chs1不作为键存在于映射中,并且映射不包含chs2作为值,那么在映射中放入 (chs1和chs2) –chs1作为键和chs2作为值。 -
重复步骤 3,直到整个文本块(
s1)被处理。 -
如果整个文本块(
s1)已被处理,那么文本块是同构的。
在代码行中,这个 O(n) 算法可以表示如下:
public static boolean isIsomorphic(String s1, String s2) {
// step 1
if (s1 == null || s2 == null
|| s1.length() != s2.length()) {
return false;
}
// step 2
Map<Character, Character> map = new HashMap<>();
// step 3(8)
for (int i = 0; i < s1.length(); i++) {
char chs1 = s1.charAt(i);
char chs2 = s2.charAt(i);
// step 4
if (map.containsKey(chs1)) {
// step 5
if (map.get(chs1) != chs2) {
return false;
}
} else {
// step 6
if (map.containsValue(chs2)) {
return false;
}
// step 7
map.put(chs1, chs2);
}
}
// step 9
return true;
}
完成!你可以在捆绑的代码中练习这个例子。这是关于文本块主题的最后一个问题。现在是时候继续前进,讨论字符串连接了。
13. 字符串连接与 StringBuilder
查看以下普通字符串连接:
String str1 = "I love";
String str2 = "Java";
String str12 = str1 + " " + str2;
我们知道 String 类是不可变的(创建的 String 不能被修改)。这意味着创建 str12 需要一个中间字符串,它代表 str1 与空格的连接。因此,在 str12 创建后,我们知道 str1 + " " 只是噪音或垃圾,因为我们无法进一步引用它。
在这种情况下,建议使用 StringBuilder,因为它是一个可变类,我们可以向其中追加字符串。所以这就是以下说法的由来:在 Java 中,不要使用 “+” 运算符来连接字符串!使用 StringBuilder,它要快得多。
你以前听说过这个说法吗?我非常确信你听说过,尤其是如果你仍然在 JDK 8 或更早的版本上运行你的应用程序。好吧,这个说法不是神话,在某个时刻确实是真的,但在智能编译器的时代它仍然有效吗?
例如,考虑以下两个代码片段,它们代表字符串的简单连接:

图 1.10:字符串连接与 StringBuilder
在 JDK 8 中,哪种方法(来自 图 1.10)更好?
JDK 8
让我们检查这两个代码片段生成的字节码(使用 javap -c -p 或 Apache Commons 字节码工程库(BCEL);我们使用了 BCEL)。concatViaPlus() 的字节码如下:

图 1.11:concatViaPlus() 的 JDK 8 字节码
JDK 8 编译器足够智能,可以在底层使用 StringBuilder 来通过 “+” 运算符来塑造我们的连接。如果你检查从 concatViaStringBuilder() 生成的字节码(这里为了简洁省略),那么你会看到与 图 1.11 大致相似的内容。
在 JDK 8 中,编译器知道何时以及如何通过StringBuilder优化字节码。换句话说,与通过“+"运算符进行普通连接相比,显式使用StringBuilder并没有带来显著的好处。有许多简单的情况都适用这个说法。基准测试对此有何看法?查看结果:

图 1.12:JDK 8 基准测试concatViaPlus()与concatViaStringBuilder()
显然,通过“+"运算符进行的连接赢得了这场比赛。让我们为 JDK 11 重复这个逻辑。
JDK 11
JDK 11 为concatViaPlus()方法生成了以下字节码:

图 1.13:JDK 11 的concatViaPlus()字节码
我们可以立即观察到这里有一个很大的不同。这次,连接是通过调用invokedynamic(这是一个动态调用)来完成的,它充当我们代码的代理人。在这里,它将代码委托给makeConcatWithConstants(),这是StringConcatFactory类的一个方法。虽然你可以在 JDK 文档中找到这个方法,请注意,这个类 API 并不是为了直接调用而创建的。这个类是专门创建和设计的,用于为invokedynamic指令提供引导方法。在继续之前,让我们看看你应该考虑的一个重要提示。
重要提示
invokedynamic将我们的连接代码委托/传递给不包含在字节码中的代码来解决(这就是为什么我们看不到解决我们代码的实际代码(指令))。这非常强大,因为它允许 Java 工程师继续优化连接逻辑的过程,同时我们可以通过简单地升级到下一个 JDK 来利用它。代码甚至不需要重新编译就可以利用进一步的优化。
有趣的事实:indify这个术语来自invokedynamic,也称为indy。它在 JDK 7 中引入,并用于 JDK 8 lambda 实现。由于这个指令非常有用,它成为了解决许多其他问题的解决方案,包括在 JDK 9 中引入的 JEP 280:Indify String Concatenation。我更喜欢在这里使用 JDK 11,但这个特性从 JDK 9+开始可用,所以你可以在 JDK 17 或 20 等版本中尝试一下。
简而言之,invokedynamic的工作方式如下:
-
编译器在连接点附加了一个
invokedynamic调用。 -
invokedynamic调用首先执行引导方法makeConcat[WithConstants]. -
invokedynamic方法调用makeConcat[WithConstants],这是一个用于调用实际负责连接的代码的引导方法。 -
makeConcat[WithConstants]使用内部策略来确定解决连接的最佳方法。 -
调用最合适的方法,然后执行连接逻辑。
这样,JEP 280 为 JDK 10、11、12、13 等版本提供了极大的灵活性,因为它们可以使用不同的策略和方法来适应我们上下文中字符串连接的最佳方式。
那么 concatViaStringBuilder() 的字节码是什么样的呢?这个方法没有利用 invokedynamic(它依赖于经典的 invokevirtual 指令),正如您在这里可以看到的:

图 1.14:JDK 11 concatViaStringBuilder() 的字节码
我相信您一定很好奇哪种字节码的性能更好,所以这里有一些结果:

图 1.15:JDK 11,concatViaPlus() 与 concatViaStringBuilder() 的基准比较
这些基准测试的结果是在一台装有 Windows 10 的 Intel^® Core^™ i7-3612QM CPU @ 2.10GHz 的机器上获得的,但您也可以在不同的机器和不同的 JDK 版本上测试它,因为结果高度依赖于机器。
再次强调,concatViaPlus() 在这场游戏中获胜。在附带代码中,您可以找到这个示例的完整代码。此外,您还可以找到检查字节码以及通过“+”运算符和 StringBuilder 在循环中进行字符串连接基准测试的代码。试试看吧!
14. 将 int 转换为 String
如同往常,在 Java 中,我们可以用多种方式完成一个任务。例如,我们可以通过 Integer.toString() 将 int(原始整数)转换为 String,如下所示:
public String intToStringV1(int v) {
return Integer.toString(v);
}
或者,您也可以通过一种相当常见的技巧(代码审查员会在这里皱眉)来完成这个任务,即通过将空字符串与整数连接起来:
public String intToStringV2(int v) {
return "" + v;
}
String.valueOf() 也可以如下使用:
public String intToStringV3(int v) {
return String.valueOf(v);
}
通过 String.format() 的更高级方法如下:
public String intToStringV4(int v) {
return String.format("%d", v);
}
这些方法也适用于装箱整数和 Integer 对象。由于装箱和拆箱是昂贵的操作,我们努力避免它们,除非它们真的有必要。然而,您永远不知道拆箱操作何时会“幕后”偷偷进行并破坏您应用程序的性能。为了验证这个说法,想象一下,对于前面提到的每种方法,我们也有一个使用 Integer 而不是 int 的等效方法。这里有一个(为了简洁起见,其他方法被省略了):
public String integerToStringV1(Integer vo) {
return Integer.toString(vo);
}
对所有这些方法的基准测试结果如下:

图 1.16:基准测试 int 转换为 String 的结果
从这里我们可以得出两个非常明确的结论:
-
使用
String.format()非常慢,对于int和Integer应该避免使用。 -
所有使用
Integer的解决方案都比使用原始int的解决方案慢。因此,即使在这种情况下,也应避免不必要的拆箱,因为它们可能会引起严重的性能惩罚。
这些基准测试的结果是在一台装有 Windows 10 的 Intel^® Core^™ i7-3612QM CPU @ 2.10GHz 的机器上获得的,但您也可以在不同的机器上测试它,因为结果高度依赖于机器。
接下来,让我们改变话题,谈谈 Java 区域设置。
15. 字符串模板的介绍
直到 JDK 21,Java 允许我们通过不同的方法执行 SQL、JSON、XML 等的字符串组合,这些方法在之前的问题 8中已经介绍过。在那个问题中,你可以看到如何通过简单的连接使用文本块和内嵌表达式,使用加号(+)运算符、StringBuilder.append()、String.format()、formatted()等。虽然使用加号(+)运算符和StringBuilder.append()可能会很繁琐并影响可读性,但String.format()和formatted()可能会引起类型不匹配。例如,在下面的例子中,很容易搞错数据类型(LocalDate、double和String)和格式说明符(%d、%s和%.2f):
LocalDate fiscalDate = LocalDate.now();
double value = 4552.2367;
String employeeCode = "RN4555";
String jsonBlock = """
{"sale": {
"id": 1,
"details": {
"fiscal_year": %d,
"employee_nr": "%s",
"value": %.2f
}
}
""".formatted(
fiscalDate.getYear(), employeeCode, value);
此外,这些方法中的任何一种都不涵盖输入有效性(因为我们不知道表达式是否有效)和安全问题(注入,这通常影响 SQL 字符串)。
从 JDK 21 开始,我们可以通过 字符串模板(JEP 430)来解决这些问题。
什么是字符串模板?
字符串模板(模板表达式)是 JDK 21 中引入的一个预览功能,可以帮助我们高效且安全地执行字符串插值。这个功能由以下三个部分组成:
-
模板处理器(
RAW、STR、FMT、用户定义的等) -
一个点字符
-
包含内嵌表达式(
\{expression})的字符串模板
RAW、STR和FMT是 JDK 21 提供的三个 模板处理器,但正如你将看到的,我们也可以编写自己的模板处理器。
一个 模板处理器 接收一个字符串字面量和适当的表达式,并且能够验证和插入它到一个最终结果中,这个结果可以是一个字符串或其他特定领域的对象(例如,一个 JSON 对象)。如果模板处理器无法成功创建结果,则可能会抛出异常。
STR 模板处理器
STR模板处理器作为java.lang.StringTemplate中的static字段提供。它的目标是服务于简单的字符串连接任务。例如,我们可以使用STR重写之前的例子,如下所示:
import static java.lang.StringTemplate.STR;
String jsonBlockStr = STR."""
{"sale": {
"id": 1,
"details": {
"fiscal_year": \{fiscalDate.getYear()},
"employee_nr": "\{employeeCode}",
"value": \{value}
}
}
""";
在这里,我们有三个内嵌表达式(\{fiscalDate.getYear()}、\{employeeCode}和\{value}),STR将按顺序处理这些表达式以获得最终的字符串:
{"sale": {
"id": 1,
"details": {
"fiscal_year": 2023,
"employee_nr": "RN4555",
"value": 4552.2367
}
}
如你所见,STR处理器已经将每个内嵌表达式替换为该表达式的字符串值。返回的结果是一个String,我们可以使用任意数量的内嵌表达式。如果表达式很大,那么你可以在你的 IDE 中将其拆分成多行,而不会在最终结果中引入新行。
FMT 模板处理器
在前面的示例中,我们有 \{value} 嵌入表达式,它被 STR 评估为 4552.2367。这是正确的,但我们可能希望将此值格式化为两位小数,即 4552.24。在这种情况下,我们需要 FMT 处理器,它作为 java.util.FormatProcessor 中的 static 字段可用,并且能够解释嵌入表达式中的格式说明符(STR 不能这样做)。因此,使用 FMT 重写我们的示例可以这样做:
String jsonBlockFmt = FMT."""
{"sale": {
"id": 1,
"details": {
"fiscal_year": \{fiscalDate.getYear()},
"employee_nr": "\{employeeCode}",
"value": %.2f\{value}
}
}
""";
注意格式说明符是如何在反斜杠字符之前添加到嵌入表达式 (%.2f{value}) 中的。这将导致以下字符串:
...
"value": 4552.24
...
以同样的方式,你可以使用任何其他格式说明符。FMT 将考虑它们以返回预期的结果。
RAW 模板处理器
RAW 模板处理器作为 java.lang.StringTemplate 的 static 字段可用。调用 RAW 将返回一个 StringTemplate 实例,稍后可以使用。例如,这里是一个我们使用 RAW 分别提取的 StringTemplate:
StringTemplate templateRaw = RAW."""
"employee_nr": "\{employeeCode}",
""";
接下来,我们可以重复使用 templateRaw,如下例所示:
LocalDate fiscalDate1 = LocalDate.of(2023, 2, 4);
LocalDate fiscalDate2 = LocalDate.of(2024, 3, 12);
double value1 = 343.23;
double value2 = 1244.33;
String jsonBlockRaw = STR."""
{"sale": {
"id": 1,
"details": {
"fiscal_year": \{fiscalDate1.getYear()},
\{templateRaw.interpolate()}\
"value": \{value1}
}
},
{"sale": {
"id": 2,
"details": {
"fiscal_year": \{fiscalDate2.getYear()},
\{templateRaw.interpolate()}\
"value": \{value2}
}
}
""";
\{templateRaw.interpolate()} 表达式调用 interpolate() 方法,该方法负责处理 templateRaw 中定义的字符串。它就像调用 interpolate() 一样,如下所示:
String employeeCodeString = templateRaw.interpolate();
最终结果是以下字符串:
{"sale": {
"id": 1,
"details": {
"fiscal_year": 2023,
"employee_nr": "RN4555",
"value": 343.23
}
},
{"sale": {
"id": 2,
"details": {
"fiscal_year": 2024,
"employee_nr": "RN4555",
"value": 1244.33
}
}
员工代码被评估为 RN4555 字符串。
在嵌入表达式之前和最后一个嵌入表达式之后的字符序列被称为 片段。如果字符串模板以嵌入表达式开始,则其片段为零长度。同样,直接相邻的嵌入表达式也是如此。例如,templateRaw 的片段("employee_nr": "{employeeCode}",)是 "employee_nr": " 和 ","。我们通过 fragments() 方法可以访问这些片段作为 List<String>。
List<String> trFragments = templateRaw.fragments();
此外,通过 values() 方法获取嵌入表达式的结果作为 List<Object> 可以这样做:
List<Object> trValues = templateRaw.values();
对于 templateRaw,此列表将包含单个条目,RN4555。
在捆绑的代码中,你可以找到更多示例,包括使用 STR、FMT 和 RAW 与简单字符串(不是文本块)一起使用。
16. 编写自定义模板处理器
内置的 STR 和 FMT 只能返回 String 实例,并且不能抛出异常。然而,它们实际上是功能接口 StringTemplate.Processor<R,E extends Throwable> 的实例,该接口定义了 process() 方法:
R process(StringTemplate stringTemplate) throws E
通过实现 Processor<R,E extends Throwable> 接口,我们可以编写自定义模板处理器,返回 R(任何结果类型),而不仅仅是 String。此外,如果在处理过程中出现问题(例如,存在验证问题),我们可以抛出检查异常(E extends Throwable)。
例如,假设我们需要将表示电话号码的表达式进行字符串插值。因此,我们只接受匹配以下正则表达式的电话号码表达式:
private static final Pattern PHONE_PATTERN = Pattern.compile(
"\\d{10}|(?:\\d{3}-){2}\\d{4}|\\(\\d{3}\\)\\d{3}-?\\d{4}");
在这种情况下,结果是String,因此我们的自定义模板处理器可以编写如下:
public class PhoneProcessor
implements Processor<String, IllegalArgumentException> {
private static final Pattern PHONE_PATTERN = ...;
@Override
public String process(StringTemplate stringTemplate)
throws IllegalArgumentException {
StringBuilder sb = new StringBuilder();
Iterator<String> fragmentsIter
= stringTemplate.fragments().iterator();
for (Object value : stringTemplate.values()) {
sb.append(fragmentsIter.next());
if (!PHONE_PATTERN.matcher(
(CharSequence) value).matches()) {
throw new IllegalArgumentException(
"This is not a valid phone number");
}
sb.append(value);
}
sb.append(fragmentsIter.next());
return sb.toString();
}
}
现在,我们可以使用以下简单消息测试我们的处理器(这里使用有效的电话号码):
PhoneProcessor pp = new PhoneProcessor();
String workPhone = "072-825-9009";
String homePhone = "(040)234-9670";
String message = pp."""
You can contact me at work at \{workPhone}
or at home at \{homePhone}.
""";
生成的字符串如下:
You can contact me at work at 072-825-9009
or at home at (040)234-9670.
如您所见,我们的处理器依赖于StringBuilder来获取最终的字符串。然而,我们也可以使用StringTemplate.interpolate(List<String> fragments, List<?> values)方法,并获得一个更简洁的解决方案,如下所示:
public class PhoneProcessor implements
Processor<String, IllegalArgumentException> {
private static final Pattern PHONE_PATTERN = ...;
@Override
public String process(StringTemplate stringTemplate)
throws IllegalArgumentException {
for (Object value : stringTemplate.values()) {
if (!PHONE_PATTERN.matcher(
(CharSequence) value).matches()) {
throw new IllegalArgumentException(
"This is not a valid phone number");
}
}
return StringTemplate.interpolate(
stringTemplate.fragments(), stringTemplate.values());
}
}
然而,正如我们之前所说的,模板处理器可以返回任何类型(R)。例如,假设我们将之前的消息格式化为 JSON 字符串,如下所示:
{
"contact": {
"work": "072-825-9009",
"home": "(040)234-9670"
}
}
这次,我们想要使用表示电话号码的变量进行字符串插值,并返回一个 JSON 对象。更确切地说,我们想要返回com.fasterxml.jackson.databind.JsonNode的实例(在这里,我们使用 Jackson 库,但也可以是 GSON、JSON-B 等):
@Override
public JsonNode process(StringTemplate stringTemplate)
throws IllegalArgumentException {
for (Object value : stringTemplate.values()) {
if (!PHONE_PATTERN.matcher(
(CharSequence) value).matches()) {
throw new IllegalArgumentException(
"This is not a valid phone number");
}
}
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.readTree(StringTemplate.interpolate(
stringTemplate.fragments(), stringTemplate.values()));
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
这次,返回的类型是JsonNode:
PhoneProcessor pp = new PhoneProcessor();
String workPhone = "072-825-9009";
String homePhone = "(040)234-9670";
JsonNode jsonMessage = pp."""
{ "contact": {
"work": "\{workPhone}",
"home": "\{homePhone}"
}
}
""";
在捆绑的代码中,您还可以找到一个使用 lambda 表达式编写之前自定义模板处理器的示例。此外,您还可以找到一个示例,其中对于无效的表达式,我们只是用默认值替换它们,而不是抛出异常。
请注意,最近的一篇文章《字符串模板更新(JEP 459)》,您可以在以下链接找到:mail.openjdk.org/pipermail/amber-spec-experts/2024-March/004010.html,指出使用这种方式,处理器最终将被更简单的方法调用所取代。
17. 创建一个区域设置
Java 的Locale(java.util.Locale)代表一个封装有关特定地理、政治或文化区域信息的对象——即用于国际化的对象。Locale通常与DateFormat/DateTimeFormatter一起使用,以特定于国家的格式表示日期时间,使用NumberFormat(或其子类DecimalFormat)以特定于国家的格式表示数字(例如,表示特定货币的金额),或使用MessageFormat为特定国家创建格式化消息。
对于最受欢迎的区域设置,Java 提供了一系列常量(例如,Locale.GERMANY、Locale.CANADA等)。对于不在该列表上的区域设置,我们必须使用在几个 RFC 中定义的格式。最常见的是使用语言模式(例如,ro代表罗马尼亚)或语言 _ 国家模式(例如,ro_RO代表罗马尼亚,en_US代表美国,等等)。有时,我们可能需要语言 _ 国家 _ 变体模式,其中变体有助于映射软件供应商添加的附加功能,例如浏览器或操作系统(例如,de_DE_WIN是德国德语使用者的区域设置,针对 Windows)。然而,有两个区域设置被视为非规范:ja_JP_JP(代表在日本使用的日语)和th_TH_TH(代表在泰国使用的泰语,包括泰语数字)。
虽然你可以从其全面的文档中了解更多关于Locale的信息,但让我们提一下,在 JDK 19 之前,我们可以通过其三个构造函数之一创建一个Locale——最常见的是通过Locale(String language, String country),如下所示:
Locale roDep = new Locale("ro", "RO"); // locale for Romania
当然,如果你的Locale已经定义了一个常量,你可以在代码中直接嵌入该常量,或者简单地声明一个Locale,如下所示(这里以德国为例):
Locale de = Locale.GERMANY; // de_DE
另一种方法是通过Locale.Builder的 setter 链:
Locale locale = new Locale.Builder()
.setLanguage("ro").setRegion("RO").build();
或者,这也可以通过Locale.forLanguageTag()来完成,以遵循 IETF BCP 47 标准语言标签(这可以用来表示复杂标签,如特定于中国的中文、普通话、简体字和“zh-cmn-Hans-CN”):
Locale locale = Locale.forLanguageTag("zh-cmn-Hans-CN");
此外,Java 支持语言范围。这意味着我们可以定义一组具有某些特定属性的标签。例如,“de-*"代表一个识别任何地区的德语的语言范围:
Locale.LanguageRange lr1
= new Locale.LanguageRange("de-*", 1.0);
Locale.LanguageRange lr2
= new Locale.LanguageRange("ro-RO", 0.5);
Locale.LanguageRange lr3
= new Locale.LanguageRange("en-*", 0.0);
之前的Locale.LanguageRange()构造函数接受两个参数:语言范围及其权重(1.0、0.5、0.0)。通常,这个权重揭示了用户的偏好(最高为 1.0,最低为 0.0)。权重对于定义优先级列表很有用,如下所示(我们更喜欢西班牙的卡斯蒂利亚语(西班牙)而不是墨西哥的西班牙语和巴西的葡萄牙语):
String rangeString = "es-ES;q=1.0,es-MX;q=0.5,pt-BR;q=0.0";
List<Locale.LanguageRange> priorityList
= Locale.LanguageRange.parse(rangeString);
注意定义一个有效的偏好字符串,以便parse()方法可以工作。
从 JDK 19 开始,Locale的三个构造函数已被弃用,我们可以依赖三个静态的of()方法。通过适当的of()方法,之前的代码等价于:
Locale ro = Locale.of("ro", "RO"); // ro_RO
这里还有两个示例:
Locale de = Locale.of("de" ,"DE", "WIN");
Locale it = Locale.of("it"); // similar to Locale.ITALIAN
使用Locale非常简单。以下是一个使用之前的ro通过DateFormat格式化罗马尼亚和意大利日期时间的示例:
// 7 ianuarie 2023, 14:57:42 EET
DateFormat rodf = DateFormat.getDateTimeInstance(
DateFormat.LONG, DateFormat.LONG, ro);
// 7\. Januar 2023 um 15:05:29 OEZ
DateFormat dedf = DateFormat.getDateTimeInstance(
DateFormat.LONG, DateFormat.LONG, de);
在下一个问题中,我们继续区域设置的旅程。
18. 自定义本地化日期时间格式
从 JDK 8 开始,我们有一个包含LocalDate、LocalTime、LocalDateTime、ZonedDateTime、OffsetDateTime和OffsetTime等类的综合日期时间 API。
我们可以通过 DateTimeFormatter.ofPattern() 简单地格式化这些类返回的日期时间输出。例如,这里,我们通过 y-MM-dd HH:mm:ss 模式格式化一个 LocalDateTime:
// 2023-01-07 15:31:22
String ldt = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("y-MM-dd HH:mm:ss"));
更多示例可以在捆绑的代码中找到。
那么,根据给定的区域设置自定义我们的格式如何——比如,德国?
Locale.setDefault(Locale.GERMANY);
我们通过 ofLocalizedDate(), ofLocalizedTime(), 和 ofLocalizedDateTime() 来完成这个任务,如下面的例子所示:
// 7\. Januar 2023
String ld = LocalDate.now().format(
DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG));
// 15:49
String lt = LocalTime.now().format(
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT));
// 07.01.2023, 15:49:30
String ldt = LocalDateTime.now().format(
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
我们还可以使用:
// Samstag, 7\. Januar 2023 um 15:49:30
// Osteuropäische Normalzeit
String zdt = ZonedDateTime.now().format(
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL));
// 07.01.2023, 15:49:30
String odt = OffsetDateTime.now().format(
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
// 15:49:30
String ot = OffsetTime.now().format(
DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM));
本地化日期、时间或日期时间格式化程序支持四种格式样式:
-
FULL: 使用所有细节的格式。 -
LONG: 使用很多细节但不是全部的格式。 -
MEDIUM: 使用一些细节的格式。 -
SHORT: 尽可能简短地格式化(通常是数字)。
根据本地化元素和格式样式的组合,代码可能会抛出异常,例如 DateTimeException: Unable to extract….。如果您看到这样的异常,那么是时候查阅以下表格了,它提供了接受的组合:

图 1.17:本地化日期、时间和日期时间的格式样式
此外,从 JDK 19 开始,我们还可以使用 ofLocalizedPattern(String pattern)。
我们可以传递 图 1.18 中显示的任何模式。

图 1.18:为 ofLocalizedPattern(String pattern) 构建模式
话虽如此,让我们将当前的区域设置更改为罗马尼亚:
Locale.setDefault(Locale.of("ro", "RO"));
让我们再举一些 ofLocalizedPattern() 的例子:
// 01.2023
String ld = LocalDate.now().format(
DateTimeFormatter.ofLocalizedPattern("yMM"));
// 15:49
String lt = LocalTime.now().format(
DateTimeFormatter.ofLocalizedPattern("Hm"));
// 01.2023, 15:49
String ldt = LocalDateTime.now().format(
DateTimeFormatter.ofLocalizedPattern("yMMHm"));
还有更多:
// 01.2023, 15:49:30 EET
String zdt = ZonedDateTime.now().format(
DateTimeFormatter.ofLocalizedPattern("yMMHmsv"));
// 01.2023, 15:49:30
String odt = OffsetDateTime.now().format(
DateTimeFormatter.ofLocalizedPattern("yMMHms"));
// 15:49:30
String ot = OffsetTime.now().format(
DateTimeFormatter.ofLocalizedPattern("Hms"));
您可以在捆绑的代码中练习所有这些示例。此外,在捆绑的代码中,您可以找到一个使用区域设置和 NumberFormat 格式化不同区域设置(货币)版税金额的应用程序。
19. 恢复始终严格的浮点语义
浮点计算并不容易!即使是某些简单的算术属性也不适用于此类计算。例如,浮点加法或乘法不是结合的。换句话说,(x + y) + z 不等于 x + (y + z),其中 x、y 和 z 是实数。以下是一个测试乘法结合性的快速示例:
double x = 0.8793331;
double y = 12.22933;
double z = 901.98334884433;
double m1 = (x * y) * z; // 9699.617442382583
double m2 = (x * (y * z)); // 9699.617442382581
// m1 == m2 returns false
这意味着浮点算术是实数算术的系统近似。由于一些限制,计算机必须进行近似。例如,精确的浮点输出会非常快地变得非常大。此外,精确的输入是未知的,因此对于不精确的输入,很难获得精确的输出。
为了解决这个问题,Java 必须采用一种 舍入策略。换句话说,Java 必须使用一种能够将实数值映射到浮点值的特殊函数。如今,Java 使用所谓的 四舍五入到最接近的策略。这种策略试图将一个不精确的值四舍五入到最接近的 无限精确的结果。在相等的情况下(可表示的值与不精确值同样接近),具有零最高有效位的值是获胜者。
此外,浮点计算在不同平台上可能会产生不同的输出。换句话说,在不同的芯片架构(例如,16 位、32 位或 64 位处理器)上运行浮点计算可能会导致不同的结果。Java 通过strictfp修饰符解决了这个问题。这个关键字遵循 IEEE 754 浮点计算标准,并在 JDK 1.2 中引入。
重要提示
strictfp修饰符表示所有中间值都符合 IEEE 754 的单精度/双精度。然而,一些硬件特定问题导致strictfp在 JDK 1.2 中成为可选的。
假设我们需要实现一个科学计算器。显然,我们的计算器必须在各个平台上提供一致的结果,因此我们依赖于strictfp,如下所示:
public **strictfp** final class ScientificCalculator {
private ScientificCalculator() {
throw new AssertionError("Cannot be instantiated");
}
public static double multiply(
final double v1, final double v2) {
return v1 * v2;
}
public static double division(
final double v1, final double v2) {
return v1 / v2;
}
// more computational methods
}
与类一起使用的strictfp修饰符确保该类的所有成员方法都利用其效果。现在,我们在各个平台上有了一致的结果。您可以在捆绑的代码中找到这个示例。
重要提示
strictfp修饰符可用于类(以及嵌套类),非抽象方法和接口。它不能用于变量、构造函数或抽象方法。
当在接口上使用strictfp修饰符时,有一些重要的事项需要考虑,如下所述:
-
它不适用于接口中声明的
abstract方法。 -
它应用于接口中声明的
default方法。 -
它不适用于实现接口的类中定义的方法。
-
它应用于接口内部类中声明的所有方法。
例如,考虑以下strictfp接口:
public **strictfp** interface Rectangle {
default double area(double length, double width) {
...
}
double diagonal(double length, double width);
public class Trigonometry {
public static double smallAngleOfDiagonals(
double length, double width) {
...
}
public static double bigAngleOfDiagonals(
double length, double width) {
...
}
}
}
此外,还有一个实现先前strictfp接口的非strictfp类:
public class Main implements Rectangle {
@Override
public double diagonal(double length, double width) {
...
}
public double perimeter(double length, double width) {
...
}
}
要找出哪些工件是strictfp,我们可以运行一小段 Java 反射代码,这将揭示每个方法的修饰符:
public static void displayModifiers(
Class clazz, String member) {
try {
int modifiers = clazz.getDeclaredMethod(member,
double.class, double.class).getModifiers();
System.out.println(member + " has the following
modifiers: " + Modifier.toString(modifiers));
} catch (NoSuchMethodException | SecurityException e) {
e.printStackTrace(System.out);
}
}
然后,让我们称这个方法为:
// public
displayModifiers(Main.class, "diagonal");
// public
displayModifiers(Main.class, "perimeter");
// public abstract
displayModifiers(Main.class.getInterfaces()[0], "diagonal");
// public strictfp
displayModifiers(Main.class.getInterfaces()[0], "area");
// public static strictfp
displayModifiers(Rectangle.Trigonometry.class,
"smallAngleOfDiagonals");
// public static strictfp
displayModifiers(Rectangle.Trigonometry.class,
"bigAngleOfDiagonals");
如您所见,strictfp修饰符并不适用于我们所有的方法。因此,如果我们需要在perimeter()和diagonal()上使用strictfp,那么我们必须手动添加它:
@Override
**strictfp** public double diagonal(double length, double width) {
...
}
**strictfp** public double perimeter(double length, double width) {
...
}
然而,从 JDK 17 开始,这个领域有一些重大新闻。
重要提示
硬件已经严重发展,导致strictfp在 JDK 1.2 中成为可选的问题已经得到解决,因此默认的浮点语义可以更改为一致严格。换句话说,从 JDK 17 开始,不再需要显式使用strictfp。JEP 306,恢复始终严格的浮点语义,提供了这种功能。因此,从 JDK 17 开始,所有浮点操作都是一致的严格。
除了对我们这些开发者来说是好消息之外,JEP 306 还支持几个 Java 类,如java.lang.Math和java.lang.StrictMath,它们变得更加健壮且易于实现。
20. 计算 int/long 的数学绝对值和结果溢出
数学绝对值通过在两个管道操作符之间放置值来表示,并按以下方式计算:
|x| = x, |-x| = x
这通常用于计算/表示距离。例如,想象 0 代表海平面,我们有一个潜水员和一名登山者。潜水员在水下-45 英尺(注意,我们使用负数来表示潜水员在水中的深度)。同时,登山者已经爬升了 30 英尺。他们哪一个离海平面(0)更近?我们可能会认为由于-45 < 30,潜水员更近,因为它的值更小。然而,我们可以通过应用数学绝对值轻松找到正确答案,如下所示:
|-45| = 45, |30| = 30
45 > 30, so the climber is closer to the sea level (0)
现在,让我们通过以下示例深入了解解决方案:
int x = -3;
int absofx = Math.abs(x); // 3
这是Math.abs()的一个非常简单的用例,它返回给定整数的数学绝对值。现在,让我们将此方法应用于以下大数:
int x = Integer.MIN_VALUE; // -2,147,483,648
int absofx = Math.abs(x); // -2,147,483,648
这不好!由于|Integer.MIN_VALUE| > |Integer.MAX_VALUE|,int域溢出了。预期的结果是正的 21,474,836,48,但这不适合int域。然而,将x类型从int改为long将解决问题:
long x = Integer.MIN_VALUE; // -2,147,483,648
long absofx = Math.abs(x); // 2,147,483,648
但如果问题不是由Integer.MIN_VALUE引起,而是由Long.MIN_VALUE引起,问题将再次出现:
long y = Long.MIN_VALUE;// -9,223,372,036,854,775,808
long absofy = Math.abs(y); // -9,223,372,036,854,775,808
从 JDK 15 开始,Math类增加了两个absExact()方法。一个用于int,一个用于long。如果数学绝对结果容易超出int或long的范围(例如,Integer/Long.MIN_VALUE值超出了正int/long范围),这些方法非常有用。在这种情况下,这些方法会抛出ArithmeticException,而不是返回误导性的结果,如下例所示:
int absofxExact = Math.absExact(x); // ArithmeticException
long absofyExact = Math.absExact(y); // ArithmeticException
在函数式风格上下文中,一个潜在的解决方案将依赖于UnaryOperator函数式接口,如下所示:
IntUnaryOperator operatorInt = Math::absExact;
LongUnaryOperator operatorLong = Math::absExact;
// both throw ArithmeticException
int absofxExactUo = operatorInt.applyAsInt(x);
long absofyExactUo = operatorLong.applyAsLong(y);
当处理大数时,也要关注BigInteger(不可变任意精度整数)和BigDecimal(不可变任意精度有符号十进制数)。
21. 计算参数和结果的商,结果溢出
让我们从两个简单的计算开始,如下所示:
-4/-1 = 4, 4/-1 = -4
这是一个非常简单的用例,按预期工作。现在,让我们保持除数为-1,并将被除数改为Integer.MIN_VALUE(-2,147,483,648):
int x = Integer.MIN_VALUE;
int quotient = x/-1; // -2,147,483,648
这次,结果是不正确的。由于|Integer.MIN_VALUE| > |Integer.MAX_VALUE|,int域溢出了。它应该是正的 21,474,836,48,但这不适合int域。然而,将x类型从int改为long将解决问题:
long x = Integer.MIN_VALUE;
long quotient = x/-1; // 2,147,483,648
但如果问题不是由Integer.MIN_VALUE引起,而是由Long.MIN_VALUE引起,问题将再次出现:
long y = Long.MIN_VALUE; // -9,223,372,036,854,775,808
long quotient = y/-1; // -9,223,372,036,854,775,808
从 JDK 18 开始,Math 类增加了两个 divideExact() 方法。一个用于 int,一个用于 long。如果除法结果容易溢出 int 或 long(如 Integer/Long.MIN_VALUE 溢出正 int/long 范围),这些方法非常有用。在这种情况下,这些方法会抛出 ArithmeticException 而不是返回误导性的结果,如下例所示:
// throw ArithmeticException
int quotientExact = Math.divideExact(x, -1);
在函数式风格语境中,一个潜在的解决方案将依赖于 BinaryOperator 函数式接口,如下所示:
// throw ArithmeticException
BinaryOperator<Integer> operator = Math::divideExact;
int quotientExactBo = operator.apply(x, -1);
正如我们在前一个问题中也说的,当处理大数时,也要关注 BigInteger(不可变任意精度整数)和 BigDecimal(不可变任意精度有符号十进制数)。
22. 计算大于/等于代数商的最大/最小值
最大的值,我们理解为最接近正无穷大的值,而最小的值,我们理解为最接近负无穷大的值。
从 JDK 8 开始,可以通过 floorDiv(int x, int y) 和 floorDiv(long x, long y) 来计算小于或等于代数商的最大值。从 JDK 9 开始,我们也有 floorDiv(long x, int y)。
从 JDK 18 开始,可以通过 ceilDiv(int x, int y)、ceilDiv(long x, int y) 和 ceilDiv(long x, long y) 来计算大于或等于代数商的最小值。
然而,这些函数都无法处理前一个问题中提到的角点情况除法,即 Integer.MIN_VALUE/-1 和 Long.MIN_VALUE/-1:
int x = Integer.MIN_VALUE; // or, x = Long.MIN_VALUE
Math.floorDiv(x, -1); // -2,147,483,648
Math.ceilDiv(x, -1); // -2,147,483,648
从 JDK 18 开始,每当 floorDiv()/ceilDiv() 返回的结果可能溢出 int 或 long 范围时,我们可以使用 floorDivExact() 和 ceilDivExact()。这些方法为 int 和 long 参数提供了不同的版本。正如你可能已经直觉到的,这些方法会抛出 ArithmeticException 而不是返回误导性的结果,如下例所示:
// throw ArtihmeticException
int resultFloorExact = Math.floorDivExact(x, -1);
// throw ArtihmeticException
int resultCeilExact = Math.ceilDivExact(x, -1);
在函数式风格语境中,一个潜在的解决方案将依赖于 BinaryOperator 函数式接口,如下所示:
// throw ArithmeticException
BinaryOperator<Integer> operatorf = Math::floorDivExact;
int floorExactBo = operatorf.apply(x, -1);
// throw ArithmeticException
BinaryOperator<Integer> operatorc = Math::ceilDivExact;
int ceilExactBo = operatorc.apply(x, -1);
完成!正如你所知,当处理大数时,也要关注 BigInteger(不可变任意精度整数)和 BigDecimal(不可变任意精度有符号十进制数)。这些可能会帮到你。
23. 从 double 中获取整数部分和小数部分
你知道那些如果你知道解决方案就非常简单,如果你不知道就看起来非常困难的问题吗?这正是那种问题。解决方案非常简单,如下面的代码所示:
double value = -9.33543545;
double fractionalPart = value % 1;
double integralPart = value - fractionalPart;
这很简单;我认为你不需要进一步的解释。但这种方法并不完全准确。我的意思是,整数部分是 -9,但返回的是 -9.0。另外,小数部分是 -0.33543545,但返回的值是 -0.3354354500000003。
如果我们需要更精确的结果,那么使用 BigDecimal 会更有用:
BigDecimal bd = BigDecimal.valueOf(value);
int integralPart = bd.intValue();
double fractionalPart = bd.subtract(
BigDecimal.valueOf(integralPart)).doubleValue();
这次,结果是 -9 和 -0.33543545。
24. 测试双精度浮点数是否为整数
首先,让我们考虑以下预期结果(false 表示双精度浮点数不是整数):
double v1 = 23.11; // false
double v2 = 23; // true
double v3 = 23.0; // true
double v4 = Double.NaN; // false
double v5 = Double.NEGATIVE_INFINITY; // false
double v6 = Double.POSITIVE_INFINITY; // false
测试一个双精度浮点数是否为整数的最常见方法可能是一个简单的强制类型转换,如下所示:
public static boolean isDoubleIntegerV1(double v) {
return v == (int) v;
}
然而,还有其他几种选择。例如,我们可以依赖模运算,如下所示:
public static boolean isDoubleIntegerV2(double v) {
return v % 1 == 0;
}
或者,我们可以依赖 Math.floor() 和 Double.isFinite() 方法。如果给定的双精度浮点数是一个有限数,并且等于 Math.floor() 的结果,那么它就是一个整数:
public static boolean isDoubleIntegerV3(double v) {
return ((Math.floor(v) == v) && Double.isFinite(v));
}
我们也可以通过 Math.ceil() 来替换这个等式:
public static boolean isDoubleIntegerV4(double v) {
return (Math.floor(v) == Math.ceil(v)
&& Double.isFinite(v));
}
此外,我们可以将 Double.isFinite() 与 Math.rint() 结合使用,如下所示:
public static boolean isDoubleIntegerV5(double v) {
return ((Math.rint(v) == v) && Double.isFinite(v));
}
最后,我们可以依赖 Guava 的 DoubleMath.isMathematicalInteger() 方法:
public static boolean isDoubleIntegerV6(double v) {
return DoubleMath.isMathematicalInteger(v);
}
但是,这些方法中哪一个性能更好?你更倾向于哪一个?好吧,让我们看看基准测试有什么要说的:

图 1.19:基准测试结果
根据这些结果,结论相当明显——基于模运算的解决方案应该避免使用。此外,Guava 的解决方案似乎比其他方案略慢。
25. 简要介绍 Java(无)符号整数
有符号 值(或变量),如有符号整数或有符号长整型,允许我们表示负数和正数。
无符号 值(或变量),如无符号整数或无符号长整型,允许我们仅表示正数。
同一类型的有符号和无符号值(变量)具有相同的范围。然而,正如你在以下图中可以看到的,无符号变量覆盖了更大的数值范围。

图 1.20:有符号和无符号整数
有符号 32 位整数范围从 –2,147,483,648 到 2,147,483,647(大约 40 亿个值)。无符号 32 位整数范围从 0 到 4,294,967,295(也是大约 40 亿个值)。
当我们使用有符号整数变量时,我们可以使用 20 亿个正数值,但当我们使用无符号整数变量时,我们可以使用 40 亿个正数值。图中的阴影部分表示额外的 20 亿个正整数值。
通常,当我们根本不需要负数值时(例如,要计数某些事件的发生),我们需要使用位于 图 1.20 中哈希区域的值,这时就需要无符号值。
Java 仅支持使用流行的 二进制补码 表示法在有符号系统中表示的有符号整数(有关二进制补码表示法和位操作的详细解释,请参阅 Java 完整编码面试指南,第九章,位操作)。然而,从 JDK 8 开始,我们也拥有了 无符号整数 API,它增加了对无符号算术的支持。
此外,JDK 9 提供了一个名为 Math.multiplyHigh(long x, long y) 的方法。此方法返回一个 long 类型的值,表示两个 64 位因子的 128 位乘积的最高 64 位。以下图解说明了这一说法:

图 1.21:两个 64 位因子 128 位乘积的最高 64 位
例如:
long x = 234253490223L;
long y = -565951223449L;
long resultSigned = Math.multiplyHigh(x, y); // -7187
返回的结果 (-7187) 是一个有符号值。此方法的未签名版本 unsignedMultiplyHigh(long x, long y) 在 JDK 18 中引入,其工作方式如下:
// 234253483036
long resultUnsigned = Math.unsignedMultiplyHigh(x, y);
因此,unsignedMultiplyHigh(long x, long y) 返回一个 long 类型的值,表示两个无符号 64 位因子的无符号 128 位乘积的最高 64 位。
然而,请记住,Java 支持无符号算术,而不是无符号值/变量。但是,多亏了 Data Geekery 公司(因其著名的 jOOQ 而闻名),我们有了 jOOU (Java Object Oriented Unsigned)项目,该项目旨在将无符号数字类型引入 Java。虽然你可以在这里探索这个项目 github.com/jOOQ/jOOU,以下是一个定义无符号 long 的示例:
// using jOOU
ULong ux = ulong(234253490223L); // 234253490223
ULong uy = ulong(-565951223449L); // 18446743507758328167
这是它在 unsignedMultiplyHigh(long x, long y) 中的使用示例:
long uResultUnsigned = Math.unsignedMultiplyHigh(
ux.longValue(), uy.longValue());
你可以在捆绑的代码中找到这些示例。
26. 返回地板/天花板模数
有 被除数 / 除数 = 商 的计算,我们知道对 (被除数, 除数) 对应用 floor 操作返回的是小于或等于代数 商 的最大整数。我们所说的最大整数是指最接近正无穷大的整数。从 JDK 8 开始,这个操作可以通过 Math.floorDiv() 方法获得,从 JDK 18 开始,可以通过 Math.floorDivExact() 方法获得。
另一方面,对 (被除数, 除数) 对应用 ceil 操作返回的是大于或等于代数 商 的最小整数。我们所说的最小整数是指最接近负无穷大的整数。从 JDK 18 开始,这个操作可以通过 Math.ceilDiv() 和 Math.ceilDivExact() 方法获得。
更多细节请参阅 问题 22。
现在,基于 floor 和 ceil 操作,我们可以定义以下 floor/ceil modulus 关系:
Floor_Modulus = dividend -
(floorDiv(dividend, divisor) * divisor)
Ceil_Modulus = dividend -
(ceilDiv(dividend, divisor) * divisor)
因此,我们可以在代码中这样写:
int dividend = 162;
int divisor = 42; // 162 % 42 = 36
int fd = Math.floorDiv(dividend, divisor);
int fmodJDK8 = dividend - (fd * divisor); // 36
int cd = Math.ceilDiv(dividend, divisor);
int cmodJDK18 = dividend - (cd * divisor); // -6
从 JDK 8 开始,可以通过 Math.floorMod() 方法获取模数,如下所示:
int dividend = 162;
int divisor = 42;
int fmodJDK8 = Math.floorMod(dividend, divisor); // 36
这里,我们使用 floorMod(int dividend, int divisor)。但也可以使用另外两种形式:floorMod(long dividend, long divisor) 和从 JDK 9 开始的 floorMod(long dividend, int divisor)。
如果 被除数 % 除数 为 0,则 floorMod() 为 0。如果 被除数 % 除数 和 floorMod() 都不为 0,则它们的结果仅在参数的符号不同时才不同。
从 JDK 18 开始,可以通过 Math.ceilMod() 方法获得 ceiling modulus,如下所示:
int cmodJDK18 = Math.ceilMod(dividend, divisor); // -6
这里,我们使用 ceilMod(int dividend, int divisor)。但也可以使用另外两种形式:ceilMod(long dividend, int divisor) 和从 JDK 9 开始的 ceilMod(long dividend, int divisor)。
如果被除数 % 除数为 0,则ceilMod()为 0。如果被除数 % 除数和ceilMod()不为 0,那么它们的结果只有在参数的符号相同时才不同。
此外,floorMod()和floorDiv()之间的关系如下:
dividend == floorDiv(dividend, divisor) * divisor
+ floorMod(dividend, divisor)
此外,ceilMod()和ceilDiv()之间的关系如下:
dividend == ceilDiv(dividend, divisor) * divisor
+ ceilMod(dividend, divisor)
注意,如果除数为 0,则floorMod()和ceilMod()都会抛出ArithmeticException。
27. 收集给定数的所有质因数
一个质数是只能被自己和 1 整除的数(例如,2,3 和 5 是质数)。给定一个数,我们可以提取它的质因数,如下所示:

图 1.22:90 的质因数是 2,3,3 和 5
90 的质因数是 2,3,3 和 5。根据图 1.22,我们可以创建一个算法来解决这个问题,如下所示:
-
定义一个
List来收集给定v的质因数。 -
将变量
s初始化为 2(最小的质数)。 -
如果
v % s为 0,则收集s作为质因数,并计算新的v为v / s。 -
如果
v % s不为 0,则将s增加 1。 -
只要
v大于 1,就重复步骤 3。
在代码行中,这个 O(n)算法(对于合数是 O(log n))可以表示如下:
public static List<Integer> factors(int v) {
List<Integer> factorsList = new ArrayList<>();
int s = 2;
while (v > 1) {
// each perfect division give us a prime factor
if (v % s == 0) {
factorsList.add(s);
v = v / s;
} else {
s++;
}
}
return factorsList;
}
在捆绑的代码中,你可以找到两种更多的方法。此外,你还会找到一个应用,它可以计算小于给定数字v(v应该是正数)的质数数量。
28. 使用巴比伦方法计算一个数的平方根
信不信由你,古代的巴比伦人(大约公元前 1500 年)在牛顿发现的方法流行之前就知道如何估计平方根。
从数学的角度讲,估计v > 0的平方根的巴比伦方法是以下图所示的递归关系:

图 1.23:巴比伦平方根近似的递归关系
递归公式从初始猜测 x[0]开始。接下来,我们通过将 x[n-1]代入右侧的公式并计算表达式来计算 x[1],x[2],…,x[n]。
例如,让我们尝试将这个公式应用于估计 65 的平方根(结果是 8.06)。让我们以 x[0]作为 65/2 开始,所以 x[0] = 32.5,然后让我们计算 x[1]如下:

有 x[1],我们可以按照以下方式计算 x[2]:

有 x[2],我们可以按照以下方式计算 x[3]:

我们正在接近最终结果。有 x[3],我们可以按照以下方式计算 x[4]:

完成!经过四次迭代,我们发现 65 的平方根是 8.06。当然,作为一个真实值的近似,我们可以继续迭代直到达到所需的精度。更高的精度需要更多的迭代。
基于巴比伦方法来近似v > 0的平方根的算法有几个步骤,如下所示:
-
首先,选择一个任意正数,
x(它越接近最终结果,所需的迭代次数就越少)。例如,我们以x = v/2作为初始猜测开始。 -
初始化
y = 1,并选择所需的精度(例如,e = 0.000000000001)。 -
直到达到精度(
e),执行以下操作:-
将下一个近似值(
xnext)计算为x和y的平均值。 -
使用下一个近似值将
y设置为v/xnext。
-
因此,在代码行中,我们有以下片段:
public static double squareRootBabylonian(double v) {
double x = v / 2;
double y = 1;
double e = 0.000000000001; // precision
while (x - y > e) {
x = (x + y) / 2;
y = v / x;
}
return x;
}
在捆绑的代码中,你还可以看到一个有用的实现,如果你知道v是一个完全平方数(例如,25、144、169 等等)。
29. 将浮点数四舍五入到指定的小数位数
考虑以下float数字和我们想要保留的小数位数:
float v = 14.9877655f;
int d = 5;
因此,向上取整后的预期结果是 14.98777。
我们可以用至少三种直接的方式解决这个问题。例如,我们可以依赖BigDecimal API,如下所示:
public static float roundToDecimals(float v, int decimals) {
BigDecimal bd = new BigDecimal(Float.toString(v));
bd = bd.setScale(decimals, RoundingMode.HALF_UP);
return bd.floatValue();
}
首先,我们将给定的float创建为一个BigDecimal数字。其次,我们将这个BigDecimal缩放到所需的小数位数。最后,我们返回新的float值。
另一种方法可以依赖于DecimalFormat,如下所示:
public static float roundToDecimals(float v, int decimals) {
DecimalFormat df = new DecimalFormat();
df.setMaximumFractionDigits(decimals);
return Float.parseFloat(df.format(v));
}
我们通过setMaximumFractionDigits()定义格式,并简单地使用这个格式在给定的float上。返回的String通过Float.parseFloat()转换为最终的float。
最后,我们可以采用一种更加晦涩但自解释的方法,如下所示:
public static float roundToDecimals(float v, int decimals) {
int factor = Integer.parseInt(
"1".concat("0".repeat(decimals)));
return (float) Math.round(v * factor) / factor;
}
你可以在捆绑的代码中练习这些示例。请随意添加你自己的解决方案。
30. 在最小和最大之间夹紧一个值
假设我们有一个能够调整给定压力在一定范围内的压力调节器。例如,如果传递的压力低于最小压力,则调节器将压力增加到最小压力。另一方面,如果传递的压力高于最大压力,则调节器将压力降低到最大压力。此外,如果传递的压力在最小(包含)和最大(包含)压力之间,则不发生任何操作——这是正常压力。
编写这个场景可以直接进行,如下所示:
private static final int MIN_PRESSURE = 10;
private static final int MAX_PRESSURE = 50;
public static int adjust(int pressure) {
if (pressure < MIN_PRESSURE) {
return MIN_PRESSURE;
}
if (pressure > MAX_PRESSURE) {
return MAX_PRESSURE;
}
return pressure;
}
真棒!你可以找到不同的方法以更短、更智能的方式表达这段代码,但自 JDK 21 开始,我们可以通过Math.clamp()方法来解决这个问题。这个方法的一个版本是clamp(long value, int min, int max),它将给定的value夹在给定的min和max之间。例如,我们可以通过clamp()方法重写之前的代码,如下所示:
public static int adjust(int pressure) {
return Math.clamp(pressure, MIN_PRESSURE, MAX_PRESSURE);
}
很酷,对吧!clamp()方法背后的逻辑依赖于以下代码行:
return (int) Math.min(max, Math.max(value, min));
clamp()的其他版本有clamp(long value, long min, long max)、clamp(float value, float min, float max)和clamp(double value, double min, double max)。
31. 不使用循环、乘法、位运算、除法和运算符乘以两个整数
这个问题的解决方案可以从以下代数公式开始,也称为特殊二项式乘积公式:

图 1.24:从二项式公式中提取 a*b
现在我们有了 ab 的乘积,只剩下一个问题。ab 的公式中包含一个除以 2 的操作,我们不允许显式地使用除法操作。然而,除法操作可以通过递归的方式模拟,如下所示:
private static int divideByTwo(int d) {
if (d < 2) {
return 0;
}
return 1 + divideByTwo(d - 2);
}
现在没有任何东西可以阻止我们使用这种递归代码来实现 a*b,如下所示:
public static int multiply(int p, int q) {
// p * 0 = 0, 0 * q = 0
if (p == 0 || q == 0) {
return 0;
}
int pqSquare = (int) Math.pow(p + q, 2);
int pSquare = (int) Math.pow(p, 2);
int qSquare = (int) Math.pow(q, 2);
int squareResult = pqSquare - pSquare - qSquare;
int result;
if (squareResult >= 0) {
result = divideByTwo(squareResult);
} else {
result = 0 - divideByTwo(Math.abs(squareResult));
}
return result;
}
在捆绑的代码中,你还可以练习对这个问题的递归方法。
32. 使用 TAU
什么是 TAU?
简答:它是希腊字母
。
长答:它是一个希腊字母,用于定义圆的周长与其半径的比例。简单地说,TAU 是一个完整圆的一圈,所以是 2*PI。
TAU 允许我们以更直观和简单的方式表示正弦、余弦和角度。例如,已知的 30°、45°、90°等角度可以通过 TAU 作为圆的分数来轻松表示,如下面的图所示:

图 1.25:使用 TAU 表示的角度
这比 PI 更直观。它就像将饼切成相等的部分。例如,如果我们切在 TAU/8(45°),这意味着我们将饼切成八等份。如果我们切在 TAU/4(90°),这意味着我们将饼切成四等份。
TAU 的值是 6.283185307179586 = 2 * 3.141592653589793。因此,TAU 与 PI 的关系是 TAU=2*PI。在 Java 中,著名的 PI 是通过Math.PI常量表示的。从 JDK 19 开始,Math类增加了Math.TAU常量。
让我们考虑以下简单问题:一个圆的周长为 21.33 厘米。这个圆的半径是多少?
我们知道 C = 2PIr,其中 C 是周长,r 是半径。因此,r = C/(2*PI)或 r = C/TAU。在代码行中,我们有:
// before JDK 19, using PI
double r = 21.33 / (2 * Math.PI);
// starting with JDK 19, using TAU
double r = 21.33 / Math.TAU;
这两种方法都返回半径等于 3.394。
33. 选择伪随机数生成器
当我们抛硬币或掷骰子时,我们说我们看到“真实”或“自然”的随机性在起作用。即便如此,也有一些工具假装它们能够预测抛硬币、掷骰子或旋转轮盘的路径,特别是如果满足某些上下文条件的话。
计算机可以通过所谓的随机生成器使用算法生成随机数。由于涉及到算法,生成的数字被认为是伪随机数。这被称为“伪”随机性。显然,伪随机数也是可预测的。为什么会这样?
伪随机生成器通过 播种 数据开始其工作。这是生成器的秘密(种子),它代表用于生成伪随机数的起始数据。如果我们知道算法的工作原理以及 种子 是什么,那么输出是可预测的。如果我们不知道 种子,那么可预测的速率非常低。因此,选择合适的 种子 是每个伪随机生成器的一个重要步骤。
直到 JDK 17,Java 生成伪随机数的 API 有点晦涩。基本上,我们有一个健壮的 API,封装在众所周知的 java.util.Random 类中,以及 Random 的两个子类:SecureRandom(密码学伪随机生成器)和 ThreadLocalRandom(非线程安全的伪随机生成器)。从性能角度来看,这些伪随机生成器之间的关系是 SecureRandom 比较慢,比 Random 慢,而 Random 又比 ThreadLocalRandom 慢。
除了这些类,我们还有 SplittableRandom。这是一个非线程安全的伪生成器,能够在每次调用其 split() 方法时生成一个新的 SplittableRandom。这样,每个线程(例如,在 fork/join 架构中)都可以使用自己的 SplittableGenerator。
以下图显示了直到 JDK 17 的伪随机生成器的类层次结构:

图 1.26:JDK 17 之前 Java 伪随机生成器的类层次结构
如此架构所示,在伪随机生成器之间切换或在不同类型的算法之间选择确实很麻烦。看看那个 SplittableRandom – 它迷失在无人之地。
从 JDK 17 开始,我们有了更灵活和强大的伪随机数生成 API。这是一个基于接口的 API(与 JEP 356 一起发布),围绕新的 RandomGenerator 接口运行。以下是 JDK 17 的增强类层次结构:

图 1.27:从 JDK 17 开始的 Java 伪随机生成器的类层次结构
RandomGenerator 接口代表了此 API 的巅峰。它代表了一种生成伪随机数的通用和统一协议。此接口已经接管了 Random API 并添加了一些更多功能。
RandomGenerator 接口通过五个子接口扩展,旨在为五种不同类型的伪随机生成器提供特殊协议。
-
StreamableGenerator可以返回RandomGenerator对象的流 -
SplittableGenerator可以从这个生成器返回一个新的生成器(自身分割) -
JumpableGenerator可以跳过适量的抽取 -
LeapableGenerator可以跳过大量抽取 -
ArbitrarilyJumpableGenerator可以跳过任意数量的抽取
获取默认的 RandomGenerator 可以按照以下方式完成(这是开始生成伪随机数的最简单方法,但你无法控制选择的内容):
RandomGenerator defaultGenerator
= RandomGenerator.getDefault();
// start generating pseudo-random numbers
defaultGenerator.nextInt/Float/...();
defaultGenerator.ints/doubles/...();
除了这些接口之外,新的 API 还附带了一个类(RandomGeneratorFactory),它是一个基于所选算法的伪随机生成器工厂。有三种新的算法组(很可能还有更多即将到来);这些组如下:
-
LXM 组;
-
L128X1024MixRandom -
L128X128MixRandom -
L128X256MixRandom -
L32X64MixRandom -
L64X1024MixRandom -
L64X128MixRandom -
L64X128StarStarRandom -
L64X256MixRandom
-
-
Xoroshiro 组:
Xoroshiro128PlusPlus
-
Xoshiro 组:
Xoshiro256PlusPlus
突出的算法是默认的(L32X64MixRandom)。
根据伪随机生成器的类型,我们可以选择所有/部分之前的算法。例如,L128X256MixRandom算法可以与SplittableGenerator一起使用,但不能与LeapableGenerator一起使用。所选算法与伪随机生成器不匹配会导致IllegalArgumentException。以下图可以帮助您决定使用哪个算法。

图 1.28:JDK 17 随机生成算法及其属性
此图是通过以下代码生成的,该代码列出了所有可用的算法及其属性(可流式传输、可跳跃、统计等):
Stream<RandomGeneratorFactory<RandomGenerator>> all
= RandomGeneratorFactory.all();
Object[][] data = all.sorted(Comparator.comparing(
RandomGeneratorFactory::group))
.map(f -> {
Object[] obj = new Object[]{
f.name(),
f.group(),
f.isArbitrarilyJumpable(),
f.isDeprecated(),
f.isHardware(),
f.isJumpable(),
f.isLeapable(),
f.isSplittable(),
f.isStatistical(),
f.isStochastic(),
f.isStreamable()
};
return obj;
}).toArray(Object[][]::new);
通过名称或属性选择一个算法可以很容易地完成。
通过名称选择算法
通过名称选择算法可以通过一组静态of()方法完成。在RandomGenerator和RandomGeneratorFactory中都有一个of()方法,可以用来为特定算法创建伪随机生成器,如下所示:
RandomGenerator generator
= RandomGenerator.of("L128X256MixRandom");
RandomGenerator generator
= RandomGeneratorFactory.of("Xoroshiro128PlusPlus")
.create();
接下来,我们可以通过调用一个众所周知的 API(ints()、doubles()、nextInt()、nextFloat()等)来生成伪随机数。
如果我们需要特定的伪随机生成器和算法,则可以使用该生成器的of()方法,如下所示(这里我们创建了一个LeapableGenerator):
LeapableGenerator leapableGenerator
= LeapableGenerator.of("Xoshiro256PlusPlus");
LeapableGenerator leapableGenerator = RandomGeneratorFactory
.<LeapableGenerator>of("Xoshiro256PlusPlus").create();
在SplittableRandom的情况下,您也可以使用构造函数,但不能指定算法:
SplittableRandom splittableGenerator = new SplittableRandom();
在捆绑的代码中,您可以看到更多示例。
通过属性选择算法
如您在图 1.28中看到的,一个算法有一组属性(是否可跳跃、是否统计等)。让我们选择一个既统计又可跳跃的算法:
RandomGenerator generator = RandomGeneratorFactory.all()
.filter(RandomGeneratorFactory::isLeapable)
.filter(RandomGeneratorFactory::isStatistical)
.findFirst()
.map(RandomGeneratorFactory::create)
.orElseThrow(() -> new RuntimeException(
"Cannot find this kind of generator"));
返回的算法可以是Xoshiro256PlusPlus。
34. 使用伪随机数填充长数组
当我们想要用数据填充一个大数组时,可以考虑使用Arrays.setAll()和Arrays.parallelSetAll()。这些方法可以通过应用一个生成器函数来计算数组的每个元素,从而填充数组。
由于我们必须用伪随机数据填充数组,我们应该考虑生成器函数应该是一个伪随机生成器。如果我们想在并行中做这件事,那么我们应该考虑SplittableRandom(JDK 8+)/SplittableGenerator(JDK 17+),它们专门用于在隔离的并行计算中生成伪随机数。总之,代码可能看起来如下(JDK 17+):
SplittableGenerator splittableRndL64X256
= RandomGeneratorFactory
.<SplittableGenerator>of("L64X256MixRandom").create();
long[] arr = new long[100_000_000];
Arrays.parallelSetAll(arr,
x ->splittableRndL64X256.nextLong());
或者,我们可以使用SplittableRandom(这次,我们无法指定算法,JDK 8+):
SplittableRandom splittableRandom = new SplittableRandom();
long[] arr = new long[100_000_000];
Arrays.parallelSetAll(arr, x ->splittableRandom.nextLong());
接下来,让我们看看我们如何创建一个伪随机生成器流。
35. 创建伪随机生成器流
在创建伪随机生成器流之前,让我们先创建一个伪随机数流。首先,让我们看看如何使用传统的Random、SecureRandom和ThreadLocalRandom来实现它。
由于这三个伪随机生成器包含如ints()返回IntStream、doubles()返回DoubleStream等方法,我们可以轻松地生成一个(无限)伪随机数流,如下所示:
Random rnd = new Random();
// the ints() flavor returns an infinite stream
int[] arrOfInts = rnd.ints(10).toArray(); // stream of 10 ints
// or, shortly
int[] arrOfInts = new Random().ints(10).toArray();
在我们的示例中,我们将生成的伪随机数收集到一个数组中。当然,你可以按需处理它们。我们可以通过SecureRandom获得类似的结果,如下所示:
SecureRandom secureRnd = SecureRandom.getInstanceStrong();
int[] arrOfSecInts = secureRnd.ints(10).toArray();
// or, shortly
int[] arrOfSecInts = SecureRandom.getInstanceStrong()
.ints(10).toArray();
那么ThreadLocalRandom呢?如下所示:
ThreadLocalRandom tlRnd = ThreadLocalRandom.current();
int[] arrOfTlInts = tlRnd.ints(10).toArray();
// or, shortly
int[] arrOfTlInts = ThreadLocalRandom.current()
.ints(10).toArray();
如果你只需要一个介于 0.0 和 1.0 之间的双精度浮点数流,那么就依靠Math.random(),它内部使用java.util.Random的一个实例。以下示例收集了一个介于 0.0 和 0.5 之间的双精度浮点数数组。流将在生成第一个大于 0.5 的双精度浮点数时停止:
Supplier<Double> doubles = Math::random;
double[] arrOfDoubles = Stream.generate(doubles)
.takeWhile(t -> t < 0.5d)
.mapToDouble(i -> i)
.toArray();
那么,使用新的 JDK 17 API 呢?RandomGenerator包含众所周知的ints()、doubles()等方法,并且它们在所有子接口中都是可用的。例如,可以使用StreamableGenerator,如下所示:
StreamableGenerator streamableRnd
= StreamableGenerator.of("L128X1024MixRandom");
int[] arrOfStRndInts = streamableRnd.ints(10).toArray();
// or, shortly
StreamableGenerator.of("L128X1024MixRandom")
.ints(10).toArray();
类似地,我们可以使用JumpableGenerator、LeapableGenerator等。
好的,现在让我们回到我们的问题。我们如何生成一个伪随机生成器流?所有RandomGenerator子接口都包含一个名为rngs()的方法,它有不同的形式。没有参数时,此方法返回一个无限流,包含实现RandomGenerator接口的新伪随机生成器。以下代码生成了五个StreamableGenerator实例,每个实例生成了 10 个伪随机整数:
StreamableGenerator streamableRnd
= StreamableGenerator.of("L128X1024MixRandom");
List<int[]> listOfArrOfIntsSG
= streamableRnd.rngs(5) // get 5 pseudo-random generators
.map(r -> r.ints(10)) // generate 10 ints per generator
.map(r -> r.toArray())
.collect(Collectors.toList());
我们可以用JumpableGenerator实现相同的功能,但可能更愿意使用jumps(),它实现了特定于此类型生成器的行为:
JumpableGenerator jumpableRnd
= JumpableGenerator.of("Xoshiro256PlusPlus");
List<int[]> listOfArrOfIntsJG = jumpableRnd.jumps(5)
.map(r -> {
JumpableGenerator jg = (JumpableGenerator) r;
int[] ints = new int[10];
for (int i = 0; i < 10; i++) {
ints[i] = jg.nextInt();
jg.jump();
}
return ints;
})
.collect(Collectors.toList());
同样,我们可以通过LeapableGenerator实现这一点。这次,我们可以使用rngs()或leaps(),它们实现了特定于此类型生成器的行为:
LeapableGenerator leapableRnd
= LeapableGenerator.of("Xoshiro256PlusPlus");
List<int[]> listOfArrOfIntsLG = leapableRnd.leaps(5)
.map(r -> {
LeapableGenerator lg = (LeapableGenerator) r;
int[] ints = new int[10];
for (int i = 0; i < 10; i++) {
ints[i] = lg.nextInt();
lg.leap();
}
return ints;
})
.collect(Collectors.toList());
接下来,让我们看看我们如何交错使用旧版和新的伪随机生成器。
36. 从 JDK 17 的新伪随机生成器获取旧版伪随机生成器
一个遗留的伪随机生成器,如 Random、SecureRandom 或 ThreadLocalRandom,可以将方法调用委托给作为 Random.from()、SecureRandom.from() 或 ThreadLocalRandom.from() 参数传递的 RandomGenerator,如下所示:
Random legacyRnd = Random.from(
RandomGenerator.of("L128X256MixRandom"));
// or, like his
Random legacyRnd = Random.from(RandomGeneratorFactory.
of("Xoroshiro128PlusPlus").create());
// or, like this
Random legacyRnd = Random.from(RandomGeneratorFactory
.<RandomGenerator.SplittableGenerator>of(
"L128X256MixRandom").create());
from() 方法从 JDK 19 开始可用。在捆绑的代码中,您可以看到更多示例。
37. 在线程安全的方式中使用伪随机生成器(多线程环境)
Random 和 SecureRandom 实例是线程安全的。虽然这个说法是正确的,但请注意,当多个线程(多线程环境)使用 Random 实例(或 Math.random())时,您的代码容易受到线程竞争的影响,因为这些线程共享相同的 种子。共享相同的种子涉及到 种子 访问的同步;因此,它打开了线程竞争的大门。显然,线程竞争会导致性能损失,因为线程可能需要在队列中等待以获取对 种子 的访问权。同步通常很昂贵。
Random 的一个替代方案是 ThreadLocalRandom,它为每个线程使用一个 Random 实例,并提供对线程竞争的保护,因为它不包含同步代码或原子操作。缺点是 ThreadLocalRandom 使用每个线程的内部 种子,我们无法控制或修改。
SplittableRandom 不是线程安全的。此外,由 RandomGenerator 的实现组成的新的 API 也不是线程安全的。
话虽如此,可以通过使用线程安全的生成器或在每个新线程中分割一个新的实例来在多线程环境中使用伪随机生成器。当我提到“分割”时,我的意思是使用 SplittableGenerator.splits(long n),其中 n 是分割的数量。查看使用 10 个线程用整数填充 Java 列表(每个线程使用自己的伪随机生成器)的代码:
List<Integer> listOfInts = new CopyOnWriteArrayList<>();
ExecutorService executorService
= Executors.newCachedThreadPool();
SplittableGenerator splittableGenerator
= RandomGeneratorFactory
.<SplittableGenerator>of("L128X256MixRandom").create();
splittableGenerator.splits(10)
.forEach((anotherSplittableGenerator) -> {
executorService.submit(() -> {
int nextInt = anotherSplittableGenerator.nextInt(1_000);
logger.info(() -> "Added in list "
+ nextInt + " by generator "
+ anotherSplittableGenerator.hashCode()
+ " running in thread"
+ Thread.currentThread().getName());
listOfInts.add(nextInt);
});
});
shutdownExecutor(executorService);
输出片段:
INFO: Added in list 192 by generator 1420516714 running in threadpool-1-thread-3
INFO: Added in list 366 by generator 1190794841 running in threadpool-1-thread-8
INFO: Added in list 319 by generator 275244369 running in threadpool-1-thread-9
...
您还可以使用 JumpableGenerator 或 LeapableGenerator。唯一的区别是,JumpableGenerator 使用 jumps(),而 LeapableGenerator 使用 leaps(),而不是 splits()。
摘要
本章收集了与字符串、区域设置、数字和数学相关的 37 个问题,旨在将经典必知问题与通过最新 JDK 功能(如文本块和伪随机生成器)解决的问题混合在一起。如果您想探索其他类似的问题,请考虑 Java 编程问题,第一版,其中有一个类似的章节(第一章),涵盖了另外 39 个问题。
留下评论!
喜欢这本书吗?通过留下亚马逊评论来帮助像您这样的读者。扫描下面的二维码以获取 20% 的折扣代码。

*限时优惠
第二章:对象、不可变性、Switch 表达式和模式匹配
本章包括 30 个问题,涉及其他一些 java.util.Objects 的不太为人所知的功能,不可变性的有趣方面,switch 表达式的最新功能,以及 instanceof 和 switch 表达式的酷模式匹配能力的深入探讨。
在本章结束时,你将了解所有这些主题,这些主题是任何 Java 开发者工具箱中的非可选内容。
问题
使用以下问题来测试你在 Objects、不可变性、switch 表达式和模式匹配方面的编程能力。我强烈建议你在查看解决方案和下载示例程序之前尝试解决每个问题:
-
解释和示例 UTF-8、UTF-16 和 UTF-32:提供关于 UTF-8、UTF-16 和 UTF-32 的详细解释。包括几个代码片段,以展示这些在 Java 中的工作方式。
-
检查从 0 到长度的范围内的子范围:编写一个程序,检查给定的子范围 [给定开始, 给定开始 + 给定结束) 是否在从 [0, 给定长度) 范围内。如果给定的子范围不在 [0, 给定长度) 范围内,则抛出
IndexOutOfBoundsException。 -
返回一个身份字符串:编写一个程序,返回对象的无重叠字符串表示,而不调用重写的
toString()或hashCode()。 -
挂钩未命名的类和实例主方法:简要介绍 JDK 21 的未命名字类和实例主方法。
-
在 Java API 文档中添加代码片段:通过新的
@snippet标签提供在 Java API 文档中添加代码片段的示例。 -
从
Proxy实例调用默认方法:编写几个程序,在 JDK 8、JDK 9 和 JDK 16 中从Proxy实例调用接口default方法。 -
在字节和十六进制编码字符串之间转换:提供几个代码片段,用于在字节和十六进制编码字符串(包括字节数组)之间进行转换。
-
示例初始化按需持有者设计模式:编写一个程序,以经典方式(在 JDK 16 之前)实现初始化按需持有者设计模式,并编写另一个程序,基于从 JDK 16+ 开始,Java 内部类可以拥有静态成员和静态初始化器的这一事实来实现此设计模式。
-
在匿名类中添加嵌套类:编写一个使用嵌套类在匿名类中的有意义的示例(JDK 16 之前,以及 JDK 16+)。
-
示例擦除与重载:简要解释 Java 中的类型擦除和多态重载是什么,并示例它们是如何一起工作的。
-
Xlinting 默认构造函数:解释并示例 JDK 16+ 为具有默认构造函数的类提供的提示
-Xlint:missing-explicit-ctor。 -
使用接收器参数:解释 Java 接收器参数的作用,并通过代码示例展示其用法。
-
实现不可变栈: 提供一个程序,从零开始创建不可变栈实现(实现
isEmpty()、push()、pop()和peek()操作)。 -
揭示与 Strings 相关的常见错误: 编写一个简单的字符串使用案例,其中包含一个常见错误(例如,与
String的不可变特性相关)。 -
使用增强的 NullPointerException: 根据你的经验,举例说明
NullPointerException的前 5 个常见原因,并解释 JDK 14 如何改进 NPE 消息。 -
在 switch 表达式中使用 yield: 解释并举例说明在 JDK 13+ 中使用
yield关键字与switch表达式的用法。 -
处理 switch 中的 null 情况子句: 编写一些示例,展示在
switch表达式中处理null值的不同方法(包括 JDK 17+ 的方法)。 -
以困难的方式发现 equals() 的不同: 解释并举例说明
equals()与==操作符的不同。 -
简要介绍 instanceof 的用法: 提供一个简短的概述和代码片段,以突出
instanceof操作符的主要方面。 -
介绍模式匹配: 提供一个关于 Java 中模式匹配的理论论文,包括主要方面和术语。
-
介绍 instanceof 的类型模式匹配: 提供使用类型模式匹配的理论和实践支持。
-
处理类型模式中绑定变量的作用域: 详细解释,包括代码片段,类型模式中绑定变量的作用域。
-
通过类型模式重写 instanceof 的 equals(): 在引入
instanceof的类型模式之前和之后,以代码示例展示equals()的实现(包括泛型类)。 -
处理 instanceof 和泛型中的类型模式: 提供几个使用
instanceof的类型模式和泛型的组合示例。 -
处理 instanceof 和流中的类型模式: 我们能否同时使用
instanceof和 Stream API 的类型模式?如果是,请至少提供一个示例。 -
介绍 switch 中的类型模式匹配: 对于
instanceof,有类型模式可用,但对于switch也有。在此提供该主题的理论标题和示例。 -
在 switch 中添加受保护的模式标签: 简要介绍 JDK 17 和 21 中
switch的受保护模式标签。 -
处理 switch 中的模式标签优先级:
switch中的模式标签优先级是一个酷特性,因此在此以综合方法举例说明,并提供大量示例。 -
处理 switch 中模式标签的完整性(类型覆盖): 这是
switch表达式的另一个酷话题。详细解释并举例说明(理论和示例)。 -
理解 switch 表达式中的无条件模式和 nulls:解释在 JDK 19 之前和之后,
null值是如何被switch表达式的无条件模式处理的。
以下几节描述了解决前面问题的方案。请记住,通常没有解决特定问题的唯一正确方法。还请注意,这里所示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节并实验程序,请访问github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter02。
38. 解释并举例说明 UTF-8、UTF-16 和 UTF-32
字符编码/解码对于浏览器、数据库、文本编辑器、文件系统、网络等都非常重要,因此它是任何程序员的主要话题。查看以下图示:

图 2.1:使用不同的字符集表示文本
在图 2.1中,我们看到几个中文字符在计算机屏幕上以 UTF-8、UTF-16 和 ANSI 的形式表示。但是,这些是什么?ANSI 是什么?UTF-8 是什么,我们是如何得到它的?为什么这些字符在 ANSI 中看起来不正常?
好吧,这个故事可能始于计算机试图表示字符(例如字母表中的字母、数字或标点符号)。计算机将现实世界中的所有东西都理解为二进制表示,因此是一个 0 和 1 的序列。这意味着每个字符(例如,A、5、+等)都必须映射到一个 0 和 1 的序列。
将字符映射到一系列 0 和 1 的过程被称为字符编码,或简单地称为编码。将一系列 0 和 1 反映射回字符的过程被称为字符解码,或简单地称为解码。理想情况下,编码-解码周期应该返回相同的字符;否则,我们得到的是我们不理解或无法使用的东西。
例如,中文字符,
,应该在计算机的内存中编码为一个 0 和 1 的序列。接下来,当这个序列被解码时,我们期望返回相同的中文字母,
。在图 2.1的左中和右图中,这种情况发生了,而在右图中,返回的字符是
……一个中文使用者将不会理解这一点(实际上,没有人会),所以出了点问题!
当然,我们不仅仅有中文字符需要表示。我们还有许多其他字符集,这些字符集被分组在字母、表情符号等中。一组字符具有定义良好的内容(例如,字母表有定义良好的字符数量)并且被称为字符集,简称charset。
拥有一个字符集后,问题是要定义一组规则(一个标准),清楚地说明这个字符集的字符应该如何在计算机内存中编码/解码。如果没有明确的规则集,编码和解码可能会导致错误或无法识别的字符。这样的标准被称为编码方案。
最早的编码方案之一是 ASCII。
介绍 ASCII 编码方案(或单字节编码)
ASCII 代表美国信息交换标准代码。这个编码方案依赖于 7 位二进制系统。换句话说,ASCII 字符集中的每个字符都应该能在 7 位上表示(编码)。一个 7 位数可以是 0 到 127 之间的十进制数,如下面的图示所示:

图 2.2:ASCII 字符集编码
因此,ASCII 是一种基于 7 位系统的编码方案,支持 128 个不同的字符。但我们知道计算机在字节(八位组)上操作,一个字节有 8 位。这意味着 ASCII 是一种单字节编码方案,每个字节都留有一位空闲。请看以下图示:

图 2.3:在 ASCII 编码中,高亮的部分是留空的
在 ASCII 编码中,字母 A 是 65,字母 B 是 66,以此类推。在 Java 中,我们可以通过现有的 API 轻松检查这一点,如下面的简单代码所示:
int decimalA = "A".charAt(0); // 65
String binaryA = Integer.toBinaryString(decimalA); // 1000001
或者,让我们看看文本“Hello World”的编码。这次,我们也加入了留空的位,所以结果将是 01001000 01100101 01101100 01101100 01101111 0100000 01010111 01101111 01110010 01101100 01100100:
char[] chars = "Hello World".toCharArray();
for(char ch : chars) {
System.out.print("0" + Integer.toBinaryString(ch) + " ");
}
如果我们进行匹配,那么我们会看到 01001000 是H,01100101 是e,01101100 是l,01101111 是o,0100000 是空格,01010111 是W,01110010 是r,01100100 是d。所以,除了字母之外,ASCII 编码还可以表示英语字母(大写和小写)、数字、空格、标点符号和一些特殊字符。
除了核心的 ASCII 编码用于英语之外,我们还有 ASCII 扩展,这些扩展基本上是原始 ASCII 的变体,以支持其他字母表。很可能你已经听说过 ISO-8859-1(也称为 ISO 拉丁 1),这是一个著名的 ASCII 扩展。但是,即使有 ASCII 扩展,世界上仍然有很多字符尚未编码。有些国家的字符数量比 ASCII 能编码的要多,甚至有些国家不使用字母表。因此,ASCII 有其局限性。
我知道你在想什么……让我们使用那个留空的位(2⁷+127)。是的,但即便如此,我们也只能达到 256 个字符。还不够!是时候使用超过 1 个字节的编码来表示字符了。
介绍多字节编码
在世界各地的不同地区,人们开始创建多字节编码方案(通常是 2 字节)。例如,中文语言的说话者,由于有很多字符,创建了 Shift-JIS 和 Big5,它们使用 1 或 2 个字节来表示字符。
但是,当大多数国家试图通过自己的多字节编码方案来覆盖其特殊字符、符号等时,会发生什么情况呢?显然,这导致了不同国家使用的编码方案之间存在着巨大的不兼容性。更糟糕的是,一些国家拥有多个彼此完全不兼容的编码方案。例如,日本有三种不同的不兼容编码方案,这意味着使用其中一种编码方案编码文档,然后用另一种解码,会导致文档混乱。
然而,在互联网出现之前,这种不兼容性并不是一个大问题,因为那时文档是通过计算机在全球范围内大量共享的。在那个时刻,独立构思的编码方案之间的不兼容性(例如,国家和地理区域)开始变得痛苦。
这正是 Unicode 联盟成立的完美时机。
Unicode
简而言之,Unicode (unicode-table.com/en/) 是一种通用的编码标准,能够编码/解码世界上所有可能的字符(我们说的是数十万个字符)。
Unicode 需要更多的字节来表示所有这些字符。但是,Unicode 并没有涉及这种表示。它只是为每个字符分配了一个数字。这个数字被称为代码点。例如,Unicode 中的字母A与十进制的 65 相关联,我们称之为 U+0041。这是以 U+开头,后面跟着 65 的十六进制数。正如你所看到的,在 Unicode 中,A是 65,这与 ASCII 编码中的相同。换句话说,Unicode 与 ASCII 向后兼容。正如你很快就会看到的,这是一个很大的问题,所以请记住这一点!
Unicode 的早期版本包含小于 65,535(0xFFFF)的代码点。Java 通过 16 位的char数据类型来表示这些字符。例如,法语中的
(e with circumflex) 与 Unicode 234 十进制或 U+00EA 十六进制相关联。在 Java 中,我们可以使用charAt()来揭示任何小于 65,535 的 Unicode 字符:
int e = "ê".charAt(0); // 234
String hexe = Integer.toHexString(e); // ea
我们也可能看到这个字符的二进制表示:
String binarye = Integer.toBinaryString(e); // 11101010 = 234
后来,Unicode 添加了越来越多的字符,直到 1,114,112(0x10FFFF)。显然,16 位的 Java char不足以表示这些字符,调用charAt()也不再有用。
重要提示
Java 19+ 支持 Unicode 14.0。java.lang.Character API 支持 Unicode 字符数据库(UCD)的第 14 级。具体来说,我们有 47 个新的表情符号,838 个新的字符和 5 个新的脚本。Java 20+ 支持 Unicode 15.0,这意味着 java.lang.Character 将有 4,489 个新的字符。
此外,JDK 21 增加了一组专门用于基于其代码点处理表情符号的方法。在这些方法中,我们有 boolean isEmoji(int codePoint)、boolean isEmojiPresentation(int codePoint)、boolean isEmojiModifier(int codePoint)、boolean isEmojiModifierBase(int codePoint)、boolean isEmojiComponent(int codePoint) 和 boolean isExtendedPictographic(int codePoint)。在捆绑的代码中,你可以找到一个小的应用程序,展示如何获取所有可用的表情符号并检查给定的字符串是否包含表情符号。因此,我们可以通过 Character.codePointAt() 获取字符的代码点,并将其作为参数传递给这些方法,以确定字符是否为表情符号。
然而,Unicode 并不涉及这些代码点如何编码成比特。这是 Unicode 内部特殊编码方案的工作,例如 Unicode 转换格式(UTF)方案。最常见的是使用 UTF-32、UTF-16 和 UTF-8。
UTF-32
UTF-32 是一种用于 Unicode 的编码方案,它使用 4 个字节(32 位)来表示每个代码点。例如,字母 A(代码点为 65),在 7 位系统中可以编码,在 UTF-32 中的编码方式如下所示,位于其他两个字符旁边:

Figure 2.4: UTF-32 中编码的三个字符示例
如你在 Figure 2.4 中所见,UTF-32 使用 4 个字节(固定长度)来表示每个字符。在字母 A 的例子中,我们看到 UTF-32 浪费了 3 个字节的内存。这意味着将 ASCII 文件转换为 UTF-32 将使其大小增加 4 倍(例如,1KB 的 ASCII 文件将变成 4KB 的 UTF-32 文件)。正因为这个缺点,UTF-32 并不太受欢迎。
Java 不支持 UTF-32 作为标准字符集,但它依赖于 代理对(将在下一节中介绍)。
UTF-16
UTF-16 是一种用于 Unicode 的编码方案,它使用 2 或 4 个字节(而不是 3 个字节)来表示每个代码点。UTF-16 具有可变长度,并使用可选的 字节顺序标记(BOM),但建议使用 UTF-16BE(BE 代表大端字节顺序),或 UTF-16LE(LE 代表小端字节顺序)。虽然有关大端与小端更详细的信息可在 en.wikipedia.org/wiki/Endianness 找到,但以下图示揭示了 UTF-16BE(左侧)与 UTF-16LE(右侧)在三个字符中的字节顺序差异:

Figure 2.5: UTF-16BE(左侧)与 UTF-16LE(右侧)
由于图示已经足够说明,让我们继续前进。现在,我们必须处理 UTF-16 的一个更复杂的问题。我们知道在 UTF-32 中,我们将 码点 转换为一个 32 位的数字,然后就这样了。但在 UTF-16 中,我们并不能每次都这样做,因为有些码点无法适应 16 位。话虽如此,UTF-16 使用所谓的 16 位 码单元。它可以使用 1 或 2 个 码单元 来表示一个 码点。有三种类型的码单元,如下所示:
-
一个码点需要一个单个码单元:这些是 16 位码单元(覆盖 U+0000 到 U+D7FF,和 U+E000 到 U+FFFF)
-
一个码点需要 2 个码单元:
-
第一个码单元被称为 高代理,它覆盖了 1,024 个值(U+D800 到 U+DBFF)
-
第二个码单元被称为 低代理,它覆盖了 1,024 个值(U+DC00 到 U+DFFF)
-
一个 高代理 后跟一个 低代理 被称为 代理对。代理对用于表示所谓的 补充 Unicode 字符或码点大于 65,535(0xFFFF)的字符。
像字母 A (65) 或中文
(26263) 这样的字符,它们的码点可以通过单个码单元来表示。以下图示展示了这些字符在 UTF-16BE 中的表示:

图 2.6:A 和
的 UTF-16 编码
这很简单!现在,让我们考虑以下图示(Unicode 的编码,带心形眼的笑脸):

图 2.7:使用代理对的 UTF-16 编码
图中这个字符的码点是 128525(或,1 F60D),并且用 4 个字节来表示。
检查第一个字节:6 位序列 110110 识别了一个高代理。
检查第三个字节:6 位序列 110111 识别了一个低代理。
这 12 位(标识高代理和低代理)可以被丢弃,我们保留剩下的 20 位:00001111011000001101。我们可以将这个数字计算为 2⁰ + 2² + 2³ + 2⁹ + 2¹⁰ + 2¹² + 2¹³ + 2¹⁴ + 2¹⁵ = 1 + 4 + 8 + 512 + 1024 + 4096 + 8192 + 16384 + 32768 = 62989(或,十六进制,F60D)。
最后,我们必须计算 F60D + 0x10000 = 1 F60D,或者用十进制表示为 62989 + 65536 = 128525(这个 Unicode 字符的码点)。我们必须加上 0x10000,因为使用 2 个码单元(代理对)的字符总是形式为 1 F…
Java 支持 UTF-16、UTF-16BE 和 UTF-16LE。实际上,UTF-16 是 Java 的原生字符编码。
UTF-8
UTF-8 是一种 Unicode 编码方案,它使用 1、2、3 或 4 个字节来表示每个码点。有了这种 1 到 4 字节的灵活性,UTF-8 以非常高效的方式使用空间。
重要提示
UTF-8 是最流行的编码方案,它主导着互联网和应用程序。
例如,我们知道字母 A 的码点是 65,它可以使用 7 位二进制表示来编码。以下图示展示了这个字母在 UTF-8 中的编码:

图 2.8:字母 A 以 UTF-8 编码
这非常酷!UTF-8 使用单个字节来编码 A。第一个(最左边的)0 表示这是一个单字节编码。接下来,让我们看看中文字符,
:

图 2.9:中文字符,
,以 UTF-8 编码
的代码点是 26263,所以 UTF-8 使用 3 个字节来表示它。第一个字节包含 4 位(1110),表示这是一个 3 字节编码。接下来的两个字节以 2 位 10 开头。所有这些 8 位都可以丢弃,我们只保留剩下的 16 位,这给我们期望的代码点。
最后,让我们处理以下图:

图 2.10:使用 4 个字节的 UTF-8 编码
这次,第一个字节通过 11110 表示这是一个 4 字节编码。接下来的 3 个字节以 10 开头。所有这些 11 位都可以丢弃,我们只保留剩下的 21 位,000011111011000001101,这给我们期望的代码点,128525。
在下面的图中,你可以看到用于编码 Unicode 字符的 UTF-8 模板:

图 2.11:用于编码 Unicode 字符的 UTF-8 模板
你知道吗?一串连续的 8 个零(00000000 – U+0000)被解释为 NULL?NULL 表示字符串的结尾,所以“意外”发送它将是一个问题,因为剩余的字符串将不会被处理。幸运的是,UTF-8 防止了这个问题,并且只有当我们有效地发送 U+0000 代码点时,才能发送 NULL。
Java 和 Unicode
只要我们使用代码点小于 65,535(0xFFFF)的字符,我们就可以依赖 charAt() 方法来获取代码点。以下是一些示例:
int cp1 = "A".charAt(0); // 65
String hcp1 = Integer.toHexString(cp1); // 41
String bcp1 = Integer.toBinaryString(cp1); // 1000001
int cp2 = "".charAt(0); // 26263
String hcp2 = Integer.toHexString(cp2); // 6697
String bcp2 = Integer.toBinaryString(cp2); // 1101100000111101
基于这些示例,我们可能可以编写一个辅助方法,返回代码点小于 65,535(0xFFFF)的字符串的二进制表示,如下所示(你之前已经看到了以下功能代码的命令式版本):
public static String strToBinary(String str) {
String binary = str.chars()
.mapToObj(Integer::toBinaryString)
.map(t -> "0" + t)
.collect(Collectors.joining(" "));
return binary;
}
如果你用这个代码对代码点大于 65,535(0xFFFF)的 Unicode 字符进行操作,那么你会得到错误的结果。你不会得到异常或任何警告。
因此,charAt() 只覆盖了 Unicode 字符的一个子集。为了覆盖所有 Unicode 字符,Java 提供了一个由几个方法组成的 API。例如,如果我们用 codePointAt() 替换 charAt(),那么在所有情况下我们都会得到正确的代码点,如下图所示:

图 2.12:charAt() 与 codePointAt()
查看最后一个例子,c2。由于 codePointAt() 返回正确的代码点(128525),我们可以得到以下二进制表示:
String uc = Integer.toBinaryString(c2); // 11111011000001101
因此,如果我们需要一个返回任何 Unicode 字符的二进制编码的方法,那么我们可以将 chars() 调用替换为 codePoints() 调用。codePoints() 方法返回给定序列的代码点:
public static String codePointToBinary(String str) {
String binary = str.codePoints()
.mapToObj(Integer::toBinaryString)
.collect(Collectors.joining(" "));
return binary;
}
codePoints() 方法只是 Java 提供的用于处理代码点的方法之一。Java API 还包括 codePointAt()、offsetByCodePoints()、codePointCount()、codePointBefore()、codePointOf() 等等。您可以在与这个示例相邻的捆绑代码中找到它们的几个示例,用于从给定的代码点获取 String:
String str1 = String.valueOf(Character.toChars(65)); // A
String str2 = String.valueOf(Character.toChars(128525));
toChars() 方法获取一个代码点并通过 char[] 返回 UTF-16 表示。第一个示例(str1)返回的字符串长度为 1,是字母 A。第二个示例返回长度为 2 的字符串,因为具有代码点 128525 的字符需要一个代理对。返回的 char[] 包含高代理和低代理。
最后,让我们有一个辅助方法,它允许我们获取给定编码方案的字符串的二进制表示:
public static String stringToBinaryEncoding(
String str, String encoding) {
final Charset charset = Charset.forName(encoding);
final byte[] strBytes = str.getBytes(charset);
final StringBuilder strBinary = new StringBuilder();
for (byte strByte : strBytes) {
for (int i = 0; i < 8; i++) {
strBinary.append((strByte & 128) == 0 ? 0 : 1);
strByte <<= 1;
}
strBinary.append(" ");
}
return strBinary.toString().trim();
}
使用此方法非常简单,如下面的示例所示:
// 00000000 00000000 00000000 01000001
String r = Charsets.stringToBinaryEncoding("A", "UTF-32");
// 10010111 01100110
String r = Charsets.stringToBinaryEncoding("",
StandardCharsets.UTF_16LE.name());
您可以在捆绑代码中练习更多示例。
JDK 18 默认字符集为 UTF-8
在 JDK 18 之前,默认字符集是根据操作系统字符集和区域设置确定的(例如,在 Windows 机器上,它可能是 windows-1252)。从 JDK 18 开始,默认字符集是 UTF-8(Charset.defaultCharset() 返回字符串,UTF-8)。或者,如果我们有一个 PrintStream 实例,我们可以通过 charset() 方法(从 JDK 18 开始)找出使用的字符集。
但是,可以通过命令行中的 file.encoding 和 native.encoding 系统属性显式设置默认字符集。例如,您可能需要执行以下修改来编译在 JDK 18 之前开发的旧代码:
// the default charset is computed from native.encoding
java -Dfile-encoding = COMPAT
// the default charset is windows-1252
java -Dfile-encoding = windows-1252
因此,从 JDK 18 开始,使用编码的类(例如,FileReader/FileWriter、InputStreamReader/OutputStreamWriter、PrintStream、Formatter、Scanner 和 URLEncoder/URLDecoder)可以默认使用 UTF-8。例如,在 JDK 18 之前使用 UTF-8 读取文件可以通过显式指定以下字符集编码方案来完成:
try ( BufferedReader br = new BufferedReader(new FileReader(
chineseUtf8File.toFile(), StandardCharsets.UTF_8))) {
...
}
在 JDK 18+ 中完成相同的事情不需要显式指定字符集编码方案:
try ( BufferedReader br = new BufferedReader(
new FileReader(chineseUtf8File.toFile()))) {
...
}
然而,对于 System.out 和 System.err,JDK 18+ 仍然使用默认的系统字符集。所以,如果您正在使用 System.out/err 并且看到问号 (?) 而不是预期的字符,那么您很可能会通过新的属性 -Dstdout.encoding 和 -Dstderr.encoding 将 UTF-8 设置为:
-Dstderr.encoding=utf8 -Dstdout.encoding=utf8
或者,您可以将它们设置为环境变量以全局设置:
_JAVA_OPTIONS="-Dstdout.encoding=utf8 -Dstderr.encoding=utf8"
在捆绑代码中您可以看到更多示例。
39. 检查从 0 到长度的范围内的子范围
检查给定的子范围是否在从 0 到给定长度的范围内是许多问题中的常见检查。例如,让我们考虑我们必须编写一个函数来检查客户是否可以增加水管中的压力。客户给我们当前的平均压力(avgPressure)、最大压力(maxPressure)和应该施加的额外压力量(unitsOfPressure)。
但是,在我们应用我们的秘密算法之前,我们必须检查输入是否正确。因此,我们必须确保以下情况都不会发生:
-
avgPressure小于 0 -
unitsOfPressure小于 0 -
maxPressure小于 0 -
范围
[avgPressure, avgPressure + unitsOfPressure)超出了由maxPressure表示的界限
因此,在代码行中,我们的函数可能看起来如下:
public static boolean isPressureSupported(
int avgPressure, int unitsOfPressure, int maxPressure) {
if(avgPresure < 0 || unitsOfPressure < 0 || maxPressure < 0
|| (avgPresure + unitsOfPressure) > maxPressure) {
throw new IndexOutOfBoundsException(
"One or more parameters are out of bounds");
}
// the secret algorithm
return (avgPressure + unitsOfPressure) <
(maxPressure - maxPressure/4);
}
编写类似于我们这样的复合条件容易出错。尽可能依靠 Java API 会更好。而且,对于这个用例,这是可能的!从 JDK 9 开始,在 java.util.Objects 中,我们有了 checkFromIndexSize(int fromIndex, int size, int length) 方法,从 JDK 16 开始,我们也为 long 参数提供了类似的方法,checkFromIndexSize(int fromIndex, int size, int length)。如果我们考虑 avgPressure 是 fromIndex,unitsOfPressure 是 size,而 maxPressure 是 length,那么 checkFromIndexSize() 执行参数验证,如果出现问题则抛出 IndexOutOfBoundsException。因此,我们编写代码如下:
public static boolean isPressureSupported(
int avgPressure, int unitsOfPressure, int maxPressure) {
Objects.checkFromIndexSize(
avgPressure, unitsOfPressure, maxPressure);
// the secret algorithm
return (avgPressure + unitsOfPressure) <
(maxPressure - maxPressure/4);
}
在代码包中,你可以看到使用 checkFromIndexSize() 的另一个示例。
除了 checkFromIndexSize(),在 java.util.Objects 中,我们还可以找到其他几个伴侣,它们涵盖了常见的复合条件,如 checkIndex(int index, int length) – JDK 9,checkIndex(long index, long length) – JDK 16,checkFromToIndex(int fromIndex, int toIndex, int length) – JDK 9,以及 checkFromToIndex(long fromIndex, long toIndex, long length) – JDK 16。
顺便说一下,如果我们切换到字符串上下文,那么 JDK 21 提供了知名 String.indexOf() 方法的重载,能够在给定的字符串中搜索一个字符/子字符串,在给定的开始索引和结束索引之间。其签名是 indexOf(String str, int beginIndex, int endIndex),它返回 str 的首次出现索引,如果 str 未找到则返回 -1。基本上,这是一个 s.substring(beginIndex, endIndex).indexOf(str) + beginIndex 的整洁版本。
40. 返回一个身份字符串
那么,什么是 身份字符串?身份字符串是从对象构建的字符串,而不调用重写的 toString() 或 hashCode()。它等同于以下连接:
object.getClass().getName() + "@"
+ Integer.toHexString(System.identityHashCode(object))
从 JDK 19 开始,这个字符串被包装在 Objects.toIdentityString(Object object) 中。考虑以下类(object):
public class MyPoint {
private final int x;
private final int y;
private final int z;
...
@Override
public String toString() {
return "MyPoint{" + "x=" + x + ", y=" + y
+ ", z=" + z + '}';
}
}
通过调用 toIdentityString(),我们得到如下内容:
MyPoint p = new MyPoint(1, 2, 3);
// modern.challenge.MyPoint@76ed5528
Objects.toIdentityString(p);
显然,重写的MyPoint.toString()方法没有被调用。如果我们打印出p的哈希码,我们得到76ed5528,这正是toIdentityString()返回的。现在,让我们也重写hashCode():
@Override
public int hashCode() {
int hash = 7;
hash = 23 * hash + this.x;
hash = 23 * hash + this.y;
hash = 23 * hash + this.z;
return hash;
}
这次,toIdentityString()返回相同的内容,而我们的hashCode()返回14ef3。
41. 钩子匿名类和实例主方法
想象一下,你必须向学生介绍 Java。介绍 Java 的经典方法是通过展示一个Hello World!示例,如下所示:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
这是最简单的 Java 示例,但向学生解释public、static或String[]等概念并不简单。这个简单示例中涉及的仪式可能会让学生感到害怕——如果这是一个简单示例,那么更复杂的示例又是怎样的呢?
幸运的是,从 JDK 21(JEP 445)开始,我们有了实例主方法,这是一个预览功能,允许我们将之前的示例缩短如下:
public class HelloWorld {
void main() {
System.out.println("Hello World!");
}
}
我们甚至可以更进一步,移除显式的类声明。这个特性被称为匿名类。匿名类位于匿名包中,而匿名包位于匿名模块中:
void main() {
System.out.println("Hello World!");
}
Java 会代表我们生成类。类的名称将与源文件名称相同。
这就是我们向学生介绍 Java 所需的所有内容。我强烈建议你阅读 JEP 445(以及将继续进行 JDK 21 预览功能工作的新 JEPs),以了解这些功能的所有方面。
42. 在 Java API 文档中添加代码片段
我相信你熟悉为你的项目生成Java API 文档(Javadoc)。我们可以通过命令行的javadoc工具、IDE 支持、Maven 插件(maven-javadoc-plugin)等方式来完成。
在编写 Javadoc 时,一个常见的做法是添加代码片段来举例说明非平凡类或方法的使用。在 JDK 18 之前,可以在文档中通过{@code...}或<pre>标签添加代码片段。添加的代码被视为纯文本,不会进行正确性验证,并且不会被其他工具发现。让我们快速看一个例子:
/**
* A telemeter with laser ranging from 0 to 60 ft including
* calculation of surfaces and volumes with high-precision
*
* <pre>{@code
* Telemeter.Calibrate.at(0.00001);
* Telemeter telemeter = new Telemeter(0.15, 2, "IP54");
* }</pre>
*/
public class Telemeter {
...
在捆绑的代码中,你可以看到完整的示例。Javadoc 是在构建时通过 Maven 插件(maven-javadoc-plugin)生成的,因此只需简单地触发构建。
@snippet can be discovered and validated by third-party tools (not by the javadoc tool itself).
@snippet as follows:
/**
* A telemeter with laser ranging from 0 to 60 ft including
* calculation of surfaces and volumes with high-precision
*
* {@snippet :
* Telemeter.Calibrate.at(0.00001);
* Telemeter telemeter = new Telemeter(0.15, 2, "IP54");
* }
*/
public class Telemeter {
...
下图展示了输出截图:

图 2.13:@snippet 的简单输出
有效代码从冒号后面的换行符开始,到关闭的右大括号}之前结束。代码缩进被处理为代码块中的缩进,因此编译器会移除意外的空白,我们可以根据关闭的右大括号}缩进代码。查看以下图示:

图 2.14:代码片段的缩进
在上面的例子中,关闭的右花括号与打开的左花括号对齐,而在下面的例子中,我们将关闭的右花括号移到了右边。
添加属性
name=value pairs. For instance, we can provide a tip about the programming language of our snippet via the lang attribute. The value of the attribute is available to external tools and is present in the generated HTML. Here are two examples:
* {@snippet lang="java" :
* Telemeter.Calibrate.at(0.00001);
* Telemeter telemeter = new Telemeter(0.15, 2, "IP54");
* }
在生成的 HTML 中,你可以轻松地识别这个属性为:
<code class="language-java"> … </code>
如果代码是一个结构化文本,例如属性文件,那么你可以参考以下示例:
* {@snippet lang="properties" :
* telemeter.precision.default=42
* telemeter.clazz.default=2
* }
在生成的 HTML 中,你将会有:
<code class="language-properties"></code>
接下来,让我们看看我们如何改变片段中显示的内容。
使用标记注释和区域
markup comments. A markup comment occurs at the end of the line and it contains one or more *markup tags* of the form @name args, where args are commonly *name=value* pairs. Common markup comments include highlighting, linking, and content (text) modifications.
突出显示
通过@highlight不带参数可以突出显示整行,如下面的图所示:

图 2.15:突出显示整行代码
如图中所示,代码的第一行被加粗了。
如果我们想突出显示多行,则可以定义区域。区域可以被视为匿名或具有显式名称。匿名区域由作为标记标签参数放置的单词region和位于区域末尾的@end标签界定。以下是一个突出显示两个区域(一个匿名和一个名为R1的区域)的示例:

图 2.16:使用区域突出显示代码块
正则表达式允许我们突出显示代码的某个部分。例如,通过@highlight regex='".*"'可以突出显示所有在引号之间发生的内容。或者,通过substring="Calibrate"参数,可以仅突出显示单词Calibrate,如下面的图所示:

图 2.17:仅突出显示单词“Calibrate”
接下来,让我们谈谈如何在代码中添加链接。
链接
Calibrate that navigates in documentation to the description of the Calibrate.at() method:

图 2.18:在代码中添加链接
接下来,让我们看看我们如何修改代码的文本。
修改代码的文本
有时我们可能需要更改代码的文本。例如,我们不想用Telemeter.Calibrate.at(0.00001, "HIGH");,而想在文档中渲染Telemeter.Calibrate.at(eps, "HIGH");。因此,我们需要将0.00001替换为eps。这正是@replace标签的完美工作。常见的参数包括substring="…"(或,regex="…")和replacement="..."。以下是一个片段:

图 2.19:替换代码的文本
如果你需要在代码块中执行多个替换,则依赖于区域。在以下示例中,我们对代码块应用正则表达式:

图 2.20:通过简单的正则表达式和匿名区域应用多个替换
如果你需要在同一行上执行更多替换,则只需链式多个@replace标签(此声明适用于所有标签,如@highlight、@link等)。
使用外部片段
到目前为止,我们只使用了内联片段。但是,在某些情况下,使用内联片段并不是一个方便的方法(例如,如果我们需要重复文档中的某些部分)或者它们无法使用(例如,如果我们想嵌入/*…*/注释,这些注释不能添加在内联片段中)。
snippet-files and it can contain external snippets as Java sources, plain text files, or properties files. In the following figure, we have a single external file named MainSnippet.txt:

图 2.21:片段文件中的外部片段
not a Java file, then it can be loaded via {@snippet file …} as follows:
{@snippet file = MainSnippet.txt}
{@snippet file = "MainSnippet.txt"}
{@snippet file = 'MainSnippet.txt'}
但,我们也可以自定义外部片段的位置和文件夹名称。例如,让我们将外部片段放在名为snippet-src的文件夹中,如下所示:

图 2.22:自定义文件夹中的外部片段和位置
javadoc. Of course, you can pass it via the command line, via your IDE, or via maven-javadoc-plugin, as follows:
<additionalJOption>
--snippet-path C:\...\src\snippet-src
</additionalJOption>
此路径相对于您的机器,因此您可以在pom.xml中相应地调整它。
接下来,AtSnippet.txt和ParamDefaultSnippet.properties可以像之前加载MainSnippet.txt时那样加载。然而,加载 Java 源代码,如DistanceSnippet.java,可以通过{@snippet class…}来完成,如下所示:
{@snippet class = DistanceSnippet}
{@snippet class = "DistanceSnippet"}
{@snippet class = 'DistanceSnippet'}
但,不要显式添加.java扩展名,因为你会得到一个错误,例如在源路径或片段路径上找不到文件:DistanceSnippet/java.java:
{@snippet class = DistanceSnippet.java}
当使用 Java 源代码作为外部片段时,请注意以下注意事项。
重要提示
Illegal package name: “foo.buzz.snippet-files”. If you find yourself in this scenario, then simply use another folder name and location for the documentation external snippets written in Java sources.
外部片段中的区域
外部片段支持通过@start region=…和@end region=…来指定区域。例如,在AtSnippet.txt中,我们有以下区域:
// This is an example used in the documentation
// @start region=only-code
Telemeter.Calibrate.at(0.00001, "HIGH");
// @end region=only-code
现在,如果我们按如下方式加载区域:
{@snippet file = AtSnippet.txt region=only-code}
我们只获取区域中的代码,而不包括文本,// 这是在文档中使用的示例。
这里是另一个具有两个区域的属性文件示例:
# @start region=dist
sc=[0,0]
ec=[0,0]
interpolation=false
# @end region=dist
# @start region=at
eps=0.1
type=null
# @end region=at
区域dist用于在文档中显示distance()方法参数的默认值:

图 2.23:使用 dist 区域
此外,at区域用于在文档中显示at()方法参数的默认值:

图 2.24:使用“at”区域
在外部片段中,我们可以使用与内联片段相同的标签。例如,在下面的图中,你可以看到AtSnippet.txt的完整源代码:

图 2.25:AtSnippet.txt 的源代码
注意@highlight和@replace的存在。
重要提示
从 JDK 19 开始,Javadoc 搜索功能也得到了改进。换句话说,JDK 19+可以为 Javadoc API 文档生成一个独立的搜索页面。此外,搜索语法也得到了增强,以支持多个搜索词。
你可以在捆绑的代码中练习这些示例。
43. 从代理实例调用默认方法
从 JDK 8 开始,我们可以在接口中定义default方法。例如,让我们考虑以下接口(为了简洁,这些接口中的所有方法都被声明为default):

图 2.26:接口:Printable、Writable、Draft 和 Book
接下来,假设我们想使用 Java 反射 API 来调用这些默认方法。作为一个快速提醒,Proxy 类的目的是在运行时提供创建接口动态实现的支撑。
话虽如此,让我们看看我们如何使用 Proxy API 来调用我们的 default 方法。
JDK 8
在 JDK 8 中调用接口的 default 方法依赖于一个小技巧。基本上,我们使用 Lookup API 从头创建一个 包私有 构造函数。接下来,我们使这个构造函数可访问——这意味着 Java 不会检查此构造函数的访问修饰符,因此当我们尝试使用它时,不会抛出 IllegalAccessException。最后,我们使用这个构造函数来包装接口的一个实例(例如,Printable),并使用反射访问在此接口中声明的 default 方法。
因此,在代码行中,我们可以如下调用默认方法 Printable.print():
// invoke Printable.print(String)
Printable pproxy = (Printable) Proxy.newProxyInstance(
Printable.class.getClassLoader(),
new Class<?>[]{Printable.class}, (o, m, p) -> {
if (m.isDefault()) {
Constructor<Lookup> cntr = Lookup.class
.getDeclaredConstructor(Class.class);
cntr.setAccessible(true);
return cntr.newInstance(Printable.class)
.in(Printable.class)
.unreflectSpecial(m, Printable.class)
.bindTo(o)
.invokeWithArguments(p);
}
return null;
});
// invoke Printable.print()
pproxy.print("Chapter 2");
接下来,让我们专注于 Writable 和 Draft 接口。Draft 扩展了 Writable 并覆盖了 default write() 方法。现在,每次我们显式调用 Writable.write() 方法时,我们期望在幕后自动调用 Draft.write() 方法。一个可能的实现如下:
// invoke Draft.write(String) and Writable.write(String)
Writable dpproxy = (Writable) Proxy.newProxyInstance(
Writable.class.getClassLoader(),
new Class<?>[]{Writable.class, Draft.class}, (o, m, p) -> {
if (m.isDefault() && m.getName().equals("write")) {
Constructor<Lookup> cntr = Lookup.class
.getDeclaredConstructor(Class.class);
cntr.setAccessible(true);
cntr.newInstance(Draft.class)
.in(Draft.class)
.findSpecial(Draft.class, "write",
MethodType.methodType(void.class, String.class),
Draft.class)
.bindTo(o)
.invokeWithArguments(p);
return cntr.newInstance(Writable.class)
.in(Writable.class)
.findSpecial(Writable.class, "write",
MethodType.methodType(void.class, String.class),
Writable.class)
.bindTo(o)
.invokeWithArguments(p);
}
return null;
});
// invoke Writable.write(String)
dpproxy.write("Chapter 1");
最后,让我们专注于 Printable 和 Book 接口。Book 扩展了 Printable 并没有定义任何方法。因此,当我们调用继承的 print() 方法时,我们期望调用 Printable.print() 方法。虽然你可以在捆绑的代码中检查此解决方案,但让我们专注于使用 JDK 9+ 完成相同的任务。
JDK 9+,JDK 16 之前
正如你所看到的,在 JDK 9 之前,Java 反射 API 提供了对非公共类成员的访问。这意味着外部反射代码(例如,第三方库)可以深入访问 JDK 内部。但是,从 JDK 9 开始,这是不可能的,因为新的模块系统依赖于强封装。
为了从 JDK 8 到 JDK 9 的平滑过渡,我们可以使用 --illegal-access 选项。此选项的值范围从 deny(保持强封装,因此不允许任何非法反射代码)到 permit(最宽松的强封装级别,仅允许从未命名的模块访问平台模块)。在 permit(JDK 9 中的默认值)和 deny 之间,我们还有两个额外的值:warn 和 debug。然而,--illegal-access=permit; 的支持已在 JDK 17 中移除。
在此上下文中,之前的代码可能在 JDK 9+ 中不起作用,或者可能仍然起作用,但你可能会看到如下警告:WARNING: An illegal reflective access operation has occurred。
但是,我们可以通过MethodHandles“修复”我们的代码以避免非法反射访问。在其优点中,这个类公开了用于创建字段和方法方法句柄的查找方法。一旦我们有了Lookup,我们就可以依赖其findSpecial()方法来访问接口的default方法。
基于MethodHandles,我们可以如下调用默认方法Printable.print():
// invoke Printable.print(String doc)
Printable pproxy = (Printable) Proxy.newProxyInstance(
Printable.class.getClassLoader(),
new Class<?>[]{Printable.class}, (o, m, p) -> {
if (m.isDefault()) {
return MethodHandles.lookup()
.findSpecial(Printable.class, "print",
MethodType.methodType(void.class, String.class),
Printable.class)
.bindTo(o)
.invokeWithArguments(p);
}
return null;
});
// invoke Printable.print()
pproxy.print("Chapter 2");
虽然在捆绑的代码中你可以看到更多示例;但让我们从 JDK 16 开始探讨相同的话题。
JDK 16+
从 JDK 16 开始,我们可以通过新的静态方法InvocationHandler.invokeDefault()简化之前的代码。正如其名称所暗示的,这个方法对于调用default方法很有用。在代码行中,我们可以通过invokeDefault()简化之前调用Printable.print()的示例,如下所示:
// invoke Printable.print(String doc)
Printable pproxy = (Printable) Proxy.newProxyInstance(
Printable.class.getClassLoader(),
new Class<?>[]{Printable.class}, (o, m, p) -> {
if (m.isDefault()) {
return InvocationHandler.invokeDefault(o, m, p);
}
return null;
});
// invoke Printable.print()
pproxy.print("Chapter 2");
在下一个示例中,每次我们显式调用Writable.write()方法时,我们期望在幕后自动调用Draft.write()方法:
// invoke Draft.write(String) and Writable.write(String)
Writable dpproxy = (Writable) Proxy.newProxyInstance(
Writable.class.getClassLoader(),
new Class<?>[]{Writable.class, Draft.class}, (o, m, p) -> {
if (m.isDefault() && m.getName().equals("write")) {
Method writeInDraft = Draft.class.getMethod(
m.getName(), m.getParameterTypes());
InvocationHandler.invokeDefault(o, writeInDraft, p);
return InvocationHandler.invokeDefault(o, m, p);
}
return null;
});
// invoke Writable.write(String)
dpproxy.write("Chapter 1");
在捆绑的代码中,你可以练习更多示例。
44. 在字节和十六进制编码字符串之间转换
将字节转换为十六进制(反之亦然)是处理文件/消息流、执行编码/解码任务、处理图像等应用中的常见操作。
Java 的字节是一个范围在[-128, +127]的数字,使用 1 个有符号字节(8 位)表示。十六进制(基数为 16)是一个基于 16 个数字(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, 和 F)的系统。换句话说,一个字节的 8 位正好可以容纳 2 个十六进制字符,范围在 00 到 FF 之间。十进制 <-> 二进制 <-> 十六进制的映射总结在下图中:

图 2.27:十进制到二进制到十六进制转换
例如,二进制的 122 是 01111010。由于 0111 在十六进制中是 7,1010 是 A,因此 122 在十六进制中是 7A(也可以写作 0x7A)。
那么,一个负字节呢?我们从上一章知道,Java 将负数表示为正数的二进制补码。这意味着-122 的二进制是 10000110(保留正数 122 的前 7 位= 1111010,取反(1111010) = 0000101,加 1(0000001) = 00000110,并添加符号位 1,10000110),在十六进制中是 0x86。
将负数转换为十六进制可以有多种方式,但我们可以轻松地获得低 4 位为 10000110 & 0xF = 0110,高 4 位为(10000110>> 4) & 0xF = 1000 & 0xF = 1000(在这里,0xF(二进制,1111)掩码对于负数是很有用的)。由于 0110 = 6 和 1000 = 8,我们看到 10000110 在十六进制中是 0x86。
如果你需要深入了解 Java 中的位操作,或者你只是面对理解当前主题的问题,那么请考虑阅读《Java 完全编码面试指南》这本书,特别是第九章。
因此,在代码行中,我们可以依赖这个简单的算法和Character.forDigit(int d, int r),它返回给定基数(r)中给定数字(d)的字符表示:
public static String byteToHexString(byte v) {
int higher = (v >> 4) & 0xF;
int lower = v & 0xF;
String result = String.valueOf(
new char[]{
Character.forDigit(higher, 16),
Character.forDigit(lower, 16)}
);
return result;
}
解决这个问题的方法有很多(在捆绑的代码中,您可以查看这种解决方案的另一种形式)。例如,如果我们知道Integer.toHexString(int n)方法返回一个表示给定参数无符号整数 16 进制字符串,那么我们只需要对负数应用 0xFF(二进制,11111111)掩码即可:
public static String byteToHexString(byte v) {
return Integer.toHexString(v & 0xFF);
}
如果有一种我们应该避免的方法,那么就是基于String.format()的方法。String.format("%02x ", byte_nr)方法简洁但非常慢!
反过来呢?将给定的十六进制字符串(例如,7d,09 等)转换为字节相当简单。只需取给定字符串的第一个(d1)和第二个(d2)字符,并应用关系,(byte) ((d1 << 4) + d2):
public static byte hexToByte(String s) {
int d1 = Character.digit(s.charAt(0), 16);
int d2 = Character.digit(s.charAt(1), 16);
return (byte) ((d1 << 4) + d2);
}
在捆绑的代码中还有更多示例。如果您依赖第三方库,那么请检查 Apache Commons Codec(Hex.encodeHexString())、Guava(BaseEncoding)、Spring Security(Hex.encode())、Bouncy Castle(Hex.toHexString())等。
JDK 17+
从 JDK 17 开始,我们可以使用java.util.HexFormat类。这个类有很多静态方法用于处理十六进制数,包括String toHexDigits(byte value)和byte[] parseHex(CharSequence string)。因此,我们可以将字节转换为十六进制字符串如下:
public static String byteToHexString(byte v) {
HexFormat hex = HexFormat.of();
return hex.toHexDigits(v);
}
反之亦然,如下所示:
public static byte hexToByte(String s) {
HexFormat hex = HexFormat.of();
return hex.parseHex(s)[0];
}
在捆绑的代码中,您还可以看到这些解决方案的扩展,用于将字节数组(byte[])转换为String,反之亦然。
45. 举例说明按需初始化持有者设计模式
在我们着手解决实现按需初始化持有者设计模式之前,让我们快速回顾一下这个解决方案的一些关键要素。
静态与非静态块
在 Java 中,我们可以有非静态初始化块和静态块。一个非静态初始化块(或简单地,非静态块)在每次实例化类时都会自动调用。另一方面,一个静态初始化块(或简单地,静态块)在类本身初始化时只调用一次。无论我们创建多少个该类的后续实例,静态块都不会再次执行。在代码行中:
public class A {
{
System.out.println("Non-static initializer ...");
}
static {
System.out.println("Static initializer ...");
}
}
接下来,让我们运行以下测试代码来创建三个A的实例:
A a1 = new A();
A a2 = new A();
A a3 = new A();
输出显示静态初始化器只调用一次,而非静态初始化器调用三次:
Static initializer ...
Non-static initializer ...
Non-static initializer ...
Non-static initializer ...
此外,静态初始化器在非静态初始化器之前被调用。接下来,让我们谈谈嵌套类。
嵌套类
让我们来看一个快速示例:
public class A {
private static class B { ... }
}
嵌套类可以是静态的或非静态的。一个非静态的嵌套类被称为内部类;进一步地,它可以是局部内部类(在方法中声明)或匿名内部类(没有名称的类)。另一方面,声明为静态的嵌套类被称为静态嵌套类。以下图解说明了这些概念:

图 2.28:Java 嵌套类
由于 B 是在 A 中声明的静态类,我们说 B 是一个静态嵌套类。
解决初始化按需持有设计模式
初始化按需持有设计模式指的是线程安全的延迟加载单例(单个实例)实现。在 JDK 16 之前,我们可以在代码中举例说明这种设计模式如下(我们希望有一个线程安全的 Connection 单例):
public class Connection { // singleton
private Connection() {
}
private static class LazyConnection { // holder
static final Connection INSTANCE = new Connection();
static {
System.out.println("Initializing connection ..."
+ INSTANCE);
}
}
public static Connection get() {
return LazyConnection.INSTANCE;
}
}
无论线程(多个线程)调用 Connection.get() 多少次,我们总是得到相同的 Connection 实例。这是我们在第一次调用 get() 时创建的实例(第一个线程),Java 已经初始化了 LazyConnection 类及其静态成员。换句话说,如果我们从未调用 get(),那么 LazyConnection 类及其静态成员永远不会被初始化(这就是为什么我们称之为延迟初始化)。而且,这是线程安全的,因为静态初始化器可以在没有显式同步的情况下构造(这里,INSTANCE)和引用,因为它们在任何线程可以使用该类之前运行(这里,LazyConnection)。
JDK 16+
直到 JDK 16,内部类可以包含静态成员作为常量变量,但不能包含静态初始化器。换句话说,以下代码由于静态初始化器而无法编译:
public class A {
public class B {
{
System.out.println("Non-static initializer ...");
}
static {
System.out.println("Static initializer ...");
}
}
}
但是,从 JDK 16 开始,之前的代码可以无问题编译。换句话说,从 JDK 16 开始,Java 内部类可以有静态成员和静态初始化器。
这允许我们从另一个角度解决初始化按需持有设计模式。我们可以将静态嵌套类 LazyConnection 替换为局部内部类,如下所示:
public class Connection { // singleton
private Connection() {
}
public static Connection get() {
class LazyConnection { // holder
static final Connection INSTANCE = new Connection();
static {
System.out.println("Initializing connection ..."
+ INSTANCE);
}
}
return LazyConnection.INSTANCE;
}
}
现在,LazyConnection 只在其包含的方法 get() 中可见。只要我们不调用 get() 方法,连接就不会被初始化。
46. 在匿名类中添加嵌套类
在上一个问题中,我们简要概述了嵌套类。作为一个快速提醒,匿名类(或匿名内部类)就像一个没有名字的局部内部类。它们的目的在于提供更简洁和表达性更强的代码。然而,代码的可读性可能会受到影响(看起来很丑),但如果可以执行一些特定任务而不必创建一个完整的类,那么这可能是有价值的。例如,匿名类在不需要创建新类的情况下改变现有方法的行为时很有用。Java 通常用于事件处理和监听器(在 GUI 应用程序中)。Java 代码中最著名的匿名类例子如下:
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
...
}
}
然而,尽管局部内部类实际上是类声明,匿名类是表达式。要创建一个匿名类,我们必须扩展一个现有的类或实现一个接口,如下面的图所示:

图 2.29:通过类扩展和接口实现匿名类
由于它们没有名字,匿名类必须在单个表达式中声明和实例化。结果实例可以被分配给一个变量,稍后可以引用。表达式的标准语法看起来像调用一个常规 Java 构造函数,该构造函数在代码块中以分号(;)结束。分号的存在是一个提示,表明匿名类是一个表达式,必须作为语句的一部分。
最后,匿名类不能有显式的构造函数,不能是抽象的,不能是单例的,不能实现多个接口,也不能被扩展。
接下来,让我们解决一些匿名类中嵌套类的例子。例如,让我们考虑以下打印服务的接口:
public interface Printer {
public void print(String quality);
}
我们在我们的打印服务中到处使用 Printer 接口,但我们还想要一个紧凑的辅助方法,简单地测试我们的打印机功能,而不需要进一步的操作或额外的类。我们决定将此代码隐藏在名为 printerTest() 的静态方法中,如下所示:
public static void printerTest() {
Printer printer = new Printer() {
@Override
public void print(String quality) {
if ("best".equals(quality)) {
Tools tools = new Tools();
tools.enableLaserGuidance();
tools.setHighResolution();
}
System.out.println("Printing photo-test ...");
}
class Tools {
private void enableLaserGuidance() {
System.out.println("Adding laser guidance ...");
}
private void setHighResolution() {
System.out.println("Set high resolution ...");
}
}
};
测试 best 质量打印需要一些额外的设置,这些设置被封装在内部 Tools 类中。正如你所见,内部 Tools 类嵌套在匿名类中。另一种方法是将 Tools 类移动到 print() 方法内部。因此,Tools 成为一个局部内部类,如下所示:
Printer printer = new Printer() {
@Override
public void print(String quality) {
class Tools {
private void enableLaserGuidance() {
System.out.println("Adding laser guidance ...");
}
private void setHighResolution() {
System.out.println("Set high resolution ...");
}
}
if ("best".equals(quality)) {
Tools tools = new Tools();
tools.enableLaserGuidance();
tools.setHighResolution();
}
System.out.println("Printing photo-test ...");
}
};
这种方法的缺点是 Tools 类不能在 print() 之外使用。因此,这种严格的封装将限制我们添加一个也需要 Tools 类的新方法(在 print() 旁边)。
JDK 16+
但是,记住从上一个问题开始,从 JDK 16 开始,Java 内部类可以有静态成员和静态初始化器。这意味着我们可以删除 Tools 类,并依赖于以下两个静态方法:
Printer printer = new Printer() {
@Override
public void print(String quality) {
if ("best".equals(quality)) {
enableLaserGuidance();
setHighResolution();
}
System.out.println("Printing your photos ...");
}
private static void enableLaserGuidance() {
System.out.println("Adding laser guidance ...");
}
private static void setHighResolution() {
System.out.println("Set high resolution ...");
}
};
如果你觉得在静态类中获取这些辅助工具更方便,那么就去做吧:
Printer printer = new Printer() {
@Override
public void print(String quality) {
if ("best".equals(quality)) {
Tools.enableLaserGuidance();
Tools.setHighResolution();
}
System.out.println("Printing photo-test ...");
}
private final static class Tools {
private static void enableLaserGuidance() {
System.out.println("Adding laser guidance ...");
}
private static void setHighResolution() {
System.out.println("Set high resolution ...");
}
}
};
你可以在捆绑的代码中练习这些例子。
47. 举例说明擦除与重载的区别
在我们用一个例子将它们结合起来之前,让我们快速处理擦除和重载。
擦除概述
Java 在编译时使用 类型擦除 来强制执行类型约束和与旧字节码的向后兼容性。基本上,在编译时,所有类型参数都被替换为 Object(任何泛型都必须可转换为 Object)或类型界限(extends 或 super)。接下来,在运行时,编译器擦除的类型将被我们的类型替换。类型擦除的一个常见情况是泛型。
泛型类型的擦除
实际上,编译器使用有界类型(如 E、T、U 等)擦除未绑定类型。这强制执行类型安全,如下面的 类类型擦除 示例所示:
public class ImmutableStack<E> implements Stack<E> {
private final E head;
private final Stack<E> tail;
...
编译器应用类型擦除将 E 替换为 Object:
public class ImmutableStack<Object> implements Stack<Object> {
private final Object head;
private final Stack<Object> tail;
...
如果 E 参数是有界的,那么编译器将使用第一个界限类。例如,在一个类如 class Node<T extends Comparable<T>> {...} 中,编译器将用 Comparable 替换 T。同样,在一个类如 class Computation<T extends Number> {...} 中,所有 T 的出现都将被编译器替换为上限 Number。
查看以下情况,这是一个 方法类型擦除 的经典案例:
public static <T, R extends T> List<T> listOf(T t, R r) {
List<T> list = new ArrayList<>();
list.add(t);
list.add(r);
return list;
}
// use this method
List<Object> list = listOf(1, "one");
这是如何工作的?当我们调用 listOf(1, "one") 时,我们实际上向泛型参数 T 和 R 传递了两种不同的类型。编译器的类型擦除将 T 替换为 Object。这样,我们可以在 ArrayList 中插入不同的类型,代码运行良好。
擦除和桥接方法
桥接方法 是由编译器创建的,以覆盖边缘情况。具体来说,当编译器遇到参数化接口的实现或参数化类的扩展时,它可能需要生成一个桥接方法(也称为合成方法),作为类型擦除阶段的一部分。例如,让我们考虑以下参数化类:
public class Puzzle<E> {
public E piece;
public Puzzle(E piece) {
this.piece = piece;
}
public void setPiece(E piece) {
this.piece = piece;
}
}
此外,这个类的一个扩展:
public class FunPuzzle extends Puzzle<String> {
public FunPuzzle(String piece) {
super(piece);
}
@Override
public void setPiece(String piece) {
super.setPiece(piece);
}
}
类型擦除修改了 Puzzle.setPiece(E) 为 Puzzle.setPiece(Object)。这意味着 FunPuzzle.setPiece(String) 方法并没有覆盖 Puzzle.setPiece(Object) 方法。由于方法的签名不兼容,编译器必须通过一个桥接(合成)方法来适应泛型类型的多态性,以确保子类型按预期工作。让我们在代码中突出显示这个方法:
/* Decompiler 8ms, total 3470ms, lines 18 */
package modern.challenge;
public class FunPuzzle extends Puzzle<String> {
public FunPuzzle(String piece) {
super(piece);
}
public void setPiece(String piece) {
super.setPiece(piece);
}
**// $FF: synthetic method**
**// $FF: bridge method**
**public****void****setPiece****(Object var1)** **{**
**this****.setPiece((String)var1);**
**}**
}
现在,每当你看到堆栈跟踪中的桥接方法时,你将知道它是什么以及为什么存在。
类型擦除和堆污染
你是否见过未检查的警告?我相信你一定见过!这是所有 Java 开发者都常见的事情。它们可能作为类型检查的结果在编译时发生,或者作为类型转换或方法调用的结果在运行时发生。在两种情况下,我们都在谈论编译器无法验证操作正确性的事实,这暗示了一些参数化类型。并非每个未检查的警告都是危险的,但有些情况下我们必须考虑和处理它们。
一种特殊情况是堆污染。如果一个特定类型的参数化变量指向的不是该类型的对象,那么我们很容易处理导致堆污染的代码。在这种情况下,一个合适的例子是带有varargs参数的方法。
查看以下代码:
public static <T> void listOf(List<T> list, T... ts) {
list.addAll(Arrays.asList(ts));
}
listOf()声明将导致以下警告:从参数化 vararg 类型 T 可能导致可能的堆污染。那么这里发生了什么?
故事从编译器将形式参数T...替换为数组开始。在应用类型擦除后,T...参数变为T[],最终变为Object[]。因此,我们打开了一扇可能导致堆污染的大门。但是,我们的代码只是将Object[]的元素添加到了List<Object>中,所以我们处于安全区域。
换句话说,如果你知道varargs方法的主体不太可能生成特定的异常(例如,ClassCastException)或者在不恰当的操作中使用varargs参数,那么我们可以指示编译器抑制这些警告。我们可以通过以下方式使用@SafeVarargs注解来实现:
@SafeVarargs
public static <T> void listOf(List<T> list, T... ts) {...}
@SafeVarargs是一个提示,表明被注解的方法将只在不恰当的操作中使用varargs形式参数。更常见但不太推荐的做法是使用@SuppressWarnings({"unchecked", "varargs"}),这简单地抑制了这些警告,而不声称varargs形式参数没有被用于不恰当的操作。
现在,让我们来处理这段代码:
public static void main(String[] args) {
List<Integer> ints = new ArrayList<>();
Main.listOf(ints, 1, 2, 3);
Main.listsOfYeak(ints);
}
public static void listsOfYeak(List<Integer>... lists) {
Object[] listsAsArray = lists;
listsAsArray[0] = Arrays.asList(4, 5, 6);
Integer someInt = lists[0].get(0);
listsAsArray[0] = Arrays.asList("a", "b", "c");
Integer someIntYeak = lists[0].get(0); // ClassCastException
}
这次,类型擦除将List<Integer>...转换为List[],它是Object[]的子类型。这允许我们执行以下赋值操作:Object[] listsAsArray = lists;。但是,检查一下代码的最后两行,我们在其中创建了一个List<String>并将其存储在listsAsArray[0]中。在最后一行,我们试图从lists[0]中访问第一个Integer,这显然会导致ClassCastException。这是使用varargs的不恰当操作,因此在这种情况下不建议使用@SafeVarargs。我们应该认真对待以下警告:
// unchecked generic array creation for varargs parameter
// of type java.util.List<java.lang.Integer>[]
Main.listsOfYeak(ints);
// Possible heap pollution from parameterized vararg
// type java.util.List<java.lang.Integer>
public static void listsOfYeak(List<Integer>... lists) { ... }
现在,既然你已经熟悉了类型擦除,让我们简要地介绍一下多态重载。
简而言之,多态重载
由于重载(也称为“临时多态”)是面向对象编程(OOP)的核心概念,我相信你对 Java 方法重载已经很熟悉了,所以我就不再坚持这个概念的基本理论了。
此外,我也知道有些人不同意重载可以是多态的一种形式,但这将是另一个我们不在这里解决的问题。
我们将更加实际,跳入一系列旨在突出重载有趣方面的测验。更确切地说,我们将讨论类型优先级。所以,让我们解决第一个测验(wordie最初是一个空字符串):
static void kaboom(byte b) { wordie += "a";}
static void kaboom(short s) { wordie += "b";}
kaboom(1);
会发生什么?如果你回答编译器会指出没有找到适合kaboom(1)的方法,那么你是对的。编译器寻找一个接受整数参数的方法,kaboom(int)。好吧,那很简单!接下来是下一个:
static void kaboom(byte b) { wordie += "a";}
static void kaboom(short s) { wordie += "b";}
static void kaboom(long l) { wordie += "d";}
static void kaboom(Integer i) { wordie += "i";}
kaboom(1);
我们知道前两个kaboom()实例是无用的。那么kaboom(long)和kaboom(Integer)呢?你说得对,kaboom(long)会被调用。如果我们移除kaboom(long),那么kaboom(Integer)会被调用。
重要提示
在原始类型重载中,编译器首先尝试寻找一对一匹配。如果这个尝试失败,那么编译器将寻找比原始当前域更广的原始类型重载(例如,对于int,它会寻找int、long、float或double)。如果这也失败了,那么编译器将检查接受装箱类型(Integer、Float等)的重载。
根据前面的说明,让我们来看这个例子:
static void kaboom(Integer i) { wordie += "i";}
static void kaboom(Long l) { wordie += "j";}
kaboom(1);
这次,wordie将是i。由于没有kaboom(int)/long/float/double),将调用kaboom(Integer)。如果我们有一个kaboom(double),那么这个方法比kaboom(Integer)有更高的优先级。有趣,对吧?!另一方面,如果我们移除kaboom(Integer),那么不要期望会调用kaboom(Long)。任何比Integer更广/窄域的kaboom(boxed type)都不会被调用。这是因为在编译器遵循基于 IS-A 关系的继承路径时发生的,所以kaboom(Integer)之后,它会寻找kaboom(Number),因为Integer是Number。
重要提示
在装箱类型重载中,编译器首先尝试寻找一对一匹配。如果这个尝试失败,那么编译器将不会考虑任何比当前域更广的装箱类型重载(当然,窄域也会被忽略)。它寻找Number作为所有装箱类型的超类。如果找不到Number,编译器将沿着继承层次向上查找,直到达到java.lang.Object,这是路的尽头。
好吧,让我们稍微复杂一点:
static void kaboom(Object... ov) { wordie += "o";}
static void kaboom(Number n) { wordie += "p";}
static void kaboom(Number... nv) { wordie += "q";}
kaboom(1);
那么,这次会调用哪个方法?我知道,你认为kaboom(Number),对吧?至少,我的简单逻辑推动我这么想,这是一个常识性的选择。而且,这是正确的!
如果我们移除kaboom(Number),编译器将调用varargs方法,kaboom(Number...)。这很有道理,因为kaboom(1)使用单个参数,所以kaboom(Number)应该比kaboom(Number...)有更高的优先级。如果我们调用kaboom(1,2,3),这种逻辑就会反转,因为kaboom(Number)不再代表对这个调用有效的重载,而kaboom(Number...)是正确的选择。
但是,这个逻辑成立是因为Number是所有装箱类(Integer、Double、Float等)的超类。
现在怎么样?
static void kaboom(Object... ov) { wordie += "o";}
static void kaboom(File... fv) { wordie += "s";}
kaboom(1);
这次,编译器将“绕过”kaboom(File...)并调用kaboom(Object...)。根据同样的逻辑,调用kaboom(1, 2, 3)将调用kaboom(Object...),因为没有kaboom(Number...)。
重要提示
在重载中,如果调用有一个单个参数,那么具有单个参数的方法比其varargs对应方法有更高的优先级。另一方面,如果调用有相同类型的更多参数,那么将调用varargs方法,因为单参数方法不再适用。当调用有一个单个参数但只有varargs重载可用时,则调用此方法。
这引出了以下示例:
static void kaboom(Number... nv) { wordie += "q";}
static void kaboom(File... fv) { wordie += "s";}
kaboom();
这次,kaboom()没有参数,编译器找不到唯一的匹配项。这意味着对kaboom()的引用是模糊的,因为两种方法都匹配(modern.challenge.Main中的kaboom(java.lang.Number...)和modern.challenge.Main中的方法kaboom(java.io.File...))。
在捆绑的代码中,你可以玩更多关于多态重载的游戏,并测试你的知识。此外,尝试挑战自己,并在等式中引入泛型。
消除与重载
好的,基于之前的经验,来看看这段代码:
void print(List<A> listOfA) {
System.out.println("Printing A: " + listOfA);
}
void print(List<B> listofB) {
System.out.println("Printing B: " + listofB);
}
会发生什么?嗯,这是一个重载和类型擦除冲突的例子。类型擦除会将List<A>替换为List<Object>,也将List<B>替换为List<Object>。因此,无法进行重载,我们得到一个错误,例如名称冲突:print(java.util.List<modern.challenge.B>)和 print(java.util.List<modern.challenge.A>)有相同的擦除。
为了解决这个问题,我们可以在这两个方法之一中添加一个虚拟参数:
void print(List<A> listOfA, Void... v) {
System.out.println("Printing A: " + listOfA);
}
现在,我们可以对两个方法进行相同的调用:
new Main().print(List.of(new A(), new A()));
new Main().print(List.of(new B(), new B()));
完成了!你可以在捆绑的代码中练习这些示例。
48. 检查默认构造函数
我们知道,没有显式构造函数的 Java 类会自动获得一个“不可见”的默认构造函数,用于设置实例变量的默认值。以下House类就属于这种情况:
public class House {
private String location;
private float price;
...
}
如果这正是我们想要的,那就没问题。但是,如果我们担心默认构造函数被类公开导出到公共导出包的事实,那么我们必须考虑使用 JDK 16+。
JDK 16+添加了一个专门的lint,用于警告我们具有默认构造函数的类。为了利用这个lint,我们必须遵循两个步骤:
-
导出包含该类的包
-
使用
-Xlint:missing-explicit-ctor(或-Xlint,-Xlint:all)编译
在我们的案例中,我们在module-info中如下导出modern.challenge包:
module P48_XlintDefaultConstructor {
exports modern.challenge;
}
一旦你用-Xlint:missing-explicit-ctor编译代码,你将看到如图所示的警告:

图 2.30:由-Xlint:missing-explicit-ctor产生的警告
现在,你可以轻松地找出哪些类有默认构造函数。
49. 使用接收者参数
从 JDK 8 开始,我们可以用可选的接收者参数丰富我们的任何实例方法。这是一个通过this关键字暴露的封装类型的纯语法参数。以下两个代码片段是相同的:
public class Truck {
public void revision1(Truck this) {
Truck thisTruck = this;
System.out.println("Truck: " + thisTruck);
}
public void revision2() {
Truck thisTruck = this;
System.out.println("Truck: " + thisTruck);
}
}
不要得出结论说revision2()是revision1()的覆盖,或者反之亦然。两种方法都有相同的输出、相同的签名和产生相同的字节码。
接收者参数也可以在内部类中使用。以下是一个示例:
public class PaymentService {
class InvoiceCalculation {
final PaymentService paymentService;
InvoiceCalculation(PaymentService PaymentService.this) {
paymentService = PaymentService.this;
}
}
}
好的,但为什么使用接收者参数呢?嗯,JDK 8 引入了所谓的类型注解,正如其名所示:可以应用于类型的注解。在这种情况下,接收者参数被添加用于注解被调用方法的对象类型。查看以下代码:
@Target(ElementType.TYPE_USE)
public @interface ValidAddress {}
public String getAddress(@ValidAddress Person this) { ... }
或者,查看这个更详细的示例:
public class Parcel {
public void order(@New Parcel this) {...}
public void shipping(@Ordered Parcel this) {...}
public void deliver(@Shipped Parcel this) {...}
public void cashit(@Delivered Parcel this) {...}
public void done(@Cashed Parcel this) {...}
}
每个Parcel客户端都必须按照通过类型注解和接收者参数绘制的精确顺序调用这些方法。换句话说,只有当它是一个新订单时,才能下单;只有当订单已下单时,才能发货;只有当已发货时,才能交付;只有当已交付时,才能付款;只有当已付款时,才能关闭。
目前,这个严格的顺序仅由这些假设的注解指出来。但,这是实现进一步静态分析工具的正确道路,该工具将理解这些注解的含义,并在Parcel的客户端每次不遵循这个精确顺序时触发警告。
50. 实现一个不可变栈
面试中常见的编码挑战是这样的:在 Java 中实现一个不可变栈。
作为一种抽象数据类型,栈至少需要这个契约:
public interface Stack<T> extends Iterable<T> {
boolean isEmpty();
Stack<T> push(T value);
Stack<T> pop();
T peek();
}
有了这个契约,我们可以专注于不可变实现。一般来说,不可变数据结构在尝试更改其内容(例如,添加、put、删除、push 等)之前保持不变。如果操作尝试更改不可变数据结构的内容,必须创建该数据结构的新实例,并由该操作使用,而之前的实例保持不变。
现在,在我们的上下文中,我们有两个可以改变栈内容的操作:push 和 pop。push 操作应该返回包含已推入元素的新栈,而 pop 操作应该返回之前的栈。但是,为了完成这个任务,我们需要从一个地方开始,因此我们需要一个空的初始栈。这是一个单例栈,可以如下实现:
private static class EmptyStack<U> implements Stack<U> {
@Override
public Stack<U> push(U u) {
return new ImmutableStack<>(u, this);
}
@Override
public Stack<U> pop() {
throw new UnsupportedOperationException(
"Unsupported operation on an empty stack");
}
@Override
public U peek() {
throw new UnsupportedOperationException (
"Unsupported operation on an empty stack");
}
@Override
public boolean isEmpty() {
return true;
}
@Override
public Iterator<U> iterator() {
return new StackIterator<>(this);
}
}
StackIterator是 Java Iterator的一个简单实现。这里没有太多花哨的东西:
private static class StackIterator<U> implements Iterator<U> {
private Stack<U> stack;
public StackIterator(final Stack<U> stack) {
this.stack = stack;
}
@Override
public boolean hasNext() {
return !this.stack.isEmpty();
}
@Override
public U next() {
U e = this.stack.peek();
this.stack = this.stack.pop();
return e;
}
@Override
public void remove() {
}
}
到目前为止,我们有了Iterator和一个空的栈单例。最后,我们可以如下实现不可变栈的逻辑:
public class ImmutableStack<E> implements Stack<E> {
private final E head;
private final Stack<E> tail;
private ImmutableStack(final E head, final Stack<E> tail) {
this.head = head;
this.tail = tail;
}
public static <U> Stack<U> empty(final Class<U> type) {
return new EmptyStack<>();
}
@Override
public Stack<E> push(E e) {
return new ImmutableStack<>(e, this);
}
@Override
public Stack<E> pop() {
return this.tail;
}
@Override
public E peek() {
return this.head;
}
@Override
public boolean isEmpty() {
return false;
}
@Override
public Iterator<E> iterator() {
return new StackIterator<>(this);
}
// iterator code
// empty stack singleton code
}
创建栈首先调用ImmutableStack.empty()方法,如下所示:
Stack<String> s = ImmutableStack.empty(String.class);
在捆绑的代码中,你可以看到这个栈如何被进一步使用。
51. 揭示与字符串相关的常见错误
每个人都知道String是一个不可变类。
即使如此,我们仍然容易不小心编写忽略String不可变性的代码。看看这段代码:
String str = "start";
str = stopIt(str);
public static String stopIt(String str) {
str.replace(str, "stop");
return str;
}
某种程度上,认为replace()调用已经用stop替换了文本start,现在str是stop,这是合乎逻辑的。这是文字的力量(replace是一个动词,清楚地诱导出文本被替换的想法)。但是,String是不可变的!哦……我们已经知道了!这意味着replace()不能改变原始的str。我们容易犯很多这样的愚蠢错误,所以请特别注意这些简单的事情,因为它们可能会在调试阶段浪费你的时间。
解决方案很明显,一目了然:
public static String stopIt(String str) {
str = str.replace(str, "stop");
return str;
}
或者,简单地说:
public static String stopIt(String str) {
return str.replace(str, "stop");
}
不要忘记String是不可变的!
52. 使用增强的 NullPointerException
仔细分析以下简单代码,并尝试识别可能导致NullPointerException的部分(这些部分被标记为编号警告,将在代码片段之后解释):
public final class ChainSaw {
private static final List<String> MODELS
= List.of("T300", "T450", "T700", "T800", "T900");
private final String model;
private final String power;
private final int speed;
public boolean started;
private ChainSaw(String model, String power, int speed) {
this.model = model;
this.power = power;
this.speed = speed;
}
public static ChainSaw initChainSaw(String model) {
for (String m : MODELS) {
if (model.endsWith(m)) {**WARNING** **3****!**
return new ChainSaw(model, null, **WARNING** **5****!**
(int) (Math.random() * 100));
}
}
return null; **WARNING** **1****,****2****!**
}
public int performance(ChainSaw[] css) {
int score = 0;
for (ChainSaw cs : css) { **WARNING** **3****!**
score += Integer.compare(
this.speed,cs.speed); **WARNING** **4****!**
}
return score;
}
public void start() {
if (!started) {
System.out.println("Started ...");
started = true;
}
}
public void stop() {
if (started) {
System.out.println("Stopped ...");
started = false;
}
}
public String getPower() {
return power; **WARNING** **5****!**
}
@Override
public String toString() {
return "ChainSaw{" + "model=" + model
+ ", speed=" + speed + ", started=" + started + '}';
}
}
你注意到了警告吗?当然,你注意到了!大多数NullPointerException(NPE)背后有五个主要场景,而且每个场景在前一个类中都有体现。在 JDK 14 之前,NPE 不包含有关原因的详细信息。看看这个异常:
Exception in thread "main" java.lang.NullPointerException
at modern.challenge.Main.main(Main.java:21)
这条消息只是调试过程的起点。我们不知道这个 NPE 的根本原因或哪个变量是null。但是,从 JDK 14(JEP 358)开始,我们有了非常有帮助的 NPE 消息。例如,在 JDK 14+中,之前的消息如下所示:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "modern.challenge.Strings.reverse()" because "str" is null
at modern.challenge.Main.main(Main.java:21)
消息中突出显示的部分提供了关于这个 NPE 根本原因的重要信息。现在,我们知道str变量是null,因此无需进一步调试。我们只需关注如何解决这个问题。
接下来,让我们逐一解决 NPE 的五个主要根本原因。
警告 1!通过空对象调用实例方法时发生 NPE
考虑以下由ChainSaw的客户编写的代码:
ChainSaw cs = ChainSaw.initChainSaw("QW-T650");
cs.start(); // 'cs' is null
客户传递了一个这个类不支持的手锯模型,因此 initChainSaw() 方法返回 null。这真的很糟糕,因为每次客户端使用 cs 变量时,他们都会得到一个如下的 NPE:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "modern.challenge.ChainSaw.start()" because "cs" is null
at modern.challenge.Main.main(Main.java:9)
与返回 null 相比,抛出一个明确的异常来通知客户端他们不能继续,因为我们没有这个手锯模型(我们可以选择经典的 IllegalArgumentException 或者,在这种情况下更有说服力的(但处理 null 值时相当不常见)UnsupportedOperationException)。在这种情况下,这可能是一个合适的修复,但并不总是如此。有些情况下,返回一个空对象(例如,一个空字符串、集合或数组)或默认对象(例如,具有最小化设置的对象)可能更好,这样就不会破坏客户端代码。自从 JDK 8 以来,我们还可以使用 Optional。当然,有些情况下返回 null 是有意义的,但这更常见于 API 和特殊情况下。
警告 2!访问(或修改)空对象的字段时出现 NPE
考虑以下由 ChainSaw 客户编写的代码:
ChainSaw cs = ChainSaw.initChainSaw("QW-T650");
boolean isStarted = cs.started; // 'cs' is null
实际上,在这个情况下,NPE 的根本原因与上一个案例相同。我们试图访问 ChainSaw 的 started 字段。由于这是一个原始的 boolean 类型,它被 JVM 初始化为 false,但我们无法“看到”这一点,因为我们试图通过一个由 cs 表示的 null 变量来访问这个字段。
警告 3!当方法参数为 null 时出现 NPE
考虑以下由 ChainSaw 客户编写的代码:
ChainSaw cs = ChainSaw.initChainSaw(null);
如果你想要一个 null ChainSaw,那么你不是一个好公民,但我是谁来判断呢?这种情况可能发生,并且会导致以下 NPE:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.endsWith(String)" because "model" is null
at modern.challenge.ChainSaw.initChainSaw(ChainSaw.java:25)
at modern.challenge.Main.main(Main.java:16)
消息非常明确。我们尝试使用 model 变量作为 null 参数调用 String.endWith() 方法。为了解决这个问题,我们必须添加一个保护条件来确保传递的 model 参数不是 null(并且最终不是空的)。在这种情况下,我们可以抛出一个 IllegalArgumentException 来通知客户端我们在这里,并且我们在进行保护。另一种方法可能包括用一个不会引起问题的虚拟模型替换给定的 null(例如,由于模型是一个 String,我们可以重新分配一个空字符串,““)。然而,我个人不推荐这种方法,即使是对于小方法也不推荐。你永远不知道代码会如何演变,这样的虚拟重新赋值可能会导致脆弱的代码。
警告 4!访问空数组/集合的索引值时出现 NPE
考虑以下由 ChainSaw 客户编写的代码:
ChainSaw myChainSaw = ChainSaw.initChainSaw("QWE-T800");
ChainSaw[] friendsChainSaw = new ChainSaw[]{
ChainSaw.initChainSaw("Q22-T450"),
ChainSaw.initChainSaw("QRT-T300"),
ChainSaw.initChainSaw("Q-T900"),
null, // ops!
ChainSaw.initChainSaw("QMM-T850"), // model is not supported
ChainSaw.initChainSaw("ASR-T900")
};
int score = myChainSaw.performance(friendsChainSaw);
在这个例子中创建一个 ChainSaw 数组相当具有挑战性。我们意外地(实际上是有意为之)插入了一个 null 值和一个不受支持的模型。作为回报,我们得到了以下 NPE:
Exception in thread "main" java.lang.NullPointerException: Cannot read field "speed" because "cs" is null
at modern.challenge.ChainSaw.performance(ChainSaw.java:37)
at modern.challenge.Main.main(Main.java:31)
消息通知我们 cs 变量是 null。这发生在 ChainSaw 的第 37 行,因此是在 performance() 方法的 for 循环中。当遍历给定的数组时,我们的代码遍历了 null 值,该值没有 speed 字段。请注意这种情况:即使给定的数组/集合本身不是 null,并不意味着它不能包含 null 项。因此,在处理每个项之前添加保护检查可以让我们避免这种情况下的 NPE。根据上下文,当循环通过第一个 null 时,我们可以抛出 IllegalArgumentException,或者简单地忽略 null 值,不中断流程(通常这更合适)。当然,使用不接受 null 值的集合也是一个好方法(Apache Commons Collection 和 Guava 有这样的集合)。
警告 5!通过 getter 访问字段时发生 NPE
考虑以下由 ChainSaw 的客户端编写的代码:
ChainSaw cs = ChainSaw.initChainSaw("T5A-T800");
String power = cs.getPower();
System.out.println(power.concat(" Watts"));
此外,相关的 NPE:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.concat(String)" because "power" is null
at modern.challenge.Main.main(Main.java:37)
实际上,getter getPower() 返回 null,因为 power 字段是 null。为什么?答案是 initChainSaw() 方法中的这一行 return new ChainSaw(model, null, (int) (Math.random() * 100));。因为我们还没有决定计算链锯功率的算法,所以我们向 ChainSaw 构造函数传递了 null。此外,构造函数简单地设置了 power 字段为 this.power = power。如果它是一个公共构造函数,那么我们很可能会添加一些保护条件,但作为一个私有构造函数,最好从根源上修复这个问题,而不是传递那个 null。由于 power 是一个 String,我们可以简单地传递一个空字符串或一个提示字符串,例如 UNKNOWN_POWER。我们还可以在代码中留下 TODO 注释,例如 // TODO (JIRA ####): replace UNKNOWN_POWER with code。这将提醒我们在下一个版本中修复这个问题。同时,代码已经消除了 NPE 风险。
好的,在我们修复了这五个 NPE 风险之后,代码变成了以下这样(新增的代码已高亮显示):
public final class ChainSaw {
private static final String UNKNOWN_POWER = "UNKNOWN";
private static final List<String> MODELS
= List.of("T300", "T450", "T700", "T800", "T900");
private final String model;
private final String power;
private final int speed;
public boolean started;
private ChainSaw(String model, String power, int speed) {
this.model = model;
this.power = power;
this.speed = speed;
}
public static ChainSaw initChainSaw(String model) {
if (model == null || model.isBlank()) {
throw new IllegalArgumentException("The given model
cannot be null/empty");
}
for (String m : MODELS) {
if (model.endsWith(m)) {
// TO DO (JIRA ####): replace UNKNOWN_POWER with code
return new ChainSaw(model, **UNKNOWN_POWER**,
(int) (Math.random() * 100));
}
}
throw new UnsupportedOperationException(
"Model " + model + " is not supported");
}
public int performance(ChainSaw[] css) {
if (css == null) {
throw new IllegalArgumentException(
"The given models cannot be null");
}
int score = 0;
for (ChainSaw cs : css) {
if (cs != null) {
score += Integer.compare(this.speed, cs.speed);
}
}
return score;
}
public void start() {
if (!started) {
System.out.println("Started ...");
started = true;
}
}
public void stop() {
if (started) {
System.out.println("Stopped ...");
started = false;
}
}
public String getPower() {
return power;
}
@Override
public String toString() {
return "ChainSaw{" + "model=" + model
+ ", speed=" + speed + ", started=" + started + '}';
}
}
完成!现在,我们的代码没有 NPE。至少直到现实与我们对立,新的 NPE 发生。
53. 在 switch 表达式中使用 yield
这里,我们将探讨 switch 表达式在 JDK 13+ 中的演变。
Java SE 13 添加了新的 yield 语句,该语句可以用作 switch 表达式中的 break 语句的替代。
我们知道 JDK 12+ 的 switch 表达式可以写成以下形式(playerType 是一个 Java 枚举):
return switch (playerType) {
case TENNIS ->
new TennisPlayer();
case FOOTBALL ->
new FootballPlayer();
...
};
此外,我们知道标签的箭头可以指向花括号块(这仅在 JDK 12 中有效,在 JDK 13+ 中无效):
return switch (playerType) {
case TENNIS -> {
System.out.println("Creating a TennisPlayer ...");
break new TennisPlayer();
}
case FOOTBALL -> {
System.out.println("Creating a FootballPlayer ...");
break new FootballPlayer();
}
...
};
由于 break 可能会令人困惑,因为它可以用于老式的 switch 语句和新的 switch 表达式,JDK 13 添加了 yield 语句来代替 break。yield 语句接受一个参数,表示当前 case 生成的值。以下示例可以从 JDK 13+ 开始编写:
return switch (playerType) {
case TENNIS:
yield new TennisPlayer();
case FOOTBALL:
yield new FootballPlayer();
...
};
return switch (playerType) {
case TENNIS -> {
System.out.println("Creating a TennisPlayer ...");
yield new TennisPlayer();
}
case FOOTBALL -> {
System.out.println("Creating a FootballPlayer ...");
yield new FootballPlayer();
}
...
};
换句话说,从 JDK 13+ 开始,switch 表达式可以依赖于 yield 但不依赖于 break,而 switch 语句可以依赖于 break 但不依赖于 yield。
54. 解决 switch 语句中的 null 子句
在 JDK 17 之前,switch 语句中的 null 情况通常会被编码为在 switch 语句外部的保护条件,如下面的示例所示:
private static Player createPlayer(PlayerTypes playerType) {
// handling null values in a condition outside switch
if (playerType == null) {
throw new IllegalArgumentException(
"Player type cannot be null");
}
return switch (playerType) {
case TENNIS -> new TennisPlayer();
case FOOTBALL -> new FootballPlayer();
...
};
}
从 JDK 17+(JEP 427)开始,我们可以将 null 情况视为任何其他常见情况。例如,这里有一个负责处理传入参数为 null 的场景的 null 情况:
private static Player createPlayer(PlayerTypes playerType) {
return switch (playerType) {
case TENNIS -> new TennisPlayer();
case FOOTBALL -> new FootballPlayer();
case SNOOKER -> new SnookerPlayer();
case null -> throw new NullPointerException(
"Player type cannot be null");
case UNKNOWN -> throw new UnknownPlayerException(
"Player type is unknown");
// default is not mandatory
default -> throw new IllegalArgumentException(
"Invalid player type: " + playerType);
};
}
在某些上下文中,null 和 default 有相同的意义,因此我们可以在同一个 case 语句中将它们链接起来:
private static Player createPlayer(PlayerTypes playerType) {
return switch (playerType) {
case TENNIS -> new TennisPlayer();
case FOOTBALL -> new FootballPlayer();
...
case null, default ->
throw new IllegalArgumentException(
"Invalid player type: " + playerType);
};
}
或者您可能更喜欢以下这种更易读的格式:
...
case TENNIS: yield new TennisPlayer();
case FOOTBALL: yield new FootballPlayer();
...
case null, default:
throw new IllegalArgumentException(
"Invalid player type: " + playerType);
...
个人建议,在用 case null 修补 switch 表达式之前,请三思,尤其是如果您计划仅为了静默清除这些值。总体而言,您的代码可能会变得脆弱,并容易受到忽略 null 值存在的不预期的行为/结果的影响。在捆绑的代码中,您可以测试完整的示例。
55. 以艰难的方式发现 equals()
查看以下代码:
Integer x1 = 14; Integer y1 = 14;
Integer x2 = 129; Integer y2 = 129;
List<Integer> listOfInt1 = new ArrayList<>(
Arrays.asList(x1, y1, x2, y2));
listOfInt1.removeIf(t -> t == x1 || t == x2);
List<Integer> listOfInt2 = new ArrayList<>(
Arrays.asList(x1, y1, x2, y2));
listOfInt2.removeIf(t -> t.equals(x1) || t.equals(x2));
因此,最初,listOfInt1 和 listOfInt2 有相同的项,[x1=14, y1=14, x2=129, y2=129]。但是,根据 removeIf() 和 == 或 equals() 执行代码后,listOfInt1/listOfInt2 将包含什么?
第一个列表将保留一个单独的项目,[129]。当 t 是 x1 时,我们知道 x1 == x1,所以 14 被移除。但是,为什么 x2 会被移除?当 t 是 y1 时,我们知道 y1 == x1 应该是 false,因为通过 ==,我们比较的是对象在内存中的引用,而不是它们的值。显然,y1 和 x1 应该在内存中有不同的引用……或者不是吗?实际上,Java 有一个内部规则,将整数缓存于 -127 … 128 之间。由于 x1=14 被缓存,y1=14 使用了缓存,因此没有创建新的 Integer。这就是为什么 y1 == x1 和 y1 也会被移除。接下来,t 是 x2,且 x2 == x2,所以 x2 也会被移除。最后,t 是 y2,但 y2 == x2 返回 false,因为 129 > 128 没有被缓存,所以 x2 和 y2 在内存中有不同的引用。
另一方面,当我们使用 equals() 方法,这是比较对象值推荐的方法,结果列表将是空的。当 t 是 x1 时,x1 =x1,因此 14 被移除。当 t 是 y1 时,y1 =x1,因此 y1 也会被移除。接下来,t 是 x2,且 x2= x2,所以 x2 也会被移除。最后,t 是 y2,且 y2 =x2,所以 y2 也会被移除。
56. 简而言之,挂钩 instanceof
有一个对象(o)和一个类型(t),我们可以使用 instanceof 操作符通过编写 o instanceof t 来测试 o 是否为类型 t。这是一个非常有用的 boolean 操作符,可以确保后续类型转换操作的成功。例如,检查以下内容:
interface Furniture {};
class Plywood {};
class Wardrobe extends Plywood implements Furniture {};
instanceof 如果我们测试对象(例如,Wardrobe)与类型本身,则返回 true:
Wardrobe wardrobe = new Wardrobe();
if(wardrobe instanceof Wardrobe) { } // true
Plywood plywood = new Plywood();
if(plywood instanceof Plywood) { } // true
如果测试对象(例如,Wardrobe)是类型的子类的实例(例如 Plywood),则 instanceof 返回 true:
Wardrobe wardrobe = new Wardrobe();
if(wardrobe instanceof Plywood) {} // true
如果测试对象(例如,Wardrobe)实现了由类型表示的接口(例如,Furniture),则 instanceof 返回 true:
Wardrobe wardrobe = new Wardrobe();
if(wardrobe instanceof Furniture) {} // true
基于此,请注意以下内容:
重要提示
instanceof 的逻辑依赖于 IS-A 关系(这在 Java 完整编码面试指南,第六章,什么是继承? 中有详细说明)。简而言之,这种关系基于接口实现或类继承。例如,wardrobe instanceof Plywood 返回 true,因为 Wardrobe 扩展了 Plywood,所以 Wardrobe 是 Plywood 的一个实例。同样,Wardrobe 是 Furniture 的一个实例。另一方面,Plywood 不是 Furniture 的一个实例,所以 plywood instanceof Furniture 返回 false。在此上下文中,由于每个 Java 类都扩展了 Object,我们知道只要 foo 是一个 Java 类的实例,foo instanceof Object 就会返回 true。此外,null instanceof Object(或任何其他对象)返回 false,因此这个操作符不需要显式的 null 检查。
最后,请记住,instanceof 只与可重载类型一起使用(可重载类型信息在运行时可用),包括:
-
基本类型(
int,float) -
原始类型(
List,Set) -
非泛型类/接口(
String) -
带有未绑定通配符的泛型类型(
List<?>,Map<?, ?>) -
可重载类型的数组(
String[],Map<?, ?>[],Set<?>[])
这意味着我们无法在参数化类型中使用 instanceof 操作符(或类型转换),因为类型擦除会改变泛型代码中的所有类型参数,所以我们无法在运行时确定泛型类型所使用的参数化类型。
57. 介绍模式匹配
JDK 16 引入了 Java 的一些主要且复杂的特性之一,称为 模式匹配。这个主题的未来前景广阔。
简而言之,模式匹配定义了一个用于检查/测试给定变量是否具有某些属性的合成表达式。如果这些属性满足条件,则自动将变量的一个或多个部分提取到其他变量中。从这一点开始,我们可以使用这些提取的变量。
模式匹配实例(请注意,这与设计模式无关)是由以下几个组件组成的结构(这基本上是模式匹配的术语):
-
目标操作数或谓词的参数:这是我们旨在匹配的变量(或表达式)。
-
谓词(或测试):这是一个在运行时进行的检查,旨在确定给定的 目标操作数 是否具有一个或多个属性(我们将 目标操作数 与属性进行匹配)。
-
一个或多个变量被称为模式变量或绑定变量:这些变量仅在谓词/测试成功时自动从目标操作数中提取。
-
最后,我们有模式本身,它由谓词 + 绑定变量表示。

图 2.31:模式匹配组件
因此,我们可以这样说,Java 的模式匹配是一个由四个组件组成的复杂解决方案的合成表达式:目标操作数、谓词/测试、绑定变量(s)和模式 = 谓词 + 绑定变量(s)。
模式匹配中绑定变量的作用域
编译器决定绑定变量的作用域(可见性),所以我们不需要通过特殊修饰符或其他技巧来处理这些方面。在谓词始终通过的情况下(如if(true) {}),编译器将绑定变量的作用域精确地设置为 Java 的局部变量。
但是,大多数模式之所以有意义,正是因为谓词可能会失败。在这种情况下,编译器应用一种称为流作用域的技术。这实际上是常规作用域和确定赋值的组合。
确定赋值是编译器根据语句和表达式的结构使用的一种技术,以确保在代码访问之前,局部变量(或空白final字段)确实被赋值。在模式匹配的上下文中,只有当谓词通过时,绑定变量才会被赋值,因此确定赋值的目标是找出这种赋值发生的精确位置。接下来,常规代码块作用域表示绑定变量在作用域内的代码。
你想要这个简单的重要提示吗?这里就是。
重要提示
在模式匹配中,绑定变量是流作用域的。换句话说,绑定变量的作用域仅覆盖传递谓词的代码块。
我们将在问题 59中介绍这个主题。
守卫模式
到目前为止,我们知道模式依赖于谓词/测试来决定是否应从目标操作数中提取绑定变量。此外,有时我们需要通过附加基于提取的绑定变量的额外boolean检查来细化这个谓词。我们称这为守卫模式。换句话说,如果谓词评估为true,则提取绑定变量,并且它们进入进一步的boolean检查。如果这些检查评估为true,我们可以说目标操作数与这个守卫模式匹配。
我们将在问题 64中介绍这一点。
类型覆盖率
简而言之,使用null和/或模式标签的switch表达式和switch语句应该是详尽的。换句话说,我们必须使用switch case标签涵盖所有可能的值。
我们将在问题 66中介绍这一点。
模式匹配的当前状态
目前,Java 支持instanceof和switch的类型模式匹配,以及记录的记录模式解构(在第四章中介绍)。这些是 JDK 21 的最终发布版本。
58. 介绍instanceof的类型模式匹配
code (this is a simple code used to save different kinds of artifacts on a USB device)?
public static String save(Object o) throws IOException {
if (o instanceof File) {
File file = (File) o;
return "Saving a file of size: "
+ String.format("%,d bytes", file.length());
}
if (o instanceof Path) {
Path path = (Path) o;
return "Saving a file of size: "
+ String.format("%,d bytes", Files.size(path));
}
if (o instanceof String) {
String str = (String) o;
return "Saving a string of size: "
+ String.format("%,d bytes", str.length());
}
return "I cannot save the given object";
}
你说得对……类型检查和转型既难以编写也难以阅读。此外,那些检查转型序列容易出错(很容易更改检查的类型或转型的类型,而忘记更改另一个对象类型)。基本上,在每一个条件语句中,我们执行三个步骤,如下所示:
-
首先,我们进行类型检查(例如,
o instanceof File)。 -
第二,我们通过转型进行类型转换(例如,
(File) o)。 -
第三,我们进行变量赋值(例如,
File file =)。
但是,从 JDK 16(JEP 394)开始,我们可以使用instanceof的类型模式匹配来在一个表达式中执行前面的三个步骤。类型模式是 Java 支持的第一个模式类别。让我们看看通过类型模式重写的之前的代码:
public static String save(Object o) throws IOException {
if (o instanceof File file) {
return "Saving a file of size: "
+ String.format("%,d bytes", file.length());
}
if (o instanceof String str) {
return "Saving a string of size: "
+ String.format("%,d bytes", str.length());
}
if (o instanceof Path path) {
return "Saving a file of size: "
+ String.format("%,d bytes", Files.size(path));
}
return "I cannot save the given object";
}
在每个if-then语句中,我们有一个测试/谓词来确定Object o的类型,将Object o转换为File、Path或String,以及从Object o中提取长度或大小的解构阶段。
这段代码(o instanceof File file)不仅仅是某种语法糖。它不仅仅是旧式代码的便捷快捷方式,以减少条件状态提取的仪式。这是一个正在运行的类型模式!
实际上,我们将变量o与File file进行匹配。更准确地说,我们将o的类型与File类型进行匹配。我们有o是目标操作数(谓词的参数),instanceof File是谓词,而变量file是模式或绑定变量,只有当instanceof File返回true时才会自动创建。此外,instanceof File file是类型模式,简而言之,File file就是模式本身。以下图示说明了这个语句:

图 2.32:instanceof的类型模式匹配
在instanceof的类型模式中,不需要执行显式的null检查(正如在普通instanceof的情况下),也不允许向上转型。以下两个示例在 JDK 16-20 中会生成编译错误,但在 JDK 14/15/21 中不会(这确实很奇怪):
if ("foo" instanceof String str) {}
if ("foo" instanceof CharSequence sequence) {}
编译错误指出,表达式类型不能是模式类型的子类型(不允许向上转型)。然而,使用普通的instanceof,这在所有 JDK 中都是可行的:
if ("foo" instanceof String) {}
if ("foo" instanceof CharSequence) {}
接下来,让我们谈谈绑定变量的作用域。
59. 处理instanceof类型模式中的绑定变量作用域
从 问题 57 中,我们知道在模式匹配中绑定变量的作用域。此外,从上一个问题中我们知道,在 instanceof 的类型模式中,我们有一个单一的绑定变量。是时候看看一些实际例子了,所以让我们快速从上一个问题中提取这个代码片段:
if (o instanceof File **file**) {
return "Saving a file of size: "
+ String.format("%,d bytes", **file.length()**);
}
// 'file' is out of scope here
file binding variable is visible in the if-then block. Once the block is closed, the file binding variable is out of scope. But, thanks to flow scoping, a binding variable can be used in the if statement that has introduced it to define a so-called *guarded pattern*. Here it is:
// 'file' is created ONLY if 'instanceof' returns true
if (o instanceof File file
// this is evaluated ONLY if 'file' was created
&& file.length() > 0 && file.length() < 1000) {
return "Saving a file of size: "
+ String.format("%,d bytes", file.length());
}
// another example
if (o instanceof Path path
&& Files.size(path) > 0 && Files.size(path) < 1000) {
return "Saving a file of size: "
+ String.format("%,d bytes", Files.size(path));
}
以 && 短路运算符开始的条件部分只有在 instanceof 运算符评估为 true 时才会由编译器评估。这意味着你不能用 || 运算符代替 &&。例如,以下写法是不合逻辑的:
// this will not compile
if (o instanceof Path path
|| Files.size(path) > 0 && Files.size(path) < 1000) {...}
另一方面,这是完全可以接受的:
if (o instanceof Path path
&& (Files.size(path) > 0 || Files.size(path) < 1000)) {...}
我们还可以扩展绑定变量的作用域如下:
if (!(o instanceof String str)) {
// str is not available here
return "I cannot save the given object";
} else {
return "Saving a string of size: "
+ String.format("%,d bytes", str.length());
}
由于我们否定了 if-then 语句,str 绑定变量在 else 分支中可用。按照这个逻辑,我们还可以使用 早期返回:
public int getStringLength(Object o) {
if (!(o instanceof String str)) {
return 0;
}
return str.length();
}
多亏了流作用域,编译器可以为绑定变量的作用域设置严格的边界。例如,在以下代码中,即使我们继续使用相同的绑定变量名称,也不会有重叠的风险:
private String strNumber(Object o) {
if (o instanceof Integer **nr**) {
return String.valueOf(nr.intValue());
} else if (o instanceof Long **nr**) {
return String.valueOf(nr.longValue());
} else {
// nr is out of scope here
return "Probably a float number";
}
}
在这里,每个 nr 绑定变量的作用域仅覆盖其自身的分支。没有重叠,没有冲突!然而,使用相同的名称为多个绑定变量可能会有些混乱,因此最好避免这样做。例如,我们可以使用 intNr 和 longNr 而不是简单的 nr。
另一个应该避免的令人困惑的场景是隐藏字段的绑定变量。查看以下代码:
private final String **str**
= " I am a string with leading and trailing spaces ";
public String convert(Object o) {
// local variable (binding variable) hides a field
if (o instanceof String **str**) {
return str.strip(); // refers to binding variable, str
} else {
return str.strip(); // refers to field, str
}
}
因此,为绑定变量(这同样适用于任何局部变量以及字段)使用相同的名称是一种不良实践,应该避免。
在 JDK 14/15 中,我们不能重新赋值绑定变量,因为它们默认被声明为 final。然而,JDK 16+ 通过移除 final 修饰符解决了本地变量和绑定变量之间可能出现的非对称性。因此,从 JDK 16+ 开始,我们可以像以下代码片段那样重新赋值绑定变量:
String dummy = "";
private int getLength(Object o) {
if(o instanceof String str) {
str = dummy; // reassigning binding variable
// returns the length of 'dummy' not the passed 'str'
return str.length();
}
return 0;
}
即使这是可能的,也强烈建议避免这种 代码异味,通过不重新赋值你的绑定变量来保持世界清洁和愉快。
60. 通过类型模式重写 instanceof 的 equals()
实现方法 equals() 不必依赖于 instanceof,但将其编写如下是一种方便的方法:
public class MyPoint {
private final int x;
private final int y;
private final int z;
public MyPoint(int x, int y, int z) {
this.x = x;
this.y = y;
this.z = z;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof MyPoint)) {
return false;
}
final MyPoint other = (MyPoint) obj;
return (this.x == other.x && this.y == other.y
&& this.z == other.z);
}
}
如果你喜欢之前实现 equals() 的方法,那么你将喜欢通过类型模式重写 instanceof。查看以下代码片段:
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
return obj instanceof MyPoint other
&& this.x == other.x && this.y == other.y
&& this.z == other.z;
}
如果 MyPoint 是泛型 (MyPoint<E>),则可以简单地使用通配符如下(更多细节将在下一个问题中提供):
return obj instanceof MyPoint<?> other
&& this.x == other.x && this.y == other.y
&& this.z == other.z;
很酷,对吧?!然而,请注意,使用instanceof来表示equals()契约强制使用final类和final equals()。否则,如果子类允许覆盖equals(),那么instanceof可能会导致传递性/对称性错误。一种好的方法是使用专门的验证器(如 equals 验证器github.com/jqno/equalsverifier)传递equals(),该验证器能够检查equals()和hashCode()契约的有效性。
61. 解决 instanceof 和泛型的类型模式问题
instanceof in the old-school fashion:
public static <K, V> void process(Map<K, ? extends V> map) {
if (map instanceof EnumMap<?, ? extends V>) {
EnumMap<?, ? extends V> books
= (EnumMap<?, ? extends V>) map;
if (books.get(Status.DRAFT) instanceof Book) {
Book book = (Book) books.get(Status.DRAFT);
book.review();
}
}
}
// use case
EnumMap<Status, Book> books = new EnumMap<>(Status.class);
books.put(Status.DRAFT, new Book());
books.put(Status.READY, new Book());
process(books);
如我们从问题 56中得知,我们可以通过无界通配符将instanceof与泛型类型结合使用,例如我们的EnumMap<?, ? extends V>(或EnumMap<?, ?>,但不能是EnumMap<K, ? extends V>、EnumMap<K, ?>或EnumMap<K, V>)。
这段代码可以通过instanceof的类型模式更简洁地编写如下:
public static <K, V> void process(Map<K, ? extends V> map) {
if (map instanceof EnumMap<?, ? extends V> books
&& books.get(Status.DRAFT) instanceof Book book) {
book.review();
}
}
在基于普通instanceof的示例中,我们还可以将EnumMap<?, ? extends V>替换为Map<?, ? extends V>。但是,如我们从问题 53中得知,由于表达式类型不能是模式类型的子类型(允许向上转型),因此这不可能通过类型模式实现。然而,从 JDK 21 开始,这不再是问题。
62. 解决 instanceof 和流的类型模式问题
让我们考虑一个List<Engine>,其中Engine是一个由几个类(如HypersonicEngine、HighSpeedEngine和RegularEngine)实现的接口。我们的目标是过滤这个List并消除所有电动且无法通过我们的自主性测试的RegularEngine类。因此,我们可以编写如下代码:
public static List<Engine> filterRegularEngines(
List<Engine> engines, int testSpeed) {
for (Iterator<Engine> i = engines.iterator(); i.hasNext();){
final Engine e = i.next();
if (e instanceof RegularEngine) {
final RegularEngine popularEngine = (RegularEngine) e;
if (popularEngine.isElectric()) {
if (!hasEnoughAutonomy(popularEngine, testSpeed)) {
i.remove();
}
}
}
}
return engines;
}
但是,从 JDK 8 开始,我们可以通过java.util.Collection的default方法public default boolean removeIf(Predicate<? super E> filter)安全地从List中移除元素,而无需使用Iterator。如果我们结合这个方法(以及因此,Stream API)与instanceof的类型模式,那么我们可以将之前的代码简化如下:
public static List<Engine> filterRegularEngines(
List<Engine> engines, int testSpeed) {
engines.removeIf(e -> e instanceof RegularEngine engine
&& engine.isElectric()
&& !hasEnoughAutonomy(engine, testSpeed));
return engines;
}
所以,每当有机会使用 Stream API 与类型模式结合时,不要犹豫。
63. 引入 switch 的类型模式匹配
JDK 17(JEP 406)作为预览功能添加了switch的类型模式匹配。第二个预览版本在 JDK 18(JEP 420)中可用。最终版本在 JDK 21 作为 JEP 441 发布。
switch的类型模式匹配允许选择表达式(即switch(o)中的o)为任何类型,而不仅仅是枚举常量、数字或字符串。这里的“任何类型”指的是任何类型(任何对象类型、枚举类型、数组类型、记录类型或密封类型)!类型模式匹配不仅限于单个继承层次,这在继承多态的情况下是常见的。case标签可以有类型模式(称为 case 模式标签或简单地称为模式标签),因此选择表达式(o)可以与类型模式匹配,而不仅仅是与常量匹配。
Problem 58 via a type pattern for switch:
public static String save(Object o) throws IOException {
return switch(o) {
case File file -> "Saving a file of size: "
+ String.format("%,d bytes", file.length());
case Path path -> "Saving a file of size: "
+ String.format("%,d bytes", Files.size(path));
case String str -> "Saving a string of size: "
+ String.format("%,d bytes", str.length());
case null -> "Why are you doing this?";
default -> "I cannot save the given object";
};
}
下图标识了switch分支的主要参与者:

图 2.33:开关的类型模式匹配
对于 null 的 case 不是强制的。我们只是为了完整性而添加它。另一方面,default 分支是必须的,但这个主题将在本章后面讨论。
64. 在开关中添加守卫模式标签
你还记得 instanceof 的类型模式可以通过对绑定变量应用额外的 boolean 检查来细化,以获得更精细的使用案例吗?嗯,我们也可以对使用模式标签的 switch 表达式做同样的事情。结果是称为 守卫模式标签。让我们考虑以下代码:
private static String turnOnTheHeat(Heater heater) {
return switch (heater) {
case Stove stove -> "Make a fire in the stove";
case Chimney chimney -> "Make a fire in the chimney";
default -> "No heater available!";
};
}
有一个 Stove 和一个 Chimney,这个 switch 根据模式标签决定在哪里生火。但是,如果 Chimney 是电的会怎样?显然,我们必须插上 Chimney 而不是点燃它。这意味着我们应该添加一个守卫模式标签,帮助我们区分电 Chimney 和非电 Chimney:
return switch (heater) {
case Stove stove -> "Make a fire in the stove";
**case** **Chimney chimney**
**&& chimney.isElectric() ->** **"Plug in the chimney"****;**
case Chimney chimney -> "Make a fire in the chimney";
default -> "No heater available!";
};
嗯,这很简单,对吧?让我们再看一个从以下代码开始的例子:
enum FuelType { GASOLINE, HYDROGEN, KEROSENE }
class Vehicle {
private final int gallon;
private final FuelType fuel;
...
}
对于每个 Vehicle,我们知道燃料类型和油箱中可以容纳多少加仑的燃料。现在,我们可以编写一个 switch,它可以通过守卫模式标签来尝试根据这些信息猜测车辆的类型:
private static String theVehicle(Vehicle vehicle) {
return switch (vehicle) {
case Vehicle v && v.getFuel().equals(GASOLINE)
&& v.getGallon() < 120 -> "probably a car/van";
case Vehicle v && v.getFuel().equals(GASOLINE)
&& v.getGallon() > 120 -> "probably a big rig";
case Vehicle v && v.getFuel().equals(HYDROGEN)
&& v.getGallon() < 300_000 -> "probably an aircraft";
case Vehicle v && v.getFuel().equals(HYDROGEN)
&& v.getGallon() > 300_000 -> "probably a rocket";
case Vehicle v && v.getFuel().equals(KEROSENE)
&& v.getGallon() > 2_000 && v.getGallon() < 6_000
-> "probably a narrow-body aircraft";
case Vehicle v && v.getFuel().equals(KEROSENE)
&& v.getGallon() > 6_000 && v.getGallon() < 55_000
-> "probably a large (B747-400) aircraft";
default -> "no clue";
};
}
注意,在所有情况下模式标签都是相同的(Vehicle v),决策是通过守卫条件来细化的。之前的例子在 JDK 17 和 18 中运行良好,但从 JDK 19+ 开始就不适用了。因为 && 运算符被认为容易混淆,从 JDK 19+ 开始,我们必须处理一种细化语法。实际上,我们不再使用 && 运算符,而是在模式标签和细化 boolean 检查之间使用新的上下文特定关键字 when。因此,在 JDK 19+ 中,之前的代码变为:
return switch (vehicle) {
case Vehicle v when (v.getFuel().equals(GASOLINE)
&& v.getGallon() < 120) -> "probably a car/van";
case Vehicle v when (v.getFuel().equals(GASOLINE)
&& v.getGallon() > 120) -> "probably a big rig";
...
case Vehicle v when (v.getFuel().equals(KEROSENE)
&& v.getGallon() > 6_000 && v.getGallon() < 55_000)
-> "probably a large (B747-400) aircraft";
default -> "no clue";
};
在捆绑的代码中,你可以找到 JDK 17/18 和 JDK 19+ 的两个版本。
65. 在开关中处理模式标签的优先级
编译器通过从上到下(或,从第一个到最后一个)按我们在 switch 块中编写的确切顺序测试选择表达式与每个标签的匹配来匹配选择表达式与可用的模式标签。这意味着第一个匹配获胜。假设我们有以下基类(Pill)和一些药片(Nurofen、Ibuprofen 和 Piafen):
abstract class Pill {}
class Nurofen extends Pill {}
class Ibuprofen extends Pill {}
class Piafen extends Pill {}
从层次结构上来说,Nurofen、Ibuprofen 和 Piafen 是三个处于相同层次级别的类,因为它们都以 Pill 类为基础类。在 IS-A 继承关系中,我们说 Nurofen 是 Pill,Ibuprofen 是 Pill,Piafen 也是 Pill。接下来,让我们使用 switch 为我们的客户提供适当的头痛药片:
private static String headache(Pill o) {
return switch(o) {
case Nurofen nurofen -> "Get Nurofen ...";
case Ibuprofen ibuprofen -> "Get Ibuprofen ...";
case Piafen piafen -> "Get Piafen ...";
default -> "Sorry, we cannot solve your headache!";
};
}
调用 headache(new Nurofen()) 将匹配第一个模式标签 Nurofen nurofen。以同样的方式,headache(new Ibuprofen()) 匹配第二个模式标签,而 headache(new Piafen()) 匹配第三个。无论我们如何混合这些标签情况的顺序,它们都将按预期工作,因为它们处于同一级别,并且没有哪一个支配其他标签。
例如,由于人们不想头疼,他们订购了很多 Nurofen,所以我们已经没有更多的了。我们通过删除/注释相应的案例来表示这一点:
return switch(o) {
// case Nurofen nurofen -> "Get Nurofen ...";
case Ibuprofen ibuprofen -> "Get Ibuprofen ...";
case Piafen piafen -> "Get Piafen ...";
default -> "Sorry, we cannot solve your headache!";
};
那么,当客户想要 Nurofen 时会发生什么?你说得对……由于 Ibuprofen 和 Piafen 不匹配选择表达式,default 分支将采取行动。
但是,如果我们按如下方式修改 switch,会发生什么?
return switch(o) {
case Pill pill -> "Get a headache pill ...";
case Nurofen nurofen -> "Get Nurofen ...";
case Ibuprofen ibuprofen -> "Get Ibuprofen ...";
case Piafen piafen -> "Get Piafen ...";
};
将 Pill 基类作为模式标签情况添加,使我们能够移除 default 分支,因为我们涵盖了所有可能值(这将在 问题 66 中详细说明)。这次,编译器将引发一个错误来通知我们 Pill 标签情况支配了其他标签情况。实际上,第一个标签情况 Pill pill 支配了所有其他标签情况,因为任何与 Nurofen nurofen、Ibuprofen ibuprofen、Piafen piafen 模式匹配的值也匹配 Pill pill 模式。所以,Pill pill 总是获胜,而其他标签情况都是无用的。将 Pill pill 与 Nurofen nurofen 交换将给 Nurofen nurofen 一个机会,但 Pill pill 仍然支配剩下的两个。因此,我们可以通过将它的标签情况移到最后位置来消除基类 Pill 的支配性:
return switch(o) {
case Nurofen nurofen -> "Get Nurofen ...";
case Ibuprofen ibuprofen -> "Get Ibuprofen ...";
case Piafenpiafen -> "Get Piafen ...";
case Pill pill -> "Get a headache pill ...";
};
现在,每个模式标签都有机会获胜。
让我们再举一个从这个层次结构开始的例子:
abstract class Drink {}
class Small extends Drink {}
class Medium extends Small {}
class Large extends Medium {}
class Extra extends Medium {}
class Huge extends Large {}
class Jumbo extends Extra {}
这次,我们有七个类在一个多层次层次结构中排列。如果我们排除基类 Drink,我们可以如下表示其余的类:
private static String buyDrink(Drink o) {
return switch(o) {
case Jumbo j: yield "We can give a Jumbo ...";
case Huge h: yield "We can give a Huge ...";
case Extra e: yield "We can give a Extra ...";
case Large l: yield "We can give a Large ...";
case Medium m: yield "We can give a Medium ...";
case Small s: yield "We can give a Small ...";
default: yield "Sorry, we don't have this drink!";
};
}
模式标签的顺序由类层次结构决定,相当严格,但我们可以在不创建任何支配问题时进行一些更改。例如,由于 Extra 和 Large 都是 Medium 的子类,我们可以交换它们的位置。由于 Jumbo 和 Huge 都是通过 Extra 分别 Large 成为 Medium 的子类,所以一些规则也适用于它们。
在这个上下文中,编译器通过尝试通过 IS-A 继承关系将选择表达式与这个层次结构匹配来评估选择表达式。例如,当没有更多的 Jumbo 和 Extra 饮料时,让我们点一杯 Jumbo 饮料:
return switch(o) {
case Huge h: yield "We can give a Huge ...";
case Large l: yield "We can give a Large ...";
case Medium m: yield "We can give a Medium ...";
case Small s: yield "We can give a Small ...";
default: yield "Sorry, we don't have this drink!";
};
如果我们按 Jumbo 排序(o 是 Jumbo),那么我们将得到 Medium。为什么?编译器在匹配 Jumbo 与 Huge 时没有成功。匹配 Jumbo 与 Large 时也会得到相同的结果。然而,当它匹配 Jumbo 与 Medium 时,它看到 Jumbo 通过 Extra 类是 Medium 的子类。因此,由于 Jumbo 是 Medium,编译器选择了 Medium m 模式标签。在这个时候,Medium 匹配 Jumbo、Extra 和 Medium。所以,很快我们也会用完 Medium:
return switch(o) {
case Huge h: yield "We can give a Huge ...";
case Large l: yield "We can give a Large ...";
case Small s: yield "We can give a Small ...";
default: yield "Sorry, we don't have this drink!";
};
这次,任何对 Jumbo、Extra、Medium 或 Small 的请求都会给我们一个 Small。我想你已经明白了这个想法。
让我们更进一步,分析以下代码:
private static int oneHundredDividedBy(Integer value) {
return switch(value) {
case Integer i -> 100/i;
case 0 -> 0;
};
}
你发现了问题吗?一个模式标签案例支配了一个常量标签案例,所以编译器会抱怨第二个案例(case 0)被第一个案例支配。这是正常的,因为 0 也是一个 Integer,所以它将匹配模式标签。解决方案需要交换案例:
return switch(value) {
case 0 -> 0;
case Integer i -> 100/i;
};
这里是另一个强制执行此类优势的案例:
enum Hero { CAPTAIN_AMERICA, IRON_MAN, HULK }
private static String callMyMarvelHero(Hero hero) {
return switch(hero) {
case Hero h -> "Calling " + h;
case HULK -> "Sorry, we cannot call this guy!";
};
}
在这种情况下,常量是 HULK,它被 Hero h 模式标签案例支配。这是正常的,因为 HULK 也是一个漫威英雄,所以 Hero h 将匹配所有漫威英雄,包括 HULK。同样,修复依赖于交换案例:
return switch(hero) {
case HULK -> "Sorry, we cannot call this guy!";
case Hero h -> "Calling " + h;
};
好的,最后,让我们来处理这段代码:
private static int oneHundredDividedByPositive(Integer value){
return switch(value) {
case Integer i when i > 0 -> 100/i;
case 0 -> 0;
case Integer i -> (-1) * 100/i;
};
}
你可能会认为,如果我们通过一个强制 i 严格为正的条件来强制 Integer i 模式标签,那么常量标签就不会被支配。但这不是真的;受保护的模式标签仍然支配常量标签。正确的顺序是将常量标签放在前面,然后是受保护的模式标签,最后是非受保护的模式标签。下一个代码修复了上一个代码:
return switch(value) {
case 0 -> 0;
case Integer i when i > 0 -> 100/i;
case Integer i -> (-1) * 100/i;
};
好的,我想你已经明白了这个想法。请随意在捆绑的代码中练习所有这些示例。
66. 在模式标签中处理完整性(类型覆盖)的 switch
简而言之,使用 null 和/或模式标签的 switch 表达式和 switch 语句应该是详尽的。换句话说,我们必须使用显式的 switch 案例标签来覆盖所有可能的值。让我们考虑以下示例:
class Vehicle {}
class Car extends Vehicle {}
class Van extends Vehicle {}
private static String whatAmI(Vehicle vehicle) {
return switch(vehicle) {
case Car car -> "You're a car";
case Van van -> "You're a van";
};
}
The switch expression does not cover all possible input values. The compiler complains because we don’t have a case pattern label for Vehicle. This base class can be legitimately used without being a Car or a Van, so it is a valid candidate for our switch. We can add a case Vehicle or a default label. If you know that Vehicle will remain an empty base class, then you’ll probably go for a default label:
return switch(vehicle) {
case Car car -> "You're a car";
case Van van -> "You're a van";
**default** **->** **"I have no idea ... what are you?"****;**
};
如果我们继续添加另一个车辆,例如 class Truck extends Vehicle {},那么这将由 default 分支处理。如果我们计划将 Vehicle 作为独立的类(例如,通过添加方法和功能来丰富它),那么我们更愿意添加一个 case Vehicle,如下所示:
return switch(vehicle) {
case Car car -> "You're a car";
case Van van -> "You're a van";
**case** **Vehicle v ->** **"You're a vehicle"****;** **// total pattern**
};
这次,Truck 类将匹配 case Vehicle 分支。当然,我们也可以添加一个 case Truck。
重要提示
Vehicle v 模式被命名为一个 完全类型模式。我们可以使用两个标签来匹配所有可能的值:完全类型模式(例如,一个基类或接口)和 default 标签。一般来说,一个完全模式是可以用来代替 default 标签的模式。
在上一个例子中,我们可以通过总模式或default标签来容纳所有可能的值,但不能同时使用两者。这很有道理,因为whatAmI(Vehicle vehicle)方法接收Vehicle作为参数。所以,在这个例子中,选择表达式只能是Vehicle或Vehicle的子类。那么,将这个方法修改为whatAmI(Object o)怎么样?
private static String whatAmI(Object o) {
return switch(o) {
case Car car -> "You're a car";
case Van van -> "You're a van";
**case** **Vehicle v ->** **"You're a vehicle"****;** **// optional**
**default** **->** **"I have no idea ... what are you?"****;**
};
}
现在,选择表达式可以是任何类型,这意味着总模式Vehicle v不再是总模式了。当Vehicle v变成一个可选的普通模式时,新的总模式是case Object obj。这意味着我们可以通过添加default标签或case Object obj总模式来覆盖所有可能的值:
return switch(o) {
case Car car -> "You're a car";
case Van van -> "You're a van";
**case** **Vehicle v ->** **"You're a vehicle"****;** **// optional**
**case** **Object obj ->** **"You're an object"****;** **// total pattern**
};
我想你已经明白了这个想法!那么,使用接口作为基类怎么样?例如,这里有一个基于 Java 内置CharSequence接口的例子:
public static String whatAmI(CharSequence cs) {
return switch(cs) {
case String str -> "You're a string";
case Segment segment -> "You're a Segment";
case CharBuffer charbuffer -> "You're a CharBuffer";
case StringBuffer strbuffer -> "You're a StringBuffer";
case StringBuilder strbuilder -> "You're a StringBuilder";
};
}
The switch expression does not cover all possible input values. But, if we check the documentation of CharSequence, we see that it is implemented by five classes: CharBuffer, Segment, String, StringBuffer, and StringBuilder. In our code, each of these classes is covered by a pattern label, so we have covered all possible values, right? Well, yes and no… “Yes” because we cover all possible values for the moment, and “no” because anyone can implement the CharSequence interface, which will break the exhaustive coverage of our switch. We can do this:
public class CoolChar implements CharSequence { ... }
在这个时候,switch表达式没有覆盖CoolChar类型。所以,我们仍然需要一个default标签或总模式case CharSequence charseq,如下所示:
return switch(cs) {
case String str -> "You're a string";
...
case StringBuilder strbuilder -> "You're a StringBuilder";
**// we have created this**
**case** **CoolChar cool ->** **"Welcome ... you're a CoolChar"****;**
**// this is a total pattern**
**case** **CharSequence charseq ->** **"You're a CharSequence"****;**
// can be used instead of the total pattern
// default -> "I have no idea ... what are you?";
};
好的,让我们来处理这个场景,它涉及到java.lang.constant.ClassDesc内置接口:
private static String whatAmI(ConstantDesc constantDesc) {
return switch(constantDesc) {
case Integer i -> "You're an Integer";
case Long l -> "You're a Long";
case Float f -> " You're a Float";
case Double d -> "You're a Double";
case String s -> "You're a String";
case ClassDesc cd -> "You're a ClassDesc";
case DynamicConstantDesc dcd -> "You're a DCD";
case MethodHandleDesc mhd -> "You're a MethodHandleDesc";
case MethodTypeDesc mtd -> "You're a MethodTypeDesc";
};
}
这段代码可以编译!没有default标签和总模式,但switch表达式覆盖了所有可能的值。怎么会这样?!这个接口是通过sealed修饰符声明的密封接口:
public **sealed** interface ClassDesc
extends ConstantDesc, TypeDescriptor.OfField<ClassDesc>
密封接口/类是在 JDK 17(JEP 409)中引入的,我们将在第八章中介绍这个主题。然而,现在,只需要知道密封允许我们精细控制继承,因此类和接口定义了它们的允许子类型。这意味着编译器可以确定switch表达式中所有可能的值。让我们考虑一个更简单的例子,它开始如下:
sealed interface Player {}
final class Tennis implements Player {}
final class Football implements Player {}
final class Snooker implements Player {}
然后,让我们有一个覆盖Player所有可能值的switch表达式:
private static String trainPlayer(Player p) {
return switch (p) {
case Tennis t -> "Training the tennis player ..." + t;
case Football f -> "Training the football player ..." + f;
case Snooker s -> "Training the snooker player ..." + s;
};
}
编译器知道Player接口只有三个实现,并且它们都通过模式标签被覆盖。我们可以添加一个default标签或总模式case Player player,但你很可能不想这么做。想象一下,我们添加了一个名为Golf的新实现,它是密封的Player接口:
final class Golf implements Player {}
如果switch表达式有一个default标签,那么Golf值将由这个default分支处理。如果我们有总模式Player player,那么这个模式将处理Golf值。另一方面,如果没有default标签或总模式,编译器会立即抱怨switch表达式没有覆盖所有可能的值。所以,我们会立即得到通知,一旦我们添加case Golf g,错误就会消失。这样,我们可以轻松地维护我们的代码,并保证我们的switch表达式始终是最新的,并覆盖所有可能的值。编译器永远不会错过通知我们Player的新实现的机会。
类似的逻辑也适用于 Java 枚举。考虑以下enum:
private enum PlayerTypes { TENNIS, FOOTBALL, SNOOKER }
编译器知道PlayerTypes的所有可能值,所以下面的switch表达式编译成功:
private static String createPlayer(PlayerTypes p) {
return switch (p) {
case TENNIS -> "Creating a tennis player ...";
case FOOTBALL -> "Creating a football player ...";
case SNOOKER -> "Creating a snooker player ...";
};
}
再次,我们可以添加一个default标签或总模式case PlayerTypes pt。但是,如果我们向enum中添加一个新值(例如,GOLF),编译器将委托default标签或总模式来处理它。另一方面,如果没有这些可用,编译器将立即抱怨GOLF值没有被覆盖,因此我们可以添加它(case GOLF g)并在需要时创建高尔夫球手。
到目前为止,一切顺利!现在,让我们考虑以下上下文:
final static class PlayerClub implements Sport {};
private enum PlayerTypes implements Sport
{ TENNIS, FOOTBALL, SNOOKER }
sealed interface Sport permits PlayerTypes, PlayerClub {};
密封接口Sport只允许两种子类型:PlayerClub(一个类)和PlayerTypes(一个枚举)。如果我们编写一个涵盖Sport所有可能值的switch,那么它将如下所示:
private static String createPlayerOrClub(Sport s) {
return switch (s) {
case PlayerTypes p when p == PlayerTypes.TENNIS
-> "Creating a tennis player ...";
case PlayerTypes p when p == PlayerTypes.FOOTBALL
-> "Creating a football player ...";
case PlayerTypes p -> "Creating a snooker player ...";
case PlayerClub p -> "Creating a sport club ...";
};
}
我们立即观察到编写case PlayerTypes p when p == PlayerTypes.TENNIS并不十分整洁。我们实际上想要的是case PlayerTypes.TENNIS,但是,直到 JDK 21,这是不可能的,因为合格的枚举常量不能用于case标签。然而,从 JDK 21 开始,我们可以使用枚举常量的合格名称作为标签,因此我们可以写出这个:
private static String createPlayerOrClub(Sport s) {
return switch (s) {
case PlayerTypes.TENNIS
-> "Creating a tennis player ...";
case PlayerTypes.FOOTBALL
-> "Creating a football player ...";
case PlayerTypes.SNOOKER
-> "Creating a snooker player ...";
case PlayerClub p
-> "Creating a sport club ...";
};
}
完成!现在你知道如何处理switch表达式的类型覆盖了。
67. 理解switch表达式中无条件模式和null值
让我们假设我们使用 JDK 17,并且我们有以下代码:
private static String drive(Vehicle v) {
return switch (v) {
case Truck truck -> "truck: " + truck;
case Van van -> "van: " + van;
case Vehicle vehicle -> "vehicle: " + vehicle.start();
};
}
drive(null);
注意到调用,drive(null)。这个调用将匹配Vehicle vehicle总模式,所以即使是null值也会匹配总模式。但是,这意味着绑定变量vehicle也将是null,这意味着这个分支容易抛出NullPointerException(例如,如果我们调用一个假设的方法,vehicle.start()):
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "modern.challenge.Vehicle.start()" because "vehicle" is null
因为Vehicle vehicle匹配所有可能的值,所以它被称为总模式,但也称为无条件模式,因为它无条件地匹配一切。
但是,正如我们从问题 54中知道的,从 JDK 17+(JEP 427)开始,我们可以为null本身有一个模式标签,因此我们可以如下处理之前的不足:
return switch (v) {
case Truck truck -> "truck: " + truck;
case Van van -> "van: " + van;
**case****null** **->** **"so, you don't have a vehicle?"****;**
case Vehicle vehicle -> "vehicle: " + vehicle.start();
};
是的,每个人都同意在车辆之间添加case null看起来很尴尬。将其添加到末尾不是选项,因为它将引发优先级问题。所以,从 JDK 19+开始,在这种情况下不再需要添加这个case null。基本上,这个想法保持不变,意味着无条件模式仍然只匹配null值,因此它不会允许执行该分支。实际上,当出现null值时,switch表达式会立即抛出NullPointerException,甚至不会查看模式。所以,在 JDK 19+中,这段代码会立即抛出 NPE:
return switch (v) {
case Truck truck -> "truck: " + truck;
case Van van -> "van: " + van;
// we can still use a null check
// case null -> "so, you don't have a vehicle?";
// total/unconditional pattern throw NPE immediately
case Vehicle vehicle -> "vehicle: " + vehicle.start();
};
NPE 消息表明vehicle.start()从未被调用。NPE 发生得早得多:
Exception in thread "main" java.lang.NullPointerExceptionatjava.base/java.util.Objects.requireNonNull(Objects.java:233)
我们将在讨论 Java 记录时进一步探讨这个话题。
摘要
就这些了!这是一个全面章节,涵盖了四个主要主题,包括:java.util.Objects、不可变性、switch 表达式以及 instanceof 和 switch 表达式的模式匹配。
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:

第三章:与日期和时间一起工作
本章包括 20 个问题,涵盖不同的日期时间主题。这些问题主要关注Calendar API 和 JDK Date/Time API。关于后者,我们将介绍一些不太流行的 API,如ChronoUnit,ChronoField,IsoFields,TemporalAdjusters等。
在本章结束时,你将拥有大量的技巧和窍门在你的工具箱中,这将非常有用,可以解决各种现实世界的日期时间问题。
问题
使用以下问题来测试你在日期和时间上的编程能力。我强烈建议你在查看解决方案并下载示例程序之前尝试每个问题:
-
定义一天的时间段:编写一个应用程序,它超越了 AM/PM 标志,并将一天分为四个时间段:夜间,早晨,下午和傍晚。根据给定的日期时间和时区生成这些时间段之一。
-
在 Date 和 YearMonth 之间转换:编写一个应用程序,在
java.util.Date和java.time.YearMonth之间进行转换,反之亦然。 -
在 int 和 YearMonth 之间转换:假设给定一个
YearMonth(例如,2023-02)。将其转换为整数表示(例如,24277),该整数可以转换回YearMonth。 -
将周/年转换为 Date:假设给定两个整数,分别表示周和年(例如,第 10 周,2023 年)。编写一个程序,通过
Calendar将 10-2023 转换为java.util.Date,通过WeekFieldsAPI 将 10-2023 转换为LocalDate。反之亦然:从给定的Date/LocalDate中提取年份和周作为整数。 -
检查闰年:假设给定一个表示年份的整数。编写一个应用程序来检查这一年是否是闰年。提供至少三种解决方案。
-
计算给定日期的季度:假设给定一个
java.util.Date。编写一个程序,返回包含此日期的季度作为整数(1,2,3 或 4)和作为字符串(Q1,Q2,Q3 或 Q4)。 -
获取季度的第一天和最后一天:假设给定一个
java.util.Date。编写一个程序,返回包含此日期的季度的第一天和最后一天。以Date(基于Calendar的实现)和LocalDate(基于 JDK 8 Date/Time API 的实现)表示返回的日期。 -
从给定季度中提取月份:假设给定一个季度(作为一个整数,一个字符串(Q1,Q2,Q3 或 Q4),或一个
LocalDate)。编写一个程序,提取该季度的月份名称。 -
计算预产期:编写一个预产期计算器。
-
实现计时器:编写一个程序,通过
System.nanoTime()和Instant.now()实现计时器。 -
提取自午夜以来的毫秒数:假设给出了一个
LocalDateTime。编写一个应用程序,计算从午夜到这个LocalDateTime经过的毫秒数。 -
将日期时间范围分割成等间隔:假设我们有一个日期时间范围,通过两个
LocalDateTime实例给出,以及一个整数n。编写一个应用程序,将给定的范围分割成n个等间隔(n个等LocalDateTime实例)。 -
解释 Clock.systemUTC()和 Clock.systemDefaultZone()之间的区别:通过有意义的示例解释
systemUTC()和systemDefaultZone()之间的区别。 -
显示一周中各天的名称:通过
java.text.DateFormatSymbolsAPI 显示一周中各天的名称。 -
获取一年中的第一天和最后一天:假设给出了一个表示年份的整数。编写一个程序,返回这一年的第一天和最后一天。提供一个基于
CalendarAPI 的解决方案和一个基于 JDK 8 Date/Time API 的解决方案。 -
获取一周的第一天和最后一天:假设我们有一个整数表示周数(例如,3 代表从当前日期开始的连续三周)。编写一个程序,返回每周的第一天和最后一天。提供一个基于
CalendarAPI 的解决方案和一个基于 JDK 8 Date/Time API 的解决方案。 -
计算月份的中间日期:提供一个包含基于
CalendarAPI 的代码片段的应用程序,以及一个基于 JDK 8 Date/Time API 的应用程序,分别用于计算给定月份的中间日期作为一个Date对象,以及作为一个LocalDate对象。 -
计算两个日期之间的季度数:假设通过两个
LocalDate实例给出了一个日期时间范围。编写一个程序,计算这个范围内包含的季度数。 -
将 Calendar 转换为 LocalDateTime:编写一个程序,将给定的
Calendar转换为LocalDateTime(默认时区),或者转换为ZonedDateTime(亚洲/加尔各答时区)。 -
计算两个日期之间的周数:假设我们有一个日期时间范围,以两个
Date实例或两个LocalDateTime实例给出。编写一个应用程序,返回这个范围内包含的周数。对于Date范围,基于CalendarAPI 编写一个解决方案,而对于LocalDateTime范围,基于 JDK 8 Date/Time API 编写一个解决方案。
以下部分描述了前面问题的解决方案。请记住,通常没有一种正确的方式来解决特定的问题。此外,请记住,这里所示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节,并实验程序,请访问 github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter03。
68. 定义一天的时间段
让我们想象一下,我们想要根据另一个国家(不同时区)的朋友的当地时间,通过像 早上好,下午好 等消息来向他们打招呼。所以,仅仅有 AM/PM 标志是不够的,因为我们认为一天(24 小时)可以表示为以下时间段:
-
晚上 9:00(或 21:00)– 上午 5:59 = 夜晚
-
早上 6:00 – 上午 11:59 = 上午
-
下午 12:00 – 晚上 5:59(或 17:59)= 下午
-
下午 6:00(或 18:00)– 晚上 8:59(或 20:59)= 晚上
在 JDK 16 之前
首先,我们必须获得我们朋友时区对应的时间。为此,我们可以从我们的本地时间开始,给定为 java.util.Date,java.time.LocalTime 等。如果我们从 java.util.Date 开始,那么我们可以按照以下方式获得我们朋友时区的时间:
LocalTime lt = date.toInstant().atZone(zoneId).toLocalTime();
在这里,date 是 new Date(),而 zoneId 是 java.time.ZoneId。当然,我们可以将区域 ID 作为 String 传递,并使用 ZoneId.of(String zoneId) 方法来获取 ZoneId 实例。
如果我们希望从 LocalTime.now() 开始,那么我们可以获得我们朋友时区的时间如下:
LocalTime lt = LocalTime.now(zoneId);
接下来,我们可以定义一天的时间段为一组 LocalTime 实例,并添加一些条件来确定当前时间段。以下代码示例说明了这一点:
public static String toDayPeriod(Date date, ZoneId zoneId) {
LocalTime lt = date.toInstant().atZone(zoneId).toLocalTime();
LocalTime night = LocalTime.of(21, 0, 0);
LocalTime morning = LocalTime.of(6, 0, 0);
LocalTime afternoon = LocalTime.of(12, 0, 0);
LocalTime evening = LocalTime.of(18, 0, 0);
LocalTime almostMidnight = LocalTime.of(23, 59, 59);
LocalTime midnight = LocalTime.of(0, 0, 0);
if((lt.isAfter(night) && lt.isBefore(almostMidnight))
|| lt.isAfter(midnight) && (lt.isBefore(morning))) {
return "night";
} else if(lt.isAfter(morning) && lt.isBefore(afternoon)) {
return "morning";
} else if(lt.isAfter(afternoon) && lt.isBefore(evening)) {
return "afternoon";
} else if(lt.isAfter(evening) && lt.isBefore(night)) {
return "evening";
}
return "day";
}
现在,让我们看看如何在 JDK 16+ 中实现这一点。
JDK 16+
从 JDK 16+ 开始,我们可以通过以下字符串超越 AM/PM 标志:早上,下午,晚上 和 夜晚。
这些友好的输出可以通过新的模式 B 获取。这个模式从 JDK 16+ 开始通过 DateTimeFormatter 和 DateTimeFormatterBuilder 可用(你应该熟悉这些 API,如 第一章,问题 18,在 图 1.18 中所示)。
因此,以下代码使用 DateTimeFormatter 来举例说明模式 B 的使用,表示一天中的时间段:
public static String toDayPeriod(Date date, ZoneId zoneId) {
ZonedDateTime zdt = date.toInstant().atZone(zoneId);
DateTimeFormatter formatter
= DateTimeFormatter.ofPattern("yyyy-MMM-dd [B]");
return zdt.withZoneSameInstant(zoneId).format(formatter);
}
这里是澳大利亚/墨尔本的一个输出示例:
2023-Feb-04 at night
你可以在捆绑的代码中看到更多示例。请随意挑战自己调整此代码以重现第一个示例的结果。
69. 日期与 YearMonth 之间的转换
将 java.util.Date 转换为 JDK 8 的 java.time.YearMonth 可以基于 YearMonth.from(TemporalAccessor temporal) 实现。TemporalAccessor 是一个接口(更确切地说,是一个框架级接口),它提供了对任何时间对象的只读访问,包括日期、时间和偏移量(这些的组合也是允许的)。因此,如果我们把给定的 java.util.Date 转换为 java.time.LocalDate,那么转换的结果可以传递给 YearMonth.from() 如下所示:
public static YearMonth toYearMonth(Date date) {
return YearMonth.from(date.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDate());
}
反过来可以通过 Date.from(Instant instant) 获取,如下所示:
public static Date toDate(YearMonth ym) {
return Date.from(ym.atDay(1).atStartOfDay(
ZoneId.systemDefault()).toInstant());
}
好吧,这很简单,不是吗?
70. 在 int 和 YearMonth 之间进行转换
假设我们有 YearMonth.now(),我们想将其转换为整数(例如,这可能在将年/月日期存储在数据库的数字字段中时很有用)。查看以下解决方案:
public static int to(YearMonth u) {
return (int) u.getLong(ChronoField.PROLEPTIC_MONTH);
}
proleptic-month 是一个 java.time.temporal.TemporalField,它基本上代表一个日期时间字段,如 year-of-month(我们的情况)或 minute-of-hour。proleptic-month 从 0 开始,并按顺序从年份 0 计算月份。因此,getLong() 返回从今年月份中指定的字段(在这里是 proleptic-month)的值作为一个 long。我们可以将这个 long 强制转换为 int,因为 proleptic-month 不应该超出 int 范围(例如,对于 2023/2 返回的 int 是 24277)。
反过来可以通过以下方式完成:
public static YearMonth from(int t) {
return YearMonth.of(1970, 1)
.with(ChronoField.PROLEPTIC_MONTH, t);
}
你可以从任何年/月开始。1970/1(称为 epoch 和 java.time.Instant 的起点)的选择只是一个任意的选择。
71. 将周/年转换为 Date
让我们考虑 2023 年,第 10 周。相应的日期是 Sun Mar 05 15:15:08 EET 2023(当然,时间部分是相对的)。将年/周转换为 java.util.Date 可以通过 Calendar API 实现,如下所示的自解释代码片段:
public static Date from(int year, int week) {
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.YEAR, year);
calendar.set(Calendar.WEEK_OF_YEAR, week);
calendar.set(Calendar.DAY_OF_WEEK, 1);
return calendar.getTime();
}
如果你更喜欢获取 LocalDate 而不是 Date,那么你可以轻松地进行相应的转换,或者你可以依赖 java.time.temporal.WeekFields。这个 API 提供了用于处理 year-of-week、month-of-week 和 day-of-week 的几个字段。话虽如此,以下是通过 WeekFields 编写的先前解决方案,用于返回 LocalDate:
public static LocalDate from(int year, int week) {
WeekFields weekFields = WeekFields.of(Locale.getDefault());
return LocalDate.now()
.withYear(year)
.with(weekFields.weekOfYear(), week)
.with(weekFields.dayOfWeek(), 1);
}
另一方面,如果我们有一个 java.util.Date,我们想从中提取年和周,那么我们可以使用 Calendar API。在这里,我们提取年份:
public static int getYear(Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
return calendar.get(Calendar.YEAR);
}
然后,我们提取周:
public static int getWeek(Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
return calendar.get(Calendar.WEEK_OF_YEAR);
}
由于 ChronoField.YEAR 和 ChronoField.ALIGNED_WEEK_OF_YEAR,从 LocalDate 中获取年和周很容易:
public static int getYear(LocalDate date) {
return date.get(ChronoField.YEAR);
}
public static int getWeek(LocalDate date) {
return date.get(ChronoField.ALIGNED_WEEK_OF_YEAR);
}
当然,获取周也可以通过 WeekFields 实现:
return date.get(WeekFields.of(
Locale.getDefault()).weekOfYear());
挑战自己从 Date/LocalDate 中获取周/月和日/周。
72. 检查闰年
只要我们知道了什么是闰年,这个问题就变得简单了。简而言之,闰年是指任何可以被 4 整除的年份(即year % 4 == 0),且不是世纪年(例如,100,200,……,n00)。然而,如果这个世纪年可以被 400 整除(即year % 400 == 0),那么它就是一个闰年。在这种情况下,我们的代码只是一个简单的if语句链,如下所示:
public static boolean isLeapYear(int year) {
if (year % 4 != 0) {
return false;
} else if (year % 400 == 0) {
return true;
} else if (year % 100 == 0) {
return false;
}
return true;
}
但是,这段代码可以使用GregorianCalendar来简化:
public static boolean isLeapYear(int year) {
return new GregorianCalendar(year, 1, 1).isLeapYear(year);
}
或者,从 JDK 8 开始,我们可以依赖于java.time.Year API,如下所示:
public static boolean isLeapYear(int year) {
return Year.of(year).isLeap();
}
在捆绑的代码中,你可以看到更多的方法。
73. 计算给定日期的季度
一年有 4 个季度(通常表示为 Q1,Q2,Q3 和 Q4),每个季度有 3 个月。如果我们考虑 1 月是 0,2 月是 1,……,12 月是 11,那么我们可以观察到 1 月/3 = 0,2 月/3 = 0,3 月/3 = 0,0 可以代表 Q1。接下来,3/3 = 1,4/3 = 1,5/3 = 1,所以 1 可以代表 Q2。基于同样的逻辑,6/3 = 2,7/3 = 2,8/3 = 2,所以 2 可以代表 Q3。最后,9/3 = 3,10/3 = 3,11/3 = 3,所以 3 代表 Q4。
基于这个声明和Calendar API,我们可以获得以下代码:
public static String quarter(Date date) {
String[] quarters = {"Q1", "Q2", "Q3", "Q4"};
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
int quarter = calendar.get(Calendar.MONTH) / 3;
return quarters[quarter];
}
但从 JDK 8 开始,我们可以依赖于java.time.temporal.IsoFields。这个类包含基于 ISO-8601 标准的日历系统中的字段(和单位)。在这些元素中,我们有基于周的年份和我们所感兴趣的年份季度。这次,让我们将季度作为整数返回:
public static int quarter(Date date) {
LocalDate localDate = date.toInstant()
.atZone(ZoneId.systemDefault()).toLocalDate();
return localDate.get(IsoFields.QUARTER_OF_YEAR);
}
在捆绑的代码中,你可以看到更多示例,包括一个使用DateTimeFormatter.ofPattern("QQQ")的示例。
74. 获取一个季度的第一天和最后一天
让我们假设我们通过这个简单的类来表示一个季度的第一天和最后一天:
public final class Quarter {
private final Date firstDay;
private final Date lastDay;
...
}
接下来,我们有一个java.util.Date,我们想要获取包含这个日期的季度的第一天和最后一天。为此,我们可以使用 JDK 8 的IsoFields.DAY_OF_QUARTER(我们在前一个问题中介绍了IsoFields)。但在我们能够使用IsoFields之前,我们必须将给定的java.util.Date转换为LocalDate,如下所示:
LocalDate localDate = date.toInstant()
.atZone(ZoneId.systemDefault()).toLocalDate();
一旦我们有了给定的Date作为LocalDate,我们就可以通过IsoFields.DAY_OF_QUARTER轻松地提取季度的第一天。接下来,我们将 2 个月加到这一天,进入季度的最后一个月(一个季度有 3 个月,所以一年有 4 个季度),我们依赖于java.time.temporal.TemporalAdjusters,更确切地说,依赖于lastDayOfMonth()来获取季度的最后一天。最后,我们将两个获得的LocalDate实例转换为Date实例。以下是完整的代码:
public static Quarter quarterDays(Date date) {
LocalDate localDate = date.toInstant()
.atZone(ZoneId.systemDefault()).toLocalDate();
LocalDate firstDay
= localDate.with(IsoFields.DAY_OF_QUARTER, 1L);
LocalDate lastDay = firstDay.plusMonths(2)
.with(TemporalAdjusters.lastDayOfMonth());
return new Quarter(
Date.from(firstDay.atStartOfDay(
ZoneId.systemDefault()).toInstant()),
Date.from(lastDay.atStartOfDay(
ZoneId.systemDefault()).toInstant())
);
}
当然,如果你直接使用LocalDate,这些转换就不需要了。但这样,你就有机会学习更多。
在捆绑的代码中,你可以找到更多示例,包括一个完全依赖于Calendar API 的示例。
75. 从给定的季度中提取月份
如果我们熟悉 JDK 8 的java.time.Month,这个问题就变得相当容易解决。通过这个 API,我们可以找到包含给定LocalDate的季度的第一个月(1 月为 0,2 月为 1,……),即Month.from(LocalDate).firstMonthOfQuarter().getValue()。
一旦我们有了第一个月,很容易获得其他两个,如下所示:
public static List<String> quarterMonths(LocalDate ld) {
List<String> qmonths = new ArrayList<>();
int qmonth = Month.from(ld)
.firstMonthOfQuarter().getValue();
qmonths.add(Month.of(qmonth).name());
qmonths.add(Month.of(++qmonth).name());
qmonths.add(Month.of(++qmonth).name());
return qmonths;
}
关于将季度本身作为参数传递,这可以通过数字(1、2、3 或 4)或字符串(Q1、Q2、Q3 或 Q4)来实现。如果给定的quarter是数字,那么季度的第一个月可以通过quarter * 3 – 2来获得,其中quarter是 1、2、3 或 4。这次,让我们以函数式风格表达代码:
int qmonth = quarter * 3 - 2;
List<String> qmonths = IntStream.of(
qmonth, ++qmonth, ++qmonth)
.mapToObj(Month::of)
.map(Month::name)
.collect(Collectors.toList());
当然,如果你觉得更简洁,那么你可以使用IntStream.range(qmonth, qmonth+2)而不是IntStream.of()。在捆绑的代码中,你可以找到更多示例。
76. 计算预产期
让我们从这两个常数开始:
public static final int PREGNANCY_WEEKS = 40;
public static final int PREGNANCY_DAYS = PREGNANCY_WEEKS * 7;
让我们将第一天视为一个LocalDate,并编写一个计算器,打印预产期、剩余天数、已过天数和当前周数。
基本上,预产期是通过将PREGNANCY_DAYS加到给定第一天来获得的。进一步,剩余天数是今天和给定第一天之间的差值,而已过天数是PREGNANCY_DAYS减去剩余天数。最后,当前周数是通过将已过天数除以 7(因为一周有 7 天)来获得的。基于这些陈述,代码自解释:
public static void pregnancyCalculator(LocalDate firstDay) {
firstDay = firstDay.plusDays(PREGNANCY_DAYS);
System.out.println("Due date: " + firstDay);
LocalDate today = LocalDate.now();
long betweenDays =
Math.abs(ChronoUnit.DAYS.between(firstDay, today));
long diffDays = PREGNANCY_DAYS - betweenDays;
long weekNr = diffDays / 7;
long weekPart = diffDays % 7;
String week = weekNr + " | " + weekPart;
System.out.println("Days remaining: " + betweenDays);
System.out.println("Days in: " + diffDays);
System.out.println("Week: " + week);
}
看看你是否能想到一种方法来计算另一个重要的日期。
77. 实现一个计时器
一个经典的计时器实现依赖于System.nanoTime()、System.currentTimeMillis()或Instant.now()。在所有情况下,我们必须提供启动和停止计时器的支持,以及一些辅助函数来以不同的时间单位获取测量的时间。
虽然基于Instant.now()和currentTimeMillis()的解决方案在捆绑代码中可用,但这里我们将展示基于System.nanoTime()的解决方案:
public final class NanoStopwatch {
private long startTime;
private long stopTime;
private boolean running;
public void start() {
this.startTime = System.nanoTime();
this.running = true;
}
public void stop() {
this.stopTime = System.nanoTime();
this.running = false;
}
//elaspsed time in nanoseconds
public long getElapsedTime() {
if (running) {
return System.nanoTime() - startTime;
} else {
return stopTime - startTime;
}
}
}
如果你需要以毫秒或秒为单位返回测量的时间,那么只需添加以下两个辅助函数:
//elaspsed time in millisecods
public long elapsedTimeToMillis(long nanotime) {
return TimeUnit.MILLISECONDS.convert(
nanotime, TimeUnit.NANOSECONDS);
}
//elaspsed time in seconds
public long elapsedTimeToSeconds(long nanotime) {
return TimeUnit.SECONDS.convert(
nanotime, TimeUnit.NANOSECONDS);
}
这种方法基于System.nanoTime()来测量高精度的经过时间。这种方法返回以纳秒为单位的分辨率高的时间,不依赖于系统时钟或任何其他墙钟(如Instant.now()或System.currentTimeMillis()),因此它不受墙钟常见问题的影响,如闰秒、时间均匀性、同步性问题等。
无论何时你需要一个专业的测量经过时间的工具,请依赖 Micrometer (micrometer.io/)、JMH (openjdk.org/projects/code-tools/jmh/)、Gatling (gatling.io/open-source/)等等。
78. 提取自午夜以来的毫秒数
因此,我们有一个日期时间(让我们假设是一个 LocalDateTime 或 LocalTime),我们想知道从午夜到这个日期时间已经过去了多少毫秒。让我们考虑给定的日期时间是现在:
LocalDateTime now = LocalDateTime.now();
午夜相对于 now 是相对的,因此我们可以找到以下差异:
LocalDateTime midnight = LocalDateTime.of(now.getYear(),
now.getMonth(), now.getDayOfMonth(), 0, 0, 0);
最后,计算午夜和现在之间的差异(以毫秒为单位)。这可以通过多种方式完成,但可能最简洁的解决方案依赖于 java.time.temporal.ChronoUnit。此 API 提供了一组用于操作日期、时间或日期时间的单位,包括毫秒:
System.out.println("Millis: "
+ ChronoUnit.MILLIS.between(midnight, now));
在捆绑的代码中,你可以看到更多关于 ChronoUnit 的示例。
79. 将日期时间范围分割成等间隔
让我们考虑一个日期时间范围(由两个 LocalDateTime 实例表示的起始日期和结束日期界定)和一个整数 n。为了将给定的范围分割成 n 个等间隔,我们首先定义一个 java.time.Duration 如下:
Duration range = Duration.between(start, end);
有了这个日期时间范围,我们可以依赖 dividedBy() 来获取它的一个副本,该副本被指定为 n 分割:
Duration interval = range.dividedBy(n - 1);
最后,我们可以从起始日期(范围的左端头)开始,并反复用 interval 值增加它,直到我们达到结束日期(范围的右端头)。在每一步之后,我们将新的日期存储在一个列表中,该列表将在最后返回。以下是完整的代码:
public static List<LocalDateTime> splitInEqualIntervals(
LocalDateTime start, LocalDateTime end, int n) {
Duration range = Duration.between(start, end);
Duration interval = range.dividedBy(n - 1);
List<LocalDateTime> listOfDates = new ArrayList<>();
LocalDateTime timeline = start;
for (int i = 0; i < n - 1; i++) {
listOfDates.add(timeline);
timeline = timeline.plus(interval);
}
listOfDates.add(end);
return listOfDates;
}
结果的 listOfDates 将包含 n 个等间隔的日期。
80. 解释 Clock.systemUTC() 和 Clock.systemDefaultZone() 之间的差异
让我们从以下三行代码开始:
System.out.println(Clock.systemDefaultZone());
System.out.println(system(ZoneId.systemDefault()));
System.out.println(Clock.systemUTC());
输出显示前两行是相似的。它们都显示了默认时区(在我的情况下,是欧洲/布加勒斯特):
SystemClock[Europe/Bucharest]
SystemClock[Europe/Bucharest]
第三行是不同的。在这里,我们看到 Z 时区,它是特定于 UTC 时区的,表示存在时区偏移:
SystemClock[Z]
另一方面,创建一个 Instant 显示 Clock.systemUTC() 和 Clock.systemDefaultZone() 产生相同的结果:
System.out.println(Clock.systemDefaultZone().instant());
System.out.println(system(ZoneId.systemDefault()).instant());
System.out.println(Clock.systemUTC().instant());
在这三种情况下,瞬时时间都是相同的:
2023-02-07T05:26:17.374159500Z
2023-02-07T05:26:17.384811300Z
2023-02-07T05:26:17.384811300Z
但是,当我们尝试从这两个时钟创建日期、时间或日期时间时,差异就出现了。例如,让我们从 Clock.systemUTC() 创建一个 LocalDateTime:
// 2023-02-07T05:26:17.384811300
System.out.println(LocalDateTime.now(Clock.systemUTC()));
以及,从 Clock.systemDefaultZone() 创建一个 LocalDateTime:
// 2023-02-07T07:26:17.384811300
System.out.println(LocalDateTime.now(
Clock.systemDefaultZone()));
我的时区(默认时区,欧洲/布加勒斯特)是 07:26:17。但是,通过 Clock.systemUTC() 的时间是 05:26:17。这是因为欧洲/布加勒斯特位于 UTC-2 的偏移量,所以 systemUTC() 产生 UTC 时区的日期时间,而 systemDefaultZone() 产生当前默认时区的日期时间。然而,它们都产生了相同的 Instant。
81. 显示星期几的名称
Java 中的一颗隐藏的宝石是 java.text.DateFormatSymbols。这个类是日期时间格式化数据(如星期几的名称和月份的名称)的包装器。所有这些名称都是可本地化的。
通常,您会通过 DateFormat(如 SimpleDateFormat)使用 DateFormatSymbols,但为了解决这个问题,我们可以直接使用它,如下面的代码所示:
String[] weekdays = new DateFormatSymbols().getWeekdays();
IntStream.range(1, weekdays.length)
.mapToObj(t -> String.format("Day: %d -> %s",
t, weekdays[t]))
.forEach(System.out::println);
这段代码将按以下方式输出星期的名称:
Day: 1 -> Sunday
...
Day: 7 -> Saturday
挑战自己,想出另一种解决方案。
82. 获取年的第一天和最后一天
获取给定年份(作为数值)的第一天和最后一天可以通过 LocalDate 和方便的 TemporalAdjusters,firstDayOfYear() 和 lastDayOfYear() 实现。首先,我们从给定年份创建一个 LocalDate。接下来,我们使用这个 LocalDate 与 firstDayOfYear()/lastDayOfYear() 结合,如下面的代码所示:
public static String fetchFirstDayOfYear(int year, boolean name) {
LocalDate ld = LocalDate.ofYearDay(year, 1);
LocalDate firstDay = ld.with(firstDayOfYear());
if (!name) {
return firstDay.toString();
}
return DateTimeFormatter.ofPattern("EEEE").format(firstDay);
}
对于最后一天,代码几乎相同:
public static String fetchLastDayOfYear(int year, boolean name) {
LocalDate ld = LocalDate.ofYearDay(year, 31);
LocalDate lastDay = ld.with(lastDayOfYear());
if (!name) {
return lastDay.toString();
}
return DateTimeFormatter.ofPattern("EEEE").format(lastDay);
}
如果标志参数(name)为 false,则我们通过 LocalDate.toString() 返回第一/最后一天,因此我们将得到类似 2020-01-01(2020 年的第一天)和 2020-12-31(2020 年的最后一天)的结果。如果这个标志参数为 true,则我们依赖于 EEEE 模式来返回年份的第一/最后一天的名字,例如星期三(2020 年的第一天)和星期四(2020 年的最后一天)。
在捆绑的代码中,您还可以找到一个基于 Calendar API 的解决方案。
83. 获取周的第一天和最后一天
假设给定一个整数(nrOfWeeks)表示我们想要提取从现在开始每周的第一天和最后一天的周数。例如,对于给定的 nrOfWeeks = 3 和一个本地日期,例如 06/02/2023,我们想要这样:
[
Mon 06/02/2023,
Sun 12/02/2023,
Mon 13/02/2023,
Sun 19/02/2023,
Mon 20/02/2023,
Sun 26/02/2023
]
这比看起来要简单得多。我们只需要从 0 到 nrOfWeeks 的循环,以及两个 TemporalAdjusters 来适应每周的第一天/最后一天。更确切地说,我们需要 nextOrSame(DayOfWeek dayOfWeek) 和 previousOrSame(DayOfWeek dayOfWeek) 调整器。
nextOrSame() 调整器的角色是将当前日期调整为调整日期之后给定 星期几 的首次出现(这可以是 下个或相同)。另一方面,previousOrSame() 调整器的角色是将当前日期调整为调整日期之前给定 星期几 的首次出现(这可以是 之前或相同)。例如,如果今天是 [星期二 07/02/2023],那么 previousOrSame(DayOfWeek.MONDAY) 将返回 [星期一 06/02/2023],而 nextOrSame(DayOfWeek.SUNDAY) 将返回 [星期日 12/02/2023]。
基于这些陈述,我们可以通过以下代码解决问题:
public static List<String> weekBoundaries(int nrOfWeeks) {
List<String> boundaries = new ArrayList<>();
LocalDate timeline = LocalDate.now();
DateTimeFormatter dtf = DateTimeFormatter
.ofPattern("EEE dd/MM/yyyy");
for (int i = 0; i < nrOfWeeks; i++) {
boundaries.add(dtf.format(timeline.with(
previousOrSame(DayOfWeek.MONDAY))));
boundaries.add(dtf.format(timeline.with(
nextOrSame(DayOfWeek.SUNDAY))));
timeline = timeline.plusDays(7);
}
return boundaries;
}
在捆绑的代码中,您还可以看到一个基于 Calendar API 的解决方案。
84. 计算月份中旬
让我们想象我们有一个 LocalDate,我们想要从它计算出代表月份中旬的另一个 LocalDate。如果我们知道 LocalDate API 有一个名为 lengthOfMonth() 的方法,它返回一个表示月份天数的整数,那么这可以在几秒钟内完成。所以,我们只需要计算 lengthOfMonth()/2,如下面的代码所示:
public static LocalDate middleOfTheMonth(LocalDate date) {
return LocalDate.of(date.getYear(), date.getMonth(),
date.lengthOfMonth() / 2);
}
在捆绑的代码中,您可以看到一个基于 Calendar API 的解决方案。
85. 获取两个日期之间的季度数
这只是另一个需要我们深入掌握 Java 日期/时间 API 的问题。这次,我们要讨论的是 java.time.temporal.IsoFields,它在 问题 73 中被引入。ISO 字段之一是 QUARTER_YEARS,它是一个表示 季度 概念的时间单位。因此,拥有两个 LocalDate 实例,我们可以写出以下代码:
public static long nrOfQuarters(
LocalDate startDate, LocalDate endDate) {
return IsoFields.QUARTER_YEARS.between(startDate, endDate);
}
随意挑战自己,为 java.util.Date/Calendar 提供解决方案。
86. 将 Calendar 转换为 LocalDateTime
在 问题 68 中,你看到将 java.util.Date(日期)转换为 LocalTime 可以如下进行:
LocalTime lt = date.toInstant().atZone(zoneId).toLocalTime();
以同样的方式,我们可以将 java.util.Date 转换为 LocalDateTime(这里,zoneId 被替换为 ZoneId.systemDefault()):
LocalDateTime ldt = date.toInstant().atZone(
ZoneId.systemDefault()).toLocalDateTime();
我们还知道,我们可以通过 getTime() 方法从 Calendar 获取 java.util.Date。因此,通过拼凑拼图碎片,我们得到以下代码:
public static LocalDateTime
toLocalDateTime(Calendar calendar) {
Date date = calendar.getTime();
return date.toInstant().atZone(
ZoneId.systemDefault()).toLocalDateTime();
}
可以通过以下更简短的路径获得相同的结果:
return LocalDateTime.ofInstant(Instant.ofEpochMilli(
calendar.getTimeInMillis()), ZoneId.systemDefault());
或者,甚至更短,如下所示:
return LocalDateTime.ofInstant(
calendar.toInstant(), ZoneId.systemDefault());
但是,此代码假设给定 Calendar 的时间区域是默认时间区域。如果日历有不同的时区(例如,亚洲/加尔各答),那么我们可能会期望返回 ZonedDateTime 而不是 LocalDateTime。这意味着我们应该相应地调整之前的代码:
public static ZonedDateTime
toZonedDateTime(Calendar calendar) {
Date date = calendar.getTime();
return date.toInstant().atZone(
calendar.getTimeZone().toZoneId());
}
再次,有一些更简短的版本可用,但我们没有展示这些,因为它们表达性较差:
return ZonedDateTime.ofInstant(
Instant.ofEpochMilli(calendar.getTimeInMillis()),
calendar.getTimeZone().toZoneId());
return ZonedDateTime.ofInstant(calendar.toInstant(),
calendar.getTimeZone().toZoneId());
完成!
87. 获取两个日期之间的周数
如果给定的两个日期是 LocalDate(Time)的实例,那么我们可以依赖 java.time.temporal.ChronoUnit。此 API 提供了一组用于操作日期、时间或日期时间的单元,我们之前在 问题 78 中已经使用过。这次,让我们再次使用它来计算两个日期之间的周数:
public static long nrOfWeeks(
LocalDateTime startLdt, LocalDateTime endLdt) {
return Math.abs(ChronoUnit.WEEKS.between(
startLdt, endLdt));
}
另一方面,如果给定的日期是 java.util.Date,那么你可以选择将它们转换为 LocalDateTime 并使用之前的代码,或者依赖于 Calendar API。使用 Calendar API 是从开始日期到结束日期循环,每周递增日历日期:
public static long nrOfWeeks(Date startDate, Date endDate) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(startDate);
int weeks = 0;
while (calendar.getTime().before(endDate)) {
calendar.add(Calendar.WEEK_OF_YEAR, 1);
weeks++;
}
return weeks;
}
当日历日期超过结束日期时,我们就有周数了。
摘要
任务完成!我希望你喜欢这个充满技巧和窍门的简短章节,这些技巧和窍门关于在现实世界应用程序中操作日期和时间。我强烈建议你阅读来自 Java 编程问题,第一版 的同类型章节,其中包含另外 20 个涵盖其他日期/时间主题的问题。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第四章:记录和记录模式
本章包括 19 个问题,详细介绍了 JDK 16(JEP 395)中引入的 Java 记录,以及作为 JDK 19(JEP 405)的预览特性、作为 JDK 20(JEP 432)的第二预览特性、作为 JDK 21(JEP 440)的最终特性引入的记录模式。
我们首先定义一个简单的 Java 记录。然后,我们分析记录的内部结构,它可以包含和不能包含的内容,如何在流中使用记录,它们如何改进序列化等。我们还对如何在 Spring Boot 应用程序中使用记录感兴趣,包括 JPA 和 jOOQ 技术。
接下来,我们将关注instanceof和switch的记录模式。我们将讨论嵌套记录模式、受保护记录模式、在记录模式中处理null值等。
在本章结束时,你将掌握 Java 记录。这很好,因为对于任何想要采用最酷 Java 特性的 Java 开发者来说,记录是必不可少的。
问题
使用以下问题来测试你在 Java 记录上的编程能力。我强烈建议你在查看解决方案并下载示例程序之前,尝试解决每个问题:
-
声明 Java 记录:编写一个示例应用程序,展示 Java 记录的创建。此外,提供编译器在幕后为记录生成的工件简短描述。
-
介绍记录的规范和紧凑构造函数:解释内置记录的规范和紧凑构造函数的作用。提供一些示例,说明何时提供这样的显式构造函数是有意义的。
-
在记录中添加更多工件:提供一个有意义的示例列表,说明如何在 Java 记录中添加显式的工件(例如,添加实例方法、静态工件等)。
-
在记录中迭代我们无法拥有的内容:举例说明我们无法在记录中拥有的内容(例如,我们无法有显式的
private字段),并解释原因。 -
在记录中定义多个构造函数:举例说明在记录中声明多个构造函数的几种方法。
-
在记录中实现接口:编写一个程序,展示如何在记录中实现接口。
-
理解记录序列化:详细解释并举例说明记录序列化在幕后是如何工作的。
-
通过反射调用规范构造函数:编写一个程序,展示如何通过反射调用记录的规范构造函数。
-
在流中使用记录:编写几个示例,突出记录在简化依赖于 Stream API 的功能表达式方面的用法。
-
为 instanceof 引入记录模式:编写一些示例,介绍
instanceof的记录模式,包括嵌套记录模式。 -
为 switch 引入记录模式:编写一些示例,介绍
switch的记录模式。 -
处理受保护的记录模式:编写几个代码片段来举例说明受保护的记录模式(基于绑定变量的受保护条件)。
-
在记录模式中使用泛型记录:编写一个应用程序来突出泛型记录的声明和使用。
-
处理嵌套记录模式中的 null 值:解释并举例说明如何在记录模式中处理
null值(解释嵌套记录模式中null值的边缘情况)。 -
通过记录模式简化表达式:想象一下,你有一个表达式(算术、基于字符串的、抽象语法树(AST)等)。编写一个使用记录模式简化评估/转换此表达式代码的程序。
-
钩子未命名的模式和变量:解释并举例说明 JDK 21 预览功能,该功能涵盖未命名的模式和变量。
-
处理 Spring Boot 中的记录:编写几个应用程序来举例说明 Spring Boot 中记录的不同用例(例如,在模板中使用记录,使用记录进行配置等)。
-
处理 JPA 中的记录:编写几个应用程序来举例说明 JPA 中记录的不同用例(例如,使用记录和构造表达式,使用记录和结果转换器等)。
-
在 jOOQ 中处理记录:编写几个应用程序来举例说明 jOOQ 中记录的不同用例(例如,使用记录和
MULTISET操作符)。
以下部分描述了前面问题的解决方案。请记住,通常没有一种正确的方式来解决特定的问题。此外,请记住,这里所示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节并实验程序,请访问github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter04。
88. 声明 Java 记录
在深入探讨 Java 记录之前,让我们稍微思考一下在 Java 应用程序中我们通常如何持有数据。你说得对……我们定义包含所需实例字段的简单类,并通过这些类的构造函数用我们的数据填充它们。我们还公开了一些特定的获取器,以及流行的 equals()、hashCode() 和 toString() 方法。此外,我们创建这些类的实例来封装我们宝贵的数据,并在整个应用程序中传递它们以解决我们的任务。例如,以下类携带有关西瓜的数据,如西瓜类型和它们的重量:
public class Melon {
private final String type;
private final float weight;
public Melon(String type, float weight) {
this.type = type;
this.weight = weight;
}
public String getType() {
return type;
}
public float getWeight() {
return weight;
}
// hashCode(), equals(), and to String()
}
你应该非常熟悉这种传统的 Java 类和这种繁琐的仪式,因此没有必要详细查看此代码。现在,让我们看看我们如何使用 Java 记录的语法糖来完成完全相同的事情,但大大减少了之前的仪式:
public record MelonRecord(String type, float weight) {}
Java 记录作为功能预览从 JDK 14 开始提供,并在 JDK 16 中作为 JEP 395 发布和关闭。这一行代码为我们提供了与之前相同的行为,即 Melon 类。在幕后,编译器提供了所有工件,包括两个 private final 字段(type 和 weight)、一个构造函数、两个与字段同名的方法(type() 和 weight()),以及包含 hashCode()、equals() 和 toString() 的三部曲。我们可以通过在 MelonRecord 类上调用 javap 工具来轻松地看到编译器生成的代码:

图 4.1:Java 记录的代码
注意,这些访问器的名称不遵循 Java Bean 规范,因此没有 getType() 或 getWeight()。有 type() 和 weight()。然而,你可以明确地编写这些访问器或明确添加 getType()/getWeight() 获取器 - 例如,为了公开字段的防御性副本。
所有这些都是在声明记录时给出的参数(type 和 weight)的基础上构建的。这些参数也被称为记录的组成部分,我们说记录是基于给定的组成部分构建的。
编译器通过 record 关键字识别 Java 记录。这是一种特殊的类类型(就像 enum 是特殊的 Java 类类型一样),声明为 final 并自动扩展 java.lang.Record。
实例化 MelonRecord 与实例化 Melon 类相同。以下代码创建了一个 Melon 实例和一个 MelonRecord 实例:
Melon melon = new Melon("Cantaloupe", 2600);
MelonRecord melonr = new MelonRecord("Cantaloupe", 2600);
Java 记录不是可变 Java Bean 类的替代品。此外,你可能认为 Java 记录只是携带不可变数据或不可变状态的简单透明方法(我们说“透明”,因为它完全暴露了其状态;我们说“不可变”,因为该类是 final 的,它只有 private final 字段,没有设置器)。在这种情况下,我们可能会认为 Java 记录并不十分有用,因为它们只是重叠了我们通过 Lombok 或 Kotlin 可以获得的功能。但是,正如你将在本章中看到的那样,Java 记录不仅仅是这样,它还提供了 Lombok 或 Kotlin 中不可用的几个功能。此外,如果你进行基准测试,你将注意到使用记录在性能方面具有显著优势。
89. 介绍记录的规范和紧凑构造函数
在上一个问题中,我们创建了 MelonRecord Java 记录,并通过以下代码实例化了它:
MelonRecord melonr = new MelonRecord("Cantaloupe", 2600);
这怎么可能(因为我们没有在 MelonRecord 中编写任何参数化构造函数)?编译器只是遵循其内部协议为 Java 记录创建了一个默认构造函数,基于我们在记录声明中提供的组件(在这种情况下,有两个组件,type 和 weight)。
这个构造函数被称为 规范构造函数,它始终与给定的组件保持一致。每个记录都有一个规范构造函数,它代表了创建该记录实例的唯一方式。
但是,我们可以重新定义规范构造函数。以下是一个类似于默认的显式规范构造函数——如您所见,规范构造函数只是简单地接受所有给定的组件,并将相应的实例字段(也由编译器生成作为 private final 字段)设置:
public MelonRecord(String type, float weight) {
this.type = type;
this.weight = weight;
}
一旦实例被创建,它就不能被更改(它是不可变的)。它将只用于在程序中携带这些数据。这个显式规范构造函数有一个称为 紧凑构造函数 的快捷方式——这是 Java 记录特有的。由于编译器知道给定的组件列表,它可以从这个紧凑构造函数中完成其工作,这与前面的一个等价:
public MelonRecord {}
注意不要混淆这个紧凑构造函数与无参数的那个。以下片段并不等价:
public MelonRecord {} // compact constructor
public MelonRecord() {} // constructor with no arguments
当然,仅仅为了模仿默认构造函数的功能而编写显式规范构造函数是没有意义的。因此,让我们检查在重新定义规范构造函数时具有意义的几个场景。
处理验证
在此刻,当我们创建一个 MelonRecord 时,我们可以将类型传递为 null,或者将西瓜的重量作为一个负数。这会导致包含非有效数据的损坏记录。可以通过以下显式规范构造函数来处理记录组件的验证:
public record MelonRecord(String type, float weight) {
// explicit canonical constructor for validations
public MelonRecord(String type, int weight) {
if (type == null) {
throw new IllegalArgumentException(
"The melon's type cannot be null");
}
if (weight < 1000 || weight > 10000) {
throw new IllegalArgumentException("The melon's weight
must be between 1000 and 10000 grams");
}
this.type = type;
this.weight = weight;
}
}
或者,通过以下紧凑构造函数:
public record MelonRecord(String type, float weight) {
// explicit compact constructor for validations
public MelonRecord {
if (type == null) {
throw new IllegalArgumentException(
"The melon's type cannot be null");
}
if (weight < 1000 || weight > 10000) {
throw new IllegalArgumentException("The melon's weight
must be between 1000 and 10000 grams");
}
}
}
验证处理是显式规范/紧凑构造函数最常见的使用场景。接下来,让我们看看两个更少为人知的用例。
重新分配组件
通过显式规范/紧凑构造函数,我们可以重新分配组件。例如,当我们创建一个 MelonRecord 时,我们提供其类型(例如,哈密瓜)和其重量(以克为单位,例如,2600 克)。但是,如果我们想使用千克(2600 g = 2.6 kg)作为重量,那么我们可以在显式规范构造函数中提供这种转换,如下所示:
// explicit canonical constructor for reassigning components
public MelonRecord(String type, float weight) {
weight = weight/1_000; // overwriting the component 'weight'
this.type = type;
this.weight = weight;
}
如您所见,weight 组件在 weight 字段使用新的重新分配值初始化之前是可用的,并被重新分配。最终,weight 组件和 weight 字段具有相同的值(2.6 kg)。那么这段代码片段呢?
public MelonRecord(String type, float weight) {
this.type = type;
this.weight = weight/1_000;
}
嗯,在这种情况下,最终,weight 字段和 weight 组件将具有不同的值。weight 字段是 2.6 kg,而 weight 组件是 2600 g。请注意,这很可能不是你想要的。让我们检查另一个片段:
public MelonRecord(String type, float weight) {
this.type = type;
this.weight = weight;
weight = weight/1_000;
}
再次,最终,weight 字段和 weight 组件将具有不同的值。weight 字段是 2600 g,而 weight 组件是 2.6 kg。再次注意——这很可能不是你想要的。
当然,最干净、最简单的方法依赖于紧凑构造函数。这次,我们无法偷偷进行任何意外的重新分配:
public record MelonRecord(String type, float weight) {
// explicit compact constructor for reassigning components
public MelonRecord {
weight = weight/1_000; // overwriting the component 'weight'
}
}
最后,让我们解决第三个场景。
给定组件的防御性副本
我们知道 Java 记录是不可变的。但这并不意味着其组件也是不可变的。想想数组、列表、映射、日期等组件。所有这些组件都是可变的。为了恢复完全不可变性,你将更愿意在这些组件的副本上工作而不是修改给定的组件。而且,正如你可能已经直觉到的,这可以通过显式的规范构造函数来完成。
例如,让我们考虑以下记录,它获取一个表示一组项目零售价格的单一组件作为Map:
public record MarketRecord(Map<String, Integer> retails) {}
这个记录不应该修改这个Map,因此它依赖于一个显式的规范构造函数来创建一个用于后续任务而没有任何修改风险的防御性副本(Map.copyOf()返回给定Map的不可修改副本):
public record MarketRecord(Map<String, Integer> retails) {
public MarketRecord {
retails = Map.copyOf(retails);
}
}
基本上,这仅仅是一种组件重新分配的变体。
此外,我们还可以通过访问器方法返回防御性副本:
public Map<String, Integer> retails() {
return Map.copyOf(retails);
}
// or, getter in Java Bean style
public Map<String, Integer> getRetails() {
return Map.copyOf(retails);
}
你可以在捆绑的代码中练习所有这些示例。
90. 在记录中添加更多工件
到目前为止,我们知道如何将显式的规范/紧凑构造函数添加到 Java 记录中。我们还能添加什么?例如,我们可以添加实例方法,就像在典型类中一样。在以下代码中,我们添加了一个返回从克转换为千克的weight的实例方法:
public record MelonRecord(String type, float weight) {
public float weightToKg() {
return weight / 1_000;
}
}
你可以像调用你类中的任何其他实例方法一样调用weightToKg():
MelonRecord melon = new MelonRecord("Cantaloupe", 2600);
// 2600.0 g = 2.6 Kg
System.out.println(melon.weight() + " g = "
+ melon.weightToKg() + " Kg");
除了实例方法之外,我们还可以添加static字段和方法。查看以下代码:
public record MelonRecord(String type, float weight) {
private static final String DEFAULT_MELON_TYPE = "Crenshaw";
private static final float DEFAULT_MELON_WEIGHT = 1000;
public static MelonRecord getDefaultMelon() {
return new MelonRecord(
DEFAULT_MELON_TYPE, DEFAULT_MELON_WEIGHT);
}
}
通过类名调用getDefaultMelon()就像往常一样:
MelonRecord defaultMelon = MelonRecord.getDefaultMelon();
添加嵌套类也是可能的。例如,这里我们添加一个static嵌套类:
public record MelonRecord(String type, float weight) {
public static class Slicer {
public void slice(MelonRecord mr, int n) {
start();
System.out.println("Slicing a " + mr.type() + " of "
+ mr.weightToKg() + " kg in " + n + " slices ...");
stop();
}
private static void start() {
System.out.println("Start slicer ...");
}
private static void stop() {
System.out.println("Stop slicer ...");
}
}
}
而且,调用Slicer可以像往常一样进行:
MelonRecord.Slicer slicer = new MelonRecord.Slicer();
slicer.slice(melon, 10);
slicer.slice(defaultMelon, 14);
但是,即使允许在 Java 记录中添加所有这些工件,我强烈建议你在这样做之前三思。主要原因在于 Java 记录应该是关于数据,而且仅仅是数据,因此用涉及额外行为的工件污染记录有点奇怪。如果你遇到这样的场景,那么你可能需要一个 Java 类,而不是 Java 记录。
在下一个问题中,我们将看到我们无法添加到 Java 记录中的内容。
91. 在记录中迭代我们无法拥有的内容
在 Java 记录中,有一些我们不能拥有的工件。让我们逐一解决前 5 个。
记录不能扩展另一个类
由于记录已经扩展了java.lang.Record,而 Java 不支持多重继承,因此我们不能编写扩展另一个类的记录:
public record MelonRecord(String type, float weight)
extends Cucurbitaceae {…}
这个片段无法编译。
记录不能被扩展
Java 记录是final类,因此不能被扩展:
public class PumpkinClass extends MelonRecord {…}
这个片段无法编译。
记录不能通过实例字段进行扩展
当我们声明一个记录时,我们也提供了将成为记录实例字段的组件。之后,我们不能再像典型类那样添加更多实例字段:
public record MelonRecord(String type, float weight) {
private String color;
private final String color;
}
将color作为final或非final的独立字段添加是不编译的。
记录不能有私有规范构造函数
有时我们创建具有private构造函数的类,该构造函数公开static工厂以创建实例。基本上,我们通过static工厂方法间接调用构造函数。这种做法在 Java 记录中不可用,因为不允许private规范/紧凑构造函数:
public record MelonRecord(String type, float weight) {
private MelonRecord(String type, float weight) {
this.type = type;
this.weight = weight;
}
public static MelonRecord newInstance(
String type, float weight) {
return new MelonRecord(type, weight);
}
}
public canonical constructors and private non-canonical constructors that first invoke one of the public canonical constructors.
记录不能有 setter
正如你所见,Java 记录为每个组件提供了一个 getter(访问器方法)。这些 getter 的名称与组件相同(对于type我们有type(),而不是getType())。另一方面,我们不能有 setter,因为对应给定组件的字段是final:
public record MelonRecord(String type, float weight) {
public void setType(String type) {
this.type = type;
}
public void setWeight(float weight) {
this.weight = weight;
}
}
these are the most common.
92. 在记录中定义多个构造函数
如你所知,当我们声明 Java 记录时,编译器使用给定的组件创建一个默认构造函数,称为规范构造函数。我们还可以提供显式的规范/紧凑构造函数,如你在问题 89中看到的。
但是,我们可以更进一步,并声明具有不同参数列表的更多构造函数。例如,我们可以有一个不带参数的构造函数来返回默认实例:
public record MelonRecord(String type, float weight) {
private static final String DEFAULT_MELON_TYPE = "Crenshaw";
private static final float DEFAULT_MELON_WEIGHT = 1000;
MelonRecord() {
this(DEFAULT_MELON_TYPE, DEFAULT_MELON_WEIGHT);
}
}
或者,我们可以编写一个只接受瓜的类型或重量作为参数的构造函数:
public record MelonRecord(String type, float weight) {
private static final String DEFAULT_MELON_TYPE = "Crenshaw";
private static final float DEFAULT_MELON_WEIGHT = 1000;
MelonRecord(String type) {
this(type, DEFAULT_MELON_WEIGHT);
}
MelonRecord(float weight) {
this(DEFAULT_MELON_TYPE, weight);
}
}
此外,我们还可以添加不适合任何组件的参数(这里,country):
public record MelonRecord(String type, float weight) {
private static Set<String> countries = new HashSet<>();
MelonRecord(String type, int weight, String country) {
this(type, weight);
MelonRecord.countries.add(country);
}
}
所有这些构造函数有什么共同点?它们都通过this关键字调用规范构造函数。记住,实例化 Java 记录的唯一方法是通过其规范构造函数,可以直接调用,或者,如你之前所见的,间接调用。所以,请记住,你添加到 Java 记录的所有显式构造函数都必须首先调用规范构造函数。
93. 在记录中实现接口
Java 记录不能扩展另一个类,但它们可以像典型类一样实现任何接口。让我们考虑以下接口:
public interface PestInspector {
public default boolean detectPest() {
return Math.random() > 0.5d;
}
public void exterminatePest();
}
以下代码片段是此接口的直接使用:
public record MelonRecord(String type, float weight)
implements PestInspector {
@Override
public void exterminatePest() {
if (detectPest()) {
System.out.println("All pests have been exterminated");
} else {
System.out.println(
"This melon is clean, no pests have been found");
}
}
}
注意代码覆盖了abstract方法exterminatePest()并调用了default方法detectPest()。
94. 理解记录序列化
为了理解 Java 记录的序列化/反序列化,让我们将基于普通 Java 类的经典代码与通过 Java 记录的语法糖表达的相同代码进行比较。
因此,让我们考虑以下两个普通的 Java 类(我们必须显式实现Serializable接口,因为在问题的第二部分,我们想要序列化/反序列化这些类):
public class Melon implements Serializable {
private final String type;
private final float weight;
public Melon(String type, float weight) {
this.type = type;
this.weight = weight;
}
// getters, hashCode(), equals(), and toString()
}
以及使用之前Melon类的MelonContainer类:
public class MelonContainer implements Serializable {
private final LocalDate expiration;
private final String batch;
private final Melon melon;
public MelonContainer(LocalDate expiration,
String batch, Melon melon) {
...
if (!batch.startsWith("ML")) {
throw new IllegalArgumentException(
"The batch format should be: MLxxxxxxxx");
}
...
this.expiration = expiration;
this.batch = batch;
this.melon = melon;
}
// getters, hashCode(), equals(), and toString()
}
如果我们通过 Java 记录表达此代码,那么我们就有以下代码:
public record MelonRecord(String type, float weight)
implements Serializable {}
public record MelonContainerRecord(
LocalDate expiration, String batch, Melon melon)
implements Serializable {
public MelonContainerRecord {
...
if (!batch.startsWith("ML")) {
throw new IllegalArgumentException(
"The batch format should be: MLxxxxxxxx");
}
...
}
}
注意,我们明确实现了 Serializable 接口,因为默认情况下,Java 记录不可序列化。
接下来,让我们创建一个 MelonContainer 实例:
MelonContainer gacContainer = new MelonContainer(
LocalDate.now().plusDays(15), "ML9000SQA0",
new Melon("Gac", 5000));
此外,一个 MelonContainerRecord 实例:
MelonContainerRecord gacContainerR = new MelonContainerRecord(
LocalDate.now().plusDays(15), "ML9000SQA0",
new Melon("Gac", 5000));
要序列化这些对象(gacContainer 和 gacContainerR),我们可以使用以下代码:
try ( ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("object.data"))) {
oos.writeObject(gacContainer);
}
try ( ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("object_record.data"))) {
oos.writeObject(gacContainerR);
}
此外,反序列化可以通过以下代码完成:
MelonContainer desGacContainer;
try ( ObjectInputStream ios = new ObjectInputStream(
new FileInputStream("object.data"))) {
desGacContainer = (MelonContainer) ios.readObject();
}
MelonContainerRecord desGacContainerR;
try ( ObjectInputStream ios = new ObjectInputStream(
new FileInputStream("object_record.data"))) {
desGacContainerR = (MelonContainerRecord) ios.readObject();
}
在实际检查序列化/反序列化之前,让我们尝试一种理论方法,旨在为这些操作提供一些提示。
序列化/反序列化是如何工作的
序列化/反序列化操作在以下图中表示:

图 4.2:Java 序列化/反序列化操作
简而言之,序列化(或序列化一个对象)是将对象的状态提取为字节流,并以持久格式(文件、数据库、内存、网络等)表示的操作。相反的操作称为 反序列化(或反序列化一个对象),它表示从持久格式中重建对象状态的过程。
在 Java 中,如果一个对象实现了 Serializable 接口,则该对象是可序列化的。这是一个没有状态或行为的空接口,它作为编译器的标记。如果没有这个接口,编译器会假设该对象不可序列化。
编译器使用其内部算法来序列化对象。这个算法依赖于书中所有的技巧,比如特殊的权限(忽略可访问性规则)来访问对象,恶意反射,构造函数绕过等等。揭示这种黑暗魔法的细节超出了我们的目的,因此作为一个开发者,知道以下内容就足够了:
-
如果对象的一部分不可序列化,那么你将得到一个运行时错误
-
你可以通过
writeObject()/readObject()API 来修改序列化/反序列化操作
好的,现在让我们看看当一个对象被序列化时发生了什么。
序列化/反序列化 gacContainer(一个典型的 Java 类)
gacContainer 对象是 MelonContainer 的一个实例,它是一个普通的 Java 类:
MelonContainer gacContainer = new MelonContainer(
LocalDate.now().plusDays(15), "ML9000SQA0",
new Melon("Gac", 5000));
在名为 object.data 的文件中序列化后,我们得到了表示 gacContainer 状态的字节流。虽然你可以在捆绑的代码中检查此文件(使用十六进制编辑器,如 hexed.it/),但以下是其内容的可读性解释:

图 4.3:gacContainer 序列化的可读性解释
反序列化操作是通过从上到下构建对象图来进行的。当类名已知时,编译器通过调用 MelonContainer 的第一个非序列化超类的无参构造函数来创建一个对象。在这种情况下,这是 java.lang.Object 的无参构造函数。因此,编译器并没有调用 MelonContainer 的构造函数。
接下来,字段被创建并设置为默认值,因此创建的对象的 expiration、batch 和 melon 都是 null。当然,这不是我们对象的正确状态,所以我们继续处理序列化流以提取和填充字段为正确的值。这可以在以下图中看到(左侧,创建的对象具有默认值;右侧,字段已填充为正确的状态):

图 4.4:用正确状态填充创建的对象
当编译器遇到 melon 字段时,它必须执行相同的步骤以获取 Melon 实例。它设置字段(type 和 weight 分别设置为 null,0.0f)。进一步地,它从流中读取真实值并设置 melon 对象的正确状态。
最后,在读取整个流之后,编译器将相应地链接对象。这如图所示(1、2 和 3 代表反序列化操作的步骤):

图 4.5:将对象链接以获得最终状态
到这一点,反序列化操作已经完成,我们可以使用生成的对象。
反序列化恶意流
提供一个恶意流意味着在反序列化之前改变对象状态。这可以通过许多方式完成。例如,我们可以在编辑器中手动修改 object.data 实例(这就像一个不受信任的来源),如下图中我们将有效的批次 ML9000SQA0 替换为无效的批次 0000000000:

图 4.6:修改原始流以获得恶意流
如果我们反序列化恶意流(在捆绑代码中,你可以找到它作为 object_malicious.data 文件),那么你可以看到损坏的数据“成功”地进入了我们的对象(简单的 toString() 调用揭示了批次是 0000000000):
MelonContainer{expiration=2023-02-26,
**batch=****0000000000**, melon=Melon{type=Gac, weight=5000.0}}
来自 Melon/MelonContainer 构造函数的防护条件是无用的,因为反序列化没有调用这些构造函数。
因此,如果我们总结序列化/反序列化 Java 类的缺点,我们必须强调在对象处于不正确状态(等待编译器用正确数据填充字段并将它们链接到最终图中)时出现的时间窗口,以及处理恶意状态的风险。现在,让我们将一个 Java 记录通过这个过程。
序列化/反序列化 gacContainerR(一个 Java 记录)
简而言之,声明 Java 记录及其语义约束的最简设计使得序列化/反序列化操作与典型的 Java 类有所不同。当我说是“不同”的时候,我实际上应该说它更好、更健壮。为什么会这样呢?好吧,Java 记录的序列化仅基于其组件的状态,而反序列化则依赖于 Java 记录的单一点——它的规范构造函数。记住,创建 Java 记录的唯一方法就是直接/间接调用其规范构造函数?这同样适用于反序列化,因此这个操作不能再绕过规范构造函数。
话虽如此,gacContainerR对象是一个MelonContainerRecord实例:
MelonContainerRecord gacContainerR = new MelonContainerRecord(
LocalDate.now().plusDays(15), "ML9000SQA0",
new Melon("Gac", 5000));
在名为object_record.data的文件中序列化后,我们获得了表示gacContainerR状态的字节流。虽然您可以在捆绑的代码中检查此文件(使用十六进制编辑器,如hexed.it/),但以下是其内容的可读解释:

图 4.7:MelonContainerRecord 序列化的人读解释
是的,您说得对——除了类名(MelonContainerRecord)之外,其余的与图 4.3中的相同。这保持了从普通/常规 Java 类到 Java 记录的迁移。这次,编译器可以使用记录公开的访问器,因此不需要使用任何暗黑魔法。
好的,这里没有什么引起我们的注意,那么让我们来检查反序列化操作。
记住,对于常规 Java 类,反序列化是从上到下构建对象图。在 Java 记录的情况下,这个操作是从下到上进行的,所以是反向的。换句话说,这次,编译器首先从流中读取字段(原始类型和重建的对象),并将它们存储在内存中。接下来,拥有所有字段后,编译器尝试将这些字段(它们的名称和值)与记录的组件进行匹配。任何与组件(名称和值)不匹配的字段都会从反序列化操作中丢弃。最后,匹配成功后,编译器调用规范构造函数来重建记录对象的状态。
反序列化恶意流
在捆绑的代码中,您可以找到一个名为object_record_malicious.data的文件,我们在其中将有效的批次ML9000SQA0替换为无效的批次0000000000。这次,反序列化这个恶意流将导致以下图中的异常:

图 4.8:反序列化恶意流导致异常
正如您已经知道的,这个异常起源于我们添加到 Java 记录显式规范构造函数中的保护条件。
很明显,Java 记录显著提高了序列化和反序列化操作。这次,重建的对象不再处于损坏状态,恶意流可以被放置在规范/紧凑构造函数中的保护条件拦截。
换句话说,记录的语义约束、它们的简约设计、只能通过访问器方法访问的状态,以及只能通过规范构造函数创建的对象,都使得序列化和反序列化成为一个可信的过程。
重构遗留序列化
通过 Java 记录进行序列化和反序列化非常棒,但在遗留代码的情况下,例如 MelonContainer,我们能做什么呢?我们不能将所有作为数据载体的遗留类重写为 Java 记录,这将消耗大量的工作和时间。
实际上,有一个基于序列化机制的解决方案,它要求我们添加两个名为 writeReplace() 和 readResolve() 的方法。通过遵循这个合理的重构步骤,我们可以将遗留代码序列化为记录,并将其反序列化回遗留代码。
如果我们将这个重构步骤应用到 MelonContainer 上,那么我们首先在这个类中添加 writeReplace() 方法,如下所示:
@Serial
private Object writeReplace() throws ObjectStreamException {
return new MelonContainerRecord(expiration, batch, melon);
}
writeReplace() 方法必须抛出 ObjectStreamException 并返回一个 MelonContainerRecord 实例。只要我们用 @Serial 注解标记它,编译器就会使用这个方法来序列化 MelonContainer 实例。现在,MelonContainer 实例的序列化将生成包含对应于 MelonContainerRecord 实例的字节流的 object.data 文件。
接下来,必须将 readResolve() 方法添加到 MelonContainerRecord 中,如下所示:
@Serial
private Object readResolve() throws ObjectStreamException {
return new MelonContainer(expiration, batch, melon);
}
readResolve() 方法必须抛出 ObjectStreamException 并返回一个 MelonContainer 实例。同样,只要我们用 @Serial 注解标记它,编译器就会使用这个方法来反序列化 MelonContainerRecord 实例。
当编译器反序列化 MelonContainerRecord 的一个实例时,它将调用这个记录的规范构造函数,因此它将通过我们的保护条件。这意味着恶意流将不会通过保护条件,因此我们避免了创建损坏的对象。如果流包含有效值,那么 readResolve() 方法将使用它们来重建遗留的 MelonContainer。
嘿,Kotlin/Lombok,你能做到这一点吗?不,你不能!
在捆绑的代码中,你可以找到一个名为 object_malicious.data 的文件,你可以用它来练习前面的说法。
95. 通过反射调用规范构造函数
通过反射调用 Java 记录的规范构造器并不是一项日常任务。然而,从 JDK 16 开始,这可以相当容易地完成,因为 java.lang.Class 提供了 RecordComponent[] getRecordComponents() 方法。正如其名称和签名所暗示的,此方法返回一个 java.lang.reflect.RecordComponent 数组,代表当前 Java 记录的组件。
有这个组件数组后,我们可以调用众所周知的 getDeclaredConstructor() 方法来识别接受这个组件数组作为参数的构造器。这就是规范构造器。
将这些语句付诸实践的代码由 Java 文档本身提供,因此没有必要重新发明它。下面是它:
// this method is from the official documentation of JDK
// https://docs.oracle.com/en/java/javase/19/docs/api/
// java.base/java/lang/Class.html#getRecordComponents()
public static <T extends Record> Constructor<T>
getCanonicalConstructor(Class<T> cls)
throws NoSuchMethodException {
Class<?>[] paramTypes
= Arrays.stream(cls.getRecordComponents())
.map(RecordComponent::getType)
.toArray(Class<?>[]::new);
return cls.getDeclaredConstructor(paramTypes);
}
考虑以下记录:
public record MelonRecord(String type, float weight) {}
public record MelonMarketRecord(
List<MelonRecord> melons, String country) {}
通过前一种解决方案找到并调用这些记录的规范构造器可以这样做:
Constructor<MelonRecord> cmr =
Records.getCanonicalConstructor(MelonRecord.class);
MelonRecord m1 = cmr.newInstance("Gac", 5000f);
MelonRecord m2 = cmr.newInstance("Hemi", 1400f);
Constructor<MelonMarketRecord> cmmr =
Records.getCanonicalConstructor(MelonMarketRecord.class);
MelonMarketRecord mm = cmmr.newInstance(
List.of(m1, m2), "China");
如果您需要深入了解 Java 反射原理,那么请考虑 Java 编程问题,第一版,第七章。
96. 在流中使用记录
考虑我们之前使用过的 MelonRecord:
public record MelonRecord(String type, float weight) {}
以下是一个西瓜列表:
List<MelonRecord> melons = Arrays.asList(
new MelonRecord("Crenshaw", 1200),
new MelonRecord("Gac", 3000),
new MelonRecord("Hemi", 2600),
...
);
我们的目标是迭代这个西瓜列表,并提取总重量和重量列表。这些数据可以由一个常规的 Java 类或另一个记录携带,如下所示:
public record WeightsAndTotalRecord(
double totalWeight, List<Float> weights) {}
使用以下几种方式之一填充此记录的数据,但如果我们更喜欢 Stream API,那么我们很可能会选择 Collectors.teeing() 收集器。这里我们不会过多地深入细节,但我们会快速展示它对于合并两个下游收集器的结果是有用的。(如果您感兴趣,可以在 Java 编程问题,第一版,第九章,问题 192 中找到更多关于这个特定收集器的详细信息。)
让我们看看代码:
WeightsAndTotalRecord weightsAndTotal = melons.stream()
.collect(Collectors.teeing(
summingDouble(MelonRecord::weight),
mapping(MelonRecord::weight, toList()),
WeightsAndTotalRecord::new
));
这里,我们有 summingDouble() 收集器,它计算总重量,以及 mapping() 收集器,它将列表中的重量进行映射。这两个下游收集器的结果合并到 WeightsAndTotalRecord 中。
如您所见,Stream API 和记录代表了一个非常好的组合。让我们从以下功能代码开始另一个例子:
Map<Double, Long> elevations = DoubleStream.of(
22, -10, 100, -5, 100, 123, 22, 230, -1, 250, 22)
.filter(e -> e > 0)
.map(e -> e * 0.393701)
.mapToObj(e -> (double) e)
.collect(Collectors.groupingBy(
Function.identity(), counting()));
这段代码从以厘米(以海平面为 0)给出的海拔列表开始。首先,我们只想保留正海拔(因此,我们应用 filter())。接下来,这些将转换为英寸(通过 map()),并通过 groupingBy() 和 counting() 收集器进行计数。
结果数据由 Map<Double, Long> 携带,这并不是非常具有表达性。如果我们把这个映射从上下文中提取出来(例如,将其作为参数传递给一个方法),就很难说 Double 和 Long 代表什么。有一个像 Map<Elevation, ElevationCount> 这样的东西会更有表达性,它清楚地描述了其内容。
因此,Elevation 和 ElevationCount 可以是以下两个记录:
record Elevation(double value) {
Elevation(double value) {
this.value = value * 0.393701;
}
}
record ElevationCount(long count) {}
为了稍微简化功能代码,我们还将在Elevation记录的显式规范构造函数中将厘米转换为英寸。这次,功能代码可以重写如下:
Map<Elevation, ElevationCount> elevations = DoubleStream.of(
22, -10, 100, -5, 100, 123, 22, 230, -1, 250, 22)
.filter(e -> e > 0)
.mapToObj(Elevation::new)
.collect(Collectors.groupingBy(Function.identity(),
Collectors.collectingAndThen(counting(),
ElevationCount::new)));
现在,将Map<Elevation, ElevationCount>传递给一个方法消除了对其内容的任何疑问。任何团队成员都可以在眨眼间检查这些记录,而无需浪费时间阅读我们的功能实现来推断Double和Long代表什么。我们可以更加明确地将Elevation记录重命名为PositiveElevation。
97. 引入记录模式用于 instanceof
为了引入记录模式,我们需要一个比迄今为止使用的更复杂的记录,所以这里有一个例子:
public record Doctor(String name, String specialty)
implements Staff {}
这个记录实现了Staff接口,就像我们医院的其他任何员工一样。现在,我们可以通过instanceof以传统方式识别某个医生,如下所示:
public static String cabinet(Staff staff) {
if (staff instanceof Doctor) {
Doctor dr = (Doctor) staff;
return "Cabinet of " + dr.specialty()
+ ". Doctor: " + dr.name();
}
...
}
但是,正如我们从第二章,问题 58-67 中所知,JDK 引入了可用于instanceof和switch的类型模式。因此,在这种情况下,我们可以通过类型模式重写之前的代码,如下所示:
public static String cabinet(Staff staff) {
if (staff instanceof Doctor dr) { // type pattern matching
return "Cabinet of " + dr.specialty()
+ ". Doctor: " + dr.name();
}
...
}
到目前为止,没有什么新东西!绑定变量dr可以用来调用记录访问器的specialty()和name(),添加检查、计算等等。但是,编译器非常清楚Doctor记录是基于两个组件(name和specialty)构建的,因此编译器应该能够解构此对象,并直接将这些组件作为绑定变量提供给我们,而不是通过dr来访问它们。
这正是记录模式匹配的全部内容。记录模式匹配作为预览功能首次出现在 JDK 19(JEP 405)中,作为第二个预览功能出现在 JDK 20(JEP 432)中,并在 JDK 21(JEP 440)中作为最终版本发布。
记录模式匹配正是通过遵循记录本身的相同声明语法(或类似于规范构造函数)来声明name和specialty作为绑定变量的语法。以下是使用记录模式编写的先前代码:
public static String cabinet(Staff staff) {
// record pattern matching
if (staff instanceof **Doctor****(String name, String specialty)**){
return "Cabinet of " + name + ". Doctor: " + specialty;
}
...
}
非常简单,不是吗?
现在,name和specialty是可以直接使用的绑定变量。我们只需将此语法放在类型模式的位置。换句话说,我们用记录模式替换了类型模式。
重要提示
编译器通过相应的绑定变量公开记录的组件。这是通过模式匹配中的记录解构来实现的,这被称为记录模式。换句话说,解构模式允许我们以非常方便、直观和可读的方式访问对象的组件。
在记录模式中,初始化绑定变量(如name和specialty)是编译器的责任。为了完成这个任务,编译器会调用相应组件的访问器。这意味着,如果您在这些访问器中有额外的代码(例如,返回防御性副本,执行验证或应用约束等),那么这些代码将被正确执行。
让我们更进一步,处理一些嵌套记录。
嵌套记录和记录模式
假设除了Doctor记录之外,我们还有以下记录:
public record Resident(String name, Doctor doctor)
implements Staff {}
每个居民都有一个协调员,即医生,所以Resident嵌套了Doctor。这次,我们必须相应地嵌套记录模式,如下面的代码所示:
public static String cabinet(Staff staff) {
if (staff instanceof Resident(String rsname,
Doctor(String drname, String specialty))) {
return "Cabinet of " + specialty + ". Doctor: "
+ drname + ", Resident: " + rsname;
}
...
}
居住者和医生都有一个name组件。但由于在这个上下文中不能重复使用绑定变量name,因为这会导致冲突,所以我们有rsname和drname。请注意,绑定变量的名称不必与组件的名称相匹配。这是因为编译器通过位置而不是名称来识别组件。但是,当然,当可能的时候,与名称相匹配可以减少混淆并保持代码的可读性高。
如果不需要解构Doctor记录,那么我们可以这样写:
if (staff instanceof Resident(String name, Doctor dr)) {
return "Cabinet of " + dr.specialty() + ". Doctor: "
+ dr.name() + ", Resident: " + name;
}
添加更多嵌套记录遵循相同的原理。例如,让我们添加Patient和Appointment记录:
public record Appointment(LocalDate date, Doctor doctor) {}
public record Patient(
String name, int npi, Appointment appointment) {}
现在,我们可以写出以下美妙的代码:
public static String reception(Object o) {
if (o instanceof Patient(var ptname, var npi,
Appointment(var date,
Doctor (var drname, var specialty)))) {
return "Patient " + ptname + " (NPI: " + npi
+ ") has an appointment at "
+ date + " to the doctor " + drname
+ " (" + specialty + ").";
}
...
}
或者,如果我们不想解构Appointment并使用var:
if (o instanceof Patient(
var ptname, var npi, var ap)) {
return "Patient " + ptname + " (NPI: " + npi
+ ") has an appointment at "
+ ap.date() + " to the doctor " + ap.doctor().name()
+ " (" + ap.doctor().specialty() + ").";
}
注意,这次我们使用了var而不是显式类型。由于var在这个情况下非常适合,所以您可以自由地这样做。如果您不熟悉类型推断,那么可以考虑阅读《Java 编程问题》,第一版,第四章,其中包含详细的解释和最佳实践。关于记录模式中参数类型推断的更多细节将在本章后面的问题 100中提供。
我想你已经明白了这个想法!
98. 为switch引入记录模式
您已经知道类型模式可以用于instanceof和switch表达式。这个说法对记录模式同样适用。例如,让我们再次回顾Doctor和Resident记录:
public record Doctor(String name, String specialty)
implements Staff {}
public record Resident(String name, Doctor doctor)
implements Staff {}
我们可以通过记录模式在switch表达式中轻松使用这两个记录,如下所示:
public static String cabinet(Staff staff) {
return switch(staff) {
case Doctor(var name, var specialty)
-> "Cabinet of " + specialty + ". Doctor: " + name;
case Resident(var rsname, Doctor(var drname, var specialty))
-> "Cabinet of " + specialty + ". Doctor: "
+ drname + ", Resident: " + rsname;
default -> "Cabinet closed";
};
}
添加更多嵌套记录遵循相同的原理。例如,让我们添加Patient和Appointment记录:
public record Appointment(LocalDate date, Doctor doctor) {}
public record Patient(
String name, int npi, Appointment appointment) {}
现在,我们可以写出以下美妙的代码:
public static String reception(Object o) {
return switch(o) {
case Patient(String ptname, int npi,
Appointment(LocalDate date,
Doctor (String drname, String specialty))) ->
"Patient " + ptname + " (NPI: " + npi
+ ") has an appointment at "
+ date + " to the doctor " + drname + " ("
+ specialty + ").";
default -> "";
};
}
或者,不解构Appointment并使用var:
return switch(o) {
case Patient(var ptname, var npi, var ap) ->
"Patient " + ptname + " (NPI: "
+ npi + ") has an appointment at "
+ ap.date() + " to the doctor " + ap.doctor().name()
+ " (" + ap.doctor().specialty() + ").";
default -> "";
};
注意,第二章中涵盖的主题,如支配性、完整性和无条件模式,对于具有switch的记录模式同样有效。实际上,还有一些关于无条件模式的重要事项需要强调,但那将在问题 101中介绍。
99. 解决受保护记录模式
正如类型模式的情况一样,我们可以根据绑定变量添加保护条件。例如,以下代码使用instanceof和保护条件来确定过敏柜是打开还是关闭(你应该熟悉前两个问题中的Doctor记录):
public static String cabinet(Staff staff) {
if (staff instanceof Doctor(String name, String specialty)
&& (specialty.equals("Allergy")
&& (name.equals("Kyle Ulm")))) {
return "The cabinet of " + specialty
+ " is closed. The doctor "
+ name + " is on holiday.";
}
if (staff instanceof Doctor(String name, String specialty)
&& (specialty.equals("Allergy")
&& (name.equals("John Hora")))) {
return "The cabinet of " + specialty
+ " is open. The doctor "
+ name + " is ready to receive patients.";
}
return "Cabinet closed";
}
如果我们将Resident记录也加入等式中,那么我们可以写成这样:
if (staff instanceof Resident(String rsname,
Doctor(String drname, String specialty))
&& (specialty.equals("Dermatology")
&& rsname.equals("Mark Oil"))) {
return "Cabinet of " + specialty + ". Doctor "
+ drname + " and resident " + rsname
+ " are ready to receive patients.";
}
如果我们还将Patient和Appointment记录添加进去,那么我们可以按照以下方式检查某个患者是否有预约:
public static String reception(Object o) {
if (o instanceof Patient(var ptname, var npi,
Appointment(var date,
Doctor (var drname, var specialty)))
&& (ptname.equals("Alicia Goy") && npi == 1234567890
&& LocalDate.now().equals(date))) {
return "The doctor " + drname + " from " + specialty
+ " is ready for you " + ptname;
}
return "";
}
当我们在switch表达式中使用带有保护条件的记录模式时,事情变得简单明了。提及的部分包括使用when关键字(而不是&&运算符),如下面的代码所示:
public static String cabinet(Staff staff) {
return switch(staff) {
case Doctor(var name, var specialty)
when specialty.equals("Dermatology")
-> "The cabinet of " + specialty
+ " is currently under renovation";
case Doctor(var name, var specialty)
when (specialty.equals("Allergy")
&& (name.equals("Kyle Ulm")))
-> "The cabinet of " + specialty
+ " is closed. The doctor " + name
+ " is on holiday.";
case Doctor(var name, var specialty)
when (specialty.equals("Allergy")
&& (name.equals("John Hora")))
-> "The cabinet of " + specialty
+ " is open. The doctor " + name
+ " is ready to receive patients.";
case Resident(var rsname,
Doctor(var drname, var specialty))
when (specialty.equals("Dermatology")
&& rsname.equals("Mark Oil"))
-> "Cabinet of " + specialty + ". Doctor "
+ drname + " and resident " + rsname
+ " are ready to receive patients.";
default -> "Cabinet closed";
};
}
如果我们还将Patient和Appointment记录添加进去,那么我们可以按照以下方式检查某个患者是否有预约:
public static String reception(Object o) {
return switch(o) {
case Patient(String ptname, int npi,
Appointment(LocalDate date,
Doctor (String drname, String specialty)))
when (ptname.equals("Alicia Goy")
&& npi == 1234567890 && LocalDate.now().equals(date))
-> "The doctor " + drname + " from " + specialty
+ " is ready for you " + ptname;
default -> "";
};
}
JDK 19+的上下文特定关键字when被添加到模式标签和检查(代表保护条件的布尔表达式)之间,这避免了使用&&运算符的混淆。
100. 在记录模式中使用泛型记录
声明用于映射水果数据的泛型记录可以按照以下方式完成:
public record FruitRecord<T>(T t, String country) {}
现在,让我们假设一个MelonRecord,它是一种水果(实际上,关于西瓜是水果还是蔬菜有一些争议,但让我们假设它是水果):
public record MelonRecord(String type, float weight) {}
我们可以按照以下方式声明一个FruitRecord<MelonRecord>:
FruitRecord<MelonRecord> fruit =
new FruitRecord<>(new MelonRecord("Hami", 1000), "China");
这个FruitRecord<MelonRecord>可以在带有instanceof的记录模式中使用:
if (fruit instanceof FruitRecord<MelonRecord>(
MelonRecord melon, String country)) {
System.out.println(melon + " from " + country);
}
或者,在switch语句/表达式中:
switch(fruit) {
case FruitRecord<MelonRecord>(
MelonRecord melon, String country) :
System.out.println(melon + " from " + country); break;
default : break;
};
接下来,让我们看看如何使用类型参数推断。
类型参数推断
Java 支持对记录模式进行类型参数推断,因此我们可以将之前的示例重写如下:
if (fruit instanceof FruitRecord<MelonRecord>(
var melon, var country)) {
System.out.println(melon + " from " + country);
}
或者,如果我们想要更简洁的代码,那么我们可以省略类型参数如下所示:
if (fruit instanceof FruitRecord(var melon, var country)) {
System.out.println(melon + " from " + country);
}
对于switch也是同样的道理:
switch (fruit) {
case FruitRecord<MelonRecord>(var melon, var country) :
System.out.println(melon + " from " + country); break;
default : break;
};
或者,更简洁一些:
switch (fruit) {
case FruitRecord(var melon, var country) :
System.out.println(melon + " from " + country); break;
default : break;
};
在这里,melon的类型被推断为MelonRecord,country的类型为String。
现在,让我们假设以下泛型记录:
public record EngineRecord<X, Y, Z>(X x, Y y, Z z) {}
泛型X、Y和Z可以是任何东西。例如,我们可以通过类型、马力以及冷却系统来定义一个引擎如下所示:
EngineRecord<String, Integer, String> engine
= new EngineRecord("TV1", 661, "Water cooled");
接下来,我们可以使用engine变量和instanceof如下所示:
if (engine instanceof EngineRecord<String, Integer, String>
(var type, var power, var cooling)) {
System.out.println(type + " - " + power + " - " + cooling);
}
// or, more concise
if (engine instanceof EngineRecord(
var type, var power, var cooling)) {
System.out.println(type + " - " + power + " - " + cooling);
}
以及使用switch如下所示:
switch (engine) {
case EngineRecord<String, Integer, String>(
var type, var power, var cooling) :
System.out.println(type + " - "
+ power + " - " + cooling);
default : break;
};
// or, more concise
switch (engine) {
case EngineRecord(var type, var power, var cooling) :
System.out.println(type + " - "
+ power + " - " + cooling);
default : break;
};
在这两个例子中,我们依赖于推断的类型作为参数。对于type参数推断的类型是String,对于power是Integer,对于cooling是String。
类型参数推断和嵌套记录
让我们假设以下记录:
public record ContainerRecord<C>(C c) {}
以及以下嵌套的container:
ContainerRecord<String> innerContainer
= new ContainerRecord("Inner container");
ContainerRecord<ContainerRecord<String>> container
= new ContainerRecord(innerContainer);
接下来,我们可以按照以下方式使用container:
if (container instanceof
ContainerRecord<ContainerRecord<String>>(
ContainerRecord(var c))) {
System.out.println(c);
}
在这里,嵌套模式ContainerRecord(var c)的类型参数被推断为String,因此模式本身被推断为ContainerRecord<String>(var c)。
如果我们在外部记录模式中省略类型参数,我们可以得到更简洁的代码如下所示:
if (container instanceof ContainerRecord(
ContainerRecord(var c))) {
System.out.println(c);
}
在这种情况下,编译器会推断整个 instanceof 模式是 ContainerRecord<ContainerRecord<String>>(ContainerRecord<String>(var c))。
或者,如果我们想得到外部容器,那么我们编写以下记录模式:
if (container instanceof
ContainerRecord<ContainerRecord<String>>(var c)) {
System.out.println(c);
}
在捆绑的代码中,你还可以找到这些 switch 的例子。
重要提示
注意,类型模式不支持类型参数的隐式推断(例如,类型模式 List list 总是作为原始类型模式处理)。
因此,Java 泛型可以在记录中像在常规 Java 类中一样使用。此外,我们可以将它们与记录模式和 instanceof/switch 结合使用。
101. 处理嵌套记录模式中的 null
从 第二章,问题 54,处理 switch 中的 null 情况,我们知道从 JDK 17(JEP 406)开始,我们可以将 switch 中的 null 情况视为任何其他常见情况:
case null -> throw new IllegalArgumentException(...);
null values only it will not allow the execution of that branch. The switch expressions will throw a NullPointerException without even looking at the patterns.
这个语句对记录模式也部分有效。例如,让我们考虑以下记录:
public interface Fruit {}
public record SeedRecord(String type, String country)
implements Fruit {}
public record MelonRecord(SeedRecord seed, float weight)
implements Fruit {}
public record EggplantRecord(SeedRecord seed, float weight)
implements Fruit {}
然后,让我们考虑以下 switch:
public static String buyFruit(Fruit fruit) {
return switch(fruit) {
case null -> "Ops!";
case SeedRecord(String type, String country)
-> "This is a seed of " + type + " from " + country;
case EggplantRecord(SeedRecord seed, float weight)
-> "This is a " + seed.type() + " eggplant";
case MelonRecord(SeedRecord seed, float weight)
-> "This is a " + seed.type() + " melon";
case Fruit v -> "This is an unknown fruit";
};
}
如果我们调用 buyFruit(null),那么我们会得到消息 Ops!。编译器知道选择表达式是 null,并且有一个 case null,因此它会执行那个分支。如果我们删除那个 case null,那么我们立即得到一个 NullPointerException。编译器不会评估记录模式;它将简单地抛出一个 NullPointerException。
接下来,让我们创建一个茄子:
SeedRecord seed = new SeedRecord("Fairytale", "India");
EggplantRecord eggplant = new EggplantRecord(seed, 300);
这次,如果我们调用 buyFruit(seed),我们会得到消息 这是来自印度的童话种子. 调用与 case SeedRecord(String type, String country) 分支匹配。如果我们调用 buyFruit(eggplant),那么我们会得到消息 这是一颗童话茄子. 调用与 case EggplantRecord(SeedRecord seed, float weight) 分支匹配。到目前为止,没有惊喜!
现在,让我们来看一个边缘情况。我们假设 SeedRecord 是 null,并创建以下“坏”茄子:
EggplantRecord badEggplant = new EggplantRecord(null, 300);
调用 buyFruit(badEggplant) 将返回一个包含以下清晰信息的 NullPointerException:java.lang.NullPointerException: 无法调用“modern.challenge.SeedRecord.type()”因为seed是 null。正如你所见,在嵌套 null 的情况下,编译器无法阻止执行相应的分支。嵌套的 null 不会短路代码,而是触发了我们的分支(case EggplantRecord(SeedRecord seed, float weight))中的代码,我们在那里调用 seed.type()。由于 seed 是 null,我们得到一个 NullPointerException。
我们无法通过例如 case EggplantRecord(null, float weight) 这样的情况来覆盖这个边缘情况。这将无法编译。显然,更深或更广的嵌套将使这些边缘情况更加复杂。然而,我们可以添加一个守卫来防止问题,并按以下方式覆盖这个情况:
case EggplantRecord(SeedRecord seed, float weight)
when seed == null -> "Ops! What's this?!";
让我们看看使用 instanceof 而不是 switch 时会发生什么。因此,代码变为:
public static String buyFruit(Fruit fruit) {
if (fruit instanceof SeedRecord(
String type, String country)) {
return "This is a seed of " + type + " from " + country;
}
if (fruit instanceof EggplantRecord(
SeedRecord seed, float weight)) {
return "This is a " + seed.type() + " eggplant";
}
if (fruit instanceof MelonRecord(
SeedRecord seed, float weight)) {
return "This is a " + seed.type() + " melon";
}
return "This is an unknown fruit";
}
在 instanceof 的情况下,没有必要添加显式的 null 检查。例如,buyFruit(null) 的调用将返回消息 This is an unknown fruit。这是由于没有 if 语句与给定的 null 匹配。
接下来,如果我们调用 buyFruit(seed),我们会得到消息 This is a seed of Fairytale from India。这个调用与 if (fruit instanceof SeedRecord(String type, String country)) 分支匹配。如果我们调用 buyFruit(eggplant),那么我们会得到消息 This is a Fairytale eggplant。这个调用与 case if (fruit instanceof EggplantRecord(SeedRecord seed, float weight)) 分支匹配。到目前为止,还没有惊喜!
最后,让我们通过 buyFruit(badEggplant) 调用将 badEggplant 带到前面。正如在 switch 示例中的情况一样,结果将包含一个 NPE:Cannot invoke “modern.challenge.SeedRecord.type()” because seed is null。再次,嵌套的 null 不能被编译器拦截,并且 if (fruit instanceof EggplantRecord(SeedRecord seed, float weight)) 分支被执行,导致 NullPointerException,因为我们调用 seed.type() 时 seed 是 null。
尝试通过以下代码片段覆盖这个边缘情况将无法编译:
if (fruit instanceof EggplantRecord(null, float weight)) {
return "Ops! What's this?!";
}
然而,我们可以添加一个守卫来覆盖这个情况,如下所示:
if (fruit instanceof EggplantRecord(
SeedRecord seed, float weight) && seed == null) {
return "Ops! What's this?!";
}
因此,请注意嵌套模式没有利用 case null 或 JDK 19+ 的行为,即在没有检查模式的情况下抛出 NPE。这意味着 null 值可以穿过一个 case(或 instanceof 检查)并执行导致 NPE 的分支。所以,尽可能避免 null 值或添加额外的检查(守卫)应该是通往顺利道路的方式。
102. 通过记录模式简化表达式
Java 记录可以帮助我们大大简化处理/评估不同表达式(数学、统计、基于字符串的、抽象语法树(AST)等)的代码片段。通常,评估此类表达式意味着有很多条件检查可以通过 if 和/或 switch 语句实现。
例如,让我们考虑以下旨在形成可以连接的基于字符串的表达式的记录:
interface Str {}
record Literal(String text) implements Str {}
record Variable(String name) implements Str {}
record Concat(Str first, Str second) implements Str {}
字符串表达式的某些部分是字面量(Literal),而其他部分作为变量(Variable)提供。为了简洁起见,我们可以通过连接操作(Concat)来评估这些表达式,但请随意添加更多操作。
在评估过程中,我们有一个中间步骤,通过删除/替换不相关的部分来简化表达式。例如,我们可以考虑表达式中的空字符串项可以安全地从连接过程中删除。换句话说,一个字符串表达式如 t + " " 可以简化为 t,因为我们的表达式的第二个项是一个空字符串。
用于执行此类简化的代码可以依赖于类型模式和 instanceof,如下所示:
public static Str shortener(Str str) {
if (str instanceof Concat s) {
if (s.first() instanceof Variable first
&& s.second() instanceof Literal second
&& second.text().isBlank()) {
return first;
} else if (s.first() instanceof Literal first
&& s.second() instanceof Variable second
&& first.text().isBlank()) {
return second;
}
}
return str;
}
如果我们继续为简化给定的str添加更多规则,这段代码将变得相当冗长。幸运的是,我们可以通过使用记录模式和switch来提高代码的可读性。这样,代码变得更加紧凑和易于表达。看看这个:
public static Str shortener(Str str) {
return switch (str) {
case Concat(Variable(var name), Literal(var text))
when text.isBlank() -> new Variable(name);
case Concat(Literal(var text), Variable(var name))
when text.isBlank() -> new Variable(name);
default -> str;
};
}
这有多酷?
103. 将未命名的模式和变量挂钩
JDK 21 最引人注目的预览特性之一是 JEP 443 或未命名的模式和变量。换句话说,通过未命名的模式和变量,JDK 21 为我们提供了表示代码中未使用(我们不关心)的记录组件和局部变量的支持,即下划线字符(_)。
未命名的模式
解构记录使我们能够表达记录模式,但我们并不总是使用所有生成的组件。未命名的模式对于指示我们不使用但为了语法必须声明的记录组件非常有用。例如,让我们看以下示例(Doctor、Resident、Patient和Appointment记录在问题 97和98中已介绍,为了简洁,我将在此省略它们的声明):
if (staff instanceof Doctor(String name, String specialty)) {
return "The cabinet of " + specialty
+ " is currently under renovation";
}
在这个例子中,Doctor记录被解构为Doctor(String name, String specialty),但我们只使用了specialty组件,而无需name组件。然而,我们不能写Doctor(String specialty),因为这不符合Doctor记录的签名。作为替代,我们可以简单地用下划线替换String name如下:
if (staff instanceof Doctor(_, String specialty)) {
return "The cabinet of " + specialty
+ " is currently under renovation";
}
未命名的模式是类型模式var _的简写,因此我们可以这样写if (staff instanceof Doctor(var _, String specialty))。
让我们考虑另一个用例:
if (staff instanceof Resident(String name, Doctor dr)) {
return "The resident of this cabinet is : " + name;
}
在这种情况下,我们使用了Resident的name,但我们不关心Doctor,因此我们可以简单地使用下划线如下:
if (staff instanceof Resident(String name, _)) {
return "The resident of this cabinet is : " + name;
}
这里是另一个忽略医生专业性的示例:
if (staff instanceof Resident(String rsname,
Doctor(String drname, _))) {
return "This is the cabinet of doctor " + drname
+ " and resident " + rsname;
}
接下来,让我们也添加Patient和Appointment记录:
if (o instanceof Patient(var ptname, var npi,
Appointment(var date,
Doctor (var drname, var specialty)))) {
return "Patient " + ptname
+ " has an appointment for the date of " + date;
}
在这个例子中,我们不需要npi组件和Doctor组件,因此我们可以用下划线替换它们:
if (o instanceof Patient(var ptname, _,
Appointment(var date, _))) {
return "Patient " + ptname
+ " has an appointment for the date of " + date;
}
此外,这里是一个只需要患者姓名的情况:
if (o instanceof Patient(var ptname, _, _)) {
return "Patient " + ptname + " has an appointment";
}
当然,在这种情况下,你可能更喜欢依赖类型模式匹配,并按以下方式表达代码:
if (o instanceof Patient pt) {
return "Patient " + pt.name() + " has an appointment";
}
我认为你已经明白了这个想法!当你不需要记录组件,并且想要在快速编写代码时清楚地传达这一方面,只需将那个组件替换为下划线(_)即可。
未命名的模式也可以与switch一起使用。以下是一个示例:
// without unnamed patterns
return switch(staff) {
case Doctor(String name, String specialty) ->
"The cabinet of " + specialty
+ " is currently under renovation";
case Resident(String name, Doctor dr) ->
"The resident of this cabinet is : " + name;
default -> "Cabinet closed";
};
// with unnamed patterns
return switch(staff) {
case Doctor(_, String specialty) ->
"The cabinet of " + specialty
+ " is currently under renovation";
case Resident(String name, _) ->
"The resident of this cabinet is : " + name;
default -> "Cabinet closed";
};
嵌套记录和未命名的模式可以显著缩短代码长度。以下是一个示例:
// without unnamed patterns
return switch(o) {
case Patient(String ptname, int npi,
Appointment(LocalDate date,
Doctor (String drname, String specialty))) ->
"Patient " + ptname + " has an appointment";
default -> "";
};
// with unnamed patterns
return switch(o) {
case Patient(String ptname, _, _) ->
"Patient " + ptname + " has an appointment";
default -> "";
};
现在,让我们专注于未命名的变量的另一个用例,并假设以下起点:
public sealed abstract class EngineType
permits ESSEngine, DSLEngine, LPGEngine {}
public final class ESSEngine extends EngineType {}
public final class DSLEngine extends EngineType {}
public final class LPGEngine extends EngineType {}
public record Car<E extends EngineType>(E engineType) {}
因此,我们有一个密封类(EngineType),它由三个最终类(ESSEngine、DSLEngine和LPGEngine)扩展,还有一个记录(Car)。接下来,我们想要编写以下switch:
public static String addCarburetor(Car c) {
return switch(c) {
case Car(DSLEngine dsl), Car(ESSEngine ess)
-> "Adding a carburetor to a ESS or DSL car";
case Car(LPGEngine lpg)
-> "Adding a carburetor to a LPG car";
};
}
查看第一个 case 标签。我们之所以将前两个模式组合在一个 case 标签中,是因为 DSL 和 ESS 汽车可以具有相同类型的化油器。然而,这将无法编译,并导致错误:从模式非法跳过。由于两个模式都可以匹配,给组件命名是错误的。在这种情况下,我们可以通过未命名的变量省略组件,如下所示:
public static String addCarburetor(Car c) {
return switch(c) {
case Car(DSLEngine _), Car(ESSEngine _)
-> "Adding a carburetor to a ESS or DSL car";
case Car(LPGEngine lpg)
-> "Adding a carburetor to a LPG car";
};
}
这会编译并正常工作。此外,第二个 case 标签也可以写成 case Car(LPGEngine _),因为我们没有在右侧使用 lpg 名称。
如果你需要向具有多个模式的 case 标签添加 守卫,那么请记住,守卫适用于多个模式作为一个整体,而不是每个单独的模式。例如,以下代码是正确的:
public static String addCarburetor(Car c, int carburetorType){
return switch(c) {
case Car(DSLEngine _), Car(ESSEngine _)
when carburetorType == 1
-> "Adding a carburetor of type 1 to a ESS or DSL car";
case Car(DSLEngine _), Car(ESSEngine _)
-> "Adding a carburetor of tpye "
+ carburetorType + " to a ESS or DSL car";
case Car(LPGEngine lpg) -> "Adding a carburetor "
+ carburetorType + " to a LPG car";
};
}
接下来,让我们来处理未命名变量。
未命名变量
除了 未命名的模式(特定于记录组件的解构)之外,JDK 21 还引入了 未命名的变量。未命名的变量也由下划线 (_) 表示,并且有助于突出显示我们不需要/使用的变量。这些变量可以出现在以下任一上下文中。
在 catch 块中
ArithmeticException but we log a friendly message that doesn’t use the exception parameter:
int divisor = 0;
try {
int result = 1 / divisor;
// use result
} catch (ArithmeticException _) {
System.out.println("Divisor " + divisor + " is not good");
}
同样的技术可以应用于多捕获情况。
在一个 for 循环中
logLoopStart() but we don’t use the returned result:
int[] arr = new int[]{1, 2, 3};
for (int i = 0, _ = logLoopStart(i); i < arr.length; i++) {
// use i
}
for loop but we don’t use the cards:
int score = 0;
List<String> cards = List.of(
"12 spade", "6 diamond", "14 diamond");
for (String _ : cards) {
if (score < 10) {
score ++;
} else {
score --;
}
}
因此,在这里,我们不在乎卡片的值,所以我们不是写 for (String card : cards) {…},而是简单地写 for (String _ : cards) {…}。
在忽略结果的赋值中
让我们考虑以下代码:
Files.deleteIfExists(Path.of("/file.txt"));
deleteIfExists() 方法返回一个布尔结果,表示给定的文件是否被成功删除。但是,在这个代码中,我们没有捕获那个结果,所以不清楚我们是想忽略结果还是只是忘记它。如果我们假设我们忘记了它,那么我们很可能会想写这样:
boolean success = Files.deleteIfExists(Path.of("/file.txt"));
if (success) { ... }
但是,如果我们只是想忽略它,那么我们可以通过未命名的变量清楚地传达这一点(这表明我们意识到了结果,但我们不想根据其值采取进一步行动):
boolean _ = Files.deleteIfExists(Path.of("/file.txt"));
var _ = Files.deleteIfExists(Path.of("/file.txt"));
每次你想忽略右侧表达式的结果时,都可以使用相同的技巧。
在 try-with-resources 中
有时,我们不会使用在 try-with-resources 块中打开的资源。我们只需要这个资源的上下文,并且我们想从它是 AutoCloseable 的这一事实中受益。例如,当我们调用 Arena.ofConfined() 时,我们可能需要 Arena 上下文,而无需明确使用它。在这种情况下,未命名的变量可以帮助我们,如下例所示:
try (Arena _ = Arena.ofConfined()) {
// don't use arena
}
或者,使用 var:
try (var _ = Arena.ofConfined()) {
// don't use arena
}
Arena API 是在 第七章 中引入的外部(函数)内存 API 的一部分。
在 lambda 表达式中
当 lambda 参数对我们 lambda 表达式不相关时,我们可以简单地将其替换为下划线。以下是一个例子:
List<Melon> melons = Arrays.asList(…);
Map<String, Integer> resultToMap = melons.stream()
.collect(Collectors.toMap(Melon::getType, Melon::getWeight,
(oldValue, _) -> oldValue));
完成!别忘了,这是 JDK 21 中的一个预览功能,所以请使用 --enable-preview。
104. 解决 Spring Boot 中的记录问题
Java 记录非常适合 Spring Boot 应用程序。让我们看看几个 Java 记录可以帮助我们通过压缩同源代码来提高可读性和表达性的场景。
在控制器中使用记录
通常,Spring Boot 控制器使用简单的 POJO 类操作,这些类携带我们的数据通过线传输到客户端。例如,检查这个简单的控制器端点,它返回一个包含作者及其书籍的作者列表:
@GetMapping("/authors")
public List<Author> fetchAuthors() {
return bookstoreService.fetchAuthors();
}
在这里,Author(和Book)可以作为简单的数据载体,以 POJO 的形式编写。但它们也可以被记录所替代。如下所示:
public record Book(String title, String isbn) {}
public record Author(
String name, String genre, List<Book> books) {}
那就结束了!Jackson 库(它是 Spring Boot 中的默认 JSON 库)将自动将Author/Book类型的实例序列化为 JSON。在捆绑的代码中,您可以通过localhost:8080/authors端点地址练习完整的示例。
使用记录与模板
Thymeleaf (www.thymeleaf.org/)可能是 Spring Boot 应用程序中最常用的模板引擎。Thymeleaf 页面(HTML 页面)通常用 POJO 类携带的数据填充,这意味着 Java 记录也应该可以工作。
让我们考虑之前的Author和Book记录,以及以下控制器端点:
@GetMapping("/bookstore")
public String bookstorePage(Model model) {
model.addAttribute("authors",
bookstoreService.fetchAuthors());
return "bookstore";
}
通过fetchAuthors()返回的List<Author>存储在模型中,变量名为authors。这个变量用于以下方式填充bookstore.html:
…
<ul th:each="author : ${authors}">
<li th:text="${author.name} + ' ('
+ ${author.genre} + ')'" />
<ul th:each="book : ${author.books}">
<li th:text="${book.title}" />
</ul>
</ul>
…
完成!
使用记录进行配置
假设我们在application.properties中拥有以下两个属性(它们也可以用 YAML 表示):
bookstore.bestseller.author=Joana Nimar
bookstore.bestseller.book=Prague history
Spring Boot 通过@ConfigurationProperties将这些属性映射到 POJO。但是,记录也可以使用。例如,这些属性可以按如下方式映射到BestSellerConfig记录:
@ConfigurationProperties(prefix = "bookstore.bestseller")
public record BestSellerConfig(String author, String book) {}
接下来,在BookstoreService(一个典型的 Spring Boot 服务)中,我们可以注入BestSellerConfig并调用其访问器:
@Service
public class BookstoreService {
private final BestSellerConfig bestSeller;
public BookstoreService(BestSellerConfig bestSeller) {
this.bestSeller = bestSeller;
}
public String fetchBestSeller() {
return bestSeller.author() + " | " + bestSeller.book();
}
}
在捆绑的代码中,我们添加了一个使用此服务的控制器。
记录和依赖注入
在之前的示例中,我们已经使用 SpringBoot 提供的典型机制将BookstoreService服务注入到BookstoreController中,即通过构造函数进行依赖注入(也可以通过@Autowired完成):
@RestController
public class BookstoreController {
private final BookstoreService bookstoreService;
public BookstoreController(
BookstoreService bookstoreService) {
this.bookstoreService = bookstoreService;
}
@GetMapping("/authors")
public List<Author> fetchAuthors() {
return bookstoreService.fetchAuthors();
}
}
但是,我们可以通过将其重写为记录来压缩这个类,如下所示:
@RestController
public record BookstoreController(
BookstoreService bookstoreService) {
@GetMapping("/authors")
public List<Author> fetchAuthors() {
return bookstoreService.fetchAuthors();
}
}
这个记录的规范构造函数将与我们的显式构造函数相同。请随意挑战自己,在 Spring Boot 应用程序中找到更多 Java 记录的使用案例。
105. 解决 JPA 中的记录问题
如果你是一个 JPA 的粉丝(我不明白为什么,但我是谁来判断呢),那么你一定会很高兴地发现 Java 记录在 JPA 中很有帮助。通常,Java 记录可以用作 DTO。接下来,让我们看看记录和 JPA 如何成为令人愉悦的组合的几个场景。
通过记录构造函数创建 DTO
假设我们有一个典型的 JPA Author实体,它映射作者数据,如id、name、age和genre。
接下来,我们想要编写一个查询,以获取某个 genre 的作者。但是,我们不需要以实体形式获取作者,因为我们不打算修改这些数据。这是一个只读查询,只返回给定 genre 的每个作者的 name 和 age。因此,我们需要一个可以通过记录如下表达的 DTO:
public record AuthorDto(String name, int age) {}
接下来,一个典型的 Spring Data JPA,由 Spring Data Query Builder 机制驱动的 AuthorRepository 可以利用这个记录如下:
@Repository
public interface AuthorRepository
extends JpaRepository<Author, Long> {
@Transactional(readOnly = true)
List<AuthorDto> findByGenre(String genre);
}
现在,生成的查询获取数据,Spring Boot 将相应地将其映射为 AuthorDto 来携带。
通过记录和 JPA 构造器表达式生成 DTO
上一个场景的另一种风味可以依赖于如下使用的构造器表达式的 JPA 查询:
@Repository
public interface AuthorRepository
extends JpaRepository<Author, Long> {
@Transactional(readOnly = true)
@Query(value = "SELECT
new com.bookstore.dto.AuthorDto(a.name, a.age)
FROM Author a")
List<AuthorDto> fetchAuthors();
}
AuthorDto 与前一个示例中列出的相同记录。
通过记录和结果转换器生成 DTO
如果你没有在“待办事项”列表中添加使用 Hibernate 6.0+ 结果转换器,那么你可以直接跳到下一个主题。
让我们考虑以下两个记录:
public record BookDto(Long id, String title) {}
public record AuthorDto(Long id, String name,
int age, List<BookDto> books) {
public void addBook(BookDto book) {
books().add(book);
}
}
这次,我们必须获取由 AuthorDto 和 BookDto 表示的层次化 DTO。由于一个作者可以写多本书,我们必须在 AuthorDto 中提供一个 List<BookDto> 类型的组件和一个用于收集当前作者书籍的辅助方法。
为了填充这个层次化的 DTO,我们可以依赖于 TupleTransformer、ResultListTransformer 的如下实现:
public class AuthorBookTransformer implements
TupleTransformer, ResultListTransformer {
private final Map<Long, AuthorDto>
authorsDtoMap = new HashMap<>();
@Override
public Object transformTuple(Object[] os, String[] strings){
Long authorId = ((Number) os[0]).longValue();
AuthorDto authorDto = authorsDtoMap.get(authorId);
if (authorDto == null) {
authorDto = new AuthorDto(((Number) os[0]).longValue(),
(String) os[1], (int) os[2], new ArrayList<>());
}
BookDto bookDto = new BookDto(
((Number) os[3]).longValue(), (String) os[4]);
authorDto.addBook(bookDto);
authorsDtoMap.putIfAbsent(authorDto.id(), authorDto);
return authorDto;
}
@Override
public List<AuthorDto> transformList(List list) {
return new ArrayList<>(authorsDtoMap.values());
}
}
你可以在捆绑的代码中找到完整的应用程序。
通过记录和 JdbcTemplate 生成 DTO
如果你没有在“待办事项”列表中添加使用 SpringBoot JdbcTemplate,那么你可以直接跳到下一个主题。
JdbcTemplate API 在喜欢使用 JDBC 的人群中取得了巨大的成功。所以,如果你熟悉这个 API,那么你一定会很高兴地发现它可以与 Java 记录很好地结合。
例如,与上一个场景中相同的 AuthorDto 和 BookDto,我们可以依赖于 JdbcTemplate 来填充这个层次化 DTO 如下:
@Repository
@Transactional(readOnly = true)
public class AuthorExtractor {
private final JdbcTemplate jdbcTemplate;
public AuthorExtractor(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<AuthorDto> extract() {
String sql = "SELECT a.id, a.name, a.age, b.id, b.title "
+ "FROM author a INNER JOIN book b ON a.id = b.author_id";
List<AuthorDto> result = jdbcTemplate.query(sql,
(ResultSet rs) -> {
final Map<Long, AuthorDto> authorsMap = new HashMap<>();
while (rs.next()) {
Long authorId = (rs.getLong("id"));
AuthorDto author = authorsMap.get(authorId);
if (author == null) {
author = new AuthorDto(rs.getLong("id"),
rs.getString("name"),
rs.getInt("age"), new ArrayList());
}
BookDto book = new BookDto(rs.getLong("id"),
rs.getString("title"));
author.addBook(book);
authorsMap.putIfAbsent(author.id(), author);
}
return new ArrayList<>(authorsMap.values());
});
return result;
}
}
你可以在捆绑的代码中找到完整的应用程序。
将 Java 记录和 @Embeddable 结合起来
Hibernate 6.2+ 允许我们定义可嵌入的 Java 记录。实际上,我们从一个如下定义的可嵌入类开始:
@Embeddable
public record Contact(
String email, String twitter, String phone) {}
接下来,我们在 Author 实体中使用这个可嵌入部分如下:
@Entity
public class Author implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private Contact contact;
private int age;
private String name;
private String genre;
...
}
并且,在我们的 AuthorDto DTO 中如下:
public record AuthorDto(
String name, int age, Contact contact) {}
接下来,一个经典的 Spring Data JPA AuthorRepository,由 Spring Data Query Builder 机制驱动,可以如下利用这个记录:
@Repository
public interface AuthorRepository
extends JpaRepository<Author, Long> {
@Transactional(readOnly = true)
List<AuthorDto> findByGenre(String genre);
}
现在,生成的查询获取数据,Spring Boot 将相应地将其映射为 AuthorDto 来携带。如果我们打印一个获取的作者到控制台,我们会看到如下内容:
[AuthorDto[name=Mark Janel, age=23,
contact=**Contact[email=mark.janel****@yahoo****.com,**
**twitter=****@markjanel****, phone=+****40198503**]]
突出的部分代表我们的可嵌入部分。
106. 处理 jOOQ 中的记录
你对 JPA 了解得越多,你就越会喜欢 jOOQ。为什么?因为 jOOQ 代表了在 Java 中编写 SQL 的最佳方式。灵活性、多功能性、方言无关、坚如磐石的 SQL 支持、学习曲线小、高性能只是使 jOOQ 成为现代应用程序最具吸引力的持久化技术的一些属性。
作为现代技术栈的一部分,jOOQ 是尊重成熟、健壮和良好文档化的技术所有标准的新的持久化趋势。
如果你不太熟悉 jOOQ,那么请考虑我的书 jOOQ 大师班。
话虽如此,让我们假设我们有一个由两个表组成的数据库模式,分别是Productline和Product。一个产品线包含多个产品,因此我们可以通过以下两个记录来塑造这种一对一的关系:
public record RecordProduct(String productName,
String productVendor, Integer quantityInStock) {}
public record RecordProductLine(String productLine,
String textDescription, List<RecordProduct> products) {}
在 jOOQ 中,我们可以通过基于MULTISET运算符的简单查询来填充此模型:
List<RecordProductLine> resultRecord = ctx.select(
PRODUCTLINE.PRODUCT_LINE, PRODUCTLINE.TEXT_DESCRIPTION,
multiset(
select(
PRODUCT.PRODUCT_NAME, PRODUCT.PRODUCT_VENDOR,
PRODUCT.QUANTITY_IN_STOCK)
.from(PRODUCT)
.where(PRODUCTLINE.PRODUCT_LINE.eq(
PRODUCT.PRODUCT_LINE))
).as("products").convertFrom(
r -> r.map(mapping(RecordProduct::new))))
.from(PRODUCTLINE)
.orderBy(PRODUCTLINE.PRODUCT_LINE)
.fetch(mapping(RecordProductLine::new));
这有多酷?jOOQ 可以以完全类型安全的方式生成 jOOQ Records 或 DTOs(POJO/Java 记录)的任何嵌套集合值,无需反射,无 N+1 风险,无去重,无意外的笛卡尔积。这允许数据库执行嵌套并优化查询执行计划。
在捆绑的代码中,你可以看到另一个示例,它在一个记录模型中检索多对多关系。此外,在捆绑的代码中,你可以找到一个依赖于 jOOQ MULTISET_AGG()函数的示例。这是一个合成聚合函数,可以用作MULTISET的替代品。
摘要
本章的目标是深入探讨 Java 记录和记录模式。我们对理论和实践部分都赋予了同等的重要性,以便最终这两个主题没有秘密。而且,如果你想知道为什么我们没有涵盖出现在增强型for语句标题中的记录模式主题,那么请注意,这被添加为 JDK 20 的一个预览功能,但在 JDK 21 中被删除。这个功能可能会在未来的 JEP 中重新提出。
留下评论!
喜欢这本书吗?通过留下亚马逊评论来帮助像你这样的读者。扫描下面的二维码以获取 20%的折扣码。

*限时优惠
第五章:数组、集合和数据结构
本章包括涵盖三个主要主题的 24 个问题。我们首先讨论与专门用于并行数据处理的新的 Vector API 相关的一些问题。然后,我们继续讨论包括 Rope、Skip List、K-D Tree、Zipper、Binomial Heap、Fibonacci Heap、Pairing Heap、Huffman 编码等在内的几个数据结构。最后,我们讨论三种最流行的连接算法。
在本章结束时,你将了解如何编写利用数据并行处理的代码,了解一些酷且不太为人所知的数据结构,以及连接操作是如何工作的。此外,作为额外奖励,你将熟悉 JDK 21 Sequenced Collections API。
问题
使用以下问题来测试你在 Java 数组、集合和数据结构方面的编程能力。我强烈建议你在查看解决方案并下载示例程序之前,尝试解决每个问题:
-
通过数组介绍并行计算:用几段话解释数据并行处理是什么以及它是如何工作的。
-
介绍向量 API 的结构和术语:通过示例解释向量 API 的术语。涵盖诸如元素类型、形状、种类、通道等概念。
-
通过向量 API 求两个数组的和:编写一个使用向量 API 对两个 Java 数组求和的应用程序。
-
通过向量 API 展开求两个数组的和:编写一个使用向量 API 通过展开技术对两个 Java 数组求和的应用程序。
-
基准测试向量 API:给定两个数组
x[]和y[],编写一个应用程序,使用纯 Java 和向量 API 来基准测试计算z[] = x[] + y[]、w[] = x[] * z[] * y[]、k[] = z[] + w[] * y[]。 -
将向量 API 应用于计算 FMA:提供一个著名的融合乘加(Fused Multiply Add,FMA)的向量 API 实现。
-
通过向量 API 乘矩阵:编写一个用于乘两个矩阵的向量 API 实现。
-
使用向量 API 钩接图像负过滤器:编写一个使用向量 API 将负过滤器应用于图像的程序。
-
剖析集合的工厂方法:举例说明在 Java 中创建不可修改/不可变映射、列表和集合的几种方法。
-
从流中获取列表:提供几个有用的代码片段,用于将
Stream内容收集到 JavaList中。 -
处理映射容量:解释 Java
Map的容量是什么,以及如何用它来控制有效映射的数量。 -
处理有序集合:深入探讨 JDK 21 Sequenced Collections API。以你最喜欢的 Java 集合为例,说明这个 API,并解释在此 API 之前有哪些替代方案。
-
介绍 Rope 数据结构:解释 Rope 数据结构是什么,并提供其主要操作(索引、插入、删除、连接和分割)的 Java 实现。
-
介绍 Skip List 数据结构:解释并示例 Skip List 数据结构。
-
介绍 K-D Tree 数据结构:简要介绍 K-D 树,并提供 2-D 树的 Java 实现。
-
介绍 Zipper 数据结构:在树上解释并示例 Zipper 数据结构。
-
介绍 Binomial Heap 数据结构:深入探讨 Binomial Heap 数据结构。解释其主要操作并在 Java 实现中示例它们。
-
介绍 Fibonacci Heap 数据结构:解释并示例 Fibonacci Heap 数据结构。
-
介绍 Pairing Heap 数据结构:解释并示例 Pairing Heap 数据结构。
-
介绍 Huffman 编码数据结构:Huffman 编码算法由 David A. Huffman 在 1950 年开发。解释其用法并通过 Java 实现进行示例。
-
介绍 Splay Tree 数据结构:Splay Tree 是二叉搜索树(BST)的一种形式。解释其特性并提供其主要操作的实现。
-
介绍 Interval Tree 数据结构:Interval Tree 是另一种二叉搜索树(BST)的形式。突出其用法并通过 Java 实现进行示例。
-
介绍 Unrolled Linked List 数据结构:解释并示例 Unrolled Linked List 数据结构。
-
实现连接算法:有三种著名的连接算法:嵌套循环连接、哈希连接和排序归并连接。在涉及一对一关系的两个表中解释并示例每个算法。
以下章节描述了前面问题的解决方案。请记住,通常没有解决特定问题的唯一正确方法。此外,请记住,这里显示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节并实验程序,请访问github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter05。
107. 使用数组介绍并行计算
曾经,CPU 只能在传统模式单指令单数据(SISD)或冯·诺伊曼架构下对数据进行操作。换句话说,一个 CPU 周期可以处理一个指令和一个数据。处理器将这个指令应用于该数据,并返回一个结果。
现代 CPU 能够执行并行计算,并以称为单指令多数据(SIMD)的模式工作。这次,一个 CPU 周期可以同时对多个数据应用单个指令,从理论上讲,这应该会加快速度并提高性能。以下图表突出了这些说法:

图 5.1:SISD 与 SIMD
如果我们通过基于 SISD 的 CPU 对数组 X 和 Y 进行加法操作,那么我们期望每个 CPU 周期都会将 X 中的一个元素与 Y 中的一个元素相加。如果我们在一个基于 SIMD 的 CPU 上执行相同的任务,那么每个 CPU 周期将同时从 X 和 Y 的块中进行加法操作。这意味着 SIMD CPU 应该比 SISD CPU 更快地完成任务。
这是整体图景!当我们靠近时,我们看到 CPU 架构有很多种类,因此开发一个能够利用特定平台最佳性能的应用程序是非常具有挑战性的。
市场上的两大竞争对手,英特尔和 AMD,提供了不同的 SIMD 实现。我们并不旨在详细剖析这个话题,但了解第一个流行的桌面 SIMD 是在 1996 年由英特尔以 MMX(x86 架构)的名义引入的,这可能是有用的。作为回应,AIM 联盟(由苹果、IBM 和飞思卡尔半导体组成)推广了 AltiVec – 一种整数和单精度浮点 SIMD 实现。后来,在 1999 年,英特尔引入了新的 SSE 系统(使用 128 位寄存器)。
从那时起,SIMD 通过扩展如高级向量扩展(AVX、AVX2(256 位寄存器)和 AVX-512(512 位寄存器))而发展。虽然 AVX 和 AVX2 都被英特尔和 AMD 支持,但 2022 年引入的 AVX-512 只被最新的英特尔处理器支持。以下图示有助于说明所有这些:

图 5.2:SIMD 实现历史
图 5.2 只是 CPU 结构的 SIMD 表示。实际上,平台要复杂得多,有多种类型。没有万能的解决方案,每个平台都有其优势和劣势。试图探索优势并避免劣势是任何试图利用特定平台性能的编程语言的真正挑战。
例如,JVM 应该生成哪些适当的指令集,以便从涉及向量的特定平台计算中榨取最佳性能?嗯,从 JDK 16(JEP 338)开始,Java 提供了一个孵化器模块,jdk.incubator.vector,称为 Vector API。这个 API 的目标是允许开发者以一种非常平台无关的方式表达向量计算,这些计算在运行时被转换为支持 CPU 架构上的最佳向量硬件指令。
从 JDK 21(JEP 448)开始,Vector API 达到了第六个孵化阶段,因此我们可以尝试一些利用数据并行加速代码的示例,与标量实现相比。基于此孵化器 API 运行示例可以通过添加 --add-modules=jdk.incubator.vector 和 --enable-preview 虚拟机选项来实现。
但在之前,让我们先了解 Vector API 的结构和术语。
108. 涵盖 Vector API 的结构和术语
向量 API 通过 jdk.incubator.vector 模块(以及具有相同名称的包)进行映射。一个 jdk.incubator.vector.Vector 实例从一个由类型和形状表征的通用抽象组合开始。向量是 Vector<E> 类的一个实例。
向量元素类型
Vector<E> 有一个元素类型(ETYPE),它是 Java 原始类型之一:byte、float、double、short、int 或 long。当我们写 Vector<E> 时,我们说 E 是 ETYPE 的装箱版本(例如,当我们写 Vector<Float> 时,E 是 Float,而 ETYPE 是 float)。为了方便起见,Java 为每个元素类型声明了一个专门的子类型,如下图所示:

图 5.3:专用向量子类型
即使 E 是一个装箱类型,也没有装箱/拆箱开销,因为 Vector<E> 在内部使用 ETYPE 和原始类型进行操作。
除了元素类型外,向量还由一个形状来表征。
向量形状
向量还由一个形状(也称为 VSHAPE)表征,表示向量的位大小或容量。它可以是以 64、128、256 或 512 位。这些值中的每一个都被 VectorShape 枚举(例如,S_128_BIT 枚举项代表长度为 128 位的形状)和一个表示平台支持的最大长度的额外枚举项(S_Max_BIT)所封装。这由当前运行的 Java 平台自动确定。
向量种类
一个由其元素类型和形状表征的向量确定了一个唯一的向量种类,这是一个 VectorSpecies<E> 的固定实例。这个实例由所有具有相同形状和 ETYPE 的向量共享。我们可以将 VectorSpecies<E> 视为一个工厂,用于创建所需元素类型和形状的向量。例如,我们可以定义一个工厂来创建具有 512 位大小的 double 类型向量,如下所示:
static final VectorSpecies<Double> VS = VectorSpecies.of(
double.class, VectorShape.S_512_BIT);
如果你只需要一个工厂来创建当前平台支持的最大位数的向量,而不考虑元素类型,那么请依赖 S_Max_BIT:
static final VectorSpecies<Double> VS = VectorSpecies.of(
double.class, VectorShape.S_Max_BIT);
如果你只需要当前平台支持的最大向量种类(此处,double)来处理你的元素类型,那么请依赖 ofLargestShape()。这个向量种类由平台选择,并且具有为你的元素类型提供可能的最大位数的形状(不要与 S_Max_BIT 混淆,它独立于元素类型):
static final VectorSpecies<Double> VS =
VectorSpecies.ofLargestShape(double.class);
或者,你可能需要当前平台为你元素类型首选的向量种类。这可以通过 ofPreferred() 如下实现:
static final VectorSpecies<Double> VS =
VectorSpecies.ofPreferred(double.class);
最优种类是在你不想指定显式形状时的最方便的方法。
重要提示
最优种类是在当前平台(运行时)上给定元素类型的最优形状。
此外,为了方便起见,每个专用向量(IntVector、FloatVector 等)定义了一组静态字段,以覆盖所有可能的 物种。例如,静态字段 DoubleVector.SPECIES_512 可以用于表示 512 位大小的 DoubleVector 实例的 物种(VectorShape.S_512_BIT):
static final VectorSpecies<Double> VS =
DoubleVector.SPECIES_512;
如果你想要最大的 物种,则依靠 SPECIES_MAX:
static final VectorSpecies<Double> VS =
DoubleVector.SPECIES_MAX;
或者,如果你想选择首选的 物种,则依靠 SPECIES_PREFERRED:
static final VectorSpecies<Double> VS =
DoubleVector.SPECIES_PREFERRED;
你可以通过 elementType() 和 vectorShape() 方法轻松检查 VectorSpecies 实例的 元素类型 和 形状,如下所示:
System.out.println("Element type: " + VS.elementType());
System.out.println("Shape: " + VS.vectorShape());
到目前为止,你已经知道了如何创建向量 物种(向量工厂)。但在开始创建向量和在它们上应用操作之前,让我们先谈谈向量 车道。
向量车道
Vector<E> 就像由 车道 组成的固定大小的 Java 数组。车道数 由 length() 方法返回,称为 VLENGTH。车道数 等于存储在该向量中的标量元素数量。
如果你知道向量的 元素大小 和 形状,那么你可以通过 (形状/元素大小) 计算出 车道数。你应该得到与 length() 返回的结果相同的结果。元素大小 由 elementSize() 返回,形状 由 vectorBitSize() 或 vectorShape().vectorBitSize() 返回。
例如,一个形状为 256 位的向量,其 元素类型 为 float(在 Java 中为 32 位(4 字节)),包含 8 个 float 标量元素,因此它有 8 个 车道。以下图示说明了这一点:

图 5.4:计算车道数
基于此示例,你可以轻松计算任何其他向量配置的 车道数。接下来,让我们看看了解 车道 为什么很重要。
向量操作
在向量上应用操作是我们努力的顶点。车道数 估计了 SIMD 的性能,因为向量操作是在 车道 上进行的。单个向量操作作为一个工作单元影响一个 车道。例如,如果我们的向量有 8 个 车道,这意味着 SIMD 将一次性执行 8 个 车道级 操作。
在以下图中,你可以看到在此上下文中 SISD 与 SIMD 的比较:

图 5.5:SISD 与 SIMD 对比
虽然 SISD 以单个标量作为工作单元,但 SIMD 有 8 个标量(8 个 车道),这也解释了为什么 SIMD 相比 SISD 提供了显著的性能提升。
因此,Vector<E> 是在 车道 上操作的。主要来说,我们有 车道级 操作(如加法、除法和位移动)以及将所有 车道 减少到单个标量的 跨车道 操作(例如,对所有 车道 进行求和)。以下图示展示了这些说明:

图 5.6:车道级和跨车道操作
此外,Vector<E>可以用VectorMask<E>操作。这是一个boolean值的序列,可以被某些向量操作用来过滤给定输入向量的车道元素的选取和操作。查看以下图(只有当掩码包含 1 时才应用加法操作):

图 5.7:带有掩码的逐车道加法
重要提示
注意,不是所有的 CPU 都支持掩码。不支持掩码的 CPU 可能会面临性能下降。
谈到向量操作,你绝对应该查看Vector和VectorOperators文档。在Vector类中,我们有在两个向量之间应用操作的方法。例如,我们有用于二元操作(如add()、div()、sub()和mul())的方法,用于比较(如eq()、lt()和compare())的方法,用于数学操作(如abs())等等。此外,在VectorOperators中,我们有一系列嵌套类(例如,VectorOperators.Associative)和代表逐车道操作的几个常量,如三角函数(SIN、COS等),位移动操作(LSHL和LSHR),数学操作(ABS、SQRT和POW)等等。
在以下问题中,你会看到这些操作的一部分正在运行,但到目前为止,让我们谈谈最后一个基本主题,创建向量。
创建向量
我们已经知道,拥有一个VectorSpecies就像拥有一个创建所需元素类型和形状向量的工厂。现在,让我们看看我们如何使用这样的工厂有效地创建向量(用标量填充它们),这些向量将参与解决实际问题。
假设以下种类(一个包含 8 个车道的向量,32*8=256):
static final VectorSpecies<Integer> VS256
= IntVector.SPECIES_256;
接下来,让我们创建最常见的向量类型。
创建全零向量
假设我们需要一个只包含零的向量。一种快速的方法是使用zero()方法,如下所示:
// [0, 0, 0, 0, 0, 0, 0, 0]
Vector<Integer> v = VS256.zero();
这产生了一个包含 8 个车道的 0.0 向量。同样,也可以通过专门的IntVector类通过zero(VectorSpecies<Integer> species)方法获得:
IntVector v = IntVector.zero(VS256);
你可以轻松地将这个例子推广到FloatVector、DoubleVector等等。
创建具有相同原始值的向量
通过broadcast()方法快速创建一个向量并加载原始值,如下所示:
// [5, 5, 5, 5, 5, 5, 5, 5]
Vector<Integer> v = VS256.broadcast(5);
同样,也可以通过专门的IntVector类通过broadcast(VectorSpecies<Integer> species, int e)或broadcast(VectorSpecies<Integer> species, long e)方法获得:
IntVector v = IntVector.broadcast(VS256, 5);
当然,我们也可以用它来广播一个全零向量:
// [0, 0, 0, 0, 0, 0, 0, 0]
Vector<Integer> v = VS256.broadcast(0);
IntVectorv = IntVector.broadcast(VS256, 0);
最后,让我们看看创建向量的最常见用例。
从 Java 数组创建向量
从 Java 数组创建向量是最常见的用例。实际上,我们从 Java 数组开始,调用fromArray()方法。
使用 VectorSpecies 的 fromArray()
fromArray()方法在VectorSpecies中作为fromArray(Object a, int offset)提供。以下是从整数数组创建向量的示例:
int[] varr = new int[] {0, 1, 2, 3, 4, 5, 6, 7};
Vector<Integer> v = VS256.fromArray(varr, 0);
由于varr长度(8)等于向量长度,并且我们从索引 0 开始,生成的向量将包含数组中的所有标量。在以下示例中,最后 4 个标量将不会是生成向量的一部分:
int[] varr = new int[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
Vector<Integer> v = VS256.fromArray(varr, 0);
标量 8、9、10 和 11 不在生成的数组中。以下是一个使用offset = 2 的另一个示例:
int[] varr = new int[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
Vector<Integer> v = VS256.fromArray(varr, 2);
这次,标量 0、1、10 和 11 不在生成的数组中。
注意,Java 数组的长度不应小于向量的长度。例如,以下示例将导致异常:
int[] varr = new int[]{0, 1, 2, 3, 4, 5};
IntVector v = IntVector.fromArray(VS256, varr, 0);
由于 Java 数组长度为 6(小于 8),这将导致java.lang.IndexOutOfBoundsException实例。因此,varr的最小接受长度为 8。
使用来自特定向量的 fromArray()方法
每个特定向量类都提供了一组fromArray()风味。例如,IntVector公开了流行的fromArray(VectorSpecies<Integer> species, int[] a, int offset)方法,它可以被直接使用:
int[] varr = new int[] {0, 1, 2, 3, 4, 5, 6, 7};
IntVector v = IntVector.fromArray(VS256, varr, 0);
如果我们更喜欢fromArray(VectorSpecies<Integer> species, int[] a, int offset, VectorMask<Integer> m)风味,那么我们可以通过VectorMask从 Java 数组中过滤选定的标量。以下是一个示例:
int[] varr = new int[]{0, 1, 2, 3, 4, 5, 6, 7};
boolean[] bm = new boolean[]{
false, false, true, false, false, true, true, false};
VectorMask m = VectorMask.fromArray(VS256, bm, 0);
IntVector v = IntVector.fromArray(VS256, varr, 0, m);
基于一对一的匹配,我们可以轻松观察到生成的向量将只获取标量 2、5 和 6。生成的向量将是:[0, 0, 2, 0, 0, 5, 6, 0]。
fromArray()的另一种风味是fromArray(VectorSpecies<Integer> species, int[] a, int offset, int[] indexMap, int mapOffset)。这次,我们使用索引映射来过滤选定的标量:
int[] varr = new int[]{11, 12, 15, 17, 20, 22, 29};
int[] imap = new int[]{0, 0, 0, 1, 1, 6, 6, 6};
IntVector v = IntVector.fromArray(VS256, varr, 0, imap, 0);
生成的数组将是:[11, 11, 11, 12, 12, 29, 29, 29]。我们有来自索引 0 的 11,来自索引 1 的 12,以及来自索引 6 的 29。
此外,我们可以通过fromArray(VectorSpecies<Integer> species, int[] a, int offset, int[] indexMap, int mapOffset, VectorMask<Integer> m)将VectorMask应用于之前的索引映射:
int[] varr = new int[]{11, 12, 15, 17, 20, 22, 29};
boolean[] bm = new boolean[]{
false, false, true, false, false, true, true, false};
int[] imap = new int[]{0, 0, 0, 1, 1, 6, 6, 6};
VectorMask m = VectorMask.fromArray(VS256, bm, 0);
IntVector v = IntVector.fromArray(VS256, varr, 0, imap, 0, m);
生成的向量是:[0, 0, 11, 0, 0, 29, 29, 0]。
从内存段创建向量
内存段是作为 Foreign Function 和 Memory API 的一部分在第七章中详细讨论的主题,但作为一个快速预告,以下是一个通过IntVector.fromMemorySegment()从内存段创建向量的示例:
IntVector v;
MemorySegment segment;
try (Arena arena = Arena.ofConfined()) {
segment = arena.allocate(32);
segment.setAtIndex(ValueLayout.JAVA_INT, 0, 11);
segment.setAtIndex(ValueLayout.JAVA_INT, 1, 21);
// continue set: 12, 7, 33, 1, 3
segment.setAtIndex(ValueLayout.JAVA_INT, 7, 6);
v = IntVector.fromMemorySegment(VS256, segment,
0, ByteOrder.nativeOrder());
}
创建的向量是:[11, 21, 12, 7, 33, 1, 3, 6]。
在捆绑的代码中,你可以找到更多关于在通道边界操作数据的示例,例如切片、非切片、洗牌/重新排列、压缩、扩展、转换、类型转换和重新解释形状。
在下一个问题中,我们将开始创建完整的示例,以利用我们迄今为止所学的内容。
109. 通过 Vector API 求两个数组的和
将两个数组相加是应用前面两个问题中学到的知识的完美起点。假设我们有以下 Java 数组:
int[] x = new int[]{1, 2, 3, 4, 5, 6, 7, 8};
int[] y = new int[]{4, 5, 2, 5, 1, 3, 8, 7};
通过 Vector API 计算z=x+y,我们必须创建两个Vector实例,并依赖于add()操作,z=x.add(y)。由于 Java 数组持有整数标量,我们可以使用IntVector特化如下:
IntVector xVector = IntVector.fromArray(
IntVector.SPECIES_256, x, 0);
IntVector yVector = IntVector.fromArray(
IntVector.SPECIES_256, y, 0);
在 Java 中,一个整数需要 4 个字节,即 32 位。由于x和y存储了 8 个整数,因此我们需要 8*32=256 位来在我们的向量中表示它们。所以,依赖SPECIES_256是正确的选择。
接下来,我们可以按照以下方式应用add()操作:
IntVector zVector = xVector.add(yVector);
完成了!现在是时候让 JVM 生成最优的指令集(数据并行加速代码),以计算我们的加法。结果将是一个向量,如[5, 7, 5, 9, 6, 9, 15, 15]。
这是一个简单的例子,但并不完全现实。谁会为了将只有几个元素的数组相加而使用并行计算能力呢?!在现实世界中,x和y可能包含比 8 个元素多得多的元素。最有可能的是,x和y有数百万个项,并且参与了多个计算周期。这正是我们可以利用并行计算力量的时刻。
但,现在,让我们假设x和y如下:
x = {3, 6, 5, 5, 1, 2, 3, 4, 5, 6, 7, 8, 3, 6, 5, 5, 1, 2, 3,
4, 5, 6, 7, 8, 3, 6, 5, 5, 1, 2, 3, 4, 3, 4};
y = {4, 5, 2, 5, 1, 3, 8, 7, 1, 6, 2, 3, 1, 2, 3, 4, 5, 6, 7,
8, 3, 6, 5, 5, 1, 2, 3, 4, 5, 6, 7, 8, 2, 8};
如果我们应用之前的代码(基于SPECIES_256),结果将相同,因为我们的向量只能容纳前 8 个标量,而忽略其余的。如果我们应用相同的逻辑但使用SPECIES_PREFERRED,那么结果是不可预测的,因为向量的形状特定于当前平台。然而,我们可以直观地认为我们将容纳前n个标量(无论n是多少),但不是所有的。
这次,我们需要分块数组,并使用循环遍历数组,计算z_chunk = x_chunk + y_chunk。将两个分块相加的结果收集在第三个数组(z)中,直到所有分块都处理完毕。我们定义的方法如下:
public static void sum(int x[], int y[], int z[]) {
...
但是,一个块应该有多大呢?第一个挑战体现在循环设计上。循环应该从 0 开始,但上界和步长是多少?通常,上界是x的长度,即 34。但是,使用x.length并不完全有用,因为它不能保证我们的向量可以容纳尽可能多的标量从数组中。我们正在寻找的是小于或等于x.length的最大VLENGTH(向量长度)的倍数。在我们的例子中,这是小于 34 的最大 8 的倍数,即 32。这正是loopBound()方法返回的值,因此我们可以将循环写成如下:
private static final VectorSpecies<Integer> VS256
= IntVector.SPECIES_256;
int upperBound = VS256.loopBound(x.length);
for (int i = 0; i < upperBound; i += VS256.length()) {
...
}
循环步长是向量的长度。以下图表预先展示了代码:

图 5.8:分块计算 z = x + y
因此,在第一次迭代中,我们的向量将容纳从索引 0 到 7 的标量。在第二次迭代中,标量是从索引 8 到 15,依此类推。以下是完整的代码:
private static final VectorSpecies<Integer> VS256
= IntVector.SPECIES_256;
public static void sum(int x[], int y[], int z[]) {
int upperBound = VS256.loopBound(x.length);
for (int i = 0; i < upperBound; i += VS256.length()) {
IntVector xVector = IntVector.fromArray(VS256, x, i);
IntVector yVector = IntVector.fromArray(VS256, y, i);
IntVector zVector = xVector.add(yVector);
zVector.intoArray(z, i);
}
}
intoArray(int[] a, int offset) 方法将标量从向量传输到 Java 数组。这个方法与 intoMemorySegment() 方法类似,有多种变体。
结果数组将是:[7, 11, 7, 10, 2, 5, 11, 11, 6, 12, 9, 11, 4, 8, 8, 9, 6, 8, 10, 12, 8, 12, 12, 13, 4, 8, 8, 9, 6, 8, 10, 12, 0, 0]。查看最后两个项目……它们等于 0。这些是从 x.length - upperBound = 34 – 32 = 2 得出的项。当 VLENGTH(向量长度)的最大倍数等于 x.length 时,这个差值将是 0,否则,我们将有未计算完的剩余项。因此,之前的代码只有在 VLENGTH(向量长度)等于 x.length 的特定情况下才会按预期工作。
至少有两种方法可以覆盖剩余的项。首先,我们可以依赖 VectorMask,如下面的代码所示:
public static void sumMask(int x[], int y[], int z[]) {
int upperBound = VS256.loopBound(x.length);
int i = 0;
for (; i < upperBound; i += VS256.length()) {
IntVector xVector = IntVector.fromArray(VS256, x, i);
IntVector yVector = IntVector.fromArray(VS256, y, i);
IntVector zVector = xVector.add(yVector);
zVector.intoArray(z, i);
}
**if** **(i <= (x.length -** **1****)) {**
**VectorMask<Integer> mask**
**= VS256.indexInRange(i, x.length);**
**IntVector****zVector****=** **IntVector.fromArray(VS256, x, i, mask)**
**.add(IntVector.fromArray(VS256, y, i, mask));**
**zVector.intoArray(z, i, mask);**
**}**
}
indexInRange() 计算一个 [i, x.length-1] 范围内的掩码。应用此掩码将产生以下 z 数组:[7, 11, 7, 10, 2, 5, 11, 11, 6, 12, 9, 11, 4, 8, 8, 9, 6, 8, 10, 12, 8, 12, 12, 13, 4, 8, 8, 9, 6, 8, 10, 12, 5, 12]。现在,最后两个项目按预期计算。
重要提示
作为一条经验法则,避免在循环中使用 VectorMask。它们相当昂贵,可能会导致性能显著下降。
处理这些剩余项的另一种方法是使用以下传统 Java 代码:
public static void sumPlus(int x[], int y[], int z[]) {
int upperBound = VS256.loopBound(x.length);
int i = 0;
for (; i < upperBound; i += VS256.length()) {
IntVector xVector = IntVector.fromArray(VS256, x, i);
IntVector yVector = IntVector.fromArray(VS256, y, i);
IntVector zVector = xVector.add(yVector);
zVector.intoArray(z, i);
}
for (; i < x.length; i++) {
z[i] = x[i] + y[i];
}
}
实际上,我们在向量循环外部使用 Java 传统循环来累加剩余的项。您可以在捆绑的代码中查看这些示例。
110. 通过 Vector API 非展开方式求和两个数组
在这个问题中,我们以从上一个问题中求和两个数组为例,并以非展开的方式重写循环。
循环展开可以手动应用(正如我们将在这里做的那样)或由编译器应用,它代表一种旨在减少循环迭代次数的优化技术。
在我们这个例子中,为了减少循环迭代的次数,我们使用更多的向量来重复循环体中负责求和项的语句序列。如果我们知道我们的数组足够长,总是需要至少 4 次循环迭代,那么按照以下方式重写代码将减少 4 倍的循环迭代次数:
public static void sumUnrolled(int x[], int y[], int z[]) {
int width = VS256.length();
int i = 0;
for (; i <= (x.length - width * 4); i += width * 4) {
IntVector s1 = IntVector.fromArray(VS256, x, i)
.add(IntVector.fromArray(VS256, y, i));
IntVector s2 = IntVector.fromArray(VS256, x, i + width)
.add(IntVector.fromArray(VS256, y, i + width));
IntVector s3 = IntVector.fromArray(VS256, x, i + width * 2)
.add(IntVector.fromArray(VS256, y, i + width * 2));
IntVector s4 = IntVector.fromArray(VS256, x, i + width * 3)
.add(IntVector.fromArray(VS256, y, i + width * 3));
s1.intoArray(z, i);
s2.intoArray(z, i + width);
s3.intoArray(z, i + width * 2);
s4.intoArray(z, i + width * 3);
}
for (; i < x.length; i++) {
z[i] = x[i] + y[i];
}
}
考虑以下 x 和 y 向量:
x = {3, 6, 5, 5, 1, 2, 3, 4, 5, 6, 7, 8, 3, 6, 5, 5, 1, 2, 3,
4, 5, 6, 7, 8, 3, 6, 5, 5, 1, 2, 3, 4, 3, 4};
y = {4, 5, 2, 5, 1, 3, 8, 7, 1, 6, 2, 3, 1, 2, 3, 4, 5, 6, 7,
8, 3, 6, 5, 5, 1, 2, 3, 4, 5, 6, 7, 8, 2, 8};
int[] z = new int[x.length];
调用之前问题中编写的 sumPlus(x, y, z) 方法需要 4 次循环迭代才能完成。调用 sumUnrolled(x, y, z) 将只需要一次迭代即可完成。
111. 基准测试 Vector API
通过 JMH 可以完成对 Vector API 的基准测试。让我们考虑三个包含 5000 万个整数的 Java 数组(x、y、z),以及以下计算:
z[i] = x[i] + y[i];
w[i] = x[i] * z[i] * y[i];
k[i] = z[i] + w[i] * y[i];
因此,最终结果存储在名为 k 的 Java 数组中。让我们考虑以下包含四种不同计算实现(使用掩码、不使用掩码、展开和纯标量 Java 数组)的基准测试:
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@BenchmarkMode({Mode.AverageTime, Mode.Throughput})
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@State(Scope.Benchmark)
@Fork(value = 1, warmups = 0,
jvmArgsPrepend = {"--add-modules=jdk.incubator.vector"})
public class Main {
private static final VectorSpecies<Integer> VS
= IntVector.SPECIES_PREFERRED;
...
@Benchmark
public void computeWithMask(Blackhole blackhole) {…}
@Benchmark
public void computeNoMask(Blackhole blackhole) {…}
@Benchmark
public void computeUnrolled(Blackhole blackhole) {…}
@Benchmark
public void computeArrays(Blackhole blackhole) {…}
}
在一个运行 Windows 10 的 Intel(R) Core(TM) i7-3612QM CPU @ 2.10GHz 的机器上运行这个基准测试产生了以下结果:

图 5.9:基准结果
总体而言,使用数据并行能力执行计算提供了最佳性能,最高吞吐量和最佳平均时间。
112. 将 Vector API 应用于计算 FMA
简而言之,融合乘加(FMA)是数学计算(ab)+ c,这在矩阵乘法中被大量使用。这就是我们为这个问题需要涵盖的所有内容,但如果您需要 FMA 的入门知识,请考虑Java 编码问题,第一版,第一章,问题 38*。
通过 Vector API 实现 FMA 可以通过fma(float b, float c)或fma(Vector<Float> b, Vector<Float> c)操作完成,后者是您将在接下来的示例中看到的。
假设我们有两个以下数组:
float[] x = new float[]{1f, 2f, 3f, 5f, 1f, 8f};
float[] y = new float[]{4f, 5f, 2f, 8f, 5f, 4f};
计算 FMA(x, y)可以表示为以下序列:4+0=4 → 10+4=14 → 6+14=20 → 40+20=60 → 5+60=65 → 32+65=97。所以,FMA(x, y) = 97。通过 Vector API 表达这个序列可以像以下代码所示:
private static final VectorSpecies<Float> VS
= FloatVector.SPECIES_PREFERRED;
public static float vectorFma(float[] x, float[] y) {
int upperBound = VS.loopBound(x.length);
FloatVector sum = FloatVector.zero(VS);
int i = 0;
for (; i < upperBound; i += VS.length()) {
FloatVector xVector = FloatVector.fromArray(VS, x, i);
FloatVector yVector = FloatVector.fromArray(VS, y, i);
sum = xVector.fma(yVector, sum);
}
if (i <= (x.length - 1)) {
VectorMask<Float> mask = VS.indexInRange(i, x.length);
FloatVector xVector = FloatVector.fromArray(
VS, x, i, mask);
FloatVector yVector = FloatVector.fromArray(
VS, y, i, mask);
sum = xVector.fma(yVector, sum);
}
float result = sum.reduceLanes(VectorOperators.ADD);
return result;
}
你有没有注意到代码行sum = xVector.fma(yVector, sum)?这相当于sum = xVector.mul(yVector).add(sum)。
这里的新特性包括以下这一行:
float result = sum.reduceLanes(VectorOperators.ADD);
这是一个关联的跨通道减少操作(见图 5.6)。在这行代码之前,总和向量看起来如下:
sum= [9.0, 42.0, 6.0, 40.0]
通过应用reduceLanes(VectorOperators.ADD),我们将这个向量的值相加并减少到最终结果,97.0。酷,对吧?!
113. 通过 Vector API 乘法矩阵
让我们考虑两个 4x4 的矩阵,分别用X和Y表示。Z=X*Y的结果如下:

图 5.10:乘以两个矩阵(X * Y = Z)
将X与Y相乘意味着将X的第一行与Y的第一列相乘,将X的第二行与Y的第二列相乘,依此类推。例如,(1 x 3) + (2 x 7) + (5 x 5) + (4 x 5) = 3 + 14 + 25 + 20 = 62。基本上,我们反复应用 FMA 计算并将结果填充到Z中。
在这个背景下,基于之前关于计算 FMA 的问题,我们可以为乘以X和Y生成以下代码:
private static final VectorSpecies<Float> VS
= FloatVector.SPECIES_PREFERRED;
public static float[] mulMatrix(
float[] x, float[] y, int size) {
final int upperBound = VS.loopBound(size);
float[] z = new float[size * size];
for (int i = 0; i < size; i++) {
for (int k = 0; k < size; k++) {
float elem = x[i * size + k];
FloatVector eVector = FloatVector.broadcast(VS, elem);
for (int j = 0; j < upperBound; j += VS.length()) {
FloatVector yVector = FloatVector.fromArray(
VS, y, k * size + j);
FloatVector zVector = FloatVector.fromArray(
VS, z, i * size + j);
zVector = eVector.fma(yVector, zVector);
zVector.intoArray(z, i * size + j);
}
}
}
return z;
}
在捆绑的代码中,您可以在使用SPECIES_512的另一个示例旁边找到这个示例。
114. 使用 Vector API 将图像负片滤镜连接起来
一张图像基本上是一个以Alpha, Red, Green, Blue(ARGB)频谱表示的像素矩阵。例如,一个 232x290 的图像可以表示为一个包含 67,280 个像素的矩阵。对图像应用特定的过滤器(如棕褐色、负片、灰度等)通常需要处理这个矩阵中的每个像素并执行某些计算。例如,应用负片滤镜到图像的算法可以如下使用:

图 5.11:将负片滤镜效果应用于图像
对于每个像素,我们提取颜色分量 A、R、G 和 B。我们从 255 中减去 R、G 和 B 的值,并将新值设置为当前像素。
假设我们有一个包含图像所有像素的数组(pixel[])。接下来,我们想要将pixel[]作为参数传递给一个由 Vector API 支持的方法,该方法能够应用负滤波器并在pixel[]中直接设置新值。
这里是一个可能的实现:
private static final VectorSpecies<Integer> VS
= IntVector.SPECIES_PREFERRED;
public static void negativeFilter(
int pixel[], int width, int height) {
for (int i = 0; i <= (width * height - VS.length());
i += VS.length()) {
IntVector alphaVector = IntVector.fromArray(VS, pixel, i)
.lanewise(VectorOperators.LSHR, 24).and(0xff);
IntVector redVector = IntVector.fromArray(VS, pixel, i)
.lanewise(VectorOperators.LSHR, 16).and(0xff);
IntVector greenVector = IntVector.fromArray(VS, pixel, i)
.lanewise(VectorOperators.LSHR, 8).and(0xff);
IntVector blueVector = IntVector.fromArray(VS, pixel, i)
.and(0xff);
IntVector subAlphaVector
= alphaVector.lanewise(VectorOperators.LSHL, 24);
IntVector subRedVector = redVector.broadcast(255)
.sub(redVector).lanewise(VectorOperators.LSHL, 16);
IntVector subGreenVector = greenVector.broadcast(255)
.sub(greenVector).lanewise(VectorOperators.LSHL, 8);
IntVector subBlueVector
= blueVector.broadcast(255).sub(blueVector);
IntVector resultVector = subAlphaVector.or(subRedVector)
.or(subGreenVector).or(subBlueVector);
resultVector.intoArray(pixel, i);
}
}
在第一部分,我们通过应用LSHR 逐行操作将 A、R、G 和 B 提取为四个向量(alphaVector、redVector、greenVector和blueVector)。之后,我们从 255 中减去 R、G 和 B,并通过应用LSHL 逐行操作计算新的 R、G 和 B。接下来,我们通过应用新的 A、R、G 和 B 值之间的位逻辑异或(|)来计算新的颜色。最后,我们将新的颜色设置在pixel[]数组中。
115. 解构集合的工厂方法
使用集合的工厂方法是必备技能。在将它们投入使用之前,能够快速轻松地创建和填充不可修改/不可变的集合是非常方便的。
映射的工厂方法
例如,在 JDK 9 之前,创建不可修改的映射可以这样完成:
Map<Integer, String> map = new HashMap<>();
map.put(1, "Java Coding Problems, First Edition");
map.put(2, "The Complete Coding Interview Guide in Java");
map.put(3, "jOOQ Masterclass");
Map<Integer, String> imap = Collections.unmodifiableMap(map);
这在某个时候你需要从可修改的映射中获取不可修改的映射时很有用。否则,你可以采取以下捷径(这被称为双括号初始化技术,通常是一个反模式):
Map<Integer, String> imap = Collections.unmodifiableMap(
new HashMap<Integer, String>() {
{
put(1, "Java Coding Problems, First Edition");
put(2, "The Complete Coding Interview Guide in Java");
put(3, "jOOQ Masterclass");
}
});
如果你需要从Stream中的java.util.Map.entry返回不可修改/不可变的映射,那么请看这里:
Map<Integer, String> imap = Stream.of(
entry(1, "Java Coding Problems, First Edition"),
entry(2, "The Complete Coding Interview Guide in Java"),
entry(3, "jOOQ Masterclass"))
.collect(collectingAndThen(
toMap(e -> e.getKey(), e -> e.getValue()),
Collections::unmodifiableMap));
此外,我们不要忘记空映射和单例映射(非常有用,可以从方法中返回映射而不是null):
Map<Integer, String> imap = Collections.emptyMap();
Map<Integer, String> imap = Collections.singletonMap(
1, "Java Coding Problems, First Edition");
从 JDK 9 开始,我们可以依赖一个更方便的方法来创建不可修改/不可变的映射,这得益于 JEP 269:集合的便利工厂方法。这种方法包括Map.of(),它从 0 到 10 个映射可用,换句话说,它被重载以支持 0 到 10 个键值对。在这里,我们使用Map.of()进行三个映射:
Map<Integer, String> imap = Map.of(
1, "Java Coding Problems, First Edition",
2, "The Complete Coding Interview Guide in Java",
3, "jOOQ Masterclass"
);
通过Map.of()创建的映射不允许null键或值。此类尝试将导致NullPointerException。
如果你需要超过 10 个映射,则可以依赖static <K,V> Map<K,V> ofEntries(Entry<? Extends K,? extends V>... entries),如下所示:
import static java.util.Map.entry;
...
Map<Integer, String> imap2jdk9 = Map.ofEntries(
entry(1, "Java Coding Problems, First Edition"),
entry(2, "The Complete Coding Interview Guide in Java"),
entry(3, "jOOQ Masterclass")
);
最后,可以通过static <K,V> Map<K,V> copyOf(Map<? extends K,? extends V> map)从现有的映射创建不可修改/不可变的映射:
Map<Integer, String> imap = Map.copyOf(map);
如果给定的映射不可修改,那么 Java 很可能会不创建一个副本,而是返回一个现有的实例。换句话说,imap == map将返回true。如果给定的映射是可修改的,那么工厂很可能会返回一个新实例,因此imap == map将返回false。
列表的工厂方法
在 JDK 9 之前,可以使用可修改的List创建具有相同内容的不可修改List,如下所示:
List<String> list = new ArrayList<>();
list.add("Java Coding Problems, First Edition");
list.add("The Complete Coding Interview Guide in Java");
list.add("jOOQ Masterclass");
List<String> ilist = Collections.unmodifiableList(list);
创建 List 的一个常见方法是通过使用 Arrays.asList():
List<String> ilist = Arrays.asList(
"Java Coding Problems, First Edition",
"The Complete Coding Interview Guide in Java",
"jOOQ Masterclass"
);
然而,请记住,这是一个固定大小的列表,不是一个不可修改/不可变列表。换句话说,尝试修改列表大小(例如,ilist.add(…))的操作将导致 UnsupportedOperationException,而修改列表当前内容的操作(例如,ilist.set(…))是允许的。
如果你需要从 Stream 返回一个不可修改/不可变的 List,那么请看这里:
List<String> ilist = Stream.of(
"Java Coding Problems, First Edition",
"The Complete Coding Interview Guide in Java",
"jOOQ Masterclass")
.collect(collectingAndThen(toList(),
Collections::unmodifiableList));
此外,创建一个空/单例列表可以这样做:
List<String> ilist = Collections.emptyList();
List<String> ilist = Collections.singletonList(
"Java Coding Problems, First Edition");
从 JDK 9+ 开始,使用 0 到 10 个元素的 List.of() 工厂方法更为方便(不允许 null 元素):
List<String> ilist = List.of(
"Java Coding Problems, First Edition",
"The Complete Coding Interview Guide in Java",
"jOOQ Masterclass");
如果你需要一个现有列表的副本,则依赖 List.copyOf():
List<String> ilist = List.copyOf(list);
如果给定的列表是不可修改的,那么 Java 很可能不会创建一个副本,并返回一个现有实例。换句话说,ilist == list 将返回 true。如果给定的列表是可修改的,那么工厂很可能返回一个新实例,因此 ilist == list 将返回 false。
集合的工厂方法
创建 Set 实例遵循与 List 实例相同的路径。然而,请注意,没有 singletonSet()。要创建一个单例集合,只需调用 singleton():
Set<String> iset = Collections.singleton(
"Java Coding Problems, First Edition");
你可以在捆绑的代码中找到更多示例。你可能还对来自《Java 编码问题》第一版的问题 109感兴趣,它涵盖了不可修改与不可变集合的比较。此外,请考虑这里提出的下一个问题,因为它提供了更多关于这个上下文的信息。
116. 从流中获取列表
将 Stream 收集到一个 List 中是一个在处理流和集合的应用程序中普遍存在的流行任务。
在 JDK 8 中,可以通过以下方式使用 toList() 收集器将 Stream 收集到一个 List 中:
List<File> roots = Stream.of(File.listRoots())
.collect(Collectors.toList());
从 JDK 10 开始,我们可以依赖 toUnmodifiableList() 收集器(对于映射,使用 toUnmodifiableMap(),对于集合,使用 toUnmodifiableSet()):
List<File> roots = Stream.of(File.listRoots())
.collect(Collectors.toUnmodifiableList());
显然,返回的列表是一个不可修改/不可变列表。
JDK 16 在 Stream 接口中引入了以下 toList() 默认方法:
default List<T> toList() {
return (List<T>) Collections.unmodifiableList(
new ArrayList<>(Arrays.asList(this.toArray())));
}
使用此方法将 Stream 收集到一个不可修改/不可变列表中很简单(请注意,这与 Collectors.toList() 不同,它返回一个可修改的列表):
List<File> roots = Stream.of(File.listRoots()).toList();
在捆绑的代码中,你还可以找到一个结合 flatMap() 和 toList() 的示例。
117. 处理映射容量
假设我们需要一个可以容纳 260 个项目的 List。我们可以这样做:
List<String> list = new ArrayList<>(260);
ArrayList 底层的数组直接创建以容纳 260 个项目。换句话说,我们可以插入 260 个项目,而不用担心调整或扩大列表以容纳这些 260 个项目。
按照这个逻辑,我们也可以为映射重现它:
Map<Integer, String> map = new HashMap<>(260);
因此,现在我们可以假设我们有一个可以容纳 260 个映射的映射表。实际上,不,这个假设是不正确的!HashMap 依据 散列 原则工作,并使用初始容量(如果没有提供明确的初始容量,则为 16)初始化,表示内部桶的数量和默认的 加载因子 0.75。这意味着什么?这意味着当 HashMap 达到当前容量的 75% 时,它的大小将加倍,并发生重新散列。这保证了映射在内部桶中均匀分布。但是,对于显著大的映射,这是一个昂贵的操作。Javadoc 表明,“创建一个具有足够大容量的 HashMap 将允许映射以比让它根据需要自动重新散列以增长表更有效的方式存储。”
在我们的情况下,这意味着一个映射表可以容纳 260 x 0.75 = 195 个映射。换句话说,当我们插入第 195 个映射时,映射表将自动调整大小到 260 * 2 = 520 个映射。
要创建一个用于 260 个映射的 HashMap,我们必须计算初始容量为映射数/加载因子:260 / 0.75 = 347 个映射:
// accommodate 260 mappings without resizing
Map<Integer, String> map = new HashMap<>(347);
或者,如果我们想用公式表达,可以这样做:
Map<Integer, String> map = new HashMap<>(
(int) Math.ceil(260 / (double) 0.75));
从 JDK 19 开始,这个公式被隐藏在 static <K,V> HashMap<K,V> newHashMap(int numMappings) 方法后面。这次,numMappings 代表映射的数量,因此我们可以这样写:
// accommodate 260 mappings without resizing
Map<Integer, String> map = HashMap.newHashMap(260);
类似的方法存在于 HashSet、LinkedHashSet、LinkedHashMap 和 WeakHashMap 中。
118. 解决有序集合问题
Sequenced Collections API 作为 JDK 21 的最后一个特性,在 JEP 431 下被添加。其主要目标是通过提供一个通用的 API,使所有具有良好定义的遍历顺序的集合的导航更加容易。
一个具有良好定义的遍历顺序的 Java 集合有一个良好定义的第一个元素、第二个元素,依此类推,直到最后一个元素。遍历顺序是 Iterator 遍历集合元素(列表、集合、有序集合、映射等)的顺序。遍历顺序可以利用时间上的稳定性(列表)或不稳定性(集合)。
此 API 由 3 个接口组成,名为 SequencedCollection(适用于任何具有良好定义的遍历顺序的集合)、SequencedSet(扩展 SequencedCollection 和 Set 以提供对 Java 集合的支持),以及 SequencedMap(扩展 Map 以支持任何具有良好定义的遍历顺序的 Java 映射)。在以下图中,您可以看到这三个接口在集合类型层次结构中的位置:

图 5.12:Sequenced Collections API 在集合类型层次结构中的位置
SequencedCollection API 针对集合的四个主要操作:获取第一个/最后一个元素,在第一个/最后一个位置添加新元素,移除第一个/最后一个元素,以及反转集合。
SequencedCollection API 定义了 7 个方法,如下所示:
-
getFirst()获取当前集合的第一个元素 -
getLast()获取当前集合的最后一个元素 -
addFirst(E e)将给定的元素e添加为当前集合的第一个元素 -
addLast(E e)将给定的元素e添加为当前集合的最后一个元素 -
removeFirst()移除当前集合的第一个元素 -
removeLast()移除当前集合的最后一个元素 -
reversed()返回当前集合的反向集合
SequencedSet 扩展 SequencedCollection 并重写 reversed() 方法以返回 SequencedSet。
SequencedMap 定义了以下方法:
-
firstEntry()返回当前映射的第一个条目(第一个键值映射) -
lastEntry()返回当前映射的最后一个条目(最后一个键值映射) -
putFirst(K k, V v)尝试将给定的键值映射作为当前映射中的第一个映射(或替换)插入 -
putLast(K k, V v)尝试将给定的键值映射作为当前映射中的最后一个映射(或替换)插入 -
pollFirstEntry()从当前映射中移除并返回第一个条目(第一个键值映射)(如果没有条目,则返回null) -
pollLastEntry()从当前映射中移除并返回最后一个条目(最后一个键值映射)(如果没有条目,则返回null) -
reversed()返回当前映射的反向映射 -
sequencedEntrySet()返回当前映射的条目集(entrySet())的SequencedSet视图 -
sequencedKeySet()返回当前映射的键集(keyset())的SequencedSet视图 -
sequencedValues()返回当前映射的值(values())的SequencedCollection视图
明确的遍历顺序是集合类型层次结构中的一个属性,因此我们必须考虑 Sequenced Collections API 完全适用于某些集合,部分适用于其他集合,而对于其他集合则完全不适用。让我们处理一些常见的集合并尝试这个新的 API。
将 Sequenced Collections API 应用到列表中
Java 列表(List 的实现)依赖于索引来支持明确的(稳定的)遍历顺序,因此它们是 Sequenced Collections API 的完美候选。
接下来,让我们看看如何利用 Sequenced Collections API 来处理 List 的两种最流行的实现。显然,我们正在谈论 ArrayList 和 LinkedList。ArrayList 和 LinkedList 实现 SequencedCollection。
将 Sequenced Collections API 应用到 ArrayList 和 LinkedList
假设我们有以下 ArrayList 和 LinkedList:
List<String> list = new ArrayList<>(
Arrays.asList("one", "two", "three", "four", "five"));
List<String> linkedlist = new LinkedList<>(
Arrays.asList("one", "two", "three", "four", "five"));
从 ArrayList 中获取第一个元素相当简单。第一个元素位于索引 0,因此调用 get(0) 就足够了:
String first = list.get(0); // one
获取最后一个元素稍微有点复杂。我们不知道最后一个元素的索引,但我们知道列表的大小,因此我们可以写出这个(它不是那么整洁,但它是有效的):
String last = list.get(list.size() - 1); // five
另一方面,如果我们依赖于 JDK 21 Sequenced Collections API,则可以如下获取第一个和最后一个元素:
String first = list.getFirst(); // one
String last = list.getLast(); // five
这真的很方便,同样也适用于 LinkedList!我们不需要任何显式的索引。
在第一个位置添加一个元素意味着通过已知的 add(index, element) 方法在索引 0 处添加一个元素。此外,在最后一个位置添加一个元素意味着调用没有显式索引的 add(element) 方法,如下所示:
list.add(0, "zero"); // add on the first position
list.add("six"); // add on the last position
通过 Sequenced Collections API 添加相同的元素可以这样做:
list.addFirst("zero");
list.addLast("six");
通过 remove() 方法和适当的索引移除第一个/最后一个元素,如下所示:
list.remove(0); // remove the first element
list.remove(list.size() - 1); // remove the last element
如果我们知道最后一个元素的值,我们可以不使用显式索引来移除它,如下所示:
list.remove("five");
通过 Sequenced Collections API 移除相同的元素可以这样做:
list.removeFirst();
list.removeLast();
因此,使用 Sequenced Collections API 非常简单。不需要任何参数。同样,这也适用于 LinkedList。
通过 Collections.reverse() 辅助方法可以反转列表。此方法反转给定的列表:
Collections.reverse(list);
另一方面,Sequenced Collections API 返回一个新的列表,表示给定列表的逆序,如下所示:
List<String> reversedList = list.reversed();
再次,相同的代码也适用于 LinkedList。因此,Sequenced Collections API 对于列表来说工作得非常完美。
将 Sequenced Collections API 应用到集合中
Java 集合(Set 的实现)可以分为两类。我们有有序集合(SortedSet 的实现),它支持一个定义良好的(稳定的)遍历顺序,以及不保证遍历顺序的集合(HashSet)。
SortedSet 有一个由比较器逻辑(自然排序 或 Comparator)决定的顺序。当我们向有序集合中插入一个新元素时,比较器逻辑决定这个元素将落在何处,因此我们不知道元素的索引值。然而,有序集合有第一个和最后一个元素的概念,并且 Iterator 将按照比较器确定的顺序遍历元素。
另一方面,像 HashSet 这样的集合没有遍历顺序的保证。HashSet 的元素按其内部哈希算法排序。HashSet 上的 Iterator 按无特定顺序遍历其元素,并且当我们插入一个新元素时,我们不知道这个元素将在 HashSet 中落在何处。
接下来,让我们看看如何利用 Sequenced Collections API 来处理三种最受欢迎的集合。我们选择了 HashSet(Set 的一个实现),LinkedHashSet(HashSet 的一个扩展),以及 TreeSet(一个扩展 SortedSet 的 NavigableSet 实现)。
将 Sequenced Collections API 应用到 HashSet
HashSet 按无特定顺序迭代其元素,这意味着 HashSet 没有关于第一个、第二个或最后一个元素(稳定性)的了解。多次迭代相同的 HashSet 可能会产生不同的输出。在这种情况下,我们可以通过 add(E e) 方法将一个元素添加到集合中。如果该元素不存在,它将被添加,并落在由 HashSet 内部哈希算法计算出的位置。此外,我们可以通过 remove(Object o) 方法通过值移除一个元素。由于元素的顺序是不稳定的,反转 HashSet 没有意义。在这种情况下,Sequenced Collections API 完全不起作用,因此 HashSet 不会利用这个 API。
将 Sequenced Collections API 应用到 LinkedHashSet
LinkedHashSet 是一个依赖于双链表来维护良好定义的遍历顺序的 HashSet。LinkedHashSet 实现 SequencedSet,因此可以利用 Sequenced Collections API。让我们深入探讨以下 LinkedHashSet:
SequencedSet<String> linkedhashset = new LinkedHashSet<>(
Arrays.asList("one", "two", "three", "four", "five"));
LinkedHashSet 没有公开用于获取第一个/最后一个元素的 API。然而,我们可以依赖于定义良好的遍历顺序以及 Iterator(或 Stream)API 来获取第一个元素,如下所示:
linkedhashset.iterator().next();
linkedhashset.stream().findFirst().get();
这并不整洁,对于最后一个元素来说甚至更糟:
linkedhashset.stream().skip(
linkedhashset.size() - 1).findFirst().get();
String last = (String) linkedhashset.toArray()
[linkedhashset.size() - 1];
幸运的是,JDK 21 通过 getFirst() 和 getLast() 方法简化了这个任务:
linkedhashset.getFirst();
linkedhashset.getLast();
将元素添加到 LinkedHashSet 中仅当该元素不存在时才可能。这是正常的,因为集合不接受重复的元素,就像列表一样。然而,在第一个位置添加元素并不是一件容易的事情(如果我们把 LinkedHashSet 转换为另一个集合,在第一个位置添加元素,然后再转换回 LinkedHashSet,我们就可以做到这一点),所以我们在这里跳过了这一点。通过 add() 方法将元素添加到最后位置是很容易的:
// cannot add on first position
linkedhashset.add("six"); // add on last position
但如果我们依赖 Sequenced Collections API,那么我们可以通过 addFirst()/addLast() 在第一个/最后一个位置添加元素:
linkedhashset.addFirst("zero");
linkedhashset.addLast("six");
仅当我们知道这些元素的值时,才能移除第一个/最后一个元素:
linkedhashset.remove("one");
linkedhashset.remove("five");
显然,这种方法并不稳健和安全,你更愿意像之前通过 Iterator/Stream 那样获取第一个/最后一个元素,然后在那些元素上调用 remove()。然而,更合适的选项是依赖 Sequenced Collections API:
linkedhashset.removeFirst();
linkedhashset.removeLast();
除了使用 Sequenced Collections API 之外,没有其他直接的方法可以反转 LinkedHashSet:
SequencedSet<String> reversedLhs = linkedhashset.reversed();
所以,正如你所见,Sequenced Collections API 真实地简化了 LinkedHashSet 的使用。酷!
将 Sequenced Collections API 应用到 TreeSet
TreeSet 是一个实现了 NavigableSet(SortedSet 的扩展)的有序集合,因此它利用了所有有序集合的方法以及一些导航方法。它还实现了 SequencedCollection 和 SequencedSet。
让我们考虑以下 TreeSet:
SortedSet<String> sortedset = new TreeSet<>(
Arrays.asList("one", "two", "three", "four", "five"));
依赖于字符串的默认比较器(即自然排序,按字典顺序比较两个字符串),排序后的集合将是 five,four,one,three,two。因此,第一个元素是 five,最后一个元素是 two。可以通过 first() 和 last() 方法获取排序集合的第一个/最后一个元素:
sortedset.first();
sortedset.last();
因此,在这种情况下,序列化集合 API 并不带来显著的价值:
sortedset.getFirst();
sortedset.getLast();
在排序集合的第一个/最后一个位置添加新元素是不可能的。由于元素的顺序由比较器(自然排序或显式的 Comparator)决定,我们无法保证添加的元素会落在第一个或最后一个位置。例如,以下代码不会添加我们可能期望的元素(zero 作为第一个元素,six 作为最后一个元素):
sortedset.add("zero");
sortedset.add("six");
在应用字典顺序标准后,得到的排序集合将是 five,four,one,six,three,two,zero。所以,zero 实际上是最后一个元素,six 是第四个元素。
尝试应用序列化集合 API(addFirst()/addLast())将抛出 UnsupportedOperationException 异常。
那么从排序集合中移除第一个/最后一个元素怎么办?由于我们可以获取树集合的第一个/最后一个元素,我们也可以按照以下方式移除它们:
String first = sortedset.first();
sortedset.remove(first);
String last = sortedset.last();
sortedset.remove(last);
序列化集合 API 的实现代表了之前代码的快捷方式:
sortedset.removeFirst();
sortedset.removeLast();
通过 descendingSet() 或 descendingIterator() 可以反转排序集合。这两个方法都在 TreeSet 中可用,所以这里是如何使用 descendingSet() 的示例:
SortedSet<String> reversedSortedSet
= new TreeSet<>(sortedset).descendingSet();
依赖于序列化集合 API 如下会更加整洁:
SortedSet<String> reversedSortedSet = sortedset.reversed();
好吧,对吧?!
将序列化集合 API 应用于映射
Java 映射(Map的实现)可能有定义良好的遍历顺序(例如,LinkedHashMap(Map和SequencedMap的实现),TreeMap(SortedMap和SequencedMap的实现))或随时间变化的不稳定顺序(例如,HashMap的Map实现)。正如在 HashSet 的情况下,HashMap无法利用序列化集合 API。
将序列化集合 API 应用于 LinkedHashMap
LinkedHashMap 是一个具有定义良好遍历顺序的映射。以下是一个示例:
SequencedMap<Integer, String> linkedhashmap
= new LinkedHashMap<>();
linkedhashmap.put(1, "one");
linkedhashmap.put(2, "two");
linkedhashmap.put(3, "three");
linkedhashmap.put(4, "four");
linkedhashmap.put(5, "five");
通过 Iterator/Stream API 从链接哈希映射中获取第一个条目(Map.Entry)如下所示:
linkedhashmap.entrySet().iterator().next();
linkedhashmap.entrySet().stream().findFirst().get();
同样的逻辑可以应用于获取第一个键(通过 keyset())或第一个值(通过 values()):
linkedhashmap.keySet().iterator().next();
linkedhashmap.keySet().stream().findFirst().get();
linkedhashmap.values().iterator().next();
linkedhashmap.values().stream().findFirst().get();
获取最后一个条目/键/值需要比之前的代码更丑陋的代码:
linkedhashmap.entrySet().stream()
.skip(linkedhashmap.size() - 1).findFirst().get();
Entry<Integer, String> lastEntryLhm = (Entry<Integer, String>)
linkedhashmap.entrySet().toArray()[linkedhashmap.size() - 1];
linkedhashmap.keySet().stream()
.skip(linkedhashmap.size() - 1).findFirst().get();
Integer lastKeyLhm = (Integer) linkedhashmap.keySet()
.toArray()[linkedhashmap.size() - 1];
linkedhashmap.values().stream()
.skip(linkedhashmap.size() - 1).findFirst().get();
String lastValueLhm = (String) linkedhashmap.values()
.toArray()[linkedhashmap.size() - 1];
在这种情况下,序列化集合 API 对于避免这种痛苦且繁琐的代码非常有用。例如,通过序列化集合 API 从 LinkedHashMap 获取第一个元素可以通过 firstEntry()/lastEntry() 如下完成:
Entry<Integer, String> fe = linkedhashmap.firstEntry();
Entry<Integer, String> le = linkedhashmap.lastEntry();
虽然没有 firstKey()/lastKey() 或 firstValue()/lastValue(),但我们可以通过 sequencedKeySet() 和 sequencedValues() 获取第一个键/值,如下所示:
SequencedSet<Integer> keysLinkedHashMap
= linkedhashmap.sequencedKeySet();
keysLinkedHashMap.getFirst();
keysLinkedHashMap.getLast();
SequencedCollection<String> valuesLinkedHashMap
= linkedhashmap.sequencedValues();
valuesLinkedHashMap.getFirst();
valuesLinkedHashMap.getLast();
同样的逻辑也可以通过 sequencedEntrySet() 应用于条目:
SequencedSet<Entry<Integer, String>> entriesLinkedHashMap
= linkedhashmap.sequencedEntrySet();
entriesLinkedHashMap.getFirst();
entriesLinkedHashMap.getLast();
但,显然,使用 firstEntry()/lastEntry() 更整洁。
通过简单地调用 put(K key, V value) 在最后一个位置添加新条目是可能的。然而,在第一个位置添加新条目并不那么容易。但,我们可以创建一个新的 LinkedHashMap 并将新条目放入其中。之后,我们按照以下方式从原始 LinkedHashMap 复制条目:
SequencedMap<Integer, String> slinkedhashmap
= new LinkedHashMap<>();
slinkedhashmap.put(0, "zero"); // add the first entry
slinkedhashmap.putAll(linkedhashmap);
slinkedhashmap.put(6, "six"); // add the last entry
生成的 slinkedhashmap 将包含以下条目:0=zero, 1=one, 2=two, 3=three, 4=four, 5=five, 6=six。
显然,这远非最佳且优雅的方法。我们最好依赖于 Sequenced Collections API 的 putFirst()/putLast(),如下所示:
linkedhashmap.putFirst(0, "zero");
linkedhashmap.putLast(6, "six");
这相当整洁!
通过两个步骤可以移除第一个/最后一个条目。首先,我们通过 Iterator/Stream API 从 LinkedHashMap 获取第一个/最后一个条目。其次,我们依赖于 remove() 方法,如下所示:
Entry<Integer, String> firstentrylhm
= linkedhashmap.entrySet().iterator().next();
linkedhashmap.remove(firstentrylhm.getKey());
// or, like this
linkedhashmap.remove(
firstentrylhm.getKey(), firstentrylhm.getValue());
Entry<Integer, String> lastEntryLhm
= linkedhashmap.entrySet().stream().skip(
linkedhashmap.size() - 1).findFirst().get();
linkedhashmap.remove(lastEntryLhm.getKey());
// or, like this
linkedhashmap.remove(
lastEntryLhm.getKey(), lastEntryLhm.getValue());
哇!这看起来很丑,对吧?!幸运的是,Sequenced Collections API 正好公开了 pollFirstEntry()/pollLastEntry() 来实现这个目的:
linkedhashmap.pollFirstEntry();
linkedhashmap.pollLastEntry();
反转 LinkedHashMap 也相当棘手。有多个繁琐的方法,其中之一是创建一个新的 LinkedHashMap。然后,使用 descendingIterator() API 从末尾到开头迭代原始 LinkedHashMap,同时将其添加到新的 LinkedHashMap 中:
SequencedMap<Integer, String> reversedlinkedhashmap
= new LinkedHashMap<>();
Set<Integer> setKeys = linkedhashmap.keySet();
LinkedList<Integer> listKeys = new LinkedList<>(setKeys);
Iterator<Integer> iterator = listKeys.descendingIterator();
while (iterator.hasNext()) {
Integer key = iterator.next();
reversedlinkedhashmap.put(key, linkedhashmap.get(key));
}
这段代码难以理解!最好使用 Sequenced Collections API,它公开了 reversed() 方法:
SequencedMap<Integer, String> reversedMap
= linkedhashmap.reversed();
这很简单!
将 Sequenced Collections API 应用到 SortedMap (TreeMap)
SortedMap 扩展了 SequencedMap 并按自然排序或显式的 Comparator 对其条目进行排序。让我们在 SortedMap 的 TreeMap 实现上试一试:
SortedMap<Integer, String> sortedmap = new TreeMap<>();
sortedmap.put(1, "one");
sortedmap.put(2, "two");
sortedmap.put(3, "three");
sortedmap.put(4, "four");
sortedmap.put(5, "five");
通过 firstKey() 和 lastKey() 方法分别获取 TreeMap 的第一个/最后一个条目,如下所示:
Integer fkey = sortedmap.firstKey(); // first key
String fval = sortedmap.get(fkey); // first value
Integer lkey = sortedmap.lastKey(); // last key
String lval = sortedmap.get(lkey); // last value
如果我们更喜欢 Sequenced Collections API,则可以使用 firstEntry()/lastEntry():
sortedmap.firstEntry();
sortedmap.firstEntry().getKey();
sortedmap.firstEntry().getValue();
sortedmap.lastEntry();
sortedmap.lastEntry().getKey();
sortedmap.lastEntry().getValue();
此外,排序映射可以利用 sequencedKeySet()、sequencedValues() 和 sequencedEntrySet() 如下:
SequencedSet<Integer> keysSortedMap
= sortedmap.sequencedKeySet();
keysSortedMap.getFirst();
keysSortedMap.getLast();
SequencedCollection<String> valuesSortedMap
= sortedmap.sequencedValues();
valuesSortedMap.getFirst();
valuesSortedMap.getLast();
SequencedSet<Entry<Integer, String>> entriesSortedMap
= sortedmap.sequencedEntrySet();
entriesSortedMap.getFirst();
entriesSortedMap.getLast();
由于排序映射根据自然排序或显式的 Comparator 保持其条目顺序,我们无法在第一个/最后一个位置添加条目。换句话说,我们想要插入的第一个/最后一个位置可能会根据 Comparator 逻辑在任何位置。在这种情况下,由 putFirst()/putLast() 表示的 Sequenced Collections API 将会抛出 UnsupportedOperationException:
sortedmap.putFirst(0, "zero"); //UnsupportedOperationException
sortedmap.putLast(6, "six"); //UnsupportedOperationException
通过 remove() 方法可以移除第一个/最后一个条目,如下所示:
Integer fkey = sortedmap.firstKey();
String fval = sortedmap.get(fkey);
Integer lkey = sortedmap.lastKey();
String lval = sortedmap.get(lkey);
sortedmap.remove(fkey);
sortedmap.remove(fkey, fval);
sortedmap.remove(lkey);
sortedmap.remove(lkey, lval);
Sequenced Collections API 可以通过 pollFirstEntry() 和 pollLastEntry() 显著减少此代码:
sortedmap.pollFirstEntry();
sortedmap.pollLastEntry();
通过 descendingMap()(或 descendingKeySet())可以反转排序映射:
NavigableMap<Integer, String> reversednavigablemap
= ((TreeMap) sortedmap).descendingMap();
或者,我们可以通过 Sequenced Collections API,它公开了 reversed() 方法,使事情保持简单:
SortedMap<Integer, String> reversedsortedmap
= sortedmap.reversed();
完成!正如你所看到的,Sequenced Collections API 非常有用且易于使用。请随意在其他集合上利用它。
119. 介绍线索数据结构
先决条件:从这个问题开始,我们将介绍一系列需要先前对二叉树、列表、堆、队列、栈等有经验的复杂数据结构。如果你是数据结构领域的初学者,那么我强烈建议你推迟以下问题,直到你设法阅读 《Java 完整编码面试指南》,它对这些初步主题进行了深入探讨。
当我们需要处理大量文本时(例如,如果我们正在开发文本编辑器或强大的文本搜索引擎),我们必须处理大量的复杂任务。在这些任务中,我们必须考虑字符串的追加/连接和内存消耗。
线索数据结构是一种特殊的二叉树,旨在在高效使用内存的同时改进字符串操作(这对于长字符串尤其有用)。其 Big O 目标如下所示:

图 5.13:Rope 的 Big O
作为二叉树,线索可以通过经典的 Node 类如下所示:
public static class Node {
private Node left;
private Node right;
private int weight;
private String str;
public Node(String str) {
this(null, null, str.length(), str);
}
public Node(Node left, Node right, int weight) {
this(left, right, weight, null);
}
public Node(Node left, Node right, int weight, String str) {
this.left = left;
this.right = right;
this.str = str;
this.weight = weight;
}
}
每个节点都持有对其子节点(左和右)及其左子树中节点总权重(weight)的指针。叶节点存储大字符串的小块(str)。以下是一个文本 我是一个非常酷的线索 的线索:

图 5.14:线索示例
接下来,让我们实现线索的主要操作,从按索引搜索开始。Rope 是一个静态类,包含以下所有操作。
实现 indexAt(Node node, int index)
indexAt(Node node, int index) 方法试图找到给定 index 的字符。这是一个基于简单规则的递归过程,如下所示:
-
如果
index > (weight - 1)则index = index - weight并移动到右节点。 -
如果
index < weight,则只需移动到左节点。
这两个步骤会重复进行,直到我们遇到叶节点,并返回当前 index 的字符。
假设我们想返回 index 5 的字符,即 e(见 图 5.14):
-
从根节点开始,我们有
index= 5,index< 8,所以我们向左移动。 -
接下来,
index= 5,5 > 3,所以index= 5 – 3 = 2 并向右移动。 -
接下来,
index= 2,2 > 1,所以index= 2 – 1 = 1 并向右移动。 -
右节点是一个叶节点,因此我们返回
charAt(1),即e。

图 5.15:实现 indexAt()
以代码形式,此算法相当简单:
public static char indexAt(Node node, int index) {
if (index > node.weight - 1) {
return indexAt(node.right, index - node.weight);
} else if (node.left != null) {
return indexAt(node.left, index);
} else {
return node.str.charAt(index);
}
}
接下来,让我们谈谈连接两个线索。
实现 concat(Node node1, Node node2)
连接两个线索(node1 和 node2)是一个简单的逐步算法:
-
创建一个新的根节点,其权重与
node1的叶节点相同。 -
新的根节点以
node1为其左子节点,以node2为其右子节点。 -
可选的重新平衡(这里没有实现,但采用经典二叉树重新平衡的形式)。
以下图表示示了两个绳子的连接:

图 5.16:连接两个绳子
以代码形式,我们有以下:
public static Node concat(Node node1, Node node2) {
return new Node(node1, node2, getLength(node1));
}
private static int getLength(Node node) {
if (node.str != null) {
return node.weight;
} else {
return getLength(node.left) + (node.right == null ?
0 : getLength(node.right));
}
}
接下来,让我们插入一个新的节点。
实现insert(Node node, int index, String str)
为了在原始字符串的某个索引处插入字符串的一部分,我们必须分割原始字符串并执行两次连接。算法分为三个步骤,如下:
-
在给定的索引处将原始字符串分割成两个字符串,
s1和s2。 -
将
s1和给定的str连接到s3中。 -
将
s1与新的s3连接起来。
以代码形式,我们得到以下实现:
public static Node insert(Node node, int index, String str) {
List<Node> splitRopes = Rope.split(node, index);
Node insertNode = new Node(null, null, str.length(), str);
Node resultNode;
if (splitRopes.size() == 1) {
if (index == 0) {
resultNode = Rope.concat(insertNode, splitRopes.get(0));
} else {
resultNode = Rope.concat(splitRopes.get(0), insertNode);
}
} else {
resultNode = Rope.concat(splitRopes.get(0), insertNode);
resultNode = Rope.concat(resultNode, splitRopes.get(1));
}
return resultNode;
}
接下来,让我们看看如何删除子字符串。
实现delete(Node node, int start, int end)
从原始字符串中删除start和end之间的子字符串需要两次分割和一次连接。算法由三个步骤组成,如下:
-
在
start处将原始字符串分割成s1和s2。 -
在
end处将s2分割成s3和s4。 -
将
s1和s4连接起来。
以代码形式,我们有以下实现:
public static Node delete(Node node, int start, int end) {
Node beforeNode = null;
Node afterNode;
List<Node> splitRopes1 = Rope.split(node, start);
if (splitRopes1.size() == 1) {
afterNode = splitRopes1.get(0);
} else {
beforeNode = splitRopes1.get(0);
afterNode = splitRopes1.get(1);
}
List<Node> splitRopes2 = Rope.split(afterNode, end - start);
if (splitRopes2.size() == 1) {
return beforeNode;
}
return beforeNode == null ? splitRopes2.get(1) :
Rope.concat(beforeNode, splitRopes2.get(1));
}
最后,让我们谈谈分割绳子。
实现split(Node node, int index)
将绳子分割成两个绳子是一个应该考虑两个因素的运算:
-
分割应发生在最后一个字符(索引)处。
-
分割应发生在中间字符(索引)处。
这两种情况都在捆绑代码中的实现中考虑到了。由于此代码简单但相当庞大,我们在这里为了简洁而省略了它。
120. 介绍跳表数据结构
跳表数据结构是一种基于链表的概率数据结构。跳表使用底层链表来保持项目的排序列表,但它还提供了跳过某些项目的能力,以加快插入、删除和查找等操作的速度。其 Big O 目标列在以下图中:

图 5.17:跳表的 Big (O)
跳表有两种类型的层。基本层(或底层,或层 0)由一个常规链表组成,该链表包含所有项目的排序列表。其余的层包含稀疏项目,并充当一个“快速通道”,旨在加快搜索、插入和删除项的速度。以下图示帮助我们可视化具有三层跳表的跳表:

图 5.18:跳表示例
因此,这个跳表在层 0 上持有项目 1、2、3、4、5、8、9、10、11 和 34,并且有两个包含稀疏项目的快速通道(层 1 和层 2)。接下来,让我们看看我们如何找到某个项目。
实现contains(Integer data)
搜索特定项从层n开始,继续到层n-1,依此类推,直到层 0。例如,假设我们想找到项 11。
我们从层 2 开始,并继续在这一层上运行,直到我们找到一个大于等于 11 的节点。由于层 2 上不存在值 11,我们搜索一个小于 11 的项,我们找到了 10。
我们在层 1 上继续搜索。根据相同的逻辑,我们再次找到项 10。层 1 也不包含项 11。如果它包含它,那么我们会停止搜索。
我们再次向下移动,这次是到层 0(包含所有项的基本层),并继续搜索,直到我们找到项 11。以下图显示了我们的搜索路径:

图 5.19:在跳表中查找一个项
通过跟随高亮显示的路径,我们可以看到我们跳过了许多项,直到我们找到了项 11。
在代码形式中,这个操作可以如下实现:
public boolean contains(Integer data) {
Node cursorNode = head;
for (int i = topLayer - 1; i >= 0; i--) {
while (cursorNode.next[i] != null) {
if (cursorNode.next[i].getData() > data) {
break;
}
if (cursorNode.next[i].getData().equals(data)) {
return true;
}
cursorNode = cursorNode.next[i];
}
}
return false;
}
接下来,让我们看看我们如何插入一个新项。
实现插入(Integer data)
插入新项发生在随机选择的层上。换句话说,项的层是在插入时随机选择的。我们可以将其插入到现有层中,或者为这个新项创建一个专门的层。我们可以创建新的层,直到我们达到任意选择的MAX_NUMBER_OF_LAYERS(我们有MAX_NUMBER_OF_LAYERS = 10)。
在插入算法中,我们应用以下步骤来搜索插入项的正确位置:
-
如果下一个节点的项小于要插入的项,那么我们就在同一层继续向前移动。
-
如果下一个节点的项大于要插入的项,那么我们保存当前节点的指针,并通过向下移动一层继续搜索。搜索从这里继续。
-
在某个时刻,我们将达到基本层(层 0)。由于这一层包含所有项,我们肯定会在那里找到一个为新项腾出的空间。
在以下图中,项 7 被插入到层 1:

图 5.20:在跳表中插入一个项
实现很简单:
public void insert(Integer data) {
int layer = incrementLayerNo();
Node newNode = new Node(data, layer);
Node cursorNode = head;
for (int i = topLayer - 1; i >= 0; i--) {
while (cursorNode.next[i] != null) {
if (cursorNode.next[i].getData() > data) {
break;
}
cursorNode = cursorNode.next[i];
}
if (i <= layer) {
newNode.next[i] = cursorNode.next[i];
cursorNode.next[i] = newNode;
}
}
size++;
}
incrementLayerNo()是一个方法,它随机决定新项将被插入的层。
实现删除(Integer data)
删除一个项是一个简单的操作。我们从顶层开始,找到要删除的项,然后删除它。挑战在于只通过正确链接剩余的节点来消除该项。实现很简单:
public boolean delete(Integer data) {
Node cursorNode = head;
boolean deleted = false;
for (int i = topLayer - 1; i >= 0; i--) {
while (cursorNode.next[i] != null) {
if (cursorNode.next[i].getData() > data) {
break;
}
if (cursorNode.next[i].getData().equals(data)) {
cursorNode.next[i] = cursorNode.next[i].next[i];
deleted = true;
size--;
break;
}
cursorNode = cursorNode.next[i];
}
}
return deleted;
}
挑战自己,在 Java 内置的LinkedList之上实现跳表。这将是一件有趣的事情,并给你一个机会进一步探索跳表数据结构。
121. 介绍 K-D 树数据结构
K-D 树(也称为 K 维树)是一种数据结构,它是一种 二叉搜索树(BST)的变体,专门用于在 K 维空间(2-D,3-D 等)中存储和组织点/坐标。K-D 树的每个节点都包含一个表示多维空间的点。以下代码片段定义了一个 2-D 树的节点:
private final class Node {
private final double[] coords;
private Node left;
private Node right;
public Node(double[] coords) {
this.coords = coords;
}
...
}
与 double[] 数组相比,你可能更喜欢 java.awt.geom.Point2D,它是专门用于表示 (x, y) 坐标空间中的位置的。
通常,K-D 树对于执行不同类型的搜索很有用,例如最近邻搜索和范围查询。例如,假设一个 2-D 空间和该空间中的一系列 (x, y) 坐标:
double[][] coords = {
{3, 5}, {1, 4}, {5, 4}, {2, 3}, {4, 2}, {3, 2},
{5, 2}, {2, 1}, {2, 4}, {2, 5}
};
我们可以使用众所周知的 X-Y 坐标系来表示这些坐标,但也可以将它们存储在如图所示的 K-2D 树中:

图 5.21:用 X-Y 坐标系和 K-2D 树表示的 2D 空间
但是,我们是如何构建 K-D 树的呢?
插入到 K-D 树中
我们逐个插入我们的坐标(cords),从 coords[0] = (3,5) 开始。这个 (3,5) 对成为 K-D 树的根。下一个坐标对是 (1,4)。我们比较根的 x 值与这个对的 x 值,我们注意到 1 < 3,这意味着 (1,4) 成为根的左子节点。下一个对是 (5,4)。在第一层,我们比较根的 x 值与 5,我们看到 5 > 3,所以 (5,4) 成为根的右子节点。以下图展示了 (3,5),(1,4) 和 (5,4) 的插入过程。

图 5.22:插入 (3,5),(1,4) 和 (5,4)
接下来,我们插入坐标对 (2,3)。我们比较 (2,3) 和 (3,5) 的 x 分量,我们注意到 2 < 3,所以 (2,3) 在根的左侧。接下来,我们比较 (2,3) 的 y 分量与 (1,4) 的 y 分量,我们注意到 3 < 4,所以 (2,3) 在 (1,4) 的左侧。
接下来,我们插入坐标对 (4,2)。我们比较 (4,2) 和 (3,5) 的 x 分量,我们注意到 4 > 3,所以 (4,2) 在根的右侧。接下来,我们比较 (4,2) 的 y 分量与 (5,4) 的 y 分量,我们注意到 2 < 4,所以 (4,2) 在 (5,4) 的左侧。以下图展示了 (2, 3) 和 (4,2) 的插入过程。

图 5.23:插入 (2,3) 和 (4,2)
接下来,我们插入坐标对 (3,2)。我们比较 (3,2) 和 (3,5) 的 x 分量,我们注意到 3 = 3,所以 (3,2) 在根的右侧。接下来,我们比较 (3,2) 的 y 分量与 (5,4) 的 y 分量,我们注意到 2 < 4,所以 (3,2) 在 (5,4) 的左侧。然后,我们比较 (3,2) 的 x 分量与 (4,2) 的 x 分量,我们注意到 3 < 4,所以 (3,2) 在 (4,2) 的左侧。
接下来,我们插入对 (5,2)。我们比较 (5,2) 和 (3,5) 的 x 分量,我们看到 5 > 3,所以 (5,2) 在根的右侧。接下来,我们比较 (5,2) 的 y 分量和 (5,4),我们看到 2 < 4,所以 (5,2) 在 (5,4) 的左侧。接下来,我们比较 (5,2) 的 x 分量和 (4,2),我们看到 5 > 4,所以 (5,2) 在 (4,2) 的右侧。以下图概述了 (3,2) 和 (5,2) 的插入。

图 5.24:插入 (3,2) 和 (5,2)
接下来,我们插入对 (2,1)。我们比较 (2,1) 和 (3,5) 的 x 分量,我们看到 2 < 3,所以 (2,1) 在根的左侧。接下来,我们比较 (2,1) 的 y 分量和 (1,4),我们看到 1 < 4,所以 (2,1) 在 (1,4) 的左侧。接下来,我们比较 (2,1) 的 x 分量和 (2,3),我们看到 2 = 2,所以 (2,1) 在 (2,3) 的右侧。
接下来,我们插入对 (2,4)。我们比较 (2,4) 和 (3,5) 的 x 分量,我们看到 2 < 3,所以 (2,4) 在根的左侧。接下来,我们比较 (2,4) 的 y 分量和 (1,4),我们看到 4 = 4,所以 (2,4) 在 (1,4) 的右侧。
最后,我们插入对 (2,5)。我们比较 (2,5) 和 (3,5) 的 x 分量,我们看到 2 < 3,所以 (2,5) 在根的左侧。接下来,我们比较 (2,5) 的 y 分量和 (1,4),我们看到 5 > 4,所以 (2,5) 在 (1,4) 的右侧。接下来,我们比较 (2,5) 的 x 分量和 (2,4),我们看到 2 = 2,所以 (2,5) 在 (2,4) 的右侧。以下图示了 (2,1),(2,4) 和 (2,5) 的插入。

图 5.25:插入 (2,1),(2,4) 和 (2,5)
完成!因此,插入有两个简单的规则:
-
我们从 x 开始交替比较分量。在第一级,我们比较 x,在第二级,我们比较 y,在第三级我们比较 x,在第四级我们比较 y,以此类推。
-
当比较 (x1, y1) 与 (x2, y2) 时,如果 x2>= x1 或 y2>= y1(取决于正在比较哪个分量)则 (x2, y2) 节点在 (x1, y1) 的右侧,否则在左侧。
基于这些陈述,二维模型的实现很简单:
public void insert(double[] coords) {
root = insert(root, coords, 0);
}
private Node insert(Node root, double[] coords, int depth) {
if (root == null) {
return newNode(coords);
}
int cd = depth % 2;
if (coords[cd] < root.coords[cd]) {
root.left = insert(root.left, coords, depth + 1);
} else {
root.right = insert(root.right, coords, depth + 1);
}
return root;
}
在 K-D 树中插入的一种方法依赖于对坐标进行排序的排序算法。这里没有提供这种实现。
寻找最近邻
寻找最近邻是在 K-D 树上执行的经典操作。我们有一个给定的点 (x, y),我们想知道 K-D 树中最近的点是什么。例如,我们可能想找到 (4,4) 的最近邻——查看以下图:

图 5.26:找到 (4,4) 的最近邻
点 (4,4) 的最近邻是 (5,4)。简而言之,寻找最近邻是找到给定点到 K-D 树中任何其他点的最短距离。我们从根节点开始,计算给定点(或目标节点)与当前节点之间的距离。最短距离获胜。实现如下:
public double[] findNearest(double[] coords) {
Node targetNode = newNode(coords);
visited = 0;
foundDistance = 0;
found = null;
nearest(root, targetNode, 0);
return found.coords.clone();
}
nearest() 方法是寻找最小距离的递归解决方案:
private void nearest(Node root, Node targetNode, int index) {
if (root == null) {
return;
}
visited++;
double theDistance = root.theDistance(targetNode);
if (found == null || theDistance < foundDistance) {
foundDistance = theDistance;
found = root;
}
if (foundDistance == 0) {
return;
}
double rootTargetDistance = root.get(index) -
targetNode.get(index);
index = (index + 1) % 2;
nearest(rootTargetDistance > 0 ?
root.left : root.right, targetNode, index);
if (rootTargetDistance *
rootTargetDistance >= foundDistance) {
return;
}
nearest(rootTargetDistance > 0 ?
root.right : root.left, targetNode, index);
}
在捆绑的代码中,您可以找到前面代码中缺失的部分,例如计算两点之间距离的方法。
在 K-D 树中搜索和删除项与在 BST 上执行这些操作类似,所以没有新内容。
挑战自己实现一个 3-D 树。
122. 介绍 Zipper 数据结构
Zipper 数据结构旨在简化在另一个数据结构(如树)上实现类似光标的导航能力。此外,它可能提供用于操作树的能力,如添加节点、删除节点等。
Zipper 是在树的顶部创建的,其特征是当前光标的位置和当前范围或当前可见区域。在任何时刻,Zipper 都不会看到或作用于整个树;它的操作仅限于相对于当前位置的子树或树的某个范围。通过 Zipper 实现的修改仅在当前范围内可见,而不是整个树中。
为了导航和确定当前范围,Zipper 必须了解树的结构。例如,它必须了解每个节点的所有子节点,这就是为什么我们从必须由任何想要利用 Zipper 的树实现的接口开始:
public interface Zippable {
public Collection<? extends Zippable> getChildren();
}
实现了 Zippable 的树确保它将其子节点暴露给 Zipper。例如,一个树 Node 实现可以如下进行:
public class Node implements Zippable {
private final String name;
private final List<Node> children;
public Node(final String name, final Node... children) {
this.name = name;
this.children = new LinkedList<>(Arrays.asList(children));
}
public String getName() {
return name;
}
@Override
public Collection<Node> getChildren() {
return this.children;
}
@Override
public String toString() {
return "Node{" + "name=" + name
+ ", children=" + children + '}';
}
}
以下图示了某个时刻 Zipper 的特征:

图 5.27:任意树上的 Zipper 位置和范围
Zipper 的当前位置由标记为 55 的节点表示 – Zipper 光标位于位置 55。高亮显示的灰色区域是 Zipper 的当前范围/可见区域。在这个区域之外发生的一切都是不可见的。从当前位置,Zipper 可以移动 down()、up()、left() 和 right()。每次移动都会相应地细化 Zipper 范围。
当 Zipper 应用到树上时,树的每个节点(Node)都成为 Zipper-节点,这里由 ZipNode 类表示。正如您在以下代码中可以看到的,ZipNode 作为 Node 的包装器,代表 Zipper 的工作单元:
public final class ZipNode<T extends Zippable>
implements Zippable {
private static final Zippable[] DUMMY = new Zippable[0];
private final T node; // wrap the original tree node
private Zippable[] children; // list of children
// wrap a ZipNode without children
protected ZipNode(final T node) {
this(node, DUMMY);
}
// wrap a ZipNode and its children
protected ZipNode(final T node, Zippable[] children) {
if (children == null) {
children = new Zippable[0];
}
this.node = node;
this.children = children;
}
剩余的代码以懒加载的方式(按需)处理子节点的初始化:
@Override
public Collection<? extends Zippable> getChildren() {
lazyGetChildren();
return (children != null) ?
new LinkedList<>(Arrays.asList(children)) : null;
}
// return the original node
public T unwrap() {
return node;
}
public boolean isLeaf() {
lazyGetChildren();
return children == null || children.length == 0;
}
public boolean hasChildren() {
lazyGetChildren();
return children != null && children.length > 0;
}
protected Zippable[] children() {
lazyGetChildren();
return children;
}
protected ZipNode<T> replaceNode(final T node) {
lazyGetChildren();
return new ZipNode<>(node, children);
}
// lazy initialization of children
private void lazyGetChildren() {
if (children == DUMMY) {
Collection<? extends Zippable> nodeChildren
= node.getChildren();
children = (nodeChildren == null) ?
null : nodeChildren.toArray(Zippable[]::new);
}
}
@Override
public String toString() {
return node.toString(); // call the original toString()
}
}
所有的 Zipper 操作都作用于 ZipNode,而不是 Node。
接下来,我们有 Zipper 范围实现,它基本上定义了 Figure 5.27 中的灰色部分。我们有当前范围的父节点和左右兄弟节点:
final class ZipperRange {
private final ZipperRange parentRange;
private final ZipNode<?> parentZipNode;
private final Zippable[] leftSiblings;
private final Zippable[] rightSiblings;
protected ZipperRange(final ZipNode<?> parentZipNode,
final ZipperRange parentRange, final Zippable[]
leftSiblings, final Zippable[] rightSiblings) {
this.parentZipNode = parentZipNode;
this.parentRange = parentRange;
this.leftSiblings = (leftSiblings == null) ?
new Zippable[0] : leftSiblings;
this.rightSiblings = (rightSiblings == null) ?
new Zippable[0] : rightSiblings;
}
// getters omitted for brevity
}
ZipperRange 与 Cursor 协同工作,其中包含 Zipper 动作的实现(down()、up()、left()、right()、rightMost()、leftMost()、clear()、add()、addAll()、insertLeft()、insertRight()、remove()、removeLeft()、removeRight() 等):
public final class Cursor<T extends Zippable> {
private final ZipNode<T> zipNode;
private final ZipperRange range;
protected Cursor(final ZipNode<T> zipNode,
final ZipperRange range) {
this.zipNode = zipNode;
this.range = range;
}
...
}
由于此代码相当大,此处省略了其余部分。你可以在捆绑的代码中找到它。
最后,我们有 Zipper 类。这个类用于通过 createZipper() 方法创建 Zipper。它还用于根据通过 Zipper 做的修改重新创建/更新树。这是在 unwrapZipper() 方法中完成的,如下所示:
public final class Zipper {
public static <T extends Zippable>
Cursor<T> createZipper(final T node) {
return new Cursor<>(new ZipNode<>(node),
new ZipperRange(null, null, null, null)); // root range
}
public static <T extends Zippable> T unwrapZipper(
final Cursor<T> tree) {
return Zipper.<T>unwrapZipper(tree.root().zipNode());
}
private static <T extends Zippable> T unwrapZipper(
final Zippable node) {
if (node instanceof ZipNode<?>) {
ZipNode<T> zipNode = (ZipNode<T>) node;
T original = zipNode.unwrap();
if (!zipNode.isLeaf()) {
Collection<T> children
= (Collection<T>) original.getChildren();
original.getChildren().clear();
for (Zippable zipped : zipNode.children()) {
children.add((T) unwrapZipper(zipped));
}
}
return original;
} else {
return (T) node;
}
}
}
在捆绑的代码中,你可以找到完整的实现以及在一个给定的树上使用 Zipper 的示例。
123. 介绍二项堆数据结构
二项堆数据结构是由二项树组成的集合。每个二项树都是最小堆,这意味着它遵循 最小堆 属性。简而言之,如果一个堆的项是降序排列的,那么它就是一个最小堆,这意味着最小项是根(更多细节可以在 Java 完整编码面试指南 书籍中找到)。
简而言之,二项树是有序的,通常以递归方式定义。它表示为 B[k],其中 k 意味着以下属性:
-
一个二项树有 2^k 个节点。
-
二项树的高度等于 k。
-
二项树的根具有度 k,这是最大的度。
一个 B[0] 二项树有一个节点。一个 B[1] 二项树有两个 B[0] 树,其中一个是另一个的左子树。一个 B[2] 树有两个 B[1],其中一个是另一个的左子树。一般来说,一个 B[k] 二项树包含两个 B[k-1] 二项树,其中一个是另一个的左子树(两个 B[k-1] 树连接到组成的 B[k] 上)。在下面的图中,你可以看到 B[0]、B[1]、B[2]、B[3] 和 B[4]:

图 5.28:B[0]-B[4] 二项树
从 Big O 角度来看,二项堆的目标如下所示:

图 5.29:二项堆的 Big O
在下面的图中,你可以看到一个二项堆的样本。二项树(在这里是 9、1 和 7)的根通过称为 根列表 的链表表示。

图 5.30:二项堆样本
换句话说,正如你可以很容易地从这张图中直观地看出,二项堆是二叉堆的扩展(或一种风味),它提供了合并或联合两个堆的高性能,并且非常适合实现优先队列的任务。
根据这个图,我们可以定义二项堆的骨架如下:
public class BinomialHeap {
private Node head;
private final class Node {
private int key;
private int degree;
private Node parent;
private Node child;
private Node sibling;
public Node() {
key = Integer.MIN_VALUE;
}
public Node(int key) {
this.key = key;
}
...
}
...
}
如果我们将Node的相关部分表示为图,我们将得到以下图(在这里,您可以查看项目 11 和 25 的Node的内部结构):

图 5.31:扩展节点
现在我们有了二项堆的主要结构,让我们来介绍几个操作。
实现 insert(int key)
将新键插入二项堆是一个两步操作。第一步,我们创建一个新的只包含给定键(一个包装给定键的Node)的堆。第二步,我们将当前堆与这个新创建的堆合并,如下所示:
public void insert(int key) {
Node node = new Node(key);
BinomialHeap newHeap = new BinomialHeap(node);
head = unionHeap(newHeap);
}
并操作被描绘为这个问题的最后一个操作。
实现 findMin()
找到二项堆的最小键需要我们遍历根列表(这是一个链表)并找到最小的键。如果我们决定维护一个指向最小根的指针,则可以将这个操作从O(log n)优化到O(1)。然而,这里列出的O(log n)方法:
public int findMin() {
if (head == null) {
return Integer.MIN_VALUE;
} else {
Node min = head;
Node nextNode = min.sibling;
while (nextNode != null) {
if (nextNode.key < min.key) {
min = nextNode;
}
nextNode = nextNode.sibling;
}
return min.key;
}
}
由于我们的二项堆包含原始整数,我们使用Integer.MIN_VALUE作为“无值”的等价物。如果您调整实现以使用Integer或泛型T,则可以将Integer.MIN_VALUE替换为null。
实现 extractMin()
在提取最小键之前,我们必须找到它。之后,我们删除它。最后,我们必须按照以下方式合并产生的子树:
public int extractMin() {
if (head == null) {
return Integer.MIN_VALUE;
}
Node min = head;
Node minPrev = null;
Node nextNode = min.sibling;
Node nextNodePrev = min;
while (nextNode != null) {
if (nextNode.key < min.key) {
min = nextNode;
minPrev = nextNodePrev;
}
nextNodePrev = nextNode;
nextNode = nextNode.sibling;
}
deleteTreeRoot(min, minPrev);
return min.key;
}
deleteTreeRoot()是一个辅助方法,用于删除给定的根节点并在剩余的子树上执行并操作:
private void deleteTreeRoot(Node root, Node previousNode) {
if (root == head) {
head = root.sibling;
} else {
previousNode.sibling = root.sibling;
}
Node unionHeap = null;
Node child = root.child;
while (child != null) {
Node nextNode = child.sibling;
child.sibling = unionHeap;
child.parent = null;
unionHeap = child;
child = nextNode;
}
BinomialHeap toUnionHeap = new BinomialHeap(unionHeap);
head = unionHeap(toUnionHeap);
}
实现 decreaseKey(int key, int newKey)
减小键值意味着用一个更小的键替换现有的键。当这个操作发生时,新键可能比其父键小,这意味着违反了最小堆属性。这种情况需要我们交换当前节点与其父节点,父节点与其祖父节点,依此类推,直到我们重新建立符合最小堆属性。实现如下:
public void decreaseKey(int key, int newKey) {
Node found = findByKey(key);
if (found != null) {
decreaseKey(found, newKey);
}
}
private void decreaseKey(Node node, int newKey) {
node.key = newKey;
goUp(node, false);
}
goUp()方法是一个辅助方法,用于重新建立最小堆属性:
private Node goUp(Node node, boolean goToRoot) {
Node parent = node.parent;
while (parent != null && (goToRoot
|| node.key < parent.key)) {
int t = node.key;
node.key = parent.key;
parent.key = t;
node = parent;
parent = parent.parent;
}
return node;
}
如您接下来将看到的,这个辅助方法对于删除节点也很有用。
实现 delete(int key)
删除键是通过首先找到相应的Node并将其减小到最小值(Integer.MIN_VALUE),然后从堆中删除最小值并连接剩余的子树来完成的。实现依赖于前几节中列出的goUp()和deleteTreeRoot()辅助方法:
public void delete(int key) {
Node found = findByKey(key);
if (found != null) {
delete(found);
}
}
private void delete(Node node) {
node = goUp(node, true);
if (head == node) {
deleteTreeRoot(node, null);
} else {
Node previousNode = head;
while (previousNode.sibling.key != node.key) {
previousNode = previousNode.sibling;
}
deleteTreeRoot(node, previousNode);
}
}
最后,让我们谈谈并堆。
实现 unionHeap(BinomialHeap heap)
考虑两个二项堆(H1 和 H2)。并操作的目标是通过统一 H1 和 H2 来创建 H3。假设 H1(使用我们的应用程序中常用的传统字符串表示法为 31 22 [ 40 ] 8 [ 13 [ 24 ] 11 ])和 H2(55 24 [ 45 ] 3 [ 7 [ 29 [ 40 ] 9 ] 5 [ 37 ] 18 ])如下图的那些:

图 5.32:两个二项堆,H1 和 H2
合并合约从按其度数顺序合并 H1 和 H2 开始。在我们的例子中,合并操作产生以下输出:

图 5.33:合并 H1 和 H2
此操作通过以下辅助方法执行:
private Node merge(BinomialHeap h1, BinomialHeap h2) {
if (h1.head == null) {
return h2.head;
} else if (h2.head == null) {
return h1.head;
} else {
Node headIt;
Node h1Next = h1.head;
Node h2Next = h2.head;
if (h1.head.degree <= h2.head.degree) {
headIt = h1.head;
h1Next = h1Next.sibling;
} else {
headIt = h2.head;
h2Next = h2Next.sibling;
}
Node tail = headIt;
while (h1Next != null && h2Next != null) {
if (h1Next.degree <= h2Next.degree) {
tail.sibling = h1Next;
h1Next = h1Next.sibling;
} else {
tail.sibling = h2Next;
h2Next = h2Next.sibling;
}
tail = tail.sibling;
}
if (h1Next != null) {
tail.sibling = h1Next;
} else {
tail.sibling = h2Next;
}
return headIt;
}
}
接下来,我们需要合并相同顺序的二项树。当我们遍历合并堆的根(在这里,31、55、22、24、8 和 3)时,我们使用三个指针,分别表示 PREV-X(当前节点的上一个节点)、X(当前节点)和 NEXT-X(当前节点的下一个节点)。这些指针帮助我们解决以下四种情况:
-
案例 1:X 和 NEXT-X 具有不同的顺序。在这种情况下,我们只需将 X 指针向前移动。
-
案例 2:X、NEXT-X 和 NEXT-NEXT-X 具有相同的顺序。在这种情况下,我们只需将 X 指针向前移动。
-
案例 3:X 和 NEXT-X 具有相同的顺序,不同于 NEXT-NEXT-X。如果 X.KEY <= NEXT-X.KEY,则 NEXT-X 成为 X 的子节点。
-
案例 4:X 和 NEXT-X 具有相同的顺序,不同于 NEXT-NEXT-X。如果 X.KEY > NEXT-X.KEY,则 X 成为 NEXT-X 的子节点。
如果我们将这四种情况应用于我们的示例,我们会注意到在合并 H1 和 H2 之后,我们处于 案例 3,因为 X 和 NEXT-X 具有相同的顺序(B[0]),这与 NEXT-NEXT-X 的顺序(B[1])不同,且 X.KEY = 31 < 55 = NEXT-X.KEY。因此,NEXT-X 成为 X 的子节点,如下图所示:

图 5.34:应用案例 3
进一步观察,我们发现 X、NEXT-X 和 NEXT-NEXT-X 具有相同的顺序 B[1]。这意味着我们处于 案例 2,因此我们必须将 X 指针向前移动,如下图所示:

图 5.35:应用案例 2
接下来,我们再次处于 案例 3。我们看到 X 和 NEXT-X 具有相同的顺序(B[1]),这与 NEXT-NEXT-X 的顺序(B[2])不同。此外,我们还可以看到 X.KEY = 22 < 24 = NEXT-X.KEY,因此 NEXT-X 成为 X 的子节点,如下图所示:

图 5.36:再次应用案例 3
接下来,我们处于 案例 4。我们看到 X 和 NEXT-X 具有相同的顺序(B[2]),这与 NEXT-NEXT-X 的顺序(B[3])不同。此外,我们还看到 X.KEY = 22 > 8 = NEXT-X.KEY,因此 X 成为 NEXT-X 的子节点,如下图所示:

图 5.37:应用案例 4
接下来,我们再次处于 案例 4。我们看到 X 和 NEXT-X 具有相同的顺序(B[3]),不同于 NEXT-NEXT-X(null)。此外,我们还可以看到 X.KEY = 8 > 3 = NEXT-X.KEY,因此 X 成为 NEXT-X 的子节点,如下图所示:

图 5.38:再次应用案例 4
到此为止,四个情况都不成立,因此这是二项堆的最终形式。
基于此示例,我们可以如下实现合并操作(注意以下代码中突出显示的情况):
private Node unionHeap(BinomialHeap heap) {
Node mergeHeap = merge(this, heap);
head = null;
heap.head = null;
if (mergeHeap == null) {
return null;
}
Node previousNode = null;
Node currentNode = mergeHeap;
Node nextNode = mergeHeap.sibling;
while (nextNode != null) {
if (currentNode.degree != nextNode.degree
|| (nextNode.sibling != null
&& nextNode.sibling.degree == currentNode.degree)) {
**[C:****1****,****2****]** previousNode = currentNode;
**[C:****1****,****2****]** currentNode = nextNode;
} else {
if (currentNode.key < nextNode.key) {
**[C:****3****]** currentNode.sibling = nextNode.sibling;
**[C:****3****]** linkNodes(currentNode, nextNode);
**[C:****4****]** } else {
**[C:****4****]** if (previousNode == null) {
**[C:****4****]** mergeHeap = nextNode;
**[C:****4****]** } else {
**[C:****4****]** previousNode.sibling = nextNode;
**[C:****4****]** }
**[C:****4****]**
**[C:****4****]** linkNodes(nextNode, currentNode);
**[C:****4****]** currentNode = nextNode;
}
}
nextNode = currentNode.sibling;
}
return mergeHeap;
}
linkNodes()方法是一个辅助方法,用于将当前节点与下一个节点链接:
private void linkNodes(Node minNodeTree, Node other) {
other.parent = minNodeTree;
other.sibling = minNodeTree.child;
minNodeTree.child = other;
minNodeTree.degree++;
}
完成!你可以在捆绑的代码中找到完整的应用。
124. 介绍斐波那契堆数据结构
斐波那契堆是二项堆的一种变体,在插入、提取最小值和合并等操作中具有优秀的摊销时间性能。它是实现优先队列的最佳选择。斐波那契堆由树组成,每棵树有一个根节点和多个以堆顺序排列的子节点。具有最小键的根节点始终位于树列表的开头。
它被称为斐波那契堆,因为每个阶数为k的树至少有 F[k+2]个节点,其中 F[k+2]是(k+2)^(th)斐波那契数。
在以下图中,你可以看到一个斐波那契堆示例:

图 5.39:斐波那契堆示例
斐波那契堆中的主要操作是(大 O 表示摊销时间):插入(O(1))、减小键(O(1))、找到最小值(O(1))、提取最小值(O(log n))、删除(O(log n))和合并(O(1))。你可以在捆绑的代码中找到这些操作的实现。
125. 介绍配对堆数据结构
配对堆是二项堆的一种变体,具有自我调整/重新排列的能力,以保持自身平衡。它在摊销时间方面表现非常好,非常适合实现优先队列的任务。
配对堆是一种配对树,具有根和子节点。配对堆中的每个堆表示一个值,并有一组也是堆的子节点。堆的值总是小于(最小堆属性)或大于(最大堆属性)其子堆的值。
在以下图中,你可以看到一个最小配对堆:

图 5.40:最小配对堆示例
配对堆中的主要操作是:插入(O(1))、减小键(实际时间:O(1),摊销时间 O(log n))、找到最小值(O(1))、提取最小值(实际时间:O(n),摊销时间(O(log n))和合并(实际时间:O(1),摊销时间(O(log n))。你可以在捆绑的代码中找到这些操作的实现。
126. 介绍霍夫曼编码数据结构
霍夫曼编码算法由 David A. Huffman 于 1950 年开发,可以通过一个示例轻松理解。让我们假设我们有一个如下图中所示的字符串。

图 5.41:初始字符串
假设每个字符需要 8 位来表示。由于我们有 14 个字符,我们可以说我们需要 8*14=112 位来通过网络发送这个字符串。
编码字符串
霍夫曼编码的想法是将这样的字符串压缩(缩小)到更小的尺寸。为此,我们创建一个字符频率的树。这个树的Node可以如下所示:
public class Huffman {
private Node root;
private String str;
private StringBuilder encodedStr;
private StringBuilder decodedStr;
private final class Node {
private Node left;
private Node right;
private final Character character;
private final Integer frequency;
// constructors
}
...
}
例如,以下图显示了从我们的字符串中按升序计算每个字符频率的计算过程:

图 5.42:计算每个字符的频率
排序后,这些字符存储在一个 优先队列(PQ)中。每个字符将通过几个步骤成为树中的一个叶子节点:
-
步骤 1:创建一个有两个子节点的节点(一个部分树)。左子节点包含最小频率,右子节点包含下一个最小频率。节点本身包含其左右子节点的和。
-
步骤 2:从 PQ 中移除这两个频率。
-
步骤 3:将这个部分树插入到 PQ 中。
-
步骤 4:重复步骤 1-3,直到 PQ 为空,并从这些部分树中获得一个单一的树。
如果我们应用 步骤 1-3 两次,我们将获得以下图:

图 5.43:应用步骤 1-3 两次
在代码形式中,这些步骤如下所示:
public void tree(String str) {
this.str = str;
this.root = null;
this.encodedStr = null;
this.decodedStr = null;
Map<Character, Integer> frequency = new HashMap<>();
for (char character : str.toCharArray()) {
frequency.put(character,
frequency.getOrDefault(character, 0) + 1);
}
PriorityQueue<Node> queue = new PriorityQueue<>(
Comparator.comparingInt(ch -> ch.frequency));
for (Entry<Character, Integer> entry : frequency.entrySet()) {
queue.add(new Node(entry.getKey(), entry.getValue()));
}
while (queue.size() != 1) {
Node left = queue.poll();
Node right = queue.poll();
int sum = left.frequency + right.frequency;
queue.add(new Node(null, sum, left, right));
}
this.root = queue.peek();
}
通过重复这些步骤直到 PQ 为空,我们获得最终的树。接下来,对于这个树中不是叶子的每个节点,我们将值 0 赋给左边缘,将值 1 赋给右边缘。这是编码步骤,可以编码如下:
public String encode() {
Map<Character, String> codes = new HashMap<>();
encode(this.root, "", codes);
this.encodedStr = new StringBuilder();
for (char character : this.str.toCharArray()) {
this.encodedStr.append(codes.get(character));
}
return this.encodedStr.toString();
}
private void encode(Node root, String str,
Map<Character, String> codes) {
if (root == null) {
return;
}
if (isLeaf(root)) {
codes.put(root.character, str.length() > 0 ? str : "1");
}
encode(root.left, str + '0', codes);
encode(root.right, str + '1', codes);
}
最终结果看起来像这样:

图 5.44:最终的树
现在,通过网络发送这个树将发送压缩字符串。下一图显示了该字符串的新大小:

图 5.45:压缩字符串的大小
因此,我们将字符串的大小从 112 位减少到 41 位。这是压缩或编码后的字符串。
解码字符串
解码字符串是一个简单的步骤。我们取每个代码并遍历树以找到分配的字符。例如,我们取 0111 并找到 d,我们取 110 并找到 a,依此类推。解码可以如下实现:
public String decode() {
this.decodedStr = new StringBuilder();
if (isLeaf(this.root)) {
int copyFrequency = this.root.frequency;
while (copyFrequency-- > 0) {
decodedStr.append(root.character);
}
} else {
int index = -1;
while (index < this.encodedStr.length() - 1) {
index = decode(this.root, index);
}
}
return decodedStr.toString();
}
private int decode(Node root, int index) {
if (root == null) {
return index;
}
if (isLeaf(root)) {
decodedStr.append(root.character);
return index;
}
index++;
root = (this.encodedStr.charAt(index) == '0')
? root.left : root.right;
index = decode(root, index);
return index;
}
private booleanisLeaf(Node root) {
return root.left == null && root.right == null;
}
处理完所有代码后,我们应该获得解码后的字符串。
127. 介绍 Splay 树数据结构
Splay 树是 二叉搜索树(BST)的一种形式。其特殊性在于它是一个自平衡树,将最近访问的项目放置在根级别。
splaying 操作或对项目进行 splaying 是一个依赖于树旋转的过程,目的是将项目带到根位置。对树的每次操作都跟随 splaying。
因此,splaying 的目标是使最近使用的项目更靠近根。这意味着对这些项目的后续操作将执行得更快。
splaying 操作依赖于六个旋转:
-
Zig 旋转 – 树向右旋转(每个节点都向右旋转)
-
Zag 旋转 – 树向左旋转(每个节点都向左旋转)
-
Zig-Zig 旋转 – 双重 Zig 旋转(每个节点向右移动两次)
-
Zag-Zag 旋转 – 双重 Zag 旋转(每个节点向左移动两次)
-
Zig-Zag 旋转 – Zig 旋转后跟一个 Zag 旋转
-
Zag-Zig 旋转 – 先进行 Zag 旋转然后进行 Zig
在捆绑的代码中,你可以找到一个 Splay 树的实现。此外,你可以使用这个可视化工具:www.cs.usfca.edu/~galles/visualization/SplayTree.html。
128. 介绍区间树数据结构
区间树是二叉搜索树(BST)的一种变体。它的特殊性在于它持有值区间。除了区间本身,区间树的节点还持有当前区间的最大值和以该节点为根的子树的最大值。
在代码形式中,区间树如下所示:
public class IntervalTree {
private Node root;
public static final class Interval {
private final int min, max;
public Interval(int min, int max) {
this.min = min;
this.max = max;
}
...
}
private final class Node {
private final Interval interval;
private final Integer maxInterval;
private Node left;
private Node right;
private int size;
private int maxSubstree;
Node(Interval interval, Integer maxInterval) {
this.interval = interval;
this.maxInterval = maxInterval;
this.size = 1;
this.maxSubstree = interval.max;
}
}
...
}
假设我们有以下整数区间:[4, 7],[1, 10],[7, 23],[6, 8],[9, 13] 和 [2, 24]。
实现 insert(Interval interval)
第一个区间 ([4, 7]) 成为树的根。接下来,我们通过比较区间的左侧将区间 [1, 10] 与 [4, 7] 进行比较。由于 1 < 4,区间 [1, 10] 位于根的左侧。
接下来,我们将区间 [7, 23] 与 [4, 7] 进行比较。由于 7 > 4,区间 [7, 23] 位于 [4, 7] 的右侧。应用相同的逻辑到剩余的区间将得到以下树:

图 5.46:区间树
之前的逻辑(插入操作,O(log n))在代码形式中如下所示:
public void insert(Interval interval) {
root = insert(root, interval);
}
private Node insert(Node root, Interval interval) {
if (root == null) {
return new Node(interval, interval.max);
}
if (interval.min < root.interval.min) {
root.left = insert(root.left, interval);
} else {
root.right = insert(root.right, interval);
}
if (root.maxSubstree < interval.max) {
root.maxSubstree = interval.max;
}
return root;
}
区间树特有的其他操作包括搜索与给定区间重叠的区间(O(log n))和删除(O(log n))。你可以在捆绑的代码中找到实现。
129. 介绍非展开链表数据结构
非展开链表是一种链表变体,它存储数组(多个项目)。非展开链表的每个节点可以存储一个数组。它就像结合了数组和链表的优点。换句话说,非展开链表是一种具有低内存占用和插入、删除操作高性能的数据结构。
从非展开链表中插入和删除有不同的实现。
例如,我们可以插入数组(insert(int[] arr)),这意味着每次插入都会创建一个新的节点并将该数组插入其中。
删除一个项目等同于从正确的数组中指定索引删除该项目。如果在删除后数组为空,则它也将从列表中删除。
另一种方法假设非展开链表有一个固定的容量(每个节点包含一个具有此容量的数组)。进一步地,我们逐个插入项目,遵循 50%的低水位线。这意味着如果我们插入的项目不能添加到当前节点(数组)中,那么我们将创建一个新的节点并将原始节点的一半项目加上这个项目插入其中。
删除一个项目使用的是相反的逻辑。如果一个节点中的项目数量低于 50%,我们就从相邻数组中移动项目,以回到高于 50% 的低水位线。如果相邻数组也低于 50%,那么我们必须合并这两个节点。
您可以在捆绑的代码中找到这两种方法。另一方面,您可以挑战自己提供一个展开的链表实现,该实现扩展了 JVM 集合 API。您可以从以下两种方法中的任何一种开始:
public class UnrolledLinkedList<E>
extends AbstractList<E>
implements List<E>, Serializable { ... }
public class UnrolledLinkedList<E>
extends AbstractSequentialList<E>
implements Deque<E>, Cloneable, Serializable { ... }
将此实现添加到您的 GitHub 站点,您将给面试官留下深刻印象。
130. 实现连接算法
连接算法通常用于数据库中,主要是在我们有两个表处于一对一关系时,并且我们想要根据连接谓词获取包含此映射的结果集。在以下图中,我们有 author 和 book 表。一个作者可以有多个书籍,我们想要连接这些表以获得一个结果集,作为第三个表。

图 5.47:连接两个表(作者和书籍)
有三种流行的连接算法用于解决这个问题:嵌套循环连接、哈希连接和排序归并连接。虽然数据库已经优化以选择给定查询的最合适的连接,但让我们尝试在以下两个表中实现它们,这两个表以记录的形式表示:
public record Author(int authorId, String name) {}
public record Book(int bookId, String title, int authorId) {}
List<Author> authorsTable = Arrays.asList(
new Author(1, "Author_1"), new Author(2, "Author_2"),
new Author(3, "Author_3"), new Author(4, "Author_4"),
new Author(5, "Author_5"));
List<Book> booksTable = Arrays.asList(
new Book(1, "Book_1", 1), new Book(2, "Book_2", 1),
new Book(3, "Book_3", 2), new Book(4, "Book_4", 3),
new Book(5, "Book_5", 3), new Book(6, "Book_6", 3),
new Book(7, "Book_7", 4), new Book(8, "Book_8", 5),
new Book(9, "Book_9", 5));
我们的目标是通过匹配 Author.authorId 和 Book.authorId 属性来连接 Author 和 Book 记录。结果应该是一个投影(ResultRow),包含 authorId、name、title 和 bookId:
public record ResultRow(int authorId, String name,
String title, int bookId) {}
接下来,让我们谈谈嵌套循环连接。
嵌套循环连接
嵌套循环连接算法依赖于两个循环,这两个循环遍历两个关系以找到匹配连接谓词的记录:
public static List<ResultRow> nestedLoopJoin(
List<Author> authorsTable, List<Book> booksTable) {
List<ResultRow> resultSet = new LinkedList();
for (Author author : authorsTable) {
for (Book book : booksTable) {
if (book.authorId() == author.authorId()) {
resultSet.add(new ResultRow(
author.authorId(), author.name(),
book.title(), book.bookId()));
}
}
}
return resultSet;
}
该算法的时间复杂度为 O(nm),其中 n 是 authorsTable 的大小,m* 是 booksTable 的大小。这是二次复杂度,这使得该算法仅适用于小数据集。
哈希连接
如其名所示,哈希连接依赖于 哈希。因此,我们必须从作者表(记录较少的表)创建一个哈希表,然后遍历书籍表,在创建的哈希表中找到它们的作者,如下所示:
public static List<ResultRow> hashJoin(
List<Author> authorsTable, List<Book> booksTable) {
Map<Integer, Author> authorMap = new HashMap<>();
for (Author author : authorsTable) {
authorMap.put(author.authorId(), author);
}
List<ResultRow> resultSet = new LinkedList();
for (Book book : booksTable) {
Integer authorId = book.authorId();
Author author = authorMap.get(authorId);
if (author != null) {
resultSet.add(new ResultRow(author.authorId(),
author.name(), book.title(), book.bookId()));
}
}
return resultSet;
}
该算法的时间复杂度为 O(n+m),其中 n 是 authorsTable 的大小,m 是 booksTable 的大小。因此,这比嵌套循环连接更好。
排序归并连接
如其名所示,排序归并连接首先按连接属性对两个表进行排序。之后,我们遍历两个表并应用连接谓词,如下所示:
public static List<ResultRow> sortMergeJoin(
List<Author> authorsTable, List<Book> booksTable) {
authorsTable.sort(Comparator.comparing(Author::authorId));
booksTable.sort((b1, b2) -> {
int sortResult = Comparator
.comparing(Book::authorId)
.compare(b1, b2);
return sortResult != 0 ? sortResult : Comparator
.comparing(Book::bookId)
.compare(b1, b2);
});
List<ResultRow> resultSet = new LinkedList();
int authorCount = authorsTable.size();
int bookCount = booksTable.size();
int p = 0;
int q = 0;
while (p <authorCount && q < bookCount) {
Author author = authorsTable.get(p);
Book book = booksTable.get(q);
if (author.authorId() == book.authorId()) {
resultSet.add(new ResultRow(author.authorId(),
author.name(), book.title(), book.bookId()));
q++;
} else {
p++;
}
}
return resultSet;
}
排序归并连接算法的时间复杂度为 O(nlog(n) + mlog(m)),其中 n 是 authorsTable 的大小,m 是 booksTable 的大小。因此,这比嵌套循环连接和哈希连接更优。
摘要
在本章中,我们涵盖了众多有趣的主题。我们首先介绍了新的 Vector API,用于增强并行数据处理能力,然后继续介绍了一系列酷炫的数据结构,如 Zipper、K-D 树、Skip List、二项堆等。最后,我们对三种主要的连接算法进行了简要概述。此外,我们还介绍了 JDK 21 的 Sequenced Collections API。
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:

第六章:Java I/O:上下文特定反序列化过滤器
本章包括与 Java 序列化/反序列化过程相关的 13 个问题。我们首先从经典的将对象序列化/反序列化为 byte[]、String 和 XML 的问题开始。然后,我们继续介绍旨在防止反序列化漏洞的 JDK 9 反序列化过滤器,最后以 JDK 17(JEP 415,最终版)的上下文特定反序列化过滤器结束。
在本章结束时,你将能够解决与 Java 中序列化/反序列化对象相关的几乎所有问题。
问题
使用以下问题来测试你在 Java 序列化/反序列化方面的编程能力。我强烈建议你在查看解决方案并下载示例程序之前,尝试解决每个问题:
-
将对象序列化为字节数组:编写一个 Java 应用程序,该程序公开两个辅助方法,用于将对象序列化/反序列化为
byte[]。 -
将对象序列化为字符串:编写一个 Java 应用程序,该程序公开两个辅助方法,用于将对象序列化/反序列化为
String。 -
将对象序列化为 XML:举例说明至少两种将对象序列化/反序列化为 XML 格式的方案。
-
介绍 JDK 9 反序列化过滤器:简要介绍 JDK 9 反序列化过滤器,包括对
ObjectInputFilterAPI 的见解。 -
实现基于模式的自定义 ObjectInputFilter:提供一个通过
ObjectInputFilterAPI 实现和设置自定义模式过滤器的示例。 -
实现自定义类 ObjectInputFilter:举例说明通过类实现创建
ObjectInputFilter。 -
实现自定义方法 ObjectInputFilter:举例说明通过方法实现创建
ObjectInputFilter。 -
实现自定义 lambda ObjectInputFilter:举例说明通过 lambda 表达式创建
ObjectInputFilter。 -
避免反序列化时的 StackOverflowError:首先,编写一段可以成功序列化的代码片段,但在反序列化阶段会导致
StackOverflowError。其次,编写一个过滤器以避免这种不愉快的场景。 -
避免反序列化时的 DoS 攻击:首先,编写一段可以成功序列化的代码片段,但在反序列化阶段会导致 DoS 攻击。其次,编写一个过滤器以避免这种不愉快的场景。
-
介绍 JDK 17 简化过滤器创建:解释并举例说明使用 JDK 17 的
allowFilter()和rejectFilter()方法。 -
处理上下文特定反序列化过滤器:解释并举例说明使用 JDK 17 过滤器工厂。
-
通过 JFR 监控反序列化:举例说明使用 Java Flight Recorder (JFR)监控反序列化事件。
以下几节描述了前面问题的解决方案。记住,通常没有一种唯一正确的方法来解决特定的问题。此外,记住这里显示的解释只包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节并实验程序,请访问 github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter06。
131. 将对象序列化为字节数组
在 第四章,问题 94 中,我们讨论了 Java 记录的序列化和反序列化,所以你应该对这些操作相当熟悉。简而言之,序列化是将内存中的对象转换成可以存储在内存中或写入文件、网络、数据库、外部存储等字节数流的过程。反序列化是相反的过程,即从给定的字节数流中重新创建对象状态。
一个 Java 对象如果是可序列化的,那么它的类必须实现 java.io.Serializable 接口(或者 java.io.Externalizable 接口)。完成序列化和反序列化的操作是通过 java.io.ObjectOutputStream 和 java.io.ObjectInputStream 类以及 writeObject()/readObject() 方法来实现的。
例如,假设以下 Melon 类:
public class Melon implements Serializable {
private final String type;
private final float weight;
// constructor, getters
}
此外,Melon 的一个实例:
Melon melon = new Melon("Gac", 2500);
将 melon 实例序列化为字节数组可以按照以下方式完成:
public static byte[] objectToBytes(Serializable obj)
throws IOException {
try (ByteArrayOutputStream baos
= new ByteArrayOutputStream();
ObjectOutputStream ois
= new ObjectOutputStream(baos)) {
ois.writeObject(obj);
return baos.toByteArray();
}
}
当然,我们可以使用这个辅助工具来序列化任何其他对象,但对于 melon 实例,我们调用它的方式如下:
byte[] melonSer = Converters.objectToBytes(melon);
反序列化是通过另一个辅助工具完成的,该工具使用 readObject() 如下所示:
public static Object bytesToObject(byte[] bytes)
throws IOException, ClassNotFoundException {
try ( InputStream is = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(is)) {
return ois.readObject();
}
}
我们可以使用这个辅助工具从字节数组反序列化任何其他对象,但对于 melonSer,我们调用它的方式如下:
Melon melonDeser = (Melon) Converters.bytesToObject(melonSer);
返回的 melonDeser 即使不是相同的实例,也能恢复初始对象状态。在捆绑的代码中,你还可以看到基于 Apache Commons Lang 的方法。
132. 将对象序列化为字符串
在上一个问题中,你看到了如何将对象序列化为字节数组。如果我们稍微处理一下字节数组,我们可以获得序列化的字符串表示。例如,我们可以依赖 java.util.Base64 将字节数组编码为 String,如下所示:
public static String objectToString(Serializable obj) throws IOException {
try ( ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream ois = new ObjectOutputStream(baos)) {
ois.writeObject(obj);
return Base64.getEncoder().encodeToString(baos.toByteArray());
}
}
可能的输出如下所示:
rO0ABXNyABZtb2Rlcm4uY2hhbGxlbmdlLk1lbG9u2WrnGA2MxZ4CAAJGAAZ3ZWlnaHRMAAR0eXBldAAST GphdmEvbGFuZy9TdHJpbmc7eHBFHEAAdAADR2Fj
获取此类字符串的代码如下所示:
String melonSer = Converters.objectToString(melon);
反向过程依赖于 Base64 解码器如下所示:
public static Object stringToObject(String obj)
throws IOException, ClassNotFoundException {
byte[] data = Base64.getDecoder().decode(obj);
try ( ObjectInputStream ois = new ObjectInputStream(
new ByteArrayInputStream(data))) {
return ois.readObject();
}
}
调用此方法很简单:
Melon melonDeser = (Melon)
Converters.stringToObject(melonSer);
melonDeser 对象是反序列化前一个字符串的结果。
133. 将对象序列化为 XML
通过 JDK API 将对象序列化/反序列化为 XML 可以通过 java.beans.XMLEncoder 和 XMLDecoder 实现。XMLEncoder API 依赖于 Java 反射来发现对象的字段并将它们写入 XML 格式。此类可以编码遵守 Java Beans 契约的对象(docs.oracle.com/javase/tutorial/javabeans/writing/index.html)。基本上,对象的类应该包含一个公共的无参数构造函数和公共的 private/protected 字段/属性的获取器和设置器。对于 XMLEncoder/XMLDecoder,实现 Serializable 不是强制性的,因此我们可以序列化/反序列化没有实现 Serializable 的对象。这里是一个将给定的 Object 编码为 XML 的辅助方法:
public static String objectToXML(Object obj)
throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try ( XMLEncoder encoder = new XMLEncoder(
new BufferedOutputStream(baos))) {
encoder.writeObject(obj);
}
baos.close();
return new String(baos.toByteArray());
}
反向过程(反序列化)使用 XMLDecoder 如下所示:
public static Object XMLToObject(String xml)
throws IOException {
try ( InputStream is
= new ByteArrayInputStream(xml.getBytes());
XMLDecoder decoder = new XMLDecoder(is)) {
return decoder.readObject();
}
}
XMLEncoder/XMLDecoder 比起 writeObject()/readObject() API 更灵活。例如,如果一个字段/属性被添加/删除/重命名或其类型已更改,那么解码过程会跳过它无法解码的所有内容,并尽可能多地尝试解码而不抛出异常。
另一种常见的方法依赖于第三方库 Jackson 2.x,该库包含 XmlMapper。此库应作为依赖项添加(当然,如果您项目中尚未添加):
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.x</version>
</dependency>
接下来,我们创建一个 XmlMapper 的实例:
XmlMapper xmlMapper = new XmlMapper();
通过 XmlMapper,我们可以将对象序列化为 XML,如下所示(如果对象的类没有实现 Serializable 或不包含设置器,则没有问题):
public static String objectToXMLJackson(Object obj)
throws JsonProcessingException {
XmlMapper xmlMapper = new XmlMapper();
if (xmlMapper.canSerialize(obj.getClass())) {
return xmlMapper.writeValueAsString(obj);
}
return "";
}
调用此方法可以如下进行(melon 是 Melon 类的一个实例):
String melonSer = Converters.objectToXMLJackson(melon);
反向过程可以依赖于 readValue() 如下所示:
public static <T> T XMLToObjectJackson(
String xml, Class<T> clazz)
throws JsonProcessingException {
XmlMapper xmlMapper = new XmlMapper();
return xmlMapper.readValue(xml, clazz);
}
调用此方法可以如下进行:
Melon melonDeser = Converters
.XMLToObjectJackson(melonSer, Melon.class);
仔细探索 XmlMapper API,因为它还有更多功能。现在,考虑运行捆绑的代码,以查看这两种方法各自产生的 XML。
如果您计划将对象序列化/反序列化为 JSON,那么请考虑 Java 编码问题,第一版,问题 141,它提供了一套基于 JSONB、Jackson 和 Gson 的全面示例。
134. 介绍 JDK 9 反序列化过滤器
如您在 第四章,问题 94 中所知,反序列化会暴露于可能导致严重安全问题的漏洞。换句话说,在序列化-反序列化周期之间,一个不受信任的过程(攻击者)可以修改/更改序列化形式以执行任意代码,偷偷植入恶意数据等。
为了防止此类漏洞,JDK 9 引入了通过过滤器创建限制的可能性,这些过滤器旨在根据特定的谓词接受/拒绝反序列化。反序列化过滤器拦截一个期望被反序列化的流,并应用一个或多个谓词,只有成功通过这些谓词才能继续反序列化。如果谓词失败,则反序列化甚至不会开始,流将被拒绝。
有两种类型的过滤器:
-
JVM 全局过滤器:应用于 JVM 中发生的每个反序列化的过滤器。这些过滤器的行为与它们如何与其他过滤器(如果有)结合紧密相关。
-
流过滤器:操作应用程序中所有
ObjectInputStream实例的过滤器(流全局过滤器)或操作某些ObjectInputStream实例的过滤器(流特定过滤器)。
我们可以创建以下类型的过滤器:
-
基于模式的过滤器(称为 模式过滤器):这些过滤器可以通过字符串模式来过滤模块、包或类。它们可以在不接触代码的情况下应用(作为 JVM 全局过滤器)或通过
ObjectInputFilterAPI 创建(作为 模式流过滤器)。 -
基于
ObjectInputFilterAPI 的过滤器:此 API 允许我们在代码中直接定义过滤器。通常,此类过滤器基于字符串模式或 Java 反射定义。
模式过滤器
让我们看看基于字符串模式的几个过滤器。例如,这个过滤器接受来自 foo 包的所有类(以及来自任何其他不是 buzz 的包)的所有类,并拒绝来自 buzz 包的所有类(一个通过以 ! 开头的模式的类会被拒绝):
foo.*;!buzz.*
模式通过分号(;)分隔,空白被认为模式的一部分。
以下过滤器仅拒绝 modern.challenge.Melon 类:
!modern.challenge.Melon
以下过滤器拒绝 modern.challenge 包中的 Melon 类,并接受该包中的所有其他类(* 是用于表示未指定的类/包/模块名称的通配符):
!modern.challenge.Melon;modern.challenge.*;!*
以下过滤器接受 foo 包及其子包中的所有类(注意 ** 通配符):
foo.**
以下过滤器接受所有以 Hash 开头的类:
Hash*
除了过滤类、包和模块之外,我们还可以定义所谓的 资源过滤器,它允许我们根据对象的图复杂性和大小来接受/拒绝资源。在此上下文中,我们有 maxdepth(最大图深度)、maxarray(最大数组大小)、maxrefs(图中对象之间的最大引用数)和 maxbytes(最大流字节数)。以下是一个示例:
maxdepth=5;maxarray=1000;maxrefs=50 foo.buzz.App
现在,让我们看看我们如何使用这样的过滤器。
为每个应用程序应用基于模式的过滤器
如果我们想对一个应用程序的单次运行应用基于模式的过滤器,那么我们可以依赖jdk.serialFilter系统属性。在不接触代码的情况下,我们可以在命令行中使用这个系统属性,如下面的示例所示:
java -Djdk.serialFilter=foo.**;Hash* foo.buzz.App
系统属性替换安全属性值。
将基于模式的过滤器应用于进程中的所有应用程序
要将基于模式的过滤器应用于进程中的所有应用程序,我们应该遵循两个步骤(再次强调,我们不接触应用程序代码):
-
在编辑器(例如,记事本或写字板)中打开
java.security文件。在 JDK 6-8 中,此文件位于$JAVA_HOME/lib/security/java.security,而在 JDK 9+中,它位于$JAVA_HOME/conf/security/java.security。 -
通过将模式追加到
jdk.serialFilter安全属性来编辑此文件。
完成!
基于 ObjectInputFilter 的过滤器
通过ObjectInputFilter API,我们可以根据字符串模式和 Java 反射创建自定义过滤器。这些过滤器可以应用于某些流(流特定过滤器)或所有流(流全局过滤器),并且可以作为基于模式的过滤器、类、方法或 lambda 表达式实现。
首先,我们通过ObjectInputFilter API 实现过滤器。其次,我们在所有/某些ObjectInputStream实例上设置过滤器。将过滤器作为流全局过滤器设置是通过ObjectInputFilter.Config.setSerialFilter(ObjectInputFilter filter)完成的。另一方面,将过滤器作为流特定过滤器可以通过ObjectInputStream.setObjectInputFilter(ObjectInputFilter filter)完成。
例如,通过此 API 创建一个基于模式的过滤器可以通过调用Config.createFilter(String pattern)方法实现。
将过滤器定义为类的自定义过滤器是通过实现ObjectInputFilter功能接口并重写Status checkInput(FilterInfo filterInfo)方法来完成的。
将过滤器定义为方法的自定义过滤器通常通过静态方法static ObjectInputFilter.Status someFilter(FilterInfo info) {…}来实现。
此外,将过滤器定义为 lambda 表达式通常表示为ois.setObjectInputFilter(f -> (…)),其中f是ObjectInputFilter,ois是ObjectInputStream的一个实例。
过滤器返回一个状态(java.io.ObjectInputFilter.Status),可以是ALLOWED、REJECTED或UNDECIDED。
在接下来的问题中,我们将通过示例来探索这些语句。
135. 实现自定义的基于模式的 ObjectInputFilter
假设我们已经有Melon类和从问题 131中序列化/反序列化对象到/从字节数组的辅助方法。
通过ObjectInputFilter API 创建一个基于模式的过滤器可以通过调用Config.createFilter(String pattern)方法实现。例如,以下过滤器拒绝modern.challenge.Melon类:
ObjectInputFilter melonFilter = ObjectInputFilter.Config
.createFilter("!modern.challenge.Melon;");
我们可以通过setSerialFilter()将其作为流全局过滤器设置,如下所示:
ObjectInputFilter.Config.setSerialFilter(melonFilter);
如果我们需要获取对 流全局过滤器 的访问权限,那么我们可以调用 getSerialFilter():
ObjectInputFilter serialFilter =
ObjectInputFilter.Config.getSerialFilter();
在这个应用程序中的任何流反序列化都将通过此过滤器,该过滤器将拒绝 modern.challenge.Melon 的任何实例。你可以在捆绑的代码中练习这个过滤器。
另一方面,如果我们想在一个特定流上设置它,那么我们可以修改我们的 Converters.bytesToObject() 方法以接受一个过滤器如下所示:
public static Object bytesToObject(byte[] bytes,
ObjectInputFilter filter)
throws IOException, ClassNotFoundException {
try ( InputStream is = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(is)) {
// set the filter
ois.setObjectInputFilter(filter);
return ois.readObject();
}
}
如果我们传递 null 作为 filter,则不会应用过滤器。否则,传递的过滤器将应用于当前流:
Melon melon = new Melon("Gac", 2500);
// serialization works as usual
byte[] melonSer = Converters.objectToBytes(melon);
// here, we pass the melonFilter, which rejects the instances
// of modern.challenge.Melon, so deserialization is rejected
Melon melonDeser = (Melon) Converters.bytesToObject(
melonSer, melonFilter);
在这个例子中,melonFilter 将拒绝反序列化,输出如下所示:
Exception in thread "main" java.io.InvalidClassException: filter status: REJECTED
…
你也可以在捆绑的代码中练习这个过滤器。
136. 实现自定义类 ObjectInputFilter
假设我们已经有 Melon 类以及从 问题 131 中序列化和反序列化对象到/从字节数组的辅助方法。
可以通过实现 ObjectInputFilter 功能接口并通过以下示例中的专用类编写 ObjectInputFilter:
public final class MelonFilter implements ObjectInputFilter {
@Override
public Status checkInput(FilterInfo filterInfo) {
Class<?> clazz = filterInfo.serialClass();
if (clazz != null) {
// or, clazz.getName().equals("modern.challenge.Melon")
return
!(clazz.getPackage().getName().equals("modern.challenge")
&& clazz.getSimpleName().equals("Melon"))
? Status.ALLOWED : Status.REJECTED;
}
return Status.UNDECIDED;
}
}
此过滤器与基于模式的过滤器 !modern.challenge.Melon 完全相同,只是它通过 Java 反射表达。
我们可以这样设置这个过滤器作为 流全局过滤器:
ObjectInputFilter.Config.setSerialFilter(new MelonFilter());
或者,作为一个 流特定过滤器 如下所示:
Melon melonDeser = (Melon) Converters.bytesToObject(
melonSer, new MelonFilter());
当然,bytesToObject() 接受类型为 ObjectInputFilter 的参数,并相应地设置此过滤器(ois 是特定的 ObjectInputStream):
ois.setObjectInputFilter(filter);
在这个例子中,MelonFilter 将拒绝反序列化,输出如下所示:
Exception in thread "main" java.io.InvalidClassException: filter status: REJECTED
…
你可以在捆绑的代码中练习这两种方法(流全局 和 流特定)。
137. 实现自定义方法 ObjectInputFilter
假设我们已经有 Melon 类以及从 问题 131 中序列化和反序列化对象到/从字节数组的辅助方法。
可以通过以下示例中的专用方法编写 ObjectInputFilter:
public final class Filters {
private Filters() {
throw new AssertionError("Cannot be instantiated");
}
public static ObjectInputFilter.Status melonFilter(
FilterInfo info) {
Class<?> clazz = info.serialClass();
if (clazz != null) {
// or, clazz.getName().equals("modern.challenge.Melon")
return
!(clazz.getPackage().getName().equals("modern.challenge")
&& clazz.getSimpleName().equals("Melon"))
? Status.ALLOWED :Status.REJECTED;
}
return Status.UNDECIDED;
}
}
当然,你可以在这个类中添加更多过滤器。
我们可以这样设置这个过滤器作为 流全局过滤器:
ObjectInputFilter.Config
.setSerialFilter(Filters::melonFilter);
或者,作为一个 流特定过滤器 如下所示:
Melon melonDeser = (Melon) Converters.bytesToObject(
melonSer, Filters::melonFilter);
当然,bytesToObject() 接受类型为 ObjectInputFilter 的参数,并相应地设置此过滤器(ois 是特定的 ObjectInputStream):
ois.setObjectInputFilter(filter);
在这个例子中,Filters::melonFilter 将拒绝反序列化,输出如下所示:
Exception in thread "main" java.io.InvalidClassException: filter status: REJECTED
…
你可以在捆绑的代码中检查这两种方法(流全局 和 流特定)。此外,你还可以练习另一个基于 流全局过滤器 拒绝 Melon 及其所有子类的示例。
138. 实现自定义 lambda ObjectInputFilter
假设我们已经有 Melon 类以及从 问题 131 中序列化和反序列化对象到/从字节数组的辅助方法。
可以通过一个专门的 lambda 表达式编写 ObjectInputFilter 并将其设置为如下所示的 流全局过滤器:
ObjectInputFilter.Config
.setSerialFilter(f -> ((f.serialClass() != null)
// or, filter.serialClass().getName().equals(
// "modern.challenge.Melon")
&& f.serialClass().getPackage()
.getName().equals("modern.challenge")
&& f.serialClass().getSimpleName().equals("Melon"))
? Status.REJECTED : Status.UNDECIDED);
或者,作为一个 流特定过滤器 如下所示:
Melon melonDeser = (Melon) Converters.bytesToObject(melonSer,
f -> ((f.serialClass() != null)
// or, filter.serialClass().getName().equals(
// "modern.challenge.Melon")
&& f.serialClass().getPackage()
.getName().equals("modern.challenge")
&& f.serialClass().getSimpleName().equals("Melon"))
? Status.REJECTED : Status.UNDECIDED);
你可以在捆绑的代码中练习这些示例。
139. 避免反序列化时的 StackOverflowError
of code:
// 'mapOfSets' is the object to serialize/deserialize
HashMap<Set, Integer> mapOfSets = new HashMap<>();
Set<Set> set = new HashSet<>();
mapOfSets.put(set, 1);
set.add(set);
我们计划如下序列化 mapOfSets 对象(我假设 Converters.objectToBytes() 在之前的问题中是已知的):
byte[] mapSer = Converters.objectToBytes(mapOfSets);
一切工作正常,直到我们尝试反序列化 mapSer。在那个时刻,,我们不会得到一个有效的对象,而是一个 StackOverflowError,如下所示:
Exception in thread "main" java.lang.StackOverflowError
at java.base/java.util.HashMap$KeyIterator
.<init>(HashMap.java:1626)
at java.base/java.util.HashMap$KeySet
.iterator(HashMap.java:991)
at java.base/java.util.HashSet
.iterator(HashSet.java:182)
at java.base/java.util.AbstractSet
.hashCode(AbstractSet.java:120)
at java.base/java.util.AbstractSet
.hashCode(AbstractSet.java:124)
...
反序列化过程卡在了 Set 的 hashCode() 方法上。解决方案是创建一个过滤器,如果对象图深度大于 2,则拒绝反序列化。这可以是一个如下所示的基于模式的过滤器:
ObjectInputFilter filter = ObjectInputFilter.Config
.createFilter("maxdepth=2;java.base/*;!*");
接下来,使用此过滤器调用反序列化过程:
HashMap mapDeser = (HashMap) Converters
.bytesToObject(mapSer, filter);
我假设 Converters.bytesToObject() 在之前的问题中是已知的。这次,反序列化被过滤器拒绝,而不是得到 StackOverflowError。
140. 避免反序列化时的 DoS 攻击
拒绝服务(DoS)攻击通常是恶意行为,旨在短时间内触发对服务器、应用程序等的大量请求。一般来说,DoS 攻击是任何故意或意外地使进程过载并迫使它减速或甚至崩溃的行为。让我们看看一个代码片段,它是一个在反序列化阶段表示 DoS 攻击的好候选:
ArrayList<Object> startList = new ArrayList<>();
List<Object> list1 = startList;
List<Object> list2 = new ArrayList<>();
for (int i = 0; i < 101; i++) {
List<Object> sublist1 = new ArrayList<>();
List<Object> sublist2 = new ArrayList<>();
sublist1.add("value: " + i);
list1.add(sublist1);
list1.add(sublist2);
list2.add(sublist1);
list2.add(sublist2);
list1 = sublist1;
list2 = sublist2;
}
我们计划如下序列化 startList 对象(我假设 Converters.objectToBytes() 在之前的问题中是已知的):
byte[] startListSer = Converters.objectToBytes(startList);
一切工作正常,直到我们尝试反序列化 startListSer。在那个时刻,我们不会得到一个有效的对象,而是一无所获!实际上,应用程序会正常启动,但它只是在反序列化阶段挂起。系统变慢,过一段时间后,它最终会崩溃。
对象图太深,无法反序列化,这会导致类似拒绝服务(DoS)攻击的行为。解决方案是创建一个过滤器,如果对象图深度大于一个安全值,则拒绝反序列化。这可以是一个如下所示的基于模式的过滤器:
ObjectInputFilter filter = ObjectInputFilter.Config
.createFilter("maxdepth=10;java.base/*;!*");
接下来,使用此过滤器调用反序列化过程:
ArrayList startListDeser = (ArrayList)
Converters.bytesToObject(startListSer, filter);
我假设 Converters.bytesToObject() 在之前的问题中是已知的。这次,反序列化被过滤器拒绝,从而防止了 DoS 攻击。
141. 引入 JDK 17 简易过滤器创建
从 JDK 17 开始,我们可以通过两个名为 allowFilter() 和 rejectFilter() 的方便方法更直观、更易读地表达过滤器。而且,最好的学习方法是通过例子,以下是对这两个方便方法的用法示例:
public final class Filters {
private Filters() {
throw new AssertionError("Cannot be instantiated");
}
public static ObjectInputFilter allowMelonFilter() {
ObjectInputFilter filter = ObjectInputFilter.allowFilter(
clazz -> Melon.class.isAssignableFrom(clazz),
ObjectInputFilter.Status.REJECTED);
return filter;
}
public static ObjectInputFilter rejectMuskmelonFilter() {
ObjectInputFilter filter = ObjectInputFilter.rejectFilter(
clazz -> Muskmelon.class.isAssignableFrom(clazz),
ObjectInputFilter.Status.UNDECIDED);
return filter;
}
}
allowMelonFilter() 方法依赖于 ObjectInputFilter.allowFilter() 来允许只有 Melon 或其子类的实例对象。rejectMuskmelonFilter() 方法依赖于 ObjectInputFilter.rejectFilter() 来拒绝所有 Muskmelon 或其子类的实例对象。
我们可以使用这些过滤器,就像你从上一个问题中已经知道的那样,所以让我们解决另一个用例。假设我们有以下类的层次结构:

图 6.1:类的任意层次结构
假设我们只想反序列化Melon或Melon的子类的实例,但它们不是Muskmelon或Muskmelon的子类。换句话说,我们允许反序列化Melon和Cantaloupe的实例。
如果我们应用allowMelonFilter(),那么我们将反序列化Melon、Muskmelon、Cantaloupe、HoneyDew和Persian的实例,因为这些全部都是Melon。
另一方面,如果我们应用rejectMuskmelonFilter(),那么我们将反序列化Melon、Cantaloupe和Pumpkin的实例,因为这些不是Muskmelon。
但是,如果我们先应用rejectMuskmelonFilter(),然后再应用allowMelonFilter(),那么我们只会反序列化Melon和Cantaloupe,这正是我们想要的。
直观地,我们可能会认为通过编写类似以下内容来链式连接我们的过滤器(ois是当前的ObjectInputStream):
ois.setObjectInputFilter(Filters.allowMelonFilter());
ois.setObjectInputFilter(Filters.rejectMuskmelonFilter());
但这不会起作用!它将导致java.lang.IllegalStateException:过滤器不能设置超过一次。
该解决方案依赖于ObjectInputFilter.merge(filter, anotherFilter),它返回一个通过应用以下逻辑合并这两个过滤器状态的过滤器:
-
调用
filter并获取返回的status -
如果返回的
status是REJECTED,则返回它 -
调用
anotherFilter并获取返回的otherStatus -
如果
anotherStatus是REJECTED,则返回它 -
如果
status或otherStatus是ALLOWED,则返回ALLOWED -
否则,返回
UNDECIDED
然而,如果anotherFilter是null,则返回过滤器。
根据这个逻辑,我们可以将Filters.allowMelonFilter()的状态与Filters.rejectMuskmelonFilter()的状态合并如下:
public static Object bytesToObject(byte[] bytes,
ObjectInputFilter allowFilter,
ObjectInputFilter rejectFilter)
throws IOException, ClassNotFoundException {
try ( InputStream is = new ByteArrayInputStream(bytes);
ObjectInputStream ois = new ObjectInputStream(is)) {
// set the filters
ObjectInputFilter filters = ObjectInputFilter.merge(
allowFilter, rejectFilter);
ois.setObjectInputFilter(filters);
return ois.readObject();
}
}
接下来,让我们谈谈 JDK 17 的过滤器工厂。
142. 解决特定上下文反序列化过滤器
JDK 17 通过实现 JEP 415,即上下文特定反序列化过滤器,丰富了反序列化过滤器的功能。
实际上,JDK 17 添加了所谓的过滤器工厂。根据上下文,过滤器工厂可以动态决定为流使用哪些过滤器。
为每个应用程序应用过滤器工厂
如果我们想要将过滤器工厂应用于应用程序的单次运行,则可以依赖jdk.serialFilterFactory系统属性。在不接触代码的情况下,我们可以在命令行中使用此系统属性,如下例所示:
java -Djdk.serialFilterFactory=FilterFactoryName YourApp
FilterFactoryName是过滤器工厂的完全限定名,它是一个可以被应用程序类加载器访问的公共类,它是在第一次反序列化之前设置的。
将过滤器工厂应用于进程中的所有应用程序
要将 Filter Factory 应用到进程中的所有应用程序,我们应该遵循两个步骤(再次,我们不修改应用程序代码):
-
在一个编辑器中(例如,记事本或写字板),打开
java.security文件。在 JDK 6-8 中,此文件位于$JAVA_HOME/lib/security/java.security,而在 JDK 9+ 中,它位于$JAVA_HOME/conf/security/java.security。 -
通过将 Filter Factory 添加到
jdk.serialFilterFactory安全属性来编辑此文件。
通过 ObjectInputFilter.Config 应用 Filter Factory
或者,可以通过 ObjectInputFilter.Config 直接在代码中设置 Filter Factory,如下所示:
ObjectInputFilter.Config
.setSerialFilterFactory(FilterFactoryInstance);
FilterFactoryInstance 参数是一个 Filter Factory 的实例。这个 Filter Factory 将应用于当前应用程序的所有流。
实现 Filter Factory
Filter Factory 被实现为一个 BinaryOperator<ObjectInputFilter>。apply(ObjectInputFilter current, ObjectInputFilter next) 方法提供了 当前过滤器 和 下一个 或 请求过滤器。
为了了解它是如何工作的,让我们假设我们有以下三个过滤器:
public final class Filters {
private Filters() {
throw new AssertionError("Cannot be instantiated");
}
public static ObjectInputFilter allowMelonFilter() {
ObjectInputFilter filter = ObjectInputFilter.allowFilter(
clazz -> Melon.class.isAssignableFrom(clazz),
ObjectInputFilter.Status.REJECTED);
return filter;
}
public static ObjectInputFilter rejectMuskmelonFilter() {
ObjectInputFilter filter = ObjectInputFilter.rejectFilter(
clazz -> Muskmelon.class.isAssignableFrom(clazz),
ObjectInputFilter.Status.UNDECIDED);
return filter;
}
public static ObjectInputFilter packageFilter() {
return ObjectInputFilter.Config.createFilter(
"modern.challenge.*;!*");
}
}
Filters.allowMelonFilter() 被设置为如下所示的 流全局过滤器:
ObjectInputFilter.Config.setSerialFilter(
Filters.allowMelonFilter());
Filters.rejectMuskmelonFilter() 被设置为如下所示的 流特定过滤器:
Melon melon = new Melon("Melon", 2400);
// serialization
byte[] melonSer = Converters.objectToBytes(melon);
// deserialization
Melon melonDeser = (Melon) Converters.bytesToObject(
melonSer, Filters.rejectMuskmelonFilter());
此外,Filters.packageFilter() 在 Filter Factory 中设置为如下所示:
public class MelonFilterFactory implements
BinaryOperator<ObjectInputFilter> {
@Override
public ObjectInputFilter apply(
ObjectInputFilter current, ObjectInputFilter next) {
System.out.println();
System.out.println("Current filter: " + current);
System.out.println("Requested filter: " + next);
if (current == null && next != null) {
return ObjectInputFilter.merge(
next, Filters.packageFilter());
}
return ObjectInputFilter.merge(next, current);
}
}
MelonFilterFactory 通过 ObjectInputFilter.Config 在任何反序列化之前设置:
MelonFilterFactory filterFactory = new MelonFilterFactory();
ObjectInputFilter.Config
.setSerialFilterFactory(filterFactory);
现在一切准备就绪,让我们看看发生了什么。apply() 方法被调用了两次。第一次调用是在创建 ObjectInputStream ois 时,我们得到了以下输出:
Current filter: null
Requested filter:
predicate(modern.challenge.Filters$$Lambda$4/0x000000080
1001800@ba8a1dc, ifTrue: ALLOWED,ifFalse: REJECTED)
当前过滤器 是 null,而 请求过滤器 是 Filters.allowMelonFilter()。由于 当前过滤器 是 null 且 请求过滤器 不是 null,我们决定返回一个过滤器作为合并 请求过滤器 状态和 Filters.packageFilter() 状态的结果。
第二次调用 apply() 方法发生在 Converters.bytesToObject(byte[] bytes, ObjectInputFilter filter) 中的 ois.setObjectInputFilter(filter) 调用。我们有以下输出:
Current filter: merge(predicate(modern.challenge.Filters$$Lambda$4/0x0000000801001800@ba8a1dc, ifTrue: ALLOWED, ifFalse:REJECTED), modern.challenge.*;!*)
Requested filter: predicate(modern.challenge.Filters$$Lambda$10/0x0000000801002a10@484b61fc, ifTrue: REJECTED, ifFalse:UNDECIDED)
这次,当前 和 请求 的过滤器都不是 null,所以我们再次合并它们的状态。最终,所有过滤器都成功通过,反序列化发生。
143. 通过 JFR 监控反序列化
Java 飞行记录器 (JFR) 是一个基于事件的工具,用于诊断和剖析 Java 应用程序。这个工具最初在 JDK 7 中添加,从那时起,它一直在不断改进。例如,在 JDK 14 中,JFR 通过事件流得到了丰富,在 JDK 19 中,通过过滤事件能力,等等。您可以在每个 JDK 版本中找到并记录所有 JFR 事件,请参阅 sap.github.io/SapMachine/jfrevents/。
在其丰富的事件列表中,JFR 可以监控和记录反序列化事件(反序列化事件)。让我们假设一个简单的应用程序,如问题 131(本章的第一个问题)。我们开始配置 JFR 以监控此应用程序的反序列化,通过将deserializationEvent.jfc添加到应用程序的根目录:
<?xml version="1.0" encoding="UTF-8"?>
<configuration version="2.0" description="test">
<event name="jdk.Deserialization">
<setting name="enabled">true</setting>
<setting name="stackTrace">false</setting>
</event>
</configuration>
实际上,此文件指示 JFR 监控和记录反序列化事件。
接下来,我们使用-XX:StartFlightRecording=filename=recording.jfr来指示 JFR 将输出记录到名为recording.jfr的文件中,然后我们继续使用settings=deserializationEvent.jfc来指出之前列出的配置文件。
因此,最终的命令是图中所示的那个:

图 6.2:运行 JFR
执行此命令后,您将看到如图 6.3 所示的输出:

图 6.3:我们应用程序的输出
JFR 生成了一个名为recording.jfr的文件。我们可以轻松地通过 JFR CLI 查看此文件的内容。命令(jfr print recording.jfr)和输出如图 6.4 所示:

图 6.4:包含反序列化信息的 JFR 输出
由于我们的应用程序只执行了一个Melon对象的序列化/反序列化周期,JFR 产生了一个单独的反序列化事件。您可以通过type字段看到对象的类型(在这里,Melon)。由于Melon实例不是数组,arrayLength被设置为-1,这意味着数组不适用。objectReferences代表流中的第一个对象引用(因此,1),而bytesRead代表从该流中读取的字节数(在这种情况下,78字节)。我们还看到没有过滤器存在,filterConfigured = false,filterStatus = N/A(不适用)。此外,exceptionType和exceptionMessage都是N/A。它们不适用,因为没有过滤器存在。它们对于捕获由潜在过滤器引起的任何异常是有用的。
除了 JFR CLI 之外,您还可以使用更强大的工具来消费反序列化事件,例如 JDK Mission Control (www.oracle.com/java/technologies/jdk-mission-control.html) 和知名的 Advanced Management Console (www.oracle.com/java/technologies/advancedmanagementconsole.html)。
摘要
在本章中,我们介绍了一系列专门用于处理 Java 序列化/反序列化过程的问题。我们首先从经典问题开始,然后继续介绍在 JDK 9 反序列化过滤器的基础上通过 JDK 17 上下文特定反序列化过滤器。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第七章:外部(函数)内存 API
本章包括涵盖外部内存 API 和外部链接器 API 的 28 个问题。我们将从依赖于 JNI API 和开源 JNA/JNR 库调用外国函数的经典方法开始。接下来,我们将介绍名为 Project Panama 的新方法(在 JDK 21 中的第三次审查和 JDK 22 中的最终发布作为 JEP 454)。我们将分析最相关的 API,如Arena、MemorySegment、MemoryLayout等。最后,我们将关注外部链接器 API 和 Jextract 工具,用于调用具有不同类型签名的国外函数,包括回调函数。
到本章结束时,你将能够熟练地使用 JNI、JNA、JNR,当然还有 Project Panama,并且能够自信地回答任何与这个主题相关的问题。
问题
使用以下问题来测试你在操作堆外内存和从 Java 调用原生外国函数方面的编程能力。我强烈建议你在查看解决方案并下载示例程序之前,尝试解决每个问题:
-
介绍 Java Native Interface (JNI):编写一个 Java 应用程序,通过 JNI API 调用 C/C++原生外国函数(例如,在 C 中实现以下签名的函数:
long sumTwoInt(int x, int y))。 -
介绍 Java Native Access (JNA):编写一个 Java 应用程序,通过 JNA API 调用 C/C++原生外国函数。
-
介绍 Java Native Runtime (JNR):编写一个 Java 应用程序,通过 JNR API 调用 C/C++原生外国函数。
-
激励并介绍 Project Panama:从操作堆外内存和外国函数的经典方法到新的 Project Panama 提供一个理论上有意义的过渡。
-
介绍 Panama 的架构和术语:简要描述 Project Panama,包括架构、术语和主要 API 组件。
-
介绍 Arena 和 MemorySegment:通过代码片段解释并举例说明
Arena和MemorySegmentAPI。 -
将数组分配到内存段中:编写几种将数组分配到内存段中的方法(通过
Arena和MemorySegment)。 -
理解地址(指针):举例说明在 Java 中使用内存地址(指针)(
ValueLayout.ADDRESS)的用法。 -
介绍序列布局:解释并举例说明序列布局的用法。此外,介绍
PathElement和VarHandleAPI。 -
将 C-like 结构体塑造成内存段:举例说明通过 Java 内存布局(
StructLayout)塑造 C-like 结构体的方法。 -
将 C-like 联合体塑造成内存段:举例说明通过 Java 内存布局(
UnionLayout)塑造 C-like 联合体的方法。 -
介绍 PaddingLayout:详细解释并给出有意义的示例,解释填充布局(介绍 size、alignment、stride、padding 和 order 的字节)。
-
复制和切片内存段:举例说明复制和切片内存段部分的不同方法,包括
asOverlappingSlice()和segmentOffset()。 -
处理切片分配器:举例说明切片分配器 (
SegmentAllocator) 的使用。 -
介绍切片句柄:解释并举例说明
sliceHandle()的使用。 -
介绍布局扁平化:考虑一个分层内存布局(例如,两个嵌套序列布局)。解释并举例说明如何扁平化此模型。
-
介绍布局重塑:提供一个示例,展示如何重塑分层序列布局。
-
介绍布局展开器:简要解释并给出使用布局展开器 (
asSpreader()) 的简单示例。 -
介绍内存段视图 VarHandle:举例说明如何使用
MethodHandles.memorySegmentViewVarHandle(ValueLayout layout)创建一个可以用于访问内存段的VarHandle。 -
流式处理内存段:编写几个代码片段,展示如何将内存段与 Java Stream API 结合使用。
-
处理映射内存段:简要介绍映射内存段,并在 Java 代码中举例说明。
-
介绍 Foreign Linker API:简要描述 Foreign Linker API,包括
Linker、SymbolLookup、downcall 和 upcall。 -
调用 sumTwoInt() 外部函数:编写一个 Java 应用程序,通过 Foreign Linker API 调用
sumTwoInt()方法(在 问题 144 中实现的long sumTwoInt(int x, int y))。 -
调用 modf() 外部函数:使用 Foreign Linker API 调用
modf()外部函数——此函数是 C 标准库的一部分。 -
调用 strcat() 外部函数:使用 Foreign Linker API 调用
strcat()外部函数——此函数是 C 标准库的一部分。 -
调用 bsearch() 外部函数:使用 Foreign Linker API 调用
bsearch()外部函数——此函数是 C 标准库的一部分。 -
介绍 Jextract:简要描述 Jextract 工具,包括主要选项。
-
为 modf() 生成原生绑定:举例说明如何结合 Jextract 和 Foreign Linker API 来调用
modf()外部函数。
以下几节描述了解决上述问题的方法。请记住,通常没有一种正确的方法来解决特定的问题。此外,请记住,这里所示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节并实验程序,请访问 github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter07。
144. 介绍 Java 本地接口 (JNI)
Java 本地接口(JNI)是第一个旨在作为 JVM 字节码和用另一种编程语言(通常是 C/C++)编写的本地代码之间的桥梁的 Java API。
假设我们计划在 Windows 10,64 位机器上通过 JNI 调用一个 C 函数。
例如,让我们考虑我们有一个名为 sumTwoInt(int x, int y) 的 C 函数,用于计算两个整数的和。这个函数定义在一个名为 math.dll 的 C 共享库中。从 Java(一般来说,由本地共享库实现的函数)调用此类函数,首先需要通过 System.loadLibrary(String library) 加载适当的共享本地库。接下来,我们通过 native 关键字在 Java 中声明 C 函数。最后,我们用以下代码调用它:
package modern.challenge;
public class Main {
static {
System.loadLibrary("math");
}
private native long sumTwoInt(int x, int y);
public static void main(String[] args) {
long result = new Main().sumTwoInt(3, 9);
System.out.println("Result: " + result);
}
}
接下来,我们关注 C 实现。我们需要头文件(.h 文件)和实现此方法的源文件(.cpp 文件)。
生成头文件 (.h)
通过在 Main.java 源文件上运行 javac 命令并使用 –h 选项(在 JDK 9 之前使用 javah),我们可以获得头文件(方法的定义):

图 7.1:运行 javac –h 编译源代码并生成 .h 文件
或者,作为纯文本:
C:\SBPBP\GitHub\Java-Coding-Problems-Second-Edition\Chapter07\P144_EngagingJNI>
javac –h src/main/java/modern/challenge/cpp –d target/classes src/main/java/modern/challenge/Main.java
此命令编译我们的代码(Main.java),并将生成的类放置在 target/classes 文件夹中。此外,此命令在 jni/cpp 中生成 C 头文件 modern_challenge_Main.h。此文件的重要代码如下:
/*
* Class: modern_challenge_Main
* Method: sumTwoInt
* Signature: (II)J
*/
JNIEXPORT jlong JNICALL Java_modern_challenge_Main_sumTwoInt
(JNIEnv *, jobject, jint, jint);
函数名称被生成为 Java_modern_challenge_Main_sumTwoInt。此外,我们还有以下工件:
-
JNIEXPORT– 函数被标记为可导出 -
JNICALL– 维持JNIEXPORT以确保函数可以通过 JNI 被找到 -
JNIEnv– 表示指向 JNI 环境的指针,用于访问 JNI 函数 -
jobject– 表示对 Java 对象的引用
实现 modern_challenge_Main.cpp
接下来,我们在 src/main/java/modern/challenge/cpp 中提供 C 实现如下:
#include <iostream>
#include "modern_challenge_Main.h"
JNIEXPORT jlong JNICALL Java_modern_challenge_Main_sumTwoInt
(JNIEnv* env, jobject thisObject, jint x, jint y) {
std::cout << "C++: The received arguments are : "
<< x << " and " << y << std::endl;
return (long)x + (long)y;
}
x + y as a long result.
编译 C 源代码
到目前为止,我们有 C 源代码(.cpp 文件)和生成的头文件(.h 文件)。接下来,我们必须编译 C 源代码,为此我们需要一个 C 编译器。有许多选项,如 Cygwin、MinGW 等。
我们决定为 64 位平台安装 MinGW (sourceforge.net/projects/mingw-w64/) 并使用 G++编译器。
我们手头有 G++,必须触发一个特定的命令来编译 C 代码,如下面的图所示:

图 7.2:编译 C 源代码
或者,以纯文本形式:
C:\SBPBP\GitHub\Java-Coding-Problems-Second-Edition\Chapter07\P144_EngagingJNI>
g++ -c "-I%JAVA_HOME%\include" "-I%JAVA_HOME%\include\win32"
src/main/java/modern/challenge/cpp/modern_challenge_Main.cpp
–o jni/cpp/modern_challenge_Main.o
接下来,我们必须将所有内容打包到math.dll中。
生成本地共享库
是时候创建本地共享库了,math.dll。为此,我们再次使用 G++,如下面的图所示:

图 7.3:创建 math.dll
或者,以纯文本形式:
C:\SBPBP\GitHub\Java-Coding-Problems-Second-Edition\Chapter07\P144_EngagingJNI>
g++ -shared –o jni/cpp/math.dll jni/cpp/modern_challenge_Main.o
–static –m64 –Wl,--add-stdcall-alias
注意,我们使用了–static选项。此选项指示 G++将所有依赖项添加到math.dll中。如果你不喜欢这种方法,那么你可能需要手动添加依赖项,以避免java.lang.UnsatisfiedLinkError错误。要找出缺失的依赖项,你可以使用 DLL 依赖项遍历工具,例如这个:github.com/lucasg/Dependencies。
最后,运行代码
最后,我们可以运行代码。请交叉手指,并按照以下图中的命令执行:

图 7.4:执行 Java 代码
或者,以纯文本形式:
C:\SBPBP\GitHub\Java-Coding-Problems-Second-Edition\Chapter07\P144_EngagingJNI> java –Djava.library.path=jni/cpp
–classpath target/classes modern.challenge.Main
注意,我们应该设置库路径;否则,Java 将无法加载math.dll。如果一切顺利,那么你应该看到这个图中的输出。
好吧,正如你可以轻易得出的结论,JNI 并不容易使用。想象一下,为像 TensorFlow 这样的整个 C 库(有 200 多个函数)做所有这些工作。JNI 不仅难以使用,而且存在许多缺点,例如,它容易出错、难以维护、脆弱,它对异常的支持较差,JNI 错误可能导致 JVM 崩溃,它通过ByteBuffer分配了最大 2GB 的堆外内存,这些内存不能直接释放(我们必须等待垃圾收集器来处理),还有更多。尽管如此,学习这项技术仍然值得,因为正如你肯定知道的,管理通常不会迅速采用新的做事方式。
考虑到这一点,社区提出了其他方法,我们将在下一个问题中进行讨论。
145. 介绍 Java 本地访问(JNA)
Java 本地访问(JNA)是一个勇敢的开源尝试,通过更直观且易于使用的 API 来解决 JNI 的复杂性。作为一个第三方库,JNA 必须作为依赖项添加到我们的项目中:
<dependency>
<groupId>net.java.dev.jna</groupId>
<artifactId>jna-platform</artifactId>
<version>5.8.0</version>
</dependency>
接下来,让我们尝试从问题 144中调用相同的sumTwoInt()方法。这个函数定义在名为math.dll的 C 本地共享库中,并存储在我们的项目中的jna/cpp文件夹中。
我们首先编写一个扩展 JNA 的Library接口的 Java 接口。该接口包含从 Java 调用并在本地代码中定义的方法和类型的声明。我们编写包含sumTwoInt()声明的SimpleMath接口如下:
public interface SimpleMath extends Library {
long sumTwoInt(int x, int y);
}
接下来,我们必须指导 JNA 加载math.dll库并生成该接口的具体实现,这样我们就可以调用其方法。为此,我们需要jna.library.path系统属性和 JNA 的Native类,如下所示:
package modern.challenge;
public class Main {
public static void main(String[] args) {
System.setProperty("jna.library.path", "./jna/cpp");
SimpleMath math = Native.load(Platform.isWindows()
? "math" : "NOT_WINDOWS", SimpleMath.class);
long result = math.sumTwoInt(3, 9);
System.out.println("Result: " + result);
}
}
在这里,我们指导 JNA 从jna/cpp通过System.setProperty()加载math.dll,但您也可以通过终端使用–Djna.library.path=jna/cpp来完成此操作。
接下来,我们调用Native.load(),它接受两个参数。首先,它接受本地库名称,在我们的例子中是math(不带.dll扩展名)。其次,它接受包含方法声明的 Java 接口,在我们的例子中是SimpleMath.class。load()方法返回一个具体的SimpleMath实现,我们用它来调用sumTwoInt()方法。
JNA 的Platform辅助类允许我们提供针对当前操作系统的特定本地库的名称。我们只有 Windows 上的math.dll。
实现.cpp 和.h 文件
这次,.cpp和.h文件没有命名约定,所以让我们将它们命名为Arithmetic.cpp和Arithmetic.h(头文件是可选的)。Arithmetic.cpp的源代码基本上是纯 C 代码:
#include <iostream>
#include "Arithmetic.h"
long sumTwoInt(int x, int y) {
std::cout << "C++: The received arguments are : " << x <<
" and " << y << std::endl;
return (long)x + (long)y;
}
如您所见,使用 JNA,我们不需要用 JNI 特定的桥接代码修补我们的代码。它只是纯 C 代码。《Arithmetic.h》是可选的,我们可以这样编写它:
#ifndef FUNCTIONS_H_INCLUDED
#define FUNCTIONS_H_INCLUDED
long sumTwoInt(int x, int y);
#endif
接下来,我们可以编译我们的代码。
编译 C 源代码
编译 C 源代码是通过以下图中的 G++编译器完成的:

图 7.5:编译 C++代码
或者,作为纯文本:
C:\SBPBP\GitHub\Java-Coding-Problems-Second-Edition\Chapter07\P145_EngagingJNA>
g++ -c "-I%JAVA_HOME%\include" "-I%JAVA_HOME%\include\win32"
src/main/java/modern/challenge/cpp/Arithmetic.cpp
–o jna/cpp/Arithmetic.o
接下来,我们可以生成适当的本地库。
生成本地共享库
是时候创建本地共享库math.dll了。为此,我们再次使用 G++,如图所示:

图 7.6:生成 math.dll
或者,作为纯文本:
C:\SBPBP\GitHub\Java-Coding-Problems-Second-Edition\Chapter07\P145_EngagingJNA>g++ -shared –o jna/cpp/math.dll jna/cpp/Arithmetic.o –static –m64 –Wl,--add-stdcall-alias
到目前为止,你应该已经在jna/cpp文件夹中有math.dll。
最后,运行代码
最后,我们可以运行代码。如果一切顺利,那么你就完成了。否则,如果你收到一个异常,比如java.lang.UnsatisfiedLinkError: 查找函数'sumTwoInt'时出错:指定的过程找不到,那么你必须修复它。
但发生了什么?很可能是 G++编译器应用了一种称为名称混淆(或,名称装饰)的技术——en.wikipedia.org/wiki/Name_mangling。换句话说,G++编译器已经将sumTwoInt()方法重命名为 JNA 所不知道的另一个名称。
解决这个问题可以分为两个步骤。首先,我们需要使用像这样的 DLL 依赖关系查看器来检查math.dll,例如这个:github.com/lucasg/Dependencies。如图所示,G++已经将sumTwoInt重命名为_Z9sumTwoIntii(当然,在您的计算机上,它可能还有另一个名称):

图 7.7:G++已将 sumToInt 重命名为 _Z9sumTwoIntii
其次,我们必须告诉 JNA 这个名称(_Z9sumTwoIntii)。基本上,我们需要定义一个包含名称对应映射的Map,并将这个映射传递给一个接受此映射作为最后一个参数的Native.load()方法的变体。代码很简单:
public class Main {
private static final Map MAPPINGS;
static {
MAPPINGS = Map.of(
Library.OPTION_FUNCTION_MAPPER,
new StdCallFunctionMapper() {
Map<String, String> methodNames
= Map.of("sumTwoInt", "_Z9sumTwoIntii");
@Override
public String getFunctionName(
NativeLibrary library, Method method) {
String methodName = method.getName();
return methodNames.get(methodName);
}
});
}
public static void main(String[] args) {
System.setProperty("jna.library.path", "./jna/cpp");
SimpleMath math = Native.load(Platform.isWindows()
? "math" : "NOT_WINDOWS", SimpleMath.class, MAPPINGS);
long result = math.sumTwoInt(3, 9);
System.out.println("Result: " + result);
}
}
完成!现在,你应该得到 3+9 的结果。请随意进一步探索 JNA,并尝试使用 C/C++结构、联合和指针。
146. 介绍 Java Native Runtime (JNR)
Java Native Runtime(JNR)是另一个开源尝试解决 JNI 复杂性的方法。它是对 JNA 的严肃竞争,拥有比 JNI 更直观和强大的 API。
我们可以将其作为依赖项添加,如下所示:
<dependency>
<groupId>com.github.jnr</groupId>
<artifactId>jnr-ffi</artifactId>
<version>2.2.13</version>
</dependency>
假设我们拥有与问题 145中完全相同的 C 方法(sumTwoInt())和本地共享库(math.dll)。
我们首先编写一个 Java 接口,其中包含我们计划从 Java 调用并在本地代码中定义的方法和类型的声明。我们编写包含sumTwoInt()声明的SimpleMath接口如下:
public interface SimpleMath {
@IgnoreError
long sumTwoInt(int x, int y);
}
@IgnoreError注解指示 JNR 不要保存errno 值(www.geeksforgeeks.org/errno-constant-in-c/)。
接下来,我们必须指示 JNR 加载math.dll库并生成此接口的实体实现,这样我们就可以调用其方法。为此,我们需要LibraryLoader和以下直观的代码:
public class Main {
public static void main(String[] args) {
LibraryLoader<SimpleMath> loader =
FFIProvider.getSystemProvider()
.createLibraryLoader(SimpleMath.class)
.search("./jnr/cpp")
.map("sumTwoInt", "_Z9sumTwoIntii");
loader = loader.map("sumTwoInt", "_Z9sumTwoIntii");
if (Platform.getNativePlatform().getOS()
== Platform.OS.WINDOWS) {
SimpleMath simpleMath = loader.load("math");
long result = simpleMath.sumTwoInt(3, 9);
System.out.println("Result: " + result);
}
}
}
通过LibraryLoader API,我们准备游乐场。我们通过search()方法指示 JNR 我们的库位于jnr/cpp。此外,我们通过map()方法提供方法名称的正确映射(记得从问题 145中,G++通过名称修饰(或,名称装饰)将方法从sumTwoInt重命名为_Z9sumTwoIntii)。
最后,我们通过load()方法加载库,并调用sumTwoInt()方法。
JNR 提供了许多其他功能,您可以从github.com/jnr开始利用。您可能还对 JavaCPP 感兴趣,它是 JNI 的另一个替代方案(github.com/bytedeco/javacpp)。
147. 介绍和激励 Project Panama
Project Panama,或 Foreign Function & Memory (FFM) API,是告别 JNI 的一种优雅方式。这个项目始于 JDK 17 作为 JEP 412(第一个孵化器)。它继续在 JDK 18 作为 JEP 419(第二个孵化器),JDK 19 作为 JEP 424(第一个预览),JDK 20 作为 JEP 434(第二个预览),以及 JDK 21 作为 JEP 442(第三个预览)。这就是撰写本文时的状况。
要了解这个项目的目标,我们必须谈谈从 Java 应用程序访问堆外内存。通过堆外内存,我们指的是位于 JVM 堆之外且不由垃圾收集器管理的内存。
在堆外内存中冲浪是 JNI、JNA 和 JNR 的工作。以某种方式,这些 API 可以在堆外空间中工作以处理不同的任务。在这些任务中,我们可以列举以下内容:
-
使用本地库(例如,一些常见的库有 Open CL/GL, CUDA, TensorFlow, Vulkan, OpenSSL, V8, BLAS, cuDNN 等)
-
在不同进程间共享内存
-
将内存内容序列化/反序列化到所谓的mmaps
Java 完成这些任务的事实上的 API 是ByteBuffer,或者更好的是所谓的分配的直接缓冲区,在访问堆外内存方面更高效。或者,我们可以使用 JNI,或者如您所见,第三方库如 JNA 和 JNR。
然而,ByteBuffer和 JNI 有很多缺点,使得它们仅在有限数量的场景中才有用。以下列出了一些它们的缺点:
-
ByteBuffer:-
脆弱且易出错
-
不稳定的内存地址
-
由垃圾收集器可以操作的数组支持
-
分配直接缓冲区
-
当用作通用 off-heap API 时无法扩展
-
只有当由深入了解其使用的强大用户使用时才表现良好
-
没有解决方案用于释放/释放内存
-
-
JNI:
-
如您在问题 144中看到的,JNI 难以使用(即使是简单的情况)
-
它是脆弱且易出错的
-
维护困难/昂贵
-
错误检查较差
-
它可能导致 JVM 崩溃
-
这些缺点以及更多是 Project Panama 创建的原因。这个项目的目标是成为与 Java 中外国数据、函数和内存交互的新事实上的 API。为了实现这个目标,Project Panama 有两个主要特性:
-
一个面向未来的 API(低级、高效、健壮和安全),用于替换基于字节数组的旧式 API——这被称为内存访问 API,能够访问堆内和堆外内存。
-
一个全新的范式取代了 JNI 的概念和机制,因此现在我们有一个直观、易于使用且健壮的解决方案来创建 Java 绑定到本地库。这被称为外部链接器 API。
在下一个问题中,我们将更深入地探讨这个项目。
148. 介绍 Panama 的架构和术语
当我们谈论架构时,展示一个有意义的图表很有帮助,所以这就是它:

图 7.8:Project Panama 架构
此图揭示了 Panama 组件的互操作性。此图的顶峰是 Jextract 工具。正如您在本章中将要看到的,Jextract 是一个非常实用的工具,能够消费本地库的头文件并生成低级 Java 本地绑定。这些绑定是 Project Panama 两个主要 API 的工作单元:
-
外部内存 API——用于分配/释放堆外/堆内内存
-
外部链接器 API——用于从 Java 直接调用外国函数,反之亦然
到目前为止描述的过程完全是机械的。当这些 API 和低级 Java 原生绑定不足以满足我们的任务时,我们可以进一步采取行动,创建一组高级 Java 绑定。当然,这不是新手的工作,但它非常强大。例如,你可能有一个现有的用于生成 JNI 绑定的自动化工具,现在你想要将你的工具现代化,以便以 Panama 的风格生成更高层次的纯 Java 绑定。
在 Project Panama 使用的抽象中,我们有以下内容:
-
java.lang.foreign.MemorySegment:此 API 形成了堆或本地内存段。堆段访问堆内存,而本地段访问非堆内存。在两种情况下,我们谈论的是一个由空间和时间限制的内存连续区域。 -
java.lang.foreign.Arena(或在 JDK 20 之前的版本中为MemorySession):此 API 可以控制内存段的生存周期。 -
java.lang.foreign.MemorySegment.Scope:此 API 表示内存段的范围。 -
java.lang.foreign.MemoryLayout:此 API 将内存段的内容描述为 内存布局。例如,在基本 Java 数据类型(int、double、long等)的上下文中,我们有 内存值布局(java.lang.foreign.ValueLayout)。
当然,除了这三个支柱之外,我们还有许多其他类和辅助工具。在接下来的问题中,我们将涵盖几个旨在让我们熟悉使用 Project Panama 的 API 的主要方面的场景。
149. 介绍 Arena 和 MemorySegment
MemorySegment 形成了堆或本地内存段。堆段访问堆内存,而本地段访问非堆内存。在两种情况下,我们谈论的是一个由空间和时间限制的内存连续区域。
在其特征中,内存段有一个以字节为单位的 大小,一个字节的 对齐,以及一个 范围。范围通过 java.lang.foreign.MemorySegment.Scope 封闭接口形成,并代表内存段的生存周期。本地内存段的生存周期由 java.lang.foreign.Arena 实例控制。Arena 有一个可以是的范围:
全局竞技场范围(或 全局竞技场):具有竞技场全局范围的内存段始终可访问。换句话说,分配给这些段的内存区域永远不会被释放,并且它们的全局范围将永远保持活跃。
尝试关闭(close())此范围将导致 UnsupportedOperationException。以下是在竞技场全局范围内创建 8 字节原生内存段的示例:
MemorySegment globalSegment = Arena.global().allocate(8);
自动竞技场范围:具有自动竞技场范围的内存段由垃圾收集器管理。换句话说,垃圾收集器决定何时可以安全地释放这些段背后的内存区域。
尝试关闭(close())此作用域将导致 UnsupportedOperationException。以下是在自动作用域中创建 8 字节数本机内存段的示例:
MemorySegment autoSegment = Arena.ofAuto().allocate(8);
受限区域作用域(或,受限区域):通过受限区域可以获得对内存段生命周期的严格控制(分配/释放和生命周期)。通常,这个作用域存在于 try-with-resources 块中。当 Arena 被关闭(通过显式调用 close(),或者简单地离开 try-with-resources 块),其作用域关闭,所有与该作用域关联的内存段被销毁,并且内存自动释放。受限区域通过 ofConfined() 打开,并由当前线程拥有 – 受限区域作用域的内存段只能由创建该区域的线程访问。
在代码行中,可以创建一个受限区域,如下所示:
try (Arena arena = Arena.ofConfined()) {
// current thread work with memory segments (MS1, MS2, …)
}
// here, memory segments MS1, MS2, …, have been deallocated
共享区域作用域(或,共享区域):共享区域通常通过 try-with-resources 块中的 ofShared() 打开,并且可以被多个线程共享 – 与共享区域作用域关联的内存段可以被任何线程访问(例如,这可以用于在内存段上执行并行计算)。当 Arena 被关闭(通过显式调用 close(),或者简单地离开 try-with-resources 块),其作用域关闭,所有与该作用域关联的内存段被销毁,并且内存自动释放。
在代码行中,可以创建一个受限区域,如下所示:
try (Arena arena = Arena.ofShared()) {
// any thread work with memory segments (MS1, MS2, …)
}
// here, memory segments MS1, MS2, …, have been deallocated
通过调用 arena.scope(),我们获得区域的 MemorySegment.Scope,通过调用 arena.scope().isAlive(),我们可以找出当前作用域是否存活。只有当作用域存活时,内存段才是可访问的,因此只要区域的范围存活。
在这里,我们有一个 8 字节的内存段位于区域作用域中:
try (Arena arena = Arena.ofConfined()) {
MemorySegment arenaSegment = arena.allocate(8);
}
可以通过以下方式将区域作用域的主要特征总结到表中:

图 7.9:总结区域作用域的主要特征
如果你想监控分配的本机内存,那么这篇文章将帮助你做到这一点:www.morling.dev/blog/tracking-java-native-memory-with-jdk-flight-recorder/。在继续之前,让我们简要介绍 内存布局。
介绍内存布局(ValueLayout)
内存布局 由 java.lang.foreign.MemoryLayout 接口定义,其目标是描述内存段的内容。
我们有 简单内存布局,包括 ValueLayout 和 PaddingLayout,但我们也有 复杂内存布局,用于描述复杂内存段,如 SequenceLayout、StructLayout、UnionLayout 和 GroupLayout。复杂布局对于建模层次化的用户定义数据类型非常有用,例如类似 C 的序列、结构、联合等。
分配值布局的内存段
目前,我们对ValueLayout感兴趣。这是一个简单的内存布局,用于表示基本 Java 数据类型,如int,float,double,char,byte等。在 API 特定的示例中,ValueLayout.JAVA_LONG是一个其载体为long.class的布局,ValueLayout.JAVA_DOUBLE是一个其载体为double.class的布局,等等。值布局的载体可以通过carrier()方法获得。
例如,假设我们有一个受限的arena并且需要一个内存段来存储单个int值。我们知道 Java int需要 4 个字节,所以我们的段可以这样分配(allocate()的第一个参数是int的字节大小,第二个参数是int的字节对齐):
MemorySegment segment = arena.allocate(4, 4);
但我们可以通过ValueLayout实现相同的功能如下(在这里,我们使用allocate(MemoryLayout layout)和allocate(long byteSize, long byteAlignment)):
MemorySegment segment = arena.allocate(ValueLayout.JAVA_INT);
MemorySegment segment = arena
.allocate(ValueLayout.JAVA_INT.byteSize(),
ValueLayout.JAVA_INT.byteAlignment());
或者,不指定字节对齐,通过allocate(long byteSize):
MemorySegment segment = arena.allocate(4);
MemorySegment segment = arena
.allocate(ValueLayout.JAVA_INT.byteSize());
这里是另一个使用ValueLayout.JAVA_DOUBLE特定的字节对齐为存储 Java double分配内存段的示例:
MemorySegment segment
= arena.allocate(ValueLayout.JAVA_DOUBLE);
MemorySegment segment = arena.allocate(
ValueLayout.JAVA_DOUBLE.byteSize(),
ValueLayout.JAVA_DOUBLE.byteAlignment());
或者,为存储 Java char分配一个内存段可以这样做:
MemorySegment segment = arena.allocate(ValueLayout.JAVA_CHAR);
MemorySegment segment = MemorySegment.allocate(
ValueLayout.JAVA_CHAR.byteSize(),
ValueLayout.JAVA_CHAR.byteAlignment());
现在我们知道了如何为不同数据类型分配内存段,让我们看看我们如何设置/获取一些值。
设置/获取内存段的内容
Arena API 提供了一组从SegmentAllocator继承的allocate()方法,这些方法可以用于在同一行代码中分配一个内存段并设置其内容(在前一节中,我们只使用了分配内存段但不设置其内容的allocate()变体)。例如,调用allocate(OfInt layout, int value)会分配一个用于存储int的内存段并将该int设置为给定的value(OfInt是一个扩展ValueLayout的接口)。在这里,我们将int视为Integer.MAX_VALUE:
MemorySegment segment = arena.allocate(
ValueLayout.JAVA_INT, Integer.MAX_VALUE);
或者,在这里我们为char分配一个内存段并将该char设置为a(allocate(OfChar layout, char value)):
MemorySegment segment = arena.allocate(
ValueLayout.JAVA_CHAR, 'a');
但如果我们想在稍后设置内存段的内容(不是在分配时),则可以使用MemorySegment.set()或setAtIndex()方法。
例如,我们可以通过set(OfInt layout, long offset, int value)设置Integer.MAX_VALUE如下所示:
MemorySegment segment = ...;
segment.set(ValueLayout.JAVA_INT, 0, Integer.MAX_VALUE);
第二个参数是offset(0,4,8,12,……),在这种情况下必须是 0。或者,我们可以使用setAtIndex(OfInt layout, long index, int value)如下所示:
segment.setAtIndex(
ValueLayout.JAVA_INT, 0, Integer.MAX_VALUE);
在这里,第二个参数代表一个索引,正如在数组中一样(0,1,2,3……)。在这种情况下,它必须是 0,因为我们只在一个内存段中存储一个整数。
从某个偏移量获取内容可以通过get()方法完成,从某个索引通过getAtIndex()方法完成。例如,可以通过get(OfInt layout, long offset)获取存储在某个偏移量处的int:
int val = segment.get(ValueLayout.JAVA_INT, 0);
并且,通过 getAtIndex(OfInt layout, long index) 在某个索引处存储的 int,如下所示:
int val = segment.getAtIndex(ValueLayout.JAVA_INT, 0);
在接下来的问题中,你将看到更多使用这些方法的示例。
处理 Java 字符串
为存储 Java String 分配内存段是一个特殊情况。如果我们有一个 Arena 实例,那么我们可以分配一个内存段,并通过 allocateUtf8String(String str) 方法将其内容设置为 Java String,如下所示(这里,Java 字符串是 abcd):
MemorySegment segment = arena.allocateUtf8String("abcd");
allocateUtf8String(String str) 方法将 Java String 转换为 UTF-8 编码且以 null 结尾的类似 C 的字符串。内存段的大小为 str.length + 1。这意味着我们可以为 abcd 字符串分配如下段:
MemorySegment segment = arena.allocate(5);
或者,更具体地说:
MemorySegment segment = arena.allocate("abcd".length() + 1);
在分配了内存段之后,我们可以通过 setUtf8String(long offset, String str) 方法设置字符串,如下所示:
segment.setUtf8String(0, "abcd");
IndexOutOfBoundsException:
segment.setUtf8String(1, "abcd");
通过 MemorySegment.getUtf8String(long offset) 获取存储在内存段中的字符串,我们可以这样做:
String str = segment.getUtf8String(0);
你可以在捆绑的代码中练习所有这些示例。
150. 将数组分配到内存段
现在我们知道了如何为存储单个值创建内存段,让我们更进一步,尝试存储一个整数数组。例如,让我们定义一个用于存储以下数组的内存段:[11, 21, 12, 7, 33, 1, 3, 6]。
Java int 需要 4 个字节(32 位),我们有 8 个整数,所以我们需要一个 4 字节 x 8 = 32 字节 = 256 位的内存段。如果我们尝试表示这个内存段,那么我们可以像以下图示那样做:

图 7.10:一个包含 8 个整数的内存段
在代码行中,我们可以通过以下任何一种方法来分配这个内存段(arena 是 Arena 的一个实例):
MemorySegment segment = arena.allocate(32);
MemorySegment segment = arena.allocate(4 * 8);
MemorySegment segment = arena.allocate(
ValueLayout.JAVA_INT.byteSize() * 8);
MemorySegment segment = arena.allocate(Integer.SIZE/8 * 8);
MemorySegment segment = arena.allocate(Integer.BYTES * 8);
接下来,我们可以使用 set(OfInt layout, long offset, int value) 方法填充内存段,如下所示:
segment.set(ValueLayout.JAVA_INT, 0, 11);
segment.set(ValueLayout.JAVA_INT, 4, 21);
segment.set(ValueLayout.JAVA_INT, 8, 12);
segment.set(ValueLayout.JAVA_INT, 12, 7);
segment.set(ValueLayout.JAVA_INT, 16, 33);
segment.set(ValueLayout.JAVA_INT, 20, 1);
segment.set(ValueLayout.JAVA_INT, 24, 3);
segment.set(ValueLayout.JAVA_INT, 28, 6);
或者,我们可以使用 setAtIndex(OfInt layout, long index, int value) 方法如下:
segment.setAtIndex(ValueLayout.JAVA_INT, 0, 11);
segment.setAtIndex(ValueLayout.JAVA_INT, 1, 21);
segment.setAtIndex(ValueLayout.JAVA_INT, 2, 12);
segment.setAtIndex(ValueLayout.JAVA_INT, 3, 7);
segment.setAtIndex(ValueLayout.JAVA_INT, 4, 33);
segment.setAtIndex(ValueLayout.JAVA_INT, 5, 1);
segment.setAtIndex(ValueLayout.JAVA_INT, 6, 3);
segment.setAtIndex(ValueLayout.JAVA_INT, 7, 6);
我们已经知道我们可以通过 get() 使用偏移量或通过 getAtIndex() 使用索引来访问这些整数中的任何一个。这次,让我们尝试使用这个内存段来填充一个 IntVector(在 第五章 中介绍)。代码应该如下所示:
IntVector v = IntVector.fromMemorySegment(
VS256, segment, 0, ByteOrder.nativeOrder());
因此,Vector API 提供了 fromMemorySegment() 方法,特别是用于从内存段填充向量。ByteOrder 可以是 nativeOrder(),这意味着平台的本地字节顺序,BIG_ENDIAN(大端字节顺序),或者 LITTLE_ENDIAN(小端字节顺序)。
填充内存段的一个更方便的方法依赖于从 SegmentAllocator 继承的 Arena.allocateArray() 方法集。这些方法可以在一行代码中创建并填充内存段,如下所示:
MemorySegment segment = arena.allocateArray(
ValueLayout.JAVA_INT, 11, 21, 12, 7, 33, 1, 3, 6);
// or, like this
MemorySegment segment = arena.allocateArray(
ValueLayout.JAVA_INT,
new int[]{11, 21, 12, 7, 33, 1, 3, 6});
或者,这里是一个 char[] 数组:
MemorySegment segment = arena.allocateArray(
ValueLayout.JAVA_CHAR,"abcd".toCharArray());
所有这些示例都分配了一个堆外内存段。如果我们需要一个堆内内存段,那么我们可以依赖 MemorySegment.ofArray(),如下所示:
MemorySegment segment = MemorySegment
.ofArray(new int[]{11, 21, 12, 7, 33, 1, 3, 6});
对于完整的示例,请考虑捆绑的代码。
151. 理解地址(指针)
内存段有一个表示为long数字的内存地址(指针)。堆外内存段有一个物理地址,它指向支持该段的内存区域(基地址)。该段中存储的每个内存布局都有自己的内存地址。例如,这是通过address()方法查询内存段基地址的一个示例(arena是Arena的一个实例):
MemorySegment segment = arena
.allocate(ValueLayout.JAVA_INT, 1000);
long addr = segment.address(); // 2620870760384
另一方面,堆内存段有一个非物理稳定的虚拟化地址,通常表示该段内存区域内的偏移量(客户端看到一个稳定的地址,而垃圾收集器可以重新分配堆内存内部的内存区域)。例如,通过ofArray()工厂方法之一创建的堆段有一个地址为 0。
接下来,让我们只关注堆外内存段。让我们考虑以下包含整数值的三个内存段(arena是Arena的一个实例):
MemorySegment i1 = arena.allocate(ValueLayout.JAVA_INT, 1);
MemorySegment i2 = arena.allocate(ValueLayout.JAVA_INT, 3);
MemorySegment i3 = arena.allocate(ValueLayout.JAVA_INT, 2);
这些段中的每一个都有一个内存地址。接下来,让我们创建一个包含它们地址的段(就像指针段一样)。首先,我们通过ValueLayout.ADDRESS分配这样一个段,如下所示:
MemorySegment addrs = arena
.allocateArray(ValueLayout.ADDRESS, 3);
由于每个地址都是一个long值,addrs的大小为 24 字节。我们可以使用set()方法和偏移量 0、8 和 16 来设置i1、i2和i3的地址,或者我们可以使用setAtIndex()并引用偏移量作为索引 0、1 和 2:
addrs.setAtIndex(ValueLayout.ADDRESS, 0, i1);
addrs.setAtIndex(ValueLayout.ADDRESS, 1, i2);
addrs.setAtIndex(ValueLayout.ADDRESS, 2, i3);
我们可以用以下图表来表示:

图 7.11:将 i1、i2 和 i3 地址存储在地址数组中
换句话说,我们在addrs的偏移量 0 处设置了i1的地址,在偏移量 8 处设置了i2的地址,在偏移量 16 处设置了i3的地址。addrs段不包含i1、i2和i3的数据。它只是一个指针段,指向i1、i2和i3的内存地址。
如果我们调用get()/getAtIndex(),我们将得到一个地址:
MemorySegment addr1 = addrs.getAtIndex(ValueLayout.ADDRESS, 0);
MemorySegment addr2 = addrs.getAtIndex(ValueLayout.ADDRESS, 1);
MemorySegment addr3 = addrs.getAtIndex(ValueLayout.ADDRESS, 2);
我们可以用以下图表来表示:

图 7.12:从地址数组中获取地址
但检查一下返回类型。它不是一个long值!它是一个MemorySegment。返回的本地内存段(addr1、addr2和addr3)自动与全局作用域相关联。它们的大小为 0(限制:0),每个段都封装了给定偏移量/索引返回的地址(long值可以通过addr1/2/3.address()获得)。然而,在无界地址布局的情况下,预期的大小将是Long.MAX_VALUE(9223372036854775807)。
这意味着我们不应该这样做:
addr1.get(ValueLayout.JAVA_INT, 0); DON'T DO THIS!
这会导致IndexOutOfBoundsException,因为addr1的大小为 0 字节——这被称为零长度内存段。通过ofAddress()和reinterpret()方法的一种变体,可以获取与地址相关联的整数值,如下所示:
int v1 = MemorySegment.ofAddress(addr1.address())
.reinterpret(ValueLayout.JAVA_INT.byteSize())
.get(ValueLayout.JAVA_INT, 0);
首先,我们调用ofAddress()并传递addr1地址。这将创建一个大小为 0 的本地内存段。接下来,我们调用reinterpret()方法并传递int类型的大小。这将返回一个新的内存段(重新解释的内存段),其地址和作用域与该段相同,但具有给定的大小(4 字节)。最后,我们读取在偏移量 0 处存储的此地址的整数值。对于addr2和addr3也可以做同样的事情:
int v2 = MemorySegment.ofAddress(addr2.address())
.reinterpret(ValueLayout.JAVA_INT.byteSize())
.get(ValueLayout.JAVA_INT, 0);
int v3 = MemorySegment.ofAddress(addr3.address())
.reinterpret(ValueLayout.JAVA_INT.byteSize())
.get(ValueLayout.JAVA_INT, 0);
在使用reinterpret()或withTargetLayout()方法之前,请考虑以下注意事项:
重要提示
reinterpret()方法(以及所有用于处理零长度内存段的方法)被视为受限方法。应谨慎使用,因为任何错误都可能导致在尝试访问内存段时虚拟机崩溃。
我们可以通过==运算符检查两个长地址是否相等:
addr1.address() == i1.address() // true
或者,通过equals():
addr1.equals(i1) // true
到目前为止,我们有i1=1, i2=3, 和 i3=2。现在,我们只想操作地址以获得i1=1, i2=2, 和 i3=3。因此,我们想要通过交换地址而不是值来交换i2和i3的整数值。首先,我们将i2地址存储为long:
long i2Addr = i2.address();
接下来,我们将i2地址设置为i3地址:
i2 = MemorySegment.ofAddress(i3.address())
.reinterpret(ValueLayout.JAVA_INT.byteSize());
最后,我们将i3的地址设置为i2的地址:
i3 = MemorySegment.ofAddress(i2Addr)
.reinterpret(ValueLayout.JAVA_INT.byteSize());
完成!现在,i1=1, i2=2, 和 i3=3。我希望你发现这个练习对理解如何操作值、偏移量和内存地址有帮助。
152. 介绍序列布局
在问题 149中,我们已经涵盖了基本数据类型的ValueLayout。接下来,让我们谈谈序列布局(java.lang.foreign.SequenceLayout)。
但在介绍序列布局之前,让我们花一点时间分析以下代码片段:
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(
ValueLayout.JAVA_DOUBLE.byteSize() * 10,
ValueLayout.JAVA_DOUBLE.byteAlignment());
for (int i = 0; i < 10; i++) {
segment.setAtIndex(ValueLayout.JAVA_DOUBLE,
i, Math.random());
}
for (int i = 0; i < 10; i++) {
System.out.printf("\nx = %.2f",
segment.getAtIndex(ValueLayout.JAVA_DOUBLE, i));
}
}
我们首先创建一个用于存储 10 个double值的本地内存段。接下来,我们依靠setAtIndex()来设置这些double值。最后,我们打印它们。
因此,基本上,我们重复ValueLayout.JAVA_DOUBLE 10 次。当一个元素布局被重复n次(有限次数)时,我们可以通过序列布局(java.lang.foreign.SequenceLayout)来表示代码。换句话说,序列布局表示给定元素布局的重复/序列,重复有限次数。
以下代码使用SequenceLayout来塑造前面的片段:
SequenceLayout seq = MemoryLayout.sequenceLayout(
10, ValueLayout.JAVA_DOUBLE);
重复次数(元素计数)为 10,重复的元素布局为ValueLayout.JAVA_DOUBLE。
但我们如何设置序列布局的值?至少有两种方法,其中一种依赖于java.lang.invoke.VarHandle API 和java.lang.foreign.MemoryLayout.PathElement API 的组合。
介绍 PathElement
简而言之,PathElement API 通过所谓的 布局路径 提供了一种友好的方法来通过层次化内存布局进行导航。通过在布局路径中链接路径元素,我们可以定位一个元素布局,这可以是一个序列布局(通过序列路径元素定位)或者,正如你将在其他问题中看到的那样,一个组布局(可以通过组路径元素定位,可以是结构布局或联合布局)。序列布局通过 PathElement.sequenceElement() 进行遍历,而组布局通过 PathElement.groupElement() 进行遍历。每个元素布局都有一个称为 元素计数 的元素数量(通过名为 elementCount() 的方法获得)。
介绍 VarHandle
VarHandle 并非新事物。它在 JDK 9 中被引入。VarHandle 是一个动态的、不可变的、无状态、强类型的对变量的引用,不能被继承。其目标是提供在特定情况下对处理变量的读写访问。
VarHandle 有两个特点:
-
由此
VarHandle表示的变量类型作为泛型类型(T) -
一组用于定位此
VarHandle引用的变量的坐标类型(表示为 CT)
CT 列表可能为空。
通常,VarHandle 方法会接收一个可变数量的 Object 参数。参数检查是在运行时完成的(静态参数检查被禁用)。VarHandle 的不同方法期望接收不同类型的可变数量的参数。
将 PathElement 和 VarHandle 结合起来
路径元素(布局路径)是 MemoryLayout.varHandle() 方法的参数,该方法能够返回一个 VarHandle,可以用来访问通过此布局路径定位的内存段。路径被认为是根在此布局中。
因此,在我们的简单情况下,我们可以如下获得 seq 的 VarHandle:
// VarHandle[varType=double,
// coord=[interface java.lang.foreign.MemorySegment, long]]
VarHandle sphandle = seq.varHandle(
PathElement.sequenceElement());
我们的路径布局只是通过 PathElement.sequenceElement() 的简单导航。返回的 VarHandle 代表 double 类型的变量,并包含一个由 (MemorySegment 和 long) 组成的 CT。
MemorySegment 代表从该序列布局开始的内存段,而 long 值代表在该内存段中的索引。这意味着我们可以设置 10 个 double 值,如下所示:
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(seq);
for (int i = 0; i < seq.elementCount(); i++) {
sphandle.set(segment, i, Math.random());
}
...
获取这 10 个 double 值可以这样做:
for (int i = 0; i < seq.elementCount(); i++) {
System.out.printf("\nx = %.2f", sphandle.get(segment, i));
}
}
VarHandle 也可以通过 arrayElementVarHandle(int... shape) 创建。此方法创建一个 VarHandle,用于以多维数组的形式访问内存段(这被称为 带步长的 var 处理器)。varargs 参数 shape 表示每个嵌套数组维度的尺寸。你可以在代码包中找到这个示例。
接下来,让我们稍微复杂化一下。
与嵌套序列布局一起工作
让我们考虑以下 400 字节的序列布局(5 * 10 * 8 字节):
SequenceLayout nestedseq = MemoryLayout.sequenceLayout(5,
MemoryLayout.sequenceLayout(10, ValueLayout.JAVA_DOUBLE));
因此,这里我们有 5 个包含 10 个 ValueLayout.JAVA_DOUBLE 的序列布局。要导航到 ValueLayout.JAVA_DOUBLE,需要通过链式调用两个 sequenceLayout() 获取布局路径,如下所示:
// VarHandle[varType=double, coord=[interface
// java.lang.foreign.MemorySegment, long, long]]
VarHandle nphandle = nestedseq.varHandle(
PathElement.sequenceElement(),
PathElement.sequenceElement());
除了内存段之外,VarHandle 还接受两个 long 值。第一个 long 对应于外部序列布局,第二个 long 对应于内部序列布局。外部序列的元素数(元素计数)为 5,如下所示:
long outer = nestedseq.elementCount();
内部序列的元素计数为 10,可以通过以下方式通过 select() 方法获取:
long inner = ((SequenceLayout) nestedseq.select(
PathElement.sequenceElement())).elementCount();
现在,outer 与 nphandle 坐标类型中的第一个 long 参数匹配,而 inner 与第二个 long 参数匹配。因此,我们可以按以下方式获取/设置序列的 double 值:
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(nestedseq);
long outer = nestedseq.elementCount();
long inner = ((SequenceLayout) nestedseq.select(
PathElement.sequenceElement())).elementCount();
for (int i = 0; i < outer; i++) {
for (int j = 0; j < inner; j++) {
nphandle.set(segment, i, j, Math.random());
}
}
for (int i = 0; i < outer; i++) {
System.out.print("\n-----" + i + "-----");
for (int j = 0; j < inner; j++) {
System.out.printf("\nx = %.2f",
nphandle.get(segment, i, j));
}
}
}
在捆绑的代码中,你可以看到一个依赖于 ValueLayout.JAVA_DOUBLE.arrayElementVarHandle(5, 10) 的示例。
153. 将类似 C 的结构体塑形为内存段
让我们考虑以下图中的类似 C 结构体:

图 7.13:类似 C 的结构体
因此,在 图 7.13 中,我们有一个名为 point 的类似 C 结构体,用于塑形 (x, y) 对的 double 值。此外,我们还有 5 个这样的对在 pointarr 下声明。我们可以尝试塑形一个内存段来适应这个模型,如下所示(arena 是 Arena 的一个实例):
MemorySegment segment = arena.allocate(
2 * ValueLayout.JAVA_DOUBLE.byteSize() * 5,
ValueLayout.JAVA_DOUBLE.byteAlignment());
接下来,我们应该将 (x, y) 对设置到这个段中。为此,我们可以将其可视化如下:

图 7.14:存储 (x, y) 对的内存段
x, *y*) pairs:
for (int i = 0; i < 5; i++) {
segment.setAtIndex(
ValueLayout.JAVA_DOUBLE, i * 2, Math.random());
segment.setAtIndex(
ValueLayout.JAVA_DOUBLE, i * 2 + 1, Math.random());
}
但另一种方法是通过使用 StructLayout,这对于此场景更为合适,因为它在数据周围提供了一个包装结构。
介绍 StructLayout
StructLayout 是一种分组布局。在这个布局中,成员(其他内存布局)是依次排列的,就像在 C 结构体中一样。这意味着我们可以通过以下方式将类似 C 的结构体布局为两个 ValueLayout.JAVA_DOUBLE:
StructLayout struct = MemoryLayout.structLayout(
ValueLayout.JAVA_DOUBLE.withName("x"),
ValueLayout.JAVA_DOUBLE.withName("y"));
但我们有 5 对 (x, y),因此我们需要将这个 StructLayout 嵌套在一个包含 5 个 StructLayout 的 SequenceLayout 中,如下所示:
SequenceLayout struct
= MemoryLayout.sequenceLayout(5,
MemoryLayout.structLayout(
ValueLayout.JAVA_DOUBLE.withName("x"),
ValueLayout.JAVA_DOUBLE.withName("y")));
接下来,正如我们从 问题 152 中已经知道的,我们需要通过 PathElement 定义适当的布局路径,并获取回 VarHandle。我们需要一个 VarHandle 用于 x 和一个用于 y。注意以下代码中我们如何通过它们的名称来指出它们:
// VarHandle[varType=double,
// coord=[interface java.lang.foreign.MemorySegment, long]]
VarHandle xHandle = struct.varHandle(
PathElement.sequenceElement(),
PathElement.groupElement("x"));
// VarHandle[varType=double,
// coord=[interface java.lang.foreign.MemorySegment, long]]
VarHandle yHandle = struct.varHandle(
PathElement.sequenceElement(),
PathElement.groupElement("y"));
最后,我们可以使用 VarHandle 和元素计数来设置数据,如下所示:
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(struct);
for (int i = 0; i < struct.elementCount(); i++) {
xHandle.set(segment, i, Math.random());
yHandle.set(segment, i, Math.random());
}
...
获取数据非常简单:
for (int i = 0; i < struct.elementCount(); i++) {
System.out.printf("\nx = %.2f", xHandle.get(segment, i));
System.out.printf("\ny = %.2f", yHandle.get(segment, i));
}
}
挑战自己通过 ValueLayout.JAVA_DOUBLE.arrayElementVarHandle(int... shape) 实现此示例。
154. 将类似 C 的联合塑形为内存段
让我们考虑以下图中的类似 C 的联合(C 联合的成员共享相同的内存位置(成员的最大数据类型决定了内存位置的大小),因此在任何时刻只有一个成员有值):

图 7.15:一个类似 C 的联合体
在图 7.15中,我们有一个名为product的类似 C 的联合体,用于形成两个成员,price(double)和sku(int),在任何时刻只有一个可以具有值。我们可以按如下方式形成内存段以适应此模型(arena是Arena的一个实例):
MemorySegment segment = arena.allocate(
ValueLayout.JAVA_DOUBLE.byteSize(),
ValueLayout.JAVA_DOUBLE.byteAlignment());
由于double需要 8 字节,而int只需要 4 字节,我们选择ValueLayout.JAVA_DOUBLE来形成内存段的大小。这样,该段可以在同一偏移量处容纳一个double和一个int。
接下来,我们可以设置price或sku并相应地使用它们:
segment.setAtIndex(ValueLayout.JAVA_DOUBLE, 0, 500.99);
segment.setAtIndex(ValueLayout.JAVA_INT, 0, 101000);
当我们设置sku(int)时,price(double)的值变成了垃圾值,反之亦然。更多详情,请查看附带代码。接下来,让我们看看基于UnionLayout的此实现的替代方案。
介绍 UnionLayout
UnionLayout是一种组合布局。在这个布局中,成员(其他内存布局)按照与 C 联合体中完全相同的起始偏移量排列。这意味着我们可以通过如下方式排列price(double)和sku(int)成员来形成我们的类似 C 的联合体:
UnionLayout union = MemoryLayout.unionLayout(
ValueLayout.JAVA_DOUBLE.withName("price"),
ValueLayout.JAVA_INT.withName("sku"));
接下来,正如我们从问题 152中已经知道的,我们需要通过PathElement定义适当的布局路径并获取回VarHandle。我们需要一个VarHandle用于price,另一个用于sku。注意以下代码中我们如何通过它们的名称来指出它们:
// VarHandle[varType=double,
// coord=[interface java.lang.foreign.MemorySegment]]
VarHandle pHandle = union.varHandle(
PathElement.groupElement("price"));
// VarHandle[varType=double,
// coord=[interface java.lang.foreign.MemorySegment]]
VarHandle sHandle = union.varHandle(
PathElement.groupElement("sku"));
最后,我们可以使用VarHandle来设置price或sku:
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(union);
pHandle.set(segment, 500.99);
sHandle.set(segment, 101000);
}
当我们设置sku(int)时,price(double)的值变成了垃圾值,反之亦然。
155. 介绍 PaddingLayout
数据类型通常由几个属性来表征:大小、对齐、步长、填充和字节顺序。
填充布局(java.lang.foreign.PaddingLayout)允许我们指定填充。换句话说,PaddingLayout允许我们在某些偏移量添加一些额外的空间,这些空间通常被应用程序忽略,但却是内存段成员布局对齐所需的。
例如,让我们考虑以下两个内存段(左侧是没有填充的内存段,而右侧是带有两个各 4 字节填充的内存段)。

图 7.16:带(右侧)/不带(左侧)填充的内存段
在代码行中,无填充的内存段可以按如下方式形成:
StructLayout npStruct = MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")
);
由于JAVA_INT的大小为 4 字节,我们可以将x和y设置为如下:
VarHandle xpHandle = npStruct.varHandle(
PathElement.groupElement("x"));
VarHandle ypHandle = npStruct.varHandle(
PathElement.groupElement("y"));
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(npStruct);
xnHandle.set(segment, 23); // offset 0
ynHandle.set(segment, 54); // offset 4
}
这段代码在偏移量 0 处写入值 23,在偏移量 4 处写入 54。没有惊喜,对吧?
paddingLayout()):
StructLayout wpStruct = MemoryLayout.structLayout(
MemoryLayout.paddingLayout(4), // 4 bytes
ValueLayout.JAVA_INT.withName("x"),
MemoryLayout.paddingLayout(4), // 4 bytes
ValueLayout.JAVA_INT.withName("y")
);
接下来,我们再次写入两个int值(23 和 54):
VarHandle xpHandle = wpStruct.varHandle(
PathElement.groupElement("x"));
VarHandle ypHandle = wpStruct.varHandle(
PathElement.groupElement("y"));
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(wpStruct);
xpHandle.set(segment, 23); // offset 4
ypHandle.set(segment, 54); // offset 12
}
这次,跳过了填充区,23 被写入偏移量 4,而 54 被写入偏移量 12。读取x和y应分别从偏移量 4 和偏移量 12 开始。从 0 到 3,以及从 8 到 11,我们通过paddingLayout()添加了额外的空间,这些空间被应用程序忽略。尝试从这些区域读取int会导致值为 0(默认值)。
这些示例很好地介绍了填充概念,但在实际场景中并不那么有用。记住我们之前说过,填充对于对齐内存段的成员是有用的。为了理解这一点,让我们简要地介绍一些更多的参与者。
查询大小、对齐、步长和填充
在继续处理填充之前,我们需要介绍一些彼此密切相关且与填充协同工作的概念。
步长挂钩
通过“大小”,我们指的是内存布局(数据类型、类似 C 的结构体、类似 C 的联合体、序列布局等)占用的内存量(以字节/位为单位)。我们知道 Java 的int占用 4 字节,Java 的byte占用 1 字节,类似 C 的结构体占用每个属性大小的总和的字节数,类似 C 的联合体占用最大的属性大小的字节数,等等。
我们可以通过byteSize()/bitSize()轻松查询大小。以下是一些示例:
long size = ValueLayout.JAVA_INT.byteSize(); // 4
long size = ValueLayout.JAVA_BYTE.byteSize(); // 1
long size = npStruct.byteSize(); // 8
long size = wpStruct.byteSize(); // 16
在这个问题中,之前介绍了npStruct和wpStruct。
对齐挂钩
我们知道每个成员布局都从特定地址的内存段开始。我们说这个地址是k-字节对齐的,如果这个地址是k(其中k是 2 的任何幂)的倍数,或者如果这个地址可以被k整除。通常,k是 1、2、4 或 8。对齐对于维持 CPU 性能是有用的,它以k字节的块读取数据而不是逐字节读取。如果 CPU 尝试访问未正确对齐的成员布局,那么我们会得到一个IllegalArgumentException:地址…的对齐访问错误。
在基本数据类型(int、double、float、byte、char等)的情况下,对齐值等于它们的大小。例如,8 位(1 字节)的 Java byte大小为 1 字节,需要对齐到 1 字节。32 位(4 字节)的 Java int大小为 4 字节,需要对齐到 4 字节。在类似 C 的结构体/联合体的情况下,对齐是所有成员布局的最大对齐。
我们可以通过byteAlignment()/bitAlignment()轻松查询对齐。以下是一些示例:
long align = ValueLayout.JAVA_INT.byteAlignment(); // 4
long align = ValueLayout.JAVA_BYTE.byteAlignment(); // 1
long align = npStruct.byteAlignment(); // 4
long align = wpStruct.byteAlignment(); // 4
简而言之,成员布局应该从一个地址开始,这个地址必须是其对齐方式的倍数。这适用于任何类型的成员布局(基本数据类型、类似 C 的结构体、类似 C 的联合体等)。
步长挂钩
两个成员布局之间的最小字节距离称为步长。步长可以大于或等于大小。当我们不面对任何对齐问题时,步长等于大小。否则,步长是通过将大小向上舍入到对齐方式的下一个倍数来计算的。当步长大于大小,这意味着我们也有一些填充。如果我们有一个名为foo的类似 C 的结构体/联合体,那么步长是两个foo对象之间的最小字节距离。
填充挂钩
因此,填充是我们需要添加的额外空间,以保持成员布局的有效对齐。
如果你对这些陈述感到有些困惑,不要担心。我们将通过一系列示例来澄清一切。
添加隐式额外空间(隐式填充)以验证对齐
让我们考虑以下简单的例子:
MemorySegment segment = Arena.ofAuto().allocate(12);
segment.set(ValueLayout.JAVA_INT, 0, 1000);
segment.set(ValueLayout.JAVA_CHAR, 4, 'a');
我们有一个 12 字节的内存段,我们在偏移量 0 处设置了一个 4 字节的int,在偏移量 4 处设置了一个 2 字节的char(紧接在int之后)。因此,我们还有 6 个空闲字节。假设我们想在char之后设置一个额外的 4 字节的int,那么偏移量应该是多少?我们首先可能认为合适的偏移量是 6,因为char消耗了 2 字节:
segment.set(ValueLayout.JAVA_INT, 6, 2000);
但如果我们这样做,那么结果将是java.lang.IllegalArgumentException: 地址处的访问未对齐:…。我们有一个未对齐的成员布局(2000 个int值),因为 6 不能被 4 整除,而 4 是int的字节对齐。查看以下图示:

图 7.17:修复未对齐问题
但我们应该怎么做呢?我们知道当前的偏移量是 6,而 6 不能被 4 整除(int的对齐)。因此,我们正在寻找下一个能被 4 整除且最接近且大于 6 的偏移量。显然,这是 8。所以,在我们设置 2000 个int值之前,我们需要 2 个字节的填充(16 位)。如果我们简单地指定偏移量为 8 而不是 6,则此填充将自动添加:
segment.set(ValueLayout.JAVA_INT, 8, 2000);
由于我们的内存段大小为 12 字节,我们将这个int正好放在字节 8、9、10 和 11 上。较小的段大小会导致IndexOutOfBoundsException: 在内存段 MemorySegment 上的越界访问。
添加显式额外空间(显式填充)以验证对齐
让我们考虑以下类似于 C 的结构(我们将其称为案例 1):
StructLayout product = MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("sku"),
ValueLayout.JAVA_CHAR.withName("energy"),
ValueLayout.JAVA_BYTE.withName("weight"));
通过byteSize()返回的product大小是 7 字节(4 + 2 + 1)。通过byteAlignment()返回的product对齐是 4(4、2 和 1 中较大的对齐)。通过byteOffset()返回的每个成员布局的字节偏移量如下:
long boSku =product.byteOffset( // 0
PathElement.groupElement("sku"));
long boEnergy =product.byteOffset( // 4
PathElement.groupElement("energy"));
long boWeight =product.byteOffset( // 6
PathElement.groupElement("weight"));
如果我们通过图表表示,我们得到以下图示:

图 7.18:结构的表示
一切看起来都很正常,所以我们可以继续。现在,让我们使用相同的结构,但我们将成员布局安排如下(我们将其称为案例 2):
StructLayout product = MemoryLayout.structLayout(
ValueLayout.JAVA_CHAR.withName("energy"),
ValueLayout.JAVA_INT.withName("sku"),
ValueLayout.JAVA_BYTE.withName("weight"));
首先,我们将energy(char)放在偏移量 0 处。由于energy(char)消耗 2 字节,它后面跟着偏移量 2 处的sku(int)。由于sku(int)消耗 4 字节,它后面跟着weight(byte)。但这种逻辑正确吗?正如你在以下图示(左侧)中可以看到的,这种逻辑是错误的,因为我们有无效的对齐错误,偏移量为 2 的sku(int)。

图 7.19:不正确/正确的填充
energy(char)的对齐是 2,所以它只能从 0、2、4、……开始。由于energy(char)是第一个,所以我们从偏移量 0 开始。接下来,sku(int)的对齐是 4,所以它只能从 0、4、8、……开始。这就是为什么sku的起始地址是 4 而不是 2。最后,weight(byte)的对齐是 1,所以它可以在sku(int)之后偏移量 8 处开始。
因此,通过遵循对齐规则,我们得出结论,product的大小是 9,而不是 7。在这个时候,我们知道为了对齐sku(int),我们应该在偏移量 2 处添加 2 个字节的填充(16 位),所以让我们这样做:
StructLayout product = MemoryLayout.structLayout(
ValueLayout.JAVA_CHAR.withName("energy"),
**MemoryLayout.paddingLayout(****2****),**
ValueLayout.JAVA_INT.withName("sku"),
ValueLayout.JAVA_BYTE.withName("weight"));
接下来,让我们假设我们想要重复这个类似 C 的结构 2 次(或n次)。为此,我们将结构嵌套在序列布局中,如下所示(让我们将其称为情况 3):
SequenceLayout product = MemoryLayout.sequenceLayout(
2, MemoryLayout.structLayout(
ValueLayout.JAVA_CHAR.withName("energy"),
MemoryLayout.paddingLayout(2),
ValueLayout.JAVA_INT.withName("sku"),
ValueLayout.JAVA_BYTE.withName("weight")));
这次,代码因为异常IllegalArgumentException:而失败,元素布局大小不是对齐的倍数。现在发生了什么?好吧,第一个结构实例从偏移量 0 到偏移量 8,并且,根据我们的代码,第二个结构实例从偏移量 9 到偏移量 18,如下面的图所示(顶部图):

图 7.20:计算步长
但这是不正确的,因为结构的第二个实例(以及第三个、第四个等)没有遵循对齐规则。结构的对齐是 4,所以结构实例应该在 0、4、8、12、16、……,而不是在 9。这意味着我们需要计算步长,这给出了两个成员布局之间的最小字节距离——在这里,是我们结构的两个实例。
我们知道结构实例的大小是 9,其对齐是 4。因此,我们需要找到一个能被 4 整除的偏移量,它大于 9 且最接近 9。这是 12。由于步长是 12,这意味着结构的第二个实例从偏移量 12 开始。我们需要添加 3(12-9)字节的填充:
SequenceLayout product = MemoryLayout.sequenceLayout(
2, MemoryLayout.structLayout(
ValueLayout.JAVA_CHAR.withName("energy"),
MemoryLayout.paddingLayout(2),
ValueLayout.JAVA_INT.withName("sku"),
ValueLayout.JAVA_BYTE.withName("weight"),
**MemoryLayout.paddingLayout(****3****)**));
完成!正如你所见,成员布局的顺序非常重要。通过了解大小、对齐、步长和填充,我们可以通过简单地按正确的顺序排列成员布局来优化内存分配,这样就需要 0 或最小的填充。
在捆绑的代码中,你可以找到更多关于排列我们结构成员布局的示例。
156. 复制和切片内存段
让我们考虑以下内存段(arena 是 Arena 的一个实例):
MemorySegment srcSegment = arena.allocateArray(
ValueLayout.JAVA_INT, 1, 2, 3, 4, -1, -1, -1,
52, 22, 33, -1, -1, -1, -1, -1, 4);
接下来,让我们看看我们如何复制这个段的全部内容。
复制一个段
我们可以通过copyFrom(MemorySegment src)来复制这个内存段,如下所示:
MemorySegment copySegment = srcSegment.copyFrom(srcSegment);
我们可以很容易地看到数据是否被复制如下:
System.out.println("Data: " + Arrays.toString(
copySegment.toArray(ValueLayout.JAVA_INT)));
这是一个批量操作,它创建给定内存段的全拷贝。
将段的一部分复制到另一个段中(1)
假设我们只想将srcSegment的一部分复制到另一个段(dstSegment)中。例如,如果我们想将srcSegment的最后 8 个元素([22, 33, -1, -1, -1, -1, -1, 4])复制到dstSegment中,我们首先会相应地分配dstSegment:
MemorySegment dstSegment
= arena.allocateArray(ValueLayout.JAVA_INT, 8);
接下来,我们调用copy(MemorySegment srcSegment, long srcOffset, MemorySegment dstSegment, long dstOffset, long bytes)方法,如图所示:

图 7.21:将段的一部分复制到另一个段中(1)
因此,我们指定源段为srcSsegment,源偏移量为 32(跳过前 8 个元素),目标段为dstSegment,目标偏移量为 0,要复制的字节数为 32(复制最后 8 个元素):
MemorySegment.copy(srcSegment, 32, dstSegment, 0, 32);
实际上,我们将srcSegment的一半复制到了dstSegment中。
将段复制到堆内存数组中
假设我们只想将srcSegment的一部分复制到堆内存 Java 常规数组(dstArray)中。例如,如果我们想将srcSegment的最后 8 个元素([22, 33, -1, -1, -1, -1, -1, 4])复制到dstArray中,我们首先会相应地创建dstArray:
int[] dstArray = new int[8];
接下来,我们将调用copy(MemorySegment srcSegment, ValueLayout srcLayout, long srcOffset, Object dstArray, int dstIndex, int elementCount),如图所示:

图 7.22:将段复制到堆内存数组中
因此,我们指定源段为srcSegment,源布局为JAVA_INT,源偏移量为 32(跳过前 8 个元素),目标数组为dstArray,目标数组索引为 0,要复制的元素数量为 8:
MemorySegment.copy(
srcSegment, ValueLayout.JAVA_INT, 32, dstArray, 0, 8);
实际上,我们将非堆内存srcSegment的一半复制到了堆内存dstArray中。
将堆内存数组复制到段中
假设我们想将堆内存数组(或其一部分)复制到段中。给定的堆内存数组是srcArray:
int[] srcArray = new int[]{10, 44, 2, 6, 55, 65, 7, 89};
目标段可以容纳 16 个整数值:
MemorySegment dstSegment
= arena.allocateArray(ValueLayout.JAVA_INT, 16);
接下来,我们想要用srcArray中的元素覆盖dstSegment的最后 8 个元素,而前面的元素保持为 0。为此,我们调用copy(Object srcArray, int srcIndex, MemorySegment dstSegment, ValueLayout dstLayout, long dstOffset, int elementCount),如图所示:

图 7.23:将堆内存数组复制到段中
因此,我们指定源数组为srcArray,源索引为 0,目标段为dstSegment,目标布局为JAVA_INT,目标偏移量为 32(跳过前 8 个元素),要复制的元素数量为 8:
MemorySegment.copy(
srcArray, 0, dstSegment, ValueLayout.JAVA_INT, 32, 8);
实际上,我们将堆内存srcArray作为非堆内存destSegment的第二半进行复制。
将段的一部分复制到另一个段中(2)
让我们考虑前几节中的srcSegment(1, 2, 3, 4, -1, -1, -1, 52, 22, 33, -1, -1, -1, -1, -1, 4)和dstSegment(0, 0, 0, 0, 0, 0, 0, 0, 10, 44, 2, 6, 55, 65, 7, 89)。我们希望将srcSegment的最后 8 个元素(22, 33, -1, -1, -1, -1, -1, 4)复制为dstSegment的前 8 个元素(10, 44, 2, 6, 55, 65, 7, 89)。我们知道这可以通过copy(MemorySegment srcSegment, long srcOffset, MemorySegment dstSegment, long dstOffset, long bytes)方法实现,如下所示:
MemorySegment.copy(srcSegment, 32, dstSegment, 0, 32);
或者,我们可以使用copy(MemorySegment srcSegment, ValueLayout srcElementLayout, long srcOffset, MemorySegment dstSegment, ValueLayout dstElementLayout, long dstOffset, long elementCount),如下图所示:

图 7.24:将段的一部分复制到另一个段(2)
因此,我们指定源段为srcSegment,源布局为JAVA_INT,源偏移量为 32(跳过前 8 个元素),目标段为dstSegment,目标布局为JAVA_INT,目标偏移量为 0,要复制的元素数量为 8:
MemorySegment.copy(srcSegment, ValueLayout.JAVA_INT,
32, dstSegment, ValueLayout.JAVA_INT, 0, 8);
随意测试这个方法与不同的值布局。接下来,让我们谈谈切片。
切片一个段
接下来,假设我们想要将包含(1, 2, 3, 4, -1, -1, -1, 52, 22, 33, -1, -1, -1, -1, -1, 4)的段切割成三个独立的IntVector实例,而不使用copy()方法。因此,v1应包含[1, 2, 3, 4],v2应包含[52, 22, 33, 0],而v3应包含[4, 0, 0, 0]。由于一个int需要 4 个字节,并且我们最多有 4 个int值,所以我们选择SPECIES_128(4 个int值 x 4 字节 = 16 字节 x 8 位 = 128 位):
VectorSpecies<Integer> VS128 = IntVector.SPECIES_128;
接下来,我们需要切片内存段以消除-1 的值。这可以通过asSlice(long offset)和asSlice(long offset, long newSize)方法实现。第一个参数表示起始偏移量。第二个参数表示新内存段的大小。以下图示有助于我们澄清这一点:

图 7.25:切片内存段
第一个内存段从偏移量 0 开始,到偏移量 16 结束,因此它包含 4 个 4 字节的int值(asSlice(0, 16))。第二个内存段从偏移量 28 开始,到偏移量 40 结束,因此它包含 3 个 4 字节的int值(asSlice(28, 12))。最后,第三个内存段从偏移量 60 开始,到段末尾结束,因此它包含一个 4 字节的int值(asSlice(60)或asSlice(60, 4))。相应的代码如下所示:
IntVector v1, v2, v3;
try (Arena arena = Arena.ofConfined()) {
MemorySegment srcSegment = arena.allocateArray(
ValueLayout.JAVA_INT, 1, 2, 3, 4, -1, -1, -1, 52, 22, 33,
-1, -1, -1, -1, -1, 4);
v1 = IntVector.fromMemorySegment(VS128,
srcSegment.asSlice(0, 16), 0, ByteOrder.nativeOrder());
v2 = IntVector.fromMemorySegment(VS128,
srcSegment.asSlice(28, 12), 0, ByteOrder.nativeOrder(),
VS128.indexInRange(0, 3));
v3 = IntVector.fromMemorySegment(VS128,
srcSegment.asSlice(60), 0, ByteOrder.nativeOrder(),
VS128.indexInRange(0, 1));
}
完成!当然,我们也可以在常规 Java 数组中切片内存段。如下所示:
int[] jv1, jv2, jv3;
try (Arena arena = Arena.ofConfined()) {
MemorySegment srcSegment = arena.allocateArray(
ValueLayout.JAVA_INT, 1, 2, 3, 4, -1, -1, -1, 52, 22, 33,
-1, -1, -1, -1, -1, 4);
jv1 = srcSegment
.asSlice(0, 16).toArray(ValueLayout.JAVA_INT);
jv2 = srcSegment
.asSlice(28, 12).toArray(ValueLayout.JAVA_INT);
jv3 = srcSegment
.asSlice(60).toArray(ValueLayout.JAVA_INT);
}
toArray()方法从切片内存段返回一个 Java 常规数组(此处为int[])。
使用 asOverlappingSlice()
asOverlappingSlice(MemorySegment other) 方法返回一个重叠给定段作为 Optional<MemorySegment> 的这个段的切片。考虑以下段(arena 是 Arena 的一个实例):
MemorySegment segment = arena.allocateArray(
ValueLayout.JAVA_INT, new int[]{1, 2, 3, 4, 6, 8, 4, 5, 3});
然后,我们在偏移量 12 处切片,所以值为 4:
MemorySegment subsegment = segment.asSlice(12);
最后,我们调用 asOverlappingSlice() 来查看重叠发生在哪里:
int[] subarray = segment.asOverlappingSlice(subsegment)
.orElse(MemorySegment.NULL).toArray(ValueLayout.JAVA_INT);
结果数组是 [4, 6, 8, 4, 5, 3]。
使用 segmentOffset()
segmentOffset(MemorySegment other) 返回给定段(other)相对于这个段的偏移量。考虑以下段(arena 是 Arena 的一个实例):
MemorySegment segment = arena.allocateArray(
ValueLayout.JAVA_INT, new int[]{1, 2, 3, 4, 6, 8, 4, 5, 3});
然后,我们在偏移量 16 处切片,所以值为 6:
MemorySegment subsegment = segment.asSlice(16);
接下来,我们调用 segmentOffset() 来找出在 segment 中 subsegment 的偏移量:
// 16
long offset = segment.segmentOffset(subsegment);
// 6
segment.get(ValueLayout.JAVA_INT, offset)
你可以在捆绑的代码中练习所有这些示例。挑战自己进一步探索 MemorySegment.mismatch()。
157. 解决切片分配器问题
让我们考虑以下三个 Java 常规 int 数组:
int[] arr1 = new int[]{1, 2, 3, 4, 5, 6};
int[] arr2 = new int[]{7, 8, 9};
int[] arr3 = new int[]{10, 11, 12, 13, 14};
接下来,我们想要为这些数组中的每一个分配一个内存段。一个直接的方法依赖于在 问题 150 中引入的 Arena.allocateArray():
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment1
= arena.allocateArray(ValueLayout.JAVA_INT, arr1);
MemorySegment segment2
= arena.allocateArray(ValueLayout.JAVA_INT, arr2);
MemorySegment segment3
= arena.allocateArray(ValueLayout.JAVA_INT, arr3);
}
这种方法为每个给定的数组分配足够的内存。但是,有时我们只想分配一定量的内存。如果这个固定量不足,那么我们想以不同的方式解决这个问题。为此,我们可以依赖 java.lang.foreign.SegmentAllocator。当然,还有许多其他场景下 SegmentAllocator 都很有用,但现在,让我们解决以下问题。
假设我们允许分配固定大小 10 * 4 = 40 字节。这是一大块内存,应该在三个数组之间切片。首先,我们这样分配这些 40 字节:
try (Arena arena = Arena.ofConfined()) {
SegmentAllocator allocator =
SegmentAllocator.slicingAllocator(arena.allocate(10 * 4));
...
接下来,我们使用 allocator 从这些 40 字节中为每个数组分配一个切片。第一个数组 (arr1) 有 6 个值,所以内存段获得 6 * 4 = 24 字节:
MemorySegment segment1 = allocator.allocateArray(
ValueLayout.JAVA_INT, arr1);
...
分段分配器还可用 40 - 24 = 16 个额外的字节。第二个数组 (arr2) 有 3 个值,所以内存段获得 3 * 4 = 12 字节:
MemorySegment segment2 = allocator.allocateArray(
ValueLayout.JAVA_INT, arr2);
...
分段分配器还可用 16 - 12 = 4 个额外的字节。第三个数组 (arr3) 有 5 个值,因此它需要一个 5 * 4 = 20 字节的内存段,但只有 4 个可用。这导致 IndexOutOfBoundsException 并给我们控制权来处理这个特殊情况:
MemorySegment segment3 = allocator.allocateArray(
ValueLayout.JAVA_INT, arr3);
} catch (IndexOutOfBoundsException e) {
System.out.println(
"There is not enough memory to fit all data");
// handle exception
}
避免这个 IndexOutOfBoundsException 的一个可能方法可能是给分段分配器更多的内存。在这种情况下,我们需要给它 16 个额外的字节,所以我们可以这样表达:
SegmentAllocator allocator = SegmentAllocator
.slicingAllocator(arena.allocate(10 * 4 + 4 * 4));
当然,你不必写 10 * 4 + 4 * 4。你可以说是 14 * 4,或者只是 56。基本上,我们的三个数组有 14 个 4 字节的元素,最初我们只覆盖了其中的 10 个。接下来,我们增加了内存以覆盖剩余的 4 个。
158. 引入切片句柄
假设我们有一个以下嵌套模型(每个有 5 个 double 值的 10 个序列):
SequenceLayout innerSeq
= MemoryLayout.sequenceLayout(5, ValueLayout.JAVA_DOUBLE);
SequenceLayout outerSeq
= MemoryLayout.sequenceLayout(10, innerSeq);
接下来,我们通过PathElement定义一个VarHandle,并相应地在这个模型中填充一些随机数据:
VarHandle handle = outerSeq.varHandle(
PathElement.sequenceElement(),
PathElement.sequenceElement());
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(outerSeq);
for (int i = 0; i < outerSeq.elementCount(); i++) {
for (int j = 0; j < innerSeq.elementCount(); j++) {
handle.set(segment, i, j, Math.random());
}
}
}
好的,你应该熟悉这段代码,到目前为止没有什么新的内容。接下来,我们计划从这个模型中提取包含 5 个double值序列的第三个序列。我们可以通过sliceHandle(PathElement... elements)方法来完成这个任务,它返回一个java.lang.invoke.MethodHandle。这个MethodHandle接受一个内存段,并返回一个与所选内存布局相对应的切片。以下是我们的场景代码:
MethodHandle mHandle = outerSeq.sliceHandle(
PathElement.sequenceElement());
System.out.println("\n The third sequence of 10: "
+ Arrays.toString(
((MemorySegment) mHandle.invoke(segment, 3))
.toArray(ValueLayout.JAVA_DOUBLE)));
完成!现在,你知道如何从给定的内存段中切片出特定的内存布局。
159. 引入布局扁平化
假设我们有一个以下嵌套模型(与问题 158中的模型完全相同):
SequenceLayout innerSeq
= MemoryLayout.sequenceLayout(5, ValueLayout.JAVA_DOUBLE);
SequenceLayout outerSeq
= MemoryLayout.sequenceLayout(10, innerSeq);
接下来,我们通过PathElement定义一个VarHandle,并相应地在这个名为segment的内存段中填充一些随机数据(你可以在问题 158中看到列出的代码)。
我们的目标是将这个嵌套模型转换为平面模型。所以,我们希望有一个包含 50 个double值的序列,而不是 10 个序列,每个序列包含 5 个double值。这可以通过flatten()方法实现,如下所示:
SequenceLayout flatten = outerSeq.flatten();
VarHandle fhandle = flatten.varHandle(
PathElement.sequenceElement());
for (int i = 0; i < flatten.elementCount(); i++) {
System.out.printf("\nx = %.2f", fhandle.get(segment, i));
}
注意到PathElement,它遍历单个序列。这是扁平化操作后的序列。我们可以进一步分配另一个内存段给这个序列,并设置新的数据:
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(flatten);
for (int i = 0; i < flatten.elementCount(); i++) {
fhandle.set(segment, i, Math.random());
}
}
接下来,让我们看看我们如何重塑内存布局。
160. 引入布局重塑
假设我们有一个以下嵌套模型(与问题 158中的模型完全相同):
SequenceLayout innerSeq
= MemoryLayout.sequenceLayout(5, ValueLayout.JAVA_DOUBLE);
SequenceLayout outerSeq
= MemoryLayout.sequenceLayout(10, innerSeq);
接下来,我们通过PathElement定义一个VarHandle,并相应地在这个模型中填充一些随机数据(你可以在问题 158中看到列出的代码)。
我们的目标是将这个模型重塑成以下样子:
SequenceLayout innerSeq
= MemoryLayout.sequenceLayout(25, ValueLayout.JAVA_DOUBLE);
SequenceLayout outerSeq
= MemoryLayout.sequenceLayout(2, innerSeq);
因此,我们希望有 25 个序列,每个序列包含 2 个double值,而不是 10 个序列,每个序列包含 5 个double值。为了实现这个重塑目标,我们可以依赖reshape(long... elementCounts)方法。这个方法接受这个序列布局的元素,并将它们重新排列成符合给定元素计数列表的多维序列布局。所以,在我们的情况下,我们这样做:
SequenceLayout reshaped = outerSeq.reshape(25, 2);
你可以在捆绑的代码中看到完整的示例。
161. 引入布局展开器
假设我们有一个以下嵌套模型(与问题 158中的模型完全相同):
SequenceLayout innerSeq
= MemoryLayout.sequenceLayout(5, ValueLayout.JAVA_DOUBLE);
SequenceLayout outerSeq
= MemoryLayout.sequenceLayout(10, innerSeq);
接下来,我们通过PathElement定义一个VarHandle,并相应地在这个名为segment的内存段中填充一些随机数据(你可以在问题 158中看到列出的代码)。
接下来,假设我们想要从第七个序列中提取第三个double值(计数从 0 开始)。在众多方法中,我们可以依赖在问题 158中引入的sliceHandle(),如下所示:
MethodHandle mHandle = outerSeq.sliceHandle(
PathElement.sequenceElement(),
PathElement.sequenceElement());
MemorySegment ms = (MemorySegment)
mHandle.invokeExact(segment, 7L, 3L);
System.out.println(ms.get(ValueLayout.JAVA_DOUBLE, 0));
另一种方法是通过使用一个 数组传播 方法句柄。换句话说,通过调用 asSpreader(Class<?> arrayType, int arrayLength) 方法,我们可以获得一个包含我们想要传递的位置参数的 传播数组,其长度等于给定的 arrayLength。由于我们有两个传递的 long 参数(7L 和 3L),我们需要一个长度为 2 的 long[] 数组:
MemorySegment ms = (MemorySegment) mHandle
.asSpreader(Long[].class, 2)
.invokeExact(segment, new Long[]{7L, 3L});
你可能还对 asCollector(Class<?> arrayType, int arrayLength) 感兴趣,这基本上是 asSpreader() 的反义词。你提供一个参数列表,这个方法会将它们收集在一个 数组收集器 中。
162. 介绍内存段视图 VarHandle
让我们考虑以下简单的内存段来存储一个 int (arena 是 Arena 的一个实例):
MemorySegment segment = arena.allocate(ValueLayout.JAVA_INT);
我们知道我们可以通过 PathElement 创建一个 VarHandle:
// VarHandle[varType=int,
// coord=[interface java.lang.foreign.MemorySegment]]
VarHandle handle = ValueLayout.JAVA_INT.varHandle();
或者,通过 arrayElementVarHandle():
// VarHandle[varType=int,
// coord=[interface java.lang.foreign.MemorySegment, long]]
VarHandle arrhandle
= ValueLayout.JAVA_INT.arrayElementVarHandle();
MethodHandles.memorySegmentViewVarHandle(ValueLayout layout) 是创建可以用于访问内存段的 VarHandle 的另一种方法。返回的 VarHandle 将内存段的内容视为给定 ValueLayout 的序列。在我们的例子中,代码如下:
// VarHandle[varType=int,
// coord=[interface java.lang.foreign.MemorySegment, long]]
VarHandle viewhandle = MethodHandles
.memorySegmentViewVarHandle(ValueLayout.JAVA_INT);
接下来,我们可以依靠 insertCoordinates(VarHandle target, int pos, Object... values) 来指定在 VarHandle 实际调用之前的一组 绑定坐标。换句话说,返回的 VarHandle 将暴露比给定的 target 更少的坐标类型(CTs)。
在我们的例子中,target 参数(在插入一组 绑定坐标 之后调用)是 viewhandle。第一个坐标的位置是 1,我们有一个表示类型 long 的偏移量 0 的单个 绑定坐标:
viewhandle = MethodHandles
.insertCoordinates(viewhandle, 1, 0);
现在,当我们调用流行的 VarHandle.set/get(Object...) 在返回的 VarHandler 上时,传入的坐标值会自动与给定的 绑定坐标 值连接。结果传递给目标 VarHandle:
viewhandle.set(segment, 75);
System.out.println("Value: " + viewhandle.get(segment));
完成!现在,你知道了创建用于解引用内存段的 VarHandle 的三种方法。
163. 流式传输内存段
通过 elements(MemoryLayout elementLayout) 方法将 Java Stream API 与内存段结合使用可以实现。此方法获取一个元素布局,并返回一个 Stream<MemorySegment>,它在这个段中的非重叠切片上是一个顺序流。流的大小与指定布局的大小相匹配。
让我们考虑以下内存布局:
SequenceLayout xy = MemoryLayout
.sequenceLayout(2, MemoryLayout.structLayout(
ValueLayout.JAVA_INT.withName("x"),
ValueLayout.JAVA_INT.withName("y")));
接下来,我们声明两个 VarHandle 并设置一些数据:
VarHandle xHandle = xy.varHandle(
PathElement.sequenceElement(),
PathElement.groupElement("x"));
VarHandle yHandle = xy.varHandle(
PathElement.sequenceElement(),
PathElement.groupElement("y"));
try (Arena arena = Arena.ofShared()) {
MemorySegment segment = arena.allocate(xy);
xHandle.set(segment, 0, 5);
yHandle.set(segment, 0, 9);
xHandle.set(segment, 1, 6);
yHandle.set(segment, 1, 8);
// stream operations
}
假设我们想要汇总所有数据。为此,我们可以这样做:
int sum = segment.elements(xy)
.map(t -> t.toArray(ValueLayout.JAVA_INT))
.flatMapToInt(t -> Arrays.stream(t))
.sum();
或者,我们可以简单地传递适当的布局,甚至启用并行处理:
int sum = segment.elements(ValueLayout.JAVA_INT)
.parallel()
.mapToInt(s -> s.get(ValueLayout.JAVA_INT, 0))
.sum();
这两种方法都返回 28 = 5 + 9 + 6 + 8。
那么只从第一个 (x, y) 对中汇总值怎么样?为此,我们必须通过 sliceHandle() 切片与第一个 (x, y) 对对应的布局——我们在 问题 151 中介绍了这个方法:
MethodHandle xyHandle
= xy.sliceHandle(PathElement.sequenceElement());
接下来,我们切割第一个(x,y)对的段(如果我们用 1 替换 0,那么我们得到第二个(x,y)对的段):
MemorySegment subsegment
= (MemorySegment) xyHandle.invoke(segment, 0);
我们用它来计算所需的和:
int sum = subsegment.elements(ValueLayout.JAVA_INT)
.parallel()
.mapToInt(s -> s.get(ValueLayout.JAVA_INT, 0))
.sum();
结果很清晰,14 = 5 + 9。
我们如何将第一对y与第二对(x,y)相加?为此,我们可以通过asSlice()切割适当的段——我们在问题 156中介绍了这个方法:
var sum = segment.elements(xy)
.parallel()
.map(t -> t.asSlice(4).toArray(ValueLayout.JAVA_INT))
.flatMapToInt(t -> Arrays.stream(t))
.sum();
asSlice(4)简单地跳过了第一个x,因为这是存储在偏移量 0 处,消耗了 4 个字节。从偏移量 4 到末尾,我们有第一个y,以及第二对(x,y)。所以,结果是 23 = 9 + 6 + 8。
注意,这次,我们使用了共享区域(Arena.ofShared())。这是必要的,因为段应该在多个线程之间共享。
完成!请随意挑战自己解决更多此类场景。
164. 解决映射内存段问题
我们知道计算机有有限的物理内存,这被称为 RAM。然而,常识告诉我们,我们不能分配比可用 RAM 更大的内存段(这应该导致内存不足错误)。但这并不完全正确!这正是映射内存段进入讨论的地方。
映射的内存段代表虚拟内存,可以非常大(千兆字节、太字节,或者你可以想到的任何大小)。这个虚拟内存实际上是文件或简称为内存映射文件(一个文件可以是普通文件,也可以是任何其他类型的文件描述符)映射的。
显然,在任何时候,只有虚拟内存的一部分存在于实际内存中。这就是为什么我们可以在拥有较少实际 RAM 的笔记本电脑上分配 TB 级的虚拟内存。实际上,缺失的映射内存部分会在需要时加载到实际 RAM 中。在加载过程中,操作此内存的进程会暂时挂起。
映射内存文件的目标是极大地减少 I/O 操作。标准的读写操作依赖于将数据复制到缓冲区,而映射文件将文件数据直接放入进程地址空间。这要快得多,并且可以在进程间共享。
在 Java 中,我们可以通过java.nio.channels.FileChannel API 设置映射内存文件,更确切地说,通过map(MapMode mode, long offset, long size, Arena arena)方法。以下是一个设置 1 MB 映射内存文件并写入/读取一些文本到其中的示例(你可以在你的机器上尝试一个 1 GB(1,073,741,824 字节)或更大的文件):
try (FileChannel file = FileChannel.open(
Path.of("readme.txt"), CREATE, READ, WRITE);
Arena arena = Arena.ofConfined()) {
MemorySegment segment
= file.map(READ_WRITE, 0, 1048576, arena);
// write the data
segment.setUtf8String(0, "This is a readme file ...");
segment.setUtf8String(1048576/2,
"Here is the middle of the file ...");
segment.setUtf8String(1048576-32,
"Here is the end of the file ...");
// read some data
System.out.println(segment.getUtf8String(1048576/2));
}
当一个文件包含大量空字节(所谓的空洞,\x00)时,它就成为一个很好的候选稀疏文件。在稀疏文件中,这些空洞不再保留在存储设备上,因此不再消耗物理内存。这是尝试更有效地使用内存并停止使用零字节块消耗物理内存的一种尝试。每个操作系统都有自己处理稀疏文件的方式,但一般来说,零字节块被简化为一些有用的元数据,这些元数据对于动态生成它们是有意义的。有关更多详细信息和一个有用的图表,请考虑这篇维基百科文章(en.wikipedia.org/wiki/Sparse_file)。
在 Java 中,我们可以通过将java.nio.file.StandardOpenOption.SPARSE选项添加到CREATE_NEW旁边的选项列表中,来创建一个稀疏文件:
try (FileChannel file = FileChannel.open(
Path.of("sparse_readme.txt"),
CREATE_NEW, SPARSE, READ, WRITE);
Arena arena = Arena.ofConfined()) {
MemorySegment segment
= file.map(READ_WRITE, 0, 1048576, arena);
// write the data
segment.setUtf8String(0, "This is a readme file ...");
segment.setUtf8String(1048576/2,
"Here is the middle of the file ...");
segment.setUtf8String(1048576-32,
"Here is the end of the file ...");
// read some data
System.out.println(segment.getUtf8String(0));
}
根据你的操作系统(机器),你应该使用专用工具来详细检查这些文件,并深入了解它们的工作原理。
如果你经常使用映射内存文件,那么你可能更喜欢扩展Arena接口,并从以下简单骨架开始提供自己的实现:
public class MappedArena implements Arena {
private final String fileName;
private final Arena shared;
public MappedArena(String fileName) {
this.fileName = fileName;
this.shared = Arena.ofShared();
}
@Override
public MemorySegment allocate(
long byteSize, long byteAlignment) {
try (FileChannel file = FileChannel.open(
Path.of(fileName + System.currentTimeMillis() + ".txt"),
CREATE_NEW, SPARSE, READ, WRITE)) {
return file.map(
READ_WRITE, 0, byteSize, shared);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// more overridden methods
}
使用MappedArena非常简单:
try (Arena arena = new MappedArena("readme")) {
MemorySegment segment1 = arena.allocate(100);
MemorySegment segment2 = arena.allocate(50);
segment1.setUtf8String(0, "Hello");
segment2.setUtf8String(0, "World");
System.out.println(segment1.getUtf8String(0)
+ " " + segment2.getUtf8String(0));
}
当然,你可以改进/修改此代码以获得其他配置。也许你想要一个受限的竞技场(这里,我们有一个共享竞技场),也许你希望在竞技场关闭后删除文件(这里,文件保留在磁盘上,因此你可以检查它们),也许你不需要稀疏文件(这里,我们使用稀疏文件),也许你更喜欢另一个文件名(这里,我们将给定名称与System.currentTimeMillis()和.txt扩展名连接),或者也许你需要考虑字节对齐。
165. 介绍 Foreign Linker API
Foreign Linker API 的主要目标是提供一个强大且易于使用的 API(无需编写 C/C++代码),以维持 Java 代码和原生共享库的 C/C++外部函数之间的互操作性(未来,将通过此 API 支持其他编程语言)。
调用外部代码的旅程始于java.lang.foreign.SymbolLookup功能接口。此接口代表入口点,并包括在已加载的原生共享库中查找给定符号的地址。有三种方法可以实现这一点,如下所示:
Linker.defaultLookup() – 如其名所示,defaultLookup()表示一个默认查找,它根据当前操作系统扫描并定位常用原生共享库的所有符号:
Linker linker = Linker.nativeLinker();
SymbolLookup lookup = linker.defaultLookup();
SymbolLookup.loaderLookup() – 表示一个加载器查找,它扫描并定位当前类加载器(通过System.loadLibrary()和System.load()基于java.library.path)加载的所有原生共享库中的所有符号:
System.loadLibrary("fooLib"); // fooLib.dll is loaded here
SymbolLookup lookup = SymbolLookup.loaderLookup();
SymbolLookup.libraryLookup(String name, Arena arena) – 表示一个库查找,能够在指定的区域范围内扫描和加载具有给定名称的本地共享库。它还为此本地共享库中的所有符号创建符号查找。或者,我们也可以通过SymbolLookup.libraryLookup(Path path, Arena arena)指定一个Path:
try (Arena arena = Arena.ofConfined()) {
SymbolLookup lookup = SymbolLookup.libraryLookup(
libName/libPath, arena);
}
如果这一步成功完成,那么我们可以选择我们想要调用的外部函数对应的符号。可以通过SymbolLookup.find(String name)方法通过其名称找到外部函数。
如果指向的方法在找到的符号中存在,则find()返回一个零长度的内存段,该段被Optional (Optional<MemorySegment>)包装。此段的基本地址指向外部函数的入口点:
MemorySegment fooFunc = mathLookup.find("fooFunc").get();
到目前为止,我们已经定位了本地共享库并找到了其方法之一(fooFunc)。接下来,我们必须将 Java 代码链接到这个外部函数。这是通过基于两个概念的Linker API 完成的:
-
downcall – 从 Java 代码调用本地代码
-
upcall – 从本地代码调用 Java 代码
这两个概念通过Linker接口实现。downcall通过以下签名的两个方法映射:
MethodHandle downcallHandle(
FunctionDescriptor function, Linker.Option... options)
default MethodHandle downcallHandle(MemorySegment symbol,
FunctionDescriptor function, Linker.Option... options)
通常,通过find()方法获得的MemorySegment来使用default方法,该方法描述了外部函数的签名,以及一个可选的链接器选项集。返回的MethodHandle随后用于通过invoke()、invokeExact()等方法调用外部函数。通过invoke()或invokeExact(),我们向外部函数传递参数,并访问外部函数运行返回的结果(如果有)。
upcall通过以下方法映射:
MemorySegment upcallStub(MethodHandle target,
FunctionDescriptor function, Arena arena)
通常,target参数指的是 Java 方法,function参数描述了 Java 方法的签名,而arena参数代表与返回的MemorySegment关联的区域。这个MemorySegment随后作为 Java 代码的参数传递,该 Java 代码调用(invoke()/invokeExact())一个downcall方法句柄。因此,这个MemorySegment充当了函数指针。
如果我们将这些知识结合起来,就可以编写一个经典的调用getpid()方法(在 Windows 10 上,为_getpid() – learn.microsoft.com/en-us/cpp/c-runtime-library/reference/getpid)的示例,如下所示(请阅读有意义的注释以了解每一步的细节):
// get the Linker of the underlying native platform
// (operating system + processor that runs the JVM)
Linker linker = Linker.nativeLinker();
// "_getpid" is part of the Universal C Runtime (UCRT) Library
SymbolLookup libLookup = linker.defaultLookup();
// find the "_getpid" foreign function
MemorySegment segmentGetpid = libLookup.find("_getpid").get();
// create a method handle for "_getpid"
MethodHandle func = linker.downcallHandle(segmentGetpid,
FunctionDescriptor.of(ValueLayout.JAVA_INT));
// invoke the foreign function, "_getpid" and get the result
int result = (int) func.invokeExact();
System.out.println(result);
此代码已在 Windows 10 上进行了测试。如果您运行的是不同的操作系统,那么请考虑了解此外部函数以相应地调整代码。
166. 调用 sumTwoInt()外部函数
你还记得 sumTwoInt() 函数吗?我们已经在名为 math.dll 的本地共享库中定义了这个 C 函数(检查 问题 144、145 和 146)。假设我们已经将 math.dll 库放置在项目文件夹下的 lib/cpp 路径中。
我们可以以几乎与调用 _getpid() 相同的方式调用此外部函数。由于 math.dll 是一个用户定义的库,不常使用,因此不能通过 defaultLookup() 加载。解决方案是从 lib/cpp 路径显式加载库,如下所示:
Linker linker = Linker.nativeLinker();
Path path = Paths.get("lib/cpp/math.dll");
try (Arena arena = Arena.ofConfined()) {
SymbolLookup libLookup = SymbolLookup.libraryLookup(
path, arena);
...
接下来,我们必须在 math.dll 中通过名称查找外部函数。如果你的 C 编译器(例如,G++)已经应用了 mangling(或 name decoration)技术,那么 sumTwoInt 在库中已经被重命名为其他名称(这里,_Z9sumTwoIntii),应该使用这个名称:
MemorySegment segmentSumTwoInt
= libLookup.find("_Z9sumTwoIntii").get();
...
接下来,我们定义此 downcall 的 MethodHandle:
MethodHandle func = linker.downcallHandle(segmentSumTwoInt,
FunctionDescriptor.of(ValueLayout.JAVA_LONG,
ValueLayout.JAVA_INT, ValueLayout.JAVA_INT));
...
最后,我们可以调用外部函数并获取结果:
long result = (long) func.invokeExact(3, 9);
System.out.println(result);
}
结果应该是 12。查看捆绑代码中的完整代码。
167. 调用 modf() 外部函数
让我们考虑我们想要调用 modf() 外部函数。此函数是 C 标准库的一部分,具有以下语法 (learn.microsoft.com/en-us/cpp/c-runtime-library/reference/modf-modff-modfl):
double modf(double x, double *intptr);
此方法获取一个 double x 并返回 x 的有符号小数部分。intptr 是一个指针参数,用于指向应该将整数部分存储为 double 值的内存地址。
由于此方法属于 UCRT,可以通过 defaultLookup() 找到:
Linker linker = Linker.nativeLinker();
SymbolLookup libLookup = linker.defaultLookup();
try (Arena arena = Arena.ofConfined()) {
MemorySegment segmentModf = libLookup.find("modf").get();
...
到目前为止没有什么新内容!接下来,我们需要定义适当的 MethodHandle。因为 modf() 的第二个参数是一个指针,所以我们需要指定一个类型为 ADDRESS 的值布局:
MethodHandle func = linker.downcallHandle(segmentModf,
FunctionDescriptor.of(ValueLayout.JAVA_DOUBLE,
ValueLayout.JAVA_DOUBLE, ValueLayout.ADDRESS));
...
如果我们现在能够调用外部函数,我们可以收集给定 x 的分数部分,但我们无法获得整数部分。我们必须创建一个内存段,并在调用时将此内存段传递给外部函数。外部函数将在这个内存段中写入整数部分,这个内存段应该能够存储一个 double 值:
MemorySegment segmentIntptr
= arena.allocate(ValueLayout.JAVA_DOUBLE);
double fractional
= (double) func.invokeExact(x, segmentIntptr);
...
小数部分由外部键返回。整数部分从偏移量 0 的内存段中读取:
System.out.println("Fractional part: " + fractional
+ " Integer part: " + segmentIntptr.get(
ValueLayout.JAVA_DOUBLE, 0));
}
如果 x = 89.76655,那么输出将是:
Fractional part: 0.7665499999999952 Integer part: 89.0
挑战自己将此代码修改为调用 modff() 和 modfl() 外部函数。
168. 调用 strcat() 外部函数
strcat() 外部函数是 C 标准库的一部分,具有以下签名 (learn.microsoft.com/en-us/cpp/c-runtime-library/reference/strcat-wcscat-mbscat):
char *strcat(char *strDestination, const char *strSource);
此函数将 strSource 追加到 strDestination 的末尾。该函数不获取这些字符串。它获取指向这些字符串的指针(因此,两个 ADDRESS)并且不返回值,所以我们依赖于 FunctionDescriptor.ofVoid(),如下所示:
Linker linker = Linker.nativeLinker();
SymbolLookup libLookup = linker.defaultLookup();
try (Arena arena = Arena.ofConfined()) {
MemorySegment segmentStrcat
= libLookup.find("strcat").get();
MethodHandle func = linker.downcallHandle(
segmentStrcat, FunctionDescriptor.ofVoid(
ValueLayout.ADDRESS, ValueLayout.ADDRESS));
...
由于 strcat() 的参数是两个指针(ADDRESS),我们必须创建两个内存段并相应地设置字符串:
String strDestination = "Hello ";
String strSource = "World";
MemorySegment segmentStrSource
= arena.allocate(strSource.length() + 1);
segmentStrSource.setUtf8String(0, strSource);
MemorySegment segmentStrDestination = arena.allocate(
strSource.length() + 1 + strDestination.length() + 1);
segmentStrDestination.setUtf8String(0, strDestination);
...
注意 segmentStrDestination 的大小。由于 strcat() 将源字符串(strSource)追加到目标字符串(strDestination)的末尾,我们必须准备 segmentStrDestination 的大小以适应源字符串,因此其大小是 strSource.length() + 1 + strDestination.length() + 1。接下来,我们可以如下调用外部函数:
func.invokeExact(segmentStrDestination, segmentStrSource);
最后,我们从 segmentStrDestination 读取结果:
// Hello World
System.out.println(segmentStrDestination.getUtf8String(0));
因此,World 字符串被添加到了 Hello 的末尾。
169. 调用 bsearch() 外部函数
bsearch() 外部函数是 C 标准库的一部分,具有以下签名 (learn.microsoft.com/en-us/cpp/c-runtime-library/reference/bsearch):
void *bsearch(
const void *key,
const void *base,
size_t num,
size_t width,
int ( __cdecl *compare ) (
const void *key, const void *datum)
);
简而言之,此方法获取键、排序数组(base)和比较器的指针。其目标是使用给定的比较器在给定的数组中对给定的 key 执行二分搜索。更确切地说,bsearch() 获取 key 的指针、数组的指针、数组中的元素数量(num)、元素的字节大小(width)以及作为回调函数的比较器。
回调函数获取 key 的指针以及要与之比较的当前数组元素的指针。它返回比较这两个元素的结果。
bsearch() 函数返回指向数组中键出现的指针。如果给定的键未找到,则 bsearch() 返回 NULL。
我们可以先从编写比较回调函数作为 Java 方法开始:
static int comparator(MemorySegment i1, MemorySegment i2) {
return Integer.compare(i1.get(ValueLayout.JAVA_INT, 0),
i2.get(ValueLayout.JAVA_INT, 0));
}
i1 内存段是 key 的指针,i2 内存段是指向要与之比较的当前数组元素的指针。此方法将由外部函数(本地代码调用 Java 代码)调用,因此应该准备一个 upcall stub。首先,我们需要一个指向此比较器的方法句柄:
MethodHandle comparatorHandle = MethodHandles.lookup()
.findStatic(Main.class, "comparator", MethodType.methodType(
int.class, MemorySegment.class, MemorySegment.class));
第二,我们创建 upcall stub。为此,我们需要 Linker:
Linker linker = Linker.nativeLinker();
SymbolLookup libLookup = linker.defaultLookup();
我们已经准备好使用受限的竞技场:
try (Arena arena = Arena.ofConfined()) {
MemorySegment comparatorFunc =
linker.upcallStub(comparatorHandle,
FunctionDescriptor.of(ValueLayout.JAVA_INT,
ValueLayout.ADDRESS.withTargetLayout(
MemoryLayout.sequenceLayout(ValueLayout.JAVA_INT)),
ValueLayout.ADDRESS.withTargetLayout(
MemoryLayout.sequenceLayout(ValueLayout.JAVA_INT))),
arena);
MemorySegment segmentBsearch
= libLookup.find("bsearch").get();
MethodHandle func = linker.downcallHandle(
segmentBsearch, FunctionDescriptor.of(
ValueLayout.ADDRESS, ValueLayout.ADDRESS,
ValueLayout.ADDRESS, ValueLayout.JAVA_INT,
ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));
...
这里,我们使用了 withTargetLayout() 方法创建一个 无界 地址。无界 的 ADDRESS 指的是我们不知道大小的地址,因此最好通过将它们设置为 无界 来确保有足够的空间。实际上,通过创建一个没有显式大小的目标序列布局,我们获得了一个最大大小的本地内存段。接下来,我们找到 bsearch() 方法并定义其方法句柄。
接下来,我们将 key 和 array 参数作为 MemorySegment 准备:
int elem = 14;
int[] arr = new int[]{1, 3, 6, 8, 10, 12, 14, 16, 20, 22};
MemorySegment key = arena.allocate(
ValueLayout.JAVA_INT, elem);
MemorySegment array
= arena.allocateArray(ValueLayout.JAVA_INT, arr);
...
我们已经有了所有需要的参数,因此我们可以调用 bsearch():
MemorySegment result = (MemorySegment) func.invokeExact(
key, array, 10, ValueLayout.JAVA_INT.byteSize(),
comparatorFunc);
...
请记住,bsearch() 返回一个指向 array 中 key 首次出现的指针,或者如果给定 key 在给定 array 中未找到,则返回 NULL。如果 bsearch() 返回 NULL,则结果应匹配 MemorySegment.NULL,这是一个表示 NULL 地址的零长度本地段:
if (result.equals(MemorySegment.NULL)) {
System.out.println("Element " + elem
+ " not found in the given array "
+ Arrays.toString(arr));
} else {
...
否则,我们知道结果表示给定 array 中的指针。因此,我们可以依靠 segmentOffset() 方法(在 问题 149 中介绍)来找到结果相对于 array 的偏移量:
long offset = array.segmentOffset(result);
System.out.println("Element found in the given array at
offset: " + offset);
System.out.println("Element value: "
+ array.get(ValueLayout.JAVA_INT, offset));
}
}
对于我们的 key(14)和 array,返回的偏移量是 24。
170. 介绍 Jextract
Jextract (github.com/openjdk/jextract) 是一个非常实用的工具,能够消费本地库的头文件 (*.h 文件) 并生成低级 Java 本地绑定。通过此工具,我们可以节省大量时间,因为我们只需关注调用本地代码,而无需关心加载库、编写方法句柄或 下调用 和 上调用 桩的机械步骤。
Jextract 是一个可以从 jdk.java.net/jextract 下载的命令行工具。此工具的主要选项在此列出:
-
--source: 当我们编写jextract --source时,我们指示 Jextract 从给定的头文件生成相应的源文件,而不包含类。当省略此选项时,Jextract 将生成类。 -
-- 输出路径: 默认情况下,生成的文件放置在当前文件夹中。通过此选项,我们可以指定这些文件应该放置的路径。 -
-t <package>: 默认情况下,Jextract 使用未命名的包名。通过此选项,我们可以指定生成类的包名。 -
-I <dir>: 指定一个或多个应附加到现有搜索路径的路径。在搜索过程中,给定的顺序是受尊重的。 -
--dump-includes <String>: 此选项允许您过滤符号。首先,使用此选项提取文件中的所有符号。然后,编辑文件以保留所需的符号。最后,将此文件传递给 Jextract。
完整的选项列表可在 github.com/openjdk/jextract 上找到。
171. 为 modf() 生成本地绑定
在 问题 160 中,我们通过 Foreign Linker API 定位、准备并调用了 modf() 外部函数。现在,让我们使用 Jextract 生成调用 modf() 所需的本地绑定。
对于 Windows,modf() 外部函数在 math.h 头文件中描述。如果您已安装 MinGW (sourceforge.net/projects/mingw-w64/) 64 位版本,则此头文件位于 mingw64\x86_64-w64-mingw32\include 文件夹中。如果我们想为 math.h 生成本地绑定,可以这样做:

图 7.26:从 math.h 生成原生绑定
或者,作为纯文本:
C:\SBPBP\GitHub\Java-Coding-Problems-Second-Edition\Chapter07\P171_JextractAndModf>
jextract --source --output src\main\java -t c.lib.math
-I C:\MinGW64\mingw64\x86_64-w64-mingw32\include
C:\MinGW64\mingw64\x86_64-w64-mingw32\include\math.h
因此,我们在当前项目的 src\main\java 子文件夹中生成源文件(--sources),在包 c.lib.math 中(-t)。math.h 从 mingw64\x86_64-w64-mingw32\include 加载。
运行此命令后,你将在 c.lib.math 中找到 math.h 中找到的所有符号的原生绑定。很可能是我们想要的,因为我们只调用 modf() 外部函数。过滤符号是一个两步过程。首先,我们生成所有符号的 dump,如下所示:

图 7.27:创建包含 math.h 中所有符号的 dump 文件
或者,作为纯文本:
C:\SBPBP\GitHub\Java-Coding-Problems-Second-Edition\Chapter07\P171_JextractAndModf>
jextract --dump-includes includes.txt
-I C:\MinGW64\mingw64\x86_64-w64-mingw32\include
C:\MinGW64\mingw64\x86_64-w64-mingw32\include\math.h
此命令将在项目根目录中创建一个名为 includes.txt 的文件,其中包含 math.h 中找到的所有符号。第二步是编辑此文件。例如,我们只保留了 modf() 符号,如下所示:

图 7.28:编辑 includes.txt 以保留所需的符号
接下来,我们将编辑后的 includes.txt 传递给 Jextract,如下所示:

图 7.29:使用过滤后的 includes.txt 运行 Jextract
或者,作为纯文本:
C:\SBPBP\GitHub\Java-Coding-Problems-Second-Edition\Chapter07\P171_JextractAndModf>
jextract --source @includes.txt --output src\main\java -t c.lib.math
-I C:\MinGW64\mingw64\x86_64-w64-mingw32\include
C:\MinGW64\mingw64\x86_64-w64-mingw32\include\math.h
这次,在 c.lib.math 中,你只会找到 modf() 外部函数的原生绑定。花时间检查这些文件,看看它们在代码层面是如何交互的。由于我们只生成源代码,我们必须编译项目以获取类。如果你希望直接通过 Jextract 生成类,则可以使用以下命令(现在,将不会生成源代码,只会生成类):

图 7.30:生成原生绑定的类
或者,作为纯文本:
C:\SBPBP\GitHub\Java-Coding-Problems-Second-Edition\Chapter07\P171_JextractAndModf>
jextract @includes.txt --output target\classes -t c.lib.math
-I C:\MinGW64\mingw64\x86_64-w64-mingw32\include
C:\MinGW64\mingw64\x86_64-w64-mingw32\include\math.h
接下来,我们可以在 Java 应用程序中使用生成的绑定来调用 modf() 函数。代码很简单(我们不需要编写方法句柄,也不需要显式使用 invoke()/invokeExact()):
double x = 89.76655;
try (Arena arena = Arena.ofConfined()) {
MemorySegment segmentIntptr
= arena.allocate(ValueLayout.JAVA_DOUBLE);
double fractional = modf(x, segmentIntptr);
System.out.println("Fractional part: " + fractional
+ " Integer part: " + segmentIntptr.get(
ValueLayout.JAVA_DOUBLE, 0));
}
modf() 函数是从 c.lib.math.math_h 包导入的。
摘要
本章涵盖了 28 个问题。其中大部分都集中在新的 Foreign (Function) Memory APIs,或 Project Panama。正如你所见,这个 API 比使用 JNI、JNA 和 JNR 的经典方法更加直观和强大。此外,Jextract 工具对于从原生共享库的头文件中生成原生绑定非常方便,并为我们节省了大量机械工作。
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

第八章:密封和隐藏类
本章包括 13 个问题,涵盖了密封和隐藏类。前 11 个食谱将涵盖密封类,这是 JDK 17(JEP 409)引入的一个非常酷的特性,用于维持封闭层次结构。最后两个问题涵盖了隐藏类,这是 JDK 15(JEP 371)的一个特性,允许框架在 JVM 的字节码内部链接中创建和使用运行时(动态)类,以及显式使用类加载器。
你将在本章结束时掌握这两个主题。
问题
使用以下问题来测试你在 Java 中操作密封类和隐藏类的编程能力。我强烈建议你在查看解决方案并下载示例程序之前尝试每个问题:
-
创建一个电气面板(类层次结构):编写一个 Java 应用程序的存根,用于构建电气面板。你可以假设电气面板由多种类型的电气元件(例如,电阻器、晶体管等)和电路(例如,并联电路、串联电路等)组成。
-
在 JDK 17 之前关闭电气面板:使用 Java 特性(例如,
final关键字和包私有技巧)来关闭这个层次结构(接近扩展)。 -
介绍 JDK 17 密封类:简要介绍 JDK 17 密封类。举例说明如何通过密封类在单个源文件中编写封闭层次结构。
-
介绍许可条款:解释并举例说明
permits子句在密封类中的作用。举例说明在不同源文件(同一包)和不同包中密封类的使用。 -
在 JDK 17 之后关闭电气面板:使用密封类完全关闭在问题 172 和 173 中开发的电气面板层次结构。
-
结合密封类和记录:展示如何将 Java 记录与密封类结合使用。
-
在 switch 中使用密封类和 instanceof:编写一个应用程序,突出密封类如何帮助编译器更好地处理
instanceof运算符。 -
在 switch 中使用密封类:编写一个应用程序,展示密封类如何帮助编译器维持详尽的 switch 表达式/语句。
-
通过密封类和类型模式匹配重解访客模式:提供一个传统访客模式实现的快速示例,并通过密封类将其转换为更简单、更易访问的代码。
-
获取密封类信息(使用反射):解释并举例说明我们如何通过 Java 反射访问密封类。
-
列出密封类的三大好处:提供你认为密封类的三大好处,并附上一些解释和论据。
-
简要介绍隐藏类:提供一个简明、清晰且富有意义的隐藏类解释。列出它们的主要特征。
-
创建隐藏类:提供一个创建和使用隐藏类的常规示例。
以下章节描述了前面问题的解决方案。请记住,通常没有解决特定问题的唯一正确方法。此外,请记住,这里所示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节,并实验github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter08中的程序。
172. 创建电气面板(类层次结构)
假设我们想要用代码行来模拟一个电气面板。当然,我们不是电工,所以对我们来说,电气面板意味着一个带有一些内部电路的盒子,这些电路由电气元件组成,并有一个断路器来打开/关闭电气面板。

图 8.1:电气面板组件
电气面板中的所有东西都可以被认为是电气元件,因此我们可以从定义一个接口开始,这个接口必须由面板中的所有东西实现:
public interface ElectricComponent {}
在继续之前,让我们看看电气面板接口和类的图,这将帮助你更容易地理解接下来的内容:

图 8.2:电气面板模型
电气面板由更多相互交互(或不交互)的电气电路组成。我们可以通过以下abstract类来表示这样的电路(这充当其子类的基础类):
public abstract class ElectricCircuit
implements ElectricComponent {
public abstract void on();
public abstract void off();
}
假设我们的电气面板由三种类型的电路组成。我们有短路、串联电路和并联电路。因此,我们可以通过扩展abstract ElectricCircuit(我们在这里只展示ShortCircuit,而ParallelCircuit和SeriesCircuit在捆绑的代码中可用)为每种类型的电路定义适当的类:
public class ShortCircuit extends ElectricCircuit {
public ShortCircuit(ElectricComponent... comp) {}
@Override
public void on() {}
@Override
public void off() {}
}
查看一下ShortCircuit类的构造函数。它接收一个类型为ElectricComponent的varargs参数。这意味着我们可以从较小的电路和其他组件(如电容器、晶体管、电阻等)构建更大的电路。每个这样的电气元件都可以通过一个abstract类来表示。例如,电容器是一个基类,可以表示如下:
public abstract class Capacitor
implements ElectricComponent {}
我们需要两种类型的电容器(陶瓷电容器和电解电容器)。一个陶瓷电容器的形状可以是以下这样:
public class CeramicCapacitor extends Capacitor {}
按照相同的逻辑,我们可以表达其他电气元件,如晶体管(Transistor(abstract),BipolarTransistor和FieldEffectTransistor)和电阻(Resistor(abstract),CarbonResistor和MetalResistor,以及其两个子类型MetalFilmResistor和MetalOxideResistor)。
我们几乎拥有了构建面板所需的所有电气组件。我们只需要断路器,它只是另一个具有特定性的电气组件,它公开了两个用于开启/关闭电气面板的方法:
public interface ElectricBreaker extends ElectricComponent {
void switchOn();
void switchOff();
}
最后,我们可以将电气面板具体化为以下形式(我们假设有三个电路,一个中心电路,一个外围电路和一个辅助电路):
public class ElectricPanel implements ElectricBreaker {
private final ElectricCircuit centralCircuit;
private final ElectricCircuit peripheralCircuit;
private final ElectricCircuit auxiliaryCircuit;
public ElectricPanel() {
peripheralCircuit = new SeriesCircuit(
new ElectrolyticCapacitor(),new ElectrolyticCapacitor(),
new MetalFilmResistor(), new CarbonResistor());
auxiliaryCircuit = new ShortCircuit(
new CeramicCapacitor(), new ElectrolyticCapacitor(),
new MetalResistor(), new FieldEffectTransistor(),
new FieldEffectTransistor());
centralCircuit = new ParallelCircuit(
peripheralCircuit, auxiliaryCircuit,
new CeramicCapacitor(), new BipolarTransistor(),
new MetalOxideResistor());
}
@Override
public void switchOn() {
auxiliaryCircuit.off();
peripheralCircuit.on();
centralCircuit.on();
}
@Override
public void switchOff() {
auxiliaryCircuit.on();
peripheralCircuit.off();
centralCircuit.off();
}
}
完成!现在,我们的面板客户端可以通过switchOn()/switchOff()方法来操作它:
ElectricPanel panel = new ElectricPanel();
panel.switchOn();
在下一个问题中,我们将看到如何关闭这个类的层次结构,以增加封装并避免意外的/非意外的扩展。
173. 在 JDK 17 之前关闭电气面板
从本质上讲,电气面板是一个封闭的工作单元。但我们的前一个问题中的代码远远不是一个封闭的层次结构。我们可以在层次结构的内部或外部扩展和实现几乎任何类/接口。
在 JDK 17 之前使用任何东西,关闭类和接口的层次结构可以使用几种工具。
应用final修饰符
例如,我们有强大的final修饰符。一旦我们将一个类声明为final,它就不能被扩展,因此它完全封闭了对扩展。显然,我们不能在层次模型中一致地应用这种技术,因为这会导致非层次模型。
如果我们扫描我们的电气面板模型,那么我们可以在几个地方使用final修饰符。首先,我们消除了接口(ElectricComponent和ElectricBreaker),因为接口不能声明为final。接下来,我们可以查看ElectricCircuit类及其子类(ParallelCircuit、SeriesCircuit和ShortCircuit)。显然,由于ElectricCircuit有子类,它不能是final。然而,它的子类正在建模不应该扩展的概念,因此它们可以是final。这是我们获得封闭层次模型的第一步:
public **final** class ParallelCircuit extends ElectricCircuit {}
public **final** class SeriesCircuit extends ElectricCircuit {}
public **final** class ShortCircuit extends ElectricCircuit {}
其他建模了明确概念且不应该扩展的类是建模电容器、晶体管和电阻的类。因此,以下类也可以是final:
public **final** class CeramicCapacitor extends Capacitor {}
public **final** class ElectrolyticCapacitor extends Capacitor {}
public **final** class FieldEffectTransistor extends Transistor {}
public **final** class BipolarTransistor extends Transistor {}
public **final** class CarbonResistor extends Resistor {}
public **final** class MetalFilmResistor extends MetalResistor {}
public **final** class MetalOxideResistor extends MetalResistor {}
最后,我们有ElectricPanel类。从电气面板派生东西是没有意义的,所以这个类也可以是final:
public **final** class ElectricPanel implements ElectricBreaker {}
到目前为止,我们已经成功关闭了层次结构的一些部分。没有其他地方可以使用final修饰符帮助我们,因此我们可以更进一步,尝试另一种技术。
定义包私有构造函数
接下来,我们可以使用定义包私有构造函数的技巧(一个没有可见修饰符的构造函数)。具有包私有构造函数的类只能在包内部实例化和扩展——从可读性的角度来看,这种技术远远没有表达其意图。然而,在复杂的设计中,我们可以偶尔应用这种技术,因为我们不能简单地将所有内容放入一个单独的包中。尽管如此,它可以被认为是一种提高层次模型封闭级别的解决方案。
例如,我们可以关注我们的抽象类。它们不能被实例化(因为它们是抽象的),但可以从任何地方扩展。然而,其中一些类只应该在它们定义的包中扩展。ElectricCircuit类是抽象的,它只能由ParallelCircuit、SeriesCircuit和ShortCircuit扩展。这些子类与ElectricCircuit类位于同一个包中,因此使用这种声明为包私有的构造函数的做法是有意义的:
public abstract class ElectricCircuit
implements ElectricComponent {
**ElectricCircuit() {}**
...
}
现在,ElectricCircuit类对外部包的任何扩展尝试都是封闭的。当然,它仍然对其包内部的扩展尝试是开放的。
将类/接口声明为非公开
进一步来说,我们可以将接口/类声明为非公开(通过从类/接口定义中省略public关键字,它变为非公开,并默认设置为所谓的包私有访问模式)。这样,这些类和接口只能在它们的包内部可见(可以使用/扩展)。我们不能将这种技术应用于ElectricComponent接口。这个接口必须被声明为public,因为它被我们的大多数类实现。然而,我们可以将这种技术应用于ElectricBreaker接口,因为这个接口应该只由位于同一包中的ElectricPanel类实现:
interface ElectricBreaker extends ElectricComponent {}
现在,ElectricBreaker不能在其包外部被扩展/实现。此外,我们可以将这种技术应用于抽象类Transistor、Resistor和Capacitor:
abstract class Capacitor implements ElectricComponent {}
abstract class Resistor implements ElectricComponent {}
abstract class Transistor implements ElectricComponent {}
注意,我们不能将这种技术应用于ElectricCircuit类。这个类是抽象的,但它被ElectricPanel类使用,因此它不能是非公开的。然而,由于之前添加了包私有的构造函数,它不能被扩展。
将所有内容放入模块中
此外,我们可以将整个层次结构放置在一个 Java 模块中,并将其中的一小部分导出/暴露给我们的客户端。然而,这种做法不会影响模块内部的封闭级别,所以我们将其跳过(也就是说,我们不会举例说明它)。
在这个时刻,几乎整个层次结构都对扩展/实现关闭。例外的是MetalResistor类和ElectricComponent接口,它们可以从模型内部/外部任何地方进行扩展/实现,以及ElectricCircuit、Capacitor、Transistor和Resistor类,它们可以从它们的包内部进行扩展。通过将模型放置在 Java 模块中,我们可以阻止模块外部的这些操作,但它们仍然可以从模块内部进行。
结论
从这个点开始(在 JDK 17 之前),我们不能再应用任何技术、技巧或黑客手段。我们可以重新考虑模型设计,但这将过于昂贵,基本上意味着完全重新设计模型,这可能会影响模型结构和逻辑。
为了讨论和重新设计的上下文,我们可能会考虑 Java 枚举。Java 枚举为我们提供了一个很好的封闭层次结构,并且在内部被转换为常规 Java 类。尽管如此,使用枚举来设计封闭模型和塑造任意类可能会非常奇怪、难以驾驭且不方便。
总之,在 JDK 17 之前,我们有激进的全局final修饰符和一些通过包私有访问在包级别上的控制。
显然,这里缺失的是介于两者之间的一些东西,一些可以给我们更多粒度和控制的东西。幸运的是,JDK 17 可以通过密封类帮助我们实现 100% 封闭的层次结构。这是一些后续问题的主题。
174. 引入 JDK 17 密封类
在 JDK 17 的酷炫特性中,我们有 JEP 409(密封类)。这个 JEP 提供了一个明确、直观、清晰易懂的解决方案,用于指定谁将扩展一个类/接口或实现一个接口。换句话说,密封类可以在更细的级别上控制继承。密封类可以影响类、abstract类和接口,并保持代码的可读性——你有一个简单且易于表达的方法来告诉你的同事谁可以扩展/实现你的代码。

图 8.3:JDK 17,JEP 409
通过密封类,我们对类层次结构有了更精细的控制。如图 8.3 所示,密封类是介于final和包私有之间的缺失拼图。换句话说,密封类提供了我们通过final修饰符和包私有访问无法获得的粒度。
重要提示
密封类不会影响final和abstract关键字的语义。它们仍然像过去几年一样精确地起作用。一个密封类不能是final,反之亦然。
让我们考虑以下类(Truck.java):
public class Truck {}
我们知道,原则上,这个类可以被任何其他类扩展。但我们只有三种类型的卡车:半挂车、厢式货车和冷藏车。因此,只有三个类应该扩展Truck类。任何其他扩展都不应该被允许。为了实现这个目标,我们在Truck类的声明中添加了sealed关键字,如下所示:
public **sealed** class Truck {}
通过添加sealed关键字,编译器将自动扫描在Truck.java中预定义的所有Truck的扩展。
接下来,我们必须指定Truck的子类(SemiTrailer、Tautliner和Refrigerated)。
重要提示
一个sealed类(无论是abstract还是不是)必须至少有一个子类(否则声明它为sealed就没有意义)。一个sealed接口必须至少有一个子接口或实现(同样,否则声明它为sealed就没有意义)。如果我们不遵循这些规则,那么代码将无法编译。
如果我们在同一个源文件(Truck.java)中声明Truck的子类,可以这样做:
final class SemiTrailer extends Truck {}
final class Tautliner extends Truck {}
final class Refrigerated extends Truck {}
检查完这段代码后,我们必须再提出另一个重要提示。
重要提示
一个sealed类的子类必须声明为final、sealed或non-sealed。一个sealed接口的子接口必须声明为sealed或non-sealed。如果一个sealed类(接口)的子类(子接口)被声明为sealed,那么它必须有自己的子类(子接口)。non-sealed关键字表示子类(子接口)可以无限制地进一步扩展(包含non-sealed类/接口的层次结构不是封闭的)。此外,final子类不能被扩展。
由于我们的子类(SemiTrailer、Tautliner和Refrigerated)被声明为final,它们不能进一步扩展。因此,Truck类只能被SemiTrailer、Tautliner和Refrigerated扩展,而这些类是不可扩展的。
在接口的情况下,我们做同样的事情。例如,一个sealed接口看起来像这样:
public sealed interface Melon {}
通过添加sealed关键字,编译器将自动扫描在Melon.java中预定义的所有Melon的实现/扩展。因此,在同一个源文件(Melon.java)中,我们声明这个接口的扩展和实现:
non-sealed interface Pumpkin extends Melon {}
final class Gac implements Melon {}
final class Cantaloupe implements Melon {}
final class Hami implements Melon {}
Pumpkin接口可以进一步自由实现/扩展,因为它被声明为non-sealed。Pumpkin的实现/扩展不需要声明为sealed、non-sealed或final(但我们仍然可以做出这样的声明)。
接下来,让我们看看一个更复杂的例子。让我们把这个模型命名为Fuel模型。在这里,所有类和接口都放在同一个源文件中,Fuel.java(com.refinery.fuel包)。花点时间分析每个类/接口,了解sealed、non-sealed和final在这个层次模型中是如何一起工作的:

图 8.4:使用sealed、non-sealed和final的层次模型
在代码行中,这个模型可以表示如下:
public sealed interface Fuel {}
sealed interface SolidFuel extends Fuel {}
sealed interface LiquidFuel extends Fuel {}
sealed interface GaseousFuel extends Fuel {}
final class Coke implements SolidFuel {}
final class Charcoal implements SolidFuel {}
sealed class Petroleum implements LiquidFuel {}
final class Diesel extends Petroleum {}
final class Gasoline extends Petroleum {}
final class Ethanol extends Petroleum {}
final class Propane implements GaseousFuel {}
sealed interface NaturalGas extends GaseousFuel {}
final class Hydrogen implements NaturalGas {}
sealed class Methane implements NaturalGas {}
final class Chloromethane extends Methane {}
sealed class Dichloromethane extends Methane {}
final class Trichloromethane extends Dichloromethane {}
将所有类/接口放在同一个源文件中允许我们表达像之前那样的封闭层次模型。然而,将所有类和接口放在同一个文件中通常不是一个有用的方法——也许当模型包含几个小的类/接口时。
在现实中,我们喜欢将类和接口分开到它们自己的源文件中。每个类/接口在自己的源文件中更自然、更直观。这样,我们避免了大型源文件,并且更容易遵循面向对象编程的最佳实践。因此,我们下一个问题的目标是使用每个类/接口一个源文件的方式重写Fuel层次模型。
175. 引入permits子句
在上一个问题中,你看到了如何在单个源文件中编写一个封闭的层次模型。接下来,让我们使用Fuel.java源文件,通过使用单独的源文件和单独的包来重写这个模型。
在单独的源文件中(同一包)使用密封类
让我们考虑Fuel.java包中的sealed Fuel接口:
public sealed interface Fuel {} // Fuel.java
我们知道这个接口被三个其他接口扩展:SolidFuel、LiquidFuel和SolidFuel。让我们在SolidFuel.java源文件(同一包)中定义SolidFuel,如下所示:
public sealed interface SolidFuel {} // SolidFuel.java
如你将看到的,这段代码将无法编译(这就像编译器在问:嘿,没有实现/扩展的密封接口有什么意义呢?)。这次,我们必须明确指定可以扩展/实现Fuel接口的接口。为此,我们使用permits关键字。由于Fuel由三个接口实现,我们只需通过permits列出它们的名称,如下所示:
public sealed interface Fuel
permits SolidFuel, LiquidFuel, GaseousFuel {}
通过permits提供的列表是详尽的。SolidFuel也是一个sealed接口,因此它必须定义自己的permits:
public sealed interface SolidFuel extends Fuel
permits Coke, Charcoal {}
LiquidFuel和GaseousFuel与SolidFuel的工作方式相同:
// LiquidFuel.java
public sealed interface LiquidFuel extends Fuel
permits Petroleum {}
// GaseousFuel.java
public sealed interface GaseousFuel extends Fuel
permits NaturalGas, Propane {}
Coke(Coke.java)和Charcoal(Charcoal.java)是final的SolidFuel实现,因此它们不使用permits关键字:
public final class Coke implements SolidFuel {}
public final class Charcoal implements SolidFuel {}
Petroleum 类(Petroleum.java)是密封的,并允许三种扩展:
public sealed class Petroleum implements LiquidFuel
permits Diesel, Gasoline, Ethanol {}
Diesel(Diesel.java)、Gasoline(Gasoline.java)和Ethanol(Ethanol.java)类是final的:
public final class Diesel extends Petroleum {}
public final class Gasoline extends Petroleum {}
public final class Ethanol extends Petroleum {}
NaturalGas接口(NaturalGas.java)是GaseousFuel的sealed扩展,而Propane(Propane.java)是GaseousFuel的final实现:
public sealed interface NaturalGas extends GaseousFuel
permits Hydrogen, Methane {}
public final class Propane implements GaseousFuel {}
如你所见,这个接口允许两个扩展。Hydrogen类是一个final扩展,而Methane是一个sealed类:
public final class Hydrogen implements NaturalGas {}
public sealed class Methane implements NaturalGas
permits Chloromethane, Dichloromethane {}
Chloromethane类是final的,而Dichloromethane是sealed的:
public final class Chloromethane extends Methane {}
public sealed class Dichloromethane extends Methane
permits Trichloromethane {}
最后,我们有Trichloromethane类。这是一个final类:
public final class Trichloromethane extends Dichloromethane {}
完成!层次模型已关闭并完整。尝试扩展/实现这个层次结构中的任何成员都将导致异常。如果我们想向密封类/接口添加新的扩展/实现,那么我们还需要将其添加到permits列表中。
在不同的包中处理密封类
在前面的例子中,我们在同一个包com.refinery.fuel中分别表达了类/接口,但它们位于同一个包中。接下来,让我们考虑将这些类和接口分散到不同的包中,如下面的图所示:

图 8.5:不同包中的密封层次结构
只要相关的密封类/接口位于同一个包中,我们就可以使用 JDK 9 的 unnamed 特殊模块(没有显式模块)。否则,我们必须使用 named 模块。例如,如果我们像 图 8.5 中那样表达我们的模型,那么我们必须通过module-info.java将模块中的所有内容添加到模块中:
module P175_SealedHierarchySeparatePackages {}
没有命名模块,代码将无法编译。在捆绑的代码中,你可以找到这个问题的两个示例。
176. 在 JDK 17 之后关闭电气面板
你还记得我们在 问题 172 和 173 中早期引入的电气面板模型吗?在 问题 173 中,我们尽可能使用 JDK 17 之前可用的 Java 功能关闭了这个模型。现在,我们可以重新审视这个模型(问题 173),并通过 JDK 17 密封类完全关闭它。
我们从ElectricComponent接口开始,该接口声明如下:
public interface ElectricComponent {}
在这个时候,这个接口还没有关闭。它可以从应用的任何其他点扩展/实现。但我们可以通过将其转换为带有适当permits子句的密封接口来关闭它,如下所示:
public sealed interface ElectricComponent
permits ElectricCircuit, ElectricBreaker,
Capacitor, Resistor, Transistor {}
接下来,让我们专注于半封闭的ElectricCircuit类。这是一个抽象类,它使用包私有构造函数来阻止其包外部的任何扩展。然而,它仍然可以从包内部扩展。我们可以通过将其转换为带有适当permits子句的密封类来完全关闭它(包私有构造函数可以安全地移除):
public sealed abstract class ElectricCircuit
implements ElectricComponent
permits ParallelCircuit, SeriesCircuit, ShortCircuit {}
ParallelCircuit、SeriesCircuit和ShortCircuit被声明为final,所以它们保持不变。我们不希望允许这些类的任何扩展。
接下来,让我们专注于电容器、晶体管和电阻器类。这些类也是抽象的,并使用包私有构造函数来避免任何来自它们包外部的扩展尝试。因此,我们可以移除这些构造函数,并将它们转换为密封类,就像我们对ElectricCircuit所做的那样:
public sealed abstract class Capacitor
implements ElectricComponent
permits CeramicCapacitor, ElectrolyticCapacitor {}
public sealed abstract class Transistor
implements ElectricComponent
permits FieldEffectTransistor, BipolarTransistor {}
public sealed abstract class Resistor
implements ElectricComponent
permits MetalResistor, CarbonResistor {}
查看一下电阻器类。它只允许MetalResistor和CarbonResistor类。接下来,MetalResistor类需要特别注意。到目前为止,这个类是public的,可以从应用的任何其他点扩展:
public class MetalResistor extends Resistor {}
通过以下方式密封这个类可以关闭它:
public sealed class MetalResistor extends Resistor
permits MetalFilmResistor, MetalOxideResistor {}
MetalFilmResistor和MetalOxideResistor类是final的,保持不变:
public final class MetalFilmResistor extends MetalResistor {}
public final class MetalOxideResistor extends MetalResistor {}
同样的声明也适用于CeramicCapacitor、ElectrolyticCapacitor、BipolarTransistor和FieldEffectTransistor类。
接下来,让我们关注ElectricBreaker接口。这个接口位于modern.circuit.panel包中,并且只由ElectricPanel实现,因此它被声明为包私有(它不能从包外部扩展/实现):
interface ElectricBreaker extends ElectricComponent {}
为了完全封闭这个接口,我们将其转换为sealed接口,如下所示:
public sealed interface ElectricBreaker
extends ElectricComponent permits ElectricPanel {}
注意,我们还添加了public修饰符。这是必要的,因为ElectricBreaker必须出现在ElectricComponent接口的permits列表中,因此它必须在其包外部可用。
最后,ElectricPanel保持不变(一个实现ElectricBreaker的final类):
public final class ElectricPanel implements ElectricBreaker {}
任务完成!电面板分层模型完全封闭,无法扩展。我们将所有内容放入一个命名模块中(因为我们有在不同包之间交互的sealed组件),任务完成。
177. 结合密封类和记录
如你在第四章中所知,Java 记录是final类,不能被扩展,也不能扩展其他类。这意味着记录和sealed类/接口可以组合起来获得一个封闭的层次结构。
例如,在以下图中,我们可以识别出在Fuel模型中可以成为 Java 记录的良好候选类的类:

图 8.6:识别可以成为 Java 记录的类
如你所见,我们有四个可以成为 Java 记录的类:Coke、Charcoal、Hydrogen和Propane。从技术上讲,这些类可以成为 Java 记录,因为它们是final类,并且没有扩展其他类:
public record Coke() implements SolidFuel {}
public record Charcoal() implements SolidFuel {}
public record Hydrogen() implements NaturalGas {}
public record Propane() implements GaseousFuel {}
当然,技术方面很重要,但不足以满足要求。换句话说,你不必将所有类都转换为 Java 记录,仅仅因为它们可以工作并且代码可以编译。你还必须考虑应用程序的逻辑和上下文。有时,一个final类就足够了;否则,你可能需要一个由sealed接口和一些同一源文件(A.java)中的记录和类组成的神秘模型:
public sealed interface A {
record A1() implements A {}
record A2() implements A {}
final class B1 implements A {}
non-sealed class B2 implements A {}
}
record A3() implements A {}
record A4() implements A {}
如果你想要将permits子句添加到A中,你可以这样做:
public sealed interface A
permits A.A1, A.A2, A.B1, A.B2, A3, A4 {…}
完成!接下来,让我们看看密封类如何帮助编译器更好地处理instanceof检查。
178. 将密封类与 instanceof 挂钩
密封类影响编译器对instanceof操作符的理解,以及隐式地影响其内部类型转换和转换操作。
让我们考虑以下代码片段:
public interface Quadrilateral {}
public class Triangle {}
因此,我们这里有一个接口(Quadrilateral)和一个没有实现这个接口的类。在这个上下文中,以下代码是否可以编译?
public static void drawTriangle(Triangle t) {
if (**t** **instanceof** **Quadrilateral**) {
System.out.println("This is not a triangle");
} else {
System.out.println("Drawing a triangle");
}
}
我们编写了if (t instanceof Quadrilateral) {…},但我们知道Triangle没有实现Quadrilateral,所以乍一看,我们可能会认为编译器会对此提出抱怨。但实际上,代码可以编译,因为在运行时,我们可能有一个扩展Triangle并实现Quadrilateral的Rectangle类:
public class Rectangle extends Triangle
implements Quadrilateral {}
因此,我们的instanceof是有意义的,并且完全合法。接下来,让我们通过final关键字关闭Triangle类:
public final class Triangle {}
由于Triangle是final的,Rectangle不能扩展它,但它仍然可以实现Quadrilateral:
public class Rectangle implements Quadrilateral {}
这次,if (t instanceof Quadrilateral) {…}代码将无法编译。编译器知道final类不能被扩展,所以Triangle永远不会是Quadrilateral。
到目前为止,一切顺利!现在,让我们将Triangle类恢复为非final类:
public class Triangle {}
让我们密封Quadrilateral接口,只允许Rectangle:
public sealed interface Quadrilateral permits Rectangle {}
此外,Rectangle类是final的,如下所示(这次,它没有扩展Triangle):
public final class Rectangle implements Quadrilateral {}
再次,编译器将对此检查提出抱怨,if (t instanceof Quadrilateral) {…}。很明显,Triangle不能是Quadrilateral的实例,因为Quadrilateral是密封的,并且只允许Rectangle,不允许Triangle。然而,如果我们修改Rectangle以扩展Triangle,则代码可以编译:
public final class Rectangle extends Triangle
implements Quadrilateral {}
因此,总的来说,密封类可以帮助编译器更好地理解instanceof检查,并在它没有意义时向我们发出信号。
179. 在switch中挂钩密封类
这本书中不是第一次介绍密封类和switch表达式的示例。在第二章,问题 66中,我们通过sealed Player接口简要介绍了这样一个示例,目的是为了覆盖switch模式标签中的完整性(类型覆盖率)。
如果当时你发现这个例子令人困惑,我非常确信现在它已经清晰了。然而,让我们保持新鲜感,并从这个abstract基类开始看另一个示例:
public abstract class TextConverter {}
此外,我们有三个转换器可用,如下所示:
final public class Utf8 extends TextConverter {}
final public class Utf16 extends TextConverter {}
final public class Utf32 extends TextConverter {}
现在,我们可以编写一个switch表达式来匹配这些TextConverter实例,如下所示:
public static String convert(
TextConverter converter, String text) {
return switch (converter) {
case Utf8 c8 -> "Converting text to UTF-8: " + c8;
case Utf16 c16 -> "Converting text to UTF-16: " + c16;
case Utf32 c32 -> "Converting text to UTF-32: " + c32;
**case** **TextConverter tc ->** **"Converting text: "** **+ tc;**
**default** **->** **"Unrecognized converter type"****;**
};
}
查看高亮显示的代码行。在三个案例(case Utf8、case Utf16和case Utf32)之后,我们必须有一个case TextConverter或default案例。换句话说,在匹配Utf8、Utf16和Utf32之后,我们必须有一个总类型模式(无条件模式)来匹配任何其他TextConverter或default案例,这通常意味着我们面临的是一个未知的转换器。
如果总类型模式和default标签都缺失,则代码无法编译。switch表达式没有涵盖所有可能的案例(输入值),因此它不是详尽的。这是不允许的,因为使用null和/或模式标签的switch表达式和switch语句应该是详尽的。
编译器会将我们的 switch 视为非穷尽性,因为我们可以自由地扩展基类(TextConverter)而不覆盖所有情况。一个优雅的解决方案是将基类(TextConverter)密封如下:
public sealed abstract class TextConverter
permits Utf8, Utf16, Utf32 {}
现在,switch 可以表达如下:
return switch (converter) {
case Utf8 c8 -> "Converting text to UTF-8: " + c8;
case Utf16 c16 -> "Converting text to UTF-16: " + c16;
case Utf32 c32 -> "Converting text to UTF-32: " + c32;
};
这次,编译器知道所有可能的 TextConverter 类型,并看到它们都在 switch 中被覆盖。由于 TextConverter 是密封的,所以没有惊喜;不会出现未覆盖的情况。尽管如此,如果我们后来决定添加一个新的 TextConverter(例如,通过扩展 TextConverter 并在 permits 子句中添加此扩展来添加 Utf7),那么编译器将立即抱怨 switch 是非穷尽性的,因此我们必须采取行动并为其添加适当的 case。
在这个时候,Utf8、Utf16 和 Utf32 被声明为 final,因此不能被扩展。假设我们将 Utf16 修改为 non-sealed:
non-sealed public class Utf16 extends TextConverter {}
现在,我们可以扩展 Utf16 如下:
public final class Utf16be extends Utf16 {}
public final class Utf16le extends Utf16 {}
即使我们在 Utf16 类中添加了两个子类,我们的 switch 仍然是穷尽性的,因为 Utf16 的情况将同时覆盖 Utf16be 和 Utf16le。尽管如此,我们仍然可以明确地添加它们的情况,只要我们在 case Utf16 之前添加这些情况,如下所示:
return switch (converter) {
case Utf8 c8 -> "Converting text to UTF-8: " + c8;
case Utf16be c16 -> "Converting text to UTF-16BE: " + c16;
case Utf16le c16 -> "Converting text to UTF-16LE: " + c16;
case Utf16 c16 -> "Converting text to UTF-16: " + c16;
case Utf32 c32 -> "Converting text to UTF-32: " + c32;
};
我们必须在 case Utf16 之前添加 case Utf16be 和 case Utf16le 以避免优先级错误(参见 第二章,问题 65)。
这里是结合密封类、模式匹配的 switch 和 Java 记录来计算整数二叉树节点总和的另一个示例:
sealed interface BinaryTree {
record Leaf() implements BinaryTree {}
record Node(int value, BinaryTree left, BinaryTree right)
implements BinaryTree {}
}
static int sumNode(BinaryTree t) {
return switch (t) {
case Leaf nl -> 0;
case Node nv -> nv.value() + sumNode(nv.left())
+ sumNode(nv.right());
};
}
下面是调用 sumNode() 的一个示例:
BinaryTree leaf = new Leaf();
BinaryTree s1 = new Node(5, leaf, leaf);
BinaryTree s2 = new Node(10, leaf, leaf);
BinaryTree s = new Node(4, s1, s2);
int sum = sumNode(s);
在这个例子中,结果是 19。
180. 通过密封类和类型模式匹配的 switch 重新解释访问者模式
访问者模式是 Gang of Four(GoF)设计模式的一部分,其目标是定义对某些类的新操作,而无需修改这些类。您可以在互联网上找到许多关于这个主题的优秀资源,因此对于经典实现,我们在这里只提供我们示例的类图,而代码可在 GitHub 上找到:

图 8.7:访问者模式类图(用例)
简而言之,我们有一系列类(Capacitor、Transistor、Resistor 和 ElectricCircuit),它们用于创建电路。我们的操作在 XmlExportVisitor(ElectricComponentVisitor 的实现)中形成,包括打印包含电路规格和参数的 XML 文档。
在继续之前,请考虑熟悉捆绑代码中可用的传统实现和输出。
接下来,假设我们想要通过密封类和类型模式匹配的 switch 转换这个传统实现。预期的类图更简单(类更少),如下所示:

图 8.8:通过密封类和 switch 模式重新解释的访问者模式
让我们从ElectricComponent接口开始转换。我们知道这个接口只由Capacitor、Resistor、Transistor和ElectricCircuit实现。因此,这个接口是一个很好的候选者,可以成为sealed,如下所示:
public sealed interface ElectricComponent
permits Capacitor, Transistor, Resistor, ElectricCircuit {}
注意,我们已经从这个接口中删除了accept()方法。我们不再需要这个方法。接下来,Capacitor、Resistor、Transistor和ElectricCircuit变成了final类,并且accept()实现也被删除了。
由于我们不依赖于传统的访问者模式,我们可以安全地移除其特定的组件,例如ElectricComponentVisitor和XmlComponentVisitor。
看起来很干净,对吧?我们保留了一个sealed接口和四个final类。接下来,我们可以编写一个switch语句来遍历电路的每个组件,如下所示:
private static void export(ElectricComponent circuit) {
StringBuilder sb = new StringBuilder();
sb.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
export(sb, circuit);
System.out.println(sb);
}
export(StringBuilder sb, ElectricComponent... comps)是有效的访问者:
private static String export(StringBuilder sb,
ElectricComponent... comps) {
for (ElectricComponent comp : comps) {
switch (comp) {
case Capacitor c ->
sb.append("""
<capacitor>
<maxImpedance>%s</maxImpedance>
<dielectricResistance>%s</dielectricResistance>
<coreTemperature>%s</coreTemperature>
</capacitor>
""".formatted(c.getMaxImpedance(),
c.getDielectricResistance(),
c.getCoreTemperature())).toString();
case Transistor t ->
sb.append("""
<transistor>
<length>%s</length>
<width>%s</width>
<threshholdVoltage>%s</threshholdVoltage>
</transistor>
""".formatted(t.getLength(), t.getWidth(),
t.getThreshholdVoltage())).toString();
case Resistor r ->
sb.append("""
<resistor>
<resistance>%s</resistance>
<clazz>%s</clazz>
<voltage>%s</voltage>
<current>%s</current>
<power>%s</power>
</resistor>
""".formatted(r.getResistance(), r.getClazz(),
r.getVoltage(), r.getCurrent(),
r.getPower())).toString();
case ElectricCircuit ec ->
sb.append("""
<electric_circuit_%s>
%s\
</electric_circuit_%s>
""".formatted(ec.getId(),
export(new StringBuilder(),
ec.getComps().toArray(ElectricComponent[]::new)),
ec.getId()).indent(3)).toString();
}
}
return sb.toString();
}
任务完成!你可以在捆绑的代码中找到完整的示例。
181. 使用反射获取密封类信息
我们可以通过 Java 反射 API 中添加的两个方法来检查sealed类。首先,我们有isSealed(),这是一个标志方法,用于检查一个类是否是sealed。其次,我们有getPermittedSubclasses(),它返回一个包含允许的类的数组。基于这两个方法,我们可以编写以下辅助工具来返回sealed类的允许类:
public static List<Class> permittedClasses(Class clazz) {
if (clazz != null && clazz.isSealed()) {
return Arrays.asList(clazz.getPermittedSubclasses());
}
return Collections.emptyList();
}
我们可以通过Fuel模型轻松测试我们的辅助工具,如下所示:
Coke coke = new Coke();
Methane methane = new Methane();
// [interface com.refinery.fuel.SolidFuel,
// interface com.refinery.fuel.LiquidFuel,
// interface com.refinery.fuel.GaseousFuel]
System.out.println("Fuel subclasses: "
+ Inspector.permittedClasses(Fuel.class));
// [class com.refinery.fuel.Coke,
// class com.refinery.fuel.Charcoal]
System.out.println("SolidFuel subclasses: "
+ Inspector.permittedClasses(SolidFuel.class));
// []
System.out.println("Coke subclasses: "
+ Inspector.permittedClasses(coke.getClass()));
// [class com.refinery.fuel.Chloromethane,
// class com.refinery.fuel.Dichloromethane]
System.out.println("Methane subclasses: "
+ Inspector.permittedClasses(methane.getClass()));
我认为你已经明白了这个想法!
182. 列出密封类的三大好处
也许你有自己的三大密封类好处,这与以下列表不匹配。没关系,它们仍然是好处!
-
密封类支持更好的设计和清楚地暴露其意图:在使用密封类之前,我们只能依赖于
final关键字(它已经足够表达),以及包私有类/构造函数。显然,包私有代码需要一些推理来理解其意图,因为通过这种技巧建立封闭层次结构并不容易识别。另一方面,密封类非常清楚地表达了它们的意图。 -
编译器可以依赖密封类为我们执行更细粒度的检查:没有人可以悄悄地将一个类放入通过密封类关闭的层次结构中。任何此类尝试都会通过一个清晰且有意义的信息被拒绝。编译器在保护我们,并作为防止任何意外/非意外尝试以不适当的方式使用我们的封闭层次结构的第一道防线。
-
密封类有助于编译器提供更好的模式匹配:您在问题 179中实验了这一好处。编译器可以依赖密封类来确定
switch是否覆盖了所有可能的输入值,因此是穷尽的。而这只是密封类在模式匹配中能做的事情的开始。
183. 简要介绍隐藏类
隐藏类是在 JDK 15 中通过 JEP 371 引入的。它们的主要目标是作为框架动态生成的类使用。它们是生命周期短的运行时生成的类,通过反射由框架使用。
重要提示
隐藏类不能直接通过字节码或其他类使用。它们不是通过类加载器创建的。基本上,隐藏类具有查找类的类加载器。
在隐藏类的其他特性中,我们应该考虑以下几点:
-
它们不能通过 JVM 的字节码内部链接或通过显式使用类加载器(它们对
Class.forName()、Lookup.findClass()或ClassLoader.findLoadedClass()等方法不可见)被发现。它们不会出现在堆栈跟踪中。 -
它们通过无法发现的类扩展访问控制巢(ACN)。
-
框架可以定义所需的隐藏类,因为它们可以从积极的卸载中受益。这样,大量隐藏类不应该对性能产生负面影响。它们保持了效率和灵活性。
-
它们不能用作字段/返回/参数类型。它们不能作为超类。
-
它们可以直接访问其代码,而无需存在类对象。
-
它们可以有
final字段,并且无论其可访问标志如何,这些字段都不能被修改。 -
它们弃用了
misc.Unsafe::defineAnonymousClass,这是一个非标准 API。从 JDK 15 开始,lambda 表达式使用隐藏类而不是匿名类。
接下来,让我们看看我们如何创建和使用隐藏类。
184. 创建隐藏类
假设我们的隐藏类名为InternalMath,如下所示非常简单:
public class InternalMath {
public long sum(int[] nr) {
return IntStream.of(nr).sum();
}
}
正如我们在前一个问题中提到的,隐藏类与查找类具有相同的类加载器,可以通过MethodHandles.lookup()获得,如下所示:
MethodHandles.Lookup lookup = MethodHandles.lookup();
接下来,我们必须知道Lookup包含一个名为defineHiddenClass(byte[] bytes, boolean initialize, ClassOption... options)的方法。其中最重要的参数是由包含类数据的字节数组表示。initialize参数是一个标志,用于指定是否应该初始化隐藏类,而options参数可以是NESTMATE(创建的隐藏类成为查找类的巢穴伙伴,并可以访问同一巢穴中的所有私有成员)或STRONG(只有当其定义加载器不可达时,创建的隐藏类才能被卸载)。
因此,我们的目标是获取包含类数据的字节数组。为此,我们依赖于 getResourceAsStream() 和 JDK 9 的 readAllBytes(),如下所示:
Class<?> clazz = InternalMath.class;
String clazzPath = clazz.getName()
.replace('.', '/') + ".class";
InputStream stream = clazz.getClassLoader()
.getResourceAsStream(clazzPath);
byte[] clazzBytes = stream.readAllBytes();
拥有 clazzBytes 后,我们可以按照以下方式创建隐藏类:
Class<?> hiddenClass = lookup.defineHiddenClass(clazzBytes,
true, ClassOption.NESTMATE).lookupClass();
完成!接下来,我们可以在我们的框架内部使用隐藏类,如下所示:
Object obj = hiddenClass.getConstructor().newInstance();
Method method = obj.getClass()
.getDeclaredMethod("sum", int[].class);
System.out.println(method.invoke(
obj, new int[] {4, 1, 6, 7})); // 18
如你所见,我们通过反射使用隐藏类。这里有趣的部分在于我们无法将隐藏类转换为 InternalMath,所以我们使用 Object obj = …。所以,这不会起作用:
InternalMath obj = (InternalMath) hiddenClass
.getConstructor().newInstance();
然而,我们可以定义一个由隐藏类实现的接口:
public interface Math {}
public class InternalMath implements Math {…}
现在,我们可以将它们转换为 Math:
Math obj = (Math) hiddenClass.getConstructor().newInstance();
从 JDK 16 开始,Lookup 类增加了一个名为 defineHiddenClassWithClassData(byte[] bytes, Object classData, boolean initialize, ClassOption... options) 的隐藏类定义方法。此方法需要通过 MethodHandles.classData(Lookup caller, String name, Class<T> type) 或 MethodHandles.classDataAt(Lookup caller, String name, Class<T> type, int index) 获取到的类数据。请花时间进一步探索。
摘要
本章涵盖了 13 个问题。其中大部分都集中在密封类功能上。最后两个问题简要介绍了隐藏类。
留下评价!
喜欢这本书吗?通过留下亚马逊评价来帮助像你这样的读者。扫描下面的二维码获取 20% 的折扣码。

*限时优惠
第九章:函数式风格编程 – 扩展 API
本章包括涵盖广泛函数式编程主题的 24 个问题。我们将首先介绍 JDK 16 的 mapMulti() 操作,然后继续讨论一些使用谓词(Predicate)、函数和收集器的练习问题。
如果你没有 Java 函数式编程的背景,那么我强烈建议你推迟学习本章,直到你花了一些时间熟悉它。你可以考虑阅读《Java 编程问题》第一版中的第八章和第九章。
在本章结束时,你将精通 Java 中的函数式编程。
问题
使用以下问题来测试你在 Java 函数式编程中的编程能力。我强烈建议你在查看解决方案并下载示例程序之前尝试每个问题:
-
使用 mapMulti():解释并举例说明 JDK 16 的
mapMulti()。提供简要介绍,解释它与flatMap()的工作方式,并指出何时mapMulti()是一个好的选择。 -
将自定义代码流式传输到 map 中:想象一个类,它处理一些博客文章。每篇文章都有一个唯一的整数 ID,文章有几个属性,包括其标签。每篇文章的标签实际上是以标签字符串的形式表示的,标签之间用井号(
#)分隔。每当我们需要给定文章的标签列表时,我们都可以调用allTags()辅助方法。我们的目标是编写一个流管道,从这个标签列表中提取一个Map<String, List<Integer>>,其中每个标签(键)对应文章列表(值)。 -
展示方法引用与 lambda 的区别:编写一段相关代码片段,以突出方法引用与等效 lambda 表达式之间的行为差异。
-
通过 Supplier/Consumer 捕获 lambda 的惰性:编写一个 Java 程序,突出
Supplier/Consumer的工作方式。在此上下文中,指出 lambda 的惰性特征。 -
重构代码以添加 lambda 惰性:提供一个简单的示例,通过函数式代码重构一段命令式代码。
-
编写一个 Function<String, T> 来解析数据:想象一个给定的文本(
test, a, 1, 4, 5, 0xf5, 0x5, 4.5d, 6, 5.6, 50000, 345, 4.0f, 6$3, 2$1.1, 5.5, 6.7, 8, a11, 3e+1, -11199, 55)。编写一个应用程序,公开一个能够解析此文本并提取双精度浮点数、整数、长整数等的Function<String, T>。 -
在 Stream 的过滤器中组合谓词:编写几个示例,突出复合谓词在过滤器中的使用。
-
使用 Streams 过滤嵌套集合:想象你有两个嵌套集合。提供几个流管道示例来从内部集合中过滤数据。
-
使用 BiPredicate:举例说明
BiPredicate的用法。 -
为自定义模型构建动态谓词:编写一个应用程序,能够根据一些简单的输入动态生成谓词(
Predicate)。 -
从自定义条件映射构建动态谓词:考虑有一个条件映射(映射的键是一个字段,映射的值是该字段的预期值)。在这种情况下,编写一个应用程序,动态生成适当的谓词。
-
在谓词中使用日志记录:编写一个自定义解决方案,允许我们在谓词中记录失败。
-
通过 containsAll()和 containsAny()扩展 Stream:提供一个解决方案,通过名为
containsAll()和containsAny()的两个最终操作扩展 Java Stream API。 -
通过 removeAll()和 retainAll()扩展 Stream:提供一个解决方案,通过名为
removeAll()和retainAll()的两个最终操作扩展 Java Stream API。 -
介绍流比较器:提供使用流比较器的详细说明(包括示例)。
-
排序映射:编写几个代码片段来突出显示排序映射的不同用例。
-
过滤映射:编写几个代码片段来突出显示过滤映射的不同用例。
-
通过 Collector.of()创建自定义收集器:通过
Collector.of()API 编写一组任意选择的自定义收集器。 -
在 lambda 表达式中抛出检查型异常:提供一个允许我们从 lambda 表达式中抛出检查型异常的技巧。
-
为 Stream API 实现 distinctBy():编写一个 Java 应用程序,实现
distinctBy()流中间操作。这类似于内置的distinct(),但它允许我们通过给定的属性/字段过滤不同的元素。 -
编写一个自定义收集器,该收集器接受/跳过给定数量的元素:提供一个自定义收集器,允许我们仅收集前n个元素。此外,提供一个自定义收集器,跳过前n个元素并收集其余元素。
-
实现一个接受五个(或任何其他任意数量)参数的函数:编写并使用一个代表
java.util.function.Function特殊化的五个参数的功能接口。 -
实现一个接受五个(或任何其他任意数量)参数的消费者:编写并使用一个代表
java.util.function.Consumer特殊化的五个参数的功能接口。 -
部分应用一个函数:编写一个代表
java.util.function.Function特殊化的n-元功能接口。此外,此接口应提供支持(即提供必要的default方法),以便仅应用n-1,n-2,n-3,…,1 个参数。
以下几节描述了先前问题的解决方案。请记住,通常没有解决特定问题的唯一正确方法。此外,请记住,这里所示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节,并实验程序,请访问 github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter09。
185. 使用 mapMulti()
从 JDK 16 开始,Stream API 通过一个新的中间操作 mapMulti() 得到了增强。这个操作在 Stream 接口中由以下 default 方法表示:
default <R> Stream<R> mapMulti (
BiConsumer<? super T, ? super Consumer<R>> mapper)
让我们采用以例学例的方法,考虑下一个经典示例,它使用 filter() 和 map() 的组合来过滤偶数整数并加倍它们的值:
List<Integer> integers = List.of(3, 2, 5, 6, 7, 8);
List<Integer> evenDoubledClassic = integers.stream()
.filter(i -> i % 2 == 0)
.map(i -> i * 2)
.collect(toList());
可以通过以下方式使用 mapMulti() 获得相同的结果:
List<Integer> evenDoubledMM = integers.stream()
.<Integer>mapMulti((i, consumer) -> {
if (i % 2 == 0) {
consumer.accept(i * 2);
}
})
.collect(toList());
因此,我们不是使用两个中间操作,而是只使用了一个,即 mapMulti()。filter() 的作用被一个 if 语句所取代,而 map() 的作用则在 accept() 方法中完成。这次,我们通过 mapper 过滤了偶数并加倍了它们的值,其中 mapper 是一个 BiConsumer<? super T, ? super Consumer<R>>。这个双函数应用于每个整数(每个流元素),并且只有偶数整数被传递给消费者。这个消费者充当一个缓冲区,简单地向下传递(在流管道中)接收到的元素。mapper.accept(R r) 可以被调用任意次数,这意味着对于给定的流元素,我们可以产生我们需要的任意数量的输出元素。在先前的例子中,我们有一个一对一映射(当 i % 2 == 0 被评估为 false 时)和一个一对应多映射(当 i % 2 == 0 被评估为 true 时)。
重要提示
更精确地说,mapMulti() 接收一个元素输入流,并输出另一个包含零个、较少、相同或更多元素(这些元素可以是未更改的或被其他元素替换)的流。这意味着输入流中的每个元素都可以通过一对一、一对应零或一对应多映射。
你注意到返回值上应用了 <Integer>mapMulti(…) 类型见证了吗?没有这个类型见证,代码将无法编译,因为编译器无法确定 R 的正确类型。这是使用 mapMulti() 的不足之处,因此我们必须付出这个代价。
对于原始类型(double、long 和 int),我们有 mapMultiToDouble()、mapMultiToLong() 和 mapMultiToInt(),它们分别返回 DoubleStream、LongStream 和 IntStream。例如,如果我们计划求和偶数整数,那么使用 mapMultiToInt() 比使用 mapMulti() 更好,因为我们可以跳过类型见证,只使用原始的 int:
int evenDoubledAndSumMM = integers.stream()
.mapMultiToInt((i, consumer) -> {
if (i % 2 == 0) {
consumer.accept(i * 2);
}
})
.sum();
另一方面,无论何时你需要 Stream<T> 而不是 Double/Long/IntStream,你仍然需要依赖于 mapToObj() 或 boxed():
List<Integer> evenDoubledMM = integers.stream()
.mapMultiToInt((i, consumer) -> {
if (i % 2 == 0) {
consumer.accept(i * 2);
}
})
.mapToObj(i -> i) // or, .boxed()
.collect(toList());
一旦熟悉了 mapMulti(),你就会开始意识到它与众所周知的 flatMap() 非常相似,后者用于展开嵌套的 Stream<Stream<R>> 模型。让我们考虑以下一对一关系:
public class Author {
private final String name;
private final List<Book> books;
...
}
public class Book {
private final String title;
private final LocalDate published;
...
}
每个 Author 都有一系列书籍。因此,一个 List<Author>(可能成为 Stream<Author> 的候选)将为每个 Author 嵌套一个 List<Book>(可能成为嵌套 Stream<Book> 的候选)。此外,我们还有以下简单的映射 author 和单个 book 的模型:
public class Bookshelf {
private final String author;
private final String book;
...
}
在函数式编程中,将一对一多模型映射到扁平的 Bookshelf 模型是使用 flatMap() 的经典场景,如下所示:
List<Bookshelf> bookshelfClassic = authors.stream()
.flatMap(
author -> author.getBooks()
.stream()
.map(book -> new Bookshelf(
author.getName(), book.getTitle()))
).collect(Collectors.toList());
flatMap() 的问题在于我们需要为每位作者创建一个新的中间流(对于大量作者,这可能会成为性能惩罚),然后我们才能应用 map() 操作。使用 mapMulti(),我们不需要这些中间流,映射过程非常直接:
List<Bookshelf> bookshelfMM = authors.stream()
.<Bookshelf>mapMulti((author, consumer) -> {
for (Book book : author.getBooks()) {
consumer.accept(new Bookshelf(
author.getName(), book.getTitle()));
}
})
.collect(Collectors.toList());
这是一个一对一的映射。对于每位作者,消费者缓冲与作者书籍数量相等的 Bookshelf 实例。这些实例在下游中展开,最终通过 toList() 收集器收集到一个 List<Bookshelf> 中。
而且这条路径带我们来到了关于 mapMulti() 的另一个重要提示。
重要提示
当我们必须替换流中的少量元素时,mapMulti() 中间操作非常有用。这一陈述在官方文档中表述如下:“*当用少量(可能为零)的元素替换每个流元素时。”
接下来,查看基于 flatMap() 的这个示例:
List<Bookshelf> bookshelfGt2005Classic = authors.stream()
.flatMap(
author -> author.getBooks()
.stream()
.filter(book -> book.getPublished().getYear() > 2005)
.map(book -> new Bookshelf(
author.getName(), book.getTitle()))
).collect(Collectors.toList());
这个例子非常适合使用 mapMulti()。一位作者有相对较少的书籍,我们对它们进行过滤。所以基本上,我们将每个流元素替换为少量(可能为零)的元素:
List<Bookshelf> bookshelfGt2005MM = authors.stream()
.<Bookshelf>mapMulti((author, consumer) -> {
for (Book book : author.getBooks()) {
if (book.getPublished().getYear() > 2005) {
consumer.accept(new Bookshelf(
author.getName(), book.getTitle()));
}
}
})
.collect(Collectors.toList());
这比使用 flatMap() 更好,因为我们减少了中间操作的数量(不再有 filter() 调用),并且避免了中间流。这也更易于阅读。
mapMulti() 的另一个用例如下。
重要提示
当使用命令式方法生成结果元素比以 Stream 形式返回它们更容易时,mapMulti() 操作也非常有用。这一陈述在官方文档中表述如下:“当使用命令式方法生成结果元素比以 Stream “*形式返回它们更容易时。”
想象一下,我们已经在 Author 类中添加了以下方法:
public void bookshelfGt2005(Consumer<Bookshelf> consumer) {
for (Book book : this.getBooks()) {
if (book.getPublished().getYear() > 2005) {
consumer.accept(new Bookshelf(
this.getName(), book.getTitle()));
}
}
}
现在,我们可以简单地使用 mapMulti() 来获取 List<Bookshelf>,如下所示:
List<Bookshelf> bookshelfGt2005MM = authors.stream()
.<Bookshelf>mapMulti(Author::bookshelfGt2005)
.collect(Collectors.toList());
这有多酷?!在下一个问题中,我们将在另一个场景中使用 mapMulti()。
186. 将自定义代码流式传输以映射
假设我们有一个以下遗留类:
public class Post {
private final int id;
private final String title;
private final String tags;
public Post(int id, String title, String tags) {
this.id = id;
this.title = title;
this.tags = tags;
}
...
public static List<String> allTags(Post post) {
return Arrays.asList(post.getTags().split("#"));
}
}
因此,我们有一个类,它塑造了一些博客文章。每篇文章都有几个属性,包括其标签。每篇文章的标签实际上是以哈希标签(#)分隔的标签字符串表示的。每当我们需要给定文章的标签列表时,我们可以调用allTags()辅助函数。例如,以下是一系列文章及其标签:
List<Post> posts = List.of(
new Post(1, "Running jOOQ", "#database #sql #rdbms"),
new Post(2, "I/O files in Java", "#io #storage #rdbms"),
new Post(3, "Hibernate Course", "#jpa #database #rdbms"),
new Post(4, "Hooking Java Sockets", "#io #network"),
new Post(5, "Analysing JDBC transactions", "#jdbc #rdbms")
);
我们的目标是从这个列表中提取一个Map<String, List<Integer>>,其中包含每个标签(键)的帖子列表(值)。例如,对于标签#database,我们有文章 1 和 3;对于标签#rdbms,我们有文章 1、2、3 和 5,等等。
在函数式编程中完成这项任务可以通过flatMap()和groupingBy()来实现。简而言之,flatMap()对于展开嵌套的Stream<Stream<R>>模型很有用,而groupingBy()是一个用于按某些逻辑或属性在 map 中分组数据的收集器。
我们需要flatMap(),因为我们有一个List<Post>,对于每个Post,通过allTags()嵌套一个List<String>(如果我们简单地调用stream(),那么我们得到的是一个Stream<Stream<R>>)。在展开后,我们将每个标签包装在Map.Entry<String, Integer>中。最后,我们将这些条目按标签分组到一个Map中,如下所示:
Map<String, List<Integer>> result = posts.stream()
.flatMap(post -> Post.allTags(post).stream()
.map(t -> entry(t, post.getId())))
.collect(groupingBy(Entry::getKey,
mapping(Entry::getValue, toList())));
然而,根据前面的问题,我们知道从 JDK 16 开始,我们可以使用mapMulti()。因此,我们可以将之前的代码片段重写如下:
Map<String, List<Integer>> resultMulti = posts.stream()
.<Map.Entry<String, Integer>>mapMulti((post, consumer) -> {
for (String tag : Post.allTags(post)) {
consumer.accept(entry(tag, post.getId()));
}
})
.collect(groupingBy(Entry::getKey,
mapping(Entry::getValue, toList())));
这次,我们保存了map()中间操作和中间流。
187. 演示方法引用与 lambda 的区别
你是否曾经编写过一个 lambda 表达式,而你的 IDE 建议你用方法引用替换它?你很可能已经这样做了!我相信你更喜欢遵循替换,因为名称很重要,方法引用通常比 lambda 表达式更易读。虽然这是一个主观问题,但我很确信你会同意,在方法中提取长 lambda 表达式并通过方法引用使用/重用它们是一种普遍接受的良好实践。
然而,除了某些神秘的 JVM 内部表示之外,它们的行为是否相同?lambda 表达式和方法引用之间是否有任何差异可能会影响代码的行为?
好吧,让我们假设我们有一个以下简单的类:
public class Printer {
Printer() {
System.out.println("Reset printer ...");
}
public static void printNoReset() {
System.out.println(
"Printing (no reset) ..." + Printer.class.hashCode());
}
public void printReset() {
System.out.println("Printing (with reset) ..."
+ Printer.class.hashCode());
}
}
如果我们假设p1是一个方法引用,而p2是对应的 lambda 表达式,那么我们可以执行以下调用:
System.out.print("p1:");p1.run();
System.out.print("p1:");p1.run();
System.out.print("p2:");p2.run();
System.out.print("p2:");p2.run();
System.out.print("p1:");p1.run();
System.out.print("p2:");p2.run();
接下来,让我们看看使用p1和p2的两个场景。
场景 1:调用 printReset()
在第一种情况下,我们通过p1和p2调用printReset(),如下所示:
Runnable p1 = new Printer()::printReset;
Runnable p2 = () -> new Printer().printReset();
如果我们现在运行代码,那么我们会得到以下输出(由Printer构造函数生成的信息):
Reset printer ...
这种输出是由方法引用p1引起的。即使我们没有调用run()方法,Printer构造函数也会立即被调用。因为p2(lambda 表达式)是惰性的,所以只有在调用run()方法时才会调用Printer构造函数。
进一步,我们为p1和p2调用run()调用链。输出将是:
p1:Printing (with reset) ...1159190947
p1:Printing (with reset) ...1159190947
p2:Reset printer ...
Printing (with reset) ...1159190947
p2:Reset printer ...
Printing (with reset) ...1159190947
p1:Printing (with reset) ...1159190947
p2:Reset printer ...
Printing (with reset) ...1159190947
如果我们分析这个输出,我们可以看到每次 lambda (p2.run()) 执行时都会调用 Printer 构造函数。另一方面,对于方法引用(p1.run()),Printer 构造函数不会被调用。它只在一个地方被调用,即在 p1 声明时。所以 p1 打印时不会重置打印机。
场景 2:调用静态 printNoReset()
接下来,让我们调用静态方法 printNoReset():
Runnable p1 = Printer::printNoReset;
Runnable p2 = () -> Printer.printNoReset();
如果我们立即运行代码,那么什么也不会发生(没有输出)。接下来,我们启动 run() 调用,我们得到以下输出:
p1:Printing (no reset) ...149928006
p1:Printing (no reset) ...149928006
p2:Printing (no reset) ...149928006
p2:Printing (no reset) ...149928006
p1:Printing (no reset) ...149928006
p2:Printing (no reset) ...149928006
printNoReset() 是一个静态方法,所以不会调用 Printer 构造函数。我们可以互换使用 p1 或 p2 而不会有任何行为上的差异。所以,在这种情况下,这只是一种偏好。
结论
当调用非静态方法时,方法引用和 lambda 之间有一个主要区别。方法引用立即且仅调用一次构造函数(在方法调用(run())时,构造函数不会被调用)。另一方面,lambda 是懒加载的。它们仅在方法调用时调用构造函数,并且在每个这样的调用(run())中。
188. 通过 Supplier/Consumer 捕获 lambda 懒加载
java.util.function.Supplier 是一个可以通过其 get() 方法提供结果的函数式接口。java.util.function.Consumer 是另一个可以通过其 accept() 方法消耗通过其提供的参数的函数式接口。它不返回任何结果(void)。这两个函数式接口都是懒加载的,因此分析和使用它们的代码并不容易,尤其是在代码片段同时使用这两个接口时。让我们试一试!
考虑以下简单的类:
static class Counter {
static int c;
public static int count() {
System.out.println("Incrementing c from "
+ c + " to " + (c + 1));
return c++;
}
}
然后让我们编写以下 Supplier 和 Consumer:
Supplier<Integer> supplier = () -> Counter.count();
Consumer<Integer> consumer = c -> {
c = c + Counter.count();
System.out.println("Consumer: " + c );
};
那么,到目前为止,Counter.c 的值是多少?
System.out.println("Counter: " + Counter.c); // 0
正确答案是,Counter.c 是 0。供应商和消费者都是懒加载的,所以在它们的声明中都没有调用 get() 或 accept() 方法。Counter.count() 没有被调用,所以 Counter.c 没有增加。
这里有一个棘手的问题……现在怎么样?
System.out.println("Supplier: " + supplier.get()); // 0
我们知道通过调用 supplier.get(),我们触发了 Counter.count() 的执行,Counter.c 应该增加并变为 1。然而,supplier.get() 将返回 0。
解释位于 count() 方法的第 return c++; 行。当我们写 c++ 时,我们使用后增量操作,因此我们在我们的语句中使用 c 的当前值(在这种情况下,return),然后我们将其增加 1。这意味着 supplier.get() 返回 c 的值为 0,而增量操作发生在 return 之后,此时 Counter.c 为 1:
System.out.println("Counter: " + Counter.c); // 1
如果我们从后增量(c++)切换到前增量(++c),那么 supplier.get() 将返回 1,这将与 Counter.c 保持同步。这是因为增量操作在我们使用值之前发生(在这里,return)。
好的,到目前为止,我们知道Counter.c等于 1。接下来,让我们调用消费者并传入Counter.c的值:
consumer.accept(Counter.c);
通过这个调用,我们在以下计算和显示中推入Counter.c(它是 1):
c -> {
c = c + Counter.count();
System.out.println("Consumer: " + c );
} // Consumer: 2
因此c = c + Counter.count()可以看作Counter.c = Counter.c + Counter.count(),这相当于 1 = 1 + Counter.count(),所以 1 = 1 + 1。输出将是Consumer: 2。这次,Counter.c也是 2(记住后增量效应):
System.out.println("Counter: " + Counter.c); // 2
接下来,让我们调用供应商:
System.out.println("Supplier: " + supplier.get()); // 2
我们知道get()将接收c的当前值,它是 2。之后,Counter.c变为 3:
System.out.println("Counter: " + Counter.c); // 3
我们可以永远这样继续下去,但我认为你已经了解了Supplier和Consumer函数式接口的工作方式。
189. 重新整理代码以添加 lambda 惰性
在这个问题中,让我们进行一次重构会话,将非功能代码转换为功能代码。我们从以下给定的代码开始——关于应用程序依赖项的简单类映射信息:
public class ApplicationDependency {
private final long id;
private final String name;
private String dependencies;
public ApplicationDependency(long id, String name) {
this.id = id;
this.name = name;
}
public long getId() {
return id;
}
public String getName() {
return name;
}
**public** **String** **getDependencies****()** **{**
**return** **dependencies;**
**}**
private void downloadDependencies() {
dependencies = "list of dependencies
downloaded from repository " + Math.random();
}
}
为什么我们强调了getDependencies()方法?因为这是应用程序中存在功能障碍的点。更准确地说,以下类需要应用程序的依赖项以便相应地处理它们:
public class DependencyManager {
private Map<Long,String> apps = new HashMap<>();
public void processDependencies(ApplicationDependency appd){
System.out.println();
System.out.println("Processing app: " + appd.getName());
System.out.println("Dependencies: "
+ **appd.getDependencies()**);
apps.put(appd.getId(),**appd.getDependencies()**);
}
}
这个类依赖于ApplicationDependency.getDependecies()方法,它只返回null(dependencies字段的默认值)。由于没有调用downloadDependecies()方法,预期的应用程序依赖项没有被下载。很可能会有一位代码审查员指出这个问题并创建一个工单来修复它。
以命令式方式修复
一个可能的修复方法如下(在ApplicationDependency中):
public class ApplicationDependency {
**private****String****dependencies****=** **downloadDependencies();**
...
public String getDependencies() {
return dependencies;
}
...
private String downloadDependencies() {
return "list of dependencies downloaded from repository "
+ Math.random();
}
}
在dependencies初始化时调用downloadDependencies()肯定可以修复加载依赖项的问题。当DependencyManager调用getDependencies()时,它将能够访问已下载的依赖项。然而,这是一个好方法吗?我的意思是,下载依赖项是一个昂贵的操作,我们每次创建ApplicationDependency实例时都会这样做。如果getDependencies()方法从未被调用,那么这个昂贵的操作就没有得到回报。
因此,一个更好的方法是在getDependencies()实际调用之前推迟应用程序依赖项的下载:
public class ApplicationDependency {
private String dependencies;
...
public String getDependencies() {
**downloadDependencies();**
return dependencies;
}
...
private void downloadDependencies() {
dependencies = "list of dependencies
downloaded from repository " + Math.random();
}
}
这比之前好,但还不是最佳方法!这次,每次调用getDependencies()方法时都会下载应用程序的依赖项。幸运的是,有一个快速的修复方法。我们只需要在执行下载之前添加一个null检查:
public String getDependencies() {
**if** **(dependencies ==** **null****) {**
**downloadDependencies();**
**}**
return dependencies;
}
完成!现在,应用程序的依赖项只在第一次调用getDependencies()方法时下载。这个命令式解决方案效果很好,并且通过了代码审查。
以函数式方式修复
关于以函数式编程的方式提供这个修复方案怎么样?实际上,我们想要的只是惰性下载应用程序的依赖项。由于惰性是函数式编程的专长,我们现在已经熟悉了Supplier(参见前一个问题),我们可以从以下开始:
public class ApplicationDependency {
**private****final** **Supplier<String> dependencies**
**=** **this****::downloadDependencies;**
**...**
public String getDependencies() {
**return** **dependencies.get();**
}
...
private **String** downloadDependencies() {
return "list of dependencies downloaded from repository "
+ Math.random();
}
}
首先,我们定义了一个调用downloadDependencies()方法的Supplier。我们知道Supplier是惰性的,所以直到其get()方法被明确调用之前,不会发生任何事情。
其次,我们已修改getDependencies()方法,使其返回dependencies.get()。因此,我们将应用程序依赖项的下载延迟到它们被明确需要时。
第三,我们将downloadDependencies()方法的返回类型从void修改为String。这是为了Supplier.get()。
这是一个很好的修复方案,但它有一个严重的缺点。我们失去了缓存!现在,依赖项将在每次getDependencies()调用时下载。
我们可以通过记忆化(en.wikipedia.org/wiki/Memoization)来避免这个问题。这个概念在《Java 完整编码面试指南》的第第八章中也有详细说明。简而言之,记忆化是一种通过缓存可重用结果来避免重复工作的技术。
记忆化是一种在动态规划中常用到的技术,但没有任何限制或限制。例如,我们可以在函数式编程中应用它。在我们的特定情况下,我们首先定义了一个扩展Supplier接口的功能接口(或者,如果你觉得更简单,可以直接使用Supplier):
@FunctionalInterface
public interface FSupplier<R> extends Supplier<R> {}
接下来,我们提供了一个FSupplier的实现,它基本上缓存了未查看的结果,并从缓存中提供已查看的结果:
public class Memoize {
private final static Object UNDEFINED = new Object();
public static <T> FSupplier<T> supplier(
final Supplier<T> supplier) {
AtomicReference cache = new AtomicReference<>(UNDEFINED);
return () -> {
Object value = cache.get();
if (value == UNDEFINED) {
synchronized (cache) {
if (cache.get() == UNDEFINED) {
System.out.println("Caching: " + supplier.get());
value = supplier.get();
cache.set(value);
}
}
}
return (T) value;
};
}
}
最后,我们将我们的初始Supplier替换为FSupplier,如下所示:
private final Supplier<String> dependencies
= Memoize.supplier(this::downloadDependencies);
完成!我们的函数式方法利用了Supplier的惰性并可以缓存结果。
190. 编写一个Function<String, T>来解析数据
假设我们有以下文本:
String text = """
test, a, 1, 4, 5, 0xf5, 0x5, 4.5d, 6, 5.6, 50000, 345,
4.0f, 6$3, 2$1.1, 5.5, 6.7, 8, a11, 3e+1, -11199, 55
""";
目标是从这段文本中提取出数字。根据给定的场景,我们可能只需要整数,或者只需要双精度浮点数,等等。有时,我们可能需要在提取之前进行一些文本替换(例如,我们可能想要将xf字符替换为点,0xf5 = 0.5)。
解决这个问题的可能方法之一是编写一个方法(让我们称它为parseText()),它接受一个Function<String, T>作为参数。Function<String, T>给我们提供了灵活性,可以塑造以下任何一种:
List<Integer> integerValues
= parseText(text, Integer::valueOf);
List<Double> doubleValues
= parseText(text, Double::valueOf);
...
List<Double> moreDoubleValues
= parseText(text, t -> Double.valueOf(t.replaceAll(
"\\$", "").replaceAll("xf", ".").replaceAll("x", ".")));
parseText()应该执行几个步骤,直到达到最终结果。它的签名可以是以下这样:
public static <T> List<T> parseText(
String text, Function<String, T> func) {
...
}
首先,我们必须通过逗号分隔符拆分接收到的文本,并从String[]中提取项目。这样,我们就能够访问文本中的每个项目。
其次,我们可以流式传输String[]并过滤掉任何空项。
第三,我们可以调用Function.apply()将给定的函数应用于每个项目(例如,应用Double::valueOf)。这可以通过中间操作map()来完成。由于一些项目可能是无效的数字,我们必须捕获并忽略任何Exception(吞咽这样的异常是不良的做法,但在这个情况下,实际上没有其他事情可做)。对于任何无效的数字,我们简单地返回null。
第四,我们过滤掉所有null值。这意味着剩余的流只包含通过Function.apply()过滤的数字。
第五,我们将流收集到一个List中并返回它。
将这五个步骤组合起来,将得到以下代码:
public static <T> List<T> parseText(
String text, Function<String, T> func) {
return Arrays.stream(text.split(",")) // step 1 and 2
.filter(s -> !s.isEmpty())
.map(s -> {
try {
return func.apply(s.trim()); // step 3
} catch (Exception e) {}
return null;
})
.filter(Objects::nonNull) // step 4
.collect(Collectors.toList()); // step 5
}
完成!您可以用这个例子解决一系列类似的问题。
191. 在 Stream 的过滤器中组合谓词
一个谓词(基本上,一个条件)可以通过java.util.function.Predicate函数式接口建模为一个布尔值函数。它的函数方法是名为test(T t)并返回一个boolean。
在流管道中应用谓词可以通过几个流中间操作来完成,但我们这里只对filter(Predicate p)操作感兴趣。例如,让我们考虑以下类:
public class Car {
private final String brand;
private final String fuel;
private final int horsepower;
public Car(String brand, String fuel, int horsepower) {
this.brand = brand;
this.fuel = fuel;
this.horsepower = horsepower;
}
// getters, equals(), hashCode(), toString()
}
如果我们有一个List<Car>并且我们想要表达一个过滤条件,产生所有雪佛兰汽车,那么我们可以先定义适当的Predicate:
Predicate<Car> pChevrolets
= car -> car.getBrand().equals("Chevrolet");
接下来,我们可以在流管道中使用这个Predicate,如下所示:
List<Car> chevrolets = cars.stream()
.filter(pChevrolets)
.collect(Collectors.toList());
一个Predicate可以通过至少三种方式取反。我们可以通过逻辑非(!)运算符取反条件:
Predicate<Car> pNotChevrolets
= car -> !car.getBrand().equals("Chevrolet");
我们可以调用Predicate.negate()方法:
Predicate<Car> pNotChevrolets = pChevrolets.negate();
或者我们可以调用Predicate.not()方法:
Predicate<Car> pNotChevrolets = Predicate.not(pChevrolets);
无论您更喜欢哪种方法,以下过滤器将产生所有不是雪佛兰的汽车:
List<Car> notChevrolets = cars.stream()
.filter(pNotChevrolets)
.collect(Collectors.toList());
在前面的例子中,我们在流管道中应用了一个谓词。然而,我们也可以应用多个谓词。例如,我们可能想要表达一个过滤条件,产生所有不是雪佛兰且至少有 150 马力的汽车。对于这个复合谓词的第一部分,我们可以任意使用pChevrolets.negate(),而对于第二部分,我们需要以下Predicate:
Predicate<Car> pHorsepower
= car -> car.getHorsepower() >= 150;
我们可以通过链式调用filter()来获得一个复合谓词,如下所示:
List<Car> notChevrolets150 = cars.stream()
.filter(pChevrolets.negate())
.filter(pHorsepower)
.collect(Collectors.toList());
依赖于Predicate.and(Predicate<? super T> other)可以使代码更简洁、更易于表达,它会在两个谓词之间应用短路逻辑与。所以前面的例子可以这样表达:
List<Car> notChevrolets150 = cars.stream()
.filter(pChevrolets.negate().and(pHorsepower))
.collect(Collectors.toList());
如果我们需要在两个谓词之间应用短路逻辑或,那么依赖于Predicate.or(Predicate<? super T> other)是正确的选择。例如,如果我们想要表达一个过滤条件,产生所有雪佛兰或电动汽车,那么我们可以这样做:
Predicate<Car> pElectric
= car -> car.getFuel().equals("electric");
List<Car> chevroletsOrElectric = cars.stream()
.filter(pChevrolets.or(pElectric))
.collect(Collectors.toList());
如果我们处于一个高度依赖复合谓词的场景中,那么我们可以先创建两个辅助函数,使我们的工作更容易:
@SuppressWarnings("unchecked")
public final class Predicates {
private Predicates() {
throw new AssertionError("Cannot be instantiated");
}
public static <T> Predicate<T> asOneAnd(
Predicate<T>... predicates) {
Predicate<T> theOneAnd = Stream.of(predicates)
.reduce(p -> true, Predicate::and);
return theOneAnd;
}
public static <T> Predicate<T> asOneOr(
Predicate<T>... predicates) {
Predicate<T> theOneOr = Stream.of(predicates)
.reduce(p -> false, Predicate::or);
return theOneOr;
}
}
这些辅助函数的目标是将几个谓词粘合在一起,通过短路逻辑与和或形成一个单一的组合谓词。
假设我们想要表达一个通过短路逻辑与应用以下三个谓词的过滤条件:
Predicate<Car> pLexus = car -> car.getBrand().equals("Lexus");
Predicate<Car> pDiesel = car -> car.getFuel().equals("diesel");
Predicate<Car> p250 = car -> car.getHorsepower() > 250;
首先,我们将这些谓词合并为一个单一的谓词:
Predicate<Car> predicateAnd = Predicates
.asOneAnd(pLexus, pDiesel, p250);
然后,我们表达过滤条件:
List<Car> lexusDiesel250And = cars.stream()
.filter(predicateAnd)
.collect(Collectors.toList());
那么表达一个产生包含所有马力在 100 到 200 或 300 到 400 之间的汽车的流的过滤条件怎么样?谓词如下:
Predicate<Car> p100 = car -> car.getHorsepower() >= 100;
Predicate<Car> p200 = car -> car.getHorsepower() <= 200;
Predicate<Car> p300 = car -> car.getHorsepower() >= 300;
Predicate<Car> p400 = car -> car.getHorsepower() <= 400;
组合谓词可以按照以下方式获得:
Predicate<Car> pCombo = Predicates.asOneOr(
Predicates.asOneAnd(p100, p200),
Predicates.asOneAnd(p300, p400)
);
表达过滤条件很简单:
List<Car> comboAndOr = cars.stream()
.filter(pCombo)
.collect(Collectors.toList());
你可以在捆绑的代码中找到所有这些示例。
192. 使用 Streams 过滤嵌套集合
这是一个面试中的经典问题,通常从一个模型开始,如下(我们假设集合是一个List):
public class Author {
private final String name;
private final List<Book> books;
...
}
public class Book {
private final String title;
private final LocalDate published;
...
}
将List<Author>表示为authors,编写一个流管道,返回在 2002 年出版的List<Book>。你应该已经认识到这是一个典型的flatMap()问题,所以无需进一步细节,我们可以写出如下代码:
List<Book> book2002fm = authors.stream()
.flatMap(author -> author.getBooks().stream())
.filter(book -> book.getPublished().getYear() == 2002)
.collect(Collectors.toList());
mapMulti():
List<Book> book2002mm = authors.stream()
.<Book>mapMulti((author, consumer) -> {
for (Book book : author.getBooks()) {
if (book.getPublished().getYear() == 2002) {
consumer.accept(book);
}
}
})
.collect(Collectors.toList());
好的,这已经很清晰了!那么,我们如何找到在 2002 年出版的List<Author>呢?当然,mapMulti()可以再次帮助我们。我们只需要遍历书籍,当我们找到一个在 2002 年出版的书籍时,我们只需将author传递给consumer而不是书籍。此外,在将author传递给consumer之后,我们可以中断当前作者的循环,并取下一个作者:
List<Author> author2002mm = authors.stream()
.<Author>mapMulti((author, consumer) -> {
for (Book book : author.getBooks()) {
if (book.getPublished().getYear() == 2002) {
consumer.accept(author);
break;
}
}
})
.collect(Collectors.toList());
另一种方法可以依赖于anyMatch()和一个产生 2002 年出版书籍流的谓词,如下所示:
List<Author> authors2002am = authors.stream()
.filter(
author -> author.getBooks()
.stream()
.anyMatch(book -> book.getPublished()
.getYear() == 2002)
)
.collect(Collectors.toList());
通常,我们不想修改给定的列表,但如果这不是问题(或者这正是我们想要的),那么我们可以依赖removeIf()直接在List<Author>上完成相同的结果:
authors.removeIf(author -> author.getBooks().stream()
.noneMatch(book -> book.getPublished().getYear() == 2002));
完成!现在,如果你在面试中遇到类似的问题,你应该不会有任何问题。
193. 使用 BiPredicate
让我们考虑Car模型和一个表示为cars的List<Car>:
public class Car {
private final String brand;
private final String fuel;
private final int horsepower;
...
}
我们的目标是查看以下Car是否包含在cars中:
Car car = new Car("Ford", "electric", 80);
我们知道List API 公开了一个名为contains(Object o)的方法。此方法在给定的Object存在于给定的List中时返回true。因此,我们可以轻松地编写一个Predicate,如下所示:
Predicate<Car> predicate = cars::contains;
然后,我们调用test()方法,我们应该得到预期的结果:
System.out.println(predicate.test(car)); // true
我们可以通过filter()、anyMatch()等在流管道中获得相同的结果。这里是通过anyMatch()实现的:
System.out.println(
cars.stream().anyMatch(p -> p.equals(car))
);
或者,我们可以依赖BiPredicate。这是一个表示已知Predicate的两个参数特殊化的函数式接口。它的test(Object o1, Object o2)方法接受两个参数,因此它非常适合我们的情况:
BiPredicate<List<Car>, Car> biPredicate = List::contains;
我们可以按照以下方式执行测试:
System.out.println(biPredicate.test(cars, car)); // true
在下一个问题中,你将看到一个使用BiPredicate的更实际的例子。
194. 为自定义模型构建动态谓词
让我们考虑Car模型和一个表示为cars的List<Car>:
public class Car {
private final String brand;
private final String fuel;
private final int horsepower;
...
}
此外,假设我们需要动态生成一系列谓词,这些谓词将<、>、<=、>=、!=和==运算符应用于horsepower字段。直接硬编码这些谓词会很麻烦,因此我们必须想出一个解决方案,可以即时构建任何涉及此字段和此处列出的比较运算符的谓词。
有几种方法可以实现这个目标,其中之一是使用 Java enum。我们有一个固定的运算符列表,可以编码为enum元素,如下所示:
enum PredicateBuilder {
GT((t, u) -> t > u),
LT((t, u) -> t < u),
GE((t, u) -> t >= u),
LE((t, u) -> t <= u),
EQ((t, u) -> t.intValue() == u.intValue()),
NOT_EQ((t, u) -> t.intValue() != u.intValue());
...
为了应用这些(t, u)lambda 表达式之一,我们需要一个BiPredicate构造函数(参见问题 193),如下所示:
private final BiPredicate<Integer, Integer> predicate;
private PredicateBuilder(
BiPredicate<Integer, Integer> predicate) {
this.predicate = predicate;
}
...
现在我们能够定义一个BiPredicate,我们可以编写包含实际测试并返回Predicate<T>的方法:
public <T> Predicate<T> toPredicate(
Function<T, Integer> getter, int u) {
return obj -> this.predicate.test(getter.apply(obj), u);
}
...
最后,我们必须提供这里的Function<T, Integer>,这是对应于horsepower的 getter。我们可以通过 Java 反射来完成此操作,如下所示:
public static <T> Function<T, Integer> getFieldByName(
Class<T> cls, String field) {
return object -> {
try {
Field f = cls.getDeclaredField(field);
f.setAccessible(true);
return (Integer) f.get(object);
} catch (IllegalAccessException | IllegalArgumentException
| NoSuchFieldException | SecurityException e) {
throw new RuntimeException(e);
}
};
}
当然,这也可以是任何其他类和整数字段,而不仅仅是Car类和horsepower字段。基于此代码,我们可以动态创建一个谓词,如下所示:
Predicate<Car> gtPredicate
= PredicateBuilder.GT.toPredicate(
PredicateBuilder.getFieldByName(
Car.class, "horsepower"), 300);
使用此谓词很简单:
cars.stream()
.filter(gtPredicate)
.forEach(System.out::println);
您可以使用这个问题作为实现更多类型动态谓词的灵感来源。例如,在下一个问题中,我们在另一个场景中使用了相同的逻辑。
195. 从自定义条件映射构建动态谓词
让我们考虑Car模型和表示为cars的List<Car>:
public class Car {
private final String brand;
private final String fuel;
private final int horsepower;
...
}
此外,假设我们收到一个类型为field : value的Map条件,这可以用来构建动态Predicate。此类Map的示例如下:
Map<String, String> filtersMap = Map.of(
"brand", "Chevrolet",
"fuel", "diesel"
);
如您所见,我们有一个Map<String, String>,因此我们对equals()比较感兴趣。这有助于我们通过以下 Java enum(我们遵循问题 194中的逻辑)开始开发:
enum PredicateBuilder {
EQUALS(String::equals);
...
当然,我们可以添加更多运算符,例如startsWith()、endsWith()、contains()等等。接下来,基于在问题 193和194中获得的经验,我们需要添加一个BiPredicate构造函数、toPredicate()方法和 Java 反射代码来获取给定字段(此处为brand和fuel)对应的 getter:
private final BiPredicate<String, String> predicate;
private PredicateBuilder(
BiPredicate<String, String> predicate) {
this.predicate = predicate;
}
public <T> Predicate<T> toPredicate(
Function<T, String> getter, String u) {
return obj -> this.predicate.test(getter.apply(obj), u);
}
public static <T> Function<T, String>
getFieldByName(Class<T> cls, String field) {
return object -> {
try {
Field f = cls.getDeclaredField(field);
f.setAccessible(true);
return (String) f.get(object);
} catch (
IllegalAccessException | IllegalArgumentException
| NoSuchFieldException | SecurityException e) {
throw new RuntimeException(e);
}
};
}
}
接下来,我们必须为每个映射条目定义一个谓词,并通过短路 AND 运算符将它们链接起来。这可以通过以下循环完成:
Predicate<Car> filterPredicate = t -> true;
for(String key : filtersMap.keySet()) {
filterPredicate
= filterPredicate.and(PredicateBuilder.EQUALS
.toPredicate(PredicateBuilder.getFieldByName(
Car.class, key), filtersMap.get(key)));
}
最后,我们可以使用生成的谓词来过滤汽车:
cars.stream()
.filter(filterPredicate)
.forEach(System.out::println);
完成!
196. 谓词的登录
我们已经知道Predicate函数式接口依赖于其test()方法来执行给定的检查,并返回一个布尔值。假设我们想要修改test()方法以记录失败案例(导致返回false值的案例)。
一种快速的方法是编写一个辅助方法,偷偷地包含日志部分,如下所示:
public final class Predicates {
private static final Logger logger
= LoggerFactory.getLogger(LogPredicate.class);
private Predicates() {
throw new AssertionError("Cannot be instantiated");
}
public static <T> Predicate<T> testAndLog(
Predicate<? super T> predicate, String val) {
return t -> {
boolean result = predicate.test(t);
if (!result) {
logger.warn(predicate + " don't match '" + val + "'");
}
return result;
};
}
}
另一种方法是通过扩展 Predicate 接口,并提供一个 default 方法来测试和记录失败案例,如下所示:
@FunctionalInterface
public interface LogPredicate<T> extends Predicate<T> {
Logger logger = LoggerFactory.getLogger(LogPredicate.class);
default boolean testAndLog(T t, String val) {
boolean result = this.test(t);
if (!result) {
logger.warn(t + " don't match '" + val + "'");
}
return result;
}
}
您可以在捆绑的代码中练习这些示例。
197. 使用 containsAll() 和 containsAny() 扩展 Stream
假设我们有以下代码:
List<Car> cars = Arrays.asList(
new Car("Dacia", "diesel", 100),
new Car("Lexus", "gasoline", 300),
...
new Car("Ford", "electric", 200)
);
Car car1 = new Car("Lexus", "diesel", 300);
Car car2 = new Car("Ford", "electric", 80);
Car car3 = new Car("Chevrolet", "electric", 150);
List<Car> cars123 = List.of(car1, car2, car3);
接下来,在流管道的上下文中,我们想检查 cars 是否包含 car1、car2、car3 或 cars123 的所有/任何项。
Stream API 提供了一组丰富的中间和最终操作,但它没有内置的 containsAll()/containsAny()。因此,我们的任务是提供以下最终操作:
boolean contains(T item);
boolean containsAll(T... items);
boolean containsAll(List<? extends T> items);
**boolean****containsAll****(Stream<? extends T> items)****;**
boolean containsAny(T... items);
boolean containsAny(List<? extends T> items);
**boolean****containsAny****(Stream<? extends T> items)****;**
我们突出了获取 Stream 参数的方法,因为这些方法提供了主要逻辑,而其余的方法只是在将它们的参数转换为 Stream 后调用这些方法。
通过自定义接口来暴露 containsAll/Any()
containsAll(Stream<? extends T> items) 依赖于一个 Set 来完成其任务,如下所示(作为一个挑战,尝试找到另一种实现方法):
default boolean containsAll(Stream<? extends T> items) {
Set<? extends T> set = toSet(items);
if (set.isEmpty()) {
return true;
}
return stream().filter(item -> set.remove(item))
.anyMatch(any -> set.isEmpty());
}
containsAny(Stream<? extends T> items) 方法也依赖于一个 Set:
default boolean containsAny(Stream<? extends T> items) {
Set<? extends T> set = toSet(items);
if (set.isEmpty()) {
return false;
}
return stream().anyMatch(set::contains);
}
toSet() 方法只是一个辅助工具,它将 Stream 项收集到一个 Set 中:
static <T> Set<T> toSet(Stream<? extends T> stream) {
return stream.collect(Collectors.toSet());
}
接下来,让我们偷偷地将这段代码放入其最终位置,即一个自定义界面。
如您所见,containsAll(Stream<? extends T> items) 方法和 containsAny(Stream<? extends T> items) 被声明为 default,这意味着它们是接口的一部分。此外,它们都调用了 stream() 方法,这也是接口的一部分,并连接了常规的 Stream。
基本上,解决这个问题的快速方法(特别是在面试中非常有用)是编写这个自定义接口(让我们随意命名为 Streams),它能够访问原始的内置 Stream 接口,如下所示:
@SuppressWarnings("unchecked")
public interface Streams<T> {
Stream<T> stream();
static <T> Streams<T> from(Stream<T> stream) {
return () -> stream;
}
...
接下来,该接口公开了一组 default 方法,代表 containsAll()/containsAny() 的风味,如下所示:
default boolean contains(T item) {
return stream().anyMatch(isEqual(item));
}
default boolean containsAll(T... items) {
return containsAll(Stream.of(items));
}
default boolean containsAll(List<? extends T> items) {
return containsAll(items.stream());
}
default boolean containsAll(Stream<? extends T> items) {
...
}
default boolean containsAny(T... items) {
return containsAny(Stream.of(items));
}
default boolean containsAny(List<? extends T> items) {
return containsAny(items.stream());
}
default boolean containsAny(Stream<? extends T> items) {
...
}
static <T> Set<T> toSet(Stream<? extends T> stream) {
...
}
}
完成!现在,我们可以编写使用全新的 containsAll/Any() 操作的不同流管道。例如,如果我们想检查 cars 是否包含 cars123 中的所有项,我们可以将流管道表达如下:
boolean result = Streams.from(cars.stream())
.containsAll(cars123);
这里有一些更多的例子:
boolean result = Streams.from(cars.stream())
.containsAll(car1, car2, car3);
boolean result = Streams.from(cars.stream())
.containsAny(car1, car2, car3);
如以下示例所示,可以涉及更多操作:
Car car4 = new Car("Mercedes", "electric", 200);
boolean result = Streams.from(cars.stream()
.filter(car -> car.getBrand().equals("Mercedes"))
.distinct()
.dropWhile(car -> car.getFuel().equals("gasoline"))
).contains(car4);
解决这个问题的更简洁和完整的解决方案是扩展 Stream 接口。让我们来做吧!
通过扩展 Stream 来暴露 containsAll/Any()
之前的解决方案更像是一种黑客行为。一个更合理和现实的解决方案是扩展内置的 Stream API,并将我们的 containsAll/Any() 方法作为团队成员添加到 Stream 操作旁边。因此,实现开始如下:
@SuppressWarnings("unchecked")
public interface Streams<T> extends Stream<T> {
...
}
在实现 containsAll/Any() 方法之前,我们需要处理一些由扩展 Stream 接口产生的问题。首先,我们需要在 Streams 中覆盖每个 Stream 方法。由于 Stream 接口有很多方法,我们这里只列出其中一些:
@Override
public Streams<T> filter(Predicate<? super T> predicate);
@Override
public <R> Streams<R> map(
Function<? super T, ? extends R> mapper);
...
@Override
public T reduce(T identity, BinaryOperator<T> accumulator);
...
@Override
default boolean isParallel() {
return false;
}
...
@Override
default Streams<T> parallel() {
throw new UnsupportedOperationException(
"Not supported yet."); // or, return this
}
@Override
default Streams<T> unordered() {
throw new UnsupportedOperationException(
"Not supported yet."); // or, return this
}
...
@Override
default Streams<T> sequential() {
return this;
}
由于 Streams 只能处理顺序流(不支持并行),我们可以直接在 Streams 中实现 isParallel()、parallel()、unordered() 和 sequential() 方法作为 default 方法。
接下来,为了使用 Streams,我们需要一个 from(Stream s) 方法,它能够包装给定的 Stream,如下所示:
static <T> Streams<T> from(Stream<? extends T> stream) {
if (stream == null) {
return from(Stream.empty());
}
if (stream instanceof Streams) {
return (Streams<T>) stream;
}
return new StreamsWrapper<>(stream);
}
StreamsWrapper 是一个将当前 Stream 包装成顺序 Streams 的类。StreamsWrapper 类实现了 Streams,因此它必须覆盖所有 Streams 方法,并正确地将 Stream 包装成 Streams。由于 Streams 有很多方法(这是扩展 Stream 的结果),我们这里只列出其中一些(其余的可以在捆绑的代码中找到):
@SuppressWarnings("unchecked")
public class StreamsWrapper<T> implements Streams<T> {
private final Stream<? extends T> delegator;
public StreamsWrapper(Stream<? extends T> delegator) {
this.delegator = delegator.sequential();
}
@Override
public Streams<T> filter(Predicate<? super T> predicate) {
return Streams.from(delegator.filter(predicate));
}
@Override
public <R> Streams<R> map(
Function<? super T, ? extends R> mapper) {
return Streams.from(delegator.map(mapper));
}
...
@Override
public T reduce(T identity, BinaryOperator<T> accumulator) {
return ((Stream<T>) delegator)
.reduce(identity, accumulator);
}
...
}
最后,我们将 Streams 添加到 containsAll/Any() 方法中,这些方法相当直接(由于 Streams 扩展了 Stream,我们无需编写 stream() 演技,就可以访问所有 Stream 的优点,就像在先前的解决方案中那样)。首先,我们添加 containsAll() 方法:
default boolean contains(T item) {
return anyMatch(isEqual(item));
}
default boolean containsAll(T... items) {
return containsAll(Stream.of(items));
}
default boolean containsAll(List<? extends T> items) {
return containsAll(items.stream());
}
default boolean containsAll(Stream<? extends T> items) {
Set<? extends T> set = toSet(items);
if (set.isEmpty()) {
return true;
}
return filter(item -> set.remove(item))
.anyMatch(any -> set.isEmpty());
}
第二,我们添加 containsAny() 方法:
default boolean containsAny(T... items) {
return containsAny(Stream.of(items));
}
default boolean containsAny(List<? extends T> items) {
return containsAny(items.stream());
}
default boolean containsAny(Stream<? extends T> items) {
Set<? extends T> set = toSet(items);
if (set.isEmpty()) {
return false;
}
return anyMatch(set::contains);
}
最后,我们添加了 toSet() 方法,您已经知道了:
static <T> Set<T> toSet(Stream<? extends T> stream) {
return stream.collect(Collectors.toSet());
}
任务完成!现在,让我们写一些示例:
boolean result = Streams.from(cars.stream())
.filter(car -> car.getBrand().equals("Mercedes"))
.contains(car1);
boolean result = Streams.from(cars.stream())
.containsAll(cars123);
boolean result = Streams.from(cars123.stream())
.containsAny(cars.stream());
您可以在捆绑的代码中找到更多示例。
198. 通过 removeAll() 和 retainAll() 扩展 Stream
在阅读这个问题之前,我强烈建议您阅读 问题 197。
在 问题 197 中,我们通过自定义接口扩展了 Stream API,添加了两个名为 containsAll() 和 containsAny() 的最终操作。在两种情况下,生成的接口都命名为 Streams。在这个问题中,我们遵循相同的逻辑来实现两个名为 removeAll() 和 retainAll() 的中间操作,其签名如下:
Streams<T> remove(T item);
Streams<T> removeAll(T... items);
Streams<T> removeAll(List<? extends T> items);
**Streams<T>** **removeAll****(Stream<? extends T> items)****;**
Streams<T> retainAll(T... items);
Streams<T> retainAll(List<? extends T> items);
**Streams<T>** **retainAll****(Stream<? extends T> items)****;**
由于 removeAll() 和 retainAll() 是中间操作,它们必须返回 Stream。更确切地说,它们必须返回 Streams,这是我们基于自定义接口或扩展 Stream 的接口的实现。
通过自定义接口公开 removeAll()/retainAll()
removeAll(Stream<? extends T> items) 方法依赖于 Set 来完成其任务,如下所示(作为一个挑战,尝试找到另一种实现):
default Streams<T> removeAll(Stream<? extends T> items) {
Set<? extends T> set = toSet(items);
if (set.isEmpty()) {
return this;
}
return from(stream().filter(item -> !set.contains(item)));
}
retainAll(Stream<? extends T> items) 方法也依赖于 Set:
default Streams<T> retainAll(Stream<? extends T> items) {
Set<? extends T> set = toSet(items);
if (set.isEmpty()) {
return from(Stream.empty());
}
return from(stream().filter(item -> set.contains(item)));
}
toSet() 方法只是一个收集 Stream 项到 Set 的辅助工具:
static <T> Set<T> toSet(Stream<? extends T> stream) {
return stream.collect(Collectors.toSet());
}
接下来,我们可以将这些 default 方法悄悄地放入一个名为 Streams 的自定义接口中,就像我们在 问题 197 中做的那样:
@SuppressWarnings("unchecked")
public interface Streams<T> {
Stream<T> stream();
static <T> Streams<T> from(Stream<T> stream) {
return () -> stream;
}
// removeAll()/retainAll() default methods and toSet()
}
这个实现有一个大问题。当我们尝试在流管道中链式调用removeAll()/retainAll()旁边其他Stream操作时,问题变得明显。因为这两个方法返回Streams(而不是Stream),我们无法在它们之后链式调用Stream操作,而必须首先调用 Java 内置的stream()。这是从Streams切换到Stream所需要的。以下是一个示例(使用在问题 197中引入的cars,car1,car2,car3和car123):
Streams.from(cars.stream())
.retainAll(cars123)
.removeAll(car1, car3)
**.stream()**
.forEach(System.out::println);
如果我们必须在Streams和Stream之间多次交替,问题会变得更加严重。查看这个僵尸:
Streams.from(Streams.from(cars.stream().distinct())
.retainAll(car1, car2, car3)
.stream()
.filter(car -> car.getFuel().equals("electric")))
.removeAll(car2)
.stream()
.forEach(System.out::println);
这个技巧并不是一个令人愉快的选项来丰富 Stream API 的中间操作。然而,它对于终端操作工作得相当好。因此,正确的方法是扩展Stream接口。
通过扩展 Stream 暴露 removeAll/retainAll()
我们已经从问题 197中了解到如何扩展Stream接口。removeAll()的实现也是直接的:
@SuppressWarnings("unchecked")
public interface Streams<T> extends Stream<T> {
default Streams<T> remove(T item) {
return removeAll(item);
}
default Streams<T> removeAll(T... items) {
return removeAll(Stream.of(items));
}
default Streams<T> removeAll(List<? extends T> items) {
return removeAll(items.stream());
}
default Streams<T> removeAll(Stream<? extends T> items) {
Set<? extends T> set = toSet(items);
if (set.isEmpty()) {
return this;
}
return filter(item -> !set.contains(item))
.onClose(items::close);
}
...
然后,以同样的方式跟随retainAll():
default Streams<T> retainAll(T... items) {
return retainAll(Stream.of(items));
}
default Streams<T> retainAll(List<? extends T> items) {
return retainAll(items.stream());
}
default Streams<T> retainAll(Stream<? extends T> items) {
Set<? extends T> set = toSet(items);
if (set.isEmpty()) {
return from(Stream.empty());
}
return filter(item -> set.contains(item))
.onClose(items::close);
}
...
}
如您从问题 197中知道,接下来,我们必须重写所有Stream方法以返回Streams。虽然这部分代码在捆绑代码中可用,以下是如何使用removeAll()/retainAll()的示例:
Streams.from(cars.stream())
.distinct()
.retainAll(car1, car2, car3)
.filter(car -> car.getFuel().equals("electric"))
.removeAll(car2)
.forEach(System.out::println);
如您所见,这次,流管道看起来相当好。没有必要通过stream()调用在Streams和Stream之间进行切换。所以,任务完成了!
199. 引入流比较器
假设我们有以下三个列表(一个数字列表,一个字符串列表和一个Car对象列表):
List<Integer> nrs = new ArrayList<>();
List<String> strs = new ArrayList<>();
List<Car> cars = List.of(...);
public class Car {
private final String brand;
private final String fuel;
private final int horsepower;
...
}
接下来,我们想在流管道中对这些列表进行排序。
通过自然顺序排序
通过自然顺序排序非常简单。我们只需要调用内置的中间操作sorted():
nrs.stream()
.sorted()
.forEach(System.out::println);
strs.stream()
.sorted()
.forEach(System.out::println);
如果nrs包含 1,6,3,8,2,3 和 0,那么sorted()将产生 0,1,2,3,3,6 和 8。因此,对于数字,自然顺序是按值升序排列。
如果strs包含“book”,“old”,“new”,“quiz”,“around”和“tick”,那么sorted()将产生“around”,“book”,“new”,“old”,“quiz”和“tick”。因此,对于字符串,自然顺序是字母顺序。
如果我们显式地通过sorted(Comparator<? super T> comparator)调用Integer.compareTo()和String.compareTo(),可以得到相同的结果:
nrs.stream()
.sorted((n1, n2) -> n1.compareTo(n2))
.forEach(System.out::println);
strs.stream()
.sorted((s1, s2) -> s1.compareTo(s2))
.forEach(System.out::println);
或者,我们可以使用java.util.Comparator函数式接口,如下所示:
nrs.stream()
.sorted(Comparator.naturalOrder())
.forEach(System.out::println);
strs.stream()
.sorted(Comparator.naturalOrder())
.forEach(System.out::println);
这三种方法返回相同的结果。
反转自然顺序
可以通过Comparator.reverseOrder()反转自然顺序,如下所示:
nrs.stream()
.sorted(Comparator.reverseOrder())
.forEach(System.out::println);
strs.stream()
.sorted(Comparator.reverseOrder())
.forEach(System.out::println);
如果nrs包含 1,6,3,8,2,3 和 0,那么sorted()将产生 8,6,3,3,2,1 和 0。反转数字的自然顺序将按值降序排列。
如果strs包含“book”,“old”,“new”,“quiz”,“around”和“tick”,那么sorted()将产生“tick”,“quiz”,“old”,“new”,“book”和“around”。所以对于字符串,反转自然顺序会导致反转字母顺序。
排序和 null 值
如果nrs/strs包含null值,那么所有之前的示例都将抛出NullPointerException。然而,java.util.Comparator提供了两个方法,允许我们首先(nullsFirst(Comparator<? super T> comparator))或最后(nullsLast(Comparator<? super T> comparator))对null值进行排序。它们的使用方法如下面的示例所示:
nrs.stream()
.sorted(Comparator.nullsFirst(Comparator.naturalOrder()))
.forEach(System.out::println);
nrs.stream()
.sorted(Comparator.nullsLast(Comparator.naturalOrder()))
.forEach(System.out::println);
nrs.stream()
.sorted(Comparator.nullsFirst(Comparator.reverseOrder()))
.forEach(System.out::println);
第三个示例首先对null值进行排序,然后按逆序排序数字。
编写自定义比较器
有时,我们需要一个自定义比较器。例如,如果我们想按最后一个字符对strs进行升序排序,那么我们可以编写一个自定义比较器,如下所示:
strs.stream()
.sorted((s1, s2) ->
Character.compare(s1.charAt(s1.length() - 1),
s2.charAt(s2.length() - 1)))
.forEach(System.out::println);
如果strs包含“book”,“old”,“new”,“quiz”,“around”和“tick”,那么sorted()将产生“old”,“around”,“book”,“tick”,“new”和“quiz”。
然而,自定义比较器通常用于对模型进行排序。例如,如果我们需要排序cars列表,那么我们需要定义一个比较器。我们不能只是说:
cars.stream()
.sorted()
.forEach(System.out::println);
这将无法编译,因为没有为Car对象提供比较器。一种方法是实现Comparable接口并重写compareTo(Car c)方法。例如,如果我们想按horsepower对cars进行升序排序,那么我们首先实现Comparable,如下所示:
public class Car implements Comparable<Car> {
...
@Override
public int compareTo(Car c) {
return this.getHorsepower() > c.getHorsepower()
? 1 : this.getHorsepower() < c.getHorsepower() ? -1 : 0;
}
}
现在,我们可以成功编写这个:
cars.stream()
.sorted()
.forEach(System.out::println);
或者,如果我们不能修改Car代码,我们可以尝试使用现有的Comparator方法之一,这些方法允许我们传递一个包含排序键的函数,并返回一个自动按该键比较的Comparator。由于horsepower是整数,我们可以使用comparingInt(ToIntFunction<? super T> keyExtractor),如下所示:
cars.stream()
.sorted(Comparator.comparingInt(Car::getHorsepower))
.forEach(System.out::println);
这里是反转顺序:
cars.stream()
.sorted(Comparator.comparingInt(
Car::getHorsepower).reversed())
.forEach(System.out::println);
你可能还对comparingLong(ToLongFunction)和comparingDouble(ToDoubleFunction)感兴趣。
ToIntFunction,ToLongFunction和ToDoubleFunction是Function方法的特殊化。在这个上下文中,我们可以说comparingInt(),comparingLong()和comparingDouble()是comparing()的特殊化,comparing()有两种风味:comparing(Function<? super T,? extends U> keyExtractor)和comparing(Function<? super T,? extends U> keyExtractor, Comparator<? super U> keyComparator)。
这里是使用comparing()的第二种风味按fuel类型(自然顺序)对cars进行升序排序的示例,将null值放在末尾:
cars.stream()
.sorted(Comparator.comparing(Car::getFuel,
Comparator.nullsLast(Comparator.naturalOrder())))
.forEach(System.out::println);
此外,这里还有一个按fuel类型的最后一个字符对cars进行升序排序的示例,将null值放在末尾:
cars.stream()
.sorted(Comparator.comparing(Car::getFuel,
Comparator.nullsLast((s1, s2) ->
Character.compare(s1.charAt(s1.length() - 1),
s2.charAt(s2.length() - 1)))))
.forEach(System.out::println);
通常,在函数表达式中链式多个比较器会导致代码可读性降低。在这种情况下,您可以通过导入静态并分配以“by”开头的变量来保持代码的可读性,如下例所示(此代码的结果与上一个示例相同,但更易于阅读):
import static java.util.Comparator.comparing;
import static java.util.Comparator.nullsLast;
...
Comparator<String> byCharAt = nullsLast(
(s1, s2) -> Character.compare(s1.charAt(s1.length() - 1),
s2.charAt(s2.length() - 1)));
Comparator<Car> byFuelAndCharAt = comparing(
Car::getFuel, byCharAt);
cars.stream()
.sorted(byFuelAndCharAt)
.forEach(System.out::println);
完成!在下一个问题中,我们将对映射进行排序。
200. 对映射进行排序
假设我们有以下映射:
public class Car {
private final String brand;
private final String fuel;
private final int horsepower;
...
}
Map<Integer, Car> cars = Map.of(
1, new Car("Dacia", "diesel", 350),
2, new Car("Lexus", "gasoline", 350),
3, new Car("Chevrolet", "electric", 150),
4, new Car("Mercedes", "gasoline", 150),
5, new Car("Chevrolet", "diesel", 250),
6, new Car("Ford", "electric", 80),
7, new Car("Chevrolet", "diesel", 450),
8, new Car("Mercedes", "electric", 200),
9, new Car("Chevrolet", "gasoline", 350),
10, new Car("Lexus", "diesel", 300)
);
接下来,我们希望将此映射排序到 List<String> 中,如下所示:
-
如果马力值不同,则按马力降序排序
-
如果马力值相等,则按映射键的升序排序
-
结果
List<String>应包含类型为 键(马力) 的项
在这些语句下,对 cars 映射进行排序将得到:
[7(450), 1(350), 2(350), 9(350), 10(300), 5(250),
8(200), 3(150), 4(150), 6(80)]
显然,这个问题需要一个自定义的比较器。有两个映射条目 (c1, c2),我们详细阐述以下逻辑:
-
检查
c2的马力是否等于c1的马力 -
如果它们相等,则比较
c1的键与c2的键 -
否则,比较
c2的马力与c1的马力 -
将结果收集到
List中
在代码行中,这可以表示如下:
List<String> result = cars.entrySet().stream()
.sorted((c1, c2) -> c2.getValue().getHorsepower()
== c1.getValue().getHorsepower()
? c1.getKey().compareTo(c2.getKey())
: Integer.valueOf(c2.getValue().getHorsepower())
.compareTo(c1.getValue().getHorsepower()))
.map(c -> c.getKey() + "("
+ c.getValue().getHorsepower() + ")")
.toList();
或者,如果我们依赖于 Map.Entry.comparingByValue()、comparingByKey() 和 java.util.Comparator,则可以写成如下:
List<String> result = cars.entrySet().stream()
.sorted(Entry.<Integer, Car>comparingByValue(
Comparator.comparingInt(
Car::getHorsepower).reversed())
.thenComparing(Entry.comparingByKey()))
.map(c -> c.getKey() + "("
+ c.getValue().getHorsepower() + ")")
.toList();
这种方法更易于阅读和表达。
201. 过滤映射
让我们考虑以下映射:
public class Car {
private final String brand;
private final String fuel;
private final int horsepower;
...
}
Map<Integer, Car> cars = Map.of(
1, new Car("Dacia", "diesel", 100),
...
10, new Car("Lexus", "diesel", 300)
);
为了流映射,我们可以从 Map 的 entrySet()、values() 或 keyset() 开始,然后调用 stream()。例如,如果我们想表达一个表示为 Map -> Stream -> Filter -> String 的管道,它返回包含所有电动汽车品牌的 List<String>,则可以依赖 entrySet() 如下:
String electricBrands = cars.entrySet().stream()
.filter(c -> "electric".equals(c.getValue().getFuel()))
.map(c -> c.getValue().getBrand())
.collect(Collectors.joining(", "));
然而,正如你所看到的,这个流管道没有使用映射的键。这意味着我们可以通过 values() 而不是 entrySet() 更好地表达它,如下所示:
String electricBrands = cars.values().stream()
.filter(c -> "electric".equals(c.getFuel()))
.map(c -> c.getBrand())
.collect(Collectors.joining(", "));
这更易于阅读,并且清楚地表达了其意图。
这里有一个例子,你应该能够理解而不需要进一步细节:
Car newCar = new Car("No name", "gasoline", 350);
String carsAsNewCar1 = cars.entrySet().stream()
.filter(c -> (c.getValue().getFuel().equals(newCar.getFuel())
&& c.getValue().getHorsepower() == newCar.getHorsepower()))
.map(map -> map.getValue().getBrand())
.collect(Collectors.joining(", "));
String carsAsNewCar2 = cars.values().stream()
.filter(c -> (c.getFuel().equals(newCar.getFuel())
&& c.getHorsepower() == newCar.getHorsepower()))
.map(map -> map.getBrand())
.collect(Collectors.joining(", "));
因此,当流管道只需要映射的值时,我们可以从 values() 开始;当它只需要键时,我们可以从 keyset() 开始;当它需要两者(值和键)时,我们可以从 entrySet() 开始。
例如,一个表示为 Map -> Stream -> Filter -> Map 的流管道,它通过键过滤前五辆汽车并将它们收集到结果映射中,需要从 entrySet() 开始,如下所示:
Map<Integer, Car> carsTop5a = cars.entrySet().stream()
.filter(c -> c.getKey() <= 5)
.collect(Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue));
//or, .collect(Collectors.toMap(
// c -> c.getKey(), c -> c.getValue()));
这里有一个返回具有超过 100 马力的前五辆汽车的映射的例子:
Map<Integer, Car> hp100Top5a = cars.entrySet().stream()
.filter(c -> c.getValue().getHorsepower() > 100)
.sorted(Entry.comparingByValue(
Comparator.comparingInt(Car::getHorsepower)))
.collect(Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue,
(c1, c2) -> c2, LinkedHashMap::new));
//or, .collect(Collectors.toMap(
// c -> c.getKey(), c -> c.getValue(),
// (c1, c2) -> c2, LinkedHashMap::new));
如果我们需要经常表达这样的管道,那么我们可能更喜欢编写一些辅助函数。以下是一组用于按键过滤和排序 Map<K, V> 的四个通用辅助函数:
public final class Filters {
private Filters() {
throw new AssertionError("Cannot be instantiated");
}
public static <K, V> Map<K, V> byKey(
Map<K, V> map, Predicate<K> predicate) {
return map.entrySet()
.stream()
.filter(item -> predicate.test(item.getKey()))
.collect(Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue));
}
public static <K, V> Map<K, V> sortedByKey(
Map<K, V> map, Predicate<K> predicate, Comparator<K> c) {
return map.entrySet()
.stream()
.filter(item -> predicate.test(item.getKey()))
.sorted(Map.Entry.comparingByKey(c))
.collect(Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue,
(c1, c2) -> c2, LinkedHashMap::new));
}
...
用于按值过滤和排序 Map 的集合:
public static <K, V> Map<K, V> byValue(
Map<K, V> map, Predicate<V> predicate) {
return map.entrySet()
.stream()
.filter(item -> predicate.test(item.getValue()))
.collect(Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue));
}
public static <K, V> Map<K, V> sortedbyValue(Map<K, V> map,
Predicate<V> predicate, Comparator<V> c) {
return map.entrySet()
.stream()
.filter(item -> predicate.test(item.getValue()))
.sorted(Map.Entry.comparingByValue(c))
.collect(Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue,
(c1, c2) -> c2, LinkedHashMap::new));
}
}
现在,我们的代码已经变得非常简短。例如,我们可以通过键过滤前五辆汽车并将它们收集到结果映射中,如下所示:
Map<Integer, Car> carsTop5s
= Filters.byKey(cars, c -> c <= 5);
或者,我们可以按照以下方式过滤出马力超过 100 的前五辆汽车:
Map<Integer, Car> hp100Top5s
= Filters.byValue(cars, c -> c.getHorsepower() > 100);
Map<Integer, Car> hp100Top5d
= Filters.sortedbyValue(cars, c -> c.getHorsepower() > 100,
Comparator.comparingInt(Car::getHorsepower));
很酷,对吧?!请随意扩展 Filters 以包含更多通用辅助函数,以处理流管道中的 Map 处理。
202. 通过 Collector.of() 创建自定义收集器
在 Java Coding Problem,第一版,第 9 章,问题 193 中,我们详细介绍了创建自定义收集器这个主题。更确切地说,在那个问题中,您看到了如何通过实现 java.util.stream.Collector 接口来编写自定义收集器。
如果您还没有阅读那本书/问题,请不要担心;您仍然可以跟随这个问题。首先,我们将创建几个自定义收集器。这次,我们将依赖于两个具有以下签名的 Collector.of() 方法:
static <T,R> Collector<T,R,R> of(
Supplier<R> supplier,
BiConsumer<R,T> accumulator,
BinaryOperator<R> combiner,
Collector.Characteristics... characteristics)
static <T,A,R> Collector<T,A,R> of(
Supplier<A> supplier,
BiConsumer<A,T> accumulator,
BinaryOperator<A> combiner,
Function<A,R> finisher,
Collector.Characteristics... characteristics)
在这个上下文中,T、A 和 R 代表以下内容:
-
T代表Stream的元素类型(将被收集的元素) -
A代表在集合过程中使用的对象类型,称为累加器,它用于在可变结果容器中累积流元素 -
R代表集合过程之后对象的类型(最终结果)
此外,一个 Collector 由四个函数和一个枚举来表征。以下是 Java Coding Problems,第一版中的一段简短笔记:
“这些函数协同工作,将条目累积到可变结果容器中,并可选择对结果执行最终转换。它们如下:
-
创建一个新的空可变结果容器(提供者参数)
-
将新的数据元素合并到可变结果容器中(累加器参数)
-
将两个可变结果容器合并为一个(组合器参数)
-
对可变结果容器执行可选的最终转换以获得最终结果(完成器参数)
此外,我们还有 Collector.Characteristics... 枚举,它定义了收集器的行为。可能的值有 UNORDERED(无顺序)、CONCURRENT(更多线程累积元素)和 IDENTITY_FINISH(完成器是恒等函数,因此不会进行进一步转换)。
在这个上下文中,让我们尝试运行几个示例。但首先,让我们假设我们有以下模型:
public interface Vehicle {}
public class Car implements Vehicle {
private final String brand;
private final String fuel;
private final int horsepower;
...
}
public class Submersible implements Vehicle {
private final String type;
private final double maxdepth;
...
}
此外,还有一些数据:
Map<Integer, Car> cars = Map.of(
1, new Car("Dacia", "diesel", 100),
...
10, new Car("Lexus", "diesel", 300)
);
接下来,让我们在名为 MyCollectors 的辅助类中创建一些收集器。
编写一个将元素收集到 TreeSet 的自定义收集器
在一个将元素收集到 TreeSet 并以 TreeSet::new 为提供者的自定义收集器中,累加器是 TreeSet.add(),组合器依赖于 TreeSet.addAll(),完成器是恒等函数:
public static <T>
Collector<T, TreeSet<T>, TreeSet<T>> toTreeSet() {
return Collector.of(TreeSet::new, TreeSet::add,
(left, right) -> {
left.addAll(right);
return left;
}, Collector.Characteristics.IDENTITY_FINISH);
}
在以下示例中,我们使用这个收集器收集所有电品牌到 TreeSet<String> 中:
TreeSet<String> electricBrands = cars.values().stream()
.filter(c -> "electric".equals(c.getFuel()))
.map(c -> c.getBrand())
.collect(MyCollectors.toTreeSet());
非常简单!
编写一个将元素收集到 LinkedHashSet 的自定义收集器
在一个将收集器收集到LinkedHashSet的自定义收集器中,其中供应商是LinkedHashSet::new,累加器是HashSet::add,组合器依赖于HashSet.addAll(),而完成器是恒等函数:
public static <T> Collector<T, LinkedHashSet<T>,
LinkedHashSet<T>> toLinkedHashSet() {
return Collector.of(LinkedHashSet::new, HashSet::add,
(left, right) -> {
left.addAll(right);
return left;
}, Collector.Characteristics.IDENTITY_FINISH);
}
在以下示例中,我们使用这个收集器来收集排序后的汽车马力:
LinkedHashSet<Integer> hpSorted = cars.values().stream()
.map(c -> c.getHorsepower())
.sorted()
.collect(MyCollectors.toLinkedHashSet());
完成!LinkedHashSet<Integer>包含按升序排列的马力值。
编写一个排除另一个收集器元素的定制收集器
本节的目标是提供一个自定义收集器,它接受一个Predicate和一个Collector作为参数。它将给定的predicate应用于要收集的元素,以排除给定collector中的失败项:
public static <T, A, R> Collector<T, A, R> exclude(
Predicate<T> predicate, Collector<T, A, R> collector) {
return Collector.of(
collector.supplier(),
(l, r) -> {
if (predicate.negate().test(r)) {
collector.accumulator().accept(l, r);
}
},
collector.combiner(),
collector.finisher(),
collector.characteristics()
.toArray(Collector.Characteristics[]::new)
);
}
自定义收集器使用给定的供应商、组合器、完成器和特性。它只影响给定收集器的累加器。基本上,它只显式调用给定收集器的累加器,对于通过给定谓词的元素。
例如,如果我们想通过这个自定义收集器获取小于 200 的排序马力,那么我们可以这样调用它(谓词指定了应该排除的内容):
LinkedHashSet<Integer> excludeHp200 = cars.values().stream()
.map(c -> c.getHorsepower())
.sorted()
.collect(MyCollectors.exclude(c -> c > 200,
MyCollectors.toLinkedHashSet()));
在这里,我们使用了两个自定义收集器,但我们可以轻松地将toLinkedHashSet()替换为一个内置收集器。挑战自己编写这个自定义收集器的对应版本。编写一个收集通过给定谓词通过的元素的收集器。
编写一个按类型收集元素的定制收集器
假设我们有一个以下List<Vehicle>:
Vehicle mazda = new Car("Mazda", "diesel", 155);
Vehicle ferrari = new Car("Ferrari", "gasoline", 500);
Vehicle hov = new Submersible("HOV", 3000);
Vehicle rov = new Submersible("ROV", 7000);
List<Vehicle> vehicles = List.of(mazda, hov, ferrari, rov);
我们的目标是只收集汽车或潜水艇,而不是两者。为此,我们可以编写一个自定义收集器,通过type收集到给定的供应商中,如下所示:
public static
<T, A extends T, R extends Collection<A>> Collector<T, ?, R>
toType(Class<A> type, Supplier<R> supplier) {
return Collector.of(supplier,
(R r, T t) -> {
if (type.isInstance(t)) {
r.add(type.cast(t));
}
},
(R left, R right) -> {
left.addAll(right);
return left;
},
Collector.Characteristics.IDENTITY_FINISH
);
}
现在,我们可以只将List<Vehicle>中的汽车收集到ArrayList中,如下所示:
List<Car> onlyCars = vehicles.stream()
.collect(MyCollectors.toType(
Car.class, ArrayList::new));
此外,我们只能将潜水艇收集到HashSet中,如下所示:
Set<Submersible> onlySubmersible = vehicles.stream()
.collect(MyCollectors.toType(
Submersible.class, HashSet::new));
最后,让我们编写一个用于自定义数据结构的定制收集器。
编写一个用于 SplayTree 的定制收集器
在第五章,问题 127中,我们实现了 SplayTree 数据结构。现在,让我们编写一个能够将元素收集到 SplayTree 中的定制收集器。显然,供应商是SplayTree::new。此外,累加器是SplayTree.insert(),而组合器是SplayTree.insertAll():
public static
Collector<Integer, SplayTree, SplayTree> toSplayTree() {
return Collector.of(SplayTree::new, SplayTree::insert,
(left, right) -> {
left.insertAll(right);
return left;
},
Collector.Characteristics.IDENTITY_FINISH);
}
这里有一个示例,它将汽车的马力收集到一个 SplayTree 中:
SplayTree st = cars.values().stream()
.map(c -> c.getHorsepower())
.collect(MyCollectors.toSplayTree());
完成!挑战自己实现一个自定义收集器。
203. 从 lambda 表达式抛出检查型异常
假设我们有一个以下 lambda 表达式:
static void readFiles(List<Path> paths) {
paths.forEach(p -> {
try {
readFile(p);
} catch (IOException e) {
**...** **// what can we throw here?**
}
});
}
我们在catch块中可以抛出什么?你们大多数人都会知道答案;我们可以抛出一个未检查的异常,例如RuntimeException:
static void readFiles(List<Path> paths) {
paths.forEach(p -> {
try {
readFile(p);
} catch (IOException e) {
**throw****new****RuntimeException****(e);**
}
});
}
此外,大多数人知道我们不能抛出一个检查型异常,例如IOException。以下代码片段将无法编译:
static void readFiles(List<Path> paths) {
paths.forEach(p -> {
try {
readFile(p);
} catch (IOException e) {
**throw****new****IOException****(e);**
}
});
}
我们能否改变这个规则?我们能否想出一个允许从 lambda 表达式抛出检查型异常的技巧?简短的回答是:当然可以!
长答案:当然可以,如果我们简单地隐藏编译器对已检查异常的检查,如下所示:
public final class Exceptions {
private Exceptions() {
throw new AssertionError("Cannot be instantiated");
}
public static void throwChecked(Throwable t) {
Exceptions.<RuntimeException>throwIt(t);
}
@SuppressWarnings({"unchecked"})
private static <X extends Throwable> void throwIt(
Throwable t) throws X {
throw (X) t;
}
}
没有其他了!现在,我们可以抛出任何已检查的异常。这里,我们抛出一个IOException:
static void readFiles(List<Path> paths) throws IOException {
paths.forEach(p -> {
try {
readFile(p);
} catch (IOException e) {
Exceptions.throwChecked(new IOException(
"Some files are corrupted", e));
}
});
}
此外,我们可以这样捕获它:
List<Path> paths = List.of(...);
try {
readFiles(paths);
} catch (IOException e) {
System.out.println(e + " \n " + e.getCause());
}
如果某个路径未找到,则报告的错误信息将是:
java.io.IOException: Some files are corrupted
java.io.FileNotFoundException: ...
(The system cannot find the path specified)
很酷,对吧?!
204. 为 Stream API 实现 distinctBy()
假设我们有以下模型和数据:
public class Car {
private final String brand;
private final String fuel;
private final int horsepower;
...
}
List<Car> cars = List.of(
new Car("Chevrolet", "diesel", 350),
...
new Car("Lexus", "diesel", 300)
);
我们知道 Stream API 包含一个名为distinct()的中间操作,它能够根据equals()方法只保留不同的元素:
cars.stream()
.distinct()
.forEach(System.out::println);
当前的代码打印出不同的汽车,但我们可能希望有一个distinctBy()中间操作,它能够根据给定的属性/键只保留不同的元素。例如,我们可能需要所有品牌不同的汽车。为此,我们可以依赖toMap()收集器和恒等函数,如下所示:
cars.stream()
.collect(Collectors.toMap(Car::getBrand,
Function.identity(), (c1, c2) -> c1))
.values()
.forEach(System.out::println);
我们可以将这个想法提取到一个辅助方法中,如下所示:
public static <K, T> Collector<T, ?, Map<K, T>>
distinctByKey(Function<? super T, ? extends K> function) {
return Collectors.toMap(
function, Function.identity(), (t1, t2) -> t1);
}
此外,我们还可以像下面这样使用它:
cars.stream()
.collect(Streams.distinctByKey(Car::getBrand))
.values()
.forEach(System.out::println);
虽然这是一个很好的工作,也适用于null值,但我们还可以想出其他不适用于null值的方法。例如,我们可以依赖ConcurrentHashMap和putIfAbsent(),如下(再次,这不适用于null值):
public static <T> Predicate<T> distinctByKey(
Function<? super T, ?> function) {
Map<Object, Boolean> seen = new ConcurrentHashMap<>();
return t -> seen.putIfAbsent(function.apply(t),
Boolean.TRUE) == null;
}
或者,我们可以稍微优化这种方法并使用一个Set:
public static <T> Predicate<T> distinctByKey(
Function<? super T, ?> function) {
Set<Object> seen = ConcurrentHashMap.newKeySet();
return t -> seen.add(function.apply(t));
}
我们可以使用以下示例中展示的这两种方法:
cars.stream()
.filter(Streams.distinctByKey(Car::getBrand))
.forEach(System.out::println);
cars.stream()
.filter(Streams.distinctByKey(Car::getFuel))
.forEach(System.out::println);
作为挑战,使用多个键实现一个distinctByKeys()操作。
205. 编写一个自定义收集器,它获取/跳过给定数量的元素
在问题 202中,我们在MyCollectors类中编写了一些自定义收集器。现在,让我们继续我们的旅程,并尝试在这里添加两个更多的自定义收集器,以从当前流中获取和/或保留给定数量的元素。
假设以下模型和数据:
public class Car {
private final String brand;
private final String fuel;
private final int horsepower;
...
}
List<Car> cars = List.of(
new Car("Chevrolet", "diesel", 350),
... // 10 more
new Car("Lexus", "diesel", 300)
);
Stream API 提供了一个名为limit(long n)的中间操作,它可以用来截断流到n个元素。所以,如果这正是我们想要的,那么我们可以直接使用它。例如,我们可以将结果流限制在前五辆汽车,如下所示:
List<Car> first5CarsLimit = cars.stream()
.limit(5)
.collect(Collectors.toList());
此外,Stream API 提供了一个名为skip(long n)的中间操作,它可以用来跳过流管道中的前n个元素。例如,我们可以跳过前五辆汽车,如下所示:
List<Car> last5CarsSkip = cars.stream()
.skip(5)
.collect(Collectors.toList());
然而,有些情况下我们需要计算不同的事情,并且只收集前五个/最后一个五个结果。在这种情况下,自定义收集器是受欢迎的。
通过依赖Collector.of()方法(如问题 202中详细说明),我们可以编写一个自定义收集器,它保留/收集前n个元素,如下(只是为了好玩,让我们在不修改的列表中收集这些n个元素):
public static <T> Collector<T, List<T>, List<T>>
toUnmodifiableListKeep(int max) {
return Collector.of(ArrayList::new,
(list, value) -> {
if (list.size() < max) {
list.add(value);
}
},
(left, right) -> {
left.addAll(right);
return left;
},
Collections::unmodifiableList);
}
因此,供应商是ArrayList::new,累加器是List.add(),组合器是List.addAll(),而最终化器是Collections::unmodifiableList。基本上,累加器的任务是在达到给定的max值之前只累积元素。从那个点开始,不再累积任何元素。这样,我们就可以只保留前五辆车,如下所示:
List<Car> first5Cars = cars.stream()
.collect(MyCollectors.toUnmodifiableListKeep(5));
另一方面,如果我们想跳过前n个元素并收集剩余的元素,那么我们可以尝试累积null元素,直到达到给定的index。从那个点开始,我们开始累积真实元素。最后,最终化器移除包含null值的列表部分(从 0 到给定的index),并从剩余元素(从给定的index到末尾)返回一个不可修改的列表:
public static <T> Collector<T, List<T>, List<T>>
toUnmodifiableListSkip(int index) {
return Collector.of(ArrayList::new,
(list, value) -> {
if (list.size() >= index) {
list.add(value);
} else {
list.add(null);
}
},
(left, right) -> {
left.addAll(right);
return left;
},
list -> Collections.unmodifiableList(
list.subList(index, list.size())));
}
或者,我们可以通过使用包含结果列表和计数器的供应商类来优化这种方法。在达到给定的index之前,我们只需简单地增加计数器。一旦达到给定的index,我们就开始累积元素:
public static <T> Collector<T, ?, List<T>>
toUnmodifiableListSkip(int index) {
class Sublist {
int index;
List<T> list = new ArrayList<>();
}
return Collector.of(Sublist::new,
(sublist, value) -> {
if (sublist.index >= index) {
sublist.list.add(value);
} else {
sublist.index++;
}
},
(left, right) -> {
left.list.addAll(right.list);
left.index = left.index + right.index;
return left;
},
sublist -> Collections.unmodifiableList(sublist.list));
}
这两种方法都可以使用,如下面的示例所示:
List<Car> last5Cars = cars.stream()
.collect(MyCollectors.toUnmodifiableListSkip(5));
挑战自己实现一个在给定范围内收集的自定义收集器。
206. 实现一个接受五个(或任何其他任意数量)参数的函数
我们知道 Java 已经有了java.util.function.Function及其特殊化java.util.function.BiFunction。Function接口定义了apply(T, t)方法,而BiFunction有apply(T t, U u)。
在这个上下文中,我们可以定义一个TriFunction、FourFunction或(为什么不呢?)一个FiveFunction函数式接口,如下所示(这些都是Function的特殊化):
@FunctionalInterface
public interface FiveFunction <T1, T2, T3, T4, T5, R> {
R apply(T1 t1, T2 t2, T3 t3, T4 t4, T5 t5);
}
如其名所示,这个函数式接口接受五个参数。
现在,让我们来使用它!假设我们有以下模型:
public class PL4 {
private final double a;
private final double b;
private final double c;
private final double d;
private final double x;
public PL4(double a, double b,
double c, double d, double x) {
this.a = a;
this.b = b;
this.c = c;
this.d = d;
this.x = x;
}
// getters
public double compute() {
return d + ((a - d) / (1 + (Math.pow(x / c, b))));
}
// equals(), hashCode(), toString()
}
compute()方法塑造了一个称为四参数逻辑(4PL - www.myassays.com/four-parameter-logistic-regression.html)的公式。不涉及无关细节,我们传递四个变量(a、b、c和d)作为输入,并且对于不同的x坐标值,我们计算y坐标。坐标对(x,y)描述了一条曲线(线性图形)。
我们需要为每个x坐标创建一个PL4实例,并且对于每个这样的实例,我们调用compute()方法。这意味着我们可以通过以下辅助方法在Logistics中使用FiveFunction接口:
public final class Logistics {
...
public static <T1, T2, T3, T4, X, R> R create(
T1 t1, T2 t2, T3 t3, T4 t4, X x,
FiveFunction<T1, T2, T3, T4, X, R> f) {
return f.apply(t1, t2, t3, t4, x);
}
...
}
这充当了PL4的工厂:
PL4 pl4_1 = Logistics.create(
4.19, -1.10, 12.65, 0.03, 40.3, PL4::new);
PL4 pl4_2 = Logistics.create(
4.19, -1.10, 12.65, 0.03, 100.0, PL4::new);
...
PL4 pl4_8 = Logistics.create(
4.19, -1.10, 12.65, 0.03, 1400.6, PL4::new);
System.out.println(pl4_1.compute());
System.out.println(pl4_2.compute());
...
System.out.println(pl4_8.compute());
然而,如果我们只需要y坐标的列表,那么我们可以在Logistics中编写一个辅助方法,如下所示:
public final class Logistics {
...
public static <T1, T2, T3, T4, X, R> List<R> compute(
T1 t1, T2 t2, T3 t3, T4 t4, List<X> allX,
FiveFunction<T1, T2, T3, T4, X, R> f) {
List<R> allY = new ArrayList<>();
for (X x : allX) {
allY.add(f.apply(t1, t2, t3, t4, x));
}
return allY;
}
...
}
我们可以像下面这样调用这个方法(这里我们传递了 4PL 公式,但它可以是任何具有五个double参数的其他公式):
FiveFunction<Double, Double, Double, Double, Double, Double>
pl4 = (a, b, c, d, x) -> d + ((a - d) /
(1 + (Math.pow(x / c, b))));
List<Double> allX = List.of(40.3, 100.0, 250.2, 400.1,
600.6, 800.4, 1150.4, 1400.6);
List<Double> allY = Logistics.compute(4.19, -1.10, 12.65,
0.03, allX, pl4);
你可以在捆绑的代码中找到完整的示例。
207. 实现一个接受五个(或任何其他任意数量)参数的消费者
在继续这个问题之前,我强烈建议你阅读 问题 206。
编写一个接受五个参数的自定义 Consumer 可以这样做:
@FunctionalInterface
public interface FiveConsumer <T1, T2, T3, T4, T5> {
void accept (T1 t1, T2 t2, T3 t3, T4 t4, T5 t5);
}
这是对 Java Consumer 的五参数特殊化,正如内置的 BiConsumer 是 Java Consumer 的双参数特殊化。
我们可以将 FiveConsumer 与 PL4 公式结合使用,如下(这里,我们计算 y 对 x = 40.3):
FiveConsumer<Double, Double, Double, Double, Double>
pl4c = (a, b, c, d, x) -> Logistics.pl4(a, b, c, d, x);
pl4c.accept(4.19, -1.10, 12.65, 0.03, 40.3);
Logistics.pl4() 是包含公式并显示结果的方法:
public static void pl4(Double a, Double b,
Double c, Double d, Double x) {
System.out.println(d + ((a - d) / (1
+ (Math.pow(x / c, b)))));
}
接下来,让我们看看我们如何部分应用一个 Function。
208. 部分应用一个 Function
部分应用的 Function 是只应用其部分参数的 Function,返回另一个 Function。例如,这里有一个包含 apply() 方法的 TriFunction(一个具有三个参数的函数式函数),旁边有两个部分应用此函数的 default 方法:
@FunctionalInterface
public interface TriFunction <T1, T2, T3, R> {
R apply(T1 t1, T2 t2, T3 t3);
default BiFunction<T2, T3, R> applyOnly(T1 t1) {
return (t2, t3) -> apply(t1, t2, t3);
}
default Function<T3, R> applyOnly(T1 t1, T2 t2) {
return (t3) -> apply(t1, t2, t3);
}
}
如你所见,applyOnly(T1 t1) 只应用 t1 参数并返回一个 BiFunction。另一方面,applyOnly(T1 t1, T2 t2) 只应用 t1 和 t2,返回一个 Function。
让我们看看我们如何使用这些方法。例如,让我们考虑公式 (a+b+c)² = a²+b²+c²+2ab+2bc+2ca,它可以通过 TriFunction 来实现,如下所示:
TriFunction<Double, Double, Double, Double> abc2 = (a, b, c)
-> Math.pow(a, 2) + Math.pow(b, 2) + Math.pow(c, 2)
+ 2.0*a*b + 2*b*c + 2*c*a;
System.out.println("abc2 (1): " + abc2.apply(1.0, 2.0, 1.0));
System.out.println("abc2 (2): " + abc2.apply(1.0, 2.0, 2.0));
System.out.println("abc2 (3): " + abc2.apply(1.0, 2.0, 3.0));
在这里,我们调用了 apply(T1 t1, T2 t2, T3 t3) 三次。正如你所见,每次调用中只有 c 项有不同的值,而 a 和 b 分别始终等于 1.0 和 2.0。这意味着我们可以为 a 和 b 使用 apply(T1 t1, T2 t2),为 c 使用 apply(T1 t1),如下所示:
Function<Double, Double> abc2Only1 = abc2.applyOnly(1.0, 2.0);
System.out.println("abc2Only1 (1): " + abc2Only1.apply(1.0));
System.out.println("abc2Only1 (2): " + abc2Only1.apply(2.0));
System.out.println("abc2Only1 (3): " + abc2Only1.apply(3.0));
如果我们假设只有 a 是常数(1.0),而 b 和 c 每次调用有不同的值,那么我们可以为 a 使用 apply(T1 t1),为 b 和 c 使用 apply(T1 t1, T2 t2),如下所示:
BiFunction<Double, Double, Double> abc2Only2
= abc2.applyOnly(1.0);
System.out.println("abc2Only2 (1): "
+ abc2Only2.apply(2.0, 3.0));
System.out.println("abc2Only2 (2): "
+ abc2Only2.apply(1.0, 2.0));
System.out.println("abc2Only2 (3): "
+ abc2Only2.apply(3.0, 2.0));
任务完成!
摘要
本章涵盖了 24 个问题。大多数问题集中在使用谓词、函数和收集器,但我们还涵盖了 JDK 16 的 mapMulti() 操作,将命令式代码重构为函数式代码,等等。
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第十章:并发 – 虚拟线程和结构化并发
本章包括 16 个问题,简要介绍了虚拟线程和结构化并发。
如果你没有 Java 并发的背景知识,我强烈建议你在阅读一些关于该主题的良好入门介绍之后再阅读本章。例如,你可以尝试阅读Java 编码问题第一版的第10章和第11章。
虚拟线程是 Java 在过去几年中添加的最重要和最令人惊讶的特性之一。它们对我们继续编写和理解并发代码的方式产生了重大影响。在本章中,你将逐步学习这个主题和结构化并发范式的每一个细节。
在本章之后,你将非常熟悉如何使用虚拟线程和结构化并发。
问题
使用以下问题来测试你在 Java 虚拟线程和结构化并发方面的编程能力。我强烈鼓励你在查看解决方案和下载示例程序之前尝试解决每个问题:
-
解释并发与并行:提供简明但富有意义的并发与并行的解释。
-
介绍结构化并发:编写一个示例,突出“非结构化”并发的关键问题。此外,介绍结构化并发范式。
-
介绍虚拟线程:解释并举例说明虚拟线程的主要概念。
-
使用 ExecutorService 进行虚拟线程:编写几个示例,通过
ExecutorService和虚拟线程突出“任务-线程”模型。 -
解释虚拟线程的工作原理:全面介绍虚拟线程的内部工作原理。
-
虚拟线程和同步代码的挂钩:通过一段有意义的代码片段解释并举例说明虚拟线程和同步代码是如何协同工作的。
-
举例说明线程上下文切换:编写几个示例,展示虚拟线程的线程上下文切换是如何工作的。
-
介绍 ExecutorService invoke all/any 对虚拟线程的调用 – 第一部分:简要介绍
ExecutorService对虚拟线程的 invoke all/any 调用。 -
介绍 ExecutorService invoke all/any 对虚拟线程的调用 – 第二部分:通过
ExecutorServiceinvoke all/any 对虚拟线程重写“问题 210”中的“非结构化”并发示例。 -
挂钩任务状态:解释并举例说明新的
Future#state()API。 -
结合 new VirtualThreadPerTaskExecutor()和流:编写几个示例,介绍 Java 流管道如何与
newVirtualThreadPerTaskExecutor()执行器结合使用。 -
介绍范围对象(StructuredTaskScope):通过
StructuredTaskScopeAPI 简要介绍结构化并发。 -
介绍 ShutdownOnSuccess:举例说明
StructuredTaskScope的ShutdownOnSuccess风味。 -
介绍 ShutdownOnFailure:举例说明
StructuredTaskScope的ShutdownOnFailure版本。 -
结合 StructuredTaskScope 和流:编写几个示例,介绍如何将 Java 流管道与
StructuredTaskScope结合使用。 -
观察和监控虚拟线程:举例说明我们如何使用JFR(Java Flight Recorder)、JMX(Java Management Extensions)以及您喜欢的任何其他工具来观察和监控虚拟线程。
以下几节描述了前面问题的解决方案。请记住,通常没有解决特定问题的唯一正确方法。此外,请记住,这里所示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节,并在此github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter10上的程序进行实验。
209. 解释并发与并行
在着手本章的主要主题——结构化并发之前,让我们先忘记结构,只保留并发。接下来,让我们将并发与并行进行比较,因为这两个概念常常是混淆的源头。
这两者,并发和并行,都使用任务作为主要的工作单元。然而,它们处理这些任务的方式使它们非常不同。
在并行的情况下,一个任务被分割成多个 CPU 核心的子任务。这些子任务并行计算,每个子任务代表给定任务的局部解决方案。通过合并这些局部解决方案,我们得到解决方案。理想情况下,并行解决任务应该比顺序解决相同任务所需的时间更少。简而言之,在并行中,至少有两个线程同时运行,这意味着并行可以更快地解决单个任务。
在并发的情况下,我们尝试通过几个相互竞争的线程尽可能多地解决任务,以时间分片的方式推进。这意味着并发可以更快地完成多个任务。这也是为什么并发也被称为虚拟并行。
下图展示了并行与并发的对比:

图 10.1:并发与并行
在并行的情况下,任务(子任务)是实施解决方案/算法的一部分。我们编写代码,设置/控制任务数量,并在具有并行计算能力的上下文中使用它们。另一方面,在并发中,任务是问题的一部分。
通常,我们通过延迟(完成任务所需的时间)来衡量并行效率,而并发的效率则是通过吞吐量(我们可以解决的任务数量)来衡量的。
此外,在并行性中,任务控制资源分配(CPU 时间、I/O 操作等)。另一方面,在并发中,多个线程相互竞争以获取尽可能多的资源(I/O)。它们无法控制资源分配。
在并行性中,线程以这种方式在 CPU 核心上操作,即每个核心都处于忙碌状态。在并发中,线程以这种方式在任务上操作,理想情况下,每个线程都有一个单独的任务。
通常,当比较并行性和并发性时,有人会过来问:异步方法怎么样?
重要的是要理解 异步性 是一个独立的概念。异步性是关于完成非阻塞操作的能力。例如,一个应用程序发送一个 HTTP 请求,但它不会只是等待响应。它会去做其他事情(其他任务),在等待响应的同时。我们每天都在做异步任务。例如,我们开始启动洗衣机,然后去打扫房子的其他部分。我们不会只是站在洗衣机旁边,直到它完成。
210. 介绍结构化并发
如果你和我一样大,那么你很可能开始编程时使用的是 BASIC 或类似的非结构化编程语言。当时,一个应用程序只是一系列定义了顺序逻辑/行为的代码行,通过一串 GOTO 语句驱动流程,像袋鼠一样在代码行之间跳来跳去。在 Java 中,典型并发代码的构建块非常原始,因此代码看起来有点像非结构化编程,因为它难以跟踪和理解。此外,并发任务的线程转储并不提供所需的答案。
让我们跟随一段 Java 并发代码,每次有问题时都停下来(总是检查问题下面的代码)。任务是并发地通过 ID 加载三个测试人员并将他们组成一个测试团队。首先,让我们列出服务器代码(我们将使用这段简单的代码来帮助我们解决这个问题和后续的问题):
public static String fetchTester(int id)
throws IOException, InterruptedException {
HttpClient client = HttpClient.newHttpClient();
HttpRequest requestGet = HttpRequest.newBuilder()
.GET()
.uri(URI.create("https://reqres.in/api/users/" + id))
.build();
HttpResponse<String> responseGet = client.send(
requestGet, HttpResponse.BodyHandlers.ofString());
if (responseGet.statusCode() == 200) {
return responseGet.body();
}
throw new UserNotFoundException("Code: "
+ responseGet.statusCode());
}
接下来,我们特别感兴趣的代码如下所示:
private static final ExecutorService executor
= Executors.newFixedThreadPool(2);
public static TestingTeam buildTestingTeam()
throws InterruptedException {
...
第一站:正如你所见,buildTestingTeam() 抛出了 InterruptedException。那么如果执行 buildTestingTeam() 的线程被中断,我们如何轻松地中断后续的线程?
Future<String> future1 = futureTester(1);
Future<String> future2 = futureTester(2);
Future<String> future3 = futureTester(3);
try {
...
第二站:这里,我们有三个 get() 调用。所以当前线程会等待其他线程完成。我们能否轻松地观察那些线程?
String tester1 = future1.get();
String tester2 = future2.get();
String tester3 = future3.get();
logger.info(tester1);
logger.info(tester2);
logger.info(tester3);
return new TestingTeam(tester1, tester2, tester3);
} catch (ExecutionException ex) {
...
第三站:如果捕获到 ExecutionException,那么我们知道这三个 Future 实例中的一个失败了。我们能否轻松地取消剩余的两个,或者它们会一直挂在那里?future1 可能会失败,而 future2 和 future3 可能会成功完成,或者也许 future2 会成功完成,而 future3 将会永远运行(所谓的 孤儿线程)。这可能导致预期的结果严重不匹配、内存泄漏等问题:
throw new RuntimeException(ex);
} finally {
...
第四个步骤:下一行代码用于关闭executor,但它很容易被忽略。这是正确的操作位置吗?
shutdownExecutor(executor);
}
}
第五个步骤:如果您没有注意到上一行代码,那么您有理由问自己这个执行器是如何/在哪里被关闭的:
public static Future<String> futureTester(int id) {
return executor.submit(() -> fetchTester(id));
}
我们省略了其余的代码,因为您可以在捆绑的代码中找到它。
当然,我们可以通过错误处理、任务放弃和终止、ExecutorService等方式为这些问题实现代码答案,但这意味着开发者需要做大量的工作。在并发环境中跟踪多个任务/子任务的进度,同时仔细覆盖所有可能的场景,编写容错解决方案并非易事。更不用说,其他开发者或甚至是 1-2 年后或几个月后的同一开发者理解和维护生成的代码有多么困难。
是时候给这段代码添加一些结构了,让我们引入结构化并发(或 Project Loom)。
结构化并发依赖于几个支柱,旨在将轻量级并发引入 Java。结构化并发的根本支柱或原则将在下面强调。
重要提示
结构化并发的根本原则是,当一个任务需要并发解决时,所有解决该任务所需的线程都在同一块代码中启动和重新连接。换句话说,所有这些线程的生命周期都绑定到块的词法作用域,因此我们为每个并发代码块提供了清晰和明确的入口和出口点。
根据这个原则,启动并发上下文的线程是父线程或拥有线程。由父线程启动的所有线程都是子线程或分支,因此它们之间是兄弟姐妹关系。父线程和子线程共同定义了一个父子层次结构。
将结构化并发原则放入图中将展示以下内容:

图 10.2:结构化并发中的父子层次结构
在父子层次结构的上下文中,我们支持带有短路、取消传播、监控/可观察性的错误/异常处理:
-
短路错误/异常处理:如果一个子线程失败,那么除非它们已经完成,否则所有子线程都会被取消。例如,如果
futureTester(1)失败,那么futureTester(2)和futureTester(3)将自动取消。 -
取消传播:如果父线程在加入子线程之前被中断,那么这些分支(子线程/子任务)将自动取消。例如,如果执行
buildTestingTeam()的线程被中断,那么它的三个分支将自动取消。 -
监控/可观察性:线程转储揭示了整个父子层次结构的清晰图像,无论产生了多少层级。此外,在结构化并发中,我们利用线程的调度和内存管理。
虽然这些都是纯粹的概念,但编写遵循这些概念的代码需要适当的 API 和以下令人惊叹的调用:

图 10.3:不要重用虚拟线程
将其剪下来贴在某个地方,以便你每天都能看到!所以在结构化并发中,不要重用虚拟线程。我知道你在想什么:嘿,兄弟,线程很贵,而且有限,所以我们必须重用它们。一个快速提示:我们谈论的是虚拟线程(大量吞吐量),而不是经典线程,但虚拟线程的话题将在下一个问题中介绍。
211. 虚拟线程的引入
Java 允许我们通过java.lang.Thread类编写多线程应用程序。这些是经典的 Java 线程,基本上只是操作系统(内核)线程的薄包装。正如你将看到的,这些经典 Java 线程被称为平台线程,并且已经存在很长时间了(自从 JDK 1.1 以来,如下面的图所示):

图 10.4:JDK 多线程演变
接下来,让我们继续了解 JDK 19 虚拟线程。
平台(操作系统)线程有什么问题?
操作系统线程在各个方面都很昂贵,或者更具体地说,它们在时间和空间上都很昂贵。因此,创建操作系统线程是一个昂贵的操作,需要大量的堆栈空间(大约 20 兆字节)来存储它们的上下文、Java 调用栈和额外的资源。此外,操作系统线程调度器负责调度 Java 线程,这是另一个昂贵的操作,需要移动大量的数据。这被称为线程上下文切换。
在下面的图中,你可以看到 Java 线程和操作系统线程之间的一对一关系:

图 10.5:JVM 到操作系统线程
几十年来,我们的多线程应用程序一直在这个环境中运行。所有这些时间和经验教会了我们,我们可以创建有限数量的 Java 线程(因为吞吐量低),而且我们应该明智地重用它们。Java 线程的数量是一个限制因素,通常在诸如网络连接、CPU 等其他资源耗尽之前就已经用完。Java 不会区分执行密集计算任务(即真正利用 CPU 的线程)或仅仅等待数据的线程(即它们只是挂载在 CPU 上)。
让我们做一个快速练习。假设我们的机器有 8 GB 的内存,而单个 Java 线程需要 20 MB。这意味着我们大约有 400 个 Java 线程的空间(8 GB = 8,000 MB / 20 MB = 400 threads)。接下来,假设这些线程在网络上进行 I/O 操作。每个 I/O 操作需要大约 100 ms 才能完成,而请求准备和响应处理需要大约 500 ns。所以一个线程工作 1,000 ns(0.001 ms),然后等待 100 ms(100,000,000 ns)以完成 I/O 操作。这意味着在 8 GB 的内存中,400 个线程将使用 0.4%的 CPU 可用性(低于 1%),这非常低。我们可以得出结论,线程有 99.99%的时间是空闲的。
基于这个练习,很明显,Java 线程成为了吞吐量的瓶颈,这不允许我们充分利用硬件。当然,我们可以通过使用线程池来最小化成本来改善这种情况,但这仍然不能解决处理资源的主要问题。你必须转向CompletableFuture、响应式编程(例如,Spring 的Mono和Flux)等等。
然而,我们可以创建多少个传统的 Java 线程呢?我们可以通过运行一个简单的代码片段来轻松找出,如下所示:
AtomicLong counterOSThreads = new AtomicLong();
while (true) {
new Thread(() -> {
long currentOSThreadNr
= counterOSThreads.incrementAndGet();
System.out.println("Thread: " + currentOSThreadNr);
LockSupport.park();
}).start();
}
或者,如果我们想尝试新的并发 API,我们可以调用新的Thread.ofPlatform()方法,如下所示(OfPlatform是一个sealed接口,在 JDK 19 中引入):
AtomicLong counterOSThreads = new AtomicLong();
while (true) {
Thread.ofPlatform().start(() -> {
long currentOSThreadNr
= counterOSThreads.incrementAndGet();
System.out.println("Thread: " + currentOSThreadNr);
LockSupport.park();
});
}
在我的机器上,我在大约 40,000 个 Java 线程后遇到了OutOfMemoryError。根据你的操作系统和硬件,这个数字可能会有所不同。
Thread.ofPlatform()方法是在 JDK 19 中添加的,以便轻松区分 Java 线程(即,几十年来我们所知道的经典 Java 线程——操作系统线程的薄包装)和城中新来的孩子,虚拟线程。
什么是虚拟线程?
虚拟线程是在 JDK 19 中作为预览版(JEP 425)引入的,并在 JDK 21 中成为最终特性(JEP 444)。虚拟线程在平台线程之上运行,形成一对一的关系,而平台线程在操作系统线程之上运行,形成一对一的关系,如下面的图所示:

图 10.6:虚拟线程架构
如果我们将这个概念分解成几个词,那么我们可以这样说,JDK 将大量虚拟线程映射到少量操作系统线程。
在创建虚拟线程之前,让我们看看两个重要的注意事项,这将帮助我们快速了解虚拟线程的基本原理。首先,让我们快速了解一下虚拟线程的内存占用:
重要注意事项
虚拟线程不是操作系统线程的包装器。它们是轻量级的 Java 实体(它们有自己的堆栈内存,占用空间很小——只有几百字节),创建、阻塞和销毁成本低(创建虚拟线程的成本大约是创建经典 Java 线程的 1,000 倍)。可以同时存在很多虚拟线程(数百万),从而实现巨大的吞吐量。虚拟线程不应重复使用(它们是一次性的)或池化。
当我们谈论虚拟线程时,我们应该忘记的东西比应该学习的东西更多。但虚拟线程存储在哪里,谁负责相应地调度它们?
重要提示
虚拟线程存储在 JVM 堆中(因此它们可以利用垃圾收集器),而不是操作系统堆栈。此外,虚拟线程由 JVM 通过一个工作窃取的ForkJoinPool调度器进行调度。实际上,JVM 以这种方式调度和编排虚拟线程在平台线程上运行,使得一个平台线程一次只执行一个虚拟线程。
接下来,让我们创建一个虚拟线程。
创建虚拟线程
从 API 的角度来看,虚拟线程是java.lang.Thread的另一种风味。如果我们通过getClass()深入研究,我们可以看到虚拟线程类是java.lang.VirtualThread,它是一个final的非公开类,继承自BaseVirtualThread类,而BaseVirtualThread是一个sealed abstract类,继承自java.lang.Thread:
final class VirtualThread extends BaseVirtualThread {…}
sealed abstract class BaseVirtualThread extends Thread
permits VirtualThread, ThreadBuilders.BoundVirtualThread {…}
让我们考虑以下任务(Runnable):
Runnable task = () -> logger.info(
Thread.currentThread().toString());
创建和启动虚拟线程
我们可以通过startVirtualThread(Runnable task)方法创建并启动一个虚拟线程,如下所示:
Thread vThread = Thread.startVirtualThread(task);
// next you can set its name
vThread.setName("my_vThread");
返回的vThread由 JVM 本身调度执行。但我们可以通过Thread.ofVirtual()创建并启动一个虚拟线程,它返回OfVirtual(JDK 19 中引入的sealed接口),如下所示:
Thread vThread =Thread.ofVirtual().start(task);
// a named virtual thread
Thread.ofVirtual().name("my_vThread").start(task);
现在,vThread将解决我们的task。
此外,我们还有Thread.Builder接口(以及Thread.Builder.OfVirtual子接口),可以用来创建虚拟线程,如下所示:
Thread.Builder builder
= Thread.ofVirtual().name("my_vThread");
Thread vThread = builder.start(task);
这里是另一个通过Thread.Builder创建两个虚拟线程的示例:
Thread.Builder builder
= Thread.ofVirtual().name("vThread-", 1);
// name "vThread-1"
Thread vThread1 = builder.start(task);
vThread1.join();
logger.info(() -> vThread1.getName() + " terminated");
// name "vThread-2"
Thread vThread2 = builder.start(task);
vThread2.join();
logger.info(() -> vThread2.getName() + " terminated");
你可以在捆绑的代码中进一步查看这些示例。
等待虚拟任务终止
给定的task由虚拟线程执行,而主线程不会被阻塞。为了等待虚拟线程终止,我们必须调用join()的一个变体。我们有不带参数的join(),它会无限期地等待,以及几个等待给定时间的变体(例如,join(Duration duration)和join(long millis)):
vThread.join();
这些方法会抛出InterruptedException,所以你必须捕获并处理它(或者只是抛出它)。现在,由于join(),主线程不能在虚拟线程完成之前终止。它必须等待虚拟线程完成。
创建未启动的虚拟线程
创建一个未启动的虚拟线程可以通过unstarted(Runnable task)来完成,如下所示:
Thread vThread = Thread.ofVirtual().unstarted(task);
或者通过Thread.Builder,如下所示:
Thread.Builder builder = Thread.ofVirtual();
Thread vThread = builder.unstarted(task);
这次,线程没有被安排执行。它只有在显式调用start()方法后才会被安排执行:
vThread.start();
我们可以通过isAlive()方法检查一个线程是否是活动的(即它已经被启动但尚未终止):
boolean isalive = vThread.isAlive();
unstarted()方法也适用于平台线程(还有Thread.Builder.OfPlatform子接口):
Thread pThread = Thread.ofPlatform().unstarted(task);
我们可以通过调用start()方法来启动pThread。
为虚拟线程创建 ThreadFactory
你可以创建一个虚拟线程的ThreadFactory,如下所示:
ThreadFactory tfVirtual = Thread.ofVirtual().factory();
ThreadFactory tfVirtual = Thread.ofVirtual()
.name("vt-", 0).factory(); // 'vt-' name prefix, 0 counter
或者通过Thread.Builder,如下所示:
Thread.Builder builder = Thread.ofVirtual().name("vt-", 0);
ThreadFactory tfVirtual = builder.factory();
以及一个平台线程的ThreadFactory,如下所示(你也可以使用Thread.Builder):
ThreadFactory tfPlatform = Thread.ofPlatform()
.name("pt-", 0).factory(); // 'pt-' name prefix, 0 counter
或者一个我们可以用来在虚拟/平台线程之间切换的ThreadFactory,如下所示:
static class SimpleThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
// return new Thread(r); // platform thread
return Thread.ofVirtual().unstarted(r); // virtual thread
}
}
接下来,我们可以通过ThreadFactory.newThread(Runnable task)使用这些工厂中的任何一个,如下所示:
tfVirtual.newThread(task).start();
tfPlatform.newThread(task).start();
SimpleThreadFactory stf = new SimpleThreadFactory();
stf.newThread(task).start();
如果线程工厂同时启动创建的线程,那么就没有必要显式地调用start()方法。
检查虚拟线程的详细信息
此外,我们可以通过isVirtual()方法检查某个线程是否是平台线程或虚拟线程:
Thread vThread = Thread.ofVirtual()
.name("my_vThread").unstarted(task);
Thread pThread1 = Thread.ofPlatform()
.name("my_pThread").unstarted(task);
Thread pThread2 = new Thread(() -> {});
logger.info(() -> "Is vThread virtual ? "
+ vThread.isVirtual()); // true
logger.info(() -> "Is pThread1 virtual ? "
+ pThread1.isVirtual()); // false
logger.info(() -> "Is pThread2 virtual ? "
+ pThread2.isVirtual()); // false
显然,只有vThread是虚拟线程。
虚拟线程总是以守护线程的方式运行。isDaemon()方法返回true,尝试调用setDaemon(false)会抛出异常。
虚拟线程的优先级总是 NORM_PRIORITY(调用getPriority()总是返回5 – NORM_PRIORITY的常量int)。使用不同的值调用setPriority()没有效果。
虚拟线程不能成为线程组的一部分,因为它已经属于VirtualThreads组。调用getThreadGroup().getName()会返回VirtualThreads。
虚拟线程在安全管理器中没有权限(安全管理器已经被弃用)。
打印一个线程(toString())
如果我们打印一个虚拟线程(调用toString()方法),那么输出将类似于以下内容:
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#26,vt-0]/runnable@ForkJoinPool-1-worker-1
简而言之,这个输出可以这样解释:VirtualThread[#22]表示这是一个包含线程标识符(#22)且没有名称的虚拟线程(在VirtualThread[#26,vt-0]的情况下,标识符是#26,名称是vt-0)。然后,我们有runnable文本,它表示虚拟线程的状态(runnable表示虚拟线程正在运行)。接下来,我们有虚拟线程的承载线程,这是一个平台线程;ForkJoinPool-1-worker-1包含了默认ForkJoinPool(ForkJoinPool-1)的平台线程名称(worker-1)。
我们可以启动多少个虚拟线程
最后,让我们运行一些代码,以便我们可以看到我们可以创建和启动多少个虚拟线程:
AtomicLong counterOSThreads = new AtomicLong();
while (true) {
Thread.startVirtualThread(() -> {
long currentOSThreadNr
= counterOSThreads.incrementAndGet();
System.out.println("Virtual thread: "
+ currentOSThreadNr);
LockSupport.park();
});
}
在我的机器上,当大约有 14,000,000 个虚拟线程时,这段代码开始变慢。当内存可用时(垃圾收集器正在运行),它继续缓慢运行,但没有崩溃。所以这是一个巨大的吞吐量!
向后兼容性
虚拟线程与以下兼容:
-
同步块
-
线程局部变量
-
Thread和currentThread() -
线程中断(`InterruptedException``)
基本上,一旦你更新到至少 JDK 19,虚拟线程就会自动工作。它们极大地支持干净、可读和更有结构的代码,是结构化并发范式背后的基石。
避免错误的结论(可能是神话)
关于虚拟线程有一些错误的结论,我们应该将其视为以下:
-
虚拟线程比平台线程快(错误!):虚拟线程的数量可以很多,但它们并不比经典(平台)线程快。它们不会提升内存计算能力(对于这一点,我们有并行流)。不要得出虚拟线程做了某些魔法使其更快或更优化的结论。因此,虚拟线程可以极大地提高吞吐量(因为数百万个虚拟线程可以等待任务),但它们不能提高延迟。然而,虚拟线程的启动速度比平台线程快得多(虚拟线程的创建时间以微秒计,需要大约千字节的空间)。
-
虚拟线程应该被池化(错误!):虚拟线程不应成为任何线程池的一部分,也不应该被池化。
-
虚拟线程很昂贵(错误!):虚拟线程不是免费的(没有什么是免费的),但它们比平台线程更容易创建、阻塞和销毁。虚拟线程比平台线程便宜 1,000 倍。
-
虚拟线程可以释放任务(错误!):这不是真的!虚拟线程接受一个任务,除非它被中断,否则会返回结果。它不能释放任务。
-
阻塞虚拟线程会阻塞其承载线程(错误!):阻塞虚拟线程不会阻塞其承载线程。承载线程可以服务其他虚拟线程。
212. 使用 ExecutorService 进行虚拟线程
虚拟线程允许我们编写更易于表达和理解的并发代码。多亏了虚拟线程带来的巨大吞吐量,我们可以轻松采用 任务-线程 模型(对于一个 HTTP 服务器,这意味着每个请求一个线程,对于一个数据库,这意味着每个事务一个线程,等等)。换句话说,我们可以为每个并发任务分配一个新的虚拟线程。
尝试使用平台线程的 任务-线程 模型会导致吞吐量受限于硬件核心的数量——这由 Little 定律(en.wikipedia.org/wiki/Little%27s_law)解释,L = λW,即吞吐量等于平均并发乘以延迟。
在可能的情况下,建议避免直接与线程交互。JDK 通过 ExecutorService/Executor API 来维持这一点。更确切地说,我们习惯于将任务(Runnable/Callable)提交给 ExecutorService/Executor 并与返回的 Future 一起工作。这种模式对虚拟线程同样适用。
因此,我们不需要自己编写所有管道代码来为虚拟线程采用 任务-线程模型,因为从 JDK 19 开始,这种模型通过 Executors 类提供。更确切地说,是通过 newVirtualThreadPerTaskExecutor() 方法,该方法创建一个能够创建无限数量遵循 任务-线程 模型的虚拟线程的 ExecutorService。这个 ExecutorService 提供了允许我们给出诸如 submit()(您将在下面看到)和 invokeAll/Any()(您将在稍后看到)方法的方法,返回包含异常或结果的 Future。
重要提示
从 JDK 19 开始,ExecutorService 扩展了 AutoCloseable 接口。换句话说,我们可以在 try-with-resources 模式中使用 ExecutorService。
考虑以下简单的 Runnable 和 Callable:
Runnable taskr = () ->logger.info(
Thread.currentThread().toString());
Callable<Boolean> taskc = () -> {
logger.info(Thread.currentThread().toString());
return true;
};
执行 Runnable/Callable 可以如下进行(这里我们提交了 15 个任务 NUMBER_OF_TASKS = 15):
try (ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < NUMBER_OF_TASKS; i++) {
executor.submit(taskr); // executing Runnable
executor.submit(taskc); // executing Callable
}
}
当然,在 Runnable/Callable 的情况下,我们可以捕获一个 Future 并相应地操作,通过阻塞的 get() 方法或我们想要做的任何事情:
Future<?> future = executor.submit(taskr);
Future<Boolean> future = executor.submit(taskc);
可能的输出如下所示:
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-6
VirtualThread[#31]/runnable@ForkJoinPool-1-worker-5
VirtualThread[#29]/runnable@ForkJoinPool-1-worker-7
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#24]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-5
VirtualThread[#26]/runnable@ForkJoinPool-1-worker-4
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#36]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#37]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#35]/runnable@ForkJoinPool-1-worker-7
VirtualThread[#34]/runnable@ForkJoinPool-1-worker-4
VirtualThread[#32]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#33]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1
查看虚拟线程的 ID。它们在 #22 和 #37 之间,没有重复。每个任务由其自己的虚拟线程执行。
任务-线程 模型也适用于经典线程,通过 newThreadPerTaskExecutor(ThreadFactory threadFactory) 实现。以下是一个示例:
static class SimpleThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
return new Thread(r); // classic
// return Thread.ofVirtual().unstarted(r); // virtual
}
}
try (ExecutorService executor =
Executors.newThreadPerTaskExecutor(
new SimpleThreadFactory())) {
for (int i = 0; i < NUMBER_OF_TASKS; i++) {
executor.submit(taskr); // executing Runnable
executor.submit(taskc); // executing Callable
}
}
如您所见,newThreadPerTaskExecutor() 可以用于经典线程或虚拟线程。创建的线程数量是无限的。通过简单地修改线程工厂,我们可以在虚拟线程和经典线程之间切换。
可能的输出如下所示:
Thread[#75,Thread-15,5,main]
Thread[#77,Thread-17,5,main]
Thread[#76,Thread-16,5,main]
Thread[#83,Thread-23,5,main]
Thread[#82,Thread-22,5,main]
Thread[#80,Thread-20,5,main]
Thread[#81,Thread-21,5,main]
Thread[#79,Thread-19,5,main]
Thread[#78,Thread-18,5,main]
Thread[#89,Thread-29,5,main]
Thread[#88,Thread-28,5,main]
Thread[#87,Thread-27,5,main]
Thread[#86,Thread-26,5,main]
Thread[#85,Thread-25,5,main]
Thread[#84,Thread-24,5,main]
查看线程的 ID。它们在 #75 和 #89 之间,没有重复。每个任务由其自己的线程执行。
213. 解释虚拟线程的工作原理
现在我们知道了如何创建和启动虚拟线程,让我们看看它们实际上是如何工作的。
让我们从一张有意义的图开始:

图 10.7:虚拟线程的工作原理
如您所见,图 10.7 与 图 10.6 类似,只是我们添加了一些更多元素。
首先,请注意平台线程在 ForkJoinPool 的伞下运行。这是一个 先进先出(FIFO)的专用 fork/join 池,专门用于调度和编排虚拟线程与平台线程之间的关系(Java 的 fork/join 框架的详细内容可在 Java 编程问题,第一版,第十一章中找到)。
重要提示
这个专门的ForkJoinPool由 JVM 控制,并基于 FIFO 队列作为虚拟线程调度器。它的初始容量(即线程数)等于可用核心数,可以增加到 256。默认的虚拟线程调度器在java.lang.VirtualThread类中实现:
private static ForkJoinPool createDefaultScheduler() {...}
不要将这个ForkJoinPool与用于并行流的那个混淆(公共 Fork Join Pool - ForkJoinPool.commonPool())。
在虚拟线程和平台线程之间,存在一种一对一的多对多关联。然而,JVM 以这种方式调度虚拟线程在平台线程上运行,即一次只有一个虚拟线程在平台线程上运行。当 JVM 将一个虚拟线程分配给平台线程时,虚拟线程的所谓栈块对象会从平台线程的堆内存中复制过来。
如果在虚拟线程上运行的代码遇到一个应该由 JVM 处理的阻塞(I/O)操作,那么虚拟线程将通过将其栈块对象复制回堆内存来释放。这种在堆内存和平台线程之间复制栈块的操作是阻塞虚拟线程的成本(这比阻塞平台线程便宜得多)。同时,平台线程可以运行其他虚拟线程。当释放的虚拟线程的阻塞(I/O)完成时,JVM 将重新调度虚拟线程在平台线程上执行。这可能是在同一个平台线程上,也可能是另一个平台线程。
重要提示
将虚拟线程分配给平台线程的操作称为挂载。从平台线程取消分配虚拟线程的操作称为卸载。运行分配的虚拟线程的平台线程称为载体线程。
让我们来看一个例子,揭示虚拟线程是如何挂载的:
private static final int NUMBER_OF_TASKS
= Runtime.getRuntime().availableProcessors();
Runnable taskr = () ->
logger.info(Thread.currentThread().toString());
try (ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < NUMBER_OF_TASKS + 1; i++) {
executor.submit(taskr);
}
}
carriers), and each of them carries a virtual thread. Since we have + 1, a *carrier* will work twice. The output reveals this scenario (check out the workers; here, worker-8 runs virtual threads #30 and #31):
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3
**VirtualThread[#30]/runnable@ForkJoinPool-1-worker-8**
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-6
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#24]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#29]/runnable@ForkJoinPool-1-worker-7
VirtualThread[#26]/runnable@ForkJoinPool-1-worker-4
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-5
**VirtualThread[#31]/runnable@ForkJoinPool-1-worker-8**
然而,我们可以通过三个系统属性来配置ForkJoinPool,如下所示:
-
jdk.virtualThreadScheduler.parallelism– CPU 核心数 -
jdk.virtualThreadScheduler.maxPoolSize– 最大池大小(256) -
jdk.virtualThreadScheduler.minRunnable– 运行线程的最小数量(池大小的一半)
在后续的问题中,我们将使用这些属性来更好地塑造虚拟线程上下文切换(挂载/卸载)的细节。
捕获虚拟线程
到目前为止,我们已经了解到,JVM 会将一个虚拟线程挂载到一个平台线程上,这个平台线程成为其载体线程。此外,载体线程会运行虚拟线程,直到它遇到一个阻塞(I/O)操作。在那个时刻,虚拟线程会从载体线程上卸载,并在阻塞(I/O)操作完成后重新调度。
虽然这种场景对于大多数阻塞操作都是真实的,导致卸载虚拟线程并释放平台线程(以及底层的操作系统线程),但还有一些异常情况,虚拟线程不会被卸载。这种行为的两个主要原因如下:
-
操作系统(例如,大量的文件系统操作)的限制
-
JDK(例如,
Object.wait())的限制
当虚拟线程无法从其承载线程卸载时,这意味着承载线程和底层的操作系统线程被阻塞。这可能会影响应用程序的可伸缩性,因此如果平台线程池允许,JVM 可以决定添加一个额外的平台线程。因此,在一段时间内,平台线程的数量可能会超过可用核心的数量。
固定虚拟线程
还有两种其他情况,虚拟线程无法卸载:
-
当虚拟线程在
synchronized方法/块中运行代码时 -
当虚拟线程调用外部函数或本地方法(这是第七章讨论的主题)
在这种情况下,我们说虚拟线程被 固定 在承载线程上。这可能会影响应用程序的可伸缩性,但 JVM 不会增加平台线程的数量。相反,我们应该采取行动,重构 synchronized 块,以确保锁定代码简单、清晰、简短。尽可能的情况下,我们应该优先选择 java.util.concurrent 锁而不是 synchronized 块。如果我们设法避免了长时间和频繁的锁定周期,那么我们就不会面临任何重大的可伸缩性问题。在未来的版本中,JDK 团队旨在消除 synchronized 块内的固定问题。
214. 固定虚拟线程和同步代码
这个问题的目标是突出虚拟线程如何与同步代码交互。为此,我们使用内置的 java.util.concurrent.SynchronousQueue。这是一个内置的阻塞队列,一次只允许一个线程操作。更确切地说,一个想要向这个队列中插入元素的线程将被阻塞,直到另一个线程尝试从其中移除元素,反之亦然。基本上,除非另一个线程尝试移除元素,否则线程无法插入元素。
假设一个虚拟线程尝试向 SynchronousQueue 中插入一个元素,而一个平台线程尝试从该队列中移除一个元素。在代码行中,我们有:
SynchronousQueue<Integer> queue = new SynchronousQueue<>();
Runnable task = () -> {
logger.info(() -> Thread.currentThread().toString()
+ " sleeps for 5 seconds");
try { Thread.sleep(Duration.ofSeconds(5)); }
catch (InterruptedException ex) {}
logger.info(() -> "Running "
+ Thread.currentThread().toString());
**queue.add(Integer.MAX_VALUE);**
};
logger.info("Before running the task ...");
Thread vThread =Thread.ofVirtual().start(task);
logger.info(vThread.toString());
因此,虚拟线程 (vThread) 在尝试将元素插入队列之前会等待 5 秒。然而,它只有在另一个线程尝试从该队列中移除元素时才能成功插入元素:
logger.info(() -> Thread.currentThread().toString()
+ " can't take from the queue yet");
**int****max int****=** **queue.take();**
logger.info(() -> Thread.currentThread().toString()
+ "took from queue: " + maxint);
logger.info(vThread.toString());
logger.info("After running the task ...");
这里,Thread.currentThread() 指的是应用程序的主线程,这是一个平台线程,不会被 vThread 阻塞。只有当另一个线程尝试插入(这里,vThread)时,该线程才能成功从队列中移除:
代码的输出如下:
[09:41:59] Before running the task ...
[09:42:00] VirtualThread[#22]/runnable
[09:42:00] Thread[#1,main,5,main]
can't take from the queue yet
[09:42:00] VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
sleeps for 5 seconds
[09:42:05] VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
inserts in the queue
[09:42:05] Thread[#1,main,5,main]took from queue: 2147483647
[09:42:05] VirtualThread[#22]/terminated
[09:42:05] After running the task ...
虚拟线程开始执行(它处于可运行状态),但主线程不能从队列中移除元素,直到虚拟线程插入元素,因此它被queue.take()操作阻塞:
[09:42:00] VirtualThread[#22]/runnable
[09:42:00] Thread[#1,main,5,main]
can't take from the queue yet
同时,虚拟线程睡眠 5 秒钟(目前主线程没有其他事情要做),然后插入一个元素:
[09:42:00] VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
sleeps for 5 seconds
[09:42:05] VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
inserts in the queue
虚拟线程已将一个元素插入队列中,因此主线程可以从其中移除该元素:
[09:42:05] Thread[#1,main,5,main]took from queue: 2147483647
虚拟线程也被终止:
[09:42:05] VirtualThread[#22]/terminated
因此,虚拟线程、平台线程和同步代码按预期工作。在捆绑的代码中,你可以找到一个示例,其中虚拟线程和平台线程交换位置。所以平台线程尝试插入元素,而虚拟线程尝试移除它们。
215. 展示线程上下文切换
记住,虚拟线程挂载在平台线程上,并且由该平台线程执行,直到发生阻塞操作。在那个时刻,虚拟线程从平台线程卸载,并在阻塞操作完成后,由 JVM 稍后重新调度执行。这意味着,在其生命周期内,虚拟线程可以在不同的或相同的平台线程上多次挂载。
在这个问题中,让我们编写几个代码片段,以捕捉和展示这种行为。
示例 1
在第一个例子中,让我们考虑以下线程工厂,我们可以用它轻松地在平台线程和虚拟线程之间切换:
static class SimpleThreadFactory implements ThreadFactory {
@Override
public Thread newThread(Runnable r) {
return new Thread(r); // classic thread
// return Thread.ofVirtual().unstarted(r); // virtual thread
}
}
接下来,我们尝试通过 10 个平台线程执行以下任务:
public static void doSomething(int index) {
logger.info(() -> index + " "
+ Thread.currentThread().toString());
try { Thread.sleep(Duration.ofSeconds(3)); }
catch (InterruptedException ex) {}
logger.info(() -> index + " "
+ Thread.currentThread().toString());
}
在两条日志行之间,有一个阻塞操作(sleep())。接下来,我们依靠newThreadPerTaskExecutor()提交 10 个任务,这些任务应该记录它们的详细信息,睡眠 3 秒钟,然后再次记录:
try (ExecutorService executor =
Executors.newThreadPerTaskExecutor(
new SimpleThreadFactory())) {
for (int i = 0; i < MAX_THREADS; i++) {
int index = i;
executor.submit(() -> doSomething(index));
}
}
使用平台线程运行此代码会显示以下侧向输出:

图 10.8:使用平台线程
通过仔细检查这个图,我们可以看到这些数字之间存在固定的关联。例如,ID 为 5 的任务由Thread-5执行,ID 为 3 的任务由Thread-3执行,依此类推。在睡眠(即阻塞操作)之后,这些数字保持不变。这意味着当任务睡眠时,线程只是挂起并等待在那里。它们没有工作可做。
让我们从平台线程切换到虚拟线程,然后再次运行代码:
@Override
public Thread newThread(Runnable r) {
// return new Thread(r); // classic thread
return Thread.ofVirtual().unstarted(r); // virtual thread
}
现在,输出继续,如图所示:

图 10.9:使用虚拟线程
这次,我们可以看到事情更加动态。例如,ID 为 5 的任务由worker-6执行的虚拟线程启动,但由worker-4完成。ID 为 3 的任务由worker-4执行的虚拟线程启动,但由worker-6完成。这意味着,当一个任务休眠(一个阻塞操作)时,相应的虚拟线程被卸载,其工作者可以为其他虚拟线程提供服务。当休眠结束后,JVM 调度虚拟线程执行,并将其挂载在另一个(也可能是同一个)工作者上。这也被称为线程上下文切换。
示例 2
在这个例子中,让我们首先将并行性限制为 1(这就像有一个单核和一个虚拟线程):
System.setProperty(
"jdk.virtualThreadScheduler.maxPoolSize", "1");
System.setProperty(
"jdk.virtualThreadScheduler.maxPoolSize", "1");
System.setProperty(
"jdk.virtualThreadScheduler.maxPoolSize", "1");
接下来,让我们考虑我们有一个慢速任务(我们称它为慢速任务,因为它休眠了 5 秒):
Runnable slowTask = () -> {
logger.info(() -> Thread.currentThread().toString()
+ " | working on something");
logger.info(() -> Thread.currentThread().toString()
+ " | break time (blocking)");
try { Thread.sleep(Duration.ofSeconds(5)); }
catch (InterruptedException ex) {} // blocking
logger.info(() -> Thread.currentThread().toString()
+ " | work done");
};
然后,一个快速任务(与慢速任务相似,但它只休眠 1 秒):
Runnable fastTask = () -> {
logger.info(() -> Thread.currentThread().toString()
+ " | working on something");
logger.info(() -> Thread.currentThread().toString()
+ " | break time (blocking)");
try { Thread.sleep(Duration.ofSeconds(1)); }
catch (InterruptedException ex) {} // blocking
logger.info(() -> Thread.currentThread().toString()
+ " | work done");
};
接下来,我们定义两个虚拟线程来执行这两个任务,如下所示:
Thread st = Thread.ofVirtual()
.name("slow-", 0).start(slowTask);
Thread ft = Thread.ofVirtual()
.name("fast-", 0).start(fastTask);
st.join();
ft.join();
如果我们运行此代码,输出将如下所示:
[08:38:46] VirtualThread[#22,slow-0]/runnable
@ForkJoinPool-1-worker-1 | working on something
[08:38:46] VirtualThread[#22,slow-0]/runnable
@ForkJoinPool-1-worker-1 | break time (blocking)
[08:38:46] VirtualThread[#24,fast-0]/runnable
@ForkJoinPool-1-worker-1 | working on something
[08:38:46] VirtualThread[#24,fast-0]/runnable
@ForkJoinPool-1-worker-1 | break time (blocking)
[08:38:47] VirtualThread[#24,fast-0]/runnable
@ForkJoinPool-1-worker-1 | work done
[08:38:51] VirtualThread[#22,slow-0]/runnable
@ForkJoinPool-1-worker-1 | work done
如果我们分析这个输出,我们可以看到执行开始执行慢速任务。快速任务无法执行,因为worker-1(唯一的可用工作者)正忙于执行慢速任务:
[08:38:46] VirtualThread[#22,slow-0]/runnable
@ForkJoinPool-1-worker-1 | working on something
Worker-1执行慢速任务,直到这个任务遇到休眠操作。由于这是一个阻塞操作,相应的虚拟线程(#22)从worker-1卸载:
[08:38:46] VirtualThread[#22,slow-0]/runnable
@ForkJoinPool-1-worker-1 | break time (blocking)
JVM 利用worker-1可用的事实,推动快速任务的执行:
[08:38:46] VirtualThread[#24,fast-0]/runnable
@ForkJoinPool-1-worker-1 | working on something
快速任务也遇到了一个休眠操作,其虚拟线程(#24)被卸载:
[08:38:46] VirtualThread[#24,fast-0]/runnable
@ForkJoinPool-1-worker-1 | break time (blocking)
然而,快速任务只休眠 1 秒,所以它的阻塞操作在慢速任务的阻塞操作之前就结束了,而慢速任务的阻塞操作仍在休眠。因此,JVM 可以再次调度快速任务执行,worker-1准备接受它:
[08:38:47] VirtualThread[#24,fast-0]/runnable
@ForkJoinPool-1-worker-1 | work done
在这个时候,快速任务已经完成,worker-1空闲。但慢速任务仍在休眠。在这 5 秒之后,JVM 调度慢速任务执行,worker-1在那里等待执行它。
[08:38:51] VirtualThread[#22,slow-0]/runnable
@ForkJoinPool-1-worker-1 | work done
完成!
示例 3
这个例子只是对示例 2 的微小修改。这次,让我们考虑慢速任务包含一个永远运行的非阻塞操作。在这种情况下,这个操作通过一个无限循环来模拟:
Runnable slowTask = () -> {
logger.info(() -> Thread.currentThread().toString()
+ " | working on something");
logger.info(() -> Thread.currentThread().toString()
+ " | break time (non-blocking)");
**while****(dummyTrue()) {}** **// non-blocking**
logger.info(() -> Thread.currentThread().toString()
+ " | work done");
};
static boolean dummyTrue() { return true; }
我们有一个单独的工作者(worker-1),快速任务与示例 2 中的相同。如果我们运行此代码,执行将挂起,如下所示:
[09:02:45] VirtualThread[#22,slow-0]/runnable
@ForkJoinPool-1-worker-1 | working on something
[09:02:45] VirtualThread[#22,slow-0]/runnable
@ForkJoinPool-1-worker-1 | break time(non-blocking)
// hang on
执行挂起是因为无限循环不被视为阻塞操作。换句话说,慢速任务的虚拟线程(#22)永远不会被卸载。由于只有一个工作者,JVM 无法推动快速任务的执行。
如果我们将并行性从 1 增加到 2,那么快速任务将由 worker-2 成功执行,而 worker-1(执行慢速任务)将简单地挂起在部分执行上。我们可以通过依赖超时连接,如 join(Duration duration),来避免这种情况。这样,在给定超时后,慢速任务将被自动中断。所以请注意这种情况。
216. 介绍 ExecutorService invoke all/any for virtual threads – 第一部分
在这个问题中,我们不会花费时间在基础知识上,而是直接跳到如何使用 invokeAll() 和 invokeAny()。如果你需要关于 ExecutorService API 的 invokeAll()/invokeAny() 函数的入门,那么你可以考虑 Java Coding Problems,第一版,第十章,问题 207。
与 invokeAll() 一起工作
简而言之,invokeAll() 执行一系列任务(Callable),并返回一个 List<Future>,它包含每个任务的结果/状态。任务可以自然完成或被给定的超时强制完成。每个任务可以成功完成或异常完成。返回后,所有尚未完成的任务都将自动取消。我们可以通过 Future.isDone() 和 Future.isCancelled() 检查每个任务的状态:
<T> List<Future<T>> invokeAll(Collection<? extends
Callable<T>> tasks) throws InterruptedException
<T> List<Future<T>> invokeAll(
Collection<? extends Callable<T>> tasks, long timeout,
TimeUnit unit) throws InterruptedException
通过 newVirtualThreadPerTaskExecutor()(或通过 newThreadPerTaskExecutor())使用 invokeAll() 是直接的。例如,这里我们有一个执行三个 Callable 实例的简单示例:
try (ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = executor.invokeAll(
List.of(() -> "pass01", () -> "pass02", () -> "pass03"));
futures.forEach(f -> logger.info(() ->
"State: " + f.state()));
}
你是否注意到了 f.state() 调用?此 API 在 JDK 19 中引入,它根据已知的 get()、isDone() 和 isCancelled() 计算未来的状态。虽然我们将在后续问题中详细说明,但目前的输出如下:
[10:17:41] State: SUCCESS
[10:17:41] State: SUCCESS
[10:17:41] State: SUCCESS
三项任务已成功完成。
与 invokeAny() 一起工作
简而言之,invokeAny() 执行一系列任务(Callable),并努力返回与成功终止的任务(在给定的超时之前,如果有)相对应的结果。所有未完成的任务都将自动取消:
<T> T invokeAny(Collection<? extends Callable<T>> tasks)
throws InterruptedException, ExecutionException
<T> T invokeAny(Collection<? extends Callable<T>> tasks,
long timeout, TimeUnit unit) throws InterruptedException,
ExecutionException, TimeoutException
通过 newVirtualThreadPerTaskExecutor()(或通过 newThreadPerTaskExecutor())使用 invokeAny() 也是直接的。例如,这里我们有一个在关注单个结果时执行三个 Callable 实例的简单示例:
try (ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor()) {
String result = executor.invokeAny(
List.of(() -> "pass01", () -> "pass02", () -> "pass03"));
logger.info(result);
}
可能的输出可能是:
[10:29:33] pass02
此输出对应于第二个 Callable。
在下一个问题中,我们将提供一个更现实的例子。
217. 介绍 ExecutorService invoke all/any for virtual threads – 第二部分
在之前,在 问题 210 中,我们编写了一段“非结构化”的并发代码来构建一个由外部服务器服务的三个测试员的测试团队。
现在,让我们尝试通过 invokeAll()/Any() 和 newVirtualThreadPerTaskExecutor() 重新编写 buildTestingTeam() 方法。如果我们依赖于 invokeAll(),那么应用程序将尝试通过 ID 加载三个测试员,如下所示:
public static TestingTeam buildTestingTeam()
throws InterruptedException {
try (ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = executor.invokeAll(
List.of(() -> fetchTester(1),
() -> fetchTester(2),
() -> fetchTester(3)));
futures.forEach(f -> logger.info(() -> "State: "
+ f.state()));
return new TestingTeam(futures.get(0).resultNow(),
futures.get(1).resultNow(), futures.get(2).resultNow());
}
}
我们有三个测试员,ID 分别为 1、2 和 3。所以输出将是:
[07:47:32] State: SUCCESS
[07:47:32] State: SUCCESS
[07:47:32] State: SUCCESS
在下一个问题中,我们将看到如何根据任务状态做出决策。
如果我们可以即使只有一个测试员也能处理测试阶段,那么我们可以依赖invokeAny(),如下所示:
public static TestingTeam buildTestingTeam()
throws InterruptedException, ExecutionException {
try (ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor()) {
String result = executor.invokeAny(
List.of(() -> fetchTester(1),
() -> fetchTester(2),
() -> fetchTester(3)));
logger.info(result);
return new TestingTeam(result);
}
}
此代码将返回一个代表这三个测试员之一的单个结果。如果他们中没有人可用,那么我们将得到一个UserNotFoundException。
218. 钩子任务状态
从 JDK 19 开始,我们可以依赖Future.state()。此方法根据已知的get()、isDone()和isCancelled()计算Future的状态,返回一个Future.State枚举项,如下所示:
-
CANCELLED– 任务已被取消。 -
FAILED– 任务异常完成(带有异常)。 -
RUNNING– 任务仍在运行(尚未完成)。 -
SUCCESS– 任务正常完成并返回结果(没有异常)。
在以下代码片段中,我们分析加载测试团队成员的状态,并据此采取行动:
public static TestingTeam buildTestingTeam()
throws InterruptedException {
List<String> testers = new ArrayList<>();
try (ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = executor.invokeAll(
List.of(() -> fetchTester(Integer.MAX_VALUE),
() -> fetchTester(2),
() -> fetchTester(Integer.MAX_VALUE)));
futures.forEach(f -> {
logger.info(() -> "Analyzing " + f + " state ...");
switch (f.state()) {
case RUNNING -> throw new IllegalStateException(
"Future is still in the running state ...");
case SUCCESS -> {
logger.info(() -> "Result: " + f.resultNow());
testers.add(f.resultNow());
}
case FAILED ->
logger.severe(() -> "Exception: "
+ f.exceptionNow().getMessage());
case CANCELLED ->
logger.info("Cancelled ?!?");
}
});
}
return new TestingTeam(testers.toArray(String[]::new));
}
我们知道当执行达到switch块时,Future对象应该是完全正常或异常的。所以如果当前的Future状态是RUNNING,那么这是一个非常奇怪的情况(可能是错误),我们抛出IllegalStateException。接下来,如果Future状态是SUCCESS(fetchTester(2)),那么我们可以通过resultNow()获取结果。此方法是在 JDK 19 中添加的,当我们知道有结果时很有用。resultNow()方法立即返回,不等待(就像get()一样)。如果状态是FAILED(fetchTester(Integer.MAX_VALUE)),那么我们通过exceptionNow()记录异常。此方法也是在 JDK 19 中添加的,它立即返回失败Future的底层异常。最后,如果Future被取消,那么就没有什么可做的。我们只需在日志中报告即可。
219. 结合 newVirtualThreadPerTaskExecutor()和 Streams
Streams 和newVirtualThreadPerTaskExecutor()是一个方便的组合。以下是一个示例,它依赖于IntStream提交 10 个简单的任务,并收集返回的Future实例的List:
try (ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = IntStream.range(0, 10)
.mapToObj(i -> executor.submit(() -> {
return Thread.currentThread().toString()
+ "(" + i + ")";
})).collect(toList());
// here we have the following snippet of code
}
接下来,我们通过调用get()方法等待每个Future完成:
futures.forEach(f -> {
try {
logger.info(f.get());
} catch (InterruptedException | ExecutionException ex) {
// handle exception
}
});
此外,使用流管道与invokeAll()结合相当有用。例如,以下流管道返回一个结果列表(它过滤了所有未成功完成的Future实例):
List<String> results = executor.invokeAll(
List.of(() -> "pass01", () -> "pass02", () -> "pass03"))
.stream()
.filter(f -> f.state() == Future.State.SUCCESS)
.<String>mapMulti((f, c) -> {
c.accept((String) f.resultNow());
}).collect(Collectors.toList());
或者,我们可以编写以下解决方案(不使用mapMulti()):
List<String> results = executor.invokeAll(
List.of(() -> "pass01", () -> "pass02", () -> "pass03"))
.stream()
.filter(f -> f.state() == Future.State.SUCCESS)
.map(f -> f.resultNow().toString())
.toList();
当然,如果你只需要List<Object>,那么你可以直接通过Future::resultNow进行,如下所示:
List<Object> results = executor.invokeAll(
List.of(() -> "pass01", () -> "pass02", () -> "pass03"))
.stream()
.filter(f -> f.state() == Future.State.SUCCESS)
.map(Future::resultNow)
.toList();
另一方面,你可能需要收集所有异常完成的Future。这可以通过exceptionNow()实现,如下所示(我们故意在给定的List<Callable>中添加了一个将生成StringIndexOutOfBoundsException的Callable,() -> "pass02".substring(50)):
List<Throwable> exceptions = executor.invokeAll(
List.of(() -> "pass01",
() -> "pass02".substring(50), () -> "pass03"))
.stream()
.filter(f -> f.state() == Future.State.FAILED)
.<Throwable>mapMulti((f, c) -> {
c.accept((Throwable) f.exceptionNow());
}).collect(Collectors.toList());
如果你不喜欢mapMulti(),那么就依靠经典方法:
List<Throwable> exceptions = executor.invokeAll(
List.of(() -> "pass01", () -> "pass02".substring(50),
() -> "pass03"))
.stream()
.filter(f -> f.state() == Future.State.FAILED)
.map(Future::exceptionNow)
.toList();
你可以在捆绑的代码中找到所有这些示例。
220. 介绍范围对象(StructuredTaskScope)
到目前为止,我们已经涵盖了一系列直接或间接通过ExecutorService使用虚拟线程的问题。我们已经知道虚拟线程创建成本低,阻塞成本低,并且应用程序可以运行数百万个。我们不需要重用它们,池化它们,或做任何花哨的事情。"使用后丢弃"是处理虚拟线程的正确和推荐方式。这意味着虚拟线程非常适合表达和编写基于大量线程的异步代码,这些线程在短时间内可以多次阻塞/解除阻塞。另一方面,我们知道创建 OS 线程成本高昂,阻塞成本非常高,并且不容易将其放入异步上下文中。
在虚拟线程(所以对于很多人来说,很多年)之前,我们必须通过ExecutorService/Executor来管理 OS 线程的生命周期,并且我们可以通过回调来编写异步(或响应式)代码(你可以在Java 编码问题,第一版,第十一章中找到异步编程的详细说明)。
然而,异步/响应式代码难以编写/阅读,非常难以调试和性能分析,并且几乎难以进行单元测试。没有人愿意阅读和修复你的异步代码!此外,一旦我们开始通过异步回调编写应用程序,我们往往会将此模型用于所有任务,即使对于那些不应该异步的任务也是如此。当我们需要将异步代码/结果与同步代码以某种方式链接时,我们很容易陷入这种陷阱。而实现它的最简单方法就是只使用异步代码。
那么,有没有更好的方法呢?是的,有!结构化并发就是答案。结构化并发最初是一个孵化器项目,并在 JDK 21(JEP 453)中达到了预览阶段。
在这种情况下,我们应该介绍StructuredTaskScope。StructuredTaskScope是一个用于Callable任务的虚拟线程启动器,它返回一个Subtask。子任务是由StructuredTaskScope.Subtask<T>接口表示的Supplier<T>函数式接口的扩展,并通过StructuredTaskScope.fork(Callable task)进行分叉。它遵循并基于结构化并发的根本原则(参见问题 210):"当任务需要并发解决时,所有解决该任务所需的线程都在同一块代码中启动和重新连接。换句话说,所有这些线程的生命周期都绑定在块的范围内,因此我们为每个并发代码块提供了清晰和明确的入口和出口点。"这些线程负责以单个工作单元运行给定任务的子任务(Subtask)。
让我们来看一个通过StructuredTaskScope从我们的 Web 服务器获取单个测试者(ID 为 1)的示例:
public static TestingTeam buildTestingTeam()
throws InterruptedException {
try (StructuredTaskScope scope
= new StructuredTaskScope<String>()) {
Subtask<String> subtask
= scope.fork(() -> fetchTester(1));
logger.info(() -> "Waiting for " + subtask.toString()
+ " to finish ...\n");
scope.join();
String result = subtask.get();
logger.info(result);
return new TestingTeam(result);
}
}
首先,我们以try-with-resources模式创建一个StructuredTaskScope。StructuredTaskScope实现了AutoCloseable接口:
try (StructuredTaskScope scope
= new StructuredTaskScope<String>()) {
...
}
scope是虚拟线程生命周期的包装器。我们通过fork(Callable task)方法使用scope来创建所需数量的虚拟线程(子任务)。在这里,我们只创建一个虚拟线程并返回一个Subtask(创建是异步操作):
Subtask<String> subtask = scope.fork(() -> fetchTester(1));
接下来,我们必须调用join()方法(或joinUntil(Instant deadline))。此方法等待从这个scope分叉的所有线程(所有Subtask实例)以及提交给此scope的所有线程完成,因此它是一个阻塞操作。作用域应该只在等待其子任务完成时阻塞,这通过join()或joinUntil()实现。
scope.join();
当执行通过这一行时,我们知道从这个scope分叉的所有线程(所有分叉的Subtask)都已经完成,无论是以结果还是异常(每个子任务独立运行,因此每个子任务都可以以结果或异常完成)。在这里,我们调用非阻塞的get()方法来获取结果,但请注意——对一个未完成的任务调用get()将抛出IllegalStateException("Owner did not join after forking subtask")异常:
String result = subtask.get();
另一方面,我们可以通过exception()方法获取失败任务的异常。然而,如果我们对一个以结果完成的子任务调用exception(),那么我们将得到一个IllegalStateException("Subtask not completed or did not complete with exception")异常。
所以,如果你不确定你的任务(们)是否以结果或异常完成,最好是在测试相应的Subtask状态之后才调用get()或exception()。状态为SUCCESS将安全地允许你调用get(),而状态为FAILED将安全地允许你调用exception()。因此,在我们的情况下,我们可能更喜欢这种方式:
String result = "";
if (subtask.state().equals(Subtask.State.SUCCESS)) {
result = subtask.get();
}
除了Subtask.State.SUCCESS和Subtask.State.FAILED之外,我们还有Subtask.State.UNAVAILABLE,这意味着子任务不可用(例如,如果子任务仍在运行,则其状态为UNAVAILABLE,但也可能有其他原因)。
ExecutorService 与 StructuredTaskScope 的比较
之前的代码看起来像是我们会通过经典的ExecutorService编写的代码,但这两个解决方案之间有两个很大的区别。首先,ExecutorService保留了宝贵的平台线程,并允许我们对其进行池化。另一方面,StructuredTaskScope只是一个虚拟线程的薄启动器,虚拟线程便宜且不应进行池化。所以一旦我们完成了工作,StructuredTaskScope就可以被销毁并回收垃圾。其次,ExecutorService为所有任务保留了一个队列,线程在有机会时从该队列中获取任务。StructuredTaskScope依赖于 fork/join 池,每个虚拟线程都有自己的等待队列。然而,虚拟线程也可以从另一个队列中窃取任务。这被称为工作窃取模式,如果你想了解更多关于它的信息,我们已经在Java 编码问题,第一版,第十一章中进行了深入探讨。
221. 引入 ShutdownOnSuccess
在上一个问题中,我们介绍了StructuredTaskScope并使用它通过单个虚拟线程(一个Subtask)来解决一个任务。基本上,我们从服务器中获取了 ID 为 1 的测试者(我们必须等待这个测试者可用)。接下来,假设我们仍然需要一个测试者,但不一定是 ID 为 1 的那个。这次,可以是 ID 为 1、2 或 3 中的任何一个。我们只需从这三个中选取第一个可用的,并取消其他两个请求。
尤其是在这种场景下,我们有一个StructuredTaskScope的扩展,称为StructuredTaskScope.ShutdownOnSuccess。这个范围能够返回第一个成功完成的任务的结果,并中断其他线程。它遵循“调用任意”模型,可以使用以下方式:
public static TestingTeam buildTestingTeam()
throws InterruptedException, ExecutionException {
try (ShutdownOnSuccess scope
= new StructuredTaskScope.ShutdownOnSuccess<String>()) {
Subtask<String> subtask1
= scope.fork(() -> fetchTester(1));
Subtask<String> subtask2
= scope.fork(() -> fetchTester(2));
Subtask<String> subtask3
= scope.fork(() -> fetchTester(3));
scope.join();
logger.info(() -> "Subtask-1 state: " + future1.state());
logger.info(() -> "Subtask-2 state: " + future2.state());
logger.info(() -> "Subtask-3 state: " + future3.state());
String result = (String) scope.result();
logger.info(result);
return new TestingTeam(result);
}
}
这里,我们创建了三个子任务(线程),它们将相互竞争以完成。第一个成功完成的子任务(线程)获胜并返回。result()方法返回这个结果(如果所有子任务(线程)都没有成功完成,则抛出ExecutionException)。
如果我们检查这三个Subtask的状态,我们可以看到其中一个成功了,而其他两个不可用:
[09:01:50] Subtask-1 state: UNAVAILABLE
[09:01:50] Subtask-2 state: SUCCESS
[09:01:50] Subtask-3 state: UNAVAILABLE
当然,你不需要检查/打印每个Subtask的状态的代码。这里添加它只是为了突出ShutdownOnSuccess的工作原理。你甚至不需要显式的Subtask对象,因为我们没有从这个 API 中调用get()或其他任何东西。基本上,我们可以将代码简化为以下内容:
public static TestingTeam buildTestingTeam()
throws InterruptedException, ExecutionException {
try (ShutdownOnSuccess scope
= new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> fetchTester(1));
scope.fork(() -> fetchTester(2));
scope.fork(() -> fetchTester(3));
scope.join();
return new TestingTeam((String) scope.result());
}
}
完成!你只需创建范围,创建子任务,调用join(),并收集结果。所以这个范围真的是以业务为中心的。
在ShutdownOnSuccess的范围内异常完成的任务永远不会被选中以产生结果。然而,如果所有任务都异常完成,那么我们将得到一个ExecutionException,它封装了第一个完成的任务(即原因)的异常。
222. 引入 ShutdownOnFailure
如其名称所示,StructuredTaskScope.ShutdownOnFailure能够返回第一个完成异常的子任务的异常,并中断其余的子任务(线程)。例如,我们可能想要获取 ID 为 1、2 和 3 的测试者。由于我们需要这三个测试者,我们希望得知其中任何一个不可用,如果有的话,取消一切(即剩余的线程)。代码如下:
public static TestingTeam buildTestingTeam()
throws InterruptedException, ExecutionException {
try (ShutdownOnFailure scope
= new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> subtask1
= scope.fork(() -> fetchTester(1));
Subtask<String> subtask2
= scope.fork(() -> fetchTester(2));
Subtask<String> subtask3
= scope.fork(() -> fetchTester(Integer.MAX_VALUE));
scope.join();
logger.info(() -> "Subtask-1 state: " + subtask1.state());
logger.info(() -> "Subtask-2 state: " + subtask2.state());
logger.info(() -> "Subtask-3 state: " + subtask3.state());
Optional<Throwable> exception = scope.exception();
if (exception.isEmpty()) {
logger.info(() -> "Subtask-1 result:" + subtask1.get());
logger.info(() -> "Subtask-2 result:" + subtask2.get());
logger.info(() -> "Subtask-3 result:" + subtask3.get());
return new TestingTeam(
subtask1.get(), subtask2.get(), subtask3.get());
} else {
logger.info(() -> exception.get().getMessage());
scope.throwIfFailed();
}
}
return new TestingTeam();
}
在这个例子中,我们故意将 ID 3 替换为Integer.MAX_VALUE。由于没有具有此 ID 的测试者,服务器将抛出UserNotFoundException。这意味着子任务的状态将揭示第三个子任务已经失败:
[16:41:15] Subtask-1 state: SUCCESS
[16:41:15] Subtask-2 state: SUCCESS
[16:41:15] Subtask-3 state: FAILED
此外,当我们调用exception()方法时,我们将得到一个包含此异常的Optional<Throwable>(如果您对这个主题感兴趣,关于Optional功能的深入覆盖可在Java Coding Problems,第一版,第十二章中找到)。如果我们决定抛出它,那么我们只需调用throwIfFailed()方法,该方法将原始异常(原因)包装在ExecutionException中并抛出。在我们的情况下,异常的消息将是:
Exception in thread "main"
java.util.concurrent.ExecutionException:
modern.challenge.UserNotFoundException: Code: 404
如果我们移除指南代码,那么我们可以将之前的代码压缩如下:
public static TestingTeam buildTestingTeam()
throws InterruptedException, ExecutionException {
try (ShutdownOnFailure scope
= new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<String> subtask1
= scope.fork(() -> fetchTester(1));
Subtask<String> subtask2
= scope.fork(() -> fetchTester(2));
Subtask<String> subtask3
= scope.fork(() -> fetchTester(
Integer.MAX_VALUE)); // this causes exception
scope.join();
scope.throwIfFailed();
// because we have an exception the following
// code will not be executed
return new TestingTeam(
subtask1.get(), subtask2.get(), subtask3.get());
}
}
如果没有发生异常,那么throwIfFailed()不会做任何事情,这三个测试者都是可用的。每个Subtask的结果都可通过非阻塞的Subtask.get()获得。
在ShutdownOnFailure的覆盖下,一个完成异常的子任务将被选择来产生一个异常。然而,如果所有子任务都正常完成,那么我们将不会得到任何异常。另一方面,如果没有子任务完成异常但被取消,那么ShutdownOnFailure将抛出CancellationException。
223. 结合 StructuredTaskScope 和流
如果您更喜欢函数式编程,那么您会高兴地看到流也可以与StructuredTaskScope一起使用。例如,在这里我们重写了问题 221中的应用程序,使用流管道来分叉我们的任务:
public static TestingTeam buildTestingTeam()
throws InterruptedException, ExecutionException {
try (ShutdownOnSuccess scope
= new StructuredTaskScope.ShutdownOnSuccess<String>()) {
Stream.of(1, 2, 3)
.<Callable<String>>map(id -> () -> fetchTester(id))
.forEach(scope::fork);
scope.join();
String result = (String) scope.result();
logger.info(result);
return new TestingTeam(result);
}
}
此外,我们可以使用流管道来收集结果和异常,如下所示:
public static TestingTeam buildTestingTeam()
throws InterruptedException, ExecutionException {
try (ShutdownOnSuccess scope
= new StructuredTaskScope.ShutdownOnSuccess<String>()) {
List<Subtask> subtasks = Stream.of(Integer.MAX_VALUE, 2, 3)
.<Callable<String>>map(id -> () -> fetchTester(id))
.map(scope::fork)
.toList();
scope.join();
List<Throwable> failed = subtasks.stream()
.filter(f -> f.state() == Subtask.State.FAILED)
.map(Subtask::exception)
.toList();
logger.info(failed.toString());
TestingTeam result = subtasks.stream()
.filter(f -> f.state() == Subtask.State.SUCCESS)
.map(Subtask::get)
.collect(collectingAndThen(toList(),
list -> { return new TestingTeam(list.toArray(
String[]::new)); }));
logger.info(result.toString());
return result;
}
}
您可以在捆绑的代码中找到这些示例。
224. 观察和监控虚拟线程
观察和监控虚拟线程可以通过几种方式完成。首先,我们可以使用Java Flight Recorder(JFR)——我们在第六章,问题 143中介绍了这个工具。
使用 JFR
在其广泛的列表事件中,JFR 可以监控和记录以下与虚拟线程相关的事件:
-
jdk.VirtualThreadStart– 当虚拟线程开始时(默认情况下,它是禁用的) -
jdk.VirtualThreadEnd– 当虚拟线程结束时(默认情况下,它是禁用的)记录此事件 -
jdk.VirtualThreadPinned– 当虚拟线程在固定时挂起时(默认情况下,它是启用的,阈值为 20 毫秒)记录此事件 -
jdk.VirtualThreadSubmitFailed– 如果虚拟线程无法启动或取消挂起(默认情况下是启用的),则记录此事件
你可以在sap.github.io/SapMachine/jfrevents/找到所有 JFR 事件。
我们开始配置 JFR 以监控虚拟线程,通过将以下vtEvent.jfc文件添加到应用程序的根目录:
<?xml version="1.0" encoding="UTF-8"?>
<configuration version="2.0" description="test">
<event name="jdk.VirtualThreadStart">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
</event>
<event name="jdk.VirtualThreadEnd">
<setting name="enabled">true</setting>
</event>
<event name="jdk.VirtualThreadPinned">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
<setting name="threshold">20 ms</setting>
</event>
<event name="jdk.VirtualThreadSubmitFailed">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
</event>
</configuration>
接下来,让我们考虑以下代码(基本上,这是问题 216 的应用程序):
public static TestingTeam buildTestingTeam()
throws InterruptedException, ExecutionException {
try (ShutdownOnSuccess scope
= new StructuredTaskScope.ShutdownOnSuccess<String>()) {
Stream.of(1, 2, 3)
.<Callable<String>>map(id -> () -> fetchTester(id))
.forEach(scope::fork);
scope.join();
String result = (String) scope.result();
logger.info(result);
return new TestingTeam(result);
}
}
接下来,我们使用-XX:StartFlightRecording=filename=recording.jfr来指示 JFR 将输出记录到名为recording.jfr的文件中,并且我们继续使用settings=vtEvent.jfc来突出显示之前列出的配置文件。
所以最终的命令是来自这个图中的命令:

图 10.10:运行 JFR
JFR 生成了一个名为recording.jfr的文件。我们可以通过 JFR CLI 轻松查看此文件的内容。命令(jfr print recording.jfr)将显示recording.jfr的内容。内容太大,无法在此列出(它包含三个jdk.VirtualThreadStart条目和三个jdk.VirtualThreadEnd条目),但以下是特定于启动虚拟线程的事件:

图 10.11:启动虚拟线程的 JFR 事件
在下一个图中,你可以看到记录来结束这个虚拟线程的事件:

图 10.12:结束虚拟线程的 JFR 事件
除了 JFR CLI 之外,你还可以使用更强大的工具来消费虚拟线程事件,例如 JDK Mission Control (www.oracle.com/java/technologies/jdk-mission-control.html)和知名的 Advanced Management Console (www.oracle.com/java/technologies/advancedmanagementconsole.html)。
要获取在固定时阻塞的线程的堆栈跟踪,我们可以设置系统属性jdk.tracePinnedThreads。完整的(详细)堆栈跟踪可以通过-Djdk.tracePinnedThreads=full获得,或者如果你只需要简短/短的堆栈跟踪,则依靠-Djdk.tracePinnedThreads=short。
在我们的例子中,我们可以通过将fetchTester()方法标记为synchronized(记住,如果一个虚拟线程在synchronized方法/块中运行代码,则无法卸载该虚拟线程)来轻松地获得一个固定的虚拟线程:
public static synchronized String fetchTester(int id)
throws IOException, InterruptedException {
...
}
在这个上下文中,JFR 将记录一个固定的虚拟线程,如图所示:

图 10.13:固定虚拟线程的 JFR 事件
如果我们使用-Djdk.tracePinnedThreads=full运行应用程序,那么你的 IDE 将打印出以下开始的详细堆栈跟踪:
Thread[#26,ForkJoinPool-1-worker-1,5,CarrierThreads] java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
...
您可以通过执行捆绑的代码来查看完整的输出。当然,您可以使用jstack、Java Mission Control(JMC)、jvisualvm或jcmd等工具获取线程转储并分析它。您可能更喜欢其中的任何一个。例如,我们可以通过jcmd以纯文本或 JSON 格式获取线程转储,如下所示:
jcmd <PID> Thread.dump_to_file -format=text <file>
jcmd <PID> Thread.dump_to_file -format=json <file>
接下来,让我们使用jconsole(JMX)来快速分析虚拟线程的性能。
使用 Java 管理扩展(JMX)
直到 JDK 20(包括),JMX 只提供了监控平台和线程的支持。然而,我们仍然可以使用 JMX 来观察与平台线程相比虚拟线程带来的性能。
例如,我们可以使用 JMX 以每 500 毫秒一次的频率监控平台线程,如下面的代码片段所示:
ScheduledExecutorService scheduledExecutor
= Executors.newScheduledThreadPool(1);
scheduledExecutor.scheduleAtFixedRate(() -> {
ThreadMXBean threadBean
= ManagementFactory.getThreadMXBean();
ThreadInfo[] threadInfo
= threadBean.dumpAllThreads(false, false);
logger.info(() -> "Platform threads: " + threadInfo.length);
}, 500, 500, TimeUnit.MILLISECONDS);
我们在以下三种场景中依赖此代码。
通过缓存的线程池执行器运行 10,000 个任务
newCachedThreadPool() and platform threads. We also measure the time elapsed to execute these tasks:
long start = System.currentTimeMillis();
try (ExecutorService executorCached
= Executors.newCachedThreadPool()) {
IntStream.range(0, 10_000).forEach(i -> {
executorCached.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
logger.info(() -> "Task: " + i);
return i;
});
});
}
logger.info(() -> "Time (ms): "
+ (System.currentTimeMillis() - start));
在我的机器上,运行这 10,000 个任务耗时 8,147 毫秒(8 秒),峰值使用 7,729 个平台线程。以下jconsole(JMX)的截图揭示了这一信息:

图 10.14:通过缓存的线程池执行器运行 10,000 个任务
接下来,让我们通过固定线程池重复这个测试。
通过固定线程池执行器运行 10,000 个任务
根据您的机器配置,之前的测试可能成功完成,或者可能导致OutOfMemoryError。我们可以通过使用固定线程池来避免这种不愉快的场景。例如,让我们通过以下代码片段将平台线程的数量限制为 200:
long start = System.currentTimeMillis();
try (ExecutorService executorFixed
= Executors.newFixedThreadPool(200)) {
IntStream.range(0, 10_000).forEach(i -> {
executorFixed.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
logger.info(() -> "Task: " + i);
return i;
});
});
}
logger.info(() -> "Time (ms): "
+ (System.currentTimeMillis() - start));
在我的机器上,运行这 10,000 个任务耗时 50,190 毫秒(50 秒),峰值使用 216 个平台线程。以下 JMX 的截图揭示了这一信息:

图 10.15:通过固定线程池执行器运行 10,000 个任务
显然,平台线程数量较少会反映在性能上。如果我们用 216 个工作者来完成 7,729 个工作者的工作,当然会花费更长的时间。接下来,让我们看看虚拟线程将如何应对这个挑战。
通过每个任务虚拟线程执行器运行 10,000 个任务
这次,让我们看看newVirtualThreadPerTaskExecutor()如何处理这 10,000 个任务。代码很简单:
long start = System.currentTimeMillis();
try (ExecutorService executorVirtual
= Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executorVirtual.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
logger.info(() -> "Task: " + i);
return i;
});
});
}
logger.info(() -> "Time (ms): "
+ (System.currentTimeMillis() - start));
在我的机器上,运行这 10,000 个任务耗时 3,519 毫秒(3.5 秒),峰值使用 25 个平台线程。以下 JMX 的截图揭示了这一信息:

图 10.16:通过每个任务虚拟线程执行器运行 10,000 个任务
哇!这有多酷?!与之前的测试相比,结果的时间远远是最好的,并且它使用了更少的资源(只有 25 个平台线程)。所以虚拟线程真的很棒!
我还强烈推荐您查看以下基准测试:github.com/colincachia/loom-benchmark/tree/main。
从 JDK 21 开始,JMX 的HotSpotDiagnosticMXBean通过dumpThreads(String outputFile, ThreadDumpFormat format)方法得到了增强。此方法将线程转储输出到指定的文件(outputFile),格式为(format)。线程转储将包含所有平台线程,但也可能包含一些或所有虚拟线程。
在以下代码中,我们尝试获取StructuredTaskScope的所有子任务(线程)的线程转储:
try (ShutdownOnSuccess scope
= new StructuredTaskScope.ShutdownOnSuccess<String>()) {
Stream.of(1, 2, 3)
.<Callable<String>>map(id -> () -> fetchTester(id))
.forEach(scope::fork);
HotSpotDiagnosticMXBean mBean = ManagementFactory
.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
mBean.dumpThreads(Path.of("dumpThreads.json")
.toAbsolutePath().toString(),
HotSpotDiagnosticMXBean.ThreadDumpFormat.JSON);
scope.join();
String result = (String) scope.result();
logger.info(result);
}
输出文件命名为threadDump.json,您可以在应用程序的根目录中找到它。我们感兴趣的部分输出如下所示:
...
{
"container": "java.util.concurrent
.StructuredTaskScope$ShutdownOnSuccess@6d311334",
"parent": "<root>",
"owner": "1",
"threads": [
{
"tid": "22"
"name": "",
"stack": [
...
"java.base\/java.lang.VirtualThread
.run(VirtualThread.java:311)"
]
},
{
"tid": "24",
"name": "",
"stack": [
...
"java.base\/java.lang.VirtualThread
.run(VirtualThread.java:311)"
]
},
{
"tid": "25",
"name": "",
"stack": [
...
"java.base\/java.lang.VirtualThread
.run(VirtualThread.java:311)"
]
}
],
"threadCount": "3"
}
...
如您所见,我们有三个虚拟线程(#22、#24 和#25)运行我们范围内的子任务。在捆绑的代码中,您可以找到完整的输出。
摘要
本章涵盖了关于虚拟线程和结构化并发的 16 个入门问题。您可以将本章视为下一章的准备,下一章将涵盖这两个主题的更多详细方面。
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:

第十一章:并发 - 虚拟线程和结构化并发:深入探讨
本章包括 18 个问题,旨在深入探讨虚拟线程和结构化并发的工作方式以及它们应该如何在你的应用程序中使用。
如果你没有 Java 并发方面的背景,我强烈建议你推迟阅读本章,直到你阅读了一些关于这个主题的良好入门内容。例如,你可以尝试《Java 编码问题》第一版的第十章和第十一章。
我们从解释虚拟线程的内部工作方式开始本章。这将有助于你更好地理解后续关于扩展和组装StructuredTaskScope、挂钩ThreadLocal和虚拟线程、避免固定、解决生产者-消费者问题、实现 HTTP 网络服务器等问题。
到本章结束时,你将全面且清晰地了解如何使用虚拟线程和结构化并发。
问题
使用以下问题来测试你在 Java 虚拟线程和结构化并发方面的高级编程能力。我强烈鼓励你在查看解决方案和下载示例程序之前尝试每个问题:
-
处理延续(Tackling continuations):详细解释什么是延续以及它们在虚拟线程上下文中的工作方式。
-
追踪虚拟线程的状态和转换(Tracing virtual thread states and transitions):构建一个有意义的虚拟线程状态和转换图,并对其进行解释。
-
扩展 StructuredTaskScope:解释并演示扩展
StructuredTaskScope的步骤。解释为什么我们不能扩展ShutdownOnSuccess和ShutdownOnFailure。 -
组装 StructuredTaskScope:编写一个 Java 应用程序,组装(嵌套)多个
StructuredTaskScope实例。 -
使用超时修改 StructuredTaskScope:修改在问题 228中开发的应用程序,为分叉的任务添加超时/截止日期。
-
挂钩 ThreadLocal 和虚拟线程:演示
ThreadLocal和虚拟线程的使用。 -
挂钩 ScopedValue 和虚拟线程:提供一个全面的介绍,并带有
ScopedValueAPI 的示例。 -
使用 ScopedValue 和执行器服务:编写一段代码,强调在执行器服务上下文中使用
ScopedValueAPI。 -
链式和重新绑定作用域值:提供一些代码片段,展示作用域值如何进行链式和重新绑定。
-
使用 ScopedValue 和 StructuredTaskScope:编写一个 Java 应用程序,突出
ScopedValue和StructuredTaskScope的使用。在代码中解释每个ScopedValue是如何绑定和未绑定的。 -
使用 Semaphore 代替 Executor:在虚拟线程的上下文中,解释使用
Semaphore而不是执行器(例如,而不是newFixedThreadPool())的好处,并举例说明。 -
通过锁定避免固定:解释并举例说明我们如何通过重构
synchronized代码通过ReentrantLock来避免固定的虚拟线程。 -
通过虚拟线程解决生产者-消费者问题:编写一个程序,通过生产者-消费者模式模拟一个由多个工人(虚拟线程)组成的检查和包装灯泡的装配线。
-
通过虚拟线程解决生产者-消费者问题(通过信号量解决):将问题 237中开发的应用程序修改为使用
Semaphore而不是执行器服务。 -
通过虚拟线程解决生产者-消费者问题(增加/减少消费者):编写一个程序,模拟一个检查和包装灯泡的装配线,根据需要使用工人(例如,调整包装工的数量(增加或减少)以适应检查器产生的输入流)。使用虚拟线程和
Semaphore。 -
在虚拟线程之上实现 HTTP 网络服务器:依靠 Java
HttpServer编写一个简单的 HTTP 网络服务器实现,能够支持平台线程、虚拟线程和锁定(用于模拟数据库连接池)。 -
挂钩 CompletableFuture 和虚拟线程:演示如何使用
CompletableFuture和虚拟线程来解决异步任务。 -
通过 wait()和 notify()信号虚拟线程:编写几个示例,使用
wait()和notify()通过虚拟线程协调对资源(对象)的访问。演示良好的信号和错过信号的情景。
以下章节描述了前面问题的解决方案。请记住,通常没有解决特定问题的唯一正确方法。此外,请记住,这里所示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节并实验程序,请访问github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter11。
225. 处理连续
虚拟线程背后的概念被称为分界连续**或简单地称为连续**。这个概念在以下代码片段中被 JVM 内部使用:
List<Thread> vtThreads = IntStream.range(0, 5)
.mapToObj(i -> Thread.ofVirtual().unstarted(() -> {
if (i == 0) {
logger.info(Thread.currentThread().toString());
}
try { Thread.sleep(1000); }
catch (InterruptedException ex) {}
if (i == 0) {
logger.info(Thread.currentThread().toString());
}
})).toList();
vtThreads.forEach(Thread::start);
vtThreads.forEach(thread -> {
try { thread.join(); } catch (InterruptedException ex) {}
});
在此代码中,我们创建了五个虚拟线程并启动了它们,但我们只记录了一个线程的信息(线程#22 – 当然,ID 值可能在不同的执行中有所不同)。因此,输出将如下所示:
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-4
线程#22 已经开始在worker-1上运行,但在阻塞操作(sleep(1000))之后,它继续在worker-4上运行。在这所谓的线程上下文切换背后,我们有所谓的*连续**。
基本上,延续的行为可以通过一个流行的调试器用例轻松解释。当我们调试代码时,我们会设置一个断点并运行代码。当执行流程遇到这个断点时,执行会冻结,我们可以检查应用程序的当前状态。稍后,当我们完成检查后,我们可以从这个断点继续运行代码。调试器知道如何从暂停的地方(冻结状态)恢复执行。因此,执行会继续,直到遇到应用程序的末尾或遇到另一个断点。
简而言之,虚拟线程遵循相同的行为。虚拟线程被挂载在平台线程(worker-x)上并开始运行。当执行遇到一个阻塞操作(例如,一个sleep()调用)时,虚拟线程就会从其工作线程上卸载。稍后,在阻塞操作结束后,通过调度和将其挂载在平台线程(相同的 worker,worker-x,或另一个worker-y)上,线程执行得以恢复。
介绍延续
深入探讨,我们必须介绍子程序和协程。子程序是可以被调用并返回响应的函数,而协程是协作子程序,它们同时运行并像人类对话一样相互交谈。就像两个人交谈一样,协程通过两个相互交谈的子程序设置对话状态。通过这种范式,应用程序可以执行一些任务,然后一段时间什么也不做,稍后再执行更多任务。
但是,协程如何记住对话中涉及的数据呢?简短的答案是延续。延续是能够携带数据(对话状态)在协程之间传递的数据结构。它们可以从离开的地方恢复处理。
虚拟线程通过能够做一些工作,然后卸载,稍后再从离开的地方恢复,利用了延续。
Project Loom 提供了一个 API 来作为内部 API 处理延续,因此它不打算直接在应用程序中使用(除非我们的目标是编写一些更高层次的 API(库)),我们不应该尝试使用这个低级 API,除非我们的目标是编写一些更高层次的 API(库)。然而,这个 API 依赖于两个主要类和三个方法。作为类,我们有ContinuationScope,它是处理嵌套Continuation实例的作用域。作为方法,我们有:
-
run()– 从离开的地方运行延续 -
yield()– 在此点冻结(挂起)延续并交出控制权给延续的调用者(run()将能够从这里恢复执行) -
isDone()– 测试当前延续是否完成
因此,在ContinuationScope的伞下,我们可以有多个嵌套的延续,通过run()、yield()和isDone()设置对话状态。对于虚拟线程,有一个名为VTHREAD_SCOPE的单个ContinuationScope。
explains this statement:
ContinuationScope cscope = new ContinuationScope("cscope");
Continuation continuation = new Continuation(cscope, () ->
logger.info("Continuation is running ...");
});
continuation.run();
由于我们调用了continuation.run(),这段代码将输出:
Continuation is running ...
这非常直接。接下来,让我们通过 yield() 暂停延续:
Continuation continuation = new Continuation(cscope, () ->
logger.info("Continuation is running ...");
**Continuation.yield(cscope);**
logger.info("Continuation keeps running ...");
});
continuation.run();
目前,输出结果相同:
Continuation is running ...
实际上,当我们调用 yield() 方法时,延续被挂起,并将控制权交给调用者。我们可以通过在调用 run() 方法后添加一些日志来轻松地看到这一点,如下所示:
continuation.run();
logger.info("The continuation was suspended ...");
现在,输出将变为:
Continuation is running ...
The continuation was suspended ...
如您所见,logger.info("Continuation keeps running ..."); 这行代码没有执行。yield() 方法在这一点上冻结了执行并将控制权返回给调用者。为了从上次停止的地方恢复延续,我们必须再次调用 run():
continuation.run();
logger.info("The continuation was suspended ...");
continuation.run();
logger.info("The continuation is done ...");
这次,输出将如下(您可以通过 isDone() 检查延续是否完成):
Continuation is running ...
The continuation was suspended ...
Continuation keeps running ...
The continuation is done ...
如您所见,当我们再次调用 run() 时,执行从上次停止的地方恢复,而不是从头开始。这就是延续的工作方式。
延续和虚拟线程
现在,让我们通过示例中的延续来了解虚拟线程和 sleep() 的工作方式。我们的虚拟线程 (#22) 通过记录一条简单消息开始其旅程。之后,它触发了 Thread.sleep(1000); 代码行,如下面的图所示:

图 11.1:虚拟线程 #22 在 worker-1 上运行
sleep() method in the Thread class:
// this is the JDK 21 code
public static void sleep(long millis)
throws InterruptedException {
...
long nanos = MILLISECONDS.toNanos(millis);
...
if (currentThread() instanceofVirtualThread vthread) {
**vthread.sleepNanos(nanos);**
}
...
}
因此,如果调用 sleep() 的线程是虚拟线程,那么代码将简单地从 VirtualThread 类调用内部的 sleepNanos() 方法。我们感兴趣的代码如下:
// this is the JDK 21 code
void sleepNanos(long nanos) throws InterruptedException {
...
if (nanos == 0) {
**tryYield();**
} else {
// park for the sleep time
try {
...
**parkNanos(remainingNanos);**
...
} finally {
// may have been unparked while sleeping
setParkPermit(true);
}
}
}
因此,在这里,代码可以调用 tryYield() 方法(如果 nanos 为 0)或 parkNanos() 方法。如果调用 tryYield(),则线程状态设置为 YIELDING。另一方面,如果调用 parkNanos(),则线程状态设置为 PARKING。在这两种情况下(通过 tryYield() 或 parkNanos()),执行将遇到 yieldContinuation(),这是我们旅程的顶点:
// this is the JDK 21 code
private boolean yieldContinuation() {
// unmount
notifyJvmtiUnmount(/*hide*/true);
unmount();
try {
**return** **Continuation.yield(VTHREAD_SCOPE);**
} finally {
// re-mount
mount();
notifyJvmtiMount(/*hide*/false);
}
}
如您所见,这里虚拟线程被卸载,并调用了 yield()。因此,虚拟线程栈被复制到堆中,线程从承载线程(变为 PARKED)卸载。我们可以通过以下图来查看这一点:

图 11.2:虚拟线程 #22 被卸载并移动到堆中
这种场景适用于任何阻塞操作,而不仅仅是sleep()。一旦虚拟线程 #22 被释放,worker-1 就可以准备为另一个虚拟线程提供服务或执行其他处理。
在阻塞操作完成(这里,sleep(1000))后,VirtualThread 类的 private 方法 runContinuation() 被调用,并且 #22 的执行被恢复。如您在以下图中所见,#22 现在在 worker-4 上运行,因为 worker-1 不可用(它必须执行一些假设的虚拟线程,#41)。

图 11.3:虚拟线程 #22 在 worker-4 上恢复执行
执行继续到第二个日志指令并终止。这就是连续性和虚拟线程如何在内部维持大量吞吐量的工作方式。
226. 追踪虚拟线程的状态和转换
正如你所知,一个线程可以处于以下状态之一:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING或TERMINATED。这些状态是State枚举的元素,并且可以通过Thread.currentThread().getState()调用公开。这些状态对平台线程和虚拟线程都有效,我们可以在我们的应用程序中使用它们。(如果你不熟悉这一点,你可以在Java 编码问题,第一版,第十章,问题 199 中找到更多详细信息。)
然而,从内部来说,虚拟线程是在状态转换模型上工作的,如下面的图所示:

图 11.4:虚拟线程状态转换
这些状态在VirtualThread类中以private static final int的形式声明。因此,它们不是公开的。然而,它们对于理解虚拟线程的生命周期至关重要,所以让我们简要地尝试追踪虚拟线程在其生命周期中的状态。
NEW
当虚拟线程被创建(例如,通过unstarted()方法)时,它处于NEW状态。在这个状态下,线程尚未挂载,甚至尚未启动。然而,在那个时刻,JVM 调用这里列出的VirtualThread的构造函数:
// this is the JDK 21 code
VirtualThread(Executor scheduler, String name,
int characteristics, Runnable task) {
super(name, characteristics, /*bound*/ false);
Objects.requireNonNull(task);
// choose scheduler if not specified
if (scheduler == null) {
Thread parent = Thread.currentThread();
if (parent instanceofVirtualThread vparent) {
scheduler = vparent.scheduler;
} else {
scheduler = DEFAULT_SCHEDULER;
}
}
**this****.scheduler = scheduler;**
**this****.cont =** **new****VThreadContinuation****(****this****, task);**
**this****.runContinuation =** **this****::runContinuation;**
}
因此,这个构造函数负责选择调度器来创建一个Continuation(这是一个存储要作为task运行的VThreadContinuation对象)并准备runContinuation private字段,这是一个Runnable,用于在虚拟线程启动时运行Continuation。
STARTED
当我们调用start()方法时,虚拟线程从NEW状态转换为STARTED:
// this is the JDK 21 code
@Override
void start(ThreadContainer container) {
**if** **(!compareAndSetState(NEW, STARTED)) {**
**throw****new****IllegalThreadStateException****(****"Already started"****);**
**}**
...
// start thread
boolean started = false;
...
try {
...
// submit task to run thread
**submitRunContinuation();**
started = true;
} finally {
if (!started) {
setState(TERMINATED);
...
}
}
}
此外,在那个时刻,runContinuation可运行对象通过submitRunContinuation()在虚拟线程调度器上安排。
运行中
runContinuation可运行对象将虚拟线程的状态从STARTED移动到RUNNING并调用cont.run()。虚拟线程被挂载(可能是第一次挂载,或者只是后续挂载以从上次停止的地方恢复执行)在一个平台线程上并开始运行:
// this is the JDK 21 code
private void runContinuation() {
...
// set state to RUNNING
int initialState = state();
**if** **(initialState == STARTED**
**&& compareAndSetState(STARTED, RUNNING)) {**
**// first run**
} else if (initialState == RUNNABLE
&& compareAndSetState(RUNNABLE, RUNNING)) {
// consume parking permit
setParkPermit(false);
} else {
// not runnable
return;
}
// notify JVMTI before mount
notifyJvmtiMount(/*hide*/true);
try {
**cont.run();**
} finally {
if (cont.isDone()) {
afterTerminate();
} else {
afterYield();
}
}
}
从这一点开始,虚拟线程的状态可以被移动到TERMINATED(执行完成)、PARKING(遇到阻塞操作)或YIELDING(调用Thread.yield()的效果)。
PARKING
虚拟线程会一直运行,直到其任务完成或遇到阻塞操作。在这个时候,虚拟线程应该从平台线程(应该是挂起状态)卸载。为了完成这个任务,JVM 通过park()方法将虚拟线程的状态从RUNNING转换为PARKING。这是一个过渡状态,可以转换为PARKED(在堆上挂起)或PINNED(在其承载线程上挂起)。
PARKED/PINNED
此外,yieldContinuation() 从 park() 被调用,虚拟线程卸载的结果通过 Continuation.yield(VTHREAD_SCOPE) 返回的标志来表示。换句话说,如果卸载操作成功,则虚拟线程的状态从 PARKING 移动到 PARKED(虚拟线程已成功停放在堆上)。否则,如果卸载操作失败,则调用 parkOnCarrierThread() 方法,并将虚拟线程的状态移动到 PINNED(虚拟线程已停放在承载线程上)。当一个 PINNED 虚拟线程可以恢复执行时,它会被移动到 RUNNING 状态(因为它已停放在其承载线程上)。另一方面,当一个 PARKED 虚拟线程被停泊(或中断)时,它会被移动到 RUNNABLE 状态。在这种情况下,虚拟线程(未挂载)被挂载,并从上次停止的地方继续执行,通过将状态从 RUNNABLE 移动到 RUNNING。
YIELDING
当遇到 Thread.yield() 调用时,虚拟线程的状态从 RUNNING 移动到 YIELDING(例如,当我们调用 Thread.yield() 或 Thread.sleep(0) 时)。如果让步失败,则虚拟线程的状态会回到 RUNNING。否则,它会被移动到 RUNNABLE。
RUNNABLE
当一个虚拟线程未挂载但想要恢复其执行时,它处于 RUNNABLE 状态。它从 PARKED 或 YIELDING 状态进入此状态。此时,虚拟线程的状态从 RUNNABLE 移动到 RUNNING,并从上次停止的地方继续执行(这发生在 runContinuation() 中)。
TERMINATED
现在,闭环完成。虚拟线程完成其执行并进入 TERMINATED 状态。此外,无法启动的虚拟线程也会被移动到这个状态。
227. 扩展 StructuredTaskScope
我们不能扩展 StructuredTaskScope.ShutdownOnSuccess(第十章,问题 221)或 ShutdownOnFailure(第十章,问题 222),因为这些是 final 类。但是,我们可以扩展 StructuredTaskScope 并通过其 handleComplete() 方法提供自定义行为。
假设我们想要从我们当前的位置到我们镇上的一个特定目的地旅行:
String loc = "124 NW Bobcat L, St. Robert"; // from user
String dest = "129 West 81st Street"; // from user
在我们的手机上,我们有一个可以查询拼车服务和公共交通服务应用程序。拼车服务可以同时查询多个拼车服务器以找到最便宜的报价。另一方面,公共交通服务可以同时查询公共交通服务器以找到最早出发的报价,无论它是通过公共汽车、火车、电车还是地铁。在图中,我们可以将这些陈述表示如下:

图 11.5:查询拼车和公共交通服务
两个服务都是通过StructuredTaskScope实现的,但查询公共交通服务器的那个使用自定义的StructuredTaskScope,而查询拼车服务器的那个使用经典的StructuredTaskScope。
由于我们已经熟悉经典的StructuredTaskScope,让我们快速了解一下拼车服务。从这个服务收到的报价形状如下:
public record RidesharingOffer(String company, Duration
minutesToYou, Duration minutesToDest, double price) {}
我们代码的核心是开始为三个拼车服务器中的每一个分叉一个任务:
public static RidesharingOffer
fetchRidesharingOffers(String loc, String dest)
throws InterruptedException {
try (StructuredTaskScope scope
= new StructuredTaskScope<RidesharingOffer>()) {
Subtask<RidesharingOffer> carOneOffer
= scope.fork(() -> Ridesharing.carOneServer(loc, dest));
Subtask<RidesharingOffer> starCarOffer
= scope.fork(() -> Ridesharing.starCarServer(loc, dest));
Subtask<RidesharingOffer> topCarOffer
= scope.fork(() -> Ridesharing.topCarServer(loc, dest));
scope.join();
...
在scope.join()完成后,我们知道所有子任务都已成功完成或异常完成。我们过滤结果以提取最便宜的报价。如果没有报价可用,那么我们将收集所有异常并将它们封装在一个自定义的RidesharingException中。将此作为代码片段的函数式编程可以这样做:
RidesharingOffer offer
= Stream.of(carOneOffer, starCarOffer, topCarOffer)
.filter(s -> s.state() == Subtask.State.SUCCESS)
.<RidesharingOffer>mapMulti((s, c) -> {
c.accept((RidesharingOffer) s.get());
})
.min(Comparator.comparingDouble(RidesharingOffer::price))
.orElseThrow(() -> {
RidesharingException exceptionWrapper
= new RidesharingException("Ridesharing exception");
Stream.of(carOneOffer, starCarOffer, topCarOffer)
.filter(s -> s.state() == Subtask.State.FAILED)
.<Throwable>mapMulti((s, c) -> {
c.accept(s.exception());
}).forEach(exceptionWrapper::addSuppressed);
throw exceptionWrapper;
});
...
最后,我们返回报价:
return offer;
}
可能的输出如下:
RidesharingOffer[company=TopCar, minutesToYou=PT9M, minutesToDest=PT16M, price=7.62]
接下来,让我们专注于公共交通服务。该服务通过自定义的StructuredTaskScope查询公共交通服务器。一个公共交通报价被封装在以下记录中:
public record PublicTransportOffer(String transport,
String station, LocalTime goTime) {}
自定义的StructuredTaskScope命名为PublicTransportScope,其目标是分析每个子任务(Subtask)并获取最佳报价。扩展StructuredTaskScope很简单:
public class PublicTransportScope
extends StructuredTaskScope<List<PublicTransportOffer>> {
...
公共交通服务器返回一个List<PublicTransportOffer>。例如,一天中可能有三趟火车或五辆公交车覆盖我们的路线。我们将它们全部放在一个单独的列表中。
当我们扩展StructuredTaskScope时,我们必须重写一个名为handleComplete()的方法。这个方法会自动为每个成功完成或异常完成的Subtask调用。我们的任务是收集和存储结果以供后续分析。为了收集结果,我们需要一个用于有效结果的集合和一个用于异常结果的集合。这些应该是线程安全的集合,因为多个Subtask实例可能几乎同时完成,这会导致竞争条件。例如,我们可以使用CopyOnWriteArrayList:
private final List<List<PublicTransportOffer>> results
= new CopyOnWriteArrayList<>();
private final List<Throwable> exceptions
= new CopyOnWriteArrayList<>();
...
接下来,我们重写handleComplete(),并根据Subtask的状态相应地收集结果:
@Override
protected void handleComplete(
Subtask<? extends List<PublicTransportOffer>> subtask) {
switch (subtask.state()) {
case SUCCESS ->
results.add(subtask.get());
case FAILED ->
exceptions.add(subtask.exception());
case UNAVAILABLE ->
throw new IllegalStateException(
"Subtask may still running ...");
}
}
...
当我们到达这个点时,我们已经收集了所有成功和异常的结果。现在是时候分析这些数据并推荐最佳报价了。我们认为最佳报价是不论是通过公交车、火车、电车还是地铁,都能最早出发的报价。所以,我们只需要找到最佳的goTime:
public PublicTransportOffer recommendedPublicTransport() {
super.ensureOwnerAndJoined();
return results.stream()
.flatMap(t -> t.stream())
.min(Comparator.comparing(PublicTransportOffer::goTime))
.orElseThrow(this::wrappingExceptions);
}
...
如果我们找不到任何有效的报价,那么我们将收集异常并将它们封装在一个自定义的PublicTransportException中,如下面的辅助函数所示:
private PublicTransportException wrappingExceptions() {
super.ensureOwnerAndJoined();
PublicTransportException exceptionWrapper = new
PublicTransportException("Public transport exception");
exceptions.forEach(exceptionWrapper::addSuppressed);
return exceptionWrapper;
}
}
注意,这两个方法都在调用ensureOwnerAndJoined()方法。这个内置方法保证当前线程是该任务范围的拥有者(否则,它抛出WrongThreadException),并且在通过join()/joinUntil()分叉子任务后已经加入(否则,它抛出IllegalStateException)。
重要提示
按照惯例,对于需要由主任务调用的每个StructuredTaskScope,依赖ensureOwnerAndJoined()检查是一个好的实践。
完成!我们的自定义StructuredTaskScope已经准备好了。接下来,我们可以通过分叉任务并调用recommendedPublicTransport()方法来使用它:
public static PublicTransportOffer
fetchPublicTransportOffers(String loc, String dest)
throws InterruptedException {
try (PublicTransportScope scope
= new PublicTransportScope()) {
scope.fork(() -> PublicTransport
.busTransportServer(loc, dest));
scope.fork(() -> PublicTransport
.subwayTransportServer(loc, dest));
scope.fork(() -> PublicTransport
.trainTransportServer(loc, dest));
scope.fork(() -> PublicTransport
.tramTransportServer(loc, dest));
scope.join();
PublicTransportOffer offer
= scope.recommendedPublicTransport();
logger.info(offer.toString());
return offer;
}
}
可能的输出如下所示:
PublicTransportOffer[transport=Tram, station=Tram_station_0, goTime=10:26:39]
最后,我们可以如下调用这两个服务(共享出行和公共交通):
RidesharingOffer roffer
= fetchRidesharingOffers(loc, dest);
PublicTransportOffer ptoffer
= fetchPublicTransportOffers(loc, dest);
到目前为止,这两个服务是顺序运行的。在下一个问题中,我们将通过引入另一个自定义的StructuredTaskScope来同时运行这两个服务。在此之前,你可以挑战自己为共享出行服务也编写一个自定义的StructuredTaskScope。
228. 组装 StructuredTaskScope
在上一个问题(问题 227)中,我们开发了一个包含共享出行服务和公共交通服务的应用程序。在这两个服务中,我们使用了StructuredTaskScope来并发查询适当的服务器。然而,尽管这两个服务是顺序执行的,但服务器是并发调用的——首先运行共享出行服务(通过并发查询三个服务器),然后从这个服务得到结果后,再运行公共交通服务(通过并发查询四个服务器)。
进一步,我们希望将这些两个服务组装成一个第三服务,使其能够同时运行,如下面的图所示:

图 11.6:同时运行共享出行和公共交通服务
我们首先将RidesharingOffer和PublicTransportOffer组装成一个名为TravelOffer的记录:
public record TravelOffer(RidesharingOffer ridesharingOffer,
PublicTransportOffer publicTransportOffer) {}
接下来,我们编写一个自定义的StructuredTaskScope,它分叉了在问题 227中创建的两个Callable对象。一个Callable代表共享出行服务(已在问题 227中通过经典的StructuredTaskScope实现),第二个Callable代表公共交通服务(已在问题 227中通过自定义的PublicTransportScope实现)。我们可以将这个StructuredTaskScope命名为TravelScope:
public class TravelScope extends StructuredTaskScope<Travel> {
...
StructuredTaskScope是参数化的——注意StructuredTaskScope<Travel>。由于我们必须分叉不同类型的Callable实例,依赖Object并编写StructuredTaskScope<Object>会很有用。但这样不会非常清晰和整洁。我们最好定义一个接口来缩小Object域,并由我们的Callable实例的结果实现,如下所示(密封接口在第八章中详细讨论过):
public sealed interface Travel
permits RidesharingOffer, PublicTransportOffer {}
public record RidesharingOffer(String company,
Duration minutesToYou, Duration minutesToDest, double price)
implements Travel {}
public record PublicTransportOffer(String transport,
String station, LocalTime goTime) implements Travel {}
回到TravelScope,我们必须重写handleComplete()方法来处理每个完成的Subtask。我们知道共享出行服务可以返回一个有效的结果作为RidesharingOffer,或者一个异常的结果作为RidesharingException。此外,公共交通服务可以返回一个有效的结果作为PublicTransportOffer,或者一个异常的结果作为PublicTransportException。我们必须存储这些结果,以便在创建TravelOffer答案时稍后分析它们。因此,我们定义以下变量来覆盖所有可能的案例:
private volatile RidesharingOffer ridesharingOffer;
private volatile PublicTransportOffer publicTransportOffer;
private volatile RidesharingException ridesharingException;
private volatile PublicTransportException
publicTransportException;
...
接下来,我们重写handleComplete()方法,正如在PublicTransportScope的情况中,我们依赖于一个简单的switch来收集结果(对于SUCCESS和FAILED状态,我们需要一个嵌套的switch来区分从共享出行服务收到的报价/异常和从公共交通服务收到的报价/异常):
@Override
protected void handleComplete(
Subtask<? extends Travel> subtask) {
switch (subtask.state()) {
case SUCCESS -> {
switch (subtask.get()) {
case RidesharingOffer ro ->
this.ridesharingOffer = ro;
case PublicTransportOffer pto ->
this.publicTransportOffer = pto;
}
}
case FAILED -> {
switch (subtask.exception()) {
case RidesharingException re ->
this.ridesharingException = re;
case PublicTransportException pte ->
this.publicTransportException = pte;
case Throwable t ->
throw new RuntimeException(t);
}
}
case UNAVAILABLE ->
throw new IllegalStateException(
"Subtask may still running ...");
}
}
...
最后,我们分析这些结果并创建适当的TravelOffer。实现这一目标的一种方法如下(请随意思考更酷/更聪明的实现方式):
public TravelOffer recommendedTravelOffer() {
super.ensureOwnerAndJoined();
return new TravelOffer(
ridesharingOffer, publicTransportOffer);
}
}
我们的TravelScope已经准备好使用。我们所需做的只是将我们的两个服务并行执行,并调用recommendedTravelOffer()方法以获取最佳报价:
public static TravelOffer fetchTravelOffers(
String loc, String dest)
throws InterruptedException {
try (TravelScope scope = new TravelScope()) {
scope.fork(() -> fetchRidesharingOffers(loc, dest));
scope.fork(() -> fetchPublicTransportOffers(loc, dest));
scope.join();
return scope.recommendedTravelOffer();
}
}
现在,我们不再依次调用fetchRidesharingOffers()和fetchPublicTransportOffers(),而是简单地调用fetchTravelOffers():
TravelOffer toffer = fetchTravelOffers(loc, dest);
可能的输出如下:
TravelOffer[
ridesharingOffer=RidesharingOffer[company=CarOne,
minutesToYou=PT5M, minutesToDest=PT5M, price=3.0],
publicTransportOffer=PublicTransportOffer[transport=Train,
station=Train_station_0, goTime=11:59:10]
]
任务完成!现在你了解了如何编写自定义的StructuredTaskScope实例,以及如何组装/嵌套它们来构建复杂的并发模型。
229. 组装带超时的 StructuredTaskScope 实例
让我们继续从问题 228开始,假设共享出行服务应该实现超时/截止日期。换句话说,如果任何一个共享出行服务器在 10 毫秒内没有回答,那么我们终止请求并通过一个有意义的消息向用户报告抛出的TimeoutException。
这意味着我们应该使用joinUntil(Instant deadline)而不是scope.join(),后者会无限期等待,它只等待给定的deadline,在抛出TimeoutException之前。因此,fetchRidesharingOffers()方法应该修改如下:
public static RidesharingOffer fetchRidesharingOffers(
String loc, String dest)
throws InterruptedException, TimeoutException {
try (StructuredTaskScope scope
= new StructuredTaskScope<RidesharingOffer>()) {
...
scope.joinUntil(Instant.now().plusMillis(10));
...
}
}
通过在任何一个共享出行服务器中简单地模拟大于 10 毫秒的延迟,我们帮助joinUntil()失败并抛出TimeoutException:
public final class Ridesharing {
public static RidesharingOffer carOneServer(
String loc, String dest) throws InterruptedException {
...
Thread.sleep(100); // simulating a delay
...
}
...
}
为了捕获这个TimeoutException并将其替换为对最终用户友好的消息,我们必须调整TravelScope类。首先,我们定义一个变量来存储潜在的TimeoutException。其次,我们调整case FAILED来相应地填充这个变量:
public class TravelScope extends StructuredTaskScope<Travel> {
...
private volatile TimeoutException timeoutException;
@Override
protected void handleComplete(Subtask<? extends Travel> subtask) {
switch (subtask.state()) {
...
case FAILED -> {
switch (subtask.exception()) {
...
case TimeoutException te ->
this.timeoutException = te;
...
}
}
...
}
}
... // the recommendedTravelOffer() method from below
}
第三,我们修改recommendedTravelOffer()方法,如果timeoutException不为null,则返回一个友好的消息:
public TravelOffer recommendedTravelOffer() {
super.ensureOwnerAndJoined();
if (timeoutException != null) {
logger.warning("Some of the called services
did not respond in time");
}
return new TravelOffer(
ridesharingOffer, publicTransportOffer);
}
如果共享出行服务超时,而公共交通服务提供了一个报价,那么输出应该是这样的:
[14:53:34] [WARNING] Some of the called services
did not respond in time
[14:53:35] [INFO] TravelOffer[ridesharingOffer=null, publicTransportOffer=PublicTransportOffer[transport=Bus, station=Bus_station_2, goTime=15:02:34]]
完成!查看捆绑的代码以练习此示例。挑战自己为公共交通服务添加超时/截止日期。
230. 钩子 ThreadLocal 和虚拟线程
简而言之,ThreadLocal是在 JDK 1.2(1998 年)中引入的,作为一种为每个线程提供专用内存的解决方案,以便与不受信任的代码(可能是一些外部编写的第三方组件)或应用程序的不同组件(可能在多个线程中运行)之间共享信息。基本上,如果你处于这种场景,那么你不想(或不能)通过方法参数来共享信息。如果你需要更深入地了解ThreadLocal API,请考虑《Java 编码问题》第一版,第十一章,问题 220。
线程局部变量是ThreadLocal类型,依赖于set()来设置值和get()来获取值。在《Java 编码问题》第一版中提到:“如果线程 A 存储了 x 值,而线程 B 在同一个 ThreadLocal 实例中存储了 y 值,那么稍后,线程 A 检索到 x 值,线程 B 检索到 y 值。因此,我们可以这样说,x* 值是线程 A 的局部值,而y* 值是线程 B 的局部值。” 调用get()或set()的每个线程都有自己的ThreadLocal变量副本。
重要提示
内部,ThreadLocal管理一个映射(ThreadLocalMap)。映射的键是线程,值是通过set()方法给出的值。ThreadLocal变量非常适合实现每个请求一个线程模型(例如,每个 HTTP 请求一个线程),因为它们允许我们轻松管理请求的生命周期。
通常,线程局部变量是全局和静态的,它可以如下声明和初始化:
private static final ThreadLocal<StringBuilder> threadLocal
= ThreadLocal.<StringBuilder>withInitial(() -> {
return new StringBuilder("Nothing here ...");
});
线程局部变量应该可以从需要它的代码中访问到(有时是从代码的任何地方)。基本上,当前线程以及由该线程派生的所有线程都应该能够访问线程局部变量。在我们的情况下,它可以从当前类的任何地方访问。接下来,让我们考虑以下Runnable:
Runnable task = () -> {
threadLocal.set(
new StringBuilder(Thread.currentThread().toString()));
logger.info(() -> " before sleep -> "
+ Thread.currentThread().toString()
+ " [" + threadLocal.get() + "]");
try {
Thread.sleep(Duration.ofSeconds(new Random().nextInt(5)));
} catch (InterruptedException ex) {}
logger.info(() -> " after sleep -> "
+ Thread.currentThread().toString()
+ " [" + threadLocal.get() + "]");
threadLocal.remove();
};
在这里,我们设置了一个表示当前线程信息的线程局部值,我们获取并记录该值,然后随机睡眠几秒钟(0 到 5 秒之间),然后再次记录该值。
接下来,让我们通过经典固定线程池(平台线程)执行 10 个任务:
try (ExecutorService executor
= Executors.newFixedThreadPool(10)) {
for (int i = 0; i < 10; i++) {
executor.submit(task);
}
}
look as follows:
[16:14:05] before sleep -> Thread[#24,pool-1-thread-3,5,main] [Thread[#24,pool-1-thread-3,5,main]]
[16:14:05] before sleep -> Thread[#31,pool-1-thread-10,5,main] [Thread[#31,pool-1-thread-10,5,main]]
[16:14:05] before sleep -> Thread[#22,pool-1-thread-1,5,main] [Thread[#22,pool-1-thread-1,5,main]]
...
[16:14:06] after sleep -> Thread[#24,pool-1-thread-3,5,main] [Thread[#24,pool-1-thread-3,5,main]]
[16:14:07] after sleep -> Thread[#31,pool-1-thread-10,5,main] [Thread[#31,pool-1-thread-10,5,main]]
[16:14:09] after sleep -> Thread[#22,pool-1-thread-1,5,main] [Thread[#22,pool-1-thread-1,5,main]]
...
我们可以很容易地看到线程#24、#31 和#22 各自设置了关于它们自己的信息,并且这些信息在睡眠后可用。例如,线程#22 设置了值[Thread[#22,pool-1-thread-1,5,main]],而这个值正是它在睡眠 4 秒后得到的结果。
现在,让我们切换到虚拟线程:
try (ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10; i++) {
executor.submit(task);
}
}
输出将如下:
[16:24:24] before sleep ->VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3 [VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3]
[16:24:24] before sleep ->VirtualThread[#27]/runnable@ForkJoinPool-1-worker-5 [VirtualThread[#27]/runnable@ForkJoinPool-1-worker-5]
[16:24:24] before sleep ->VirtualThread[#28]/runnable@ForkJoinPool-1-worker-6 [VirtualThread[#28]/runnable@ForkJoinPool-1-worker-6]
...
[16:24:24] after sleep ->VirtualThread[#28]/runnable@ForkJoinPool-1-worker-3 [VirtualThread[#28]/runnable@ForkJoinPool-1-worker-6]
[16:24:27] after sleep ->VirtualThread[#25]/runnable@ForkJoinPool-1-worker-4 [VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3]
[16:24:27] after sleep ->VirtualThread[#27]/runnable@ForkJoinPool-1-worker-8 [VirtualThread[#27]/runnable@ForkJoinPool-1-worker-5]
...
我们可以很容易地看到,线程 #25、#27 和 #28 每个都设置了关于它们自己的信息,并且这个信息在睡眠期之后是可用的。例如,线程 #25 设置了值 [VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3],这正是它在睡眠 3 秒后获取的值。
然而,当我们获取这个信息时,线程 #25 在 worker-4 上执行,而不是在 worker-3 上,正如信息所揭示的那样。实际上,当信息被设置时,线程 #25 在 worker-3 上执行,而当信息(保持不变)被获取时,它在 worker-4 上执行。
这是完全正常的,因为当执行遇到 Thread.sleep() 阻塞操作时,线程从 worker-3 上卸载。睡眠后,它被安装到 worker-4 上。然而,信息没有被更改,所以虚拟线程和 ThreadLocal 正如预期的那样一起工作。换句话说,虚拟线程的安装-卸载周期不会影响 ThreadLocal 的工作方式。虚拟线程完全支持 ThreadLocal 变量。
231. 将 ScopedValue 和虚拟线程挂钩
ScopedValue API 被添加来处理 ThreadLocal 的不足之处。但 ThreadLocal 的不足之处是什么?
线程局部变量的不足之处
首先,很难说和追踪谁在修改线程局部变量。这是 API 设计的不足之处。基本上,ThreadLocal 变量在全局范围内可用(在应用级别或更低级别),因此很难说它从哪里被修改。想象一下,你有责任阅读、理解和调试一个使用多个线程局部变量的应用程序。你将如何管理代码逻辑,你将如何知道在任何给定时间,这些线程局部变量存储了什么值?从类到类跟踪这些变量并将它们修改的信号将会是一场噩梦。
其次,线程局部变量可能永远存在,或者比它们应该存在的时间更长。这是如何可能的?线程局部变量将一直存在,直到使用它们的平台线程存在,甚至更长。确实,我们可以通过显式调用 remove() 来从内部映射中删除线程局部变量。但是,如果我们忘记调用 remove(),那么我们就打开了内存泄漏的大门(我们只是希望垃圾收集器在某个时候收集这些数据)。永远不要忘记在完成使用平台线程的线程局部变量后调用 remove()!另一方面,如果你在使用虚拟线程的线程局部变量,那么就没有必要调用 remove(),因为一旦虚拟线程死亡,线程局部变量就会被删除。
第三,线程局部变量容易重复。当我们从当前线程(父线程)创建一个新的线程(子线程)时,子线程会复制父线程的所有线程局部变量。因此,如果我们从具有大量线程局部变量的当前线程中产生多个线程,那么我们将复制大量这些变量。这对于平台线程和虚拟线程都是如此。由于线程局部变量不是不可变的,我们无法简单地在线程之间共享引用。我们必须复制它们。当然,这对应用程序来说可能不是好事,因为它将负面地影响这些变量的内存占用(想象一下有百万个虚拟线程都有线程局部变量的副本)。
介绍范围值
从 JDK 20(JEP 429)开始,我们有一个名为“范围值”的线程局部变量的替代方案。这是为了与虚拟线程一起工作,并克服线程局部变量的不足。在 JDK 20 中,这个特性处于孵化阶段,在 JDK 21(JEP 446)中处于预览阶段,所以不要忘记使用–code-preview VM 选项运行代码。
范围值允许我们在应用程序的组件之间共享不可变信息(无需复制),并且具有有限的生存期(没有内存泄漏的风险)。
正如您将看到的,ScopedValue API 非常整洁且易于使用。要创建一个ScopedValue,我们只需调用一个名为newInstance()的工厂方法,如下所示:
ScopedValue<String> SCOPED_VALUE = ScopedValue.newInstance();
在这里,我们创建了一个ScopedValue(未绑定),它能够携带类型为String的值(当然,它可以是任何其他类型)。您可以根据它应该从哪些位置访问来本地声明、全局声明或按需声明。然而,映射到ScopedValue的值仅对当前线程及其由当前线程产生的所有线程(到目前为止,这类似于ThreadLocal)可用,但它仅限于方法调用。我们将在稍后澄清这一点。
重要提示
如果一个值映射到ScopedValue,则认为ScopedValue已绑定。否则,认为ScopedValue未绑定。我们可以通过isBound()标志方法检查ScopedValue是否已绑定。这是一个重要的检查,因为如果我们尝试获取未绑定的ScopedValue的值,那么我们将得到NoSuchElementException。除了isBound()之外,我们还有orElse()和orElseThrow()。使用orElse(),我们可以为未绑定的ScopedValue设置一个默认值,而通过orElseThrow(),我们可以抛出一个默认异常。
一个值可以通过where()方法映射到一个ScopedValue(因此,ScopedValue与一个值绑定)。这个方法的语法如下:
public static <T> Carrier where(ScopedValue<T> key, T value)
或者,通过runWhere()和callWhere()方法:
public static <T> void runWhere(
ScopedValue<T> key, T value, Runnable op)
public static <T,R> R callWhere(
ScopedValue<T> key, T value, Callable<? extends R> op)
throws Exception
这三个方法有共同点,即key和value参数。key代表ScopedValue的键(例如,SCOPED_VALUE),而value是映射到这个键的值。而where()方法只是创建一个绑定到值的ScopedValue,而runWhere()方法可以创建一个绑定到值的ScopedValue并在当前线程中调用Runnable操作(op),而callWhere()在当前线程中调用Callable操作(op)。如果您只想依赖where()方法,那么只需依赖以下语法:
ScopedValue.where(key, value).run(op); // like runWhere()
ScopedValue.where(key, value).call(op); // like callWhere()
因此,链式调用where().run()相当于runWhere(),而where().call()相当于callWhere()。使用带有run()/call()后缀的where()的优点在于我们可以编写ScopedValue.where(key1, value1).where(key2, value2), ….run()/call()来获取多个绑定到其值的ScopedValue实例。
重要提示
ScopedValue没有set()方法。一旦我们将值映射到ScopedValue,我们就不能更改它(它是不可变的)。这意味着 JVM 不需要在值之间复制(记住,这是一个特定于ThreadLocal的缺点)。
值仅在该方法(Runnable或Callable)调用期间映射到该键。例如,让我们假设以下Runnable:
Runnable taskr = () -> {
logger.info(Thread.currentThread().toString());
logger.info(() -> SCOPED_VALUE.isBound() ?
SCOPED_VALUE.get() : "Not bound");
};
通过isBound()方法,我们可以检查一个ScopedValue是否已绑定(如果它有一个值)。如果存在值,那么我们可以通过get()方法成功访问它。对一个未绑定的ScopedValue调用get()将导致NoSuchElementException异常。例如,如果我们现在运行这个任务:
taskr.run();
然后,输出将是:
Thread[#1,main,5,main]
Not bound
这是正常的,因为我们没有将任何值映射到SCOPED_VALUE。
接下来,我们可以使用where()方法将一个值映射到SCOPED_VALUE并与之前的Runnable共享这个值:
Carrier cr = ScopedValue.where(SCOPED_VALUE, "Kaboooom!");
cr.run(taskr);
Carrier对象是一个不可变且线程安全的键值映射累加器,可以与Runnable/Callable共享。通过调用cr.run(taskr),我们将值Kaboooom!与Runnable共享,因此输出将是:
Thread[#1,main,5,main]
Kaboooom!
但我们可以将这个例子写得更紧凑一些,如下所示:
ScopedValue.where(SCOPED_VALUE, "Kaboooom!").run(taskr);
或者,使用runWhere():
ScopedValue.runWhere(SCOPED_VALUE, "Kaboooom!", taskr);
调用taskr.run()将再次输出Not bound。这是因为在方法调用期间ScopedValue才被绑定。图 11.7通过一个更具有表达性的例子突出了这一点。

图 11.7:ThreadLocal 与 ScopedValue
如此图(左侧所示)所示,一旦线程局部变量设置了值Mike,这个值在sayHelloTL()和sayGoodByeTL()中都是可用的。这个值绑定到这个线程上。另一方面(右侧),值Mike被映射到一个ScopedValue,但这个值只在sayHelloSV()中可用。这是因为我们只将SCOPED_VALUE绑定到sayHelloSV()方法调用。当执行离开sayHelloSV()时,SCOPED_VALUE未绑定,值Mike不再可用。如果从sayHelloSV()调用sayGoodByeSV(),那么值Mike将是可用的。或者,如果我们如下调用sayGoodByeSV(),那么值Mike也是可用的:
ScopedValue.where(SCOPED_VALUE, "Mike").run(
() -> sayGoodByeSV());
ScopedValue也与Callable一起工作,但我们必须将run()替换为call()。例如,让我们假设以下相当简单的Callable:
Callable<Boolean> taskc = () -> {
logger.info(Thread.currentThread().toString());
logger.info(() -> SCOPED_VALUE.isBound() ?
SCOPED_VALUE.get() : "Not bound");
return true;
};
以及以下调用序列:
taskc.call();
ScopedValue.where(SCOPED_VALUE, "Kaboooom-1!").call(taskc);
ScopedValue.callWhere(SCOPED_VALUE, "Kaboooom-2!", taskc);
Carrier cc = ScopedValue.where(SCOPED_VALUE, "Kaboooom-3!");
cc.call(taskc);
taskc.call();
你能直观地推断出输出结果吗?应该是未绑定,Kaboooom-1!,Kaboooom-2!,Kaboooom-3!,然后再一次未绑定:
Thread[#1,main,5,main]
Not bound
Thread[#1,main,5,main]
Kaboooom-1!
Thread[#1,main,5,main]
Kaboooom-2!
Thread[#1,main,5,main]
Kaboooom-3!
Thread[#1,main,5,main]
Not bound
因此,让我再次强调这一点。ScopedValue在方法调用生命周期内绑定(它有一个值)。isBound()将在该方法外部返回false。
重要提示
如此例所示,一个ScopedValue在同一个线程中可以有不同的值。在这里,同一个Callable在同一线程中执行了五次。
从某个线程(除了主线程)设置ScopedValue可以非常容易地完成。例如,我们可以如下从平台线程设置ScopedValue:
Thread tpr = new Thread(() ->
ScopedValue.where(SCOPED_VALUE, "Kaboooom-r!").run(taskr));
Thread tpc = new Thread(() -> {
try {
ScopedValue.where(SCOPED_VALUE, "Kaboooom-c!").call(taskc);
} catch (Exception ex) { /* handle exception */ }
});
或者,通过ofPlatform()方法如下:
Thread tpr = Thread.ofPlatform().unstarted(
() -> ScopedValue.where(SCOPED_VALUE, "Kaboooom-r!")
.run(taskr));
Thread tpc = Thread.ofPlatform().unstarted(()-> {
try {
ScopedValue.where(SCOPED_VALUE, "Kaboooom-c!").call(taskc);
} catch (Exception ex) { /* handle exception */ }
});
从某个虚拟线程映射一个ScopedValue可以按照以下方式完成:
Thread tvr = Thread.ofVirtual().unstarted(
() -> ScopedValue.where(SCOPED_VALUE, "Kaboooom-r!")
.run(taskr));
Thread tvc = Thread.ofVirtual().unstarted(() -> {
try {
ScopedValue.where(SCOPED_VALUE, "Kaboooom-c!").call(taskc);
} catch (Exception ex) { /* handle exception */ }
});
这里,我们有两个线程,每个线程都将不同的值映射到SCOPED_VALUE:
Thread tpcx = new Thread(() ->
ScopedValue.where(SCOPED_VALUE, "Kaboooom-tpcx!")
.run(taskr));
Thread tpcy = new Thread(() ->
ScopedValue.where(SCOPED_VALUE, "Kaboooom-tpcy!")
.run(taskr));
因此,第一个线程(tpcx)映射的值是Kaboooom-tpcx!,而第二个线程(tpcy)映射的值是Kaboooom-tpcy!。当taskr由tpcx执行时,映射的值将是Kaboooom-tpcx!,而当taskr由tpcy执行时,映射的值将是Kaboooom-tpcy!。
这里是一个例子,tpca映射的值是Kaboooom-tpca!,而tpcb没有映射任何值:
Thread tpca = new Thread(() ->
ScopedValue.where(SCOPED_VALUE, "Kaboooom-tpca!")
.run(taskr));
Thread tpcb = new Thread(taskr);
请确保不要从这个结论中得出ScopedValue绑定到特定线程的结论。以下说明应该可以澄清这一点。
重要提示
ScopedValue绑定到特定的方法调用,而不是特定的线程(如ThreadLocal的情况)。换句话说,如果一个方法调用的代码将其映射,那么该方法可以获取ScopedValue的值。数据/值只能以这种方式传递:从调用者到被调用者。否则,ScopedValue未绑定,不能在当前方法上下文中绑定和使用。与ThreadLocal的情况类似,ScopedValue会被传递(并且可用)给在当前ScopedValue上下文中执行的任务所创建的所有线程。
除了isBound()方法外,ScopedValue还有一个orElse()和orElseThrow()。通过orElse(),当ScopedValue未绑定时,我们可以指定一个替代/默认值,而通过orElseThrow(),我们可以抛出一个默认异常。以下是一个使用这些方法的两个Runnable对象的示例:
Runnable taskr1 = () -> {
logger.info(Thread.currentThread().toString());
logger.info(() -> SCOPED_VALUE.orElse("Not bound"));
};
Runnable taskr2 = () -> {
logger.info(Thread.currentThread().toString());
logger.info(() -> SCOPED_VALUE.orElseThrow(() ->
new RuntimeException("Not bound")));
};
当然,我们也可以在Runnable/Callable之外使用这些方法。以下是一个示例:
Runnable taskr = () -> {
logger.info(Thread.currentThread().toString());
logger.info(() -> SCOPED_VALUE.get());
};
Thread.ofVirtual().start(() -> ScopedValue.runWhere(
SCOPED_VALUE, SCOPED_VALUE.orElse("Kaboooom"), taskr))
.join();
Thread.ofVirtual().start(() -> ScopedValue.runWhere(
SCOPED_VALUE, SCOPED_VALUE.orElseThrow(() ->
new RuntimeException("Not bound")), taskr)).join();
在第一个虚拟线程中,我们依赖orElse()来映射SCOPED_VALUE的值,因此taskr中的SCOPED_VALUE.get()将返回Kaboooom值。在第二个虚拟线程中,我们依赖orElseThrow(),因此由于会抛出RuntimeException,taskr将不会执行。
在接下来的问题中,我们将解决作用域值的更多方面。
232. 使用 ScopedValue 和执行器服务
在问题 230中,我们编写了一个结合ThreadLocal和执行器服务(我们使用了newVirtualThreadPerTaskExecutor()和newFixedThreadPool())的应用程序。
在这个问题中,我们重新编写了问题 230中的代码,以便使用ScopedValue。首先,我们有以下Runnable:
Runnable task = () -> {
logger.info(() -> Thread.currentThread().toString()
+ " | before sleep | " + (SCOPED_VALUE.isBound()
? SCOPED_VALUE.get() : "Not bound"));
try {
Thread.sleep(Duration.ofSeconds(new Random().nextInt(5)));
} catch (InterruptedException ex) {}
logger.info(() -> Thread.currentThread().toString()
+ " | after sleep | " + (SCOPED_VALUE.isBound()
? SCOPED_VALUE.get() : "Not bound"));
};
这段代码很简单。我们检索映射到SCOPED_VALUE的值,然后从 0 到 5 秒的随机时间数内休眠,再次检索映射到SCOPED_VALUE的值。接下来,让我们通过newFixedThreadPool()运行这段代码:
try (ExecutorService executor
= Executors.newFixedThreadPool(10)) {
for (int i = 0; i < 10; i++) {
int copy_i = i;
executor.submit(() -> ScopedValue.where(
SCOPED_VALUE, "Kaboooom-" + copy_i).run(task));
}
}
因此,我们有 10 个平台线程和 10 个任务。每个线程将值Kaboooom-I映射到SCOPED_VALUE并调用Runnable。可能的输出如下:
Thread[#30,pool-1-thread-9,5,main] | before sleep | Kaboooom-8
Thread[#24,pool-1-thread-3,5,main] | before sleep | Kaboooom-2
Thread[#27,pool-1-thread-6,5,main] | before sleep | Kaboooom-5
...
Thread[#30,pool-1-thread-9,5,main] | after sleep | Kaboooom-8
Thread[#27,pool-1-thread-6,5,main] | after sleep | Kaboooom-5
Thread[#24,pool-1-thread-3,5,main] | after sleep | Kaboooom-2
...
让我们随意检查线程#27。在休眠之前,这个线程看到的作用域值是Kabooom-2。休眠后,线程#27 看到的仍然是相同的值,Kabooom-2。每个平台线程都会看到在创建线程和提交任务时映射的作用域值。因此,我们具有与使用ThreadLocal相同的行为。
接下来,让我们切换到newVirtualThreadPerTaskExecutor():
try (ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor()) {
...
}
现在,可能的输出如下:
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
| before sleep | Kaboooom-0
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3
| before sleep | Kaboooom-2
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-5
| before sleep | Kaboooom-4
...
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
| after sleep | Kaboooom-0
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-1
| after sleep | Kaboooom-2
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-7
| after sleep | Kaboooom-4
...
再次强调,每个虚拟线程都会看到在创建线程和提交任务时映射的作用域值。唯一的区别是虚拟线程在休眠前后运行在不同的工作者上。
因此,我们可以依赖ScopedValue而不是ThreadLocal,并利用这个 API 带来的所有好处(参见问题 231),与ThreadLocal相比。
233. 链式和重新绑定作用域值
在这个问题中,您将看到如何链式和重新绑定作用域内的值。这些操作非常实用,您会喜欢使用。
更改作用域值
假设我们有三个ScopedValue实例,如下所示:
private static final ScopedValue<String> SCOPED_VALUE_1
= ScopedValue.newInstance();
private static final ScopedValue<String> SCOPED_VALUE_2
= ScopedValue.newInstance();
private static final ScopedValue<String> SCOPED_VALUE_3
= ScopedValue.newInstance();
我们还有一个使用所有三个ScopedValue实例的Runnable:
Runnable task = () -> {
logger.info(Thread.currentThread().toString());
logger.info(() -> SCOPED_VALUE_1.isBound()
? SCOPED_VALUE_1.get() : "Not bound");
logger.info(() -> SCOPED_VALUE_2.isBound()
? SCOPED_VALUE_2.get() : "Not bound");
logger.info(() -> SCOPED_VALUE_3.isBound()
? SCOPED_VALUE_3.get() : "Not bound");
};
我们可以通过简单地链式调用where()来将这些值映射到这三个ScopedValue实例。这是一种非常方便的设置多个作用域值的方法:
ScopedValue.where(SCOPED_VALUE_1, "Kaboooom - 1")
.where(SCOPED_VALUE_2, "Kaboooom - 2")
.where(SCOPED_VALUE_3, "Kaboooom - 3")
.run(task);
就这些!非常简单!
重新绑定作用域内的值
让我们设想我们有两个Runnable对象,taskA和taskB。我们从taskB开始,它很简单:
Runnable taskB = () -> {
logger.info(() -> "taskB:"
+ Thread.currentThread().toString());
logger.info(() -> SCOPED_VALUE_1.isBound()
? SCOPED_VALUE_1.get() : "Not bound");
logger.info(() -> SCOPED_VALUE_2.isBound()
? SCOPED_VALUE_2.get() : "Not bound");
logger.info(() -> SCOPED_VALUE_3.isBound()
? SCOPED_VALUE_3.get() : "Not bound");
};
因此,taskB简单地记录了三个ScopedValue实例。接下来,taskA只需要SCOPED_VALUE_1,但它还必须调用taskB。所以,taskA应该为SCOPED_VALUE_2和SCOPED_VALUE_3映射适当的值。那么SCOPED_VALUE_1怎么办呢?嗯,taskA不想将SCOPED_VALUE_1的当前值传递给taskB,所以它必须按照以下方式重新绑定这个作用域内的值:
Runnable taskA = () -> {
logger.info(() -> "taskA: "
+ Thread.currentThread().toString());
logger.info(() -> SCOPED_VALUE_1.isBound()
? SCOPED_VALUE_1.get() : "Not bound");
ScopedValue.where(SCOPED_VALUE_1, "No kaboooom") // rebind
.where(SCOPED_VALUE_2, "Kaboooom - 2")
.where(SCOPED_VALUE_3, "Kaboooom - 3")
.run(taskB);
logger.info(() -> SCOPED_VALUE_1.isBound()
? SCOPED_VALUE_1.get() : "Not bound");
logger.info(() -> SCOPED_VALUE_2.isBound()
? SCOPED_VALUE_2.get() : "Not bound");
logger.info(() -> SCOPED_VALUE_3.isBound()
? SCOPED_VALUE_3.get() : "Not bound");
};
调用taskA只为SCOPED_VALUE_1映射一个值:
ScopedValue.where(SCOPED_VALUE_1, "Kaboooom - 1").run(taskA);
输出将如下所示(注释是手动添加的;它们不是输出的一部分):
taskA: Thread[#1,main,5,main]
Kaboooom - 1
taskB: Thread[#1,main,5,main]
No kaboooom // this is the rebinded value
Kaboooom - 2
Kaboooom - 3
Kaboooom– 1 // back in taskA
Not bound
Not bound
因此,taskA看到SCOPED_VALUE_1的值为Kabooom-1,但它没有将这个值传递给taskB。它将这个作用域内的值重新绑定到No kaboooom。这就是在taskB旁边与Kaboooom -2和Kaboooom -3一起出现的值,这些值已经映射到SCOPED_VALUE_2和SCOPED_VALUE_3。如果你不想让某个作用域内的值超出你的目标,或者你只需要另一个值,这个技术是有用的。一旦执行回到taskA,SCOPED_VALUE_1就恢复到Kaboooom - 1,所以初始值不会丢失,并且在taskA中可用。另一方面,SCOPED_VALUE_2和SCOPED_VALUE_3没有被绑定。它们只为taskB的执行而绑定。这有多酷?!
234. 使用 ScopedValue 和 StructuredTaskScope
在这个问题中,我们将重复使用在问题 227和228中开发的程序,并为其添加一些ScopedValue变量以实现新功能。我将假设你已经熟悉这个应用程序。
我们计划添加的ScopedValue列表如下(这些是在主类中添加的,因为我们希望它们在应用级别可访问):
public static final ScopedValue<String> USER
= ScopedValue.newInstance();
public static final ScopedValue<String> LOC
= ScopedValue.newInstance();
public static final ScopedValue<String> DEST
= ScopedValue.newInstance();
public static final ScopedValue<Double> CAR_ONE_DISCOUNT
= ScopedValue.newInstance();
public static final ScopedValue<Boolean>
PUBLIC_TRANSPORT_TICKET = ScopedValue.newInstance();
首先,让我们关注fetchTravelOffers()方法,这是我们分叉两个任务fetchRidesharingOffers()和fetchPublicTransportOffers()的点。调用fetchTravelOffers()的代码被修改如下:
TravelOffer offer;
if (user != null && !user.isBlank()) { // is user logged in ?
offer = ScopedValue.where(USER, user)
.call(() -> fetchTravelOffers(loc, dest));
} else {
offer = fetchTravelOffers(loc, dest);
}
因此,我们的旅行页面需要用户凭据(为了简单起见,只有用户名)。如果用户已登录,那么我们应该有一个有效的用户名,并且我们可以通过USER作用域值与fetchTravelOffers()共享它。如果用户未登录,则USER保持未绑定状态。fetchTravelOffers()被修改如下:
public static TravelOffer fetchTravelOffers(
String loc, String dest) throws Exception {
return ScopedValue
.where(LOC, loc)
.where(DEST, dest)
.call(() -> {
try (TravelScope scope = new TravelScope()) {
if (USER.isBound()) {
scope.fork(() -> fetchRidesharingOffers());
} else {
logger.warning("Ridesharing services can be
accessed only by login users");
}
scope.fork(() ->
ScopedValue.where(PUBLIC_TRANSPORT_TICKET, true)
.call(Main::fetchPublicTransportOffers));
scope.join();
return scope.recommendedTravelOffer();
}
});
}
这段代码中发生了很多事情,所以让我们逐个来看。
共享乘车服务仅对已登录用户可用,所以我们只有在USER被绑定时才调用它。否则,我们为用户记录一条消息:
if (USER.isBound()) {
scope.fork(() -> fetchRidesharingOffers());
} else {
logger.warning("Ridesharing services can be
accessed only by login users");
另一方面,公共交通服务不需要用户登录。然而,为了使用公共交通,我们需要一张特殊的票。我们有一张这样的票,并通过PUBLIC_TRANSPORT_TICKET作用域值与公共交通服务共享:
scope.fork(() ->
ScopedValue.where(PUBLIC_TRANSPORT_TICKET, true)
.call(Main::fetchPublicTransportOffers));
PUBLIC_TRANSPORT_TICKET作用域值仅可以从公共交通服务(仅从fetchPublicTransportOffers()和其他从该函数调用的方法)访问。
接下来,拼车和公共交通服务需要我们的位置和目的地信息。这些信息从用户/客户端收集,并通过fetchTravelOffers()作为参数传递。然后,我们将这些信息映射到LOC和DEST作用域值:
return ScopedValue
.where(LOC, loc)
.where(DEST, dest)
.call(() -> {
...
});
现在,LOC和DEST已经绑定,并且只能从拼车和公共交通服务中访问。它们将与从这些服务分叉的所有线程共享。
接下来,让我们检查拼车服务,fetchRidesharingOffers()。这项服务检查用户是否已登录,并记录一条有意义的消息:
public static RidesharingOffer fetchRidesharingOffers()
throws InterruptedException, Exception {
logger.info(() -> "Ridesharing: Processing request for "
+ USER.orElseThrow(() -> new RuntimeException(
"Ridesharing: User not login")));
...
}
拼车公司之一(CarOne)向其客户提供随机折扣。我们有一个 0.5 的折扣可以映射到CAR_ONE_DISCOUNT作用域值:
Subtask<RidesharingOffer> carOneOffer
= scope.fork(() -> ScopedValue.where(CAR_ONE_DISCOUNT, 0.5)
.call(Ridesharing::carOneServer));
如果我们在fetchRidesharingOffers()的上下文中查看作用域值状态,那么我们可以这样说,USER作用域值是在应用级别绑定的,因此它应该在应用中的任何地方都是可用的。LOC和DEST作用域值已经在fetchTravelOffers()中绑定,所以它们在fetchRidesharingOffers()中也是可用的。另一方面,PUBLIC_TRANSPORT_TICKET在fetchRidesharingOffers()中不可用(未绑定)。
接下来,让我们专注于公共交通服务,fetchPublicTransportOffers()。这项服务不需要用户登录,但它可以使用这些信息记录一条友好的消息,如下所示:
public static PublicTransportOffer
fetchPublicTransportOffers() throws InterruptedException {
logger.info(() -> "Public Transport: Processing
request for " + USER.orElse("anonymous"));
...
}
如果我们简要回顾一下在fetchPublicTransportOffers()上下文中作用域值的当前状态,那么我们可以这样说,USER作用域值是在应用级别绑定的,因此它应该在应用中的任何地方都是可用的。LOC和DEST作用域值已经在fetchTravelOffers()中绑定,所以它们在fetchPublicTransportOffers()中也是可用的。另一方面,PUBLIC_TRANSPORT_TICKET和CAR_ONE_DISCOUNT在fetchPublicTransportOffers()中不可用(未绑定)。
到目前为止,我们已经使用了所有五个作用域值。我们继续在模拟拼车服务器的Ridesharing类中跟踪它们。在这个类中,我们可以访问USER、DEST和LOC作用域值。此外,只有在carOneServer()中我们才能访问CAR_ONE_DISCOUNT:
public static RidesharingOffer carOneServer() {
...
if (CAR_ONE_DISCOUNT.isBound()) {
logger.info(() -> "Congrats " + USER.get()
+ "! You have a discount of "
+ CAR_ONE_DISCOUNT.orElse(0.0));
price = price - CAR_ONE_DISCOUNT.orElse(0.0);
}
...
throw new RidesharingException(
"No drivers are available at CarOne for route: "
+ LOC.get() + " -> " + DEST.get());
}
因此,如果我们有折扣(我们确实有一个),CarOne的服务器将应用它。如果没有司机可用,那么服务器将抛出一个有意义的异常。这个异常也是从topCarServer()和starCarServer()抛出的。这些分别是TopCar公司和StarCar公司的服务器。
好的,到目前为止一切顺利!接下来,让我们检查 PublicTransport 类,该类模拟公共交通服务器。在这个类中,我们可以访问 USER、DEST、LOC 和 PUBLIC_TRANSPORT_TICKET 范围值。我们随意选择一个服务器(它们都使用相同的核心代码)并在这里列出我们感兴趣的代码:
public static List<PublicTransportOffer>
tramTransportServer() {
List<PublicTransportOffer> listOfOffers = new ArrayList<>();
Random rnd = new Random();
boolean makeAnOffer = rnd.nextBoolean();
if (makeAnOffer && PUBLIC_TRANSPORT_TICKET.isBound()
&& PUBLIC_TRANSPORT_TICKET.get()) {
...
}
if (listOfOffers.isEmpty()) {
throw new RidesharingException(
"No public tram-transport is available for route: "
+ LOC.get() + " -> " + DEST.get());
}
return listOfOffers;
}
如您所见,公共交通服务只能在我们有通过 PUBLIC_TRANSPORT_TICKET 范围值验证的特殊票时才能提供报价。如果我们路线没有可用的公共电车交通,则服务器会抛出一个异常,该异常使用 LOC 和 DEST 范围值构建一个有意义的消息。
完成!使用 ScopedValue 和 StructuredTaskScope 允许我们设计复杂的并发模型。
235. 使用信号量代替执行器
假设我们有一个以下任务(Runnable):
Runnable task = () -> {
try {
Thread.sleep(5000);
} catch (InterruptedException ex) { /* handle exception */ }
logger.info(Thread.currentThread().toString());
};
我们计划通过 3 个线程执行这个任务 15 次:
private static final int NUMBER_OF_TASKS = 15;
private static final int NUMBER_OF_THREADS = 3;
我们可以通过 Executors.newFixedThreadPool() 和平台线程轻松解决这个问题:
// using cached platform threads
try (ExecutorService executor =
Executors.newFixedThreadPool(NUMBER_OF_THREADS)) {
for (int i = 0; i < NUMBER_OF_TASKS; i++) {
executor.submit(task);
}
}
可能的输出片段:
Thread[#24,pool-1-thread-3,5,main]
Thread[#22,pool-1-thread-1,5,main]
Thread[#23,pool-1-thread-2,5,main]
Thread[#22,pool-1-thread-1,5,main]
Thread[#24,pool-1-thread-3,5,main]
Thread[#23,pool-1-thread-2,5,main]
...
如您所见,应用程序只有三个平台线程(#22、#23 和 #24)。
但是,我们已经知道平台线程很昂贵,因此最好依赖于虚拟线程。问题是,我们不能简单地用 newVirtualThreadPerTaskExecutor() 替换这个固定线程池,因为我们无法控制线程的数量。虽然我们只想使用 3 个线程,但虚拟线程执行器将为每个任务分配一个虚拟线程,所以我们最终会有 15 个线程。
为了控制虚拟线程的数量,我们可以依赖 Semaphore(如果您想了解更多关于这个主题的细节,您可以查看 Java 编程问题,第一版,第十章,问题 211)。首先,我们声明一个具有 NUMBER_OF_THREADS 许可证的 Semaphore:
Semaphore semaphore = new Semaphore(NUMBER_OF_THREADS);
接下来,我们依靠 semaphore.acquire() 和 semaphore.release() 来控制对这些许可的访问,并按照以下方式执行 NUMBER_OF_TASKS 个任务:
Thread vt = Thread.currentThread();
for (int i = 0; i < NUMBER_OF_TASKS; i++) {
vt = Thread.ofVirtual().start(() -> {
try {
semaphore.acquire();
} catch (InterruptedException ex) { /* handle it */ }
try {
task.run();
} finally {
semaphore.release();
}
});
}
vt.join();
output:
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#33]/runnable@ForkJoinPool-1-worker-8
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#30]/runnable@ForkJoinPool-1-worker-3
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-8
VirtualThread[#31]/runnable@ForkJoinPool-1-worker-4
...
在这里,我们有六个虚拟线程(#27、#33、#28、#30、#25 和 #31),而不是三个。想法是,Semaphore 只允许同时创建和运行三个虚拟线程。你可以通过亲自运行代码来验证这个说法。在前三个虚拟线程创建后,它们将睡眠 5 秒。但是,因为虚拟线程便宜,它们不会回到线程池中,所以不会被重用。创建另外三个来使用和丢弃更便宜。想法是,一次不会超过三个。
236. 通过锁定避免固定
从 第十章,问题 213 中记住,当一个虚拟线程在执行过程中通过一个 synchronized 代码块时,它会被固定(不会从其承载线程卸载)。例如,以下 Runnable 将导致虚拟线程被固定:
Runnable task1 = () -> {
synchronized (Main.class) {
try {
Thread.sleep(1000);
} catch (InterruptedException ex) { /* handle it */ }
logger.info(() -> "Task-1 | "
+ Thread.currentThread().toString());
}
};
synchronized 块包含一个阻塞操作(sleep()),但触达此执行点的虚拟线程并未卸载。它被固定在其承载线程上。让我们通过以下执行器尝试捕捉这种行为:
private static final int NUMBER_OF_TASKS = 25;
try (ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < NUMBER_OF_TASKS; i++) {
executor.submit(task1);
}
}
可能的输出如下所示:
Task-1 | VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
Task-1 | VirtualThread[#30]/runnable@ForkJoinPool-1-worker-8
Task-1 | VirtualThread[#29]/runnable@ForkJoinPool-1-worker-7
Task-1 | VirtualThread[#28]/runnable@ForkJoinPool-1-worker-6
Task-1 | VirtualThread[#27]/runnable@ForkJoinPool-1-worker-5
Task-1 | VirtualThread[#26]/runnable@ForkJoinPool-1-worker-4
Task-1 | VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3
Task-1 | VirtualThread[#24]/runnable@ForkJoinPool-1-worker-2
Task-1 | VirtualThread[#37]/runnable@ForkJoinPool-1-worker-3
Task-1 | VirtualThread[#36]/runnable@ForkJoinPool-1-worker-4
Task-1 | VirtualThread[#35]/runnable@ForkJoinPool-1-worker-5
Task-1 | VirtualThread[#34]/runnable@ForkJoinPool-1-worker-6
...
检查一下工作者!因为虚拟线程被固定在其承载线程上,应用程序使用了所有可用的工作者(在我的机器上有八个)。在那段 sleep(1000) 期间,工作者不可用,因此它们无法执行其他任务。换句话说,承载线程只有在虚拟线程完成执行后才能可用。
但是,我们可以通过用 ReentrantLock 重新编写应用程序来避免这种情况,而不是使用 synchronized。如果您想了解更多关于 ReentrantLock 的信息,可以查看 Java 编程问题,第一版,第十一章,问题 222 和 223。因此,考虑到您熟悉 ReentrantLock,我们可以提出以下非固定解决方案:
Lock lock = new ReentrantLock();
Runnable task2 = () -> {
lock.lock();
try {
Thread.sleep(1000);
logger.info(() -> "Task-2 | "
+ Thread.currentThread().toString());
} catch (InterruptedException ex) { /* handle it */
} finally {
lock.unlock();
}
};
我们通过相同的 newVirtualThreadPerTaskExecutor() 执行此代码:
executor.submit(task2);
让我们分析一段可能的输出片段:
Task-2 | VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
Task-2 | VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
Task-2 | VirtualThread[#25]/runnable@ForkJoinPool-1-worker-5
Task-2 | VirtualThread[#26]/runnable@ForkJoinPool-1-worker-1
Task-2 | VirtualThread[#27]/runnable@ForkJoinPool-1-worker-3
Task-2 | VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
Task-2 | VirtualThread[#29]/runnable@ForkJoinPool-1-worker-3
Task-2 | VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1
Task-2 | VirtualThread[#31]/runnable@ForkJoinPool-1-worker-3
Task-2 | VirtualThread[#33]/runnable@ForkJoinPool-1-worker-1
Task-2 | VirtualThread[#32]/runnable@ForkJoinPool-1-worker-5
Task-2 | VirtualThread[#34]/runnable@ForkJoinPool-1-worker-1
...
这次,我们可以看到只使用了三个工作者,worker-1,3 和 5。因为虚拟线程没有被固定,它们可以释放它们的承载线程。这样,平台线程就可以被重用,我们可以为其他任务节省剩余的资源。如果固定操作过于频繁,那么它将影响应用程序的可伸缩性,因此建议重新审视您的 synchronized 代码,并在可能的情况下将其替换为 ReentrantLock。
237. 通过虚拟线程解决生产者-消费者问题
假设我们想要编写一个模拟装配线(或传送带)的程序,用于检查和包装灯泡,使用两个工作者。检查意味着工作者测试灯泡是否发光。包装意味着工作者将经过验证的灯泡放入盒中。
接下来,假设有固定数量的生产者(3 个)和固定数量的消费者(2 个);让我们通过以下图表来表示它:

图 11.8:具有固定工作者数量的生产者-消费者问题
我们可以通过众所周知的 Executors.newFixedThreadPool(PRODUCERS),Executors.newFixedThreadPool(CONSUMERS),以及 ConcurrentLinkedQueue 作为检查灯泡的临时存储来实现这一场景,正如您在 github.com/PacktPublishing/Java-Coding-Problems/tree/master/Chapter10/P203_ThreadPoolFixed_ConcurrentLinkedQueue 所见。
让我们将此代码视为遗留代码,并通过虚拟线程进行重构。我们只需将 Executors.newFixedThreadPool()(用于生产者和消费者的执行器)替换为 newVirtualThreadPerTaskExecutor(),如下所示:
private static ExecutorService producerService;
private static ExecutorService consumerService;
...
producerService = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < PRODUCERS; i++) {
producerService.execute(producer);
}
consumerService = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < CONSUMERS; i++) {
consumerService.execute(consumer);
}
那就结束了!难道你不觉得我们很容易将代码重构以从平台线程迁移到虚拟线程吗?可能的输出如下所示:
Checked: bulb-106 by producer: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-2
Checked: bulb-58 by producer: VirtualThread[#25]/runnable@ForkJoinPool-1-worker-2
Packed: bulb-106 by consumer: VirtualThread[#26]/runnable@ForkJoinPool-1-worker-2
Packed: bulb-58 by consumer: VirtualThread[#27]/runnable@ForkJoinPool-1-worker-5
...
当然,你可以在 GitHub 上找到完整的代码。花些时间熟悉它,尤其是如果你没有阅读这本书的第一版。在接下来的两个问题中,我们也将依赖这段代码。
238. 通过虚拟线程解决生产者-消费者问题(通过 Semaphore 固定)
在上一个问题中,我们通过固定数量的生产者(三个虚拟线程)和消费者(两个虚拟线程)实现了生产者-消费者问题。此外,由于我们的应用程序作为一个装配线工作,我们可以说任务的数量是无限的。实际上,生产者和消费者在没有休息的情况下工作,直到装配线停止。这意味着分配给生产者和消费者的虚拟线程在装配线的启动-停止生命周期中保持完全相同。
接下来,让我们假设我们想使用Semaphore对象而不是newVirtualThreadPerTaskExecutor()来获得完全相同的行为。
根据问题 235,我们可以如下实现固定数量的生产者:
private final static Semaphore producerService
= new Semaphore(PRODUCERS);
...
for (int i = 0; i < PRODUCERS; i++) {
Thread.ofVirtual().start(() -> {
try {
producerService.acquire();
} catch (InterruptedException ex) { /* handle it */ }
try {
producer.run();
} finally {
producerService.release();
}
});
}
固定数量的消费者如下安排:
private final static Semaphore consumerService
= new Semaphore(CONSUMERS);
...
for (int i = 0; i < CONSUMERS; i++) {
Thread.ofVirtual().start(() -> {
try {
consumerService.acquire();
} catch (InterruptedException ex) { /* handle it */ }
try {
consumer.run();
} finally {
consumerService.release();
}
});
}
在下一个问题中,我们将稍微复杂化一下。
239. 通过虚拟线程解决生产者-消费者问题(增加/减少消费者)
让我们继续我们的生产者-消费者问题,通过另一个场景开始,该场景有三个生产者和两个消费者:
private static final int PRODUCERS = 3;
private static final int CONSUMERS = 2;
假设每个生产者最多在一秒内检查一个灯泡。然而,一个消费者(包装工)需要最多 10 秒来包装一个灯泡。生产者和消费者的时间可以如下安排:
private static final int MAX_PROD_TIME_MS = 1 * 1000;
private static final int MAX_CONS_TIME_MS = 10 * 1000;
显然,在这些条件下,消费者无法面对不断涌入的流量。用于存储灯泡直到它们被包装的队列(这里,LinkedBlockingQueue)将不断增长。生产者将比消费者能够检索的速度更快地将灯泡推入这个队列。
由于我们只有两个消费者,我们必须增加它们的数量,以便能够处理和稳定队列的负载。但是,过了一会儿,生产者会感到疲倦,需要更多的时间来检查每个灯泡。如果生产者放慢生产速度,消费者的数量也应该相应减少,因为其中许多消费者将只是坐着。后来,生产者可能会加快速度,如此类推。
这种问题可以通过newCachedThreadPool()和平台线程来解决。如果你对这个主题不熟悉,你可以在Java 编码问题,第一版,第十章,问题 204中找到更多细节。
我们如何通过虚拟线程来解决它?我们可以通过两个Semaphore对象启动生产者和消费者,就像我们在问题 238中所做的那样。接下来,我们需要监控队列大小并相应地采取行动。让我们假设只有当队列大小大于五个灯泡时,我们才采取行动:
private static final int MAX_QUEUE_SIZE_ALLOWED = 5;
此外,假设我们可以将消费者数量增加到 50:
private static final int MAX_NUMBER_OF_CONSUMERS = 50;
我们希望每 3 秒监控一次队列,初始延迟为 5 秒,因此我们可以依赖ScheduledExecutorService:
private static ScheduledExecutorService monitorService;
monitorQueueSize()方法负责初始化monitorService,调用addNewConsumer()、removeConsumer()并记录状态如下:
private static final int
MONITOR_QUEUE_INITIAL_DELAY_MS = 5000;
private static final int MONITOR_QUEUE_RATE_MS = 3000;
private static final AtomicInteger nrOfConsumers
= new AtomicInteger(CONSUMERS);
...
private static void monitorQueueSize() {
monitorService = Executors
.newSingleThreadScheduledExecutor();
monitorService.scheduleAtFixedRate(() -> {
if (queue.size() > MAX_QUEUE_SIZE_ALLOWED
&& nrOfConsumers.get() < MAX_NUMBER_OF_CONSUMERS) {
addNewConsumer();
} else {
if (nrOfConsumers.get() > CONSUMERS) {
removeConsumer();
}
}
logger.warning(() -> "### Bulbs in queue: " + queue.size()
+ " | Consumers waiting: "
+ consumerService.getQueueLength()
+ " | Consumer available permits: "
+ consumerService.availablePermits()
+ " | Running consumers: " + nrOfConsumers.get());
}, MONITOR_QUEUE_INITIAL_DELAY_MS,
MONITOR_QUEUE_RATE_MS, TimeUnit.MILLISECONDS);
}
因此,如果队列大小超过MAX_QUEUE_SIZE_ALLOWED且消费者数量低于MAX_NUMBER_OF_CONSUMERS,那么我们应该添加一个新的消费者。这可以通过释放处理消费者的Semaphore的新许可证来完成。当当前许可证数量为零时释放许可证,将简单地添加一个新的许可证,因此一个新的虚拟线程可以获取这个许可证并成为新的消费者:
private static void addNewConsumer() {
logger.warning("### Adding a new consumer ...");
if (consumerService.availablePermits() == 0) {
consumerService.release();
}
Thread.ofVirtual().start(() -> {
try {
consumerService.acquire();
} catch (InterruptedException ex) { /* handle it */ }
try {
consumer.run();
} finally {
consumerService.release();
}
});
nrOfConsumers.incrementAndGet();
}
很可能,当生产者减慢生产速度时,会有太多的消费者只是传递。在这种情况下,我们必须中断虚拟线程,直到我们成功平衡生产者和消费者之间的工作。removeConsumer()方法负责发出中断消费者的信号,为此,它将AtomicBoolean设置为true:
private static final AtomicBoolean
removeConsumer = new AtomicBoolean();
...
private static void removeConsumer() {
logger.warning("### Removing a consumer ...");
removeConsumer.set(true);
}
Consumer在每次运行时都会检查这个标志,当它是true时,它将简单地中断当前正在运行的虚拟线程:
private static class Consumer implements Runnable {
@Override
public void run() {
while (runningConsumer) {
...
if (removeConsumer.get()) {
nrOfConsumers.decrementAndGet();
removeConsumer.set(false);
Thread.currentThread().interrupt();
}
}
}
}
为了模拟生产者生产速率的降低,我们可以依赖ScheduledExecutorService如下:
private static int extraProdTime;
private static final int EXTRA_TIME_MS = 4 * 1000;
private static final int SLOW_DOWN_PRODUCER_MS = 150 * 1000;
private static ScheduledExecutorService slowdownerService;
...
private static void slowdownProducer() {
slowdownerService = Executors
.newSingleThreadScheduledExecutor();
slowdownerService.schedule(() -> {
logger.warning("### Slow down the producers ...");
extraProdTime = EXTRA_TIME_MS;
}, SLOW_DOWN_PRODUCER_MS, TimeUnit.MILLISECONDS);
}
因此,我们启动了生产线,生产者将以非常高的速率检查灯泡。2.5 分钟后,我们通过extraProdTime变量为每个生产者添加额外的 4 秒时间来降低这个速率。最初,这是 0,但在 2.5 分钟后变为 4000。由于它是生产时间的一部分,它将使生产者慢 4 秒:
Thread.sleep(rnd.nextInt(MAX_PROD_TIME_MS) + extraProdTime);
让我们尝试追踪我们的生产线以了解其工作原理。因此,我们启动了生产线,不久后,我们注意到队列中灯泡的数量(27)大于 5,应用程序开始添加消费者:
...
[14:20:41] [INFO] Checked: bulb-304 by producer: VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
[14:20:42] [INFO] Checked: bulb-814 by producer: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
[14:20:42] [INFO] Checked: bulb-155 by producer: VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
**[14:20:42] [WARNING] ### Adding a new consumer ...**
[14:20:42] [INFO] Checked: bulb-893 by producer: **VirtualThread[#25]/runnable@ForkJoinPool-1-worker-1**
**[14:20:42] [WARNING] ### Bulbs in queue: 27 | Consumers waiting: 0 | Consumer available permits: 0 | Running consumers: 3**
...
当未处理的灯泡数量持续增长时,应用程序继续添加消费者(这里队列中有 237 个灯泡和 32 个消费者):
...
[14:22:09] [INFO] Checked: bulb-388 by producer: VirtualThread[#25]/runnable@ForkJoinPool-1-worker-1
[14:22:09] [INFO] Packed: bulb-501 by consumer: VirtualThread[#43]/runnable@ForkJoinPool-1-worker-1
[14:22:09] [INFO] Packed: bulb-768 by consumer: VirtualThread[#27]/runnable@ForkJoinPool-1-worker-3
**[14:22:09] [WARNING] ### Adding a new consumer ...**
**[14:22:09] [WARNING] ### Bulbs in queue: 237 | Consumers waiting: 0 | Consumer available permits: 1 | Running consumers: 32**
...
当应用程序达到大约 37 个消费者时,队列大小开始下降趋势。在这里,你可以看到两个连续的队列状态日志(同时,应用程序仍在添加更多消费者——它这样做,直到queue.size()小于 5):
...
[14:22:24] [WARNING] ### Adding a new consumer ...
[14:22:24] [WARNING] ### Bulbs in queue: **214** | Consumers waiting: 0 | Consumer available permits: 1 | Running consumers: **37**
...
[14:22:27] [WARNING] ### Adding a new consumer ...
[14:22:27] [WARNING] ### Bulbs in queue: **203** | Consumers waiting: 0 | Consumer available permits: 1 | Running consumers: **38**
...
队列大小继续下降,消费者数量达到最大值 50。在某个时刻,队列被耗尽。客户数量远远高于所需数量,因此它们一个接一个地被移除。以下是最后一个消费者(消费者#50 被移除)的时刻:
...
[14:23:15] [INFO] Packed: bulb-180 by consumer: VirtualThread[#46]/runnable@ForkJoinPool-1-worker-3
[14:23:15] [INFO] Packed: bulb-261 by consumer: VirtualThread[#67]/runnable@ForkJoinPool-1-worker-3
**[14:23:15] [WARNING] ### Removing a consumer ...**
**[14:23:15] [WARNING] ### Bulbs in queue: 0 | Consumers waiting: 0 | Consumer available permits: 1 | Running consumers: 49**
...
当应用程序通过移除消费者来继续自我校准时,生产者减慢了速度:
...
[14:23:07] [WARNING] ### Slow down the producers ...
...
由于生产者减慢了速度,消费者数量继续减少,队列负载可以由两个消费者处理:
...
**[14:28:24] [WARNING] ### Bulbs in queue: 3 | Consumers waiting: 0 | Consumer available permits: 48 | Running consumers: 2**
[14:28:26] [INFO] Checked: bulb-812 by producer: VirtualThread[#25]/runnable@ForkJoinPool-1-worker-3
[14:28:26] [INFO] Packed: bulb-207 by consumer: VirtualThread[#102]/runnable@ForkJoinPool-1-worker-3
...
**[14:28:27] [WARNING] ### Bulbs in queue: 4 | Consumers waiting: 0 | Consumer available permits: 48 | Running consumers: 2**
[14:28:28] [INFO] Checked: bulb-259 by producer: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-3
...
**[14:28:30] [WARNING] ### Bulbs in queue: 3 | Consumers waiting: 0 | Consumer available permits: 48 | Running consumers: 2**
...
在这一点上,装配线已经校准。如果生产者再次提高生产率,则应用程序准备响应。如您所见,消费者的Semaphore有 48 个许可可用,因此我们不应再次创建它们。如果您想删除与中断的消费者对应的许可,则必须扩展Semaphore类并重写受保护的reducePermits()方法。许可的数量只是一个计数器;因此,在这种情况下,删除许可实际上并不必要。
240. 在虚拟线程之上实现 HTTP Web 服务器
在 Java 中实现简单的 HTTP Web 服务器相当容易,因为我们已经有了现成的 API 来引导和实现我们的目标。我们从HttpServer类(该类位于com.sun.net.httpserver包中)开始,这个类允许我们通过几个步骤直接实现我们的目标。
在深入代码之前,让我们简要地提到,我们的 Web 服务器将允许我们在平台线程和虚拟线程之间以及在不锁定或锁定(例如,模拟对数据库的访问)之间进行选择。我们将通过startWebServer(boolean virtual, boolean withLock)方法的两个布尔参数virtual和withLock来做出这些选择。因此,我们将有四种可能的配置。
首先,我们通过create()方法创建一个HttpServer。在这个时候,我们还设置了 Web 服务器的端口:
private static final int MAX_NR_OF_THREADS = 200;
private static final int WEBSERVER_PORT = 8001;
private static void startWebServer(
boolean virtual, boolean withLock) throws IOException {
HttpServer httpServer = HttpServer
.create(new InetSocketAddress(WEBSERVER_PORT), 0);
...
接下来,我们通过指定访问页面和处理 HTTP 请求的处理程序(WebServerHandler将在稍后实现)来创建 Web 服务器上下文:
httpServer.createContext("/webserver",
new WebServerHandler(withLock));
...
接下来,我们可以选择执行器服务(覆盖默认的执行器服务)来编排我们 Web 服务器的线程。这可以通过setExecutor()方法完成。由于我们可以在平台线程(我们任意选择了 200 个这样的线程)和虚拟线程之间进行选择,我们必须如下覆盖两种情况:
if (virtual) {
httpServer.setExecutor(
Executors.newVirtualThreadPerTaskExecutor());
} else {
httpServer.setExecutor(
Executors.newFixedThreadPool(MAX_NR_OF_THREADS));
}
...
最后,我们调用start()方法来启动 Web 服务器,并相应地记录:
httpServer.start();
logger.info(() -> " Server started on port "
+ WEBSERVER_PORT);
}
接下来,我们关注WebServerHandler类,该类实现了com.sun.net.httpserver.HttpHandler接口,并负责处理传入的 HTTP 请求。我们通过暂停 200 毫秒来模拟 HTTP 请求处理,并通过名为task的Callable创建一个简单的String响应:
public class WebServerHandler implements HttpHandler {
private final static Logger logger
= Logger.getLogger(WebServerHandler.class.getName());
private final static int PERMITS = 20;
private final static Semaphore semaphore
= new Semaphore(PERMITS);
private final static AtomicLong
requestId = new AtomicLong();
private static final Callable<String> task = () -> {
String response = null;
try {
Thread.sleep(200);
response = "Request id_" + requestId.incrementAndGet();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return response;
};
private final boolean withLock;
public WebServerHandler(boolean withLock) {
this.withLock = withLock;
}
...
当WebServerHandler启动时,我们还设置了withLock值。如果此值为true,则我们的实现将依赖于一个有 20 个许可的Semaphore来限制平台线程(200 个)或无界数量的虚拟线程的访问。这个Semaphore模拟了一个外部资源,例如依赖于 20 个连接的连接池的数据库。
HTTP 请求(我们只关注GET请求)在重写的handle(HttpExchange exchange)方法中如下处理:
@Override
public void handle(HttpExchange exchange)
throws IOException {
String response = null;
if (withLock) {
try {
semaphore.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e); }
try {
response = task.call();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
semaphore.release();
}
} else {
try {
response = task.call();
} catch (Exception e) {
throw new RuntimeException(e); }
}
logger.log(Level.INFO, "{0} | {1}",
new Object[]{response, Thread.currentThread()
});
...
一旦处理完 HTTP GET请求,我们必须为客户准备响应并发送它。这项工作是通过HttpExchange对象完成的,如下所示:
exchange.sendResponseHeaders(
200, response == null ? 0 : response.length());
try (OutputStream os = exchange.getResponseBody()) {
os.write(response == null ? new byte[0]
: response.getBytes());
}
}
}
完成!我们的 HTTP Web 服务器已经准备好大显身手。
如果我们通过startWebServer(false, false)启动 Web 服务器,那么我们将得到一个在非锁定上下文中准备就绪的具有 200 个平台线程的 Web 服务器。如果我们将第一个参数设置为true,那么我们将切换到无限制数量的虚拟线程。在下面的图中,您可以看到这两个场景在 2 秒内通过 JMeter 测试增加 400 个请求时的堆内存使用情况:

图 11.9:内存使用(无锁)
如您所见,虚拟线程使用的堆内存比平台线程少,效率更高。
如果我们将锁定机制加入等式中(我们将startWebServer()函数的第二个参数设置为true),那么堆内存的可能配置如图所示:

图 11.10:内存使用(使用锁定)
如您所见,即使使用锁定,虚拟线程仍然比平台线程使用更少的内存。在第十三章中,我们将更深入地探讨通过 JDK API 创建 Web 服务器,包括 JDK 18 带来的新特性。
241. 将 CompletableFuture 和虚拟线程挂钩
CompletableFuture是 Java 中主要的异步编程 API 之一(如果您需要对此主题进行深入探讨,那么可以考虑查看Java 编码问题,第一版,第十一章)。
为了使用虚拟线程与CompletableFuture一起,我们只需使用适合虚拟线程的正确执行器:
private static final ExecutorService executor
= Executors.newVirtualThreadPerTaskExecutor();
接下来,我们使用这个执行器通过CompletableFuture以异步模式获取三个应用程序测试员:
public static CompletableFuture<String> fetchTester1() {
return CompletableFuture.supplyAsync(() -> {
String tester1 = null;
try {
logger.info(Thread.currentThread().toString());
tester1 = fetchTester(1);
} catch (IOException | InterruptedException ex)
{ /* handle exceptions */ }
return tester1;
}**, executor);**
}
public static CompletableFuture<String> fetchTester2() { … }
public static CompletableFuture<String> fetchTester3() { … }
接下来,我们感兴趣的是在所有这三个CompletableFuture实例完成之后才返回TestingTeam。为此,我们依赖于allOf()如下:
public static TestingTeam buildTestingTeam()
throws InterruptedException, ExecutionException {
CompletableFuture<String> cfTester1 = fetchTester1();
CompletableFuture<String> cfTester2 = fetchTester2();
CompletableFuture<String> cfTester3 = fetchTester3();
CompletableFuture<Void> fetchTesters
= CompletableFuture.allOf(
cfTester1, cfTester2, cfTester3);
fetchTesters.get();
TestingTeam team = new TestingTeam(cfTester1.resultNow(),
cfTester2.resultNow(), cfTester3.resultNow());
return team;
}
如果我们运行此代码,那么输出将揭示在异步模式下使用了三个虚拟线程:
[12:04:32] VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
[12:04:32] VirtualThread[#24]/runnable@ForkJoinPool-1-worker-2
[12:04:32] VirtualThread[#26]/runnable@ForkJoinPool-1-worker-3
完成!在CompletableFuture中使用虚拟线程非常简单。
242. 通过 wait()和 notify()信号虚拟线程
wait()、notify()和notifyAll()是Object类中定义的三个方法,允许多个线程相互通信并协调对资源的访问,而不会出现任何问题。
wait()方法必须由拥有该对象监视器的线程调用,以强制该线程无限期等待,直到另一个线程在同一个对象上调用notify()或notifyAll()。换句话说,wait()方法必须在synchronized上下文中调用(实例、块或静态方法)。
这里是一个调用wait()方法的虚拟线程:
Object object = new Object();
Thread wThread = Thread.ofVirtual().unstarted(() -> {
synchronized (object) {
try {
logger.info("Before calling wait()");
logger.info(() -> Thread.currentThread() + " | "
+ Thread.currentThread().getState());
object.wait();
logger.info("After calling notify()");
logger.info(() -> Thread.currentThread() + " | "
+ Thread.currentThread().getState());
} catch (InterruptedException e) {}
}
});
这里还有一个虚拟线程通过notify()调用唤醒前面的线程:
Thread nThread = Thread.ofVirtual().unstarted(() -> {
synchronized (object) {
logger.info(() -> Thread.currentThread()
+ " calls notify()");
object.notify();
}
});
到目前为止,还没有发生任何事情,因为wThread和nThread尚未启动。我们启动wThread,并给它 1 秒钟来完成:
wThread.start();
Thread.sleep(1000); // give time to 'wThread' to start
logger.info("'wThread' current status");
logger.info(() -> wThread + " | " + wThread.getState());
接下来,我们启动nThread,给它 1 秒钟来完成:
nThread.start();
Thread.sleep(1000); // give time to 'nThread' to start
最后,我们记录wThread的状态:
logger.info("After executing 'wThread'");
logger.info(() -> wThread + " | " + wThread.getState());
运行此代码将显示以下输出:
[14:25:06] Before calling wait()
[14:25:06] VirtualThread[#22]
/runnable@ForkJoinPool-1-worker-1 | RUNNABLE
[14:25:07] 'wThread' current status
[14:25:07] VirtualThread[#22]
/waiting@ForkJoinPool-1-worker-1 | WAITING
[14:25:07] VirtualThread[#23]
/runnable@ForkJoinPool-1-worker-3 calls notify()
[14:25:07] After calling notify()
[14:25:07] VirtualThread[#22]
/runnable@ForkJoinPool-1-worker-1 | RUNNABLE
[14:25:08] After executing 'wThread'
[14:25:08] VirtualThread[#22]/terminated | TERMINATED
虚拟线程#22 是我们的wThread。最初(在调用wait()之前),它处于RUNNABLE状态,因此线程在 JVM 上执行。调用wait()后,此线程的状态被设置为WAITING,因此线程#22 无限期地等待另一个线程唤醒它。这就是虚拟线程#23(nThread)在相同对象上调用notify()方法的时候。在调用notify()方法后,线程#22 被唤醒,其状态再次变为RUNNABLE。在完成其执行后,wThread状态变为TERMINATED。
好吧,这个场景是快乐的路径或一个好的信号。让我们检查基于相同wThread和nThread的以下场景:
nThread.start();
Thread.sleep(1000); // give time to 'nThread' to start
wThread.start();
Thread.sleep(1000); // give time to 'wThread' to start
logger.info("'wThread' current status");
logger.info(() -> wThread + " | " + wThread.getState());
wThread.join(); // waits indefinitely - notify() was missed
输出结果将是(#22 是wThread,#23 是nThread):
[14:38:25] VirtualThread[#23]
/runnable@ForkJoinPool-1-worker-1 calls notify()
[14:38:26] Before calling wait()
[14:38:26] VirtualThread[#22]
/runnable@ForkJoinPool-1-worker-1 | RUNNABLE
[14:38:27] 'wThread' current status
[14:38:27] VirtualThread[#22]
/waiting@ForkJoinPool-1-worker-1 | WAITING
这次,nThread首先启动并调用notify()。这只是一个盲目的尝试,因为wThread不在WAITING状态。稍后,wThread调用wait()并无限期地等待由nThread唤醒。但这永远不会发生,因为notify()已经被触发,所以wThread将永远阻塞。简而言之,这被称为未检测到的信号。
当我们开发涉及wait()、notify()和notifyAll()的并发应用程序时,我们必须确保应用程序的复杂性不会隐藏这样的未检测到的信号。我们可以通过简单地计数wait()和notify()调用的次数并相应地采取行动来避免未检测到的信号。例如,让我们将此逻辑移动到应该发出信号的物体中,并将其称为SignaledObject。首先,我们有callWait()方法,它如下使用counter:
public class SignaledObject {
private static final Logger logger
= Logger.getLogger(SignaledObject.class.getName());
private int counter;
public void callWait() throws InterruptedException {
synchronized (this) {
counter = counter - 1;
if (counter >= 0) {
logger.info(() -> Thread.currentThread()
+ " | Missed signals: " + counter
+ " | 'wait() will not be called'");
return;
}
logger.info("Before calling wait()");
logger.info(() -> Thread.currentThread() + " | "
+ Thread.currentThread().getState());
wait();
logger.info("After calling notify()");
logger.info(() -> Thread.currentThread() + " | "
+ Thread.currentThread().getState());
}
}
...
如果没有错过任何信号,那么counter变量应该是 0。否则,我们至少有一个未检测到的信号(notify()调用),因此没有必要等待。我们立即返回,不调用wait(),因为这可能导致致命陷阱。
另一方面,callNotify()在每次调用时增加counter,如下所示:
public void callNotify() {
synchronized (this) {
counter = counter + 1;
logger.info(() -> "Signal counter: " + counter);
notify();
}
}
}
如果我们运行快乐的路径(好的信号)场景,那么输出结果如下:
[14:50:32] Before calling wait()
[14:50:32] VirtualThread[#22]
/runnable@ForkJoinPool-1-worker-1 | RUNNABLE
[14:50:33] 'wThread' current status
[14:50:33] VirtualThread[#22]
/waiting@ForkJoinPool-1-worker-1 | WAITING
**[14:50:33] Signal counter: 0**
[14:50:33] After calling notify()
[14:50:33] VirtualThread[#22]
/runnable@ForkJoinPool-1-worker-1 | RUNNABLE
[14:50:34] After executing 'wThread'
[14:50:34] VirtualThread[#22]/terminated | TERMINATED
由于counter为 0,一切按预期工作。如果我们尝试未检测到信号的场景,那么输出结果如下:
[14:52:24] Signal counter: 1
[14:52:25] VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
| Missed signals: 0 | 'wait() will not be called'
[14:52:26] 'wThread' current status
[14:52:26] VirtualThread[#22]/terminated | TERMINATED
如您所见,我们没有调用wait(),从而避免了不确定的阻塞。我们巧妙地处理了未检测到的信号。酷,不是吗!?
摘要
本章涵盖了关于虚拟线程和结构化并发的 18 个高级问题。您可以将本章视为一个大师班,旨在帮助您加快学习速度,并带着对知识的强烈信心准备投入生产。覆盖了这些内容后,您现在已经完成了本章和整本书。
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

第十二章:垃圾收集器和动态 CDS 归档
本章包括 15 个关于垃圾收集器和应用程序类数据共享(AppCDS)的问题。
到本章结束时,你将深刻理解垃圾收集器是如何工作的,以及如何对其进行调整以实现最佳性能。此外,你将很好地理解 AppCDS 如何提高你的应用程序启动速度。
问题
使用以下问题来测试你在 Java 中垃圾收集器和应用程序类数据共享方面的高级编程能力。我强烈建议你在查看解决方案并下载示例程序之前,尝试解决每个问题:
-
挂钩垃圾收集器目标:快速介绍 Java 垃圾收集器。强调垃圾收集器的主要目标(优点)和缺点。
-
处理垃圾收集器阶段:列出并简要描述垃圾收集器最常见的阶段。
-
涵盖一些垃圾收集器术语:垃圾收集器有特定的术语。在此提供与垃圾收集器一起使用的主要术语。
-
追踪代垃圾收集过程:举例说明并解释一个包含多个连续运行代垃圾收集器的假设场景。
-
选择正确的垃圾收集器:列出并解释在选择正确的垃圾收集器时应考虑的三个主要因素。
-
分类垃圾收集器:强调 JDK 发展历程中垃圾收集器的主要类别。
-
介绍 G1:简要介绍 G1 GC,包括其设计原则。
-
解决 G1 吞吐量改进:列出 G1 GC 在 JDK 各个版本中的主要改进。
-
解决 G1 延迟改进:列出 G1 GC 在 JDK 各个版本中的主要改进。
-
解决 G1 脚本改进:列出 G1 GC 在 JDK 各个版本中的主要改进。
-
介绍 ZGC:简要介绍 Z 垃圾收集器。
-
监控垃圾收集器:解释并举例说明至少一个用于监控垃圾收集器的工具。
-
记录垃圾收集器:提供记录垃圾收集器活动的步骤。此外,强调一些能够分析和绘制记录数据的工具。
-
调整垃圾收集器:解释如何调整垃圾收集器,包括 G1 和 ZGC。
-
介绍应用程序类数据共享(AppCDS,或 Java 的启动加速器):提供使用 JDK 10/11、13 和 19 中的 CDS 和 AppCDS 的快速实用指南。
以下部分描述了前面问题的解决方案。请记住,通常没有解决特定问题的唯一正确方法。此外,请记住,这里所示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节,并在github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter12上实验程序。
243. 钩子垃圾回收器目标
每种编程语言都必须管理内存使用。一些编程语言将这项任务委托给程序员,而其他编程语言则利用不同的机制来部分控制内存的使用方式。Java 程序员可以 100%专注于应用程序的功能,让垃圾回收器管理内存的使用。
垃圾回收器这个名字暗示了一个能够从内存中找到并收集垃圾的实体。实际上,垃圾回收器是一个非常复杂的过程,代表了 Java 内存管理的巅峰,能够跟踪堆中的每个对象,并识别和删除那些未被应用程序使用/引用的对象。垃圾回收器的主要优势包括:
-
Java 程序员不需要手动处理内存的分配/释放。
-
Java 程序员不需要处理悬挂指针和野指针(
en.wikipedia.org/wiki/Dangling_pointer)。 -
在各种场景中,垃圾回收器可以防止内存泄漏(
en.wikipedia.org/wiki/Memory_leak)。然而,这个问题并没有得到 100%的解决。
虽然这些优势是主要的,但也有一些缺点:
-
垃圾回收器本身是一个需要 CPU 功率来工作的资源。我们说的是除了应用程序需要的 CPU 功率之外的 CPU 功率。更多的垃圾回收器活动需要更多的 CPU 功率。
-
程序员无法控制垃圾回收器的调度器。这可能在高峰时段或当应用程序处理密集型计算时导致性能问题。
-
一些垃圾回收器会导致应用程序出现长时间且不可预测的暂停。
-
学习和调整正确的垃圾回收器可能真的非常繁琐。
在接下来的问题中,我们将更深入地探讨这个主题。
244. 处理垃圾回收器阶段
在其工作过程中,GC 会经过不同的阶段或步骤。它可以经过以下一个或多个阶段:
-
Mark – 在这个阶段,垃圾回收器识别并标记(或涂鸦)所有被使用(有引用)和未被使用(没有引用)的内存(块)。被标记(涂鸦)的块被称为 活动对象,而其余的则被称为 非活动对象。想象一下,你走进储藏室,识别所有新鲜的水果和蔬菜,并将它们与变质的水果和蔬菜分开。
-
Sweep – 在这个阶段,垃圾回收器(GC)从内存中移除所有 非活动对象。接下来,你将所有变质的水果和蔬菜从储藏室中取出并扔掉。
-
Compact – 在这个阶段,垃圾回收器试图将 活动对象 组得更近一些——换句话说,它将活动对象在堆的起始处排列成一系列连续的内存块。因此,压缩涉及 碎片整理 和 重定位 活动对象。压缩的目的是获得大块空闲内存,以便为其他对象提供服务。接下来,我们进入储藏室,将所有水果和蔬菜堆叠在箱子里,以便尽可能多地获得空闲空间。我们将使用这个空间来存放我们打算购买的其他水果和蔬菜。
-
Copy – 这是另一个专门用于组织内存的阶段。它是 标记 阶段的替代方案。在这个阶段,垃圾回收器将 活动对象 移动到所谓的 ToSpace。其余的对象被认为是 非活动对象,并保留在所谓的 FromSpace 中。
通常,垃圾回收器遵循以下三种场景之一:
-
Mark -> Sweep -> Compact
-
Copy
-
Mark -> Compact
接下来,让我们来了解一下垃圾回收器的一些术语。
245. 涵盖一些垃圾回收器术语
垃圾回收有其自己的术语,了解这些术语对于更好地理解其工作方式至关重要。这里介绍了一些这些术语;我们首先从 epoch、单次遍历 和 多次遍历 开始。
Epoch
垃圾回收器在周期中工作。垃圾回收器的一个完整周期被称为 epoch。
单次遍历和多次遍历
垃圾回收器可以在单次遍历(单次遍历)或多次遍历(多次遍历)中处理其内部步骤。在 单次遍历 的情况下,垃圾回收器将多个步骤组合在一起,并在单次运行中处理它们。另一方面,在 多次遍历 的情况下,垃圾回收器在多个遍历的序列中处理多个步骤。
串行和并行
如果垃圾回收器使用单个线程,则被认为是 串行 的。另一方面,如果垃圾回收器使用多个线程,则被认为是 并行 的。
Stop-the-World(STW)和并发
如果垃圾回收器(GC)必须停止(暂时挂起)应用程序执行以执行其周期,则它属于 Stop-the-World(STW)类型。另一方面,如果垃圾回收器(GC)能够在不影响其执行的情况下与应用程序同时运行,则它被认为是 并发 的。
活动集合
GC 的活跃集代表当前应用程序中所有的活跃对象。如果没有内存泄漏(或其他问题),那么活跃集应该具有恒定的负载因子和相对恒定的大小。在应用程序执行期间,对象分别从堆和活跃集中添加/移除。
分配率
Java 允许我们通过–Xmx选项设置堆内存的大小。这个大小不应超过您的机器(服务器)上的内存,并且应该足够大,以服务于活跃集。这可以通过考虑分配率来实现,它表示单位时间内分配的内存量(例如,MB)。
重要提示
作为一条经验法则,尽量将堆大小设置为平均活跃集大小的 2.5 到 5 倍。
换句话说,当创建许多对象时,也会有大量的清理工作。这意味着 GC 将以高频率运行,并且需要更高的分配率。
NUMA
NUMA 是“非一致性内存访问”的缩写。处理器有自己的内存(称为本地内存),但它也可以访问其他处理器的内存。访问其本地内存的速度比访问非本地内存快。基本上,NUMA 是一种尝试优化本地内存访问的内存架构。
基于区域
基于区域的 GC 将堆划分为更小的(最终相等)的区域/内存块(例如,G1 和 ZGC 是基于区域的 GC)。每个这样的区域可以用于不同的目的。
代际垃圾回收
代际垃圾回收是一种在处理短生命周期对象方面表现优异的算法。实现此算法的 GC 称为代际 GC。
此算法区分年轻和老对象,并将它们分开。年轻对象被保存在一个称为年轻代或保育区的空间中,而老对象被保存在一个称为老年代或持久代的空间中。以下图显示了对象通过年轻代和老年代的转换:

图 12.1:对象通过年轻代和老年代的转换
如您所见,年轻代被分为两个区域/空间,分别命名为Eden区域或Eden空间和幸存者区域或幸存者空间。最初,年轻代是空的。
默认情况下,新创建的对象会被放置在Eden空间中。然而,对于超过区域大小 50%的极大对象,称为巨无霸对象,它们会被直接放入老年代区域,这可能会因为主要/完全GC 事件的增加而导致性能问题。重要的是要注意,GC 可以触发不同类型的事件:
-
MinorGC – 当 Eden 空间满时,这个事件发生在 Young 代。其目的是收集 非活动 对象并将剩余的促进到 Survivor 空间。这个事件是最常由 GC 触发的。
-
MajorGC – 这个事件发生在 Old 代,负责从这个区域收集垃圾。
-
MixedGC – 这是一个紧随其后的 MinorGC 事件,随后回收 Old 代。
-
FullGC – 清理 Young 和 Old 代,并对 Old 代进行压缩(我们可以通过
System.gc()或Runtime.getRunTime().gc()程序化地强制执行 FullGC)。
接下来,让我们回到 Young 代的主题。在 epoch(一个 GC 完成周期)期间,存活的(未被垃圾回收)对象会被提升到 Survivor 空间(GC 算法在 Survivor space 0(称为 S0 或 FromSpace)和 1(称为 S1 或 ToSpace)之间进行选择)。那些不适合 Survivor 空间的对象(如果有)将被移动到 Tenured 空间——这被称为 premature promotion。通常,GC 通过 MinorGC 事件快速处理 Eden 空间。使用具有短生命周期方法的局部变量鼓励使用 Eden 空间并维持 GC 的性能。
被认为是足够老的(它们在多个时代中存活下来)对象最终会被提升到 Old 代。这个区域通常(但不一定是)比 Young 代大。一些 GC 使用这些区域之间的固定界限(例如,Concurrent Mark Sweep (CMS)GC),而其他 GC 使用这些区域之间的弹性界限(例如,G1 GC)。Old 代区域回收垃圾所需的时间相对较长,频率低于 Young 代。
正如你在 Figure 12.1 中可以看到的,堆中还包含一个名为 Metadata 的区域。在 JDK 8 之前,这个区域被称为 PermGenSpace 或 Permanent 代。这个区域用于存储类和方法。这个区域是专门设计用来在堆大小之外增长到本地内存(如果这个区域的大小超过了物理内存,操作系统将使用虚拟内存——但请注意,在物理内存和虚拟内存之间移动数据是一个昂贵的操作,这将影响应用程序的性能)。通过 Metadata 空间,JVM 避免了内存不足的错误。然而,这个区域可以被垃圾回收以删除未使用的类/方法。在这种情况下,有几个标志可以帮助我们调整它,但我们将这些标志放在 Problem 256 中讨论。
246. 跟踪代 GC 过程
在这个问题中,让我们从一个任意初始状态的代 GC 开始,并跟随几个假设的时代(通常,所有代 GC 的工作方式与你在这个问题中看到的方式大致相同)。我们从一个以下图表开始:

图 12.2:GC 初始状态
在其初始状态,GC 有一个几乎满载的 Eden 空间(它存储了对象 1、4、5、2、6 和 3,以及一些空闲空间——由对象之间的白色间隙表示),Survivor 和 Tenured 空间为空。此外,对象 7 应该被添加到 Eden 空间中,但内存不足以容纳它。当 Eden 空间无法容纳更多对象时,GC 触发一个 MinorGC 事件。首先,识别 non-live objects。这里(如图所示),我们有三个对象(5、2 和 3)应该被收集为垃圾:

图 12.3:识别 Eden 空间中的非活动对象
这三个对象被收集为垃圾,因此它们被从堆中移除。接下来,live objects(1、4 和 6)被移动到 Survivor space 0。最后,新的对象(7)被添加到 Eden 空间中,如图所示:

图 12.4:从内存中移除对象(5、2 和 3),将对象移动到 Survivor space 0(1、4 和 6),并将对象 7 添加到 Eden 空间
这里,一个 epoch(完整的 GC 循环)已经结束。
之后,更多的对象被添加到 Eden 空间中,直到它再次几乎满载:

图 12.5:Eden 空间再次几乎满载
添加新的对象(12)需要触发一个 Minor GC 事件。再次,non-living objects 被识别如下:

图 12.6:在 Eden 和 Survivor 0 空间中存在非活动对象
有四个对象应该被收集为垃圾。在 Eden 空间中,有三个对象(11、10 和 9),在 Survivor space 0 中有一个对象(4)。这四个对象都被从堆中移除。Survivor space 0 中的 live objects(1 和 6)被移动到 Survivor space 1。Eden 空间中的 live objects(7 和 8)也被移动到 Survivor space 1。在任何时刻,一个 Survivor 空间都是空的。最后,新的对象(12)被添加到 Eden 空间中,如图所示:

图 12.7:另一个时代的结束
这里,另一个时代已经结束。
接下来,对象 13、14、15 和 16 被添加到 Eden 空间中,它再次几乎满载:

图 12.8:没有可用内存为对象 17 分配
随着 Eden 空间几乎满载,它无法容纳新的对象 17。一个新的 Minor GC 事件被触发,对象 12、15、16、13、6 和 8 被识别为 non-live objects:

图 12.9:在不同空间中有几个非活动对象
这些对象(12、15、16、13、6 和 8)从堆中移除。接下来,对象 14 从伊甸空间移动到幸存者空间 0。之后,对象 1 和 7(来自幸存者空间 1)被移动到幸存者空间 0。最后,新对象 17 被移动到伊甸空间,如图所示:

图 12.10:新对象(17)被添加到伊甸空间
这里,另一个时代已经结束。
我们重复这个场景,再次填满伊甸空间。当我们应该将对象 22 添加到伊甸空间时停止:

图 12.11:尝试在伊甸空间添加对象 22
如我们所知,GC 标记了所有非活动对象(在这里,17、21、18 和 7):

图 12.12:标记非活动对象
这次,垃圾收集器(GC)将对象 1(当它被认为足够老时)从年轻代提升到老代。接下来,来自伊甸空间(19 和 20)的对象和来自幸存者空间 0(14)的对象被移动到幸存者空间 1。结果如图所示:

图 12.13:我们第一个提升到老代的对象
在这个时代结束时,我们最终在持久代中有一个对象(1)。继续运行时代最终会填满持久代,这将无法容纳更多对象。换句话说,小 GC事件(这些是停止世界事件)将回收年轻代的内存,直到老代填满。当这种情况发生时,将触发混合 GC甚至全 GC事件(全 GC也是一个 STW 事件,并将处理元数据空间)。
简而言之,这就是 GC 的工作方式。当然,还有许多其他内部/外部因素可能会影响 GC 的决策。
247. 选择正确的垃圾收集器
正如你将在下一个问题中看到的,Java 允许我们在几个垃圾收集器之间进行选择。没有银弹,因此为你的特定应用程序选择正确的垃圾收集器是一个重要的决定,应该基于三个因素:吞吐量、延迟和占用空间。

图 12.14:影响 GC 选择的因素
吞吐量表示运行应用程序代码所花费的总时间与运行 GC 所花费的时间之比。例如,你的应用程序可能运行了总时间的 97%,因此你有 97%的吞吐量。剩余的 3%是运行 GC 所花费的时间。
延迟衡量了应用程序执行因 GC 导致的暂停而延迟的程度。这很重要,因为延迟会影响应用程序的响应性。这些暂停可能导致在交互层面给最终用户带来不愉快的体验。
内存占用表示 GC 运行其算法所需的额外内存。这是除了应用程序本身使用的内存之外的内存需求。
根据这三个因素选择合适的 GC 是一个非常主观的决定。你可能需要巨大的吞吐量同时可以忍受延迟,或者你可能无法承受延迟,因为你与最终用户有高度交互,或者你的可伸缩性与有限的物理内存直接相关,因此你非常关注内存占用因素。正如你将在下一个问题中看到的那样,每种 GC 类型在这三个因素的情况下都有其自身的优缺点。
248. 垃圾收集器的分类
垃圾收集器的演变与 Java 本身的演变完全一致。今天(JDK 21),我们区分了几种 GC 类型,如下所示:
-
串行垃圾收集器
-
并行垃圾收集器
-
垃圾-第一(G1)收集器
-
Z 垃圾收集器(ZGC)
-
沙南多垃圾收集器(非代收集器)
-
并发标记清除(CMS)收集器(已弃用)
让我们解决每种 GC 类型的主要方面。
串行垃圾收集器
串行垃圾收集器是一个 STW 单线程代收集器。在运行自己的算法之前,这个 GC 会冻结/暂停所有应用程序线程。这意味着这个 GC 不适合多线程应用程序,如服务器端组件。然而,由于它专注于非常小的内存占用(对小型堆很有用),这个收集器非常适合单线程应用程序(以及单处理器机器),它们可以轻松地容纳和容忍显著的延迟(例如,批处理作业或批量处理)。
并行垃圾收集器
并行垃圾收集器是一个 STW 多线程代收集器。在运行自己的算法之前,这个 GC 会冻结/暂停所有应用程序线程,但它通过使用多个线程来加速垃圾收集。换句话说,这个 GC 可以利用多处理器机器,并且对于使用中等/大型数据集的多线程应用程序来说是一个很好的选择。这个 GC 关注吞吐量而不是延迟,并且伴随着 1 秒或更长的暂停。因此,如果你处于可以承受 1 秒或更长时间暂停的多线程环境中,那么这个 GC 是正确的选择。
垃圾-第一(G1)收集器
垃圾优先(G1)收集器是一个 STW 多线程、基于区域、分代收集器,专注于平衡性能。这个 GC 在 JDK 7 更新 4 中引入,作为默认(自 JDK 9 起)解决方案,以维持高吞吐量和低延迟(几秒)。为此性能付出的代价是更频繁的 epochs。GC 将更频繁地运行,因此请准备提供一台 CPU,以便能够容纳比其他 GC 更多的周期。这个 GC 是为在多处理器机器上执行的服务器式应用程序设计的,这些机器具有大量内存(大堆大小)。也称为主要并发收集器,G1 使用等大小的空间/区域(从 1 到 32MB)在应用程序旁边进行大量操作。因此,如果您可以承受大堆大小并且需要低延迟,那么 G1 是正确的选择。我们将在后续问题中详细讨论 G1。
Z 垃圾回收器(ZGC)
Z 垃圾回收器(ZGC)从 JDK 15 开始用于生产,它是一种低延迟的 GC,可以处理大堆大小(数 TB)。像 G1 一样,ZGC 是并发工作的,但它保证不会使应用程序线程停止超过几毫秒(文档甚至指出 ZGC 可以以亚毫秒的最大暂停时间运行)。我们将在后续问题中详细讨论它。
Shenandoah 垃圾回收器
Shenandoah 垃圾回收器在 JDK 12 中引入(并在 JDK 17 中变得更加可靠)作为一个非常低延迟、高度响应的 GC(亚毫秒暂停)。它与应用程序并发执行其工作(包括压缩)。Shenandoah 暂停非常短,与堆大小无关。垃圾回收 1GB 的堆或 300GB 的堆应该产生类似的暂停。
并发标记清除(CMS)收集器(已废弃)
CMS 是一个被 G1 废弃的主要并发收集器。由于它已被废弃,我将不再进一步讨论它。
249. 引入 G1
G1 垃圾回收器可能是 Java 中最成熟、维护和改进的 GC。它在 JDK 7 更新 4 中引入,从 JDK 9 开始成为默认 GC。这个 GC 维持高吞吐量和低延迟(几秒),以其平衡的性能而闻名。
在内部,G1 将堆分割成大小相等的块(最大 32MB),这些块相互独立,可以动态地分配给Eden、Survivor或Tenured空间。每个这样的块被称为 G1 的堆区域。因此,G1 是一种基于区域的 GC。

图 12.15:G1 将内存堆分割成相等的小块
这种架构具有许多显著的优势。可能最重要的是,Old代可以通过清理低延迟的部分来有效地清理。
对于小于 4 GB 的堆大小,G1 将创建 1 MB 的区域。对于 4 到 8 GB 的堆,G1 将创建 2 MB 的区域,以此类推,直到 64 GB 或更大的堆为 32 MB。基本上,JVM 设置了一定数量的区域,这些区域的数量是 2 的幂,且在 1 到 32 MB 之间(通常,在应用启动期间,JVM 设置大约 2,000+个区域)。
设计原则
G1 的设计基于以下原则:
-
平衡性能 - 设计用于平衡吞吐量和低延迟,以维持性能。
-
代际 - 动态地将堆分为Young和Old代,并专注于Young代,因为在这个区域中垃圾更多(大多数对象在Young代区域死亡)。大多数对象生命周期短的观点也被称为代际假设。
-
Old代的增量收集 - G1 最终将对象从Young代移动到Old代,并让它们在那里慢慢死亡,并逐步收集它们。
-
主要并发 - G1 努力在应用(并发)附近执行重任务,同时保持低且可预测的暂停。
多亏了这些设计原则,G1 已经弃用了 CMS 收集器。
250. 解决 G1 吞吐量提升问题
从 JDK 8 到 JDK 20,G1 取得了重大进展。其中一些改进已经体现在吞吐量上。当然,这种吞吐量提升取决于许多因素(应用、机器、调整等),但你可以预期在 JDK 18/20 中至少比 JDK 8 有 10%以上的吞吐量提升。
为了提高吞吐量,G1 已经经历了几次变化,如下所述。
延迟 Old 代的启动
从 JDK 9 开始,G1 主要专注于从Young代收集垃圾,同时将Old代的启动(初始化、资源分配等)推迟到最后时刻(它预计何时应该启动Old代)。
专注于易收集
我们所说的“易收集”是指那些生命周期短(例如,临时缓冲区)、占用大量堆内存且可以以低成本轻松收集的对象,从而带来重要益处。从 JDK 9 开始,G1 高度关注易收集的对象。
提高 NUMA 感知内存分配
NUMA 代表非均匀内存访问,这在问题 245中有所描述。G1 从 JDK 14 开始利用 NUMA,并且持续改进。如果启用 NUMA,那么 JVM 要求操作系统将 G1 堆区域放置在 NUMA 节点上。在此过程结束时,整个堆应均匀分布在所有活跃的 NUMA 节点上。

图 12.16:带有和没有 NUMA 的堆内存
G1 堆区域与内存页面(操作系统页面 - en.wikipedia.org/wiki/Page_(computer_memory))之间的关系属于以下两种情况之一:
-
如果一个 G1 堆区域的尺寸大于或等于一个内存页的尺寸,那么一个 G1 堆区域将包含多个内存页(图 12.17,左侧)。
-
如果一个 G1 堆区域的尺寸小于或等于一个内存页的尺寸,那么一个内存页将包含多个 G1 堆区域(图 12.17,右侧)。

图 12.17:G1 堆区域与内存页的关系
没有 NUMA 时,G1 GC 从单个公共内存分配器为线程分配内存。有 NUMA 时,每个 NUMA 节点都有一个内存分配器,内存分配是基于这些 NUMA 节点的。
提高 NUMA 分配意识是 G1 的持续目标。
并行化全堆收集
在其他不太常见的优化中,我们有全堆收集的并行化。这一改进是在 JDK 10 中添加的,作为使全堆收集尽可能快的一种解决方案。
其他改进
JVM 本身已经添加了大量的微小改进,这些改进在 GC 性能上也有所体现。这意味着通过简单地更新到最新的 JDK,我们的 GC 将会表现得更好。您会发现 JDK 8 和 JDK 20 之间至少有 10% 的性能提升。
251. 解决 G1 延迟改进
从 JDK 8 到 JDK 20,G1 GC 延迟也记录了一些改进(这显然也反映在 G1 GC 吞吐量上)。
为了减少延迟,G1 已经经历了几次变化,如下所述。
合并并行阶段为一个更大的阶段
从 JDK 8 开始,G1 的许多方面都进行了并行化。换句话说,在任何时刻,我们可能都在执行多个并行阶段。从 JDK 9 开始,这些并行阶段可以被合并成一个更大的阶段。实际上,这意味着减少了同步,减少了创建/销毁线程的时间。因此,这一改进加快了并行化处理,减少了延迟。
减少元数据
减少元数据是在 JDK 11 中添加的。实际上,G1 尽可能地减少元数据量,以管理更少的元数据。管理的数据越少,延迟就越好。当然,这也意味着更小的内存占用。
更好的工作平衡
从 JDK 11 开始,工作平衡得到了改进。简而言之,这意味着完成当前工作的线程可以从其他线程那里窃取工作。实际上,这意味着任务完成得更快,因为所有线程都在工作(在自己的工作或窃取的工作上),没有线程只是挂起。因此,开发了更智能的算法来协调和保持线程忙碌,以更快地完成任务并减少延迟。然而,减少窃取工作的开销仍然是改进的主题。
更好的并行化
从 JDK 14 开始,提供了更好的并行化。实际上,G1 从潜在的引用区域中移除了所有重复项。之后,它应用并行化而不是蛮力。
更好的引用扫描
为了更好地实现并行化,JDK 15 也改进了收集区域中的引用扫描。JDK 14 知道如何去除重复项并并行化数据处理,而 JDK 15 知道如何更优化地扫描引用。它们的效果结合在一起,降低了延迟。
其他改进
在改进所谓的非常见情况上花费了很多时间。例如,特别关注了迁移失败(在两个内存区域之间移动活动对象并压缩它们的尝试被称为迁移方式,当移动对象导致内存不足问题时,我们就有了一个迁移失败)。为了比以前更快地处理此类场景,这个边缘情况得到了严重改进(在 JDK 17 之前)。
252. 解决 G1 占用空间改进
在 JDK 8 和 JDK 20 之间,G1 的占用空间通过关注高效的元数据和尽可能快地释放内存得到了改进。
为了优化其占用空间,G1 已经经历了多次变化,如下所述。
仅保留所需的元数据
为了仅保留所需的元数据,JDK 11 能够并发(重新)创建所需的数据,并尽可能快地释放它。在 JDK 17 中,对所需元数据的关注得到了重申,并且只保留绝对需要的数据。此外,JDK 18 提出了数据更密集的表示形式。所有这些改进都反映在更小的占用空间中。
释放内存
从 JDK 17 开始,G1 垃圾收集器能够并发释放内存(将其归还给操作系统)。这意味着内存可以被最优地重用,并可用于服务其他任务。
253. 引入 ZGC
Z 垃圾收集器(ZGC)首次(作为一个实验性功能)在 JDK 11 中引入。它在 JDK 15 中被提升到生产阶段(生产就绪),根据 JEP 377。它仍在不断改进——在 JDK 21 中,ZGC 通过维护年轻和旧对象的不同代来维持应用程序性能。基本上,这最小化了分配停滞和堆内存开销。此外,JDK 21(JEP 439)将 ZGC 的状态从目标提升到完成。
ZGC 是并发的(基于低级并发原语,如加载屏障和彩色指针),跟踪(遍历对象图以识别活动和非活动对象),以及压缩(对抗碎片)。它也是 NUMA 意识的,基于区域的。
ZGC 是专门设计为低延迟、高度可扩展的垃圾收集器,能够处理从小型(几兆字节;文档中提到 8 MB)到大型(兆字节;文档中提到 16 TB)的堆,最大暂停时间(脉冲时间)仅为几毫秒(文档中提到亚毫秒最大暂停时间)。

图 12.18:ZGC 专注于低延迟
非常重要的是要说明,暂停时间不会随着堆大小的增加而增加(脉冲时间复杂度为 O(1),因此它们在恒定时间内执行)。
ZGC 的缺点(权衡)在于吞吐量。换句话说,与 G1 吞吐量相比,ZGC 的吞吐量略有降低(例如,在某些情况下从 0%降低到 10%)。
从 JDK 16 开始,ZGC 利用了并发线程堆栈,从 JDK 18 开始,它支持字符串去重。这些只是众多改进中的两个主要改进。
ZGC 是自动调优的。换句话说,正如你将在问题 256中看到的那样,ZGC 只有少数几个我们可以调整的选项,而大部分调整是自动的。
ZGC 是并发的
G1 和 ZGC 都是并发的,但它们并不遵循相同的路径。ZGC 力求以并发的方式收集尽可能多的垃圾。为此,ZGC 依赖于三个主要轻量级(非常短,亚毫秒级)暂停和三个并发阶段,如下面的图所示:

图 12.19:ZGC 并发
每个阶段都由一个同步点(暂停)来表示:
-
暂停标记开始 – 这个暂停信号表示将跟随并发标记阶段。在这个同步点,ZGC 为执行并发标记阶段准备当前状态。这是一个轻量级暂停,对彩色指针进行一些设置,并重置一些标志和计数器。接下来,并发标记阶段并发运行并标记堆中的对象。
-
暂停标记结束 – 这个暂停信号表示并发标记阶段的结束。它也在执行并发准备重定位阶段之前暂停。这个阶段负责定位所有来自稀疏填充区域的活动对象,并将它们标记为可以移动/迁移到其他区域的候选者。此外,在这个阶段,ZGC 解除分配不包含活动对象的区域。
-
暂停重定位开始 – 这个暂停信号表示将跟随并发重定位阶段。在这个阶段,前一个阶段标记为迁移候选者的对象实际上被(复制)从当前区域移动到新区域。它们的引用也被恢复,从当前区域解除分配并重新分配到新区域。
ZGC 和彩色指针
ZGC 在应用程序旁边运行并操作(移动)该应用程序使用的对象。这可能导致意外的错误(例如,应用程序可能尝试使用过时的引用),从而导致应用程序行为异常甚至崩溃。为了防止这种情况,ZGC 依赖于两种称为彩色指针和加载屏障的低级并发原语。
彩色指针是一个 64 位指针。这个指针由 ZGC 使用一个 44 位对象地址,能够处理高达 16 terabytes。

图 12.20:彩色指针
彩色指针为存储此指针的元数据保留了 20 位。最重要的元数据是:
-
Finalizable – 1 位表示对象是否可达(活动对象)
-
重映射 – 1 位表示对象是否不指向重定位集
-
Marked0/Marked1 – 2 位表示对象是否被标记
除了彩色指针外,ZGC 还需要负载屏障。
ZGC 和负载屏障
负载屏障是编译器注入的代码片段,用于处理彩色指针。虽然应用程序不知道彩色指针,但 ZGC 需要解释和与它们一起工作,这正是负载屏障的工作。例如,假设我们在应用程序中有以下代码片段(我故意手动添加了行号):
1: Manager manager = company.manager;
2: Manager cManager = manager;
3: manager.attendMeeting();
4: int employeeNr = company.size;
编译器分析代码以决定在哪里注入一个负载屏障。结论是,唯一应该在行 1 和行 2 之间注入负载屏障的地方,因为那里是唯一从堆中加载对象的地方。在行 2 中,不需要负载屏障,因为有一个内存引用的副本。在行 3 中,也不需要负载屏障,因为有一个方法引用。最后,在行 4 中,也不需要负载屏障,因为没有对象引用。所以,ZGC 看到这段代码如下:
1: Manager manager = company.manager;
**<load barrier injected at this point>**
2: Manager cManager = manager; // copying reference
3: manager.attendMeeting(); // method reference
4: int employeeNr = company.size; // no object reference
负载屏障的目的是确保指针是有效的(通过良好的颜色显示)。如果遇到坏颜色,则负载屏障会尝试修复它(更新指针,重新定位对象引用等)。
ZGC 是区域基于的
ZGC 是一种基于区域的垃圾回收器,因此它将堆划分为更小的区域/块,这些区域/块分配给年轻或老代,如下面的图所示:

图 12.21:ZGC 堆区域
与 G1 完全一样,ZGC 是一种基于区域的垃圾回收器。然而,ZGC 比 G1 更强大,并且能够在运行时动态地增加/减少活动区域的数量。此外,ZGC 可以依赖于三种大小的区域,如下所示:
-
小区域 – 这些区域大小为 2 MB。
-
中等区域 – 这些区域可以从 4 MB 到 32 MB 不等。它们是动态大小的。
-
大区域 – 这些区域是为巨大对象预留的。这些区域是紧密匹配的,可能比中等区域小或大。
因此,乍一看,ZGC 是一种具有恒定暂停时间(亚毫秒级)的并发垃圾回收器,以并行模式工作,并且能够通过压缩来对抗碎片化。此外,它是基于区域的,NUMA 感知的,能够自动调整,并依赖于彩色指针和负载屏障。
254. 监控垃圾回收器
监控 GC 在时间线中的活动和演变是识别潜在性能问题的关键。例如,您可能对监控暂停时间、识别 GC 事件的频率和类型、被触发的 GC 事件填充的空间等感兴趣。主要目标是收集尽可能多的信息,这些信息有助于解决与堆内存和 GC 进化相关的性能问题。
任何现代 IDE 都提供包含(包括其他相关事物)GC epochs/cycles 的信息和实时图表的剖析器。例如,以下图来自 NetBeans IDE,它将 GC 进化(堆状态)显示为工具栏的一项(只需单击该区域,就可以强制 GC 执行垃圾回收):

图 12.22:NetBeans 在工具栏上显示 GC 进化
当然,通过 NetBeans 剖析器可以获得更详细的信息:

图 12.23:NetBeans 的 GC 分析器
在可用于监控您的 GC 的其他工具中,还有 jstat 命令行实用程序(jstat -gc $JAVA_PID)和 JConsole(Java 监控和管理控制台)。
以下图是来自 JConsole 的截图:

图 12.24:通过 JConsole 监控 GC
您可能还对 Oracle 的 visualgc(可视化垃圾回收监控工具)、JDK VisualGC(IntelliJ IDE 插件)和 Eclipse 的 Memory Analyzer(MAT)感兴趣。
255. 记录垃圾收集器
分析 GC 日志是另一种有用的方法,可以帮助找到内存问题。由于 GC 日志不会增加显著的开销,因此可以在生产环境中启用它们进行调试。实际上,GC 日志的开销微乎其微,因此您绝对应该使用它们!
让我们考虑一些简单的 Java 代码,该代码向 List<String> 添加和删除元素。添加和删除代码需要通过 System.gc() 执行完整 GC:
private static final List<String> strings = new ArrayList<>();
...
logger.info("Application started ...");
String string = "prefixedString_";
// Add in heap 5 millions String instances
for (int i = 0; i < 5_000_000; i++) {
String newString = string + i;
strings.add(newString);
}
logger.info(() -> "List size: " + strings.size());
// Force GC execution
System.gc();
// Remove 10_000 out of 5 millions
for (int i = 0; i < 10_000; i++) {
String newString = string + i;
strings.remove(newString);
}
logger.info(() -> "List size: " + strings.size());
logger.info("Application done ...");
接下来,我们想要运行这个简单的应用程序并记录 GC 活动。
在 JDK 9 之前,我们可以通过 -verbose:gc 选项获取 GC 的快速和详细日志:
java … -verbose:gc
可能的输出如下所示:
[0.319s][info][gc] Using G1
**[17:03:47] [INFO] Application started ...**
[0.917s][info][gc] GC(0) Pause Young (Normal)
(G1 Evacuation Pause) 27M->24M(96M) 34.391ms
[0.948s][info][gc] GC(1) Pause Young (Normal)
(G1 Evacuation Pause) 40M->40M(96M) 21.300ms
[0.986s][info][gc] GC(2) Pause Young (Normal)
(G1 Evacuation Pause) 60M->60M(96M) 24.085ms
[0.997s][info][gc] GC(3) Pause Young (Concurrent Start)
(G1 Humongous Allocation) 63M->64M(96M) 8.072ms
[0.997s][info][gc] GC(4) Concurrent Mark Cycle
[1.030s][info][gc] GC(5) Pause Young (Normal)
(G1 Evacuation Pause) 78M->78M(288M) 17.036ms
[1.059s][info][gc] GC(4) Pause Remark 101M->94M(288M) 0.867ms
[1.083s][info][gc] GC(4) Pause Cleanup 109M->109M(288M) 0.14ms
[1.085s][info][gc] GC(4) Concurrent Mark Cycle 87.261ms
[1.125s][info][gc] GC(6) Pause Young (Prepare Mixed)
(G1 Evacuation Pause) 116M->118M(288M) 32.640ms
[1.220s][info][gc] GC(7) Pause Young (Mixed)
(G1 Evacuation Pause) 181M->181M(288M) 42.497ms
[1.257s][info][gc] GC(8) Pause Young (Concurrent Start)
(G1 Humongous Allocation) 200M->201M(288M) 23.297ms
[1.257s][info][gc] GC(9) Concurrent Mark Cycle
[1.316s][info][gc] GC(10) Pause Young (Normal)
(G1 Evacuation Pause) 243M->244M(288M) 24.492ms
[1.345s][info][gc] GC(11) Pause Young (Normal)
(G1 Evacuation Pause) 256M->258M(776M) 12.445ms
[1.400s][info][gc] GC(9) Pause Remark 290M->274M(776M) 0.732ms
[1.461s][info][gc] GC(9) Pause Cleanup 335M->335M(776M) 0.25ms
[1.466s][info][gc] GC(9) Concurrent Mark Cycle 209.289ms
[1.531s][info][gc] GC(12) Pause Young (Prepare Mixed)
(G1 Evacuation Pause) 344M->345M(776M) 54.939ms
**[17:03:48] [INFO] List size: 5000000**
[1.830s][info][gc] GC(13) Pause Full (System.gc())
368M->330M(776M) 277.793ms
**[17:04:15] [INFO] List size: 4990000**
**[17:04:15] [INFO] Application done ...**
这是最简单的 GC 日志。要获取更多详细信息,我们可以添加 -XX:+PrintGCDetails 选项:
java … -XX:+PrintGCDetails -verbose:gc
此外,我们可以附加一些选项来获取关于十岁分布(-XX:+PrintTenuringDistribution)、垃圾收集器时间戳(-XX:+PrintGCTimeStamps)、类直方图(-XX:+PrintClassHistogram)和应用停止时间(-XX:+PrintGCApplicationStoppedTime)的信息。
在这种情况下,GC 日志在控制台(stdout)上可用,使用 info 级别。您可以通过 -Xloggc 选项轻松地将 GC 日志重定向到文件:
java … -verbose:gc -Xloggc:gclog.txt
实际上,-Xloggc 已被弃用,并且您只有在使用版本低于 9 的 JDK 时才应使用它。从 JDK 9(JEP 158 – openjdk.org/jeps/158)开始,我们有一个针对所有 JVM 组件的 统一日志系统。
因此,从 JDK 9 开始,我们通过 –Xlog 选项拥有一个统一的日志系统。-XX:+PrintGCDetails -verbose:gc 的等效选项是 -Xlog:gc*。如果我们想以调试级别将 GC 日志重定向到文件,则可以这样做:
java … -Xlog:gc*=debug:file=gclog.txt
gclog.txt 将保存在应用程序根目录中。如果您删除了 * 字符,那么您将得到一个不那么冗长的垃圾收集器日志。
通过 -Xlog:numa*={log level} 可用仅记录 NUMA 日志。
拥有垃圾收集器日志只是问题的一半。另一半包括解释这个日志。如您所见,这并不容易。幸运的是,您不必费心阅读日志文件,因为我们有能够解析、分析和从垃圾收集器日志中提供详细报告的工具。
这些工具之一是通用垃圾收集器日志分析器(gceasy.io/)。使用免费版本,我们可以上传我们的 gclog.txt 文件并获得详细报告。例如,在下面的图中,我们可以看到为我们的应用程序分配了多少内存。

图 12.25:来自通用垃圾收集器日志分析器(GCEasy)报告的截图
此图只是报告的一小部分。自己尝试一下,看看完整的报告。您可能还想尝试的其他类似工具包括 GCViewer、GCPlot、IBM 垃圾收集器和内存可视化器、garbagecat、SolarWinds Loggly、Sematext Logs、Java 飞行记录器(JFR)、jvm-gc-logs-analyzer 等。
256. 调整垃圾收集器
垃圾收集器是一种复杂的机械,其性能与当前 JVM、当前应用程序和硬件环境中的设置(启动参数)高度相关。由于垃圾收集器会消耗和共享资源(内存、CPU 时间等)与我们的应用程序,因此将其调整到尽可能高效地工作至关重要。如果垃圾收集器效率不高,那么我们可能会面临显著的暂停时间,这将负面地影响应用程序的运行。
在这个问题中,我们将介绍串行 GC、并行 GC、G1 GC 和 ZGC 可用的主要调整选项。
如何调整
在尝试调整垃圾收集器之前,请确保它确实引起了问题。通过检查和关联图表和日志,您可以识别这些问题并决定您应该采取行动的地方(应该调整哪些参数)。检查堆内存的使用情况以及对象如何填充 Eden、Survivor 和 Tenured 空间。
通常,一个健康的垃圾收集器会产生一个称为 shark teeth 的堆使用图,如下面的图所示:

图 12.26:健康的堆使用
此外,检查 90^(th)和 99^(th)百分位数以及平均 GC 时间。这些信息可以给你一个提示,了解是否需要更多的内存或者是否已经正确清理。
一旦你确定了 GC 问题,尝试逐一解决它们。不要急于同时更改多个参数,因为这很难管理和分析它们的综合效果。尝试修改其中一个,并实验看看发生了什么以及结果如何。如果你看到一些好处,那么就进行下一个实验,并再次实验。观察综合效果是否有所改善。否则,也许在尝试下一个之前,最好将这个参数恢复到其默认值。
调整串行垃圾回收器
可以通过-XX:+UseSerialGC启用串行垃圾回收器。
由于这是一个单线程 GC,没有太多可以调整的。然而,你可以通过-Xmx和-Xms调整堆大小(例如,可以通过-Xmx3g和-Xms3g设置 3GB 的堆大小)以及通过-Xmn选项调整Young代的大小。不过,这些选项与所有类型的 GC 一起工作,用于设置堆大小。
调整并行垃圾回收器
可以通过-XX:+UseParallelGC启用并行垃圾回收器。
这个垃圾回收器是多线程的,我们可以通过-XX:ParallelGCThreads选项来控制用于清理任务的线程数量(例如,设置六个线程可以通过-XX:ParallelGCThreads=6来完成)。
请记住,线程数量越多,为Tenured空间保留的堆的碎片化程度就越高。每个参与Minor GC 事件的线程都会在Tenured空间中为其晋升目标保留一些空间。这将导致Tenured空间的严重碎片化。解决这个问题需要减少线程数量并增加Old代的大小。
最大暂停时间可以通过-XX:MaxGCPauseMillis选项来控制(例如,-XX:MaxGCPauseMillis=150,这将确保在两次连续的 GC 运行/事件之间最大暂停时间为 150 毫秒)。然而,请注意,更大的暂停时间将允许更多的垃圾进入堆。这意味着 GC 的下一次运行将更加昂贵。另一方面,较小的暂停时间将指示 GC 更频繁地运行,这可能会导致应用程序在垃圾回收上花费太多时间。
接下来,我们想要达到的最大吞吐量可以通过-XX:GCTimeRatio选项来设置。此选项是 GC 内部与外部花费时间的比率。这是一个计算为 1/(1+n)的百分比。换句话说,-XX:GCTimeRatio指定了在 1/(1+n)比率中分配给垃圾回收的时间量。
例如,如果我们设置此选项为 -XX:GCTimeRatio=14,那么我们的目标是 1/15。这意味着总时间的 6% 应该用于垃圾收集(默认情况下,此选项设置为 99,即 1% 的时间用于垃圾收集)。
如果您遇到 OutOfMemoryError,那么这很可能是由于垃圾收集花费了太多时间造成的。例如,如果超过 98% 的时间用于恢复不到 2% 的堆,那么您将看到这样的错误。换句话说,GC 花了很长时间清理堆的小部分。这可能表明存在内存泄漏或堆太小。尽管如此,如果您可以容忍这个错误,那么您可以通过 -XX:-UseGCOverheadLimit 选项来抑制它。
我们还可以控制 Young/Old 代的大小。您可以通过 -XX:YoungGenerationSizeIncrement 控制年轻代的增长,通过 -XX:TenuredGenerationSizeIncrement 控制老年代的成长。这些选项的值是百分比(默认情况下,增长百分比为 20%,缩减百分比为 5%)。此外,您可以通过简单地设置 -XX:AdaptiveSizeDecrementScaleFactor 选项来控制缩减百分比。Young 代的缩减会自动通过 -XX:YoungGenerationSizeIncrement/-XX:AdaptiveSizeDecrementScaleFactor 计算。
调整 G1 垃圾收集器
可以通过 -XX:+UseG1GC 启用 G1 垃圾收集器。
默认情况下,G1 负责管理 Young 代。基本上,它会清理 Young 代并将可达对象提升到 Old 代,直到达到 45% 的阈值。此默认值可以通过 -XX:InitiatingHeapOccupancyPercent 进行更改。
当调整 G1 收集器时,我们可以针对吞吐量、延迟或占用空间进行优化。当针对延迟进行优化时,我们必须关注低暂停时间。这可以通过将 –Xmx 和 –Xms 选项设置为相同的值(以避免堆大小调整)来实现。此外,我们可以依靠 -XX:+AlwaysPreTouch 和 -XX:+UseLargePages 标志选项在应用程序启动时加载(大)内存页面。
如果延迟受到 Young 代大小的影响,那么通过 -XX:G1NewSizePercent 和 -XX:G1MaxNewSizePercent 减小其大小是一个好主意。另一方面,如果 Mixed GC 事件影响延迟,那么我们应该通过 -XX:G1MixedGCCountTarget 标志选项在更多收集中分配 Tenured 空间。此外,我们可能还想关注 -XX:G1HeapWastePercent(提前停止 Tenured 空间清理)和 -XX:G1MixedGCLiveThresholdPercent(只有当此阈值超过时,Tenured 空间才成为混合收集的一部分,默认为 65)。您可能还对 -XX:G1RSetUpdatingPauseTimePercent、-XX:-ReduceInitialCardMarks 和 -XX:G1RSetRegionEntries(有关详细信息,请参阅 G1 文档)感兴趣。
当调整吞吐量(处理大量数据的应用程序需要能够清理尽可能多的垃圾的 GC)时,我们必须关注 -XX:MaxGCPauseMillis 选项。当此选项效果较低时,我们应该关注 -XX:G1NewSizePercent 和 -XX:G1MaxNewSizePercent。基本上,G1 力求将 Young 代的大小限制在 -XX:G1NewSizePercent(默认为 5)和 -XX:G1MaxNewSizePercent(默认为 60)的值之间。通过调整这三个选项,我们可以放宽 GC,给它更多的时间和空间来处理大量垃圾。此外,吞吐量可以通过 -XX:G1RSetUpdatingPauseTimePercent 选项来维持。
通过增加此选项的值,我们可以在暂停应用程序线程时进行更多工作,同时减少在并发部分花费的时间。此外,与调整延迟的情况一样,我们可能想要避免堆大小调整(将 –Xmx 和 –Xms 设置为相同的值)并开启 -XX:+AlwaysPreTouch 和 -XX:+UseLargePages 标志选项。
调整内存占用大小可能会受到设置 -XX:GCTimeRatio 的影响。默认值为 12(8%),但我们可以将其增加以迫使垃圾回收(GC)花费更多时间在垃圾回收上。结果,将有更多的堆内存被释放,但这并不是一个普遍的规则。建议进行实验以了解其真实效果。此外,自 JDK 8(更新 20)以来,我们可以设置 -XX:+UseStringDeduplication 标志选项。实际上,如果启用此选项,那么 G1 在清理重复字符串时会定位重复的字符串,并保留对其中一个字符串的单个引用。这应该会导致堆内存的更高效和优化使用。您还可能希望查阅 -XX:+PrintStringDeduplicationStatistics 和 -XX:StringDeduplicationAgeThreshold=n 的文档。
如您所知,G1 将堆分成最多 32 MB 的小区域。在实践中,这可能会导致性能下降,尤其是在非常大的堆上处理大对象时。但是,从 JDK 18 开始,最大区域大小被设置为 512 MB。您需要时,可以通过 -XX:G1HeapRegionSize 控制最大区域大小。
调整 Z 垃圾回收器
Z 垃圾回收器可以通过 -XX:+UseZGC 启用(在 JDK 15 之前,您可能还需要 -XX:+UnlockExperimentalVMOptions)。
这个 GC 最重要的设置之一是 –Xmx,用于设置最大堆大小。接下来是 -XX:ConcGCThreads=n,其中 n 是 ZGC 使用的线程数。然而,ZGC 完全能够动态确定此选项的最佳值,因此在修改之前请三思。
调整元空间(元数据空间)
如果您的重点是调整元空间,那么您会对以下选项感兴趣:
-
-XX:MetaspaceSize– 设置元空间的初始大小 -
-XX:MaxMetaspaceSize– 设置元空间的最大大小 -
-XX:MinMetaspaceFreeRatio– 设置 GC 运行后应空闲的类元数据容量(这是作为百分比的最低值) -
-XX:MaxMetaspaceFreeRatio– 设置 GC 运行后应空闲的类元数据容量(这是作为百分比的最高值)
控制元空间的大小和行为也可以是调整 GC 的一部分。再次强调,实验和比较结果是你通往成功和最佳 GC 的主要规则。
257. 介绍应用程序类数据共享(AppCDS,或 Java 的启动加速器)
启动 Java 应用程序是一个多步骤的过程。在执行类的字节码之前,JVM 必须为给定的类名执行至少以下步骤:
-
在磁盘上查找类(JVM 必须扫描磁盘并找到给定的类名)。
-
加载类(JVM 打开文件并加载其内容)。
-
检查字节码(JVM 验证内容的一致性)。
-
在内部拉取字节码(JVM 将代码传输到内部数据结构)。
显然,这些步骤并非没有成本。加载数百/数千个类将在启动时间和内存占用上产生显著开销。通常,应用程序的 JAR 文件在很长时间内保持不变,但 JVM 执行前面的步骤,并在每次启动应用程序时获得相同的结果。
提高或加速启动性能,甚至减少内存占用,是应用程序类数据共享(AppCDS)的主要目标。简而言之,AppCDS 最初在 JDK 10(2018)中流行起来,并在 JDK 13 和 JDK 19 中简化。AppCDS 的想法是只执行前面的步骤一次,并将结果存入存档。这个存档可以用于后续的启动,甚至可以在同一主机上运行的多个 JVM 实例之间共享。应用程序越大,启动时的好处就越大。
将这些想法付诸实践需要以下三个步骤:
-
创建应在应用程序实例之间共享的类列表。
-
将这个类列表存档到适合内存映射的存档中。
-
将生成的存档提供给每个应用程序启动(每个应用程序实例)。
根据使用的 JDK,你可能需要手动遵循这些步骤或其中的一部分。AppCDS 算法不断改进,因此其使用取决于你的 JDK,如下所示:
-
在 JDK 10/11 中,你必须遵循前面的三个步骤。然而,如果你想只共享 JDK 类(而不是应用程序类),那么你可以跳过步骤 1。JDK 已经在
$JAVA_HOME\lib\classlist中准备了应共享的类列表(大约有 1,200 个类)。 -
在 JDK 12+ 中,你可以跳过步骤 1 和 2,因为 JDK 类的存档已经可用。然而,如果你想共享应用程序类,那么你需要遵循所有三个步骤。
-
在 JDK 13+ 中,我们可以利用动态 CDS 存档。实际上,JVM 在应用程序运行时收集要添加到存档中的类。步骤 1 和 2 会自动合并。
-
在 JDK 19+ 中,我们可以利用自动生成的共享存档。CDS 存档在一个命令中构建和使用。
处理 JDK 类数据存档
处理 JDK 类数据存档意味着我们将创建一个只包含 JDK 类的可重复使用的存档,而不是我们的应用程序类。
JDK 10/JDK 11
在 JDK 10/11 中,我们可以使用现有的 $JAVA_HOME/lib/classlist 文件。这是一个包含 JDK 类列表的文件,你可以使用文本编辑器轻松地检查它。有了类列表,我们可以通过 –Xshare:dump 选项创建适当的 CDS 存档,如下所示:
java … -Xshare:dump
生成的存档将存储在 $JAVA_HOME\bin\server\classes.jsa(这是默认位置,你可能需要以管理员身份运行此命令以避免权限拒绝限制)。
接下来,我们可以通过 –Xshare:on 使用此存档,如下所示(如果你在 JDK 11 下运行,则需要 --enable-preview,但在这里我将跳过它):
java … -Xshare:on
我们可以使用统一的日志系统通过 –Xlog 跟踪 CDS 工作,如下所示:
java … -Xshare:on -Xlog:class+load:file=cds.log
在输出中,我们可以看到共享对象被标记了一个重要的消息,如下所示(未共享的对象不包含“共享对象文件”文本):
[6.376s][info][class,load] java.lang.Object source: shared objects file
默认情况下,JVM 在默认位置搜索存档。但是,如果我们把存档移动到另一个位置(例如,在应用程序根目录中),那么我们必须通过 -XX:SharedArchiveFile 指定此位置,如下所示:
java … -Xshare:on -XX:SharedArchiveFile=./classes.jsa
-Xlog:class+load:file=cds.log
实际上,–Xshare 的默认值是 auto。这意味着如果找到了存档,则会自动使用。所以,如果你省略 –Xshare:on,则 JVM 依赖于 –Xshare:auto,这具有相同的效果。如果你想关闭 CDS 支持,则使用 –Xshare:off。
JDK 12+
JDK 12+ 已经为 JDK 类准备好了存档,因此不需要创建(不需要使用 –Xshare:dump)。JVM 会自动使用它,归功于 –Xshare:auto 或显式的 –Xshare:on:
java … -Xshare:on -Xlog:class+load:file=cds.log
当然,如果你有不同于默认位置($JAVA_HOME\bin\server\classes.jsa)的位置,则使用 -XX:SharedArchiveFile。
处理应用程序类数据存档
除了 JDK 类,我们可能还想共享我们的应用程序类。这取决于 JDK,可以通过几个步骤完成。
在 JDK 13 之前
在 JDK 13 之前,我们需要创建我们想要共享的类的列表。我们可以手动完成或通过 -XX:DumpLoadedClassList 选项完成,如下所示(我将跳过它,但你需要 --enable-preview):
java ... -XX:DumpLoadedClassList=classes.lst
生成的 classes.lst 包含所有将共享的类(由 JDK 和你的应用程序类使用的类)。接下来,我们可以获取存档,如下所示:
java … -Xshare:dump -XX:SharedClassListFile=classes.lst
-XX:SharedArchiveFile=appcds.jsa --class-path app.jar
考虑以下重要提示。
重要提示
注意,CDS(AppCDS)只能从 JAR 文件存档类。不要使用带有通配符或展开路径的类路径,例如 target/classes。将 app.jar 替换为您的 JAR 文件。
存档 (appcds.jsa) 存储在应用程序根目录中,而不是在 $JAVA_HOME 中。
最后,我们可以分享存档并获取一些日志,如下所示:
java … -Xshare:on -XX:SharedArchiveFile=./appcds.jsa
-Xlog:class+load:file=cds.log
完成!
JDK 13+
从 JDK 13 开始,我们可以利用 动态应用程序类数据共享。换句话说,我们可以通过 -XX:ArchiveClassesAtExit 选项在 JVM 退出时获取存档,如下所示:
java ... -XX:ArchiveClassesAtExit=appcds.jsa -jar app.jar
将 app.jar 替换为您的 JAR 文件。通过 -Xshare:on 和 -XX:SharedArchiveFile 选项像往常一样使用生成的存档。
JDK 19+
从 JDK 19 开始,我们可以依赖自动生成的存档。这可以通过单个命令实现,如下所示:
java … -XX:+AutoCreateSharedArchive
-XX:SharedArchiveFile=appcds.jsa –jar app.jar
这次,JVM 会检查通过 -XX:SharedArchiveFile 选项提供的路径上是否存在存档。如果存在这样的存档,则 JVM 会加载并使用它;否则,在退出时,JVM 将在该位置生成一个存档。此外,JVM 会检查创建存档所使用的 JDK 版本。如果当前 JDK 版本(JVM JDK 版本)和存档 JDK 版本不同,则 JVM 将覆盖现有的存档。
您可能还对以下文章感兴趣:spring.io/blog/2023/12/04/cds-with-spring-framework-6-1。
摘要
本章涵盖了 15 个与垃圾收集器和 AppCDS 相关的问题。即使这些问题大多是理论性的,它们仍然代表了可以提升您应用程序在运行时(在 GC 的情况下)和启动时(在 AppCDS 的情况下)性能的主要主题。
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:

第十三章:Socket API 和简单 Web 服务器
本章包括 11 个关于 Socket API 的问题和 8 个关于 JDK 18 简单 Web 服务器(SWS)的问题。在前 11 个问题中,我们将讨论实现基于套接字的应用程序,如阻塞/非阻塞服务器/客户端应用程序、基于数据报的应用程序和多播应用程序。在本章的第二部分,我们将讨论 SWS 作为命令行工具和一系列 API 点。
在本章结束时,您将了解如何通过 Socket API 编写应用程序,以及如何使用 SWS 进行测试、调试和原型设计任务。
问题
使用以下问题来测试您在 Socket API 和 SWS 中的高级编程能力。我强烈建议您在查看解决方案和下载示例程序之前尝试解决每个问题:
-
介绍套接字基础知识:提供对套接字基础知识及其相关背景(TCP、UDP、IP 等)的简要但富有意义的介绍。
-
介绍 TCP 服务器/客户端应用程序:介绍编写阻塞/非阻塞 TCP 服务器/客户端应用程序所需的知识。
-
介绍 Java Socket API:突出编写基于套接字的 Java 应用程序所需的主要 Socket API(NIO.2)。
-
编写阻塞 TCP 服务器/客户端应用程序:提供一个阻塞 TCP 服务器/客户端应用程序的详细示例(理论和代码)。
-
编写非阻塞 TCP 服务器/客户端应用程序:提供一个非阻塞 TCP 服务器/客户端应用程序的详细示例(理论和代码)。
-
编写 UDP 服务器/客户端应用程序:编写一个 UDP 服务器/客户端应用程序,包括无连接客户端和连接客户端。
-
介绍多播:用简单的话解释多播的含义。
-
探索网络接口:编写一段代码,显示您机器上可用的网络接口的详细信息。
-
编写 UDP 多播服务器/客户端应用程序:解释并举例说明基于 UDP 多播的应用程序实现。
-
将密钥封装机制 (KEM) 添加到 TCP 服务器/客户端应用程序中:解释并举例说明 JDK 21 KEM 在 TCP 服务器/客户端应用程序中加密/解密通信的使用。
-
重新实现传统的 Socket API:简要概述 JDK 版本之间 Socket API 的演变。
-
SWS 快速概述:简要介绍 JDK 18 SWS。解释其工作原理及其关键抽象。
-
探索 SWS 命令行工具:提供通过命令行启动、使用和停止 SWS 的分步指南。
-
介绍 com.sun.net.httpserver API:描述 SWS API 的支柱。
-
适配请求/交换:提供一些代码片段,以适应 SWS 请求/交换的定制场景。
-
用另一个处理器补充条件 HttpHandler:编写一个示例,展示如何有条件地选择两个
HttpHandler实例。 -
为内存文件系统实现 SWS:编写一个 SWS 实现,从内存文件系统中提供资源(例如,Google Jimfs 内存文件系统或其他类似解决方案)。
-
为 ZIP 文件系统实现 SWS:编写一个 SWS 实现,从 ZIP 存档中提供资源。
-
为 Java 运行时目录实现 SWS:编写一个 SWS 实现,从 Java 运行时目录(JEP 220)中提供资源。
以下章节描述了前面问题的解决方案。请记住,通常没有解决特定问题的唯一正确方法。此外,请记住,这里所示的解释仅包括解决这些问题所需的最有趣和最重要的细节。下载示例解决方案以查看更多细节并实验程序,请访问github.com/PacktPublishing/Java-Coding-Problems-Second-Edition/tree/main/Chapter13。
258. 介绍套接字基础
套接字概念在 20 世纪 80 年代被引入。这个概念是在伯克利软件发行版(BSD)(一种 Unix 风味)中作为通过互联网协议(IP)在进程之间进行网络通信的解决方案而被引入的。Java 在 1996 年(JDK 1.0)引入了其第一个跨平台的套接字 API。正如你很快就会看到的,只需几个概念,如网络接口、IP 地址和端口,Java 开发者就可以编写通过套接字进行通信的应用程序。
在 IP 层,数据以数据块(数据包)的形式从源传输到目的地。每个数据包被视为一个独立的实体,并且无法保证从源发送的所有数据包都会到达目的地。尽管如此,在 IP 之上,我们还有其他更流行的协议,如传输控制协议(TCP)和用户数据报协议(UDP)。此外,在这些协议之上,我们有众所周知的 HTTP、DNS、Telnet 等等。通过套接字进行机器通信基于 IP,因此使用 Socket API 的 Java 应用程序可以基于它们预定义的协议与其他基于套接字的应用程序(服务器)进行通信。
连接到互联网的每一台机器都由一个数字或数值标签表示,这通常被称为该机器的IP 地址。作为 Java 开发者,我们应该知道 IP 地址有类别:
-
IPv4 – 以 32 位表示的 IP 地址(例如,
89.165.254.108) -
IPv6 – 以 128 位表示的 IP 地址(例如,
2001:db8:3333:4444:5555:6666:7777:8888)
此外,IP 地址被分为 A、B、C、D 和 E 类。例如,IP 地址的 D 类范围从224.0.0.0到239.255.255.255,并保留用于多播应用。当然,127.0.0.1是一个为localhost保留的特殊 IP 地址。
现在,说到端口,你应该知道 Java 将它们表示为范围在0-65535的整数。一些端口是著名的,通常与某种类型的服务器相关联——例如,端口80与 HTTP 服务器相关联,端口23与 Telnet 服务器相关联,端口21与 FTP 服务器相关联,等等。
尽管有针对这些概念深入探讨的书籍,但在这里我们已有足够的信息开始编写依赖套接字的客户端/服务器应用程序。实际上,在这样的客户端/服务器应用程序中,我们有一个在主机(通过 IP 地址和端口号识别的远程或本地主机)上运行的服务器。在运行过程中,服务器会在特定端口上监听进入的客户端。客户端可以通过这两个坐标定位服务器:服务器的 IP 地址和端口号。客户端需要向服务器展示一个本地端口(由内核自动分配或由我们显式设置),服务器正是通过这个端口定位客户端。一个套接字(客户端套接字)与这个本地端口关联或绑定,并用于与服务器通信。一旦连接被接受,服务器也会得到一个套接字(服务器套接字),它绑定到一个新的本地端口(不是用于监听进入客户端的服务器端口)。现在,可以通过这两个套接字(端点)进行双向通信。
259. 介绍 TCP 服务器/客户端应用程序
我们不需要成为 TCP 专家就能编写基于 Java 的 TCP 服务器/客户端应用程序。虽然这个主题(TCP)在专门的书籍和文章中有详细的描述(非常完善的文档),但让我们简要概述一下 TCP 原理。
TCP 的目标是在两个端点之间提供点对点通信机制。一旦这两个端点之间的连接建立(通过套接字),在通信期间保持开放,直到其中一方关闭它(通常是客户端)。换句话说,位于不同机器或同一台机器上的两个进程可以像电话连接一样相互通信。在下面的图中,你可以看到一个基于套接字的经典服务器-客户端会话:

图 13.1:基于套接字的服务器/客户端会话(TCP)
服务器/客户端 TCP 连接通过以下坐标表示:
-
服务器端通过其 IP 地址和端口号表示
-
客户端通过其 IP 地址和端口号表示
-
服务器和客户端通过一种协议(UDP、TCP/IP 等)进行通信
正如你在图 13.1中看到的,服务器的套接字已绑定并监听客户端的请求(服务器可以同时与多个客户端通信)。客户端的套接字已绑定并准备好请求与服务器建立连接。一旦连接被接受,它们可以双向通信(完成读写操作),直到客户端关闭连接。客户端稍后可以再次连接。
TCP(与 UDP 相比)擅长处理数据包,能够将数据分成数据包、缓冲数据以及跟踪重发丢失或顺序错误的数据包。此外,TCP 能够控制发送数据的速度,以适应接收者的处理能力。TCP 可以将数据作为数据 I/O 流或字节数组发送。
阻塞与非阻塞机制
基于 Java TCP 的服务器/客户端应用程序可以是 阻塞 或 非阻塞。在阻塞应用程序中,一个给定的线程会在 I/O 完全接收之前被阻塞。因此,该线程在 I/O 准备好处理之前不能做任何事情——它只能挂起。另一方面,在非阻塞应用程序中,I/O 请求被排队,线程可以自由地执行其他任务。这些排队的请求将由内核稍后处理。
从 Java 实现的角度来看,编写阻塞应用程序比编写非阻塞应用程序要容易得多。然而,非阻塞应用程序的性能更高,并且能够持续扩展。NIO.2 支持这两种机制,我们也将实现这两种机制,但在介绍 Java Socket API 之前。
260. 介绍 Java Socket API
Java 的 Socket API 支持从 JDK 1.0 到 JDK 7 一直在不断进化。从 JDK 7 和 NIO.2 开始,通过引入新的 API(新的类和接口)来极大地改善了套接字支持,这使得编写复杂的 TCP/UDP 应用程序变得容易。例如,NetworkChannel 接口被引入作为所有网络通道类的通用实现点。任何实现 NetworkChannel 的类都可以访问处理通道到网络套接字的有用方法。这些类包括 SocketChannel、ServerSocketChannel 和 DatagramChannel。这些类利用了处理本地地址和通过 SocketOption<T>(接口)和 StandardSocketOptions(类)配置套接字选项的方法。此外,此 API 还公开了访问远程地址、检查连接状态和关闭套接字的方法。
NetworkChannel 的最重要的子接口之一是 MulticastChannel。此接口仅由 DatagramChannel 实现,并且知道如何将能够提供 IP 多播的网络通道映射。任何人都可以获得一个成员密钥(类似于令牌),可以用来加入一个多播组并成为成员。成员密钥对于定制你在多播组中的存在非常有用(例如,根据发送者的地址阻塞或解阻塞数据报)。
介绍 NetworkChannel
NetworkChannel 提供了适用于所有套接字的方法,因此 NetworkChannel 是 Socket API 的一个支柱。NetworkChannel 揭示的最重要方法之一是 bind()。正如其名称所暗示的,此方法将套接字通道绑定到本地地址(或简称为套接字与本地地址相关联)。更确切地说,套接字通过 InetSocketAddress 实例绑定到本地地址——此类扩展了 SocketAddress(抽象类)并将套接字地址映射为主机(IP)-端口号对。bind() 方法返回绑定的套接字通道(服务器套接字通道、数据报套接字通道等)。返回的通道被显式/手动绑定到给定的主机端口或自动绑定(如果没有提供主机端口):
NetworkChannel bind(SocketAddress local) throws IOException
可以通过 getLocalAddress() 获取绑定本地地址:
SocketAddress getLocalAddress() throws IOException
如果没有地址存在,则此方法返回 null。
处理套接字选项
一个套接字有多个选项,这些选项通过 SocketOption<T> 接口表示。NIO.2 通过 StandardSocketOptions 提供了一组标准选项来实现 SocketChannel<T>,如下所示:
-
IP_MULTICAST_IF:通过此选项,我们设置用于有界数据报套接字(或简称为数据报套接字)进行多播数据报的NetworkInterface。我们可以显式设置此选项,或者允许 操作系统(OS)选择一个(如果有可用的话)通过将此选项保留为默认值(null)。 -
IP_MULTICAST_LOOP:这是一个标志选项(默认为true),可以设置给有界数据报套接字,用于控制多播数据报的 环回(true表示发送的数据应回环到您的主机)。 -
IP_MULTICAST_TTL:此选项适用于有界数据报套接字。它被称为 生存时间(TTL),用于设置多播数据报的作用域(设置多播数据包的 TTL)。默认情况下,此选项的值为1,这意味着多播数据报不会发送到本地网络之外。此选项的值介于0和255之间。 -
IP_TOS:通过此选项,我们设置 IPv4 数据包中 服务类型(ToS)八位字节的值。对于有界数据报套接字,此值可以随时设置,默认值为0。有关 ToS 八位字节的更多信息,请参阅 RFC 2474 和 RFC 1349(ToS 八位字节的解释是网络特定的)。 -
SO_BROADCAST:这是一个标志选项,适用于向 IPv4 广播地址发送数据的有界数据报套接字(默认为false)。当它为true时,此选项允许传输广播数据报。 -
SO_KEEPALIVE:这是一个标志选项(默认为false),适用于有界套接字,用于指示操作系统是否应该保持连接活跃。 -
SO_LINGER: 这个选项定义了所谓的linger 间隔为一个整数(以秒为单位的超时)。linger 间隔仅适用于工作在阻塞模式的套接字,它代表了应用于close()方法的超时。换句话说,当在套接字上调用close()方法时,其执行将被阻塞在这个超时(linger 间隔)期间,操作系统尝试传输未发送的数据(如果可能的话)。这个选项可以在任何时候设置(默认情况下,它有一个负值,表示禁用)并且最大超时值是操作系统特定的。 -
SO_RCVBUF: 这个选项是一个可以在套接字绑定/连接之前设置的整型值(默认值为操作系统依赖的值)。如果您想设置网络输入缓冲区的大小(以字节为单位),则需要此选项。 -
SO_SNDBUF: 这个选项是一个可以在套接字绑定/连接之前设置的整型值(默认值为操作系统依赖的值)。如果您想设置网络输出缓冲区的大小(以字节为单位),则需要此选项。 -
SO_REUSEADDR: 通过这个整型选项,我们可以指示一个地址是否可以被重用。对于数据报多播(数据报套接字),这意味着多个程序可以使用(可以绑定到)同一个地址。在面向流的套接字(或简单地说,流套接字)的情况下,只有在之前的连接处于TIME_WAIT状态(套接字即将由操作系统关闭,但它仍然等待客户端发送可能的延迟通信)时,地址才能被重用。这个选项的默认值依赖于操作系统,应该在套接字绑定/连接之前设置。 -
SO_REUSEPORT: 通过这个整型选项(从 JDK 9 开始可用),我们可以指示一个端口是否可以被重用。对于数据报多播(数据报套接字)和流套接字,这意味着多个套接字可以使用(可以绑定到)同一个端口和地址。应该在连接/绑定套接字之前设置SO_REUSEPORT;否则,操作系统将为其提供默认值。 -
TCP_NODELAY: 这个标志选项(默认为false)用于启用/禁用 Nagle 算法(en.wikipedia.org/wiki/Nagle%27s_algorithm)。它可以在任何时候设置。
设置选项可以通过NetworkChannel.getOption()完成,而获取选项可以通过NetworkChannel.setOption()完成:
<T> T getOption(SocketOption<T> op_name) throws IOException
<T> NetworkChannel setOption(SocketOption<T> op_name, T op_value)
throws IOException
此外,通过NetworkChannel,我们可以通过supportedOptions()方法获取特定网络套接字支持的所有选项:
Set<SocketOption<?>> supportedOptions()
拥有这些信息后,是时候开始编写我们的第一个基于客户端/服务器套接字的应用程序了。
261. 编写阻塞式 TCP 服务器/客户端应用程序
在这个问题中,我们将编写一个阻塞式 TCP 服务器/客户端应用程序。更确切地说,让我们从一个单线程阻塞式 TCP 回显服务器开始。
编写单线程阻塞式 TCP 回显服务器
为了编写一个单线程阻塞式 TCP 回显服务器,我们将遵循以下步骤:
-
创建一个新的服务器套接字通道
-
配置阻塞机制
-
设置服务器套接字通道选项
-
绑定服务器套接字通道
-
接受连接
-
通过连接传输数据
-
关闭通道
因此,让我们从第一步开始。
创建一个新的服务器套接字通道
通过线程安全的java.nio.channels.ServerSocketChannel API 创建和打开一个新的服务器套接字通道(面向流的监听套接字)可以按照以下方式完成:
ServerSocketChannel serverSC = ServerSocketChannel.open();
生成的服务器套接字通道尚未绑定/连接。然而,它是开放的,这可以通过isOpen()方法来验证:
if (serverSC.isOpen()) {
...
}
接下来,让我们配置阻塞机制。
配置阻塞机制
一旦服务器套接字通道成功打开,我们可以决定阻塞机制。这可以通过configureBlocking()方法完成,该方法接受一个布尔参数(true表示阻塞服务器套接字通道):
serverSC.configureBlocking(true);
一种特殊的通道类型(将在后续问题中介绍)是SelectableChannel。这种通道由configureBlocking()方法返回(从AbstractSelectableChannel继承而来),并且对于通过Selector API 实现多路复用非常有用。但是,正如我所说的,这将在稍后介绍。
设置服务器套接字通道选项
由于所有选项都有默认值,我们可以直接使用它们,或者明确设置我们需要的选项。例如,让我们按照以下方式设置SO_RCVBUF和SO_REUSEADDR:
serverSC.setOption(StandardSocketOptions.SO_RCVBUF, 4 * 1024);
serverSC.setOption(StandardSocketOptions.SO_REUSEADDR, true);
服务器套接字通道支持的选项可以通过supportedOptions()获取:
Set<SocketOption<?>> options = serverSC.supportedOptions();
for (SocketOption<?> option : options) {
System.out.println(option);
}
如您所见,您可以在控制台上简单地打印出支持选项。
绑定服务器套接字通道
将服务器套接字通道绑定到本地地址是一个非常重要的步骤。我们通过bind()方法来完成这个操作——例如,让我们将serverSC绑定到本地主机(127.0.0.1)和任意选择的端口4444:
private static final int SERVER_PORT = 4444;
private static final String SERVER_IP = "127.0.0.1";
...
serverSC.bind(new InetSocketAddress(SERVER_IP, SERVER_PORT));
如果我们省略 IP 地址并使用只接受端口参数的InetSocketAddress构造函数,那么 Java 将依赖于通配符IP 地址。这个地址是一个仅用于绑定操作的专用本地 IP,通常它被解释为任何:
serverSC.bind(new InetSocketAddress(SERVER_PORT));
然而,当您决定使用通配符 IP 地址时,请记住以下注意事项。
重要提示
注意,IP 通配符地址在存在具有单独 IP 地址的多个网络接口的情况下可能会导致不希望出现的复杂情况。如果您没有准备好处理这种复杂情况,那么将套接字绑定到特定的网络地址,而不是 IP 通配符,会更好。
如果我们需要指定套接字地址(local_addr)和挂起的连接数(pending_c),那么我们应该依赖以下bind():
public abstract ServerSocketChannel bind(
SocketAddress local_addr,int pending_c) throws IOException
通过getLocalAddress()方法获取绑定的本地地址(SocketAddress)。如果套接字尚未绑定,该方法返回null。
接受连接
到目前为止,服务器套接字通道已打开并绑定。我们已准备好接受传入的客户端。由于我们设置了阻塞模式,应用程序将在建立连接(接受客户端连接请求)或发生 I/O 错误之前被阻塞。
可以通过accept()方法接受连接,如下所示:
SocketChannel acceptSC = serverSC.accept();
此方法返回一个表示客户端套接字通道的SocketChannel(或者简单地说,与新的连接关联的套接字通道)。返回的SocketChannel是一个流套接字的可选择通道。
重要提示
如果对一个尚未绑定的服务器套接字通道调用accept()方法,那么我们将得到NotYetBoundException异常。
通过getRemoteAddress()方法可以获取此通道套接字连接的远程地址(SocketAddress):
System.out.println("New connection: "
+ acceptSC.getRemoteAddress());
接下来,让我们看看如何通过此连接传输数据。
通过连接传输数据
到目前为止,服务器和客户端之间的双向连接已经建立,它们可以开始相互传输数据。每一方都可以使用 Java I/O 流或字节数组映射发送/接收数据包。实现通信协议和选择合适的 API 相当灵活。例如,我们可以依赖ByteBuffer来实现我们的回声服务器:
ByteBuffer tBuffer = ByteBuffer.allocateDirect(1024);
...
while (acceptSC.read(tBuffer) != -1) {
tBuffer.flip();
acceptSC.write(tBuffer);
if (tBuffer.hasRemaining()) {
tBuffer.compact();
} else {
tBuffer.clear();
}
}
为了支持通过ByteBuffer进行数据传输,SocketChannel公开了一组read()/write()方法,如下所示:
-
从通道读取到给定的缓冲区,并返回读取的字节数(如果已到达流末尾,则返回
-1):public abstract int read(ByteBuffer dest_buffer) throws IOException public final long read(ByteBuffer[] dests_buffers) throws IOException public abstract long read(ByteBuffer[] dests_buffers, int buffer_offset, int buffer_length) throws IOException -
将缓冲区的字节数写入通道,并返回写入的字节数:
public abstract int write(ByteBuffer source_buffer) throws IOException public final long write(ByteBuffer[] source_buffers) throws IOException public abstract long write(ByteBuffer[] source_buffers, int buffer_offset, int buffer_length) throws IOException
如果您更喜欢使用 Java I/O API 而不是操作多个ByteBuffer实例,那么请这样做:
InputStream in = acceptSC.socket().getInputStream();
OutputStream out = acceptSC.socket().getOutputStream();
或者,像这样:
BufferedReader in = new BufferedReader(
new InputStreamReader(acceptSC.getInputStream()));
PrintWriter out = new PrintWriter(
acceptSC.getOutputStream(), true);
当涉及到 I/O 流时,我们不得不讨论关闭 I/O 连接的问题。如果不关闭通道,可以通过shutdownInput()和shutdownOutput()方法关闭 I/O 连接。shutdownInput()方法关闭读取连接,而shutdownOutput()方法关闭写入连接。尝试从已关闭的读取连接(流末尾)读取将导致返回-1。另一方面,尝试在已关闭的写入连接上写入将引发ClosedChannelException异常:
// connection will be shut down for reading
acceptSC.shutdownInput();
// connection will be shut down for writing
acceptSC.shutdownOutput();
另一方面,如果您只需要检查 I/O 连接是否打开,可以通过以下代码实现:
boolean inputdown = acceptSC.socket().isInputShutdown();
boolean outputdown = acceptSC.socket().isOutputShutdown();
在尝试通过此连接进行读写操作之前,这些检查很有用。
关闭通道
可以通过close()方法关闭通道。如果我们想关闭特定的客户端套接字通道,那么我们依赖于SocketChannel.close() – 这将不会关闭服务器(停止监听传入的客户端)。另一方面,如果我们想关闭服务器以停止它监听传入的客户端,那么只需按照以下方式调用ServerSocketChannel.close():
acceptSC.close(); // close a specific client
serverSC.close(); // close the server itself
通常,你会在 try-with-resources 块中关闭这些资源。
将所有这些整合到回声服务器中
我们回声服务器的源代码可以通过连接之前的代码片段并添加一些粘合代码和注释来获取:
public class Main {
private static final int SERVER_PORT = 4444;
private static final String SERVER_IP = "127.0.0.1";
public static void main(String[] args) {
ByteBuffer tBuffer = ByteBuffer.allocateDirect(1024);
// open a brand new server socket channel
try (ServerSocketChannel serverSC
= ServerSocketChannel.open()) {
// server socket channel was created
if (serverSC.isOpen()) {
// configure the blocking mode
serverSC.configureBlocking(true);
// optionally, configure the server side options
serverSC.setOption(
StandardSocketOptions.SO_RCVBUF, 4 * 1024);
serverSC.setOption(
StandardSocketOptions.SO_REUSEADDR, true);
// bind the server socket channel to local address
serverSC.bind(new InetSocketAddress(
SERVER_IP, SERVER_PORT));
// waiting for clients
System.out.println("Waiting for clients ...");
// ready to accept incoming connections
while (true) {
try (SocketChannel acceptSC = serverSC.accept()) {
System.out.println("New connection: "
+ acceptSC.getRemoteAddress());
// sending data
while (acceptSC.read(tBuffer) != -1) {
tBuffer.flip();
acceptSC.write(tBuffer);
if (tBuffer.hasRemaining()) {
tBuffer.compact();
} else {
tBuffer.clear();
}
}
} catch (IOException ex) {
// handle exception
}
}
} else {
System.out.println(
"Server socket channel unavailable!");
}
} catch (IOException ex) {
System.err.println(ex);
// handle exception
}
}
}
接下来,让我们专注于开发我们的回声服务器客户端。
编写单线程阻塞 TCP 客户端
在编写客户端之前,我们必须定义它的工作方式。例如,我们的客户端连接到服务器并发送 Hey! 文本。之后,它继续发送 0-100 范围内的随机整数,直到生成并发送数字 50。一旦发送了 50,客户端将关闭通道。服务器将简单地回显从客户端接收到的每条消息。现在,基于这个场景,我们可以通过以下步骤开发客户端:
-
创建一个新的(客户端)套接字通道
-
配置阻塞机制
-
连接客户端套接字通道
-
通过连接传输数据
-
关闭通道
让我们解决第一步。
创建一个新的(客户端)套接字通道
通过线程安全的 java.nio.channels.SocketChannel API 创建和打开一个新的客户端套接字通道(面向流的连接套接字)如下所示:
SocketChannel clientSC = SocketChannel.open();
结果客户端套接字通道尚未连接。然而,它是打开的,这可以通过 isOpen() 方法来验证:
if (clientSC.isOpen()) {
...
}
然而,客户端套接字通道可以通过 open(SocketAddress) 方法在单步中打开和连接。接下来,让我们配置阻塞机制。
配置阻塞机制
一旦客户端套接字通道成功打开,我们可以决定阻塞机制。这可以通过 configureBlocking() 方法完成,该方法接受一个布尔参数(true 表示阻塞客户端套接字通道):
clientSC.configureBlocking(true);
接下来,我们为这个客户端套接字通道设置一些选项。
设置客户端套接字通道选项
以下选项特定于客户端套接字通道:IP_TOS、SO_RCVBUF、SO_LINGER、SO_OOBINLINE、SO_REUSEADDR、TCP_NODELAY、SO_KEEPALIVE 和 SO_SNDBUF。这些选项具有默认值,但可以像以下示例中那样显式设置:
clientSC.setOption(
StandardSocketOptions.SO_RCVBUF, 131072); // 128 * 1024
clientSC.setOption(
StandardSocketOptions.SO_SNDBUF, 131072); // 128 * 1024
clientSC.setOption(
StandardSocketOptions.SO_KEEPALIVE, true);
clientSC.setOption(
StandardSocketOptions.SO_LINGER, 5);
客户端套接字通道通过 supportedOptions() 方法揭示支持的选项:
Set<SocketOption<?>> options = clientSC.supportedOptions();
for (SocketOption<?> option : options) {
System.out.println(option);
}
如您所见,您可以将支持的选项简单地打印到控制台。
连接客户端套接字通道
在打开客户端套接字通道后,我们必须将其连接到监听 127.0.0.1 和端口 4444 的服务器。这可以通过 connect() 方法完成,如下所示:
private final int SERVER_PORT = 4444;
private final String SERVER_IP = "127.0.0.1";
...
clientSC.connect(
new InetSocketAddress(SERVER_IP, SERVER_PORT));
由于这是一个阻塞客户端,应用程序会阻塞,直到与该远程地址建立连接或发生 I/O 错误。
在传输数据(发送/接收数据包)之前,您应该确保通过 isConnected() 方法连接可用,如下所示:
if (clientSC.isConnected()) {
...
}
此外,请注意以下注意事项:
重要注意事项
在我们的简单示例中,服务器和客户端在同一台机器上运行(localhost/127.0.0.1)。然而,在实际应用中,你应该避免硬编码 IP 地址,并使用服务器的主机名代替其 IP 地址。由于 IP 地址可能会更改或通过如 DHCP 等服务动态分配,你应该依赖主机名(最终通过 DNS 配置)。
接下来,让我们看看我们如何向服务器发送和接收数据。
在连接上传输数据
首先,我们发送文本Hey!。之后,我们发送介于 0 到 100 之间的整数,直到生成整数 50。在 API 级别,我们依赖于ByteBuffer/CharBuffer如下:
ByteBuffer tBuffer = ByteBuffer.allocateDirect(1024);
ByteBuffer hBuffer = ByteBuffer.wrap("Hey !".getBytes());
ByteBuffer rBuffer;
CharBuffer cBuffer;
Charset charset = Charset.defaultCharset();
CharsetDecoder chdecoder = charset.newDecoder();
...
clientSC.write(hBuffer);
while (clientSC.read(tBuffer) != -1) {
tBuffer.flip();
cBuffer = chdecoder.decode(tBuffer);
System.out.println(cBuffer.toString());
if (tBuffer.hasRemaining()) {
tBuffer.compact();
} else {
tBuffer.clear();
}
int r = new Random().nextInt(100);
if (r == 50) {
System.out.println(
"Number 50 is here so the channel will be closed");
break;
} else {
rBuffer = ByteBuffer.wrap(
"Random number:".concat(String.valueOf(r)).getBytes());
clientSC.write(rBuffer);
}
}
如你所见,我们使用ByteBuffer发送/接收数据,并使用CharBuffer解码从服务器接收到的数据。
关闭通道
通过close()方法断开客户端与服务器之间的连接(关闭客户端套接字通道)可以这样做:
clientSC.close();
通常,你会在try-with-resources块中关闭这些资源。
将所有内容整合到客户端
我们的客户端源代码可以通过连接之前的代码片段并添加一些粘合代码和注释来获取:
public class Main {
private static final int SERVER_PORT = 4444;
private static final String SERVER_IP = "127.0.0.1";
public static void main(String[] args) {
ByteBuffer tBuffer = ByteBuffer.allocateDirect(1024);
ByteBuffer hBuffer = ByteBuffer.wrap("Hey !".getBytes());
ByteBuffer rBuffer;
CharBuffer cBuffer;
Charset charset = Charset.defaultCharset();
CharsetDecoder chdecoder = charset.newDecoder();
// create a brand new client socket channel
try (SocketChannel clientSC = SocketChannel.open()) {
// client socket channel was created
if (clientSC.isOpen()) {
// configure the blocking mode
clientSC.configureBlocking(true);
// optionally, configure the client side options
clientSC.setOption(
StandardSocketOptions.SO_RCVBUF, 128 * 1024);
clientSC.setOption(
StandardSocketOptions.SO_SNDBUF, 128 * 1024);
clientSC.setOption(
StandardSocketOptions.SO_KEEPALIVE, true);
clientSC.setOption(
StandardSocketOptions.SO_LINGER, 5);
// connect this channel's socket to the proper address
clientSC.connect(
new InetSocketAddress(SERVER_IP, SERVER_PORT));
// check the connection availability
if (clientSC.isConnected()) {
// sending data
clientSC.write(hBuffer);
while (clientSC.read(tBuffer) != -1) {
tBuffer.flip();
cBuffer = chdecoder.decode(tBuffer);
System.out.println(cBuffer.toString());
if (tBuffer.hasRemaining()) {
tBuffer.compact();
} else {
tBuffer.clear();
}
int r = new Random().nextInt(100);
if (r == 50) {
System.out.println(
"Number 50 is here so the channel
will be closed");
break;
} else {
rBuffer = ByteBuffer.wrap(
"Random number:".concat(
String.valueOf(r)).getBytes());
clientSC.write(rBuffer);
}
}
} else {
System.out.println("Connection unavailable!");
}
} else {
System.out.println(
"Client socket channel unavailable!");
}
} catch (IOException ex) {
System.err.println(ex);
// handle exception
}
}
}
最后,让我们测试我们的服务器/客户端应用程序。
测试阻塞回显应用程序
首先,启动服务器应用程序。其次,启动客户端应用程序并检查控制台输出:
Hey !
Random number:17
Random number:31
Random number:53
…
Random number:7
Number 50 is here so the channel will be closed
你甚至可以同时启动几个客户端来查看其工作情况。服务器将显示每个客户端的远程地址。
262. 编写非阻塞 TCP 服务器/客户端应用程序
在这个问题中,我们将编写一个非阻塞 TCP 服务器/客户端应用程序。
非阻塞套接字(或非阻塞模式的套接字)允许我们在套接字通道上执行 I/O 操作,而不会阻塞使用它的进程。非阻塞应用程序的主要步骤与阻塞应用程序完全相同。服务器被打开并绑定到本地地址,准备处理传入的客户端。客户端被打开并连接到服务器。从这一点开始,服务器和客户端可以以非阻塞方式交换数据包。
当我们提到以非阻塞方式交换数据时,我们指的是非阻塞技术的基石,即java.nio.channels.Selector类。Selector的作用是在多个可用的套接字通道之间协调数据传输。基本上,一个Selector可以监控每个记录的套接字通道,并检测可用于数据传输的通道,以便处理客户端的请求。此外,Selector可以通过一个称为多路复用的概念来处理多个套接字的 I/O 操作。这样,就不需要为每个套接字连接分配一个线程,多路复用允许使用单个线程来处理多个套接字连接。Selector被称为通过SocketChannel或ServerSocketChannel(SelectableChannel的子类)的register()方法注册的SelectableChannel的多路复用器。Selector和SelectableChannel一起注销/释放。
使用 SelectionKey 类
是时候进一步介绍SelectionKey类了。一个通道通过java.nio.channels.SelectionKey的一个实例注册到Selector,所有的SelectionKey实例都称为选择键。选择键作为辅助器,用于对客户端的请求进行排序。实际上,选择键携带有关单个客户端子请求的信息(元数据),例如子请求的类型(连接、写、读等)以及用于唯一标识客户端所需的信息。
在将SelectableChannel注册到Selector的过程中,我们指出该键的通道将由该选择器监控的操作集——这被称为兴趣集。当一个操作有资格执行时,它就成为所谓的就绪集(当键创建时,此集初始化为 0)。因此,选择键处理两个操作集(兴趣集和就绪集),这些操作集表示为整数值。操作集的每个位代表由键的通道支持的选可操作类别之一。一个键可以是以下类型之一:
-
SelectionKey.OP_ACCEPT(可接受): 标记套接字接受操作的位 -
SelectionKey.OP_CONNECT(可连接): 标记套接字连接操作的位 -
SelectionKey.OP_READ(可读): 标记读操作的位 -
SelectionKey.OP_WRITE(可写): 标记写操作的位
选择器必须处理三组选择键,如下所示:
-
键集:包含当前注册通道的所有键。
-
已选择键:每个至少准备好执行其兴趣集中至少一个操作的键都是已选择键的一部分。
-
已取消键:这包含所有已取消但仍注册了通道的键。
重要提示
当创建选择器时,这三个集合都是空的。请注意,
Selector实例是线程安全的,但它们的键集合却不是。
当发生某些操作时,选择器会唤醒。它开始创建 SelectionKey,其中每个这样的键都包含有关当前请求的信息。
选择器在一个无限循环中等待记录的事件(例如,传入的连接请求)。通常,这个循环的第一行是 Selector.select()。这是一个阻塞调用,直到 Selector.wakeup() 方法被调用,至少有一个通道是可选择的,或者当前线程被中断。还有一个 select(long timeout) 方法,它的工作方式与 select() 相同,但有一个超时。此外,我们还有 selectNow(),它是非阻塞的——如果没有可选择的通道,则 selectNow() 立即返回 0。
假设选择器正在等待连接尝试。当客户端尝试连接时,服务器会检查选择器创建的每个键的类型。如果类型是 OP_ACCEPT(可接受键),则 SelectionKey.isAcceptable() 方法会采取行动。当此方法返回 true 时,服务器通过 accept() 方法定位客户端套接字通道。此外,它将此套接字通道设置为非阻塞,并将其注册到选择器,使其适用于 OP_READ 和/或 OP_WRITE 操作。在处理选择器创建的键时,服务器将它们从列表中删除(这些键的 Iterator),以防止对同一键的重新评估。
到目前为止,客户端套接字通道已注册到选择器以进行读写操作。现在,如果客户端在套接字通道上发送(写入)一些数据,则选择器将通知服务器端它应该/可以读取该数据(在这种情况下,SelectionKey.isReadable() 方法返回 true)。另一方面,当客户端从服务器接收(读取)一些数据时,SelectionKey.isWritable() 方法返回 true。
以下图示突出了基于选择器的非阻塞流程:

图 13.2:基于选择器的非阻塞流程
重要提示
在非阻塞模式下,我们可能会遇到所谓的 部分读取/写入。这意味着一个 I/O 操作已部分传输(读取或写入)了一些数据(字节较少)或根本没有数据(0 字节)。
接下来,让我们简要介绍 Selector 方法。
使用选择器方法
在编码非阻塞 TCP 服务器/客户端应用程序之前,我们必须了解一些内置方法,这些方法支持我们的目标:
-
Selector.open(): 这将创建并打开一个新的选择器。 -
Selector.select(): 这是一个阻塞操作,用于选择一组键。 -
Selector.select(long t): 它的工作方式与select()完全相同,但有一个以毫秒为单位的超时。如果在t期间没有可选择的项,则此方法返回0。 -
Selector.selectNow(): 这是select()的非阻塞版本。如果没有可选择的内容,则此方法返回0。 -
Selector.keys(): 这返回Set<SelectionKey>(选择器的键集)。 -
Selector.selectedKeys(): 这返回Set<SelectionKey>(选择器的选择键集)。 -
Selector.wakeup(): 第一次选择操作(尚未返回)将立即返回。 -
SelectionKey.isReadable(): 这检查此键的通道是否已准备好读取。 -
SelectionKey.isWritable(): 这检查此键的通道是否已准备好写入。 -
SelectionKey.isValid(): 这检查此键的有效性。无效的键会被取消,其选择器会被关闭,或者其通道会被关闭。 -
SelectionKey.isAcceptable(): 如果此方法返回true,则此键的通道将接受一个新的套接字连接。 -
SelectionKey.isConnectable(): 这检查此键的通道是否已成功完成或未能完成其当前的套接字连接操作。 -
SelectionKey.interestOps(): 这返回此键的兴趣集。 -
SelectionKey.interestOps(t): 这将此键的兴趣集设置为t。 -
SelectionKey.readyOps(): 这返回此键的准备操作集。 -
SelectionKey.cancel(): 这取消此键的通道与其选择器的注册。
通过ServerSocketChannel和SocketChannel的register()方法可以将通道注册到给定的选择器:
public final SelectionKeyregister(
Selector s, int p, Object a) throws ClosedChannelException
s参数是给定的选择器。p参数是选择键的兴趣集,a参数是选择键的附件(可能为null)。
编写非阻塞服务器
根据前面的信息,我们可以编写以下非阻塞回声服务器(代码可能看起来有点大,但它包含了有用的注释):
public class Main {
private static final int SERVER_PORT = 4444;
private final Map<SocketChannel, List<byte[]>>
registerTrack = new HashMap<>();
private final ByteBuffer tBuffer
= ByteBuffer.allocate(2 * 1024);
private void startEchoServer() {
// call the open() method for Selector/ServerSocketChannel
try (Selector selector = Selector.open();
ServerSocketChannel serverSC
= ServerSocketChannel.open()) {
// ServerSocketChannel and Selector successfully opened
if ((serverSC.isOpen()) && (selector.isOpen())) {
// configure non-blocking mode
serverSC.configureBlocking(false);
// optionally, configure the client side options
serverSC.setOption(
StandardSocketOptions.SO_RCVBUF, 256 * 1024);
serverSC.setOption(
StandardSocketOptions.SO_REUSEADDR, true);
// bind the server socket channel to the port
serverSC.bind(new InetSocketAddress(SERVER_PORT));
// register this channel with the selector
serverSC.register(selector, SelectionKey.OP_ACCEPT);
// waiting for clients
System.out.println("Waiting for clients ...");
...
接下来,我们有Selector的无穷循环:
while (true) {
// waiting for events
selector.select();
// the selected keys have something to be processed
Iterator itkeys =selector.selectedKeys().iterator();
while (itkeys.hasNext()) {
SelectionKey selkey
= (SelectionKey) itkeys.next();
// avoid processing the same key twice
itkeys.remove();
if (!selkey.isValid()) {
continue;
}
if (selkey.isAcceptable()) {
acceptOperation(selkey, selector);
} else if (selkey.isReadable()) {
this.readOperation(selkey);
} else if (selkey.isWritable()) {
this.writeOperation(selkey);
}
}
}
} else {
System.out.println(
"Cannot open the selector/channel");
}
} catch (IOException ex) {
System.err.println(ex);
// handle exception
}
}
...
此外,我们有一组负责接受连接和执行读写操作的辅助工具。首先,我们有acceptOperation()辅助工具:
// isAcceptable = true
private void acceptOperation(SelectionKey selkey,
Selector selector) throws IOException {
ServerSocketChannel serverSC
= (ServerSocketChannel) selkey.channel();
SocketChannel acceptSC = serverSC.accept();
acceptSC.configureBlocking(false);
System.out.println("New connection: "
+ acceptSC.getRemoteAddress());
// send an welcome message
acceptSC.write(ByteBuffer.wrap(
"Hey !\n".getBytes("UTF-8")));
// register the channel with selector to support more I/O
registerTrack.put(acceptSC, new ArrayList<>());
acceptSC.register(selector, SelectionKey.OP_READ);
}
...
读写操作的辅助工具如下:
// isReadable = true
private void readOperation(SelectionKey selkey) {
try {
SocketChannel socketC
= (SocketChannel) selkey.channel();
tBuffer.clear();
int byteRead = -1;
try {
byteRead = socketC.read(tBuffer);
} catch (IOException e) {
System.err.println("Read error!");
// handle exception
}
if (byteRead == -1) {
this.registerTrack.remove(socketC);
System.out.println("Connection was closed by: "
+ socketC.getRemoteAddress());
socketC.close();
selkey.cancel();
return;
}
byte[] byteData = new byte[byteRead];
System.arraycopy(
tBuffer.array(), 0, byteData, 0, byteRead);
System.out.println(new String(byteData, "UTF-8")
+ " from " + socketC.getRemoteAddress());
// send the bytes back to client
doEchoTask(selkey, byteData);
} catch (IOException ex) {
System.err.println(ex);
// handle exception
}
}
// isWritable = true
private void writeOperation(SelectionKey selkey)
throws IOException {
SocketChannel socketC = (SocketChannel) selkey.channel();
List<byte[]> channelByteData = registerTrack.get(socketC);
Iterator<byte[]> iter = channelByteData.iterator();
while (iter.hasNext()) {
byte[] itb = iter.next();
iter.remove();
socketC.write(ByteBuffer.wrap(itb));
}
selkey.interestOps(SelectionKey.OP_READ);
}
private void doEchoTask(
SelectionKey selkey, byte[] dataByte) {
SocketChannel socketC = (SocketChannel) selkey.channel();
List<byte[]> channelByteData = registerTrack.get(socketC);
channelByteData.add(dataByte);
selkey.interestOps(SelectionKey.OP_WRITE);
}
...
最后,我们必须调用startEchoServer():
public static void main(String[] args) {
Main main = new Main();
main.startEchoServer();
}
}
接下来,让我们专注于编写我们的非阻塞回声服务器的客户端。
编写非阻塞客户端
非阻塞客户端的主要结构与非阻塞服务器相同。然而,有一些不同之处,以下简要概述:
-
客户端套接字通道必须注册到
SelectionKey.OP_CONNECT操作。这是必需的,因为客户端必须由选择器通知非阻塞服务器已接受其连接请求。 -
虽然服务器端可能无限期地等待传入客户端,但客户端不能以相同的方式尝试连接。换句话说,客户端将依赖于
Selector.select(long timeout)。500 到 1,000 毫秒的超时应该可以完成任务。 -
客户端还负责检查是否可以通过
SelectionKey.isConnectable()检查键是否可连接。如果此方法返回true,则客户端在条件语句中连接到isConnectionPending()和finishConnect()API。这种结构对于关闭任何挂起的连接是必要的。实际上,isConnectionPending()方法告诉我们当前客户端通道上是否有任何连接操作正在进行,而finishConnect()方法将完成连接套接字通道的过程。
现在,我们准备列出客户端代码,该代码遵循与上一个问题相同的场景(我们发送 Hey! 文本,然后是 0 到 100 之间的随机整数,直到生成数字 50):
public class Main {
private static final int SERVER_PORT = 4444;
private static final String SERVER_IP = "127.0.0.1";
private static final int TIMEOUT_SELECTOR = 1_000;
public static void main(String[] args)
throws InterruptedException {
ByteBuffer tBuffer = ByteBuffer.allocateDirect(2 * 1024);
ByteBuffer rBuffer;
CharBuffer cBuffer;
Charset charset = Charset.defaultCharset();
CharsetDecoder chdecoder = charset.newDecoder();
// call the open() for ServerSocketChannel and Selector
try (Selector selector = Selector.open();
SocketChannel clientSC = SocketChannel.open()) {
// ServerSocketChannel and Selector successfully opened
if ((clientSC.isOpen()) && (selector.isOpen())) {
// configure non-blocking mode
clientSC.configureBlocking(false);
// optionally, configure the client side options
clientSC.setOption(
StandardSocketOptions.SO_RCVBUF, 128 * 1024);
clientSC.setOption(
StandardSocketOptions.SO_SNDBUF, 128 * 1024);
clientSC.setOption(
StandardSocketOptions.SO_KEEPALIVE, true);
// register this channel with the selector
clientSC.register(selector, SelectionKey.OP_CONNECT);
// connecting to the remote host
clientSC.connect(new java.net.InetSocketAddress(
SERVER_IP, SERVER_PORT));
System.out.println("Local host: "
+ clientSC.getLocalAddress());
...
接下来,我们准备等待连接到服务器:
// waiting for the connection
while (selector.select(TIMEOUT_SELECTOR) > 0) {
// get the keys
Set selkeys = selector.selectedKeys();
Iterator iter = selkeys.iterator();
// traverse and process the keys
while (iter.hasNext()) {
SelectionKey selkey = (SelectionKey) iter.next();
// remove the current key
iter.remove();
// get the key's socket channel
try (SocketChannel keySC
= (SocketChannel) selkey.channel()) {
// attempt a connection
if (selkey.isConnectable()) {
// connection successfully achieved
System.out.println(
"Connection successfully achieved!");
// pending connections will be closed
if (keySC.isConnectionPending()) {
keySC.finishConnect();
}
...
一旦客户端连接,它就可以从服务器端读取/写入数据:
// read/write from/to server
while (keySC.read(tBuffer) != -1) {
tBuffer.flip();
cBuffer = chdecoder.decode(tBuffer);
System.out.println(cBuffer.toString());
if (tBuffer.hasRemaining()) {
tBuffer.compact();
} else {
tBuffer.clear();
}
int r = new Random().nextInt(100);
if (r == 50) {
System.out.println(
"Number 50 is here so
the channel will be closed");
break;
} else {
rBuffer = ByteBuffer.wrap(
"Random number:".concat(
String.valueOf(r).concat(" "))
.getBytes("UTF-8"));
keySC.write(rBuffer);
}
}
}
} catch (IOException ex) {
System.err.println(ex);
// handle exception
}
}
}
} else {
System.out.println(
"Cannot open the selector/channel");
}
} catch (IOException ex) {
System.err.println(ex);
// handle exception
}
}
}
最后,让我们测试我们的非阻塞应用程序。
测试非阻塞回显应用程序
首先,启动服务器端。然后,启动几个客户端并检查每个控制台输出:

图 13.3:我们的非阻塞服务器的可能输出
请记住,这不是一个多线程应用程序。它只是一个单线程应用程序,依赖于 多路复用 技术。
263. 编写 UDP 服务器/客户端应用程序
UDP 是建立在 IP 协议之上的协议。通过 UDP,我们可以发送最多 65,507 字节的数据包(即 65,535 字节的 IP 数据包大小减去最小 IP 头部 20 字节,再加上 8 字节的 UDP 头部,总共 65,507 字节)。在 UDP 中,数据包被视为独立的实体。换句话说,没有任何数据包知道其他数据包的存在。数据包可能以任何顺序到达,也可能根本不会到达。发送者将不会被告知丢失的数据包,因此它将不知道需要重发什么。此外,数据包可能到达得太快或太慢,因此处理它们可能是一个真正的挑战。
虽然 TCP 以高可靠性数据传输而闻名,但 UDP 以低开销传输而闻名。因此,UDP 更像发送一封信(记住 TCP 像是电话通话)。你在信封上写上接收者的地址(在这里,远程 IP 和端口)和你的地址(在这里,本地 IP 和端口),然后发送它(在这里,通过电线)。你不知道信件是否会到达接收者(在这里,发送者无法追踪数据包的路径)并且如果你发送更多的信件,你无法控制它们的到达顺序(在这里,旧的数据包可以在较新的数据包之后到达)。在这种情况下,如果你只关心速度,UDP 就非常适合。因此,如果你可以承受丢失数据包,并且接收它们的顺序不重要,那么 UDP 可能是正确的选择。例如,一个应该每 n 毫秒发送传感器状态的程序可以利用 UDP 协议。
编写单个线程阻塞 UDP 回显服务器
为了编写单个线程阻塞 UDP 回显服务器,我们将遵循以下步骤:
-
创建面向数据报的服务器套接字通道
-
设置面向数据报的套接字通道选项
-
绑定服务器面向数据报的套接字通道
-
传输数据包
-
关闭通道
让我们从第一步开始。
创建面向数据报的服务器套接字通道
服务器/客户端 UDP 应用程序的高潮是由一个线程安全的可选择通道表示的,该通道专门用于与面向数据报的套接字(或简单地,数据报套接字)一起工作。在 API 术语中,这被称为java.nio.channels.DatagramChannel。
这样的通道可以通过DatagramChannel.open()方法获得,该方法接受一个类型为java.net.ProtocolFamily的单个参数。协议族实现(java.net.StandardProtocolFamily)有两个可能的值:
-
StandardProtocolFamily.INET:IP 版本 4(IPv4) -
StandardProtocolFamily.INET6:IP 版本 6(IPv6)
接下来,我们关注 IPv4 的数据报套接字,因此我们按照以下方式调用open()方法:
DatagramChannel dchannel
= DatagramChannel.open(StandardProtocolFamily.INET);
如果你不在乎协议族,那么你可以不带参数调用open()方法。在这种情况下,协议族是平台相关的。
在继续之前,我们可以通过isOpen()标志方法检查数据报套接字是否打开:
if (dchannel.isOpen()) {
...
}
客户端数据报套接字可以以与服务器数据报套接字相同的方式打开。
设置面向数据报的套接字通道选项
数据报套接字通道支持以下选项:SO_BROADCAST、IP_TOS、IP_MULTICAST_LOOP、IP_MULTICAST_TTL、SO_SNDBUF、SO_REUSEADDR、IP_MULTICAST_IF和SO_RCVBUF。以下是一些设置其中几个的示例:
dchannel.setOption(StandardSocketOptions.SO_RCVBUF, 4 * 1024);
dchannel.setOption(StandardSocketOptions.SO_SNDBUF, 4 * 1024);
支持的选项可以通过supportedOptions()方法获得:
Set<SocketOption<?>> options = dchannel.supportedOptions();
for(SocketOption<?> option : options) {
System.out.println(option);
}
接下来,让我们绑定服务器数据报套接字。
绑定服务器面向数据报的套接字通道
在监听连接之前,服务器数据报套接字通道应该通过bind()方法绑定到本地地址。这里我们有localhost(127.0.0.1)和任意选择的端口4444:
private static final int SERVER_PORT = 4444;
private static final String SERVER_IP = "127.0.0.1";
...
dchannel.bind(new InetSocketAddress(SERVER_IP, SERVER_PORT));
// or, if you prefer the wildcard address
dchannel.bind(new InetSocketAddress(SERVER_PORT));
如果我们使用bind(null),那么本地地址将被自动分配。我们可以通过getLocalAddress()方法来发现本地地址。
传输数据包
基于数据报的回显服务器几乎准备好了。我们可以通过send()和receive()方法以无连接的方式开始发送和接收数据包(UDP 是一种无连接网络协议)。这可以通过以下方式完成:send()方法获取要发送的数据作为ByteBuffer,远程地址并返回发送的字节数。官方文档给出了关于其工作原理的最佳解释:
如果此通道处于非阻塞模式并且底层输出缓冲区有足够的空间,或者如果此通道处于阻塞模式并且有足够的空间变得可用,则给定缓冲区中的剩余字节将作为一个单独的数据报发送到给定的目标地址。此方法可以在任何时候调用。然而,如果另一个线程已经在此通道上启动了写入操作,则对方法的调用将阻塞,直到第一个操作完成。如果此通道的套接字未绑定,则此方法将首先导致套接字绑定到一个自动分配的地址,就像调用带有 null 参数的 bind() 方法一样。
另一方面,receive() 方法获取你期望找到接收到的数据报的 ByteBuffer。数据报的源地址是此方法的返回值,并且可以用来发送回一个应答包(如果此方法返回 null,则表示通道处于非阻塞模式且没有立即可用的数据报)。再次强调,官方文档为我们提供了关于此方法如何工作的最佳解释:
如果数据报立即可用,或者如果此通道处于阻塞模式并且最终变得可用,则数据报将被复制到给定的字节数组中,并且其源地址将被返回。如果此通道处于非阻塞模式并且数据报不可立即获得,则此方法立即返回 null。此方法可以在任何时候调用。然而,如果另一个线程已经在此通道上启动了读取操作,则对方法的调用将阻塞,直到第一个操作完成。如果此通道的套接字未绑定,则此方法将首先导致套接字绑定到一个自动分配的地址,就像调用带有 null 参数的 bind() 方法一样。
远程地址可以通过 getRemoteAddress() 方法发现。
我们的阻塞式回声服务器在一个无限循环中监听数据包。当一个数据包可用(已到达服务器)时,我们从其中提取数据以及发送者的地址(远程地址)。我们使用此地址来发送回相同的数据(回声):
private static final int MAX_SIZE_OF_PACKET = 65507;
...
ByteBuffer echoBuffer
= ByteBuffer.allocateDirect(MAX_SIZE_OF_PACKET);
...
while (true) {
SocketAddress clientSocketAddress
= dchannel.receive(echoBuffer);
echoBuffer.flip();
System.out.println("Received " + echoBuffer.limit()
+ " bytes from " + clientSocketAddress.toString()
+ "! Echo ...");
dchannel.send(echoBuffer, clientSocketAddress);
echoBuffer.clear();
}
关闭通道
断开数据报套接字通道可以通过以下 close() 方法完成:
dchannel.close();
通常,你会在 try-with-resources 块中关闭这些资源。
将所有内容整合到客户端
我们服务器的源代码可以通过链式调用之前的代码片段,并添加一些粘合代码和注释来获得:
public class Main {
private static final int SERVER_PORT = 4444;
private static final String SERVER_IP = "127.0.0.1";
private static final int MAX_SIZE_OF_PACKET = 65507;
public static void main(String[] args) {
ByteBuffer echoBuffer
= ByteBuffer.allocateDirect(MAX_SIZE_OF_PACKET);
// create a datagram channel
try (DatagramChannel dchannel
= DatagramChannel.open(StandardProtocolFamily.INET)) {
// if the channel was successfully opened
if (dchannel.isOpen()) {
System.out.println("The echo server is ready!");
// optionally, configure the server side options
dchannel.setOption(
StandardSocketOptions.SO_RCVBUF, 4 * 1024);
dchannel.setOption(
StandardSocketOptions.SO_SNDBUF, 4 * 1024);
// bind the channel to local address
dchannel.bind(new InetSocketAddress(
SERVER_IP, SERVER_PORT));
System.out.println("Echo server available at: "
+ dchannel.getLocalAddress());
System.out.println("Ready to echo ...");
// sending data packets
while (true) {
SocketAddress clientSocketAddress
= dchannel.receive(echoBuffer);
echoBuffer.flip();
System.out.println("Received " + echoBuffer.limit()
+ " bytes from " + clientSocketAddress.toString()
+ "! Echo ...");
dchannel.send(echoBuffer, clientSocketAddress);
echoBuffer.clear();
}
} else {
System.out.println("The channel is unavailable!");
}
} catch (SecurityException | IOException ex) {
System.err.println(ex);
// handle exception
}
}
}
接下来,让我们专注于编写回声服务器的客户端。
编写无连接的 UDP 客户端
在实现方面,编写无连接的 UDP 客户端几乎与编写服务器端相同。我们创建一个DatagramChannel,设置所需选项,然后就可以开始(准备发送/接收数据包)。请注意,数据报客户端不需要绑定(但可以)到本地地址,因为服务器可以从每个数据包中提取 IP/端口对——服务器知道客户端在哪里。然而,如果服务器通过bind(null)(因此,自动绑定)绑定,则客户端应该知道分配的服务器 IP/端口对(服务器地址)。当然,反过来也是正确的:如果服务器是第一个发送数据包的(在这种情况下,客户端应该绑定)。
在我们的情况下,客户端知道回声服务器监听在127.0.0.1/4444,因此它可以发送第一个数据包的以下代码:
public class Main {
private static final int SERVER_PORT = 4444;
private static final String SERVER_IP = "127.0.0.1";
private static final int MAX_SIZE_OF_PACKET = 65507;
public static void main(String[] args)
throws InterruptedException {
CharBuffer cBuffer;
Charset charset = Charset.defaultCharset();
CharsetDecoder chdecoder = charset.newDecoder();
ByteBuffer bufferToEcho = ByteBuffer.wrap(
"Echo: I'm a great server!".getBytes());
ByteBuffer echoedBuffer
= ByteBuffer.allocateDirect(MAX_SIZE_OF_PACKET);
// create a datagram channel
try (DatagramChannel dchannel
= DatagramChannel.open(StandardProtocolFamily.INET)) {
// if the channel was successfully opened
if (dchannel.isOpen()) {
// optionally, configure the client side options
dchannel.setOption(
StandardSocketOptions.SO_RCVBUF, 4 * 1024);
dchannel.setOption(
StandardSocketOptions.SO_SNDBUF, 4 * 1024);
// sending data packets
int sentBytes = dchannel.send(bufferToEcho,
new InetSocketAddress(SERVER_IP, SERVER_PORT));
System.out.println("Sent " + sentBytes
+ " bytes to the server");
dchannel.receive(echoedBuffer);
// hack to wait for the server to echo
Thread.sleep(5000);
echoedBuffer.flip();
cBuffer = chdecoder.decode(echoedBuffer);
System.out.println(cBuffer.toString());
echoedBuffer.clear();
} else {
System.out.println("Cannot open the channel");
}
} catch (SecurityException | IOException ex) {
System.err.println(ex);
// handle exception
}
}
}
最后,让我们测试我们的基于阻塞的客户端/服务器数据报应用程序。
测试 UDP 无连接回声应用程序
为了测试我们的应用程序,我们启动服务器,它将输出以下消息:
The echo server is ready!
Echo server available at: /127.0.0.1:4444
Ready to echo ...
接下来,我们启动客户端,它将文本Echo: 我是一个伟大的服务器!发送到服务器。客户端将输出以下内容:
Sent 25 bytes to the server
服务器将接收这个数据报并将其发送回:
Received 25 bytes from /127.0.0.1:59111! Echo ...
客户端将等待服务器回声 5 秒钟(任意选择的超时时间)。由于服务器将回声接收到的数据报,客户端将收到它并将其打印到控制台:
Echo: I'm a great server!
在这个时候,客户端已经停止,服务器继续等待传入的数据报。所以,别忘了手动停止服务器。
编写一个连接的 UDP 客户端
除了无连接的send()和receive()方法之外,Java API 还支持read()和write()方法。这些方法仅适用于连接的 UDP 客户端,并且基于ByteBuffer来保存读写数据。与无连接的 UDP 客户端(相对)相比,连接的 UDP 客户端依赖于一个套接字通道,它只允许与给定的远程对等地址进行交互(发送/接收数据报)。客户端数据报套接字保持连接。它必须显式关闭。
通过connect()方法连接 UDP 客户端,该方法需要服务器端远程地址作为参数:
private static final int SERVER_PORT = 4444;
private static final String SERVER_IP = "127.0.0.1";
...
dchannel.connect(new InetSocketAddress(
SERVER_IP, SERVER_PORT));
由于 UDP 是一个无连接协议,connect()方法不会在网络上与服务器交换任何数据包。该方法立即返回,实际上不会阻塞应用程序。主要来说,connect()方法可以在任何时候调用,因为它不会影响当前正在处理的读写操作。它的目的是将这个套接字通道(如果尚未)绑定到一个自动分配的地址(就像调用bind(null)一样)。
可以通过isConnected()获取连接状态:
if (dchannel.isConnected()) {
...
}
在这个上下文中,为我们的 UDP 回声服务器编写一个 UDP 连接客户端可以这样做:
public class Main {
private static final int SERVER_PORT = 4444;
private static final String SERVER_IP = "127.0.0.1";
private static final int MAX_SIZE_OF_PACKET = 65507;
public static void main(String[] args) {
CharBuffer cBuffer;
Charset charset = Charset.defaultCharset();
CharsetDecoder chdecoder = charset.newDecoder();
ByteBuffer bufferToEcho = ByteBuffer.wrap(
"Echo: I'm a great server!".getBytes());
ByteBuffer echoedBuffer
= ByteBuffer.allocateDirect(MAX_SIZE_OF_PACKET);
// create a datagram channel
try (DatagramChannel dchannel
= DatagramChannel.open(StandardProtocolFamily.INET)) {
// optionally, configure the client side options
dchannel.setOption(
StandardSocketOptions.SO_RCVBUF, 4 * 1024);
dchannel.setOption(
StandardSocketOptions.SO_SNDBUF, 4 * 1024);
// if the channel was successfully opened
if (dchannel.isOpen()) {
// connect to server (remote address)
dchannel.connect(new InetSocketAddress(
SERVER_IP, SERVER_PORT));
// if the channel was successfully connected
if (dchannel.isConnected()) {
// sending data packets
int sentBytes = dchannel.write(bufferToEcho);
System.out.println("Sent " + sentBytes
+ " bytes to the server");
dchannel.read(echoedBuffer);
echoedBuffer.flip();
cBuffer = chdecoder.decode(echoedBuffer);
System.out.println(cBuffer.toString());
echoedBuffer.clear();
} else {
System.out.println("Cannot connect the channel");
}
} else {
System.out.println("Cannot open the channel");
}
} catch (SecurityException | IOException ex) {
System.err.println(ex);
// handle exception
}
}
}
测试这个客户端很简单。首先,启动服务器。其次,启动客户端并查看控制台输出。
264. 介绍多播
多播就像是一种互联网广播的口味。我们知道,电视台可以从源头向所有订阅者或特定区域内的所有人广播(共享)其信号。例外的是那些没有合适的接收器(设备)或对此电视台不感兴趣的人。
从计算机的角度来看,电视台可以被视为一个向一组听众/订阅者或简单地说目的地主机发送数据报的源头。虽然可以通过单播传输服务进行点对点通信,但在多播(在单个调用中从源头向多个目的地发送数据报)中,我们有多播传输服务。在单播传输服务的情况下,通过所谓的复制单播(实际上每个点都接收数据的副本)向多个点发送相同的数据是可能的。
在多播术语中,多播数据报的接收者被称为一个组。该组由一个 D 类 IP 地址(224.0.0.0-239.255.255.255)唯一标识。只有当新客户端通过相应的 IP 地址连接到该组后,它才能监听并接收多播数据报。多播在多个领域都有用,例如数据共享管理、会议、电子邮件组、广告等等。
Java (NIO.2)通过MulticastChannel接口(NetworkChannel的子接口)来塑造多播,该接口有一个名为DatagramChannel的单个实现。该接口公开了两个join()方法,如下所示:
MembershipKey join(InetAddress g, NetworkInterface i)
throws IOException
MembershipKey join(InetAddress g, NetworkInterface i,
InetAddress s) throws IOException
想要加入多播组的客户端必须调用这些join()方法之一。第一个join()方法需要多播组的 IP 地址和一个能够执行多播的网络接口。第二个join()方法有一个额外的参数(InetAddress s),用于指示一个源地址,从该地址开始组成员可以接收数据报。由于成员资格是累积的,我们可以使用多个源与同一个组和网络接口。
如果客户端成功加入多播组,那么它会得到一个MembershipKey实例,该实例作为一个令牌,用于在该组中执行不同的操作。
重要提示
多播通道可以加入多个组。此外,多播通道可以在多个网络接口上加入同一个组。
可以在任何时候通过close()方法离开多播组。
MembershipKey 简要概述
通过MembershipKey实例,多播组的客户端可以执行的最常见操作如下:
-
阻止/解锁:通过调用带有源地址的
block()方法,我们可以阻止来自该源发送的数据报。另一方面,我们可以通过unblock()方法解锁一个源。 -
获取组:
group()方法返回创建当前成员资格密钥的组的源地址(InetAddress)。 -
获取通道:
channel()方法返回创建当前成员资格密钥的组的通道(MulticastChannel)。 -
获取源地址:对于特定源成员资格密钥(只接收来自该源的数据报),
sourceAddress()方法返回源地址(InetAddress)。 -
获取网络接口:本会员密钥的网络接口(
NetworkInterface)可通过networkInterface()方法获取。 -
检查有效性:如果成员资格密钥有效,则
isValid()方法返回true。 -
丢弃:通过
drop()方法丢弃成员资格(不再接收来自该组的数据报)可以完成。通常,在创建后,成员资格密钥变得有效,并保持这种状态,直到调用drop()方法或关闭通道。
接下来,让我们谈谈网络接口。
265. 探索网络接口
在 Java 中,网络接口由NetworkInterface API 表示。基本上,网络接口通过一个名称和分配给它的 IP 列表来识别。通过这些信息,我们可以将网络接口与不同的网络任务关联起来,例如多播组。
以下代码片段列出了您机器上可用的所有网络接口:
public class Main {
public static void main(String[] args)
throws SocketException {
Enumeration allNetworkInterfaces
= NetworkInterface.getNetworkInterfaces();
while (allNetworkInterfaces.hasMoreElements()) {
NetworkInterface ni = (NetworkInterface)
allNetworkInterfaces.nextElement();
System.out.println("\nDisplay Name: "
+ ni.getDisplayName());
System.out.println(ni.getDisplayName()
+ " is up and running ? " + ni.isUp());
System.out.println(ni.getDisplayName()
+ " is multicast capable ? "
+ ni.supportsMulticast());
System.out.println(ni.getDisplayName() + " name: "
+ ni.getName());
System.out.println(ni.getDisplayName()
+ " is virtual ? " + ni.isVirtual());
Enumeration ips = ni.getInetAddresses();
if (!ips.hasMoreElements()) {
System.out.println("IP addresses: none");
} else {
System.out.println("IP addresses:");
while (ips.hasMoreElements()) {
InetAddress ip = (InetAddress) ips.nextElement();
System.out.println("IP: " + ip);
}
}
}
}
}
对于每个网络接口,我们在控制台打印显示名称(这是一个描述网络接口的文本,供人类阅读)和名称(这对于通过名称识别网络接口很有用)。此外,我们检查网络接口是否是虚拟的(如果它实际上是一个子接口),是否支持多播,以及是否正在运行。在我的机器上运行此应用程序已返回多个网络接口,在下图中,有一个显示支持多播的网络接口(ethernet_32775)的截图:

图 13.4:多播网络接口
接下来,让我们看看如何使用ethernet_32775编写一个服务器/客户端多播应用程序。
266. 编写 UDP 多播服务器/客户端应用程序
在问题 263中,我们开发了一个 UDP 服务器/客户端应用程序。因此,基于这个经验,我们可以进一步深入,并强调可以将基于 UDP 的经典应用程序转换为多播应用程序的主要方面。
例如,假设我们想要编写一个多播服务器,该服务器向组(向所有有兴趣接收来自该服务器数据报的成员)发送封装了服务器当前日期时间的数据报。这个数据报每 10 秒发送一次。
编写 UDP 多播服务器
编写 UDP 多播服务器从通过open()方法获取的新DatagramChannel实例开始。接下来,我们设置IP_MULTICAST_IF选项(用于指示多播网络接口)和SO_REUSEADDR选项(用于允许多个成员绑定到同一地址——这应该在绑定套接字之前完成):
private static final String
MULTICAST_NI_NAME = "ethernet_32775";
...
NetworkInterface mni
= NetworkInterface.getByName(MULTICAST_NI_NAME);
dchannel.setOption(
StandardSocketOptions.IP_MULTICAST_IF, mni);
dchannel.setOption(
StandardSocketOptions.SO_REUSEADDR, true);
...
接下来,我们调用bind()方法将通道的套接字绑定到本地地址:
private static final int SERVER_PORT = 4444;
...
dchannel.bind(new InetSocketAddress(SERVER_PORT));
最后,我们需要传输包含服务器日期时间的数据报的代码。我们任意选择了225.4.5.6作为多播组的 IP 地址。将所有这些粘合在一起,结果如下面的服务器代码:
public class Main {
private static final int SERVER_PORT = 4444;
private static final String MULTICAST_GROUP = "225.4.5.6";
private static final String MULTICAST_NI_NAME
= "ethernet_32775";
public static void main(String[] args) {
ByteBufferd tBuffer;
// create a channel
try (DatagramChannel dchannel
= DatagramChannel.open(StandardProtocolFamily.INET)) {
// if the channel was successfully opened
if (dchannel.isOpen()) {
// get the multicast network interface
NetworkInterface mni
= NetworkInterface.getByName(MULTICAST_NI_NAME);
// optionally, configure the server side options
dchannel.setOption(
StandardSocketOptions.IP_MULTICAST_IF, mni);
dchannel.setOption(
StandardSocketOptions.SO_REUSEADDR, true);
// bind the channel to local address
dchannel.bind(new InetSocketAddress(SERVER_PORT));
System.out.println(
"Server is ready...sending date-time info soon...");
// sending datagrams
while (true) {
// sleep for 10000 ms (10 seconds)
try {
Thread.sleep(10000);
} catch (InterruptedException ex) {}
System.out.println("Sending date-time ...");
dtBuffer = ByteBuffer.wrap(
new Date().toString().getBytes());
dchannel.send(dtBuffer, new InetSocketAddress(
InetAddress.getByName(MULTICAST_GROUP),
SERVER_PORT));
dtBuffer.flip();
}
} else {
System.out.println("The channel is unavailable!");
}
} catch (IOException ex) {
System.err.println(ex);
}
}
}
接下来,让我们编写一个感兴趣接收来自该服务器数据报的客户端。
编写 UDP 多播客户端
编写 UDP 多播客户端与编写多播服务器没有太大区别。然而,也有一些不同之处——例如,我们可能想通过isMulticastAddress()检查远程地址(我们从那里接收数据报的地址)是否是多播地址。接下来,客户端必须加入多播组,因此它必须调用之前在问题 264中描述的join()方法之一。最后,实现应该编写为接收数据报,如下面的代码所示:
public class Main {
private static final int SERVER_PORT = 4444;
private static final int MAX_SIZE_OF_PACKET = 65507;
private static final String MULTICAST_GROUP = "225.4.5.6";
private static final String MULTICAST_NI_NAME
= "ethernet_32775";
public static void main(String[] args) {
CharBuffer cBuffer;
Charset charset = Charset.defaultCharset();
CharsetDecoder chdecoder = charset.newDecoder();
ByteBuffer dtBuffer
= ByteBuffer.allocateDirect(MAX_SIZE_OF_PACKET);
// create a channel
try (DatagramChannel dchannel
= DatagramChannel.open(StandardProtocolFamily.INET)) {
InetAddress multigroup
= InetAddress.getByName(MULTICAST_GROUP);
// if the group address is multicast
if (multigroup.isMulticastAddress()) {
// if the channel was successfully open
if (dchannel.isOpen()) {
// get the multicast network interface
NetworkInterface mni
= NetworkInterface.getByName(MULTICAST_NI_NAME);
// optionally, configure the client side options
dchannel.setOption(
StandardSocketOptions.SO_REUSEADDR, true);
// bind the channel to remote address
dchannel.bind(new InetSocketAddress(SERVER_PORT));
// join the multicast group and receive datagrams
MembershipKeymemkey = dchannel.join(
multigroup, mni);
// wait to receive datagrams
while (true) {
if (memkey.isValid()) {
dchannel.receive(dtBuffer);
dtBuffer.flip();
cBuffer = chdecoder.decode(dtBuffer);
System.out.println(cBuffer.toString());
dtBuffer.clear();
} else {
break;
}
}
} else {
System.out.println("The channel is unavailable!");
}
} else {
System.out.println("Not a multicast address!");
}
} catch (IOException ex) {
System.err.println(ex);
// handle exception
}
}
}
接下来,让我们讨论阻塞/解除阻塞数据报。
阻塞/解除阻塞数据报
如您所知,可以通过block()方法阻塞来自特定多播组的数据报,而通过unblock()方法解除阻塞。以下是一个阻止我们不喜欢的地址列表的代码片段:
List<InetAddress> dislike = ...;
DatagramChannel datagramChannel = ...;
MembershipKey memkey = datagramChannel
.join(group, network_interface);
if(!dislike.isEmpty()){
for(InetAddress source : dislike){
memkey.block(source);
}
}
或者,如果您有一个您喜欢的地址列表,那么您可以按照以下方式连接到所有这些地址:
List<InetAddress> like = ...;
DatagramChannel dchannel = ...;
if (!like.isEmpty()){
for (InetAddress source : like){
dchannel.join(group, network_interface, source);
}
}
最后,让我们测试我们的应用程序。
测试多播服务器/客户端应用程序
首先,启动服务器,等待您在控制台看到以下消息:
Server is ready ... sending date-time info soon ...
然后,您可以启动一个或多个客户端实例。每 10 秒钟,服务器将发送一个带有“发送日期时间 ...”消息标记的数据报:
Server is ready ... sending date-time info soon ...
Sending date-time ...
Sending date-time ...
Sending date-time ...
加入此多播组的每个客户端都将从服务器接收数据报:
Fri Aug 25 08:17:30 EEST 2023
Fri Aug 25 08:17:40 EEST 2023
Fri Aug 25 08:17:50 EEST 2023
完成!请注意,这个应用程序存在一些不足之处。例如,服务器和客户端并不知道彼此。即使没有客户端监听,服务器也会发送数据报,而客户端可能会等待数据报,即使服务器已离线。挑战自己,通过为每一侧添加更多控制来解决这个问题。此外,您可以自由地尝试阻塞/非阻塞模式和连接/无连接特性,以进行多播应用程序的实验。
267. 将 KEM 添加到 TCP 服务器/客户端应用程序
在这个问题中,我们试图编写一个 TCP 服务器/客户端应用程序,通过加密消息相互通信。服务器端被称为发送者,客户端被称为接收者。
在这种情况下,发送者可以使用其私钥加密消息,接收者则使用发送者的公钥解密它。如果您没有认出这个场景,那么请允许我提一下,我们正在讨论认证密钥交换(AKE)在公钥加密(PKE)中的内容,或者简而言之,就是基于密钥交换算法加密/解密消息。
在 PKE 中,AKE 是一个流行的选择,但它并不安全。换句话说,量子计算机可以推测出 AKE 的漏洞,这些量子计算机能够改变大多数密钥交换算法。JDK 21 可以通过新引入的 KEM(en.wikipedia.org/wiki/Key_encapsulation_mechanism)来防止这些问题。这是一个作为 JEP 452 交付的最终特性。
KEM 方案依赖于一个私钥公钥对,以及一个共同的密钥。KEM 的工作步骤如下。
接收者生成公私钥对
接收者(客户端)通过被称为KeyPairGenerator API 的旧式方法生成一个私钥公钥对。公钥通过getPublic()获得,私钥通过getPrivate()获得。在这里,我们生成了用于Diffie-Hellman(DH)密钥交换的密钥对,其中使用Curve25519,如 RFC 7748 中定义:
private static PublicKey publicKey;
private static PrivateKey privateKey;
...
static {
try {
KeyPairGenerator kpg
= KeyPairGenerator.getInstance("X25519");
KeyPair kp = kpg.generateKeyPair();
publicKey = kp.getPublic();
privateKey = kp.getPrivate();
} catch (NoSuchAlgorithmException ex) {...}
}
目前,我们只需要公钥。
将公钥传输给发送者
接下来,接收者将之前生成的公钥发送给发送者。这是通过接收者的SocketChannel完成的:
try (SocketChannel socketChannel = SocketChannel.open()) {
...
socketChannel.write(
ByteBuffer.wrap(publicKey.getEncoded()));
...
}
发送者需要接收者的公钥来生成一个密钥。
发送者生成共同的密钥
首先,发送者需要从接收到的byte[]中重建接收者的PublicKey实例,为此,它可以使用KeyFactory API,如下所示:
KeyFactory kf = KeyFactory.getInstance("X25519");
PublicKey publicKeyReceiver = kf.generatePublic(
new X509EncodedKeySpec(buffer.array()));
buffer.array()表示包含公钥字节的byte[]。有了PublicKey,发送者可以依赖 KEM 方案来获取密钥。它首先使用 JDK 21 中的KEM类,该类提供了 KEM 的功能:
KEM kemSender = KEM.getInstance("DHKEM");
DHKEM 内置算法是 DH 算法的高级版本(en.wikipedia.org/wiki/Diffie%E2%80%93Hellman_key_exchange)。
接下来,发送者创建一个Encapsulator并调用encapsulate()方法(称为密钥封装函数),该方法在每次调用时生成一个密钥和一个封装消息:
private static SecretKey secretKeySender;
...
KEM.Encapsulator encorSender
= kemSender.newEncapsulator(publicKeyReceiver);
KEM.Encapsulate dencedSender = encorSender.encapsulate(
0, encorSender.secretSize(), "AES");
secretKeySender = encedSender.key();
如果我们不带参数调用encapsulate()方法,那么这相当于调用encapsulate(0, encorSender.secretSize(), "Generic")。但是,正如你所见,我们更倾向于使用AES算法而不是Generic。
在这个时候,发送者通过encedSender拥有了密钥和封装消息。
将封装消息发送给接收者
现在,发送者将通过encapsulation()方法将封装消息传输给接收者:
socketChannel.write(ByteBuffer.wrap(
encedSender.encapsulation()));
接收者是唯一能够使用他们的私钥通过一个新的Decapsulator解封装接收到的数据包的人。一旦这样做,接收者就拥有了密钥:
private static SecretKey secretKeyReceiver;
...
KEM kemReceiver = KEM.getInstance("DHKEM");
KEM.Decapsulator decReceiver
= kemReceiver.newDecapsulator(privateKey);
secretKeyReceiver = decReceiver.decapsulate(
buffer.array(), 0, decReceiver.secretSize(), "AES");
发送者的密钥(secretKeySender)和接收者的密钥(secretKeyReceiver)是相同的。
使用密钥加密/解密消息
现在,发送者和接收者可以通过使用密钥和已知的 Cipher API 加密/解密消息来继续通信:
Cipher cipher = Cipher.getInstance("...");
cipher.init(Cipher.ENCRYPT_MODE/DECRYPT_MODE,
secretKeyReceiver/secretKeySender);
socketChannel.write(ByteBuffer.wrap(
cipher.doFinal("some message".getBytes())));
例如,接收者可以向发送者发送加密令牌:
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKeyReceiver);
socketChannel.write(ByteBuffer.wrap(
cipher.doFinal("My token is: 763".getBytes())));
发送者可以根据此令牌生成密码并将其发送回接收者:
// decrypt the token
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, secretKeySender);
String decMessage = new String(
cipher.doFinal(message), Charset.defaultCharset());
// generating the password based on token
// encrypt the password and send it
cipher.init(Cipher.ENCRYPT_MODE, secretKeySender);
socketChannel.write(ByteBuffer.wrap(cipher.doFinal(
"The generated password is: O98S!".getBytes())));
最后,接收者解密接收到的密码。在捆绑的代码中,你可以找到完整的代码。
268. 重构遗留的 Socket API
Socket API 随着时间的推移得到了改进,并且仍然在为潜在的进一步改进而受到关注。
在 JDK 13 之前,Socket API (java.net.ServerSocket 和 java.net.Socket) 依赖于 PlainSocketImpl。从 JDK 13(JEP 353)开始,此 API 已被 NioSocketImpl 替换。
如其名称所示,NioSocketImpl 基于 NIO 基础设施。新的实现不依赖于线程栈能够利用缓冲区缓存机制。此外,可以通过 java.lang.ref.Cleaner 机制关闭套接字,这特别关注套接字对象是如何进行垃圾回收的。
从 JDK 15(JEP 373,JEP 353 的后续)开始,内部 Socket API 在 DatagramSocket 和 MulticastSocket API 中进行了重构。目标是使这些 API 更简单,更容易适应与 Project Loom(虚拟线程)一起工作。
无论何时你更喜欢使用旧的 PlainSocketImpl,你应该使用 JVM -Djdk.net.usePlainSocketImpl=true 选项运行你的代码。
269. SWS 快速概述
SWS 在 JDK 18 中通过 JEP 408 添加到 jdk.httpserver 模块中。基本上,SWS 是一个静态文件服务器的最小化实现,能够服务于单个目录层次结构。如果请求指向一个文件,则 SWS 将提供该文件。如果请求指向包含索引文件的目录,则将提供索引文件;否则,将列出目录内容。
SWS 非常容易设置,作为命令行工具 (jwebserver) 和程序化 API 点套件 (com.sun.net.httpserver) 提供。SWS 仅支持 HTTP 1.1(不支持 HTTPS 或 HTTP/2),并且只能响应幂等的 HEAD 和 GET 请求(任何其他请求类型将返回 405 或 501 HTTP 状态码)。
此外,MIME 类型由 SWS 自动设置,并且没有可用的安全机制(例如 OAuth)。
SWS 的关键抽象
为了更好地理解 SWS 在幕后是如何工作的,考虑以下图表:

图 13.5:SWS 关键抽象
在左侧,我们有 SWS 的客户端(例如,浏览器)触发 HTTP 请求并获取 HTTP 响应。HTTP 请求-响应周期也称为 交换(客户端触发请求并获取响应作为交换)。在右侧,有一个包含服务器、几个 处理器 和 过滤器 的 SWS。服务器监听传入的 TCP 连接。接下来,每个 HTTP 请求被委托给适当的处理器(可能有一个或多个处理器)。在这里,请求实际上由 SWS 处理。最后,我们有一些过滤器。这些是可选的,它们可以在处理请求之前(预处理过滤器)或之后(后处理过滤器,例如,用于记录目的)执行。
进一步来说,HTTP 请求在 SWS 中的流程如下所示:

图 13.6:SWS 处理器和过滤器
SWS 使用的抽象之一是 上下文。上下文是根 URI 部分(/context/)和处理器之间的映射。例如,URL http://localhost:9009/a/file.txt 的上下文是 /a/,它与某个特定的处理器相关联。这样,SWS 就知道如何将请求分派给处理器。换句话说,SWS 检查传入的请求,提取上下文,并尝试找到一个与上下文匹配的处理器。该处理器将提供请求的资源(file.txt)。最后,在请求被处理后,一个后处理过滤器将记录请求详情。
270. 探索 SWS 命令行工具
运行 SWS 命令行工具(jwebserver)的唯一先决条件是 JDK 18 和以下语法:

图 13.7:jwebserver 命令行工具语法
jwebserver 的选项很简单。以下是其中一些最有用的选项的简要描述:
-
-b addr: 这是绑定地址。默认为回环地址,127.0.0.1或::1。对于所有接口,我们可以使用-b 0.0.0.0或-b ::。–b addr与--bind-address addr类似。 -
-p port: 这指定了 SWS 将监听传入请求的端口。默认端口是8000。–p端口选项与--port port类似。 -
-d dir:dir变量指向要服务的目录。默认是当前目录。–d dir与--directory dir类似。 -
-o level:level变量可以是none、info(默认)或verbose,它指定了输出格式。–o level与--output level类似。
您可以通过 jwebsever –h 命令列出所有选项及其描述。记住这些选项后,让我们启动一个 SWS 实例。
从命令行启动 SWS
假设我们想从一个名为docs的目录中提供服务,该目录包含一个文本文件和一些图像文件(你可以在捆绑的代码中找到这个文件夹)。为此,我们打开一个命令提示符(在 Windows 中)并导航到docs文件夹。接下来,从这个文件夹中,我们像以下图所示启动jwebserver命令:

图 13.8:通过 jwebserver 命令启动 SWS
接下来,我们需要将命令提示符上显示的 URL 复制到浏览器地址栏中(这里,URL 是http://127.0.0.1:8000/)。由于我们的请求指向当前目录(docs),我们得到以下图中的目录列表:

图 13.9:SWS 服务当前目录列表
如果我们点击books.txt或任何图像,那么我们将触发对文件的请求,因此 SWS 将返回文件内容,浏览器将相应地渲染它。
这里是点击books.txt和Java Coding Problems 2nd Edition.png的截图:

图 13.10:SWS 服务两个文件(一个文本文件和一个图像文件)
同时,SWS 已将我们的每个请求记录在以下图片中:

图 13.11:SWS 已记录 GET 请求
完成!接下来,让我们看看我们如何配置服务器。
通过命令行配置 SWS
通过命令行配置 SWS 可以通过前面列出的选项来完成。例如,如果我们想更改默认端口,则使用–p port(或--port port)。在以下示例中,我们使用端口9009而不是默认的8000。此外,我们通过–b addr(这是一个特定于我的机器的地址,代表一个支持组播的Hyper-V 虚拟以太网适配器)使我们的服务器仅对地址172.27.128.1可用:
jwebserver -b 172.27.128.1 -p 9009
这次,我们打开浏览器,将其指向http://172.27.128.1:9009/。
随意测试其他选项。
通过命令行停止 SWS
一个 SWS 实例会一直运行,直到它被明确停止。在 Unix/Windows 中,我们可以通过简单地输入Ctrl + C从命令行停止 SWS。有时,你可能需要等待几秒钟,直到服务器停止,命令提示符才可用于其他命令。
271. 介绍 com.sun.net.httpserver API
自 2006 年以来,除了 SWS 命令行工具外,我们还拥有由com.sun.net.httpserver API 表示的程序化桥梁。实际上,这个 API 的目标是允许我们以非常简单的方式程序化启动一个 SWS 实例。
首先,我们有 SimpleFileServer,这是通过三个静态方法创建 SWS 实例的主要 API,即 createFileServer()、createFileHandler() 和 createOutputFilter()。我们特别感兴趣的是 createFileServer(InetSocketAddress addr, Path rootDirectory, SimpleFileServer.OutputLevel outputLevel) 方法。正如您所看到的,通过此方法,我们可以创建一个具有给定端口、根目录(这应该是一个绝对路径)和输出级别的 SWS 实例,如下所示:
HttpServer sws = SimpleFileServer.createFileServer(
new InetSocketAddress(9009),
Path.of("./docs").toAbsolutePath(),
OutputLevel.VERBOSE);
sws.start();
运行此应用程序后,将在 http://localhost:9009/ 处可用一个 SWS。
默认地址是回环地址,因此您也可以将 InetSocketAddress 表达为 new InetSocketAddress(InetAddress.getLoopbackAddress(), 9009)。此外,我们可以在创建服务器后通过 bind(InetSocketAddress addr, int backlog) 绑定服务器。
对于程序化启动和停止 SWS 实例,我们有 start() 和 stop() 方法。
使用自定义 HttpHandler
另一种程序化创建自定义 SWS 实例的方法是创建一个文件处理器,并将其传递给重载的 HttpServer.create() 方法。文件处理器可以创建如下:
HttpHandler fileHandler = SimpleFileServer.createFileHandler(
Path.of("./docs").toAbsolutePath());
此文件处理器可以传递给 HttpServer.create(InetSocketAddress addr, int backlog, String path, HttpHandler handler, Filter... filters),与端口、套接字回退(允许在监听套接字上排队等待的最大传入连接数)、上下文路径和可选过滤器一起,如下所示:
HttpServer sws = HttpServer.create(
new InetSocketAddress(9009), 10, "/mybooks", fileHandler);
sws.start();
这次,服务器将在 http://localhost:9009/mybooks/ 处可用。如果您想实现相同的功能并且仍然使用 createFileServer(),那么在创建 SWS 后,您需要通过 setContext() 和 setHandler() 显式设置上下文和文件处理器,如下所示:
private static final Path ROOT_DIRECTORY_PATH =
Path.of("./docs").toAbsolutePath();
...
HttpHandler fileHandler
= SimpleFileServer.createFileHandler(ROOT_DIRECTORY_PATH);
HttpServer sws = SimpleFileServer.createFileServer(
new InetSocketAddress(9009),
ROOT_DIRECTORY_PATH,
OutputLevel.VERBOSE);
sws.createContext("/mybooks").setHandler(fileHandler);
sws.start();
还有一个 createContext(String path, HttpHandler handler) 方法。
使用自定义过滤器
通过 createOutputFilter(OutputStream out, OutputLevel outputLevel) 添加 SWS 记录的后处理过滤器可以完成。因此,我们必须指定一个输出流和日志级别。
我们可以有多个过滤器(通过过滤器数组,Filter...),但在这里我们创建一个将日志发送到名为 swslog.txt 的文本文件的单一过滤器:
HttpHandler fileHandler = SimpleFileServer.createFileHandler(
Path.of("./docs").toAbsolutePath());
Path swslog = Paths.get("swslog.txt");
BufferedOutputStream output = new
BufferedOutputStream(Files.newOutputStream(swslog,
StandardOpenOption.CREATE, StandardOpenOption.WRITE));
Filter filter = SimpleFileServer.createOutputFilter(output,
SimpleFileServer.OutputLevel.VERBOSE);
HttpServer sws = HttpServer.create(
new InetSocketAddress(9009), 10, "/mybooks",
fileHandler, filter);
sws.start();
这次,我们的 SWS 解决的每个请求都将记录到 swslog.txt。
使用自定义执行器
默认情况下,SWS 实例的所有 HTTP 请求都由 start() 方法创建的线程处理。但是,我们可以通过 setExecutor() 方法指定任何 Executor 来处理 HTTP 请求。例如,我们可以依赖 newVirtualThreadPerTaskExecutor() 如下所示:
HttpServer sws = SimpleFileServer.createFileServer(
new InetSocketAddress(9009),
Path.of("./docs").toAbsolutePath(),
OutputLevel.VERBOSE);
sws.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
sws.start();
通过 getExecutor() 方法可以获取当前的 Executor。如果没有之前设置 Executor,则 getExecutor() 返回 null。
272. 适配请求/交换
适应请求对于测试和调试目的可能很有用。实际上,我们可以在处理器看到它之前适应请求(com.sun.net.httpserver.Request),这样我们就可以修改初始请求并将结果传递给处理器。为此,我们可以依赖预处理Filter.adaptRequest(String description, UnaryOperator<Request> requestOperator)方法。除了描述外,此方法获取交换的有效请求状态作为UnaryOperator<Request>。
这里有一个例子,它将Author头添加到每个请求旁边的一个后处理过滤器中,该过滤器将请求详情记录到控制台:
HttpHandler fileHandler = ...;
Filter preFilter = Filter.adaptRequest(
"Add 'Author' header", r -> r.with(
"Author", List.of("Anghel Leonard")));
Filter postFilter = SimpleFileServer.createOutputFilter(
out, SimpleFileServer.OutputLevel.VERBOSE);
HttpServer sws = HttpServer.create(
new InetSocketAddress(9009), 10, "/mybooks",
fileHandler, preFilter, postFilter);
sws.start();
我们可以看到Author头直接添加到每个请求中。在下图中,你可以看到这个头在其他头之间:

图 13.12:添加了作者头
除了adaptRequest()之外,Filter类还定义了beforeHandler(String description, Consumer<HttpExchange> operation)预处理过滤器和afterHandler(String description, Consumer<HttpExchange> operation)后处理过滤器。在两种情况下,operation参数代表过滤器的有效实现。正如你所看到的,这些过滤器充当了com.sun.net.httpserver.HttpExchange的钩子,它代表一个交换(一个 HTTP 请求和相应的响应):
Filter preFilter = Filter.beforeHandler("some description",
exchange -> {
// do something with the exchange before handler
});
Filter postFilter = Filter.afterHandler("some description",
exchange -> {
// do something with the exchange after handler
});
通过exchange对象,我们可以访问请求/响应头和正文。
273. 使用另一个处理器补充条件HttpHandler
假设我们想要根据条件在两个HttpHandler实例之间进行选择。例如,对于所有GET请求,我们希望使用以下众所周知的HttpHandler:
HttpHandler fileHandler = SimpleFileServer.createFileHandler(
Path.of("./docs").toAbsolutePath());
对于所有其他请求,我们希望使用一个HttpHandler,它总是返回相同的资源(例如,文本No data available)。通过HttpHandlers.of(int statusCode, Headers headers, String body)方法定义一个总是返回相同代码和资源(因此,一个预定义响应)的HttpHandler,如下例所示:
HttpHandler complementHandler = HttpHandlers.of(200,
Headers.of("Content-Type", "text/plain"),
"No data available");
此外,HttpHandler类公开了一个方法,可以根据条件在两个HttpHandler实例之间进行选择。这个方法是handleOrElse(Predicate<Request> handlerTest, HttpHandler handler, HttpHandler fallbackHandler)。正如你所看到的,条件被表示为Predicate<Request>,因此在我们的情况下,我们可以写成如下:
Predicate<Request> predicate = request ->
request.getRequestMethod().equalsIgnoreCase("GET");
接下来,我们只需将这个Predicate<Request>和两个处理器传递给handleOrElse()方法:
HttpHandler handler = HttpHandlers.handleOrElse(
predicate, fileHandler, complementHandler);
如果predicate评估为true(因此,收到了 HTTP GET请求),则使用fileHandler;否则,使用complementHandler。最后,我们创建并启动一个 SWS 实例,如下所示:
HttpServer sws = HttpServer.create(
new InetSocketAddress(9009), 10, "/mybooks", handler);
sws.start();
注意传递的HttpHandler是handler。
274. 实现内存文件系统的 SWS
我们已经知道 SWS 可以从默认的本地文件系统提供文件。虽然这个文件系统适合许多场景,但也有一些用例(例如,测试场景)中,模拟目录结构以模拟某些期望将更加实用。在这种情况下,内存文件系统将比本地文件系统更适合,因为我们可以避免资源的创建/删除,并且可以使用不同的平台。
Google 项目名为Jimfs(github.com/google/jimfs)为 Java 8 提供了一个基于java.nio.file API 的内存文件系统实现。通过遵循 GitHub 示例中的说明,我们编写了以下代码以实现一个简单的内存文件系统:
private static Path inMemoryDirectory() throws IOException {
FileSystem fileSystem
= Jimfs.newFileSystem(Configuration.forCurrentPlatform());
Path docs = fileSystem.getPath("docs");
Files.createDirectory(docs);
Path books = docs.resolve("books.txt"); // /docs/books.txt
Files.write(books, ImmutableList.of(
"Java Coding Problems 1st Edition",
"Java Coding Problems 2nd Edition",
"jOOQ Masterclass",
"The Complete Coding Interview Guide in Java"),
StandardCharsets.UTF_8);
return docs.toAbsolutePath();
}
之前代码返回的路径是/docs/books.txt(你可以轻松创建任何类型的目录/文件层次结构)。由于 SWS 文件处理器支持实现java.nio.file API 的任何类型的路径文件系统,我们应该能够启动一个 SWS 实例,如下所示,通过inMemoryDirectory()返回的内存路径:
HttpServer sws = SimpleFileServer.createFileServer(
new InetSocketAddress(9009), inMemoryDirectory(),
OutputLevel.VERBOSE);
sws.start();
对于测试,将浏览器指向http://localhost:9009。
275. 为 zip 文件系统实现 SWS
java.nio.file API and returns the corresponding path:
private static Path zipFileSystem() throws IOException {
Map<String, String> env = new HashMap<>();
env.put("create", "true");
Path root = Path.of("./zips").toAbsolutePath();
Path zipPath = root.resolve("docs.zip")
.toAbsolutePath().normalize();
FileSystem zipfs = FileSystems.newFileSystem(zipPath, env);
Path externalTxtFile = Paths.get("./docs/books.txt");
Path pathInZipfile = zipfs.getPath("/bookszipped.txt");
// copy a file into the zip file
Files.copy(externalTxtFile, pathInZipfile,
StandardCopyOption.REPLACE_EXISTING);
return zipfs.getPath("/");
}
结果是一个名为docs.zip的存档,其中包含一个名为bookszipped.txt的单个文件——这个文件是从外部/docs文件夹中复制的,在该文件夹中以books.txt命名存储。
接下来,我们可以如下编写我们的 SWS 实例:
HttpServer sws = SimpleFileServer.createFileServer(
new InetSocketAddress(9009), zipFileSystem(),
OutputLevel.VERBOSE);
sws.start();
启动 SWS 实例后,只需将浏览器指向http://localhost:9009/bookszipped.txt URL,你应该能看到文本文件的内容。
276. 为 Java 运行时目录实现 SWS
从 JDK 9(JEP 220)开始,运行时图像已经重新构建以支持模块,并变得更加高效和安全。此外,命名存储的模块、类和资源已收到一个新的 URI 方案(jrt)。通过jrt方案,我们可以引用运行时图像中包含的模块、类和资源,而无需触及图像的内部结构。一个jrt URL 看起来如下所示:
jrt:/[$MODULE[/$PATH]]
在这里,$MODULE是一个模块名称(可选),而$PATH(可选)表示该模块内某个类/资源文件的路径。例如,要指向File类,我们编写以下 URL:
jrt:/java.base/java/io/File.class
在jrt文件系统中,有一个顶层modules目录,其中包含图像中每个模块的一个子目录。因此,我们可以如下获取 SWS 的正确路径:
private static Path jrtFileSystem() {
URI uri = URI.create("jrt:/");
FileSystem jrtfs = FileSystems.getFileSystem(uri);
Path jrtRoot = jrtfs.getPath("modules").toAbsolutePath();
return jrtRoot;
}
接下来,SWS 可以如下提供给定运行时图像的modules目录:
HttpServer sws = SimpleFileServer.createFileServer(
new InetSocketAddress(9009), jrtFileSystem(),
OutputLevel.VERBOSE);
sws.start();
最后,启动 SWS 并将 URL 指向一个班级/资源。例如,http://localhost:9009/java.base/java/io/File.class URL 将下载File.class以供本地检查。
摘要
本章涵盖了 19 个与 Socket API 和 SWS 相关的问题。在本章的第一部分,我们介绍了 NIO.2 针对 TCP/UDP 服务器/客户端应用程序的功能。在第二部分,我们介绍了 JDK 18 SWS 作为一个命令行工具以及作为一系列 API 点的集合。
留下评论!
喜欢这本书吗?通过留下亚马逊评论帮助像你这样的读者。扫描下面的二维码获取 20%的折扣码。

*限时优惠


浙公网安备 33010602011771号