Java-技术手册第八版-全-

Java 技术手册第八版(全)

原文:zh.annas-archive.org/md5/450d5a6a158c65e96e7be41e1a8ae3c7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书是一本桌面 Java 参考书,设计为您编程时放在键盘旁边的忠实伴侣。第一部分,“介绍 Java” 是一本快节奏的“无废话”介绍,涵盖了 Java 编程语言和 Java 平台核心运行时方面的内容。第二部分,“使用 Java 平台” 是一个参考部分,混合了核心概念的阐述和重要核心 API 的示例。本书覆盖了 Java 17,但我们知道有些团队可能还未采用该版本,因此在可能的情况下,我们标注了某些功能在 Java 8 之后引入。

第八版的变化

本书的第七版覆盖了 Java 11,而本版则覆盖了 Java 17。然而,随着 Java 9 的到来,Java 的发布过程发生了重大变化,并且某些 Java 的发布现在被标记为 长期支持(LTS)版本。因此,Java 17 是 Java 11 之后的下一个 LTS 发布版本。

在第八版中,我们试图更新“概述”指南的概念。现代 Java 开发者需要了解的不仅仅是语法和 API。随着 Java 环境的成熟,诸如并发性、面向对象设计、内存和 Java 类型系统等主题对所有开发者都变得更加重要。

在本版中,我们采取的方法是,只有最新版本的 Java 才可能引起大多数 Java 开发者的兴趣,因此我们通常只在 Java 8 之后引入新功能时进行标注。

例如,模块系统(Java 9 中引入)对于一些开发者来说可能仍然是新事物,并且它代表了一个重大的变化。然而,它也是一个高级主题,并在某些方面与语言的其余部分分开,因此我们将其处理限制在了一个单独的章节中。

本书的内容

前六章记录了 Java 语言和 Java 平台的内容,它们都应被视为必读内容。本书对 Oracle/OpenJDK(开放 Java 开发工具包)的实现有所偏好,但并不是过于偏袒。使用其他 Java 环境的开发者仍然会找到大量内容可供参考。第一部分 包括:

第一章,“介绍 Java 环境”

本章是 Java 语言和 Java 平台的概述。它解释了 Java 的重要特性和优势,包括 Java 程序的生命周期。我们还涉及了 Java 安全性,并回答了一些关于 Java 的批评。

第二章,“从基础开始的 Java 语法”

本章详细说明了 Java 编程语言的细节,包括 Java 8 语言变化。这是一个长篇详细的章节,不假设有大量编程经验。有经验的 Java 程序员可以将其用作语言参考。有着诸如 C 和 C++之类语言丰富经验的程序员可以通过仔细阅读本章迅速掌握 Java 语法;而仅具有少量编程经验的初学者可以通过仔细学习本章来学习 Java 编程,尽管最好与入门文本(如 O’Reilly 的Head First Java,作者为 Kathy Sierra、Bert Bates 和 Trisha Gee)一起阅读。

第三章,“Java 中的面向对象编程”

本章描述了如何使用 Java 中的类和对象编写简单的面向对象程序的基本 Java 语法,该章节不假设有面向对象编程的先验经验。它可供新程序员作为教程使用,也可供有经验的 Java 程序员作为参考使用。

第四章,“Java 类型系统”

本章在 Java 中的面向对象编程的基本描述基础上,介绍了 Java 类型系统的其他方面,如泛型类型、枚举类型和注解。有了这更全面的视角,我们可以讨论 Java 8 中最大的变化——Lambda 表达式的引入。

第五章,“Java 中的面向对象设计简介”

本章是对设计声音面向对象程序中使用的一些基本技术的概述,并简要讨论设计模式及其在软件工程中的应用。

第六章,“Java 的内存和并发处理方法”

本章解释了 Java 虚拟机如何代表程序员管理内存,以及内存和可见性如何与 Java 对并发编程和线程的支持紧密交织在一起。

前六章教会你 Java 语言,并让你快速掌握 Java 平台的最重要概念。第二部分详细介绍了如何在 Java 环境中进行实际编程工作。它包含大量示例,并旨在补充其他一些文本中的食谱方法。这部分包括:

第七章,“编程和文档约定”

本章记录了重要且广泛采用的 Java 编程约定。还解释了如何通过包含特殊格式的文档注释使你的 Java 代码具有自文档性。

第八章,“使用 Java 集合”

本章介绍了 Java 的标准集合库。这些数据结构对几乎每个 Java 程序的正常运行至关重要,如ListMapSet。详细解释了新的Stream抽象和 lambda 表达式与集合之间的关系。

第九章,“处理常见数据格式”

本章讨论如何使用 Java 有效地处理非常常见的数据格式,如文本、数字和时间(日期和时间)信息。

第十章,“文件处理和 I/O”

本章涵盖了多种不同的文件访问方法——从旧版本 Java 中发现的更经典的方法,到更现代甚至异步的风格。本章最后简要介绍了使用核心 Java 平台 API 进行网络编程。

第十一章,“类加载、反射和方法句柄”

本章介绍了 Java 中元编程的微妙艺术——首先介绍了关于 Java 类型的元数据概念,然后转向类加载的主题以及 Java 安全模型与类型动态加载的关联。本章最后介绍了类加载的一些应用和相对较新的方法句柄特性。

第十二章,“Java 平台模块”

本章描述了 Java 平台模块系统(JPMS),这是 Java 9 的重要特性,并介绍了它带来的广泛变化。

第十三章,“平台工具”

Oracle 的 JDK(以及 OpenJDK)包含许多有用的 Java 开发工具,尤其是 Java 解释器和 Java 编译器。本章记录了这些工具,以及用于与模块化 Java 工作的新工具,如交互式环境jshell

附录

本附录涵盖了 Java 17 版本之外的 Java,包括 Java 18 和 19 的发布以及增强语言和 JVM 的研究和开发项目。

相关书籍

O’Reilly 出版了一整套关于 Java 编程的书籍,包括本书的几本配套书籍:

学习 Java 由 Patrick Niemeyer 和 Daniel Leuck 撰写

这本书是 Java 的全面教程介绍,包括 XML 和客户端 Java 编程等主题。

Java 8 Lambdas 由 Richard Warburton 撰写

本书详细记录了 Java 8 的 lambda 表达式新特性,并介绍了对于来自早期版本 Java 的开发者来说可能不熟悉的函数式编程概念。

Head First Java 由 Kathy Sierra、Bert Bates 和 Trisha Gee 撰写

本书采用了一种独特的方法来教授 Java。那些视觉思维的开发者通常会发现它是传统 Java 书籍的很好补充。

您可以在 http://java.oreilly.com 找到完整的 O’Reilly Java 书籍列表。

本书使用约定

以下是本书使用的排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序清单,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应直接输入的命令或其他文本。

常量宽度斜体

显示应由用户提供值或由上下文确定值的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

此元素指示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可在 https://github.com/kittylyst/javanut8 下载。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至 bookquestions@oreilly.com

本书旨在帮助您完成工作。一般情况下,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了大量代码,否则无需征得我们的许可。例如,编写一个使用本书中多个代码片段的程序不需要许可。销售或分发 O’Reilly 图书示例需要许可。引用本书并引用示例代码回答问题不需要许可。将本书中大量示例代码合并到产品文档中需要许可。

我们欣赏但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Java in a Nutshell,第八版,作者本·埃文斯、杰森·克拉克和大卫·弗拉纳根(O’Reilly)。版权所有 2023 年由本杰明·J·埃文斯和杰森·克拉克,978-1-098-13100-5。”

如果您认为使用代码示例超出了合理使用范围或上述授权,请随时通过 permissions@oreilly.com 联系我们。

致谢

Melissa Potter 是第八版的编辑。她在整个过程中的指导和许多有益的贡献非常帮助。

特别感谢 Dan Heidinga、Ashutosh Mehra 和 Aleksey Shipilëv。

本版的技术审阅者包括 Mario Torre、Tony Mancill、Achyut Madhusudan、Jeff Maury、Rui Vieira、Jeff Alder、Kevin Earls 和 Ashutosh Mehra。

第一部分:介绍 Java

第一部分是介绍 Java 语言和 Java 平台。这些章节提供了足够的信息让你立即开始使用 Java:

  • 第一章,“Java 环境简介”

  • 第二章,“从基础开始的 Java 语法”

  • 第三章,“Java 中的面向对象编程”

  • 第四章,“Java 类型系统”

  • 第五章,“Java 中面向对象设计的介绍”

  • 第六章,“Java 内存和并发处理的方法”

第一章:介绍 Java 环境

欢迎来到 2023 年的 Java 世界。

你可能是从另一个传统来到 Java 世界,或者这可能是你第一次接触计算机编程。无论你是以何种方式来到这里,欢迎——我们很高兴你来了。

Java 是一个功能强大的通用编程环境。它是世界上使用最广泛的编程环境之一,在商业和企业计算方面取得了非常成功的成就超过 25 年。

在本章中,我们将通过描述 Java 语言(程序员编写应用程序的语言)、Java 执行环境(称为“Java 虚拟机”,实际运行这些应用程序的环境)和 Java 生态系统(为开发团队提供编程环境价值的大部分内容)来揭开序幕。

这三个概念(语言、执行环境和生态系统)通常被简称为“Java”,具体用法取决于上下文。实际上,它们是如此紧密相关的想法,以至于这并不像一开始看起来那么令人困惑。

我们将简要介绍 Java 语言和虚拟机的历史,然后讨论 Java 程序的生命周期,最后解答一些关于 Java 和其他环境的常见问题。

语言、JVM 和生态系统

Java 编程环境自上世纪 90 年代末就存在了。它由 Java 语言和支持的运行时环境 Java 虚拟机(JVM)组成。第三个要素——超出 Java 标准库的 Java 生态系统——由第三方提供,例如开源项目和 Java 技术供应商。

在 Java 最初开发的时候,这种分离被认为是一种新颖的方式,但是在接下来的几年里,软件开发的趋势使它变得更加普遍。值得注意的是,微软的.NET 环境在 Java 之后几年宣布采用了非常类似的平台架构。

微软的.NET 平台和 Java 之间的一个重要区别是,Java 始终被构想为一个相对开放的多供应商生态系统,尽管由一个拥有该技术的管理者领导。在 Java 的历史中,这些供应商在 Java 技术的各个方面既合作又竞争。

Java 成功的主要原因之一是这个生态系统是一个标准化环境。这意味着组成环境的技术有规范。这些标准使开发者和用户确信,即使这些技术来自不同的技术供应商,它们也将与其他组件兼容。

Java 目前的管理者是 Oracle Corporation(收购了 Java 的发起者 Sun Microsystems)。其他公司,如 Red Hat、IBM、Amazon、Microsoft、AliBaba、SAP、Azul Systems 和 Bellsoft,也参与生产标准化 Java 技术的实现。

提示

从 Java 7 开始,Java 的参考实现是开源的 OpenJDK(Java 开发工具包),许多公司在此基础上进行合作并发布他们的产品。

Java 最初由几个不同但相关的环境和规范组成,如 Java Mobile Edition(Java ME),¹ Java Standard Edition(Java SE)和 Java Enterprise Edition(Java EE)。² 在本书中,我们将只涵盖 Java SE 17,并附带某些历史注解,相关内容涉及某些功能何时引入到该平台中。一般而言,如果没有进一步的澄清,人们提到“Java”,通常指的是 Java SE。

我们稍后将详细讨论标准化问题,因此让我们继续讨论 Java 语言和 JVM 作为分开但相关的概念。

Java 语言是什么?

Java 程序以 Java 语言的源代码形式编写。这是一种人类可读的编程语言,严格基于类并面向对象。语法设计有意模仿 C 和 C++,明确旨在让那些从这些语言转来的程序员感到熟悉,这两种语言在 Java 创建时期非常主导。

注意

尽管源代码类似于 C++,但在实践中,Java 包括与 Smalltalk 等动态语言更多相似的特性和受控运行时。

Java 被认为相对易于阅读和编写(尽管有时有些啰嗦)。它具有严格的语法和简单的程序结构,旨在易于学习和教学。它建立在 C++等语言的行业经验之上,并尝试删除复杂的特性,同时保留了先前编程语言中有效的部分。

Java 语言由 Java 语言规范(JLS)管理,定义了符合实现的行为。

总体而言,Java 旨在为公司开发关键业务应用提供稳定、坚实的基础。作为一种编程语言,它设计相对保守,变化缓慢。这些特性是有意为之,旨在保护组织在 Java 技术上的投资。

自 1996 年创立以来,语言经历了逐步修订(但没有完全重写)。这意味着 Java 一些最初的设计选择,虽然在 90 年代末是权宜之计,但至今仍在影响着语言的发展—详见第二章和第三章了解更多细节。

另一方面,在过去的 10 多年中,Java 在语法上有所现代化,以解决啰嗦性和提供更为熟悉的功能,以吸引来自其他流行语言的程序员。

例如,在 2014 年,Java 8 增加了近十年来语言中看到的最激进的变化。 像 Lambda 表达式和引入 Streams API 等功能非常受欢迎,并永久改变了 Java 开发人员编写代码的方式。

正如我们将在本章后面讨论的那样,Java 项目已经过渡到了一个新的发布模型。 在这个新模型中,Java 版本每 6 个月发布一次,但只有特定版本(8、11 和 17)被认为是 LTS 合格的。 所有其他版本仅支持 6 个月,未被开发团队广泛采纳。

什么是 JVM?

JVM 是一个提供 Java 程序运行环境所必需的程序。 如果没有适合我们希望在其上执行的硬件和操作系统平台的 JVM,则 Java 程序无法运行。

幸运的是,JVM 已被移植到许多硬件环境中——从机顶盒或蓝光播放器到大型主机,几乎都可以为其提供 JVM。 JVM 有其自己的规范,即 Java 虚拟机规范,每个实现都必须符合该规范的规则。 当新的硬件类型进入主流市场时,可能会有公司或个人对该硬件感兴趣,从而启动一个项目来将 OpenJDK 移植到新的芯片上。 最近的一个例子是新的 Apple M1 芯片——Red Hat 将 JVM 移植到 AArch64 架构,然后微软对构建所需的 Apple 硅进行了移植更改。

Java 程序可以通过多种方式启动,但最简单(也是最早的)方法是从命令行启动:

java <*`arguments`*> <*`program` `name`*>

JVM 作为操作系统进程启动,提供 Java 运行环境,然后在全新启动(空)虚拟机的背景下执行我们的程序。

需要理解的是,当 JVM 接收 Java 程序进行执行时,程序不是以 Java 语言源代码的形式提供的。 相反,Java 语言源代码必须编译成称为 Java 字节码的形式。 Java 字节码以 class 文件的形式提供给 JVM(其扩展名始终为.class)。 Java 平台一直强调向后兼容性,编写 Java 1.0 的代码仍然可以在今天的 JVM 上运行,无需修改或重新编译。

JVM 为程序提供了执行环境。 它启动了一个字节码形式程序的解释器,逐条执行字节码指令。 然而,生产质量的 JVM 还提供了一个特殊的编译器,在 Java 程序运行时运行。 这个编译器(称为“JIT”或即时编译器)通过用等效的编译(和高度优化的)机器代码替换重要部分来加速程序的执行。

你还应该知道,JVM 和用户程序都能够生成额外的执行线程,因此用户程序可以同时运行许多不同的函数。

JVM 的原始设计基于对较早编程环境(特别是 C 和 C++)多年的经验,因此我们可以将其视为具有几个不同目标——全部旨在为程序员简化生活:

  • 构成应用代码运行的标准执行环境

  • 促进安全可靠的代码执行(与 C/C++ 相比)

  • 将低级内存管理从开发者手中拿走

  • 提供跨平台执行环境

这些目标经常在平台讨论中一起提到。

当我们讨论 JVM 及其字节码解释器时,已经提到了这些目标的第一个——它作为应用代码的容器。

当我们讨论 Java 环境如何处理内存管理时,我们将在第六章中讨论第二个和第三个目标。

第四个目标,有时被称为“一次编写,到处运行”(WORA),是 Java 类文件可以从一个执行平台移动到另一个,只要有 JVM 可用,它们就会运行不变。

这意味着可以在运行 macOS 上的 M1 芯片的机器上开发 Java 程序(并转换为类文件),然后将类文件移动到 Linux 或 Microsoft Windows 上的 Intel 硬件(或其他平台),Java 程序将无需进一步工作即可运行。

注意

Java 环境已经广泛移植,包括到与主流平台如 Linux、macOS 和 Windows 非常不同的平台。在本书中,我们使用术语“大多数实现”来表示大多数开发者可能会遇到的平台;macOS、Windows、Linux、BSD Unix 等都被视为“主流平台”并且在“大多数实现”中计数。

除了这四个主要目标之外,JVM 设计的另一个方面经常未被认可或讨论——它使用运行时信息进行自管理。

20 世纪 70 年代和 80 年代的软件研究揭示了程序的运行时行为有许多有趣和有用的模式,这些模式在编译时无法推断出。JVM 是第一个真正主流的编程环境,使用了这些研究结果。

它收集运行时信息以做出更好的代码执行决策。这意味着 JVM 能够监控并优化在其上运行的程序,这是其他平台无法实现的。

一个关键例子是运行时事实,即 Java 程序的各个部分在程序生命周期内被调用的可能性并不相等——某些部分远远比其他部分频繁调用。Java 平台利用这一事实的技术称为即时(JIT)编译。

在 HotSpot JVM(这是 Sun 在 Java 1.3 中首次发布的 JVM,并且至今仍在使用中),JVM 首先识别程序中调用最频繁的部分——“热方法”。然后,JVM 将这些热方法直接编译成机器代码,绕过 JVM 解释器。

JVM 利用可用的运行时信息提供比纯解释执行更高的性能。事实上,在许多情况下,JVM 现在使用的优化技术产生的性能甚至超过了编译的 C 和 C++代码。

描述 JVM 必须如何正常运行的标准称为 JVM 规范。

什么是 Java 生态系统?

与其他编程语言相比,Java 语言易于学习且包含相对较少的抽象。JVM 为 Java(或其他语言)提供了坚实、可移植、高性能的执行基础。总之,这两个相关技术为企业在选择其开发努力的基础时提供了可信赖的基础。

然而,Java 的好处并不仅限于此。自 Java 诞生以来,第三方库和组件的极其庞大生态系统已经形成。这意味着开发团队可以从几乎每种可想象的技术中获益,包括专有和开源的连接器和驱动程序。

在现代技术生态系统中,现在几乎找不到提供 Java 连接器的技术组件。从传统关系数据库到 NoSQL,再到各种企业监控系统,消息系统,到物联网(IoT)——一切都与 Java 集成。

正是这一事实成为企业和大公司采用 Java 技术的主要驱动因素之一。开发团队能够通过利用现有的库和组件释放其潜力。这促进了开发者的选择,并鼓励基于 Java 技术核心的开放式、最佳的架构。

注意

谷歌的 Android 环境有时被认为是“基于 Java 的”。然而,事实情况要复杂得多。Android 代码是用 Java(或 Kotlin 语言)编写的,但最初使用了 Java 类库的不同实现以及一个交叉编译器,将其转换为非 Java 虚拟机的不同文件格式。

丰富的生态系统与一流的虚拟机结合,并具有程序二进制的开放标准,使得 Java 平台成为一个非常吸引人的执行目标。实际上,有许多非 Java 语言针对 JVM 并与 Java 进行互操作(使它们可以依附于平台的成功)。这些语言包括 Kotlin、JRuby、Scala、Clojure 等。虽然它们与 Java 相比都很小,但它们在 Java 世界内有着明确的市场定位,并为 Java 提供了创新和健康竞争的源泉。

Java 程序的生命周期

要更好地理解 Java 代码是如何编译和执行的,以及 Java 与其他类型的编程环境之间的区别,请考虑 图 1-1 中的流水线。

JN7 0101

图 1-1. Java 代码是如何编译和加载的

这从 Java 源代码开始,通过 javac 程序生成包含已编译为 Java 字节码的源代码的类文件。类文件是平台处理的最小功能单元,也是将新代码导入运行程序的唯一方式。

新的类文件通过类加载机制(详见 第十章 了解更多有关类加载工作原理的细节)上载。这使得新代码(表示为类型)可供解释器执行,并且执行从 main() 方法开始。

Java 程序的性能分析和优化是一个重要话题,有兴趣的读者应参考专业文本,如 优化 Java(O’Reilly)。

常见问题

在本节中,我们将讨论关于 Java 和 Java 环境中编写的程序生命周期的一些最常见问题。

什么是虚拟机?

当开发人员首次接触虚拟机的概念时,他们有时会将其视为“计算机中的计算机”或“在软件中模拟的计算机”。然后很容易将字节码想象为“内部计算机的 CPU 的机器码”或“虚拟处理器的机器码”。然而,这种简单的直觉可能会误导。

什么是字节码?

实际上,JVM 字节码与真正运行在硬件处理器上的机器码并不十分相似。相反,计算机科学家会将字节码称为一种 中间表示 类型,介于源代码和机器码之间。

javac 是编译器吗?

编译器通常会生成机器码,但 javac 生成的是字节码,与机器码并不十分相似。然而,类文件有点像对象文件(如 Windows 的 .dll 文件或 Unix 的 .so 文件),它们肯定不是人类可读的。

从理论计算机科学的角度来看,javac最类似于编译器的前半部分——它创建了中间表示,随后可以用于生成(发射)机器代码。

然而,由于类文件的创建是一个单独的构建时间步骤,类似于 C/C++中的编译,许多开发人员认为运行javac是编译。在本书中,我们将使用术语“源代码编译器”或“javac编译器”来表示javac生成类文件的过程。

我们将保留“编译”作为一个独立的术语,指的是 JIT 编译——因为正是 JIT 编译实际上生成了机器代码。

为什么称为“字节码”?

指令代码(操作码)只是一个字节(有些操作还有随后的参数跟在字节流中),因此只有 256 个可能的指令。实际上,有些是未使用的——大约有 200 个在使用中,但某些最近版本的javac并没有发出它们。

字节码是否经过优化?

在平台早期阶段,javac生成了经过大量优化的字节码。这被证明是一个错误。

随着 JIT 编译的出现,重要的方法将被编译成非常快速的机器代码。因此,让 JIT 编译器的工作更容易非常重要——因为与优化字节码相比,JIT 编译可以带来更大的性能提升,而优化字节码仍然必须被解释。

字节码真的是机器无关的吗?诸如字节序之类的因素呢?

无论字节码是在何种类型的机器上创建的,其格式始终相同。这包括机器的字节顺序(有时称为“字节序”)。对于对细节感兴趣的读者,字节码始终是大端序的。

Java 是一种解释性语言吗?

JVM 基本上是一个解释器(通过 JIT 编译来大幅提升性能)。然而,大多数解释性语言直接从源代码形式解释程序(通常通过从输入源文件构造抽象语法树来实现)。另一方面,JVM 解释器需要类文件——当然,这需要使用javac进行单独的源代码编译步骤。

实际上,许多传统上是解释性的语言(如 PHP、Ruby 和 Python)的现代版本现在也具有 JIT 编译器,因此“解释性”和“编译性”语言之间的界限越来越模糊。再次验证了 Java 在其他编程环境中采用的设计决策的有效性。

其他语言可以在 JVM 上运行吗?

是的。JVM 可以运行任何有效的类文件,这意味着非 Java 语言可以通过多种方式在 JVM 上运行。首先,它们可以有一个类似于javac的源代码编译器,生成类文件,这些类文件可以像 Java 代码一样在 JVM 上运行(这是 Kotlin 和 Scala 等语言采用的方法)。

或者,一种非 Java 语言可以在 Java 中实现解释器和运行时,然后直接解释其语言的源形式。这种第二种选择是像 JRuby 这样的语言采取的方法(但在某些情况下 JRuby 具有非常复杂的运行时,能够进行二次 JIT 编译)。

将 Java 与其他语言进行比较

在本节中,我们将简要介绍 Java 平台与您可能熟悉的其他编程环境之间的一些区别。

Java 与 JavaScript 比较

  • Java 是静态类型的;JavaScript 是动态类型的。

  • Java 使用基于类的对象;JavaScript 是基于原型的(JS 关键字 class 是语法糖)。

  • Java 提供了良好的对象封装;JavaScript 则没有。

  • Java 有命名空间;JavaScript 没有。

  • Java 是多线程的;JavaScript 不是。

Java 与 Python 比较

  • Java 是静态类型的;Python 是动态类型的(具有可选的、逐渐类型)。

  • Java 是一种带有函数式编程(FP)特性的面向对象语言;Python 是一种混合面向对象/过程化语言,具有一些 FP 支持。

  • Java 和 Python 都有字节码格式—Java 使用 JVM 类文件;Python 使用 Python 字节码。

  • Java 的字节码具有广泛的静态检查;Python 的字节码则没有。

  • Java 是多线程的;Python 一次只允许一个线程执行 Python 字节码(全局解释器锁)。

Java 与 C 比较

  • Java 是面向对象的;C 是过程化的。

  • Java 作为类文件是可移植的;C 需要重新编译。

  • Java 作为运行时的一部分提供了广泛的仪器设备。

  • Java 没有指针,也没有指针算术的等价物。

  • Java 通过垃圾收集实现了自动内存管理。

  • Java 当前无法在低级别上布局内存(没有结构体)。

  • Java 没有预处理器。

Java 与 C++ 比较

  • Java 的对象模型与 C++ 相比较简化。

  • Java 的方法分派默认是虚拟的。

  • Java 总是传值调用(但 Java 值的唯一可能性之一是对象引用)。

  • Java 不支持完全多继承。

  • Java 的泛型比 C++ 模板弱大(但也更安全)。

  • Java 没有操作符重载。

对 Java 的一些批评进行回答

Java 在公众眼中有着悠久的历史,因此多年来吸引了很多批评。这些负面报道的一部分可以归因于一些技术上的缺陷,再加上在 Java 的最初版本中过度热衷的营销。

尽管一些批评已经进入技术传说,但实际上已经不再很准确。在本节中,我们将看一下一些常见的抱怨以及它们对于现代版本的平台来说是否属实。

过于冗长

Java 核心语言有时被批评为过于冗长。甚至简单的 Java 语句如 Object o = new Object(); 似乎是重复的——类型 Object 在赋值语句的左右两侧都出现。批评者指出这基本上是多余的,其他语言不需要这种类型信息的重复,并且许多语言支持可以移除它的特性(例如类型推断)。

这一论点的反驳是,Java 从一开始就被设计成易于阅读(代码读取次数多于编写次数),许多程序员,特别是新手,在阅读代码时发现额外的类型信息非常有帮助。

Java 广泛应用于企业环境中,这些环境通常有单独的开发和运维团队。当你响应故障调用时,或者需要维护和修补已经由长时间离开的开发人员编写的代码时,额外的冗长通常是一种福音。

在最近的 Java 版本中,语言设计者试图通过找到语法可以变得更简洁的地方,并更好地利用类型信息来响应其中一些观点。例如:

// Files helper methods
byte[] contents =
  Files.readAllBytes(Paths.get("/home/ben/myFile.bin"));

// Diamond syntax for repeated type information
List<String> l = new ArrayList<>();

// Local variables can be type inferred
var threadPool = Executors.newScheduledThreadPool(2);

// Lambda expressions simplify Runnables
threadPool.submit(() -> { System.out.println("On Threadpool"); });

然而,Java 的总体哲学是非常缓慢和谨慎地修改语言,因此这些变化的步伐可能无法完全满足批评者的要求。

缓慢的变化

原始的 Java 语言现在已经超过 20 年历史,这段时间内没有进行完整的修订。许多其他语言(例如微软的 C#)在同一时期内发布了不兼容的版本,一些开发人员批评 Java 没有做同样的事情。

此外,近年来,Java 语言因为缓慢采纳现在其他语言普遍采用的语言特性而受到批评。

Sun(现在是 Oracle)采取的语言设计保守方法是为了避免在非常庞大的用户群体中强加错误特性的成本和外部性。许多 Java 开发团队对该技术进行了重大投资,语言设计者严肃地对待不干扰现有用户和安装基础的责任。

每个新的语言特性都需要非常谨慎地考虑——不仅是独立存在的,还要考虑它将如何与语言的所有现有特性相互作用。新特性有时会在其直接范围之外产生影响——而 Java 被广泛应用于非常庞大的代码库中,这里有更多潜在的地方可能出现意外的相互作用。

一旦推出后发现有错误的特性几乎不可能移除。Java 有一些误功能(如序列化机制),几乎无法安全地移除而不影响已安装基础。语言设计者认为,在演化语言时需要极度谨慎。

尽管如此,近期版本中引入的新语言特性显著地解决了最常见的功能缺失问题,它们应该涵盖开发人员一直在寻求的许多编程习惯。

性能问题

尽管 Java 平台有时被批评运行速度慢,但在所有批评中,这可能是最不合理的一个。这是对该平台的一个真正的误解。

Java 1.3 发布引入了 HotSpot 虚拟机及其 JIT 编译器。自那时以来,虚拟机及其性能已经持续创新和改进超过 15 年。如今,Java 平台运行速度极快,在流行框架的性能基准测试中经常获胜,甚至超越了本地编译的 C 和 C++。

在这一领域的批评似乎主要是由一个传统观念引起的,即 Java 在过去某个时刻运行缓慢。Java 应用于的一些较大、较复杂的架构也可能加深了这种印象。

事实是,任何一个大型架构都需要基准测试、分析和性能调优,以达到最佳状态——Java 也不例外。

平台的核心部分——语言和 JVM——过去和现在仍然是开发者可用的最快速的通用环境之一。

不安全

一些人历史上批评过 Java 在安全漏洞方面的记录。

许多这些漏洞涉及 Java 系统的桌面和 GUI 组件,并不会影响使用 Java 编写的网站或其他服务器端代码。

事实是,Java 从一开始就以安全性为设计核心;这使得它比许多其他现有的系统和平台具有明显优势。Java 安全架构是由安全专家设计的,并自平台成立以来得到了许多其他安全专家的研究和检验。普遍的共识是,该架构本身坚固可靠,设计上没有任何安全漏洞(至少目前没有被发现的)。

安全模型设计的核心是,字节码在表达能力上受到严格限制——例如,无法直接访问内存。这一设计消除了像 C 和 C++语言中存在的大量安全问题。此外,每当加载一个不受信任的类时,虚拟机会进行称为字节码验证的过程,进一步消除了另一大类问题(详见第十章了解更多关于字节码验证的内容)。

然而,尽管如此,没有任何系统可以保证 100%的安全性,Java 也不例外。

虽然设计在理论上仍然健壮,但安全架构的实现又是另一回事,Java 的特定实现中存在着漫长的安全漏洞历史。很可能会在 Java VM 的实现中继续发现(并修补)安全漏洞。

所有编程平台都可能存在安全问题,许多其他语言也有着相当数量的安全漏洞历史,只不过这些漏洞的公开程度要低得多。就实际的服务器端编码而言,Java 或许是目前可用的最安全的通用平台,特别是在保持最新补丁的情况下。

过于企业化

Java 是一个被企业开发者广泛使用的平台。因此,人们认为它过于企业化并不奇怪 —— Java 经常被认为缺乏更加面向社区的“自由发挥”风格的语言。

事实上,Java 一直都是一个非常广泛使用的语言,用于社区和免费或开源软件开发。它是 GitHub 和其他项目托管站点上托管的项目中最受欢迎的语言之一。不仅如此,Java 社区经常被认为是生态系统的真正优势之一 —— 具有用户组、会议、期刊等一切活跃和健康用户社区的最显著迹象。

最广泛使用的语言实现是基于 OpenJDK —— OpenJDK 本身是一个具有充满活力和增长的社区的开源项目。

Java 和 JVM 的简史

Java 1.0 (1996)

这是 Java 的第一个公开版本。它仅包含 212 个类,组织在八个包中。

Java 1.1 (1997)

这个版本的 Java 将 Java 平台的规模增加了一倍以上。这个版本引入了“内部类”和第一个版本的反射 API。

Java 1.2 (1998)

这是 Java 的一个非常重要的版本发布;它将 Java 平台的规模扩大了三倍。这个版本标志着 Java 集合 API 的首次亮相(包括集合、映射和列表)。1.2 版本中的许多新特性导致 Sun 将平台重新命名为“Java 2 平台”。然而,“Java 2”这个术语仅仅是一个商标,而不是实际的版本号。

Java 1.3 (2000)

这主要是一个维护版本,重点是修复错误、提高稳定性和性能。这个版本还引入了 HotSpot Java 虚拟机,至今仍在使用(尽管自那时以来已经经过了大幅修改和改进)。

Java 1.4 (2002)

这是另一个相当重要的版本发布,增加了一些重要的新功能,如更高性能的低级 I/O API;用于文本处理的正则表达式;XML 和 XSLT 库;SSL 支持;日志记录 API;以及加密支持。

Java 5 (2004)

这个大版本的 Java 引入了一些对核心语言本身的改变,包括泛型类型、枚举类型、注解、可变参数方法、自动装箱以及新的for循环。这些改变被认为足够重要以至于改变了主版本号的编号方式,开始以主要版本发布。此版本包含了 166 个包中的 3562 个类和接口。显著的增加包括用于并发编程的实用程序、远程管理框架以及用于 Java 虚拟机自身的远程管理和仪器化类。

Java 6 (2006)

这个版本主要是维护和性能优化版本。它引入了编译器 API,扩展了注解的使用和范围,并提供了绑定以允许脚本语言与 Java 互操作。还有大量内部错误修复和对 JVM 和 Swing GUI 技术的改进。

Java 7 (2011)

在 Oracle 掌管下的第一个 Java 发布版包括对语言和平台的一些重大升级,同时也是基于开源参考实现的第一个发布版。引入了try-with-resources 和 NIO.2 API,使开发者能够编写更安全、更少出错的处理资源和 I/O 的代码。方法句柄 API 提供了反射的简单和安全替代方案;此外,它还为invokedynamic(自 Java 1.0 版以来的第一个新字节码)开启了大门。

Java 8 (2014) (LTS)

这是一个重大的发布版本,可能是自 Java 5 以来(或可能是有史以来)对语言进行的最重要的改变。引入了 lambda 表达式,显著提高了开发者的生产力,集合框架也更新以利用 lambda 表达式,为实现这一点所需的机制标志着 Java 在面向对象方面的根本性变化。其他主要更新包括新的日期和时间 API 以及并发库的重大更新。

Java 9 (2017)

这个版本显著延迟,引入了新的平台模块化特性,允许 Java 应用程序打包成部署单元并模块化平台运行时。其他变更包括新的默认垃圾收集算法、用于处理进程的新 API 以及框架访问内部方式的一些变更。此外,这个版本还改变了发布周期本身,使得新版本每 6 个月发布一次,但只有 LTS 版本获得了广泛应用。因此,我们从此点开始只记录 LTS 版本的发布。

Java 11 (September 2018) (LTS)

这个版本是首个被视为长期支持(LTS)版本的模块化 Java。它添加了一些对开发者直接可见的新功能,主要包括对类型推断(var)、JDK 飞行记录器(JFR)和新的 HTTP/2 API 的改进支持。还有一些额外的内部变更和显著的性能改进,但这个 LTS 版本主要旨在 Java 9 之后的稳定化。

Java 17(2021 年 9 月)(LTS)

当前版本 LTS 发布。包括对 Java 面向对象模型的重要改变(封闭类、记录和巢状类),以及开关表达式、文本块和语言模式匹配的初版。JVM 还进行了额外的性能改进,并提供了更好的容器中运行支持。内部升级继续进行,并添加了两个新的垃圾收集器。

目前,仅有 LTS 版本 11 和 17 是当前生产版本。由于模块引入的重大变化,Java 8 被追溯地宣布为 LTS 版本,为团队和应用程序迁移到受支持的模块化 Java 提供了额外的时间。现在它被认为是一个“经典”版本,并强烈建议团队迁移到其中一种现代的 LTS 版本。

摘要

在本介绍性章节中,我们将 Java 放置在编程语言的整体格局和历史中进行了比较。我们将语言与其他流行的替代方案进行了比较,首次查看了 Java 程序编译和执行的基本解剖,并试图消除关于 Java 的一些流行误解。

下一章将从自下而上的角度,重点介绍 Java 语言的语法,专注于词法语法的各个基本单元并逐步构建。如果您已经熟悉类似 Java 的语言的语法(如 JavaScript、C 或 C++),您可以选择略读或跳过本章,并在遇到不熟悉的语法时参考。

¹ Java ME 是面向功能手机和第一代智能手机的一个较旧的标准。如今,Android 和 iOS 主导手机市场,Java ME 已不再更新。

² Java EE 现已移交至 Eclipse Foundation,继续作为 Jakarta EE 项目存在。

第二章:从基础开始的 Java 语法

本章内容较为密集,但应提供 Java 语法的全面介绍。主要面向对该语言新手,但具有一定编程经验的读者。对于没有任何编程经验的决心新手,也可能会发现它有用。如果你已经了解 Java,你会发现它是一个有用的语言参考。本章还包括一些将 Java 与 JavaScript、C 和 C++进行比较的内容,以帮助那些来自这些语言的程序员。

本章从 Java 语法的最低级别开始,逐步构建并移向越来越高层次的结构,来记录 Java 程序的语法。涵盖内容包括:

  • 用于编写 Java 程序的字符及其编码方式。

  • Java 程序中包含的字面值、标识符和其他标记。

  • Java 可以操作的数据类型。

  • Java 中用于将单个标记组合成较大表达式的运算符。

  • 语句,将表达式和其他语句分组形成 Java 代码的逻辑块。

  • 方法,是可以被其他 Java 代码调用的命名 Java 语句集合。

  • 类,是方法和字段的集合。类是 Java 中的中心程序元素,并且构成面向对象编程的基础。第三章完全专注于讨论类和对象。

  • 包,是相关类的集合。

  • Java 程序由一个或多个相互作用的类组成,这些类可以来自一个或多个包。

大多数编程语言的语法都很复杂,Java 也不例外。一般而言,不可能在不涉及尚未讨论的其他元素的情况下记录语言的所有元素。例如,不可能真正有意义地解释 Java 支持的运算符和语句而不涉及对象。但也不可能在不涉及语言的运算符和语句的情况下彻底记录对象。因此,学习 Java 或任何语言的过程是一个迭代的过程。

自上而下的 Java 程序

在我们开始自底向上探索 Java 语法之前,让我们来了解一下 Java 程序的概述。Java 程序由一个或多个 Java 源代码文件或编译单元组成。在本章末尾附近,我们描述了 Java 文件的结构,并解释了如何编译和运行 Java 程序。每个编译单元以一个可选的package声明开头,后跟零个或多个import声明。这些声明指定了编译单元将定义名称的命名空间以及编译单元从中导入名称的命名空间。我们将在本章后面再次看到packageimport,详见“包和 Java 命名空间”。

可选的packageimport声明后面可以跟零个或多个引用类型定义。我们将在第三章和第四章中详细介绍可能的引用类型,但现在需要注意的是,这些类型通常是classinterface定义之一。

在引用类型的定义中,我们将遇到成员,例如字段方法构造函数。方法是最重要的成员类型。方法是由语句组成的 Java 代码块。

通过定义这些基本术语,让我们从底层向上探索 Java 程序,通过检查词法标记的基本语法单元开始。

词法结构

本节解释了 Java 程序的词法结构。它从编写 Java 程序的 Unicode 字符集讨论开始。然后,它涵盖了组成 Java 程序的标记,解释了注释、标识符、保留字、字面量等内容。

Unicode 字符集

Java 程序使用 Unicode 编写。您可以在 Java 程序的任何地方使用 Unicode 字符,包括注释和标识符(如变量名)。与仅对英语有用的 7 位 ASCII 字符集以及仅对主要西欧语言有用的 8 位 ISO Latin-1 字符集不同,Unicode 字符集可以表示几乎全球所有通用的书面语言。

提示

如果您不使用支持 Unicode 的文本编辑器,或者不想强迫查看或编辑您代码的其他程序员使用支持 Unicode 的编辑器,您可以使用特殊的 Unicode 转义序列\u*xxxx*将 Unicode 字符嵌入到 Java 程序中——即反斜杠和小写 u,后跟四个十六进制字符。例如,\u0020是空格字符,\u03c0是π字符。

Java 在确保其 Unicode 支持一流的情况下投入了大量时间和工程努力。如果您的业务应用程序需要处理全球用户,特别是非西方市场的用户,那么 Java 平台是一个很好的选择。此外,Java 还支持多种编码和字符集,以便与不支持 Unicode 的非 Java 应用程序进行交互。

大小写敏感性和空白字符

Java 是大小写敏感的语言。它的关键字以小写形式编写并且必须始终使用。也就是说,WhileWHILEwhile关键字不同。同样,如果您在程序中声明了一个名为i的变量,您不能将其称为I

提示

总的来说,依赖大小写敏感性来区分标识符是一个糟糕的主意。标识符越相似,代码的可读性和理解难度就越大。不要在自己的代码中使用它,特别是不要给一个关键字相同但大小写不同的标识符。

Java 忽略空格、制表符、换行符和其他空白字符,除非它们出现在引号字符和字符串字面值内。程序员通常使用空白字符来格式化和缩进他们的代码以便于阅读,但这不会像 Python 中的缩进那样影响程序的行为。您将在本书的代码示例中看到常见的缩进约定。

注释

注释是为程序的人类读者而设计的自然语言文本。它们被 Java 编译器忽略。Java 支持三种类型的注释。第一种类型是单行注释,以字符 // 开始,并延续至当前行的结尾。例如:

int i = 0;   // Initialize the loop variable

第二种类型的注释是多行注释。它以字符 /* 开始,并在任意行数后续续,直到字符 */javac 忽略 /**/ 之间的任何文本。虽然这种注释风格通常用于多行注释,但也可以用于单行注释。这种类型的注释不能嵌套(即一个 /* */ 注释不能出现在另一个注释内)。编程人员在编写多行注释时经常使用额外的 * 字符来使注释突出显示。这是一个典型的多行注释示例:

/*
 * First, establish a connection to the server.
 * If the connection attempt fails, quit right away.
 */

第三种类型的注释是第二种的特例。如果一个注释以 /** 开头,它被视为特殊的文档注释。与常规的多行注释类似,文档注释以 */ 结尾,不能嵌套。当你编写一个希望其他程序员使用的 Java 类时,提供文档注释以将关于类及其每个方法的文档直接嵌入源代码中。一个名为 javadoc 的程序会提取这些注释并处理它们以创建类的在线文档。文档注释可以包含 HTML 标签,并且可以使用 javadoc 理解的额外语法。例如:

/**
 * Upload a file to a web server.
 *
 * @param file The file to upload.
 * @return <tt>true</tt> on success,
 *         <tt>false</tt> on failure.
 * @author David Flanagan
 */

更多关于文档注释语法的信息请参见第七章,更多关于 javadoc 程序的信息请参见第十三章。

注释可以出现在 Java 程序的任何标记之间,但不能出现在标记内部。特别是,注释不能出现在双引号字符串字面值内。字符串字面值内的注释简单地成为该字符串的字面部分。

保留字

下列单词在 Java 中是保留的(它们是语言的语法的一部分,不能用于变量名、类名等):

abstract   const      final        int         public        throw
assert     continue   finally      interface   return        throws
boolean    default    float        long        short         transient
break      do         for          native      static        true
byte       double     goto         new         strictfp      try
case       else       if           null        super         void
catch      enum       implements   package     switch        volatile
char       extends    import       private     synchronized  while
class      false      instanceof   protected   this
_ (underscore)

其中,truefalsenull 在技术上是字面值。

请注意 constgoto 虽然被保留但实际上在语言中并未使用,而 interface 有一个额外的变体形式——@interface,用于定义称为注解的类型。一些保留字(特别是 finaldefault)根据上下文有多种含义。

还有其他一些关键字不是通常的保留字,被称为上下文关键字

exports      opens      requires     uses
module       permits    sealed       var
non-sealed   provides   to           with
open         record     transitive   yield

var表示应该进行类型推断的局部变量。在定义类时使用sealednon-sealedrecord,我们将在第三章中遇到。yield出现在稍后本章中将遇到的switch表达式中,而其他上下文关键字涉及模块,其语法和用法在第十二章中有详细介绍。

警告

尽管允许使用上下文关键字作为变量名,但不建议这样做。var var = "var";可能是一个有效的语句,但是这是一个应该引起怀疑的有效语句。

标识符

标识符只是 Java 程序中某部分(如类、类内方法或方法内声明的变量)的名称。标识符可以任意长度,并且可以包含来自整个 Unicode 字符集的字母和数字。标识符不能以数字开头。

一般情况下,标识符不能包含标点符号字符。例外包括美元符号($)以及其他 Unicode 货币符号,如£¥

提示

货币符号用于自动生成的源代码,例如javac生成的代码。通过避免在您自己的标识符中使用货币符号,您就不必担心与自动生成的标识符发生冲突。

ASCII 下划线(_)也值得特别提及。最初,下划线可以自由地用作标识符或其一部分。然而,在包括 Java 17 在内的最新版本中,下划线不能作为标识符使用。

下划线字符仍然可以出现在 Java 标识符中,但不能再单独作为完整的标识符合法存在。这是为了支持即将推出的语言特性,其中下划线将获得新的特殊语法意义。

通常的 Java 约定是使用驼峰命名法命名变量。这意味着变量的第一个字母应小写,但标识符中其他单词的第一个字母应大写。

正式地说,标识符的起始和内部允许的字符由java.lang.Character类的isJavaIdentifierStart()isJavaIdentifierPart()方法定义。

以下是合法标识符的示例:

i    x1    theCurrentTime    current    獺

特别注意 UTF-8 标识符的例子,。这是表示“水獭”的汉字字符,作为 Java 标识符完全合法。在主要由西方人编写的程序中使用非 ASCII 标识符是不寻常的,但有时会见到。

字面量

字面量是源代码中直接表示常量值的源字符序列。它们包括整数和浮点数、单引号内的单个字符、双引号内的字符序列,以及保留字truefalsenull。例如,以下都是字面量:

1    1.0    '1'    1L    "one"    true    false    null

表达数字、字符和字符串字面值的语法在“原始数据类型”中详细说明。

标点符号

Java 还使用一些标点符号字符作为标记。Java 语言规范将这些字符(有些是任意的)分为两类,分隔符和操作符。这 12 种分隔符包括:

( ) { } [ ]
... @ ::
; , .

操作符包括:

+ * / % & &#124; ^ << >> >>>
+= -= *= /= %= &= &#124;= ^= <<= >>= >>>=
= == != < <= > >=
! ~ && || ++ -- ? : ->

我们会在全书中看到分隔符,并且会在“表达式和操作符”中单独介绍每个操作符。

原始数据类型

Java 支持八种基本数据类型,称为原始类型,如表 2-1 所述。原始类型包括布尔类型、字符类型、四种整数类型和两种浮点类型。这四种整数类型和两种浮点类型在表示它们的位数和因此它们可以表示的数字范围上有所不同。请注意,这些类型的大小是 Java 语言中的概念大小。由于填充、对齐等原因,不同的 JVM 实现可能使用更多的实际空间来保存这些值。

表 2-1. Java 原始数据类型

类型 包含 默认 大小 范围
boolean true or false false 1 位 NA
char Unicode 字符 \u0000 16 位 \u0000\uFFFF
byte 有符号整数 0 8 位 –128 到 127
short 有符号整数 0 16 位 –32768 到 32767
int 有符号整数 0 32 位 –2147483648 到 2147483647
long 有符号整数 0 64 位 –9223372036854775808 到 9223372036854775807
float IEEE 754 浮点数 0.0 32 位 1.4E–45 到 3.4028235E+38
double IEEE 754 浮点数 0.0 64 位 4.9E–324 到 1.7976931348623157E+308

下一节总结了这些原始数据类型。除了这些原始类型外,Java 还支持称为引用类型的非原始类型,这些类型在“引用类型”中介绍。

布尔类型

boolean 类型代表真值。这种类型只有两个可能的值,表示两个布尔状态:开启或关闭,是或否,真或假。Java 保留了 truefalse 用于表示这两个布尔值。

从其他语言(特别是 JavaScript、Python 或 C)转到 Java 的程序员应该注意,Java 对其布尔值要求比其他语言严格得多;特别是,boolean既不是整数类型也不是对象类型,不兼容的值不能用于boolean的位置。换句话说,在 Java 中不能像以下示例中那样使用捷径:

Object o = new Object();
int i = 1;

if (o) {     // Invalid!
  while(i) {
    //...
  }
}

Java 要求您通过明确声明想要的比较来编写更清晰的代码:

if (o != null) {
  while(i != 0) {
    // ...
  }
}

字符类型

char 类型表示 Unicode 字符。Java 在表示字符方面有着略微独特的方法—javac接受输入时将标识符和字面值作为 UTF-8(一种可变宽度编码)。然而,在内部,Java 以固定宽度编码表示字符—​在 Java 9 之前是 16 位编码,在 Java 9 及以后可能是 ISO-8859-1(一种用于西欧语言的 8 位编码,也称为 Latin-1)。

外部和内部表示之间的区别通常不需要开发人员关注。在大多数情况下,只需记住这条规则即可:要在 Java 程序中包含字符字面值,只需将其放在单引号(撇号)之间:

char c = 'A';

当然,您可以使用 \u Unicode 转义序列将 Unicode 字符作为字符字面值。此外,Java 还支持许多其他转义序列,使得表示常用的非打印 ASCII 字符(如 newline)以及转义某些在 Java 中具有特殊含义的标点字符变得更加容易。例如:

char tab = '\t', nul = '\000', aleph = '\u05D0', backslash = '\\';

表 2-2 列出了可以在 char 字面值中使用的转义字符。这些字符也可以用于字符串字面值,这将在下一节中介绍。

表 2-2. Java 转义字符

转义序列 字符值
\b 退格符
\t 水平制表符
\n 换行符
\f 换页符
\r 回车符
\" 双引号
\' 单引号
\\ 反斜杠
\*xxx* 使用编码为 xxx 的 Latin-1 字符,其中 xxx 是一个在 000 到 377 之间的八进制(基数为 8)数字。形式 \x\xx 也是合法的,如 \0,但不建议使用,因为它们可能在字符串常量中引起困扰,后面跟随的是普通数字。一般不推荐此形式,而是更倾向于使用 \uXXXX 形式。
\u*xxxx* 使用编码为 xxxx 的 Unicode 字符,其中 xxxx 是四位十六进制数字。Unicode 转义可以出现在 Java 程序的任何位置,不仅限于字符和字符串字面值中。

char值可以与各种整数类型相互转换,char数据类型是 16 位整数类型。但与byteshortintlong不同,char是无符号类型,只能接收 0 到 65535 范围内的值。Character类定义了一些有用的静态方法,用于处理字符,包括isDigit()isJavaLetter()isLowerCase()toUpperCase()

Java 语言及其char类型是以 Unicode 为基础设计的。然而,Unicode 标准在不断发展,每个新版本的 Java 都会采用新版本的 Unicode。Java 11 使用 Unicode 10.0.0,Java 17 使用 Unicode 13.0。

近期 Unicode 版本的复杂之处在于引入了一些字符,其编码或代码点不适合 16 位。这些补充字符大多是罕见使用的汉字,占用 21 位,无法用单个char值表示。相反,您必须使用一个int值来保存补充字符的代码点,或者使用所谓的“代理对”来编码成两个char值。

除非您经常编写使用亚洲语言的程序,否则不太可能遇到任何补充字符。如果您预计要处理不适合char的字符,已向CharacterString及相关类添加了方法,用于使用int代码点处理文本。

字符串字面量

除了char类型外,Java 还有一种用于处理文本字符串的数据类型(通常简称为字符串)。String类型是一个类,而不是语言的基本类型之一。然而,由于字符串被广泛使用,Java 确实有语法可以在程序中直接包含字符串值。String字面量由双引号中的任意文本组成(与char字面量的单引号相对)。例如:

"Hello World"
"'This' is a string!"

Java 的最新版本还引入了一种称为文本块的多行字符串字面量语法。文本块以"""和换行符开头,当看到另一个"""序列时结束。这些由javac编译器完全处理,并且在字节码中与普通"字符串字面量相同。

"""
Multi-line text blocks
Can use "double quotes" without escaping
"""

字符串字面量可以包含任何作为char字面量出现的转义序列(参见表 2-2)。使用\\"序列在标准String字面量中包含双引号。文本块允许使用这些转义序列,但在换行符或双引号时不需要它们。

因为String是引用类型,字符串字面量将在本章后面的“字符串字面量”中详细描述。第九章详细介绍了在 Java 中处理String对象的一些方法。

整数类型

Java 中的整数类型是byteshortintlong。如表 2-1 所示,这四种类型仅在位数和因此在每种类型可以表示的数字范围方面有所不同。所有整数类型都表示有符号数;与 C 和 C++中的unsigned关键字不同。

对于每种类型的字面量,它们的写法与你期望的完全相同:一系列十进制数字,可选地以减号开头。¹这些字面量中的数字可以用下划线(_)分隔以提高可读性。以下是一些合法的整数字面量:

0
1
123
9_000
-42000

整数字面量是 32 位值(因此被视为 Java 类型int),除非它们以字符Ll结尾,此时它们是 64 位值(并被理解为 Java 类型long):

1234        // An int value
1234L       // A long value
0xffL       // Another long value

整数字面量也可以用十六进制、二进制或八进制表示法表示。以0x0X开头的字面量被视为十六进制数,使用字母AF(或af)作为基数为 16 的数字所需的额外数字。

二进制整数字面量以0b开头,当然,只能包含数字 1 或 0。二进制字面量中使用下划线分隔符是非常常见的,因为二进制字面量可以非常长。

Java 还支持八进制(基数为 8)整数字面量。这些字面量以前导0开头,不能包括数字 8 或 9。它们并不经常使用,除非需要,应该避免使用。合法的十六进制、二进制和八进制字面量包括:

0xff              // Decimal 255, expressed in hexadecimal
0377              // The same number, expressed in octal (base 8)
0b0010_1111       // Decimal 47, expressed in binary
0xCAFEBABE        // A magic number used to identify Java class files

当超出给定整数类型的范围时,Java 中的整数运算永远不会产生溢出或下溢。相反,数字会简单地循环。例如,让我们看一个溢出的例子:

byte b1 = 127, b2 = 1;        // Largest byte is 127
byte sum = (byte)(b1 + b2);   // Sum wraps to -128, the smallest byte

以及相应的下溢行为:

byte b3 = -128, b4 = 5;        // Smallest byte is -128
byte sum2 = (byte)(b3 - b4);   // Sum wraps to a large byte value, 123

当发生这种情况时,Java 编译器和 Java 解释器都不会以任何方式警告你。在进行整数运算时,你必须确保所使用的类型对你打算的目的具有足够的范围。整数除以零和模零是非法的,并导致抛出ArithmeticException。(我们很快将在“已检查和未检查的异常”中详细了解更多异常)。

每种整数类型都有一个对应的包装类:ByteShortIntegerLong。每个这些类都定义了MIN_VALUEMAX_VALUE常量来描述该类型的范围。每个类还提供了一个静态的valueOf()方法,强烈建议使用该方法从原始值创建包装类的实例。虽然包装类具有接受原始类型的普通构造函数,但它们已被弃用,应避免使用。包装类还定义了一些有用的静态方法,如Byte.parseByte()Integer.parseInt(),用于将字符串转换为整数值。

浮点类型

在 Java 中,实数由floatdouble数据类型表示。如表 2-1 所示,float是 32 位单精度浮点值,而double是 64 位双精度浮点值。这两种类型都遵循 IEEE 754-1985 标准,该标准指定了数字的格式以及数字的算术行为。

浮点值可以直接作为 Java 程序中的可选数字字符串包含,后面跟着小数点和另一个数字字符串。以下是一些示例:

123.45
0.0
.01

浮点文字面量还可以使用指数或科学表示法,其中一个数字后跟着字母eE(指数),然后是另一个数字。第二个数字表示第一个数字乘以的 10 的幂。例如:

1.2345E02    // 1.2345 * 10² or 123.45
1e-6         // 1 * 10^-6 or 0.000001
6.02e23      // Avogadro's Number: 6.02 * 10²³

浮点文字面量默认为double值。要在程序中直接包含float值,请在数字后面跟上fF

double d = 6.02E23;
float f = 6.02e23f;

浮点文字面量不能用十六进制、二进制或八进制表示。

除了表示普通数字外,floatdouble类型还可以表示四个特殊值:正无穷大、负无穷大、零和 NaN。当浮点计算产生超出floatdouble可表示范围的值时,会得到无穷大值。

当浮点计算下溢到floatdouble的可表示范围时,将得到零值。

注意

我们可以想象重复地将双精度值1.0除以2.0(例如,在while循环中)。在数学上,无论我们进行多少次除法,结果永远不会变成零。然而,在浮点表示中,经过足够多的除法之后,结果最终会变得非常小,以至于与零几乎无法区分。

Java 浮点类型区分正零和负零,具体取决于下溢发生的方向。实际上,正零和负零的行为几乎相同。最后,最后一个特殊浮点值是 NaN,表示“不是一个数字”。当执行非法浮点操作(例如 0.0/0.0)时,将得到 NaN 值。以下是导致这些特殊值的语句示例:

double inf = 1.0/0.0;             // Infinity
double neginf = -1.0/0.0;         // Negative infinity
double negzero = -1.0/inf;        // Negative zero
double NaN = 0.0/0.0;             // Not a Number

floatdouble原始类型有相应的类,名为FloatDouble。每个类定义了以下有用的常量:MIN_VALUEMAX_VALUENEGATIVE_INFINITYPOSITIVE_INFINITYNaN。与整数包装类类似,浮点包装类也有一个用于构造实例的静态valueOf()方法。

注意

Java 浮点类型可以处理溢出到无穷大和下溢到零以及具有特殊 NaN 值的情况。这意味着浮点算术永远不会抛出异常,即使执行非法操作,如零除以零或对负数取平方根。

无限浮点值的行为如预期。例如,将任何有限值加或减无穷大将得到无穷大。负零的行为与正零几乎相同,事实上,==等号操作符报告负零等于正零。区分负零和正常零的一种方法是通过除以它来进行:1.0/0.0得到正无穷大,但1.0除以负零得到负无穷大。最后,因为 NaN 不是一个数字,==操作符表明它与任何其他数字(包括自身)都不相等!

double NaN = 0.0/0.0;             // Not a Number
NaN == NaN;                       // false
Double.isNaN(NaN);                // true

要检查floatdouble值是否为 NaN,必须使用Float.isNaN()Double.isNaN()方法。

原始类型转换

Java 允许在整数值和浮点值之间进行转换。此外,因为 Unicode 编码中的每个字符对应一个数字,char值可以在整数和浮点类型之间转换。事实上,boolean是 Java 中唯一不能转换为其他原始类型或从其他原始类型转换的类型。

有两种基本类型的转换。扩展转换是指将一种类型的值转换为更宽的类型,即具有更大合法值范围的类型。例如,当你将int字面量分配给double变量或char字面量分配给int变量时,Java 会自动执行扩展转换。

然而,窄化转换是另一回事。窄化转换是指将值转换为不比其宽的类型。窄化转换并不总是安全的:例如,将整数值 13 转换为byte是合理的,但将 13000 转换为byte是不合理的,因为byte只能保存在-128 到 127 之间的数字。由于在窄化转换中可能丢失数据,即使要转换的值实际上可以适合指定类型的更窄范围,javac也会在尝试任何窄化转换时发出警告:

int i = 13;
// byte b = i;    // Incompatible types: possible lossy conversion
                  // from int to byte

唯一的例外是,如果字面量在变量的范围内,可以将整数字面量(int值)分配给byteshort变量。

byte b = 13;

如果需要执行窄化转换并且确信不会丢失数据或精度,可以使用称为强制转换的语言结构强制 Java 执行转换。通过在要转换的值之前在括号中放置所需类型的名称来执行转换。例如:

int i = 13;
byte b = (byte) i;   // Force the int to be converted to a byte
i = (int) 13.456;    // Force this double literal to the int 13

原始类型的强制转换通常用于将浮点值转换为整数。这样做时,浮点值的小数部分会被简单截断(即浮点值向零舍入,而不是向最接近的整数舍入)。静态方法Math.round()Math.floor()Math.ceil()执行其他类型的舍入。

char 类型在大多数情况下像整数类型,因此 char 值可以在需要 intlong 值的任何地方使用。然而,请记住,char 类型是 无符号 的,因此它与 short 类型行为不同,即使两者都是 16 位宽度:

short s = (short) 0xffff; // These bits represent the number -1
char c = '\uffff';        // The same bits, as a Unicode character
int i1 = s;               // Converting the short to an int yields -1
int i2 = c;               // Converting the char to an int yields 65535

表格 2-3(#javanut8-CHP-2-TABLE-3)显示了哪些基本类型可以转换为哪些其他类型以及转换的执行方式。表中的字母 N 表示无法执行转换。字母 Y 表示这是一种自动扩展转换,因此 Java 会自动隐式执行。字母 C 表示这是一种缩小转换,需要显式转换。

最后,Y* 表示转换为自动扩展转换,但在转换过程中可能会丢失一些最低有效位数。当你将 intlong 转换为浮点类型时会发生这种情况——详细信息请参见表格。浮点类型的范围比整数类型更大,因此任何 intlong 都可以被表示为 floatdouble。然而,浮点类型是数字的近似值,不能始终保存与整数类型一样多的有效位数(详见第九章关于浮点数的详细信息)。

表 2-3. Java 基本类型转换

转换为:
从以下类型转换: boolean byte short char int long float double
--- --- --- --- --- --- --- --- ---
boolean - N N N N N N N
byte N - Y C Y Y Y Y
short N C - C Y Y Y Y
char N C C - Y Y Y Y
int N C C C - Y Y* Y
long N C C C C - Y* Y*
float N C C C C C - Y
double N C C C C C C -

表达式和运算符

到目前为止,在本章中,我们已经了解了 Java 程序可以操作的基本类型,并看到如何将基本值作为 字面值 包含在 Java 程序中。我们还使用了 变量 作为表示或保存值的符号名称。这些字面值和变量是构成 Java 程序的标记。

表达式 是 Java 程序中的下一个更高级的结构。Java 解释器 评估 表达式以计算其值。最简单的表达式称为 主表达式,由字面值和变量组成。因此,例如以下都是表达式:

1.7         // A floating-point literal
true        // A Boolean literal
sum         // A variable

当 Java 解释器评估字面表达式时,结果值是字面本身。当解释器评估变量表达式时,结果值是变量中存储的值。

主要表达式并不是很有趣。通过使用运算符来组合主要表达式可以创建更复杂的表达式。例如,以下表达式使用赋值运算符将两个主要表达式——一个变量和一个浮点数文字——组合成一个赋值表达式:

sum = 1.7

但是运算符不仅与主要表达式一起使用;它们还可以与任何复杂程度的表达式一起使用。以下都是合法的表达式:

sum = 1 + 2 + 3 * 1.2 + (4 + 8)/3.0
sum/Math.sqrt(3.0 * 1.234)
(int)(sum + 33)

运算符摘要

编程语言中可以编写的表达式种类完全取决于您可用的运算符集合。Java 拥有丰富的运算符,但要有效地使用它们,您必须理解两个重要的概念:优先级结合性。这些概念以及运算符本身将在以下章节中更详细地解释。

优先级

表格 2-4 的 P 列指定了每个运算符的优先级。优先级指定了操作执行的顺序。具有更高优先级的操作会在具有较低优先级的操作之前执行。例如,考虑这个表达式:

a + b * c

乘法运算符的优先级高于加法运算符,因此a加上bc的乘积,正如我们从初等数学中期望的那样。运算符优先级可以被视为运算符与其操作数绑定得有多紧密的度量。数字越高,绑定得越紧。

通过使用明确指定操作顺序的括号,可以覆盖默认的运算符优先级。前述表达式可以被重写为指定加法应在乘法之前执行:

(a + b) * c

Java 中的默认运算符优先级是为了与 C 兼容而选择的;C 的设计者选择了这种优先级,以便大多数表达式可以自然地写成而不需要括号。只有少数常见的 Java 习语需要括号。例如:

// Class cast combined with member access
((Integer) o).intValue();

// Assignment combined with comparison
while((line = in.readLine()) != null) { ... }

// Bitwise operators combined with comparison
if ((flags & (PUBLIC | PROTECTED)) != 0) { ... }

结合性

结合性是运算符的一个属性,定义了如何评估本来会产生歧义的表达式。当表达式涉及多个具有相同优先级的运算符时,这一点尤为重要。

大多数运算符都是从左到右结合的,这意味着操作是从左到右执行的。然而,赋值和一元运算符具有从右到左的结合性。表格 2-4 的 A 列指定了每个运算符或运算符组的结合性。值 L 表示从左到右,值 R 表示从右到左。

加法运算符都是从左到右结合的,因此表达式a+b-c从左到右进行评估:(a+b)-c。一元运算符和赋值运算符从右到左进行评估。考虑这个复杂表达式:

a = b += c = -~d

这被如下评估:

a = (b += (c = -(~d)))

操作符的结合性与操作符优先级一样,为表达式的默认评估顺序建立了一个默认顺序。可以通过使用括号来覆盖这个默认顺序。然而,在 Java 中,默认的操作符结合性被选择为产生自然的表达式语法。

操作符总结表

表格 2-4 总结了 Java 中可用的操作符。表格中的 P 和 A 列指定了每组相关操作符的优先级和结合性。表格按优先级从高到低排序。在需要时,可以将此表格作为操作符(尤其是它们的优先级)的快速参考。

表 2-4. Java 操作符

P A 操作符 操作数类型 执行的操作
--- --- --- --- ---
16 L . object, member 对象成员访问
[ ] array, int 数组元素访问
( *args* ) method, arglist Method invocation
++, -- variable 后增量,后减量
15 R ++, -- variable 前增量,前减量
+, - number 一元加,一元减
~ integer 位取反
! boolean Boolean NOT
14 R new class, arglist 对象创建
( *type* ) type, any 强制类型转换
13 L *, /, % number, number 乘法,除法,求余
12 L +, - number, number 加法,减法
+ string, any 字符串连接
11 L << integer, integer 左移
>> integer, integer 带符号右移
>>> integer, integer 无符号右移
10 L <, <= number, number 小于,小于等于
>, >= number, number 大于,大于等于
instanceof reference, type Type comparison
9 L == primitive, primitive 等于(具有相同的值)
!= primitive, primitive 不等于(具有不同的值)
== reference, reference 等于(引用同一对象)
!= reference, reference 不等于(引用不同对象)
8 L & integer, integer 位与
& boolean, boolean 布尔与
7 L ^ integer, integer 位异或
^ boolean, boolean 布尔异或
6 L ǀ integer, integer 位或
ǀ boolean, boolean 布尔或
5 L && boolean, boolean 条件与
4 L ǀǀ boolean, boolean 条件或
3 R ? : boolean, any 条件(三元)操作符
2 R = variable, any 赋值
*=, /=, %=, variable, any Assignment with operation
+=, -=, <<=,
>>=, >>>=,
&=, ^=, ǀ=
1 R arglist, method body lambda expression

操作数的数量和类型

表格 2-4 的第四列指定了每个运算符期望的操作数的数量和类型。有些运算符仅作用于一个操作数;这些被称为一元运算符。例如,一元减号运算符改变单个数字的符号:

-n             // The unary minus operator

大多数运算符都是二元运算符,操作两个操作数的值。减号运算符实际上有两种形式:

a – b          // The subtraction operator is a binary operator

Java 还定义了一个三元运算符,通常称为条件运算符。它类似于表达式中的if语句。它的三个操作数由问号和冒号分隔;第二和第三个操作数必须可以转换为相同的类型:

x > y ? x : y  // Ternary expression; evaluates to larger of x and y

除了期望特定数量的操作数外,每个运算符还期望特定类型的操作数。表中的第四列列出了操作数类型。该列中使用的一些代码需要进一步解释:

数字

整数、浮点值或字符(即任何原始类型,除了boolean)。自动拆箱(参见“装箱和拆箱转换”)意味着这些类型的包装类(如CharacterIntegerDouble)也可以在此上下文中使用。

整数

byteshortintlongchar值(数组访问操作符[ ]不允许long值)。使用自动拆箱,也可以允许ByteShortIntegerLongCharacter值。

引用

一个对象或数组。

变量

变量或其他任何可以分配值的内容,如数组元素。

返回类型

就像每个运算符期望其操作数具有特定的类型一样,每个运算符也产生特定类型的值。算术、递增和递减、位和移位运算符中,如果至少有一个操作数是double,则返回double。如果至少有一个操作数是float,则返回float。如果至少有一个操作数是long,则返回long。否则,返回int,即使两个操作数都是比int更窄的byteshortchar类型。

比较、相等和布尔运算符始终返回boolean值。每个赋值运算符返回其分配的值,该值与表达式左侧的变量兼容。条件运算符返回其第二或第三个参数的值(这两个参数必须可以转换为相同的类型)。

副作用

每个运算符基于一个或多个操作数的值计算一个值。然而,一些运算符除了基本计算外,还有副作用。如果一个表达式包含副作用,评估它会改变 Java 程序的状态,因此再次评估表达式可能会产生不同的结果。

例如,++增量运算符具有增加变量的副作用。表达式++a增加变量a并返回新增后的值。如果再次评估此表达式,则值将不同。各种赋值运算符也具有副作用。例如,表达式a*=2也可以写为a=a*2。表达式的值是a乘以 2 的值,但表达式具有将该值存储回a的副作用。

方法调用运算符()如果调用的方法具有副作用,则具有副作用。例如Math.sqrt()等一些方法仅计算并返回值,没有任何副作用。通常情况下,方法确实具有副作用。最后,new运算符具有创建新对象的深远副作用。

评估顺序

当 Java 解释器评估表达式时,它根据表达式中括号的顺序、运算符的优先级和运算符的结合性执行各种操作。然而,在执行任何操作之前,解释器首先评估运算符的操作数。(但是,&&||?运算符除外,它们并不总是评估所有操作数。)解释器总是按从左到右的顺序评估操作数。如果操作数中有包含副作用的表达式,则这很重要。例如,考虑以下代码:

int a = 2;
int v = ++a + ++a * ++a;

虽然乘法在加法之前执行,但+运算符的操作数首先被评估。因为++的操作数都是++a,它们被评估为34,因此表达式评估为3 + 4 * 5,即23

算术运算符

算术运算符可用于整数、浮点数,甚至字符(即它们可以用于除boolean以外的任何基本类型)。如果操作数中有任一操作数是浮点数,则使用浮点数算术;否则,使用整数算术。这很重要,因为整数算术和浮点数算术在执行除法的方式以及处理下溢和上溢的方式等方面有所不同。算术运算符包括:

加法 (+)

+运算符添加两个数字。正如我们将很快看到的,+运算符也可以用于连接字符串。如果+的任一操作数是字符串,则另一个操作数也将转换为字符串。当您希望将加法与连接结合时,请务必使用括号。例如:

System.out.println("Total: " + 3 + 4);   // Prints "Total: 34", not 7!

+运算符也可以作为一元运算符使用,表示正数,例如+42

减法 (-)

-运算符用作二元运算符时,它将其第二个操作数从第一个操作数中减去。例如,7-3的结果为4-运算符还可以执行一元否定。

乘法 (*)

*运算符将其两个操作数相乘。例如,7*3的结果为21

除法 (/)

/运算符将其第一个操作数除以第二个操作数。如果两个操作数都是整数,则结果是整数,并且任何余数都会丢失。但是,如果任一操作数是浮点值,则结果是浮点值。当你除以零时,整数除法会抛出ArithmeticException。然而,对于浮点数计算,除以零会简单地产生一个无限的结果或NaN

7/3          // Evaluates to 2
7/3.0f       // Evaluates to 2.333333f
7/0          // Throws an ArithmeticException
7/0.0        // Evaluates to positive infinity
0.0/0.0      // Evaluates to NaN

模运算%

%运算符计算第一个操作数除以第二个操作数的余数(即,当第一个操作数被第二个操作数整除时的余数)。例如,7%31。结果的符号与第一个操作数的符号相同。虽然模运算符通常用于整数操作数,但它也适用于浮点值。例如,4.3%2.1评估为0.1。当你操作整数时,尝试计算模零的值会导致ArithmeticException。当你使用浮点值时,任何值模0.0都会评估为NaN,正如无穷大模任何值一样。

一元减号-

-运算符用作一元运算符时——即在单个操作数之前时——它执行一元否定。换句话说,它将正值转换为等效的负值,反之亦然。

字符串连接运算符

除了添加数字外,+运算符(以及相关的+=运算符)还可以连接或拼接字符串。如果+的任一操作数是字符串,则运算符会将另一个操作数转换为字符串。例如:

// Prints "Quotient: 2.3333333"
System.out.println("Quotient: " + 7/3.0f);

因此,在将任何附加表达式与字符串连接时,务必将其放在括号中。如果不这样做,加法运算符将被解释为连接运算符。

Java 为所有基本类型提供了内置的字符串转换。对象通过调用其toString()方法转换为字符串。一些类定义了自定义的toString()方法,以便可以轻松地将该类的对象转换为字符串。但遗憾的是,并非所有类在转换为字符串时都返回友好的结果。例如,数组的内置toString()并不返回其内容的有用字符串表示,而仅返回有关数组对象本身的信息。

递增和递减运算符

++运算符递增其单个操作数,该操作数必须是变量、数组的元素或对象的字段,递增量为 1。此运算符的行为取决于其相对于操作数的位置。当用于操作数之前时,称为前递增运算符,它递增操作数并评估为递增后的值。当用于操作数之后时,称为后递增运算符,它递增其操作数但评估为递增前的值。

例如,以下代码将ij都设置为 2:

i = 1;
j = ++i;

但是,这些行将i设置为 2,j设置为 1:

i = 1;
j = i++;

同样地,--运算符将其单个数值操作数递减 1。与++运算符类似,--的行为取决于其相对于操作数的位置。当在操作数之前使用时,它会递减操作数并返回递减后的值。当在操作数之后使用时,它会递减操作数但返回未递减的值。

表达式x++x--分别等同于x = x + 1x = x - 1,但是当你使用增量和减量运算符时,x只计算一次。如果x本身是一个具有副作用的表达式,这将产生很大的不同。例如,这两个表达式并不等同,因为第二种形式会使i增加两次:

a[i++]++;             // Increments an element of an array

// Adds 1 to an array element and stores new value in another element
a[i++] = a[i++] + 1;

这些运算符,无论是前缀还是后缀形式,最常用于增加或减少控制循环计数器。然而,越来越多的程序员更喜欢避免使用增量和减量运算符,而是更喜欢使用显式的代码。这种观点是由于历史上由于操作符的错误使用而导致的大量 bug。

比较运算符

比较运算符包括测试值是否相等或不等的相等运算符和与有序类型(数字和字符)一起使用的关系运算符。这两种类型的运算符产生一个boolean结果,因此它们通常与if语句、三元条件运算符或whilefor循环一起使用,以进行分支和循环决策。例如:

if (o != null) ...;           // The not equals operator
while(i < a.length) ...;      // The less than operator

Java 提供以下相等运算符:

等于 (==)

==运算符在其两个操作数相等时求值为true,否则为false。对于原始操作数,它测试操作数值本身是否相同。然而,对于引用类型的操作数,它测试操作数是否引用同一个对象或数组。换句话说,它不测试两个不同的对象或数组的相等性。特别地,请注意,你不能使用此运算符测试两个不同的字符串是否相等。

警告

如果你通过==比较字符串,可能会看到结果表明它正常工作。这是 Java 内部字符串缓存的副作用,称为interning。比较字符串(或任何其他引用类型)的唯一可靠方法是使用equals()方法。

对于基本包装类也是一样的,所以new Integer(1) != new Integer(1),而推荐的Integer.valueOf(1) == Integer.valueOf(1)则相等。显然,任何非原始类型的等式比较都应该使用equals()方法。关于对象等式的更多讨论可以在equals()中找到。

如果使用==比较两个不同类型的数值或字符操作数,窄操作数在比较之前会被转换为宽操作数的类型。例如,当你比较一个short和一个float时,short会先转换为float再进行比较。对于浮点数,特殊的负零值与常规的正零值相等。此外,特殊的NaN(不是一个数字)值不等于任何其他数字,包括自身。要测试浮点数值是否为NaN,可以使用Float.isNan()Double.isNan()方法。

不等于 (!=)

!=运算符与==运算符正好相反。如果其两个基本操作数具有不同的值,或者其两个引用操作数引用不同的对象或数组,则评估为true。否则,评估为false

关系运算符可以用于数字和字符,但不能用于boolean值、对象或数组,因为这些类型没有顺序。

Java 提供以下关系运算符:

小于 (<)

如果第一个操作数小于第二个,则评估为true

小于或等于 (<=)

如果第一个操作数小于或等于第二个,则评估为true

大于 (>)

如果第一个操作数大于第二个,则评估为true

大于或等于 (>=)

如果第一个操作数大于或等于第二个,则评估为true

布尔运算符

正如我们刚刚看到的,比较运算符比较它们的操作数,并产生一个boolean结果,通常用于分支和循环语句。为了使基于条件的分支和循环决策更有趣,而不仅仅是单个比较,你可以使用布尔(或逻辑)运算符将多个比较表达式组合成一个单一的、更复杂的表达式。布尔运算符要求其操作数为boolean值,并评估为boolean值。这些运算符包括:

条件与 (&&)

此运算符对其操作数执行布尔 AND 操作。如果且仅当其两个操作数都为true时,评估为true。如果其中一个或两个操作数为false,则评估为false。例如:

if (x < 10 && y > 3) ... // If both comparisons are true

此运算符(以及所有布尔运算符,除了一元!运算符外)的优先级低于比较运算符。因此,像刚刚显示的代码行是完全合法的。但是,一些程序员喜欢使用括号来显式地指定评估顺序:

if ((x < 10) && (y > 3)) ...

你应该使用你觉得阅读更容易的风格。

这个运算符称为条件 AND,因为它有条件地评估其第二个操作数。如果第一个操作数评估为false,则表达式的值为false,无论第二个操作数的值如何。因此,为了提高效率,Java 解释器采取了一种捷径,并跳过第二个操作数。不能保证评估第二个操作数,因此在使用具有副作用的表达式与此运算符时必须小心。另一方面,此运算符的条件性质允许我们编写如下的 Java 表达式:

if (data != null && i < data.length && data[i] != -1)
    ...

此表达式中的第二个和第三个比较如果第一个或第二个比较结果为false将导致错误。幸运的是,由于&&运算符的条件行为,我们不必担心这个问题。

条件或 (||)

这个运算符在其两个boolean操作数上执行布尔 OR 操作。如果其任一或两个操作数为true,则评估为true。如果两个操作数都为false,则评估为false。与&&运算符类似,||运算符并不总是评估其第二个操作数。如果第一个操作数评估为true,则表达式的值为true,无论第二个操作数的值如何。因此,在这种情况下,该运算符简单地跳过第二个操作数。

布尔非 (!)

此一元运算符改变其操作数的boolean值。如果应用于true值,则评估为false,如果应用于false值,则评估为true。在这些表达式中很有用:

if (!found) ...          // found is a boolean declared somewhere
while (!c.isEmpty()) ... // The isEmpty() method returns a boolean

因为!是一元运算符,具有很高的优先级,并且通常必须与括号一起使用:

if (!(x > y && y > z))

布尔与 (&)

当与boolean操作数一起使用时,&运算符的行为类似于&&运算符,但始终评估两个操作数,而不管第一个操作数的值如何。然而,此运算符几乎总是作为整数操作数的位运算符使用,因此许多 Java 程序员甚至不会认识其在boolean操作数中作为合法 Java 代码的使用。

布尔或 (|)

这个运算符在其两个boolean操作数上执行布尔 OR 操作。它与||运算符类似,但始终评估两个操作数,即使第一个操作数为true|运算符几乎总是用作整数操作数的位运算符;在boolean操作数中很少见到其使用。

布尔异或 (^)

当与boolean操作数一起使用时,此运算符计算其操作数的异或(XOR)。如果两个操作数中恰好一个为true,则评估为true。换句话说,如果两个操作数都为false或者两个操作数都为true,则评估为false。与&&||运算符不同,此运算符必须始终评估两个操作数。^运算符在整数操作数中作为位运算符使用得更为常见。对于boolean操作数,此运算符等同于!=运算符。

位运算符和移位运算符

位与移位运算符是操作整数值中构成其个别位的低级运算符。位运算符在现代 Java 中不常用,除非进行低级工作(例如网络编程)。它们用于测试和设置值中的单个标志位。要理解它们的行为,您必须理解用于表示负整数的二进制(基数 2)数和两补码格式。

你不能将这些运算符与浮点、boolean、数组或对象操作数一起使用。当与boolean操作数一起使用时,&|^运算符执行不同的操作,如前一节所述。

如果位运算符的任一参数是long,则结果是long。否则,结果是int。如果位移运算符的左操作数是long,则结果是long;否则,结果是int。这些运算符是:

位取反~

一元~运算符称为位取反或位 NOT 运算符。它反转其单个操作数的每个位,将 1 转换为 0,将 0 转换为 1。例如:

byte b = ~12;           // ~00001100 =  => 11110011 or -13 decimal
flags = flags & ~f;     // Clear flag f in a set of flags

位与&

该运算符通过对其两个整数操作数的各自位执行布尔 AND 运算来结合它们。只有在两个操作数中的相应位都设置时,结果才具有一个位设置。例如:

10 & 7                   // 00001010 & 00000111 =  => 00000010 or 2
if ((flags & f) != 0)    // Test whether flag f is set

当使用boolean操作数时,&是之前描述的不常用的布尔 AND 运算符。

位或|

该运算符通过对其两个整数操作数的各自位执行布尔 OR 运算来结合它们。如果相应位在一个或两个操作数中被设置,则结果有一个位设置。仅在两个相应操作数位都为零时,它具有零位。例如:

10 | 7                   // 00001010 | 00000111 =  => 00001111 or 15
flags = flags | f;       // Set flag f

当与boolean操作数一起使用时,|是之前描述的不常用的布尔 OR 运算符。

位异或^

该运算符通过对其各自位执行布尔异或(exclusive OR)操作来结合其两个整数操作数的位。如果两个操作数中的对应位不同,则结果具有一个位设置。如果对应的操作数位都是 1 或都是 0,则结果位为 0。例如:

10 ^ 7               // 00001010 ^ 00000111 =  => 00001101 or 13

当与boolean操作数一起使用时,^是很少使用的布尔 XOR 运算符。

左移<<

<<运算符将左操作数的位左移右操作数指定的位数。左操作数的高阶位将丢失,并且从右侧移入零位。将整数左移n位等效于将该数字乘以 2^(n)。例如:

10 << 1    // 0b00001010 << 1 = 00010100 = 20 = 10*2
7 << 3     // 0b00000111 << 3 = 00111000 = 56 = 7*8
-1 << 2    // 0xFFFFFFFF << 2 = 0xFFFFFFFC = -4 = -1*4
           // 0xFFFF_FFFC == 0b1111_1111_1111_1111_1111_1111_1111_1100

如果左操作数是long,则右操作数应在 0 到 63 之间。否则,左操作数被视为int,右操作数应在 0 到 31 之间。如果超出这些范围,则可能会看到这些运算符的不直观的包装行为。

有符号右移>>

>> 运算符将左操作数的位向右移动右操作数指定的位数。左操作数的低位位移并丢失。位于左操作数中的高位位移相同于原始左操作数的高位位。换句话说,如果左操作数为正,则将 0 移入高位位。如果左操作数为负,则移入的是 1。这种技术称为符号扩展;它用于保留左操作数的符号。例如:

10 >> 1      // 00001010 >> 1 = 00000101 = 5 = 10/2
27 >> 3      // 00011011 >> 3 = 00000011 = 3 = 27/8
-50 >> 2     // 11001110 >> 2 = 11110011 = -13 != -50/4

如果左操作数为正,右操作数为 n,则 >> 运算符与整数除法相同除以 2^(n)。

无符号右移 (>>>)

此运算符类似于 >> 运算符,但它总是将零移入结果的高阶位,而不管左操作数的符号如何。这种技术称为零扩展;当左操作数被视为无符号值时(尽管 Java 整数类型都是有符号的),这是适当的。以下是示例:

0xff >>> 4    // 11111111 >>> 4 = 00001111 = 15  = 255/16
-50 >>> 2     // 0xFFFFFFCE >>> 2 = 0x3FFFFFF3 = 1073741811

赋值运算符

赋值运算符将值存储或分配到计算机的一部分内存中--通常称为存储位置。左操作数必须评估为适当的局部变量,数组元素或对象字段。

注意

赋值表达式的左操作数有时被称为 *lvalue*。在 Java 中,它必须引用一些可赋值的存储(即可写入的内存)。

右操作数(*rvalue*)可以是与变量兼容的任何类型的值。赋值表达式的评估结果是分配给变量的值。然而,更重要的是,该表达式具有实际执行分配的副作用—将 rvalue 存储在 lvalue 中。

提示

与所有其他二进制运算符不同,赋值运算符是右关联的,这意味着 a=b=c 中的赋值是从右向左执行的,如下所示: a=(b=c)

基本赋值运算符是 =。不要将其与相等运算符 == 混淆。为了区分这两个运算符,我们建议将 = 读作“被赋予值”。

除了这个简单的赋值运算符之外,Java 还定义了另外 11 个将赋值与 5 个算术运算符和 6 个位和移位运算符结合的运算符。例如,+= 运算符读取左变量的值,将右操作数的值添加到它中,作为副作用将总和存储回左变量,并返回总和作为表达式的值。因此,表达式 x+=2 几乎与 x=x+2 相同。这两个表达式之间的区别在于当您使用 += 运算符时,左操作数只被评估一次。当该操作数具有副作用时,这是有区别的。考虑以下两个不等式:

a[i++] += 2;
a[i++] = a[i++] + 2;

这些组合赋值运算符的一般形式是:

lvalue op= rvalue

这与以下内容等效(除非在 lvalue 中存在副作用):

lvalue = lvalue op rvalue

可用的运算符有:

+=    -=    *=    /=    %=    // Arithmetic operators plus assignment

&=    |=    ^=                // Bitwise operators plus assignment

<<=   >>=   >>>=              // Shift operators plus assignment

最常用的运算符是 +=-=,尽管 &=|= 在处理布尔值或位标志时也很有用。例如:

i += 2;          // Increment a loop counter by 2
c -= 5;          // Decrement a counter by 5
flags |= f;      // Set a flag f in an integer set of flags
flags &= ~f;     // Clear a flag f in an integer set of flags

条件运算符

条件运算符 ?: 是从 C 语言继承过来的一个有些晦涩的三元(三操作数)运算符。它允许你在表达式中嵌入条件判断。你可以将它视为if/else语句的运算符版本。条件运算符的第一和第二操作数之间用问号 (?) 分隔,第二和第三操作数之间用冒号 (:) 分隔。第一操作数必须求值为布尔值。第二和第三操作数可以是任何类型,但它们必须可转换为相同的类型。

条件运算符首先评估其第一个操作数。如果它为 true,则运算符评估其第二个操作数并将其用作表达式的值。另一方面,如果第一个操作数为 false,则条件运算符评估并返回其第三个操作数。条件运算符永远不会同时评估其第二和第三个操作数,因此在使用具有副作用的表达式时要小心。此运算符的示例有:

int max = (x > y) ? x : y;
String name = (value != null) ? value : "unknown";

注意,?: 运算符的优先级低于除赋值运算符之外的所有其他运算符,因此通常不需要在此运算符的操作数周围使用括号。然而,许多程序员发现如果将第一个操作数放在括号内,条件表达式更易于阅读。这一点尤其重要,因为条件 if 语句总是将其条件表达式写在括号内。

instanceof 运算符

instanceof 运算符与对象和 Java 类型系统的操作密切相关。如果这是您第一次了解 Java,可能最好先略过这个定义,等您对 Java 的对象有了较好的理解后再回到这部分。

instanceof 要求其左操作数为对象或数组值,右操作数为引用类型的名称。在其基本形式中,如果对象或数组是指定类型的实例,则它评估为 true;否则返回 false。如果左操作数为 nullinstanceof 总是评估为 false。如果 instanceof 表达式评估为 true,这意味着您可以安全地将左操作数强制转换并赋值给右操作数类型的变量。

instanceof 运算符只能与引用类型和对象一起使用,不能与基本类型和值一起使用。instanceof 的示例有:

// True: all strings are instances of String
"string" instanceof String
// True: strings are also instances of Object
"" instanceof Object
// False: null is never an instance of anything
null instanceof String

Object o = new int[] {1,2,3};
o instanceof int[]   // True: the array value is an int array
o instanceof byte[]  // False: the array value is not a byte array
o instanceof Object  // True: all arrays are instances of Object

// Use instanceof to make sure that it is safe to cast an object
if (object instanceof Account) {
   Account a = (Account) object;
}

在 Java 17 中,instanceof 有一个被称为模式匹配的扩展形式。上面最后的示例展示了一个常见的模式检查 instanceof,然后在条件中将其转换为类型。使用模式匹配,我们可以一次性地表达这一切,包括引用类型后面的变量。如果 instanceof 看到类型兼容,变量将被赋予转换后的对象。

if (object instanceof Account a) {
   // variable a is available in this scope
}

这种模式匹配是 Java 中的一个最新添加。预计未来的版本将在整个语言中提供更多此类便利功能。

历史上,鼓励使用 instanceof 以支持其他更面向对象的解决方案,我们将在 Chapter 5 中看到。然而,Java 对模式匹配的日益采用正在改变对这个运算符的态度。在通过 API 接收不可预测格式的数据的常见情况下,instanceof 尤其适合,并且这些天通常是一种务实的选择,而不是最后的手段。

特殊运算符

Java 有六种语言构造,有时被认为是运算符,有时被认为仅仅是基本语言语法的一部分。这些“运算符”在 Table 2-4 中列出,以显示它们相对于其他真正运算符的优先级。这些语言构造的使用在本书的其他地方有详细说明,但在这里简要描述,以便您能够在代码示例中识别它们:

成员访问.

一个对象是一组操作数据和操作该数据的方法的集合;对象的数据字段和方法称为其成员。点(.)运算符用于访问这些成员。如果 o 是一个求值为对象引用(或类名)的表达式,并且 f 是类的字段名,则 o.f 求值为该字段包含的值。如果 m 是一个方法名,则 o.m 引用该方法并允许使用稍后显示的 () 运算符调用它。

数组元素访问[]

一个数组是值的编号列表。数组的每个元素可以通过其编号或索引引用。[] 运算符允许您引用数组的单个元素。如果 a 是一个数组,并且 i 是一个求值为 int 的表达式,则 a[i] 引用数组 a 的一个元素。与处理整数值的其他运算符不同,此运算符将数组索引值限制为 int 类型或更窄。

方法调用()

方法是一组命名的 Java 代码,可以通过在方法名后跟随零个或多个逗号分隔的表达式括在括号中来运行或调用。这些表达式的值是方法的参数。方法处理这些参数并可选择返回一个值,该值成为方法调用表达式的值。如果o.m是一个不带参数的方法,则可以使用o.m()来调用该方法。例如,如果方法期望三个参数,则可以使用表达式o.m(x,y,z)来调用它。o称为方法的接收者 —— 如果o是一个对象,则称其为接收对象。在 Java 解释器调用方法之前,它会评估要传递给方法的每个参数。这些表达式将按从左到右的顺序进行评估(如果其中任何一个参数具有副作用,则这一点很重要)。

Lambda 表达式 (->)

Lambda 表达式是一个匿名的可执行 Java 代码集合,本质上是一个方法体。它由一个方法参数列表(零个或多个逗号分隔的表达式括在括号中)后跟 lambda箭头运算符,然后是一段 Java 代码块组成。如果代码块只包含单个语句,则可以省略通常用于标识块边界的大括号。如果 lambda 只接受一个参数,则可以省略参数周围的括号。

对象创建 (new)

在 Java 中,对象是使用new运算符创建的,后面跟着要创建的对象类型和用括号括起的要传递给对象构造函数的参数列表。构造函数是一个特殊的代码块,用于初始化新创建的对象,因此对象创建语法类似于 Java 方法调用语法。例如:

new ArrayList<String>();
new Account("Jason", 0.0, 42);

数组创建 (new)

数组是对象的一种特殊情况,它们也是使用new运算符创建的,但语法略有不同。关键字后跟要创建的数组类型和用方括号括起的数组大小 —— 例如,new int[5]。在某些情况下,还可以使用数组字面值语法创建数组。

类型转换或强制转换 (())

正如我们已经看到的,括号也可以用作运算符来执行类型转换或强制转换。此运算符的第一个操作数是要转换的类型;它位于括号之间。第二个操作数是要转换的值;它跟在括号后面。例如:

(byte) 28          // An integer literal cast to a byte type
(int) (x + 3.14f)  // A floating-point sum value cast to an integer
(String)h.get(k)   // A generic object cast to a string

语句

语句是 Java 语言中执行的基本单元 —— 它表达了程序员的单一意图。与表达式不同,Java 语句没有值。语句通常包含表达式和操作符(特别是赋值操作符),并且通常执行引起的副作用。

Java 定义的许多语句是流程控制语句,例如条件语句和循环语句,可以以明确定义的方式改变默认的线性执行顺序。表 2-5 总结了 Java 定义的语句。

表 2-5. Java 语句

语句 用途 语法
表达式 副作用 variable = expr ; expr ++; method (); new Type ( );
复合 组合语句 { statements }
什么也不做 ;
带标签的 命名语句 label : statement
变量 声明变量 [final] type name [= value ] [, name [= value ]] …;
if 条件 if ( expr ) statement [ else statement ]
switch 条件 switch ( expr ) { [ case expr : statements ] … [ default: statements ] }
switch 条件表达式 switch ( expr ) { [ case expr , [ expr …] -> expr ;] … [ default -> expr ;] }
while 循环 while ( expr ) statement
do 循环 do statement while ( expr );
for 简化循环 for ( init ; test ; increment ) statement
foreach 集合迭代 for ( variable : iterable ) statement
break 退出循环 break [ label ] ;
continue 重新开始循环 continue [ label ] ;
return 结束方法 return [ expr ] ;
synchronized 临界区 synchronized ( expr ) { statements }
throw 抛出异常 throw expr ;
try 处理异常 try { statements } [ catch ( type name ) { statements } ] … [ finally { statements } ]
try 处理异常,关闭资源 try ([ variable = expr ]) { statements } [ catch ( type name ) { statements } ] … [ finally { statements } ]
assert 验证不变性 assert invariant [ error ];

表达式语句

正如我们在本章前面看到的那样,Java 的某些类型的表达式具有副作用。换句话说,它们不仅仅评估为某个值;它们还以某种方式改变程序状态。您可以使用具有副作用的任何表达式作为语句,只需在分号后面跟随它即可。表达式语句的合法类型包括赋值、增量和减量、方法调用和对象创建。例如:

a = 1;                             // Assignment
x *= 2;                            // Assignment with operation
i++;                               // Post-increment
--c;                               // Pre-decrement
System.out.println("statement");   // Method invocation

复合语句

复合语句 是任意数量和类型的语句在花括号内组合在一起。您可以在 Java 语法所需的任何位置使用复合语句作为语句:

for(int i = 0; i < 10; i++) {
   a[i]++;           // Body of this loop is a compound statement.
   b[i]--;           // It consists of two expression statements
}                    // within curly braces.

空语句

Java 中的空语句写为一个单分号。空语句不做任何事情,但语法偶尔会有用。例如,您可以在for循环中使用它来指示一个空的循环体:

for(int i = 0; i < 10; a[i++]++)  // Increment array elements
     /* empty */;                 // Loop body is empty statement

标记语句

标记语句简单地说就是给一个语句起了一个名字,方法是在其前面加上标识符和冒号。标签由breakcontinue语句使用。例如:

rowLoop: for(int r = 0; r < rows.length; r++) {        // Labeled loop
   colLoop: for(int c = 0; c < columns.length; c++) {  // Another one
     break rowLoop;                                    // Use a label
   }
}

本地变量声明语句

局部变量,通常简称为变量,是一个在方法或复合语句中定义的用于存储值的位置的符号名称。所有变量在使用前必须声明;这通过变量声明语句完成。因为 Java 是一种静态类型语言,所以变量声明指定变量的类型,只有该类型的值可以存储在变量中。

在其最简单的形式中,变量声明指定变量的类型和名称:

int counter;
String s;

变量声明还可以包括一个初始化器,这是一个指定变量初始值的表达式。例如:

int i = 0;
String s = readLine();
int[] data = {x+1, x+2, x+3}; // Array initializers are discussed later

Java 编译器不允许使用未初始化的局部变量,所以通常方便将变量声明和初始化结合为一个语句。初始化器表达式不必是编译器可以评估的文字值或常量表达式;它可以是在程序运行时计算值的任意复杂表达式。

如果变量有一个初始化器,那么程序员可以使用特殊的语法要求编译器自动计算类型,如果可能的话:

var i = 0;          // type of i inferred as int
var s = readLine(); // type of s inferred as String

这可能是一种有用的语法,但可能更难阅读。例如,我们的第二个例子需要您知道readLine()的返回类型是String,才能知道var将推断为何种类型。因此,在文本中,我们只在初始化器使类型完全冗余时在示例中使用var。当您学习 Java 语言时,这可能是一个合理的政策,因为您熟悉 Java 类型系统时需要遵循这个政策。

单变量声明语句可以声明和初始化多个变量,但所有变量必须是显式声明类型相同的。变量名称和可选的初始化器用逗号分隔:

int i, j, k;
float x = 1.0f, y = 1.0f;
String question = "Really Quit?", response;

变量声明语句可以以final关键字开头。这个修饰符指定了一旦为变量定义了初始值,那么该值就永远不允许更改:

final String greeting = getLocalLanguageGreeting();

我们稍后会更详细地讨论final关键字,特别是在谈论类的设计和编程的不可变风格时。

Java 变量声明语句可以出现在 Java 代码的任何地方;它们不限于方法或代码块的开头。局部变量声明也可以与for循环的初始化部分集成,我们将很快讨论。

局部变量只能在定义它们的方法或代码块内部使用。这称为它们的作用域词法作用域

void method() {            // A method definition
   int i = 0;              // Declare variable i
   while (i < 10) {        // i is in scope here
     int j = 0;            // Declare j; the scope of j begins here
     i++;                  // i is in scope here; increment it
   }                       // j is no longer in scope;
   System.out.println(i);  // i is still in scope here
}                          // The scope of i ends here

if/else 语句

if语句是一个基本的控制语句,允许 Java 进行决策,更准确地说,有条件地执行语句。if语句有一个关联的表达式和语句。如果表达式求值为true,解释器将执行语句。如果表达式求值为false,解释器将跳过该语句。

Java 允许表达式是包装类型Boolean而不是基本类型boolean。在这种情况下,包装对象将自动取消装箱。

以下是一个示例if语句:

if (username == null)         // If username is null,
   username = "John Doe";     // use a default value

尽管它们看起来是多余的,但是表达式周围的括号是if语句语法的必需部分。正如我们已经看到的,被花括号括起来的语句块本身就是一个语句,所以我们也可以编写如下形式的if语句:

if ((address == null) || (address.equals(""))) {
   address = "[undefined]";
   System.out.println("WARNING: no address specified.");
}

if语句可以包括一个可选的else关键字,后面跟着第二个语句。在这种形式的语句中,表达式被求值,如果它是true,则执行第一个语句。否则,执行第二个语句。例如:

if (username != null)
   System.out.println("Hello " + username);
else {
   username = askQuestion("What is your name?");
   System.out.println("Hello " + username + ". Welcome!");
}

当使用嵌套的if/else语句时,需要谨慎确保else子句与适当的if语句配对。考虑以下行:

if (i == j)
   if (j == k)
     System.out.println("i equals k");
else
   System.out.println("i doesn't equal j");    // WRONG!!

在此示例中,内部if语句形成了外部if语句语法允许的单个语句。不幸的是,不清楚(除了缩进给出的提示之外)else与哪个if配对。在这个例子中,缩进提示是错误的。规则是这样的:这样的else子句与最近的if语句关联。正确缩进后,此代码如下所示:

if (i == j)
   if (j == k)
     System.out.println("i equals k");
   else
     System.out.println("i doesn't equal j");    // WRONG!!

这是合法的代码,但显然不是程序员所想要的。在使用嵌套if语句时,应该使用花括号使您的代码更易于阅读。以下是编写代码的更好方法:

if (i == j) {
   if (j == k)
     System.out.println("i equals k");
}
else {
   System.out.println("i doesn't equal j");
}

else if 子句

if/else语句用于测试条件并选择要执行的两个语句或代码块之间的选择。但是,当您需要在几个代码块之间进行选择时呢?这通常使用else if子句来完成,这并不是真正的新语法,而是标准if/else语句的一种常见习惯用法。它看起来像这样:

if (n == 1) {
    // Execute code block #1
}
else if (n == 2) {
    // Execute code block #2
}
else if (n == 3) {
    // Execute code block #3
}
else {
    // If all else fails, execute block #4
}

这段代码并没有什么特别之处。它只是一系列if语句,每个if语句都是上一条语句的else子句的一部分。使用else if习语比完全嵌套形式更可取,并且更易读:

if (n == 1) {
   // Execute code block #1
}
else {
   if (n == 2) {
     // Execute code block #2
   }
   else {
     if (n == 3) {
       // Execute code block #3
     }
     else {
       // If all else fails, execute block #4
     }
   }
}

switch 语句

if语句会导致程序执行流程的分支。您可以使用多个if语句,如前一节所示,执行多路分支。然而,并非总是最佳解决方案,特别是当所有分支都依赖于单个变量的值时。

在这种情况下,重复的if语句可能会严重影响可读性,特别是如果代码经过了重构或具有多层嵌套的if

更好的解决方案是使用switch语句,这是从 C 编程语言继承而来的。然而,请注意,此语句的语法并不像 Java 的其他部分那样优雅。没有重新审视这一特性的设计被普遍认为是一个错误,这在最近的版本中部分得到了纠正,引入了我们将在稍后讨论的switch表达式形式。然而,该替代格式不会抹去语言中长期存在的switch语句的历史,因此理解它是有益的。

注意

switch语句以一个表达式开始,其类型为intshortcharbyte(或它们的包装类型)、String或枚举(详见第四章关于枚举类型的更多信息)。

此表达式后跟着一个带有多个入口点的代码块,这些入口点对应于表达式可能的值。例如,以下switch语句等效于前一节中显示的重复ifelse/if语句:

switch(n) {
   case 1:                         // Start here if n == 1
     // Execute code block #1
     break;                        // Stop here
   case 2:                         // Start here if n == 2
     // Execute code block #2
     break;                        // Stop here
   case 3:                         // Start here if n == 3
     // Execute code block #3
     break;                        // Stop here
   default:                        // If all else fails...
     // Execute code block #4
     break;                        // Stop here
}

正如您从示例中看到的那样,switch语句中的各个入口点要么用关键字case标记,后面跟着一个整数值和一个冒号,要么用特殊的default关键字标记,后面跟着一个冒号。当switch语句执行时,解释器计算括号中表达式的值,然后查找与该值匹配的case标签。如果找到匹配的标签,解释器将从case标签后的第一条语句开始执行代码块。如果没有找到具有匹配值的case标签,则解释器将从特殊的default:标签后的第一条语句开始执行。或者,如果没有default:标签,则解释器完全跳过switch语句的主体。

在前面的代码中,在每个case结尾处使用了break关键字,请注意。break语句将在本章后面进行解释,但在此示例中,它使解释器退出switch语句的主体。switch语句中的case子句仅指定所需代码的起始点。各个case不是独立的代码块,并且它们没有任何隐含的结束点。

警告

您必须显式指定每个 case 的结束,使用 break 或相关语句。在缺少 break 语句的情况下,switch 语句从匹配的 case 标签后的第一个语句开始执行代码,并继续执行语句,直到达到块的末尾。控制流会 fall through 到下一个 case 标签并继续执行,而不是退出块。

在罕见情况下,编写这样的代码是有用的,从一个 case 标签穿透到下一个 case 标签,但 99% 的情况下,您应该小心地结束每个 casedefault 部分,使得 switch 语句停止执行。通常使用 break 语句,但也可以使用 returnthrow 语句。

由于默认的 fall-through 特性,一个 switch 语句可以有多个标记相同语句的 case 子句。考虑下面方法中的 switch 语句:

boolean parseYesOrNoResponse(char response) {
   switch(response) {
     case 'y':
     case 'Y': return true;
     case 'n':
     case 'N': return false;
     default:
       throw new IllegalArgumentException("Response must be Y or N");
   }
}

switch 语句及其 case 标签有一些重要限制。首先,与 switch 语句关联的表达式必须具有适当的类型 —— bytecharshortint(或它们的包装类)、枚举类型或 String。不支持浮点型和 boolean 类型,即使 long 是整数类型也不支持。其次,与每个 case 标签关联的值必须是编译器可以评估的常量值或常量表达式。例如,case 标签不能包含涉及变量或方法调用的运行时表达式。第三,case 标签的值必须在用于 switch 表达式的数据类型的范围内。最后,不能有两个或更多具有相同值的 case 标签或一个以上的 default 标签是不合法的。

考虑到所有这些注意事项,让我们看看新的 switch 表达式如何提供更清晰的体验。

switch 表达式

经典 switch 语句的一个常见问题是捕获变量值时产生的问题。

Boolean yesOrNo = null;
switch(input) {
    case "y":
    case "Y":
        yesOrNo = true;
        break;
    case "n":
    case "N":
        yesOrNo = false;
        break;
    default:
        throw new IllegalArgumentException("Response must be Y or N");
}

变量在 switch 后仍然可用,必须在语句外声明并赋予初始值。然后,每个 case 必须确保设置变量。但是,我们没有保证,在比这个简单示例更多分支的代码中,很容易忽略并引入错误。

switch 表达式明确设计用于解决这些及其他缺陷。正如其名称所示,它是一个 表达式 —— 是语言中语法上较复杂的表达式之一,并因此产生一个值。

boolean yesOrNo = switch(input) {
    case "y" -> true;
    case "Y" -> true;
    case "N" -> false;
    case "n" -> false;
    default -> throw new IllegalArgumentException("Y or N");
};

就像 switch 语句一样,每个 case 在此处评估输入与其值。在 -> 之后,您提供整个 switch 表达式的结果值。在此示例中,我们将其分配给我们的变量 yesOrNo,这不再需要是可空包装类型。

我们这里编写的代码隐藏了switch表达式为我们提供的保护之一。如果我们移除default子句,编译器会报错,因为表达式不能始终完全评估。

boolean yesOrNo = switch(input) {
    case "y" -> true;
    case "Y" -> true;
    case "N" -> false;
    case "n" -> false;
};

// Compiler error:
//   the switch expression does not cover all possible input values

Switch 表达式不像语句形式那样会掉落到下一个case。为了支持多个值评估为相同结果,每个case可以接受逗号分隔的值列表,而不仅仅是单个值。

boolean yesOrNo = switch(input) {
    case "y", "Y" -> true;
    case "n", "N" -> false;
    default -> throw new IllegalArgumentException("Y or N");
};

我们的期望结果并非总是可以表示为单个值或方法调用。为了支持这一点,花括号可以引入一个语句。但是,该语句必须以yield结束以退出带有值的switch,或者使用return离开整个封闭方法。

boolean yesOrNo = switch(input) {
    case "y", "Y" -> { System.out.println("Got it"); yield true; }
    case "n", "N" -> { System.out.println("Nope"); yield false; }
    default -> throw new IllegalArgumentException("Y or N");
};

实际上,如果我们不使用switch表达式的结果,甚至可以仅用于副作用的语法,具有改进的分支检查和安全性。

switch(input) {
    case "y", "Y" -> System.out.println("Sure");
    case "n", "N" -> System.out.println("Nope");
    default -> throw new IllegalArgumentException("Y or N");
}

while语句

while语句是一个基本语句,允许 Java 执行重复的操作——换句话说,它是 Java 的主要循环结构之一。它的语法如下:

while (*`expression`*)
  *`statement`*

while语句首先评估expression,该表达式必须返回booleanBoolean值。如果值为false,解释器跳过循环关联的statement,并移动到程序的下一条语句。然而,如果值为true,则执行循环体形成的statement,并重新评估expression。再次,如果expression的值为false,解释器继续执行程序的下一条语句;否则,它再次执行statement。这个循环在expression保持true(即直到它评估为false)时继续,此时while语句结束,解释器继续执行程序的下一条语句。您可以使用语法while(true)创建无限循环。

这里是一个打印数字 0 到 9 的示例while循环:

int count = 0;
while (count < 10) {
   System.out.println(count);
   count++;
}

正如您所看到的,变量count在这个示例中从 0 开始,并且每次循环体运行时都会递增。一旦循环执行了 10 次,表达式变为false(即count不再小于 10),while语句结束,Java 解释器可以转到程序中的下一条语句。大多数循环都有像count这样的计数器变量。变量名ijk通常用作循环计数器,尽管如果使您的代码更易于理解,您应该使用更具描述性的名称。

do语句

do循环与while循环非常相似,不同之处在于循环表达式在循环体底部测试,而不是在顶部测试。这意味着循环体至少会执行一次。语法如下:

do
   *`statement`*
while (*`expression`*);

注意do循环与更普通的while循环之间的一些区别。首先,do循环需要do关键字标记循环的开始和while关键字标记结束并引入循环条件。此外,与while循环不同,do循环以分号结束。这是因为do循环以循环条件结束,而不仅仅是以标记循环体结束的花括号结束。以下的do循环打印了与前述while循环相同的输出:

int count = 0;
do {
   System.out.println(count);
   count++;
} while(count < 10);

do循环比它的while表兄弟要少见得多,因为实际上很少遇到您确信总是希望至少执行一次循环的情况。

for语句

for语句提供了一个循环结构,通常比whiledo循环更方便。for语句利用了一种常见的循环模式。大多数循环都有一个计数器或某种状态变量,在循环开始前初始化,在测试后确定是否执行循环体,并在循环体结束前以某种方式递增或更新。initializetestupdate步骤是循环变量的三个关键操作,而for语句使这三个步骤成为循环语法的显式部分:

for(*`initialize`*; *`test`*; *`update`*) {
    *`statement`*
}

这个for循环基本上等同于以下的while循环:

*`initialize`*;
while (*`test`*) {
   *`statement`*;
   *`update`*;
}

initializetestupdate表达式放在for循环的顶部使得理解循环正在做什么特别容易,并且防止像忘记初始化或更新循环变量这样的错误。解释器会丢弃initializeupdate表达式的值,因此为了有用,这些表达式必须具有副作用。initialize通常是赋值表达式,而update通常是增量、减量或其他一些赋值。

下面的for循环打印出 0 到 9 的数字,就像之前的whiledo循环所做的那样:

int count;
for(count = 0 ; count < 10 ; count++)
   System.out.println(count);

注意这种语法如何将关于循环变量的所有重要信息放在一行上,使得循环执行过程非常清晰。将update表达式放在for语句本身中也简化了循环体到一个单一语句;我们甚至不需要使用花括号来生成语句块。

for循环支持一些额外的语法,使其更加方便使用。因为许多循环仅在循环内部使用它们的循环变量,所以for循环允许initialize表达式是一个完整的变量声明,因此该变量的作用域仅限于循环体内部,在外部不可见。例如:

for(int count = 0 ; count < 10 ; count++)
   System.out.println(count);

此外,for循环的语法不限于只写使用单个变量的循环。for循环的initializeupdate表达式都可以使用逗号来分隔多个初始化和更新表达式。例如:

for(int i = 0, j = 10 ; i < 10 ; i++, j--)
     sum += i * j;

尽管到目前为止所有的示例都是计数数字,但for循环并不局限于计数数字的循环。例如,你可以使用for循环来遍历链表的元素:

for(Node n = listHead; n != null; n = n.nextNode())
   process(n);

for循环的initializetestupdate表达式都是可选的;只有分号用于分隔表达式是必需的。如果省略了test表达式,则假定为true。因此,你可以将一个无限循环写成for(;;)

foreach 语句

Java 的for循环适用于原始类型,但对处理对象集合来说过于笨拙。相反,另一种称为foreach循环的替代语法用于处理需要遍历的对象集合。

foreach 循环使用关键字for后跟一个开括号,一个变量声明(没有初始化器),一个冒号,一个表达式,一个闭括号,最后是组成循环体的语句(或块):

for( *`declaration`* : *`expression`* )
     *`statement`*

尽管其名称如此,foreach 循环并没有关键字foreach——相反,通常将冒号读作“in”——例如“foreach name in studentNames.”。

对于whiledofor循环,我们展示了一个打印 10 个数字的例子。foreach 循环也可以做到,但它需要一个要遍历的集合。为了循环 10 次(打印出 10 个数字),我们需要一个包含 10 个元素的数组或其他集合。下面是我们可以使用的代码:

// These are the numbers we want to print
int[] primes = new int[] { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 };
// This is the loop that prints them
for(int n : primes)
     System.out.println(n);

foreach 无法做到的事情

foreach 与whilefordo循环不同,因为它隐藏了循环计数器或Iterator。这是一个非常强大的想法,当我们讨论 lambda 表达式时会看到,但是有些算法用 foreach 循环表达起来并不自然。

例如,假设你想将数组的元素打印为逗号分隔的列表。为此,你需要在数组的每个元素后打印一个逗号,除了最后一个元素之外,或者等效地,在数组的每个元素之前打印一个逗号。使用传统的for循环,代码可能如下所示:

for(int i = 0; i < words.length; i++) {
     if (i > 0) System.out.print(", ");
     System.out.print(words[i]);
}

这是一个非常直接的任务,但你在不保留额外状态的情况下简单地无法使用 foreach 完成。问题在于 foreach 循环不提供循环计数器或其他方式来告诉你是第一次迭代、最后一次迭代还是中间某个迭代。

注意

当你使用 foreach 来遍历集合元素时,存在类似的问题。正如在数组上进行 foreach 循环时无法获取当前元素的数组索引一样,在集合上进行 foreach 循环时也无法获取正在用于枚举集合元素的Iterator对象。

这里有一些你不能在 foreach 风格循环中做的事情:

  • 反向迭代数组或 List 的元素。

  • 使用单个循环计数器访问两个不同数组的相同编号元素。

  • 使用调用其 get() 方法而不是调用其迭代器来遍历 List 的元素。

break 语句

break 语句会导致 Java 解释器立即跳过包含语句的末尾。我们已经看到 break 语句与 switch 语句一起使用。break 语句通常简单地写为关键字 break 后跟一个分号:

break;

在这种形式下,它会导致 Java 解释器立即退出最内层的包含的 whiledoforswitch 语句。例如:

for(int i = 0; i < data.length; i++) {
    if (data[i] == target) {  // When we find what we're looking for,
        index = i;              // remember where we found it
        break;                  // and stop looking!
    }
}   // The Java interpreter goes here after executing break

break 语句也可以跟随包含的标记语句的名称。在这种形式下,break 会导致 Java 解释器立即退出命名的块,该块可以是任何类型的语句,而不仅仅是循环或 switch。例如:

TESTFORNULL: if (data != null) {
   for(int row = 0; row < numrows; row++) {
     for(int col = 0; col < numcols; col++) {
       if (data[row][col] == null)
         break TESTFORNULL;           // treat the array as undefined.
     }
   }
}  // Java interpreter goes here after executing break TESTFORNULL

continue 语句

虽然 break 语句退出循环,但 continue 语句结束当前循环迭代并开始下一个迭代。continue 在其未标记和标记形式中,只能在 whiledofor 循环内使用。未带标记时,continue 导致最内层循环开始新的迭代。当使用包含循环名称的标签时,它导致命名循环开始新的迭代。例如:

for(int i = 0; i < data.length; i++) {  // Loop through data.
   if (data[i] == -1)                   // If a data value is missing,
     continue;                          // skip to the next iteration.
   process(data[i]);                    // Process the data value.
}

whiledofor 循环在 continue 启动新迭代的方式上略有不同:

  • 对于 while 循环,Java 解释器简单地返回到循环的顶部,重新测试循环条件,如果评估为 true,则再次执行循环体。

  • 对于 do 循环,解释器跳到循环的底部,检查循环条件以决定是否执行循环的另一个迭代。

  • 对于 for 循环,解释器跳到循环的顶部,首先评估 update 表达式,然后评估 test 表达式以决定是否再次循环。从这些例子中可以看出,带有 continue 语句的 for 循环的行为与之前介绍的“基本等效”的 while 循环的行为不同;updatefor 循环中被评估,但在等效的 while 循环中却不是。

return 语句

return 语句告诉 Java 解释器停止执行当前方法。如果方法声明要返回一个值,则 return 语句后必须跟表达式。表达式的值成为方法的返回值。例如,以下方法计算并返回一个数字的平方:

double square(double x) {      // A method to compute x squared
   return x * x;               // Compute and return a value
}

有些方法声明为void,表示它们不返回任何值。Java 解释器通过逐一执行它们的语句直到方法结束来运行这些方法。执行完最后一个语句后,解释器会隐式返回。然而,有时void方法必须在到达最后一个语句之前显式返回。在这种情况下,它可以使用return语句本身,不带任何表达式。例如,以下方法打印但不返回其参数的平方根。如果参数是负数,则打印之前返回而不打印任何内容:

// A method to print square root of x
void printSquareRoot(double x) {
   if (x < 0) return;                // If x is negative, return
   System.out.println(Math.sqrt(x)); // Print the square root of x
}                                    // Method end: return implicitly

同步语句

Java 一直为多线程编程提供支持。我们稍后会详细介绍这一点(特别是在“Java 对并发的支持”中);然而,请注意,编写正确的并发代码很难且有许多微妙之处。

特别是在处理多个线程时,通常需要注意防止多个线程同时修改对象的方式,这可能会破坏对象的状态。Java 提供了synchronized语句来帮助程序员防止这种破坏。语法是:

synchronized ( *`expression`* ) {
   *`statements`*
}

expression 是必须求值为对象(包括数组)的表达式。statements 构成可能会造成损害的部分的代码,并且必须用大括号括起来。

注意

在 Java 中,对象状态(即数据)的保护是并发原语的主要关注点。这与其他一些语言不同,其他语言更关注对临界区(即代码)的排除。

在执行语句块之前,Java 解释器首先获取由expression指定的对象或数组的独占锁。它在运行完块后释放锁。当一个线程持有对象的锁时,其他线程无法获取该锁。

除了块形式外,synchronized在 Java 中还可以作为方法修饰符使用。当应用于方法时,该关键字表示整个方法被视为synchronized

对于synchronized实例方法,Java 获取类实例的独占锁。(类方法和实例方法在第三章中有详细讨论。)它可以被视为覆盖整个方法的synchronized (this) { ... }块。

static synchronized 方法(类方法)会导致 Java 在执行该方法之前获取类(技术上对应于类型的类对象)的独占锁。

抛出语句

异常 是指示发生了某种异常条件或错误的信号。抛出异常是为了信号异常条件。捕获异常是为了处理它,采取必要的措施以从中恢复。

在 Java 中,throw语句用于抛出异常:

throw *`expression`*;

expression 必须评估为描述发生的异常或错误的异常对象。稍后我们将更详细地讨论异常的类型;现在你只需要知道,异常:

  • 由对象表示

  • 其类型是 Exception 的子类

  • 在 Java 语法中具有稍微专门化的角色

  • 可以是两种不同类型:checkedunchecked

下面是一个抛出异常的示例代码:

public static double factorial(int x) {
   if (x < 0)
     throw new IllegalArgumentException("x must be >= 0");
   double fact;
   for(fact=1.0; x > 1; fact *= x, x--)
     /* empty */ ;          // Note use of the empty statement
   return fact;
}

当 Java 解释器执行 throw 语句时,它立即停止正常的程序执行,并开始寻找能够捕获或处理异常的异常处理程序。异常处理程序使用 try/catch/finally 语句编写,下一节将对此进行描述。Java 解释器首先查看包含代码块,看看是否有相关联的异常处理程序。如果有,则退出该代码块,并开始运行与该代码块相关联的异常处理代码。运行完异常处理程序后,解释器继续执行跟在处理程序代码之后的语句。

如果包含代码块没有适当的异常处理程序,则解释器检查方法中的下一个更高的包含代码块。这将继续,直到找到处理程序。如果方法中没有可以处理 throw 语句抛出的异常的异常处理程序,则解释器停止运行当前方法,并返回给调用者。现在解释器开始在调用方法的代码块中寻找异常处理程序。通过这种方式,异常沿着 Java 方法的词法结构向上传播,沿着 Java 解释器的调用堆栈向上传播。如果异常从未被捕获,则它一直传播到程序的 main() 方法。如果在该方法中未处理它,则 Java 解释器打印错误消息,打印堆栈跟踪以指示异常发生的位置,然后退出。

try/catch/finally 语句

Java 有两种略有不同的异常处理机制。经典形式是 try/catch/finally 语句。此语句的 try 子句建立了一个用于异常处理的代码块。这个 try 块后面是零个或多个 catch 子句,每个 catch 子句是一组语句块,用于处理特定的异常。每个 catch 块可以处理多个不同的异常—为了指示一个 catch 块应该处理多个异常,我们使用 | 符号分隔不同的异常。catch 子句后面是一个可选的 finally 块,其中包含保证执行的清理代码,无论 try 块中发生了什么。

以下代码说明了 try/catch/finally 语句的语法和目的:

try {
   // Normally this code runs from the top of the block to the bottom
   // without problems. But it can sometimes throw an exception,
   // either directly with a throw statement or indirectly by calling
   // a method that throws an exception.
}
catch (SomeException e1) {
   // This block contains statements that handle an exception object
   // of type SomeException or a subclass of that type. Statements in
   // this block can refer to that exception object by the name e1.
}
catch (AnotherException | YetAnotherException e2) {
   // This block contains statements that handle an exception of
   // type AnotherException or YetAnotherException, or a subclass of
   // either of those types. Statements in this block refer to the
   // exception object they receive by the name e2.
}
finally {
   // This block contains statements that are always executed
   // after we leave the try clause, regardless of whether we leave it:
   //   1) normally, after reaching the bottom of the block;
   //   2) because of a break, continue, or return statement;
   //   3) with an exception that is handled by a catch clause above;
   //   4) with an uncaught exception that has not been handled.
   // If the try clause calls System.exit(), however, the interpreter
   // exits before the finally clause can be run.
}

try

try子句简单地建立一个代码块,要么处理其异常,要么在任何情况下终止时运行特殊的清理代码。try子句本身不执行任何有趣的操作;是catchfinally子句执行异常处理和清理操作。

捕获

一个try块后面可以跟随零个或多个catch子句,这些子句指定处理各种类型异常的代码。每个catch子句声明一个参数,指定该子句可以处理的异常类型(可能使用特殊的|语法来表示catch块可以处理多种类型的异常),并且为该子句提供一个名称,用于引用它当前正在处理的异常对象。catch块希望处理的任何类型必须是Throwable的某个子类。

当抛出异常时,Java 解释器会查找一个带有与异常对象类型相匹配或超类匹配的catch子句。解释器调用它找到的第一个这样的catch子句。catch块内的代码应采取必要的操作来处理异常情况。例如,如果异常是java.io.FileNotFoundException,你可以通过要求用户检查拼写并重试来处理它。

并非每个可能的异常都需要有一个catch子句;在某些情况下,正确的响应是允许异常传播并被调用方法捕获。在其他情况下,比如由NullPointerException信号的编程错误,正确的响应可能并不是捕获异常,而是允许其传播,并让 Java 解释器输出堆栈跟踪和错误消息。

最终

finally子句通常用于在try子句中的代码之后进行清理(例如关闭文件和关闭网络连接)。finally子句非常有用,因为它保证在执行try块的任何部分之后执行,无论try块中的代码如何完成。实际上,try子句退出而不允许执行finally子句的唯一方法是调用System.exit()方法,这将导致 Java 解释器停止运行。

在正常情况下,控制流程到达try块的末尾,然后继续执行finally块,执行任何必要的清理工作。如果控制流程因为returncontinuebreak语句离开try块,那么在转移到新目的地之前将执行finally块。

如果在try块中发生异常,并且有相关的catch块来处理异常,则控制首先转移到catch块,然后再到finally块。如果没有局部catch块来处理异常,则控制首先转移到finally块,然后传播到最近的能处理异常的包含catch子句。

如果finally块本身使用returncontinuebreakthrow语句转移控制,或通过调用抛出异常的方法,挂起的控制转移将被放弃,并处理这种新的转移。例如,如果finally子句抛出异常,则该异常将替换正在被抛出的任何异常。如果finally子句发出return语句,则方法会正常返回,即使已经抛出异常且尚未处理。

tryfinally可以一起使用,不带异常或任何catch子句。在这种情况下,finally块只是保证执行的清理代码,无论try子句中有任何breakcontinuereturn语句。

尝试使用资源语句

try块的标准形式非常通用,但在编写catchfinally块时,需要开发人员在操作需要在不再需要时清理或关闭的资源时特别小心。

Java 提供了一种非常有用的机制来自动关闭需要清理的资源。这就是try-with-resources(TWR),或称为尝试资源。我们在“经典 Java I/O”中详细讨论了 TWR,但为了完整起见,让我们现在介绍一下语法。以下示例展示了如何使用FileInputStream类打开文件(这会生成一个需要清理的对象):

try (InputStream is = new FileInputStream("/Users/ben/details.txt")) {
  // ... process the file
}

这种新形式的try接受的参数都是需要清理的对象,²这些对象的作用域限于此try块,然后无论如何退出此块,它们都会自动清理。开发人员不需要编写任何catchfinally块——Java 编译器会自动插入正确的清理代码。

所有涉及资源的新代码都应以 TWR 风格编写——这比手动编写catch块容易出错得多,并且不会遭受像最终化技术(详见“终结”)那样的问题。

断言语句

assert语句是在 Java 代码中验证设计假设的一种尝试。断言assert关键字后跟程序员认为应始终评估为true的布尔表达式组成。默认情况下,断言是禁用的,而assert语句实际上不执行任何操作。

可以将断言作为调试工具启用,但是当这样做时,assert 语句会评估表达式。如果它确实是 trueassert 不会执行任何操作。另一方面,如果表达式评估为 false,断言将失败,并且 assert 语句将抛出一个 java.lang.AssertionError

提示

除了核心的 JDK 库之外,assert 语句几乎极少被使用。事实证明,它对于测试大多数应用程序来说过于不灵活,并且普通开发人员很少使用它。相反,开发者使用普通的测试库,比如 JUnit。

assert 语句可以包含一个可选的第二表达式,用冒号与第一个表达式分隔。当启用断言并且第一个表达式评估为 false 时,第二个表达式的值将作为错误代码或错误消息,并传递给 AssertionError() 构造函数。语句的完整语法是:

assert *`assertion`*;

或者:

assert *`assertion`* : *`errorcode`*;

要有效地使用断言,您还必须了解一些细节。首先,请记住,您的程序通常会禁用断言并且仅在某些时候启用断言。这意味着您应该小心,不要编写包含副作用的断言表达式。

警告

您不应该从自己的代码中抛出 AssertionError,因为它可能会在平台的将来版本中产生意外的结果。

如果抛出 AssertionError,则表明程序员的某些假设不成立。这意味着代码正在超出其设计的参数范围,并且不能期望其能正常工作。简而言之,没有合理的方式可以从 AssertionError 中恢复,您不应尝试捕获它(除非您仅在顶层捕获它,以便以更用户友好的方式显示错误)。

启用断言

为了效率,每次执行代码时测试断言是没有意义的 — assert 语句编码了应始终为真的假设。因此,默认情况下禁用断言,assert 语句没有任何效果。但是,断言代码仍然编译在类文件中,因此始终可以为诊断或调试目的启用它们。您可以使用 Java 解释器的命令行参数启用断言,要么全面启用,要么选择性启用。

要在所有类中除系统类外启用断言,请使用 -ea 参数。要在系统类中启用断言,请使用 -esa。要在特定类内启用断言,请使用 -ea 后跟一个冒号和类名:

java -ea:com.example.sorters.MergeSort com.example.sorters.Test

要为包及其所有子包中的所有类启用断言,请在 -ea 参数后跟一个冒号,包名称和三个点:

java -ea:com.example.sorters... com.example.sorters.Test

您可以以相同的方式禁用断言,使用 -da 参数。例如,要在一个包中全面启用断言,然后在特定类或子包中禁用它们,请使用:

java -ea:com.example.sorters... -da:com.example.sorters.QuickSort
java -ea:com.example.sorters... -da:com.example.sorters.plugins..

最后,可以控制是否在类加载时启用或禁用断言。如果你在程序中使用自定义类加载器(有关自定义类加载的详细信息,请参见第十一章)并想启用断言,可能会对这些方法感兴趣。

方法

方法是一个由 Java 语句组成的命名序列,可以被其他 Java 代码调用。当调用一个方法时,会传递零个或多个值,称为参数。该方法执行一些计算,且可选择地返回一个值。正如前面在“表达式与运算符”中描述的,方法调用是一个由 Java 解释器评估的表达式。然而,由于方法调用可能有副作用,它们也可以用作表达式语句。本节不讨论方法调用,而是描述如何定义方法。

定义方法

你已经知道如何定义方法的主体;它只是一个任意的语句序列,括在花括号内。方法更有趣的是它的签名。(3)。签名指定:

  • 方法的名称

  • 方法使用的参数的数量、顺序、类型和名称

  • 方法返回值的类型

  • 方法可能抛出的已检查异常(签名也可以列出未检查异常,但不是必需的)

  • 提供关于方法的额外信息的各种方法修饰符

方法签名定义了在调用方法之前你需要知道的所有信息。它是方法规范,定义了方法的 API。要使用 Java 平台的在线 API 参考,你需要知道如何阅读方法签名。而且,编写 Java 程序时,你需要知道如何定义你自己的方法,每个方法都以方法签名开始。

方法签名如下所示:

*`modifiers` `type` `name`* (*`paramlist`*) [ throws exceptions ]

方法签名(方法规范)后是方法体(方法实现),它只是一个由 Java 语句组成的序列,括在花括号内。如果方法是抽象的(参见第三章),则省略实现,方法体用一个分号替换。

方法的签名也可能包括类型变量声明——此类方法被称为泛型方法。泛型方法和类型变量在第四章中讨论。

下面是一些示例方法定义,方法签名后面是方法体:

// This method is passed an array of strings and has no return value.
// All Java programs have an entry point with this name and signature.
public static void main(String[] args) {
     if (args.length > 0) System.out.println("Hello " + args[0]);
     else System.out.println("Hello world");
}

// This method is passed two double arguments and returns a double.
static double distanceFromOrigin(double x, double y) {
     return Math.sqrt(x*x + y*y);
}

// This method is abstract which means it has no body.
// Note that it may throw exceptions when invoked.
protected abstract String readText(File f, String encoding)
    throws FileNotFoundException, UnsupportedEncodingException;

修饰符是零个或多个特殊的修饰符关键字,由空格分隔。例如,一个方法可能使用publicstatic修饰符声明。允许的修饰符及其含义在下一节中描述。

方法签名中的type指定方法的返回类型。如果方法不返回值,type必须是void。如果方法声明具有非void返回类型,则必须包含一个返回语句,该语句返回声明类型的值(或可转换为其的值)。

构造函数是一段代码块,类似于方法,用于初始化新创建的对象。正如我们将在第三章中看到的,构造函数的定义方式与方法非常相似,只是它们的签名不包括这个type规范,并且必须与类名相同。

方法的name遵循其修饰符和类型的规范。方法名像变量名一样是 Java 标识符,并且像所有 Java 标识符一样,可以包含由 Unicode 字符集表示的任何语言中的字母。定义具有相同名称的多个方法通常是合法且非常有用的,只要每个方法的版本具有不同的参数列表。定义具有相同名称的多个方法称为方法重载

提示

不同于其他一些语言,Java 没有匿名方法。相反,Java 8 引入了 lambda 表达式,它们类似于匿名方法,但 Java 运行时会自动将它们转换为适当命名的方法——详见“Lambda Expressions”了解更多细节。

例如,我们已经见过的System.out.println()方法是一个重载方法。同名的一个方法打印一个字符串,同名的其他方法打印各种基本类型的值。Java 编译器根据传递给方法的参数类型决定调用哪个方法。

当你定义一个方法时,方法名后总是跟着方法的参数列表,参数列表必须用括号括起来。参数列表定义了零个或多个传递给方法的参数。如果有参数规范,每个规范包括类型和名称,并且规范之间用逗号分隔(如果有多个参数)。当调用一个方法时,传递给它的参数值必须与该方法签名行中指定的参数的数量、类型和顺序匹配。传递的值不需要与签名中指定的类型完全相同,但必须可以在不进行强制转换的情况下转换为这些类型。

注意

当一个 Java 方法不希望有参数时,其参数列表仅为(),而不是(void)。Java 不将void视为一种类型——特别是 C 和 C++ 程序员应该注意。

Java 允许程序员定义和调用接受可变数量参数的方法,使用一种俗称为varargs的语法。有关 varargs 的详细信息稍后在本章中讨论。

方法签名的最后一部分是throws子句,用于列出方法可以抛出的受检异常。受检异常是一类必须在方法的throws子句中列出的异常类。

如果一个方法使用throw语句抛出一个受检异常,那么该方法必须声明它可以抛出该异常。在调用某个抛出受检异常的其他方法且调用方法没有明确捕获该异常的情况下,方法必须声明它可以抛出异常。

如果一个方法可能抛出一个或多个受检异常,它会通过在参数列表之后放置throws关键字并跟随异常类的名称来指定这一点。如果一个方法不会抛出任何受检异常,则不使用throws关键字。如果一个方法抛出多种类型的受检异常,使用逗号将异常类的名称分开。稍后详细介绍。

方法修饰符

方法的修饰符由零个或多个修饰符关键字组成,例如publicstaticabstract。以下是允许的修饰符及其含义列表:

abstract

abstract方法是没有实现的规范。方法的大括号和 Java 语句通常组成方法体的部分被替换为一个分号。包含abstract方法的类本身必须声明为abstract。这样的类是不完整的,不能被实例化(参见第三章)。

default

default方法只能在接口上定义。实现接口的所有类都会接收默认方法,除非它们直接覆盖它。在第三章中详细探讨了在类中实现接口。

final

final方法不能被子类重写或隐藏,这使得它适合进行编译器优化,这对于普通方法来说是不可能的。所有private方法都隐式地是final的,同样,所有声明为final的类的方法也是final的。

native

native修饰符指定方法实现是用某些“本地”语言编写的,比如 C,并且是外部提供给 Java 程序的。与abstract方法类似,native方法没有方法体:大括号被分号替代。

public, protected, private

这些访问修饰符指定方法是否以及在哪里可以在定义它的类的外部使用。这些非常重要的修饰符在第三章中有详细解释。

static

声明为static的方法是与类本身关联的类方法,而不是与类的实例关联的(我们在第三章中详细讨论这一点)。

strictfp

这个笨拙命名、很少使用的修饰符中的fp代表“浮点”。出于性能原因,在 Java 1.2 中,当使用某些浮点加速硬件时,语言允许对严格的 IEEE-754 标准进行微小的偏离。添加了strictfp关键字以强制 Java 严格遵守该标准。这些硬件考虑多年来已不再相关,因此 Java 17 将默认返回 IEEE 标准。使用strictfp关键字将会发出警告,因为它已不再必要。

synchronized

synchronized修饰符使方法具有线程安全性。在线程调用synchronized方法之前,它必须获取方法类(对于静态方法)或类的相关实例(对于非静态方法)的锁定。这可以防止两个线程同时执行该方法。

synchronized修饰符是一个实现细节(因为方法可以以其他方式使自己线程安全),并不是方法规范或 API 的正式部分。良好的文档明确指定方法是否线程安全;在处理多线程程序时,不应依赖于synchronized关键字的存在或缺失。

提示

注解是一个有趣的特例(详见第四章关于注解的更多内容)——它们可以被看作是方法修饰符和额外补充类型信息之间的一种中间形式。

Checked 和 Unchecked Exceptions

Java 的异常处理方案区分为两种类型的异常,称为checkedunchecked异常。

区分 checked 和 unchecked 异常与异常可能被抛出的情况有关。Checked 异常发生在特定而明确定义的情况下,并且应用程序可能能够部分或完全恢复。

例如,考虑一些可能在几个可能的目录中找到其配置文件的代码。如果尝试从不存在的目录中打开文件,则会抛出FileNotFoundException。在我们的例子中,我们希望捕获此异常并继续尝试文件的下一个可能位置。换句话说,虽然文件不存在是一个异常情况,但这是一个我们可以恢复的情况,并且是一种可以理解和预见的失败。

另一方面,在 Java 环境中,存在一组无法轻易预测或预见的失败,原因可能是运行时条件或滥用库代码。例如,无法有效预测OutOfMemoryError,而且任何使用对象或数组的方法,如果传递了无效的null参数,都可能抛出NullPointerException

这些是未检查的异常——基本上任何方法都可能在任何时候抛出未检查的异常。它们是 Java 环境版本的墨菲定律:“任何可能出错的事情,最终都会出错。” 由于它们的完全不可预测性,从未检查的异常中恢复通常非常困难,甚至是不可能的。

要确定异常是已检查还是未检查的,记住异常是 Throwable 对象,这些对象分为两大类,由 ErrorException 子类指定。任何 Error 类型的异常对象都是未检查的。还有一个名为 RuntimeExceptionException 子类——任何 RuntimeException 子类也都是未检查的异常。所有其他异常都是已检查的异常。

处理已检查的异常

Java 对处理已检查和未检查的异常有不同的规则。如果你编写一个会抛出已检查异常的方法,你必须在方法签名中使用 throws 子句来声明异常。Java 编译器会检查你是否在方法签名中声明了它们,如果没有声明就会产生编译错误(这就是它们被称为“已检查的异常”的原因)。

即使你自己从不抛出已检查的异常,有时你也必须使用 throws 子句来声明已检查的异常。如果你的方法调用了一个可能抛出已检查异常的方法,你必须要么包含处理异常的代码来处理该异常,要么使用 throws 来声明你的方法也可以抛出该异常。

例如,以下方法尝试估算网页的大小——它使用标准的 java.net 库和类 URL(我们将在第十章中了解到这些内容)来联系网页。它使用可能抛出各种类型的 java.io.IOException 对象的方法和构造函数,因此它使用 throws 子句声明了这一事实:

public static estimateHomepageSize(String host) throws IOException {
    URL url = new URL("htp://"+ host +"/");
    try (InputStream in = url.openStream()) {
        return in.available();
    }
}

实际上,前面的代码有一个错误:我们拼错了协议说明符——htp:// 并不存在这样的协议。所以,estimateHomepageSize() 方法将始终失败并抛出 MalformedURLException

你怎么知道你正在调用的方法是否会抛出已检查的异常?你可以查看它的方法签名来找出。或者,如果你调用了必须处理或声明异常的方法,Java 编译器会告诉你(通过报告编译错误)。

可变长度的参数列表

方法可以声明接受和被调用时传递可变数量的参数。这样的方法通常称为 varargs 方法。 “print formatted” 方法 System.out.printf() 以及相关的 Stringformat() 方法使用 varargs,java.lang.reflect 的 Reflection API 中的一些重要方法也是如此。

要声明一个可变长度的参数列表,请在方法的最后一个参数的类型后面跟着省略号(...),表示这个最后一个参数可以重复零次或更多次。例如:

public static int max(int first, int... rest) {
    /* body omitted for now */
}

可变参数方法由编译器纯粹处理。它们通过将可变数量的参数转换为数组来运作。对于 Java 运行时而言,max()方法与此方法无异:

public static int max(int first, int[] rest) {
    /* body omitted for now */
}

要将可变参数签名转换为“真实”签名,只需将...替换为[ ]。请记住,参数列表中只能出现一个省略号,并且它只能出现在列表中的最后一个参数上。

让我们稍微详细说明一下max()的示例:

public static int max(int first, int... rest) {
    int max = first;
    for(int i : rest) { // legal because rest is actually an array
        if (i > max) max = i;
    }
    return max;
}

这个max()方法声明了两个参数。第一个参数是一个普通的int值。然而,第二个参数可以重复零次或多次。以下所有调用max()方法的方式都是合法的:

max(0)
max(1, 2)
max(16, 8, 4, 2, 1)

因为可变参数方法被编译为期望一个参数数组的方法,调用这些方法被编译为包括创建和初始化这样一个数组的代码。因此,调用max(1,2,3)被编译为这样:

max(1, new int[] { 2, 3 })

实际上,如果您已经将方法参数存储在数组中,您可以合法地以这种方式将它们传递给方法,而不是逐个写出它们。您可以将任何...参数视为已声明为数组。然而,反之则不成立:只有当方法实际上使用省略号声明为可变参数方法时,您才能使用可变参数方法调用语法。

类和对象介绍

现在我们已经介绍了操作符、表达式、语句和方法,我们终于可以讨论类了。是一个命名的字段集合,其中包含存储数据值和操作这些值的方法。类只是 Java 支持的五种引用类型之一,但它们是最重要的类型。类在单独的章节中有详细的文档说明(第三章)。然而,我们在这里介绍它们,是因为它们是方法之后的下一个更高级别的语法,以及本章的其余部分需要对类的概念有基本的熟悉,以及定义类、实例化类和使用生成的对象的基本语法。

类最重要的一点是它们定义了新的数据类型。例如,您可以定义一个名为Account的类来表示一个持有余额的银行账户。该类将定义字段来存储数据项,如余额(可能表示为double)、账户持有人的姓名和地址(作为String实例),以及操作账户的方法。Account类就是一个新的数据类型。

在讨论数据类型时,区分数据类型本身和数据类型表示的值是很重要的。char是一个数据类型:它表示 Unicode 字符。但是char值表示一个具体的字符。类是一种数据类型;一个类的值称为对象。我们使用类名是因为每个类定义了一种对象的类型(或种类、类别、类)。Account类是一个表示银行账户的数据类型,而Account对象表示一个具体的账户。正如你可以想象的那样,类和它们的对象是紧密联系的。接下来的章节中,我们将讨论这两者。

定义一个类

这里是我们讨论过的Account类的一个可能定义:

/** Represents a customer bank account */
public class Account {
     public String name;
     public double balance;
     public int accountId;

     // A constructor that initializes the fields
     public Account(String name, double openingBalance, int id) {
         this.name = name;
         this.balance = openingBalance;
         this.accountId = id;
     }
}

此类定义存储在名为Account.java的文件中,并编译为名为Account.class的文件,可供 Java 程序和其他类使用。这里提供类定义是为了完整性和提供上下文,但不要期望立即理解所有细节;第三章大部分内容专注于类定义的主题。

请记住,在 Java 程序中,你不必定义每个想要使用的类。Java 平台包含成千上万的预定义类,保证在运行给定版本 Java 的每台计算机上都可用。

创建对象

现在我们已经将Account类定义为一个新的数据类型,我们可以使用以下行来声明一个变量,以保存Account对象:

Account a;

声明一个变量来持有Account对象并不会创建对象本身。要实际创建一个对象,你必须使用new操作符。这个关键字后面跟着对象的类(即其类型)和一个可选的参数列表在括号中。这些参数被传递给类的构造函数,构造函数初始化新对象的内部字段:

// Declare variable a and store a reference to new Account object
Account a = new Account("Jason Clark", 0.0, 42);

// Create some other objects as well
// An object that represents the current time
LocalDateTime d = new LocalDateTime();

// A HashSet object to hold a set of strings
Set<String> words = new HashSet<>();

在 Java 中,new关键字是创建对象最常见的方式。还有几种方式也值得一提。首先,符合特定标准的类非常重要,Java 为这些类型的对象创建定义了特殊的字面语法(如我们稍后在本节讨论)。其次,Java 支持一种机制,允许程序加载类并动态创建这些类的实例。详细信息请参见第十一章。最后,对象也可以通过反序列化来创建。已经保存其状态(通常是到文件)的对象可以使用java.io.ObjectInputStream类重新创建。

使用对象

现在我们已经看到如何定义类并通过创建对象进行实例化,我们需要查看允许我们使用这些对象的 Java 语法。请记住,一个类定义了一组字段和方法。每个对象都有它自己的这些字段的副本,并且可以访问这些方法。我们使用点字符(.)来访问对象的命名字段和方法。例如:

Account a = new Account("Jason", 0.0, 42);  // Create an object

double b  = a.balance;                 // Read a field of the object
a.balance = a.balance + 10.0;          // Set the value of a field

String s  = a.toString();              // Access a method of the object

当在面向对象的语言中编程时,这种语法非常常见,Java 也不例外。特别要注意,表达式a.toString()。这告诉 Java 编译器查找一个名为toString的方法(该方法由Account的父类Object定义),并使用该方法在对象a上执行计算。我们将在第三章中详细讨论此操作的细节。

对象字面量

在我们讨论原始类型时,我们看到每个原始类型都有一个文字语法,用于将该类型的值文字地包含到程序的文本中。Java 还定义了几种特殊引用类型的文字语法,如下所述。

字符串字面量

String类将文本表示为字符串。因为程序通常通过书面文字与用户进行交流,所以在任何编程语言中,操作文本字符串的能力非常重要。在 Java 中,字符串是对象;用于表示文本的数据类型是String类。现代 Java 程序通常使用比其他任何数据类型都更多的字符串数据。

因此,由于字符串是如此基础的数据类型,Java 允许您以两种格式之一直接在程序中包含文本。传统字符串放置在双引号(")字符之间,或者可以在三个双引号字符序列(""")之间使用较新的文本块形式。

传统的双引号字符串看起来像这样:

String name = "David";
System.out.println("Hello, " + name);

不要将包围字符串字面量的双引号字符与包围char字面量的单引号(或撇号)字符混淆。

任何一种形式的字符串字面量都可以包含char字面量可以使用的任何转义序列(见表 2-2)。传统的双引号字符串需要转义序列来嵌入双引号字符或换行符。它们还必须在我们的 Java 代码中是单行的。例如:

String story = "\t\"How can you stand it?\" he asked sarcastically.\n";

文本块的主要用途而不是传统字符串是表示多行字符串。文本块以"""开头,后跟换行符,并在遇到结尾的"""时结束。

除了支持多行字符串外,文本块还允许我们在不转义的情况下使用双引号。这通常使得文本块在阅读时更加容易,特别是在我们的 Java 代码中表达另一种编程语言(如 SQL 或 HTML)时。

String html = """
 <html>
 <body class="main-body">
 ...
 </body>
 </html>""";
System.out.println(html);

从这段代码的输出中可以看出文本块关于缩进的另一个有趣事实。上述内容在输出的第一列中打印<html>,没有前导空格。

编译器找到文本块各行中的最小缩进,并从每行中剥离相同数量的前导空格。如果不希望这样做,闭合的"""的放置位置也参与选择缩进。我们可以通过以下方式保留完整的空白:

String html = """
 <html>
 <body class="main-body">
 ...
 </body>
 </html>
""";  // As smallest indent (0), this leaves the text block as written

System.out.println(html);

在 Java 引入文本块之前,通常使用+将字符串字面量拆分为更易读的部分。与现有的许多代码库一样,如果您的字符串不应包含换行符,这仍然是一种有效的技术。

// This is illegal
// Traditional string literals cannot break across lines.
String x = "This is a test of the
 emergency broadcast system";

// Common before text blocks
// Still useful if avoiding newlines in the text
String s = "This is a test of the " +
           "emergency broadcast system";

当您的程序编译时,无论是传统文字块还是文字块,文字都会被连接起来,而不是在运行时,因此您不必担心任何性能损失的问题。

类型字面量

支持其特殊对象文字语法的第二类是名为Class的类。Class类的实例表示 Java 数据类型,并包含有关所引用类型的元数据。要在 Java 程序中直接包含Class对象,请在任何数据类型的名称后跟.class。例如:

Class<?> typeInt = int.class;
Class<?> typeIntArray = int[].class;
Class<?> typeAccount = Account.class;

空引用

null关键字是一个特殊的字面量值,它是对空值的引用,或者说是引用的缺失。null值之所以独特,是因为它是每种引用类型的成员。您可以将null赋给任何引用类型的变量。例如:

String s = null;
Account a = null;

Lambda 表达式

Java 8 引入了一个重要的新功能——lambda 表达式。这些是非常常见的编程语言构造,特别是在被称为函数式编程语言的语言家族中广泛使用(例如,Lisp,Haskell 和 OCaml)。Lambda 的强大和灵活性远远超出了仅仅在函数式语言中,它们几乎可以在所有现代编程语言中找到应用。

Lambda 表达式的语法如下:

( *`paramlist`* ) -> { *`statements`* }

一个简单而非常传统的例子:

Runnable r = () -> System.out.println("Hello World");

当 lambda 表达式用作值时,它会自动转换为正确类型的新对象,以便放入变量中。这种自动转换和类型推断对于 Java 的 lambda 表达式方法至关重要。不幸的是,它依赖于对 Java 类型系统作为整体的正确理解。"嵌套类型"提供了对 lambda 表达式的更详细解释——因此,现在简单地认识 lambda 的语法就足够了。

一个稍微复杂的例子:

ActionListener listener = (e) -> {
  System.out.println("Event fired at: "+ e.getWhen());
  System.out.println("Event command: "+ e.getActionCommand());
};

数组

数组是一种特殊类型的对象,它保存零个或多个原始值或引用。这些值保存在数组的元素中,这些元素是由其位置或索引引用的未命名变量。数组的类型由其元素类型所特征化,并且数组的所有元素都必须是该类型的。

数组元素从零开始编号,有效索引范围从零到元素数量减一。例如,索引为 1 的数组元素是数组中的第二个元素。数组的元素数量是其length。数组的长度在创建数组时指定,且永远不会改变(不像 Java 集合,在第八章中我们将会看到)。

数组的元素类型可以是任何有效的 Java 类型,包括数组类型。这意味着 Java 支持数组的数组,提供了一种多维数组的能力。Java 不支持某些语言中的矩阵式多维数组。

尽管 Java 的集合 API 在第八章中得到了全面的覆盖,通常比基本数组更灵活和功能丰富,但数组在整个平台上仍然很常见,值得了解其详细使用细节。

数组类型

数组类型是引用类型,就像类一样。数组的实例是对象,就像类的实例一样。⁴ 与类不同,数组类型不必被定义。只需在元素类型后面放置方括号即可。例如,以下代码声明了三个数组类型的变量:

byte b;                        // byte is a primitive type
byte[] arrayOfBytes;           // byte[] is an array of byte values
byte[][] arrayOfArrayOfBytes;  // byte[][] is an array of byte[]
String[] strings;              // String[] is an array of strings

数组的长度不是数组类型的一部分。例如,不可能声明一个期望恰好有四个int值的数组的方法。如果方法参数是int[]类型,调用者可以传递包括零在内的任意数量的元素的数组。

数组类型不是类,但数组实例是对象。这意味着数组继承了java.lang.Object的方法。数组实现了Cloneable接口,并重写了clone()方法以确保数组始终可以被克隆,并且clone()永远不会抛出CloneNotSupportedException异常。数组还实现了Serializable接口,因此如果其元素类型可以序列化,任何数组都可以被序列化。最后,所有数组都有一个名为lengthpublic final int字段,指定数组中元素的数量。

数组类型扩展转换

因为数组扩展了Object并实现了CloneableSerializable接口,任何数组类型都可以扩展到这三种类型中的任何一种。但某些数组类型也可以扩展到其他数组类型。如果数组的元素类型是引用类型T,并且T可以分配给类型S,则数组类型T[]可以分配给数组类型S[]。请注意,对于给定原始类型的数组,没有此类扩展转换。例如,以下代码行展示了合法的数组扩展转换示例:

String[] arrayOfStrings;      // Created elsewhere
int[][] arrayOfArraysOfInt;   // Created elsewhere

// String is assignable to Object,
// so String[] is assignable to Object[]
Object[] oa = arrayOfStrings;

// String implements Comparable, so a String[] can
// be considered a Comparable[]
Comparable[] ca = arrayOfStrings;

// An int[] is an Object, so int[][] is assignable to Object[]
Object[] oa2 = arrayOfArraysOfInt;

// All arrays are cloneable, serializable Objects
Object o = arrayOfStrings;
Cloneable c = arrayOfArraysOfInt;
Serializable s = arrayOfArraysOfInt[0];

这种将数组类型扩展到另一个数组类型的能力意味着数组的编译时类型并不总是与其运行时类型相同。

提示

这种扩展称为数组协变性,正如我们将在“有界类型参数”中看到的,根据现代标准,它被视为历史遗留和误操作,因为它暴露了编译时和运行时类型之间的不匹配。

编译器通常必须在将引用值存储到数组元素之前插入运行时检查,以确保值的运行时类型与数组元素的运行时类型匹配。如果运行时检查失败,则抛出ArrayStoreException

C 兼容性语法

正如我们所见,通过在元素类型后放置方括号,您可以简单地编写数组类型。但是,为了与 C 和 C++兼容,Java 还支持变量声明中的另一种语法:方括号可以放置在变量名称后,而不是或者除了元素类型之外。这适用于局部变量、字段和方法参数。例如:

// This line declares local variables of type int, int[] and int[][]
int justOne, arrayOfThem[], arrayOfArrays[][];

// These three lines declare fields of the same array type:
public String[][] aas1;   // Preferred Java syntax
public String aas2[][];   // C syntax
public String[] aas3[];   // Confusing hybrid syntax

// This method signature includes two parameters with the same type
public static double dotProduct(double[] x, double y[]) { ... }
提示

这种兼容性语法非常罕见,不建议使用。

创建和初始化数组

要在 Java 中创建数组值,您使用new关键字,就像创建对象一样。数组类型没有构造函数,但在创建数组时必须指定长度。将所需的数组大小指定为方括号内的非负整数:

// Create a new array to hold 1024 bytes
byte[] buffer = new byte[1024];
// Create an array of 50 references to strings
String[] lines = new String[50];

当使用此语法创建数组时,每个数组元素都会自动初始化为类字段使用的相同默认值:布尔元素为false,字符元素为\u0000,整数元素为0,浮点数元素为0.0,引用类型的元素为null

数组创建表达式也可用于创建和初始化数组的多维数组。这种语法稍微复杂,并且在本节后面有详细解释。

数组初始化器

要创建一个数组并在单个表达式中初始化其元素,省略数组长度,然后在方括号后跟随用逗号分隔的表达式列表,这些表达式在花括号内。每个表达式的类型必须可分配给数组元素类型,当然,所创建的数组的长度等于表达式的数量。在列表中的最后一个表达式后面包含尾逗号是合法的但不是必需的。例如:

String[] greetings = new String[] { "Hello", "Hi", "Howdy" };
int[] smallPrimes = new int[] { 2, 3, 5, 7, 11, 13, 17, 19, };

请注意,此语法允许创建、初始化和使用数组,而无需将其分配给变量。在某种意义上,这些数组创建表达式是匿名数组字面量。以下是示例:

// Call a method, passing an anonymous array literal that
// contains two strings
String response = askQuestion("Do you want to quit?",
                               new String[] {"Yes", "No"});

// Call another method with an anonymous array (of anonymous objects)
double d = sumAccounts(new Account[] { new Account("1st", 100.0, 1),
                                       new Account("2nd", 200.0, 2),
                                       new Account("3rd", 300.0, 3) });

当数组初始化器是变量声明的一部分时,您可以省略new关键字、元素类型并在花括号内列出所需的数组元素:

String[] greetings = { "Hello", "Hi", "Howdy" };
int[] powersOfTwo = {1, 2, 4, 8, 16, 32, 64, 128};

在程序运行时创建和初始化数组文字,而不是在编译程序时。考虑以下数组文字:

int[] perfectNumbers = {6, 28};

这被编译成等同于 Java 字节码的内容:

int[] perfectNumbers = new int[2];
perfectNumbers[0] = 6;
perfectNumbers[1] = 28;

Java 在运行时执行所有数组初始化的事实具有一个重要的推论。这意味着数组初始化器中的表达式可以在运行时计算,而不必是编译时常量。例如:

Account[] accounts = { findAccountById(1), findAccountById(2) };

使用数组

一旦数组被创建,您就可以开始使用它了。以下各节解释了对数组元素的基本访问,并涵盖了数组使用的常见习惯用法,例如遍历数组元素和复制数组或数组的一部分。

访问数组元素

数组的元素是变量。当数组元素出现在表达式中时,它会评估为元素中保存的值。当数组元素出现在赋值运算符的左侧时,会将新值存储到该元素中。然而,与普通变量不同,数组元素没有名称,只有一个数字。数组元素使用方括号表示法进行访问。如果a是一个评估为数组引用的表达式,那么您可以通过a[i]进行数组索引,并引用特定元素,其中i是一个整数字面值或一个评估为int的表达式。例如:

// Create an array of two strings
String[] responses = new String[2];
responses[0] = "Yes";  // Set the first element of the array
responses[1] = "No";   // Set the second element of the array

// Now read these array elements
System.out.println(question + " (" + responses[0] + "/" +
                   responses[1] + " ): ");

// Both the array reference and the array index may be more complex
double datum = data.getMatrix()[data.row() * data.numColumns() +
                   data.column()];

数组索引表达式必须是int类型,或者可以扩展为int的类型:byteshort,甚至char。显然,使用booleanfloatdouble值索引数组是不合法的。请记住,数组的length字段是一个int,并且数组的元素不得超过Integer.MAX_VALUE。使用long类型的表达式对数组进行索引会生成编译时错误,即使该表达式在运行时的值在int范围内也是如此。

数组边界

请记住,数组a的第一个元素是a[0],第二个元素是a[1],最后一个元素是a[a.length-1]

关于数组的一个常见 bug 是使用太小的索引(负索引)或太大的索引(大于或等于数组长度)。在像 C 或 C++这样的语言中,访问数组开始之前或结束之后的元素会产生不可预测的行为,这种行为可能会因调用方式和平台而异。这样的 bug 可能并不总是被捕获到,如果发生故障,则可能会在稍后的某个时间发生。虽然在 Java 中编写有错误的数组索引代码同样容易,但 Java 通过在运行时检查每次数组访问来保证可预测的结果。如果数组索引太小或太大,Java 会立即抛出一个ArrayIndexOutOfBoundsException

迭代数组

常见的做法是编写循环,按顺序遍历数组的每个元素,以执行某些操作。这通常使用for循环来完成。例如,以下代码计算整数数组的总和:

int[] primes = { 2, 3, 5, 7, 11, 13, 17, 19, 23 };
int sumOfPrimes = 0;
for(int i = 0; i < primes.length; i++)
    sumOfPrimes += primes[i];

for循环的结构是惯用的,您将经常看到它。Java 还具有我们已经遇到的 foreach 语法。求和代码可以简洁地重写为:

for(int p : primes) sumOfPrimes += p;

复制数组

所有数组类型都实现了 Cloneable 接口,可以通过调用其 clone() 方法进行复制。需要注意的是,需要将返回值强制转换为适当的数组类型,但数组的 clone() 方法保证不会抛出 CloneNotSupportedException

int[] data = { 1, 2, 3 };
int[] copy = data.clone();

clone() 方法生成一个浅拷贝。如果数组的元素类型是引用类型,则只复制引用,而不是引用对象本身。由于是浅拷贝,任何数组都可以被克隆,即使元素类型本身不是 Cloneable

有时你只是想将一个现有数组的元素复制到另一个现有数组中。System.arraycopy() 方法旨在高效地完成此操作,并且可以假定 Java VM 实现会使用基础硬件上的高速块复制操作执行此方法。

arraycopy() 是一个简单的函数,唯一难以使用的地方在于需要记住五个参数。首先,传递要复制元素的源数组。其次,传递该数组中起始元素的索引。作为第三和第四个参数,传递目标数组和目标索引。最后,作为第五个参数,指定要复制的元素数量。

arraycopy() 即使在同一数组内进行重叠复制也能正确工作。例如,如果你从数组 a 中“删除”了索引为 0 的元素,并希望将索引在 1n 之间的元素向下移动一个位置,使它们占据索引 0n-1,你可以这样做:

System.arraycopy(a, 1, a, 0, n);

数组工具

java.util.Arrays 类包含许多用于处理数组的静态实用方法。大多数这些方法都有重载版本,用于每种基本类型的数组以及对象数组的另一个版本。

sort()binarySearch() 方法特别适用于对数组进行排序和搜索。equals() 方法允许你比较两个数组的内容。当你希望将数组内容转换为字符串(例如用于调试或记录输出)时,toString() 方法非常有用。如果你可以接受分配新数组而不是将其复制到现有数组中,copyOf() 是我们之前看到的 arraycopy() 的一个有用的替代方法。

Arrays 类还包括 deepEquals()deepHashCode()deepToString() 方法,适用于多维数组并能正确工作。

多维数组

正如我们所见,数组类型写作元素类型后跟一对方括号。char 类型的数组是 char[]char 数组的数组是 char[][]。当数组的元素本身是数组时,我们称该数组为 多维数组。为了处理多维数组,你需要了解一些额外的细节。

想象一下,你想使用多维数组来表示一个乘法表:

int[][] products;      // A multiplication table

每对方括号表示一个维度,因此这是一个二维数组。要访问这个二维数组中的单个int元素,必须指定两个索引值,一个用于每个维度。假设该数组实际上被初始化为一个乘法表,那么存储在任何给定元素处的int值将是两个索引的乘积。也就是说,products[2][4]将是 8,而products[3][7]将是 21。

要创建一个新的多维数组,使用new关键字并指定数组的两个维度的大小。例如:

int[][] products = new int[10][10];

在某些语言中,像这样的数组会创建为 100 个int值的单个块。但 Java 不是这样工作的。这行代码执行三件事:

  • 声明一个名为products的变量,用于保存一个int数组的数组。

  • 创建一个包含 10 个元素的数组,用于保存 10 个int数组。

  • 创建 10 个新数组,每个数组都是一个包含 10 个int元素的数组。它将这 10 个新数组分配给初始数组的元素。每个这 10 个新数组的每个int元素的默认值都是 0。

换句话说,前一行代码相当于以下代码:

int[][] products = new int[10][]; // An array to hold 10 int[] values
for(int i = 0; i < 10; i++)      // Loop 10 times...
    products[i] = new int[10];   // ...and create 10 arrays

new关键字自动为您执行此附加初始化。它也适用于超过两个维度的数组:

float[][][] globalTemperatureData = new float[360][180][100];

当使用new创建多维数组时,您不必为数组的所有维度指定大小,只需指定最左边的维度或维度。例如,以下两行是合法的:

float[][][] globalTemperatureData = new float[360][][];
float[][][] globalTemperatureData = new float[360][180][];

第一行创建一个单维数组,其中每个数组元素可以容纳一个float[][]。第二行创建一个二维数组,其中每个数组元素是一个float[]。然而,如果只为某些维度指定了大小,则这些维度必须是最左边的。以下行不合法:

float[][][] globalTemperatureData = new float[360][][100];  // Error!
float[][][] globalTemperatureData = new float[][180][100];  // Error!

像一维数组一样,可以使用数组初始化器初始化多维数组。只需使用嵌套的花括号来嵌套数组。例如,我们可以像这样声明、创建和初始化一个 5×5 的乘法表:

int[][] products = { {0, 0, 0, 0, 0},
                     {0, 1, 2, 3, 4},
                     {0, 2, 4, 6, 8},
                     {0, 3, 6, 9, 12},
                     {0, 4, 8, 12, 16} };

或者,如果要使用多维数组而不声明变量,可以使用匿名初始化器语法:

boolean response = bilingualQuestion(question, new String[][] {
                                                   { "Yes", "No" },
                                                   { "Oui", "Non" }});

当使用new关键字创建多维数组时,通常最好使用矩形数组:即每个维度的所有数组值都具有相同的大小。

引用类型

现在我们已经讨论了数组并引入了类和对象,我们可以转向对引用类型的更一般描述。类和数组是 Java 的五种引用类型之一。类在前面介绍过,并且详细介绍了接口,在第三章中进行了详细介绍。枚举类型和注解类型是在第四章中引入的引用类型。

本节不涵盖任何特定引用类型的具体语法,而是解释引用类型的一般行为,并说明它们与 Java 的原始类型的区别。在本节中,“对象”一词指的是任何引用类型的值或实例,包括数组。

引用类型与原始类型

引用类型和对象与原始类型及其原始值有很大的不同:

Java 语言定义了八种原始类型,程序员不能定义新的原始类型。

引用类型是用户定义的,因此它们的数量是无限的。例如,程序可以定义一个名为Account的类,并使用这种新定义类型的对象来存储和跟踪用户的银行账户。

原始类型表示单个值。

引用类型是聚合类型,可以持有零个或多个原始值或对象。例如,我们假设的Account类可能持有一个用于余额的数值,以及用于账户所有者的标识符。char[]Account[]数组类型是聚合类型,因为它们持有一系列原始char值或Account对象。

原始类型需要 1 到 8 字节的内存。

当原始值存储在变量中或传递给方法时,计算机会复制持有值的字节。另一方面,对象可能需要更多内存。对象的存储空间在对象创建时动态分配在堆上,并且当对象不再需要时,此存储空间会自动进行“垃圾收集”。

提示

当对象被分配给变量或传递给方法时,并不会复制表示对象的内存。相反,只会存储对该内存的引用在变量中或传递给方法。

在 Java 中,引用完全是不透明的,引用的表示是 Java 运行时的实现细节。然而,如果你是 C 程序员,可以安全地将引用想象为指针或内存地址。请记住,Java 程序不能以任何方式操作引用。

与 C 和 C++中的指针不同,Java 中的引用不能转换为或从整数转换,并且不能递增或递减。C 和 C++程序员还应注意,Java 不支持&取地址运算符或*->解引用运算符。

操纵对象和引用副本

以下代码操作一个原始的int值:

int x = 42;
int y = x;

执行这些行之后,变量y包含了变量x中持有值的副本。在 Java 虚拟机内部,有两个独立的 32 位整数 42 的副本。

现在想象一下,如果我们运行相同的基本代码,但使用引用类型而不是原始类型会发生什么:

Account a = new Account("Jason", 0.0, 42);
Account b = a;

代码运行后,变量b保存了变量a中保存的引用的副本。在虚拟机中仍然只有一个Account对象的拷贝,但现在有两个引用指向该对象。这有一些重要的含义。假设前两行代码后面跟着这段代码:

System.out.println(a.balance);  // Print out balance of a: 0.0
b.balance = 13.0;               // Now change balance of b
System.out.println(a.balance);  // Print a's balance again: 13.0

因为变量ab保存对同一对象的引用,因此可以使用任一变量来对对象进行更改,并且这些更改也会通过另一个变量可见。由于数组是一种对象,因此对数组也会发生同样的情况,如下面的代码所示:

// greet holds an array reference
char[] greet = { 'h','e','l','l','o' };
char[] cuss = greet;             // cuss holds the same reference
cuss[4] = '!';                   // Use reference to change an element
System.out.println(greet);       // Prints "hell!"

在将参数传递给方法时,原始类型和引用类型之间的行为也存在类似的差异。考虑以下方法:

void changePrimitive(int x) {
    while(x > 0) {
        System.out.println(x--);
    }
}

当调用此方法时,方法会获得用于调用方法的参数的私有副本,该参数保存在参数x中。方法中的代码将x用作循环计数器,并将其递减到零。由于x是原始类型,方法有自己的私有副本,因此这是完全合理的操作。

另一方面,考虑如果修改方法使参数成为引用类型会发生什么:

void changeReference(Account b) {
    while (b.balance > 0) {
        System.out.println(b.balance--);
    }
}

当调用此方法时,会传递一个对Account对象的私有引用副本,并可以使用此引用来改变Account对象。例如,考虑:

Account a = new Account("Jason", 3.0, 42);  // Account balance: 3.0
changeReference(a);             // Prints 3,2,1 and modifies the Account
System.out.println(a.balance);  // The balance of a is now 0!

当调用changeReference()方法时,会传递变量a中保存的引用的副本给方法。现在变量a和方法参数b都保存着指向同一对象的引用。方法可以使用它的引用来改变对象的内容。但请注意,它不能改变变量a的内容。换句话说,方法可以彻底改变Account对象,但不能改变变量a引用该对象的事实。

比较对象

我们已经看到,原始类型和引用类型在赋值给变量、传递给方法和复制时存在显著差异。这些类型在比较相等性时也存在差异。当与原始值一起使用时,等号操作符(==)简单地测试两个值是否相同(即它们是否具有完全相同的位)。然而,与引用类型一起使用时,==比较的是引用,而不是实际对象。换句话说,==测试两个引用是否引用同一对象;它不测试两个对象是否具有相同的内容。以下是一个例子:

String letter = "o";
String s = "hello";              // These two String objects
String t = "hell" + letter;      // contain exactly the same text.
if (s == t) System.out.println("equal"); // But they are not equal!

byte[] a = { 1, 2, 3 };
// A copy with identical content.
byte[] b = (byte[]) a.clone();
if (a == b) System.out.println("equal"); // But they are not equal!

当使用引用类型时,请记住有两种相等性:引用相等性和对象相等性。重要的是要区分这两种相等性。在谈论引用相等性时,一种方法是使用“相同”这个词,而在谈论拥有相同内容的两个不同对象时,使用“相等”。要测试两个非相同对象的相等性,将其中一个传递给另一个的equals()方法即可:

String letter = "o";
String s = "hello";              // These two String objects
String t = "hell" + letter;      // contain exactly the same text.
if (s.equals(t)) {               // And the equals() method
    System.out.println("equal"); // tells us so.
}

所有对象都继承了一个equals()方法(来自Object),但默认实现只是使用==来测试引用的身份,而不是内容的相等性。想要允许对象比较相等性的类可以定义自己版本的equals()方法。我们的Account类没有这样做,但String类做了,正如代码示例中所示。你可以在数组上调用equals()方法,但它与使用==运算符相同,因为数组始终继承默认的equals()方法,该方法比较引用而不是数组内容。你可以使用java.util.Arrays.equals()便捷方法来比较数组的相等性。

装箱和拆箱转换

原始类型和引用类型行为大不相同。有时将原始值视为对象是有用的,因此 Java 平台为每个原始类型包括一个包装类BooleanByteShortCharacterIntegerLongFloatDouble是不可变的、终态的类,它们的每个实例都持有单个原始值。当你想要在诸如java.util.List之类的集合中存储原始值时,通常使用这些包装类:

// Create a List-of-Integer collection
List<Integer> numbers = new ArrayList<>();
// Store a wrapped primitive
numbers.add(Integer.valueOf(-1));
// Extract the primitive value
int i = numbers.get(0).intValue();

Java 允许称为装箱和拆箱转换的类型转换。装箱转换将原始值转换为其对应的包装对象,而拆箱转换则相反。你可以在变量赋值或将值传递给方法时显式指定装箱或拆箱转换,但这是不必要的,因为当你将值分配给变量或将值传递给方法时,这些转换会自动进行。此外,如果在 Java 运算符或语句期望原始值时使用包装对象,则拆箱转换也是自动的。由于 Java 自动执行装箱和拆箱,这种语言特性通常称为自动装箱

这里是一些自动装箱和拆箱转换的示例:

Integer i = 0;   // int literal 0 boxed to an Integer object
Number n = 0.0f; // float literal boxed to Float and widened to Number
Integer i = 1;   // this is a boxing conversion
int j = i;       // i is unboxed here
i++;             // i is unboxed, incremented, and then boxed up again
Integer k = i+2; // i is unboxed and the sum is boxed up again
i = null;
j = i;           // unboxing here throws a NullPointerException

自动装箱使得处理集合变得更加容易。让我们看一个使用 Java 的泛型(我们将在“Java 泛型”中详细了解的语言特性)的示例,它允许我们限制可以放入列表和其他集合的类型:

List<Integer> numbers = new ArrayList<>(); // Create a List of Integer
numbers.add(-1);                           // Box int to Integer
int i = numbers.get(0);                    // Unbox Integer to int

包和 Java 命名空间

是一组命名的类、接口和其他引用类型。包用于组织相关类并为其包含的类定义命名空间。

Java 平台的核心类位于以java开头的包中。例如,语言的最基本类位于java.lang包中。各种实用类位于java.util中。输入和输出类位于java.io中,网络类位于java.net中。一些包含子包,如java.lang.reflectjava.util.regex。Oracle(或最初的 Sun)标准化的 Java 平台扩展通常具有以javax开头的包名。其中一些扩展,如javax.swing及其众多子包,后来被纳入核心平台。最后,Java 平台还包括几个“认可标准”,这些标准有以标准化组织名称命名的包,如org.w3corg.omg

每个类都有一个简单名称,在其定义中给出,以及一个完全限定名,其中包括其所属包的名称。例如,String类属于java.lang包,因此其完全限定名为java.lang.String

本节解释了如何将自己的类和接口放入包中,以及如何选择不会与其他包名冲突的包名。接下来,它解释了如何选择性地导入类型名称或静态成员到命名空间,以便您不必为每个使用的类或接口输入包名。

包声明

要指定类所属的包,您使用一个package声明。如果出现package关键字,则必须是 Java 代码中的第一个标记(即除注释和空格外的第一件事)。关键字后面应跟所需包的名称和一个分号。考虑一个以此指令开头的 Java 文件:

package org.apache.commons.net;

此文件定义的所有类都属于包org.apache.commons.net

如果在 Java 文件中没有package指令,那么该文件中定义的所有类都属于一个未命名的默认包。在这种情况下,类的限定名和非限定名是相同的。

提示

命名冲突的可能性意味着不应使用默认包。随着项目变得更加复杂,冲突几乎是不可避免的——最好从一开始就创建包。

全局唯一包名

包的重要功能之一是分隔 Java 命名空间,防止类之间的名称冲突。例如,只有它们的包名使得java.util.Listjava.awt.List类不同。但为了使此机制生效,包名必须是唯一的。作为 Java 的开发者,Oracle 控制着所有以javajavaxsun开头的包名。

一个常见的方案是使用您的域名,将其元素反转,作为所有包名称的前缀。例如,Apache 项目作为 Apache Commons 项目的一部分生产了一个网络库。Commons 项目可以在 http://commons.apache.org 找到,因此用于网络库的包名称是 org.apache.commons.net

请注意,这些包命名规则主要适用于 API 开发人员。如果其他程序员将使用您开发的类以及未知的其他类,则确保您的包名称在全球范围内是唯一的非常重要。另一方面,如果您正在开发一个 Java 应用程序,并且不会释放任何类供他人重用,那么您知道您的应用程序将部署的完整类集,并且无需担心意外的命名冲突。在这种情况下,您可以选择一个适合自己方便而不是全局唯一性的包命名方案。一个常见的方法是使用应用程序名称作为主要包名称(可以有其下的子包)。

导入类型

在 Java 代码中引用类或接口时,默认情况下必须使用类型的完全限定名称,包括包名称。如果您编写用于操作文件并且需要使用 java.io 包中的 File 类的代码,则必须输入 java.io.File。此规则有三个例外:

  • java.lang 包中的类型非常重要且常用,因此它们可以始终通过其简单名称引用。

  • 类型 p.T 中的代码可以通过其简单名称引用包 p 中定义的其他类型。

  • 使用 import 声明导入命名空间的类型可以通过其简单名称引用。

前两个例外被称为“自动导入”。java.lang 中的类型和当前包中的类型被“导入”到命名空间中,以便可以在不使用其包名称的情况下使用它们。快速输入不在 java.lang 或当前包中的常用类型的包名称会变得乏味,因此还可以明确地从其他包中导入类型到命名空间中。使用 import 声明完成此操作。

在 Java 文件中,import 声明必须出现在 package 声明(如果有的话)之后,任何类型定义之前的开头。您可以在文件中使用任意数量的 import 声明。一个 import 声明适用于文件中的所有类型定义(但不适用于其后的任何 import 声明)。

import 声明有两种形式。要将单个类型导入命名空间,请在 import 关键字后跟类型名称和分号:

import java.io.File;    // Now we can type File instead of java.io.File

这被称为“单类型导入”声明。

另一种import声明形式是“按需类型import”。在此形式中,您指定包的名称,后跟.*字符,以指示可以使用该包中的任何类型而无需其包名称。因此,如果您想要除File类之外的java.io包中的几个其他类,您可以简单地导入整个包:

import java.io.*;   // Use simple names for all classes in java.io

此按需import语法不适用于子包。如果我导入java.util包,我仍然必须通过其完全限定名或导入来引用java.util.zip.ZipInputStream类。

使用按需类型import声明与为包中的每个类型显式编写单个类型import声明并不相同。它更像是针对代码中实际使用的包中每个类型的显式单个类型import。这就是称为“按需”的原因;类型在使用时导入。

命名冲突和遮蔽

import声明对 Java 编程非常宝贵。然而,它们确实使我们面临命名冲突的可能性。考虑java.utiljava.awt包。两者都包含名为List的类型。

java.util.List是一个重要且常用的接口。java.awt包含许多在客户端应用程序中常用的重要类型,但java.awt.List已过时,不是这些重要类型之一。在同一个 Java 文件中导入java.util.Listjava.awt.List是非法的。以下单个类型import声明会产生编译错误:

import java.util.List;
import java.awt.List;

使用两个包的按需类型导入是合法的:

import java.util.*;  // For collections and other utilities.
import java.awt.*;   // For fonts, colors, and graphics.

然而,如果您确实尝试使用类型List,则会出现困难。此类型可以从任一包中“按需”导入,并且任何尝试将List作为未限定类型名称使用都会产生编译错误。在这种情况下的解决方法是明确指定您想要的包名称。

因为java.util.Listjava.awt.List更常用,将这两个按需类型import声明与单个类型import声明结合起来以消除我们在说List时的歧义是有用的:

import java.util.*;    // For collections and other utilities.
import java.awt.*;     // For fonts, colors, and graphics.
import java.util.List; // To disambiguate from java.awt.List

有了这些import声明,我们可以使用List来表示java.util.List接口。如果我们确实需要使用java.awt.List类,只要包括其包名称,我们仍然可以这样做。在java.utiljava.awt之间没有其他命名冲突,并且在使用时,它们的类型将被“按需”导入而无需包名称。

导入静态成员

除了类型,您还可以使用关键字import static导入类型的静态成员。(静态成员在第三章中有解释。如果您对它们还不熟悉,可以稍后再回到本节。)与类型import声明类似,这些静态import声明有两种形式:单个静态成员import和按需静态成员import。例如,假设您正在编写一个向System.out发送大量输出的文本程序。在这种情况下,您可以使用单个静态成员import来节省输入:

import static java.lang.System.out;

然后,您可以使用out.println()代替System.out.println()。或者假设您正在编写一个使用许多Math类的三角和其他函数的程序。在这种明显专注于数值方法的程序中,反复输入类名Math并没有增加代码的清晰度;它只是妨碍了代码的编写。在这种情况下,按需静态成员import可能是合适的:

import static java.lang.Math.*

使用此import声明,您可以自由地编写像sqrt(abs(sin(x)))这样简洁的表达式,而无需为每个静态方法名称添加类名Math的前缀。

另一个import static声明的重要用途是将常量的名称导入到您的代码中。这在枚举类型中特别有效(参见第四章)。例如,假设您希望在编写的代码中使用此枚举类型的值:

package climate.temperate;
enum Seasons { WINTER, SPRING, SUMMER, AUTUMN };

您可以导入类型climate.temperate.Seasons,然后使用类型名称前缀常量:Seasons.SPRING。为了更简洁的代码,您可以直接导入枚举值本身:

import static climate.temperate.Seasons.*;

对于常量,使用静态成员import声明通常比实现定义常量的接口更好。

静态成员导入和重载方法

静态import声明导入的是一个名称,而不是具有该名称的任何特定成员。因为 Java 允许方法重载并允许类型具有相同名称的字段和方法,单个静态成员import声明实际上可能导入多个成员。考虑以下代码:

import static java.util.Arrays.sort;

此声明将名称sort导入命名空间,而不是java.util.Arrays定义的 19 个sort()方法中的任何一个。如果您使用导入的名称sort调用方法,则编译器将查看方法参数的类型以确定您指的是哪个方法。

即使是合法的,也可以从两个或更多不同类型中导入具有相同名称的静态方法,只要这些方法都具有不同的签名。这里有一个自然的例子:

import static java.util.Arrays.sort;
import static java.util.Collections.sort;

你可能会期望这段代码会导致语法错误。事实上,它不会,因为Collections类定义的sort()方法的签名与Arrays类定义的所有sort()方法的签名不同。当你在代码中使用“sort”这个名字时,编译器会查看参数的类型来确定你是指哪一个 21 个可能的导入方法中的哪一个。

Java 源文件结构

本章带我们从 Java 语法的最小元素到最大的元素,从单个字符和标记到运算符、表达式、语句和方法,再到类和包。从实际角度来看,您最常处理的 Java 程序结构单元是 Java 文件。Java 文件是 Java 代码中可以被 Java 编译器编译的最小单元。一个 Java 文件由以下内容组成:

  • 一个可选的package指令

  • 零个或多个importimport static指令

  • 一个或多个类型定义

这些元素当然可以与注释交替出现,但它们必须按照这个顺序出现。这就是一个 Java 文件的全部内容。所有 Java 语句(除了packageimport指令,这些不是真正的语句,以及我们将在第十二章中讨论的专用模块描述符)必须出现在方法中,所有方法必须出现在类型定义中。

一个名为module-info.java的特殊 Java 文件仅用于声明我们在模块化 Java 应用程序中的包的结构和可见性。这些更高级的技术和语法在第十二章中有详细介绍。

Java 文件有一些其他重要的限制。首先,每个文件最多只能包含一个顶级public类。一个public类是为其他包中的类使用而设计的。一个类可以包含任意数量的public嵌套或内部类。我们将在第三章中更多地了解public修饰符和嵌套类。

第二个限制涉及 Java 文件的文件名。如果一个 Java 文件包含一个public类,文件名必须与类名相同,后缀为.java。因此,如果Account定义为一个public类,它的源代码必须出现在名为Account.java的文件中。无论你的类是否public,良好的编程习惯是每个文件只定义一个类,并给文件起一个与类名相同的名字。

当 Java 文件编译时,它定义的每个类都会编译成一个单独的class文件,其中包含 Java 字节码,由 Java 虚拟机执行。类文件的名称与其定义的类名称相同,并附加扩展名.class。因此,如果文件Account.java定义了一个名为Account的类,Java 编译器将其编译为名为Account.class的文件。在大多数系统上,类文件存储在与其包名称对应的目录中。例如,类com.davidflanagan.examples.Account由类文件com/davidflanagan/examples/Account.class定义。

Java 运行时知道标准系统类的类文件位于何处,并且可以根据需要加载它们。当解释器运行一个想要使用名为com.davidflanagan.examples.Account的类的程序时,它知道该类的代码位于名为com/davidflanagan/examples/的目录中,并且默认情况下会在当前目录中查找该名称的子目录。为了告诉解释器查找除当前目录以外的位置,您必须在调用解释器时使用-classpath选项或设置CLASSPATH环境变量。有关详细信息,请参阅 Java 可执行文件java的文档,位于第十三章中。

定义和运行 Java 程序

一个 Java 程序由一组互动的类定义组成。但并非每个 Java 类或 Java 文件都定义了一个程序。要创建一个程序,您必须定义一个具有以下签名的特殊方法的类:

public static void main(String[] args)

这个main()方法是程序的主入口点。Java 解释器从这里开始运行。此方法接收一个字符串数组并且不返回任何值。当main()方法返回时,Java 解释器退出(除非main()创建了单独的线程,在这种情况下,解释器会等待所有这些线程退出)。

要运行 Java 程序,您运行 Java 可执行文件java,指定包含main()方法的类的完全限定名称。请注意,您指定类的名称,而不是包含该类的类文件的名称。您在命令行上指定的任何其他参数都将作为其String[]参数传递给main()方法。您可能还需要指定-classpath选项(或-cp)以告知解释器程序所需的类的查找位置。考虑以下命令:

java -classpath /opt/Jude com.davidflanagan.jude.Jude datafile.jude

java是运行 Java 解释器的命令。-classpath /opt/Jude告诉解释器在哪里查找.class文件。com.davidflanagan.jude.Jude是要运行的程序的名称(即定义了main()方法的类的名称)。最后,datafile.jude是作为String对象数组的单个元素传递给main()方法的字符串。

运行程序有一种更简单的方法。如果程序及其所有辅助类(除了那些属于 Java 平台的类)已经被正确打包在一个 Java 归档(JAR)文件中,您只需指定 JAR 文件的名称即可运行该程序。在下一个示例中,我们展示如何启动日志分析器:

java -jar /usr/local/log-analyzer/log-analyzer.jar

一些操作系统可以自动执行 JAR 文件。在这些系统上,您可以简单地说:

/usr/local/log-analyzer/log-analyzer.jar

Java 17 还引入了直接对源文件运行 java 的能力,类似于 Python 等脚本语言。您仍然必须定义一个与文件名匹配的类和一个 main() 方法,然后可以执行以下程序:

java MyClass.java

查看第十三章 获取有关如何执行 Java 程序的更多详细信息。

摘要

在本章中,我们介绍了 Java 语言的基本语法。由于编程语言语法的相互关联性,如果您现在感觉还没有完全掌握语言的所有语法,这是完全可以的。通过实践,我们才能掌握任何语言,无论是人类语言还是计算机语言。

值得注意的是,语法的某些部分比其他部分更常用。例如,strictfpassert 关键字几乎不被使用。与其试图掌握 Java 语法的每个方面,不如开始掌握 Java 核心方面的技能,然后再回到可能仍然困扰您的语法细节。考虑到这一点,让我们继续下一章,讨论 Java 中的类和对象,以及 Java 面向对象编程的基础知识。

¹ 技术上来说,减号是一个操作符,作用于字面量本身,而不是字面量的一部分。

² 技术上来说,它们都必须实现AutoCloseable接口。

³ 在 Java 语言规范中,“签名”一词有一个技术上的含义,与此处使用的含义略有不同。本书使用了方法签名的较少正式的定义。

⁴ 在数组的讨论中存在术语难度。与类及其实例不同,我们用“数组”一词来表示数组类型和数组实例。实际上,从上下文中通常可以清楚地知道是讨论类型还是值。

第三章:Java 面向对象编程

现在我们已经讲解了基本的 Java 语法,可以开始在 Java 中进行面向对象编程了。所有 Java 程序都使用对象,对象的类型由其 接口 定义。每个 Java 程序都定义为一个类,复杂的程序包括多个类和接口定义。

本章将解释如何定义新的类(和记录),以及如何使用它们进行面向对象编程。我们还介绍了接口的概念,但对接口和 Java 类型系统的全面讨论将推迟到第四章。

注意

然而,如果您有面向对象编程经验,请小心。术语“面向对象”在不同语言中有不同的含义。不要假设 Java 与您喜欢的其他面向对象语言工作方式相同。(这对 JavaScript 或 Python 程序员特别适用。)

由于这是一个相当长的章节,让我们从概述和一些定义开始。

类和记录概述

类是所有 Java 程序中最基本的结构元素。在定义类之前无法编写 Java 代码。所有 Java 语句都出现在类中,并且所有方法都在类中实现。

基本面向对象定义

下面是一些重要的定义:

是包含保存值的数据字段以及操作这些值的方法的集合。类定义了一个新的引用类型,例如第二章中定义的Account类型。

Account类定义了银行系统中客户账户的类型。

从 Java 17 开始,语言还包括对记录的支持——这是一种具有附加语义的特殊类。

对象

对象 是类的 实例

Account对象是该类型的一个值:它表示一个特定的客户银行账户。

对象通常通过使用new关键字和构造函数调用来 实例化,如下所示:

Account a = new Account("John Smith", 100, 1144789);

构造函数将在本章稍后详细讲解,参见“创建和初始化对象”。

类定义由 签名主体 组成。类签名定义了类的名称,可能还指定其他重要信息。类的主体是一组用大括号括起来的 成员。类的成员通常包括字段和方法,还可能包括构造函数、初始化程序和嵌套类型。

成员可以是 静态 的或非静态的。静态成员属于类本身,而非静态成员与类的实例关联(参见“字段和方法”)。

注意

有四种非常常见的成员类型——类字段、类方法、实例字段和实例方法。大多数 Java 工作涉及与这些类型的成员交互。

类的签名可以声明类扩展另一个类。扩展的类称为超类,扩展称为子类。子类继承其超类的成员,可以声明新的成员或使用新实现覆盖继承的方法。

类的成员可以具有访问修饰符publicprotectedprivate。¹ 这些修饰符指定了它们对客户端和子类的可见性和访问权限。这使得类能够控制对不属于其公共 API 的成员的访问。隐藏成员的能力支持一种称为数据封装的面向对象设计技术,我们在“数据隐藏和封装”中讨论。

记录

记录(或记录类)是一种提供了比一般类更多语义保证的特殊形式。

具体而言,记录(record)保证实例字段精确地定义了该类型对象的唯一有意义的状态。这可以表达为该记录类是一个数据载体或“仅持有字段”的原则(或模式)。同意这一原则对程序员施加了约束,但也使他们无需明确某些设计细节。

记录类定义如下:

/** Represents a point in 2-dimensional space */
public record Point(double x, double y) {}

不需要显式声明构造函数或字段的访问方法,因为对于记录类,编译器会自动生成这些成员并将它们添加到类定义中。访问方法的名称与它们提供访问权限的底层字段完全相同。虽然可以向记录添加额外的方法,但如果所需的仅仅是基本的数据载体形式,则不必这样做。

记录类的实例(或称为记录)的创建和实例化方式与常规类相同,我们可以在创建的对象上调用访问器:

// Create a Point object representing (2,-3.5).
// Declare a variable p and store a reference to the new Point object
Point p = new Point(2.0, -3.5);
double x = p.x();                    // Read a field of the object

记录(record)的另一个方面是它们始终是不可变的。一旦创建,记录字段的值就不能被修改。这意味着不需要为字段编写 setter 方法,因为它们不能被修改。

Java 记录的契约是,参数名(如记录声明中指定的)与字段名和方法名都完全相同:如果有一个类型为double的记录参数x,那么类就有一个名称为x、类型为double的字段和一个名称为x()的实例方法,返回double

记录还具有某些其他方法,这些方法也是由编译器自动生成的。在我们讨论如何将记录作为面向对象设计的一部分时,我们将详细介绍它们,见第五章。

其他引用类型

类的签名还可以声明该类实现一个或多个接口。接口是一种类似于类的引用类型,它定义了方法签名,但通常不包括实现方法的方法体。

但是,从 Java 8 开始,接口可以使用关键字default来指示接口中指定的方法是可选的。如果方法是可选的,则接口文件必须包含默认实现(因此选择了关键字),该实现将被所有未提供可选方法实现的实现类使用。

实现接口的类需要为接口的非默认方法提供方法体。实现接口的类的实例也是接口类型的实例。

类和接口是 Java 定义的五种基本引用类型中最重要的两种。数组、枚举类型(或“enums”)和注解类型(通常称为“注解”)是另外三种。数组在第二章中有所涉及。枚举是一种特殊的类,而注解是一种特殊的接口——它们将在第四章中讨论,同时还会全面讨论接口。

类定义语法

在其最简单的级别上,类定义由关键字class后跟类名和一组在大括号内的类成员组成。class关键字前可以有修饰符关键字和注解。如果类扩展另一个类,则类名后跟extends关键字和被扩展的类的名称。如果类实现一个或多个接口,则类名或extends子句后跟implements关键字和逗号分隔的接口名称列表。例如,对于java.lang中的Integer

public class Integer extends Number
                     implements Serializable, Comparable {
    // class members go here
}

Java 还包括声明泛型类的能力,允许从单个类声明中创建整个类型族。我们将在第四章中遇到此功能,以及支持其的机制(如类型参数通配符)。

类声明可以包括修饰符关键字。除了访问控制修饰符(publicprotected等)外,还包括:

abstract

一个abstract类是一个实现不完整且无法实例化的类。任何具有一个或多个abstract方法的类必须声明为abstract。抽象类在“抽象类和方法”中讨论。

final

final修饰符指定类不可扩展。类不能同时声明为abstractfinal

sealed

密封类是只能由已知子类集扩展的类。密封类提供了final类和默认开放扩展类之间的中间地带。密封类的使用在第五章中有更详细的讨论。密封类仅在 Java 17 及以上版本中可用。

strictfp

一个类可以声明为strictfp;其所有方法的行为都像它们被声明为strictfp一样,并且严格遵循浮点标准的形式语义。这个修饰符在 Java 17 中实际上是一个无操作符,因为其原因在第二章中讨论过。

字段和方法

一个类可以被视为数据(也称为状态)和操作该状态的代码的集合。数据存储在字段中,而代码组织成方法。

本节介绍字段和方法,这两种是类成员中最重要的。字段和方法有两种不同的类型:类成员(也称为静态成员)与类本身关联,而实例成员与类的各个实例(即对象)关联。这使得我们有四种成员:

  • 类字段

  • 类方法

  • 实例字段

  • 实例方法

Circle的简单定义,如示例 3-1 所示,包含了所有四种类型的成员。

示例 3-1. 一个简单的类及其成员
public class Circle {
  // A class field
  public static final double PI= 3.14159;     // A useful constant

  // A class method: just compute a value based on the arguments
  public static double radiansToDegrees(double radians) {
    return radians * 180 / PI;
  }

  // An instance field
  public double r;                  // The radius of the circle

  // Two instance methods: operate on an object's instance fields

  // Compute the area of the circle
  public double area() {
    return PI * r * r;
  }

  // Compute the circumference of the circle
  public double circumference() {
    return 2 * PI * r;
  }
}
警告

在我们的例子中,有一个公共实例字段r并不是好的做法。最好有一个私有字段r和一个方法radius()(或r())来提供对它的访问。这个原因稍后将在“数据隐藏与封装”中解释。现在,我们使用公共字段只是为了给出如何使用实例字段的示例。

接下来的部分解释了所有四种常见成员。首先,我们介绍字段的声明语法。(方法的声明语法稍后在“数据隐藏与封装”章节中讨论。)

字段声明语法

字段声明语法与声明局部变量的语法非常相似(参见第二章)。不同之处在于字段定义可能还包括修饰符。最简单的字段声明由字段类型后跟字段名称组成。

类型可能由零个或多个修饰符关键字或注解前缀,并且名称后面可能跟着一个等号和初始化表达式,该表达式提供字段的初始值。如果两个或更多字段共享相同的类型和修饰符,类型后面可以跟着一个逗号分隔的字段名称和初始化器列表。以下是一些有效的字段声明:

int x = 1;
private String name;
public static final int DAYS_PER_WEEK = 7;
String[] daynames = new String[DAYS_PER_WEEK];
private int a = 17, b = 37, c = 53;

字段修饰关键字由以下零个或多个关键字组成:

public, protected, private

这些访问修饰符指定字段是否以及在类定义之外的地方能否使用。

static

如果存在这个修饰符,它指定字段与定义类本身相关联,而不是与类的每个实例相关联。

final

这个修饰符指定一旦字段被初始化,其值就永远不能改变。同时具有staticfinal修饰符的字段是编译时常量,javac可以内联它们。final字段也可以用于创建其实例是不可变的类。

transient

这个修饰符指定字段不是对象的持久状态的一部分,因此它不需要与对象的其余部分一起序列化。这个修饰符非常少见。

volatile

这个修饰符指示该字段具有用于两个或多个线程并发使用的额外语义。volatile修饰符表示必须始终从主内存读取字段的值并刷新到主内存,并且它可能不会被线程(在寄存器或 CPU 缓存中)缓存。详见第六章了解更多细节。

类字段

类字段与定义它的类相关联,而不是与类的实例相关联。以下行声明了一个类字段:

public static final double PI = 3.14159;

这行声明了一个名为PIdouble类型字段,并赋予它值3.14159

static修饰符表示该字段是一个类字段。由于这个static修饰符,类字段有时也称为静态字段。final修饰符表示字段的值不能直接重新分配。因为字段PI表示一个常量,我们将其声明为final,以确保它不可更改。

在 Java(以及许多其他语言)中,常量通常使用大写字母命名,这就是为什么我们的字段命名为PI而不是pi的惯例。像这样定义常量是类字段的常见用途,因此staticfinal修饰符经常一起使用。然而,并非所有的类字段都是常量。换句话说,字段可以被声明为static而不声明为final

注意

使用非final的公共字段是一种代码异味——因为多个线程可能更新字段并导致极难调试的行为。初学者不应该使用非final的公共字段。

公共静态字段本质上是全局变量。类字段的名称由包含它们的类的唯一名称限定。因此,在不同代码模块定义具有相同名称的全局变量时,Java 不会遇到名称冲突的问题。

关于静态字段的关键是它只有一个副本。该字段与类本身相关联,而不是类的实例。如果你查看Circle类的各种方法,你会发现它们使用了这个字段。在Circle类内部,可以简单地将该字段称为PI。然而,在类外部,为了唯一指定该字段,需要同时使用类名和字段名。不属于Circle的方法将该字段访问为Circle.PI

类方法

与类字段类似,类方法使用static修饰符声明。它们也被称为静态方法

public static double radiansToDegrees(double rads) {
  return rads * 180 / PI;
}

此行声明了一个名为radiansToDegrees()的类方法。它有一个double类型的单参数,并返回一个double值。

类方法与类相关联,而不是对象。在从类外部代码调用类方法时,必须同时指定类名和方法名。例如:

// How many degrees is 2.0 radians?
double d = Circle.radiansToDegrees(2.0);

如果要在定义它的类内部调用类方法,不必指定类名。还可以通过静态导入来缩短所需的输入量(如第二章中讨论的)。

注意,我们的Circle.radiansToDegrees()方法体使用了类字段PI。类方法可以使用其自身类的任何类字段和类方法(或任何其他可见的类)。

类方法不能使用任何实例字段或实例方法,因为类方法不与类的实例相关联。换句话说,尽管radiansToDegrees()方法在Circle类中定义,但它不能使用任何Circle对象的实例部分。

注意

一个思考方式是,在任何实例中,我们总是有一个引用this指向当前对象。this引用作为隐式参数传递给任何实例方法。然而,类方法不与特定实例关联,因此它们没有this引用,也无法访问实例字段。

正如我们之前讨论的,类字段本质上是全局变量。类方法类似地是全局方法或全局函数。尽管radiansToDegrees()不在Circle对象上操作,但它在Circle类中定义,因为它是一个在处理圆时有时有用的实用方法,所以将它与Circle类的其他功能打包是有意义的。

实例字段

没有static修饰符声明的任何字段都是实例字段

public double r;    // The radius of the circle

实例字段与类的实例相关联,因此我们创建的每个Circle对象都有其自己的double类型字段r的副本。在我们的示例中,r表示特定圆的半径。每个Circle对象可以具有与所有其他Circle对象独立的半径。

在类定义内部,实例字段仅通过名称引用。如果查看circumference()实例方法的方法体,可以看到一个示例。在类外部的代码中,实例方法的名称必须前缀引用包含该方法的对象。例如,如果变量c持有Circle对象的引用,则使用表达式c.r来引用该圆的半径:

Circle c = new Circle(); // Create a Circle object; store a ref in c
c.r = 2.0;               // Assign a value to its instance field r
Circle d = new Circle(); // Create a different Circle object
d.r = c.r * 2;           // Make this one twice as big

实例字段是面向对象编程的关键。实例字段保存对象的状态;这些字段的值使一个对象与另一个对象不同。

实例方法

实例方法 是针对类的特定实例(对象)运行的方法,未声明为static关键字的任何方法都自动成为实例方法。

实例方法是使面向对象编程开始变得有趣的特性。在Example 3-1中定义的Circle类包含两个实例方法,area()circumference(),用于计算并返回给定Circle对象表示的圆的面积和周长。

要从定义它的类的外部使用实例方法,必须前缀引用要操作的实例。例如:

// Create a Circle object; store in variable c
Circle c = new Circle();
c.r = 2.0;                 // Set an instance field of the object
double a = c.area();       // Invoke an instance method of the object
注意

这就是为什么它被称为面向对象编程;对象是焦点,而不是方法调用。

在实例方法内部,我们自然地可以访问所有属于调用该方法的对象的实例字段。请记住,一个对象通常被认为是一个包含状态(表示为对象的字段)和行为(操作该状态的方法)的捆绑体。

所有实例方法都使用一个在方法签名中未显示的隐式参数来实现。隐式参数被命名为this;它保存通过它调用方法的对象的引用。在我们的示例中,该对象是一个Circle

注意

area()circumference()方法的主体都使用类字段PI。我们前面看到,类方法只能使用类字段和类方法,而不是实例字段或方法。实例方法没有这种限制:它们可以使用类的任何成员,无论它是声明为static还是非static的。

this 引用的工作原理

隐式的this参数在方法签名中未显示,因为通常不需要;每当一个 Java 方法访问其类中的实例字段时,隐含地它正在访问this参数所引用的对象的字段。当一个实例方法在同一个类中调用另一个实例方法时,情况也是如此——这意味着“在当前对象上调用实例方法”。

然而,当您希望明确指出方法正在访问其自身字段和/或方法时,可以显式使用this关键字。例如,我们可以重写area()方法,显式使用this来引用实例字段:

public double area() { return Circle.PI * this.r * this.r; }

此代码还显式使用类名来引用类字段PI。在如此简单的方法中,通常不必这样明确。然而,在更复杂的情况下,即使不严格要求,有时使用显式的this 可以增加代码的清晰度。

在某些情况下,this 关键字是必需的。例如,当方法参数或方法中的局部变量与类的某个字段同名时,必须使用this 来引用该字段。这是因为单独使用字段名称会引用方法参数或局部变量,详见“词法作用域和局部变量”。

例如,我们可以将以下方法添加到Circle 类中:

public void setRadius(double r) {
  this.r = r;      // Assign the argument (r) to the field (this.r)
                   // Note that writing r = r is a bug
}

一些开发人员会有意地选择方法参数的名称,以避免与字段名称冲突,因此可以大部分避免使用this。然而,由任何主要 Java IDE 生成的访问方法(setter)将使用此处所示的this.x = x 样式。

最后,请注意,虽然实例方法可以使用this 关键字,但类方法不能,因为类方法不与单个对象相关联。

创建和初始化对象

现在我们已经介绍了字段和方法,让我们继续了解类的其他重要成员。特别是,我们将看看构造函数——这些是类的成员,其工作是在创建类的新实例时初始化类的字段。

再看一下我们如何创建Circle 对象:

Circle c = new Circle();

这段代码可以轻松地看作是通过调用类似方法的东西来创建Circle 的新实例。事实上,Circle() 就是构造函数 的一个示例。这是类的成员,与类同名,并且有一个像方法一样的主体。

构造函数的工作方式如下。new 操作符表示我们需要创建类的一个新实例。首先,在 Java 堆中分配内存以容纳新对象实例。然后,调用构造函数体,传入任何指定的参数。构造函数使用这些参数来执行新对象的任何初始化工作。

Java 中的每个类至少有一个构造函数,其目的是为新对象执行任何必要的初始化。如果程序员未显式为类定义构造函数,则javac 编译器会自动创建一个构造函数(称为默认构造函数),它不接受任何参数并且不执行任何特殊初始化。在示例 3-1 中看到的Circle 类使用了这种机制来自动声明一个构造函数。

定义构造函数

对于我们的 Circle 对象,有一些明显的初始化工作可以做,因此让我们定义一个构造函数。示例 3-2 展示了 Circle 的新定义,包含了一个构造函数,允许我们指定新 Circle 对象的半径。我们还利用这个机会将字段 r 设为受保护状态(以防止任意对象访问)。

示例 3-2. Circle 类的构造函数
public class Circle {
    public static final double PI = 3.14159;  // A constant
    // An instance field that holds the radius of the circle
    protected double r;

    // The constructor: initialize the radius field
    public Circle(double r) { this.r = r; }

    // The instance methods: compute values based on the radius
    public double circumference() { return 2 * PI * r; }
    public double area() { return PI * r * r; }
    public double radius() { return r; }
}

当我们依赖编译器提供的默认构造函数时,我们必须编写类似以下的代码来显式初始化半径:

Circle c = new Circle();
c.r = 0.25;

使用新的构造函数,初始化成为对象创建步骤的一部分:

Circle c = new Circle(0.25);

这里有关于命名、声明和编写构造函数的一些基础知识:

  • 构造函数的名称始终与类名相同。

  • 构造函数声明时没有返回类型(甚至不是 void 占位符)。

  • 构造函数的主体是初始化对象的代码。您可以将其视为设置 this 引用的内容。

  • 构造函数不会返回 this(或任何其他值)。

定义多个构造函数

有时,您希望根据特定情况的方便程度以多种不同的方式初始化对象。例如,我们可能希望将圆的半径初始化为指定值或合理的默认值。以下是我们如何为 Circle 定义两个构造函数的方式:

public Circle() { r = 1.0; }
public Circle(double r) { this.r = r; }

当然,因为我们的 Circle 类只有一个实例字段,所以我们不能以太多的方式初始化它。但在更复杂的类中,通常方便定义各种构造函数。

定义多个构造函数为一个类是完全合法的,只要每个构造函数有不同的参数列表。编译器根据您提供的参数数量和类型确定您希望使用的构造函数。定义多个构造函数的能力类似于方法重载的能力。

从另一个构造函数中调用

当一个类有多个构造函数时,this 关键字的一个特殊用法是从一个构造函数中调用同一类的另一个构造函数。换句话说,我们可以将前面两个 Circle 构造函数重写如下:

// This is the basic constructor: initialize the radius
public Circle(double r) { this.r = r; }
// This constructor uses this() to invoke the constructor above
public Circle() { this(1.0); }

当多个构造函数共享大量初始化代码时,这是一种有用的技术,因为它避免了重复编写该代码。在更复杂的情况下,如果构造函数进行了更多的初始化操作,这将是一种非常有用的技术。

使用 this() 有一个重要的限制:它只能作为构造函数中的第一条语句出现,但随后可以跟随特定构造函数需要执行的任何额外初始化操作。这个限制涉及到超类构造函数的自动调用,我们将在本章后面探讨这个问题。

字段默认值和初始化器

类的字段不一定需要初始化。如果它们的初始值未指定,则字段将自动初始化为默认值false\u000000.0null,具体取决于它们的类型(有关更多详细信息,请参见表 2-1)。这些默认值由 Java 语言规范指定,并适用于实例字段和类字段。

注意

默认值实际上是每种类型的零位模式的“自然”解释。

如果默认字段值不适合您的字段,则可以显式提供不同的初始值。例如:

public static final double PI = 3.14159;
public double r = 1.0;

字段声明不属于任何方法。相反,Java 编译器会自动生成字段的初始化代码,并将其放入类的所有构造函数中。初始化代码按照其在源代码中出现的顺序插入到构造函数中,这意味着字段初始化程序可以使用其之前声明的任何字段的初始值。

请考虑以下代码摘录,显示了假设类的构造函数和两个实例字段:

public class SampleClass {
  public int len = 10;
  public int[] table = new int[len];

  public SampleClass() {
    for(int i = 0; i < len; i = i + 1) {
        table[i] = i;
    }
  }

  // The rest of the class is omitted...
}

在这种情况下,由javac为构造函数生成的代码实际上等效于:

public SampleClass() {
  len = 10;
  table = new int[len];
  for(int i = 0; i < len; i = i + 1) {
      table[i] = i;
  }
}

如果构造函数以对另一个构造函数的this()调用开始,则字段初始化代码不会出现在第一个构造函数中。相反,初始化在由this()调用的构造函数中处理。

因此,如果实例字段在构造函数中初始化,那么类字段在哪里初始化呢?即使永远不创建类的实例,这些字段也与类关联。从逻辑上讲,这意味着它们需要在调用构造函数之前初始化。

为了支持这一点,javac 为每个类自动生成一个类初始化方法。类字段在该方法体中初始化,该方法在类第一次使用之前恰好调用一次(通常是在 Java VM 第一次加载类时)。

与实例字段初始化类似,类字段初始化表达式按照其在源代码中出现的顺序插入到类初始化方法中。这意味着类字段的初始化表达式可以使用在其之前声明的类字段。

类初始化方法是一个对 Java 程序员隐藏的内部方法。在类文件中,它被命名为<clinit>(例如,可以使用javap检查类文件,详细信息请参见第十三章)。

初始化块

到目前为止,我们已经看到对象可以通过其字段的初始化表达式和构造函数中的任意代码进行初始化。一个类有一个类初始化方法(类似于构造函数),但我们不能在 Java 中显式定义此方法的主体,尽管在字节码中这样做是完全合法的。

然而,Java 确实允许我们使用称为静态初始化器的结构来表达类的初始化。静态初始化器只是关键字static后跟一对花括号内的代码块。静态初始化器可以出现在类定义的任何地方,就像字段或方法定义一样。例如,考虑以下代码,它对两个类字段执行一些非平凡的初始化:

// We can draw the outline of a circle using trigonometric functions
// Trigonometry is slow, though, so we precompute a bunch of values
public class TrigCircle {
  // Here are our static lookup tables and their own initializers
  private static final int NUMPTS = 500;
  private static double sines[] = new double[NUMPTS];
  private static double cosines[] = new double[NUMPTS];

  // Here's a static initializer that fills in the arrays
  static {
    double x = 0.0;
    double delta_x = (Circle.PI/2)/(NUMPTS - 1);
    for(int i = 0, x = 0.0; i < NUMPTS; i = i + 1, x += delta_x) {
      sines[i] = Math.sin(x);
      cosines[i] = Math.cos(x);
    }
  }
  // The rest of the class is omitted...
}

类可以有任意数量的静态初始化器。每个初始化器块的主体都会与类初始化方法一起合并,以及任何静态字段初始化表达式。静态初始化器类似于类方法,因为它不能使用this关键字或类的任何实例字段或实例方法。

记录构造函数

记录类作为 Java 16 的标准特性引入,隐式定义一个构造函数:由参数列表定义的标准构造函数。然而,开发人员可能需要为记录类提供额外的(也称为辅助)构造函数的情况。例如,为记录参数提供默认值,如:

public record Point(double x, double y) {
    /** Constructor simulates default parameters */
    public Point(double x) {
        this(x, 0.0);
    }
}

记录还提供了类构造函数的另一种改进:紧凑构造函数。这在帮助创建有效的记录对象时,某些验证或其他检查代码很有用。例如:

/** Represents a point in 2-dimensional space */
public record Point(double x, double y) {
    /** Compact constructor provides validation */
    public Point {
        if (Double.isNaN(x) || Double.isNaN(y)) {
            throw new IllegalArgumentException("Illegal NaN");
        }
    }
}

在紧凑构造函数语法中,请注意参数列表不需要重复(因为可以从记录声明中推断出来),而且参数(在我们的示例中为xy)已经在范围内。紧凑构造函数与标准构造函数一样,也会从参数值隐式初始化字段。

子类和继承

早些时候定义的Circle是一个简单的类,仅通过其半径区分圆对象。假设我们想要表示既有大小又有位置的圆。例如,在笛卡尔平面上以点 0,0 为中心的半径为 1.0 的圆与以点 1,2 为中心的半径为 1.0 的圆是不同的。为此,我们需要一个新类,我们称之为PlaneCircle

我们希望能够表示圆的位置,而不丢失Circle类的任何现有功能。我们通过将PlaneCircle定义为Circle的子类来实现这一点,这样PlaneCircle就继承了其超类Circle的字段和方法。通过子类化或扩展类来添加功能是面向对象编程范式的核心。

扩展类

在 示例 3-3 中,我们展示了如何将 PlaneCircle 实现为 Circle 类的子类。

示例 3-3. 扩展 Circle 类
public class PlaneCircle extends Circle {
  // We automatically inherit the fields and methods of Circle,
  // so we only have to put the new stuff here.
  // New instance fields that store the center point of the circle
  private final double cx, cy;

  // A new constructor to initialize the new fields
  // It uses a special syntax to invoke the Circle() constructor
  public PlaneCircle(double r, double x, double y) {
    super(r);       // Invoke the constructor of the superclass, Circle()
    this.cx = x;    // Initialize the instance field cx
    this.cy = y;    // Initialize the instance field cy
  }

  public double getCenterX() {
    return cx;
  }

  public double getCenterY() {
    return cy;
  }

  // The area() and circumference() methods are inherited from Circle
  // A new instance method checks whether a point is inside the circle
  // Note that it uses the inherited instance field r
  public boolean isInside(double x, double y) {
    double dx = x - cx, dy = y - cy;             // Distance from center
    double distance = Math.sqrt(dx*dx + dy*dy);  // Pythagorean theorem
    return (distance < r);                       // Returns true or false
  }
}

注意第一行中 示例 3-3 中关键字 extends 的使用。此关键字告诉 Java PlaneCircle 类扩展或子类化了 Circle,这意味着它继承了该类的字段和方法。

isInside() 方法的定义展示了字段继承;此方法使用了 r 字段(由 Circle 类定义),就好像它是在 PlaneCircle 类自身定义的一样。PlaneCircle 还继承了 Circle 的方法。因此,如果我们有一个变量 pc 引用的是 PlaneCircle 对象,我们可以说:

double ratio = pc.circumference() / pc.area();

这就像 area()circumference() 方法是在 PlaneCircle 类自身定义的一样。

子类化的另一个特点是每个 PlaneCircle 对象也是一个完全合法的 Circle 对象。如果 pc 引用的是 PlaneCircle 对象,我们可以将其分配给 Circle 变量,并忘记其额外的定位能力:

// Unit circle at the origin
PlaneCircle pc = new PlaneCircle(1.0, 0.0, 0.0);
Circle c = pc;     // Assigned to a Circle variable without casting

可以将 PlaneCircle 对象分配给 Circle 变量而无需强制转换。正如我们在 第二章 中讨论的那样,这样的转换始终是合法的。存储在 Circle 变量 c 中的值仍然是有效的 PlaneCircle 对象,但是编译器无法确定这一点,因此不允许我们在没有强制转换的情况下进行相反的(缩小范围)转换:

// Narrowing conversions require a cast (and a runtime check by the VM)
PlaneCircle pc2 = (PlaneCircle) c;
boolean inside = ((PlaneCircle) c).isInside(0.0, 0.0);

这种区别在 “嵌套类型” 中有更详细的介绍,我们在那里讨论了对象的编译时和运行时类型之间的区别。

最终类

当一个类使用 final 修饰符声明时,意味着它不能被扩展或子类化。java.lang.String 就是 final 类的一个例子。声明类为 final 可以防止不需要的类扩展:如果您在 String 对象上调用方法,您知道该方法是由 String 类自身定义的,即使这个 String 是从某个未知外部来源传递给您的。

一般来说,Java 开发者创建的许多类应该是 final 的。仔细考虑是否允许其他(可能未知的)代码扩展您的类是否合理——如果不合理,则通过声明您的类为 final 来禁止此机制。

超类、Object 和类层次结构

在我们的示例中,PlaneCircleCircle 的子类。我们还可以说 CirclePlaneCircle 的超类。类的超类在其 extends 子句中指定,并且一个类可能只有一个直接的超类:

public class PlaneCircle extends Circle { ... }

程序员定义的每个类都有一个超类。如果超类未在 extends 子句中指定,则超类被认为是 java.lang.Object 类。

因此,Object 类对于几个特定原因非常特殊:

  • 这是 Java 中唯一一个没有超类的类。

  • 所有 Java 类(直接或间接)继承Object的方法。

因为每个类(除了Object)都有一个超类,Java 类形成一个类层次结构,可以将其表示为以Object为根的树形结构。

注意

Object没有超类,但每个其他类都恰好有一个超类。子类不能扩展多个超类;详见第四章了解如何使用接口实现类似结果的更多信息。

图 3-1 展示了一个部分类层次结构图,包括我们的CirclePlaneCircle类,以及一些来自 Java API 的标准类。

JN7 0301

图 3-1. 类层次结构图

子类构造函数

再看一下来自示例 3-3 的PlaneCircle()构造函数:

public PlaneCircle(double r, double x, double y) {
  super(r);       // Invoke the constructor of the superclass, Circle()
  this.cx = x;    // Initialize the instance field cx
  this.cy = y;    // Initialize the instance field cy
}

虽然此构造函数显式初始化了由PlaneCircle新定义的cxcy字段,但它依赖于超类Circle()构造函数来初始化类的继承字段。为了调用超类构造函数,我们的构造函数调用了super()

super是 Java 中的保留字。其主要用途之一是从子类构造函数中调用超类的构造函数。这与使用this()从同一类的另一个构造函数中调用构造函数类似。使用super()调用构造函数的限制与使用this()相同:

  • super()只能在构造函数中以这种方式使用。

  • 必须将对超类构造函数的调用作为构造函数中的第一条语句出现,甚至在局部变量声明之前。

传递给super()的参数必须与超类构造函数的参数匹配。如果超类定义了多个构造函数,则可以使用super()来调用其中任何一个,具体取决于传递的参数。

构造函数链和默认构造函数

Java 保证每当创建该类的实例时都会调用该类的构造函数。它还保证每当创建任何子类的实例时都会调用构造函数。为了保证第二点,Java 必须确保每个构造函数调用其超类的构造函数。

因此,如果构造函数中的第一个语句没有显式地使用this()super()调用另一个构造函数,javac编译器会插入调用super()(即调用没有参数的超类构造函数)。如果超类没有一个可见且不带参数的构造函数,这种隐式调用将导致编译错误。

考虑当我们创建PlaneCircle类的新实例时会发生什么:

  1. 首先,调用了PlaneCircle构造函数。

  2. 此构造函数显式调用super(r)来调用Circle构造函数。

  3. 那个Circle()构造函数隐式调用super()来调用其超类Object的构造函数(Object只有一个构造函数)。

  4. 到这一点,我们已经到达了层次结构的顶部,并且构造函数开始运行。

  5. Object构造函数的主体首先运行。

  6. 当它返回时,Circle()构造函数的主体运行。

  7. 最后,当对super(r)的调用返回时,会执行PlaneCircle()构造函数的剩余语句。

所有这些意味着构造函数调用是链式的;每次创建对象时,会调用一系列构造函数,从子类到超类直到Object在类层次结构的根部。

因为超类构造函数总是作为其子类构造函数的第一个语句调用,所以Object构造函数的主体总是首先运行,然后是其子类的构造函数,以及类层次结构直到被实例化的类。

每当调用构造函数时,它可以确保其超类的字段在构造函数开始运行时已被初始化。

默认构造函数

在先前对构造函数链的描述中,有一个遗漏的部分。如果一个构造函数没有调用超类的构造函数,Java 会隐式地这样做。

注意

如果一个类声明时没有构造函数,Java 会隐式地为该类添加一个构造函数。这个默认构造函数什么也不做,只调用超类的构造函数。

例如,如果我们没有为PlaneCircle类声明构造函数,Java 会隐式插入这个构造函数:

public PlaneCircle() { super(); }

声明为public的类具有public构造函数。所有其他类都将获得默认构造函数,该构造函数声明时没有任何可见性修饰符;这样的构造函数具有默认可见性。

一个非常重要的点是,如果一个类声明了带参数的构造函数但没有定义无参数构造函数,那么它的所有子类必须定义构造函数来显式调用具有必要参数的构造函数。

注意

如果您正在创建一个不应公开实例化的public类,请至少声明一个非public构造函数,以防止插入默认的public构造函数。

永远不应该实例化的类(如java.lang.Mathjava.lang.System)应该只定义一个private构造函数。这样的构造函数永远不能从类的外部调用,并且它阻止默认构造函数的自动插入。总体效果是该类永远不会被实例化,因为它不会被类本身实例化,也没有其他类有正确的访问权限。

隐藏超类字段

举个例子,假设我们的PlaneCircle类需要知道圆心与原点(0,0)之间的距离。我们可以添加另一个实例字段来保存这个值:

public double r;

将以下行添加到构造函数会计算字段的值:

this.r = Math.sqrt(cx*cx + cy*cy);  // Pythagorean theorem

但是,请注意;这个新字段rCircle超类中的半径字段r名称相同。当这种情况发生时,我们称PlaneCircle的字段r隐藏Circle的字段r。(当然,这是一个刻意构造的例子:这个新字段应该被称为distanceFromOrigin。)

注意

在你写的代码中,应避免声明名称隐藏超类字段的字段。这几乎总是糟糕代码的标志。

使用PlaneCircle的这个新定义,表达式rthis.r都指的是PlaneCircle的字段。那么,我们如何引用Circle中持有圆的半径的字段r呢?一种特殊的语法使用super关键字:

r        // Refers to the PlaneCircle field
this.r   // Refers to the PlaneCircle field
super.r  // Refers to the Circle field

引用隐藏字段的另一种方法是将this(或任何类的实例)转型为适当的超类,然后访问字段:

((Circle) this).r   // Refers to field r of the Circle class

当你需要引用定义在不是直接超类的类中的隐藏字段时,这种转型技术特别有用。例如,类ABC都定义了一个名为x的字段,并且CB的子类,BA的子类。那么在类C的方法中,你可以如下引用这些不同的字段:

x                // Field x in class C
this.x           // Field x in class C
super.x          // Field x in class B
((B)this).x      // Field x in class B
((A)this).x      // Field x in class A
super.super.x    // Illegal; does not refer to x in class A
注意

你不能使用super.super.x来引用超类的超类中隐藏的字段x。这不是合法的语法。

类似地,如果你有一个类C的实例c,你可以像这样引用三个名为x的字段:

c.x              // Field x of class C
((B)c).x         // Field x of class B
((A)c).x         // Field x of class A

到目前为止,我们一直在讨论实例字段。类字段也可以被隐藏。你可以使用相同的super语法来引用字段的隐藏值,但这从未是必要的,因为你总是可以通过在所需类名前添加来引用类字段。例如,假设PlaneCircle的实现者决定Circle.PI字段的精度不够。她可以定义自己的类字段PI

public static final double PI = 3.14159265358979323846;

现在,PlaneCircle中的代码可以使用这个更精确的值,表达式PIPlaneCircle.PI。它还可以通过表达式super.PICircle.PI引用旧的、不太精确的值。然而,PlaneCircle继承的area()circumference()方法是在Circle类中定义的,因此它们使用Circle.PI的值,即使现在被PlaneCircle.PI隐藏了。

覆盖超类方法

当一个类定义一个与其超类中方法相同名称、返回类型和参数的实例方法时,该方法覆盖了超类的方法。当为该类的对象调用方法时,调用的是该方法的新定义,而不是超类中的旧定义。

提示

覆盖方法的返回类型可以是原始方法返回类型的子类(而不是完全相同的类型)。这被称为协变返回

方法覆盖是面向对象编程中一种重要且有用的技术。PlaneCircle 并没有覆盖 Circle 定义的任何方法,实际上很难想象出一个能够清晰定义覆盖 Circle 方法的好例子。

警告

不要试图用像 Ellipse 这样的类对 Circle 进行子类化——这实际上违反了面向对象开发的核心原则(里斯科夫原则,我们将在本章后面讨论)。

相反,让我们看一个确实使用方法覆盖的不同示例:

public class Car {
    public static final double LITRE_PER_100KM = 8.9;

    protected double topSpeed;

    protected double fuelTankCapacity;

    private int doors;

    public Car(double topSpeed, double fuelTankCapacity, 
               int doors) {
        this.topSpeed = topSpeed;
        this.fuelTankCapacity = fuelTankCapacity;
        this.doors = doors;
    }

    public double getTopSpeed() {
        return topSpeed;
    }

    public int getDoors() {
        return doors;
    }

    public double getFuelTankCapacity() {
        return fuelTankCapacity;
    }

    public double range() {
        return 100 * fuelTankCapacity / LITRE_PER_100KM;
    }
}

这有点复杂,但它将说明覆盖背后的概念。与 Car 类一起,我们还有一个特殊化的类 SportsCar。这有几个区别:它有一个固定大小的燃料箱,只有两门版本。它的最高速度可能比常规形式高得多,但如果最高速度超过 200 公里/小时,则汽车的燃油效率会下降,因此汽车的整体续航开始减少:

public class SportsCar extends Car {

    private double efficiency;

    public SportsCar(double topSpeed) {
        super(topSpeed, 50.0, 2);
        if (topSpeed > 200.0) {
            efficiency = 200.0 / topSpeed;
        } else {
            efficiency = 1.0;
        }
    }

    public double getEfficiency() {
        return efficiency;
    }

    @Override
    public double range() {
        return 100 * fuelTankCapacity * efficiency / LITRE_PER_100KM;
    }

}

即将讨论的方法覆盖仅考虑实例方法。类(也称为静态)方法的行为完全不同,并且它们不能被覆盖。就像字段一样,子类可以隐藏类方法,但不能覆盖它们。正如本章前面所述,始终在类方法调用之前加上定义它的类名是良好的编程风格。如果你认为类名是类方法名称的一部分,那么这两个方法实际上具有不同的名称,因此并没有隐藏任何内容。

注意

SportsCar 的代码示例包括语法结构 @Override。这被称为注解,我们将在第四章中详细介绍这个 Java 语法。

在进一步讨论方法覆盖之前,你应该理解方法覆盖和方法重载之间的区别。正如我们在第二章中讨论的,方法重载指的是在同一个类中定义多个方法,它们具有相同的名称但参数列表不同。

另一方面,当实例方法与其超类中的方法具有相同的名称、返回类型和参数列表时,该方法覆盖其超类中的方法。这两个特性在本质上是非常不同的,所以不要混淆它们。

覆盖并非隐藏

尽管 Java 在许多方面类似地对待类的字段和方法,但方法覆盖与字段隐藏毫不相同。你可以通过将对象强制转换为适当超类的实例来引用隐藏字段,但不能使用此技术调用被覆盖的实例方法。以下代码说明了这一关键区别:

class A {                          // Define a class named A
  int i = 1;                       // An instance field
  int f() { return i; }            // An instance method
  static char g() { return 'A'; }  // A class method
}

class B extends A {                // Define a subclass of A
  int i = 2;                       // Hides field i in class A
  int f() { return -i; }           // Overrides method f in class A
  static char g() { return 'B'; }  // Hides class method g() in class A
}

public class OverrideTest {
  public static void main(String args[]) {
    B b = new B();               // Creates a new object of type B
    System.out.println(b.i);     // Refers to B.i; prints 2
    System.out.println(b.f());   // Refers to B.f(); prints -2
    System.out.println(b.g());   // Refers to B.g(); prints B
    System.out.println(B.g());   // A better way to invoke B.g()

    A a = (A) b;                 // Casts b to an instance of class A
    System.out.println(a.i);     // Now refers to A.i; prints 1
    System.out.println(a.f());   // Still refers to B.f(); prints -2
    System.out.println(a.g());   // Refers to A.g(); prints A
    System.out.println(A.g());   // A better way to invoke A.g()
  }
}

尽管方法覆盖与字段隐藏之间的区别乍看起来令人惊讶,稍加思考就能明确其目的。

假设我们正在操作一堆CarSportsCar对象,并将它们存储在类型为Car[]的数组中。我们可以这样做是因为SportsCarCar的子类,因此所有SportsCar对象都是合法的Car对象。

当我们遍历这个数组的元素时,我们不需要知道或关心元素实际上是Car还是SportsCar。然而,我们非常关心的是,在调用数组中任何元素的range()方法时,计算出正确的值。换句话说,当对象实际上是跑车时,我们不希望使用汽车范围的公式!

我们真正想要的是,我们正在计算其范围的对象“做正确的事情”——Car对象使用它们自己定义的计算范围的方式,而SportsCar对象使用适合它们的定义。

在这种情况下看,Java 处理方法重写与字段隐藏的方式有所不同并不奇怪。

虚拟方法查找

如果我们有一个Car[]数组,其中包含CarSportsCar对象,javac如何知道对数组中的任何给定项调用range()方法时是调用Car类还是SportsCar类的range()方法?实际上,源代码编译器无法在编译时知道这一点。

相反,javac创建的字节码在运行时使用虚拟方法查找。当解释器运行代码时,它查找适合数组中每个对象调用的适当range()方法。也就是说,当解释器解释表达式o.range()时,它检查变量o引用的对象的实际运行时类型,然后找到适合该类型的range()方法。

注意

某些其他语言(如 C#或 C++)默认不会进行虚拟查找,而是通过virtual关键字明确指定,以允许子类覆盖方法。

这是方法重写概念的另一种方式,我们之前讨论过。如果使用o的静态类型关联的range()方法版本,而没有运行时(也称为虚拟)查找,则重写将无法正常工作。

对于 Java 实例方法,默认为虚拟方法查找。有关编译时和运行时类型及其对虚拟方法查找的影响的更多详细信息,请参见第四章。

调用重写方法

我们已经看到了方法重写和字段隐藏之间的重要差异。尽管如此,调用重写方法的 Java 语法与访问隐藏字段的语法非常相似:两者都使用super关键字。以下代码说明了这一点:

class A {
  int i = 1;            // An instance field hidden by subclass B
  int f() { return i; } // An instance method overridden by subclass B
}

class B extends A {
  int i;                    // This field hides i in A
  int f() {                 // This method overrides f() in A
    i = super.i + 1;        // It can retrieve A.i like this
    return super.f() + i;   // It can invoke A.f() like this
  }
}

请注意,当您使用super引用隐藏字段时,它等同于将this强制转换为超类类型并通过它访问字段。然而,使用super来调用覆盖的方法并不同于转换this引用。换句话说,在前面的代码中,表达式super.f()((A)this).f()不同。

当解释器使用super语法调用实例方法时,执行一种修改后的虚拟方法查找。首先步骤,与常规虚拟方法查找一样,确定通过其调用方法的对象的实际类。通常,运行时搜索适当方法定义的过程将从这个类开始。然而,当使用super语法调用方法时,搜索从类的超类开始。如果超类直接实现该方法,则调用该方法的版本。如果超类继承该方法,则调用继承的版本。

请注意,super关键字调用的是方法的最直接覆盖版本。假设类A有一个子类BB有一个子类C,并且这三个类都定义了相同的方法f()。方法C.f()可以使用super.f()调用它直接覆盖的方法B.f()。但是没有办法让C.f()直接调用A.f()super.super.f()不是合法的 Java 语法。当然,如果C.f()调用B.f(),那么假设B.f()可能也会调用A.f()是合理的。

这种链式调用在覆盖方法中比较常见:这是一种在不完全替换方法的情况下增强方法行为的方式。

注意

不要混淆使用super来调用覆盖方法和在构造函数中用super()调用超类构造函数的方法。尽管它们都使用相同的关键字,但这是两种完全不同的语法。特别是,您可以在覆盖类中的任何地方使用super来调用覆盖方法,但只能在构造函数的第一个语句中使用super()来调用超类构造函数。

还要记住,super只能在覆盖它的类内部使用来调用覆盖的方法。给定对SportsCar对象e的引用,程序无法使用e来调用Car类定义的range()方法。

封闭类

到目前为止,我们只遇到了类继承的两种可能性:

  • 无限制的子类化能力(这是默认情况,没有与之关联的关键字)

  • 使用final关键字应用于类完全防止子类化

截至 Java 17,有第三种可能性,由sealed关键字控制。封闭类是一种可以被特定已知类别的类继承的类。声明封闭类时,使用permits关键字列举可能的子类列表(这些子类必须与基类在同一个包中)。示例如下:

// In Shape.java
public abstract sealed class Shape permits Circle, Triangle {
    // ...
}

// In Circle.java
public final class Circle extends Shape {
    // ...
}

// In Triangle.java
public final class Triangle extends Shape {
    // ...
}

在此示例中,我们将CircleTriangle都声明为final,因此它们无法进一步被子类化。这是一种常见的做法,但也可以将封闭类的子类型声明为sealed(具有进一步允许的子类集合),或者声明为non-sealed,恢复 Java 默认的无限制子类化行为。

这最后一个选项(non-sealed)不应该没有非常充分的理由而使用,因为这将首先破坏使用类封闭的语义目的。因此,尝试对封闭类进行子类化而不提供三个封闭修饰符之一会在编译时产生错误:这里没有默认行为。

注意

non-sealed的引入是 Java 中首次出现的连字符关键字的例子。

在此示例中,我们使用了一个抽象的封闭基类(Shape)。这并非总是必要的,但通常是一个良好的实践,因为这意味着我们遇到的类型实例必定是“叶子类型”之一,例如CircleTriangle。我们稍后将在本章更详细地介绍抽象类。

虽然封闭类是 Java 17 的新特性,但我们预计许多开发者会快速采用它们——连同记录(records)一起,它们代表了 Java 面向对象视角中一个“遗漏的概念”。在我们讨论与封闭类型相关的面向对象设计方面时,我们将在第五章中详细阐述此点。

数据隐藏和封装

我们从描述类为数据和方法的集合开始了本章。到目前为止我们尚未讨论的最重要的面向对象技术之一是隐藏类内部数据,并且只通过方法来访问数据。

这种技术被称为封装,因为它将数据(和内部方法)安全地包含在类的“胶囊”内部,只有信任的用户(即类的方法)可以访问它。

为什么要这样做?最重要的原因是隐藏类的内部实现细节。如果阻止程序员依赖这些细节,您可以安全地修改实现,而不必担心会破坏使用该类的现有代码。

注意

您应该始终封装您的代码。几乎不可能推理和确保没有良好封装的代码的正确性,特别是在多线程环境中(而且基本上所有 Java 程序都是多线程的)。

封装的另一个原因是保护你的类免受意外或故意的愚蠢。一个类通常包含许多相互依赖的字段,这些字段必须保持一致的状态。如果允许程序员(包括你自己)直接操作这些字段,他们可能只改变一个字段而不改变重要的相关字段,导致类处于不一致的状态。相反,如果程序员必须调用一个方法来改变字段,那么这个方法可以确保做一切必要的工作以保持状态的一致性。同样,如果一个类定义了某些只供内部使用的方法,隐藏这些方法可以防止类的用户调用它们。

这里还有另一种封装的思考方式:当一个类的所有数据都被隐藏时,方法定义了对该类对象可以执行的唯一可能操作。

一旦你仔细测试和调试了你的方法,你可以确信类将按预期工作。另一方面,如果类的所有字段都可以直接操作,那么你需要测试的可能性就变得难以管理。

注意

这个想法可以得出一个非常强有力的结论,正如我们将在“安全的 Java 编程”中看到的那样,当我们讨论 Java 程序的安全性(这与 Java 编程语言的类型安全性概念不同)。

隐藏类的字段和方法的其他次要原因包括:

  • 对外可见的内部字段和方法只会混淆 API。保持可见字段的最小化可以使你的类更整洁,因此更容易使用和理解。

  • 如果一个方法对你的类的用户可见,你必须对其进行文档化。相反,隐藏它可以节省时间和精力。

访问控制

Java 定义了访问控制规则,可以限制类的成员在类外部的使用。在本章的许多示例中,你已经看到public修饰符在字段和方法声明中的使用。这个public关键字,以及protectedprivate(还有一个特殊的关键字),是访问 控制 修饰符;它们指定了字段或方法的访问规则。

模块访问

Java 9 中最大的变化之一是 Java 平台模块的到来。这些模块是比单个包更大的代码组合,旨在作为未来部署重用代码的方式。由于 Java 经常用于大型应用和环境中,模块的到来应该使得构建和管理企业代码库变得更加容易。

模块技术是一个高级的主题,如果 Java 是你接触的第一种编程语言之一,你不应该在没有获得一些语言熟练程度之前尝试学习它。在第十二章中提供了模块的介绍,我们推迟讨论模块的访问控制影响直到那时。

包访问

包内基础上的访问控制并非直接属于核心 Java 语言的一部分,而是由模块机制提供。在正常的编程过程中,访问控制通常是在类及其成员级别上进行的。

注意

已加载的包始终对同一包内定义的代码可访问。它是否对来自其他包的代码可访问取决于包在主机系统上的部署方式。例如,当包含构成包的类文件存储在一个目录中时,用户必须对该目录及其内部文件具有读取权限才能访问该包。

类的访问

默认情况下,顶级类在其定义的包内可访问。然而,如果顶级类声明为public,则可在任何地方访问。

提示

在第四章,我们将会遇到嵌套类。这些是可以定义为其他类成员的类。由于这些内部类是类的成员,它们遵守成员访问控制规则。

成员访问

类的成员在类的主体内部始终是可访问的。默认情况下,成员在定义类的包中也是可访问的。这种默认访问级别通常称为包访问

这是四种可能的访问级别之一。其他三个级别由publicprotectedprivate修饰符定义。以下是使用这些修饰符的一些示例代码:

public class Laundromat {    // People can use this class.
  private Laundry[] dirty;   // They cannot use this internal field,
  public void wash() { ... } // but they can use these public methods
  public void dry() { ... }  // to manipulate the internal field.
  // A subclass might want to tweak this field
  protected int temperature;
}

这些访问规则适用于类的成员:

  • 类的所有字段和方法都可以在类本身的主体内部使用。

  • 如果类的成员使用public修饰符声明,则意味着该成员可以在包含类可访问的任何地方访问。这是最不严格的访问控制类型。

  • 如果类的成员声明为private,则该成员除了在类本身内部外不可访问。这是最严格的访问控制类型。

  • 如果类的成员声明为protected,则在包内的所有类(与默认包访问权限相同),以及在该类的任何子类主体内,无论该子类定义在哪个包中,都可以访问该成员。

  • 如果类的成员没有使用这些修饰符声明,则具有默认访问权限(有时称为访问权限),可以在同一包内定义的所有类的代码中访问,但在包外部无法访问。

警告

默认访问权限比protected更为严格,因为默认访问权限不允许子类在包外访问。

protected访问需要更多阐述。假设类A声明了一个protected字段x,并且由一个类B扩展,该类在一个不同的包中定义(这一点很重要)。类B继承了protected字段x,并且它的代码可以访问当前B实例中的该字段,或者代码可以引用的任何其他B实例中的该字段。但这并不意味着类B的代码可以开始读取任意A实例的受保护字段。

让我们在代码中详细查看这个语言细节。以下是A的定义:

package javanut8.ch03;

public class A {
    protected final String name;

    public A(String named) {
        name = named;
    }

    public String getName() {
        return name;
    }
}

这是B的定义:

package javanut8.ch03.different;

import javanut8.ch03.A;

public class B extends A {

    public B(String named) {
        super(named);
    }

    @Override
    public String getName() {
        return "B: " + name;
    }
}
注意

Java 包不会“嵌套”,所以javanut8.ch03.different只是与javanut8.ch03不同的包,它不包含在其中,也没有任何关联。

但是,如果我们尝试将此新方法添加到B,我们将收到编译错误,因为B的实例没有访问任意A实例的权限:

    public String examine(A a) {
        return "B sees: " + a.name;
    }

如果我们将方法更改为:

    public String examine(B b) {
        return "B sees another B: " + b.name;
    }

那么编译器会很高兴,因为相同类型的实例总是可以看到彼此的protected字段。当然,如果BA在同一个包中,那么B的任何实例都可以读取A的任何实例的protected字段,因为protected字段对同一包中的每个类都是可见的。

访问控制和继承

Java 规范说明了:

  • 子类继承其可访问的超类的所有实例字段和实例方法。

  • 如果子类在与超类相同的包中定义,它将继承所有非private实例字段和方法。

  • 如果子类在不同的包中定义,它将继承所有protectedpublic实例字段和方法。

  • private字段和方法永远不会被继承;类字段或类方法也不会被继承。

  • 构造函数不会被继承(而是链接,如本章前面描述的)。

但是,一些程序员对子类不继承其超类的不可访问字段和方法的说法感到困惑。让我们明确一下:每个子类的实例都包含其中完整的超类实例,包括所有私有字段和方法。当您创建子类的实例时,为超类定义的所有private字段都分配了内存;但是,子类不能直接访问这些字段。

这种存在可能无法访问的成员似乎与类的成员始终在类体内部可访问的说法相冲突。为了消除这种混淆,我们定义“继承成员”为指那些可访问的超类成员。

那么关于成员可访问性的正确说明是:“所有继承成员和所有在此类中定义的成员都是可访问的。”这种说法的另一种表述方式是:

  • 类继承其超类的所有实例字段和实例方法(但不包括构造函数)。

  • 类的主体始终可以访问其自身声明的所有字段和方法。它还可以访问其从超类继承的 可访问 字段和成员。

成员访问摘要

我们在 Table 3-1 中总结了成员访问规则。

Table 3-1. 类成员访问性

成员可见性
可访问 公共 保护的 默认 私有
--- --- --- --- ---
定义类
同一包中的类
不同包中的子类
非子类不同包

Java 程序的哪些部分应使用每种可见性修饰符有一些普遍遵循的规则。即使是初学者的 Java 程序员也应遵循这些规则:

  • 仅对类的公共 API 的方法和常量使用 publicpublic 字段的唯一可接受用法是常量或不可变对象,并且它们必须同时声明为 final

  • 对于大多数程序员不使用但可能对创建子类的任何人有兴趣的字段和方法,请使用 protected

注:

protected 成员在技术上是类的导出 API 的一部分。它们必须有文档说明,且不能更改,否则可能会破坏依赖它们的代码。

  • 对于内部实现细节但被同一包中的协作类使用的字段和方法,请使用默认的包可见性。

  • 对于仅在类内部使用且应在其他任何地方隐藏的字段和方法,请使用 private

如果不确定是否使用 protected、包或 private 访问性,请从 private 开始。如果这太严格,您可以稍微放松访问限制(或在字段的情况下提供访问器方法)。

这对于设计 API 尤为重要,因为增加访问限制不是向后兼容的更改,可能会破坏依赖于这些成员访问的代码。

数据访问方法

Circle的例子中,我们声明了圆的半径为public字段。Circle类是一个可能合理地保持该字段公开访问的类;它是一个足够简单的类,没有字段之间的依赖关系。另一方面,我们当前的类实现允许一个Circle对象具有负半径,而具有负半径的圆根本不应该存在。然而,只要半径存储在一个public字段中,任何程序员都可以将字段设置为任何他们想要的值,无论多么不合理。唯一的解决方案是限制程序员对字段的直接访问,并定义public方法提供对字段的间接访问。提供读取和写入字段的public方法并不等同于使字段本身成为public。关键区别在于方法可以执行错误检查。

例如,我们可能希望阻止具有负半径的Circle对象——这些显然是不明智的,但我们当前的实现不会禁止这样做。在例子 3-4 中,我们展示了如何修改Circle的定义以防止这种情况发生。

这个Circle的版本将r字段声明为protected,并定义了名为getRadius()setRadius()的访问器方法来读取和写入字段值,同时强制限制半径值为负。因为r字段是protected的,所以它可以直接(并且更有效地)被子类访问。

例子 3-4. 使用数据隐藏和封装的 Circle 类
package javanut8.ch03.shapes; // Specify a package for the class

public class Circle {     // The class is still public
    // This is a generally useful constant, so we keep it public
    public static final double PI = 3.14159;

    protected double r;     // Radius is hidden but visible to subclasses

    // A method to enforce the restriction on the radius
    // Subclasses may be interested in this implementation detail
    protected void checkRadius(double radius) {
        if (radius < 0.0)
            throw new IllegalArgumentException("illegal negative radius");
    }

    // The non-default constructor
    public Circle(double r) {
        checkRadius(r);
        this.r = r;
    }

    // Public data accessor methods
    public double getRadius() { return r; }
    public void setRadius(double r) {
        checkRadius(r);
        this.r = r;
    }

    // Methods to operate on the instance field
    public double area() { return PI * r * r; }
    public double circumference() { return 2 * PI * r; }
}

我们在一个名为javanut8.ch03.shapes的包中定义了Circle类;rprotected的,因此javanut8.ch03.shapes包中的任何其他类都可以直接访问该字段并按照他们喜欢的方式设置它。这里的假设是javanut8.ch03.shapes包中的所有类都由同一作者或紧密合作的一组作者编写,并且这些类之间相互信任,不滥用对彼此实现细节的特权级别。

最后,强制限制半径值为负数的代码本身放置在一个protected方法checkRadius()中。虽然Circle类的用户无法调用此方法,但类的子类可以调用它,甚至可以覆盖它,如果他们想要改变对半径的限制。

注意

一组常见(但较旧的)Java 约定之一——称为 Java Bean 约定——是数据访问器方法以前缀“get”和“set”开头。但是,如果被访问的字段是boolean类型,则get()方法可以被一个以“is”开头的等效方法替换——一个名为readableboolean字段的访问器方法通常被称为isReadable()而不是getReadable()

抽象类和方法

在 示例 3-4 中,我们声明了我们的 Circle 类是属于名为 shapes 的包的一部分。假设我们计划实现许多形状类:RectangleSquareHexagonTriangle 等等。我们可以给这些形状类我们的两个基本的 area()circumference() 方法。现在,为了便于使用形状数组,如果我们的所有形状类都有一个公共的超类 Shape 就会很有帮助。如果我们以这种方式结构化我们的类层次结构,那么无论形状对象表示的实际形状类型如何,都可以将其分配给类型为 Shape 的变量、字段或数组元素。我们希望 Shape 类封装所有我们的形状共有的特征(例如 area()circumference() 方法)。但是我们的通用 Shape 类并不表示任何实际的形状,因此它不能定义有用的方法实现。Java 使用 抽象方法 处理这种情况。

Java 允许我们通过声明带有 abstract 修饰符的方法来定义一个方法而不实现它。一个 abstract 方法没有主体;它只是有一个签名定义,后面跟着一个分号。² 关于 abstract 方法和包含它们的 abstract 类的规则如下:

  • 任何带有 abstract 方法的类自动成为 abstract,必须声明为这样。不这样做会导致编译错误。

  • 抽象类不能被实例化。

  • 抽象类的子类只有在覆盖了其超类的每个 abstract 方法并为所有方法提供实现(即方法体)时才能被实例化。这样的类通常被称为 具体 子类,以强调它不是 abstract

  • 如果抽象类的子类没有实现它继承的所有 abstract 方法,那么该子类本身就是 abstract 的,必须声明为这样。

  • static, private, and final 方法不能是 abstract,因为这些类型的方法不能被子类覆盖。同样,一个 final 类不能包含任何 abstract 方法。

  • 即使一个类实际上没有任何 abstract 方法,也可以声明该类为 abstract。声明这样一个类为 abstract 表示该实现在某种程度上是不完整的,是为一个或多个子类提供实现的超类。这样的类不能被实例化。

注意

我们将在 第十一章 中遇到的 ClassLoader 类是一个没有任何抽象方法的抽象类的好例子。

让我们看一个示例,说明这些规则是如何工作的。如果我们定义Shape类具有abstract area()circumference()方法,那么Shape的任何子类都必须提供这些方法的实现,以便可以实例化它。换句话说,每个Shape对象都保证具有这些方法的实现。示例 3-5 展示了这是如何工作的。它定义了一个abstractShape类和它的一个具体子类。您还应该想象,从示例 3-4 中的Circle类已被修改为extends Shape

示例 3-5. 一个抽象类和具体子类
public abstract class Shape {
    public abstract double area();            // Abstract methods: note
    public abstract double circumference();   // semicolon instead of body.
}

public class Rectangle extends Shape {
    // Instance data
    protected double w, h;

    // Constructor 
    public Rectangle(double w, double h) {               
        this.w = w;  this.h = h;
    }

    // Accessor methods
    public double getWidth() { return w; }               
    public double getHeight() { return h; }

    // Implementation of abstract methods 
    public double area() { return w*h; }                 
    public double circumference() { return 2*(w + h); }   
}

每个Shape类中的abstract方法在其括号后面都有一个分号。这种类型的方法声明没有花括号,并且没有定义方法体。

请注意,我们本可以将Shape类声明为密封类,但故意选择不这样做。这样其他程序员就可以定义自己的形状类作为Shape的新子类,如果他们希望的话。

使用示例 3-5 中定义的类,我们现在可以编写如下代码:

Shape[] shapes = new Shape[3];        // Create an array to hold shapes
shapes[0] = new Circle(2.0);          // Fill in the array
shapes[1] = new Rectangle(1.0, 3.0);
shapes[2] = new Rectangle(4.0, 2.0);

double totalArea = 0;
for(int i = 0; i < shapes.length; i++) {
    totalArea += shapes[i].area();   // Compute the area of the shapes
}

这里要注意两个重要点:

  • Shape的子类可以分配给Shape数组的元素。不需要转换。这是引用类型扩展的另一个示例(见第二章讨论)。

  • 您可以为任何Shape对象调用area()circumference()方法,即使Shape类没有定义这些方法的具体实现。在这种情况下,通过虚拟查找找到要调用的方法,我们之前已经遇到过。在我们的例子中,这意味着圆的面积是使用Circle定义的方法计算的,而矩形的面积是使用Rectangle定义的方法计算的。

引用类型转换

可以在不同的引用类型之间转换对象引用。与原始类型一样,引用类型转换可以是宽化转换(编译器自动允许)或需要转型的窄化转换(可能需要运行时检查)。为了理解引用类型转换,您需要了解引用类型形成的层次结构,通常称为类层次结构

每个 Java 引用类型扩展其他某个类型,称为其超类。类型继承其超类的字段和方法,然后定义其自己的额外字段和方法。一个名为Object的特殊类作为 Java 类层次结构的根。所有 Java 类直接或间接扩展ObjectObject类定义了一些特殊方法,这些方法被所有对象继承(或重写)。

我们前面讨论的预定义String类和Account类都扩展自Object。因此,我们可以说所有String对象也是Object对象。我们还可以说所有Account对象也是Object对象。然而,反之不成立。我们不能说每个Object都是String,因为正如我们刚刚看到的,一些Object对象是Account对象。

通过对类层次结构的简单理解,我们可以定义引用类型转换的规则:

  • 一个对象引用不能转换为不相关的类型。例如,即使使用强制类型转换运算符,Java 编译器也不允许你将String转换为Account

  • 可以将对象引用转换为其超类或任何祖先类的类型。这是一种扩展转换,因此不需要强制类型转换。例如,String值可以赋给类型为Object的变量,或者传递给期望Object参数的方法。

注意

实际上不会执行任何转换;该对象仅被视为超类的实例。这是 Liskov 替换原则的一个简单形式,以巴巴拉·利斯科夫命名,她首次明确表述了该原则。

  • 可以将对象引用转换为子类的类型,但这是一种窄化转换,需要进行强制类型转换。Java 编译器暂时允许这种类型的转换,但 Java 解释器在运行时会检查其是否有效。只有在基于程序逻辑确信对象实际上是子类的实例时,才可以将引用转换为子类的类型。如果不是,则解释器会抛出ClassCastException异常。例如,如果我们将String引用赋给类型为Object的变量,稍后可以将该变量的值强制转换回String类型:

    Object o = "string";    // Widening conversion from String
                            // to Object later in the program...
    String s = (String) o;  // Narrowing conversion from Object
                            // to String
    

数组是对象,并遵循其自己的一些转换规则。首先,通过扩展转换,任何数组都可以转换为Object值。通过强制类型转换,可以将这样的对象值转换回数组。这里是一个例子:

// Widening conversion from array to Object
Object o = new int[] {1,2,3};
// Later in the program...

int[] a = (int[]) o;      // Narrowing conversion back to array type

除了将数组转换为对象外,还可以将数组转换为另一种数组类型,如果两个数组的“基本类型”是可以自身转换的引用类型。例如:

// Here is an array of strings.
String[] strings = new String[] { "hi", "there" };
// A widening conversion to CharSequence[] is allowed because String
// can be widened to CharSequence
CharSequence[] sequences = strings;
// The narrowing conversion back to String[] requires a cast.
strings = (String[]) sequences;

// This is an array of arrays of strings
String[][] s = new String[][] { strings };
// It cannot be converted to CharSequence[] because String[] cannot be
// converted to CharSequence: the number of dimensions don't match

sequences = s;  // This line will not compile
// s can be converted to Object or Object[], because all array types
// (including String[] and String[][]) can be converted to Object.
Object[] objects = s;

请注意,这些数组转换规则仅适用于对象数组和数组数组。原始类型数组不能转换为任何其他数组类型,即使原始基本类型可以转换:

// Can't convert int[] to double[] even though
// int can be widened to double
// This line causes a compilation error
double[] data = new int[] {1,2,3};
// This line is legal, however,
// because int[] can be converted to Object
Object[] objects = new int[][] {{1,2},{3,4}};

修饰符概要

如我们所见,类、接口及其成员可以使用一个或多个修饰符进行声明,如publicstaticfinal等关键字。让我们通过列出 Java 修饰符来结束这一章节,解释它们可以修饰哪些 Java 构造,并解释它们的作用。详见表格 3-2;你也可以参考“类和记录概述”、“字段声明语法”和“方法修饰符”。

表格 3-2. Java 修饰符

修饰符 用于 含义
abstract 该类不能被实例化,可能包含未实现的方法。
接口 所有接口都是abstract的。在接口声明中,修饰符是可选的。
方法 方法没有提供方法体;方法体由子类提供。方法签名后跟一个分号。包含该方法的类也必须是abstract的。
default 方法 此接口方法的实现是可选的。接口为那些选择不实现它的类提供了默认实现。详见第四章。
final 该类不能被子类化。
方法 该方法不能被重写。
字段 该字段不能更改其值。static final字段是编译时常量。
变量 局部变量、方法参数或异常参数不能更改其值。
native 方法 该方法以某种平台相关的方式实现(通常为 C 语言)。没有方法体;方法签名后跟一个分号。
non-sealed 该类从一个密封类型继承,但它本身具有无限制的开放继承。
<无>(包) public类只能在其包内访问。
接口 public接口只能在其包内访问。
成员 privateprotectedpublic的成员具有包可见性,只能在其包内访问。
private 成员 该成员只能在定义它的类内部访问。
protected 成员 该成员只能在定义它的包内和子类中访问。
public 该类在其包的任何地方都是可访问的。
接口 接口在其包的任何地方都是可访问的。
成员 该成员在其类所在的任何地方都是可访问的。
sealed 该类只能被已知子类列表(由permits子句给出)继承。如果缺少permits子句,则该类只能被同一编译单元内的类继承。
static 声明为static的内部类是一个顶级类,不与包含类的成员相关联。详见第四章。
方法 static方法是类方法。它不会传递隐式的this对象引用。可以通过类名调用它。
字段 static字段是类字段。无论创建了多少类实例,只有一个字段实例。可以通过类名访问它。
初始化器 初始化器在类加载时运行,而不是在创建实例时运行。
strictfp 类的所有方法都隐式地采用strictfp
方法 方法中进行的所有浮点计算必须严格遵循 IEEE 754 标准。特别是,所有值,包括中间结果,必须表达为 IEEE 的floatdouble值,不能利用本地平台浮点格式或硬件提供的任何额外精度或范围。这个修饰符极少被使用,在 Java 17 中已经是一个无操作,因为该语言现在总是严格遵循标准。
synchronized 方法 该方法对类或实例进行非原子性修改,因此必须确保两个线程不能同时修改类或实例。对于static方法,在执行方法之前会获取类的锁。对于非static方法,则会获取特定对象实例的锁。详见第五章了解更多细节。
transient 字段 此字段不是对象的持久状态的一部分,不应与对象一起序列化。与对象序列化一起使用;请参阅java.io.ObjectOutputStream
volatile 字段 此字段可以被非同步线程访问,因此不能对它进行某些优化。这个修饰符有时可以作为synchronized的替代。详见第五章了解更多细节。

摘要

Java,像所有面向对象的语言一样,有其自己的面向对象工作模型。在本章中,我们已经了解了这个模型的基本概念:静态类型、字段、方法、继承、访问控制、封装、重载、覆盖和密封。要成为一名熟练的 Java 程序员,您需要掌握所有这些概念,并理解它们之间的关系以及它们的交互方式。

接下来的两章将进一步探讨这些特性,并理解这些基本面向对象设计的方面如何直接源自这一相对较小的基本概念集合。

¹ 后面我们还会介绍默认的包可见性。

² 在 Java 中,abstract 方法类似于 C++ 中的纯虚函数(即声明为 = 0 的虚函数)。在 C++ 中,包含纯虚函数的类称为抽象类,不能被实例化。Java 中包含 abstract 方法的类也是如此。

第四章:Java 类型系统

在本章中,我们超越了基本的面向对象编程与类,进入了有效使用 Java 类型系统所需的其他概念。

注意

静态类型语言是指变量具有明确的类型,并且将不兼容类型的值分配给变量是编译时错误的语言。仅在运行时检查类型兼容性的语言称为动态类型语言。

Java 是一个典型的静态类型语言的例子。JavaScript 则是一个动态类型语言的例子,允许任何变量存储任何类型的值。

Java 类型系统不仅涉及类和基本类型,还包括与类的基本概念相关的其他种类的引用类型,但它们在某些方面有所不同,并且通常由javac或 JVM 特殊处理。

我们已经见过数组和类,Java 最广泛使用的两种引用类型之一。本章开始讨论另一种非常重要的引用类型——接口。然后我们进入讨论 Java 的泛型,它在 Java 类型系统中扮演重要角色。掌握了这些主题后,我们可以讨论 Java 中编译时和运行时类型的差异。

为了完整展示 Java 参考类型的全貌,我们看看特殊类型的类和接口——被称为枚举注解。我们在这一章节结束时讨论lambda 表达式嵌套类型,然后回顾增强类型推断如何使 Java 的非显式类型可供程序员使用。

让我们开始看一下接口——除了类之外 Java 最重要的参考类型之一,也是 Java 类型系统其余部分的关键构建块。

接口

在第三章中,我们介绍了继承的概念。我们也看到 Java 类只能继承自一个类。这对我们想要构建的面向对象程序类型是一个相当大的限制。Java 的设计者们知道这一点,但他们也希望确保 Java 的面向对象编程方法比如 C++更简单且不易出错。

他们选择的解决方案是引入接口的概念到 Java 中。像类一样,接口定义了一个新的引用类型。顾名思义,接口旨在表示 API——因此它提供了一个类型的描述以及实现该 API 的类必须提供的方法(及其签名)的描述。

通常情况下,Java 接口不提供描述的方法的任何实现代码。这些方法被认为是强制性的——希望实现接口的任何类必须提供这些方法的实现。

但是,接口可能希望标记一些 API 方法是可选的,如果选择不实现它们,则实现类不需要实现它们。这是通过default关键字完成的,并且接口必须提供这些可选方法的实现,这将被任何选择不实现它们的实现类使用。

注意

在 Java 8 中引入了接口中可选方法的能力。在任何早期版本中都不可用。请参阅“记录和接口”以获取有关可选(也称为默认)方法如何工作的完整描述。

不可能直接实例化一个接口并创建一个接口类型的成员。相反,类必须实现接口以提供必要的方法体。

实现类的任何实例都类定义的类型和接口定义的类型兼容。这意味着实例可以在需要类类型或接口类型的任何代码中替换。这扩展了 Liskov 原则,如在“引用类型转换”中所见。

另一种说法是,如果两个对象不共享相同的类或超类,它们仍然可以与相同接口类型兼容,如果两个对象都是实现接口的类的实例的话。

定义接口

接口定义有些类似于类定义,其中所有(必需的)方法都是抽象的,关键字class已被替换为interface。例如,以下代码显示了名为Centered的接口的定义(例如,一个Shape类,比如在第三章中定义的那些,如果想要允许其中心坐标被设置和查询,则可能实现该接口):

interface Centered {
  void setCenter(double x, double y);
  double getCenterX();
  double getCenterY();
}

对接口成员施加了一些限制:

  • 所有接口的强制方法都是隐式abstract的,必须用分号代替方法体。abstract修饰符是允许的,但按照惯例通常省略。

  • 接口定义了一个公共 API。按照惯例,接口成员隐式地是public的,并且通常省略不必要的public修饰符。

  • 一个接口不能定义任何实例字段。字段是一个实现细节,而接口是一个规范,不是一个实现。在接口定义中唯一允许的字段是声明为staticfinal的常量。

  • 一个接口不能被实例化,因此它不定义构造函数。

  • 接口可以包含嵌套类型。任何此类类型都隐式地是publicstatic的。请参阅“嵌套类型”以获取嵌套类型的完整描述。

  • 自 Java 8 起,接口可以包含静态方法。Java 的早期版本不允许这样做,这被广泛认为是 Java 语言设计上的一个缺陷。

  • 从 Java 9 开始,接口可以包含private方法。这些方法的使用案例有限,但是随着接口结构的其他变化,禁止它们似乎是随意的。

  • 在接口中尝试定义protected方法是编译时错误。

扩展接口

接口可以扩展其他接口,并且与类定义类似,接口定义通过包含一个extends子句来指示这一点。当一个接口扩展另一个接口时,它继承其超接口的所有方法和常量,并且可以定义新的方法和常量。然而,不同于类,接口定义的extends子句可以包含多个超接口。例如,以下是一些扩展其他接口的接口:

interface Positionable extends Centered {
  void setUpperRightCorner(double x, double y);
  double getUpperRightX();
  double getUpperRightY();
}
interface Transformable extends Scalable, Translatable, Rotatable {}
interface SuperShape extends Positionable, Transformable {}

如果一个接口扩展了多个接口,则继承每个接口的所有方法和常量,并且可以定义自己的额外方法和常量。实现这种接口的类必须实现直接由接口定义的抽象方法,以及从所有超接口继承的所有抽象方法。

实现一个接口

就像类使用extends指定其超类一样,它可以使用implements来命名一个或多个它支持的接口。implements关键字可以出现在类声明中,在extends子句之后。它应该跟随一个逗号分隔的接口列表,该类实现这些接口。

当一个类在其implements子句中声明一个接口时,它表明它为该接口的每个强制方法提供了一个实现(即一个主体)。如果一个类实现了一个接口但没有为每个强制接口方法提供实现,它会从接口继承这些未实现的abstract方法,并且必须自己声明为abstract。如果一个类实现了多个接口,则必须实现每个接口的每个强制方法(或声明为abstract)。

以下代码显示了如何定义一个CenteredRectangle类,它扩展了第三章中的Rectangle类,并实现我们的Centered接口:

public class CenteredRectangle extends Rectangle implements Centered {
  // New instance fields
  private double cx, cy;

  // A constructor
  public CenteredRectangle(double cx, double cy, double w, double h) {
    super(w, h);
    this.cx = cx;
    this.cy = cy;
  }

  // We inherit all the methods of Rectangle but must
  // provide implementations of all the Centered methods.
  public void setCenter(double x, double y) { cx = x; cy = y; }
  public double getCenterX() { return cx; }
  public double getCenterY() { return cy; }
}

假设我们实现了CenteredCircleCenteredSquare,就像我们实现了这个CenteredRectangle类一样。每个类都扩展了Shape,所以类的实例可以被视为Shape类的实例,正如我们之前看到的那样。因为每个类实现了Centered接口,实例也可以被视为该类型的实例。以下代码演示了对象如何可以是类类型和接口类型的成员:

Shape[] shapes = new Shape[3];      // Create an array to hold shapes

// Create some centered shapes, and store them in the Shape[]
// No cast necessary: these are all compatible assignments
shapes[0] = new CenteredCircle(1.0, 1.0, 1.0);
shapes[1] = new CenteredSquare(2.5, 2, 3);
shapes[2] = new CenteredRectangle(2.3, 4.5, 3, 4);

// Compute average area of the shapes and
// average distance from the origin
double totalArea = 0;
double totalDistance = 0;
for(int i = 0; i < shapes.length; i = i + 1) {
  totalArea += shapes[i].area();   // Compute the area of the shapes

  // Be careful, in general, the use of instanceof to determine the
  // runtime type of an object is quite often an indication of a
  // problem with the design
  if (shapes[i] instanceof Centered) { // The shape is a Centered shape
    // Note the required cast from Shape to Centered (no cast would
    // be required to go from CenteredSquare to Centered, however).
    Centered c = (Centered) shapes[i];

    double cx = c.getCenterX();    // Get coordinates of the center
    double cy = c.getCenterY();    // Compute distance from origin
    totalDistance += Math.sqrt(cx*cx + cy*cy);
  }
}
System.out.println("Average area: " + totalArea/shapes.length);
System.out.println("Average distance: " + totalDistance/shapes.length);
注意

接口是 Java 中的数据类型,就像类一样。当一个类实现一个接口时,该类的实例可以赋值给接口类型的变量。

不要解释此示例为必须将CenteredRectangle对象分配给Centered变量,然后才能调用setCenter()方法或者分配给Shape变量然后再调用area()方法。相反,因为CenteredRectangle类定义了setCenter()方法并从其Rectangle超类继承了area()方法,所以你总是可以调用这些方法。

正如我们可以通过查看字节码(例如,使用javap工具我们将在第十三章遇到)所见,JVM 根据持有形状的局部变量类型是CenteredRectangle还是Centered而稍有不同地调用setCenter()方法,但这在大多数情况下在编写 Java 代码时并不重要。

记录和接口

记录(records)作为类的一种特例,可以像任何其他类一样实现接口。记录的主体必须包含接口所有强制方法的实现代码,并且可以包含接口任意默认方法的覆盖实现。

让我们看一个例子,应用于我们在上一章中遇到的Point记录。给定一个定义如下的接口:

interface Translatable {
    Translatable deltaX(double dx);
    Translatable deltaY(double dy);
    Translatable delta(double dx, double dy);
}

那么我们可以像这样更新Point类型:

public record Point(double x, double y) implements Translatable {
    public Translatable deltaX(double dx) {
        return delta(dx, 0.0);
    }

    public Translatable deltaY(double dy) {
        return delta(0.0, dy);
    }

    public Translatable delta(double dx, double dy) {
        return new Point(x + dx, y + dy);
    }
}

注意,因为记录是不可变的,所以不可能在原地修改实例,因此,如果我们需要一个修改过的对象,我们必须显式地创建一个并返回它。这意味着并非每个接口都适合由记录类型实现。

密封接口

我们在上一章中遇到了sealed关键字,应用于类。它也可以应用于接口,如下所示:

sealed interface Rotate90 permits Circle, Rectangle {
    void clockwise();
    void antiClockwise();
}

这个密封接口表示一个形状可以旋转 90 度的能力。注意声明中还包含一个permits子句,指定允许实现这个接口的唯一类——在这种情况下,只有CircleRectangle类,以简化问题。Circle被修改如下:

public final class Circle extends Shape implements Rotate90 {
    // ...

    @Override
    public void clockwise() {
        // No-op, circles are rotation-invariant
    }

    @Override
    public void antiClockwise() {
        // No-op, circles are rotation-invariant
    }

    // ...
}

Rectangle已被修改如下:

public final class Rectangle extends Shape implements Rotate90 {
    // ...

    @Override
    public void clockwise() {
        // Swap width and height
        double tmp = w;
        w = h;
        h = tmp;
    }

    @Override
    public void antiClockwise() {
        // Swap width and height
        double tmp = w;
        w = h;
        h = tmp;
    }

    // ...
}

目前为止,我们不希望处理其他形状具有旋转行为的复杂性,因此我们限制接口只能由两种最简单的情况实现:圆和矩形。

密封接口与记录之间还有一个有趣的互动,我们将在第五章讨论。

默认方法

从 Java 8 开始,可以在接口中声明包含实现的方法。在本节中,我们将讨论这些方法,应该将它们理解为接口所代表的 API 中的可选方法——通常称为默认方法。让我们首先看看为什么我们需要首先的默认机制。

向后兼容性

Java 平台一直非常关注向后兼容性。这意味着为早期版本的平台编写(甚至编译)的代码必须继续在后续版本的平台上运行。这一原则使得开发团队对其 JDK 或 Java 运行时环境(JRE)的升级具有高度信心,不会破坏当前正常运行的应用程序。

向后兼容性是 Java 平台的一大优势,但为了实现它,平台对其施加了一些限制。其中之一是接口不能在新版本中添加新的强制性方法。

例如,假设我们想要更新Positionable接口以添加底部左下角边界点的能力:

public interface Positionable extends Centered {
  void setUpperRightCorner(double x, double y);
  double getUpperRightX();
  double getUpperRightY();
  void setLowerLeftCorner(double x, double y);
  double getLowerLeftX();
  double getLowerLeftY();
}

通过这个新的定义,如果我们试图将这个新接口与为旧接口开发的代码一起使用,那就不会起作用,因为现有代码缺少强制性方法setLowerLeftCorner()getLowerLeftX()getLowerLeftY()

注意

您可以很容易地在自己的代码中看到这种效果。编译一个依赖于接口的类文件。然后向接口添加一个新的强制性方法,并尝试使用新版本的接口与旧类文件一起运行程序。您应该会看到程序因为NoClassDefError而崩溃。

这个限制是 Java 8 设计者的一个关注点——因为他们的目标之一是能够升级核心 Java 集合库并引入使用 lambda 表达式的方法。

要解决这个问题,需要一个新的机制,基本上允许接口通过添加新方法来演变,而不会破坏向后兼容性。

实现默认方法

在不破坏向后兼容性的情况下向接口添加新方法,需要为接口的旧实现提供一些实现,以便它们可以继续工作。这个机制就是default方法,在 JDK 8 中首次添加到平台中。

注意

可以向任何接口添加默认方法(有时称为可选方法)。这必须包括一个内联的实现,称为默认实现,它写在接口定义中。

默认方法的基本行为是:

  • 实现类可以(但不需要)实现默认方法。

  • 如果实现类实现了默认方法,则使用类中的实现。

  • 如果找不到其他实现,则使用默认实现。

一个例子是sort()方法。它已经在 JDK 8 中被添加到接口java.util.List中,并且定义如下:

// The <E> syntax is Java's way of writing a generic type - see
// the next section for full details. If you aren't familiar with
// generics, just ignore that syntax for now.
interface List<E> {
  // Other members omitted

  public default void sort(Comparator<? super E> c) {
    Collections.<E>sort(this, c);
  }
}

因此,从 Java 8 开始,任何实现List的对象都有一个sort()实例方法,可用于使用适当的Comparator对列表进行排序。由于返回类型是void,我们可能期望这是一种原地排序,事实也是如此。

默认方法的一个结果是,在实现多个接口时,可能有两个或更多接口包含具有完全相同名称和签名的默认方法。

例如:

interface Vocal {
  default void call() {
    System.out.println("Hello!");
  }
}

interface Caller {
  default void call() {
    Switchboard.placeCall(this);
  }
}

public class Person implements Vocal, Caller {
  // ... which default is used?
}

这两个接口对call()的默认语义有很大的不同,并且可能导致潜在的实现冲突——冲突的默认方法。在 Java 8 之前的版本中,这种情况是不可能发生的,因为语言只允许单一实现继承。引入默认方法意味着 Java 现在允许一种有限的多继承形式(但仅限于方法实现)。Java 仍然不允许(也没有计划添加)对象状态的多重继承。

提示

在一些其他语言中,特别是 C++,这个问题被称为菱形继承

默认方法有一组简单的规则,以帮助解决任何潜在的歧义:

  • 如果一个类以导致默认方法实现潜在冲突的方式实现了多个接口,则实现类必须重写冲突方法并提供所需的定义。

  • 提供了语法,允许实现类简单地调用接口的默认方法之一,如果需要的话:

public class Person implements Vocal, Caller {

    public void call() {
        // Can do our own thing
        // or delegate to either interface
        // e.g.,
        // Vocal.super.call();
        // or
        // Caller.super.call();
    }
}

由于默认方法的设计,存在一个轻微但无法避免的使用问题,可能在演化中的接口出现方法冲突时出现。考虑一个字节码版本为 51.0(Java 7)的类实现了两个接口AB,它们的版本号分别为a.0b.0。由于 Java 7 中没有默认方法,这个类将正常工作。然而,如果稍后其中一个或两个接口采用了冲突方法的默认实现,则可能会发生编译时断裂。

例如,如果版本a.1A中引入了一个默认方法,那么当使用新版本的依赖运行时,实现类将采用这个实现。如果版本b.1现在也引入了相同的方法,就会造成冲突:

  • 如果B将方法引入为强制性的(即抽象的)方法,则实现类将继续工作——无论是在编译时还是在运行时。

  • 如果B将方法引入为默认方法,则这是不安全的,实现类将在编译时和运行时均失败。

这个小问题很大程度上是一个边界情况,在实践中支付的代价很小,以便在语言中拥有可用的默认方法。

在使用默认方法时,我们应该意识到我们可以在默认方法内部执行的操作集合有一定的限制:

  • 调用接口公共 API 中的另一个方法(无论是强制性还是可选的);此类方法的某些实现是可用的。

  • 在接口上调用私有方法(Java 9 及以上)。

  • 调用静态方法,无论是在接口上还是在其他地方定义的。

  • 使用this引用(例如,作为方法调用的参数)。

这些限制的最大教训是,即使有了默认方法,Java 接口仍然缺乏有意义的状态;我们不能在接口内部修改或存储状态。

默认方法对 Java 实践者处理面向对象编程的方式产生了深远影响。与 lambda 表达式的兴起相结合,它们颠覆了许多以前的 Java 编码约定;我们将在下一章中详细讨论这一点。

标记接口

有时候定义一个完全空的接口是很有用的。一个类可以通过在其 implements 子句中简单地命名该接口来实现它,而无需实现任何方法。在这种情况下,该类的任何实例也将成为该接口的有效实例,并且可以将其强制类型转换为该类型。Java 代码可以使用 instanceof 运算符检查对象是否是接口的实例,因此这种技术是提供关于对象的附加信息的有用方式。它可以被看作是为类提供额外的辅助类型信息。

提示

标记接口的使用远不如以前广泛。由于注解(我们将很快见到)在传递扩展类型信息时具有更大的灵活性,它们已经大多取代了标记接口。

接口 java.util.RandomAccess 就是一个标记接口的示例:java.util.List 实现使用这个接口来表明它们提供对列表元素的快速随机访问。例如,ArrayList 实现了 RandomAccess,而 LinkedList 则没有。关心随机访问操作性能的算法可以像这样测试 RandomAccess

// Before sorting the elements of a long arbitrary list, we may want
// to make sure that the list allows fast random access.  If not,
// it may be quicker to make a random-access copy of the list before
// sorting it. Note that this is not necessary when using
// java.util.Collections.sort().
List l = ...;  // Some arbitrary list we're given
if (l.size() > 2 && !(l instanceof RandomAccess)) {
    l = new ArrayList(l);
}
sortListInPlace(l);

正如我们稍后将看到的,Java 的类型系统与类型名称紧密耦合,这被称为 命名类型 的方法。标记接口就是一个很好的例子:除了名称外,它什么都没有。

Java 泛型

Java 平台的一个显著优势是其提供的标准库。它提供了大量有用的功能,特别是常见数据结构的健壮实现。这些实现相对简单易用,并且有很好的文档支持。这些库被称为 Java 集合框架,我们将在 第八章 中详细讨论它们。如需更全面的信息,请参阅 Maurice Naftalin 和 Philip Wadler 的书 Java Generics and Collections(O’Reilly)。

尽管最早期的集合版本仍然非常有用,但它们存在一个相当重要的限制:数据结构(有时称为 容器)基本上会隐藏在其中存储的数据类型。

注意

数据隐藏和封装是面向对象编程的重要原则,但在这种情况下,容器的不透明性给开发者带来了许多问题。

让我们通过展示问题并展示泛型类型的引入是如何解决它并使 Java 开发人员的生活变得更加轻松的。

泛型介绍

如果我们想要构建一个Shape实例的集合,我们可以使用List来持有它们,就像这样:

List shapes = new ArrayList();   // Create a List to hold shapes

// Create some centered shapes, and store them in the list
shapes.add(new CenteredCircle(1.0, 1.0, 1.0));
// This is legal Java-but is a very bad design choice
shapes.add(new CenteredSquare(2.5, 2, 3));

// List::get() returns Object, so to get back a
// CenteredCircle we must cast
CenteredCircle c = (CenteredCircle)shapes.get(0);

// Next line causes a runtime failure
CenteredCircle c = (CenteredCircle)shapes.get(1);

这段代码的一个问题源于需要执行强制类型转换以获得可用形式的形状对象——List不知道它包含的对象类型。不仅如此,而且实际上可以将不同类型的对象放入同一个容器中,一切工作正常,直到使用非法强制转换并导致程序崩溃。

我们真正想要的是一种形式的List,它能理解它包含的类型。然后,javac可以在将非法参数传递给List的方法时检测到并导致编译错误,而不是推迟到运行时处理。

注意

所有元素类型相同的集合称为同类,而可能包含不同类型元素的集合称为异类(有时称为“神秘肉集合”)。

Java 提供了一个简单的语法来适应同类集合。要指示一个类型是一个容器,它持有另一个引用类型的实例,我们将容器持有的有效载荷类型括在尖括号内:

// Create a List-of-CenteredCircle
List<CenteredCircle> shapes = new ArrayList<CenteredCircle>();

// Create some centered shapes, and store them in the list
shapes.add(new CenteredCircle(1.0, 1.0, 1.0));

// Next line will cause a compilation error
shapes.add(new CenteredSquare(2.5, 2, 3));

// List<CenteredCircle>::get() returns a CenteredCircle, no cast needed
CenteredCircle c = shapes.get(0);

这种语法确保了编译器在运行时之前能够捕获大类不安全的代码。这当然是静态类型系统的整体目标——利用编译时的知识尽可能地帮助消除运行时问题。

结果类型结合了一个封装的容器类型和一个有效载荷类型,通常称为泛型类型,并且它们声明如下:

interface Box<T> {
  void box(T t);
  T unbox();
}

这表明Box接口是一个通用的构造,可以容纳任何类型的有效载荷。它本身并不是一个完整的接口——它更像是一个整个接口家族的通用描述,每个接口都可以用T的类型替代。

泛型类型和类型参数

我们已经看到如何使用泛型类型通过利用编译时知识来提供增强的程序安全性,以防止简单的类型错误。在这一节中,让我们更深入地探讨泛型类型的属性。

<T>这种语法有一个特殊的名称,类型参数,另一个泛型类型的名称是参数化类型。这应该传达出容器类型(例如,List)由另一种类型(有效载荷类型)参数化的意义。当我们写一个类型像Map<String, Integer>时,我们正在为类型参数指定具体的值。

当我们定义具有参数的类型时,需要以不假设类型参数的方式进行。因此,List 类型以泛型方式声明为 List<E>,而类型参数 E 在整个过程中都作为占位符,用于当程序员使用 List 数据结构时使用的实际类型的载荷。

提示

类型参数总是代表引用类型。不可能使用原始类型作为类型参数的值。

类型参数可以像真实类型一样在方法的签名和主体中使用,例如:

interface List<E> extends Collection<E> {
  boolean add(E e);
  E get(int index);
  // other methods omitted
}

注意类型参数 E 如何用作返回类型和方法参数的参数。我们不假设载荷类型具有任何特定属性,只做一致性的基本假设——我们放入的类型是后来取出的相同类型。

这种增强实际上引入了一种新类型到 Java 的类型系统中。通过将容器类型与类型参数的值组合,我们正在创建新类型。

Diamond 语法

当我们创建泛型类型的实例时,赋值语句的右侧重复了类型参数的值。通常情况下这是不必要的,因为编译器可以推断出类型参数的值。在现代版本的 Java 中,我们可以在所谓的 diamond 语法 中省略重复的类型值。

让我们通过重新编写我们早期的一个例子来看如何使用 diamond 语法:

// Create a List-of-CenteredCircle using diamond syntax
List<CenteredCircle> shapes = new ArrayList<>();

这是赋值语句冗长性的小幅改进——我们设法节省了一些键入字符。在本章稍后讨论 Lambda 表达式时,我们将返回类型推断的话题。

类型擦除

在 “默认方法” 中,我们讨论了 Java 平台对向后兼容性的强烈偏好。Java 5 中引入泛型就是向新语言特性的向后兼容性的另一个例子。

中心问题是如何设计一个类型系统,允许旧的非泛型集合类与新的泛型集合类并存。设计决策是通过使用强制类型转换来实现这一点:

List someThings = getSomeThings();
// Unsafe cast, but we know that the
// contents of someThings are really strings
List<String> myStrings = (List<String>)someThings;

这意味着 ListList<String> 作为类型是兼容的,至少在某种程度上是这样。Java 通过 类型擦除 实现了这种兼容性。这意味着泛型类型参数只在编译时可见——它们被 javac 剥离并不反映在字节码中。¹

警告

非泛型类型 List 通常被称为 原始类型。对于现在是泛型的类型来说,使用原始形式仍然是完全合法的 Java。然而,这几乎总是质量较差代码的标志。

类型擦除机制导致 javac 和 JVM 看到的类型系统存在差异——我们将在“编译和运行时类型”中全面讨论这一点。

类型擦除还禁止了一些其他本来看起来合法的定义。在这段代码中,我们想要计算两种稍有不同的数据结构中表示的订单:

// Won't compile
interface OrderCounter {
  // Name maps to list of order numbers
  int totalOrders(Map<String, List<String>> orders);

  // Name maps to total orders made so far
  int totalOrders(Map<String, Integer> orders);
}

这段代码看起来像是完全合法的 Java 代码,但它将无法编译。问题在于,尽管这两个方法看起来像是普通的重载方法,但在类型擦除后,两个方法的签名变成了相同的:

  int totalOrders(Map);

类型擦除后,容器的原始类型仅剩下 Map。运行时无法通过签名区分这些方法,因此语言规范将此语法视为非法。

有界类型参数

考虑一个简单的泛型盒子:

public class Box<T> {
    protected T value;

    public void box(T t) {
        value = t;
    }

    public T unbox() {
        T t = value;
        value = null;
        return t;
    }
}

这是一个有用的抽象,但假设我们想要一个只能容纳数字的限制形式的盒子。Java 允许我们通过对类型参数设置 边界 来实现这一点。这是限制可以用作类型参数值的类型的能力,例如:

public class NumberBox<T extends Number> extends Box<T> {
    public int intValue() {
        return value.intValue();
    }
}

类型边界 T extends Number 确保 T 只能被兼容于 Number 类型的类型所替代。因此,编译器知道 value 必定有一个可用的 intValue() 方法。

注意

请注意,由于 value 字段具有受保护的访问权限,在子类中可以直接访问它。

如果我们试图用类型参数的无效值实例化 NumberBox,结果将是编译错误:

NumberBox<Integer> ni = new NumberBox<>(); // This compiles fine

NumberBox<Object> no = new NumberBox<>(); // Won't compile

初学者应尽量避免使用原始类型。即使是有经验的 Java 程序员在使用时也可能遇到问题。例如,在使用原始类型处理类型边界时,类型边界可能会被规避,但这样做会使代码容易受到运行时异常的影响:

// Compiles
NumberBox n = new NumberBox();
// This is very dangerous
n.box(new Object());
// Runtime error
System.out.println(n.intValue());

调用 intValue() 失败,并抛出 java.lang.ClassCastException —— 因为在调用方法之前,javac 已经对 value 插入了一个无条件的强制类型转换到 Number

通常情况下,类型边界可用于编写更好的泛型代码和库。通过实践,一些相当复杂的结构可以被构建,例如:

public class ComparingBox<T extends Comparable<T>> extends Box<T>
                            implements Comparable<ComparingBox<T>> {
    @Override
    public int compareTo(ComparingBox<T> o) {
        if (value == null)
            return o.value == null ? 0 : -1;
        return value.compareTo(o.value);
    }
}

这个定义可能看起来令人生畏,但 ComparingBox 实际上只是包含一个 Comparable 值的 Box。该类型还通过比较两个盒子的内容,扩展了对 ComparingBox 类型本身的比较操作。

引入协变性

Java 泛型的设计包含了一个古老问题的解决方案。在 Java 的早期版本中,甚至在引入集合库之前,语言就不得不面对一个深层次的类型系统设计问题。

简单来说,问题是这样的:

字符串数组是否应与类型为对象数组的变量兼容?

换句话说,这段代码应该合法吗?

String[] words = {"Hello World!"};
Object[] objects = words;

如果没有这一点,那么甚至像 Arrays::sort 这样的简单方法都将非常难以以预期的方式编写:

Arrays.sort(Object[] a);

方法声明仅适用于类型为 Object[] 而不适用于任何其他数组类型。由于这些复杂性的结果,Java 语言标准的第一个版本确定了以下结论:

如果类型 C 的值可以分配给类型 P 的变量,则类型 C[] 的值可以分配给类型 P[] 的变量。

也就是说,数组的赋值语法 随其所持有的基本类型变化,或者说数组是 协变的

这个设计决定相当不幸,因为它导致了立即的负面后果:

String[] words = {"Hello", "World!"};
Object[] objects = words;

// Oh, dear, runtime error
objects[0] = new Integer(42);

objects[0] 的赋值企图将 Integer 存储到期望保存 String 的存储空间中。这显然是行不通的,并将抛出 ArrayStoreException

警告

协变数组的实用性导致它们在平台的早期阶段被视为一种必要之恶,尽管这种功能暴露了静态类型系统中的漏洞。

然而,对现代开源代码库的更多研究表明,数组协变极少被使用,且是语言的误功能。² 写新代码时应避免使用它。

在考虑 Java 平台上泛型行为时,可以提出一个非常相似的问题:“List<String> 是否是 List<Object> 的子类型?”也就是说,我们可以这样写:

// Is this legal?
List<Object> objects = new ArrayList<String>();

乍一看,这似乎是完全合理的——StringObject 的子类,因此我们知道集合中的任何 String 元素也是有效的 Object

然而,请考虑以下代码(只是将数组协变代码转换为使用 List):

// Is this legal?
List<Object> objects = new ArrayList<String>();

// What do we do about this?
objects.add(new Object());

由于 objects 的类型声明为 List<Object>,因此将 Object 实例添加到其中应该是合法的。然而,由于实际实例持有字符串,尝试添加 Object 将不兼容,因此在运行时会失败。

这将与数组的情况没有任何变化,因此解决方案是意识到虽然这是合法的:

Object o = new String("X");

这并不意味着泛型容器类型的相应语句也是正确的,因此:

// Won't compile
List<Object> objects = new ArrayList<String>();

另一种说法是,List<String> 不是 List<Object> 的子类型,或者泛型类型是 不变的,而不是 协变的。在讨论有界通配符时,我们将详细说明这一点。

通配符

例如 ArrayList<T> 这样的参数化类型是不 可实例化 的;我们无法创建它们的实例。这是因为 <T> 只是一个类型参数,仅仅是一个真实类型的占位符。只有当我们为类型参数提供一个具体值(例如 ArrayList<String>)时,类型才变得完全形成,我们才能创建该类型的对象。

如果我们希望在编译时不知道要使用的类型,则会出现问题。幸运的是,Java 类型系统能够容纳这一概念。它通过具有显式概念的未知类型来实现。这表示为<?>。这是 Java 的通配符类型的最简单示例。

我们可以编写涉及未知类型的表达式:

ArrayList<?> mysteryList = unknownList();
Object o = mysteryList.get(0);

这是完全有效的 Java 代码:ArrayList<?>是一个变量可以拥有的完整类型,不像ArrayList<T>。我们不知道mysteryList的载荷类型的任何信息,但这对我们的代码可能并非问题。

例如,当我们从mysteryList中获取一个项时,它具有完全未知的类型。但是,我们可以确保该对象可以赋值给Object,因为泛型类型参数的所有有效值都是引用类型,而所有引用值都可以赋给类型为Object的变量。

另一方面,当我们使用未知类型时,它在用户代码中有一些使用限制。例如,以下代码将无法编译:

// Won't compile
mysteryList.add(new Object());

这样做的原因很简单:我们不知道mysteryList的载荷类型是什么!例如,如果mysteryList实际上是ArrayList<String>的实例,那么我们不希望能够将Object放入其中。

我们知道我们始终可以将null插入到容器中,因为我们知道null是任何引用类型的可能值。这并不是很有用,因此,Java 语言规范还排除了使用未知类型作为载荷来实例化容器对象的可能性,例如:

// Won't compile
List<?> unknowns = new ArrayList<?>();

未知类型可能看起来用处不大,但它的一个非常重要的用途是作为解决协变问题的起点。如果我们想要为容器使用子类型关系,我们可以使用未知类型,例如:

// Perfectly legal
List<?> objects = new ArrayList<String>();

这意味着List<String>实际上是List<?>的子类型 — 虽然当我们使用像前面这样的赋值时,我们会丢失一些类型信息。例如,objects.get()的返回类型现在实际上是Object

注意

对于类型参数T的任何值,List<?>不是类型List<T>的子类型。

未知类型有时会使开发人员感到困惑,引发类似以下问题:“为什么不只使用Object而不是未知类型?”然而,正如我们所见,需要在泛型类型之间具有子类型关系,这实质上要求我们具有未知类型的概念。

有界通配符

实际上,Java 的通配符类型不仅限于未知类型,还有有界通配符的概念。

这些用于描述大部分未知类型的继承层次结构 —— 有效地使类似“我不知道这种类型的任何信息,但它必须实现List”的语句成立。

在类型参数中,这将被写成? extends List。这为程序员提供了一个有用的生命线。不再局限于完全未知的类型,他们知道至少类型边界的功能是可用的。

警告

不管约束类型是类类型还是接口类型,都始终使用extends关键字。

这是一个被称为类型变异的概念的示例,它是关于容器类型之间继承如何与它们的载荷类型之间的继承关系相关的一般理论。

类型协变性

这意味着容器类型之间的关系与载荷类型的关系相同。这是用extends关键字来表达的。

类型逆变性

这意味着容器类型之间的关系与载荷类型的关系相反。这是用super关键字来表达的。

当讨论容器类型时,这些想法往往会出现。例如,如果Cat扩展Pet,那么List<Cat>List<? extends Pet>的子类型,因此:

List<Cat> cats = new ArrayList<Cat>();
List<? extends Pet> pets = cats;

然而,这与数组情况不同,因为类型安全性是以以下方式维护的:

pets.add(new Cat()); // won't compile
pets.add(new Pet()); // won't compile
cats.add(new Cat());

编译器不能证明由pets指向的存储能够存储Cat,因此拒绝调用add()。然而,由于cats明确指向一个Cat对象列表,因此将新对象添加到列表中是可以接受的。

因此,非常普遍地看到这些类型的通用构造与作为载荷类型的生产者或消费者的类型一起使用。

例如,当List充当Pet对象的生产者时,适当的关键字是extends

Pet p = pets.get(0);

请注意,对于生产者情况,载荷类型出现为生产者方法的返回类型。

对于作为某种类型实例的消费者的容器类型,我们将使用super关键字,并且我们期望在方法参数的类型中看到载荷类型。

请注意

这在由 Joshua Bloch 提出的生产者扩展,消费者超级(PECS)原则中得到了具体表述。

正如我们将在第八章中讨论的那样,协变性和逆变性都出现在 Java 集合中。它们主要存在是为了确保通用性“做正确的事情”,并且表现出不会让开发人员感到惊讶的方式。

通用方法

通用方法是能够接受任何引用类型实例的方法。

让我们看一个例子。在 Java 中,逗号用于允许在单行中进行多个声明(通常称为复合声明)。其他语言,如 Javascript 或 C,具有更一般的逗号运算符。JS 的逗号运算符 (,) 会评估其提供的两个表达式(从左到右),并返回最后一个表达式的值。其目的是创建一个复合表达式,在这个表达式中,多个表达式被评估,而复合表达式的值是其成员表达式的最右边的值。请注意,与短路逻辑运算符不同,逗号评估表达式的任何副作用总是会触发。

Java 的逗号比设计时更为严格。这是因为其他语言中的逗号可能导致一些非常难以理解的代码,并且可能是错误的一个极好的来源。然而,如果我们确实想要模仿其他语言中逗号运算符的行为,我们可以通过创建一个泛型方法来实现:

// Note that this class is not generic
public class Utils {
  public static <T> T comma(T a, T b) {
    return b;
  }
}

调用 Utils.comma() 方法将导致计算表达式 ab 的值,并在方法调用之前触发任何副作用,这是我们想要的行为。

然而,需要注意的是,即使在方法的定义中使用了类型参数,其定义所在的类(Utils)也不是泛型的。相反,我们看到使用了新的语法来指示可以自由使用该方法,并且返回类型与参数类型相同。

让我们再看一个例子,来自 Java 集合库。在 ArrayList 类中,我们可以找到一个方法,用于从 ArrayList 实例创建一个新的数组对象:

@SuppressWarnings("unchecked")
public <T> T[] toArray(T[] a) {
    if (a.length < size)
        // Make a new array of a's runtime type, but my contents:
        return (T[]) Arrays.copyOf(elementData, size, a.getClass());
    System.arraycopy(elementData, 0, a, 0, size);
    if (a.length > size)
        a[size] = null;
    return a;
}

这个方法使用低级的 arraycopy() 方法来执行实际的工作。

注意

如果我们查看 ArrayList 的类定义,我们可以看到它是一个泛型类,但类型参数是 <E>,而不是 <T>,而且类型参数 <E>toArray() 的定义中根本不出现。

toArray() 方法提供了集合与 Java 原始数组之间桥接 API 的一半。API 的另一半——从数组到集合的转换——涉及一些额外的细微差别,我们将在第八章中讨论。

编译和运行时类型

考虑一个代码示例:

List<String> l = new ArrayList<>();
System.out.println(l);

我们可以提出以下问题:l 的类型是什么?这个问题的答案取决于我们是在编译时(即 javac 看到的类型)还是在运行时(作为 JVM 看到的类型)考虑 l

javac 将会将 l 的类型视为 List-of-String,并使用该类型信息来仔细检查语法错误,比如尝试对非法类型进行 add() 操作。

JVM 将会把 l 视为 ArrayList 类型的对象,正如我们可以从 println() 语句看到的那样。由于类型擦除,l 的运行时类型是原始类型。

因此,编译时和运行时类型略有不同。稍微奇怪的是,在某些方面,运行时类型既比编译时类型更具体,也比编译时类型更不具体。

运行时类型比编译时类型更不具体,因为有关有效载荷类型的类型信息已经消失 —— 它已经被擦除,并且产生的运行时类型只是一个原始类型。

编译时类型比运行时类型更不具体,因为我们不知道 l 将具体是什么类型;我们只知道它将是与 List 兼容的类型。

编译时和运行时类型的差异有时会让新手 Java 程序员感到困惑,但这种区别很快会被视为语言工作中的正常部分。

使用和设计泛型类型

在使用 Java 泛型时,按照两种不同的理解层次进行思考可能会有所帮助:

实践者

从实践者的角度来看,需要使用现有的通用库并构建一些相当简单的通用类。在这个层次上,开发人员还应该理解类型擦除的基础知识,因为几个 Java 语法特性如果没有对泛型运行时处理的意识,可能会感到困惑。

设计师

使用泛型的新库的设计者需要更多地了解泛型的能力。规范中还包括一些更难理解的部分,包括对通配符的完全理解,以及高级主题,例如“捕获”错误消息。

Java 泛型是语言规范中最复杂的部分之一,具有许多潜在的边界情况。并非每个开发人员在首次接触 Java 类型系统的时候都需要完全理解这部分内容。

枚举和注解

我们已经见过记录(records),但 Java 还有额外的专用类和接口形式,用于在类型系统中扮演特定角色。它们被称为枚举类型注解类型,通常简称为枚举注解

枚举

枚举是类的一种变体,具有有限的功能和特定的语义意义,即该类型仅具有少量可能的允许值。

例如,假设我们想定义一个类型来表示红、绿和蓝的主要颜色,并且我们希望这些是该类型的唯一可能值。我们可以使用 enum 关键字来实现:

public enum PrimaryColor {
  // The ; is not required at the end of the list of instances
  RED, GREEN, BLUE
}

然后,类型 PrimaryColor 的唯一可用实例可以作为静态字段进行引用:PrimaryColor.REDPrimaryColor.GREENPrimaryColor.BLUE

在其他语言(如 C++)中,通过使用常量整数来实现枚举类型的角色,但 Java 的方法提供了更好的类型安全性和更大的灵活性。

由于枚举是专门的类,因此枚举可以具有成员字段和方法。如果它们具有主体(由字段或方法组成),则需要在实例列表的末尾使用分号,并且枚举常量列表必须在方法和字段之前。

例如,假设我们想要一个枚举来包含标准扑克牌的花色。我们可以通过使用一个带有参数值的枚举来实现这一点,像这样:

public enum Suit {
    // ; at the end of list required for enums with parameters
    HEART('♥'),
    CLUB('♣'),
    DIAMOND('♦'),
    SPADE('♠');

    private char symbol;
    private char letter;

    public char getSymbol() {
        return symbol;
    }

    public char getLetter() {
        return letter;
    }

    private Suit(char symbol) {
        this.symbol = symbol;
        this.letter = switch (symbol) {
            case '♥' -> 'H';
            case '♣' -> 'C';
            case '♦' -> 'D';
            case '♠' -> 'S';
            default -> throw new RuntimeException("Illegal:" + symbol);
        };
    }
}

参数(在此示例中仅有一个)被传递给构造函数以创建单个枚举实例。由于枚举实例由 Java 运行时创建,并且不能从外部实例化,因此构造函数被声明为私有。

枚举具有一些特殊属性:

  • 所有(隐式地)扩展java.lang.Enum

  • 可能不是泛型的

  • 可能实现接口

  • 不能被扩展

  • 如果所有枚举值提供实现主体,则可能只有抽象方法。

  • 可能不能直接通过new实例化

注释

注释是一种特殊的接口,顾名思义,用于注释 Java 程序的某些部分。

例如,考虑@Override注释。您可能在一些早期的示例中的一些方法上看到了它,并且可能提出了以下问题:它是做什么的?

简短的,也许令人惊讶的答案是它根本不起作用。

简短的答案是,与所有注释一样,它没有直接影响,而是作为有关所注释的方法的附加信息;在这种情况下,它表示一个方法覆盖了超类方法。

这对编译器和集成开发环境(IDE)来说是一个有用的提示——如果开发人员拼错了一个意图作为超类方法的覆盖的方法的名称,那么在拼错的方法上存在@Override注释(它不覆盖任何内容)会提示编译器有些地方不对。

注释,如最初的构思,不应改变程序语义;相反,它们应该提供可选的元数据。在最严格的意义上,这意味着它们不应影响程序执行,而应该只为编译器和其他执行前阶段提供信息。

在实践中,现代 Java 应用程序广泛使用注释,现在包括许多使用情况,实际上使带注释的类在没有额外运行时支持的情况下无法使用。

例如,带有诸如@Inject@Test@Autowired之类的注释的类在适当的容器之外实际上不能使用。因此,很难说此类注释不违反“没有语义意义”规则。

平台在java.lang中定义了一小部分基本注释。最初的集合是@Override@Deprecated@SuppressWarnings,它们用于指示方法已被覆盖、已过时或生成了一些应该被抑制的编译器警告。

Java 7 中通过 @SafeVarargs 扩展了这些(为可变参数方法提供了扩展警告抑制),Java 8 中通过 @FunctionalInterface 进行了扩展。

这个最后的注解表明一个接口可以作为 lambda 表达式的目标使用 — 虽然不是强制的,我们会看到它是一个有用的标记注解。

注解与常规接口相比具有一些特殊的属性:

  • 所有(隐式)扩展 java.lang.annotation.Annotation

  • 不得是泛型的

  • 不得扩展任何其他接口

  • 只能定义零参数方法

  • 不得定义抛出异常的方法

  • 对方法的返回类型有限制

  • 方法可以有默认返回值

在实践中,注解通常没有太多功能,而是一个相当简单的语言概念。

定义自定义注解

为了在自己的代码中使用定义的自定义注解类型并不困难。@interface 关键字允许开发人员定义新的注解类型,与使用 classinterface 类似。

注意

自定义注解的关键在于使用“元注解”。这些特殊的注解出现在新(自定义)注解类型的定义中。

元注解定义在 java.lang.annotation 中,并允许开发人员指定新注解类型的使用策略,以及编译器和运行时的处理方式。

创建新注解类型时需要两个主要的元注解 — @Target@Retention,它们都接受枚举表示的值。

@Target 元注解指示新的自定义注解可以在 Java 源代码中合法放置的位置。枚举 ElementType 包含可能的取值 TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE, ANNOTATION_TYPE, PACKAGE, TYPE_PARAMETER, 和 TYPE_USE,注解可以指示它们意图在一个或多个位置使用。

另一个元注解是 @Retention,它指示 javac 和 Java 运行时如何处理自定义注解类型。它可以有三个值,由枚举 RetentionPolicy 表示:

SOURCE

具有此保留策略的注解在编译时由 javac 丢弃。

CLASS

这意味着注解将出现在类文件中,但不一定可以通过 JVM 在运行时访问。这很少使用,但有时在对 JVM 字节码进行离线分析的工具中可见。

RUNTIME

这表明注解将可供用户代码在运行时访问(通过反射)。

让我们看一个例子,一个简单的注解称为 @Nickname,允许开发人员为方法定义一个昵称,然后可以在运行时通过反射找到该方法:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Nickname {
    String[] value() default {};
}

定义注解所需的全部内容只是一个语法元素,用于注解可以出现的位置,保留策略和元素的名称。由于我们需要能够提供方法的昵称,我们还需要在注解上定义一个方法。尽管如此,定义新的自定义注解是一项非常紧凑的工作。

除了两个主要的元注解外,还有@Inherited@Documented元注解。这两个在实践中遇到的频率要低得多,详细信息可以在平台文档中找到。

类型注解

随着 Java 8 的发布,ElementType新增了两个新的取值:TYPE_PARAMETERTYPE_USE。这些新的取值允许在以前不合法的地方使用注解,比如任何类型使用的地方。这使得开发人员可以编写如下代码:

@NotNull String safeString = getMyString();

@NotNull传递的额外类型信息可以被特殊类型检查器用于检测问题(例如可能的NullPointerException),并执行额外的静态分析。基本的 Java 8 发行版附带了一些基本的可插拔类型检查器,但它也提供了一个框架,允许开发人员和库作者创建他们自己的类型检查器。

在本节中,我们已经接触了 Java 的枚举和注解类型。让我们继续考虑 Java 类型系统的下一个重要部分:lambda 表达式。

Lambda 表达式

Java 8 最令人期待的功能之一是引入了 Lambda 表达式(通常简称为 lambda)。

这次 Java 平台的重大升级由五个目标驱动,大致按优先级降序排列:

  • 更有表现力的编程

  • 更好的库

  • 简洁的代码

  • 提升的编程安全性

  • 潜在的增加数据并行性

Lambda 具有三个关键方面,帮助定义该特性的基本特性:

  • 它们允许在程序中将小段代码以字面量形式内联编写。

  • 它们通过使用类型推断放宽了 Java 代码的严格语法。

  • 它们促进了更加功能化的 Java 编程风格。

正如我们在第二章中看到的,lambda 表达式的语法是取参数列表(其类型通常是推断出来的),并将其附加到方法体,就像这样:

(p, q) -> { /* method body */ }

这可以提供一种非常紧凑的方式来表示实际上是单一方法的内容。这也是与早期版本的 Java 的一个重大变化 —— 目前为止,我们总是需要一个类声明,然后是一个完整的方法声明,所有这些都增加了代码的冗长。

事实上,在 lambda 出现之前,模拟这种编码风格的唯一方法是使用匿名类,我们将在本章后面讨论。然而,自 Java 8 以来,lambda 表达式在 Java 程序员中非常受欢迎,并且现在大多数情况下已经取代了匿名类的角色。

注意

尽管 Lambda 表达式与匿名类之间有相似之处,但 Lambda 并不仅仅是匿名类的语法糖。事实上,Lambda 使用方法句柄(我们将在 第十一章 中遇到)和一个名为 invokedynamic 的特殊 JVM 字节码实现。

Lambda 表达式代表创建特定类型的对象。创建的实例类型称为 Lambda 的 目标类型

只有特定类型才能作为 Lambda 的目标。

目标类型也称为 功能接口,它们必须:

  • 必须是接口

  • 只能有一个非默认方法(但可以有其他默认方法)

一些开发人员也喜欢使用 单一抽象方法(或 SAM)类型来指代 Lambda 转换的接口类型。这突出了一个事实,即要能够使用 Lambda 表达式机制,接口必须只有一个非默认方法。

注意

Lambda 表达式几乎具备方法的所有组成部分,唯一的异常是 Lambda 没有名称。事实上,许多开发人员喜欢将 Lambda 视为“匿名方法”。

因此,这意味着单行代码:

Runnable r  = () -> System.out.println("Hello");

并不执行 println(),而是创建一个对象,该对象赋值给变量 r,类型为 Runnable。这个对象 r 将执行 println() 语句,但只有在调用 r.run() 时才执行,而不是立即执行。

Lambda 表达式转换

javac 遇到 Lambda 表达式时,它将其解释为具有特定签名的方法体——但是哪个方法?

要解决这个问题,javac 查看周围的代码。为了合法的 Java 代码,Lambda 表达式必须满足以下属性:

  • Lambda 必须出现在期望接口类型的实例位置上。

  • 预期的接口类型应该有且仅有一个强制方法。

  • 预期的接口方法应该具有与 Lambda 表达式完全匹配的签名。

如果是这样,那么将创建一个实现预期接口并将 Lambda 体作为强制方法实现的类型的实例。

这种稍微复杂的转换方法源于希望保持 Java 的类型系统纯粹 名义(基于名称)。Lambda 表达式被称为 转换 成正确接口类型的实例。

从这个讨论中,我们可以看到,虽然 Java 8 添加了 Lambda 表达式,但它们被专门设计为适应 Java 现有的类型系统——这个系统非常强调名义类型(而不是其他一些编程语言中可能存在的类型)。

让我们考虑 lambda 转换的一个例子——java.io.File类的list()方法。此方法列出目录中的文件。在返回列表之前,它会将每个文件的名称传递给程序员必须提供的FilenameFilter对象。这个FilenameFilter对象接受或拒绝每个文件,是java.io包中定义的 SAM 类型之一。

@FunctionalInterface
public interface FilenameFilter {
    boolean accept(File dir, String name);
}

类型FilenameFilter携带了@FunctionalInterface注解,以指示它是一个适合作为 lambda 目标类型的合适类型。然而,此注解并非必需,任何符合要求的类型(通过是接口且为 SAM 类型)都可以用作目标类型。

这是因为在 Java 8 发布之前,JDK 和现有的 Java 代码库已经拥有大量的 SAM 类型。要求潜在的目标类型携带注解会阻止将 lambda 适配到现有代码中,但并没有真正的好处。

提示

在您编写的代码中,您应该始终尝试指示您的类型可用作目标类型,这可以通过为它们添加@FunctionalInterface来实现。这有助于提高可读性,并且可以帮助一些自动化工具。

下面是如何定义一个FilenameFilter类,以仅列出那些文件名以.java结尾的文件,使用 lambda:

File dir = new File("/src");      // The directory to list

String[] filelist = dir.list((d, fName) -> fName.endsWith(".java"));

对于列表中的每个文件,将评估 lambda 表达式中的代码块。如果方法返回true(如果文件名以.java结尾),则该文件将包含在输出中,最终存储在数组filelist中。

这种模式,其中一个代码块用于测试容器中的元素是否满足条件,并且仅返回通过条件的元素,被称为过滤习语。这是函数式编程的标准技术之一,我们将很快更深入地讨论它。

方法引用

请回忆,我们可以将 lambda 表达式视为代表没有名称的方法的对象。现在,请考虑这个 lambda 表达式:

// In real code this would probably be
// shorter because of type inference
(MyObject myObj) -> myObj.toString()

这将自动转换为实现@FunctionalInterface类型的实现,该类型具有一个非默认方法,接受一个MyObject并返回一个String,具体来说,是通过在MyObject实例上调用toString()获取的字符串。然而,这似乎是过度样板代码,因此 Java 8 提供了一种语法以使其更易于阅读和编写:

MyObject::toString

这种简写称为方法引用,它使用现有方法作为 lambda 表达式。方法引用语法与作为 lambda 表达式表示的先前形式完全等效。可以将其视为使用现有方法但忽略方法名称,因此它可以用作 lambda,然后以通常的方式自动转换。Java 定义了四种方法引用类型,这等效于四种略有不同的 lambda 表达式形式(见 Table 4-1)。

表格 4-1. 方法引用

名称 方法引用 等效的 lambda
未绑定 Trade::getPrice trade -> trade.getPrice()
绑定 System.out::println s -> System.out.println(s)
静态 System::getProperty key -> System.getProperty(key)
构造函数 Trade::new price -> new Trade(price)

我们最初引入的形式可以看作是一个 未绑定的方法引用。当我们使用未绑定的方法引用时,它等同于一个期望包含方法引用的类型实例的 lambda 表达式—​在 Table 4-1 中,这是一个 Trade 对象。

它被称为未绑定的方法引用,因为接收对象需要在使用方法引用时提供(作为 lambda 的第一个参数)。也就是说,我们将在某个 Trade 对象上调用 getPrice(),但方法引用的提供者尚未定义具体是哪一个。这由引用的使用者决定。

相比之下,绑定的方法引用 总是将接收者作为方法引用的实例化的一部分。在 Table 4-1 中,接收者是 System.out,因此在使用引用时,println() 方法将始终在 System.out 上调用,并且 lambda 的所有参数都将作为 println() 方法的参数使用。

我们将在下一章节更详细地讨论方法引用与 lambda 表达式的使用场景。

函数式编程

Java 从根本上来说是一种面向对象的语言。然而,随着 lambda 表达式的到来,编写接近函数式编程风格的代码变得更加容易。

注意

没有一个确切的定义可以说明什么是 函数式语言 —— 但至少有共识认为,它应该至少包含将函数表示为可以放入变量中的值的能力。

自从版本 1.1 以来,Java 一直能够通过内部类来表示函数(参见下一节),但语法复杂且缺乏清晰度。Lambda 表达式极大地简化了这种语法,因此很自然地,更多的开发人员将寻求在其 Java 代码中使用函数式编程的方面。

Java 开发人员可能会遇到的第一次函数式编程尝试是三种基本习语,这些习语非常实用:

map()

映射习语通常与列表和类列表容器一起使用。其思想是传入一个应用于集合中每个元素的函数,并创建一个由将该函数应用于每个元素的结果组成的新集合。这意味着映射习语可以将一个类型的集合转换为可能是不同类型的新集合。

filter()

当我们讨论如何用 lambda 替换FilenameFilter的匿名实现时,我们已经见过 filter 惯用语的一个例子。该 filter 惯用语用于基于某些选择条件生成集合的新子集。请注意,在函数式编程中,通常生成新集合而不是就地修改现有集合是正常的。

reduce()

reduce 惯用语有几种不同的形式。它是一个聚合操作,也可以称为foldaccumulateaggregate,以及 reduce。其基本思想是使用初始值和聚合(或缩减)函数,逐个应用缩减函数于每个元素,通过一系列中间结果(类似于“运行总计”)构建整个集合的最终结果,当 reduce 操作遍历集合时。

Java 具有对这些关键函数惯用语(和其他几种)的全面支持。具体实现在第八章中有详细解释,我们在那里讨论了 Java 的数据结构和集合,特别是stream抽象,这使得所有这些都成为可能。

让我们在这个介绍中总结一些警告。值得注意的是,Java 最好被视为对“稍微函数式编程”的支持。它不是特别函数式的语言,也没有尝试成为一个。Java 的一些特定方面反对它成为函数式语言的任何主张,包括:

  • Java 没有结构类型,这意味着没有“真正”的函数类型。每个 lambda 都会自动转换为相应的目标类型。

  • 类型擦除对函数式编程造成问题——对于高阶函数,类型安全可能会丢失。

  • Java 本质上是可变的(正如我们将在第六章中讨论的那样)—对于函数式语言来说,可变性通常被认为是非常不可取的。

  • Java 集合是命令式的,而不是函数式的。集合必须转换为流才能使用函数式风格。

尽管如此,易于访问函数式编程的基础知识——特别是 map、filter 和 reduce 等惯用语——对 Java 社区来说是一大步向前的。这些惯用语非常有用,以至于大多数 Java 开发人员永远不需要或错过具有更彻底函数式血统的语言提供的更高级功能。

事实上,许多这些技术使用嵌套类型是可能的(请参阅下一节的详细信息),通过诸如回调和处理程序之类的模式,但是语法总是相当繁琐的,特别是在你需要仅表达单行代码的回调时,你必须显式定义一个全新的类型。

词法作用域和局部变量

局部变量在定义其作用域的代码块内部定义,在该作用域之外,无法访问局部变量并且停止存在。只有在定义块边界的花括号内的代码可以使用该块中定义的局部变量。这种作用域被称为词法作用域,它只定义了可以使用变量的源代码部分。

程序员通常将这样的作用域视为临时,即将局部变量视为从 JVM 开始执行块到控制退出块的时间存在。这通常是一种合理的局部变量及其作用域的思考方式。然而,lambda 表达式(以及稍后将遇到的匿名和本地类)有能力弯曲或打破这种直觉。

这可能导致一些开发人员最初感到惊讶的效果。因为 lambda 可以使用局部变量,它们可以包含来自不存在的词法范围的值的副本。这可以在以下代码中看到:

public interface IntHolder {
    public int getValue();
}

public class Weird {
    public static void main(String[] args) {
        IntHolder[] holders = new IntHolder[10];
        for (int i = 0; i < 10; i++) {
            final int fi = i;

            holders[i] = () -> {
                return fi;
            };
        }
  // The lambda is now out of scope, but we have 10 valid instances
  // of the class the lambda has been converted to in our array.
  // The local variable fi is not in our scope here, but is still
  // in scope for the getValue() method of each of those 10 objects.
  // So call getValue() for each object and print it out.
  // This prints the digits 0 to 9.
        for (int i = 0; i < 10; i++) {
            System.out.println(holders[i].getValue());
        }
    }
}

每个 lambda 实例都有一个自动创建的私有副本每个使用的最终局部变量,因此实际上它有其自己的私有副本在创建时存在的作用域。这有时被称为captured变量。

捕获变量这样的 lambda 称为closures,而这些变量被称为closed over

警告

其他编程语言对闭包的定义可能略有不同。事实上,一些理论家会质疑 Java 的机制是否算得上闭包,因为技术上来说,被捕获的是变量的内容(一个值),而不是变量本身。

实际上,前述闭包示例比实际需要的更冗长,有两种不同的方式:

  • Lambda 有一个明确的作用域{}return语句。

  • 变量fi明确声明为final

编译器javac帮助处理这两种情况。

只返回单个表达式值的 lambda 不需要包括作用域或者return;相反,lambda 的主体只是表达式,不需要花括号。在我们的示例中,我们明确地包含了花括号和return语句,以阐明 lambda 正在定义其自身的作用域。

在 Java 早期版本中,关闭变量时有两个严格的要求:

  • 在捕获后,被捕获的变量不能被修改(例如,在 lambda 之后)。

  • 被捕获的变量必须声明为final

然而,在最近的 Java 版本中,javac可以分析代码并检测程序员是否尝试在 lambda 的范围之后修改捕获的变量。如果没有,则可以省略对捕获变量的final修饰符(这样的变量被称为effectively final)。如果省略了final修饰符,则试图在 lambda 范围之后修改捕获变量将导致编译时错误。

这是因为 Java 通过将变量内容的位模式复制到闭包创建的范围来实现闭包。对于封闭变量内容的进一步更改不会反映在闭包范围中的副本中,因此设计决策是使这些更改非法并在编译时错误。

这些来自javac的辅助功能意味着我们可以将前面示例的内部循环重写为非常紧凑的形式:

for (int i = 0; i < 10; i++) {
    int fi = i;
    holders[i] = () -> fi;
}

闭包在某些编程风格中非常有用,不同的编程语言以不同的方式定义和实现闭包。Java 将闭包实现为 lambda 表达式,但本地类和匿名类也可以捕获状态——实际上这是 Java 在 lambda 可用之前实现闭包的方式。

嵌套类型

在本书中到目前为止所见的类、接口和枚举类型都被定义为顶级类型。这意味着它们是包的直接成员,独立于其他类型之外定义。然而,类型定义也可以嵌套在其他类型定义之内。这些嵌套类型,通常被称为“内部类”,是 Java 语言的一个强大特性。

一般来说,嵌套类型用于两个不同的目的,都与封装相关。首先,类型可能被嵌套,因为它需要特别亲密地访问另一个类型的内部。作为嵌套类型,它以与成员变量和方法相同的方式访问。这意味着嵌套类型具有特权访问权限,可以被视为“略微违反封装规则”。

对于嵌套类型的此用例的另一种思考方式是,它们是与另一个类型紧密联系的类型。这意味着它们实际上并没有完全独立的实体存在,只是共存。

或者,类型可能仅仅是因为一个非常特定的原因在代码的一个非常小的部分中需要。这意味着它应该被紧密地局部化,因为它实际上是实现细节的一部分。

在较早版本的 Java 中,这样做的唯一方法是使用嵌套类型,例如接口的匿名实现。实际上,随着 Java 8 的出现,这种用例已经大大被 lambda 表达式取代。将匿名类型作为紧密局部化类型的使用在某些情况下仍然存在,但显著下降。

类型可以以四种不同的方式嵌套在另一个类型中:

静态成员类型

静态成员类型是定义为另一个类型的static成员的任何类型。嵌套接口、枚举和注解始终是静态的(即使您没有使用关键字)。

非静态成员类

“非静态成员类型”简单地是不声明为static的成员类型。只有类可以是非静态成员类型。

本地类

本地类是在 Java 代码块内定义并且仅在其中可见的类。接口、枚举和注解不能在本地定义。

匿名类

匿名类是一种没有对人类有意义的有意义名称的本地类;它仅仅是编译器分配的任意名称,程序员不应直接使用。接口、枚举和注解不能匿名定义。

“嵌套类型”这个术语虽然准确和精确,但并不被开发人员广泛使用。相反,大多数 Java 程序员使用更模糊的术语“内部类”。根据情况,这可能指非静态成员类、本地类或匿名类,但不是静态成员类型,没有真正的区分方法。

幸运的是,尽管描述嵌套类型的术语并不总是清晰,但与其一起工作的语法通常是明显的,通常可以从上下文中看出正在讨论哪种类型的嵌套类型。

注意

直到 Java 11,嵌套类型是使用编译器技巧实现的,大部分是语法糖。有经验的 Java 程序员应注意,这个细节在 Java 11 中发生了变化,不再像过去那样实现。

让我们继续详细描述四种嵌套类型中的每一种。每个部分描述了嵌套类型的特点、其使用的限制以及与该类型一起使用的任何特殊 Java 语法。

静态成员类型

静态成员类型与常规顶层类型非常相似。然而,为了方便起见,它嵌套在另一个类型的命名空间内。静态成员类型具有以下基本属性:

  • 静态成员类型与类的其他静态成员(如静态字段和静态方法)类似。

  • 静态成员类型与包含类的任何实例无关(即没有this对象)。

  • 静态成员类型可以(仅)访问包含它的类的static成员。

  • 静态成员类型可以访问其包含类型的所有static成员(包括任何其他静态成员类型)。

  • 嵌套接口、枚举和注解无论static关键字是否出现,都隐式为静态。

  • 任何嵌套在接口或注解中的类型也隐式为static

  • 静态成员类型可以在顶层类型内定义,也可以在其他静态成员类型内的任何深度嵌套。

  • 静态成员类型不能在任何其他类型的嵌套类型内定义。

让我们快速看一下静态成员类型的语法示例。示例 4-1 展示了一个帮助接口作为包含接口的静态成员定义的示例,本例中为 Java 的Map

示例 4-1. 定义并使用静态成员接口
public interface Map<K, V> {
    // ...

    Set<Map.Entry<K, V>> entrySet();

    // All nested interfaces are automatically static
    interface Entry<K, V> {
        K getKey();
        V getValue();
        V setValue(V value);

        // other members elided
    }

    // other members elided
}

当被外部类使用时,Entry将通过其层次名称Map.Entry来引用。

静态成员类型的特点

静态成员类型可以访问其包含类型的所有静态成员,包括private成员。反之亦然:包含类型的方法可以访问静态成员类型的所有成员,包括这些类型的private成员。静态成员类型甚至可以访问任何其他静态成员类型的所有成员,包括这些类型的private成员。静态成员类型可以使用任何其他静态成员,而无需使用包含类型的名称限定其名称。

顶层类型可以声明为public或包私有(如果它们没有使用public关键字声明)。但将顶层类型声明为privateprotected并没有太大意义——protected只是意味着与包私有相同,而private顶层类无法被任何其他类型访问。

另一方面,静态成员类型是成员,因此可以使用与包含类型的其他成员相同的任何访问控制修饰符。对于静态成员类型,这些修饰符的含义与类型的其他成员相同。

在大多数情况下,类名使用Outer.Inner语法可以很好地提醒内部类与其包含类型的互联性。但是,Java 语言允许您使用import指令直接导入静态成员类型:

import java.util.Map.Entry;

然后,您可以引用嵌套类型,而无需包含其封闭类型的名称(例如,只需像Entry这样)。

注意

您还可以使用import static指令导入静态成员类型。有关importimport static的详细信息,请参见第二章中的“包和 Java 命名空间”。

然而,导入嵌套类型会掩盖该类型与其包含类型紧密关联的事实——这通常是重要信息,并因此不常见。

非静态成员类

非静态成员类是一种声明为包含类或枚举类型的成员的类,没有static关键字:

  • 如果静态成员类型类比于类字段或类方法,那么非静态成员类类比于实例字段或实例方法。

  • 只有类可以是非静态成员类型。

  • 非静态成员类的实例始终与封闭类型的实例相关联。

  • 非静态成员类的代码可以访问其封闭类型的所有字段和方法(包括static和非static)。

  • Java 语法具有几个特定功能,专门用于处理非静态成员类的封闭实例。

示例 4-2 展示了如何定义和使用成员类。这个例子展示了一个LinkedStack的例子:它定义了一个嵌套接口,描述了堆栈底层的链表节点,并且定义了一个嵌套类来允许对堆栈上的元素进行枚举。成员类定义了java.util.Iterator接口的一个实现。

示例 4-2. 作为成员类实现的迭代器
import java.util.Iterator;

public class LinkedStack {

    // Our static member interface
    public interface Linkable {
        public Linkable getNext();
        public void setNext(Linkable node);
    }

    // The head of the list
    private Linkable head;

    // Method bodies omitted here
    public void push(Linkable node) { ... }
    public Linkable pop() { ... }

    // This method returns an Iterator object for this LinkedStack
    public Iterator<Linkable> iterator() { return new LinkedIterator(); }

    // Here is the implementation of the Iterator interface,
    // defined as a nonstatic member class.
    protected class LinkedIterator implements Iterator<Linkable> {
        Linkable current;

        // The constructor uses a private field of the containing class
        public LinkedIterator() { current = head; }

        // The following three methods are defined
        // by the Iterator interface
        public boolean hasNext() {  return current != null; }

        public Linkable next() {
            if (current == null)
              throw new java.util.NoSuchElementException();
            Linkable value = current;
            current = current.getNext();
            return value;
        }

        public void remove() { throw new UnsupportedOperationException(); }
    }
}

注意LinkedIterator类如何嵌套在LinkedStack类内部。LinkedIterator是一个仅在LinkedStack内部使用的辅助类,因此在包含类使用它的地方定义它可以产生清晰的设计。

成员类的特性

像实例字段和实例方法一样,每个非静态成员类的实例都与其定义的包含类的实例关联。这意味着成员类的代码可以访问包含实例的所有实例字段和实例方法(以及static成员),包括任何声明为private的成员。

这一关键特性已经在示例 4-2 中进行了演示。这里再次展示了LinkedStack.LinkedIterator()构造函数:

public LinkedIterator() { current = head; }

这行代码将内部类的current字段设置为包含类的head字段的值。尽管在包含类中,head声明为private字段,代码如所示仍然可以工作。

非静态成员类,像类的任何成员一样,可以被分配标准访问控制修饰符之一。在示例 4-2 中,LinkedIterator类声明为protected,因此对使用LinkedStack类的代码(在不同包中)是不可访问的,但对任何子类化LinkedStack的类是可访问的。

成员类有两个重要的限制:

  • 非静态成员类不能与任何包含类或包具有相同的名称。这是一个重要的规则,不同于字段和方法。

  • 非静态成员类不能包含任何static字段、方法或类型,除了被声明为staticfinal的常量字段。

成员类的语法

成员类最重要的特性是它可以访问其包含对象的实例字段和方法。

如果我们想要使用显式引用,并使用this,那么我们必须使用一种特殊的语法来显式地引用this对象的包含实例。例如,如果我们在构造函数中想要显式地表示,我们可以使用以下语法:

public LinkedIterator() { this.current = LinkedStack.this.head; }

通用语法是classname.this,其中classname是包含类的名称。请注意,成员类本身可以包含成员类,嵌套到任意深度。

然而,没有任何成员类可以与任何包含类具有相同的名称,因此,在this之前加上包含类名称是引用任何包含实例的一种完全通用的方式。换句话说,EnclosingClass.this的语法构造是引用包含实例的一种明确方式,称为上级引用

本地类

本地类是在 Java 代码块内部声明的类,而不是类的成员。只有类可以在本地定义:接口、枚举类型和注释类型必须是顶级或静态成员类型。通常,本地类在方法内部定义,但也可以在类的静态初始化器或实例初始化器内定义。

正如所有 Java 代码块都出现在类定义内部一样,所有本地类都嵌套在包含块内部。因此,尽管本地类与成员类共享许多特性,通常更合适的是将它们视为一种完全不同的嵌套类型。

注意

详见第 5 章,了解何时适合选择本地类而不是 lambda 表达式。

本地类的定义特点是它仅在代码块的范围内有效。示例 4-3 演示了如何修改LinkedStack类的iterator()方法,使其将LinkedIterator定义为本地类而不是成员类。

这样做可以将类的定义更接近其使用位置,从而进一步提高代码的清晰度。为简洁起见,示例 4-3 仅显示了iterator()方法,而不是包含它的整个LinkedStack类。

示例 4-3. 定义和使用本地类
// This method returns an Iterator object for this LinkedStack
public Iterator<Linkable> iterator() {
    // Here's the definition of LinkedIterator as a local class
    class LinkedIterator implements Iterator<Linkable> {
        Linkable current;

        // The constructor uses a private field of the containing class
        public LinkedIterator() { current = head; }

        // The following three methods are defined
        // by the Iterator interface
        public boolean hasNext() {  return current != null; }

        public Linkable next() {
            if (current == null)
              throw new java.util.NoSuchElementException();
            Linkable value = current;
            current = current.getNext();
            return value;
        }

        public void remove() { throw new UnsupportedOperationException(); }
    }

    // Create and return an instance of the class we just defined
    return new LinkedIterator();
}

本地类的特性

本地类具有以下有趣的特性:

  • 像成员类一样,本地类与包含实例关联,并且可以访问包含类的任何成员,包括private成员。

  • 除了访问包含类定义的字段外,本地类还可以访问任何本地方法定义作用域内的局部变量、方法参数或异常参数,并且这些变量必须声明为final

本地类受以下限制:

  • 本地类的名称仅在定义它的块内部有效;它永远不能在该块外部使用。(注意,但是,在类的范围内创建的本地类的实例可以继续存在于该范围之外。本节稍后将详细描述这种情况。)

  • 本地类不能声明为publicprotectedprivatestatic

  • 与成员类一样,由于同样的原因,局部类不能包含static字段、方法或类。唯一的例外是同时声明为staticfinal的常量。

  • 接口、枚举类型和注解类型不能在局部定义。

  • 与成员类一样,局部类也不能与其封闭类的任何名称相同。

  • 正如前面提到的,局部类可以关闭作用域内的局部变量、方法参数甚至异常参数,但前提是这些变量或参数是有效地final

局部类的作用域

在讨论非静态成员类时,我们看到成员类可以访问从超类继承的任何成员以及由其包含的类定义的任何成员。

对于局部类也是如此,但局部类还可以像 Lambda 一样访问有效的final局部变量和参数。示例 4-4 展示了局部类(或 Lambda)可以访问的不同类型的字段和变量。

示例 4-4. 局部类可访问的字段和变量
class A { protected char a = 'a'; }
class B { protected char b = 'b'; }

public class C extends A {
  private char c = 'c';         // Private fields visible to local class
  public static char d = 'd';
  public void createLocalObject(final char e)
  {
    final char f = 'f';
    int i = 0;                  // i not final; not usable by local class
    class Local extends B
    {
      char g = 'g';
      public void printVars()
      {
        // All of these fields and variables are accessible to this class
        System.out.println(g);  // (this.g) g is a field of this class
        System.out.println(f);  // f is a final local variable
        System.out.println(e);  // e is a final local parameter
        System.out.println(d);  // (C.this.d) d field of containing class
        System.out.println(c);  // (C.this.c) c field of containing class
        System.out.println(b);  // b is inherited by this class
        System.out.println(a);  // a is inherited by the containing class
      }
    }
    Local l = new Local();      // Create an instance of the local class
    l.printVars();              // and call its printVars() method.
  }
}

因此,局部类具有相当复杂的作用域结构。要了解原因,请注意,局部类的实例的生命周期可以延伸到 JVM 退出定义局部类的块之后。

注意

换句话说,如果您创建了局部类的实例,那么当 JVM 完成定义类的块的执行时,该实例不会自动消失。因此,即使类的定义是局部的,该类的实例也可以逃离其定义的位置。

因此,局部类在许多方面的行为类似于 Lambda,尽管局部类的用例比 Lambda 更通用。然而,在实践中,很少需要额外的通用性,并且尽可能使用 Lambda。

匿名类

匿名类是一种没有名称的局部类。它在一个表达式中使用new运算符进行定义和实例化。虽然局部类定义是 Java 代码块中的语句,但匿名类定义是一个表达式,这意味着它可以作为较大表达式的一部分,例如方法调用。

注意

为了完整起见,我们在这里涵盖了匿名类,但对于大多数用例,Lambda 表达式(参见“Lambda 表达式”)已经取代了匿名类。

请参考示例 4-5,它展示了LinkedIterator类作为LinkedStack类的iterator()方法内的匿名类实现。与示例 4-4 进行比较,它展示了相同的类作为局部类实现。

示例 4-5. 使用匿名类实现的枚举
public Iterator<Linkable> iterator() {
    // The anonymous class is defined as part of the return statement
    return new Iterator<Linkable>() {
        Linkable current;
        // Replace constructor with an instance initializer
        { current = head; }

        // The following three methods are defined
        // by the Iterator interface
        public boolean hasNext() {  return current != null; }
        public Linkable next() {
            if (current == null)
              throw new java.util.NoSuchElementException();
            Linkable value = current;
            current = current.getNext();
            return value;
        }
        public void remove() { throw new UnsupportedOperationException(); }
    };  // Note the required semicolon. It terminates the return statement
}

如您所见,定义匿名类并创建该类的实例的语法使用 new 关键字,后跟类型名称和用大括号括起的类体定义。如果 new 关键字后面的名称是类的名称,则匿名类是指定类的子类。如果 new 后面的名称指定了一个接口,就像前两个示例中一样,匿名类实现该接口并扩展 Object

注意

匿名类的语法特意不包括任何指定 extends 子句、implements 子句或类名的方式。

因为匿名类没有名称,所以不可能在类体中为其定义构造函数。这是匿名类的基本限制之一。在匿名类定义中紧随超类名称后的括号内指定的任何参数都会隐式传递给超类构造函数。匿名类通常用于子类化不需要任何构造函数参数的简单类,因此匿名类定义语法中的括号经常是空的。

因为匿名类只是一种局部类的类型,匿名类和局部类共享相同的限制。匿名类不能定义任何 static 字段、方法或类,除了 static final 常量。接口、枚举类型和注解类型不能匿名定义。此外,像局部类一样,匿名类不能是 publicprivateprotectedstatic

定义匿名类的语法将定义与实例化结合在一起,类似于 lambda 表达式。如果每次执行包含块时需要创建多个类的实例,则不适合使用匿名类而应使用局部类。

描述 Java 类型系统

到目前为止,我们已经涵盖了 Java 类型系统的所有主要方面,因此我们可以对其进行描述和表征。

Java 类型系统的最重要和显而易见的特征是它是:

  • 静态

  • 不是单根

  • 名义上

静态类型是三个方面中最广为人知的,意味着在 Java 中,每个数据存储(如变量、字段等)都有一个类型,并且该类型在首次引入存储时声明。尝试将不兼容的值放入不支持的存储中会导致编译时错误。

Java 的类型系统不是单根的也是立即显而易见的。Java 有原始类型和引用类型。Java 中的每个对象都属于一个类,除了 Object 外,每个类都有一个单一的父类。这意味着任何 Java 程序中的类集合形成一个以 Object 为根的树结构。

然而,任何原始类型和Object之间都没有继承关系。因此,Java 类的整体图形由大量的引用类型树和八个不相交的孤立点(对应于原始类型)组成。这导致需要使用包装类型,如Integer,在必要时将原始值表示为对象(例如在 Java 集合中)。

最后一个方面,则需要更详细的讨论。

名义类型

在 Java 中,每种类型都有一个名称。在 Java 编程的正常过程中,这将是一个简单的字母(有时是数字)串,具有反映类型用途的语义意义。这种方法被称为名义类型

并非所有语言都具有纯粹的名义类型;例如,一些语言可以表达“此类型具有特定签名方法”的概念,而无需显式引用类型名称,有时被称为结构类型

例如,在 Python 中,您可以对定义了__len__()方法的任何对象调用len()。当然,Python 是一种动态类型语言,如果无法进行len()调用,则会引发运行时异常。但是,在静态类型语言中也可以表达类似的概念,例如 Scala。

Java 另一方面,没有办法在不使用接口的情况下表达这个想法,这当然有一个名字。Java 也严格基于继承和实现来维护类型兼容性。让我们看一个例子:

@FunctionalInterface
public interface MyRunnable {
    void run();
}

接口MyRunnable有一个单一方法,与Runnable完全匹配。然而,这两个接口彼此没有继承或其他关系,所以像这样的代码:

MyRunnable myR = () -> System.out.println("Hello");
Runnable r = (Runnable)myR;
r.run();

将会编译成功,但在运行时会失败并抛出ClassCastException。事实上,即使两个接口上存在具有相同签名的run()方法,编译器也不会考虑,实际上程序根本没有执行到调用run()的地步:它失败在前一行,即尝试进行类型转换的地方。

另一个重要的点是 Java 的整个 lambda 表达式构建,特别是将目标类型定型为函数接口,是为了确保 lambda 能够适应名义类型方法。例如,考虑这样一个接口:

@FunctionalInterface
public interface MyIntProvider {
    int run() throws InterruptedException;
}

然后,可以在多种不同的情况下使用一个产生常量的 lambda 表达式,例如() -> 42

MyIntProvider prov       = () -> 42;
Supplier<Integer> sup    = () -> 42;
Callable<Integer> callMe = () -> 42;

从这里我们可以看到,单独的表达式() -> 42是不完整的。Java lambda 表达式依赖于类型推断,因此我们需要看到表达式与其目标类型结合在一起才有意义。与目标类型组合时,lambda 的类类型是“一个在编译时未知的目标接口的实现”,程序员必须将接口类型作为 lambda 的类型使用。

除了 lambda 之外,在 Java 中还有一些名义类型的边缘情况。一个例子是匿名类,但即使在这里,类型仍然有名称。然而,匿名类型的类型名称是由编译器自动生成的,并且专门选择以便 JVM 可以使用但 Java 源代码编译器不接受。

还有另一种边缘情况需要考虑,它与近期 Java 版本引入的增强类型推断有关。

非标记类型和var

从 Java 11 开始(实际上是在 Java 10 非 LTS 版本中引入),Java 开发人员可以利用新的语言特性局部变量类型推断(LVTI),又称var。这是 Java 类型推断能力的增强,可能比一开始看起来更重要。在最简单的情况下,它允许如下代码:

var ls = new ArrayList<String>();

将推断从值的类型移到变量的类型。

实现方法是将var作为保留的类型名称而不是关键字。这意味着代码仍然可以将var用作变量、方法或包名,而不受新语法的影响。然而,先前将var用作类型名称的代码将需要重新编译。

这个简单的案例旨在减少冗长,并使从其他语言(特别是 Scala、.NET 和 JavaScript)转到 Java 的程序员感觉更舒适。然而,过度使用可能会模糊编写代码的意图,因此应该谨慎使用。

除了简单的情况外,var实际上允许了以前不可能的编程构造。为了看到差异,让我们考虑javac一直允许的一种非常有限的类型推断:

public class Test {
    public static void main(String[] args) {
        (new Object() {
            public void bar() {
                System.out.println("bar!");
            }
        }).bar();
    }
}

代码将编译并运行,打印出bar!。这种略显反直觉的结果发生是因为javac保留了关于匿名类的足够类型信息(即它有一个bar()方法),以至于编译器可以推断调用bar()是有效的。

实际上,这种边缘情况自 2009 年以来就在Java 社区中已知,早在 Java 7 到来之前。

这种类型推断的问题在于它没有真正的实际应用:“带有 bar 方法的对象”的类型存在于编译器中,但是这种类型无法表达为变量的类型——它不是一个可标记的类型。这意味着在 Java 10 之前,这种类型的存在仅限于单个表达式,不能在更大的范围内使用。

然而,随着 LVTI 的到来,变量的类型并不总是需要显式指定。相反,我们可以使用var来允许我们通过避免指定类型来保留静态类型信息。

这意味着现在我们可以修改我们的示例并编写:

var o = new Object() {
    public void bar() {
        System.out.println("bar!");
    }
};

o.bar();

这使得我们能够在单个表达式之外保留o的真实类型。o的类型不能被指定,因此它不能作为方法参数或返回类型的类型出现。这意味着类型仍然仅限于单个方法,但仍然可以用于表达某些在其他情况下会很尴尬或不可能的结构。

var用作“魔术类型”允许程序员为每个var的不同使用保留类型信息,这在某种程度上类似于 Java 泛型的有界通配符。

更高级的var用法与非注记类型是可能的。虽然这个特性不能满足所有对 Java 类型系统的批评,但它确实代表了一个明确(尽管谨慎)的进步步骤。

概要

通过分析 Java 的类型系统,我们已经能够建立起 Java 平台对数据类型的世界观的清晰图景。Java 的类型系统可以被描述为:

静态的

所有的 Java 变量在编译时都有已知的类型。

名义上的

Java 类型的名称至关重要。Java 不允许结构类型,并且对于非注记类型的支持有限。

面向对象/命令式

Java 代码是面向对象的,所有的代码必须存在于方法中,方法必须存在于类中。然而,Java 的原始类型阻止了对“一切皆对象”的完全采纳。

稍微具有函数式特征

Java 提供对一些常见的函数式习语的支持,但更多作为程序员的便利而非其他。

类型推断

Java 优化了代码的可读性(即使是对初学者),并倾向于显式声明,但在不影响代码可读性的情况下使用类型推断来减少样板代码。

强大的向后兼容性

Java 主要是面向业务的语言,向后兼容性和保护现有代码库是非常高的优先事项。

类型擦除

Java 允许参数化类型,但这些信息在运行时不可用。

Java 的类型系统经过多年的演进(尽管缓慢而谨慎),现在与其他主流编程语言的类型系统处于同一水平。Lambda 表达式与默认方法一起,代表了自 Java 5 问世以来最大的转变,以及泛型、注解及相关创新的引入。

默认方法代表了 Java 面向对象编程方法的一个重大转变,也许是自语言问世以来最大的转变。从 Java 8 开始,接口可以包含实现代码。这从根本上改变了 Java 的性质。此前是单继承语言的 Java,现在在行为上可以多继承(但仅限于行为,状态仍然不支持多继承)。

尽管有所有这些创新,Java 的类型系统并没有(也不打算)配备类似于 Scala 或 Haskell 等语言的类型系统的强大能力。相反,Java 的类型系统在简洁性、可读性和新手学习曲线方面都倾向于简单。

过去 10 年,Java 也从其他语言中开发的类型方法中受益匪浅。Scala 作为一种静态类型语言的例子,通过使用类型推断实现了很多动态类型语言的感觉,为 Java 添加特性提供了很好的思路,即使这两种语言有着非常不同的设计理念。

仍然有一个问题是,Java 中 Lambda 表达式提供的对函数式习惯的适度支持是否足以满足大多数 Java 程序员的需求。

注:

Java 的类型系统的长期发展方向正在研究项目中探索,例如 Valhalla,其中正在探索诸如数据类、模式匹配和密封类等概念。

尚待观察的是,普通 Java 程序员是否需要像 Scala 那样的高级(且远非名义上的)类型系统所带来的更大能力——以及随之而来的复杂性,还是 Java 8 中引入的“稍微函数式编程”(例如 mapfilterreduce 等)已经足够满足大多数开发者的需求。

¹ 一些泛型的小痕迹仍然存在,可以通过反射在运行时看到。

² Raoul-Gabriel Urma 和 Janina Voigt,“使用 OpenJDK 探究 Java 中的协变”,Java Magazine(2012 年 5 月/6 月):44–47。

第五章:Java 面向对象设计简介

在本章中,我们将考虑与 Java 中面向对象设计(OOD)相关的几种技术。

我们将讨论如何处理 Java 的对象,涵盖Object的关键方法、面向对象设计的方面以及实现异常处理方案。在整个章节中,我们将介绍一些设计模式——基本上是解决软件设计中一些非常常见情况的最佳实践。在本章的末尾,我们还将考虑安全程序——这些程序设计得不会随着时间的推移而变得不一致。

注意

本章旨在展示一个复杂主题和一些基本原则的示例。我们鼓励您查阅其他资源,比如 Josh Bloch 的Effective Java

我们将开始考虑 Java 的调用和传递约定以及 Java 值的性质。

Java 值

Java 的值及其与类型系统的关系非常直接。Java 有两种类型的值:原始值和对象引用。

注意

Java 中只有八种不同的原始类型,并且程序员不能定义新的原始类型。

原始值和引用之间的关键区别在于原始值不能被更改;值2始终是相同的值。相比之下,对象引用的内容通常可以更改——通常称为对象内容的突变

还请注意,变量只能包含适当类型的值。特别是,引用类型的变量始终包含对持有对象的内存位置的引用——它们不直接包含对象内容。这意味着在 Java 中没有等价的解引用运算符或struct

Java 试图简化一个经常让 C++程序员困惑的概念:“对象的内容”和“对象的引用”的区别。不幸的是,完全隐藏这种差异是不可能的,因此程序员有必要了解引用值在平台上的工作原理。

Java 是按值传递的事实可以非常简单地证明,例如,通过运行以下代码:

public void manipulate(Circle circle) {
    circle = new Circle(3);
}

Circle c = new Circle(2);
System.out.println("Radius: "+ c.getRadius());
manipulate(c);
System.out.println("Radius: "+ c.getRadius());

这会两次输出Radius: 2,因此显示即使在调用manipulate()之后,变量c中包含的值也未更改——它仍然持有半径为 2 的Circle对象的引用。如果 Java 是一种按引用传递的语言,那么它将持有对半径为 3 的Circle的引用:

如果我们对这种区别非常小心,并且将对象引用称为 Java 可能的一种值类型之一,则 Java 的一些否则令人惊讶的特性变得明显。要小心!一些较旧的文本在这一点上是含糊的。当我们讨论内存和垃圾收集时,我们将再次遇到 Java 值的概念 第六章。

重要的常见方法

正如我们所述,所有类都直接或间接扩展自java.lang.Object。该类定义了许多有用的方法,其中一些是为你编写的类设计的。示例 5-1 展示了一个重写了这些方法的类。接下来的章节将文档化每个方法的默认实现,并解释为何你可能需要重写它们。

请注意,此示例仅用于演示目的;在实际情况中,我们会将类如Circle表示为记录,并让编译器自动实现许多这些方法。

示例 5-1. 一个重写重要 Object 方法的类
// This class represents a circle with immutable position and radius.
public class Circle implements Comparable<Circle> {
    // These fields hold the coordinates of the center and the radius.
    // They are private for data encapsulation and final for immutability
    private final int x, y, r;

    // The basic constructor: initialize the fields to specified values
    public Circle(int x, int y, int r) {
        if (r < 0) throw new IllegalArgumentException("negative radius");
        this.x = x; this.y = y; this.r = r;
    }

    // This is a "copy constructor"--a useful alternative to clone()
    public Circle(Circle original) {
        x = original.x;   // Just copy the fields from the original
        y = original.y;
        r = original.r;
    }

    // Public accessor methods for the private fields.
    // These are part of data encapsulation.
    public int getX() { return x; }
    public int getY() { return y; }
    public int getR() { return r; }

    // Return a string representation
    @Override public String toString() {
        return String.format("center=(%d,%d); radius=%d", x, y, r);
    }

    // Test for equality with another object
    @Override public boolean equals(Object o) {
        // Identical references?
        if (o == this) return true;
        // Correct type and non-null?
        if (!(o instanceof Circle)) return false;
        Circle that = (Circle) o;                 // Cast to our type
        if (this.x == that.x && this.y == that.y && this.r == that.r)
            return true;                          // If all fields match
        else
            return false;                         // If fields differ
    }

    // A hash code allows an object to be used in a hash table.
    // Equal objects must have equal hash codes.  Unequal objects are
    // allowed to have equal hash codes, but we try to avoid that.
    // We must override this method because we also override equals().
    @Override public int hashCode() {
        int result = 17;          // This hash code algorithm from
        result = 37*result + x;   // Effective Java, by Joshua Bloch
        result = 37*result + y;
        result = 37*result + r;
        return result;
    }

    // This method is defined by the Comparable interface. Compare
    // this Circle to that Circle.  Return a value < 0 if this < that
    // Return 0 if this == that. Return a value > 0 if this > that.
    // Circles are ordered top to bottom, left to right, then by radius
    public int compareTo(Circle that) {
        // Smaller circles have bigger y
        long result = (long)that.y - this.y;
        // If same compare l-to-r
        if (result==0) result = (long)this.x - that.x;
        // If same compare radius
        if (result==0) result = (long)this.r - that.r;

        // We have to use a long value for subtraction because the
        // differences between a large positive and large negative
        // value could overflow an int. But we can't return the long,
        // so return its sign as an int.
        return Long.signum(result);
    }
}

示例 5-1 展示了我们在第四章中介绍的类型系统的许多扩展特性。首先,该示例实现了一个参数化或泛型版本的Comparable接口。其次,它使用@Override注解来强调(并让编译器验证)某些方法覆盖了Object

toString()

toString()方法的目的是返回对象的文本表示。在字符串连接和诸如System.out.println()等方法中,对象会自动调用此方法。给对象提供文本表示在调试或日志输出中非常有用,一个精心制作的toString()方法甚至可以帮助生成报告。

继承自ObjecttoString()版本返回一个字符串,包括对象的类名以及对象的hashCode()值的十六进制表示(本章稍后讨论)。这个默认实现为对象提供了基本的类型和标识信息,但不是非常有用。示例 5-1 中的toString()方法返回一个包含Circle类每个字段值的可读字符串。

equals()

==运算符测试两个引用是否指向同一个对象。如果你想测试两个不同的对象是否相等,你必须使用equals()方法。任何类都可以通过重写equals()方法来定义自己的相等性概念。Object.equals()方法简单地使用==运算符:此默认方法只在两个对象实际上是同一个对象时才认为它们相等。

在示例 5-1 中,equals()方法认为两个不同的Circle对象在它们的字段都相等时是相等的。注意,它首先通过==进行快速的身份测试作为优化,然后使用instanceof检查其他对象的类型:一个Circle只能与另一个Circle相等,且equals()方法不可抛出ClassCastException。注意,instanceof测试还可以排除null参数:如果其左操作数为nullinstanceof始终评估为false

hashCode()

每当您重写 equals(),您也必须重写 hashCode()。此方法返回一个整数,用于哈希表数据结构。如果根据 equals() 方法两个对象相等,则这两个对象必须具有相同的哈希码,这一点至关重要。

对于哈希表的高效操作很重要,但不是必须的,不相等的对象必须具有不相等的哈希码,或者至少不相等的对象不太可能共享一个哈希码。这第二个标准可能会导致涉及轻微复杂算术或位操作的 hashCode() 方法。

Object.hashCode() 方法与 Object.equals() 方法一起使用,根据对象的身份而不是对象的相等性返回一个哈希码。(如果您需要基于身份的哈希码,可以通过静态方法 System.identityHashCode() 访问 Object.hashCode() 的功能。)

警告

当您重写 equals() 时,必须始终重写 hashCode(),以保证相等的对象具有相等的哈希码。否则可能会在程序中引起难以察觉的错误。

因为在示例 5-1 中,equals() 方法基于三个字段的值来判断对象的相等性,所以 hashCode() 方法也基于这三个字段计算其哈希码。从代码可以清楚地看出,如果两个 Circle 对象具有相同的字段值,则它们将具有相同的哈希码。

注意,在示例 5-1 中的 hashCode() 方法并不简单地将三个字段相加并返回它们的和。这样的实现是合法的,但不高效,因为具有相同半径但 xy 坐标互换的两个圆将具有相同的哈希码。重复的乘法和加法步骤“扩展”了哈希码的范围,并显著降低了两个不相等的 Circle 对象具有相同代码的可能性。

在实践中,现代 Java 程序员通常会从他们的 IDE 中自动生成类的 hashCode()equals()toString() 方法,或者使用记录(records)类型,其中源代码编译器会生成这些方法的标准形式。对于极少数情况,程序员选择不使用这两种方法的情况,《Effective Java》(Addison Wesley)中包含了一种构建高效 hashCode() 方法的有用方法。

Comparable::compareTo()

示例 5-1 包括一个 compareTo() 方法。此方法由 java.lang.Comparable 接口定义,而不是由 Object 定义,但它是一个常见的实现方法,因此我们在本节中包括它。Comparable 及其 compareTo() 方法的目的是允许类的实例以类似于 <, <=, >, 和 >= 操作符比较数字的方式进行比较。如果一个类实现了 Comparable 接口,我们可以调用方法来比较类的实例,从而判断一个实例是否小于、大于或等于另一个实例。这也意味着 Comparable 类的实例可以进行排序。

注意

compareTo() 方法设置了该类型对象的 全序。这被称为该类型的 自然顺序,该方法被称为 自然比较方法

因为 compareTo() 没有被 Object 类声明,所以每个单独的类都需要确定其实例是否应该以及如何排序,并包括一个实现该排序的 compareTo() 方法。

由 示例 5-1 定义的顺序将 Circle 对象比作页面上的单词。首先,圆按从上到下的顺序排列:具有较大 y 坐标的圆小于具有较小 y 坐标的圆。如果两个圆具有相同的 y 坐标,则按从左到右的顺序排列。具有较小 x 坐标的圆小于具有较大 x 坐标的圆。最后,如果两个圆具有相同的 xy 坐标,则按半径比较。具有较小半径的圆小于另一个圆。

注意,在这种顺序下,仅当三个字段都相等时,两个圆才相等。这意味着 compareTo() 定义的顺序与 equals() 定义的相等性一致。虽然这不是严格要求的,但非常值得,您应该尽可能实现它。

compareTo() 方法返回一个需要进一步解释的 int 值。如果 this 对象小于传递给它的对象,则 compareTo() 应返回一个负数。如果两个对象相等,则应返回 0。如果 this 大于方法参数,则 compareTo() 应返回一个正数。

clone()

Object 定义了一个名为 clone() 的方法,其目的是返回一个字段设置与当前对象完全相同的对象。这是一个不寻常的方法的原因之一。

首先,clone() 被声明为 protected。因此,如果你想让你的对象可以被其他类克隆,你必须重写 clone() 方法,并使其为 public。接下来,Objectclone() 的默认实现抛出一个受检异常,CloneNotSupportedException,除非类实现了 java.lang.Cloneable 接口。注意,这个接口不定义任何方法(它是一个标记接口),所以实现它只是在类签名的 implements 子句中列出它。

clone() 的最初目的是提供一个生成对象的“深度拷贝”的机制,但它本质上是有缺陷的,不建议使用。相反,开发人员应该首选声明一个 复制构造函数 来制作他们对象的副本,例如:

Circle original = new Circle(1, 2, 3);  // regular constructor
Circle copy = new Circle(original);     // copy constructor

当我们考虑工厂方法时,我们将再次遇到复制构造函数。

常量

在 Java 中,常量是一个 static final 字段。这个修饰符的组合给定了一个单一的值(每个类),并且在类加载时初始化,然后不能被更改。

按照惯例,Java 的常量以全大写的形式命名,使用 蛇形命名,例如 NETWORK_SERVER_SOCKET¹,而不是“驼峰命名法”(或“驼峰式”)的约定,如 networkServerSocket 对于一个常规字段。

基本上有

  • public 常量:这些构成了类的公共 API 的一部分

  • private 常量:当常量仅为该类的内部实现细节时使用

  • 包级别的常量:这些没有额外的访问关键字,当常量是需要被同一包中的不同类看到的内部实现细节时使用

最终情况可能会出现,例如,客户端和服务器类实现了一个网络协议,其细节(例如连接和监听的端口号)被捕获在一组符号常量中。

正如前面讨论的,常量出现在接口定义中是一种替代方法。实现接口的任何类都会继承它定义的常量,并且可以像在类本身中直接定义它们一样使用它们。这样做的优点是不需要用接口的名称前缀常量,也不需要提供任何常量的实现。

然而,这种方式相当复杂,所以首选方法是在一个类中定义常量(可以是公共的或包级别的),并通过使用 import static 声明从定义类中导入常量来使用它们。有关详细信息,请参阅 “包和 Java 命名空间”。

处 处理字段

Java 提供了多种访问控制关键字,用于定义字段的访问方式。使用任何这些可能性都是完全合法的,但实际上,Java 开发者通常有三种主要的字段访问选择:

  • 常量(static final):我们刚刚遇到的情况,可能还带有额外的访问控制关键字。

  • 不可变字段(private final):使用此组合的字段在对象创建后无法更改。

  • 可变字段(private):只有在程序员确定字段值在对象生命周期内会改变时才应该使用这种组合。

近年来,许多开发者开始采用尽可能使用不可变数据的实践。这种做法有几个好处,但主要的好处是,如果对象设计得不可修改,那么它们可以在线程之间自由共享。

在编写类时,我们建议根据情况使用上述三种字段修饰符选择。实例字段应始终首先写为final,只有在必要时才应使其可变。

此外,除了常量外,不应使用直接字段访问。应优先使用 getter 方法(以及 setter,对于可变状态的情况)。这样做的主要原因是直接字段访问会非常紧密地耦合定义类和任何客户端代码。如果使用访问器方法,则可以稍后修改这些方法的实现代码而无需更改客户端代码——而直接字段访问则无法做到这一点。

我们还应该指出字段处理中的一个常见错误:从 C++转过来的开发者经常犯的一个错误是省略字段的任何访问修饰符。这是一个严重的缺陷,因为 C++的默认可见性是 private,而 Java 的默认访问权限更加开放。这代表了 Java 中封装的失败,开发者应该注意避免这种情况。

字段继承和访问器

除了上述考虑因素外,Java 还提供了多种可能的方法来解决状态继承的设计问题。程序员可以选择将字段标记为protected,并允许子类直接访问(包括写入)。或者,我们可以提供访问器方法来读取(和写入,如果需要)实际的对象字段,同时保持封装性并将字段保留为private

让我们重新审视我们之前在第三章末尾的PlaneCircle示例,并明确显示字段继承:

public class Circle {
  // This is a generally useful constant, so we keep it public
  public static final double PI = 3.14159;

  protected double r;     // State inheritance via a protected field

  // A method to enforce the restriction on the radius
  protected void checkRadius(double radius) {
    if (radius < 0.0)
      throw new IllegalArgumentException("radius may not < 0");
  }

  // The non-default constructor
  public Circle(double r) {
    checkRadius(r);
    this.r = r;
  }

  // Public data accessor methods
  public double getRadius() { return r; }
  public void setRadius(double r) {
    checkRadius(r);
    this.r = r;
  }

  // Methods to operate on the instance field
  public double area() { return PI * r * r; }
  public double circumference() { return 2 * PI * r; }
}

public class PlaneCircle extends Circle {
  // We automatically inherit the fields and methods of Circle,
  // so we only have to put the new stuff here.
  // New instance fields that store the center point of the circle
  private final double cx, cy;

  // A new constructor to initialize the new fields
  // It uses a special syntax to invoke the Circle() constructor
  public PlaneCircle(double r, double x, double y) {
    super(r);       // Invoke the constructor of the superclass
    this.cx = x;    // Initialize the instance field cx
    this.cy = y;    // Initialize the instance field cy
  }

  public double getCenterX() {
    return cx;
  }

  public double getCenterY() {
    return cy;
  }

  // The area() and circumference() methods are inherited from Circle
  // A new instance method that checks whether a point is inside the
  // circle; note that it uses the inherited instance field r
  public boolean isInside(double x, double y) {
    double dx = x - cx, dy = y - cy;
    // Pythagorean theorem
    double distance = Math.sqrt(dx*dx + dy*dy);
    return (distance < r);                   // Returns true or false
  }
}

而不是前面的代码,我们可以使用访问器方法来重写PlaneCircle,如下所示:

public class PlaneCircle extends Circle {
  // Rest of class is the same as above; the field r in
  // the superclass Circle can be made private because
  // we no longer access it directly here

  // Note that we now use the accessor method getRadius()
  public boolean isInside(double x, double y) {
    double dx = x - cx, dy = y - cy;            // Distance to center
    double distance = Math.sqrt(dx*dx + dy*dy); // Pythagorean theorem
    return (distance < getRadius());
  }
}

这两种方法在 Java 中都是合法的,但它们有一些区别。正如我们在 “数据隐藏和封装” 中讨论的那样,可在类外部写入的字段通常不是模型化对象状态的正确方式。事实上,正如我们稍后将在本章中看到的,并再次在 “Java 对并发的支持” 中看到的,它们可能会对程序的运行状态造成无法修复的损害。

因此,Java 中的 protected 关键字不幸地允许从子类和与声明类在同一包中的类(以及方法)访问字段。这与任何人都可以编写属于任何给定包的类(除了系统包)的能力结合在一起,意味着在 Java 中,受保护的状态继承可能存在缺陷。

警告

Java 不提供一种仅在声明类及其子类中可见成员的机制。

出于所有这些原因,几乎总是更好地使用访问器方法(无论是公共的还是受保护的)来为子类提供对状态的访问——除非继承的状态声明为 final,在这种情况下,受保护的状态继承是完全允许的。

单例

单例模式 是一个非常著名的设计模式。它旨在解决只需要或希望一个类的单个实例的设计问题。Java 提供了多种不同的实现单例模式的方式。在我们的讨论中,我们将使用稍微冗长的形式,这种形式的好处在于非常明确地说明了安全单例所需的操作:

public class Singleton {
  private final static Singleton instance = new Singleton();
  private static boolean initialized = false;

  // Constructor
  private Singleton() {
    super();
  }

  private void init() {
    /* Do initialization */
  }

  // This method should be the only way to get a reference
  // to the instance
  public static synchronized Singleton getInstance() {
    if (initialized) return instance;
    instance.init();
    initialized = true;
    return instance;
  }
}

关键点在于,单例模式要有效,必须不可能创建多个实例,并且不可能获取到处于未初始化状态的对象的引用(有关这一重要点的更多信息,请参见本章后面)。

为了实现这一点,我们需要一个仅被调用一次的 private 构造函数。在我们的 Singleton 版本中,我们只在初始化私有静态变量 instance 时调用构造函数。我们还将创建唯一 Singleton 对象的过程与初始化分开,初始化过程发生在私有方法 init() 中。

有了这个机制,获取 Singleton 唯一实例的唯一方法是通过静态辅助方法 getInstance()。此方法检查标志 initialized,以查看对象是否已经处于活动状态。如果是,则返回对单例对象的引用。如果不是,则 getInstance() 调用 init() 来激活对象,并将标志设置为 true,这样下次请求 Singleton 的引用时,不会再进行进一步的初始化。

最后,我们还注意到getInstance()是一个synchronized方法。详细信息请参见第 6 章,了解其含义及为何需要这样做,但现在只需知道,它存在是为了防止在多线程程序中使用Singleton时出现意外后果。

提示

单例模式作为最简单的模式之一,经常被滥用。正确使用时,它可以是一种有用的技术,但程序中有太多的单例类通常是代码设计不良的典型迹象。

单例模式有一些缺点,特别是在测试和与其他类分离时可能很难。在多线程代码中使用时也需要注意。尽管如此,开发人员熟悉单例模式并不会意外地重新发明它是很重要的。单例模式经常用于配置管理,但现代代码通常会使用框架(通常是依赖注入框架)自动为程序员提供单例,而不是通过显式的Singleton(或等效)类。

工厂方法

直接使用构造函数的另一种选择是工厂方法模式。这种技术的基本形式是将构造函数设为私有的(或者在某些变体中设为其他非公共修饰符),并提供一个静态方法返回所需的类型。然后客户端代码使用这个静态方法来获取类型的实例。

作为代码作者,我们可能不希望直接暴露构造函数,并可能选择使用工厂。例如,缓存工厂不一定创建新对象,或者因为有多种有效构造对象的方式。

注意

静态工厂方法的方法并不同于经典书籍设计模式中的抽象工厂模式。

让我们重写来自示例 5-1 的构造函数,并引入一些工厂方法:

public final class Circle implements Comparable<Circle> {
    private final int x, y, r;

    // Main constructor
    private Circle(int x, int y, int r) {
        if (r < 0) throw new IllegalArgumentException("radius < 0");
        this.x = x; this.y = y; this.r = r;
    }

    // Usual factory method
    public static Circle of(int x, int y, int r) {
        return new Circle(x, y, r);
    }

    // Factory method playing the role of the copy constructor
    public static Circle of(Circle original) {
        return new Circle(original.x, original.y, original.r);
    }

    // Third factory with intent given by name
    public static Circle ofOrigin(int r) {
        return new Circle(0, 0, r);
    }

    // other methods elided
}

这个类包含一个私有构造函数和三个独立的工厂方法:一个与构造函数具有相同签名的“常规”方法,以及两个额外的方法。其中一个额外的工厂实际上是一个复制构造函数,另一个用于处理一个特殊情况:原点处的圆。

使用工厂方法的一个优势是,与构造函数不同,该方法具有名称,因此可以使用名称的一部分指示其意图。在我们的示例中,工厂方法是of(),这是一个非常常见的选择,并且我们通过使用表达这一点的名称ofOrigin()来区分原点圆。

构建器

工厂方法是一种有用的技术,当您不想将构造函数暴露给客户端代码时。然而,工厂方法也有其局限性。当只有少数几个参数是必需的,并且所有这些参数都需要被传递时,它们运行良好。但在某些情况下,我们需要建模数据,其中许多部分是可选的,或者我们的领域对象有许多有效的不同可能的构造。在这种情况下,工厂方法的数量可能会快速增加,以表示所有可能的组合,并且可能会使 API 混乱。

另一种方法是建造者模式。这种模式使用一个辅助建造者对象,该对象与真实领域对象的状态完全相同(假设为不可变)。对于领域对象的每个字段,建造者都有相同的字段——相同的名称和类型。然而,虽然领域对象是不可变的,建造者对象是显式可变的。实际上,建造者有一个 setter 方法,命名方式与字段相同(即按“记录约定”),开发人员将使用该方法来设置状态的一部分。

建造者模式的整体意图是从一个“空白”的建造者对象开始,并向其添加状态,直到建造者准备好转换为实际的领域对象,通常是通过在建造者上调用build()方法。

让我们看一个简单的例子:

// Generic builder interface
public interface Builder<T> {
    T build();
}

public class BCircle {
    private final int x, y, r;

    // The main constructor is now private
    private BCircle(CircleBuilder cb) {
        if (cb.r < 0)
        throw new IllegalArgumentException("negative radius");
        this.x = cb.x; this.y = cb.y; this.r = cb.r;
    }

    public static class CircleBuilder implements Builder<BCircle> {
        private int x = 0, y = 0, r = 0;

        public CircleBuilder x(int x) {
            this.x = x;
            return this;
        }

        public int x() {
            return x;
        }

        // Similarly for y and r

        @Override
        public BCircle build() {
            return new BCircle(this);
        }
    }

    // Other methods elided
}

注意建造者接口通常是泛型的。这是因为在实践中,我们可能会有大量的领域类,所有这些类都将需要建造者,因此使用泛型建造者接口可以消除重复。Builder接口只包含一个方法,因此在技术上它可以作为 lambda 目标类型的候选者。但实际上这几乎从不是意图,因此没有被标记为@FunctionalInterfacebuild()方法的实现还包含对this引用的非可选使用。

建造者可以通过如下简单的代码驱动:

var cb = new BCircle.CircleBuilder();
cb.x(1).y(2).r(3);
var circle = cb.build();

注意首先我们要实例化建造者。然后,我们调用方法来设置建造者的各种参数。最后,我们通过调用build()方法从建造者创建一个不可变对象。

您可能注意到,增加状态的建造者上的方法都返回this。这种接口设计的目的是可以将调用链式——即可以在同一个可变对象上连续调用方法,例如cb.x(1).y(2).r(3)。另一种描述这种接口设计风格的方式是流畅接口。由于每个方法都返回this,我们知道所有这些调用都是安全的:不会出现NullPointerException

我们的示例非常简单,有些刻意;它只有三个参数,而且所有这些参数都是必需的。在实践中,当对象参数数量较多且对象状态的“跨度集”有多个可能性时,构建器更加有用。工厂与构建器的使用案例存在重叠;确定在您自己的代码中确切的边界位置是面向对象设计技能开发的一部分。

接口与抽象类的比较

Java 8 彻底改变了 Java 的面向对象编程模型。在 Java 8 之前,接口是纯粹的 API 规范,不包含任何实现。当接口有多个实现时,这可能(并经常)导致代码重复。

为了避免这种浪费的努力,发展出了一个简单的编码模式,利用抽象类可以包含子类可以构建的部分实现。许多子类可以依赖抽象超类(也称为抽象基类)提供的方法实现。

这种模式由包含基本方法 API 规范的接口以及作为抽象类的主要部分实现配对而成。一个很好的例子就是java.util.List,它与java.util.AbstractList配对。JDK 提供的List的两个主要实现(ArrayListLinkedList)都是AbstractList的子类。

另一个例子:

// Here is a basic interface. It represents a shape that fits inside
// of a rectangular bounding box. Any class that wants to serve as a
// RectangularShape can implement these methods from scratch.
public interface RectangularShape {
    void setSize(double width, double height);
    void setPosition(double x, double y);
    void translate(double dx, double dy);
    double area();
    boolean isInside();
}

// Here is a partial implementation of that interface. Many
// implementations may find this a useful starting point.
public abstract class AbstractRectangularShape
                        implements RectangularShape {
    // The position and size of the shape
    protected double x, y, w, h;

    // Default implementations of some of the interface methods
    public void setSize(double width, double height) {
     w = width; h = height;
    }
    public void setPosition(double x, double y) {
     this.x = x; this.y = y;
    }
    public void translate (double dx, double dy) { x += dx; y += dy; }
}

Java 8 的默认方法的到来显著改变了这一局面。接口现在可以包含实现代码,就像我们在“默认方法”中看到的那样。

这意味着当定义一个抽象类型(例如Shape),你期望它有许多子类型(例如CircleRectangleSquare)时,你面临选择接口和抽象类之间的选择。由于它们现在可能具有相似的特性,因此并不总是清楚该选择哪一个。

请记住,扩展抽象类的类不能扩展任何其他类,而接口仍然不能包含任何非常量字段。这意味着在 Java 程序中使用继承仍然有一些限制。

接口和抽象类之间的另一个重要区别与兼容性有关。如果你将接口定义为公共 API 的一部分,然后稍后向接口添加一个新的强制方法,你将会破坏实现了接口先前版本的任何类——换句话说,任何新的接口方法必须声明为默认方法,并提供一个实现。

然而,如果使用抽象类,可以安全地向该类添加非抽象方法,而无需修改扩展抽象类的现有类。

在这两种情况下,添加新方法可能会与具有相同名称和签名的子类方法发生冲突 —— 子类方法始终胜出。因此,在添加新方法时要仔细考虑,特别是当方法名称对于此类型是“显而易见”或方法可能具有多个可能含义时。

通常,建议的方法是在需要 API 规范时首选接口。接口的强制方法是非默认的,因为它们代表 API 的一部分,必须存在于实现中才能被视为有效。默认方法应仅在方法真正可选时使用,或者如果它们只打算具有单个可能的实现时使用。

最后,旧的(Java 8 之前的)在文档中声明接口的哪些方法被认为是“可选的”,并指示实现在程序员不想实现它们时抛出java.lang.UnsupportedOperationException的技术充满了问题,不应在新代码中使用。

默认方法改变了 Java 的继承模型吗?

在 Java 8 之前,语言的严格单继承模型是清晰的。每个类(除了Object)都有一个直接超类,方法实现只能在类中定义,或者从超类层次结构继承。

默认方法改变了这种情况,因为它们允许方法实现从多个地方继承 —— 可以是从超类层次结构,也可以是从接口提供的默认实现。任何来自不同接口的默认方法之间的潜在冲突都会导致编译时错误。

这意味着不存在实现的多重继承可能性,因为在任何冲突情况下,程序员需要手动消除歧义的方法。

同样,接口仍然没有状态的多重继承:接口仍然没有非常量字段。

这意味着 Java 的多重继承与例如 C++中的一般多重继承不同。实际上,默认方法实际上是来自 C++的Mixin模式(对于熟悉该语言的读者)。一些开发人员还将默认成员视为某些面向对象语言(例如 Scala)中出现的trait语言特性的一种形式。

然而,来自 Java 语言设计者的官方立场是,默认方法并不完全满足完整的特征。 JDK 中随附的代码 —— 即使是java.util.function中的接口(如Function本身)也表现为简单的特征。

例如,考虑以下示例:

public interface IntFunc {
    int apply(int x);

    default IntFunc compose(IntFunc before) {
        return (int y) -> apply(before.apply(y));
    }

    default IntFunc andThen(IntFunc after) {
        return (int z) -> after.apply(apply(z));
    }

    static IntFunc id() {
        return x -> x;
    }
}

它是java.util.functionFunction接口的简化版本,删除了泛型,并仅处理int作为数据类型。

这种情况显示了现有的功能组合方法(compose()andThen())的一个重要点:这些函数只会以标准方式组合,任何理智的对默认 compose() 方法的重写都几乎不可能存在。

当然,在 java.util.function 中存在的函数类型也是如此,这表明在提供的有限域内,默认方法确实可以被视为一种无状态特征。

使用 Lambdas 的面向对象设计

考虑这个简单的 lambda 表达式:

Runnable r = () -> System.out.println("Hello World");

lvalue(赋值语句左侧)的类型是 Runnable,这是一个接口类型。为了使这个语句有意义,rvalue(赋值语句右侧)必须包含实现 Runnable 的某个类类型的实例(因为接口不能被实例化)。满足这些约束的最小实现是一个类类型(名称不重要),它直接扩展 Object 并实现 Runnable

请记住,lambda 表达式的意图是允许 Java 程序员表达尽可能接近其他语言中看到的匿名或内联方法的概念。

此外,鉴于 Java 是一种静态类型语言,这直接导致了 lambda 的设计实现。

提示

Lambdas 是一个新类类型的实例构造的简写,这个新类类型本质上是 Object 增强了一个方法。

lambda 的额外方法由接口类型提供签名,并且编译器将检查 rvalue 是否与此类型签名一致。

Lambdas 与嵌套类

在 Java 8 中向语言添加 lambda 相对较晚,与其他编程语言相比。因此,Java 社区已经建立了模式来解决 lambda 的缺失问题。这表现在大量使用非常简单的嵌套(也称为内部)类来填补通常由 lambda 占据的空缺。

在现代的 Java 项目中,开发人员通常会尽可能地使用 lambda。我们还强烈建议,在重构旧代码时,您花一些时间将内部类转换为 lambda,只要有可能。一些 IDE 甚至提供了自动转换功能。

然而,这仍然留下了一个设计问题,即何时使用 lambda,何时使用嵌套类仍然是正确的解决方案。

有些情况是显而易见的;例如,当扩展某些功能的默认实现时,嵌套类方法是适当的,原因有两个:

  1. 自定义实现可能必须重写多个方法。

  2. 基础实现是一个类,而不是一个接口。

另一个要考虑的主要用例是有状态 lambda。由于没有地方声明任何字段,乍一看似乎 lambda 不能直接用于涉及状态的任何事物—语法只给出了声明方法体的机会。

然而,lambda 可能引用 lambda 所创建的范围内定义的变量,因此我们可以创建一个闭包,如在 第四章 中讨论的那样,来扮演有状态 lambda 的角色。

Lambda 与方法引用

何时使用 lambda 和何时使用方法引用大多数是个人品味和风格问题。当然,在一些情况下创建 lambda 是必要的。然而,在许多简单情况下,lambda 可以被方法引用替代。

一种可能的方法是考虑 lambda 符号是否增加了代码的可读性。例如,在流 API 中,使用 lambda 形式可能会带来潜在的好处,因为它使用 -> 操作符。这提供了一种视觉隐喻形式——流 API 是一种惰性抽象,可以将数据项“通过函数管道流动”。

例如,让我们考虑一个 Person 对象,它具有标准特征,如姓名、年龄等。我们可以使用类似以下的流水线计算平均值:

List<Person> persons = ... // derived from somewhere
double aveAge = persons.stream()
        .mapToDouble(o -> o.getAge())
        .reduce(0, (x, y) -> x + y ) / persons.size();

mapToDouble() 方法具有运动或转换方面的概念,使用显式 lambda 很明显。对于经验不足的程序员,这也引起了对函数式 API 的注意。

对于其他用例(例如 分发表),方法引用可能更合适。例如:

public class IntOps {
    private Map<String, BinaryOperator> table =
        Map.of("add", IntOps::add, "subtract", IntOps::sub);

    private static int add(int x, int y) {
        return x + y;
    }

    private static int sub(int x, int y) {
        return x - y;
    }

    public int eval(String op, int x, int y) {
        return table.get(op).apply(x, y);
    }
}

在可以使用任一符号的情况下,随着时间的推移,您会形成适合个人风格的偏好。关键考虑因素是在重新阅读数月(或数年)前编写的代码时,符号选择是否仍然合理且代码易于阅读。

使用封闭类型的面向对象设计

我们在 第三章 中第一次遇到封闭类,并在 第四章 中引入了封闭接口。除了我们已经遇到的情况外,还有一个更简单的可能性,即封闭类型只能由定义在同一编译单元内的类(即 Java 源文件)扩展,例如:

// Note the absence of a permits clause
public abstract sealed class Shape {

    public static final class Circle extends Shape {
        // ...
    }

    public static final class Rectangle extends Shape {
        // ...
    }
}

Shape.CircleShape.RectangleShape 的唯一允许的子类:任何试图扩展 Shape 的尝试都将导致编译错误。这实际上只是额外的细节,因为一般概念保持不变;sealed 表示一个只有有限可能兼容的类型。

这里有一个有趣的二元性:

  • 枚举是只有有限数量实例的类——任何枚举对象都是这些实例之一

  • 封闭类型仅有限数量的兼容类——任何封闭对象都属于其中一个类

现在考虑一个接受枚举的 switch 表达式,例如:

var temp = switch(season) {
    case WINTER -> 2.0;
    case SPRING -> 10.5;
    case SUMMER -> 24.5;
    case AUTUMN -> 16.0;
};
System.out.println("Average temp: "+ temp);

所有季节的可能枚举常量都出现在这个 switch 表达式中,因此匹配被称为完全的。在这种情况下,不需要包含default,因为编译器可以利用枚举常量的详尽性推断出永远不会激活默认情况。

不难看出我们可以对密封类型进行类似的操作。一些代码如下所示:

Shape shape = ...

if (shape instanceof Shape.Circle c) {
    System.out.println("Circle: "+ c.circumference());
} else if (shape instanceof Shape.Rectangle r) {
    System.out.println("Rectangle: "+ r.circumference());
}

对于人类来说,这显然是详尽无遗的,但当前(截至 Java 17)并未直接被编译器识别。

这是因为,截至 Java 17,密封类型实质上是一种不完整的功能。在 Java 的未来版本中,打算扩展 switch 表达式功能并将其与新形式的instanceof(以及其他新语言特性)结合起来,以提供称为模式匹配的功能。

这一新特性将使开发人员能够编写代码,例如“对变量的类型进行切换”,这将解锁由函数式编程启发的新设计模式,在 Java 中实现起来并不容易。

注意

附录包含有关模式匹配和其他未来功能的更多信息。

尽管截至 Java 17 还不完全完整,但密封类型在其当前形式下仍然非常有用,也可以与记录结合以产生一些引人注目的设计。

使用记录的 OOD

记录在第三章中引入,以其最简单的形式代表“仅仅是字段”或“数据包”。在其他一些编程语言中,这由一个元组表示,但 Java 的记录与元组有两个重要的不同之处:

  1. Java 记录是具名类型,而元组是匿名的

  2. Java 记录可以拥有方法、辅助构造函数以及几乎类似类的一切内容

这两者都源于记录是一种特殊类型的类的事实。这使得程序员可以从使用记录作为基本字段集合开始其设计,然后从那里进化。

例如,让我们将示例 5-1 重写为一条记录(简化了Comparable接口):

public record Circle(int x, int y, int r) {
    // Primary (compact) constructor
    public Circle {
        // Validation code in the constructor
        // This would be impossible in a tuple
        if (r < 0) {
            throw new IllegalArgumentException("negative radius");
        }
    }

    // Factory method playing the role of the copy constructor
    public static Circle of(Circle original) {
        return new Circle(original.x, original.y, original.r);
    }
}

请注意,我们引入了一种新类型的构造函数,称为紧凑构造函数。它仅适用于记录,并且在我们希望在构造函数中做一些额外工作以及初始化字段的情况下使用。紧凑构造函数没有(或不需要)参数列表,因为它们的参数列表始终与记录的声明相同。

这段代码比示例 5-1 要简短得多,并清楚地区分了记录的主构造函数(“真实形式”)与复制构造函数及可能存在的其他工厂之间的情况。

Java 记录的设计意味着它们是程序员的一种非常灵活的选择。一个实体可以最初被建模为仅仅是字段,并且随着时间的推移,可以获得更多的方法、实现接口等。

另一个重要方面是记录可以与密封接口结合使用。让我们来看一个例子:一个快递公司有不同类型的订单:基本订单(免费送货)和快速订单(速度更快但需额外费用)。

订单的基本接口如下所示:

sealed interface Order permits BasicOrder, ExpressOrder {
    double price();
    String address();
    LocalDate delivery();
}

并且有两个实现:

public record BasicOrder(double price,
                         String address,
                         LocalDate delivery) implements Order {}

public record ExpressOrder(double price,
                           String address,
                           LocalDate delivery,
                           double deliveryCharge) implements Order {}

请记住,所有记录类型的超类型是java.lang.Record,因此对于这种类型的用例,我们必须使用接口;不可能使不同的订单类型扩展抽象基类。我们的选择是:

  • 将实体建模为类,并使用sealed abstract基类。

  • 将实体建模为记录,并使用密封接口。

在第二种情况下,任何常见的记录组件都需要提升到接口中,就像我们在Order示例中看到的那样。

实例方法还是类方法?

实例方法是面向对象编程的一个关键特性。然而,并不意味着您应该避免使用类方法。在许多情况下,定义类方法是完全合理的。

提示

请记住,在 Java 中,类方法使用static关键字声明,术语static methodclass method可互换使用。

例如,在使用Circle类时,您可能经常需要计算具有给定半径的圆的面积,但不想费事地创建表示该圆的Circle对象。在这种情况下,类方法更方便:

public static double area(double r) { return PI * r * r; }

一个类可以定义多个具有相同名称的方法,只要这些方法具有不同的参数列表是完全合法的。area()方法的这个版本是一个类方法,因此它没有隐含的this参数,并且必须有一个参数来指定圆的半径。这个参数使它与同名的实例方法有所区别。

另一个关于实例方法和类方法选择的例子是,考虑定义一个名为bigger()的方法,它检查两个Circle对象,并返回半径较大的那个。我们可以将bigger()编写为实例方法,如下所示:

// Compare the implicit "this" circle to the "that" circle passed
// explicitly as an argument and return the bigger one.
public Circle bigger(Circle that) {
  if (this.r > that.r) return this;
  else return that;
}

我们还可以将bigger()实现为一个类方法,如下所示:

// Compare circles a and b and return the one with the larger radius
public static Circle bigger(Circle a, Circle b) {
  if (a.r > b.r) return a;
  else return b;
}

给定两个Circle对象xy,我们可以使用实例方法或类方法来确定哪个更大。然而,这两种方法的调用语法有显著区别:

// Instance method: also y.bigger(x)
Circle biggest = x.bigger(y);
Circle biggest = Circle.bigger(x, y);  // Static method

这两种方法都很有效,并且从面向对象设计的角度来看,这两种方法都没有比另一种方法更“正确”的说法。实例方法在形式上更符合面向对象,但其调用语法存在某种不对称性。在这种情况下,选择实例方法或类方法仅仅是一种设计决策。根据情况,其中一种方法可能更为自然。

关于 System.out.println()的一点说明

我们经常遇到方法System.out.println() — 它用于将输出显示到终端窗口或控制台。我们从未解释过为什么这个方法有这么长、笨拙的名字,或者这两个点在里面做什么。现在你理解了类和实例字段以及类和实例方法,更容易理解正在发生的事情:System是一个类。它有一个名为out的公共类字段。这个字段是java.io.PrintStream类型的对象,它有一个名为println()的实例方法。

我们可以使用静态导入来缩短这个过程,比如import static java.lang.System.out; — 这将使我们能够将打印方法简化为out.println(),但由于这是一个实例方法,我们无法进一步缩短它。

组合与继承

继承并不是面向对象设计中我们唯一可以使用的技术。对象可以包含对其他对象的引用,因此可以从更小的组件部分聚合出更大的概念单元;这被称为组合

一个重要的相关技术是委托,其中特定类型的对象持有对兼容类型的次要对象的引用,并将所有操作转发到次要对象。这通常通过接口类型来完成,正如本例中展示的那样,我们在这里模拟软件公司的就业结构:

public interface Employee {
  void work();
}

public class Programmer implements Employee {
  public void work() { /* program computer */ }
}

public class Manager implements Employee {
  private Employee report;

  public Manager(Employee staff) {
    report = staff;
  }

  public Employee setReport(Employee staff) {
    report = staff;
  }

  public void work() {
    report.work();
  }
}

Manager类被认为是委托work()操作给他们的直接报告,Manager对象不执行任何实际工作。此模式的变体涉及在委托类中执行一些工作,只将一些调用转发到委托对象。

另一种有用的相关技术称为装饰者模式。这提供了在运行时扩展对象功能的能力。设计时需要一些额外的工作量。让我们看一个装饰者模式的例子,应用于模拟在塔科店出售的卷饼。为了保持简单,我们仅模拟要装饰的一个方面——卷饼的价格:

// The basic interface for our burritos
interface Burrito {
  double getPrice();
}

// Concrete implementation-standard size burrito
public class StandardBurrito implements Burrito {
  private static final double BASE_PRICE = 5.99;

  public double getPrice() {
    return BASE_PRICE;
  }
}

// Larger, super-size burrito
public class SuperBurrito implements Burrito {
  private static final double BASE_PRICE = 6.99;

  public double getPrice() {
    return BASE_PRICE;
  }
}

这些涵盖了可以提供的基本卷饼——两种不同的尺寸,不同的价格。让我们通过添加一些可选的额外配料——辣椒和鳄梨酱来增强这一点。这里的关键设计点是使用一个抽象基类,所有可选的装饰组件都将其子类化:

/*
 * This class is the Decorator for Burrito. It represents optional
 * extras that the burrito may or may not have.
 */
public abstract class BurritoOptionalExtra implements Burrito {
    private final Burrito burrito;
    private final double price;

    protected BurritoOptionalExtra(Burrito toDecorate,
          double myPrice) {
        burrito = toDecorate;
        price = myPrice;
    }

    public final double getPrice() {
        return (burrito.getPrice() + price);
    }
}

结合一个abstract基类BurritoOptionalExtra,以及一个protected构造函数,意味着获取BurritoOptionalExtra的唯一有效方法是构造其子类的实例,因为它们具有公共构造函数。这种方法还可以隐藏组件价格的设置,使客户端代码无法访问。

注意

当然,装饰者也可以与密封类型结合使用,以允许仅限于已知的有限列表的装饰者。

让我们来测试这个实现:

Burrito lunch = new Jalapeno(new Guacamole(new SuperBurrito()));
// The overall cost of the burrito is the expected $8.09.
System.out.println("Lunch cost: "+ lunch.getPrice());

装饰器模式被广泛应用,尤其是在 JDK 实用类中。当我们在第十章中讨论 Java I/O 时,我们将看到更多实际应用中的装饰器示例。

异常和异常处理

我们在“已检查和未检查异常”中遇到了已检查和未检查异常。在本节中,我们讨论异常设计的一些附加方面以及如何在您自己的代码中使用它们。

请记住,在 Java 中,异常是一个对象。这个对象的类型是 java.lang.Throwable,或者更常见的是 Throwable 的某个子类,更具体地描述了发生的异常类型。Throwable 有两个标准的子类:java.lang.Errorjava.lang.Exception。属于 Error 子类的异常通常指示不可恢复的问题:虚拟机已经耗尽了内存,或者类文件已损坏且无法读取,例如。这类异常可以被捕获和处理,但很少这样做 —— 这些是先前提到的未检查异常。

另一方面,属于 Exception 子类的异常指示的是较不严重的情况。这些异常可以被合理地捕获和处理。它们包括诸如 java.io.EOFException(表示文件结束)和 java.lang.ArrayIndexOutOfBoundsException(指示程序尝试读取超出数组末尾的位置)等异常。这些是来自第二章中的已检查异常(除了 RuntimeException 子类,它们也是一种未检查异常)。在本书中,我们使用术语“异常”来指代任何异常对象,无论该异常的类型是 Exception 还是 Error

因为异常是一个对象,它可以包含数据,并且它的类可以定义操作该数据的方法。Throwable 类及其所有子类都包括一个 String 字段,用于存储描述异常条件的可读错误消息。异常对象创建时设置该字段,并可以通过 getMessage() 方法从异常中读取。大多数异常只包含这一条消息,但有些异常会添加其他数据。例如,java.io.InterruptedIOException 添加了一个名为 bytesTransferred 的字段,指定了在异常条件中断之前完成的输入或输出量。

在设计自己的异常时,应考虑与异常对象相关的其他建模信息。这通常是关于中止操作的具体信息,以及遇到的异常情况(正如我们在 java.io.InterruptedIOException 中看到的)。

在应用程序设计中使用异常存在一些权衡。使用受检异常意味着编译器可以强制处理(或向上传播到调用堆栈)已知的可能恢复或重试的条件。这也意味着更难忘记实际处理错误——从而减少忘记错误条件导致系统在生产中失败的风险。

另一方面,有些应用程序将无法从某些条件中恢复,即使这些条件在理论上由受检异常建模。例如,如果应用程序要求在文件系统中特定位置放置配置文件,并且在启动时找不到它,则可能只能打印错误消息并退出——尽管java.io.FileNotFoundException是一个受检异常。在这些情况下,强制处理或传播无法从中恢复的异常,边缘情况,打印错误并退出是唯一真正明智的操作。

在设计异常方案时,以下是一些您应该遵循的良好实践:

  • 考虑需要放置在异常上的附加状态——记住它也是一个像其他任何对象一样的对象。

  • Exception有四个公共构造函数——在正常情况下,自定义异常类应该实现所有这些函数——用于初始化额外状态或自定义消息。

  • 不要在您的 API 中创建许多细粒度的自定义异常类——Java I/O 和反射 API 都受到此类问题的困扰,并且这样做只会不必要地复杂化与这些包的工作。

  • 不要用一个单一的异常类型描述太多的条件。

  • 在确定需要抛出异常之前,永远不要创建异常。异常创建可能是一个昂贵的操作。

最后,有两种异常处理反模式您应该避免:

// Never just swallow an exception
try {
  someMethodThatMightThrow();
} catch(Exception e){
}

// Never catch, log, and rethrow an exception
try {
  someMethodThatMightThrow();
} catch(SpecificException e){
  log(e);
  throw e;
}

前者只是忽略了几乎肯定需要采取某些行动的条件(即使只是在日志中通知)。这增加了系统中其他地方发生失败的可能性——可能远离原始的真实来源。

第二个只是制造噪音。我们记录了一条消息,但实际上没有处理这个问题;我们仍然需要在系统中更高级别的其他代码来实际处理这个问题。

安全的 Java 编程

编程语言有时被描述为类型安全;然而,这个术语在工作程序员中使用得比较宽泛。关于类型安全有许多不同的观点和定义,并非所有观点都是相互兼容的。对于我们的目的来说,最有用的观点是类型安全是编程语言的一个属性,可以防止在运行时错误地标识数据的类型。这应该被视为一个滑动尺度——更有助于将语言视为在类型安全性方面更多(或更少)的语言,而不是一个简单的安全/不安全的二元属性。

在 Java 中,类型系统的静态特性通过产生编译错误来防止大量可能的错误,例如,如果程序员尝试将不兼容的值分配给变量。然而,Java 并不是完全类型安全的,因为我们可以在任何两个引用类型之间进行强制类型转换——如果值不兼容,这将在运行时失败,抛出 ClassCastException

在本书中,我们倾向于将安全性视为不可分割的正确性主题。这意味着我们应该以程序为中心,而不是语言。这强调了一个观点:安全代码并不是任何广泛使用的语言所保证的,反而需要相当大的程序员努力(和严格的编码纪律)才能确保最终结果真正安全和正确。

我们通过与状态模型抽象的合作来接近安全程序的视角,如图 5-1 所示。一个 安全 的程序是指:

  • 所有对象在创建后都处于合法状态。

  • 外部可访问的方法在合法状态之间转换对象。

  • 外部可访问的方法不得在对象处于不一致状态时返回。

  • 外部可访问的方法在抛出异常之前必须将对象重置为合法状态。

在这种情况下,“外部可访问”意味着 public、包私有(package-private)或 protected。这为程序的安全性定义了一个合理的模型,因为它与定义我们的抽象类型有关,使其方法确保状态的一致性,因此合理地将满足这些要求的程序称为“安全程序”,无论该程序是在何种语言中实现。

警告

私有方法不需要以合法状态的对象开始或结束,因为它们不能被外部代码调用。

如 正如你可能想象的,实际实现大量代码,以确保状态模型和方法遵守这些属性,可能是相当艰巨的任务。在像 Java 这样的语言中,程序员直接控制预先多任务执行线程的创建,这个问题要复杂得多。

JN7 0501

图 5-1. 程序状态转换

从我们对面向对象设计的介绍过渡到 Java 语言和平台的最后一个方面,需要理解一个坚实的基础。这就是内存和并发的特性——这是平台中最复杂的之一,但也正是通过细致研究带来了巨大的回报。它是我们下一章的主题,并结束了第一部分。

¹ 从技术上讲,这应该被称为 SCREAMING_SNAKE_CASE

第六章:Java 对内存和并发的处理方式

本章是介绍 Java 平台中的并发(多线程)和内存处理的入门。这些主题本质上是相互交织在一起的,因此将它们一起处理是有意义的。我们将涵盖以下内容:

  • Java 内存管理简介

  • 基本的标记-清除垃圾收集(GC)算法

  • HotSpot JVM 如何根据对象的生命周期优化 GC

  • Java 的并发原语

  • 数据的可见性和可变性

Java 内存管理的基本概念

在 Java 中,当不再需要对象时,对象所占用的内存会自动释放。这是通过一种称为 垃圾收集(或 GC)的过程来完成的。垃圾收集是一种已经存在多年的技术,由诸如 Lisp 等语言率先采用。对于那些习惯于诸如 C 和 C++ 之类的语言,在其中必须调用 free() 函数或 delete 运算符来回收内存的程序员来说,这需要一些适应。

注意

不需要记住销毁每个创建的对象的事实是使 Java 成为一种愉快的工作语言的特性之一。这也是使用 Java 编写的程序比那些不支持自动垃圾收集的语言编写的程序更不容易出现错误的特性之一。

不同的虚拟机实现以不同的方式处理垃圾收集,并且规范对 GC 的实现没有很严格的限制。在本章的后面,我们将讨论 HotSpot JVM(这是 Oracle 和 OpenJDK Java 实现的基础)。尽管这不是你可能会遇到的唯一的 JVM,但它在服务器端部署中是最常见的,并提供了现代生产 JVM 的参考示例。

Java 中的内存泄漏

Java 支持垃圾收集的事实大大降低了 内存泄漏 的发生率。内存泄漏是指分配了内存但从未释放的情况。乍一看,似乎垃圾收集可以防止所有内存泄漏,因为它回收了所有未使用的对象。

然而,在 Java 中,如果对未使用的对象保留了一个有效的(但未使用的)引用,仍然可能发生内存泄漏。例如,当一个方法运行了很长时间(或永远)时,该方法中的局部变量可能会比实际需要的时间长保留对象引用。以下代码为例:

public static void main(String args[]) {
  int bigArray[] = new int[100000];

  // Do some computations with bigArray and get a result.
  int result = compute(bigArray);

  // We no longer need bigArray. It will get garbage collected when
  // there are no more references to it. Because bigArray is a local
  // variable, it refers to the array until this method returns. But
  // this method doesn't return.
  // If we explicitly sever the reference by assigning it to
  // null then the garbage collector knows it can reclaim the array.
  bigArray = null;

  // Loop forever, handling the user's input
  for(;;) handle_input(result);
}

当你使用 HashMap 或类似的数据结构将一个对象与另一个对象关联起来时,也可能会发生内存泄漏。即使这两个对象都不再需要,关联仍然存在于映射中,直到映射本身被回收。如果映射的寿命远远长于它所持有的对象,这可能会导致内存泄漏。

介绍标记-清除算法

Java GC 通常依赖于被广泛称为标记-清除的算法家族。要理解这些算法,回顾一下所有 Java 对象都是在堆中创建的,当对象创建时,一个引用(基本上是指针)存储在 Java 局部变量(或字段)中。局部变量存在于方法的堆栈帧中,如果一个对象从方法中返回,那么当方法退出时,引用将传回调用者的堆栈帧。

由于所有对象都分配在堆中,当堆变满时(或者在细节上取决之前),GC 将会触发。标记-清除的基本思想是追踪堆并确定哪些对象仍在使用中。这可以通过检查每个 Java 线程的堆栈帧(以及一些其他引用来源)并跟随任何引用到堆中来完成。定位到的每个对象都会被标记为仍然存活,并且随后可以检查它是否具有任何引用类型的字段。如果有,这些引用也可以被跟踪和标记。

当递归跟踪活动完成时,所有剩余的未标记对象都被认为不再需要,并且它们占据的堆空间可以作为垃圾进行清除,即,它们使用的内存可以用于进一步的对象分配。如果此分析可以精确执行,那么这种类型的收集器被称为精确垃圾收集器,这并不奇怪。在所有实际目的上,所有 Java GC 都可以被认为是精确的,但在其他软件环境中可能并非如此。

在实际的 JVM 中,堆内存很可能会有不同的区域,并且真实的程序将在正常操作中使用它们全部。在图 6-1 中,我们展示了堆的一个可能布局,其中两个线程(T1 和 T2)持有指向堆的引用。

这些不同的区域被称为伊甸园幸存者老年代;我们稍后将在本章中了解每一个区域及其彼此之间的关系。为了简单起见,图示展示了 Java 堆的旧形式,其中每个内存区域是一个单一的内存块。现代的收集器实际上并不是这样布置对象的,但首先这样考虑会更容易理解!

JN7 0601

图 6-1. 堆结构

图中还显示,在程序运行时移动应用线程引用的对象将是危险的。

为了避免这种情况,像刚才描述的简单追踪 GC 将在运行时导致停顿(STW)。这是因为所有应用程序线程都被停止,然后进行 GC,最后再次启动应用程序线程。运行时通过在达到安全点时停止应用程序线程来处理此问题——例如,循环开始或方法调用返回之前。在这些执行点上,运行时知道可以停止应用程序线程而没有问题。

这些暂停有时会让开发人员担心,但对于大多数主流用途来说,Java 运行在一个不断在处理器核心上交换进程的操作系统(可能还有多个虚拟化层)上,因此这种轻微的额外停顿通常不是一个问题。在 HotSpot 情况下,已经做了大量工作来优化 GC 并减少 STW 时间,对于那些对应用程序工作负载重要的情况。我们将在下一节讨论其中的一些优化。

JVM 如何优化垃圾收集

弱分代假设(WGH)是我们在第一章中介绍的关于软件的运行时事实的一个很好的例子。简单来说,对象倾向于具有少数几种可能的寿命期望(称为)。

通常对象只存活了很短的时间(有时称为瞬态对象),然后就变得符合垃圾回收的条件。然而,有一小部分对象存活时间较长,注定成为程序长期状态的一部分(有时称为工作集)。这可以在图 6-2 中看到,我们看到内存量(或创建的对象数)根据预期生命周期进行了绘制。

JN7 0602

图 6-2. 弱分代假设

这个事实无法从程序的静态分析中推断出来,然而当我们测量软件的运行时行为时,我们看到这在广泛的工作负载范围内都是正确的。

HotSpot JVM 有一个专门设计用于利用弱分代假设的垃圾收集子系统,在本节中,我们将讨论这些技术如何适用于短寿命对象(这是主要情况)。这个讨论直接适用于 HotSpot,但其他 JVM 通常也采用类似或相关的技术。

在其最简单的形式中,分代垃圾收集器是一种注意到WGH的收集器。他们认为,通过监视内存的一些额外记录,将会比通过友好对待WGH而获得的收益更加有利。在最简单的分代收集器中,通常只有两代——通常被称为年轻代和老年代。

疏散

在我们最初的标记-清除方法中,在清理阶段,GC 回收了个别对象以便重用。这在某种程度上是可以接受的,但会导致内存碎片化以及 GC 需要维护一个可用的“空闲列表”内存块。然而,如果 WGH 成立,并且在任何给定的 GC 周期中大多数对象都是死的,那么使用替代方法来回收空间可能是有意义的。

这种方法通过将堆划分为不同的内存空间来实现;新对象在Eden空间创建。然后,在每次 GC 运行时,我们仅定位活动对象并将它们移动到不同的空间,这个过程称为evacuation。执行这种操作的收集器称为evacuating collectors,它们的特性是在收集结束时可以清空整个内存空间,以便重复使用。

图 6-3 展示了一个正在运行的 evacuating collector,实心块表示存活对象,而斜线框表示已分配但已经死亡(且不可达)的对象。

JN7 0603

图 6-3. 疏散收集器

这比朴素的收集方法潜在地更高效,因为不会触及死亡对象。这意味着 GC 时间与活动对象的数量成正比,而不是已分配对象的数量。唯一的缺点是略微增加的簿记成本——我们必须支付复制活动对象的成本,但这几乎总是与通过疏散策略实现的巨大收益相比微不足道。

使用疏散收集器还允许使用每线程分配。这意味着每个应用程序线程可以被分配一个连续的内存块(称为thread-local allocation buffer或 TLAB),用于分配新对象时的独占使用。当分配新对象时,只需在分配缓冲区中递增指针,这是一个极其廉价的操作。

如果一个对象在收集开始前刚创建,那么它将没有时间完成其目的并在 GC 周期开始之前死亡。在只有两个代的收集器中,这种短寿命对象将被移动到长寿命区域,几乎立即死亡,并在下一次完全收集之前一直保留在那里。由于这些收集事件较少(通常也更昂贵),这显得相当浪费。

为了缓解这个问题,HotSpot 引入了survivor space的概念,用于存放已经经历过前几次年轻对象收集的对象。存活的对象在tenuring threshold达到之前会在 survivor spaces 之间被疏散收集器复制,当对象被promoted到老年代时,称为TenuredOldGen。这解决了短寿命对象堆积在老年代的问题,但也增加了 GC 子系统的复杂性。

压缩

另一种收集算法形式称为compacting collector。这些收集器的主要特点是,在收集周期结束时,分配的内存(即存活对象)被安排在收集区域内的单一连续区域中。

正常情况下,所有存活对象都已经在内存池(或区域)内“洗牌”到了内存范围的开始位置,现在有一个指针指示可供应用程序线程重新启动后写入对象的空闲空间的开始位置。

压缩收集器将避免内存碎片化,但在消耗 CPU 量方面通常比疏散收集器昂贵得多。这两种算法之间存在设计权衡(其细节超出本书的范围),但这两种技术都在 Java(以及许多其他编程语言的)生产收集器中使用。长期存活对象最终进入的空间通常使用压缩收集器进行清理。

本书不讨论 GC 子系统的详细细节。对于需要关心这些细节的生产应用程序,应当查阅专业材料,比如优化 Java(O'Reilly)。

HotSpot 堆

HotSpot JVM 是一个相对复杂的代码片段,由解释器和即时编译器组成,以及一个用户空间内存管理子系统。它由 C、C++以及相当大量的平台特定的汇编代码组成。

注意

HotSpot 管理 JVM 堆本身,基本上完全在用户空间中进行,并且不需要执行系统调用来分配或释放内存。对象最初创建的区域通常称为 Eden(或者 Nursery),大多数生产 JVM 都会在收集 Eden 时使用疏散策略。

现在,让我们总结一下 HotSpot 堆的描述,并回顾其基本特性:

  • Java 堆是 JVM 启动时预留的连续内存块。

  • 只有一部分堆最初分配给各种内存池。

  • 应用程序运行时,内存池会根据需要调整大小。

  • 这些调整由 GC 子系统执行。

  • 对象由应用程序线程在 Eden 中创建,并在非确定性 GC 周期中删除。

  • 在必要时(即内存紧张时),GC 周期会运行。

  • 堆分为两代,年轻代和老年代。

  • 年轻代由 Eden 和幸存者空间组成,而老年代只是一个内存空间。

  • 经过几个 GC 周期后,对象会晋升到老年代。

  • 只收集年轻代的集合通常非常廉价(就所需计算而言)。

  • HotSpot 使用一种高级形式的标记-清除,并准备额外的簿记以提高 GC 性能。

在讨论垃圾收集器时,开发人员应了解另一个重要的术语区分:

并行收集器

一个垃圾收集器可以使用多个线程执行收集。

并发收集器

一个垃圾收集器可以在应用程序线程运行时同时运行。

到目前为止,我们描述的收集算法隐含地都是并行的,但不是并发的收集器。

注意

在现代 GC 方法中,越来越多地采用部分并发算法的趋势。这些类型的算法比 STW 算法更为复杂且计算成本更高,并涉及权衡。然而,今天的应用程序通常愿意为减少应用程序暂停而进行一些额外的计算。

在传统的 Java 版本(版本 8 及更早版本)中,堆具有简单的结构:每个内存池(Eden、幸存者空间和 Tenured)都是连续的内存块。这是我们在图表中展示的结构,因为它更容易让初学者可视化。这些旧版本中老年代的默认收集器称为并行。然而,在现代版本的 HotSpot 中,一个名为垃圾优先(G1)的新型部分并发收集算法已成为默认。

G1

G1 是基于区域的收集器的一个示例,并且与旧式堆的布局不同。一个区域是一个内存区域(通常大小为 1M,但较大堆可能有 2、4、8、16 或 32M 的区域),其中所有对象都属于同一个内存池。然而,在区域收集器中,组成池的不同区域不一定位于内存中的相邻位置。这与 Java 8 堆不同,在 Java 8 堆中,每个池都是连续的,尽管在这两种情况下整个堆仍然是连续的。

警告

G1 在每个 Java 版本中使用不同版本的算法,各版本在性能和其他行为方面有一些重要的差异。从 Java 8 升级到更高版本并采用 G1 时,进行全面性能重新测试非常重要。您可能会发现,切换到 Java 11 或 17 时,您需要更少的资源(甚至可以节省金钱)。

G1 的注意点在于它主要集中在大部分是垃圾的区域上,因为这些区域有最佳的自由内存回收。它是一个撤离收集器,在撤离单个区域时进行增量整理

最初,G1 收集器的目标是取代之前的 CMS 作为低暂停时间收集器,并允许用户根据 GC 时的暂停时间和频率指定暂停目标

JVM 提供了一个命令行开关来控制收集器的暂停目标:-XX:MaxGCPauseMillis=200。这意味着默认的暂停时间目标是 200 毫秒,但您可以根据需要更改此值。

当然,收集器能推动的极限是有限的。Java GC 受到新分配内存速度的驱动,对于许多 Java 应用程序来说,这是非常不可预测的。

如前所述,G1 最初是为了成为一种低暂停的替代收集器。然而,其行为的整体特性使得它实际上演变成了一种更通用的收集器(这就是为什么它现在成为默认收集器的原因)。

需要注意的是,开发一个适用于通用使用的新生产级收集器并非一蹴而就的过程。在接下来的章节中,让我们继续讨论 HotSpot 提供的替代收集器(包括 Java 8 的并行收集器)。

详细的全面处理超出了本书的范围,但了解备选收集器的存在是值得的。对于非 HotSpot 用户,您应该查阅您的 JVM 文档,了解可能适合您的选项。

并行老年代(ParallelOld)

默认情况下,在 Java 8 中,老年代的收集器是一个并行(但不是并发)的标记-清除收集器。乍看之下,它似乎与年轻代使用的收集器相似。然而,它在一个非常重要的方面有所不同:它是一个疏散式收集器。相反,老年代在进行收集时进行压缩。这是重要的,以防止内存空间随着时间的推移变得碎片化。

ParallelOld 收集器非常高效,但有两个特性使其在现代应用中不太理想。它是:

  • 完全 STW

  • 堆大小的暂停时间线性增长

这意味着一旦 GC 开始,就无法提前中止,并且必须允许循环完成。随着堆大小的增加,这使得ParallelOld 收集器比 G1 收集器不那么吸引人,后者可以在管理可分配速率的情况下通常保持恒定的暂停时间。

在现代部署中,特别是针对 Java 11+,G1 通常在以前使用ParallelOld的大多数应用程序中表现更好。ParallelOld 收集器截至 Java 17 仍然可用,供那些(希望很少)仍然需要的应用程序使用,但平台的方向显而易见——尽可能地使用 G1。

串行

串行和 SerialOld 收集器与并行收集器的运行方式类似,但有一个重要区别:它们仅使用单个 CPU 核心执行完全 STW 的 GC。

在现代多核系统上,使用这些收集器没有任何好处,因此不应使用它们,因为它们只是并行收集器的低效形式。然而,您仍可能在容器中运行 Java 应用程序时遇到这些收集器。有关容器化 Java 的完整讨论超出了本书的范围。但是,如果您的应用程序在容器中运行的环境过小(内存不足或仅有单个 CPU),那么 JVM 将自动选择串行收集器。

因此,我们不建议在单核容器中运行 Java,因为串行收集器在几乎所有实际负载场景下的表现明显比 G1 差。

Shenandoah

Shenandoah 是 Red Hat 开发的一种新的 GC 算法,可以有效地处理某些情况下 G1 和其他算法表现不佳的情况。

Shenandoah 的目标是降低暂停时间,特别是在大堆上,并尽可能地保证暂停时间不会超过 1 毫秒,无论堆的大小如何。

像 G1 一样,Shenandoah 是一种执行并发标记的疏散区域收集器。区域的疏散导致增量压缩,但关键的区别在于,在 G1 中,疏散发生在 STW 阶段,而在 Shenandoah 中,疏散是与应用线程并发进行的。

然而,没有免费的午餐,Shenandoah 的用户可能会经历高达 15% 的额外开销(即,应用吞吐量的减少),但确切的数字将取决于工作负载的细节。例如,在一些特定的基准测试中,您可以观察到显著的开销,接近预期范围的上限。

可以通过以下命令行开关激活 Shenandoah:

-XX:+UseShenandoahGC

需要注意的一个重要点是,在撰写本文时,Shenandoah 还不是一个分代收集器,尽管正在进行将分代添加到实现中的工作。

ZGC

除了 Shenandoah 外,Oracle 还创建了一种新的超低暂停收集器,称为 ZGC。它的设计旨在吸引与 Shenandoah 类似的工作负载,并且在意图、效果和开销上基本相似。ZGC 是一种单代、区域化、NUMA-aware、压缩收集器。然而,ZGC 的实现与 Shenandoah 大不相同。

可以通过以下命令行开关激活 ZGC:

-XX:+UseZGC

ZGC 只需一个停止-世界暂停来执行根扫描,这意味着 GC 暂停时间不会随着堆的大小或活动对象的数量增加而增加。由于其预期的适用领域(大堆的超低暂停),ZGC 最常由 Oracle 客户在 Oracle 支持的 Java 构建上使用。

终结

为了完整起见,开发人员应该知道一种称为 终结 的旧的资源管理技术。然而,这种技术已经 极度 被弃用,绝大多数 Java 开发人员在任何情况下 不应该 直接使用它。

注意

终结已经被弃用,并将在未来的发布中删除。目前机制仍然默认启用,但可以使用开关禁用。在未来的发布中,它将默认禁用,然后最终删除。

最终化机制旨在自动释放不再需要的资源。垃圾收集器可以自动释放对象使用的内存资源,但对象可能持有其他类型的资源,例如打开的文件和网络连接。垃圾收集器无法释放这些额外的资源,因此最终化机制旨在允许开发人员执行清理任务,如关闭文件、终止网络连接、删除临时文件等。

最终化机制的工作方式如下:如果一个对象有一个finalize()方法(通常称为终结器),那么在对象变得未使用(或不可达)但在垃圾收集器回收对象分配的空间之前,将调用该方法。终结器用于执行对象的资源清理工作。

最终化的核心问题在于 Java 不保证何时进行垃圾收集或对象收集的顺序。因此,平台无法保证何时(甚至是否)调用终结器或调用终结器的顺序。

最终化细节

最终化机制是试图在其他语言和环境中实现类似概念的一种尝试。特别是,C++有一种称为 RAII(资源获取即初始化)的模式,以类似的方式提供自动资源管理。在该模式中,程序员提供一个析构函数(在 Java 中称为finalize()),用于在对象销毁时执行清理和释放资源。

这个基本用例非常简单:当对象被创建时,它获取某些资源的所有权,并且对象对该资源的所有权与对象的生命周期相关联。当对象销毁时,资源的所有权会自动释放,因为平台会调用析构函数而无需程序员介入。

尽管最终化听起来与此机制表面上相似,但实际上它们是根本不同的。事实上,最终化语言特性存在致命缺陷,这是由于 Java 与 C++的内存管理方案之间的差异。

在 C++的情况下,内存是手动处理的,对象的生命周期由程序员控制。这意味着在对象删除后可以立即调用析构函数(平台保证这一点),因此资源的获取和释放直接与对象的生命周期相关联。

另一方面,Java 的内存管理子系统是一个根据需要运行的垃圾收集器,响应内存不足以分配时运行。因此,它以可变(非确定性)的间隔运行,因此finalize()只有在对象被收集时才会运行,而这将在未知时间发生。

如果使用finalize()机制自动释放资源(例如文件句柄),则不能保证这些资源何时(如果有的话)会实际可用。这导致最终化机制在其所声明的自动资源管理目的上基本不适用——我们无法保证最终化会在资源不足之前足够快地发生。作为保护稀缺资源(例如文件句柄)的自动清理机制,最终化在设计上存在缺陷。

最终化只有极少数合法用例,只有少数 Java 开发人员会遇到它们。如果有任何疑问,请不要使用最终化——通常正确的替代方法是使用try-with-resources。有关try-with-resources 的更多详细信息,请参见第十章。

Java 对并发的支持

线程的概念是执行的轻量级单位——比进程小,但仍能执行任意 Java 代码。通常的实现方式是每个线程作为操作系统的完全成熟执行单元,但属于一个进程,该进程的地址空间与该进程组成的其他线程共享。这意味着每个线程可以独立调度,具有自己的堆栈和程序计数器,但与同一进程中的其他线程共享内存和对象。

Java 平台从第一个版本开始就支持多线程编程。该平台为开发人员提供了创建新执行线程的能力。

要理解这一点,首先我们必须详细考虑 Java 程序启动时发生的情况以及原始应用程序线程(通常称为线程)的出现:

  1. 程序员执行java Main(也可能是其他启动方式)。

  2. 这使得 Java 虚拟机,即所有 Java 程序运行的上下文,启动起来。

  3. JVM 检查其参数,并看到程序员请求从Main.class的入口点(main()方法)开始执行。

  4. 假设Main通过类加载检查,为程序的执行启动了一个专用线程(主线程)。

  5. JVM 字节码解释器在主线程上启动。

  6. 主线程的解释器读取Main::main()的字节码,执行开始,逐个字节码执行。

每个 Java 程序都是这样开始的,但这也意味着:

  • 每个 Java 程序都作为管理模型的一部分开始,每个线程都有一个解释器。

  • 每个 Java 程序始终作为多线程操作系统进程的一部分运行。

  • JVM 具有控制 Java 应用程序线程的能力。

由此引发的是,在 Java 代码中创建新的执行线程通常很简单:

Thread t = new Thread(() -> {System.out.println("Hello Thread");});
t.start();

这段小代码创建并启动一个新线程,该线程执行 lambda 表达式的主体,然后执行。从技术上讲,lambda 在传递给Thread构造函数之前被转换为Runnable接口的实例。

线程机制允许新线程与原始应用程序线程以及 JVM 本身为各种目的启动的线程并发执行。

对于 Java 平台的主流实现,每当我们调用Thread::start()时,此调用被委托给操作系统,并创建一个新的 OS 线程。这个新的 OS 线程exec()了 JVM 字节码解释器的一个新副本。解释器从run()方法(或等效地从 lambda 的主体)开始执行。

这意味着应用程序线程的 CPU 访问由操作系统的调度程序控制——这是操作系统的内置部分,负责管理处理器时间片段(并且不允许应用程序线程超出其分配的时间)。

在 Java 的较新版本中,出现了越来越多的运行时管理并发的趋势。这是指对于许多目的来说,显式管理线程并不理想。相反,运行时应该提供“点火并忘记”的能力,程序指定需要做什么,但如何完成这些工作的底层细节留给运行时处理。这一观点可以在java.util.concurrent中的并发工具包中看到,我们在第八章中简要讨论。

在本章的其余部分,我们将介绍 Java 平台提供的低级并发机制,每个 Java 开发人员都应该了解。强烈建议读者在进行任何重要的并发编程之前,理解基于低级Thread和运行时管理方法。

线程生命周期

让我们从查看应用程序线程的生命周期开始。每个操作系统都有一个线程视图,其细节可能不同(但在大多数情况下在高层次上是大致相似的)。Java 努力将这些细节抽象化,并有一个称为Thread.State的枚举,它包装了操作系统对线程状态的视图。Thread.State的值提供了线程生命周期的概述:

NEW

线程已创建,但尚未调用其start()方法。所有线程都从这个状态开始。

RUNNABLE

当操作系统安排它时,线程正在运行或可运行。

BLOCKED

线程没有运行,因为它正在等待获取锁定,以便可以进入synchronized方法或块。在本节的后面部分,我们将进一步了解synchronized方法和块。

WAITING

线程没有运行,因为它已调用Object.wait()Thread.join()

TIMED_WAITING

线程不在运行,因为它已调用Thread.sleep()或已调用Object.wait()Thread.join()并带有超时值。

TERMINATED

线程已完成执行。它的run()方法已正常退出或通过抛出异常退出。

这些状态代表了一个线程的视图,这在至少主流操作系统中是通用的,导致一个像图 6-4 那样的视图。

JN7 0604

图 6-4. 线程生命周期

线程也可以使用Thread.sleep()方法使其休眠。这需要一个以毫秒为单位的参数,表示线程希望休眠的时间,如下所示:

try {
    Thread.sleep(2000);
} catch (InterruptedException e) {
    e.printStackTrace();
}

sleep方法的参数是对操作系统的请求,而不是一个要求。例如,您的程序可能因负载和其他特定于运行时环境的因素而睡眠时间超过请求的时间。

我们将在本章稍后讨论Thread的其他方法,但首先我们需要涵盖一些重要的理论,这些理论涉及线程如何访问内存,并且对理解为何多线程编程困难且可能给开发者带来许多问题至关重要。

可见性和可变性

在主流的 Java 实现中,一个进程中所有 Java 应用程序线程都有自己的调用堆栈(和本地变量),但共享一个单一的堆。这使得在线程之间共享对象非常容易,因为只需从一个线程传递一个引用到另一个线程即可。这在图 6-5 中有所说明。

这导致了 Java 的一个通用设计原则——对象默认是可见的。如果我有一个对象的引用,我可以复制它并将其交给另一个线程,没有任何限制。Java 的引用实质上是指向堆中某个位置的类型化指针——线程共享同一个堆,因此默认可见是一种自然的模型。

除了默认可见性,Java 还有另一个对完全理解并发性非常重要的属性,即对象是可变的:对象实例字段的内容通常可以更改。我们可以通过使用final关键字使个别变量或引用常量化,但这并不适用于对象的内容。

正如我们将在本章的其余部分看到的那样,这两个属性的结合——跨线程的可见性和对象的可变性——在尝试推理并发 Java 程序时引发了许多复杂性。

JN7 0605

图 6-5. 线程之间共享的内存

并发安全性

如果我们要编写正确的多线程代码,那么我们希望我们的程序满足某个重要的属性。

在第五章中,我们定义了一个安全的面向对象程序,即通过调用其可访问方法将对象从合法状态移动到合法状态。这个定义对于单线程代码很有效。然而,当我们尝试将其扩展到并发程序时,会遇到一个特殊的困难。

提示

安全的多线程程序 是指任何对象不论调用什么方法,以及应用程序线程由操作系统按何种顺序调度,都不会被其他对象看到处于非法或不一致状态的程序。

对于大多数主流情况,操作系统将根据负载和系统中其他运行情况,在特定处理器核心上安排线程运行。如果负载很高,可能还有其他需要运行的进程。

操作系统如果需要的话,会强制从 CPU 核心上删除 Java 线程。该线程会立即暂停,无论它当前在做什么,包括正在执行方法的中间过程。然而,正如我们在第五章中讨论的那样,一个方法在操作对象时可能会将其临时置于非法状态,只要在方法退出之前纠正即可。

这意味着,如果一个线程在完成长时间方法之前被换出,即使程序遵循安全规则,也可能会使对象处于不一致状态。另一种说法是,即使正确建模了单线程情况下的数据类型,也仍需保护免受并发影响。为此添加额外保护层的代码称为并发安全 或(更非正式地)线程安全。

在下一节中,我们将讨论实现这种安全性的主要手段,而在本章末尾,我们将介绍一些在某些情况下也可能有用的其他机制。

排除和保护状态

任何修改或读取可能导致状态不一致的代码都必须受到保护。为此,Java 平台只提供了一种机制:排除

考虑一个方法,其中包含一系列操作,如果中途被中断,可能会使对象处于不一致或非法状态。如果这种非法状态对其他对象可见,则可能导致代码执行错误。

例如,考虑一个 ATM 或其他发放现金的机器:

public class Account {
    private double balance = 0.0; // Must be >= 0
    // Assume the existence of other field (e.g., name) and methods
    // such as deposit(), checkBalance(), and dispenseNotes()

    public Account(double openingBal) {
        balance = openingBal;
    }

    public boolean withdraw(double amount) {
        if (balance >= amount) {
            try {
                Thread.sleep(2000); // Simulate risk checks
            } catch (InterruptedException e) {
                return false;
            }
            balance = balance - amount;
            dispenseNotes(amount);
            return true;
        }
        return false;
    }
}

withdraw() 内发生的操作序列可能会使对象处于不一致状态。特别是在检查余额后,第二个线程可能在第一个线程在模拟风险检查时休眠时介入,并且账户可能会透支,违反了balance >= 0的约束。

这是一个系统的例子,对象上的操作是单线程安全的(因为如果从单个线程调用,则对象不会达到非法状态(balance < 0)),但不是并发安全的。

为了让开发人员使此类代码具备并发安全性,Java 提供了synchronized关键字。此关键字可以应用于块或方法,当使用它时,平台将其用于限制块或方法内部代码的访问。

注意

因为synchronized包围了代码,许多开发人员得出结论,Java 中的并发性是关于代码的。一些文本甚至将位于同步块或方法内部的代码称为临界区,并认为这是并发性的关键方面。实际情况并非如此;相反,我们必须防范的是数据的不一致性,正如我们将看到的那样。

Java 平台为其创建的每个对象跟踪一个特殊的标记,称为监视器。这些监视器(也称为)被synchronized用来指示以下代码可能会暂时使对象不一致。synchronized块或方法的事件序列是:

  1. 线程需要修改对象,并可能在中间步骤中使其暂时不一致

  2. 线程获取监视器,表示它需要临时独占对象的访问权限

  3. 线程修改对象,在完成时将其保留在一致的、合法状态

  4. 线程释放监视器

如果另一个线程在修改对象时尝试获取锁定,则获取锁定的尝试将被阻塞,直到持有线程释放锁定。

请注意,除非您的程序创建了多个共享数据的线程,否则无需使用synchronized语句来保护数据结构。如果只有一个线程访问数据结构,就无需用synchronized保护它。

一个关键的要点是——获取监视器并不会阻止访问对象。它只是防止其他线程获取锁。正确的并发安全代码要求开发人员确保所有可能修改或读取潜在不一致状态的访问都在操作或读取该状态之前获取对象监视器。

换句话说,如果一个synchronized方法正在操作一个对象并将其置于非法状态,而另一个(非同步的)方法读取该对象,则可能看到不一致的状态。

注意

同步是一种保护状态的协作机制,因此非常脆弱。一个错误(例如在所需的方法上漏掉一个synchronized关键字)可能会对系统整体的安全性产生灾难性的后果。

使用关键字synchronized作为“需要临时独占访问”的关键字的原因是,除了获取监视器之外,JVM 还会在进入代码块时重新从主内存中读取对象的当前状态。同样,在退出synchronized块或方法时,JVM 会将对象的任何修改状态刷新回主内存。

没有同步,系统中不同的 CPU 核心可能看到内存的不同视图,内存不一致性可能会损坏运行程序的状态,就像我们在 ATM 示例中看到的那样。

这最简单的例子称为丢失更新,如下面的代码所示:

public class Counter {
    private int i = 0;

    public int increment() {
        return i = i + 1;
    }
    public int getCounter() { return i; }
}

可以通过一个简单的控制程序来驱动这一点:

    Counter c = new Counter();
    int REPEAT = 10_000_000;
    Runnable r = () -> {
        for (int i = 0; i < REPEAT; i++) {
            c.increment();
        }
    };
    Thread t1 = new Thread(r);
    Thread t2 = new Thread(r);

    t1.start();
    t2.start();
    t1.join();
    t2.join();

    int anomaly = (2 * REPEAT) - c.getCounter();
    double perc = ((double) anomaly * 100) / (2 * REPEAT);
    System.out.println("Lost updates: "+ anomaly +" ; % = " + perc);

如果这个并发程序是正确的,那么异常值(丢失更新的数量)应该完全为零。但事实并非如此,因此我们可以得出结论,非同步访问基本上是不安全的。

相比之下,我们还看到将关键字synchronized添加到增量方法中足以将丢失更新异常减少到零—即使在多个线程存在的情况下,也能使方法正确。

volatile

Java 提供了另一个关键字来处理对数据的并发访问。这就是volatile关键字,它指示在应用程序代码使用字段或变量之前,必须重新从主内存中读取其值。同样,在修改了volatile值后,一旦写入变量完成,它必须立即写回主内存。

volatile关键字的一个常见用法是“运行直到关闭”模式。这在多线程编程中被使用,其中外部用户或系统需要向处理线程发出信号,告知它应该完成当前正在进行的工作,然后优雅地关闭。有时这被称为“优雅完成”模式。让我们看一个典型的例子,假设这段代码用于实现Runnable的类中的处理线程:

private volatile boolean shutdown = false;

public void shutdown() {
    shutdown = true;
}

public void run() {
    while (!shutdown) {
        // ... process another task
    }
}

只要另一个线程没有调用shutdown()方法,处理线程就会继续顺序处理任务(这通常与BlockingQueue结合非常有用以传递工作)。一旦另一个线程调用了shutdown(),处理线程立即看到shutdown标志改为true。这不会影响正在运行的作业,但一旦任务完成,处理线程将不会接受另一个任务,而是会优雅地关闭。

然而,尽管 volatile 关键字很有用,但它并不能完全保护状态——正如我们在 Counter 中使用它来标记字段 volatile 一样。我们可能天真地假设这会保护 Counter 中的代码。然而,实际上并非如此。为了验证这一点,修改之前的 Counter 示例,并在字段 i 中添加 volatile 关键字,然后重新运行示例。观察到的非零异常值(因此丢失更新问题的存在)告诉我们,仅凭 volatile 关键字并不能使代码线程安全。

Thread 的有用方法

Thread 类具有许多方法,可在创建新应用程序线程时为您提供便利。这不是一个详尽的列表——Thread 上还有许多其他方法,但这是一些常见方法的描述。

getId()

此方法返回线程的 ID 号,作为 long 类型。此 ID 在线程生命周期内保持不变,并且在此 JVM 实例中保证是唯一的。

getPriority() 和 setPriority()

这些方法用于控制线程的优先级。调度程序决定如何处理线程优先级;例如,一种策略可能是在高优先级线程等待时不运行低优先级线程。在大多数情况下,无法影响调度程序如何解释优先级。线程优先级表示为一个介于 1 和 10 之间的整数,10 表示最高优先级。

setName() 和 getName()

这些方法允许开发者为每个线程设置或检索名称。命名线程是一种良好的实践,特别是在像 JDK Mission Control 这样的工具中,它可以极大地简化调试(我们将在第十三章中简要讨论)。

getState()

返回一个 Thread.State 对象,指示此线程处于哪种状态,其值如“线程生命周期”中定义的那样。

isAlive()

此方法用于测试线程是否仍然存活。

start()

此方法用于创建一个新的应用程序线程,并安排其运行,其中 run() 方法是执行的入口点。当线程在其 run() 方法中达到结尾或在该方法中执行 return 语句时,线程会正常终止。

interrupt()

如果线程在 sleep()wait()join() 调用中被阻塞,则在表示该线程的 Thread 对象上调用 interrupt() 将导致线程收到 InterruptedException(并唤醒)。

如果线程涉及可中断 I/O,则 I/O 将被终止,并且线程将收到 ClosedByInterruptException。即使线程未参与可以中断的任何活动,线程的中断状态也将设置为 true

join()

当前线程等待,直到与 Thread 对象对应的线程已经终止。可以将其视为一条指令,要求在另一个线程完成之前不要继续执行。

setDaemon()

用户线程是一种线程,如果它仍然活动,将阻止进程退出 — 这是线程的默认行为。有时,程序员希望线程不阻止退出的发生 — 这些称为守护线程。线程作为守护线程或用户线程的状态可以通过setDaemon()方法来控制,并可以使用isDaemon()来检查。

setUncaughtExceptionHandler()

当线程通过抛出异常(即程序没有捕获的异常)退出时,默认行为是打印线程的名称、异常类型、异常消息和堆栈跟踪。如果这不足够,您可以为线程安装自定义的未捕获异常处理程序。例如:

// This thread just throws an exception
Thread handledThread =
  new Thread(() -> { throw new UnsupportedOperationException(); });

// Giving threads a name helps with debugging
handledThread.setName("My Broken Thread");

// Here's a handler for the error.
handledThread.setUncaughtExceptionHandler((t, e) -> {
    System.err.printf("Exception in thread %d '%s':" +
        "%s at line %d of %s%n",
        t.getId(),    // Thread id
        t.getName(),  // Thread name
        e.toString(), // Exception name and message
        e.getStackTrace()[0].getLineNumber(),
        e.getStackTrace()[0].getFileName()); });
handledThread.start();

在某些情况下,这可能很有用;例如,如果一个线程正在监督一组其他工作线程,那么这种模式可以用来重新启动任何死掉的线程。

还有setDefaultUncaughtExceptionHandler(),一个static方法,设置一个备用处理程序来捕获任何线程的未捕获异常。

线程的弃用方法

除了Thread的有用方法外,还有一些危险的方法不应使用。这些方法是原始 Java 线程 API 的一部分,但很快就被发现不适合开发者使用。不幸的是,由于 Java 的向后兼容性要求,无法将它们从 API 中移除。开发者需要意识到它们,并在所有情况下都避免使用。

stop()

Thread.stop() 几乎不可能正确使用,因为stop()会立即终止线程,而不给它恢复对象到合法状态的机会。这与并发安全等原则直接相反,因此永远不应使用。

suspend()、resume()和 countStackFrames()

suspend()机制挂起时,不会释放它所持有的任何监视器,因此任何试图访问这些监视器的其他线程都将导致死锁。在实践中,这种机制在这些死锁和resume()之间产生竞态条件,使得这组方法无法使用。方法countStackFrames()仅在对挂起线程调用时才起作用,因此也被此限制禁用。

destroy()

此方法从未被实现 — 如果实现了,它将会遇到与suspend()相同的竞态条件问题。

所有这些已弃用的方法都应该被始终避免使用。已开发出一组安全替代模式,这些模式实现了与前述方法相同的预期目标。其中一个良好的示例是我们已经见过的运行至关闭模式。

多线程处理

要有效地处理多线程代码,您需要掌握关于监视器和锁的基本事实。这份清单包含了您应该了解的主要事实:

  • 同步是保护对象状态和内存,而不是代码。

  • 同步是线程之间的一种合作机制。一个 bug 可能会破坏这种合作模型,并产生深远的后果。

  • 获取监视器只会防止其他线程获取监视器 —— 它并不会保护对象本身。

  • 非同步方法在锁定对象的监视器时可能会看到(并修改)不一致的状态。

  • 锁定 Object[] 不会锁定各个对象。

  • 基本类型是不可变的,因此它们不能(也不需要)被锁定。

  • 在接口方法声明中不能出现 synchronized

  • 内部类只是语法糖,因此对内部类的锁定对外部类没有影响(反之亦然)。

  • Java 的锁是 可重入 的。这意味着如果持有监视器的线程遇到同一监视器的同步块,它可以进入该块。¹

我们还看到线程可以被要求睡眠一段时间。同样有用的是无限期地睡眠,直到满足条件。在 Java 中,这通过 Object 上存在的 wait()notify() 方法来处理。

就像每个 Java 对象都有一个关联的锁一样,每个对象都维护着一个等待线程的列表。当线程调用对象的 wait() 方法时,线程持有的任何锁都会被临时释放,并且该线程会被添加到该对象的等待线程列表中,并停止运行。当另一个线程调用相同对象的 notifyAll() 方法时,对象会唤醒等待的线程,并允许它们继续运行。

例如,让我们看一个简化版本的队列,它对多线程使用是安全的:

/*
 * One thread calls push() to put an object on the queue.
 * Another calls pop() to get an object off the queue. If there is no
 * data, pop() waits until there is some, using wait()/notify().
 */
public class WaitingQueue<E> {
    LinkedList<E> q = new LinkedList<E>(); // storage
    public synchronized void push(E o) {
        q.add(o);         // Append the object to the end of the list
        this.notifyAll(); // Tell waiting threads that data is ready
    }
    public synchronized E pop() {
        while(q.size() == 0) {
            try { this.wait(); }
            catch (InterruptedException ignore) {}
        }
        return q.remove();
    }
}

如果队列为空(导致 pop() 失败),这个类会在 WaitingQueue 的实例上使用 wait()。等待的线程会暂时释放其监视器,允许另一个线程来获取它 —— 一个可能会在队列上 push() 新内容的线程。当原始线程再次被唤醒时,它会从最初等待的位置重新开始运行,并重新获取它的监视器。

注意

wait()notify() 必须在 synchronized 方法或块中使用,因为它们需要临时放弃锁才能正常工作。

一般情况下,大多数开发者不应该像本例中这样自己编写类——而是应该使用 Java 平台为你提供的库和组件。

总结

在本章中,我们讨论了 Java 对内存和并发的视图,以及这些主题如何密切相关。

Java 的垃圾回收是简化开发的重要方面之一,因为它消除了程序员手动管理内存的需要。我们已经看到 Java 提供了先进的 GC 能力,以及现代版本默认使用部分并发的 G1 收集器。

此外,随着处理器开发出越来越多的核心,我们将需要使用并发编程技术来有效地利用这些核心。换句话说,并发性是未来高性能应用的关键。

Java 的线程模型基于三个基本概念:

共享的、默认可见的可变状态

对象可以在进程中的不同线程之间轻松共享,并且任何持有它们引用的线程都可以对它们进行改变(“突变”)。

抢占式线程调度

操作系统线程调度程序可以随时在核心上切换线程。

对象状态只能由锁来保护

锁可能难以正确使用,状态在意想不到的地方(如读操作)也很容易受到影响。

综上所述,Java 在并发处理方面的这三个方面解释了为什么多线程编程会给开发者带来这么多头痛。

¹ 除了 Java 之外,不是所有的锁实现都具有这种属性。

第二部分:使用 Java 平台

第二部分介绍了一些随 Java 附带的核心库以及一些中高级 Java 程序常见的编程技术。

  • 第七章,“编程和文档约定”

  • 第八章,“Java 集合框架使用”

  • 第九章,“处理常见数据格式”

  • 第十章,“文件处理和 I/O”

  • 第十一章,“类加载、反射和方法句柄”

  • 第十二章,“Java 平台模块”

  • 第十三章,“平台工具”

第七章:编程和文档约定

本章解释了许多重要且有用的 Java 编程和文档约定。它涵盖了:

  • 一般的命名和大小写约定

  • 可移植性提示和约定

  • javadoc 文档注释语法和约定

命名和大小写约定

以下广泛采用的命名约定适用于 Java 中的模块、包、引用类型、方法、字段和常量。由于这些约定几乎被普遍遵循,并且因为它们影响你所定义的类的公共 API,你应该也要采用它们:

模块

由于从 Java 9 开始,模块是 Java 应用程序的首选分发单元,所以当命名它们时应特别小心。

模块名必须是全局唯一的——模块系统基本上是以此假设为前提的。由于模块实际上是超级包(或者包的聚合),模块名应与分组到模块中的包名密切相关。一个推荐的做法是将包分组到模块中,并使用包的 根名称 作为模块名。例如,如果一个应用程序的所有包都位于 com.mycompany.* 下,则 com.mycompany 是你的模块的一个好名字。

通常习惯确保你公开可见的包名是唯一的。一种常见的做法是用你拥有的互联网域名的倒置名称作为前缀(例如,com.oreilly.javanutshell)。

现在对这个约定的严格遵循已经不像以前那样严格了,一些项目仅仅采用一个简单、可识别且唯一的前缀。所有包名应该是小写的。

类型名应该以大写字母开头,并使用驼峰命名法(例如,String)。这通常被称为 帕斯卡命名法。如果一个类名由多个单词组成,每个单词应该以大写字母开头(例如,StringBuffer)。如果类型名或类型名中的一个词是一个首字母缩写词,那么首字母缩写词可以用全大写字母来书写(例如,URLHTMLParser)。

因为类和枚举类型被设计用来表示对象,所以你应该选择名词作为类名(例如,ThreadTeapotFormatConverter)。

枚举类型是具有有限实例数量的类的特殊情况。除非是非常特殊的情况,它们应该被命名为名词。enum 类型定义的常量通常也是按照下面的常量规则写成全大写字母。

接口

Java 程序员通常以以下两种方式使用接口:要么传达一个类具有额外的、补充的方面或行为;要么指示该类是接口的一个可能的实现,而对于这个接口有多种有效的实现选择。

当一个接口用于提供关于实现它的类的附加信息时,通常选择一个形容词作为接口名称(例如 RunnableCloneableSerializable)。

当一个接口旨在更像一个抽象超类时,使用名词作为名称(例如,DocumentFileNameMapCollection)。按照惯例,不要通过名称表明它是一个接口(即不要使用 IDocumentDocumentInterface)。

方法

方法名始终以小写字母开头。如果名称包含多个单词,则从第二个单词开始每个单词的首字母大写(例如,insert()insertObject()insertObjectAt())。这通常被称为驼峰命名法

方法名称通常选择使第一个单词为动词。方法名称可以尽可能长以清晰表达其目的,但在可能的情况下选择简洁的名称。避免过于通用的方法名称,如 performAction()go(),或可怕的 doIt()

字段和常量

非常量字段名称遵循与方法名称相同的大写规范。应选择最能描述字段用途或值的名称。不鼓励使用前缀来指示字段的类型或可见性。

如果一个字段是 static final 常量,则应使用全大写字母编写。如果常量的名称包含多个单词,则应使用下划线分隔这些单词(例如,MAX_VALUE)。

参数

方法参数遵循与非常量字段相同的大写规范。方法参数的名称出现在方法的文档中,因此应选择能够尽可能清楚地表明参数用途的名称。尽量将参数名称保持为单个单词,并且在使用时保持一致。例如,如果一个 WidgetProcessor 类定义了许多接受 Widget 对象作为第一个参数的方法,则将该参数命名为 widget

局部变量

局部变量名称是实现细节,从不在类外部可见。尽管如此,选择良好的名称可以使您的代码更易于阅读、理解和维护。通常,变量的命名遵循与方法和字段相同的约定。

除了特定类型名称的约定外,还有关于您应在名称中使用哪些字符的约定。Java 允许在任何标识符中使用 $ 字符,但按照惯例,其使用应保留给源代码处理器生成的合成名称。例如,Java 编译器用它来使内部类工作。不应在您创建的任何名称中使用 $ 字符。

Java 允许名称使用来自整个 Unicode 字符集的任何字母数字字符。虽然这对于非英语系程序员来说可能很方便,但 Unicode 的使用从未真正普及,这种用法非常罕见。

实用命名

我们赋予构造物的名称非常重要。命名是将我们的抽象设计传达给同行的关键过程。将软件设计从一个人的头脑转移到另一个人的头脑的过程很难——在许多情况下,比将我们的设计从头脑转移到将执行它的机器更难。

因此,我们必须尽一切努力确保这一过程得以简化。名称是这一过程的关键。在审查代码时(所有代码都应该经过审查),特别注意已选择的名称:

  • 类型的名称是否反映了这些类型的目的?

  • 每个方法是否确切地执行其名称所暗示的操作?理想情况下,既不多也不少?

  • 名称是否足够描述性?是否可以使用更具体的名称?

  • 这些名称是否适合描述它们所描述的领域?

  • 名称是否在整个领域中保持一致?

  • 名称是否混合了隐喻?

  • 名称是否重复使用了软件工程中的常见术语?

  • 布尔返回方法的名称是否包括否定?这些通常需要更多注意力才能理解(例如,notEnabled() vs. enabled())。

在软件中,混合隐喻很常见,尤其是在应用程序发布了几个版本之后。一个系统最初完全合理地使用称为Receptionist(用于处理传入连接)、Scribe(用于持久化订单)和Auditor(用于检查和调解订单)的组件,很容易在后续版本中以一个称为Watchdog的类结束,用于重新启动进程。这并不是很糟糕,但它打破了先前存在的人们职称的已建立模式。

还有一点非常重要,那就是要意识到软件随时间变化很多。发布第 1 版时非常适当的名称可能到第 4 版时已经非常误导。应该注意的是,随着系统的重心和意图的变化,名称应该与代码一起进行重构。现代 IDE 对全局搜索和替换符号没有问题,因此在不再有用时没有必要固守过时的隐喻。

最后要注意的一点是:过于严格地解释这些指南可能会导致开发人员产生一些非常奇怪的命名结构。有许多优秀的描述,说明了将这些约定推向极端可能导致的一些荒谬行为。

换句话说,这里描述的约定并非强制性的。在绝大多数情况下,遵循它们将使您的代码更易于阅读和维护。但是,如果因为更易于阅读和理解而偏离这些指南,也不必害怕。

宁可违反这些规则,也不要说出任何显得十分粗野的话。

乔治·奥威尔

最重要的是,您应该对您编写的代码预期的寿命有所了解。银行中的风险计算系统可能有十年或更长的寿命,而初创公司的原型可能仅在几周内相关。因此,需要相应地进行文档编写 - 代码越长时间活跃,其文档和命名就需要越好。

Java 文档注释

Java 代码中的大多数普通注释解释了该代码的实现细节。相比之下,Java 语言规范定义了一种特殊类型的注释,称为文档注释,用于记录您代码的 API。

文档注释是普通的多行注释,以/**开头(而不是通常的/*),以*/结尾。文档注释出现在类型或成员定义之前,包含该类型或成员的文档。文档可以包括简单的 HTML 格式化标记和其他特殊关键字,提供额外的信息。

编译器会忽略文档注释,但可以通过javadoc程序提取并自动转换为在线 HTML 文档(请参阅第十三章以获取有关javadoc的更多信息)。

这里是一个包含适当文档注释的示例类:

/**
 * This immutable class represents <i>complex numbers</i>.
 *
 * @author David Flanagan
 * @version 1.0
 */
public class Complex {
    /**
 * Holds the real part of this complex number.
 * @see #y
 */
    protected double x;

    /**
 * Holds the imaginary part of this complex number.
 * @see #x
 */
    protected double y;

    /**
 * Creates a new Complex object that represents the complex number
 * x+yi.
 * @param x The real part of the complex number.
 * @param y The imaginary part of the complex number.
 */
    public Complex(double x, double y) {
        this.x = x;
        this.y = y;
    }

    /**
 * Adds two Complex objects and produces a third object that
 * represents their sum.
 * @param c1 A Complex object
 * @param c2 Another Complex object
 * @return  A new Complex object that represents the sum of
 *          <code>c1</code> and <code>c2</code>.
 * @exception java.lang.NullPointerException
 *            If either argument is <code>null</code>.
 */
    public static Complex add(Complex c1, Complex c2) {
        return new Complex(c1.x + c2.x, c1.y + c2.y);
    }
}

文档注释的结构

文档注释的正文应以对被记录的类型或成员的一句摘要开始。这句话可能会单独显示为摘要文档,因此应编写得能够独立存在。初始句子后面可以跟随任意数量的其他句子和段落,详细描述类、接口、方法或字段。

在描述性段落之后,文档注释可以包含任意数量的其他段落,每个段落以特殊的文档注释标签开头,例如@author@param@returns。这些标记段落为javadoc程序以标准方式显示提供了有关类、接口、方法或字段的具体信息。文档注释标签的完整集合将在下一节中列出。

文档注释中的描述材料可以包含简单的 HTML 标记,如用于强调的<i>;用于类、方法和字段名称的<code>;以及用于多行代码示例的<pre>。它还可以包含<p>标记以将描述分隔成单独的段落,以及<ul><li>和相关标记以显示项目符号列表和类似结构。但请记住,您编写的材料嵌入在更大、更复杂的 HTML 文档中。因此,文档注释不应包含可能干扰更大文档结构的主要结构 HTML 标记,如<h2><hr>

避免在文档注释中使用 <a> 标签来包含超链接或交叉引用。相反,请使用特殊的 {@link} 文档注释标签,与其他文档注释标签不同,它可以出现在文档注释的任何位置。正如在下一节所述,{@link} 标签允许您指定到其他类、接口、方法和字段的超链接,而无需了解 javadoc 使用的 HTML 结构约定和文件名。

如果要在文档注释中包含图像,请将图像文件放置在源代码目录的 doc-files 子目录中。将图像命名为与类相同,并带有整数后缀。例如,名为 Circle 类文档注释中的第二个图像可以使用以下 HTML 标签包含:

<img src="doc-files/Circle-2.gif">

因为文档注释的行被嵌入在 Java 注释中,每行注释的开头空格和星号 (*) 在处理之前都会被去除。因此,您不需要担心星号出现在生成的文档中,也不需要担心注释的缩进会影响使用 <pre> 标签包含在注释中的代码示例的缩进。

文档注释标签

javadoc 程序识别一些特殊标签,每个标签以 @ 字符开头。这些文档注释标签允许您以标准化的方式将特定信息编码到您的注释中,并允许 javadoc 选择适合该信息的输出格式。例如,@param 标签允许您指定方法的单个参数的名称和含义。javadoc 可以提取此信息并使用 HTML <dl> 列表、HTML <table> 或其他适合的方式显示它。

下面是javadoc所识别的文档注释标签;一个文档注释应按照这里列出的顺序使用这些标签:

@author name

添加一个包含指定名称的“Author:”条目。这个标签应该用于每个类或接口的定义,但不能用于单独的方法和字段。如果一个类有多个作者,可以在相邻的行上使用多个 @author 标签。例如:

@author David Flanagan
@author Ben Evans
@author Jason Clark

按照时间顺序列出作者,首先是原始作者。如果作者未知,您可以使用“未署名”。除非指定了 -author 命令行参数,否则 javadoc 不会输出作者信息。

@version text

插入一个包含指定文本的“Version:”条目。例如:

@version 1.32, 08/26/04

这个标签应该包含在每个类和接口的文档注释中,但不能用于单独的方法和字段。这个标签通常与版本控制系统(如 git、Perforce 或 SVN)的自动版本编号功能一起使用。除非指定了 -version 命令行参数,否则javadoc不会在生成的文档中输出版本信息。

@param parameter-name description

将指定的参数及其描述添加到当前方法的“Parameters:”部分。方法或构造函数的文档注释必须包含方法期望的每个参数的一个@param标记。这些标记应按方法指定的参数顺序出现。此标记仅可用于方法和构造函数的注释。

鼓励您在可能的情况下使用短语和句子片段,以保持描述的简洁性。但是,如果一个参数需要详细的文档,描述可以换行并包含尽可能多的文本。为了在源代码形式中的可读性,考虑使用空格来对齐描述。例如:

@param o      the object to insert
@param index  the position to insert it at

@return description

插入一个包含指定描述的“Returns:”部分。除非方法返回void或是构造函数,否则该标记应出现在每个方法的文档注释中。描述可以尽可能长,但考虑使用句子片段以保持简短。例如:

@return <code>true</code> if the insertion is successful, or
        <code>false</code> if the list already contains the object.

@exception full-classname description

添加一个包含指定异常名称和描述的“Throws:”条目。方法或构造函数的文档注释应该为其throws子句中出现的每个已检查异常包含一个@exception标记。例如:

@exception java.io.FileNotFoundException
           If the specified file could not be found

当方法可能抛出用户可能希望捕获的未检查异常(即RuntimeException的子类)时,可以选择使用@exception标记。如果方法可能抛出多个异常,请在相邻的行上使用多个@exception标记,并按字母顺序列出异常。描述可以简短或长到足以描述异常的重要性。此标记仅可用于方法和构造函数的注释。@throws标记是@exception的同义词。

@throws full-classname description

此标记是@exception的同义词。

@see reference

添加一个包含指定引用的“See Also:”条目。此标记可以出现在任何类型的文档注释中。reference的语法在 “交叉引用在文档注释中” 中有解释。

@deprecated explanation

该标记指定以下类型或成员已被弃用,应避免使用。javadoc在文档中添加一个突出显示的“Deprecated”条目,并包含指定的explanation文本。此文本应指明类或成员被弃用的时间,如果可能的话,建议替换类或成员,并包含指向其的链接。例如:

@deprecated As of Version 3.0, this method is replaced
            by {@link #setColor}.

@deprecated标记是javac忽略所有注释的一般规则的例外情况。当此标记出现时,编译器会在生成的类文件中记录此过时信息。这使得它能够为依赖于过时特性的其他类发出警告。

@since version

指定类型或成员添加到 API 的时间。此标签应跟随版本号或其他版本规范。例如:

@since JNUT 3.0

每个类型的文档注释都应包括一个 @since 标签,而在类型的初始发布之后添加的任何成员应在其文档注释中具有 @since 标签。

@serial 描述

从技术上讲,类序列化的方式是其公共 API 的一部分。如果你编写了一个希望被序列化的类,你应该使用 @serial 和相关标签来记录其序列化格式,这些标签列在下面。对于任何作为 Serializable 类序列化状态的一部分的字段,@serial 应该出现在其文档注释中。

对于使用默认序列化机制的类,这意味着所有非声明为 transient 的字段,包括声明为 private 的字段。描述 应为序列化对象中字段及其目的的简要描述。

你也可以在类和包级别使用 @serial 标签来指定是否为类或包生成“序列化形式页面”。语法是:

@serial include
@serial exclude

@serialField 名称 类型 描述

可序列化类可以通过在名为 serialPersistentFields 的字段中声明 ObjectStreamField 对象的数组来定义其序列化格式。对于这样的类,serialPersistentFields 的文档注释应包括数组每个元素的 @serialField 标签。每个标签指定了类序列化状态中特定字段的名称、类型和描述。

@serialData 描述

可序列化类可以定义 writeObject() 方法来写入除了默认序列化机制之外的数据。Externalizable 类定义 writeExternal() 方法来负责将对象的完整状态写入序列化流。应该在这些 writeObject()writeExternal() 方法的文档注释中使用 @serialData 标签,而 描述 则应该描述方法使用的序列化格式。

内联文档注释标签

除了前面列出的标签外,javadoc 还支持几个内联标签,它们可以出现在文档注释中的 HTML 文本任何位置。由于这些标签直接出现在 HTML 文本的流中,它们需要使用大括号作为定界符,以将标记文本与 HTML 文本分隔开。支持的内联标签包括:

{@link 引用 }

{@link} 标签类似于 @see 标签,不同之处在于它会在行内插入链接,而不是将链接放在特定的“See Also:”部分中。 {@link} 标签可以出现在文档注释中的任何 HTML 文本位置。换句话说,它可以出现在类、接口、方法或字段的初始描述以及与 @param@returns@exception@deprecated 标签相关联的描述中。 {@link} 标签的 reference 使用下面 “Cross-References in Doc Comments” 中描述的语法。例如:

@param regexp The regular expression to search for. This string
              argument must follow the syntax rules described for
              {@link java.util.regex.Pattern}.

{@linkplain reference }

{@linkplain} 标签与 {@link} 标签类似,不同之处在于链接的文本使用正常字体而不是 {@link} 标签使用的代码字体。当 reference 同时包含要链接到的 feature 和指定要在链接中显示的 label 时,这是最有用的。更多关于 reference 参数中 featurelabel 部分的信息,请参见 “Cross-References in Doc Comments”。

{@inheritDoc}

当一个方法覆盖超类中的方法或实现接口中的方法时,可以省略文档注释,javadoc 会自动从被覆盖或实现的方法继承文档。可以使用 {@inheritDoc} 标签来继承单个标签的文本。此标签还允许继承和增强注释的描述性文本。要继承单个标签,请像这样使用它:

@param index {@inheritDoc}
@return {@inheritDoc}

{@docRoot}

此内联标签不带参数,将替换为生成文档的根目录的引用。它在引用外部文件(如图像或版权声明)的超链接中非常有用:

<img src="{@docroot}/images/logo.gif">
This is <a href="{@docRoot}/legal.xhtml">Copyrighted</a> material.

{@literal text }

此内联标签以文本的形式显示 text,逐字显示其中的 HTML 并忽略其中可能包含的 javadoc 标签。它不保留空格格式,但在 <pre> 标签内使用时非常有用。

{@code text }

此标签类似于 {@literal} 标签,但以代码字体显示 text 的文字。相当于:

&lt;code&gt;{@literal <replaceable>text</replaceable>}&lt;/code&gt;

{@value}

{@value} 标签不带参数,用于文档注释中的 static final 字段中,将替换为该字段的常量值。

{@value reference }

{@value} 标签的变体包括对 static final 字段的 reference 引用,并替换为该字段的常量值。

文档注释中的交叉引用

@see 标签和内联标签 {@link}{@linkplain}{@value} 都编码了对某些其他文档源的交叉引用,通常是对某些其他类型或成员的文档注释的引用。

reference可以采用三种不同的形式。如果以引号字符开头,则视为书籍名称或其他印刷资源的名称,并按原样显示。如果reference以<字符开头,则视为任意 HTML 超链接,使用<a>标签,并将超链接插入输出文档中。@see标签的此形式可以插入到其他在线文档中,例如程序员指南或用户手册。

如果reference不是引号括起的字符串或超链接,则预期其具有以下形式:

*`feature`* [*`label`*]

在这种情况下,javadoc输出由label指定的文本,并将其编码为指定feature的超链接。如果通常省略labeljavadoc将使用指定feature的名称。

feature可以引用包、类型或类型成员,采用以下形式之一:

pkgname

对命名包的引用。例如:

@see java.lang.reflect

pkgname.typename

对使用其完整包名指定的类、接口、枚举类型或注解类型的引用。例如:

@see java.util.List

typename

对未指定其包名的类型的引用。例如:

@see List

javadoc通过搜索当前包和导入类列表来解析此引用,以找到具有此名称的类。

typename # methodname

对指定类型中命名方法或构造函数的引用。例如:

@see java.io.InputStream#reset
@see InputStream#close

如果类型没有指定其包名,则按照typename描述的方式解析。如果方法重载或类定义了同名字段,则此语法存在歧义。

typename # methodname ( paramtypes )

对方法或构造函数的引用,其参数类型明确指定。在交叉引用重载方法时很有用。例如:

@see InputStream#read(byte[], int, int)

# methodname

对当前类或接口中的非重载方法或构造函数的引用,或者对当前类或接口的包含类、超类或超接口中的一个方法的引用。使用这种简洁形式来引用同一类中的其他方法。例如:

@see #setBackgroundColor

# methodname ( paramtypes )

对当前类或接口或其超类或包含类中方法或构造函数的引用。此形式适用于重载方法,因为它明确列出了方法参数的类型。例如:

@see #setPosition(int, int)

typename # fieldname

对指定类中命名字段的引用。例如:

@see java.io.BufferedInputStream#buf

如果类型没有指定其包名,则按照typename描述的方式解析。

# fieldname

对当前类型或其包含类、超类或超接口中的字段的引用。例如:

@see #x

包的文档注释

Java 源代码中类、接口、方法、构造函数和字段的文档注释出现在其定义之前。javadoc 也可以读取并显示包的摘要文档。由于包在目录中定义,而不是在单个源代码文件中,因此 javadoc 在包含该包类的源代码目录中查找名为 package.xhtml 的文件以获取包文档。

package.xhtml 文件应包含包的简单 HTML 文档。它还可以包含 @see@link@deprecated@since 标签。因为 package.xhtml 不是 Java 源代码文件,所以其中的文档应为 HTML,并且不应为 Java 注释(即不应该包含在 /***/ 字符之间)。最后,在 package.xhtml 中出现的任何 @see@link 标签都必须使用完全限定的类名。

除了为每个包定义 package.xhtml 文件外,还可以通过在这些包的源树中定义 overview.xhtml 文件来为一组包提供高级文档。当对该源树运行 javadoc 时,它将使用 overview.xhtml 作为显示的最高级概述。

文档生成器

用于生成 HTML 文档的 javadoc 工具基于标准 API。自 Java 9 以来,此标准接口已通过模块 jdk.javadoc 提供,并且通常使用此 API 的工具称为 doclets(其中 javadoc 被称为标准文档生成器)。

Java 9 发布还包括标准文档生成器的主要升级。特别是自 Java 10 起,默认生成现代 HTML5。这允许其他改进,例如实现 WAI-ARIA 标准 以提高可访问性。该标准使视觉或其他障碍的人能够使用屏幕阅读器等工具访问 javadoc 输出。

注意

javadoc 还已经增强以理解新的平台模块,因此构成 API 的语义含义(以及应该记录的内容)现在与模块化 Java 定义对齐。

标准文档生成器现在在生成文档时还会自动索引代码,并在 JavaScript 中创建客户端索引。生成的网页具有搜索功能,允许开发人员轻松找到一些常见的程序组件,例如:

  • 模块

  • 类型和成员

  • 方法参数类型

开发人员还可以使用 @index 内联 javadoc 标签添加搜索术语或短语。

便携程序的约定

Java 最早的口号之一是“一次编写,到处运行”。这强调了 Java 使编写便携程序变得容易,但仍然可能编写不会自动在任何 Java 平台上成功运行的 Java 程序。以下提示有助于避免可移植性问题:

本地方法

可移植的 Java 代码可以使用核心 Java API 中的任何方法,包括作为native方法实现的方法。但是,可移植的代码不得定义自己的 native 方法。由于其本质,native 方法必须适应每个新平台,因此直接违反了 Java“一次编写,到处运行”的承诺。

Runtime.exec()方法

在可移植代码中,几乎不允许调用Runtime.exec()方法来生成一个进程并在本地系统上执行外部命令。这是因为无法保证要执行的本地操作系统命令在所有平台上都存在或行为相同。

在可移植代码中,唯一允许使用Runtime.exec()的时候是用户可以指定要运行的命令,可以通过在运行时输入命令或在配置文件或首选项对话框中指定命令来实现。

如果程序员希望控制外部进程,则应通过 Java 9 引入的增强的ProcessHandle功能而不是使用Runtime.exec()和解析输出来实现。这并非完全可移植,但至少可以减少控制外部进程所需的特定于平台的逻辑量。

System.getenv()方法

使用System.getenv()在本质上是不可移植的。不同的操作系统具有不同的大小写约定(例如,Windows 不区分大小写,而 Unix 系统则不同)。此外,环境中常见的值在操作系统和组织之间有很大的差异。如果良好记录,使用System.getenv()来参数化应用程序所期望的特定值是可以接受的;这在容器化应用程序中经常这样做。但是,访问更广泛的环境可能会产生不兼容的行为。

未记录的类

可移植的 Java 代码必须仅使用作为 Java 平台文档的一部分记录的类和接口。大多数 Java 实现都附带有作为实现的一部分但不属于 Java 平台规范的额外未记录的公共类。

模块系统防止程序使用和依赖这些实现类,但即使在 Java 17 中增加了更多限制,仍然可以通过使用反射来绕过此保护(尽管允许反射的确切运行时开关在最近的版本中已经改变;请参阅第十二章了解更多详情)。

但是,这样做是不可移植的,因为实现类不能保证存在于所有 Java 实现或所有平台上,并且它们可能会在未来版本中更改或消失。即使你对可移植性不是很在意,使用未记录的类也可能会大大复杂化未来 JDK 版本的升级。

特别需要注意的是 sun.misc.Unsafe 类,它提供了许多“不安全”方法,开发者可以利用这些方法绕过 Java 平台的关键限制。无论何种情况下,开发者都不应直接使用 Unsafe 类。

实现特定的特性

可移植的代码不能依赖于特定于单个实现的特性。例如,在 Java 的早期年份,Microsoft 发布了一个包含许多额外方法的 Java 运行时系统版本,这些方法不属于规范定义的 Java 平台。任何依赖此类扩展的程序显然无法在其他平台上移植。

实现特定的 bug

正如可移植的代码不得依赖于特定于实现的特性,它也不得依赖于特定于实现的 bug。如果类或方法的行为与规范所述的不同,可移植程序不能依赖于这种行为,因为它可能在不同的平台上有所不同,并且将来的版本可能会修复该 bug,从而阻碍 JDK 的升级。

实现特定的行为

有时不同平台和不同实现会呈现不同的行为,所有这些行为都符合 Java 规范的要求。可移植的代码不能依赖于任何特定的行为。例如,Java 规范没有指示相同优先级的线程是否共享 CPU,或者一个长时间运行的线程是否可以饿死同一优先级的另一个线程。如果应用程序假定其中一种行为,可能无法在所有平台上正确运行。

定义系统类

可移植的 Java 代码永远不会尝试在系统或标准扩展包中定义类。这样做违反了这些包的保护边界,并暴露了包可见的实现细节,即使在模块系统不禁止的情况下,也是如此。

硬编码的文件名

可移植程序不包含硬编码的文件或目录名。这是因为不同的平台有着显著不同的文件系统组织和不同的目录分隔符字符。如果需要处理文件或目录,应该让用户指定文件名,或者至少指定基本目录,在运行时、配置文件或程序的命令行参数中进行规范。在将文件或目录名称连接到目录名称时,应使用 File() 构造函数、File.separator 常量或 Path.of() 方法。

行分隔符

不同系统使用不同字符或字符序列作为行分隔符。不要在程序中硬编码 \n\r\r\n 作为行分隔符。相反,使用 PrintStreamPrintWriterprintln() 方法,该方法会自动以适合平台的行分隔符终止行,或使用 line.separator 系统属性的值。您还可以使用 java.util.Formatter 及其相关类的 printf()format() 方法中的 “%n” 格式字符串。

摘要

在本章中,我们看到了关于命名 Java 代码部分的标准约定。虽然语言允许超出这些约定的许多内容,但遵循这些约定的代码将更容易让其他人阅读和理解。

良好的文档是创建可维护系统的核心。javadoc 工具允许我们在代码中编写大部分文档,保持文档与代码的关联性。各种文档标签可生成清晰一致的文档。

JVM 的吸引力之一是它在许多操作系统和硬件类型上的广泛安装基础。然而,如果您在几个方面不注意,可能会损害应用程序的可移植性,因此本章回顾了围绕其中最典型的障碍的指导方针以避免出错。

接下来,我们将看一下 Java 标准库中最常用的部分之一:集合。

第八章:使用 Java 集合

本章介绍了 Java 对基本数据结构的解释,即 Java 集合。这些抽象是许多编程类型的核心,并构成任何程序员基本工具包的重要组成部分。因此,这是整本书中最重要的章节之一,提供了几乎所有 Java 程序员都必不可少的工具包。

在本章中,我们将介绍基本接口和类型层次结构,展示如何使用它们,并讨论它们整体设计的各个方面。我们将涵盖处理集合的“经典”方法以及较新方法(使用 Java 8 中引入的 Streams API 和 lambda 表达式功能)。

引入集合 API

Java 集合是一组描述最常见数据结构形式的通用接口。Java 附带了每个经典数据结构的几种实现,因为这些类型被表示为接口,开发团队非常可能为自己的项目开发出专门的接口实现。

Java 集合定义了两种基本类型的数据结构。Collection 是对象的集合,而 Map 是对象之间的映射或关联集合。Java 集合的基本布局如图 Figure 8-1 所示。

在这个基本描述中,Set 是一种没有重复元素的 Collection 类型,而 List 是元素有序(但可以包含重复元素)的 Collection

JN7 0801

图 8-1。集合类和继承

SortedSetSortedMap 是特殊的集合和映射,它们维护其元素以排序顺序排列。

CollectionSetListMapSortedSetSortedMap 都是接口,但 java.util 包还定义了各种具体实现,如基于数组和链表的列表,以及基于哈希表或二叉树的映射和集合。其他重要的接口包括 IteratorIterable,它们允许您遍历集合中的对象,正如我们将在稍后看到的那样。

Collection 接口

Collection<E> 是一个参数化的接口,表示类型为 E 的对象的广义分组。我们可以创建任何类型的引用类型的集合。

注意

要正确地与集合的期望一起工作,您必须在类上定义 hashCode()equals() 方法时小心,正如 Chapter 5 中所讨论的那样。

定义了向组中添加和删除对象的方法,测试对象是否属于组的方法以及迭代组中所有元素的方法。额外的方法将组的元素作为数组返回,并返回集合的大小。

注意

Collection中的分组可以允许或不允许重复元素,并且可以或不可以对元素进行排序。

Java 集合框架提供Collection,因为它定义了所有常见数据结构形式共享的特性。JDK 提供SetListQueue作为Collection的子接口。

以下代码展示了您可以对Collection对象执行的操作:

// Create some collections to work with.
Collection<String> c = new HashSet<>();  // An empty set

// We'll see these utility methods later. Be aware that there are
// some subtleties to watch out for when using them
Collection<String> d = Arrays.asList("one", "two");
Collection<String> e = Collections.singleton("three");

// Add elements to a collection. These methods return true
// if the collection changes, which is useful with Sets that
// don't allow duplicates.
c.add("zero");           // Add a single element
c.addAll(d);             // Add all of the elements in d

// Copy a collection: most implementations have a copy constructor
Collection<String> copy = new ArrayList<String>(c);

// Remove elements from a collection.
// All but clear return true if the collection changes.
c.remove("zero");        // Remove a single element
c.removeAll(e);          // Remove a collection of elements
c.retainAll(d);          // Remove all elements that are not in d
c.clear();               // Remove all elements from the collection

// Querying collection size
boolean b = c.isEmpty(); // c is now empty, so true
int s = c.size();        // Size of c is now 0.

// Restore collection from the copy we made
c.addAll(copy);

// Test membership in the collection. Membership is based on
// the equals method, not the == operator.
b = c.contains("zero");  // true
b = c.containsAll(d);    // true

// Most Collection implementations have a useful toString()  method
System.out.println(c);

// Obtain an array of collection elements.  If the iterator guarantees
// an order, this array has the same order. The Object array is a new
// instance, containing references to the same objects as the original
// collection `c` (aka a shallow copy).
Object[] elements = c.toArray();

// If we want the elements in a String[], we must pass one in
String[] strings = c.toArray(new String[c.size()]);

// Or we can pass an empty String[] just to specify the type and
// the toArray method will allocate an array for us
strings = c.toArray(new String[0]);

请记住,您可以在任何SetListQueue上使用此处显示的任何方法。这些子接口可能对集合的元素施加成员限制或排序约束,但仍提供相同的基本方法。

注意

诸如addAll()retainAll()clear()remove()等修改集合的方法被设计为 API 的可选部分。不幸的是,它们是很久以前指定的,在那时的普遍观点是通过抛出UnsupportedOperationException来指示可选方法的缺失。因此,一些实现(尤其是只读形式)可能会抛出这个未检查异常。

CollectionMap及其子接口扩展CloneableSerializable接口。然而,Java 集合框架提供的所有集合和映射实现类都实现了这些接口。

一些集合实现对它们可以包含的元素施加限制。例如,一个实现可能禁止null作为元素,而EnumSet限制成员只能是指定枚举类型的值。

试图向集合中添加禁止的元素总是会抛出未检查异常,例如NullPointerExceptionClassCastException。检查集合是否包含禁止元素也可能会抛出这样的异常,或者可能简单地返回false

集合接口

集合是一组对象,不允许重复:它可能不包含对同一对象的两个引用、两个对null的引用,或者对满足a.equals(b)条件的两个对象ab的引用。大多数通用的Set实现对集合的元素不施加任何排序,但不排除有序集合的存在(参见SortedSetLinkedHashSet)。集合还通过通常期望具有在常数或对数时间内运行的高效contains方法来与列表等有序集合区分开来。

SetCollection定义的方法之外没有自己的方法,但对某些方法施加了额外的限制。Setadd()addAll()方法必须强制执行无重复规则:如果集合已经包含该元素,则不能将元素添加到Set中。请记住,由Collection接口定义的add()addAll()方法返回true,如果调用导致对集合的更改,则返回false。对于Set对象,这个返回值很重要,因为无重复的限制意味着添加元素并不总是导致对集合的更改。

表 8-1 列出了Set接口的实现方式,并总结了它们的内部表示、排序特性、成员限制以及基本的add()remove()contains操作的性能,以及迭代性能。请注意,CopyOnWriteArraySet位于java.util.concurrent包中;所有其他实现都属于java.util。还请注意,java.util.BitSet不是Set的实现。这个传统类用作boolean值的紧凑和高效列表,但不属于 Java 集合框架。

表 8-1. Set 实现方式

内部表示 元素顺序 成员限制 基本操作 迭代性能 备注
HashSet 哈希表 1.2 O(1) O(capacity) 最佳通用实现
LinkedHashSet 链接哈希集合 1.2 插入顺序 O(1) O(n) 保留插入顺序
EnumSet 枚举集合 5.0 枚举声明 枚举值 O(1) O(n) 仅包含非null枚举值
TreeSet 红黑树 1.2 按升序排序 Comparable O(log(n)) O(n) Comparable元素或Comparator
CopyOnWriteArraySet 数组 5.0 插入顺序 O(n) O(n) 线程安全,无需同步方法

TreeSet实现使用红黑树数据结构来维护一个根据Comparable对象的自然顺序或由Comparator对象指定的顺序按升序迭代的集合。TreeSet实际上实现了SortedSet接口,这是Set的子接口。

SortedSet接口提供了几种利用其排序特性的有趣方法。以下代码示例:

public static void testSortedSet(String[] args) {
    // Create a SortedSet
    SortedSet<String> s = new TreeSet<>(Arrays.asList(args));

    // Iterate set: elements are automatically sorted
    for (String word : s) {
        System.out.println(word);
    }

    // Special elements
    String first = s.first();  // First element
    String last = s.last();    // Last element

    // all elements but first
    SortedSet<String> tail = s.tailSet(first + '\0');
    System.out.println(tail);

    // all elements but last
    SortedSet<String> head = s.headSet(last);
    System.out.println(head);

    SortedSet<String> middle = s.subSet(first+'\0', last);
    System.out.println(middle);
}
警告

添加\0字符是必要的,因为tailSet()和相关方法使用元素的后继,对于字符串来说,后继是附加有NULL字符(ASCII 码 0)的字符串值。

从 Java 9 开始,API 还升级了Set接口的辅助静态方法,如下所示:

Set<String> set = Set.of("Hello", "World");

此 API 有几个重载版本,每个版本都接受固定数量的参数,还有一个可变参数的重载。后者用于需要任意多个元素的情况,并回退到标准的可变参数机制(在调用之前将元素编组成数组)。值得注意的是,Set.of返回的集合是不可变的,如果在实例化后尝试添加或删除元素,将抛出UnsupportedOperationException异常。

列表接口

List是一组有序的对象。列表中的每个元素在列表中都有一个位置,List接口定义了查询或设置特定位置或索引处元素的方法。在这方面,List类似于一个大小会根据需要变化的数组,以容纳其包含的元素数量。与集合不同,列表允许重复元素。

除了基于索引的get()set()方法之外,List接口还定义了方法,在特定索引处添加或删除元素,并且还定义了返回列表中特定值第一次出现或最后一次出现的索引的方法。从Collection继承的add()remove()方法被定义为将元素追加到列表的末尾,并从列表中删除指定值的第一个出现。从指定集合添加所有元素到列表末尾的addAll()方法的另一个版本将元素插入到指定索引处。retainAll()removeAll()方法像对任何Collection一样行为,如果需要,保留或删除相同值的多个出现。

List接口并不定义操作列表索引范围的方法。相反,它定义了一个subList()方法,该方法返回一个List对象,该对象仅表示原始列表的指定范围。子列表由父列表支持,对子列表的任何更改都会立即反映在父列表中。以下是subList()和其他基本的List操作方法的示例:

// Create lists to work with
List<String> l = new ArrayList<String>(Arrays.asList(args));
List<String> words = Arrays.asList("hello", "world");
List<String> words2 = List.of("hello", "world");

// Querying and setting elements by index
String first = l.get(0);             // First element of list
String last = l.get(l.size() - 1);   // Last element of list
l.set(0, last);                      // The last shall be first

// Adding and inserting elements.  add  can append or insert
l.add(first);       // Append the first word at end of list
l.add(0, first);    // Insert first at the start of the list again
l.addAll(words);    // Append a collection at the end of the list
l.addAll(1, words); // Insert collection after first word

// Sublists: backed by the original list
List<String> sub = l.subList(1,3);  // second and third elements
sub.set(0, "hi");                   // modifies 2nd element of l

// Sublists can restrict operations to a subrange of backing list
String s = Collections.min(l.subList(0,4));
Collections.sort(l.subList(0,4));

// Independent copies of a sublist don't affect the parent list.
List<String> subcopy = new ArrayList<String>(l.subList(1,3));
subcopy.clear();

// Searching lists
int p = l.indexOf(last);  // Where does the last word appear?
p = l.lastIndexOf(last);  // Search backward

// Print the index of all occurrences of last in l.  Note subList
int n = l.size();
p = 0;
while (p < n) {
    // Get a view of the list that includes only the elements we
    // haven't searched yet.
    List<String> list = l.subList(p, n);
    int q = list.indexOf(last);
    if (q == -1) break;
    System.out.printf("Found '%s' at index %d%n", last, p+q);
    p += q+1;
}

// Removing elements from a list
l.remove(last);         // Remove first occurrence of the element
l.remove(0);            // Remove element at specified index
l.subList(0,2).clear(); // Remove a range of elements using subList
l.retainAll(words);     // Remove all but elements in words
l.removeAll(words);     // Remove all occurrences of elements in words
l.clear();              // Remove everything

Foreach 循环和迭代

处理集合的一种非常重要的方式是依次处理每个元素,这种方法称为迭代。这是一种查看数据结构的较旧方式,但仍然非常有用(特别是对于小数据集),而且易于理解。这种方法与for循环自然契合,如下面的代码片段所示,而且最容易用List来说明:

List<String> c = new ArrayList<String>();
// ... add some Strings to c

for(String word : c) {
    System.out.println(word);
}

代码的意图应该清晰明了——它逐个取出 c 的元素,并将它们用作循环体中的变量。更正式地说,它遍历数组或集合(或实现 java.lang.Iterable 接口的任何对象)。在每次迭代中,它将数组或 Iterable 对象的一个元素赋给你声明的循环变量,然后执行循环体,通常使用循环变量来操作元素。不涉及循环计数器或 Iterator 对象;循环自动执行迭代,你无需关注循环的正确初始化或终止。

这种类型的 for 循环通常被称为 foreach 循环。让我们看看它是如何工作的。下面的代码片段显示了一个重写(及等效)的 for 循环,明确显示了方法调用:

// Iteration with a for loop
for(Iterator<String> i = c.iterator(); i.hasNext();) {
    System.out.println(i.next());
}

Iterator 对象 i 是从集合生成并用于逐个遍历集合中的项目。它也可以与 while 循环一起使用:

// Iterate through collection elements with a while loop.
// Some implementations (such as lists) guarantee an order of iteration
// Others make no guarantees.
Iterator<String> iterator = c.iterator();
while (iterator.hasNext()) {
    System.out.println(iterator.next());
}

以下是关于 foreach 循环语法的更多信息:

  • 正如前面提到的,expression 必须是数组或实现了 java.lang.Iterable 接口的对象。这种类型必须在编译时已知,以便编译器能够生成适当的循环代码。

  • 数组或 Iterable 元素的类型必须与 declaration 中声明的变量类型兼容。如果使用未参数化元素类型的 Iterable 对象,则必须将变量声明为 Object

  • declaration 通常只包括类型和变量名,但可能包括 final 修饰符和任何适当的注解(参见 第四章)。使用 final 可以防止循环变量获得除了循环分配的数组或集合元素之外的任何值,并强调数组或集合不能通过循环变量进行更改。

  • foreach 循环的循环变量必须作为循环的一部分声明,具有类型和变量名。你不能像 for 循环那样使用在循环外声明的变量。

要详细了解 foreach 循环如何与集合一起工作,我们需要考虑两个接口,java.util.Iteratorjava.lang.Iterable

public interface Iterator<E> {
     boolean hasNext();
     E next();
     void remove();
}

Iterator 定义了通过集合或其他数据结构遍历元素的方法。其工作方式如下:当集合中还有更多元素时(hasNext() 返回 true),调用 next 获取集合的下一个元素。有序集合(如列表)通常具有保证按顺序返回元素的迭代器。无序集合(如 Set)仅保证多次调用 next() 返回集合的所有元素,但不指定顺序。

警告

Iteratornext()方法执行两个功能——它在集合中前进,并返回我们刚刚移动过的集合元素。这些操作的组合可能会在以函数式或不可变风格编程时引发问题,因为它会改变底层集合。

Iterable接口的引入是为了使 foreach 循环工作。类实现此接口来告知任何有兴趣的人它能够提供一个Iterator

public interface Iterable<E> {
     java.util.Iterator<E> iterator();
}

如果对象是Iterable<E>,这意味着它有一个返回Iterator<E>iterator()方法,该迭代器有一个返回E类型对象的next()方法。

如果使用Iterable<E>的 foreach 循环,循环变量必须是类型E或超类或接口。

例如,要遍历List<String>的元素,变量必须声明为String或其超类Object,或者它实现的接口之一:CharSequenceComparableSerializable

迭代器常见的一个陷阱涉及修改。如果在迭代过程中修改了集合,可能会抛出ConcurrentModificationException类型的错误。

List<String> l = new ArrayList<>(List.of("one", "two", "three"));
for (String x : l) {
    if (x.equals("one")) {
        l.remove("one");  // throws ConcurrentModificationException
    }
}

避免此异常需要重新思考算法,以便不修改集合。通常可以通过针对副本而不是原始集合进行操作来实现这一点。新的Stream API 也为这些情况提供了许多有用的辅助功能。

对列表的随机访问

List实现的一个普遍期望是它们能够高效地进行迭代,通常在与列表大小成比例的时间内。然而,并非所有列表都能高效地在任何索引处提供元素的随机访问。顺序访问列表,如LinkedList类,在提供高效的插入和删除操作的同时,牺牲了随机访问的性能。提供高效随机访问的实现会实现RandomAccess标记接口,如果需要确保高效的列表操作,可以使用instanceof测试此接口:

// Arbitrary list we're passed to manipulate
List<?> l = ...;

// Ensure we can do efficient random access.  If not, use a copy
// constructor to make a random-access copy of the list before
// manipulating it.
if (!(l instanceof RandomAccess)) l = new ArrayList<?>(l);

Listiterator()方法返回的Iterator按列表中元素出现的顺序迭代列表元素。List实现了Iterable,因此可以像任何其他集合一样使用 foreach 循环进行迭代。

要仅迭代列表的一部分,可以使用subList()方法创建一个子列表视图:

List<String> words = ...;  // Get a list to iterate

// Iterate just all elements of the list but the first
for(String word : words.subList(1, words.size()))
    System.out.println(word);

表 8-2 总结了 Java 平台中五个通用的List实现。VectorStack是遗留实现,不应使用。CopyOnWriteArrayList属于java.util.concurrent包,仅适用于多线程用例。

表 8-2. 列表实现

类别 表示 自 Java 版本 随机访问 备注
ArrayList 数组 1.2 最全面的实现
LinkedList 双向链表 1.2 更有效的在列表中间插入和删除。
CopyOnWriteArrayList 数组 5.0 线程安全;快速遍历,修改慢。
Vector 数组 1.0 旧类;同步方法。不要使用。
Stack 数组 1.0 扩展自Vector;添加push()pop()peek()。旧类;建议使用Deque代替。

映射接口

Map 是一组 key 对象和对该集合中每个成员的 value 对象的映射。Map 接口定义了用于定义和查询映射的 API。Map 是 Java 集合框架的一部分,但它不扩展Collection接口,因此Map是一个小写的集合,而不是大写的CollectionMap 是一个带有两个类型变量的参数化类型,Map<K, V>。类型变量K表示映射中键的类型,类型变量V表示这些键映射到的值的类型。例如,从String键到Integer值的映射可以用Map<String,Integer>表示。

最重要的Map方法是put(),用于在映射中定义键/值对;get(),用于查询与指定键关联的值;以及remove(),用于从映射中移除指定的键及其关联的值。对于Map实现的一般性能期望是这三个基本方法非常高效:它们应该在常数时间内运行,绝对不会更糟糕。

Map的一个重要特性是其支持“集合视图”。这可以总结为:

  • Map 不是 Collection

  • Map的键可以看作是一个Set

  • 值可以看作是一个Collection

  • 映射可以看作是Map.Entry对象的一个Set

注意

Map.Entry 是在Map内部定义的一个嵌套接口:它简单地表示单个键/值对。

下面的示例代码展示了Mapget()put()remove()和其他方法,并演示了Map的集合视图的一些常见用法:

// New, empty map
Map<String,Integer> m = new HashMap<>();

// Immutable Map containing a single key/value pair
Map<String,Integer> singleton = Collections.singletonMap("test", -1);

// Note this rarely used syntax to explicitly specify the parameter
// types of the generic emptyMap method. The returned map is immutable
Map<String,Integer> empty = Collections.<String,Integer>emptyMap();

// Populate the map using the put method to define mappings
// from array elements to the index at which each element appears
String[] words = { "this", "is", "a", "test" };
for(int i = 0; i < words.length; i++) {
    m.put(words[i], i);  // Note autoboxing of int to Integer
}

// Each key must map to a single value. But keys may map to the
// same value
for(int i = 0; i < words.length; i++) {
    m.put(words[i].toUpperCase(), i);
}

// The putAll() method copies mappings from another Map
m.putAll(singleton);

// Query the mappings with the get()  method
for(int i = 0; i < words.length; i++) {
    if (m.get(words[i]) != i) throw new AssertionError();
}

// Key and value membership testing
m.containsKey(words[0]);        // true
m.containsValue(words.length);  // false

// Map keys, values, and entries can be viewed as collections
Set<String> keys = m.keySet();
Collection<Integer> values = m.values();
Set<Map.Entry<String,Integer>> entries = m.entrySet();

// The Map and its collection views typically have useful
// toString  methods
System.out.printf("Map: %s%nKeys: %s%nValues: %s%nEntries: %s%n",
                  m, keys, values, entries);

// These collections can be iterated.
// Most maps have an undefined iteration order (but see SortedMap)
for(String key : m.keySet()) System.out.println(key);
for(Integer value: m.values()) System.out.println(value);

// The Map.Entry<K,V> type represents a single key/value pair in a map
for(Map.Entry<String,Integer> pair : m.entrySet()) {
    // Print out mappings
    System.out.printf("'%s' ==> %d%n", pair.getKey(), pair.getValue());
    // And increment the value of each Entry
    pair.setValue(pair.getValue() + 1);
}

// Removing mappings
m.put("testing", null);   // Mapping to null can "erase" a mapping:
m.get("testing");         // Returns null
m.containsKey("testing"); // Returns true: mapping still exists
m.remove("testing");      // Deletes the mapping altogether
m.get("testing");         // Still returns null
m.containsKey("testing"); // Now returns false.

// Deletions may also be made via the collection views of a map.
// Additions to the map may not be made this way, however.
m.keySet().remove(words[0]);  // Same as m.remove(words[0]);

// Removes one mapping to the value 2 - usually inefficient and of
// limited use
m.values().remove(2);
// Remove all mappings to 4
m.values().removeAll(Collections.singleton(4));
// Keep only mappings to 2 & 3
m.values().retainAll(Arrays.asList(2, 3));

// Deletions can also be done via iterators
Iterator<Map.Entry<String,Integer>> iter = m.entrySet().iterator();
while(iter.hasNext()) {
    Map.Entry<String,Integer> e = iter.next();
    if (e.getValue() == 2) iter.remove();
}

// Find values that appear in both of two maps.  In general, addAll()
// and retainAll() with keySet() and values() allow union and
// intersection
Set<Integer> v = new HashSet<>(m.values());
v.retainAll(singleton.values());

// Miscellaneous methods
m.clear();                // Deletes all mappings
m.size();                 // Returns number of mappings: currently 0
m.isEmpty();              // Returns true
m.equals(empty);          // true: Maps implementations override equals

随着 Java 9 的到来,Map接口也已经通过工厂方法增强了集合的创建:

Map<String, Double> cities =
        Map.of(
          "Barcelona", 22.5,
          "New York", 28.3);

情况与SetList相比稍微复杂一些,因为Map类型既有键又有值,并且 Java 不允许在方法声明中有多个变长参数。解决方法是提供固定大小的重载,最多支持 10 个条目,并提供一个新的静态方法entry(),用于构造表示键/值对的对象。

然后可以编写代码来使用变长参数形式,如下所示:

Map<String, Double> cities =
        Map.ofEntries(
          entry("Barcelona", 22.5),
          entry("New York", 28.3));

方法名称必须与of()不同,因为参数类型不同——现在这是一个变长参数方法在Map.Entry中。

Map 接口包含各种通用和特殊用途的实现,总结如表 8-3。如常,详细信息请参阅 JDK 文档和 javadoc。表 8-3 中的所有类均位于 java.util 包中,除了 ConcurrentHashMapConcurrentSkipListMap,它们属于 java.util.concurrent 包。

表 8-3. 地图实现

表示 自从 空键 空值 备注
HashMap Hashtable 1.2 通用实现
ConcurrentHashMap Hashtable 5.0 通用线程安全实现;参见 ConcurrentMap 接口
ConcurrentSkipListMap Hashtable 6.0 专用线程安全实现;参见 ConcurrentNavigableMap 接口
EnumMap 数组 5.0 键是枚举实例
LinkedHashMap Hashtable 加列表 1.4 保持插入或访问顺序
TreeMap 红黑树 1.2 按键值排序。操作复杂度为 O(log(n))。参见SortedMap接口。
IdentityHashMap Hashtable 1.4 使用 == 而不是 equals() 比较
WeakHashMap Hashtable 1.2 不会阻止键的垃圾回收
Hashtable Hashtable 1.0 传统类;同步方法。不建议使用。
Properties Hashtable 1.0 扩展了 Hashtable 并添加了 String 方法

ConcurrentHashMapConcurrentSkipListMap 类属于 java.util.concurrent 包,实现了该包中的 ConcurrentMap 接口。ConcurrentMap 扩展自 Map 并定义了一些在多线程编程中重要的原子操作。例如,putIfAbsent() 方法类似于 put(),但仅在键尚未映射时才向映射中添加键值对。

TreeMap 实现了 SortedMap 接口,该接口扩展自 Map 并添加了利用有序映射性质的方法。SortedMapSortedSet 接口非常相似。firstKey()lastKey() 方法返回映射中键集的第一个和最后一个键。headMap()tailMap()subMap() 返回原始映射的受限范围。

队列和阻塞队列接口

队列 是一种有序集合,具有从队列头部按顺序提取元素的方法。队列实现通常基于插入顺序,如先进先出 (FIFO) 队列或后进先出 (LIFO) 队列。

注意

LIFO 队列也称为栈,Java 提供了 Stack 类,但强烈不建议使用——而是使用 Deque 接口的实现。

还有其他可能的排序方式:优先队列根据外部Comparator对象或根据Comparable元素的自然顺序对其元素进行排序。与Set不同,Queue实现通常允许重复元素。与List不同,Queue接口不定义用于在任意位置操作队列元素的方法。只有队列头部的元素可供检查。许多Queue实现通常具有固定的容量:当队列已满时,不可能再添加更多元素。类似地,当队列为空时,不可能再移除任何元素。由于满和空的条件是许多基于队列的算法的正常部分,Queue接口定义了用返回值而不是抛出异常来表示这些条件的方法。具体来说,peek()poll()方法返回null来指示队列为空。因此,大多数Queue实现不允许null元素。

阻塞队列是一种定义了阻塞put()take()方法的队列类型。put()方法会添加一个元素到队列中,在必要时等待,直到队列有空间可用。而take()方法会从队列头部移除一个元素,在必要时等待,直到有元素可移除。阻塞队列是许多多线程算法的重要部分,BlockingQueue接口(它扩展了Queue)作为java.util.concurrent包的一部分进行了定义。

与集合、列表和映射相比,队列并不是那么常用,除了在某些多线程编程风格中可能会使用。在这里,我们将尝试澄清不同可能的队列插入和移除操作,而不提供示例代码。

向队列中添加元素

add()

这个Collection方法会以常规方式添加元素。在有界队列中,如果队列已满,该方法可能会抛出异常。

offer()

这个Queue方法类似于add(),但是如果由于有界队列已满而无法添加元素,它会返回false而不是抛出异常。

BlockingQueue定义了offer()的超时版本,它会等待指定时间,直到一个满队列中有空间可用。与该方法的基本版本一样,如果元素被插入则返回true,否则返回false

put()

这个BlockingQueue方法会阻塞:如果由于队列已满而无法插入元素,则put()会等待直到另一个线程从队列中移除一个元素,为新元素腾出空间。

从队列中移除元素

remove()

除了Collection.remove()方法可以从队列中移除指定元素之外,Queue接口还定义了remove()的无参数版本,它会移除并返回队列头部的元素。如果队列为空,该方法会抛出NoSuchElementException

poll()

Queue 方法移除并返回队列头部的元素,类似于 remove(),但如果队列为空则返回 null,而不是抛出异常。

BlockingQueue 定义了 poll() 的超时版本,等待指定的时间量以在空队列中添加元素。

take()

BlockingQueue 方法移除并返回队列头部的元素。如果队列为空,它会阻塞,直到其他线程向队列添加元素。

drainTo()

BlockingQueue 方法从队列中移除所有可用元素并将它们添加到指定的 Collection 中。它不会阻塞等待元素添加到队列中。该方法的一个变体接受最大数量的要排放的元素。

查询

在这个上下文中,查询是指检查队列头部的元素,而不从队列中移除它。

element()

Queue 方法返回队列头部的元素,但不从队列中移除该元素。如果队列为空,则抛出 NoSuchElementException 异常。

peek()

Queue 方法类似于 element,但如果队列为空则返回 null

注意

在使用队列时,通常建议选择一种处理失败的特定方式。例如,如果希望操作阻塞直到成功,则选择 put()take()。如果想要通过方法的返回代码来检查队列操作是否成功,则适合使用 offer()poll()

LinkedList 类也实现了 Queue。它提供了无界的 FIFO 排序,插入和删除操作需要常数时间。尽管 LinkedList 允许使用 null 元素,但在列表用作队列时不建议使用它们。

java.util 包中还有另外两个 Queue 实现。PriorityQueue 根据 Comparator 或者根据元素的 compareTo() 方法定义的顺序对其元素进行排序。PriorityQueue 的头部始终是根据定义顺序的最小元素。最后,ArrayDeque 是双端队列实现,在需要栈实现时经常使用。

java.util.concurrent 包中还包含多个 BlockingQueue 实现,专为多线程编程设计;提供了高级版本,可以避免使用同步方法。

遗憾的是,本书不涵盖对 java.util.concurrent 的全面讨论。有兴趣的读者应参考 Brian Goetz 等人的《Java 并发实战》(Addison-Wesley, 2006)。

实用方法

java.util.Collections 类拥有许多专为集合设计的静态实用方法。其中一个重要的方法组是集合 包装 方法:它们返回围绕您指定的集合包装的特殊目的集合。包装集合的目的是在不提供自身的集合周围提供额外的功能。包装器用于提供线程安全性、写保护和运行时类型检查。包装集合始终是 原始集合支持的,这意味着包装器的方法只是将操作分派到包装的集合的等效方法。这意味着通过包装器对集合进行的更改会通过包装的集合反映出来,反之亦然。

第一组包装方法提供了围绕集合的线程安全包装器。除了遗留类 VectorHashtable 外,java.util 中的集合实现没有 synchronized 方法,并且不能受到多线程并发访问的保护。如果您需要线程安全的集合并且不介意额外的同步开销,可以使用类似以下代码创建它们:

List<String> list =
    Collections.synchronizedList(new ArrayList<>());
Set<Integer> set =
    Collections.synchronizedSet(new HashSet<>());
Map<String,Integer> map =
    Collections.synchronizedMap(new HashMap<>());

第二组包装方法提供了通过这些包装对象无法修改底层集合的集合对象。它们返回集合的只读视图:如果更改集合的内容将导致 UnsupportedOperationException。当您必须传递一个不允许以任何方式修改或变异集合内容的方法时,这些包装器非常有用:

List<Integer> primes = new ArrayList<>();
List<Integer> readonly = Collections.unmodifiableList(primes);
// We can modify the list through primes
primes.addAll(Arrays.asList(2, 3, 5, 7, 11, 13, 17, 19));
// But we can't modify through the read-only wrapper
readonly.add(23);  // UnsupportedOperationException

java.util.Collections 类还定义了操作集合的方法。其中一些最显著的是对集合元素进行排序和搜索的方法:

Collections.sort(list);
// list must be sorted first
int pos = Collections.binarySearch(list, "key");

这里是一些其他有趣的 Collections 方法:

// Copy list2 into list1, overwriting list1
Collections.copy(list1, list2);
// Fill list with Object o
Collections.fill(list, o);
// Find the largest element in Collection c
Collections.max(c);
// Find the smallest element in Collection c
Collections.min(c);

Collections.reverse(list);      // Reverse list
Collections.shuffle(list);      // Mix up list

熟悉 CollectionsArrays 中的实用方法是一个好主意,因为它们可以避免您编写自己的常见任务实现。

特殊情况集合

除了其包装方法外,java.util.Collections 类还定义了用于创建包含单个元素的不可变集合实例以及用于创建空集合的实用方法。singleton(), singletonList()singletonMap() 返回不可变的 Set, ListMap 对象,其中包含单个指定对象或单个键值对。当您需要向期望收集的方法传递单个对象时,这些方法非常有用。

Collections 类还包括返回空集合的方法。如果您正在编写一个返回集合的方法,通常最好通过返回空集合而不是像 null 这样的特殊情况值来处理没有值可返回的情况:

Set<Integer> si = Collections.emptySet();
List<String> ss = Collections.emptyList();
Map<String, Integer> m = Collections.emptyMap();

自 Java 9 以来,这些方法经常被 Set, ListMap 接口上的 of() 方法所取代。

Set<Integer> si = Set.of();
List<String> ss = List.of();
Map<String, Integer> m = Map.of();

这些方法返回它们类型的不可变版本,也可能通过相同的方法获取元素。

Set<Integer> si = Set.of(1);
List<String> ss = List.of("string");
Map<String, Integer> m = Map.of("one", 1);

最后,nCopies() 返回一个包含指定数量副本的不可变 List

List<Integer> tenzeros = Collections.nCopies(10, 0);

数组和辅助方法

对象数组和集合提供类似的功能。可以从一个转换到另一个:

String[] a = { "this", "is", "a", "test" };  // An array
// View array as an ungrowable list
List<String> l = Arrays.asList(a);
// Make a growable copy of the view
List<String> m = new ArrayList<>(l);

// asList() is a varargs method so we can do this, too:
Set<Character> abc =
    new HashSet<Character>(Arrays.asList('a', 'b', 'c'));

// Collection defines a toArray method. The no-args version creates
// an Object[] array, copies collection elements to it and returns it
// Get set elements as an array
Object[] members = set.toArray();
// Get list elements as an array
Object[] items = list.toArray();
// Get map key objects as an array
Object[] keys = map.keySet().toArray();
// Get map value objects as an array
Object[] values = map.values().toArray();

// If you want the return value to be something other than Object[],
// pass in an array of the appropriate type. If the array is not
// big enough, another one of the same type will be allocated.
// If the array is too big, the collection elements copied to it
// will be null-filled
String[] c = l.toArray(new String[0]);

此外,还有一些有用的辅助方法来处理 Java 的数组,这些方法在这里完整列出。

java.lang.System 类定义了一个 arraycopy() 方法,用于将一个数组中的指定元素复制到第二个数组的指定位置。第二个数组必须与第一个数组类型相同,甚至可以是同一个数组:

char[] text = "Now is the time".toCharArray();
char[] copy = new char[100];
// Copy 10 characters from element 4 of text into copy,
// starting at copy[0]
System.arraycopy(text, 4, copy, 0, 10);

// Move some of the text to later elements, making room for
// insertions If target and source are the same, this will involve
// copying to a temporary array
System.arraycopy(copy, 3, copy, 6, 7);

Arrays 类还定义了许多有用的静态方法:

int[] intarray = new int[] { 10, 5, 7, -3 }; // An array of integers
Arrays.sort(intarray);                       // Sort it in place
// Value 7 is found at index 2
int pos = Arrays.binarySearch(intarray, 7);
// Not found: negative return value
pos = Arrays.binarySearch(intarray, 12);

// Arrays of objects can be sorted and searched too
String[] strarray = new String[] { "now", "is", "the", "time" };
Arrays.sort(strarray);   // sorted to: { "is", "now", "the", "time" }

// Arrays.equals compares all elements of two arrays
String[] clone = (String[]) strarray.clone();
boolean b1 = Arrays.equals(strarray, clone);  // Yes, they're equal

// Arrays.fill  initializes array elements
// An empty array; elements set to 0
byte[] data = new byte[100];
// Set them all to -1
Arrays.fill(data, (byte) -1);
// Set elements 5, 6, 7, 8, 9 to -2
Arrays.fill(data, 5, 10, (byte) -2);

// Creates a new array with elements copied into it
int[] copied = Arrays.copyOf(new int[] { 1, 2, 3 }, 2);

在 Java 中,可以将数组视为对象并进行操作。对于任意对象 o,可以使用以下代码来查找该对象是否为数组,以及如果是,则是什么类型的数组:

Class type = o.getClass();
if (type.isArray()) {
  Class elementType = type.getComponentType();
}

Java 流与 Lambda 表达式

引入 Java 8 中 lambda 表达式的一个主要原因是促进对集合 API 的重大改革,以允许 Java 开发者使用更现代的编程风格。直到 Java 8 发布之前,Java 中处理数据结构的方式看起来有些过时。许多现代语言现在支持一种编程风格,允许将集合作为整体来处理,而不是分解和迭代它们。

实际上,许多 Java 开发者已经开始使用替代的数据结构库来实现他们认为在集合 API 中缺乏的表达性和生产力。更新 API 的关键在于引入新的类和方法,这些方法可以接受 lambda 表达式作为参数,以定义需要执行的内容,而不是具体的方式。这是功能风格编程的概念。

引入功能性集合(称为Java 流,以明确其与旧集合方法的区别)是迈出的重要一步。可以通过在现有集合上调用 stream() 方法来创建流。

注意

想要向现有接口添加新方法,这直接导致了一种称为默认方法的新语言特性的引入(详见“默认方法”以获取更多详情)。如果没有这种新机制,Java 8 之前的集合接口的旧实现将无法在 Java 8 下编译,并且如果加载到 Java 8 运行时中,则无法连接。

然而,流 API 的到来并没有抹去历史。集合 API 深深嵌入在 Java 世界中,它不是功能性的。Java 对向后兼容性和严格的语言语法的承诺意味着集合永远不会消失。即使以功能风格编写的 Java 代码也永远不会完全摆脱样板代码,并且永远不会具有我们在 Haskell 或 Scala 等语言中看到的简洁语法。

这是语言设计中不可避免的权衡之一——Java 在命令式设计和基础上添加了功能性能力。这与从头开始为函数式编程设计不同。更重要的问题是:从 Java 8 开始提供的功能性能力是否符合工作程序员构建应用程序的需要?

Java 8 相对于先前版本的快速采用以及社区的反应似乎表明新特性取得了成功,并提供了生态系统所期待的功能。

在本节中,我们将介绍 Java 集合中使用 Java 流(Java streams)和 lambda 表达式的方法。有关更详尽的内容,请参阅Java 8 Lambdas(Richard Warburton 著,O'Reilly 出版社)。

功能性方法

Java 8 Streams 希望启用的方法来源于功能性编程语言和风格。我们在“函数式编程”中遇到了一些关键模式——让我们重新介绍它们,并查看每个的一些示例。

过滤器

过滤器模式应用了一个返回truefalse(称为谓词)的代码片段到集合中的每个元素上。构建一个新的集合,其中包含“通过测试”的元素(即应用于元素时代码返回true的部分)。

例如,让我们看一些用于处理猫集合并挑选出老虎的代码:

List<String> cats = List.of("tiger", "cat", "TIGER", "leopard");
String search = "tiger";
String tigers = cats.stream()
                    .filter(s -> s.equalsIgnoreCase(search))
                    .collect(Collectors.joining(", "));
System.out.println(tigers);

关键部分是调用filter(),它接受一个 lambda 表达式。Lambda 接受一个字符串并返回一个布尔值。这被应用于整个cats集合,并创建一个只包含老虎(无论它们是否大写)的新集合。

filter()方法接受一个来自java.util.function包的Predicate接口的实例。这是一个功能性接口,只有一个非默认方法,因此非常适合 lambda 表达式。

注意最终调用的collect();这是 API 的一个重要部分,用于在 lambda 操作结束时“收集”结果。我们将在下一节中更详细地讨论它。

Predicate还具有一些其他非常有用的默认方法,例如通过逻辑操作构建组合谓词。例如,如果老虎们想要允许豹子加入他们的团体,可以使用or()方法表示:

Predicate<String> p = s -> s.equalsIgnoreCase(search);
Predicate<String> combined = p.or(s -> s.equals("leopard"));
String pride = cats.stream()
                   .filter(combined)
                   .collect(Collectors.joining(", "));
System.out.println(pride);

注意,如果明确创建Predicate<String>对象p,那么默认的or()方法就可以在其上调用,并且第二个 lambda 表达式(也将自动转换为Predicate<String>)将被传递给它。

Map

这种映射范式利用了java.util.function包中的接口Function<T, R>。与Predicate<T>类似,这是一个功能接口,因此只有一个非默认方法apply()。映射范式是关于将一个流转换为一个新流,新流可能具有与原始流不同的类型和值。这在 API 中显示为Function<T, R>有两个单独的类型参数。类型参数R的名称表示这表示函数的返回类型。

让我们看一个使用map()的代码示例:

List<Integer> namesLength = cats.stream()
                .map(String::length)
                .toList();
System.out.println(namesLength);

这是对先前的cats变量(这是一个Stream<String>)调用的,并将函数String::length(方法引用)应用于每个字符串。结果是一个新的流,但这次是Integer。我们使用toList()方法将该流转换为List。请注意,与集合 API 不同,map()方法不会就地变异流,而是返回一个新值。这对于此处使用的功能样式至关重要。

forEach

映射和过滤范式用于从另一个集合创建一个集合。在强烈的函数式语言中,这将与要求原始集合不受 lambda 主体影响而被合并。从计算机科学的角度来看,这意味着 lambda 主体应该是“无副作用”的。

当然,在 Java 中,我们经常需要处理可变数据,因此 Streams API 提供了一种在遍历集合时修改元素的方法——forEach()方法。它接受一个类型为Consumer<T>的参数,这是一个预期通过副作用操作的功能接口(尽管它实际上是否改变数据不太重要)。这意味着可以转换为Consumer<T>的 lambda 的签名是(T t) → void。让我们看一个forEach()的快速示例:

List<String> pets =
  List.of("dog", "cat", "fish", "iguana", "ferret");
pets.stream().forEach(System.out::println);

在此示例中,我们仅仅打印出集合的每个成员。但是,我们通过使用一种特殊类型的方法引用作为 lambda 表达式来实现。这种类型的方法引用称为绑定方法引用,因为它涉及特定对象(在本例中为System.out对象,这是System的静态公共字段)。这相当于 lambda 表达式:

s -> System.out.println(s);

当然,这可以转换为实现Consumer<? super String>的类型的实例,如方法签名所需。

警告

没有什么能阻止map()filter()调用改变元素。只是约定它们不能改变元素,但这是每个 Java 程序员都应该遵守的约定。

在我们继续之前,我们应该看看一个最后的函数式技术。这是将集合聚合到单个值的做法,也是我们下一节的主题。

归约

让我们看看reduce()方法。这实现了归约模式,这实际上是一组相似和相关的操作,有些被称为折叠或聚合操作。

在 Java 中,reduce()接受两个参数。这些是初始值,通常称为标识(或零),以及一个逐步应用的函数。这个函数的类型是BinaryOperator<T>,这是另一个接受两个相同类型参数并返回该类型值的函数式接口。reduce()的第二个参数是一个二参数的 lambda。reduce()javadoc中像这样定义:

T reduce(T identity, BinaryOperator<T> aggregator);

简单来说,reduce()的第二个参数可以想象成在流运行时创建一个“运行总和”。它从将标识元素与流的第一个元素组合起来产生第一个结果开始,然后将该结果与流的第二个元素组合,依此类推。

可以想象,reduce()的实现工作起来有点像这样:

public T reduce(T identity, BinaryOperator<T> aggregator) {
    T runningTotal = identity;
    for (T element : myStream) {
        runningTotal = aggregator.apply(runningTotal, element);
    }

    return runningTotal;
}
注意

实际上,reduce()的实现可以比这更复杂,并且如果数据结构和操作适合,甚至可以并行执行。

让我们快速看一个reduce()的例子,并计算一些质数的总和:

double sumPrimes = List.of(2, 3, 5, 7, 11, 13, 17, 19, 23)
        .stream()
        .reduce(0, (x, y) -> x + y);
System.out.println("Sum of some primes: " + sumPrimes);

在本节中我们遇到的所有示例中,您可能已经注意到在List实例上调用了stream()方法。这是 Java 集合演变的一部分——最初部分地出于必要性选择,但已被证明是一个极好的抽象。让我们继续详细讨论流 API。

流 API

引起 Java 库设计者引入流 API 的根本问题是现有的核心集合接口实现的数量庞大。由于这些实现是在 Java 8 和 lambda 之前存在的,它们不会具有任何对应于新的函数式操作的方法。更糟糕的是,像map()filter()这样的方法名称从未作为集合接口的一部分,可能已经存在于实现中。

为了解决这个问题,引入了一个称为Stream的新抽象。其思想是通过stream()方法可以从集合对象生成一个Stream对象。这种Stream类型是新的,并且受到库设计者的控制,因此可以确保没有冲突。这进一步减少了冲突的风险,因为只有包含stream()方法的集合实现才会受到影响。

Stream 对象在新的集合代码方法中扮演与 Iterator 类似的角色。总体思路是让开发人员建立一个操作序列(或“管道”),对整个集合应用操作(如 mapfilterreduce)。操作的实际内容通常作为每个操作的 Lambda 表达式来表示。

在管道的末端,通常需要收集或“具现化”结果,要么作为新的集合,要么作为另一个值。这可以通过使用 Collector 或通过使用像 reduce() 这样的“终端方法”来完成,后者返回实际值而不是另一个流。总体而言,新的集合方法看起来是这样的:

        stream()   filter()   map()   collect()
Collection -> Stream -> Stream -> Stream -> Collection

Stream 类表现为一系列元素的序列,可以逐个访问(尽管有些类型的流支持并行访问,并且可以用于以自然多线程方式处理更大的集合)。类似于 IteratorStream 用于逐个获取每个项目。

与 Java 中的通用类一样,Stream 是由引用类型参数化的。然而,在许多情况下,我们实际上希望使用基本类型的流,尤其是 intdouble。我们不能有 Stream<int>,因此在 java.util.stream 中有特殊的(非泛型)类,如 IntStreamDoubleStream。这些被称为 Stream 类的原始特化,它们的 API 与一般 Stream 方法非常相似,只是在适当的地方使用原始类型。

惰性求值

实际上,流比迭代器(甚至集合)更为一般化,因为流不管理数据的存储。在 Java 的早期版本中,通常假定集合的所有元素都存在(通常在内存中)。可以通过坚持到处都使用迭代器以及让迭代器在需要时动态构造元素的方式来部分地解决这个问题。然而,这既不是非常方便,也不是很常见。

相比之下,流是一种管理数据的抽象,而不是关注存储细节。这使得可以处理比简单有限集合更为复杂的数据结构。例如,无限流可以轻松地用 Stream 接口表示,并且它们可以作为处理所有平方数集合的一种方式。让我们看看如何使用 Stream 完成这个任务:

public class SquareGenerator implements IntSupplier {
    private int current = 1;

    @Override
    public synchronized int getAsInt() {
        int thisResult = current * current;
        current++;
        return thisResult;
    }
}

IntStream squares = IntStream.generate(new SquareGenerator());
PrimitiveIterator.OfInt stepThrough = squares.iterator();
for (int i = 0; i < 10; i++) {
    System.out.println(stepThrough.nextInt());
}
System.out.println("First iterator done...");

// We can go on as long as we like...
for (int i = 0; i < 10; i++) {
    System.out.println(stepThrough.nextInt());
}

因为我们的可能值列表是无限的,所以我们必须采用一种模型,其中元素不会提前全部存在。基本上,一段代码必须在我们需要时返回下一个元素。用于实现这一点的关键技术是惰性求值

注意

对于 Java 来说,惰性评估是一个重大的变化,因为直到 JDK 8 为止,表达式的值总是在将其分配给变量(或传递给方法)时立即计算的。这种熟悉的模型,即值立即计算,称为“急切评估”,是大多数主流编程语言中表达式评估的默认行为。

我们可以在上面的示例中看到惰性评估的实际操作,如果我们稍微修改getAsInt()来主动提供输出时:

    @Override
    public synchronized int getAsInt() {
        int thisResult = current * current;
        System.out.print(String.format("%d... ", thisResult));
        current++;
        return thisResult;
    }

当运行此修改后的程序时,我们将看到输出,显示每个getAsInt()调用紧接着在for循环中使用该值:

1... 1
4... 4
9... 9
16... 16
25... 25
36... 36
49... 49
64... 64
81... 81
100... 100
First iterator done...
121... 121
...

将无限流建模的一个重要后果是collect()等方法无法工作。这是因为我们无法将整个流实例化为一个集合(在创建无限数量的对象之前,我们会耗尽内存)。

即使流不是无限的,也很重要意识到评估的哪些部分是惰性的。例如,尝试在map操作期间显示诊断信息的以下代码实际上并不产生任何输出:

List.of(1, 2, 3, 4, 5)
    .stream()
    .map((i) - > {
        System.out.println(i);
        return i;
    });

只有当我们提供像collect()toList()这样的终端操作时,我们的map() lambda 才会真正执行。

意识到哪些中间结果在其评估时是惰性的,是 Java 开发人员在使用 Stream API 时应该注意的一个话题。然而,更复杂的实现细节通常由库编写者而不是流的用户来处理。

虽然filtermapreduce的结合几乎可以完成我们所需的任何与流相关的任务,但这并不总是最方便的 API。有许多额外的方法建立在这些原语之上,为我们提供了更丰富的词汇来处理流。

进一步的过滤

处理流时更复杂的方法经常受益于更精细的过滤。Stream接口上的许多方法允许更具表现力地描述我们希望如何裁剪我们的流以供消费:

// Distinct elements only
Stream.of(1, 2, 1, 2, 3, 4)
      .distinct();
// Results in  [1, 2, 3, 4]

// Ignores items until predicate matches, then returns remainder
// Note that later elements aren't required to match the predicate.
Stream.of(1, 2, 3, 4, 5, 3)
      .dropWhile((i) -> i < 4);
// Results in [4, 5, 3]

// Returns items from the stream until the predicate stops matching.
// Note that later elements matching the predicate aren't returned.
Stream.of(1, 2, 3, 4, 3)
      .takeWhile((i) -> i < 4);
// Results in [1, 2, 3]

// Skips the first N items in the stream
Stream.of(1, 2, 3, 4, 5)
      .skip(2);
// Results in [3, 4, 5]

// Limits items taken from stream to an exact value
// Useful with infinite streams to set boundaries
Stream.of(1, 2, 3, 4, 5)
      .limit(3);
// Results in [1, 2, 3]

流中的匹配

另一个典型的操作是对整个元素流提出问题,例如是否所有元素都(或者没有一个)与给定的谓词匹配,或者是否有任何一个单独的元素匹配:

// Are all the items odd?
Stream.of(1, 1, 3, 5)
      .allMatch((i) -> i % 2 == 1);
// Returns true

// Are none of the items even?
Stream.of(1, 1, 3, 5)
      .noneMatch((i) -> i % 2 == 0);
// Returns true

// Is at least one item even?
Stream.of(1, 1, 3, 5, 6)
      .anyMatch((i) -> i % 2 == 0);
// Returns true

展开

一旦我们开始将数据建模为流,发现另一个层次的流并不罕见。例如,如果我们处理多行文本并希望从整个块中收集单词集合,我们可能首先使用以下代码:

var lines = Stream.of(
    "For Brutus is an honourable man",
    "Give me your hands if we be friends and Robin shall restore amends",
    "Misery acquaints a man with strange bedfellows");

lines.map((s) -> s.split(" +"));
// Returns Stream.of(new String[] { "For", "Brutus",...},
//                   new String[] { "Give", "me", "your", ... },
//                   new String[] { "Misery", "acquaints", "a", ... },

然而,这并不是我们所期望的纯粹的单词列表。我们有一个额外的嵌套层次,一个Stream<String[]>而不是Stream<String>

flatMap()方法专为这些情况设计。对于原始流中的每个元素,提供给flatMap()的 lambda 返回的不是单个值,而是另一个Stream。然后flatMap()收集这些多个流并将它们连接起来,平铺成包含类型的单个流。

在我们的例子中,split()给了我们数组,我们可以轻松地将其转换为流。从那里开始,flatMap()将会把那些多个流转换成我们需要的单个单词流:

lines.flatMap((s) -> Arrays.stream(s.split(" +")));
// Returns Stream.of("For", "Brutus", "is", "an", ...)

从流到集合

定义一个单独的Stream接口是一种实用的方式,可以在 Java 中启用更新的开发风格,同时不会破坏现有的代码。然而,有时您仍然需要标准的 Java 集合,无论是传递给另一个 API 还是用于流中不存在的功能。对于返回简单的List或元素数组的最常见情况,这些方法直接在Stream接口上提供:

// Immutable list returned
List<Integer> list =
    Stream.of(1, 2, 3, 4, 5).toList();

// Note the return type is `Object[]`
Object[] array =
    Stream.of(1, 2, 3, 4, 5).toArray();

将流转换为非流集合或其他对象的最主要方法是通过collect()方法执行的。该方法接收Collector接口的一个实例,允许以各种可能的方式收集我们的流结果,而不会向Stream接口本身添加内容。

Collectors类作为静态方法提供了各种收集器的标准实现。例如,我们可以将我们的流转换为我们任何普通的集合类型:

// In earlier versions of Java, Stream#toList() didn't exist
// This was the commonly used approach so you'll still see it often
List<Integer> list =
    Stream.of(1,2,3,4,5)
          .collect(Collectors.toList());

// Create a standard Set (no duplicates)
Set<Integer> set =
    Stream.of(1,2,3,4,5)
          .collect(Collectors.toSet());

// For Collection types that don't have a specific method, we can
// use toCollection with a function that creates our empty instance
// Each item will be added to that collection
TreeSet<Integer> collection =
    Stream.of(1,2,3,4,5)
          .collect(Collectors.toCollection(TreeSet::new));

// When creating maps we must provide two functions
// The first constructs the key for each element, the second the value
// Here, each int is its own key and the value is its toString()
Map<Integer, String> map =
    Stream.of(1,2,3,4,5)
          .collect(Collectors.toMap(
                      (i) -> i,
                      Object::toString));

Stream#toList()不同,所有这些选项都返回其集合类型的可修改版本。如果你想返回一个不可修改或不可变版本,Collectors还提供了特定的方法。它们遵循了一个命名约定toUnmodifiableX(),其中X是上面看到的集合类型。

收集集合的最后一种变化是当您想要根据某些属性对元素进行分组时。在这个例子中,我们想要根据它们的第一个数字将数字分组:

Map<Character, List<Integer>> grouped =
        Stream.of(10, 11, 12, 20, 30)
                .collect(Collectors.groupingBy((i) -> {
                    return i.toString().charAt(0);
                }));
// Returns map with {"1"=[10, 11, 12], "2"=[20], "3"=[30]}

从流到值

我们并不总是想从我们的流中检索集合,有时我们需要单个值,就像reduce()方法给我们的那样。

Stream有一些内置方法,用于我们可能想要从我们的流中获得的最常见值:

var count = Stream.of(1,2,3).count();
var max = Stream.of(1,2,3).max(Integer::compareTo);
var min = Stream.of(1,2,3).min(Integer::compareTo);

collect()方法不仅限于返回集合类型。Collectors提供了各种各样的结果收集方法,以帮助进行常见的计算,特别是在数字流上。这些方法都需要一个函数,用于将流中的传入项转换为数字,这样它就可以轻松地与对象以及原始值一起使用:

var average =
    Stream.of(1,2,3)
          .collect(Collectors.averagingInt(Integer::intValue));

var sum =
    Stream.of(1,2,3)
          .collect(Collectors.summingInt(Integer::intValue));

var summary =
    Stream.of(1,2,3)
          .collect(Collectors.summarizingInt(Integer::intValue));
// IntSummaryStatistics{count=3, sum=6, min=1, average=2.0, max=3}

类似的方法也适用于长整型和双精度浮点型,除了整型之外。

从流中获取结果的最后一种方法有助于我们处理字符串。一个经典问题是将一系列较小的字符串转换为一个较大的分隔字符串。流使这变得非常简单。

var words = Stream.of("This", "is", "some", "text");
var csv = words.collect(Collectors.joining(", "));
// Returns string "This, is, some, text"

流实用程序默认方法

Java Streams 利用机会向 Java 集合库引入了许多新方法。通过默认方法,可以向集合添加新方法而不会破坏向后兼容性。

这些方法中,有些是从我们现有的集合中创建流的脚手架方法。这些方法包括Collection::streamCollection::parallelStream以及Collection::spliterator(其具有专门的形式List::spliteratorSet::spliterator)。

其他方法提供了先前版本中存在的功能的快捷方式。例如,List::sort方法基本上委托给Collections类上已经可用的更繁琐的版本:

// Essentially just forwards to the helper method in Collections
public default void sort(Comparator<? super E> c) {
    Collections.<E>sort(this, c);
}

其余的方法利用java.util.function接口提供了额外的功能技术:

Collection::removeIf

此方法接受一个Predicate并在集合内部进行迭代,移除满足谓词对象的任何元素。

Map::forEach

此方法的单个参数是一个接受两个参数(键的类型和值的类型之一)并返回void的 lambda 表达式。这将转换为BiConsumer的实例,并应用于映射中的每个键值对。

Map::computeIfAbsent

这需要一个键和一个将键类型映射到值类型的 lambda 表达式。如果映射中不存在指定的键(第一个参数),则使用 lambda 表达式计算默认值并将其放入映射中。

(还参见Map::computeIfPresentMap::computeMap::merge。)

摘要

在本章中,我们已经了解了 Java 集合库,并看到了如何开始使用 Java 的基本和经典数据结构的实现。我们遇到了通用的Collection接口,以及ListSetMap。我们看到了处理集合的原始迭代方式,并引入了基于基础编程思想的新 Java Streams 风格。在 Streams API 中,我们看到新方法比经典方法更加通用,可以表达比较微妙的编程概念。

我们只是触及了表面——Streams API 是 Java 代码编写和架构中的根本性转变。在 Java 中,函数式编程理念的实现存在设计上的固有限制。尽管如此,Streams 代表“恰好足够的函数式编程”的可能性非常有吸引力。

让我们继续吧。在下一章中,我们将继续探讨数据,以及如文本处理、处理数值数据和 Java 8 的新日期和时间库等常见任务。

第九章:处理常见数据格式

大多数编程是处理各种格式的数据。在本章中,我们将介绍 Java 处理两类重要数据——文本和数字的支持。本章的后半部分将专注于处理日期和时间信息。这尤其重要,因为 Java 8 发布了完全新的 API 来处理日期和时间。在讨论 Java 的原始日期和时间 API 之前,我们会对这个接口进行深入讨论。

许多应用程序仍在使用旧的 API,因此开发人员需要了解旧方法,但新的 API 要好得多,我们建议尽快转换。在我们开始处理更复杂的格式之前,让我们先讨论文本数据和字符串。

文本

我们已经在许多场合见过 Java 的字符串。它们由 Unicode 字符序列组成,并表示为String类的实例。字符串是 Java 程序处理的最常见数据类型之一(您可以通过使用我们将在第十三章中介绍的jmap工具自行验证这一点)。

在本节中,我们将更深入地了解String类,并理解它在 Java 语言中处于相当独特的位置。在本节的后面,我们将介绍正则表达式,这是一种非常常见的用于搜索文本模式的抽象(无论编程语言如何,都是程序员工具箱中的经典工具)。

字符串的特殊语法

String类在 Java 语言中以一种略微特殊的方式处理。这是因为尽管它不是原始类型,但字符串是如此常见,以至于 Java 具有许多特殊的语法功能,旨在使字符串处理变得容易。让我们看看 Java 为字符串提供的一些特殊语法功能的示例。

字符串字面量

正如我们在第二章中看到的,Java 允许将一系列字符放置在双引号中以创建文字字符串对象。就像这样:

String pet = "Cat";

如果没有这种特殊语法,我们将不得不编写像这样可怕的大量代码:

char[] pullingTeeth = {'C', 'a', 't'};
String pet = new String(pullingTeeth);

这种方法很快就会变得枯燥乏味,所以毫无意外,像所有现代编程语言一样,Java 提供了简单的字符串字面量语法。字符串字面量是完全合法的对象,因此像下面这样的代码完全合法:

System.out.println("Dog".length());

使用基本双引号的字符串不能跨越多行,但是最近版本的 Java 已经包括了使用"""语法的多行文本块。生成的字符串对象在编译时创建,并且与"引号括起来的字符串没有区别,只是更易于表达:

String lyrics = """
 This is the song that never ends
 This song goes on and one my friend
 ...""";

请参阅“字符串字面量”了解 Java 中字符串字面量的完整覆盖。

toString()

此方法在Object上定义,旨在允许将任何对象轻松转换为字符串。这使得可以通过使用System.out.println()方法轻松打印出任何对象。实际上,该方法是PrintStream::println,因为System.out是一个类型为PrintStream的静态字段。让我们看看该方法的定义:

    public void println(Object x) {
        String s = String.valueOf(x);
        synchronized (this) {
            print(s);
            newLine();
        }
    }

这通过使用静态方法String::valueOf()创建一个新的字符串:

    public static String valueOf(Object obj) {
        return (obj == null) ? "null" : obj.toString();
    }
注意

静态的valueOf()方法用于代替直接调用toString(),以避免在objnull时抛出NullPointerException

这种构造方式意味着toString()对于任何对象始终可用,这对 Java 提供的另一个主要语法特性非常有用:字符串连接。

字符串连接

Java 允许我们通过将一个字符串的字符“添加”到另一个字符串的末尾来创建新的字符串。这称为字符串连接,并使用+运算符。在 Java 8 及以下版本中,它的工作原理是首先创建一个StringBuilder对象作为“工作区”,其中包含与原始字符串相同的字符序列。

注意

Java 9 引入了一种新的机制,使用invokedynamic指令而不是直接使用StringBuilder。这是一种高级功能,超出了本讨论的范围,但不会改变对 Java 开发人员可见的行为。

然后更新构建器对象,并将附加字符串的字符添加到末尾。最后,在StringBuilder对象上调用toString()(现在包含来自两个字符串的字符)。这给了我们一个包含所有字符的新字符串。无论何时使用+运算符连接字符串,javac都会自动创建所有这些代码。

连接过程返回一个全新的String对象,正如我们在这个例子中所见:

String s1 = "AB";
String s2 = "CD";

String s3 = s1;
System.out.println(s1 == s3); // Same object? Yes.

s3 = s1 + s2;
System.out.println(s1 == s3); // Still same? Nope!
System.out.println(s1);
System.out.println(s3);

连接示例直接显示了+运算符不会直接修改(或突变s1。这是一个更一般的原则示例:Java 的字符串是不可变的。这意味着一旦选择了组成字符串的字符并创建了String对象,那么这个String就不能被改变。这是 Java 中一个重要的语言原则,让我们稍微深入了解一下。

字符串的不可变性

要“改变”一个字符串,就像我们讨论字符串连接时看到的那样,实际上需要创建一个中间的StringBuilder对象作为临时的工作区,并在其上调用toString(),以将其转换为一个新的String实例。让我们看看代码是如何工作的:

String pet = "Cat";
StringBuilder sb = new StringBuilder(pet);
sb.append("amaran");
String boat = sb.toString();
System.out.println(boat);

像这样的代码在行为上等效于以下内容,尽管在 Java 9 及以上版本中实际的字节码序列将有所不同:

String pet = "Cat";
String boat = pet + "amaran";
System.out.println(boat);

当然,除了在javac下使用之外,StringBuilder类也可以直接在应用程序代码中使用,我们已经看到了这一点。

警告

除了 StringBuilder,Java 还有一个 StringBuffer 类。这来自 Java 的最古老版本,不应该用于新开发——应该使用 StringBuilder,除非你确实需要在多个线程之间共享新字符串的构造。

字符串的不可变性是一种非常有用的语言特性。例如,假设 + 修改了字符串而不是创建新的字符串;那么,每当任何线程连接两个字符串时,所有其他线程也会看到这种变化。对于大多数程序来说,这不太可能是有用的行为,因此不可变性是合理的选择。

哈希码和有效不可变性

我们已经在 第五章 中遇到了 hashCode() 方法,我们描述了该方法必须满足的合约。让我们看一下 JDK 源代码,看看 String::hashCode() 方法是如何定义的:

    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

字段 hash 存储了字符串的哈希码,而字段 value 是一个 char[],存储了构成字符串的实际字符。从代码中可以看出,Java 通过循环遍历字符串的所有字符来计算哈希值。因此,计算机指令的数量与字符串中的字符数成正比。对于非常大的字符串,这可能需要一些时间。Java 不会预先计算哈希值,而是在需要时才计算。

当方法运行时,通过遍历字符数组来计算哈希值。在数组的末尾,我们退出 for 循环并将计算得到的哈希值写回到 hash 字段中。现在,当再次调用此方法时,值已经被计算过了,所以我们可以直接使用缓存的值,后续调用 hashCode() 将立即返回。

注意

字符串的哈希码计算是一个良性数据竞争的例子。在具有多个线程的程序中,它们可能会竞争计算哈希码。但是,它们最终会得出完全相同的答案,因此称为良性

String 类的所有字段都是 final 的,除了 hash。因此,Java 的字符串严格来说并不是不可变的。但是,因为 hash 字段只是从其他所有不可变字段中确定性计算的值的缓存,只要 String 类被正确编码,它就会表现得像是不可变的。具有这种属性的类称为有效不可变——它们在实践中非常罕见,工作程序员通常可以忽略真正不可变和有效不可变数据之间的区别。

字符串格式化

在 “字符串连接” 中,我们看到 Java 如何支持通过连接较小的字符串来构建更大的字符串。虽然这很有效,但在构造更复杂的输出字符串时,这往往会变得单调和容易出错。Java 提供了许多其他方法和类来进行更丰富的字符串格式化。

String类上的静态方法format允许我们指定一个模板,然后动态地插入各种值:

// Result is the string "The 1 pet is a cat: true?"
var s = String.format("The %d pet is a %s: %b?%n", 1, "cat", true);

// Same result, but called on string instance instead of statically
s = "The %d pet is a %s: %b?%n".formatted(1, "cat", true);

格式字符串中的占位符,其中将引入值,以%字符开始。在这个例子中,我们用%d替换整数,用%s替换字符串,用%b替换布尔值,并最终用%n换行。

那些在 C 或类似语言中有背景的人会从这个受人尊敬的printf函数中认识到这种格式。Java 支持许多相同的格式,尽管不是全部,具有各种各样的选项。Java 的printf还提供了更复杂的日期和时间格式化,就像在 C 的strftime函数中看到的一样。请参阅 Java 文档中的java.util.Formatter,了解可用的所有选项的完整列表。

Java 还通过在出现无效条件时抛出异常来改进使用这些格式字符串的体验,比如占位符与值的数量不匹配,或者无法识别的%值。

String.format()提供了构建复杂字符串的强大工具,但是,特别是在使输出在各个国家正确时,需要更多的帮助。 NumberFormat是 Java 提供的支持更复杂的、与区域设置相关的值格式化的类的一个示例。其他格式化程序也可以在java.text下找到:

// Some common locales are available as constants
// A much longer list can be accessed at runtime
var locale = Locale.US;

NumberFormat.getNumberInstance(locale).format(1_000_000_000L)
// 1,000,000,000

NumberFormat.getCurrencyInstance(locale).format(1_000_000_000L)
// $1,000,000,000.00

NumberFormat.getPercentInstance(locale).format(0.1)
// 10%

NumberFormat.getCompactNumberInstance(locale , NumberFormat.Style.LONG)
            .format(1_000_000_000L)
// 1 billion

NumberFormat.getCompactNumberInstance(locale, NumberFormat.Style.SHORT)
            .format(1_000_000_000L)
// 1B

正则表达式

Java 支持正则表达式(通常缩写为regexregexp)。这些是用于扫描和匹配文本的搜索模式的表示。正则表达式是我们要搜索的字符序列。它们可以非常简单——例如,abc表示我们正在搜索的文本中的任何位置的a,后面紧跟着b,后面紧跟着c。请注意,搜索模式可能在输入文本中匹配零个、一个或多个位置。

最简单的正则表达式只是文本的字面字符序列,比如abc。然而,正则表达式的语言可以表达比字面序列更复杂、更微妙的想法。例如,正则表达式可以表示如下所示的匹配模式:

  • 数字字符

  • 任意字母

  • 任意数量的字母,它们必须都在aj的范围内,但可以是大写或小写

  • a后面跟着任意四个字符,然后是b

我们用来编写正则表达式的语法很简单,但是由于我们可以构建复杂的模式,通常可以编写一个没有精确实现我们想要的内容的表达式。在使用正则表达式时,全面测试它们非常重要。这应该包括应该通过的测试用例和应该失败的情况。

为了表达这些更复杂的模式,正则表达式使用元字符。这些是指示需要特殊处理的特殊字符。这可以类比于操作系统 shell 中使用 * 字符的用法。在这些情况下,理解 * 不是字面上解释,而是表示“任何东西”。如果我们想在 Unix 的当前目录中列出所有的 Java 源文件,我们会发出以下命令:

ls *.java

正则表达式的元字符是相似的,但它们的数量要多得多,并且比 shell 中可用的集合要灵活得多。它们的含义也不同于它们在 shell 脚本中的含义,因此不要感到困惑。

警告

世界上存在许多不同的正则表达式模式。Java 的正则表达式与 PCRE 兼容,支持一组常见的 Perl 编程语言中广泛使用的元字符。但请注意,网上找到的随机正则表达式可能实际上可能有效,也可能无效,取决于你使用的正则表达式库。

让我们来看几个例子。假设我们想要一个拼写检查程序,它对英式英语和美式英语之间的拼写差异宽松。这意味着 honorhonour 都应该被接受为有效的拼写选择。这在正则表达式中很容易做到。

Java 使用一个称为 Pattern(来自包 java.util.regex)的类来表示正则表达式。但是,这个类不能直接实例化。相反,通过使用静态工厂方法 compile() 来创建新实例。然后,从模式派生一个 Matcher 用于特定输入字符串,我们可以用它来探索输入字符串。例如,让我们来看一下莎士比亚戏剧 凯撒大帝 中的一部分:

Pattern p = Pattern.compile("honou?r");

String caesarUK = "For Brutus is an honourable man";
Matcher mUK = p.matcher(caesarUK);

String caesarUS = "For Brutus is an honorable man";
Matcher mUS = p.matcher(caesarUS);

System.out.println("Matches UK spelling? " + mUK.find());
System.out.println("Matches US spelling? " + mUS.find());
注意

当使用 Matcher 时要小心,因为它有一个名为 matches() 的方法。但是,这个方法指示模式是否可以覆盖整个输入字符串。如果模式只在字符串中间开始匹配,它将返回 false

最后一个示例介绍了我们的第一个正则表达式元字符 ?,在模式 honou?r 中。这意味着“前面的字符是可选的”——所以 honourhonor 都将匹配。让我们看另一个例子。假设我们想匹配 minimizeminimise(后一种拼写在英国英语中更常见)。我们可以使用方括号来指示可以从集合中选择任何一个字符(但只能有一个备选项)[]——就像这样:

Pattern p = Pattern.compile("minimi[sz]e");

表 9-1 提供了 Java 正则表达式中可用的扩展元字符列表。

表 9-1. 正则表达式元字符

元字符 含义 注释
? 可选字符——零个或一个实例
* 前面字符的零个或多个
+ 前面字符的一个或多个
{M,N} 在前面字符的 MN 个实例之间
\d 一个数字
\D 一个非数字字符
\w 一个单词字符 数字,字母和 _
\W 一个非单词字符
\s 空白字符
\S 非空白字符
\n 换行符
\t 制表符
. 任意单个字符 在 Java 中不包括换行符
[ ] 方括号中的任何字符 称为字符类
[^ ] 不在方括号中的任何字符 称为否定字符类
( ) 构建模式元素组 称为组(或捕获组)
&#124; 定义替代可能性 实现逻辑OR
^ 字符串开头
` 元字符 含义
--- --- ---
? 可选字符——零个或一个实例
* 前面字符的零个或多个
+ 前面字符的一个或多个
{M,N} 在前面字符的 MN 个实例之间
\d 一个数字
\D 一个非数字字符
\w 一个单词字符 数字,字母和 _
\W 一个非单词字符
\s 空白字符
\S 非空白字符
\n 换行符
\t 制表符
. 任意单个字符 在 Java 中不包括换行符
[ ] 方括号中的任何字符 称为字符类
[^ ] 不在方括号中的任何字符 称为否定字符类
( ) 构建模式元素组 称为组(或捕获组)
&#124; 定义替代可能性 实现逻辑OR
^ 字符串开头
字符串结尾
\\ 字面转义(\\)字符

还有一些其他内容,但这是基本列表。java.util.regex.Pattern的 Java 文档是获取所有详细信息的良好来源。通过这些信息,我们可以构建更复杂的匹配表达式,比如本节前面提到的示例:

String text = "Apollo 13";

// A numeric digit. Note we must use \\ because we need a literal \
// and Java uses a single \ as an escape character, as per the table
Pattern p = Pattern.compile("\\d");
Matcher m = p.matcher(text);
System.out.print(p + " matches " + text + "? " + m.find());
System.out.println(" ; match: " + m.group());

// A single letter
p = Pattern.compile("[a-zA-Z]");
m = p.matcher(text);
System.out.print(p + " matches " + text + "? " + m.find());
System.out.println(" ; match: " + m.group());

// Any number of letters, which must all be in the range 'a' to 'j'
// but can be upper- or lowercase
p = Pattern.compile("([a-jA-J]*)");
m = p.matcher(text);
System.out.print(p + " matches " + text + "? " + m.find());
System.out.println(" ; match: " + m.group());

// 'a' followed by any four characters, followed by 'b'
text = "abacab";
p = Pattern.compile("a....b");
m = p.matcher(text);
System.out.print(p + " matches " + text + "? " + m.find());
System.out.println(" ; match: " + m.group());

正则表达式极其有用,可以确定字符串是否与给定模式匹配,还可以从字符串中提取片段。这是通过机制完成的,模式中通过()表示:

String text = "Apollo 13";

Pattern p = Pattern.compile("Apollo (\\d*)");
Matcher m = p.matcher(text);
System.out.print(p + " matches " + text + "? " + m.find());
System.out.println("; mission: " + m.group(1));

调用Matcher.group(1)返回我们模式中(\\d*)匹配的文本。允许多个组,还有通过名称而非位置使用组的语法。详细信息请参阅 Java 文档。

处理正则表达式时的一个常见困难是需要同时为 Java 字符串和正则表达式使用转义字符。文本块中少了一些转义字符,比如引号字符,它们可以提供更清晰的表达:

// Detect if there are any double-quoted passages in string
// Note standard string literal requires escaping quotations
Pattern oldQuoted = Pattern.compile(".*\".*\".*");

Pattern newQuoted = Pattern.compile("""
 .*".*".*""");

让我们通过介绍 Java 8 中作为Pattern的一部分新增的方法asPredicate()来结束我们对正则表达式的快速导览。该方法的存在使我们能够轻松地从正则表达式过渡到 Java 集合及其对 lambda 表达式的新支持。

例如,假设我们有一个正则表达式和一组字符串。很自然地会问一个问题:“哪些字符串与该正则表达式匹配?”我们可以使用过滤惯用法,并使用辅助方法将正则表达式转换为Predicate,像这样:

// Contains a numeric digit
Pattern p = Pattern.compile("\\d");

List<String> ls = List.of("Cat", "Dog", "Ice-9", "99 Luftballoons");
List<String> containDigits = ls.stream()
        .filter(p.asPredicate())
        .toList();

System.out.println(containDigits);

Java 内置的文本处理支持对于大多数商业应用通常需要的文本处理任务已经足够。更高级的任务,例如搜索和处理非常大的数据集,或复杂的解析(包括形式语法),超出了本书的范围,但 Java 拥有大量有用的库和专门技术的绑定,用于文本处理和分析。

数字和数学

在这一节中,我们将更详细地讨论 Java 对数值类型的支持。特别是,我们将讨论 Java 使用的整数类型的二进制补码表示。我们将介绍浮点数的表示方式,并涉及它们可能引起的一些问题。我们还将通过一些使用 Java 标准数学操作库函数的示例来说明。

Java 如何表示整数类型

Java 的整数类型都是有符号的,正如我们在“原始数据类型”中首次提到的那样。这意味着所有整数类型都可以表示正数和负数。由于计算机使用二进制,这意味着表示这些数字的唯一合理方式是分割可能的位模式并使用其中一半来表示负数。

让我们用 Java 的byte类型来研究 Java 如何表示整数。它有 8 位,因此可以表示 256 个不同的数字(即 128 个负数和 128 个非负数)。用0b0000_0000模式表示零是合乎逻辑的(回忆一下 Java 使用0b<二进制数字>的语法来表示二进制数),然后可以轻松地找出正数的位模式:

byte b = 0b0000_0001;
System.out.println(b); // 1

b = 0b0000_0010;
System.out.println(b); // 2

b = 0b0000_0011;
System.out.println(b); // 3

// ...

b = 0b0111_1111;
System.out.println(b); // 127

当我们设置字节的第一个位时,符号会改变(因为我们已经用完了为非负数保留的所有位模式)。所以模式0b1000_0000应该表示某个负数——但是具体是哪个呢?

注意

由于我们定义的方式,我们可以很简单地识别出一个位模式是否表示负数:如果位模式的最高位是1,则表示的数字是负数。

考虑一个由所有位设置为 1 的位模式:0b1111_1111。如果我们给这个数字加上1,结果会溢出一个byte类型的 8 位存储空间,导致0b1_0000_0000。如果我们希望将其限制在byte数据类型内,则应忽略溢出,因此这变为0b0000_0000,也就是零。因此,自然地采用“所有位设置为 1 表示-1”的表示方式。这样可以实现自然的算术行为,如下所示:

b = (byte) 0b1111_1111; // -1
System.out.println(b);
b++;
System.out.println(b);

b = (byte) 0b1111_1110; // -2
System.out.println(b);
b++;
System.out.println(b);

最后,让我们看一下0b1000_0000表示的数字。它是该类型可以表示的最负的数字,所以对于byte类型:

b = (byte) 0b1000_0000;
System.out.println(b); // -128

这种表示方式被称为二进制补码,是有符号整数最常见的表示方式。要有效使用它,你只需记住两点:

  • 一个所有位为 1 的模式表示为-1。

  • 如果最高位设置为 1,则数字为负数。

Java 的其他整数类型(shortintlong)的行为方式非常相似,但其表示中包含更多的位数。char数据类型不同,因为它表示 Unicode 字符,但在某些方面它的行为类似于无符号的 16 位数值类型。在 Java 程序员眼中,它通常不被视为整数类型。

Java 和浮点数

计算机使用二进制表示数字。我们已经看到 Java 如何使用补码表示整数。但是对于分数或小数呢?Java 和几乎所有现代编程语言一样,使用浮点算术来表示它们。让我们首先看看这是如何工作的,首先是十进制,然后是二进制。Java 将两个最重要的数学常数,eπ(pi),定义为java.lang.Math中的常量,如下:

public static final double E = 2.7182818284590452354;
public static final double PI = 3.14159265358979323846;

当然,这些常数实际上是无理数,不能精确地表示为分数或任何有限小数。¹ 这意味着每当我们尝试在计算机中表示它们时,总会存在舍入误差。假设我们只想处理π的八位数,我们希望将这些数字表示为一个整数。我们可以使用以下表示方法:

314159265 • 10^(–8)

This starts to suggest the basis of how floating-point numbers work. We
use some of the bits to represent the significant digits (`314159265`,
in our example) of the number and some bits to represent the *exponent*
of the base (`-8`, in our example). The collection of significant digits
is called the *significand* and the exponent describes whether we need
to shift the significand up or down to get to the desired number.
Of course, in the examples we’ve met until now, we’ve been working in
base-10\. Computers use binary, so we need to use this as the base in our
floating-point examples. This introduces some additional complications.

###### Note

The number `0.1` cannot be expressed as a finite sequence of binary
digits. This means that virtually all calculations that humans care
about will lose precision when performed in floating point, and rounding
error is essentially inevitable.

Let’s look at an example that shows the rounding problem:

double d = 0.3;

System.out.println(d); // 为了避免丑陋的表示而特别处理

double d2 = 0.2;

// 应该是 -0.1,但打印出 -0.09999999999999998

System.out.println(d2 - d);


The official standard that describes floating-point arithmetic is IEEE-754, and
Java’s support for floating point is based on that standard. The standard uses
24 binary digits for standard precision and 53 binary digits for double
precision.
As we mentioned briefly in Chapter 2, Java previously allowed deviation
from this standard, resulting in greater precision when some hardware features
were used to accelerate calculations. As of Java 17, this is no longer allowed,
and all floating-point operations comply with the IEEE-754 standard.

### BigDecimal

Rounding error is a constant source of headaches for programmers who
work with floating-point numbers. In response, Java has a class
`java.math.BigDecimal` that provides arbitrary precision arithmetic, in
a decimal representation. This works around the problem of `0.1` not
having a finite representation in binary, but there are still some edge
conditions when converting to or from Java’s primitive types, as you can
see:

double d = 0.3;

System.out.println(d);

BigDecimal bd = new BigDecimal(d);

System.out.println(bd);

bd = new BigDecimal("0.3");

System.out.println(bd);


However, even with all arithmetic performed in base-10, there are still
numbers, such as `1/3`, that do not have a terminating decimal
representation. Let’s see what happens when we try to represent such
numbers using `BigDecimal`:

bd = new BigDecimal(BigInteger.ONE);

bd.divide(new BigDecimal(3.0));

System.out.println(bd); // 应该是 1/3


As `BigDecimal` can’t represent `1/3` precisely, the call to `divide()`
blows up with `ArithmeticException`. When you are working with `BigDecimal`, it
is therefore necessary to be acutely aware of exactly which operations
could result in a nonterminating decimal result. To make matters worse,
`ArithmeticException` is an unchecked, runtime exception and so the Java
compiler does not even warn about possible exceptions of this type.

As a final note on floating-point numbers, the paper “What Every
Computer Scientist Should Know About Floating-Point Arithmetic” by David
Goldberg should be considered essential further reading for all
professional programmers. It is easily and freely obtainable on the
internet.

Java 的标准数学函数库

结束对 Java 对数值数据和数学支持的探索,让我们快速浏览一下 Java 附带的标准库函数。这些主要是静态辅助方法,位于java.lang.Math类上,包括函数如下:

abs()

返回一个数的绝对值。具有多种基本类型的重载形式。

三角函数

计算正弦、余弦、正切等的基本函数。Java 还包括双曲函数版本和反函数(如反正弦)。

max()min()

重载函数用于返回两个参数中较大和较小的一个(相同的数值类型)。

ceil()floor()

用于舍入到整数。floor()返回小于参数的最大整数(参数为 double)。ceil()返回大于参数的最小整数。

pow()exp()log()

用于计算一个数的幂以及计算指数和自然对数的函数。log10()提供以 10 为底的对数,而不是自然底数。

让我们看一些如何使用这些函数的简单示例:

System.out.println(Math.abs(2));
System.out.println(Math.abs(-2));

double cosp3 = Math.cos(0.3);
double sinp3 = Math.sin(0.3);
System.out.println((cosp3 * cosp3 + sinp3 * sinp3)); // Always 1.0

System.out.println(Math.max(0.3, 0.7));
System.out.println(Math.max(0.3, -0.3));
System.out.println(Math.max(-0.3, -0.7));

System.out.println(Math.min(0.3, 0.7));
System.out.println(Math.min(0.3, -0.3));
System.out.println(Math.min(-0.3, -0.7));

System.out.println(Math.floor(1.3));
System.out.println(Math.ceil(1.3));
System.out.println(Math.floor(7.5));
System.out.println(Math.ceil(7.5));

System.out.println(Math.round(1.3)); // Returns long
System.out.println(Math.round(7.5)); // Returns long

System.out.println(Math.pow(2.0, 10.0));
System.out.println(Math.exp(1));
System.out.println(Math.exp(2));
System.out.println(Math.log(2.718281828459045));
System.out.println(Math.log10(100_000));
System.out.println(Math.log10(Integer.MAX_VALUE));

System.out.println(Math.random());
System.out.println("Let's toss a coin: ");
if (Math.random() > 0.5) {
    System.out.println("It's heads");
} else {
    System.out.println("It's tails");
}

结束本节时,让我们简要讨论一下 Java 的 random() 函数。第一次调用时,它设置一个新的 java.util.Random 实例。这是一个 伪随机数生成器(PRNG)—一个通过数学公式产生 看起来 随机但实际上是由公式产生的确定性代码片段。² 在 Java 的情况下,用于 PRNG 的公式非常简单,例如:

    // From java.util.Random
    public double nextDouble() {
        return (((long)(next(26)) << 27) + next(27)) * DOUBLE_UNIT;
    }

如果伪随机数的序列总是从同一位置开始,那么将生成完全相同的数字流。为了解决这个问题,伪随机数生成器的种子由一个应包含尽可能多真实随机性的值来设置。对于这种随机性来源于种子值的来源,Java 使用通常用于高精度计时的 CPU 计数器值。

警告

虽然 Java 内置的伪随机数对大多数一般应用来说足够了,但某些专业应用(尤其是密码学和某些类型的模拟)对随机数有更严格的要求。如果你正在开发这类应用,请寻求已在该领域工作的程序员的专家建议。

现在我们已经看过了文本和数值数据,让我们继续看一下另一种最常见的数据类型:日期和时间信息。

日期和时间

几乎所有的业务软件应用都涉及到日期和时间的概念。在建模真实世界的事件或交互时,收集事件发生时间点对未来的报告或领域对象比较至关重要。Java 8 对开发者处理日期和时间的方式进行了彻底的改革。本节介绍了这些概念。在早期版本中,唯一的支持是通过类似 java.util.Date 的类,这些类不能很好地建模这些概念。使用旧的 API 的代码应尽快迁移。

引入 Java 8 日期和时间 API

Java 8 引入了新的包 java.time,其中包含大多数开发者使用的核心类。它还包含四个子包:

java.time.chrono

开发者使用非 ISO 标准的日历系统时将与之交互的备选年表。例如,日本的日历系统。

java.time.format

包含用于将日期和时间对象转换为 String,以及将字符串解析为日期和时间对象的 DateTimeFormatter

java.time.temporal

包含核心日期和时间类所需的接口,还包括用于日期高级操作的抽象(如查询和调整器)。

java.time.zone

用于底层时区规则的类;大多数开发者不需要这个包。

在表示时间时,最重要的概念之一是某个实体时间线上瞬时点的概念。虽然这个概念在例如特殊相对论中有明确定义,但在计算机中表示它需要我们做出一些假设。在 Java 中,我们将时间的单个点表示为Instant,它有以下关键假设:

  • 我们无法表示超过long类型可以容纳的秒数。

  • 我们无法以比纳秒精度更精确地表示时间。

这意味着我们限制自己以一种与当前计算机系统能力相一致的方式来建模时间。然而,还应引入另一个基本概念。

一个Instant是关于时空中单个事件的概念。然而,程序员经常需要处理两个事件之间的间隔,因此 Java 还包含了java.time.Duration类。该类忽略了可能出现的日历效应(例如夏令时)。通过这种对瞬时事件和事件之间持续时间的基本理解,让我们继续探讨关于瞬时事件的可能思考方式。

时间戳的部分

在图 9-1 中,我们展示了时间戳的不同部分在多种可能的方式下的分解。

JN7 0901

图 9-1. 时间戳的分解

这里的关键概念是在不同的时间可能适用于多种不同的抽象。例如,有些应用程序中,LocalDate对业务处理至关重要,所需的粒度是工作日。另外,有些应用程序要求亚秒甚至毫秒的精度。开发人员应了解他们的领域,并在应用程序中使用合适的表示。

示例

日期和时间 API 一开始可能会让人感到困惑,所以让我们从看一个示例开始,并讨论一个日记类,用于跟踪生日。如果你对生日很健忘,那么这样的类(特别是像getBirthdaysInNextMonth()这样的方法)可能会非常有帮助:

public class BirthdayDiary {
    private Map<String, LocalDate> birthdays;

    public BirthdayDiary() {
        birthdays = new HashMap<>();
    }

    public LocalDate addBirthday(String name, int day, int month,
                                 int year) {
        LocalDate birthday = LocalDate.of(year, month, day);
        birthdays.put(name, birthday);
        return birthday;
    }

    public LocalDate getBirthdayFor(String name) {
        return birthdays.get(name);
    }

    public int getAgeInYear(String name, int year) {
        Period period = Period.between(
              birthdays.get(name),
              birthdays.get(name).withYear(year));

        return period.getYears();
    }

    public Set<String> getFriendsOfAgeIn(int age, int year) {
        return birthdays.keySet().stream()
                .filter(p -> getAgeInYear(p, year) == age)
                .collect(Collectors.toSet());
    }

    public int getDaysUntilBirthday(String name) {
        Period period = Period.between(
              LocalDate.now(),
              birthdays.get(name));

        return period.getDays();
    }

    public Set<String> getBirthdaysIn(Month month) {
        return birthdays.entrySet().stream()
                .filter(p -> p.getValue().getMonth() == month)
                .map(p -> p.getKey())
                .collect(Collectors.toSet());
    }

    public Set<String> getBirthdaysInCurrentMonth() {
        return getBirthdaysIn(LocalDate.now().getMonth());
    }

    public int getTotalAgeInYears() {
        return birthdays.keySet().stream()
                .mapToInt(p -> getAgeInYear(p,
                      LocalDate.now().getYear()))
                .sum();
    }
}

这个课程展示了如何使用低级 API 来构建有用的功能。它还使用了像 Java Streams API 这样的创新技术,并演示了如何将LocalDate作为不可变类使用,以及如何将日期视为值进行处理。

查询

在广泛的情况下,我们可能会发现自己想要回答有关特定时间对象的问题。一些可能需要回答的示例问题包括:

  • 日期是否在三月一日之前?

  • 日期是否在闰年中?

  • 从今天到我的下一个生日还有多少天?

这是通过使用TemporalQuery接口来实现的,其定义如下:

public interface TemporalQuery<R> {
    R queryFrom(TemporalAccessor temporal);
}

queryFrom()的参数不应为null,但如果结果表明未找到值,则可以使用null作为返回值。

注意

Predicate 接口可以被视为只能代表是或否问题的查询。时间查询更为通用,可以返回“多少?”或“哪个?”的值,而不仅仅是“是”或“否”。

让我们通过考虑一个回答以下问题的查询来看一个查询的示例在操作中的应用:“这个日期属于一年的哪个季度?”Java 不直接支持季度的概念。而是使用这样的代码:

LocalDate today = LocalDate.now();
Month currentMonth = today.getMonth();
Month firstMonthofQuarter = currentMonth.firstMonthOfQuarter();

这仍然没有提供季度作为一个单独的抽象,而是仍然需要特殊的情况代码。所以让我们通过定义这个枚举类型稍微扩展 JDK 的支持:

public enum Quarter {
    FIRST, SECOND, THIRD, FOURTH;
}

现在,查询可以写为:

public class QuarterOfYearQuery implements TemporalQuery<Quarter> {
    @Override
    public Quarter queryFrom(TemporalAccessor temporal) {
        LocalDate now = LocalDate.from(temporal);

        if(now.isBefore(now.with(Month.APRIL).withDayOfMonth(1))) {
            return Quarter.FIRST;
        } else if(now.isBefore(now.with(Month.JULY)
                               .withDayOfMonth(1))) {
            return Quarter.SECOND;
        } else if(now.isBefore(now.with(Month.NOVEMBER)
                               .withDayOfMonth(1))) {
            return Quarter.THIRD;
        } else {
           return Quarter.FOURTH;
        }
    }
}

TemporalQuery 对象可以直接或间接使用。让我们分别看一些示例:

QuarterOfYearQuery q = new QuarterOfYearQuery();

// Direct
Quarter quarter = q.queryFrom(LocalDate.now());
System.out.println(quarter);

// Indirect
quarter = LocalDate.now().query(q);
System.out.println(quarter);

在大多数情况下,最好使用间接方法,其中查询对象作为参数传递给 query()。因为这样在代码中通常更容易阅读。

调整器

调整器修改日期和时间对象。例如,假设我们想返回包含特定时间戳的季度的第一天:

public class FirstDayOfQuarter implements TemporalAdjuster {
    @Override
    public Temporal adjustInto(Temporal temporal) {
        final int currentQuarter = YearMonth.from(temporal)
                .get(IsoFields.QUARTER_OF_YEAR);

        final Month firstMonthOfQuarter = switch (currentQuarter) {
            case 1 -> Month.JANUARY;
            case 2 -> Month.APRIL;
            case 3 -> Month.JULY;
            case 4 -> Month.OCTOBER;
            default -> throw new IllegalArgumentException("Impossible");
        };

        return LocalDate.from(temporal)
                .withMonth(firstMonthOfQuarter.getValue())
                .with(TemporalAdjusters.firstDayOfMonth());
    }
}

让我们看一个使用调整器的例子:

LocalDate now = LocalDate.now();
Temporal fdoq = now.with(new FirstDayOfQuarter());
System.out.println(fdoq);

这里的关键是 with() 方法,代码应该被解读为接受一个 Temporal 对象并返回另一个已修改的对象。对于处理不可变对象的 API 来说,这是完全正常的。

时区

如果你处理关于日期的代码,几乎肯定会遇到来自时区的复杂性。除了向用户清晰地展示信息的简单问题之外,时区还会引起问题,因为它们会变化。无论是来自夏令时的调整还是政府重新分配给定领土的区域,今天的时区定义不能保证下个月相同。

JVM 自带标准 IANA 时区数据的副本,因此通常需要 JDK 升级来获取时区更新。对于那些需要更频繁变更的人,Oracle 发布了一个 tzupdater 工具,可用于在原地修改 JDK 安装以使用更新的数据。

遗留日期和时间

不幸的是,许多应用程序尚未转换为使用随 Java 8 一起提供的优秀日期和时间库。因此,为了完整起见,我们简要提到了基于 java.util.Date 的遗留日期和时间支持。

警告

遗留的日期和时间类,特别是 java.util.Date,不应在现代 Java 环境中使用。考虑重构或重新编写任何仍使用旧类的代码。

在旧版 Java 中,java.time 不可用。相反,程序员依赖于由 java.util.Date 提供的传统和基础支持。从历史上看,这是表示时间戳的唯一方法,尽管被称为 Date,但实际上这个类包含了日期和时间组件 —— 这导致许多程序员感到困惑。

Date提供的遗留支持存在许多问题,例如:

  • Date类的设计有误。它实际上并不指代一个日期,而更像是一个时间戳。事实证明,我们需要不同的表示形式来表示日期、日期时间和瞬时时间戳。

  • Date是可变的。我们可以获得一个日期的引用,然后改变它所指向的日期。

  • Date类实际上不接受 ISO-8601,即通用的 ISO 日期标准,作为有效的日期。

  • Date类有大量被弃用的方法。

当前的 JDK 为Date使用了两个构造函数——一个是旨在成为“现在构造函数”的void构造函数,另一个是接受自纪元以来的毫秒数的构造函数。

如果无法避免使用java.util.Date,你仍然可以通过像以下示例中的代码进行转换,以利用更新的 API:

// Defaults to timestamp when called
var oldDate = new java.util.Date();

// Note both forms require specifying timezone -
// part of the failing in the old API
var newDate = LocalDate.ofInstant(
                  oldDate.toInstant(),
                  ZoneId.systemDefault());

var newTime = LocalDateTime.ofInstant(
                  oldDate.toInstant(),
                  ZoneId.systemDefault());

摘要

在本章中,我们遇到了几种不同类别的数据。文本和数字数据是最明显的例子,但作为工作程序员,我们将遇到许多不同类型的数据。让我们继续看看如何处理整个数据文件以及使用新的 I/O 和网络工作方式。幸运的是,Java 提供了处理许多这些抽象的良好支持。

¹ 实际上,它们实际上是超越数的两个已知例子之一。

² 让计算机产生真正的随机数是非常困难的,在确实需要这样做的罕见情况下,通常需要专门的硬件。

第十章:文件处理和 I/O

Java 自第一个版本以来就支持输入/输出(I/O)功能。然而,由于 Java 强烈追求平台独立性,早期版本的 I/O 功能强调可移植性而不是功能性。因此,它们并不总是易于使用。

我们将在本章后面看到原始 API 是如何被补充的——它们现在非常丰富,功能完备,并且非常易于开发。让我们从查看 Java I/O 的原始“经典”方法开始,而更现代的方法则在其之上增加了层次。

经典的 Java I/O

File类是 Java 原始文件 I/O 方式的基石。这种抽象化可以同时表示文件和目录,但在处理过程中有时会显得有些累赘,导致这样的代码:

// Get a file object to represent the user's home directory
var homedir = new File(System.getProperty("user.home"));

// Create an object to represent a config file (should
// already be present in the home directory)
var f = new File(homedir, "app.conf");

// Check the file exists, really is a file, and is readable
if (f.exists() && f.isFile() && f.canRead()) {

  // Create a file object for a new configuration directory
  var configdir = new File(homedir, ".configdir");
  // And create it
  configdir.mkdir();

  // Finally, move the config file to its new home
  f.renameTo(new File(configdir, ".config"));
}

这展示了File类可能具有的一些灵活性,但也展示了抽象化带来的一些问题。它非常通用,因此需要大量的方法来查询File对象,以确定它实际代表什么以及其功能。

文件

File类上有大量的方法,但某些基本功能(特别是直接提供读取文件实际内容的方式)则没有,并且从未直接提供过。以下是File方法的快速总结:

// Permissions management
boolean canX = f.canExecute();
boolean canR = f.canRead();
boolean canW = f.canWrite();

boolean ok;
ok = f.setReadOnly();
ok = f.setExecutable(true);
ok = f.setReadable(true);
ok = f.setWritable(false);

// Different views of the file's name
File absF = f.getAbsoluteFile();
File canF = f.getCanonicalFile();
String absName = f.getAbsolutePath();
String canName = f.getCanonicalPath();
String name = f.getName();
String pName = f.getParent();
URI fileURI = f.toURI(); // Create URI for File path

// File metadata
boolean exists = f.exists();
boolean isAbs = f.isAbsolute();
boolean isDir = f.isDirectory();
boolean isFile = f.isFile();
boolean isHidden = f.isHidden();
long modTime = f.lastModified(); // milliseconds since epoch
boolean updateOK = f.setLastModified(updateTime); // milliseconds
long fileLen = f.length();

// File management operations
boolean renamed = f.renameTo(destFile);
boolean deleted = f.delete();

// Create won't overwrite existing file
boolean createdOK = f.createNewFile();

// Temporary file handling
var tmp = File.createTempFile("my-tmp", ".tmp");
tmp.deleteOnExit();

// Directory handling
boolean createdDir = dir.mkdir(); // Non-recursive create only
String[] fileNames = dir.list();
File[] files = dir.listFiles();

File类还有一些方法并不完全适合于该抽象化。它们主要涉及对文件所在的文件系统进行询问(例如,询问可用的空闲空间):

long free = f.getFreeSpace();
long total = f.getTotalSpace();
long usable = f.getUsableSpace();

File[] roots = File.listRoots(); // all available Filesystem roots

I/O 流

I/O 流抽象化(不要与处理 Java 8 集合 API 时使用的流混淆)已存在于 Java 1.0 中,作为处理来自磁盘或其他源的顺序字节流的一种方式。

这个 API 的核心是一对抽象类,InputStreamOutputStream。它们被广泛使用,事实上,“标准”输入和输出流,即称为System.inSystem.out的流,属于这种类型。它们是System类的公共静态字段,甚至在最简单的程序中经常被使用:

System.out.println("Hello World!");

流的特定子类,包括FileInputStreamFileOutputStream,可以用于操作文件中的单个字节,例如,通过计算 ASCII 97(小写字母a)在文件中出现的次数:

try (var is = new FileInputStream("/Users/ben/cluster.txt")) {
  byte[] buf = new byte[4096];
  int len, count = 0;
  while ((len = is.read(buf)) > 0) {
    for (int i = 0; i < len; i = i + 1) {
      if (buf[i] == 97) {
        count = count + 1;
      }
    }
  }
  System.out.println("'a's seen: "+ count);
} catch (IOException e) {
  e.printStackTrace();
}

处理磁盘数据的这种方法可能缺乏一些灵活性——大多数开发人员都是以字符为单位思考,而不是以字节为单位。为了解决这个问题,这些流通常与更高级别的ReaderWriter类结合使用,提供字符流级别的交互,而不是由InputStreamOutputStream及其子类提供的低级字节流。

读者和写作者

通过转向以字符为基础的抽象层,而不是字节,开发人员面对的是一个更加熟悉的 API,它隐藏了许多字符编码、Unicode 等问题。

ReaderWriter类旨在覆盖字节流类,并消除对 I/O 流的低级处理的需要。它们有几个子类经常用于彼此叠加,例如:

  • FileReader

  • BufferedReader

  • StringReader

  • InputStreamReader

  • FileWriter

  • PrintWriter

  • BufferedWriter

要从文件中读取所有行并将它们打印出来,我们使用一个在FileReader之上叠加的BufferedReader,如下所示:

try (var in = new BufferedReader(new FileReader(filename))) {
  String line;

  while((line = in.readLine()) != null) {
    System.out.println(line);
  }
} catch (IOException e) {
  // Handle FileNotFoundException, etc. here
}

如果我们需要从控制台而不是文件中读取行,则通常会使用InputStreamReader应用于System.in。让我们看一个例子,我们想从控制台读取输入行,但要将以特殊字符开头的输入行视为特殊命令(“元命令”),而不是普通文本。这是许多聊天程序(包括 IRC)的常见特性。我们将使用来自第九章的正则表达式来帮助我们:

// Meta example: "#info username"
var SHELL_META_START = Pattern.compile("^#(\\w+)\\s*(\\w+)?");

try (var console =
      new BufferedReader(new InputStreamReader(System.in))) {
  String line;

  while((line = console.readLine()) != null) {
    // Check for special commands ("metas")
    Matcher m = SHELL_META_START.matcher(line);
    if (m.find()) {
      String metaName = m.group(1);
      String arg = m.group(2);
      doMeta(metaName, arg);
    } else {
      System.out.println(line);
    }
  }
} catch (IOException e) {
  // Handle FileNotFoundException, etc. here
}

要将文本输出到文件,我们可以使用以下代码:

var f = new File(System.getProperty("user.home")
 + File.separator + ".bashrc");
try (var out =
      new PrintWriter(new BufferedWriter(new FileWriter(f)))) {
  out.println("## Automatically generated config file. DO NOT EDIT");
  // ...
} catch (IOException iox) {
  // Handle exceptions
}

这种较旧的 Java I/O 风格还有许多其他偶尔有用的功能。例如,要处理文本文件,FilterInputStream类经常很有用。或者对于希望以类似于经典“管道”I/O 方法进行通信的线程,提供了PipedInputStreamPipedReader及其写入对应物。

到目前为止,我们在本章中一直在使用“try-with-resources”(TWR)这种语言特性。这种语法在“try-with-resources 语句”中简要介绍过,但是结合像 I/O 这样的操作时,它才发挥出最大的潜力,并且它给旧的 I/O 风格带来了新生。

再探讨 try-with-resources

要充分利用 Java 的 I/O 功能,理解何时以及如何使用 TWR 至关重要。只要可能,代码就应该使用 TWR,这一点非常容易理解。

在 TWR 之前,资源必须手动关闭;资源之间复杂的交互导致有漏洞的泄漏资源的错误代码。

实际上,Oracle 的工程师们估计,初始 JDK 6 版本中 60%的资源处理代码是错误的。因此,即使是平台的作者也无法可靠地处理手动资源处理,那么所有新代码肯定都应该使用 TWR。

TWR 的关键在于一个新接口—AutoCloseable。这个接口是Closeable的直接超接口。它标志着一个必须自动关闭的资源,并且编译器将插入特殊的异常处理代码。

在 TWR 资源子句中,只能声明实现了AutoCloseable接口的对象,但开发人员可以声明所需数量的对象:

try (var in = new BufferedReader(
                           new FileReader("profile"));
     var out = new PrintWriter(
                         new BufferedWriter(
                           new FileWriter("profile.bak")))) {
  String line;
  while((line = in.readLine()) != null) {
    out.println(line);
  }
} catch (IOException e) {
  // Handle FileNotFoundException, etc. here
}

这意味着资源自动限定于try块。这些资源(无论是可读还是可写的)会按照打开的相反顺序自动关闭,并且编译器会插入异常处理以考虑资源之间的依赖关系。

TWR 与其他语言和环境中的类似概念相关,例如 C++中的 RAII(资源获取即初始化)。然而,如终结部分所讨论的,TWR 仅限于块范围。这种轻微的限制是因为该功能由 Java 源代码编译器实现 —— 它在退出作用域时自动插入调用资源的close()方法的字节码(无论通过何种方式退出)。

因此,TWR 的整体效果更类似于 C#的using关键字,而不是 C++版本的 RAII。对于 Java 开发人员,理解 TWR 的最佳方式是“正确执行的终结”。正如在“Finalization”中所述,新代码不应直接使用终结机制,而应始终使用 TWR。较旧的代码应尽快重构为使用 TWR,因为它为资源处理代码提供了真正的实际好处。

经典 I/O 存在问题

即使引入了try-with-resources,File类及其相关类在执行标准 I/O 操作时仍存在一些问题。例如:

  • 缺少常见操作的方法。

  • 平台之间并不一致地处理文件名。

  • 没有统一的文件属性模型(例如,建模读写访问)。

  • 难以遍历未知的目录结构

  • 没有平台或操作系统特定的功能。

  • 不支持文件系统的非阻塞操作。

要解决这些缺点,Java 的 I/O 在几个重要版本中逐步演变。随着 Java 7 的发布,这种支持变得真正简单和高效。

现代 Java I/O

Java 7 引入了全新的 I/O API —— 通常称为 NIO.2 —— 几乎完全取代了原始的File方法进行 I/O 操作。

新的类位于java.nio.file包中,对于许多用例来说更加简单。API 由两个主要部分组成。第一个是称为Path的新抽象(可以视为表示文件位置,实际上可能存在也可能不存在)。第二部分是许多处理文件和文件系统的新便利和实用方法。这些方法作为Files类的静态方法提供。

文件

例如,当您使用新的Files功能时,基本的复制操作现在就像这样简单:

var inputFile = new File("input.txt");
try (var in = new FileInputStream(inputFile)) {
  Files.copy(in, Path.of("output.txt"));
} catch(IOException ex) {
  ex.printStackTrace();
}

让我们快速浏览一些Files中的主要方法——它们的大多数操作都是很明显的。在许多情况下,这些方法有返回类型。我们已经省略了处理这些内容,因为它们除了人为示例和复制等效的 C 代码行为外,很少有用:

Path source, target;
Attributes attr;
Charset cs = StandardCharsets.UTF_8;

// Creating files
//
// Example of path --> /home/ben/.profile
// Example of attributes --> rw-rw-rw-
Files.createFile(target, attr);

// Deleting files
Files.delete(target);
boolean deleted = Files.deleteIfExists(target);

// Copying/moving files
Files.copy(source, target);
Files.move(source, target);

// Utility methods to retrieve information
long size = Files.size(target);

FileTime fTime = Files.getLastModifiedTime(target);
System.out.println(fTime.to(TimeUnit.SECONDS));

Map<String, ?> attrs = Files.readAttributes(target, "*");
System.out.println(attrs);

// Methods to deal with file types
boolean isDir = Files.isDirectory(target);
boolean isSym = Files.isSymbolicLink(target);

// Methods to deal with reading and writing
List<String> lines = Files.readAllLines(target, cs);
byte[] b = Files.readAllBytes(target);

var br = Files.newBufferedReader(target, cs);
var bwr = Files.newBufferedWriter(target, cs);

var is = Files.newInputStream(target);
var os = Files.newOutputStream(target);

Files上的一些方法提供了传递可选参数的机会,以提供额外的(可能是实现特定的)操作行为。

这里的一些 API 选择会产生偶尔令人讨厌的行为。例如,默认情况下,复制操作不会覆盖现有文件,因此我们需要指定此行为作为复制选项:

Files.copy(Path.of("input.txt"), Path.of("output.txt"),
           StandardCopyOption.REPLACE_EXISTING);

StandardCopyOption是一个实现CopyOption接口的枚举。这也被LinkOption实现。因此,Files.copy()可以接受任意数量的LinkOptionStandardCopyOption参数。LinkOption用于指定如何处理符号链接(当然,前提是底层操作系统支持符号链接)。

Path

Path是用于在文件系统中定位文件的类型。它表示一个路径,其特点是:

  • 系统相关

  • 分层的

  • 由一系列路径元素组成

  • 假设性的(可能尚不存在,或已被删除)

因此,它与File基本不同。特别是,系统依赖通过Path作为接口而不是类来体现,这使得不同的文件系统提供者可以各自实现Path接口,并提供系统特定的功能,同时保留总体抽象。

Path的元素包括可选的根组件,它标识了此实例所属的文件系统层次结构。请注意,例如,相对Path实例可能没有根组件。除了根之外,所有的Path实例都有零个或多个目录名称和一个名称元素。

名称元素是距离目录层次结构根部最远的元素,并表示文件或目录的名称。Path可以被认为是由特殊分隔符或分隔符连接的路径元素组成。

Path是一个抽象概念;它不一定与任何物理文件路径绑定。这使得我们可以轻松地讨论尚不存在的文件的位置。Path接口提供了用于创建Path实例的静态工厂方法。

注意

当 NIO.2 在 Java 7 中引入时,接口上不支持静态方法,因此引入了Paths类来保存工厂方法。到了 Java 17,推荐使用Path接口的方法,而Paths类可能会在未来被弃用。

Path 提供了两个 of() 方法用于创建 Path 对象。通常版本使用一个或多个 String 实例,并使用默认的文件系统提供者。URI 版本利用了 NIO.2 的能力,可以插入额外提供定制文件系统的提供者。这是一个高级用法,有兴趣的开发人员应该查阅主要文档。让我们看一些如何使用 Path 的简单示例:

var p = Path.of("/Users/ben/cluster.txt");
var p2 = Path.of(new URI("file:///Users/ben/cluster.txt"));
System.out.println(p2.equals(p));

File f = p.toFile();
System.out.println(f.isDirectory());

Path p3 = f.toPath();
System.out.println(p3.equals(p));

该示例还展示了 PathFile 对象之间的简单互操作性。Path 增加了 toFile() 方法,而 File 增加了 toPath() 方法,允许开发人员在两个 API 之间轻松切换,并允许通过简单的方法重构基于 File 的代码内部,改为使用 Path

我们还可以使用 Files 类提供的一些有用的“桥接”方法。例如,通过提供方便的方法来打开 Writer 对象到指定的 Path 位置:

var logFile = Path.of("/tmp/app.log");
try (var writer =
       Files.newBufferedWriter(logFile, StandardCharsets.UTF_8,
                               StandardOpenOption.WRITE,
                               StandardOpenOption.CREATE)) {
  writer.write("Hello World!");
  // ...
} catch (IOException e) {
  // ...
}

我们使用了 StandardOpenOption 枚举,它提供了类似于复制选项的能力,但用于打开新文件的情况。我们同时提供了 WRITECREATE,所以如果文件不存在,它将被创建;否则,我们只是打开它以进行额外的写入操作。

在这个示例用例中,我们已经使用了 Path API 来:

  • 创建对应于新文件的 Path

  • 使用 Files 类来创建这个新文件

  • 打开一个 Writer 到该文件

  • 向该文件写入

  • 在完成时自动关闭它

在我们的下一个示例中,我们将在此基础上操作 JAR 文件,将其作为一个独立的 FileSystem 来修改,直接向 JAR 中添加文件。请记住,JAR 文件实际上只是 ZIP 文件,因此这种技术也适用于 .zip 归档文件:

var tempJar = Path.of("sample.jar");
try (var workingFS =
      FileSystems.newFileSystem(tempJar)) {

  Path pathForFile = workingFS.getPath("/hello.txt");
  Files.write(pathForFile,
              List.of("Hello World!"),
              Charset.defaultCharset(),
              StandardOpenOption.WRITE, StandardOpenOption.CREATE);
}

这显示了我们如何创建一个 FileSystem 对象,以便创建引用 jar 内文件的 Path 对象,通过 getPath() 方法。这使得开发人员基本上可以将 FileSystem 对象视为黑匣子:它们通过服务提供者接口(SPI)机制自动创建。

要查看您的计算机上可用的文件系统,您可以运行类似于以下的代码:

for (FileSystemProvider f : FileSystemProvider.installedProviders()) {
    System.out.println(f.toString());
}

Files 类还提供了处理临时文件和目录的方法,这是一个令人惊讶地常见的用例(也可能是安全漏洞的源头)。例如,让我们看看如何从类路径中加载资源文件,将其复制到新创建的临时目录,然后安全地清理临时文件(使用在线书籍资源中提供的 Reaper 类):

Path tmpdir = Files.createTempDirectory(Path.of("/tmp"), "tmp-test");
try (InputStream in =
      FilesExample.class.getResourceAsStream("/res.txt")) {
    Path copied = tmpdir.resolve("copied-resource.txt");
    Files.copy(in, copied, StandardCopyOption.REPLACE_EXISTING);
    // ... work with the copy
}
// Clean up when done...
Files.walkFileTree(tmpdir, new Reaper());

Java 原始 I/O API 的一个批评是缺乏对本地和高性能 I/O 的支持。在 Java 1.4 中首次添加了解决方案,即 Java 新 I/O (NIO) API,并在后续 Java 版本中进行了改进。

NIO 通道和缓冲区

NIO 缓冲区是高性能 I/O 的低级抽象。它们提供了一个特定原始类型的线性序列元素的容器。我们将在示例中使用ByteBuffer(最常见的情况)。

ByteBuffer

这是一系列字节,可以概念上看作是与byte[]工作的性能关键替代品。为了获得最佳性能,ByteBuffer提供了支持直接处理 JVM 运行平台的本地能力。

这种方法称为直接缓冲区情况,尽可能绕过 Java 堆。直接缓冲区在本机内存中分配,而不是在标准的 Java 堆上,并且不会像常规的在堆 Java 对象那样受垃圾收集的影响。

要获取直接的ByteBuffer,调用allocateDirect()工厂方法。也提供了一个在堆上的版本allocate(),但在实践中这不常用。

获取字节缓冲区的第三种方法是使用wrap()一个现有的byte[] —— 这将提供一个在堆上的缓冲区,用于提供对底层字节的更面向对象的视图:

var b = ByteBuffer.allocateDirect(65536);
var b2 = ByteBuffer.allocate(4096);

byte[] data = {1, 2, 3};
ByteBuffer b3 = ByteBuffer.wrap(data);

字节缓冲区都是关于对字节的低级访问。这意味着开发人员必须手动处理细节 —— 包括处理字节的字节顺序和 Java 整数原始类型的有符号性:

b.order(ByteOrder.BIG_ENDIAN);

int capacity = b.capacity();
int position = b.position();
int limit = b.limit();
int remaining = b.remaining();
boolean more = b.hasRemaining();

要将数据输入或输出到缓冲区,我们有两种操作类型 —— 单值类型,读取或写入单个值,以及批量操作,接受一个byte[]ByteBuffer并作为单个操作操作(可能大量的)值。我们期望从批量操作中获得性能提升:

b.put((byte)42);
b.putChar('x');
b.putInt(0xc001c0de);

b.put(data);
b.put(b2);

double d = b.getDouble();
b.get(data, 0, data.length);

单值形式也支持用于在缓冲区内进行绝对定位的形式:

b.put(0, (byte)9);

缓冲区是内存中的抽象。要影响外部世界(例如文件或网络),我们需要使用java.nio.channels包中的Channel。通道代表可以支持读取或写入操作的实体连接。文件和套接字是通道的常见示例,但我们可以考虑用于低延迟数据处理的自定义实现。

通道在创建时处于打开状态,可以随后关闭。一旦关闭,就不能重新打开。通常通道要么可读要么可写,但不能同时。理解通道的关键在于:

  • 从通道读取将字节放入缓冲区

  • 向通道写入从缓冲区中获取的字节

例如,假设我们有一个大文件,想要以 16M 块进行校验和计算:

FileInputStream fis = getSomeStream();
boolean fileOK = true;

try (FileChannel fchan = fis.getChannel()) {
  var buffy = ByteBuffer.allocateDirect(16 * 1024 * 1024);
  while(fchan.read(buffy) != -1 || buffy.position() > 0 || fileOK) {
    fileOK = computeChecksum(buffy);
    buffy.compact();
  }
} catch (IOException e) {
  System.out.println("Exception in I/O");
}

这将尽可能使用本机 I/O,并避免在 Java 堆上复制大量字节。如果computeChecksum()方法已经实现良好,那么这可能是一个非常高效的实现。

映射的字节缓冲区

这些是一种直接字节缓冲区,包含一个内存映射文件(或其一部分)。它们是从 FileChannel 对象创建的,但请注意,与 MappedByteBuffer 对应的 File 对象在内存映射操作后不能再使用,否则会抛出异常。为了减少这种情况,我们再次使用 try-with-resources,以紧密地限定对象的作用域:

try (var raf =
  new RandomAccessFile(new File("input.txt"), "rw");
     FileChannel fc = raf.getChannel();) {

  MappedByteBuffer mbf =
    fc.map(FileChannel.MapMode.READ_WRITE, 0, fc.size());
  var b = new byte[(int)fc.size()];
  mbf.get(b, 0, b.length);
  for (int i = 0; i < fc.size(); i = i + 1) {
    b[i] = 0; // Won't be written back to the file, we're a copy
  }
  mbf.position(0);
  mbf.put(b); // Zeros the file
}

即使使用缓冲区,Java 对于大型 I/O 操作(例如在文件系统之间传输 10G)也存在一些限制,这些操作在单个线程上同步执行。在 Java 7 之前,这类操作通常通过编写自定义的多线程代码来完成,并管理单独的线程执行后台复制。让我们继续看看 JDK 7 中新增的异步 I/O 特性。

异步 I/O

异步功能的关键在于 Channel 的新子类,它们可以处理需要交给后台线程的 I/O 操作。相同的功能可以应用于大型、长时间运行的操作以及其他几种用例。

在本节中,我们将专注于文件 I/O 的 AsynchronousFileChannel,但还有几个其他异步通道需要了解。我们将在本章末尾查看异步套接字。我们将看到:

  • 用于文件 I/O 的 AsynchronousFileChannel

  • 用于客户端套接字 I/O 的 AsynchronousSocketChannel

  • 用于异步套接字接受传入连接的 AsynchronousServerSocketChannel

与异步通道交互的有两种不同的方式 — Future 风格和回调风格。

基于 Future 的风格

Future 接口的全面讨论将使我们深入了解 Java 并发的细节。然而,对于本章的目的,它可以被视为一个可能已经完成或尚未完成的任务。它有两个关键方法:

isDone()

返回一个布尔值,指示任务是否已完成。

get()

返回结果。如果完成,则立即返回。如果未完成,则阻塞直到完成。

让我们看一个示例程序,异步读取一个大文件(可能达到 100 Mb):

try (var channel =
         AsynchronousFileChannel.open(Path.of("input.txt"))) {
  var buffer = ByteBuffer.allocateDirect(1024 * 1024 * 100);
  Future<Integer> result = channel.read(buffer, 0);

  while(!result.isDone()) {
    // Do some other useful work....
  }

  System.out.println("Bytes read: " + result.get());
}

基于回调的风格

异步 I/O 的回调风格基于 CompletionHandler,它定义了两个方法 completed()failed(),在操作成功或失败时将回调调用。

如果您希望在异步 I/O 中立即收到事件通知,则此风格非常有用 — 例如,如果有大量的 I/O 操作正在进行中,但任何单个操作的失败并不一定是致命的:

byte[] data = {2, 3, 5, 7, 11, 13, 17, 19, 23};
ByteBuffer buffy = ByteBuffer.wrap(data);

CompletionHandler<Integer,Object> h =
  new CompletionHandler<>() {
    public void completed(Integer written, Object o) {
      System.out.println("Bytes written: " + written);
    }

    public void failed(Throwable x, Object o) {
      System.out.println("Asynch write failed: "+ x.getMessage());
    }
  };

try (var channel =
       AsynchronousFileChannel.open(Path.of("primes.txt"),
          StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {

  channel.write(buffy, 0, null, h);

  // Give the CompletionHandler time to run before foreground exit
  Thread.sleep(1000);
}

AsynchronousFileChannel 对象与后台线程池相关联,因此 I/O 操作可以继续进行,而原始线程可以继续进行其他任务。

注意

CompletionHandler 接口有两个抽象方法,而不是一个,所以它不可以是 lambda 表达式的目标类型,遗憾地。

默认情况下,这使用由运行时提供的托管线程池。如果需要,可以创建一个由应用程序管理的线程池(通过AsynchronousFileChannel.open()的重载形式),但这很少是必要的。

最后,为了完整起见,让我们简要介绍一下 NIO 对多路复用 I/O 的支持。这使得单个线程能够管理多个通道,并检查这些通道以查看哪些已准备好进行读取或写入。支持这些功能的类位于java.nio.channels包中,包括SelectableChannelSelector

当编写需要高可伸缩性的高级应用程序时,这些非阻塞的多路复用技术可以非常有用,但是在本书的讨论范围之外。通常情况下,非阻塞 API 应仅用于高级用例,当高性能或其他非功能性要求要求时。

监控服务和目录搜索

我们将考虑的最后一类异步服务是监视目录或访问目录(或树)。观察服务通过观察目录中发生的一切来操作,例如文件的创建或修改:

try {
  var watcher = FileSystems.getDefault().newWatchService();

  var dir = FileSystems.getDefault().getPath("/home/ben");
  dir.register(watcher,
                StandardWatchEventKinds.ENTRY_CREATE,
                StandardWatchEventKinds.ENTRY_MODIFY,
                StandardWatchEventKinds.ENTRY_DELETE);

  while(!shutdown) {
    WatchKey key = watcher.take();
    for (WatchEvent<?> event: key.pollEvents()) {
      Object o = event.context();
      if (o instanceof Path) {
        System.out.println("Path altered: "+ o);
      }
    }
    key.reset();
  }
}

相比之下,目录流提供了对单个目录中当前所有文件的视图。例如,要列出所有 Java 源文件及其大小(以字节为单位),我们可以使用如下代码:

try(DirectoryStream<Path> stream =
    Files.newDirectoryStream(Path.of("/opt/projects"), "*.java")) {
  for (Path p : stream) {
    System.out.println(p +": "+ Files.size(p));
  }
}

此 API 的一个缺点是,它仅根据通配符语法返回匹配的元素,有时不够灵活。我们可以通过使用Files.find()Files.walk()方法进一步处理通过目录的递归遍历获取的每个元素:

var homeDir = Path.of("/Users/ben/projects/");
Files.find(homeDir, 255,
  (p, attrs) -> p.toString().endsWith(".java"))
     .forEach(q -> {System.out.println(q.normalize());});

还可以进一步构建基于java.nio.file中的FileVisitor接口的高级解决方案,但这要求开发人员实现接口的所有四个方法,而不仅仅是像这里所做的单个 lambda 表达式。

在本章的最后一部分,我们将讨论 Java 的网络支持和使其成为可能的核心 JDK 类。

网络

Java 平台提供了对大量标准网络协议的访问,这使得编写简单的网络应用程序非常容易。Java 网络支持的核心位于java.net包中,通过javax.net(特别是javax.net.ssl)提供了额外的可扩展性,所有这些都在模块java.base中。

构建应用程序最简单的协议之一是超文本传输协议(HTTP),这是 Web 的基本通信协议。

HTTP

HTTP 是 Java 支持的最常见和流行的高级网络协议。它是一个非常简单的协议,在标准 TCP/IP 协议栈的基础上实现。它可以运行在任何网络端口上,但通常在加密的 TLS(称为 HTTPS)上使用端口 443 或者在未加密时使用端口 80。如今,应尽可能在所有地方默认使用 HTTPS。

Java 有两个单独的处理 HTTP 的 API —— 其中一个可以追溯到平台早期的日子,另一个是全面支持的 Java 11 新 API。

为了完整起见,让我们快速看一下旧 API。在这个 API 中,URL 是关键类 —— 它支持 http://ftp://file://https:// 形式的 URL。它非常易于使用,Java HTTP 支持的最简单示例是下载特定 URL:

var url = new URL("http://www.google.com/");
try (InputStream in = url.openStream()) {
  Files.copy(in, Path.of("output.txt"));
} catch(IOException ex) {
  ex.printStackTrace();
}

对于更低级别的控制,包括请求和响应的元数据,我们可以使用 URLConnection 来实现类似以下的操作:

try {
  URLConnection conn = url.openConnection();

  String type = conn.getContentType();
  String encoding = conn.getContentEncoding();
  Date lastModified = new Date(conn.getLastModified());
  int len = conn.getContentLength();
  InputStream in = conn.getInputStream();
} catch (IOException e) {
  // Handle exception
}

HTTP 定义了“请求方法”,即客户端可以在远程资源上执行的操作。这些方法包括 GET、POST、HEAD、PUT、DELETE、OPTIONS 和 TRACE。

每个方法有稍微不同的用法,例如:

  • GET 应仅用于检索文档,并且永远不应执行任何副作用。

  • HEAD 与 GET 类似,但不返回主体 —— 如果程序想通过标头快速检查 URL 是否已更改,则此方法很有用。

  • 当我们需要将数据发送到服务器进行处理时使用 POST。

默认情况下,Java 使用 GET 方法,但它提供了一种使用其他方法构建更复杂应用程序的方式;然而,这样做比较复杂。在下一个示例中,我们使用 Postman 提供的 echo 函数来返回我们发布的数据的视图:

var url = new URL("https://postman-echo.com/post");
var encodedData = URLEncoder.encode("q=java", "ASCII");
var contentType = "application/x-www-form-urlencoded";

var conn = (HttpURLConnection) url.openConnection();
conn.setInstanceFollowRedirects(false);
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", contentType );
conn.setRequestProperty("Content-Length",
  String.valueOf(encodedData.length()));

conn.setDoOutput(true);
OutputStream os = conn.getOutputStream();
os.write( encodedData.getBytes() );

int response = conn.getResponseCode();
if (response == HttpURLConnection.HTTP_MOVED_PERM
    || response == HttpURLConnection.HTTP_MOVED_TEMP) {
  System.out.println("Moved to: "+ conn.getHeaderField("Location"));
} else {
  try (InputStream in = conn.getInputStream()) {
    Files.copy(in, Path.of("postman.txt"),
                StandardCopyOption.REPLACE_EXISTING);
  }
}

注意,我们需要将查询参数发送到请求体中并在发送前进行编码。我们还需要禁用 HTTP 重定向的跟随,并手动处理来自服务器的任何重定向。这是由于 HttpURLConnection 类在处理 POST 请求的重定向时存在限制。

旧 API 明显显露其年龄,事实上仅实现了 HTTP 标准的 1.0 版本,非常低效且被认为是过时的。作为替代,现代 Java 程序可以使用新 API,这是为了支持新的 HTTP/2 协议而添加的结果。自 Java 11 开始,它已经作为 java.net.http 模块的完全支持部分提供。让我们看一个使用新 API 的简单示例:

        var client = HttpClient.newBuilder().build();
        var uri = new URI("https://www.oreilly.com");
        var request = HttpRequest.newBuilder(uri).build();

        var response = client.send(request,
                ofString(Charset.defaultCharset()));
        var body = response.body();
        System.out.println(body);

请注意,该 API 设计为可扩展,例如通过 HttpResponse.BodySubscriber 接口来实现自定义处理。该接口还可以无缝隐藏 HTTP/2 和旧版 HTTP/1.1 协议之间的差异,这意味着 Java 应用程序能够在 Web 服务器采用新版本时进行优雅的迁移。

让我们继续看网络堆栈中更底层的下一层,传输控制协议(TCP)。

TCP

TCP 是互联网上可靠网络传输的基础。它确保网页和其他互联网流量以完整和可理解的状态传输。从网络理论的角度来看,使 TCP 能够作为互联网流量的“可靠性层”的协议特性包括:

基于连接

数据属于单个逻辑流(即连接)。

保证有序交付

直到数据包到达为止,数据包将被重新发送。

经过错误检查

网络传输引起的损坏将被自动检测和修复。

TCP 是双向(或双向)通信通道,并使用特殊的编号方案(TCP 序列号)来确保通信流的两侧保持同步。为了支持同一网络主机上的多个不同服务,TCP 使用端口号来标识服务,并确保发送到一个端口的流量不会进入另一个端口。

在 Java 中,TCP 由SocketServerSocket类表示。它们用于提供能力,分别作为连接的客户端和服务器端—这意味着 Java 既可用于连接到网络服务,也可作为实现新服务的语言。

注意

Java 的原始套接字支持在 Java 13 中重新实现,没有 API 更改。经典套接字 API 现在与更现代的 NIO 基础结构共享代码,并且将继续有效地运行到未来。

举例来说,让我们考虑重新实现 HTTP 1.1。这是一个相对简单的基于文本的协议。我们需要实现连接的双方,所以让我们从基于 TCP 套接字的 HTTP 客户端开始。为了完成这个任务,我们实际上需要实现 HTTP 协议的细节,但我们有一个优势,那就是我们可以完全控制 TCP 套接字。

我们将需要从客户端套接字读取和写入,并且我们将按照 HTTP 标准(称为 RFC 2616,并使用显式的行结束语法)构造实际的请求行。生成的客户端代码将类似于以下内容:

var hostname = "www.example.com";
int port = 80;
var filename = "/index.xhtml";

try (var sock = new Socket(hostname, port);
  var from = new BufferedReader(
      new InputStreamReader(sock.getInputStream()));
  var to = new PrintWriter(
      new OutputStreamWriter(sock.getOutputStream())); ) {

  // The HTTP protocol
  to.print("GET " + filename +
    " HTTP/1.1\r\nHost: "+ hostname +"\r\n\r\n");
  to.flush();

  for (String l = null; (l = from.readLine()) != null; )
    System.out.println(l);
}

在服务器端,我们需要接收可能的多个传入连接。为了处理这一点,我们将启动一个主服务器循环,然后使用accept()从操作系统获取新连接。然后快速将新连接传递给一个单独的处理程序类,以便主服务器循环可以继续监听新连接。这段代码比客户端情况复杂一些:

// Handler class
private static class HttpHandler implements Runnable {
  private final Socket sock;
  HttpHandler(Socket client) { this.sock = client; }

  public void run() {
    try (var in =
           new BufferedReader(
             new InputStreamReader(sock.getInputStream()));
         var out =
           new PrintWriter(
             new OutputStreamWriter(sock.getOutputStream())); ) {
      out.print("HTTP/1.0 200\r\nContent-Type: text/plain\r\n\r\n");
      String line;
      while((line = in.readLine()) != null) {
        if (line.length() == 0) break;
        out.println(line);
      }
    } catch(Exception e) {
      // Handle exception
    }
  }
}

// Main server loop
public static void main(String[] args) {
  try {
    var port = Integer.parseInt(args[0]);

    ServerSocket ss = new ServerSocket(port);
    while (true) {
      Socket client = ss.accept();
      var handler = new HTTPHandler(client);
      new Thread(handler).start();
    }
  } catch (Exception e) {
    // Handle exception
  }
}

当设计一个应用程序通过 TCP 进行通信的协议时,有一个简单而深刻的网络架构原则,即著名的波斯特尔定律(以互联网之父之一乔恩·波斯特尔命名),你应该始终牢记。它有时被表述为:“在发送时要严格,接收时要宽松。”这个简单的原则意味着即使在网络系统中存在相当不完美的实现,通信仍然可以广泛可能。

波斯特尔定律与协议应尽可能简单的一般原则结合起来(有时被称为 KISS 原则),将使得开发者实现基于 TCP 的通信任务比起其他方式要容易得多。

TCP 下面是互联网的通用运输协议——互联网协议(IP)本身。

IP

IP,作为“最低公共分母”传输,为实际从点 A 到点 B 传输字节的物理网络技术提供了一个有用的抽象。

不同于 TCP,IP 数据包的传递并不保证,可能会被沿途任何过载系统丢弃。IP 数据包确实有一个目的地,但通常没有路由数据——这是沿途(可能是多个不同的)物理传输的责任来实际交付数据。

在 Java 中可以创建基于单个 IP 数据包(或带有 UDP 头部而不是 TCP 的数据包)的“数据报”服务,但这通常只有在需要极低延迟的应用程序时才需要。Java 使用类DatagramSocket来实现此功能,尽管少数开发人员可能永远不需要深入到这个网络栈的层次。

最后,值得注意的是,当前在互联网上使用的寻址方案正在飞行中发生一些变化。目前主导的 IP 版本是 IPv4,其拥有 32 位的可能网络地址空间。现在这个空间已经非常紧张,已经部署了各种缓解技术来处理这种枯竭。

下一个 IP 版本(IPv6)正在逐步推出,但尚未完全被接受,也尚未取代 IPv4,尽管向它成为标准的稳步进展正在持续。截至目前,IPv6 流量约占互联网流量的 35%,并且稳步增长。在未来的 10 年内,IPv6 有可能在流量量上超过 IPv4,并且低级网络将需要适应这个根本性的新版本。

然而,对于 Java 程序员来说,好消息是该语言和平台多年来一直在为 IPv6 和其引入的变化提供良好支持。与其他许多语言相比,IPv4 到 IPv6 的过渡可能对 Java 应用程序来说会更加平稳和少问题。

摘要

在这一章中,我们介绍了 Java SDK 提供的文件处理、I/O 和网络功能。然而,并不是所有这些功能都被同等频繁地使用。核心的文件处理类(特别是Path和 NIO.2 的其他部分)被 Java 开发者经常使用,而更高级的功能则较少遇到。

网络库的情况有所不同。了解这些功能很好,但它们相当基础。在实践中,通常会使用第三方提供的更高级别的库(例如 Netty)。唯一的例外是,Java 开发者可以相对经常遇到的低级别 JDK 网络库是java.net.http中的新 HTTP 库。

现在让我们来了解一些 Java 的关键动态特性——类加载和反射——这些强大的技术允许代码在运行时以编译时未知的方式被发现、加载和执行。

第十一章:类加载、反射和方法句柄

在第三章中,我们遇到了 Java 的Class对象,一种表示运行中 Java 进程中的活动类型的方式。在本章中,我们将在此基础上讨论 Java 环境如何加载和使新类型可用。在本章的下半部分,我们将介绍 Java 的内省能力——原始的反射 API 和较新的方法句柄功能。

类文件、类对象和元数据

类文件,正如我们在第一章中所看到的,是将 Java 源文件(或者,可能是其他语言)编译成 JVM 使用的中间形式的结果。这些是不打算供人类阅读的二进制文件。

这些类文件的运行时表示是包含元数据的类对象,该元数据表示创建该类文件的 Java 类型。

类对象示例

在 Java 中,您可以通过几种方式获取类对象。最简单的方法是:

Class<?> myClass = getClass();

这会返回调用它的实例的类对象。然而,正如我们从调查Object的公共方法所知道的那样,Object上的getClass()方法是公共的,所以我们也可以获取任意对象o的类:

Class<?> c = o.getClass();

已知类型的类对象也可以写为“类文字”:

// Express a class literal as a type name followed by ".class"
c = String.class; // Same as "a string".getClass()
c = byte[].class; // Type of byte arrays

对于原始类型和void,我们还有表示为字面量的类对象:

// Obtain a Class object for primitive types with various
// predefined constants
c = Void.TYPE; // The special "no-return-value" type
c = Byte.TYPE; // Class object that represents a byte
c = Integer.TYPE; // Class object that represents an int
c = Double.TYPE; // etc.; see also Short, Character, Long, Float

还有可能直接在原始类型上使用.class语法,像这样:

c = int.class; // Same as Integer.TYPE

.class.TYPE之间的关系可以通过一些简单的测试看出:

// outputs true
System.out.printf("%b%n", Integer.TYPE == int.class);

// outputs false
System.out.printf("%b%n", Integer.class == int.class);

// outputs false
System.out.printf("%b%n", Integer.class == Integer.TYPE);

请注意,包装器类型(Integer等)具有.TYPE属性,但一般类不具备。此外,所有这些仅适用于在编译时已知的类型;对于未知类型,我们将不得不使用更复杂的方法。

类对象和元数据

类对象包含有关给定类型的元数据。这包括在所讨论的类上定义的方法、字段、构造函数等。程序员可以访问此元数据来调查类,即使在加载类时对该类一无所知也可以。

例如,我们可以在类文件中找到所有已弃用的方法(它们将用@Deprecated注解标记):

Class<?> clz =  ... // Get class from somewhere, e.g. loaded from disk
for (Method m : clz.getMethods()) {
  for (Annotation a : m.getAnnotations()) {
    if (a.annotationType() == Deprecated.class) {
      System.out.println(m.getName());
    }
  }
}

我们还可以找到一对类文件的公共祖先类。当这两个类都由同一个类加载器加载时,这种简单的形式将起作用:

public static Class<?> commonAncestor(Class<?> cl1, Class<?> cl2) {
  if (cl1 == null || cl2 == null) return null;
  if (cl1.equals(cl2)) return cl1;
  if (cl1.isPrimitive() || cl2.isPrimitive()) return null;

  List<Class<?>> ancestors = new ArrayList<>();
  Class<?> c = cl1;
  while (!c.equals(Object.class)) {
    if (c.equals(cl2)) return c;
    ancestors.add(c);
    c = c.getSuperclass();
  }
  c = cl2;
  while (!c.equals(Object.class)) {
    for (Class<?> k : ancestors) {
      if (c.equals(k)) return c;
    }
    c = c.getSuperclass();
  }

  return Object.class;
}

如果类文件要合法并且能够被 JVM 加载,它们必须符合非常具体的布局。类文件的各个部分依次是:

  • 魔数(所有以十六进制的四个字节CA FE BA BE开头的类文件)

  • 使用的类文件标准的版本

  • 此类的常量池

  • 访问标志(abstractpublic等)

  • 此类的名称

  • 继承信息(例如,超类的名称)

  • 实现的接口

  • 字段

  • 方法

  • 属性

类文件是一种简单的二进制格式,但它并不是人类可读的。相反,应该使用类似javap(参见第十三章)的工具来理解其内容。

类文件中最常用的部分之一是常量池,其中包含类需要引用的所有方法、类、字段和常量的表示(无论它们是在本类中还是在其他类中)。它的设计使得字节码可以简单地通过索引号引用常量池条目,从而节省字节码表示中的空间。

多个不同的 Java 版本创建了不同的类文件版本。然而,Java 的向后兼容规则之一是,更新版本的 JVM(和工具)始终可以使用旧的类文件。

让我们看看类加载过程如何将磁盘上的一组字节转换为一个新的类对象。

类加载的阶段

类加载是将新类型添加到正在运行的 JVM 进程中的过程。这是新代码可以进入系统的唯一方式,也是将数据转换为代码的 Java 平台的唯一方式。类加载过程包括多个阶段,让我们逐个审视它们。

加载

类加载过程从加载字节数组开始。通常情况下,这些字节数组是从文件系统读取的,但也可以从 URL 或其他位置读取(通常表示为 Path 对象)。

ClassLoader::defineClass() 方法负责将类文件(表示为字节数组)转换为类对象。这是一个受保护的方法,因此在没有子类化的情况下是不可访问的。

defineClass() 的第一个任务是加载。这会生成一个类对象的骨架,对应于您尝试加载的类。在这个阶段,对类执行了一些基本检查(例如,检查常量池中的常量以确保它们是自洽的)。

然而,加载本身并不会产生完整的类对象,该类也还不能被使用。相反,在加载后,必须链接该类。这一步骤分解为多个子阶段:

  • 验证

  • 准备和解析

  • 初始化

验证

验证确认类文件符合预期,并且不会尝试违反 JVM 的安全模型(详情见“安全编程与类加载”)。

JVM 字节码设计使得它可以(大部分)静态检查。这样做的效果是减慢类加载过程但加快运行时(因为可以省略一些检查)。

验证步骤旨在防止 JVM 执行可能导致崩溃或将其置于未定义和未经测试状态的字节码,从而可能使其易受恶意代码攻击的字节码。字节码验证是防范恶意手工制作的 Java 字节码和不受信任的 Java 编译器可能输出无效字节码的防御措施。

注意

默认方法机制通过类加载工作。在加载接口的实现时,会检查类文件是否包含默认方法的实现。如果存在这些方法,类加载将正常继续。如果某些方法缺失,则会补丁化以添加缺失方法的默认实现。

准备和解析

验证成功后,类准备好供使用。会为类分配内存,并为类中的静态变量做好初始化准备。

在这个阶段,变量尚未初始化,并且新类中没有执行任何字节码。在运行任何代码之前,JVM 检查新类文件引用的每种类型是否已知于运行时。如果类型不被认识,则可能需要加载它们——这可能会重新启动类加载过程,因为 JVM 加载新类型。

这种加载和发现的过程可以迭代执行,直到达到一组稳定的类型。这称为最初加载的类型的“传递闭包”¹。

让我们通过检查 java.lang.Object 的依赖关系的示例来快速看一下。 图 11-1 展示了 Object 的简化依赖图。它仅显示了 Object 在其公共 API 中可见的直接依赖项,以及这些依赖项的直接 API 可见依赖项的依赖项。此外,还以非常简化的形式显示了 Class 对反射子系统的依赖,以及 PrintStreamPrintWriter 对 I/O 子系统的依赖。

在 图 11-1 中,我们可以看到 Object 的传递闭包的一部分。

JN7 1101

图 11-1. 类型的传递闭包

初始化

解析完成后,JVM 最终可以初始化类。静态变量可以初始化,并且会运行静态初始化块。

这是 JVM 首次执行新加载类的字节码。静态块完成后,类就完全加载并且准备就绪。

安全编程和类加载

Java 程序可以从各种来源动态加载 Java 类,包括来自不受信任的来源,例如通过不安全网络到达的网站。能够创建和使用这些动态代码源是 Java 的一个伟大优势和特性之一。为了成功地使其工作,Java 非常重视一种安全架构,允许不受信任的代码安全地运行,而无需担心对主机系统的损害。

Java 的类加载子系统是许多安全特性的实现地方。类加载架构安全方面的核心思想是,将新的可执行代码引入进程的唯一方法是通过类。

这提供了一个“关键点” —— 创建新类的唯一方法是使用ClassLoader从字节流加载类。通过集中精力使类加载安全,我们可以限制需要保护的攻击面。

JVM 设计中非常有帮助的一个方面是它是一个堆栈机器,因此所有操作都在堆栈上进行,而不是在寄存器中。在方法的每个点上可以推断堆栈状态,并且可以用于确保字节码不会试图违反安全模型。

JVM 实施的一些安全检查包括:

  • 类的所有字节码都具有有效的参数。

  • 所有方法调用时,参数的数量和静态类型必须正确。

  • 字节码永远不会尝试下溢或溢出 JVM 堆栈。

  • 局部变量在初始化之前不被使用。

  • 变量只能被分配合适类型的值。

  • 必须尊重字段、方法和类访问控制修饰符。

  • 没有不安全的强制转换(例如,尝试将int转换为指针)。

  • 所有的分支指令都指向同一个方法内的合法点。

最重要的是处理内存和指针的方法。在汇编语言和 C/C++中,整数和指针是可互换的,因此整数可以用作内存地址。我们可以这样在汇编中编写它:

mov eax, [STAT] ; Move 4 bytes from addr STAT into eax

Java 安全体系结构的最低层涉及 Java 虚拟机的设计及其执行的字节码。JVM 不允许任何形式的直接访问底层系统的内存地址,这可以防止 Java 代码干扰本机硬件和操作系统。JVM 上的这些故意限制反映在 Java 语言本身上,它不支持指针或指针算术。

语言和 JVM 都不允许将整数强制转换为对象引用或反之,并且绝对没有任何方式可以获取对象在内存中的地址。没有这些功能,恶意代码根本无法立足。

从第二章回忆起,Java 有两种类型的值 —— 原始类型和对象引用。这些是唯一可以放入变量中的东西。注意“对象内容”不能放入变量中。Java 没有 C 中的struct的等价物,而且始终具有按值传递的语义。对于引用类型,传递的是引用的副本 —— 这是一个值。

在 JVM 中,引用被表示为指针,但字节码不直接操作它们。事实上,字节码没有“访问位置 X 的内存”的操作码。

相反,我们只能访问字段和方法;字节码不能调用任意内存位置。这意味着 JVM 始终知道代码和数据的区别。反过来,这可以防止一系列栈溢出和其他攻击。

应用类加载

要应用类加载知识,充分理解java.lang.ClassLoader是很重要的。

这是一个抽象类,是完全功能的,并且没有抽象方法。abstract修饰符只存在以确保用户必须子类化ClassLoader才能使用它。

除了上述的defineClass()方法之外,我们还可以通过一个公共的loadClass()方法加载类。这通常由URLClassLoader子类使用,它可以从 URL 或文件路径加载类。

我们可以使用URLClassLoader从本地磁盘加载类,就像这样:

var current = new File( "." ).getCanonicalPath();
var urls = new URL[] {new URL("file://"+ current + "/")};
try (URLClassLoader loader = new URLClassLoader(urls)) {
  Class<?> clz = loader.loadClass("com.example.DFACaller");
  System.out.println(clz.getName());
}

loadClass()的参数是类文件的二进制名称。注意,为了使URLClassLoader正确找到类,它们需要在文件系统上的预期位置上。在这个例子中,类com.example.DFACaller需要在工作目录相对路径com/example/DFACaller.class中找到。

或者,Class提供了Class.forName(),一个静态方法,可以加载类,这些类存在于类路径上,但尚未被引用。

该方法接受一个完全限定的类名。例如:

Class<?> jdbcClz = Class.forName("oracle.jdbc.driver.OracleDriver");

如果找不到类,则会抛出ClassNotFoundException。正如示例所示,在较早的 Java 数据库连接(JDBC)的版本中通常使用它来确保加载正确的驱动程序,同时避免对驱动程序类的直接import依赖。随着 JDBC 4.0 的到来,这个初始化步骤不再需要了。

Class.forName()有一个替代的、三参数形式,有时与替代的类加载器一起使用:

Class.forName(String name, boolean inited, ClassLoader classloader);

有一系列ClassLoader的子类处理类加载的各种特殊情况——它们适应类加载器的层次结构。

类加载器层次结构

JVM 有一个类加载器的层次结构;系统中的每个类加载器(除了初始的“引导”类加载器)都有一个可以委托的父加载器。

注意

Java 9 中模块的引入影响了类加载的细节操作方式。特别是,加载 JRE 类的类加载器现在是模块化类加载器

惯例是类加载器将请求其父加载器来解析和加载一个类,如果只有父类加载器无法遵守,它将自己执行这个任务。一些常见的类加载器显示在图 11-2 中。

JN7 1102

图 11-2. 类加载器层次结构

引导类加载器

这是任何 JVM 进程中出现的第一个类加载器,仅用于加载核心系统类。在较早的文本中,它有时被称为原始类加载器,但现代用法更倾向于使用引导名称。

出于性能原因,引导类加载器不执行验证,并依赖于引导类路径的安全性。由引导类加载器加载的类型隐含地被授予所有安全权限,因此这组模块尽可能地保持受限。

平台类加载器

这个类加载器层次结构的这个级别最初被用作扩展类加载器,但这种机制现在已被移除。

在其新角色中,这个类加载器(其父类加载器为引导类加载器)现在被称为平台类加载器。它可以通过方法ClassLoader::getPlatformClassLoader获得,并且从 Java 9 版开始出现在(和被)Java 规范中。它从基本系统中加载剩余的模块(相当于旧版 Java 8 及更早版本中使用的rt.jar)。

在新的 Java 模块化实现中,启动 Java 进程需要的代码大大减少;因此,尽可能多的 JDK 代码(现在表示为模块)已从引导加载器的范围移出,并移到了平台加载器中。

应用程序类加载器

历史上,有时被称为系统类加载器,但这是一个不好的名字,因为它并不加载系统(引导加载器和平台类加载器负责)。相反,它是从模块路径或类路径加载应用程序代码的类加载器。它是最常遇到的类加载器,并且其父加载器是平台类加载器。

在执行类加载时,应用程序类加载器首先搜索模块路径上的命名模块(任何三个内置类加载器中已知的模块)。如果请求的类在已知的某个这些类加载器的模块中找到,则该类加载器将加载该类。如果在任何已知的命名模块中找不到该类,则应用程序类加载器委托给其父加载器(平台类加载器)。如果父加载器未能找到该类,则应用程序类加载器搜索类路径。如果在类路径上找到该类,则作为应用程序类加载器的未命名模块的成员加载。

应用程序类加载器被广泛使用,但许多高级 Java 框架需要主类加载器无法提供的功能。相反,需要对标准类加载器进行扩展。这形成了“自定义类加载”的基础,依赖于实现ClassLoader的新子类。

自定义类加载器

在执行类加载时,迟早我们必须将数据转换为代码。如前所述,defineClass()(实际上是一组相关方法)负责将byte[]转换为类对象。

这个方法通常由子类调用,例如,这个简单的自定义类加载器从磁盘上的文件创建一个类对象:

public static class DiskLoader extends ClassLoader {
  public DiskLoader() {
    super(DiskLoader.class.getClassLoader());
  }

  public Class<?> loadFromDisk(String clzPath) throws IOException {
    byte[] b = Files.readAllBytes(Paths.get(clzPath));

    return defineClass(null, b, 0, b.length);
  }
}

注意,在前面的例子中,我们不需要将类文件放在磁盘上的“正确”位置,就像我们在URLClassLoader示例中所做的那样。

我们需要提供一个类加载器来作为任何自定义类加载器的父加载器。在这个例子中,我们提供了加载DiskLoader类的类加载器(通常是应用程序类加载器)。

自定义类加载是 Java EE 和高级 SE 环境中非常常见的技术,它为 Java 平台提供了非常复杂的能力。我们将在本章后面看到自定义类加载的示例。

动态类加载的一个缺点是,当与动态加载的类对象一起工作时,通常对该类的信息很少或没有。为了有效地使用这个类,因此我们将不得不使用一组称为反射的动态编程技术。

反射

反射是在运行时检查、操作和修改对象的能力。这包括修改它们的结构和行为,甚至自我修改。

警告

Java 模块系统对平台上的反射工作引入了重大变化。重要的是,在理解模块如何工作及两种能力如何交互之后,重新阅读本节。有关模块如何限制反射的详细信息,请参见“开放模块”。

反射能够在编译时甚至不知道类型和方法名称的情况下工作。它使用类对象提供的基本元数据,并且可以从类对象中发现方法或字段名称,然后获取表示方法或字段的对象。

实例也可以通过反射方式构造(使用Class::newInstance()或另一个构造函数)。通过一个反射构造的对象和一个Method对象,我们可以在先前未知类型的对象上调用任何方法。

这使得反射成为一种非常强大的技术,因此重要的是要理解何时应该使用它,以及何时它过于复杂。

何时使用反射

许多,如果不是大多数,Java 框架在某种程度上使用反射。编写足够灵活以处理运行时未知代码的架构通常需要反射。例如,插件架构、调试器、代码浏览器和类似读取-评估-打印循环(REPL)的环境通常是在反射之上实现的。

反射在测试中也被广泛使用(例如,通过 JUnit 和 TestNG 库),以及用于模拟对象的创建。如果你使用过任何一种 Java 框架,你几乎肯定在使用反射代码,即使你没有意识到它。

要在自己的代码中开始使用反射 API,最重要的是意识到它是关于访问几乎不知道信息的对象,并且由于这一点,交互可能会很繁琐。

如果对动态加载的类有一些静态信息(例如,加载的所有类都实现了已知接口),这可以极大地简化与类的交互,并减少反射操作的负担。

常见的错误是尝试创建一个反射框架,试图涵盖所有可能的情况,而不是只处理与问题域直接相关的情况。

如何使用反射

任何反射操作的第一步是获取代表要操作的类型的Class对象。从这个对象可以访问并应用于未知类型的实例的其他对象,如字段、方法或构造函数。

如果我们已经有一个未知类型的实例,可以通过Object::getClass()方法检索其类。或者,也可以通过类加载的静态Class.forName()方法在“应用类加载”中进行Class对象的查找:

var clzForInstance = "Hi".getClass();
var clzForName = Class.forName("java.lang.String");

一旦我们有一个Class对象的实例,下一个合理的步骤就是通过反射调用方法。Method对象是 Reflection API 提供的一些最常用的对象之一。我们将详细讨论它们——ConstructorField对象在许多方面都很相似。

方法对象

类对象包含每个类上的Method对象。这些在类加载后延迟创建,因此它们在 IDE 的调试器中并不立即可见。

Class上的方法允许我们检索(并在必要时惰性初始化)这些Method对象:

var clz = Class.forName("java.lang.String");

// Returns list of all publicly visible methods on clz
var publicMethods = clz.getMethods();

// Returns named method from clz, or throws
var toString = clz.getMethod("toString", new Class[] {});

getMethod()的第二个参数接受一个Class对象数组,表示方法的参数,以区分方法重写。

此处演示的代码将仅列出并查找Class对象上的公共方法。有一些类似getDeclaredMethod形式的替代方法,可以访问受保护和私有方法。我们很快将有更多关于使用这些机制来绕过 Java 访问模型的内容。

像任何良好的 Java 对象一样,Method提供了所有关于方法的相关信息的访问器。让我们看看我们可以检索到的关于方法的最关键的元数据:

var clz = Class.forName("java.lang.String");
var toString = clz.getMethod("toString", new Class[] {});

// The method's name
String name = toString.getName();

// Generic type information for the method
TypeVariable[] typeParams = toString.getTypeParameters();

// List of method annotations with RUNTIME retention
Annotation[] ann = toString.getAnnotations();

// List of checked exception types declared by method
Class[] exceptions = toString.getExceptionTypes();

// List of Parameter objects for callling the method
Parameter[] params = toString.getParameters();

// List of just the `Class` for each parameter to the method
Class[] paramTypes = toString.getParameterTypes();

// Class of the method's return type
Class ret = toString.getReturnType();

我们可以通过调用访问器方法来探索Method对象的元数据,但远远最大的用例是反射调用Method

这些对象所代表的方法可以通过反射使用Method上的invoke()方法执行。

String对象上调用hashCode()的示例如下:

Object rcvr = "a";
try {
  Class<?>[] argTypes = new Class[] { };
  Object[] args = null;

  Method meth = rcvr.getClass().getMethod("hashCode", argTypes);
  Object ret = meth.invoke(rcvr, args);
  System.out.println(ret);

} catch (IllegalArgumentException | NoSuchMethodException |
         SecurityException e) {
  e.printStackTrace();
} catch (IllegalAccessException | InvocationTargetException x) {
  x.printStackTrace();
}

请注意,rcvr的静态类型声明为Object。在反射调用期间没有使用静态类型信息。invoke()方法还返回Object,因此hashCode()的实际返回类型已经自动装箱为Integer

这种自动装箱是 Reflection 的一个方面,您可以看到 API 的一些轻微笨拙之处——我们将在即将到来的部分讨论。

使用反射创建实例

如果您想创建Class对象的新实例,您会发现方法查找并不起作用。我们的构造函数没有那些 API 能够找到的名称。

在没有参数的构造函数的最简单情况下,可以通过Class对象获取助手:

Class<?> clz = ... // Get some class object
Object rcvr = clz.getDeclaredConstructor().newInstance();

对于接受参数的构造函数,Class 类有像 getConstructor 这样的方法,允许找到你需要的覆盖方法。虽然它们返回一个单独的 Constructor 类型,但使用它们与我们已经看到的与 Method 对象交互非常相似。

让我们看一个扩展示例,并看看如何将反射与自定义类加载结合起来,以检查磁盘上的类文件是否有任何已过时的方法(这些方法应标记为 @Deprecated):

public class CustomClassloadingExamples {
    public static class DiskLoader extends ClassLoader {

        public DiskLoader() {
            super(DiskLoader.class.getClassLoader());
        }

        public Class<?> loadFromDisk(String clzName)
          throws IOException {
            byte[] b = Files.readAllBytes(Paths.get(clzName));

            return defineClass(null, b, 0, b.length);
        }
    }

    public void findDeprecatedMethods(Class<?> clz) {
        for (Method m : clz.getMethods()) {
            for (Annotation a : m.getAnnotations()) {
                if (a.annotationType() == Deprecated.class) {
                    System.out.println(m.getName());
                }
            }
        }
    }

    public static void main(String[] args)
      throws IOException, ClassNotFoundException {
        var rfx = new CustomClassloadingExamples();

        if (args.length > 0) {
            DiskLoader dlr = new DiskLoader();
            Class<?> clzToTest = dlr.loadFromDisk(args[0]);
            rfx.findDeprecatedMethods(clzToTest);
        }
    }
}

这展示了反射技术的一些强大之处,但是使用 API 也会带来一些问题。

反射的问题

Java 的反射 API 常常是处理动态加载代码的唯一途径,但 API 中的一些恼人之处可能会使其处理起来稍显麻烦:

  • 大量使用 Object[] 来表示调用参数和其他实例。

  • 还有在讨论类型时使用 Class[] 的情况。

  • 方法可以根据名称重载,因此我们需要一个类型数组来区分方法。

  • 表示原始类型可能会有问题——我们必须手动装箱和拆箱。

void 是一个特殊的问题——有一个 void.class,但它的使用不一致。Java 实际上不知道 void 是否是一种类型,反射 API 中的某些方法使用 null 而不是它。

这很麻烦,可能会出错——特别是,Java 数组语法的轻微冗长可能会导致错误。

另一个问题是对非 public 方法的处理。如前所述,我们不能使用 getMethod(),必须使用 getDeclaredMethod() 来获取非 public 方法的引用。此外,要调用非 public 方法,我们必须重写 Java 访问控制子系统,调用 setAccessible() 以允许其执行:

public class MyCache {
  private void flush() {
    // Flush the cache...
  }
}

Class<?> clz = MyCache.class;
try {
  Object rcvr = clz.newInstance();
  Class<?>[] argTypes = new Class[] { };
  Object[] args = null;

  Method meth = clz.getDeclaredMethod("flush", argTypes);
  meth.setAccessible(true);
  meth.invoke(rcvr, args);
} catch (IllegalArgumentException | NoSuchMethodException |
         InstantiationException | SecurityException e) {
  e.printStackTrace();
} catch (IllegalAccessException | InvocationTargetException x) {
  x.printStackTrace();
}

因为反射总是涉及未知信息,我们只能接受一些冗长。这是使用动态运行时反射调用的代价。

动态代理

Java 反射故事的最后一部分是创建动态代理。这些是类(扩展自 java.lang.reflect.Proxy),实现多个接口。实现类在运行时动态构造,并将所有调用转发到一个调用处理器对象:

InvocationHandler handler = (proxy, method, args) -> {
    String name = method.getName();
    System.out.println("Called as: "+ name);
    return switch (name) {
        case "isOpen" -> Boolean.TRUE;
        case "close" -> null;
        default -> null;
    };
};

Channel c = (Channel) Proxy.newProxyInstance(
        Channel.class.getClassLoader(),
        new Class[] { Channel.class },
        handler);
System.out.println("Open? "+ c.isOpen());
c.close();

代理可以用作测试的替代对象(尤其是在测试模拟方法中)。

另一个用例是提供接口的部分实现,或者装饰或以其他方式控制委托的某些方面:

public class RememberingList implements InvocationHandler {
  private final List<String> proxied = new ArrayList<>();

  @Override
  public Object invoke(Object proxy, Method method, Object[] args)
                         throws Throwable {
    String name = method.getName();
    switch (name) {
      case "clear":
        return null;
      case "remove":
      case "removeAll":
        return false;
    }

    return method.invoke(proxied, args);
  }
}

RememberingList hList = new RememberingList();

var l = (List<String>) Proxy.newProxyInstance(
                                List.class.getClassLoader(),
                                new Class[] { List.class },
                                hList);
l.add("cat");
l.add("bunny");
l.clear();
System.out.println(l);

代理是许多 Java 框架中广泛使用的一种强大而灵活的能力。

方法句柄

在 Java 7 中,引入了一种全新的用于内省和方法访问的机制。最初设计用于动态语言,在运行时可能需要参与方法分派决策。为了在 JVM 级别支持这一点,引入了新的invokedynamic字节码。这个字节码在 Java 7 本身中没有使用,但随着 Java 8 的到来,它在 lambda 表达式和 Nashorn JavaScript 实现中得到了广泛应用。

即使没有invokedynamic,新的方法句柄 API 在许多方面与反射 API 相比具有相似的功能强大性,并且即使是独立使用时也可能更加清晰和概念上更简单。可以将其视为以更安全、更现代的方式完成的反射。

MethodType

在反射中,方法签名表示为Class[],这相当繁琐。相比之下,方法句柄依赖于MethodType对象。这是一种类型安全且面向对象的方法,用于表示方法的类型签名。

它们包括返回类型和参数类型,但不包括方法的接收器类型或名称。名称不存在,因为这允许将任何具有正确签名的方法绑定到任何名称(根据 lambda 表达式的函数接口行为)。

方法的类型签名表示为MethodType的不可变实例,可以通过工厂方法MethodType.methodType()获得。methodType()的第零个参数是方法的返回类型,其后是方法参数的类型。

例如:

// Matching method type for toString()
MethodType m2Str = MethodType.methodType(String.class);

// Matching method type for Integer.parseInt()
MethodType mtParseInt =
  MethodType.methodType(Integer.class, String.class);

// Matching method type for defineClass() from ClassLoader
MethodType mtdefClz = MethodType.methodType(Class.class, String.class,
                                            byte[].class, int.class,
                                            int.class);

这个单一的谜题部分提供了比反射更显著的增益,因为它显著地简化了方法签名的表示和讨论。下一步是获得方法的句柄。这是通过查找过程实现的。

方法查找

方法查找查询是在定义方法的类上执行的,并且依赖于执行它们的上下文:

// String.toString only has return type with no parameter
MethodType mtToString = MethodType.methodType(String.class);

try {
  Lookup l = MethodHandles.lookup();
  MethodHandle mh = l.findVirtual(String.class, "toString",
                                  mtToString);
  System.out.println(mh);
} catch (NoSuchMethodException | IllegalAccessException e) {
  e.printStackTrace();
}

我们始终需要调用MethodHandles.lookup()—这会给我们一个基于当前执行方法的查找上下文对象。

查找对象上有几种方法(全部以find开头)声明用于方法解析。这些包括findVirtual()findConstructor()findStatic()

反射和方法句柄 API 之间的一个重大区别是访问控制。一个Lookup对象只会返回在创建查找的上下文中可访问的方法,并且没有办法绕过这一点(没有类似于反射中的setAccessible()的黑客方法)。

例如,我们可以看到,当我们尝试从通用查找上下文查找受保护的ClassLoader::defineClass()方法时,由于受保护的方法不可访问,我们未能解析它,导致IllegalAccessException的抛出:

public static void lookupDefineClass(Lookup l) {
  MethodType mt = MethodType.methodType(Class.class, String.class,
                                        byte[].class, int.class,
                                        int.class);

  try {
    MethodHandle mh =
      l.findVirtual(ClassLoader.class, "defineClass", mt);
    System.out.println(mh);
  } catch (NoSuchMethodException | IllegalAccessException e) {
    e.printStackTrace();
  }
}

Lookup l = MethodHandles.lookup();
lookupDefineClass(l);

因此,方法句柄始终符合安全管理器的要求,即使相应的反射代码没有这样做。它们在构造查找上下文的地方进行访问检查——查找对象不会返回对其没有适当访问权限的方法句柄。

查找对象或从其派生的方法句柄可以返回到其他上下文,包括那些不再可能访问该方法的上下文。在这些情况下,句柄仍然是可执行的——访问控制在查找时检查,正如我们在此示例中所见:

public class SneakyLoader extends ClassLoader {
  public SneakyLoader() {
    super(SneakyLoader.class.getClassLoader());
  }

  public Lookup getLookup() {
    return MethodHandles.lookup();
  }
}

SneakyLoader snLdr = new SneakyLoader();
l = snLdr.getLookup();
lookupDefineClass(l);

使用Lookup对象,我们能够生成对任何我们可以访问的方法的方法句柄。我们还可以生成一种访问可能没有给予访问权限的字段的方式。Lookup上的findGetter()findSetter()方法生成可以根据需要读取或更新字段的方法句柄。

调用方法句柄

方法句柄代表调用方法的能力。它们是强类型的,并尽可能类型安全。所有实例都是java.lang.invoke.MethodHandle的某个子类,这是 JVM 需要特殊处理的类。

调用方法句柄有两种方式——invoke()invokeExact()。这两种方法都将接收器和调用参数作为参数。invokeExact()尝试直接调用方法句柄本身,而invoke()则在需要时调整调用参数。

一般而言,如果必要,invoke()执行asType()转换——这将根据以下规则转换参数:

  • 如果需要,原始参数将被装箱。

  • 如果需要,装箱的原始类型将被取消装箱。

  • 如果需要,原始类型将被扩展。

  • 返回类型为void的情况将会根据预期返回的是基本类型还是引用类型而转换为 0 或null

  • null值将被传递,而不考虑静态类型。

有了这些潜在的转换,调用看起来像这样:

Object rcvr = "a";
try {
  MethodType mt = MethodType.methodType(int.class);
  MethodHandles.Lookup l = MethodHandles.lookup();
  MethodHandle mh = l.findVirtual(rcvr.getClass(), "hashCode", mt);

  int ret;
  try {
    ret = (int)mh.invoke(rcvr);
    System.out.println(ret);
  } catch (Throwable t) {
    t.printStackTrace();
  }
} catch (IllegalArgumentException |
  NoSuchMethodException | SecurityException e) {
  e.printStackTrace();
} catch (IllegalAccessException x) {
  x.printStackTrace();
}

方法句柄提供了一种更清晰和更连贯的方式来访问与反射相同的动态编程能力。此外,它们设计用于与 JVM 的低级执行模型良好配合,因此比反射提供的性能要好得多。

¹ 正如在第六章中,我们从称为图论的数学分支借用了表达式传递闭包

第十二章:Java 平台模块

在本章中,我们将对 Java 平台模块系统(JPMS)进行基本介绍。然而,这是一个大而复杂的主题——有兴趣的读者可能需要更深入的参考资料,如《Java 9 Modularity》(O'Reilly)的 Sander Mak 和 Paul Bakker。

模块,作为一个相对先进的特性,主要用于打包和部署整个应用程序及其依赖项。它们被添加到平台大约在 Java 第一个版本之后的 20 年左右,因此可以看作是与语言语法的其他部分正交的。

Java 强烈推广向后兼容性,在这里也发挥了作用,因为非模块化应用必须继续运行。这导致 Java 平台的架构师和管理者采取了一种务实的观点,认为团队采用模块化的必要性。

没有必要切换到模块。

从未有过必要切换到模块。

Java 9 及以后的版本支持传统的 JAR 文件在传统的类路径上,通过未命名模块的概念,可能会一直持续到宇宙的热死亡。

是否开始使用模块完全取决于您自己。

Mark Reinhold

https://oreil.ly/4RjDH

由于模块的先进性质,本章假定您熟悉现代 Java 构建工具,如 Gradle 或 Maven。

如果您是 Java 的新手,可以安全地忽略对这些工具的引用,只需阅读本章节,以获取对 JPMS 的首次高级概述。新的 Java 程序员在学习如何编写 Java 程序时,不需要完全理解这个主题。

为什么要使用模块?

添加模块到 Java 平台的几个主要动机包括:

  • 强封装

  • 明确定义的接口

  • 明确的依赖关系

这些都是语言(和应用程序设计)级别的,并且它们与新平台级别的能力承诺相结合:

  • 可扩展的开发

  • 改进的性能(特别是启动时间)和减少的占用空间

  • 减少攻击面和提高安全性

  • 可演化的内部

封装点是由于原始语言规范仅支持私有、公共、受保护和包私有的可见性级别。没有办法以更精细的方式控制访问,以表达诸如以下概念:

  • 只有指定的包可作为 API 访问——其他包是内部的,可能无法访问

  • 某些包可以通过此包列表访问,但不包括其他包

  • 定义严格的导出机制

当设计更大型的 Java 系统时,缺乏这些及相关能力一直是一个重大缺点。而且,没有适当的保护机制,JDK 内部的演进将非常困难——因为没有任何东西阻止用户应用程序直接访问实现类。

模块系统试图一次性解决所有这些问题,并提供适用于 JDK 和用户应用程序的解决方案。

将 JDK 模块化

随着模块系统的推出,Java 8 随附的单片式 JDK 成为首个目标,熟悉的 rt.jar 被分解为模块。

Java 8 已经开始模块化工作,通过提供称为紧凑配置的功能,整理代码并使其可以以减少的运行时占用空间进行部署。

java.base 是代表 Java 应用程序启动实际所需的最小模块。它包含核心包,例如:

java.io
java.lang
java.math
java.net
java.nio
java.security
java.text
java.time
java.util
javax.crypto
javax.net
javax.security

以及一些子包和非导出实现包,例如 sun.text.resources。可以通过这个简单的程序看到 Java 8 和模块化 Java 之间编译行为的一些差异,它扩展了包含在 java.base 中的内部公共类:

import java.util.Arrays;
import sun.text.resources.FormatData;

public final class FormatStealer extends FormatData {
    public static void main(String[] args) {
        FormatStealer fs = new FormatStealer();
        fs.run();
    }

    private void run() {
        String[] s = (String[]) handleGetObject("japanese.Eras");
        System.out.println(Arrays.toString(s));

        Object[][] contents = getContents();
        Object[] eraData = contents[14];
        Object[] eras = (Object[])eraData[1];
        System.out.println(Arrays.toString(eras));
    }
}

在 Java 11 上尝试编译代码会产生以下错误消息:

$ javac javanut8/ch12/FormatStealer.java
javanut8/ch12/FormatStealer.java:4:
        error: package sun.text.resources is not visible
import sun.text.resources.FormatData;
               ^
  (package sun.text.resources is declared in module
        java.base, which does not export it to the unnamed module)
javanut8/ch12/FormatStealer.java:14: error: cannot find symbol
        String[] s = (String[]) handleGetObject("japanese.Eras");
                                ^
  symbol:   method handleGetObject(String)
  location: class FormatStealer
javanut8/ch12/FormatStealer.java:17: error: cannot find symbol
        Object[][] contents = getContents();
                              ^
  symbol:   method getContents()
  location: class FormatStealer
3 errors

在模块化的 Java 中,即使是公共的类也不能访问,除非它们被定义的模块明确导出。我们可以使用 --add-exports 开关临时强制编译器使用内部包(基本上是重新确认旧的访问规则),像这样:

$ javac --add-exports java.base/sun.text.resources=ALL-UNNAMED \
        javanut8/ch12/FormatStealer.java
javanut8/ch12/FormatStealer.java:5:
        warning: FormatData is internal proprietary API and may be
        removed in a future release
import sun.text.resources.FormatData;
                         ^
javanut8/ch12/FormatStealer.java:7:
        warning: FormatData is internal proprietary API and may be
        removed in a future release
public final class FormatStealer extends FormatData {
                                         ^
2 warnings

我们需要指定导出被授予给未命名模块,因为我们正在编译我们的类独立地,而不是作为模块的一部分。编译器会警告我们正在使用内部 API,并且这可能会在将来的 Java 版本中中断。在 Java 11 下编译和运行时,这将产生一个日本时代列表,如下所示:

[, Meiji, Taisho, Showa, Heisei, Reiwa]
[, Meiji, Taisho, Showa, Heisei, Reiwa]

然而,如果我们尝试在 Java 17 下运行,那么结果就会有所不同:

$ java javanut8.ch12.FormatStealer

Error: LinkageError occurred while loading main class
        javanut8.ch12.FormatStealer

java.lang.IllegalAccessError: superclass access check failed:

class javanut8.ch12.FormatStealer (in unnamed module @0x647c3190)
        cannot access class sun.text.resources.FormatData (in module
        java.base) because module java.base does not export
        sun.text.resources to unnamed module @0x647c3190

这是因为 Java 17 现在在强化内部封装时执行了额外的检查。为了使程序运行,我们还需要添加 --add-exports 运行时标志:

$ java --add-exports java.base/sun.text.resources=ALL-UNNAMED \
        javanut8.ch12.FormatStealer
[, Meiji, Taisho, Showa, Heisei, Reiwa]
[, Meiji, Taisho, Showa, Heisei, Reiwa]

虽然 java.base 是应用程序启动所需的绝对运行时最小值,但在编译时,我们希望可见平台尽可能接近旧版(Java 8)的体验。

这意味着我们使用了一个更大的模块集,包含在一个总称模块下,即 java.se。该模块有一个依赖关系图,如 图 12-1 所示。

JN7 1201

图 12-1. java.se 的模块依赖关系图

这几乎包含了大多数 Java 开发人员期望可用的所有类和包。

然而,一个重要的例外是,定义 CORBA 和 Java EE API(现在称为 Jakarta EE)的 Java 8 包已被移除,并且不包含在 java.se 中。这意味着任何依赖于这些 API 的项目在 Java 11 及以后版本上默认情况下将无法编译,并且必须使用特殊的构建配置,显式依赖于提供这些 API 的外部库。

由于 JDK 的模块化,编译可见性也发生了变化,模块系统还旨在允许开发人员对其自己的代码进行模块化。

撰写您自己的模块

在本节中,我们将讨论启动编写模块化 Java 应用程序所需的基本概念。

基本模块语法

模块化的关键在于新文件module-info.java,它包含了一个模块的描述。这被称为模块描述符

模块在文件系统上的正确布局方式如下:

  • 在项目的源根目录(src)下面,需要有一个与模块同名的目录(moduledir)。

  • moduledir目录中,module-info.java位于与包开始的相同级别。

模块信息编译为二进制格式,module-info.class,其中包含模块运行时链接和运行应用程序时使用的元数据。让我们看一个module-info.java的简单示例:

module httpchecker {
    requires java.net.http;

    exports httpchecker.main;
}

这引入了一些新语法:moduleexportsrequires,但实际上这些并不是完全的关键字。正如 Java 语言规范 SE 11 中所述:

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

这意味着这些受限关键字只能出现在模块元数据中,并且由javac编译为二进制格式。主要受限关键字的含义是:

module

开始模块的元数据声明

requires

列出此模块依赖的模块

exports

声明导出哪些包作为 API

剩余的(与模块相关的)受限关键字将在本章的其余部分介绍。

注意

在 Java 17 中,受限关键字的概念得到了显著扩展,因此描述更长,更不清晰。在这里,我们使用旧规范,因为它特指模块系统,更适合我们的目的。

在我们的示例中,这意味着我们声明了一个模块httpchecker,它依赖于 Java 11 中标准化的模块java.net.http(以及对java.base的隐式依赖)。该模块导出一个单独的包httpchecker.main,这是此模块中唯一可以在编译时从其他模块访问的包。

构建一个简单的模块化应用程序

例如,让我们构建一个简单的工具,检查网站是否已经使用了 HTTP/2,使用我们在第十章中遇到的 API:

import static java.net.http.HttpResponse.BodyHandlers.ofString;

public final class HTTP2Checker {
    public static void main(String[] args) throws Exception {
        if (args.length == 0) {
            System.err.println("Provide URLS to check");
        }
        for (final var location : args) {
            var client = HttpClient.newBuilder().build();
            var uri = new URI(location);
            var req = HttpRequest.newBuilder(uri).build();

            var response = client.send(req,
                    ofString(Charset.defaultCharset()));
            System.out.println(location +": "+ response.version());
        }
    }
}

这依赖于两个模块——java.net.http和无处不在的java.base。应用程序的模块文件非常简单:

module http2checker {
    requires java.net.http;
    exports httpchecker.main;
}

假设一个简单的标准模块布局,可以像这样进行编译:

$ javac -d out/httpchecker \
        httpchecker/httpchecker/main/HTTP2Checker.java \
        httpchecker/module-info.java

这将在out/目录中创建一个已编译的模块。为了使用,它需要被打包为一个 JAR 文件:

$ jar --create --file httpchecker.jar \
        --main-class httpchecker.main.HTTP2Checker \
        -C out/httpchecker .

--create开关告诉jar创建一个新的 jar,其中将包含目录中包含的类。命令末尾的最后一个.是强制性的,并表示应将所有类文件(相对于使用-C指定的路径)打包到 jar 中。

我们使用--main-class开关来设置模块的入口点—​即,在我们将模块用作应用程序时要执行的类。让我们看看它的工作原理:

$ java -jar httpchecker.jar http://www.google.com
http://www.google.com: HTTP_1_1
$ java -jar httpchecker.jar https://www.google.com
https://www.google.com: HTTP_2

这表明,撰写时,谷歌的网站使用 HTTP/2 通过 HTTPS 提供其主页,但仍然使用 HTTP/1.1 提供遗留的未加密 HTTP 服务。

现在我们已经看到了如何编译和运行一个简单的模块化应用程序,让我们了解一些构建和运行全尺寸应用程序所需的核心模块化功能。

模块路径

许多 Java 开发人员熟悉类路径的概念。在使用模块化 Java 应用程序时,我们需要使用模块路径。这是一种新的模块概念,尽可能替换类路径。

模块携带关于其导出和依赖项的元数据—​它们不仅仅是类型的长列表。这意味着可以轻松构建模块依赖关系图,并且模块解析可以有效进行。

尚未模块化的代码仍然放置在类路径上。此代码加载到未命名模块中,该模块是特殊的,并且可以读取从java.se可达的所有其他模块。当将类放置在类路径上时,将自动使用未命名模块。

这为采用模块化 Java 运行时提供了迁移路径,而无需迁移到完全模块化的应用程序路径。然而,它确实有两个主要缺点:在应用程序完全迁移之前,模块的任何好处都不可用,并且必须手动维护类路径的自一致性,直到模块化完成。

自动模块

模块系统的约束之一是我们不能从命名模块引用类路径上的 JAR。这是一个安全特性—​模块系统的设计者希望模块依赖关系图利用完整的元数据,并且能够依赖于该元数据的完整性。

然而,可能会有时候模块化的代码需要引用尚未模块化的包。解决此问题的方法是将未修改的 JAR 直接放置在模块路径上(并从类路径中删除它)。像这样放置在模块路径上的 JAR 成为自动模块

它具有以下功能:

  • 模块名称源自 JAR 名称(或从MANIFEST.MF中读取)

  • 导出每个包

  • 需要所有其他模块(包括未命名模块)

这是另一个旨在缓解和帮助迁移的功能,但通过使用自动模块仍然会放弃一些安全性。

开放模块

如前所述,仅仅将一个方法标记为public不再保证该元素在任何地方都可访问。相反,现在访问性也取决于定义模块是否导出包含该元素的包。模块设计中的另一个重要问题是使用反射访问类。

反射是一个广泛而通用的机制,初看起来很难看出它如何与 JPMS 的强封装目标相 reconciled。更糟糕的是,Java 生态系统中许多重要的库和框架都依赖于反射(例如单元测试、依赖注入等),如果没有反射的解决方案,模块对于任何真实应用都将不可能采用。

提供的解决方案是双重的。首先,一个模块可以声明自己是一个open模块,像这样:

open module jin8 {
    exports jin8.api;
}

这种声明具有以下效果:

  • 模块中的所有包都可以通过反射访问

  • 编译时访问提供给非导出包。

这意味着配置在编译时的行为就像一个标准模块一样。总体意图是提供与现有代码和框架的简单兼容性,并减少迁移的痛苦。对于开放模块,先前能够通过反射访问代码的期望得到恢复。此外,允许访问private和其他通常不允许访问的方法的setAccessible() hack 也被保留了。

通过opens限制关键字还提供了对反射访问的更细粒度控制。这不会创建一个开放模块,而是通过显式声明某些包可以通过反射访问来选择性地开放特定包:

module ojin8 {
    exports ojin8.api;
    opens ojin8.domain;
}

当你需要为一个模块感知的对象-关系映射(ORM)系统提供完整的反射访问以获取核心域类型时,这种用法可能非常有用。

可以进一步限制对特定客户端包的反射访问,使用to关键字来实现。在可行的情况下,这可以作为一个良好的设计原则,但当然这种技术在像 ORM 这样的通用框架中效果不佳。

注意

类似地,还可以限制包的导出仅针对特定外部包。然而,此功能主要是为了帮助 JDK 本身的模块化而添加的,对用户模块的适用性有限。

不仅如此,还可以同时导出和开放一个包,但这不被推荐——在迁移期间,最好将对包的访问限制在编译时或反射访问中,而不是同时两者。

如果现在一个模块内包含一个需要反射访问的包,则平台提供了一些开关作为过渡期的应急措施。

特别是,java 选项 --add-opens module/package=ALL-UNNAMED 可以用来打开模块的特定包,以便对类路径中所有代码进行反射访问,覆盖模块系统的行为。对于已经是模块化的代码,它也可以用来允许对特定模块进行反射访问。

当您正在迁移到模块化的 Java 时,任何反射访问另一个模块的内部代码的代码应该首先使用该开关运行,直到情况得到解决。

与反射访问的这个问题相关(也是它的特殊情况)是框架广泛使用内部平台 API 的问题。这通常被描述为 “Unsafe 问题”,我们将在本章末尾遇到它。

提供服务

模块系统包含了 服务 机制,以缓解封装的高级形式的另一个问题。这个问题可以通过考虑一个熟悉的代码片段来简单地解释:

import com.example.Service;

Service s = new ServiceImpl();

即使 Service 存在于一个已导出的 API 包中,这行代码也不会编译通过,除非包含 ServiceImpl 的包也被导出。我们需要的是一种机制,允许对实现服务类的类进行细粒度的访问,而无需导入整个包。例如,我们可以这样写:

module jin8 {
    exports jin8.api;
    requires othermodule.services;

    provides services.Service with jin8.services.ServiceImpl;
}

现在 ServiceImpl 类在编译时作为 Service 接口的实现可访问。请注意,services 包必须包含在另一个模块中,这是当前模块所需要的,以便此提供可以工作。

多版本 JAR

为了解释多版本 JAR 解决的问题,让我们考虑一个简单的例子:找到当前执行进程的进程 ID(PID)(即,执行我们代码的 JVM)。

注意

我们不使用之前的 HTTP/2 示例,因为 Java 8 没有 HTTP/2 API,所以我们需要做大量的工作(基本上是完整的后向兼容!)来为 8 提供相同的功能。

这可能看起来是一个简单的任务,但在 Java 8 上,这需要大量的样板代码:

public class GetPID {
    public static long getPid() {
        // This rather clunky call uses JMX to return the name that
        // represents the currently running JVM. This name is in the
        // format <pid>@<hostname>—on OpenJDK and Oracle VMs only—there
        // is no guaranteed portable solution for this on Java 8
        final String jvmName = 
            ManagementFactory.getRuntimeMXBean().getName();
        final int index = jvmName.indexOf('@');
        if (index < 1)
            return -1;

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

如我们所见,这一点远非我们所希望的那么简单。更糟糕的是,它在所有 Java 8 实现中都没有标准的支持方式。幸运的是,从 Java 11 开始,我们可以使用新的 ProcessHandle API,如下所示:

public class GetPID {
    public static long getPid() {
        // Use new Java 9 Process API...
        ProcessHandle processHandle = ProcessHandle.current();
        return processHandle.getPid();
    }
}

现在这使用了一个标准的 API,但它引发了一个基本问题:开发人员如何编写能够在所有当前 Java 版本上运行的代码?

我们想要的是正确构建和运行一个项目在多个 Java 版本上。我们想依赖于仅在较新版本中可用的库类,但仍然可以通过使用一些代码 “shims” 在较早版本上运行。最终结果必须是一个单独的 JAR,并且我们不需要项目切换到多模块格式——事实上,这个 JAR 必须作为自动模块工作。

让我们来看一个例子项目,它必须在 Java 8 和 Java 11 或更高版本中都能正确运行。主要代码库是用 Java 8 构建的,Java 11 的部分必须用 Java 11 构建。为了防止编译失败,这部分构建必须与主代码库隔离,尽管它可以依赖于 Java 8 构建的构件。

为了保持构建配置的简单性,此功能是通过 JAR 文件中的 MANIFEST.MF 条目来控制的:

Multi-Release: True

变体代码(即后续版本的代码)然后存储在 META-INF 的特殊目录中。在我们的案例中,这是 META-INF/versions/11

对于实现此功能的 Java 运行时,版本特定目录中的任何类都会覆盖内容根目录中的版本。另一方面,对于 Java 8 及更早版本,MANIFEST.MF 条目和 versions/ 目录都会被忽略,只会找到内容根目录中的类。

转换为多版本 JAR

要开始将您的软件部署为多版本 JAR,请按照以下概要进行操作:

  1. 隔离依赖于 JDK 版本的特定代码。

  2. 如果可能,将该代码放入一个或一组包中。

  3. 使版本 8 项目能够干净地构建

  4. 为补充类创建一个新的独立项目。

  5. 为新项目设置一个单一的依赖项(版本 8 构件)。

对于 Gradle,您还可以使用 source set 的概念,并使用不同(较新)的编译器编译 v11 代码。然后可以使用类似以下的段落将其构建为 JAR 文件:

jar {
  into('META-INF/versions/11') {
     from sourceSets.java11.output
  }

  manifest.attributes(
     'Multi-Release': 'true'
  )
}

对于 Maven,当前最简单的方法是使用 Maven 依赖插件,并在单独的 generate-resources 阶段将模块化类添加到整体 JAR 中。

迁移到模块

许多 Java 开发人员面临一个问题,即是否以及何时应该将他们的应用程序迁移到使用模块。

提示

模块应该成为所有新开发的应用程序的默认选项,特别是那些采用微服务架构的应用。

许多应用程序根本不需要迁移。然而,将现有代码库模块化确实值得,因为更好的封装和整体架构上的好处会在长期内显现出来——可以更快地引入新开发人员,并提供更清晰的结构,易于理解和维护。

在考虑迁移现有应用程序(特别是单体设计)时,可以使用以下路线图:

  1. 首先将应用程序运行时升级到 Java 17(最初从类路径运行)。

  2. 确定已模块化的任何应用程序依赖项,并将这些依赖项迁移到模块中。

  3. 将所有非模块化的依赖项保留为自动模块。

  4. 引入一个单一的 单体模块,包含所有应用程序代码。

此时,一个最小化的模块化应用程序应该准备好进行生产部署。在此阶段,该模块通常会是一个open模块。下一步是架构重构;在此阶段,应用程序可以根据需要拆分为单独的模块。

一旦应用程序代码在模块中运行,限制通过 opens 对您的代码的反射访问可能是有意义的。此访问可以限制在特定模块(如 ORM 或依赖注入模块)作为删除任何不必要访问的第一步。

对于 Maven 用户来说,值得记住 Maven 不是一个模块系统,但它确实有依赖项——并且(与 JPMS 的依赖项不同)它们是有版本的。在撰写本文时,Maven 工具仍在不断发展以完全与 JPMS 集成(许多插件在此时尚未跟进)。然而,正在出现一些关于模块化 Maven 项目的一般指导原则,特别是:

  • 目标是为每个 Maven POM 文件生成一个模块。

  • 不要在 Maven 项目未准备好(或没有立即需要)之前进行模块化。

  • 请记住,在 Java 11+ 运行时上运行不需要在 Java 11+ 工具链上构建。

最后一点表明,将 Maven 项目迁移到 Java 8 项目并确保这些 Maven 构件能够在 Java 11(或 17)运行时上作为自动模块清洁地部署,是迁移 Maven 项目的一种路径。只有在第一步正常工作后,才应进行全面的模块化。

有一些很好的工具支持可帮助进行模块化过程。Java 8 及更高版本附带了jdeps工具(参见 Chapter 13),用于确定您的代码依赖于哪些包和模块。这对于从 Java 8 迁移和在重新架构时使用jdeps非常有帮助。

自定义运行时映像

JPMS 的关键目标之一是应用程序可能不需要 Java 8 传统单片式运行时中的每个类,并且可以管理较小的模块子集。这些应用程序在启动时间和内存开销方面的足迹可以更小。这可以进一步进行:如果不需要所有类,那么为什么不与仅包含必要内容的减小、自定义运行时映像一起提供应用程序呢?

为了演示这个想法,让我们将 HTTP/2 检查器打包成一个独立的工具,并使用jlink工具(自 Java 9 起成为平台的一部分)来实现这一点:

$ jlink --module-path httpchecker.jar:$JAVA_HOME/jmods \
      --add-modules httpchecker,jdk.crypto.ec \
      --launcher http2chk=httpchecker \
      --output http2chk-image

注意,这假设 JAR 文件 httpchecker.jar 已创建了一个主类(即入口点)。结果是一个输出目录 http2chk-image,大小约为 39M,远小于完整映像。这也说明,因为该工具使用新的 HTTP 模块,在使用 HTTPS 连接时需要安全、加密等库。

在自定义镜像目录中,我们可以直接运行http2chk工具,并查看它在机器没有所需版本的java时也可以工作:

$ java -version
java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)
$ ./bin/http2chk https://www.google.com
https://www.google.com: HTTP_2

自定义运行时镜像的部署仍然是一种相当新的工具,但它具有减少代码占用空间并帮助 Java 在微服务时代保持竞争力的巨大潜力。未来,jlink甚至可以与新的编译方法结合使用,包括提前(AOT)编译器。

模块化的问题

尽管模块系统是 Java 9 的旗舰功能,并且已经花费了大量的工程时间来开发它,但它也不是没有问题。这或许是不可避免的——这一功能从根本上改变了 Java 应用程序的架构和交付方式。当试图为 Java 的成熟生态系统提供后向兼容时,模块几乎不可能避免遇到一些问题。

Unsafe及其相关问题

sun.misc.Unsafe是一个在 Java 世界中被广泛使用和受欢迎的类,特别是在框架编写者和其他实现者中。然而,它是一个内部实现类,并不是 Java 平台标准 API 的一部分(正如包名所清楚表明的)。类名也很明显地表明这实际上不是为 Java 应用程序使用而设计的。

Unsafe是一个不受支持的内部 API,因此可能会在任何新的 Java 版本中被撤回或修改,而不考虑对用户应用程序的影响。任何使用它的代码技术上都直接耦合于 HotSpot JVM,并且可能是非标准的,并且可能无法在其他实现上运行。

虽然Unsafe并非以任何方式成为 Java SE 的官方部分,但已成为事实上的标准,并成为几乎每个主要框架实现的关键部分之一。随着版本的不断更新,它已经演变成为一种非标准但必要功能的垃圾桶。这些功能的混合使用形成了一个真正的混合包,每种能力提供的安全程度各不相同。Unsafe的示例用途包括:

  • 快速序列化/反序列化

  • 线程安全的 64 位大小的本地内存访问(例如,堆外内存)

  • 原子内存操作(例如,比较并交换)

  • 快速字段/内存访问

  • 多操作系统替代 JNI

  • 访问具有 volatile 语义的数组项(参见 第六章)

核心问题在于许多框架和库在没有某些Unsafe功能的替代方案的情况下无法迁移到模块化的 JDK。这反过来影响了使用 Java 生态系统中各种框架的每个应用程序。

为解决这个问题,Oracle 为一些所需功能创建了新的受支持的 API,并将无法及时封装到模块中的 API 分离出来,jdk.unsupported。这清楚地表明这不是一个受支持的 API,开发人员使用它需自担风险。这使得Unsafe获得了临时许可证(严格限制时间),同时鼓励库和框架开发人员转移到新的 API。

替代 API 的一个示例是 VarHandles。这些扩展了Method Handles概念(来自第十一章)并添加了新功能,例如 Java 11 的并发障碍模式。这些,连同对 JMM 的一些适度更新,旨在生成用于访问新低级处理器功能的标准 API,而不允许开发人员完全访问危险功能,就像Unsafe中发现的一样。

关于Unsafe和相关低级平台技术的更多详细信息可在Optimizing Java(O’Reilly)中找到。

缺乏版本控制

Java 17 的 JPMS 标准不包括依赖项的版本控制。

注意

这是一个有意的设计决定,旨在降低交付系统的复杂性,并不排除未来模块可能包含有版本的依赖项的可能性。

当前情况需要外部工具来处理模块依赖项的版本控制。在 Maven 的情况下,这将在项目对象模型(POM)中完成。这种方法的一个优点是下载和管理版本也在构建工具的本地存储库中处理。

但是,无论如何,简单的事实是,依赖版本信息必须存储在模块之外,并且不构成 JAR 工件的一部分。

无法摆脱——这相当丑陋,但与从类路径中推导出依赖项的情况相比,情况并不更糟。

采用率缓慢

随着 Java 9 的发布,Java 发布模型在根本上发生了变化。 Java 8 和 9 使用了“关键发布”模型——其中一个关键(或标志性)功能,如 Java 8 的 lambda 或 Java 9 的模块——基本上定义了发布,因此交付日期取决于功能何时完成。

这种模型的问题在于,由于不确定版本何时发布,可能会导致效率低下。特别是,只是错过发布的一个小功能将不得不等待很长时间才能等到下一个主要发布版。因此,从 Java 10 开始,采用了一种新的发布模型,引入了严格的基于时间的版本控制

这意味着:

  • Java 现在被分类为功能发布,每六个月定期发生一次。

  • 功能在基本上完成之前不会合并到平台中。

  • 主线仓库始终处于可发布状态。

这些发布仅适用于六个月,过后将不再得到支持。Oracle 将某些版本指定为长期支持(LTS)版本,用户可以从 Oracle 获取扩展的付费支持。

这些 LTS 版本最初的发布节奏是三年,但预计在撰写本文时将改为两年。这意味着 Oracle 的 LTS 版本目前是 8(事后添加的)、11 和 17;预计下一个版本将在 2023 年 9 月发布 Java 21。

除了 Oracle 外,OpenJDK 的版本还可以从包括 Amazon、Azul、Eclipse Adoptium、IBM、Microsoft、Red Hat 和 SAP 在内的其他供应商获取。这些供应商提供多种获取 JDK 更新(包括安全更新)的方式,且零成本。

还有多种付费支持模型从上述供应商提供。

欲深入了解此主题,请参阅指南:“Java Is Still Free” by the Java Champions community,这是 Java 行业中独立的 Java 领袖组成的团体。

尽管 Java 社区普遍对新的更快发布周期持积极态度,但 Java 9 及以上版本的采纳率远低于以往版本。这可能是因为团队希望有更长的支持周期,而不是仅在六个月后升级到每个特性发布。实际上,只有 LTS 版本才看到广泛采用,甚至与 Java 8 的快速普及相比也较慢。

从 Java 8 升级到 11(或 17)也不是一种插拔替换(与 7 到 8、6 到 7 相比)。即使最终用户应用程序不利用模块,模块子系统也从根本上改变了 Java 平台的许多方面。

在 Java 11 发布四年后,它似乎终于超过了 Java 8,现在更多工作负载在 Java 11 上运行而不是 8。Java 17 的采用速度以及 Java 21 的影响有待观察(假设 21 确实是下一个 LTS 版本)。

摘要

Java 9 首次引入的模块化特性旨在一次解决多个问题。通过拒绝访问内部实现,已经实现了更短的启动时间、更小的内存占用和减少的复杂性目标。长期目标是实现更好的应用架构,并开始思考编译和部署的新方法,这些目标仍在进行中。

然而,事实是,截至 Java 17 发布时,很少有团队和项目全面转向模块化世界。这是可以预料的,因为模块化是一个长期的项目,回报缓慢,并依赖于生态系统内部的网络效应来实现全部收益。

新应用程序在一开始时应考虑以模块化方式构建,但 Java 生态系统内平台模块性的整体故事仍处于初期阶段。

第十三章:平台工具

本章讨论了 Java 平台的 OpenJDK 版本附带的工具。所涵盖的工具都是命令行工具。如果您使用的是其他版本的 Java,您可能会发现在您的分发版本中有不同的工具,但功能类似。

在本章后面,我们将专门为两个工具分配专门的部分:jshell,它将交互式开发引入了 Java 平台,以及用于深度分析 Java 应用程序的 Java Flight Recorder(JFR)工具。

命令行工具

我们涵盖的命令行工具是最常用的工具和最有用的工具,它们不是每个可用工具的完整描述。特别是与 CORBA 和 RMI 服务器部分有关的工具未涵盖,因为这些模块在 Java 11 发布时从平台中删除了。

注意

在某些情况下,我们需要讨论接受文件系统路径的开关。与本书的其他地方一样,我们在这种情况下使用 Unix 约定。

下面我们将讨论以下工具,包括它们的基本用法、描述和常用开关:

  • javac

  • java

  • jar

  • javadoc

  • jdeps

  • jps

  • jstat

  • jstatd

  • jinfo

  • jstack

  • jmap

  • javap

  • jlink

  • jmod

  • jcmd

注意

整个描述的选项都针对 Java 17,并且在较旧的 Java 版本中可能会有所不同。例如,--class-path 是在 --module-path 成为选项时引入的,但在 Java 8 及更早版本中不起作用(它们需要 -cp--classpath)。

javac

基本用法

bjavac *some*/*package*/MyClass.java

描述

javac 是 Java 源代码编译器——它从 .java 源文件生成字节码(以 .class 文件的形式)。

对于现代 Java 项目,javac 不经常直接使用,因为它相当低级且笨重,特别是对于较大的代码库。相反,现代集成开发环境(IDE)要么自动为开发人员驱动 javac,要么在编写代码时具有内置编译器供使用。对于部署,大多数项目将使用单独的构建工具,最常见的是 Maven 或 Gradle。这些工具的讨论超出了本书的范围。

尽管如此,对于开发人员来说,了解如何使用 javac 是有用的,因为有些情况下,通过手动编译小型代码库比安装和管理 Maven 等生产级构建工具更可取。

常用开关

-cp--class-path *<path>*

为编译提供我们需要的类。

-p--module-path *<path>*

为编译提供应用程序模块。请参阅第十二章以了解 Java 模块的全面讨论。

-d *some*/*dir*

告诉 javac 输出类文件的位置。

@project.list

从文件 project.list 加载选项和源文件。

-help

选项的帮助。

-X

非标准选项的帮助。

-source *<version>*

控制 javac 将接受的 Java 版本。

-target *<version>*

控制 javac 将输出的类文件的版本。

-profile *<profile>*

控制 javac 编译应用程序时将使用的配置文件。

-Xlint

启用有关警告的详细信息。

-Xstdout *<path>*

将编译运行的输出重定向到文件。

-g

向类文件添加调试信息。

笔记

javac 传统上接受控制编译器接受的源语言版本和用于输出类文件的类文件格式版本的开关(-source-target)。

此功能引入了额外的编译器复杂性(因为必须内部支持多种语言语法),以换取一些小型开发人员的利益。在 Java 8 中,这种能力稍作整理并放置在更加正式的基础上。

从 JDK 8 开始,javac 只接受来自三个版本之前的源和目标选项。也就是说,javac 版本 8 只接受 JDK 5、6、7 和 8 的格式。这不影响 java 解释器——任何 Java 版本的类文件仍将在 Java 8 附带的 JVM 上正常工作。

C 和 C++ 开发人员可能会发现,与这些其他语言相比,-g 开关对他们的帮助较少。这在很大程度上是因为 Java 生态系统中广泛使用的 IDE——集成调试比在类文件中添加额外的调试符号简单得多,也更加有用。

在开发人员中,对于 lint 功能的使用仍然存在一些争议。许多 Java 开发人员生成触发大量编译警告的代码,然后简单地忽略它们。但是,在更大的代码库(特别是 JDK 代码库本身)中的经验表明,在相当大的比例的情况下,触发警告的代码是隐藏着微妙错误的代码。强烈建议使用 lint 功能或静态分析工具(例如 SpotBugs)。

java

基本用法

java some.package.MyClass

java -jar my-packaged.jar

描述

java 是启动 Java 虚拟机的可执行文件。程序的初始入口点是存在于指定类上的 main() 方法,其签名为:

public static void main(String[] args);

此方法在由 JVM 启动创建的单个应用程序线程上运行。一旦此方法返回(以及任何额外启动的非守护应用程序线程终止),JVM 进程将退出。

如果形式使用 JAR 文件而不是类(可执行的 JAR 形式),则 JAR 文件必须包含一段元数据,告诉 JVM 从哪个类开始启动。

这段元数据是 Main-Class 属性,包含在 META-INF/ 目录中的 MANIFEST.MF 文件中。有关更多详细信息,请参阅 jar 工具的描述。

常见开关

-cp, --class-path *<path>*

定义从中读取的类路径。

-p, --module-path *<path>*

定义查找模块的路径。

--list-modules

找到当前设置中的模块列表并退出。

-X, -?, -help

提供关于 java 可执行文件及其开关的帮助。

-D*<property=value>*

设置一个可以被 Java 程序检索的 Java 系统属性。可以通过这种方式指定任意数量的这种属性。

-jar

运行一个可执行的 JAR 文件(参见 jar 条目)。

-Xbootclasspath(/a or /p)

使用替代的系统类路径运行(极少使用)。

-client, -server

选择 HotSpot JIT 编译器(参见 “Notes” for this entry)。

-Xint, -Xcomp, -Xmixed

控制 JIT 编译(很少使用)。

-Xms*<size>*

设置 JVM 的最小已提交堆大小。

-Xmx*<size>*

为 JVM 设置最大的已提交堆大小。

-agentlib:*<agent>*, -agentpath:*<path to agent>*

指定一个 JVM 工具接口(JVMTI)代理程序附加到正在启动的进程。代理通常用于仪器化或监控。

-verbose

生成额外的输出,有时用于调试。

Notes

HotSpot VM 包含两个单独的 JIT 编译器——称为客户端(或 C1)编译器和服务器(或 C2)编译器。它们设计用于不同目的,客户端编译器提供更可预测的性能和更快的启动速度,但牺牲了不执行激进代码优化的性能。

传统上,Java 进程使用的 JIT 编译器是通过 -client-server 开关在进程启动时选择的。然而,随着硬件的进步使得编译成本越来越低,出现了一种新的可能性——在 Java 进程热身时使用客户端编译器,然后在可用时切换到服务器编译器进行高性能优化。这种方案称为分层编译,它是 Java 8 的默认设置。大多数进程将不再需要显式的 -client-server 开关。

在 Windows 平台上,通常会使用稍微不同版本的 java 可执行文件——javaw。此版本启动一个 Java 虚拟机,而不会强制出现 Windows 控制台窗口。

在较旧的 Java 版本中,支持多种不同的遗留解释器和虚拟机模式。现在这些大多数已经被移除,任何剩余的应被视为残留的。

-X 开头的开关原本是非标准的开关。然而,趋势已经向标准化了一些这些开关(特别是 -Xms-Xmx)。与此同时,Java 版本引入了越来越多的 -XX: 开关。这些开关原本是实验性的,不适合生产使用。然而,随着实现的稳定,一些这些开关现在适合一些高级用户(甚至在生产部署中使用)。

总体来说,详细讨论开关超出了本书的范围。为了生产使用配置 JVM 是一个专业的主题,建议开发人员特别注意,尤其是在修改与垃圾收集子系统相关的任何开关时。

jar

基本用法

jar cvf my.jar *someDir/*

描述

jar实用程序用于创建和操作 Java 存档(.jar)文件。这些是包含 Java 类、额外资源和(通常)元数据的 ZIP 格式文件。该工具在一个 JAR 文件上有五种主要的操作模式——创建、更新、索引、列出和提取。

这些由传递给jar的命令选项字符(而不是开关)来控制。只能指定一个命令字符,但也可以使用可选的修饰符字符。

命令选项
  • c: 创建新存档

  • u: 更新存档

  • i: 索引存档

  • t: 列出一个存档

  • x: 提取存档

修饰符
  • v: 详细模式

  • f: 操作指定的文件,而不是标准输入

  • 0: 存储但不压缩添加到存档中的文件

  • m: 将指定文件的内容添加到jar元数据清单中

  • e: 使此jar可执行,并将指定的类作为入口点

注意

jar命令的语法故意与 Unix tar命令非常相似。这种相似性是jar使用命令选项而不是开关(其他 Java 平台命令所做的)的原因。更典型的显式开关(例如--create)也可用,并且可以通过jar --help找到它们的文档。

创建 JAR 文件时,jar工具将自动添加一个名为META-INF的目录,其中包含一个名为MANIFEST.MF的文件——这是以头部与值配对的形式的元数据。默认情况下,MANIFEST.MF只包含两个头部:

Manifest-Version: 1.0
Created-By: 17.0.4 (Eclipse Adoptium)

使用m选项允许在 JAR 创建时将附加的元数据添加到MANIFEST.MF中。一个经常添加的片段是Main-Class:属性,它指示 JAR 中包含的应用程序的入口点。具有指定Main-Class:的 JAR 可以通过 JVM 直接执行,通过java -jar或在图形文件浏览器中双击 JAR 文件。

添加Main-Class:属性是如此常见,以至于jar具有e选项直接在MANIFEST.MF中创建它,而不必为此创建单独的文本文件。可以使用--extract选项轻松检查 jar 的内容,包括清单。

javadoc

基本用法

javadoc *some.package*

描述

javadoc从 Java 源文件生成文档。它通过阅读一种特殊的注释格式(称为 Javadoc 注释)并将其解析成标准文档格式来实现,然后可以将其输出到各种文档格式中(尽管 HTML 是最常见的)。

有关 Javadoc 语法的完整描述,请参阅第七章。

常用开关

-cp--class-path *<path>*

定义要使用的类路径。

-p--module-path *<path>*

定义要查找模块的路径。

-D *<directory>*

告诉javadoc生成文档的输出位置。

-quiet

除了错误和警告之外,抑制输出。

注意

平台 API 文档都是用 Javadoc 编写的。

javadoc 建立在与 javac 相同的类之上,并使用一些源编译器基础设施来实现 Javadoc 的特性。

使用 javadoc 的典型方式是针对整个包运行,而不仅仅是一个类。

javadoc 有很多开关和选项,可以控制其行为的许多方面。详细讨论所有选项超出本书范围。

jdeps

jdeps 工具是一个静态分析工具,用于分析包或类的依赖关系。该工具有许多用途,从识别开发者代码调用内部未文档化的 JDK API(如 sun.misc 类)到帮助跟踪传递依赖关系。

jdeps 还可以用来确认一个 JAR 文件是否能在一个紧凑配置文件下运行(更多关于紧凑配置文件的详细信息请参见本章后面)。

基本用法

jdeps com.me.MyClass

描述

jdeps 报告请求分析的类的依赖信息。可以指定的类包括类路径上的任何类、文件路径、目录或者 JAR 文件。

常见开关

-cp, --class-path *<path>*

定义要使用的类路径。

-p, --module-path *<path>*

定义查找模块的路径。

-s, -summary

仅打印依赖摘要。

-m *<module-name>*

针对一个模块进行分析

-v, -verbose

打印所有类级别的依赖关系。

-verbose:package

打印包级别的依赖关系,排除同一存档内的依赖关系。

-verbose:class

打印类级别的依赖关系,排除同一存档内的依赖关系。

-p *<pkg name>*, -package *<pkg name>*

在指定的包中查找依赖项。可以多次指定此选项以获取不同的包。-p-e 选项是互斥的。

-e *<regex>*, -regex *<regex>*

查找与指定正则表达式模式匹配的包中的依赖项。-p-e 选项是互斥的。

-include *<regex>*

限制分析到匹配模式的类。此选项过滤要分析的类列表。可以与 -p-e 一起使用。

-jdkinternals

在 JDK 内部 API 中查找类级别的依赖关系(这些 API 可能在即使是次要平台发布中也会更改或消失)。

-apionly

限制分析到 API —— 例如,从公共类的签名中的公共和受保护成员的依赖,包括字段类型、方法参数类型、返回类型和已检查的异常类型。

-R, -recursive

递归遍历所有依赖关系。

-h, -?, --help

打印 jdeps 的帮助消息。

注意事项

jdeps 是一个有用的工具,可以让开发者意识到他们对 JRE 的依赖不是作为一个单一的环境,而是作为一个更加模块化的东西。

jps

基本用法

jps

jps *<remote URL>*

描述

jps提供了本地机器(或者如果远程端运行了适当的jstatd实例,则是远程机器)上所有活动 JVM 进程的列表。远程 URL 支持需要 RMI;这种配置在jstatd部分有更详细的解释。

常见开关

-m

输出传递给主方法的参数。

-l

输出应用程序主类的完整包名称(或应用程序的 JAR 文件的完整路径名称)。

-v

输出传递给 JVM 的参数。

笔记

这个命令并不是严格必需的,因为标准的 Unix ps命令可能已经足够了。但它不使用标准的 Unix 进程查询机制,因此在某些情况下,Java 进程停止响应(并且在jps中看起来已经死掉),但仍然被操作系统列为活动状态。

jstat

基本用法

jstat -options

jstat *<report type such as -class>* *<PID>*

描述

这个命令显示了关于给定 Java 进程的一些基本统计信息。通常这是一个本地进程,但可以位于远程机器上,只要远程端运行了合适的jstatd进程。

常见开关

-options

列出jstat可以生成的报告类型。最常见的选项包括:

-class

报告迄今为止的类加载活动。

-compiler

到目前为止的 JIT 编译过程。

-gcutil

详细的垃圾回收报告。

-printcompilation

更详细的编译信息。

笔记

jstat用于识别进程(可能是远程的)的一般语法是:

[*`<``protocol``>`*://]<vmid>[@hostname][:port][/servername]

此语法用于指定一个远程进程(通常通过 JMX 通过 RMI 连接),但实际上,更常见的本地语法仅仅使用 VM ID,即主流平台(Linux、Windows、Unix、macOS 等)上的操作系统进程 ID(PID)。

jstatd

基本用法

jstatd *<options>*

描述

jstatd通过网络公开了本地 JVM 的信息。它使用 RMI 实现,可以使这些本地能力对 JMX 客户端可访问。这需要特殊的安全设置,与 JVM 默认设置不同。要启动jstatd,首先需要创建以下文件并将其命名为jstatd.policy

grant codebase "jrt:/jdk.jstatd" {
   permission java.security.AllPermission;
};

grant codebase "jrt:/jdk.internal.jvmstat" {
   permission java.security.AllPermission;
};

该策略文件授予加载自实现jstatd的 JDK 模块的任何类所有安全权限。引入 JDK 9 中的模块后,精确的策略要求已经改变,并且可能会在未来的 JDK 版本中有所不同。

要使用此策略启动jstatd,请使用以下命令行:

jstatd -J-Djava.security.policy=*`<``path` `to` `jstat``.``policy``>`*
常见开关

-p *<port>*

在该端口上查找现有的 RMI 注册表,如果找不到则创建一个。

笔记

建议在生产环境中始终启用jstatd,但不要在公共互联网上使用。对于大多数公司和企业环境来说,这并不容易实现,需要运维和网络工程人员的合作。然而,从生产 JVM 中获取遥测数据的好处,特别是在故障期间,难以言表。

本书不涵盖完整的 JMX 和监控技术讨论。

jinfo

基本用法

jinfo *<PID>*

jinfo *<core file>*

描述

此工具显示运行中 Java 进程(或核心文件)的系统属性和 JVM 选项。

常见开关

-flags

仅显示 JVM 标志。

-sysprops

仅显示系统属性。

注意

实际上,这很少被使用,尽管偶尔作为预期程序实际执行的健全性检查可能会有所帮助。

jstack

基本用法

jstack *<PID>*

描述

jstack 实用程序为进程中的每个 Java 线程生成堆栈跟踪。

常见开关

-e

扩展模式(包含关于线程的额外信息)。

-l

长模式(包含关于锁的额外信息)。

注意

生成堆栈跟踪不会停止或终止 Java 进程。jstack 生成的文件可能非常大,通常需要对文件进行后处理。

jmap

基本用法

jmap *<output option>* *<process>*

描述

jmap 提供了运行中 Java 进程的内存分配视图。

常见开关

-dump:

生成运行进程的堆转储。

-histo

生成当前分配内存状态的直方图。

-histo:live

此版本的直方图仅显示活动对象的信息。

注意

直方图形式遍历 JVM 分配列表。这包括活动对象和未收集的(但尚未收集)对象。直方图按使用内存的对象类型组织,并按特定类型使用的字节数量从多到少排序。标准形式不会暂停 JVM。

在执行之前,通过进行完整的停顿式(STW)垃圾回收来确保准确性。因此,在生产系统中,不应在垃圾回收可能显著影响用户的时间使用此工具。

对于 -dump 形式,请注意生成堆转储可能是一个耗时的过程,并且是 STW 的。由于当前分配的堆的大小成比例,因此对于某些进程可能非常大。

javap

基本用法

javap *<classname>*

javap *<path/to/ClassFile.class>*

描述

javap 是 Java 类反汇编器,实际上是查看类文件内部的工具。它可以显示 Java 方法编译成的字节码,以及常量池信息(类似于 Unix 进程的符号表)。

默认情况下,javap 显示 publicprotected 和默认方法的签名。-p 开关还将显示 private 方法。

常见开关

-c

反编译字节码

-v

冗长模式(包括常量池信息)

-p

包括 private 方法

-cp, --class-path

类名加载位置

-p, --module-path

模块加载的位置(如果按类名加载)

注意

javap 工具将与任何类文件一起工作,前提是 javap 来自于与生成文件相同或更高版本的 JDK。

注意

一些 Java 语言特性在字节码中可能有令人惊讶的实现。例如,正如我们在第九章中所见,Java 的 String 类具有有效不可变实例,并且 JVM 在 Java 8 版本后通过不同的方式实现字符串连接运算符 +。这种差异在javap显示的反汇编字节码中清晰可见。

jlink

基本用法

jlink *[options]* --module-path modulepath --add-modules module

描述

jlink 是 Java 平台的自定义运行时映像链接器工具,用于将 Java 类、模块及其依赖项打包成自定义运行时映像。jlink 工具创建的映像将包括一组链接的模块及其传递的依赖关系。

常见开关

--add-modules *<module>* [, *module1*]

将模块添加到要链接的模块的根集合中

--endian {little|big}

指定目标体系结构的字节顺序

--module-path *<path>*

指定链接模块的路径

--save-opts *<file>*

将选项保存到指定文件的链接器中

--help

打印帮助信息

@filename

从文件名而不是命令行读取选项

注释

jlink 工具将与任何类文件或模块一起工作,并且链接将需要代码的传递依赖项被链接。

注意

默认情况下,自定义运行时映像不支持自动更新。这意味着开发人员在必要时需要负责重新构建和更新其应用程序。一些 Java 语言特性可能会有限制,因为运行时映像可能不包含完整的 JDK;因此,反射和其他动态技术可能不完全受支持。

jmod

基本用法

jmod create *[options]* my-new.jmod

描述

jmod 为自定义链接器(jlink)准备 Java 软件组件。结果是一个 .jmod 文件。这应被视为中间文件,而不是主要的分发工件。

基本模式

create

创建新的 JMOD 文件

extract

从 JMOD 文件中提取所有文件(展开它)

list

列出来自 JMOD 文件的所有文件

describe

打印有关 JMOD 文件的详细信息

常见开关

--module-path path

指定模块路径,其中可以找到模块的核心内容。

--libs path

指定用于包含的本地库的路径。

--help

打印帮助信息。

@filename

从文件名而不是命令行读取选项。

注释

jmod 读取和写入 JMOD 格式,但请注意,这与模块化 JAR 格式不同,不打算立即替换它。

注意

jmod工具目前仅用于要链接到运行时映像(使用jlink工具)的模块。另一个可能的用例是打包具有必须随模块一起分发的本地库或其他配置文件的模块。

jcmd

基本用法

jcmd *<PID>*

jcmd *<PID>* *<command>*

描述

jcmd向正在运行的 Java 进程发出命令。精确的命令可能因 Java 版本而异,并且可以通过使用进程 ID 和无命令运行jcmd来列出。

常见开关

-f *<path>*

从文件而不是命令行参数读取命令

-l

列出 Java 进程(类似于jps

--help

打印帮助信息

常见命令

GC.heap_dump *<path>*

生成类似于jmap的堆转储。注意路径是相对于 Java 进程的,而不是 jcmd 运行的位置!

GC.heap_info

显示有关 Java 进程堆的统计和大小信息。

JFR.start

开始 Java Flight Recorder(JFR)会话。JFR 是 JVM 内置的性能监控和分析工具。

JFR.stop name=*<name from start>* filename=*<path>*

停止命名的 JFR 会话并记录到文件中。

VM.system_properties

输出 Java 进程的系统属性。

注意事项
注意

jcmd的命令按其与之交互的子系统分组,例如GCJFR。除了我们提供的示例之外,还有许多其他命令。值得探索您的 Java 安装中可用的内容,以帮助在生产环境中操作 JVM。

JShell 介绍

Java 传统上被理解为一种类导向的语言,并具有明确的编译-解释-评估执行模型。然而,在本节中,我们将讨论一种通过提供一种交互/脚本能力来扩展此编程范例的新技术。

随着 Java 9 的到来,Java 运行时和 JDK 捆绑了一个新工具,JShell。这是一个用于 Java 的交互式 Shell,类似于 Python、Scala 或 Lisp 中的 REPL。该 Shell 旨在用于教学和探索使用,并且由于 Java 语言的性质,不像其他语言中类似的 Shell 对于工作程序员来说那么有用。

特别是,预计 Java 不会成为一个以 REPL 驱动的语言。相反,这打开了一个机会,可以使用 JShell 进行一种不同风格的编程,这种风格既补充了传统用例,又提供了新的视角,尤其是用于使用新 API 的情况。

使用 JShell 非常容易探索简单的语言特性,例如:

  • 原始数据类型

  • 简单的数值操作

  • 字符串操作基础

  • 对象类型

  • 定义新类

  • 创建新对象

  • 调用方法

要启动 JShell,我们只需从命令行调用它:

$ jshell
|  Welcome to JShell -- Version 17.0.4
|  For an introduction type: /help intro

jshell>

从这里,我们可以输入小段的 Java 代码,称为片段

jshell> 2 * 3
$1 ==> 6

jshell> var i = 2 * 3
i ==> 6

Shell 被设计为一个简单的工作环境,因此放宽了一些工作中的 Java 程序员可能期望的规则。JShell 代码片段与常规 Java 之间的一些差异包括:

  • 在 JShell 中分号是可选的

  • JShell 支持详细模式

  • JShell 具有比常规 Java 程序更广泛的默认导入集合

  • 方法可以在顶层声明(不在类内部)

  • 方法可以在代码片段内重新定义

  • 代码片段不能声明包或模块——所有内容都放在由 shell 控制的无名称包中

  • 只有公共类可以从 JShell 访问

  • 由于包限制,定义类和在 JShell 中工作时忽略访问控制是明智的选择

创建简单的类层次结构很简单(例如,用于探索 Java 的继承和泛型):

jshell> class Pet {}
|  created class Pet

jshell> class Cat extends Pet {}
|  created class Cat

jshell> var c = new Cat()
c ==> Cat@2ac273d3

在 shell 内进行的 tab 自动补全也是可能的,例如用于可能方法的自动补全:

jshell> c.<TAB>
equals(       getClass()    hashCode()    notify()      notifyAll()
toString()    wait(

按下 tab 键两次并输入某些内容将显示方法的文档:

jshell> c.hashCode(<TAB>
Signatures:
int Object.hashCode()

<press tab again to see documentation>
jshell> c.hashCode(<TAB TAB>
int Object.hashCode()
Returns a hash code value for the object. (Full Javadoc follows...)

我们还可以创建顶层方法,比如:

jshell> int div(int x, int y) {
   ...> return x / y;
   ...> }
|  created method div(int,int)

也支持简单的异常回溯:

jshell> div(3,0)
|  Exception java.lang.ArithmeticException: / by zero
|        at div (#2:2)
|        at (#3:1)

我们可以访问来自 JDK 的类:

jshell> var ls = List.of("Alpha", "Beta", "Gamma", "Delta", "Epsilon")
ls ==> [Alpha, Beta, Gamma, Delta, Epsilon]

jshell> ls.get(3)
$11 ==> "Delta"

jshell> ls.forEach(s -> System.out.println(s.charAt(1)))
l
e
a
e
p

或者如有必要,显式导入类:

jshell> import java.time.LocalDateTime

jshell> var now = LocalDateTime.now()
now ==> 2018-10-02T14:48:28.139422

jshell> now.plusWeeks(3)
$9 ==> 2018-10-23T14:48:28.139422

环境还允许以/开头的 JShell 命令。熟悉一些最常见的基本命令非常有用:

  • /help intro 是介绍性的帮助文本

  • /help 是进入帮助系统的更全面入口

  • /vars 显示当前作用域中的变量

  • /list 显示 shell 历史记录

  • /save 将接受的代码片段源输出到文件

  • /open 读取保存的文件并将其引入环境

  • /exit 退出 jshell 界面

例如,在 JShell 中可用的导入不仅限于java.lang。整个列表在 JShell 启动时由 JShell 加载,可以通过/list -all命令看到特殊导入

jshell> /list -all

  s1 : import java.io.*;
  s2 : import java.math.*;
  s3 : import java.net.*;
  s4 : import java.nio.file.*;
  s5 : import java.util.*;
  s6 : import java.util.concurrent.*;
  s7 : import java.util.function.*;
  s8 : import java.util.prefs.*;
  s9 : import java.util.regex.*;
 s10 : import java.util.stream.*;

JShell 环境支持 tab 自动补全,这极大地增加了工具的可用性。详细模式特别适用于了解 JShell 时——它可以通过在启动时传递-v开关或通过 shell 命令激活。

Java Flight Recorder (JFR) 简介

Java Flight Record (JFR) 是一个强大的、低延迟的分析系统,直接内嵌在 JVM 中。在 Java 11 之前,它存在多年,但只能通过商业许可证获得。现在,这个丰富的信息源已经可以通过 OpenJDK 访问,值得探索。

典型的 JFR 工作流程涉及对运行中的 JVM 启动分析,将结果下载为文件,然后通过 JDK Mission Control (JMC) GUI 应用程序离线检查该文件。虽然 JFR 直接嵌入在 OpenJDK 中,但 JMC 并不随 JDK 分发,可以从https://oreil.ly/eq4cg下载。

JFR(Java Flight Recorder)的录制可以通过 JVM 启动时的选项或本章前面展示的jcmd工具进行交互式启动。以下java调用会在两分钟内开始 JFR 录制,并在完成后将结果写入文件:

java -XX:StartFlightRecording=duration=120s,filename=flight.jfr \
   Application

选项允许紧密控制 JFR 在内存中保存的数据量,可以通过指定生成录制的持续时间或生成文件的大小来实现。结合其低开销,可以合理地在生产环境中持续运行 JFR,以便随时捕获数据(有时称为“环形缓冲”模式)。这为使用 JFR 分析在问题发生几分钟到几小时后的详细调试开启了可能性。

除了大小限制外,JFR 录制还可以配置为仅收集感兴趣的特定信息。典型领域(但只是 JFR 测量的一小部分)包括:

  • 对象分配

  • 垃圾收集

  • 线程和锁

  • 方法分析

随着 Java 17,API 已经提供在进程中以流式方式消费 JFR 事件,逐步摆脱基于文件的分析方法。这为监控工具无需登录服务器请求转储性能分析数据开辟了新的可能性。

未来,我们可以期待 JFR 作为 Java 生态系统采用的新一代可观察性工具的数据源。

概要

在过去的 15 年中,Java 发生了巨大变化,然而平台和社区仍然充满活力。能够在保留可识别的语言和平台的同时取得这一成就,绝非小事。

最终,Java 的持续存在和可行性取决于个别开发者。基于这一点,未来看起来光明,我们期待着下一个浪潮及其之后的发展。

附录:超越 Java 17

本附录讨论 Java 17 之后的版本。在之前的《Java 速查表》版本中,我们一直抵制添加前瞻性材料,但 Java 发布模型的最近变化(我们在第一章中讨论过),以及正在进行和即将进行的 Java 发展,促使我们在这个新版本中改变了策略。

在当前模型中,每六个月发布一个新版本的 Java,但只有特定版本是 LTS。目前,Java 11 和 17 被视为 LTS(回顾性添加了 8)。请注意,LTS 具有双重含义:对于 Oracle 客户来说,它意味着可以获得多年的付费支持,而其他 JDK 提供者(包括 Red Hat、Microsoft、Amazon 等)实际上采用了与那些将会公开提供后向兼容安全性和其他修复的版本相同的版本——作为经过认证的 OpenJDK 二进制文件,免费提供。

由于各种原因,整个行业并没有选择采用每六个月升级一次 Java 的周期,因此在实践中,只有 LTS 版本可能会被部署到生产环境中。然而,大多数 OpenJDK 提供者确实会认真发布所有 Java 版本的二进制文件,即使在六个月窗口之后,这些版本也不会得到支持。

这造成了一个两难局面:新功能每六个月就会推出,但直到下一个 LTS 版本发布后,团队才会广泛使用,这使得撰写特定 Java 版本的文章变得更加复杂。这一情况进一步复杂化了孵化预览功能的概念,它们分别用于在最终确定并成为语言标准的新 API 和新语言功能之前进行实验。

我们选择的解决方案是针对 LTS 版本编写本书的新版本,并包括一篇附录,介绍自上一个 LTS 版本以来(或预期将要到来的)的任何新功能。我们还选择只在主要部分中涵盖最终功能;所有关于孵化和预览功能的讨论将被限制在附录中。

让我们首先讨论 OpenJDK 中主要开发工作是如何安排的,然后再讨论 Java 18 和 19,最后再展望那个版本之后的未来。

长期 JDK 项目

OpenJDK 按照涵盖正在进行的工作的特定主要领域进行组织,这包括专注于开发未来语言或 JVM 功能的项目,这些项目可能需要多年的努力才能完成。

目前有四个项目专注于交付 Java 的重要未来方面。它们通常以项目代号来命名:

  • Panama

  • Loom

  • Valhalla

  • Amber

其中,Panama 项目提供了两个主要改进:Java 的现代外部函数接口以及对矢量 CPU 指令的支持。

它已经孵化了一段时间,但 Java 18 包含了功能的有趣的里程碑迭代,因此我们将在 Java 18 部分介绍该项目。

Project Loom 是 Java 的新并发模型。Loom 的一些功能首次在 Java 19 中提供预览,因此我们将在该部分讨论 Loom。

Valhalla 项目是所有项目中最雄心勃勃、影响最广泛且影响最大的。它也是最复杂且最远离成品交付的。我们将在附录的最后讨论它。

Project Amber 的任务是增量语言改进。它可能是四个项目中最为熟悉和最容易理解的,因此我们将在这里作为下一个主题进行讨论。

Amber

Amber 自 Java 9 发布以来一直在运行。它旨在提供有用的语言功能的小块,这种方法非常适合 Java 版本的新交付计划。迄今为止作为 Amber 的一部分并已交付的功能包括:

  • 局部变量类型推断 (var)

  • switch表达式

  • 增强的instanceof

  • 文本块

  • 记录

  • 密封类型

  • 模式匹配

这些特性中大多数已经完成,但作为 Java 17 的最后一个,模式匹配还没有完全交付。迄今为止,只有最简单的情况,即instanceof模式,已经作为最终特性到达。Java 17 确实有一个更高级形式的预览版本(正如我们在第五章中提到的),可以作为switch表达式的一部分使用:

sealed interface Pet permits Cat, Dog {}
record Cat(String name) implements Pet {}
record Dog(String name) implements Pet {}

boolean isDog(Pet p) {
    return switch (p) {
        case Cat c -> false;
        case Dog d -> true;
    };
}

注意不需要default情况。所有的Pet对象要么是Cat要么是Dog,因为Pet接口声明为密封的。

当未来进一步的情况到来并标准化为最终特性时,模式匹配将真正展现其全部能力。特别是模式匹配与代数数据类型(记录和密封类型组合的其中一种名称)的结合尤为强大。

我们可以看到 Amber 的方法如何与 Java 半年度发布模式的模型相适应;switch表达式和增强的instanceof被扩展并结合成模式匹配的基础,然后通过代数数据类型和与之相配的进一步模式进一步增强。

Java 18

新的 Java 版本由 Java Enhancement Proposals (JEPs)组成:当前、过去和未来的所有 JEPs 的完整列表可以在https://oreil.ly/BE1r1找到。

Java 18 发布于 2022 年 3 月,包括以下 JEPs:

  • 400: 默认使用 UTF-8

  • 408: 简单 Web 服务器

  • 413: Java API 文档中的代码片段

  • 416: 使用方法句柄重新实现核心反射

  • 417: 向量 API(第三孵化器)

  • 418: Internet 地址解析 SPI

  • 419: 外部函数和内存 API(第二孵化器)

  • 420: switch的模式匹配(第二预览)

其中大多数是非常小的或者是内部实现的变化。与 Panama 相关的两个 JEPs(417 和 419)是该项目的重要进展,我们将在这里详细讨论。

Panama

Panama 项目旨在为连接到本地代码的现代外部(即非 Java)接口提供支持。该代号源自巴拿马地峡,这是连接两个较大“陆地块”的狭长地带,被理解为 JVM 和本地内存(即“堆外”)。

总体目标是取代 Java 本机接口(JNI),这是众所周知存在主要问题,例如过多的仪式感、额外的构件和与除 C / C++之外的库的互操作性不足。事实上,即使对于 C 的情况,JNI 也不会自动映射类型系统,Java 和 C 代码的部分必须被半手动映射。

Panama 为帮助 Java 和本地代码互操作提供了两个主要组件:

  • 外部内存和函数 API

  • 向量 API

外部内存 API 涉及结构化外部内存的分配、操作和释放,以及外部资源的生命周期管理。这超出了ByteBuffer类的现有能力,例如可以作为单个段管理超过 2GB 的内存。管理外部内存的问题很复杂,因为它超出了 JVM 的垃圾收集器范围,而现有的机制如最终化已被证明存在严重缺陷。

使用 Panama 也可以调用外部函数。一个名为jextract的新命令行工具,从 C 头文件创建一个 Java 桥接。该桥接使用方法和变量句柄构建,以提供一组(静态)Java 方法,尽可能接近原始的 C API。

运行时支持包含在jdk.incubator.foreign模块中,这显然是一个孵化 API,在最终版本发布之前可能会发生更改。目前,最初支持的外部语言是 C 和 C++,但随着项目发展,预计会添加其他可能性(尤其是 Rust)。

除外部 API 外,Panama 还通过提供以下主要目标的 API 支持向量计算:

  • 清晰简洁的 API

  • 平台无关

  • 可靠的 JIT 编译和性能

  • 向量模式退化为线性指令的优雅降级

初始情况下,Panama 为 x64 和 AArch64 CPU 架构提供实现。然而,正如目标所表达的,该 API 不得排除其他 CPU 的可能实现。

Java 19

Java 19 发布于 2022 年 9 月,包含一个新主要特性(Loom)的预览版本,以及以下几个 JEP 选择:

  • 405: 记录模式(预览版)

  • 422: Linux/RISC-V 端口

  • 424: 外部函数和内存 API(预览版)

  • 426: 向量 API(第四孵化器)

  • 427: switch 模式匹配(第三预览版)

这些 JEP 主要是现有预览和孵化功能的延续,因此我们不再花时间讨论它们,而是专注于:

  • 425: 虚拟线程(预览版)

  • 428: 结构化并发(孵化器)

这两个 JEP 为 Project Loom 的首个预览版交付提供了基础。

Loom

在 Java 17 中,每个执行的 Java 语言线程都是一个操作系统线程,因为调用 Thread.start() 会触发一个系统调用来创建操作系统线程。因此,这在可用的 Java 执行上下文数量与操作系统限制之间创建了一个约束。随着编程语言的发展,这种约束变得更加棘手。操作系统为每个线程创建了数据结构(例如堆栈),并独立调度每个线程的执行。

这自然引出了一个问题:一个应用程序能启动多少个操作系统线程?1,000 个?也许是 10,000 个?无论确切数量如何,这种方法肯定存在硬性限制。Project Loom 是 Java 并发模型的重新构想,旨在超越这种限制。

关键在于 虚拟线程,这是一种新的构造,不是一对一对应的操作系统线程。从 Java 编程的角度看,虚拟线程看起来像 Thread 的实例,但由 JVM 管理,而不是操作系统。这意味着不会创建操作系统级的数据结构(例如线程的堆栈帧),所有管理元数据由 JVM 处理。这包括调度行为;与操作系统调度程序不同,使用的是 Java 执行调度程序(线程池)。

当虚拟线程希望执行时,它会在一个操作系统的 载体线程 上执行,并运行直到发出阻塞调用(例如 I/O)。载体线程会 让步 给另一个虚拟线程,因此一个虚拟线程可能在其生命周期内在多个不同的载体上执行。与阻塞调用的关联意味着虚拟线程不适合纯 CPU 绑定的任务,总体上,使用 Loom 的方式与开发者可能在其他语言中使用的 async / await 等方法非常不同。

尚不清楚 Loom 将对最终用户开发者产生多大影响,尽管从框架和库作者那里表现出了很高的兴趣。首个预览版本将在 JDK 19 中作为预览版推出,但仍不清楚它何时作为标准功能推出。总体来看,社区的期待是它将在下一个 LTS 版本中最终确定,预计这将是 Java 21。

未来的 Java

除了已提到的项目完成之外,还在进行着更长期的努力来发展 Java:Valhalla 项目和云原生 Java 的兴起。

让我们依次看一看。

Valhalla

Valhalla 项目是一个自 2014 年以来一直在运行的非常雄心勃勃的 OpenJDK 项目。其目标是“将 JVM 内存布局行为与现代硬件的成本模型对齐”,看起来非常简单和无害。

但是,这是极其具有欺骗性的。

首先,这将现有的 Java 对象分为两种情况:我们习惯使用的身份对象和一种新的值对象,其主要区别在于它没有唯一的身份。从这些值对象中,进一步采取的一步是允许移除引用性或间接性,并且直接用其位模式来表示值。

这些新数据值的预期使用案例是小型、不可变、最终的、无身份的类型。这使得这些新的无身份值可以与现有的对象引用和原始类型完美结合,并且也暗示了一个可能的用例,即“用户定义的原始类型”。

用户应将值视为没有身份的对象,这样他们将从 JIT(例如增强的逃逸分析)中获得性能保证的好处。

注意

Valhalla 还为低级库(如复数、半浮点数用于机器学习等复杂数据类型)提供了直接使用原始值类型的机制,但大多数开发者不应该需要使用这个方面。

这些新数据值缺乏对象身份的事实意味着它们破坏了传统的继承层次结构——没有身份就没有对象监视器,因此这些类型不支持wait()notify()synchronized

这反过来与 Java 泛型产生了一个潜在的惊人联系,因为只有引用类型可以作为类型参数的值。因此,Valhalla 提议扩展泛型,允许抽象覆盖所有类型,包括这些新的数据值甚至现有的原始类型。

除了通过 JVM 针对这些新形式的数据进行广泛工作外,还必须在 Java 语言中创建一个使用模型,这对 Java 程序员来说似乎很自然。Valhalla 还必须使现有的库(包括但不限于 JDK)能够在这些变化被推出时兼容地演进。

由于 Valhalla 的新类型是不可变的,因此将需要一些新的字节码指令,因此putfield指令(修改对象字段的指令)将无法工作。

在项目历史中,Valhalla 的新类型已经以几种名称为人所知,包括值类型内联类型原始类。涵盖 Valhalla 实现的 JEP 在撰写本文时并未针对特定的 Java 版本,大多数 Java 程序员可能在日常工作中很少遇到它们。

云原生 Java

软件行业的一个持续的巨大趋势是向在“云”中运行的工作负载过渡,这意味着由亚马逊、微软和谷歌等基础设施提供商拥有的按时间租赁的服务器上运行。

现代编程环境越来越需要确保它们在云部署中经济实用,Java 也不例外。然而,Java 的设计确实有一些方面可能对云应用不太友好。这些主要源于运行时的类加载和 JIT 编译方面,这些都是为了单个 JVM 进程的灵活性和高性能而设计的。

在云中,这可能会产生诸如:

  • 应用启动时间慢

  • 较长的达到峰值性能时间

  • 可能会有较高的内存开销

特别是对于云处理进程的生命周期(特别是“无服务器”和函数即服务部署),可能过短,以至于 Java 的性能优势无法得到回报。这可以看作是为了获得收益而需付出的成本在进程退出时尚未完全摊销。

目前正在尝试解决这些长期的痛点,并确保 Java 在云优先成为主流服务器端应用交付模式时仍具有竞争力和吸引力的编程环境。

其中一个主要方法是本地编译:将 Java 程序从字节码转换为编译后的机器码。由于这种编译发生在程序执行开始之前(就像 Rust 和 C++等语言一样),因此被称为预编译编译(AOT),或简称 AOT。这种技术在 Java 领域并不常见,但它旨在提供更快的启动时间,因为程序不需要进行类加载或 JIT 编译。然而,通常情况下它不会比在动态虚拟机模式下运行的同一应用程序提供更好的峰值性能。这是因为这里重点不在于峰值性能。AOT 和 JIT 代表了不同的策略和不同的权衡。

当前支持本地编译的主要工作是Oracle’s GraalVM。这是在 Oracle Labs 作为一个独立的研究项目开发的,但截至 2022 年底,Oracle 宣布计划向 OpenJDK 贡献部分内容。它有两个版本,一个是开源版,另一个是专有的企业版,后者有许可和支持成本。

GraalVM 包含一个名为 Graal 的编译器,它可以在 JIT 或 AOT 模式下运行。Graal 的 AOT 模式是 GraalVM 的 Native Image 技术的基础,可以从 Java 应用程序生成一个独立的编译后的机器码二进制文件。Graal 编译器的一个有趣之处在于它是用 Java 编写的,而不像 OpenJDK 中的 JIT 编译器那样是用本地代码实现的。

GraalVM 还包括 Truffle,一个用于 JVM 上语言的解释器生成框架。在 Truffle 之上编写的支持语言的解释器本身就是运行在 JVM 上的 Java 程序。已经支持多种非 Java 语言,如 JavaScript、Python、Ruby 和 R。

另一个致力于改进云原生支持的项目是Quarkus,一个专为 Kubernetes 云编排和部署堆栈设计的 Java 微服务框架。Quarkus 尝试通过广泛使用构建时处理来减少云原生的痛点影响。通常在启动过程中通过反射处理的昂贵计算和启动过程将尽可能提前执行。

Quarkus 还强调开发者体验,并提供响应式和命令式两种编程微服务的风格。

这个框架是开源且已经投入生产使用,并且有来自红帽的支持,红帽是该项目的主要维护者。它还包括对 GraalVM 开源版本的本地编译支持。不过,Quarkus 也可以在 OpenJDK 运行时的动态虚拟机模式上运行。

最后,我们还应该提到项目 Leyden。这是一个新的(2022 年 5 月)OpenJDK 项目,旨在为 Java 平台引入静态运行时镜像。该项目的名称来源于 18 世纪用于存储静电的“莱顿瓶”。其中一个关键点是封闭世界假设,即移除反射等动态运行时行为。

该项目仍处于早期阶段,但采用的方法与 GraalVM 有所不同(并更为谨慎);Leyden 的一个关键目标是能够有选择性地和灵活地限制和转移动态性。其意图是朝着 GraalVM 创建的 AOT 编译的本地镜像二进制文件类似的目标发展,但目前尚无迹象表明这些技术何时可能出现在 Java 的生产形式中。

posted @ 2024-06-15 12:22  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报