Java-开发者落地指南第二版-全-

Java 开发者落地指南第二版(全)

原文:The Well-Grounded Java Developer, Second Edition

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

“well-grounded”是指“全面发展”吗?两年的疫情就能做到这一点,无需这本书。

Merriam-Webster 将“well-grounded”定义为“拥有坚实的基础”。我喜欢这个定义。我们希望在 Java 上有一个坚实的基础——对我们称之为 Java 专家所需知识的实际了解。这本书从《Effective Java》结束的地方开始。

这是本优秀书籍的第二版。第一版教给我们所有关于 Java 7 所需知道的知识。那似乎是很久以前的事情了。Java 7 属于另一个时代,那时语言的最佳更新频率是每三年一次。当时,区分版本很容易。Java 5?泛型和枚举。Java 7?try-with-resource。Java 8?流和 lambda。那些舒适的简单日子在 Oracle 引入六个月周期时结束了。记录——那是 Java 14、15 还是 17?增强的 Switch?那不是已经在 Java 11 中了吗?

快速发布周期对于在冒险公司工作的程序员来说很棒。每六个月,他们就能得到新的玩具来玩耍。他们甚至可以尝试即将到来的预览。众多新特性对程序员来说很棒,但对作者来说就不那么好了。在墨水干透之前,新特性的发布就会使许多事物过时。

本、杰森和马蒂恩在这本新的 Java 书中做得非常出色。基本前提保持不变。用我的话说:“如果你想要雇佣一个专业的 Java 程序员,你期望他们已经知道什么?他们需要哪些技能来证明他们是基础扎实的?”

这本书的新版本尽可能与六个月一次的发布周期保持同步。同时,作者并没有用新内容压倒我们。残酷的现实是,大多数企业仍然停留在较旧的 Java 版本上。即使 Java 18 已经发布,许多银行、保险公司和政府部门仍然在使用 Java 8。

这本书比上一版多了大约 200 页。字体稍微大了一些——嗯,我们都已经老了九岁,不是吗?但页边距小了。相当多的章节内容完全更新。这是一个新版本并没有使旧版本过时的例子。两者都应放在一个严肃的 Java 程序员的书架上。

本杰明·J.埃文斯、杰森·R.克拉克和马蒂恩·弗伯格是 Java 专家。他们在红帽、New Relic 和微软担任高级 Java 职位。让我们利用他们的集体智慧。这本书将帮助我们发现我们可以改进的弱点。最终,通过足够的工作,我们可以称自己为基础扎实的 Java 程序员

海因茨·卡布茨

Java 专家通讯

序言

这本书的第一版最初是一套为银行外汇部门的新毕业开发者编写的培训笔记。我们中的一人(本),在查看市场上的现有书籍时,发现缺乏针对想要提升水平的 Java 开发者的最新资料。在编写这些资料的过程中,他发现他正在编写那本缺失的书籍,并招募了 Martijn 来帮忙。

那是在 10 多年前——我们写作的时候 Java 7 正在开发中——而现在的世界已经非常不同了。作为回应,自第一版以来,这本书已经发生了很大的变化。因此,尽管我们的最初主要目标是介绍诸如

  • 多语言编程

  • 依赖注入

  • 多线程编程

  • 声音构建和 CI 实践

  • Java 7 的新特性

当我们着手编写第二版时,我们发现需要做一些修改,包括

  • 稍微减少多语言的使用

  • 对函数式编程增加新的重视

  • 加强对多线程的讨论

  • 对构建和部署(包括容器)进行不同的处理

  • 讨论 Java 11 和 17 的新特性

一个非常重要的变化是,第一版将 Scala 作为讨论的三种非 Java 语言之一(其他是 Groovy 和 Clojure——当时 Kotlin 实际上并不存在)。当时,许多探索 Scala 的开发者正在寻找“Java,但更好的捕鼠器”,这本质上是我们第一版中提出的 Scala 的观点。

然而,从那时起,世界已经发展。Java 8 和 11 变得极其流行,而“更好的捕鼠器”团队大多在编写 Kotlin(或者只是坚持使用 Java)。与此同时,Scala 已经变成了一种非常强大的静态类型、以函数式编程为第一语言的 JVM 语言。这对那些想要的人来说是好事,但它也带来了成本,比如越来越复杂的运行时和随着时间的推移与 Java 越来越少的共同之处。

这种发展有时被简称为“Scala 想在 JVM 上成为 Haskell”,尽管这并不完全准确,而更多的是一种方便的对话简语。因此,在决定从第二版中删除 Groovy 后,我们深思熟虑地考虑了是否保留 Scala 或用 Kotlin 替换它。

我们最终得出的结论基本上是 Scala 正朝着自己的、以函数式编程为重点的方向发展,而我们希望展示一种对刚开始接触非 Java 语言(如 Kotlin)的 Java 开发者来说更容易接近的语言。这让我们陷入了困境。Scala 中 Java 开发者容易接触到的部分与 Kotlin 非常相似(在某些情况下语法几乎相同),但两种语言的哲学和方向完全不同。我们觉得,在书中充分解释 Scala是什么——以便覆盖范围与 Kotlin 不同——会占用太多的空间。

因此,我们最终的决定是从三种语言减少到两种额外的语言,为剩余的语言(Kotlin 和 Clojure)的覆盖范围腾出空间并增加深度。因此,尽管我们偶尔会评论 Scala,但我们并没有为其分配整个部分(更不用说章节)。

Clojure 与 Kotlin 或 Java 相比,是一个完全不同的故事——实际上,它是一种非常不同的语言。例如,在第十五章中,我们遇到了一些困难,因为我们在其他语言中介绍的大多数概念(例如,高阶函数和递归)已经在 Clojure 中介绍过,并且只是“风景的一部分”。我们并没有遵循 Java 和 Kotlin 使用的模板,讨论的方向是不同的。Clojure 从根本上讲,是一种功能导向性更强的语言,如果我们遵循与其他语言完全相同的结构,我们只会重复很多次。

在这本书中,我们希望软件开发作为一种社会活动的主题能够清晰地展现出来。我们相信工艺的技术方面很重要,但人与人之间的沟通和互动的微妙之处至少同样重要。在书中轻松地解释这些方面可能有些困难,但这个主题贯穿始终。

开发者通过与技术互动和对持续学习的热情来维持他们的职业生涯。在这本书中,我们希望我们已经能够突出一些能够激发这种热情的话题。这是一次观光之旅,而不是百科全书式的研究,但这是我们的意图——让你开始,然后让你继续关注那些激发你想象的话题。

我们带你从 Java 最新版本的特性到现代软件开发的最佳实践,再到平台的发展前景。在这个过程中,我们展示了在我们作为 Java 技术人员的旅程中具有相关性的亮点。

并发、性能、字节码和类加载是我们最感兴趣的几个核心技术。我们也讨论了在 JVM 上运行的新非 Java 语言,原因有两个:

  • 非 Java 语言在整体 Java 生态系统中继续获得重要性。

  • 理解不同语言带来的不同视角可以使你在任何你使用的语言中成为一个更好的程序员。

首先,这是一次面向未来的旅程,将你和你自己的兴趣放在首位。我们相信,成为一个扎实的 Java 开发者将帮助你保持参与感和对自身发展的控制,并帮助你更多地了解 Java 及其周围不断变化的世界。我们希望你手中的浓缩经验对你有用且有趣,阅读它能够引发思考并带来乐趣。写作它当然也是这样!

致谢

我们还想要感谢以下人员对本书的贡献:

艾莱莎·海德,感谢您作为一位杰出的开发编辑;乔纳森·汤姆森,感谢他在技术审阅中的出色工作;亚历克斯·巴克利,感谢他对类加载过程的详细讨论;海因茨·卡布茨,感谢他对并发章节细节的出色建议和讨论(甚至还有 PRs!)以及另一篇精彩的序言;霍莉·卡明斯,不仅因为帮助激发原始版本,还因为她始终如一、切实可行的建议;布鲁斯·达林,感谢他对 Clojure 材料的讨论;丹·海丁,感谢他对瓦尔哈拉项目当前状态的详细反馈;皮奥特·雅吉尔斯基、路易斯·雅科梅、约瑟夫·巴托克和汤姆·特雷桑斯基,感谢他们对 Gradle 实际工作方式的一些细节的纠正;以及安德鲁·宾斯托克,感谢他对几章内容的非常细致的阅读和一贯的明智建议。

我们还想要感谢曼宁出版社的员工:米哈伊拉·巴蒂尼奇,我们的审阅编辑;迈克尔·哈勒,我们的技术审阅员;迪尔德丽·希姆,我们的项目编辑;帕梅拉·亨特,我们的校对员;以及贾森·埃弗雷特,我们的校对员。对所有审阅者:亚当·科赫、阿兰·洛姆波、亚历克斯·古特、安德烈斯·萨科、安迪·凯法拉斯、安舒曼·普鲁希特、艾什莉·伊特利、克里斯蒂安·索达尔、克里斯托弗·卡德尔、克劳迪娅·马德尔特纳、科诺尔·雷德蒙德、艾尔凡·乌拉赫博士、埃杜·梅伦德斯·冈萨雷斯、埃兹拉·西梅洛夫、乔治·托马斯、吉尔贝托·塔卡里、雨果·达·席尔瓦·波萨尼、伊戈尔·卡普、贾里德·邓肯、贾维德·阿萨罗夫、让-弗朗索瓦·莫林、杰罗姆·梅耶、肯特·R·斯皮尔纳、金伯利·L·温斯顿-杰克逊、康斯坦丁·埃雷明、马特·迪梅尔、迈克尔·哈勒、迈克尔·沃尔、米哈伊尔·科瓦列夫、帕特里夏·吉、拉马南·纳塔拉扬、拉斐尔·维莱拉、萨特杰·库马尔·萨胡、塞尔吉奥·埃德加·马丁内斯·帕切科、西蒙娜·鲁索、史蒂文·K·马库恩扎瓦、塞奥法尼斯·德索皮迪斯、特罗伊·艾斯勒、约什·谢蒂和威廉·E·惠勒,你们的建议帮助使这本书变得更好。

来自贾森

感谢许多不同的人多年来对本书的贡献。

向尼莫夫人,感谢你在中学英语课上对我的那些愚蠢小故事给予的额外加分。毫不夸张地说,你的鼓励让我走上了终身写作的道路。

向妈妈,感谢你那感染力极强的阅读热情,我很高兴继承了它。

致爸爸,感谢您分享您对计算机的热爱。这不仅仅为我提供了一份职业,还让我有机会将这份喜悦与他人分享。

致本,首先感谢您的友谊。能够因为您那令人惊叹的好奇心和热情而更深入地了解 JVM,真是件乐事。当然,感谢您邀请我参与第二版。这比我们预期的要辛苦得多,但最终也成了一本更好的书!

最后但同样重要的是,感谢我的妻子,艾梅柏,以及我的孩子们,科拉琳和艾舍尔,在整个制作书籍的奇特而美妙的过程中,他们一直给予我持续的爱和支持。

来自马蒂恩

首先,我想感谢本和贾森邀请我成为这个第二版的参与者。与他们的贡献相比,我的贡献微乎其微,但他们非常慷慨地坚持我的名字仍然出现在封面上!

致凯里,在过去十年的职业生涯中,你一直是我的坚强后盾,不仅在我表示“这次只是几处编辑,我保证!”时微笑着回应。

致亨特,你对生活的热情让我想起了我最初为什么会对编程的创造性乐趣产生兴趣。我希望无论你选择什么道路,你都能在生活中找到同样的快乐。

致微软 Java 工程组的优秀成员,Eclipse Adoptium 社区,伦敦 Java 社区,Java 冠军社区,以及太多无法一一提及的人。你们每天都激励着我,我每天都带着学到的新知识和第二天要读的五件事离开!

来自本

致我的父母,苏和马丁,感谢他们坚定不移的信念,相信我们会找到并开辟一条少有人走的路。

致我的妻子安娜,感谢她的插图,她的艺术视野,以及她在另一本书中给予我的不懈支持和理解。

纪念玛丽安托,在本书开发过程中,他发现那些被遗忘的笔记本电脑可以成为温暖的小窝,在上面睡觉非常舒适。

致约塞利托,他通过好奇我为什么会坐在屏幕前如此着迷,而那个屏幕比另一个房间里那个有宇宙飞船和爆炸的屏幕有趣得多,从而克服了一些恐惧。

关于这本书

谁应该阅读这本书?

欢迎来到《扎实的 Java 开发者》。这本书旨在让你成为下一个十年的 Java 开发者,重新点燃你对语言和平台的热情。在这个过程中,你会发现新的 Java 特性,确保你熟悉必要的现代软件技术(如测试驱动开发、基于容器的部署),并开始探索 JVM 上的非 Java 语言世界。

首先,让我们考虑詹姆斯·艾里在精彩的博客文章《编程语言简短、不完整且大多错误的历史》中提供的 Java 语言描述,该文章可在mng.bz/2rz9找到:

1996 年——詹姆斯·戈斯林发明了 Java。Java 是一种相对冗长的、垃圾回收的、基于类的、静态类型的、单派发、面向对象的语言,具有单一实现继承和多重接口继承。Sun 大声宣扬 Java 的新颖性。

虽然 Java 的介绍主要是为了设置一个玩笑,让 C#得到同样的描述,但就语言描述而言,这并不坏。完整的博客文章包含了许多其他珍贵的宝石,在空闲时刻阅读它是非常值得的。

这确实提出了一个非常现实的问题:为什么我们还在谈论一个现在已经超过 26 年的语言?当然,它已经稳定,关于它的新鲜或有趣的内容不多?

如果是这样,这将是一本短书。我们还在谈论它,因为 Java 最大的优势之一就是其能够建立在几个核心设计决策之上,这些决策在市场上已被证明非常成功:

  • 运行时环境的自动管理(例如,垃圾收集、即时编译)

  • 核心语言中的简单语法和相对较少的概念

  • 对语言演变的保守态度

  • 库中的附加功能和复杂性

  • 广泛的开放生态系统

这些设计决策使 Java 世界中的创新得以持续——简单的核心保持了加入开发者社区的门槛较低,广泛的生态系统使得新来者能够轻松找到符合他们需求的现有组件。这些特性使 Java 平台和语言保持强大和充满活力——即使语言有历史性地倾向于缓慢变化。事实证明,强大的一致性和进化变化的结合赢得了许多软件开发者的喜爱。

如何使用这本书

本书的内容设计得可以从头到尾阅读,但我们理解有些读者可能希望直接深入研究特定主题,因此我们尽力也满足了这种阅读风格。

我们坚信实践学习,因此我们建议读者在阅读文本的同时尝试书中提供的示例代码。本节的其余部分将讨论如果你更倾向于独立章节风格的阅读,你可以如何接近这本书。

《扎实的 Java 开发者》分为以下五个部分:

  • 从 8 到 11,以及更远的地方

  • 内部机制

  • JVM 上的非 Java 语言

  • 构建和部署

  • Java 前沿

第一部分(第 1-3 章)包含关于 Java 最新版本的三个章节。整本书始终使用 Java 11 语法和语义,并指出对 11 版之后语法的具体应用。

第二部分(第 4-7 章)揭开了一层面纱。艺术的一个真理是,在能够令人信服地打破规则之前,需要了解这些规则。这些章节概述了如何首先弯曲,然后打破 Java 编程语言的规则。

第三部分(第 8-10 章)涵盖了 JVM 上的多语言编程。第八章应被视为必读内容,因为它通过讨论 JVM 上替代语言的分类和使用来设定舞台。

以下两个语言章节涵盖了一种类似 Java 的面向对象函数式语言(Kotlin)和一种真正的函数式语言(Clojure)。这些语言可以独立阅读,尽管对于初学函数式编程的开发者来说,可能希望按顺序阅读它们。

第四部分(第十一章至第十四章)介绍了在现代项目中如何进行构建、部署和测试,并假设读者至少对单元测试有基本了解,例如 JUnit。

第五部分(第十五章至第十八章)基于之前介绍的主题,深入探讨函数式编程、并发和平台内部结构。尽管这些章节可以独立阅读,但在某些部分,我们假设你已经阅读了前面的章节,或者已经对某些主题有所了解。

这本书旨在帮助想要在语言和平台两方面更新其知识库的 Java 开发者。如果你想了解现代 Java 能提供什么,这本书就是为你准备的。

如果你想要提高自己在函数式编程、并发和高级测试等主题上的技巧和理解,这本书将为你提供这些主题的良好基础。这也是一本适合那些对非 Java 语言能教给他们什么以及拓宽视野将如何使他们成为更好的程序员感到好奇的开发者的书。

关于代码

你需要下载和安装的是 Java 17(或 11)。只需按照适用于你使用的操作系统的二进制文件的下载和安装说明操作即可。你可以在你常用的 Java 供应商网站上找到二进制文件和说明,或者在由 Eclipse 基金会运营的中立供应商项目 Adoptium 上找到,网址为adoptium.net/

Java 11(以及 17)可以在 Mac、Windows、Linux 以及几乎任何其他现代操作系统和硬件平台上运行。

注意:如果你对 Java 许可等细节问题感到担忧,可以查看附录 A,那里有完整的讨论。

这本书包含了大量的源代码示例,既有编号列表中的,也有与普通文本混排的。在两种情况下,源代码都以固定宽度字体格式化,如this,以将其与普通文本区分开来。

在许多情况下,原始源代码已经被重新格式化;我们添加了换行并重新调整了缩进,以适应书籍中的可用页面空间。此外,当代码在文本中描述时,源代码中的注释通常已被从列表中删除。许多列表旁边都有代码注释,突出显示重要概念。

你可以从这本书的 liveBook(在线)版本中获取可执行的代码片段,网址为livebook.manning.com/book/the-well-grounded-java-developer-second-edition。书中示例的完整代码可以从 Manning 网站www.manning.com/books/the-well-grounded-java-developer-second-edition下载,也可以从 GitHubgithub.com/well-grounded-java/resources下载。

然而,大多数读者可能希望在一个 IDE 中尝试代码示例。Java 11/17 以及主要 IDE 的最新版本都很好地支持了 Kotlin 和 Clojure:

  • Eclipse IDE

  • IntelliJ IDEA 社区版(或终极版)

  • Apache NetBeans

liveBook 讨论论坛

购买《精通 Java 开发者,第 2 版》包括免费访问 liveBook,曼宁的在线阅读平台。使用 liveBook 的独特讨论功能,您可以在全球范围内或特定章节或段落中附加评论。为自己做笔记、提出和回答技术问题以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/the-well-grounded-java-developer-second-edition/discussion。您还可以在livebook.manning.com/discussion了解更多关于曼宁论坛和行为准则的信息。

曼宁对读者的承诺是提供一个场所,让读者之间以及读者和作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他们的兴趣转移!只要这本书有售,论坛和以前讨论的存档将可通过出版社的网站访问。

其他在线资源

github.com/well-grounded-java/resources

关于作者

Evans.png

本·埃文斯是 Java 冠军和红帽公司的资深软件工程师。之前他是 New Relic 仪器部门的负责人,并与 jClarity 共同创立,这是一家被微软收购的性能工具初创公司。他还曾担任德意志银行上市衍生品的首席架构师,以及摩根士丹利的资深技术讲师。他在 Java 社区进程执行委员会工作了六年,帮助定义新的 Java 标准。

本是六本书的作者,包括《Java 优化》和《Java 精华》的新版,他的技术文章每月被成千上万的开发者阅读。本是一位经常在世界各地的公司和会议上就 Java 平台、系统架构、性能和并发等主题发表演讲和教育的专家。

Clark.png

贾森·克拉克是新 relic 的首席工程师和架构师,在那里他参与了从 Ruby 仪器库到容器编排平台的所有工作。他之前是 WebMD 的架构师,负责基于.NET 的 Web 服务。

作为一位常任会议演讲者,Jason 为开源项目 Shoes 做出贡献,旨在使 GUI 编程对初学者和学生来说既简单又有趣。

Martijn Verburg 是微软 Java 工程组的首席 SWE 团队经理。他是伦敦 Java 用户组(即 LJC)的共同领导者,在那里他与他人共同创立了 AdoptOpenJDK(现在是 Eclipse Adoptium),这是世界上领先的(非 Oracle)OpenJDK 发行版。Martijn 是《扎实的 Java 开发者,第 1 版》的共同作者,并担任多个 Java 标准机构(JCP、Jakarta EE 等)的成员。

关于封面插图

《扎实的 Java 开发者》封面上的插图“花束售卖者”是从 Sylvain Maréchal 的十九世纪地区服饰习俗汇编中取材的,该汇编在法国出版。

在那些日子里,人们通过他们的服饰就能轻易地识别出他们的居住地以及他们的职业或社会地位。Manning 通过基于几个世纪前丰富多样的地区文化的书封面来庆祝计算机行业的创新精神和主动性,这些文化通过像这样的一些图片被重新带回生活。

第一部分。从 8 到 11 以及更远!

这前三章是关于过渡到 Java 17 的。你将从一个介绍性的章节开始,该章节涵盖了随着 Java 11 而来的某些生活品质的改变。你将看到自 Java 8 以来 Java 生态系统和发布周期的变化,包括以下内容,以及这对开发者意味着什么:

  • var关键字

  • 集合工厂

  • 新的 HTTP 客户端,支持 HTTP/2

  • 单文件源代码程序

从那里,你将深入了解近年来 Java 领域最大的变化之一——完整模块系统的添加。你将看到为什么这个戏剧性的变化是必要的。它已经被精心设计为逐步采用,并且除了理解这些概念之外,你还将了解到如何在你的应用程序和库中开始利用它。

在新的发布周期下,Java 17 汇集了一批新的语言特性,包括

  • 文本块

  • Switch 表达式

  • 记录

  • 密封类型

到第一部分结束时,你将自然地思考和用 Java 17 编写代码,准备好在整个书籍的剩余部分使用这些新知识。

1 现代 Java 简介

本章涵盖

  • Java 作为平台和语言

  • 新的 Java 发布模型

  • 增强类型推断(var)

  • 孵化和预览特性

  • 更改语言

  • Java 11 中的小语言变化

欢迎来到 2022 年的 Java。这是一个激动人心的时刻。Java 17,作为最新的长期支持(LTS)版本,于 2021 年 9 月发布,最早和最具冒险精神的团队开始转向它。

在撰写本文时,除了少数先驱者外,Java 应用程序在运行在 Java 11(2018 年 9 月发布)和远早的 Java 8(2014 年)之间大致均衡分布。Java 11 提供了很多值得推荐的地方,特别是对于在云中部署的团队来说,但有些人对它的采用速度有点慢。

因此,在这本书的第一部分,我们将花一些时间介绍 Java 11 和 17 中出现的一些新特性。希望这次讨论能说服一些可能不愿意从 Java 8 升级的团队和经理们,新版本中的事物比以往任何时候都要好。

我们本章的重点将是 Java 11,因为 a)它是市场份额最大的 LTS 版本,b)目前还没有明显的 Java 17 采用。然而,在第三章中,我们将介绍 Java 17 的新特性,以便将您带到最新的版本。

让我们从讨论现代 Java 核心的“语言-平台”二分法开始。这是一个至关重要的观点,我们将在本书的多个地方回到这一点,因此从一开始就掌握它是至关重要的。

1.1 语言和平台

Java作为一个术语可以指代几个相关概念之一。特别是,它可能指的是人类可读的编程语言,或者是更广泛的“Java 平台”。

令人惊讶的是,不同的作者有时会给出略微不同的定义,以确定构成语言和平台的内容。这可能导致对两者之间的差异以及哪个提供应用程序代码使用的各种编程功能缺乏清晰性,并产生一些混淆。

让我们立即明确这一点,因为它触及了本书中许多主题的核心。以下是我们的定义:

  • Java 语言——Java 语言是我们轻描淡写地讽刺过的静态类型、面向对象的编程语言。希望它已经非常熟悉了。关于用 Java 语言编写的源代码的一个明显观点是,它是人类可读的(或者应该是这样的!)。

  • Java 平台——平台是提供运行时环境的软件。它是 JVM,它将链接并执行以(非人类可读的)类文件形式提供给它的代码。它不直接解释 Java 语言源文件,而是要求它们首先转换为类文件。

Java 作为软件系统成功的一个主要原因是它是一个标准。这意味着它有描述其应该如何工作的规范。标准化允许不同的供应商和项目组产生实现,理论上它们都应该以相同的方式工作。规范并不保证不同实现处理相同任务时的性能如何,但它们可以提供关于结果正确性的保证。

几个独立的规范管理着 Java 系统——其中最重要的是 Java 语言规范(JLS)和 JVM 规范(VMSpec)。在现代 Java 中,这种分离被非常认真地对待;事实上,VMSpec 不再直接引用 JLS。我们将在本书的后面部分更多地讨论这两个规范之间的差异。

注意:如今,JVM 实际上是一个相当通用的、与语言无关的运行程序的环境。这是规范分离的一个原因。

当你面对所描述的双重性时,一个明显的问题就是,“它们之间的联系是什么?”如果它们现在是分开的,它们是如何结合在一起形成 Java 系统的?

语言和平台之间的联系是共享的类文件格式定义(.class 文件)。对类文件定义的深入研究会给你带来回报(我们将在第四章提供一份)——实际上,这是优秀 Java 程序员开始成为杰出程序员的一种方式。在图 1.1 中,你可以看到 Java 代码的产生和使用过程的完整流程。

图片

图 1.1 Java 源代码被转换成.class 文件,然后在加载时进行操作,最后进行 JIT 编译。

如图中所示,Java 代码最初以人类可读的 Java 源代码形式存在,然后由javac编译成.class 文件,并加载到 JVM 中。在加载过程中对类进行操作和修改是很常见的。许多流行的 Java 框架在加载类时会将其转换,以注入动态行为,例如代码插装或对要加载的类的替代查找。

注意:类加载是 Java 平台的一个基本特性,我们将在第四章中了解更多关于它的内容。

Java 是编译型语言还是解释型语言?Java 的标准形象是先编译成.class 文件,然后在 JVM 上运行。如果被追问,许多开发者也可以解释说字节码最初是由 JVM 解释的,但会在某个后期点进行即时(JIT)编译。然而,在这里,许多人对于字节码的理解变得有些模糊,认为字节码基本上是针对一个想象中的或简化的 CPU 的机器代码。

事实上,JVM 字节码更像是人类可读源代码和机器代码之间的中间状态。在编译器理论的技术术语中,字节码实际上是一种中间语言(IL),而不是实际的机器代码。这意味着将 Java 源代码转换为字节码的过程并不是像 C++ 或 Go 程序员理解的那种编译,javac 也不是像 gcc 那样的编译器——它实际上是为 Java 源代码生成类文件的生成器。Java 生态系统中的真正编译器是 JIT 编译器,如图 1.1 所示。

有些人将 Java 系统描述为“动态编译”。这强调了真正重要的编译是在运行时的 JIT 编译,而不是在构建过程中的类文件创建。

注意:源代码编译器 javac 的存在让许多开发者认为 Java 是一种静态编译语言。一个巨大的秘密是,在运行时,Java 环境实际上非常动态——只是隐藏在表面之下一点。

因此,“Java 是编译还是解释?”的真正答案是“两者都是”。随着语言和平台之间的区别现在更加清晰,让我们继续谈谈新的 Java 发布模型。

1.2 新的 Java 发布模型

Java 并非一直是一个开源语言,但在 2006 年 JavaOne 大会上的一个公告之后,Java 本身的源代码(除了 Sun 没有源代码的几个部分)被发布在 GPLv2+CE 许可证下 (openjdk.java.net/legal/gplv2+ce.html)。

这大约是在 Java 6 发布的时候,因此 Java 7 是第一个在开源软件(OSS)许可证下开发的 Java 版本。从那时起,Java 平台开源开发的主要重点是 OpenJDK 项目 (openjdk.java.net),并且这种情况一直持续到今天。

大部分的项目讨论都在涵盖整体代码库方面的邮件列表上进行。有“永久性”列表,如 core-libs(核心库),以及更多短暂的列表,这些列表作为特定 OpenJDK 项目(如 lambda-dev(lambda 表达式))的一部分形成,当特定项目完成后,这些列表就会变得不活跃。总的来说,这些列表一直是讨论可能未来功能的论坛,允许更广泛的社区的开发者参与到产生新的 Java 版本的过程中。

注意:Sun Microsystems 在 Java 7 发布前不久被 Oracle 收购。因此,Oracle 所有的 Java 发布版都是基于开源代码库的。

Java 的开源发布已经形成了以功能驱动的发布周期,其中单个突出功能实际上定义了发布(例如,Java 8 中的 lambda 表达式或 Java 9 中的模块)。

然而,随着 Java 9 的发布,发布模式发生了变化。从 Java 10 开始,Oracle 决定 Java 将按照严格的基于时间模型的发布。这意味着 OpenJDK 现在使用主线开发模型,包括以下内容:

  • 新特性是在分支上开发的,只有当它们代码完成时才会合并。

  • 发布可以在严格的时间周期内进行。

  • 晚期特性不会延迟发布,但会留到下一个版本。

  • 树干当前的头应该始终是可释放的(从理论上讲)。

  • 如果有必要,可以在任何时间点准备和推出紧急修复。

  • 使用独立的 OpenJDK 项目来探索和研究长期、未来的方向。

每隔六个月就会发布一个新的 Java 版本(“功能发布”)。各种提供商(Oracle、Eclipse Adoptium、Amazon、Azul 等)可以选择将其中任何一个版本作为长期支持(LTS)版本。然而,在实践中,所有供应商都遵循每三年发布一个 LTS 版本的策略。

注意:截至 2021 年底,正在讨论将 LTS 间隔从三年缩短到两年。我们可能会在 2023 年看到下一个 LTS 版本 Java 21,而不是在 2024 年的 Java 23。

第一个 LTS 版本是 Java 11,Java 8 被回顾性地包含在 LTS 版本集中。Oracle 的意图是 Java 社区定期升级,并随着新特性的出现而采用它们。然而,在实践中,社区(尤其是企业客户)已经证明对这个模型持抵制态度,更愿意从一个 LTS 版本升级到下一个版本。

当然,这种方法限制了新 Java 特性的采用并抑制了创新。然而,企业软件的现实就是这样,许多人仍然将 Java 版本的升级视为一项重大任务。

图片

图 1.2 近期和未来发布的时序

这意味着,虽然图 1.2 所示的发布路线图每六个月有一个主要版本,但只有 LTS 版本具有显著的使用率——Java 17(刚刚在 2021 年 9 月发布),Java 11(在 2018 年 9 月发布),以及超过七年的预模块版本 Java 8。Java 8 和 Java 11 的市场份额大致相等,Java 11 最近已经占据了 50%以上,并且正在迅速增长。预计 Java 17 的采用速度将比从 Java 8 迁移到 Java 11 的速度快得多,因为早期迁移中引入的最困难的路障和安全性限制已经克服。

新发布模型中的另一个重大变化是,Oracle 更改了其分发的许可证。尽管 Oracle 的 JDK 是从 OpenJDK 源构建的,但二进制文件不是在 OSS 许可证下许可的。相反,Oracle 的 JDK 是专有软件,并且从 JDK 11 开始,Oracle 为每个版本提供为期六个月的支持和更新。这意味着许多依赖 Oracle 免费更新的人现在面临着一个选择:

  • 为支持和服务向 Oracle 付费,或

  • 使用产生开源二进制文件的不同发行版。

替代 JDK 供应商包括 Eclipse Adoptium(以前称为 AdoptOpenJDK)、阿里巴巴(Dragonwell)、亚马逊(Corretto)、Azul Systems(Zulu)、IBM、Microsoft、Red Hat 和 SAP。

注意:两位作者(Martijn 和 Ben)帮助创立了 AdoptOpenJDK 项目,该项目已发展成为构建和发布高质量、免费和开源 Java 二进制分发的供应商中立 Eclipse Adoptium 社区项目。有关更多详细信息,请参阅adoptium.net

鉴于许可证变更和如此多的提供商,为您和您的团队选择正确的 Java 是一个需要谨慎做出的选择。幸运的是,Java 生态系统中的领导者已经编写了一些非常详细的指南,附录 A 为您总结了这些指南。

尽管 Java 发布模型已经改为使用定时发布,但绝大多数团队仍在使用 JDK 8 或 11。这些长期支持(LTS)版本由社区(包括主要供应商)维护,并且仍然会定期接收安全更新和错误修复。对 LTS 版本所做的更改故意范围较小,是“维护更新”。除了安全和小的错误修复外,只允许进行最小限度的更改。这些包括确保 LTS 版本在其预期使用寿命内继续正确工作的必要修复。这包括以下内容:

  • 新增日本时代

  • 时区数据库更新

  • TLS 1.3

  • 添加 Shenandoah,适用于大型现代工作负载的低暂停垃圾回收器

另一个必要的更改是,macOS 的构建脚本需要更新以与苹果最近版本的 Xcode 工具兼容,以便它们可以在苹果操作系统的最新版本上继续工作。

在维护 JDK 8 和 11 的项目(有时称为“更新”项目)中,仍然存在一些空间可以回滚新功能,但这个空间非常小。例如,一个指导原则是,新移植的功能不得改变程序语义。允许的更改示例可能包括对 TLS 1.3 的支持或将 Java Flight Recorder 回滚到 Java 8u272。

现在我们已经通过阐明语言和平台之间的区别以及解释新的发布模型来设定了场景,让我们来认识现代 Java 的第一个技术特性。我们将要遇到的新特性是自从 Java 的第一个版本发布以来开发者一直要求的功能——减少编写 Java 程序所需输入量的方法。

1.3 增强类型推断(var 关键字)

Java 在历史上一直以冗长著称。然而,在最近的版本中,语言已经发展,越来越多地使用类型推断。这个源代码编译器的特性使得编译器能够自动处理程序中的一些类型信息。因此,不需要显式地告诉所有信息。

注意:类型推断的目的是减少样板内容,消除重复,并允许代码更加简洁易读。

这种趋势始于 Java 5,当时引入了泛型方法。泛型方法允许对泛型类型参数进行非常有限的形式的类型推断,因此,不需要显式提供所需的精确类型,如下所示:

List<Integer> empty = Collections.<Integer>emptyList();

泛型类型参数可以在右侧省略,如下所示:

List<Integer> empty = Collections.emptyList();

以这种方式编写泛型方法的调用如此熟悉,以至于许多开发者会努力记住带有显式类型参数的形式。这是一个好事——这意味着类型推断正在做它的工作,移除多余的样板内容,从而使代码的意义清晰。

Java 类型推断的下一个重大增强是在 Java 7 版本中实现的,它引入了处理泛型时的一个变化。在 Java 7 之前,常见的代码如下:

Map<Integer, Map<String, String>> usersLists =
                        new HashMap<Integer, Map<String, String>>();

这是一种非常冗长的声明方式,表明你有一些用户,你通过userid(这是一个整数)来识别他们,每个用户都有一个特定的属性集(建模为字符串到字符串的映射),这些属性属于该用户。

实际上,源代码中几乎有一半是重复的字符,它们并没有告诉我们任何信息。因此,从 Java 7 开始,我们可以这样写

Map<Integer, Map<String, String>> usersLists = new HashMap<>();

并让编译器在右侧确定类型信息。编译器正在确定右侧表达式的正确类型——它不仅仅是替换定义完整类型的文本。

注意:由于简化的类型声明看起来像菱形,这种形式被称为“菱形语法”。

在 Java 8 中,为了支持 lambda 表达式的引入,增加了更多的类型推断功能,例如这个例子中类型推断算法可以推断出s的类型是String

Function<String, Integer> lengthFn = s -> s.length();

在现代 Java 中,随着局部变量类型推断(LVTI)的引入,也称为var,类型推断又前进了一步。这个特性是在 Java 10 中添加的,允许开发者推断变量的类型,而不是的类型,如下所示:

var names = new ArrayList<String>();

这是通过将 var 实现为一个保留的、“魔法”类型名称而不是语言关键字来实现的。理论上,开发者仍然可以使用 var 作为变量、方法或包的名称。

注意:适当地使用 var 的重要副作用是,你的代码域再次成为焦点(与类型信息相反)。但是,权力越大,责任越大!请确保你仔细命名变量,以帮助未来的代码阅读者。

另一方面,之前使用 var 作为类型名称的代码将需要重新编译。然而,几乎所有的 Java 开发者都遵循一个约定,即类型名称应该以大写字母开头,因此被称为 var 的现有类型实例数量应该非常少。这意味着编写类似下一列表中所示代码是完全合法的。

列表 1.1 恶劣的代码

package var;

public class Var {
  private static Var var = null;

  public static Var var() {
    return var;
  }

  public static void var(Var var) {
    Var.var = var;
  }
}

然后可以这样调用它:

var var = var();
if (var == null) {
  var(new Var());
}

然而,仅仅因为某件事是 合法的,并不意味着它是 合理的。编写类似前述列表中的代码不会让你赢得任何朋友,并且不应该通过代码审查!

var 的目的是减少 Java 代码的冗长性,并使从其他语言转向 Java 的程序员感到熟悉。它不引入动态类型,Java 中的所有变量在所有时候都继续具有静态类型——你只是不需要在所有情况下都明确写出它们。

Java 中的类型推断是 局部的,在 var 的情况下,算法仅检查局部变量的声明。这意味着它不能用于字段、方法参数或返回类型。编译器应用一种 约束求解 形式来确定是否存在任何类型可以满足代码中所有要求。

注意:var 仅在源代码编译器(javac)中实现,并且没有任何运行时或性能影响。

例如,在上一个代码示例中 lengthFn 的声明中,约束求解器可以推断出方法参数 s 的类型必须与 String 兼容,这是作为 Function 参数的类型显式提供的。当然,在 Java 中,字符串类型是 final 的,因此编译器可以得出结论,s 的类型正好是 String

为了让编译器能够推断类型,程序员必须提供足够的信息,以便解决约束方程。例如,这样的代码

var fn = s -> s.length();

这没有提供足够的信息供编译器推断 fn 的类型,因此它将无法编译。这种情况的一个重要例子是

var n = null;

这不能被编译器解决,因为空值可以被分配给任何引用类型的变量,因此没有关于 n 可能是什么类型的信息。我们说在这种情况下,推断器需要解决的类型约束方程是“欠确定的”——这是一个数学术语,它将需要解决的方程数量与变量的数量联系起来。

你可以想象一种类型推断方案,它不仅限于局部变量的初始声明,还会检查更多代码以做出推断决策,如下所示:

var n = null;
String.format(n);

一个更复杂的推断算法(或人类)可能能够得出结论,n的类型实际上是String,因为format()方法接受字符串作为第一个参数。

这可能看起来很有吸引力,但,就像软件中的其他一切一样,它代表了一种权衡。更多的复杂性意味着更长的编译时间和更多推断可能失败的方式。这反过来意味着程序员必须发展出更复杂的直觉,才能正确地使用非局部类型推断。

其他语言可能会选择做出不同的权衡,但 Java 很明确:只有声明被用来推断类型。局部变量类型推断旨在是一种有益的技术,可以减少样板文本和冗余。然而,它只应在必要时使用,以使代码更清晰,而不是作为一种在任何可能的情况下都可以使用的钝工具(“黄金锤”反模式)。

以下是一些关于何时使用 LVTI 的快速指南:

  • 在简单的初始化器中,如果右侧是构造函数或静态工厂方法的调用

  • 如果删除显式类型可以删除重复或冗余信息

  • 如果变量名已经表明了它们的类型

  • 如果局部变量的作用域和用途简短且简单

Java 语言的核心开发者之一 Stuart Marks 在他的 LVTI 使用风格指南中提供了一套完整的适用规则,可以在mng.bz/RvPK找到。

为了总结本节,让我们看看var的另一种更高级的使用——所谓的不可表示的类型。这些类型在 Java 中是合法的,但不能作为变量的类型出现。相反,它们必须被推断为被分配的表达式的类型。让我们通过使用 Java 9 中引入的jshell交互式环境来举一个简单的例子:

jshell> var duck = new Object() {
   ...>     void quack() {
   ...>         System.out.println("Quack!");
   ...>     }
   ...> }
duck ==> $0@5910e440

jshell> duck.quack();
Quack!

变量duck有一个不寻常的类型——它实际上是Object,但扩展了一个名为quack()的方法。尽管这个对象可能像鸭子一样嘎嘎叫,但它的类型没有名称,所以我们不能将其用作方法参数或返回类型。

使用 LVTI,我们可以将其用作局部变量的推断类型。这允许我们在方法中使用该类型。当然,该类型不能在紧密的局部作用域之外使用,因此这种语言特性的整体效用有限。它更多的是一种好奇心,而不是其他。

尽管存在这些限制,这确实代表了 Java 对某些其他语言中存在的特性的看法——在静态类型语言中有时被称为结构化类型,在动态类型语言(尤其是 Python)中称为鸭子类型

1.4 更改语言和平台

我们认为解释语言变化的“为什么”和“是什么”同样重要。在 Java 新版本的开发过程中,对新语言特性的兴趣通常很大,但社区并不总是理解要完全工程化并准备好投入使用的更改需要多少工作量。

你可能也注意到,在成熟的运行时环境如 Java 中,语言特性往往是从其他语言或库中演变而来,进入流行的框架,然后才被添加到语言或运行时本身。我们希望在这个领域提供一些启示,并希望在这个过程中消除一些误解。但如果你对 Java 的演变不太感兴趣,可以自由地跳到 1.5 节,直接进入语言变化的讨论。

在更改 Java 语言的过程中存在一个工作量曲线——一些可能的实现方式比其他方式需要更少的工程工作量。在图 1.3 中,我们试图表示不同的路径,并展示每个路径所需的相对工作量。

图片

图 1.3 以不同方式实现新功能所涉及的相对工作量

通常,选择最省力的路线更好。这意味着如果可能的话,将新特性作为库来实现,你通常应该这样做。但并非所有特性都容易实现,或者甚至可能通过库或 IDE 功能来实现。一些特性必须在平台内部更深处实现。以下是一些最近特性如何适应我们新语言特性复杂度尺度的例子:

  • 库更改—Collections 工厂方法(Java 9)

  • 语法糖—数字中的下划线(Java 7)

  • 小型新语言特性—try-with-resources(Java 7)

  • 类文件格式更改—注解(Java 5)

  • 新的 JVM 特性—Nestmates(Java 11)

  • 主要新特性—Lambda 表达式(Java 8)

让我们仔细看看如何在复杂度尺度上做出变化。

1.4.1 撒上一些糖

有时用来描述语言特性的短语是“语法糖”。也就是说,提供语法糖形式是因为它对人类来说更容易操作,尽管语言中已经存在该功能。

按照惯例,被称为语法糖的特性在编译过程的早期就被从编译器对程序的表示中移除——它被说成是“去糖化”成了相同特性的基本表示。

这使得对语言进行语法糖(syntactic sugar)的更改更容易实现,因为它们通常涉及相对较少的工作量,并且仅涉及对编译器(在 Java 的情况下是javac)的更改。

在这个阶段可能会提出的一个问题是,“什么构成了对规范的微小更改?”Java 7 中最直接的一个更改是在 JLS 的第 14.11 节中添加了一个单词——“String”,这使得在 switch 语句中可以使用字符串。作为一个更改,你实际上无法做得更小,然而即使是这个更改也触及了规范的几个其他方面。任何变更都会产生后果,并且这些后果必须在整个语言设计中追踪。

1.4.2 更改语言

必须执行(或至少调查)的完整操作集(或至少是任何更改)如下:

  • 更新 JLS。

  • 在源编译器中实现原型。

  • 添加对更改至关重要的库支持。

  • 编写测试和示例。

  • 更新文档。

此外,如果更改触及 JVM 或平台方面,必须执行以下操作:

  • 更新 VMSpec。

  • 实施 JVM 变更。

  • 在类文件和 JVM 工具中添加支持。

  • 考虑对反射的影响。

  • 考虑对序列化的影响。

  • 考虑对原生代码组件(如 Java 原生接口(JNI))的影响。

这不是一项小工作量,而且在考虑了整个语言规范中更改的影响之后!

在进行更改时,类型系统是一个棘手的问题区域。这并不是因为 Java 的类型系统很糟糕。相反,具有丰富静态类型系统的语言很可能在其类型系统的不同部分之间有许多可能的交互点。对这些交互点进行更改很容易产生意外的惊喜。

1.4.3 JSRs 和 JEPs

用于更改 Java 平台的两个主要机制是 Java 规范请求(JSR),由 Java 社区进程(JCP)指定。这用于确定标准 API——既包括外部库也包括主要内部平台 API。

这在历史上是更改 Java 平台的唯一方式,并且最好用于将已经成熟的技术的共识编码化。然而,在近年来,为了更快(并且以更小的单元)实施更改,导致了 JDK 增强提案(JEP)作为一种更轻量级的替代方案的开发。平台(也称为伞状)JSR 现在由针对下一个 Java 版本的 JEP 组成。JSR 流程用于为整个生态系统提供额外的知识产权保护。

当讨论新的 Java 功能时,通常很有用通过其 JEP 编号来引用即将推出或最近的功能。所有 JEP 的完整列表,包括已交付或撤回的,可以在 openjdk.java.net/jeps/0 找到。

1.4.4 孵化和预览功能

在新的发布模型中,Java 在最终确定功能之前,在后续版本中尝试一个提议的功能有两种机制。这些机制的目标是通过从更广泛的用户群体收集反馈,以及在它成为 Java 永久部分之前可能更改或撤回功能,来提供更好的功能。

孵化功能是新 API 及其实现,它们在 simplest 形式下实际上只是作为一个自包含模块发布的新的 API(我们将在第二章中遇到 Java 模块的详细信息)。模块的命名是为了使其清楚表明该 API 是临时的,并且将在功能最终确定时发生变化。

注意:这意味着任何依赖于孵化功能非最终版本的代码,在功能最终确定时将不得不进行更改。

孵化功能的一个非常明显的例子是对 HTTP 协议第 2 版的新支持,通常称为 HTTP/2。在 Java 9 中,它作为孵化器模块jdk.incubator.http发布。该模块的命名以及使用jdk.incubator命名空间而不是java命名空间,清楚地表明该功能是非标准的,并且将在功能最终确定时发生变化。该功能在 Java 11 中标准化,当时它被移动到命名空间java部分的java.net.http模块中。

注意:当我们讨论 Foreign Access API 时,我们将在第十八章遇到另一个孵化功能,它是 OpenJDK 项目中一个名为 Panama 的项目的一部分。

这种方法的主要优势是孵化功能可以被隔离到单个命名空间中。开发者可以快速尝试该功能,甚至可以在生产代码中使用它,前提是他们愿意修改一些代码,并在功能标准化时重新编译和重新链接。

预览功能是近期 Java 版本提供的一种机制,用于发布尚未最终确定的功能。与孵化功能相比,它们更具侵入性,因为它们作为语言本身的一部分,在更深的层次上实现。这些功能可能需要以下方面的支持:

  • javac编译器

  • 字节码格式

  • 类文件和类加载

它们仅在向编译器和运行时传递特定标志时才可用。在没有启用标志的情况下尝试使用预览功能是错误的,无论是在编译时还是在运行时。

这使得它们处理起来更加复杂(与孵化功能相比)。因此,预览功能实际上不能用于生产。一方面,它们由一个尚未最终确定且可能永远不会由任何生产版本的 Java 支持的类文件格式表示。

这意味着预览功能仅适用于实验、开发者测试和熟悉。不幸的是,在几乎所有部署中,只有完全最终确定的功能才能用于旨在生产使用的代码。

Java 11 没有包含任何预览功能(尽管 Java 12 中出现了 switch 表达式 的第一个预览版本),因此很难在这个部分给出一个很好的例子。不过,当我们在第三章讨论 Java 17 时,我们会更深入地探讨预览版本。

1.5 Java 11 的小改动

自 Java 8 以来,在连续的版本中出现了相当多的新小功能。让我们快速浏览一下其中一些最重要的功能——尽管这绝对不是所有变化。

1.5.1 集合工厂(JEP 213)

常被请求的功能增强之一是扩展 Java 以支持一种简单的声明 集合字面量 的方法——一个简单的对象集合(例如列表或映射)。这看起来很有吸引力,因为许多其他语言都支持这种形式,Java 本身也一直有数组字面量,如下所示:

jshell> int[] numbers = {1, 2, 3};
numbers ==> int[3] { 1, 2, 3 }

然而,尽管表面上看起来很有吸引力,但在语言级别添加这个功能有一些显著的缺点。例如,尽管 ArrayListHashMapHashSet 是开发者最熟悉的实现,但 Java 集合的一个主要设计原则是它们被表示为接口,而不是类。其他实现也是可用的,并且被广泛使用。

这意味着,如果有一个新的语法直接与特定的实现耦合,无论多么常见,都将与设计意图相悖。相反,设计决策是在相关接口中添加简单的工厂方法,利用 Java 8 添加了在接口上定义静态方法的能力。生成的代码如下所示:

Set<String> set = Set.of("a", "b", "c");

var list = List.of("x", "y");

虽然这个方法在语言级别添加支持时稍微有点冗长,但在实现层面的复杂性成本却大大降低。这些新方法被实现为一组重载,如下所示:

List<E> List<E>.<E>of()
List<E> List<E>.<E>of(E e1)
List<E> List<E>.<E>of(E e1, E e2)
List<E> List<E>.<E>of(E e1, E e2, E e3)
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4)
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5)
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5, E e6)
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5, E e6, E e7)
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8)
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9)
List<E> List<E>.<E>of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9,
  E e10)
List<E> List<E>.<E>of(E... elements)

提供了常见的案例(最多 10 个元素),以及一个 varargs 形式,用于不太可能的情况,即集合中需要超过 10 个元素。

对于映射,情况要复杂一些,因为映射有两个泛型参数(键类型和值类型),因此,尽管简单的案例可以写成这样:

var m1 = Map.of(k1, v1);
var m2 = Map.of(k1, v1, k2, v2);

没有简单的方法来编写映射的 varargs 形式的等价形式。相反,使用不同的工厂方法 ofEntries() 与静态辅助方法 entry() 结合使用,以提供 varargs 形式的等价形式,如下所示:

Map.ofEntries(
    entry(k1, v1),
    entry(k2, v2),
    // ...
    entry(kn, vn));

开发者应该注意的一个最后一点是:工厂方法产生不可变类型的实例,如下所示:

jshell> var ints = List.of(2, 3, 5, 7);
ints ==> [2, 3, 5, 7]

jshell> ints.getClass();
$2 ==> class java.util.ImmutableCollections$ListN

这些类是 Java 集合接口的新实现,它们是不可变的——它们不是熟悉的可变类(例如 ArrayListHashMap)。尝试修改这些类型的实例将导致抛出异常。

1.5.2 移除企业模块(JEP 320)

随着时间的推移,Java 标准版(即 Java SE)增加了一些模块,这些模块实际上是 Java 企业版(Java EE)的一部分,例如

  • JAXB

  • JAX-WS

  • CORBA

  • JTA

在 Java 9 中,以下实现这些技术的包被移动到非核心模块,并已弃用以供移除:

  • java.activation (JAF)

  • java.corba (CORBA)

  • java.transaction (JTA)

  • java.xml.bind (JAXB)

  • java.xml.ws (JAX-WS,以及一些相关技术)

  • java.xml.ws.annotation (通用注解)

作为简化平台的一部分,在 Java 11 中,这些模块已被移除。以下三个用于工具和聚合的相关模块也已从核心 SE 分发中移除:

  • java.se.ee (上述六个模块的聚合模块)

  • jdk.xml.ws (JAX-WS 工具)

  • jdk.xml.bind (JAXB 工具)

基于 Java 11 及以后版本的项目,如果想要使用这些功能,现在需要包含一个显式的外部依赖项。这意味着一些依赖于这些 API 的程序在 Java 8 下可以干净地构建,但在 Java 11 下构建需要修改其构建脚本。我们将在第十一章中更全面地研究这个问题。

1.5.3 HTTP/2 (Java 11)

在现代时代,HTTP 标准的新版本已经发布——HTTP/2。我们将探讨最终更新这个自 1997 年以来一直受到尊敬的 HTTP 1.1 规范(!)的原因。然后我们将看到 Java 11 如何为经验丰富的开发者提供对 HTTP/2 的新功能和性能的访问。

如您所料,对于 1997 年的技术,HTTP 1.1 已经显示出其年龄,尤其是在现代 Web 应用程序的性能方面。局限性包括如下问题:

  • 首行阻塞

  • 限制连接到单个站点

  • HTTP 控制头部的性能开销

HTTP/2 是对协议的 传输层 更新,专注于修复这些类型的根本性能问题,这些问题不适合当今网络的实际工作方式。它对性能的关注在于客户端和服务器之间字节流的流动,HTTP/2 实际上并没有改变许多熟悉的 HTTP 概念——请求/响应、头部、状态码、响应体——所有这些在 HTTP/2 与 HTTP 1.1 中在语义上保持相同。

首行阻塞

HTTP 中的通信发生在 TCP 套接字上。尽管 HTTP 1.1 默认重用单个套接字以避免重复不必要的设置成本,但协议规定请求必须按顺序返回,即使多个请求共享一个套接字(称为流水线;见图 1.4)。这意味着来自服务器的缓慢响应会阻塞后续请求,而理论上这些请求可以更早返回。这些影响在浏览器在下载资源时渲染停滞的地方很容易看到。每次一个响应-一次连接的行为也可以限制与基于 HTTP 的服务通信的 JVM 应用程序。

图片

图 1.4 HTTP 1.1 传输

HTTP/2 从一开始就被设计为在相同的连接上多路复用请求,如图 1.5 所示。客户端和服务器之间的多个 总是得到支持。它甚至允许分别接收单个请求的头部和主体。

图片

图 1.5 HTTP/2 传输

这从根本上改变了数十年来 HTTP 1.1 已经使许多开发者习以为常的假设。例如,长期以来人们普遍认为,在一个网站上返回大量的小资产比返回更大的包表现更差。JavaScript、CSS 和图片都有共同的技巧和工具,可以将许多较小的文件合并在一起以更有效地返回。在 HTTP/2 中,多路复用响应意味着你的资源不会因为其他缓慢的请求而被阻塞,并且较小的响应可能被更准确地缓存,从而提供更好的整体体验。

受限连接

HTTP 1.1 规范建议同时限制对服务器的连接数为两个。这被列为“应该”而不是“必须”,现代网络浏览器通常允许每个域名之间有六到八个连接。这种对网站并发下载的限制常常导致开发者从多个域名提供服务或实施前面提到的打包方式。

HTTP/2 解决了这种情况:每个连接可以有效地用于发送所需数量的并发请求。浏览器只对一个域名打开一个连接,但可以在同一连接上同时执行许多请求。

在我们的 JVM 应用程序中,我们可能会池化 HTTP 1.1 连接以允许更多的并发活动,HTTP/2 给我们提供了另一种内置的方式来挤出更多的请求。

HTTP 头部性能

HTTP 的一个显著特性是能够在请求中发送 头部。头部是 HTTP 协议本身无状态的关键部分,但我们的应用程序可以在请求之间保持状态(例如,用户已经登录)。

尽管如果客户端和服务器可以就算法达成一致(通常是 gzip),HTTP 1.1 有效载荷的主体可以被压缩,但头部并不参与。随着更丰富的网络应用程序发出越来越多的请求,越来越大的头部的重复可能会成为一个问题,特别是对于大型网站。

HTTP/2 通过为头部引入一个新的二进制格式来解决此问题。作为协议的用户,你不必过多考虑这一点——它只是内置到客户端和服务器之间传输头部的方式中。

TLS 的一切

在 1997 年,HTTP 1.1 进入了一个与今天截然不同的互联网。互联网上的商业刚刚开始起飞,而在早期的协议设计中,安全性并不总是首要考虑。计算系统也足够慢,以至于加密等做法通常过于昂贵。

HTTP/2 于 2015 年正式被接纳到一个对安全性更加重视的世界。此外,通过 TLS(在早期版本中称为 SSL)对 Web 请求进行普遍加密的计算需求足够低,以至于消除了大多数关于是否加密的争论。因此,在实践中,HTTP/2 仅支持 TLS 加密(该协议在理论上允许明文传输,但没有任何主要实现提供这种功能)。

这对部署 HTTP/2 有实际影响,因为它需要一个具有过期和续订生命周期的证书。对于企业来说,这增加了对证书管理的需求。Let’s Encrypt (www.letsencrypt.org) 和其他私人选项已经随着这种需求而增长。

其他考虑因素

尽管未来趋势是采用 HTTP/2,但它在网络上的部署速度并不快。除了加密要求,这甚至影响了本地开发,这种延迟可能归因于以下粗糙边缘和额外复杂性:

  • HTTP/2 仅支持二进制格式;与不透明的格式一起工作具有挑战性。

  • 需要更新以支持 HTTP/2 的 HTTP 层产品,如负载均衡器、防火墙和调试工具。

  • 性能优势主要针对基于浏览器的 HTTP 使用。通过 HTTP 工作的底层服务可能看到更新带来的好处较少。

Java 11 中的 HTTP/2

在多年之后出现新的 HTTP 版本激励了 JEP 110 引入一个全新的 API。在 JDK 中,这取代了(但未删除)HttpURLConnection,旨在将可用的 HTTP API “打包”在一起,因为许多开发者已经转向外部库来满足他们的 HTTP 相关需求。

结果是,HTTP/2 和 WebSocket 兼容的 API 首先作为 Java 9 的孵化特性出现。JEP 321 将其移至 Java 11 的永久位置,在 java.net.http 下。新的 API 支持 HTTP/1.1 以及 HTTP/2,并且当被调用的服务器不支持 HTTP/2 时,可以回退到 HTTP/1.1。

与新 API 的交互从 HttpRequestHttpClient 类型开始。这些类型通过构建器实例化,在发出实际的 HTTP 调用之前设置配置,如下所示:

var client = HttpClient.newBuilder().build();           ❶

var uri = new URI("https://google.com");
var request = HttpRequest.newBuilder(uri).build();      ❷

var response = client.send(                             ❸
    request,
    HttpResponse.BodyHandlers.ofString(                 ❹
        Charset.defaultCharset()));

System.out.println(response.body());

❶ 构建一个我们可以用来发送请求的 HttpClient 实例

❷ 使用 HttpRequest 实例构建一个针对 Google 的特定请求

❸ 同步发送 HTTP 请求并保存其响应。此行会阻塞,直到整个请求完成。

❹ 发送方法需要一个处理程序来告诉它如何处理响应体。这里我们使用一个标准处理程序将体作为 String 返回。

这展示了 API 的同步使用。在构建我们的请求和客户端之后,我们使用 send 方法发出 HTTP 调用。我们不会在完整的 HTTP 调用完成之前收到 response 对象,这与 JDK 中较旧的 HTTP API 类似。

第一个参数是我们设置的请求,但第二个参数值得更仔细地观察。send 方法期望我们提供一个 HttpResponse.BodyHandler<T> 接口的实现,以告诉它如何处理响应。HttpResponse.BodyHandlers 提供了一些有用的基本处理程序,可以将响应作为字节数组、字符串或文件接收。但自定义此行为只需实现 BodyHandler 即可。所有这些管道都是基于 java.util.concurrent.Flow 发布者和订阅者机制,这是一种称为反应式流的编程形式。

HTTP/2 最显著的优点之一是其内置的多路复用功能。仅使用同步的 send 真实情况下并不能获得这些好处,因此 HttpClient 也支持 sendAsync 方法也就不足为奇了。sendAsync 返回一个包裹着 HttpResponseCompletableFuture,提供了一组丰富的功能,这些功能在其他平台的部分中可能很熟悉,如下所示:

var client = HttpClient.newBuilder().build();

var uri = new URI("https://google.com");
var request = HttpRequest.newBuilder(uri).build();           ❶

var handler = HttpResponse.BodyHandlers.ofString();
CompletableFuture.allOf(                                     ❷
    client.sendAsync(request, handler)                       ❸
        .thenAccept((resp) ->                                ❹
                      System.out.println(resp.body()),
    client.sendAsync(request, handler)                       ❺
        .thenAccept((resp) ->                                ❺
                      System.out.println(resp.body()),       ❺
    client.sendAsync(request, handler)                       ❺
        .thenAccept((resp) ->
                      System.out.println(resp.body())
).join();

❶ 如前所述创建客户端和请求

❷ 使用 CompletableFuture.allOf 等待所有请求完成

sendAsync 启动一个 HTTP 请求,但返回一个未来,并不阻塞。

❹ 当未来完成时,我们使用 thenAccept 接收响应。

❺ 我们可以使用相同的客户端同时发出多个请求。

我们再次设置请求和客户端,但随后异步地分别调用三次。CompletableFuture.allOf 结合这三个未来,因此我们可以通过单个 join 等待它们全部完成。

这只是触及了此 API 的两个主要入口点。它提供了大量的功能和定制,从配置超时和 TLS,到接收 HTTP/2 服务器推送的高级异步功能,如通过 HttpResponse.PushPromiseHandler

在未来的基础上,JDK 中的新 HTTP API 为 HTTP 空间中主导生态系统的庞大库提供了一个有吸引力的替代方案。设计时以现代异步编程为核心,java.net.http 使 Java 处于一个极佳的位置,无论网络未来如何发展。

1.5.4 单文件源代码程序(JEP 330)

Java 程序通常的执行方式是将源代码编译成类文件,然后启动一个充当执行容器的虚拟机进程来解释类的字节码。

这与 Python、Ruby 和 Perl 等语言非常不同,在这些语言中,程序的源代码是直接被解释的。Unix 环境有这些类型 脚本语言 的悠久历史,但 Java 传统上并不被认为属于这一类。

随着 JEP 330 的到来,Java 11 提供了一种执行程序的新方法。源代码可以在内存中编译,然后由解释器执行,而无需在磁盘上产生 .class 文件,如图 1.6 所示。

图片

图 1.6 单文件执行

这为用户提供了类似于 Python 和其他脚本语言的用户体验。

该特性有一些限制,包括以下内容:

  • 它仅限于存在于单个源文件中的代码。

  • 它不能在同一个运行中编译额外的源文件。

  • 它可以在源文件中包含任意数量的类。

  • 它必须将源文件中声明的第一个类作为入口点。

  • 它必须在入口点类中定义主方法。

该特性还使用 --source 标志来指示源代码兼容模式——本质上就是脚本的语言级别。

Java 文件命名约定必须遵循以执行,因此类名应与文件名匹配。然而,不应使用 .java 扩展名,因为这可能会使启动器产生混淆。

这些类型的 Java 脚本也可以包含一个 shebang 行,如下所示:

#!/usr/bin/java --source 11

public final class HTTP2Check {
    public static void main(String[] args) {
        if (args.length < 1) {
            usage();
        }
        // implementation of our HTTP callers...      ❶
    }
}

❶ HTTP2Check 的完整代码在项目资源中提供。

shebang 行提供了必要的参数,以便文件可以被标记为可执行,并直接调用,如下所示:

$ ./HTTP2Check https://www.google.com
https://www.google.com: HTTP_2

虽然这个特性并没有将脚本语言的全部体验带到 Java 中,但它可以是一种在 Unix 传统中编写简单、有用工具的有用方式,而无需引入另一种编程语言。

摘要

  • Java 语言和平台是 Java 生态系统中的两个独立(尽管关系紧密)的组件。平台支持许多语言,而不仅仅是 Java。

  • Java 8 之后,Java 平台采用了新的定时发布流程。新版本每六个月发布一次,每两到三年发布一个长期支持(LTS)版本。

  • 当前长期支持版本为 11 和 17,Java 8 目前仍然得到支持。

  • 由于其关注向后兼容性,对 Java 进行更改通常很困难。仅限于库或编译器的更改通常比需要更新虚拟机的更改要简单得多。

  • Java 11 引入了许多值得升级的功能:

    • 使用 var 关键字简化变量定义

    • 工厂方法以简化创建列表、映射和其他集合

    • 一个全新的 HttpClient 实现,支持完整的 HTTP/2

    • 可以直接运行而无需编译为类文件的单一文件程序

2 Java 模块

本章涵盖

  • Java 的平台模块

  • 访问控制语义的变化

  • 编写模块化应用程序

  • 多版本 JAR

如第一章所述,Java 的版本,直到包括 Java 9,都是根据以功能驱动的发布计划交付的,通常包含一个定义或与发布强相关的主要新功能。

对于 Java 9,这个特性是 Java 平台模块(也称为 JPMS、Jigsaw 或简称为“模块”)。这是对 Java 平台的重大增强和改变,它已经讨论了很多年——它最初是在 2009/2010 年作为 Java 7 的一部分可能发布的。

在本章中,我们将解释为什么需要模块,以及用于阐述模块概念的新语法以及如何在您的应用程序中使用它们。这将使您能够在构建时使用 JDK 和第三方模块,以及将应用程序或库作为模块打包。

注意模块代表了一种新的代码打包和部署方式,采用它们会使您的应用程序变得更好。然而,如果您只想开始使用现代 Java 功能(11 或 17),您无需立即采用模块,除非您想这么做。

模块的出现对应用程序的架构有深远的影响,并且模块对关注诸如进程占用、启动成本和预热时间等方面的现代项目有许多好处。模块还可以帮助解决可能困扰具有复杂依赖的 Java 应用程序的所谓 JAR 地狱问题。让我们来了解它们。

2.1 设置场景

模块是 Java 语言中的一个基本新概念(截至 Java 9)。它是一个应用程序部署和依赖的单位,对运行时有语义意义。这与 Java 中现有的概念不同,以下是一些原因:

  • JAR 文件对运行时来说是不可见的——它们基本上只是包含类文件的压缩目录。

  • 包实际上只是用于将类分组在一起以进行访问控制的命名空间。

  • 依赖仅在类级别定义。

  • 访问控制和反射以一种方式结合,产生了一个基本开放的系统,没有清晰的部署单元边界,并且执行力度最小。

另一方面

  • 定义模块之间的依赖信息,以便在编译或应用程序启动时检测到各种解析和链接问题

  • 提供适当的封装,因此内部包和类可以免受可能想要篡改它们的烦人用户的干扰

  • 是一个具有元数据的适当部署单元,这些元数据可以被现代 Java 运行时理解和消费,并在 Java 类型系统中表示(例如,通过反射)。

注意:在模块之前,在核心语言和运行时环境中,没有聚合的依赖元数据。相反,它仅在 Maven 等构建系统或 JVM 既不知道也不关心的第三方模块系统(如 OSGI 或 JBoss 模块)中定义。

Java 平台模块代表了在版本 8 存在的 Java 世界中一个缺失概念的实现。

注意:Java 模块通常打包为特殊的 JAR 文件,但它们并不局限于该格式(我们将在后面看到其他可能的格式)。

模块系统的目标是使部署单元(模块)尽可能相互独立。想法是模块能够分别加载和链接,尽管在实践中,实际应用程序可能最终依赖于提供相关功能(如安全)的一组模块。

2.1.1 项目 Jigsaw

在 OpenJDK 中交付模块功能的项目的名称是项目 Jigsaw。它旨在提供一个功能齐全的模块化解决方案,包括以下目标:

  • 模块化 JDK 平台源

  • 减少进程占用

  • 提高应用程序启动时间

  • 使模块对 JDK 和应用代码都可用

  • 首次在 Java 中实现真正的严格封装

  • 向 Java 语言添加新的、以前不可能的访问控制模式

这些目标反过来又是由以下其他目标驱动的,这些目标更专注于 JDK 和 Java 运行时:

  • 带来单一单体运行时 JAR(rt.jar)的终结

  • 正确封装和保护 JDK 内部组件

  • 允许进行重大内部更改(包括将破坏未经授权的非 JDK 使用的更改)

  • 将模块作为“超级包”引入

这些次要目标可能需要更多的解释,因为它们与平台内部和实现方面更为紧密相关。

模块化而非单体化的 Java 运行时

传统的 JAR 格式本质上只是一个包含类的 zip 文件。它可以追溯到平台最早的日子,并且根本不是为 Java 类和应用程序优化的。放弃平台类对 JAR 格式的使用可以在多个领域提供帮助——例如,实现更好的启动性能。

模块提供了两种新的格式——JMODJIMAGE,它们在程序生命周期的不同时间(编译/链接时间和运行时)使用。

JMOD 格式与现有的 JAR 格式有些相似,但它已被修改以允许将本地代码作为单个文件的一部分包含进来(而不是像 Java 8 那样必须单独发送一个共享对象文件)。对于大多数开发者的需求,包括将模块发布到 Maven,将您自己的模块打包为模块化 JAR 而不是 JMOD 会更好。

JIMAGE 格式用于表示 Java 运行时图像。在 Java 8 之前,只有两种可能的运行时图像存在(JDK 和 JRE),但这在很大程度上是历史的一个偶然。Oracle 在 Java 8 中引入了服务器 JRE(以及紧凑配置文件)作为迈向完全模块化的垫脚石。这些图像基本上删除了一些功能(例如,GUI 框架),以提供更小的足迹,专门针对服务器端应用程序的需求。

模块化应用程序具有足够的元数据,可以在程序启动前知道确切的依赖集。这导致了一种可能性,即只需要加载所需的内容,这要高效得多。甚至可以更进一步,定义一个自定义运行时图像,它可以与应用程序一起分发,并且不包含完整的通用 Java 安装,而只包含应用程序所需的内容。我们将在本章末尾遇到jlink工具时遇到这种最后一种可能性。

目前,让我们来认识一下jimage工具,它可以用来显示 Java 运行时图像的详细信息。例如,对于一个 Java 15 完整运行时(即过去包含在 JDK 中的内容),请看以下代码示例:

$ jimage info $JAVA_HOME/lib/modules
 Major Version:  1
 Minor Version:  0
 Flags:          0
 Resource Count: 32780
 Table Length:   32780
 Offsets Size:   131120
 Redirects Size: 131120
 Locations Size: 680101
 Strings Size:   746471
 Index Size:     1688840

或者

$ jimage list $JAVA_HOME/lib/modules
jimage: /Library/Java/JavaVirtualMachines/java15/Contents/Home/lib/modules

Module: java.base
    META-INF/services/java.nio.file.spi.FileSystemProvider
    apple/security/AppleProvider$1.class
    apple/security/AppleProvider$ProviderService.class
    apple/security/AppleProvider.class
    apple/security/KeychainStore$CertKeychainItemPair.class
    apple/security/KeychainStore$KeyEntry.class
    apple/security/KeychainStore$TrustedCertEntry.class
    apple/security/KeychainStore.class
    com/sun/crypto/provider/AESCipher$AES128_CBC_NoPadding.class
    ... many, many lines of output

离开rt.jar可以提升启动性能,并针对应用程序所需的内容进行优化。新格式旨在对开发者透明,并且依赖于实现。现在已不再可能简单地解压rt.jar并恢复 JDK 的类库。然而,这只是使平台内部对 Java 程序员更不透明的一步,这也是模块系统的一个目标。

封装内部结构

Java 平台与其用户之间的合同原本旨在是一个 API 合同——向后兼容性将在接口级别得到保持,而不是在实现的细节中。

然而,Java 开发者并没有履行他们的承诺,相反,随着时间的推移,他们倾向于使用平台实现中从未打算公开消费的部分。

这是有问题的,因为 OpenJDK 平台开发者希望有修改 JVM 和平台类实现的自由,以使其面向未来和现代化——提供新功能和更好的性能,而无需担心破坏用户应用程序。

对平台内部进行重大更改的主要障碍是 Java 8 中存在的访问控制方法。Java 只定义了publicprivateprotectedpackage-private作为访问控制级别,并且这些修饰符仅应用于类级别及其更细的级别。

我们可以通过多种方式(如反射或在相关包中创建额外的类)来绕过这些限制,而且没有万无一失(或专家级)的方法来完全保护内部结构。

使用替代方案访问内部的功能在历史上往往是出于正当理由。然而,随着平台的成熟,已经添加了一种官方方式来访问几乎所有期望的功能。因此,未受保护的内部功能代表了平台未来的一个风险——而没有相应的利益——模块化是解决这个遗留问题的方法之一。

总结来说,Project Jigsaw 是一种同时解决几个问题的方法——主要是减少运行时大小、提高启动时间和整理内部包之间的依赖关系。这些问题是难以逐步解决的(或不可能解决)。这类“非局部”改进的机会并不常见,尤其是在成熟的软件平台上,因此 Jigsaw 团队希望利用他们的情况。

JVM 现在是模块化的了

为了看到这一点,考虑下一个非常简单的程序:

public class StackTraceDemo {
    public static void main(String[] args) {
        var i = Integer.parseInt("Fail");
    }
}

编译和运行此代码会产生一个运行时异常,如下所示:

$ java StackTraceDemo
Exception in thread "main" java.lang.NumberFormatException:
  For input string: "Fail"
    at java.base/java.lang.NumberFormatException.forInputString(
    NumberFormatException.java:65)
    at java.base/java.lang.Integer.parseInt(Integer.java:652)
    at java.base/java.lang.Integer.parseInt(Integer.java:770)
    at StackTraceDemo.main(StackTraceDemo.java:3)

然而,我们可以清楚地看到,堆栈跟踪的格式与 Java 8 中使用的格式有所不同。特别是,现在堆栈帧由模块名称(java.base)、包名、类名和行号限定。这清楚地表明,平台的模块化性质是普遍存在的,甚至在最简单的程序中也存在。

2.1.2 模块图

所有模块化的关键在于模块图,它表示模块之间是如何相互依赖的。模块通过一些新的语法来明确它们的依赖关系,而这些依赖关系是编译器和运行时可以依赖的硬性保证。一个非常重要的概念是模块图必须是一个有向无环图(DAG),在数学术语中,不能有任何循环依赖。

注意:重要的是要认识到,在现代 Java 环境中,所有应用程序都是在模块化的 JRE 之上运行的;不存在“模块模式”和“遗留类路径模式”。

虽然不是每个开发者都需要成为模块系统的专家,但一个扎实的 Java 开发者对一个新的子系统有实际了解是有意义的,这个子系统改变了所有程序在 JVM 上执行的方式。让我们看看模块系统的第一个视图,如图 2.1 所示,这是大多数开发者遇到的情况。

图 2.1 JDK 系统模块(简化视图)

在图 2.1 中,我们可以看到 JDK 中一些主要模块的简化视图。请注意,模块java.base始终是每个模块的依赖项。在绘制模块图时,对java.base的隐式依赖通常被消除,以减少视觉上的杂乱。

我们可以在图 2.1 中看到的干净且相对简单的模块边界需要与 Java 8 中 JDK 的状态进行对比。不幸的是,在模块出现之前,Java 的最高级代码单元是包——Java 8 在标准运行时中就有近 1000 个包。这基本上是无法绘制的,图中的依赖关系会如此复杂,以至于人类无法理解。

将预模块化的 JDK 重塑成我们今天看到的良好定义的形式并不容易实现,交付 JDK 模块化的道路也很长。Java 9 于 2017 年 9 月发布,但该功能的发展始于几年前的 Java 8 发布列车。特别是,有几个子目标是模块交付的必要第一步,包括以下内容:

  • 模块化 JDK 中源代码布局(JEP 201)

  • 模块化运行时图像结构(JEP 220)

  • 解耦 JDK 包之间的复杂实现依赖

尽管模块功能直到 Java 9 才发布,但大部分清理工作是在 Java 8 期间进行的,甚至允许一个名为紧凑配置文件(我们将在本章末尾遇到)的功能作为该版本的一部分发布。

2.1.3 保护内部结构

模块需要解决的主要问题之一是用户 Java 框架与内部实现细节的过度耦合。例如,这段 Java 8 代码通过扩展一个内部类来获取访问低级URL 规范器的权限。

以下代码仅用于演示目的,以便我们可以有一个具体的例子来讨论模块和访问控制——你的代码永远不应该直接访问内部类:

import sun.net.URLCanonicalizer;

public class MyURLHandler extends URLCanonicalizer {

    public boolean isSimple(String url) {
        return isSimpleHostName(url);
    }
}

URL 规范器是一段代码,它将 URL 标准允许的多种形式之一转换为标准(规范)形式。目的是规范 URL 可以作为多个不同可能的 URL 访问的内容位置的单一来源。如果我们尝试使用 Java 8 编译它,javac会警告我们正在访问内部 API,如下所示:

$ javac MyURLHandler.java
MyURLHandler.java:1: warning: URLCanonicalizer is internal proprietary API
  and may be removed in a future release

import sun.net.URLCanonicalizer;
              ^
MyURLHandler.java:3: warning: URLCanonicalizer is internal proprietary API
  and may be removed in a future release

public class MyURLHandler extends URLCanonicalizer {
                                  ^
2 warnings

然而,默认情况下,编译器仍然允许访问,结果是用户类与 JDK 的内部实现紧密耦合。这种联系是脆弱的,如果被调用的代码移动或被替换,它就会断裂。

如果足够的开发者滥用这种开放性,那么这会导致一种难以或不可能对内部进行更改的情况,因为这样做会破坏已部署的库和应用。

注意:URLCanonicalizer类需要从多个不同的包中调用,而不仅仅是它自己的包,因此它必须是一个公开的类——不能是包私有——这意味着它对任何人都是可访问的。

解决这个非常普遍的问题的方法是对 Java 访问控制模型进行一次性的更改。这个更改既适用于调用 JDK 的用户代码,也适用于调用第三方库的应用程序。

2.1.4 新的访问控制语义

模块为 Java 的访问控制模型添加了一个新概念:导出 包的想法。在 Java 8 及更早版本中,任何包中的代码都可以调用任何包中任何公共类的公共方法。这有时被称为“猎枪隐私”,这是关于另一种编程语言的一个著名引言:

Perl 并没有对强制隐私的迷恋。它更希望你不进入它的客厅,因为你没有被邀请,而不是因为它有猎枪。

——拉里·沃德

然而,对于 Java 来说,猎枪隐私代表了一个重大问题。越来越多的库正在使用内部 API 来提供难以或无法以其他方式提供的功能,这可能会损害平台的长期健康。

截至 Java 8,无法在整个包中强制执行访问控制。这意味着 JDK 团队无法定义一个公共 API,并确信该 API 的客户端无法绕过它或直接链接到内部实现。

任何以 javajavax 开头的包中的内容都是公共 API,而其他所有内容仅限于内部,这种约定只是约定而已。没有虚拟机或类加载机制强制执行这一点,正如我们之前所看到的。

然而,随着模块的出现,这发生了变化。引入了 exports 关键字来指示哪些包被认为是模块的公共 API。在模块化 JDK 中,sun.net 包没有被导出,因此之前的 Java 8 URL 规范化代码将无法编译。以下是尝试使用 Java 11 时发生的情况:

$ javac src/ch02/MyURLHandler.java
src/ch02/MyURLHandler.java:3: error: package sun.net is not visible
import sun.net.URLCanonicalizer;
          ^
  (package sun.net is declared in module java.base, which does not export
     it to the unnamed module)
src/ch02/MyURLHandler.java:8: error: cannot find symbol
        return isSimpleHostName(url);
               ^
  symbol:   method isSimpleHostName(String)
  location: class MyURLHandler
2 errors

注意,错误信息的格式明确指出 sun.net 包现在不可见——编译器甚至看不到该符号。这是 Java 访问控制工作方式的一个根本性变化。只有导出包上的方法才是可访问的。公共类上的公共方法不再是自动对所有代码都可见的。

然而,这个变化可能对许多开发者来说并不明显。如果你是一个遵守规则的 Java 开发者,你永远不会直接调用内部包中的 API。然而,你可能会使用一个库或框架,它会这样做,因此了解实际上发生了什么并避免恐惧、不确定和怀疑是好的。

注意 正确的封装不是免费的,而且预模块化的 Java 实际上是一个非常开放的系统。面对模块提供的更结构化的系统,许多 Java 开发者可能会觉得一些额外的保护限制或令人沮丧。让我们来认识一下编码 Java 模块新语义的语法。

2.2 基本模块语法

Java 平台模块被定义为概念单元,它是一组声明和加载为单一实体的包和类。每个模块必须声明一个新的文件,称为模块描述符,表示为 module-info.java 文件,其中包含以下内容:

  • 模块名称

  • 模块依赖

  • 公共 API(导出的包)

  • 反射访问权限

  • 提供的服务

  • 消耗的服务

此文件必须放置在源层次结构中的合适位置。例如,在 Maven 风格的布局中,完整的模块名称wgjd.discovery直接位于 src/main/java 之后,包含 module-info.java 和包根,如下所示:

src
  └── main
       └── java
            └── wgjd.discovery
                 ├── wgjd
                 │      └── discovery
                 │              ├── internal
                 │              │      ├── AttachOutput.java
                 │              │      └── PlainAttachOutput.java
                 │              ├── VMIntrospector.java
                 │              └── Discovery.java
                 └── module-info.java

这当然与非线性模块的 Java 项目略有不同,后者通常将 src/main/java 指定为包目录的根。然而,在模块根下的包的熟悉层次结构仍然可见。

注意:当模块化项目构建时,模块描述符将被编译成一个类文件,module-info.class,但这个文件(尽管它的名字如此)实际上与我们在 Java 平台中看到的普通类文件有很大不同。

在本章中,我们将讨论描述符的基本指令,但不会深入探讨模块提供的所有功能。特别是,我们不会讨论模块的服务方面。

一个模块描述符的简单例子如下所示:

module wgjd.discovery {
  exports wgjd.discovery;

  requires java.instrument;
  requires jdk.attach;
  requires jdk.internal.jvmstat;
}

这包含三个新关键字——moduleexportsrequires——其语法对大多数 Java 程序员来说应该是启发性的。关键字module简单地声明声明的开始范围。

注意:模块-info.java 的名称让人联想到 package-info.java,并且它们有些相关。因为包对运行时来说实际上并不可见,所以需要一个解决方案(黑客技巧?)来提供应用于整个包的注解元数据的钩子。这个解决方案是package-info.java。在模块化的世界中,可以与模块关联更多的元数据,因此选择了类似的名字。新的语法实际上由限制性关键字组成,这在 Java 语言规范中是这样描述的:

另有十个字符序列被限制为关键字:openmodulerequirestransitiveexportsopenstousesprovideswith。这些字符序列仅在它们出现在ModuleDeclarationModuleDirective产生式中的终端位置时被标记为关键字。

用更简单的语言来说,这意味着这些新关键字只会出现在模块元数据的描述符中,而不是在一般的 Java 源代码中被视为关键字。然而,避免将这些词用作 Java 标识符是一个好的实践,尽管在技术上使用它们是合法的。这与我们在第一章中看到的var的情况相同,并且我们将在本书的其余部分使用更宽松的语言,并将它们称为“关键字”。

2.2.1 导出和需求

exports 关键字期望一个参数,即包名称。在我们的例子中

exports wgjd.discovery;

意味着我们的示例发现模块导出了 wgjd.discovery 包,但由于描述符没有提及任何其他包,wgjd.discovery.internal 没有被导出,并且通常不可用于发现模块外的代码。

模块描述符中可以有多个 exports 行,实际上这是相当常见的。使用 exports ... to ... 语法也可以实现细粒度的控制,该语法仅指示某些外部模块可以访问此模块中指定的包。

注意一个模块 exports 一个或多个构成模块公共 API 的包,并且是其他模块中的代码可以访问的唯一包,除非使用覆盖(例如,命令行开关)。

requires 关键字声明了当前模块的依赖关系,并且总是需要一个参数,即一个 模块 名称,而不是包名称。java.base 模块包含了 Java 运行时最基础的包和类。我们可以使用 jmod 命令来查看,如下所示:

$ jmod describe $JAVA_HOME/jmods/java.base.jmod
java.base@11.0.3
exports java.io
exports java.lang
exports java.lang.annotation
exports java.lang.invoke
exports java.lang.module
exports java.lang.ref
exports java.lang.reflect
exports java.math
exports java.net
exports java.net.spi
exports java.nio
// ... many, many more lines of output

这些包被每个 Java 程序使用,因此 java.base 总是每个模块的隐式依赖,所以不需要在 module-info.java 中显式声明。这与 java.lang 是每个 Java 类的隐式 import 的方式非常相似。

模块名称的一些基本规则和约定如下:

  • 模块存在于一个全局命名空间中。

  • 模块名称必须是唯一的。

  • 如果适用,请使用标准的 com.company.project 约定。

一个重要的基本模块概念是 传递性。让我们更仔细地看看这个概念,因为它不仅出现在模块的上下文中,也出现在 Java 更熟悉的库(即 JAR 文件)依赖中(我们将在第十一章中遇到)。

2.2.2 传递性

传递性 是一个非常通用的计算机术语,并不特指 Java,它描述了当代码单元需要其他单元以正确运行时的情况,而这些单元本身也可以需要其他单元。我们的原始代码可能甚至从未提及这些“一步之遥”的代码单元,但它们仍然需要存在,否则我们的应用程序将无法工作。

要理解为什么是这样——以及为什么这很重要——考虑两个模块 AB,其中 A 依赖于 B。有两种不同的情况可能发生:

  • A 不导出任何直接提及 B 中类型的方法。

  • AB 中的类型作为其 API 的一部分包含。

A 导出返回类型定义在 B 中的方法的情况下,这将导致 A 无法使用,除非 A 的客户端(那些需要 A 的模块)也要求 B。这对 A 的客户端来说是一个相当不必要的开销。

模块系统提供了一些简单的语法来解决这个问题:requires transitive。如果一个模块A需要另一个模块的传递性,那么任何依赖于A的代码也将隐式地获取传递性依赖。

尽管在某些用例中不可避免地要使用requires transitive,但在一般情况下,编写模块时,最小化使用传递性被认为是一种最佳实践。当我们讨论第十一章中的构建工具时,我们将有更多关于传递性依赖的内容要讲。

2.3 加载模块

如果你第一次遇到 Java 类加载是在第一章中我们简要提到它,并且你没有其他相关经验,不要担心。现在最重要的知道的事情是以下四种类型的模块存在,其中一些在加载时具有略微不同的行为:

  • 平台模块

  • 应用模块

  • 自动模块

  • 未命名的模块

另一方面,如果你已经熟悉类加载,你应该知道模块的出现已经改变了类加载操作的一些细节。

现代 JVM 具有模块感知的类加载器,JRE 类加载的方式与 Java 8 相比有很大不同。一个关键概念是模块路径,它是一系列指向模块(或包含模块的目录)的路径。这与传统的 Java 类路径类似,但却是分开的。

注意:我们将在第四章中详细介绍类加载,并向新读者和有经验的读者介绍现代的做法。

类加载模块化方法的根本原则如下:

  • 模块是从模块路径解析的,而不是从旧的类路径解析的。

  • 在启动时,JVM 解析一个模块图,该图必须是无环的。

  • 一个模块是图的根,也是执行开始的地方。它包含具有主方法的类,该主方法将是入口点。

已经模块化的依赖项被称为应用模块,并放置在模块路径上。未模块化的依赖项放置在熟悉的类路径上,并通过迁移机制被纳入模块系统。

模块解析使用深度优先遍历,由于图必须是无环的,解析算法将终止(并且在线性时间内)。让我们更深入地探讨四种模块类型中的每一个。

2.3.1 平台模块

这些是来自模块化 JDK 本身的模块。在 Java 8 中,它们将是单体运行时(rt.jar)的一部分(或者可能是辅助 JAR 文件,如tools.jar)。我们可以通过--list-modules标志获取可用的平台模块列表,如下所示:

$ java --list-modules
java.base@11.0.6
java.compiler@11.0.6
...
java.xml@11.0.6
java.xml.crypto@11.0.6
jdk.accessibility@11.0.6
...
jdk.unsupported@11.0.6
...

此代码将提供一个未经删减的列表,而不是我们在图 2.1 中看到的部分集合。

注意:模块及其名称的确切列表将取决于所使用的 Java 版本。例如,在 Oracle 的 GraalVM 实现中,可能存在一些额外的模块,如com.oracle.graal.graal_enterpriseorg.graalvm.js.scriptengineorg.graalvm.sdk

平台模块大量使用了合格导出机制,其中一些包仅导出到指定的模块列表中,并不对一般用户公开。

在分发中最重要的模块是java.base,它是每个其他模块的隐式依赖。它包含java.langjava.utiljava.io以及各种其他基本包。该模块基本上对应于应用程序可能需要的最小 Java 运行时,同时仍然可以运行。

在另一端的是聚合模块,它们不包含任何代码,但作为允许应用程序通过传递方式引入非常广泛的依赖项的快捷机制。例如,java.se模块引入了整个 Java SE 平台。

2.3.2 应用程序模块

这类模块是应用程序的模块化依赖,或者是应用程序本身。这种模块有时也被称为库模块

注意:平台模块和应用程序模块之间没有技术上的区别——区别纯粹是哲学上的——以及用于加载它们的类加载器,正如我们将在第四章中讨论的那样。

应用程序所依赖的第三方库将是应用程序模块。例如,用于操作 JSON 的 Jackson 库自 2.10 版本以来已经模块化,并被视为应用程序(也称为库)模块。

应用程序模块通常依赖于平台模块和其他应用程序模块。尽可能约束这些模块的依赖性是一个好主意,并尽量避免将其作为依赖项,例如java.se

2.3.3 自动模块

模块系统的第三个设计特性是您不能从模块中引用类路径。这种限制似乎可能存在潜在问题——如果模块需要依赖于尚未模块化的某些代码,会发生什么?

解决方案是将非模块化的 JAR 文件移动到模块路径上(并从类路径中移除)。完成此操作后,JAR 成为了一个自动模块。模块系统将自动为您的模块生成一个名称,该名称是从 JAR 的名称派生出来的。

自动模块导出它包含的每个包,并自动依赖于模块路径中的所有其他模块。自动模块没有适当的模块依赖信息,因为它们既没有明确声明它们的依赖关系,也没有宣传它们的 API。这意味着它们不是模块系统中的第一类公民,并且不提供与真正的 Java 模块相同的保证。

可以显式声明一个名称,通过在 JAR 文件中的 MANIFEST.MF 文件中添加Automatic-Module-Name条目来实现。这通常是在迁移到 Java 模块时作为一个中间步骤完成的,因为它允许开发者为模块名称保留空间,并开始获得与模块化代码交互的一些好处。

例如,Apache Commons Lang 库尚未完全模块化,但它提供了org.apache.commons.lang3作为自动模块名称。其他模块可以声明它们依赖于这个自动模块,即使维护者还没有完成将其过渡到完全模块化的工作。

2.3.4 未命名的模块

类路径上的所有类和 JAR 文件都被添加到单个模块中,即未命名的模块或UNNAMED。这是为了向后兼容,但代价是模块系统可能不如它可能的那样有效,因为在未命名的模块中仍然有一些代码时。

对于完全非模块化应用程序的情况(例如,运行在 Java 11 运行时之上的 Java 8 应用程序),类路径的内容将被倒入未命名的模块中,而根模块被认为是java.se

模块化代码不能依赖于未命名的模块,因此实际上,模块不能依赖于类路径中的任何内容。自动模块通常用于帮助解决这种情况。形式上,未命名的模块依赖于 JDK 中的所有模块和模块路径,因为它正在复制预模块化行为。

2.4 构建第一个模块化应用程序

让我们构建一个模块化应用程序的第一个示例。为此,我们需要构建一个模块图(这当然是一个有向无环图 DAG)。图中必须有一个根模块,在我们的例子中,是包含应用程序入口点类的模块。应用程序的模块图是根模块所有模块依赖的传递闭包。

对于我们的示例,我们将把我们在第一章末尾创建的 HTTP 站点检查工具改编成一个模块化应用程序。文件将按照以下方式布局:

.
└── wgjd.sitecheck
     ├── wgjd
     │      └── sitecheck
     │              ├── concurrent
     │              │      └── ParallelHTTPChecker.java
     │              ├── internal
     │              │      └── TrustEveryone.java
     │              ├── HTTPChecker.java
     │              └── SiteCheck.java
     └── module-info.java

我们将某些关注点(例如,TrustEveryone提供者)拆分到它们自己的类中,而不是像所有代码都需要生活在单个文件中时那样,将它们表示为静态内部类。我们还设置了单独的包,并且不会导出所有这些包。模块文件与我们在前面遇到的非常相似,如下所示:

module wgjd.sitecheck {
  requires java.net.http;
  exports wgjd.sitecheck;
  exports wgjd.sitecheck.concurrent;
}

注意对模块java.net.http的依赖。为了调查当错过依赖项时会发生什么,让我们取消注释对 HTTP 模块的依赖,并尝试使用以下方式使用javac编译项目:

$ javac -d out wgjd.sitecheck/module-info.java \
    wgjd.sitecheck/wgjd/sitecheck/*.java \
    wgjd.sitecheck/wgjd/sitecheck/*/*.java
wgjd.sitecheck/wgjd/sitecheck/SiteCheck.java:8: error:
  package java.net.http is not visible
import java.net.http.*;
               ^
  (package java.net.http is declared in module java.net.http, but
      module wgjd.sitecheck does not read it)
wgjd.sitecheck/wgjd/sitecheck/concurrent/ParallelHTTPChecker.java:4:
  error: package java.net.http is not visible
import java.net.http.*;
               ^

// Several similar errors

这个失败表明,模块的简单问题可能非常容易解决。模块系统已检测到缺失的模块,并试图通过建议解决方案来帮助:将缺失的模块作为依赖项添加。如果我们做出这个改变,那么,正如预期的那样,模块构建时不会有任何抱怨。然而,更复杂的问题可能需要更改编译步骤或通过开关手动干预来控制模块系统。

2.4.1 模块的命令行开关

在编译模块时,可以使用一些命令行开关来控制编译(以及稍后执行)的模块化方面。这些开关中最常见的是:

  • list-modules——打印所有模块的列表

  • module-path——指定包含您的模块的一个或多个目录

  • add-reads——向解析添加额外的requires

  • add-exports——向编译添加额外的exports

  • add-opens——启用运行时对所有类型的反射访问

  • add-modules——将模块列表添加到默认集合中

  • illegal-access=permit|warn|deny——更改反射访问规则

我们已经遇到了这些概念中的大多数——除了与反射相关的限定符,我们将在第 2.4.3 节中详细讨论。

让我们看看这些开关的实际应用。这将演示模块打包中常见的一个问题,并作为一个真实世界问题的例子,许多开发者在使用模块和自己的代码时可能会遇到。

当开始使用模块时,我们有时会发现我们需要打破封装。例如,一个从 Java 8 迁移的应用程序可能期望访问一个不再导出的内部包。

例如,让我们考虑一个使用Attach API动态连接到主机上运行的其他 JVM 并报告一些基本信息的简单结构的项目。它在磁盘上的布局如下,就像我们在前面的例子中看到的那样:

.
└── wgjd.discovery
     ├── wgjd
     │      └── discovery
     │              ├── internal
     │              │      └── AttachOutput.java
     │              ├── Discovery.java
     │              └── VMIntrospector.java
     └── module-info.java

编译项目时出现以下一系列错误:

$ javac -d out/wgjd.discovery wgjd.discovery/module-info.java \
  wgjd.discovery/wgjd/discovery/*.java \
  wgjd.discovery/wgjd/discovery/internal/*

wgjd.discovery/wgjd/discovery/VMIntrospector.java:4: error: package
  sun.jvmstat.monitor is not visible
import sun.jvmstat.monitor.MonitorException;
                  ^
  (package sun.jvmstat.monitor is declared in module jdk.internal.jvmstat,
    which does not export it to module wgjd.discovery)
wgjd.discovery/wgjd/discovery/VMIntrospector.java:5: error: package
  sun.jvmstat.monitor is not visible
import sun.jvmstat.monitor.MonitoredHost;
                  ^
  (package sun.jvmstat.monitor is declared in module jdk.internal.jvmstat,
    which does not export it to module wgjd.discovery)

这些问题是由项目中某些使用内部 API 的代码引起的,如下所示:

public class VMIntrospector implements Consumer<VirtualMachineDescriptor> {

    @Override
    public void accept(VirtualMachineDescriptor vmd) {
        var isAttachable = false;
        var vmVersion = "";
        try {
            var vmId = new VmIdentifier(vmd.id());
            var monitoredHost = MonitoredHost.getMonitoredHost(vmId);
            var monitoredVm = monitoredHost.getMonitoredVm(vmId, -1);
            try {
                isAttachable = MonitoredVmUtil.isAttachable(monitoredVm);
                vmVersion = MonitoredVmUtil.vmVersion(monitoredVm);
            } finally {
                monitoredHost.detach(monitoredVm);
            }
        } catch (URISyntaxException | MonitorException e) {
            e.printStackTrace();
        }

        System.out.println(
                vmd.id() + '\t' + vmd.displayName() + '\t' + vmVersion +
                  '\t' + isAttachable);
    }
}

尽管像VirtualMachineDescriptor这样的类是jdk.attach模块的导出接口的一部分(因为该类位于导出包com.sun.tools.attach中),但我们所依赖的其他类(如sun.jvmstat.monitor中的MonitoredVmUtil)则不可访问。幸运的是,工具提供了一种方法来软化模块边界,并提供对非导出包的访问。

为了实现这一点,我们需要添加一个开关——--add-exports——来强制访问jdk.internal.jvmstat模块的内部,这意味着我们通过这样做肯定是在打破封装。生成的编译命令行看起来像这样:

$ javac -d out/wgjd.discovery \
  --add-exports=jdk.internal.jvmstat/sun.jvmstat.monitor=wgjd.discovery \
  wgjd.discovery/module-info.java \
  wgjd.discovery/wgjd/discovery/*.java \
  wgjd.discovery/wgjd/discovery/internal/*

--add-exports的语法是我们必须提供我们需要的模块和包名,以及哪个模块正在被授予访问权限。

2.4.2 执行模块化应用程序

在模块出现之前,只有以下两种方法可以启动 Java 应用程序:

java -cp classes wgjd.Hello
java -jar my-app.jar

这些对于 Java 程序员来说应该是熟悉的,因为它们涉及到从 JAR 文件中启动类和主类。在现代 Java 中,又增加了两种启动程序的方法。我们在 1.5.4 节中遇到了启动单源文件程序的新方法,现在我们将遇到第四种模式:启动模块的主类。语法如下:

java --module-path mods -m my.module/my.module.Main

然而,就像编译一样,我们可能需要额外的命令行开关。例如,从我们之前的内省示例:

$ java --module-path out -m wgjd.discovery/wgjd.discovery.Discovery
Exception in thread "main" java.lang.IllegalAccessError:
  class wgjd.discovery.VMIntrospector (in module wgjd.discovery) cannot
    access class sun.jvmstat.monitor.MonitorException (in module
      jdk.internal.jvmstat) because module jdk.internal.jvmstat does not
        export sun.jvmstat.monitor to module wgjd.discovery
    at wgjd.discovery/wgjd.discovery.VMIntrospector.accept(
    VMIntrospector.java:19)
    at wgjd.discovery/wgjd.discovery.Discovery.main(Discovery.java:26)

为了防止这种错误,我们还必须向实际程序执行提供封装破坏开关,如下所示:

$ java --module-path out \
  --add-exports=jdk.internal.jvmstat/sun.jvmstat.monitor=wgjd.discovery \
  -m wgjd.discovery/wgjd.discovery.Discovery

Java processes:
PID    Display Name    VM Version    Attachable
53407    wgjd.discovery/wgjd.discovery.Discovery    15-ea+24-1168    true

如果运行时系统找不到我们请求的根模块,那么我们预计会看到如下异常:

$ java --module-path mods -m wgjd.hello/wgjd.hello.HelloWorld
Error occurred during initialization of boot layer
java.lang.module.FindException: Module wgjd.hello not found

即使这个简单的错误信息也显示我们对于 JDK 有新的认识,包括

  • 包,包括 java.lang.module

  • 异常,包括 FindException

这再次表明,模块系统确实已经成为每个 Java 程序执行的一个基本组成部分,即使它并不总是立即明显。

在下一节中,我们将简要介绍模块与反射的交互。我们假设你已经熟悉反射,但如果你不熟悉,现在可以自由跳过这一节,在阅读完包含类加载和反射介绍的第四章后回来。

2.4.3 模块和反射

在 Java 8 中,开发者可以使用反射访问运行时几乎任何内容。甚至有一种方法可以绕过 Java 的访问控制检查,例如,通过所谓的 setAccessible() 欺骗调用其他类的私有方法。

正如我们已经看到的,模块改变了访问控制的规则。这也适用于反射——默认情况下,只有导出的包应该被反射访问。

然而,模块系统的创建者意识到,有时开发者希望给予某些包的反射访问权限(但不是直接访问)。这需要显式的权限,并且可以通过使用 opens 关键字来提供对其他内部包的仅反射访问。开发者还可以通过使用 opens ... to ... 语法来指定细粒度访问,允许一组命名的包可以反射地打开到特定的模块,但不能更广泛地打开。

之前的讨论似乎暗示这些类型的反射技巧现在已被禁止。事实要复杂一些,最好通过讨论命令行开关 --illegal-access 来解释。此开关有三个设置——permit|warn|deny——用于控制对反射的检查的严格性。

模块系统的意图始终是随着时间的推移,整个 Java 生态系统应该朝着适当的封装方向发展,包括反射,并且在某一点上,默认的切换将变为deny(并且最终将被移除)。这种变化显然不可能一夜之间发生——如果反射切换突然设置为deny,Java 生态系统的大部分内容都会崩溃,并且没有人会升级

然而,随着 Java 17 的发布,Java 9 已经发布了四年,这个警告第一次开始出现。这无疑已经足够多了,并且是一个合理的警告。因此,Java 16 中做出了决定,将--illegal-access的默认选项更改为deny,并在 Java 17 中完全移除该选项的效果。

注意:这种对反射封装语义的改变是为什么直接从 8 版本迁移到 17 版本的应用程序可能会遇到比执行两次升级跳转(8 到 11,然后 11 到 17)更多的麻烦。

仍然可以使用--add-opens命令行选项或Add-Opens JAR 清单属性来打开特定的包。这种用法可能对于一些始终使用反射且尚未完全模块化的特定库或框架是必需的。然而,在 Java 17 中已经移除了全局重新启用访问的暴力选项。

一个额外的有用概念可以帮助这个过渡,那就是开放模块。这个简单的声明用于允许完全开放的反射访问——它为反射打开了所有模块的包,但不允许编译时访问。这为现有的代码和框架提供了简单的兼容性,但是一种更宽松的封装形式。因此,最好避免使用开放模块,或者仅在迁移到模块化构建时作为过渡形式使用。在第十七章中,我们将讨论Unsafe的具体案例,这是一个很好的例子,可以表明模块化世界中反射的一些问题。

2.5 为模块构建架构

模块代表了打包和部署代码的一种全新的方式。团队确实需要采用一些新的实践来充分利用新的功能和架构优势。然而,好消息是,你不需要立即开始这样做,只是为了开始使用现代 Java。使用类路径和 JAR 文件的传统、老式方法将继续工作,直到团队准备好全心全意地采用模块。

事实上,Oracle 的 Java 首席架构师马克·雷诺尔德(Mark Reinhold)关于应用程序采用模块化的“必要性”是这样说的。

没有必要切换到模块。

从来没有必要切换到模块。

Java 9 及以后的版本支持在传统类路径上使用传统的 JAR 文件,通过未命名模块的概念,并且可能会这样做直到宇宙的热寂。

是否开始使用模块完全取决于你。

如果您维护一个大型遗留项目,而且这个项目变化不大,那么可能不值得付出努力。

—马克·雷诺尔德,stackoverflow.com/a/62959016

在一个理想的世界里,模块应该是所有绿色字段应用程序的默认选项,但在实践中这正在证明是复杂的,因此作为一个替代方案,在迁移时遵循以下过程:

  1. 升级到 Java 11(仅 classpath)。

  2. 设置一个自动模块名称。

  3. 引入一个包含所有代码的单体模块

  4. 根据需要将其拆分为单独的模块。

通常,在步骤 3 中,会暴露出过多的实现代码。这意味着,在步骤 4 的工作中,通常需要创建额外的包来存放内部和实现代码,并将代码重构到这些包中。

如果您仍在使用 Java 8,并且尚未准备好迁移到模块化构建,您仍然可以执行以下操作来为迁移准备您的代码:

  • 在 MANIFEST.MF 中引入自动模块名称。

  • 从您的部署工件中移除分割包。

  • 使用jdeps和 Compact Profiles 来减少您不必要的依赖项的足迹。

首先考虑这些中的一个,使用显式的自动模块名称(如我们在本章前面讨论的)将简化过渡。自动模块名称将不被不支持模块的所有 Java 版本忽略,但仍然允许您为您的库保留一个稳定的名称,并将一些代码移出未命名的模块。它还有这样的优势,即您的库的消费者已经为模块的过渡做好了准备,因为您已经宣传了模块将使用的名称。让我们更详细地看看其他两个具体建议。

2.5.1 分割包

开发者在开始使用模块时遇到的一个常见问题是分割包——当两个或更多单独的 JAR 文件包含属于同一包的类。在非模块应用程序中,分割包没有问题,因为 JAR 文件和包对运行时没有任何特定的意义。然而,在模块世界中,一个包必须只属于一个模块,不能分割。

如果一个现有应用程序升级为使用模块并且有包含分割包的依赖项,这将必须得到修复——别无他法。对于团队控制的代码,这将是额外的工作,但并不太难。一种技术是有一个特定的工件(通常使用-all后缀),它由构建系统与非模块版本一起生成,包含分割包的所有部分。

对于外部依赖项,修复可能更复杂。可能需要将第三方开源代码重新打包成一个可以消费为自动模块的 JAR 文件。

2.5.2 Java 8 Compact Profiles

Compact 配置文件是 Java 8 的一个特性。它们是经过缩减的运行时环境,必须实现 JVM 和 Java 语言规范。它们是在 Java 8 中引入的,作为通向 Java 9 中即将到来的模块化故事的有用垫脚石。

一个 Compact 配置文件必须包含 Java 语言规范中明确提到的所有类和包。配置文件是包的列表,它们通常与完整 Java SE 平台中同名的包相同。存在非常少的例外,但它们被明确指出。

配置文件的主要用例之一是作为服务器应用程序或其他环境的基础,在这些环境中,部署不必要的功能是不受欢迎的。例如,历史上,大量安全漏洞与 Java 的 GUI 功能有关,尤其是在 Swing 和 AWT 中。通过选择不在不需要这些功能的程序中部署实现这些功能的包,我们可以获得一定程度的额外安全性,尤其是在服务器应用程序中。

注意:Oracle 曾经发布了一个缩减版的 JRE(“服务器 JRE”),它在某些方面与 Compact 1 非常相似。

Compact 1 是可以部署应用程序的最小包集。它包含 50 个包,从非常熟悉的

  • java.io

  • java.lang

  • java.math

  • java.net

  • java.text

  • java.util

到一些可能不那么意外的包,尽管如此,它们还是为现代应用程序提供了必要的类:

  • java.util.concurrent.atomic

  • java.util.function

  • javax.crypto.interfaces

  • javax.net.ssl

  • javax.security.auth.x500

Compact 2 的体积显著更大,包含 XML、SQL、RMI 和安全所需的包。Compact 3 更大,基本上是整个 JRE,除了窗口和 GUI 组件——类似于java.se模块。

注意:所有配置文件都包含由Object引用的所有类型以及语言规范中提到的所有类型的传递闭包。

Compact 1 配置文件与最小运行时最为接近,因此在某些方面它类似于java.base模块的原型形式。理想情况下,如果你的应用程序或库可以仅通过 Compact 1 作为依赖项运行,那么应该这样做。

为了帮助确定您的应用程序是否可以与 Compact 1 或其他配置文件一起运行,JDK 提供了 jdeps。这是一个静态分析工具,随 Java 8 和 11 一起提供,用于检查包或类的依赖关系。该工具可以以多种方式使用,从确定应用程序需要在哪个配置文件下运行,到识别调用未记录的、内部的 JDK API(例如 sun.misc 类)的开发者代码,再到帮助跟踪传递依赖。对于从 Java 8 迁移到 11 非常有帮助,并且与 JAR 和模块都兼容。在其最简单的形式中,jdeps 接收一个类或包,并提供一个简短的依赖包列表。例如,对于发现示例:

$ jdeps Discovery.class
Discovery.class -> java.base
Discovery.class -> jdk.attach
Discovery.class -> not found
   wgjd.discovery              -> com.sun.tools.attach      jdk.attach
   wgjd.discovery              -> java.io                   java.base
   wgjd.discovery              -> java.lang                 java.base
   wgjd.discovery              -> java.util                 java.base
   wgjd.discovery              -> wgjd.discovery.internal   not found

-P 开关显示一个类(或包)运行所需的配置文件,尽管当然,这仅适用于 Java 8 运行时。

让我们快速看一下另一个迁移技术,一个经验丰富的 Java 开发者应该了解的——使用 多版本 JAR

2.5.3 多版本 JAR

这种新功能允许构建一个可以容纳在 Java 8 和现代、模块化 JVM 上运行的库和组件的 JAR 文件。例如,您可以使用仅在后续版本中可用的库类,但通过回退和存根方法仍然可以在早期版本上运行。

要创建多版本 JAR,必须在 JAR 的清单文件中包含以下条目:

Multi-Release: true

此条目仅对从版本 9 开始的 JVM 有意义,因此如果 JAR 在 Java 8(或更早)VM 上使用,则多版本特性将被忽略。

针对 Java 8 之后版本的类被称为 变体代码,并存储在 JAR 内部 META-INF 的一个特殊目录中,如下所示:

META-INF/versions/<version number>

该机制通过每个类的基础上进行覆盖来实现。Java 9 及以后的版本将寻找在 versions 目录中与主内容根目录中完全相同的类名。如果找到,则使用覆盖版本代替内容根目录中的类。

注意 Java 类文件带有创建它们的 Java 编译器的版本号——即 类文件版本号——而后来 Java 版本创建的代码在较旧的 JVM 上无法运行。

META-INF/versions 位置被 Java 8 及更早版本忽略,因此这提供了一个巧妙的技巧来规避多版本 JAR 中包含的一些代码的类文件版本过高,无法在 Java 8 上运行的事实。

然而,这也意味着内容根目录中的类及其覆盖变体的 API 必须相同,因为它们将以完全相同的方式在两种情况下链接。

示例:构建多版本 JAR

让我们看看提供一个示例功能:获取正在运行的 JVM 的进程 ID。不幸的是,在 Java 9 之前的版本中,这有些繁琐,并且需要在 java.lang.management 包中进行一些低级黑客操作。

Java 11 为 Process API 提供了一个获取 PID 的 API,因此我们想要设置一个简单的多版本 JAR,当可用时使用更简单的 API,并在必要时回退到基于 JMX 的方法。

主类看起来像这样:

public class Main {
    public static void main(String[] args) {
        System.out.println(GetPID.getPid());
    }
}

注意,具有版本相关实现的特性已被隔离到单独的类 GetPID 中。Java 8 版本的代码有些冗长,如下所示:

public class GetPID {
  public static long getPid() {
    System.out.println("Java 8 version...");                               ❶
    // ManagementFactory.getRuntimeMXBean().getName() returns the name that
    // represents the currently running JVM. On Sun and Oracle JVMs, this
    // name is in the format <pid>@<hostname>.

    final var jvmName = ManagementFactory.getRuntimeMXBean().getName();
    final var index = jvmName.indexOf('@');
    if (index < 1) {
        return 0;
    }

    try {
        return Long.parseLong(jvmName.substring(0, index));
    } catch (NumberFormatException e) {
        return 0;
    }
  }
}

❶ 我们包含这一行是为了可以看到这是 Java 8 版本。

这需要我们从 JMX 方法中解析字符串——即使如此,我们的解决方案也不能保证在 JVM 实现之间具有可移植性。相比之下,Java 9 及以后的版本在 API 中提供了一个更简单的标准方法,如下面的代码片段所示:

public class GetPID {
    public static long getPid() {
        // Use the ProcessHandle API, new in Java 9 ...
        var ph = ProcessHandle.current();
        return ph.pid();
    }
}

由于 ProcessHandle 类位于 java.lang 包中,我们甚至不需要 import 语句。

现在,我们需要安排多版本 JAR,以便在 JVM 版本足够高时,Java 11 代码包含在 JAR 中,并优先使用回退版本。一个合适的代码布局如下所示:

.
└── src
     ├── main
     │      └── java
     │              └── wgjd2ed
     │                      ├── Main.java
     │                      └── GetPID.java
     └── versions
          └── 11
               └── java
                    └── wgjd2ed
                         └── GetPID.java

代码库的主要部分需要使用 Java 8 编译,然后使用不同的 Java 版本编译 Java 8 之后的代码,最后将其手动打包到多版本 JAR 中(即,直接使用命令行 jar 工具)。

注意:此代码布局遵循 Maven 和 Gradle 构建工具的约定,我们将在第十一章中详细介绍。

让我们使用 JDK 版本的 javac 命令行编译代码,但通过 --release 标志将输出目标设置为 Java 8:

$ javac --release 8 -d out src/main/java/wgjd2ed/*.java

接下来,我们使用单独的输出目录 out-11 为针对 Java 11 的代码构建:

$ javac --release 11 -d out-11 versions/11/java/wgjd2ed/GetPID.java

我们还需要一个 MANIFEST.MF 文件,但我们可以使用(Java 11)jar 工具自动构建我们需要的文件,如下所示:

$ jar --create --release 11 \
      --file pid.jar --main-class=wgjd2ed.Main
      -C out/ . \
      -C out-11/ .

这将创建一个多版本 JAR,它也是可运行的(其中 Main 是入口点类)。在 Java 11 上运行 JAR 会得到以下输出:

$ java -version
openjdk version "11.0.3" 2019-04-16
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.3+7)
OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.3+7, mixed mode)

$ java -jar pid.jar
13855

在 Java 8 上:

$ java -version
openjdk version "1.8.0_212"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_212-b03)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.212-b03, mixed mode)

$ java -jar pid.jar
Java 8 version...
13860

注意我们添加到 Java 8 版本中的额外横幅,这样您可以区分两种情况,并确保两个不同的类实际上正在运行。对于多版本 JAR 的实际用例,我们希望代码在这两种情况下都能执行相同的功能(如果我们正在将功能回退到 Java 8),或者在运行不支持该功能的 JVM 上以优雅或可预测的方式失败。

我们推荐遵循的一个重要架构模式是将特定 JDK 版本的代码隔离到包或一组包中,具体取决于功能范围的大小。

以下是项目的某些基本指南和原则:

  • 主代码库必须能够使用 Java 8 构建。

  • Java 11 部分必须使用 Java 11 构建。

  • Java 11 部分必须在单独的代码根目录中,与主构建隔离。

  • 最终结果应该是一个单一的 JAR 文件。

  • 尽可能保持构建配置简单。

  • 考虑将多版本 JAR 文件也模块化。

最后一点尤为重要,并且对于不可避免地需要适当构建工具(而不仅仅是 javacjar)的更复杂项目来说,这一点依然成立。

2.6 超越模块

为了结束本章,让我们快速看一下模块之外的内容。回想一下,模块的整个目的就是向 Java 语言中引入一个缺失的抽象:具有依赖保证的部署单元,这些保证可以被源代码编译器和运行时依赖。

这种可信赖的模块化依赖信息在现代可部署软件世界中有着许多应用。在 Java 中,随着工具和生态系统的全面支持,模块的采用速度缓慢但稳定,它们所提供的优势也变得更为人所知。

让我们通过介绍平台新增的一项新功能来结束本章内容——与模块一起新增的JLink。这是将缩减版的 Java 运行时与应用程序打包在一起的能力。这为使用它的应用程序提供了以下好处:

  • 将应用程序和 JVM 打包成一个单一的自包含目录。

  • 减少应用程序和 JRE 打包的整体下载大小。

  • 减少支持开销,因为不需要调试 Java 应用程序与主机安装的 JVM 之间的交互。

jlink 生成的自包含目录可以轻松地打包成可部署的工件(如 Linux 的 .rpm.deb,Mac 的 .dmg 或 Windows 的 .msi),为现代 Java 应用程序提供简单的安装体验。

在某些方面,Java 8 中的 Compact Profiles 技术提供了 JLink 的早期版本,但与模块一起到来的版本更有用且更全面。例如,我们将重用本章早期部分的发现示例。它有一个简单的 module-info.java

module wgjd.discovery {
  exports wgjd.discovery;

  requires java.instrument;
  requires java.logging;
  requires jdk.attach;
  requires jdk.internal.jvmstat;
}

可以通过以下命令将其构建成一个 JLink 打包文件:

$ jlink --module-path $JAVA_HOME/jmods/:out  --output bundle/ --add-modules
  wgjd.discovery

在我们的简单示例中,我们生成了一个可以以 TAR 包或打包成 Linux 软件包(如 .deb 或 .rpm)交付的 JLink 打包文件。实际上,我们可以更进一步,使用 静态编译 将这样的打包文件转换为本地可执行文件,但对此的全面讨论超出了本书的范围。

我们应该提出一个警告:JLink 是一项伟大的技术,但它有一些重要的限制,您应该了解:

  • 它仅与完全模块化的应用程序一起工作。

  • 它与非模块化代码不兼容。

  • 即使是自动模块也不够。

这是因为,为了确保 JRE 的所有必要部分都包含在打包文件中,JLink 依赖于模块图中强声明性的信息,因此需要为每个依赖项提供一个 module-info.class 文件。没有这些信息,构建缩减版的 JRE 很可能是不安全的。

不幸的是,在现实世界中,许多应用程序所依赖的库仍然没有完全模块化。这大大降低了 JLink 的有用性。为了解决这个问题,工具制造商开发了插件,从非模块化库中重新打包和合成“真正的”模块。我们将在第十一章中讨论这些内容。然而,要使用这些工具需要使用构建系统。这意味着我们将推迟到第十一章介绍构建工具时再提供 JLink 的实际示例。

摘要

  • 模块是 Java 中的新概念。它们将包分组并提供关于整个单元、其依赖项及其公共接口的元数据。然后,编译器和运行时会强制执行这些约束。

  • 模块不是一种部署结构(例如,不同的文件格式)。模块化的库和应用程序仍然可以通过 JAR 文件分发并由标准构建工具下载。

  • 转向模块需要改变我们开发 Java 应用程序的方式。

    • module-info.java 文件中的新语法控制着类和方法在模块系统中的暴露方式。

    • 类加载器了解模块定义的限制并处理非模块化代码的加载。

    • 使用模块构建需要新的命令行标志以及更改 Java 项目的标准布局。

  • 模块提供了许多好处作为对这种工作的回报。

    • 由于更细粒度的控制,模块是构建现代部署和未来可维护性应用程序的基本更好的方法。

    • 模块对于减少占用空间至关重要,尤其是在容器中。

    • 模块为其他新功能(如静态编译)铺平了道路。

  • 迁移到模块可能具有挑战性,尤其是对于遗留的单体应用程序。即使第一个模块化运行时发布已经三年了,采用率仍然参差不齐且不完整。

  • 多版本 JAR 和紧凑配置文件等工具可以帮助准备现有的 Java 8 项目以集成到模块化生态系统中,即使它们现在无法迁移。

3 Java 17

本章涵盖

  • Text blocks

  • Switch 表达式

  • 记录

  • 封闭类型

这些代表了自 Java 11 发布以来,直到包括 Java 17 在内,添加到 Java 语言和平台中的主要新特性。

注意:为了理解自 Java 8 以来 Java 发布方法的变化,回顾第一章或附录 A 的讨论可能是个好主意。

除了主要的、用户可见的语言升级之外,Java 17 还包含了许多内部改进(特别是性能升级)。然而,本章重点介绍我们预计将改变开发者编写 Java 方式的主要特性。

3.1 Text Blocks

自从 Java 1.0 的第一个版本以来,开发者们一直在抱怨 Java 的字符串。与其他编程语言,如 Groovy、Scala 或 Kotlin 相比,Java 的字符串有时似乎有点原始。

Java 历史上只提供了一种字符串类型——简单的双引号字符串,其中某些字符(特别是 "\)必须转义才能安全使用。这些字符在出人意料广泛的情境下,导致了需要产生复杂的转义字符串,即使在非常常见的编程情况下也是如此。

Text Blocks 项目作为一个预览特性已经经历了多次迭代,现在在 Java 17 中已成为标准特性(我们已在第一章简短地讨论了预览特性)。它的目标是通过允许跨多行的字符串字面量来扩展 Java 语法中的字符串概念。反过来,这应该可以避免大多数历史上 Java 程序员发现是过度阻碍的转义序列。

注意:与各种其他编程语言不同,Java Text Blocks 目前不支持 插值,尽管这个特性正在积极考虑中,以包含在未来的版本中。

除了帮助 Java 程序员摆脱处理字符过度转义的麻烦之外,Text Blocks 的一个具体目标是允许嵌入在 Java 程序中的可读字符串代码,而这些字符串代码不是 Java 代码。毕竟,你有多经常需要在你的 Java 程序中包含 SQL 或 JSON(甚至 XML)?

在 Java 17 之前,这个过程确实可能很痛苦,实际上,许多团队求助于使用带有所有额外复杂性的外部模板库。自从 Text Blocks 出现以来,在许多情况下,这不再是必要的。

让我们通过考虑一个 SQL 查询来看看它们是如何工作的。在本章中,我们将使用一些来自金融交易的例子——特别是外汇货币交易(FX)。也许我们的客户订单存储在一个 SQL 数据库中,我们将使用如下查询来访问:

String query = """
        SELECT "ORDER_ID", "QUANTITY", "CURRENCY_PAIR" FROM "ORDERS"
        WHERE "CLIENT_ID" = ?
        ORDER BY "DATE_TIME", "STATUS" LIMIT 100;
        """;

你应该注意到两点。首先,Text Block 以 """ 序列开始和结束,这在 Java 15 之前是不合法的 Java 语法。其次,Text Block 可以在每行的开头使用空白字符缩进——这些空白字符将被忽略。

如果我们打印出 query 变量,那么我们得到的就是我们构造的字符串,如下所示:

SELECT "ORDER_ID", "QUANTITY", "CURRENCY_PAIR" FROM "ORDERS"
WHERE "CLIENT_ID" = ?
ORDER BY "DATE_TIME", "STATUS" LIMIT 100;

这是因为 Text Block 是一个常量表达式(String 类型),就像字符串字面量一样。区别在于 Text Block 在 javac 处理并记录到类文件中的常量之前。

  1. 行终止符字符被翻译为 LF (\u000A),即 Unix 行结束约定。

  2. 块周围的额外空白被移除,以便允许 Java 源代码有额外的缩进,正如我们的示例所示。

  3. 块中的任何转义序列都将被解释。

这些步骤按照上述顺序执行是有原因的。具体来说,最后解释转义序列意味着块可以包含字面转义序列(如 \n),而不会被早期步骤修改或删除。

注意:在运行时,从字面量或 Text Block 获取的字符串常量之间绝对没有任何区别。类文件不会以任何方式记录常量的原始来源。

关于 Text Blocks 的更多详细信息,请参阅 JEP 378 (openjdk.java.net/jeps/378)。让我们继续前进,了解新的 Switch Expressions 功能。

3.2 Switch 表达式

自从它的早期版本以来,Java 就支持 switch 语句。Java 从 C 和 C++ 中的现有形式中汲取了许多语法灵感,switch 语句也不例外,如下所示:

switch(month) {
  case 1:
    System.out.println("January");
    break;
  case 2:
    System.out.println("February");
    break;
  // ... and so on
}

特别地,Java 的 switch 语句继承了这样一个属性:如果一个 case 没有以 break 结尾,执行将在下一个 case 之后继续。这个规则允许将需要相同处理的案例分组,如下所示:

switch(month) {
  case 12:
  case 1:
  case 2:
    System.out.println("Winter, brrrr");
    break;
  case 3:
  case 4:
  case 5:
    System.out.println("Spring has sprung!");
    break;
  // ... and so on
}

尽管这种情况带来了便利,但也带来了暗淡和有缺陷的一面。遗漏单个 break 是程序员(无论是新手还是老手)容易犯的错误,并且经常引入错误。在我们的例子中,我们会得到错误的答案,因为省略第一个 break 会导致 winter 和 spring 的消息都被打印出来。

当尝试捕获一个值以供以后使用时,switch 语句也会显得笨拙。例如,如果我们想获取那条消息以供其他地方使用,而不是打印它,我们就必须在 switch 之外设置一个变量,并在每个分支中正确设置它,并可能确保在 switch 之后实际上设置了值;类似于以下这样:

String message = null;
switch(month) {
  case 12:
  case 1:
  case 2:
    message = "Winter, brrrr";
    break;
  case 3:
  case 4:
  case 5:
    message = "Spring has sprung!";
    break;
  // ... and so on
}

就像遗漏 break 一样,我们现在必须确保每个 case 正确设置消息变量,否则我们未来的错误报告会有风险。当然,我们可以做得更好。

在 Java 14(JEP 361)中引入的 Switch 表达式提供了解决这些不足的替代方案,同时也旨在打开未来的语言前沿。这个目标包括帮助缩小与更多面向函数式语言(例如 Haskell、Scala 或 Kotlin)的语言差距。Switch 表达式的第一个版本更加简洁,如下所示:

String message = switch(month) {
  case 12:
  case 1:
  case 2:
    yield "Winter, brrrr";
  case 3:
  case 4:
  case 5:
    yield "Spring has sprung!";
  // ... and so on
}

在这种修改后的形式中,我们不再在每个分支中设置变量。相反,每个情况都使用新的 yield 关键字将我们想要的值返回以分配给 String 变量,整个表达式 产生一个值——从一个情况分支到另一个分支(并且每个情况分支都必须产生一个 yield)。

带着这个例子,这个新特性——Switch 表达式与现有的Switch 语句——的含义更加丰富。在编程语言中,语句是指执行时产生副作用的一段代码。而表达式则是指执行时产生值的一段代码。在 Java 14 之前,switch 只是一个产生副作用的语句,但现在它可以用作表达式来产生值。

Switch 表达式还带来了另一种更加简洁的语法,这可能会被更广泛地采用,如下所示:

String message = switch(month) {
  case 1, 2, 12  -> "Winter, brrrr";
  case 3, 4, 5   -> "Spring has sprung!";
  case 6, 7, 8   -> "Summer is here!";
  case 9, 10, 11 -> "Fall has descended";
  default        -> {
    throw new IllegalArgumentException("Oops, that's not a month");
  }
}

-> 表示我们处于 switch 表达式中,因此那些情况不需要显式的 yield。我们的 default 情况显示了如何在没有单个值的情况下使用 {} 包裹的块。如果你正在使用 switch 表达式的值(就像我们通过将其分配给 message 一样),多行情况必须 yieldthrow

但新的标签格式不仅更有帮助且更短,它还解决了实际问题。首先,多个情况通过 case 后的逗号分隔列表直接支持。这解决了之前需要危险的 switch 穿透问题。新的标签语法中的 switch 表达式永远不会穿透,为每个人关闭了这个障碍。

增加的安全保障还不止这些。破坏 switch 语句的另一种常见方式是遗漏了你应该处理的情况。如果我们从上一个例子中删除 default 行,就会得到一个编译错误,如下所示:

error: the switch expression does not cover all possible input values
    String message = switch(month) {
                     ^

与 switch 语句不同,Switch 表达式必须处理你输入类型的每个可能的情况,否则你的代码甚至无法编译。这是一个很好的保证,可以帮助你覆盖所有的基础。它还很好地与 Java 的枚举结合,正如我们通过将 switch 重写为使用类型安全的常量而不是 int 一样:

String message = switch(month) {
    case JANUARY, FEBRUARY, DECEMBER  -> "Winter, brrrr";
    case MARCH, APRIL, MAY            -> "Spring has sprung!";
    case JUNE, JULY, AUGUST           -> "Summer is here!";
    case SEPTEMBER, OCTOBER, NOVEMBER -> "Fall has descended";
};

这种新功能作为一个独立的功能很有用,因为它允许我们简化 switch 的一个非常常见的用法,表现得有点像函数,根据输入值产生一个输出值。实际上,Switch 表达式的规则是,必须保证每个可能的输入值都能产生一个输出值。

注意:如果所有可能的枚举常量都出现在一个 Switch 表达式中,匹配是 完全的,因此不需要包含一个 default 情况——编译器可以使用枚举常量的完备性。

然而,对于例如接受 int 类型的 Switch 表达式,我们必须包含一个 default 子句,因为列出大约四十亿个可能值是不切实际的。

Switch 表达式也是通往 Java 未来版本中一个主要特性——模式匹配——的垫脚石,我们将在本章后面和书中后面讨论这一点。现在,让我们继续前进,来认识下一个新特性,即 Records。

3.3 Records

Records 是一种新的 Java 类形式,旨在执行以下操作:

  • 提供一种一等方法来建模仅包含数据的聚合

  • 弥合 Java 类型系统的一个可能差距

  • 为常见的编程模式提供语言级别的语法

  • 减少类样板代码

这些项目符号的顺序很重要,实际上,Records 更关注语言语义,而不是样板代码的减少和语法(尽管第二个方面是许多开发者倾向于关注的)。让我们首先解释一下 Java 记录的基本概念。

Records 的理念是扩展 Java 语言,并创建一种方式来说明一个类是“仅仅是字段,没有其他”。通过对我们类的这种声明,编译器可以帮助我们自动创建所有方法,并让所有字段参与像 hashCode() 这样的方法。

注意:这是语义“记录是字段的透明载体”定义语法的这种方式:“访问器方法和其他样板代码自动从记录定义中派生。”

要了解它在日常编程中的表现,请记住,关于 Java 最常见的抱怨之一是你需要编写很多代码才能使一个类变得有用。通常,我们需要编写

  • toString()

  • hashCode()equals()

  • 获取器方法

  • 公共构造函数

等等。

对于简单的领域类,这些方法通常是无聊的、重复的,并且很容易被机械地生成(IDEs 通常提供这种功能),但直到我们有了 Records,语言并没有提供直接这样做的方法。当我们阅读别人的代码时,这种令人沮丧的差距实际上更糟。例如,它可能看起来作者正在使用 IDE 生成的 hashCode()equals(),它使用了类的所有字段,但我们如何能确定而不检查实现中的每一行呢?如果重构期间添加了字段而方法没有重新生成会发生什么?

Records 解决了这些问题。如果一个类型被声明为记录,它就是一个强有力的声明,编译器和运行时会相应地处理它。让我们看看它是如何付诸实践的。

要完全解释这个特性,我们需要一个非平凡的示例领域,所以让我们继续使用 FX 货币交易。如果你对这个领域使用的概念不熟悉,请不要担心——我们会随着我们的进展解释你需要知道的内容。在本书的后面部分,我们将继续探讨金融示例的主题,所以这是一个好的开始点。

让我们来看看我们如何使用 Records 和一些其他功能来改进我们的领域建模,并因此得到更干净、更简洁、更简单的代码。考虑当我们进行 FX 交易时想要下订单的情况。基本订单类型可能包括以下内容:

  • 我要购买或出售的单位数量(以货币单位的百万计)

  • “方向”——我是买入还是卖出(通常称为BidAsk

  • 我要兑换的货币(货币对

  • 我下单的时间

  • 我的订单在超时前有效的时间(TTL存活时间

因此,如果我有一千万英镑,想在下一秒内换成美元,并且我想要每英镑 1.25 美元,那么我现在就是“以 1.25 美元的价格买入 GBP/USD 汇率,有效期为 1 秒。”在 Java 中,我们可能会声明一个领域类,如下所示(我们称之为“classic”,以表明我们目前必须使用类来做这件事——更好的方法即将到来):

public final class FXOrderClassic {
    private final int units;
    private final CurrencyPair pair;
    private final Side side;
    private final double price;
    private final LocalDateTime sentAt;
    private final int ttl;

    public FXOrderClassic(int units, CurrencyPair pair, Side side,
                          double price, LocalDateTime sentAt, int ttl) {
        this.units = units;
        this.pair = pair; // CurrencyPair is a simple enum
        this.side = side; // Side is a simple enum
        this.price = price;
        this.sentAt = sentAt;
        this.ttl = ttl;
    }

    public int units() {
        return units;
    }

    public CurrencyPair pair() {
        return pair;
    }

    public Side side() {
        return side;
    }

    public double price() {
        return price;
    }

    public LocalDateTime sentAt() {
        return sentAt;
    }

    public int ttl() {
        return ttl;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        FXOrderClassic that = (FXOrderClassic) o;

        if (units != that.units) return false;
        if (Double.compare(that.price, price) != 0) return false;
        if (ttl != that.ttl) return false;
        if (pair != that.pair) return false;
        if (side != that.side) return false;
        return sentAt != null ? sentAt.equals(that.sentAt) :
                                that.sentAt == null;
    }

    @Override
    public int hashCode() {
        int result;
        long temp;
        result = units;
        result = 31 * result + (pair != null ? pair.hashCode() : 0);
        result = 31 * result + (side != null ? side.hashCode() : 0);
        temp = Double.doubleToLongBits(price);
        result = 31 * result + (int) (temp ^ (temp >>> 32));
        result = 31 * result + (sentAt != null ? sentAt.hashCode() : 0);
        result = 31 * result + ttl;
        return result;
    }

    @Override
    public String toString() {
        return "FXOrderClassic{" +
                "units=" + units +
                ", pair=" + pair +
                ", side=" + side +
                ", price=" + price +
                ", sentAt=" + sentAt +
                ", ttl=" + ttl +
                '}';
    }
}

这段代码很多,但它的意思是我的订单可以像这样创建:

var order = new FXOrderClassic(1, CurrencyPair.GBPUSD, Side.Bid,
                                1.25, LocalDateTime.now(), 1000);

但声明类的代码中,真正必要的部分有多少呢?在 Java 的旧版本中,大多数开发者可能会只是声明字段,然后使用他们的 IDE 来自动生成所有方法。让我们看看 Records 如何改善这种情况。

注意,Java 没有提供除了通过定义一个类之外的方式来谈论数据聚合的方法,所以很明显,任何只包含“只是字段”的类型都将是一个类。

新的概念是一个 record class(或通常简称为 record)。这是一个不可变(在通常的“所有字段都是 final”的 Java 意义上),透明的固定值集的载体,被称为 record components。每个组件都会产生一个 final 字段来存储提供的值和一个访问器方法来检索该值。字段名和访问器名与组件名匹配。

字段列表为 record 提供了一个 状态描述。在一般类中,字段x、构造函数参数x和访问器x()之间可能没有关系,但在 record 中,它们是 定义上 指的是同一件事——record 就是 它的状态。

为了让我们能够创建 record 类的新的实例,还会生成一个构造函数——称为 规范构造函数——它有一个参数列表,与声明的状态描述完全匹配。Java 语言现在也提供了简洁的语法来声明 Records,其中程序员只需要声明构成 record 的组件名称和类型,如下所示:

public record FXOrder(int units,
                      CurrencyPair pair,
                      Side side,
                      double price,
                      LocalDateTime sentAt,
                      int ttl) {}

通过编写这个记录声明,我们不仅节省了一些输入,我们还做出了一个更强的、语义上的声明。FXOrder 类型 就是 提供的状态,任何实例 就是 字段值的透明聚合。

如果我们现在使用 javap 检查类文件(我们将在第四章中详细介绍),我们可以看到编译器已经为我们自动生成了一堆样板代码:

$ javap FXOrder.class
Compiled from "FXOrder.java"
public final class FXOrder extends java.lang.Record {
  public FXOrder(int, CurrencyPair, Side,
                 double, java.time.LocalDateTime, int);

  public java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public int units();
  public CurrencyPair pair();
  public Side side();
  public double price();
  public java.time.LocalDateTime sentAt();
  public int ttl();
}

这看起来非常像我们为了基于类的实现而必须编写的那些方法集。实际上,构造函数和访问器方法的行为与之前完全相同。然而,像 toString()equals() 这样的方法使用了一种可能让一些开发者感到惊讶的实现方式,如下所示:

public java.lang.String toString();
    Code:
       0: aload_0
       1: invokedynamic #51,  0            // InvokeDynamic #0:toString:
                                           // (LFXOrder;)Ljava/lang/String;
       6: areturn

也就是说,toString() 方法(以及 equals()hashCode())是通过基于 invokedynamic 的机制实现的。这是一种我们将在本书后面的章节(第四章和第十六章)中遇到的有力技术。

我们还可以看到,有一个新的类 java.lang.Record,它将作为所有记录类的超类型。它是抽象的,并声明 equals()hashCode()toString() 为抽象方法。java.lang.Record 类不能直接扩展,正如我们可以通过尝试编译一些像这样的代码来看到:

public final class FXOrderClassic extends Record {
    private final int units;
    private final CurrencyPair pair;
    private final Side side;
    private final double price;
    private final LocalDateTime sentAt;
    private final int ttl;

    // ... rest of class elided
}

编译器将拒绝这个尝试:

$ javac FXOrderClassic.java
FXOrderClassic.java:3: error: records cannot directly extend Record
public final class FXOrderClassic extends Record {
             ^
1 error

获取记录的唯一方法是通过显式声明一个记录并让 javac 创建类文件。这也确保了所有记录类都被创建为最终的。

除了方法的自动生成和样板代码的减少之外,当应用于记录时,Java 的几个核心特性也具有特殊的特点。首先,记录必须遵守关于 equals() 方法的特殊契约:如果一个记录 R 有组件 c1c2、... cn,并且如果记录实例按照以下方式复制:

R copy = new R(r.c1(), r.c2(), ..., r.cn());

那么,r.equals(copy) 必须是 true。请注意,这个不变性是在关于 equals()hashCode() 的通常熟悉契约的基础上增加的——它并不取代它。

到目前为止,让我们继续讨论记录功能的一些更设计层面的方面。为了做到这一点,回忆一下 Java 中枚举的工作方式是有帮助的。Java 中的枚举是一种特殊的类形式,它实现了一个设计模式(有限数量的类型安全实例)但具有最少的语法开销——编译器为我们生成了一堆代码。

同样,Java 中的记录是一种特殊的类形式,它实现了一个模式(数据载体仅包含字段)并具有最少的语法。我们期望的所有样板代码都将由编译器自动生成。然而,尽管仅包含字段的数据载体类的简单概念在直觉上是有意义的,但这在细节上究竟意味着什么呢?

当首次讨论记录时,考虑了许多可能的不同设计。例如:

  • POJOs 的样板代码减少

  • Java Beans 2.0

  • 命名元组

  • 产品类型(一种代数数据类型的形式)

布赖恩·戈茨在他的原始设计草图(mng.bz/M5j8)中详细讨论了这些可能性。每个设计选项都伴随着从记录设计中心的选择中产生的附加次要问题,例如:

  • Hibernate 能否代理它们?

  • 它们是否与经典 Java Bean 完全兼容?

  • 它们是否支持名称擦除/“形状可塑性”?

  • 它们是否会包含模式匹配和结构化?

如果记录功能基于上述四种方法中的任何一种——每种方法都有其优点和缺点——似乎是合理的。然而,最终的设计决策是记录是命名元组。这在一定程度上是由 Java 类型系统中的一个关键设计理念驱动的——名义类型化。让我们更深入地了解一下这个关键理念。

3.3.1 名义类型化

静态类型化的名义方法是指每个 Java 存储(变量、字段)都有一个确定的类型,并且每种类型都有一个名称,这个名称应该(至少在一定程度上)对人类有意义。

即使在匿名类的情况下,类型仍然有名称——只是编译器分配了名称,这些名称在 Java 语言中不是有效的类型名称(但在 JVM 内部仍然是可以的)。例如,我们可以在 jshell 中看到这一点:

jshell> var o = new Object() {
   ...>   public void bar() { System.out.println("bar!"); }
   ...> }
o ==> $0@37f8bb67

jshell> var o2 = new Object() {
   ...>   public void bar() { System.out.println("bar!"); }
   ...> }
o2 ==> $1@31cefde0

jshell> o = o2;
|  Error:
|  incompatible types: $1 cannot be converted to $0
|  o = o2;
|      ^^

注意,尽管匿名类是以完全相同的方式声明的,但编译器仍然生成了两个不同的匿名类$0$1,并且不允许赋值,因为在 Java 类型系统中,变量具有不同的类型。

注意,在其他(非 Java)语言中,类的整体形状(例如,它有哪些字段和方法)可以用作类型(而不是显式的类型名称)。这被称为结构化类型化

如果记录与 Java 的传统相悖,引入了结构化类型,这将是一个重大的变化。因此,“记录是名义元组”的设计选择意味着我们期望记录在可能使用其他语言中的元组的地方表现最佳。这包括复合映射键的使用,或者模拟方法的多返回值。一个复合映射键的例子可能如下所示:

record OrderPartition(CurrencyPair pair, Side side) {}

相反,记录不一定能很好地作为现有代码的替代品,这些代码目前使用 Java Bean。存在许多原因,特别是 Java Bean 是可变的,而记录是不可变的,并且它们对访问器的约定不同。记录将访问器方法命名为与字段名相同(这是因为在 Java 中字段和方法名称是分开命名空间的)而 Bean 则使用getset前缀。

记录确实允许一些额外的灵活性,超出简单的单行声明形式,因为它们是真正的类。具体来说,开发者可以定义额外的函数、构造函数和静态字段,除了自动生成的默认值之外。然而,这些功能应该谨慎使用。记住,记录的设计意图是允许开发者将相关字段组合成一个单一的不可变数据项。

记录可能创建的一个使用额外方法的例子是一个静态工厂方法,用于模拟某些记录参数的默认值。另一个例子可能是一个Person类(具有不可变的出生日期),它可能定义一个currentAge()方法。

一个很好的经验法则是:越是有诱惑力添加大量额外的方法等到基本数据载体(或使其实现多个接口),就越有可能应该使用一个完整的类,而不是记录。

3.3.2 紧凑记录构造函数

简单性/“完整类”规则的一个可能的重要例外是使用紧凑构造函数,语言规范中是这样描述的:

记录类的紧凑构造函数的正式参数是隐式声明的。它们由记录类的派生正式参数列表给出。

紧凑构造函数声明的意图是,在规范构造函数体中只需要给出验证和/或规范化代码;其余的初始化代码由编译器提供。

——Java 语言规范

例如,我们可能想要验证订单,以确保它们不会尝试购买或出售负数量或设置无效的存活时间,如下所示:

public record FXOrder(int units, CurrencyPair pair, Side side,
                      double price, LocalDateTime sentAt, int ttl) {
    public FXOrder {
        if (units < 1) {
            throw new IllegalArgumentException(
                    "FXOrder units must be positive");
        }
        if (ttl < 0) {
            throw new IllegalArgumentException(
                    "FXOrder TTL must be positive, or 0 for market orders");
        }
        if (price <= 0.0) {
            throw new IllegalArgumentException(
                    "FXOrder price must be positive");
        }
    }
}

Java 记录相对于其他语言中找到的匿名元组的一个优点是,记录的构造函数体允许在创建记录时运行代码。这允许进行验证(如果传递了无效状态,则抛出异常)。这在纯结构元组中是不可能的。

在记录体中使用静态工厂方法也可能是有意义的,例如,为了解决 Java 中默认参数值的缺乏。在我们的交易示例中,我们可能包括一个这样的静态工厂:

public static FXOrder of(CurrencyPair pair, Side side, double price) {
    var now = LocalDateTime.now();
    return new FXOrder(1, pair, side, price, now, 1000);
}

声明一个快速创建具有默认参数的订单的方法。当然,这也可以声明为一个替代构造函数。开发者应该选择在每个情况下对他们有意义的做法。

另一个使用替代构造函数的用途是创建用于作为复合映射键的记录,如下例所示:

record OrderPartition(CurrencyPair pair, Side side) {
    public OrderPartition(FXOrder order) {
        this(order.pair(), order.side());
    }
}

然后,类型OrderPartition可以很容易地用作映射键。例如,我们可能想要构建一个用于我们的交易匹配引擎的订单簿,如下所示:

public final class MatchingEngine {
    private final Map<OrderPartition, RankedOrderBook> orderBooks =
                                                            new TreeMap<>();

    public void addOrder(final FXOrder o) {
        orderBooks.get(new OrderPartition(o)).addAndRank(o);
        checkForCrosses(o.pair());
    }

    public void checkForCrosses(final CurrencyPair pair) {
        // Do any buy orders match with sells now?
    }

    // ...
}

现在,当收到新的订单时,addOrder() 方法会提取适当的订单分区(由货币对和买卖双方的元组组成)并使用它将新订单添加到相应的价格排名订单簿中。新订单可能与簿上已有的订单相匹配(这被称为“订单交叉”),因此我们需要在 checkForCrosses() 方法中检查它是否匹配。

有时我们可能不想使用紧凑的构造函数,而是有一个完整、显式的规范构造函数。这表明我们需要在构造函数中实际工作——对于简单数据载体类,这种用法的用例数量很少。然而,在某些情况下,例如需要制作传入参数的防御性副本时,这是必要的。因此,编译器允许使用显式规范构造函数的可能性——但在使用这种方法之前请仔细思考。

Records 的目的是作为简单的数据载体,是元组的一种版本,它以逻辑和一致的方式适应 Java 的既定类型系统。这将帮助许多应用程序使域类更清晰、更小。它还将帮助团队消除许多底层模式的代码实现。它还应该减少或消除对 Lombok 等库的需求。

许多开发者已经开始报告在使用 Records 后的显著改进。它们还与 Java 17 中出现的另一个新特性——Sealed Types 结合得非常好。

3.4 密封类型

Java 的枚举是一个众所周知的语言特性。它允许程序员对有限集合的替代方案进行建模,这些替代方案代表了一个类型的所有可能值——实际上是类型安全的常量。

为了继续我们的 FX 示例,让我们考虑一个 OrderType 枚举来表示不同的订单类型:

enum OrderType {
    MARKET,
    LIMIT
}

这代表了两种可能的 FX 订单类型:一种 市价 订单,它将接受当前最佳价格,以及一种 限价 订单,它仅在特定价格可用时才会执行。平台通过让 Java 编译器自动生成特殊形式的类类型来实现枚举。

注意:运行时实际上以与其他类略有不同的方式处理库类型 java.lang.Enum(所有枚举类都直接扩展),但这里的细节不需要我们关心。

让我们反编译这个枚举,看看编译器生成了什么,如下所示:

$ javap -c -p OrderType.class
final class OrderType extends java.lang.Enum<OrderType> {
  public static final OrderType MARKET;

  public static final OrderType LIMIT;

  ...
  // Private constructor
}

在类文件中,枚举的所有可能值都定义为 public static final 变量,构造函数是私有的,因此不能构造额外的实例。

实际上,枚举类似于 Singleton 模式的泛化,除了它不是类的一个实例,而是有限数量的实例。这个模式非常有用,尤其是因为它给了我们一个关于 完备性 的概念——给定一个非空的 OrderType 对象,我们可以确定它要么是 MARKET 实例,要么是 LIMIT 实例。

然而,假设我们想在 Java 11 中模拟许多不同的订单。我们必须在两种不令人满意的替代方案之间做出选择。首先,我们可以选择只有一个实现类(或记录),FXOrder,其中包含一个状态字段来持有实际类型。这种模式之所以有效,是因为状态字段是枚举类型,并提供了指示哪个类型真正适用于此特定对象的位。这显然是不太理想的,因为它要求应用程序程序员跟踪类型系统真正关心的位。或者,我们可以声明一个抽象基类,BaseOrder,并具有具体类型,MarketOrderLimitOrder,它们是它的子类。

这里的问题是,Java 一直被设计为一个开放的语言,默认可扩展。类一次编译,子类可以在多年(甚至几十年)后编译。截至 Java 11,Java 语言中允许的唯一类继承结构是开放继承(默认)和没有继承(final)。

类可以声明一个包私有构造函数,这实际上意味着“只能被包成员扩展”,但在运行时没有任何东西可以阻止用户在平台不包含的包中创建新类,所以这最多只是一种不完整的保护。

如果我们定义一个 BaseOrder 类,那么没有任何东西可以阻止第三方创建一个继承自 BaseOrderEvilOrder 类。更糟糕的是,这种不受欢迎的扩展可以在 BaseOrder 类型编译多年(甚至几十年)之后发生,这是非常不希望的。

结论是,到目前为止,开发者一直受到限制,如果他们想要确保未来兼容性,就必须使用一个字段来持有 BaseOrder 的实际类型。Java 17 改变了这种状况,通过允许一种新的方式以更细粒度的方式控制继承:密封类型

注意:这种能力以各种形式存在于几种其他编程语言中,近年来已经变得相当流行,尽管实际上它是一个相当古老的想法。

在其 Java 实现(incarnation)中,密封(sealing)所表达的概念是一个类型可以被扩展,但只能由已知的子类型列表扩展,而不能由其他类型扩展。让我们通过一个简单的 Pet 类(我们稍后会回到 FX 示例)的例子来看看新的语法:

public abstract sealed class Pet {
    private final String name;

    protected Pet(String name) {
        this.name = name;
    }

    public String name() {
        return name;
    }

    public static final class Cat extends Pet {
        public Cat(String name) {
            super(name);
        }

        void meow() {
            System.out.println(name() +" meows");
        }
    }

    public static final class Dog extends Pet {
        public Dog(String name) {
            super(name);
        }

        void bark() {
            System.out.println(name() +" barks");
        }
    }
}

Pet 被声明为 sealed,这并不是 Java 之前允许的关键字。未指定的情况下,sealed 表示该类只能在本编译单元内部扩展。因此,子类必须嵌套在当前类内部。我们还声明 Petabstract,因为我们不希望有任何一般的 Pet 实例,只有 Pet.CatPet.Dog 对象。这为我们提供了一种很好的方式来实现我们之前描述的面向对象(OO)建模模式,而不必承担我们讨论的缺点。

密封也可以与接口一起使用,并且在实际应用中,接口形式可能比类形式更广泛地使用。让我们看看当我们想要使用密封来帮助建模不同类型的 FX 订单时会发生什么:

public sealed interface FXOrder permits MarketOrder, LimitOrder {
    int units();
    CurrencyPair pair();
    Side side();
    LocalDateTime sentAt();
}

public record MarketOrder(int units,
                          CurrencyPair pair,
                          Side side,
                          LocalDateTime sentAt,
                          boolean allOrNothing) implements FXOrder {

    // constructors and factories elided
}

public record LimitOrder(int units,
                         CurrencyPair pair,
                         Side side,
                         LocalDateTime sentAt,
                         double price,
                         int ttl) implements FXOrder {

    // constructors and factories elided
}

这里有几个需要注意的地方。首先,FXOrder现在是一个sealed接口。其次,我们可以看到第二个新关键字permits的使用,它允许开发者列出这个密封接口的允许实现——我们的实现是记录。

注意:当你使用permits时,实现类不必位于同一文件中,可以是独立的编译单元。

最后,我们有一个很好的额外奖励——因为MarketOrderLimitOrder是合适的类,它们可以拥有特定于其类型的操作。例如,市价单立即采取可用的最佳价格,无需指定价格。另一方面,限价单需要指定订单将接受的价格以及它准备等待多长时间来尝试实现它(生存时间或TTL)。如果我们使用字段来指示对象的“真实类型”,这不会很直接,因为所有子类型的方法都必须存在于基类型上,或者迫使我们使用丑陋的下转型。

如果我们现在用这些类型编程,我们知道我们遇到的任何FXOrder实例必须是MarketOrderLimitOrder之一。更重要的是,编译器也可以使用这个信息。库代码现在可以安全地假设这些是唯一可能的选择,并且这个假设不能被客户端代码违反。

Java 的 OO 模型代表了类型之间关系的两个最基本概念。具体来说,是"Type X IS-A Y""Type X HAS-A Y"。密封类型代表了一种面向对象的概念,以前在 Java 中无法建模:"Type X IS-EITHER-A Y OR Z"。或者,它们也可以被视为

  • final和开放类之间的折中方案

  • 将枚举模式应用于类型而不是实例

在面向对象编程理论方面,它们代表了一种新的形式关系,因为o的可能类型集合是YZ的并集。因此,这被称为各种语言中的联合类型求和类型,但不要混淆——它们与 C 的union不同。

例如,Scala 程序员可以使用类似的想法,使用 case 类和它们自己的sealed关键字版本(我们稍后会遇到 Kotlin 对这个想法的看法)。

除了 JVM 之外,Rust 语言还提供了一种称为 不交联合 类型的概念,尽管它使用 enum 关键字来引用,这对 Java 程序员来说可能 极其 混淆。在函数式编程领域,一些语言(例如 Haskell)提供了一种称为 代数数据类型 的功能,其中包含作为特殊情况的和类型。实际上,Sealed Types 和 Records 的组合也为 Java 17 提供了这一功能的版本。

表面上看,这些类型在 Java 中似乎是一个全新的概念,但它们与枚举的深层相似性应该为许多 Java 程序员提供一个良好的起点。事实上,与这些类型相似的东西已经存在于一个地方:多捕获子句中异常参数的类型。

根据 Java 语言规范(JLS 11,第 14.20 节):

The declared type of an exception parameter that denotes its type as a union
with alternatives D1 | D2 | ... | Dn is lub(D1, D2, ..., Dn).

然而,在多捕获的情况下,真正的联合类型不能写成局部变量的类型——它是 不可表示的。在多捕获的情况下,我们不能创建一个类型为真正联合类型的局部变量。

我们应该关于 Java 的 Sealed Types 提出一点:它们必须有一个基类,所有允许的类型都扩展(或一个所有允许的类型都必须实现的公共接口)。无法表达一个“ISA-String-OR-Integer”的类型,因为 StringInteger 类型除了 Object 之外没有共同的继承关系。

注意:一些其他语言允许构建通用联合类型,但在 Java 中这是不可能的。

让我们继续讨论 Java 17 中引入的另一个新语言特性——instanceof 关键字的新形式。

3.5 新的 instanceof 形式

尽管自 Java 1.0 以来就是语言的一部分,但 instanceof 操作符有时会从一些 Java 开发者那里得到一些负面评价。在其最简单的形式中,它提供了一个简单的测试:x instanceof Y 如果值 x 可以赋给类型 Y 的变量,则返回 true,否则返回 false(前提是 null instanceof Y 对于每个 Y 都是 false)。

这种定义被批评为破坏面向对象设计,因为它暗示了对象类型和参数类型选择的不精确性。然而,在实践中,在某些场景中,开发者必须面对一个在编译时类型不完全已知的对象。例如,考虑一个通过反射获得的对象,对其了解甚少。

在这种情况下,正确的方法是使用 instanceof 来检查类型是否符合预期,然后进行向下转型。instanceof 测试提供了一个保护条件,确保转型不会在运行时引发 ClassCastException。生成的代码如下所示:

Object o = // ...
if (o instanceof String) {
    String s = (String)o;
    System.out.println(s.length());
} else {
    System.out.println("Not a String");
}

从开发者的角度来看,Java 17 中提供的新的 instanceof 功能非常简单——它仅仅提供了一种避免转型的方法,如下所示:

if (o instanceof String s) {
    System.out.println(s.length());                           ❶
} else {
    System.out.println("Not a String");                       ❷
}

// ... More code                                              ❸

❶ s 在此分支中是有效的。

❷ 在“else”分支上,s 不在作用域内。

❸ 一旦 if 语句结束,s 就不再在作用域内。

然而,尽管这可能看起来并不重要,但我们从为这个功能的 JEP 命名方式中得到了一个重要的线索。JEP 394 的标题是“为 instanceof 的模式匹配”,它引入了一个新概念——模式

注意 非常重要的一点是要理解,这与在文本处理和正则表达式中所使用的模式匹配是不同的用法。

在这个上下文中,一个模式是以下两个事物的组合:

  1. 一个将被应用于值的谓词(又称测试)

  2. 一组局部变量,称为模式变量,将从值中提取

关键点是,只有当谓词成功应用于值时,才会提取模式变量。

在 Java 17 中,instanceof运算符已被扩展为接受类型或类型模式,其中类型模式由指定类型的谓词和一个模式变量组成。

注意 我们将在下一节中更详细地介绍类型模式。

目前看来,升级后的instanceof似乎并不非常显著,但这却是 Java 语言中首次出现模式,正如我们将看到的,更多的用法即将到来!这只是第一步。

完成了对 Java 17 新语言特性的巡礼后,是时候展望未来,并回到预览功能的主题上。

3.6 模式匹配和预览功能

在第一章,我们介绍了预览功能的概念,但我们无法给出一个很好的例子,因为 Java 11 没有任何预览功能!现在我们正在谈论 Java 17,我们可以继续讨论。

事实上,我们在本章中遇到的所有新语言特性,包括 Switch 表达式、记录和密封类型,都经历了相同的生命周期。它们最初作为预览功能出现,并在成为最终功能之前经历了一轮或多轮公开预览。例如,密封类在 Java 15 中进行了预览,然后在 16 中也进行了预览,最终在 Java 17 LTS 中作为最终功能发布。

在本节中,我们将遇到一个扩展模式匹配从instanceofswitch的预览功能。Java 17 包括这个功能的版本,但仅作为第一个预览版本(有关预览功能的更多详细信息,请参阅第一章)。语法可能在最终发布前发生变化(尽管这个功能被撤回的可能性很小,但对于模式匹配来说,这种情况最不可能发生)。

让我们看看模式匹配如何在简单情况下被用来改进一些必须处理未知类型对象的代码。我们可以使用新的instanceof形式来编写一些安全的代码,如下所示:

Object o = // ...

if (o instanceof String s) {
    System.out.println("String of length:"+ s.length());
} else if (o instanceof Integer i) {
    System.out.println("Integer:"+ i);
} else {
    System.out.println("Not a String or Integer");
}

这很快就会变得繁琐且冗长。相反,我们可以在switch表达式和简单的instanceof布尔表达式中引入类型模式。在当前(Java 17)预览功能的语法中,我们可以将之前的代码重写为简单形式:

var msg = switch (o) {
    case String s      -> "String of length:"+ s.length();
    case Integer i     -> "Integer:"+ i;
    case null, default -> "Not a String or Integer";             ❶
};
System.out.println(msg);

❶ 现在允许将Null作为情况标签,以防止NullPointerException的可能性。

对于那些想要尝试类似代码的开发者,我们应该解释如何使用预览功能进行构建和运行。如果我们尝试编译使用预览功能的类似上一个示例的代码,我们会得到一个错误,如下所示:

$ javac ch3/Java17Examples.java
ch3/Java17Examples.java:68: error: patterns in switch statements are a
  preview feature and are disabled by default.

            case String s -> "String of length:"+ s.length();
                 ^
  (use --enable-preview to enable patterns in switch statements)
1 error

编译器友好地提示我们可能需要启用预览功能,所以我们再次尝试启用标志:

$ javac --enable-preview -source 17 ch3/Java17Examples.java
Note: ch3/Java17Examples.java uses preview features of Java SE 17.
Note: Recompile with -Xlint:preview for details.

运行时的情况也类似:

$ java ch3.Java17Examples
Error: LinkageError occurred while loading main class ch3.Java17Examples
    java.lang.UnsupportedClassVersionError: Preview features are not enabled
  for ch16/Java17Examples (class file version 61.65535). Try running with
  '--enable-preview'

最后,如果我们包含预览标志,那么代码最终将运行:

$ java --enable-preview ch13.Java17Examples

恒常地启用预览功能确实很麻烦,但它是为了保护开发者,防止任何使用未完成功能的代码逃入生产环境并造成问题。同样,重要的是要注意,当我们尝试在没有运行时标志的情况下运行包含预览功能的类时出现的关于类文件版本的提示。如果我们明确编译了预览功能,我们不会得到标准的类文件,而且大多数团队不应该在生产环境中运行该代码。

Java 17 中模式匹配的预览版本还具有与 Sealed Types 紧密集成的功能。具体来说,模式可以利用 Sealed Types 提供的可能看到类型的唯一性。例如,在处理 FX 订单响应时,我们可能有以下基本类型:

public sealed interface FXOrderResponse
        permits FXAccepted, FXFill, FXReject, FXCancelled {
    LocalDateTime timestamp();
    long orderId();
}

我们可以将此与switch表达式和类型模式结合起来,给出以下代码:

FXOrderResponse resp = // ... response from market
var msg = switch (resp) {
    case FXAccepted a  -> a.orderId() + " Accepted";
    case FXFill f      -> f.orderId() + " Filled "+ f.units();
    case FXReject r    -> r.orderId() + " Rejected: "+ r.reason();
    case FXCancelled c -> c.orderId() + " Cancelled";
    case null          -> "Order is null";
};
System.out.println(msg);

注意到 a)我们明确包含了一个case null来确保此代码是 null 安全的(并且不会抛出NullPointerException),以及 b)我们不需要默认情况。第二个原因是因为编译器可以检查FXOrderResponse的所有允许的子类型,并且可以得出结论,模式匹配是完全的,它涵盖了可能发生的所有可能性,因此默认情况在所有情况下都是无效代码。在匹配不是完全的情况下,并且某些情况没有被覆盖时,需要默认情况。

第一次预览还包括受保护的模式,这允许一个模式被布尔守卫条件装饰,因此只有当模式谓词和守卫都为真时,整体模式才匹配。例如,假设我们只想看到大型填充订单的详细信息。我们可以将上一个示例中的填充情况更改为以下代码:

case FXFill f && f.units() < 100 -> f.orderId() + " Small Fill";
case FXFill f                    -> f.orderId() + " Fill "+ f.units();

注意,更具体的案例(小于 100 个单位的少量订单)首先进行测试,并且只有当它失败时,匹配尝试下一个案例,即填充的无保护匹配。模式变量也已经适用于任何保护条件。我们将在第十八章中回到模式匹配,当我们讨论 Java 的未来并讨论一些未能及时加入 Java 17 的特性时。

摘要

  • Java 17 引入了许多新特性,开发者可以立即在自己的代码中利用这些特性:

    • 文本块,用于多行字符串。

    • 开关表达式,提供更现代的开关体验。

    • 记录作为透明数据载体。

    • 封装类型——一个重要的新面向对象建模概念。

    • 模式匹配——尽管在 Java 17 中尚未完全实现,但它清楚地显示了语言在即将到来的版本中的发展方向。

第二部分:内部结构

这本书的这一部分完全是关于探索 JVM实际上是如何工作的。你将从检查类加载开始。许多 Java 开发者对 JVM 实际上是如何加载、链接和验证类的理解并不好。这导致由于某种类加载器冲突而执行了某些类的“不正确”版本时,会感到沮丧并浪费了时间。

能够深入探究 Java 类文件及其包含的字节码的内部结构是一种强大的调试技能。你将看到如何使用javap来导航和理解字节码的含义。

接下来,你将深入了解硬件领域正在发生的多核 CPU 革命。扎实的 Java 开发者需要了解 Java 的并发能力。第五章和第六章将教你如何充分利用现代处理器。首先,你将了解并发理论以及 Java 自 2006 年(Java 5)以来用于并发编程的基础构件。你将学习 Java 内存模型以及线程和并发在该模型中的实现方式。

一旦你掌握了一些理论知识,我们将引导你了解java.util.concurrent包的功能,它为并发 Java 的实际开发提供了现代结构。

性能调优通常被视为一门艺术,而不是一门科学,追踪和修复性能问题往往需要开发团队付出非凡的时间和精力。在本部分的最后一章第七章中,我们将教你测量而不是猜测,并且“根据传说调优”是错误的。你将学习一种科学的方法,它能快速带你找到性能问题的核心。

尤其是关注垃圾收集(GC)和即时(JIT)编译器,这两者是 JVM 影响性能的两个主要部分。在其他的性能知识中,你将学习如何阅读 GC 日志并使用免费的 Java VisualVM(jvisualvm)工具来分析内存使用。

到第二部分结束时,你将不再是一个只关注 IDE 中源代码的开发者。你将了解 Java 和 JVM 在底层是如何工作的,并且能够充分利用地球上可能提供的最强大的通用虚拟机。

4 类文件和字节码

本章涵盖

  • 类加载

  • 反射

  • 类文件的解剖结构

  • JVM 字节码及其重要性

成为一个更加扎实的 Java 开发者的一个经过验证的方法是提高你对平台工作原理的理解。熟悉核心功能,如类加载和 JVM 字节码的本质,将极大地帮助你实现这一目标。

考虑以下一个资深 Java 开发者可能会遇到的情况:想象一下,你有一个大量使用依赖注入(DI)技术(如 Spring)的应用程序,它在启动时出现问题,并带有难以理解的错误信息。如果问题不仅仅是简单的配置错误,你可能需要了解 DI 框架的实现方式,以便追踪问题。这意味着你需要理解类加载。

或者假设你正在与之合作的供应商倒闭了。你只剩下最后一点编译后的代码,没有源代码,文档也不完整。你如何探索编译后的代码,看看它包含什么?

除了最简单的应用外,所有应用都可能因为ClassNotFoundExceptionNoClassDefFoundError而失败,但许多开发者并不了解这些错误,也不知道它们之间的区别,甚至不知道它们为什么会发生。

本章重点介绍这些关注点背后的平台方面。我们还将讨论一些更高级的功能,但它们是为那些喜欢深入研究的人准备的,如果你时间紧迫,可以跳过这些内容。

我们将从类加载的概述开始——这是 JVM 定位和激活新类型以便在运行程序中使用的过程。该讨论的核心是代表 JVM 中类型的Class对象。接下来,我们将探讨这些概念如何构建成称为反射(或核心反射)的主要语言功能。

之后,我们将讨论用于检查和解剖类文件的工具。我们将使用与 JDK 一起提供的javap作为我们的参考工具。在完成类文件解剖课程之后,我们将转向字节码。我们将介绍 JVM 操作码的主要家族,并查看运行时在低级别是如何操作的。

让我们从讨论类加载开始——这是将新类纳入正在运行的 JVM 进程的过程。在本节中,我们将首先讨论“经典”类加载的基础,正如 Java 8 及之前版本所做的那样。在章节的后面部分,我们将讨论模块化 JVM 的出现如何对类加载带来一些(较小的)变化。

4.1 类加载和类对象

.class文件为 JVM 定义了一个类型,包括字段、方法、继承信息、注解和其他元数据。类文件格式由标准详细描述,任何希望在 JVM 上运行的语言都必须遵守它。

注意:类是 Java 平台将理解、接受和执行的程序代码的基本单元。

从一个初学 Java 开发者的角度来看,许多类加载机制是隐藏的。开发者提供可执行 JAR 文件或主应用程序类的名称(该名称必须存在于类路径上),然后 JVM 找到并执行该类。

任何应用程序依赖项(例如,除了 JDK 之外的库)也必须在类路径上,JVM 会找到并加载它们。然而,Java 规范没有说明这需要在应用程序启动时完成,还是根据需要稍后完成。

注意:Java 类加载系统向用户提供的 API 相对简单——很多复杂性都是故意隐藏的,我们将在本章后面讨论开发者可用的 API。

让我们从一个非常简单的例子开始:

Class<?> clazz = Class.forName("MyClass");

这段代码将加载一个名为 MyClass 的类到当前执行状态中。从 JVM 的角度来看,为了实现这一点,必须执行一系列步骤。首先,必须找到与 MyClass 名称对应的类文件,然后解析其中包含的类。这些步骤在本地代码中执行——在 HotSpot 中,本地方法被调用为 JVM_DefineClass()

实际过程在高层上,是本地代码构建 JVM 的内部表示(称为 klass,它不是 Java 对象——我们将在第十七章中详细介绍)。然后,如果 klass 能够成功从类文件中提取出来,JVM 将构建一个 Java “镜像”的 klass,并将其作为 Class 对象传递回 Java 代码。

之后,代表类型的 Class 对象对运行系统可用,可以创建其新的实例。在先前的例子中,clazz 最终持有代表 MyClass 类型的 Class 对象。它不能持有 klass,因为 klass 是 JVM 内部对象,不是 Java 对象。

注意:相同的流程也用于主应用程序类、所有依赖项以及程序启动后可能需要的任何其他类。

在本节中,我们将更详细地介绍 JVM 视角下的步骤,并介绍类加载器,它是控制整个过程的对象。

4.1.1 加载和链接

从一个角度来看,JVM 可以被视为一个执行容器。在这个观点下,JVM 的目的是消费类文件并执行其中包含的字节码。为了实现这一点,JVM 必须以字节流的形式检索类文件的内容,将其转换为可用的形式,并将其添加到运行状态中。这正是我们在这里所描述的过程。

这个相对复杂的过程可以以多种方式划分,但我们将它称为 加载链接

注意:我们关于加载和链接的讨论涉及一些特定于 HotSpot 代码的细节,但其他实现应该做类似的事情。

第一步是获取构成类文件的字节序列数据流。这个过程通常从一个从文件系统读取的字节数组开始(但其他替代方案肯定也是可能的)。

一旦我们有了数据流,就必须解析它以检查它是否包含有效的类文件结构(这有时被称为格式检查)。如果是这样,那么就会创建一个候选 klass。在这个阶段,当候选 klass 正在填充时,会执行一些基本检查(例如,正在加载的类实际上能否访问其声明的超类?它是否试图覆盖任何final方法?)。

然而,在加载过程结束时,对应于类的数据结构还不能被其他代码使用,特别是我们还没有一个完全功能的 klass。

要到达那里,类必须在被使用之前先进行链接和初始化。从逻辑上讲,这一步可以分为三个子阶段:验证、准备和解决。然而,在实际实现中,代码可能并没有被干净地分离出来,所以如果你打算阅读源代码,你应该意识到这里提供的描述是对过程的高层次或概念性描述,并不与实际实现代码有精确的对应关系。

考虑到这一点,验证可以理解为确认类符合 Java 规范的要求,并且不会在运行时引起错误或其他问题的阶段。这种链接阶段的相互关系可以在图 4.1 中看到。

图片

图 4.1 加载和链接(链接的子阶段)

让我们依次了解每个阶段。

验证

验证是一个相当复杂的过程,由几个独立的部分组成。例如,JVM 需要检查常量池中包含的符号信息(在 4.3.3 节中详细讨论)是否自洽,并遵守常量的基本行为规则。

另一个主要关注点,可能是验证中最复杂的一部分,是检查方法的字节码。这涉及到确保字节码表现良好,并且不会试图绕过 JVM 的环境控制。

执行的一些主要检查如下:

  • 确保字节码不会尝试以不允许或邪恶的方式操作栈。

  • 确保每个分支指令(例如,从if或循环)都有一个适当的跳转指令。

  • 确保方法调用使用正确的静态类型和正确数量的参数。

  • 检查局部变量是否只分配了适合其类型的值。

  • 检查每个可能抛出的异常都有一个合法的捕获处理器。

这些检查是出于几个原因,包括性能。这些检查使得运行时检查可以跳过,从而使解释代码运行更快。其中一些还可以在运行时将字节码编译成机器码(即时编译,我们将在第六章中介绍)时简化编译过程。

准备

准备类涉及分配内存并使类中的静态变量准备好初始化,但它不会初始化变量或执行任何 JVM 字节码。

解析

解析是链接的一部分,其中 JVM 检查正在链接的类的超类型(以及它实现的任何接口)是否已经链接,如果没有,则在继续链接此类之前进行链接。这可能导致任何以前未见过的新的类型出现递归链接过程。

注意:与类加载的这个方面相关的一个关键短语是类型的传递闭包。不仅包括类直接继承的类型,还包括所有间接引用的类型也必须链接。

一旦找到并解析了需要加载的所有附加类型,JVM 就可以初始化它最初请求加载的类。

初始化

在这个最终阶段,初始化任何静态变量,并运行任何静态初始化块。这是一个重要的点,因为只有现在 JVM 才最终从新加载的类中运行字节码。

当这一步完成时,类就完全加载并准备好使用。该类对运行时可用,并且可以创建其新实例。任何进一步引用此类的类加载操作现在将看到它已加载并可用。

4.1.2 类对象

链接和加载过程的结果是一个Class对象,它代表新加载和链接的类型。现在它在 JVM 中完全功能化,尽管出于性能原因,Class对象的某些方面仅在需要时初始化。

注意:Class对象是常规的 Java 对象。它们生活在 Java 堆中,就像任何其他对象一样。

您的代码现在可以继续使用新类型并创建新实例。此外,类型的Class对象提供了一些有用的方法,例如getSuperclass(),它返回对应于超类型的Class对象。

类对象可以使用反射 API 进行间接访问方法、字段、构造函数等。Class对象包含对MethodField以及与类成员相对应的各种其他对象的引用。这些对象可以在反射 API 中使用,以提供对类功能的间接访问,正如我们将在本章后面看到的那样。您可以在图 4.2 中看到其高级结构。

图 4.2 Class对象和Method引用

到目前为止,我们还没有讨论运行时负责定位和链接将成为新加载类的字节流的哪个部分。这是由类加载器处理的——ClassLoader抽象类的子类,它们是我们接下来要讨论的主题。

4.2 类加载器

Java 是一个本质上面向对象的系统,具有动态运行时。其一个方面是 Java 的类型在运行时是活跃的,运行中的 Java 平台类型系统可以被修改——特别是通过添加新的类型。构成 Java 程序的类型在运行时可以由未知类型进行扩展(除非它们是final或新的sealed类)。类加载能力对用户是开放的。类加载器只是扩展ClassLoader的 Java 类——它们自身也是 Java 类型。

注意:在现代 Java 环境中,所有类加载器都是模块化的。加载类始终是在模块的上下文中完成的。

ClassLoader类有一些本地方法,包括负责对类文件进行低级解析的加载和链接方面,但用户类加载器无法覆盖类加载的这个方面。无法使用本地代码编写类加载器。

平台附带以下典型类加载器,它们在平台的启动和正常运行期间执行不同的任务:

  • BootstrapClassLoader(或原初类加载器)——这个类加载器在 JVM 启动过程中非常早就被实例化了,所以通常最好将其视为 JVM 本身的一部分。它通常用于加载绝对基本系统——本质上就是java.base

  • PlatformClassLoader——在启动最基本系统之后,平台类加载器加载应用程序依赖的其他平台模块。这个类加载器是访问任何平台类的首选接口,无论它实际上是由这个加载器还是引导加载器加载的。它是一个内部类的实例。

  • AppClassLoader——应用程序类加载器——这是最广泛使用的类加载器。它加载应用程序类,并在大多数现代 Java 环境中执行大部分工作。在模块化 JVM 中,应用程序类加载器不再是URLClassLoader的实例(如 Java 8 及之前版本),而是一个内部类的实例。

让我们通过向第二章中wgjd.sitecheck模块的SiteCheck类的main方法顶部添加一些代码,来看看这些新的类加载器是如何工作的:

...
var clThis = SiteCheck.class.getClassLoader();
System.out.println(clThis);
var clObj = Object.class.getClassLoader();
System.out.println(clObj);
var clHttp = HttpClient.class.getClassLoader();
System.out.println(clHttp);
....

我们使用以下方式重新编译它:

$ javac -d out wgjd.sitecheck/module-info.java \
        wgjd.sitecheck/wgjd/sitecheck/*.java \
        wgjd.sitecheck/wgjd/sitecheck/*/*.java

并按如下方式运行:

$ java -cp out wgjd.sitecheck.SiteCheck http://github.com/well-grounded-java

注意使用“起始模块”语法而不是显式的起始类。

这会产生以下输出:

jdk.internal.loader.ClassLoaders$AppClassLoader@277050dc
null
jdk.internal.loader.ClassLoaders$PlatformClassLoader@12bb4df8
http://github.com/well-grounded-java: HTTP_1_1

Object类(位于java.base中)的类加载器报告为null。这是一个安全特性——引导类加载器不进行验证,并为它加载的每个类提供完全的安全访问。因此,在 Java 运行时中不表示和提供类加载器是没有意义的——这可能导致太多的错误或滥用。

除了它们的核心角色外,类加载器还经常用于从 JAR 文件或其他类路径上的位置加载资源(不是类的文件,如图像或配置文件)。这通常在结合 try-with-resources 模式时看到,如下所示:

try (var is = TestMain.class.getResourceAsStream("/resource.csv");
     var br = new BufferedReader(new InputStreamReader(is));) {
     // ...
}
// Exception handling elided

类加载器以几种不同的形式提供这种机制,返回一个URL或一个InputStream

4.2.1 自定义类加载

更复杂的环境通常会有许多额外的自定义类加载器——这些是继承自java.lang.ClassLoader(直接或间接)的类。这是可能的,因为类加载器类不是最终的,并且开发人员实际上被鼓励编写针对他们特定需求的自己的类加载器。

自定义类加载器以 Java 类型表示,因此需要由类加载器加载,这通常被称为它们的父类加载器。这不应与类继承和父类混淆。相反,类加载器通过一种委托形式相互关联。

在图 4.3 中,您可以查看类加载器的委托层次结构以及不同的加载器是如何相互关联的。在某些特殊情况下,自定义类加载器可能具有不同的类加载器作为其父类加载器,但通常情况下,它是加载类加载器。

图片

图 4.3 类加载器层次结构

自定义机制的关键在于loadClass()findClass()方法,这些方法在ClassLoader上定义。主要入口点是loadClass(),下面是ClassLoader中相关代码的简化形式:

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                // ...
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    // ...
                    c = findClass(name);

                    // ...
                }
            }
            // ...

            return c;
        }
    }

实际上,loadClass()机制会检查类是否已经加载,然后询问其父类加载器。如果类加载失败(注意parent.loadClass(name, false)调用周围的 try-catch),则加载过程委托给findClass()java.lang.ClassLoaderfindClass()的定义非常简单——它只是抛出一个ClassNotFoundException

在这一点上,让我们回到我们在本章开头提出的问题,并探讨在类加载过程中可能遇到的异常和错误类型。

类加载异常

ClassNotFoundException的含义相对简单:类加载器尝试加载指定的类,但无法完成。也就是说,在请求加载时,该类对 JVM 来说是未知的,并且 JVM 无法找到它。

接下来是NoClassDefFoundError。请注意,这是一个错误而不是异常。这个错误表明 JVM 确实知道请求的类的存在,但在其内部元数据中找不到其定义。让我们快速看一下一个例子:

public class ExampleNoClassDef {

    public static class BadInit {
        private static int thisIsFine = 1 / 0;
    }

    public static void main(String[] args) {
        try {
            var init = new BadInit();
        } catch (Throwable t) {
            System.out.println(t);
        }
        var init2 = new BadInit();
        System.out.println(init2.thisIsFine);
    }
}

当它运行时,我们得到一些类似以下的输出:

$ java ExampleNoClassDef
java.lang.ExceptionInInitializerError
Exception in thread "main" java.lang.NoClassDefFoundError: Could
  not initialize class ExampleNoClassDef$BadInit
    at ExampleNoClassDef.main(ExampleNoClassDef.java:13)

这表明 JVM 试图加载BadInit类,但未能成功。尽管如此,程序捕获了异常并尝试继续执行。然而,当第二次遇到该类时,JVM 的内部元数据表显示该类已被识别,但没有加载有效的类。

JVM 在失败类加载尝试上有效地实现了负缓存——不会重试加载,而是抛出一个错误(NoClassDefFoundError)。

另一个常见的错误是UnsupportedClassVersionError,当类加载操作尝试加载由比运行时支持的 Java 源代码编译器更高版本的编译器编译的类文件时触发。例如,考虑一个用 Java 11 编译的类,我们尝试在 Java 8 上运行,如下所示:

$ java ScratchImpl
Error: A JNI error has occurred please check your installation and try again
Exception in thread "main" java.lang.UnsupportedClassVersionError:
  ScratchImpl has been compiled by a more recent version of the Java
    Runtime (class file version 55.0), this version of the Java Runtime
    only recognizes class file versions up to 52.0
    at java.lang.ClassLoader.defineClass1(Native Method)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
    at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
    at java.net.URLClassLoader.defineClass(URLClassLoader.java:468)
    at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
    at java.net.URLClassLoader$1.run(URLClassLoader.java:369)
    at java.net.URLClassLoader$1.run(URLClassLoader.java:363)
    at java.security.AccessController.doPrivileged(Native Method)
    at java.net.URLClassLoader.findClass(URLClassLoader.java:362)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
    at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:349)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
    at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)

Java 11 格式的字节码可能包含运行时不支持的特性,因此继续尝试加载它是不安全的。请注意,因为这是一个 Java 8 运行时,它没有在堆栈跟踪中具有模块入口。

最后,我们还应该提到LinkageError,它是包含NoClassDefFoundErrorVerifyErrorUnsatisfiedLinkError以及几个其他可能性的类层次结构的基类。

第一个自定义类加载器

自定义类加载的最简单形式是简单地继承ClassLoader并重写findClass()方法。这允许我们重用之前讨论过的loadClass()逻辑,并简化我们的类加载器复杂性。

我们的第一个例子是SadClassLoader,如下一代码示例所示。它实际上并没有做什么,但它确保你知道它在技术上参与了该过程,并祝愿你一切顺利:

public class LoadSomeClasses {

    public static class SadClassloader extends ClassLoader {
        public SadClassloader() {
            super(SadClassloader.class.getClassLoader());
        }

        public Class<?> findClass(String name) throws
          ClassNotFoundException {
            System.out.println("I am very concerned that I
              couldn't find the class");
            throw new ClassNotFoundException(name);
        }
    }

    public static void main(String[] args) {
        if (args.length > 0) {
            var loader = new SadClassloader();
            for (var name : args) {
                System.out.println(name +" ::");
                try {
                    var clazz = loader.loadClass(name);
                    System.out.println(clazz);
                } catch (ClassNotFoundException x) {
                    x.printStackTrace();
                }
            }
        }
    }
}

在我们的例子中,我们设置了一个非常简单的类加载器以及一些使用它的代码来尝试加载可能已经或尚未加载的类。

注意:自定义类加载器的一个常见约定是提供一个无参数的构造函数,该构造函数调用超类构造函数并将加载类加载器作为参数(成为父类加载器)。

许多自定义类加载器并不比我们的例子复杂多少——它们只是重写findClass()方法以提供所需的特定功能。例如,这可能包括通过网络查找类。在一个难忘的案例中,一个自定义类加载器通过连接数据库(使用 JDBC)并访问加密的二进制列来获取将用于加载的字节来加载类。这是为了满足在高度监管环境中非常敏感代码的静态加密要求。

然而,不仅仅可以重写findClass()。例如,loadClass()不是 final 的,因此可以被重写,实际上,一些自定义类加载器正是为了改变我们之前遇到的通用逻辑而重写它。

最后,我们还有在ClassLoader上定义的defineClass()方法。这个方法是类加载的关键,因为它是一个用户可访问的方法,执行我们在本章中之前描述的“加载和链接”过程。它接受一个字节数组并将它们转换为类对象。这是在运行时加载不在类路径上的新类的主要机制。

只有当传递的字节缓冲区是正确的 JVM 类文件格式时,对defineClass()的调用才会成功。如果不是,它将失败加载,因为加载或验证步骤将失败。

注意:此方法可用于加载在运行时生成且没有源代码表示的类的高级技术。这种技术是 Java 中 lambda 表达式机制的工作方式。我们将在第十七章中对此主题有更多讨论。

defineClass()方法既是受保护的也是 final 的,并且定义在java.lang.ClassLoader上,因此只能由ClassLoader的子类访问。因此,自定义类加载器始终可以访问defineClass()的基本功能,但不能干扰验证或其他低级类加载逻辑。这一点很重要:无法更改验证算法是一个非常有用的安全特性——一个编写不良的自定义类加载器无法危害 JVM 提供的基本平台安全。

在 HotSpot 虚拟机(这是最常用的 JVM 实现)的情况下,defineClass()委托给本地方法defineClass1(),它进行一些基本检查,然后调用一个名为JVM_DefineClassWithSource()的 C 函数。

这个函数是进入 JVM 的入口点,它提供了访问 HotSpot 的 C 代码的权限。HotSpot 使用 C SystemDictionary通过 C++方法ClassFileParser::parseClassFile()来加载新的类。这段代码实际上运行了大部分链接过程,特别是验证算法。

一旦类加载完成,方法的字节码就被放置在 HotSpot 的表示方法的元数据对象中(它们被称为methodOops)。然后它们可供字节码解释器使用。这可以被视为一个方法缓存的概念,尽管出于性能原因,字节码实际上是由 methodOops 持有的。

我们已经遇到了SadClassloader。现在让我们看看几个自定义类加载器的例子,首先看看如何使用类加载来实现依赖注入。

示例:依赖注入框架

我们想强调以下两个与 DI 高度相关的核心概念:

  • 系统内的功能单元有依赖项和配置信息,它们依赖于这些信息以实现正常功能。

  • 许多对象系统都有难以或笨拙地在代码中表达依赖项。

你应该想象的是包含行为和配置的类以及外部于对象的依赖项。这部分通常被称为对象的运行时连接。在这个例子中,我们将讨论一个假设的 DI 框架如何使用类加载器来实现运行时连接。

注意:我们将采取的方法类似于 Spring 框架原始实现的简化版本。然而,现代的生产级依赖注入(DI)框架具有显著更高的复杂性。我们的例子仅用于演示目的。

让我们从如何在我们假想的 DI 框架下启动应用程序开始看起,如下所示:

java -cp <CLASSPATH> org.wgjd.DIMain /path/to/config.xml

DIMain类是 DI 框架的入口点类。它将读取配置文件,创建对象系统,并将它们连接起来(“连接它们”)。请注意,DIMain类不是一个应用程序类——它来自框架,并且是完全通用的。

我们还可以看到,应用程序的CLASSPATH必须包含三样东西:a) DI 框架的 JAR 文件,b) 在config.xml文件中引用的应用程序类,以及 c) 应用程序的其他(非 DI)依赖项。让我们看看下一个示例配置文件,如下所示:

<beans>

 <bean id="dao" class="app.ch04.PaymentsDAO">
  <constructor-arg index="0" value="jdbc:postgresql://db.wgjd.org/payments"/>
  <constructor-arg index="1" value="org.postgresql.Driver"/>
 </bean>

  <bean id="service" class="app.ch04.PaymentService">
    <constructor-arg index="0" ref="dao"/>
  </bean>

</beans>

DI 框架使用配置文件来确定要构造哪些对象。这个例子需要创建daoservice豆类,并且框架将需要调用每个豆类的构造函数,并传递指定的参数。

类加载分为两个独立阶段。第一阶段(由应用程序类加载器处理)加载DIMain类及其引用的任何框架类。然后DIMain开始运行,并将配置文件的路径作为main()参数接收。

到目前为止,框架已经在 JVM 中启动并运行,但config.xml中指定的用户类尚未被触及。事实上,直到DIMain检查配置文件,框架都没有办法知道要加载哪些类。

要加载config.xml中指定的应用程序配置,需要第二个阶段的类加载。在我们的例子中,这使用了一个自定义的类加载器。

首先,检查config.xml文件以确保其一致性并确保没有错误。然后,如果一切顺利,自定义类加载器尝试从CLASSPATH加载类型。如果其中任何一个失败,整个过程将被终止,导致运行时错误。

如果成功,DI 框架可以按正确的顺序(使用它们的构造函数参数)实例化所需的对象。最后,如果所有这些操作都正确完成,应用程序上下文就绪并可以开始运行。

值得重申的是,这个例子是假设性的和说明性的。完全有可能构建一个简单的 DI 框架,其工作方式如这里所描述。然而,实际实现真正的 DI 系统在实践中要复杂得多。让我们继续看看另一个例子。

示例:一个用于监控的类加载器

考虑一个在类加载时修改类字节码以添加额外监控信息的类加载器。当测试用例运行在转换后的代码上时,监控代码会记录测试用例实际测试了哪些方法和代码分支。从这些信息中,开发者可以了解一个类的单元测试是否全面。

这种方法曾是 EMMA 测试覆盖率工具的基础,该工具目前可以从emma.sourceforge.net/获取,尽管它现在相当过时,并且没有为现代 Java 版本更新。尽管如此,遇到使用专门类加载器转换加载时字节码的框架和其他代码是很常见的。

注意:在加载时修改字节码的技术也见于java agent方法,该方法被 New Relic 等工具用于性能监控、可观察性和其他目标。

我们简要地讨论了自定义类加载的一些用例。Java 技术领域的许多其他领域都是类加载和相关技术的重度使用者。以下是一些最著名的例子:

  • 插件架构

  • 框架(无论是供应商的还是自建的)

  • 从非传统位置(非文件系统或 URL)检索类文件

  • Java EE

  • 在 JVM 进程已经开始运行之后,可能需要添加新的、未知代码的任何情况

让我们继续讨论模块系统如何影响类加载并修改我们刚刚解释的经典图景。

4.2.2 模块和类加载

模块系统旨在在类加载的不同级别上运行,而类加载是平台中相对低级的一种机制。模块涉及程序单元之间的大规模依赖关系,而类加载涉及小规模。然而,了解这两种机制如何交叉以及模块的引入对程序启动造成的改变是很重要的。

回想一下,当在模块化 JVM 上运行时,为了执行程序,运行时会首先计算一个模块图,并尝试满足它。这被称为模块解析,它推导出根模块及其依赖项的传递闭包。

在此过程中,会执行额外的检查(例如,没有具有重复名称的模块,没有分割的包)。由于模块图的存在,预计运行时类加载问题会更少,因为现在可以在进程完全开始之前检测到模块路径上缺失的 JAR 文件。

除了这个之外,模块系统在大多数情况下并不会对类加载产生太大影响。有一些高级的可能性(例如通过反射动态加载服务提供者接口的模块实现),但这些不太可能被大多数开发者经常遇到。

4.3 检查类文件

类文件是二进制块,因此它们不容易直接处理。但在许多情况下,你会发现调查类文件是必要的。

假设你的应用程序需要额外的公共方法来允许更好的运行时监控(例如通过 JMX)。重新编译和重新部署似乎完成得很好,但当检查管理 API 时,方法并不在那里。额外的重建和重新部署步骤没有效果。

为了调试部署问题,你可能需要检查 javac 是否生成了你认为它应该生成的类文件。或者你可能需要调查一个你没有源代码的类,并且你怀疑文档是不正确的。

对于这些和类似的任务,你必须使用工具来检查类文件的内容。幸运的是,标准的 Oracle JVM 随带一个名为 javap 的工具,它非常适合查看内部和反汇编类文件。

我们将首先介绍 javap 以及它提供的一些基本开关,用于检查类文件的不同方面。然后我们将讨论 JVM 内部使用的一些方法名称和类型的表示形式。接下来,我们将查看常量池——JVM 的“有用之物宝箱”——它在理解字节码的工作方式中起着重要作用。

4.3.1 介绍 javap

从查看一个类声明的方法到打印字节码,javap 可以用于许多有用的任务。让我们检查 javap 的最简单使用形式,这是应用在章节前面提到的类加载示例:

$ javap LoadSomeClasses.class
Compiled from "LoadSomeClasses.java"
public class LoadSomeClasses {
  public LoadSomeClasses();
  public static void main(java.lang.String[]);
}

内部类已经被编译成单独的类,因此我们还需要查看这一点:

$ javap LoadSomeClasses\$SadClassloader.class
Compiled from "LoadSomeClasses.java"
public class LoadSomeClasses$SadClassloader extends java.lang.ClassLoader {
  public LoadSomeClasses$SadClassloader();
  public java.lang.Class<?> findClass(java.lang.String) throws
    java.lang.ClassNotFoundException;
}

默认情况下,javap 显示公共、受保护的和默认访问(包受保护)可见的方法。-p 开关也显示私有方法和字段。

4.3.2 方法签名的内部形式

JVM 在内部使用的方法签名形式与 javap 显示的供人类阅读的形式略有不同。随着我们更深入地了解 JVM,你将更频繁地看到这些内部名称。如果你热衷于继续前进,你可以跳过,但请记住,这一节在这里——你可能需要从后面的章节中参考它。

在紧凑形式中,类型名称被压缩。例如,int 被表示为 I。这些紧凑形式有时被称为 类型描述符。一个完整的列表在表 4.1 中提供(包括 void,它不是一个类型,但出现在方法签名中)。

表 4.1 类型描述符

描述符 类型
B Byte
C Char(一个 16 位 Unicode 字符)
D Double
F Float
I Int
J 长整型
L<类型名>; 引用类型(例如,对于字符串,Ljava/lang/String;)
S 短整型
V 无值
Z 布尔型
[ 数组

在某些情况下,类型描述符可能比源代码中出现的类型名称更长(例如,Ljava/lang/Object;Object长,但类型描述符总是完全限定的,因此可以直接解析)。

javap提供了一个有用的开关-s,它会为你输出签名类型描述符,这样你就不必使用表格来计算它们。你可以使用javap的一个稍微高级的调用,来显示我们之前查看的一些方法的签名,如下所示:

$ javap -s LoadSomeClasses.class
Compiled from "LoadSomeClasses.java"
public class LoadSomeClasses {
  public LoadSomeClasses();
    descriptor: ()V

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
}

对于内部类:

$ javap -s LoadSomeClasses\$SadClassloader.class
Compiled from "LoadSomeClasses.java"
public class LoadSomeClasses$SadClassloader extends java.lang.ClassLoader {
  public LoadSomeClasses$SadClassloader();
    descriptor: ()V

  public java.lang.Class<?> findClass(java.lang.String) throws
    java.lang.ClassNotFoundException;
    descriptor: (Ljava/lang/String;)Ljava/lang/Class;
}

如你所见,方法签名中的每个类型都由一个类型描述符表示。

在下一节中,我们将看到类型描述符的另一种用途。这是类文件中的一个非常重要的部分——常量池。

4.3.3 常量池

常量池是一个提供便捷快捷方式访问类文件中其他(常量)元素的区域。如果你研究过像 C 或 Perl 这样的语言,它们明确使用了符号表,你可以将常量池视为与 JVM 概念有些相似。

让我们在下一个列表中使用一个非常简单的例子来演示常量池,这样我们就不至于被细节淹没。下一个列表显示了一个简单的“playpen”或“scratchpad”类。通过在run()中编写一小段代码,它可以快速测试 Java 语法特性或库。

列表 4.1 示例 playpen 类

package wgjd.ch04;

public class ScratchImpl {

    private static ScratchImpl inst = null;

    private ScratchImpl() {

    }

    private void run() {

    }

    public static void main(String[] args) {
        inst = new ScratchImpl();
        inst.run();
    }
}

要查看常量池中的信息,你可以使用javap -v。这将打印出大量的附加信息——远不止常量池本身——但让我们专注于下一个示例中的常量池条目:

#1 = Class #2 // wgjd/ch04/ScratchImpl

#2 = Utf8 wgjd/ch04/ScratchImpl

#3 = Class #4 // java/lang/Object

#4 = Utf8 java/lang/Object

#5 = Utf8 inst

#6 = Utf8 Lwgjd/ch04/ScratchImpl;

#7 = Utf8 <clinit>

#8 = Utf8 ()V

#9 = Utf8 Code

#10 = Fieldref #1.#11 // wgjd/ch04/ScratchImpl.inst:Lwgjd/ch04/ScratchImpl;

#11 = NameAndType #5:#6 // instance:Lwgjd/ch04/ScratchImpl;

#12 = Utf8 LineNumberTable

#13 = Utf8 LocalVariableTable

#14 = Utf8 <init>

#15 = Methodref #3.#16 // java/lang/Object."<init>":()V

#16 = NameAndType #14:#8 // "<init>":()V

#17 = Utf8 this

#18 = Utf8 run

#19 = Utf8 ([Ljava/lang/String;)V

#20 = Methodref #1.#21 // wgjd/ch04/ScratchImpl.run:()V

#21 = NameAndType #18:#8 // run:()V

#22 = Utf8 args

#23 = Utf8 [Ljava/lang/String;

#24 = Utf8 main

#25 = Methodref #1.#16 // wgjd/ch04/ScratchImpl."<init>":()V

#26 = Methodref #1.#27 // wgjd/ch04/ScratchImpl.run:([Ljava/lang/String;)V

#27 = NameAndType #18:#19 // run:([Ljava/lang/String;)V

#28 = Utf8 SourceFile

#29 = Utf8 ScratchImpl.java

如你所见,常量池条目是分类型的。它们也相互引用,例如,类型为Class的条目将引用类型为Utf8的条目。Utf8条目意味着一个字符串,因此Class条目指向的Utf8条目将是类的名称。

表 4.2 展示了常量池条目的可能集合。常量池中的条目有时会使用CONSTANT_前缀进行讨论,例如CONSTANT _Class。这是为了在它们可能被混淆的情况下,明确指出它们不是 Java 类型。

表 4.2 常量池条目

名称 描述
Class 一个类常量。指向类的名称(作为一个Utf8条目)。
Fieldref 定义一个字段。指向此字段的ClassNameAndType
Methodref 定义一个方法。指向此字段的ClassNameAndType
InterfaceMethodref 定义一个接口方法。指向此字段的ClassNameAndType
String 一个字符串常量。指向包含字符的Utf8条目。
Integer 一个整型常量(4 字节)。
Float 一个浮点常量(4 字节)。
Long 一个长常量(8 字节)。
Double 一个双精度浮点常量(8 字节)。
NameAndType 描述一个名称和类型对。类型指向包含类型描述符的Utf8
Utf8 表示Utf8编码字符的字节流。
InvokeDynamic invokedynamic机制的一部分——见第十七章。
MethodHandle invokedynamic机制的一部分——见第十七章。
MethodType invokedynamic机制的一部分——见第十七章。

使用这个表格,你可以查看来自 playpen 常量池的一个示例常量解析。考虑第 10 个条目的Fieldref。要解析一个字段,你需要一个名称、一个类型以及它所在类的信息:第 10 个条目有值#1.#11,这意味着类#1 中的常量#11。很容易检查#1 确实是一个类型为Class的常量,而#11 是一个NameAndType。#1 指的是ScratchImpl Java 类本身,而#11 指的是#5:#6——一个类型为ScratchImpl的变量inst。因此,总的来说,#10 指的是ScratchImpl类本身中的静态变量inst(你可能已经从上面的输出中猜到了)。

在类加载的验证步骤中,有一个步骤来检查类文件中的静态信息是否一致。前面的例子显示了运行时在加载新类时将执行的那种完整性检查。

我们已经讨论了一些类文件的基本结构。让我们继续下一个主题,我们将深入字节码的世界。了解源代码是如何转换为字节码的,将帮助你更好地理解你的代码将如何运行。反过来,这将导致当我们达到第六章及以后时,对平台功能的更多洞察。

4.4 字节码

字节码在我们之前的讨论中一直是一个幕后玩家。让我们首先回顾一下我们已经学到的关于它的知识:

  • 字节码是程序的中间表示,介于人类可读的源代码和机器码之间。

  • 字节码由javac从 Java 源代码文件生成。

  • 一些高级语言特性已经被编译掉了,不会出现在字节码中。例如,Java 的循环结构(forwhile等)已经消失,变成了字节码分支指令。

  • 每个操作码由一个字节表示(因此得名字节码)。

  • 字节码是一种抽象表示,不是“一个虚构 CPU 的机器码。”

  • 字节码可以进一步编译成机器码,通常是“即时”编译。

当解释字节码时,可能会出现一个轻微的“鸡生蛋,蛋生鸡”的问题。要完全理解正在发生的事情,你需要理解字节码及其执行的运行时环境。这是一个相当循环的依赖关系,因此为了解决这个问题,我们将首先深入探讨,并查看一个相对简单的例子。即使你在第一次阅读这个例子时并不理解所有内容,你可以在阅读了后续章节中关于字节码的更多内容后回过头来再看。

在示例之后,我们将提供一些关于运行时环境的背景信息,然后列出 JVM 的操作码,包括算术、调用、快捷形式等。最后,我们将通过一个基于字符串连接的例子来结束。让我们先看看如何从 .class 文件中检查字节码。

4.4.1 解析类

使用带有 -c 选项的 javap,你可以解析类。在我们的例子中,我们将使用之前遇到的 ScratchImpl 类。主要关注点将是检查构成方法的字节码。我们还将使用 -p 选项,这样我们就可以看到私有方法的字节码。

让我们逐节工作——javap 的输出中每个部分都有很多信息,很容易感到不知所措。首先,是标题。这里没有什么特别意外或令人兴奋的内容,如下所示:

$ javap -c -p wgjd/ch04/ScratchImpl.class

Compiled from "ScratchImpl.java"

public class wgjd.ch04.ScratchImpl extends java.lang.Object {
  private static wgjd.ch04.ScratchImpl inst;

接下来是静态块。这是放置变量初始化的地方,因此这表示将 inst 初始化为 null。敏锐的读者可能会猜测 putstatic 可能是一个将值放入静态字段的字节码:

static {};

Code:
  0: aconst_null
  1: putstatic #10 // Field inst:Lwgjd/ch04/ScratchImpl;
  4: return

上一段代码中的数字代表从方法开始到字节码流中的偏移量。所以字节 1 是 putstatic 操作码,字节 2 和 3 代表一个指向常量池的 16 位索引。在这种情况下,16 位索引的值是 10,这意味着值(在这种情况下,null)将被存储在常量池条目 #10 指示的字段中。从字节码流开始处的字节 4 是返回操作码——代码块的结束。

接下来是构造函数:

private wgjd.ch04.ScratchImpl();

Code:
  0: aload_0
  1: invokespecial #15 // Method java/lang/Object."<init>":()V
  4: return

记住,在 Java 中,void 构造函数将始终隐式调用超类构造函数。在这里,你可以从字节码中看到这一点——它是 invokespecial 指令。一般来说,任何方法调用都将转换为 JVM 的五个 invoke 指令之一,我们将在 4.4.7 节中遇到。

构造函数调用需要一个目标,这个目标由 aload_0 指令提供。它加载一个引用(一个地址)并使用快捷形式(我们将在 4.4.9 节中详细了解)来加载 0 号局部变量,即 this,当前对象。

run() 方法中基本上没有代码,因为这只是一个用于测试代码的临时类。此方法立即返回给调用者,并且不返回任何值(这是正确的,因为该方法返回 void):

private void run();

Code:
  0: return

在主方法中,我们初始化 inst 并进行一些对象创建。这展示了我们可以学会识别的一些非常常见的基本字节码模式:

public static void main(java.lang.String[]);

Code:
  0: new #1 // class wgjd/ch04/ScratchImpl
  3: dup
  4: invokespecial #21 // Method "<init>":()V

这种由三个字节码指令组成的模式——newdup 和调用名为 <init> 的方法的 invokespecial——始终代表创建一个新的实例。

new 操作码为新实例分配内存,并将对该实例的引用放置在栈顶。dup 操作码复制栈顶的引用(因此现在有两个副本)。为了完全创建对象,我们需要调用构造函数的主体。<init> 方法包含构造函数主体的代码,因此我们使用 invokespecial 调用该代码块。

当调用方法时,接收器对象的引用(如果有)以及任何方法参数都会从栈中消耗掉。这就是为什么我们需要先执行 dup 的原因——如果没有它,新分配的对象的唯一引用将被 invoke 消耗掉,并且在此之后将无法访问。

让我们看看主方法剩余的字节码:

  7: putstatic #10 // Field inst:Lwgjd/ch04/ScratchImpl;
 10: getstatic #10 // Field inst:Lwgjd/ch04/ScratchImpl;
 13: invokevirtual #22 // Method run:()V
 16: return

指令 7 保存已创建的单例实例的地址。指令 10 将其放回栈顶,以便指令 13 可以调用其方法。这是通过 invokevirtual 操作码完成的,该操作码执行 Java 的“标准”实例方法分派。

注意:一般来说,javac 生成的字节码是一种简单的表示——它并没有高度优化。总体策略是即时(JIT)编译器执行大量的优化,因此如果它们有一个相对简单和简单的起点,这会有所帮助。表达式“字节码应该是愚蠢的”描述了 JVM 实现者对从源语言生成的字节码的一般感觉。

invokevirtual 操作码包括检查对象继承层次结构中方法的覆盖情况。你可能觉得这有点奇怪,因为私有方法不能被覆盖。你可能猜测源代码编译器实际上可以发出 invokespecial 而不是 invokevirtual 用于私有方法。实际上,这曾经是情况,只是在最近的 Java 版本中才进行了更改。有关详细信息,请参阅第十七章关于嵌套成员的部分。

让我们继续讨论字节码所需的运行时环境。之后,我们将介绍我们将用来描述主要字节码指令家族的表格——加载/存储、算术、执行控制、方法调用和平台操作。然后我们将讨论操作码的可能快捷形式,然后再继续另一个示例。

4.4.2 运行时环境

理解 JVM 使用的堆栈机的操作对于理解字节码至关重要。JVM 不像硬件 CPU(如 x64 或 ARM 芯片)的一个最明显的原因是,JVM 没有处理器寄存器,而是使用堆栈进行所有计算和操作。这被称为评估堆栈(在 VM 规范中官方称为操作数堆栈,我们将交替使用这两个术语)。

评估堆栈是局部于方法的,当方法被调用时,会创建一个新的评估堆栈。当然,JVM 为每个 Java 线程都有一个调用堆栈,用于记录哪些方法已被执行(这构成了 Java 中堆栈追踪的基础)。保持每个线程调用堆栈和每个方法评估堆栈之间的区别是很重要的。

图 4.4 展示了如何使用评估堆栈对两个 int 常量执行加法操作。我们将在每个步骤下方显示等效的 JVM 字节码——我们将在本章后面遇到这个字节码,所以如果它现在看起来不完全合理,请不要担心。

图片

图 4.4 使用堆栈进行数值计算

正如我们在本章前面讨论的,当一个类被链接到运行环境时,其字节码将被检查,其中很多验证都归结为分析堆栈上的类型模式。

注意:只有当堆栈上的值具有正确的类型时,对堆栈上值的操作才有效。如果,例如,我们将一个对象的引用压入堆栈,然后试图将其作为 int 处理并对其进行算术运算,可能会发生未定义或不良的事情。

类加载的验证阶段执行广泛的检查,以确保新加载的类中的方法不会滥用堆栈。这防止了格式不正确(或故意邪恶)的类被系统接受并造成问题。

当一个方法运行时,它需要一个内存区域作为评估堆栈,用于计算新值。此外,每个运行的线程都需要一个调用堆栈,用于记录当前正在执行的方法(堆栈追踪会报告的堆栈)。在某些情况下,这两个堆栈将相互作用。考虑以下这段代码:

var numPets = 3 + petRecords.getNumberOfPets("Ben");

为了评估这一点,JVM 将3压入操作数堆栈。然后它需要调用一个方法来计算本有多少宠物。为此,它将接收对象(方法被调用的对象——在这个例子中是petRecords)压入评估堆栈,然后是任何调用参数。

然后使用invoke操作码之一调用getNumberOfPets()方法,这将导致控制权转移到被调用的方法,并且刚刚进入的方法将出现在调用堆栈中。但是,当 JVM 进入新方法时,它开始使用一个新的操作数堆栈,因此调用者的操作数堆栈上的值不可能影响在调用方法中计算的结果。

getNumberOfPets() 完成时,返回值会被放置在调用者的操作栈上,作为 getNumberOfPets() 从调用栈中移除的过程的一部分。然后加法操作将这两个值相加。

现在我们转向检查字节码。这是一个大主题,有很多特殊情况,所以我们将提供一个主要功能的概述,而不是完整的处理。

4.4.3 指令集介绍

JVM 字节码由一系列操作码(指令集),可能跟随着每个指令的一些参数组成。指令集期望在给定状态下找到栈,并转换栈,以便参数被移除,结果被放置在那里。

每个指令集由一个单字节值表示,因此最多存在 255 个可能的指令集。目前,只有大约 200 个被使用。这太多了,我们无法一一列出(但完整列表可以在 mng.bz/aJaX 找到)。幸运的是,大多数指令集都适合于几个基本家族之一,这些家族提供了类似的功能。我们将依次讨论每个家族,以帮助您了解它们。有些操作不适合任何家族,但它们出现的频率较低。

注意:JVM 不是一个纯粹面向对象的运行时环境。它了解原始类型。这在一些指令集家族中有所体现——一些基本的指令集类型(如 storeadd)需要根据它们作用的原始类型具有不同的变体。

指令集表有以下四个列:

  • 名称—这是指令集类型的一般名称。在许多情况下,几个相关的指令集会执行类似的功能。

  • 参数—指令集所接受的参数。以 i 开头的参数是(无符号)字节,用于在常量池或局部变量表中形成查找索引。

注意:为了创建更长的索引,字节会被连接起来,所以 i1, i2 表示通过位移和加法“将这些两个字节组合成一个 16 位索引”:((i1 << 8) + i2)

如果一个参数显示在括号中,这意味着并非所有指令集的形式都会使用它。

  • 栈布局—这显示了指令集执行前后栈的状态。括号中的元素表示并非所有指令集的形式都会使用它们,或者这些元素是可选的(例如,对于调用指令集)。

  • 描述—指令集执行的操作。

让我们通过检查 getfield 指令集的条目来查看表 4.3 中的一个示例行。这个指令集用于从对象字段中读取值。

getfield i1, i2 [obj] -> [val] 从栈顶对象获取常量池中指定索引的字段。

第一列给出了操作码的名称——getfield。下一列说明在字节码流中跟随操作码有两个参数。这些参数组合在一起形成一个 16 位的值,在常量池中查找以确定所需的字段(记住常量池索引总是 16 位)。栈布局列显示对象引用被字段的值所替换。

在操作过程中作为一部分移除对象实例的模式,仅仅是为了使字节码更加紧凑,避免进行大量繁琐的清理工作,并记得移除已经处理完毕的对象实例。

4.4.4 加载和存储操作码

加载和存储操作码系列关注于将值加载到栈上或从中检索。表 4.3 显示了加载/存储家族中的主要操作。

表 4.3 加载和存储操作码

名称 参数 栈布局 描述
load (i1) [] -> [val] 从局部变量加载一个值(原始类型或引用)到栈上。有快捷形式和类型特定的变体。
ldc i1 [] -> [val] 从池中加载一个常量到栈上。有类型特定的和宽变体。
store (i1) [val] -> [] 将值(原始类型或引用)存储在局部变量中,在此过程中从栈中移除。有快捷形式和类型特定的变体。
dup [val] -> [val, val] 复制栈顶的值。有变体形式。
getfield i1, i2 [obj] -> [val] 从栈顶对象获取指定常量池索引的字段。
putfield i1, i2 [obj, val] -> [] 将值放入对象在指定常量池索引的字段中。
getstatic i1, i2 [] -> [val] 从指定的常量池索引获取静态字段的值。
putstatic i1, i2 [val] -> [] 将值放入指定常量池索引的静态字段中。

如我们之前所述,存在多种形式的加载和存储指令。例如,dload操作码从局部变量将双精度值加载到栈上,而astore操作码从栈中弹出一个对象引用到局部变量中。

让我们快速举例说明getfieldputfield。这个简单的类:

public class Scratch {
    private int i;

    public Scratch() {
        i = 0;
    }

    public int getI() {
        return i;
    }

    public void setI(int i) {
        this.i = i;
    }
}

将反编译为 getter 和 setter:

public int getI();
    Code:
       0: aload_0
       1: getfield      #7                  // Field i:I
       4: ireturn

  public void setI(int);
    Code:
       0: aload_0
       1: iload_1
       2: putfield      #7                  // Field i:I
       5: return

这显示了在将临时变量传输到堆存储之前,栈是如何用来存储它们的。

4.4.5 算术操作码

这些操作码在栈上执行算术运算。它们从栈顶获取参数,并对它们执行所需的计算。参数(总是原始类型)必须完全匹配,但平台提供了丰富的操作码来将一种原始类型转换为另一种类型。表 4.4 显示了基本的算术运算。

表 4.4 算术操作码

名称 参数 栈布局 描述
add [val1, val2] -> [res] 将栈顶的两个值(必须是相同原始类型的值)相加,并将结果存储在栈上。具有快捷形式和特定类型的变体。
sub [val1, val2] -> [res] 从栈顶减去两个值(相同原始类型的值)。具有快捷形式和特定类型的变体。
div [val1, val2] -> [res] 将栈顶的两个值(相同原始类型的值)相除。具有快捷形式和特定类型的变体。
mul [val1, val2] -> [res] 将栈顶的两个值(相同原始类型的值)相乘。具有快捷形式和特定类型的变体。
(转换) [value] -> [res] 将值从一种原始类型转换为另一种类型。具有对应于每种可能转换的形式。

转换操作码具有非常短的名字,例如 i2d 用于 intdouble 的转换。特别是,单词 cast 不出现在名称中,这就是为什么它在表中用括号括起来的原因。

4.4.6 执行流程控制操作码

如前所述,高级语言的控制结构不在 JVM 字节码中。相反,流程控制由少量原始操作处理,这些操作在表 4.5 中显示。

表 4.5 执行控制操作码

名称 参数 栈布局 描述
if b1, b2 [val1, val2] -> [] 或 [val1] -> [] 如果特定条件匹配,则跳转到指定的分支偏移量。
goto b1, b2 [] -> [] 无条件跳转到分支偏移量。有宽形式。
tableswitch [index] -> [] 用于实现 switch。
lookupswitch [key] -> [] 用于实现 switch。

与用于查找常量的索引字节类似,b1, b2 参数用于在此方法中构建一个字节码位置以跳转。它们不能用于跳转到方法外部——这在类加载时进行检查,会导致类验证失败。

if 操作码系列比你想象的要大一些——它有超过 15 条指令来处理各种源代码可能性(例如,数值比较、引用相等)。

注意:if 操作码系列还包含两个已弃用的指令,jsrret,这些指令不再由 javac 生成,并且在现代 Java 版本中是非法的。

goto 指令的宽形式(goto_w)需要 4 个字节的参数并构建一个偏移量,该偏移量可以大于 64 KB。这并不常见,因为它只会应用于非常大非常大的方法(并且这样的方法有其他问题,例如太大而不能被 JIT 编译)。还有 ldc_w,它可以用来引用非常大的常量池。

4.4.7 调用操作码

调用操作码包括四个用于处理一般方法调用的操作码,以及不寻常的invokedynamic操作码,该操作码是在 Java 7 中添加的。我们将在第十七章中更详细地讨论这个特殊情况。五个方法调用操作码如表 4.6 所示。

表 4.6 调用操作码

名称 参数 堆栈布局 描述
invokestatic i1, i2 [(val1, ...)] -> [] 调用一个静态方法。
invokevirtual i1, i2 [obj, (val1, ...)] -> [] 调用一个“正常”的实例方法。
调用接口 i1, i2, count, 0 [obj, (val1, ...)] -> [] 调用一个接口方法。
invokespecial i1, i2 [obj, (val1, ...)] -> [] 调用一个“特殊”的实例方法,例如构造函数。
invokedynamic i1, i2, 0, 0 [val1, ...] -> [] 动态调用;请参阅第十七章。

通过一个扩展的例子,我们可以最容易地看到这些操作码之间的区别,如下所示:

long time = System.currentTimeMillis();

// This explicit typing is deliberate... read on
HashMap<String, String> hm = new HashMap<>();
hm.put("now", "bar");

Map<String, String> m = hm;
m.put("foo", "baz");

让我们使用javap -c来查看这个的字节码:

Code:
       0: invokestatic  #2  // Method java/lang/System.currentTimeMillis:()J
       3: lstore_1
       4: new           #3 // class java/util/HashMap
       7: dup
       8: invokespecial #4 // Method java/util/HashMap."<init>":()V
      11: astore_3
      12: aload_3
      13: ldc           #5 // String now
      15: ldc           #6 // String bar
      17: invokevirtual #7 // Method java/util/HashMap.put:(
                           //Ljava/lang/Object;Ljava/lang/Object;)
                           //Ljava/lang/Object;
      20: pop
      21: aload_3
      22: astore        4
      24: aload         4
      26: ldc           #8 // String foo
      28: ldc           #9 // String baz
      30: invokeinterface #10,  3 // InterfaceMethod java/util/Map.put:(
                                  //Ljava/lang/Object;Ljava/lang/Object;)
                                  //Ljava/lang/Object;
      35: pop

正如我们之前讨论的那样,Java 方法调用实际上被转换成了几种可能的invoke*字节码之一。让我们更仔细地看看:

       0: invokestatic  #2 // Method java/lang/System.currentTimeMillis:()J
       3: lstore_1

System.currentTimeMillis()的静态调用被转换成了在字节码中位置为 0 的invokestatic。这个方法没有参数,因此在调用分发之前不需要将任何内容加载到评估堆栈上。

接下来,字节流中出现了两个字节00 02。这些字节组合成一个 16 位数字,用作常量池的偏移量。

反编译器方便地包含了一条注释,告知用户方法偏移量#2对应的是哪个方法。在这个例子中,正如预期的那样,它是System .currentTimeMillis()方法。

返回时,调用结果被放置在堆栈上,在偏移量 3 处,我们看到单个无参数操作码lstore_1,它将这个返回值保存到局部变量 1 中。

当然,人类读者能够看到变量time从未再次被使用。然而,javac的设计目标之一是尽可能忠实地表示 Java 源代码的内容,无论其是否有意义。因此,System.currentTimeMillis()的返回值被存储,即使在此之后的程序中没有被使用。

这就是“愚蠢的字节码”在起作用:记住,从平台的角度来看,类文件格式是真正重要的编译器的输入格式——即时编译器:

       4: new           #3 // class java/util/HashMap
       7: dup
       8: invokespecial #4 // Method java/util/HashMap."<init>":()V
      11: astore_3
      12: aload_3
      13: ldc           #5 // String now
      15: ldc           #6 // String bar
      17: invokevirtual #7 // Method java/util/HashMap.put:(
                          //Ljava/lang/Object;Ljava/lang/Object;)
                          //Ljava/lang/Object;
      20: pop

字节码 4 到 10 创建了一个新的HashMap实例,在指令 11 将它的副本保存到局部变量中之前。接下来,指令 12 到 16 使用HashMap对象和调用put()的参数设置堆栈。实际的put()方法调用由指令 17 到 19 执行。

这次使用的调用操作码是invokevirtual,因为局部变量的静态类型被声明为HashMap——一个类类型。我们将在稍后看到如果局部变量被声明为Map会发生什么。

实例方法调用与静态方法调用不同,因为静态调用没有方法被调用的实例(有时称为接收者对象)。

注意:在字节码中,实例调用必须通过将接收者和任何调用参数放置在评估堆栈上,然后发出调用指令来设置。

在这种情况下,put() 的返回值没有被使用,所以指令 20 丢弃它,如下所示:

      21: aload_3
      22: astore        4
      24: aload         4
      26: ldc           #8 // String foo
      28: ldc           #9 // String baz
      30: invokeinterface #10,  3 //InterfaceMethod java/util/Map.put:(
                                  //Ljava/lang/Object;Ljava/lang/Object;)
                                  //Ljava/lang/Object;
      35: pop

从 21 到 25 的字节序列一开始看起来相当奇怪。我们在指令 11 时创建并保存到局部变量 3 的 HashMap 实例现在被重新加载到堆栈上,并且其引用的副本被保存到局部变量 4。这个过程将其从堆栈中移除,因此在使用之前必须重新加载(从变量 4)。这种重新排列发生是因为在原始的 Java 代码中,我们创建了一个额外的局部变量(类型为 Map 而不是 HashMap),尽管它始终引用与原始变量相同的对象。这是字节码尽可能接近原始源代码的另一个例子。

在堆栈和变量重新排列之后,要在指令 26 到 29 之间加载要放入映射中的值。在堆栈准备好接收者和参数后,put() 的调用在指令 30 处分发。这次,指令是 invokeinterface,尽管实际上正在调用的是完全相同的方法。这是因为 Java 本地变量是 Map 类型——一个接口类型。再次,put() 的返回值通过指令 35 的 pop 被丢弃。

除了知道哪些 Java 方法调用会转换为哪些操作外,你还应该注意关于调用指令的一些其他细节。首先,invokeinterface 有额外的参数。这些参数是出于历史和向后兼容性原因而存在的,并且现在不再使用。invokedynamic 上的两个额外零是出于向前兼容性原因。

另一个重要点是常规和特殊实例方法调用的区别。常规调用是 虚拟的,这意味着确切要调用的方法是在运行时使用标准的 Java 覆盖规则查找的。

然而,存在一些特殊情况,包括对超类方法的调用。在这些情况下,你不想触发覆盖规则,因此需要一个不同的调用指令来允许这种情况。这就是为什么指令集需要为没有覆盖机制的方法调用提供一个指令——invokespecial——它确切地指示将被调用的方法。

4.4.8 平台操作指令集

指令集操作平台家族包括新的指令集,用于分配新的对象实例,以及与线程相关的指令集,例如 monitorentermonitorexit。这个家族的详细信息可以在表 4.7 中查看。

表 4.7 平台指令集

名称 参数 堆栈布局 描述
new i1, i2 [] -> [obj] 为指定索引处常量指定的类型的新对象分配内存。
monitorenter [obj] -> [] 锁定一个对象。参见第五章。
monitorexit [obj] -> [] 解锁一个对象。参见第五章。

平台操作码用于控制对象生命周期的某些方面,例如创建新对象和锁定它们。重要的是要注意,新操作码只分配存储空间。对对象构造的高级概念也包括在构造函数中运行代码。

在字节码级别,构造函数被转换为一个具有特殊名称的方法——<init>。这个方法不能从用户 Java 代码中调用,但可以从字节码中调用。这导致了与对象创建直接对应的独特字节码模式——一个new后面跟着一个dup,然后是一个invokespecial来调用<init>方法,正如我们之前看到的。

monitorentermonitorexit字节码对应于synchronized块的开始和结束。

4.4.9 简化操作码形式

许多操作码都有简化的形式,以节省一些字节。一般模式是某些局部变量会被比其他变量更频繁地访问,因此有一个特殊的操作码表示“直接在局部变量上执行通用操作”是有意义的,而不是必须指定局部变量作为参数。这导致了在加载/存储家族中的操作码,如aload_0dstore_2,它们比等效的字节序列aload 00dstore 02短 1 个字节。

注意:节省的一个字节可能看起来不多,但整个类中加起来就很重要了。Java 最初的使用案例是 applet,它们通常通过拨号调制解调器下载,速度为每秒 28.8 千比特。在这种带宽速度下,尽可能节省字节是很重要的。

要成为一个真正的经验丰富的 Java 开发者,你应该对你的某些自己的类运行javap,并学会识别常见的字节码模式。现在,带着对字节码的简要介绍,让我们继续解决我们的下一个主题——反射。

4.5 反射

一个经验丰富的 Java 开发者应该掌握的关键技术之一是反射。这是一个极其强大的功能,但许多开发者最初都会觉得它很陌生,因为它与大多数 Java 开发者对代码的看法不同。

反射是查询或内省对象的能力,在运行时发现(并使用)它们的特性。它可以被视为在上下文中不同的几件事情:

  • 一种编程语言 API

  • 一种编程风格或技术

  • 一种启用该技术的运行时机制

  • 语言类型系统的一个属性

在面向对象系统中,反射本质上是一种思想,即编程环境可以将程序的类型和方法表示为对象。这只有在具有支持此功能的运行时的语言中才可能,并且这是语言的一个基本动态特性。

在使用反射式编程风格时,可以在完全不使用它们的静态类型的情况下操作对象。这似乎是一个倒退,但如果我们可以不依赖于它们的静态类型来处理对象,那么这意味着我们可以构建可以与 任何 类型一起工作的库、框架和工具——包括在我们编写代码时甚至不存在的那种类型。

当 Java 还是一个年轻的语言时,反射是它带给主流的几个关键技术创新之一。尽管其他语言(特别是 Smalltalk)早在很久以前就引入了它,但在 Java 发布时,它并不是许多语言中常见的部分。

4.5.1 反射简介

反射的抽象描述常常显得令人困惑或难以理解。让我们通过 JShell 中的简单示例来尝试获得对反射更具体的概念:

jshell> Object o = new Object();
o ==> java.lang.Object@a67c67e

jshell> Class<?> clz = o.getClass();
clz ==> class java.lang.Object

这是我们对反射的第一瞥——Object 类型的类对象。实际上,clz 的实际类型是 Class<Object>,但当我们从类加载或 getClass() 获取类对象时,我们必须使用泛型中的未知类型 ? 来处理它,如下所示:

jshell> Class<Object> clz = Object.class;
clz ==> class java.lang.Object

jshell> Class<Object> clz = o.getClass();
|  Error:
|  incompatible types: java.lang.Class<capture#1 of ? extends
  java.lang.Object> cannot be converted to java.lang.Class<java.lang.Object>
|  Class<Object> clz = o.getClass();
|                      ^----------^

这是因为反射是一个动态的运行时机制,真正的 Class<Object> 类型对源代码编译器来说是未知的。这个过程给使用反射的工作引入了不可减少的额外复杂性,因为我们不能依赖 Java 类型系统来帮助我们很多。另一方面,这种动态性质是反射的关键点——如果我们不知道在编译时某个类型是什么,并且必须以非常通用的方式处理它,我们可以利用这种灵活性来构建一个开放、可扩展的系统。

注意反射产生了一个根本上是开放的系统,正如我们在第二章中看到的,这可能会与 Java 模块试图引入平台的更封装的系统发生冲突。

许多熟悉的框架和开发工具严重依赖反射来实现其功能,例如调试器和代码浏览器。插件架构、交互式环境和 REPL 也广泛使用反射。事实上,如果没有反射子系统,JShell 本身也无法构建。让我们利用这一点,使用 JShell 探索反射的一些关键特性,如下所示:

jshell> class Pet {
   ...>   public void feed() {
   ...>     System.out.println("Feed the pet");
   ...>   }
   ...> }
|  created class Pet

jshell> var clz = Pet.class;
clz ==> class Pet

现在我们有一个代表 Pet 类型的对象,我们可以用它来进行其他操作,例如创建一个新的实例,如下所示:

jshell> Object o = clz.newInstance();
o ==> Pet@66480dd7

我们遇到的问题是newInstance()返回Object,这并不是一个非常有用的类型。我们当然可以将o回显式地转换为Pet,但这要求我们事先知道我们正在处理什么类型,这几乎抵消了反射动态特性的意义。所以让我们试试别的:

jshell> import java.lang.reflect.Method;

jshell> Method m = clz.getMethod("feed", new Class[0]);
m ==> public void Pet.feed()

现在我们有一个代表feed()方法的对象,但它将其表示为抽象元数据——它没有附加到任何特定的实例上。

对于代表方法的对象,自然的事情就是调用它。java.lang.reflect.Method类定义了一个invoke()方法,该方法的效果是调用Method对象代表的方法。

注意:在 JShell 中工作,我们避免了大量的异常处理代码。当编写使用反射的常规 Java 代码时,你必须以某种方式处理可能的异常类型。

为了使此调用成功,我们必须提供正确数量和类型的参数。此参数列表必须包括被反射调用的方法所在的接收器对象(假设该方法是一个实例方法)。在我们的简单示例中,它看起来是这样的:

jshell> Object ret = m.invoke(o);
Feed the pet
ret ==> null                        ❶

❶ 调用返回 null,因为 feed()方法实际上是 void。

除了Method对象外,反射还提供了代表 Java 类型系统和语言中其他基本概念的对象,例如字段、注解和构造函数。这些类位于java.lang.reflect包中,其中一些类(如Constructor)是泛型类型。

反射子系统也必须升级以处理模块。正如类和方法可以被反射处理一样,因此需要有一个用于处理模块的反射 API。关键类可能是出人意料的是java.lang.Module,我们可以直接从一个Class对象访问它,如下所示:

var module = String.class.getModule();
var descriptor = module.getDescriptor();

模块的描述符是ModuleDescriptor类型,它提供了对模块元数据的只读视图——基本上等同于module-info .class的内容。

在新的反射 API 中,也支持动态功能,例如模块的发现。这是通过ModuleFinder等接口实现的,但如何详细地使用模块系统进行反射操作超出了本书的范围——感兴趣的读者应参考 Nicolai Parlog 的书籍第十二章,The Java Module System(Manning,2019),mng.bz/gwGG

4.5.2 结合类加载和反射

让我们看看一个结合类加载和反射的例子。我们不需要遵循通常的findClass()loadClass()协议的完整类加载器。相反,我们将仅通过子类化ClassLoader来访问受保护的defineClass()方法。

主要方法接受一个文件名列表,如果它们是 Java 类,它将使用反射依次访问每个方法并检测它是否是本地方法,如下所示:

public class NativeMethodChecker {

    public static class EasyLoader extends ClassLoader {
        public EasyLoader() {
            super(EasyLoader.class.getClassLoader());
        }

        public Class<?> loadFromDisk(String fName) throws IOException {
            var b = Files.readAllBytes(Path.of(fName));
            return defineClass(null, b, 0, b.length);
        }
    }

    public static void main(String[] args) {
        if (args.length > 0) {
            var loader = new EasyLoader();
            for (var file : args) {
                System.out.println(file +" ::");
                try {
                    var clazz = loader.loadFromDisk(file);
                    for (var m : clazz.getMethods()) {
                        if (Modifier.isNative(m.getModifiers())) {
                            System.out.println(m.getName());
                        }
                    }
                } catch (IOException | ClassFormatError x) {
                    System.out.println("Not a class file");
                }
            }
        }
    }
}

这些类型的例子可以很有趣,可以探索 Java 平台的动态性,并了解反射 API 是如何工作的。然而,一个扎实的 Java 开发者应该意识到,在反射工作时可能会遇到的限制和偶尔的挫败感。

4.5.3 反射的问题

反射 API 自 Java 平台 1.1 版本(1996 年)以来就是其一部分,自从它出现以来,已经出现了一些问题和弱点。以下是一些不便之处:

  • 这是一个非常古老的 API,到处都是数组类型(它早于 Java 集合)。

  • 确定调用哪个方法重载是痛苦的。

  • API 提供了两种不同的方法,getMethod()getDeclaredMethod(),用于反射访问方法。

  • API 提供了setAccessible()方法,可以用来忽略访问控制。

  • 反射调用中的异常处理很复杂——检查异常被提升为运行时异常。

  • 为了进行传递或返回原始类型的反射调用,需要装箱和拆箱。

  • 原始类型需要占位符类对象,例如,int.class,它实际上是Class<Integer>类型。

  • void方法需要引入java.lang.Void类型。

除了 API 中的各种尴尬角落,Java 反射由于几个原因(包括对 JVM 的 JIT 编译器的友好性不足)一直遭受性能不佳的困扰。

注意:解决反射调用性能问题是在第十七章中将要介绍的 Method Handles API 添加的主要原因之一。

反射还有一个最终问题,这或许更是一个哲学问题(或反模式):开发者们在 Java 进阶过程中,经常会遇到反射作为第一个真正高级的技术。因此,它可能会被过度使用,或者变成一种金锤技术——用于实现过度灵活的系统,或者显示一个实际上并不需要的内部迷你框架(有时称为内部框架反模式)。这样的系统通常非常可配置,但代价是将领域模型编码到配置中,而不是直接在领域类型中。

反射是一项伟大的技术,一个扎实的 Java 开发者应该在他们的工具箱中拥有它,但它并不适合每种情况,大多数开发者可能只需要少量使用它。

摘要

  • 类文件格式和类加载是 JVM 操作的核心。对于任何希望在 VM 上运行的语言来说,它们是必不可少的。

  • 类加载的各个阶段在运行时既提供了安全特性,也提供了性能特性。

  • JVM 字节码被组织成具有相关功能的家庭。

  • 使用javap反汇编类文件可以帮助你理解底层。

  • 反射是一个主要特性,非常强大。

5 Java 并发基础

本章涵盖

  • 并发理论

  • 块结构并发

  • 同步

  • Java 内存模型(JMM)

  • 字节码中的并发支持

Java 有两个,主要独立的并发 API:较老的 API,通常被称为块结构并发基于同步的并发,甚至“经典并发”,以及较新的 API,通常通过其 Java 包名java.util.concurrent来引用。

在这本书中,我们将讨论这两种方法。在本章中,我们将通过探讨这两种方法中的第一种方法开始我们的旅程。之后,在下一章中,我们将介绍 java.util.concurrent。稍后,我们将在第十六章“高级并发编程”中回到并发主题,该章节讨论了高级技术、非 Java JVM 语言中的并发以及并发与函数式编程之间的相互作用。

让我们开始吧,了解经典的并发方法。这是 Java 5 之前可用的唯一 API。正如你可能从其备选名称“基于同步的并发”中猜到的,这是一个内置到平台中的语言级 API,依赖于 synchronizedvolatile 关键字。

这是一个低级 API,可能有些难以处理,但它非常值得理解。它为书中后续章节中解释其他并发类型和方面提供了坚实的基础。

事实上,如果没有至少对我们在本章中将要介绍的底层 API 和概念有实际了解,正确推理其他形式的并发是非常困难的。当我们遇到相关主题时,我们还将引入足够的理论来阐明我们在书中稍后讨论的其他并发观点,包括当我们遇到非 Java 语言中的并发时。

为了理解 Java 的并发编程方法,我们将从讨论一些理论开始。之后,我们将讨论“设计力量”在设计实现系统中的影响。我们将讨论其中两个最重要的力量,安全性活性,并提及一些其他的力量。

一个重要的部分(也是本章中最长的一个部分)是关于块结构并发和底层线程 API 的细节。我们将通过讨论 Java 内存模型(JMM)来结束本章,然后使用我们在第四章中学到的字节码技术来理解并发 Java 编程中一些常见复杂性的真正来源。

5.1 并发理论入门

在我们遇到一些基本理论之前,让我们用一个警示故事开始我们的并发之旅。

5.1.1 但我已经知道关于线程的知识

这可能是开发者可能犯的最常见(并且可能致命)的错误之一:认为对 ThreadRunnable 和 Java 并发机制的语言级基本原语有所了解就足以成为一个合格的并发代码开发者。实际上,并发是一个很大的主题,良好的多线程开发困难重重,即使是经验丰富的开发者也会遇到问题。

事实上,并发领域目前正在进行大量的活跃研究——这至少已经持续了 5-10 年,并且没有减缓的迹象。这些创新可能会对 Java 以及您在职业生涯中使用的其他语言产生影响。

在本书的第一版中,我们提出了以下观点:“如果我们选择一个可能在五年内彻底改变行业实践的计算机计算基本领域,那么这个领域将是并发。”历史已经证实了这一观点,我们感到很舒服地将这个预测向前推进——接下来的五年将继续强调编程景观中现在已经是并发不同方法的重点。

因此,而不是试图成为并发编程每个方面的权威指南,本章的目标是让您了解解释 Java 并发工作方式的底层平台机制。我们还将涵盖足够的通用并发理论,以便您能够理解涉及的问题,并教授您在正确实现并发时所需的知识和难度。首先,我们将讨论每个扎实的 Java 开发者都应该了解的关于硬件和并发最重要的理论限制。

5.1.2 硬件

让我们从并发和多线程的一些基本事实开始:

  • 并发编程在本质上关乎性能。

  • 如果您运行的系统性能足够,以至于串行算法也能工作,那么实现并发算法基本上没有很好的理由。

  • 现代计算机系统具有多个处理核心——即使是现在的手机也有两个或四个核心。

  • 所有 Java 程序都是多线程的,即使那些只有单个应用程序线程的程序也是如此。

这最后一个观点是正确的,因为 JVM 本身就是一个多线程的二进制文件,可以使用多个核心(例如,用于 JIT 编译或垃圾回收)。此外,标准库还包括使用 运行时管理的并发 来实现某些执行任务的并发算法的 API。

注意:Java 应用程序仅通过升级其运行的 JVM,就有可能因为运行时的性能改进而运行得更快。

关于硬件的更详细讨论将在第七章进行,但这些基本事实是如此基础且与并发编程如此相关,我们希望立即介绍它们。

现在,让我们来认识一下Amdahl 定律,它是以早期 IBM 计算机科学家、Gene Amdahl 的名字命名的,有时被称为“大型机之父”。

5.1.3 Amdahl 定律

这是一个简单的、现成的模型,用于推理在多个执行单元之间共享工作的效率。在这个模型中,执行单元是抽象的,所以你可以把它们看作是线程,但它们也可能是进程,或者任何其他能够执行工作的实体。

注意:Amdahl 定律的设置或后果的细节并不取决于工作是如何完成的,或者执行单元的确切性质,或者计算系统是如何实现的。

基本前提是我们有一个可以拆分成更小处理单元的单个任务。这使我们能够使用多个执行单元来加快完成工作的时间。

因此,如果我们有N个处理器(或线程来完成工作),那么我们可能会天真地期望经过的时间是T1 / N(如果T1是单个处理器完成工作所需的时间)。在这个模型中,我们可以通过添加执行单元并增加N来尽快完成工作。

然而,将工作拆分并非没有代价!在任务的细分和重组过程中,会涉及到一个(希望是小的)额外开销。让我们假设这个通信开销(有时称为计算的串行部分)是一个相当于几个百分比的额外开销,我们可以用数字s(0 < s < 1)来表示它。因此,s的典型值可能是0.05(或 5%,您喜欢哪种表达方式都可以)。这意味着任务将始终至少需要s * T1的时间来完成——无论我们投入多少处理单元。

这假设s不依赖于N,当然,但在实践中,s所代表的任务拆分可能会变得更加复杂,并且随着N的增加需要更多的时间。很难想象一个系统架构,其中s随着N的增加而减少。因此,“s是常数”的简单假设通常被理解为一种最佳情况情景。

因此,思考 Amdahl 定律的最简单方法是:如果s在 0 和 1 之间,那么可以达到的最大加速比是1 / s。这个结果有些令人沮丧——这意味着如果通信开销仅为 2%,那么可以实现的最高加速比(即使有成千上万的处理器以全速工作)也只有 50 倍。

Amdahl 定律有一个稍微复杂一些的公式,表示如下:

T(N) = s + (1/N) * (T1 - s)

这在图 5.1 中可以直观地看到。注意,x轴是对数刻度——在线性刻度表示中,收敛到1 / s将非常难以看到。

图片

图 5.1 Amdahl 定律

在硬件和第一个非常简单的并发模型的基础上设定了场景后,让我们深入了解 Java 如何处理线程的具体细节。

5.1.4 解释 Java 的线程模型

Java 的线程模型基于以下两个基本概念:

  • 默认情况下可见的共享可变状态

  • 操作系统执行的抢占式线程调度

让我们考虑这些想法最重要的几个方面:

  • 对象可以很容易地在进程内的所有线程之间共享。

  • 对象可以被任何持有其引用的线程所改变(“变异”)。

  • 线程调度器(操作系统)可以在任何时候在核心之间切换线程,或多或少。

  • 方法必须在运行时能够被替换出来(否则,一个无限循环的方法将永远占用 CPU)。

  • 然而,这存在不可预测的线程交换风险,导致方法“半完成”和对象处于不一致的状态。

  • 对象可以被锁定以保护易受攻击的数据。

最后一点至关重要——没有它,一个线程中做出的更改在其他线程中可能无法正确看到。在 Java 中,通过核心语言中的synchronized关键字提供了锁定对象的能力。

注意:从技术上讲,Java 为每个对象提供了监视器,它将锁(也称为互斥)与等待某个条件变为真的能力结合起来。

Java 基于线程和锁的并发性非常低级,通常很难处理。为了应对这种情况,引入了一套名为java.util.concurrent的并发库,这个名字来源于新类所在的新 Java 包。这提供了一套编写并发代码的工具,许多程序员发现这些工具比经典的块结构并发原语更容易使用。我们将在下一章讨论java.util.concurrent,现在我们将专注于语言级别的 API。

5.1.5 得到的教训

Java 是第一个主流支持多线程编程的语言。这在当时是一个巨大的进步,但现在,15 年后,我们关于如何编写并发代码的了解更多了。

结果表明,Java 的一些初始设计决策对于大多数程序员来说相当难以处理。这是不幸的,因为硬件的趋势正在向多核处理器发展,而利用这些核心的唯一好方法就是使用并发代码。在本章中,我们将讨论并发代码的一些困难。现代处理器自然需要并发编程的主题在第七章中进行了详细讨论,其中我们讨论了性能。

随着开发者对编写并发代码经验的增加,他们发现自己遇到了一些反复出现且对其系统重要的关注点。我们把这些关注点称为设计力。它们是存在于(并且通常相互冲突)实际并发面向对象系统设计中的高级概念。在接下来的几节中,我们将花一些时间来探讨一些最重要的这些力。

5.2 设计概念

下列最重要的设计力是由 Doug Lea 在他进行标志性的工作生产java.util.concurrent时编目:

  • 安全性(也称为并发类型安全

  • 活跃性

  • 性能

  • 可复用性

让我们现在来看看这些力。

5.2.1 安全性和并发类型安全

安全性是确保对象实例在同时发生的任何其他操作中保持自洽性的问题。如果一个对象系统具有这种属性,那么它就被说成是安全并发类型安全

如你所猜测的,思考并发的一种方式是将它视为对常规对象建模和类型安全概念的扩展。在非并发代码中,你希望确保无论在对象上调用哪些公共方法,方法结束时对象都处于一个定义良好且一致的状态。通常的做法是将对象的所有状态保持为私有,并公开一个仅以对设计域有意义的方修改对象状态的公共 API。

并发类型安全与对象类型安全的基本概念相同,但应用于一个更为复杂的场景,即其他线程可能同时在不同的 CPU 核心上操作相同的对象。例如,考虑以下简单的类:

public class StringStack {
    private String[] values = new String[16];
    private int current = 0;

    public boolean push(String s) {
        // Exception handling elided
        if (current < values.length) {
            values[current] = s;
            current = current + 1;
        }
        return false;
    }

    public String pop() {
        if (current < 1) {
            return null;
        }
        current = current - 1;
        return values[current];
    }
}

当由单线程客户端代码使用时,这是没有问题的。然而,抢占式线程调度可能会引起问题。例如,在代码的此点可能会发生执行线程之间的上下文切换:

public boolean push(String s) {
        if (current < values.length) {
            values[current] = s;
            // .... context switch here     ❶
            current = current + 1;
        }
        return false;
    }

❶ 对象被留在不一致和不正确的状态。

如果从另一个线程查看该对象,状态的一部分(values)已经被更新,而另一部分(current)则没有。探索和解决这个问题是本章的主要主题。

通常,一种确保安全性的策略是永远不要在非私有方法中返回不一致的状态,并且永远不要在不一致的状态下调用任何非私有方法(当然,更不要调用任何其他对象的方法)。如果这种做法与一种在对象不一致时保护对象的方式(如同步锁或临界区)相结合,则可以保证系统是安全的。

5.2.2 活跃性

一个活跃的系统是指每个尝试的活动最终要么进展,要么失败。一个不活跃的系统基本上是停滞的——它既不会向成功进展,也不会失败。

定义中的关键字是最终——暂时性故障(即使它不是理想的,孤立地看也不是那么糟糕)和永久性故障之间有一个区别。暂时性故障可能由许多潜在问题引起,例如

  • 锁定或等待获取锁

  • 等待输入(例如,网络 I/O)

  • 资源的暂时性故障

  • 可用的 CPU 时间不足以运行线程

永久性故障可能由多种原因引起。以下是一些最常见的原因:

  • 死锁

  • 不可恢复的资源问题(例如,如果网络文件系统 [NFS] 不可用)

  • 丢失信号

我们将在本章后面讨论锁定和这些问题中的几个,尽管你可能已经熟悉其中的一些或全部。

5.2.3 性能

系统的性能可以通过多种方式量化。在第七章中,我们将讨论性能分析和调优技术,并介绍一些你应该了解的其他指标。现在,将性能视为系统在给定资源下能完成多少工作的度量。

5.2.4 可重用性

可重用性形成第四个设计力,因为它并没有被其他任何考虑所涵盖。一个为易于重用而设计的并发系统有时是非常理想的,尽管这并不总是容易实现。一种方法是在可重用工具箱(如java.util.concurrent)上构建不可重用的应用程序代码。

5.2.5 如何以及为什么力会冲突?

设计力往往是相互对立的,这种紧张关系可以被视为设计良好的并发系统困难的一个核心原因,如下所述:

  • 安全性与活跃性相对立——安全性是确保坏事不会发生,而活跃性要求取得进展。

  • 可重用系统倾向于暴露其内部结构,这可能会引起安全问题。

  • 一个天真编写的安全系统通常不会非常高效,因为它通常依赖于大量使用锁定来提供安全保证。

你最终应该努力达到的平衡是代码足够灵活,可以用于广泛的问题,足够封闭以确保安全,同时仍然合理活跃和高效。这是一项相当艰巨的任务,但幸运的是,一些实用的技术可以帮助实现这一点。以下是一些最常见的,按有用性大致排序:

  1. 尽可能限制每个子系统的外部通信。数据隐藏是帮助确保安全的有力工具。

  2. 尽可能使每个子系统的内部结构尽可能确定。例如,设计时考虑每个子系统中的线程和对象,即使子系统将以并发和非确定性的方式进行交互。

  3. 应用客户端应用程序必须遵守的策略方法。这种技术很强大,但依赖于用户应用程序的合作,如果表现不佳的应用程序不遵守规则,调试可能会很困难。

  4. 记录所需的行为。这是所有替代方案中最弱的,但有时如果代码要在非常通用的环境中部署,这是必要的。

开发者应该了解这些可能的安全机制中的每一个,并应使用最强可能的技巧,同时意识到在某些情况下,可能只能使用较弱的机制。

5.2.6 开销来源

并发系统的许多方面都可能对固有的开销做出贡献:

  • 监视器(即,锁和条件变量)

  • 上下文切换次数

  • 线程数量

  • 调度

  • 内存局部性

  • 算法设计

这应该成为你心中的清单基础。在开发并发代码时,你应该确保你已经考虑了清单上的每一项。

特别是,这些中的最后一个是算法设计,这是开发者可以真正脱颖而出的一块领域,因为了解算法设计将使你在任何语言中都能成为更好的程序员。

两本标准文本(作者强烈推荐)是 Cormen 等人所著的《算法导论》(MIT,2009 年)——不要被标题所欺骗;这是一部严肃的作品——以及 Skiena 所著的《算法设计手册》(第 3 版,Springer-Verlag,2020 年)。对于单线程和并发算法,这些书都是进一步阅读的绝佳选择。

我们将在本章和随后的章节(特别是关于性能的第七章)中提到许多这些开销的来源,但现在让我们转向下一个主题:对 Java 的“经典”并发进行回顾,并深入了解为什么使用它编程可能会很困难。

5.3 块结构并发(Java 5 之前)

我们关于 Java 并发的许多内容都是关于讨论语言级别、即基于块同步的、即内在的并发方法之外的替代方案。但为了最大限度地发挥对替代方案的讨论,了解经典并发观点的优缺点非常重要。

因此,在本章的剩余部分,我们将讨论使用 Java 的并发关键字(如synchronizedvolatile等)处理多线程编程的原始、相当低级的方法。这次讨论将在设计力量和关注未来将发生什么的背景下进行。

在此基础上,我们将简要考虑线程的生命周期,然后讨论并发代码的常见技术(以及陷阱),例如完全同步的对象、死锁、volatile关键字和不可变性。让我们从同步的概述开始。

5.3.1 同步和锁

如你所知,synchronized关键字可以应用于代码块或方法。它表示在进入代码块或方法之前,线程必须获取适当的锁。例如,让我们考虑一个从银行账户提取资金的示例方法,如下所示:

public synchronized boolean withdraw(int amount) {     ❶
    // Check to see amount > 0, throw if not
    if (balance >= amount) {
        balance = balance - amount;
        return true;
    }

    return false;
}

❶ 同时只能有一个线程尝试从这个账户中提取资金。

方法必须获取对象实例所属的锁(或static synchronized方法所属的Class对象的锁)。对于代码块,程序员应指明要获取哪个对象的锁。

同时只能有一个线程可以进入对象的所有同步块或方法之一;如果其他线程尝试进入,它们将被 JVM 挂起。无论其他线程是尝试进入同一对象上的相同或不同的同步块,还是尝试进入不同对象上的同步块,这都是正确的。在并发理论中,这种结构有时被称为临界区,但这个术语在 C++中比在 Java 中更常用。

注意:你是否曾经想过为什么 Java 用于临界区的关键字是synchronized?为什么不叫“critical”或“locked”?正在被什么同步?我们将在 5.3.5 节中回到这个问题,但如果你不知道或者从未想过这个问题,你可能想在继续之前花几分钟思考一下。

让我们看看关于 Java 中同步和锁的一些基本事实。希望你已经掌握了这些(或大部分):

  • 只有对象——而不是原始数据类型——可以被锁定。

  • 锁定对象数组不会锁定单个对象。

  • 可以将同步方法视为等同于一个覆盖整个方法的 synchronized (this) { ... } 块(但请注意,它们在字节码中的表示方式不同)。

  • static synchronized方法锁定Class对象,因为没有实例对象可以锁定。

  • 如果你需要锁定Class对象,仔细考虑你是否需要显式地这样做,或者使用getClass(),因为这两种方法在子类中的行为将不同。

  • 内部类的同步与外部类无关(要了解为什么是这样,请记住内部类是如何实现的)。

  • synchronized不是方法签名的一部分,因此它不能出现在接口的方法声明中。

  • 未同步的方法不会查看或关心任何锁的状态,并且它们可以在同步方法运行时继续执行。

  • Java 的锁是可重入的——持有锁的线程遇到同一锁的同步点(例如,一个synchronized方法在同一个对象上调用另一个synchronized方法)将被允许继续执行。

注意:非可重入锁定方案在其他语言中确实存在(也可以在 Java 中实现——如果你想知道详细情况,请参阅java.util.concurrent.locksReentrantLock的 Javadoc),但它们通常很难处理,最好避免使用,除非你真的知道自己在做什么。

对于 Java 的同步性,我们已经足够回顾了。现在让我们继续讨论线程在其生命周期中移动到的状态。

5.3.2 线程的状态模型

在图 5.2 中,你可以看到 Java 线程的状态模型。这决定了 Java 线程在其生命周期中的进展方式。

图 5.2 Java 线程的状态模型

图 5.2 Java 线程的状态模型

Java 有一个名为Thread.State的枚举,它对应于上述状态模型,并且是在操作系统对线程状态的视图之上的一层。

注意:每个操作系统都有自己的线程版本,它们可能在细节上有所不同。在大多数情况下,现代操作系统具有相当相似的线程和调度实现,但这种情况并非总是如此(例如,Solaris 或 Windows XP)。

Java 线程对象最初处于NEW状态。此时,操作系统线程尚不存在(可能永远不存在)。要创建执行线程,必须调用Thread.start()。这会向操作系统发出创建线程的信号。

调度器将新线程放入运行队列,并在稍后的某个时刻找到一个运行它的核心(如果机器负载很重,可能涉及一些等待时间)。从那里,线程可以通过消耗其时间分配继续执行,并被放回运行队列以等待进一步的处理器时间片。这是我们第 5.1.1 节中提到的强制线程调度的动作。

在整个调度过程中,即被放置在核心上、运行,然后被放回运行队列中,Java Thread对象始终处于RUNNABLE状态。除了这个调度动作外,线程本身还可以表明它现在无法使用核心。这可以通过两种不同的方式实现:

  1. 程序代码通过调用Thread.sleep()来指示线程应在继续之前等待固定的时间。

  2. 线程意识到它必须等待直到某个外部条件得到满足,并调用Object.wait()

在这两种情况下,线程都会立即被操作系统从核心中移除。然而,从那时起的行为在每个情况下都是不同的。

在第一种情况下,线程请求睡眠一定的时间。Java 线程进入TIMED_WAITING状态,操作系统设置一个计时器。当计时器到期时,睡眠的线程被唤醒,并准备好再次运行,并被放回运行队列。

第二种情况略有不同。它使用了 Java 对象监视器的条件方面。线程将进入WAITING状态并无限期等待。它通常不会在操作系统发出条件可能已满足的信号之前醒来——通常是通过其他线程在当前对象上调用Object.notify()来实现的。

除了这两个在线程控制下的可能性之外,线程还可以进入BLOCKED状态,因为它正在等待 I/O 或获取另一个线程持有的锁。最后,如果对应于 Java Thread的操作系统线程已停止执行,那么该线程对象将进入TERMINATED状态。让我们继续讨论解决同步问题的一个著名方法:完全同步对象的概念。

5.3.3 完全同步对象

在本章的早期,我们介绍了并发类型安全的概念,并提到了实现这一目标的一种策略。让我们看看这种策略的更完整描述,这通常被称为完全同步对象。如果遵守以下所有规则,该类被认为是线程安全的,也将是活跃的。

完全同步的类是满足以下所有条件的类:

  • 所有字段在每一个构造函数中都被初始化为一致的状态。

  • 没有公共字段。

  • 对象实例在从任何非私有方法返回后都保证是一致的(假设在调用方法时状态是一致的)。

  • 所有方法都能在有限的时间内终止。

  • 所有方法都是同步的。

  • 在不一致的状态下,任何方法都不会调用另一个实例的方法。

  • 在不一致的状态下,任何方法都不会在当前实例上调用任何非私有方法。

列表 5.1 展示了来自银行系统后端的一个此类示例。FSOAccount类模拟了一个账户。FSO 前缀存在是为了清楚地表明这个实现使用了完全同步的对象。

这种情况提供了存款、取款和余额查询——这是读操作和写操作之间的一种经典冲突——因此使用同步来防止不一致性。

列表 5.1 一个完全同步的类

public class FSOAccount {
    private double balance;                                   ❶

    public FSOAccount(double openingBalance) {
        // Check to see openingBalance > 0, throw if not
        balance = openingBalance;                             ❷
    }

    public synchronized boolean withdraw(int amount) {        ❸
        // Check to see amount > 0, throw if not
        if (balance >= amount) {
            balance = balance - amount;
            return true;
        }

        return false;
    }

    public synchronized void deposit(int amount) {            ❸
        // Check to see amount > 0, throw if not
        balance = balance + amount;
    }

    public synchronized double getBalance() {                 ❸
        return balance;
    }
}

❶ 没有公共字段

❷ 所有字段都在构造函数中初始化。

❸ 所有方法都是同步的。

这看起来非常棒——这个类既安全又活跃。问题是性能。仅仅因为某件事是安全和活跃的,并不意味着它一定会非常快。你必须使用synchronized来协调对余额的所有访问(无论是获取还是放置),而这种锁定最终会减慢你的速度。这是处理并发方式的一个核心问题。

除了性能问题之外,列表 5.1 中的代码相当脆弱。你可以看到你从未在同步方法之外触摸balance,但这只可能是通过检查代码的少量来实现的。

在现实中的大型系统中,由于代码量很大,这种手动验证是不可能的。对于使用这种方法的较大代码库,很容易出现错误,这也是 Java 社区开始寻找更稳健方法的原因之一。

5.3.4 死锁

并发(不仅仅是 Java 的观点)的另一个经典问题是 死锁。考虑列表 5.2,它是先前示例的略微扩展形式。在这个版本中,除了模拟账户余额外,我们还有一个 transferTo() 方法,可以将资金从一个账户转移到另一个账户。

注意:这是一个构建多线程事务系统的天真尝试。它旨在演示死锁——你不应该将其作为真实代码的基础。

在下一个列表中,让我们添加一个方法来在两个 FSOAccount 对象之间转移资金,如下所示。

列表 5.2 死锁示例

public synchronized boolean transferTo(FSOAccount other, int amount) {
        // Check to see amount > 0, throw if not
        // Simulate some other checks that need to occur
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        if (balance >= amount) {
            balance = balance - amount;
            other.deposit(amount);
            return true;
        }

        return false;
    }

现在,让我们在主类中实际引入一些并发性:

public class FSOMain {
    private static final int MAX_TRANSFERS = 1_000;

    public static void main(String[] args) throws InterruptedException {
        FSOAccount a = new FSOAccount(10_000);
        FSOAccount b = new FSOAccount(10_000);
        Thread tA = new Thread(() -> {
            for (int i = 0; i < MAX_TRANSFERS; i = i + 1) {
                boolean ok = a.transferTo(b, 1);
                if (!ok) {
                    System.out.println("Thread A failed at "+ i);
                }
            }
        });
        Thread tB = new Thread(() -> {
            for (int i = 0; i < MAX_TRANSFERS; i = i + 1) {
                boolean ok = b.transferTo(a, 1);
                if (!ok) {
                    System.out.println("Thread B failed at "+ i);
                }
            }
        });
        tA.start();
        tB.start();
        tA.join();
        tB.join();

        System.out.println("End: "+ a.getBalance() + " : "+ b.getBalance());
    }
}

初看,这段代码看起来是合理的。你有两个由不同线程执行的事务。这种设计看起来并不离谱——只是线程在两个账户之间转账——并且所有的方法都是 synchronized

注意,我们在 transferTo() 方法中引入了一个小的睡眠。这是为了让线程调度器运行两个线程,并导致死锁的可能性。

注意:这里的睡眠是为了演示目的,并不是因为你在编写银行转账代码时实际上会这样做。它在那里是为了模拟实际中可能存在的代码——由数据库调用或授权检查引起的延迟。

如果你运行代码,你通常会看到一个死锁的例子——两个线程会运行一段时间,最终陷入停滞。原因是每个线程都需要其他线程在转移方法可以继续之前释放它持有的锁。这可以在图 5.3 中看到。

图 5.3 死锁线程

另一种看待这个问题的方式可以在图 5.4 中看到,其中我们展示了 JDK Mission Control 工具的线程转储视图(我们将在第七章中详细介绍这个工具,并展示如何找到这个有用的视图)。

图 5.4 死锁线程

两个线程被创建为 Thread-0 和 Thread-1,我们可以看到 Thread-0 锁定了一个引用,并且处于 BLOCKED 状态,等待锁定另一个。Thread-1 的对应线程转储将显示锁的相反配置,因此发生死锁。

注意:在完全同步对象方法方面,这个死锁是由于违反了“有限时间”原则而发生的。当代码调用 other.deposit() 时,我们无法保证代码将运行多长时间,因为 Java 内存模型没有给我们关于阻塞监视器何时释放的保证。

为了处理死锁,一种技术是在每个线程中始终以相同的顺序获取锁。在前面的例子中,第一个启动的线程以AB的顺序获取它们,而第二个线程以BA的顺序获取它们。如果两个线程都坚持按AB的顺序获取锁,那么死锁就可以避免,因为第二个线程将无法运行,直到第一个线程完成并释放其锁。在本章的后面部分,我们将展示一种简单的方法来安排所有锁以相同的顺序获取,以及一种验证这确实得到满足的方法。

接下来,我们将回到我们之前提出的一个谜题:为什么 Java 中临界区的关键字被命名为synchronized。这将引导我们讨论volatile关键字。

5.3.5 为什么是synchronized

并发编程的简单概念模型是 CPU 的时分共享——也就是说,线程在单个核心上开关。这种经典观点在图 5.5 中显示。

图片

图 5.5 单核(左)和多核(右)并发和线程的思考

然而,现在许多年已经不再是对现代硬件的准确描述。二十年前,一个工作的程序员可以连续几年不遇到一个拥有超过一个或最多两个处理核心的系统。情况已经不再是这样了。

现在,任何大小等于或大于移动电话的设备都有多个核心,因此心智模型也应该不同,涵盖在同一物理时刻运行在不同核心上的多个线程(并且可能操作共享数据)。您可以在图 5.5 中看到这一点。为了效率,同时运行的每个线程可能都有自己的数据缓存副本。

注意:我们仍然会展示执行的理论模型,其中我们的假设计算机只有一个核心。这样做纯粹是为了让您看到我们讨论的非确定性并发问题固有的,而不是由硬件设计的特定方面引起的。

在这个图景中,让我们转向关于用于表示锁定部分或方法的关键字的选择问题。

我们之前问过,在列表 5.1 中的代码中,是什么被synchronized同步了?答案是:被锁定对象在不同线程中的内存表示。也就是说,在synchronized方法(或块)完成后,对被锁定对象所做的任何更改都会在释放锁之前刷新回主内存,如图 5.6 所示。

图片

图 5.6 对象的更改通过主内存传播到线程之间。

此外,当一个同步块被进入,并且在获取锁之后,任何对锁定对象的更改都是从主内存中读取的,因此拥有锁的线程在锁定部分的代码开始执行之前与主内存中对象的观点同步。

5.3.6 volatile 关键字

Java 自从诞生之初(Java 1.0)就拥有了 volatile 关键字,它被用作处理对象字段并发处理的一种简单方式,包括原始数据类型。以下规则支配一个 volatile 字段:

  • 线程看到的值总是在使用之前从主内存中重新读取。

  • 线程写入的任何值总是在字节码指令完成之前通过主内存刷新。

这有时被描述为在单个操作周围有一个“小小的同步块”,但这种说法是误导性的,因为 volatile 不涉及任何锁定。synchronized 的作用是使用一个互斥锁在对象上,以确保只有一个线程可以执行该对象上的同步方法。同步方法可以在对象上包含许多读取和写入操作,并且它们将作为一个不可分割的单元(从其他线程的角度看)执行,因为直到方法退出并将对象刷新回主内存,其他线程看不到对象上方法执行的结果。

关于 volatile 的关键点是它只允许对内存位置进行 一个 操作,该操作将立即刷新到内存中。这意味着要么是单个读取,或者 是单个写入,但不会超过这个范围。我们在图 5.6 中看到了这两种类型的操作。

应该只在写入变量不依赖于变量的当前状态(读取状态)的情况下使用 volatile 变量来模拟变量。这是 volatile 保证只执行单个操作的结果。

例如,++-- 运算符在 volatile 上使用是不安全的,因为它们等价于 v = v + 1v = v – 1。增量示例是一个经典的 状态相关更新 例子。

对于当前状态重要的情况,你必须始终引入一个锁来确保完全安全。因此,volatile 允许程序员在某些情况下编写简化的代码,但代价是每次访问都要进行额外的刷新。注意,由于 volatile 机制不引入任何锁,你不能使用 volatile 导致死锁——只能使用同步。在本章的后面部分,我们将遇到 volatile 的其他一些应用,并更详细地讨论该机制。

5.3.7 线程状态和方法

一个 java.lang.Thread 对象就是这样:一个存在于堆中的 Java 对象,它包含有关操作系统线程的元数据,该线程可能存在、曾经存在,或者将来可能存在。

Java 为线程对象定义了以下状态,这些状态对应于主流操作系统上的操作系统线程状态。它们与我们图 5.2 中看到的状态模型密切相关:

  • NEWThread对象已被创建,但实际的操作系统线程尚未创建。

  • RUNNABLE—线程可运行。操作系统负责调度它。

  • BLOCKED—线程未运行;它需要获取一个锁或处于系统调用中。

  • WAITING—线程未运行;它已经调用了Object.wait()Thread.join()

  • TIMED_WAITING—线程未运行;它已经调用了Thread.sleep()

  • TERMINATED—线程未运行;它已经完成了执行。

所有线程都从NEW状态开始,无论线程的run()方法是否正常退出或抛出异常,最终都会结束于TERMINATED状态。

注意:Java 线程状态模型不区分一个RUNNABLE线程是否在那一刻实际物理执行或在等待(在运行队列中)。

线程的实际创建是通过start()方法完成的,该方法调用本地代码来执行相关的系统调用(例如 Linux 上的clone())以创建线程,并在线程的run()方法中开始代码执行。

Java 的 Thread API 可以分为三组方法。我们不会包含每个方法的很多样板 Javadoc 描述,而是只列出它们,并让读者查阅 API 文档以获取更多详细信息。

第一个是读取线程元数据的一组方法:

  • getId()

  • getName()

  • getState()

  • getPriority()

  • isAlive()

  • isDaemon()

  • isInterrupted()

其中一些元数据(例如从getId()获取的线程 ID)将在线程的生命周期内保持固定。其中一些(例如线程状态和中断状态)会随着线程的运行而自然变化,而其中一些(例如名称和守护状态)可以被程序员设置。这引出了第二组方法:

  • setDaemon()

  • setName()

  • setPriority()

  • setUncaughtExceptionHandler()

对于程序员来说,在启动线程之前配置任何适当的线程属性通常更好。

最后,以下线程控制方法用于启动新线程并与其他正在运行的线程交互:

  • start()

  • interrupt()

  • join()

注意,Thread.sleep()没有出现在这个列表中,因为它是一个静态方法,仅针对当前线程。

注意:一些具有超时的线程方法(例如,带有超时参数的Thread.join())实际上可能导致线程被放置在TIMED_WAITING状态而不是WAITING状态。

让我们看看一个示例,了解如何在简单多线程应用程序的典型生命周期中使用线程方法:

Runnable r = () -> {
    var start = System.currentTimeMillis();
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    var thisThread = Thread.currentThread();
    System.out.println(thisThread.getName() +
        " slept for "+ (System.currentTimeMillis() - start));
};

var t = new Thread(r);            ❶
t.setName("Worker");
t.start();                        ❷
Thread.sleep(100);
t.join();                         ❸
System.out.println("Exiting");

❶ 创建线程的元数据对象。

❷ 操作系统创建了一个实际的线程。

❸ 主线程暂停并等待工作线程退出后再继续。

这相当简单:主线程创建工作线程,启动它,然后在达到 join() 调用之前至少等待 100 毫秒(给调度器一个运行的机会),join() 调用会导致它暂停,直到工作线程退出。与此同时,工作线程完成睡眠,再次醒来,并打印出消息。

注意:睡眠的经过时间很可能不会正好是 1000 毫秒。操作系统的调度器是非确定性的,因此操作系统提供的最佳保证是操作系统将尝试确保线程睡眠所需的时间,除非被唤醒。然而,多线程编程通常涉及处理意外情况,正如我们将在下一节中看到的那样。

中断线程

当与线程一起工作时,相对常见的是想要安全地中断线程正在执行的工作,Thread 对象上提供了用于此目的的方法。然而,它们可能不会像我们最初预期的那样表现。让我们运行以下代码,该代码创建了一个正在努力工作的线程,然后尝试中断它:

var t = new Thread(() -> { while (true); });    ❶
t.start();                                      ❶

t.interrupt();                                  ❷
t.join();                                       ❸

❶ 创建并启动一个将永远运行的新的线程

❷ 请求线程中断自己(即停止执行)

❸ 在主线程中等待另一个线程完成

如果你运行这段代码,你可能会惊讶地发现我们的 join() 将永久阻塞。这里发生的情况是线程中断是可选的——线程中调用的方法必须显式检查中断状态并对其做出响应,而我们的简单 while 循环从未进行过这样的检查。我们可以在循环中通过执行预期的检查来修复这个问题,如下所示:

var t = new Thread(() -> { while (!Thread.interrupted()); });   ❶
t.start();                                                      ❶

t.interrupt();
t.join();

❶ 检查当前线程的中断状态,而不是在 true 上循环

现在当请求时,我们的循环将退出,并且我们的 join() 方法不再永久阻塞。

在 JDK 中,方法通常是阻塞的——无论是 IO、等待锁还是其他场景,都普遍存在检查线程中断状态的情况。惯例是这些方法将抛出 InterruptedException,这是一个检查型异常。这解释了为什么例如 Thread.sleep() 需要你将 InterruptedException 添加到方法签名中或处理它。

让我们修改上一节中的示例,看看 Thread.sleep() 在被中断时的行为:

Runnable r = () -> {
    var start = System.currentTimeMillis();
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {    ❶
        e.printStackTrace();
    }
    var thisThread = Thread.currentThread();
    System.out.println(thisThread.getName() +
        " slept for "+ (System.currentTimeMillis() - start));
    if (thisThread.isInterrupted()) {
        System.out.println("Thread "+ thisThread.getName() +" interrupted");
    }
};

var t = new Thread(r);
t.setName("Worker");
t.start();                                ❷
Thread.sleep(100);
t.interrupt();                            ❸
t.join();
System.out.println("Exiting");

❶ 我们的 Runnable 必须处理检查型 InterruptedException。当我们中断时,它打印堆栈,然后从这里继续执行。

❷ 创建工作线程

❸ 主线程中断工作线程并唤醒它。

当我们运行这段代码时,我们会看到一些类似以下的输出:

java.lang.InterruptedException: sleep interrupted
    at java.base/java.lang.Thread.sleep(Native Method)
    at examples.LifecycleWithInterrupt.lambda$main$0
     (LifecycleWithInterrupt.java:9)
    at java.base/java.lang.Thread.run(Thread.java:832)
Worker slept for 101
Exiting

如果你仔细观察,你会看到消息 "Thread Worker interrupted" 并没有出现。这揭示了关于我们在代码中处理中断的一个相关事实:检查线程的中断状态实际上会重置该状态。抛出标准 InterruptedException 的代码清除了中断,因为当异常被抛出时,它被认为是“已处理”。

注意:我们有以下两种方法来检查中断状态:一个静态的 Thread.interrupted(),它隐式地查看当前线程,以及一个线程对象的实例级别 isInterrupted()。静态版本在检查后清除状态,这是在抛出 InterruptedException 之前期望使用的。另一方面,实例方法不会改变状态。

如果我们想要保留我们的线程被中断的信息,我们必须直接处理它。对于我们的简单示例,我们只需要在线程代码的稍后部分使用状态,以下内容将有效:

Runnable r = () -> {
    var start = System.currentTimeMillis();
    var wasInterrupted = false;              ❶
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        wasInterrupted = true;               ❷
        e.printStackTrace();
    }
    var thisThread = Thread.currentThread();
    System.out.println(thisThread.getName() +
        " slept for "+ (System.currentTimeMillis() - start));
    if (wasInterrupted) {
        System.out.println("Thread "+ thisThread.getName() +" interrupted");
    }
};

var t = new Thread(r);
t.setName("Worker");
t.start();
Thread.sleep(100);
t.interrupt();
t.join();
System.out.println("Exiting");

❶ 设置状态以记录可能的中断

❷ 记录中断

在更复杂的情况下,你可能希望确保为调用者重新抛出 InterruptedException,抛出某种自定义异常,执行自己的自定义逻辑,或者甚至将中断状态恢复到相关的线程上。所有这些都是可能的,具体取决于你的具体需求。

与异常和线程一起工作

另一个多线程编程的问题是处理可能从线程内部抛出的异常。例如,假设我们正在执行一个来源未知的 Runnable。如果它抛出异常并死亡,那么其他代码可能不会意识到这一点。幸运的是,Thread API 提供了在启动线程之前向线程添加未捕获异常处理器的功能,如下例所示:

var badThread = new Thread(() -> {
    throw new UnsupportedOperationException(); });

// Set a name before starting the thread
badThread.setName("An Exceptional Thread");

// Set the handler
badThread.setUncaughtExceptionHandler((t, e) -> {
    System.err.printf("Thread %d '%s' has thrown exception " +
                    "%s at line %d of %s",
            t.getId(),
            t.getName(),
            e.toString(),
            e.getStackTrace()[0].getLineNumber(),
            e.getStackTrace()[0].getFileName()); });

badThread.start();

处理器是 UncaughtExceptionHandler 的一个实例,它是一个函数式接口,定义如下:

public interface UncaughtExceptionHandler {
    void uncaughtException(Thread t, Throwable e);
}

此方法提供了一个简单的回调,允许线程控制代码根据观察到的异常采取行动——例如,线程池可能会重新启动以这种方式退出的线程,以保持池的大小。

注意:由 uncaughtException() 抛出的任何异常都将被 JVM 忽略。

在我们继续之前,我们需要讨论一些 Thread 的已废弃控制方法,这些方法不应该由应用程序员使用。

已废弃的线程方法

Java 是第一个支持多线程编程的主流语言。然而,这种“先行者”优势也有其阴暗面——许多并发编程中固有的问题首先是由在 Java 中工作的程序员遇到的。

这其中有一个不幸的事实,即原始 Thread API 中的某些方法实际上是不安全的,不适合使用,特别是 Thread.stop()。此方法基本上无法安全使用——它在不警告的情况下杀死另一个线程,并且没有让被杀死的线程确保任何锁定对象都变得安全的方法。

stop()的弃用紧随其在早期 Java 中的积极使用之后,因为停止另一个线程需要在另一个线程的执行中注入一个异常。然而,无法确切知道那个其他线程在执行中的确切位置。也许线程在开发者假设会完全运行的finally块中间被杀死,程序处于损坏状态。

机制是在被杀死的线程上触发未检查的ThreadDeath异常。代码无法通过 try 块来保护自己免受此类异常的影响(就像不可能可靠地保护自己免受OutOfMemoryError一样),因此异常会立即开始回滚被杀死线程的堆栈,并解锁所有监视器。这立即使可能受损的对象对其他线程可见,因此stop()的使用并不安全。

除了众所周知的stop()问题之外,还有其他几个方法也存在严重问题。例如,suspend()不会释放任何监视器,因此任何尝试访问被挂起线程锁定的同步代码的线程将永久阻塞,除非挂起线程被重新激活。这代表了一个重大的活跃性风险,因此suspend()resume()永远不应该使用。destroy()方法从未实现过,但如果实现了,也会遇到相同的问题。

注意:这些危险的线程方法自 Java 1.2 以来就被弃用了——超过 20 年前,并且最近已被标记为即将删除(这将是一个破坏性变更,让你了解这个问题被如何看待得多么严重)。

从其他线程可靠地控制线程的真实解决方案最好通过我们在本章后面将要遇到的Volatile Shutdown模式来展示。现在让我们继续讨论在以并发方式编程时处理必须安全共享的数据时最有用的技术之一。

5.3.8 不可变性

一种非常有价值的技术是使用不可变对象。这些对象要么没有任何状态,要么只有最终字段(因此,必须在对象的构造函数中填充这些字段)。这些对象总是安全且活跃的,因为它们的状态不能被修改,所以它们永远不会处于不一致的状态。

一个问题是,任何需要用于初始化特定对象的值都必须传递给构造函数。这可能导致构造函数调用笨拙,参数众多。因此,许多编码者使用工厂方法。这可以简单到在类上使用静态方法而不是构造函数来生成新对象。构造函数通常被设置为受保护的或私有的,这样静态工厂方法就成为了实例化的唯一方式。例如,考虑一个简单的存款类,我们可能在银行系统中看到,如下所示:

public final class Deposit {
    private final double amount;
    private final LocalDate date;
    private final Account payee;

    private Deposit(double amount, LocalDate date, Account payee) {
        this.amount = amount;
        this.date = date;
        this.payee = payee;
    }

    public static Deposit of(double amount, LocalDate date, Account payee) {
        return new Deposit(amount, date, payee);
    }

    public static Deposit of(double amount, Account payee) {
        return new Deposit(amount, LocalDate.now(), payee);
    }

这具有类的字段,一个私有构造函数,以及两个工厂方法,其中一个是为创建今天的存款提供的便利方法。接下来是字段的访问器方法:

    public double amount() {
        return amount;
    }

    public LocalDate date() {
        return date;
    }

    public Account payee() {
        return payee;
    }

注意,在我们的示例中,这些是以记录风格呈现的,其中访问器方法的名称与字段的名称相匹配。这与 bean 风格形成对比,在 bean 风格中,getter 方法以get为前缀,setter 方法(对于任何非 final 字段)以set为前缀。

不变对象显然不能被更改,那么当我们想要更改其中一个时会发生什么?例如,如果存款或其他交易在特定一天无法进行,那么这笔交易通常会“滚动”到下一天。我们可以通过在类型上有一个实例方法来实现这一点,该方法返回一个几乎相同但某些字段已修改的对象,如下所示:

    public Deposit roll() {
        // Log audit event for rolling the date
        return new Deposit(amount, date.plusDays(1), payee);
    }

    public Deposit amend(double newAmount) {
        // Log audit event for amending the amount
        return new Deposit(newAmount, date, payee);
    }

不变对象可能存在的一个问题是,它们可能需要许多参数传递给工厂方法。这并不总是非常方便,尤其是在你需要在创建新的不变对象之前从多个来源累积状态时。

为了解决这个问题,我们可以使用构建器模式。这是两种结构的组合:一个实现泛型构建器接口的静态内部类,以及不变类本身的私有构造函数。

静态内部类是不变类的构建器,它为开发者提供了获取不变类型新实例的唯一方式。一个非常常见的实现是,Builder类与不变类具有完全相同的字段,但允许字段的可变。以下列表显示了如何使用这种方法来模拟存款的更复杂视图。

列表 5.3 不变对象和构建器

    public static class DepositBuilder implements Builder<Deposit> {
        private double amount;
        private LocalDate date;
        private Account payee;

        public DepositBuilder amount(double amount) {
            this.amount = amount;
            return this;
        }

        public DepositBuilder date(LocalDate date) {
            this.date = date;
            return this;
        }

        public DepositBuilder payee(Account payee) {
            this.payee = payee;
            return this;
        }

        @Override
        public Deposit build() {
            return new Deposit(amount, date, payee);
        }
    }

构建器是一个泛型顶层接口,通常定义如下:

public interface Builder<T> {
    T build();
}

我们应该注意关于构建器的一些事情。首先,它是一个所谓的 SAM 类型(代表“单个抽象方法”),从技术上讲,它可以作为 lambda 表达式的目标类型。然而,构建器的目的是生成不可变实例——它关于聚集状态,而不是表示一个函数或回调。这意味着尽管构建器可以用作功能接口,但在实践中,这样做永远不会有用。

因此,我们不会用@FunctionalInterface注解来装饰接口——这是“仅仅因为你可以做某事,并不意味着你应该这样做”的另一个好例子。

其次,我们还应该注意到构建器不是线程安全的。设计隐式假设用户知道不要在线程之间共享构建器。相反,正确的 Builder API 使用方式是一个线程使用构建器聚合所有需要的状态,然后生成一个可以与其他线程简单共享的不变对象。

注意:如果你发现自己想在多个线程之间共享一个构建器,请花点时间停下来重新考虑你的设计和你的领域是否需要重构。

不可变性是一个非常常见的模式(不仅限于 Java,也适用于其他语言,尤其是函数式语言),并且具有广泛的应用性。

关于不可变对象的一个最后要点:final关键字仅适用于直接指向的对象。正如你在图 5.7 中可以看到的,主对象的引用不能被分配以指向对象 3,但在对象内部,对对象 1 的引用可以被更新以指向对象 2。另一种说法是,final引用可以指向具有非 final 字段的对象。这有时被称为浅不可变性

图片

图 5.7 值与引用的不可变性

另一种看待这个问题的方式是,完全有可能写出以下内容:

final var numbers = new LinkedList<Integer>();

在这个声明中,引用numbers和列表中包含的整数对象是不可变的。然而,列表对象本身仍然是可变的,因为整数对象仍然可以被添加、删除和替换到列表中。

不可变性是一个非常强大的技术,你应该在可行的情况下使用它。然而,有时仅仅使用不可变对象是无法高效开发的,因为对象状态的任何更改都需要启动一个新的对象。因此,我们有时不得不处理可变对象。

在下一节中,我们将讨论经常被误解的 Java 内存模型(JMM)的细节。许多 Java 程序员对 JMM 有所了解,并且一直在根据自己的理解编写代码,而没有正式地介绍过它。如果你是这样的程序员,这种新的理解将建立在你的非正式认识之上,并将其建立在坚实的基础之上。JMM 是一个非常高级的话题,所以如果你急于进入下一章,可以跳过它。

5.4 Java 内存模型(JMM)

Java 语言规范(JLS)的第 17.4 节描述了 JMM。这是规范的一个正式部分,它使用同步操作和一些相当数学的概念来描述 JMM,例如操作的偏序

从语言理论家和 Java 规范(编译器和 JVM 制造者)的角度来看,这是非常好的,但对于需要理解其多线程代码执行细节的应用程序开发者来说,这是更糟的。

我们不会重复正式的细节,而是将最重要的规则列在这里,以几个基本概念的形式:代码块之间的同步于发生之前关系:

  • 发生之前—这种关系表示一个代码块在另一个代码块开始之前完全完成。

  • 同步于—在继续之前,一个操作将同步其对对象的视图与主内存。

如果你已经研究过面向对象编程的正式方法,你可能听说过用来描述面向对象构建块的 Has-AIs-A 这些表达。一些开发者发现将 Happens-Before 和 Synchronizes-With 视为类似的基本概念构建块是有用的,但这是为了理解 Java 并发而不是面向对象。然而,我们应该强调这两组概念之间没有直接的技术联系。在图 5.8 中,你可以看到一个与后续读取访问(对于 println())同步的易变写入示例。

JMM 有以下主要规则:

  • 在监视器上的解锁操作将同步与后续的锁定操作。

  • 向一个易变变量写入将同步与该变量的后续读取操作。

  • 如果操作 A 与操作 B 同步,那么 A 发生在 B 之前。

  • 如果在程序顺序中 A 在 B 之前发生,那么在同一个线程中,A 发生在 B 之前。

示例图片

图 5.8 同步示例

第一条和第二条规则的一般表述是“释放发生在获取之前。”换句话说,线程在写入时持有的锁在可以由其他操作(包括读取)获取之前被释放。例如,规则保证如果一个线程向一个易变变量写入值,那么任何稍后读取该变量的线程都将看到写入的值(假设没有其他写入发生)。

随后是关于合理行为的附加规则:

  • 构造函数的完成发生在该对象的终结器开始运行之前(一个对象必须完全构建后才能进行终结)。

  • 启动线程的操作与新线程的第一个操作同步。

  • Thread.join() 将同步与被加入线程的最后一个(以及所有其他)操作。

  • 如果 X 发生在 Y 之前,并且 Y 发生在 Z 之前,那么 X 发生在 Z 之前(传递性)。

这些简单的规则定义了整个平台对内存和同步工作方式的看法。图 5.9 阐述了传递性规则。

图 5.9 Happens-Before 的传递性

图 5.9 Happens-Before 的传递性

注意:在实际应用中,这些规则是 JMM 做出的最小保证。实际的 JVM 可能在实际中表现得比这些保证更好。这对开发者来说可能是一个陷阱,因为特定 JVM 的行为所提供的虚假安全感可能会暴露出隐藏的并发错误。

从这些最小保证中,很容易看出不可变性是并发 Java 编程中的一个重要概念。如果对象不能被更改,那么就不会有与确保更改对所有线程可见相关的问题。

5.5 通过字节码理解并发

让我们通过一个经典示例来讨论并发:一个银行账户。假设客户的账户看起来像这样,并且可以通过调用方法进行提款和存款。我们已经提供了关键方法的同步和未同步实现:

public class Account {
    private double balance;

    public Account(int openingBalance) {
        balance = openingBalance;
    }

    public boolean rawWithdraw(int amount) {
        // Check to see amount > 0, throw if not
        if (balance >= amount) {
            balance = balance - amount;
            return true;
        }
        return false;
    }

    public void rawDeposit(int amount) {
        // Check to see amount > 0, throw if not
        balance = balance + amount;
    }

    public double getRawBalance() {
        return balance;
    }

    public boolean safeWithdraw(final int amount) {
        // Check to see amount > 0, throw if not
        synchronized (this) {
            if (balance >= amount) {
                balance = balance - amount;
                return true;
            }
        }
        return false;
    }

    public void safeDeposit(final int amount) {
        // Check to see amount > 0, throw if not
        synchronized (this) {
            balance = balance + amount;
        }
    }

    public double getSafeBalance() {
        synchronized (this) {
            return balance;
        }
    }
}

这组方法将使我们能够探索 Java 中许多常见的并发问题。

注意:我们之所以在这个阶段使用同步的块形式,而不是synchronized方法修饰符,是因为我们将在本章稍后解释原因。

我们还可以假设,如果需要,该类有两个类似这样的辅助方法:

    public boolean withdraw(int amount, boolean safe) {
        if (safe) {
            return safeWithdraw(amount);
        } else {
            return rawWithdraw(amount);
        }
    }

让我们首先遇到多线程系统显示的一个基本问题,这需要我们引入某种保护机制。

5.5.1 更新丢失

为了演示这个常见问题(或反模式),称为更新丢失,让我们看看rawDeposit()方法的字节码:

public void rawDeposit(int);
    Code:
       0: aload_0
       1: aload_0
       2: getfield      #2  // Field balance:D   ❶
       5: iload_1
       6: i2d
       7: dadd                                   ❷
       8: putfield      #2  // Field balance:D   ❸
      11: return

❶ 从对象中读取余额

❷ 添加存款金额

❸ 将新余额写入对象

让我们引入两个执行线程,称为AB。然后我们可以想象同时尝试在同一个账户上存款。通过在指令前加上线程标签,我们可以看到不同线程上执行的单个字节码指令,但它们都影响同一个对象。

注意:请记住,一些字节码指令后面跟着参数,这会导致指令编号偶尔出现“跳过”。

更新丢失是由于应用程序线程的非确定性调度,可能出现这种读取和写入的字节码序列的问题:

A0: aload_0
     A1: aload_0
     A2: getfield      #2  // Field balance:D       ❶
     A5: iload_1
     A6: i2d
     A7: dadd

// ....           Context switch A -> B

         B0: aload_0
         B1: aload_0
         B2: getfield      #2  // Field balance:D   ❷
         B5: iload_1
         B6: i2d
         B7: dadd
         B8: putfield      #2  // Field balance:D   ❸
        B11: return

// ....           Context switch B -> A

     A8: putfield      #2  // Field balance:D       ❹
    A11: return

❶ 线程 A 从余额中读取一个值。

❷ 线程 B 读取与 A 相同的余额值。

❸ 线程 B 将新值写回余额。

❹ 线程 A 覆盖了余额——B 的更新丢失了。

更新后的余额是由每个线程使用评估栈计算的。dadd 操作码是将更新后的余额放置在栈上的点,但请记住,每个方法调用都有自己的、私有的评估栈。因此,在前一个流程中的B7点有两个更新后的余额副本:一个在 A 的评估栈中,一个在 B 的。然后执行B8A8处的两个putfield操作,但A8覆盖了在B8处放置的值。这导致两个存款似乎都成功了,但实际上只有一个出现。

账户余额将记录存款,但代码仍然会导致账户中的钱消失,因为余额字段被读取两次(使用getfield),然后被写入和覆盖(通过两个putfield操作)。例如,在某些代码如下:

Account acc = new Account(0);
Thread tA = new Thread(() -> acc.rawDeposit(70));
Thread tB = new Thread(() -> acc.rawDeposit(50));
tA.start();
tB.start();
tA.join();
tB.join();

System.out.println(acc.getRawBalance());

最终余额可能是 50 或 70——但两个线程都“成功”存了钱。代码存入了 120,但丢失了一些——这是不正确多线程代码的一个经典例子。

注意这里展示的代码的简单形式。在这样一个简单的例子中,可能不会显示出非确定性的全部可能性。不要被这个例子所迷惑——当这段代码组合进一个大型程序中,错误肯定会显现出来。认为代码因为“太简单”就一定没问题,或者试图欺骗并发模型,最终结果必然是糟糕的。

注意:源代码库中有一个示例(AtmLoop)展示了这种效果,但它依赖于我们尚未遇到的一个类(AtomicInteger),所以我们在这里不会完全展示。因此,如果您需要确信,请去检查示例的行为。

通常,访问模式

A: getfield
B: getfield
B: putfield
A: putfield

或者

A: getfield
B: getfield
A: putfield
B: putfield

将会对我们的账户对象造成问题。

回想一下,操作系统实际上会导致线程的非确定性调度,因此这种交错总是可能的,Java 对象存在于堆中,因此线程正在操作共享的、可变的数据。

我们真正需要的是引入一种机制,以某种方式防止这种情况,并确保排序始终是以下形式:

...
A: getfield
A: putfield
...
B: getfield
B: putfield
...

此机制是同步,这是我们下一个主题。

5.5.2 字节码中的同步

在第四章中,我们介绍了 JVM 字节码,并简要地遇到了monitorentermonitorexit。同步块被转换成这些操作码(我们稍后会讨论同步方法)。让我们通过查看我们之前看到的示例来观察它们的作用(我们正在重现 Java 代码,因此它就在手边):

    public boolean safeWithdraw(final int amount) {
        // Check to see amount > 0, throw if not
        synchronized (this) {
            if (balance >= amount) {
                balance = balance - amount;
                return true;
            }
        }
        return false;
    }

这被转换成 40 字节 JVM 字节码:

public boolean safeWithdraw(int);
    Code:
       0: aload_0
       1: dup
       2: astore_2
       3: monitorenter                           ❶
       4: aload_0
       5: getfield      #2  // Field balance:D
       8: iload_1
       9: i2d
      10: dcmpl
      11: iflt          29                       ❷
      14: aload_0
      15: aload_0
      16: getfield      #2  // Field balance:D
      19: iload_1
      20: i2d
      21: dsub
      22: putfield      #2  // Field balance:D   ❸
      25: iconst_1
      26: aload_2
      27: monitorexit                            ❹
      28: ireturn                                ❺
      29: aload_2
      30: monitorexit                            ❹
      31: goto          39
      34: astore_3
      35: aload_2
      36: monitorexit                            ❹
      37: aload_3
      38: athrow
      39: iconst_0
      40: ireturn                                ❺

❶ 同步块的开始

❷ 检查余额的 if 语句

❸ 将新值写入余额字段

❹ 同步块的结束

❺ 方法返回值

留意细节的读者可能会在字节码中注意到几个奇怪之处。首先,让我们看看代码路径。如果余额检查成功,则字节码 0-28 被执行,没有跳转。如果失败,字节码 0-11 被执行,然后跳转到 29-31 和 39-40。

乍一看,没有任何一组情况会导致字节码 34-38 被执行。这种看似矛盾的情况实际上是由异常处理解释的——一些字节码指令(包括monitorenter)可以抛出异常,因此需要有一个异常处理代码路径。

第二个难题是方法的返回类型。在 Java 代码中,它被声明为boolean,但我们可以看到返回指令是ireturn,这是返回操作码的整数变体。实际上,对于字节、短整型、字符或布尔类型,不存在指令的变体形式。这些类型在编译过程中被替换为整型。这是一种类型擦除,这是 Java 类型系统(尤其是通常应用于泛型和类型参数的情况)被误解的一个方面。

总体而言,之前的字节码序列比非同步情况更复杂,但应该可以理解:我们将要锁定的对象加载到评估堆栈上,然后执行monitorenter以获取锁。让我们假设锁尝试成功。

现在,如果任何其他线程尝试在同一个对象上执行monitorenter,该线程将被阻塞,并且第二个monitorenter指令将不会完成,直到持有锁的线程执行monitorexit并释放锁。这就是我们处理“丢失更新”的方式——monitor指令强制执行以下顺序:

...
A: monitorenter
A: getfield
A: putfield
A: monitorexit
...
B: monitorenter
B: getfield
B: putfield
B: monitorexit
...

这提供了同步块之间的发生之前关系:一个同步块的结束发生在任何其他同步块的开始之前,这是由 JMM 保证的。

我们还应该注意,Java 源代码编译器确保每个包含monitorenter的方法中的代码路径都会在方法终止前执行一个monitorexit。不仅如此,在类加载时,类文件验证器会拒绝任何试图规避此规则的类。

我们现在可以看到“同步是 Java 中的合作机制”这一说法的基础。让我们看看当线程 A 调用safeWithdraw()而线程 B 调用rawDeposit()时会发生什么:

    public boolean safeWithdraw(final int amount) {
        // Check to see amount > 0, throw if not
        synchronized (this) {
            if (balance >= amount) {
                balance = balance - amount;
                return true;
            }
        }
        return false;
    }

我们再次重现了 Java 代码,以便于比较:

public boolean safeWithdraw(int);
    Code:
       0: aload_0
       1: dup
       2: astore_2
       3: monitorenter
       4: aload_0
       5: getfield      #2  // Field balance:D
       8: iload_1
       9: i2d
      10: dcmpl
      11: iflt          29
      14: aload_0
      15: aload_0
      16: getfield      #2  // Field balance:D
      19: iload_1
      20: i2d
      21: dsub
      22: putfield      #2  // Field balance:D
      25: iconst_1
      26: aload_2
      27: monitorexit
      28: ireturn

存款代码非常简单:只读取一个字段,进行算术运算,然后将结果写回同一个字段,如下所示:

public void rawDeposit(int amount) {
        // Check to see amount > 0, throw  if not
        balance = balance + amount;
    }

字节码看起来更复杂,但实际上并不复杂:

public void rawDeposit(int);
    Code:
       0: aload_0
       1: aload_0
       2: getfield      #2  // Field balance:D
       5: iload_1
       6: i2d
       7: dadd
       8: putfield      #2  // Field balance:D
      11: return

注意:rawDeposit()的代码中不包含任何monitor指令——没有monitorenter,锁将永远不会被检查。

在两个线程AB之间,这种排序是完全可能的,如下所示:

      // ...
       A3: monitorenter
      // ...

      A14: aload_0
      A15: aload_0
      A16: getfield      #2  // Field balance:D

      // ... Context switch A -> B

       B0: aload_0
       B1: aload_0
       B2: getfield      #2  // Field balance:D
       B5: iload_1
       B6: i2d
       B7: dadd
       B8: putfield      #2  // Field balance:D    ❶

      // ... Context switch B -> A

      B11: return
      A19: iload_1
      A20: i2d
      A21: dsub
      A22: putfield      #2  // Field balance:D    ❷
      A25: iconst_1
      A26: aload_2
      A27: monitorexit
      A28: ireturn

❶ 通过非同步方法对余额的写入

❷ 通过同步对余额进行第二次写入

这只是我们熟悉的老朋友“丢失更新”,但现在它发生在一种方法使用同步而另一种方法没有使用同步的情况下。存款金额已经丢失——这对银行是个好消息,但对客户来说却不是那么好。不可避免的结论是:为了获得同步提供的安全保护,所有方法都必须正确使用它。

5.5.3 同步方法

到目前为止,我们一直在谈论同步块的情况,但同步方法的情况又如何呢?我们可能会猜测编译器会插入合成的monitor字节码,但实际上并非如此,正如我们通过将安全方法修改如下所示可以看到:

    public synchronized boolean safeWithdraw(final int amount) {
        // Check to see amount > 0, throw if not
        if (balance >= amount) {
            balance = balance - amount;
            return true;
        }
        return false;
    }

    // and the others...

实际上,方法上的synchronized修饰符并没有出现在字节码序列中,而是出现在方法的标志中,作为ACC_SYNCHRONIZED。我们可以通过重新编译方法并注意到monitor指令已经消失来看到这一点,如下所示:

    public synchronized boolean safeWithdraw(int);
    Code:
       0: aload_0
       1: getfield      #2  // Field balance:D
       4: iload_1
       5: i2d
       6: dcmpl
       7: iflt          23
      10: aload_0
      // ... no monitor instructions

当执行invoke指令时,字节码解释器首先检查的一件事是查看该方法是否是synchronized。如果是,则解释器沿着不同的代码路径进行——首先尝试获取适当的锁。如果没有ACC_SYNCHRONIZED,则不会进行此类检查。

这意味着,正如我们可能预期的,一个非同步方法可以与一个同步方法同时执行,因为只有一个方法执行了锁的检查。

5.5.4 非同步读取

Java 并发中一个非常常见的初学者错误是假设“只有写入数据的方法需要同步;读取是安全的。”这绝对不是真的,我们将证明这一点。

这种对读取操作的错误安全感有时发生,因为正在推理的代码示例过于简单。当我们向示例中引入一小笔 ATM 费用时——比如说,取款金额的 1%——会发生什么?

    private final double atmFeePercent = 0.01;

    public boolean safeWithdraw(final int amount, final boolean withFee) {
        // Check to see amount > 0, throw if not
        synchronized (this) {
            if (balance >= amount) {
                balance = balance - amount;
                if (withFee) {
                    balance = balance - amount * atmFeePercent;
                }
                return true;
            }
        }
        return false;
    }

这个方法的字节码现在稍微复杂一些:

public boolean safeWithdraw(int, boolean);
    Code:
       0: aload_0
       1: dup
       2: astore_3
       3: monitorenter
       4: aload_0
       5: getfield      #2  // Field balance:D
       8: iload_1
       9: i2d
      10: dcmpl
      11: iflt          49                            ❶
      14: aload_0
      15: aload_0
      16: getfield      #2  // Field balance:D
      19: iload_1
      20: i2d
      21: dsub
      22: putfield      #2  // Field balance:D        ❷
      25: iload_2
      26: ifeq          45
      29: aload_0
      30: aload_0
      31: getfield      #2  // Field balance:D
      34: iload_1
      35: i2d
      36: aload_0
      37: getfield      #5  // Field atmFeePercent:D
      40: dmul
      41: dsub
      42: putfield      #2  // Field balance:D        ❸
      45: iconst_1
      46: aload_3
      47: monitorexit
      48: ireturn
      49: aload_3
      50: monitorexit
      51: goto          61
      54: astore        4
      56: aload_3
      57: monitorexit
      58: aload         4
      60: athrow
      61: iconst_0
      62: ireturn

❶ 比较余额与金额

❷ 更新账户余额。

❸ 应用费用并再次更新余额。

注意,现在有两个putfield指令,因为safeWithdraw()方法接受一个boolean参数,用于确定是否应收费。两次单独更新的事实引发了并发错误的潜在可能性。

读取原始余额的代码非常简单:

public double getRawBalance();
    Code:
       0: aload_0
       1: getfield      #2  // Field balance:D
       4: dreturn

然而,这可以与带有费用的取款代码交织在一起,如下所示:

      A14: aload_0
      A15: aload_0
      A16: getfield      #2  // Field balance:D
      A19: iload_1
      A20: i2d
      A21: dsub
      A22: putfield      #2  // Field balance:D   ❶
      A25: iload_2
      A26: ifeq          45
      A29: aload_0
      A30: aload_0
      A31: getfield      #2  // Field balance:D

      // ... Context switch A -> B

       B0: aload_0
       B1: getfield      #2  // Field balance:D   ❷
       B4: dreturn

      // ... Context switch B -> A

      A34: iload_1
      A35: i2d
      A36: aload_0
      A37: getfield      #5  // Field atmFeePercent:D
      A40: dmul
      A41: dsub
      A42: putfield      #2  // Field balance:D

❶ 减去金额(但不是费用)后写入的余额

❷ 在完整取款处理过程中读取的余额

在非同步读取中,存在发生不可重复读取的可能性——一个实际上并不对应于系统真实状态的价值。如果你熟悉 SQL 数据库,这可能会让你想起在数据库事务进行中执行读取操作。

注意:你可能会想,“我知道字节码”,并基于此优化你的代码。你应该抵制这种诱惑,有几个原因。例如,当你将代码交给其他开发者维护,而这些开发者不了解看似无害的代码更改的上下文或后果时,会发生什么?

结论:对于“仅读取”没有逃避的途径。如果即使一个代码路径未能正确使用同步,生成的代码也不是线程安全的,因此在多线程环境中是不正确的。让我们继续前进,看看死锁如何在字节码中体现出来。

5.5.5 再次探讨死锁

假设银行想要将账户间转账的功能添加到我们的代码中。这个代码的初始版本可能看起来像这样:

    public boolean naiveSafeTransferTo(Account other, int amount) {
        // Check to see amount > 0, throw if not
        synchronized (this) {
            if (balance >= amount) {
                balance = balance - amount;
                synchronized (other) {
                    other.rawDeposit(amount);
                }
                return true;
            }
        }
        return false;
    }

这会产生相当长的字节码列表,所以我们通过省略检查余额是否支持取款以及一些合成异常处理块来缩短它。

注意:现在有两个账户对象,每个对象都有一个锁。为了安全起见,我们需要协调对两个锁的访问——属于this的锁和属于other的锁。

我们需要处理两对monitor指令,每一对处理不同对象的锁:

public boolean naiveSafeTransferTo(Account, int);
    Code:
       0: aload_0
       1: dup
       2: astore_3
       3: monitorenter                                 ❶

      // Omit the usual balance checking bytecode

      14: aload_0
      15: aload_0
      16: getfield      #2  // Field balance:D
      19: iload_2
      20: i2d
      21: dsub
      22: putfield      #2  // Field balance:D
      25: aload_1
      26: dup
      27: astore        4
      29: monitorenter                                 ❷
      30: aload_1
      31: iload_2
      32: invokevirtual #6  // Method rawDeposit:(I)V
      35: aload         4
      37: monitorexit                                  ❸
      38: goto          49

      // Omit exception handling code

      49: iconst_1
      50: aload_3
      51: monitorexit                                  ❹
      52: ireturn

      // Omit exception handling code

❶ 获取这个对象的锁

❷ 获取另一个对象的锁

❸ 释放另一个对象的锁

❹ 释放这个对象的锁

想象有两个线程正在尝试在相同的两个账户之间转账——让我们称这两个线程为AB。进一步假设,这两个线程正在执行由发送账户标记的事务,因此线程A正在尝试从对象A向对象B转账,反之亦然:

       A0: aload_0
       A1: dup
       A2: astore_3
       A3: monitorenter                            ❶

       // Omit the usual balance checking bytecode

       B0: aload_0
       B1: dup
       B2: astore_3
       B3: monitorenter                            ❷

       // Omit the usual balance checking bytecode

      B14: aload_0
      B15: aload_0
      B16: getfield      #2  // Field balance:D
      B19: iload_2
      B20: i2d
      B21: dsub
      B22: putfield      #2  // Field balance:D
      B25: aload_1
      B26: dup
      B27: astore        4
      B29: ...                                     ❸
      A14: aload_0
      A15: aload_0
      A16: getfield      #2  // Field balance:D
      A19: iload_2
      A20: i2d
      A21: dsub
      A22: putfield      #2  // Field balance:D
      A25: aload_1
      A26: dup
      A27: astore        4
      A29: ...                                     ❹

❶ 线程 A 获取的账户对象 A 的锁(由线程 A 获取)

❷ 线程 B 获取的账户对象 B 的锁

❸ 线程 B 尝试获取对象 A 的锁。它失败了,然后阻塞。

❹ 线程 A 尝试获取对象 B 的锁。它失败了,然后阻塞。

执行这个序列后,两个线程都无法继续前进。更糟糕的是,只有线程 A 可以释放对象A的锁,只有线程 B 可以释放对象B的锁,所以这两个线程被同步机制永久阻塞,这些方法调用将永远不会完成。通过在字节码级别查看死锁反模式,我们可以清楚地看到它实际上是由什么引起的。

5.5.6 死锁解决,重新审视

为了解决这个问题,正如我们之前讨论的,我们需要确保每个线程总是以相同的顺序获取锁。一种方法是为线程创建一个排序——比如说,通过引入一个唯一的账户号并实施规则:“首先获取对应最低账户 ID 的锁。”

注意:对于没有数字 ID 的对象,我们需要做些不同的事情,但使用不明确的总顺序的一般原则仍然适用。

这种方法会产生一点更多的复杂性,为了完全正确地执行它,我们需要保证账户 ID 不会被重复使用。我们可以通过引入一个static int字段来实现,这个字段持有下一个要分配的账户 ID,并且只在一个synchronized方法中更新它,如下所示:

    private static int nextAccountId = 1;

    private final int accountId;

    private static synchronized int getAndIncrementNextAccountId() {
        int result = nextAccountId;
        nextAccountId = nextAccountId + 1;
        return result;
    }

    public Account(int openingBalance) {
        balance = openingBalance;
        atmFeePercent = 0.01;
        accountId = getAndIncrementNextAccountId();
    }

    public int getAccountId() {
        return accountId;
    }

我们不需要同步getAccountId()方法,因为字段是final的,不能改变,如下所示:

    public boolean safeTransferTo(final Account other, final int amount) {
        // Check to see amount > 0, throw if not
        if (accountId == other.getAccountId()) {
            // Can't transfer to your own account
            return false;
        }

        if (accountId < other.getAccountId()) {
            synchronized (this) {
                if (balance >= amount) {
                    balance = balance - amount;
                    synchronized (other) {
                        other.rawDeposit(amount);
                    }
                    return true;
                }
            }
            return false;
        } else {
            synchronized (other) {
                synchronized (this) {
                    if (balance >= amount) {
                        balance = balance - amount;
                        other.rawDeposit(amount);
                        return true;
                    }
                }
            }
            return false;
        }
    }

结果的 Java 代码当然有点不对称。

注意:通过避免持有任何锁超过必要的时间,可以清楚地知道代码的哪些部分实际上需要锁。

之前的代码产生了一个非常长的字节码列表,但让我们按部分来分解它。首先,我们检查账户 ID 的排序:

      // Elide balance and account equality checks
      13: aload_0
      14: getfield      #8   // Field accountId:I
      17: aload_1
      18: invokevirtual #10  // Method getAccountId:()I
      21: if_icmpge     91

如果A < B(确实如此),则继续执行到指令 24;否则,跳转到 91,如下所示:

      24: aload_0
      25: dup
      26: astore_3
      27: monitorenter                                ❶
      28: aload_0
      29: getfield      #3   // Field balance:D
      32: iload_2
      33: i2d
      34: dcmpl
      35: iflt         77                             ❷

synchronized(this)的开始

❷ 如果资金不足,则跳转到偏移量 77(稍后)

让我们跟随发送账户有足够资金继续的分支,因此控制流通过字节码 38,这是 Java 代码中 balance = balance - amount; 语句的开始:

      38: aload_0
      39: aload_0
      40: getfield      #3   // Field balance:D
      43: iload_2
      44: i2d
      45: dsub
      46: putfield      #3   // Field balance:D
      49: aload_1
      50: dup
      51: astore        4
      53: monitorenter                                 ❶
      54: aload_1
      55: iload_2
      56: invokevirtual #9   // Method rawDeposit:(I)V
      59: aload         4
      61: monitorexit                                  ❷
      62: goto          73
      // Omit exception handling code
      73: iconst_1
      74: aload_3
      75: monitorexit                                  ❸
      76: ireturn

❶ synchronized (other) {} 的开始

❷ synchronized (other) {} 的结束

❸ synchronized (this) {} 的结束

为了完整性,让我们展示在发送账户余额不足的情况下的代码路径。我们基本上只是解锁 this 上的监视器并返回这个:

      77: aload_3
      78: monitorexit                    ❶
      79: goto          89
      // Omit exception handling code
      89: iconst_0
      90: ireturn

❶ synchronized (this) {} 的结束

注意,一些指令(如 invokemonitor 指令)可能会抛出异常,所以我们,像往常一样,忽略这些异常的字节码处理器。方法的其余部分如下所示:

      91: aload_1
      // ...
      // Highly similar, but for the other branch

让我们看看两个线程会发生什么,记住账户 ID A < B

现在我们有一个额外的复杂性:两个线程中的局部变量(在 aload_0 等指令中使用)是不同的。为了突出这种区别,我们将稍微修改字节码,通过将线程标签添加到局部变量上,以便我们用 aload_A0aload_A1 来表示,以增加清晰度:

      A24: aload_A0
      A25: dup
      A26: astore_A3
      A27: monitorenter                                        ❶

      // Elide balance check

      A38: aload_A0
      A39: aload_A0
      A40: getfield      #3   // Field balance:D

// ....           Context switch A -> B

      B91: aload_B1
      B92: dup
      B93: astore_B3
      B94: monitorenter                                        ❷

// ....           Context switch B -> A

      A43: iload_A2
      A44: i2d
      A45: dsub
      A46: putfield      #3   // Field balance:D
      A49: aload_A1
      A50: dup
      A51: astore        A4
      A53: monitorenter                                        ❸
      A54: aload_A1
      A55: iload_A2
      A56: invokevirtual #9   // Method rawDeposit:(I)V
      A59: aload         A4
      A61: monitorexit                                         ❹
      A62: goto          73

      // Omit exception handling code

      A73: iconst_A1
      A74: aload_A3
      A75: monitorexit                                         ❺

// ....           Context switch A -> B

      B95: aload_B0
      B96: dup
      B97: astore        B4
      B99: monitorenter
      // ...
     B132: ireturn

// ....           Context switch B -> A

      A76: ireturn

❶ 线程 A 获取对象 A 的锁

❷ 线程 B 尝试获取对象 A 的锁:阻塞

❸ 线程 A 获取对象 B 的锁

❹ 线程 A 释放对象 B 的锁

❺ 线程 A 释放对象 A 的锁:线程 B 可以继续执行

毫无疑问,这是一个复杂的列表。关键洞察是 A0 == B1,因此锁定这两个对象将始终在第二个线程中引起阻塞调用。不变量 A < B 确保线程 B 被发送到替代分支。

5.5.7 可见性访问

在字节码中 volatile 看起来是什么样子?让我们通过查看一个重要的模式——Volatile Shutdown——来帮助回答这个问题。

Volatile Shutdown 模式有助于解决我们在遇到危险的已弃用 stop() 方法时提到的线程间通信问题。考虑一个负责执行一些工作的简单类。在最简单的情况下,我们将假设工作以离散的单位到来,每个单位都有一个定义良好的“完成”状态,如下所示:

public class TaskManager implements Runnable {
    private volatile boolean shutdown = false;

    public void shutdown() {
        shutdown = true;
    }

    @Override
    public void run() {
        while (!shutdown) {
            // do some work - e.g. process a work unit
        }
    }
}

该模式的意图希望是清晰的。只要 shutdown 标志为 false,工作单元将继续被处理。如果它变为 true,那么 TaskManager 在完成当前工作单元后,将退出 while 循环,线程将干净地退出,即“优雅关闭”。

更微妙的一点来自于 Java 内存模型:对任何 volatile 变量的写入都会在所有后续对该变量的读取之前发生。一旦另一个线程在 TaskManager 对象上调用 shutdown(),标志将变为 true,并且该变化的效果将保证在下一个读取标志时可见——在下一个工作单元被接受之前。

Volatile Shutdown 模式生成的字节码如下所示:

public class TaskManager implements java.lang.Runnable {
  private volatile boolean shutdown;

  public TaskManager();
    Code:
       0: aload_0
       1: invokespecial #1          // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_0
       6: putfield      #2          // Field shutdown:Z
       9: return

  public void shutdown();
    Code:
       0: aload_0
       1: iconst_1
       2: putfield      #2          // Field shutdown:Z
       5: return

  public void run();
    Code:
       0: aload_0
       1: getfield      #2          // Field shutdown:Z
       4: ifne          10
       7: goto          0
      10: return
}

如果你仔细观察,你会发现shutdown的 volatile 特性仅在字段定义中出现。在操作码中没有额外的线索——它使用标准的getfieldputfield操作码进行访问。

注意volatile是一种硬件访问模式,它产生一个 CPU 指令,指示忽略缓存硬件,而是直接从主内存中读取或写入。

唯一的区别在于putfieldgetfield的行为——字节码解释器的实现将为 volatile 字段和标准字段提供不同的代码路径。

事实上,任何物理内存都可以以 volatile 的方式访问,并且——正如我们稍后将看到的——这并不是唯一的访问模式。volatile 只是访问语义的常见情况,James Gosling 和 Java 的原始设计者选择将其编码在语言的核心中,通过将其作为一个可以应用于字段的关键字。

并发是 Java 平台最重要的特性之一,一个优秀的开发者将越来越需要对其有一个坚实的理解。我们已经回顾了 Java 并发的底层原理以及多线程系统中出现的设计力量。我们讨论了 Java 内存模型以及平台实现并发的底层细节。

本章的目的并不是要完整地陈述你将需要了解的所有关于并发的知识——它足以让你开始学习,并让你了解你需要进一步学习的内容,以及在你编写并发代码时避免危险。但如果你想要成为一名真正一流的并发代码开发者,你需要了解的将远超我们在这里所能涵盖的内容。关于 Java 并发的优秀书籍有很多。其中之一是 Brian Goetz 和其他人合著的《Java 并发实践》(Addison-Wesley Professional,2006 年)。

摘要

  • Java 的线程是一个低级抽象。

  • 多线程甚至存在于 Java 字节码中。

  • Java 内存模型非常灵活,但只提供最少的保证。

  • 同步是一种协作机制——所有线程都必须参与以实现安全性。

  • 永远不要使用Thread.stop()Thread.suspend()

6 JDK 并发库

本章涵盖了

  • 原子类

  • 锁类

  • 并发数据结构

  • 阻塞队列

  • Futures 和 CompletableFuture

  • 执行器

在本章中,我们将介绍每个有经验的开发者都应该了解的 java.util.concurrent 以及如何使用它提供的并发构建块工具箱。目标是到本章结束时,你将准备好在自己的代码中开始应用这些库和并发技术。

6.1 现代并发应用的基本构建块

正如我们在上一章所看到的,Java 从一开始就支持并发。然而,随着 Java 5 的出现(这本身已经超过 15 年了),Java 中关于并发的思考方式出现了一种新的方式。这由 java.util.concurrent 包引领,它包含了一套丰富的工具箱,用于处理多线程代码。

注意:这个工具箱在 Java 的后续版本中得到了增强,但 Java 5 中引入的类和包仍然以相同的方式工作,并且对工作开发者来说仍然非常有价值。

如果你(仍然!)有基于较老(Java 5 之前)方法的现有多线程代码,你应该考虑重构它以使用 java.util.concurrent。根据我们的经验,如果你有意识地将其迁移到较新的 API,你的代码将会得到改进——更高的清晰度和可靠性将值得所付出的迁移努力。

我们将游览 java.util.concurrent 和相关包中的一些主要类,例如原子和锁包。我们将帮助你开始使用这些类,并查看它们的使用案例。

你还应该阅读它们的 Javadoc,并尝试熟悉整个包。大多数开发者发现,它们提供的更高层次抽象使并发编程变得更加容易。

6.2 原子类

java.util.concurrent.atomic 包含了几个以 Atomic 开头的类,例如 AtomicBooleanAtomicIntegerAtomicLongAtomicReference。这些类是并发原语的最简单例子之一——一个可以用来构建可工作、安全并发应用的类。

警告:原子类不继承自同名的类,因此 AtomicBoolean 不能替代 Boolean,而 AtomicInteger 也不是 Integer(但它扩展了 Number)。

原子操作的目的在于提供线程安全的可变变量。这四个类中的每一个都提供了访问相应类型单个变量的接口。

注意:原子操作的实现被编写为利用现代处理器的特性,因此如果硬件和操作系统支持,它们可以是非阻塞的(无锁的),这在几乎所有现代系统中都是可行的。

提供的访问在几乎所有现代硬件上都是无锁的,因此原子操作的行为与 volatile 字段类似。然而,它们被封装在一个类 API 中,这个 API 超越了 volatile 的可能性。这个 API 包括适合操作的原子(意味着全有或全无)方法——包括依赖于状态更新(在没有使用锁的情况下,使用 volatile 变量是无法做到的)。结果是,原子操作可以是一个非常简单的方法,让开发者避免在共享数据上出现竞态条件。

注意:如果你对原子操作是如何实现的感兴趣,我们将在第十七章讨论细节,当我们讨论内部结构和 sun.misc.Unsafe 类时。

原子操作的一个常见用例是实现类似于序列号的功能,就像你可能在 SQL 数据库中找到的那样。这个功能可以通过在 AtomicIntegerAtomicLong 类上使用原子的 getAndIncrement() 方法来访问。让我们看看我们如何将第五章中的 Account 示例重写为使用原子操作:

    private static AtomicInteger nextAccountId = new AtomicInteger(1);

    private final int accountId;
    private double balance;

    public Account(int openingBalance) {
        balance = openingBalance;
        accountId = nextAccountId.getAndIncrement();
    }

每当创建一个对象时,我们都会在 AtomicInteger 的静态实例上调用 getAndIncrement(),它返回一个整数值并原子地增加可变变量。这种原子性保证两个对象不可能共享相同的 accountId,这正是我们想要的属性(就像数据库序列号一样)。

注意:我们可以将 final 标识符添加到原子操作中,但这是不必要的,因为字段是 static 的,并且类没有提供任何修改字段的方法。

作为另一个例子,以下是我们将如何重写我们的 volatile 关闭示例以使用 AtomicBoolean

public class TaskManager implements Runnable {
    private final AtomicBoolean shutdown = new AtomicBoolean(false);

    public void shutdown() {
        shutdown.set(true);
    }

    @Override
    public void run() {
        while (!shutdown.get()) {
            // do some work - e.g. process a work unit
        }
    }
}

除了这些示例之外,AtomicReference 也可以用来实现对象的原子变更。一般模式是构建一些可能不可变的修改后的状态,然后可以通过在 AtomicReference 上使用 比较并交换 (CAS) 操作来“交换”这些状态。

接下来,让我们考察 java.util.concurrent 如何对经典同步方法的核心——Lock 接口进行建模。

6.3 锁类

同步的块结构方法基于锁的简单概念。这种方法有一些缺点,如下所述:

  • 只存在一种类型的锁。

  • 这同样适用于锁定对象上的所有同步操作。

  • 锁是在同步块或方法开始时获取的。

  • 锁是在块或方法结束时释放的。

  • 要么获取锁,要么线程无限期地阻塞——没有其他结果可能发生。

如果我们要重新设计锁的支持,我们可能会对几个方面进行改进:

  • 添加不同类型的锁(例如读写锁)。

  • 不要将锁限制在块中(允许一个方法获取锁,另一个方法释放锁)。

  • 如果一个线程无法获取锁(例如,如果另一个线程持有锁),允许线程退出或继续或做其他事情——一个tryLock()

  • 允许一个线程尝试获取锁,并在一定时间后放弃。

实现所有这些可能性的关键是java.util.concurrent.locks中的Lock接口。此接口附带以下实现:

  • ReentrantLock—这本质上等同于 Java 同步块中使用的熟悉锁,但更灵活。

  • ReentrantReadWriteLock—在存在许多读者但很少写者的情况下,这可以提供更好的性能。

注意:其他实现存在,包括 JDK 内部和第三方编写的,但这些是最常见的。

Lock接口可以用来完全复制任何由块结构并发提供的功能。例如,列表 6.1 展示了第五章中如何避免死锁的示例,重写为使用ReentrantLock

我们需要将锁对象作为字段添加到类中,因为我们不再依赖于对象的内建锁。我们还需要维护锁总是以相同顺序获取的原则。在我们的示例中,我们保持的简单协议是,具有最低账户 ID 的对象的锁首先获取。

列表 6.1 重写死锁示例以使用ReentrantLock

    private final Lock lock = new ReentrantLock();

    public boolean transferTo(SafeAccount other, int amount) {
        // We also need code to check to see amount > 0, throw if not
        // ...

        if (accountId == other.getAccountId()) {
            // Can't transfer to your own account
            return false;
        }

        var firstLock = accountId < other.getAccountId() ?
                lock : other.lock;
        var secondLock = firstLock == lock ? other.lock : lock;

        firstLock.lock();             ❶
        try {
            secondLock.lock();        ❷
            try {
                if (balance >= amount) {
                    balance = balance - amount;
                    other.deposit(amount);
                    return true;
                }
                return false;
            } finally {
                secondLock.unlock();
            }
        } finally {
            firstLock.unlock();
        }
    }

❶ 第一个锁对象具有较低的账户 ID。

❷ 第二个锁对象具有较高的账户 ID。

将对lock()的初始调用与一个try ... finally块结合的模式,在finally中释放锁,是您工具箱中的一个很好的补充。

注意:锁,就像java.util.concurrent中的许多内容一样,依赖于一个名为AbstractQueuedSynchronizer的类来实现其功能。

如果您正在复制一个类似于您会使用块结构并发的场景,这种模式工作得非常好。另一方面,如果您需要传递Lock对象(例如,通过从方法返回它),则不能使用此模式。

6.3.1 条件对象

java.util.concurrent提供的 API 的另一个方面是条件对象。这些对象在 API 中扮演的角色与原始内建 API 中的wait()notify()相同,但更灵活。它们提供了线程能够无限期等待某些条件,并在该条件变为真时被唤醒的能力。

然而,与内建 API(其中对象监视器只有一个条件用于信号)不同,Lock接口允许程序员创建尽可能多的条件对象。这允许关注点的分离——例如,锁可以有多个不重叠的方法组,这些方法可以使用不同的条件。

通过在实现 Lock 接口的锁对象上调用 newCondition() 方法创建一个条件对象(它实现了 Condition 接口)。除了条件对象之外,API 还提供了一些 闩锁屏障 作为并发原语,在某些情况下可能很有用。

6.4 CountDownLatch

CountDownLatch 是一个简单的并发原语,它提供了一个 共识屏障——它允许多个线程达到一个协调点并等待直到屏障释放。这是通过在创建 CountDownLatch 的新实例时提供一个整数值(count)来实现的。从那时起,使用两个方法来控制闩锁:countDown()await()。前者将计数减 1,后者使调用线程阻塞,直到计数达到 0(如果计数已经是 0 或更少,则不执行任何操作)。在下面的列表中,闩锁被每个 Runnable 用于指示它已完成分配的工作。

列表 6.2 使用闩锁在线程间进行信号传递

    public static class Counter implements Runnable {
        private final CountDownLatch latch;
        private final int value;
        private final AtomicInteger count;

        public Counter(CountDownLatch l, int v, AtomicInteger c) {
            this.latch = l;
            this.value = v;
            this.count = c;
        }

        @Override
        public void run() {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            count.addAndGet(value);   ❶
            latch.countDown();        ❷
        }
    }

❶ 原子性地更新计数值

❷ 递减闩锁

注意,countDown() 方法是非阻塞的,因此一旦闩锁被递减,运行 Counter 代码的线程将退出。

我们还需要一些驱动代码,如下所示(省略了异常):

var latch = new CountDownLatch(5);
var count = new AtomicInteger();
for (int i = 0; i < 5; i = i + 1) {
    var r  = new Counter(latch, i, count);
    new Thread(r).start();
}

latch.await();
System.out.println("Total: "+ count.get());

在代码中,闩锁被设置为法定人数值(在图 6.1 中,值为 2)。接下来,创建并初始化相同数量的线程,以便开始处理。主线程等待闩锁并阻塞,直到它被释放。每个工作线程在完成处理后将执行睡眠并调用 countDown()。主线程将不会继续进行,直到两个线程都完成其处理。这种情况在图 6.1 中显示。

图片

图 6.1 使用 CountDownLatch

为了提供一个 CountDownLatch 的良好用例的另一个示例,考虑一个需要在服务器准备好接收传入请求之前预先填充几个缓存的参考数据的应用程序。我们可以通过使用一个共享的闩锁,每个缓存填充线程都持有该闩锁的引用,轻松地实现这一点。

当每个缓存加载完成时,填充它的 Runnable 会递减闩锁并退出。当所有缓存都加载完成后,主线程(它一直在等待闩锁打开)可以继续进行,并准备好将服务标记为启动并开始处理请求。

我们接下来要讨论的下一个类是多线程开发者工具箱中最有用的类之一:ConcurrentHashMap

6.5 ConcurrentHashMap

ConcurrentHashMap 类提供了标准 HashMap 的并发版本。一般来说,映射是构建并发应用程序非常有用(且常见)的数据结构。这至少部分归因于底层数据结构的形状。让我们更仔细地看看基本的 HashMap,以了解为什么。

6.5.1 理解简化的 HashMap

如图 6.2 所示,经典的 Java HashMap使用一个函数(哈希函数)来确定它将存储键值对的哪个。这就是类名中“哈希”部分的原因。

图片

图 6.2 经典的HashMap视图

键值对实际上存储在一个从通过哈希键获得的索引对应的桶开始的链表中(称为哈希链)。

在伴随本书的 GitHub 项目中,有一个Map<String, String>的简化实现——Dictionary类。这个类实际上是基于 Java 7 中作为一部分提供的HashMap的形式。

注意:现代 Java 版本提供的HashMap实现要复杂得多,因此在这个解释中,我们关注一个更简单的版本,其中设计概念更清晰可见。

基本类只有两个字段:主要数据结构和size字段,它为了性能原因缓存了映射的大小,如下所示:

public class Dictionary implements Map<String, String> {
    private Node[] table = new Node[8];
    private int size;

    @Override
    public int size() {
        return size;
    }

    @Override
    public boolean isEmpty() {
        return size == 0;
    }

这些依赖于一个名为Node的辅助类,它代表一个键值对并实现了Map.Entry接口,如下所示:

    static class Node implements Map.Entry<String,String> {
        final int hash;
        final String key;
        String value;
        Node next;

        Node(int hash, String key, String value, Node next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        public final String getKey()        { return key; }
        public final String getValue()      { return value; }
        public final String toString() { return key + "=" + value; }

        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        public final String setValue(String newValue) {
            String oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Node) {
                Node e = (Node)o;
                if (Objects.equals(key, e.getKey()) &&
                        Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

要在映射中查找值,我们使用get()方法,该方法依赖于几个辅助方法,hash()indexFor(),如下所示:

    @Override
    public String get(Object key) {
        if (key == null)
            return null;
        int hash = hash(key);
        for (Node e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k = e.key;
            if (e.hash == hash && (k == key || key.equals(k)))
                return e.value;
        }
        return null;
    }

    static final int hash(Object key) {
        int h = key.hashCode();
        return h ^ (h >>> 16);                ❶
    }

    static int indexFor(int h, int length) {
        return h & (length - 1);              ❷
    }

❶ 一个位运算操作,以确保哈希值是正数

❷ 一个位运算操作,以确保索引在表的大小范围内

首先,get()方法处理了令人烦恼的 null 情况。随后,我们使用键对象的哈希码来构建一个指向数组table的索引。一个未写明的假设是table的大小是 2 的幂,因此indexFor()操作基本上是一个模运算,这确保了返回值是table的有效索引。

注意:这是一个人类思维可以确定异常(在这种情况下,ArrayIndexOutOfBoundsException)永远不会抛出的经典例子,但编译器不能。

现在我们有了table的索引,我们使用它来选择我们的查找操作的相关哈希链。我们从头部开始,沿着哈希链向下走。在每一步中,我们评估是否找到了我们的键对象,如下所示:

    if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
        return e.value;

如果我们有,则返回相应的值。我们将键和值作为对(实际上是Node实例)存储,以便采用这种方法。

put()方法与之前的代码有些相似:

    @Override
    public String put(String key, String value) {
        if (key == null)
            return null;

        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);
        for (Node e = table[i]; e != null; e = e.next) {
            Object k = e.key;
            if (e.hash == hash && (k == key || key.equals(k))) {
                String oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }

        Node e = table[i];
        table[i] = new Node(hash, key, value, e);

        return null;
    }

这种哈希数据结构的版本不是 100%的生产质量,但它的目的是演示基本行为和解决问题的方法,以便理解并发情况。

6.5.2 字典的限制

在我们继续讨论并发情况之前,我们应该提到,Map 中的一些方法在我们的玩具实现 Dictionary 中不受支持。具体来说,putAll()keySet()values()entrySet()(因为该类实现了 Map)将简单地抛出 UnsupportedOperationException()

我们不支持这些方法纯粹是因为复杂性。正如我们将在本书中多次看到的那样,Java 集合接口很大且功能丰富。这对于最终用户来说是个好事,因为他们有很多功能,但这意味着实现者必须提供更多的方法。

特别是像 keySet() 这样的方法需要 Map 实现提供 Set 实例,这通常会导致需要编写整个 Set 接口的实现作为内部类。这对于我们的示例来说太复杂了,所以我们只是不支持这些方法。

注意:正如我们将在本书后面看到的那样,集合接口的单体、复杂、命令式设计在详细考虑函数式编程时会出现各种问题。

简单的 Dictionary 类在其限制内工作得很好。然而,它没有防范以下两种情况:

  • 随着存储元素数量的增加,需要调整 table 的大小

  • 防御实现 hashCode() 的病态形式的键

这些方法中的第一个是一个严重的限制。散列数据结构的一个主要目的是将操作的预期复杂度从 O(N) 降低到 O(log N),例如,对于值检索。如果表的大小不随映射中存储的元素数量增加而调整,这种复杂度优势就会丧失。实际实现必须处理随着映射增长而调整表大小的需求。

6.5.3 并发字典的实现方法

目前来看,Dictionary 显然不是线程安全的。考虑两个线程——一个尝试删除某个键,另一个尝试更新与其关联的值。根据操作顺序的不同,删除和更新都可能报告它们成功了,而实际上只有一个成功了。为了解决这个问题,我们有两种相当明显(如果有些天真)的方法来使 Dictionary(以及由此扩展的通用 Java Map 实现)实现并发。

首先是完全同步的方法,我们在第五章中遇到过。这个结论不难预测:由于性能开销,这种方法对于大多数实际系统来说不可行。然而,简要地看看我们如何实现它还是值得的。

在这里,我们有两种简单的方法来实现简单的线程安全。第一种是复制 Dictionary 类——让我们称它为 ThreadSafeDictionary,然后使所有的方法都同步。这可行,但涉及到大量的复制和粘贴代码。

或者,我们可以使用一个同步包装器来提供委托——也就是转发——到实际包含字典的底层对象。下面是如何做到这一点的方法:

public final class SynchronizedDictionary extends Dictionary {
    private final Dictionary d;

    private SynchronizedDictionary(Dictionary delegate) {
        d = delegate;
    }

    public static SynchronizedDictionary of(Dictionary delegate) {
        return new SynchronizedDictionary(delegate);
    }

    @Override
    public synchronized int size() {
        return d.size();
    }

    @Override
    public synchronized boolean isEmpty() {
        return d.isEmpty();
    }

    // ... other methods

}

这个例子有几个问题,其中最重要的是对象d已经存在并且没有同步。这是在为自己设置失败——其他代码可能在同步块或方法之外修改d,我们发现自己处于与上一章讨论的完全相同的情况。这不是并发数据结构的正确方法。

我们应该提到,实际上,JDK 提供了这样一个实现——Collections类中提供的synchronizedMap()方法。它的工作效果大致如此,并且像你预期的那样广泛使用。

第二种方法是诉诸不可变性。正如我们将说到的,并且还会再次说到的,Java Collections 是庞大而复杂的接口。这种表现方式之一是,可变性的假设贯穿于整个集合中。在没有任何意义上,它不是某些实现可以选择或选择不表达的可分离关注点——所有MapList的实现都必须实现修改方法。

由于这种限制,我们可能会觉得在 Java 中无法建模一个既不可变又符合 Java Collections APIs 的数据结构——如果它符合 API,则该类还必须提供一个修改方法的实现。然而,存在一个令人非常不满意的解决方案。如果一个接口的实现没有实现某个方法,它总是可以抛出UnsupportedOperationException。从语言设计的角度来看,这当然是糟糕的。接口合约应该正好是那样——一个合约。

不幸的是,这种机制和惯例早于 Java 8(以及默认方法的到来),因此它代表了在 Java 语言中实际上不存在这种区分时,尝试编码“强制”方法和“可选”方法之间的差异。

这是一个糟糕的机制和做法(尤其是因为UnsupportedOperationException是一个运行时异常),但我们可以这样使用它:

public final class ImmutableDictionary extends Dictionary {
    private final Dictionary d;

    private ImmutableDictionary(Dictionary delegate) {
        d = delegate;
    }

    public static ImmutableDictionary of(Dictionary delegate) {
        return new ImmutableDictionary(delegate);
    }

    @Override
    public int size() {
        return d.size();
    }

    @Override
    public String get(Object key) {
        return d.get(key);
    }

    @Override
    public String put(String key, String value) {
        throw new UnsupportedOperationException();
    }

    // other mutating methods also throw UnsupportedOperationException

}

可以认为这是对面向对象原则的一种违反——用户的期望是这是一个有效的Map<String, String>实现,然而,如果用户尝试修改实例,则会抛出一个未经检查的异常。这可以合法地被视为一个安全隐患。

注意:这基本上是Map.of()必须做出的妥协:它需要完全实现接口,因此不得不求助于在修改方法调用时抛出异常。

这也不是这个方法唯一的问题。另一个缺点是,这当然也受我们之前看到的同步案例的基本缺陷的影响——仍然存在一个可变对象,并且可以通过该路径引用(并修改),违反了我们试图实现的基本标准。让我们对这些尝试画上一个句号,并尝试寻找更好的方法。

6.5.4 使用ConcurrentHashMap

在展示了简单的映射实现并讨论了我们可以用来使其并发的途径之后,现在是时候认识ConcurrentHashMap了。在某种程度上,这很简单:这是一个极其易于使用的类,在大多数情况下是HashMap的直接替代品。

关于ConcurrentHashMap的关键点在于它允许多个线程同时安全地更新它。为了了解为什么我们需要这一点,让我们看看当有两个线程同时向一个HashMap添加条目时会发生什么(省略了异常处理):

var map = new HashMap<String, String>();
var SIZE = 10_000;

Runnable r1 = () -> {
    for (int i = 0; i < SIZE; i = i + 1) {
        map.put("t1" + i, "0");
    }
    System.out.println("Thread 1 done");
};
Runnable r2 = () -> {
    for (int i = 0; i < SIZE; i = i + 1) {
        map.put("t2" + i, "0");
    }
    System.out.println("Thread 2 done");
};
Thread t1 = new Thread(r1);
Thread t2 = new Thread(r2);
t1.start();
t2.start();

t1.join();
t2.join();
System.out.println("Count: "+ map.size());

如果我们运行这段代码,我们将看到我们老朋友,丢失更新反模式的另一种表现——Count的输出值将小于2 * SIZE。然而,在并发访问映射的情况下,情况实际上要糟糕得多。

在并发修改下,HashMap最危险的行为并不总是出现在小尺寸时。然而,如果我们增加SIZE的值,它最终会表现出来。

如果我们将SIZE增加到,比如说,1_000_000,那么我们很可能会看到这种行为。更新map的线程之一将无法完成。没错——一个线程可以(并将)陷入一个实际的无穷循环。这使得HashMap在多线程应用中使用时完全不安全(我们的示例Dictionary类也是如此)。

另一方面,如果我们用ConcurrentHashMap替换HashMap,那么我们可以看到并发版本的行为是正确的——没有无穷循环,也没有丢失更新的实例。它还有一个很好的特性,即无论你对它做什么,映射操作都不会抛出ConcurrentModificationException

让我们简要地看看这是如何实现的。结果发现,图 6.2,它展示了Dictionary的实现,也指出了对Map的一个有用的多线程泛化,这个泛化比我们之前的两个尝试都要好。它基于以下洞察:在做出更改时,不需要锁定整个结构,只需要锁定正在更改或读取的哈希链(也称为桶)。

我们可以在图 6.3 中看到这是如何工作的。实现已经将锁下移到单个哈希链上。这种技术被称为锁条带化,它使得多个线程可以访问映射,前提是它们正在操作不同的链。

图 6.3 锁条带化

当然,如果两个线程需要操作同一个链,那么它们仍然会相互排除,但总的来说,这比同步整个映射提供了更好的吞吐量。

注意回想一下,随着映射中元素数量的增加,桶的表将进行缩放,这意味着随着越来越多的元素被添加到 ConcurrentHashMap 中,它将能够以更有效的方式处理越来越多的线程。

ConcurrentHashMap 实现了这种行为,但存在一些额外的低级细节,大多数开发者不需要过多担心。实际上,ConcurrentHashMap 的实现从 Java 8 开始发生了重大变化,现在它比我们在这里描述的设计更复杂。

使用 ConcurrentHashMap 可以几乎太简单了。在许多情况下,如果你有一个多线程程序并且需要共享数据,那么只需使用一个 Map,并让其实现为 ConcurrentHashMap。实际上,如果 Map 有可能被多个线程修改,那么你应该始终使用并发实现。它确实比普通的 HashMap 使用更多的资源,并且由于某些操作的同步,吞吐量会更差。然而,正如我们将在第七章中讨论的,与可能导致丢失更新或无限循环的竞态条件相比,这些不便微不足道。

最后,我们还应该注意,ConcurrentHashMap 实际上实现了 ConcurrentMap 接口,该接口扩展了 Map。它最初包含以下新方法,以提供线程安全的修改:

  • putIfAbsent()—如果键不存在,则将键值对添加到 HashMap 中。

  • remove()—如果键存在,则安全地删除键值对。

  • replace()—该实现为 HashMap 中的安全替换提供了两种不同形式的方法。

然而,随着 Java 8 的推出,其中一些方法被回滚到 Map 接口作为默认方法,例如:

default V putIfAbsent(K key, V value) {
        V v = get(key);
        if (v == null) {
            v = put(key, value);
        }

        return v;
    }

在 Java 的最新版本中,ConcurrentHashMapMap 之间的差距有所缩小,但别忘了,尽管如此,HashMap 仍然是非线程安全的。如果你想在多线程之间安全地共享数据,你应该使用 ConcurrentHashMap

总体而言,ConcurrentHashMapjava.util.concurrent 中最有用的类之一。它提供了比同步更高的多线程安全性和性能,并且在正常使用中没有任何严重的缺点。对于 List 的对应类是 CopyOnWriteArrayList,我们将在下一节中讨论。

6.6 CopyOnWriteArrayList

我们当然可以将我们在上一节中看到的两个不令人满意的并发模式应用到 List 上。完全同步和不可变(但具有抛出运行时异常的修改方法)的列表与映射一样容易编写,并且它们的效果也不比映射好。

我们能做得更好吗?不幸的是,列表的线性性质在这里没有帮助。即使在链表的情况下,多个线程尝试修改列表也会引发竞争的可能性,例如,在工作负载中有很大比例的追加操作时。

存在一个替代方案,那就是CopyOnWriteArrayList类。正如其名所示,这种类型是标准ArrayList类的替代品,它通过添加写时复制(copy-on-write)语义来提高线程安全性。这意味着任何修改列表的操作都将创建一个新副本,作为列表背后的数组(如图 6.4 所示)。这也意味着任何创建的迭代器都不必担心任何未预期的修改。

图片

图 6.4 写时复制数组

迭代器保证不会抛出ConcurrentModificationException,并且不会反映自迭代器创建以来列表的任何添加、删除或更改——当然,除了(在 Java 中通常如此)列表元素仍然可以修改。只是列表本身不能。

这种实现通常对于一般用途来说太昂贵,但在遍历操作远多于变异操作,并且程序员不想处理同步的麻烦,但又想排除线程相互干扰的可能性时,可能是一个不错的选择。

让我们快速看一下核心思想是如何实现的。关键方法是iterator(),它始终返回一个新的COWIterator对象:

    public Iterator<E> iterator() {
        return new COWIterator<E>(getArray(), 0);
    }

以及add()remove()和其他变异方法。变异方法始终用新的、克隆的并修改后的数组副本替换代表数组。保护数组必须在同步块内完成,因此CopyOnWriteArrayList类有一个内部锁,仅用作监视器(并注意其注释),如下所示:

    /**
     * The lock protecting all mutators.  (We have a mild preference
     * for builtin monitors over ReentrantLock when either will do.)
     */
    final transient Object lock = new Object();

    private transient volatile Object[] array;

然后,可以将像add()这样的操作保护如下:

     public boolean add(E e) {
        synchronized (lock) {
            Object[] es = getArray();
            int len = es.length;
            es = Arrays.copyOf(es, len + 1);
            es[len] = e;
            setArray(es);
            return true;
        }
    }

这使得CopyOnWriteArrayList在一般操作上比ArrayList效率更低,原因有几个:

  • 变异操作的同步。

  • 易失性存储(即array)。

  • ArrayList仅在需要调整底层数组的大小时才会分配内存;CopyOnWriteArrayList在每次变异时都会分配和复制。

创建迭代器时,会存储一个对该数组在那一刻存在的引用。进一步修改列表将导致创建一个新的副本,因此迭代器将指向数组的一个过去版本,如下所示:

     static final class COWIterator<E> implements ListIterator<E> {
        /** Snapshot of the array */
        private final Object[] snapshot;
        /** Index of element to be returned by subsequent call to next */
        private int cursor;

        COWIterator(Object[] es, int initialCursor) {
            cursor = initialCursor;
            snapshot = es;
        }
        // ...
    }

注意,COWIterator实现了ListIterator,因此根据接口合同,它必须支持列表变异方法,但为了简单起见,所有变异方法都抛出UnsupportedOperationException

CopyOnWriteArrayList 对共享数据采取的方法,当快速、一致的数据快照(读者之间可能偶尔不同)比完美的同步更重要时可能很有用。这在涉及非关键数据的情况下相当常见,而写时复制的方法避免了与同步相关的性能损失。

让我们看看下一个列表中写时复制的实际示例。

列表 6.3 基于写时复制的示例

        var ls = new CopyOnWriteArrayList(List.of(1, 2, 3));
        var it = ls.iterator();
        ls.add(4);
        var modifiedIt = ls.iterator();
        while (it.hasNext()) {
            System.out.println("Original: "+ it.next());
        }
        while (modifiedIt.hasNext()) {
            System.out.println("Modified: "+ modifiedIt.next());
        }

这段代码专门设计来展示 Iterator 在写时复制语义下的行为。它产生如下输出:

Original: 1
Original: 2
Original: 3
Modified: 1
Modified: 2
Modified: 3
Modified: 4

通常,使用 CopyOnWriteArrayList 类比使用 ConcurrentHashMap 需要更多的思考,因为 ConcurrentHashMap 主要是由于性能问题而成为 HashMap 的并发替代品——写时复制的特性意味着如果列表被修改,整个数组必须被复制。如果列表的更改比读取访问更常见,这种方法不一定能提供高性能。

通常,CopyOnWriteArrayListsynchronizedList() 的权衡不同。后者在所有操作上同步,因此不同线程的读取可以相互阻塞,这在 COW 数据结构中并不成立。另一方面,CopyOnWriteArrayList 在每次变异时都会复制底层数组,而同步版本仅在底层数组满时才这样做(与 ArrayList 的行为相同)。然而,正如我们在第七章中反复所说的,从第一原理推理代码极其困难——唯一可靠地获得高性能代码的方法是测试、重新测试并衡量结果。

在第十五章中,我们将遇到持久数据结构的概念,这是另一种处理并发数据的方法。Clojure 编程语言非常重视持久数据结构,CopyOnWriteArrayList(以及 CopyOnWriteArraySet)是它们的实现示例之一。

让我们继续前进。java.util.concurrent 中并发代码的下一个主要常见构建块是 Queue。它用于在线程之间传递工作元素,并且它是许多灵活和可靠的多线程设计的基础。

6.7 阻塞队列

队列是并发编程的一个奇妙抽象。队列提供了一个简单且可靠的方式来分配处理资源给工作单元(或者根据你如何看待它,将工作单元分配给处理资源)。

多线程 Java 编程中的许多模式都严重依赖于 Queue 的线程安全实现,因此完全理解它是很重要的。基本的 Queue 接口在 java.util 中,因为它可以是一个重要的模式,即使在单线程编程中也是如此,但我们将关注多线程的使用案例。

一个非常常见的用例,也是我们将要关注的,是使用队列在线程之间传递工作单元。这种模式通常非常适合 Queue 的最简单的并发扩展——BlockingQueue

BlockingQueue 是一个具有以下两个额外特殊属性的队列:

  • 当尝试向队列 put() 时,如果队列已满,它将导致放置线程等待空间变得可用。

  • 当尝试从队列中 take() 时,如果队列为空,它将导致获取线程阻塞。

这两个属性非常有用,因为如果一个线程(或线程池)的能力超过了另一个线程的能力,那么较快的线程将被迫等待,从而调节整个系统。这如图 6.5 所示。

图 6.5

图 6.5 BlockingQueue

Java 随带实现了两个基本的 BlockingQueue 接口:LinkedBlockingQueueArrayBlockingQueue。它们提供了一些不同的属性;例如,当队列的大小有一个确切的上限时,数组实现非常高效,而在某些情况下,链式实现可能稍微快一些。

然而,实现之间的真正区别在于隐含的语义。尽管链式变体可以构建一个大小限制,但它通常是不带限制创建的,这导致了一个队列大小为 Integer.MAX_VALUE 的对象。这实际上是无限的——在实际应用中,其队列中超过二十亿项的积压是无法恢复的。

因此,虽然在理论上 LinkedBlockingQueue 上的 put() 方法可能会阻塞,但在实践中,它从未这样做。这意味着正在向队列写入的线程可以有效地以无限的速度进行。

相比之下,ArrayBlockingQueue 的队列大小是固定的——支持它的数组的大小。如果生产线程将对象放入队列的速度比接收者处理的速度快,那么在某个时候,队列将完全填满,进一步调用 put() 的尝试将阻塞,生产线程将被迫降低任务生产的速度。

ArrayBlockingQueue 的这个属性是所谓的 背压 的一种形式,这是并发和分布式系统工程的一个重要方面。

让我们通过一个示例来看看 BlockingQueue 的实际应用:将账户示例修改为使用队列和线程。示例的目标是消除对账户对象进行锁定的需求。应用程序的基本架构如图 6.6 所示。

图 6.6 处理带队列的账户

图 6.6 处理带队列的账户

我们首先通过以下列表引入一个具有这些字段的 AccountManager 类。

列表 6.4 AccountManager

public class AccountManager {
    private ConcurrentHashMap<Integer, Account> accounts =
        new ConcurrentHashMap<>();
    private volatile boolean shutdown = false;

    private BlockingQueue<TransferTask> pending =
        new LinkedBlockingQueue<>();
    private BlockingQueue<TransferTask> forDeposit =
        new LinkedBlockingQueue<>();
    private BlockingQueue<TransferTask> failed =
        new LinkedBlockingQueue<>();

    private Thread withdrawals;
    private Thread deposits;

阻塞队列包含 TransferTask 对象,这些是简单的数据载体,表示要进行的传输,如下所示:

public class TransferTask {
    private final Account sender;
    private final Account receiver;
    private final int amount;

    public TransferTask(Account sender, Account receiver, int amount) {
        this.sender = sender;
        this.receiver = receiver;
        this.amount = amount;
    }

    public Account sender() {
        return sender;
    }

    public int amount() {
        return amount;
    }

    public Account receiver() {
        return receiver;
    }

    // Other methods elided
}

转账没有额外的语义——该类只是一个笨拙的数据载体类型。

注意:TransferTask类型非常简单,在 Java 17 中可以写成记录类型(我们在第三章中遇到过)

AccountManager类提供了创建账户和提交转账任务的功能,如下所示:

    public Account createAccount(int balance) {
        var out = new Account(balance);
        accounts.put(out.getAccountId(), out);
        return out;
    }

    public void submit(TransferTask transfer) {
        if (shutdown) {
            return false;
        }
        return pending.add(transfer);
    }

AccountManager的实际工作由管理队列之间转账任务的两个线程处理。让我们首先看看提款操作:

    public void init() {
        Runnable withdraw = () -> {
            boolean interrupted = false;
            while (!interrupted || !pending.isEmpty()) {
                try {
                    var task = pending.take();
                    var sender = task.sender();
                    if (sender.withdraw(task.amount())) {
                        forDeposit.add(task);
                    } else {
                        failed.add(task);
                    }
                } catch (InterruptedException e) {
                    interrupted = true;
                }
            }
            deposits.interrupt();
        };

存款操作的定义与此类似,然后我们使用以下任务初始化账户管理器:

    Runnable deposit = () -> {
            boolean interrupted = false;
            while (!interrupted || !forDeposit.isEmpty()) {
                try {
                    var task = forDeposit.take();
                    var receiver = task.receiver();
                    receiver.deposit(task.amount());
                } catch (InterruptedException e) {
                    interrupted = true;
                }
            }
        };

        init(withdraw, deposit);
    }

init()方法的包私有重载用于启动后台线程。它作为一个单独的方法存在,以便更容易进行测试,如下所示:

    void init(Runnable withdraw, Runnable deposit) {
        withdrawals = new Thread(withdraw);
        deposits = new Thread(deposit);
        withdrawals.start();
        deposits.start();
    }

我们需要一些代码来驱动这个过程:

        var manager = new AccountManager();
        manager.init();
        var acc1 = manager.createAccount(1000);
        var acc2 = manager.createAccount(20_000);

        var transfer = new TransferTask(acc1, acc2, 100);
        manager.submit(transfer);                           ❶
        Thread.sleep(5000);                                 ❷
        System.out.println(acc1);
        System.out.println(acc2);
        manager.shutdown();
        manager.await();

❶ 从 acc1 提交转账到 acc2

❷ 睡眠以允许时间执行转账

这会产生如下输出:

Account{accountId=1, balance=900.0,
    lock=java.util.concurrent.locks.ReentrantLock@58372a00[Unlocked]}
Account{accountId=2, balance=20100.0,
    lock=java.util.concurrent.locks.ReentrantLock@4dd8dc3[Unlocked]}

然而,尽管调用了shutdown()await(),代码仍然没有干净地执行,因为使用的调用具有阻塞性质。让我们看看图 6.7 来了解原因。

图 6.7 错误的关闭序列

当主代码调用shutdown()时,volatile 布尔标志被翻转成 true,所以后续对布尔值的每次读取都将看到这个值。不幸的是,由于队列是空的,提款和存款线程都阻塞在take()调用中。如果 somehow 将对象放入pending队列中,那么提款线程将处理它,然后将对象放入forDeposit队列(假设提款成功)。此时,提款线程将退出while循环,线程将正常终止。

然后,存款线程将看到forDeposit队列中的对象并唤醒,取走它,处理它,然后退出自己的while循环并正常终止。然而,这个干净的终止过程取决于队列中仍然有任务。在队列空的情况边缘,线程将永远坐在它们的阻塞take()调用中。为了解决这个问题,让我们探索阻塞队列实现提供的完整方法集。

6.7.1 使用 BlockingQueue API

BlockingQueue接口实际上提供了三种与它交互的独立策略。为了理解策略之间的差异,考虑以下场景中 API 可能显示的可能行为:一个线程尝试向一个当前无法容纳项目的容量受限队列中插入一个项目(即队列已满)。

从逻辑上讲,我们有以下三种可能性。插入调用可能

  • 阻塞直到队列中有空间释放

  • 返回一个值(可能是 Boolean false)以指示失败

  • 抛出异常

当然,在相反的情况下(尝试从一个空队列中取出项),也会出现同样的三种可能性。这些可能性中的第一种是通过我们已遇到的take()put()方法实现的。

注意:第二和第三是Queue接口提供的选项,它是BlockingQueue的超接口。

第二种选项提供了一个非阻塞的 API,它返回特殊值,并在offer()poll()方法中体现。如果无法完成队列的插入,则offer()会快速失败并返回 false。程序员必须检查返回码并采取适当的行动。

类似地,poll()在无法从队列中检索时立即返回null。在名为BlockingQueue的类上使用非阻塞 API 可能看起来有些奇怪,但实际上是有用的(并且也是由于BlockingQueueQueue之间的继承关系所要求的)。

实际上,BlockingQueue提供了非阻塞方法的额外重载。这些方法提供了带有超时的轮询或提供的功能,以便遇到问题的线程可以从与队列的交互中退出并做其他事情。

我们可以将列表 6.4 中的AccountManager修改为使用带有超时的非阻塞 API,如下所示:

    Runnable withdraw = () -> {
      LOOP:
      while (!shutdown) {
          try {
              var task = pending.poll(5,                 ❶
                                      TimeUnit.SECONDS);
              if (task == null) {
                  continue LOOP;                         ❷
              }
              var sender = task.sender();
              if (sender.withdraw(task.amount())) {
                  forDeposit.put(task);
              } else {
                  failed.put(task);
              }
          } catch (InterruptedException e) {
              // Log at critical and proceed to next item
          }
      }
      // Drain pending queue to failed or log
  };

❶ 如果计时器到期,poll()返回 null。

❷ 显式使用 Java 循环标签以清楚地表明正在继续什么。

同样,也需要对存款线程进行类似的修改。

这解决了我们在前一小节中概述的关闭问题,因为现在线程在检索方法中不会永远阻塞。相反,如果在超时之前没有对象到达,则poll()仍然会返回并提供值null。然后测试将继续循环,但 volatile 布尔值的可见性保证确保while循环条件现在得到满足,循环退出并且线程干净地关闭。这意味着,总的来说,一旦调用shutdown()方法,AccountManager将在有限的时间内关闭,这正是我们想要的行为。

为了结束对BlockingQueue API 的讨论,我们应该看看我们之前提到的第三种方法:如果队列操作无法立即完成,则抛出异常的方法。这些方法add()remove(),坦白地说,由于几个原因而存在问题,其中最不重要的是它们在失败时抛出的异常(分别是IllegalStateExceptionNoSuchElementException)是运行时异常,因此不需要显式处理。

然而,异常抛出 API 的问题远不止于此。Java 中的一个普遍原则是,异常应该用于处理异常情况,即程序通常不认为它们是正常操作的一部分。然而,空队列的情况却是一个完全可能的情况。因此,对此情况抛出异常违反了有时被表述为“不要使用异常进行流程控制”的原则。

异常通常很昂贵,因为当异常实例化时需要构建堆栈跟踪,在抛出时需要执行堆栈回溯。因此,除非异常将立即被抛出,否则不创建异常是一个好的做法。出于这些原因,我们确实建议不要使用 BlockingQueue API 的异常抛出形式。

6.7.2 使用 WorkUnit

Queue 接口都是泛型的:它们是 Queue<E>BlockingQueue<E> 等等。尽管这可能看起来有些奇怪,但有时利用这一点并引入一个人工的容器类来包装工作项是明智的。

例如,如果你有一个名为 MyAwesomeClass 的类,它代表你想要在多线程应用程序中处理的工作单元,那么你不会这样做:

BlockingQueue<MyAwesomeClass>

有时候,这样做可能更好:

BlockingQueue<WorkUnit<MyAwesomeClass>>

其中 WorkUnit(或 QueueObject,或你想要称谓的容器类)是一个包装类,可能看起来像这样:

public class WorkUnit<T> {
    private final T workUnit;

    public T getWork() {
        return workUnit;
    }

    public WorkUnit(T workUnit) {
        this.workUnit = workUnit;
    }

    // ... other methods elided
}

做这件事的原因是这种级别的间接性提供了一个地方来添加额外的元数据,而不会损害包含类型(在这个例子中是 MyAwesomeClass)的概念完整性。在图 6.8 中,我们可以看到外部元数据包装器是如何工作的。

图 6.8 使用工作单元作为元数据包装器

这非常实用。需要额外元数据的使用场景很多。以下是一些例子:

  • 测试(例如显示对象的变更历史)

  • 性能指标(例如到达时间或服务质量)

  • 运行时系统信息(例如这个 MyAwesomeClass 实例是如何被路由的)

在事后添加这种间接性可能要困难得多。如果你后来发现某些情况下需要更多的元数据,那么在 WorkUnit 类中添加这些原本简单的更改可能需要进行重大的重构工作。让我们继续讨论期货,期货是表示 Java 中正在进行的(通常在另一个线程上)任务占位符的一种方式。

6.8 期货

java.util.concurrent 中的 Future 接口是对异步任务的一个简单表示:它是一个类型,它持有来自可能尚未完成但可能在未来的某个时刻完成的任务的结果。Future 上的主要方法如下:

  • get()—获取结果。如果结果尚未可用,将阻塞直到它可用。

  • isDone()—允许调用者确定计算是否已完成。它是非阻塞的。

  • cancel()——允许在完成之前取消计算。

还有一个版本的 get() 方法接受超时,它不会永远阻塞,类似于我们之前遇到的带有超时的 BlockingQueue 方法。下面的列表显示了在素数查找器中使用 Future 的一个示例。

列表 6.5 使用 Future 查找素数

Future<Long> fut = getNthPrime(1_000_000_000);
try {
    long result = fut.get(1, TimeUnit.MINUTES);
    System.out.println("Found it: " + result);
} catch (TimeoutException tox) {
    // Timed out - better cancel the task
    System.err.println("Task timed out, cancelling");
    fut.cancel(true);
} catch (InterruptedException e) {
    fut.cancel(true);
    throw e;
} catch (ExecutionException e) {
    fut.cancel(true);
    e.getCause().printStackTrace();
}

在这个片段中,你应该想象 getNthPrime() 返回一个在某个后台线程(甚至多个线程)上执行的未来,可能是我们将在本章后面讨论的执行器框架之一。

运行代码片段的线程进入带有超时的 get,并最多阻塞 60 秒等待响应。如果没有收到响应,则线程循环并进入另一个阻塞等待。即使在现代硬件上,这个计算也可能需要很长时间,因此你可能需要使用 cancel() 方法(尽管编写的代码没有提供取消请求的任何机制)。

作为第二个例子,让我们考虑非阻塞 I/O。图 6.9 显示了 Future 的实际应用,允许我们使用后台线程进行 I/O。

图 6.9 在 Java 中使用 Future

这个 API 已经存在了一段时间——它在 Java 7 中被引入——它允许用户执行类似这样的非阻塞并发操作:

try {
    Path file = Paths.get("/Users/karianna/foobar.txt");

    var channel = AsynchronousFileChannel.open(file);        ❶

    var buffer = ByteBuffer.allocate(1_000_000);             ❷
    Future<Integer> result = channel.read(buffer, 0);        ❷

    BusinessProcess.doSomethingElse();                       ❸

    var bytesRead = result.get();                            ❹
    System.out.println("Bytes read [" + bytesRead + "]");
} catch (IOException | ExecutionException | InterruptedException e) {
    e.printStackTrace();
}

❶ 异步打开文件

❷ 请求读取最多一百万字节

❸ 执行其他操作

❹ 当准备好时获取结果

这种结构允许主线程在另一个线程上执行 I/O 操作的同时 doSomethingElse(),这个线程是由 Java 运行时管理的。这是一个有用的方法,但它需要提供该功能的库的支持。这可能会有些限制——如果我们想创建自己的异步工作流怎么办?

6.8.1 CompletableFuture

Java 的 Future 类型被定义为一种接口,而不是一个具体的类。任何想要使用基于 Future 风格的 API 都必须提供一个具体的 Future 实现。

对于一些开发者来说,这些可能具有挑战性,并且在工具箱中存在明显的差距,因此从 Java 8 开始,JDK 中包含了一种新的未来方法——Future 的具体实现,它增强了功能,并在某些方面与其他语言中的未来(例如 Kotlin 和 Scala)更相似。

这个类被称为 CompletableFuture——它是一个实现了 Future 接口的具体类型,提供了额外的功能,并旨在作为构建异步应用程序的简单构建块。核心思想是我们可以创建 CompletableFuture<T> 类型的实例(它对返回值的类型是泛型的),创建的对象代表一个 未完成(或“未满足”)状态的未来。

之后,任何拥有可完成 Future 引用的线程都可以调用其上的 complete() 并提供值——这完成了(或“履行”)了未来。完成后的值立即对所有在 get() 调用上阻塞的线程可见。完成之后,任何进一步的 complete() 调用都将被忽略。

CompletableFuture 不能导致不同的线程看到不同的值。Future 要么未完成,要么已完成,如果已完成,则它持有的值是第一个调用 complete() 的线程提供的值。

这显然不是不可变性——CompletableFuture 的状态会随时间改变。然而,它只改变 一次——从未完成到完成。不同线程看到不一致状态的可能性是不存在的。

注意,Java 的 CompletableFuture 与其他语言(如 JavaScript)中的 promise 类似,这就是为什么我们除了“履行承诺”之外,还提到了“完成未来”的替代术语。

让我们来看一个示例并实现 getNthPrime(),这是我们之前遇到的:

public static Future<Long> getNthPrime(int n) {
    var numF = new CompletableFuture<Long>();     ❶

    new Thread( () -> {                           ❷
        long num = NumberService.findPrime(n);    ❸
        numF.complete(num);
    } ).start();

    return numF;
}

❶ 创建一个未完成的可完成 Future

❷ 创建并启动一个新线程以完成 Future

❸ 实际计算素数

getNthPrime() 方法创建一个“空”的 CompletableFuture 并将此容器对象返回给其调用者。为了驱动它,我们确实需要一些代码来调用 getNthPrime()——例如,列表 6.5 中显示的代码。

考虑 CompletableFuture 的一个方法是通过与客户端/服务器系统的类比。Future 接口只提供查询方法——isDone() 和阻塞的 get()。这相当于客户端的角色。CompletableFuture 的一个实例扮演服务器端的角色——它提供了对正在履行未来并提供值的代码的执行和完成的完全控制。

在示例中,getNthPrime() 在一个单独的线程中评估对数字服务的调用。当这个调用返回时,我们显式地完成未来。

实现相同效果的一种稍微更简洁的方法是使用 CompletableFuture .supplyAsync() 方法,传递一个表示要执行的任务的 Callable<T> 对象。此调用利用了由并发库管理的全局线程池,如下所示:

public static Future<Long> getNthPrime(int n) {
    return CompletableFuture.supplyAsync(
        () -> NumberService.findPrime(n));
}

这标志着我们对并发数据结构的初步探索的结束,这些数据结构是构建坚固的多线程应用程序的主要构建块之一。

注意,本书后面还将详细介绍 CompletableFuture,特别是在讨论高级并发和与函数式编程交互的章节中。

接下来,我们将介绍 executors 和 threadpools,它们提供了一种比基于 Thread 的原始 API 更高级、更方便的方式来处理执行。

6.9 任务和执行

java.lang.Thread 类自 Java 1.0 以来就存在了——Java 语言最初的一个亮点就是内置、语言级别的对多线程的支持。它功能强大,并以接近底层操作系统支持的形式表达并发。然而,它是一个处理并发的根本上是低级 API。

这种低级特性使得许多程序员难以正确或高效地使用它。在 Java 之后发布的其他语言从 Java 的线程经验中学习,并在此基础上提供了替代方法。其中一些方法反过来又影响了 java.util.concurrent 的设计以及 Java 并发方面的后续创新。

在这种情况下,我们的直接目标是让任务(或工作单元)能够在不为每个任务启动新线程的情况下执行。最终,这意味着任务必须被建模为可调用的代码,而不是直接表示为线程。

然后,这些任务可以调度到共享资源——一个线程池——上,该线程池执行任务直到完成,然后继续下一个任务。让我们看看我们是如何建模这些任务的。

6.9.1 任务建模

在本节中,我们将探讨两种不同的任务建模方法:Callable 接口和 FutureTask 类。我们也可以考虑 Runnable,但它并不总是那么有用,因为 run() 方法不返回值,因此它只能通过副作用执行工作。

任务建模的另一个重要方面虽然可能不明显——即如果我们假设我们的线程容量是有限的,那么任务必须在有限的时间内完成。

如果我们有无限循环的可能性,一些任务可能会“窃取”池中的一个执行线程,这将从那时起减少所有任务的总体容量。随着时间的推移,这最终可能导致线程池资源耗尽,无法进行进一步的工作。因此,我们必须小心,确保我们构建的任何任务确实遵守“在有限时间内终止”的原则。

可调用接口

Callable 接口代表一个非常常见的抽象。它代表了一段可以被调用并返回结果的代码。尽管这是一个直接的想法,但实际上这是一个微妙且强大的概念,可以导致一些极其有用的模式。

Callable 的一个典型用途是 lambda 表达式(或匿名实现)。这个片段的最后一行将 s 设置为 out.toString() 的值:

var out = getSampleObject();
Callable<String> cb = () -> out.toString();

String s = cb.call();

Callable 视为对 lambda 提供的单个方法 call() 的延迟调用。

FutureTask 类

FutureTask 类是 Future 接口的一个常用实现,它也实现了 Runnable。正如我们将看到的,这意味着 FutureTask 可以提供给执行器。FutureTask 的 API 基本上是 FutureRunnable 的组合:get()cancel()isDone()isCancelled()run(),尽管最后一个——实际执行工作的那个——将由执行器调用,而不是由客户端代码直接调用。

FutureTask 提供了两个便利的构造函数:一个接受 Callable,另一个接受 Runnable(它使用 Executors.callable()Runnable 转换为 Callable)。这表明了一种灵活的任务处理方法,允许将工作编写为 Callable,然后将其包装到 FutureTask 中,这样就可以在执行器上调度(如果需要,还可以取消)它,这是由于 FutureTaskRunnable 特性。

该类提供了一个简单的任务状态模型,并通过该模型管理任务。可能的状态转换在图 6.10 中显示。

图 6.10 任务状态模型

图 6.10 任务状态模型

这对于广泛的普通执行可能性是足够的。让我们来看看 JDK 提供的标准执行器。

6.9.2 执行器

有几个标准接口用于描述 JDK 中存在的线程池。第一个是 Executor,它非常简单,定义如下:

public interface Executor {

    /**
     * Executes the given command at some time in the future. The command
     * may execute in a new thread, in a pooled thread, or in the calling
     * thread, at the discretion of the {@code Executor} implementation.
     *
     * @param command the runnable task
     * @throws RejectedExecutionException if this task cannot be
     * accepted for execution
     * @throws NullPointerException if command is null
     */
    void execute(Runnable command);
}

应该注意,尽管这个接口只有一个抽象方法(即,它是一个所谓的 SAM 类型),但它并没有用 @FunctionalInterface 注解标记。它仍然可以用作 lambda 表达式的目标类型,但它并不打算用于函数式编程。

实际上,Executor 并不常用——更常见的是扩展 Executor 并添加 submit() 以及几个生命周期方法(如 shutdown())的 ExecutorService 接口。

为了帮助开发者实例化和使用一些标准线程池,JDK 提供了 Executors 类,它是一组静态辅助方法(主要是工厂)。以下是最常用的四个方法:

newSingleThreadExecutor()
newFixedThreadPool(int nThreads)
newCachedThreadPool()
newScheduledThreadPool(int corePoolSize)

让我们逐一查看这些内容。在本书的后面部分,我们将深入探讨一些其他更复杂但同样提供的可能性。

6.9.3 单线程执行器

执行器中最简单的是单线程执行器。这本质上是一个封装的单线程和任务队列(这是一个阻塞队列)的组合。

客户端代码通过 submit() 将可执行任务放入队列。然后单个执行线程一次取一个任务,并运行到完成,然后再取下一个任务。

注意:执行器不是作为不同的类型实现的,而是表示在构建底层线程池时的不同参数选择。

在执行线程忙碌时提交的任务将被排队,直到线程可用。因为此执行器由单个线程支持,如果违反了之前提到的“在有限时间内终止”的条件,这意味着随后提交的作业将永远不会运行。

注意:此版本的执行器通常对测试很有用,因为它可以比其他形式更确定。

下面是一个如何使用单线程执行器的非常简单的例子:

var pool = Executors.newSingleThreadExecutor();
Runnable hello = () -> System.out.println("Hello world");
pool.submit(hello);

submit()调用通过将可运行的任务放置在执行器的作业队列上来传递任务。该作业提交是非阻塞的(除非作业队列已满)。

然而,仍然需要小心——例如,如果主线程立即退出,提交的作业可能没有时间被池线程收集,可能不会运行。为了避免立即退出,首先在执行器上调用shutdown()方法是明智的。

详细信息可以在ThreadPoolExecutor类中找到,但基本上这个方法启动了一个有序关闭,其中之前提交的任务将被执行,但不会接受新的任务。这有效地解决了我们在 6.4 列表中看到关于清空挂起事务队列的问题。

注意:一个无限循环的任务与有序关闭请求的组合将产生不良交互,导致线程池永远不会关闭。

当然,如果单线程执行器就是所有需要的东西,那么就没有必要深入理解并发编程及其挑战。因此,我们也应该看看利用多个执行器线程的替代方案。

6.9.4 固定线程池

通过Executors.newFixedThreadPool()的一个变体获得的固定线程池实际上是单线程执行器的多线程推广。在创建时,用户提供一个明确的线程数,并且线程池将使用这么多线程创建。

这些线程将被重用来运行多个任务,一个接一个。设计防止用户支付线程创建的成本。与单线程变体一样,如果所有线程都在使用中,新任务将存储在阻塞队列中,直到线程空闲。

如果任务流稳定且已知,并且所有提交的作业在计算持续时间方面大致相同,则此版本的线程池特别有用。它再次最容易通过适当的工厂方法创建,如下所示:

var pool = Executors.newFixedThreadPool(2);

这将创建一个由两个执行器线程支持的显式线程池。这两个线程将以非确定性的方式轮流从队列中接受任务。即使任务提交有严格的时序(基于时间的)顺序,也无法保证哪个线程将处理给定的任务。

这的一个后果是,在如图 6.11 所示的情况中,即使上游队列中的任务在时间上准确排序,下游队列中的任务也不能保证准确的时间顺序。

图片

图 6.11 一个线程池和两个队列

固定线程池有其用途,但并非市场上唯一的游戏。一方面,如果其中的执行器线程死亡,它们不会被替换。如果提交的工作有抛出运行时异常的可能性,这可能导致线程池饥饿。让我们看看另一种选择,它做出了不同的权衡,但可以避免这种可能性。

6.9.5 缓存线程池

当工作负载的活动模式已知且相对稳定时,通常会使用固定线程池。然而,如果进入的工作更加不均匀或突发,那么固定数量的线程池可能不是最优选择。

CachedThreadPool是一个无界池,如果可用将重用线程,否则将根据需要创建新线程来处理进入的任务,如下所示:

var pool = Executors.newCachedThreadPool();

线程被保留在空闲缓存中 60 秒,如果它们在那个时间段结束时仍然存在,它们将被从缓存中移除并销毁。

当然,确保任务实际上能够终止仍然非常重要。如果不这样做,那么随着时间的推移,线程池将创建越来越多的线程,消耗越来越多的机器资源,最终崩溃或变得无响应。

通常,固定大小线程池和缓存线程池之间的权衡主要是在重用线程与创建和销毁线程以实现不同效果之间。CachedThreadPool的设计应该在小异步任务上提供比固定大小池更好的性能。然而,正如往常一样,如果认为效果显著,必须进行适当的性能测试。

6.9.6 ScheduledThreadPoolExecutor

我们将要查看的执行器的最后一个例子有一点不同。这是ScheduledThreadPoolExecutor,有时简称为 STPE,如下所示:

ScheduledExecutorService pool = Executors.newScheduledThreadPool(4);

注意,这里我们明确指出的是返回类型是ScheduledExecutorService。这与返回ExecutorService的其他工厂方法不同。

注意:ScheduledThreadPoolExecutor是一个令人惊讶的有能力的执行器选择,可以在广泛的情境中使用。

调度服务扩展了常规执行器服务并添加了一些新的功能:schedule(),在指定延迟后运行一次性的任务,以及两个用于安排周期性(即重复)任务的方法——scheduleAtFixedRate()scheduleWithFixedDelay()

这两个方法的行为略有不同。scheduleAtFixedRate()将在固定的时间表上激活任务的副本(无论之前的副本是否完成),而scheduleWithFixedDelay()只有在之前的实例完成并且指定的延迟时间过去之后才会激活任务的副本。

除了ScheduledThreadPoolExecutor之外,我们遇到的所有其他池都是通过为相当通用的Thread-PoolExecutor类选择略微不同的参数选择获得的。例如,让我们看看以下Executors .newFixedThreadPool()的定义:

    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

当然,这是辅助方法的目的:提供一个方便的方式来访问线程池的一些标准选择,而无需与ThreadPoolExecutor的全部复杂性打交道。除了 JDK 之外,还有许多其他执行器和相关线程池的例子,例如来自 Tomcat web 服务器的org.apache.catalina.Executor类。

摘要

  • java.util.concurrent类应该是你所有新多线程 Java 代码的首选工具包:

    • 原子整数

    • 并发数据结构,特别是ConcurrentHashMap

    • 阻塞队列和闩锁

    • 线程池和执行器

  • 这些类可以用来实现安全的并发编程技术,包括:

    • 解决synchronized锁的不灵活性

    • 使用阻塞队列进行任务传递

    • 使用闩锁在多个线程之间达成共识

    • 将执行分割成工作单元

    • 任务控制,包括安全关闭

7 理解 Java 性能

本章涵盖

  • 性能为什么重要

  • G1 垃圾收集器

  • 即时编译(JIT)

  • JFR——JDK 飞行记录器

糟糕的性能会杀死应用程序——这对你的客户和应用程序的声誉都是有害的。除非你有一个完全受控的市场,否则你的客户会用他们的脚投票——他们已经出门,走向竞争对手。为了阻止糟糕的性能损害你的项目,你需要了解性能分析和如何让它为你工作。

性能分析和调整是一个巨大的主题,太多的处理方法都集中在错误的事情上。因此,我们将从告诉你性能调整的巨大秘密开始。这里就是——性能调整的最大秘密:你必须进行测量。不测量,你无法正确调整。

原因如下:当涉及到猜测系统中的慢速部分时,人脑几乎总是错误的。每个人的都是。你的,我的,詹姆斯·高斯林的——我们都受到我们无意识的偏见的影响,并倾向于看到可能并不存在的模式。事实上,对于“我的 Java 代码中哪部分需要优化?”这个问题,答案通常是“都不是。”

考虑一个典型的(如果相当保守的)电子商务网络应用程序,为注册客户提供服务。它有一个 SQL 数据库,Java 服务的前端是 Web 服务器,以及连接所有这些的相当标准的网络配置。非常常见的是,系统的非 Java 部分(数据库、文件系统、网络)是真正的瓶颈,但没有测量,Java 开发者永远不会知道这一点。开发者可能不会找到并修复真正的问题,而是浪费时间在微优化那些实际上并没有真正贡献到问题的代码方面。

你希望能够回答的基本问题类型是这些:

  • 如果你有一个销售驱动,突然有 10 倍多的客户,系统是否有足够的内存来应对?

  • 你的客户从你的应用程序中看到的平均响应时间是多少?

  • 这与你的竞争对手相比如何?

注意,所有这些示例问题都是关于你的系统与你的客户——系统用户直接相关方面的。这里没有关于诸如

  • Lambda 和流比for循环更快吗?

  • 正常方法(虚方法)比接口方法更快吗?

  • hashcode()函数的最快实现是什么?

经验不足的性能工程师经常会犯的错误是假设用户可见的性能强烈依赖于,或者与第二组问题解决的微观性能方面密切相关。

这个假设——本质上是一种还原论观点——在实践中实际上并不成立。相反,现代软件系统的复杂性导致整体性能成为系统及其所有层的涌现属性。特定的微观效应几乎无法隔离,并且对于大多数应用程序程序员来说,微观基准测试的效用非常有限。

相反,为了进行性能调优,你必须跳出猜测系统缓慢原因的领域——这里的“缓慢”意味着“影响客户的体验”。你必须开始了解,而唯一确定了解的方法就是测量。

你还需要了解性能调优不是什么。它不是以下内容:

  • 一系列技巧和窍门

  • 秘密配方

  • 在项目结束时撒上的“魔法粉”

特别注意“技巧和窍门”的方法。事实是,JVM 是一个非常复杂且高度优化的环境,如果没有适当的环境,大多数这些技巧都是无用的(甚至可能是有害的)。随着 JVM 在优化代码方面变得越来越聪明,它们也很快就会过时。

性能分析实际上是一种实验科学。你可以把你的代码看作是一种科学实验,它有输入并产生“输出”——性能指标,这些指标表明系统执行所要求工作的效率。性能工程师的职责是研究这些输出并寻找模式。这使得性能调优成为应用统计学的一个分支,而不是一系列老妇人的传说和应用的民间传说。

本章旨在帮助你入门。这是 Java 性能调优实践的一个介绍。但这是一个很大的主题,我们只能给你一些基本理论和一些路标。我们将尝试回答以下最基本的问题:

  • 性能为什么很重要?

  • 为什么性能分析很难?

  • JVM 的哪些方面使其调优变得可能复杂?

  • 应该如何思考和处理性能调优?

  • 最常见的导致系统缓慢的根本原因是什么?

我们还将向你介绍 JVM 中以下两个子系统,当涉及到性能相关问题时,它们是最重要的:

  • 垃圾回收子系统

  • JIT 编译器

这应该足以让你开始,并帮助你将这种(不可否认地有些理论性)知识应用到你在代码中面临的实际问题中。让我们快速浏览一些基本词汇,这将使你能够表达和界定你的性能问题和目标。

7.1 性能术语:一些基本定义

为了充分利用本章的讨论,我们需要将一些你可能已经了解的性能概念进行形式化。我们将从定义性能工程师词汇表中的以下一些重要术语开始:

  • 延迟

  • 吞吐量

  • 利用率

  • 效率

  • 容量

  • 可伸缩性

  • 退化

Doug Lea 在多线程代码的背景下讨论了这些术语中的许多,但我们在这里考虑的是一个更广泛的背景。当我们谈论性能时,我们可能意味着从单个多线程进程到托管在云中的整个服务集群的任何事情。

7.1.1 延迟

延迟是在给定工作负载下处理单个工作单元所需的总时间。通常,延迟只是针对“正常”工作负载进行报价,但一个经常有用的性能指标是显示延迟作为增加工作负载函数的图表。

图 7.1 显示了随着工作负载的增加,性能指标(例如,延迟)的突然、非线性下降。这通常被称为性能拐点(或“曲棍球棒”)。

图 7.1 性能拐点

7.1.2 吞吐量

吞吐量是指系统在给定资源下在某个时间段内可以执行的工作单元数量。一个常见的引用数字是在某些参考平台(例如,具有指定硬件、操作系统和软件堆栈的特定品牌服务器)上的每秒事务数。

7.1.3 利用率

利用率表示可用于处理工作单元的资源百分比,而不是用于维护任务(或只是闲置)。人们通常会引用服务器的利用率,例如 10%。这指的是在正常处理时间内 CPU 处理工作单元的百分比。请注意,不同资源(如 CPU 和内存)的利用率水平之间的差异可能非常大。

7.1.4 效率

系统的效率等于吞吐量除以使用的资源。需要更多资源来产生相同吞吐量的系统效率较低。

例如,考虑比较两种聚类解决方案。如果方案 A 需要比方案 B 多两倍的服务器才能达到相同的吞吐量,那么它的效率只有一半。

记住,资源也可以从成本的角度来考虑——如果方案 A 的成本是方案 B 的两倍(或运行生产环境需要两倍的员工),那么它的效率只有一半。

7.1.5 容量

容量是指在任何时候可以通过系统传输的工作单元(如事务)的数量。也就是说,这是在指定延迟或吞吐量下可用的并发处理量。

7.1.6 可伸缩性

当向系统添加资源时,吞吐量(或延迟)将发生变化。这种吞吐量或延迟的变化是系统的可伸缩性

如果方案 A 在服务器池中可用的服务器数量加倍时,其吞吐量也加倍,那么它以完美的线性方式扩展。在大多数情况下,完美线性扩展是非常、非常困难的——记住 Amdahl 定律。

你还应该注意,一个系统的可扩展性取决于许多因素,并且它不是恒定的。一个系统可以接近线性地扩展到某个点,然后开始严重退化。这是一种不同的性能拐点。

7.1.7 退化

如果你添加更多的工作单元,或者网络系统的客户端,而没有添加更多资源,你通常会看到观察到的延迟或吞吐量的变化。这种变化是在额外负载下系统退化的表现。

在正常情况下,退化将是负面的。也就是说,向系统中添加工作单元将导致性能下降(例如,导致处理延迟增加)。但在某些情况下,退化可能是正面的。例如,如果额外的负载导致系统的一部分超过阈值并切换到高性能模式,这可能会使系统工作得更有效率,并减少处理时间,尽管实际上还有更多的工作要做。JVM 是一个非常动态的运行时系统,其几个部分可能会对这种效果做出贡献。

前述术语是性能最常用的指标。其他指标偶尔也很重要,但这些是通常用于指导性能调整的基本系统统计指标。在下一节中,我们将阐述一种基于对这些数字的密切关注的做法,并尽可能地量化。

7.2 一种实用的性能分析方法

许多开发者在接近性能分析任务时,并没有一个清晰的关于通过分析想要实现什么目标的认识。当工作开始时,开发者或管理者通常只有一种模糊的感觉,即代码“应该运行得更快”。

但这是完全相反的。为了真正有效地进行性能调整,你应该在开始任何技术工作之前考虑一些关键领域。你应该知道以下事情:

  • 你正在测量你代码的哪些可观察方面

  • 如何衡量那些可观察量

  • 可观察量的目标是什么

  • 你将如何知道性能调整已经完成

  • 性能调整的最大可接受成本(以开发者投入的时间和代码的额外复杂性来衡量)

  • 优化时不应牺牲什么

最重要的是,正如我们将在本章中多次说到的,你必须进行测量。如果没有至少一个可观察量的测量,你就不在进行性能分析。

当你开始测量你的代码时,发现时间并没有花在你认为的地方,这也是非常常见的。缺少数据库索引或争用的文件系统锁可能是许多性能问题的根源。在考虑优化你的代码时,你应该始终记住,代码可能不是问题所在。为了量化问题的位置,你需要知道的第一件事是你正在测量什么。

7.2.1 了解你在测量什么

在性能调整中,你总是需要测量某些东西。如果你没有测量可观察的量,你就没有进行性能调整。坐着盯着你的代码,希望更快解决问题的方法会突然出现在你脑海中,这不是性能分析。

提示:要成为一名优秀的性能工程师,你应该了解诸如 平均数、中位数、众数、方差、百分位数、标准差、样本大小正态分布 等术语。如果你不熟悉这些概念,你应该从快速网络搜索开始,并在需要时进行进一步阅读。Leonard Apeltsin 的 数据科学 Bootcamp(Manning,2021)的第五章是一个很好的起点。mng.bz/e7Oq

在进行性能分析时,了解我们上节中描述的哪些可观察量对你来说很重要。你应该始终将你的测量、目标和结论与我们所介绍的一个或多个基本可观察量联系起来。以下是一些典型的可观察量,它们是性能调整的良好目标:

  • handleRequest() 方法运行的平均时间(在预热之后)

  • 在 10 个并发客户端的情况下,系统端到端延迟的 90 分位数

  • 当从 1 个增加到 1,000 个并发用户时,响应时间的下降

所有这些都代表了工程师可能想要测量和可能调整的量。为了获得准确和有用的数字,基本统计学知识是必不可少的。

知道你在测量什么,并对你所得到的数字的准确性有信心是第一步。但模糊或不明确的目标往往不会产生好的结果,性能调整也不例外。相反,你的性能目标应该是所谓的 SMART 目标(即 具体、可衡量、达成共识、相关时间限制)。

7.2.2 了解如何进行测量

我们实际上只有以下两种方法来确定一个方法或其他 Java 代码运行的确切时间:

  • 直接测量,通过在源类中插入测量代码来实现。

  • 在类加载时转换要测量的类。

这两种方法分别被称为 手动自动 仪器化。所有常用的性能测量技术都将依赖于其中一种(或两种)技术。

注意:还有 JVM 工具接口(JVMTI),它可以用来创建非常复杂的性能工具,但它也有一些缺点,尤其是它需要使用原生代码,这会影响使用它编写的工具的复杂性和安全性。

直接测量

直接测量是最容易理解的技术,但它也是侵入性的。在其最简单的形式中,它看起来像这样:

long t0 = System.currentTimeMillis();
methodToBeMeasured();
long t1 = System.currentTimeMillis();

long elapsed = t1 - t0;
System.out.println("methodToBeMeasured took "+ elapsed +" millis");

这将生成一条输出行,应该可以给出methodToBeMeasured()运行所需时间的毫秒级准确视图。不方便的是,像这样的代码必须添加到代码库的各个部分,随着测量数量的增加,避免被数据淹没变得越来越困难。

还存在其他问题——例如,如果methodToBeMeasured()的运行时间少于毫秒,会发生什么?正如我们将在本章后面看到的那样,还有冷启动效应需要担心:JIT 编译意味着方法的后续运行可能比早期运行更快。

还存在一些更微妙的问题:调用currentTimeMillis()需要调用本地方法和对系统时钟的系统调用。这不仅耗时,还可能从执行管道中清除代码,导致额外的性能下降,而这种下降在没有测量代码的情况下是不会发生的。

通过类加载进行自动插装

在第一章和第四章中,我们讨论了类是如何组装成执行程序的。其中经常被忽视的关键步骤之一是字节码在加载时的转换。这非常强大,并且它是 Java 平台许多现代技术的核心。

其中一个例子是方法的自动插装。在这种方法中,methodToBeMeasured()由一个特殊的类加载器加载,该类加载器在方法的开头和结尾添加字节码,以记录方法进入和退出的时间。这些时间通常写入共享数据结构,由其他线程访问。这些线程对数据进行操作,通常是将输出写入日志文件或联系基于网络的服务器,该服务器处理原始数据。

这种技术是许多专业级 Java 性能监控工具(如 New Relic)的核心,但填补相同空白且积极维护的开源工具却很少。随着 OpenTelemetry OSS 库和标准的兴起以及它们的 Java 自动插装子项目,这种状况可能正在改变。

注意:正如我们稍后将要讨论的,Java 方法最初是解释执行的,然后切换到编译模式。为了获得真正的性能数字,您必须丢弃在解释模式下生成的计时数据,因为这些数据可能会严重扭曲结果。稍后我们将更详细地讨论您如何知道方法何时切换到编译模式。

使用这两种技术之一,您可以为给定方法的执行速度生成数字。接下来的问题是,在调整完成后,您希望这些数字看起来是什么样子?

7.2.3 了解您的性能目标

没有什么比一个明确的目标更能集中注意力,因此了解要测量什么和了解并传达调整的最终目标同样重要。在大多数情况下,这应该是一个简单且精确陈述的目标,例如以下内容:

  • 在 10 个并发用户的情况下,将 90%分位数的端到端延迟降低 20%

  • handleRequest()的平均延迟降低 40%

在更复杂的情况下,目标可能是同时达到几个相关的性能目标。你应该意识到,你测量的和尝试调整的独立可观察量越多,性能练习可能变得越复杂。优化一个性能目标可能会对另一个目标产生负面影响。

有时候,在设定目标之前,比如让它们运行得更快,进行一些初步分析是必要的,比如确定哪些是重要的方法。这是可以的,但在初步探索之后,几乎总是最好在尝试实现它们之前停下来并明确你的目标。开发者经常会在不停止阐明他们的目标的情况下继续分析。

7.2.4 知道何时停止

理论上,知道何时停止优化很容易——当你达到你的目标时,你就完成了。然而,在实践中,很容易陷入性能调优的陷阱。如果一切顺利,继续努力做得更好的诱惑可能非常强烈。或者,如果你在努力达到目标时遇到困难,很难不尝试不同的策略来达到目标。

知道何时停止需要意识到你的目标,同时也需要有一种感觉,知道它们的价值。达到性能目标的 90%通常已经足够,工程师的时间可能更好地花在其他地方。

另一个重要的考虑因素是花费在很少使用的代码路径上的工作量。几乎总是浪费时间来优化只占程序运行时间 1%或更少的代码,但令人惊讶的是,许多开发者会参与这种行为。

这里有一套非常简单的指导原则,用于了解什么需要优化。你可能需要根据你的具体情况调整这些原则,但它们在许多情况下都适用:

  • 优化重要的,而不是容易优化的。

  • 首先击中最重要的(通常是调用最频繁的)方法。

  • 遇到低垂的果实就摘取,但要注意代表这些代码的调用频率。

最后,再进行一轮测量。如果你没有达到性能目标,就要进行盘点。看看你离达到这些目标有多近,以及你所取得的进步是否对整体性能产生了预期的积极影响。

7.2.5 了解实现更高性能的成本

所有性能调整都有一个价格标签,如下所示:

  • 这包括进行分析和开发改进所需的时间(而且值得记住,开发者时间的成本几乎总是任何软件项目中最高的开销)。

  • 修复可能引入的额外技术复杂性。(也有性能改进可以简化代码,但它们并不是大多数情况。)

  • 可能引入了额外的线程来执行辅助任务,以便允许主处理线程更快地运行,这些线程在更高负载下可能对整个系统产生不可预见的影响。

无论代价如何,都要注意它,并在完成一轮优化之前尝试确定它。

有时了解提高性能所能接受的最大成本是有帮助的。这可以设定为调整开发人员的时间限制,或者作为额外类或代码行的数量。例如,开发人员可以决定优化不应超过一周的时间,或者优化的类不应增长超过 100%(加倍其原始大小)。

7.2.6 了解过早优化的危险

关于优化的最著名引语之一来自 Donald Knuth(《使用 goto 语句的结构化编程》,《计算机评论》,6,第 4 期 [1974 年 12 月]):

程序员在思考或担心程序非关键部分的运行速度上浪费了大量的时间,而这些试图提高效率的努力实际上有强烈的负面影响...过早优化是万恶之源。

这句话在社区中引起了广泛的讨论,在许多情况下,只有第二部分被记住。这有几个不幸的原因:

  • 在引文的第一部分,Knuth 在隐晦地提醒我们需要测量,没有测量我们就无法确定程序的临界部分。

  • 我们还需要再次记住,延迟可能不是由代码引起的——可能是环境中的其他东西。

  • 在完整的引文中,很容易看出 Knuth 在谈论形成有意识、一致努力的优化。

  • 简短的引文导致这句话被用作相当陈词滥调的借口,用于糟糕的设计或执行选择。

一些优化,特别是以下这些,实际上是良好风格的一部分:

  • 不要分配你不需要的对象。

  • 如果你永远不会需要它,就删除调试日志消息。

在下面的代码片段中,我们添加了一个检查,看看日志对象是否会处理调试日志消息。这种检查被称为可记录性保护。如果日志子系统没有为调试日志设置,则此代码永远不会构造日志消息,从而节省了调用currentTimeMillis()和用于日志消息的StringBuilder对象构造的成本:

if (log.isDebugEnabled()) {
  log.debug("Useless log at: "+ System.currentTimeMillis());
}

但如果调试日志消息确实毫无用处,我们可以通过完全删除代码来节省几个处理器周期(可记录性保护的成本)。这种成本微不足道,将在性能配置文件的其他部分的噪声中丢失,但如果它确实不是必需的,就将其删除。

性能调优的一个方面是首先编写好、性能良好的代码。更好地了解平台及其底层行为(例如,理解来自两个字符串连接的隐式对象分配)以及在编写代码时考虑性能方面,都会导致更好的代码。

我们现在有一些基本的词汇可以用来构建我们的性能问题和目标,以及如何解决问题的概述方法。但我们还没有解释为什么这是软件工程师的问题,以及这种需求是从何而来的。为了理解这一点,我们需要简要地深入到硬件的世界。

7.3 发生了什么问题?为什么我们必须关心?

在 2000 年中期之前的几年里,性能似乎并不是一个真正的问题。时钟速度在不断提高,似乎所有软件工程师需要做的只是等待几个月,改进的 CPU 速度就会给即使是写得不好的代码带来提升。

那么,事情为什么会变得如此糟糕?为什么时钟速度不再有太大的提升?更令人担忧的是,为什么一个 3 GHz 芯片的计算机似乎并不比一个 2 GHz 芯片的计算机快多少?这种软件工程师行业普遍关注性能的趋势是从何而来的?

在本节中,我们将讨论推动这一趋势的力量,以及为什么即使是纯粹的软件开发者也需要稍微关注一下硬件。我们将为本章剩余部分的主题设定舞台,并为您提供真正理解即时编译(JIT compilation)以及一些深入示例所需的概念。

你可能听说过“摩尔定律”这个词被广泛讨论。许多开发者都知道它与计算机速度提高的速率有关,但对细节并不清楚。让我们开始解释它确切的意思以及它可能在不久的将来结束的后果。

7.3.1 摩尔定律

摩尔定律是以英特尔创始人之一戈登·摩尔的名字命名的。以下是他的定律最常见的一种表述:在经济上可生产的芯片上,晶体管的最大数量大约每两年翻一番。

这条定律,实际上是对计算机处理器(CPU)趋势的观察,基于他在 1965 年撰写的一篇论文,他最初预测了 10 年——即直到 1975 年。它持续得如此之好,确实令人印象深刻。

在图 7.2 中,我们绘制了来自各个系列(主要是英特尔 x86 系列)的多个真实 CPU,从 1980 年到最新的(2021 年)苹果硅(图中的数据来自维基百科,略有编辑以增强清晰度)。该图显示了芯片的晶体管数量与其发布日期的关系。

图片

图 7.2 随时间变化的晶体管数量对数线性图

这是一个对数线性图,因此 y 轴上的每个增量是前一个的 10 倍。正如您所看到的,线条基本上是直的,大约需要六到七年才能跨越每个垂直级别。这证明了摩尔定律,因为用六到七年的时间增加十倍相当于大约每两年翻一番。

请记住,图中的 y 轴是对数刻度——这意味着 2005 年生产的主流英特尔芯片大约有 1 亿个晶体管。这是 1990 年生产的芯片的 100 倍

重要的是要注意摩尔定律专门讨论的是晶体管数量。这是理解为什么摩尔定律本身不足以让软件工程师继续从硬件工程师那里获得免费午餐的基本点(参见 Herb Sutter,“The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software,” Dr. Dobb’s Journal 30 (2005): 202–210)。

摩尔定律一直是过去的良好指南,但它是以晶体管数量来表述的,这并不是开发者应该从他们的代码中期望的性能的良好指南。正如我们将看到的,现实更加复杂。

注意晶体管数量并不等同于时钟速度,而且一个更高的时钟速度意味着更好的性能这种仍然普遍存在的想法也是一个过于简化的说法。

事实是,现实世界的性能取决于许多因素,所有这些因素都很重要。如果我们必须只选择一个,那么它将是:与后续指令相关的数据能够多快地定位?这是一个对性能至关重要的概念,我们应该深入探讨它。

7.3.2 理解内存延迟层次结构

计算机处理器需要数据来工作。如果要处理的数据不可用,那么 CPU 循环的速度有多快都没有关系——它只能等待,执行无操作 (NOP) 并基本上停滞,直到数据可用。

这意味着在处理延迟时,最基本的问题有两个,“CPU 核心需要工作的数据最近副本在哪里?”以及“到达核心可以使用数据的地方需要多长时间?”以下是一些主要可能性(在所谓的 冯·诺依曼架构 中,这是最常用的形式):

  • 寄存器—位于 CPU 上并准备立即使用的内存位置。这是指令直接操作的内存部分。

  • 主存储器—通常是 DRAM。访问时间大约为 50 纳秒(但请参阅后面的细节,了解处理器缓存如何用于避免这种延迟)。

  • 固态硬盘 (SSD)—访问这些磁盘需要 0.1 毫秒或更少的时间,但它们通常比传统硬盘更贵。

  • 硬盘—访问磁盘并将所需数据加载到主存储器大约需要 5 毫秒。

摩尔定律描述了晶体管数量的指数增长,这也使内存受益——内存访问速度也呈指数增长。但这两个指数并不相同。内存速度的提高速度比 CPU 增加晶体管的速度慢,这意味着处理核心可能会因为缺乏处理所需的相关数据而闲置。

为了解决这个问题,在寄存器和主存储器之间引入了缓存——这是一种更快的内存(SRAM,而不是 DRAM)的小量。这种内存比 DRAM 在金钱和晶体管预算上都贵得多,这也是为什么计算机不会简单地使用 SRAM 作为它们整个内存的原因。

缓存被称为 L1 和 L2(一些机器也有 L3),数字表示缓存在物理上离核心有多近(更近的缓存会更快)。我们将在第 7.6 节(关于即时编译)中更多地讨论缓存,并展示一个关于 L1 缓存对运行代码的重要性示例。图 7.3 显示了 L1 和 L2 缓存比主存储器快多少。

图片

图 7.3 寄存器、处理器缓存和主存储器的相对访问时间(以时钟周期计)

除了添加缓存之外,20 世纪 90 年代和 21 世纪初广泛使用的一种技术是添加越来越复杂的处理器特性,试图克服内存的延迟。复杂的硬件技术,如指令级并行性(ILP)和芯片多线程(CMT),被用来试图保持 CPU 在数据上运行,即使在 CPU 能力和内存延迟之间的差距不断扩大的情况下。

这些技术最终消耗了 CPU 晶体管预算的大部分,它们对实际性能的影响是递减的。这种趋势导致了这样一个观点:CPU 设计的未来在于具有多个(或许多)核心的芯片。现代处理器基本上都是多核的——事实上,这是摩尔定律的二级后果之一:核心数量增加是为了利用可用的晶体管。

性能的未来与并发性紧密相连——一个系统可以使其整体性能更优的主要方法之一是利用更多的核心。这样,即使一个核心在等待数据,其他核心仍然可能继续进步(但请记住第五章中介绍的 Amdahl 定律的影响)。这种联系非常重要,所以我们再次强调:

  • 实际上,几乎所有现代 CPU 都是多核的。

  • 性能和并发性是紧密相连的。

我们只是触及了与软件和 Java 编程相关的计算机体系结构世界的表面。对了解更多信息感兴趣的读者应查阅专业文本,例如 Hennessy 等人所著的《计算机体系结构:定量方法》第 6 版(Morgan Kaufmann,2017 年 12 月)。

这些硬件问题并不特定于 Java 程序员,但 JVM 的托管特性引入了一些额外的复杂性。让我们继续前进,在下一节中查看这些内容。

7.4 为什么 Java 性能调优如此困难?

在 JVM(或实际上任何其他托管运行时)上进行的性能调优,本质上比未托管的代码要困难得多。在托管系统中,整个目的就是让运行时对环境进行一些控制,这样开发者就不必处理每个细节。这使得程序员在整体上更加高效,但也意味着必须放弃一些控制权。

这种重点的转移使得整个系统更难以推理,因为对开发者来说,托管运行时是一个不透明的盒子。另一种选择是放弃托管运行时带来的所有优势,迫使 C/C++等语言的程序员几乎一切都要自己来做。在这种情况下,操作系统只提供最基本的服务,例如基本的线程调度,这通常比额外的性能调优所需的努力要高得多。

Java 平台中一些最重要的方面,这些方面使得调优变得困难,包括:

  • 线程调度

  • 垃圾收集(GC)

  • 即时编译(JIT)

这些方面可以以微妙的方式相互作用。例如,编译子系统使用计时器来决定编译哪些方法。候选方法集可能会受到诸如调度和 GC 等关注点的影响。编译的方法可能会在每次运行中有所不同。

正如你在本节中看到的,准确测量是性能分析决策过程的关键。因此,如果你想要认真进行性能调优,了解 Java 平台中时间处理的细节(以及局限性)非常有用。

7.4.1 时间在性能调优中的作用

性能调优需要你理解如何解释代码执行期间记录的测量结果,这意味着你还需要理解平台任何时间测量固有的局限性。

精度

时间量通常以某个尺度上的最近单位来报价。这被称为测量的精度。例如,时间通常测量到毫秒精度。如果重复测量给出围绕相同值的狭窄分布,则计时是精确的。

精度是给定测量中包含的随机噪声量的度量。我们将假设对特定代码片段的测量是正态分布的。在这种情况下,报价精度的常见方式是报价 95%置信区间的宽度。

准确性

测量的准确性(在我们的案例中,是时间)是获得接近真实值的能力。实际上,您通常不会知道真实值,因此准确性可能比精度更难确定。

准确性衡量测量中的系统误差。可能存在准确但不太精确的测量(因此基本读数是正确的,但存在随机环境噪声)。也可能存在精确但并不准确的结果。

理解测量

一个以纳秒精度表示的 5945 ns 间隔,来自精度为 1 μs 的计时器,实际上可能在 3945–7945 ns 之间(95%的概率)。警惕那些看似过于精确的性能数字;始终检查测量的精度和准确性。

粒度

系统的真正粒度是最快计时器的频率——可能是中断计时器,在 10 ns 范围内。这有时被称为可区分性,即两个事件之间可以肯定地说它们“接近但不同时”的最短间隔。

随着我们通过操作系统、JVM 和库代码的各个层次前进,这些极其短时间的分辨率基本上变得不可能。在大多数情况下,这些非常短的时间对于应用程序开发者来说是不可用的。

网络分布式计时

我们关于性能调整的大部分讨论集中在所有处理都在单个主机上进行的系统。但您应该意识到,在跨网络的系统进行性能调整时,可能会出现一些特殊问题。网络同步和计时远非易事,不仅限于互联网——甚至以太网网络也会出现这些问题。

对网络分布式计时的全面讨论超出了本书的范围,但您应该知道,通常情况下,对于跨越多个盒子的工作流程,很难获得准确的计时。此外,即使是标准协议如 NTP 也可能对于高精度工作来说不够准确。

让我们回顾一下关于 Java 计时系统的最重要几点:

  • 大多数系统内部都有几个不同的时钟。

  • 毫秒级的计时是安全可靠的。

  • 高精度时间需要谨慎处理,以避免漂移。

  • 您需要意识到计时测量的精度和准确性。

在我们继续讨论垃圾回收之前,让我们看看我们之前提到的一个例子——内存缓存对代码性能的影响。

7.4.2 理解缓存未命中

对于许多高吞吐量的代码片段,降低性能的主要因素之一是执行应用程序代码时涉及的 L1 缓存未命中次数。列表 7.1 遍历一个 2MiB 数组,并打印执行两个循环之一所需的时间。第一个循环在int[]的每个 16 个条目中增加 1。几乎总是有 64 字节在一个 L1 缓存行中(Java 的int是 4 字节宽),这意味着只接触每个缓存行一次。

注意,在您能够获得准确结果之前,我们需要预热代码,这样 JVM 就会编译您感兴趣的方法。我们将在本章后面更详细地讨论 JIT 预热。

列表 7.1 理解缓存未命中

public class Caching {
    private final int ARR_SIZE = 2 * 1024 * 1024;
    private final int[] testData = new int[ARR_SIZE];

    private void touchEveryItem() {
        for (int i = 0; i < testData.length; i = i + 1) {
            testData[i] = testData[i] + 1;                    ❶
        }
    }

    private void touchEveryLine() {
        for (int i = 0; i < testData.length; i = i + 16) {
            testData[i] = testData[i] + 1;                    ❷
        }
    }

    private void run() {
        for (int i = 0; i < 10_000; i = i + 1) {              ❸
            touchEveryLine();
            touchEveryItem();
        }
        System.out.println("Line     Item");
        for (int i = 0; i < 100; i = i + 1) {
            long t0 = System.nanoTime();
            touchEveryLine();
            long t1 = System.nanoTime();
            touchEveryItem();
            long t2 = System.nanoTime();
            long el1 = t1 - t0;
            long el2 = t2 - t1;
            System.out.println("Line: "+ el1 +" ns ; Item: "+ el2);
        }
    }

    public static void main(String[] args) {
        Caching c = new Caching();
        c.run();
    }
}

❶ 涉及每个项目

❷ 涉及每个缓存行

❸ 预热代码

第二个函数touchEveryItem()增加数组中的每个字节,所以它比touchEveryLine()多做 16 倍的工作。但是,这里有一些来自典型笔记本电脑的样本结果:

Line: 487481 ns ; Item: 452421
Line: 425039 ns ; Item: 428397
Line: 415447 ns ; Item: 395332
Line: 372815 ns ; Item: 397519
Line: 366305 ns ; Item: 375376
Line: 332249 ns ; Item: 330512

这段代码的结果表明touchEveryItem()的运行时间并不比touchEveryLine()长 16 倍。是内存传输时间——从主内存到 CPU 缓存的加载——主导了整体性能轮廓。touchEveryLine()touchEveryItem()有相同数量的缓存行读取,数据传输时间远远超过了实际修改数据所花费的周期。

注意:这证明了关键点:我们需要至少有一个工作理解(或心理模型)来了解 CPU 实际上是如何花费它的时间的。

我们接下来要讨论的是平台垃圾回收子系统的讨论。这是性能图中最重要的部分之一,它有可调整的部分,对于进行性能分析的开发者来说可能是非常重要的工具。

7.5 垃圾回收

自动内存管理是 Java 平台最重要的部分之一。在 Java 和.NET 等托管平台之前,开发者可以预期在他们的职业生涯中花费相当一部分时间来追踪由不完善的内存处理引起的错误。

然而,近年来,自动分配技术已经变得如此先进和可靠,以至于它们已经成为基础设施的一部分——大量的 Java 开发者对平台内存管理功能的工作原理、开发者可用的选项以及如何在框架约束内进行优化一无所知。

这标志着 Java 方法取得了多么大的成功。大多数开发者不知道内存和 GC 系统的细节,因为他们通常根本不需要知道。JVM 可以很好地处理大多数应用程序的内存,无需任何特殊调整。

那么,当你处于需要调整的情况时,你能做什么呢?首先,你需要了解 JVM 实际上是如何为您管理内存的。因此,在本节中,我们将介绍基本理论,包括

  • 运行 Java 进程时的内存处理方式

  • 标记-清除收集的基础

  • 自 Java 9 以来,垃圾收集器(Garbage First,G1)一直是 Java 的默认收集器

让我们从基础知识开始。

7.5.1 基础知识

标准的 Java 进程既有栈也有堆。 是存储局部变量的地方。直接存储原始值的局部变量将原始值存储在栈中。

注意:原始值持有将被根据其类型解释的位模式,因此这两个字节 00000000 01100001 如果类型是 char,将被解释为 a;如果类型是 short,将被解释为 97。

另一方面,引用类型的局部变量将指向 Java 的 中的一个位置,这是对象实际创建的地方。图 7.4 展示了各种类型变量存储的位置。

图片

图 7.4 栈和堆中的变量

注意,对象的原始字段仍然在堆内的地址分配。随着 Java 程序的运行,会在堆中创建新的对象,对象之间的关系会发生变化(因为字段被更新)。最终,堆将没有足够的空间来创建新的对象。然而,许多已经创建的对象将不再需要(例如,在一个方法中创建但未传递给任何其他方法或返回给调用者的临时对象)。

因此,堆中的空间可以被回收,程序可以继续运行。平台通过恢复和重用不再由应用程序代码使用的堆内存的机制被称为 垃圾收集

7.5.2 标记和清除

一个简单的垃圾收集算法的绝佳例子是 标记和清除,实际上,它是第一个被开发的(在 1965 年发布的 LISP 1.5 中)。

注意:其他自动内存管理技术存在,例如 Perl 等语言使用的引用计数方法,它们可能更简单(至少表面上),但它们实际上并不是垃圾收集(如 Guy L. Steele 在“Multiprocessing Compactifying Garbage Collection”ACM 通讯 18, no. 9 [1975 年 9 月] 中所述)。

标记-清除算法在其最简单形式下,会暂停所有正在运行的程序线程,并从已知为“活动”的对象集合开始——这些对象在任何用户线程的任何栈帧(无论该引用是局部变量、方法参数、临时变量还是一些更罕见的情况)中都有一个引用。然后,它遍历从活动对象开始的引用树,标记沿途找到的任何对象为活动状态。当这一过程完成后,剩下的所有东西都是垃圾,可以被收集(清除)。请注意,清除的内存返回给 JVM,而不是返回给操作系统。

那非确定性暂停怎么办?

对 Java(以及.NET 等其他环境)的批评之一是,标记-清除形式的垃圾回收不可避免地会导致“停止世界”(通常称为 STW)。在这些状态下,所有用户线程必须短暂停止,这会导致持续一段时间的不确定暂停。

这个问题通常被夸大了。对于服务器软件来说,很少有应用程序需要关心现代 Java 版本垃圾收集器显示的暂停时间。例如,在 Java 11 及以上版本中,默认的垃圾收集器是一个并发收集器,它的大部分工作与应用程序线程并行进行,并最小化暂停时间。

注意:开发者有时会想出复杂的方案来避免暂停,或者完全收集内存。在几乎所有情况下,都应该避免这些方案,因为它们通常弊大于利。

Java 平台为基本的标记-清除(mark-and-sweep)方法提供了一系列增强。其中最简单的一种是添加了代际垃圾回收(generational GC)。在这种方法中,堆(heap)不是一个统一的内存区域——堆内存的多个不同区域参与 Java 对象的整个生命周期。

根据对象的寿命长短,它们在收集过程中可以从一个区域移动到另一个区域。在对象的整个生命周期中,对其的引用可以指向几个不同的内存区域(如图 7.5 所示)。

图 7.5 内存区域

这种安排(以及对象的移动)的原因是,对运行系统的分析表明,对象要么寿命短暂,要么非常长寿。堆内存的不同区域被设计成允许平台利用这一特性,通过将长寿对象与其他对象分开。

请注意,图 7.5 是一个简单的堆示意图,旨在说明代际区域的概念。真实 Java 堆的现实情况要复杂一些,这取决于所使用的收集器,我们将在本章后面解释。

7.5.3 内存区域

JVM 在对象自然生命周期期间使用以下不同的内存区域来存储对象:

  • 伊甸园(Eden)—伊甸园是堆中所有对象最初分配的区域,对于许多对象来说,这将是它们唯一驻留的内存部分。

  • 幸存者—这些空间是那些在垃圾回收周期中幸存下来的对象(因此得名)被移动的地方。最初,它们是从伊甸园(Eden)移动过来的,但在随后的垃圾回收(GC)过程中,它们也可能在幸存者空间之间移动。

  • 持久代(Tenured)—持久代(也称为老年代)是那些被认为“足够老”的幸存对象被移动到的区域(逃离幸存者空间)。在年轻代收集期间不会收集持久代内存。

正如所提到的,这些内存区域也以不同的方式参与收集。例如,幸存者空间实际上是一个通用的捕获机制,以便在收集之前立即创建的短生命期对象能够得到适当的处理。

如果没有幸存者空间,那么非常新创建的(但短生命期的)对象会被 GC 标记为“活着的”,并提升到 Tenured。然后它们会立即死亡,但会继续占用 Tenured 空间,直到下一次它被收集。下一次收集也会因为实际上短生命期对象的错误提升而比必要的更早发生。从理论角度来看,代假设也引导我们想到有两种类型的收集:年轻和完整。

7.5.4 年轻集合

一个 年轻集合 尝试清除“年轻”空间(伊甸园和幸存者)。这个过程相对简单,如下所述:

  • 在标记阶段发现的全部活着的对象都会被移动。

  • 足够老的(那些在足够的 GC 运行中存活下来的)对象会进入 Tenured。

  • 所有其他年轻、活着的对象都会进入一个空的幸存者空间。

  • 最后,伊甸园和任何最近被清空的幸存者空间都准备好被覆盖和重用,因为它们除了垃圾什么都没有。

当伊甸园满时,会触发一个年轻集合。请注意,标记阶段必须遍历整个活着的对象图。如果一个年轻对象引用了一个 Tenured 对象,那么 Tenured 对象持有的引用也必须被扫描和标记。否则,可能会出现 Tenured 对象持有对伊甸园中对象的引用,但没有其他任何东西持有该引用。如果标记阶段没有完全遍历,这个伊甸园对象将永远不会被看到,并且不会被正确处理。在实践中,一些性能优化(例如 卡片表)被用来减少全标记遍历可能的高成本。

7.5.5 完整集合

当一个年轻集合无法将对象提升为 Tenured(由于空间不足)时,会触发一个完整集合。根据所使用的收集器,这可能会涉及在旧代内部移动对象。这样做是为了确保旧代有足够的空间在必要时分配一个大对象。这被称为 压缩

7.5.6 安全点

垃圾收集不能在没有至少短暂暂停所有应用程序线程的情况下进行。然而,线程不能在任何任意时间停止以进行 GC,因为应用程序代码可以修改堆的内容。相反,在特定的时间点,JVM 可以确保堆处于一致状态,GC 可以发生——这些被称为 安全点

safepoint 的一个简单例子是“字节码指令之间”。JVM 解释器一次执行一个字节码,然后循环从流中获取下一个字节码。在循环之前,那个解释器线程必须完成对堆的任何修改(例如,来自putfield的修改),所以如果线程在那里停止,它就是“安全的”。一旦所有应用程序线程达到 safepoint,垃圾收集就可以进行。

这是一个简单的 safepoint 示例,但还有其他示例。关于 safepoint 的更完整讨论,以及它们如何影响某些 JIT 编译器技术,可以在这里找到:mng.bz/Oo8a。让我们从理论讨论转向实际,了解 JVM 中的垃圾收集算法。

7.5.7 G1: Java 的默认收集器

G1 是 Java 平台的一个相对较新的收集器。它在 Java 8u40 时达到生产质量,并在 Java 9(2017 年)中被设置为默认收集器。它最初被设计为一个低暂停收集器,但在实践中已经发展成为一个通用收集器(因此成为默认状态)。

它不仅是一个代垃圾收集器,而且它是区域化的,这意味着 G1 Java 堆将堆分成大小相等的区域(例如,每个 1、2 或 4MB)。代仍然存在,但现在它们在内存中不再一定是连续的。堆中大小相等的区域的新排列如图 7.6 所示。

图片

图 7.6 G1 如何划分堆

为了支持 GC 暂停的可预测性,引入了区域化。较老的收集器(如并行收集器)存在一个问题,一旦 GC 周期开始,它就需要运行到完成,不管这需要多长时间(即,它们是“全有或全无”的)。

G1 提供了一种收集策略,它不会导致较大堆的暂停时间更长。它被设计用来避免全有或全无的行为,而关键概念之一是暂停目标。这是程序在恢复执行之前可以暂停 GC 的时间长度。G1 会尽其所能,在合理范围内达到您的暂停目标。在暂停期间,幸存对象会被迁移到另一个区域(例如,将 Eden 对象移动到幸存空间),然后该区域被放置回空区域的自由列表

G1 中的年轻收集是完全 STW(Stop-The-World)的,并且会运行到完成。这避免了收集线程和分配线程之间的竞争条件(如果年轻收集与应用程序线程并发运行,可能会发生这种情况)。

注意:代假设是,在年轻收集期间遇到的仅有一小部分对象仍然存活。因此,年轻收集所需的时间应该非常小,远小于暂停目标。

旧对象的集合与年轻集合具有不同的特性——首先,因为一旦对象达到旧代,它们往往能存活相当长的时间。其次,为旧代提供的空间往往比年轻代大得多。

G1 会跟踪移动到旧代的对象,当足够的老空间被填满(由InitiatingHeapOccupancyPercent或 IHOP 控制,默认为 45%)时,就会启动旧代收集。这是一个并发收集,因为它尽可能地与应用程序线程并发运行。

这个旧代收集的第一部分是一个并发标记阶段。这是基于 1978 年由迪杰斯特拉和兰波特首次描述的算法(见dl.acm.org/doi/10.1145/359642.359655)。一旦完成,就会立即触发年轻代收集。随后是一个混合收集,它根据区域中垃圾的数量(可以从并发标记期间收集的统计数据中推断出来)来收集旧区域。旧区域中幸存的对象会被疏散到新的旧区域(并进行压缩)。

G1 收集策略的性质还允许平台收集有关单个区域收集所需时间(平均)的统计数据。这就是暂停目标是如何实现的——G1 将只收集它有时间收集的区域(尽管如果最后一个区域收集时间比预期长,可能会出现超时)。

有可能整个旧代的收集无法在一个 GC 周期内完成。在这种情况下,G1 只收集一组区域,然后完成收集,释放用于 GC 的 CPU 核心。只要在一段持续的时间内,长生命对象的创建不会超过 GC 回收它们的能力,一切应该都会顺利。

在分配超过回收持续一段时间的情况下,作为最后的努力,GC 将执行 STW 完整收集,并完全清理和压缩旧代。在实践中,除非应用程序处于严重困境,否则这种行为是不会看到的。

另有一点值得提及:可以分配比单个区域更大的对象。在实践中,这意味着一个大数组(通常是字节或其他原始数据类型)。

注意:理论上可以人为构造一个具有如此多字段,以至于单个对象实例大于 1 MB 的类,但在实际、真实的系统中,这种情况永远不会发生。

这样的对象需要一个特殊类型的区域——一个巨型区域。这些区域需要 GC 的特殊处理,因为为大数组分配的空间在内存中必须是连续的。如果足够的空闲区域相邻,它们可以被转换成一个单一的巨型区域,数组就可以被分配。

如果内存中没有地方可以分配数组(甚至在年轻代收集之后),那么内存就被说成是碎片化的。GC 必须执行完全 STW 和压缩收集,以尝试为分配腾出足够的空间。

G1 被确立为在广泛的负载和应用类型中非常有效的收集器。然而,对于某些工作负载(例如,需要纯吞吐量或仍在运行 Java 8 的工作负载),另一个收集器,如并行收集器,可能是有用的。

7.5.8 并行收集器

并行收集器在 Java 8 之前是默认的,并且今天仍然可以用作 G1 的替代选择。Parallel 这个名字需要一点解释,因为并发并行都用来描述 GC 算法的特性。它们听起来好像应该意味着相同的事情,但实际上它们有两个完全不同的含义,如以下所述:

  • 并发——GC 线程可以与应用程序线程同时运行。

  • 并行——GC 算法是多线程的,可以使用多个核心。

这些术语在没有任何方式上是等效的。相反,最好将它们视为两个其他 GC 术语的对立面——并发是 STW 的对立面,并行单线程的对立面。

在某些收集器(包括并行收集器)中,堆不是区域化的。相反,代是连续的内存区域,可以根据需要增长和缩小。在这种堆配置中,有两个幸存空间。它们有时被称为FromTo,除非正在进行收集,否则其中一个幸存空间总是空的。

注意:Java 的非常早期版本中有一个名为PermGen(或永久生成)的空间。这是为 JVM 的内部结构分配内存的地方,例如类和方法定义。PermGen 在 Java 8 中被移除,所以如果你发现任何提及它的资源,那么它们很可能是旧的并且可能已经过时。

并行收集器是一个非常高效的收集器——在主流 Java 中效率最高的一个——但它也有一个缺点:它没有真正的暂停目标能力,并且旧收集(STW)必须运行完成,不管需要多长时间。

一些开发者有时会询问关于 GC 算法的复杂度(即“大 O”)行为的问题。然而,这实际上并不是一个有用的问题。GC 算法非常通用,并且它们需要在整个可能的负载范围内表现出可接受的行为。仅仅关注它们的渐近行为并不是非常有用,而且绝对不是它们一般情况性能的合适代理。

垃圾回收总是关于权衡,G1 做出的权衡对大多数工作负载来说非常好(好到许多开发者可以完全忽略它们)。然而,权衡总是存在的,无论开发者是否意识到它们。有些应用程序不能忽略权衡,必须选择关注 GC 子系统的细节,无论是通过更改收集算法还是通过调整 GC 参数来微调。

7.5.9 GC 配置参数

JVM 随带了大量有用的参数(至少有一百个),可以用来定制 JVM 运行行为的许多方面。在本节中,我们将讨论一些与垃圾收集相关的基本开关。

如果一个开关以 -X: 开头,它是不标准的,可能无法跨 JVM 实现移植(例如 HotSpot 或 Eclipse OpenJ9)。如果它以 -XX: 开头,它是一个扩展开关,不建议用于日常使用。许多与性能相关的开关都是扩展开关。

一些开关在效果上是布尔值,并在前面带有 + 或 - 来打开或关闭。其他开关需要参数,例如 -XX:CompileThreshold=20000(这将设置方法需要被调用多少次才被认为是 JIT 编译的次数为 20000)。表 7.1 列出了基本的 GC 开关,并显示了开关的默认值(如果有)。

表 7.1 基本垃圾收集开关

开关 影响
-Xmsm 堆的初始大小(默认为物理内存的 1/64)
-Xmxm 堆的最大大小(默认为物理内存的 1/4)
-Xmnm 堆中年轻代的大小
-XX: -DisableExplicitGC 防止对 System.gc() 的调用产生任何效果

一个不幸但常见的技巧是将 -Xms 的大小设置为与 -Xmx 相同。这意味着进程将以确切的堆大小运行,并且在执行期间不会调整大小。表面上,这似乎是有道理的,并且给开发者一种控制的错觉。然而,在实践中,这种方法是一种反模式。现代 GC 具有良好的动态大小算法,人工限制它们几乎总是弊大于利。

注意:在 2022 年,对于大多数工作负载,如果没有其他证据,最佳实践是设置 Xmx 而不设置 Xms

还值得注意的是 JVM 在容器中的行为。对于 Java 11 和 17,“物理内存”意味着容器限制,因此堆的最大大小必须适合任何容器限制,并留有非 Java 堆内存和 JVM 以外的任何其他进程的空间。Java 8 的早期版本不一定尊重容器限制,因此建议始终升级到 Java 11,如果您在容器中运行应用程序。对于 G1 收集器,在调整练习期间可能还有两个其他设置可能有用——它们在表 7.2 中显示。

表 7.2 G1 收集器的标志

开关 影响
-XX:MaxGCPauseMillis=50 告诉 G1 在一次收集期间尝试暂停不超过 50 毫秒
-XX:GCPauseIntervalMillis=200 告诉 G1 在收集之间至少运行 200 毫秒

这些开关可以组合使用,例如,将最大暂停目标设置为 50 毫秒,暂停间隔不小于 200 毫秒。当然,GC 系统可以施加的压力是有限的。必须有一定的暂停时间来清理垃圾。每 100 年暂停 1 毫秒的目标显然是无法实现或被尊重的。

在下一节中,我们将探讨 JIT 编译。对于许多程序来说,这是产生高性能代码的主要贡献因素之一。我们将探讨 JIT 编译的一些基本知识,并在本节结束时解释如何开启 JIT 编译的日志记录,以便您知道哪些方法正在被编译。

7.6 HotSpot 的 JIT 编译

正如我们在第一章中讨论的,Java 平台最好被看作是“动态编译”的。一些应用程序和框架类在运行时进行进一步编译,以将它们转换为可以直接执行的机器代码。

这个过程被称为即时(JIT)编译,或简称 JITing,它通常一次只针对一个方法。理解这个过程通常是识别任何大型代码库重要部分的关键。

让我们看看关于 JIT 编译的一些基本事实:

  • 几乎所有现代 JVM 都将拥有某种类型的 JIT 编译器。

  • 与纯解释型 JVM 相比,速度非常慢。

  • 编译方法比解释代码运行得快得多。

  • 首先编译最频繁使用的方法是有意义的。

  • 在进行即时编译(JIT)时,首先抓住低垂的果实总是很重要的。

这最后一点意味着我们应该首先查看编译后的代码,因为在正常情况下,任何仍然处于解释状态的方法都没有像编译过的那些方法运行得频繁。(偶尔一个方法会失败编译,但这非常罕见。)

方法最初是从它们的字节码表示形式进行解释的,JVM 会跟踪一个方法被调用的次数(以及一些其他统计数据)。当达到阈值值时,如果方法符合条件,JVM 线程将在后台将字节码编译成机器代码。如果编译成功,所有后续对该方法的调用都将使用编译后的形式,除非发生某些情况使其无效或导致去优化。

根据方法中代码的确切性质,编译后的方法可能比解释模式下的相同方法快得多。有时会给出“最多快 100 倍”的数字,但这只是一个非常粗略的经验法则。即时编译的本质会极大地改变执行代码,任何单一数字都是误导性的。理解程序中哪些方法是重要的,以及哪些重要的方法正在被编译,通常是提高性能的主要技术之一。

7.6.1 为什么要有动态编译?

有时会被问到的问题之一是,为什么 Java 平台要费心进行动态编译?为什么不像 C++那样在编译前完成所有编译?第一个答案通常是,拥有平台无关的工件(.jar 和.class 文件)作为部署的基本单元,比尝试处理每个目标平台的不同编译二进制文件要少很多麻烦。

另一个替代方案,并且更加雄心勃勃的答案是,使用动态编译的语言(如 Java)有更多的信息可供编译器使用。具体来说,AOT 编译的语言无法访问任何运行时信息,例如某些指令的可用性或其他硬件细节,或代码运行的任何统计数据。这开启了一个引人入胜的可能性,即动态编译的语言(如 Java)实际上可能比 AOT 编译的语言运行得更快。

注意:直接、AOT(Ahead-of-Time)编译 Java 字节码到机器代码(也称为“静态 Java”)是 Java 社区中的一个活跃研究领域,但不幸的是,它超出了本书的范围。

在本节关于 JIT 机制的其他讨论中,我们将具体讨论名为 HotSpot 的 JVM。许多一般性讨论将适用于其他虚拟机(VM),但具体细节可能会有很大差异。

我们将首先介绍随 HotSpot 一起提供的不同即时(JIT)编译器,然后解释 HotSpot 提供的两种最强大的优化——内联单态分发。我们将通过展示如何开启方法编译的日志记录来结束本节,这样您就可以看到哪些方法正在被编译。让我们从介绍 HotSpot 开始。

7.6.2 热点(HotSpot)简介

HotSpot 是 Oracle 在收购 Sun Microsystems 时获得的 JVM(它已经拥有一个名为 JRockit 的 JVM,最初由 BEA Systems 开发)。HotSpot 是构成 OpenJDK 基础的 JVM。它能够在两种不同的模式下运行:客户端和服务器端。

在过去,可以通过在启动 JVM 时指定-client-server开关来选择模式。每种模式都有不同的应用场景,可以根据需要选择。

C1(也称为客户端编译器)

C1 编译器最初是为了在 GUI 应用程序中使用而设计的。这是一个重视操作一致性的领域,因此 C1(有时称为客户端编译器)在编译时倾向于做出更保守的决定。它不能在撤销一个证明是错误的或基于错误假设的优化决策时意外地暂停。它的编译阈值相当低——一个方法必须执行 1500 次才能有资格进行编译——因此它的预热期相对较短。

C2(又称服务器编译器)

相比之下,服务器编译器(C2)在编译时做出大胆的假设。为了确保运行的代码始终正确,C2 添加了一个快速的运行时检查(通常称为保护条件),以验证它所做的假设是否有效。如果不是,它会撤销大胆的编译,并经常尝试其他方法。这种大胆的方法可以带来比相对风险规避的客户端编译器更好的性能。

C2 的内联阈值比 C1 高得多。默认情况下,一个方法必须达到 10,000 次调用才有资格进行 C2 编译,这意味着更长的预热时间。

实时 Java

从历史上看,开发了一种称为实时 Java 的 Java 形式,一些开发者想知道为什么需要高性能的代码不简单地使用这个平台(这是一个独立的 JVM,而不是 HotSpot 选项)。答案是,尽管有普遍的误解,实时系统并不一定是最快的系统。

实时编程实际上关于可以做出的保证。从统计学的角度来看,实时系统寻求减少执行某些操作所需时间的方差,并准备为此牺牲一定量的平均延迟。为了达到更一致的运行,整体性能可能会略有牺牲。寻求更高性能的团队通常在寻求更低的平均延迟,即使这意味着更高的方差,因此服务器编译器的大胆优化特别适合。

在现代 JVM 中,客户端和服务器编译器都得到使用——客户端编译器在早期使用,而高级服务器类优化在应用程序预热后使用。这种双重使用被称为分层编译。我们接下来要讨论的是所有 JIT 编译器广泛使用的一个主题。

7.6.3 内联方法

内联是 HotSpot 拥有的最强大的技术之一。它通过消除对内联方法的调用,并将被调用方法的代码放置在调用者内部来实现。

平台的一个优点是编译器可以根据关于方法调用频率和其他因素的合理运行时统计数据来决定是否内联(例如,这会使调用方法变得过大并可能影响代码缓存)。HotSpot 的编译器在决定内联方面比即时编译器能做出更明智的决定。

访问器方法怎么办?

一些开发者错误地认为,访问器方法(一个公共获取器访问私有成员变量)不能被 HotSpot 内联。他们的理由是,因为变量是私有的,方法调用不能被优化掉,因为对它的访问在类外是被禁止的。这是不正确的。

HotSpot 可以在编译方法到机器代码时忽略访问控制,并将访问器方法替换为直接访问私有字段。这并不损害 Java 的安全模型,因为所有的访问控制都是在类加载或链接时检查过的。

方法的内联是完全自动的,在几乎所有情况下,默认参数值都是合适的。有开关可以控制将内联的方法大小以及一个方法需要被调用多少次才能成为候选者。

这些开关主要用于好奇的程序员更好地理解内部工作的内联部分。它们通常对生产代码没有太大用处,应被视为一种最后的性能技术手段,因为它们可能会对运行时系统的性能产生其他不可预测的影响。

7.6.4 动态编译和单态调用

这种类型激进优化的一个例子是单态调用。这是一种基于观察的优化,即,在大多数情况下,对对象的方法调用,例如:

MyActualClassNotInterface obj = getInstance();

obj.callMyMethod();

将只被一种类型的对象调用。另一种说法是,调用点obj.callMyMethod()几乎永远不会同时遇到一个类及其子类。在这种情况下,Java 方法查找可以被替换为直接调用对应于callMyMethod()的编译代码。

注意:单态分派提供了 JVM 运行时分析的一个例子,允许平台执行 C++这样的 AOT 语言无法进行的优化。

没有技术原因说明为什么在某些情况下getInstance()方法不能返回MyActualClassNotInterface类型的对象,而在其他情况下返回某个子类的对象。为了防止这种情况发生,除非在每次调用位置都看到完全相同的类型,直到达到编译阈值,否则getInstance()方法不会用于单态优化。编译代码中还会插入一个运行时测试来检查obj的类型,以便于未来的调用。如果这个预期被违反,运行时会撤销优化,而程序不会注意到或做任何错误的事情。

这是一个相当激进的优化,仅由服务器编译器执行。客户端编译器不会这样做。

7.6.5 阅读编译日志

让我们通过一个例子来看看如何使用 JIT 编译器输出的日志消息。Hipparcos 星表列出了从地球可以观察到的恒星详情。我们的示例应用程序处理该目录以生成特定地点特定夜晚可见的恒星星图。

让我们看看一些示例输出,这些输出显示了在运行我们的星图应用程序时正在编译哪些方法。我们使用的关键 JVM 标志是-XX:+Print-Compilation。这是我们之前简要讨论过的一个扩展开关。将此开关添加到启动 JVM 时使用的命令行中,告诉 JIT 编译线程向标准日志添加消息。这些消息指示方法何时通过编译阈值并转换为机器代码,如下所示:

1 java.lang.String::hashCode (64 bytes)
2 java.math.BigInteger::mulAdd (81 bytes)
3 java.math.BigInteger::multiplyToLen (219 bytes)
4 java.math.BigInteger::addOne (77 bytes)
5 java.math.BigInteger::squareToLen (172 bytes)
6 java.math.BigInteger::primitiveLeftShift (79 bytes)
7 java.math.BigInteger::montReduce (99 bytes)
8 sun.security.provider.SHA::implCompress (491 bytes)
9 java.lang.String::charAt (33 bytes)
1% ! sun.nio.cs.SingleByteDecoder::decodeArrayLoop @ 129 (308 bytes)
...
39 sun.misc.FloatingDecimal::doubleValue (1289 bytes)
40 org.camelot.hipparcos.DelimitedLine::getNextString (5 bytes)
41 ! org.camelot.hipparcos.Star::parseStar (301 bytes)
...
2% ! org.camelot.CamelotStarter::populateStarStore @ 25 (106 bytes)
65 s java.lang.StringBuffer::append (8 bytes)

这是从PrintCompilation输出的典型输出。这些行指示哪些方法被认为足够“热”而被编译。正如你所期望的,首先被编译的方法很可能是平台方法(例如String ::hashCode())。随着时间的推移,应用程序方法(例如示例中用于解析天文目录记录的org.camelot.hipparcos .Star::parseStar()方法)也将被编译。

输出行带有编号,这表示在这次运行中方法被编译的顺序。请注意,由于平台的动态特性,这个顺序可能在不同的运行之间略有变化。其他一些字段如下:

  • s—表示该方法被同步

  • !—表示该方法有异常处理器

  • %—栈上替换(OSR)

OSR 表示该方法已被编译并替换了运行代码中的解释版本。请注意,OSR 方法有自己的编号方案,从 1 开始。

小心僵尸

当查看使用服务器编译器(C2)运行的代码的示例输出日志时,你偶尔会看到“made not entrant”和“made zombie”这样的行。这些行意味着一个特定的方法,它已经被编译,但现在已被无效化,通常是因为类加载操作。

7.6.6 反优化

HotSpot 能够反优化基于一个最终证明是错误的假设的代码。在许多情况下,它将重新考虑并尝试另一种优化。因此,同一个方法可能被反优化和重新编译多次。

随着时间的推移,您会发现编译方法的数量会趋于稳定。代码达到一个稳定的编译状态,并大部分保持在那里。具体哪些方法会被编译可能取决于所使用的 JVM 版本和操作系统平台。认为所有平台都会产生相同的一组编译方法,并且给定方法的编译代码在各个平台上的大小大致相同是错误的。正如性能空间中的许多其他事情一样,这应该被衡量,结果可能会令人惊讶。即使是看起来相当无害的 Java 方法,JIT 编译生成的机器代码在 Mac 和 Linux 之间也证明了有五倍的性能差异。

测量始终是必要的。幸运的是,现代 JVM 预装了一些优秀的工具,以促进深入的性能分析。让我们来看看它们。

7.7 JDK 飞行记录器

从历史上看,飞行记录器和任务控制工具(通常称为 JFR 和 JMC)是在 2008 年 Oracle 收购 BEA Systems 时获得的。这两个组件协同工作——JFR 是一个低开销、基于事件的分析引擎,具有高性能的后端用于以二进制格式写入事件,而 JMC 是一个用于检查由 JFR 从单个 JVM 的遥测数据创建的数据文件的 GUI 工具。

这些工具最初是 BEA 的 JRockit JVM 工具集的一部分,在将 JRockit 与 HotSpot 合并的过程中被转移到 Oracle JDK 的商业版本中。JDK 9 发布后,Oracle 改变了 Java 的发布模式,并宣布 JFR 和 JMC 将成为开源工具。JFR 被贡献给了 OpenJDK,并在 JDK 11 中作为 JEP 328 提供。JMC 被分离出来成为一个独立的开源项目,现在作为一个单独的下载存在。

注意,Java 14 为 JFR 引入了一个新特性:JFR 能够产生一个连续的事件流。这一变化提供了一个回调 API,以便能够立即处理事件,而不是在事后解析文件。

然而,一个问题是因为 JFR 和 JMC 最近才成为开源工具,许多 Java 开发者并不了解它们的强大功能。让我们借此机会从开始介绍 JMC 和 JFR。

7.7.1 飞行记录器

JFR 首次作为 OpenJDK 11 的一部分以开源形式提供,因此要使用它,您需要运行该版本(或更高版本)。该技术也被回滚到 OpenJDK 8,并适用于 8u262 及更高版本。

创建 JFR 记录有多种方法,但我们将重点探讨两种:在启动 JVM 时使用命令行参数以及使用 jcmd

首先,让我们看看在进程启动时需要哪些命令行开关来启动 JFR。关键开关如下:

-XX:StartFlightRecording:<options>

这可以是作为一次性转储文件或连续环形缓冲区,并且大量的单个命令行选项控制着正在捕获哪些数据。

此外,JFR 可以捕获一百多种不同的可能指标。其中大多数影响非常小,但有些确实会产生一些开销。单独管理所有这些指标的配置将是一项巨大的任务。

相反,为了简化过程,JFR 使用配置文件。这些是简单的 XML 文件,包含每个指标的配置以及是否应该捕获。标准的 JDK 下载包含两个基本文件:default.jfc 和 profile.jfc。

默认的记录级别旨在具有极低的开销,并且基本上可以由每个生产 Java 进程使用。profile.jfc 配置包含更详细的信息,但当然,这会带来更高的运行时成本。

注意:除了提供的两个文件,还可以创建一个自定义配置文件,其中只包含所需的数据点。JMC 工具有一个模板管理器,可以轻松创建这些文件。

除了设置文件,还可以传递其他选项,包括存储记录数据的文件名以及要保留的数据量(以数据点的年龄来衡量)。例如,一个整体的 JFR 命令行可能看起来像这样(给出在一行中):

-XX:StartFlightRecording:disk=true,filename=svc/sandbox/service.jfr,
                         maxage=12h,settings=profile

注意:当 JFR 是商业构建的一部分时,可以通过-XX:+UnlockCommercialFeatures开关来解锁。然而,Oracle JDK 11+在使用-XX:+UnlockCommercialFeatures选项时会发出警告。这是因为所有商业功能都已开源,并且由于该标志从未是 OpenJDK 的一部分,继续使用它没有意义。在 OpenJDK 构建中,使用商业功能标志会导致错误。

JFR 的一个伟大特性是它不需要在进程启动时进行配置。相反,它可以通过jcmd命令从命令行进行控制,如下所示:

$ jcmd <pid> JFR.start name=Recording1 settings=default
$ jcmd <pid> JFR.dump filename=recording.jfr
$ jcmd <pid> JFR.stop

JFR 还提供了一个 JMX API 来控制 JFR 记录。然而,无论 JFR 如何激活,最终结果都是相同的——每个 JVM 的每个分析运行只有一个文件。该文件包含大量二进制数据,不便于人类阅读,因此我们需要某种工具来提取和可视化这些数据。

7.7.2 任务控制台

JDK 任务控制台(JMC)是一个图形工具,用于显示 JFR 输出文件中的数据。它可以通过jmc命令启动。这个程序曾经包含在 Oracle JDK 下载中,但现在可以从jdk.java.net/jmc/单独获取。

任务控制台的启动屏幕如图 7.7 所示。在加载文件后,JMC 会对其进行一些自动分析,以识别记录运行中存在的任何明显问题。

图片

图 7.7 JMC 启动屏幕

注意:要进行分析,当然必须在目标应用程序上启用 Flight Recorder。除了使用之前创建的文件外,还可以在应用程序启动后动态地附加它。对于后者,JMC 在左上角面板的左侧提供了一个标签为 JVM 浏览器的选项卡,用于动态地将它附加到本地应用程序。

在 JMC 中遇到的第一个屏幕之一是概述遥测屏幕,它显示了 JVM 整体健康状况的高级仪表板。这可以在图 7.8 中看到。

图片

图 7.8 JMC 仪表板

JVM 的主要子系统都拥有专门的屏幕,以便进行深入分析。例如,垃圾回收有一个概述屏幕,用于显示 JFR 文件生命周期内的 GC 事件。底部的“最长暂停”显示允许用户查看在时间线中任何异常长的 GC 事件发生的位置,如图 7.9 所示。

图片

图 7.9 JMC 垃圾回收

在详细配置配置中,还可以看到将新的分配缓冲区(TLABs)分配给应用程序线程的单独事件。我们可以看到进程内分配的更精确视图。这个视图看起来就像图 7.10 中所示的那样。这个视图允许开发者轻松地看到哪些线程分配了最多的内存——在这个例子中,是一个正在消费 Apache Kafka 主题数据的线程。

图片

图 7.10 JMC TLAB 分配

JVM 的另一个主要子系统是 JIT 编译器,JMC 允许我们深入了解编译器的工作细节,如图 7.11 所示。

图片

图 7.11 JMC JIT 编译

一个关键资源是 JIT 编译器的代码缓存中可用的内存。这是存储方法编译版本的区域。JMC 可以可视化代码缓存的使用情况——一个示例在图 7.12 中显示。

图片

图 7.12 JMC JIT 代码缓存

对于拥有大量编译方法的过程,这个内存区域可能会耗尽,导致进程无法达到峰值性能。

JMC 还包括一个方法级分析器,其工作方式与 VisualVM 或 JProfiler 或 YourKit 等商业工具中的分析器非常相似。图 7.13 显示了一个典型的结果。

图片

图 7.13 JMC 方法分析

JMC 中更高级的屏幕之一是 VM 操作视图,它显示了 JVM 执行的一些内部操作及其持续时间。这不是我们预期在每次分析中都需要查看的视图,但它对于检测某些不太常见的问题可能非常有用。我们可以在图 7.14 中看到一个典型的用法。

图片

图 7.14 JMC JVM 操作

JMC 可以用来诊断单个 JVM,这是一个非常棒的功能。然而,这种用例并不能扩展到检查整个集群(或完整的应用程序)。此外,现代系统通常还需要监控或可观察性解决方案以及深入探究的能力。

经典的 JFR 记录文件模型(以及单文件 JVM)并不便于此。它并不适合通过网络传输到 SaaS 提供商或内部工具的遥测数据流。一些供应商(例如 New Relic 和 DataDog)确实提供了 JFR 功能,但使用这些技术的范围仍然相对狭窄。

幸运的是,Java 14 中引入的 JFR 流式 API 为可观察性用例以及深入探究提供了一个出色的构建块。然而,整个社区倾向于不采用 Java 的非 LTS 版本。这意味着,可能只有随着 Java 17(LTS 版本)的到来,我们才会看到支持 JFR 流式形式的 Java 版本得到广泛采用。

性能调优不是关于盯着你的代码祈祷获得启迪或应用现成的快速修复。相反,它关乎细致的测量、关注细节和耐心。它关乎在测试中持续减少错误来源,以便真正导致性能问题的根源显现出来。

在本章中,我们只能对丰富多样的主题进行简要介绍。还有更多内容值得探索,感兴趣的读者可以参考 Ben Evans、James Gough 和 Chris Newland 合著的《优化 Java》(O’Reilly Media,2018 年 5 月)等专门文本。

摘要

  • JVM 是一个极其强大且复杂的运行时环境。

  • JVM 的本质有时会使得优化其内部的代码变得具有挑战性。

  • 您必须进行测量才能准确了解问题真正所在的位置。

  • 特别注意垃圾回收子系统和即时编译器。

  • 监控和其他工具确实能提供帮助。

  • 学习阅读日志和其他平台指标——工具并不总是可用。

第三部分。JVM 上的非 Java 语言

这本书的这一部分全部关于在 JVM 上探索新的语言范式。JVM 是一个惊人的运行时环境:它不仅提供性能和力量,而且为程序员提供了令人惊讶的灵活性。事实上,JVM 是探索 Java 之外其他语言的门户,并允许你尝试不同的编程方法。

如果你只使用 Java 编程,你可能想知道学习不同语言会带来什么好处。正如我们在第一章所说,成为一名扎实的 Java 开发者的本质是不断掌握 Java 语言、平台和生态系统的各个方面。这包括对现在处于地平线但将在不久的将来成为景观重要部分的课题的欣赏。

未来已经到来——只是分布不均。

——威廉·吉布森

结果表明,未来将需要的许多新想法现在已经在其他 JVM 语言中存在,例如函数式编程。通过学习一种新的 JVM 语言,你可以窥见另一个世界——这个世界可能与你的一些未来项目相似。探索一个不熟悉的观点也可以帮助你以新的视角看待现有的知识。这为通过学习新语言发现你不知道的新才能和添加将证明有用的新技能的可能性打开了大门。

在第九章中,我们将探讨 Kotlin,这是一种相对较新的语言,它解决了 Java 的许多批评,而没有从根本上改变其基础。它旨在简洁和安全,同时解锁过去更多倾向于动态脚本语言的使用案例。

函数式编程近年来一直作为 Java 所表达的典型面向对象观点的替代品而受到关注。为了体验这个不同的世界,你将看到 Clojure,这是 JVM 上功能语言之一,它最远离 Java 思维模式。

第四部分和第五部分将经常回到这些语言,展示它们如何在你的项目中应用——从构建和测试,到并发和程序结构的更深层次问题。所以,让我们暂时离开 Java 的舒适区,看看我们可用的替代方案。

8 种替代 JVM 语言

本章涵盖

  • 语言动物学

  • 为什么你应该使用替代 JVM 语言

  • 替代语言的选取标准

  • JVM 如何处理替代语言

如果你曾经使用 Java 做过任何规模的工作,你可能已经注意到它有时会显得有点冗长和笨拙。你甚至可能发现自己希望事情有所不同——以某种方式变得更容易。

幸运的是,正如你在前几章中看到的,JVM 真的很棒——实际上,它为除了 Java 之外的其他编程语言提供了一个自然的家园。在本章中,我们将向你展示为什么以及如何可能想要将另一种 JVM 编程语言混合到你的项目中。

在本章中,我们将介绍描述不同语言类型(如静态类型与动态类型)的方法,为什么你可能想要使用替代语言,以及在选择它们时应该寻找哪些标准。你还将被介绍到本书剩余部分我们将更深入探讨的两种语言(Kotlin 和 Clojure)。

8.1 语言动物学

编程语言有多种不同的风味和分类。另一种说法是,广泛的编程风格和方法体现在不同的语言中。当你了解如何分类语言之间的差异时,掌握这些不同的风格通常更容易。

注意:这些分类有助于思考语言的多样性。这些划分中的一些比其他划分更为明确,但没有任何分类方案是完美的。不同的人对如何安排分类有不同的看法。

近年来,一种趋势是语言从可能性范围中添加功能。通常,将一种语言视为“比另一种语言功能更少”或“动态类型但在需要时具有可选静态类型”是有帮助的。

我们将涵盖的分类包括“解释型与编译型”、“动态与静态”、“命令式与函数式”,以及语言的重新实现与原始版本。一般来说,这些分类是思考空间的方法,而不是完整精确的学术方案。

例如,我们可以这样说,Java 是一种运行时编译的、静态类型的、命令式语言,并具有一些函数式特性。它强调安全性、代码清晰度、向后兼容性和性能,并且愿意接受一定程度的冗长和仪式感(例如在部署中)来实现这些目标。

注意:不同的语言可能有不同的优先级;例如,动态类型语言可能强调部署速度。

让我们从解释型与编译型分类开始。

8.1.1 解释型语言与编译型语言

解释型语言是一种在执行源代码的每一步时直接执行,而不是在执行开始之前将整个程序转换为机器码的语言。这与编译型语言形成对比,编译型语言使用编译器将人类可读的源代码转换为二进制形式作为初始任务。

这种区别最近变得不那么明显了。在 20 世纪 90 年代初,这种划分相当清晰:C/C++、FORTRAN 及其朋友是编译型语言,而 Perl 和 Python 是解释型语言。但正如我们在第一章中提到的,Java 具有编译型和解释型语言的特点。字节码的使用进一步模糊了这个问题。字节码当然不是人类可读的,但也不是机器码。

在本书的这一部分,我们将研究 JVM 语言,我们将区分语言是否从源代码生成类文件并执行它。在后一种情况下,使用解释器(可能用 Java 编写)逐行执行源代码。一些语言提供编译器和解释器,而一些语言提供解释器和即时(JIT)编译器,该编译器将生成 JVM 字节码。

8.1.2 动态类型与静态类型

在动态类型语言中,一个变量可以在程序执行的不同时间包含不同的类型。作为一个例子,让我们看看一个在知名动态语言 JavaScript 中的简单代码片段。希望这个例子即使你不详细了解该语言也能理解:

var answer = 40;
answer = answer + 2;
answer = "What is the answer? " + answer;

这里使用的var关键字创建了一个新变量。在 JavaScript 的动态类型系统中,这个变量可以包含任何类型的值。这个变量最初被设置为40,当然是一个数值。然后我们向它添加2,得到42。然后我们稍微改变一下方向,让answer持有字符串值。这在动态语言中是一种常见的技术,并且不会引起语法错误。

JavaScript 解释器也能够区分+操作符的两种用法。+的第一个用法是数值加法——将2加到40上——而在下一行中,解释器根据上下文推断出开发者意图进行字符串连接。

让我们在 Java 中使用 JShell 再次尝试这个技巧:

jshell> var answer = 40;
answer ==> 40

jshell> answer = answer + 2;
answer ==> 42

jshell> answer = "What is the answer? " + answer;
|  Error:
|  incompatible types: java.lang.String cannot be converted to int
|  answer = "What is the answer? " + answer;
|           ^-----------------------------^

哗啦。尽管在两种语言中,完全相同的源代码看起来都是合法的,但 Java 的静态类型系统阻止了最后一行代码的执行。Java 的var关键字不仅仅创建了一个名为answer的变量。正如我们在 1.3 节中学到的,Java 的var还会从表达式的右侧推断出这个新变量的类型。我们不必显式指定answer的类型,但 Java 的静态类型系统会分配一个永远不会改变的类型。

注意:这里的关键点是,动态类型跟踪变量包含的(例如,数字或字符串)的类型信息,而静态类型跟踪的是变量定义的类型信息。

静态类型对于编译型语言来说是一个很好的匹配,因为类型信息全部关于变量,而不是变量中的值。这允许在代码甚至有机会运行之前,在编译时对潜在的类型系统违规进行推理。

动态类型语言在变量持有的值上携带类型信息。这提供了很多灵活性,但意味着类型违规(例如,“我以为这是一个数字,但它是一个字符串”)会在执行期间发生。这可能导致更多的运行时错误,这些错误可能比编译时错误更难调试且成本更高。

8.1.3 命令式与函数式语言

Java 是一种命令式语言的经典例子。命令式语言可以被视为将程序的运行状态建模为可变数据并发布一系列指令以转换该运行状态的语言。因此,程序状态是命令式语言中占据中心舞台的概念。

命令式语言存在两种主要子类型。过程式语言,如 BASIC 和 FORTRAN,将代码和数据视为完全分离的,并具有简单的代码操作数据范式。另一种子类型是面向对象(OO)语言,其中数据和代码(以方法的形式)捆绑在一起形成对象。面向对象系统中的程序状态是程序中所有对象的状态。在 OO 语言中,通过元数据(如类信息)在更大或更小的程度上强加了额外的结构。然而,这些子类型之间的差异可能并不总是清晰的。例如,C++明确旨在支持 OO 和过程式编码,而一些 BASIC 的后期版本也具有面向对象的功能。

函数式语言认为计算本身是最重要的概念。函数在值上操作,就像过程式语言一样,但与改变它们的输入不同,函数被视为像数学函数一样行动,并返回新的值。以新的和独特的方式组合不同的函数也是这种模型的基本内容。

如图 8.1 所示,函数被视为“小处理机”,它们接受值并输出新的值。它们没有自己的状态,将它们与任何外部状态捆绑在一起实际上并不合理。以对象为中心的世界观与函数式语言的自然观点有些不一致。

图 8.1 命令式与函数式语言

函数式语言的一个关键特性是一等函数——将函数视为值的能力,将其分配给变量,传递给其他函数,甚至从其他函数返回函数。

这是一个我们之前讨论过的功能谱系的绝佳例子,因为 Java 8 添加了 lambda 表达式语法,这使得 Java 程序员可以将函数视为值。然而,作为一个较新的特性,这个特性并没有在平台的所有可能位置使用,而且用于获得类似行为的旧技术,如 RunnableCallable 接口,仍然在使用中。

在接下来的两章中,我们将学习不同的语言,一个关键焦点将是它们如何支持函数式编程方法。使用 Kotlin,我们将看到即使是命令式语言也可以被设计成平滑地支持函数式思想。然后我们将探讨 Clojure,这是一种更加纯粹的函数式语言,它根本不再以面向对象为中心。

8.1.4 重新实现与原始版本

JVM 语言之间的另一个重要区别是,将它们分为现有语言的重新实现和专门为 JVM 编写的语言。一般来说,专门为 JVM 编写的语言在其类型系统和 JVM 的原生类型之间提供了更紧密的绑定。

以下三种语言是现有语言的 JVM 重新实现:

  • JRuby 是 Ruby 编程语言的 JVM 重新实现。Ruby 是一种动态类型面向对象语言,并具有一些函数式特性。它基本上是在 JVM 上进行解释,但最近版本已经包含了一个运行时 JIT 编译器,在有利条件下可以生成 JVM 字节码。

  • Jython 是由 Jim Hugunin 于 1997 年启动的,作为一种使用高性能 Java 库的方式。它是在 JVM 上对 Python 的重新实现,因此它是一种动态的、主要是面向对象的语言。它通过生成内部 Python 字节码,然后将其转换为 JVM 字节码来运行。遗憾的是,自 2015 年以来,该项目活动很少,并且只支持 Python 2.7,不支持当前的 Python 3。

  • Rhino 最初由 Netscape 开发,后来由 Mozilla 项目继续开发。它提供了 JVM 上的 JavaScript 实现,并一直随着 JDK 一起发布。

    • JDK 8 包含了一个新的 JavaScript 引擎,Nashorn(“Nashorn”这个名字有点双关——它是德语中“犀牛”的意思),但随着 JavaScript 语言变化的加速,它被迫在 JDK 11 中弃用,并在 JDK 15 中移除。尽管未来的 JDK 不会直接提供 JavaScript 实现,但它们仍然可以独立找到。(Mozilla 的 Rhino (github.com/mozilla/rhino)和 Nashorn (openjdk.java.net/projects/nashorn/) 作为独立的 OpenJDK 项目,它打算继续存在,并且应该在未来的 JDK 中得到支持。

注意:最早的 JVM 语言?最早的非 Java JVM 语言难以确定。当然,Kawa(Lisp 的一个实现)可以追溯到 1997 年左右。从那时起,我们看到了语言的爆炸性增长,以至于几乎不可能跟踪它们。

在撰写本文时,一个合理的猜测是至少有 200 种语言针对 JVM。并非所有都可以被认为是活跃的或广泛使用的(有些实际上非常细分),但大量表明 JVM 是一个非常活跃的语言开发和实现平台。

注意:在 Java 7 发布时首次亮相的语言和 VM 规范版本中,所有直接引用 Java 语言的条目都已从 VM 规范中删除。Java 现在只是许多在 JVM 上运行的许多语言之一——它不再享有特权地位。

使许多不同语言能够针对 JVM 的关键技术是类文件格式,正如我们在第四章中讨论的那样。任何可以生成类文件的语言都被认为是 JVM 上的编译语言。

让我们继续讨论多语言编程如何成为 Java 程序员感兴趣的一个领域。我们将解释基本概念以及为什么以及如何为您的项目选择替代 JVM 语言。

8.2 JVM 上的多语言编程

“在 JVM 上使用多语言编程”这个短语被用来描述使用一个或多个非 Java JVM 语言与 Java 代码核心一起使用的项目。思考多语言编程的一种常见方式是作为一种关注点分离的形式。如图 8.2 所示,可能存在三个层次,其中非 Java 技术可以发挥有用的作用。这个图表有时被称为多语言编程金字塔,它最初归功于 Ola Bini 的工作(olabini.com/blog/tag/polyglot/))。

图片

图 8.2 多语言编程金字塔

在金字塔中,依赖关系只有一个方向——稳定层相对独立,动态层使用稳定层,领域特定代码可以从其下两个层中拉取。

在给定的系统中定义这些层并不总是容易的;存在灰色区域,并非所有系统都完美匹配。然而,这是一个有用的工具,可以识别不同部分系统有不同的需求,并可能从不同的语言中受益的接缝。

稳定层包含您系统的核心 API 和抽象。类型安全、彻底的测试和性能都是至关重要的。

动态层使用稳定层的抽象来创建一个工作系统。这可能包括如何一个系统通过 HTTP 公开自己或与其他后端系统交互的代码。编译时间和灵活性等问题可能使考虑在动态层使用不同的语言变得值得。

领域特定层处理特定于应用程序的关注点,例如展示、规则定制和处理的定制,或 CI/CD。这段代码完全关于应用程序领域的特定方面,可能从其他层中约束性的语言选择中受益。

注意:多语言编程是有意义的,因为不同的代码片段有不同的生命周期。银行中的风险计算引擎可能持续五年或更长时间。网站上的 JSP 页面可能持续几个月。初创公司中最短命的代码可能只存活几天。代码存活的时间越长,就越接近金字塔的稳定层。参见表 8.1。

表 8.1 多语言编程金字塔的三个层级

名称 描述 示例
领域特定 领域特定语言。紧密耦合到应用程序的特定部分 Apache Camel, DSLs, Drools, 网页模板
动态 功能的快速、高效、灵活的开发 Clojure, Groovy, JRuby
稳定 核心功能,稳定,经过充分测试,性能良好 Java, Kotlin, Scala

正如你所见,模式出现在层级中——静态类型语言倾向于向稳定层任务靠拢。相反,针对更具体目的的技术往往非常适合金字塔顶部的角色。

让我们深入探讨一下为什么 Java 不是金字塔中所有事物的最佳选择。我们将从讨论为什么你应该考虑使用非 Java 语言开始,然后我们将讨论在选择非 Java 语言时需要考虑的一些主要标准。

8.2.1 为什么使用非 Java 语言?

Java 作为通用、静态类型、编译型语言的本质提供了许多优势。这些品质使其成为在稳定层实现功能的一个很好的选择。但正如这里所描述的,这些相同的属性在金字塔的上层成为负担:

  • 重新编译是费时的。

  • 静态类型可能不够灵活。

  • 部署是一个重量级的过程。

  • Java 的语法可能很严格,并且不适合产生领域特定语言(DSL)。

Java 项目的重新编译和重建时间通常达到 90 秒到两分钟。这已经足够长,足以严重破坏开发者的工作流程,而且对于可能只在生产环境中存活几周的项目来说,这不是一个好的选择。

Java 的严格语法

Java 语言具有非常严格的语法。基本语言组件是提供的关键字。你不能“创造新的语法”或创建任何可能被误认为是关键字的新的形式。

程序员可以创建新的类,这些类的功能包括在字段中存储状态以及在类或对象上调用方法。然而,这仅限于此——程序员不能创建任何类似控制结构的任何东西。换句话说,字段访问将始终看起来像以下这样:

anObject.someField
AClass.someStaticField

方法调用将始终看起来像这样:

anObject.someMethod(params)
AClass.someStaticMethod(params)

在 Java 中,方法参数从不可选(与某些其他语言不同,如 Kotlin),因此即使在字段访问和方法调用之间也不能模糊界限。例如,我们不能创建看起来像关键字的结构。例如,我们希望能够创建一个类似于以下的when

when(value) {
  // action to be taken
}

但我们能做到的最好的事情可能就像这样:

import static when.When.when;

...

when(value, () -> {
  // action to be taken
});

这种不可重定义的语法在尝试使用 Java 创建领域特定语言(DSL)时也会显现出来。我们将在下一章中看到我们的非 Java 语言如何处理这个问题。

总体而言,一个实用的解决方案是发挥 Java 的优势。利用其丰富的 API 和库支持,在稳定层中为应用程序做大量工作。

即使在稳定层内,你也可能发现除了 Java 之外的其他语言可能更受欢迎,例如以下情况:

  • Java 的冗长可能会让一些开发者望而却步,它也可能隐藏某些类别的错误。

  • 尽管 Java 越来越多地支持函数式编程,但应用某些模式仍然存在限制。

  • 其他语言提供了 Java 中不存在的并发解决方案(Kotlin 中的协程,Clojure 中的代理)。

注意:即使你选择另一种语言来支持你的稳定层中的功能,也不应该为了使语言匹配而丢弃现有的代码。考虑使用新语言来添加新功能或低风险区域,我们将在本章后面讨论如何识别这些区域。

在这个阶段,你可能正在自问,哪些类型的编程挑战适合这些层级?我应该选择哪种语言?一个经验丰富的 Java 开发者知道没有一劳永逸的解决方案,但我们确实有一些标准来评估你的选择。

8.2.2 新兴语言

在本书的其余部分,我们选择了两种我们认为具有巨大潜力和影响力的语言。这两种语言都是运行在 JVM 上的(Kotlin 和 Clojure),在多语言程序员中已经建立了良好的认知度。为什么这些语言会受到青睐?让我们逐一分析。

Kotlin

Kotlin 是由 JetBrains(IntelliJ IDEA 的制作者)开发的一种命令式、静态类型、面向对象的编程语言。它的目标是解决 Java 最常见的问题,同时保持熟悉的开发生态。Kotlin 是一种编译型语言,它提供了比仅运行在 JVM 上更高级别的兼容性。

Kotlin 的关键特性包括简洁的语法、null安全性、与 Java 代码的极强互操作性,以及协程——一种 Java 传统线程模型的替代并发机制。Kotlin 的一些特性已经在最近的 Java 版本中得到了应用,这证实了这些变化对开发者带来的价值。

虽然它在多个领域确立了自身作为关键 JVM 语言选项的地位,但 Kotlin 在移动领域取得了特别的成功,2019 年,Android 平台将其作为推荐语言采用。Kotlin 也支持与 Groovy 相同的级别用于 Gradle 构建脚本。它还被许多其他框架所接受,例如 Spring。无论您的 JVM 运行在何处,开发者广泛的安全性和便利性改进都值得考虑。第九章介绍了 Kotlin。

Kotlin 将在第十一章中作为 Gradle 构建的主要脚本语言使用。我们还将重新审视它,以展示第十五章中函数式编程的独特方法以及第十六章中的并发编程(协程)。

Clojure

Clojure,由 Rich Hickey 设计,是一种 Lisp 家族的语言。它继承了该遗产的许多语法特性(以及大量的括号)。它是一种动态类型、函数式语言,这与 Lisp 的常规做法一致。它是一种编译型语言,但通常以源代码形式分发,原因我们稍后会看到。它还为其 Lisp 核心添加了大量的新特性(特别是在并发领域)。

Lisp 通常被视为专家语言。Clojure 比其他 Lisp 更容易学习,但仍为开发者提供了强大的功能(并且非常适合测试驱动开发风格)。但它可能仍然处于主流之外,主要用于爱好者以及一些专业工作(例如,一些金融应用发现其功能组合非常吸引人)。

Clojure 最好将其视为位于动态层,但由于其并发支持和其他特性,它也可以被视为能够执行稳定层语言的大部分角色。第十章介绍了 Clojure。

我们将在第十五章中广泛使用 Clojure 来学习关于函数式编程的知识,这些知识超出了 Java 的能力。它还将出现在第十六章中介绍演员模型,这是并发编程中的一个强大替代方案。

8.2.3 我们本可以选择但未选择的语言

如前所述,存在大量我们可以考虑的语言。以下是一些其他可能的竞争者,您可能需要更深入地研究。

Groovy

Groovy 语言是由 James Strachan 于 2003 年发明的。它是一种动态、编译型语言,语法与 Java 非常相似,但更灵活。它被广泛用于脚本和测试。它是 Gradle 构建工具的原始语言,也用于配置 Jenkins,这是一种极其常见的 CI/CD 工具。它通常是开发人员或团队在 JVM 上调查的第一种非 Java 语言。Groovy 可以被视为位于动态层,并且也因其非常适合构建领域特定语言(DSLs)而闻名。

我们选择不更详细地介绍 Groovy,因为在框架和其他语言的改进面前,它在原型设计和应用程序使用案例中的市场份额正在下降。

Scala

Scala 是一种支持许多函数式编程方面的面向对象语言。它的起源可以追溯到 2003 年,当时马丁·奥德斯基在学术环境中开始研究它,这是在他之前与 Java 泛型相关项目之后。它是一种类似于 Java 的静态类型、编译型语言,并且执行大量的类型推断,因此它通常给人一种动态语言的感觉。

Scala 从 Java 中学到了很多,其语言设计“修复”了 Java 中一些常见的烦恼。然而,Scala 最终拥有一个非常大的功能集,与 Java 相比,它有一个更先进的类型系统。

编程可能很复杂,而且不容易彻底学习。因此,我们选择专注于 Kotlin,为那些只想改进 Java 语言状态的开发者。

GraalVM

Oracle Labs 开发了 GraalVM (www.graalvm.org/),他们将其描述为部分源自 Java 和 JVM 代码库的多语言虚拟机和平台。当前版本包括运行 Java 和其他 JVM 语言(作为字节码)以及支持 JavaScript 和 LLVM 位码(来自流行的 LLVM 编译器的中间表示),并提供 Ruby、Python、R 和 WASM 的测试版支持。

整个平台包括以下组件:

  • Java HotSpot 虚拟机

  • Node.js JavaScript 运行时环境

  • LLVM 运行时用于执行 LLVM 位码

  • Graal——用 Java 编写的 JIT 编译器

  • Truffle——构建语言解释器的工具包和 API

  • SubstrateVM——原生图像的轻量级执行容器

在 GraalVM 项目中,语言之间可以非常自由地相互桥接,目标是允许使用不同技术实现的组件在单个应用程序过程中组合和使用。这是一种与多语言编程非常不同的方法,但它足够接近我们感兴趣的主题,所以我们至少想提到它。

非 JVM 语言

本章重点介绍在 JVM 上运行的替代语言。然而,值得承认的是,有时多语言程序员可能有一个理由,使得他们的系统的一部分需要完全离开 JVM。

以下是一些在 JVM 之外有更广泛支持的技术的例子:

  • 原生系统代码(C、Go 或 Rust)

  • 机器学习(Python)

  • 在用户的网络浏览器中运行(JavaScript)

虽然对于这些中的许多都有基于 JVM 的方法,但在尝试将每一行代码完全保留在 JVM 上之前,评估替代方案的成熟度和我们团队的构成是值得的。

现在我们已经概述了一些可能的选择,让我们讨论应该驱动您选择哪种语言的决策问题。

8.3 如何为您的项目选择非 Java 语言

一旦您决定在项目中尝试使用非 Java 语言,您需要确定哪些项目部分自然适合稳定、动态或特定领域的层。表 8.2 突出了可能适合每一层的任务。

表 8.2 适用于特定领域、动态和稳定层的项目领域

名称 示例问题领域
特定领域特定领域通常受益于专家的可读性,这些专家可能不知道 Java。软件生命周期工具也经常有特定领域的语言和配置。 构建、持续集成、持续部署 Dev-ops 业务规则建模
动态动态层的系统可能从其他语言中可用的更大灵活性和开发速度中受益。这尤其适用于内部面向的工具(测试和管理)。 快速 Web 开发原型交互式管理和用户控制台脚本测试
稳定稳定层的代码表达了系统的核心抽象。更严格的数据类型安全和测试值得额外的开发者开销。 并发代码应用容器核心业务功能

如您所见,存在广泛的使用替代语言用例。但确定可以用替代语言解决的问题只是开始。接下来,您需要评估使用替代语言是否合适。以下是我们考虑技术堆栈时考虑的一些有用标准:

  • 项目领域是否存在低风险?

  • 该语言与 Java 的互操作性如何?

  • 该语言有哪些工具支持(例如,IDE 支持)?

  • 学习这门语言的曲线陡峭吗?

  • 招聘具有该语言经验的开发人员有多容易?

让我们深入探讨这些领域,以便您了解需要提出哪些类型的问题。

8.3.1 项目领域是否存在低风险?

假设您有一个核心支付处理规则引擎,每天处理数百万笔交易。这是一块稳定的 Java 软件,已经存在超过七年,但测试不多,代码中存在许多暗角。支付处理引擎的核心显然是一个高风险区域,引入新语言,尤其是在它运行成功且缺乏测试覆盖和完全理解它的开发者时。

但是,一个系统不仅仅是其核心处理。例如,这是一个更好的测试会明显有帮助的情况。Kotlin 有许多好的选择,包括 Spek 框架(www.spekframework.org/)和 Kotest (kotest.io),它们利用语言来启用清晰、可读的规范,而不需要典型的 JUnit 烂模板。或者,也许你的规则引擎会从属性测试中受益,其中测试是编写来验证生成的输入条件,Clojure 的 test.check (clojure.org/guides/test_check_beginner) 将是混合中的宝贵工具。

或者,假设你需要构建一个网络控制台,以便操作用户可以管理支付处理系统背后的某些非关键静态数据。开发团队成员已经熟悉 Struts 和 JSF,但对这两种技术都没有任何热情。这是尝试新语言和技术栈的另一个低风险领域。Spring Boot 与 Kotlin 将是一个明显的选择。

通过在一个低风险领域进行有限的试点,如果新技术栈不适合,总有终止项目并转移到不同交付技术的选项,而不会造成太大的干扰。

8.3.2 语言与 Java 的交互操作如何?

你不希望丢失你已经编写的所有优秀 Java 代码的价值!这是组织犹豫在技术堆栈中引入新编程语言的主要原因之一。但是,使用在 JVM 上运行的替代语言,你可以扭转这一局面,使其成为最大化代码库中现有价值,而不是丢弃有效代码。

在 JVM 上运行的替代语言能够与 Java 清晰地交互操作,并且当然可以部署在现有的环境中。这对于避免影响部署的所有者来说尤为重要,无论是生产管理团队还是你团队中的 DevOps 人员。通过将非 Java JVM 语言作为你系统的一部分,你保留了组织的运营专业知识,这有助于减轻担忧并降低支持新解决方案的风险。

注意 DSL 通常使用动态(或在某些情况下,稳定的)层语言构建,因此许多 DSL 都是通过它们构建的语言在 JVM 上运行的。

一些语言与 Java 的交互比其他语言更容易。我们发现,大多数流行的 JVM 替代语言(如 Kotlin、Clojure、JRuby、Groovy 和 Scala)都与 Java 有良好的互操作性(对于某些语言,集成非常好,几乎无缝)。如果你是一个非常谨慎的商店,运行一些实验很快很容易,并确保你理解集成如何为你工作。

以 Kotlin 为例。你可以通过熟悉的导入语句直接将其代码中的 Java 包导入。从这里,你可以轻松地编写一个小型的 Kotlin 脚本,甚至可以使用交互式的 Kotlin shell 来检查你的 Java 模型对象,看看交互表面会是什么样子。我们将在接下来的语言章节中具体讨论 Java 互操作性。

8.3.3 这种语言有良好的工具和测试支持吗?

大多数开发者低估了他们在熟悉环境后节省的时间量。他们强大的 IDE 和构建及测试工具帮助他们快速生产高质量的软件。Java 开发者已经从多年的优秀工具支持中受益,因此记住其他语言可能并不处于完全相同的成熟水平是很重要的。

一些语言(如 Kotlin)长期以来一直支持 IDE 进行编译、测试和部署最终结果。其他语言可能具有尚未完全成熟的工具。

相关问题是,当一种替代语言为其自身开发了一个强大的工具(如 Clojure 的出色的 Leiningen 构建工具)时,这个工具可能并不适合处理其他语言。因此,团队需要仔细思考如何划分项目,尤其是在部署相互关联但独立的组件时。

8.3.4 学习这种语言有多难?

学习一门新语言总是需要时间,而且如果这种语言的范式不是你的开发团队所熟悉的,那么所需的时间还会增加。大多数 Java 开发团队如果新语言是面向对象且具有类似 C 语言的语法(例如 Kotlin),那么他们会比较容易掌握这门新语言。

当 Java 开发者逐渐远离这种范式时,事情会变得更加困难。在流行的替代语言中,像 Clojure 这样的语言可以带来极其强大的好处,但它也可能要求开发团队在学习和理解 Clojure 的函数式特性和 Lisp 语法时进行重大的再培训。

一种替代方案是查看那些是现有语言重新实现的 JVM 语言。Ruby 和 Python 是成熟的编程语言,为开发者提供了大量的资料来学习。这些语言的 JVM 实现可以为你的团队提供一个甜点,让他们开始使用易于学习的非 Java 语言。

8.3.5 有很多开发者使用这种语言吗?

组织必须务实;他们不能总是雇佣最顶尖的 2%(尽管他们的广告可能这么说),并且他们的开发团队在一年中会发生变化。有些语言,如 Kotlin 或 Scala,已经足够成熟,以至于有一个可供雇佣的开发者池。但像 Clojure 这样的语言可能会带来更多困难。管理者可能会反对使用非同寻常的东西,担心会创建一个难以维护的代码库,他们将为雇佣而感到困难。

注意:关于重新实现的语言的警告:例如,许多用 Ruby 编写的现有包和应用程序仅针对基于 C 的原生实现进行测试。当尝试在 JVM 上使用它们时可能会出现问题。在做出平台决策时,如果你计划利用用重新实现的语言编写的整个堆栈,你应该考虑额外的测试时间。

再次强调,重新实现的语言(如 JRuby、Jython 等)可能在这里有所帮助。可能很少有开发者的简历上有 JRuby,但由于它只是运行在 JVM 上的 Ruby,实际上有一个庞大的开发者池可供雇佣——一个熟悉 C 版本的 Ruby 开发者可以很容易地学习在 JVM 上运行引起的差异。

现在我们有一系列问题要问,当选择替代语言时,以及一些可用选项的概述。在这个时候,深入了解 JVM 如何支持多种语言是值得的。这次窥视揭示了某些设计选择和 JVM 上替代语言的限制的根源。

8.4 JVM 如何支持替代语言

一种语言可以在 JVM 上以两种可能的方式运行:

  • 拥有一个生成类文件的源代码编译器。Kotlin 和 Clojure 都以这种方式运行。

  • 拥有一个用 JVM 字节码实现的解释器。JRuby 就是这样实现的。

在这两种情况下,通常会有一个运行时环境,它为执行程序提供特定语言的支持。图 8.3 显示了 Java 和典型非 Java 语言的运行时环境堆栈。

图片

图 8.3 非 Java 语言运行时支持

这些运行时支持系统在复杂性上有所不同,这取决于特定非 Java 语言在运行时需要多少手动操作。在几乎所有情况下,运行时都将实现为一组 JAR 文件或模块,执行程序需要在它的类路径上拥有这些文件。在解释器的情况下,解释器将在程序执行开始时启动引导,然后读取要执行的源文件。

8.4.1 性能

开发者经常对不同的语言提出一个问题,那就是它们相对于彼此的表现如何?虽然表面上看起来很有吸引力,但这个问题并不简单回答,实际上并不那么有意义。

正如我们在第七章中看到的,经验丰富的开发者知道性能是由测量驱动的。测量是在单个程序上进行的,而不是在编程语言的抽象概念上。将任何声称语言 X“性能优于”Y 的主张视为可疑,除非有伴随的可靠数据。

然而,在实践中,JVM 语言的某些整体性能特征可以通过语言实现的方式大致确定。编译型语言在运行时只是字节码,将以与 Java 相同的方式进行即时编译。解释型语言将具有非常不同的性能行为,因为要即时编译的代码是解释器,而不是程序本身。

注意:一些语言(例如,JRuby)采用混合策略——它们为脚本提供解释器,但也可以动态地将单个源方法编译成 JVM 字节码,然后由 JVM 的即时编译器将其编译成机器代码。

在这本书中,我们的重点是编译型语言。为了完整性,提到了像 Rhino 这样的解释型语言,但不会过多地涉及它们。因此,我们预计我们考虑的语言之间的性能将大致相似。要获得更详细的答案,你应该对特定的程序或工作负载进行详细分析。

在本节的其余部分,我们将讨论对替代语言(即使是编译型语言)的运行时支持的需求,然后讨论编译器虚构功能——由编译器合成的特定语言功能,这些功能可能不会出现在低级字节码中。

8.4.2 非 Java 语言的运行时环境

一种简单测量特定语言所需的运行时环境复杂性的方法是通过查看提供运行时实现的 JAR 文件的大小。使用这个指标,我们可以看到 Clojure 是一个相对轻量级的运行时环境,而 JRuby 是一个需要更多支持的编程语言。

这并不是一个完全公平的测试,因为一些语言的标准化库和附加功能比其他语言的标准化分布要大得多。然而,这可以是一个有用的(如果粗略的)经验法则。

通常,运行时环境的目的在于帮助非 Java 语言中的类型系统和其他方面实现所需的语义。替代语言并不总是与 Java 对基本编程概念有完全相同的看法。

例如,Java 对面向对象的方法并不被其他语言普遍接受。在 Java 中,所有特定类的实例都具有完全相同的方法集,并且这个集合在编译时是固定的。另一方面,在 Ruby 中,一个单独的对象实例可以在运行时附加额外的、在定义类时未知且不一定在其他相同类的实例上定义的方法。

注意:invokedynamic 字节码最初被添加到 JVM 中,是为了方便高效地实现这类语言特性。

这种动态添加方法的能力(有些令人困惑地称为“开放类”)需要由 JRuby 实现来复制。这只有在 JRuby 运行时提供一些高级支持的情况下才可能实现。

8.4.3 编译器虚构

某些语言特性是由编程环境和高级语言合成的,并不存在于底层的 JVM 实现中。这些被称为 编译器虚构

注意:了解这些特性是如何实现的会有所帮助;否则,你可能会发现代码运行缓慢,在某些情况下甚至会导致进程崩溃。有时环境需要做大量工作来合成特定的特性。

Java 中的其他例子包括检查型异常和内部类(如果需要,总是转换为顶级类,并带有特别合成的访问方法,如图 8.4 所示)。如果你曾经使用 jar tvf 命令查看过 JAR 文件内部,并看到很多名字中带有 $ 的类,这些就是已展开并转换为“常规”类的内部类。

图 8.4 内部类作为编译器虚构

其他语言也有编译器虚构的概念。在某些情况下,这些编译器虚构甚至构成了语言功能的核心部分。

在第 8.2 节中,我们介绍了函数式编程中的关键概念 一等函数——即函数应该是可以放入变量的值。本书第三部分的所有非 Java 语言在 Java 添加 lambda 表达式之前就支持了这个特性。当 JVM 只处理类作为代码和功能的最小单元时,它们是如何实现这一点的呢?

解决源代码和 JVM 字节码之间差异的原始方案是记住对象只是数据包,以及操作这些数据的方法。想象一个没有任何状态且只有一个方法的对象——例如,Java 的 Callable 的简单匿名实现。将这样的对象放入变量中,传递它,然后稍后调用它的 call() 方法,这在任何情况下都并不罕见:

Callable<String> myFn = new Callable<String>() {
    @Override
    public String call() {
        return "The result";
    }
};

System.out.println(myFn.call());

注意:在这个例子中,myFn 变量是一个匿名类型,因此编译后它将显示为类似于 NameOfEnclosingClass$1.class 的形式。类编号从 1 开始,对于编译器遇到的每个匿名类型都会递增。如果它们是动态创建的,并且数量很多(如 JRuby 等语言中有时发生的情况),这可能会对存储类定义的堆外内存造成压力。

Java 的 lambda 表达式实际上并不使用这种匿名类型的方法,而是建立在 JVM 的一种通用特性 invokedynamic 之上,我们将在第十七章中详细讨论。其他语言也在从它们的专用实现转向使用 invokedynamic。这是一个编译器虚构影响平台现实发展的有趣案例。

另一个例子,在下一章中,我们将遇到 Kotlin 的 数据类——这是一种语言特性,有助于减少声明“只是一堆字段”的类时所需的输入和 仪式。在今天的 Kotlin 中,这是一个编译器虚构,但 Java 17 添加了一个名为 records 的特性,这可能会最终为 Kotlin 提供构建数据类的基础。

摘要

  • 在 JVM 上的替代语言已经走得很远,为某些问题提供了比 Java 更好的解决方案。

  • 语言可以根据不同的方式分类(静态与动态、命令式与函数式、编译与解释),这有助于选择适合特定任务的正确语言。

  • 多语言编程通常分为三个层次:稳定层、动态层和领域特定层。Java 和 Kotlin 适用于软件开发中的稳定层。Clojure 可能更适合动态层或领域特定层的工作。

  • 现有生产应用程序的核心业务功能几乎永远不会是引入新语言的正确地方。为您的替代语言首次部署选择一个低风险区域。

  • 团队和项目具有独特的特征,这将影响语言选择。这里没有普遍正确的答案。

9 Kotlin

本章涵盖

  • 为什么使用 Kotlin?

  • 方便性和简洁性

  • 安全性

  • 并发

  • Java 互操作性

Kotlin 是由 JetBrains(jetbrains.com)创建的语言,JetBrains 是流行的 IntelliJ IDEA 的制造商。2011 年公开宣布,Kotlin 旨在填补他们在 Java 开发中感到的语言空白,而没有看到其他现有 JVM 语言中的摩擦。

Kotlin 在次年开源,并在 2016 年达到了 1.0 版本——由 JetBrains 提供保证的支持和维护水平。从那时起,它已成为 Android 平台的推荐语言,并在其他 JVM 编码圈子中积累了坚实的支持。2018 年宣布的 Kotlin 基金会,为语言带来了 JetBrains 和 Google 的长期支持。Kotlin 甚至超越了 JVM,以支持 JavaScript 和本地后端。

9.1 为什么使用 Kotlin?

作为替代语言,Kotlin 在 Java 之上提供了许多生活质量的改进,而不会彻底改变整个世界。它对便利性、安全性和良好互操作性的关注,为在现有 Java 项目中增量使用提供了很好的故事。在 IntelliJ IDEA 中,将文件从 Java 转换为 Kotlin 通常只需点击一下。

值得注意的是,一些最初仅在 Kotlin 中可用的功能已经回到了 Java 中。一个很好的例子是 Kotlin 脚本——Kotlin 可以直接运行源文件,通常带有kts扩展名,而无需开发者请求编译它。如果这听起来像我们在第一章中展示的 Java 11 的单文件功能,你并没有错!

在本章之后,我们将利用对 Kotlin 的新熟悉程度,在 11 章中将 Kotlin 作为构建 Gradle 项目的主要脚本语言。它也将在 15 章中再次被提及,届时我们将探讨 JVM 上的函数式编程,以及在 16 章中,Kotlin 内置的协程机制为 Java 中的经典多线程提供了一个令人信服的替代方案。让我们开始看看 Kotlin 能为我们提供什么。

9.1.1 安装

如果你使用 IntelliJ IDEA,Kotlin 通过插件提供。安装后,你就可以开始用 Kotlin 编写代码了,就像在 IDE 中支持的其他任何语言一样。

对于那些更倾向于裸机设置的人来说,Kotlin 还提供了一个命令行编译器(kotlinc)(见mng.bz/YGoa)和交互式外壳(kotlin)。

将 Kotlin 添加到现有项目需要更新您的构建脚本。第十一章将使您更熟悉这些系统,但到目前为止,您可以参考 Kotlin 的优秀文档,以 Maven(mng.bz/GEYJ)或 Gradle(mng.bz/z4vA)开始。Kotlin 准备好运行后,开始探索的好地方是了解其基本功能是如何工作和改进 Java 的。

9.2 方便性和简洁性

Java 以冗长著称。尽管它与 Java 保持了很多视觉上的相似性,但 Kotlin 不懈地简化了您必须编写的代码。

9.2.1 从更少开始

其简化性质的一个例子是简单的字符,分号。Kotlin 不需要分号来表示行结束,允许典型的换行符代替。分号是允许的——实际上,如果您想在单行上放置多个语句,例如,则必须使用分号——但大多数情况下并不需要。

Kotlin 利用其空白状态来改变其他在 Java 中难以更改的默认设置。尽管 Java 默认只导入 java.lang,但 Kotlin 在任何地方都提供了以下包:

  • java.lang.*

  • kotlin.*

  • kotlin.annotation.*

  • kotlin.collections.*

  • kotlin.comparisons.*

  • kotlin.io.*

  • kotlin.ranges.*

  • kotlin.sequences.*

  • kotlin.text.*

  • kotlin.jvm.*

几乎没有程序不需要集合、文本或 IO,因此这些包含节省了很多不必要的导入。

Java 的冗长性通常在类型上体现出来——我们在第一章中看到 var 关键字如何减少类型信息的重复。Kotlin 从一开始就采用了这种类型推断的风格,尽管这并不是唯一的思想来源。

9.2.2 变量

当引入变量时,Kotlin 甚至使用了与较新 Java 版本相同的关键字:var。它将从右侧的表达式推断出变量的类型,如下所示:

var i = 1          ❶
var s = "String"   ❷

❶ i 是 kotlin.Int 类型。

❷ s 是 kotlin.String 类型。

与 Java 不同的是,在 Kotlin 中 var 不仅仅是一个快捷方式。如果您想显式地指出变量的类型,var 仍然存在,但类型被添加到变量名称之后,如下面的代码片段所示。这有时被称为 类型提示

var i: Int = 1
var s: String = "String"
var n: Int = "error"        ❶

❶ 此赋值将因编译错误而失败,错误:类型不匹配:推断类型为 String 但期望 Int。

在 Kotlin 中,var 有一个伙伴,即 val 关键字。使用 val 声明的变量是不可变的,并且在赋值后不能被写入。这相当于 Java 中的 final var——对于任何不期望重新分配的变量来说,这是一个高度推荐默认设置。Kotlin 优雅地将这种更安全的设置与可变替代方案一样简洁,如下所示:

var i = 1
i = 2              ❶

val s = "String"
s = "boom"         ❷

❶ 允许对 var 变量进行重新赋值。

❷ 编译错误,错误:val 不能重新赋值。

在 Kotlin 中,varval 一致地用于变量和参数。默认倾向于不可变性的主题也是 Kotlin 的一个关键设计因素,我们将在整个语言中反复看到这一点。

一旦有了变量,自然就想比较它们。Kotlin 在相等性方面提供了一些新的帮助,这是值得了解的。

9.2.3 相等性

许多 Java 程序中常见的错误如下所示:

// Java
String s = retrieveString();   ❶
if (s == "A value") {
    // ...                     ❷
}

❶ 从某处接收一个字符串。注意,字符串字面量实际上可能被内部化到同一个对象中,这可能会在错误的比较中产生一种错误的安全感。

❷ 即使使用相同的值但不同的引用,也不会到达这里

我们很快就会了解到 Java 中的 == 比较的是 引用,而不是 ,所以这不会像许多其他语言那样做你所期望的事情。

Kotlin 消除了这个怪癖,并将 == 作为 String 等常见类型的值相等性处理。实际上,对 == 的调用相当于对 equals 的安全调用。这优化了更常见的编程情况,并避免了 Java 编程中一个巨大的错误原因,如下所示:

// Kotlin
var s: String = retrieveString()
if (s == "A value") {
    // ...                        ❶
}

❶ 如果 s 的值为“一个值”,则会到达这里

在罕见的情况下,你可能仍然有理由比较引用。当这些情况出现时,Kotlin 的 ===(及其配对 !==)提供了 Java 中 ==!= 的行为。比较变量很重要,但如果你不调用其他代码或定义自己的子程序,你不会走得很远。

9.2.4 函数

有时在说话时,我们会互换使用“函数”和“方法”这两个术语,但实际上,Java 只有方法——你无法在类的作用域之外定义可重用的代码块。尽管 Kotlin 支持所有 Java 的面向对象优点——我们将在下一节中看到——但它也认识到有时你可能只需要一个单独的函数。

遵循 Kotlin 的简洁原则,一个最小化的函数定义看起来像这样:

fun doTheThing() {    ❶
  println("Done!")
}

fun 在 Kotlin 中定义一个函数。当我们到达定义与类相关联的方法时,我们还会再次看到它。

这看起来与 Java 的做法略有不同,但仍然相当容易辨认。除了用于开始声明的 fun 关键字之外,最大的区别是缺少返回类型。在 Kotlin 中,如果你的函数不返回任何内容,而不是明确地声明 void,你可以说 nothing,返回类型将被视为 Unit(Kotlin 表示“没有返回值”的方式)。

如果我们确实想返回一个值,我们必须直接声明,如下所示:

fun doTheThing(): Boolean {   ❶
  println("Done!")
  return true                 ❷
}

❶ 声明我们的函数返回类型为 Boolean

❷ 返回我们的成功

函数如果没有参数就没有太大用处。Kotlin 用于此的语法与变量声明类似,如下所示:

fun doTheThing(value: Int): Boolean {   ❶
  println("Done $value!")               ❷
  return true
}

❶ 我们的功能现在接受一个类型为 Int 的单个参数。

❷ 参数在函数中像局部定义的变量一样出现。这里我们使用了 Kotlin 的便捷字符串插值。

即使是 Kotlin 中简单的函数定义也有一些技巧。有时函数参数的顺序可能不清楚,尤其是当参数类型相同时,如下例所示:

fun coordinates(x: Int, y: Int) {
  // ...
}

当我们调用这个函数时,我们必须记住参数的顺序——xy 之前——否则可能会出现错误。Kotlin 通过 命名参数 解决了这个问题,如下所示:

fun coordinates(x: Int, y: Int) {
  // ...
}

coordinates(10, 20)           ❶
coordinates(y = 20, x = 10)   ❷

❶ 对函数的正常位置调用

❷ 尽管重新排序,但这个调用仍然有相同的结果,因为我们命名了参数。

注意:在调用 Java 函数或相反,从 Java 调用 Kotlin 函数时,不能使用命名参数。参数的名称在字节码中不被保留,以允许这样做。此外,更改参数名称在 Kotlin 代码中可能被视为破坏性 API 变更,而在 Java 中,只有类型、参数数量或顺序的改变会引起问题。

有时没有必要将所有参数传递给函数——存在合理的默认值。在 Java 中,我们通过具有不同参数集的多重方法重载来适应这种情况。尽管 Kotlin 也支持这一点,但我们可以使用更直接的方法来设置默认值,如下所示:

fun callOtherServices(value: Int, retry: Boolean = false) {
  // ...
}

callOtherServices(10)   ❶

❶ 函数中的重试默认值为 false,这是由于默认值的原因。

我们不需要提供两个 callOtherServices 的定义——一个带有单个参数,另一个带有两个——我们可以将所有相关部分保留在一个函数中,而不需要样板代码。

Kotlin 提供了语法支持的单行函数是另一种常见情况,如下面的代码所示。这些函数提供了封装,并为特定的计算或检查提供了一个名称。Kotlin 通过为这样的短函数提供另一种声明方式,支持这一简洁趋势:

fun checkCondition(n: Int) = n == 42

这种格式不仅因为省略了花括号而更短,也因为类型推断。可以省略返回类型,Kotlin 会自动推断表达式的类型。

这一特性暗示了 Kotlin 设计的更深层部分,即其对 一等函数 的支持。Kotlin 中的函数可以作为参数传递,存储在变量和属性中,并从其他函数中返回。尽管 Kotlin 并不被视为函数式编程语言,但对一等函数的支持使 Kotlin 能够从函数式编程的许多常见模式中受益。

将函数分配给变量可以采取几种不同的形式,具体取决于函数的来源。如果我们之前已经声明了函数,我们可以使用 :: 操作符通过名称来引用它:

fun checkCondition(n: Int) = n == 42
val check = ::checkCondition

Kotlin 还具有 lambda 表达式 语法,可以即时创建匿名函数:

val anotherCheck = { n: Int -> n > 100 }

无论如何分配,函数引用可以像变量名一样调用函数本身。或者,如果这样更清晰,可以使用 invoke 函数,如下所示:

println(check(42))                 ❶
println(anotherCheck.invoke(42))   ❷

❶ 运行之前在 checkCondition 变量中声明的 fun 声明的检查条件,并打印 true

❷ 运行我们的 lambda 来查看我们是否大于 100 并打印 false

我们不仅限于将函数分配给局部变量。我们可以像传递任何其他值一样将它们作为参数传递给其他函数。这是具有一等函数的语言的关键特性之一。

正如我们之前看到的,Kotlin 要求声明参数的类型。函数也不例外,并且有特定的语法来表示函数的类型,如下所示:

fun callsAnother(funky: (Int) -> Unit) {        ❶
  funky(42)                                     ❷
}

callsAnother({ n: Int -> println("Got $n") })   ❸

callsAnother 函数接受一个参数,该参数是一个接受 Int 并不返回任何内容的函数。

❷ callsAnother 调用它接收到的函数。

❸ 我们可以调用 callsAnother,传递一个与函数类型匹配的 lambda。

函数类型由两部分组成,由->分隔——它的参数列表在()中,以及返回类型。参数类型的列表可以是空的,但返回类型不能省略。如果你传递的函数不返回任何内容,其类型必须指定为Unit

注意:当一个 lambda 只接受一个参数且类型可以推断时,可以使用标识符it,无需指定特定的名称,也不需要在 lambda 的开始处使用->

函数参数必须指定它们期望调用者传递的类型,但 Kotlin 通过将类型推断应用于 lambda 来为调用者节省一些打字。以下都是我们之前对callsAnother的调用允许的形式,类型指定越来越不明确:

callsAnother({ n: Int -> println("Got $n") })   ❶
callsAnother({ n -> println("Got $n") })        ❷
callsAnother({ println("Got $it") })            ❸

❶ 使用完全指定的 lambda 类型调用 callsAnother 的原始调用

❷ Kotlin 可以推断出 n 必须是 Int 类型,因为调用 another 需要的就是这个类型。

❸ 将单个参数传递给 lambda 的模式非常常见,以至于 Kotlin 提供了特殊的支持,即隐式的 it 参数。

Kotlin 在传递 lambda 作为参数时还有一个技巧。如果一个函数调用的最后一个参数是一个 lambda,那么这个 lambda 可以出现在括号之后。如果一个函数的唯一参数是一个 lambda,你甚至可以完全不用括号!以下三个调用都是相同的:

callsAnother({ println("Got $it") })
callsAnother() { println("Got $it") }
callsAnother { println("Got $it") }

我们将在第十五章深入探讨 Kotlin 的函数式编程的更多细节,但现在让我们看看 Kotlin 是如何利用所有这些函数式优点来改善 Java 中常见的痛点——集合。

9.2.5 集合

集合是程序中最常见的几种数据结构之一。Java 的标准集合库,从最早的版本开始,就提供了大量的功能和灵活性。但是,语言本身的限制和向后兼容性通常会导致代码更加冗长,仪式感更强,尤其是在与 Python 这样的脚本语言或 Haskell 这样的函数式语言相比时。最近的 Java 版本大大改善了这种情况——参见第一章对集合工厂的介绍和附录 B 中的流——但 Java 原始集合设计中的困难仍然存在。

自然地,Kotlin 从这些错误中吸取了教训,并且从第一天起就提供了无缝的集合体验。下面展示的标准函数,在 Kotlin 中一直存在,用于创建最常见的集合类型——这是一个在 Java 中直到版本 9 才出现的特性:

val readOnlyList = listOf("a", "b", "c")                  ❶
val mutableList  = mutableListOf("a", "b", "c")           ❶

val readOnlyMap = mapOf("a" to 1, "b" to 2, "c" to 3)     ❷
val mutMap = mutableMapOf("a" to 1, "b" to 2, "c" to 3)   ❷

val readOnlySet = setOf(0, 1, 2)                          ❸
val mutableMap  = mutableSetOf(1, 2, 3)                   ❸

❶ 使用推断的类型 kotlin.collections.List和 kotlin.collections.MutableList创建列表

❷ 使用推断的类型 kotlin.collections.Map<String, Int>和 kotlin.collections.MutableMap<String, Int>创建映射。注意使用 to 关键字定义映射的内置语法。

❸ 创建具有推断类型kotlin.collections.Set<String>kotlin.collections.MutableSet<String>的集合

默认函数返回其集合的只读副本——这对于性能和正确性来说是一个非常好的默认选择。你必须明确要求mutable版本才能得到一个具有修改接口的集合。Kotlin 再次旨在通过将代码推向不可变性作为更简单、更短的选择来保护你免受整个类别的错误。

你可能已经注意到这些集合的推断类型与标准 Java 对应项不同——尽管名称相似,但它们位于kotlin.collections包中。Kotlin 定义了自己的集合接口层次结构,如图 9.1 所示,但在底层,它重新使用了 JDK 的实现。这允许使用kotlin.collections接口提供更干净的 API,同时保留将我们的集合传递到 Java 代码中的能力,因为实现也支持java.util集合接口。

图 9.1 Kotlin 集合层次结构

这些集合参与所有标准的 Java 接口和模式。你可以使用for ... in来迭代它们,如下所示:

val l = listOf("a", "b", "c")

for (s in l) {
  println(s)
}

然而,迭代集合的for循环是语言中唯一的直接操作。许多其他关于集合的工作反复进行,Kotlin 的集合使用我们在上一节中看到的顶级函数具有大量功能。这些功能几乎总是返回一个新的集合,而不是修改它们被调用的集合。自 lambda 和流发布以来,你可能在 Java 中遇到了这种风格的集合代码,它们共享许多共同的想法。

经常我们会从一个集合中取一个值,并根据某些计算将每个元素转换成不同的值。map函数正是使用我们传递的函数来完成这个任务,如下所示:

val l = listOf("a", "b", "c")
val result = l.map { it.toUpperCase() }   ❶

❶ 结果包含“A”、“B”、“C”的列表。

另一个常见的操作是在进一步处理之前从集合中移除某些值。filter期望一个返回Boolean的 lambda 表达式。这个 lambda 表达式被称为谓词filter会重复调用谓词来决定在新的集合中返回哪些元素,如下所示:

val l = listOf("a", "b", "c")
val result = l.filter { it != "b" }   ❶

❶ 结果包含“a”、“c”的列表。

如果你只关心一个集合是否满足某些条件,但不需要元素,那么allanynone函数正是你所需要的。这些函数会避免复制数据,并在可能的情况下尽早返回(即对于all()在第一个false之后),如下所示:

val l = listOf("a", "b", "c")
val all  = l.all  { it.length == 1 }   ❶
val any  = l.any  { it.length == 2 }   ❷
val none = l.none { it == "a" }        ❸

❶ all == true

❷ any == false

❸ none == false

您可以使用associateWithassociateBy函数从列表构建映射。associateWith期望集合元素是结果映射中的键。associateBy假设集合元素是映射中的值。如果遇到这些函数中的任何一种重复项,则最后计算出的值获胜,如下一个代码片段所示:

val l = listOf("!", "-", "--", "---")

val resultWith = l.associateWith { it.length }   ❶
val resultBy   = l.associateBy   { it.length }   ❷

❶ resultWith 包含 mapOf(“!” to 1, “-” to 1, “--” to 2, “---” to 3)。

❷ resultBy 包含 mapOf(1 to “-”, 2 to “--”, 3 to “---”)。

这只是 Kotlin 中用于处理集合的丰富函数集的冰山一角。这些函数可以连在一起,以便对集合上的操作进行表达性、简洁的描述。文档非常优秀,提供了关于分组、排序、聚合和复制的其他主题的示例。请参阅kotlinlang.org/docs/collections-overview.html

Kotlin 在保持代码片段之间紧密流动方面的关注也体现在其他基本特性上。优先使用表达式而非语句是 Kotlin 使代码边缘更加平滑的另一种方式。

9.2.6 表达自己

在学习编程时,我们遇到的第一个结构就是if。在 Java 中,if是一个用于控制程序执行流程的语句。Kotlin 也用if来达到这个目的,但与只是执行的单个statement不同,if是一个返回值的expression,如下所示:

val iffy = if (checkCondition()) {   ❶
  "sure"                             ❷
} else {
  "nope"                             ❸
}

❶ iffy 变量将根据选择的分支接收一个值。

❷ 如果 checkCondition()为真,则“sure”将被赋值给 iffy。

❸ 如果 checkCondition()为假,则“nope”将被赋值给 iffy。

就像任何其他变量赋值一样,Kotlin 允许我们推断类型。在这种情况下,每个分支的最后一行在确定类型时都被考虑在内。

if表达式足够强大,以至于 Kotlin 实际上放弃了一个 Java 从 C 继承来的特性——三元condition ? "sure" : "nope"运算符。三元运算符可以缩短代码,但它也有一个压缩到失去可读性的名声。尽管它有更多的字符,但 Kotlin 的版本在许多情况下更易于阅读,如下一个代码示例所示,并且当逻辑进一步增长时,自然转换为多行if

val myTurn = if (condition) "sure" else "nope"

在第一章中,我们讨论了将switch 表达式引入 Java。这是 Kotlin 的设计先于 Java 类似增强的另一个例子。Kotlin 根本不使用传统的 C 样式switch语法,而是支持一个强大的替代方案,即使用关键字when,如下所示:

val w = when (x) {
  1 -> "one"         ❶
  2 -> "two"         ❷
  else -> "lots"     ❸
}

❶ 如果值 x 为 1,则将“one”赋值给 w。

❷ 如果值 x 为 2,则将“two”赋值给 w。

❸ 如果值 x 是任何其他值,则将“lots”赋值给 w。

when支持许多其他非常有用的形式。使用in关键字,您可以检查集合中的成员资格,如下所示:

val valid = listOf(1, 2, 3)
val invalid = listOf(4, 5, 6)

val w = when (x) {
  in valid   -> "valid"     ❶
  in invalid -> "invalid"   ❶
  else       -> "unknown"
}

❶ 检查 x 是否在每一个集合中——相当于调用 valid.contains(x) 和 invalid.contains(x)

Kotlin 还提供了对数值范围的语言级支持,这很好地与whenin一起使用,如下所示:

val w = when (x) {
  in 1..3 -> "valid"      ❶
  in 4..6 -> "invalid"    ❶
  else    -> "unknown"
}

.. 语法定义了一个包含范围,因此这段代码与先前的基于列表的示例等效。

值得注意的是,when的左侧条件可以是任何有效的表达式,只要类型与所需类型匹配即可。例如,可以使用函数调用,这是一个澄清复杂条件的不错技巧,如下所示:

fun theBest() = 1
fun okValues() = listOf(2, 3)

val message = when (incoming) {
  theBest()     -> "best!"        ❶
  in okValues() -> "ok!"          ❷
  else          -> "nope"
}

❶ 因为theBest的返回值被直接使用,所以它必须返回一个 Int 以与传入值进行比较。

❷ 因为okValues的返回值与in一起使用,所以它必须是一个集合。

关于when的最后一个要点,如果你还没有被它的超能力所说服,那就是安全性。我们所有的示例都提供了一个else情况。移除这些中的任何一个都会导致编译错误,抱怨我们没有处理所有的情况,如下所示:

error: 'when' expression must be exhaustive, add necessary 'else' branch

Kotlin 还有一个技巧可以用来替换 Java 中的语句结构——使用try catch进行错误处理,如下面的代码所示:

val message = try {
  dangerousCall()            ❶
  "fine"                     ❷
} catch (e: Exception) {
  "oops"                     ❸
}

❶ 可能会失败的函数调用

❷ 如果我们成功通过了危险调用,则message将被分配“fine”。

❸ 如果我们的危险调用抛出异常,则message将被分配“oops”。

这避免了在try catch之外声明变量这种尴尬的结构,所有路径内部都必须记住正确设置。这不仅更易于编写,而且更安全,因为编译器可以保证赋值是有效的。

虽然 Kotlin 和 Java 都采用了函数式编程的一些方面,但它们本质上仍然是面向对象的语言。接下来,我们将探讨在 Kotlin 中定义类和对象。

9.3 类和对象的另一种视角

Kotlin 的类提供了与 Java 非常相似的功能,从class关键字开始。但就像我们在其他地方看到的那样,代码是不同的,强调简洁和便利。

首先,Kotlin 不使用 new 关键字来创建类的实例。相反,它的语法更类似于使用类名调用函数,如下所示:

val person = Person()

Kotlin 并没有像 Java 那样真正有字段。相反,当我们声明类内的属性时,我们的朋友valvar就会出现。这些可以像 Java 字段一样内联初始化,如下所示:

import java.time.LocalDate

class Person {
  val birthdate = LocalDate.of(1996, 1, 23)     ❶
  var name = "Name Here"                        ❷
}

❶ 只读属性 birthdate

❷ 可变属性名

注意:正如我们在第四章中看到的,字段在 JVM 层级上是存在的,所以 Kotlin 的属性实际上在字节码中转换为字段访问。然而,在语言层面上,最好从属性的角度来思考。

Java 类中的主要样板代码来源是字段的获取器和设置器方法。Kotlin 通过默认提供属性访问器来解决此问题。更进一步,Kotlin 还允许我们像访问 Java 中的字段一样使用这些访问器方法,如下所示:

println("Hi ${person.name}. " +                    ❶
        "You were born on ${person.birthdate}")
person.name = "Somebody Else"                      ❷
// person.birthdate = LocalDate.of(2000, 1, 1)     ❸

❶ 在此处打印“Hi Name”。你出生于 1996-01-23。

❷ 变量属性也获得设置器,可以使用 = 使用。

❸ val 属性不能被设置。

在类设计中,状态数据的可见性是一个大问题,尤其是与 封装 相关。Kotlin 采取了一个稍微有争议的举措,将默认可见性设置为 public,这与 Java 的 包保护 默认设置不同。公开暴露所有属性并不被认为是良好的实践,但 Kotlin 的设计者发现,在实际代码中 public 需要被声明得更加频繁,因此将其作为默认设置具有重大简化效果。

Kotlin 支持以下四个可见级别,其中大多数与它们的 Java 对应项相匹配:

  • 私有—仅对当前类或文件中的顶级函数可见

  • 受保护—在类及其子类中可见

  • 内部—可见于您一起编译的代码集

  • 公共—对每个人可见

例如,如果我们想将 birthdate 设置为私有,它看起来会是这样:

class Person {
  private val birthdate = LocalDate.of(1996, 1, 23)
  var name = "Name Here"
}

由于 Kotlin 仅向程序员公开属性,而不是字段,因此它为 委托属性 开辟了可能性。当跟随 by 关键字时,属性可以提供其获取和设置行为的自定义实现。这种做法将在后续章节的许多高级技术中体现出来。

标准库中包含了一些有用的委托。例如,在调试时,想知道何时更改了值是很常见的。Delegates.observable 提供了这样的钩子,如下所示:

import kotlin.properties.Delegates

class Person {
  var name: String by Delegates.observable("Name Here") {
      prop, old, new -> println("Name changed from $old to $new")
  }
}

调用 Delegates.observable 时传递的值被视为属性的初始值。我们传递给 Delegates.observable 的 lambda 将在支持属性值更改后调用。一个属性本身的句柄,以及旧值和新值,将传递给 lambda,供我们操作。在这里,我们只是简单地打印出更改了什么。

与 Java 类似,Kotlin 支持为我们的类创建实例的构造函数,实际上,Kotlin 在构造方面有几个不同的形式。这些中的第一个是在类名顶部声明一个 主构造函数,如下所示。Kotlin 将此用作一个替代位置,您可以在此处指定您的属性:

class Person(
  val birthdate: LocalDate,                      ❶
  var name: String) {                            ❶
}

val person = Person(LocalDate.of(1996, 1, 23),   ❷
                    "Somebody Else")             ❷

❶ 在主构造函数中使用的 val 和 var 创建属性,因此我们不需要稍后声明它们。

❷ 由于我们没有提供默认值,参数必须在构造时传递。

如果您需要在构造函数上使用可见性修饰符或注解,您可以使用 constructor 关键字的较长语法。例如,如果我们想将我们的构造函数从世界中隐藏,我们可以这样做:

class Person private constructor(
  val birthdate: LocalDate,
  var name: String) {
}

如果我们想在所有对象构造期间运行其他逻辑,Kotlin 使用 init 关键字,如下所示:

class Person(
  val birthdate: LocalDate,
  var name: String) {

  init {
    if (birthdate.year < 2000) {    ❶
      println("So last century")
    }
  }
}

init 在我们从构造函数分配属性之后运行,这样我们就可以在代码中访问它们。

一个类可以有多个 init 块,并且它们按照在类中定义的顺序执行,如下所示。在类体中定义的属性只有在定义之后才能被 init 块访问:

class Person(
  val birthdate: LocalDate,
  var name: String) {

  init {
    // println(nameParts)                         ❶
  }

  val nameParts: List<String> = name.split(" ")

  init {
    println(nameParts)                            ❷
  }
}

❶ 编译失败,错误:变量 nameParts 必须被初始化

❷ 如预期的那样工作,并打印出一个列表

如果我们需要额外的构造函数,我们可以在类体中使用 constructor 关键字定义它们,如下所示。这些被称为 二级构造函数

class Person(
  val birthdate: LocalDate,
  var name: String) {

  constructor(name: String)
    : this(LocalDate.of(0, 1, 1), name) {   ❶
  }

❶ 当一个类有一个主构造函数时,二级构造函数必须通过 this(直接或通过其他二级构造函数)调用它。

注意:在 Java 中,许多存在多个构造函数重写以提供默认值的情况可以用 Kotlin 的默认参数值来处理。

虽然只有属性的类可能有用,但大多数时候,我们的类还有其他功能。下面的代码示例使用我们在本章前面已经见过的熟悉函数语法向类添加一个方法:

class Person(
  val birthdate: LocalDate,
  var name: String) {

  fun isBirthday(): Boolean {
    val today = LocalDateTime.now().toLocalDate()
    return today == birthdate
  }
}

如前所述,Kotlin 中的函数默认为 public 可见性。如果我们想隐藏一个函数,可以在它前面加上所需的访问修饰符,如下所示:

class Person(
  val birthdate: LocalDate,
  var name: String) {

  fun isBirthday(): Boolean {                   ❶
    return today() == birthdate
  }

  private fun today(): LocalDate {              ❷
    return LocalDateTime.now().toLocalDate()
  }
}

isBirthday 对任何可以看到 Person 类的人来说都是可用的。

today 只在 Person 类内部可用。

面向对象编程的另一个关键部分是 继承。Kotlin 没有使用 extends 关键字,而是通过我们在类型声明中已经看到的熟悉的 : 语法来表示继承,如下所示:

class Child(birthdate: LocalDate, name: String)   ❶
  : Person(birthdate, name) {                     ❷
}

❶ 子类构造函数的参数。注意这些参数没有被标记为 val 和 var,所以它们不会与父属性冲突,但可以作为局部变量传递给超类构造函数。

❷ 调用超类构造函数

继承需要对 Parent 类进行一项其他更改。为了鼓励仅在打算和计划的地方进行子类化,Kotlin 类默认是 关闭的。如果一个类可以被子类化,它必须使用 open 关键字,如下面的代码示例所示。这与 Java 中的情况相反,Java 中的类默认是开放的,并使用 final 来表示它们可能 不能 被子类化:

open class Person(             ❶
  val birthdate: LocalDate,
  var name: String) {
  //...
}

open 关键字及其可见性修饰符位于类关键字之前。

同样的默认关闭原则也适用于方法。父类必须声明一个要重写的 open 方法,并且重写必须在子类中用 override 标记,如下所示:

open class Person(
  val birthdate: LocalDate,
  var name: String) {

  open fun isBirthday(): Boolean {               ❶
    return today() == birthdate
  }

  private fun today() : LocalDate {
    return LocalDateTime.now().toLocalDate()
  }
}

class Child(birthdate: LocalDate, name: String)
  : Person(birthdate, name) {

  override fun isBirthday(): Boolean {           ❷
    val itsToday = super.isBirthday()            ❸
    if (itsToday) {
      println("YIPPY!!")
    }
    return itsToday
  }
}

Person 类声明 isBirthday 函数可以在子类中重写。

❷ 子类必须显式标记其方法为重写。

Child 可以使用 super 调用父类的 isBirthday 实现。

与 Java 一样,Kotlin 只允许一个基类,但类可以继承多个接口,如以下代码所示。Kotlin 的接口允许函数有默认实现,就像 Java 从 8 版本以来所做的那样:

interface Greetable {
  fun greet(): String              ❶
}

open class Person constructor(
  val birthdate: LocalDate,
  var name: String): Greetable {   ❷

  override fun greet(): String {   ❸
    return "Hello there"
  }
}

❶ 定义我们的接口,包含一个返回问候语的函数

❷ Person 声明实现了 Greetable。

❸ 接口函数是开放的,因此它们的实现必须指定 override

在典型的 Kotlin 风格中,实现接口使用了一种简洁的形式,看起来与我们用来扩展基类已经很相似——不再需要记住是否像在 Java 中那样使用 extendsimplements

9.3.1 数据类

虽然这些基本构造允许我们在 Kotlin 中创建丰富的对象模型,但有时你只是需要一个容器来传递数据。Kotlin 通过 数据类 提供了对这一点的支持。

注意:我们在第三章中遇到了 Java 的新 record 功能,Kotlin 数据类在某些方面与 Java 记录非常相似。

Kotlin 已经使属性方面变得无缝,但标准类的相等性问题仍然存在——默认的 equalshashCode 实现是基于对象引用,而不是其属性的值。

当我们声明一个类型为 data class 时,Kotlin 将创建我们想要的相等函数(除非我们显式地提供实现),如下所示:

class PlainPoint(val x: Int, val y: Int)

val pl1 = PlainPoint(1, 1)
val pl2 = PlainPoint(1, 1)

println(pl1 == pl2)                           ❶

data class DataPoint(val x: Int, val y: Int)

val pd1 = DataPoint(1, 1)
val pd2 = DataPoint(1, 1)

println(pd1 == pd2)                           ❷

❶ 默认的 equals 方法比较引用相等性,因此这里打印出 false。

❷ 使用 Kotlin 的数据类实现,这里打印出 true。

数据类必须有一个至少包含一个 valvar 的主构造函数。它们不能是 open 的,因为在编译时 Kotlin 无法正确生成可能存在子类的类型的相等函数。数据类也不允许作为内部类。尽管如此,除了这些和一些更特殊的约束之外,它们仍然是普通的类,你可以在上面实现任何你想要的功能或接口。

来自 Java 的人可能在类中寻找的最后一个特性是声明一个属于整个类而不是实例的函数。然而,Kotlin 没有选择支持 static——函数要么是自由浮动的,要么是类型的成员。

然而,将函数与类关联的便利性是无法否认的,Kotlin 通过其 companion object 提供了类似的功能。这种语法声明了一个在类内部存在的单例对象。Kotlin 中的 object 声明是具有典型属性和函数的完整对象。这避免了 Java 中 static 方法所遭受的奇怪边缘问题(例如,测试困难),同时保持了将功能与类关联的便利性。

这些函数的常见用例是工厂方法,其中你希望保持对象的构造函数为私有,但允许通过更具体命名的函数进行受控的创建,如下所示:

class ShyObject private constructor(val name: String) {   ❶

  companion object {
    fun create(name: String): ShyObject {                 ❷
      return ShyObject(name)
    }
  }
}

ShyObject.create("The Thing")                             ❸

❶ ShyObject 声明其构造函数为私有,因此类外部的任何人都不可以使用它。

❷ 我们在伴随对象中的工厂方法属于 ShyObject 类的一部分,因此它可以访问私有构造函数。

❸ 在我们的类外部,ShyObject 伴随对象上的函数可以通过其类名直接访问。

作为 Java 的实用替代方案,Kotlin 带来了很多便利和样板代码的减少。但不仅如此,我们将在下一节中看到。

9.4 安全性

Kotlin 是建立在 JVM 之上的,因此它别无选择,只能遵守一些来自虚拟机设计的实际设计约束。例如,JVM 规范定义 null 为可以分配给任何引用类型变量的值。

尽管存在这些问题,Kotlin 语言试图解决一些常见的代码安全性问题,以尽量减少继承的痛苦和困扰。这通过将许多 Java 代码模式提升为语言特性来实现,使代码默认更安全。

9.4.1 可空性

在最常见的 Java 异常中,NullPointerException 是其中之一。这发生在我们尝试访问一个应该包含对象但实际为 null 的变量或字段时。Tony Hoare,快速排序算法的原始创造者,将 null 称为他的“十亿美元的错误”(参见 qconlondon.com/london-2009/qconlondon.com/london-2009/speaker/Tony+Hoare.html),鉴于他在 ALGOL 中引入 null 引用方面的作用。

在其历史中,Java 已经发展出几种不同的方法来提供对 null 的保护。Optional 类型让你始终有一个具体对象,同时仍然表示一个“缺失”的值,而不必求助于 null@NotNull@Nullable 注解,由许多不同的验证和序列化框架支持,可以在我们的应用程序的关键点上确保值不会意外地变为 null

如您所预期的那样,Kotlin 已经将这些常见模式直接烘焙到语言本身中。让我们回顾一下我们之前关于赋值变量的例子。当它们与 null 结合时,它们是如何表现的?

val i: Int = null      ❶
val s: String = null   ❶

❶ 尝试将这些类型赋值为 null 将导致编译失败。

这两种赋值都会导致编译错误,error: null can not be a value of a non-null type Int。尽管那些 IntString 类型声明看起来像 Java 的,但实际上它们不允许 null 值。

注意 Kotlin 已经将可空性作为其类型系统的一部分。Kotlin 类型 String 实际上并不等同于允许 null 的 Java 类型 String

要使 Kotlin 变量允许 null,我们必须明确声明,通过在类型后添加 ? 后缀,如下所示:

val i: Int? = null      ❶
val s: String? = null   ❶

❶ 将我们的类型更改为 Int? 和 String? 将告诉 Kotlin 允许 null

注意:在可能的情况下,请使用非空类型声明变量和参数。您可以放心,Kotlin 正在保护您免受那些 NullPointerException 痛苦。

尽管我们总是无法避免 null,但也许我们正在与 Java 代码交互,或者我们的类在设计时没有考虑到 null 安全性。即使我们已经涉足到可空性的危险,Kotlin 仍然尽力通知我们风险,如下所示:

val s: String? = null   ❶
println(s.length)       ❷

❶ 创建一个可空变量

❷ 尝试访问该变量的属性

Kotlin 认识到在调用 s.length 时解引用可能是不安全的,并拒绝编译,错误信息为 error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?

Kotlin 建议的第一个选项是将此更正为安全操作符 ?.。这个操作符检查其应用的对象。如果对象为 null,则返回 null 而不是进行进一步的函数调用,如下所示:

val s: String? = null
println(s?.length)       ❶

?. 导致打印出 null 值。

安全操作符会提前返回,因此即使在嵌套调用链中也能正常工作。在我们以下示例中的任何一点都可以安全地返回 null,整个表达式将变为 null,如下所示:

data class Node(val parent: Node?, val value: String)   ❶

val node = getNode()                                    ❷
node.parent?.parent?.parent                             ❸

❶ 一个允许可选父节点的数据类

❷ 从某处检索一个节点

❸ 检查节点是否有曾祖父母节点

这可能很方便,但 ?. 可能会隐藏数据问题。如果我们之前的曾祖父母节点检查返回了 null,没有进一步的检查,我们无法确定它来自我们层次结构的哪个级别。

我们编译失败的第二种选择(error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?)是在变量上使用 !!。这个操作符强制 Kotlin 判断对象是否为 null,如果值为 null,则会抛出熟悉的 NullPointerException,如下所示:

val s: String? = null
println(s!!.length)      ❶

❶ 抛出 NullPointerException

虽然这种情况可能需要得较少,但我们仍然可以检查变量是否为 null。实际上,Kotlin 通常可以注意到这样的检查,并让我们避免进一步的 ?.!!,如下所示:

val s: String? = null

if (s != null) {        ❶
  println(s.length)     ❷
}

❶ 在所有情况下检查 null

❷ 因为我们知道 s 不是 null,所以可以安全地引用。

我们在这里看到的是 Kotlin 的一个更深层功能,称为 智能转换,这值得更仔细地研究。

9.4.2 智能转换

虽然良好的面向对象设计试图避免直接检查对象类型,但有时这是必要的。我们系统边缘的数据格式可能对类型较为宽松(即 JSON)并且通常不受我们控制。在其他时候,我们可能有插件系统,必须动态探测对象的能力。

Kotlin 接受了这种需求,并在编译器如何支持常见模式方面更进一步。首先,Kotlin 使用 is 操作符来检查对象类型,如下所示:

val s: Any = "Hello"         ❶
if (s is String) {           ❷
  println(s.toUpperCase())   ❸
}

Any 是 Java 的 Object 类型的等价物——所有对象的基础类型。

❷ 检查 s 是否包含一个 String 实例

❸ 使用变量 s 作为 String。如果编译器将其视为类型 Any 仍然在分支中,则 toUpperCase 将不可用。

如果你熟悉 Java 的 instanceof 构造,这段代码似乎遗漏了一个关键步骤——我们查看 s 是否是 String 类型,但随后在将其作为 String 处理之前没有进行转换。幸运的是,Kotlin 有所准备。在编译器可以确保我们有 Stringif 块中,我们可以使用 s 作为 String 而不进行显式转换。这被称为 智能转换

注意:Java 有一个名为 模式匹配 的新特性,作为 Amber 项目的一部分正在逐步推出。其中的一部分应用于 instanceof 并提供了与智能转换相同的一些好处。我们将在第十八章中更详细地讨论模式匹配。

Kotlin 的智能转换功能也允许在 if 条件语句中使用,如下所示:

val s: Any = "Hello"
if (s is String && s.toUpperCase() == "HELLO") {     ❶
  println("Got something")
}

❶ Kotlin 可以确保对 && 左侧的检查类型,因此它可以安全地不进行转换而转换为大写。

智能转换的触发条件存在限制。特别是,它不能与类上的 var 属性一起使用。这保护我们在智能转换检查执行后但在下一个块执行之前,属性不会并发地突变到不同的兼容子类型。

即使 Kotlin 不能直接做到这一点,我们仍然可以将类型转换为期望的类型——只是稍微不太方便,如下所示:

class Example(var s: Any) {
  fun checkIt() {
    if (s is String) {              ❶
      val cast = s as String        ❷
      println(cast.toUpperCase())
    }
  }
}

❶ 假设 s 是以我们无法智能转换的方式定义的

as 转换到期望的类型。

当我们使用 as 时,我们回到了 Java 中进行转换时的同一个位置。如果类型实际上不兼容,我们将看到 ClassCastException。如果宁愿允许可空性而不是异常,Kotlin 提供以下替代方案:

val cast: String? = s as? String   ❶
if (cast != null) {                ❷
  println(cast.toUpperCase())
}

as? 尝试进行转换但不会抛出异常。注意,结果类型是 String? 而不是 String。

❷ 如果 s 不能进行转换,则变量将为 null。

Kotlin 的许多功能来自于对 Java 开发者多年来一直在做的常见、实用编码的新审视。然而,语言在并发方面提供的不只是润色和保护,还有一个领域。Kotlin 提供了一种称为协程的技术,可以被视为 Java 中最广泛使用的经典线程方法的替代方案。

9.5 并发

正如我们在第五章中讨论的,自从 JVM 的最初版本以来,它就支持 Thread 类作为操作系统管理的线程模型。线程模型是众所周知的,但它带来了许多问题。

注意:尽管线程在 Java 语言和生态系统中根深蒂固,几乎不可能将其移除,但转向一种新的、非 Java 语言使我们有可能重新构想该语言可能使用的并发原语。

虽然 Kotlin 作为 JVM 语言仍然暴露线程,但它也引入了另一种称为协程的结构。在最简单的层面上,协程可以被看作是一个更轻量级的线程。这些协程在运行时实现和调度,而不是在操作系统级别,这使得它们对资源的消耗要小得多。启动数千个协程根本不是问题,而相同数量的线程可能会使系统停止运行。

注意:我们将在第十八章讨论 Project Loom 时遇到 Java 对协程的看法。

Kotlin 对协程的支持部分直接在语言中(suspend函数),但为了实际使用协程,还需要一个额外的库,即kotlin-coroutine-core。我们将在第十一章中看到更多关于引入这些类型依赖的内容,但到目前为止,在 Maven 中的添加看起来像这样:

<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-coroutines-core</artifactId>
    <version>1.6.0</version>
</dependency>

在 Kotlin 风格的 Gradle 中,等效的代码如下:

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0")
}

在 Java 中,线程是通过传递一个实现了Runnable接口的对象来启动的。Kotlin 中的协程也需要一种接收要运行代码的方式,但它们使用的是语言的 lambda 语法。

协程总是在一个作用域中启动,它控制着协程的调度和运行方式。我们将从最简单的选项开始,即GlobalScope,这是一个在整个应用程序运行期间都存在的范围。GlobalScope有一个launch函数,我们通过传递一个 lambda 表达式来调用它以开始运行,如下所示:

import kotlinx.coroutines.GlobalScope   ❶
import kotlinx.coroutines.launch        ❶

fun main() {
    GlobalScope.launch {                ❷
       println("Inside!")
    }
    println("Outside")                  ❸
}

❶ 导入我们将使用的协程函数和对象

❷ 在全局作用域中启动一个新的协程,该作用域与我们的程序一样长

❸ 在我们的协程外部,我们将打印出来以确认main仍在运行。

当我们运行这个示例时,大多数情况下你只会看到它输出以下内容:

Outside

为什么我们的协程没有工作?我们预期在某个时刻也会看到Inside被打印出来。然而,如果我们仔细思考事件的顺序,我们就能发现问题。main启动了我们的程序。然后我们异步地启动协程。接着,我们打印出Outside消息,然后程序结束。当main完成时,程序退出,不管是否有协程正在等待运行。

为了得到我们想要的结果,我们需要在程序结束前引入一个暂停。这可以通过循环或请求在控制台输入来实现。我们只是使用Thread.sleep(1000)来为所有事情足够的时间来稳定,如下所示:

import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch

fun main() {
    GlobalScope.launch {    ❶
       println("Inside!")
    }
    println("Outside")
    Thread.sleep(1000)      ❷
}

❶ 再次启动我们的协程

❷ 给协程运行留出时间

现在,我们将看到包含两条消息的输出,尽管顺序可能是非确定性的,这取决于协程启动的速度以及主线程正在做什么。

从高层次来看,这和使用线程来获得类似的并发执行代码看起来并没有太大的不同。但是,底层的实现需要更少的操作系统资源(每个协程没有自己的执行栈和本地存储),并且允许像取消协程这样的操作更安全。

要看到这个功能在实际中的表现,我们可以通过 launch 的返回值捕获协程的句柄。这个协程对象提供了一个 cancel 函数,如果我们想的话,可以立即调用,如下所示:

import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

fun main() {
    val co = GlobalScope.launch {   ❶
        delay(1000)                 ❷
        println("Inside!")
    }
    co.cancel()                     ❸
    println("Outside")
    Thread.sleep(2000)              ❹
}

❶ 捕获由 launch 返回的协程对象

❷ 在协程内部,我们可以调用 delay 来等待一段时间。

❸ 立即取消协程

❹ 在这里等待多长时间都可以——你永远不会看到协程的输出。

这段代码将安全地停止协程并只打印 Outside。这与我们在第五章讨论的 java.lang.Thread 上的 stop() 方法形成了鲜明的对比,因为 stop() 方法由于安全性极差而被弃用很久。

为什么协程能够安全地完成这项工作,而线程却不能?关键在于 delay 函数。它的声明被标记了一个特殊的修饰符:suspend。Kotlin 知道将 suspend 函数视为协程执行中的安全点,用于执行如切换到另一个任务或查找取消等操作。这被称为 合作多任务处理,而且正是因为我们协程内部的代码在其对挂起函数的调用中“合作”,它才能被取消。

这种合作带来的好处远不止于能够安全取消。例如,Kotlin 理解当一个协程(父协程)启动另一个协程(子协程)时。取消父协程会自动取消子协程,而无需我们进行额外的管理,如下所示:

import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

fun main() {
    val co = GlobalScope.launch {  ❶
       coroutineScope {            ❷
          delay(1000)
          println("First")
       }
       coroutineScope {            ❸
          delay(1000)
          println("Second")
       }
    }
    co.cancel()                    ❹
    Thread.sleep(2000)             ❺
}

❶ 如前所述,启动我们的父协程

❷ 启动两个子协程。coroutineScope 将这些协程关联到封装作用域——在这种情况下,我们的全局协程。

❸ 启动两个子协程。coroutineScope 将这些协程关联到封装作用域——在这种情况下,我们的全局协程。

❹ 取消父协程

❺ 再次,我们可以在这里等待,但不会看到任何输出。

如果你已经看到了在 Java 中完成这种协调所需的实现,Kotlin 在这里带来的价值就非常明显了。

协程是 Kotlin 如何利用其作为独立语言并拥有自己的编译器的优势,与库一起工作以干净地完成许多复杂行为的绝佳例子。事实上,关于协程的内容足够多,我们将在第十六章进行更深入的探讨。

但没有一种语言是孤立存在的,尤其是在 JVM 上。Kotlin 因为其强烈关注与庞大的 Java 代码世界的互操作性而取得了巨大的成功和普及。

9.6 Java 兼容性

正如我们在第四章中学到的,类文件是 JVM 执行模型的核心。Kotlin 编译器(kotlinc)生成类文件的方式与 javac 为 Java 生成的类文件类似,如图 9.2 所示。

图片

图 9.2 Kotlin 和 Java 并行工作以生成类文件

基本类定义在两种语言之间看起来相似,但当 Kotlin 提供了 Java 中不可用的功能时,我们在生成的类文件中会看到更多有趣的不同。这些都是编译器虚构的证据,我们在第八章中讨论了这一点。

例如,Kotlin 类外的顶级函数。这在 JVM 类文件格式中甚至没有直接支持。Kotlin 通过生成一个带有 Kt 后缀的类来弥合这个差距,该类以它的编译文件命名。该文件中的任何顶级函数都将出现在 Kt 类中。

注意:您可以使用 .kt 文件中的 @file:JvmName("AlternateClassName") 注解更改生成的类名。

例如:

// Main.kt                         ❶
package com.wellgrounded.kotlin    ❷

fun shout() {
  println("No classes in sight!")
}

❶ 默认情况下,文件名会影响生成的封装类名。

❷ 函数的使用者需要像往常一样从我们的包中导入。

编译后,这将生成一个包含我们的函数的类文件 MainKt.class。因为 Java 本身不提供顶级函数,所以从 Java 使用该函数必须通过这个中间类,如下所示:

// Help.java
import com.wellgrounded.kotlin.MainKt;       ❶

public class Help {
  public static void main(String[] args) {
    MainKt.shout();                          ❷
  }
}

❶ 导入 Kotlin 创建的用于封装函数的类

❷ 通过 Java 的静态方法语法调用函数

Kotlin 的另一个关键便利之处在于其对属性的内置处理。通过使用一点 valvar,我们永远不会写满屏幕的样板代码 getter 和 setter。从 Java 使用 Kotlin 类可以看出,在底层,这些方法一直都在那里——Kotlin 只是方便地封装了它们,如下所示:

// Person.kt
class Person(var name: String)               ❶

// App.java
public class App {
  public static void main(String[] args) {
    Person p = new Person("Some Body");      ❷
    System.out.println(p.getName());         ❸

    p.setName("Somebody Else");              ❹
    System.out.println(p.getName());
  }
}

❶ 我们的属性是 var,所以它是可变的。

❷ 当从 Java 使用时,Kotlin 类仍然使用 new 实例化。

❸ 在 Kotlin 中访问 Person.name,在 Java 中是 Person.getName()。

❹ 在 Kotlin 中访问 Person.name = “...”,在 Java 中是 Person.setName(“...”)。请注意,这个访问器之所以可用,是因为 Person 类将名称属性声明为 var,即可变的。如果名称被声明为 val,则只会生成 getName() 访问器。

注意:此示例表明,在幕后,Kotlin 一直在执行创建私有字段并封装对字段访问的标准模式。Kotlin 允许我们使用更自然的属性访问形式,而不必直接暴露字段的风险。

Kotlin 中的一些其他便利功能在使用其他 JVM 语言时不会在生成的代码中体现出来。命名参数是一个例子——Java 没有通过名称引用参数的方法,所以这种优雅性只存在于 Kotlin 代码中。

在表面层面上,默认值可能会遭受相同的命运——毕竟,从 Java 调用需要你明确传递所有参数给函数,如下所示:

// Person.kt
class Person(var name: String) {
  fun greet(words: String = "Hi there") {     ❶
    println(words)
  }
}

// App.java
public class App {
  public static void main(String[] args) {
    Person p = new Person("Some Body");
    // p.greet();                             ❷
    p.greet("Howdy");                         ❸
  }
}

❶ 对于参数单词的标准 Kotlin 默认值

❷ 我们不能使用默认值调用它,否则会得到编译错误,原因是实际参数列表和形式参数列表的长度不同。

❸ 我们可以传递自己的值。

然而,存在一个逃生门,这意味着我们不必放弃 Kotlin 的整洁性。@JvmOverloads 注解告诉 Kotlin 显式生成函数所需的必要变体,因此从其他 JVM 语言调用它看起来相同:

// Person.kt
class Person(var name: String) {
  @JvmOverloads                              ❶
  fun greet(words: String = "Hi there") {    ❶
    println(words)
  }
}

// App.java
public class App {
  public static void main(String[] args) {
    Person p = new Person("Some Body");
    p.greet();                               ❷
    p.greet("Howdy");                        ❸
  }
}

❶ 注释我们的 Kotlin 函数并提供默认值,与之前一样

❷ 运行正常并打印默认的“Hi there”

❸ 与之前一样运行,并打印我们传递的备用问候语

几个其他注释允许我们控制 Kotlin 代码在 JVM 层面的表现。我们已经在另一个上下文中看到的一个是 @JvmName。它适用于函数以及文件,以控制 Kotlin 之外最终命名的命名。@JvmField 允许我们在需要时避免属性包装器并向世界公开裸字段。

最后,但同样重要的是,是 @JvmStatic。正如我们之前所看到的,Kotlin 将顶层函数包装在特别命名的类中,这些类可以作为 Java 中的静态方法访问。在所有 Java 应用程序中都有一个突出的静态方法,即使你避免使用静态方法:启动应用程序的 main 方法。

如果我们想在 Kotlin 中创建一个应用程序,可以使用 @JvmStatic 定义其 main 方法,以避免在启动时出现任何奇怪的命名需求,如下所示:

class App {                                      ❶
  companion object {
    @JvmStatic fun main(args: Array<String>) {   ❷
      println("Hello from Kotlin")
    }
  }
}

❶ 我们将指定为主类的 App 类以启动。

@JvmStatic 表示此函数将作为包含类上的静态方法呈现,而不仅仅是伴生对象上的。

在项目中更改语言通常是一个巨大的步骤。然而,Kotlin 通过依赖 JVM 多语言项目的标准模式来减轻这种负担。不出所料,如果你使用 IntelliJ IDEA,还有额外的工具。我们将在第十一章中查看标准项目布局,但就目前而言,只需知道项目通常将使用的语言嵌入到目录布局中,如下所示:

.
└── src
     └── main
          ├── java
          │      └── JavaCode.java
          └── kotlin
               └── KotlinCode.kt

这种分离使得构建工具很容易找到它们需要的东西,以便所有代码可以共存。

如果你正在使用 IntelliJ IDEA,JetBrains 的好人们已经将这一功能进一步扩展。通过在 Java 文件上右键单击,你会找到一个“转换”选项,可以直接将单个文件转换为 Kotlin。将 Java 代码粘贴到 Kotlin 文件中也会提供相同的转换功能。这使得从系统中的任何合理点开始转换都成为可能——也许是从测试或与应用程序其余部分不太紧密耦合的模块开始。

当需要时,IDE 会引导你完成额外的步骤,但转换确实需要比仅仅切换一些源文件更多的工作。你的构建工具需要知道 Kotlin 以便与现有代码一起编译它。此外,Kotlin 标准库kotlin-stdlib需要作为依赖项包含到你的项目中。我们将在第十一章中了解更多关于如何管理这类依赖项的内容。

注意:尽管 IntelliJ IDEA 提供了 Java 到 Kotlin 的翻译,但它不支持反向翻译。它的翻译可能也不是用 Kotlin 编写代码的理想方式。在开始大规模转换时,始终要保留源代码控制。

Kotlin 编译成类文件并提供大量附加功能通过库来实现,这意味着即使将这种新语言包含到你的项目中,也不会改变你只是在运行那个好用的旧 JVM。

摘要

  • Kotlin 是 JVM 上的一种实用且吸引人的替代语言。

  • Kotlin 从多年的生产级 Java 使用经验中汲取灵感,作为一个新语言,它做出了 Java 可能由于向后兼容性而无法复制的改变。

  • Kotlin 非常重视其简洁性。在 Java 中熟悉的构造在 Kotlin 中几乎总是可以用更少的代码来编写。

  • 对于 Kotlin 来说,安全性至关重要,它直接将null安全性嵌入到语言中,以减少生产中的NullPointerException

  • 协程提供了对 Java 经典线程模型的引人注目的并发替代方案。

  • Kotlin 脚本(kts)使得以前动态语言或 shell 的领域也可以进行脚本编写。

  • 即使是构建脚本也可以使用 Kotlin 编写,正如我们将在第十一章详细讨论 Gradle 时所见。

10 Clojure:对编程的不同看法

本章涵盖

  • Clojure 的标识和状态概念

  • Clojure 的 REPL

  • Clojure 语法、数据结构和序列

  • Clojure 与 Java 的互操作性

  • Clojure 宏

Clojure 与 Java 以及我们迄今为止研究过的其他语言相比,是一种非常不同的语言风格。Clojure 是 Lisp 这一最古老编程语言之一的 JVM 重启版本。如果你不熟悉 Lisp,不用担心。我们将教你所有关于 Lisp 语言家族的知识,以便你开始学习 Clojure。

注意:由于 Clojure 是一种非常不同的语言,在阅读本章时查阅额外的、针对 Clojure 的资源可能会有所帮助。几本优秀的书籍是《Clojure 实战》(Manning,2011;livebook.manning.com/book/clojure-in-action)和《Clojure 的乐趣》(Manning,2014;livebook.manning.com/book/the-joy-of-clojure-second-edition)。

除了从经典 Lisp 继承的强大编程技术之外,Clojure 还增加了与现代 Java 开发者非常相关的尖端技术。这种组合使 Clojure 成为 JVM 上的一门突出语言,也是应用开发的一个有吸引力的选择。Clojure 新技术的特定例子包括其并发工具包(我们将在第十六章中遇到)和数据结构(我们将在本章介绍,并在第十五章中进一步展开)。

对于迫不及待想要了解的读者,我们只需说:并发抽象使程序员能够编写比在 Java 中工作时更安全的线程代码。这些抽象可以与 Clojure 的 seq 概念(对集合和数据结构的不同看法)结合使用,以提供强大的开发者工具箱。

要访问所有这些功能,一些重要的语言概念在方法上与 Java 有根本的不同。这种方法的差异使 Clojure 的学习变得有趣,而且可能也会改变你对编程的看法。

注意:学习 Clojure 将帮助你在任何语言中成为一个更好的程序员。函数式编程很重要。

我们将从讨论 Clojure 对状态和变量的方法开始。在几个简单示例之后,我们将介绍语言的基本词汇——与 Java 等语言中的关键字等效的特殊形式。其中一小部分用于构建语言的其他部分。

我们还将深入研究 Clojure 的数据结构、循环和函数的语法。这将使我们能够介绍序列,这是 Clojure 最强大的抽象之一。

我们将通过探讨两个非常吸引人的特性来结束本章:紧密的 Java 集成和 Clojure 的惊人宏支持(这是 Lisp 非常灵活语法的关键)。在本书的后面部分,当我们讨论高级函数式编程(第十五章)和高级并发(第十六章)时,我们将遇到更多 Clojure 的优点(以及 Kotlin 和 Java 的示例)。

10.1 介绍 Clojure

Lisp 语法的基本单位是一个要评估的表达式。这些表达式通常表示为零个或多个括号内的符号。如果评估成功且没有错误,则该表达式被称为 形式

注意 Clojure 是编译的,而不是解释的,但编译器非常简单。还要记住 Clojure 是动态类型的,所以不会有太多的类型检查错误来帮助你——它们将作为运行时异常出现。

表达式的简单例子包括:

0
(+ 3 4)
(list 42)
(quote (a b c))

语言的核心真正核心只有很少的内置形式(特殊形式)。它们是 Clojure 对应于 Java 关键字的,但请注意以下内容:

  1. Clojure 对 keyword 这个术语有不同的含义,我们稍后会遇到。

  2. Clojure(像所有的 Lisp 语言一样)允许创建与内置语法不可区分的构造。

当使用 Clojure 代码时,你使用的形式是特殊形式还是由它们构建的库函数几乎无关紧要。

让我们从查看 Clojure 与 Java 最重要概念性差异之一的形式开始。这是对状态、变量和存储的处理。如图 10.1 所示,Java(如 Kotlin)有一个涉及变量作为“盒子”(实际上是内存位置)的内存和状态模型,其内容可以随时间改变。

图片

图 10.1 命令式语言内存使用

像 Java 这样的编程语言默认是 可变的,因为我们试图改变程序状态,在 Java 中,程序状态由对象组成。遵循此模型的语言通常被称为命令式语言,正如我们在第八章中讨论的那样。

Clojure 略有不同。重要的概念是 的概念。值可以是数字、字符串、向量、映射、集合或许多其他东西。一旦创建,值就不会改变。这非常重要,所以我们再重复一遍:一旦创建,Clojure 的值就不能改变——它们是 不可变的

注意不可变性是用于函数式编程的语言的常见属性,因为它允许使用关于函数属性(例如相同的输入总是产生相同的输出)的数学推理技术。

包含可变内容的盒子的命令式语言模型并不是 Clojure 的工作方式。图 10.2 展示了 Clojure 如何处理状态和内存。它创建了一个名称和值之间的关联。

图片

图 10.2 Clojure 内存使用

这被称为绑定,它使用def特殊形式来完成。让我们在这里看看(def)的语法:

(def <name> <value>)

不要担心语法看起来有点奇怪——这对于 Lisp 语法来说完全是正常的,你很快就会习惯的。现在你可以假装括号排列得稍微不同一些,并且你像这样调用一个方法:

def(<name>, <value>)

让我们用一个时间久远的例子来演示(def),这个例子使用了 Clojure 交互环境。

10.1.1 Clojure 中的 Hello World

如果你还没有安装 Clojure,你可以在 Mac 上通过运行以下命令来安装:

brew install clojure/tools/clojure

这将使用 brew 从clojure/toolstap 安装命令行工具。对于其他操作系统,可以在clojure.org网站上找到说明。

注意,Clojure 在 Windows 上的支持并不那么出色。例如,clj仍然处于 alpha 状态。请仔细遵循网站上的说明。

安装完成后,你可以使用clj命令启动 Clojure 交互会话。或者,如果你是从源代码构建 Clojure 的,请切换到安装 Clojure 的目录并运行以下命令:

java -cp clojure.jar clojure.main

无论哪种方式,这都会打开 Clojure 读取-评估-打印循环(REPL)的用户提示。这是交互会话,你通常会在开发 Clojure 代码时花费相当多的时间。它看起来像这样:

$ clj
Clojure 1.10.1
user=>

user=>部分是 Clojure 会话的提示,可以将其视为一个高级调试器或命令行。要退出会话(这将导致会话中累积的所有状态丢失),请使用传统的 Unix 序列Ctrl-D。让我们用 Clojure 编写一个“Hello World”程序:

user=> (def hello (fn [] "Hello world"))
#'user/hello

user=> (hello)
"Hello world"
user=>

在这段代码中,你首先将标识符hello绑定到一个值。(def)始终将标识符(Clojure 称为symbols)绑定到values。幕后,它还会创建一个称为var的对象,代表绑定(以及符号的名称),如下所示:

(def hello (fn [] "Hello world"))
 --- ----- ---------------------
  |    |             |
  |    |           value
  |  symbol
  |
special form

你绑定hello的值是什么?它就是

(fn [] "Hello world")

这是一个函数,在 Clojure 中是一个真正的值(因此,因此,不可变)。这是一个不接受任何参数并返回字符串“Hello world”的函数。空参数列表由[]表示。

注意,在 Clojure(但不在其他 Lisp 中),方括号表示一个称为向量的线性数据结构——在这种情况下,是函数参数的向量。

绑定后,你通过(hello)来执行它。这会导致 Clojure 运行时打印出函数评估的结果,即“Hello world”。

记住,在 Lisp 中,圆括号表示“函数评估”,所以这个例子基本上由以下内容组成:

  • 创建一个函数,并将其绑定到符号hello

  • 调用绑定到符号hello的函数。

到目前为止,你应该输入 Hello World 示例(如果你还没有的话)并看到它表现得如描述的那样。一旦你做到了这一点,我们就可以进一步探索。

10.1.2 使用 REPL 开始

REPL 允许你输入 Clojure 代码并执行 Clojure 函数。它是一个交互式环境,早期评估的结果仍然存在。这使你可以进行一种称为探索性编程的编程类型,这基本上意味着你可以对代码进行实验。在许多情况下,正确的方法是在 REPL 中进行实验,一旦构建块正确,就构建更大和更大的函数。

注意子划分是函数式编程的关键技术——将问题分解成更小的部分,直到它变得可解或适合于可重用模式(可能已经在标准库中)。

让我们看看更多的 Clojure 语法。首先要注意的是,通过另一个对 (def) 的调用,可以更改符号到值的绑定,所以让我们在 REPL 中看看这个动作。我们将实际使用 (def) 的一个轻微变体,称为 (defn),如下所示:

user=> (hello)
"Hello world"

user=> (defn hello [] "Goodnight Moon")
#'user/hello

user=> (hello)
"Goodnight Moon"

注意,直到你更改它,hello 的原始绑定仍然有效——这是 REPL 的一个关键特性。存在状态,即哪些符号绑定到哪些值,并且这种状态在用户输入的行之间持续存在。

图片

图 10.3 Clojure 绑定随时间变化

改变符号绑定到哪个值的能力是 Clojure 对状态修改的替代方案。Clojure 允许符号在不同时间绑定到不同的不可变值,而不是允许存储位置(或“内存盒子”)的内容随时间变化。另一种说法是,var 可以在程序的生命周期内指向不同的值。一个例子可以在图 10.3 中看到。

注意这种可变状态和不同时间不同绑定之间的区别是微妙的,但这是一个重要的概念要掌握。记住,可变状态意味着盒子的内容发生变化,而重新绑定意味着在不同时间指向不同的盒子。

这在某种程度上类似于 Java 中的 final 引用概念。在 Java 中,如果我们说 final int,存储位置的内容无法更改。因为 int 以位模式存储,这意味着 int 的值无法更改。

然而,如果我们说 final AtomicInteger,存储位置的 内容再次无法更改。但这种情况是不同的,因为包含原子整数的变量实际上持有对象引用。堆中存储的原子整数对象可以更改其存储的值(而 Integer 不能),无论对象引用是否为 final。

我们还在上一个代码片段中巧妙地引入了另一个 Clojure 概念——(defn) “定义函数”宏。宏是 Lisp 类语言的关键概念之一。核心思想是尽可能减少内置构造和普通代码之间的区别。

注意:宏允许你创建类似内置语法的表单。宏的创建是一个高级主题,但掌握它们的创建将使你能够制作出非常强大的工具。

系统的真实语言原语(特殊形式)可以用来构建语言的核心,这样你几乎不会注意到两者之间的区别。

注意:(defn) 宏是这种类型的例子。它只是将函数值绑定到符号(当然,创建一个合适的变量)的一种稍微简单的方法。它不是一个特殊形式,而是一个由特殊形式 (def)(fn) 组成的宏。

我们将在本章末尾正确介绍宏。

10.1.3 犯错

如果你犯了错误会发生什么?假设你试图声明一个函数,但意外地只 def 了一个值,如下所示:

user=> (def hello "Goodnight Moon")
#'user/hello

user=> (hello)
Execution error (ClassCastException) at user/eval137 (REPL:1).
class java.lang.String cannot be cast to class clojure.lang.IFn
(java.lang.String is in module java.base of loader 'bootstrap';
clojure.lang.IFn is in unnamed module of loader 'app')

这里有几个需要注意的地方。首先,错误是一个运行时异常。这意味着表单 (hello) 编译良好;只是在运行时失败了。从 Java 中等效的代码来看,它看起来有点像这样(我们简化了一些东西,以便让 Clojure 或语言实现的新手更容易理解):

// (def hello "Goodnight Moon")
var helloSym = Symbol.of("user", "hello");
var hello = Var.of(helloSym, "Goodnight Moon");

// Or just
// var hello = Var.of(Symbol.of("user", "hello"), "Goodnight Moon");

// #'user/hello

// (hello)
hello.invoke();

// ClassCastException

其中 SymbolVarclojure.lang 包中的类,它提供了 Clojure 运行时的核心。它们看起来与以下基本实现相似,我们在这里进行了简化:

public class Symbol {
    private final String ns;
    private final String name;

    private Symbol(String ns, String name) {
        this.ns = ns;
        this.name = name;
    }
    // toString() etc
}

public class Var implements IFn {
    private volatile Object root;

    public final Symbol sym;
    public final Namespace ns;

    private Var(Symbol sym, Namespace ns, Object root) {
        this.sym = sym;
        this.ns = ns;
        this.root = root;
    }

    public static Var of(Symbol sym, Object root){
        return new Var(sym, Namespace.of(sym), root);
    }

    static public class Unbound implements IFn {
        final public Var v;
        public Unbound(Var v){
            this.v = v;
        }

        @Override
        public String toString(){
            return "Unbound: " + v;
        }
    }

    public synchronized void bindRoot(Object root) {
        this.root = root;
    }

    public synchronized void unBindRoot(Object root) {
        this.root = new Unbound(this);
    }

    @Override
    public Object invoke() {
        return ((IFn)root).invoke();
    }

    @Override
    public Object invoke(Object o1) {
        return ((IFn)root).invoke(o1);
    }

    @Override
    public Object invoke(Object o1, Object o2) {
        return ((IFn)root).invoke(o1, o2);
    }

    @Override
    public Object invoke(Object o1, Object o2, Object o3) {
        return ((IFn)root).invoke(o1, o2, o3);
    }
    // ...
}

非常重要的接口 IFn 看起来有点像这样:

public interface IFn {
    default Object invoke() {
        return throwArity();
    }
    default Object invoke(Object o1) {
        return throwArity();
    }
    default Object invoke(Object o1, Object o2) {
        return throwArity();
    }
    default Object invoke(Object o1, Object o2, Object o3) {
        return throwArity();
    }

    // ... many others including eventually a variadic form

    default Object throwArity(){
        throw new IllegalArgumentException("Wrong number of args passed: "
                + toString());
    }
}

IFn 是 Clojure 表单工作方式的关键——表单中的第一个元素被视为要调用的函数或函数名。其余元素是函数的参数,并且调用 invoke() 方法(具有适当的参数数量,即 arity)。

如果 Clojure 变量没有绑定到实现 IFn 的值,则在运行时会抛出 ClassCastException。如果值是 IFn 但表单尝试用错误的参数数量调用它,则会抛出 IllegalArgumentException(它实际上是一个子类型,称为 ArityException)。

注意:记住 Clojure 是动态类型的,正如你在几个地方可以看到的那样,例如,IFn 中方法的所有参数和返回类型都是 Object,并且 IFn 不是 Java 风格的 @FunctionalInterface,而是在其上定义了多个方法来处理许多不同的 arity。

这种对底层的窥视应该有助于澄清 Clojure 的语法以及它们是如何结合在一起的。然而,我们仍然有一些损坏的代码需要修复——但幸运的是,这并不太难!

发生的一切只是你的 hello 标识符绑定到了一个不是函数的东西上,因此不能被调用。在 REPL 中,你可以通过简单地重新绑定它来修复这个问题:

user=> (defn hello [] (println "Dydh da an Nor")) ; "Hello World" in Cornish
#'user/hello

user=> (hello)
Dydh da an Nor
nil

如您从前面的代码片段中猜测的那样,分号(;)字符表示该行末尾的所有内容都是注释,而(println)是打印字符串的函数。请注意,(println),像所有函数一样,返回一个值,该值在函数执行结束时被回显到 REPL 中。

Clojure 没有像 Java 那样的语句,只有表达式,所以所有函数都必须返回一个值。如果没有值要返回,则使用nil,这基本上是 Clojure 中 Java 的null的等价物。在 Java 中将是void的函数在 Clojure 中会返回nil

10.1.4 学习热爱括号

程序员的文化一直包含着大量的异想天开和幽默。其中最古老的笑话之一是 Lisp 代表的是大量令人烦恼的愚蠢括号(而不是更平淡无奇的真相——它是列表处理的缩写)。这个相当自嘲的笑话在一些 Lisp 程序员中很受欢迎,部分原因在于它指出了不幸的真相,那就是 Lisp 的语法有难以学习的名声。

实际上,这个障碍被夸大了。Lisp 的语法与大多数程序员习惯的不同,但它并不是有时被描述的那样一个障碍。此外,Clojure 有几个创新,进一步降低了入门的门槛。

让我们再次看看 Hello World 的例子。要调用返回值“Hello World”的函数,我们写了以下代码:

(hello)

如果我们想要带有参数的函数,而不是有myFunction(someObj)这样的表达式,在 Clojure 中我们写成(myFunction someObj)。这种语法称为波兰记号,因为它是在 20 世纪初由波兰数学家开发的(它也称为前缀记号)。

如果您研究过编译器理论,您可能会想知道这里是否与抽象语法树(AST)等概念有关。简短的答案是是的,有。用波兰记号(通常由 Lisp 程序员称为 s-expression)编写的 Clojure(或其他 Lisp)程序可以证明是那个程序 AST 一个非常简单和直接的表现。

注意 这一次又回到了 Clojure 编译器的简单本质。Lisp 代码的编译是一个非常便宜的操作,因为其结构非常接近 AST。

您可以将 Lisp 程序视为直接用其 AST(抽象语法树)编写的。表示 Lisp 程序的数据结构与代码之间没有真正的区别,因此代码和数据非常可互换。这就是为什么记法有些奇怪的原因:它被 Lisp 类语言用来模糊内置原语和用户及库代码之间的区别。这种能力非常强大,以至于它远远超过了新来的 Java 程序员眼中语法的一点点奇怪。让我们深入更多语法,并开始使用 Clojure 构建真实程序。

10.2 寻找 Clojure:语法和语义

在上一节中,你遇到了(def)(fn)特殊形式(我们也遇到了(defn),但它是一个宏,不是一个特殊形式)。你需要立即了解少量其他特殊形式,以提供语言的基本词汇。此外,Clojure 提供了大量有用的形式和宏,随着实践的深入,对这些形式的了解将更加深入。

Clojure 拥有多种有用的函数,可以执行各种可想象的任务。不要被吓倒——接受它。高兴的是,对于你可能在 Clojure 中遇到的许多实际编程任务,其他人已经为你做了大量的工作。

在本节中,我们将介绍特殊形式的基本工作集,然后过渡到 Clojure 的本地数据类型(Java 集合的等价物)。之后,我们将过渡到编写 Clojure 的自然风格——在这种风格中,函数而不是变量是舞台的中心。JVM 的面向对象性质仍然在表面之下存在,但 Clojure 对函数的强调在纯面向对象语言中并不明显,并且远远超出了map()filter()reduce()等基本功能。

10.2.1 特殊形式训练营

表 10.1 涵盖了 Clojure 一些最常用特殊形式的定义。为了最好地使用表格,现在快速浏览一下,并在达到 10.3 节及以后的某些示例时需要时参考它。该表格使用传统的正则表达式语法表示法,其中?代表单个可选值,*代表零个或多个值。

这不是特殊形式的完整列表,其中大部分都有多种使用方式。表 10.1 是一个基本用例的起始集合,并不是全面的。

表 10.1 Clojure 的一些基本特殊形式

特殊形式 含义
(def <symbol> <value?>) 将符号绑定到值(如果提供);如果需要,为符号创建一个 var
(fn <name>? [<arg>*] <expr>*) 返回一个函数值,它接受指定的参数并将它们应用到 exprs 上;通常与(def)结合成(defn)等形式
(if <test> <then> <else>?) 如果 test 评估为逻辑真,则评估并产生 then;否则,如果存在,评估并产生 else
(do <expr>*) 按从左到右的顺序评估 exprs 并产生最后一个 expr 的值
(let [<binding>*] <expr>*) 将值别名到局部名称并隐式定义一个作用域;使别名在 let 的作用域内对所有 exprs 可用
(quote <form>) 返回形式而不进行任何评估;接受一个形式并忽略所有其他参数
(var <symbol>) 返回与符号对应的 var(返回 Clojure JVM 对象,而不是值)

有几个要点需要进一步解释,因为 Clojure 代码的结构在第一眼看来可能非常不同于 Java 代码。首先,(do) 形式是构建在 Java 中会是一个语句块的最简单方法之一。

第二,我们需要更深入地探讨 var、值以及值(暂时)绑定的符号之间的区别。这段简单的代码创建了一个名为 hi 的 Clojure var。这是一个 JVM 对象(clojure.lang.Var 类型的实例),它生活在堆中——就像所有对象一样——并将其绑定到一个包含“hello”的 java.lang.String 对象上:

user=> (def hi "Hello")
#'user/hi

var 有一个 符号 hi,它还有一个 命名空间 user,Clojure 使用它来组织程序——有点像 Java 包。如果我们不加修饰地使用符号在 REPL 中,它将评估为它当前绑定的值,如下所示:

user=> hi
"Hello"

(def) 形式中,我们将新符号绑定到一个值,因此在这段代码中

user=> (def bye hi)
#'user/bye

符号 bye 绑定到当前绑定到 hi,如下所示:

user=> bye
"Hello"

实际上,在这个简单的形式中,hi 被评估,符号被替换为结果值。

然而,Clojure 给我们的可能性远不止这些。例如,符号绑定的值可以是任何 JVM 值。因此,我们可以将符号绑定到我们创建的 var 上,因为 var 本身就是一个 JVM 对象。这可以通过使用 (var) 特殊形式来实现,如下所示:

user=> (def bye (var hi))
#'user/bye

user=> bye
#'user/hi

这实际上利用了 Java/JVM 对象总是通过引用来处理的事实,正如我们在图 10.4 中所看到的。

图 10.4 Clojure var 通过引用操作

要获取 var 中包含的值,我们可以使用 (deref) 形式(简称“解引用”),如下所示:

user=> (deref bye)
"Hello"

此外,还有一个 (ref) 形式,用于 Clojure 中的安全并发编程——我们将在第十六章中遇到它。

从变量与其当前绑定值的区别来看,(quote) 形式应该更容易理解。它不是评估传入的表单,而是简单地返回一个包含未评估符号的表单。

现在你已经对一些基本特殊形式的语法有所了解,让我们转向 Clojure 的数据结构,并开始了解形式如何操作数据。

10.2.2 列表、向量、映射和集合

Clojure 有几个原生数据结构。最熟悉的是 列表,在 Clojure 中它是一个单链表。

注意:在某些方面,Clojure 列表与 Java 中的 LinkedList 类似,但 LinkedList 是一个双链表,其中每个元素都指向下一个元素和前一个元素。

列表通常被括号包围,这看似构成了一个轻微的语法障碍,因为圆括号也用于通用形式。特别是,括号用于函数调用的评估。这导致以下常见的初学者语法错误:

user=> (1 2 3)
Execution error (ClassCastException) at user/eval1 (REPL:1).
class java.lang.Long cannot be cast to class clojure.lang.IFn
(java.lang.Long is in module java.base of loader 'bootstrap';
clojure.lang.IFn is in unnamed module of loader 'app')

这里的问题是,由于 Clojure 对其值非常灵活,它期望第一个参数是一个函数值(或解析为函数的符号),因此它可以调用该函数并将 2 和 3 作为参数传递;1 不是一个函数值的值,所以 Clojure 不能评估这个表达式。我们说这个 s 表达式是无效的,并回忆一下,只有有效的 s 表达式才是 Clojure 表达式。

解决方案是使用我们在上一节中遇到的 (quote) 形式。它有一个方便的简写形式,即 '。这给我们提供了两种等效的写法来表示这个列表,它是一个包含三个不可变元素的列表,分别是数字 1、2 和 3,如下所示:

user=> '(1 2 3)
(1 2 3)

user=> (quote (1 2 3))
(1 2 3)

注意,(quote) 以特殊方式处理其参数。特别是,没有尝试评估参数,因此不存在由于第一个槽位缺少函数值而导致的错误。

Clojure 有向量,它们类似于数组(实际上,将列表视为类似于 Java 的 LinkedList,将向量视为 ArrayList 并不是太离谱)。它们有一个方便的文本形式,使用方括号,所以以下所有内容都是等效的:

user=> (vector 1 2 3)
[1 2 3]

user=> (vec '(1 2 3))
[1 2 3]

user=> [1 2 3]
[1 2 3]

我们已经遇到了向量。当我们声明 Hello World 函数和其他函数时,我们使用向量来指示声明函数所接受的参数。请注意,(vec) 形式接受一个列表并将其转换为向量,而 (vector) 是一个接受多个单独符号的格式,并返回它们的向量。

集合的 (nth) 函数接受两个参数:一个集合和一个索引。它可以看作类似于 Java 的 List 接口中的 get() 方法。它可以用于向量、列表,以及 Java 集合,甚至字符串,字符串被视为字符的集合。以下是一个示例:

user=> (nth '(1 2 3) 1)
2

Clojure 还支持具有这种简单文本语法的映射(你可以将其视为与 Java 的 HashMap 非常相似——实际上它们确实实现了 Map 接口):

{key1 value1 key2 value2}

要从映射中获取值,下面的语法非常简单:

user=> (def foo {"aaa" "111" "bbb" "2222"})
#'user/foo

user=> foo
{"aaa" "111", "bbb" "2222"}

user=> (foo "aaa")              ❶
"111"

❶ 此语法相当于在 Java 中使用 get() 方法。

除了 Map 接口外,Clojure 的映射还实现了 IFn 接口,这就是为什么它们可以在 (foo "aaa") 这样的形式中使用而不抛出运行时异常。

一个非常有用的风格点是使用前面带有冒号的前缀键。Clojure 将这些称为 关键字

注意:Clojure 中“关键字”的使用,当然与其他语言(包括 Java)中该术语的含义非常不同(在其他语言中,该术语意味着语言语法中保留的部分,不能用作标识符)。

以下是一些关于关键字和映射的有用要点,请记住:

  • Clojure 中的关键字是一个接受一个参数的函数,该参数必须是一个映射。

  • 在映射上调用关键字函数会返回映射中对应的关键字函数的值。

  • 当使用关键字时,语法中存在一种有用的对称性,因为 (my-map :key)(:key my-map) 都是合法的。

  • 作为值,关键字返回其自身。

  • 关键字在使用前不需要声明或 def

  • 记住,Clojure 函数是值,因此可以作为映射中的键使用。

  • 逗号可以用(但不必要)来分隔键值对,因为 Clojure 将其视为空白字符。

  • 除了关键字之外,其他符号也可以用作 Clojure 映射的键,但关键字语法非常有用,并且值得在您的代码风格中强调。

让我们看看这里的一些实际应用:

user=> (def martijn {:name "Martijn Verburg", :city "London",
:area "Finsbury Park"})
#'user/martijn

user=> (:name martijn)   ❶
"Martijn Verburg"

user=> (martijn :area)   ❷
"Finsbury Park"

user=> :area             ❸
:area

user=> :foo              ❸
:foo

❶ 在映射上调用关键字函数

❷ 在映射中查找与关键字关联的值

❸ 展示了当作为值评估时,关键字返回其自身

除了映射字面量之外,Clojure 还有一个 (map) 函数。但不要被误导。与 (list) 不同,(map) 函数不会生成映射。相反,(map) 依次将提供的函数应用于集合中的每个元素,并从返回的新值中构建一个新的集合(实际上是一个 Clojure 序列,您将在第 10.4 节中详细了解),如下所示:

user=> (def ben {:name "Ben Evans", :city "Barcelona", :area
"El Born"})
#'user/ben

user=> (def authors [ben martijn])          ❶
#'user/authors

user=> (defn get-name [y] (:name y))
#'user/get-name

user=> (map get-name authors)               ❷
("Ben Evans" "Martijn Verburg")

user=> (map (fn [y] (:name y)) authors)     ❸
("Ben Evans" "Martijn Verburg")

❶ 创建一个包含作者数据的映射的向量

❷ 在数据上映射 get-name 函数

❸ 使用内联函数字面量交替形式

(map) 有其他形式可以同时处理多个集合,但接受单个集合作为输入的形式是最常见的。

Clojure 还支持集合,它们与 Java 的 HashSet 非常相似。它们有数据结构字面量的简短形式,不支持重复键(与 HashSet 不同),如下所示:

user=> #{"a" "b" "c"}
#{"a" "b" "c"}

user=> #{"a" "b" "a"}
Syntax error reading source at (REPL:15:15).
Duplicate key: a

这些数据结构为构建 Clojure 程序提供了基础。

对于 Java 原生用户来说,可能令人惊讶的是,没有立即提到对象作为一等公民。这并不是说 Clojure 不是面向对象的,但它并不像 Java 那样看待面向对象。Java 选择以静态类型的数据和代码包的形式来看待世界,这些数据类型在用户定义的数据类型的显式类定义中。Clojure 强调函数和形式,尽管这些在 JVM 后台作为对象实现。

Clojure 与 Java 之间的这种哲学区别在两种语言编写代码的方式中体现出来,要完全理解 Clojure 的观点,有必要在 Clojure 中编写程序并理解淡化 Java 的面向对象结构所带来的优势。

10.2.3 算术、相等和其他操作

Clojure 没有您可能在 Java 中期望的操作符。那么,例如,您会如何添加两个数字?在 Java 中很容易:

3 + 4

但 Clojure 没有操作符。我们将不得不使用一个函数,如下所示:

(add 3 4)   ❶

❶ 如果不提供添加函数,这段代码将无法正常工作。

这一切都很好,但我们能做得更好。因为 Clojure 中没有运算符,所以我们不需要为它们保留键盘上的任何字符。这意味着我们的函数名可以比 Java 中更奇特,因此我们可以写出这个:

(+ 3 4)   ❶

❶ 这实际上是前面讨论过的波兰表示法。

Clojure 的函数在许多情况下是 可变参数(它们接受可变数量的输入),所以例如,你可以写出这个:

(+ 1 2 3)

这将返回值 6。

对于等价形式(Java 中的 equals()== 的等价物),情况要复杂一些。Clojure 有两个与等价相关的形式:(=)(identical?)。请注意,这些都是 Clojure 中没有运算符的例子,这意味着函数名中可以使用的字符更多。此外,(=) 是一个等号,因为在 Java 类语言中不存在相同的赋值概念。

这段 REPL 代码设置了一个列表 list-int 和一个向量 vect-int,并像这样应用了等价逻辑:

user=> (def list-int '(1 2 3 4))
#'user/list-int

user=> (def vect-int (vec list-int))
#'user/vect-int

user=> (= vect-int list-int)
true

user=> (identical? vect-int list-int)
false

关键点是 (=) 形式在集合中检查集合是否包含相同顺序的相同对象(对于 list-intvect-int 来说是真的),而 (identical?) 检查它们是否真的是同一个对象。

你可能还会注意到我们的符号名不使用驼峰式命名法。这在 Clojure 中很常见。符号通常全部小写,单词之间用连字符分隔(有时称为 kebab case)。

Clojure 中的真和假

Clojure 为逻辑假提供了两个值:falsenil。任何其他内容都是逻辑真(包括字面量 true)。这与许多动态语言(例如 JavaScript)中的情况相似,但对于第一次遇到这种情况的 Java 程序员来说可能有点奇怪。

在掌握了基本的数据结构和运算符之后,让我们将一些我们看到的特殊形式和函数组合起来,编写一些稍微长一点的 Clojure 函数示例。

10.2.4 在 Clojure 中处理函数

在本节中,我们将开始处理 Clojure 编程的一些主要内容。我们将开始编写作用于数据的函数,并将 Clojure 对函数的关注重点突出。接下来是 Clojure 的循环结构,然后是读取宏和调度形式。我们将通过讨论 Clojure 对函数式编程的方法及其对闭包的看法来结束本节。

开始做所有这些的最佳方式是通过示例,所以让我们从几个简单的示例开始,逐步构建 Clojure 提供的一些强大的函数式编程技术。

一些简单的 Clojure 函数

下一个列表定义了三个函数,其中两个是非常简单的单参数函数;第三个稍微复杂一些。

列表 10.1 定义简单的 Clojure 函数

(defn const-fun1 [y] 1)

(defn ident-fun [y] y)

(defn list-maker-fun [x f]      ❶
   (map (fn [z] (let [w z]      ❷
       (list w (f w))           ❸
   )) x))

❶ 列表生成器接受两个参数,第二个参数是一个函数。

❷ 一个内联的匿名函数

❸ 创建一个包含两个元素的列表:值和将 f 应用到值的结果

在这个列表中,(const-fun1)接受一个值并返回 1,而(ident-fun)接受一个值并返回相同的值。数学家会称这些为常函数恒等函数。你还可以看到,函数的定义使用向量字面量来表示函数的参数和(let)形式。

第三个函数更复杂。函数(list-maker-fun)接受两个参数:首先是一个要操作的值向量,称为x,其次是一个函数(称为f)。如果我们用 Java 来写,它可能看起来像这样:

    public List<Object> listMakerFun(List<Object> x,
                                     Function<Object, Object> f) {
        return x.stream()
                .map(o -> List.of(o, f.apply(o)))
                .collect(toList());
    }

Clojure 中内联匿名函数的作用在 Java 代码中由 lambda 表达式扮演。然而,不要过分强调这两个代码列表(Clojure 和 Java)之间的等价性——Clojure 和 Java 是非常不同的语言。

注意:接受其他函数作为参数的函数称为高阶函数。我们将在第十五章中详细介绍它们。

让我们看看(list-maker-fun)是如何工作的。

列表 10.2 函数操作

user=> (list-maker-fun ["a"] const-fun1)
(("a" 1))

user=> (list-maker-fun ["a" "b"] const-fun1)
(("a" 1) ("b" 1))

user=> (list-maker-fun [2 1 3] ident-fun)
((2 2) (1 1) (3 3))

user=> (list-maker-fun [2 1 3] "a")
java.lang.ClassCastException: java.lang.String cannot be cast to
  clojure.lang.IFn

注意,当你将这些表达式输入到 REPL 中时,你是在与 Clojure 编译器交互。表达式(list-maker-fun [2 1 3] "a")无法运行(尽管它可以编译),因为(list-maker-fun)期望其第二个参数是一个函数,而字符串不是。所以尽管 Clojure 编译器为该形式输出了字节码,但它会因运行时异常而失败。

注意:在 Java 中,我们可以编写有效的代码,如Integer.parseInt("foo"),它将编译良好,但在运行时总是会失败。Clojure 的情况类似。

这个例子表明,在与 REPL 交互时,你仍然有一定程度的静态类型检查,因为 Clojure 不是一种解释型语言。即使在 REPL 中,每个被类型化的 Clojure 形式都会编译成 JVM 字节码,并链接到运行系统中。当定义 Clojure 函数时,它会编译成 JVM 字节码,因此ClassCastException是由于 JVM 中的静态类型违规而发生的。

列表 10.3 展示了更长的 Clojure 代码片段,即Schwartzian 转换。这是编程历史的一部分,在 20 世纪 90 年代由 Perl 编程语言普及。其想法是在向量上执行排序操作,不是基于提供的向量,而是基于向量元素的某些属性。要排序的属性值是通过在元素上调用键函数来找到的。

列表 10.3 中 Schwartzian 转换的定义调用了键函数key-fn。当你实际想要调用(schwartz)函数时,你需要提供一个用于键的函数。在这个代码示例中,我们使用了列表 10.1 中的老朋友(ident-fun)

列表 10.3 Schwartzian 转换

user=> (defn schwartz [x key-fn]
  (map (fn [y] (nth y 0))             ❶
    (sort-by (fn [t] (nth t 1))       ❷
      (map (fn [z] (let [w z]         ❸
        (list w (key-fn w))
      )) x))))
#'user/schwartz

user=> (schwartz [2 3 1 5 4] ident-fun)
(1 2 3 4 5)

user=> (apply schwartz [[2 3 1 5 4] ident-fun])
(1 2 3 4 5)

❶ 使用键函数创建由对组成的列表

❷ 根据键函数的值对对进行排序

❸ 通过减少操作——只从每个对中取原始值来构建新的列表

这段代码正在执行三个独立的步骤,乍一看可能显得有些反直觉。这些步骤在图 10.5 中展示。

图片

图 10.5 Schwartzian 转换

注意,在列表 10.3 中,我们引入了一个新的形式:(sort-by)。这是一个接受两个参数的函数:一个用于排序的函数和一个要排序的向量。我们还展示了 (apply) 形式,它接受两个参数:一个要调用的函数和一个传递给它的参数向量。

Schwartzian 转换的一个有趣方面是,为其命名的人故意模仿 Lisp,当他提出 Perl 版本时。在这里用 Clojure 代码表示它意味着我们已经回到了起点——再次回到了 Lisp!

Schwartzian 转换是一个有用的例子,我们稍后会再次提到它。它包含足够的复杂性来展示许多有用的概念。现在让我们继续讨论 Clojure 中的循环,这些循环的工作方式可能与你习惯的不同。

10.2.5 Clojure 中的循环

Java 中的循环相当直接:开发者可以从 forwhile 和几种其他循环类型中选择。通常,中心概念是重复一组语句,直到满足一个条件(通常用可变变量来表示)。

这在 Clojure 中给我们带来了一点难题:当没有可变的变量可以作为循环索引时,我们如何表达 for 循环?在更传统的 Lisp 中,这种情况通常通过将迭代循环重写为使用递归的形式来解决。

然而,JVM 不保证优化尾递归(如 Scheme 和其他 Lisp 所需),因此天真地使用递归可能会导致堆栈溢出。我们将在第十五章中更多关于这个问题进行讨论。

相反,Clojure 提供了一些有用的构造,允许在不增加堆栈大小的情况下进行循环。其中最常见的是 loop-recur。下面的代码片段展示了如何使用 loop-recur 来构建一个类似于 Java for 循环的简单结构:

(defn like-for [counter]
  (loop [ctr counter]      ❶
    (println ctr)
    (if (< ctr 10)
      (recur (inc ctr))    ❷
      ctr
   )))

❶ 循环入口点

❷ 回跳的递归点

(loop) 形式接受一个局部符号名称的向量作为参数——实际上就像 (let) 一样是别名。然后,当执行达到 (recur) 形式时(在这个例子中,只有当 ctr 别名小于 10 时才会这样做),(recur) 将控制权分支回 (loop) 形式,但带有新的指定值。这类似于一个相当原始的 Java 循环结构,如下所示:

public int likeFor(int ctr) {
        LOOP: while (true) {
            System.out.println(ctr);
            if (ctr < 10) {
                ctr = ctr + 1;
                continue LOOP;
            } else {
                return ctr;
            }
        }
    }

然而,对于函数式程序员来说,提前返回的唯一常见原因是满足某些条件。然而,函数返回最后一个评估的表达式的结果,而 (if) 已经为我们做了这件事。

在我们的例子中,我们将 (recur) 放在 if 的主体中,将计数值放在 else 位置。这允许我们构建迭代式结构(例如 Java 的 forwhile 循环的等效形式),同时仍然保持实现的函数式风格。我们现在将转向下一个主题,即探讨 Clojure 语法中有用的缩写,以帮助使您的程序更短、更简洁。

10.2.6 读取宏和分派

Clojure 有一些语法特性会令许多 Java 程序员感到惊讶。其中之一是缺乏运算符。这导致放松了 Java 对函数名中可以使用哪些字符的限制。您已经遇到了像 (identical?) 这样的函数,这在 Java 中是非法的,但我们还没有解决在符号中允许或不允许使用哪些字符的问题。

表 10.2 列出了不允许在 Clojure 符号中使用的字符。这些字符都是 Clojure 解析器为其自身使用保留的。它们通常被称为 读取宏,并且实际上是一个特殊的字符序列,当读取器(Clojure 编译器的第一部分)看到它时,会修改读取器的行为。

例如,; 读取宏是 Clojure 实现单行注释的方式。当读取器看到 ; 时,它会立即忽略该行上剩余的所有字符,然后重置以获取下一行的输入。

注意:稍后我们将遇到 Clojure 的一般(或常规)宏。不要将读取宏与常规宏混淆。

读取宏仅用于语法简洁和方便,而不是提供完整的通用元编程能力。

表 10.2 读取宏

字符 名称 含义
' 引用 展开为 (quote);返回未经评估的形式
; 注释 标记注释直到行尾;类似于 Java 中的 //
\ 字符 生成一个字面字符,例如,\n 表示换行符
@ 解引用 展开为 (deref),它接受一个 var 对象并返回该对象中的值((var) 表达式的相反操作);在事务内存上下文中具有额外的意义(见第十五章)
^ 元数据 将元数据映射附加到对象上;有关详细信息,请参阅 Clojure 文档
` 语法引用 常用于宏定义的引用形式;有关详细信息,请参阅宏部分
# 分派 有几个不同的子形式;见表 10.3

分派读取宏有几个不同的子形式,具体取决于 # 字符后面的内容。表 10.3 展示了不同的可能形式。

表 10.3 分派读取宏的子形式

分派形式 含义
#' 展开为 (var)
#{} 创建一个集合字面量,如第 10.2.2 节所述
#() 创建一个匿名函数字面量;在单次使用时比 (fn) 更简洁
#_ 跳过下一个形式;可以通过 #_( ... 多行 ...) 产生多行注释
#"<pattern>" 创建一个正则表达式字面量(作为 java.util.regex.Pattern 对象)

从分派形式中可以得出几个额外的观点。下面的 var-quote (#') 形式解释了为什么在 (def) 之后 REPL 的行为是这样的:

user=> (def someSymbol)

#'user/someSymbol

(def) 形式返回一个新创建的名为 someSymbol 的 var 对象,它位于当前命名空间中(在 REPL 中是 user),因此 #'user/someSymbol(def) 返回的完整值。

匿名函数字面量 #() 也有一个重大的创新,可以减少冗余——它省略了参数向量,而是使用一种特殊的语法来允许 Clojure 读取器推断函数字面量所需的参数数量。该语法是 %N,其中 N 是函数参数的编号。

让我们回到一个早期的例子,看看如何使用匿名函数。回想一下 (list-maker-fun) 这个函数,它接受两个参数(一个列表和一个函数),并通过依次将函数应用于列表中的每个元素来创建一个新的列表:

(defn list-maker-fun [x f]
   (map (fn [z] (let [w z]
       (list w (f w))
   )) x))

而不是费尽心思定义一个单独的符号,我们可以如下使用内联函数调用这个函数:

user=> (list-maker-fun ["a" "b"] (fn [x] x))
(("a" "a") ("b" "b"))

但我们可以更进一步,并像这样使用紧凑的 #() 语法:

user=> (list-maker-fun ["a" "b"] #(do %1))
(("a" "a") ("b" "b"))

这个例子有点不寻常,因为我们使用了之前在基本特殊形式表中遇到过的 (do) 形式,但它确实有效。现在,让我们使用 #() 形式简化 (list-maker-fun) 本身:

(defn list-maker-fun [x f]
   (map #(list %1 (f %1)) x))

Schwartzian 转换也是一个很好的用例,可以展示如何在更复杂的示例中使用这种语法,如以下代码示例所示。

列表 10.4 重新编写的 Schwartzian 转换

(defn schwartz [x key-fn]
  (map #(nth %1 0)               ❶
    (sort-by #(nth %1 1)         ❶
      (map #(let [w %1]          ❶
        (list w (key-fn w))
      ) x))))

❶ 对应于三个步骤的匿名函数字面量

使用 %1 作为函数字面量参数的占位符(以及 %2、%3 等后续参数),使使用方式非常突出,并使代码更容易阅读。这个视觉线索对程序员来说确实很有帮助,类似于 Java 中 lambda 表达式使用的箭头符号。

正如你所见,Clojure 严重依赖于函数作为计算基本单元的概念,而不是像 Java 这样的语言中的对象。这种方法的自然环境是函数式编程,这是我们下一个主题。

10.3 函数式编程和闭包

现在,我们将转向 Clojure 中令人畏惧的函数式编程世界。或者,更确切地说,我们并没有,因为其实并没有那么可怕。事实上,我们已经在整个章节中进行了函数式编程;我们只是没有告诉你不要因此而感到沮丧。

正如我们在第 8.1.3 节中提到的,函数式编程是一个有些模糊的概念——它所能依赖的含义仅仅是一个函数是一个值。函数可以被传递、放置在变量中并像2"hello."一样被操作。但这又如何呢?我们在我们的第一个例子中就做过:(def hello (fn [] "Hello world"))。我们创建了一个函数(一个不接受任何参数并返回字符串"Hello world"的函数),并将其绑定到符号hello。函数只是一个值,与像2这样的值没有本质的不同。

在第 10.3 节中,我们介绍了 Schwartzian 转换作为接受另一个函数作为输入值的函数的例子。同样,这只是一个接受特定类型作为其输入参数之一的函数。它唯一的不同之处在于它接受的类型是一个函数。

现在可能也是介绍(filter)形式的好时机,它应该会让你想起 Java Streams 中类似命名的方法:

user=> (defn gt4 [x] (> x 4))
#'user/gt4
user=> (filter gt4 [1 2 3 4 5 6])
(5 6)

此外,还有(reduce)形式,用于完成 filter-map-reduce 操作集。它最常见有两种变体,一种接受一个初始起始值(有时称为“零”),另一种则不:

user=> (reduce + 1 [2 3 4 5])
15
user=> (reduce + [1 2 3 4 5])
15

关于闭包呢?当然,它们一定很可怕,对吧?其实并没有那么可怕。让我们看看一个简单的例子,希望它能让你想起我们在第九章为 Kotlin 做的例子:

user=> (defn adder [constToAdd] #(+ constToAdd %1))
#'user/adder

user=> (def plus2 (adder 2))
#'user/plus2

user=> (plus2 3)
5

user=> 1:9 user=> (plus2 5)
7

你首先设置一个名为(adder)的函数。这是一个创建其他函数的函数。如果你熟悉 Java 中的工厂方法模式,你可以将其视为 Clojure 的类似物。函数返回其他函数作为其返回值并没有什么奇怪——这是函数只是普通值的概念的关键部分。

注意,这个例子使用了匿名函数字面量的缩写形式#()(adder)函数接受一个数字并返回一个函数,而返回的函数接受一个参数。

然后,你使用(adder)来定义一个新的形式:(plus2)。这是一个接受一个数值参数并将其加 2 的函数。在(adder)内部绑定到constToAdd的值是2。现在让我们创建一个新的函数:

user=> (def plus3 (adder 3))
#'user/plus3

user=> (plus3 4)
7

user=> (plus2 4)
6

这表明你可以创建一个不同的函数(plus3),它将不同的值绑定到constToAdd。我们说函数(plus3)(plus2)已经捕获封闭了它们环境中的值。请注意,(plus3)(plus2)捕获的值是不同的,并且定义(plus3)(plus2)捕获的值没有影响。

封闭其环境中的某些值的函数被称为闭包(plus2)(plus3)是闭包的例子。函数创建函数返回另一个更简单的函数,该函数已经封闭了某些内容,这种模式在具有闭包的语言中非常常见。

注意请记住,尽管 Clojure 会编译任何语法上有效的形式,但如果函数以错误数量的参数被调用,程序将抛出运行时异常。一个需要两个参数的函数不能在期望一个参数的地方使用。

我们将在第十五章中详细介绍关于在上下文中使用函数式编程的更多内容。现在,让我们转向 Clojure 的一个强大功能——序列。

10.4 介绍 Clojure 序列

Clojure 有一个强大的核心抽象,称为序列或更常见的是seq

注意序列是利用 Clojure 语言优势编写代码的重要组成部分,并且它们将与 Java 处理类似概念的方式形成有趣的对比。

序列类型大致对应于 Java 中的集合和迭代器,但序列具有不同的属性。基本思想是序列本质上合并了 Java 这两种类型的一些特性到一个概念中。这是由想要以下三个东西所驱动的:

  • 不可变性,允许序列在函数(和线程)之间传递而不会出现问题

  • 一个更健壮的类似迭代器的抽象,特别是对于多遍算法

  • 延迟序列的可能性(稍后详细介绍)

在这三者中,Java 程序员有时最难以理解的是不可变性。Java 的迭代器概念本质上是可变的,部分原因是因为它没有提供一个干净可分离的接口。事实上,Java 的Iterator违反了单一责任原则,因为当调用next()时,它执行以下两个逻辑上不同的操作:

  • 它返回当前指向的元素。

  • 它通过前进元素指针来突变迭代器。

序列基于函数式思想,并通过以不同的方式分割hasNext()next()的能力来避免突变。让我们来认识 Clojure 最重要的接口之一的一个稍微简化的版本,即clojure.lang.ISeq

interface ISeq {
    Object first();      ❶
    ISeq rest();         ❷
}

❶ 返回序列中的第一个对象

❷ 返回一个包含旧序列所有元素的新序列,除了第一个

现在,序列永远不会被突变。相反,每次我们调用rest()时,都会创建一个新的序列值,这是我们在迭代器移动到下一个值时应该执行的操作。让我们看看一些代码,以展示我们如何在 Java 中实现这一点:

public class ArraySeq implements ISeq {
    private final int index;                            ❶
    private final Object[] values;                      ❶

    private ArraySeq(int index, Object[] values) {
        this.index = index;
        this.values = values;
    }

    public static ArraySeq of(List<Object> objects) {   ❷
        if (objects == null || objects.size() == 0) {
            return Empty.of();
        }
        return new ArraySeq(0, objects.toArray());
    }

    @Override
    public Object first() {
        return values[index];
    }

    @Override
    public ISeq rest() {
        if (index >= values.length - 1) {
            return Empty.of();                          ❸
        }
        return new ArraySeq(index + 1, values);
    }

    public int count() {
        return values.length - index;
    }
}

❶ 最终字段

❷ 一个接受列表的工厂方法

❸ 需要一个空的实现

如您所见,我们需要一个特殊的序列来表示序列的末尾。让我们将其表示为ArraySeq内部的内部类,如下所示:

    public static class Empty extends ArraySeq {
        private static Empty EMPTY = new Empty(-1, null);

        private Empty(int index, Object[] values) {
            super(index, values);
        }

        public static Empty of() {
            return EMPTY;
        }

        @Override
        public Object first() {
            return null;
        }

        @Override
        public ISeq rest() {
            return of();
        }

        public int count() {
            return 0;
        }
    }

让我们看看它是如何付诸实践的:

ISeq seq = ArraySeq.of(List.of(10000,20000,30000));
var o1 = seq.first();
var o2 = seq.first();
System.out.println(o1 == o2);

如预期的那样,对first()的调用是幂等的——它们不会改变序列,并且会重复返回相同的值。

让我们看看我们如何使用ISeq在 Java 中编写循环:

while (seq.first() != null) {
    System.out.println(seq.first());
    seq = seq.rest();
}

这个例子展示了我们如何处理一些 Java 程序员有时对不可变序列方法提出的反对意见:“那么所有垃圾怎么办?”

确实,每次调用 rest() 都会创建一个新的序列,这是一个对象。然而,如果你仔细查看实现代码,你可以看到我们非常小心地没有复制 values——数组存储。复制那将是昂贵的,所以我们不做那件事。

我们在每一步实际上只是创建了一个包含整数和对象引用的小对象。如果这些临时变量没有存储在任何地方,当我们在序列中向下移动时,它们会超出作用域,并且很快就会成为垃圾回收的候选对象。

注意:Empty 的方法体不引用 indexvalues,因此我们可以自由地使用特殊值(-1 和 null),这些值无法通过 ArraySeq 的任何其他实例访问——这是一个调试辅助工具。

现在我们已经解释了使用 Java 解释序列理论的一些理论,让我们切换回 Clojure。

注意:所有 Clojure 序列实现的实际 ISeq 接口比我们之前遇到的情况要复杂一些,但基本意图是相同的。

表 10.4 展示了一些与序列相关的核心函数。请注意,这些函数都不会修改它们的输入参数;如果它们需要返回不同的值,它将是一个不同的序列。

表 10.4 基本序列函数

函数 影响
(seq <coll>) 返回一个序列,作为对所作用集合的“视图”
(first <coll>) 如果需要,首先调用 (seq),返回集合的第一个元素;如果集合为空,则返回 nil
(rest <coll>) 返回一个新的序列,由集合生成,但不包含第一个元素
(seq? <o>) 如果 o 是一个序列(意味着,如果它实现了 ISeq),则返回 true
(cons <elt> <coll>) 返回一个由集合生成的序列,额外元素被添加到前面
(conj <coll> <elt>) 返回一个新的集合,新元素被添加到适当的位置——对于向量是末尾,对于列表是头部
(every? <pred-fn> <coll>) 如果 (pred-fn) 对集合中的每个元素都返回逻辑真值,则返回 true

Clojure 与其他 Lisp 语言不同,因为 (cons) 需要第二个参数是一个集合(或者,实际上是一个 ISeq)。一般来说,许多 Clojure 程序员更喜欢使用 (conj) 而不是 (cons)。以下是一些示例:

user=> (rest '(1 2 3))
(2 3)

user=> (first '(1 2 3))
1

user=> (rest [1 2 3])
(2 3)

user=> (seq ())
nil

user=> (seq [])
nil

user=> (cons 1 [2 3])
(1 2 3)

user=> (every? is-prime [2 3 5 7 11])
true

需要注意的一个重要观点是,Clojure 列表是它们自己的序列,但向量不是。理论上,你不应该能够在向量上调用 (rest)。你能够这样做的原因是 (rest) 在操作之前会调用 (seq) 来处理向量。

注意:许多序列函数接受比序列更通用的对象,并在开始之前调用 (seq)

在下一节中,我们将探讨序列抽象的一些基本属性和用法,特别关注可变参数函数。稍后,在第十五章中,我们将遇到惰性序列——这是一种非常重要的函数技术。

10.4.1 序列和可变参数函数

我们一直推迟讨论 Clojure 对函数方法的强大功能之一,直到现在。这是函数自然具有可变数量参数的能力,有时称为函数的arity。接受可变数量参数的函数称为可变参数函数,它们在操作序列时经常被使用。

注意 Java 支持可变方法,其语法中方法的最后一个参数在类型上以...表示,以指示参数列表末尾允许任何数量的该类型参数。

作为一个非常简单的例子,考虑我们在列表 10.1 中讨论的常量函数(const-fun1)。这个函数接受一个参数并丢弃它,总是返回值 1。但是,考虑当你像这样传递多个参数给(const-fun1)时会发生什么:

user=> (const-fun1 2 3)
java.lang.IllegalArgumentException:
  Wrong number of args (2) passed to: user$const-fun1 (repl-1:32)

Clojure 编译器无法在编译时对(const-fun1)传递的参数数量(和类型)执行静态检查,因此我们不得不冒运行时异常的风险。

这似乎过于严格,尤其是对于一个简单地丢弃所有参数并返回一个常量值的函数。在 Clojure 中,一个可以接受任意数量参数的函数看起来会是什么样子?

下面的列表展示了如何为章节中较早提到的(const-fun1)常量函数的版本执行此操作。我们将其命名为(const-fun-arity1),表示具有变量arity常量函数 1

注意实际上,这是一个 Clojure 标准函数库中提供的(constantly)函数的自制版本。

列表 10.5 可变参数函数

user=> (defn const-fun-arity1
  ([] 1)                        ❶
  ([x] 1)                       ❶
  ([x & more] 1)                ❶
)
#'user/const-fun-arity1

user=> (const-fun-arity1)
1

user=> (const-fun-arity1 2)
1

user=> (const-fun-arity1 2 3 4)
1

❶ 具有不同签名的多个函数定义

关键在于,函数定义后面不是函数参数的向量以及定义函数行为的表单。相反,有一系列成对出现的内容,每个成对由参数的向量(实际上是该版本函数的签名)和该版本函数的实现组成。

这可以被视为类似于 Java 中的方法重载的概念。或者,它也可以被视为与模式匹配(我们在第三章中遇到过的)相关。然而,由于 Clojure 是一种动态类型语言,没有类型模式的等价物,因此这种联系并不像可能的那样紧密。

通常的约定是定义几种特殊情况的形式(这些形式接受零个、一个或两个参数)以及一个额外的形式,其最后一个参数是一个序列。在列表 10.5 中,这是具有参数向量[x & more]的形式。&符号表示这是函数的可变版本。

序列是 Clojure 的一项强大创新。实际上,学习如何在 Clojure 中思考的大部分内容就是开始思考如何将 seq 抽象应用于解决你特定的编码问题。Clojure 的另一个重要创新是 Clojure 与 Java 之间的集成,这是下一节的主题。

10.5 在 Clojure 和 Java 之间进行互操作

Clojure 是从头开始设计的,旨在成为 JVM 语言,并且不试图完全隐藏 JVM 的特性给程序员。这些具体的设计选择在许多地方都很明显。例如,在类型系统级别,Clojure 的列表和向量都实现了 List——Java 集合库的标准接口。此外,从 Clojure 使用 Java 库以及反之亦然都非常容易。这些特性非常有用,因为 Clojure 程序员可以利用丰富的 Java 库和工具,以及 JVM 的性能和其他功能。

在本节中,我们将介绍这个互操作性决策的多个方面,特别是:

  • 从 Clojure 调用 Java

  • Java 如何看待 Clojure 函数的类型

  • Clojure 代理

  • 使用 REPL 进行探索性编程

  • 从 Java 调用 Clojure

让我们通过查看如何从 Clojure 访问 Java 方法来开始探索这种集成。

10.5.1 从 Clojure 调用 Java

考虑以下在 REPL 中评估的 Clojure 代码片段:

user=> (defn lenStr [y] (.length (.toString y)))
#'user/lenStr

user=> (schwartz ["bab" "aa" "dgfwg" "droopy"] lenStr)
("aa" "bab" "dgfwg" "droopy")

在这个片段中,我们使用了 Schwartzian 转换来按字符串长度对字符串向量进行排序。为此,我们使用了 (.toString)(.length) 形式,它们是 Java 方法。它们被调用在 Clojure 对象上。符号开头的点表示运行时应该在下一个参数上调用命名方法。这是通过使用我们尚未遇到的另一个宏 (.) 来实现的。

回想一下,所有通过 (def) 或其变体定义的 Clojure 值都被放置在 clojure.lang.Var 的实例中,它可以容纳任何 java.lang.Object,因此可以在 Clojure 值上调用任何可以在 java.lang.Object 上调用的方法。与其他与 Java 世界交互的形式一样

(System/getProperty "java.vm.version")

调用静态方法(在这种情况下是 System.getProperty() 方法)和

Boolean/TRUE

用于访问静态公共变量(例如常量)。

熟悉的“Hello World”示例看起来像这样:

user=> (.println System/out "Hello World")
Hello World
nil

注意,最后的 nil 是因为,当然,所有 Clojure 表达式都必须返回一个值,即使它们是对 void Java 方法的调用。

在这三个示例中,我们隐式地使用了 Clojure 的命名空间概念,这与 Java 包类似,并为常见情况(如前面所述)提供了从简写形式到 Java 包名的映射。

10.5.2 Clojure 调用的本质

Clojure 中的函数调用被编译成 JVM 方法调用。JVM 不保证优化掉尾递归,而 Lisp(尤其是 Scheme 实现)通常都会这样做。一些其他在 JVM 上的 Lisp 方言持有一个观点,即它们想要真正的尾递归,因此它们准备让 Lisp 函数调用在所有情况下都不完全等同于 JVM 方法调用。然而,Clojure 却完全拥抱 JVM 作为平台,即使这意味着完全遵守通常的 Lisp 实践。

如果你想在 Clojure 中创建一个新的 Java 对象实例并对其进行操作,你可以通过使用(new)形式轻松做到这一点。它有一个简短的替代形式,即类名后跟一个全点,这归结为(.)宏的另一种用法,如下所示:

(import '(java.util.concurrent CountDownLatch LinkedBlockingQueue))

(def cdl (new CountDownLatch 2))

(def lbq (LinkedBlockingQueue.))

在这里,我们也在使用(import)形式,它允许在单行中导入一个包中的多个 Java 类。

我们之前提到,Clojure 的类型系统与 Java 的类型系统有一定的对应关系。让我们更详细地看看这个概念。

10.5.3 Clojure 值的 Java 类型

从 REPL 中,查看一些 Clojure 值的 Java 类型非常简单,如下所示:

user=> (.getClass "foo")
java.lang.String

user=> (.getClass 2.3)
java.lang.Double

user=> (.getClass [1 2 3])
clojure.lang.PersistentVector

user=> (.getClass '(1 2 3))
clojure.lang.PersistentList

user=> (.getClass (fn [] "Hello world!"))
user$eval110$fn__111

首先要注意的是,所有 Clojure 值都是对象;JVM 的原始类型默认不暴露(尽管有方法可以获取性能敏感的原始类型)。正如你所期望的,字符串和数值值直接映射到相应的 Java 引用类型(java.lang.Stringjava.lang.Double等)。

匿名的“Hello world!”函数有一个表明它是动态生成类实例的名称。这个类将实现IFn接口,这是 Clojure 用来指示一个值是函数的非常重要的接口。

如我们之前讨论的,seqs 实现了ISeq接口。它们通常是抽象的ASeq的具体系列之一或懒实现LazySeq(我们将在第十五章讨论高级函数式编程时遇到懒加载)。

我们已经探讨了各种值的类型,但那些值的存储呢?正如我们在本章开头提到的,(def)将一个符号绑定到一个值,并在这样做的同时创建一个 var。这些 var 是类型为clojure.lang.Var的对象(它实现了IFn以及其他接口)。

10.5.4 使用 Clojure 代理

Clojure 有一个强大的宏叫做(proxy),它允许你创建一个真正的 Clojure 对象,该对象扩展 Java 类(或实现接口)。例如,下一个列表回顾了一个早期的例子(使用第六章中的ScheduledThreadPoolExecutor),但由于 Clojure 更紧凑的语法,执行示例的核心现在只需很少的代码。

列表 10.6 重访计划执行器

(import '(java.util.concurrent Executors LinkedBlockingQueue TimeUnit))

(def stpe (Executors/newScheduledThreadPool 2))            ❶

(def lbq (LinkedBlockingQueue.))

(def msgRdr (proxy [Runnable] []                           ❷
  (run [] (.println System/out (.toString (.poll lbq))))
))

(def rdrHndl
  (.scheduleAtFixedRate stpe msgRdr 10 10 TimeUnit/MILLISECONDS))

❶ 创建执行器的工厂方法

❷ 定义一个匿名的 Runnable 实现

(proxy) 的一般形式如下:

(proxy [<superclass/interfaces>] [<args>] <impls of named functions>+)

第一个向量参数包含这个代理类应该实现的接口。如果代理类还应该扩展一个 Java 类(当然,它只能扩展一个 Java 类),那么这个类的名称必须是向量中的第一个元素。

第二个向量参数包含要传递给超类构造函数的参数。这通常是一个空向量,并且对于 (proxy) 形式只是实现 Java 接口的所有情况,它肯定是一个空向量。

在这两个参数之后,是表示接口或超类指定的单个方法实现的表单。在我们的例子中,代理只需要实现 Runnable,所以这是第一个向量参数中的唯一符号。不需要超类参数,所以第二个向量是空的(这通常也是这样)。

在两个向量之后,是一个定义代理将实现的方法的表单列表。在我们的情况下,这只是 run(),我们给它定义 (run [] (.println System/out (.toString (.poll lbq)))))。这当然是 Clojure 写这个 Java 代码块的方式:

public void run() {
    System.out.println(lbq.poll().toString());
}

(proxy) 形式允许简单地实现任何 Java 接口。这导致了一个引人入胜的可能性——使用 Clojure REPL 作为扩展的实验平台来实验 Java 和 JVM 代码。

10.5.5 使用 REPL 进行探索性编程

探索性编程的关键概念是,由于 Clojure 的语法,以及 REPL 提供的实时、交互式环境,REPL 可以是一个很好的环境,不仅用于探索 Clojure 编程,还可以用于学习 Java 库。

让我们考虑 Java 列表实现。它们有一个 iterator() 方法,返回一个 Iterator 类型的对象。但 Iterator 是一个接口,所以你可能想知道实际的实现类型是什么。使用 REPL,很容易找到,如下所示:

user=> (import '(java.util ArrayList LinkedList))
java.util.LinkedList

user=> (.getClass (.iterator (ArrayList.)))
java.util.ArrayList$Itr

user=> (.getClass (.iterator (LinkedList.)))
java.util.LinkedList$ListItr

(import) 形式从 java.util 包中引入了两个不同的类。然后你可以在 REPL 中使用 getClass() Java 方法,就像你在 10.5.3 节中所做的那样。正如你所看到的,迭代器实际上是由内部类提供的。这可能并不令人惊讶;正如我们在 10.4 节中讨论的,迭代器与它们所属的集合紧密相关,因此它们可能需要看到这些集合的内部实现细节。

注意,在先前的例子中,我们没有使用单个 Clojure 构造,只是稍微使用了一点语法。我们操作的一切都是真正的 Java 构造。但是,假设你想使用不同的方法,并在 Java 程序中使用 Clojure 提供的强大抽象。下一小节将向你展示如何实现这一点。

10.5.6 从 Java 使用 Clojure

回想一下,Clojure 的类型系统与 Java 的类型系统紧密对齐。Clojure 的数据结构都是真正的 Java 集合,实现了 Java 接口的强制性部分。可选部分通常不实现,因为它们通常涉及数据结构的修改,而 Clojure 不支持这种修改。

这种类型系统的对齐为在 Java 程序中使用 Clojure 数据结构打开了可能性。由于 Clojure 本身的性质——它是一种编译语言,具有与 JVM 匹配的调用机制,这使得这一点更加可行。这最小化了运行时方面,意味着从 Clojure 获得的类可以几乎像任何其他 Java 类一样处理。解释型语言会发现这要困难得多,并且通常需要一个最小的非 Java 语言运行时来支持。

下一个示例展示了如何使用 Clojure 的 seq 构造在普通的 Java 字符串上。为了使此代码运行,clojure.jar需要位于类路径中:

ISeq seq = StringSeq.create("foobar");

while (seq != null) {
  Object first = seq.first();
  System.out.println("Seq: "+ seq +" ; first: "+ first);
  seq = seq.next();
}

上述代码片段使用了StringSeq类的create()工厂方法。这为字符串的字符序列提供了一个 seq 视图。first()next()方法返回新的值,而不是像我们在第 10.4 节中讨论的那样修改现有的 seq。

在下一节中,我们将继续讨论 Clojure 的宏。这是一种强大的技术,允许经验丰富的程序员有效地修改 Clojure 语言本身。这种能力在像 Lisp 这样的语言中很常见,但对于 Java 程序员来说却相当陌生,因此它值得单独一节来介绍。

10.6 宏

在第八章中,我们讨论了 Java 语言语法的刚性。相比之下,Clojure 提供并积极鼓励使用宏作为提供更灵活方法的机制,允许程序员编写更多或更少的普通程序代码,这些代码的行为与内置语言语法相同。

注意:许多语言都有宏(包括 C++),并且它们大多以类似的方式运作——通过提供一个特殊的源代码编译阶段,通常是第一个阶段。

例如,在 C 语言中,第一步是预处理,它移除注释、内联包含的文件,并展开宏,这些宏是不同的预处理器指令,如#include#define

然而,尽管 C 宏非常强大,但它们也使得工程师能够生成一些非常微妙且难以理解和调试的代码。为了避免这种复杂性,Java 语言从未实现过宏系统或预处理器。

C 宏通过在预处理阶段提供非常简单的文本替换功能来工作。Clojure 宏更安全,因为它们在 Clojure 本身的语法中工作。实际上,它们允许程序员创建一种特殊的函数,该函数在编译时(以特殊方式)进行评估。宏可以在所谓的宏展开时间期间在编译过程中转换源代码。

注意:宏的强大之处在于 Clojure 代码被写成有效的 Clojure 数据结构——具体来说,是一个形式的列表。

我们说 Clojure,就像其他 Lisp(以及一些其他语言)一样,是homoiconic的,这意味着程序以与数据相同的方式表示。其他编程语言,如 Java,将它们的源代码写成字符串,并且如果不解析这个字符串在 Java 编译器中,就无法确定程序的结构。

回想一下,Clojure 在遇到源代码时会编译源代码。许多 Lisp 是解释型语言,但 Clojure 不是。相反,当 Clojure 源代码被加载时,它会即时编译成 JVM 字节码。这可能会给人一种 Clojure 是解释型的表面印象,但实际上(非常简单的)Clojure 编译器隐藏在表面之下。

注意,Clojure 形式是一个列表,宏本质上是一个不评估其参数而是操作它们以返回另一个列表的函数,然后该列表将被编译为 Clojure 形式。

为了演示这一点,让我们尝试编写一个类似于(if)的反向操作的宏形式。在某些语言中,这会用unless关键字表示,所以在 Clojure 中它将是一个(unless)形式。我们想要的格式看起来像(if),但行为是逻辑上的相反,如下所示:

user=> (def test-me false)
#'user/test-me

user=> (unless test-me "yes")
"yes"

user=> (def test-me true)
#'user/test-me

user=> (unless test-me "yes")
nil

注意,我们没有提供else条件的等效物。这使示例变得有些简单,而且“unless ... else”听起来也很奇怪。在我们的示例中,如果unless逻辑测试失败,该形式评估为nil

如果我们尝试使用(defn)来编写,我们可以写一个简单的初步尝试,如下所示(剧透:实际上它不会正确工作):

user=> (defn unless [p t]
  (if (not p) t))
#'user/unless

user=> (def test-me false)
#'user/test-me

user=> (unless test-me "yes")
"yes"

user=> (def test-me true)
#'user/test-me

user=> (unless test-me "yes")
nil

这看起来似乎没问题。然而,考虑一下我们希望(unless)以与(if)相同的方式工作——特别是,只有当布尔谓词条件为真时,then形式才应该被评估。换句话说,对于(if),我们看到这种行为:

user=> (def test-me true)
#'user/test-me

user=> (if test-me (do (println "Test passed") true))
Test passed
true

user=> (def test-me false)
#'user/test-me

user=> (if test-me (do (println "Test passed") true))
nil

当我们尝试以相同的方式使用我们的(unless)函数时,问题变得明显,如下所示:

user=> (def test-me false)
#'user/test-me

user=> (unless test-me (do (println "Test passed") true))
Test passed
true

user=> (def test-me true)
#'user/test-me

user=> (unless test-me (do (println "Test passed") true))
Test passed
nil

无论谓词是真还是假,then 形式仍然会被评估,并且由于在我们的例子中它是 (println),它会产生输出,这为我们提供了线索,让我们知道评估正在进行。为了解决这个问题,我们需要处理我们传递的表单而无需评估它们。这本质上是一种(略有不同)的惰性概念,这在函数式编程中非常重要(我们将在第十五章中详细描述)。特殊形式 (defmacro) 用于声明一个新的宏,如下所示:

(defmacro unless [p t]
  (list 'if (list 'not p) t))

让我们看看它是否做得正确:

user=> (def test-me true)
#'user/test-me

user=> (unless test-me (do (println "Test passed") true))
nil

user=> (def test-me false)
#'user/test-me

user=> (unless test-me (do (println "Test passed") true))
Test passed
true

现在它表现得就像我们希望的那样:本质上,(unless) 形式现在看起来和表现得就像内置的 (if) 特殊形式一样。

如你所见,编写宏的一个缺点是涉及到大量的引用。宏在编译时将其参数转换为一个新的大 Clojure 形式,因此输出应该是 (list) 是很自然的。

列表中包含在运行时将被评估的 Clojure 符号,因此在我们进行宏展开期间不需要显式评估的任何内容都必须被引用。这依赖于宏在编译时接收它们的参数,因此它们作为未评估的数据可用。

在我们的例子中,我们需要引用所有不是我们参数的内容——这些将在展开期间被字符串替换为符号。这很快就变得相当繁琐。我们能做得更好吗?

让我们认识一个可能指明正确方向的实用工具。在编写或调试宏时,(macroexpand-1) 形式非常有用。如果将这个形式传递给一个宏形式,它将展开宏并返回展开后的形式。如果传递的形式不是一个宏,它就只返回该形式,例如:

user=> (macroexpand-1 '(unless test-me (do (println "Test passed") true)))
(if (not test-me) (do (println "Test passed") true))

我们真正希望的是能够编写出看起来像它们的宏展开形式,而不需要像迄今为止示例中所见的那样大量的引用。

注意:使用 (macroexpand) 形式进行完全宏展开时,只需重复调用前面的、更简单的形式即可构建。当应用 (macroexpand-1) 时没有操作,宏展开就结束了。这种能力的关键是特殊的读取宏 ``` ``,它读作“语法引号”,我们在本章关于读取宏的部分中已经预览过它。语法引号读取宏通过基本上引用以下形式中的所有内容来工作。如果你想让某些内容不被引用,你必须使用语法非引号(~)运算符来免除一个值从语法引号中。这意味着我们的示例宏 (unless) 可以写成如下形式:

(defmacro unless [p t]
  `(if (not ~p) ~t))

现在这个形式现在更加清晰,更接近我们在宏展开时看到的形式。~ 字符提供了一个很好的视觉线索,让我们知道那些符号将在宏展开时被替换。这与宏作为编译时代码模板的想法很好地吻合。

除了语法引用和取消引用之外,一些重要的特殊变量有时在宏定义中使用。其中,最常见的是以下两个:

  • &form—正在调用的表达式

  • &env—宏展开点的局部绑定映射

可以从每个特殊变量中获得的信息的详细信息可以在 Clojure 文档中找到。

我们还应该注意,在编写 Clojure 宏时需要小心。例如,可以创建递归展开而不终止而是发散的宏,如下面的示例所示:

(defmacro diverge [t]
  `((diverge ~t) (diverge ~t)))
#'user/diverge

user=> (diverge true)
Syntax error (StackOverflowError) compiling at (REPL:1:1).
null

作为最后的例子,让我们通过构建一个本质上充当从编译到运行时桥梁的闭包的宏来确认宏确实在编译时操作,如下所示:

user=> (defmacro print-msg-with-compile []
  (let [num (System/currentTimeMillis)]
    `(fn [t#] (println t# " " ~num))))
#'user/print-msg-with-compile

user=> (def p1 (print-msg-with-compile))
#'user/p1

user=> (p1 "aaa")
aaa   1603437421852
nil

user=> (p1 "bbb")
bbb   1603437421852
nil

注意 (let) 形式是在编译时评估的,因此当宏被评估时,(System/currentTimeMillis) 的值被捕获,绑定到符号 num,然后在展开形式中用绑定的值替换——实际上是一个在编译时确定的常量。

尽管我们在本章的最后介绍了宏,但实际上宏在 Clojure 中无处不在。事实上,Clojure 标准库的大部分内容都是作为宏实现的。有经验的开发者可以通过花时间阅读标准库的源代码并观察其关键部分的编写方式来学到很多东西。

在这一点上,一个警告也是及时的:宏是一种强大的技术,有些开发者可能会陷入一种诱惑(就像其他“提升”程序员思维的技术一样),那就是在不严格必要的情况下过度使用这种技术。

为了防止这种情况,我们强烈建议您牢记以下简单的通用规则,用于 Clojure 宏的使用:

  • 当目标可以通过函数实现时,永远不要编写宏。

  • 编写一个宏来实现语言或标准库中尚未存在的功能、能力或模式。

这些中的第一个当然是“你可以做某事并不意味着你应该”的另一种说法。

第二个是一个提醒,宏的存在是有原因的:你可以用它们做些其他方式真的无法做到的事情。熟练的 Clojure 程序员将能够在适当的地方有效地使用宏。

除了宏之外,还有更多关于 Clojure 的知识需要学习,例如语言对动态运行时行为的处理方式。在 Java 中,这通常是通过类和接口继承以及虚拟调度来处理的,但这些是基本面向对象的概念,并不特别适合 Clojure。

相反,Clojure 使用协议数据类型——以及我们已经遇到的代理——来提供大部分这种灵活性。甚至还有更多可能性,例如使用多方法的定制分派方案。这些也是非常强大的技术,但不幸的是,它们超出了 Clojure 的这种入门级介绍。

作为一种语言,Clojure 可以说是我们探讨过的语言中最不同于 Java 的。它的 Lisp 血统、对不可变性的强调以及不同的方法似乎使它成为一个完全不同的语言。但与 JVM 的紧密集成、类型系统的对齐(即使它提供了替代方案,如 seqs),以及探索性编程的力量使它成为 Java 的一个非常互补的语言。

我们在本部分探讨的语言之间的差异清楚地显示了 Java 平台演变和继续成为应用开发可行目的地的力量。这也是对 JVM 的灵活性和能力的证明。

摘要

  • Clojure 是动态类型的,Java 程序员需要小心运行时异常。

  • 探索性和基于 REPL 的开发与 Java IDE 的感觉截然不同。

  • Clojure 提供并推广了一种非常不可变风格的编程。

  • 函数式编程贯穿 Clojure——比 Java 或 Kotlin 都要多。

  • Seqs 是 Java 的迭代器和集合的功能等价物。

  • 宏定义了 Clojure 源代码的编译时转换。

第四部分:构建和部署

这本书的这一部分主要讲述如何有效地使用工具来构建、测试和部署我们的 Java 应用程序。尽管构建工具在 Java 出现之前就已经存在,但这个领域仍在不断发展。在第十一章中,你将了解两个最受欢迎的 Java 构建工具——Maven 和 Gradle——以及它们的相似之处和不同之处。除了基本命令之外,你还将看到它们如何构建世界模型,以及这些模型在哪里为经验丰富的开发者提供了扩展和定制的空间。

在过去的几年里,容器化技术席卷了整个行业。从最初作为 Linux 原始操作的一小部分,Docker 和 Kubernetes 已经将这些技术转变为主流。你将了解如何将我们的 Java 应用程序环境与容器中的规范集成,需要改变什么,什么保持不变。

在部署应用程序之前,你需要对它们进行测试。这种测试的确切含义可能因人而异,取决于你与谁交谈以及项目需要什么。一个经验丰富的开发者知道,一种方法并不适合所有人,因此我们将探讨多种不同的测试方法及其优缺点,并为我们更精确地描述测试提供一个词汇表。你还将看到 JUnit 最新主要版本的重大变化,这些变化使用了你沿途学到的一些较新的 JDK 特性。

测试代码的多样性在各个方向上延伸,特别是 Java 以外的其他语言和技术也提供了有价值的见解。在第十四章中,你将看到容器、Kotlin 和 Clojure 如何为你的测试工具箱带来各自独特的视角。到第四部分的结尾,你将准备好将你的 JVM 应用程序带入现实世界,并确信它已被正确构建和测试。

11 使用 Gradle 和 Maven 构建

本章涵盖

  • 为什么构建工具对扎实的开发者很重要

  • Maven

  • Gradle

JDK 附带了一个编译器,可以将 Java 源代码转换为类文件,正如我们在第四章中看到的。尽管如此,很少有项目仅依赖于javac。让我们首先看看为什么扎实的开发者应该投资于熟悉这一层工具。

11.1 为什么构建工具对扎实的开发者很重要

构建工具之所以成为规范,有以下原因:

  • 自动化繁琐操作

  • 管理依赖项

  • 确保开发者之间的一致性

尽管存在许多选项,但今天有两个选择主导着这个领域:Maven 和 Gradle。了解这些工具旨在解决的问题,深入了解它们如何完成任务,以及了解它们之间的差异——以及如何扩展它们——对于扎实的开发者来说将是有益的。

11.1.1 自动化繁琐操作

javac可以将任何 Java 源文件转换为类文件,但构建一个典型的 Java 项目不仅仅是这样。如果手动进行,仅将所有文件正确列出给编译器在大型项目中可能就是一项繁琐的工作。构建工具提供了查找代码的默认设置,并允许你轻松配置,如果你有一个非标准布局的话。

Maven 推广的常规布局,以及 Gradle 默认使用的布局,看起来是这样的:

.
└── src
    ├── main                                       ❶
    │      └── java                                  ❷
    │               └── com                              ❸
    │                       └── wellgrounded
    │                               └── Main.java
    └── test
        └── java
            └── com
                └── wellgrounded
                    └── MainTest.java

❶ main 和 test 将我们的生产代码与测试代码分开。

❷ 使用这种结构,一个项目中可以轻松地共存多种语言。

❸ 进一步的目录结构通常反映了你的包层次结构。

如你所见,测试已经完全融入到我们的代码布局中。自从人们问是否真的需要为他们的代码编写测试以来,Java 已经走了很长的路。构建工具在使测试以一致的方式在所有地方可用方面发挥了关键作用。

注意:你可能已经知道如何在 Java 中使用 JUnit 或其他库进行单元测试。我们将在第十四章中讨论其他形式的测试。

虽然将代码编译成类文件是 Java 程序存在的起点,但通常并不是终点。幸运的是,构建工具还提供了将你的类文件打包成 JAR 或其他格式以方便分发支持。

11.1.2 管理依赖项

在 Java 的早期,如果你想要使用一个库,你必须找到它的 JAR 文件,下载文件,并将其放入应用程序的类路径中。这导致了一些问题——特别是,所有库缺乏一个中心、权威的来源,有时需要寻宝才能找到不太常见的依赖项的 JAR 文件。

显然,这并不理想,因此 Maven(以及其他项目)为 Java 生态系统提供了仓库,工具可以在其中为我们找到和安装依赖关系。Maven Central 至今仍然是互联网上最常用的 Java 依赖关系注册表之一。其他也存在——如由 Google 托管或 GitHub 上共享的公共注册表,以及通过 Artifactory 等产品进行的私有安装。

下载所有这些代码可能也会很耗时,因此构建工具已经通过在项目之间共享工件来标准化了几种减少痛苦的方法。如果有第二个项目需要相同的库,由于有一个本地仓库来缓存,你就不需要再次下载它,如图 11.1 所示。当然,这种方法也可以节省磁盘空间,但这里真正的胜利在于单一来源的工件。

图 11.1 Maven 的本地仓库不仅帮助在线查找依赖关系,而且能够高效地本地管理依赖关系

图 11.1 Maven 的本地仓库帮助不仅在线查找依赖关系,而且能够高效地本地管理依赖关系

注意:你可能想知道模块在这个依赖图中是如何定位的。模块化的库以带有module-info.class文件的 JAR 文件的形式发货,正如我们在第二章中看到的。可以从标准仓库下载模块化的 JAR 文件。真正的区别在于你开始使用模块编译和运行时,而不是在打包和分发时。

然而,除了提供一个中心位置来查找和下载依赖关系之外,注册表还为更好地管理传递依赖关系打开了大门。在 Java 中,我们通常在项目使用的库本身依赖于另一个库时看到这种情况。实际上,我们在第二章中已经遇到了模块的传递依赖关系,但这个问题在 Java 模块出现之前就已经存在了。事实上,在模块出现之前,这个问题要严重得多。

记住,JAR 文件只是一个压缩文件——它们没有任何描述 JAR 依赖关系的元数据。这意味着 JAR 的依赖关系只是 JAR 中所有类依赖关系的并集。

更糟糕的是,类文件格式没有描述需要哪个版本的类来满足依赖关系——我们只有类或方法名符号描述,该类需要链接(正如我们在第四章中看到的)。这暗示了以下两点:

  1. 需要外部依赖关系信息来源。

  2. 随着项目规模的扩大,传递依赖关系图将变得越来越复杂。

随着开源库和框架的爆炸式增长,以支持开发者,真实项目中传递依赖关系的典型树状结构已经变得越来越大。

一个潜在的利好消息是,JVM 生态系统的状况比 JavaScript 的情况要好一些。JavaScript 缺乏一个丰富、集中的运行时库,该库保证始终存在,因此许多基本功能必须作为外部依赖来管理。这引入了问题,如多个不兼容的库各自提供相同功能的版本,以及一个脆弱的生态系统,其中错误和敌意攻击可能对公共部分产生不成比例的影响(例如,2016 年的“left-pad”事件[见mng.bz/5Q64])。

另一方面,Java 有一个运行时库(JRE),其中包含许多常用的类,并且这个库在每一个 Java 环境中都是可用的。然而,一个真正的生产应用程序将需要超出 JRE 的功能,并且几乎总是有太多的依赖层,难以手动管理。唯一的解决方案是自动化。

冲突出现

这种自动化对于在丰富的开源代码生态系统中构建的开发者来说是一个福音,但升级依赖通常也会揭示问题。例如,图 11.2 显示了一个可能让我们陷入麻烦的依赖树。

图 11.2 冲突的传递依赖

我们明确要求了lib-a的 2.0 版本,但我们的依赖lib-b却要求使用较老的 1.0 版本。这被称为依赖冲突,并且根据如何解决,它可能会引起各种其他问题。

不匹配的库版本可能导致哪些类型的破坏?这取决于版本之间变化的本性。变化可以分为几个类别,如下所示:

  1. 稳定的 API,其中只有行为在版本之间发生变化

  2. 在版本之间出现新类或方法时添加的 API

  3. 在版本之间扩展方法签名或接口时更改的 API

  4. 在版本之间删除类或方法时移除的 API

在 a)或 b)的情况下,你可能甚至都不会注意到你的构建工具选择了哪个版本的依赖。c)中最常见的案例是方法签名在库版本之间的变化。在我们的上一个例子中,如果lib-a 2.0 改变了lib-b所依赖的方法的签名,当lib-b尝试调用该方法时,它会收到一个NoSuchMethodError异常。

在 d)中删除的方法将导致相同的NoSuchMethodError。这包括“重命名”一个方法,在字节码级别上并不与删除一个方法并添加一个新方法有任何不同,只是新方法恰好有相同的实现。

类在删除或重命名时也容易发生 d)冲突,并会导致NoClassDefFoundError错误。还有可能,从一个类中删除接口可能会导致一个难看的ClassCastException

这个关于冲突传递依赖的问题列表绝不是详尽的。所有这些都归结于同一软件包的两个版本之间实际发生了什么变化。

事实上,关于版本之间变化性质的沟通是跨语言的一个常见问题。处理这个问题的最广泛采用的方法之一是 语义版本化(见 semver.org/)。语义版本化为我们提供了声明传递依赖要求的一套词汇,反过来又允许机器帮助我们整理它们。

当使用语义版本化时,请记住以下几点:

  • 主要 版本增量(1.x -> 2.x)是对 API 的破坏性更改,如上述 c) 和 d) 的情况。

  • 次要 版本增量(1.1 -> 1.2)是对向后兼容的添加,如案例 b)。

  • 修补 版本是对错误修复的增量(1.1.0 -> 1.1.1)。

虽然不是万无一失,但它至少提供了一个关于版本更新带来的变化级别的预期,并且在开源项目中广泛使用。

在尝到了依赖管理不容易的原因之后,请放心,Maven 和 Gradle 都提供了工具来帮助。在本章的后面部分,我们将详细探讨每个工具提供的内容,以解决当你遇到依赖冲突时的问题。

11.1.3 确保开发人员之间的一致性

随着项目代码量和参与开发者的增加,它们通常变得更加复杂,更难处理。虽然你的构建工具可以减轻这种痛苦,但内置功能,如确保每个人都在编译和运行相同的测试,是一个开始。但我们也应该考虑许多超出基本功能的添加。

测试是好的,但你有多确定 所有 你的代码都经过了测试?代码覆盖率工具对于检测你的测试击中了哪些代码以及哪些没有击中至关重要。尽管互联网上关于代码覆盖率正确目标的争论不断,但行级输出覆盖率工具可以帮助你避免遗漏那个额外的特殊条件测试。

作为一种语言,Java 也非常适合各种静态分析工具。从检测常见模式(例如,没有重写 hashCode 就重写 equals)到嗅探未使用的变量,静态分析可以让计算机验证代码的合法方面,但在生产中可能会给你带来麻烦。

然而,除了正确性之外,还有样式和格式化工具。你是否曾与某人争论过在语句中花括号应该放在哪里?如何缩进代码?一旦同意了一套规则,即使它们并不完全符合你的口味,也让你在项目上永远专注于实际工作,而不是纠结于代码的外观细节。

最后但同样重要的是,您的构建工具是提供自定义功能的关键中心点。对于您的项目,人们是否需要定期运行特殊的设置或操作命令?在部署之前但在构建之后,项目应该运行哪些验证?所有这些都非常适合考虑将其集成到构建工具中,以便所有与代码一起工作的人都可以使用。Maven 和 Gradle 都提供了许多扩展它们以适应您自己的逻辑和需求的方法。

希望你现在已经相信,构建工具不仅仅是项目上的一次性设置,理解它们是值得投资的。让我们先从最常见的一个开始:Maven。

11.2 Maven

在 Java 早期,Ant 框架是默认的构建工具。通过 XML 描述的任务,它允许比 Make 等工具更以 Java 为中心的脚本构建方式。但 Ant 缺乏关于如何配置您的构建的结构——步骤是什么,它们如何相关联,如何管理依赖关系。Maven 通过其标准化的 构建生命周期 和处理依赖关系的一致方法解决了许多这些差距。

11.2.1 构建生命周期

Maven 是一个有偏见的工具。这些偏见在构建生命周期中表现得尤为明显。与用户定义自己的任务并确定它们的顺序不同,Maven 有一个包含通常步骤的 默认生命周期,这些步骤被称为 阶段,您在构建中会期望看到这些阶段。虽然不是全面的,但以下阶段捕捉了默认生命周期的要点:

  • 验证—检查项目配置是否正确且可以构建

  • 编译—编译源代码

  • 测试—运行单元测试

  • 打包—生成如 JAR 文件等工件

  • 验证—运行集成测试

  • 安装—将包安装到本地仓库

  • 部署—使包结果可供他人使用,通常在 CI 环境中运行

这些步骤很可能对应于您从源代码到已部署的应用程序或库所采取的大部分步骤。这是 Maven 预设方法的一个主要优势——任何 Maven 项目都将共享这个相同的生命周期。您对如何运行构建的知识比以前更易于迁移。

Maven 中的阶段定义得很好,但每个项目在细节上都需要一些特殊的东西。在 Maven 的模型中,各种 插件目标 绑定到这些阶段。目标是一个具体的任务,包括如何执行它的实现。

除了默认的生命周期之外,Maven 还包括 清理站点 生命周期。清理 生命周期旨在进行清理(例如,删除中间构建结果),而 站点 生命周期旨在生成文档。

我们将在讨论扩展 Maven 时更详细地探讨如何挂钩生命周期,但如果您确实需要重新定义整个宇宙,Maven 支持编写完全自定义的生命周期。然而,这是一个非常高级的话题,超出了本书的范围。

11.2.2 命令/POM 简介

Maven 是 Apache 软件基金会的一个项目,并且是开源的。安装说明可以在项目网站上找到,网址为 maven.apache.org/install.html

通常,Maven 被安装在开发工作站的全球范围内,并且可以在任何非古老 JVM(JDK 7 或更高版本)上运行。一旦安装,调用它将给我们以下输出:

~: mvn

  [INFO] Scanning for projects...
  [INFO] ------------------------------------------------------------------
  [INFO] BUILD FAILURE
  [INFO] ------------------------------------------------------------------
  [INFO] Total time:  0.066 s
  [INFO] Finished at: 2020-07-05T21:28:22+02:00
  [INFO] ------------------------------------------------------------------
  [ERROR] No goals have been specified for this build. You must specify a
  valid lifecycle phase or a goal in the format <plugin-prefix>:<goal> or
  <plugin-group-id>:<plugin-artifact-id>[:<plugin-version>]:<goal>.
  Available lifecycle phases are: validate, initialize, ....

特别值得注意的是消息“此构建未指定任何目标。”这表明 Maven 对我们的项目一无所知。我们通过 pom.xml 文件提供这些信息,这是 Maven 项目的宇宙中心。

注意,POM 代表项目对象模型。

尽管完整的 pom.xml 文件可能非常长且复杂,但您可以用更少的配置开始。例如,一个更接近最小化的 pom.xml 文件看起来像这样:

<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.wellgrounded</groupId>                   ❶
  <artifactId>example</artifactId>                      ❶
  <version>1.0-SNAPSHOT</version>
  <name>example</name>

  <properties>
    <maven.compiler.source>11</maven.compiler.source>   ❷
    <maven.compiler.target>11</maven.compiler.target>
  </properties>
</project>

❶ 识别我们的项目

❷ Maven 插件默认使用 Java 1.6。显然,我们希望使用更新的版本。

我们的 pom.xml 文件声明了两个特别重要的字段:groupIdartifactId。这些字段与一个版本号结合,形成 GAV 坐标(组,构件,版本),它唯一地、全局地标识了您包的特定版本。groupId 通常指定负责库的公司、组织或开源项目,而 artifactId 是特定库的名称。GAV 坐标通常用冒号(:)分隔每个部分来表示,例如 org.apache.commons:collections4:4.4com.google.guava:guava:30.1-jre

这些坐标不仅对本地配置项目很重要。坐标作为依赖项的地址,因此我们的构建工具可以找到它们。以下章节将深入探讨我们如何更详细地表达这些依赖项的机制。

就像 Maven 标准化了构建生命周期一样,它也普及了我们之前在 11.1.1 节中看到的标准布局,并将在下面展示。如果您遵循这些约定,就不需要告诉 Maven 任何关于您项目的信息,它就能进行编译:

.
├── pom.xml
└── src
     ├── main
     │      └── java
     │              └── com
     │                      └── wellgrounded
     │                               └── Main.java
     └── test
         └── java
              └── com
                   └── wellgrounded
                        └── MainTest.java

注意到平行结构——src/main/javasrc/test/java——相同的目录映射到我们的包层次结构。这个约定将测试代码与主应用程序代码分开,这简化了打包主代码以部署的过程,排除了用户通常不需要或使用的测试代码。

除了这两个之外,还存在其他标准目录。例如,src/main/resources 是包含在 JAR 文件中的额外非代码文件的典型位置。有关 Maven 标准布局的完整列表,请参阅文档 mng.bz/6XoG

当你还在适应 Maven 时,坚持使用 Maven 提供的约定、标准布局和其他默认设置是个好主意。正如我们提到的,它是一个有偏见的工具,所以在学习过程中最好遵守它提供的规则。经验丰富的 Maven 开发者可以(并且确实)打破常规和规则,但让我们先学会走路再尝试跑步。

11.2.3 构建

我们之前看到,在命令行上运行 mvn 会警告我们需要选择一个生命周期阶段或目标才能实际执行操作。大多数情况下,我们都会想运行一个阶段,这可能包括许多目标。

开始的最简单方式是通过请求 compile 阶段来编译我们的代码,如下所示:

~: mvn compile

  [INFO] Scanning for projects...
  [INFO]
  [INFO] -------------------< com.wellgrounded:example >---------------
  [INFO] Building example 1.0-SNAPSHOT
  [INFO] -----------------------------[ jar ]--------------------------
  [INFO]
  [INFO] -- maven-resources-plugin:2.6:resources (default-resources) --
  [INFO] Using 'UTF-8' to copy filtered resources.                         ❶
  [INFO] Copying 0 resource
  [INFO]
  [INFO] ----- maven-compiler-plugin:3.1:compile (default-compile) ----
  [INFO] Changes detected - recompiling the module!                        ❷
  [INFO] Compiling 1 source file to ./maven-example/target/classes
  [INFO] --------------------------------------------------------------
  [INFO] BUILD SUCCESS
  [INFO] --------------------------------------------------------------
  [INFO] Total time:  0.940 s
  [INFO] Finished at: 2020-07-05T21:46:25+02:00
  [INFO] --------------------------------------------------------------

❶ 尽管我们的项目中没有资源,但默认生命周期中的 maven-resources-plugin 会为我们检查。

❷ 我们的实际编译是由 maven-compiler-plugin 提供的。

Maven 默认将输出目录设置为 target。在执行 mvn compile 后,我们可以在 target/classes 下找到类文件。仔细检查会发现我们只编译了 main 目录下的代码。如果我们想编译测试代码,可以使用 test-compile 阶段。

默认的生命周期不仅包括编译。例如,对于前面的项目,mvn package 将在 target/example-1.0-SNAPSHOT.jar 生成一个 JAR 文件。

虽然我们可以将这个 JAR 作为库使用,但如果尝试通过 java -jar target/ example-1.0-SNAPSHOT.jar 运行它,我们会发现 Java 抱怨找不到主类。为了了解我们如何开始构建 Maven 项目,让我们将其修改为生成的 JAR 是一个可运行的应用程序。

11.2.4 控制清单

Maven 从 mvn package 生成的 JAR 缺少一个 清单 来告诉 JVM 在启动时在哪里查找 main 方法。幸运的是,Maven 随带一个构建 JAR 的插件,该插件知道如何编写清单。该插件通过我们的 pom.xml 中的 properties 元素之后,仍然在 project 元素内部公开配置,如下所示:

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>            ❶
        <version>2.4</version>
        <configuration>                                      ❷
          <archive>
            <manifest>                                       ❸
              <addClasspath>true</addClasspath>
              <mainClass>com.wellgrounded.Main</mainClass>
              <Automatic-Module-Name>
                com.wellgrounded
              </Automatic-Module-Name>                       ❹
            </manifest>
          </archive>
        </configuration>
      </plugin>
    </plugins>
  </build>

❶ maven-jar-plugin 是插件名称。在运行 mvn package 命令的输出中可以轻松找到它。

❷ 每个插件都有自己的专用配置元素,具有不同的子元素和属性支持。

<manifest> 配置生成的 JAR 的清单内容。

❹ 配置我们的自动模块名称

添加这个部分设置主类,这样 java 启动器就知道如何直接执行 JAR。我们还添加了一个自动模块名称——这是为了在模块化世界中成为好公民。正如我们在第二章中讨论的,即使我们编写的代码不是模块化的(就像这个例子一样),提供显式的自动模块名称仍然是有意义的,这样模块化应用程序就可以更容易地使用我们的代码。

plugin 元素下设置配置的模式在 Maven 中非常标准。为了简化问题,大多数默认插件会友好地警告你使用不受支持或意外的配置属性,尽管具体细节可能因插件而异。

11.2.5 添加另一种语言

正如我们在第八章中讨论的,JVM 作为平台的一个优点是能够在同一个项目中使用多种语言。当特定语言在应用程序的某个部分有更好的功能时,这可能很有用,甚至可以允许应用程序从一种语言逐渐转换为另一种语言。

让我们看看如何配置我们的简单 Maven 项目,以便从 Kotlin 而不是 Java 构建一些类。幸运的是,我们的标准布局已经设置为允许轻松添加语言,如下所示:

.
├── pom.xml
└── src
     ├── main
     │      ├── java
     │      │      └── com
     │      │              └── wellgrounded
     │      │                       └── Main.java
     │      └── kotlin                                  ❶
     │               └── com
     │                       └── wellgrounded                  ❷
     │                                └── MessageFromKotlin.kt
     └── test
          └── java
               └── com
                    └── wellgrounded
                         └── MainTest.java

❶ 我们将 Kotlin 代码保存在自己的子目录中,这样就可以轻松地知道哪些路径使用哪个编译器来生成类文件。

❷ 包可以在语言之间混合,因为生成的类文件没有直接了解它们是从哪种语言生成的知识。

与 Java 不同,Maven 默认不知道如何编译 Kotlin,因此我们需要在 pom.xml 中添加 kotlin-maven-plugin。我们建议您查阅 Kotlin 文档 kotlinlang.org/docs/maven.html,以获取最新的使用方法,但我们将在此处演示,以便您知道可以期待什么。

如果一个项目完全用 Kotlin 编写,编译只需要添加并附加到 compile 目标的插件,如下所示:

  <build>
    <plugins>
      <plugin>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-maven-plugin</artifactId>
        <version>1.6.10</version>                      ❶
        <executions>
          <execution>
            <id>compile</id>
            <goals>                                    ❷
              <goal>compile</goal>
            </goals>
          </execution>
          <execution>
            <id>test-compile</id>
            <goals>                                    ❷
              <goal>test-compile</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

❶ 当本章编写时的 Kotlin 当前版本。

❷ 将此插件添加到编译主代码和测试代码的目标中。

当混合 Kotlin 和 Java 时,情况变得更加复杂。为我们编译 Java 的 Maven 默认 maven-compiler-plugin 需要被覆盖,以便 Kotlin 首先编译,如下所示,否则我们的 Java 代码将无法使用 Kotlin 类:

  <build>
    <plugins>
      <plugin>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-maven-plugin</artifactId>                       ❶
        <version>1.6.10</version>
        <executions>
          <execution>
            <id>compile</id>
            <goals>
              <goal>compile</goal>
            </goals>
            <configuration>
              <sourceDirs>                                                 ❷
                <sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
                <sourceDir>${project.basedir}/src/main/java</sourceDir>
              </sourceDirs>
            </configuration>
          </execution>
          <execution>
            <id>test-compile</id>
            <goals>
              <goal>test-compile</goal>
            </goals>
            <configuration>
              <sourceDirs>                                                 ❷
                <sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
                <sourceDir>${project.basedir}/src/test/java</sourceDir>
              </sourceDirs>
            </configuration>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.1</version>
        <executions>
          <execution>
            <id>default-compile</id>                                       ❸
            <phase>none</phase>
          </execution>
          <execution>
            <id>default-testCompile</id>                                   ❸
            <phase>none</phase>
          </execution>
          <execution>
            <id>java-compile</id>                                          ❹
            <phase>compile</phase>
            <goals>
              <goal>compile</goal>
            </goals>
          </execution>
          <execution>
            <id>java-test-compile</id>                                     ❹
            <phase>test-compile</phase>
            <goals>
              <goal>testCompile</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

❶ 主要添加 kotlin-maven-plugin 与之前类似,确保现在它知道 Java 和 Kotlin 路径

❷ Kotlin 编译器需要知道我们的 Kotlin 和 Java 代码位置。

❸ 禁用 maven-compiler-plugin 的默认值以构建 Java,因为这些默认值会强制它首先运行

❹ 重新应用 maven-compiler-plugin 到编译和测试编译阶段。现在这些将在 kotlin-maven-plugin 之后添加。

注意:当使用 Maven 功能如父项目时,上述覆盖可能会变得复杂,其中可能存在额外的 POM 定义冲突。当出现这些问题时,我们将很快看到一些调试策略。

您的项目至少需要一个对 Kotlin 标准库的依赖,因此我们明确添加如下:

<dependencies>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
        <version>1.6.10</version>
    </dependency>
</dependencies>

在此设置之后,我们的多语言项目可以像以前一样构建和运行。

11.2.6 测试

一旦代码构建完成,下一步就是对其进行测试。Maven 将测试深度集成到其生命周期中。实际上,主代码的编译只有一个阶段,而 Maven 支持开箱即用的两个独立的测试阶段:testintegration-testtest用于典型单元测试,而integration-test阶段在构建 JAR 等工件之后运行,目的是对最终输出进行端到端验证。

注意,集成测试也可以使用 JUnit 运行,因为尽管名称如此,JUnit 是一个非常强大的测试运行器,不仅限于单元测试。不要陷入这样的陷阱,认为 JUnit 执行的任何测试都是自动的单元测试!我们将在第十三章中详细检查不同类型的测试。

几乎任何项目都将从一些测试中受益。正如你所期望的,Maven 有自己的一套观点,测试默认使用几乎无处不在的框架 JUnit。其他框架只需一个插件即可。

尽管标准插件知道如何运行 JUnit,我们仍然必须将其作为依赖项声明,以便 Maven 知道如何编译我们的测试。你可以在<project>元素下添加如下片段来添加库:

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-api</artifactId>
      <version>5.8.1</version>
      <scope>test</scope>                              ❶
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-engine</artifactId>
      <version>5.8.1</version>
      <scope>test</scope>                              ❶
    </dependency>
  </dependencies>

<scope>表示这个库只需要在测试编译阶段使用。

在此基础上,我们可以尝试运行我们的单元测试。根据你的 Maven 版本,即使是最新版本也可能给出这个奇怪的结果:

~:mvn test

  [INFO] Scanning for projects...
  [INFO]
  [INFO] -------------------< com.wellgrounded:example >----------------
  [INFO] Building example 1.0-SNAPSHOT
  [INFO] --------------------------------[ jar ]------------------------
  [INFO]
  [INFO] .....
  [INFO]
  [INFO] -- maven-surefire-plugin:2.12.4:test (default-test) @ example -
  [INFO] Surefire report dir: ./target/surefire-reports                    ❶

  -------------------------------------------------------
   T E S T S
  -------------------------------------------------------
  Running com.wellgrounded.MainTest
  Tests run: 0, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.001 sec

  Results :

  Tests run: 0, Failures: 0, Errors: 0, Skipped: 0                         ❷

  [INFO] ------------------------------------------------
  [INFO] BUILD SUCCESS
  [INFO] ------------------------------------------------
  [INFO] Total time:  5.605 s
  [INFO] Finished at: 2021-11-29T09:41:06+01:00
  [INFO] ------------------------------------------------

❶ Maven 运行 JUnit 测试的默认插件是 maven-surefire-plugin。

❷ 没有运行测试?这显然是不对的!

由于兼容性原因,默认安装的插件maven-surefire-plugin,即使是在 Maven 3.8.4 这样的较晚版本,也不了解 JUnit 5。我们将在第十三章中更深入地探讨这些转换问题,但在此期间,让我们将插件的版本提升到更近期的版本,如下所示:

    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.0.0-M5</version>                       ❶
    </plugin>

❶ 如果在 2.12 之后移动,插件将直接理解 JUnit 5。

在此基础上,我们看到以下更令人放心的结果:

~:mvn test

  [INFO] .....

  -------------------------------------------------------
   T E S T S
  -------------------------------------------------------
  Running com.wellgrounded.MainTest
  Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.04 sec

  Results :

  Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

  [INFO] ------------------------------------------------
  [INFO] BUILD SUCCESS
  [INFO] ------------------------------------------------
  [INFO] Total time:  1.010 s
  [INFO] Finished at: 2020-07-06T15:45:22+02:00
  [INFO] -------------------------------------------------------------

默认情况下,Surefire 插件在test阶段运行标准位置的所有单元测试,即src/test/*。如果我们想利用integration-test阶段,建议使用单独的插件,例如maven-failsafe-plugin。Failsafe 由制作maven-surefire-plugin的同一群人维护,并专门针对集成测试案例。我们在之前用于配置清单的<build><plugins>部分添加了插件,如下所示:

  <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-failsafe-plugin</artifactId>
    <version>3.0.0-M5</version>
    <executions>
      <execution>
        <goals>
          <goal>integration-test</goal>
          <goal>verify</goal>
        </goals>
      </execution>
    </executions>
  </plugin>

Failsafe 将以下文件名模式视为集成测试,尽管它可以被重新配置:

  • **/IT*.java

  • **/*IT.java

  • **/*ITCase.java

因为它属于同一套插件,Surefire 也了解这个约定,并将这些测试排除在test阶段之外。

建议通过 mvn verify 运行集成测试,如下所示,而不是 mvn integration-testverify 包括 post-integration-test,这是插件附加任何后测试清理工作的典型位置:

~: mvn verify

  [INFO] ... compilation output omitted for length ...

  [INFO] --- maven-failsafe-plugin:3.0.0-M5:integration-test @ example ---
  [INFO]
  [INFO] -------------------------------------------------------
  [INFO]  T E S T S
  [INFO] -------------------------------------------------------
  [INFO] Running com.wellgrounded.LongRunningIT
  [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0,
  [INFO] Time elapsed: 0.032 s - in com.wellgrounded.LongRunningIT
  [INFO]
  [INFO] Results:
  [INFO]
  [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
  [INFO]
  [INFO]
  [INFO] --- maven-failsafe-plugin:3.0.0-M5:verify (default) @ example ---
  [INFO] -----------------------------------------------------------------
  [INFO] BUILD SUCCESS
  [INFO] -----------------------------------------------------------------

11.2.7 依赖管理

Maven 带给生态系统的关键特性是通过 pom.xml 文件以标准格式表达依赖管理信息。Maven 还建立了一个库的中央仓库。Maven 可以遍历 pom.xml 和依赖项的 pom.xml 文件,以确定应用程序所需的整个传递依赖项集。

遍历树并找到所有必要库的过程称为 依赖解析。虽然对于管理现代应用程序至关重要,但这个过程确实有其锋利的边缘。

为了了解问题出现在哪里,让我们回顾一下在 11.1.2 节中看到的早期项目设置。回想一下,项目的依赖项导致了一个看起来像图 11.3 所示的树。

图片

图 11.3 请求较旧版本的冲突传递依赖

在这里,我们明确请求了 lib-a 的 2.0 版本,但我们的依赖项 lib-b 请求了较旧的 1.0 版本。Maven 的依赖解析算法倾向于选择离根最近的库版本。图 11.3 所示配置的最终结果是,我们将在应用程序中使用 lib-a 2.0 版本。正如我们在 11.1.2 节中概述的,这可能会工作得很好,也可能导致灾难性的错误。

另一个可能导致问题的常见场景是,当发生反向情况,即离根最近的依赖项比预期的传递依赖项更旧,如图 11.4 所示。

图片

图 11.4 冲突的传递依赖,其中依赖项请求更高版本

在这种情况下,lib-d 可能依赖于 lib-c 中不存在于 3.0 版本的 API,因此将 lib-d 的依赖项添加到已经使用 lib-c 的项目中将导致运行时异常。

注意:鉴于这些可能性,我们建议任何与代码直接交互的包都应在您的 pom.xml 中明确声明。如果您不这样做,而是依赖于传递依赖项,更新直接依赖项可能会导致意外的构建中断。

在我们能够解决依赖问题之前,了解我们的依赖项非常重要。Maven 通过 mvn dependency:tree 命令为我们提供了支持,如下所示:

~:mvn dependency:tree
  [INFO] Scanning for projects...
  [INFO]
  [INFO] -------------------< com.wellgrounded:example >---------------
  [INFO] Building example 1.0-SNAPSHOT
  [INFO] -----------------------------[ jar ]--------------------------
  [INFO]
  [INFO] -- maven-dependency-plugin:2.8:tree (default-cli) @ example --
  [INFO] com.wellgrounded:example:jar:1.0-SNAPSHOT
  [INFO] +- org.junit.jupiter:junit-jupiter-api:jar:5.8.1:test
  [INFO] |  +- org.opentest4j:opentest4j:jar:1.2.0:test
  [INFO] |  +- org.junit.platform:junit-platform-commons:jar:1.8.1:test
  [INFO] |  \- org.apiguardian:apiguardian-api:jar:1.1.2:test
  [INFO] \- org.junit.jupiter:junit-jupiter-engine:jar:5.8.1:test
  [INFO]    \- org.junit.platform:junit-platform-engine:jar:1.8.1:test
  [INFO] --------------------------------------------------------------
  [INFO] BUILD SUCCESS
  [INFO] --------------------------------------------------------------
  [INFO] Total time:  0.790 s
  [INFO] Finished at: 2020-08-13T23:02:10+02:00
  [INFO] --------------------------------------------------------------

这个命令生成的树显示了我们直接依赖于 pom.xml 文件中的 JUnit,这是在嵌套的第一层,然后是 JUnit 自己的传递依赖项。

JUnit 有一组很小的依赖项,为了进一步探索传递依赖问题,让我们假设我们的团队想要使用我们公司内的两个内部库来支持执行自定义断言。这两个库都是使用 assertj 库构建的,但不幸的是,版本不同,如下所示:

  [INFO] com.wellgrounded:example:jar:1.0-SNAPSHOT
  [INFO] +- org.junit.jupiter:junit-jupiter-api:jar:5.8.1:test
  [INFO] |  +- org.opentest4j:opentest4j:jar:1.2.0:test
  [INFO] |  +- org.junit.platform:junit-platform-commons:jar:1.8.1:test
  [INFO] |  \- org.apiguardian:apiguardian-api:jar:1.1.2:test
  [INFO] +- org.junit.jupiter:junit-jupiter-engine:jar:5.8.1:test
  [INFO] |  \- org.junit.platform:junit-platform-engine:jar:1.8.1:test
  [INFO] +- com.wellgrounded:first-test-helper:1.0.0:test
  [INFO] |  \- org.assertj:assertj-core:3.21.0:test            ❶
  [INFO] \- com.wellgrounded:second-test-helper:2.0.0:test
  [INFO]    \- org.assertj:assertj-core:2.9.1:test             ❷

❶ 我们的第一辅助库带来了版本 3.21.0 的 assertj-core。

❷ 我们的第二个辅助库需要 assertj-core 版本 2.9.1。

最佳方法是在我们的依赖项中找到更新的版本,它们都可以就其依赖项达成一致。作为内部库,这显然是可能的。即使在更广泛的开源世界中,这也通常是可能的。话虽如此,有时库会失去其维护者而变得过时,因此完全有可能陷入难以获得我们所需更新的情况。

这使我们寻找其他处理冲突的方法。如果我们找不到自然的解决方案,两种主要方法就会发挥作用。请注意,这两种解决方案都需要找到某种兼容版本,以满足你的依赖项。

如果你的某个依赖项指定了一个大家都能同意的版本,但这个版本没有被 Maven 的解析算法选中,你可以告诉 Maven 在解析时排除树的部分。如果我们的两个辅助库都可以与较新的 assertj-core 正常工作,我们就可以忽略第二个库带来的旧版本,如下所示:

<dependencies>
    <dependency>
      <groupId>com.wellgrounded</groupId>
      <artifactId>second-test-helper</artifactId>
      <version>2.0.0</version>
      <scope>test</scope>
      <exclusions>                                 ❶
        <exclusion>
          <groupId>org.assertj</groupId>
          <artifactId>assertj-core</artifactId>
        </exclusion>
      </exclusions>
    </dependency>
    <dependency>
      <groupId>com.wellgrounded</groupId>          ❷
      <artifactId>first-test-helper</artifactId>
      <version>1.0.0</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

❶ 排除第二测试辅助库的过时版本 assertj-core

❷ 允许来自第一测试辅助库的传递依赖正常进行

在最坏的情况下,也许两个库都没有表达兼容的版本。为了处理这种情况,我们指定精确版本作为项目中的直接依赖项,如下面的代码示例所示。根据其解析规则,Maven 将选择该版本,因为它更接近项目根。虽然这说服了工具按照我们的意愿行事,但我们承担了混合库版本运行时错误的危险,因此彻底测试交互非常重要:

<dependencies>
    <dependency>
      <groupId>com.wellgrounded</groupId>
      <artifactId>second-test-helper</artifactId>   ❶
      <version>2.0.0</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>com.wellgrounded</groupId>
      <artifactId>first-test-helper</artifactId>    ❷
      <version>1.0.0</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.assertj</groupId>
      <artifactId>org.assertj</artifactId>
      <version>3.1.0</version>                      ❸
      <scope>test</scope>
    </dependency>
  </dependencies>

❶ 我们的依赖项将请求不同版本的 assertj-core。

❷ 我们的依赖项将请求不同版本的 assertj-core。

❸ 但我们强制将 assertj-core 的解析版本精确到我们想要的版本。

最后,值得注意的是,maven-enforcer-plugin 可以配置为在发现任何不匹配的依赖项时失败构建,这样我们就可以避免依赖于不良的运行时行为来暴露问题。(见 mng.bz/o2WN。)然后我们可以使用我们之前讨论的技术来解决这些构建失败。

11.2.8 审查

我们的构建过程是挂钩额外工具和检查的绝佳位置。一条关键信息是代码覆盖率,它告诉我们我们的测试执行了代码的哪些部分。

Java 生态系统中的代码覆盖率领先选项是 JaCoCo (mng.bz/nNjv)。JaCoCo 可以配置为在测试期间强制执行某些覆盖率级别,并将输出报告告诉你哪些被覆盖了,哪些没有被覆盖。

启用 JaCoCo 只需要在pom.xml文件的<build><plugins>部分添加一个插件。它默认不会启用,所以你必须告诉它何时执行。在这个例子中,我们将其绑定到test阶段,如下所示:

  <build>
    <plugins>
      <plugin>
        <groupId>org.jacoco</groupId>
        <artifactId>jacoco-maven-plugin</artifactId>
        <version>0.8.5</version>
        <executions>
          <execution>                      ❶
            <goals>
              <goal>prepare-agent</goal>
            </goals>
          </execution>
          <execution>                      ❷
            <id>report</id>
            <phase>test</phase>
            <goals>
              <goal>report</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

❶ JaCoCo 需要在过程早期开始运行。这将其添加到初始化阶段。

❷ 告诉 JaCoCo 在测试阶段报告

默认情况下,这将在target/site/jacoco目录中生成关于你所有类的报告,如图 11.5 所示,完整的 HTML 版本在index.html中供探索。

图片

图 11.5 JaCoCo 覆盖率报告页面

11.2.9 超越 Java 8

在第一章中,我们提到了以下一系列属于 Java 企业版但存在于核心 JDK 中的模块。这些模块在 JDK 9 中被弃用,在 JDK 11 中被移除,但作为外部库仍然可用:

  • java.activation (JAF)

  • java.corba (CORBA)

  • java.transaction (JTA)

  • java.xml.bind (JAXB)

  • java.xml.ws (JAX-WS,以及一些相关技术)

  • java.xml.ws.annotation (通用注解)

如果你的项目依赖于这些模块中的任何一个,当你迁移到更近期的 JDK 时,你的构建可能会中断。幸运的是,在pom.xml中添加几个简单的依赖项可以解决这个问题,如下所示:

<dependencies>
  <dependency>
    <groupId>com.sun.activation</groupId>               ❶
    <artifactId>jakarta.activation</artifactId>
    <version>1.2.2</version>
  </dependency>
  <dependency>
    <groupId>org.glassfish.corba</groupId>              ❷
    <artifactId>glassfish-corba-omgapi</artifactId>
    <version>4.2.1</version>
  </dependency>
  <dependency>
    <groupId>javax.transaction</groupId>                ❸
    <artifactId>javax.transaction-api</artifactId>
    <version>1.3</version>
  </dependency>
  <dependency>
    <groupId>jakarta.xml.bind</groupId>                 ❹
    <artifactId>jakarta.xml.bind-api</artifactId>
    <version>2.3.3</version>
  </dependency>
  <dependency>
    <groupId>jakarta.xml.ws</groupId>                   ❺
    <artifactId>jakarta.xml.ws-api</artifactId>
    <version>2.3.3</version>
  </dependency>
  <dependency>
    <groupId>jakarta.annotation</groupId>               ❻
    <artifactId>jakarta.annotation-api</artifactId>
    <version>1.3.5</version>
  </dependency>
</dependencies>

java.activation (JAF)

java.corba (CORBA)

java.transaction (JTA)

java.xml.bind (JAXB)

java.xml.ws (JAX-WS,以及一些相关技术)

java.xml.ws.annotation (通用注解)

11.2.10 Maven 中的多版本 JAR

JDK 9 中引入的一个特性是能够打包针对不同 JDK 的不同代码的 JAR 文件。这允许我们利用平台的新特性,同时仍然支持我们代码的旧版本客户端。

在第二章中,我们检查了特征并手工制作了必要的特定 JAR 格式,以启用此功能。布局将版本化的目录放置在 JAR 文件中的META-INF/versions下,从 JVM 9 开始,在加载过程中将检查给定类的较新版本,如下所示:

.
├── META-INF
│   ├── MANIFEST.MF
│   └── versions
│           └── 11
│                   └── wgjd2ed
│                           └── GetPID.class
└── wgjd2ed
     ├── GetPID.class
     └── Main.class

在这个结构中,wgjd2ed中的类将有一个表示 JAR 可能使用的最老 JVM 的类文件版本。(在我们的后续示例中,这将是在 JDK 8。)然而,在META-INF/versions/11下的类可以使用较新的 JDK 进行编译,并具有较新的类文件版本。因为较老的 JDK 忽略了META-INF/versions目录(以及从 9 开始的 JDK 理解它们允许使用哪些版本),我们可以在 JAR 中混合较新的代码,同时仍然在较老的 JVM 上正常工作。这正是 Maven 被构建来自动化的繁琐过程。

尽管我们的 JAR 中的输出格式对于启用多版本功能至关重要,但我们将模仿代码布局中的结构以提高清晰度。如下所示,src 中的代码是任何 JDK 默认将看到的基线功能。versions 下的代码可选地用替代实现替换特定类:

.
├── pom.xml
├── src
│      └── main
│              └── java
│                      └── wgjd2ed
│                               └── GetPID.java
│                                       └── Main.java
└── versions
    └── 11
         └── src
              └── wgjd2ed
                   └── GetPID.java

Maven 的默认设置将在 src/main 中找到并编译我们的代码,但我们有两个需要解决的复杂问题:

  • Maven 还需要 同样versions 目录中找到我们的代码。

  • 此外,Maven 需要针对与主项目不同的 JDK 编译该源代码。

这两个目标都可以通过配置构建我们的 Java 类文件的 maven-compiler-plugin 来实现。在下一个代码片段中,我们引入了两个单独的 <execution> 步骤——一个用于编译针对 JDK 8 的基本代码,然后是第二个步骤,用于编译针对 JDK 11 的版本化代码。

注意:我们必须使用至少与您要针对的最新版本一样新的 JDK 版本进行编译。然而,我们将明确指示一些构建步骤针对比编译器能够处理的更低版本。

  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.8.1</version>
      <executions>
        <execution>
          <id>compile-java-8</id>                     ❶
          <goals>
            <goal>compile</goal>
          </goals>
          <configuration>
            <source>1.8</source>                      ❷
            <target>1.8</target>
          </configuration>
        </execution>
        <execution>
          <id>compile-java-11</id>                    ❸
          <phase>compile</phase>
          <goals>
            <goal>compile</goal>
          </goals>
          <configuration>
            <compileSourceRoots>                      ❹
              <compileSourceRoot>
                ${project.basedir}/versions/11/src
              </compileSourceRoot>
            </compileSourceRoots>
            <release>11</release>                     ❺
            <multiReleaseOutput>                      ❺
              true
            </multiReleaseOutput>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>

❶ 编译针对 JDK 8 的执行步骤

❷ 我们将使用 JDK 11 进行编译,因此我们将此步骤的输出针对 JDK 8。

❸ 针对 JDK 11 的第二个执行步骤

❹ 告诉 Maven 关于版本特定代码的替代位置

❺ 设置发布和 multiReleaseOutput 告诉 Maven 这个版本化代码打算针对哪个 JDK,并要求它将类放在输出中的正确多版本位置。

这将构建我们的 JAR 并以正确的布局打包。还有一步,那就是将清单标记为多版本发布。这配置在 maven-jar-plugin 中,如下所示,接近我们在第 11.2.4 节中使应用程序 JAR 可执行的位置:

  <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <version>3.2.0</version>
    <configuration>
      <archive>
        <manifest>
          <addClasspath>true</addClasspath>
          <mainClass>wgjd2ed.Main</mainClass>
        </manifest>
        <manifestEntries>                          ❶
          <Multi-Release>true</Multi-Release>
        </manifestEntries>
      </archive>
    </configuration>
  </plugin>

❶ 标记 JAR 为多版本

通过这样,我们可以针对不同的 JDK 执行我们的代码并查看其行为是否符合预期。在我们的示例应用程序中,针对 JDK 8 的基本实现将输出一个额外的版本信息,如下所示,因此我们可以看到它正在工作:

~:mvn clean compile package
[INFO] Scanning for projects...
[INFO]
[INFO] ----------------< wgjd2ed:maven-multi-release >-------------------
[INFO] Building maven-multi-release 1.0-SNAPSHOT
[INFO] ----------------------------[ jar ]-------------------------------
[INFO]
[INFO] .... Lots of additional steps
[INFO]
[INFO] - maven-jar-plugin:3.2.0:jar (default-jar) @ maven-multi-release -
[INFO] Building jar: ~/target/maven-multi-release-1.0-SNAPSHOT.jar
[INFO] ------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------
[INFO] Total time:  1.813 s
[INFO] Finished at: 2021-03-05T09:39:16+01:00
[INFO] ------------------------------------------------------------------

~:java -version
openjdk version "11.0.6" 2020-01-14
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.6+10)
OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.6+10, mixed mode)

~:java -jar target/maven-multi-release-1.0-SNAPSHOT.jar
75891

# Change JDK versions by your favorite means....

~:java -version
openjdk version "1.8.0_265"
OpenJDK Runtime Environment (AdoptOpenJDK)(build 1.8.0_265-b01)
OpenJDK 64-Bit Server VM (AdoptOpenJDK)(build 25.265-b01, mixed mode)

~:java -jar target/maven-multi-release-1.0-SNAPSHOT.jar
Java 8 version...
76087

使用新 JDK 功能而无需放弃旧客户端的路径已经设置好了!

11.2.11 Maven 和模块

在第二章中,我们详细研究了 JDK 的新模块系统。让我们看看它如何影响我们的构建脚本。我们将从一个简单的库开始,该库公开其一个包,同时隐藏另一个包。

一个模块化库

模块化项目在代码布局上与严格的 Maven 标准略有不同。main目录反映了模块的名称,如下所示:

.
├── pom.xml
└── src
     └── com.wellgrounded.modlib                        ❶
          └── java
               └── com
                    └── wellgrounded
                         ├── hidden
                         │      └── CantTouchThis.java    ❷
                         └── visible
                              └── UseThis.java          ❸

❶ 我们的模块化代码目录

❷ 我们打算保持私有的一个类

❸ 我们打算通过我们的模块公开分享的班级

进行了此更改后,我们必须通知 Maven 新的位置以查找要编译的源代码,如下所示:

  <build>
    <sourceDirectory>src/com.wellgrounded.modlib/java</sourceDirectory>
  </build>

使我们的库成为模块化的最后一部分是在我们的代码根目录中添加一个module-info.java文件(与com目录并列)。这将命名我们的模块,并声明我们允许访问的内容,如下所示:

module com.wellgrounded.modlib {
    exports com.wellgrounded.modlib.visible;
}

这个简单的库的其他所有内容都保持不变,如果我们执行mvn package,我们将在target目录中得到一个 JAR 文件。在进一步操作之前,我们也可以通过mvn install将这个库放入本地 Maven 缓存中。

注意:JDK 的模块系统是关于构建和运行时的访问控制,不是打包。一个模块化库可以作为一个普通的 JAR 文件共享,只是额外包含module-info.class来告诉模块化应用程序如何与之交互。

现在我们有一个模块化库,让我们构建一个模块化应用程序来使用它。

一个模块化应用程序

我们模块化应用程序的布局与我们所用的库类似,如下所示:

.
├── pom.xml
└── src
     └── com.wellgrounded.modapp
          └── java
               ├── com
               │   └── wellgrounded
               │           └── Main.java
               └── module-info.java

我们的应用程序的module-info.java声明了我们的名称,并声明我们需要的包是由我们的库导出的,如下所示:

module com.wellgrounded.modapp {
    requires com.wellgrounded.modlib;
}

然而,仅此并不能告诉 Maven 在哪里找到我们的库 JAR 文件,因此我们将其作为正常的<dependency>包含,如下所示:

<dependencies>
    <dependency>
      <groupId>com.wellgrounded</groupId>   ❶
      <artifactId>modlib</artifactId>
      <version>2.0</version>
    </dependency>
  </dependencies>

❶ 我们上一节中的库已安装到本地 Maven 仓库中

当我们在编译并随后运行时,将这个依赖项放在模块路径上而不是类路径上是很重要的。Maven 是如何完成这个任务的?幸运的是,最近的maven-compiler-plugin版本足够智能,能够注意到 1)我们的应用程序有一个module-info.java,所以它是模块化的;2) 依赖项包括module-info.class,因此它也是一个模块。只要您使用的是maven-compiler-plugin的较新版本(在写作时 3.8 版本表现良好),Maven 就会为您解决这个问题。

我们的应用程序代码是完美的 Java 代码,我们可以按照预期使用模块化库的功能,如下所示:

package com.wellgrounded.modapp;

import com.wellgrounded.modlib.visible.UseThis;   ❶

public class Main {
  public static void main(String[] args) {
    System.out.println(UseThis.getMessage());     ❷
  }
}

❶ 从模块中导入,就像任何其他包一样。

❷ 使用我们模块中的类来获取消息

你可能还记得,在我们的库中还有一个我们没有提供访问权限的包。如果我们修改我们的应用程序来尝试拉取它,会发生什么,如下所示:

package com.wellgrounded.modapp;

import com.wellgrounded.modlib.visible.UseThis;
import com.wellgrounded.modlib.hidden.CantTouchThis;   ❶

public class Main {
  public static void main(String[] args) {
    System.out.println(UseThis.getMessage());
  }
}

com.wellgrounded.modlib.hidden没有被列入库的导出列表中。

编译这个将会立即给出以下错误:

[INFO] - maven-compiler-plugin:3.8.1:compile @ modapp ---
  [INFO] Changes detected - recompiling the module!
  [INFO] Compiling 2 source files to /mod-app/target/classes
  [INFO] -------------------------------------------------------------
  [ERROR] COMPILATION ERROR :
  [INFO] -------------------------------------------------------------
  [ERROR]                                                                 ❶
    src/com.wellgrounded.modapp/java/com/wellgrounded/Main.java:[4,31]
      package com.wellgrounded.modlib.hidden is not visible (package
      com.wellgrounded.modlib.hidden is declared in module
      com.wellgrounded.modlib, which does not export it)

  [INFO] 1 error
  [INFO] ------------------------------------------------------------
  [INFO] BUILD FAILURE
  [INFO] ------------------------------------------------------------

❶ javac 和模块系统甚至不允许我们尝试接触未导出的内容!

自从 JDK 9 中模块发布以来,Maven 的工具已经取得了很大的进步。所有标准场景都得到了很好的覆盖,并且只需要最少的额外配置。

在离开之前,让我们稍微偏离一下主题。在整个这一节中,module-info.class经常是 Maven 应该开始应用模块化规则的信号。但是,模块是 JDK 中的一个可选功能,以保持与大量预模块化代码的兼容性。

如果我们使用我们的模块化库构建相同的应用程序,但应用程序没有通过包含module-info.java文件来标记自己使用模块,会发生什么?在这种情况下,尽管库是模块化的,但它将通过类路径包含。这会将它放置在未命名的模块中,与应用程序自己的代码一起,并且我们定义在库中的所有访问限制都将被有效地忽略。一个示例应用程序包含在补充材料中,与使用我们的库通过类路径使用的模块化应用程序一起,这样你可以更清楚地看到选择进入或退出模块的工作方式。

到此为止,我们对 Maven 默认功能的探索就结束了。但如果我们需要扩展系统,而实际上我们可以在网上找到的插件种类繁多,我们该怎么办呢?

11.2.12 编写 Maven 插件

即使是 Maven 中最基本的默认设置也是作为插件提供的,而且没有理由我们不能在需要的时候编写一个。正如我们所看到的,引用一个插件与引入一个依赖库非常相似。因此,我们将 Maven 插件作为单独的 JAR 文件实现,这并不令人惊讶。

对于我们的示例,我们从pom.xml文件开始。大部分模板代码与之前相似,只是增加了一些小的修改,如下所示:

<project>
  <modelVersion>4.0.0</modelVersion>

  <name>A Well-Grounded Maven Plugin</name>
  <groupId>com.wellgrounded</groupId>
  <artifactId>wellgrounded-maven-plugin</artifactId>
  <packaging>maven-plugin</packaging>                                   ❶
  <version>1.0-SNAPSHOT</version>                                       ❷

  <properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
  </properties>

  <dependencies>                                                        ❸
    <dependency>
      <groupId>org.apache.maven</groupId>
      <artifactId>maven-plugin-api</artifactId>
      <version>3.0</version>
    </dependency>

    <dependency>
      <groupId>org.apache.maven.plugin-tools</groupId>
      <artifactId>maven-plugin-annotations</artifactId>
      <version>3.4</version>
      <scope>provided</scope>
    </dependency>
  </dependencies>
</project>

❶ 让 Maven 知道我们打算构建一个插件包

-SNAPSHOT是添加到尚未发布的库版本的一个典型后缀。当你引入库时,这会显示出来,因为你必须指定完整的字符串,例如,当请求依赖项时,必须指定 1.0-SNAPSHOT。

❸ 我们实现所需的大纲 Maven API 依赖项

这使我们准备好开始添加代码。将 Java 文件放置在标准布局位置,我们实现了一个称为Mojo的东西——实际上是一个 Maven 目标,如下所示:

package com.wellgrounded;

import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;

@Mojo(name = "wellgrounded")
public class WellGroundedMojo extends AbstractMojo
{
    public void execute() throws MojoExecutionException
    {
        getLog().info("Extending Maven for fun and profit.");
    }
}

我们的类扩展了AbstractMojo,并通过@Mojo注解告诉 Maven 我们的目标名称。方法体负责我们想要的任何工作。在这种情况下,我们只是记录一些文本,但此时你拥有完整的 Java 语言和生态系统来实施你的目标。

要在另一个项目中测试插件,我们需要执行mvn install,这将把我们的 JAR 文件放入本地缓存库。一旦在那里,我们就可以像本章中已经看到的所有其他“真实”插件一样,将我们的插件拉入另一个项目,如下所示:

  <build>
    <plugins>
      <plugin>
        <groupId>com.wellgrounded</groupId>                  ❶
        <artifactId>wellgrounded-maven-plugin</artifactId>
        <version>1.0-SNAPSHOT</version>
        <executions>
          <execution>                                        ❷
            <phase>compile</phase>
            <goals>
              <goal>wellgrounded</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

❶ 通过 groupId 和 artifactId 引用我们的插件坐标

❷ 将我们的目标绑定到编译阶段

在此基础上,我们可以在编译时看到我们的插件在行动,如下所示:

~: mvn compile
  [INFO] Scanning for projects...
  [INFO]
  [INFO] ------------------< com.wellgrounded:example >--------------
  [INFO] Building example 1.0-SNAPSHOT
  [INFO] ----------------------------[ jar ]-------------------------
  [INFO]
  [INFO] - maven-resources-plugin:2.6:resources (default-resources) -
  [INFO] Using 'UTF-8' encoding to copy filtered resources.
  [INFO] skip non existing resourceDirectory /src/main/resources
  [INFO]
  [INFO] --- maven-compiler-plugin:3.1:compile (default-compile)  ---
  [INFO] Nothing to compile - all classes are up to date
  [INFO]
  [INFO] --- wellgrounded-maven-plugin:1.0-SNAPSHOT:wellgrounded  ---
  [INFO] Extending Maven for fun and profit.                            ❶
  [INFO] ------------------------------------------------------------
  [INFO] BUILD SUCCESS
  [INFO] ------------------------------------------------------------
  [INFO] Total time:  0.872 s
  [INFO] Finished at: 2020-08-16T22:26:20+02:00
  [INFO] ------------------------------------------------------------

❶ 我们插件作为编译阶段的一部分运行

值得注意的是,如果我们只是包含插件而没有<executions>元素,我们不会在我们的项目中看到我们的插件出现在任何地方。自定义插件必须通过pom.xml文件在生命周期中声明它们希望的阶段。

了解生命周期以及哪些目标绑定到哪些阶段可能会很困难,但幸运的是,有一个插件可以帮助我们。buildplan-maven-plugin为你的当前任务提供了清晰度。

虽然它像任何其他插件一样可以包含在pom.xml中,但为了避免重复,一个有用的替代方案是将它放入用户自己的~/.m2/settings.xml文件中,如下所示。settings.xml文件与 Maven 中的pom.xml文件类似,但它们与任何特定项目无关:

<settings 

  xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
                      https://maven.apache.org/xsd/settings-1.0.0.xsd">
  <pluginGroups>
    <pluginGroup>fr.jcgay.maven.plugins</pluginGroup>
  </pluginGroups>
</settings>

一旦安装,你就可以像这样在任何使用 Maven 构建的项目中调用它:

~: mvn buildplan:list

  [INFO] Scanning for projects...
  [INFO]
  [INFO] --------------------< com.wellgrounded:example >-----------------
  [INFO] Building example 1.0-SNAPSHOT
  [INFO] ------------------------------[ jar ]----------------------------
  [INFO]
  [INFO] ---- buildplan-maven-plugin:1.3:list (default-cli) @ example ----
  [INFO] Build Plan for example:
  ------------------------------------------------------------------------
  PLUGIN               | PHASE           | ID                 | GOAL
  ------------------------------------------------------------------------
  jacoco-maven-plugin  | initialize      | default            | prep-agent
  maven-compiler-plugin| compile         | default-compile    | compile
  maven-compiler-plugin| test-compile    | default-testCompile| testCompile
  maven-surefire-plugin| test            | default-test       | test
  jacoco-maven-plugin  | test            | report             | report
  maven-jar-plugin     | package         | default-jar        | jar
  maven-failsafe-plugin| integration-test| default            | int-test
  maven-failsafe-plugin| verify          | default            | verify
  maven-install-plugin | install         | default-install    | install
  maven-deploy-plugin  | deploy          | default-deploy     | deploy
  [INFO] -----------------------------------------------------------------
  [INFO] BUILD SUCCESS
  [INFO] -----------------------------------------------------------------
  [INFO] Total time:  0.461 s
  [INFO] Finished at: 2020-08-30T15:54:30+02:00
  [INFO] -----------------------------------------------------------------

注意:如果你不想在pom.xmlsettings.xml中添加插件,你可以直接使用完全限定的插件名称来请求 Maven 运行命令!在我们的上一个例子中,我们可以说mvn fr.jcgay.maven .plugins:buildplan-maven-plugin:list,Maven 将下载插件并运行它一次。这对于不常见的任务或实验来说非常棒。Maven 关于编写插件的文档(见mng.bz/v6dx)非常详尽且维护良好,所以当你开始实现自己的插件时,请务必查看。

Maven 仍然是 Java 中最常见的构建工具之一,并且具有巨大的影响力。然而,并不是每个人都喜欢它的强烈立场。Gradle 是最受欢迎的替代方案,让我们看看它是如何处理相同的问题空间的。

11.3 Gradle

Gradle 在 Maven 之后出现,与 Maven 开创的许多依赖管理基础设施兼容。它支持熟悉的标准目录布局,并为 JVM 项目提供默认的构建生命周期,但与 Maven 不同,所有这些功能都是完全可定制的。

与 XML 不同,Gradle 在 Kotlin 或 Groovy 等实际编程语言之上使用声明性的领域特定语言(DSL)。这通常会导致简单情况下的简洁构建逻辑,并在事情变得更加复杂时提供很多灵活性。

Gradle 还包括一些性能特性,用于避免不必要的操作并增量处理任务。这通常提供了更快的构建和更高的可伸缩性。让我们通过查看如何运行 Gradle 命令来试试水。

11.3.1 安装 Gradle

Gradle 可以从其网站(gradle.org/install)安装。最新版本依赖于 JVM 版本 8 或更高。一旦安装,你可以在命令行中运行它,默认情况下会显示帮助,如下所示:

~: gradle

  > Task :help

  Welcome to Gradle 7.3.3.

  To run a build, run gradlew <task> ...

  To see a list of available tasks, run gradlew tasks

  To see more detail about a task, run gradlew help --task <task>

  To see a list of command-line options, run gradlew --help

  For more detail on using Gradle, see
    https://docs.gradle.org/7.3.3/userguide/command_line_interface.html

  For troubleshooting, visit https://help.gradle.org

  BUILD SUCCESSFUL in 606ms
  1 actionable task: 1 executed

这使得开始变得容易,但只有一个全局 Gradle 版本并不理想。对于开发者来说,构建多个不同项目是很常见的,每个项目可能需要不同版本的 Gradle。

为了处理这个问题,Gradle 引入了包装器的概念。gradle wrapper任务将捕获 Gradle 的特定版本并本地化到你的项目中。然后可以通过./gradlewgradlew.bat命令访问它。使用gradlew包装器被认为是一种良好的实践,以避免版本不兼容,因此你可能很少直接运行gradle

注意 建议您将包装器的 gradlegradlew* 结果包含在源代码控制中,但排除 .gradle 的本地缓存。

在提交了包装器之后,任何下载您的项目的人都可以获得正确版本化的构建工具,而无需进行任何额外的安装。

11.3.2 任务

Gradle 的关键概念是 任务。任务定义了一项可以调用的工作。任务可以依赖于其他任务,可以通过脚本进行配置,并通过 Gradle 的插件系统添加。这些类似于 Maven 的目标,但在概念上更像函数。它们有明确定义的输入和输出,可以组合和链接。而 Maven 的目标必须与构建生命周期的某个特定阶段相关联,Gradle 的任务可以以您方便的方式调用和使用。

Gradle 提供了出色的内省功能。其中最重要的是 ./gradlew tasks 元任务,它列出了项目中当前可用的任务。在您声明任何内容之前,运行 tasks 将显示以下任务列表:

~: ./gradlew tasks

  > Task :tasks

  ------------------------------------------------------------
  Tasks runnable from root project
  ------------------------------------------------------------

  Build Setup tasks
  -----------------
  init - Initializes a new Gradle build.
  wrapper - Generates Gradle wrapper files.

  Help tasks
   ----------
  buildEnvironment - Displays all buildscript dependencies in root project
  components - Displays the components produced by root project.
  dependencies - Displays all dependencies declared in root project.
  dependencyInsight - Displays insight for dependency in root project
  dependentComponents - Displays dependent components in root project
  help - Displays a help message.
  model - Displays the configuration model of root project. [incubating]
  outgoingVariants - Displays the outgoing variants of root project.
  projects - Displays the sub-projects of root project.
  properties - Displays the properties of root project.
  tasks - Displays the tasks runnable from root project.

向任何任务提供 --dry-run 标志将显示 Gradle 将要运行的任务,而不会执行这些操作。这对于理解您的构建系统流程或调试行为异常的插件或自定义任务非常有用。

11.3.3 脚本中有什么?

Gradle 构建的核心是其 构建脚本。这是 Gradle 与 Maven 之间的一个关键区别——不仅格式不同,整个哲学也不同。Maven POM 文件是基于 XML 的,而在 Gradle 中,构建脚本是用编程语言编写的可执行脚本——通常被称为 领域特定语言 或 DSL。Gradle 的现代版本支持 Groovy 和 Kotlin。

Groovy 与 Kotlin 的比较

Gradle 的 DSL 方法最初是基于 Groovy 的。正如我们在第八章简要介绍时所了解到的,Groovy 是 JVM 上的动态语言,并且它与灵活性和简洁的构建脚本目标非常契合。然而,自 Gradle 5.0 以来,另一个选项也已可用:Kotlin,我们在第九章中对其进行了详细讨论。

注意 Kotlin 构建脚本使用扩展名 .gradle.kts 而不是 .gradle

这非常有意义,因为 Kotlin 现在是 Android 开发的主导语言,而 Gradle 是该平台的官方构建工具。在整个项目的各个部分使用相同的语言可以是一个很好的简化因素。

对于我们的目的而言,Kotlin 更像 Java 而不是 Groovy。缩小这种语言差距意味着,如果您是 Gradle 生态系统的初学者,如果您正在用 Java 编码,那么使用 Kotlin 编写构建脚本可能是有意义的。

Groovy 仍然是一个突出且非常有前景的选择,但我们将加大 Kotlin 经验的投入,并使用它来展示我们接下来的所有示例。我们在这个章节中展示的任何内容都可以用具有相同 Gradle 行为的 Groovy 构建脚本以类似的方式表达。Gradle 文档展示了所有示例的 DSL。

11.3.4 使用插件

Gradle 使用插件来定义我们使用的所有任务。正如我们之前看到的,在空 Gradle 项目中列出任务不会说明构建、测试或部署。所有这些都来自插件。

Gradle 本身附带了许多插件,因此使用它们只需要在build.gradle.kts中进行声明。其中一个关键插件是base插件,如下所示:

  plugins {
    base
  }

在包含base插件后查看我们的任务,可以揭示一些我们可能期望的常见构建生命周期任务,如下所示:

~:./gradlew tasks

  > Task :tasks

  ------------------------------------------------------------
  Tasks runnable from root project
  ------------------------------------------------------------

  Build tasks
  -----------
  assemble - Assembles the outputs of this project.
  build - Assembles and tests this project.
  clean - Deletes the build directory.

  ... Other tasks omitted for length

  Verification tasks
  ------------------
  check - Runs all checks.

  ...

  BUILD SUCCESSFUL in 640ms
  1 actionable task: 1 executed

在此基础上,让我们开始构建我们的 Gradle 项目。

11.3.5 构建

虽然 Gradle 允许你随心所欲地进行自定义,但它默认期望与 Maven 建立和普及的相同代码布局。对于许多(也许甚至大多数)项目来说,改变这种布局没有意义,尽管这样做是可能的。

让我们从基本的 Java 库开始。为此,我们创建以下源代码树:

.
├── build.gradle.kts
├── gradle
│      └── wrapper
│              ├── gradle-wrapper.jar                    ❶
│              └── gradle-wrapper.properties             ❶
├── gradlew                                        ❶
├── gradlew.bat                                    ❶
├── settings.gradle.kts
└── src
     └── main
          └── java
               └── com
                    └── wellgrounded
                         └── AwesomeLib.java

❶ 这些文件是由 Gradle 包装命令自动创建的。

base插件对 Java 一无所知,因此我们需要一个更了解 Java 的插件。对于我们的纯 Java JAR 用例,我们将使用 Gradle 的java-library插件,如下所示。此插件基于base的所有必要部分构建——在实践中,你很少在 Gradle 构建中看到单独的base插件。这是因为插件可以应用其他插件来构建在其之上,就像面向对象编程中的组合一样:

plugins {
  `java-library`   ❶
}

❶ 在插件名称周围使用反引号(不是撇号),当它们包含特殊字符时,例如这里。

这将在我们的构建部分生成越来越多的任务,如下所示:

Build tasks
  -----------
  assemble - Assembles the outputs of this project.
  build - Assembles and tests this project.
  buildDependents - Assembles and tests this project and dependent projects.
  buildNeeded - Assembles and tests this project and dependent projects.
  classes - Assembles main classes.
  clean - Deletes the build directory.
  jar - Assembles a jar archive containing the main classes.
  testClasses - Assembles test classes.

在 Gradle 术语中,assemble是编译并打包 JAR 文件的任务。一个干燥运行会显示所有步骤,其中一些默认任务列表没有显示:

./gradlew assemble --dry-run
  :compileJava SKIPPED
  :processResources SKIPPED
  :classes SKIPPED
  :jar SKIPPED
  :assemble SKIPPED

运行./gradlew assemble会在build目录生成以下输出:

.
└── build
     ├── classes
     │      └── java
     │              └── main
     │                       └── com
     │                               └── wellgrounded
     │                                       └── Main.class
     └── libs
          └── wellgrounded.jar

制作应用程序

纯 JAR 是一个好的开始,但最终你想要运行一个应用程序。这需要更多的配置,但默认情况下这些组件都是可用的。

我们将更改插件,并告诉 Gradle 我们的应用程序的主类是什么。我们还可以看到 Kotlin 在这个简短的片段中带来的几个优秀特性:

plugins {                                                 ❶
  application                                             ❷
}

application {
  mainClass.set("wgjd.Main")
}

tasks.jar {                                               ❸
  manifest {
    attributes("Main-Class" to application.mainClass)     ❹
  }
}

❶ Kotlin 在最终参数是 lambda 表达式时可选的括号

❷ 知道如何编译和运行 Java 应用的插件

❸ 组装修改后的清单的 JAR 文件的任务

❹ Kotlin 使用 to 语法在原地声明一个哈希映射(也称为哈希字面量)。

使用./gradlew build构建会得到与之前相同的 JAR 输出,但如果我们执行java -jar build/libs/wellgrounded.jar,我们的测试程序将会运行。或者,application插件也支持./gradlew run来直接加载并执行你的主类。

注意:application 插件只需要设置 mainClass,但排除 tasks.jar 配置将导致一个 ./gradlew run 能够启动但 java -jar 无法启动的 JAR 文件。绝对不推荐这样做!

我们现在已经有了检查 Gradle 另一个关键功能的必要组件:它避免工作并减少我们的构建时间的能力。

11.3.6 避免工作

为了尽可能快地运行构建,Gradle 尝试避免重复不必要的操作。为此,一种策略是增量构建。Gradle 中的每个任务都声明其输入和输出。Gradle 使用这些信息来检查自上次构建运行以来是否有任何变化。如果没有变化,Gradle 将跳过该任务并重用之前构建的输出。

注意:在使用 Gradle 时,您不应该定期运行 clean,因为 Gradle 将确保只完成必要的——并且仅是必要的工作来生成构建结果。

我们可以通过查看一次完整运行(强制清洁)和第二次运行(如下所示)后的构建时间来看到这一点:

~: ./gradlew clean build

  BUILD SUCCESSFUL in 2s
  13 actionable tasks: 13 executed

  ~: ./gradlew build

  BUILD SUCCESSFUL in 804ms
  12 actionable tasks: 12 up-to-date

增量构建只能重用同一台计算机上同一位置的任务的最后一次执行的输出。Gradle 做得更好:构建缓存允许重用任何先前构建的任务输出——甚至是从其他地方运行的构建。

您可以通过带有 --build-cache 命令行标志的属性在项目中启用此功能。我们可以看到,即使是下面的 clean 构建也更快,因为它可以重用先前执行中的缓存输出:

~: ./gradlew clean build --build-cache

  BUILD SUCCESSFUL in 2s
  13 actionable tasks: 13 executed

  ~: ./gradlew clean build --build-cache

  BUILD SUCCESSFUL in 1s
  13 actionable tasks: 6 executed, 7 from cache

性能是 Gradle 的一个关键特性,它可以帮助您在代码规模扩大的同时保持项目构建时间的低效。其他一些我们不会涉及的能力包括增量 Java 编译、Gradle 守护进程以及并行任务和测试执行。

没有人是一座孤岛。同样,很少有应用程序在没有引入其他库依赖项的情况下能走得很远。这是 Gradle 的一个主要主题,也是与 Maven 有相当差异的一个点。

11.3.7 Gradle 中的依赖项

要开始引入依赖项,我们必须首先告诉 Gradle 它可以从哪些仓库下载。对于 mavenCentral(如下所示)和 google 有内置函数。您可以使用更详细的 API 来配置其他仓库,包括您自己的私有实例:

repositories {
  mavenCentral()
}

我们可以通过使用 Maven 流行的标准坐标格式来引入我们的依赖项。与 Maven 有 <scope> 元素来控制给定依赖项的使用位置类似,Gradle 通过 依赖配置 来表达这一点。每个配置跟踪一组特定的依赖项。您的插件定义了哪些配置可用,您可以通过函数调用向配置列表中添加。例如,为了拉取 SLF4J 库 (www.slf4j.org/) 以帮助进行日志记录,我们会使用以下配置:

dependencies {
    implementation("org.slf4j:slf4j-api:1.7.30")
    runtimeOnly("org.slf4j:slf4j-simple:1.7.30")
  }

在此示例中,我们的代码直接调用 slf4j-api 中的类和方法,因此它通过 implementation 配置被包含进来。这使得它在编译和运行应用程序时可用。尽管如此,我们的应用程序绝不应该直接调用 slf4j-simple 中的方法——这是严格通过 slf4j-api 来做的——因此请求 slf4j-simple 作为 runtimeOnly 确保代码在编译期间不可用,从而防止我们误用库。这实现了与 Maven 中依赖项的 <scope> 元素相同的目的。

我们使用的依赖项与我们类路径在运行时仅需要的依赖项之间的区别并不是区分依赖项差异的唯一方式。特别是对于库的作者来说,我们使用的库和作为我们公共 API 部分的库之间也有区别。如果一个依赖项是项目公共 API 的一部分,我们可以用 api 来标记它。在以下示例中,我们声明 Guava 是我们项目公共 API 的一部分:

  dependencies {
    api("com.google.guava:guava:31.0.1-jre")
  }

配置可以相互扩展,就像从基类派生一样。Gradle 在许多领域应用了这一特性。例如,在创建类路径时,Gradle 使用 compileClasspathruntimeClassPath,它们扩展了 implementationruntimeOnly。你不应该直接向 *Classpath 配置添加内容——我们添加到其基配置的依赖项将构建结果类路径配置,如图 11.6 所示。

图 11.6 Gradle 配置层次结构

表 11.1 展示了在使用 Gradle 伴随的 Java 插件时可用的一些主要配置,以及每个配置扩展了哪些其他配置。完整的列表可在 Java 插件文档中找到,网址为 mng.bz/445B

注意:Gradle 7.0 版本移除了一些长期废弃的配置,例如 compileruntime。如果你在网上浏览,可能会找到对这些配置的引用,但应转向较新的选项,即 implementation(或 api)和 runtimeOnly

表 11.1 典型的 Gradle 依赖项配置

名称 目的 扩展
api 作为项目外部公共 API 部分的依赖项
implementation 编译和运行期间使用的主要依赖项
compileOnly 仅在编译期间需要的依赖项
compileClasspath Gradle 用于查找编译类路径的配置 compileOnly, implementation
runtimeOnly 仅在运行时需要的依赖项
runtimeClasspath Gradle 用于查找运行时类路径的配置 runtimeOnly, implementation
testImplementation 编译和运行测试期间使用的依赖项 implementation
testCompileOnly 仅在测试编译期间需要的依赖项
testCompileClasspath Gradle 用于查找测试编译类路径的配置 testCompileOnly, testImplementation
testRuntimeOnly 仅在运行时需要的依赖项 runtimeOnly
testRuntimeClasspath Gradle 用于查找测试运行时类路径的配置 testRuntimeOnly, testImplementation
archives 我们项目输出的 JAR 列表

与 Maven 一样,Gradle 使用包信息来创建传递依赖树。然而,Gradle 处理版本冲突的默认算法与 Maven 的“最接近根的获胜”方法不同。在解析时,Gradle 会遍历整个依赖树以确定任何给定包的所有请求版本。从请求版本的完整集合中,Gradle 将默认选择可用的最高版本。

这种方法避免了 Maven 方法中的一些意外行为——例如,包的顺序/深度的变化可能会导致不同的解析。Gradle 还可以使用额外的信息,如丰富的版本约束来定制解析过程。更进一步,如果 Gradle 无法满足定义的约束,它将使用清晰的错误信息来失败构建,而不是选择可能存在问题的版本。

在这种情况下,Gradle 提供了丰富的 API 来覆盖和控制其解析行为。它还内置了强大的内省工具,当出现问题时会揭开面纱。当传递依赖问题出现时,一个关键的命令是./gradlew dependencies,如下所示:

~: ./gradlew dependencies

testImplementation - Implementation only dependencies for compilation 'test'
\--- org.junit.jupiter:junit-jupiter-api:5.8.1 (n)

... Other configurations skipped for length

testRuntimeClasspath - Runtime classpath of compilation 'test'
+--- org.junit.jupiter:junit-jupiter-api:5.8.1
|    +--- org.junit:junit-bom:5.8.1
|    |    +--- org.junit.jupiter:junit-jupiter-api:5.8.1 (c)
|    |    +--- org.junit.jupiter:junit-jupiter-engine:5.8.1 (c)
|    |    +--- org.junit.platform:junit-platform-commons:1.8.1 (c)
|    |    \--- org.junit.platform:junit-platform-engine:1.8.1 (c)
|    +--- org.opentest4j:opentest4j:1.2.0
|    \--- org.junit.platform:junit-platform-commons:1.8.1
|         \--- org.junit:junit-bom:5.8.1 (*)
\--- org.junit.jupiter:junit-jupiter-engine:5.8.1
     +--- org.junit:junit-bom:5.8.1 (*)
     +--- org.junit.platform:junit-platform-engine:1.8.1
     |    +--- org.junit:junit-bom:5.8.1 (*)
     |    +--- org.opentest4j:opentest4j:1.2.0
     |    \--- org.junit.platform:junit-platform-commons:1.8.1 (*)
     \--- org.junit.jupiter:junit-jupiter-api:5.8.1 (*)

testRuntimeOnly - Runtime only dependencies for compilation 'test'
\--- org.junit.jupiter:junit-jupiter-engine:5.8.1 (n)

在大型项目中,此输出可能令人不知所措,因此dependencyInsight允许你关注你关心的特定依赖项,如下所示:

~: ./gradlew dependencyInsight \
       --configuration testRuntimeClasspath \
       --dependency junit-jupiter-api

> Task :dependencyInsight
org.junit.jupiter:junit-jupiter-api:5.8.1 (by constraint)
   variant "runtimeElements" [
      org.gradle.category                 = library
      org.gradle.dependency.bundling      = external
      org.gradle.jvm.version              = 8 (compatible with: 11)
      org.gradle.libraryelements          = jar
      org.gradle.usage                    = java-runtime
      org.jetbrains.kotlin.localToProject = public (not requested)
      org.jetbrains.kotlin.platform.type  = jvm
      org.gradle.status                   = release (not requested)
   ]

org.junit.jupiter:junit-jupiter-api:5.8.1
+--- testRuntimeClasspath
+--- org.junit:junit-bom:5.8.1
|    +--- org.junit.platform:junit-platform-engine:1.8.1
|    |    +--- org.junit:junit-bom:5.8.1 (*)
|    |    \--- org.junit.jupiter:junit-jupiter-engine:5.8.1
|    |         +--- testRuntimeClasspath
|    |         \--- org.junit:junit-bom:5.8.1 (*)
|    +--- org.junit.platform:junit-platform-commons:1.8.1
|    |    +--- org.junit.platform:junit-platform-engine:1.8.1 (*)
|    |    +--- org.junit:junit-bom:5.8.1 (*)
|    |    \--- org.junit.jupiter:junit-jupiter-api:5.8.1 (*)
|    +--- org.junit.jupiter:junit-jupiter-engine:5.8.1 (*)
|    \--- org.junit.jupiter:junit-jupiter-api:5.8.1 (*)
\--- org.junit.jupiter:junit-jupiter-engine:5.8.1 (*)

(*) - dependencies omitted (listed previously)

依赖冲突可能难以解决。如果可能的话,最佳方法是使用 Gradle 中的依赖工具来查找不匹配并升级到相互兼容的版本。啊,要是能生活在一个这样的世界里,那该多好啊!

让我们回顾一下之前提到的例子,其中两个版本的内部辅助库引入了不兼容的主版本号的assertj。在这种情况下,first-test-helper依赖于assertj-core 3.21.0,而second-test-helper想要 2.9.1。

Gradle 的constraints提供了一种机制,告诉解析过程我们希望如何选择版本,如下所示:

dependencies {
  testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.1")
  testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1")

  testImplementation(
      "com.wellgrounded:first-test-helper:1.0.0")    ❶
  testImplementation(
      "com.wellgrounded:second-test-helper:2.0.0")   ❶

  constraints {
    testImplementation(
      "org.assertj:assertj-core:3.1.0") {            ❷
        because("Newer incompatible because...")     ❸
    }
  }
}

❶ 所有依赖项仍然像以前一样请求它们想要的。

❷ Gradle 将尊重此约束或失败解析。

❸ 使用这种做法是良好的实践,因为它可以记录我们干预的原因,Gradle 的工具可以使用这些文本,而脚本中的注释仅对人类读者有用。

如果你确实需要精确,可以使用strictly设置版本,这将覆盖任何其他解析,如下所示:

dependencies {
  testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.1")
  testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1")

  testImplementation(
    "com.wellgrounded:first-test-helper:1.0.0")       ❶
  testImplementation(
    "com.wellgrounded:second-test-helper:2.0.0")      ❶

  testImplementation("org.assertj:assertj-core") {
    version {
      strictly("3.1.0")                               ❷
    }
  }
}

❶ 所有依赖项仍然像以前一样请求它们想要的。

❷ 强制版本为 3.1.0。这不会匹配 3.1 或任何其他相关版本。

如果这些机制不够或者一个库在其列出的依赖项中存在错误,你也可以要求 Gradle 通过exclude忽略特定的组或工件,如下所示:

dependencies {
  testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.1")
  testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1")

  testImplementation(
    "com.wellgrounded:first-test-helper:1.0.0")      ❶
  testImplementation(
    "com.wellgrounded:second-test-helper:2.0.0") {   ❷
    exclude(group = "org.assertj")
  }
}

❶ 将选择来自 first-test-helper 的依赖项。

❷ Gradle 将忽略第二个辅助工具中的 org.assertj 依赖。

这是一个更激进的选项,但在这里所写的内容仅适用于我们应用了exclude的依赖项。如果我们能找到一个使用constraints的解决方案,从长远来看我们会更好。

如我们之前章节中提到的,手动强制依赖版本是一个最后的手段,并且需要特别注意以确保不会出现运行时异常。一个健壮的测试套件在确保你的库组合能够顺利协同工作时可以非常关键。

11.3.8 添加 Kotlin

正如我们在第八章和本章的 Maven 部分所讨论的,能够在项目中添加另一种语言是一个在 JVM 上运行的大好处。

添加 Kotlin 展示了 Gradle 脚本方法相对于 Maven 更静态的基于 XML 配置的优势。遵循我们原始代码的标准多语言布局产生以下结果:

.
├── build.gradle.kts
├── gradle
│      └── wrapper
│              ├── gradle-wrapper.jar
│              └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
     ├── main
     │      ├── java
     │      │      └── com
     │      │              └── wellgrounded
     │      │                      └── Main.java
     │      └── kotlin                                ❶
     │              └── com
     │                      └── wellgrounded
     │                               └── kotlin
     │                                        └── MessageFromKotlin.kt
     └── test
          └── java
               └── com
                    └── wellgrounded
                         └── MainTest.java

❶ 我们额外的 Kotlin 代码位于 kotlin 子目录下。

我们通过在build.gradle.kts中添加 Gradle 插件来启用 Kotlin 支持,如下所示:

  plugins {
     application
     id("org.jetbrains.kotlin.jvm") version "1.6.10"
  }

就这样。由于 Gradle 的灵活性,插件能够改变构建顺序并添加必要的kotlin-stdlib依赖,而无需我们采取额外的步骤。

11.3.9 测试

我们之前讨论的assemble任务将编译和打包你的主代码,但我们需要编译和运行测试。默认情况下,build任务就是为此配置的,如下所示:

./gradlew build --dry-run
  :compileJava SKIPPED
  :processResources SKIPPED
  :classes SKIPPED
  :jar SKIPPED
  :assemble SKIPPED
  :compileTestJava SKIPPED
  :processTestResources SKIPPED
  :testClasses SKIPPED
  :test SKIPPED
  :check SKIPPED
  :build SKIPPED

我们将使用标准位置添加一个测试用例,如下所示:

src
  └── test
       └── java
            └── com
                 └── wellgrounded
                      └── MainTest.java

接下来,我们需要将我们的测试框架添加到正确的依赖配置中,使其对我们的代码可用。我们还让 Gradle 知道,在运行测试任务时应该使用 JUnit,如下所示:

dependencies {
  ....
  testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.1")
  testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.1")
}

tasks.named<Test>("test") {
  useJUnitPlatform()
}

testImplementation配置使得org.junit.jupiter在构建和执行测试代码时可用——但不包括主代码。当我们再次运行./gradlew build时,你会看到如果它还没有在本地缓存中,它将下载库。

包括基于 HTML 的报告在内的完整列表和堆栈跟踪生成在build/reports/test下。

11.3.10 自动化静态分析

构建是一个添加功能以保护项目的绝佳地方。除了单元测试之外,还有一种检查方式是静态分析。这个类别中有几个工具,但 SpotBugs (spotbugs.github.io/)(FindBugs 的继任者)是一个容易上手的选择。请注意,这些工具中的大多数都有 Maven 和 Gradle 的插件,所以这里展示的处理方法只是为了给你一个可能性的尝鲜:

  plugins {
    application
    id("com.github.spotbugs") version "4.3.0"
  }

如果我们故意在我们的代码中引入问题(例如,在一个类上实现equals而不重写hashCode),典型的./gradlew check将让我们知道存在问题,如下所示:

~:./gradlew check

  > Task :spotbugsTest FAILED

  FAILURE: Build failed with an exception.

  * What went wrong:
  Execution failed for task ':spotbugsTest'.
  > A failure occurred while executing SpotBugsRunnerForWorker
     > Verification failed: SpotBugs violation found:
       2\. SpotBugs report can be found in build/reports/spotbugs/test.xml

  * Try:
  Run with --stacktrace option to get the stack trace.
  Run with --info or --debug option to get more log output.
  Run with --scan to get full insights.

  * Get more help at https://help.gradle.org

  BUILD FAILED in 1s
  5 actionable tasks: 3 executed, 2 up-to-date

就像单元测试失败一样,报告文件位于build/reports/spotbugs下。默认情况下,SpotBugs 可能只生成一个 XML 文件,虽然对计算机来说很好,但对大多数人来说不太有用。我们可以配置插件以生成以下 HTML:

tasks.withType<com.github.spotbugs.snom.SpotBugsTask>()    ❶
  .configureEach {                                         ❷
    reports.create("html") {                               ❸
      isEnabled = true
      setStylesheet("fancy-hist.xsl")
    }
}

tasks.withType以类型安全的方式为我们查找任务。

configureEach运行块,就像我们编写了tasks.spotbugsMain { }tasks.spotbugsTest { }具有相同代码一样。

❸ 剩余的配置来自 GitHub 上项目的 README(mng.bz/Qvdm)。

11.3.11 超越 Java 8

在第一章中,我们提到了以下一系列属于 Java 企业版但存在于核心 JDK 中的模块。这些模块在 JDK 9 中被弃用,在 JDK 11 中被移除,但作为外部库仍然可用:

  • java.activation (JAF)

  • java.corba (CORBA)

  • java.transaction (JTA)

  • java.xml.bind (JAXB)

  • java.xml.ws (JAX-WS,以及一些相关技术)

  • java.xml.ws.annotation (通用注解)

如果你的项目依赖于这些模块中的任何一个,当你迁移到更近期的 JDK 时,你的构建可能会中断。幸运的是,你可以在build.gradle.kts中添加以下简单的依赖关系来解决此问题:

dependencies {
  implementation("com.sun.activation:jakarta.activation:1.2.2")
  implementation("org.glassfish.corba:glassfish-corba-omgapi:4.2.1")
  implementation("javax.transaction:javax.transaction-api:1.3")
  implementation("jakarta.xml.bind:jakarta.xml.bind-api:2.3.3")
  implementation("jakarta.xml.ws:jakarta.xml.ws-api:2.3.3")
  implementation("jakarta.annotation:jakarta.annotation-api:1.3.5")
}

11.3.12 使用 Gradle 与模块

与 Maven 一样,Gradle 完全支持 JDK 模块系统。让我们分解一下我们需要更改的内容,以便使用 Gradle 使用我们的模块化项目。

一个模块化库

一个模块化库通常有两个主要结构差异:从使用main到在src目录下使用模块名称,以及在模块根目录中添加一个module-info.java文件,如下所示:

.
├── build.gradle.kts
├── gradle
│      └── wrapper
│              ├── gradle-wrapper.jar
│              └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
     └── com.wellgrounded.modlib                    ❶
          └── java
               ├── com
               │    wellgrounded
               │       ├── hidden                      ❷
               │       │      └── CantTouchThis.java
               │       └── visible                     ❸
               │               └── UseThis.java
               └── module-info.java                 ❹

❶ 目录名称与我们的模块对齐

❷ 我们打算保持此包隐藏。

❸ 此包将导出以供此模块外使用。

❹ 此模块的module-info.java声明

Gradle 不会自动找到我们更改的源位置,因此我们需要在build.gradle.kts中给出提示,如下所示:

sourceSets {
  main {
    java {
      setSrcDirs(listOf("src/com.wellgrounded.modlib/java"))
    }
  }
}

module-info.java文件包含我们在本章和第二章中早期展示的典型声明。我们将命名我们的模块,并选择一个,而不是两个,的包导出如下:

module com.wellgrounded.modlib {
    exports com.wellgrounded.modlib.visible;
}

这就是使我们的库作为模块可消费所需的所有内容。接下来,我们将从模块化应用程序中使用该库。

一个模块化应用程序

当我们开始使用 Maven 测试我们的模块化应用程序时,与我们的应用程序共享我们创建的库的最简单方法是将其安装到本地 Maven 仓库。这也通过maven-publish插件从 Gradle 支持,但我们还有一个值得了解其工作原理的选项。

我们模块化应用程序的标准布局如下所示。为了便于测试,我们将确保顶级目录相邻:

mod-lib                                              ❶
└── ...

mod-app
├── build.gradle.kts
├── gradle
│      └── wrapper
│              ├── gradle-wrapper.jar
│              └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
     └── com.wellgrounded.modapp                     ❷
          └── java
               ├── com
               │      └── wellgrounded
               │              └── Main.java
               └── module-info.java                  ❸

❶ mod-lib 库源代码与我们的 mod-app 应用程序处于同一级别。

❷ 目录名与模块名对齐。

❸ 我们使用module-info.java来声明这是一个模块化应用程序。

我们的module-info.java文件显示了我们的名称和模块要求,如下所示:

module com.wellgrounded.modapp {        ❶
    requires com.wellgrounded.modlib;   ❷
}

❶ 我们的模块名称

❷ 我们对库导出包的要求

对于测试我们的本地库,而不是安装它,我们暂时将其本地引用,如下所示的下一段代码。这可以通过在之前提供依赖项 GAV 坐标的位置使用files函数来实现。显然,一旦我们准备好开始共享和部署,这就不适用了,但这是一个快速开始本地测试的方法:

dependencies {
  implementation(files("../mod-lib/build/libs/gradle-mod-lib.jar"))
}

接下来,当前版本的 Gradle 需要我们提供一个提示,表示我们希望它检测哪些依赖项是模块化的,以便正确地将它们放在模块路径上而不是类路径上,如下所示。这最终可能成为默认设置,但在此写作时(Gradle 7.3),它仍然是一个可选设置:

java {
  modularity.inferModulePath.set(true)
}

最后,也是最平凡的,就像我们的库一样,我们需要让 Gradle 知道我们的非 Maven 标准文件位置,如下所示:

sourceSets { 
  main {
    java {
      setSrcDirs(listOf("src/com.wellgrounded.modapp/java"))
    }
  }
}

在所有这些准备就绪后,./gradlew build run会产生预期的结果。如果我们尝试使用未导出的库包,我们将在编译时遇到如下错误:

> Task :compileJava FAILED
/mod-app/src/com.wellgrounded.modapp/java/com/wellgrounded/Main.java:4:
error: package com.wellgrounded.modlib.hidden is not visible

import com.wellgrounded.modlib.hidden.CantTouchThis;
                              ^
  (package com.wellgrounded.modlib.hidden is declared in module
   com.wellgrounded.modlib, which does not export it)
1 error

JLink

在第二章中我们看到的模块解锁的能力之一是,可以为应用程序创建一个仅包含其所需依赖项的精简环境。这是可能的,因为模块系统为我们提供了关于我们的代码使用哪些模块的明确保证,因此工具可以构建必要的、最小的模块集。

注意 JLink 只能与完全模块化的应用程序一起工作。如果一个应用程序仍然通过类路径加载一些代码,JLink 将无法成功创建一个安全、完整的镜像。

这个功能通过jlink工具最为明显。对于模块化应用程序,JLink 可以生成一个完全功能的 JVM 镜像,可以在不依赖于系统安装的 JVM 的情况下运行。

让我们回顾一下第二章中我们使用 JLink 演示的应用程序,看看 Gradle 插件如何简化管理。该示例应用程序可在补充材料中找到,它使用 JDK 类连接到机器上所有正在运行的 JVM 进程,并显示有关它们的各种信息。

在我们要打包的模块化应用程序中,一个重要的审查点是应用程序自己的module-info.java声明。如下所示,这些声明告诉我们 JLink 需要将其拉入自定义镜像以使我们的构建工作:

module wgjd.discovery {
  exports wgjd.discovery;

  requires java.instrument;
  requires java.logging;
  requires jdk.attach;
  requires jdk.internal.jvmstat;   ❶
}

❶ 警示标志:注意我们正在访问的jdk.internal包!

在我们开始使用 JLink 之前,从手动编译到 Gradle 构建需要一些额外的配置。我们需要将前面章节中解释的相同模块化更改作为起点应用,如下所示。但即使这些更改到位,我们也无法成功编译:

~:./gradlew build

> Task :compileJava FAILED
/gradle-jlink/src/wgjd.discovery/wgjd/discovery/VMIntrospector.java:4:
error: package sun.jvmstat.monitor is not visible
  import sun.jvmstat.monitor.MonitorException;
                    ^
  (package sun.jvmstat.monitor is declared in module jdk.internal.jvmstat,
   which does not export it to module wgjd.discovery)

/gradle-jlink/src/wgjd.discovery/wgjd/discovery/VMIntrospector.java:5:
error: package sun.jvmstat.monitor is not visible
  import sun.jvmstat.monitor.MonitoredHost;
                    ^
  (package sun.jvmstat.monitor is declared in module jdk.internal.jvmstat,
   which does not export it to module wgjd.discovery)

/gradle-jlink/src/wgjd.discovery/wgjd/discovery/VMIntrospector.java:6:
error: package sun.jvmstat.monitor is not visible
  import sun.jvmstat.monitor.MonitoredVmUtil;
                    ^
  (package sun.jvmstat.monitor is declared in module jdk.internal.jvmstat,
   which does not export it to module wgjd.discovery)

/gradle-jlink/src/wgjd.discovery/wgjd/discovery/VMIntrospector.java:7:
error: package sun.jvmstat.monitor is not visible
  import sun.jvmstat.monitor.VmIdentifier;
                    ^
  (package sun.jvmstat.monitor is declared in module jdk.internal.jvmstat,
   which does not export it to module wgjd.discovery)

4 errors

FAILURE: Build failed with an exception.

模块系统让我们知道,我们正在试图使用jdk.internal.jvmstat中的类,这是违反规则的。我们的模块wgjd.discovery不包括在允许的jdk.internal.jvmstat模块列表中。了解规则和我们所承担的风险后,我们可以使用--add-exports来强制我们的模块进入列表。这是通过编译器标志完成的,并在我们的 Gradle 配置中看起来如下所示:

tasks.withType<JavaCompile> {
  options.compilerArgs = listOf(
      "--add-exports",
      "jdk.internal.jvmstat/sun.jvmstat.monitor=wgjd.discovery")
}

这样我们就得到了干净的编译,我们可以转向使用 JLink 来打包它。目前最具影响力的插件是org.beryx.jlink,在文档中被称为“坏小子 JLink 插件”(badass-jlink-plugin.beryx.org)。我们通过插件行将其添加到我们的 Gradle 项目中。

plugins {
  id("org.beryx.jlink") version("2.23.3")   ❶
}

❶ 此插件会自动为我们应用,因此我们不需要重复该声明。

在添加之后,我们将在列表中看到jlink任务,我们可以立即运行它。结果将显示在build/image目录中,如下所示:

build/image/
├── bin
│      ├── gradle-jlink
│      ├── gradle-jlink.bat
│      ├── java
│      └── keytool
├── conf
│      └── ... various configuration files
├── include
│      └── ... require headers
├── legal
│      └── ... license and legal information for all included modules
├── lib
│      └── ... library files and dependencies for our image
└── release

build/image/bin/java是我们定制的 JVM,它只对我们的应用程序模块依赖项可用。您可以从终端像运行正常的java命令一样运行它,如下所示:

~:build/image/bin/java -version
openjdk version "11.0.6" 2020-01-14
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.6+10)
OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.6+10, mixed mode)

我们可以将我们的模块传递给build/image/bin/java以启动,但插件已经为我们干净利落地生成了一个启动脚本,位于build/image/bin/gradle-jlink(以我们的项目命名,如下所示),我们可以使用它代替。但我们的新铸造镜像并非一切顺利:

~:build/image/bin/gradle-jlink

Java processes:
PID    Display Name    VM Version    Attachable
Exception in thread "main" java.lang.IllegalAccessError:
 class wgjd.discovery.VMIntrospector (in module wgjd.discovery) cannot
   access class sun.jvmstat.monitor.MonitorException (in module
   jdk.internal.jvmstat) because module jdk.internal.jvmstat does not
   export sun.jvmstat.monitor to module wgjd.discovery
 wgjd.discovery/wgjd.discovery.VMIntrospector.accept(VMIntrospector.java:19)
 wgjd.discovery/wgjd.discovery.Discovery.main(Discovery.java:26)

这不是完全陌生的错误消息——这是我们在早期通过编译器选项解决的相同访问问题的另一种风味。显然,我们需要通知应用程序启动我们的模块欺骗需求。幸运的是,插件为我们提供了广泛的配置,包括运行jlink和为我们创建的脚本,如下所示:

jlink {
  launcher{
    jvmArgs = listOf(
                "--add-exports",
                "jdk.internal.jvmstat/sun.jvmstat.monitor=wgjd.discovery")
  }
}

添加此功能后,启动脚本将按以下方式运行一切:

~:build/image/bin/gradle-jlink
Java processes:
PID    Display Name    VM Version    Attachable
833 wgjd.discovery/wgjd.discovery.Discovery    11.0.6+10    true
276 org.jetbrains.jps.cmdline.Launcher /Applications/IntelliJ IDEA CE.app...

值得注意的是,我们在这里生成的镜像默认针对的是 JLink 正在运行的相同操作系统,如下一个代码示例所示。然而,这并不是必需的——跨平台支持是可用的。主要要求是您有目标平台 JDK 安装的文件可用。这些文件可以从 Eclipse Adoptium 网站等来源轻松获得:adoptium.net/

jlink {
  targetPlatform("local",
                  System.getProperty("java.home"))    ❶
  targetPlatform("linux-x64",
                  "/linux_jdk-11.0.10+9")             ❷

  launcher{
    jvmArgs = listOf(
                "--add-exports",
                "jdk.internal.jvmstat/sun.jvmstat.monitor=wgjd.discovery")
  }
}

❶ 基于本地 JDK 构建镜像

❷ 构建指向我们已下载的 Linux JDK 的镜像

一旦您开始针对特定平台,插件将在build/image结果中放置额外的目录。显然,您必须将这些结果带到匹配的系统中去测试它们。

在尝试使用 JLink 时可能会遇到的一个最终障碍是其对自动命名模块的限制。虽然将名称添加到 JAR 清单中并获得参与模块化世界的基本能力对于迁移来说是个不错的功能,但遗憾的是,JLink 不支持它。

然而,Badass JLink Plugin 可以解决您的问题。它将重新打包任何自动命名的模块,使其成为 JLink 可以消费的正确模块。文档(可在mng.bz/XZ2Y找到)对此功能进行了全面介绍,这可能是 JLink 是否工作以及根据您的应用程序依赖项的不同而有所区别的关键。

11.3.13 自定义

Gradle 最大的优势之一是其开放式的灵活性。不引入插件,它甚至没有构建生命周期的概念。您可以添加任务,并重新配置现有任务,几乎没有限制。不需要在项目中保留scripts目录以及随机的工具——您的自定义需求可以直接集成到您的日常构建和测试工具中。

自定义任务

定义自定义任务可以直接在您的build.gradle.kts文件中这样做:

  tasks.register("wellgrounded") {
    println("configuring")
    doLast {
      println("Hello from Gradle")
    }
  }

运行此命令将产生以下输出:

~: ./gradlew wellgrounded
  configuring...

  > Task :wellgrounded
  Hello from Gradle

println("configuring")这一行在任务设置期间运行,但doLast块的内容是在任务实际运行时发生的。我们可以通过以下方式对任务进行 dry-run 来确认这一点:

~: ./gradlew wellgrounded --dry-run
  configuring...
  :wellgrounded SKIPPED

可以配置任务以依赖于其他任务,如下所示:

  tasks.register("wellgrounded") {
    println("configuring...")
    dependsOn("assemble")
    doLast {
      println("Hello from Gradle")
    }
  }

这种技术同样适用于您没有编写的任务——您可以查找它们,并将您的任务作为依赖项添加,如下所示:

  tasks {
    named<Task>("help") {
      dependsOn("wellgrounded")
    }
  }
~: ./gradlew help
  configuring...

  > Task :wellgrounded
  Hello from Gradle

  > Task :help

  Welcome to Gradle 7.3.3.

  To run a build, run gradlew <task> ...

  To see a list of available tasks, run gradlew tasks

  To see more detail about a task, run gradlew help --task <task>

  To see a list of command-line options, run gradlew --help

  For more detail on using Gradle, see
    https://docs.gradle.org/7.3.3/userguide/command_line_interface.html

  For troubleshooting, visit https://help.gradle.org

能够直接在构建文件中编写自定义任务非常强大。然而,将它们放在build.gradle.kts中确实有几个相当严重的限制:它们不能很容易地在项目之间共享,而且编写针对它们的自动化测试也不容易。Gradle 插件就是为了解决这些问题而构建的。

创建自定义插件

Gradle 插件作为 JVM 代码实现。它们可以直接作为源文件提供到您的项目中,或者通过库来引入。许多插件是用 Groovy 编写的,这是 Gradle 最初支持的脚本语言,但您可以使用任何 JVM 语言。如果您打算分享您的插件,用 Java 编写是一个好主意,以实现最大的兼容性和最小化特定语言习惯用法的问题。

插件可以直接在您的构建脚本中编码,我们将使用该技术演示主要 API。当您准备好分享时,可以将代码拉入其自己的独立项目。以下是我们之前wellgrounded任务的等效代码:

    class WellgroundedPlugin : Plugin<Project> {   ❶
      override fun apply(project: Project) {
          project.task("wellgrounded") {           ❷
              doLast {
                  println("Hello from Gradle")
              }
          }
      }
  }

  apply<WellgroundedPlugin>()                      ❸

❶ 继承自 Plugin

❷ 使用熟悉的 project-level API 和任务实现

❸ 使用apply来实际使用插件——它不像我们之前的任务定义那样自动调用。

除了共享之外,将作者任务作为插件可以让我们有更多的能力来自定义配置。代表我们的Project的标准 Gradle 对象在extensions属性下有一个特定的位置来存放插件配置。我们可以通过以下方式添加自己的Extension对象来扩展这些扩展:

  open class WellgroundedExtensions {
    var count: Int = 1
  }

  class WellgroundedPlugin : Plugin<Project> {
    override fun apply(proj: Project) {
      val extensions = proj.extensions
      val ext = extensions.create<WellgroundedExtensions>("wellgrounded")
      proj.task("wellgrounded") {
        doLast {
          repeat(ext.count) {
            println("Hello from Gradle")
          }
        }
      }
    }
  }

  apply<WellgroundedPlugin>()

  configure<WellgroundedExtensions> {
    count = 4
  }

我们编程语言的所有功能都在我们的插件中可用。

如果你将插件提取到另一个库中,你可以通过我们之前看到的包括 SpotBugs 插件的方式,通过相同的机制将插件包含在你的构建中,如下所示:

  plugins {
    id("com.wellgrounded.gradle") version "1000.0"
  }

  apply<WellgroundedPlugin>()

  configure<WellgroundedExtensions> {
    count = 4
  }

摘要

  • 构建工具在现实世界中构建 Java 软件的方式中起着核心作用。它们自动化繁琐的操作,帮助管理依赖关系,确保开发者工作的一致性,并且,关键的是,确保在不同机器上构建的相同项目得到相同的结果。

  • Maven 和 Gradle 是 Java 生态系统中最常用的两个构建工具,大多数任务都可以在这两个工具中完成。

    • Maven 采用了一种通过 XML 进行配置并结合使用 JVM 代码编写的插件的方法。

    • Gradle 提供了一个使用实际编程语言(Kotlin 或 Groovy)的声明式构建语言,对于简单情况,这导致了简洁的构建逻辑,对于复杂用例,则提供了灵活性。

  • 处理冲突的依赖关系是无论你的构建工具是什么都是一个主要话题。Maven 和 Gradle 都为你提供了处理库版本冲突的方法。Gradle 提供了更多高级功能来处理常见的依赖关系管理问题。

  • Gradle 提供了诸如增量构建等避免工作的功能,从而实现了更快的构建。

  • 模块,如第二章所述,需要对我们的构建脚本和源代码布局进行一些修改,但这些修改得到了工具的良好支持。

12 在容器中运行 Java

本章涵盖

  • 为什么容器驱动的开发对于扎实的 Java 开发者很重要

  • 操作系统、虚拟机、容器和编排之间的区别

  • Docker

  • Kubernetes

  • 在容器中运行 Java 工作负载的实用指南

  • 容器中的性能和可观察性

Docker (www.docker.com/) 容器已成为打包 Java 应用程序进行部署的事实标准,而 Kubernetes (kubernetes.io/)(k8s)是编排这些容器的最受欢迎的选择。特别是如果您要将应用程序部署到任何主要云服务提供商,您将需要了解这些技术,更重要的是,了解 Java 与它们的交互行为。

注意:尽管存在其他容器和容器编排技术,但 Docker 和 Kubernetes 分别在容器和编排市场中占据主导地位。

12.1 为什么容器对于扎实的开发者很重要

为了更好地理解容器是什么以及为什么它们对于一个扎实的 Java 开发者来说很重要,我们将查看以下内容:

  • 宿主操作系统与虚拟机与容器的比较

  • 容器的优点

  • 容器的缺点

12.1.1 宿主操作系统与虚拟机与容器的比较

自从计算机的早期阶段起,我们就一直在在软件和它运行的硬件之间引入抽象层。容器是这一进步中的另一个自然步骤。让我们简要地浏览这些层,看看容器是如何嵌入其中的。

裸金属

让我们从最基本的概念开始——一个没有安装宿主操作系统的 裸金属机器。这块裸金属机器代表了一组有限资源,这些资源可以提供给可能安装在其上的任何软件,包括 CPU、RAM、硬盘、网络等等。

注意:这个有限资源的概念是您需要牢记的关键概念。开发者常常被误导,认为容器以某种方式给了他们神奇的无尽资源!

总要记住,在宿主操作系统、虚拟机或容器之下,是一块带有 有限资源 的裸金属。

宿主操作系统或类型 1 虚拟化程序

在现代数据中心,裸金属机器上要么安装了宿主操作系统(例如,Linux)或类型 1 虚拟化程序(例如,VMWare ESXi、Microsoft Hyper-V)。虚拟化程序是能够创建和管理虚拟机的软件的术语。虚拟化程序可以存在于堆栈的多个层级。类型 1 虚拟化程序安装在裸金属上,充当轻量级操作系统,将机器的大部分资源分配给其运行的虚拟机。

无论运行的是传统操作系统还是虚拟机管理程序,这一层通常是轻量级的,并且做的不仅仅是确保安全保证,并允许在顶部安装高级抽象。尽管如此,宿主操作系统运行确实需要一些 CPU、RAM 和网络资源。

Type 2 虚拟机管理程序

如果我们的裸机安装了传统的操作系统,如 Linux,那么下一层通常是 Type 2 虚拟机管理程序。无论是 Type 1 还是 Type 2,虚拟机管理程序负责管理带有客户操作系统的虚拟机(VMs)的底层硬件资源。

例如,一台裸机机器,拥有 32 GB 的 RAM 和 16 核心 CPU,运行 Linux 宿主操作系统,可以运行一个 Type 2 虚拟机管理程序,该虚拟机管理程序反过来可以托管四个虚拟机(VMs),每个虚拟机运行一个 Linux 客户操作系统,看起来每个虚拟机有 8 GB 的 RAM 和 4 个 CPU 核心。现代虚拟机管理程序通常不会占用太多底层资源来运行自身。如果直接在我们的裸机上使用 Type 1 虚拟机管理程序,它就可以准备运行下一层,即虚拟机,而无需额外的干预。

虚拟机

每个虚拟机都是完全自包含的。对于用户来说,它有自己的 CPU、RAM、网络和磁盘资源。当你登录到生产环境中的服务器时,很可能会登录到一个虚拟机而不是裸机服务器。

自包含的虚拟机也有自己的操作系统,称为客户操作系统。在过去,虚拟机为了提供这种隔离环境而付出了性能代价,但随着技术的进步,这些问题的许多方面在近年来已经得到了解决。

记得我们提到的有限资源吗?每个虚拟机只是那样——虚拟的。当虚拟机管理程序配置不正确,或者虚拟机被分配了比物理存在更多的资源,或者不是专门为用户分配的(在云环境中非常常见),你可能会遇到不可预测的性能。

容器引擎

在现代容器引擎技术之前,通常在客户操作系统之上运行容器引擎。这个容器引擎本身可以运行多个容器。

这一层展示了虚拟机和容器之间主要区别之一,因为容器引擎的关键职责之一是在它运行的容器之间共享单个操作系统内核的访问权限。这种设置比虚拟机模型轻得多,在虚拟机模型中,每个实例都有自己的操作系统完整副本。然而,这种优势需要 Linux 内核本身许多不同部分的很大支持。

容器

最后我们来到了容器。你可以将容器想象成一个定制的、隔离的环境,用于运行应用程序。容器有一个文件系统,并至少运行一个进程。尽管该容器中的进程都可以与内核通信,但许多限制被施加以保持容器与其他世界的隔离,包括对内存、CPU、网络(使用和可见性)和磁盘的限制。

在容器内部,你运行你的 Java 应用程序、数据存储或其他所需的服务。让我们看看所有这些抽象层。

图片

图 12.1 Java 应用程序的目标环境

在图 12.1 中,主机操作系统是抽象的最底层。虚拟机管理程序是下一层,然后是容器引擎、容器和 Java 应用程序。这似乎有点过分,不是吗?在更纯粹的容器环境中,确实如此,所以过去几年里,你会看到专门的容器主机机,如图 12.2 所示,它们移除了虚拟机管理程序和客户操作系统层。

图片

图 12.2 在专用容器引擎上 Java 应用程序的目标环境

这要好得多!话虽如此,大多数开发者并不确定他们的目标环境是什么样的。这里的要点是确保你与系统管理员联系,了解你的目标环境的确切样子以及每一层分配了多少裸金属有限资源。

尽管这些抽象层中存在所有这些复杂性,但作为一个 Java 开发者,你将主要关注容器作为部署目标,这种方式做事情有一些重要的好处。

12.1.2 容器的优势

在运行容器所需的额外移动部件中,为什么它们成为了新的部署标准?其中一个关键好处是容器能够应用限制,将单个运行进程彼此隔离。在过去,如果你在同一主机上部署了两个 Java 应用程序,它们相互干扰性能的可能性很高——窃取过多的 CPU 时间或占用超过其公平份额的内存。虽然存在缓解措施,但这些想法已经融入了容器的基本层。实际上,拥有我们可以信赖的限制,在实践中,使我们能够更充分地使用计算资源,在主机上运行比容器出现之前更安全的更多软件。

这种隔离如此关键,以至于在本章的剩余部分,我们将通过展示嵌套而不是堆叠的图像来展示容器、主机和进程之间的关系。这两种可视化关系都是有效的,所以不要对在野外找到两者感到惊讶,这取决于上下文。

容器还引领了一个更一致打包的世界,用于部署。你如何将应用程序的代码复制到部署环境,如何管理操作系统依赖,甚至如何管理进程启动,这些都曾经是随意的事情。容器为所有这些问题提供了答案,使得大量的工具和定制脚本变得不再必要。它们还为我们部署环境和容器内容之间提供了隔离。我们的容器引擎不需要关心我们如何安排容器内部结构——它只需要知道在请求时如何启动自己。容器镜像的打包是基础设施即服务(IaaS)的一个关键例子,它使用声明性、源代码控制的系统层描述,这在以前需要仔细的命令性构建。

最后一个好处建立在一致的打包上——围绕容器开发的生态系统。今天,几乎任何你想运行的软件的重要部分可能已经在 Docker Hub 或其他地方打包成容器了。大量的安装说明或定制安装脚本现在变得不再必要。

但不可能全是优点,对吧?运行在容器中有什么缺点?

12.1.3 容器的缺点

结果表明,我们列出的容器好处中的第一个——内置隔离——实际上在使用它们时却是一个难点。容器的任务是保持容器内部世界与外部世界的分离,而这个外部世界包括你,开发者。你在外部容器中常用的许多技术和工具在迁移到容器时可能需要特殊处理和配置。

尤其是在尝试将容器纳入你的本地开发流程时,这一点更为明显。更长的构建时间和在容器镜像之间移动大量时间可能并不总是值得。

尽管容器为我们打包和启动应用程序引入了一致接口,但现实世界的部署并不总是简单。例如,一个期望对其宿主机的磁盘有完全访问权限的应用程序可能需要配置,以便使所需的文件对容器可见。如果一组进程相互通信,将它们分离到容器中需要显式配置它们如何相互访问。捕获和应用此类配置是 Kubernetes 等编排器的一个关键任务。然而,请注意,我们将在本章简要介绍的 Kubernetes 是一个适合填满书籍的主题,并且该生态系统仍在快速发展。

尽管容器已经成为完全主流的技术,但经验丰富的开发者知道如何权衡利弊,为他们的系统找到合适的平衡点。让我们开始了解如何使用这些工具,以便我们能够感受到它们适合的位置。

12.2 Docker 基础知识

尽管许多构成容器的技术在此之前就已经存在,但 Docker 引入了方便的工具和抽象,将容器化推向了主流。让我们看看 Docker 给我们提供的两个核心功能——构建镜像和运行容器——以及我们作为 Java 开发者在实践中如何与之交互。

12.2.1 构建 Docker 镜像

Docker 容器是从 镜像 启动的。镜像本质上是一个快照,它捕获了运行软件所需的所有文件系统依赖项。镜像包括本地库、语言运行时、工具,最重要的是,运行你的软件的特定版本。

Dockerfile 是捕获构建镜像步骤集的典型格式。可能的最简单镜像,一个完全为空的镜像,看起来像这样:

FROM scratch

我们使用以下 docker build 命令构建镜像:

$ docker build .

[+] Building 0.1s (3/3) FINISHED
 => [internal] load build definition Dockerfile    0.0s
 => => transferring dockerfile: 55B                0.0s
 => [internal] load .dockerignore                  0.0s
 => => transferring context: 2B                    0.0s
 => exporting to image                             0.0s
 => writing image sha256:71de1148337f4d1845be0...  0.0s      ❶

Use 'docker scan' to run Snyk tests against images to find vulnerabilities
and learn how to fix them

❶ sha256 ID 71de114... 唯一标识生成的图像。我们稍后将看到如何给事物起一个更友好的名字。

当然,一个空图像并没有太大的用处。在实践中,存在许多已经安装了有用软件的基础镜像。这些基础镜像的默认来源是 Docker Hub (hub.docker.com/)。我们稍后会更多地讨论如何选择合适的 Java 基础镜像,但现在,让我们从一个由 Adoptium 提供的 Eclipse Temurin 版本的 OpenJDK 开始构建镜像。我们将特别选择这里显示的 eclipse-temurin:11 镜像,它包含 Java 11 的最新版本:

FROM eclipse-temurin:11
RUN java -version

默认情况下,Docker 的最新版本在交互式终端中构建时会动态隐藏输出。我们将使用 --progress plain 来获得更清晰的了解:

$ docker build --progress plain .

=1 [internal] load build definition from Dockerfile                        ❶
=1 sha256:261a2389333859f063c39502b306e984de49700a9...
=1 transferring dockerfile: 36B done
=1 DONE 0.0s

=2 [internal] load .dockerignore                                           ❶
=2 sha256:909e36a5a9cd7cc4e95e7926f84f982542233925d...
=2 transferring context: 2B done
=2 DONE 0.0s

=3 [internal] load docker.io/library/eclipse-temurin:11                    ❶
=3 sha256:6a73b62137bbf64760945abf21baf23bf909644cf...
=3 DONE 0.5s

=4 [1/2] FROM docker.io/library/eclipse-temurin:11...                      ❷
=4 sha256:f225b618d7ad96bd25e0182d6e89aa8e77643f42f...
=4 CACHED

=5 [2/2] RUN java -version                                                 ❸
=5 sha256:556476b43b8626a27892422f8688979c4ba1e6029...
=5 0.38 openjdk version "11.0.13" 2021-10-19
=5 0.38 OpenJDK Runtime Environment Temurin-11.0.13+8 (build 11.0.13+8)
=5 0.38 OpenJDK 64-Bit Server VM Temurin-11.0.13+8 (build 11.0.13+8)
=5 DONE 0.4s

=6 exporting to image                                                      ❶
=6 sha256:e8c613e07b0b7ff33893b694f7759a10d42e180f2...
=6 exporting layers 0.0s done
=6 writing image sha256:9796a789e295989cec550f... done
=6 DONE 0.0s

Use 'docker scan' to run Snyk tests against images to find vulnerabilities
and learn how to fix them

❶ Docker 在准备构建我们的镜像时采取的内部步骤

❷ 获取我们请求的基础镜像

❸ 我们的 RUN 命令在构建过程中执行,我们可以看到它的输出。

你可能会注意到,这段代码至少第一次运行需要更长的时间,因为 Docker 必须从 Docker Hub 下载相关的基镜像。我们添加的 RUN 命令在基镜像之上引入了我们自己的新步骤。RUN 可以在容器环境中执行任何有效的命令。如果命令改变了文件系统,这些更改将作为我们最终镜像的一部分被捕获。这个例子实际上并没有改变文件系统,但 RUN 经常用于下载文件(例如,通过 curl),使用标准包管理器安装操作系统包,或进行其他本地修改。

如果我们不触摸 Dockerfile 而再次运行相同的构建命令,我们可以看到构建 Docker 镜像的另一个重要部分:

$ docker build --progress plain .

=1-4 excluded for length...

=5 [2/2] RUN java -version
=5 sha256:556476b43b8626a27892422f8688979c4ba1e602907a09d62a39a2
=5 CACHED                                                           ❶

=6 exporting to image
=6 sha256:e8c613e07b0b7ff33893b694f7759a10d42e180f2b4dc349fb57dc
=6 exporting layers done
=6 writing image sha256:9796a789e295989cec5550fb3c17bc6c1d9c0867  done
=6 DONE 0.0s

❶ Docker 会在跳过某个步骤时通知我们,因为结果被缓存了。

Dockerfile 中每个前导命令(如 FROMRUN)都创建了一个称为 的东西。因为这些命令通常很耗时,所以这些层被缓存,Docker 尽力避免不必要的操作。

现在我们已经有了 Java 环境,我们可以在那里运行自己的代码。我们将在Dockerfile旁边创建一个简单的 Java 文件,命名为HelloDocker.java。为了便于启动,我们将使用 Java 的单文件执行来运行它,而不是现在就构建一个完整的构建。基本代码如下:

public class HelloDocker {
  public static void main(String[] args) {
    System.out.println("Hello Docker!");
  }
}

我们可以指示 Docker 构建将此文件包含在我们的镜像中,并设置运行此镜像的容器的默认命令如下:

FROM eclipse-temurin:11
RUN java -version

COPY HelloDocker.java .            ❶

CMD ["java", "HelloDocker.java"]   ❷

❶ 将我们的文件复制到 Docker 当前工作目录

❷ 设置镜像的默认命令。注意每个命令行参数都在其单独的字符串中。

COPY(以及更复杂的ADD命令)将文件从我们的本地构建环境复制到我们的容器中。ADD特别具有许多选项,包括从远程源获取和自动提取 TAR 文件,但通常,如果您可以使用简单的COPY,那么您会更好。

CMD指向镜像生命周期的下一个阶段。我们不仅仅是为了好玩而构建这些镜像——我们希望运行我们在其中配置的软件。如前所述,每个镜像都有一个唯一的 SHA256 身份标识,但这些标识难以处理,并且每次构建时都会改变。在我们运行镜像之前,让我们用更简单的名称标记我们的镜像,如下所示:

$ docker build -t hello .

... Previous build steps excluded for length

=8 exporting to image
=8 sha256:e8c613e07b0b7ff33893b694f7759a10d42e...
=8 exporting layers done
=8 writing image sha256:666fdc7613189865b9a5f2... done   ❶
=8 naming to docker.io/library/hello done                ❷
=8 DONE 0.0s

❶ 我们镜像的 SHA256 身份标识

❷ 我们应用到最后镜像上的标签

目前我们的hello镜像仅在本地上可用,但通过FROM行我们已经看到镜像是可以共享的。这是通过所谓的容器仓库来实现的。当我们请求eclipse-temurin:11基础镜像时,Docker 默认会在 Docker Hub(hub.docker.com/)上查找该镜像。其他容器仓库也存在,实际上,它们可以在内部运行以托管您的应用程序镜像。

您可以通过docker pushdocker pull命令分别推送和拉取镜像,如下面的代码所示。如果与默认仓库一起工作,那么该名称将放在镜像和标签名称之前:

$ docker pull k8s.gcr.io/echoserver:1.4     ❶
1.4: Pulling from echoserver
6d9e6e7d968b: Pull complete
...
7abee76f69c0: Pull complete
Digest: sha256:5d99aa1120524c801bc8c1a7077e8f5ec122ba16b6dda1a...
Status: Image is up to date for k8s.gcr.io/echoserver:1.4
k8s.gcr.io/echoserver:1.4

❶ k8s.gcr.io 是仓库域名,echoserver 是镜像名称,1.4 是标签。

如果仓库需要身份验证,您可能在使用之前必须使用docker login。不过,Docker Hub 上公开可用的镜像不需要这一步。

构建好的 Docker 镜像还有很多内容,我们将在稍后回到一些这些主题。但首先,让我们看看如何将这些镜像转换为运行中的容器。

12.2.2 运行 Docker 容器

尽管您可能听到关于 Docker 和容器的所有炒作和讨论,但核心思想仅仅是能够在严格控制的环境中执行一个定义良好的过程。环境主要是由我们构建的镜像定义的。Docker 允许我们通过docker run命令运行容器,如下所示:

$ docker run hello
Hello Docker!

在这个命令中,Docker 基于我们的镜像创建了一个新的文件系统,应用了限制和控制(如 CPU 和内存),然后启动了由CMD定义的默认进程。我们的程序输出一条消息并退出,但它同样可以启动一个服务器并无限期地运行。

在图 12.3 中,我们可以看到我们在镜像的CMD中列出的java进程。请记住,这里显示的主机实际上在到达裸机之前可能隐藏了许多额外的层。

图片

图 12.3 运行基本容器

我们的CMD仅定义了启动容器的默认命令。我们可以要求 Docker 使用我们想要的任何替代命令来运行镜像。我们之前提到容器有一个工作目录,就像你的交互式终端一样。我们可以使用pwd命令来询问容器该路径是什么,如下所示:

$ docker run hello pwd
/

如图 12.4 所示,当我们运行一个替代命令来启动容器时,默认的CMD进程就无处可寻。

图片

图 12.4 在容器中运行替代命令

我们可以通过文件将配置写入我们的镜像中,但通常希望允许在运行时定义它们。十二要素应用(12factor.net/)中的一个原则,这是一套关于运行软件(如容器)的有影响力的思想,是通过环境来定义配置,这样相同的构建资源(在我们的情况下,是镜像)可以在不更改代码的情况下部署到新的目的地。

如以下代码片段所示,我们可以使用-e标志在启动容器时更改容器内的环境变量,这个标志可以多次传递。在我们的应用程序代码中,这些变量可以通过标准方式(如System.getenv()方法)读取:

$ docker run -e MY_VAR=here -e OTHER_VAR=there hello env         ❶
PATH=/opt/java/openjdk/bin:/usr/local/sbin:/usr/local/bin:...
HOSTNAME=f25762652561
MY_VAR=here                                                      ❷
OTHER_VAR=there                                                  ❷
LANG=en_US.UTF-8
LANGUAGE=en_US:en
LC_ALL=en_US.UTF-8
JAVA_VERSION=jdk-11.0.13+8
JAVA_HOME=/opt/java/openjdk
HOME=/root

❶ 运行标准 env 命令以查看容器的环境

❷ 我们完整的环境变量列表

在我们探讨更现实的构建 Java 应用程序在容器中的方法之前,让我们讨论一种最后的技巧:交互式运行镜像。我们已经看到如何更改默认命令以在容器中运行。我们可以使用同样的能力在容器中启动一个 shell,如bash,以进行额外的调试。这需要额外的docker run标志——具体来说,是-i,以便STDIN连接到容器,以及-t,以便容器为我们启动一个交互式 TTY,如下所示:

$ docker run -it hello bash
root@b770c2ac829c: ls *.java   ❶
HelloDocker.java
root@b770c2ac829c:

❶ 交互式输入 shell 命令以检查容器

这让我们能够精确地看到容器内部署的应用程序的世界。

将 hello world 单文件应用程序复制到容器中固然很好,但现在让我们看看更现实的将 Docker 和 Java 结合使用的方法。

12.3 使用 Docker 开发 Java 应用程序

在本节中,我们将探讨使用 Docker 开发 Java 应用程序的各种实际考虑因素。我们将从更深入地了解我们的 JVM 基镜像以及如何构建我们的镜像开始。从那里,我们将深入研究配置、运行和调试我们的容器时的各种考虑因素。我们的容器必须从某处获取 JVM,这把我们带到了选择基镜像的话题上。

12.3.1 选择您的基镜像

对于运行你的 JVM 应用程序的“正确”基镜像并没有一个单一的答案。确定适合你的镜像需要考虑以下因素:

  • 我想要哪个供应商?

  • 我想在容器内使用什么操作系统?

  • 我需要在什么系统架构上运行?

供应商的选择也包含了一些可能影响你选择的因素(我们曾在第一章中简要讨论过),包括以下内容:

  • 支持可用性和合同

  • 安全更新策略和及时性

  • 云部署的特殊考虑——Azure 的 Microsoft Build of OpenJDK,AWS 的 Amazon Corretto

云供应商特定的构建,虽然基于 OpenJDK,但可能包括在该供应商的云中具有益处的性能和其他增强功能。它们也可能有额外的支持和发布频率的好处。

大多数供应商在其容器中提供对多个操作系统的支持。常见的有 Debian、Ubuntu 或 Alpine,以及一些其他 Linux 变体。操作系统的选择在很大程度上决定了用于安装本地依赖项的包管理器以及容器内可用的额外工具。如果你的需求没有指定特定的操作系统,坚持使用更主流的选项,如 Debian/Ubuntu,通常可以避免在查找和更新软件包时遇到困难。

注意:特别小心使用 Alpine Linux。直到最近,Alpine 上还没有官方的 Java 镜像。你应该与你的 Java 供应商联系,并确保他们为 Alpine 提供镜像。

如果你需要在供应商直接提供的操作系统上运行,不要灰心。在这些情况下,你可以自己构建一个镜像,该镜像将使用系统的典型包管理器手动安装 JDK。记住,基镜像和我们的 Docker 构建只是确保容器文件系统中有了正确的位。通常有不止一种方法可以达到你想要的结果。

最后一点是关于镜像的系统架构。在云环境中,使用基于 ARM 的芯片越来越普遍。尽管这具有性能优势,但请注意,你需要为该架构专门构建的镜像。如果你需要在不同的架构上运行,你可能最终不得不构建和发布多个镜像,但 Docker 工具已经很好地支持了这一点。

12.3.2 使用 Gradle 构建镜像

正如我们在第十一章中看到的,任何大型的 Java 项目都将从使用一致的构建工具中受益。为了演示目的,我们将通过 Gradle 构建来构建一个镜像,但在资源中还有一个类似的 Maven 版本。

至少,我们的镜像需要包含我们应用程序的所有 JAR 文件(或类文件)以及我们类路径的所有依赖项。在我们的示例中,我们的应用程序依赖于org.apache.commons:commons-lang3,如下所示:

plugins {
  application
  java
}

application {
  mainClass.set("com.wellgrounded.Main")
}

tasks.jar {
  manifest {
    attributes("Main-Class" to application.mainClass)
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation("org.apache.commons:commons-lang3:3.12.0")
}

我们需要一个与通常的buildassemble命令略有不同的命令,但 Gradle 的默认设置通过installDist为我们提供了所需的功能,如下所示:

$ ./gradlew installDist

从此命令生成的简化构建结果如下:

build
└── install
     └── docker-gradle
          ├── bin
          │     ├── docker-gradle
          │     └── docker-gradle.bat
          └── lib
               ├── commons-lang3-3.12.0.jar
               └── docker-gradle.jar

我们可以直接从 JAR 文件中运行容器,但 Gradle 已经为我们创建了一些启动应用程序的辅助脚本。让我们利用这些脚本:

FROM eclipse-temurin:17-jdk

RUN mkdir /opt/app                           ❶
WORKDIR /opt/app/bin                         ❷

COPY build/install/docker-gradle /opt/app/   ❸

CMD ["./docker-gradle"]                      ❹

❶ 确保目录存在,以便我们将结果复制进去

❷ Gradle 的启动脚本期望工作目录为 bin,因此将 bin 设置为 Docker 启动的默认位置。

❸ 将整个安装结果树复制到容器中

❹ 现在默认的运行命令是 Gradle 的启动脚本。

你可以在应用程序插件的文档中了解更多关于 Gradle 的启动脚本的信息(见mng.bz/yvxJ)。

这种方法假设我们已经在本地安装了适当的 JDK,以便在将结果复制到我们的镜像之前使用 Gradle 构建。接下来,我们将看到如何完全使用 Docker 来实现这一点。

12.3.3 在 Docker 中运行构建

容器的一个关键承诺是能够为我们的软件创建一个隔离的、可重复的环境。这对于部署我们的服务是一个巨大的优势,但这并不止于此。许多项目的经典问题是设置本地开发环境。如果你曾经逐个步骤地阅读过 README,确保你得到了所有正确的版本,你就知道这种痛苦。容器可以帮助我们摆脱这种痛苦。让我们看看我们如何改变我们的构建以利用这种隔离。

到目前为止,我们的Dockerfile只涉及我们试图构建的一个结果镜像。但 Docker 允许我们在同一个文件中定义多个镜像,并且最重要的是,可以在它们之间复制内容。利用这种能力,我们可以构建一个用于构建我们应用程序的镜像——完全独立于本地系统上的任何 JDK——然后将结果复制到我们的部署镜像中。这对安全和镜像大小都有优势。

这个过程被称为多阶段构建,当Dockerfile中有多个FROM语句时,你可以看到它的实际操作。只是构建的中间阶段的FROM行也包括一个AS关键字来命名它们,以便在Dockerfile中稍后使用,而我们的主要结果镜像保持不变,如下所示:

FROM eclipse-temurin:17-jdk AS build                    ❶

RUN mkdir /project                                      ❷
WORKDIR /project

COPY . .                                                ❸

RUN ./gradlew clean installDist                         ❹

FROM eclipse-temurin:17-jre                             ❺

RUN mkdir /opt/app

COPY --from=build \                                     ❻
      /project/build/install/docker-gradle-multi \      ❻
      /opt/app/                                         ❻

WORKDIR /opt/app/bin
CMD ["./docker-gradle-multi"]

❶ 运行编译的容器,命名为 build

❷ 创建源代码的位置并将其设置为默认工作目录

❸ 将我们的完整项目复制到容器中

❹ 如同之前一样,在本地构建我们的应用程序(在这种情况下,是 Gradle 版本)

❺ 我们的部署镜像现在只需使用 JRE,它要小得多。

❻ 使用COPY --from=build从我们的构建镜像而不是本地文件系统获取文件。

现在持续集成环境只需要 Docker,而不需要安装 JDK,就能构建我们的应用程序以进行部署。如图 12.5 所示,所有必要的构建组件都完全位于容器内。

图 12.5 Docker 中的多阶段构建

值得指出的是,这几乎是最小化的此类构建设置,但它有一些关于构建时间的缺点。正如我们提到的,每个 Docker 命令都会创建一个被缓存的层,但如果我们不小心,这些缓存可能会被不必要地失效。

我们当前的Dockerfile中导致此类缓存失效的一个来源是将完整的项目目录复制到容器中。任何文件更改,无论多小,都会使COPY . .行失效,我们必须重新运行之后的所有内容。尽管如此,有些本地文件可能对我们的构建并不重要——例如,我们的 git 历史、IDE 文件和本地构建输出实际上不需要最终出现在我们的构建容器镜像中。幸运的是,我们可以通过在Dockerfile旁边放置一个.dockerignore文件来排除这些文件。格式简单,如果你之前使用过.gitignore文件,可能会很熟悉。如下代码片段所示,每一行都表达了一个模式(允许标准 shell 通配符),Docker 在查找要复制的文件时应忽略:

.git
.idea/
*.iml
*.class

# Ignore build folders
out/
build/
target/
.gradle/

第二个更为微妙的问题是我们的 Gradle 包装器。如果我们运行构建时观察输出,我们会看到它在启动时花费一段时间下载正确的分发。由于我们的容器启动时没有 Gradle 的本地缓存,这个下载每次运行都会重复。

避免这种重复需要将 Gradle 的第一次执行分离成一组单独的层,这些层在将我们的完整项目复制到容器之前发生。我们只想复制 Gradle 运行下载所需的最小文件,因此这个层的缓存只有在我们的 Gradle 包装器被更改(例如,更新版本)时才会失效:

COPY ./gradle ./gradle                 ❶
COPY ./gradlew* ./settings.gradle* .   ❶
RUN ./gradlew                          ❷

COPY . .                               ❸

RUN ./gradlew clean installDist

❶ 仅复制足够的 Gradle 配置以运行

❷ 仅运行./gradlew会强制下载分发,现在它被缓存在自己的层中。

❸ 我们的构建继续进行,我们的COPY操作很可能每次都会刷新(假设我们的代码已更改)。

这只是你在构建容器镜像时可以应用的各种优化类型的一个开始。需要记住的关键点是仔细考虑每个层应该包含什么。如果你的系统部分将以不同的速度发生变化,为它们提供单独的层可能是有益的。

我们已经展示了构建 Docker 镜像的相当原始的方法。正如你可能预料的那样,如果你想要封装这个功能而不手动编写 Dockerfile,Maven 和 Gradle 都有一系列插件。甚至有像 Jib(github.com/GoogleContainerTools/jib)这样的选项,可以完全不使用 Docker 工具。所有这些都很实用,但一个扎实的开发者通过更深入地了解容器是如何构建的,即使他们在日常工作中得到帮助,也会得到帮助。

12.3.4 端口和主机

除了为我们应用程序提供其自己的独立文件系统外,容器还为网络做了同样的事情。以我们的示例应用程序为例,让我们假设我们添加了代码来运行一个标准的 HTTP 服务器,例如,在 JDK 中提供的com.sun.net.httpserver.HttpServer中的基本服务器。如果我们运行我们的容器,我们会发现没有方法可以调用那个 HTTP 端点。

为了解决这个问题,我们需要要求 Docker 为我们提供一个可用的端口。我们可以通过直接添加到我们的运行命令来实现这一点,如下所示:

$ docker run -p 8080:8080 hello

-p接受一个由冒号分隔的端口对。第一个值是我们希望在容器外部可用的端口。第二个值是我们软件在容器内部监听的端口。如果我们去另一个终端(或网页浏览器),我们可以看到它的工作情况,如下所示,以及图 12.6:

$ curl http://localhost:8080/hello
Hello from HttpServer

图片

图 12.6 在 Docker 中暴露端口

如你从格式和图 12.6 中预期的那样,这两个端口值不必匹配。如果我们使用以下命令行运行:

$ docker run -p 9000:8080 hello   ❶

❶ 端口 9000 将在容器外部可见,连接到容器内部进程的端口 8080。

现在,我们将在端口 9000 上看到良好的响应,而 8080 不再可访问,如下所示:

$ curl http://localhost:9000/hello
Hello from HttpServer

$ curl http://localhost:8080/hello
curl: (7) Failed to connect to localhost port 8080: Connection refused

暴露端口是容器部署的基本部分,因此Dockerfile允许我们记录我们的镜像预期提供的端口,如下所示:

EXPOSE 8080

如果我们设置这个,再次运行docker build,并且不使用端口开关运行,你可能会惊讶地发现 Docker 不会默认使EXPOSE端口可用。然而,如果你只提供-P开关(注意大写和没有参数),Docker 将把我们的镜像中的每个EXPOSE端口绑定到一个随机或临时端口。因为我们无法猜测分配的端口是什么,我们需要一个新的命令来窥视并找到我们的临时端口。这可以通过以下docker ps来完成:

$ docker run -P hello

... In another terminal, some columns trimmed...
$ docker ps
CONTAINER ID   IMAGE     COMMAND             PORTS
94d7f125caad   hello     "./docker-gradle"   0.0.0.0:55031->8080/tcp

0.0.0.0:55031->8080/tcp告诉我们,容器外部的端口 55031 绑定到了容器内部的端口 8080。

这种临时端口的业务可能一开始看起来像是一种烦恼,尤其是在测试时,因为端口会变动。但实际上,当在生产环境中运行容器时,这是一个关键特性。想象一下,你有一个主机,你希望充分利用它运行许多不同的 Java 容器。这些应用程序中的每一个都可能想要使用相同的端口,但主机只能分配一次该端口。尽管这需要在系统的其他部分进行额外的协调,但分配临时端口允许容器保持对世界的简单看法——“我运行在 8080”——同时仍然在一个更广泛、更复杂的环境中共存。

这使我们准备好在本地运行容器时与我们的应用程序进行通信。但另一方面——当我们的容器需要连接到其他服务,如数据库时,怎么办呢?

当我们在生产环境中运行时,明确配置服务位置并使用正常的负载均衡和 DNS 来访问它们是一种良好的做法。这些可以通过环境变量或其他服务发现系统注入到容器中,但关键是你不要假设资源相对于你的容器所在的位置。

但在本地工作时,这会变得更加困难,因为普通的开发者设置不会提供那种类型的基础设施。如果你使用 Docker for Mac 或 Docker for Windows,你可以在容器内部使用名称 host.docker.internal,它自动指向你的主机机器。Docker for Linux 可以在容器启动时使用 --add-host host.docker.internal :host-gateway 标志设置相同的设置。在这些情况下,如果你的应用程序设置为通过环境变量接收此类位置,你可以将你的容器指向该主机名。

如果这在你给定的环境中不起作用,容器内部存在一个主机的 IP 地址。例如,sudo ip addr show 这样的命令可能会给你一些关于位置的提示,但这很快就会变得繁琐。

容器拥有许多网络选项,这些选项超出了本书的范围,但其中一些可以帮助我们解决这个具体问题的选项被一个名为 Docker Compose 的工具所使用。让我们看看容器如何帮助我们本地解决外部资源访问问题。

12.3.5 使用 Docker Compose 进行本地开发

就像令人畏惧的新项目安装列表一样,应用程序在运行时通常也需要多个其他服务。也许你有一个数据库、一个缓存、一个 NoSQL 存储,甚至其他自定义应用程序,所有这些都必须运行起来,你的应用程序才能在本地工作。

Docker Compose 是一个用于声明和运行容器集的工具。它让我们能够捕获精确的服务集并一起启动它们。它还管理这些容器的状态保存,这样我们就可以停止和重新启动,而无需从头开始做所有事情。

如果这听起来类似于 Kubernetes 这样的编排工具,你并没有错。这两个工具在容器管理方面有重叠。然而,Docker Compose 是针对单台机器运行的,这使其不适合许多生产环境。

注意 Docker Compose 最初是一个独立的工具,但它已被集成到 docker 本身中的另一个命令中。如果你在网上看到有关运行 docker-compose 的信息,现在你只需将 - 替换为空格即可。

默认情况下,我们在名为 docker-compose.yml 的文件中描述我们的配置。首先,让我们这样告诉 Docker Compose 关于我们的应用程序:

version: "3.9"        ❶
services:
  app:                ❷
    build: .          ❸
    ports:
      - "8080:8080"   ❹

❶ Docker Compose 文件版本

❷ 声明一个名为 app 的服务来运行

❸ 指示 Docker Compose 在当前目录中运行一个典型的 Docker 构建来生成此服务的镜像

❹ 端口声明,就像我们在之前的手动 docker run 中做的那样

我们在命令行中使用 docker compose up 运行它。这将显示我们熟悉的构建输出,当它启动时,然后显示一些新的输出,当它启动我们的容器时,如下所示:

[+] Running 2/2
 - Network docker-gradle_default  Created                       0.1s
 - Container docker-gradle-app-1  Created                       0.1s
Attaching to docker-gradle-app-1
docker-gradle-app-1  | (Howdy,Docker)

我们的 docker-compose.yml 可以包含多个服务,我们将在稍后看到。每个服务的输出都带有名称前缀以区分它们,默认情况下基于我们的当前目录和服务名称,因此我们的名称是 docker-gradle-app-1

假设我们的应用程序需要一个 Redis 实例。我们将其添加为一个新的键,我们将其称为 redis,在 services 键下,如下所示:

version: "3.9"
services:
  app:
    build: .
    ports:
      - "8080:8080"
  redis:
    image: "redis:alpine"   ❶

❶ 来自 Docker Hub 的 redis:alpine 镜像

现在我们运行时,Docker Compose 将拉取 redis:alpine 镜像,并使用我们的应用程序容器启动它。图 12.7 和接下来的代码示例说明了这些容器之间的关系:

[+] Running 7/7
 - redis Pulled                                    5.0s
   - 59bf1c3509f3 Pull complete                    1.2s
   - 719adce26c52 Pull complete                    1.2s
[+] Running 2/2
 - Container docker-gradle-redis-1  Created        0.2s
 - Container docker-gradle-app-1    Created        0.0s
Attaching to docker-gradle-app-1, docker-gradle-redis-1
docker-gradle-redis-1  | # oO0Oo Redis is starting...     ❶
docker-gradle-redis-1  | # Redis version=6.2.6, ...       ❶
docker-gradle-redis-1  | * monotonic clock: POSIX ...     ❶
docker-gradle-redis-1  | # Warning: no config file...     ❶
docker-gradle-redis-1  | * Running mode=standalone, ...   ❶
docker-gradle-redis-1  | # Server initialized             ❶
docker-gradle-redis-1  | * Ready to accept connections    ❶
docker-gradle-app-1    | (Howdy,Docker)                   ❷

❶ Redis 容器输出

❷ 我们的应用程序容器输出

图 12.7 Docker Compose 容器运行

这已经很方便了——我们可以本地获取数据库和其他外部服务的精确版本,而无需手动安装。但是 Docker Compose 带来了另一个有用的功能,可以避免我们之前看到的许多网络问题。在初始启动期间,有一条消息显示 Network docker-gradle_default Created。这告诉我们 Docker Compose 创建了一个新的、独立的网络命名空间 docker-gradle_default。这个网络在我们为 Docker Compose 启动的所有服务之间共享。更好的是,我们在 docker-compose.yml 中指定的每个服务名称——appredis——在所有容器内部都显示为真实的主机名。

如果我们根据十二要素原则设计了我们的应用程序,并通过环境变量传递 Redis 的位置,我们可以在 docker-compose.yml 中完全配置它,如下所示:

version: "3.9"
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      REDIS_URL: redis://redis:6379   ❶
  redis:
    image: "redis:alpine"

❶ 第一个 redis 是 URL 方案,第二个 redis 是主机名。

这只是 Docker Compose 表面的冰山一角。所有控制docker run的常见选项都可以在docker-compose.yml中设置,这是平滑本地开发的一个很好的方法。

12.3.6 Docker 中的调试

当我们的软件表现不佳时,有时我们需要查看容器为我们设置的边界内部。之前我们遇到了docker ps来确定容器暴露的端口。然而,docker ps提供的信息远不止这些。特别是,默认情况下,容器会被赋予一个方便的、随机生成的名称,我们可以通过它来引用,如下所示:

$ docker ps
CONTAINER ID   IMAGE     COMMAND           ...  PORTS      NAMES
c103de6e6634   hello     "./docker-gradle" ...  8080/tcp   vigilant_austin

这个容器可以被称为vigilant_austin。如果你想要避免每次容器运行时名称都改变,你可以在docker run--name container-name参数上控制它。你可能会想将它与--rm结合使用,以便在容器退出时删除它;否则,名称在第二次运行时将不可用。

有了容器的名称,我们可以采取其他调试步骤。使用docker exec,我们可以在运行中的容器中执行命令。正如我们之前在docker run -it中看到的那样,我们甚至可以在容器内获得一个交互式 shell,如下所示,假设它已安装了bash或类似的东西:

$ docker run --name hello-container --rm hello

# In another terminal start a shell in the container
$ docker exec -it hello-container bash

root@18a5f04bb4c8: ps aux
USER PID %CPU %MEM COMMAND
root   1  1.6  1.9 /opt/java/openjdk/bin/java -cp /opt/app/lib/docker-gradle
root  37  0.1  0.1 bash
root  47  0.0  0.1 ps aux

重要的是要记住,exec不会启动一个新的容器——它只是附加到一个现有的容器上。图 12.8 展示了进程如何在单个容器中共存。

图片

图 12.8 docker exec进入容器

虽然我们并不局限于仅使用基本的 Unix 命令,例如,我们可以使用jpsjcmd来检查容器中运行的 JVM,如下所示:

root@18a5f04bb4c8: jps
1 Main
148 Jps

root@18a5f04bb4c8: jcmd 1 VM.version
1:
OpenJDK 64-Bit Server VM version 17.0.1+12
JDK 17.0.1

在第七章中,我们探讨了使用 JFR(JDK 飞行记录器)工具可以获得的深度可见性。通过进入运行中的容器,我们可以使用几个简单的命令来收集 JFR 数据。如果它还没有运行,我们告诉 JFR 像这样开始记录:

root@4f146639fcfc: jcmd 1 JFR.start
1:
Started recording 1\. No limit specified, using maxsize=250MB as default.

在让应用程序收集数据一段时间后,我们将当前记录保存到容器内的文件中,如下所示:

root@4f146639fcfc: jcmd 1 JFR.dump name=1 filename=./capture.jfr
1:
Dumped recording "1", 293.3 kB written to:

要离线检查文件,我们需要将其从容器中复制出来。回到我们的主机系统,我们可以使用docker cp命令来完成,如下面的代码示例所示。同样,我们的容器名称在这里派上了用场,可以用来指定从哪里获取文件:

$ docker cp hello-container:/opt/app/bin/capture.jfr .   ❶

cp命令的第一个参数是文件源,第二个参数是目标。它以container-name:path的格式指定。第二个参数是本地的,所以我们不需要容器名称,只需使用路径。

capture.jfr现在可在您的本地系统上打开,通过 JDK 任务控制(JMC)。

因为 Docker 公开了一个 API,所以你的docker命令可以指向远程主机而不是本地环境。有关如何配置的详细信息,请参阅 Docker 文档docs.docker.com/

所有这些 shell 和命令行选项都是获取我们容器中发生事情的低级别信息的好方法。但如果我们只想在本地容器中的 Java 应用程序的 IDE 中设置一个断点呢?幸运的是,JDK 的远程调试功能包含了我们配置此功能所需的所有组件,如下所示:

docker run --rm \
  -p 8090:8090 \
  -e JAVA_TOOL_OPTIONS=\
  '-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:8090' \
  --name hello-container \
  hello

除了应用程序启动的正常输出外,你还应该看到如下消息,表明远程调试端口可用:

Listening for transport dt_socket at address: 8090

从这里,你可以使用你的 IDE 功能来调试指向端口 8090 的远程 JVM。一切都应该表现得与在本地环境中调试应用程序非常相似,但都是在容器这个舒适、受限的世界中。

12.3.7 使用 Docker 进行日志记录

正如我们反复看到的,容器从其宿主环境引入的隔离需要思维方式的转变。一个常见的障碍是日志记录。无论你使用的是流行的日志框架还是简单地写入System.out,在运行时产生输出是很常见的。我们不想因为迁移到容器而失去对这些信息的访问。

你可以使用我们在本章中已经看到的技术采取手动方法。简单地像以前一样将你的日志写入磁盘。当你需要检查它们时,你可以使用docker execdocker cp来访问文件,如下所示:

// Start our container
$ docker run --rm --name hello-container hello

// In another shell, copy the file locally
// Assumes log is at /log/application.log
$ docker cp hello-container:/log/application.log .

// Or alternatively, tail the file continually
$ docker exec hello-container tail -f /log/application.log

然而,这引入了一些在检索这些信息时的摩擦,并且如果容器过早完全移除,可能会导致数据丢失。

一种常见的做法——无论是否有容器——是将应用程序的日志转发到中央位置。这个转发的目的地可以是集中式存储,一个索引服务,如 Elasticsearch,甚至是一个完全外部的日志提供商。

但是,如果我们试图在容器中保持将日志写入本地文件的简单做法,我们必须回答我们的日志转发应用程序在哪里运行的问题。将其放入容器中会消耗额外的内存和资源,我们需要考虑,通常建议避免在单个容器中放置多个东西。容器允许挂载卷,这样日志文件可以在容器和主机之间共享,但这需要配置,并且并不总是性能最佳。

一个更好的选择是依靠 Docker 捕捉我们的容器写入典型输出流的事实,即STDOUTSTDERR。在主机上,这些流被保存到每个正在运行的容器的已知文件位置。这简化了配置,因为我们可以在主机上一次性安装日志转发,并只需告诉我们的各个容器将日志写入STDOUT而不是文件。它还与现有的日志库兼容,例如log4j2,它有写入CONSOLE的附加器,正好用于这个目的。

这种围绕如何运行容器和捕获其日志的基础设施设置是一个例子,说明了在单个主机之外扩展容器时可能出现的问题。提供一种系统化的方法来回答这些问题是我们下一个主题的关键好处之一:Kubernetes。

12.4 Kubernetes

这本 Docker 入门书实际上只是触及了配置和定制我们容器的表面。在实际的生产环境中,您可能需要许多容器实例。仅使用docker命令管理大量容器会很快变得难以控制,并且生产环境中有数百个独立的容器并不罕见。您需要自动化这些任务。此类自动化的通用术语是编排器,尽管该领域有许多选项,但 Kubernetes 是主导解决方案。

Kubernetes(通常称为 K8s)是一个开源项目,最初源于谷歌在容器编排方面的内部工作。在其核心,它提供了一套标准、API 驱动的工具来描述系统的期望状态,并确保该状态在一段时间内得到维护。

Kubernetes 将您的系统建模为不同类型的对象集合。一组控制器持续运行,监视系统的实际状态并应用更改(例如,如果旧容器死亡则创建一个新的容器),以确保系统的期望状态和实际状态相匹配。

对 Kubernetes 的全面介绍远远超出了本书的范围,但为了了解其工作原理,让我们看看最基本的对象类型以及我们如何使用我们迄今为止获得的容器技能来使用它们。

  • Cluster—在单台机器到数百个节点上的单个 Kubernetes 安装

  • Node—集群中的单个机器(虚拟或物理)

  • Pod—一个可部署的容器单元(或多个容器)

  • Deployment—部署 Pod 的声明性方式

  • Service—一个对象,将集群中的容器暴露给调用者

为了逐步介绍这些概念并演示,我们将使用minikube,这是 Kubernetes 项目本身提供的本地开发环境。请参阅链接中的说明,获取您操作系统上的当前安装说明(minikube.sigs.k8s.io/docs/start/)。

安装完成后,我们可以使用minikube start命令启动本地集群,如下所示。请注意,第一次可能需要几分钟来下载所有必需的镜像:

$ minikube start

  minikube v1.25.2 on Darwin 11.6.2
  Using the docker driver based on existing profile
  Starting control plane node minikube in cluster minikube
  Pulling base image ...
  Downloading Kubernetes v1.23.1 preload ...
  > preloaded-images-k8s-v17-v1...: 504.44 MiB / 504.44 MiB  100.00%
  Restarting existing docker container for "minikube" ...
  Preparing Kubernetes v1.23.1 on Docker 20.10.12 ...
  * kubelet.housekeeping-interval=5m
  Verifying Kubernetes components...
  * Using image kubernetesui/dashboard:v2.3.1
  * Using image kubernetesui/metrics-scraper:v1.0.7
  * Using image gcr.io/k8s-minikube/storage-provisioner:v5
  Enabled addons: storage-provisioner, default-storageclass, dashboard

  Done! kubectl is now configured to use "minikube" cluster and "default"
  namespace by default

我们现在运行的 Kubernetes 集群是本地的。我们可以使用minikube stop命令停止集群,或者如果我们完全完成了实验,可以使用minikube delete命令将其删除。

虽然 Kubernetes 为系统提供了 REST API 以进行交互,但通过kubectl命令有一个更适合人类消费的方便包装器。我们可以使用它来查看、创建和编辑集群中的对象。例如,minikube默认负责创建我们的节点对象,但我们可以使用kubectl describe node来查看它代表我们设置了什么。此列表仅突出显示输出的一小部分,因为它提供了大量详细信息:

$ kubectl describe node

Name:               minikube
Roles:              control-plane,master
Labels:             kubernetes.io/arch=amd64
                    kubernetes.io/hostname=minikube
                    kubernetes.io/os=linux
Addresses:
  InternalIP:  192.168.49.2
  Hostname:    minikube

Non-terminated Pods:          (12 in total)                                ❶
  Namespace                   Name
  ---------                   ----
  kube-system                 coredns-64897985d-n8fzv
  kube-system                 etcd-minikube
  kube-system                 kube-apiserver-minikube
  kube-system                 kube-controller-manager-minikube
  kube-system                 kube-proxy-4zvll
  kube-system                 kube-scheduler-minikube
  kube-system                 storage-provisioner
  kubernetes-dashboard        dashboard-metrics-scraper-58549894f-bcjh4
  kubernetes-dashboard        kubernetes-dashboard-ccd587f44-mq8zv

Events:                                                                    ❷
  Type   Reason                  Message
  ----   ------                  -------
  Normal Starting                Starting kubelet.
  Normal NodeHasSufficientMemory Node status is: NodeHasSufficientMemory
  Normal NodeHasNoDiskPressure   Node status is: NodeHasNoDiskPressure
  Normal NodeHasSufficientPID    Node status is: NodeHasSufficientPID

❶ Kubernetes 本身在节点上运行在 Pod 中,如下所示。

❷ 如果节点中出现意外问题时,事件可能会有所帮助。

由于minikube为我们提供了集群和节点,我们已准备好运行一些软件。为了保持简单,我们将使用k8s.gcr.io/echoserver:1.4镜像,正如其名称所暗示的,它只是将发送给它的 HTTP 请求的信息回显。

注意minikube支持使用本地镜像进行工作,但它运行一个独立的 Docker 守护进程,因此镜像管理变得稍微复杂一些。如果您想使用minikube进行更多本地开发,请参阅github.com/kubernetes/minikube的 README。在我们的示例中,我们将坚持使用已发布的镜像以保持简单。

我们的首要目标是让集群上运行echoserver容器的 Pod 运行。我们通过请求kubectl创建一个部署来实现这一点,如以下代码片段所示。部署对象告诉 Kubernetes 集群我们希望 Pod 运行的状态。Kubernetes 的控制循环注意到期望状态与实际情况不匹配,并为我们启动 Pod 以解决这个问题:

$ kubectl create deployment echoes --image=k8s.gcr.io/echoserver:1.4
deployment.apps/echoes created

我们可以使用标准的kubectl get命令检查集群,以查看我们的部署是否存在,如下所示。此命令适用于系统中的任何类型的对象:

$ kubectl get deployments
NAME     READY   UP-TO-DATE   AVAILABLE   AGE
echoes   1/1     1            1           55s

如果我们查找 Pod,我们很快就会看到集群已经将其实际状态与我们的部署请求对齐,如下所示。图 12.9 展示了我们达到的单个 Pod 状态。

$ kubectl get pods
NAME                      READY   STATUS    RESTARTS   AGE
echoes-7989cff4bc-7m4df   1/1     Running   0          78s

图 12.9 运行一个 Pod 的 Kubernetes 集群

kubectl create deployment命令是一个简单的入门方式,但它的参数只是触及了 Kubernetes 可以配置的表面。Kubernetes 对象的自然表示形式是 YAML,我们可以通过kubectl edit deployment echoes来访问完整的图片,如下所示。这将打开您的默认编辑器,并显示对象的当前 YAML。如果您对文件进行了更改,它们将在编辑器退出时应用。我们不会讨论所有这些选项,因此请参阅mng.bz/M5m2的文档以获取更多信息:

# Please edit the object below. Lines beginning with a '#' will be ignored,
# and an empty file will abort the edit. If an error occurs while saving
# this file will be reopened with the relevant failures.
#
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    deployment.kubernetes.io/revision: "1"
  creationTimestamp: "2022-02-01T08:26:32Z"
  generation: 1
  labels:
    app: echoes
  name: echoes                                           ❶
  namespace: default
  resourceVersion: "1310"
  uid: e8b775f6-243e-46c1-9275-dadaecf2db3b
spec:                                                    ❷
  progressDeadlineSeconds: 600
  replicas: 1                                            ❸
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app: echoes
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: echoes
    spec:
      containers:
      - image: k8s.gcr.io/echoserver:1.4                 ❹
        imagePullPolicy: IfNotPresent
        name: echoserver
        resources: {}
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      dnsPolicy: ClusterFirst
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 30
status:                                                  ❺
  availableReplicas: 1
  conditions:
  - lastTransitionTime: "2022-02-01T08:26:33Z"
    lastUpdateTime: "2022-02-01T08:26:33Z"
    message: Deployment has minimum availability.
    reason: MinimumReplicasAvailable
    status: "True"
    type: Available
  - lastTransitionTime: "2022-02-01T08:26:32Z"
    lastUpdateTime: "2022-02-01T08:26:33Z"
    message: ReplicaSet "echoes-7989cff4bc" has successfully progressed.
    reason: NewReplicaSetAvailable
    status: "True"
    type: Progressing
  observedGeneration: 1
  readyReplicas: 1
  replicas: 1
  updatedReplicas: 1

❶ 我们为部署给出的 echo 名称

❷ spec 描述了我们的部署所需的状态。

❸ 我们将在稍后讨论的一个重要值,它决定了我们希望运行的 Pod 数量

❹ 我们为 Pod 运行请求的镜像

status 告诉我们目前观察到的部署状态。请注意,它还有一个副本,告诉我们现在有多少正在运行。

如果我们将 replicas: 1spec 值更改为 3,会发生什么?Kubernetes 会看到部署状态与集群实际状态之间的不匹配,并代表我们启动新的容器,如下所示。图 12.10 显示了容器启动后的结果。

$ kubectl get pods
NAME                      READY   STATUS    RESTARTS   AGE
echoes-7989cff4bc-7m4df   1/1     Running   0          7m38s
echoes-7989cff4bc-7qn47   1/1     Running   0          8s
echoes-7989cff4bc-cmngm   1/1     Running   0          8s

图 12.10 多个 pod 运行的 Kubernetes 集群

实际上,你很可能不会在生产 Kubernetes 集群上手动编辑 YAML 文件,但所有工具都是基于此构建的——CI/CD 系统、生成的或源控制的 Kubernetes 清单只是帮助我们创建正确的 YAML 和 API 调用的辅助工具。

在我们的本地系统上,我们之前用 docker psdocker exec 玩过的同样的技巧将适用于让我们更仔细地查看正在运行的容器。一旦我们知道容器名称,kubectl 就有一个稍微干净一点的命令,允许我们在 pod 内部启动一个 shell,如下所示:

$ kubectl exec echoes-7989cff4bc-7m4df -- bash
root@echoes-7989cff4bc-7m4df: uname -a
Linux echoes-7989cff4bc-7m4df 5.10.76-linuxkit #1 SMP \
  Mon Nov 8 10:21:19 2021 x86_64 x86_64 x86_64 GNU/Linux

需要再进行最后一步才能使这次部署更有用。默认情况下,我们根本无法与集群中的 pod 进行通信。如果我们查看容器的 docker ps,你会看到没有任何端口被暴露。

Kubernetes 通过其 服务 抽象来解决这一问题,这是一个用于处理集群中各种负载均衡和流量路由的通用接口。这里的细节很快就会超出本介绍的范畴,但我们将设置其中最简单的一个,称为 NodePort,使用 kubectl expose 如下所示:

$ kubectl expose deployment echoes --type=NodePort --port=8080
service/echoes exposed

如图 12.11 所示,这会在集群中创建一个新的对象,代表我们的 NodePort

图 12.11 Kubernetes 集群中的 NodePort 和服务

查看服务,我们可以看到集群中配置了 NodePort,如下所示:

$ kubectl get services
NAME         TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
echoes       NodePort    10.108.182.100   <none>        8080:31980/TCP   12s
kubernetes   ClusterIP   10.96.0.1        <none>        443/TCP          35m

内部来说,这意味着集群中的每个节点上都可用端口 8080,并将流量转发到我们的 pod。现在我们已经有了让流量到达我们的 pod 的方法,我们仍然需要将其暴露在集群外部才能调用它。kubectl 通过其端口转发功能支持这一点,如下所示:

$ kubectl port-forward service/echoes 7080:8080
Forwarding from 127.0.0.1:7080 -> 8080
Forwarding from [::1]:7080 -> 8080
Handling connection for 7080

在终端中运行此转发,我们可以在浏览器中访问 127.0.0.1:7080,我们会看到我们的请求被回显。图 12.12 显示了流量通过各个组件到达 pod 的流程。

图 12.12 Kubernetes 集群中的端口转发

Kubernetes 的复杂性比仅在本地上运行我们的 Docker 容器要高得多,但它也提供了解决大规模运行容器困难的方法。然而,无论我们是否在 Kubernetes 上运行,我们几乎总是关心我们服务的性能。让我们来看看如何管理我们的容器在生产中的运行情况。

12.5 可观察性和性能

Java 技术最初是为一个在数据中心裸金属上运行 JVMs 的世界而设计的,在这个世界中,开发者可以相对独立于(甚至对)部署环境的细节保持无知。然而,世界正在从根本上发生变化。云原生部署——尤其是容器——已经到来,并且正在迅速被采用(但不同行业的各个部分采用的速度不同)。

容器在理解现代应用程序发生的细节方面提出了一些特定的挑战。例如,容器运行诸如 ssh 守护程序等服务并不是常见的做法,这使得无法登录到容器中观察正在发生的事情。相反,任何关于应用程序健康状况的数据都必须从容器中导出。

被称为可观测性的 DevOps 实践源于现代开发实践的几个不同分支,包括应用性能监控(APM)领域以及对编排系统(如 Kubernetes)可见性的需求。它的目标是提供对系统行为的非常细粒度的洞察,以及丰富的上下文。它提供的技术对于理解和调整容器中 Java 应用程序的性能非常有用,如果不是必不可少的。

12.5.1 可观测性

总体而言,可观测性是一个相当简单的概念集合:

  1. 仪器化和应用程序以收集相关数据

  2. 将这些数据发送到可以存储和分析的系统(包括查询能力)

  3. 提供对整个系统的可视化洞察

查询和可视化能力是可观测性力量的关键。它被描述为“回答你不知道需要问的问题”——而这只有通过收集足够的数据来准确建模系统的内部状态才能实现。

注意:可观测性背后的理论来自系统控制理论——本质上是一个问题:“从外部可以多好地推断出系统的内部状态?”

最终的目标是能够从整个系统中获得可操作的洞察,这应该取代仅基于整体系统的一两个部分的片段性观点。

因此,虽然事件解决是一个显然适合可观测性的用例——毕竟,这是该实践起源的地方——但事实也是,潜在的适用范围要大得多。如果收集了正确的数据,可观测性的利益相关者远不止软件可靠性工程师(SREs)、生产支持和 DevOps 人员。

可观察性对于容器化应用程序尤其相关,因为这些部署通常比传统的本地应用程序更复杂。云部署的应用程序通常有更多的服务和组件,以及更复杂的拓扑结构,以及变化的速度更快(由持续部署等实践驱动)。

这也与新兴云原生技术的日益普及相结合,这些技术具有新的操作行为。这包括 Kubernetes,以及作为服务的函数部署,如 AWS Lambda。这个新世界使得根本原因分析和事件解决可能变得相当困难。

可观察性数据通常用“三个支柱”来概念化。这是一个简单的思维模型(有些人可能会认为太简单),但对于刚开始接触可观察性的开发者来说很有用。支柱如下:

  • 分布式跟踪——单个服务调用的记录,对应于用户的一个请求

  • 度量——在时间间隔内测量特定活动的值

  • 日志——随时间发生的不变事件记录(可以是纯文本、结构化或二进制)

核心库和仪器组件都是开源的,其中大部分由云原生计算基金会(CNCF)等行业协会管理。

OpenTelemetry

OpenTelemetry 项目(opentelemetry.io/),作为 CNCF 中的一个主要项目,是一套标准、格式、客户端库和相关软件组件,用于提供可观察性。这些标准是明确跨平台的,并且不依赖于任何特定的技术堆栈。

这提供了一个框架,可以与操作系统和商业产品集成,并可以从用多种语言编写的应用程序中收集可观察性数据。由于实现是开源的,它们的技术成熟度各不相同,这取决于 OpenTelemetry 在特定语言社区中吸引的兴趣。

OpenTelemetry 项目源于两个先前开源项目的合并,即 OpenTracing 和 OpenCensus 项目。尽管 OpenTelemetry 仍在不断发展,但它正在获得动力,越来越多的应用程序和团队正在研究和实施它。这个数字似乎在 2022 年和 2023 年将显著增长。

从我们的角度来看,Java/JVM 实现是最成熟的可用的之一,并且与传统 APM/监控相比具有许多优势。特别是,使用开放标准提供了以下优势:

  • 大大减少了供应商锁定

  • 开放规范线协议

  • 开源客户端组件

  • 标准化架构模式

  • 开源后端组件的数量和质量不断提高

OpenTelemetry 有几个子项目构成了整个标准,它们在其整体生命周期方面并不处于相同的成熟度水平。

分布式跟踪规范已达到 v1.0,并正在积极部署到生产系统中。它完全取代了 OpenTracing,OpenTracing 项目已被官方存档。最受欢迎的分布式跟踪后端之一,Jaeger 项目也已经停止其客户端库的开发,并将默认使用 OpenTelemetry 协议。

OpenTelemetry 度量项目虽然不算特别先进,但已经达到了 v1.0 和通用可用性(GA)阶段。在撰写本文时,该协议是稳定的,API 处于特性冻结状态。

最后,日志规范仍在草案阶段,预计要到 2022 年底才能达到 v1.0。目前仍认为在规范上还有一定的工作要做。

总体而言,当度量标准达到 v1.0 并与跟踪一起时,OpenTelemetry 作为一个整体将被视为 v1.0/GA。

OpenTelemetry 的 Java 库可以通过手动方法(开发者必须明确选择应用中需要被检测的部分)或使用自动检测(使用 Java 代理)部署到您的应用中。OpenTelemetry 的 Java 组件可以在 GitHub 上找到,并存在于多个项目中,包括mng.bz/aJyJ

完全可观测性解决方案的实施方法(无论基于 OpenTelemetry 还是其他堆栈)超出了本书的范围,但经验丰富的 Java 开发者应该彻底探索这个领域。

与可观测性相关的一些性能细节,对于不是 Java/VM 专家的工程师来说可能并不了解。让我们更深入地了解一下。

12.5.2 容器中的性能

许多开发者在将 Java 应用程序迁移到容器时,会尝试使用尽可能小的容器。这似乎是有道理的,因为基于云的应用通常按使用的 RAM 和 CPU 数量计费。

然而,JVM 是一个非常动态的平台,某些重要的参数在启动时由 JVM 根据 JVM 运行所在机器的观察属性自动确定。

这些属性包括 CPU 的类型和数量以及物理内存。当在不同的机器上运行时,运行中的应用程序的行为可能会不同,这包括容器。以下是一些动态属性:

  • JVM 内省,一种可以利用非常具体的 CPU 特性(例如向量支持)的 JIT 技术

  • 内部线程池(如“通用池”)的大小

  • 用于 GC 的线程数量

只从这份列表中,我们就可以看出,错误地选择容器镜像的大小可能会导致与 GC 或常见线程操作相关的问题。然而,这个问题在本质上比这更深。

当前版本的 Java,包括 Java 17,在命令行未显式指定 GC 的情况下,会进行一些动态检查并** ergonomically**(自动地)决定使用哪种 GC。如果您没有指定收集器,那么逻辑如下:

  • 如果机器是“服务器级”,则选择 G1(Java 8 的并行)。

  • 如果机器不是“服务器级”,则选择 Serial。

服务器级机器的工作定义是:>= 两个物理 CPU 和 >= 2 GB 内存

这意味着如果 Java 应用程序在一个看起来少于两个 CPU 和 2 GB 内存的机器上运行,除非显式选择特定的收集器算法,否则将使用 Serial 算法。这通常不是团队想要的,并导致以下最佳实践:

建议:始终以至少两个 CPU 和 2 GB 内存运行 Java 应用程序。

同样重要的是要认识到,传统的 Java 应用程序生命周期包括多个阶段:引导、密集的类加载、预热(带有 JIT 编译),然后是一个长期稳定的状态(可能持续数天或数周),在此期间类加载和 JIT 相对较少。这种模式在云部署中受到挑战,因为容器可能存在的时间更短,集群大小可能会动态调整。

在这个新世界中,Java 必须确保它在几个关键轴上保持竞争力,包括以下方面:

  • 占用空间

  • 密度

  • 启动时间

幸运的是,正在进行确保平台继续优化这些特性的工作和研究——我们将在第十八章中了解更多关于它。

摘要

  • 容器彻底改变了我们打包和部署应用程序的方式,并需要一些新的技术和想法。

  • 容器在经典操作系统、虚拟机管理程序和过去我们看到的虚拟机之上提供了一个另一层抽象。

  • Docker 是构建、发布和运行容器镜像最常用的工具。

  • 我们通过 Dockerfile 指定容器镜像。生成的镜像包含我们的应用程序和一个完整的运行环境,包括 JVM、本地依赖项和额外的工具。

  • 容器在特定于网络方面引入了一个额外的层。在其最基本的形式中,我们必须管理容器暴露的端口,以便它们对外部世界是可访问的。

  • 在大规模上运行容器集群需要跟踪很多东西,因此使用编排器来系统地完成这项工作。最受欢迎的选择是 Kubernetes。

  • Kubernetes 提供了一个丰富的、可扩展的 API,用于声明系统的期望状态,并在运行时实现该状态。它可以通过命令行和一个 REST API 访问,周围有一个庞大的支持工具生态系统。

  • 容器的一个关键特性是强制资源约束。这些对内存和 CPU 的限制可能对在容器中运行的应用程序的性能产生影响。

13 测试基础

本章涵盖

  • 为什么我们要进行测试

  • 我们如何进行测试

  • 测试驱动开发

  • 测试替身

  • 从 JUnit 4 到 5

近年来,编程领域越来越接受自动化测试作为开发过程的一个预期部分。测试由开发者在本地运行,并在构建和持续集成环境中运行,以确保我们的系统表现良好。随之而来的是各种工具、方法和哲学的爆炸式增长。

就像任何技术一样,没有一劳永逸的解决方案——没有一种测试方法能涵盖所有可能的情况。鉴于这种情况,了解你为什么要测试非常重要,这样你才能确定最佳的测试方法。

13.1 为什么我们要进行测试

事实上,这个词“测试”隐藏了我们检查代码行为可能存在的许多原因。以下是一个非详尽(有时重叠)的考虑列表:

  • 确认单个方法的逻辑是正确的

  • 确认代码中两个对象之间的交互

  • 确认库或其他外部依赖按预期行为

  • 确认系统某部分产生或消耗的数据是有效的

  • 确认系统与外部组件(如数据库)正确工作

  • 确认系统的端到端行为满足重要的业务场景

  • 为后来的维护者记录假设(因为测试不会像注释和文档那样同步)

  • 通过暴露紧密耦合和对象职责来影响系统设计

  • 自动化发布后清单,这些清单通常由人工执行

  • 通过随机输入在代码中找到意外的边缘情况

即使这个简短的测试动机列表也表明,“测试你的代码”这个简单的想法并不一定那么简单。因此,当我们接近测试时,我们需要问自己以下问题:

  • 我测试这段代码的动机是什么?

  • 哪些技术能让我最准确、最干净地实现这个目标?

13.2 我们如何进行测试

讨论不同类型测试时最常用的工具之一是测试金字塔,如图 13.1 所示。它最初来自迈克·科恩的书籍《成功实施敏捷》(Addison-Wesley Professional,2009 年),金字塔表达了一种平衡不同类型测试成本的方法,以最大化它们为我们提供的帮助。

图 13.1 测试金字塔

尽管互联网上关于这些测试类型之间确切边界的争论激烈,但核心思想非常有用。

注意:这些类型的测试不是由你使用的工具定义的——你使用 JUnit 并不意味着你只是在编写单元测试,使用规范库也不能保证你实际上创建了对利益相关者有益的可用的验收测试。这些类型的测试是关于我们想要练习和证明的内容。

单元测试构成了金字塔的基础。这些是专注于测试系统某一方面的测试。我们所说的“一方面”是什么意思呢?最容易的部分是测试代码与外部依赖项的关系。如果你的测试在逻辑处理结果之前调用数据库,那么这不再是“一个”你要测试的事物——你现在正在测试数据库检索以及你的逻辑是否正确。这些外部依赖项也可能包括网络服务或文件。

避免违反单一关注点的一种常见方法是用测试替身——例如,让我们的单元测试与一个假对象通信而不是与真实的数据库通信。我们将在下一节中详细讨论这一点,但基本思想是这种欺骗有许多风味,如果要做好,我们需要考虑许多必要的事情。

单元测试因其多个原因而具有吸引力,因此它们在测试金字塔中的传统位置是最大的部分。这些原因包括以下内容:

  • 快速——如果一个测试没有外部依赖项,它的执行时间不应该很长。

  • 专注——通过只讨论一个“单元”的代码,测试所表达的内容通常比在更大、设置更复杂的测试中更清晰。

  • 可靠的失败——最小化外部依赖项,特别是对外部状态的依赖,有助于使单元测试更加确定。

所有这些都听起来很棒,那么为什么我们不是一直只写单元测试呢?事实是,单元测试有其局限性,这阻止了它们在需要测试的每个规模上都有用。问题包括以下内容:

  • 紧密耦合——由于单元测试本质上与其实现紧密相关,它们也容易过于紧密地绑定到那些实现选择上。当底层实现发生变化时,整个单元测试套件失效的情况并不少见。

  • 缺失有意义的交互——虽然将我们的代码视为一队只关心自己事务的对象很有吸引力,但现实是程序的真正工作包括这些依赖部分之间的交互,而单元测试可能会错过这些交互。

  • 内部关注——测试的目标通常是证明我们的软件最终用户得到了正确的结果。很少有一个方法的正确性实际上会转化为满意的用户。

集成测试,金字塔的下一步,摆脱了单元测试中与依赖项通信的限制。集成测试跨越这些边界,实际上可能专注于确保系统的不同部分能够无缝集成。

与单元测试一样,集成测试也可以选择只测试系统的部分。一些依赖项,如外部服务,可能仍然可以用测试替身来替换,而其他依赖项,如数据库,则适合进行测试。关键在于,测试的范围超出了单个“单元”代码。

单元测试和集成测试之间的确切界限可能很模糊。尽管如此,以下是一些明显跨越界限进入集成测试领域的例子:

  • 你需要一个数据库实例,并调用你的数据访问代码。

  • 你启动一个特殊的进程内 HTTP 服务器,并对其发起测试请求。

  • 你会对另一个服务进行实际调用(无论是否在测试环境中)。

集成测试具有许多不错的特性,如下所示:

  • 更广泛的覆盖范围——集成测试必然需要测试更多的代码以及你依赖的库的代码。

  • 更多验证——某些类型的错误可能只有在使用真实依赖项时才能检测到。例如,SQL 语句中的语法错误在没有调用实际数据库的情况下很难找到。

当然,没有选择是没有权衡的。如果管理不当,集成测试可能会成为痛苦之源,原因如下:

  • 慢速测试——访问真实数据库而不是从内存中读取值要慢得多。将这一点乘以数百或数千次测试,你可能会发现自己等待……很长时间。

  • 非确定性结果——外部依赖增加了重要状态可能在测试运行之间发生变化的可能性。例如,数据库中留下的记录可能会改变 SQL 语句返回的内容。

  • 虚假信心——集成测试有时使用与主系统略有不同的依赖项。例如,如果测试数据库的版本与生产不同,集成测试可能会错误地建议一切正常,而实际上并非如此。

对于所有这些困难,集成测试是测试领域的一个关键部分。

端到端测试超越了集成测试,目的是复制系统的完整用户体验。这可能意味着通过程序驱动网络浏览器或其他应用程序,或者在测试环境中操作服务的完全部署实例。端到端测试带来了以下难以在系统较低级别复制的优势:

  • “真实”用户体验——一个好的端到端测试接近用户所看到的内容。这让我们能够直接验证用户的高层次期望。

  • “真实”环境——许多端到端测试是在测试、预发布或甚至生产环境中运行的。这验证了我们的代码在舒适、精心管理的构建环境之外也能正常工作。

  • UI 可用性——许多端到端测试方法,如驱动网络浏览器的那些,可以查看系统的某些方面(例如,按钮是否渲染,因此可以点击),这些在其他地方可能很难验证。

但这种在我们端到端测试中的更大现实也伴随着下一个严峻的困难列表:

  • 更慢的测试—在许多单元测试几乎瞬间运行,甚至集成测试通常也能在几秒内完成的情况下,控制网络浏览器遍历网站的全链路测试必然需要更长的时间来运行。

  • 不稳定的测试—从历史上看,端到端测试的工具,尤其是 UI 测试,容易出问题,需要重试和长时间的超时来避免不必要的失败。

  • 脆弱的测试—因为端到端测试位于金字塔的顶部,任何低于这一级别的更改都可能导致失败。看似无害的文本更改可能会无意中破坏这些庞大的测试。

  • 更难的调试—因为端到端测试通常引入了另一个驱动测试的层,找出什么出了问题通常是一项繁琐的工作。

拥有这个金字塔,你可能会想问,“层与层之间正确的测试比例是多少?”事实是,没有唯一的答案。每个项目和系统的需求都不同。但金字塔可以帮助你指导你在选择如何测试系统中的每个功能部分时的利弊。

虽然这肯定不是唯一的方法,但经验丰富的开发者可能会发现,随着系统的演变,测试驱动开发有助于保持不同级别测试的清晰。

13.3 测试驱动开发

测试驱动开发(TDD)已经软件开发生业中存在了一段时间。其基本前提是在实现过程中编写测试,而不是之后,这些测试会影响代码的设计。一个常见的 TDD 方法,称为测试先行,是实际编写一个失败的测试,然后再提供实现,根据需要重构。例如,要编写两个字符串对象("foo""bar")的连接实现,你首先编写测试(测试结果必须等于"foobar"),以确保你知道你的实现是正确的。尽管许多开发者编写测试,但往往是在实现之后,从而失去了 TDD 的一些主要好处。

尽管看似无处不在,但许多开发者并不理解为什么他们应该进行测试驱动开发。许多开发者的疑问仍然是,“为什么编写测试驱动代码?有什么好处?”

我们相信,消除恐惧和不确定性是你应该编写测试驱动代码的压倒性原因。Kent Beck(JUnit 测试框架的共同发明者)也在他的书中很好地总结了这一点,测试驱动开发:通过示例(Addison-Wesley Professional,2002):

  • 恐惧会让你变得犹豫不决。

  • 恐惧会让你想要减少沟通。

  • 恐惧会让你回避反馈。

  • 恐惧会让你变得易怒。

TDD 消除了恐惧,使经验丰富的 Java 开发者变得更加自信、善于沟通、乐于接受并更快乐。换句话说,TDD 帮助你摆脱了导致这些陈述的心态:

  • 当开始一项新的工作时,“我不知道从哪里开始,所以我只会开始乱搞。”

  • 当更改现有代码时,“我不知道现有代码会如何表现,所以我秘密地太害怕去更改它。”

TDD 带来了许多其他好处,这些好处并不总是立即明显,如下所示:

  • 更干净的代码—您只编写所需的代码。

  • 更好的设计—一些开发者称 TDD 为测试驱动设计

  • 更好的 API—您的测试充当实现的一个额外客户端,可以提前揭示粗糙之处。

  • 更大的灵活性—TDD 鼓励编写接口代码。

  • 通过测试进行文档化—因为您不会在没有测试的情况下编写代码,所以所有内容都在测试中有示例用法。

  • 快速反馈—您现在就了解有关错误的信息,而不是在生产中。

对于刚开始接触的开发者来说,一个障碍是 TDD 有时可能被视为“普通”开发者不使用的技巧。这种看法可能是,只有某些想象中的“敏捷教会”或其他神秘运动的实践者使用 TDD,并且必须严格遵循每个 TDD 原则才能获得好处。这种看法是完全错误的,我们将证明这一点。TDD 是每个开发者的技巧。

13.3.1 TDD 概述

TDD 在单元测试级别上最容易,如果您不熟悉 TDD,这是一个好的起点。我们将从这里开始,但会展示 TDD 是如何工作的,特别是在单元测试和集成测试的边界上。

注意处理具有非常少或没有测试的现有代码可能是一项艰巨的任务。几乎不可能事后填充所有测试。相反,您应该为添加的每个新功能添加测试。有关进一步的帮助,请参阅 Michael Feathers 的杰出著作《与遗留代码有效工作》(Prentice Hall,2004)。

我们将从对 TDD 背后的红-绿-重构前提的简要覆盖开始,使用 JUnit 来测试驱动计算售票销售收入的代码。如果 JUnit 框架不熟悉,我们建议查看在线用户指南(见junit.org/junit5/docs/current/user-guide)或,如需更详细的信息,请参阅 Cătălin Tudose 的《JUnit 实战》(Manning,2020;mng.bz/gwOR)。让我们从一个 TDD 三个基本步骤的工作示例——红-绿-重构循环——开始,通过计算售票收入来计算收入。

13.3.2 单个用例的 TDD 示例

如果您是经验丰富的 TDD 实践者,您可能想跳过这个小型示例,尽管我们还会提供可能的新见解。假设您被要求编写一个坚如磐石的方法来计算销售一定数量戏院票所产生收入。剧院公司会计的初始业务规则很简单,如下所示:

  • 票的基准价格是 30 美元。

  • 总收入 = 售出票数 * 价格。

  • 戏院座位 = 100 人。

由于戏院没有非常好的销售点软件,用户目前必须手动输入售出的票数。

如果你已经实践过 TDD,你将熟悉 TDD 的三个基本步骤:红色、绿色、重构。如果你是 TDD 的新手或者需要一点复习,让我们来看看 Kent Beck 在《测试驱动开发:通过示例》中对这些步骤的定义:

  • 红色—编写一个不起作用的测试(失败的测试)。

  • 绿色—尽可能快地使测试通过(通过测试)。

  • 重构—消除重复(精炼通过测试)。

为了给你一个我们试图实现的TicketRevenue实现的思路,这里有一些你可能在脑海中的一些伪代码:

estimateRevenue(int numberOfTicketsSold)
  if (numberOfTicketsSold is less than 0 OR greater than 100)
    Deal with error and exit
  else
    revenue = 30 * numberOfTicketsSold;
    return revenue;
  endif

注意,你不必对此想得太深。测试将最终驱动你的设计和部分实现。

编写失败的测试(红色)

在这一步中的目的是从一个失败的测试开始。事实上,测试甚至无法编译,因为你还没有编写一个TicketRevenue类!

在与会计简短的白板会议后,你意识到你将需要为以下五种情况编写测试:负数的票销售、012–100> 100

写测试时(尤其是涉及数字时)的一个好规则是考虑零/null情况、一个情况以及许多(N)情况。在这一点上更进一步是考虑对N的其他约束,例如负数或超过最大限制的金额。

首先,你决定编写一个测试,覆盖一张票销售所收到的收入。你的 JUnit 测试将类似于以下代码(记住我们在这个阶段不是在编写一个完美的、通过测试):

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.*;

public class TicketRevenueTest {
  private TicketRevenue venueRevenue;

  @BeforeEach
  public void setUp() {
    venueRevenue = new TicketRevenue();
  }

  @Test
  public void oneTicketSoldIsThirtyInRevenue() {    ❶
    var expectedRevenue = new BigDecimal("30");
    assertEquals(expectedRevenue, venueRevenue.estimateTotalRevenue(1));
  }
}

❶ 一个已售出的案例

如你所见,代码中的测试非常清楚地期望一张票的销售收入等于 30。

但就目前而言,这个测试无法编译,因为你还没有编写一个包含estimateTotalRevenue(int numberOfTicketsSold)方法的TicketRevenue类。为了消除编译错误以便运行测试,你可以添加一个随机实现,使测试能够编译,如下所示:

public class TicketRevenue {
  public BigDecimal estimateTotalRevenue(int i) {
    return BigDecimal.ZERO;
  }
}

你可能会觉得测试提取一个可变的venueRevenue字段有点奇怪,因为我们的一般建议是优先考虑不可变性。背后的理由是,共享字段允许我们在(即将到来的)不同的测试用例之间表达一个共同的设置。我们的测试不需要与我们的生产代码相同的保护,而且突出显示所有测试用例之间相同的部分,提高了整体的可读性。

现在测试可以编译了,你可以从你喜欢的 IDE 或命令行运行它。对于命令行测试,Gradle 和 Maven 都提供了运行测试的简单方法(gradle testmvn test)。

注意:IDEs 也有它们自己运行 JUnit 测试的独特方式,但一般来说,它们都允许你右键单击测试类以获取运行测试选项。一旦你这样做,IDE 将显示一个窗口或部分,告诉你你的测试失败了,因为调用estimateTotalRevenue(1)没有返回预期的 30,而是返回了 0。

现在你有一个失败的测试,下一步是让测试通过(变为绿色)。

编写通过测试(绿色)

这一步的目的就是让测试通过,但实现不一定要完美。通过为TicketRevenue类提供一个更好的estimateTotalRevenue()实现(一个不仅仅返回 0 的实现),你会让测试通过(变为绿色)。

记住,在这个阶段,你试图让测试通过,而不一定需要编写完美的代码。你的初始解决方案可能看起来像以下代码:

import java.math.BigDecimal;

public class TicketRevenue {
  public BigDecimal estimateTotalRevenue(int numberOfTicketsSold) {
    BigDecimal totalRevenue = BigDecimal.ZERO;
    if (numberOfTicketsSold == 1) {
      totalRevenue = new BigDecimal("30");     ❶
    }

    return totalRevenue;
  }
}

❶ 通过测试的实现

当你现在运行测试时,它将通过,在大多数 IDE 中,这将以绿色条或勾号表示。即使是我们的命令行也会给我们一个友好的绿色消息,让我们知道代码一切正常。

下一个问题是你是否可以说“我完成了!”然后继续下一项工作?响亮的答案应该是“不!”像我们一样,你可能会迫不及待地整理之前的代码列表,所以让我们现在就着手做吧。

重构测试

这一步的目的就是查看你为了通过测试而编写的快速实现,并确保你遵循了公认的实践。显然,代码并不像它本可以的那样干净整洁。你当然可以重构它,并为将来自己和他人改善生活。

记住,现在你已经通过了考试,你可以无惧重构。你不会失去迄今为止所实现的企业逻辑的视线。

提示:通过编写初始通过测试,你已经给自己和更广泛的团队带来了一个好处,那就是更快的整体开发过程。其他团队成员可以立即使用这个代码的第一个版本,并开始与更大的代码库一起测试它(用于集成测试等)。

在这个例子中,你不想使用魔法数字——你想要确保 30 元的票价在代码中是一个命名的概念——所以我们编写以下代码:

import java.math.BigDecimal;

public class TicketRevenue {

  private final static int TICKET_PRICE = 30;                          ❶

  public BigDecimal estimateTotalRevenue(int numberOfTicketsSold) {
    BigDecimal totalRevenue = BigDecimal.ZERO;

    if (numberOfTicketsSold == 1) {
      totalRevenue = new BigDecimal(TICKET_PRICE *                     ❷
                                    numberOfTicketsSold);
    }

    return totalRevenue;
  }
}

❶ 没有魔法数字

❷ 重构后的计算

重构改进了代码,但显然它没有涵盖所有潜在用例(例如,负数、02–100> 100张票的销售)。

而不是试图猜测其他用例的实现应该是什么样子,你应该让进一步的测试来驱动设计和实现。下一节通过在这个票据收入示例中展示更多用例,遵循测试驱动设计。

具有多种用例的 TDD 示例

一种特定的 TDD 风格会持续地一次添加一个测试用例,针对我们之前提出的负数、02–100> 100的票务销售测试用例。但提前编写一组测试用例是完全有效的,尤其是如果它们与原始测试相关。

注意,在这里遵循红-绿-重构生命周期仍然非常重要。在添加所有这些用例后,你可能会得到一个有失败测试(红色)的测试类,如下所示:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.*;

public class TicketRevenueTest {

  private TicketRevenue venueRevenue;
  private BigDecimal expectedRevenue;

  @BeforeEach
  public void setUp() {
    venueRevenue = new TicketRevenue();
  }

  @Test
  public void failIfLessThanZeroTicketsAreSold() {                         ❶
    assertThrows(IllegalArgumentException.class,
                 () -> venueRevenue.estimateTotalRevenue(-1));
  }

  @Test
  public void zeroSalesEqualsZeroRevenue() {                               ❷
    assertEquals(BigDecimal.ZERO, venueRevenue.estimateTotalRevenue(0));
  }

  @Test
  public void oneTicketSoldIsThirtyInRevenue() {                           ❸
    expectedRevenue = new BigDecimal("30");
    assertEquals(expectedRevenue, venueRevenue.estimateTotalRevenue(1));
  }

  @Test
  public void tenTicketsSoldIsThreeHundredInRevenue() {                    ❹
    expectedRevenue = new BigDecimal("300");
    assertEquals(expectedRevenue, venueRevenue.estimateTotalRevenue(10));
  }

  @Test
  public void failIfMoreThanOneHundredTicketsAreSold() {                   ❺
    assertThrows(IllegalArgumentException.class,
                 () -> venueRevenue.estimateTotalRevenue(101));
  }
}

❶ 负数售出情况

❷ 未售出 0 张票的情况

❸ 售出 1 张票的情况

❹ 售出 N 张票的情况

❺ 售出超过 100 张票的情况

通过通过所有这些测试(绿色)的初始基本实现可能看起来如下:

import java.math.BigDecimal;

public class TicketRevenue {
  public BigDecimal estimateTotalRevenue(int numberOfTicketsSold)
    throws IllegalArgumentException {

    if (numberOfTicketsSold < 0) {
      throw new IllegalArgumentException(                   ❶
                  "Must be > -1");
    }

    if (numberOfTicketsSold == 0) {
      return BigDecimal.ZERO;
    }

    if (numberOfTicketsSold == 1) {
      return new BigDecimal("30");
    }

    if (numberOfTicketsSold == 101) {
      throw new IllegalArgumentException(                   ❷
                  "Must be < 101");
    }

    return new BigDecimal(30 * numberOfTicketsSold);        ❸
  }
}

❶ 异常情况

❷ 异常情况

❸ 已售 N 张票的情况

在刚刚完成实现后,你现在有了通过的所有测试。

再次强调,通过遵循 TDD 生命周期,你现在将重构该实现。例如,你可以将非法的numberOfTicketsSold情况(小于 0 或大于 100)合并为一个if语句,并使用公式(TICKET_PRICE * numberOfTicketsSold)来返回所有其他合法numberOfTicketsSold值的收入。以下代码应类似于你可能会想到的:

import java.math.BigDecimal;

public class TicketRevenue {

  private final static int TICKET_PRICE = 30;

  public BigDecimal estimateTotalRevenue(int numberOfTicketsSold)
    throws IllegalArgumentException {

    if (numberOfTicketsSold < 0 || numberOfTicketsSold > 100) {
      throw new IllegalArgumentException(                           ❶
                    "# Tix sold must == 1..100");
    }

    return new BigDecimal(TICKET_PRICE *                            ❷
                          numberOfTicketsSold);
  }
}

❶ 异常情况

❷ 所有其他情况

TicketRevenue类现在更加紧凑,但仍通过所有测试!你已经完成了完整的红-绿-重构周期,可以自信地继续你的下一部分业务逻辑。或者,如果你(或会计)发现了任何遗漏的边缘情况,例如可变票价,你可以再次开始这个周期。

13.4 测试替身

当你继续以 TDD 风格编写代码时,你很快会遇到代码引用某些(通常是第三方)依赖或子系统的情形。在这种情况下,你通常会想确保正在测试的代码与该依赖项隔离,以确保你只编写针对你实际构建内容的测试代码。你还会希望测试尽可能快地运行,特别是如果你旨在编写单元测试而不是集成测试。调用第三方依赖或子系统,如数据库,可能需要很长时间,这意味着你失去了 TDD 的快速反馈优势。测试替身是解决这个问题的方案。

在本节中,你将了解测试替身如何帮助你有效地隔离依赖和子系统。你将通过使用四种测试替身类型(模拟、存根、伪造和模拟)的示例来操作。我们还将探讨测试替身带来的风险和困难,以及它们的优点。

我们喜欢 Gerard Meszaros 在他的《xUnit Test Patterns》一书中对测试替身的简单解释(Addison-Wesley Professional, 2007),所以我们很乐意在这里引用他:“测试替身(想想特技替身)是任何用于测试目的而代替真实对象的虚拟对象的通用术语。”

Meszaros 定义了四种测试替身类型,这些类型在表 13.1 中概述。

表 13.1 四种测试双胞胎类型

类型 描述
虚拟对象 一个被传递但从未使用的对象;通常用于满足方法参数列表
存根 总是返回相同预定义响应的对象;也可能持有一些虚拟状态
模拟 一个实际工作的实现(不是生产质量或配置),可以替换真实实现
模拟 代表一系列期望并提供预定义响应的对象

当你通过使用它们的代码示例来理解四种测试双胞胎类型时,它们就变得容易理解得多。现在让我们开始做,从虚拟对象开始。

13.4.1 虚拟对象

一个虚拟对象是四种测试双胞胎类型中最容易使用的。记住,它被设计用来帮助填充参数列表或满足你知道对象永远不会被使用的某些强制字段要求。在许多情况下,你甚至可以传递一个空对象(甚至null,尽管这并不保证是安全的)。

让我们回到剧院票的情景。对你单一售票亭的收入进行估计是非常好的,但剧院的所有者已经开始考虑得更大一些。需要更好地建模已售出的票和预期的收入,你听到更多要求和复杂性的低语。

你被要求跟踪已售出的票,并允许某些票享受 10%的折扣价格。看起来你需要一个Ticket类来提供折扣价格方法。你从熟悉的 TDD 周期开始,用一个失败的测试,专注于新的getDiscountPrice()方法。你还知道将需要几个构造函数:一个用于普通价格的票,另一个票面价值可能不同。Ticket对象最终将期望以下两个参数:

  • 客户端名称——一个在此测试中根本不会被引用的String

  • 正常价格——一个将用于此测试的BigDecimal

你相当确定客户端名称在getDiscountPrice()方法中不会被引用。这意味着你可以向构造函数传递一个虚拟对象(在这种情况下,任意的字符串"Riley"),如下面的代码所示:

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.*;

public class TicketTest {

  private static String dummyName = "Riley";                   ❶

  @Test
  public void tenPercentDiscount() {
    Ticket ticket = new Ticket(dummyName,                      ❷
                                new BigDecimal("10"));

    assertEquals(new BigDecimal("9.0"), ticket.getDiscountPrice());
  }
}

❶ 创建一个虚拟对象

❷ 传递一个虚拟对象

如你所见,虚拟对象的概念是微不足道的。

为了使概念非常清晰,以下代码片段中有一个Ticket类的部分实现:

import java.math.BigDecimal;

public class Ticket {

  public static final int BASIC_TICKET_PRICE = 30;              ❶
  private static final BigDecimal DISCOUNT_RATE =               ❷
                                      new BigDecimal("0.9");

  private final BigDecimal price;
  private final String clientName;

  public Ticket(String clientName) {
    this.clientName = clientName;
    price = new BigDecimal(BASIC_TICKET_PRICE);
  }

  public Ticket(String clientName, BigDecimal price) {
    this.clientName = clientName;
    this.price = price;
  }

  public BigDecimal getPrice() {
    return price;
  }

  public BigDecimal getDiscountPrice() {
    return price.multiply(DISCOUNT_RATE);
  }
}

❶ 默认价格

❷ 默认折扣

一些开发者被虚拟对象搞糊涂了——他们寻找并不存在的复杂性。虚拟对象非常简单:它们是任何旧对象,用于避免NullPointerException并使代码运行。

让我们继续到下一种测试双胞胎类型。在复杂性方面,下一步是存根对象。

13.4.2 存根对象

当你想要用一个每次都会返回相同响应的对象替换真实实现时,通常使用 存根 对象。让我们回到我们的剧院票价定价示例,看看它是如何发挥作用的。

你在实现了 Ticket 类之后,从一次应得的假期中回来,你的收件箱中的第一件事是一份错误报告,指出你的 tenPercentDiscount() 测试现在间歇性地失败。当你查看代码库时,你会发现 Ticket 类现在使用一个具体的 HttpPrice 类,该类实现了新引入的 Price 接口。正如其名所示,HttpPrice 会联系外部网站,并可能在任何时刻返回不同的值或失败。

这使得测试失败,但进一步污染了我们的测试目的。记住,你只是想单元测试计算 10% 折扣!

注意:调用第三方定价网站绝对不是本测试的责任。应该有单独的集成测试来覆盖 HttpPrice 类及其第三方 HttpPricingService

为了让我们的测试回到一致、稳定的状态,我们将用存根替换 HttpPrice 类。首先,让我们看看当前代码的状态,如下面的三个代码片段所示:

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.*;

public class TicketTest {

  private static String dummyName = "Riley";

  @Test
  public void tenPercentDiscount() {
    Price price = new HttpPrice();                    ❶
    Ticket ticket = new Ticket(dummyName, price);     ❷
    assertEquals(new BigDecimal("9.0"),               ❸
                  ticket.getDiscountPrice());
  }
}

❶ HttpPrice 实现 Price 接口。

❷ 创建票据

❸ 测试可能会失败。

下一个片段显示了 Ticket 的新实现:

import java.math.BigDecimal;

public class Ticket {
  private final String clientName;
  private final Price priceSource;
  private final BigDecimal discountRate;

  private BigDecimal faceValue = null;

  public Ticket(String clientName,
                Price price,
                BigDecimal discountRate) {          ❶
    this.clientName = clientName;
    this.priceSource = price;
    this.discountRate = discountRate;
  }

  public BigDecimal getPrice() {
    if (faceValue == null) {
      faceValue = priceSource.getInitialPrice();    ❷
    }

    return faceValue;
  }

  public BigDecimal getDiscountPrice() {
    return faceValue.multiply(discountRate);        ❸
  }
}

❶ 改变的构造函数

❷ 新的 getInitialPrice 调用

❸ 未改变的计算

提供完整的 HttpPrice 类实现会让我们偏离太远,所以让我们假设它调用另一个类,HttpPricingService,如下所示:

import java.math.BigDecimal;

public interface Price {
  BigDecimal getInitialPrice();
}

public class HttpPrice implements Price {
  @Override
  public BigDecimal getInitialPrice() {
    return HttpPricingService.getInitialPrice();    ❶
  }
}

❶ 返回随机结果

现在我们已经调查了损害情况,让我们考虑我们原本想要测试的内容。我们的目标是展示 Ticket 类的 getDiscountPrice() 方法中的乘法按预期工作。证明这一点不需要外部网站。

Price 接口为我们提供了替换我们敏感的 HttpPrice 实例与一致的 StubPrice 实现所需的接口,如下所示:

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.*;

public class TicketTest {
  @Test
  public void tenPercentDiscount() {
    Price price = new StubPrice();               ❶
    Ticket ticket = new Ticket(price);           ❷
    assertEquals(new BigDecimal("9.0"),          ❸
                  ticket.getDiscountPrice());
  }
}

❶ StubPrice 存根

❷ 创建一个票据

❸ 检查价格

StubPrice 类是一个简单的类,它始终返回初始价格 10,如下所示:

import java.math.BigDecimal;

public class StubPrice implements Price {
  @Override
  public BigDecimal getInitialPrice() {
    return new BigDecimal("10");               ❶
  }
}

❶ 返回一致的价格

呼吁!现在测试再次通过,同样重要的是,你可以放心地重构其余的实现细节。

存根是有用的测试双胞胎类型,但有时我们希望存根执行一些更接近生产系统的实际工作。为此,你使用模拟对象作为你的测试双胞胎。

13.4.3 模拟对象

一个对象可以被视为一个增强的存根,它几乎与您的生产代码做相同的工作,但为了满足您的测试要求而采取了一些捷径。当您希望您的代码针对与您在实时实现中使用的真实第三方子系统或依赖非常接近的东西运行时,假对象特别有用。

对于我们的票务应用程序,让我们假设我们的数据库层提供了一个用于处理票务的简单接口,如下所示:

package com.wellgrounded;

public interface TicketDatabase {
    Ticket findById(int id);
    Ticket findByName(String name);
    int count();

    void insert(Ticket ticket);
    void delete(int id);
}

我们用于管理单个演出的类需要与这个数据库接口一起工作,并管理诸如检查我们是否像这样过度销售等功能:

package com.wellgrounded;

import java.math.BigDecimal;

public class Show {
    private TicketDatabase db;
    private int capacity;

    public Show(TicketDatabase db, int capacity) {
        this.db = db;
        this.capacity = capacity;
    }

    public void addTicket(String name, BigDecimal amount) {
        if (db.count() < capacity) {
            var ticket = new Ticket(name, amount);
            db.insert(ticket);
        } else {
          throw new RuntimeException("Oversold");
        }
    }
}

我们希望在不需要完全实例化我们的关系数据库的情况下对addTicket进行单元测试。这样的测试可能看起来像这样:

package com.wellgrounded;

import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;

public class ShowTest {
    @Test
    public void plentyOfSpace() {
        var db = new FakeTicketDatabase();     ❶
        var show = new Show(db, 5);

        var name = "New One";
        show.addTicket(name, BigDecimal.ONE);

        var mine = db.findByName(name);
        assertEquals(name, mine.getName());
        assertEquals(BigDecimal.ONE, mine.getAmount());
    }
}

FakeTicketDatabase不存在,但本着 TDD 的精神,我们将编写我们想要通过的代码。

虽然我们可以通过存根(stubbing)来实现这一点,但这会有很大的缺点。我们不得不对数据库的countinsert方法进行存根处理,而这些方法在测试中甚至都不可见,这会让测试变得杂乱无章,并分散了测试的实际目的。但困难还远不止于此——每个测试都必须确保countinsert调用次数之间的关系是一致的。更糟糕的是,我们最后的findByName调用,其目的是确保我们的数据已被保存,也需要进行存根处理。但正是这种存根意味着断言变得毫无用处——无论实现代码是否正确,它都会通过!存根不足以让我们准确验证这一系列紧密相关的操作。

一个假对象通过提供一个真实但简化的实现来提供另一种选择。提供的接口,如下所示,可以通过围绕简单的HashMap的包装来轻松提供:

package com.wellgrounded;

import java.util.HashMap;

class FakeTicketDatabase implements TicketDatabase {
    private HashMap<Integer, Ticket> tickets =              ❶
                                      new HashMap<>();
    private Integer nextId = 1;                             ❷

    @Override
    public Ticket findByName(String name) {
        var found = tickets.values()
                .stream()
                .filter(ticket -> ticket.getName().equals(name))
                .findFirst();
        return found.orElse(null);
    }

    @Override
    public int count() {
        return tickets.size();
    }

    @Override
    public void insert(Ticket ticket) {
        tickets.put(nextId, ticket);
        nextId++;
    }

    // Remaining methods available in resources
}

❶ 我们的地图在单元测试的生命周期中取代了数据库的位置。

❷ 我们必须复制诸如数据库自动递增 ID 等特性。

假对象,尤其是在具有强大接口的项目中共享时,可以是为支持单元测试提供的一种很好的解决方案。它们并不适用于所有地方——如果我们的数据库接口允许我们传递 SQL 子句进行额外的过滤,这很快就会超出我们假对象处理的能力——但它们是一个有用的工具。只需注意,实现不要变得太大或太复杂,因为我们写的每一行代码都可能是 bug 的来源。

13.4.4 模拟对象

Mock对象与您已经遇到的存根测试双胞胎有关,但存根对象通常是相当愚蠢的生物。例如,存根通常会模拟方法以始终给出相同的结果。这并不提供任何方式来模拟状态相关行为。

例如:你正在尝试遵循 TDD(测试驱动开发),并且你正在编写一个文本分析系统。你其中一个单元测试指示文本分析类对一个特定的博客文章计算“Java11”短语出现的次数。但由于博客文章是第三方资源,存在许多可能的失败场景,这些场景与你要编写的计数算法关系甚微。换句话说,被测试的代码没有隔离,调用第三方资源可能会很耗时。以下是一些常见的失败场景:

  • 由于你所在组织的防火墙限制,你的代码可能无法上网查询博客文章。

  • 博客文章可能已经被移动,但没有重定向。

  • 博客文章可能被编辑以增加或减少“Java11”出现的次数。

使用桩,这个测试几乎不可能编写,并且对于每个测试用例来说都会非常冗长。这时就出现了模拟对象。这是一种特殊的测试双胞胎,你可以将其视为可编程的桩。使用模拟对象非常简单:当你准备模拟对象使用时,你告诉它预期的调用序列以及它应该如何对每个调用做出响应。

让我们通过查看一个简单的示例来观察这个行为,这个示例是关于剧院票用例的。我们将使用流行的模拟库 Mockito (site.mockito.org)。以下代码片段展示了如何使用它:

import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

public class TicketTest {
  @Test
  public void tenPercentDiscount() {
    Price price = mock(Price.class);       ❶

    when(price.getInitialPrice())
      .thenReturn(new BigDecimal("10"));   ❷

    Ticket ticket = new Ticket(price, new BigDecimal("0.9"));
    assertEquals(new BigDecimal("9.0"), ticket.getDiscountPrice());

    verify(price).getInitialPrice();
  }
}

❶ 创建模拟

❷ 为测试编程模拟

要创建一个模拟对象,你调用静态的mock()方法,并传入你想要模拟的类型的类对象。然后,通过调用when()方法来指示要记录哪个方法,以及通过调用thenReturn()来指定预期的结果来“记录”你希望模拟对象显示的行为。最后,你验证在模拟对象上是否调用了预期的方法。这确保你没有通过错误路径得到正确的结果。

这个验证捕捉了桩(stub)和模拟(mock)之间的重大差异。使用桩时,你的主要关注点是返回预设值。使用模拟时,目的是验证行为,例如实际做出的精确调用。在实践中,如果我们忽略验证,Mockito 丰富的mock()方法可以很容易地用来创建桩,但重要的是,作为程序员,你必须清楚你打算测试什么。

你可以使用模拟对象就像使用普通对象一样,并将其传递给Ticket构造函数的调用,无需任何额外的仪式。这使得模拟对象成为 TDD(测试驱动开发)中一个非常强大的工具。一些实践者实际上并不使用其他类型的测试双胞胎,而是几乎用模拟对象做所有事情。但正如许多强大的工具一样,模拟也伴随着需要注意的锋利边缘。

13.4.5 模拟的问题

测试替身(test doubles)最大的困难之一正是它们是假的,因此它们的行为可能会与实际的生产系统有所不同。不幸的是,这种情况可能会在你仍然感到温暖、安慰地认为你的测试已经全面覆盖了所有内容时发生——直到现实打破这种幻想。

这些行为差异可以有多种形式。常见的一些包括以下内容:

  • 返回负载的差异,尤其是与复杂嵌套对象相关

  • 序列化/反序列化测试数据的差异

  • 集合中项目的顺序

  • 对错误条件的响应——要么是未能抛出异常,要么是抛出不同的异常类型

虽然没有一劳永逸的解决方案,但这些问题通常可以在我们提升到集成测试级别时被发现。再次强调,如果我们始终牢记每套测试中我们在测试什么,我们就可以将单元测试集中在局部逻辑上,并在其他地方使用集成测试来覆盖与我们的依赖项的交互。

我们接口的稳固设计也有助于我们的测试替身。而不是服务类从 HTTP 调用返回原始内容字符串,返回一个特定对象让我们的测试替身有更少的变动空间。为你的类抛出的异常提供精确的子类,包装更原始的异常类型,不仅使你的代码更具表现力,而且更容易准确模拟。

如果在所有地方都使用模拟(mocking),也可能导致我们的测试过于紧密地模仿我们的生产代码。当这种情况发生时,你的实际代码中的每一行在测试配置中都有一个对应的行,重申了精确的预期调用。这样的测试非常脆弱,通常在修改代码时会导致大量看似无关的更改。

当模拟时,这种脆弱性甚至延伸到单个参数的级别。尽管框架使你能够非常精确地指定传递的值,但请考虑你的测试是否真的需要验证该参数。正如我们通过哑对象(dummy objects)所看到的,对于某些测试用例,给定的值可能并不重要。模拟框架提供了允许类似“任何整数”这样的语句的机制,如果值不重要,这些语句既明确了你的测试关心什么,又为生产代码的更容易演变留出了空间。给你的测试留出呼吸的空间!

当测试在运行前需要大量复杂的设置时,测试也可以暴露问题。特别是当与依赖注入结合使用时,模拟(Mocking)可以使在类中堆积依赖变得容易,而不会引起太多注意。如果你的测试设置比执行和验证结果的代码还要长,那么这可能是一个提示,表明你的类可能过于复杂,需要重构。测试设置也是观察违反迪米特法则(Law of Demeter)的绝佳方式,该法则建议对象只应了解其直接邻居。如果你的测试设置需要与自身多级之外的物体打交道,那么你的对象可能已经超出了自己的范围。

测试双倍(Test doubles)是经验丰富的开发者的一项宝贵工具。到目前为止,我们在讨论中已经稍微介绍了一些 JUnit,但还没有深入探讨。让我们更仔细地看看它,并借此机会看看 JUnit 5,最新主要版本的新特性。

13.5 从 JUnit 4 到 JUnit 5

JUnit 是基于 JVM 的 xUnit 风格测试框架的实现,最初由 Kent Beck 和 Erich Gamma 开发。这种单元测试风格已被证明是灵活且易于使用的,使 JUnit 成为 JVM 生态系统中最常用的库之一。

这段悠久的历史和广泛的使用也带来了许多限制。JUnit 4 最初于 2006 年发布,不能使用 lambda 表达式等特性而不破坏兼容性。2017 年,JUnit 5 发布,利用主要版本变更的机会引入了重大变化。

注意:下一章将花费大量时间介绍其他工具和技术,但经验丰富的 Java 开发者几乎肯定会遇到值得迁移到最新版本的 JUnit 代码。

JUnit 5 最大的变化之一是其打包方式。之前的版本是单体式的,包含编写测试的 API 以及运行和报告这些测试的支持,而 JUnit 5 将事物分解为更专注的包。JUnit 5 还摆脱了 JUnit 4 附带的外部依赖项 Hamcrest。

JUnit 5 位于一个全新的包中—org.junit.jupiter—这意味着在迁移期间两个版本可以共存。我们稍后会更详细地探讨这一点。

JUnit 5 的主要两个依赖项如下:

  • org.junit.jupiter.junit-jupiter-api—这是从你的测试代码中引用的,以提供编写测试所需的所有必要的注解和辅助工具。

  • org.junit.jupiter.junit-jupiter-engine—这是运行 JUnit 5 测试的默认引擎。它仅作为运行时依赖项,不是编译时依赖项,并且可以被增强或替换为其他测试运行器。

在 Gradle 中,这看起来是这样的,同时还有一个提示告诉 Gradle 在运行测试时使用新的 JUnit 组件:

dependencies {
  testImplementation("org.junit.jupiter:junit-jupiter-api:5.7.1")
  testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.7.1")
}

tasks.named<Test>("test") {
  useJUnitPlatform()
}

Maven 的等效项如下。Maven 的 surefirefailsafe 插件知道如何与 JUnit 5 自动工作,只要你有一个足够新的版本(建议使用 2.22 或更高版本):

<project>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-api</artifactId>
      <version>5.7.1</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-engine</artifactId>
      <version>5.7.1</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

如果你将这些添加到 JUnit 4 项目中并简单地运行测试,你会发现一个奇怪的结果——套件可能会通过,但如果仔细查看报告,实际上没有测试运行过! 这是因为实际的标记测试用例的注解随着 JUnit 5 的变化而变化。

注意 JUnit 5 在包 org.junit.jupiter.api 中带来了自己的 @Test 注解。默认情况下,它不会识别用较旧的 org.junit 版本的 @Test 标记的现有测试!

从这个点开始,有两种路径可以选择。第一种是更改所有导入旧注解的地方,使用新版本。逐个类地,你的测试套件将开始在 JUnit 5 下运行。这些转换可能需要我们稍后讨论的其他工作。

另一个选择是引入对 junit-vintage-engine 的额外运行时依赖。这个包使用 JUnit 5 更丰富的能力来插入不同的运行器和支持类,以允许与 JUnit 4(甚至 3)测试的向后兼容性。

在 Gradle 中:

dependencies {
  testImplementation("org.junit.jupiter:junit-jupiter-api:5.7.1")
  testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.7.1")

  testRuntimeOnly("org.junit.vintage:junit-vintage-engine:5.7.1")
}

在 Maven 中:

<project>
  <dependencies>
    <dependency>                                         ❶
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.13</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.junit.vintage</groupId>
      <artifactId>junit-vintage-engine</artifactId>
      <version>5.7.1</version>
      <scope>test</scope>
    </dependency>
    <dependency>                                         ❷
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-api</artifactId>
      <version>5.7.1</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-engine</artifactId>
      <version>5.7.1</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>

❶ 支持在 JUnit 5 中运行 JUnit 4 测试

❷ 我们主要的 JUnit 5 依赖项,api 和 engine

这种支持可以允许更容易的过渡,因为你可以启用 JUnit 5,然后随着时间的推移逐个转换测试,而不是要求一次性迁移所有内容。不过,值得注意的是,旧版支持确实有一些限制,这些限制在 JUnit 用户指南中有很好的文档记录,可在 mng.bz/5Q61 查阅。

除了新的打包方式,还重命名了各种类,使它们更清晰、更准确地反映其用法,如下所示:

  • @Before 改为 @BeforeEach.

  • @After 改为 @AfterEach.

  • @BeforeClass 改为 @BeforeAll.

  • @AfterClass 改为 @AfterAll.

  • @Category 改为 @Tag.

  • @Ignored 改为 @Disabled(或者在新扩展模型中可能通过 ExecutionCondition 处理)。

  • @RunWith@Rule@ClassRule 被新的扩展模型所取代。

如最后几点所暗示的,JUnit 5 的一个重要特性是新的扩展模型,它涵盖了 JUnit 早期版本中的各种单独特性。这些特性允许你在类之间共享行为——例如测试设置、清理和期望设置——但它们并没有很好地结合在一起。

例如,这里有一个基本的 JUnit 4 测试,在测试运行之前需要启动服务器。它使用了 ExternalResource 类,以及一个 @Rule 注解来要求在生命周期中的正确点调用:

package com.wellgrounded;

import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.rules.ExternalResource;

import static org.junit.Assert.*;

public class PasswordCheckerTest {
    private PasswordChecker checker = new PasswordChecker();

    @Rule                                                ❶
    public ExternalResource passwordServer =
                              new ExternalResource() {   ❷
        @Override
        protected void before() throws Throwable {
            super.before();
            checker.reset();
            checker.start();
        }

        @Override
        protected void after() {
            super.after();
            checker.stop();
        }
    };

    @Test
    public void ok() {
        assertTrue(checker.isOk("abcd1234!"));
    }
}

@Rule 要求在每个测试之前/之后应用。

ExternalResource 是由 JUnit 特别为这样的自定义设置/清理场景提供的。

我们对 ExternalResource 的覆盖可以轻松地拉入另一个位置,并在我们的测试之间共享。

JUnit 5 将测试生命周期分解为更小的接口,您需要实现。然后,这些扩展可以像下面展示的那样应用于类或测试方法级别:

package com.wellgrounded;

import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;

public class PasswordCheckerExtension
    implements AfterEachCallback, BeforeEachCallback {                     ❶

    private PasswordChecker checker;

    PasswordCheckerExtension(PasswordChecker checker) {                    ❷
        this.checker = checker;
    }

    @Override
    public void beforeEach(ExtensionContext context) {                     ❸
        checker.reset();
        checker.start();
    }

    @Override
    public void afterEach(ExtensionContext context) {                      ❸
        checker.stop();
    }
}

❶ 我们实现了 AfterEachCallback 和 BeforeEachCallback,以便在每个测试方法中像以前一样被调用。同样存在 AfterAllCallback 和 BeforeAllCallback,用于替代 @ClassRule 功能。

❷ 因为我们的扩展与测试类上的字段一起工作,我们需要在构造时传递它。

❸ 回调按之前的方式进行,以执行设置/清理工作。

拥有这个类后,我们可以在测试中如下应用它:

package com.wellgrounded;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import static org.junit.Assert.*;

public class PasswordCheckerTest {
    private static PasswordChecker checker = new PasswordChecker();

    @RegisterExtension                                                     ❶
    static PasswordCheckerExtension ext =
              new PasswordCheckerExtension(checker);                       ❷

    @Test
    public void ok() {
        assertTrue(checker.isOk("abcd1234!"));
    }
}

❶ @RegisterExtension 允许我们为测试实例化扩展。

❷ 测试类上的字段必须是公共的,才能与 @RegisterExtension 一起工作。

如果扩展在构造时不需要参数,它也可以使用 @ExtendWith 应用于类或方法定义,如下所示:

@ExtendWith(CustomConfigurationExtension.class)
public class PasswordCheckerTest {
    // ....
}

JUnit 5 要求使用更近期的 JDK 版本,这也清理了一些角落,其中规则曾经是标准方法。在 JUnit 4 及更早版本中,检查测试方法是否抛出异常可以采取以下两种形式:

package com.wellgrounded;

import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;

import static org.junit.Assert.*;

public class PasswordCheckerTest {
    private PasswordChecker checker = new PasswordChecker();

    @Rule
    public ExpectedException ex = ExpectedException.none();

    @Test
    public void nullThrows() {
        ex.expect(IllegalArgumentException.class);     ❶
        checker.isOk(null);
    }

    @Test(expected = IllegalArgumentException.class)   ❷
    public void alsoThrows() {
        checker.isOk(null);
    }
}

❶ 在测试方法上的基于规则的异常检查

❷ 测试注解中期望异常的配置

Lambda 表达式提供了一种新的表达方式,看起来更像我们的典型断言,如下所示:

package com.wellgrounded;

import org.junit.jupiter.api.Test;

import static org.junit.Assert.*;

public class PasswordCheckerTest {
    private static PasswordChecker checker = new PasswordChecker();

    @Test
    public void nullThrows() {
        assertThrows(IllegalArgumentException.class, () -> {
            checker.isOk(null);
        });
    }
}

assertThrows 相比于我们测试注解中的旧 expected 参数或 ExpectedException 规则,有几个原因更可取。首先,断言更加直接,位于我们实际测试的代码所在的位置,而不是方法的开头。此外,assertThrows 在我们不需要设置 try/catch 的情况下返回异常,因此我们可以更轻松地执行关于发生了什么的典型断言,而不需要特殊配置。尽管 JUnit 4 在生态系统中的遗留问题意味着它还将存在并维护一段时间,但如果你在使用 JUnit,了解新版本提供的内容是值得的。

尽管我们的测试库只是其中的一部分,尤其是当我们编写集成测试时。下一章将深入探讨一些有助于解决使用外部依赖的长期难题的有用工具。

摘要

  • 我们讨论了测试的动机和类型,以及了解我们正在测试的内容的答案对于决定方法至关重要。

  • 我们回顾了测试驱动开发,看到它如何以逐步的方式让我们有信心地演进设计。

  • 我们检查了各种测试替身,它们有什么用,更重要的是,如果误用,它们会在哪里引起麻烦。

  • 经过多年,JUnit 的新版本发布,解决了长期的设计问题,通常是来自长期废弃的 JDK 的限制。我们简要地探讨了将测试的基本知识迁移到这个最新版本。

14 超越 JUnit 的测试

本章涵盖了

  • 使用 Testcontainers 进行集成测试

  • 使用 Spek 和 Kotlin 进行规范式测试

  • 使用 Clojure 进行基于属性的测试

在上一章中,我们研究了指导我们测试的一般原则。现在我们将更深入地探讨针对不同情况的具体方法来改进我们的测试。无论我们的目标是更干净地测试依赖项、在测试代码中更好的沟通,还是甚至发现我们没有考虑到的边缘情况,JVM 生态系统提供了许多工具来帮助我们,我们只突出其中的一些。让我们从那个始终存在的斗争开始:在编写集成测试时如何有效地处理外部依赖项。

14.1 使用 Testcontainers 进行集成测试

当我们从隔离的单元测试向上移动金字塔时,我们会遇到各种障碍。要针对真实数据库进行集成测试,我们需要一个可用的真实数据库!从现实测试中获得好处意味着设置复杂性的巨大增加。这些外部系统的状态性也增加了我们的测试失败的可能性,不是因为我们的代码有问题,而是因为测试之间意外保留的状态。

在过去的几年里,这已经以许多方式解决,从内存数据库到在事务中完全运行测试并自行清理的框架。但这些解决方案通常带来了自己的边缘情况和困难。

如第十二章所述的容器化技术,为解决该问题提供了一种有趣的新方法。因为容器是短暂的,非常适合为特定的测试运行启动。因为它们封装了我们想要与之交互的真实数据库和其他服务,所以避免了内存数据库容易出现的细微不匹配的替代品。

14.1.1 安装 testcontainers

在我们的测试中利用容器的最简单方法之一是通过testcontainers库(见www.testcontainers.org/))。这为我们提供了从测试代码中直接控制容器的 API,以及广泛支持的模块,用于常见的依赖项。核心功能是通过 Maven 中的org.testcontainers.testcontainers JAR 提供:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers</artifactId>
  <version>1.15.3</version>
  <scope>test</scope>
</dependency>

或者 Gradle:

testImplementation "org.testcontainers:testcontainers:1.15.3"

14.1.2 Redis 示例

如果你还记得,我们留下了我们的剧院应用程序从 HTTP 服务下载价格。我们希望为这些值引入一个缓存。尽管适当的缓存是一个完整的主题,但想象一下,我们决定将缓存外部化,而不是仅仅将值放在内存中。这种数据存储的典型例子是 Redis (redis.io/)。Redis 提供了快速访问以获取、设置和删除键值对,以及其他更复杂的数据结构。

我们已经为从 HTTP 服务中查找数据而引入的Price接口,如下所示,使我们能够将缓存作为单独的关注点添加:

package com.wellgrounded;

import redis.clients.jedis.Jedis;

import java.math.BigDecimal;

public class CachedPrice implements Price {
    private final Price priceLookup;
    private final Jedis cacheClient;

    private static final String priceKey = "price";       ❶

    CachedPrice(Price priceLookup, Jedis cacheClient) {   ❷
        this.priceLookup = priceLookup;
        this.cacheClient = cacheClient;
    }

    @Override
    public BigDecimal getInitialPrice() {
        String cachedPrice = cacheClient.get(priceKey);   ❸
        if (cachedPrice != null) {
            return new BigDecimal(cachedPrice);
        }

        BigDecimal price =
            priceLookup.getInitialPrice();                ❹
        cacheClient.set(priceKey,
                        price.toPlainString());           ❺
        return price;
    }
}

❶ 我们将在 Redis 中缓存价格的关键字名称

❷ 我们使用 Jedis (github.com/redis/jedis)库来访问 Redis。

❸ 检查缓存中是否已有这个价格

❹ 如果我们没有价格,则使用提供的查找

❺ 缓存我们刚刚检索到的值

在这一点上,值得停下来考虑我们想要测试系统的哪个方面。CachedPrice类的主要点是 Redis 与我们的底层价格查找之间的交互。我们如何与 Redis 交互是关键,Testcontainers 允许我们按照以下方式对实际事物进行测试:

package com.wellgrounded;

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.*;
import org.testcontainers.utility.DockerImageName;
import redis.clients.jedis.*;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Testcontainers
public class CachedPriceTest {
    private static final DockerImageName imageName =
            DockerImageName.parse("redis:6.2.3-alpine");

    @Container
    public static GenericContainer redis = new GenericContainer(imageName)
            .withExposedPorts(6379);

    // Tests to follow...
}

在测试的这个开始部分,我们看到与 Testcontainers 连接的最基本形式。我们将@Testcontainers注解应用于整个测试类,让库知道它应该在测试执行期间监视我们所需的容器。标记为@Container的字段请求启动特定的容器镜像"redis:6.2.3-alpine",使用标准的 Redis 端口 6379。

当这个测试类执行时,如图 14.1 所示,Testcontainers 会启动我们请求的容器。Testcontainers 将等待默认的超时时间(60 秒),直到第一个映射端口可用,这样我们就可以确信容器已经准备好通信。redis字段然后允许我们获取主机名和端口等信息,以便在测试的后续部分使用。

图 14.1 Testcontainers 执行

图 14.1 测试容器执行

当我们的容器化 Redis 运行时,我们可以开始实际的测试。因为关键点是 Redis 与查找之间的交互——而不是底层价格查找的实际实现——我们可以重用我们之前的StubPrice,它总是返回 10 以简化测试,如下所示:

@Test
    public void cached() {
        var jedis = getJedisConnection();
        jedis.set("price", "20");                                          ❶

        CachedPrice price =
            new CachedPrice(new StubPrice(), jedis);                       ❷
        BigDecimal result = price.getInitialPrice();

        assertEquals(new BigDecimal("20"), result);                        ❸
    }

    @Test
    public void noCache() {
        var jedis = getJedisConnection();
        jedis.del("price");                                                ❹

        CachedPrice price = new CachedPrice(new StubPrice(), jedis);
        BigDecimal result = price.getInitialPrice();

        assertEquals(new BigDecimal("10"), result);
    }

    private Jedis getJedisConnection() {                                   ❺
        HostAndPort hostAndPort = new HostAndPort(
                                        redis.getHost(),
                                        redis.getFirstMappedPort());
        return new Jedis(hostAndPort);
    }

❶ 在 Redis 中设置一个与我们的 stubbed price 不同的价格

❷ 将 StubPrice 作为我们的查找传递,它将返回 10 而不是 20

❸ 断言我们收到了缓存的值

❹ 使用 del 调用删除 Redis 中之前缓存的任何值

❺ 设置我们的 Jedis 实例的辅助方法。

重要的是要注意getJedisConnection方法如何使用 Testcontainers 的配置来连接到 Redis。尽管你可能观察到redis.getHost()是一个常见的值,例如localhost,但这并不一定在每种环境中都保证。最好向 Testcontainers 请求这样的值,并保护自己免受未来这些值意外变化的影响。

虽然在这里自动启动容器非常方便,但了解如何更直接地控制它是有价值的。这尤其适用于你的容器需要时间来启动的情况,正如我们将在后续示例中看到的,例如需要模式的关系数据库。

@Container 注解识别它是在静态字段上应用还是在实例字段上应用,如图 14.2 所示。当应用于静态字段时,容器将在测试类执行期间启动一次。如果你将字段留在实例级别,那么每个单独的测试将启动和停止容器。

图片

图 14.2 字段和 @Container

这指向了管理我们的容器生命周期的另一种潜在方法:如果我们只想为整个测试套件运行一次容器怎么办?为了实现这一点,我们必须放弃 @Container 注解,并直接使用 GenericContainer 对象本身公开的 API,如下所示:

    private static final DockerImageName imageName =
            DockerImageName.parse("redis:6.2.3-alpine");

    public static GenericContainer redis = new GenericContainer(imageName)
            .withExposedPorts(6379);

    @BeforeAll
    public void setUp() {
        redis.start();        ❶
    }

❶ 在实例上安全地多次调用 start —— 它将为每个对象只开始一次容器。

我们不需要提供 tearDown 来显式停止容器,因为 testcontainers 库会自动为我们处理。

尽管前面的例子为每个测试调用 start,但 redis 对象可以移动到一个位置,在那里它可以安全地在多个测试类之间共享。

14.1.3 收集容器日志

如果你通过命令行或在你的集成开发环境中运行这些测试,你可能会注意到默认情况下容器没有输出。对于我们的简单 Redis 案例,这并不是问题,但对于更复杂的设置或调试,你可能希望有更多的容器可见性。为了帮助这里,Testcontainers 允许访问它启动的容器的 STDOUTSTDERR

这种支持基于 JDK 的 Consumer<> 接口,库中包含几个实现。你可以连接到标准日志提供者,或者,如我们将要展示的,直接获取原始日志。

你可能会觉得将容器日志输出到主输出不方便,但当你确实需要它们时,进行自定义操作也很痛苦。一个解决方案是添加支持,始终将它们捕获到单独的位置,例如构建输出中的一个文件,如下所示:

    @Container                                                ❶
    public static GenericContainer redis =
        new GenericContainer(imageName)
            .withExposedPorts(6379);

    public static ToStringConsumer consumer =                 ❷
                                    new ToStringConsumer();

    @BeforeAll
    public static void setUp() {
        redis.followOutput(consumer,
                           OutputType.STDOUT,
                           OutputType.STDERR);                ❸
    }

    @AfterAll
    public static void tearDown() throws IOException {
        Path log = Path.of("./build/tc.log");                 ❹
        byte[] bytes = consumer.toUtf8String().getBytes();
        Files.write(log, bytes,
                    StandardOpenOption.CREATE);               ❺
    }

❶ 我们再次使用 @Container 注解来启动,因为它非常简单。

❷ 我们的消费者实例将在测试运行过程中收集日志。

❸ 将消费者附加到我们的容器,请求 STDOUT 和 STDERR

❹ 写入方便的位置

❺ 使用 java.nio.Files 轻松写入文件内容

14.1.4 Postgres 的示例

由于 Redis 没有依赖关系,通常存储的数据是临时的,以及容器上的快速启动时间,它是一个容易的例子。但关于传统集成测试中的痛点:关系型数据库怎么办?我们放入关系型存储的数据通常是我们的应用程序真正功能的最关键部分,但测试它充满了陈旧数据、尴尬的模拟和假阳性。

Testcontainers 支持广泛的不同的数据存储。这些被包装在单独的模块中,必须将其引入。我们将使用 Postgres 进行演示,但在 Testcontainers 网站 (www.testcontainers.org/modules/databases/) 上,您将找到其他选项的长列表。

我们将 Postgres 模块作为测试依赖项以及主要的 Postgres 驱动程序一起包含,以便能够在 Maven 中连接到我们的新数据库:

<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <version>42.2.1</version>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <version>1.15.3</version>
  <scope>test</scope>
</dependency>

或者使用 Gradle:

  implementation("org.postgresql:postgresql:42.2.1")
  testImplementation("org.testcontainers:postgresql:1.15.3")

重要的是,这个版本需要与您使用的基 org.testcontainers:testcontainers 库相匹配。

一个特定的类封装了我们访问 Postgres 容器的操作。它提供了配置信息,如数据库名称和凭证的帮助器,如下所示:

public static DockerImageName imageName =
            DockerImageName.parse("postgres:9.6.12"));

    @Container
    public static PostgreSQLContainer postgres =
        new PostgreSQLContainer<>(imageName)
            .withDatabaseName("theater_db")
            .withUsername("theater")
            .withPassword("password");

这里适用所有相同的生命周期管理考虑因素,额外的问题是关系数据库在使用之前需要应用模式。许多常见的数据库迁移项目都可以从代码中运行,但我们将仅使用 JDBC 直接演示,以表明没有发生任何魔法。

首先,我们需要连接到我们的容器实例。使用 JDBC 类,我们可以使用来自我们的 postgres Testcontainer 对象的参数来设置它,如下所示:

    private static Connection getConnection() throws SQLException {
        String url = String.format(
                "jdbc:postgresql://%s:%s/%s",
                postgres.getHost(),
                postgres.getFirstMappedPort(),
                postgres.getDatabaseName());

        return DriverManager.getConnection(url,
                                            postgres.getUsername(),
                                            postgres.getPassword());
    }

注意,Testcontainers 包含一个功能,允许您修改您的连接字符串,并且它会自动启动数据库的容器。虽然很方便,但演示起来可能不太直接。然而,当将 Testcontainers 集成到现有的测试套件中时,这可能特别有价值。

使用我们的连接,我们希望在运行任何测试之前确保我们的模式已经就绪。在一个测试类的作用域内,我们可以通过以下方式使用 @BeforeAll 来完成:

    @BeforeAll
    public static void setup() throws SQLException, IOException {
        var path = Path.of("src/main/resources/init.sql");
        var sql = Files.readString(path);                    ❶
        try (Connection conn = getConnection()) {
            conn.createStatement().execute(sql);             ❷
        }
    }

❶ 对于我们的示例,一个 SQL 文件包含我们的模式定义。

❷ 应用 SQL

在模式就绪后,我们的测试现在可以针对这个完整的、空的 Postgres 数据库运行,如下所示:

    @Test
    public void emptyDatabase() throws SQLException {
        try (Connection conn = getConnection()) {
            Statement st = conn.createStatement();
            ResultSet result = st.executeQuery("SELECT * FROM prices");
            assertEquals(0, result.getFetchSize());
        }
    }

如果您有其他抽象,如 DAO(数据访问对象)、仓库或其他从数据库读取的方式,它们都应该与容器的连接正常工作。

14.1.5 使用 Selenium 进行端到端测试的示例

使用容器中的外部资源进行集成测试是一个自然的选择。在端到端测试中,也适用类似的技术。尽管这取决于您的具体系统,但通常端到端测试会想要测试浏览器,以确保网络应用程序按预期运行。

从历史上看,从代码中驱动网络浏览器是一个有点棘手的问题。这些技术仍然很脆弱且速度慢,但 Testcontainers 通过让您在容器内启动浏览器并远程控制它来消除安装和配置的痛苦。

就像我们的 Postgres 示例一样,我们需要引入依赖项。在这种情况下,有一个用于 Testcontainers 支持的模块,以及用于在 Maven 中远程控制浏览器实例所需的库:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>selenium</artifactId>
  <version>1.15.3</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.seleniumhq.selenium</groupId>
  <artifactId>selenium-remote-driver</artifactId>
  <version>3.141.59</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.seleniumhq.selenium</groupId>
  <artifactId>selenium-chrome-driver</artifactId>
  <version>3.141.59</version>
  <scope>test</scope>
</dependency>

或者使用 Gradle:

  testImplementation("org.testcontainers:selenium:1.15.3")
  testImplementation(
    "org.seleniumhq.selenium:selenium-remote-driver:3.141.59")
  testImplementation(                                                ❶
    "org.seleniumhq.selenium:selenium-chrome-driver:3.141.59")

❶ 类似名称的包中也存在对其他网络浏览器的支持。

特定的类配置浏览器实例。这里我们将传递 ChromeOptions 来表明我们正在启动那个特定的浏览器:

    @Container
    public static BrowserWebDriverContainer<?> chrome =
        new BrowserWebDriverContainer<>()
            .withCapabilities(new ChromeOptions());

使用这个实例,我们现在可以编写测试,访问网页并检查结果,如下所示:

    @Test
    public void checkTheSiteOut() {
        var url = "https://github.com/well-grounded-java";
        RemoteWebDriver driver = chrome.getWebDriver();
        driver.get(url);                                        ❶

        WebElement title =
                    driver.findElementByTagName("h1");          ❷
        assertEquals("well-grounded-java", title.getText());
    }

❶ 导航到 well-grounded-java 的 GitHub 组织

❷ 页面加载后,检查第一个

内容

这个简单的例子已经显示了端到端测试容易出现的脆弱性。如果 GitHub 重新设计并决定在页面上添加另一个 <h1>,或者他们以某种微妙的方式更改标题文本,会怎样?如果你正在测试自己的应用程序,这可能不是一个大问题,但与展示的紧密耦合仍然是一个问题。

在容器内运行时,如果事情不是我们预期的,理解原因可能会很困难。幸运的是,我们可以通过几种方式获得视觉反馈。

首先,我们可以像这样在特定时间点截图:

    @Test
    public void checkTheSiteOut() {
        RemoteWebDriver driver = chrome.getWebDriver();
        driver.get("https://github.com/well-grounded-java");

        File screen = driver.getScreenshotAs(OutputType.FILE);
    }

返回的文件是临时的,将在测试结束时被删除,但你可以在创建后将其复制到代码的其他地方。

只看到某个时间点的情况很常见。你还可以请求自动记录会议的视频,如下所示:

    private static final File tmpDirectory = new File("build");

    @Container
    public static BrowserWebDriverContainer<?> chrome =
        new BrowserWebDriverContainer<>()
            .withCapabilities(new ChromeOptions())
            .withRecordingMode(RECORD_ALL,
                                tmpDirectory,
                                VncRecordingFormat.MP4);

就像我们对容器日志所做的那样,这将使我们的构建输出在测试运行时进行记录。我们需要调试的一切都准备好了,如果出现麻烦,一切都在那里。

这只是触及了 Testcontainers 可以让你完成的表面。现在让我们看看如何放弃 JUnit,以不同的、可能更易读的形式编写我们的测试。

14.2 使用 Spek 和 Kotlin 进行规范式测试

JUnit 使用方法、类和注解的方式对 Java 开发者来说非常自然。但无论我们是否意识到,它都塑造了我们表达和分组测试的方式。尽管不是必需的,我们通常最终会得到一个测试类映射到我们的生产类,以及为每个实现方法松散的测试方法集群。

另一个想法是所谓的编写 规范。这源于 RSpec 和 Cucumber 等框架,而不是关注我们的代码是如何被塑造的,它旨在在更高层次上支持指定系统的工作方式,更符合人类讨论需求的方式。

这种测试的一个例子可以通过 Kotlin 中的 Spek 框架获得(见 www.spekframework.org/)。正如我们将看到的,Kotlin 的许多内置功能允许我们在规范中实现非常不同的组织和感觉。

安装 Spek 遵循依赖项的典型过程。Spek 完全关注我们如何构建规范,并依赖于生态系统中的功能,如断言和测试运行。为了简单起见,我们将使用 JUnit 5 中的断言和测试运行器进行演示,但如果你有其他你更喜欢的库,则不需要使用这些。

在 Maven 中,只需通知 11.2.6 节中的maven-surefire-plugin我们的规范文件,我们将通过在文件名中包含Spek来标记这些文件,如下所示。我们还需要 11.2.5 节中描述的 Kotlin 支持(此处未重复,以节省篇幅):

  <build>
    <plugins>
      <plugin>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>2.22.2</version>
        <configuration>
          <includes>
            <include>**/*Spek*.*</include>            ❶
          </includes>
        </configuration>
      </plugin>
    </plugins>
  </build>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-api</artifactId>      ❷
      <version>5.7.1</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.spekframework.spek2</groupId>
      <artifactId>spek-dsl-jvm</artifactId>
      <version>2.0.15</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.spekframework.spek2</groupId>
      <artifactId>spek-runner-junit5</artifactId>     ❸
      <version>2.0.15</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

❶ 由于我们的自定义文件约定,我们必须告诉 Maven 要运行什么。

❷ 使用 JUnit 的断言 API

❸ 使用 Spek 与 JUnit 测试运行器的集成

在 Gradle 中,我们连接到标准的test任务,并通知 JUnit 平台 Spek 的引擎,如下面的代码片段所示。你可能发现命令行测试将看到我们的规范,而无需引擎行,但其他系统,如我们的 IDE,可能错过它们:

dependencies {
  testImplementation(                                                      ❶
      "org.junit.jupiter:junit-jupiter-api:5.7.1")

  testImplementation("org.spekframework.spek2:spek-dsl-jvm:2.0.15")
  testRuntimeOnly(                                                         ❷
      "org.spekframework.spek2:spek-runner-junit5:2.0.15")
}

tasks.named<Test>("test") {                                                ❸
  useJUnitPlatform() {
    includeEngines("spek2")                                                ❹
  }
}

❶ 使用 JUnit 的断言 API

❷ 使用 Spek 与 JUnit 测试运行器的集成

❸ 查找测试任务,通知它是 Test 类型,因此我们可以访问 useJUnitPlatform 和后续方法

❹ 通知 JUnit 我们的引擎以实现更好的 IDE 集成

现在,我们可以着手编写我们的第一个规范。为了检查这一点,我们将查看针对InMemoryCachedPrice类所做的先前测试,并看看 Spek 如何改变我们的测试结构和流程,如下所示:

import org.spekframework.spek2.Spek
import org.junit.jupiter.api.Assertions.assertEquals
import java.math.BigDecimal

object InMemoryCachedPriceSpek : Spek({
    group("empty cache") {
        test("gets default value") {
            val stubbedPrice = StubPrice()
            val cachedPrice = InMemoryCachedPrice(stubbedPrice)

            assertEquals(BigDecimal(10), cachedPrice.initialPrice)
        }

        test("gets same value when called again") {
            val stubbedPrice = StubPrice()
            val cachedPrice = InMemoryCachedPrice(stubbedPrice)

            val first = cachedPrice.initialPrice
            val second = cachedPrice.initialPrice
            assertTrue(first === second)               ❶
        }
    }
})

===是 Kotlin 的引用相等运算符,因此这检查在调用之间我们是否得到完全相同的对象,而不仅仅是相同的值。

我们的第一项规范详细说明了空缓存的行为。我们可以看到许多 Kotlin 特性正在发挥作用。首先,我们的规范被声明为一个单例object而不是一个类。这有助于阐明 JUnit 中偶尔发生的测试生命周期问题,这取决于测试运行器是按类还是按单个测试方法构造测试的单个实例。

主要规范在 lambda 表达式中声明,作为Spek类的参数传递。在这个 lambda 中,有两个重要的函数可用:grouptest。每个函数都提供了一个完整的String描述。不需要驼峰命名法、下划线或其他技巧来使描述可读。group用于将各种相关的test调用组合在一起。如果需要,group构造函数也可以嵌套。

如果这种重新格式化是规范式测试带来的唯一好处,那么它就不会很有吸引力。然而,分组不仅仅是命名,因为我们可以声明固定装置,以便在多个测试中共享设置,如下所示:

object InMemoryCachedPriceSpek : Spek({
    group("empty cache") {
        lateinit var stubbedPrice : Price
        lateinit var cachedPrice : InMemoryCachedPrice

        beforeEachTest {
          stubbedPrice = StubPrice()
          cachedPrice = InMemoryCachedPrice(stubbedPrice)
        }

        test("gets default value") {
            assertEquals(BigDecimal(10), cachedPrice.initialPrice)
        }

        test("gets same value when called again") {
            val first = cachedPrice.initialPrice
            val second = cachedPrice.initialPrice
            assertTrue(first === second)
        }
    }
})

在我们的“空缓存”组中,我们声明了两个 fixtures:一个 stubbedPrice 用于设置缓存,以及我们将要测试的 cachedPrice 实例。任何属于这个 grouptest 调用都会得到这些 fixtures 的相同视图。

推荐的 fixtures 模式是使用 lateinit 并在 beforeEachTest 中初始化它们。这种延迟初始化的需要实际上反映了 Spek 以两个阶段运行我们的规范:发现和执行。

在发现阶段,我们的规范的最高级 lambda 被运行。group lambda 被急切地评估,但 test 调用尚未执行;相反,它们被注意到以供稍后执行。在评估完所有规范的组之后,test lambda 被执行。这种分离,如以下所示,允许在每次单个 test 运行之前更紧密地控制每个 group 的上下文:

object InMemoryCachedPriceSpek : Spek({
    group("empty cache") {                                 ❶
        lateinit var stubPrice : Price                     ❶
        lateinit var cachedPrice : InMemoryCachedPrice     ❶

        beforeEachTest {                                   ❷
          stubPrice = StubPrice()                          ❷
          cachedPrice = InMemoryCachedPrice(stubPrice)     ❷
        }                                                  ❷
                                                           ❷
        test("gets default value") {                       ❷
            assertEquals(BigDecimal(10),                   ❷
                          cachedPrice.initialPrice)        ❷
        }                                                  ❷
                                                           ❷
        test("gets same value when called again") {        ❷
            val first = cachedPrice.initialPrice           ❷
            val second = cachedPrice.initialPrice          ❷
            assertTrue(first === second)                   ❷
        }
    }
})

❶ 在发现阶段运行

❷ 在执行阶段运行

使用 lateinit 有点笨拙,所以 Spek 使用 Kotlin 的 委托属性 来封装它。每个 fixture 都可以跟一个 by memoized 调用和一个 lambda 来提供值。

注意 memoized(不是 memorized!)是一个表示计算一次并缓存以供以后使用的值的术语。

不要将这些用于你正在测试的动作的结果——那些应该在 test lambda 本身内完成,如下所示:

object InMemoryCachedPriceSpek : Spek({
    val stubbedPrice : Price by memoized { StubPrice() }

    group("empty cache") {
        val cachedPrice by memoized { InMemoryCachedPrice(stubbedPrice) }

        test("gets default value") {
            assertEquals(BigDecimal(10), cachedPrice.initialPrice)
        }

        test("gets same value when called again") {
            val first = cachedPrice.initialPrice
            val second = cachedPrice.initialPrice
            assertTrue(first === second)
        }
    }
})

通过直接执行我们的 Kotlin 代码来发生测试发现阶段,这允许比 JUnit 可用的参数化更简单。我们不需要额外的注解和基于反射的查找,我们只需循环并重复调用 test,如下所示:

object InMemoryCachedPriceSpek : Spek({
    group("parameterized example") {
        listOf(1, 2, 3).forEach {
            test("testing $it") {           ❶
                assertNotEquals(it, 0)
            }
        }
    }
})

❶ 在循环的每次迭代中使用它为我们提供了测试 1、测试 2 和测试 3。

对于可能在其他生态系统(如 Ruby 中的 RSpec 或 JavaScript 中的 Jasmine)中遇到过规范式测试的人来说,你可以用 describeit 替换 grouptest 方法,以获得更自然的叙述流程,如下所示:

object InMemoryCachedPriceSpek : Spek({
    val stubbedPrice : Price by memoized { StubPrice() }

    describe("empty cache") {
        val cachedPrice by memoized { InMemoryCachedPrice(stubbedPrice) }

        it("gets default value") {
            assertEquals(BigDecimal(10), cachedPrice.initialPrice)
        }

        it("gets same value when called again") {
            val first = cachedPrice.initialPrice
            val second = cachedPrice.initialPrice
            assertEquals(true, first === second)
        }
    }
})

编写规范的另一常见格式是 Gherkin 语法 (cucumber.io/docs/gherkin/reference/),由 Cucumber 测试工具普及。这种语法通过一系列的给定-当-然后语句来声明我们的规范:given 这个设置,when 这个动作发生,then 我们看到这些后果。强制这种结构通常会使规范在自然语言中更易于阅读,而不仅仅是代码。

以 Gherkin 风格重述先前的测试可能看起来像这样:Given 一个空缓存,when 计算价格,then 我们查找默认值。以下是它如何转换为 Spek 的 Gherkin 支持:

object InMemoryCachedPriceSpekGherkin : Spek({
    Feature("caching") {
        val stubbedPrice by memoized { StubPrice() }

        lateinit var cachedPrice : Price
        lateinit var result : BigDecimal

        Scenario("empty cache") {
            Given("an empty cache") {
                cachedPrice = InMemoryCachedPrice(stubbedPrice)
            }

            When("calculating") {
                result = cachedPrice.initialPrice
            }

            Then("it looks up the default value") {
                assertEquals(BigDecimal(10), result)
            }
        }
    }
})

你会注意到这也带来了来自 Cucumber 的额外分组,我们在应用给定-当-然后组织之前,通过将规范分为 FeatureScenario 来实现。

规范为我们提供了一种不同的方式来组织我们的测试代码,以便更好地与后来的读者沟通。但它们仍然要求我们手动编写所有案例。Clojure 提供了一些不同的可能性来探索我们如何选择测试数据。

14.3 使用 Clojure 进行基于属性的测试

与 Java 和 Kotlin 不同,Clojure 的标准库中自带了一个测试框架clojure.test。虽然我们不会深入探讨这个库,但在访问 Clojure 测试生态系统的其他更复杂部分之前,让我们先熟悉一下基础知识。

14.3.1 clojure.test

我们将通过 Clojure REPL 来练习我们的测试,就像我们在第十章中做的那样。如果你跳过了那一章或者已经有一段时间了,现在是复习 Clojure 基础知识的好时机,如果这些测试难以理解的话。

虽然它直接与 Clojure 一起发货,但clojure.test并没有自动捆绑到我们的代码中。我们需要通过require请求这个库。在我们的 REPL 中输入以下内容将使clojure.test中的所有函数都可用,并带有我们通过:as声明的test前缀:

user=> (require '[clojure.test :as test])
nil
user=> (test/is (= 1 1))
true

或者,我们可以通过:refer选择特定的函数,以便不带前缀使用,如下所示:

user=> (require '[clojure.test :refer [is]])
nil
user=> (is (= 1 1))
true

is函数代表clojure.test中断言的基础。当断言通过时,我们看到函数返回true。那么当它失败时呢?

user=> (is (= 1 2))

FAIL in () (NO_SOURCE_FILE:1)
expected: (= 1 2)
  actual: (not (= 1 2))
false

任何谓词都可以与is一起使用。例如,以下是确认一个函数将抛出我们期望的异常的方法:

user=> (defn oops [] (throw (RuntimeException. "Oops")))   ❶
#'user/oops

user=> (is (thrown? RuntimeException (oops)))
#error {                                                   ❷
 :cause "Oops"
 :via
 [{:type java.lang.RuntimeException
   :message "Oops"
   :at [user$oops invokeStatic "NO_SOURCE_FILE" 1]}]
   ...                                                     ❸

❶ 总是抛出RuntimeException的函数

❷ 我们收到的是一个#error 值,而不是 FAIL 消息。这表明断言通过了。

❸ 错误信息还包含完整的堆栈跟踪,这里为了节省空间省略了。

使用我们的断言,我们现在已经准备好开始构建测试。为此的主要方法是deftest函数,如下所示:

user=> (require '[clojure.test :refer [deftest]])
nil
user=> (deftest one-is-one (is (= 1 1)))
#'user/one-is-one

定义了我们的测试后,我们现在需要执行它。我们可以通过run-tests函数来完成,该函数将在我们的当前命名空间中找到所有定义的测试。对于 REPL,存在一个名为user的默认命名空间,它自动存在,并且我们的deftest将测试放在那里,如下所示:

user=> (require '[clojure.test :refer [run-tests]])
nil
user=> (run-tests)

Testing user

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.
{:test 1, :pass 1, :fail 0, :error 0, :type :summary}

显然,在 REPL 中编写和运行测试对学习很有好处,但不适合在项目中长期使用。最终,设置一个测试运行器是值得的,尽管与 Java 世界中的 JUnit 是突出领导者不同,Clojure 中存在一些竞争选项。以下是一些可以考虑的选项:

话虽如此,我们将在我们的 REPL 中继续操作,现在来看看 Clojure 带来的一项有趣的能力:数据规范。

14.3.2 clojure.spec

尽管 Clojure 与 JVM 的集成意味着你可以自然地处理类和对象,但函数式编程并不像面向对象编程中那样紧密地将行为与数据耦合。常见的情况是函数操作由基本原始数据结构组成的数据结构,特别是当映射履行了我们与面向对象编程中的类相关联的数据携带行为时。

这使得拥有更好的设施来测试内置数据结构的形状和内容变得很有吸引力。这由标准库中的 clojure.spec 提供。与 clojure.test 一样,我们需要导入库才能访问它,如下所示:

user=> (require '[clojure.spec.alpha :as spec])
nil

注意:尽管 clojure.spec 使用了“specification”这个术语,但这与我们在 Kotlin 中的 Spek 所见到的“specification”是完全不同的用法。clojure.spec 定义的是 数据 的规范,而不是 行为 的规范。

在有了那个库之后,我们可以开始使用 valid? 函数对不同值做出声明。这个函数执行我们传递给它的谓词函数,并返回一个布尔值,如下所示:

user=> (spec/valid? even? 10)
true
user=> (spec/valid? even? 13)
false

conform 函数为我们提供了下一级别的检查,如下一个代码示例所示。如果值通过了谓词,我们会收到该值。否则,返回的是一个关键字 :clojure.spec.alpha/invalid

user=> (spec/conform even? 10)
10
user=> (spec/conform even? 13)
:clojure.spec.alpha/invalid

我们可以使用 and 函数将不同的检查组合在一起。这可以通过直接编写我们自己的谓词函数来实现,但使用 clojure.spec 中的版本,如下一个代码片段所示,意味着库理解我们创建的组合。我们很快就会看到这如何给我们带来更多信息:

user=> (spec/conform (spec/and int? even?) 10)
10
user=> (spec/conform (spec/and int? even?) 13)
:clojure.spec.alpha/invalid
user=> (spec/conform (spec/and int? even?) "not int")
:clojure.spec.alpha/invalid

在看到 and 之后,可能会有所惊讶地发现存在一个 or 函数。但如果尝试像使用 and 一样使用 or,情况就会变得复杂,如下所示:

user=> (spec/conform (spec/or int? string?) 10)
Unexpected error (AssertionError) macroexpanding spec/or at (REPL:1:15).
Assert failed: spec/or expects k1 p1 k2 p2..., where ks are keywords
(c/and (even? (count key-pred-forms)) (every? keyword? keys))

这个错误信息告诉我们 or 期望在其传递给它的谓词之间包含关键字。这可能看起来像是一个简单的布尔函数的奇怪要求。然而,当我们更仔细地查看给定 or 条件时 conform 的结果,原因就会变得 clearer:

user=> (spec/conform (spec/or :a-number int? :a-string string?) "hello")
[:a-string "hello"]
user=> (spec/conform (spec/or :a-number int? :a-string string?) 10)
[:a-number 10]
user=> (spec/conform (spec/or :a-number int? :a-string string?) nil)
:clojure.spec.alpha/invalid

库不仅告诉我们值符合我们的规范,而且还告诉我们 or 条件中的哪个分支满足了规范。我们的规范不仅提供了简单的 yes/no 有效性,我们还找到了值通过的原因。

重复我们的规范变得越来越乏味,在实际应用中,这种重复显然是代码的坏味道。clojure.spec 允许我们使用命名空间关键字注册规范。然后我们只需像这样调用 conform

user=> (spec/def :well/even (spec/and int? even?))
:well/even
user=> (spec/conform :well/even 10)
10
user=> (spec/conform :well/even 11)
:clojure.spec.alpha/invalid

Clojure REPL 内置了一个方便的 doc 函数,它与我们的规范很好地集成。当我们传递一个已注册的关键字时,我们得到一个格式整洁的规范版本,如下所示:

user=> (doc :well/even)
 -------------------------
 :well/even
 Spec
   (and int? even?)

虽然conform提供了关于成功匹配如何发生的反馈,但:clojure.spec.alpha/invalid关键字对失败的解释相当晦涩。explain函数依赖于我们的规范已经拥有的更深入的知识,告诉我们为什么给定的值失败,如下所示:

user=> (spec/explain :well/even 10)
Success!
nil
user=> (spec/explain :well/even 11)
11 - failed: even? spec: :well/even
nil
user=> (spec/explain :well/even "")
"" - failed: int? spec: :well/even
nil

现在我们已经为值定义了可重用的规范,我们可以在单元测试中直接应用它们,如下所示:

(deftest its-even
    (is (spec/valid? :well/even 4)))

(deftest its-not-even
    (is (not (spec/valid? :well/even 5))))

到目前为止,我们的规范主要集中在检查单个值。当我们与映射一起工作时,还有一个额外的问题:提供的数据形状是否符合我们的预期?我们用keys函数来验证这一点。

让我们设想我们的剧院票务系统的一部分是用 Clojure 编写的。我们希望确认我们传递的任何票都有一个idamount。可选地,我们允许notes。我们可以像这样定义一个规范:

user=> (spec/def :well/ticket (spec/keys
                                :req [:ticket/id :ticket/amount]
                                :opt [:ticket/notes]))
:well/ticket

注意,这里的键都是用:ticket命名空间化的。这在 Clojure 的 map 键中被认为是良好的形式,因为它允许我们在票价和特定场所可用的座位数之间保持区分。如果您需要使用非命名空间化的键,各种函数如req会通过添加-un提供替代版本,例如,req-un

在一个映射上调用conform将验证我们已明确指定的键的存在。它还允许在必需键旁边有未指定的键,如下所示:

user=> (spec/conform :well/ticket
                      {:ticket/id 1
                      :ticket/amount 100
                      :ticket/notes "Noted"})
#:ticket{:id 1, :amount 100, :notes "Noted"}

user=> (spec/conform :well/ticket
                      {:ticket/id 1
                      :ticket/amount 100
                      :ticket/other-stuff true})
#:ticket{:id 1, :amount 100, :other-stuff true}

user=> (spec/conform :well/ticket {:ticket/id 1})
:clojure.spec.alpha/invalid

命名空间化键清楚地显示了它的价值,尤其是在它如何与我们的先前值检查无缝工作。如果一个键名有一个已注册的规范,那么在conform时,该值将被验证,如下所示:

user=> (spec/def :ticket/amount int?)
:ticket/amount

user=> (spec/conform :well/ticket
                      {:ticket/id 1 :ticket/amount 100})
#:ticket{:id 1, :amount 100]}

user=> (spec/conform :well/ticket {:ticket/id 1 :ticket/amount "100"})
:clojure.spec.alpha/invalid

clojure.spec为我们提供了丰富的数据验证能力。但 Clojure 对如何与数据交互的关注并不仅限于此。

14.3.3 test.check

当我们编写测试时,我们的大部分时间都花在挑选好的数据来测试我们的代码上。无论是构建代表性的对象还是找到验证的边缘值,我们在这个寻找要测试的内容上投入了大量的精力。

基于属性的测试将这种关系颠倒过来。我们不是构建示例来执行,而是定义应该对我们函数成立的属性,然后输入随机数据来确保这些属性是真实的。

注意:最近围绕基于属性的测试的热潮归功于 Haskell 库 QuickCheck (hackage.haskell.org/package/QuickCheck)。其他语言也有等效的库,例如 Python 中的 Hypothesis (hypothesis.readthedocs.io/en/latest/)。在 Clojure 中,这由test.check库提供。

这种测试范式与传统单元测试相比是一个重大的变化。在我们迄今为止看到的测试中,你期望 100%的确定性结果。测试运行中的任何波动都是测试编写不佳的迹象,应该被消除。

为什么基于属性的测试不同——不仅允许,而且依赖于随机数据?一方面,尽管输入是随机的,但失败并不表明测试有误——它揭示了我们对系统的理解,即通过我们定义的属性表达,是错误的。实际上,基于属性的测试发现了手动选择的数据可能遗漏的边缘情况。

这也不是完全放弃更传统单元测试的论点。用基于属性的测试补充我们的典型测试是合理的,尤其是在输入数据种类繁多、可能让我们陷入困境的领域。

clojure.testclojure.spec不同,test.check是一个独立的包,不在 Clojure 的标准库中。要在我们的 REPL 中使用它,我们必须告诉 Clojure 这个依赖项。最简单的方法是在运行clj的同一目录中放置一个名为deps.edn的文件。该文件指示 Clojure 从 Maven 仓库下载库,如下所示:

{
  :deps { org.clojure/test.check {:mvn/version "1.1.0"}}
}

在创建deps.edn文件后,您需要重新启动clj REPL。您第一次启动 REPL 时应看到消息,表明它正在下载必要的 JAR 文件。

基于属性的测试有两个主要部分:如何定义要检查代码的属性,以及如何生成随机数据来测试这些属性。让我们先从配置数据生成器开始,这可能会激发我们检查属性的想法。

test.checkgenerators包中为主创建随机数据提供支持。我们将引入整个包,并将其别名为gen,以减少一些输入,如下所示:

user=> (require '[clojure.test.check.generators :as gen])
nil

两个主要函数作为我们生成随机数据的入口点:generatesamplegenerate获取单个值,而sample获取一组值。这些函数都需要一个生成器,其中许多是内置的。例如,我们可以通过生成随机的布尔值来模拟抛硬币:

user=> (gen/generate gen/boolean)
false

user=> (gen/sample gen/boolean)
(true false true false false false true true false false)

user=> (gen/sample gen/boolean 5)
(true true true true true)

test.check提供的基本生成器涵盖了 Clojure 中原始类型的大部分需求。以下是一些使用示例。您可以在mng.bz/6XoD上查看文档以获取更多详细信息以及一些生成器可能接受的附加可选参数:

user=> (gen/sample gen/nat)                                                ❶
(0 1 0 2 3 5 5 7 4 5)

user=> (gen/sample gen/small-integer)                                      ❷
(0 -1 1 1 2 4 0 5 -7 -8)

user=> (gen/sample gen/large-integer)                                      ❸
(-1 0 -1 -3 3 -1 -8 9 26 -249)

user=> (gen/sample (gen/choose 10 20))                                     ❹
(11 20 17 16 11 16 14 19 14 13)

user=> (gen/sample gen/any)                                                ❺
(#{} (true) (-3.0) () (Xs/B 553N -4460N) {} #{-3 W_/R? :? \} () #{} [])

user=> (gen/sample gen/string)                                             ❻
("" "" "" "ØI_" "" "rý" "ƒHODÄ" "fÿí'ß" "ü<Ò29eXÔ" "‚ÅÆk0®<")

user=> (gen/sample gen/string-alphanumeric)                                ❼
("" "" "3" "G" "pB9" "e2" "oRt98" "l8" "T61T75k4" "b8505NXt")

user=> (gen/sample (gen/elements [:a :b :c]))                              ❽
(:b :c :b :a :c :b :a :c :a :b)

user=> (gen/sample (gen/list gen/nat))                                     ❾
(() (1) (1) (0 2 1) (0 3) (3 3) (1) (1 6 5 1 2 4 4) (4 7 3 4 7 0) (3 2))

❶ 小的自然(非负)整数

❷ 包括负数在内的小整数

❸ 包括负数在内的较大整数

❹ 从提供的整数范围内选择

❺ 任何 Clojure 值

❻ 任何有效的 Clojure 字符串

❼ 任何字母数字字符串

❽ 从元素列表中选择

❾ 根据提供的生成器创建列表

这些生成器对于一种称为 fuzzing 的测试类型非常有用。fuzzing 的实践,在安全领域经常使用,向系统投掷各种数据,特别是无效数据,以查看它在哪里崩溃。通常,我们测试的例子并不够有创意,尤其是当涉及到来自外部世界的输入时。生成器为我们提供了一个简单的方法,用我们不会想到的数据来加强我们的测试。

想象一下,我们的票务应用程序允许开放文本输入备注,但希望尝试提取关键词。如果我们的应用程序面向互联网,我们绝对不希望该函数抛出意外异常。我们可以这样 fuzz 它:

user=> (defn validate-input [s]
; imagine implementation here that should never throw
)
#'user/validate-input

user=> (deftest never-throws
         (doall (map (gen/sample gen/string)      ❶
                      validate-input)))

user=> (run-tests)

Testing user

Ran 1 tests containing 0 assertions.
0 failures, 0 errors.
{:test 1, :pass 0, :fail 0, :error 0, :type :summary}

doall 确保 Clojure 不会因为其返回值未使用而懒加载我们的 map。

Fuzzing 可以是一个有用的第一步,但显然,我们的函数比“不会意外崩溃”有更多有趣的属性。

回到我们的剧院票务系统,所有者现在对一项新功能感兴趣,即人们可以竞标门票。已经从机器学习咨询公司购买了一个复杂的算法,以最大化在给定的一组竞标价格中购买的人数。该算法保证它不会提供超出提供的竞标价格范围的任何价格。

我们还没有收到代码,但当我们收到时,我们想要准备好检查他们的声明。在此之前,我们已经提供了一个占位符实现,如下所示,它给定一个竞标价格列表,将随机选择一个:

user=> (defn bid-price [prices] (rand-nth prices))
#'user/bid-price
user=> user=> (bid-price [1 2 3])
1
user=> (bid-price [1 2 3])
3

让我们看看我们如何使用 test.check 来定义关于我们的竞标函数的属性。除了我们之前拉入的生成器之外,我们还需要在 clojure.test.checkclojure.test.check.properties 中引入函数,如下所示:

user=>(require '[clojure.test.check :as tc])
nil

user=>(require '[clojure.test.check.properties :as prop])
nil

我们将要检查的第一个属性——对剧院所有者来说最重要的是——是我们永远不会返回一个比有人提供的竞标价更低的竞标价:

user=>(def bigger-than-minimum
  (prop/for-all [prices (gen/not-empty (gen/list gen/nat))]
    (<= (apply min prices) (bid-price prices))))
#'user/bigger-than-minimum

在这个小片段中有很多内容,所以让我们来分解一下。首先,我们的 def bigger-than-minimum 给我们的属性起了一个名字,以便稍后引用。重要的是要记住,这只是在定义属性,还没有实际检查它。

下一条语句声明了 prop/for-all,这是我们声明想要检查的属性的方式。它后面跟着一个列表,确定了我们将如何生成数据以及将这些值绑定到什么上。[prices (gen/not-empty (gen/list gen/nat))]prices 依次从随后的生成器语句中获取每个生成的值。在这种情况下,我们请求一个非空的自然(非负)整数列表。

最后一条语句最终表达了我们的属性的实际逻辑。(<= (apply min prices) (bid-price prices)) 找到我们生成的列表中的最小值,对同一列表调用我们的竞标函数,并确保竞标价格不低于最小值。

有了这个,我们现在可以要求test.check运行一组生成的值来检查属性,如下所示。quick-check函数需要一个迭代次数和一个要检查的属性:

user=> (tc/quick-check 100 bigger-than-minimum)
{:result true, :pass? true, :num-tests 100,
 :time-elapsed-ms 13, :seed 1631172881794}

我们的属性通过了!另一个请求的条件——我们不提供比任何出价更高的价格——是从我们已编写的内容中轻松扩展出来的,如下所示:

user=>(def smaller-than-maximum
  (prop/for-all [prices (gen/not-empty (gen/list gen/nat))]
    (>= (apply max prices) (bid-price prices))))
#'user/smaller-than-maximum

user=>(tc/quick-check 100 smaller-than-maximum)
{:result true, :pass? true, :num-tests 100,
 :time-elapsed-ms 13, :seed 1631173295156}

虽然我们的属性通过测试是件好事,但让我们打破它们,看看会发生什么。一个简单的方法是在出价函数中悄悄增加一点,然后重新检查我们的属性,如下所示:

user=>(defn bid-price [prices] (+ (rand-nth prices) 2))
#'user/bid-price

user=>(tc/quick-check 100 smaller-than-maximum)
{:shrunk {:total-nodes-visited 3, :depth 1, :pass? false, :result false,
:result-data nil, :time-shrinking-ms 1, :smallest [(0)]},
:failed-after-ms 5, :num-tests 1, :seed 1631173486892, :fail [(2)] }

现在,这看起来不同了!正如我们所希望的,检查失败了,我们在这里得到了关于失败案例的所有必要信息。特别是,:smallest [(0)]键指示了运行期间看到的精确失败值。我们之前的结果中看到了:seed。如果我们想用相同的值再次运行属性,我们可以将种子传递给调用,如下所示:

user=>(tc/quick-check 100 smaller-than-maximum
        :seed 1631173486892)                                               ❶
{:shrunk {:total-nodes-visited 3, :depth 1, :pass? false, :result false,
:result-data nil, :time-shrinking-ms 1, :smallest [(0)]},
:failed-after-ms 5, :num-tests 1, :seed 1631173486892, :fail [(2)] }

❶ 传递与之前相同的种子值以获得相同的失败

响应中的一个有趣点是键:shrunk。当test.check发现失败时,它不会只是停止并报告。它会通过一个过程进行缩小——从失败的生成数据创建更小的排列,以找到最小的情况。这对于调试非常有用,尤其是在更复杂的数据上。拥有将导致失败的最小、最简单的输入是一个巨大的帮助。

test.check与基础clojure.test库集成。defspec函数同时定义了一个测试(如deftest)和一个属性,如下所示:

user=> (require '[clojure.test.check.clojure-test :refer [defspec]])
nil

user=> (defspec smaller-than-maximum
  (prop/for-all [prices (gen/not-empty (gen/list gen/nat))]
    (>= (apply max prices) (bid-price prices))))
#'user/smaller-than-maximum

user=> (run-tests)
Testing user
{:result true, :num-tests 100, :seed 1631516389835,
 :time-elapsed-ms 36, :test-var "smaller-than-maximum"}

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.
{:test 1, :pass 1, :fail 0, :error 0, :type :summary}

基于属性的测试最困难的部分通常不是编码,而是确定属性本身。虽然我们的票务示例和许多基本算法,如排序,适合明显的属性,但许多现实世界的情况并不那么明显。

这里有一些想法,可以在您的系统中寻找属性:

  • 验证和边界—如果一个函数有一个在运行时需要验证的条件,例如值的限制、列表的长度或字符串的内容,那么这是一个定义属性的绝佳位置。

  • 数据往返—在许多系统中,转换数据格式是一个常见的操作。也许我们在网络请求中收到一种类型的数据,需要将其转换为不同的形状才能存储到数据库中。对于这些情况,我们可以定义属性来显示值将成功通过我们的转换并返回其原始形式,而不会丢失。

  • 预言机—有时我们最终会编写替代现有功能的内容。这可能是因为性能、更好的可读性或其他许多原因。如果我们有一个我们认为的“正确”答案的替代路径,这可以作为一个丰富的属性比较来源,即使只是在开发替代品的过程中。

14.3.4 clojure.spec 和 test.check

test.check为 Clojure 中的原始数据提供了丰富的生成器集,但我们几乎总是处理更丰富的结构。为这些更复杂的形状编写准确的生成器可能会很繁琐且困难。

幸运的是,clojure.spec有助于缩小这个差距。clojure.spec允许我们以通用方式描述我们的高级数据结构,并且它可以自动将这些转换为test.check兼容的生成器,这些生成器手动定义可能会很复杂。

为了刷新,以下是我们的票据结构定义——包括映射要求和值上的约束:

user=> (spec/def :well/ticket (spec/keys
                                :req [:ticket/id :ticket/amount]
                                :opt [:ticket/notes]))
:well/ticket

user=> (spec/def :ticket/amount int?)
:ticket/amount

user=> (spec/def :ticket/id int?)
:ticket/id

user=> (spec/def :ticket/notes string?)
:ticket/notes

clojure.spec.alpha中的gen函数会将规范转换为生成器。然后我们可以将这个生成器传递给之前用于创建类似这种随机数据的相同test.check函数方法:

user=> (gen/generate (spec/gen :well/ticket))
#:ticket{:notes "fZBvSkOAWERawpNz", :id -3, :amount 233194633}

这张随机票据已经揭示了我们在规范中可能没有考虑到的角落:我们真的想要负 ID 吗?我们应该对我们的票据金额实施范围限制吗?看起来我们还需要做更多的规范和测试工作!

摘要

  • 测试并非一刀切。不同的技术有不同的优势。测试代码是混合和匹配库和语言的绝佳场所,以增强这些优势。

  • 其他语言,如 Kotlin 和 Clojure,可以开启在 Java 中难以实现的测试风格。

  • 集成测试——与数据存储和其他服务交互——可能会很棘手且容易出错。Testcontainers 提供了平滑的集成方式来处理这些外部依赖,利用我们从第十二章学到的容器知识。

  • 我们如何编写规范会影响我们对我们系统的思考方式。使用 Kotlin 编写的 Spek,以及其他类似规范风格的测试框架,为以代码为中心的 JUnit 类型测试提供了一种替代方案。我们看到了它如何提升我们的测试沟通。

  • 最后,我们采用了与“编写示例并检查结果”完全不同的测试方法,即使用 Clojure 的基于属性的测试。从生成随机数据,定义我们系统的全局属性,到将失败缩小到可能的最小输入,基于属性的测试为确保我们系统的质量开辟了新的途径。

第五部分. Java 前沿

这一部分书籍汇集了您在其他部分中看到的大部分技术和概念。

在第十章对 Clojure 的介绍之后,您将深入探讨超出 map-filter-reduce 基础的功能式编程。您将详细了解 Java 的设计和历史为什么对功能式风格构成一些障碍。然后,您将更深入地了解功能语言中更高级的技术如何在 Kotlin 和 Clojure 中出现,以简化并增强您的代码。

在第二部分并发主题的基础上,您还将看到构建安全、高性能应用程序的其他可能性。从 Java 最近引入的功能如 Fork/Join,到 Kotlin 的协程,再到 Clojure 的代理,您将获得更多管理现代计算多核、多线程世界的选项。

从那里,您将通过研究 JVM 的内部结构来完成学习。如果您曾经好奇为什么反射如此缓慢,或者动态语言如何定位到 JVM,第十七章将填补这些空白。

最后,您将了解目前正在进行的几个主要 OpenJDK 项目,它们的目标是什么,以及在未来版本中可以期待它们带来什么。

15 高级函数式编程

本章涵盖

  • 函数式编程概念

  • Java 中函数式编程的局限性

  • Kotlin 高级函数式编程

  • Clojure 高级函数式编程

我们在本书的早期已经遇到了函数式编程概念,但在这章中,我们希望将这些线索汇总并提升到一个新的层次。在业界,关于 函数式编程 的讨论很多,但它仍然是一个相当不明确的概念。唯一达成共识的是,在函数式编程(FP)语言中,代码可以表示为第一类数据项,也就是说,应该能够将一个延迟计算的片段表示为一个可以分配给变量的值。

当然,这个定义过于宽泛——在所有实际应用中,过去 30 年中几乎所有的主流语言(除了极少数例外)都符合这个定义。因此,当不同的程序员群体讨论 FP 时,他们谈论的是不同的事情。每个群体对“FP”这个术语下隐含的其他语言属性都有不同的、不言而喻的理解。

换句话说——就像面向对象(OO)一样——对“函数式编程语言”的定义并没有根本上的共识。或者,如果一切都是 FP 语言,那么就没有什么是了。

稳健的开发者最好在一条轴(或者,更好的是,作为一个可能的语言特征的多维空间中的点)上可视化编程语言。语言只是比其他语言更函数式或更少函数式——没有某种绝对尺度来衡量它们。让我们来认识一些超越“代码是数据”这种简单概念的函数式编程语言常见工具箱中的概念。

15.1 函数式编程概念简介

在接下来的内容中,我们将经常提到 函数,但 neither Java 语言 nor JVM 并没有这样的东西——所有可执行代码都必须以 方法 的形式表达,该方法在 中定义、链接和加载。然而,其他非 JVM 语言对可执行代码有不同的理解,因此当我们在本章中提到函数时,应理解为与 Java 方法大致对应的可执行代码片段。

15.1.1 纯函数

一个 纯函数 是一个不会改变任何其他实体状态的函数。有时人们说它是 无副作用的,这意味着函数的行为像一个数学函数的构想:它接受参数,以任何方式都不影响它们,并返回一个仅依赖于传递的值的输出。

与纯度概念相关的是引用透明性的概念。这个名称有些不幸——它与 Java 程序员理解的引用无关。相反,这意味着一个函数调用可以用任何之前对相同函数使用相同参数的调用结果来替换。

显然,所有纯函数都是引用透明的,但也可能存在既不纯又是引用透明的函数。要允许非纯函数以这种方式被考虑,需要基于代码分析的形式证明。纯度关乎代码,但不可变性关乎数据,这是我们接下来要探讨的下一个 FP 概念。

15.1.2 不可变性

不可变性意味着一旦一个对象被创建,其状态就不能被改变。在 Java 中,对象的默认状态是可变的。Java 中关键字final被用于多种方式,但在这里我们关注的是防止在创建后修改字段。其他语言可能更倾向于不可变性,并以各种方式表明这种偏好——例如 Rust,它要求程序员使用mut修饰符显式地使变量可变。

不可变性使得代码更容易推理:对象具有简单的状态模型,仅仅因为它们只以它们将永远存在的唯一状态被构建。其他好处包括,这意味着它们可以在线程之间安全地复制和共享。

注意:我们可能会问是否存在任何“几乎不可变”的数据方法,同时仍然保持不可变性的一些(或大多数)吸引人的属性。实际上,我们之前遇到的 Java CompletableFuture类就是这样的一个例子。我们将在下一章中对此有更多讨论。

一个后果是,由于不可变对象不能被改变,系统中表达状态变化唯一的方法是从一个不可变值开始,构建一个完全新的、或多或少相同但某些字段已更改的不可变值——可能通过使用withers(也称为with*()方法)。

例如,java.time API 非常广泛地使用了不可变数据,可以通过使用类似这样的 withers 来创建新实例:

LocalDate ld = LocalDate.of(1984, Month.APRIL, 13);
LocalDate dec = ld.withMonth(12);
System.out.println(dec);

不可变方法有后果——特别是对内存子系统可能产生重大影响,因为旧值的组件必须作为创建修改后值的一部分被复制。这意味着从性能角度来看,就地修改通常要便宜得多。

15.1.3 高阶函数

高阶函数实际上是一个非常简单的概念,由以下洞察来描述:如果一个函数可以被表示为一个数据项,那么它应该能够被当作任何其他值来处理。

我们可以将高阶函数定义为一种函数值,它执行以下一个或两个操作:

  • 将函数值作为参数

  • 返回一个函数值

例如,考虑一个静态方法,它接受一个 Java String并从中生成一个函数对象,如下所示:

    public static Function<String, String> makePrefixer(String prefix) {
        return s -> prefix +": "+ s;
    }

这提供了一种直接创建函数对象的方法。现在让我们结合另一个静态方法,如下所示,这次它接受一个函数对象作为输入:

    public static String doubleApplier(String input,
                                       Function<String, String> f) {
        return f.apply(f.apply(input));
    }

这为我们提供了一个简单的例子:

var f = makePrefixer("NaNa");                       ❶
System.out.println(doubleApplier("Batman", f));     ❷

❶ 创建一个函数对象

❷ 将函数对象作为参数传递给另一个方法

然而,对于 Java 来说,这并不是全部的故事,我们将在下一节中看到。

15.1.4 递归

一个递归函数是指在函数的至少一些代码路径上调用自身的函数。这导致了编程中最古老的笑话之一:“为了理解递归,一个人必须首先理解递归。”

然而,为了更加严格地准确,我们可能可以这样写:为了理解递归,一个人必须首先理解

  1. 递归,以及

  2. 在一个物理可实现系统中,每个递归调用链最终都必须终止并返回一个值。

第二点很重要:编程语言使用调用栈来允许函数调用其他函数,这会占用内存空间。因此,递归的问题在于深度递归调用可能会消耗过多的内存并导致崩溃。

在理论计算机科学中,递归因其许多不同的原因而有趣且重要。其中最重要的原因是递归可以用作探索计算理论和如图灵完备性等想法的基础,这大致是指所有非平凡的计算系统都具有相同的理论能力来执行计算。

15.1.5 闭包

一个闭包通常被定义为“捕获”周围上下文中一些状态的 lambda 表达式。然而,为了使这个定义有意义,我们需要解释捕获概念的含义。

当我们创建一个值并将其分配(或绑定)给一个局部变量时,该变量将存在并且可以在代码的某个后续点被使用。这个后续点可能是声明变量的函数或块的末尾。变量存在并可被使用的代码区域是变量的作用域

当我们创建一个函数值时,函数体内部声明的局部变量在函数值的调用期间仍然在作用域内,这个调用将发生在函数值声明之后。如果在函数值的声明中提到了在函数体作用域之外声明的变量(或其他状态,如字段),那么函数值就说是对状态进行了封闭,并且这个函数值被称为闭包。

当闭包稍后调用时,它对捕获的变量有完全的访问权限,即使调用发生在捕获声明的作用域之外。

例如,在 Java 中:

    public static Function<Integer, Integer> closure() {
        var atomic = new AtomicInteger(0);
        return i -> atomic.addAndGet(i);
    }

这个静态方法是一个高阶函数,它返回一个 Java 闭包,因为它返回了一个引用atomic的 lambda 表达式,atomic是在方法内部声明的局部变量,即在 lambda 本身声明的范围内。从closure()返回的闭包可以被重复调用,并且它会在每次调用中聚合状态。

15.1.6 懒惰

我们在第十章中简要提到了懒惰的概念。本质上,惰性评估允许将表达式的值计算推迟到实际需要时。相比之下,表达式的即时评估被称为即时评估(或严格评估)。

懒惰的概念很简单:如果你不需要做工作,那就不要做!这听起来很简单,但对你编写程序以及程序的性能有着深远的影响。这个额外复杂性的一个关键部分是,你的程序需要跟踪哪些工作已经完成,哪些还没有完成。

并非每种语言都支持惰性评估,许多程序员可能在这个旅程中只遇到过即时评估——这是完全可以接受的。

例如,Java 在语言级别上没有对懒惰的一般支持,所以很难给出一个清晰的例子。我们得等到我们谈到 Kotlin 时才能具体化。

然而,尽管懒惰对于 Java 程序员来说可能不是一个自然的概念,但在函数式编程(FP)中,懒惰是一个非常有用且强大的技术。事实上,对于某些 FP 语言,如 Haskell,惰性评估是默认的。

15.1.7 柯里化和部分应用

柯里化不幸的是,与食物无关。相反,它是一种以 Haskell Curry(他的名字也给了 Haskell 编程语言)命名的编程技术。为了解释它,让我们从一个具体的例子开始。

考虑一个即时评估的纯函数,它接受两个参数。如果我们提供两个参数,我们将得到一个值,函数调用可以在任何地方被结果值替换(这就是引用透明性)。但如果我们只提供两个参数中的一个会发生什么呢?

直观地,我们可以将其视为创建一个新的函数,但这个函数只需要一个参数来计算结果。这个新函数被称为柯里化函数(或部分应用函数)。Java 没有直接支持柯里化的功能,所以我们再次将具体的例子推迟到本章的后面部分。

从更广泛的角度来看,一些编程语言支持具有多个参数列表的函数概念(或者有语法允许程序员伪造它们)。在这种情况下,我们可以将柯里化视为函数的一种转换。在数学符号中,我们将一个作为 f(a, b) 调用的多参数函数转换为可以调用为 (g(a))(b) 的形式,其中 g(a) 是部分应用函数。

现在应该很明显,我们迄今为止遇到的不同语言对函数式编程的支持程度不同——例如,Clojure 对我们在本节中讨论的许多概念有非常好的支持。另一方面,Java 的情况则完全不同,我们将在下一节中看到。

15.2 Java 作为函数式编程语言的局限性

让我们从好消息开始,尽管如此:Java 确实通过java.util.function中的类型以及运行时提供的广泛内省支持(如反射和方法句柄)清除了“将代码表示为数据”的相对较低门槛。

注意:使用内部类来模拟函数对象作为技术早于 Java 8,并且存在于像 Google Guava 这样的库中,所以严格来说,Java 将代码表示为数据的能力并不局限于那个版本。

自从第 8 版以来,Java 语言通过引入流以及与之相关的严格受限的延迟操作领域,在最低限度上有所超越。然而,尽管有了流,Java 并不是一个自然的功能环境。这其中的部分原因是平台的历史以及现在已经几十年前的设计决策。

注意:值得记住的是,Java 是一种已经迭代了 25 年的命令式语言,它已经进行了广泛的迭代。其中一些 API 适合函数式编程、不可变数据等,而另一些则不适合。这是在一种已经生存并繁荣的语言中工作的现实,同时仍然保持向后兼容性。

因此,总的来说,Java 可能最好被描述为一种“稍微有点函数式编程语言”。它拥有支持函数式编程所需的基本特性,并通过 Streams API 为开发者提供访问基本模式(如 filter-map-reduce)的途径,但大多数高级函数式特性要么是不完整的,要么完全缺失。让我们详细看看。

15.2.1 纯函数

正如我们在第四章中看到的,Java 的字节码执行许多不同类型的事情,包括算术、堆栈操作、流程控制,特别是调用和数据存储和检索。对于已经熟悉 JVM 字节码的扎实开发者来说,这意味着我们可以通过考虑字节码的效果来表达方法的纯度。具体来说,JVM 语言中的纯方法是指以下方法:

  • 不修改对象或静态状态(不包含putfieldputstatic

  • 不依赖于外部可变对象或静态状态

  • 不调用任何非纯方法

这是一个相当限制性的条件集,并强调了使用 JVM 作为纯函数式编程基础时的困难。

关于 JDK 中不同接口的语义——即意图——也存在疑问。例如,Callable(在java.util.concurrent中)和Supplier(在java.util.function中)基本上做的是同一件事:它们执行一些计算并返回一个值,如下所示:

@FunctionalInterface
public interface Callable<V> {
    V call() throws Exception;
}

@FunctionalInterface
public interface Supplier<T> {
    T get();
}

它们都是@FunctionalInterface,并且通常被用作 lambda 表达式的目标类型。接口的签名是相同的,除了处理异常的方法不同。

然而,它们可以被视为具有不同的角色:Callable暗示在调用代码中可能需要进行一些非平凡的工作来创建将要返回的值。另一方面,Supplier这个名字似乎暗示工作量较少——可能只是返回一个缓存的值。

15.2.2 可变性

Java 是一种可变语言——可变性从其设计之初就内置其中。部分原因是历史的偶然——20 世纪 90 年代末的机器(Java 的起源)在内存方面非常受限(按现代标准)。不可变的数据模型将大大增加内存管理子系统的压力,并导致更频繁的 GC 事件,从而导致更差的吞吐量。

因此,Java 的设计倾向于修改而不是创建修改后的副本。所以原地修改可以看作是 25 年前由于性能权衡而做出的设计选择。

然而,情况甚至比这更糟。Java 通过引用来引用所有复合数据,final关键字应用于引用,而不是数据。例如,当应用于字段时,字段只能被分配一次。

这意味着即使一个对象的所有字段都是 final 的,组合状态仍然可能是可变的,因为对象可以持有对另一个具有一些非 final 字段的final引用。这导致了我们在第五章讨论的浅层不可变性问题。

注意:对于 C++程序员来说:Java 没有const的概念,尽管它确实有一个(未使用)关键字。

例如,这里是一个我们在第五章遇到的不可变Deposit类的略微增强版本:

public final class Deposit implements Comparable<Deposit> {
    private final double amount;
    private final LocalDate date;
    private final Account payee;

    private Deposit(double amount, LocalDate date, Account payee) {
        this.amount = amount;
        this.date = date;
        this.payee = payee;
    }

    @Override
    public int compareTo(Deposit other) {
        return Comparator.nullsFirst(LocalDate::compareTo)
                         .compare(this.date, other.date);
    }

    // methods elided
}

这个类的不可变性基于假设Account及其所有传递依赖也是不可变的。这意味着有一些限制——从根本上讲,Java 和 JVM 的数据模型并不自然地支持不可变性。

在字节码中,我们可以看到 final 字段作为以下字段元数据出现:

$ javap -c -p out/production/resources/ch13/Deposit.class
Compiled from "Deposit.java"
public final class ch13.Deposit
    implements java.lang.Comparable<ch13.Deposit> {

    private final double amount;

    private final java.time.LocalDate date;

    private final ch13.Account payee;

  // ...
  }

尝试在 Java 中使用不可变状态的方法就像是在救一个正在漏水的船。必须检查每个引用的可变性,如果遗漏任何一个,那么整个对象图都是可变的。

更糟糕的是,JVM 的反射和其他子系统也提供了绕过不可变性的方法,如下所示:

var account = new Account(100);
var deposit = Deposit.of(42.0, LocalDate.now(), account);
try {
    Field f = Deposit.class.getDeclaredField("amount");
    f.setAccessible(true);
    f.setDouble(deposit, 21.0);
    System.out.println("Value: "+ deposit.amount());
} catch (NoSuchFieldException e) {
    e.printStackTrace();
} catch (IllegalAccessException e) {
    e.printStackTrace();
}

总的来说,这意味着 Java 和 JVM 都不是提供任何特定支持以使用不可变数据的编程环境。像 Clojure 这样的语言,由于有更严格的要求,最终不得不在其语言特定的运行时中做大量工作。

15.2.3 高阶函数

高阶函数的概念对 Java 程序员来说不应该令人惊讶。我们已经看到了一个静态方法makePrefixer()的例子,它接受一个前缀字符串并返回一个函数对象。让我们重写代码并将静态工厂改为另一个函数对象,如下所示:

Function<String, Function<String, String>> prefixer =
                                           prefix -> s -> prefix +": "+ s;

这可能一开始看起来有点难以阅读,所以让我们包括一些实际上不需要的额外语法,以使正在发生的事情更清晰:

Function<String, Function<String, String>> prefixer = prefix -> {
    return s -> prefix +": "+ s;
};

在这个扩展视图中,我们可以看到prefix是函数的参数,返回值是一个 lambda(实际上是一个 Java 闭包),它实现了Function <String, String>

注意函数类型Function<String, Function<String, String>>的出现——它有两个类型参数定义了输入和输出类型。第二个(输出)类型参数只是另一个类型——在这种情况下,它是一个函数类型。这是在 Java 中识别高阶函数类型的一种方法:一个Function(或其他函数类型),其中Function是其类型参数之一。

最后,我们应该指出,语言语法确实很重要——毕竟,函数对象可以作为匿名实现来创建,如下所示:

public class PrefixerOld
    implements Function<String, Function<String, String>> {

    @Override
    public Function<String, String> apply(String prefix) {
        return new Function<String, String>() {
            @Override
            public String apply(String s) {
                return prefix +": "+ s;
            }
        };
    }
}

如果当时存在Function类型(以及如果我们删除注解和泛型,那么至少从 Java 1.1 开始),这段代码甚至会是合法的。但它非常难看。很难看到结构,这也是为什么许多程序员认为函数式编程只有在 Java 8 中才出现。

15.2.4 递归

javac编译器提供了将 Java 源代码直接转换为字节码的简单翻译。正如我们所见,这适用于递归调用:

    public static long simpleFactorial(long n) {
        if (n <= 0) {
            return 1;
        } else {
            return n * simpleFactorial(n - 1);
        }
    }

这编译成以下字节码:

public static long simpleFactorial(long);
    Code:
       0: lload_0
       1: lconst_0
       2: lcmp
       3: ifgt          8
       6: lconst_1
       7: lreturn
       8: lload_0
       9: lload_0
      10: lconst_1
      11: lsub
      12: invokestatic  #37                 // Method simpleFactorial:(J)J
      15: lmul
      16: lreturn

当然,这有一些主要的限制。在这种情况下,调用simpleFactorial(100000)将由于字节 12 处的invokestatic调用而导致StackOverflowError,这将为每个递归调用在栈上放置一个额外的解释器帧。

注意:一个递归方法是指调用自身的方法。一个尾递归方法是指自我调用是该方法的最后一件事。

让我们尝试找到一种方法来查看是否可以避免递归调用。一种方法是将阶乘代码重写为尾递归形式,在 Java 中,我们可以通过一个私有辅助方法最简单地做到这一点,如下所示:

    public static long tailrecFactorial(long n) {
        if (n <= 0) {
            return 1;
        }
        return helpFact(n, 1);
    }

    private static long helpFact(long i, long j) {
        if (i == 0) {
            return j;
        }
        return helpFact(i - 1, i * j);
    }

入口方法tailrecFactorial()不执行任何递归;它只是设置尾递归调用并隐藏更复杂签名细节。该方法的字节码基本上是微不足道的,但为了完整性,让我们包括它:

public static long tailrecFactorial(long);
    Code:
       0: lload_0
       1: lconst_0
       2: lcmp
       3: ifgt          8
       6: lconst_1
       7: lreturn
       8: lload_0
       9: lconst_1
      10: invokestatic  #49                 // Method helpFact:(JJ)J
      13: lreturn

如您所见,这里没有循环,只有字节码 3 处的单个分支if。真正的动作(以及递归)发生在helpFact()中。这仍然由javac编译成字节码,其中包含递归调用,如下所示:

private static long helpFact(long, long);
    Code:
       0: lload_0
       1: lconst_0
       2: lcmp
       3: ifne          8
       6: lload_2                                        ❶
       7: lreturn                                        ❷
       8: lload_0
       9: lconst_1
      10: lsub
      11: lload_0
      12: lload_2
      13: lmul
      14: invokestatic  #49  // Method helpFact:(JJ)J    ❸
      17: lreturn

❶ 长整型是 8 字节,因此每个都需要两个局部变量槽

❷ 从 i == 0 路径返回

❸ 尾递归调用

然而,以这种形式,我们现在可以看到,这个方法中有两条路径。简单的i == 0路径从字节码 0 开始,通过 3 号字节码处的if条件,并在 7 号字节码处返回j。更一般的情况是从 0 到 3,然后从 8 到 14,这会触发一个递归调用。

因此,在只有方法调用的路径上,调用是递归的,并且总是发生在return之前最后发生的事情——也就是说,调用处于尾位置。然而,它可以被编译成以下字节码,从而避免了调用:

private static long helpFact(long, long);
    Code:
       0: lload_0
       1: lconst_0
       2: lcmp
       3: ifne          8
       6: lload_2             ❶
       7: lreturn             ❷
       8: lload_0
       9: lconst_1
      10: lsub
      11: lload_0
      12: lload_2
      13: lmul
      14: lstore_2            ❸
      15: lstore_0            ❸
      16: goto          0     ❹

❶ 长整型是 8 字节,因此每个都需要两个局部变量槽

❷ 从 i == 0 路径返回

❸ 重置局部变量

❹ 跳转到方法顶部

现在是坏消息的时候了:javac不会自动执行这个操作,尽管这是可能的。这又是编译器试图尽可能精确地将 Java 源代码转换为字节码的另一个例子。

注意:在本书附带的资源项目中,有一个如何使用 ASM 库生成实现先前字节码序列的类的示例,因为javac不会从递归代码中生成它。

为了完整性,我们应该说,在实践中,实现一个使用递归调用而不是覆盖栈帧来处理长整型的阶乘函数实际上并不会引起问题,因为阶乘增长得如此之快,以至于在达到任何栈大小限制之前,它就会溢出long类型可用的空间,如下所示:

$ java TailRecFactorial 20
2432902008176640000

$ jshell
jshell> 2432902008176640000L + 0.0
$1 ==> 2.43290200817664E18

jshell> Long.MAX_VALUE + 0.0
$2 ==> 9.223372036854776E18

因此,factorial(21)已经大于 JVM 可以表示的最大正long值。然而,尽管这个特定的简单例子相对安全,但它并没有改变这样一个困难的事实:Java 中所有的递归算法都潜在地容易受到栈溢出的影响。

这个特定的缺陷是 Java 语言的问题——而不是 JVM 的问题。JVM 上的其他语言可以,并且确实以不同的方式处理这个问题,例如,通过使用注解或关键字。当我们讨论本章后面如何使用 Kotlin 和 Clojure 处理递归时,我们将看到这方面的例子。

15.2.5 闭包

正如我们之前看到的,闭包本质上是一个捕获了 lambda 表达式声明作用域中一些可见状态的 lambda 表达式,如下所示:

int i = 42;
Function<String, String> f = s -> s + i;
// i = 37;
System.out.println(f.apply("Hello "));

当它运行时,它会产生预期的结果:Hello 42。然而,如果我们取消注释重新分配i值的行,那么会发生不同的事情:代码根本无法编译。

为了理解为什么会发生这种情况,让我们看看代码编译成的字节码。正如我们将在第十七章中看到的,Java 中 lambda 表达式的主体被转换为私有静态方法。在这种情况下,lambda 体变成了这样:

private static java.lang.String lambda$main$0(int, java.lang.String);
    Code:
       0: aload_1
       1: iload_0
       2: invokedynamic #32,  0 // InvokeDynamic #1:makeConcatWithConstants:
                                // (Ljava/lang/String;I)Ljava/lang/String;
       7: areturn

关键在于lambda$main$0()的签名。它接受两个参数,而不是一个。第一个参数是传递给闭包的i的值——在创建闭包时是 42(第二个是当 lambda 执行时 lambda 接受的String参数)。Java 闭包包含值的副本,这些值是位模式(无论是原始类型还是对象引用),而不是变量

注意 Java 是严格按值传递的语言——在核心语言中没有方法可以按引用传递按名称传递

要看到在闭包体外部作用域内对捕获状态的变化的影响(或在其他作用域中产生影响),捕获状态必须是一个可变对象,如下所示:

var i = new AtomicInteger(42);
Function<String, String> f = s -> s + i.get();
i.set(37);                                        ❶
// i = new AtomicInteger(37);                     ❷
System.out.println(f.apply("Hello "));

❶ 将值重新分配给可变对象状态是可行的。

❷ 这将无法编译。

事实上,在 Java 的早期版本中,只有显式标记为final的变量才能被 Java 闭包捕获其值。然而,从 Java 8 开始,这个限制被改为实际上是 final的变量——即使用起来像 final 变量一样使用的变量,即使它们实际上没有在声明中附加关键字。

这实际上是更深层次问题的症状。JVM 有一个共享的堆、方法私有的局部变量和方法私有的评估栈,仅此而已。与其他语言相比,JVM 和 Java 语言都没有“环境”或符号表的概念,也没有传递一个条目引用的能力。

在 JVM 上运行的非 Java 语言必须在其语言运行时中支持这些概念,因为 JVM 不提供对这些概念的任何固有支持。因此,一些编程语言理论家得出结论,Java 提供的不完全是真正的闭包,因为需要额外的间接层。Java 程序员必须修改对象值的内部状态,而不是能够直接更改捕获的变量。

15.2.6 懒惰

Java 在核心语言中不提供对普通值的懒惰评估的一级支持。然而,我们可以看到懒惰评估在 Java Streams API 中使用的有趣地方。如果你需要的话,附录 B 有关于流方面的复习内容。

注意:懒惰确实在 JVM 及其编程环境的一些部分中扮演着角色(例如,类加载的一些方面是懒惰的)。

在 Java 集合上调用stream()会产生一个Stream对象,这实际上是一组元素的懒惰表示。一些流也可以表示为 Java 集合;然而,流更通用,并不是每个流都可以表示为集合。

让我们再次看看典型的 Java filter()map()管道:

              stream()  filter()  map()     collect()
Collection -> Stream -> Stream -> Stream -> Collection

stream() 方法返回一个 Stream 对象。map()filter() 方法(以及几乎所有的 Stream 操作)都是懒加载的。在管道的另一端,我们有一个 collect() 操作,它将剩余的 Stream 的内容 实体化 回一个 Collection。这个 终端 方法是急切的,因此整个管道的行为如下:

              lazy      lazy      lazy      eager
Collection -> Stream -> Stream -> Stream -> Collection

除了将数据实体化回集合之外,该平台完全控制着可以评估多少流数据。这为一系列在纯急切方法中不可用的优化打开了大门。

有时将 Java 流的懒加载、函数式模式想象成科幻电影中的超空间旅行可能会有所帮助。调用 stream() 等同于从“正常空间”跳入一个规则不同的超空间领域(函数式和懒加载,而不是面向对象和急切)。

在操作管道的末端,一个终端流操作将我们从懒加载的函数式世界跳回到“正常空间”,要么通过将流重新实体化回一个 Collection(例如,通过 toList()),要么通过 reduce() 或其他操作聚合流。

使用懒加载确实需要程序员更加小心,但这种负担在很大程度上落在库编写者,如 JDK 开发者的身上。然而,Java 开发者应该了解并尊重流的一些懒加载特性的规则。例如,一些 java.util.function 接口的实现(例如,PredicateFunction)不应该改变内部状态或产生副作用。违反这个假设可能会导致开发者编写的实现或 lambda 表达式出现重大问题。

流的另一个重要方面是流对象本身(在流调用管道中被视为中间对象的 Stream 实例)是单次使用的。一旦它们被遍历,就应该认为它们是无效的。换句话说,开发者不应该尝试存储或重用流对象,因为这样做几乎肯定会导致错误,并且可能会抛出异常。

注意:将流对象放入临时变量几乎总是代码的坏味道,尽管在开发过程中,当调试与流相关的复杂泛型问题时这样做是可以接受的,前提是在代码完成后移除对流临时变量的使用。

流的另一个懒加载特性是能够模拟比集合更通用的数据。例如,可以通过使用 Stream.generate() 结合生成函数来构建一个无限流。让我们看一下:

public class DaySupplier implements Supplier<LocalDate> {
    private LocalDate current = LocalDate.now().plusDays(1);

    @Override
    public LocalDate get() {
        var tmp = current;
        current = current.plusDays(1);
        return tmp;
    }
}

final var tomorrow = new DaySupplier();
Stream.generate(() -> tomorrow.get())
      .limit(10)
      .forEach(System.out::println);

这会产生一个无限(或者如果你更喜欢,是所需大小)的日数据流。如果没有耗尽空间,这将是无法表示为集合的,从而表明流更加通用。

这个例子还表明,Java 的限制,例如按值传递,在一定程度上限制了设计空间。LocalDate 类是不可变的,因此我们需要有一个包含可变字段 current 的类,然后在 get() 方法中修改 current 以提供一个具有状态的、可以生成一系列 LocalDate 对象的方法。

在支持按引用传递的语言中,DaySupplier 类型是不必要的,因为 current 可以是一个与 tomorrow 同作用域的局部变量,而 tomorrow 可以是一个 lambda 表达式。

15.2.7 柯里化和部分应用

我们已经知道 Java 没有任何语言级别的支持用于柯里化,但我们可以快速看一下如何添加它。例如,以下是 java.util.functionBiFunction 接口的声明:

@FunctionalInterface
public interface BiFunction<T, U, R> {
    R apply(T t, U u);

    default <V> BiFunction<T, U, V> andThen(
                                  Function<? super R, ? extends V> after) {

        Objects.requireNonNull(after);
        return (T t, U u) -> after.apply(apply(t, u));
    }
}

注意接口的默认方法特性是如何用来定义 andThen() 的——一个超出标准 apply() 方法的额外方法。同样的技术可以用来提供一些对柯里化的支持,例如,通过定义以下两个新的默认方法:

default Function<U, R> curryLeft(T t) {
        return u -> this.apply(t, u);
    }

    default Function<T, R> curryRight(U u) {
        return t -> this.apply(t, u);
    }

这些定义了两种生成 Java Function 对象的方法,即从我们原始的 BiFunction 中产生一个只有一个参数的函数。请注意,它们被实现为闭包。我们只是捕获提供的值并存储起来,以便在真正应用函数时使用。然后我们可以像这样使用这些额外的默认方法:

BiFunction<Integer, LocalDate, String> bif =
                                       (i, d) -> "Count for "+ d + " = "+ i;

Function<LocalDate, String> withCount = bif.curryLeft(42);
Function<Integer, String> forToday = bif.curryRight(LocalDate.now());

然而,语法有些笨拙:它需要两个不同的方法来处理两种可能的柯里化,并且由于类型擦除,它们必须有不同的名称。即使如此,最终的功能可能也只有有限的用途,因此这种方法从未被实现,正如讨论的那样,Java 并不支持开箱即用的柯里化。

15.2.8 Java 的类型系统和集合

为了结束我们对 Java 对函数式编程不太友好倾向的不幸故事,让我们谈谈 Java 的类型系统和集合。Java 语言这些部分的以下三个主要问题导致了它们与函数式编程风格的某种不匹配:

  • 非单根类型系统

  • void

  • Java 集合的设计

首先,Java 有一个非单根类型系统(即没有 Objectint 的公共超类型)。这使得在 Java 中无法编写 List<int>,结果导致了自动装箱及其相关问题。

注意许多开发者抱怨在编译过程中泛型类型参数的擦除,但事实上,非单根类型系统才是泛型在集合中真正引起问题的原因。

Java 还有一个问题,与非单根类型系统有关:void。这个关键字表示方法不返回值(或者从另一个角度来看,当方法返回时,方法的评估栈为空)。因此,这个关键字携带的语义是,无论方法做什么,它都是纯粹通过副作用来执行的——在某种程度上与“纯”相反。

void 的存在意味着 Java 既有语句也有表达式,因此不可能实现设计原则,“一切皆表达式”,这是某些函数式编程传统非常热衷的。

注意:在第十八章中,我们将讨论瓦尔哈拉项目,该项目为 Java 语言设计者提供了一个机会,可能重新审视 Java 类型系统的非单根特性(以及其他目标)。

另一个问题与 Java 集合接口的形状和本质有关。它们是在 Java 1.2 版本(也称为 Java 2)中添加到 Java 语言的,该版本于 1998 年 12 月发布。它们并不是以函数式编程为设计初衷。

在使用 Java 集合进行函数式编程时,一个主要问题是到处都内置了可变性的假设。集合接口很大,并且明确包含来自 List<E> 的这些方法:

  • boolean add(E e)

  • E remove(int index)

这些是修改方法——它们的签名暗示了集合对象本身在原地被修改。

对于不可变列表的相应方法会有这样的签名:List<E> add(E e),它们返回一个新的、修改后的列表副本。remove() 的情况很难实现,因为 Java 没有从方法中返回多个值的能力。

注意:真正的问题是 remove() 方法在函数式编程(FP)中的实现是错误的,这与我们在第 10.4 节中讨论的 Iterator 的情况非常相似。

因此,所有这些都暗示了 任何 集合的实现都隐式地期望是可变的。确实存在使用 UnsupportedOperationException 的可怕技巧,我们在第 6.5.3 节中讨论过,但这不是一位扎实的 Java 开发者应该使用的东西。

其他非 Java 语言将集合类型的概念与可变性分开,例如,通过将它们表示为不同的接口(或在支持该概念的语言中将它们表示为不同的 特质)。这使得实现可以指定它们是否在类型级别上是可变的,通过选择实现或不实现这些单独的接口。

在所有这些背后,Java 的主要优点和最重要的设计原则之一是向后兼容性。这使得改变这些方面以使语言更具函数性变得困难,甚至不可能。例如,在集合的案例中,而不是直接尝试在集合接口上添加额外的函数式方法,我们采取了彻底的突破,引入了Stream,作为一种新的容器类型,它不具有集合的隐式语义。

当然,仅仅引入一个新的容器类型和 API 并不能改变数百万行使用集合的现有代码。对于 API 已经以集合类型表达的情况,这也没有丝毫帮助。

注意:这个问题并不仅限于流/集合的划分。例如,Java 反射是在 Java 1.1 中引入的,早于集合的出现。因此,API 的使用非常令人烦恼,因为它依赖于数组作为元素容器。

本节展示了关于 Java 中函数式编程支持状态的几个相当令人沮丧的事实。主要信息是,简单的函数式模式(如 filter-map-reduce)是可用的。这些模式对于各种应用以及很好地推广到并发(甚至分布式)应用都非常有用,但它们几乎就是 Java 能够做到的极限。让我们继续看看我们的非 Java 语言,看看情况是否有所改善。

15.3 Kotlin FP

我们已经展示了现代 Java 如何处理函数式编程范式中的某些基本、常见模式。对于倾向于函数式编程的人来说,Kotlin 带来了简洁性和一些额外的想法,这或许并不令人意外。

本节将概述要点,但如果你想要更深入地了解,可以查阅 Marco Vermeulen、Rúnar Bjarnason 和 Paul Chiusano 合著的《Kotlin 函数式编程》(Manning, 2021,mng.bz/o2Wr)。

15.3.1 纯函数和高级函数

在 9.2.4 节中,我们介绍了 Kotlin 的函数。在 Kotlin 中,函数是类型系统的一部分,使用如(Int) -> Int这样的语法表示,其中括号列表的内容是参数类型,箭头右边是返回类型。

通过使用这种表示法,我们可以轻松地为接受其他函数作为参数或返回函数的函数编写签名——即高级函数。Kotlin 自然鼓励使用这样的高级函数。实际上,围绕mapfilter等处理集合的 API 的大部分 API 都是基于这些高级函数构建的,正如我们在提供等效语言特征的 Java(和 Clojure)API 中所看到的那样。

但高阶函数不仅限于集合和流。例如,这里有一个经典的函数式编程函数称为composecompose将返回一个函数,该函数将调用传递给它作为参数的每个函数:

fun compose(callFirst: (Int) -> Int,
            callSecond: (Int) -> Int): (Int) -> Int {
  return { callSecond(callFirst(it)) }                   ❶
}

val c = compose({ it * 2 }, { it + 10 })                 ❷
c(10)                                                    ❸

❶ compose 返回一个函数,所以当此行执行时,callFirst 和 callSecond 不会被调用。

❷ 我们传递两个 lambda,使用第九章中描述的 it 简写来避免显式列出 lambda 的单个参数。

❸ 我们调用并运行由 compose 返回的函数,该函数返回 30。

Kotlin 提供了多种方式来获取函数的引用,具体取决于你的需求。你可以像之前那样声明 lambda 表达式(以及第九章中讨论的许多其他风味和功能)。或者,我们可以通过以下方式通过::语法引用命名函数:

fun double(x: Int): Int {
  return x * 2
}

val c = compose(::double, { it + 10 })
c(10)                                      ❶

❶ 与我们之前的示例结果相同

::不仅知道顶级函数。它还可以引用属于特定对象实例的函数,如下所示:

data class Multiply(var factor: Int) {
  fun apply(x: Int): Int = x * factor
}

val m = Multiply(2)
val c = compose(m::apply, { it + 10 })    ❶
c(10)

❶ 引用 Multiply 类上绑定到特定实例 m 的 apply 方法。

很遗憾,与 Java 类似,Kotlin 没有提供内置的方式来保证给定函数的纯度。虽然定义顶级函数(任何类之外)并使用val确保不可变数据可以让你走得很远,但它们并不确保函数的引用透明性。

15.3.2 闭包

lambda 表达式的一个可能表面上不明显方面是它们与周围代码的交互。例如,以下代码可以正常工作,即使local没有在我们的 lambda 中声明:

var local = 0
val lambda = { println(local) }
lambda()                           ❶

❶ 打印 0

这被称为闭包(即,lambda 捕获它可以看到的值)。重要的是,与 Java 不同,它不仅仅是 lambda 可以访问的变量的值——在幕后,它实际上保留了变量的引用,如下所示:

var local = 0
val lambda = { println(local) }
lambda()                           ❶

local = 10
lambda()                           ❷

❶ 打印 0

❷ 打印 10,lambda 被调用时 local 的更新值

即使变量本身在其他情况下已经超出作用域,这种对变量的闭包仍然存在。在这里,我们从函数中返回一个 lambda,保持对通常无法访问的变量的引用:

fun makeLambda(): () -> Unit {
  val inFunction = "I'm from makeLambda"    ❶
  return { println(inFunction) }
}

val lambda = makeLambda()
lambda()                                    ❷

❶ 因为我们的 lambda 表达式捕获了 inFunction,所以它在这里仍然可用——但仅限于我们的 lambda 内部。

❷ 当 makeLambda 完成后,inFunction 通常会超出作用域。

注意:将引用携带到其典型作用域之外的 lambda 可能是不期望的对象泄漏的来源!

lambda 表达式声明的位置决定了它在其闭包中可以捕获的内容。例如,如果在一个类内部声明,那么 lambda 可以像这样捕获对象中的属性:

class ClosedForBusiness {
  private val amount = 100
  val check = { println(amount) }     ❶
}

fun getTheCheck(): () -> Unit {
  val closed = ClosedForBusiness()
  return closed.check                 ❷
}

val check = getTheCheck()
check()                               ❸

❶ 将 lambda 保存到 check 中会捕获私有属性 amount。

❷ 此函数返回该 lambda,它保持对 amount 的引用。这使实例在函数完成后通常不会存在时仍然保持活跃。

❸ 当被调用时打印 100。当检查变量退出作用域时,关闭的实例也将最终有资格进行垃圾回收。

高阶函数的闭包为从旧函数构建新函数提供了丰富的基础。

15.3.3 Currying 和部分应用

Kotlin 的 currying 故事与 Java 中的非常相似。让我们看看一个例子:

fun add(x: Int, y: Int): Int {
  return x + y
}

fun partialAdd(x: Int): (Int) -> Int {
  return { add(x, it) }
}

val addOne = partialAdd(1)
println(addOne(1))
println(addOne(2))

val addTen = partialAdd(10)
println(addTen(1))
println(addTen(2))

这实际上只是一个语法技巧,因为它的工作原理是()运算符将转换为对apply()方法的调用。在字节码级别,这实际上与 Java 示例完全相同。我们可以想象一些辅助语法来自动创建 curries,可能类似于

val addOne: (Int) -> Int = add(1, _)
println(addOne(10))

然而,核心语言并不直接支持这一点。各种第三方库可以提供类似但稍微冗长的功能,通常通过扩展方法来实现。

15.3.4 不可变性

15.1.2 节将不可变性描述为函数式编程成功的关键技术。如果一个纯函数返回的数据对于给定的输入应该是相同的,那么允许对象事后改变就破坏了纯度为我们带来的保证。

Kotlin 帮助我们追求不可变性的一个主要特性是val声明。val确保属性只能在对象构造期间写入,就像 Java 的final一样。事实上,val实际上是 Java 的final var组合,但也可以应用于属性,并且编写起来不那么笨拙。

第九章涵盖了 Kotlin 支持使用val/var的许多位置,但要在函数式编程中取得成功,建议拥抱不可变性,并优先使用val而不是var。Kotlin 对属性的内置支持也消除了 Java 中所需的 getter 样板代码,如下所示:

class Point(val x: Int, val y: Int)

val point = Point(10, 20)
println(point.x)
// point.x = 20  // Won't compile because x is immutable!

尽管不可变对象有一个主要的问题,那就是当你实际上想要更改某事时的困难。为了保持我们的不可变性,我们必须创建全新的实例,但这可能既繁琐又容易出错。在 Java 中,这通常通过静态工厂方法、构建器对象或 wither 方法来解决,以减少噪音。

Kotlin 的data class结构为我们提供了对这些方法的良好替代方案。除了我们在 9.3.1 节中提到的构造函数和相等性操作之外,data 类还获得了一个copy方法。将copy与 Kotlin 的命名参数配对,你可以生成我们想要的新的实例,只写出你实际想要更改的部分,如下所示:

data class Point(val x: Int, val y: Int)

val point = Point(10, 20)
val offsetPoint = point.copy(x = 100)

copy确实有几个重要的注意事项。首先,它是一个浅拷贝。如果我们的字段之一是一个对象,我们复制的是对该对象的引用,而不是整个对象本身。就像在 Java 中,如果对象链中的任何一个允许修改,那么我们的保证就会被打破。为了实现真正的不可变性,所有涉及的对象都需要配合,但语言不会为你强制执行。

另一个需要注意的问题是,copy 仅从类的构造函数生成。如果我们改变规则,在对象的其他地方放置 var 字段,copy 就不知道这些额外的字段,它们在复制时只会获得默认值,如下所示:

data class Point(val x: Int, val y: Int) {
  var shown: Boolean = false
}

val point = Point(10, 20)
point.shown = true

val newPoint = point.copy(x = 100)
println(newPoint.shown)                 ❶

❶ 输出为 false,因为非构造函数字段没有被复制操作触及

但我们一开始就绝不允许可变字段悄悄地进入我们美好的不可变对象中,对吧?

控制我们对象的可变性是一个重要的第一步,但大多数非平凡代码将涉及对象集合,而不仅仅是单个实例。在第九章中,我们了解到 Kotlin 构建集合的函数(例如,listOfmapOf)返回接口,如 kotlin.collections.Listkotlin.collections.Map,与 java.util 中的对应物不同,它们是只读的。遗憾的是,尽管这是一个好的开始,但它并没有提供我们想要的保证。

我们无法信任这些对象的不可变性,因为可变接口扩展了只读特性。任何可以传递 List 的地方,你都可以按照以下方式传递 MutableList

fun takesList(list: List<String>) {
  println(list.size)
}

val mutable = mutableListOf("Oh", "hi")
takesList(mutable)

mutable.add("there")
takesList(mutable)

takesList 在两次调用中都接收了相同的对象,但调用结果却不同。我们的功能纯净性被破坏了!

注意:只读辅助工具如 listOf 使用底层的 JDK 集合,并返回只读的对象。例如,listOf 默认使用数组支持的列表实现,无法添加元素。正是 Kotlin 的可变接口与标准只读接口的混合,破坏了整个局面。

通过 JDK 类实现这些集合也会在接口之间进行类型转换时留下一些锋利的边缘。Kotlin 与 Java 集合的干净交互目标是,listOf() 的结果可以转换为 Kotlin 的可变接口和经典的 java.util.List<T>,在那里我们可以尝试修改集合!以下代码在编译时没有问题,但在运行时失败:

fun takesMutableList(muted: MutableList<Int>) {
  muted.add(4)
}

val list = listOf(1,2,3)
takesMutableList(list as MutableList<Int>)     ❶

❶ 此调用将抛出 java.lang.UnsupportedOperationException

这种真正的不可变性缺失在讨论并发时变得尤为成问题。正如我们在第六章中看到的,在多个线程之间使可变集合安全需要相当大的努力。如果我们有一个真正不可变的集合实例,那么它可以自由地在不同的执行线程之间分发,确保每个人都能得到一个相同的世界图景。

虽然 Kotlin 标准库中没有这些,但 kotlinx.collections.immutable 库(见 mng.bz/nNjg)提供了各种不可变和 持久 数据结构。常见的库如 Guava 和 Apache Commons 也提供了许多类似选项。

对于一个集合来说,持久性意味着什么?正如我们多次讨论的那样,不可变性意味着当你需要“更改”一个对象时,你实际上创建了一个新的实例。对于大型集合,这可能会非常低效。持久集合依赖于不可变性来降低修改的成本——它们被构建为安全地共享其内部存储的不可变部分。尽管你仍然创建一个新的对象来实施任何更改,但这些新对象可以比完整复制小得多,如下所示:

import kotlinx.collections.immutable.persistentListOf

val pers = persistentListOf(1, 2, 3)
val added = pers.add(4)
println(pers)                          ❶
println(added)                         ❷

❶ 打印 [1, 2, 3]

❷ 打印 [1, 2, 3, 4]

图书馆的核心实现了两组接口,分别以 ImmutableListPersistentList 为代表。对于映射、集合和通用集合也存在匹配对。ImmutableList 扩展了 List,但与它的基接口不同,它保证任何实例都是不可变的。ImmutableList 可以在任何需要传递列表并强制不可变性的地方使用。PersistentList 建立在 ImmutableList 之上,为我们提供了“修改并返回”的方法。

该库还包括以下熟悉的扩展,用于将其他集合转换为持久版本:

val mutable = mutableListOf(1,2,3)
val immutable = mutable.toImmutableList()
val persistent = mutable.toPersistentList()

在这一点上,你可能会想知道为什么我们不将每个 listOf 改为 persistentListOf 以防万一。但它们并不是默认实现的原因。尽管持久数据结构降低了复制的成本,但它们仍然无法触及经典可变数据结构的速度。更少的复制*并不等于没有复制!这要付出多少代价?

如同第七章所希望说服你的那样,唯一的方法是在你自己的用例中进行测量。但如果你需要在多个线程之间对集合进行并发访问,那么比较这些持久结构与更标准的带有同步的复制性能是值得的。现在我们已经有了 Kotlin 的工具包,可以用来使数据不可变,让我们看看它为递归函数世界带来的一个特性。

15.3.5 尾递归

在第 15.2.4 节中,我们探讨了 Java 中的递归函数。它们有一个主要的限制,因为每个连续的递归函数调用都会添加一个栈帧,最终耗尽可用空间。从我们将 simpleFactorial 函数翻译成 Kotlin(注意 Kotlin if 表达式作为返回值的使用)中可以看出,Kotlin 也有同样的限制:

fun simpleFactorial(n: Long): Long {
  return if (n <= 0) {
    1
  } else {
    n * simpleFactorial(n - 1)
  }
}

这产生了以下字节码,这与 javac 为我们的 Java 函数生成的字节码等效:

public final long simpleFactorial(long);
    Code:
       0: lload_1
       1: lconst_0
       2: lcmp
       3: ifgt          10
       6: lconst_1
       7: goto          22
      10: lload_1
      11: aload_0
      12: checkcast     #2                  // class Factorial
      15: lload_1
      16: lconst_1
      17: lsub
      18: invokevirtual #19                 // Method simpleFactorial:(J)J
      21: lmul
      22: lreturn

除了额外的验证(字节 12)和用 goto 代替多个 lreturn 指令之外,这基本上是相同的。在字节 18 处的递归调用,我们在 simpleFactorial 上调用 invokevirtual,最终会崩溃,如下所示:

java.lang.StackOverflowError
    at Factorial.simpleFactorial(factorial.kts:32)
    at Factorial.simpleFactorial(factorial.kts:32)
    at Factorial.simpleFactorial(factorial.kts:32)
  ...

尽管在一般情况下这个问题是不可避免的,但如果我们的函数是尾递归的,Kotlin 可以帮助我们解决问题。记住,一个尾递归函数是指递归是整个函数中的最后一个操作。之前,我们展示了如何在字节码级别重置状态并goto函数的顶部,而不是添加一个栈帧。这把我们的递归调用转换成了一个循环,没有任何栈溢出的风险。Java 没有提供任何方法来做这件事,但 Kotlin 可以。

注意:任何递归函数都可以重写为尾递归。这可能需要额外的参数、变量和技巧来完成转换,但总是可能的。鉴于尾递归函数可以被转换为简单的循环,这表明任何递归函数也可以仅使用循环结构迭代实现。

将我们的阶乘递归调用放到最终位置需要一点调整。就像 Java 一样,我们拆分了函数,以保留对用户友好的单参数形式,并将更复杂的递归函数——现在需要多个参数——放入一个单独的函数中,如下所示:

fun tailrecFactorial(n: Long): Long {
  return if (n <= 0) {
    1
  } else {
    helpFact(n, 1)
  }
}

tailrec fun helpFact(i: Long, j: Long): Long {    ❶
  return if (i == 0L) {
    j
  } else {
    helpFact(i - 1, i * j)
  }
}

❶ 我们的帮助函数被标记为 tailrec,这样 Kotlin 就知道要寻找尾递归。

入口函数 tailrecFactorial 在字节码中没有任何惊喜。它执行了我们的起始范围检查,然后按照以下方式将任务转交给我们的尾递归辅助函数:

public final long tailrecFactorial(long);
    Code:
       0: lload_1
       1: lconst_0
       2: lcmp
       3: ifgt          10
       6: lconst_1
       7: goto          19
      10: aload_0
      11: checkcast     #2                  // class Factorial
      14: lload_1
      15: lconst_1
      16: invokevirtual #10                 // Method helpFact:(JJ)J
      19: lreturn

字节 0–3 检查通过字节 6–7 实现的早期返回。如果我们需要执行递归调用,那么它会在字节 16 加载执行 invokevirtualhelpFact 所需的值。

tailrec 关键字引入的重要区别在 helpFact 的字节码中体现出来:

public final long helpFact(long, long);
    Code:
       0: lload_1
       1: lconst_0
       2: lcmp
       3: ifne          10
       6: lload_3
       7: goto          26
      10: aload_0
      11: checkcast     #2                  // class Factorial
      14: pop
      15: lload_1
      16: lconst_1
      17: lsub
      18: lload_1
      19: lload_3
      20: lmul
      21: lstore_3
      22: lstore_1
      23: goto          0
      26: lreturn

这个方法的大部分工作是在进行阶乘的逻辑检查和算术运算,但关键在于字节 23。我们不是通过 invokevirtualhelpFact 进行递归调用,而是简单地 goto 0 并重新开始函数。由于没有调用指令,我们没有任何栈溢出的风险,这是一个好消息。谁说 goto 总是危险的?

当你的函数可以被重写为适当的形式时,尾递归是一个优雅的解决方案。但如果你要求一个不是尾递归的函数具有尾递归,情况会怎样呢?如下所示:

tailrec fun simpleFactorial(n: Long): Long {   ❶
  return if (n <= 0) {
    1
  } else {
    n * simpleFactorial(n - 1)
  }
}

❶ 当我们的最终调用不是我们自己时,不恰当地请求 tailrec——在这种情况下,最终操作是递归调用结果上的 *

Kotlin 发现了问题,并警告我们它无法将字节码转换为利用尾递归的优势,直接指向我们的错误调用,如下所示:

factorial.kts:28:1: warning: a function is marked as tail-recursive
                             but no tail calls are found

tailrec fun simpleFactorial(n: Long): Long {
^
factorial.kts:32:9: warning: recursive call is not a tail call
    n * simpleFactorial(n - 1)
        ^

对于编译器来说,发出警告并不是一种特别强烈的行为,因为可能在一个尾递归实现创建之后,它可以被随后微妙地修改为不是尾递归。除非构建过程标记了警告,否则这段代码可能会逃逸到生产环境中,并在运行时引发 StackOverflowError。可以说,如果像某些其他语言(例如 Scala)那样,将非尾递归函数声明为 tailrec 导致编译错误会更好。

15.3.6 懒评估

如我们在本章前面提到的,许多函数式语言(如 Haskell)严重依赖于懒评估。作为 JVM 上的语言,Kotlin 并没有在其核心执行模型中将懒评估作为中心。但它确实通过 Lazy<T> 接口提供了对懒性的第一级支持。这为当你想要延迟——或者可能完全跳过——一些处理时提供了一个标准结构。

通常你不会自己实现 Lazy<T>,而是使用 lazy() 函数来构建实例。在最简单的形式中,lazy() 接受一个 lambda,其返回类型决定了返回接口的类型 T。lambda 不会执行,直到显式请求 value。我们还可以检查是否已经计算了值,如下所示:

val lazing = lazy {
  42
}

println("init? ${lazing.isInitialized()}")    ❶
println("value = ${lazing.value}")            ❷
println("init? ${lazing.isInitialized()}")    ❸

❶ 检查我们是否已初始化;将报告为假

❷ 访问值将强制我们的 lambda 执行并保存结果。

❸ 检查我们是否已初始化;将报告为真

我们推迟不必要的计算的需求可能与在多个线程上执行的需求重叠。当这种情况发生时,lazy() 接受一个 LazyThreadSafetyMode 枚举值来帮助控制这种情况。枚举值包括 SYNCHRONIZEDlazy() 的默认值)、PUBLICATIONNONE,如下所述:

  • SYNCHRONIZED 使用 Lazy<T> 实例本身来同步初始化 lambda 的执行。

  • PUBLICATION 允许多个初始化 lambda 的并发执行,但只保存第一个看到的值。

  • NONE 跳过同步,如果并发访问则行为未定义。

注意,LazyThreadSafetyMode.NONE 应该仅在满足以下条件时使用:1) 你已经测量过你的懒实例中的同步确实是一个性能问题,2) 你能以某种方式保证你永远不会从多个线程访问该对象。其他选项 SYNCHRONIZEDPUBLICATION 可以根据你的用例是否对初始化 lambda 并发运行多次敏感来选择。

Lazy<T> 接口是为了与另一个高级 Kotlin 特性配合使用,即 委托属性。当在类上定义属性时,我们不仅可以提供值或自定义的 getter/setter,还可以提供一个带有 by 关键字的对象。该对象必须实现 getValue() 和(对于 var 属性)setValue()Lazy<T> 符合这一规范,如下所示,因此我们可以轻松地将类的属性初始化推迟,而无需重复样板代码或偏离自然的 Kotlin 语法:

class Procrastinator {
  val theWork: String by lazy {
    println("Ok, I'm doing it...")   ❶
    "It's done finally"
  }
}

val me = Procrastinator()

println(me.theWork)                  ❷
println(me.theWork)                  ❸
println(me.theWork)                  ❸

❶ 诊断信息,以便更容易证明一切正常工作

❷ 第一次调用 theWork 将运行 lambda 并打印工作信息。

❸ 如最后两行所示,后续对 theWork 的调用将只返回相同的、已经计算出的值。

与不可变性一样,惰性非常适合我们自己的对象,但让我们对集合和迭代感到困惑。接下来,我们将看看 Kotlin 如何通过 Sequence<T> 接口允许我们更好地控制通过集合流式传输时的执行流程。

15.3.7 序列

虽然 Kotlin 的集合函数通常很方便,但它们假设我们会急切地对整个集合应用该函数。在我们的函数链中的每一步都会创建一个中间集合,如下所示——如果我们实际上不需要整个结果,这可能会造成浪费:

val iter = listOf(1, 2, 3)
val result = iter
    .map { println("1@ $it"); it.toString() }    ❶
    .map { println("2@ $it"); it + it }          ❷
    .map { println("3@ $it"); it.toInt() }       ❸

❶ 生成包含 [“1”,“2”,“3”] 的中间集合

❷ 生成包含 [“11”,“22”,“33”] 的中间集合

❸ 生成我们的最终结果 [11, 22, 33]

如果我们通过图 15.1 跟踪执行过程,我们可以看到我们的 map 调用链中的每一步都是在下一个 map 运行之前对整个列表进行的。

图 15.1 标准集合迭代

这会导致以下输出:

1@ 1
1@ 2
1@ 3
2@ 1
2@ 2
2@ 3
3@ 11
3@ 22
3@ 33

除了可能浪费资源的情况外,还有一些用例中我们的输入可能是无限的。如果我们想继续映射尽可能多的数字,直到用户告诉我们停止,我们无法提前创建列表并逐步处理整个内容。

为了处理这个问题,Kotlin 有 序列。序列的核心是 Sequence<T> 接口,它看起来与 Iterable<T> 类似,但在底层提供了全新的功能集。

我们可以使用 sequenceOf() 函数创建一个新的序列,然后开始应用我们熟悉的函数。在以下示例中,我们将列表转换为序列,并保留了诊断打印,以便我们可以看到正在发生什么:

val seq = sequenceOf(1, 2, 3)
val result = seq
    .map { println("1@ $it"); it.toString() }
    .map { println("2@ $it"); it + it }           ❶
    .map { println("3@ $it"); it.toInt() }

❶ 注意,这里的 + 表示字符串连接,而不是数值相加。

当我们运行这个简短的程序时,我们会发现没有任何打印输出。序列的主要特性是它们在评估上是惰性的。这个程序中实际上没有任何内容需要返回我们的 map 调用,所以 Kotlin 不会运行它们!不过,如果我们使用以下代码将序列转换为列表,程序将被迫评估一切,我们可以看到序列的控制流是如何运行的:

val seq = sequenceOf(1, 2, 3)
val result = seq
    .map { println("1@ $it"); it.toString() }
    .map { println("2@ $it"); it + it }
    .map { println("3@ $it"); it.toInt() }
    .toList()

我们可以在图 15.2 中看到序列的每个元素都单独经过 map 链,首先是 1,然后是 2,依此类推,在处理下一个元素之前。

图片

图 15.2 序列执行

这将产生以下输出:

1@ 1
2@ 1
3@ 11
1@ 2
2@ 2
3@ 22
1@ 3
2@ 3
3@ 33

这很有趣,但在相对较小、静态的列表上,可能并不那么吸引人——适当的测量是应得的,但序列所需的簿记可能会抵消不分配中间集合的收益。序列的力量在创建序列的替代方法中变得更加明显。

我们的第一站是 asSequence() 函数。不出所料,这个函数会将可迭代的对象转换为序列。然而,这个函数不仅与你可能期望的列表和集合一起工作,还可以对 范围 进行调用。

我们在 9.2.6 节中遇到了 Kotlin 的数字范围,当时它们被用来与 when 表达式一起检查包含性。但是范围也可以迭代。我们可以将此与 asSequence() 结合起来创建长数字列表,而无需繁琐的输入或过度分配,如下所示:

(0..1000000000)          ❶
  .asSequence()
  .map { it * 2 }

❶ 范围只跟踪其开始和结束,所以我们不会创建数十亿个元素。

但如果即使是范围的大但仍然有限的本性感觉过于限制性,怎么办呢?标准库中的 kotlin.sequencesgenerateSequence() 函数可以满足你的需求。这个函数创建了一个新的通用序列对象,并可选地包含一个起始值。每次它需要下一个元素时,它会运行提供的 lambda 表达式,并传入前一个值,如下所示:

generateSequence(0) { it + 2 }    ❶

❶ 一个无限序列的偶数

一个无限的 Iterable<T> 将无法链式调用方法,因为对它的第一次调用永远不会返回。Sequence<T> 将只获取它需要的,并将其余的留到以后。这与 take() 函数很好地搭配,我们可以请求获取特定数量的元素,作为一个新的、有界的序列,如下所示:

generateSequence(0) { it + 2 }
    .take(3)                      ❶
    .forEach { println(it) }      ❷

❶ 创建一个包含前三个元素的序列

❷ 通过序列强制评估并打印我们收到的结果

序列上的 forEach() 被称为 终端,因为它结束了序列的惰性并评估了所有内容。我们已经看到了另一个终端,即 toList(),它必然要遍历每个元素来构建一个列表。

如果出于某种原因,仅从序列中的前一个元素工作很困难,Kotlin 提供了另一种创建序列的选项。sequence()yield() 的组合允许我们构建完全任意的序列,如下所示:

val yielded = sequence {
  yield(1)
  yield(100)
  yieldAll(listOf(42, 3))     ❶
}

yieldAll() 接受一个与我们要 yield 的相同类型的可迭代对象,并在请求时依次产生每个元素。

正如我们所期望的,与序列一样,lambda 是惰性执行的,以确定下一个元素。然而,这里独特的是,对于每个对下一个元素的调用,lambda 只会运行到下一个 yield 并暂停。随后的对另一个元素的请求将恢复 lambda 在暂停的地方,并再次运行到下一个 yield,如图 15.3 所示。

图片

图 15.3 yield 执行的时间线视图

yield 使用 Kotlin 中的一个称为 suspend 函数的特性。正如其名所示,这些函数是 Kotlin 能够识别代码执行中可以暂停和恢复的点。在这种情况下,Kotlin 会看到每次我们 yield 一个值时,我们的 sequence lambda 的执行应该暂停,直到请求下一个值。尽管我们的代码看起来像是一个简单的 lambda,但实际上 Kotlin 编译器在幕后为我们做了很多额外的工作。

悬挂函数与 Kotlin 的替代并发模型——协程密切相关,我们在第 9.4 节中介绍了协程,将在第十六章中更详细地讨论。然而,值得注意的是,我们经常从并发角度考虑的功能特性,也解锁了独特的函数式编程方式。

15.4 Clojure FP

我们在第十章中遇到了 Clojure 函数式编程的基础,例如 (map) 形式。我们还对不变性和高阶函数等概念有了早期的介绍,因为这些想法和能力与 Clojure 编程模型的核心非常接近。

因此,在本节中,我们不会介绍 Clojure 对 Java 和 Kotlin 中讨论的功能特性的看法,而是超越这些基础,展示 Clojure 一些更高级的函数式特性是如何工作的,从对列表理解的一个注释开始。

15.4.1 理解

函数式编程中的一个重要概念是 comprehension 的概念,这里的词意味着集合或其他数据结构的“完整描述”。这个概念源于数学,我们经常看到集合论符号用来描述集合,如下所示:

{ x ∈ ℕ : (x / 2) ∈ ℕ }

在这个符号中,∈ 表示“是……的成员”,ℕ 是所有 自然数(或计数数)的无限集合,这些是我们用来计数对象(如 1、2、3 等等)的数,而 : 定义了一个条件或 限制

因此,这种理解描述了一组具有特殊属性的计数数字:集合中的每个数字,当除以二时,都会得到一个也是计数数字的数。当然,我们早已通过另一个名字知道这个集合——它是偶数集合。

关键点在于我们没有通过列出元素来指定偶数的集合(那是不可能的——有无限多个)。相反,我们通过从自然数开始并指定一个附加条件来定义“偶数”,这个条件必须对每个要包含在新集合中的元素成立。

如果这听起来有点像函数技术(如filter)的使用,那么它应该是——它们是非常相关的概念。然而,函数语言通常提供推导和filter-map,因为每种方法在不同的环境中都更容易使用。

Clojure 使用(for)形式来实现列表推导,以返回列表(或在某些情况下迭代器)。这就是为什么当我们遇到第十章中的 Clojure 循环时,我们没有介绍(for)——它实际上不是一个循环。让我们看看它是如何工作的:

user=> (for [x [1 2 3]] (* x x))
(1 4 9)

(for)形式接受两个参数:一个参数向量和将代表(for)将返回的整体列表中要生成的值的表达式。

参数向量包含一对(或多个对)元素:一个临时变量,它将在生成的值的定义中使用,以及一个序列来提供输入。我们可以将临时变量视为依次绑定每个值。当然,这可以很容易地写成如下映射:

user=> (map #(* % %) [1 2 3])
(1 4 9)

那么,我们想在何处使用(for)?当我们要构建更复杂结构时,它就派上用场,例如:

(for [num [1 2 3]
      ch [:a :b :c]]
  (str num ch))
("1:a" "1:b" "1:c" "2:a" "2:b" "2:c" "3:a" "3:b" "3:c")

我们也可以用映射来做这件事,但构造可能会更复杂和繁琐,而使用(for)则清晰直接。为了得到过滤的效果,我们还可以在(for)上使用一个额外的限定符,它可以作为一个限制,如下所示:

user=> (for [x (range 8) :when (even? x)] x)
(0 2 4 6)

让我们继续看看 Clojure 是如何实现懒加载的,特别是当应用于序列时。

15.4.2 懒序列

在 Clojure 中,懒加载通常在处理序列而不是单个值的懒加载时最常见。对于序列,懒加载意味着我们不必有一个包含序列中所有值的完整列表,而是在需要时(例如,通过调用一个函数按需生成它们)才能获得值。

在 Java 集合中,这样的想法可能需要类似自定义实现List的东西,而且没有方便的方法来编写它而不需要大量样板代码。另一方面,使用ISeq的实现将允许我们编写如下内容:

public class SquareSeq implements ISeq {
    private final int current;

    private SquareSeq(int current) {
        this.current = current;
    }

    public static SquareSeq of(int start) {
        if (start < 0) {
            return new SquareSeq(-start);
        }
        return new SquareSeq(start);
    }

    @Override
    public Object first() {
        return Integer.valueOf(current * current);
    }

    @Override
    public ISeq rest() {
        return new SquareSeq(current + 1);
    }
}

没有存储值,相反,序列的每个新元素都是按需生成的。这使我们能够模拟无限序列。或者考虑这个例子:

public class IntGeneratorSeq implements ISeq {
  private final int current;
  private final Function<Integer, Integer> generator;

  private IntGeneratorSeq(int seed,
                          Function<Integer, Integer> generator) {
      this.current = seed;
      this.generator = generator;
  }

  public static IntGeneratorSeq of(int seed,
                                   Function<Integer, Integer> generator) {
      return new IntGeneratorSeq(seed, generator);
  }

  @Override
  public Object first() {
      return generator.apply(current);
  }

  @Override
  public ISeq rest() {
      return new IntGeneratorSeq(generator.apply(current), generator);
  }
}

这个代码示例使用函数应用的结果来提供下一个序列的种子。这是可以的,前提是生成函数是纯的,但在 Java 中,当然没有任何保证。让我们继续前进,看看一些强大的 Clojure 宏,这些宏旨在帮助您只需付出少量努力就能创建懒序列。

考虑如何表示一个懒的、可能无限的序列。一个明显的选择是使用一个函数来生成序列中的项。该函数应该做两件事:

  • 返回序列中的下一个项

  • 采取固定、有限数量的参数

数学家会说,这样的函数定义了一个递归关系,而这种关系的理论立即表明递归是进行操作的一种适当方式。

想象你有一个机器,其中没有堆栈空间和其他限制,并且假设你可以设置两个执行线程:一个将准备无限序列,另一个将使用它。然后你可以使用递归在生成线程中定义懒序列,如下面的伪代码片段所示:

(defn infinite-seq <vec-args>
  (let [new-val (seq-fn <vec-args>)]
    (cons new-val (infinite-seq <new-vec-args>))    ❶
  ))

❶ 这实际上不起作用,因为对(infinite-seq)的递归调用会耗尽堆栈。

解决方案是添加一个结构,告诉 Clojure 优化递归并仅在需要时进行操作:(lazy-seq)宏。让我们看看下一个列表中的快速示例,它定义了懒序列kk+1k+2,...,对于某个数字 k。

列表 15.1 懒序列示例

(defn next-big-n [n] (let [new-val (+ 1 n)]
  (lazy-seq                                         ❶
    (cons new-val (next-big-n new-val))             ❷
  )))

(defn natural-k [k]
  (concat [k] (next-big-n k)))                      ❸

1:57 user=> (take 10 (natural-k 3))
(3 4 5 6 7 8 9 10 11 12)

❶ 懒序列标记

❷ 无限递归

❸ concat 限制了递归。

关键点是(lazy-seq)形式,它标记了一个可能发生无限递归的点,以及(concat)形式,它安全地处理它。然后你可以使用(take)形式从懒序列中提取所需数量的元素。懒序列是一个非常强大的功能,通过实践,你会发现它们是 Clojure 工具箱中非常有用的工具。

15.4.3 Clojure 中的柯里化

与其他语言相比,Clojure 中的柯里化函数具有额外的复杂性。这是由于我们第十章讨论的事实,即许多 Clojure 形式是可变参数的。

可变参数函数是一个复杂因素,因为它们提出了像“用户是否打算柯里化两个参数形式还是评估一个参数形式?”这样的问题——尤其是在 Clojure 使用函数的贪婪求值的情况下。

解决方案是(partial)形式,顺便说一下,这是一个真正的 Clojure 函数,而不是宏。让我们看看它在实际中的应用:

user=> (filter (partial = :b) [:a :b :c :b :d])
(:b :b)

函数=接受 1 个或多个参数,因此(= :b)会贪婪地求值到true,但(partial)的使用将其转换为柯里化函数。它在(filter)调用中的使用使其被识别为一个单参数函数(实际上,是单参数重载),然后它被用来测试以下向量中的每个元素。

注意 (partial) 本身只会对形式的前一个参数进行柯里化。如果我们想对其他参数进行柯里化,我们需要将 (partial) 与另一个形式结合——这个形式在函数应用之前会重新排列参数列表。

在我们探讨的三种语言中,Clojure 的函数式特性最为突出。如果这里的介绍已经激起了你的兴趣,你还有更多可以探索的内容。我们到目前为止所涵盖的只是冰山一角,但它确实有助于证明 JVM 本身可以是一个良好的函数式编程家园——更多的是 Java 语言阻碍了以函数式风格进行编程。

在本章中,我们深入探讨了函数式编程,远不止 Java Streams 的传统 filter-map-reduce 范式。我们主要通过跳出 Java,转向其他 JVM 语言来实现这一点。

当然,可以更进一步。存在两个主要的函数式编程学派:动态类型学派,以 Clojure(以及 JVM 之外的 Erlang 等语言)为代表,以及静态类型学派,包括 Kotlin,但可能更由 Scala(以及非 JVM 语言的 Haskell)代表。

然而,Java 的一项主要设计优点——向后兼容性——也可以被视为一种潜在的弱点。为 1.0 版本(超过 25 年前)编译的 Java 代码仍然可以在现代 JVM 上运行而无需修改。然而,这一显著的成就并非没有代价。Java 的 API 以及字节码和 JVM 的设计不得不忍受现在难以或无法更改的设计决策,而这些决策并不特别有利于函数式编程。这是开发者想要频繁使用函数式风格时,常常发现自己转向非 Java JVM 语言的一个主要原因。

摘要

  • Filter-map-reduce 是函数式编程的起点,而非终点。

  • Java 并非特别适合函数式风格的编程语言,因为它缺少像惰性求值、柯里化和尾递归优化这样的内置特性。

  • JVM 上的其他语言可以更好地支持函数式编程,例如 Kotlin 的 Lazy<T> 和 Clojure 的惰性序列。

  • JVM 层面仍然存在一些问题,例如数据的默认可变性,改变编程语言的选择并不能从根本上解决这个问题。

16 高级并发编程

本章涵盖

  • Fork/Join API

  • 工作窃取算法

  • 并发与函数式编程

  • Kotlin 协程背后的原理

  • Clojure 并发

  • 软件事务内存

  • 代理

在本章中,我们将结合前面章节中的几个主题。特别是,我们将把前面章节中的函数式编程概念与第六章中的 Java 并发库编织在一起。我们的非 Java 语言也包括在内,Kotlin 和 Clojure 的一些新颖并发特性将在本章后面出现。

注意:本章中的概念,如协程和代理(又称演员),也越来越多地成为 Java 并发领域的一部分。

我们将从一个小小的异常开始:Java Fork/Join API。这个框架允许一类并发问题比我们在第六章中看到的 executors 更有效地处理。

16.1 Fork/Join 框架

正如我们在第七章中讨论的那样,处理器速度(或者更确切地说,CPU 上的晶体管数量)在近年来大幅提高。I/O 性能并没有相同的显著改进,因此最终结果是等待 I/O 现在成为一种常见情况。这表明我们可以更好地利用我们计算机内部的处理器能力。Fork/Join(F/J)框架正是为了实现这一点而进行的尝试。

F/J 主要关于在用户不可见的线程池上自动调度任务。为此,任务必须能够以用户指定的方式进行拆分。在许多应用中,F/J 有一个关于“小任务”和“大任务”的概念,这对于框架来说是非常自然的。

让我们快速看一下与 F/J 相关的某些主要事实和基本概念:

  • 框架引入了一种新的执行器服务类型,称为ForkJoinPool

  • ForkJoinPool处理一个比Thread“更小”的并发单元(ForkJoinTask)。

  • ForkJoinPool可以通过更轻量级的方式对ForkJoinTask进行调度。

  • F/J 使用以下两种类型的任务(都表示为ForkJoinTask的实例):

    • “小任务”是指那些可以立即执行而不会消耗太多处理器时间的任务。

    • “大任务”是指在直接执行之前需要拆分(可能需要多次拆分)的任务。

  • 框架提供了基本方法来支持大任务的拆分。

  • 框架具有自动调度和重新调度功能。

框架的一个关键特性是,预期这些轻量级任务可能会产生其他ForkJoinTask实例,这些实例将在执行其父任务的同一个线程池上进行调度。这种模式有时被称为分而治之

我们将从使用 F/J 框架的简单示例开始,然后简要介绍适合这种并行处理方法的问题特征。然后我们将讨论 F/J 中使用的“工作窃取”功能及其在更广泛环境中的相关性。开始使用 F/J 的最佳方式是举一个例子。

16.1.1 一个简单的 F/J 示例

作为 F/J 框架能做什么的一个简单例子,考虑以下情况:我们在不同时间创建了一系列事务对象。我们将使用Transaction类来表示它们,如下所示,这是从第五章和第六章中遇到的TransferTask类演变而来的:

public class Transaction implements Comparable<Transaction> {
    private final Account sender;
    private final Account receiver;
    private final int amount;
    private final long id;
    private final LocalDateTime time;

    private static final AtomicLong counter = new AtomicLong(1);

    Transaction(Account sender, Account receiver,
                int amount, LocalDateTime time) {
        this.sender = sender;
        this.receiver = receiver;
        this.amount = amount;
        this.id = counter.getAndIncrement();
        this.time = time;
    }

    public static Transaction of(Account sender, Account receiver,
                                 int amount) {
        return new Transaction(sender, receiver,
                               amount, LocalDateTime.now());
    }

    @Override
    public int compareTo(Transaction other) {
        return Comparator.nullsFirst(LocalDateTime::compareTo)
                         .compare(this.time, other.time);
    }

    // Getter and other methods (equals, hashcode, etc) elided
}

我们希望获得一个按时间排序的事务列表。为了实现这一点,我们将使用 F/J 作为多线程排序——实际上是一种归并排序算法的变体。

我们的例子使用了RecursiveAction,它是ForkJoinTask的一个特殊子类。它比一般的ForkJoinTask简单,因为它明确表示没有整体结果(事务将在原地重新排序),并且强调任务的递归性质。

TransactionSorter类提供了一种使用Transaction对象的compareTo()方法对一系列更新进行排序的方法。compute()方法(你必须实现,因为它在RecursiveAction超类中是抽象的)基本上是按照创建时间对事务数组进行排序,如下一个列表所示。

列表 16.1 使用RecursiveAction进行排序

public class TransactionSorter extends RecursiveAction {
    private static final int SMALL_ENOUGH = 32;                            ❶
    private final Transaction[] transactions;
    private final int start, end;
    private final Transaction[] result;

    public TransactionSorter(List<Transaction> transactions) {
        this(transactions.toArray(new Transaction[0]),
             0, transactions.size());
    }

    public TransactionSorter(Transaction[] transactions) {
        this(transactions, 0, transactions.length);
    }

    public TransactionSorter(Transaction[] txns, int start, int end) {
        this.start = start;
        this.end = end;
        this.transactions = txns;
        this.result = new Transaction[this.transactions.length];
    }

    /**
     * This method implements a simple Mergesort. Please consult a suitable
     * textbook if you are interested in the implementation details.
     *
     * @param left
     * @param right
     */
    private void merge(TransactionSorter left, TransactionSorter right) {
        int i = 0;
        int lCount = 0;
        int rCount = 0;

        while (lCount < left.size() && rCount < right.size()) {
            int comp = left.result[lCount].compareTo(right.result[rCount]);
            result[i++] = (comp < 0)
                    ? left.result[lCount++]
                    : right.result[rCount++];
        }

        while (lCount < left.size()) {
            result[i++] = left.result[lCount++];
        }

        while (rCount < right.size()) {
            result[i++] = right.result[rCount++];
        }
    }

    public int size() {
        return end - start;
    }

    public Transaction[] getResult() {
        return result;
    }

    @Override
    protected void compute() {                                             ❷
        if (size() < SMALL_ENOUGH) {
            System.arraycopy(transactions, start, result, 0, size());
            Arrays.sort(result, 0, size());
        } else {
            int mid = size() / 2;
            TransactionSorter left =
                new TransactionSorter(transactions, start, start + mid);
            TransactionSorter right =
                new TransactionSorter(transactions, start + mid, end);
            invokeAll(left, right);

            merge(left, right);
        }
    }
}

❶ 32 个或更少的排序序列

❷ 在 RecursiveAction 中定义的方法

要使用排序器,你可以用一些像下面这样的代码来驱动它,这将生成一些事务并将它们打乱,然后再将它们传递给排序器。输出是重新排序的更新:

var transactions = new ArrayList<Transaction>();
var accs = new Account[] {
              new Account(1000),
              new Account(1000)};

for (var i = 0; i < 256; i = i + 1) {
  transactions.add(Transaction.of(accs[i % 2], accs[(i + 1) % 2], 1));
  Thread.sleep(1);
}
Collections.shuffle(transactions);

var sorter = new TransactionSorter(transactions);
var pool = new ForkJoinPool(4);

pool.invoke(sorter);

for (var txn : sorter.getResult()) {
  System.out.println(txn);
}

F/J 的承诺似乎很有吸引力,但在实践中,并不是每个问题都能像我们刚才讨论的多线程MergeSort那样简单地简化。

这是一个反模式“简单情况很简单”的例子,其中开发者可能会被一种看似简单的技术所吸引,这种技术允许通过非常少的努力完成简单的任务,但掩盖了技术无法扩展或很好地推广到不那么简单的情况的事实。我们应该谈谈那些可能通过使用 F/J 方法容易解决的问题,以及那些可能更适合其他方法的问题。

16.1.2 并行化适合 F/J 的问题

这里有一些适合 F/J 方法的典型问题示例:

  • 模拟大量简单物体的运动(例如,粒子效果)

  • 日志文件分析

  • 数据操作,其中从一个聚合输入中计算出一个数量(例如,map-reduce 操作)

另一种看待这个问题的方式是说,一个好的 F/J 问题是可以分解的,如图 16.1 所示。

图片

图 16.1 分支和合并

确定一个问题是否可能得到良好解决的一个实用方法是将以下清单应用于该问题和其子任务:

  • 问题子任务能否在没有子任务之间显式合作或同步的情况下工作?

  • 子任务是否从其数据中计算一些值而不改变它(即,它们是否是纯函数)?

  • 对于子任务来说,分而治之是否是自然的?

如果对前面问题的答案是“是!”或“大多数情况下是,但有边缘情况”,那么你的问题可能非常适合 F/J 方法。另一方面,如果那些问题的答案是“可能”或“不是真的”,你可能会发现 F/J 的性能不佳,而另一种方法可能更有效。

设计良好的多线程算法很困难,F/J 并不适用于所有情况。它在自己的适用范围内非常有用,但最终,你必须决定你的问题是否适合该框架。如果不适合,你必须准备好开发自己的解决方案,这可能意味着在 java.util.concurrent 的出色工具箱之上构建。

16.1.3 工作窃取算法

ForkJoinTaskRecursiveAction 的超类。它是一个动作返回类型中的泛型类(因此 RecursiveAction 扩展 ForkJoinTask<Void>)。这使得 ForkJoinTask 非常适合将数据集归结为一种方法,并返回一个结果或通过副作用执行(正如 RecursiveAction 的情况)。

ForkJoinTask 类型的对象在 ForkJoinPool 上进行调度,这是一种专为这些轻量级任务设计的新的执行服务类型。服务为每个线程维护一个任务列表,如果一个任务完成,服务可以从满载的线程重新分配任务给空闲的线程。我们可以在图 16.2 中看到这种情况。

图 16.2

图 16.2 工作窃取:当第二个线程完成其任务时,服务将任务从仍然忙碌的第一个线程重新分配给第二个线程。

没有这个 工作窃取算法,可能会出现与两种任务大小相关的调度问题。一般来说,两种大小的任务可能需要非常不同的运行时间。

例如,一个线程可能只有一个由小任务组成的工作队列,而另一个线程可能只有大任务。如果小任务运行速度比大任务快五倍,那么只有小任务的线程很可能在大型任务线程完成之前就空闲了。

警告:工作窃取依赖于任务之间相互独立的假设。如果这个假设不成立,计算结果可能会在不同的运行中有所不同。

工作窃取(Work-stealing)被精确实现以解决此问题,并允许在整个 F/J 作业的生命周期中使用所有池线程。这是完全自动的,你不需要做任何具体的事情来获得工作窃取的好处。这是运行时环境为了帮助开发者管理并发而做更多工作的另一个例子。文档也明确指出:ForkJoinPool 也适用于与事件式任务一起使用,这些任务永远不会合并。

注意 ForkJoinPool 也用于流行的 Java/JVM 库中,例如 Scala 和 Java 中的基于演员的并发 Akka 系统。

要与 ForkJoinPool 交互,该类公开以下主要方法:

  • execute()—启动异步执行

  • invoke()—启动执行并等待结果

  • submit()—启动执行并返回结果的 future

自 Java 8 以来,运行时已包含一个公共池,通过 ForkJoinPool.commonPool() 访问。这主要提供其工作窃取功能——并不期望许多程序会将其用于递归分解。

公共池具有许多可配置的属性,可以设置以控制诸如并行级别(即使用多少线程)和用于为公共池创建新线程的线程工厂类等事项。

16.2 并发与函数式编程

在第五章中,我们遇到了不可变对象的概念,并展示了它们对于并发编程非常有用,因为它们避开了 共享可变状态 的问题,这是许多并发问题的核心。因此,我们可能会猜测,利用不可变性的函数技术对于构建并发应用程序是一个重要的工具。这是真的,但不可变性的一个小扩展也与并发编程相关。

16.2.1 重新审视 CompletableFuture

在第六章中,我们遇到了 CompletableFuture 类。这种类型不是不可变的,但它有一个非常简单的状态模型,下面将进行描述:

  • 它从未完成状态开始。

  • 任何尝试从其中获取 get() 值的尝试都将阻塞。

  • 在某个稍后的时间,发生发布事件。

  • 这将设置值并将其传递给任何阻塞在 get() 上的线程。

  • 已发布的价值现在不可更改。

图 16.3 展示了未来和发布事件。

图片

图 16.3 事件发布设置值并将其传递给任何阻塞在 get() 上的线程。

CompletableFuture 的一个重大优势是,可以组合带有结果的功能,并且结果将被延迟评估,也就是说,函数将在值到达之前不会执行。

这种函数组合可以同步或异步发生。这也许通过运行几个示例最容易看出。让我们重用第六章中提到的 NumberService 的想法,并使用一个模拟实现,例如:

public class NumberService {
    public static long findPrime(int n) {
        try {
            Thread.sleep(5_000);
        } catch (InterruptedException e) {
            throw new CancellationException("interrupted");
        }
        return 42L;
    }
}

这显然实际上并没有计算素数,但它足以演示线程行为,这是我们想要的目标。我们需要一些代码来驱动它,如下所示:

var n = 1000;
var future =
  CompletableFuture.supplyAsync(() -> {                                    ❶
  System.out.println("Starting on: "+ Thread.currentThread().getName());
  return NumberService.findPrime(n);
});
var f2 = future.thenApply(l -> {                                           ❷
  System.out.println("Applying on: "+ Thread.currentThread().getName());
  return l * 2;
});
var f3 = future.thenApplyAsync(l -> {                                      ❸
  System.out.println("Async on: "+ Thread.currentThread().getName());
  return l * 3;
});

try {
  System.out.println("F2: "+ f2.get());
  System.out.println("F3: "+ f3.get());
} catch (InterruptedException | ExecutionException e) {
  e.printStackTrace();
}

❶ 提供要异步运行的计算

❷ 为异步计算的结果提供要应用的功能

❸ 为结果提供另一个异步应用的功能

当我们运行这段代码时,我们会得到一些类似这样的输出:

Starting up on thread: ForkJoinPool.commonPool-worker-19
Applying on thread: ForkJoinPool.commonPool-worker-19
Applying async on thread: ForkJoinPool.commonPool-worker-5
F2: 84
F3: 126

使用thenApply()f2未来将在与future相同的线程上执行,而f3(使用thenApplyAsync())将在池中的不同线程上执行。

你可能会注意到,默认情况下,CompletableFuture代码的执行都使用公共池。这个池以ForkJoinPool.commonPool的名称出现,如前一个输出所示。

在某些情况下,开发者可能想要使用一个替代的线程池。例如,常见的线程池在可配置的最大线程数方面是不可配置的,这可能不适合某些工作负载。幸运的是,CompletableFuture的工厂方法,如supplyAsync(),提供了带有显式Executor参数的重载。这允许未来在特定的线程池上运行。

除了thenApply()方法外,CompletableFuture还提供了thenCompose()。一些开发者发现这两个方法之间的区别令人困惑,所以让我们花点时间来解释一下。

回想一下,thenApply()接受一个Function作为参数,该函数将T映射到U。这个函数在原始未来完成后的同步方式应用于CompletableFuture运行的任何线程上。

另一方面,thenCompose()接受一个将T映射到CompletableFuture<U>(实际的返回类型是CompletionStage<U>而不是CompletableFuture<U>,但现在我们先忽略这个细节)的Function。这实际上是一个异步函数(它可以在不同的线程上运行)。让我们看一个具体的例子:

Function<Long, CompletableFuture<Long>> f = l ->
  CompletableFuture.supplyAsync(() -> {
      System.out.println("Applying on thread: " +
                          Thread.currentThread().getName());
      return l * 2;
  });

我们可以将这个函数传递给thenApply(),但结果将会是一个CompletableFuture<CompletableFuture<Long>>。相反,thenCompose()会将结果扁平化回一个CompletableFuture<Long>。这类似于 Java Streams API 中的flatMap()方法——它将返回Stream<T>的函数应用到流对象上,但与返回Stream<Stream<T>>不同,它将独立的流扁平化并合并成一个单一的流。

CompletableFuture还支持join(),它本质上就像一个线程 join,但返回一个值。也可以通过让代码在任意(或两个)未来完成之后运行来“连接”未来。例如:

var n = 1000;
var future = CompletableFuture.supplyAsync(() -> {
    System.out.println("Starting up: "+ Thread.currentThread().getName());
    return NumberService.findPrime(n);
});

var future2 = CompletableFuture.supplyAsync(() -> {
    System.out.println("Starting up: "+ Thread.currentThread().getName());
    return NumberService.findPrime(n);
});

Runnable dontKnow = () -> System.out.println("One of the futures finished");
future.runAfterEither(future2, dontKnow);

现在,我们希望将上一章中关于函数式编程的讨论与这里的思想结合起来。

如果我们想要应用于 CompletableFuture 结果的函数是纯函数且不依赖于任何东西,只依赖于输入值,那么将函数应用于未来的操作与将函数应用于结果的操作相同。换句话说:如果将未来视为一个容器类型,它包含一个值,那么容器对于应用于该值的函数来说是“透明的”,一旦值到达,容器就不再存在。

特别是,引用透明度(例如,使用纯函数)的两个主要好处如下:

  • 缓存。

  • 可移植性。

第一个意思是任何纯函数调用都可以替换为已计算出的值——我们不需要用相同的参数重新运行函数调用,因为我们已经知道答案了。其次,当然,如果我们正在计算一个纯函数,那么它发生在哪个线程上并不重要,所以无论我们是否提供了同步或异步应用函数,都不会影响结果。

注意,如前所述,Java 是一种相当不纯的语言,因此许多这些好处只有在程序员小心地使用纯函数和不可变数据时才会适用。

当我们在讨论并发函数式编程时,似乎有必要谈谈 并行流,这是许多开发者误解的 Streams API 的一个领域。

16.2.2 并行流

在第五章中,我们遇到了阿姆达尔定律,这是关于数据并行性的基本结果之一。这是我们处理大量非常相似且必须以更多或更少相同方式处理的大批量数据时经常想要使用的并发方法。一般来说,如果以下所有条件都成立,数据并行方法是有用的:

  • 你需要处理大量以相同(或非常相似)方式处理的数据。

  • 排序不重要。

  • 项之间相互独立。

  • 你可以显示特定的处理步骤是瓶颈。

并行流是一种数据并行类型,许多 Java 开发者在 Java 8 中将其包含进来时非常兴奋。然而,正如我们将看到的,现实与最初的希望大相径庭。这里显示的 API 看起来足够简单:

// Just replace stream() with parallelStream()
List<String> origins = musicians
      .parallelStream()
      .filter(artist -> artist.getName().startsWith("The"))
      .map(artist -> artist.getNationality())
      .collect(toList());

在底层,工作是通过 F/J 框架进行分布式处理的,并使用工作窃取算法将计算分散到多个核心上。以下内容看起来好得令人难以置信:

  • 工作由框架管理。

  • API 旨在明确但又不显眼。

  • 按数据分布。

  • parallelStream() 允许程序员在顺序和并行之间切换。

  • “免费加速”。

事实上,这确实好得令人难以置信。第一个且最明显的问题是阿姆达尔定律。要将顺序任务分割成可以并行执行的一组块,需要做工作——即计算时间。准备和通信开销越大,多处理器提供的益处就越少——这就是阿姆达尔定律的本质。

没有一种简单可靠的方法可以轻松地估计拆分与线性操作成本之间的相对成本。框架将决定是否值得并行化的认知成本转回给开发者。这听起来就像“自动并行化”并不是曾经承诺的极乐境界。相反,最终用户必须对许多理想的抽象化细节有敏锐的认识。

仅举一个例子:拆分和重新组合的工作必须在 JVM 内部的线程池中完成。JVM 创建的线程越多,它们对 CPU 时间的竞争就越激烈。Streams API 并非事先就知道当前进程中存在多少个并行流的实例。这导致了以下两种同样令人不快的策略:

  • 为并行流的每次调用创建一个新的、专用的线程池。

  • 创建一个线程池的单个实例(私有于 JVM),并使所有并行流的调用都使用它。

第一种选择可能导致线程的无界创建,最终会饿死或崩溃 JVM。因此,在 Java 8 中,并行流下面有一个单一的共享线程池:ForkJoinPool.commonPool()。这个选择可能导致对共享资源的潜在竞争(正如我们在第七章中看到的,这是许多性能问题的真正根源)。

有一个解决方案:如果你将并行流作为一个任务在 ForkJoinPool 上执行,它将在这里执行,而不是使用公共池,如下所示:

// Use a custom pool
var forkJoinPool = new ForkJoinPool(4);
List<String> origins2 = forkJoinPool.submit(() -> musicians
    .parallelStream()
    .filter(artist -> artist.getName().startsWith("The"))
    .map(artist -> artist.getNationality())
    .collect(toList())).get();
forkJoinPool.shutdown();

注意,forkJoinPool 必须显式关闭,否则它将保留在内存中等待新任务,从而泄漏内存和线程。

通常,关于并行流的最佳建议是不要盲目地应用并行化。相反,实际上展示你有使用它的用例。像往常一样,这是通过测量并展示流操作确实是瓶颈,然后再尝试看并行流是否有所帮助来完成的。

不幸的是,没有一类问题可以预期并行流可能有所帮助。每个案例都必须从基本原则出发进行检验和测试。只有在这种情况下,你才能尝试将并行性应用于流,并通过数据证明可以取得有价值的改进。

16.3 Kotlin 协程的内部机制

正如我们在第九章中介绍的,Kotlin 为并发提供了一个对 Thread 模型的替代方案,即 协程。协程可以被视为“轻量级线程”,它们没有完整操作系统线程的资源惩罚。这与 Java 的 Fork/Join 有一些相似之处。Kotlin 是如何提供这种替代执行方式的?对表面之下发生的事情有更深入的了解是必要的。

16.3.1 协程的工作原理

让我们从第九章中看到的修改后的例子开始:

package com.wgjd

import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

fun main() {
  GlobalScope.launch {
    delay(1000)
    sus()
  }

  Thread.sleep(2000)
}

suspend fun sus() {
  println("Totally sus...")
}

这里我们使用GlobalScope.launch来启动一个新的协程。作用域用于表达协程应该如何运行,我们将在下一节更仔细地研究它们。

我们在这里创建的协程将使用delay函数等待一秒,然后调用我们的函数sus。最后,我们使用Thread.sleep等待 2 秒,以确保在程序完全退出之前协程有足够的时间完成。

协程与线程的不同之处在于,它们的执行可能在特定的点暂停。Kotlin 是如何知道它可以暂停的地方呢?这来自于我们放在sus函数上的suspend关键字(以及库提供的delay函数)。suspend函数标记了 Kotlin 将其视为执行单元的代码块。

在我们的协程中,suspend函数标记了代码块,在这些代码块之间我们可以暂停,Kotlin 可以创建一个状态机来管理协程的执行。通过我们生成的代码跟踪状态机的进度,我们的suspend定义的块提供了到达该机器的步骤。

让我们将我们的协程转换成这样的状态机:

  GlobalScope.launch {
    delay(1000)
    sus()
  }

我们协程的步骤如下,并在图 16.4 中展示:

  • 通过调用launch创建我们的新协程实例。

  • 通过delay执行。

  • 控制权返回到 Kotlin,它将等待请求的 1 秒。

  • delay之后恢复,并通过sus执行。

  • 将控制权交还给 Kotlin,这次不需要暂停。

  • sus之后恢复,并完成协程。

图片

图 16.4 协程状态机

这种步骤分解清楚地说明了为什么协程有时被描述为“协作式多任务处理”。在调用suspend函数之间的代码是同步执行的,suspend点提供了唯一的暂停和执行其他协程步骤的机会。想象一下,在delaysus之间我们进入了一个无限循环。这个循环将永远阻塞它正在执行的任何线程。

这个状态机不仅仅是一个想法——Kotlin 直接生成执行此操作的代码,我们可以检查它。让我们看看我们之前函数的输出,看看它是如何被转换的。(注意,为了长度和清晰度,省略了一些细节。)

我们基本应用程序的编译结果比我们预期的有更多的类文件。例如,以下是 Gradle 构建目录下的结果输出:

build
└── classes
     └── kotlin
          └── main
               └── com
                    └── wgjd
                         ├── MainKt$main$1.class
                         └── MainKt.class

我们在第九章遇到了MainKt.class。Kotlin 透明地创建了一个用于顶层函数的持有类,因为 JVM 原生不支持自由浮动的函数,必须将所有方法代码放在某个类中。

同时,我们还有一个新的类:MainKt$main$1.class。反汇编这个类文件揭示 Kotlin 为我们的协程创建了类似的“秘密”类,就像它为我们顶级函数所做的那样。这个生成的类代表我们协程的单次执行。正如我们接下来可以看到的,这个生成的类是我们代码和将我们编写的作为协程运行的管道的混合体:

final class com.wgjd.MainKt$main$1
    extends kotlin.coroutines.jvm.internal.SuspendLambda    ❶
    implements kotlin.jvm.functions.Function2<              ❷
        kotlinx.coroutines.CoroutineScope,
        kotlin.coroutines.Continuation<? super kotlin.Unit>,
        java.lang.Object> {

❶ 我们生成的类从 Kotlin 的 SuspendLambda 获取功能。

❷ 我们生成的类实现了调用代码将用来调用我们的协程的特定接口。

如果我们检查 MainKt.class 中从我们的 main 函数生成的代码,我们看到 Kotlin 创建了一个我们的协程实例然后调用了它:

Compiled from "Main.kt"
public final class com.wgjd.MainKt {

  public static final void main();
    Code:
       0: getstatic     #41  // Field                                      ❶
                             // kotlinx/coroutines/GlobalScope.INSTANCE:
                             // Lkotlinx/coroutines/GlobalScope;
       3: checkcast     #43  // class kotlinx/coroutines/CoroutineScope
       6: aconst_null
       7: aconst_null
       8: new           #45  // class com/wgjd/
                             // MainKt$main$1                              ❷
      11: dup
      12: aconst_null
      13: invokespecial #49  // Method com/wgjd/MainKt$main$1."<init>":
                             // (Lkotlin/coroutines/Continuation;)V
      16: checkcast     #51  // class kotlin/jvm/functions/Function2
      19: iconst_3
      20: aconst_null
      21: invokestatic  #57  // Method kotlinx/coroutines/
                             // BuildersKt.launch$                         ❸
                             // default:(Lkotlinx/coroutines/CoroutineScope;
                             // Lkotlin/coroutines/CoroutineContext;
                             // Lkotlinx/coroutines/CoroutineStart;
                             // Lkotlin/jvm/functions/Function2;
                             // ILjava/lang/Object;)Lkotlinx/coroutines/Job;
      24: pop
      25: ldc2_w        #58  // long 2000l
      28: invokestatic  #65  // Method java/lang/Thread.sleep:(J)V
      31: return

❶ 获取全局作用域实例以启动我们的协程

❷ 创建并初始化我们生成的协程类的新实例

❸ 调用 launch 方法,向其提供作用域和我们的协程实例。特别是,我们的协程实例作为 Function2 参数传递。

launch 方法中的代码将开始调用我们生成的协程实例上的方法,运行我们的状态机。让我们看看实现该状态机的字节码。首先,协程实例有两个单独的字段。这些跟踪协程作用域和我们在状态机中的当前位置:

final class com.wgjd.MainKt$main$1
    extends kotlin.coroutines.jvm.internal.SuspendLambda
    implements kotlin.jvm.functions.Function2<
        kotlinx.coroutines.CoroutineScope,
        kotlin.coroutines.Continuation<? super kotlin.Unit>,
        java.lang.Object> {

  java.lang.Object L$0;    ❶

  int label;               ❷

❶ 当前执行的协程作用域

❷ 表示我们状态机当前步骤的整数值

当我们的状态机运行时,其核心是一个基于这些字段决定我们下一步的方法。Kotlin 为此目的生成一个名为 invokeSuspend 的方法。invokeSuspend 最终成为我们代码和跟踪我们进度的状态机的混合体。在我们的协程生命周期内,每当协程准备好执行下一个步骤时,Kotlin 都会重复调用 invokeSuspend

下一个代码片段展示了 invokeSuspend 的开始,以及从状态机中的第一个步骤(从协程的启动到我们调用 delay):

public final java.lang.Object invokeSuspend(java.lang.Object);
    Code:
       0: invokestatic  #36  // Method kotlin/coroutines/intrinsics/
                             // IntrinsicsKt.getCOROUTINE_SUSPENDED:
                             // ()Ljava/lang/Object;
       3: astore_3
       4: aload_0
       5: getfield      #40  // Field label:I                              ❶
       8: tableswitch   {    // 0 to 2
                     0: 36
                     1: 69
                     2: 104
               default: 122
          }
      36: aload_1                                                          ❷
      37: invokestatic  #46  // Method kotlin/ResultKt.throwOnFailure:
                             // (Ljava/lang/Object;)V
      40: aload_0
      41: getfield      #48  // Field p$:Lkotlinx/coroutines/CoroutineScope;
      44: astore_2
      45: ldc2_w        #49  // long 1000l
      48: aload_0
      49: aload_0
      50: aload_2
      51: putfield      #52  // Field L$0:Ljava/lang/Object;
      54: aload_0
      55: iconst_1
      56: putfield      #40  // Field label:I
      59: invokestatic  #58  // Method kotlinx/coroutines/DelayKt.delay:
                             // (JLkotlin/coroutines/Continuation;)
                             // Ljava/lang/Object;
      62: dup
      63: aload_3
      64: if_acmpne     82
      67: aload_3
      68: areturn
      69: aload_0                                                          ❸
      70: getfield      #52  // Field L$0:Ljava/lang/Object;
      73: checkcast     #60  // class kotlinx/coroutines/CoroutineScope

      // Further steps excluded for length.
      // See resources for full listing

❶ 确定状态机中的下一步

❷ 首个步骤的开始(直到调用延迟)

❸ 第二个步骤的开始(直到调用 suspend),这将在再次调用 invokeSuspend 时发生

在收集当前协程的信息后,字节 5 从 label 字段加载我们即将执行的步骤。然后在字节 8,它使用我们之前未见过的操作码 tableswitch。这个操作码查看栈上的值并根据定义的值跳转。因为这是我们第一次通过 invokeSuspend,我们的 label 的值为 0,然后我们继续到字节 36。从这里开始线性执行。在字节 55 和 56,我们更新我们的状态 label 为 1 并前进到下一步。我们在字节 59 调用 delay,然后在字节 68 从 invokeSuspend 返回。

在这个点上,Kotlin 的代码获得了控制权,并决定何时运行协程的下一步。当它确定时机合适时,它将在相同的协程实例上调用 invokeSuspend。我们的状态 label 将被设置为 1,然后我们跳到执行第二个步骤的代码,在 delay 之后但在我们的 sus 调用之前。

虽然在日常使用协程时真正深入到底层字节码级别并非必要,但理解其机制是有价值的。对于那些基础扎实的开发者来说,当某个特性看起来过于神奇时,也不应该感到满足。毕竟,所有这些都是一次执行一条指令的代码,而我们拥有理解它的工具。

这次检查也可能回答第 15.3.7 节中的一些问题,在那里我们看到了在定义 Kotlin 序列时使用 yield 函数。在那个点上,我们稍微挥了挥手,关于我们的 lambda 定义序列的函数如何“暂停”其执行。实际上,这是使用基于 suspend 函数生成的状态机机制。每次对 invokeSuspend 的连续调用都会获取序列中的下一个项目。

16.3.2 协程作用域和调度

尽管协程为标准操作系统线程模型提供了不同的抽象,但在表面之下,我们的代码仍然在某个线程中执行。Kotlin 协程如何管理和协调这项工作由协程作用域和 调度器 管理。

让我们修改我们的示例,看看协程的每个步骤实际上在哪里执行:

package com.wgjd

import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

fun main() {
  GlobalScope.launch {
    println("On thread ${Thread.currentThread().name}")
    delay(500)

    println("On thread ${Thread.currentThread().name}")
    delay(500)

    println("On thread ${Thread.currentThread().name}")
  }

  Thread.sleep(2000)
}

结果并不是确定性的,但看起来可能像这样:

On thread DefaultDispatcher-worker-1
On thread DefaultDispatcher-worker-2
On thread DefaultDispatcher-worker-1

我们线程的名称提供了两个有趣的信息:一个调度器(DefaultDispatcher)的名称和一个数字,表示我们正在使用可用池中的哪个线程。当我们请求特定的作用域时——在我们的例子中,是整个应用程序生命周期的 GlobalScope——我们选择的部分包括调度器,它决定了我们的工作是如何实际调度的。

假设我们想要对工作调度有更大的控制权。我们不是使用 GlobalScope,而是可以创建自己的、独立的 CoroutineScope 实例,如下所示。通常,一个作用域与我们的系统中不同的对象相关联,具有其特定的生命周期。我们的自定义作用域在构造时需要一个上下文,标准函数工厂方法主要根据它们如何配置调度来识别:

  val context: CoroutineContext = newFixedThreadPoolContext(3, "New-Pool")

  CoroutineScope(context).launch {
    println("On thread ${Thread.currentThread().name}")
    delay(500)

    println("On thread ${Thread.currentThread().name}")
    delay(500)

    println("On thread ${Thread.currentThread().name}")
  }

并非特别令人惊讶的输出显示我们正在执行与之前完全不同的线程集:

On thread New-Pool-1
On thread New-Pool-2
On thread New-Pool-1

注意:值得注意,因为 newFixedThreadPoolContext 和其他相关函数已被标记为过时,但截至本文撰写时,它们的替代品尚不可用。请查看 Kotlin 协程文档 kotlin.github.io/kotlinx.coroutines 以获取最新的实践方法。

上下文类不仅封装了协程的调度器,还可以提供额外的信息。我们可以提供的信息示例包括为我们的协程命名(这改善了 IDE 中的调试体验,尤其是在许多协程共享调度器时)和通用错误处理器。元素可以通过plus函数添加到现有的上下文对象中,如下所示:

  val context: CoroutineContext = newFixedThreadPoolContext(3, "New-Pool")
    .plus(CoroutineExceptionHandler { _, thrown ->
        println(thrown.message + "!!!!") })
    .plus(CoroutineName("Our Coroutine"))

  CoroutineScope(context).launch {
    throw RuntimeException("Failed")
  }

除了在异常抛出时自动取消协程之外,我们的CoroutineExceptionHandler将被执行并打印“Failed!!!!”。您可能希望考虑为您的生产应用程序制定更彻底的错误处理策略,但协程提供了必要的钩子。

想要并行执行各种步骤,然后再继续之前需要那些阶段的结果,这种情况并不少见。在协程中,这可以通过async函数来实现,如下所示,它返回一个Deferred<T>——实际上是一个允许等待和检索值的协程Job

GlobalScope.launch {
    val result: Deferred<Int> = async {
      10;
    }

    println("Got ${result.await()}")    ❶
  }

❶ 毫不意外,打印出“Got 10”

在 9.5 节中,我们看到了默认情况下,协程会在错误发生时取消整个协程层次结构,如下所示:

  val failed = GlobalScope.launch {
    launch { throw RuntimeException("Failing...") }
  }

  Thread.sleep(2000)                               ❶

  println("Cancelled ${failed.isCancelled}")       ❷

❶ 给予时间完成执行

❷ 打印取消为 true

当你需要它时,这种行为非常强大。然而,这并不总是可取的。如果某些子协程可以安全失败,我们可以用supervisorScope将它们包装起来,如下所示。这就像一个典型的协程包装器,但不会向上传递取消操作:

  val supervised = GlobalScope.launch {
    supervisorScope {
      launch { throw RuntimeException("Failing...") }
    }
  }

  Thread.sleep(2000)                               ❶

  println("Cancelled ${supervised.isCancelled}")   ❷

❶ 再次,需要时间来完成

❷ 打印取消为 false,表示我们的主管允许子协程失败而不取消父协程

协程为我们提供了许多处理并发执行的方式。尽管它们不是思考世界的唯一替代方式,但它们提供了很多选项。让我们看看 Clojure 能带来什么。

16.4 并发 Clojure

Java 的状态模型从根本上基于可变对象的概念。正如我们在第五章中看到的,这直接导致并发代码中的安全问题。我们需要引入相当复杂的锁定策略来防止其他线程在给定线程正在修改对象状态时看到中间状态(即不一致的对象状态)。这些策略很难想出来,很难调试,更难测试。

Clojure 有不同的看法,其并发抽象在某些方面并不像 Java 那样低级。例如,使用由 Clojure 运行时(开发者几乎无法控制)管理的线程池可能看起来很奇怪。但获得的力量来自于允许平台(在这种情况下,是 Clojure 运行时)为你做账本记录,让你有更多精力去处理更重要的任务,比如整体设计。

总体而言,Clojure 的哲学是默认将线程彼此隔离,这有助于使语言默认情况下并发类型安全。通过假设基线为“不需要共享任何东西”并使用不可变值,Clojure 避免了许多 Java 的问题,并且可以专注于安全共享状态以进行并发编程的方法。

注意:为了促进安全性,Clojure 的运行时提供了线程间协调的机制,强烈建议您使用这些机制,而不是尝试使用 Java 习惯用法或创建自己的并发结构。

让我们看看这些构建块中的第一个:持久化数据结构。

16.4.1 持久化数据结构

一种 持久化数据结构 是在修改时保留先前版本的。因此,它们是线程安全的,因为对它们的操作不会改变现有读者所看到的结构,而是始终产生一个新的更新对象。

所有 Clojure 集合都是持久的,并允许通过使用 结构共享 高效地创建修改后的副本。集合本质上是线程安全的,并且设计得非常高效。

需要注意的是,Clojure 的持久化集合不允许原地修改或删除元素。如果您的程序调用 Java 集合接口(如 ListMap)的这些方法,它们将抛出 UnsupportedOperationException。相反,预期持久化集合将通过 (cons)(conj) 等操作构建,遵循 Lisp 传统。

所有集合都支持以下基本方法:

  • count 获取集合的大小。

  • consconj 向集合中添加元素。

  • seq 获取一个可以遍历集合的序列。

因此,所有序列函数都可以通过 seq 的支持与任何集合一起使用。让我们看一个例子——Clojure 的 PersistentVector——从一些 Java 代码开始,展示如何通过重复使用 (cons) 添加新元素来构建向量:

var aList = new ArrayList<PersistentVector>();
var vector = PersistentVector.EMPTY;
for (int i=0; i < 32; i = i + 1) {
    vector = vector.cons(i);
    aList.add(vector);
}
System.out.println(aList);

这将输出类似以下内容

[[0], [0 1], [0 1 2], [0 1 2 3],

...

[0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
 28 29 30 31]]

这表明,如果您需要,可以保留持久向量的每个早期版本。这也意味着可以在线程之间传递向量,并且每个线程都可以修改它,而不会影响其他线程。

让我们快速看一下这种数据结构的实现方式。回想一下,其他语言中基于数组的结构(如向量)通常是通过一个单一的连续内存块实现的。这种实现使得索引操作,如查找,非常快,但对于像创建向量的修改副本(同时保留原始副本)这样的操作,我们必须复制整个支持数组。

Clojure 的PersistentVector非常不同。相反,Clojure 将向量的元素存储在 32 个元素的块中。一般思路是,如果添加了一个元素,只需要复制当前的 32 个元素的尾部。如果一个向量添加了超过 32 个元素,就会创建一个称为节点的结构,它包含一个 32 个元素的数组,这些元素本身是包含完整 32 个元素数组的节点的引用。

注意PersistentVector是 Clojure 的核心抽象之一,它被广泛使用,实际上是在 Java(而不是 Clojure)中实现的,因为它需要启动 Clojure 语言运行时。

类定义看起来像这样(为了清晰起见略有简化):

public class PersistentVector extends APersistentVector
                              implements IObj, ... {

    // ...

    public final PersistentVector.Node root;
    public final Object[] tail;
    private final int cnt;

    // ...

    public static final PersistentVector EMPTY;
    public static final PersistentVector.Node EMPTY_NODE;

   // ...
}

内部类PersistentVector.Node定义如下:

public static class Node implements Serializable {
        public final transient AtomicReference<Thread> edit;
        public final Object[] array;

        public Node(AtomicReference<Thread> edit, Object[] array) {
            this.edit = edit;
            this.array = array;
        }

        Node(AtomicReference<Thread> edit) {
            this.edit = edit;
            this.array = new Object[32];
        }
    }

注意,array字段只是一个Object[]。这是 Clojure 动态类型特性的一部分——这里没有泛型。

此外,Clojure 在其核心中频繁使用公共最终字段,并不总是定义访问器方法。因此,我们可以查看数据结构内部,看看节点是如何工作的。例如:

var vector = PersistentVector.EMPTY;
for (int i = 0; i < 32; i = i + 1) {
    vector = vector.cons(i);
}
System.out.println(Arrays.toString(vector.tail));
System.out.println(Arrays.toString(vector.root.array));
System.out.println("----------------");
for (int i=32; i < 64; i = i + 1) {
    vector = vector.cons(i);
}
System.out.println(Arrays.toString(vector.tail));
System.out.println(Arrays.toString(vector.root.array));
var earlier = (PersistentVector.Node)(vector.root.array[0]);
System.out.println("Earlier: "+ Arrays.toString(earlier.array));

产生如下输出:

[0, 1, 2, 3, ... 31]
[null, null, null, null, ... null]
----------------
[32, 33, 34, 35,  ...  63]
[clojure.lang.PersistentVector$Node@783e6358, null, null, null, ... null]
Full Tail: [0, 1, 2, 3, ... 31]

添加了 64 个元素之后,tail将是[32, ... 63],而root.array包含一个单一的PersistentVector.Node,其数组字段包含元素[0, ... 31]。因此,在图示形式上,对于 0 到 32 个元素,向量看起来像图 16.5。

图 16.5 一个包含 32 个元素的持久向量

对于超过 32 个元素但少于 64 个元素的数目,结构将类似于图 16.6。

图 16.6 一个包含 64 个元素的持久向量

对于 64 到 96 个元素,其结构将类似于图 16.7,依此类推。

图 16.7 一个包含 96 个元素的持久向量

你可能会合情合理地问,当所有节点的数组槽位都满了会发生什么。这发生在向量包含 32 + (32 * 32)——或 1,056——个元素时。读者可能期望这个数字是 1,024,但我们还有尾部,它包含 32 个元素——实际上是一个“偏移一个”的效果。一个包含一个Node层级的完整PersistentVector可以在图 16.8 中看到。

图 16.8 一个包含 1,056 个元素的持久向量

如果我们继续添加元素,树结构将再增加一个层级,如图 16.9 所示。

图 16.9 一个包含许多元素的持久向量

要在代码中看到这一点,我们可以运行一些这样的代码:

var vector = PersistentVector.EMPTY;
for (int i = 0; i < 1088; i = i + 1) {
    vector = vector.cons(i);
}
System.out.println(Arrays.toString(vector.tail));
System.out.println(Arrays.toString(vector.root.array));
System.out.println();

var v0 = (PersistentVector.Node) (vector.root.array[0]);
var v1 = (PersistentVector.Node) (vector.root.array[1]);
System.out.println("r.a[0] : " + Arrays.toString(v0.array));
System.out.println("r.a[1] : " + Arrays.toString(v1.array));
System.out.println();

var v0A0 = (PersistentVector.Node)(((PersistentVector.Node)v0).array[0]);
var v0A31 = (PersistentVector.Node)(((PersistentVector.Node)v0).array[31]);
var v1A0 = (PersistentVector.Node)(((PersistentVector.Node)v1).array[0]);
System.out.println("r.a[0].a[0] : " + Arrays.toString(v0A0.array));
System.out.println("r.a[0].a[31] : " + Arrays.toString(v0A31.array));
System.out.println("r.a[1].array[0] : " + Arrays.toString(v1A0.array));

这会产生如下输出:

[1056, 1057, 1058, 1059, ... 1087]
[clojure.lang.PersistentVector$Node@2344fc66,
clojure.lang.PersistentVector$Node@458ad742, null, null, ... null]

r.a[0] : [clojure.lang.PersistentVector$Node@735f7ae5,
          clojure.lang.PersistentVector$Node@180bc464, ... ,
          clojure.lang.PersistentVector$Node@617c74e5]
r.a[1] : [clojure.lang.PersistentVector$Node@6ea12c19, null, ... , null]

r.a[0].a[0] : [0, 1, ... , 31]
r.a[0].a[31] : [992, 993, ... , 1023]
r.a[1].a[0] : [1024, 1025, ... , 1055]

注意在这个例子中,v0v1的数组字段不再包含Integer元素,而是现在包含Node对象。这就是为什么array字段被类型化为Object[],以允许这种动态类型。

注意:如果我们继续添加元素,我们将构建一个多层结构,这允许 PersistentVector 处理任何大小的向量(尽管对于更大的向量,间接成本会不断增加)。

然而,数据结构并不是 Clojure 需要成为成功并发语言所需要的一切。例如,并发模型和执行的概念绝对至关重要。幸运的是,Clojure 已经为您提供了这些!

实际上,Clojure 使用多种方法来提供不同类型的并发模型:futures 和 pcalls、refs 和 agents。让我们逐一查看,从最简单的一个开始。

16.4.2 Futures 和 pcalls

我们首先应该明确,您可以通过利用 Clojure 对 Java 的紧密绑定来启动新线程。您在 Java 中能做的事情,在 Clojure 中也能做,并且您可以在 Clojure 中非常容易地编写并发 Java 代码。

然而,Java 的一些抽象在 Clojure 中有一种清理后的形式。例如,Clojure 提供了一个非常干净的方法来处理我们在第六章中在 Java 中遇到的 Future 概念。下面的列表显示了一个简单的示例。

列表 16.2 Clojure 中的 Future

user=> (def simple-future
  (future (do
        (println "Line 0")
        (Thread/sleep 10000)
        (println "Line 1")
        (Thread/sleep 10000)
        (println "Line 2"))))

#'user/simple-future
Line 0                                ❶
user=> (future-done? simple-future)
user=> false
Line 1
user=> @simple-future                 ❷
Line 2
nil
user=>

❶ 立即开始执行

❷ 解引用时的阻塞

在这个列表中,您使用 (future) 设置一个 Future。一旦创建,它就会在后台线程上开始运行,这就是为什么您在 Clojure REPL 上看到第 0 行(以及后来的第 1 行)的打印输出——代码已经开始在另一个线程上运行。

您可以使用 (future-done?) 来测试代码是否已完成,这是一个非阻塞调用(类似于 Java 中的 isDone())。然而,尝试解引用 future 会导致调用线程阻塞,直到函数完成。

这实际上是一个简单的 Clojure 包装器,覆盖了 Java Future,并有一些稍微干净的语法。Clojure 还提供了对并发程序员非常有用的辅助形式。一个简单的函数是 (pcalls),它接受一个零参数函数的变量数量,并并行执行它们。

注意 (pcalls) 与 Java 的 ExecutorService.invokeAll() 辅助方法有些类似。

调用在运行时管理的线程池上执行,并将返回一个惰性序列的结果。尝试访问尚未完成的序列中的任何元素将导致访问线程阻塞。

列表 16.3 设置一个名为 (wait-with-for) 的一参数函数。这使用一个类似于第 10.2.5 节中引入的循环形式。从这个循环中,您创建了一系列零参数函数——(wait-1)(wait-2) 等等,您可以将它们馈送到 (pcalls)

列表 16.3 Clojure 中的并行调用

user=> (defn wait-with-for [limit]
  (let [counter 1]
    (loop [ctr counter]
      (Thread/sleep 500)
      (println (str "Ctr=" ctr))
    (if (< ctr limit)
      (recur (inc ctr))
    ctr))))
#'user/wait-with-for

user=> (defn wait-1 [] (wait-with-for 1))
#'user/wait-1

user=> (defn wait-2 [] (wait-with-for 2))
#'user/wait-2

user=> (defn wait-3 [] (wait-with-for 3))
#'user/wait-3

user=> (def wait-seq (pcalls wait-1 wait-2 wait-3))
#'user/wait-seq
Ctr=1
Ctr=1
Ctr=1
Ctr=2
Ctr=2
Ctr=3

user=> (first wait-seq)
1

user=> (first (next wait-seq))
2

使用仅 500 毫秒的线程睡眠值,等待函数会非常快地完成。通过调整超时(例如,将其扩展到 10 秒),很容易验证由 (pcalls) 返回的名为 wait-seq 的惰性序列具有描述的阻塞行为。

这种对简单多线程结构的访问对于不需要共享状态的情况是好的,但在许多应用中,不同的处理线程需要在飞行中通信。Clojure 有几个处理这种情况的模型,所以让我们看看下一个:由(ref)形式启用的共享状态,该状态在事务中处理。

16.4.3 软件事务内存

第一种,也是最明显的方法是不共享状态。事实上,我们到目前为止一直在使用的 Clojure 构造,即var,实际上并不能被共享。如果两个不同的线程继承了同一个var的名称并在线程内重新绑定它,那么这些重新绑定只对那些个别线程可见,并且永远不会被其他线程共享。

这是由设计决定的,Clojure 提供了一个在线程之间共享状态的替代方法:ref。这个概念依赖于运行时提供的模型,该模型用于需要被多个线程看到的状态更改。该模型有效地在符号和值之间引入了一个额外的间接层——也就是说,一个符号绑定到一个值的引用,而不是直接绑定到值。

系统本质上具有事务性,对底层值的更改由 Clojure 运行时进行协调。这如图 16.10 所示。

图 16.10 软件事务内存

图 16.10 软件事务内存

这种间接性意味着在 ref 可以被更改或更新之前,它必须被放置在一个事务中。当事务完成时,所有的更新要么全部生效,要么全部不生效,这显然与数据库中的事务有明显的类比。

这可能看起来有点抽象,所以让我们回到一个更早的例子,我们在第五章和第六章讨论过的Account类。回想一下,为了避免像丢失更新这样的并发问题,在 Java 中,你必须使用锁来保护每一点敏感数据,如下所示:

    // ...

    private final Lock lock = new ReentrantLock();
    private int balance;

    public boolean withdraw(final int amount) {
        // Elided code - check to see amount > 0, throw if not

        lock.lock();
        try {
            if (balance >= amount) {
                balance = balance - amount;
                return true;
            }
        } finally {
            lock.unlock();
        }
        return false;
    }

    // ...

让我们看看你如何在 Clojure 中尝试编写类似的东西。然而,正是在这里,我们遇到了一个概念上的问题。

在 Java 中,默认使用可变状态,这在之前的代码中就是这种情况。withdraw()方法接受一个参数,amount,以下三种情况中的一种会发生:

  • amount 小于或等于零 —会抛出一个IllegalArgumentException异常,因为这不是一个有效的提款操作。

  • 提款成功—余额已更新,并返回true

  • 提款失败(可用余额不足)—余额更新,并返回false

不考虑无效的情况,这里有两个独立的过程正在发生:更新可变状态和通过返回码信号操作是否成功。

在函数式编程中,我们通常会返回一个包含更新状态的新值,而不是更新可变状态。然而,如果提款失败,代码的使用者将如何知道余额是否已更新?我们可以想象将返回代码更改为一对返回代码和可能的一个更新值,但这有些笨拙。

相反,让我们从一个稍微不同的单线程版本开始,这个版本将在下一列表中展示。这里的语义是,(debit)形式在映射(表示账户)上操作,如果提款成功则返回一个新的映射,如果失败则抛出异常。

列表 16.4 Clojure 中的简单账户模型

(defn make-new-acc [account-name opening-balance]
  {:name account-name :bal opening-balance})

(defn debit [account amount]
  (let [balance (:bal account) my-name (:name account)]
    (if (<= amount 0)
      (throw (AssertionError. "Withdrawal amount cannot be < 0")))
    (if (> balance amount)
      (make-new-acc my-name (- balance amount))
      (throw (AssertionError. "Withdrawal amount cannot exceed balance"))
    )))

(debit (make-new-acc "Ben" 5000) 1000)

注意与 Java 版本相比,这段代码是多么紧凑。诚然,这仍然是单线程的,但与 Java 所需的代码相比要少得多。运行代码将给出预期的结果:你最终会得到一个余额为 4000 的映射。

尽管相对简单,但这并不完全令人满意——我们实际上是用不同的语义解决了一个不同的问题。让我们看看是否可以通过推广到并发版本来解决一些问题。

要使这段代码并发,我们需要引入 Clojure 的 refs。这些是通过(ref)形式创建的,是类型为clojure.lang.Ref的 JVM 对象。通常它们与 Clojure 映射一起设置,以保存状态。

我们还需要(dosync)形式,它设置了一个事务。在这个事务中,我们还将使用(alter)形式,它可以用来修改 refs 的内容。让我们看看如何使用 refs 来实现对账户构造的多线程方法,如下所示。

列表 16.5 多线程账户处理

user=> (defn safe-debit [ref-account amount]
  (dosync
    (alter ref-account debit amount)
    ref-account))
#'user/safe-debit

user=> (def my-acc (make-new-acc "Ben" 5000))
#'user/my-acc

user=> (def r-my-acc (ref my-acc))
#'user/r-my-acc

user=> (safe-debit r-my-acc 1000)
#object[clojure.lang.Ref 0x6b1e7ad3 {:status :ready,
                                     :val {:name "Ben", :bal 4000}}]

如前所述,(alter)形式通过应用一个带有参数的函数来作用于一个 ref。所作用的价值是在事务期间对该线程可见的局部值。这被称为事务中的值。返回的值是在 alter 函数返回后 refs 的新值。这个值在退出由(dosync)定义的事务块之前对更改线程不可见。

其他交易可能同时进行。如果这样,Clojure STM 系统将跟踪这些交易,并且只有在它与自开始以来已提交的其他交易一致的情况下,才会允许事务提交。如果它不一致,它将被回滚,并且可能需要使用更新后的世界视图重新尝试。

如果事务执行任何产生副作用(如日志文件或其他输出)的操作,这种重试行为可能会导致问题。你需要尽可能使事务部分简单且在函数式编程意义上纯净(意味着无副作用)。

对于某些多线程方法,这种乐观的事务行为可能看起来是一种相当重量级的做法。一些并发应用程序只需要偶尔以相当不对称的方式在线程之间进行通信。幸运的是,Clojure 提供了另一种并发机制,它更加简单直接,这是我们下一节的主题。

16.4.4 代理

代理是 Clojure 的另一个并发原语。与使用共享状态不同,Clojure 代理是一个异步、面向消息的执行对象。它们与其他语言中的 actor 概念(如 Scala 和 Erlang)类似。

代理是一个可以接收来自另一个线程(或同一个线程)发送给它的消息(以函数的形式)的执行上下文。新的代理通过 (agent) 函数声明,可以使用 (send) 向它们发送消息。

“它们必须通过载体传递,”她心想;“给自己的脚送礼物看起来多么有趣!而且指示看起来多么奇怪!”

——刘易斯·卡罗尔

代理本身不是线程,而是比线程“更小”的可执行对象。它们被安排在由 Clojure 运行时管理的线程池中(线程池通常不能直接由程序员访问)。

注意:与 Java 线程池中的任务对象不同,Clojure 代理可能是长期存在的,因为任务对象通常具有有限的生命周期。

运行时还确保从外部看到的代理的值是隔离和原子的。这意味着用户代码将只看到代理在其之前或之后的状态。

注意:我们将在第十八章中遇到另一个小于线程的可执行对象示例。

下面的列表展示了代理的一个简单示例,类似于我们用来讨论未来的示例。

列表 16.6 Clojure 中的代理

user=> (defn wait-and-log [coll str-to-add]
  (do (Thread/sleep 10000)
    (let [my-coll (conj coll str-to-add)]
      (Thread/sleep 10000)
      (conj my-coll str-to-add))))
#'user/wait-and-log

user=> (def str-coll (agent []))
#'user/str-coll

user=> (send str-coll wait-and-log "foo")
#object[clojure.lang.Agent 0x38499e48 {:status :ready, :val []}]

user=> @str-coll
[]

// Wait to allow message to be handled

user=> @str-coll
["foo" "foo"]

(send) 调用会将 (wait-and-log) 调用分发给代理,通过使用 REPL 来取消引用它,你可以看到,正如承诺的那样,你永远看不到代理的中间状态——只有最终状态出现(其中 "foo" 字符串被添加了两次)。

在代理方法中,我们向一个在 Clojure 管理的线程池中的线程上安排的代理发送消息,当两个线程已经共享地址空间时,这似乎有些奇怪。但你在并发中已经多次遇到的一个主题是,如果它能够使使用更简单、更清晰,那么额外的抽象可能是一件好事。

在 Clojure 将许多线程和并发控制方面的低级方面委托给运行时的情况下,这种协同效应在 Clojure 中表现得尤为明显。这使程序员能够专注于良好的多线程设计和更高级别的关注点。这与 Java 的垃圾收集设施允许你从内存管理的细节中解脱出来的方式类似。

摘要

  • 每种语言都以自己的方式扩展了执行的核心概念。

  • Java 通过 Fork/Join 库引入了可分解的任务和工作窃取;Kotlin 使用高级编译器技巧来生成协程版本;而 Clojure 通过代理概念构建了一种形式的 actor 模型。

  • 在我们的三种语言中,对状态的处理不同,这是并发编程的关键

  • 在 Java 中,可变性是默认的,通过CompletableFuture等一些增强功能。

  • Kotlin 更加重视不可变性,但仍然从根本上借鉴了共享、可变状态的世界观。

  • Clojure 通过软件事务内存中心化不可变性,但代价是编程模型不太熟悉,与 Java 集合的集成也不太紧密。

17 现代内部结构

本章涵盖

  • 介绍 JVM 内部结构

  • 反射内部结构

  • 方法处理

  • Invokedynamic

  • 近期内部更改

  • 不安全

Java 的虚拟机(JVM)是一个极其复杂的运行环境,几十年来一直优先考虑稳定性和生产级工程。因此,许多 Java 开发者从未需要深入了解内部结构,因为这通常并不必要。

另一方面,本章是为好奇的人准备的——那些想要了解更多、想要揭开面纱看看 JVM 实现细节的人。让我们从方法调用开始。

17.1 介绍 JVM 内部结构:方法调用

为了开始,让我们看看由PetCatBear类以及Furry接口定义的一个简单示例。这可以在图 17.1 中看到。

图 17.1 简单继承层次结构

我们还可以假设存在其他Pet的子类(例如DogFish),这些子类在图中没有显示,以保持清晰。我们将使用这个例子来详细解释不同的调用操作码是如何工作的,从invokevirtual开始。

17.1.1 调用虚拟方法

最常见的方法调用类型是通过使用invokevirtual字节码在特定类(或其子类)的对象上调用实例方法。这被称为分派(即调用)虚拟方法(或简称虚拟分派),这意味着要调用的确切方法是在运行时而不是编译时通过查看运行时的实际对象类型来确定的。当 JVM 执行这段代码时:

Pet p = getPet();
p.feed();

实际调用feed()的实现是在方法需要执行时确定的。

实现可能因p持有CatDog(或假设超类不是抽象的Pet)而不同。还有可能getPet()在程序执行的不同时间返回Pet的不同子类型对象。这并不重要——每次方法需要执行时都会查找要调用的实现。尽管这是一段有点像墙的文字,但这正是自从你第一次学习这门语言以来 Java 方法一直以来的工作方式。

在内部,为了使这成为可能,JVM 存储了一个表(每个类一个),该表包含对应类型的方法定义,称为vtable(这是 C++程序员所说的虚函数表)。这个表存储在 JVM 内部的一个特殊内存区域,称为元空间,其中包含 VM 需要的元数据。

注意:在 Java 7 及之前版本中,此元数据位于 Java 堆的一个区域,称为permgen

要了解 vtable 的使用方法,我们需要简要地看一下类的 JVM 元数据。在 Java 中,所有对象都生活在 Java 堆中,并且仅通过引用来处理。HotSpot 使用通用术语 oop(“普通对象指针”)来指代堆中存在的各种内部数据结构。

每个 Java 对象都必须有一个 对象头,它包含以下两种类型的元数据:

  • 特定于类实例的元数据(“标记词”)

  • 由类的所有实例共享的元数据(“klass 词”)

为了节省空间,每个类的元数据只存储一个副本,并且属于该类的每个对象都有一个指向它的指针——klass 词。在图 17.2 中,我们可以看到一个表示,它是一个存储在局部变量中的 Java 引用,指向堆中 Java 对象头的开始。

图 17.2 Java 对象头和布局

klass 是 JVM 在运行时对 Java 类的内部表示,存储在元空间中。它包含 JVM 在运行时与该类交互所需的所有信息(例如,方法定义和字段布局)。

从 klass 中的一些信息可以通过对应于类型的 Class<?> 对象提供给 Java 程序员,但 klass 和 Class 是两个不同的概念。特别是,klass 包含一些故意不使普通应用程序代码能够访问的信息。

注意:选择拼写“klass”是相当故意的,因为它在书面文档中将内部数据结构与其他使用“class”一词的情况区分开来,但在英语口语中却不是这样。您也可能看到使用“clazz”或“clz”的单词——这些通常命名一个包含 Class 对象的 Java 变量。

我们现在可以用 JVM 的内部结构来解释虚拟分派(由 invokevirtual 字节码实现),特别是 klass 和它的 vtable。当 JVM 遇到要执行的 invokevirtual 指令时,它会从当前方法的评估堆栈中弹出接收对象和任何方法参数。

注意:接收对象是实例方法被调用的对象。

JVM 对象头布局从标记词开始,紧接着是 klass 词。因此,为了定位要执行的方法,JVM 会跟随指针进入元空间,在那里它咨询 klass 的 vtable,以确定需要执行的确切代码。这个过程可以在图 17.3 中看到。

图 17.3 定位方法实现

如果 klass 没有对该方法的定义,JVM 会跟随一个指针到对应直接超类的 klass,并再次尝试。这个过程是 JVM 中方法重写的基础。

为了使其高效,vtable 以特定的方式布局。每个 klass 都布局其 vtable,使得首先出现的方法是父类型定义的方法。这些方法按照父类型使用的确切顺序布局。对于这个类型来说是新的、且不是由父类型声明的方法,放在 vtable 的末尾。

当子类重写一个方法时,它将在 vtable 中的位置与被重写实现的偏移量相同。这使得查找重写方法变得完全简单,因为它们在 vtable 中的偏移量将与它们的父类相同。在图 17.4 中,我们可以看到我们示例中一些类的 vtable 布局。

图片

图 17.4 Vtable 结构

因此,如果我们调用Cat::feed,JVM 将不会在Cat类中找到重写,而会跟随超类指针到Pet的 klass。这确实有一个feed()的实现,所以这将是被调用的代码。

注意:这种 vtable 结构——以及重写的有效实现——之所以工作得很好,是因为 Java 只实现了类的单继承。任何类型(除了没有超类的Object)只有一个直接超类。

17.1.2 调用接口方法

invokeinterface的情况下,情况要复杂一些。例如,请注意,groom()方法不一定在每个Furry实现的 vtable 中出现在相同的位置。Cat::groomBear::groom的不同偏移量是由它们不同的类继承层次结构造成的。结果是,当在编译时只知道接口类型的对象上调用方法时,需要额外的查找。

注意:尽管查找接口调用会做稍微多一点的工作,但你不应试图通过避免接口来微优化。记住,JVM 有一个即时编译器,它将基本上消除这两种情况之间的性能差异。

让我们看看一个例子。考虑以下代码片段:

Cat tom = new Cat();
Bear pooh = new Bear();
Furry f;

tom.groom();
pooh.groom();
f = tom;
f.groom();
f = pooh;
f.groom();

这产生了以下字节码:

0: new           #2                  // class ch15/Cat
       3: dup
       4: invokespecial #3                  // Method ch15/Cat."<init>":()V
       7: astore_1
       8: new           #4                  // class ch15/Bear
      11: dup
      12: invokespecial #5                  // Method ch15/Bear."<init>":()V
      15: astore_2
      16: aload_1
      17: invokevirtual #6                  // Method ch15/Cat.groom:()V
      20: aload_2
      21: invokevirtual #7                  // Method ch15/Bear.groom:()V
      24: aload_1
      25: astore_3
      26: aload_3
      27: invokeinterface #8,  1            // InterfaceMethod
                                            // ch15/Furry.groom:()V
      32: aload_2
      33: astore_3
      34: aload_3
      35: invokeinterface #8,  1            // InterfaceMethod
                                            // ch15/Furry.groom:()V

在 27 和 35 处的两个调用在 Java 代码中看起来相同,但实际上会调用不同的方法,因为f的运行时内容不同。27 处的调用实际上会调用Cat::groom,而 35 处的调用会调用Bear::groom

17.1.3 调用“特殊”方法

在了解了invokevirtualinvokeinterface的背景之后,invokespecial的行为现在就容易理解了。如果一个方法是通过invokespecial调用的,它不会经历虚拟查找。相反,JVM 将只在请求的方法的确切 vtable 位置进行查找。

invokespecial用于两种情况:调用超类方法和调用构造函数体(在字节码中转换为名为<init>的方法)。在这两种情况下,虚拟查找和重写的可能性都被明确排除。

我们应该提到两个进一步的边缘情况,它们可能看起来暗示了使用invokespecial(也称为精确分发)。第一个是私有方法——它们不能被重写,当类被编译时,要调用的确切方法已知,所以它们可能应该通过invokespecial来调用。然而,这种情况比它看起来更复杂。让我们通过一个例子来演示:

public class ExamplePrivate {

  public void entry() {
    callThePrivate();
  }

  private void callThePrivate() {
    System.out.println("Private method");
  }
}

让我们先使用 Java 8 来编译这个例子。使用javap反编译它给出以下结果:

$ javap -c ch15/ExamplePrivate.class
Compiled from "ExamplePrivate.java"
public class ch15.ExamplePrivate {
  public ch15.ExamplePrivate();
    Code:
       0: aload_0
       1: invokespecial #1   // Method java/lang/Object."<init>":()V
       4: return

  public void entry();
    Code:
       0: aload_0
       1: invokespecial #2   // Method callThePrivate:()V
       4: return
}

注意,javap是在没有-p开关的情况下被调用的,所以私有方法的反编译没有出现。到目前为止,一切顺利——私有方法确实是通过invokespecial调用的。然而,如果我们用 Java 11 重新编译并仔细观察,我们会看到不同的结果,如下所示:

$ javap -c ch15/ExamplePrivate.class
Compiled from "ExamplePrivate.java"
public class ch15.ExamplePrivate {
  public ch15.ExamplePrivate();
    Code:
       0: aload_0
       1: invokespecial #1 // Method java/lang/Object."<init>":()V
       4: return

  public void entry();
    Code:
       0: aload_0
       1: invokevirtual #2 // Method callThePrivate:()V    ❶
       4: return
}

❶ 这现在是invokevirtual

正如我们所见,在现代 Java 中,对私有方法的调用处理方式不同,我们将在第 17.5.3 节中解释,当我们遇到嵌套成员时。

17.1.4 最终方法

另一个特殊情况是final方法的使用。乍一看,调用final方法似乎也会被转换为invokespecial指令——毕竟,它们不能被重写,要调用的实现是在编译时已知的。然而,Java 语言规范对此有一些说法:

将声明为final的方法更改为不再声明为final不会破坏与现有二进制的兼容性。

假设一个类的代码调用了另一个类中的final方法,该方法已被编译成invokespecial。然后,如果包含final方法的类被修改为使方法不再为final(并重新编译),则可以在子类中重写它。

现在假设将子类的实例传递到第一个类中的调用方法。将执行invokespecial,现在将调用错误的方法实现。这是违反 Java 面向对象规则的(严格来说,它违反了 Liskov 替换原则)。因此,对final方法的调用必须编译成invokevirtual指令。

注意:在实践中,HotSpot 包含优化,可以非常高效地检测和执行final方法的情况。

我们通过虚拟方法调用的视角介绍了 HotSpot 内部机制的基础。在这个阶段,重新阅读第七章的 JIT 编译部分可能很有趣——特别是关于单态分发和内联的部分。现在你已经看到了它们实现的一些细节,你可能能够对这些技术有更深入的理解。

17.2 反射内部机制

我们在第四章中遇到了反射,作为在运行时动态处理对象和调用方法的一种方式。现在我们知道了 vtable,我们可以更深入地了解 JVM 是如何实现反射的。

回想一下,我们可以从一个类对象中获取一个java.lang.reflect.Method对象,然后调用它,如下所示(省略了异常处理):

Class<?> clazz = // ... some class
Method m = clazz.getMethod("toString");
Object ret = m.invoke(this);
System.out.println(ret);

但这个Method对象代表什么?它实际上是“在运行时动态调用特定方法的能力。”调用的动态性质意味着在编译代码中,我们只看到Method上的invokevirtual调用,如下所示:

0: ldc           #7   // ... some class
 2: astore_1
 3: aload_1
 4: ldc           #24  // String toString
 6: iconst_0                                                       ❶
 7: anewarray     #26  // class java/lang/Class                    ❶
10: invokevirtual #28  // Method java/lang/                        ❶
                       // Class.getMethod:                         ❶
                       // (Ljava/lang/String;[Ljava/lang/Class;)
                       // Ljava/lang/reflect/Method;
13: astore_2
14: aload_2
15: aload_0
16: iconst_0                                                       ❷
17: anewarray     #2   // class java/lang/Object                   ❷
20: invokevirtual #32  // Method java/lang/reflect/                ❷
                       // Method.invoke:                           ❷
                       // (Ljava/lang/Object;[Ljava/lang/Object;)
                       // Ljava/lang/Object;
23: astore_3

❶ 调用 getMethod()是可变参数的,并传递一个大小为 0 的 Class 对象数组。

❷ 调用 invoke()传递一个大小为 0 的 Object 对象数组(参数)。

注意:没有字节码通过方法描述符(例如java/lang/Object.toString:()Ljava/lang/String;)引用toString(),而是直接作为字符串toString

现在,让我们回顾一下,类对象(例如,String.class)只是普通的 Java 对象——它们具有普通 Java 对象的属性,并由 oops 表示。类对象为类上的每个方法包含一个Method对象,而这些方法对象再次只是普通的 Java 对象。

注意:Method对象是在类加载后延迟创建的。有时你可以在 IDE 的代码调试器中看到这种效果的痕迹。

那么 JVM 实际上是如何实现反射的呢?让我们看看Method类的部分源代码,并看看它的字段:

    private Class<?>            clazz;                           ❶
    private int                 slot;                            ❷
    // This is guaranteed to be interned by the VM in the 1.4
    // reflection implementation
    private String              name;
    private Class<?>            returnType;
    private Class<?>[]          parameterTypes;
    private Class<?>[]          exceptionTypes;
    private int                 modifiers;
    // Generics and annotations support
    private transient String    signature;
    // generic info repository; lazily initialized
    private transient MethodRepository genericInfo;
    private byte[]              annotations;
    private byte[]              parameterAnnotations;
    private byte[]              annotationDefault;
    private volatile MethodAccessor methodAccessor;              ❸

❶ 该方法所属的类

❷ 该方法在 vtable 中的偏移量

❸ 执行调用的代理

我们已经知道,在 Java 中,调用实例方法涉及到在 vtable 中查找它。所以,从概念上讲,我们想要利用 vtable 和由Class对象持有的Method对象数组之间的双重性。我们可以在图 17.5 中看到这种双重性,其中Method对象数组通过Entry.class帮助,与Entry的 klassOop 上的 vtable 是双重关系。

图 17.5 反射内部结构

让我们看看Method是如何利用这种双重性来实现反射的。关键在于MethodAccessor对象。

注意:以下部分代码进行了简化,并基于 Java 的早期版本,以帮助理解机制。Java 11 及以后的当前发行版生产代码更为复杂。

Method上的invoke()方法看起来有点像这样:

public Object invoke(Object obj, Object... args)
    throws IllegalAccessException, IllegalArgumentException,
           InvocationTargetException {

  if (!override) {                                                  ❶
    if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
      Class<?> caller = Reflection.getCallerClass();
      checkAccess(caller, clazz, obj, modifiers);
    }
  }
  MethodAccessor ma = methodAccessor;                               ❷
  if (ma == null) {
    ma = acquireMethodAccessor();
  }
  return ma.invoke(obj, args);                                      ❸
}

❶ 执行安全访问检查(如果未设置 setAccessible())

❷ 访问者的 volatile 读取

❸ 方法访问者代理

在第一次反射调用此方法时,acquireMethodAccessor()创建了一个DelegatingMethodAccessorImpl实例,该实例持有一个指向NativeMethodAccessorImpl的引用。这些类是在sun.reflect中定义的,它们都实现了MethodAccessor。请注意,它们不是java.base模块的 API 的一部分,不能直接调用。

下面是完整的DelegatingMethodAccessorImpl

class DelegatingMethodAccessorImpl extends MethodAccessorImpl {
  private MethodAccessorImpl delegate;

  DelegatingMethodAccessorImpl(MethodAccessorImpl delegate) {
    setDelegate(delegate);
  }

  public Object invoke(Object obj, Object[] args)
        throws IllegalArgumentException, InvocationTargetException {
    return delegate.invoke(obj, args);
  }

  void setDelegate(MethodAccessorImpl delegate) {
    this.delegate = delegate;
  }
}

下面是NativeMethodAccessorImpl

class NativeMethodAccessorImpl extends MethodAccessorImpl {
  private Method method;
  private DelegatingMethodAccessorImpl parent;
  private int numInvocations;

  // ...

  public Object invoke(Object obj, Object[] args)
          throws IllegalArgumentException, InvocationTargetException {

    if (++numInvocations >
          ReflectionFactory.inflationThreshold()) {       ❶
      MethodAccessorImpl acc = (MethodAccessorImpl)
          new MethodAccessorGenerator()
            .generateMethod(method.getDeclaringClass(),
                            method.getName(),
                            method.getParameterTypes(),
                            method.getReturnType(),
                            method.getExceptionTypes(),
                            method.getModifiers());       ❷
        parent.setDelegate(acc);                          ❸
    }

    return invoke0(method, obj, args);                    ❹
  }

  private static native Object invoke0(Method m, Object obj, Object[] args);

  // ...
}

❶ 在达到调用阈值后进入

❷ 使用MethodAccessorGenerator创建一个实现反射调用的自定义类

❸ 将当前对象作为代理替换为新自定义类的实例

❹ 如果尚未达到阈值,则继续进行本地调用

这种技术——使用可以与新动态生成的字节码访问器修补的委托访问器——可以在图 17.6 中看到。请注意,自定义访问器类是MethodAccessorImpl的子类,以便成功进行类型转换。

图 17.6 实现反射

关于性能的一个词:这个机制涉及权衡两种不同的可能来源的缓慢。一方面,本地访问器使用本地调用,这比 Java 方法调用慢,并且不能被 JIT 编译。另一方面,在MethodAccessorGenerator中动态生成字节码可能很慢,这可能是一个我们想要避免的坏交易,对于仅通过反射调用一次的方法。这种技巧,即延迟加载访问器对象然后动态修补调用点,我们将在本章后面以不同的形式再次遇到。

另一个值得注意的事实是,反射也破坏了内联和 JVM 可以很好地优化的标准方法调度类型。修补后的Delegating-MethodAccessorImpl方法的调用点被称为megamorphic(方法有许多可能的实现),因为每个Method实例都有一个不同的、动态生成的访问器对象。这意味着 JVM 的一些主要优化机制对于反射调用可能不会很好地工作。

因此,使用委托和修补本地访问器是一种折衷方案,旨在在可接受的性能和 JIT 的一些好处之间取得平衡。这种折衷方案,以及我们在第四章中讨论的反射的其他问题,导致了对动态调用和轻量级方法对象问题更好方法的探索。在下一节中,我们将介绍该研究的第一项成果:方法句柄 API。

17.3 方法句柄

方法句柄 API 是在 Java 7 版本中添加到 Java 中的。这个 API 的核心是java.lang.invoke包,特别是MethodHandle类。这种类型的实例代表调用方法的能力,并且可以直接执行,类似于java.lang.reflect.Method对象。

该 API 是作为将invokedynamic(我们在第 17.4 节中讨论)引入 JVM 的项目的一部分产生的。但方法句柄对象在框架和常规用户代码中的应用远远超出了invokedynamic用例。

我们将首先介绍方法句柄的基本技术;然后,我们将查看一个扩展示例,将其与一些替代方案进行比较,并总结差异。

17.3.1 方法句柄

什么是MethodHandle?官方答案是它是对可以直接执行的方法的带类型的引用。另一种说法是,MethodHandle是一个表示能够安全调用方法的对象。

注意:MethodHandle在很多方面与java.lang.reflect中的Method对象相似,但 API 通常更好,更简洁,并且纠正了几个重大的设计缺陷。

使用方法句柄有两个方面:获取它们和使用它们。第二个方面,使用它们,非常简单。让我们看看调用方法句柄的一个非常简单的例子。现在,我们假设我们有一些静态辅助方法getTwoArgMH(),它返回一个接收者对象obj和一个调用参数arg0的方法句柄,并返回String

之后,我们将解释如何获取与该签名匹配的方法句柄,但现在我们只是假设我们有一个辅助方法会为我们创建方法句柄。以下用法应该会让你想起反射调用:

MethodHandle mh = getTwoArgMH();                  ❶

try {
  String result = mh.invokeExact(obj, arg0);      ❷
} catch (Throwable e) {
  e.printStackTrace();
}

❶ 从辅助方法获取方法句柄

❷ 执行调用,传递接收者和一个参数

这看起来像是对方法的反射调用,就像我们在第 4.5.1 节中看到的那样——我们使用MethodHandle上的invokeExact()而不是Method上的invoke(),但除此之外,它应该看起来非常相似。然而,这只有在我们实际上首先有一个方法句柄对象的情况下才可能——那么,我们如何得到一个呢?

要获取一个方法句柄,我们需要通过一个查找上下文来查找它。获取上下文的通常方法是通过调用静态辅助方法MethodHandles.lookup()。这将返回一个基于当前执行方法的查找上下文。从查找中,我们可以通过调用find*()方法之一(如findVirtual()findConstructor())来获取方法句柄。

查找上下文对象可以提供对任何从创建查找的执行上下文中可见的方法的方法句柄。然而,除了方法查找上下文之外,我们还需要考虑如何表示我们想要句柄的方法的签名。

回想一下第六章中的Callable接口。它表示要执行的一段代码,与方法句柄类似。然而,Callable的一个问题是它只能模拟不带参数的方法。

如果我们要模拟所有类型的方法,我们就必须创建其他接口,类型参数的数量逐渐增加。我们最终会得到一组像这样的接口:

Function0<R>
Function1<R, P>
Function2<R, P1, P2>
Function3<R, P1, P2, P3>
...

这将非常快地导致接口的大量激增。这种方法被一些非 Java 语言(例如 Scala)采用,但 Java 中没有。

考虑 Clojure 是如何做的也很具有洞察力。IFn接口有invoke方法,代表所有不同类型的函数(包括用于接受超过 20 个参数的函数的可变参数形式)。我们在第十章遇到了IFn的简化版本。

然而,Clojure 是动态类型的,所以所有的invoke方法都接受每个参数的Object,并且也返回Object——这消除了处理泛型时的所有复杂性。Clojure 形式也可以非常自然地以可变的形式编写——如果形式以错误的 arity 调用,Clojure 将抛出运行时异常。Java 不使用这两种方法中的任何一种。

相反,Java 的方法处理实现了一种可以模拟任何方法签名的方案,无需生成大量的小类。这是通过新的MethodType类来实现的。

17.3.2 MethodType

MethodType是一个不可变对象,表示方法的类型签名。每个方法处理程序都有一个包含返回类型和参数类型的MethodType实例。但它不包含方法名称或接收器类型——实例方法被调用的类型。

获取新的MethodType实例的一个简单方法是在MethodType类中使用工厂方法。以下是一些示例:

var mtToString = MethodType.methodType(String.class);
var mtSetter = MethodType.methodType(void.class, Object.class);
var mtStringComparator = MethodType.methodType(int.class,
                                                String.class, String.class);

这些是表示toString()、一个 setter 方法(对于类型Object的成员)和由Comparator<String>定义的compareTo()方法的MethodType实例。通用实例遵循相同的模式,首先传递返回类型,然后是参数类型(所有作为Class对象),如下所示:

MethodType.methodType(RetType.class, Arg0Type.class, Arg1Type.class, ...);

正如你所见,不同的方法签名现在可以表示为正常的实例对象,无需为每个签名定义新的类型。这也提供了一种简单的方法来确保尽可能多的类型安全。如果你想了解一个候选方法处理是否可以用一组特定的参数调用,你可以检查属于该处理程序的MethodType

注意:传递单个MethodType对象比反射强制你使用的繁琐的Class[]要方便得多。

现在你已经看到了MethodType对象如何解决接口膨胀问题,让我们看看我们如何创建指向我们类型中方法的method handle

17.3.3 查找方法处理程序

让我们看看如何获取指向当前类toString()方法的method handle。注意,我们希望mtToStringtoString()的签名完全匹配——它有一个返回类型为String,没有参数。相应的MethodType实例应该是MethodType.methodType(String.class),如下所示:

public MethodHandle getToStringMH() {
  MethodHandle mh;
  var mt = MethodType.methodType(String.class);
  var lk = MethodHandles.lookup();                                  ❶

  try {
    mh = lk.findVirtual(getClass(), "toString", mt);                ❷
  } catch (NoSuchMethodException | IllegalAccessException mhx) {
    throw (AssertionError)new AssertionError().initCause(mhx);
  }

  return mh;
}

❶ 获取查找上下文

❷ 从上下文中查找处理程序

要从查找对象中获取方法处理程序,你需要提供包含你想要的方法的类,方法名称,以及表示适当签名的MethodType。方法类型对于处理重载方法是必要的。

使用查找上下文在当前类上查找方法是非常常见的,但实际上,你可以使用上下文来获取属于 任何 类型的方法句柄,包括 JDK 类型。当然,如果你从不同包或模块中的类获取句柄,查找上下文只能看到你有权访问的方法(例如,导出包中公共类的公共方法)。这是方法句柄 API 的重要方面:方法句柄的访问控制是在找到方法时检查的,而不是在执行句柄时检查。

一旦获得方法句柄,就可以安全地调用它,因为没有进一步的访问控制检查。方法句柄可以在允许访问的上下文中创建,然后传递到不允许访问的另一个上下文中,它仍然会执行。这与反射的一个重要区别。

注意:调用方法句柄的访问控制无法绕过,与反射调用不同。没有与第四章中遇到的反射 setAccessible() 漏洞等效的方法。

现在我们已经拥有了方法句柄,自然的事情就是用它来执行。API 提供了两种主要方式来完成这个任务:invokeExact()invoke() 方法。

invokeExact() 方法要求参数类型与底层方法期望的参数类型完全匹配。invoke() 方法执行一些转换,试图在类型不完全匹配的情况下(例如,装箱或拆箱,如所需)使类型匹配。

在介绍之后,我们将继续展示一个更长的示例,说明如何使用方法句柄来替换其他技术,例如反射和用于代理功能的小型内联类。

17.3.4 反射 vs. 代理 vs. 方法句柄

如果你花过时间处理包含大量反射的代码库,你很可能非常熟悉反射代码带来的痛苦。在本小节中,我们想向你展示如何使用方法句柄来替换大量的反射样板代码,使你的编码生活变得更加轻松。

为了展示方法句柄与其他技术的区别,我们提供了三种从类外部访问私有 callThePrivate() 方法的途径。有两种标准技术:反射和用作代理的内联类。我们可以将这些技术与基于 MethodHandle 的现代方法进行比较。三种替代方案的示例将在下一列表中展示。

列表 17.1 提供三种访问方式

public class ExamplePrivate {
    // Some state ...

    public void entry() {
        callThePrivate();
    }

    private void callThePrivate() {                                      ❶
        System.out.println("Private method");
    }

    public Method makeReflective() {
        Method meth = null;

        try {
            Class<?>[] argTypes = new Class[] { Void.class };
            meth = ExamplePrivate.class
                       .getDeclaredMethod("callThePrivate", argTypes);
            meth.setAccessible(true);
        } catch (IllegalArgumentException |
                    NoSuchMethodException |
                    SecurityException e) {
            throw (AssertionError)new AssertionError().initCause(e);
        }

        return meth;
    }

    public static class Proxy {
        private Proxy() {}

        public static void invoke(ExamplePrivate priv) {
            priv.callThePrivate();
        }
    }

    public MethodHandle makeMh() {
        MethodHandle mh;
        var desc = MethodType.methodType(void.class);                    ❷

        try {
            mh = MethodHandles.lookup()
                     .findVirtual(ExamplePrivate.class,
                         "callThePrivate", desc);                        ❸
        } catch (NoSuchMethodException | IllegalAccessException e) {
            throw (AssertionError)new AssertionError().initCause(e);
        }

        return mh;
    }

}

❶ 我们想要提供访问权限的私有方法

❷ 方法类型创建——我们可以使用确切类型,而不是需要在这里装箱。

❸ 方法句柄查找

示例类提供了三种不同的能力,可以访问私有的 callThePrivate() 方法。在实际应用中,通常只会提供其中一种能力——我们只展示所有三种能力来讨论它们之间的区别。在实际应用中,作为 API 的用户,你通常不需要关心使用哪种方法。

在表 17.1 中,你可以看到反射的主要优势是熟悉性。对于简单用例,代理可能更容易理解,但我们相信方法句柄代表了两者的最佳结合。我们强烈建议在所有新应用程序中使用它们。

表 17.1 比较 Java 的间接方法访问技术

功能 反射 内部类/lambda 方法句柄
访问控制 必须使用 setAccessible()。可能被活动的安全管理器禁止。 内部类可以访问受限制的方法。 允许从当前上下文访问所有方法。与安全管理器无关。
类型纪律 无。不匹配时会产生丑陋的异常。 静态。可能过于严格。可能需要大量的元空间来存储所有代理。 运行时类型安全。几乎不消耗(如果有的话)元空间。
性能 与其他替代方案相比较慢。 与任何其他方法调用一样快。 力求与其他方法调用一样快。

方法句柄提供的一个额外功能是能够从静态上下文中确定当前类。如果你曾经编写过类似这样的日志代码(例如,对于 log4j):

Logger lgr = LoggerFactory.getLogger(MyClass.class);

然后你知道这段代码是脆弱的。如果将其重构为移动到父类或子类,显式的类名会导致问题。然而,使用方法句柄,你可以这样写:

Logger lgr = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

在此代码中,lookupClass() 表达式可以被视为与 getClass() 等效,它可以在静态上下文中使用。这在处理日志框架等场景中特别有用,因为它们通常为每个类提供一个日志记录器。

注意方法句柄已被证明是一个非常成功的 API。事实上,它如此成功,以至于在 Java 18(但不在 11 或 17)中,反射的实现技术已经改为依赖于它,而不是我们在上一节中遇到的那种实现。

在你的技术工具箱中拥有方法句柄技术,并具备第四章中字节码的实用知识,让我们深入了解 invokedynamic 指令。它在 Java 7 中被引入,并且(到目前为止)是唯一被添加到 JVM 指令集的指令。invokedynamic 的原始用例是帮助非 Java 语言充分利用 JVM 作为平台,但它已经成为平台内变革的主要推动者,正如我们将看到的。

17.4 Invokedynamic

本节讨论了现代 Java 中最技术复杂的新特性之一。但尽管它非常强大,这并不是每个工作开发者都会直接使用的特性。相反,目前这个特性主要用于框架开发者和非 Java 语言实现者。

因此,在第一次阅读时可以跳过这一节。为了最好地利用它,你需要阅读并理解本章前面关于执行invoke指令的讨论——了解我们即将打破的规则会有所帮助。

我们将详细介绍invokedynamic的工作原理,并查看一些使用新字节码的调用站点的反编译示例。请注意,为了使用利用invokedynamic的语言和框架,并不需要完全理解这一点,但这是内部章节,所以我们将深入细节。

如你所猜,invokedynamic是一种新的调用指令类型,即它用于进行方法调用。它用于告诉 JVM 它必须推迟确定调用哪个方法,直到运行时。

这可能看起来不是什么大问题——毕竟,invokevirtualinvokeinterface都会在运行时决定调用哪个实现。然而,这些操作码的目标选择受到 Java 语言继承规则和类型系统的约束,因此在编译时至少知道一些调用目标信息。

另一方面,invokedynamic是为了放宽这些约束而创建的,它通过调用一个辅助方法(称为引导方法BSM)来做出应该调用哪个方法的决策。

注意:invokedynamic站点的目标方法(调用目标)根本不需要符合 Java 继承层次结构的规则——这是一个用户定义的选择。

为了允许这种灵活性,invokedynamic操作码引用类常量池的一个特殊部分,该部分包含扩展条目以支持调用的动态性——BSMs。这些是invokedynamic的关键部分,所有invokedynamic调用点都有一个对应的 BSM 常量池条目。

BSM 接受有关调用站点的信息并链接动态调用。BSM 至少接受三个参数,并返回一个CallSite对象。标准参数类型如下:

  • MethodHandles.Lookup—在调用点发生的类上的查找对象

  • String—在NameAndType中提到的名称

  • MethodTypeNameAndType的解析类型描述符

在这些参数之后是 BSM 需要的任何其他参数。在文档中,这些被称为附加静态参数。返回的调用点包含一个MethodHandle,这是调用调用点的效果,并将作为invokedynamic的实际调用执行。

注意:为了允许将 BSM 与特定的invokedynamic调用点关联,在类文件格式中添加了一个新的常量池条目类型,也称为InvokeDynamic

invokedynamic指令的调用点在类加载时被称为“未编织”。这意味着尚未将目标方法与调用点关联,并且只有在调用点被到达时(即,当 JVM 尝试链接并执行该特定的invokedynamic指令时)才会这样做。

在这一点上,BSM 将被调用以确定应该实际调用哪个方法。BSMs 总是返回一个包含MethodHandleCallSite对象,并且它将被“编织”到调用点中。一旦CallSite被链接,实际的调用就可以进行——它是到由CallSite持有的MethodHandle

在最简单的情况下,当使用ConstantCallSite时,一旦查找完成一次,就不会重复。相反,调用点的目标将在所有未来的调用上直接调用,而无需任何进一步的工作。它的行为类似于CompletableFuture<CallSite>。在实践中,这意味着调用点现在是稳定的,因此对其他 JVM 子系统(如 JIT 编译器)友好。还可能存在更复杂的选择,例如MutableCallSite(甚至VolatileCallSite),这些允许在一段时间内重新链接调用点,使其指向不同的目标方法。

一个非常量调用点在其程序生命周期内可以拥有许多不同的方法句柄作为其目标。实际上,能够在特定的调用点更改被调用的方法是一种对于非 Java 语言来说可能很重要的技术。

例如,在 JavaScript 或 Ruby 中,特定类型的单个对象可以定义其上不存在于该类其他实例上的方法。这在 Java 中是不可能的——类定义了一组在类加载时用于构建 vtable 的方法,所有实例共享相同的 vtable。使用invokedynamic与可变调用点可以有效地实现这种非 Java 特性。

我们应该指出,你不能从常规方法调用中让javac生成invokedynamic——Java 方法调用始终被转换为我们在第四章中遇到的四个“常规”invoke 操作码之一。相反,Java 框架和库(包括 JDK 中的那些)出于各种目的使用invokedynamic。Lambdas 提供了一个很好的案例研究,让我们更深入地了解这些目的之一。

17.4.1 实现 lambda 表达式

Lambdas 在 Java 编程中变得无处不在,但许多 Java 程序员实际上并不真正了解它们的实现方式。让我们从以下简单的例子开始了解:

public class LambdaExample {
    private static final String HELLO = "Hello World!";

    public static void main(String[] args) throws Exception {
        Runnable r = () -> System.out.println(HELLO);
        Thread t = new Thread(r);
        t.start();
        t.join();
    }
}

你可能会猜测 lambda 实际上只是Runnable匿名实现的语法糖。然而,如果我们编译前面的类,我们可以看到只生成了一个名为LambdaExample.class的单个文件——没有第二个类文件(正如我们在第八章讨论的,内部类应该放在那里)。所以,故事还有更多,我们将看到。

相反,如果我们反编译,那么我们可以看到 lambda 体实际上已经被编译成了一个私有的静态方法,这个方法出现在主类中:

private static void lambda$main$0();
    Code:
       0: getstatic     #7   // Field
                             // java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #9   // String Hello World!
       5: invokevirtual #10  // Method java/io/PrintStream.println:
                             // (Ljava/lang/String;)V
       8: return

主方法看起来是这样的:

public static void main(java.lang.String[]) throws java.lang.Exception;
    Code:
       0: invokedynamic #2,  0  // InvokeDynamic #0:run:
                                // ()Ljava/lang/Runnable;
       5: astore_1
       6: new           #3      // class java/lang/Thread
       9: dup
      10: aload_1
      11: invokespecial #4      // Method java/lang/Thread."<init>"
                                // :(Ljava/lang/Runnable;)V
      14: astore_2
      15: aload_2
      16: invokevirtual #5      // Method java/lang/Thread.start:()V
      19: aload_2
      20: invokevirtual #6      // Method java/lang/Thread.join:()V
      23: return

invokedynamic正在充当对一种不寻常的工厂方法的调用。调用返回一个实现了Runnable类型的实例。确切类型在字节码中未指定,并且这基本上并不重要。事实上,实际返回的类型在编译时并不存在,将在运行时按需创建。

我们知道invokedynamic站点总是与引导方法相关联。对于我们的简单Runnable示例,在类文件的相关部分有一个单一的 BSM,如下所示:

BootstrapMethods:
  0: #28 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:
        (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;
         Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;
         Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)
         Ljava/lang/invoke/CallSite;
    Method arguments:
      #29 ()V
      #30 REF_invokeStatic LambdaExample.lambda$main$0:()V
      #29 ()V

这有点难以阅读,所以让我们来解码它。这个调用站的引导方法是常量池中的第 28 项——一个类型为MethodHandle的条目。它指向java.lang.invoke包中的静态工厂方法LambdaMetafactory.metafactory()。元工厂方法接受相当多的参数,但它们大多数是由 BSM(条目#29 和#30)的附加静态参数提供的。

单个 lambda 表达式生成三个静态参数,这些参数被传递给 BSM:lambda 的签名、lambda 实际最终调用目标的处理方法(即 lambda 体),以及签名的擦除形式。

让我们跟随代码进入java.lang.invoke,看看平台如何使用元工厂来动态创建实现我们 lambda 表达式目标类型的类。BSM(即对metafactory方法的调用)返回一个调用站对象,就像往常一样。当invokedynamic指令被执行时,调用站中包含的方法处理程序将返回一个实现了 lambda 目标类型的类的实例。

注意:如果invokedynamic指令从未被执行,那么动态创建的类将永远不会被创建。

metafactory方法的源代码相对简单,如下所示:

public static CallSite metafactory(MethodHandles.Lookup caller,
                                       String invokedName,
                                       MethodType invokedType,
                                       MethodType samMethodType,
                                       MethodHandle implMethod,
                                       MethodType instantiatedMethodType)
            throws LambdaConversionException {
        AbstractValidatingLambdaMetafactory mf;
        mf = new InnerClassLambdaMetafactory(caller, invokedType,
                                             invokedName, samMethodType,
                                             implMethod,
                                             instantiatedMethodType,
                                             false, EMPTY_CLASS_ARRAY,
                                             EMPTY_MT_ARRAY);
        mf.validateMetafactoryArgs();
        return mf.buildCallSite();
    }

查找对象对应于invokedynamic指令存在的上下文。在我们的情况下,那就是定义 lambda 的同一个类,所以查找上下文将具有正确的权限来访问 lambda 体编译成的私有方法。

JVM 提供了调用的名称和类型,这些都是实现细节。最后的三个参数是从 BSM 提供的额外静态参数。

在当前实现的 lambdas 中,metafactory 委托给使用 ASM 字节码库内部、阴影副本的代码来启动一个实现目标类型的内部类。这可能在将来改变。

最后,我们应该注意,创建一个使用invokedynamic执行特殊操作的定制类是完全可能的,但为了构建这样的类,你必须使用字节码操作库来生成包含invokedynamic指令的.class文件。一个不错的选择是 ASM 库(见asm.ow2.org/)。我们已经提到过这个库几次,它是一个工业级的库,被广泛应用于各种知名的 Java 框架中(包括前面提到的 JDK 本身)。这标志着我们对invokedynamic的讨论结束,现在是时候讨论一些较小但仍然重要的内部变化了。

17.5 小型内部更改

有时小的变化可以对语言产生重大影响。在本节中,我们将遇到三个小的实现内部变化,这些变化要么有助于性能,要么纠正平台上的旧问题。让我们先从字符串开始谈。

17.5.1 字符串连接

记住,在 Java 中,String的实例实际上是不可变的。那么,当你使用+运算符连接两个字符串时会发生什么?JVM 必须创建一个新的String对象,但这里发生的事情可能比表面上看起来更复杂。

考虑一个具有main()方法的简单类,如下所示:

public static void main(String[] args) {
  String str = "foo";
  if (args.length > 0) {
    str = args[0];
  }
  System.out.println("this is my string: " + str);
}

对应于这个相对简单方法的 Java 8 字节码如下:

public static void main(java.lang.String[]);
Code:
  0: ldc #17            // String foo
  2: astore_2
  3: aload_1
  4: arraylength
  5: ifle 12                                                               ❶
  8: aload_1
  9: iconst_0
 10: aaload
 11: astore_2
 12: getstatic #19      // Field java/lang/System.out:                     ❷
                        // Ljava/io/PrintStream;                           ❷
 15: new #25            // class java/lang/StringBuilder                   ❸
 18: dup                                                                   ❸
 19: ldc #27            // String this is my string:                       ❸
 21: invokespecial #29  // Method java/lang/                               ❸
                        // StringBuilder."<init>"                          ❸
                        // :(Ljava/lang/String;)V                          ❸
 24: aload_2
 25: invokevirtual #32  // Method java/lang/StringBuilder.append
                        // (Ljava/lang/String;)Ljava/lang/StringBuilder;
 28: invokevirtual #36  // Method java/lang/                               ❹
                        // StringBuilder.toString:                         ❹
                        // ()Ljava/lang/String;                            ❹
 31: invokevirtual #40  // Method java/io/                                 ❺
                        // PrintStream.println:                            ❺
                        // (Ljava/lang/String;)V                           ❺
 34: return

❶ 如果数组为空,则跳转到指令 12。

❷ 将 System.out 加载到栈上

❸ 设置 StringBuilder

❹ 从 StringBuilder 创建字符串

❺ 打印字符串

在这个字节码中,我们有几件事情需要注意。特别是,StringBuilder的出现可能有点令人惊讶——我们要求连接一些字符串,但字节码却告诉我们,我们实际上是在创建额外的对象,然后调用append(),然后对它们调用toString()

指令 15-23 显示了临时StringBuilder对象的newdupinvokespecial对象创建模式,但在这个情况下,构造还包括在dup之后的ldc(加载常量)。这种变体模式表明你正在调用一个非 void 构造函数——在这个例子中是StringBuilder(String)

所有这一切背后的原因是 Java 的字符串(实际上是)不可变的。我们不能通过连接来修改字符串内容,因此我们不得不创建一个新的对象,而StringBuilder只是做这件事的一种方便方式。

然而,Java 11 的字节码形状看起来完全不同:

public static void main(java.lang.String[]);
Code:
  0: ldc           #2      // String foo
  2: astore_1
  3: aload_0
  4: arraylength
  5: ifle          12
  8: aload_0
  9: iconst_0
 10: aaload
 11: astore_1
 12: getstatic     #3      // Field java/lang/System.out:
                           // Ljava/io/PrintStream;
 15: aload_1
 16: invokedynamic #4,  0  // InvokeDynamic #0:makeConcatWithConstants:
                           // (Ljava/lang/String;)Ljava/lang/String;
 21: invokevirtual #5      // Method java/io/PrintStream.println:
                           // (Ljava/lang/String;)V
 24: return

前面的 12 条指令与 Java 8 的情况相同,但之后事情开始发生变化。一个明显的改变是第 16 条指令处的StringBuilder临时变量完全消失了。取而代之的是,有一个invokedynamic指令。当然,这需要引导方法:

BootstrapMethods:
  0: #23 REF_invokeStatic java/lang/invoke/StringConcatFactory.
      makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;
      Ljava/lang/String;Ljava/lang/invoke/MethodType;
      Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #24 this is my string: \u0001

这是一个动态调用静态工厂方法makeConcatWithConstants(),该方法位于java.lang.invoke包中的名为StringConcatFactory的类中。这个工厂方法接受一个字符串——这些特定参数的连接配方——并生成一个CallSite,它与针对此特定情况的定制方法相关联。

通常,这是 JVM 实现代码深处的机制。大多数普通 Java 代码永远不会直接调用这些方法,而是依赖于 JDK 代码和库/框架,这些代码和框架会调用它们。

注意:引导方法的静态参数包括字符\u0001(Unicode 点 0001),它代表一个普通参数,将被插入到连接配方中。

invokedynamic调用点可以被重用,如果需要,实现类也可以动态创建。实现类还可以访问私有 API——例如零拷贝String构造函数——这些 API 作为StringBuilder的一部分是不可能公开的。

17.5.2 紧凑字符串

当你最初学习 Java 时,你被介绍到原始类型,并了解到在 Java 中char是两个字节。不难猜测,在底层,Java 字符串是通过使用char[]来存储字符串的各个字符来实现的。

然而,这并不完全正确。确实,在 Java 8 及之前,String的内容是以char[]的形式表示的。然而,这种表示可能导致一个可能不明显的不效率,所以让我们稍微深入了解一下。

Java 的两个字节char(表示 UTF-16 字符)对于只包含西欧语言字符的字符串来说是浪费的,因为这种字符串的每个char的第一个字节总是零。这浪费了这些语言的字符串近 50%的存储空间,而这些语言非常常见。

为了解决这个问题,Java 9 引入了一种性能优化:它允许每个字符串选择(目前)两种表示之一。每个字符串可以编码为拉丁-1(用于西欧语言)或 UTF-16(原始表示)。

注意:拉丁-1 也以其标准号 ISO-8859-1 而闻名。请勿将其与 ISO-8851 混淆,ISO-8851 是确定黄油水分含量的国际标准。

内部,字符串的表示已经改为byte[],如果字符串是拉丁-1 编码,则n个字符的字符串表示为n个字节;如果不是,则为n * 2个字节。java.lang.String中的代码如下:

private final byte[] value;

/**
 * The identifier of the encoding used to encode the bytes in
 * {@code value}. The supported values in this implementation are
 *
 * LATIN1
 * UTF16
 *
 * @implNote This field is trusted by the VM, and is a subject to
 * constant folding if String instance is constant. Overwriting this
 * field after construction will cause problems.
 */
private final byte coder;

static final byte LATIN1 = 0;
static final byte UTF16  = 1;

根据工作负载的性质,这可以在常见的拉丁-1 情况下带来显著的节省。另一方面,主要处理例如 CJKV 语言(中文、日语、韩语和越南语)文本的应用程序将不会从这个内部变化中看到任何空间节省。

在实际应用中,使用西方语言的应用程序在从 Java 8 迁移到 11 时,仅从这个变化本身就可以看到高达 30%甚至 40%的堆大小节省。较小的堆大小意味着较小的容器,这可以转化为云计算成本的可视节省。

为了结束这次讨论,简要说明一下这个变化对性能的影响:它提供了一个很好的例子,说明了为什么我们必须将性能视为一门实验科学,并从上到下进行测量。引入两种不同的String表示形式确实涉及更多的代码执行,因为字符串操作现在需要两个单独的实现——一个用于拉丁-1,一个用于 UTF-16——因此代码需要检查coder并根据结果进行分支。

然而,关键的性能问题是,“额外的代码是否重要?”也就是说,这个额外的“复杂性税”的好处是否超过了成本?这个变化的好处包括以下方面:

  • 较小的堆大小

  • 可能更快的 GC 时间

  • 拉丁-1 字符串的更好缓存局部性

我们还可以质疑额外的比较和分支操作实际上对实际执行代码量的影响有多大——记住 JIT 编译器会进行许多非显而易见的优化,并可以利用等待缓存缺失的时间,因此紧凑字符串所需的额外指令实际上可能是免费的。

注意:一般来说,“计算执行指令数”并不是推理 Java 性能的好方法。

这种相互制衡的力量和权衡的画面,其影响只能通过测量大规模可观察量来确定,这正是我们在第七章讨论的性能模型。在这个具体案例中,较小的堆大小可能直接转化为减少云托管成本,因为可以使用较小的容器。

17.5.3 寄居者

寄居者是在 JEP 181:基于巢的访问控制中指定的,并且这个变化本质上纠正了一个追溯到 Java 1.1 的内部类实现上的实现技巧。让我们看一个例子,看看为了支持寄居者所做的字节码更改:

public class Outer {
    private int i = 0;

    public class Inner {
        public int i() {
            return i;
        }
    }
}

如果我们编译这段代码,无论我们使用 Java 8 还是 17 编译,最终都会得到两个独立的类文件。然而,每种情况下的字节码都是不同的。我们可以使用javap来查看这两种情况之间的差异。以下是 Java 8 的情况:

Compiled from "Outer.java"
public class Outer {
  private int i;

  public Outer();
    Code:
       0: aload_0
       1: invokespecial #2       // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_0
       6: putfield      #1       // Field i:I
       9: return

  static int access$000(Outer);           ❶
    Code:
       0: aload_0
       1: getfield      #1       // Field i:I
       4: ireturn
}

❶ 编译器已插入此“桥梁”方法。

对于内部类的单独类文件:

Compiled from "Outer.java"
public class Outer$Inner {
  final Outer this$0;

  public Outer$Inner(Outer);
    Code:
       0: aload_0
       1: aload_1
       2: putfield      #1    // Field this$0:LOuter;
       5: aload_0
       6: invokespecial #2    // Method java/lang/Object."<init>":()V
       9: return

  public int i();
    Code:
       0: aload_0
       1: getfield      #1    // Field this$0:LOuter;
       4: invokestatic  #3    // Method                 ❶
                              // Outer.access$000:(LOuter;)I
       7: ireturn
}

❶ 这里使用的桥梁方法

注意如何将合成访问方法(或桥接方法)access$000()添加到外部类中,以提供对我们在内部类中访问的私有字段的包私有访问。现在让我们看看如果我们使用 Java 17(或 11)重新编译源代码会发生什么:

Compiled from "Outer.java"
public class Outer {
  private int i;

  public Outer();
    Code:
       0: aload_0
       1: invokespecial #2         // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_0
       6: putfield      #1         // Field i:I
       9: return
}

合成访问器已经完全消失。相反,看看这里显示的内部类:

Compiled from "Outer.java"
public class Outer$Inner {
  final Outer this$0;

  public Outer$Inner(Outer);
    Code:
       0: aload_0
       1: aload_1
       2: putfield      #1      // Field this$0:LOuter;
       5: aload_0
       6: invokespecial #2      // Method java/lang/Object."<init>":()V
       9: return

  public int i();
    Code:
       0: aload_0
       1: getfield      #1      // Field this$0:LOuter;
       4: getfield      #3      // Field Outer.i:I
       7: ireturn
}

这现在是对私有字段的直接访问,如下所示:

SourceFile: "Outer.java"
NestMembers:
  Outer$Inner
InnerClasses:
  public #6= #5 of #3;            // Inner=class Outer$Inner of class Outer

Java 11 引入了“巢”的概念,这实际上是现有嵌套类概念的泛化。在 Java 的早期版本中,为了共享相同的访问控制上下文,一个类的源代码必须物理地位于另一个类的源代码内部。在新概念中,一组类文件可以形成一个巢,其中巢居者共享一个共同的访问控制机制,并且可以无限制地直接(和反射)访问彼此——包括对私有成员的访问。

注意:巢居者的到来微妙地改变了“私有”一词的含义,正如我们在之前讨论invokespecial时所见。

这个变化确实很小,但它不仅移除了一些不太完美的实现冗余,而且对于平台即将到来的变化也是必要的。让我们从小的变化中走出来,讨论 JVM 内部的一个主要(也是最臭名昭著的)方面:Unsafe类。

17.6 不安全

在 Java 平台中,如果某些功能或行为看起来“神奇”,通常是通过使用三种主要机制之一来实现的:反射、类加载(包括相关的字节码转换)或Unsafe

一个 Java 的高级用户会试图理解这三种技术,即使他们只有在必要时才会使用它们。原则“仅仅因为你可以做某事,并不意味着你应该这样做”不仅适用于我们的软件设计选择,也适用于其他地方。

在这三个中,Unsafe是最有可能危险的(也是最有力的),因为它提供了一种做某些事情的方式,这些事情在其他情况下是不可能的,并且违反了平台已经建立良好的规则。例如,Unsafe允许 Java 代码执行以下操作:

  • 直接访问硬件 CPU 功能

  • 创建一个对象但不运行其构造函数

  • 创建一个真正匿名的类,而不进行通常的验证

  • 手动管理堆外内存

  • 执行许多其他“不可能”的事情

Java 8 的Unsafe类,sun.misc.Unsafe,立即警告我们其本质——不仅在类名中,还在于它所在的包。sun.misc包是一个内部、实现特定的位置,不是 Java 代码应该直接接触的东西。

注意:在 Java 9 及以后的版本中,Unsafe的危险性变得更加明显,因为其功能已被移动到一个名为jdk.unsupported的模块中。

当然,Java 库不应该直接与这些类型的实现细节耦合。为了加强这一观点,Java 平台维护者的态度一直是,违反规则并链接到内部细节的用户这样做是自担风险。

然而,不便的事实是,尽管这个 API 不受支持,但它被库作者广泛使用。它不是 Java 的官方标准,但它已经成为非标准但必要的平台特性的垃圾场,这些特性具有不同的安全性。

为了说明这一点,让我们看看Unsafe的经典用法:使用称为“比较并交换”或 CAS 的硬件功能。这种功能几乎存在于所有现代 CPU 上,但众所周知,它不是 Java 内存模型(JMM)的一部分。

在我们的例子中,我们将回顾第五章中遇到的Account类。由于技术原因,在本节中我们将假设余额是一个int而不是doubleAccount接口的定义如下:

public interface Account {
    boolean withdraw(int amount);

    void deposit(int amount);

    int getBalance();

    boolean transferTo(Account other, int amount);
}

我们将用两种不同的方式来实现它。首先,我们将坚持规则并使用同步。在SynchronizedAccount接口中的两个方法看起来像这样:

public class SynchronizedAccount implements Account {
    private int balance;

    public SynchronizedAccount(int openingBalance) {
        balance = openingBalance;
    }

    @Override
    public int getBalance() {
        synchronized (this) {
            return balance;
        }
    }

    @Override
    public void deposit(int amount) {
        // Check to see amount > 0, throw if not
        synchronized (this) {
            balance = balance + amount;
        }
    }

现在让我们将其与原子性的Unsafe实现进行比较,由于需要反射地访问Unsafe类,因此它包含大量的样板代码:

public class AtomicAccount implements Account {
    private static final Unsafe unsafe;                                    ❶
    private static final long balanceOffset;                               ❷

    private volatile int balance = 0;                                      ❸

    static {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            unsafe = (Unsafe) f.get(null);                                 ❹
            balanceOffset = unsafe.objectFieldOffset(                      ❺
                                AtomicAccount.class
                                  .getDeclaredField("balance"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    public AtomicAccount(int openingBalance) {
        balance = openingBalance;
    }

    @Override
    public double getBalance() {
        return balance;                                                    ❻
    }

    @Override
    public void deposit(int amount) {
        // Check to see amount > 0, throw if not
        unsafe.getAndAddInt(this,
                            balanceOffset, amount);                        ❼
    }

    // ...

❶ 我们的Unsafe对象副本

❷ 相对于对象开始的余额字段的指针偏移的数值

❸ 实际的余额字段

❹ 反射地查找Unsafe对象

❺ 计算指针偏移

❻ 对余额的易失性读取——没有锁

❼ 使用 CAS 操作更新余额

在这个例子中,我们正在做几件在 Java 中似乎不可能的事情。首先,我们正在计算指针偏移(字段value相对于AtomicAccount对象开始的偏移量)。没有 JVM 字节码指令序列可以提供这个——只有直接访问 JVM 内部数据结构的本地代码才能做到。Unsafe对象的objectFieldOffset()方法使我们能够做到这一点。

第二,我们正在对余额执行无锁的原子性加法。在 JMM 的条款下,这是不可能的,因为volatile只给我们一个操作(一个读取一个写入),而加法需要读取写入。让我们看看Unsafe中的getAndAddInt()方法中的代码,看看这是如何完成的:

    public final int getAndAddInt(Object o, long offset, int delta) {
        int v;
        do {
            v = getIntVolatile(o, offset);                      ❶
        } while (!compareAndSetInt(o, offset, v, v + delta));   ❷
        return v;
    }

❶ 程序化易失性访问

❷ 低级 CAS 操作

在这段代码中,我们选择内存访问模式(在这种情况下,volatile),而不是让变量声明的方式决定它。我们也是通过指针偏移直接访问内存,而不是通过字段间接访问——这是在正常 Java 中不可能做到的。

注意:JDK 11+中的实现使用了一个“内部Unsafe”对象,出于封装原因——这里显示的代码就是这部分。

CAS 方法的语义如下:在对象o上,该对象从对象头开始有一个给定offset的字段,作为一个单 CPU 操作:

  1. 将内存位置(四个字节)的当前状态与 int 类型的v进行比较。

  2. 如果v的值匹配,则将其更新为v + delta

  3. 如果替换成功则返回true;如果失败则返回false

替换可能会失败,因为一个在另一个 CPU 上积极运行的线程在易失性读取和 CAS 之间更新了内存位置。在这种情况下,compareAndSetInt()方法返回false,而do-while循环会再次尝试。

因此,这类操作是无锁的,但不是无循环的。许多线程正在操作的高度竞争字段可能需要我们在原子加法最终成功之前在循环中旋转一段时间,但不存在丢失更新的可能性。

注意:如果我们追踪到 JDK 中的代码,我们可以看到这个实现与 JDK 实际上为AtomicInteger所做的非常接近。

为了完整性,让我们也看看withdraw()的实现:

    @Override
    public boolean withdraw(int amount) {
        // Check to see amount > 0, throw if not
        var currBal = balance;                                 ❶
        var newBal = currBal - amount;
        if (newBal >= 0) {
            if (unsafe.compareAndSwapInt(this,                 ❷
                                         balanceOffset,
                                         currBal, newBal)) {
                return true;
            }
        }
        return false;
    }

❶ 对余额进行易失性读取

❷ 通过低级 CAS 尝试更新余额

这种情况有点不同,因为我们直接使用低级 API 进行余额更新。这是必要的,因为我们必须保持约束,即账户余额不能变为负数。这需要额外的操作,例如在newBal上的比较。

对于存款的情况,我们可以使用高级 API,它会循环直到成功。然而,这是因为钱总是可以存入账户,无论其状态如何。如果我们在这里使用同样的技术,这个提款可能会无限期地旋转,因为账户可能没有足够的钱来满足它。

相反,我们采取的方法是尝试一次,如果 CAS 操作失败则取消提款。这消除了两个提款操作试图索要相同资金的竞争条件,但副作用是,由于另一个线程(在易失性读取之后改变了余额)发生的存款,一个本应成功的提款可能会意外失败。

注意:我们可以在提款代码中引入一个for循环来减少意外失败的可能性,但它必须是一个可证明的有限循环。

在基准测试中,这两种方法之间的性能差异相当显著——Unsafe实现在现代硬件上大约快两到三倍。然而,你不应该在最终用户代码中使用这种技术。如前所述,许多(几乎全部?)现代框架已经使用Unsafe。直接针对Unsafe编码而不是使用你选择的框架提供的内容,不太可能带来任何性能上的好处。

更重要的是,你通过这种方式违反了 Java 规范:使用内部功能,这些功能不一定遵循用户代码应该遵循的规则。在下文中,我们将讨论 Java 的最新版本是如何尝试减少不受支持的 API,并用完全支持的替代方案来替换它的。

17.7 用支持的 API 替换Unsafe

回想一下,在第二章中,我们遇到了 Java 模块。这种封装机制提供了严格的导出能力,并移除了调用内部包中代码的能力。这对Unsafe及其使用的代码有何影响?

由于许多框架和库依赖于Unsafe,它们将无法升级到不允许反射访问Unsafe的 Java 版本。

注意:必须通过反射访问Unsafe对象,因为平台已经阻止非 JDK 代码直接访问它。

在 Java 11+中,模块系统提供了jdk.unsupported模块。它的声明方式如下:

module jdk.unsupported {
    exports sun.misc;
    exports sun.reflect;
    exports com.sun.nio.file;

    opens sun.misc;
    opens sun.reflect;
}

此代码为任何明确依赖于unsupported模块的应用程序提供访问权限,并且——关键的是——还提供了对sun.misc包的无限制反射访问,该包包含Unsafe。尽管这有助于将Unsafe转换为更模块友好的形式,但我们有理由问:这种妥协的访问应该维持多久?

这只是暂时给Unsafe放行一段时间——真正的解决方案是 Java 平台团队创建新的、受支持的 API 来替代sun.misc.Unsafe的“安全”功能,然后一旦 Java 库作者有机会迁移到新 API,就移除或关闭jdk.unsupported模块。

注意:关闭Unsafe会影响使用非常广泛范围的框架的用户——说基本上 Java 生态系统中的每一个非平凡应用都间接依赖于Unsafe并不夸张。

需要移除的主要Unsafe API 之一是内存的编程访问模式,例如getIntVolatile()。替代方案是 VarHandles API,这是我们下一节的主题。

17.7.1 VarHandles

Java 9 中引入的 VarHandles API 旨在扩展 Method Handles 的概念,为字段和内存访问提供类似的功能。回想一下,在第五章中讨论的 JMM 中,只提供了两种内存访问模式:正常访问和 volatile(“从主内存重新读取,忽略 CPU 缓存,直到读取完成”)。不仅如此,Java 语言还提供了一种仅在字段级别表达这些模式的方法。所有访问都是在正常模式下进行的,除非字段被显式声明为volatile,在这种情况下,对该字段的所有访问都是在 volatile 模式下进行的。如果这些规定不足以满足现代应用的需求怎么办?

注意:Volatile 是 Java 语言的虚构。内存只是内存,并没有分开的 volatile 访问和非 volatile 访问的内存芯片。

VarHandles 的一个重要目标是允许新的内存访问方式,即提供对 Unsafe 使用的一种受支持的、更优越的替代方案,例如执行 CAS 或一般可变访问的替代方法。

为了看到这个功能在实际中的应用,让我们来看一个快速示例,展示我们如何使用 VarHandles 来替换账户类中的 Unsafe

public class VHAccount implements Account {
    private static final VarHandle vh;
    private volatile int balance = 0;

    static {
        try {
            var l = MethodHandles.lookup();                  ❶
            vh = l.findVarHandle(VHAccount.class,            ❷
                                  "balance", int.class);
        } catch (Exception ex) { throw new Error(ex); }
    }

    @Override
    public void deposit(int amount) {
        // Check to see amount > 0, throw if not
        vh.getAndAdd(this, amount);                          ❸
    }

    // ...
}

❶ 创建一个 Lookup 对象

❷ 获取 balance 的 VarHandle 并将其缓存

❸ 使用 VarHandle 以可变内存语义访问字段

这在功能上与使用 Unsafe 的版本等效,但现在它只使用完全受支持的 API。

使用 MethodHandles.Lookup 是一个重要的变化。与依赖于 setAccessible() 来访问私有字段的反射不同,查找对象拥有调用上下文所拥有的任何权限,这包括对私有字段 balance 的访问。

从反射迁移到方法和字段处理意味着在 Java 8 中的 Unsafe 中存在的一些方法现在可以从不受支持的 API 中删除,包括以下方法:

  • compareAndSwapInt()

  • compareAndSwapLong()

  • compareAndSwapObject()

这些方法的等价方法可以在 VarHandle 中找到,以及一些有用的访问器方法。还有对原始类型和对象的获取和设置方法,在正常和可变的访问模式下,以及构建高效加法器的方法,例如:

  • getAndAddInt()

  • getAndAddLong()

  • getAndSetInt()

  • getAndSetLong()

  • getAndSetObject()

另一个 VarHandles 的关键目标是允许对 JDK 9 及以后版本中可用的新内存顺序模式进行低级访问。这些 Java 9 的新并发屏障模式也要求对 JMM 进行一些相当适度的更新。

总体来说,在创建 Unsafe 的实际 API 的替代品方面已经取得了明确的进步。例如,除了 VarHandles 之外,Unsafe 中的 getCallerClass() 功能现在也通过 JEP 259 定义的堆栈跟踪 API 可用(见 openjdk.java.net/jeps/259)。然而,还有更多的工作要做。

17.7.2 隐藏类

隐藏类 在 JEP 371 中描述(见 openjdk.java.net/jeps/371)。这个内部特性是为平台和框架作者设计的。JEP 的目标是提供一个受支持的 API,用于 Unsafe 最常见的用途之一:创建即兴类,这些类不能直接由其他类使用(但可以间接处理)。

这些类有时被称为 匿名类Unsafe 中的方法被称为 defineAnonymousClass()。然而,这个术语对开发者来说很令人困惑,因为在正常 Java 应用程序代码的上下文中,它意味着某个接口的嵌套实现,该接口声明其静态类型为该接口,如下所示:

public class Scratch {
    public void foo() {
        Runnable r = new Runnable() {
            @Override
            public void run() {
                System.out.println("Only way possible before lambdas!");
            }
        };
    }
}

这通常被称为“Runnable 的匿名实现”;然而,这样的类实际上并不是匿名的——相反,编译器将生成一个类似 Scratch$1 的类名,这是一个真正的、可用的 Java 类。尽管类名对 Java 源代码不可用,但可以使用该名称找到该类,并通过反射访问它,然后像任何其他类一样使用它。

一个隐藏类并非真正匿名——它有一个可以通过在其类对象上直接调用 getName() 来获取的名称。这个名称也可能出现在几个其他地方,包括诊断信息、JVM 工具接口(JVMTI)或 JDK 飞行记录器(JFR)事件中。然而,隐藏类不能像常规类那样通过类加载器或任何方式找到,包括使用反射(例如,通过 Class.forName())。

目的是隐藏类的命名方式将它们明确地放在与常规类不同的命名空间中——名称具有足够不寻常的形式,从而有效地使该类对所有其他类不可见。

命名方案利用了在 JVM 中类通常有两种名称形式的事实:二进制名称(com.acme.Gadget),这是通过在类对象上调用 getName() 返回的,以及内部形式(com/acme/Gadget)。隐藏类的命名方式不符合这种模式。相反,通过在隐藏类的类对象上调用 getName(),会返回一个类似 com.acme.Gadget/1234 的名称。这既不是二进制名称也不是内部形式,任何尝试创建与该名称匹配的常规类的尝试都将失败。让我们快速看一下如何创建一个隐藏类的示例:

var fName = "/Users/ben/projects/books/resources/Ch15/ch15/Concat.class";
var buffy = Files.readAllBytes(Path.of(fName));
var lookup = MethodHandles.lookup();
var hiddenLookup = lookup.defineHiddenClass(buffy, true);
var klazz = hiddenLookup.lookupClass();
System.out.println(klazz.getName());

这种命名方案(以及以这种方式区分隐藏类)的一个优点是,它们不需要受到 JVM 类加载机制通常的严格审查。这与整体设计相吻合,即隐藏类旨在供框架作者和其他需要超越常规 Java 类通常施加的“万无一失”检查的能力的人使用。

注意:隐藏类是作为 Java 15 的一部分提供的,并且在 Java 11 中不可用。

Unsafe 的上下文中,JEP 371 旨在废弃 Unsafe 中的 defineAnonymousClass() 方法,整体目标是未来某个版本中将其移除。这是一个纯粹的内部变更——没有建议隐藏类的出现将以任何方式改变 Java 编程语言,至少最初是这样。然而,LambdaMetaFactoryStringConcatFactory 和其他“灵活工厂”方法等类的实现可能会更新以使用新的 API。

摘要

  • Java 提供了在 C++ 等语言中不易获得的运行时内省功能。

    • 反射

    • 方法处理

    • invokedynamic

    • 不安全

18 未来 Java

本章涵盖了

  • Project Amber

  • Project Panama

  • Project Loom

  • Project Valhalla

  • Java 18

本章涵盖了自 Java 17 发布以来的 Java 语言和平台的发展,包括尚未到达的未来更新。Java 语言和平台的新方向由 JEPs 管理,但那些是特定功能实现的描述。在更高层次上,OpenJDK 中有几个大型、长期运行的项目正在实施目前正在进行的重大变化,并将在未来几年内交付。

我们将依次介绍每个项目,然后是 Java 18。我们将从Project Amber开始,我们将听到更多关于模式匹配及其为何如此重要的故事。

18.1 Project Amber

在 OpenJDK 的当前主要项目中,Amber 最接近完成。它还受益于在开发者日常工作中相对容易理解。从项目的章程中:

Project Amber 的目标是探索和孵化更小、面向生产力的 Java 语言特性 ...

—Project Amber,openjdk.java.net/projects/amber/

该项目的主要目标是

  • 局部变量类型推断(已交付)

  • Switch 表达式(已交付)

  • 记录(已交付)

  • Sealed Types(已交付)

  • 模式匹配

如您所见,许多这些功能已在 Java 17 中交付——而且非常实用!

Amber 最后一个尚未解决的重大问题是模式匹配。正如我们在第三章中看到的,它正在逐步到来,首先是instanceof中的类型模式的使用。我们还遇到了switch中的模式预览版本。

有理由预期switch中的模式将经历与其他 Project Amber 功能相同的生命周期:首先是一个预览,然后是第二个预览,之后作为标准功能交付。

展望未来,更多 JEPs 计划推出。确定模式匹配的基本形式并非唯一的目标——还有更多模式形式需要添加。这告诉我们,随着发布节奏的变化,每两年一个“LTS”模式,Java 18 或 19 中最初预览的内容将有机会在下一个预期的 LTS,即 2023 年 9 月的 Java 21 中完全成熟。

例如,我们已经看到了如何在当前的模式匹配预览版本中有效地使用 Sealed Types。没有 Sealed Types,即使我们现在拥有的模式匹配形式也不会那么有用。以类似的方式,记录在模式中的某些最重要的用例尚未交付。特别是,解构模式将允许将记录分解为其组件,作为模式的一部分。

注意:如果您在 Python、JS 或其他语言中编程,您可能熟悉解构。Java 中解构的想法类似,但受 Java 的名义类型系统指导。

这是因为记录是由其语义定义的——记录实际上 nothing more than the sum of its parts。因此,如果记录只能通过拼接其组件来构建,那么它就可以得出结论,它可以无语义后果地分解为其组件。

在撰写本文时,此功能尚未进入主线 JDK 开发,甚至尚未进入 Amber 特定的 JDK 仓库。然而,语法预计将类似于以下:

FXOrder order = // ...

// WARNING This is preliminary syntax!!!

var isMarket = switch (order) {
    case MarketOrder(int units, CurrencyPair pair, Side side,
                     LocalDateTime sent, boolean allOrNothing) -> true;
    case LimitOrder(int units, CurrencyPair pair, Side side,
                    LocalDateTime sent double price, int ttl) -> false;
};

注意,此代码包含 Record 组件的显式类型。也可以合理地预期,编译器可以推断出这些类型。

也应该能够解构数组,因为它们也充当没有额外语义的元素容器。该语法的样子可能如下所示:

// WARNING This is preliminary syntax!!!

if (o instanceof String[] { String s1, String s2, ... }){
    System.out.println(s1 + s2);
}

注意,在两个示例中,我们都没有为元素容器声明绑定,无论是记录还是数组。

应该在这里提到的一个旁白是 Java 序列化如何影响这个特性。一般来说,Java 序列化是一个问题,因为它违反了 Java 中封装应该如何工作的基本规则。

序列化构成了一个无形但公开的构造函数,以及一个无形但公开的访问器集合,用于您的内部状态。

——Brian Goetz

幸运的是,记录和数组都非常简单:它们只是其内容的透明载体,因此不需要在序列化机制的细节中调用怪异之处。相反,我们始终可以使用公共 API 和规范构造函数来序列化和反序列化记录。在此基础上,甚至有建议可以非常深远,例如部分或完全移除序列化机制,并将解构扩展到某些(甚至所有)Java 类。

总体来说,Amber 传达的信息是:如果你熟悉其他编程语言中的这些特性,那就太好了。但是,如果你不熟悉,也无需担心——它们被设计成与您已经熟悉的 Java 语言兼容,并且易于在代码中开始使用。

虽然一些特性很小,而另一些则较大,但它们都可以对您的代码产生与更改规模不成比例的积极影响。一旦您开始使用它们,您可能会发现它们为您的程序提供了真正的益处。现在让我们转向下一个主要项目,代号为 Panama。

18.2 项目 Panama

根据项目页面上的说法,Project Panama 的全部内容是

提高和丰富 Java 虚拟机与定义良好但“外国”(非 Java)API 之间的连接,包括许多 C 程序员常用的接口。

——Project Panama, openjdk.org/projects/panama/

“巴拿马”这个名字来源于一个地峡的概念——一条狭窄的陆地连接两个更大的陆地,在这个类比中,这两个更大的陆地被认为是 JVM 和本地代码。它包括两个主要领域的 JEP:

  • 外部函数和内存 API

  • 向量 API

在这些中,我们将在本节中仅讨论外部 API。向量 API 尚未准备好进行全面讨论,原因我们将在本章后面解释。

18.2.1 外部函数和内存 API

自 Java 1.1 以来,Java 就拥有了 Java Native Interface (JNI)来调用本地代码,但它长期以来一直被认为存在以下主要问题:

  • JNI 有很多仪式和额外组件。

  • JNI 实际上仅与用 C 和 C++编写的库良好互操作。

  • JNI 没有自动将 Java 类型系统映射到 C 类型系统的功能。

开发者对额外组件方面有相当好的理解:除了native方法的 Java API 外,JNI 还需要一个从 Java API 派生的 C 头文件(.h)和一个 C 实现文件,该文件将调用本地库。其他一些方面则不太为人所知,例如,本地方法不能用来调用使用与 JVM 构建时不同的调用约定编写的函数。

自 JNI 首次出现以来,已经尝试了多种提供更好替代方案的方法,例如 JNA。然而,其他(非 JVM)语言在与其他本地代码互操作方面有显著更好的支持。例如,Python 作为机器学习良好语言的声誉很大程度上取决于打包本地库并将其在 Python 代码中提供的简便性。

巴拿马外国 API 是一个尝试填补这一差距的尝试,它允许 Java 直接支持以下功能:

  • 外部内存分配

  • 结构化外部内存操作

  • 外部资源生命周期管理

  • 调用外部函数

API 位于jdk.incubator.foreign包中的jdk.incubator.foreign模块中。它建立在我们在第十七章中遇到的 MethodHandles 和 VarHandles 之上。

注意:外部 API 包含在 Java 17 的孵化模块中。我们早在第一章就讨论了孵化模块及其重要性。要使本节中的代码示例运行,您需要明确将孵化模块添加到您的模块路径中。

API 的第一部分依赖于 MemorySegmentMemoryAddressSegmentAllocator 等类。这提供了对堆外内存的分配和处理访问。目标是提供一个更好的替代方案,以替代 ByteBuffer API 和 Unsafe 的使用。Foreign API 旨在避免 ByteBuffer 的限制,例如性能受限,只能处理 2 GB 大小的段,并且不是专门为堆外使用而设计的。同时,它应该比使用 Unsafe 更安全,Unsafe 允许基本上无限制的内存访问,这使得 JVM 出现故障崩溃变得非常容易。

注意:在本节的剩余部分,我们假设你已经熟悉 C 语言概念,以及从源代码构建 C/C++ 程序,并了解 C 编译、链接等阶段。

让我们看看它的实际效果。要开始,你需要从 jdk.java.net/panama/ 下载 Panama 的早期访问构建版本。尽管孵化器模块存在于 JDK 17 中,但重要的 jextract 工具并不包含在内,我们需要它来演示我们的例子。

一旦你设置了 Panama 的早期访问安装,可以使用 jextract -h 来测试它。你应该会看到如下输出:

WARNING: Using incubator modules:
         jdk.incubator.jextract, jdk.incubator.foreign
Non-option arguments:
[String] -- header file

Option                         Description
------                         -----------
-?, -h, --help                 print help
-C <String>                    pass through argument for clang
-I <String>                    specify include files path
-d <String>                    specify where to place generated files
--dump-includes <String>       dump included symbols into specified file
--header-class-name <String>   name of the header class
--include-function <String>    name of function to include
--include-macro <String>       name of constant macro to include
--include-struct <String>      name of struct definition to include
--include-typedef <String>     name of type definition to include
--include-union <String>       name of union definition to include
--include-var <String>         name of global variable to include
-l <String>                    specify a library
--source                       generate java sources
-t, --target-package <String>  target package for specified header files

对于我们的示例,我们将使用一个用 C 语言编写的简单 PNG 库:LibSPNG (libspng.org/)。

示例:LibSPNG

我们将首先使用 jextract 工具获取一组基础 Java 包,我们可以使用这些包。语法看起来像这样:

$ jextract --source -t <target Java package> -l <library name> \
    -I <path to /usr/include> <path to header file>

在 Mac 上,这最终会变成如下所示:

$ jextract --source -t org.libspng \
  -I /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/ \
      Developer/SDKs/MacOSX.sdk/usr/include \
  -l spng /Users/ben/projects/libspng/spng/spng.h

这可能会根据我们生成的头文件的版本产生一些警告,但只要成功,它将在当前目录中创建一个目录结构。这将包含一个名为 org.libspng 的包中的许多 Java 类,我们可以在稍后的 Java 程序中使用它们。

我们还需要构建一个共享对象,以便在运行我们的程序时进行链接。这最好是通过遵循项目构建说明来完成,请参阅 mng.bz/v6dJ

安装会在项目内部生成 libspng.dylib,并将其安装到系统共享位置。在运行项目时,你需要确保该文件位于系统属性 java.library.path 列出的路径之一,或者直接设置该属性以包含你的位置。在 Mac 上,一个示例默认目录是 ~/Library/Java/Extensions/。代码生成完成并且库安装后,我们可以继续进行一些 Java 编程。

Panama 的目标是提供与我们要链接的 C 库中符号的名称(以及参数类型的 Java 版本)相匹配的 Java 静态方法。因此,生成的 Java 代码中的符号将遵循 C 命名约定,并且看起来不太像 Java 名称。

对于 Java 程序员来说,整体印象是我们直接调用 C 函数(尽可能直接)。实际上,底层发生了一些巴拿马魔法,使用方法句柄等技术来隐藏复杂性。在正常情况下,大多数开发者不需要担心巴拿马的确切工作细节。

让我们看看一个例子:一个使用 C 库从 PNG 文件读取一些基本数据的程序。我们将把这个代码设置为一个合适的模块化构建。模块描述符module-info.java看起来像这样:

module wgjd.png {
  exports wgjd.png;

  requires jdk.incubator.foreign;
}

代码包括org.libspng包,这是我们自动从 C 代码生成的,以及单个导出包wgjd.png。它包含一个文件,我们将完整展示,因为导入等对于理解正在发生的事情很重要:

package wgjd.png;

import jdk.incubator.foreign.MemoryAddress;                                ❶
import jdk.incubator.foreign.MemorySegment;                                ❶
import jdk.incubator.foreign.SegmentAllocator;                             ❶
import org.libspng.spng_ihdr;

import static jdk.incubator.foreign.CLinker.toCString;
import static jdk.incubator.foreign.ResourceScope.newConfinedScope;
import static org.libspng.spng_h.*;

public class PngReader {
    public static void main(String[] args) {
        if (args.length < 1) {
            System.err.println("Usage: pngreader <fname>");
            System.exit(1);
        }

        try (var scope = newConfinedScope()) {
            var allocator = SegmentAllocator.ofScope(scope);

            MemoryAddress ctx = spng_ctx_new(0);                           ❷
            MemorySegment ihdr = allocator.allocate(spng_ihdr.$LAYOUT());

            spng_set_crc_action(ctx, SPNG_CRC_USE(),                       ❸
                                     SPNG_CRC_USE());

            int limit = 1024 * 1024 * 64;                                  ❹
            spng_set_chunk_limits(ctx, limit, limit);                      ❷

            var cFname = toCString(args[0], scope);                        ❺
            var cMode = toCString("rb", scope);                            ❺
            var png = fopen(cFname, cMode);                                ❻
            spng_set_png_file(ctx, png);                                   ❷

            int ret = spng_get_ihdr(ctx, ihdr);                            ❷

            if (ret != 0) {
                System.out.println("spng_get_ihdr() error: " +
                                   spng_strerror(ret));
                System.exit(2);
            }

            final String colorTypeMsg;
            final byte colorType = spng_ihdr.color_type$get(ihdr);

            if (colorType ==
                  SPNG_COLOR_TYPE_GRAYSCALE()) {                           ❸
                colorTypeMsg = "grayscale";
            } else if (colorType ==
                  SPNG_COLOR_TYPE_TRUECOLOR()) {                           ❼
                colorTypeMsg = "truecolor";
            } else if (colorType ==
                  SPNG_COLOR_TYPE_INDEXED()) {                             ❼
                colorTypeMsg = "indexed color";
            } else if (colorType ==
                  SPNG_COLOR_TYPE_GRAYSCALE_ALPHA()) {                     ❼
                colorTypeMsg = "grayscale with alpha";
            } else {
                colorTypeMsg = "truecolor with alpha";
            }

            System.out.println("File type: " + colorTypeMsg);
        }
    }
}

❶ 使用 C 风格内存管理的巴拿马课程

❷ spng.h 中 C 函数的 Java 包装器

❸ C 常量的 Java 包装器

❹ 以 64 M 块读取数据

❺ 将 Java 字符串的内容复制到 C 字符串中

❻ C 标准库函数的 Java 包装器

❼ C 常量的 Java 包装器

这是通过一个 Gradle 构建脚本构建的,如下所示:

plugins {
  id("org.beryx.jlink") version("2.24.2")
}

repositories {
  mavenCentral()
}

application {
  mainModule.set("wgjd.png")
  mainClass.set("wgjd.png.PngReader")
}

java {
    modularity.inferModulePath.set(true)
}

sourceSets {
  main {
    java {
      setSrcDirs(listOf("src/main/java/org",
                        "src/main/java/wgjd.png"))
    }
  }
}

tasks.withType<JavaCompile> {
  options.compilerArgs = listOf()
}

tasks.jar {
  manifest {
    attributes("Main-Class" to application.mainClassName)
  }
}

可以像这样执行:

$ java --add-modules jdk.incubator.foreign \
    --enable-native-access=ALL-UNNAMED \
    -jar build/libs/Panama.jar <FILENAME>.png

这应该会输出一些基本元数据,关于我们的图像文件。

在巴拿马中处理原生内存

处理内存的一个关键方面是原生内存的生命周期问题。C 没有垃圾回收器,所以所有内存都必须手动分配和释放。这当然是极其容易出错的,并且对于 Java 程序员来说一点也不自然。

为了解决这个问题,巴拿马提供了几个类,这些类用作 C 内存管理操作的 Java 句柄。关键是ResourceScope类,它可以用来提供确定性清理。这通常通过 Java 的try-with-resources 方式处理。例如,前面的代码使用了本地内存处理的词法作用域生命周期:

try (var scope = newConfinedScope()) {
    var allocator = SegmentAllocator.ofScope(scope);

    // ...

}

allocator对象是SegmentAllocator接口实现的一个实例。它通过工厂方法从作用域创建,然后我们可以从分配器创建MemorySegment对象。

实现MemorySegment接口的对象代表连续的内存块。通常,这些将由原生内存块支持,但也可以使用堆上的数组来支持内存段。这与 Java NIO API 中的ByteBuffer的情况类似。

注意:巴拿马 API 还包含MemoryAddress,它实际上是一个 C 指针的 Java 包装器(以long值表示)。

当作用域自动关闭时,分配器将被回调以确定性地释放和释放它所持有的任何资源。这就是资源获取即初始化(或 RAII)模式在 Java 中使用try-with-resources 实现,并将其带入原生代码的方式。作用域和分配器对象持有原生资源的引用,并在 TWR 块退出时自动释放它们。

或者,这可以通过隐式处理,当MemorySegment对象被垃圾回收时,原生内存将被清理。当然,这意味着清理是非确定性的,无论 GC 何时运行。一般来说,建议使用显式作用域,特别是如果你不熟悉处理堆外内存的潜在陷阱。

在撰写本文时,jextract只理解 C 头文件。这意味着,目前,要从其他原生语言(例如,Rust)中使用它,你必须首先生成一个 C 头文件。理想情况下,将会有一个自动的工具来生成这些文件,它的工作方式类似于rust-bindgen工具,但方向相反。

更广泛地说,随着时间的推移,jextract可能会为其他语言提供更多的支持。该工具基于 LLVM,它已经是语言无关的,因此,从理论上讲,它应该可以扩展到 LLVM 所知的任何语言,并且可以处理 C 函数调用约定。

外部 API 即将作为孵化功能发布第二个版本(参见第一章中对孵化功能和预览功能的描述),作为 Java 18 的一部分。希望它能在 2022 年 9 月的 Java 19 中成为最终的标准功能。

向量 API 并不像其他 API 那样先进,主要是因为 API 设计者决定他们宁愿等待 Project Valhalla(见本章后面的内容)的功能可用。因此,这个 API 将不会在 Valhalla 作为标准功能可用之前离开孵化状态。

18.3 Project Loom

用它自己的话说,OpenJDK 的Project Loom是关于

在 Java 平台上,易于使用、高吞吐量、轻量级并发以及新的编程模型。

—Project Loom, wiki.openjdk.org/display/loom/Main

为什么需要这种新的并发方法?让我们从更历史的角度来考虑 Java。

考虑 Java 的一个有趣的方式是,它是一个 20 世纪 90 年代末的语言和平台,对软件演化的方向做出了许多有见地的、战略性的赌注。从 2022 年的角度来看,这些赌注在很大程度上已经得到了回报(当然,是靠运气还是靠判断,这当然是一个值得讨论的问题)。

例如,考虑线程。Java 是第一个将线程内置于核心语言的流行编程平台。在引入线程之前,最先进的技术是使用多个进程和各种不令人满意的机制(比如 Unix 共享内存)来在它们之间进行通信。

在操作系统级别,线程是独立调度的执行单元,属于一个进程。每个线程都有一个执行指令计数器和调用栈,但与同一进程中的每个其他线程共享堆。

不仅如此,Java 堆只是进程堆的单个连续子集(至少在 HotSpot 实现中是这样——其他 JVM 可能不同),因此线程在操作系统级别的内存模型自然地延续到 Java 语言领域。

线程的概念自然地导致了一个轻量级上下文切换的概念。在同一进程中切换两个线程比其他方式更便宜。这主要是因为将虚拟内存地址转换为物理地址的映射表对于同一进程中的线程来说大部分是相同的。

注意:创建线程也比创建进程便宜。这种说法的确切程度取决于所讨论的操作系统细节。

在我们的情况下,Java 规范并没有强制要求 Java 线程和操作系统(OS)线程之间有任何特定的映射(假设宿主操作系统甚至有一个合适的线程概念,这并不总是如此)。事实上,在非常早期的 Java 版本中,JVM 线程被多路复用到 OS(也称为平台)线程上,这被称为绿色线程M:1 线程(因为实现实际上只使用了单个平台线程)。

然而,这种做法在 Java 1.2/1.3 时代(以及在 Sun Solaris OS 上稍微早一些)就消失了,而主流操作系统上运行的现代 Java 版本则实现了规则:一个 Java 线程 = 精确的一个操作系统线程。调用Thread.start()会调用线程创建系统调用(例如,Linux 上的clone())并实际上创建一个新的 OS 线程。

OpenJDK 的 Project Loom 的主要目标是启用新的Thread对象,这些对象可以执行代码,但并不对应于专用的 OS 线程,或者换句话说,创建一个执行模型,其中表示执行上下文的对象不一定是需要由操作系统调度的事物。

因此,在某种程度上,Loom 是回归到类似于绿色线程的东西。然而,在这段时间里,世界发生了很大的变化,有时在计算中,有些想法是超越时代的。

例如,可以将企业 JavaBeans(EJBs)视为一种虚拟化/受限的环境,它试图过度虚拟化环境。它们是否可以被视为后来在现代 PaaS 系统中(以及在一定程度上在 Docker/K8s 中)受到青睐的想法的原型形式?

因此,如果 Loom 是(部分)回归到绿色线程的想法,那么一种接近它的方法可能是通过询问:“环境中的哪些变化使得回归到过去被认为没有用处的旧想法变得有趣?”

为了稍微探讨这个问题,让我们看看一个例子。具体来说,让我们尝试通过创建过多的线程来崩溃 JVM。除非你准备好可能发生的崩溃,否则你不应该运行这个例子中的代码:

//
// Do not actually run this code... it may crash your JVM or laptop
//
public class CrashTheVM {
    private static void looper(int count) {
        var tid = Thread.currentThread().getId();
        if (count > 500) {
            return;
        }
        try {
            Thread.sleep(10);
            if (count % 100 == 0) {
                System.out.println("Thread id: "+ tid +" : "+ count);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        looper(count + 1);
    }

    public static Thread makeThread(Runnable r) {
        return new Thread(r);
    }

    public static void main(String[] args) {
        var threads = new ArrayList<Thread>();
        for (int i = 0; i < 20_000; i = i + 1) {
            var t = makeThread(() -> looper(1));
            t.start();
            threads.add(t);
            if (i % 1_000 == 0) {
                System.out.println(i + " thread started");
            }
        }
        // Join all the threads
        threads.forEach(t -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
}

代码启动了 20,000 个线程,并在每个线程中执行最小量的处理——或者试图这样做。在实践中,它通常会在达到稳定状态之前就死亡或锁定机器。

注意:如果机器或操作系统被限制并且不能快速创建线程以引起资源饥饿,则可以将示例运行到完成。

虽然这显然并不完全具有代表性,但这个例子旨在表明,例如,对于每个连接一个线程的 Web 服务环境会发生什么。一个现代高性能 Web 服务器能够处理 20,000 个并发连接是完全合理的,而这个例子清楚地表明,对于这种情况,按连接分配线程的架构失败了。

注意:另一种思考 Loom 的方式是,现代 Java 程序可能需要跟踪比它可以创建的线程更多的可执行上下文。

另一种可能的启示可能是,线程可能比我们想象的要昂贵得多,并且代表了现代 JVM 应用程序的扩展瓶颈。开发者已经尝试了多年解决这个问题,要么通过降低线程的成本,要么通过使用不是线程的执行上下文的表示。

实现这一目标的一种方法是通过 SEDA 方法(阶段事件驱动架构)——大致来说,这是一个将域对象从一个阶段移动到另一个阶段,并在途中发生各种不同转换的多阶段管道系统。这可以通过使用消息系统在分布式系统中实现,或者在一个进程中实现,使用阻塞队列和每个阶段的线程池。

在每个步骤中,域对象的处理由一个包含实现步骤转换的代码的 Java 对象描述。为了正确工作,代码必须保证能够终止——没有无限循环——而这不能由框架强制执行。

这种方法有一些明显的缺点——首先是程序员为了有效地使用架构所必需的纪律。让我们看看一个更好的替代方案。

18.3.1 虚拟线程

Project Loom 旨在通过向 JVM 添加以下新结构来为今天的超大规模应用程序提供更好的体验:

  • 虚拟线程

  • 分界性延续

  • 尾调用消除

这个关键方面是虚拟线程。它们被设计成对程序员来说看起来就像“只是线程”。然而,它们是由 Java 运行时管理的,并且不是轻量级的、一对一的操作系统线程的包装器。相反,它们是由 Java 运行时在用户空间中实现的。虚拟线程旨在带来的主要优势包括

  • 创建和阻塞它们是廉价的。

  • 可以使用标准的 Java 执行调度器(线程池)。

  • 对于栈不需要 OS 级别的数据结构。

移除操作系统在虚拟线程生命周期中的参与是消除可扩展性瓶颈的原因。我们的 JVM 应用程序可以处理数百万甚至数十亿个对象——那么为什么我们只限于几千个可由操作系统调度的对象(这是思考线程的一种方式)呢?打破这一限制并解锁新的并发编程风格是 Project Loom 的主要目标。

让我们看看虚拟线程的实际应用。下载 Loom 测试版(jdk.java.net/loom/),并像这样启动 jshell(启用预览模式以激活 Loom 功能):

$ jshell --enable-preview
|  Welcome to JShell -- Version 18-loom
|  For an introduction type: /help intro

jshell> Thread.startVirtualThread(() -> {
   ...>     System.out.println("Hello World");
   ...> });
Hello World
$1 ==> VirtualThread[<unnamed>,<no carrier thread>]

jshell>

我们可以直接在输出中看到虚拟线程结构。我们还在使用一个新的静态方法 startVirtualThread() 来在一个新的执行上下文中启动 lambda,这是一个虚拟线程。简单!

通用规则必须是,现有的代码库必须继续以它们在 Loom 之前的方式运行。或者,换句话说,使用虚拟线程必须是可选的。我们必须做出保守的假设,即所有现有的 Java 代码真正需要的是,直到现在,城镇中唯一的游戏——在操作系统线程之上的轻量级包装器。

虚拟线程的出现以其他方式开辟了新的天地。到目前为止,Java 语言提供了以下两种创建新线程的主要方式:

  • 继承 java.lang.Thread 并调用继承的 start() 方法。

  • 创建一个 Runnable 实例,并将其传递给 Thread 构造函数,然后启动生成的对象。

如果线程是什么的概念将会改变,那么重新审视我们用来创建线程的方法也是有意义的。我们已经遇到了为 fire-and-forget 虚拟线程提供的新静态工厂方法,但现有的线程 API 还需要在其他几个方面进行改进。

18.3.2 线程构建器

一个重要的新概念是 Thread.Builder 类,它被添加为 Thread 的内部类。Thread 中添加了两个新的工厂方法,以提供对平台和虚拟线程构建器的访问,如下所示:

jshell> var tb = Thread.ofPlatform();
tb ==> java.lang.ThreadBuilders$PlatformThreadBuilder@312b1dae

jshell> var tb = Thread.ofVirtual();
tb ==> java.lang.ThreadBuilders$VirtualThreadBuilder@506e1b77

让我们通过替换我们示例中的 makeThread() 方法来查看构建器的实际应用:

    // Loom-specific code
    public static Thread makeThread(Runnable r) {
        return Thread.ofVirtual().unstarted(r);
    }

这调用 ofVirtual() 方法来显式创建一个将执行我们的 Runnable 的虚拟线程。当然,我们也可以使用 ofPlatform() 工厂方法,我们最终会得到一个传统的、可由操作系统调度的线程对象。但那样有什么乐趣呢?

如果我们用虚拟版本的 makeThread() 替换,并使用支持 Loom 的 Java 版本重新编译我们的示例,那么我们可以执行生成的代码。这次,程序运行完成且没有问题。这是一个 Loom 哲学在行动中的好例子——将应用程序需要更改的代码位置局部化。

新的线程库鼓励开发者从旧范式迁移的一种方式是,Thread 的子类不能是虚拟的。因此,子类化 Thread 的代码将继续使用传统的操作系统线程来创建。

注意:随着时间的推移,随着虚拟线程变得更加普遍,开发者不再关心虚拟和操作系统线程之间的区别,这应该会阻止使用子类化机制,因为它总是会创建一个可由操作系统调度的线程。

目的是保护使用 Thread 子类的现有代码,并遵循最小惊讶原则。

线程库的其他部分也需要升级,以更好地支持 Loom。例如,ThreadBuilder 也可以构建可以传递给各种 Executors(如这样)的 ThreadFactory 实例:

jshell> var tb = Thread.ofVirtual();
tb ==> java.lang.ThreadBuilders$VirtualThreadBuilder@312b1dae

jshell> var tf = tb.factory();
tf ==> java.lang.ThreadBuilders$VirtualThreadFactory@506e1b77

jshell> var tb = Thread.ofPlatform();
tb ==> java.lang.ThreadBuilders$PlatformThreadBuilder@1ddc4ec2

jshell> var tf = tb.factory();
tf ==> java.lang.ThreadBuilders$PlatformThreadFactory@b1bc7ed

虚拟线程需要附加到实际的操作系统线程才能执行。这些执行虚拟线程的操作系统线程被称为 载体线程。我们已经在之前的一些示例中看到过载体线程。然而,在其生命周期内,单个虚拟线程可能运行在几个不同的载体线程上。这有点类似于常规线程随着时间的推移在不同的物理 CPU 核心上执行的方式——两者都是执行调度的例子。

18.3.3 使用虚拟线程进行编程

虚拟线程的出现带来了思维方式的转变。那些用今天存在的 Java 编写并发应用程序的程序员已经习惯了(有意识地或无意识地)处理线程固有的扩展限制。

我们习惯于创建任务对象,通常基于 RunnableCallable,并将它们传递给由线程池支持的执行器,以保存我们宝贵的线程资源。如果所有这些都突然变得不同会怎样?

从本质上讲,Project Loom 通过引入一个比现有概念更便宜且不直接映射到操作系统线程的新线程概念,试图解决线程的扩展限制。然而,这种新功能仍然看起来和表现像一个线程,就像 Java 程序员已经理解的那样。

与要求开发者学习全新的编程风格(如延续传递风格或 Promise/Future 方法或回调)相比,Loom 运行时保留了我们从今天线程中熟悉的相同编程模型,用于虚拟线程;对于程序员来说,虚拟线程就是线程。

虚拟线程是抢占式的,因为用户代码不需要显式地释放。调度点由虚拟调度器和 JDK 决定。用户无需假设它们何时发生,因为这完全是实现细节。然而,了解支撑调度的操作系统理论基础,有助于理解虚拟线程的不同之处。

当操作系统调度平台线程时,它会为线程分配一个时间片的 CPU 时间。当时间片结束时,会生成一个硬件中断,内核能够恢复控制,移除正在执行的平台(用户)线程,并用另一个线程替换它。

注意:这种机制是 Unix(以及其他操作系统)能够在不同任务之间实现处理器时间共享的原因——甚至在计算机只有一个处理核心的几十年前就已经如此。

虚拟线程与平台线程的处理方式不同。现有的虚拟线程调度器都不使用时间片来抢占虚拟线程。

注意:使用时间片来抢占虚拟线程是可能的,并且虚拟机已经能够控制执行 Java 线程——例如,在 JVM 安全点时。

相反,当进行阻塞调用(如 I/O)时,虚拟线程会自动放弃(或释放)其承载线程。这由库和运行时处理,并且不在程序员的显式控制之下。

因此,Loom 而不是强迫程序员显式管理释放,或者依赖于非阻塞或基于回调的操作的复杂性,允许 Java 程序员以传统的、线程顺序的方式编写代码。这带来了额外的优势,例如允许调试器和性能分析器以通常的方式工作。工具制造商和运行时工程师需要做一些额外的工作来支持虚拟线程,但这比强迫最终用户 Java 开发者承担额外的认知负担要好。特别是,这种方法与其他一些编程语言采用的async/await方法不同。

Loom 的设计者预期,由于虚拟线程无需池化,因此它们不应该池化,而是采用无约束的虚拟线程创建模式。为此,增加了一个无界执行器。可以通过新的工厂方法访问它,即Executors.newVirtualThreadPerTaskExecutor()。虚拟线程的默认调度器是ForkJoinPool中引入的工作窃取调度器。

注意:Fork/Join 的工作窃取方面已经变得比任务的递归分解更为重要。

Loom 的当前设计基于开发者理解他们应用程序中不同线程上存在的计算开销。简单来说,如果大量线程需要持续的大量 CPU 时间,那么你的应用程序将面临资源紧张,即使是巧妙的调度也无法帮助。另一方面,如果只有少数线程预期会变成 CPU 绑定,那么这些线程应该被放置在单独的池中,并分配平台线程。

虚拟线程也旨在在存在许多偶尔仅 CPU 绑定的线程的情况下表现良好。目标是工作窃取调度器将平滑 CPU 利用率,并且现实世界的代码最终会调用一个传递 yield 点的操作(例如阻塞 I/O)。

18.3.4 Project Loom 何时到来?

Loom 的开发正在进行一个单独的仓库中,而不是在 JDK 主线中。早期访问的二进制文件是可用的,但这些仍然有一些粗糙的边缘——崩溃仍然发生,但变得越来越不常见。基本 API 正在成形,但几乎可以肯定还没有完全最终确定。

JEP 425 (openjdk.java.net/jeps/425) 已被提交以将虚拟线程作为预览功能进行集成,但在撰写本文时,此 JEP 尚未针对任何版本进行目标定位。有理由假设,如果它不在 Java 19 中作为预览功能包含,那么该功能的最终版本可能不会作为 Java 21(可能是下一个 Java LTS 版本)的一部分提供。在虚拟线程之上构建的 API 还有很多工作要做,例如结构化并发和其他更高级的功能。

开发者总是有一个关键问题,那就是性能,但在新技术开发的早期阶段,这总是很难回答。对于 Loom,我们还没有达到可以进行有意义的比较的阶段,并且当前的性能并不被认为是最终版本的真实反映。

就像 OpenJDK 中的其他长期项目一样,真正的答案是它准备好了才会准备好。目前,已经有一个足够的原型可以开始实验它,并尝尝未来 Java 开发可能的样子。让我们把注意力转向我们正在讨论的四个主要 OpenJDK 项目中的最后一个:Valhalla。

18.4 Project Valhalla

为了使 JVM 内存布局行为与现代硬件的成本模型相一致。

—Brian Goetz

要理解当前 Java 内存布局模型达到极限并开始崩溃的地方,让我们从一个例子开始。在图 18.1 中,我们可以看到一个原始整型数组。因为这些值是原始类型而不是对象,所以它们被布局在相邻的内存位置。

图 18.1 原始整型数组

为了看到与对象数组的区别,让我们将其与装箱整数的情况进行对比。一个Integer对象数组将是一个引用数组,如图 18.2 所示。

图片

图 18.2 Integer对象数组

因为每个Integer都是一个对象,所以它需要有一个对象头,正如我们在上一章中解释的那样。我们有时说每个对象都需要支付作为 Java 对象带来的“头税”。

超过 20 年来,这种内存布局模式一直是 Java 平台运行的方式。它具有简单性的优势,但存在性能权衡:处理对象数组涉及不可避免的指针间接引用和相关的缓存未命中。

例如,考虑一个表示三维空间中点的类,一个Point3D类型。它实际上只包含三个空间坐标,并且从 Java 17 开始,可以表示为一个具有三个字段(或等效的记录)的对象类型,如下所示:

public final class Point3D {
    private final double x;
    private final double y;
    private final double z;

    public Point3D(double a, double b, double c) {
        x = a;
        y = b;
        c = z;
    }

    // Additional methods, e.g getters, toString() etc.
}

在 HotSpot 中,这些点对象的数组在内存中布局,如图 18.3 所示。

图片

图 18.3 Point3D数组

在处理这个数组时,每个元素都是通过额外的间接引用来访问的,以获取每个点的坐标。这导致数组中每个点都会发生缓存未命中,没有任何合理的理由而降低性能。

对于非常关注性能的程序员来说,能够定义在内存中更有效地布局的类型将非常有用。我们还应该注意,当与Point3D值一起工作时,对象身份对程序员来说并没有真正的益处,因为两个点只有在所有字段都相等的情况下才应该相等。

此示例演示了以下两个独立的编程概念,这两个概念都通过移除对象身份而得以实现:

  • 堆扁平化—对于无身份对象移除指针间接引用,从而提高内存密度

  • 标量化—将无身份对象分解为字段并在需要时重新构建的能力

这些独立的属性将对无身份对象的用户模型产生后果。

注意:标量化——JVM 分解和重新构建值对象的能力,出人意料地有用。JVM 包含一种称为逃逸分析的 JIT 技术,可以从将值对象拆分为其单个字段并将它们分别通过代码的自由中受益。

记住这些属性,我们也可以从以下问题开始接近 Valhalla:“我们能否避免支付头税?”广泛地说,答案是肯定的,前提是以下条件成立:

  • 对象不需要身份的概念。

  • 该类是最终的,因此所有方法调用的目标都可以在类加载时知道。

基本上,第一个属性消除了对头部标记词的需要,第二个大大减少了对于 klass 词的需求(参见第四章和第十七章了解更多关于 klass 词的内容)。

当对象在堆上时,klass 词仍然是必需的,除非它已经被作为另一个对象的实例字段或作为数组元素扁平化,因为对象字段布局可能需要被描述——例如,以便 GC 可以遍历对象图。然而,当对象被标量化后,我们可以丢弃头部。

从开发者的角度来看,因此,Valhalla 的主要成果之一是 Java 生态系统中出现了一种新的值形式,被称为值对象,它们是值类的实例。这些新类型被认为是(通常是)小、不可变、无身份的类型。

音符值类在其发展过程中被赋予了多个不同的名称,包括原始类内联类型。给事物命名是困难的,尤其是在一个成熟的语言中,它可能已经使用了许多语言概念的常见名称。

值类的示例用例包括

  • 新的数值类型,如无符号字节、128 位整数和半精度浮点数

  • 复数、颜色、向量以及其他多维数值

  • 带单位的数字:大小、温度、速度、现金流等等

  • 映射条目、数据库行和多返回类型

  • 不可变游标、子数组、中间流以及其他数据结构视图抽象

还有可能一些现有的类型可以被改造并演变成表示为值类。例如,Optionaljava.time中的许多类型显然是未来版本中可能成为值类的候选者,如果证明这是可行的。

注意记录本身与值类没有直接关系,但高度可能有许多记录是聚合体,不需要身份,因此值记录的概念可能非常有用。

如果这种新的值形式可以在 JVM 上实现,那么对于像我们所讨论的空间点这样的类,一个如图 18.4 所示的扁平化内存布局将更加高效。

图片

图 18.4 内联点数组

这种内存布局将接近 C 程序员所认识到的struct数组,但不会暴露低级内存访问的全部危险。扁平化布局不仅减少了内存占用,还减轻了垃圾收集器的负担。

18.4.1 改变语言模型

需要做的最大改变是修改java.lang.Object作为通用超类的概念,因为它上面有wait()notify()这样的方法,这些方法本质上与对象身份相关联。没有对象头,就没有标记词来存储对象的监视器,也没有可以等待的东西。对象实际上也没有一个明确的生存期,因为它可以被自由复制,并且产生的副本是无法区分的。相反,在java.lang中定义了两个新的接口:IdentityObjectValueObject。JEP 401(见openjdk.org/jeps/401)详细描述了值对象,但基本上所有值类都隐式实现了ValueObject

所有身份类都将隐式实现IdentityObject,所有现有的具体类都作为身份类选择加入。现有的接口和(大多数)抽象类不扩展这两个新接口。如果 API 设计者的功能与新语义不兼容,他们可能希望更新他们的接口以显式扩展IdentityObject

值类是final的,不能是abstract的。它们不能实现(直接或间接地)IdentityObject。可以使用instanceof测试来检查一个对象是否是值对象。

注意 除了不能在值对象上调用wait()notify()之外,也不可能拥有同步方法或块,因为值对象没有监视器。

Object本身将经历一些微妙的位置调整,因为它将不实现IdentityObjectValueObject,并将更类似于抽象类或接口。如下代码所示

var o = new Object();

这也将改变意义——预计o将包含Object的某个匿名子类的实例,出于向后兼容性的原因,它将被理解为身份类。

虽然值类的初始目标似乎很明确,但它实际上有一些深远的影响。为了使项目成功,有必要考虑引入第三种值形式的逻辑结论。

18.4.2 值对象的影响

值对象的赋值具有相当明显的语义:位被复制,就像原始类型(引用的赋值也复制位,但那种情况下,我们最终会得到两个指向同一堆位置的引用)。然而,如果我们需要构建一个与原始对象不完全相同但经过修改的副本的值对象会发生什么呢?

回想一下,值对象是不可变的——它们只有final字段。这意味着它们的州不能通过putfield操作来改变。相反,需要另一种机制来生成一个与原始对象状态不同的值对象。为了实现这一点,需要一些新的字节码,如下所示:

  • aconst_init

  • withfield

新的withfield指令本质上相当于字节码级别的 wither 方法的使用(我们在第十五章中讨论过)。

另一个新的指令,aconst_init,为值类的一个实例提供一个默认值。让我们更深入地了解一下为什么需要这个。

到目前为止,在 Java 中,原始类型和对象引用都被理解为具有与“所有位为零”相对应的默认值,而null被理解为引用的零位的意义。然而,当我们尝试将这些语义扩展到处理值对象时,我们发现存在两个相关的问题:

  • 一些值类没有好的默认值选择。

  • 存在着值撕裂的可能性。

没有好的默认值的问题实际上归结于想要能够说值对象实际上还不是值,但 Java 已经有了一种方法来做这件事:null。此外,用户已经习惯了在值未初始化时处理null指针异常(NPEs)。

第二个问题,撕裂,实际上是一个老问题的新形式。在 Java 的旧版本中,在 32 位硬件上运行时,处理 64 位值(如 longs)时有一些微妙的问题。特别是,对 longs 的写入作为两个独立的(32 位)写入执行,并且读取线程可能会观察到只完成了一个 32 位写入的状态。这会使读取线程观察到 long 的“撕裂”值——既不是之前的状态也不是之后的状态。

值类型有可能重新引入这个问题。如果值可以被标量化,那么我们如何保证写入的原子性?

解决方案是认识到值类代表的不只是一种新的数据形式,而是两种。如果我们想避免撕裂,我们需要使用引用。这是一个已经确立的想法,即使用间接层允许我们更新值而不撕裂它们。

此外,考虑一些类可能没有合理的默认值,这些值可以对应于零位。例如,当LocalDate迁移到值类后,它的默认值会是什么?有些人可能会争论零位应该被解释为零偏移量(即,1970 年 1 月 1 日),但这似乎很容易出错。

这引出了无身份引用的概念——基本上,我们移除了对象的身份。在底层,这仍然允许在 JIT(例如,值的标量化传递)和 JIT 代码中进行标量化优化,但放弃了堆上的内存改进。这些对象总是通过引用来处理,就像身份对象一样,并且它们有一个简单的默认值null。然后,通过构造函数或工厂方法设置非平凡默认值,正如它应该的那样。

此外,对于高级用例,也存在原始值类型。这些类型更像是内置的“真正原始类型”,允许在堆中扁平化以及与值对象一起允许的标量化。然而,额外的利益伴随着相关的成本——需要接受零位作为默认值,以及在可能存在数据竞争的更新中可能会撕裂的可能性。

注意:原始值类型实际上仅适用于小型值(在今天的硬件上为 64-128 位或更少),在用它们编程时需要额外的注意。

撕裂确实使用户面临潜在的安全问题,尽管人们可能会倾向于认为这是一个只有“坏”程序(存在数据竞争)的问题。无论如何,它是一种新的可能的并发错误形式,需要锁定来正确防御。

原始值类的另一个方面是,运行时需要知道如何在内存中布局它们。因此,不可能创建一个引用声明类的原始值类字段(无论是直接还是间接)。换句话说,原始值类的实例不能包含原始值类的循环数据结构——它们必须有固定大小的布局,这样它们才能在堆中扁平化。总的来说,预期是大多数用户将想要使用无身份的值对象,而扩展的原始类型将很少使用。

为了总结本节,让我们看看值类在字节码中表示的另一个方面。回想一下,在第四章中,我们遇到了类型描述符的概念。通过L 类型描述符表示身份引用类型,例如字符串的Ljava/lang/String;

为了描述原始类的值,正在添加一个新的基本类型,即Q 类型描述符。以Q开头的描述符与 L 描述符具有相同的结构(例如,对于原始类Point3DQPoint3D;)。Q 和 L 值都由同一组字节码操作,即以a开头的字节码,例如aloadastore

通过 Q 描述符(有时称为“裸”对象)引用的值与值对象的引用有以下主要区别:

  • 值对象的引用,如所有对象引用一样,可以是null,而裸值则不能。

  • 引用加载和存储是相对于彼此原子的,而足够大的裸值加载和存储可能会撕裂,就像在 32 位实现中的长和双精度浮点数一样。

  • 如果通过 Q 描述符的路径链接,对象图可能不是循环的:类C在其布局中不能直接或间接地引用QC;

  • 由于技术原因,JVM 需要比由 L 描述符命名的类更早地加载 Q 描述符中命名的类。

这些属性基本上是我们已经遇到的一些值和原始对象属性的字节码级编码。

在从 Valhalla 移动之前,我们应该简要考虑一个最终的问题:需要重新审视泛型类型的问题。这作为引入值和原始对象的自然结果而出现。

18.4.3 泛型重访

如果 Java 要包含值类,自然会提出一个问题:值类是否可以在泛型类型中使用,例如,作为类型参数的值。如果不能,这似乎会极大地限制该特性的有用性。因此,高级设计始终包括假设值类最终将以增强泛型形式作为类型参数的值。

幸运的是,在 Valhalla 中 Object 的角色发生了微妙的变化——它被追溯性地修改为值对象和身份对象的超类。这使我们能够将值对象纳入现有泛型的范畴。然而,将原始类型整合到这个模型中也是可取的。

长期目标是能够扩展泛型——允许对所有类型进行抽象,包括值类和现有的原始类型(以及 void)。如果这个项目要成功,我们需要能够兼容地演进现有的库——特别是 JDK 库——以充分利用这些特性。

在一定程度上,这项工作还涉及更新基本值类型(intboolean等)以成为原始值类,从而使基本原始值成为原始对象。这也意味着包装类将被重新用于适应原始类模型。

这种泛型扩展将为原始类型产生一种 泛型特化 形式。这引入了类似于在其他语言中找到的泛型编程系统的方面,例如 C++ 中的模板。在撰写本文时,泛型工作仍处于早期阶段,所有与之相关的 JEPs 都仍处于草案状态。

18.5 Java 18

任何试图具有前瞻性的文本的本质在于,它在被阅读时不可避免地会过时。在撰写本文时,Java 17 已经发布,Java 18 于 2022 年 3 月发布。以下 JEPs 针对 Java 18,并构成了新版本的内容:

  • JEP 400 默认使用 UTF-8

  • JEP 408 简单 Web 服务器

  • JEP 413 Java API 文档中的代码片段

  • JEP 416 重新实现核心反射使用方法句柄

  • JEP 417 向量 API(第三孵化器)

  • JEP 418 互联网地址解析 SPI

  • JEP 419 外部函数和内存 API(第二次孵化器)

  • JEP 420 switch 的模式匹配(第二次预览)

  • JEP 421 废弃最终化以进行移除

在这些中,UTF-8 的变化、核心反射(Core Reflection)的变化以及最终化(finalization)的弃用是一些内部变化,它们提供了一些整理和简化,可以在未来的版本中构建。

向量(Vector)和外部(Foreign)API 的更新是通往巴拿马(Panama)之旅的下一个里程碑,而模式匹配(Pattern Matching)的下一版本是琥珀(Amber)的下一步。Java 18 不包含任何 JEPs,它们提供 Loom 或 Valhalla 的任何部分。在撰写本文时,尚未有任何确认,但据传闻,Loom 的第一个版本将在 Java 19(预计于 2022 年 9 月发布)中作为预览功能提供。

摘要

Java 的一个原始设计原则是,语言应该谨慎地进化——一个语言特性应该在完全理解其对整个语言的影响之前不应实现。其他语言可以,并且确实比 Java 进化得更快,这有时会导致开发者抱怨“Java 需要更快地进化”。然而,这一面的反面是,其他语言可能会“快速前进,然后悠闲地反省”。一旦一个有缺陷的设计被整合到语言中,它基本上就永远存在了。

相反,Java 的做法是谨慎行事——在做出承诺之前,确保理解一个特性,包括其所有后果。让其他语言开辟新天地(或者,愤世嫉俗者可能会说,首先“冲在最前面”),然后看看可以从他们的实验中得出什么结论。

实际上,这种影响——语言概念的相互借鉴——是语言设计中的常见特征。它也提供了一个很好的例子,有时这种观点被表达为“伟大的艺术家偷窃”。这句话通常被归功于史蒂夫·乔布斯,但他并非发明者——他只是从其他思想家那里借鉴(或窃取)了它。

实际上,这个想法似乎被发明了多次,但可以明确追踪的一种原始形式是这样的:

最可靠的测试之一是诗人借鉴的方式。不成熟的诗人在模仿;成熟的诗人在窃取;糟糕的诗人在他们所取的东西上留下污点;而优秀的诗人在他们所取的东西上创造出更好的东西,或者至少是不同的东西。

——T. S. 埃利奥特

埃利奥特的观点同样适用于语言设计者,就像适用于诗人一样。真正伟大的编程语言(以及语言设计者)会自由地相互借鉴(或窃取)。首先在一个语言中表达的好想法并不会仅仅局限于那里——事实上,这正是我们知道这个想法最初就是好的原因之一。

在这一章的结尾,我们遇到了 OpenJDK 中的四个主要正在进行的项目。总的来说,它们的目的是提供未来 Java 的一个根本不同的版本。其中一些项目非常雄心勃勃,而另一些则更为谨慎。它们都将作为 Java 正常发布节奏的一部分交付。我们一年或三年后编写的 Java 可能看起来与我们今天编写的大相径庭。

我们可能会猜测,以下主要方面将会发生改变:

  • 面向对象和函数式编程的融合——Amber 引入了新的语言特性,使这些模型趋于一致。

  • 线程处理——Loom 将引入一个新的线程模型,用于处理 I/O 操作。

  • 内存布局——Valhalla 一次解决多个问题,提高内存密度并扩展泛型。

  • 更好的本地互操作性——Panama 有助于解决 JNI 和其他本地技术中的一些设计问题。

  • 持续清理内部结构——一系列 JEPs 将逐步移除平台中不再需要的部分。

未来 Java 的最终形态尚未确定——到目前为止,未来仍是未写的。然而,可以肯定的是,经过超过 25 年的发展,Java 仍然是一个不容忽视的力量。它已经在软件世界中经历了多次重大转变——这是一份值得骄傲的记录,同时也预示着未来的光明。

附录 A. 选择您的 Java

随着 Oracle JDK 分发和支持的变化,关于使用 Oracle JDK 与 Oracle OpenJDK 构建或其他提供商的 OpenJDK 构建的权利存在相当大的不确定性。存在多种方式可以获得免费更新(包括安全更新)和来自多个供应商提供的(新和现有)付费支持模型。关于这个主题的完整说明,请参阅 Java Champs 社区的开创性指南:“Java Is Still Free”,网址为mng.bz/Qvdw,该社区是 Java 行业中的独立 Java 领导者团体。dev.java.community/jcs/

A.1 Java 仍然免费

您仍然可以通过多个提供商(包括 Oracle)在 GPLv2+CE 许可的完全自由下获取 OpenJDK 构建(请参阅openjdk.java.net/legal/gplv2+ce.html)。在某些情况下,Oracle JDK 仍然免费(无成本)。本节其余部分将详细介绍这一点的确切细微差别。

A.1.1 Java SE/OpenJDK/Oracle OpenJDK 构建/Oracle JDK

OpenJDK 社区根据 Java 社区过程(JCP)和通过每个功能发布的一个总括 Java 规范请求(JSR)定义,创建并维护 Java SE 规范的(GPLv2+CE)开源参考实现(RI)。Java SE 的实现(主要是基于 OpenJDK 的)可以从各种提供商处获得,例如阿里巴巴、Amazon、Azul、BellSoft、Eclipse Adoptium(AdoptOpenJDK 的继任者)、IBM、Microsoft、Red Hat、Oracle、SAP 等。

Oracle JDK 8 已经完成了“公共更新结束”的过程,这意味着从 2019 年 4 月开始的更新需要生产使用支持合同。如前所述,您可以从其他提供商完全免费地获取 OpenJDK 8、11 和 17 构建。Oracle 还提供了 Oracle JDK 17 的零成本二进制文件。

您有多种获取 JDK 的选择;本文件重点介绍 Java SE 8、11 和 17。

A.2 保持使用 Java SE 8

由于各种原因,有些人希望继续使用 Java SE 8:

  1. 自 2019 年 4 月更新以来,Oracle JDK 8 已经受到商业使用限制。要获取更新的 Java SE 8 二进制文件,用户可以获取 Oracle JDK 8 的付费支持计划或使用来自其他提供商的 Java SE 8/OpenJDK 8 二进制文件。

  2. 如果您不使用 Oracle JDK 8,您的当前 Java SE 8/OpenJDK 8 提供商可能提供更新或付费支持计划。

A.2.1 $免费(就像啤酒一样)和免费(就像使用一样)Java SE 8

如果您想免费更新 Java SE 8(包括安全更新),请使用通过 TCK 测试的 OpenJDK 发行版,例如 Amazon、Azul、BellSoft、Eclipse Adoptium、IBM、Microsoft、Red Hat、SAP 等。

A.3 获取 Java SE 11

您可以从以下选项中选择。请仔细阅读它们,特别是 Oracle JDK 如何管理 Java SE 11 的发布和更新:

  1. 对于 Java SE 11,Oracle 通过以下方式提供他们的(基于 OpenJDK 的)JDK:

    1. Oracle OpenJDK 11 构建——在 GPLv2+CE 许可下

    2. Oracle JDK—在付费的商业许可下(但对于个人使用、开发、测试、原型制作和演示以及某些类型的应用程序是免费的),对于那些不想使用 GPLv2+CE 或正在使用与 Oracle 产品或服务一起的 Oracle JDK 的人。

  2. 您还可以从各种其他提供商那里获取 Java SE / OpenJDK 的二进制分发。这些提供商为您提供了不同时间段的更新(包括安全更新),但对于 LTS 版本通常是更长时间。

A.3.1 $免费(就像啤酒一样)和免费(就像使用一样)的 Java SE 11

如果您想要 Java SE 11 的免费更新(包括安全更新),请使用通过 TCK 的 OpenJDK 分发,例如,Amazon、Azul、BellSoft、Eclipse Adoptium、IBM、Microsoft、Red Hat、SAP 等。

A.4 获取 Java SE 17(LTS)

您可以从以下选项中选择。请仔细阅读它们,特别是 Oracle JDK 如何管理 Java SE 17 的发布和更新:

  1. 从 Java SE 17 开始,Oracle 通过以下方式提供他们的(基于 OpenJDK 的)JDK:

    1. Oracle OpenJDK 构建—在 GPLv2+CE 许可下

    2. Oracle JDK—在三年内的无费条款和条件(NFTC)许可之后,然后是常规的商业许可

  2. 您还可以从各种其他提供商那里获取 Java SE/OpenJDK 的二进制分发。这些提供商为您提供了不同时间段的更新(包括安全更新),但对于 LTS 版本通常是更长时间。

注意:NFTC 许可对 Oracle JDK 17 的免费再分发有一些限制。请确保您仔细阅读许可的详细信息。

A.4.1 $免费(就像啤酒一样)和免费(就像使用一样)的 Java SE 17

如果您想要 Java SE 17 的免费更新(包括安全更新),请使用通过 TCK 的 OpenJDK 分发,例如 Amazon、Azul、BellSoft、Eclipse Adoptium、IBM、Microsoft、Red Hat、SAP 等。

A.5 付费支持

对于来自 Azul、BellSoft、IBM、Oracle、Red Hat 等公司的 Java SE/OpenJDK 8、11 和 17 的二进制文件,存在广泛的付费支持选项。Azul 还提供中期支持版本。

附录 B. Java 8 中流的回顾

本附录是 Java 8 流和与之相关的基本函数式编程方面的复习。如果你不熟悉 Java 8 lambda 表达式的基本语法或其设计背后的哲学,你应该先阅读一本基础教材,熟悉这些概念,例如《现代 Java 实战:Lambda、Streams、函数式和响应式编程》,第 2 版,作者为 Raoul-Gabriel Urma、Mario Fusco 和 Alan Mycroft(Manning,2018)。Java 8 将 lambda 表达式作为Project Lambda的一部分引入,其总体目标可以总结如下:

  • 允许开发者编写更干净、更简洁的代码。

  • 为 Java Collections 库提供现代升级。

  • 介绍一种抽象,它允许方便地使用基本功能习语。

在本附录中,我们讨论了 Collections 库的升级、默认方法和Stream抽象作为数据元素的函数式容器类型。

B.1 向后兼容性

Java 平台最重要的概念之一是向后兼容性。指导哲学始终是,为早期版本的平台编写的或编译的代码必须继续与平台的后续版本一起工作。这一原则使开发者有更大的信心,即他们 Java 平台软件的升级不会影响当前正在运行的应用程序。

作为向后兼容性的结果,平台发展的方式存在限制,并且这些限制会影响开发者。

注意:为了保持向后兼容性,Java 平台可能不会在 JDK 中向现有接口添加额外的方法。

为了理解为什么是这样,考虑以下情况:如果某个接口IFoo的新版本在 Java 平台的版本 N 中添加了一个名为newWithPlatformReleaseN()的新方法,那么所有使用平台版本 N-1(或更早版本)编译的IFoo的先前实现将缺少这个新方法,这会导致在 Java 平台版本 N 下链接旧的IFoo实现失败。

这种限制对于 JDK 8 中 lambda 表达式的实现是一个严重的问题,因为其主要设计目标是能够将标准 JDK 数据结构升级以实现函数式编程学校的编码习语。意图是在 Java Collections 库的各个部分添加使用 lambda 表达式表达函数思想的新方法(如map()filter())。

B.2 默认方法

为了解决这个问题,需要一个全新的机制。目标是允许通过添加默认方法来使用 Java 平台的新版本升级接口。

注意:从 Java 8 开始,任何接口都可以添加默认方法(有时称为可选方法)。这必须包括一个实现,称为默认实现,该实现直接在接口定义中编写。这一变化代表了接口定义的演变,并且不会破坏向后兼容性。

管理默认方法的规则如下:

  • 接口实现可能(但不一定必须)实现默认方法。

  • 如果实现类实现了默认方法,则使用类中的实现。

  • 如果实现类没有实现默认方法,则使用接口定义中的默认实现。

让我们快速看一下一个例子。在 JDK 8 中添加到List的一个默认方法是sort()方法。其定义如下:

public default void sort(Comparator<? super E> c) {
    Collections.<E>sort(this, c);
}

这意味着任何List对象都有一个实例方法sort(),可以使用合适的Comparator就地排序列表。任何List的实现都可以提供自己的sort()行为覆盖,但如果它没有这样做,则默认实现(回退到Collections辅助类中提供的实现)将可用。

默认方法机制通过类加载工作。当一个接口的实现正在加载时,会检查类文件以查看是否所有可选方法都存在。如果存在,则类加载正常继续。如果不存在,则实现类的字节码会被修补以添加缺失方法的默认实现。

注意:默认方法代表了 Java 面向对象方法的基本变化。从 Java 8 开始,接口可以包含实现代码。许多开发者认为这放宽了 Java 严格单继承的一些规则。

开发者应该了解默认方法工作方式的一个细节:默认实现冲突的可能性,它有两个部分。首先,如果一个实现类已经有一个与新的默认方法同名和相同签名的现有方法,则始终会优先使用现有的实现,而不是默认实现。

其次,如果一个类实现了两个都包含同名和相同签名的默认方法的接口,该类必须实现该方法(并且可以选择将方法委托给接口的默认实现,或者完全执行其他操作)。这可能导致向接口添加默认方法会破坏客户端代码,因为如果客户端代码已经实现了另一个包含默认方法的接口,那么存在实现冲突的可能性。然而,在实践中,这种情况非常罕见,这种可能性被视为为了默认方法带来的其他好处而付出的微小代价。

B.3 流

回想一下,Project Lambda 的目标之一是为 Java 语言提供轻松表达函数式编程技术的能力。例如,这意味着 Java 获得了编写map()filter()惯用语的简单方法。

在 Java 8 的原始设计草图上,这些惯用语是通过直接将这些方法添加到经典 Java 集合接口中作为额外的默认方法来实现的。然而,这种方法有多个不满意的地方。

首先,因为map()filter()是相对常见的名称,所以感觉现有实现的风险太高——许多用户编写的集合实现将会有不尊重新方法预期语义的现有方法。

相反,发明了一个新的抽象概念,称为StreamStream是一种容器类型,在某些方面与迭代器类似,用于更函数式的方法来处理集合和聚合数据。

Stream接口是放置所有新函数式导向方法的地方,例如map()filter()reduce()forEach()flatMap()Stream上的方法广泛使用了函数式接口类型,例如 lambda 表达式作为参数。

Stream视为一个可消费的元素序列。这意味着一旦从Stream中取出一个元素,它就不再可用,这与迭代器类似。

注意:由于Stream对象是可消费的,因此它们不应该被重用或存储在临时变量中。将Stream值分配给局部变量几乎总是代码的坏味道。

原始的集合类,如ListSet,被赋予了一个新的默认方法,称为stream()。这个方法为集合返回一个Stream对象,类似于在经典集合代码中使用iterator()的方式。

B.3.1 示例

这段代码展示了我们如何使用Stream和 lambda 表达式来实现一个过滤惯用语:

List<String> myStrings = getSomeStrings();
String search = getSearchString();

System.out.println(myStrings.stream()
                            .filter(s -> s.equals(search))
                            .collect(Collectors.toList()));

注意,我们还需要调用collect()——这是因为filter()返回另一个Stream。在过滤操作之后,为了得到一个集合类型,我们需要做一些事情来主动将Stream转换为Collection

整体方法看起来是这样的:

         stream()  filter()   map()   collect()
Collection -> Stream -> Stream -> Stream -> Collection

这个想法是让开发者构建一个需要应用到流中的“管道”操作。每个操作的实际上内容将使用每个操作的 lambda 表达式来表示。在管道的末端,结果需要被物化回一个集合中,因此使用了collect()方法。

让我们看看Stream接口定义的一部分(它定义了map()filter()方法):

public interface Stream<T> extends BaseStream<T, Stream<T>> {
    Stream<T> filter(Predicate<? super T> predicate);

    <R> Stream<R> map(Function<? super T, ? extends R> mapper);

    // ...
}

注意:不要担心那些看起来吓人的泛型定义。所有的“? super”和“? extends”子句意味着:当流中的对象有子类时,做正确的事情。

这些定义涉及两个新的接口:PredicateFunction。这两个接口都可以在java.util.function包中找到。这两个接口都只有一个方法,没有默认方法。因此,我们可以为它们编写 lambda 表达式,这些表达式将被自动转换为正确类型的实例。

注意:记住,当 Java 平台遇到 lambda 表达式时,总是将其转换为正确的函数式接口类型(通过类型推断)。

让我们看看一个代码示例。假设我们正在模拟海狸种群。有些是野生的,有些在野生动物园。我们想知道有多少圈养海狸是由培训动物园管理员照看的。使用 lambda 表达式和流,这很容易做到,如下所示:

Set<Otter> ots = getOtters();
System.out.println(ots.stream()
    .filter(o -> !o.isWild())
    .map(o -> o.getKeeper())
    .filter(k -> k.isTrainee())
    .collect(Collectors.toList())
    .size());

首先,我们过滤流,以便只处理圈养的海狸。然后,我们执行一个map()操作,以获取管理员流,而不是海狸流(注意,这个流的类型已从Stream<Otter>变为Stream<Keeper>)。然后,我们再次过滤,以选择仅培训管理员,然后使用静态方法Collectors.toList()将此转换为具体的集合实例。最后,我们使用熟悉的size()方法从具体的列表中返回计数。

在这个例子中,我们将我们的海狸转变成了负责它们的管理员。我们没有对任何海狸的状态进行变异以实现这一点——这有时被称为无副作用

注意:在 Java 中,map()filter()表达式中的代码应该是无副作用的。然而,Java 运行时并没有强制执行这个“规则”,所以请小心。你应该始终在自己的代码中遵循这个约定。

如果我们的用例意味着我们需要变异某些外部状态,我们可以使用两种方法之一,具体取决于我们想要实现什么。首先,如果我们想要构建聚合状态(例如,海狸年龄的运行总和),我们可以使用reduce()。或者,如果我们想要执行更通用的状态转换(例如,当旧管理员离开时将海狸转移到新管理员),则forEach()更合适。

让我们来看看如何使用下一代码片段中的reduce()方法计算海狸的平均年龄:

var kate = new Keeper();
var bob = new Keeper();
var splash = new Otter();
splash.incAge();
splash.setKeeper(kate);
Set<Otter> ots = Set.of(splash);

double aveAge = ((double) ots.stream()
    .map(o -> o.getAge())
    .reduce(0, (x, y) -> {return x + y;} )) / ots.size();
System.out.println("Average age: "+ aveAge);

首先,我们将海狸映射到它们的年龄。接下来,我们使用reduce()方法。它接受两个参数:初始值(通常称为)和一个逐步应用的功能。在我们的例子中,这只是一个简单的加法,因为我们想要计算所有海狸的年龄总和。最后,我们将总年龄除以海狸的数量。

注意到reduce()的第二个参数是一个双参数 lambda。简单来说,这两个参数中的第一个是聚合操作的“运行总和”,第二个在遍历集合时实际上是循环变量。

最后,让我们转向我们想要改变状态的情况。为此,我们将使用forEach()操作。在我们的例子中,我们想要模拟 Keeper Kate 去度假的情况,因此现在应该将她的所有海獭交给 Bob。这可以轻松地完成如下:

ots.stream()
.filter(o -> !o.isWild())
.filter(o -> o.getKeeper().equals(kate))
.forEach(o -> o.setKeeper(bob));

注意,reduce()forEach()都没有使用collect()reduce()在遍历流的过程中收集状态,而forEach()只是将操作应用到流上的每个元素,所以在这两种情况下,都没有必要重新创建流。

B.4 集合的局限性

Java 的集合类为该语言提供了极大的便利。然而,它们基于这样一个理念:集合中的所有元素都存在,并且它们在内存中的某个地方有表示。这意味着它们无法表示更通用的数据,例如无限集合。

例如,考虑所有质数的集合。我们不能将其建模为Set<Integer>,因为我们不知道所有的质数是什么,而且我们当然没有足够的堆空间来表示它们。在 Java 的早期版本中,这将是标准集合中一个非常难以解决的问题。

有可能构建一个主要使用迭代器并且将底层集合作为辅助角色的数据视图。然而,这需要纪律,并且不是 Java 集合的立即明显方法。在过去,如果开发者想要使用这种类型的方法,他们通常会依赖于提供更好功能支持的第三方库。

幸运的是,Java Stream 通过引入Stream接口作为更适合处理更通用数据结构的抽象来解决这个问题。这意味着Stream可以被视为比IteratorCollection更通用。

注意:Stream不管理元素的存储,也不提供直接从流中访问单个元素的方法。

然而,Stream 并非真正的数据结构——相反,它是一种处理数据的抽象,尽管这两种情况之间的区别有些微妙。

B.5 无限流

让我们更深入地探讨一下对无限序列数字建模的概念。以下是一些后果:

  • 我们无法将整个流实体化为一个集合,因此像collect()这样的方法将不可行。

  • 我们必须通过从流中拉取元素来操作。

  • 我们需要一段代码,它能按需返回下一个元素。

这种方法还意味着表达式的值只有在需要时才会被计算。

在 Java 8 之前,表达式的值总是在它被绑定到变量或传递给函数时立即计算。这被称为贪婪评估,当然,这也是大多数主流编程语言中表达式评估的默认行为。

注意:从版本 8 开始,Java 引入了一种新的编程范式——Stream尽可能使用延迟评估

这是一个非常强大的新功能,确实需要一些时间来适应。我们将在第十五章中更详细地讨论延迟评估。Java 中 lambda 表达式的目的是简化普通程序员的日常生活,即使这需要在平台上增加额外的复杂性。

B.6 处理原始类型

我们之前一直忽略的一个 Stream API 的重要方面是如何处理原始类型。Java 的泛型不允许将原始类型用作类型参数,因此我们无法编写Stream<int>。幸运的是,Streams库提供了一些技巧来帮助我们解决这个问题。让我们看一个例子:

double totalAge = ((double) ots.stream()
                             .map(o -> o.getAge())
                             .reduce(0, (x, y) -> {return x + y;} ));

double aveAge = totalAge / ots.size();
System.out.println("Average age: "+ aveAge);

这实际上在大多数管道中使用了原始类型,所以让我们稍微展开一下,看看如何在像这样的代码中使用原始类型。

首先,不要被转换为double的操作弄混淆。这只是为了确保 Java 执行正确的平均计算,而不是执行整数除法。

map()的参数是一个 lambda 表达式,它接受一个Otter并返回一个int。如果我们能使用 Java 的泛型来编写它,lambda 表达式将被转换为实现Function<Otter, int>的对象。然而,由于 Java 的泛型不允许这样做,我们需要以不同的方式编码返回类型是int的事实——通过将其放入类型名称中,因此,实际上推断出的类型是ToIntFunction<Otter>。这种类型被称为函数类型的原始特殊化,用于避免在intInteger之间的装箱和拆箱,从而节省不必要的对象生成,并允许我们使用特定于所使用原始类型的函数类型。

让我们更详细地分解平均计算。为了获取每只海狸的年龄,我们使用这个表达式:

ots.stream().map(o -> o.getAge())

让我们看看被调用的map()方法的定义,如下所示:

IntStream map(ToIntFunction<? super T> mapper);

从这里我们可以看到我们使用了特殊的函数类型ToIntFunction,我们还使用了一种特殊的Stream形式来表示整数的流。

之后,我们传递给reduce(),其定义如下:

int reduce(int identity, IntBinaryOperator op);

这也是一种专门的形式,它完全基于整数(ints)操作,并接受一个接受两个参数的 lambda 表达式(两个参数都是整数)来执行归约。

reduce()是一个收集操作(因此是急切的),所以管道在该点进行评估并返回一个单一值,然后将其转换为 double 并转换为整体平均值。

如果你错过了所有关于原始类型的细节,不要担心——这是类型推断的好处之一:大多数这些差异可以在大多数时候隐藏起来。

让我们通过讨论一个经常被开发者误解的主题来结束:对流上的并行操作的支持。

B.7 并行操作?

在 Java 的旧版本(7 及之前),所有集合操作都是串行的。无论操作的集合有多大,都只会使用一个 CPU 核心来执行操作。随着数据集的增大,这可能会变得非常浪费,Project Lambda 的一个可能目标就是提升 Java 对集合的支持,以便高效地使用多核处理器。

注意:流式的惰性求值方法允许 lambda 表达式框架提供对并行操作的支持。

Stream API 的主要假设是创建流对象(无论是从集合还是通过其他方式)应该是低成本的,但管道中的某些操作可能很昂贵。这个假设使我们能够描述一个如下的并行管道:

s.stream()
    .parallel()
    // sequence of stream operations
    .collect( ... );

parallel() 方法将串行流转换为并行操作。其意图是允许普通开发者将 parallel() 作为透明并行化的入口点,并将提供并行支持的负担放在库编写者而不是最终用户身上。

理论上听起来不错,但在实践中,实现和其他细节最终会削弱 parallel() 机制的有用性。第十六章将更深入地讨论这一点。

由于这些限制,强烈建议您避免使用并行流,除非您能证明(使用第七章的方法)您的应用程序将从添加它们中受益。在实践中,作者们看到的情况中,实际有效的并行流案例不到六个。

符号

:clojure.spec.alpha/invalid: keyword 484

:shrunk key 491

:smallest [(0)] key 491

./gradlew command 377

./gradlew tasks meta-task 377

.gradle.kts extension 378

(.) macro 332

(agent) function 569

(alter) form 568

(apply) form 319

(cast) name 108

(concat) form 534

(conj ) function 328

(conj) operation 557

(cons ) function 328

(cons) operation 557

(constantly) function 330

(def <value?>) special form 310

(def) form 310

(defmacro) special form 338

(defn) macro 305

(deref) form 311

(do *) special form 310

(do) form 309

(dosync) form 568

(every? ) function 328

(filter) call 535

(filter) form 323

(first ) function 328

(fn ? [] ) special form 310

(for) form 531

(future-done?) nonblocking call 564

(if ) special form 310

(if) special form 338

(import) form 332

(lazy-seq) form 534

(lazy-seq) macro 534

(let [] ) special form 310

(let) form 317

(loop) form 320

(macroexpand-1) form 339

(macroexpand) form 339

(map) function 314

(new) form 332

(nth) function 313

(partial) form 535

(pcalls) function 564

(println) function 308

(proxy) form 334

(proxy) macro 333

(quote

) special form 310

(quote) form 311

(recur) form 320

(reduce) form 324

(ref) form 311, 565, 568

(rest ) function 328

(send) call 570

(seq ) function 328

(seq? ) function 328

(sort-by) new form 319

(take) form 534

(unless) form 336

(unless) function 337

(unless) macro 339

(var ) special form 310

(var) special form 311

(vec) form 313

(vector) form 313

(wait-and-log) call 570

@Container annotation 470

@file:JvmName(“AlternateClassName”) annotation 295

@FunctionalInterface annotation 146, 202

@JvmOverloads annotation 296

@Mojo annotation 373

@NotNull 注解 288

@Nullable 注解 288

@Test 注解 460

@Testcontainers 注解 469

&env 特殊变量 339

&form 特殊变量 339

()形式 323 – 324

define 预处理指令 336

include 预处理指令 336

A

AbstractQueuedSynchronizer 类 173

访问控制 33 – 34, 588

访问器方法 236

Account 类 601

Account 接口 601

AccountManager 类 190

准确性 221 – 222

aconst_init 操作码 631

add 操作码 108

  • -add-exports 开关 41, 43

  • -add-host 标志 418

  • -add-modules 开关 42

  • -add-opens 命令行选项 45

Add-Opens JAR 清单属性 45

  • -add-opens 开关 42

  • -add-reads 开关 41

add()方法 186

额外的静态参数 590

代理 569 – 570

聚合模块 39

代数数据类型 73

《算法设计手册》 (Skiena) 128

所有函数 279

分配器对象 617

aload_0 操作码 102

替代 JVM 语言

如何选择 262 – 265

使用该语言的开发者 264 – 265

学习语言难度 264

语言与 Java 良好交互 263 – 264

项目区域低风险 262 – 263

语言工具和测试支持 264

支持 JVM 265 – 268

编译器虚构 267 – 268

性能 266

非 Java 语言的运行时环境 266 – 267

语言动物学 252 – 256

动态类型与静态类型 253 – 254

命令式语言与函数式语言 254 – 255

解释性语言与编译性语言 252 – 253

重实现与原始版本 255 – 256

在 JVM 上进行多语言编程 256 – 262

竞争者 260 – 262

非 Java 语言,使用原因 257 – 259

新兴语言 259 – 260

Amdahl 定律 121 – 122

andThen() 方法 513

匿名类 607

任何函数 279

AOT(编译时)编译语言 234

API

Foreign Function 和 Memory API 612 – 618

在 Panama 处理本地内存 617 – 618

LibSPNG 示例 614 – 617

用支持的安全替换不安全 604 – 608

隐藏类 607 – 608

VarHandles 605 – 607

使用 BlockingQueue API 194 – 195

APM(应用程序性能监控) 432

AppClassLoader 类加载器 86

应用程序模块 38 – 39

应用程序插件 381

应用程序

使用 Docker 开发 Java 应用程序 411 – 424

使用 Gradle 构建镜像 412 – 414

在 Docker 中调试 421 – 423

使用 Docker Compose 进行本地开发 418 – 421

使用 Docker 记录日志 423 – 424

端口和主机 416 – 418

在 Docker 中运行构建 414 – 416

选择基础镜像 411 – 412

Gradle 构建 380 – 381

apply() 方法 513

为模块设计架构 46 – 52

Java 8 紧凑配置文件 47 – 49

多版本 JAR 文件 49 – 52

拆分包 47

算术

Clojure 315 – 316

操作码 107

函数的算术 329 – 330

AS Docker 命令 414

组装任务 388

asSequence() 函数 529

assertj 库 362

associateBy 函数 279

associateWith 函数 279

AST(抽象语法树) 308

astore 操作码 107

异步函数 556

async/await 方法 624

原子类 170 – 172

AtomicBoolean 类 170

AtomicInteger 类 170

AtomicLong 类 170

AtomicReference 类 170

Attach API 42

自动仪表化 213 – 214

自动模块 39 – 40

自动化

操作 346

静态分析 389 – 390

await() 方法 174

B

背压 189

向后兼容性 640 – 641

BadInit 类 90

裸机 402

屏障 174

基本插件 378

bean 风格 145

贝克,肯特 441, 443

BiFunction 接口 513

绑定 301

Bjarnason, Rúnar 516

块结构并发(Java 5 之前) 128 – 147

死锁 132 – 135

完全同步对象 131 – 132

免疫性 144 – 147

线程状态模型 130 – 131

同步

锁和 128 – 130

原因 135 – 137

线程状态和方法 137 – 143

废弃的线程方法 143

中断线程 140 – 142

处理异常和线程 142 – 143

volatile 关键字 137

BLOCKED 状态 131

BlockingQueue 接口 188, 194

BlockingQueue 模式 188 – 197

使用 BlockingQueue API 194 – 195

使用 WorkUnit 196 – 197

布尔参数 159

BootstrapClassLoader 类加载器 86

括号,在 Clojure 中 308 – 309

构建生命周期 350

部分 359

构建工具

Gradle 376 – 400

添加 Kotlin 387 – 388

自动化静态分析 389 – 390

构建 379 – 381

自定义 397 – 400

依赖关系 382 – 387

安装 376 – 377

超越 Java 8 390

脚本 378

任务 377 – 378

测试 388 – 389

使用插件 378 – 379

使用模块 391 – 397

避免工作 381 – 382

对开发者的重要性 345 – 350

自动化繁琐操作 346

确保开发者之间的一致性 349 – 350

管理依赖 346 – 349

Maven 350 – 376

添加另一种语言 355 – 357

编写 Maven 插件 372 – 376

构建生命周期 350 – 351

构建 353 – 354

控制清单 354

依赖管理 360 – 364

安装/POM 351 – 353

模块和 369 – 372

超越 Java 8 365 – 366

多版本 JAR 在 366 – 369

回顾 364 – 365

测试 357 – 360

  • -build-cache 命令行标志 382

Builder 类 145

构建模块化应用 40 – 46

命令行开关 41 – 43

执行 43 – 45

反射和 45 – 46

buildscript 378

通过关键字 282, 526

通过记忆化调用 479

字节码 101 – 113

通过并发 149 – 168

死锁已解决,重新审视 162 – 166

重新审视死锁 160 – 162

丢失更新 151 – 153

字节码中的同步 153 – 157

同步方法 157 – 158

非同步读取 158 – 160

易失性访问 166 – 168

反汇编类 101 – 103

Kotlin 和 551 – 553

操作码(操作码)

算术 107

执行流程控制 108 – 109

调用 109 – 112

加载和存储 106 – 107

概述 105 – 106

平台操作 112

快捷形式 112 – 113

递归和 507 – 509, 522, 524

运行时环境 103 – 105

C

C 类 632

-c 开关 101

C1(客户端编译器) 235

C2(服务器编译器) 235

缓存未命中 222 – 224

缓存线程池 205

调用栈 104

调用目标 589

call() 方法 201, 268

可调用接口 201, 255, 584

CallSite 对象 589

取消函数 293

cancel() 方法 197

规范构造函数 63

容量 211

CAS(比较并交换)操作 171

Chiusano, Paul 516

类文件 82 – 86

字节码 101 – 113

反汇编类 101 – 103

操作码(操作码) 105 – 113

运行时环境 103 – 105

类加载和链接 83 – 85

初始化 85

准备 85

解析 85

验证 84 – 85

类对象 85 – 86

Classloader 类 86 – 95

自定义类加载 88 – 95

模块和类加载 95

检查 95 – 100

常量池 98 – 100

方法签名内部形式 96 – 98

javap 96

反射 113 – 118

结合类加载和 116 – 117

概述 114 – 116

问题 117 – 118

类关键字 281

类加载 82

类对象 83, 85 – 86, 129, 573

类类型 99

Class<?> 对象 573

Class 类型 117

类,Kotlin 281 – 287

ClassFileParser::parseClassFile() 方法 92

ClassLoader 类 86 – 95

自定义类加载 88 – 95

依赖注入框架示例 93 – 94

异常,类加载 89 – 91

第一个自定义类加载器 91 – 93

类加载器示例 94 – 95

模块和类加载 95

clean lifecycle 351

-客户端切换 235

clj 命令 302

Clojure 260, 302

括号 308 – 309

并发 Clojure 557 – 570

代理 569 – 570

未来和 pcall 563 – 565

持久数据结构 557 – 563

软件事务内存 565 – 569

函数式编程和闭包 323 – 325

从 REPL 开始 303 – 305

Gradle 376 – 377

302 中的 Hello World – 303

在 Java 和 330 之间交互 – 335

从 Clojure 调用 Java 331 – 332

使用 REPL 进行探索性编程 334 – 335

Clojure 值的 Java 类型 332 – 333

Clojure 调用的本质 332

从 Java 使用 Clojure 335

使用 Clojure 代理 333 – 334

Kotlin 271

宏 335 – 341

犯错误 305 – 308

Maven 351 – 353

概述 300 – 302

使用 481 进行基于属性的测试 – 493

clojure.spec 483 – 486

clojure.spec 和 test.check 492 – 493

clojure.test 481 – 483

test.check 487 – 492

序列 325 – 330

语法和语义 309 – 323

算术、相等和其他操作 315 – 316

函数 316 – 319

列表、向量、映射和集合 312 – 315

循环 319 – 320

读取宏和调度 321 – 323

特殊形式 309 – 311

Testcontainers 467

Clojure 函数式编程 301, 325, 531 – 535

comprehensions 531 – 532

Clojure 中的柯里化 535

Kotlin 函数式编程 519 – 522

惰性序列 532 – 534

概述 498 – 499

Clojure in Action (Rathore) 299

clojure.lang 包 305

clojure.lang.ISeq 接口 325

clojure.lang.Ref 类型 568

clojure.lang.Var 类型 333

clojure.spec 483 – 486, 492 – 493

clojure.test 481 – 483

clojure.test 标准库 481

闭包 325

Clojure 323 – 325

Java 作为函数式编程语言的限制 509 – 511

Kotlin 函数式编程 517 – 518

概述 500 – 501

集群对象类型 425

CMD Docker 命令 408 – 410, 413 – 414

CMT (芯片多线程) 220

CNCF (云原生计算基金会) 433

Cognitect Labs 测试运行器测试运行器 483

科恩,迈克 438

collect() 方法 511, 643, 646

集合字面量 17

集合

Java 作为函数式编程语言的限制 514 – 515

Kotlin 277 – 279

限制 645 – 646

Collections 类 181

集合工厂(JEP 213) 17 – 18

集合辅助类 642

Collectors.toList() 静态方法 644

com.company.project 规范 37

com.oracle.graal.graal_enterprise 模块 38

命令行开关 41 – 43

通信开销 122

紧凑构造函数 67

紧凑配置文件(Java 8)47 – 49

紧凑记录构造函数 67 – 69

紧凑字符串 596 – 597

压缩 228

compareAndSwapInt() 方法 606

compareAndSwapLong() 方法 606

compareAndSwapObject() 方法 606

compareTo() 方法 539, 585

编译错误 288

编译日志 237 – 238

编译目标 355

编译阶段 350

compileClasspath Gradle 依赖配置 383 – 384

编译语言 252 – 253

compileOnly Gradle 依赖配置 384

编译器虚构 267 – 268

CompletableFuture 类 198 – 200, 499, 544 – 547

CompletableFuture.supplyAsync() 方法 200

CompletableFuture 类型 199

组合函数 516

理解,Clojure 函数式编程 531 – 532

*《计算机体系结构:定量方法》(Hennessey 等著)220

并发

块结构并发(Java 5 之前)128 – 147

死锁 132 – 135

完全同步对象 131 – 132

免疫性 144 – 147

线程状态模型 130 – 131

同步和锁 128 – 130

同步,原因 135 – 137

线程状态和方法 137 – 143

volatile 关键字 137

设计力 124 – 128

冲突的力,原因 127

活系统 126

性能 126

可重用性 126 – 127

安全和并发类型安全 125 – 126

开销来源 127 – 128

Java 内存模型 (JMM) 147 – 149

Kotlin 291 – 294

理论入门 120 – 124

Amdahl 定律 121 – 122

硬件 121

Java 线程模型 123 – 124

经验教训 124

线程熟悉假设 120 – 121

通过字节码 149 – 168

死锁解决,重访 162 – 166

死锁重访 160 – 162

丢失更新 151 – 153

字节码中的同步 153 – 157

同步方法 157 – 158

未同步读取 158 – 160

volatile 访问 166 – 168

并发原语 170

并发编程

并发 Clojure 557 – 570

代理 569 – 570

futures 和 pcalls 563 – 565

持久数据结构 557 – 563

软件事务内存 565 – 569

F/J (Fork/Join) 框架 538 – 544

示例 539 – 542

并行化问题 542

工作窃取算法 543 – 544

FP (函数式编程) 和 544 – 549

CompletableFuture 类 544 – 547

并行流 547 – 549

Kotlin 协程 549 – 556

协程作用域和调度 554 – 556

协程如何工作 549 – 554

ConcurrentHashMap 类 176 – 185

并发 Dictionary 的方法 180 – 182

Dictionary 类的限制 179 – 180

简化版 HashMap 176 – 179

使用 182 – 185

并发类型安全 125

ConcurrentMap 接口 184

条件对象 173 – 174

配置参数,垃圾回收 232 – 233

conj 方法 557

cons 方法 557

共识屏障 174

一致性 349 – 350

常量池 98 – 100

CONSTANT_ 前缀 99

构造函数关键字 283

Consumer<> 接口 471

容器引擎 403

容器注册表 409

容器

Docker 406 – 411

构建 Docker 镜像 406 – 409

使用 Java 应用程序开发 411 – 424

运行 Docker 容器 409 – 411

收集容器日志 471

对于开发者的重要性 402 – 406

裸机 402

容器的好处 404 – 405

容器引擎 403

容器 403 – 404

容器缺点 405 – 406

宿主操作系统或 Type 1 虚拟机 402

Type 2 虚拟机 402 – 403

虚拟机 (VM) 403

Kubernetes 424 – 432

可观测性 432 – 435

性能 434 – 435

控制器 424

便利性和简洁性,Kotlin 271 – 281

集合 277 – 279

等价性 273

函数 273 – 277

if 表达式 279 – 281

从更少开始 271 – 272

变量 272 – 273

协作多任务处理 293

复制方法 519, 550

写时复制语义 185

CopyOnWriteArrayList 类 185 – 188

协程 291, 549

概述 549 – 554

作用域和调度 554 – 556

CoroutineScope 实例 555

成本,实现更高的性能 215 – 216

计数方法 455, 557

countDown() 方法 174

CountDownLatch 174 – 175

COWIterator 对象 186

create() 工厂方法 335

临界区 129

curried 函数 502

currying

在 Clojure 中 535

Java 作为函数式编程语言的限制 513

Kotlin FP 518

概述 502

自定义类加载 88 – 95

依赖注入框架示例 93 – 94

异常,类加载 89 – 91

第一个自定义类加载器 91 – 93

类加载器示例 94 – 95

D

DAG (有向无环图) 31

DAO (数据访问对象) 473

数据类构造 519

数据类,Kotlin 286 – 287

数据并行 547

数据科学书坊 (Apeltsin) 212

数据类型 341

DaySupplier 类型 512

死锁 132 – 135, 160 – 166

在 Docker 中调试 421 – 423

解构模式 610

定义特殊形式 301

默认子句 60

默认实现 641

默认实现冲突 642

默认生命周期 350

默认行 59

默认方法 641 – 642

defineAnonymousClass() 方法 607

defineClass() 方法 92

defineClass1() 原生方法 92

defspec 函数 491

deftest 函数 482

退化 211

延迟函数 550

委托属性 479, 526

委托 180

去优化,即时编译 (JIT) 238

依赖 346 – 349

依赖冲突 348 – 349

在 Gradle 中 382 – 387

Maven 360 – 364

依赖配置 382

依赖冲突 348

依赖注入框架示例 93 – 94

依赖解析 361

部署阶段 351

部署对象类型 425

已弃用的线程方法 143

描述方法 480

设计力量,并发 124 – 128

冲突,原因 127

实时系统 126

性能 126

可重用性 126 – 127

安全和并发类型安全 125 – 126

开销来源 127 – 128

destroy() 方法 143

解构 610

开发者

构建工具 345 – 350

自动化繁琐操作 346

确保开发者之间的一致性 349 – 350

管理依赖 346 – 349

容器,其重要性 402 – 406

裸金属机器 402

容器的优点 404 – 405

容器引擎 403

容器 403 – 404

容器的缺点 405 – 406

宿主操作系统或 Type 1 虚拟机 402

Type 2 虚拟机 402 – 403

虚拟机 (VM) 403

使用语言 264 – 265

DI (依赖注入) 81

字典类 176

并发方法的途径 180 – 182

局限性 179 – 180

直接测量 213 – 214

不相交联合类型 73

Clojure 中的调度读取宏 321 – 323

调度,JVM 中的方法 572

调度,协程 554 – 556

可区分性 222

分布式跟踪可观察性支柱 433

div 操作码 108

分而治之模式 538

dload 操作码 106

do-while 循环 603

doc 函数 485

Docker 406 – 411

构建 Docker 镜像 406 – 409

使用 411 开发 Java 应用程序 – 424

使用 Gradle 构建镜像 412 – 414

在 Docker 中调试 421 – 423

使用 Docker Compose 进行本地开发 418 – 421

使用 Docker 记录日志 423 – 424

端口和主机 416 – 418

在 Docker 中运行构建 414 – 416

选择基础镜像 411 – 412

运行 Docker 容器 409 – 411

docker build 命令 406

docker 命令 423

Docker Compose 418 – 421

docker cp 命令 422

docker pull 命令 409

docker push 命令 409

docker run 命令 409

  • -dry-run 标志 378

DSL (领域特定语言) 376, 378

鸭子类型 13

鸭子变量 13

哑对象 449 – 451

dup 操作码 103, 106

动态编译

单态调用和 236 – 237

原因 234

动态类型 253 – 254

E

-e 标志 410

贪婪评估 501, 646

简单情况是简单反模式 542

echoserver 容器 426

有效最终变量 510

效率 211

EJBs (企业 JavaBeans) 619

else 情况 281

else 条件 337

else 位置 320

涌现属性 208

端到端测试 440, 474 – 476

企业模块(JEP 320) 18 – 19

entry() 方法 18

entrySet() 方法 179

枚举关键字 73

环境 410, 510

临时端口 417

平等

Clojure 315 – 316

Kotlin 273

equals() 方法 64

人体工程学 435

逃逸分析 628

评估堆栈 104

EvilOrder 类 70

异常 142 – 143

execute() 方法 543

执行,模块化应用程序 43 – 45

执行控制操作码 108 – 109

执行步骤 367

executions 元素 374

执行器参数 546

执行器接口 202

Executors 类 202

Executors.newVirtualThreadPerTaskExecutor() 工厂方法 625

ExecutorService 接口 202

ExecutorService.invokeAll() 辅助方法 564

完备性 70

ExpectedException 规则 464

解释函数 485

探索性编程 303, 334 – 335

导出关键字 36 – 37

导出关键字 33

EXPOSE 端口 Docker 命令 417

表达式 59

扩展关键字 285

扩展对象 399

扩展属性 399

ExternalResource 类 461

F

F/J (Fork/Join) 框架 538 – 544

示例 539 – 542

针对 542 的并行化问题

工作窃取算法 543 – 544

工厂方法 144

编写失败的测试 443 – 445

安全故障插件 459

伪造对象 449, 454 – 456

false 值,Clojure 316

Feather, Michael 442

feed() 方法 115

字段对象 85

Fieldref 100

files 函数 392

filter 集合 516

filter 函数技术 531

filter() 方法 511, 641 – 644

filter() 管道 511

final 关键字 146, 171, 498

final 方法 578

final 引用 504

final var 组合 519

findClass() 方法 88

findConstructor() 方法 584

findVirtual() 方法 584

有限资源 402

fire-and-forget 虚拟线程 622

一等函数 254, 268, 275

first() 方法 335

固定线程池 204 – 205

fixtures 478

flatMap() 方法 546, 643

飞行记录器 239 – 240

浮点数 100

for 循环 278, 319, 604

forEach() 方法 643

forEach() 操作 645

Foreign Function API 612 – 618

在 Panama 处理原生内存 617 – 618

LibSPNG 示例 614 – 617

ForkJoinPool 执行器服务 538

ForkJoinTask 类型 543

form 300

format() 方法 12

FP (函数式编程)

Clojure 323 – 325

Clojure FP 531 – 535

comprehensions 531 – 532

Clojure 中的 currying 535

惰性序列 532 – 534

概念 498 – 502

闭包 500 – 501

柯里化和部分应用 502

高阶函数 499 – 500

不可变性 498 – 499

懒惰 501

纯函数 498

递归 500

并发编程和 544 – 549

CompletableFuture 类 544 – 547

并行流 547 – 549

Java 作为 FP 语言的限制 502 – 515

闭包 509 – 511

柯里化和部分应用 513

高阶函数 505 – 506

Java 类型系统和集合 514 – 515

懒惰 511 – 512

可变性 503 – 505

纯函数 503

递归 506 – 509

Kotlin FP 515 – 530

闭包 517 – 518

柯里化和部分应用 518

不可变性 519 – 522

惰性求值 525 – 526

纯和高阶函数 516 – 517

序列 526 – 530

尾递归 522 – 525

FROM Docker 命令 409, 414

完整集合 228

完全同步对象 131 – 132

函数接口 644

函数对象 513

函数类型 506

函数接口类型 644

函数式语言 254 – 255

Kotlin 中的函数式编程 (Vermeulen, Bjarnason, and Chiusano) 516

面向函数的方法 642

函数 498

Clojure 316 – 319

Kotlin 273 – 277

Fusco, Mario 640

Future 接口 197 – 200

futures,在 Clojure 中 563 – 565

FutureTask 类 200 – 201

模糊测试 489

FX (外汇货币交易) 56

G

G1 收集 229 – 231

GA (一般可用性) 434

垃圾回收 224 – 233

内存区域 227

基础 225

配置参数 232 – 233

完整集合 228

G1 229 – 231

标记和清除 225 – 227

并行收集器 231 – 232

safepoints 228

年轻集合 228

GAV (组,工件,版本) 坐标 352

gen 函数 493

生成函数 488

代际垃圾回收 226

生成器包 488

泛型特化 633

GenericContainer 对象 470

泛型 633

get() 方法 145, 178, 197, 199, 313, 512

getAndAddInt() 方法 602

getAndAddLong() 方法 606

getAndIncrement() 方法 171

getAndSetInt() 方法 606

getAndSetLong() 方法 606

getAndSetObject() 方法 606

getCallerClass() 607

getClass() 方法 334

getDeclaredMethod() 方法 117

getfield 指令码 106, 168

getInstance() 方法 237

getMethod() 方法 117

getstatic 指令码 106

getSuperclass() 方法 85

目标 351

Goetz, Brian 168

黄金锤技术 117

goto 指令码 108 – 109

Gough, James 246

GraalVM 261

Gradle 376 – 400

添加 Kotlin 387 – 388

自动化静态分析 389 – 390

构建 379 – 381

使用 412 构建图像 – 414

自定义 397 – 400

创建自定义插件 399 – 400

自定义任务 397 – 399

依赖关系 382 – 387

安装 376 – 377

超越 Java 8 390

脚本 378

任务 377 – 378

测试 388 – 389

使用插件 378 – 379

与模块一起使用 391 – 397

JLink 393 – 397

模块化应用 392 – 393

模块化库 391 – 392

避免工作 381 – 382

gradlew 包装器 377

gradlew.bat 命令 377

粒度 222

绿色线程 619

groom() 方法 575

Groovy

Kotlin 与 378

概述 260

分组构造 478

分组函数 478

分组 lambda 479

分组方法 480

守卫模式 77

H

Happens-Before 147

硬件 121

hash() 辅助方法 178

hashCode() 方法 60, 64

HashMap 111, 176 – 179

头部阻塞 19 – 20

头部阻塞,HTTP 21

堆 225

堆扁平化 628

Clojure 中的 Hello World 302 – 303

hi 符号 310

hi 变量 310

隐藏类 607 – 608

高阶函数

Java 作为函数式编程语言的限制 505 – 506

Kotlin 函数式编程 516 – 517

概述 499 – 500

同构语言 336

主机操作系统 402

主机,Docker 416 – 418

HotSpot 235 – 236

C1 (客户端编译器) 235

C2 (服务器编译器) 235

实时 Java 235 – 236

HTTP/2 19 – 24

头部阻塞 19 – 20

HTTP 头部性能 21

Java 11 22 – 24

其他考虑因素 22

受限连接 20 – 21

TLS 21 – 22

HttpClient 类型 22

HttpRequest 类型 22

HttpResponse.BodyHandler 接口 23

巨无霸区域 230

I

IaaS (基础设施即服务) 405

Idc 指令 106

无身份引用 631

if 条件 508

if 表达式 279 – 281

if 指令 108

if 语句 279, 448

IFn 接口 307, 584

IFoo 接口 641

  • -illegal-access 命令行开关 42, 45

ILP (指令级并行) 220

图片

构建 Docker 406 – 409

使用 Gradle 构建 412 – 414

选择基础 411 – 412

命令式语言 254 – 255

实现配置 383

导入语句 50

in 关键字 280

孵化特性 15 – 16

indexFor() 辅助方法 178

无限流 646

初始化块,Kotlin 283

方法 103

初始化阶段 85

内联类型 628

内联方法 234, 236

插入方法 455

实例变量 100

安装阶段 351

instanceof 关键字 74 – 75, 630

类加载器示例 94 – 95

Int 类型 288

Integer 类型 73, 100, 563, 626

集成测试 466 – 476

使用 Selenium 进行端到端测试示例 474 – 476

收集容器日志 471

安装 testcontainers 467

Postgres,示例 472 – 473

Redis,示例 467 – 471

接口方法 575 – 576

InterfaceMethodref 100

内部结构

封装 30

invokedynamic 589 – 593

方法句柄 583 – 589

查找 585 – 586

MethodHandle 583 – 584

MethodType 584 – 585

反射 vs. 代理 vs. 586 – 589

方法调用 571 – 578

最终方法 578

接口方法 575 – 576

特殊方法 576 – 577

虚拟方法 572 – 575

保护 32 – 33

反射内部 578 – 583

小的内部更改 593 – 600

紧凑字符串 596 – 597

嵌套成员 597 – 600

字符串连接 593 – 595

不安全

概述 600 – 604

使用支持的 API 替换 604 – 608

与 Java 的互操作性

Kotlin 294 – 297

语言 263 – 264

使用 Clojure 330 – 335

从 Clojure 调用 Java 331 – 332

使用 REPL 进行探索性编程 334 – 335

Clojure 值的 Java 类型 332 – 333

Clojure 调用的本质 332

从 Java 使用 Clojure 335

使用 Clojure 代理 333 – 334

解释性语言 252 – 253

中断线程 140 – 142

算法导论 (Cormen 等人) 128

调用操作码 109 – 112

调用函数 275

调用方法 584

invoke 操作码 102, 105, 110, 158

调用()方法 115, 307, 543, 578

InvokeDynamic 100

invokedynamic 64, 589 – 593

invokedynamic 操作码 109, 267, 588

invokeExact() 方法 586

invokeinterface 操作码 109, 111

invokespecial 操作码 102 – 103, 109, 578

调用静态操作码 109, 507

invokeSuspend 方法 552

invokevirtual 操作码 103, 109, 572, 574

调用方法 571 – 578

final 方法 578

接口方法 575 – 576

特殊方法 576 – 577

虚拟方法 572 – 575

is 操作符 290

isDone() 方法 197, 199

ISeq 接口 328

isInterrupted() 方法 141

地峡 612

it 方法 480

iterator() 方法 186, 334

J

JAR 工具 51

JARs, 多版本 49 – 52

构建示例 49 – 52

在 Maven 中 366 – 369

Java

Java 11 中的变化 17 – 25

集合工厂 (JEP 213) 17 – 18

HTTP/2 (Java 11) 19 – 24

企业模块的移除 (JEP 320) 18 – 19

单文件源代码程序(JEP 330)24 – 25

Clojure 与 330 的互操作性 – 335

从 Clojure 调用 Java 331 – 332

使用 REPL 进行探索性编程 334 – 335

Clojure 值的 Java 类型 332 – 333

Clojure 调用的本质 332

从 Java 使用 Clojure 335

使用 Clojure 代理 333 – 334

使用 Docker 开发应用程序 411 – 424

使用 Gradle 构建镜像 412 – 414

在 Docker 中调试 421 – 423

使用 Docker Compose 进行本地开发 418 – 421

使用 Docker 进行日志记录 423 – 424

端口和主机 416 – 418

在 Docker 中运行构建 414 – 416

选择基础镜像 411 – 412

增强类型推断(var 关键字)9 – 13

免费使用 637 – 638

语言和平台功能 13 – 16

更改语言 14 – 15

定义 4 – 6

孵化功能和预览功能 15 – 16

JSRs 和 JEPs 15

语法糖 14

作为函数式编程语言的局限性 502 – 515

闭包 509 – 511

柯里化和部分应用 513

高阶函数 505 – 506

Java 类型系统和集合 514 – 515

惰性 511 – 512

可变性 503 – 505

纯函数 503

递归 506 – 509

新的 Java 发布模型 6 – 9

付费支持选项 639

项目琥珀 610 – 612

项目 loom 618 – 625

使用虚拟线程编程 623 – 625

发布 625

线程构建类 622 – 623

虚拟线程 621 – 622

项目巴拿马 612 – 618

项目瓦尔哈拉 626 – 633

更改语言模型 630

值对象的影响 630 – 633

泛型 633

严格的语法 258 – 259

线程模型 123 – 124

Java 8

向后兼容性 640 – 641

紧凑配置文件 47 – 49

默认方法 641 – 642

Gradle 和 390

处理原始数据 646 – 647

无限流 646

集合的限制 645 – 646

Maven 和 365 – 366

并行操作 648

流 642 – 645

Java 11 17 – 25

集合工厂(JEP 213)17 – 18

HTTP/2(Java 11)19 – 24

企业模块的移除(JEP 320)18 – 19

单文件源代码程序(JEP 330)24 – 25

Java 17

instanceof 运算符 74 – 75

模式匹配和预览功能 75 – 77

记录 60 – 69

紧凑记录构造函数 67 – 69

名义类型 66 – 67

密封类型 69 – 74

Switch 表达式 57 – 60

文本块 55 – 57

Java 18 633 – 634

Java 代理 94

Java 命令 354, 396

Java 并发实践 (Goetz) 168

Java EE (Java 企业版) 18

Java 语言 4

Java 模块系统,Java 模块系统 (Parlog) 116

Java 模块

为模块设计 46 – 52

Java 8 紧凑配置文件 47 – 49

多版本 JARs 49 – 52

分割包 47

基本语法 34 – 37

导出和需求 36 – 37

传递性 37

构建第一个模块化应用 40 – 46

命令行开关 41 – 43

执行 43 – 45

反射和 45 – 46

加载 37 – 40

应用程序模块 39

自动模块 39 – 40

平台模块 38 – 39

未命名模块 40

概述 27 – 34

模块图 31 – 32

新的访问控制语义 33 – 34

Project Jigsaw 28 – 31

保护内部结构 32 – 33

Java 性能调优 220 – 224

缓存未命中 222 – 224

时间在 221 中的作用 – 222

精度 221 – 222

粒度 222

测量 222

网络分布式定时 222

精度 221

java 进程 410

Java SE (Java 标准版) 18, 637 – 638

Java SE 8 638

Java SE 11 638

Java SE 17 (LTS) 638 – 639

Java Streams 地址 646

java-library 插件 380

java.base 模块 31, 581

java.io 包 39

java.lang 包 39

java.lang: IdentityObject 接口 630

java.lang.Enum 库类型 70

java.lang.management 包 49

java.lang.Record 类 64

java.lang.reflect 包 115

java.lang.reflect.Method 类 115, 578

java.lang.String 对象 310

java.lang.Thread 类 137, 200

java.lang.Thread 子类 622

java.lang.Void 类型 117

java.library.path 系统属性 614

java.net.http 模块 16, 41

java.se 模块 39

java.time API 499

java.util 集合接口 277

java.util 包 39, 334

java.util.concurrent 包 169

java.util.concurrent.atomic 包 170

java.util.concurrent.Flow 发布者 23

java.util.function 接口 511

javap 编译器 506

javac 源代码编译器 11

javap 95 – 96

jcmd 240

JCP (Java 社区进程) 15

JDK 并发库

原子类 170 – 172

BlockingQueue 模式 188 – 197

使用 BlockingQueue API 194 – 195

使用 WorkUnit 196 – 197

现代并发应用程序的构建块 169 – 170

ConcurrentHashMap 类 176 – 185

并发字典的方法 180 – 182

Dictionary 类的限制 179 – 180

简化 HashMap 176 – 179

使用 182 – 185

CopyOnWriteArrayList 类 185 – 188

CountDownLatch 174 – 175

Future 类型 197 – 200

锁类 172 – 174

任务和执行 200 – 206

缓存线程池 205

Executor 接口 202

固定线程池 204 – 205

任务建模 200 – 201

单线程执行器 203

STPE (ScheduledThreadPoolExecutor) 205 – 206

JDK Flight Recorder 238 – 246

Flight Recorder 239 – 240

Mission Control 240 – 246

jdk.attach 模块 43

jdk.incubator 命名空间 16

jdk.incubator.foreign 模块 613

jdk.internal.jvmstat 模块 43

jdk.unsupported 模块 600

JEPs (JDK Enhancement Proposal) 15

jextract 工具 613

JIMAGE 格式 28

jimage 工具 29

JIT (即时) 编译 233 – 238

去优化 238

动态编译

单态调用和 236 – 237

原因 234

HotSpot 235 – 236

C1 (客户端编译器) 235

C2 (服务器编译器) 235

实时 Java 235 – 236

内联方法 236

读取编译日志 237 – 238

jlink 工具 29, 52, 393 – 397

JLS (Java 语言规范) 4, 147

JMC (JDK 任务控制) 240, 423

JMM (Java 内存模型) 120, 147 – 149, 601

jmod 命令 36

JMOD 格式 28

JNI (Java 本地接口) 15, 612

join() 调用 139

Clojure 的乐趣 (Fogus 和 Houser) 299

JPMS (Java 平台模块) 26

JRE (Java 运行时库) 348

JRuby 语言 255

jshell 交互环境 12, 623

jsr 指令 (已弃用) 108

JSRs (Java 规范请求) 15

JUnit 4 和 5 459 – 464

JUnit in Action (Tudose) 443

junit-vintage-engine 依赖项 460

JVM (Java 的虚拟机) 30 – 31, 571

JVM_DefineClass() 本地方法 83

JVM_DefineClassWithSource() C 函数 92

JVMTI (JVM 工具接口) 213, 607

Jython 语言 255

K

K8s (Kubernetes) 424

key-fn 函数 318

keying 函数 318

keys 函数 485

keySet() 方法 179

klass 573

唐纳德·E·克努特 216

Kotlin 259 – 260

并发 291 – 294

便利性和简洁性 271 – 281

集合 277 – 279

等价性 273

函数 273 – 277

if 表达式 279 – 281

从更少开始 271 – 272

变量 272 – 273

协程 549 – 556

协程作用域和调度 554 – 556

协程的工作原理 549 – 554

对类和对象的另一种看法 281 – 287

Gradle 添加 387 – 388

Groovy 与 378

安装 271

Java 兼容性 294 – 297

使用原因 271

安全性 287 – 291

空安全 288 – 289

智能转换 289 – 291

使用 476 进行规范式测试 – 481

Kotlin 函数式编程 515 – 530

闭包 517 – 518

柯里化和部分应用 518

不可变性 519 – 522

惰性求值 525 – 526

纯函数和一阶函数 516 – 517

序列 526 – 530

尾递归 522 – 525

kotlin 交互式 shell 271

kotlin-coroutine-core 库 291

kotlin-stdlib 库 297, 388

kotlin.collections 接口 277

kotlin.collections 包 277

kotlin.collections.List 接口 520

kotlin.collections.Map 接口 520

kotlinc 命令行编译器 271

kotlinx.collections.immutable library 521

Kt class 294

kubectl command 425 – 427

Kubernetes 424 – 432

L

L-type descriptors 632

lambda expressions 275, 591 – 593

LambdaMetafactory.metafactory() method 592

language features, Java 13 – 16

changing language 14 – 15

defined 4 – 6

incubating and preview features 15 – 16

instanceof operator 74 – 75

JSRs and JEPs 15

Pattern Matching and preview features 75 – 77

Records 60 – 69

compact record constructors 67 – 69

nominal typing 66 – 67

sealed types 69 – 74

Switch Expressions 57 – 60

syntactic sugar 14

Text Blocks 55 – 57

latches 174

lateinit pattern 479

latency 210

launch method 292, 552

layers 408

laziness

Clojure FP 532 – 534

Java limitations as FP language 511 – 512

Kotlin FP 525 – 526

overview 501

lazy evaluation 501, 646

lazy() function 525

Lazy interface 525 – 526

LazySeq lazy implementation 333

LazyThreadSafetyMode enumeration 526

ldc (load constant) opcode 594

learning languages 264

library module 39

LibSPNG 614 – 617

生命周期方法 202

LIMIT 实例 70

LinkageError 类 91

Lisp (大量令人烦恼的愚蠢括号) 308

列表推导式 531

列表接口 313, 641, 643

列表对象 641

  • -list-modules 标志 38

list-modules 开关 41

listOf 只读辅助函数 520

列表,Clojure 312 – 315

实时系统,并发 126

加载操作码 106 – 107

loadClass() 方法 88

加载和链接,类 83 – 85

通过 214 自动仪器化

与反射结合 116 – 117

初始化 85

准备 85

解析 85

验证 84 – 85

加载 Java 模块 37 – 40

应用程序模块 39

自动模块 39 – 40

平台模块 38 – 39

未命名的模块 40

使用 Docker Compose 的本地开发 418 – 421

LocalDate 类 512

锁类 172 – 174

锁对象 172 – 173

锁条带化 183

log4j2 库 424

可记录性守卫 216

使用 Docker 记录 423 – 424

日志支柱 433

Long 100

lookupClass() 表达式 588

lookupswitch 操作码 108

循环,Clojure 319 – 320

丢失更新 151 – 153

低暂停收集器 229

低风险项目区域 262 – 263

lreturn 操作码 522

LVTI (局部变量类型推断) 10

M

M:1 线程 619

宏展开时间 336

宏,Clojure 304, 335 – 341

main 方法 87, 296, 354, 593

主线开发模型 6

清单,Maven 354

手动仪器 213

Map 集合 516

Map 接口 111, 184

Map.Entry 接口 177

map()方法 314, 511, 528, 641 – 643, 647

Map,Clojure 312 – 315

标记和清除 225 – 227

Maven 350 – 376

添加另一种语言 355 – 357

编写 Maven 插件 372 – 376

构建生命周期 350 – 351

构建 353 – 354

控制清单 354

依赖管理 360 – 364

安装和 POM (项目对象模型) 351 – 353

模块和 369 – 372

模块化应用程序 370 – 372

模块化库 369 – 370

超越 Java 8 365 – 366

多版本 JARs 在 366 – 369

审查 364 – 365

测试 357 – 360

Maven 目标 373

maven 编译器插件 367

maven-failsafe-plugin 359

maven-publish 插件 392

maven-surefire-plugin 359

测量,性能分析 212 – 217

实现更高性能的成本 215 – 216

过早优化的危险 216 – 217

如何进行 213 – 214

通过类加载自动仪器化 214

直接测量 213 – 214

性能目标 214 – 215

时间和 222

要测量什么 212 – 213

何时停止 215

megamorphic 582

memoized 479

内存

领域 227

在 Panama 中处理原生内存 617 – 618

延迟层次结构 219 – 220

软件事务内存 565 – 569

Memory API 612 – 618

在 Panama 中处理原生内存 617 – 618

LibSPNG 示例 614 – 617

MemoryAddress 类 613

MemorySegment 接口 613, 617

MergeSort 算法 539

Meszaros, Gerard 449

metafactories 592

Method 类 579

方法句柄 583 – 589

查找 585 – 586

MethodHandle 583 – 584

MethodType 584 – 585

反射与代理的比较 586 – 589

Method Handles API 583

方法调用 571 – 578

final 方法 578

接口方法 575 – 576

特殊方法 576 – 577

虚拟方法 572 – 575

方法对象 85, 115, 579

方法签名 96 – 98

MethodAccessor 对象 580

MethodHandle 100, 592

MethodHandles.Lookup 类型 589

MethodHandles.lookup() 方法 584

Methodref 100

方法 498

MethodType 100, 584 – 585, 589

度量支柱 433

minikube 命令 425

任务控制 240 – 246

错误,在 Clojure 中 305 – 308

混合集合 230

模拟对象 449, 456 – 457

mock() 方法 457

模拟 457 – 458

建模任务 200 – 201

Callable 接口 201

FutureTask 类 201

*《现代 Java 动作,第 2 版》(Urma, Fusco, Mycroft)640

模块化应用程序

Gradle 392 – 393

Maven 和 370 – 372

模块化 Java 运行时 28 – 29

模块化库

Gradle 391 – 392

Maven 和 369 – 370

模块图 31 – 32

模块关键字 35

模块名称 36

模块路径 38

模块解析 95

module-info.java 声明 394

module-info.java 模块描述符 614

  • -module-path 开关 41

ModuleDeclaration 生成式 35

ModuleDescriptor 类型 116

ModuleDirective 生成式 35

模块 27

类加载和 95

Gradle

JLink 393 – 397

模块化应用 392 – 393

模块化库 391 – 392

Gradle 和 391 – 397

Maven 和 369 – 372

模块化应用 370 – 372

模块化库 369 – 370

monitorenter 操作码 112, 154, 157

monitorexit 操作码 112, 154, 157

单态调用 235 – 237

摩尔定律 217 – 219

mul 操作码 108

多方法 341

多版本 JAR 49 – 52

构建示例 49 – 52

在 Maven 中 366 – 369

多阶段构建 414

可变性 503 – 505

默认可变 301

可变状态 304

mvn dependency:tree 命令 362

Mycroft, Alan 640

N

  • -name container-name 参数 421

NameAndType 100

命名元组 66

本地方法 612

自然数 531

负缓存 90

巢居者 597, 600

网络分布式定时 222

new 关键字 281

新名称 112

new 操作码 103

NEW 线程状态 130

newCondition() 方法 174

newFixedThreadPoolContext 函数 555

Newland, Chris 246

newWithPlatformReleaseN() 方法 641

next() 方法 335

节点 558

名义类型 66 – 67

非 JVM 语言 261 – 262

不可表示的类型 12

非确定性暂停 226 – 227

无函数 279

不可重复读 160

NOP (无操作) 219

NoSuchMethodError 异常 349

notify() 方法 630

NullPointerException (NPE) 288, 631

O

Object 类 114, 630

对象头 573

objectFieldOffset() 方法 602

对象

Kotlin 281 – 287

Kubernetes 424

可观察性,容器 432 – 435

ofEntries() 方法 18

offer() 方法 194

ofPlatform() 方法 622

ofVirtual() 方法 622

OO (面向对象) 语言 254

指令集(操作码)

算术 107

执行流程控制 108 – 109

调用 109 – 112

加载和存储 106 – 107

概述 105 – 106

平台操作 112

112 的快捷形式 – 113

开放关键字 35, 285

开放模块 45

OpenJDK 637 – 638

opens 关键字 35

OpenTelemetry 433 – 434

操作

自动化 346

Clojure 315 – 316

Optimizing Java (Evans, Gough, Newland) 246

Optional 类型 288

或函数 484

Oracle JDK 637 – 638

Oracle OpenJDK 构建 637 – 638

编排器 424

有序关闭 203

OrderPartition 类型 68

org.apache.catalina.Executor 类 206

org.beryx.jlink 插件 395

org.graalvm.js.scriptengine 模块 38

org.graalvm.sdk 模块 38

org.junit.jupiter.junit-jupiter-api 依赖 459

org.junit.jupiter.junit-jupiter-engine 依赖 459

org.testcontainers:testcontainers 库 472

原始语言,重新实现与 255 – 256

操作系统 (操作系统) 619

OSR (栈上替换) 238

overhead, concurrency 127 – 128

P

-P 开关 417

-p 开关 577

包阶段 350

包保护默认 282

付费支持选项 639

并行收集器 231 – 232

并行操作 648

并行流 547 – 549

parallel() 方法 648

父类加载器 88

Parlog, Nicolai 116

部分应用

Java 作为函数式编程语言的限制 513

Kotlin 函数式编程 518

概述 502

偏序 147

按值传递语言 510

编写测试,传递 445

模式匹配 74 – 77, 290

模式变量 75

暂停目标 229

pcalls 函数 563 – 565

待处理队列 193

性能

替代 JVM 语言 266

并发 126

目标 214 – 215

在容器中 434 – 435

性能分析

垃圾回收 224 – 233

内存区域 227

基础 225

配置参数 232 – 233

完整集合 228

G1 229 – 231

标记和清除 225 – 227

并行收集器 231 – 232

安全点 228

年轻代收集器 228

Java 性能调优 220 – 224

缓存未命中 222 – 224

时间在 221 – 222

JDK 飞行记录器 238 – 246

飞行记录器 239 – 240

任务控制 240 – 246

即时编译 (JIT) 233 – 238

去优化 238

动态编译 234, 236 – 237

HotSpot 235 – 236

内联方法 236

阅读编译日志 237 – 238

对 212 的实用方法 – 217

实现更高性能的成本 215 – 216

过早优化的危险 216 – 217

如何进行测量 213 – 214

性能目标 214 – 215

要测量什么 212 – 213

何时停止 215

术语 209 – 211

容量 211

退化 211

效率 211

延迟 210

可扩展性 211

吞吐量 210

利用率 210

为什么要关心 217 – 220

内存延迟层次 219 – 220

摩尔定律 217 – 219

PermGen (永久生成) 231

permgen Java 堆 572

允许 | 警告 | 拒绝设置 45

允许关键字 72

持久数据结构 521, 557 – 563

PersistentVector.Node 内部类 559

阶段 350

平台功能,Java 13 – 16

更改语言 14 – 15

定义 4 – 6

孵化功能和预览功能 15 – 16

JSRs 和 JEPs 15

语法糖 14

平台模块 38 – 39

平台操作指令 112

PlatformClassLoader 86

<插件> 元素 354

插件 351

编写 Maven 372 – 376

Gradle

创建自定义插件 399 – 400

概述 378 – 379

加法函数 555

Pod 对象类型 425

波兰表示法 308

poll() 方法 194

在 JVM 上进行多语言编程 256 – 262

竞争者 260 – 262

GraalVM 261

Groovy 260

非 JVM 语言 261 – 262

Scala 260 – 261

非 Java 语言,使用原因 257 – 259

新兴语言 259 – 260

Clojure 260

Kotlin 259 – 260

POM (项目对象模型) 351 – 353

端口,Docker 416 – 418

Postgres 472 – 473

Postgres 测试容器对象 473

精度 221

谓词接口 644

谓词 278

抢占式线程 624

过早优化 216 – 217

准备阶段 85

预处理器指令 336

预览功能 15 – 16, 75 – 77

Price 接口 451, 467

原始类 628

原始类型特殊化 647

原始值类型 632

原始类型 646 – 647

ProcessHandle 类 50

项目 Amber 609 – 612

<项目> 元素 354, 358

项目 Jigsaw 28 – 31

封装内部 30

JVM 现在是模块化的 30 – 31

模块化 Java 运行时 28 – 29

项目 Lambda 640

项目 Loom 618 – 625

使用虚拟线程编程 623 – 625

发布 625

线程构建类 622 – 623

虚拟线程 621 – 622

项目巴拿马

Foreign Function and Memory API 612 – 618

在巴拿马处理原生内存 617 – 618

LibSPNG 示例 614 – 617

概述 612

项目 Valhalla 626 – 633

改变语言模型 630

值对象的影响 630 – 633

泛型 633

属性 487

<属性> 元素 354

属性测试 263

基于属性的测试 263, 481 – 493

clojure.spec 483 – 486

clojure.spec 和 test.check 492 – 493

clojure.test 481 – 483

test.check 487 – 492

协议 341

provides 关键字 35

代理

Clojure 333 – 334

反射与 586 – 589

public static final 变量 70

public 可见性 284

纯函数

Java 作为函数式编程语言的限制 503

Kotlin 函数式编程 516 – 517

概述 498

put() 方法 111, 178

putAll() 方法 179

putfield 操作码 106, 152, 159, 168, 630

putIfAbsent() 方法 184

putstatic 操作码 102, 106

pwd 命令 410

Q

Q 类型描述符 632

限定导出 39

查询变量 56

队列接口 188

快速检查函数 490

R

RAII(资源获取即初始化)模式 617

范围 529

reader 宏 321 – 323

实时 Java 235 – 236

rebinding 304

接收器对象 115

记录 60 – 69

紧凑记录构造函数 67 – 69

名义类型 66 – 67

记录类 63

记录组件 63

记录命名风格 145

递归关系 534

递归

Java 作为函数式编程语言的限制 506 – 509

Kotlin 522 – 526

概述 500

Redis 419, 467 – 471

redis 键 419

redis 对象 471

reduce() 方法 643, 645

可重入锁实现 172

可重入读写锁实现 172

重构测试 445 – 446

引用透明性 498

反射 45 – 46, 113 – 118

组合类加载 116 – 117

内部结构 578 – 583

概述 114 – 116

问题 117 – 118

代理与 586 – 589

重实现语言 255 – 256

  • -release 标志 51

remove (int index) 列表变异方法 514

remove() 方法 184

REPL (读取-评估-打印循环) 302

使用 334 – 335

入门 303 – 305

replace() 方法 184

requires 关键字 35 – 37

解析阶段 85

ResourceScope 类 617

响应对象 23

受限连接 20 – 21

受限关键字 35

ret 操作码 (已弃用) 108

可重用性 126 – 127

Rhino 语言 255

根模块 40

RUN Docker 命令 407

运行测试函数 482

run() 方法 102, 138, 200

Runnable 接口 255

RUNNABLE 线程状态 130, 138

非 Java 语言的运行时环境 266 – 267

运行时布线 93

运行时管理的并发 121

runtimeClasspath Gradle 依赖配置 383 – 384

runtimeOnly Gradle 依赖配置 384

rust-bindgen 工具 618

S

安全点 228

安全性

并发类型安全 125 – 126

Kotlin 287 – 291

空安全 288 – 289

智能转换 289 – 291

SAM (单抽象方法) 146

Scala 260 – 261

可伸缩性 211

标量化 628

scheduleAtFixedRate()方法 205

ScheduledThreadPoolExecutor (STPE) 205 – 206

scheduleWithFixedDelay()方法 205

Schwartzian 转换 318

<作用域>元素 382

作用域 550

作用域,协程 554 – 556

脚本,Gradle 378

密封接口 72

密封关键字 73

密封类型 69, 71 – 74

次构造函数 284

SEDA (分阶段事件驱动架构)方法 621

SegmentAllocator 接口 613, 617

Selenium 474 – 476

语义版本控制 349

语义。语法和语义

发送方法 23

sendAsync 方法 23

seq (序列) 325

seq 方法 557

sequence lambda 530

Sequence接口 526

sequenceOf()函数 528

序列 527

Clojure 325 – 330

Kotlin 函数式编程 526 – 530

服务器 JRE 28

-server 开关 235

服务抽象 430

服务 Bean 94

服务 Kubernetes 对象类型 425

Set 接口 179, 643

setAccessible() 方法 45,117

集合,Clojure 312 – 315

浅不可变性 147

共享可变状态 544

快捷形式 112 – 113

收缩 491

关闭标志 167

shutdown() 方法 195

无副作用 498,644

单文件源代码程序(JEP 330)24 – 25

单线程执行器 203

站点生命周期 351

size() 方法 644

斯凯纳,史蒂文 128

小内部更改 593 – 600

紧凑字符串 596 – 597

巢居者 597 – 600

字符串连接 593 – 595

智能转换 289

软件事务内存 565 – 569

sort() 方法 641

  • -source 标志 24

特殊形式,Clojure 309 – 311

特殊方法 576 – 577

规范式测试 476 – 481

规范 476

Spek 476 – 481

拆分包 47

SREs(软件可靠性工程师)433

SSD(固态硬盘)219

栈 225

start() 方法 138,622

startVirtualThread() 静态方法 622

状态机 550

状态相关更新 137

静态分析 389 – 390

静态方法 287

静态同步方法 129

静态类型 253 – 254

STDERR 输出流 424

STDOUT 输出流 424

斯蒂尔,盖·L. 226

stop() 方法 166, 293

store 操作码 106

Stream 抽象 640, 642

Stream 接口 511, 642 – 643

Stream 对象 643

stream() 方法 511, 643

流 642 – 646

String 构造函数 595

字符串表示 597

字符串类型 73, 273, 288, 589, 593

StringBuilder 对象 216, 594

StringConcatFactory 类 595

字符串

紧凑型 596 – 597

连接 593 – 595

StringSeq 类 335

结构数组 629

结构化共享 557

结构化类型 13, 66

stub 对象 449, 451 – 453

STW (Stop-the-World) 226

子操作码 108

submit() 方法 543

敏捷成功 (Cohn) 438

sudo ip addr show 命令 418

求和类型 73

sun.misc.Unsafe 类 170, 600

sun.net 包 33

supplyAsync() 工厂方法 546

surefire 插件 459

suspend 函数 293, 530, 550

suspend 关键字 293, 550

suspend() 方法 143

Sutter, Herb 218

switch 表达式 57 – 60

switch 语句 57

Symbol 类 305

同步

字节码中 153 – 157

锁和 128 – 130

原因 135 – 137

基于同步的并发 119

synchronized 关键字 120, 157

synchronized 关键字 150, 157 – 158

synchronizedMap()方法 181

同步关系 147

语法糖 14

语法和语义

Clojure 309 – 323

算术、相等和其他操作 315 – 316

函数 316 – 319

列表、向量、映射和集合 312 – 315

循环 319 – 320

读取宏和调度 321 – 323

特殊形式 309 – 311

Java 模块 34 – 37

导出和需求 36 – 37

传递性 37

合成访问方法 599

System.currentTimeMillis()方法 110

System.getenv()方法 411

T

tableswitch 操作码 108

尾位置 508

尾递归 507, 522 – 525

tailrec 关键字 524

尾递归非尾递归函数 525

take()方法 194, 529

任务管理器对象 167

任务和执行 200 – 206

缓存线程池 205

执行器接口 202

固定线程池 204 – 205

Gradle 377 – 378

建模任务 200 – 201

Callable 接口 201

FutureTask 类 201

单线程执行器 203

STPE (ScheduledThreadPoolExecutor) 205 – 206

tasks.jar 配置 381

TDD (测试驱动开发) 441 – 448

多个用例示例 446 – 448

概述 442 – 443

单一用例示例 443 – 448

重构测试 445 – 446

编写失败的测试(红色) 443 – 445

编写通过测试(绿色) 445

终端方法 511

已终止线程状态 131

测试调用 478

测试双倍 449 – 458

哑对象 449 – 451

伪造对象 454 – 456

模拟对象 456 – 457

模拟的问题 457 – 458

存根对象 451 – 453

测试函数 478

测试 lambda 479

测试方法 480

测试阶段 350

测试支持,替代 JVM 语言 264

测试任务 477

测试编译阶段 353

测试驱动开发:通过示例(Beck) 441, 443

test.check 487 – 493

test.check-compatible 生成器 492

测试编译 Classpath Gradle 依赖配置 384

测试编译 Only Gradle 依赖配置 384

Testcontainers 466 – 476

使用 Selenium 进行端到端测试的示例 474 – 476

收集容器日志 471

安装 467

Postgres,示例 472 – 473

Redis,示例 467 – 471

testImplementation Gradle 依赖配置 384, 389

测试

从 JUnit 4 到 5 459 – 464

Gradle 388 – 389

如何 438 – 441

使用 Testcontainers 进行集成测试 466 – 476

使用 Selenium 进行端到端测试的示例 474 – 476

收集容器日志 471

安装 testcontainers 467

Postgres,示例 472 – 473

Redis,示例 467 – 471

Maven 357 – 360

使用 Clojure 进行基于属性的测试 481 – 493

clojure.spec 483 – 486

clojure.spec 和 test.check 492 – 493

clojure.test 481 – 483

test.check 487 – 492

原因 438

使用 Spek 和 Kotlin 进行规范式测试 476 – 481

TDD(测试驱动开发) 441 – 448

多用途案例示例 446 – 448

概述 442 – 443

单用途案例示例 443 – 448

测试替身 449 – 458

哑对象 449 – 451

伪造对象 454 – 456

模拟对象 456 – 457

模拟的问题 457 – 458

存根对象 451 – 453

testRuntimeClasspath Gradle 依赖配置 384

testRuntimeOnly Gradle 依赖配置 384

文本块 55 – 57

然后形成 338

thenApply() 方法 546

理论入门,并发 120 – 124

Amdahl 的定律 121 – 122

硬件 121

Java 线程模型 123 – 124

经验教训 124

Thread 熟人假设 120 – 121

线程

熟人假设 120 – 121

线程构建类 622 – 623

Thread.Builder 类 622

Thread.interrupted() 方法 141

Thread.State 枚举 130

Thread.stop() 方法 143

ThreadDeath 异常 143

ThreadFactory 实例 623

线程模型 123 – 124

ThreadPoolExecutor 类 203

线程

状态模型 130 – 131

状态和方法 137 – 143

已弃用的线程方法 143

中断线程 140 – 142

处理异常和线程 142 – 143

吞吐量 210

分层编译 236

时间和性能调整 221 – 222

精度 221 – 222

粒度 222

测量 222

网络分布式计时 222

精度 221

时间变量 110

TIMED_WAITING 线程状态 131

时间片 624

TLS, HTTP/2 (Java 11) 21 – 22

转换为关键字 35

ToIntFunction 特殊函数类型 647

工具,替代 JVM 语言 264

toString() 方法 64, 585

touchEveryItem() 函数 223

传递性闭包 85

传递性依赖 347

传递性关键字 35

传递性 37

传输级更新 19

真实值,Clojure 316

try-with-resources 617

Tudose, Cătălin 443

图灵完备性 500

类型 1 虚拟机 402

类型 2 虚拟机 402 – 403

类型描述符 96

类型擦除 154

类型提示 272

类型推断(var 关键字) 9 – 13

类型模式 75

类型系统 514 – 515

U

无界执行器 625

联合类型 73

单元测试 439

未命名的模块 40

Unsafe

概述 600 – 604

替换为支持的 API 604 – 608

隐藏类 607 – 608

VarHandles 605 – 607

UnsupportedClassVersionError 错误 90

非同步读取 158 – 160

URLCanonicalizer 类 32 – 33

Urma, Raoul-Gabriel 640

用户命名空间 310, 483

userid 整数 9

uses 关键字 35

Utf8 99 – 100

利用率 210

V

val 声明 519

valid? 函数 483

验证阶段 350

值类 628, 630 – 633

值记录 629

ValueObject 接口 630

values 510

values() 方法 179

Var 类,Clojure 305

var 关键字 253, 272

var 对象,Clojure 302

var 属性,Kotlin 290

VarHandles 605 – 607

变量,Kotlin 272 – 273

可变参数函数 329 – 330

变体代码 49

向量,Clojure 312 – 315

验证阶段 84 – 85

Verify 阶段 351

验证行为 457

Vermeulen, Marco 516

虚拟方法 572 – 575

虚拟线程

概述 621 – 622

使用编程 623 – 625

VirtualMachineDescriptor 类 43

VMs (虚拟机) 403

VMSpec (JVM 规范) 4

void 方法 117, 332

volatile 关键字 120, 137

Volatile Shutdown 模式 166 – 168

W

等待序列懒序列 565

wait() 方法 630

when 关键字 280

when() 方法 457

while 循环 140, 193, 319

with 关键字 35

withers 499

withfield 操作码 631

避免工作,Gradle 381 – 382

工作窃取算法 543 – 544

Working Effectively with Legacy Code (Feather) 442

WorkUnit 196 – 197

包装器 377

X

xUnit Test Patterns (Meszaros) 449

-XX:+UnlockCommercialFeatures 选项 240

Y

yield 关键字 58, 554

年轻集合 228

Z

僵尸,编译日志和 238

posted @ 2025-11-15 13:06  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报