Java-12-编程学习手册-全-
Java 12 编程学习手册(全)
零、前言
本书的目的是让读者对 Java 基础有一个坚实的理解,并引导他们完成一系列从基础到实际编程的实际步骤。讨论和示例旨在通过使用经验证的编程原理和实践来刺激读者专业直觉的增长。这本书从基础知识开始,把读者带到最新的编程技术,从专业的角度考虑。
读完这本书后,你将能够做到以下几点:
- 安装并配置 Java 开发环境
- 安装并配置您的集成开发环境(IDE)——本质上就是您的编辑器
- 编写、编译和执行 Java 程序和测试
- 了解并使用 Java 语言基础
- 理解并应用面向对象的设计原则
- 掌握最常用的 Java 构造
- 了解如何从 Java 应用访问和管理数据库中的数据
- 增强您对网络编程的理解
- 了解如何添加图形用户界面,以便更好地与应用交互
- 熟悉函数式编程
- 了解最先进的数据处理技术流,包括并行和反应流
- 学习并练习创建微服务和构建反应式系统
- 学习最佳设计和编程实践
- 展望 Java 的未来,学习如何成为它的一部分
这本书是给谁的
这本书是为那些想在现代 Java 编程专业中开始一个新的职业生涯的人,以及那些已经从事这项工作并且想更新他们对最新 Java 和相关技术和思想的知识的人准备的。
这本书的内容
第 1 章“Java12 入门”从基础开始,首先解释什么是“Java”并定义其主要术语,然后继续介绍如何安装编写和运行(执行)程序所需的工具。本章还描述了基本的 Java 语言构造,并用可以立即执行的示例来说明它们。
第 2 章“面向对象编程(OOP)”介绍了面向对象编程的概念及其在 Java 中的实现。每一个概念都用具体的代码示例来演示。详细讨论了类和接口的 Java 语言结构,以及重载、覆盖、隐藏和使用final关键字,最后一节介绍了多态的威力。
第 3 章“Java 基础”向读者展示了 Java 作为一种语言的更详细的观点。它从包中的代码组织开始,描述类(接口)及其方法和属性(字段)的可访问性级别。本文详细介绍了 Java 面向对象特性的主要类型引用类型,然后列出了保留关键字和限制关键字,并讨论了它们的用法。本章最后介绍了原始类型之间的转换方法,以及从原始类型到相应引用类型的转换方法。
第 4 章“处理”向读者介绍了与异常处理相关的 Java 构造的语法以及处理(处理)异常的最佳实践。本章以可用于在生产中调试应用代码的断言语句的相关主题结束。
第 5 章、“字符串、输入/输出和文件”,讨论字符串类方法,以及来自标准库和 ApacheCommons 项目的流行字符串工具。下面概述了 Java 输入/输出流和java.io包的相关类以及org.apache.commons.io包的一些类。文件管理类及其方法在专门的一节中进行了描述。
第 6 章、“数据结构、泛型和流行工具”介绍了 Java 集合框架及其三个主要接口List、Set和Map,包括泛型的讨论和演示。equals()和hashCode()方法也在 Java 集合的上下文中讨论。用于管理数组、对象和时间/日期值的工具类也有相应的专用部分。
第 7 章、“Java 标准和外部库”概述了 Java 类库(JCL)最流行的包的功能:java.lang、java.util、java.time、java.io和java.nio、java.sql和javax.sql、java.net java.lang.math、java.math、java.awt、javax.swing和javafx。最流行的外部库是以org.junit、org.mockito、org.apache.log4j、org.slf4j和org.apache.commons包为代表的。本章帮助读者避免在已经存在此类功能并且可以直接导入和删除的情况下编写自定义代码开箱即用。
第 8 章、“多线程和并发处理”介绍了通过使用并发处理数据的 worker(线程)来提高 Java 应用性能的方法。它解释了 Java 线程的概念并演示了它们的用法。文中还讨论了并行处理和并发处理的区别,以及如何避免由于并发修改共享资源而导致的不可预知的结果。
第 9 章、“JVM 结构和垃圾收集”为读者提供了 JVM 结构和行为的概述,这些比我们通常预期的要复杂。其中一个服务线程被称为垃圾收集,它执行的一项重要任务是从未使用的对象中释放内存。阅读本章后,读者将更好地了解什么是 Java 应用执行、JVM 中的 Java 进程、垃圾收集以及 JVM 的总体工作原理。
第 10 章“管理数据库中的数据”,说明并演示如何管理,即从 Java 应用插入、读取、更新和删除数据库中的数据。本文还简要介绍了 SQL 语言和基本的数据库操作:如何连接到数据库,如何创建数据库结构,如何使用 SQL 编写数据库表达式,以及如何执行它们。
第 11 章、“网络编程”,描述和讨论了最流行的网络协议用户数据报协议(UDP)、传输控制协议(TCP)、超文本传输协议(HTTP)和 WebSocket 及其对 JCL 的支持。它演示了如何使用这些协议,以及如何在 Java 代码中实现客户端服务器通信。所审查的 API 包括基于 URL 的通信和最新的 JavaHTTPClient API。
第 12 章“Java GUI 编程”,概述 Java GUI 技术,并演示如何使用 JavaFX 工具包创建 GUI 应用。JavaFX 的最新版本不仅提供了许多有用的特性,还允许保留和嵌入遗留的实现和样式。
第 13 章、“函数式编程”,解释了什么是函数式接口,概述了 JDK 附带的函数式接口,定义并演示了 Lambda 表达式以及如何与函数式接口一起使用,包括使用方法引用。
第 14 章、“Java 标准流”讲述了数据流的处理,不同于第 5 章、“字符串、输入/输出、文件”中回顾的 I/O 流。它定义了什么是数据流,如何使用java.util.stream.Stream对象的方法(操作)处理它们的元素,以及如何在管道中链接(连接)流操作。本文还讨论了流的初始化以及如何并行处理流。
第 15 章“反应式编程”,介绍了反应式宣言和反应式编程的世界。首先定义和讨论了主要的相关概念-“异步”、“非阻塞”、“响应”等。然后使用它们定义并讨论了反应式编程、主要的反应式框架,并更详细地讨论了 RxJava。
第 16 章“微服务”解释了如何构建微服务——创建反应式系统的基础组件。它讨论了什么是微服务,它们可以有多大或多小,以及现有的微服务框架如何支持消息驱动的架构。讨论通过使用 Vert.x 工具箱构建的小型反应式系统的详细代码演示进行了说明。
第 17 章“Java 微基准线束”,介绍了“Java 微基准线束”(JMH)项目,该项目允许我们测量各种代码性能特征。它定义了什么是 JMH,如何创建和运行基准,基准参数是什么,并概述了支持的 IDE 插件。本章最后给出了一些实际的演示示例和建议。
第 18 章“编写高质量代码的最佳实践”,介绍了 Java 习惯用法以及设计和编写应用代码的最流行和最有用的实践。
第 19 章“Java 新特性”,讲述当前最重要的项目,这些项目将为 Java 添加新特性并在其他方面增强 Java。在阅读了本章之后,读者将了解如何遵循 Java 开发,并能够预见未来 Java 发行版的路线图。如果需要,读者也可以成为 JDK 源代码贡献者。
充分利用这本书
系统地阅读各章,并在每章末尾回答测验问题。克隆或只是下载源代码存储库(请参阅以下部分),然后运行演示所讨论主题的所有代码示例。为了加快编程速度,没有什么比执行提供的示例、修改它们和尝试自己的想法更好的了。密码就是真理。
下载示例代码文件
您可以从您的帐户下载本书的示例代码文件 www.packt.com。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
- 在登录或注册 www.packt.com
- 选择“支持”选项卡
- 点击代码下载和勘误表
- 在搜索框中输入图书名称,然后按照屏幕上的说明进行操作
下载文件后,请确保使用最新版本的解压缩或解压缩文件夹:
- 用于 Windows 的 WinRAR/7-Zip
- Mac 的 Zipeg/iZip/UnRarX
- 用于 Linux 的 7-Zip/PeaZip
这本书的代码包也托管在 GitHub 上。如果代码有更新,它将在现有 GitHub 存储库中更新。
我们的丰富书籍和视频目录中还有其他代码包,可在这个页面上找到。看看他们!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载。
使用的约定
这本书中使用了许多文本约定。
CodeInText:表示文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。下面是一个示例:“当在一个try块中抛出异常时,它将控制流重定向到第一个catch子句。”
代码块设置如下:
void someMethod(String s){
try {
method(s);
} catch (NullPointerException ex){
//do something
} catch (Exception ex){
//do something else
}
}
当我们希望提请您注意代码块的特定部分时,相关行或项以粗体显示:
class TheParentClass {
private int prop;
public TheParentClass(int prop){
this.prop = prop;
}
// methods follow
}
任何命令行输入或输出的编写方式如下:
--module-path /path/JavaFX/lib \
:-add-modules=javafx.controls,javafx.fxml
粗体:表示一个新术语、一个重要单词或屏幕上显示的单词。例如,菜单或对话框中的单词会像这样出现在文本中。下面是一个示例:“为项目 SDK(Java 版本 12,如果您已经安装了 JDK12)选择一个值,然后单击‘下一步’。”
警告或重要提示如下所示。
提示和窍门是这样出现的。
一、Java12 入门
本章介绍如何开始学习 Java12 和 Java。我们将从基础知识开始,首先解释什么是 Java 及其主要术语,然后介绍如何安装必要的工具来编写和运行(执行)程序。在这方面,Java12 与以前的 Java 版本没有太大区别,因此本章的内容也适用于旧版本。
我们将描述并演示构建和配置 Java 编程环境的所有必要步骤。这是最低限度,你必须在电脑上,以开始编程。我们还描述了基本的 Java 语言构造,并用可以立即执行的示例加以说明。
学习编程语言或任何语言的最好方法就是使用它,本章将指导读者如何使用 Java 实现这一点。本章涵盖的主题包括:
- 如何安装和运行 Java
- 如何安装并运行集成开发环境(IDE)
- Java 原始类型和运算符
- 字符串类型和字面值
- 标识符和变量
- Java 语句
如何安装和运行 Java
当有人说“Java”时,他们的意思可能完全不同:
- Java 程序设计语言:一种高级程序设计语言,允许以人类可读的格式表达意图(程序),并将其翻译成计算机可执行的二进制代码
- Java 编译器:一种程序,它能读取用 Java 编程语言编写的文本,并将其翻译成字节码,由 Java 虚拟机(JVM)解释成计算机可执行的二进制代码
- Java 虚拟机(JVM):一种程序,它读取已编译的 Java 程序,并将其解释为计算机可执行的二进制代码
- Java 开发工具包(JDK):程序(工具和工具)的集合,包括 Java 编译器、JVM 和支持库,允许编译和执行用 Java 语言编写的程序
下一节将引导读者完成 Java12 的 JDK 的安装以及基本的相关术语和命令
什么是 JDK?我们为什么需要它?
正如我们已经提到的,JDK 包括一个 Java 编译器和 JVM。编译器的任务是读取一个包含用 Java 编写的程序文本的.java文件(称为源代码),并将其转换(编译)为存储在.class文件中的字节码。然后 JVM 可以读取.class文件,将字节码解释为二进制代码,并将其发送到操作系统执行。编译器和 JVM 都必须从命令行显式调用
为了支持.java文件编译及其字节码的执行,JDK 安装还包括标准 Java 库 Java 类库(JCL)。如果程序使用第三方库,则在编译和执行过程中必须存在该程序。它必须从调用编译器的同一命令行中引用,然后在 JVM 执行字节码时引用。另一方面,JCL 不需要显式地引用。假设标准 Java 库位于 JDK 安装的默认位置,因此编译器和 JVM 知道在哪里找到它们
如果您不需要编译 Java 程序,只想运行已经编译的.class文件,可以下载安装 Java 运行时环境(JRE)。例如,它由 JDK 的一个子集组成,不包括编译器。
有时,JDK 被称为软件开发工具包(SDK),它是一组软件工具和支持库的总称,这些工具和库允许创建使用某种编程语言编写的源代码的可执行版本。因此,JDK 是一个用于 Java 的 SDK。这意味着可以将 JDK 称为 SDK。
您还可能听到与 JDK 相关的术语 Java 平台和 Java 版本。典型的平台是允许开发和执行软件程序的操作系统。由于 JDK 提供了自己的操作环境,因此也被称为平台。版是为特定目的组装的 Java 平台(JDK)的变体。有五个 Java 平台版本,如下所示:
- Java 平台标准版(Java SE):包括 JVM、JCL 等工具和工具。
- Java 平台企业版(Java EE):这包括 Java SE、服务器(为应用提供服务的计算机程序)、JCL 和其他库、代码示例、教程以及用于开发和部署大规模、多层和安全网络应用的其他文档。
- Java 平台微型版(Java ME):这是 Java SE 的一个子集,有一些专门的库,用于为手机、个人数字助理、电视机顶盒、打印机、传感器等嵌入式和移动设备开发和部署 Java 应用。JavaME 的一个变体(有自己的 JVM 实现)称为 AndroidSDK。它是由 Google 为 Android 编程开发的。
- Java Card:它是 Java 版本中最小的一个,用于在小型嵌入式设备(如智能卡)上开发和部署 Java 应用。它有两个版本:Java Card Classic Edition,用于智能卡,基于 ISO7816 和 ISO14443 通信;以及 Java Card Connected Edition,支持 Web 应用模型和 TCP/IP 作为基本协议,运行在高端安全微控制器上。
所以,安装 Java 就意味着安装 JDK,这也意味着在其中一个版本上安装 Java 平台,在本书中,我们只讨论和使用 JavaSE。
安装 Java SE
所有最近发布的 JDK 都列在 Oracle 官方页面上:www.oracle.com/technetwork/java/javase/overview/index.html(我们将其称为安装主页,以供进一步参考)。
以下是安装 Java SE 需要遵循的步骤:
- 找到要查找的 JavaSE 版本的链接(本例中是 JavaSE12)并单击它。
- 您将看到各种链接,其中之一是安装说明。或者,您可以通过单击下载选项卡访问此页面。
- 单击标题为 OracleJDK 的下载链接。
- 一个新的屏幕将提供一个单选按钮和指向各种 JDK 安装程序的链表,供您选择接受或拒绝许可协议。
- 阅读许可协议并做出决定。如果您不接受它,就不能下载 JDK。如果您接受许可协议,您可以从可用列表中选择 JDK 安装程序。
- 您需要选择适合您的操作系统的安装程序和您熟悉的格式(扩展名)。
- 如果有疑问,请返回安装主页,选择下载选项卡,然后单击安装说明链接。
- 按照与您的操作系统相对应的步骤进行操作。
- 当您计算机上的
java -version命令显示正确的 Java 版本时,JDK 安装成功,如下面的屏幕截图所示:

命令、工具和工具
如果按照安装说明进行操作,您可能已经注意到目录下给出的链接(JDK 的已安装目录结构)。它将带您进入一个页面,该页面描述已安装的 JDK 在您的计算机上的位置以及 JDK 根目录的每个目录的内容。bin目录包含构成 Java 命令、工具和工具的所有可执行文件。如果目录bin没有自动添加到环境变量PATH,请考虑手动添加,这样您就可以从任何目录启动 Java 可执行文件。
在上一节中,我们已经演示了Java命令java -version。其他可用 Java 可执行文件(命令、工具、和工具)可以在 JavaSE 文档中找到。点击链接 Java 平台标准版技术文档站点,然后点击下一页的链接工具参考。您可以通过单击每个可执行工具的链接来了解其更多信息。
您还可以使用以下选项之一在计算机上运行列出的每个可执行文件:-?、-h、--help或-help。它将显示可执行文件及其所有选项的简要说明。
最重要的 Java 命令如下:
javac:根据.java文件中定义了多少 Java 类,读取.java文件,编译并创建一个或多个相应的.class文件。java:执行.class文件。
这些命令使编程成为可能。每个 Java 程序员都必须很好地理解自己的结构和功能。但是,如果您对 Java 编程不熟悉,并且使用 IDE(请参阅“如何安装和运行 IDE”一节),则不需要立即掌握这些命令。一个好的 IDE 通过在每次更改时自动编译一个.java文件来隐藏它们。它还提供了一个图形元素,可以在每次单击它时运行程序。
另一个非常有用的 Java 工具是jcmd。它有助于与当前运行的任何 Java 进程(JVM)进行通信和诊断,并且有许多选项。但是在最简单的形式中,没有任何选项,它列出了当前运行的所有 Java 进程及其进程 ID(PID)。您可以使用它来查看是否已经运行了 Java 进程。如果有,那么可以使用提供的 PID 终止这样的进程。
如何安装和运行 IDE
曾经只是一个专门的编辑器,允许像 Word 编辑器检查英语句子的语法一样检查书面程序的语法,逐渐演变成一个集成开发环境(IDE)。它的主要功能在名称上。它集成了在一个图形用户界面(GUI)下编写、编译和执行程序所需的所有工具。利用 Java 编译器的强大功能,IDE 可以立即识别语法错误,然后通过提供上下文相关的帮助和建议来帮助提高代码质量
选择 IDE
Java 程序员可以使用几种 IDE,如 NetBeans、Eclipse、IntelliJ IDEA、BlueJ、DrJava、JDeveloper、JCreator、jEdit、JSource、jCRASP 和 jEdit 等等。最流行的是 NetBeans、Eclipse 和 IntelliJ IDEA。
NetBeans 开发始于 1996 年,是布拉格查尔斯大学的一个 JavaIDE 学生项目。1999 年,该项目和围绕该项目创建的公司被 Sun Microsystems 收购。在甲骨文收购 Sun Microsystems 之后,NetBeans 成为了开源软件,许多 Java 开发人员也为这个项目做出了贡献。它与 JDK8 捆绑在一起,成为 Java 开发的官方 IDE。2016 年,Oracle 将其捐赠给了 Apache 软件基金会。
有一个用于 Windows、Linux、Mac 和 Oracle Solaris 的 NetBeans IDE。它支持多种编程语言,并可以扩展插件。NetBeans 只与 JDK8 捆绑在一起,但是 netbeans8.2 也可以与 JDK9 一起工作,并使用 JDK9 引入的特性,例如 Jigsaw。在上 netbeans.apache.org,您可以阅读更多关于 NetBeans IDE 的信息,并下载最新版本,截至本文撰写之时,该版本为 11.0。
Eclipse 是使用最广泛的 JavaIDE。向 IDE 添加新特性的插件列表在不断增长,因此无法列举 IDE 的所有功能。EclipseIDE 项目从 2001 年开始作为开源软件开发。一个非营利性的、成员支持的企业 Eclipse 基金会在 2004 创建,目的是提供基础设施(版本控制系统、代码审查系统、构建服务器、下载站点等等)和结构化的过程。基金会 30 多岁的员工中,没有一个人在从事 150 个 Eclipse 支持的项目。
EclipseIDE 插件的数量和种类之多对初学者来说是一个挑战,因为您必须找到解决相同或类似特性的不同实现的方法,这些实现有时可能是不兼容的,并且可能需要深入的调查以及对所有依赖项的清楚理解。尽管如此,eclipseIDE 还是非常流行,并且有可靠的社区支持。您可以阅读有关 eclipseIDE 的内容,并从下载最新版本 www.eclipse.org/ide。
IntelliJ 有两个版本:付费版和免费社区版。付费版一直被评为最佳 Java IDE,但社区版也被列为三大主要 Java IDE 之一。开发该 IDE 的 JetBrains 软件公司在布拉格、圣彼得堡、莫斯科、慕尼黑、波士顿和新西伯利亚设有办事处。IDE 以其深刻的智能而闻名,即“在每一个上下文中都给出相关的建议:即时而巧妙的代码完成、动态的代码分析和可靠的重构工具”,正如作者在其网站上描述产品时所说。在“安装和配置 IntelliJ IDEA”部分,我们将引导您完成 IntelliJ IDEA 社区版的安装和配置。
安装和配置 IntelliJ IDEA
下载并安装 IntelliJ IDEA 需要遵循以下步骤:
-
启动安装程序并接受所有默认值。
-
在安装选项屏幕上选择.java。我们假设您已经安装了 JDK,所以您不必检查下载和安装 JRE 选项。
-
最后一个安装屏幕有一个复选框 Run IntelliJ IDEA,您可以选中它来自动启动 IDE。或者,您可以不选中该复选框,并在安装完成后手动启动 IDE。
-
当 IDE 第一次启动时,它会询问您是否要导入 IntelliJ IDEA 设置。如果您以前没有使用过 IntelliJ IDEA 并且希望重用设置,请选中“不导入设置”复选框。
-
下面的一两个屏幕询问您是否接受 JetBrains 隐私政策,以及您是否愿意支付许可证费用,还是愿意继续使用免费社区版或免费试用版(这取决于您获得的特定下载)。
-
以您喜欢的方式回答问题,如果您接受隐私策略,CustomizeIntelliJ IDEA 屏幕将要求您选择一个主题,白色(IntelliJ)或黑色(Darcula)。
-
如果提供了“全部跳过”和“设置默认值”以及“下一步:默认插件”按钮,请选择“下一步:默认插件”,因为它将为您提供预先配置 IDE 的选项。
-
在任务屏幕上显示“调整”想法时,请为以下三个选项选择“自定义…”链接,每次一个:
-
如果您决定更改设置值,您可以稍后通过从最顶部的菜单文件、Windows 上的设置或 Linux 和 MacOS 上的首选项进行选择。
创建项目
在开始编写程序之前,您需要创建一个项目。在 IntelliJ IDEA 中创建项目有几种方法,对于任何 IDE 都是一样的,如下所示:
- 创建新项目:这将从头开始创建一个新项目。
- 导入项目:这允许从文件系统读取现有的源代码。
- 打开:这允许从文件系统读取现有项目。
- 从版本控制签出:这允许从版本控制系统读取现有项目。
在本书中,我们将仅使用 IDE 提供的一系列引导步骤来引导您完成第一个选项。另外两个选项要简单得多,不需要额外的解释。一旦您学会了如何从头开始创建一个新项目,在 IDE 中创建项目的其他方法将非常简单。
首先单击“创建新项目”链接,然后按以下步骤继续操作:
-
为项目 SDK 选择一个值(Java 版本 12,如果您已经安装了 JDK12),然后单击“下一步”。
-
不要选中“创建项目模板”(如果选中,IDE 会生成一个固定程序
Hello world和类似的程序,我们不需要),然后单击“下一步”。 -
在“项目位置”字段中选择所需的项目位置(这是新代码将驻留的位置)。
-
在“项目名称”字段中输入您喜欢的任何内容(例如,本书中代码的项目名为
learnjava,然后单击Finish按钮。 -
您将看到以下项目结构:

- 右键单击项目名称(
learnjava),从下拉菜单中选择添加框架支持。在以下弹出窗口中,选择 Maven:

- Maven 是一个项目配置工具。它的主要功能是管理项目依赖关系。我们稍后再谈。现在,我们将使用它的另一个职责,使用三个属性来定义和保持项目代码标识:
主要目标是使一个项目的身份在世界上所有项目中独一无二。为了避免groupId冲突,约定要求从相反的组织域名开始构建。例如,如果一个公司的域名是company.com,那么它的项目的组 ID 应该以com.company开头。这就是为什么在本书的代码中,我们使用了groupId值com.packt.learnjava。
我们开始吧。在弹出的“添加框架支持”窗口中,单击〖确定〗按钮,系统将弹出一个新生成的pom.xml文件,如下所示:

同时,在屏幕右下角会弹出另一个小窗口:

单击“启用自动导入”链接。这将使编写代码更容易:您将开始使用的所有新类都将自动导入。我们将在适当的时候讨论类导入
现在让我们输入groupId、artifactId和version值:

现在,如果有人想在他们的应用中使用您的项目代码,他们会通过显示的三个值引用它,Maven(如果他们使用它)会将它引入(当然,如果您将您的项目上传到公共共享的 Maven 存储库中)。在这个页面上阅读更多关于 Maven 的信息。
groupId值的另一个功能是定义保存项目代码的文件夹树的根目录。我们打开src文件夹,您将看到下面的目录结构:

main下的java文件夹保存应用代码,test下的java文件夹保存测试代码。
让我们使用以下步骤创建第一个程序:
- 右键点击
java,选择新建,点击打包:

- 在提供的新包窗口中,键入
com.packt.learnjava.ch01_start如下:

- 单击 OK,您应该会在左侧面板中看到一组新文件夹,其中最后一个是
com.packt.learnjava.ch01_start:

- 右键单击它,选择“新建”,然后单击“Java 类”:

- 在提供的输入窗口中,键入
PrimitiveTypes:

- 单击 OK,您将看到在包
com.packt.learnjava.ch01_start包中创建的第一个 Java 类PrimitiveTypes:

包反映了文件系统中 Java 类的位置。我们将在第二章“Java 面向对象编程”中讨论。现在,为了运行一个程序,我们创建了一个main()方法。如果存在,可以执行此方法并将其作为应用的入口点。它有一定的格式,如下所示:

它必须具有以下属性:
public:可从包外自由进入static:应该能够在不创建所属类的对象的情况下被调用
还应包括以下内容:
- 返回
void(无)。 - 接受一个
String数组作为输入,或者像我们所做的那样接受varargs。我们将在第二章“Java 面向对象编程(OOP)”中讨论varargs。现在,只需说String[] args和String... args定义了本质上相同的输入格式
我们在“执行来自于命令行的例子”部分中解释了如何使用命令行来运行主类。您可以在 Oracle 官方文档中阅读更多关于 Java 命令行参数的信息。也可以运行 IntelliJ IDEA 中的示例。
注意下面截图中左边的两个绿色三角形。点击其中任何一个,就可以执行main()方法。例如,让我们显示Hello, world!。
为此,请在main()方法内键入以下行:
System.out.println("Hello, world!");
然后,单击其中一个绿色三角形:

您应该在终端区域获得如下输出:

从现在开始,每次讨论代码示例时,我们都将使用main()方法以相同的方式运行它们。在进行此操作时,我们将不捕获屏幕截图,而是将结果放在注释中,因为这样的样式更容易遵循。例如,以下代码显示了以前的代码演示在这种样式下的外观:
System.out.println("Hello, world!"); //prints: Hello, world!
可以在代码行右侧添加注释(任意文本),该行的右键以双斜杠//分隔。编译器不读取此文本,只保留它的原样。注释的存在不会影响性能,并用于向人类解释程序员的意图。
导入项目
在本节中,我们将演示使用本书的源代码将现有代码导入 IntelliJ IDEA 的过程。我们假设您已经在你的电脑上安装了 Maven 也安装了 Git,可以使用。我们还假设您已经安装了 JDK12,正如在 JavaSE 的“安装”一节中所描述的那样。
要使用本书的代码示例导入项目,请执行以下步骤:
- 转到源库,点击克隆或下载链接,如下图所示:

- 单击克隆或下载链接,然后复制提供的 URL:

- 在计算机上选择要放置源代码的目录,然后运行以下 Git 命令:

- 新建
Learn-Java-12-Programming文件夹,如下图所示:

或者,您可以使用前面屏幕截图上显示的链接下载 ZIP 将源代码下载为一个.zip文件,而不是克隆。将下载的源代码解压到计算机上希望放置源代码的目录中,然后通过从名称中删除后缀-master来重命名新创建的文件夹,确保文件夹的名称为Learn-Java-12-Programming。
- 新的
Learn-Java-12-Programming文件夹包含 Maven 项目以及本书中的所有源代码。现在运行 IntelliJ IDEA 并单击最顶部菜单中的“文件”,然后单击“新建”和“从现有源项目…”:

- 选择步骤 4 中创建的
Learn-Java-12-Programming文件夹,点击打开按钮:

- 接受默认设置并单击以下每个屏幕上的“下一步”按钮,直到出现显示已安装 JDK 列表和“完成”按钮的屏幕:

- 选择
12并点击“完成”。您将看到项目导入到 IntelliJ IDEA 中:

- 等待右下角出现以下小窗口:

您可能不想等待并继续执行步骤 12。当窗口稍后弹出时,只需执行步骤 10 和 11。如果错过此窗口,您可以随时单击事件日志链接,系统将向您显示相同的选项。
- 单击它;然后单击“添加为 Maven”项目链接:

- 每当出现以下窗口时,请单击启用自动导入:

您可能不想等待并继续执行步骤 12。当窗口稍后弹出时,只需执行步骤 11。如果错过此窗口,您可以随时单击事件日志链接,系统将向您显示相同的选项。
- 选择项目结构符号,它是以下屏幕截图右侧的第三个:

- 如果列出了主模块和测试模块,请通过高亮显示它们并单击减号(
-)来删除它们,如下屏幕所示:

- 下面是模块的最终列表:

- 单击右下角的“确定”返回项目。单击左窗格中的“Learn-Java-12-Programming”,继续在源代码树中向下,直到看到以下类列表:

- 单击右窗格中的绿色箭头并执行所需的任何类。在“运行”窗口中可以看到的结果类似于以下内容:

从命令行执行示例
要从命令行执行示例,请执行以下步骤:
- 转到“导入项目”部分“步骤 4”中创建的
Learn-Java-12-Programming文件夹pom.xml文件所在位置,运行mvn clean package命令:

- 选择要运行的示例。例如,假设要运行
ControlFlow.java,请运行以下命令:
java -cp target/learnjava-1.0-SNAPSHOT.jar:target/libs/* \
com.packt.learnjava.ch01_start.ControlFlow
您将看到以下结果:

- 如果要运行
ch05_stringsIoStreams包中的示例文件,请使用不同的包和类名运行相同的命令:
java -cp target/learnjava-1.0-SNAPSHOT.jar:target/libs/* \
com.packt.learnjava.ch05_stringsIoStreams.Files
如果您的计算机有 Windows 系统,请使用以下命令作为一行:
java -cp target\learnjava-1.0-SNAPSHOT.jar;target\libs\* com.packt.learnjava.ch05_stringsIoStreams.Files
请注意,Windows 命令具有不同的斜杠和分号(;)作为类路径分隔符。
- 结果如下:

- 这样,您就可以运行任何包含
main()方法的类。将执行main()方法的内容。
Java 原始类型和运算符
有了所有主要的编程工具,我们就可以开始把 Java 作为一种语言来讨论了。语言语法由 Java 语言规范定义,您可以在这个页面上找到。每次你需要澄清的时候,请不要犹豫参考它。这并不像很多人想象的那么令人畏惧
Java 中的所有值都分为两类:reference类型和primitive类型。我们从基本类型和运算符开始,作为任何编程语言的自然入口点,在本章中,我们还将讨论一种称为String的引用类型(参见“字符串类型和字面值”部分)
所有的原始类型都可以分为两类:boolean类型和numeric类型。
布尔型
Java 中只有两个boolean类型值:true和false。这样的值只能分配给一个boolean类型的变量,例如:
boolean b = true;
boolean变量通常用于控制流语句中,我们将在“Java 语句”一节中讨论。下面是一个例子:
boolean b = x > 2;
if(b){
//do something
}
在代码中,我们将x > 2表达式的求值结果赋给b变量。如果x的值大于2,则b变量得到赋值true。然后执行大括号内的代码{}。
数字类型
Java 数字类型形成两组:整数型(byte、char、short、int、long)和浮点型(float和double)。
整数类型
整数类型消耗的内存量如下:
byte:8 位char:16 位short:16 位int:32 位long:64 位
char类型是一个无符号整数,它可以保存 0 到 65535 之间的值(称为码位)。它表示一个 Unicode 字符,这意味着有 65536 个 Unicode 字符。以下是构成 Unicode 字符基本拉丁列表的三条记录:
| 码位 | Unicode 转义 | 可打印符号 | 说明 |
|---|---|---|---|
33 |
\u0021 |
! |
感叹号 |
50 |
\u0032 |
2 |
数字二 |
65 |
\u0041 |
A |
拉丁文大写字母 A |
下面的代码演示了char类型的属性:
char x1 = '\u0032';
System.out.println(x1); //prints: 2
char x2 = '2';
System.out.println(x2); //prints: 2
x2 = 65;
System.out.println(x2); //prints: A
char y1 = '\u0041';
System.out.println(y1); //prints: A
char y2 = 'A';
System.out.println(y2); //prints: A
y2 = 50;
System.out.println(y2); //prints: 2
System.out.println(x1 + x2); //prints: 115
System.out.println(x1 + y1); //prints: 115
代码示例的最后两行解释了为什么将char类型视为整数类型,因为char值可以用于算术运算。在这种情况下,每个char值由其代码点表示。
其他整数类型的取值范围如下:
byte:从-128到127包括short:从-32,768到32,767包括int:从-2.147.483.648到2.147.483.647包括long:从-9,223,372,036,854,775,808到9,223,372,036,854,775,807包括
始终可以从相应的 Java 常量中检索每个原始类型的最大值和最小值,如下所示:
System.out.println(Byte.MIN_VALUE); //prints: -128
System.out.println(Byte.MAX_VALUE); //prints: 127
System.out.println(Short.MIN_VALUE); //prints: -32768
System.out.println(Short.MAX_VALUE); //prints: 32767
System.out.println(Integer.MIN_VALUE); //prints: -2147483648
System.out.println(Integer.MAX_VALUE); //prints: 2147483647
System.out.println(Long.MIN_VALUE); //prints: -9223372036854775808
System.out.println(Long.MAX_VALUE); //prints: 9223372036854775807
System.out.println((int)Character.MIN_VALUE); //prints: 0
System.out.println((int)Character.MAX_VALUE); //prints: 65535
最后两行中的构造(int)是转换操作符用法的一个示例。它强制将值从一种类型转换为另一种类型,但这种转换并不总是保证成功。从我们的示例中可以看到,某些类型允许比其他类型更大的值。但是程序员可能知道某个变量的值永远不会超过目标类型的最大值,而转换操作符是程序员将自己的观点强加给编译器的方式。否则,如果没有转换运算符,编译器将引发错误,并且不允许赋值。但是,程序员可能会弄错,值可能会变大。在这种情况下,将在执行期间引发运行时错误。
但有些类型原则上不能转换为其他类型,或者至少不能转换为所有类型。例如,boolean类型值不能转换为整型值。
浮点类型
这组原始类型中有两种类型,float和double:
float:32 位doubele:64 位
其正最大和最小可能值如下:
System.out.println(Float.MIN_VALUE); //prints: 1.4E-45
System.out.println(Float.MAX_VALUE); //prints: 3.4028235E38
System.out.println(Double.MIN_VALUE); //prints: 4.9E-324
System.out.println(Double.MAX_VALUE); //prints: 1.7976931348623157E308
最大和最小负值与刚才显示的值相同,只是前面有一个减号(-。因此,实际上,Float.MIN_VALUE和Double.MIN_VALUE不是最小值,而是对应类型的精度。对于每种浮点类型,零值可以是0.0或-0.0
浮点型的特点是有一个点(.),它将数字的整数部分和小数部分分开。默认情况下,在 Java 中,带点的数字被假定为double类型。例如,假设以下为双精度值:
42.3
这意味着以下赋值会导致编译错误:
float f = 42.3;
要表示您希望将其视为float类型,需要添加f或F。例如,以下分配不会导致错误:
float f = 42.3f;
float d = 42.3F;
double a = 42.3f;
double b = 42.3F;
float x = (float)42.3d;
float y = (float)42.3D;
正如您可能已经从示例中注意到的,d和D表示double类型。但我们能够将它们转换成float型,因为我们确信42.3完全在float型可能值的范围内。
基本类型的默认值
在某些情况下,即使程序员不想这样做,也必须给变量赋值。我们将在第 2 章、“Java 面向对象编程(OOP)”中讨论这种情况。在这种情况下,默认的原始类型值如下所示:
byte、short、int和long类型具有默认值0。char类型的默认值为\u0000,代码点为0float和double类型具有默认值0.0。boolean类型有默认值false。
原始类型的字面值
值的表示称为字面值。boolean类型有两个文本:true和false。byte、short、int、long整数类型的字面值默认为int类型:
byte b = 42;
short s = 42;
int i = 42;
long l = 42;
另外,为了表示一个long类型的文本,您可以在后面加上字母l或L:
long l1 = 42l;
long l2 = 42L;
字母l很容易与数字 1 混淆,因此为此使用L(而不是l)是一种好的做法。
到目前为止,我们已经用十进制表示整数字面值。同时,byte、short、int和long类型的字面值也可以用二进制(以 2 为基数,数字 0-1)、八进制(以 8 为基数,数字 0-7)和十六进制(以 16 为基数,数字 0-9 和 a-f)数制表示。二进制字面值以0b(或0B开头,后跟二进制表示的值。例如,小数点42表示为101010 = 2^0*0 + 2^1*1 + 2^2*0 + 2^3 *1 + 2^4 *0 + 2^5 *1(我们从右边0开始)。八进制字面值以0开头,后跟八进制表示的值,因此42表示为52 = 8^0*2+ 8^1*5。十六进制字面值以0x(或0X开头),后跟以十六进制表示的值。因此,42被表示为2a = 16^0*a + 16^1*2,因为在十六进制系统中,a到f(或A到F)的符号映射到十进制值10到15。下面是演示代码:
int i = 42;
System.out.println(Integer.toString(i, 2)); // 101010
System.out.println(Integer.toBinaryString(i)); // 101010
System.out.println(0b101010); // 42
System.out.println(Integer.toString(i, 8)); // 52
System.out.println(Integer.toOctalString(i)); // 52
System.out.println(052); // 42
System.out.println(Integer.toString(i, 10)); // 42
System.out.println(Integer.toString(i)); // 42
System.out.println(42); // 42
System.out.println(Integer.toString(i, 16)); // 2a
System.out.println(Integer.toHexString(i)); // 2a
System.out.println(0x2a); // 42
如您所见,Java 提供了将十进制系统值转换为具有不同基的系统的方法。所有这些数值表达式都称为字面值。
数字字面值的一个特点是对人友好。如果数字较大,可以将其分成三个部分,用下划线(_符号)分隔。例如,请注意以下事项:
int i = 354_263_654;
System.out.println(i); //prints: 354263654
float f = 54_436.98f;
System.out.println(f); //prints: 54436.98
long l = 55_763_948L;
System.out.println(l); //prints: 55763948
编译器忽略嵌入的下划线符号。
char型字面值分为两种:单字符或转义序列。在讨论数字类型时,我们看到了char型字面值的示例:
char x1 = '\u0032';
char x2 = '2';
char y1 = '\u0041';
char y2 = 'A';
如您所见,字符必须用单引号括起来
转义序列以反斜杠(\)开头,后跟字母或其他字符。以下是转义序列的完整列表:
\b:退格BS、Unicode 转义\u0008\t:水平制表符HT、Unicode 转义符\u0009\n:换行LF、Unicode 转义\u000a\f:表单馈送FF、Unicode 转义\u000c\r:回车CR,Unicode 转义\u000d\":双引号",Unicode 转义\u0022\':单引号',Unicode 转义\u0027\\:反斜杠\、Unicode escape \u005c
在八个转义序列中,只有最后三个用符号表示。如果无法以其他方式显示此符号,则使用它们。例如,请注意以下事项:
System.out.println("\""); //prints: "
System.out.println('\''); //prints: '
System.out.println('\\'); //prints: \
其余部分更多地用作控制代码,用于指示输出设备执行某些操作:
System.out.println("The back\bspace"); //prints: The bacspace
System.out.println("The horizontal\ttab"); //prints: The horizontal tab
System.out.println("The line\nfeed"); //prints: The line
// feed
System.out.println("The form\ffeed"); //prints: The form feed
System.out.println("The carriage\rreturn");//prints: return
如您所见,\b删除前一个符号,\t插入制表符空间,\n断开线开始新符号,\f迫使打印机弹出当前页,继续在另一页顶部打印,/r重新启动当前行。
新的紧凑数字格式
java.text.NumberFormat类以各种格式表示数字。它还允许根据所提供的格式(包括区域设置)调整格式。称为压缩或短数字格式。
它以特定于语言环境的可读形式表示一个数字。例如,请注意以下事项:
NumberFormat fmt = NumberFormat.getCompactNumberInstance(Locale.US,
NumberFormat.Style.SHORT);
System.out.println(fmt.format(42_000)); //prints: 42K
System.out.println(fmt.format(42_000_000)); //prints: 42M
NumberFormat fmtP = NumberFormat.getPercentInstance();
System.out.println(fmtP.format(0.42)); //prints: 42%
如您所见,要访问此功能,您必须获取NumberFormat类的特定实例,有时还需要基于区域设置和提供的样式。
运算符
Java 中有 44 个运算符,如下表所示:
| 运算符 | 说明 |
|---|---|
+``-``*``/``% |
算术一元和二元运算符 |
++``-- |
递增和递减一元运算符 |
==``!= |
相等运算符 |
<``>``<=``>= |
关系运算符 |
!``&``| |
逻辑运算符 |
&&``||``?: |
条件运算符 |
=``+=``-=``*=``/=``%= |
分配运算符 |
&=``|=``^=``<<=``>>=``>>>= |
赋值运算符 |
&``|``~``^``<<``>>``>>> |
位操作符 |
->``:: |
箭头和方法引用运算符 |
new |
实例创建操作符 |
. |
字段访问/方法调用运算符 |
instanceof |
类型比较运算符 |
| (目标类型) | 铸造操作工 |
我们将不描述不常用的赋值运算符&=、|=、^=、<<=、>>=、>>>=和位运算符。您可以在 Java 规范中了解它们。箭头->和方法引用::运算符将在第 14 章、“函数式编程”中描述。实例创建操作符new、字段访问/方法调用操作符.和类型比较操作符instanceof将在第 2 章、“Java 面向对象编程(OOP)”中讨论。至于cast运算符,我们已经在“整数类型”一节中描述过了。
算术一元(+和-)和二元运算符(+、-、*、/和%)
大多数算术运算符和正负号(一元运算符)我们都很熟悉。模运算符%将左操作数除以右操作数,并返回余数,如下所示:
int x = 5;
System.out.println(x % 2); //prints: 1
另外值得一提的是,Java 中两个整数的除法会丢失小数部分,因为 Java 假定结果应该是整数 2,如下所示:
int x = 5;
System.out.println(x / 2); //prints: 2
如果需要保留结果的小数部分,请将其中一个操作数转换为浮点类型。以下是实现这一目标的几种方法:
int x = 5;
System.out.println(x / 2.); //prints: 2.5
System.out.println((1\. * x) / 2); //prints: 2.5
System.out.println(((float)x) / 2); //prints: 2.5
System.out.println(((double) x) / 2); //prints: 2.5
递增和递减一元运算符(++和--)
++运算符将整型的值增加1,而--运算符将整型的值减少1,如果放在变量前面(前缀),则在返回变量值之前将其值更改 1。但是当放在变量后面(后缀)时,它会在返回变量值后将其值更改为1。以下是几个例子:
int i = 2;
System.out.println(++i); //prints: 3
System.out.println(i); //prints: 3
System.out.println(--i); //prints: 2
System.out.println(i); //prints: 2
System.out.println(i++); //prints: 2
System.out.println(i); //prints: 3
System.out.println(i--); //prints: 3
System.out.println(i); //prints: 2
相等运算符(==和!=)
==运算符表示等于,而!=运算符表示不等于。它们用于比较同一类型的值,如果操作数的值相等,则返回boolean值true,否则返回false。例如,请注意以下事项:
int i1 = 1;
int i2 = 2;
System.out.println(i1 == i2); //prints: false
System.out.println(i1 != i2); //prints: true
System.out.println(i1 == (i2 - 1)); //prints: true
System.out.println(i1 != (i2 - 1)); //prints: false
但是,在比较浮点类型的值时,尤其是在比较计算结果时,要小心。在这种情况下,使用关系运算符(<、>、<=和>=)更可靠,因为例如,除法1/3会产生一个永无止境的小数部分0.33333333...,并最终取决于精度实现(这是一个复杂的主题,超出了本书的范围)。
关系运算符(<、>、<=和>=)
关系运算符比较值并返回一个boolean值。例如,观察以下情况:
int i1 = 1;
int i2 = 2;
System.out.println(i1 > i2); //prints: false
System.out.println(i1 >= i2); //prints: false
System.out.println(i1 >= (i2 - 1)); //prints: true
System.out.println(i1 < i2); //prints: true
System.out.println(i1 <= i2); //prints: true
System.out.println(i1 <= (i2 - 1)); //prints: true
float f = 1.2f;
System.out.println(i1 < f); //prints: true
逻辑运算符(!,&和|)
逻辑运算符的定义如下:
- 如果操作数是
false,则!二进制运算符返回true,否则返回false。 - 如果两个操作数都是
true,&二进制运算符返回true。 - 如果至少有一个操作数是
true,则|二进制运算符返回true。
举个例子:
boolean b = true;
System.out.println(!b); //prints: false
System.out.println(!!b); //prints: true
boolean c = true;
System.out.println(c & b); //prints: true
System.out.println(c | b); //prints: true
boolean d = false;
System.out.println(c & d); //prints: false
System.out.println(c | d); //prints: true
条件运算符(&&、||和?:)
&&和||运算符产生的结果与我们刚才演示的&和|逻辑运算符相同:
boolean b = true;
boolean c = true;
System.out.println(c && b); //prints: true
System.out.println(c || b); //prints: true
boolean d = false;
System.out.println(c && d); //prints: false
System.out.println(c || d); //prints: true
不同之处在于&&和||运算符并不总是求值第二个操作数,例如,在&&运算符的情况下,如果第一个操作数是false,则不求值第二个操作数,因为整个表达式的结果无论如何都是false。类似地,在||运算符的情况下,如果第一个操作数是true,则整个表达式将被清楚地求值为true,而不求值第二个操作数。我们可以用以下代码来演示:
int h = 1;
System.out.println(h > 3 & h++ < 3); //prints: false
System.out.println(h); //prints: 2
System.out.println(h > 3 && h++ < 3); //prints: false
System.out.println(h); //prints: 2
? :运算符称为三元运算符。它计算一个条件(在符号?之前),如果结果是true,则将第一个表达式(在?和:符号之间)计算的值赋给变量;否则,将第二个表达式(在:符号之后)计算的值赋给变量:
int n = 1, m = 2;
float k = n > m ? (n * m + 3) : ((float)n / m);
System.out.println(k); //prints: 0.5
赋值运算符(=,+=,-=,*=,/=和%=)
=运算符只是将指定的值赋给一个变量:
x = 3;
其他赋值运算符在赋值前计算新值:
x += 42将表达式x = x + 42的结果赋给x。x -= 42将表达式x = x - 42的结果赋给x。x *= 42将表达式x = x * 42的结果赋给x。x /= 42将表达式x = x / 42的结果赋给x。x %= 42赋值表达式x = x + x % 42的剩余部分。
以下是这些运算符的工作方式:
float a = 1f;
a += 2;
System.out.println(a); //prints: 3.0
a -= 1;
System.out.println(a); //prints: 2.0
a *= 2;
System.out.println(a); //prints: 4.0
a /= 2;
System.out.println(a); //prints: 2.0
a %= 2;
System.out.println(a); //prints: 0.0
字符串类型和字面值
我们刚刚描述了 Java 语言的基本值类型。Java 中的所有其他值类型都属于一类引用类型。每个引用类型都是一个比值更复杂的构造。它由类来描述,该类用作创建对象的模板,该对象是包含在该类中定义的值和方法(处理代码)的存储区域。一个对象是由new操作符创建的。我们将在第 2 章"Java 面向对象编程"中更详细地讨论类和对象
在本章中,我们将讨论一种称为String的引用类型。它由java.lang.String类表示,正如您所看到的,它属于 JDK 最基本的包java.lang。我们之所以在早期引入String类,是因为它在某些方面的行为与原始类型非常相似,尽管它是一个引用类型。
之所以称为引用类型,是因为在代码中,我们不直接处理此类型的值。引用类型的值比原始类型的值更复杂。它称为对象,需要更复杂的内存分配,因此引用类型变量包含内存引用。它指向对象所在的内存区域,因此得名。
当引用类型变量作为参数传递到方法中时,需要特别注意引用类型的这种性质。我们将在第 3 章、“Java 基础”中详细讨论。现在,关于String,我们将看到String作为引用类型如何通过只存储一次每个String值来优化内存使用。
字符串常量
String类表示 Java 程序中的字符串。我们见过好几根这样的弦。例如,我们看到了Hello, world!。那是一个String字。
字面值的另一个例子是null。任何引用类都可以引用文本null。它表示不指向任何对象的引用值。在String类型的情况下,显示如下:
String s = null;
但是由双引号("abc"、"123"、"a42%$#"括起来的字符组成的文本只能是String类型。在这方面,String类作为引用类型,与原始类型有一些共同点。所有的String字面值都存储在一个称为字符串池的专用内存段中,两个字面值的拼写相同,表示池中的相同值:
String s1 = "abc";
String s2 = "abc";
System.out.println(s1 == s2); //prints: true
System.out.println("abc" == s1); //prints: true
JVM 作者选择了这样的实现来避免重复和提高内存使用率。前面的代码示例看起来很像带有原始类型的操作,不是吗?但是,当使用new操作符创建String对象时,新对象的内存分配在字符串池之外,因此两个String对象或任何其他对象的引用总是不同的:
String o1 = new String("abc");
String o2 = new String("abc");
System.out.println(o1 == o2); //prints: false
System.out.println("abc" == o1); //prints: false
如有必要,可以使用intern()方法将用new运算符创建的字符串值移动到字符串池:
String o1 = new String("abc");
System.out.println("abc" == o1); //prints: false
System.out.println("abc" == o1.intern()); //prints: true
在前面的代码中,intern()方法试图将新创建的"abc"值移动到字符串池中,但发现那里已经存在这样一个文本,因此它重用了字符串池中的文本。这就是上例中最后一行中的引用相等的原因。
好消息是,您可能不需要使用new操作符创建String对象,而且大多数 Java 程序员从不这样做。但是当String对象作为输入传递到您的代码中,并且您无法控制其来源时,仅通过引用进行比较可能会导致错误的结果(如果字符串具有相同的拼写,但是由new operator创建的)。这就是为什么,当需要通过拼写(和大小写)使两个字符串相等时,为了比较两个字面值或String对象,equals()方法是更好的选择:
String o1 = new String("abc");
String o2 = new String("abc");
System.out.println(o1.equals(o2)); //prints: true
System.out.println(o2.equals(o1)); //prints: true
System.out.println(o1.equals("abc")); //prints: true
System.out.println("abc".equals(o1)); //prints: true
System.out.println("abc".equals("abc")); //prints: true
我们将很快讨论equals()和String类的其他方法。
使String文本和对象看起来像原始值的另一个特性是,可以使用算术运算符+添加它们:
String s1 = "abc";
String s2 = "abc";
String s = s1 + s2;
System.out.println(s); //prints: abcabc
System.out.println(s1 + "abc"); //prints: abcabc
System.out.println("abc" + "abc"); //prints: abcabc
String o1 = new String("abc");
String o2 = new String("abc");
String o = o1 + o2;
System.out.println(o); //prints: abcabc
System.out.println(o1 + "abc"); //prints: abcabc
没有其他算术运算符可以应用于String文本或对象。
最后,Java12 引入了一个新的String文本,称为原始字符串字面值。它允许保留缩进和多行,而无需在引号中添加空格。例如,程序员将如何在 Java12 之前添加缩进并使用\n断行:
String html = "<html>\n" +
" <body>\n" +
" <p>Hello World.</p>\n" +
" </body>\n" +
"</html>\n";
下面是 Java12 如何实现相同的结果:
String html = `<html>
<body>
<p>Hello World.</p>
</body>
</html>
`;
如您所见,原始字符串文本由一个或多个包含在反引号`(\u0060)中的字符组成,也称为反引号或重音符号。
字符串不变性
由于所有的String文本都可以共享,JVM 作者确保,一旦存储,String变量就不能更改。它不仅有助于避免从代码的不同位置同时修改相同值的问题,而且还可以防止未经授权修改通常表示用户名或密码的String值。
下面的代码看起来像一个String值修改:
String str = "abc";
str = str + "def";
System.out.println(str); //prints: abcdef
str = str + new String("123");
System.out.println(str); //prints: abcdef123
但是,在幕后,原始的"abc"字面值仍然完好无损。相反,创建了一些新的文本:"def"、"abcdef"、"123"、"abcdef123"。为了证明这一点,我们执行了以下代码:
String str1 = "abc";
String r1 = str1;
str1 = str1 + "def";
String r2 = str1;
System.out.println(r1 == r2); //prints: false
System.out.println(r1.equals(r2)); //prints: false
如您所见,r1和r2变量表示不同的记忆,它们所指的对象的拼写也不同。
我们将在第 5 章中进一步讨论字符串、“字符串、输入/输出和文件”。
标识符和变量
从我们的学校时代起,我们就有了一个直观的理解变量是什么。我们认为它是一个代表值的名称。我们用诸如x加仑水或n英里的距离等变量来解决问题,以及类似的问题。在 Java 中,变量的名称称为标识符,可以通过某些规则构造。使用标识符,可以声明(定义)变量并初始化变量。
标识符
根据 Java 语言规范,标识符(变量名)可以是表示字母、数字 0-9、美元符号($)或下划线(_的 Unicode 字符序列。
其他限制如下:
- 标识符的第一个符号不能是数字。
- 标识符的拼写不能与关键字相同(参见第 3 章“Java 基础”中的 Java 关键字)。
- 它不能拼写为
boolean字面值true或false或字面值null。 - 而且,由于 Java9,标识符不能只是下划线(
_。
以下是一些不寻常但合法的标识符示例:
$
_42
αρετη
String
变量声明(定义)和初始化
变量有名称(标识符)和类型。通常,它指的是存储值的存储器,但是可以不指任何内容(null)或者根本不指任何内容(那么它就不会被初始化)。它可以表示类属性、数组元素、方法参数和局部变量。最后一种是最常用的变量
在使用变量之前,必须声明并初始化它。在其他一些编程语言中,变量也可以被定义,因此 Java 程序员有时会使用定义这个词作为声明的同义词,这并不完全正确。
以下是术语回顾和示例:
int x; //declartion of variable x
x = 1; //initialization of variable x
x = 2; //assignment of variable x
初始化和赋值看起来是一样的。区别在于它们的顺序:第一个赋值称为初始化。没有初始化,就不能使用变量。
声明和初始化可以组合在一个语句中。例如,请注意以下事项:
float $ = 42.42f;
String _42 = "abc";
int αρετη = 42;
double String = 42.;
类型推断变量
在 Java10 中,引入了一种类型保持器var。Java 语言规范对其定义如下:“var不是关键字,而是具有特殊含义的标识符,作为局部变量声明的类型。
在实际应用中,它可以让编译器计算出声明变量的类型,如下所示:
var x = 1;
在前面的示例中,编译器可以合理地假设x具有原始类型int。
你可以猜到,要做到这一点,光靠一个声明是不够的:
var x; //compilation error
也就是说,在没有初始化的情况下,编译器无法在使用var时找出变量的类型。
Java 语句
“Java 语句”是可以执行的最小构造。它描述一个动作,以分号(;结束。我们已经看到许多声明。例如,这里有三种说法:
float f = 23.42f;
String sf = String.valueOf(f);
System.out.println(sf);
第一行是声明语句和赋值语句的组合。第二行也是一个声明语句,它与赋值语句和方法调用语句相结合。第三行只是一个方法调用语句。
以下是 Java 语句类型列表:
- 只有一个符号
;(分号)的空语句 - 一个类或接口声明语句(我们将在第 2 章、"Java 面向对象编程"中讨论)
- 局部变量声明语句:
int x; - 同步声明:这超出了本书的范围
- 表达式语句
- 控制流语句
表达式语句可以是以下语句之一:
- 方法调用语句:
someMethod(); - 赋值声明:
n = 23.42f; - 对象创建语句:
new String("abc"); - 一元递增或递减语句:
++x ;或--x;或x++;或x--;
我们将在“表达式语句”部分中详细讨论表达式语句。
控制流语句可以是以下语句之一:
- 选择语句:
if-else或switch-case - 迭代语句:
for或while或do-while - 异常处理语句:
throw或try-catch或try-catch-finally - 分支语句:
break或continue或return
我们将在“控制流语句”一节中详细讨论控制语句。
表达式语句
表达式语句由一个或多个表达式组成。表达式通常包含一个或多个运算符。可以对其求值,这意味着它可以生成以下类型之一的结果:
-
变量:例如
x = 1。 -
值:例如
2*2。 -
如果表达式是对返回
void的方法的调用,则不返回任何东西。这种方法据说只产生副作用:例如void someMethod()
考虑以下表达式:
x = y++;
前面的表达式将值赋给x变量,并且具有将1添加到y变量的值的副作用。
另一个例子是打印一行的方法:
System.out.println(x);
println()方法不返回任何内容,并且具有打印某些内容的副作用。
根据其形式,表达式可以是以下表达式之一:
- 主要表达式:文本、新对象创建、字段或方法访问(调用)
- 一元运算符表达式:例如
x++ - 二元运算符表达式:例如
x*y - 三元运算符表达式:例如
x > y ? true : false - 一个 Lambda 表达式:
x -> x + 1(见第 14 章、“函数式编程”)
如果表达式由其他表达式组成,则括号通常用于清楚地标识每个表达式。这样,更容易理解和设置表达式的优先级。
控制流语句
当一个 Java 程序被执行时,它是一个语句一个语句地执行的。有些语句必须根据表达式求值的结果有条件地执行。这种语句被称为控制流语句,因为在计算机科学中,控制流(或控制流)是执行或求值单个语句的顺序。
控制流语句可以是以下语句之一:
- 选择语句:
if-else或switch-case - 迭代语句:
for或while或do-while - 异常处理语句:
throw或try-catch或try-catch-finally - 分支语句:
break或continue或return
选择语句
选择语句基于表达式求值,有四种变体:
if (expr) {do sth}if (expr) {do sth} else {do sth else}if (expr) {do sth} else if {do sth else} else {do sth else}switch case语句
以下是if语句的示例:
if(x > y){
//do something
}
if(x > y){
//do something
} else {
//do something else
}
if(x > y){
//do something
} else if (x == y){
//do something else
} else {
//do something different
}
switch...case语句是if...else语句的变体:
switch(x){
case 5: //means: if(x = 5)
//do something
break;
case 7:
//do something else
break;
case 12:
//do something different
break;
default:
//do something completely different
//if x is not 5, 7, or 12
}
如您所见,switch...case语句根据变量的值派生执行流。break语句允许退出switch...case语句。否则,将执行以下所有案件。
在 Java12 中,在预览模式中引入了一个新特性—一个不太详细的switch...case语句:
void switchDemo1(int x){
switch (x) {
case 1, 3 -> System.out.print("1 or 3");
case 4 -> System.out.print("4");
case 5, 6 -> System.out.print("5 or 6");
default -> System.out.print("Not 1,3,4,5,6");
}
System.out.println(": " + x);
}
如您所见,它使用箭头->,而不使用break语句。要利用此功能,您必须向javac和java命令添加一个--enable-preview选项。如果从 IDE 运行示例,则需要将此选项添加到配置中。在 IntelliJ IDEA 中,该选项应添加到两个配置屏幕:编译器和运行时:
- 打开“首选项”屏幕并将其作为 LearnJava 模块的编译选项,如下屏幕所示:

- 在最顶部的水平菜单上选择“运行”:

- 单击编辑配置。。。并将 VM 选项添加到将在运行时使用的 ControlFlow 应用:

如前所述,我们已经添加了--enable-preview选项,并使用不同的参数执行了switchDemo1()方法:
switchDemo1(1); //prints: 1 or 3: 1
switchDemo1(2); //prints: Not 1,3,4,5,6: 2
switchDemo1(5); //prints: 5 or 6: 5
你可以从注释中看到结果。
如果在每种情况下都要执行几行代码,您可以在代码块周围加上大括号{},如下所示:
switch (x) {
case 1, 3 -> {
//do something
}
case 4 -> {
//do something else
}
case 5, 6 -> System.out.println("5 or 6");
default -> System.out.println("Not 1,3,4,5,6");
}
java12switch...case语句甚至可以返回一个值。例如,这里的情况是,必须根据switch...case语句结果分配另一个变量:
void switchDemo2(int i){
boolean b = switch(i) {
case 0 -> false;
case 1 -> true;
default -> false;
};
System.out.println(b);
}
如果我们执行switchDemo2()方法,结果如下:
switchDemo2(0); //prints: false
switchDemo2(1); //prints: true
switchDemo2(2); //prints: false
这看起来是一个很好的改进,如果这个特性被证明是有用的,它将作为一个永久的特性包含在未来的 Java 版本中。
迭代语句
迭代语句可以是以下三种形式之一:
while语句do..while语句for语句,也称为循环语句
while语句如下:
while (boolean expression){
//do something
}
下面是一个具体的例子:
int n = 0;
while(n < 5){
System.out.print(n + " "); //prints: 0 1 2 3 4
n++;
}
在一些例子中,我们使用了不给另一条线馈电的print()方法,而不是println()方法(在其输出端不添加换行控制)。print()方法在一行中显示输出。
do...while语句的形式非常相似:
do {
//do something
} while (boolean expression)
它不同于while语句,总是在计算表达式之前至少执行一次语句块:
int n = 0;
do {
System.out.print(n + " "); //prints: 0 1 2 3 4
n++;
} while(n < 5);
如您所见,当表达式在第一次迭代时为true时,它的行为方式相同。但如果表达式的计算结果为false,则结果不同:
int n = 6;
while(n < 5){
System.out.print(n + " "); //prints:
n++;
}
n = 6;
do {
System.out.print(n + " "); //prints: 6
n++;
} while(n < 5);
for语句语法如下:
for(init statements; boolean expression; update statements) {
//do what has to be done here
}
以下是for语句的工作原理:
init语句初始化一些变量。- 使用当前变量值来计算
boolean expression:如果是true,则执行语句块,否则退出for语句。 update statements更新变量,用这个新值重新计算boolean expression:如果为true,则执行语句块,否则退出for语句。- 除非退出,否则重复最后一步。
如你所见,如果你不小心,你会进入一个无限循环:
for (int x = 0; x > -1; x++){
System.out.print(x + " "); //prints: 0 1 2 3 4 5 6 ...
}
因此,必须确保布尔表达式保证最终退出循环:
for (int x = 0; x < 3; x++){
System.out.print(x + " "); //prints: 0 1 2
}
以下示例演示了多个初始化和更新语句:
for (int x = 0, y = 0; x < 3 && y < 3; ++x, ++y){
System.out.println(x + " " + y);
}
以下是前面for语句的变体,用于演示目的:
for (int x = getInitialValue(), i = x == -2 ? x + 2 : 0, j = 0;
i < 3 || j < 3 ; ++i, j = i) {
System.out.println(i + " " + j);
}
如果getInitialValue()方法像int getInitialValue(){ return -2; }一样实现,那么前面的两条for语句产生完全相同的结果。
要对值数组进行迭代,可以使用数组索引:
int[] arr = {24, 42, 0};
for (int i = 0; i < arr.length; i++){
System.out.print(arr[i] + " "); //prints: 24 42 0
}
或者,您可以使用更紧凑的形式的for语句来产生相同的结果,如下所示:
int[] arr = {24, 42, 0};
for (int a: arr){
System.out.print(a + " "); //prints: 24 42 0
}
最后一个表单对于如下所示的集合特别有用:
List<String> list = List.of("24", "42", "0");
for (String s: list){
System.out.print(s + " "); //prints: 24 42 0
}
我们将在第 6 章、“数据结构、泛型和流行工具”中讨论集合。
异常处理语句
在 Java 中,有称为异常的类,它们表示中断正常执行流的事件。它们的名字通常以Exception结尾:NullPointerException、ClassCastException、ArrayIndexOutOfBoundsException等等。
所有异常类都扩展了java.lang.Exception类,而java.lang.Exception类又扩展了java.lang.Throwable类(我们将在第 2 章“Java 面向对象编程(OOP)”中解释这意味着什么)。这就是为什么所有异常对象都有一个共同的行为。它们包含有关异常情况的原因及其起源位置(源代码行号)的信息。
每个异常对象可以由 JVM 自动生成(抛出),也可以由应用代码使用关键字throw自动生成(抛出)。如果一个代码块抛出异常,您可以使用一个try-catch或try-catch-finally构造来捕获抛出的异常对象,并将执行流重定向到另一个代码分支。如果周围的代码没有捕获异常对象,它将从应用传播到 JVM 并强制它退出(并中止应用执行)。因此,在所有可能引发异常的地方使用try-catch或try-catch-finally是一种很好的做法,您不希望应用中止执行。
以下是异常处理的典型示例:
try {
//x = someMethodReturningValue();
if(x > 10){
throw new RuntimeException("The x value is out of range: " + x);
}
//normal processing flow of x here
} catch (RuntimeException ex) {
//do what has to be done to address the problem
}
在前面的代码段中,x > 10的情况下不执行normal processing flow。相反,do what has to be done块将被执行。但是在x <= 10的情况下,normal processing flow块将运行,do what has to be done块将被忽略。
有时,不管是否抛出/捕获了异常,都必须执行代码块。不必在两个地方重复相同的代码块,您可以将其放入一个finally块中,如下所示:
try {
//x = someMethodReturningValue();
if(x > 10){
throw new RuntimeException("The x value is out of range: " + x);
}
//normal processing flow of x here
} catch (RuntimeException ex) {
System.out.println(ex.getMessage());
//prints: The x value is out of range: ...
//do what has to be done to address the problem
} finally {
//the code placed here is always executed
}
我们将在第 4 章、“处理”中更详细地讨论异常处理。
分支语句
分支语句允许中断当前执行流,并从当前块后的第一行或控制流的某个(标记的)点继续执行。
分支语句可以是以下语句之一:
breakcontinuereturn
我们已经看到了break在switch-case语句中的用法。下面是另一个例子:
String found = null;
List<String> list = List.of("24", "42", "31", "2", "1");
for (String s: list){
System.out.print(s + " "); //prints: 24 42 31
if(s.contains("3")){
found = s;
break;
}
}
System.out.println("Found " + found); //prints: Found 31
如果我们需要找到包含"3"的第一个列表元素,我们可以在condition s.contains("3")被求值为true后立即停止执行。其余的列表元素将被忽略。
在更复杂的场景中,使用嵌套的for语句,可以设置一个标签(带有:列),指示必须退出哪个for语句:
String found = null;
List<List<String>> listOfLists = List.of(
List.of("24", "16", "1", "2", "1"),
List.of("43", "42", "31", "3", "3"),
List.of("24", "22", "31", "2", "1")
);
exit: for(List<String> l: listOfLists){
for (String s: l){
System.out.print(s + " "); //prints: 24 16 1 2 1 43
if(s.contains("3")){
found = s;
break exit;
}
}
}
System.out.println("Found " + found); //prints: Found 43
我们已经选择了标签名exit,但我们也可以称它为任何其他名称。
continue语句的工作原理类似,如下所示:
String found = null;
List<List<String>> listOfLists = List.of(
List.of("24", "16", "1", "2", "1"),
List.of("43", "42", "31", "3", "3"),
List.of("24", "22", "31", "2", "1")
);
String checked = "";
cont: for(List<String> l: listOfLists){
for (String s: l){
System.out.print(s + " "); //prints: 24 16 1 2 1 43 24 22 31
if(s.contains("3")){
continue cont;
}
checked += s + " ";
}
}
System.out.println("Found " + found); //prints: Found 43
System.out.println("Checked " + checked);
//prints: Checked 24 16 1 2 1 24 22
它与break不同,它告诉for语句中的哪一个继续,而不是仅仅退出。
return语句用于返回方法的结果:
String returnDemo(int i){
if(i < 10){
return "Not enough";
} else if (i == 10){
return "Exactly right";
} else {
return "More than enough";
}
}
如您所见,一个方法中可以有几个return语句,每个语句在不同的情况下返回不同的值。如果方法不返回任何内容(void),则不需要return语句,尽管为了更好的可读性,经常使用return语句,如下所示:
void returnDemo(int i){
if(i < 10){
System.out.println("Not enough");
return;
} else if (i == 10){
System.out.println("Exactly right");
return;
} else {
System.out.println("More than enough");
return;
}
}
语句是 Java 编程的构造块。它们就像英语中的句子,是可以付诸行动的完整的意图表达。它们可以被编译和执行。编程就是用语句来表达行动计划。
至此,Java 基础知识的解释就结束了。
恭喜你度过难关!
总结
本章向您介绍了令人兴奋的 Java 编程世界。我们从解释主要术语开始,然后解释了如何安装必要的工具、JDK 和 IDE,以及如何配置和使用它们。
有了适当的开发环境,我们为读者提供了 Java 作为编程语言的基础知识。我们已经描述了 Java 基本类型,String类型及其文本。我们还定义了什么是标识符,什么是变量,最后描述了 Java 语句的主要类型。通过具体的代码示例说明了讨论的所有要点。
在下一章中,我们将讨论 Java 的面向对象方面。我们将介绍主要概念,解释什么是类,什么是接口,以及它们之间的关系。术语重载、覆盖、隐藏也将在代码示例中定义和演示,以及final关键字的用法。
测验
-
JDK 代表什么?
-
JCL 代表什么?
-
JavaSE 代表什么?
-
IDE 代表什么?
-
Maven 的功能是什么?
-
什么是 Java 原始类型?
-
什么是 Java 原始类型?
-
什么是字面值?
-
以下哪项是字面值?
-
以下哪些是 Java 操作符?
-
下面的代码段打印什么?
int i = 0; System.out.println(i++);
- 下面的代码段打印什么?
boolean b1 = true;
boolean b2 = false;
System.out.println((b1 & b2) + " " + (b1 && b2));
- 下面的代码段打印什么?
int x = 10;
x %= 6;
System.out.println(x);
- 以下代码段的结果是什么?
System.out.println("abc" - "bc");
- 下面的代码段打印什么?
System.out.println("A".repeat(3).lastIndexOf("A"));
-
正确的标识符是什么?
-
下面的代码段打印什么?
for (int i=20, j=-1; i < 23 && j < 0; ++i, ++j){
System.out.println(i + " " + j + " ");
}
- 下面的代码段打印什么?
int x = 10;
try {
if(x++ > 10){
throw new RuntimeException("The x value is out of range: " + x);
}
System.out.println("The x value is within the range: " + x);
} catch (RuntimeException ex) {
System.out.println(ex.getMessage());
}
- 下面的代码段打印什么?
int result = 0;
List<List<Integer>> source = List.of(
List.of(1, 2, 3, 4, 6),
List.of(22, 23, 24, 25),
List.of(32, 33)
);
cont: for(List<Integer> l: source){
for (int i: l){
if(i > 7){
result = i;
continue cont;
}
}
}
System.out.println("result=" + result);
-
从以下选项中选择所有正确的语句:
-
从以下选项中选择所有正确的 Java 语句类型:
二、Java 面向对象编程(OOP)
面向对象编程(OOP)是为了更好地控制共享数据的并发修改而产生的,这是前 OOP 编程的祸根。这个想法的核心不是允许直接访问数据,而是只允许通过专用的代码层访问数据。由于数据需要在这个过程中传递和修改,因此就产生了对象的概念。在最一般的意义上,对象是一组数据,它们也只能通过传递的一组方法来传递和访问。这些数据被称为组成了一个对象状态,而这些方法构成了对象行为。对象状态被隐藏(封装),不允许直接访问。
每个对象都是基于一个称为类的模板构建的,换句话说,一个类定义了一个对象类。每个对象都有一个特定的接口,这是其他对象与之交互方式的正式定义。最初,据说一个对象通过调用其方法向另一个对象发送消息。但这个术语并不适用,特别是在引入了实际的基于消息的协议和系统之后。
为了避免代码重复,引入了对象之间的父子关系:据说一个类可以从另一个类继承行为。在这种关系中,第一类称为子类或派生类,第二类称为父类或基类或超类。
在类和接口之间定义了另一种关系:据说一个类可以实现一个接口。由于接口描述了如何与对象交互,而不是对象如何响应交互,因此在实现同一接口时,不同对象的行为可能不同。
在 Java 中,一个类只能有一个直接父类,但可以实现许多接口。像它的祖先一样行为并依附于多个接口的能力被称为多态。
在本章中,我们将介绍这些面向对象的概念以及它们是如何在 Java 中实现的。讨论的主题包括:
- 面向对象的概念
- 类
- 接口
- 重载、覆盖和隐藏
- 最终变量、方法和类
- 多态的作用
面向对象的概念
正如我们在引言中所述,OOP 的主要概念如下:
- 对象/类:定义一个状态(数据)和行为(方法),并将它们结合在一起
- 继承:它将行为传播到通过父子关系连接的类链上
- “抽象/接口”:描述如何访问对象数据和行为。它将对象的外观与其实现(行为)隔离(抽象)
- 封装:隐藏实现的状态和细节
- 多态:允许对象呈现实现接口的外观,并表现为任何祖先类
对象/类
原则上,您可以用最少的类和对象来创建一个非常强大的应用。在 Java8 和 JDK 中加入了函数式编程之后,实现这一点就变得更加容易了,它允许您将行为作为函数传递。但是传递数据(状态)仍然需要类/对象。这意味着 Java 作为 OOP 语言的地位保持不变。
类定义了保存对象状态的所有内部对象属性的类型。类还定义了由方法的代码表示的对象行为。类/对象可能没有状态或行为。Java 还提供了一个在不创建对象的情况下静态访问行为的方法。但是这些可能性仅仅是为了保持状态和行为一致而引入的对象/类概念的补充。
举例来说,为了说明这个概念,一个类Vehicle在原则上定义了车辆的特性和行为。让我们把模型简单化,假设一辆车只有两个特性:重量和一定功率的发动机。它也可以有一定的行为:它可以在一定的时间内达到一定的速度,这取决于它的两个属性的值。这种行为可以用一种方法来表示,该方法计算车辆在一定时间内可以达到的速度。Vehicle类的每个对象都有一个特定的状态(属性值),速度计算将在同一时间段内产生不同的速度
所有 Java 代码都包含在方法中。方法是一组具有(可选)输入参数和返回值(可选)的语句。此外,每种方法都有副作用:例如,它可以显示消息或将数据写入数据库。类/对象行为在方法中实现。
例如,按照我们的示例,速度计算可以驻留在double calculateSpeed(float seconds)方法中。您可以猜到,该方法的名称是calculateSpeed,它接受秒数(带有小数部分)作为参数,并将速度值返回为double。
继承
正如我们已经提到的,对象可以建立父子关系,并以这种方式共享属性和行为。例如,我们可以创建一个继承Vehicle类的属性(例如权重)和行为(速度计算)的Car类。此外,子类可以有自己的属性(例如,乘客数量)和特定于汽车的行为(例如,软减震)。但是,如果我们创建一个Truck类作为车辆的子类,它的额外卡车特定属性(例如有效载荷)和行为(硬减震)将不同。
据说,Car类或Truck类的每个对象都有一个Vehicle类的父对象。但是Car和Truck类的对象不共享特定的Vehicle对象(每次创建子对象时,首先创建一个新的父对象)。他们只分享父项的行为。这就是为什么所有子对象可以有相同的行为,但状态不同。这是实现代码可重用性的一种方法。当对象行为必须动态更改时,它可能不够灵活。在这种情况下,对象组合(从其他类带来行为)或函数式编程更合适(参见第 13 章、“函数式编程”)。
有可能使子项的行为与遗传行为不同。为了实现它,捕获行为的方法可以在child类中重新实现。据说子项可以覆盖遗传的行为,我们将很快解释如何做(见“重载、覆盖和隐藏”一节)。例如,如果Car类有自己的速度计算方法,则不继承父类Vehicle的相应方法,而是使用在子类中实现的新速度计算方法。
父类的属性也可以继承(但不能覆盖)。然而,类属性通常被声明为私有的;它们不能被继承这就是封装的要点。参见“访问修饰符”部分中对各种访问级别的描述public、protected和private。
如果父类从另一个类继承某些行为,那么子类也会获取(继承)该行为,当然,除非父类覆盖它。继承链的长度没有限制。
Java 中的父子关系用extends关键字表示:
class A { }
class B extends A { }
class C extends B { }
class D extends C { }
在此代码中,A、B、C和D类具有以下关系:
- 类
D继承自类C、B和A - 类
C继承自类B和A - 类
B继承自类A
类A的所有非私有方法都由类B、C和D继承(如果不覆盖)
抽象/接口
一个方法的名称及其参数类型的列表称为方法签名。它描述了如何访问一个对象(在我们的示例中是Car或Truck的)的行为。这样的描述与return类型一起被呈现为接口。它没有说明只计算方法名、参数类型、它们在参数列表中的位置以及结果类型的代码。所有的实现细节都隐藏(封装)在实现这个接口的类中。
正如我们已经提到的,一个类可以实现许多不同的接口。但是两个不同的类(及其对象)即使实现同一个接口,其行为也可能不同
与类类似,接口也可以使用extends关键字具有父子关系:
interface A { }
interface B extends A {}
interface C extends B {}
interface D extends C {}
在本规范中,A、B、C、D的接口关系如下:
- 接口
D继承自接口C、B和A - 接口
C继承自接口B和A - 接口
B继承自接口A
接口A的所有非私有方法都由接口B、C和D继承
抽象/接口还减少了代码不同部分之间的依赖性,从而提高了代码的可维护性。只要接口保持不变,每个类都可以更改,而无需与客户端协调。
封装
封装通常被定义为一种数据隐藏,或者将公开访问的方法和私有访问的数据捆绑在一起。从广义上讲,封装是对对象属性的受控访问
对象属性值的快照称为对象状态。对象状态是封装的数据。因此,封装解决了促使创建面向对象编程的主要问题:更好地管理对共享数据的并发访问。例如:
class A {
private String prop = "init value";
public void setProp(String value){
prop = value;
}
public String getProp(){
return prop;
}
}
如您所见,要读取或修改prop属性的值,我们不能直接访问它,因为访问修饰符private。相反,我们只能通过setProp(String value)和getProp()方法来实现。
多态
多态是一个对象作为不同类的对象或作为不同接口实现的能力。它的存在归功于前面提到的所有概念:继承、接口和封装。没有它们,多态就不可能
继承允许对象获取或覆盖其所有祖先的行为。接口对客户端代码隐藏实现它的类的名称。封装防止暴露对象状态
在下面的部分中,我们将演示所有这些概念的实际应用,并在“多态的实际应用”部分中查看多态的具体用法。
类
Java 程序是表示可执行操作的一系列语句,这些语句按方法组织,方法按类组织。一个或多个类存储在.java文件中,它们可以由 Java 编译器javac编译(从 Java 语言转换成字节码)并存储在.class文件中。每个.class文件只包含一个编译类,可以由 JVM 执行。
一个java命令启动 JVM 并告诉它哪个类是main类,这个类有一个名为main()的方法。main方法有一个特定的声明:它必须是public static,必须返回void,名称为main,并接受一个String类型数组的单个参数。
JVM 将主类加载到内存中,找到main()方法,开始一条语句一条语句地执行它。java命令还可以传递main()方法作为String值数组接收的参数(参数),如果 JVM 遇到需要执行另一个类的方法的语句,那么这个类(它的.class文件)也会加载到内存中,并执行相应的方法,Java 程序流是关于加载类和执行它们的方法的。
下面是主类的示例:
public class MyApp {
public static void main(String[] args){
AnotherClass an = new AnotherClass();
for(String s: args){
an.display(s);
}
}
}
它表示一个非常简单的应用,它接收任意数量的参数并将它们逐个传递到AnotherClass类的display()方法中,当 JVM 启动时,它首先从MyApp.class文件加载MyApp类。然后它从AnotherClass.class文件加载AnotherClass类,使用new操作符创建该类的对象(我们稍后将讨论),并调用display()方法。
这里是AnotherClass类:
public class AnotherClass {
private int result;
public void display(String s){
System.out.println(s);
}
public int process(int i){
result = i *2;
return result;
}
public int getResult(){
return result;
}
}
如您所见,display()方法用于它的副作用,只是它打印出传入的值,并且不返回任何内容(void。AnotherClass类还有两种方法:
process()方法将输入整数加倍,存储在其result属性中,并将值返回给调用者getResult()方法允许以后随时从对象获取结果
在我们的演示应用中没有使用这两种方法。我们展示它们只是为了演示一个类可以有属性(在本例中为result)和许多其他方法。
private关键字使值只能从类内部、从其方法访问。关键字使属性或方法可由任何其他类访问。
方法
如前所述,Java 语句被组织为方法:
<return type> <method name>(<list of parameter types>){
<method body that is a sequence of statements>
}
我们已经看到了一些例子。一个方法有一个名称,一组输入参数或根本没有参数,{}括号内有一个主体,返回类型或void关键字表示该方法不返回任何值。
方法名和参数类型列表一起称为方法签名。输入参数的数量称为参数量。
如果两个方法在输入参数列表中具有相同的名称、相同的参数量和相同的类型序列,则它们具有相同的签名。
以下两种方法具有相同的签名:
double doSomething(String s, int i){
//some code goes here
}
double doSomething(String i, int s){
//some code other code goes here
}
即使签名相同,方法中的代码也可能不同
以下两种方法具有不同的签名:
double doSomething(String s, int i){
//some code goes here
}
double doSomething(int s, String i){
//some code other code goes here
}
只要改变参数序列,签名就不同了,即使方法名保持不变。
可变参数
有一种特殊类型的参数需要提及,因为它与所有其他类型的参数完全不同。它被声明为后跟三个点的类型。它被称为可变参数(varargs)。但是,首先,让我们简单地定义一下 Java 中的数组是什么。
数组是保存相同类型元素的数据结构。元素由数字索引引用。这就够了,现在。我们在第 6 章、“数据结构、泛型和流行工具”中更多地讨论数组
让我们从一个例子开始。让我们使用可变参数声明方法参数:
String someMethod(String s, int i, double... arr){
//statements that compose method body
}
当调用someMethod方法时,Java 编译器从左到右匹配参数。一旦到达最后一个可变参数,它将创建一个剩余参数的数组并将其传递给方法。下面是演示代码:
public static void main(String... args){
someMethod("str", 42, 10, 17.23, 4);
}
private static String someMethod(String s, int i, double... arr){
System.out.println(arr[0] + ", " + arr[1] + ", " + arr[2]);
//prints: 10.0, 17.23, 4.0
return s;
}
如您所见,可变参数的作用类似于指定类型的数组。它可以作为方法的最后一个或唯一参数列出。这就是为什么有时您可以看到前面示例中声明的main方法。
构造器
当创建一个对象时,JVM 使用一个构造器。构造器的目的是初始化对象状态,为所有声明的属性赋值。如果类中没有声明构造器,JVM 只会将缺省值赋给属性。我们已经讨论了原始类型的默认值:整数类型的默认值是0,浮点类型的默认值是0.0,布尔类型的默认值是false。对于其他 Java 引用类型(参见第 3 章、“Java 基础”),默认值为null,表示引用类型的属性没有赋值。
当一个类中没有声明构造器时,就说这个类有一个没有 JVM 提供的参数的默认构造器。
如果需要,可以显式声明任意数量的构造器,每个构造器使用不同的参数集来设置初始状态。举个例子:
class SomeClass {
private int prop1;
private String prop2;
public SomeClass(int prop1){
this.prop1 = prop1;
}
public SomeClass(String prop2){
this.prop2 = prop2;
}
public SomeClass(int prop1, String prop2){
this.prop1 = prop1;
this.prop2 = prop2;
}
// methods follow
}
如果属性不是由构造器设置的,则相应类型的默认值将自动分配给它。
当多个类沿同一连续线相关联时,首先创建父对象。如果父对象需要为其属性设置非默认初始值,则必须使用如下所示的super关键字将其构造器作为子构造器的第一行调用:
class TheParentClass {
private int prop;
public TheParentClass(int prop){
this.prop = prop;
}
// methods follow
}
class TheChildClass extends TheParentClass{
private int x;
private String prop;
private String anotherProp = "abc";
public TheChildClass(String prop){
super(42);
this.prop = prop;
}
public TheChildClass(int arg1, String arg2){
super(arg1);
this.prop = arg2;
}
// methods follow
}
在前面的代码示例中,我们向TheChildClass添加了两个构造器:一个总是将42传递给TheParentClass的构造器,另一个接受两个参数。请注意已声明但未显式初始化的x属性。当创建TheChildClass的对象时,它将被设置为值0,即int类型的默认值。另外,请注意显式初始化为"abc"值的anotherProp属性。否则,它将被初始化为值null,任何引用类型的默认值,包括String。
从逻辑上讲,有三种情况不需要类中构造器的显式定义:
- 当对象及其任何父对象都没有需要初始化的属性时
- 当每个属性与类型声明一起初始化时(例如,
int x = 42) - 当属性初始化的默认值足够好时
然而,即使满足了所有三个条件(在列表中提到),也有可能仍然实现了构造器。例如,您可能希望执行一些语句来初始化某个外部资源—对象一经创建就需要的文件或另一个数据库。
一旦添加了显式构造器,就不会提供默认构造器,并且以下代码将生成一个错误:
class TheParentClass {
private int prop;
public TheParentClass(int prop){
this.prop = prop;
}
// methods follow
}
class TheChildClass extends TheParentClass{
private String prop;
public TheChildClass(String prop){
//super(42); //No call to the parent's contuctor
this.prop = prop;
}
// methods follow
}
为了避免这个错误,要么在TheParentClass中添加一个没有参数的构造器,要么调用父类的显式构造器作为子类构造器的第一条语句。以下代码不会生成错误:
class TheParentClass {
private int prop;
public TheParentClass() {}
public TheParentClass(int prop){
this.prop = prop;
}
// methods follow
}
class TheChildClass extends TheParentClass{
private String prop;
public TheChildClass(String prop){
this.prop = prop;
}
// methods follow
}
需要注意的一个重要方面是,构造器虽然看起来像方法,但不是方法,甚至不是类的成员。构造器没有返回类型,并且总是与类同名。它的唯一用途是在创建类的新实例时调用。
new运算符
new操作符通过为新对象的属性分配内存并返回对该内存的引用来创建类的对象(也可以说它实例化类或创建类的实例)。此内存引用被分配给与用于创建对象或其父对象类型的类相同类型的变量:
TheChildClass ref1 = new TheChildClass("something");
TheParentClass ref2 = new TheChildClass("something");
这是一个有趣的观察。在代码中,对象引用ref1和ref2都提供了对TheChildClass和TheParentClass方法的访问。例如,我们可以向这些类添加方法,如下所示:
class TheParentClass {
private int prop;
public TheParentClass(int prop){
this.prop = prop;
}
public void someParentMethod(){}
}
class TheChildClass extends TheParentClass{
private String prop;
public TheChildClass(int arg1, String arg2){
super(arg1);
this.prop = arg2;
}
public void someChildMethod(){}
}
然后我们可以使用以下任何引用调用它们:
TheChildClass ref1 = new TheChildClass("something");
TheParentClass ref2 = new TheChildClass("something");
ref1.someChildMethod();
ref1.someParentMethod();
((TheChildClass) ref2).someChildMethod();
ref2.someParentMethod();
注意,要使用父级的类型引用访问子级的方法,我们必须将其强制转换为子级的类型。否则,编译器将生成一个错误。这是可能的,因为我们已经为父对象的类型引用指定了子对象的引用。这就是多态的力量。我们将在“多态的作用”一节中详细讨论。
当然,如果我们将父对象赋给父类型的变量,那么即使使用强制转换,也无法访问子对象的方法,如下例所示:
TheParentClass ref2 = new TheParentClass(42);
((TheChildClass) ref2).someChildMethod(); //compiler's error
ref2.someParentMethod();
为新对象分配内存的区域称为堆。JVM 有一个名为垃圾收集的进程,它监视这个区域的使用情况,并在不再需要对象时释放内存以供使用。例如,查看以下方法:
void someMethod(){
SomeClass ref = new SomeClass();
ref.someClassMethod();
//other statements follow
}
一旦someMethod()方法的执行完成,SomeClass的对象就不再可访问。这就是垃圾收集器注意到的,并释放这个对象占用的内存,我们将在第 9 章、“JVM 结构和垃圾收集”中讨论垃圾收集过程。
java.lang.Object对象
在 Java 中,默认情况下,所有类都是Object类的子类,即使您没有隐式地指定它。Object类在标准 JDK 库的java.lang包中声明。我们将在“包、导入和访问”部分定义什么是包,并在第 7 章、“Java 标准和外部库”中描述库。
让我们回顾一下在“继承”一节中提供的示例:
class A { }
class B extends A {}
class C extends B {}
class D extends C {}
所有类A、B、C、D都是Object类的子类,每个类继承 10 个方法:
public String toString()public int hashCode()public boolean equals (Object obj)public Class getClass()protected Object clone()public void notify()public void notifyAll()public void wait()public void wait(long timeout)public void wait(long timeout, int nanos)
前三个toString()、hashCode()和equals()是最常用的方法,并且经常被重新实现(覆盖)。toString()方法通常用于打印对象的状态。它在 JDK 中的默认实现如下所示:
public String toString() {
return getClass().getName()+"@"+Integer.toHexString(hashCode());
}
如果我们在TheChildClass类的对象上使用它,结果如下:
TheChildClass ref1 = new TheChildClass("something");
System.out.println(ref1.toString());
//prints: com.packt.learnjava.ch02_oop.Constructor$TheChildClass@72ea2f77
顺便说一句,在将对象传递给System.out.println()方法和类似的输出方法时,不需要显式调用toString(),因为它们无论如何都是在方法内部调用的,在我们的例子中System.out.println(ref1)会产生相同的结果。
所以,正如您所看到的,这样的输出对人不友好,所以覆盖toString()方法是个好主意,最简单的方法是使用 IDE。例如,在 IntelliJ IDEA 中,在TheChildClass代码中单击鼠标右键,如下图所示:

选择并单击“生成”,然后选择并单击toString(),如下图所示:

新的弹出窗口将允许您选择在toString()方法中包含哪些属性。仅选择TheChildClass的属性,如下所示:

单击“确定”按钮后,将生成以下代码:
@Override
public String toString() {
return "TheChildClass{" +
"prop='" + prop + '\'' +
'}';
}
如果类中有更多属性并且您选择了它们,那么更多属性及其值将包含在方法输出中。如果我们现在打印对象,结果将是:
TheChildClass ref1 = new TheChildClass("something");
System.out.println(ref1.toString());
//prints: TheChildClass{prop='something'}
这就是为什么toString()方法经常被覆盖,甚至包括在 IDE 的服务中。
我们将在第 6 章、“数据结构、泛型和流行工具”中更详细地讨论hashCode()和equals()方法。
getClass()和clone()方法不常使用。getClass()方法返回Class类的一个对象,这个对象有许多提供各种系统信息的方法。最常用的方法是返回当前对象的类名的方法。clone()方法可以复制当前对象。只要当前对象的所有属性都是原始类型,它就可以正常工作。但是,如果存在引用类型属性,clone()方法必须重新实现,这样才能正确复制引用类型。否则,将只复制引用,而不复制对象本身。这种拷贝称为浅拷贝,在某些情况下可能已经足够好了。protected关键字表示只有该类的子类可以访问它(参见“包、导入和访问”部分)。
类Object中的最后五个方法用于线程之间的通信,而轻量级进程用于并发处理。它们通常不会重新实现。
实例和静态属性及方法
到目前为止,我们看到的大多数方法只能在类的对象(实例)上调用。这种方法称为实例方法。它们通常使用对象属性(对象状态)的值。否则,如果它们不使用对象状态,则可以使它们成为static并在不创建对象的情况下调用。这种方法的例子是main()方法,这里是另一个例子:
class SomeClass{
public static void someMethod(int i){
//do something
}
}
此方法可按如下方式调用:
SomeClass.someMethod(42);
静态方法也可以在对象上调用,但这被认为是不好的做法,因为它对试图理解代码的人隐藏了方法的静态特性。此外,它还会引发编译器警告,根据编译器的实现,甚至可能生成编译器错误。
类似地,属性可以声明为静态的,因此无需创建对象即可访问。例如:
class SomeClass{
public static String SOME_PROPERTY = "abc";
}
也可以通过类直接访问此属性,如下所示:
System.out.println(SomeClass.SOME_PROPERTY); //prints: abc
拥有这样一个静态属性与状态封装的思想背道而驰,可能会导致并发数据修改的所有问题,因为它作为一个副本存在于 JVM 内存中,并且使用它的所有方法共享相同的值。这就是为什么静态属性通常用于两个目的:
- 存储一个常数—一个可以读取但不能修改的值(也称为只读值)
- 存储无状态对象,该对象的创建成本很高或保留只读值
常量的典型示例是资源的名称:
class SomeClass{
public static final String INPUT_FILE_NAME = "myFile.csv";
}
注意static属性前面的final关键字。它告诉编译器和 JVM 这个值一旦分配就不能改变。尝试这样做会产生错误。它有助于保护该值并清楚地表达将该值作为常量的意图。当人们试图理解代码的工作原理时,这些看似很小的细节使代码更容易理解。
也就是说,考虑使用接口来达到这样的目的。由于 Java1.8,接口中声明的所有字段都是隐式静态和final的,因此忘记将值声明为final的可能性较小。我们将很快讨论接口。
当一个对象被声明为静态final类属性时,并不意味着它的所有属性都自动成为final。它只保护属性不被分配同一类型的另一个对象,我们将在第 8 章、“多线程和并发处理”中讨论并发访问对象属性的复杂过程。然而,程序员通常使用静态final对象来存储只读的值,这些值只是按照在应用中使用的方式来存储的。典型的例子是应用配置信息。一旦从磁盘读取后创建,它就不会更改,即使可以更改。此外,数据的缓存是从外部资源获得的
同样,在将此类类属性用于此目的之前,请考虑使用一个接口,该接口提供更多支持只读功能的默认行为
与静态属性类似,可以在不创建类实例的情况下调用静态方法。例如,考虑以下类:
class SomeClass{
public static String someMethod() {
return "abc";
}
}
我们可以只使用类名来调用前面的方法:
System.out.println(SomeClass.someMethod()); //prints: abc
接口
在“抽象/接口”部分中,我们一般地讨论了接口。在本节中,我们将描述一个表示它的 Java 语言构造
一个接口显示了一个对象的期望值。它隐藏了实现,并且只公开带有返回值的方法签名。例如,下面是一个接口,它声明了两个抽象方法:
interface SomeInterface {
void method1();
String method2(int i);
}
下面是一个实现它的类:
class SomeClass implements SomeInterface{
public void method1(){
//method body
}
public String method2(int i) {
//method body
return "abc";
}
}
无法实例化接口。只有创建实现此接口的类的对象,才能创建接口类型的对象:
SomeInterface si = new SomeClass();
如果没有实现接口的所有抽象方法,则必须将类声明为抽象的,并且不能实例化(参见“接口与抽象类”部分)
接口不描述如何创建类的对象。要发现这一点,必须查看该类并查看它有哪些构造器。接口也不描述静态类方法。因此,接口只是类实例(对象)的公共面。
在 Java8 中,接口不仅具有抽象方法(没有主体),而且具有真正实现的方法。根据 Java 语言规范,“接口的主体可以声明接口的成员,即字段、方法、类和接口。”如此宽泛的语句提出了一个问题:接口和类有什么区别?我们已经指出的一个主要区别是:不能实例化接口;只能实例化类。
另一个区别是在接口内部实现的非静态方法被声明为default或private。相反,default声明对于类方法不可用。
此外,接口中的字段是隐式公共的、静态的和最终的。相比之下,默认情况下,类属性和方法不是静态的或最终的。类本身、其字段、方法和构造器的隐式(默认)访问修饰符是包私有的,这意味着它只在自己的包中可见
默认方法
为了了解接口中默认方法的功能,让我们看一个接口和实现它的类的示例,如下所示:
interface SomeInterface {
void method1();
String method2(int i);
default int method3(){
return 42;
}
}
class SomeClass implements SomeInterface{
public void method1(){
//method body
}
public String method2(int i) {
//method body
return "abc";
}
}
我们现在可以创建一个SomeClass类的对象并进行以下调用:
SomeClass sc = new SomeClass();
sc.method1();
sc.method2(22); //returns: "abc"
sc.method3(); //returns: 42
如您所见,method3()并没有在SomeClass类中实现,但是看起来好像该类已经实现了它。这是一种将新方法添加到现有类而不更改它的方法,方法是将默认方法添加到类实现的接口。
现在我们也将method3()实现添加到类中,如下所示:
class SomeClass implements SomeInterface{
public void method1(){
//method body
}
public String method2(int i) {
//method body
return "abc";
}
public int method3(){
return 15;
}
}
现在忽略method3()的接口实现:
SomeClass sc = new SomeClass();
sc.method1();
sc.method2(22); //returns: "abc"
sc.method3(); //returns: 15
接口中默认方法的目的是为类(实现此接口的类)提供一个新方法,而不更改它们。但是一旦类实现了新方法,接口实现就会被忽略。
私有方法
如果接口中有多个默认方法,则可以创建只能由接口的默认方法访问的私有方法。它们可以用来包含公共功能,而不是在每个默认方法中重复:
interface SomeInterface {
void method1();
String method2(int i);
default int method3(){
return getNumber();
}
default int method4(){
return getNumber() + 22;
}
private int getNumber(){
return 42;
}
}
私有方法的这个概念与类中的私有方法没有什么不同(参见“包、导入和访问”部分)。无法从接口外部访问私有方法。
静态字段和方法
自 Java8 以来,接口中声明的所有字段都是隐式公共、静态和final常量。这就是为什么接口是常量的首选位置。你不需要在他们的声明中加上public static final。
至于静态方法,它们在接口中的作用方式与在类中的作用方式相同:
interface SomeInterface{
static String someMethod() {
return "abc";
}
}
注意,不需要将接口方法标记为public。默认情况下,所有非私有接口方法都是公共的。
我们可以只使用一个接口名来调用前面的方法:
System.out.println(SomeInetrface.someMethod()); //prints: abc
接口与抽象类
我们已经提到类可以声明为abstract。它可能是我们不希望实例化的常规类,也可能是包含(或继承)抽象方法的类。在后一种情况下,我们必须将此类类声明为abstract,以避免编译错误。
在许多方面,抽象类与接口非常相似。它强制扩展它的每个子类实现抽象方法。否则,子级不能实例化,必须声明为抽象本身
但是,接口和抽象类之间的一些主要区别使它们在不同的情况下都很有用:
- 抽象类可以有构造器,而接口不能。
- 抽象类可以有状态,而接口不能。
- 抽象类的字段可以是
public、private或protected、static或final或final,而在接口中,字段总是public、static、final。 - 抽象类中的方法可以是
public、private或protected,接口方法只能是public或private。 - 如果要修改的类已经扩展了另一个类,则不能使用抽象类,但可以实现接口,因为一个类只能扩展另一个类,但可以实现多个接口
参见“多态实践”一节中的抽象用法示例。
重载、覆盖和隐藏
我们已经在继承和“抽象/接口”部分中提到了覆盖。它将父类中实现的非静态方法替换为子类中具有相同签名的方法。接口的默认方法也可以在扩展它的接口中覆盖。
隐藏类似于覆盖,但仅适用于静态方法和静态以及实例属性。
重载是在同一个类或接口中创建几个具有相同名称和不同参数(因此,不同的签名)的方法
在本节中,我们将讨论所有这些概念,并演示它们如何用于类和接口。
重载
不可能在同一接口中有两个方法,也不可能在一个类中有相同的签名。要有不同的签名,新方法必须有新名称或不同的参数类型列表(类型的顺序很重要)。有两个同名但参数类型列表不同的方法构成重载。下面是一些在接口中重载的合法方法的示例:
interface A {
int m(String s);
int m(String s, double d);
default int m(String s, int i) { return 1; }
static int m(String s, int i, double d) { return 1; }
}
请注意,前面的两个方法没有相同的签名,包括default和static方法,否则将生成编译器的错误。指定为默认值或静态值都不会在重载中起任何作用。返回类型也不影响重载。我们到处使用int作为返回类型,只是为了让示例不那么混乱。
方法重载在类中的执行方式类似:
class C {
int m(String s){ return 42; }
int m(String s, double d){ return 42; }
static int m(String s, double d, int i) { return 1; }
}
在哪里声明具有相同名称的方法并不重要。下面的方法重载与前面的示例没有区别,如下所示:
interface A {
int m(String s);
int m(String s, double d);
}
interface B extends A {
default int m(String s, int i) { return 1; }
static int m(String s, int i, double d) { return 1; }
}
class C {
int m(String s){ return 42; }
}
class D extends C {
int m(String s, double d){ return 42; }
static int m(String s, double d, int i) { return 1; }
}
私有非静态方法只能由同一类的非静态方法重载。
当方法具有相同名称但参数类型列表不同,并且属于同一接口(或类)或不同接口(或类),其中一个接口是另一个接口的祖先时,就会发生重载。私有方法只能由同一类中的方法重载。
覆盖
与重载不同的是,重载发生在静态和非静态方法中,方法覆盖只发生在非静态方法中,并且只有当它们具有完全相同的签名和属于不同的接口(或类),其中一个接口是另一个接口的祖先。
覆盖方法驻留在子接口(或类)中,而覆盖方法具有相同的签名,并且属于某个祖先接口(或类)。不能覆盖私有方法。
以下是在接口中覆盖方法的示例:
interface A {
default void method(){
System.out.println("interface A");
}
}
interface B extends A{
@Override
default void method(){
System.out.println("interface B");
}
}
class C implements B { }
如果我们使用C类实例调用method(),结果如下:
C c = new C();
c.method(); //prints: interface B
请注意注解@Override的用法。它告诉编译器程序员认为带注解的方法覆盖了一个祖先接口的方法。通过这种方式,编译器可以确保覆盖确实发生,否则会生成错误。例如,程序员可能会将方法的名称拼错如下:
interface B extends A{
@Override
default void metod(){
System.out.println("interface B");
}
}
如果发生这种情况,编译器会生成一个错误,因为没有方法metod()可以覆盖。如果没有注解@Overrride,这个错误可能会被程序员忽略,结果会截然不同:
C c = new C();
c.method(); //prints: interface A
覆盖的规则同样适用于类实例方法。在下面的示例中,C2类覆盖了C1类的方法:
class C1{
public void method(){
System.out.println("class C1");
}
}
class C2 extends C1{
@Override
public void method(){
System.out.println("class C2");
}
}
结果如下:
C2 c2 = new C2();
c2.method(); //prints: class C2
而且,在具有覆盖方法的类或接口和具有覆盖方法的类或接口之间有多少祖先并不重要:
class C1{
public void method(){
System.out.println("class C1");
}
}
class C3 extends C1{
public void someOtherMethod(){
System.out.println("class C3");
}
}
class C2 extends C3{
@Override
public void method(){
System.out.println("class C2");
}
}
前面方法的覆盖结果仍然相同。
隐藏
隐藏被很多人认为是一个复杂的话题,但不应该是,我们会尽量让它看起来简单。
隐藏这个名字来源于类和接口的静态属性和方法的行为。每个静态属性或方法在 JVM 内存中作为单个副本存在,因为它们与接口或类关联,而不是与对象关联。接口或类作为单个副本存在。这就是为什么我们不能说子级的静态属性或方法覆盖父级的具有相同名称的静态属性或方法。当类或接口被加载时,所有静态属性和方法只被加载到内存中一次,并且保持在那里,而不是复制到任何地方。让我们看看这个例子。
让我们创建两个具有父子关系和具有相同名称的静态字段和方法的接口:
interface A {
String NAME = "interface A";
static void method() {
System.out.println("interface A");
}
}
interface B extends A {
String NAME = "interface B";
static void method() {
System.out.println("interface B");
}
}
请注意接口字段标识符的大写字母。这就是通常用来表示常量的约定,不管它是在接口中还是在类中声明的。只是提醒您,Java 中的常量是一个变量,一旦初始化,就不能重新分配另一个值。接口字段默认为常量,因为接口中的任何字段都是最终的(请参阅“最终属性、方法和类”部分)。
如果从B接口打印NAME并执行其method(),则得到如下结果:
System.out.println(B.NAME); //prints: interface B
B.method(); //prints: interface B
它看起来很像覆盖,但实际上,它只是我们调用与这个特定接口相关联的特定属性或方法。
类似地,考虑以下类:
public class C {
public static String NAME = "class C";
public static void method(){
System.out.println("class C");
}
public String name1 = "class C";
}
public class D extends C {
public static String NAME = "class D";
public static void method(){
System.out.println("class D");
}
public String name1 = "class D";
}
如果我们尝试使用类本身访问D类的静态成员,我们将得到我们所要求的:
System.out.println(D.NAME); //prints: class D
D.method(); //prints: class D
只有在使用对象访问属性或静态方法时才会出现混淆:
C obj = new D();
System.out.println(obj.NAME); //prints: class C
System.out.println(((D) obj).NAME); //prints: class D
obj.method(); //prints: class C
((D)obj).method(); //prints: class D
System.out.println(obj.name1); //prints: class C
System.out.println(((D) obj).name1); //prints: class D
obj变量引用了D类的对象,强制转换证明了这一点,如前面的示例所示。但是,即使我们使用对象,尝试访问静态属性或方法也会带来用作声明变量类型的类的成员。对于示例最后两行中的实例属性,Java 中的属性不符合多态行为,我们得到父C类的name1属性,而不是子D类的预期属性。
若要避免与类的静态成员混淆,请始终使用类而不是对象访问它们。若要避免与实例属性混淆,请始终将它们声明为私有并通过方法访问。
要演示最后一个技巧,请考虑以下类:
class X {
private String name = "class X";
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
class Y extends X {
private String name = "class Y";
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
如果我们对实例属性执行与对类C和D相同的测试,结果将是:
X x = new Y();
System.out.println(x.getName()); //prints: class Y
System.out.println(((Y)x).getName()); //prints: class Y
现在我们使用方法访问实例属性,这些方法是覆盖效果的主题,不再有意外的结果。
为了结束在 Java 中隐藏的讨论,我们想提到另一种类型的隐藏,即当局部变量隐藏具有相同名称的实例或静态属性时。下面是一个类:
public class HidingProperty {
private static String name1 = "static property";
private String name2 = "instance property";
public void method() {
var name1 = "local variable";
System.out.println(name1); //prints: local variable
var name2 = "local variable"; //prints: local variable
System.out.println(name2);
System.out.println(HidingProperty.name1); //prints: static property
System.out.println(this.name2); //prints: instance property
}
}
如您所见,局部变量name1隐藏同名的静态属性,而局部变量name2隐藏实例属性。仍然可以使用类名访问静态属性(参见HidingProperty.name1。请注意,尽管声明为private,但可以从类内部访问它
实例属性总是可以通过使用this关键字来访问,该关键字表示当前对象。
最终变量、方法和类
在 Java 中,我们已经多次提到了与常量概念相关的一个final属性。但这只是使用final关键字的一种情况,它一般可以应用于任何变量。此外,类似的约束也可以应用于方法,甚至类,从而防止方法被覆盖和类被扩展。
最终变量
变量声明前面的final关键字使该变量在初始化后不可变。例如:
final String s = "abc";
初始化甚至可以延迟:
final String s;
s = "abc";
对于对象属性,此延迟只能持续到创建对象为止。这意味着可以在构造器中初始化属性。例如:
private static class A {
private final String s1 = "abc";
private final String s2;
private final String s3; //error
private final int x; //error
public A() {
this.s1 = "xyz"; //error
this.s2 = "xyz";
}
}
请注意,即使在对象构造期间,也不可能在声明期间和构造器中两次初始化属性。另外值得注意的是,final属性必须显式初始化。从前面的示例中可以看到,编译器不允许将final属性初始化为默认值。
也可以初始化初始化块中的final属性:
class B {
private final String s1 = "abc";
private final String s2;
{
s1 = "xyz"; //error
s2 = "abc";
}
}
对于静态属性,无法在构造器中对其进行初始化,因此必须在其声明期间或在静态初始化块中对其进行初始化:
class C {
private final static String s1 = "abc";
private final static String s2;
static {
s1 = "xyz"; //error
s2 = "abc";
}
}
在接口中,所有字段都是final,即使它们没有声明为final。由于接口中不允许使用构造器或初始化块,因此初始化接口字段的唯一方法是在声明期间。否则会导致编译错误:
interface I {
String s1; //error
String s2 = "abc";
}
最终方法
声明为final的方法不能在子类中覆盖,也不能在静态方法中隐藏。例如,java.lang.Object类是 Java 中所有类的祖先,它的一些方法声明为final:
public final Class getClass()
public final void notify()
public final void notifyAll()
public final void wait() throws InterruptedException
public final void wait(long timeout) throws InterruptedException
public final void wait(long timeout, int nanos)
throws InterruptedException
final类的所有私有方法和未继承方法实际上都是final的,因为您不能覆盖它们。
最终类
final类不能扩展。它不能有子项,这使得所有的方法都有效的final。此功能用于安全性,或者当程序员希望确保类功能不能由于某些其他设计考虑而被覆盖、重载或隐藏时。
多态的作用
多态是 OOP 最强大、最有用的特性。它使用了我们目前介绍的所有其他面向对象的概念和特性。这是掌握 Java 编程的最高概念点。之后,本书的其余部分将主要介绍 Java 语言语法和 JVM 功能
正如我们在“OOP 概念”一节中所述,多态是一个对象作为不同类的对象或作为不同接口的实现的能力。如果你在网上搜索“多态”这个词,你会发现它是“以几种不同形式出现的状态”。“变形”是“通过自然或超自然的方式,将一个事物或人的形式或性质改变为一种完全不同的形式或性质”。所以,Java 多态是一个对象在不同的条件下表现出完全不同的行为的能力,就像经历了一次蜕变。
我们将使用对象工厂——工厂的具体编程实现,这是一种返回不同原型或类的对象的方法(参见《面向对象程序设计》以实际动手的方式提出这个概念。*
对象工厂
对象工厂背后的思想是创建一个方法,在特定条件下返回特定类型的新对象。例如,查看CalcUsingAlg1和CalcUsingAlg2类:
interface CalcSomething{ double calculate(); }
class CalcUsingAlg1 implements CalcSomething{
public double calculate(){ return 42.1; }
}
class CalcUsingAlg2 implements CalcSomething{
private int prop1;
private double prop2;
public CalcUsingAlg2(int prop1, double prop2) {
this.prop1 = prop1;
this.prop2 = prop2;
}
public double calculate(){ return prop1 * prop2; }
}
如您所见,它们都实现相同的接口CalcSomething,但使用不同的算法。现在,假设我们决定在属性文件中选择所使用的算法。然后我们可以创建以下对象工厂:
class CalcFactory{
public static CalcSomething getCalculator(){
String alg = getAlgValueFromPropertyFile();
switch(alg){
case "1":
return new CalcUsingAlg1();
case "2":
int p1 = getAlg2Prop1FromPropertyFile();
double p2 = getAlg2Prop2FromPropertyFile();
return new CalcUsingAlg2(p1, p2);
default:
System.out.println("Unknown value " + alg);
return new CalcUsingAlg1();
}
}
}
工厂根据getAlgValueFromPropertyFile()方法返回的值选择要使用的算法,对于第二种算法,工厂还使用getAlg2Prop1FromPropertyFile()方法和getAlg2Prop2FromPropertyFile()方法获取算法的输入参数。但这种复杂性对客户来说是隐藏的:
CalcSomething calc = CalcFactory.getCalculator();
double result = calc.calculate();
我们可以添加新的算法变体,改变源代码中的算法参数或算法选择的过程,但是客户端不需要改变代码。这就是多态的力量。
或者,我们可以使用继承来实现多态行为。考虑以下类别:
class CalcSomething{
public double calculate(){ return 42.1; }
}
class CalcUsingAlg2 extends CalcSomething{
private int prop1;
private double prop2;
public CalcUsingAlg2(int prop1, double prop2) {
this.prop1 = prop1;
this.prop2 = prop2;
}
public double calculate(){ return prop1 * prop2; }
}
那么我们的工厂可能会如下所示:
class CalcFactory{
public static CalcSomething getCalculator(){
String alg = getAlgValueFromPropertyFile();
switch(alg){
case "1":
return new CalcSomething();
case "2":
int p1 = getAlg2Prop1FromPropertyFile();
double p2 = getAlg2Prop2FromPropertyFile();
return new CalcUsingAlg2(p1, p2);
default:
System.out.println("Unknown value " + alg);
return new CalcSomething();
}
}
}
但客户端代码不变:
CalcSomething calc = CalcFactory.getCalculator();
double result = calc.calculate();
如果有选择的话,有经验的程序员会使用一个公共接口来实现。它允许更灵活的设计,因为 Java 中的类可以实现多个接口,但可以扩展(继承)一个类。
实例运算符
不幸的是,生活并不总是那么简单,有时,程序员不得不处理一个由不相关的类(甚至来自不同的框架)组装而成的代码,在这种情况下,使用多态可能不是一个选择。尽管如此,您仍然可以隐藏算法选择的复杂性,甚至可以使用instanceof操作符模拟多态行为,当对象是某个类的实例时,该操作符返回true。
假设我们有两个不相关的类:
class CalcUsingAlg1 {
public double calculate(CalcInput1 input){
return 42\. * input.getProp1();
}
}
class CalcUsingAlg2{
public double calculate(CalcInput2 input){
return input.getProp2() * input.getProp1();
}
}
每个类都需要一个特定类型的对象作为输入:
class CalcInput1{
private int prop1;
public CalcInput1(int prop1) { this.prop1 = prop1; }
public int getProp1() { return prop1; }
}
class CalcInput2{
private int prop1;
private double prop2;
public CalcInput2(int prop1, double prop2) {
this.prop1 = prop1;
this.prop2 = prop2;
}
public int getProp1() { return prop1; }
public double getProp2() { return prop2; }
}
假设我们实现的方法接收到这样一个对象:
void calculate(Object input) {
double result = Calculator.calculate(input);
//other code follows
}
我们在这里仍然使用多态,因为我们将输入描述为Object类型,我们可以这样做,因为Object类是所有 Java 类的基类。
现在让我们看看Calculator类是如何实现的:
class Calculator{
public static double calculate(Object input){
if(input instanceof CalcInput1){
return new CalcUsingAlg1().calculate((CalcInput1)input);
} else if (input instanceof CalcInput2){
return new CalcUsingAlg2().calculate((CalcInput2)input);
} else {
throw new RuntimeException("Unknown input type " +
input.getClass().getCanonicalName());
}
}
}
如您所见,它使用instanceof操作符来选择适当的算法。通过使用Object类作为输入类型,Calculator类也利用了多态,但它的大多数实现与之无关。然而,从外面看,它看起来是多态的,确实如此,但只是在一定程度上。
总结
本章向读者介绍了 OOP 的概念以及它们是如何在 Java 中实现的。它提供了每个概念的解释,并演示了如何在特定的代码示例中使用它。详细讨论了class和interface的 Java 语言结构。读者还了解了什么是重载、覆盖和隐藏,以及如何使用final关键字保护方法不被覆盖
从“多态的作用”部分,读者了解了多态强大的 Java 特性。本节将所有呈现的材料放在一起,并展示了多态如何保持在 OOP 的中心。
在下一章中,读者将熟悉 Java 语言语法,包括包、导入、访问修饰符、保留关键字和限制关键字,以及 Java 引用类型的一些方面。读者还将学习如何使用this和super关键字,原始类型的加宽和缩小转换是什么,装箱和拆箱,原始类型和引用类型的赋值,以及引用类型的equals()方法是如何工作的。
测验
-
从以下列表中选择所有正确的 OOP 概念:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
-
从以下列表中选择所有正确的语句:
三、Java 基础
本章向读者展示了 Java 作为一种语言的更详细的视图。从包中的代码组织、类(接口)的可访问性级别及其方法和属性(字段)的描述入手,详细介绍了 Java 面向对象的主要类型&引用类型,并给出了保留关键字和限制关键字的列表,讨论了它们的用法。本章最后介绍了原始类型之间的转换方法,以及从原始类型到相应引用类型的转换方法。
这些是 Java 语言的基本术语和特性。他们理解的重要性怎么强调都不为过。没有它们,就不能编写任何 Java 程序。所以,尽量不要匆匆读完这一章,确保你理解了所有的内容。
本章将讨论以下主题:
- 包、导入和访问
- Java 引用类型
- 保留和限制关键字
this和super关键字的用法- 在原始类型之间转换
- 在原始类型和引用类型之间转换
包、导入和访问
如您所知,包名反映了目录结构,从包含.java文件的项目目录开始。每个.java文件的名称必须与其中声明的顶级类的名称相同(该类可以包含其他类)。.java文件的第一行是package语句,该语句以package关键字开头,后跟实际的包名—指向此文件的目录路径,其中斜杠替换为点
包名和类名一起构成一个完全限定类名。它唯一地标识类,但往往太长,使用起来不方便。也就是说,当导入成功时,只允许指定一次完全限定名,然后只通过类名引用类。
只有调用方能够访问某个类及其方法时,才能从另一个类的方法调用该类的方法。访问修饰符public、protected和private定义了可访问性级别,并允许(或不允许)某些方法、属性,甚至类本身对其他类可见。
本节将详细讨论所有这些方面。
包
让我们看看我们称之为Packages的类:
package com.packt.learnjava.ch03_fundamentals;
import com.packt.learnjava.ch02_oop.hiding.C;
import com.packt.learnjava.ch02_oop.hiding.D;
public class Packages {
public void method(){
C c = new C();
D d = new D();
}
}
Packages类中的第一行是一个包声明,它标识源树上的类位置,或者换句话说,文件系统中的.java文件位置。在编译类并生成包含字节码的.class文件时,包名还反映了文件系统中的.class文件位置
导入
在包声明之后,import语句如下。从前面的示例中可以看出,它们允许避免在当前类的任何其他位置使用完全限定的类(或接口)名称。当导入来自同一个包的多个类(和接口)时,可以使用符号*将来自同一个包的所有类和接口作为一个组导入。在我们的示例中,它如下所示:
import com.packt.learnjava.ch02_oop.hiding.*;
但这不是推荐的做法,因为当几个包作为一个组导入时,它会隐藏导入的类(和接口)位置。例如,请看以下代码段:
package com.packt.learnjava.ch03_fundamentals;
import com.packt.learnjava.ch02_oop.*;
import com.packt.learnjava.ch02_oop.hiding.*;
public class Packages {
public void method(){
C c = new C();
D d = new D();
}
}
在前面的代码中,您能猜出类C或类D属于哪个包吗?另外,不同包中的两个类可能具有相同的名称。如果是这样,组导入可能会造成混乱,甚至是难以解决的问题。
也可以导入单个静态类(或接口)成员。例如,如果SomeInterface有一个NAME属性(提醒您,接口属性默认为public和static),您通常可以如下引用它:
package com.packt.learnjava.ch03_fundamentals;
import com.packt.learnjava.ch02_oop.SomeInterface;
public class Packages {
public void method(){
System.out.println(SomeInterface.NAME);
}
}
为了避免使用接口名称,可以使用静态导入:
package com.packt.learnjava.ch03_fundamentals;
import static com.packt.learnjava.ch02_oop.SomeInterface.NAME;
public class Packages {
public void method(){
System.out.println(NAME);
}
}
类似地,如果SomeClass具有公共静态属性someProperty和公共静态方法someMethod(),则也可以静态地导入它们:
package com.packt.learnjava.ch03_fundamentals;
import com.packt.learnjava.ch02_oop.StaticMembers.SomeClass;
import com.packt.learnjava.ch02_oop.hiding.C;
import com.packt.learnjava.ch02_oop.hiding.D;
import static com.packt.learnjava.ch02_oop.StaticMembers
.SomeClass.someMethod;
import static com.packt.learnjava.ch02_oop.StaticMembers
.SomeClass.SOME_PROPERTY;
public class Packages {
public static void main(String... args){
C c = new C();
D d = new D();
SomeClass obj = new SomeClass();
someMethod(42);
System.out.println(SOME_PROPERTY); //prints: abc
}
}
但是应该明智地使用这种技术,因为它可能会造成静态导入的方法或属性属于当前类的印象。
访问修饰符
我们已经在我们的示例中使用了三个访问修饰符-public、protected和private-它们控制对类、接口和,还有第四个隐式的(也称为默认修饰符包级private),当没有指定三个显式访问修饰符时应用。
它们的使用效果非常简单:
public:可访问当前包和其他包的其他类和接口protected:只允许同一个包的其他成员和该类的子级访问- 无访问修饰符表示仅可由同一包的其他成员访问
private:只允许同一类成员访问
从类或接口内部,所有的类或接口成员总是可以访问的。此外,正如我们已经多次声明的那样,除非声明为private,否则所有接口成员在默认情况下都是公共的。
另外,请注意,类可访问性取代了类成员的可访问性,因为如果类本身不能从某个地方访问,那么对其方法或属性的可访问性的任何更改都不能使它们可访问。
当人们谈论类和接口的访问修饰符时,他们指的是在其他类或接口中声明的类和接口。包含的类或接口称为顶级类或接口,其中的类或接口称为内部类或接口。静态内部类也称为静态嵌套类。
声明顶级类或接口private是没有意义的,因为它不能从任何地方访问。Java 作者决定不允许顶级类或接口也被声明protected。但是,有一个没有显式访问修饰符的类是可能的,这样就使得它只能被同一个包的成员访问。
举个例子:
public class AccessModifiers {
String prop1;
private String prop2;
protected String prop3;
public String prop4;
void method1(){ }
private void method2(){ }
protected void method3(){ }
public void method4(){ }
class A1{ }
private class A2{ }
protected class A3{ }
public class A4{ }
interface I1 {}
private interface I2 {}
protected interface I3 {}
public interface I4 {}
}
请注意,静态嵌套类无权访问顶级类的其他成员。
*内部类的另一个特殊特性是它可以访问顶级类的所有成员,甚至私有成员,反之亦然。为了演示此功能,让我们在顶级类和私有内部类中创建以下私有属性和方法:
public class AccessModifiers {
private String topLevelPrivateProperty = "Top-level private value";
private void topLevelPrivateMethod(){
var inner = new InnerClass();
System.out.println(inner.innerPrivateProperty);
inner.innerPrivateMethod();
}
private class InnerClass {
//private static String PROP = "Inner static"; //error
private String innerPrivateProperty = "Inner private value";
private void innerPrivateMethod(){
System.out.println(topLevelPrivateProperty);
}
}
private static class InnerStaticClass {
private static String PROP = "Inner private static";
private String innerPrivateProperty = "Inner private value";
private void innerPrivateMethod(){
var top = new AccessModifiers();
System.out.println(top.topLevelPrivateProperty);
}
}
}
如您所见,前面类中的所有方法和属性都是私有的,这意味着通常不能从类外部访问它们。对于AccessModifiers类也是如此:它的私有方法和属性对于在它之外声明的其他类是不可访问的。但是InnerClass类可以访问顶级类的私有成员,而顶级类可以访问其内部类的私有成员。唯一的限制是非静态内部类不能有静态成员。相比之下,静态嵌套类可以同时具有静态和非静态成员,这使得静态嵌套类更加可用。
为了演示所描述的所有可能性,我们在类AccessModifiers中添加了以下main()方法:
public static void main(String... args){
var top = new AccessModifiers();
top.topLevelPrivateMethod();
//var inner = new InnerClass(); //error
System.out.println(InnerStaticClass.PROP);
var inner = new InnerStaticClass();
System.out.println(inner.innerPrivateProperty);
inner.innerPrivateMethod();
}
自然地,不能从顶级类的静态上下文访问非静态内部类,因此前面代码中的注释是无效的。如果我们运行它,结果如下:

输出的前两行来自topLevelPrivateMethod(),其余来自main()方法。如您所见,内部类和顶级类可以访问彼此的私有状态,从外部无法访问。
Java 引用类型
new操作符创建一个类的对象,并返回对该对象所在内存的引用。从实际的角度来看,保存此引用的变量在代码中被视为对象本身。此类变量的类型可以是类、接口、数组或指示未向该变量分配内存引用的null文本。如果引用的类型是一个接口,则可以将其分配给null或对实现该接口的类的对象的引用,因为接口本身无法实例化。
JVM 监视所有创建的对象,并检查当前执行的代码中是否有对每个对象的引用。如果有一个对象没有任何引用,JVM 会在名为垃圾收集的进程中将其从内存中移除。我们将在第 9 章、“JVM 结构和垃圾收集”中描述这个过程。例如,在方法执行期间创建了一个对象,并由局部变量引用。此引用将在方法完成执行后立即消失。
您已经看到了定制类和接口的示例,我们已经讨论了String类(参见第 1 章、“Java12 入门”)。在本节中,我们还将描述另外两种 Java 引用类型数组和枚举,并演示如何使用它们
类和接口
类类型的变量使用相应的类名声明:
<Class name> identifier;
可分配给此类变量的值可以是以下值之一:
- 引用类型字面值
null(表示可以使用变量,但不引用任何对象) - 对同一类的对象或其任何子对象的引用(因为子对象继承其所有祖先的类型)
最后一种类型的赋值被称为加宽赋值,因为它迫使一个特化的引用变得不那么专业化。例如,由于每个 Java 类都是java.lang.Object的子类,因此可以对任何类进行以下赋值:
Object obj = new AnyClassName();
这种赋值也被称为向上转型,因为它将变量的类型在继承线上上移(与任何家谱树一样,通常在最上面显示最早的祖先)。
在这样的向上转型之后,可以使用转型操作符(type)进行缩小分配:
AnyClassName anyClassName = (AnyClassName)obj;
这样的赋值也称为向下转型,允许您恢复子体类型。要应用此操作,必须确保标识符实际上引用了子体类型。如果有疑问,可以使用instanceof操作符(参见第 2 章、"Java 面向对象编程")检查引用类型。
类似地,如果类实现某个接口,则可以将其对象引用指定给该接口或该接口的任何祖先:
interface C {}
interface B extends C {}
class A implements B { }
B b = new A();
C c = new A();
A a1 = (A)b;
A a2 = (A)c;
如您所见,在类引用向上转换和向下转换的情况下,在将对象的引用分配给某个实现接口类型的变量之后,可以恢复该对象的原始类型
本节的内容也可以看作 Java 多态的另一个实际演示。
数组
数组是一种引用类型,因此也扩展了java.lang.Object类。数组元素的类型与声明的数组类型相同。元素的数目可以是零,在这种情况下,数组被称为空数组。每个元素都可以被一个索引访问,索引是正整数或零。第一个元素的索引为零。元素的数量称为数组长度。数组一旦创建,其长度就不会改变。
以下是数组声明的示例:
int[] intArray;
float[][] floatArray;
String[] stringArray;
SomeClass[][][] arr;
每个括号对表示另一个维度。括号对的数目是数组的嵌套深度:
int[] intArray = new int[10];
float[][] floatArray = new float[3][4];
String[] stringArray = new String[2];
SomeClass[][][] arr = new SomeClass[3][5][2];
new操作符为以后可以赋值(填充)的每个元素分配内存。但是数组的元素在创建时被初始化为默认值,如下例所示:
System.out.println(intArray[3]); //prints: 0
System.out.println(floatArray[2][2]); //prints: 0.0
System.out.println(stringArray[1]); //prints: null
创建数组的另一种方法是使用数组初始化器,即用逗号分隔的值列表,每个维度都用大括号括起来。例如:
int[] intArray = {1,2,3,4,5,6,7,8,9,10};
float[][] floatArray ={{1.1f,2.2f,3,2},{10,20.f,30.f,5},{1,2,3,4}};
String[] stringArray = {"abc", "a23"};
System.out.println(intArray[3]); //prints: 4
System.out.println(floatArray[2][2]); //prints: 3.0
System.out.println(stringArray[1]); //prints: a23
可以创建多维数组,而无需声明每个维度的长度。只有第一个维度必须指定长度:
float[][] floatArray = new float[3][];
System.out.println(floatArray.length); //prints: 3
System.out.println(floatArray[0]); //prints: null
System.out.println(floatArray[1]); //prints: null
System.out.println(floatArray[2]); //prints: null
//System.out.println(floatArray[3]); //error
//System.out.println(floatArray[2][2]); //error
其他尺寸的缺失长度可以稍后指定:
float[][] floatArray = new float[3][];
floatArray[0] = new float[4];
floatArray[1] = new float[3];
floatArray[2] = new float[7];
System.out.println(floatArray[2][5]); //prints: 0.0
这样,就可以为不同的尺寸指定不同的长度。使用数组初始化器,还可以创建不同长度的维度:
float[][] floatArray ={{1.1f},{10,5},{1,2,3,4}};
唯一的要求是在使用维度之前必须对其进行初始化。
枚举
枚举引用类型类扩展了java.lang.Enum类,后者又扩展了java.lang.Object。它允许指定一组有限的常量,每个常量都是同一类型的实例。此类集合的声明以关键字enum开始。举个例子:
enum Season { SPRING, SUMMER, AUTUMN, WINTER }
所列的每一项–SPRING、SUMMER、AUTUMN和WINTER–都是Season 类型的实例。它们是Season类仅有的四个实例。它们是预先创建的,可以作为Season类型的值在任何地方使用。无法创建Season类的其他实例。这就是创建enum类型的原因:当一个类的实例列表必须限制为固定的集合时,可以使用它。
enum声明也可以用驼色字母写:
enum Season { Spring, Summer, Autumn, Winter }
但是,使用全部大写样式的频率更高,因为正如我们前面提到的,有一个约定,在大写情况下表示静态最终常量的标识符。它有助于区分常量和变量。enum常量是静态的,隐式地是最终的。
因为enum值是常量,所以它们在 JVM 中是唯一存在的,可以通过引用进行比较:
Season season = Season.WINTER;
boolean b = season == Season.WINTER;
System.out.println(b); //prints: true
以下是java.lang.Enum类中最常用的方法:
name():按声明时的拼写返回enum常量的标识符(例如WINTER)。toString():默认返回与name()方法相同的值,但可以覆盖以返回任何其他String值。ordinal():返回声明时enum常量的位置(列表中第一个有0序数值)。valueOf(Class enumType, String name):返回enum常量对象,其名称表示为String文本。values():在java.lang.Enum类的文档中没有描述的静态方法。在《Java 语言规范 8.9.3》中,描述为隐式声明。《Java™ 教程》表示编译器在创建enum时会自动添加一些特殊方法,其中静态values()方法按声明顺序返回包含enum所有值的数组。
为了演示上述方法,我们将使用已经熟悉的enum、Season:
enum Season { SPRING, SUMMER, AUTUMN, WINTER }
下面是演示代码:
System.out.println(Season.SPRING.name()); //prints: SPRING
System.out.println(Season.WINTER.toString()); //prints: WINTER
System.out.println(Season.SUMMER.ordinal()); //prints: 1
Season season = Enum.valueOf(Season.class, "AUTUMN");
System.out.println(season == Season.AUTUMN); //prints: true
for(Season s: Season.values()){
System.out.print(s.name() + " ");
//prints: SPRING SUMMER AUTUMN WINTER
}
为了覆盖toString()方法,我们创建enum Season1:
enum Season1 {
SPRING, SUMMER, AUTUMN, WINTER;
public String toString() {
return this.name().charAt(0) +
this.name().substring(1).toLowerCase();
}
}
其工作原理如下:
for(Season1 s: Season1.values()){
System.out.print(s.toString() + " ");
//prints: Spring Summer Autumn Winter
}
可以向每个enum常量添加任何其他属性。例如,让我们为每个enum实例添加一个平均温度值:
enum Season2 {
SPRING(42), SUMMER(67), AUTUMN(32), WINTER(20);
private int temperature;
Season2(int temperature){
this.temperature = temperature;
}
public int getTemperature(){
return this.temperature;
}
public String toString() {
return this.name().charAt(0) +
this.name().substring(1).toLowerCase() +
"(" + this.temperature + ")";
}
}
如果我们迭代enum Season2的值,结果如下:
for(Season2 s: Season2.values()){
System.out.print(s.toString() + " ");
//prints: Spring(42) Summer(67) Autumn(32) Winter(20)
}
在标准 Java 库中,有几个enum类。例如,java.time.Month、java.time.DayOfWeek、java.util.concurrent.TimeUnit
默认值和字面值
我们已经看到,引用类型的默认值是null。一些源代码将其称为特殊类型null,但 Java 语言规范将其限定为文本。当引用类型的实例属性或数组自动初始化时(未显式赋值时),赋值为null
除了null字面值之外,唯一的引用类型是String类,我们在第 1 章、“Java12 入门”中讨论了字符串。
作为方法参数的引用类型
当一个原始类型值被传递到一个方法中时,我们使用它。如果我们不喜欢传递到方法中的值,我们会根据需要进行更改,并且不会三思而后行:
void modifyParameter(int x){
x = 2;
}
我们不担心方法之外的变量值会发生变化:
int x = 1;
modifyParameter(x);
System.out.println(x); //prints: 1
无法在方法之外更改原始类型的参数值,因为原始类型参数是通过值传递到方法的。这意味着值的副本被传递到方法中,因此即使方法中的代码为其指定了不同的值,原始值也不会受到影响。
引用类型的另一个问题是,即使引用本身是通过值传递的,它仍然指向内存中相同的原始对象,因此方法中的代码可以访问该对象并修改它。为了演示它,让我们创建一个DemoClass和使用它的方法:
class DemoClass{
private String prop;
public DemoClass(String prop) { this.prop = prop; }
public String getProp() { return prop; }
public void setProp(String prop) { this.prop = prop; }
}
void modifyParameter(DemoClass obj){
obj.setProp("Changed inside the method");
}
如果我们使用上述方法,结果如下:
DemoClass obj = new DemoClass("Is not changed");
modifyParameter(obj);
System.out.println(obj.getProp()); //prints: Changed inside the method
这是一个很大的区别,不是吗?因此,您必须小心不要修改传入的对象以避免产生不希望的效果。但是,此效果偶尔用于返回结果。但它不属于最佳实践列表,因为它会降低代码的可读性。更改传入对象就像使用一个难以注意的秘密隧道。所以,只有在必要的时候才使用它。
即使传入的对象是一个包装原始类型值的类,这种效果仍然有效(我们将在“原始和引用类型”之间的转换部分讨论原始类型值包装类型),下面是一个DemoClass1和一个重载版本的modifyParameter()方法:
class DemoClass1{
private Integer prop;
public DemoClass1(Integer prop) { this.prop = prop; }
public Integer getProp() { return prop; }
public void setProp(Integer prop) { this.prop = prop; }
}
void modifyParameter(DemoClass1 obj){
obj.setProp(Integer.valueOf(2));
}
如果我们使用上述方法,结果如下:
DemoClass1 obj = new DemoClass1(Integer.valueOf(1));
modifyParameter(obj);
System.out.println(obj.getProp()); //prints: 2
引用类型的这种行为的唯一例外是String类的对象。下面是另一个重载版本的modifyParameter()方法:
void modifyParameter(String obj){
obj = "Changed inside the method";
}
如果我们使用上述方法,结果如下:
String obj = "Is not changed";
modifyParameter(obj);
System.out.println(obj); //prints: Is not changed
obj = new String("Is not changed");
modifyParameter(obj);
System.out.println(obj); //prints: Is not changed
如您所见,无论我们使用一个字面值还是一个新的String对象,结果都是一样的:在给它赋值的方法之后,原始的String值没有改变。这正是我们在第 1 章“Java12 入门”中讨论的String值不变性特性的目的
equals()方法
等式运算符(==应用于引用类型的变量时,比较的是引用本身,而不是对象的内容(状态)。但是两个对象总是有不同的内存引用,即使它们有相同的内容。即使用于String对象,如果至少有一个对象是使用new操作符创建的,操作符(==也会返回false(参见第 1 章“Java12 入门”中关于String值不变性的讨论)。
要比较内容,可以使用equals()方法。它在String类和数值类型包装类(Integer、Float等)中的实现正好可以比较对象的内容
然而,java.lang.Object类中的equals()方法实现只比较引用,这是可以理解的,因为子类可能拥有的内容种类繁多,而泛型内容比较的实现是不可行的。这意味着每一个需要有equals()方法来比较对象内容而不仅仅是引用的 Java 对象都必须重新实现equals()方法,从而在java.lang.Object类中覆盖其实现,如下所示:
public boolean equals(Object obj) {
return (this == obj);
}
相比之下,看看同样的方法是如何在Integer类中实现的:
private final int value;
public boolean equals(Object obj) {
if (obj instanceof Integer) {
return value == ((Integer)obj).intValue();
}
return false;
}
如您所见,它从输入对象中提取原始int值,并将其与当前对象的原始值进行比较。它根本不比较对象引用
另一方面,String类首先比较引用,如果引用的值不相同,则比较对象的内容:
private final byte[] value;
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String aString = (String)anObject;
if (coder() == aString.coder()) {
return isLatin1() ? StringLatin1.equals(value, aString.value)
: StringUTF16.equals(value, aString.value);
}
}
return false;
}
StringLatin1.equals()和StringUTF16.equals()方法逐个字符比较值,而不仅仅是引用值。
类似地,如果应用代码需要按内容比较两个对象,则必须覆盖相应类中的equals()方法。例如,让我们看看熟悉的DemoClass类:
class DemoClass{
private String prop;
public DemoClass(String prop) { this.prop = prop; }
public String getProp() { return prop; }
public void setProp(String prop) { this.prop = prop; }
}
我们可以手动添加equals()方法,但是 IDE 可以帮助我们完成以下操作:
- 在类中右键单击右大括号(
}) - 选择“生成”,然后按照提示进行操作
最终,将生成两个方法并将其添加到类中:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof DemoClass)) return false;
DemoClass demoClass = (DemoClass) o;
return Objects.equals(getProp(), demoClass.getProp());
}
@Override
public int hashCode() {
return Objects.hash(getProp());
}
通过查看生成的代码,我们希望您注意以下几点:
@Override注解的用法:它确保该方法覆盖某个祖先中的方法(具有相同的签名)。有了这个注解,如果您修改了方法并更改了签名(错误地或有意地),编译器(和您的 IDE)将立即引发一个错误,告诉您在任何祖先类中都没有具有这种签名的方法。因此,它有助于及早发现错误。java.util.Objects类的用法:它有很多非常有用的方法,包括equals()静态方法,它不仅比较引用,还使用equals()方法:
public static boolean equals(Object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}
因为,正如我们前面所演示的,在String类中实现的equals()方法根据字符串的内容进行比较,符合我们的目的,因为DemoClass的方法getProp()返回一个字符串
hashCode()方法:这个方法返回的整数唯一地标识这个特定的对象(但是请不要期望它在应用的不同运行之间是相同的)。如果唯一需要的方法是equals(),则不需要实现此方法。尽管如此,我们还是建议在Set或基于哈希码的另一个集合中收集此类的对象时使用它(我们将在第 6 章、“数据结构、泛型和流行工具”中讨论 Java 集合)
这两种方法都在Object中实现,因为许多算法使用equals()和hashCode()方法,如果没有实现这些方法,应用可能无法工作。同时,对象在应用中可能不需要它们。但是,一旦您决定实现equals()方法,也可以实现hasCode()方法。此外,正如您所看到的,IDE 可以做到这一点而不需要任何开销。
保留和受限关键字
关键字是对编译器有特殊意义的词,不能用作标识符。保留关键字 51 个,限制关键字 10 个。保留关键字不能在 Java 代码中的任何地方用作标识符,而受限关键字只能在模块声明的上下文中用作标识符。
保留关键字
以下是所有 Java 保留关键字的列表:
abstract |
assert |
boolean |
break |
byte |
case |
catch |
char |
class |
const |
continue |
default |
do |
double |
else |
enum |
extends |
final |
finally |
float |
for |
if |
goto |
implements |
import |
instanceof |
int |
interface |
long |
native |
new |
package |
private |
protected |
public |
return |
short |
static |
strictfp |
super |
switch |
synchronized |
this |
throw |
throws |
transient |
try |
void |
volatile |
while |
下划线(_也是一个保留字。
到现在为止,您应该对前面的大多数关键字都很熟悉了。通过一个练习,你可以浏览一下清单,看看你记得其中有多少。我们不仅仅讨论了以下八个关键词:
const和goto已保留,但尚未使用assert关键字用于assert语句中(我们将在第 4 章、“处理”中讨论)synchronized关键字用于并发编程(我们将在第 8 章、“多线程和并发处理”中讨论)volatile关键字使变量的值不被缓存transient关键字使变量的值不可序列化strictfp关键字限制浮点计算,使得在对浮点变量执行操作时,每个平台上的结果相同- 关键字 AutoT0:Audio 声明了一种在依赖于平台的代码中实现的方法,如 C 或 C++。
受限关键字
Java 中的 10 个受限关键字如下:
openmodulerequirestransitiveexportsopenstousesprovideswith
它们被称为受限,因为它们不能作为模块声明上下文中的标识符,这在本书中我们将不讨论。在所有其他地方,都可以将它们用作标识符。例如:
String to = "To";
String with = "abc";
尽管可以,但最好不要将它们用作标识符,即使是在模块声明之外
this和super关键字的用法
this关键字提供对当前对象的引用。super关键字引用父类对象。这些关键字允许我们引用在当前上下文和父对象中具有相同名称的变量或方法。
this关键字的用法
下面是最流行的例子:
class A {
private int count;
public void setCount(int count) {
count = count; // 1
}
public int getCount(){
return count; // 2
}
}
第一行看起来模棱两可,但事实上并非如此:局部变量int count隐藏实例私有属性int count。我们可以通过运行以下代码来演示:
A a = new A();
a.setCount(2);
System.out.println(a.getCount()); //prints: 0
使用this关键字修复问题:
class A {
private int count;
public void setCount(int count) {
this.count = count; // 1
}
public int getCount(){
return this.count; // 2
}
}
将this添加到第 1 行允许将值赋给实例属性。在第 2 行中添加this并没有什么区别,但是每次都使用this关键字和instance属性是一个很好的做法。它使代码更具可读性,并有助于避免难以跟踪的错误,例如我们刚刚演示的错误。
我们也看到了equals()方法中的this关键字用法:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof DemoClass)) return false;
DemoClass demoClass = (DemoClass) o;
return Objects.equals(getProp(), demoClass.getProp());
}
并且,为了提醒您,下面是我们在第 2 章、“Java 面向对象编程(OOP)”中介绍的构造器示例:
class TheChildClass extends TheParentClass{
private int x;
private String prop;
private String anotherProp = "abc";
public TheChildClass(String prop){
super(42);
this.prop = prop;
}
public TheChildClass(int arg1, String arg2){
super(arg1);
this.prop = arg2;
}
// methods follow
}
在前面的代码中,您不仅可以看到this关键字,还可以看到super关键字的用法,我们将在下面讨论。
super 关键字的用法
super关键字引用父对象。我们已经在“构造器中的this关键字的用法”部分中看到了它的用法,因为必须先创建父类对象,然后才能创建当前对象。如果构造器的第一行不是super(),则表示父类有一个没有参数的构造器。
当方法被覆盖并且必须调用父类的方法时,super关键字特别有用:
class B {
public void someMethod() {
System.out.println("Method of B class");
}
}
class C extends B {
public void someMethod() {
System.out.println("Method of C class");
}
public void anotherMethod() {
this.someMethod(); //prints: Method of C class
super.someMethod(); //prints: Method of B class
}
}
随着本书的深入,我们将看到更多使用this和super关键字的例子。
在原始类型之间转换
一个数值类型可以容纳的最大数值取决于分配给它的位数。以下是每种数字表示形式的位数:
byte:8 位char:16 位short:16 位int:32 位long:64 位float:32 位double:64 位
当一个数值类型的值被分配给另一个数值类型的变量,并且新类型可以容纳更大的数值时,这种转换被称为加宽转换。否则,它是一个缩小转换,通常需要使用cast操作符进行类型转换
加宽转换
根据 Java 语言规范,有 19 种基本类型转换:
byte至short、int、long、float或doubleshort至int、long、float或doublechar至int、long、float或doubleint至long、float或doublelong至float或doublefloat至double
在整数类型之间以及从某些整数类型到浮点类型的加宽转换过程中,生成的值与原始值完全匹配。然而,从int到float,或从long到float,或从long到double的转换可能会导致精度损失。根据 Java 语言规范,产生的浮点值可以使用IEEE 754 round-to-nearest mode正确舍入。以下几个例子说明了精度的损失:
int i = 123456789;
double d = (double)i;
System.out.println(i - (int)d); //prints: 0
long l1 = 12345678L;
float f1 = (float)l1;
System.out.println(l1 - (long)f1); //prints: 0
long l2 = 123456789L;
float f2 = (float)l2;
System.out.println(l2 - (long)f2); //prints: -3
long l3 = 1234567891111111L;
double d3 = (double)l3;
System.out.println(l3 - (long)d3); //prints: 0
long l4 = 12345678999999999L;
double d4 = (double)l4;
System.out.println(l4 - (long)d4); //prints: -1
如您所见,从int到double的转换保留了值,但是long到float或long到double可能会失去精度。这取决于这个值有多大。所以,如果它对你的计算很重要的话,请注意并考虑到精度的损失。
缩小转换
Java 语言规范确定了 22 种缩小原始类型转换:
short至byte或charchar至byte或shortint至byte、short或charlong至byte、short、char或intfloat至byte、short、char、int或longdouble至byte、short、char、int、long或float
与加宽转换类似,变窄转换可能导致精度损失,甚至值幅度损失。缩小的转换比扩大的转换更复杂,在本书中我们将不讨论它。请务必记住,在执行缩小之前,必须确保原始值小于目标类型的最大值。否则,您可以得到完全不同的值(丢失幅值)。请看以下示例:
System.out.println(Integer.MAX_VALUE); //prints: 2147483647
double d1 = 1234567890.0;
System.out.println((int)d1); //prints: 1234567890
double d2 = 12345678909999999999999.0;
System.out.println((int)d2); //prints: 2147483647
从示例中可以看出,不必首先检查目标类型是否可以容纳该值,就可以得到正好等于目标类型的最大值的结果。剩下的就要丢了,不管差别有多大。
在执行缩小转换之前,请检查目标类型的最大值是否可以保持原始值。
请注意,char类型和byte或short类型之间的转换是一个更复杂的过程,因为char类型是无符号数字类型,而byte和short类型是有符号数字类型,所以即使值看起来像它符合目标类型。
转换方法
除了转换之外,每个原始类型都有一个对应的引用类型(称为包装类),该类具有将该类型的值转换为除boolean和char之外的任何其他原始类型的方法。所有包装类都属于java.lang包:
java.lang.Booleanjava.lang.Bytejava.lang.Characterjava.lang.Shortjava.lang.Integerjava.lang.Longjava.lang.Floatjava.lang.Double
除了类Boolean和Character之外,它们都扩展了抽象类java.lang.Number,抽象类有以下抽象方法:
byteValue()shortValue()intValue()longValue()floatValue()doubleValue()
这样的设计迫使Number类的后代实现所有这些。它们产生的结果与前面示例中的cast运算符相同:
int i = 123456789;
double d = Integer.valueOf(i).doubleValue();
System.out.println(i - (int)d); //prints: 0
long l1 = 12345678L;
float f1 = Long.valueOf(l1).floatValue();
System.out.println(l1 - (long)f1); //prints: 0
long l2 = 123456789L;
float f2 = Long.valueOf(l2).floatValue();
System.out.println(l2 - (long)f2); //prints: -3
long l3 = 1234567891111111L;
double d3 = Long.valueOf(l3).doubleValue();
System.out.println(l3 - (long)d3); //prints: 0
long l4 = 12345678999999999L;
double d4 = Long.valueOf(l4).doubleValue();
System.out.println(l4 - (long)d4); //prints: -1
double d1 = 1234567890.0;
System.out.println(Double.valueOf(d1)
.intValue()); //prints: 1234567890
double d2 = 12345678909999999999999.0;
System.out.println(Double.valueOf(d2)
.intValue()); //prints: 2147483647
此外,每个包装器类都有允许将数值的String表示转换为相应的原始数值类型或引用类型的方法。例如:
byte b1 = Byte.parseByte("42");
System.out.println(b1); //prints: 42
Byte b2 = Byte.decode("42");
System.out.println(b2); //prints: 42
boolean b3 = Boolean.getBoolean("property");
System.out.println(b3); //prints: false
Boolean b4 = Boolean.valueOf("false");
System.out.println(b4); //prints: false
int i1 = Integer.parseInt("42");
System.out.println(i1); //prints: 42
Integer i2 = Integer.getInteger("property");
System.out.println(i2); //prints: null
double d1 = Double.parseDouble("3.14");
System.out.println(d1); //prints: 3.14
Double d2 = Double.valueOf("3.14");
System.out.println(d2); //prints: 3.14
在示例中,请注意接受参数属性的两种方法。这两种方法以及其他包装类的类似方法将系统属性(如果存在)转换为相应的原始类型。
并且每个包装器类都有一个toString(primitive value)静态方法来将原始类型值转换为它的String表示。例如:
String s1 = Integer.toString(42);
System.out.println(s1); //prints: 42
String s2 = Double.toString(3.14);
System.out.println(s2); //prints: 3.14
包装器类还有许多其他有用的方法,可以将一种原始类型转换为另一种原始类型和不同的格式。因此,如果您需要这样做,请首先查看相应的包装器类。
在原始类型和引用类型之间转换
将原始类型值转换为相应包装类的对象称为装箱。此外,从包装类的对象到相应的原始类型值的转换被称为拆箱。
装箱
原始类型的装箱可以自动补全(称为自动装箱),也可以显式使用每个包装器类型中可用的valueOf()方法完成:
int i1 = 42;
Integer i2 = i1; //autoboxing
//Long l2 = i1; //error
System.out.println(i2); //prints: 42
i2 = Integer.valueOf(i1);
System.out.println(i2); //prints: 42
Byte b = Byte.valueOf((byte)i1);
System.out.println(b); //prints: 42
Short s = Short.valueOf((short)i1);
System.out.println(s); //prints: 42
Long l = Long.valueOf(i1);
System.out.println(l); //prints: 42
Float f = Float.valueOf(i1);
System.out.println(f); //prints: 42.0
Double d = Double.valueOf(i1);
System.out.println(d); //prints: 42.0
请注意,只有在将原始类型转换为相应的包装器类型时,才能进行自动装箱。否则,编译器将生成一个错误。
Byte和Short包装器的方法valueOf()的输入值需要强制转换,因为这是我们在上一节讨论过的原始类型的缩小。
拆箱
拆箱可以使用在每个包装类中实现的Number类的方法来完成:
Integer i1 = Integer.valueOf(42);
int i2 = i1.intValue();
System.out.println(i2); //prints: 42
byte b = i1.byteValue();
System.out.println(b); //prints: 42
short s = i1.shortValue();
System.out.println(s); //prints: 42
long l = i1.longValue();
System.out.println(l); //prints: 42
float f = i1.floatValue();
System.out.println(f); //prints: 42.0
double d = i1.doubleValue();
System.out.println(d); //prints: 42.0
Long l1 = Long.valueOf(42L);
long l2 = l1; //implicit unboxing
System.out.println(l2); //prints: 42
double d2 = l1; //implicit unboxing
System.out.println(d2); //prints: 42
long l3 = i1; //implicit unboxing
System.out.println(l3); //prints: 42
double d3 = i1; //implicit unboxing
System.out.println(d3); //prints: 42
从示例中的注释可以看出,从包装器类型到相应的原始类型的转换不是称为自动拆箱,而是称为隐式拆箱。与自动装箱不同的是,即使在包装和不匹配的原始类型之间也可以使用隐式拆箱。
总结
在本章中,您了解了什么是 Java 包,以及它们在组织代码和类可访问性(包括import语句和访问修饰符)方面所起的作用。您还熟悉了引用类型:类、接口、数组和枚举。任何引用类型的默认值为null,包括String类型。
现在您了解了引用类型是通过引用传递到方法中的,以及如何使用和覆盖equals()方法。您还学习了保留关键字和限制关键字的完整列表,了解了this和super关键字的含义和用法。
本章最后描述了原始类型、包装类型和String字面值之间转换的过程和方法。
在下一章中,我们将讨论 Java 异常框架、受检和非受检(运行时)异常、try-catch-finally块、throws和throw语句,以及异常处理的最佳实践。
测验
-
选择所有正确的语句:
Package语句描述类或接口位置Package语句描述类或接口名称Package是一个完全限定的名称Package名称和类名构成了类的完全限定名
-
选择所有正确的语句:
Import语句允许使用完全限定名Import语句必须是.java文件中的第一个语句Group import语句只引入一个包的类(和接口)Import statement允许避免使用完全限定名
-
选择所有正确的语句:
- 如果没有访问修饰符,该类只能由同一包的其他类和接口访问
- 私有类的私有方法可以被同一
.java文件中声明的其他类访问 - 私有类的
public方法可以被不在同一.java文件中声明但来自同一包的其他类访问 - 受保护的方法只能由类的后代访问
-
选择所有正确的语句:
- 私有方法可以重载,但不能覆盖
- 受保护的方法可以覆盖,但不能重载
- 没有访问修饰符的方法可以被覆盖和重载
- 私有方法可以访问同一类的私有属性
-
选择所有正确的语句:
- 缩小和向上转型是同义词
- 加宽和向下转型是同义词
- 加宽和向上转型是同义词
- 加宽和缩小与向上转型和向下转型没有任何共同之处
-
选择所有正确的语句:
Array是一个对象Array的长度是它能容纳的元素的数量- 数组的第一个元素具有索引 1
- 数组的第二个元素具有索引 1
-
选择所有正确的语句:
Enum包含常量。Enum总是有一个构造器,默认或显式enum常量可以有属性Enum可以有任何引用类型的常量
-
选择所有正确的语句:
- 可以修改作为参数传入的任何引用类型
- 作为参数传入的
new String()对象可以修改 - 不能修改作为参数传入的对象引用值
- 作为参数传入的数组可以将元素指定给不同的值
-
选择所有正确的语句:
- 不能使用保留关键字
- 受限关键字不能用作标识符
- 保留关键字
identifier不能用作标识符 - 保留关键字不能用作标识符
-
选择所有正确的语句:
1.this关键字是指current类
2.super关键字是指super类
3. 关键词this和super指的是对象
4.this和super是指方法 -
选择所有正确的语句:
1. 原始类型的加宽使值变大
2. 原始类型的缩小总是会更改值的类型
3. 原始类型的加宽只能在缩小转换后进行
4. 缩小会使值变小 -
选择所有正确的语句:
1. 装箱限制了值
2. 拆箱将创建一个新值
3. 装箱创建引用类型对象
4. 拆箱将删除引用类型对象
四、异常处理
我们在第 1 章“Java12 入门”中简要介绍了异常。在本章中,我们将更系统地讨论这个问题。Java 中有两种异常:受检异常和非受检异常。两者都将被演示,并解释两者之间的区别。读者还将了解与异常处理相关的 Java 构造的语法以及处理异常的最佳实践。本章将以可用于调试生产代码的断言语句的相关主题结束。
本章将讨论以下主题:
- Java 异常框架
- 受检和非受检(运行时)异常
try、catch和finally块throws声明throw声明assert声明- 异常处理的最佳实践
Java 异常框架
正如我们在第一章“Java12 入门”中所描述的,一个意外的情况可能会导致 Java 虚拟机(JVM)创建并抛出一个异常对象,或者应用代码可以这样做。一旦发生异常,如果异常是在一个try块中抛出的,那么控制流就被转移到catch子句。让我们看一个例子。考虑以下方法:
void method(String s){
if(s.equals("abc")){
System.out.println("Equals abc");
} else {
System.out.println("Not equal");
}
}
如果输入参数值为null,则可以预期输出为Not equal。不幸的是,情况并非如此。s.equals("abc")表达式对s变量引用的对象调用equals()方法,但是,如果s变量是null,则它不引用任何对象。让我们看看会发生什么。
让我们运行以下代码:
try {
method(null);
} catch (Exception ex){
System.out.println(ex.getClass().getCanonicalName());
//prints: java.lang.NullPointerException
ex.printStackTrace(); //prints: see the screenshot
if(ex instanceof NullPointerException){
//do something
} else {
//do something else
}
}
此代码的输出如下:

在屏幕截图上看到的红色部分称为栈跟踪。名称来自方法调用在 JVM 内存中的存储方式(作为栈):一个方法调用另一个方法,而另一个方法又反过来调用另一个方法,依此类推。在最内部的方法返回后,遍历栈,并从栈中移除返回的方法(栈帧)(我们将在第 9 章、“JVM 结构和垃圾收集”中详细讨论 JVM 内存结构)。当发生异常时,所有栈内容(栈帧)都作为栈跟踪返回。它允许我们追踪导致问题的代码行。
在前面的代码示例中,根据异常的类型执行不同的代码块。在我们的案例中,是java.lang.NullPointerException。如果应用代码没有捕获它,这个异常将通过被调用方法的栈一直传播到 JVM 中,JVM 随后停止执行应用。为了避免这种情况的发生,可以捕获异常并执行一些代码来从异常情况中恢复。
Java 中异常处理框架的目的是保护应用代码不受意外情况的影响,并在可能的情况下从中恢复。在下面的部分中,我们将更详细地剖析它,并使用框架功能重新编写给定的示例。
受检和非受检的异常
如果你查阅java.lang包 API 的文档,你会发现这个包包含了近三十个异常类和几十个错误类。两个组都扩展了java.lang.Throwable类,从中继承所有方法,并且不添加其他方法。java.lang.Throwable类最常用的方法如下:
void printStackTrace():输出方法调用的栈跟踪(栈帧)StackTraceElement[] getStackTrace():返回与printStackTrace()相同的信息,但允许对栈跟踪的任何帧进行编程访问String getMessage():检索通常包含异常或错误原因的用户友好解释的消息Throwable getCause():检索java.lang.Throwable的可选对象,该对象是异常的原始原因(但代码的作者决定将其包装在另一个异常或错误中)
所有错误都扩展了java.lang.Error类,而java.lang.Error类又扩展了java.lang.Throwable类。一个错误通常是由 JVM 抛出的,根据官方文档,表示一个合理的应用不应该试图捕捉的严重问题。以下是几个例子:
OutOfMemoryError:当 JVM 耗尽内存并且无法使用垃圾收集清理内存时抛出StackOverflowError:当分配给方法调用栈的内存不足以存储另一个栈帧时抛出NoClassDefFoundError:当 JVM 找不到当前加载的类所请求的类的定义时抛出
框架的作者假设应用不能自动从这些错误中恢复,这在很大程度上被证明是正确的假设。这就是为什么程序员通常不会捕捉到错误,我们将不再讨论它们。
另一方面,异常通常与特定于应用的问题相关,通常不需要我们关闭应用并允许恢复。这就是为什么程序员通常会捕捉到它们并实现应用逻辑的替代(主流程)路径,或者至少在不关闭应用的情况下报告问题。以下是几个例子:
ArrayIndexOutOfBoundsException:当代码试图通过等于或大于数组长度的索引访问元素时抛出(记住数组的第一个元素有索引0,所以索引等于数组之外的数组长度点)ClassCastException:当代码对与变量引用的对象无关的类或接口进行引用时抛出NumberFormatException:当代码试图将字符串转换为数字类型,但字符串不包含必需的数字格式时抛出
所有异常都扩展了java.lang.Exception类,而java.lang.Exception类又扩展了java.lang.Throwable类。这就是为什么通过捕捉java.lang.Exception类的对象,代码捕捉任何异常类型的对象。我们已经在“Java 异常框架”一节中通过这种方式捕获了java.lang.NullPointerException进行了演示。
异常之一是java.lang.RuntimeException。扩展它的异常称为运行时异常或非受检异常。我们已经提到了其中的一些:NullPointerException、ArrayIndexOutOfBoundsException、ClassCastException和NumberFormatException。为什么它们被称为运行时异常是很清楚的,而为什么它们被称为非受检的异常将在下一段中变得很清楚。
祖先中没有java.lang.RuntimeException的称为检查异常。这样命名的原因是编译器确保(检查)这些异常被捕获或列在方法的throws子句中(参见“throws语句”部分)。这种设计迫使程序员做出有意识的决定,要么捕获受检的异常,要么通知方法的客户端该异常可能由方法引发,并且必须由客户端处理(处理)。以下是一些受检异常的示例:
ClassNotFoundException:当尝试用Class类的forName()方法加载使用其字符串名称的类失败时抛出CloneNotSupportedException:当代码试图克隆未实现Cloneable接口的对象时抛出NoSuchMethodException:代码没有调用方法时抛出
并非所有的异常都存在于java.lang包中。许多其他包包含与包支持的功能相关的异常。例如,java.util.MissingResourceException运行时异常和java.io.IOException检查异常。
尽管不是被迫的,程序员也经常捕捉运行时(非受检的)异常,以便更好地控制程序流,使应用的行为更稳定和可预测。顺便说一下,所有的错误都是运行时(非受检的)异常,但是,正如我们已经说过的,通常不可能以编程方式处理它们,因此捕捉java.lang.Error类的后代是没有意义的。
try,catch,finally块
当在try块中抛出异常时,它将控制流重定向到第一个catch子句。如果没有可以捕获异常的catch块(但是finally块必须就位),异常会一直向上传播并从方法中传播出去。如果有多个catch子句,编译器会强制您排列它们,以便子异常列在父异常之前。让我们看看下面的例子:
void someMethod(String s){
try {
method(s);
} catch (NullPointerException ex){
//do something
} catch (Exception ex){
//do something else
}
}
在上例中,由于NullPointerException扩展RuntimeException,而RuntimeException又扩展Exception,所以将具有NullPointerException的catch块放置在具有Exception的块之前。我们甚至可以实现以下示例:
void someMethod(String s){
try {
method(s);
} catch (NullPointerException ex){
//do something
} catch (RuntimeException ex){
//do something else
} catch (Exception ex){
//do something different
}
}
第一个catch子句只包含NullPointerException。其他扩展了RuntimeException的异常将被第二个catch子句捕获。其余的异常类型(所有选中的异常)将被最后一个catch块捕获。请注意,这些catch子句中的任何一个都不会捕捉到错误。为了捕获它们,应该为Error(在任何位置)或Throwable(在上一个示例中的最后一个catch子句之后)添加catch子句,但是程序员通常不会这样做,并且允许错误一直传播到 JVM 中。
每个异常类型都有一个catch块,这允许我们提供一个特定于异常类型的处理。但是,如果在异常处理中没有差异,则可以只使用一个具有Exception基类的catch块来捕获所有类型的异常:
void someMethod(String s){
try {
method(s);
} catch (Exception ex){
//do something
}
}
如果没有一个子句捕捉到异常,则会进一步抛出异常,直到它被某个方法调用者中的try...catch语句处理,或者传播到应用代码之外。在这种情况下,JVM 终止应用并退出。
添加一个finally块不会改变所描述的行为。如果存在,不管是否生成了异常,它总是被执行。finally块通常用于释放资源:关闭数据库连接、文件等。但是,如果资源实现了Closeable接口,那么最好使用资源尝试语句,该语句允许自动释放资源。下面是如何使用 Java7 实现的:
try (Connection conn = DriverManager.getConnection("dburl",
"username", "password");
ResultSet rs = conn.createStatement()
.executeQuery("select * from some_table")) {
while (rs.next()) {
//process the retrieved data
}
} catch (SQLException ex) {
//Do something
//The exception was probably caused by incorrect SQL statement
}
本例创建数据库连接,检索数据并对其进行处理,然后关闭(调用close()方法)conn和rs对象。
Java9 增强了资源尝试语句功能,允许创建表示try块外资源的对象,然后在资源尝试语句中使用这些对象,如下所示:
void method(Connection conn, ResultSet rs) {
try (conn; rs) {
while (rs.next()) {
//process the retrieved data
}
} catch (SQLException ex) {
//Do something
//The exception was probably caused by incorrect SQL statement
}
}
前面的代码看起来更简洁,尽管在实践中,程序员更喜欢在同一上下文中创建和释放(关闭)资源。如果这也是您的偏好,请考虑将throws语句与资源尝试语句结合使用。
throws语句
前面使用资源尝试语句的示例可以使用在相同上下文中创建的资源对象重新编写,如下所示:
Connection conn;
ResultSet rs;
try {
conn = DriverManager.getConnection("dburl", "username", "password");
rs = conn.createStatement().executeQuery("select * from some_table");
} catch (SQLException e) {
e.printStackTrace();
return;
}
try (conn; rs) {
while (rs.next()) {
//process the retrieved data
}
} catch (SQLException ex) {
//Do something
//The exception was probably caused by incorrect SQL statement
}
我们必须处理SQLException,因为它是一个受检异常,getConnection()、createStatement()、executeQuery()和next()方法在它们的throws子句中声明它,下面是一个例子:
Statement createStatement() throws SQLException;
这意味着该方法的作者警告该方法的用户它可能抛出这样一个异常,并强制他们要么捕获异常,要么在方法的throws子句中声明异常。在前面的例子中,我们选择捕捉它,并且必须使用两个try...catch语句。或者,我们也可以在throws子句中列出异常,从而有效地将异常处理的负担推给我们方法的用户,从而消除混乱:
void throwsDemo() throws SQLException {
Connection conn = DriverManager.getConnection("url","user","pass");
ResultSet rs = conn.createStatement().executeQuery("select * ...");
try (conn; rs) {
while (rs.next()) {
//process the retrieved data
}
} finally { }
}
我们去掉了catch子句,但是 Java 语法要求catch或finally块必须跟在try块后面,所以我们添加了一个空的finally块
throws条款允许但不要求我们列出非受检异常的情况。添加非受检的异常不会强制方法的用户处理它们。
最后,如果方法抛出几个不同的异常,可以列出基本的Exception异常类,而不是列出所有异常。这将使编译器感到高兴,但这并不是一个好的实践,因为它隐藏了方法用户可能期望的特定异常的细节。
请注意,编译器不会检查方法体中的代码可以引发何种异常。因此,可以在throws子句中列出任何异常,这可能会导致不必要的开销。如果程序员错误地在throws子句中包含一个受检异常,而该异常从未被方法实际抛出,那么该方法的用户可能会为它编写一个从未执行过的catch块
throw语句
throw语句允许抛出程序员认为必要的任何异常。人们甚至可以创建自己的异常。要创建选中的异常,请扩展java.lang.Exception类:
class MyCheckedException extends Exception{
public MyCheckedException(String message){
super(message);
}
//add code you need to have here
}
另外,要创建非受检的异常,请扩展java.lang.RunitmeException类,如下所示:
class MyUncheckedException extends RuntimeException{
public MyUncheckedException(String message){
super(message);
}
//add code you need to have here
}
注意注释这里需要添加代码。您可以像向任何其他常规类一样向自定义异常添加方法和属性,但程序员很少这样做。最佳实践甚至明确建议避免使用异常来驱动业务逻辑。异常应该是顾名思义,只包括异常的,非常罕见的情况。
但是,如果您需要宣布异常情况,请使用throw关键字和new运算符来创建并触发异常对象的传播。以下是几个例子:
throw new Exception("Something happend");
throw new RunitmeException("Something happened");
throw new MyCheckedException("Something happened");
throw new MyUncheckedException("Something happened");
甚至可以按如下方式抛出null:
throw null;
上述语句的结果与此语句的结果相同:
throw new NullPointerException;
在这两种情况下,非受检的NullPointerException的对象开始在系统中传播,直到它被应用或 JVM 捕获。
assert语句
有时,程序员需要知道代码中是否发生了特定的情况,即使应用已经部署到生产环境中。同时,没有必要一直运行检查。这就是分支assert语句派上用场的地方。举个例子:
public someMethod(String s){
//any code goes here
assert(assertSomething(x, y, z));
//any code goes here
}
boolean assertSomething(int x, String y, double z){
//do something and return boolean
}
在前面的代码中,assert()方法从assertSomething()方法获取输入,如果assertSomething()方法返回false,程序停止执行。
只有当 JVM 使用-ea选项运行时,assert()方法才会执行。-ea标志不应该在生产中使用,除非可能暂时用于测试目的,因为它会产生影响应用性能的开销。
异常处理的最佳实践
当应用可以自动执行某些操作来修改或解决问题时,选中的异常被设计为用于可恢复条件。实际上,这种情况并不经常发生。通常,当捕捉到异常时,应用会记录栈跟踪并中止当前操作。根据记录的信息,应用支持团队修改代码以解决未知情况或防止将来发生这种情况
每个应用都是不同的,因此最佳实践取决于特定的应用需求、设计和上下文。一般来说,在开发社区中似乎有一个协议,即避免使用检查过的异常,并尽量减少它们在应用代码中的传播。以下是其他一些被证明是有用的建议:
- 始终捕获靠近源的所有受检异常
- 如果有疑问,也可以在源代码附近捕获非受检的异常
- 尽可能靠近源处理异常,因为它是上下文最具体的地方,也是根本原因所在的地方
- 除非必须,否则不要抛出选中的异常,因为您强制为可能永远不会发生的情况生成额外代码
- 如果有必要,将第三方的受检异常转换为非受检的异常,方法是将它们作为
RuntimeException重新抛出,并显示相应的消息 - 除非必须,否则不要创建自定义异常
- 除非必须,否则不要使用异常处理机制来驱动业务逻辑
- 通过使用消息系统和可选的枚举类型(而不是使用异常类型)来定制泛型
RuntimeException,以传达错误的原因
总结
本章向读者介绍了 Java 异常处理框架,了解了两种异常:受检和非受检(运行时),以及如何使用try-catch-finally和throws语句处理它们。读者还学习了如何生成(抛出)异常以及如何创建自己的(自定义)异常。本章最后介绍了异常处理的最佳实践。
在下一章中,我们将详细讨论字符串及其处理,以及输入/输出流和文件读写技术。
测验
-
什么是栈跟踪?选择所有适用项:
- 当前加载的类的列表
- 当前正在执行的方法的列表
- 当前正在执行的代码行的列表
- 当前使用的变量列表
-
有哪些异常?选择所有适用的选项:
- 编译异常
- 运行时异常
- 读取异常
- 写入异常
-
以下代码的输出是什么?
try {
throw null;
} catch (RuntimeException ex) {
System.out.print("RuntimeException ");
} catch (Exception ex) {
System.out.print("Exception ");
} catch (Error ex) {
System.out.print("Error ");
} catch (Throwable ex) {
System.out.print("Throwable ");
} finally {
System.out.println("Finally ");
}
- 下列哪种方法编译时不会出错?
void method1() throws Exception { throw null; }
void method2() throws RuntimeException { throw null; }
void method3() throws Throwable { throw null; }
void method4() throws Error { throw null; }
- 下列哪个语句编译时不会出错?
throw new NullPointerException("Hi there!"); //1
throws new Exception("Hi there!"); //2
throw RuntimeException("Hi there!"); //3
throws RuntimeException("Hi there!"); //4
- 假设
int x = 4,下列哪条语句编译时不会出错?
assert (x > 3); //1
assert (x = 3); //2
assert (x < 4); //3
assert (x = 4); //4
- 以下列表中的最佳实践是什么?
- 始终捕获所有异常和错误
- 总是捕获所有异常
- 从不抛出非受检的异常
- 除非必须,否则不要抛出受检的异常
五、字符串、输入/输出和文件
在本章中,读者将更详细地了解String类方法。我们还将讨论标准库和 ApacheCommons 项目中流行的字符串工具。下面将概述 Java 输入/输出流和java.io包的相关类,以及org.apache.commons.io包的一些类。文件管理类及其方法在专用部分中进行了描述。
本章将讨论以下主题:
- 字符串处理
- I/O 流
- 文件管理
- Apache Commons 工具
FileUtils和IOUtils
字符串处理
在主流编程中,String可能是最流行的类。在第一章“Java12 入门”中,我们了解了这个类,它的文本和它的特殊特性字符串不变性。在本节中,我们将解释如何使用标准库中的String类方法和工具类处理字符串,特别是使用org.apache.commons.lang3包中的StringUtils类。
字符串类的方法
String类有 70 多个方法,可以分析、修改、比较字符串,并将数字文本转换为相应的字符串文本。要查看String类的所有方法,请参考在线 Java API。
字符串分析
length()方法返回字符串中的字符数,如下代码所示:
String s7 = "42";
System.out.println(s7.length()); //prints: 2
System.out.println("0 0".length()); //prints: 3
当字符串长度(字符数)为0时,下面的isEmpty()方法返回true:
System.out.println("".isEmpty()); //prints: true
System.out.println(" ".isEmpty()); //prints: false
indexOf()和lastIndexOf()方法返回指定子字符串在该代码段所示字符串中的位置:
String s6 = "abc42t%";
System.out.println(s6.indexOf(s7)); //prints: 3
System.out.println(s6.indexOf("a")); //prints: 0
System.out.println(s6.indexOf("xyz")); //prints: -1
System.out.println("ababa".lastIndexOf("ba")); //prints: 3
如您所见,字符串中的第一个字符有一个位置(索引)0,缺少指定的子字符串将导致索引-1。
matches()方法将正则表达式(作为参数传递)应用于字符串,如下所示:
System.out.println("abc".matches("[a-z]+")); //prints: true
System.out.println("ab1".matches("[a-z]+")); //prints: false
正则表达式超出了本书的范围。你可以在这个页面了解它们。在上例中,表达式[a-z]+只匹配一个或多个字母。
字符串比较
在第 3 章、“Java 基础”中,我们已经讨论过只有当两个String对象或文字拼写完全相同时才返回true的equals()方法。以下代码段演示了它的工作原理:
String s1 = "abc";
String s2 = "abc";
String s3 = "acb";
System.out.println(s1.equals(s2)); //prints: true
System.out.println(s1.equals(s3)); //prints: false
System.out.println("abc".equals(s2)); //prints: true
System.out.println("abc".equals(s3)); //prints: false
另一个String类equalsIgnoreCase()方法做了类似的工作,但忽略了字符大小写的区别,如下所示:
String s4 = "aBc";
String s5 = "Abc";
System.out.println(s4.equals(s5)); //prints: false
System.out.println(s4.equalsIgnoreCase(s5)); //prints: true
contentEquals()方法的作用类似于此处所示的equals()方法:
String s1 = "abc";
String s2 = "abc";
System.out.println(s1.contentEquals(s2)); //prints: true
System.out.println("abc".contentEquals(s2)); //prints: true
区别在于equals()方法检查两个值是否都用String类
表示,而contentEquals()只比较字符序列的字符(内容),字符序列可以用String、StringBuilder、StringBuffer、CharBuffer表示,或者实现CharSequence接口的任何其他类。然而,如果两个序列包含相同的字符,contentEquals()方法将返回true,而如果其中一个序列不是由String类创建的,equals()方法将返回false。
如果string包含某个子串,contains()方法返回true,如下所示:
String s6 = "abc42t%";
String s7 = "42";
String s8 = "xyz";
System.out.println(s6.contains(s7)); //prints: true
System.out.println(s6.contains(s8)); //prints: false
startsWith()和endsWith()方法执行类似的检查,但仅在字符串的开头或字符串值的结尾执行,如以下代码所示:
String s6 = "abc42t%";
String s7 = "42";
System.out.println(s6.startsWith(s7)); //prints: false
System.out.println(s6.startsWith("ab")); //prints: true
System.out.println(s6.startsWith("42", 3)); //prints: true
System.out.println(s6.endsWith(s7)); //prints: false
System.out.println(s6.endsWith("t%")); //prints: true
compareTo()和compareToIgnoreCase()方法根据字符串中每个字符的 Unicode 值按字典顺序比较字符串。如果字符串相等,则返回值0;如果第一个字符串按字典顺序小于第二个字符串(Unicode 值较小),则返回负整数值;如果第一个字符串按字典顺序大于第二个字符串(Unicode 值较大),则返回正整数值。例如:
String s4 = "aBc";
String s5 = "Abc";
System.out.println(s4.compareTo(s5)); //prints: 32
System.out.println(s4.compareToIgnoreCase(s5)); //prints: 0
System.out.println(s4.codePointAt(0)); //prints: 97
System.out.println(s5.codePointAt(0)); //prints: 65
从这个代码片段中,您可以看到,compareTo()和compareToIgnoreCase()方法基于组成字符串的字符的代码点。字符串s4比字符串s5大32的原因是因为字符a(97的码点比字符A(65的码点大32
示例还显示,codePointAt()方法返回字符串中指定位置的字符的码位。代码点在第 1 章“Java12 入门”的“整数类型”部分进行了描述。
字符串变换
substring()方法返回从指定位置(索引)开始的子字符串,如下所示:
System.out.println("42".substring(0)); //prints: 42
System.out.println("42".substring(1)); //prints: 2
System.out.println("42".substring(2)); //prints:
System.out.println("42".substring(3)); //error: index out of range: -1
String s6 = "abc42t%";
System.out.println(s6.substring(3)); //prints: 42t%
System.out.println(s6.substring(3, 5)); //prints: 42
format()方法使用传入的第一个参数作为模板,并在模板的相应位置依次插入其他参数!请给我两个苹果!“三次:
String t = "Hey, %s! Give me %d apples, please!";
System.out.println(String.format(t, "Nick", 2));
String t1 = String.format(t, "Nick", 2);
System.out.println(t1);
System.out.println(String
.format("Hey, %s! Give me %d apples, please!", "Nick", 2));
%s和%d符号称为格式说明符。有许多说明符和各种标志,允许程序员精确控制结果。您可以在java.util.Formatter类的 API 中了解它们。
concat()方法的工作方式与算术运算符(+相同,如图所示:
String s7 = "42";
String s8 = "xyz";
String newStr1 = s7.concat(s8);
System.out.println(newStr1); //prints: 42xyz
String newStr2 = s7 + s8;
System.out.println(newStr2); //prints: 42xyz
以下join()方法的作用类似,但允许添加分隔符:
String newStr1 = String.join(",", "abc", "xyz");
System.out.println(newStr1); //prints: abc,xyz
List<String> list = List.of("abc","xyz");
String newStr2 = String.join(",", list);
System.out.println(newStr2); //prints: abc,xyz
以下一组replace()、replaceFirst()和replaceAll()方法用提供的字符替换字符串中的某些字符:
System.out.println("abcbc".replace("bc", "42")); //prints: a4242
System.out.println("abcbc".replaceFirst("bc", "42")); //prints: a42bc
System.out.println("ab11bcd".replaceAll("[a-z]+", "42"));//prints: 421142
前面代码的第一行用"42"替换"bc"的所有实例。第二个实例仅将"bc"的第一个实例替换为"42"。最后一个将匹配所提供正则表达式的所有子字符串替换为"42"。
toLowerCase()和toUpperCase()方法改变整个字符串的大小写,如下所示:
System.out.println("aBc".toLowerCase()); //prints: abc
System.out.println("aBc".toUpperCase()); //prints: ABC
split()方法将字符串分成子字符串,使用提供的字符作为分隔符,如下所示:
String[] arr = "abcbc".split("b");
System.out.println(arr[0]); //prints: a
System.out.println(arr[1]); //prints: c
System.out.println(arr[2]); //prints: c
有几种valueOf()方法可以将原始类型的值转换为String类型。例如:
float f = 23.42f;
String sf = String.valueOf(f);
System.out.println(sf); //prints: 23.42
也有()和getChars()方法将字符串转换为相应类型的数组,而chars()方法创建一个IntStream字符(它们的代码点)。我们将在第 14 章、“Java 标准流”中讨论流。
使用 Java11 添加的方法
Java11 在String类中引入了几个新方法。
repeat()方法允许您基于同一字符串的多个连接创建新的字符串值,如下代码所示:
System.out.println("ab".repeat(3)); //prints: ababab
System.out.println("ab".repeat(1)); //prints: ab
System.out.println("ab".repeat(0)); //prints:
如果字符串长度为0或只包含空格,isBlank()方法返回true。例如:
System.out.println("".isBlank()); //prints: true
System.out.println(" ".isBlank()); //prints: true
System.out.println(" a ".isBlank()); //prints: false
stripLeading()方法从字符串中删除前导空格,stripTrailing()方法删除尾部空格,strip()方法同时删除这两个空格,如下所示:
String sp = " abc ";
System.out.println("'" + sp + "'"); //prints: ' abc '
System.out.println("'" + sp.stripLeading() + "'"); //prints: 'abc '
System.out.println("'" + sp.stripTrailing() + "'"); //prints: ' abc'
System.out.println("'" + sp.strip() + "'"); //prints: 'abc'
最后,lines()方法通过行终止符来中断字符串并返回结果行的Stream<String>,行终止符是转义序列换行符\n(\u000a),或回车符\r(\u000d),或回车符紧跟换行符\r\n(\u000d\u000a)。例如:
String line = "Line 1\nLine 2\rLine 3\r\nLine 4";
line.lines().forEach(System.out::println);
上述代码的输出如下:

我们将在第 14 章、“Java 标准流”中讨论流。
字符串工具
除了String类之外,还有许多其他类具有处理String值的方法。其中最有用的是来自一个名为 Apache Commons 的项目的org.apache.commons.lang3包的StringUtils类,该项目由名为 Apache Software Foundation 的开源程序员社区维护。我们将在第 7 章、“Java 标准和外部库”中详细介绍这个项目及其库。要在项目中使用它,请在pom.xml文件中添加以下依赖项:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.8.1</version>
</dependency>
StringUtils类是许多程序员的最爱。它通过提供以下空安全操作来补充String类的方法:
isBlank(CharSequence cs):如果输入值为空格、空(""或null,则返回trueisNotBlank(CharSequence cs):前面方法返回true时返回falseisEmpty(CharSequence cs):如果输入值为空(""或null,则返回trueisNotEmpty(CharSequence cs):前面方法返回true时返回falsetrim(String str):从输入值中删除前导和尾随空格,并按如下方式处理null、空("")和空格:
System.out.println("'" + StringUtils.trim(" x ") + "'"); //prints: 'x'
System.out.println(StringUtils.trim(null)); //prints: null
System.out.println("'" + StringUtils.trim("") + "'"); //prints: ''
System.out.println("'" + StringUtils.trim(" ") + "'"); //prints: ''
trimToNull(String str):从输入值中删除前导和尾随空格,并按如下方式处理null、空("")和空格:
System.out.println("'" + StringUtils.trimToNull(" x ") + "'"); // 'x'
System.out.println(StringUtils.trimToNull(null)); //prints: null
System.out.println(StringUtils.trimToNull("")); //prints: null
System.out.println(StringUtils.trimToNull(" ")); //prints: null
trimToEmpty(String str):从输入值中删除前导和尾随空格,并按如下方式处理null、空("")和空格:
System.out.println("'" + StringUtils.trimToEmpty(" x ") + "'"); // 'x'
System.out.println("'" + StringUtils.trimToEmpty(null) + "'"); // ''
System.out.println("'" + StringUtils.trimToEmpty("") + "'"); // ''
System.out.println("'" + StringUtils.trimToEmpty(" ") + "'"); // ''
-
strip(String str)、stripToNull(String str)、stripToEmpty(String str):产生与前面trim*(String str)方法相同的结果,但使用更广泛的空格定义(基于Character.isWhitespace(int codepoint)),从而删除与trim*(String str)相同的字符,等等 -
strip(String str, String stripChars)、stripAccents(String input)、stripAll(String... strs)、stripAll(String[] strs, String stripChars)、stripEnd(String str, String stripChars)、stripStart(String str, String stripChars):从String或String[]数组元素的特定部分删除特定字符 -
startsWith(CharSequence str, CharSequence prefix)、startsWithAny(CharSequence string, CharSequence... searchStrings)、startsWithIgnoreCase(CharSequence str, CharSequence prefix)以及类似的endsWith*()方法:检查String值是否以某个前缀(或后缀)开始(或结束) -
indexOf、lastIndexOf、contains:以空安全的方式检查索引 -
indexOfAny、lastIndexOfAny、indexOfAnyBut、lastIndexOfAnyBut:收益指标 -
containsOnly、containsNone、containsAny:检查值是否包含特定字符 -
substring、left、right、mid:空安全返回子串 -
substringBefore、substringAfter、substringBetween:从相对位置返回子串 -
split、join:拆分或合并一个值(对应) -
remove、delete:消除子串 -
replace、overlay:替换一个值 -
chomp、chop:移除末尾的换行符 -
appendIfMissing:如果不存在,则添加一个值 -
prependIfMissing:如果不存在,则在String值的开头加前缀 -
leftPad、rightPad、center、repeat:添加填充 -
upperCase、lowerCase、swapCase、capitalize、uncapitalize:变更案例 -
countMatches:返回子串出现的次数 -
isWhitespace、isAsciiPrintable、isNumeric、isNumericSpace、isAlpha、isAlphaNumeric、isAlphaSpace、isAlphaNumericSpace:检查是否存在某种类型的字符 -
isAllLowerCase、isAllUpperCase:检查案例 -
defaultString、defaultIfBlank、defaultIfEmpty:若null返回默认值 -
rotate:使用循环移位旋转字符 -
reverse、reverseDelimited:倒排字符或分隔字符组 -
abbreviate、abbreviateMiddle:使用省略号或其他值的缩写值 -
difference:返回值的差异 -
getLevenshteinDistance:返回将一个值转换为另一个值所需的更改数
如您所见,StringUtils类有一组非常丰富的方法(我们没有列出所有的方法)用于字符串分析、比较和转换,这些方法是对String类方法的补充。
I/O 流
任何软件系统都必须接收和生成某种数据,这些数据可以组织为一组独立的输入/输出或数据流。流可以是有限的,也可以是无穷无尽的。一个程序可以从一个流中读取(然后称为一个输入流),或者写入一个流(然后称为一个输出流)。java I/O 流要么基于字节,要么基于字符,这意味着它的数据要么被解释为原始字节,要么被解释为字符。
java.io包包含支持许多(但不是所有)可能数据源的类。它主要围绕文件、网络流和内部内存缓冲区的输入来构建。它不包含许多网络通信所必需的类。它们属于 Java 网络 API 的java.net、javax.net等包。只有在建立了网络源或目的地(例如网络套接字)之后,程序才能使用java.io包的InputStream和OutputStream类读写数据
java.nio包的类与java.io包的类具有几乎相同的功能。但是,除此之外,它们还可以在非阻塞的模式下工作,这可以在某些情况下显著提高性能。我们将在第 15 章“反应式编程”中讨论非阻塞处理。
流数据
一个程序所能理解的数据必须是二进制的,基本上用 0 和 1 表示。数据可以一次读或写一个字节,也可以一次读或写几个字节的数组。这些字节可以保持二进制,也可以解释为字符。
在第一种情况下,它们可以被InputStream和OutputStream类的后代读取为字节或字节数组。例如(如果类属于java.io包,则省略包名):ByteArrayInputStream、ByteArrayOutputStream、FileInputStream、FileOutputStream、ObjectInputStream、ObjectOutputStream、javax.sound.sampled.AudioInputStream、org.omg.CORBA.portable.OutputStream;使用哪一个取决于数据的来源或目的地。InputStream和OutputStream类本身是抽象的,不能实例化。
在第二种情况下,可以解释为字符的数据称为文本数据,在Reader和Writer的基础上还有面向字符的读写类,它们也是抽象类。它们的子类的例子有:CharArrayReader、CharArrayWriter、InputStreamReader、OutputStreamWriter、PipedReader、PipedWriter、StringReader和StringWriter。
你可能已经注意到了,我们把这些类成对地列了出来。但并非每个输入类都有匹配的输出特化。例如,有PrintStream和PrintWriter类支持输出到打印设备,但没有相应的输入伙伴,至少没有名称。然而,有一个java.util.Scanner类以已知格式解析输入文本
还有一组配备了缓冲区的类,它们通过一次读取或写入更大的数据块来帮助提高性能,特别是在访问源或目标需要很长时间的情况下。
在本节的其余部分,我们将回顾java.io包的类以及其他包中一些流行的相关类。
InputStream类及其子类
在 Java 类库中,InputStream抽象类有以下直接实现:ByteArrayInputStream、FileInputStream、ObjectInputStream、PipedInputStream、SequenceInputStream、FilterInputStream、javax.sound.sampled.AudioInputStream
它们要么按原样使用,要么覆盖InputStream类的以下方法:
int available():返回可读取的字节数void close():关闭流并释放资源void mark(int readlimit):标记流中的一个位置,定义可以读取的字节数boolean markSupported():支持打标返回truestatic InputStream nullInputStream():创建空流abstract int read():读取流中的下一个字节int read(byte[] b):将流中的数据读入b缓冲区int read(byte[] b, int off, int len):从流中读取len或更少字节到b缓冲区byte[] readAllBytes():读取流中所有剩余的字节int readNBytes(byte[] b, int off, int len):在off偏移量处将len或更少字节读入b缓冲区byte[] readNBytes(int len):将len或更少的字节读入b缓冲区void reset():将读取位置重置为上次调用mark()方法的位置long skip(long n):跳过流的n或更少字节;返回实际跳过的字节数long transferTo(OutputStream out):从输入流读取数据,逐字节写入提供的输出流;返回实际传输的字节数
abstract int read()是唯一必须实现的方法,但是这个类的大多数后代也覆盖了许多其他方法。
字节数组输入流
ByteArrayInputStream类允许读取字节数组作为输入流。它有以下两个构造器,用于创建类的对象并定义用于读取字节输入流的缓冲区:
ByteArrayInputStream(byte[] buffer)ByteArrayInputStream(byte[] buffer, int offset, int length)
第二个构造器除了允许设置缓冲区外,还允许设置缓冲区的偏移量和长度。让我们看看这个例子,看看如何使用这个类。我们假设有一个byte[]数组的数据源:
byte[] bytesSource(){
return new byte[]{42, 43, 44};
}
然后我们可以写下:
byte[] buffer = bytesSource();
try(ByteArrayInputStream bais = new ByteArrayInputStream(buffer)){
int data = bais.read();
while(data != -1) {
System.out.print(data + " "); //prints: 42 43 44
data = bais.read();
}
} catch (Exception ex){
ex.printStackTrace();
}
bytesSource()方法生成填充缓冲区的字节数组,缓冲区作为参数传递给ByteArrayInputStream类的构造器。然后使用read()方法逐字节读取得到的流,直到到达流的末尾为止(并且read()方法返回-1。每个新字节都会被打印出来(不带换行符,后面有空格,所以所有读取的字节都显示在一行中,用空格隔开)
前面的代码通常以更简洁的形式表示,如下所示:
byte[] buffer = bytesSource();
try(ByteArrayInputStream bais = new ByteArrayInputStream(buffer)){
int data;
while ((data = bais.read()) != -1) {
System.out.print(data + " "); //prints: 42 43 44
}
} catch (Exception ex){
ex.printStackTrace();
}
不只是打印字节,它们可以以任何其他必要的方式进行处理,包括将它们解释为字符。例如:
byte[] buffer = bytesSource();
try(ByteArrayInputStream bais = new ByteArrayInputStream(buffer)){
int data;
while ((data = bais.read()) != -1) {
System.out.print(((char)data) + " "); //prints: * + ,
}
} catch (Exception ex){
ex.printStackTrace();
}
但在这种情况下,最好使用专门用于字符处理的Reader类之一。我们将在“读取器类和写入器及其子类”部分讨论它们。
文件输入流
FileInputStream类从文件系统中的文件获取数据,例如图像的原始字节。它有以下三个构造器:
FileInputStream(File file)FileInputStream(String name)FileInputStream(FileDescriptor fdObj)
每个构造器打开指定为参数的文件。第一个构造器接受File对象,第二个是文件系统中文件的路径,第三个是表示文件系统中实际文件的现有连接的文件描述符对象。让我们看看下面的例子:
String filePath = "src/main/resources/hello.txt";
try(FileInputStream fis=new FileInputStream(filePath)){
int data;
while ((data = fis.read()) != -1) {
System.out.print(((char)data) + " "); //prints: H e l l o !
}
} catch (Exception ex){
ex.printStackTrace();
}
在src/main/resources文件夹中,我们创建了只有一行的hello.txt文件—Hello!。上述示例的输出如下所示:

因为我们在 IDE 中运行这个示例,所以它在项目根目录中执行。为了找到代码的执行位置,您可以这样打印:
File f = new File("."); //points to the current directory
System.out.println(f.getAbsolutePath()); //prints the directory path
在从hello.txt文件读取字节之后,出于演示目的,我们决定将每个byte转换为char,因此您可以看到我们的代码确实从指定的文件读取,但是对于文本文件处理,FileReader类是一个更好的选择(我们将很快讨论)。如果没有演员阵容,结果将是:
System.out.print((data) + " "); //prints: 72 101 108 108 111 33
顺便说一下,因为src/main/resources文件夹是由 IDE(使用 Maven)放置在类路径上的,所以放置在其中的文件也可以通过类加载器访问,该类加载器使用自己的InputStream实现创建流:
try(InputStream is = InputOutputStream.class.getResourceAsStream("/hello.txt")){
int data;
while ((data = is.read()) != -1) {
System.out.print((data) + " "); //prints: 72 101 108 108 111 33
}
} catch (Exception ex){
ex.printStackTrace();
}
上例中的InputOutputStream类不是某个库中的类。它只是我们用来运行示例的主类。InputOutputStream.class.getResourceAsStream()构造允许使用加载了InputOutputStream类的类加载器来查找类路径上的文件并创建包含其内容的流。在“文件管理”部分,我们也将介绍其他读取文件的方法。
对象输入流
ObjectInputStream类的方法集比任何其他InputStream实现的方法集大得多。原因是它是围绕读取对象字段的值构建的,对象字段可以是各种类型的。为了使ObjectInputStream能够从输入的数据流构造一个对象,该对象必须是可反序列化的,这意味着它首先必须是可序列化的,可以转换成字节流。通常,这样做是为了通过网络传输对象。在目标位置,序列化对象被反序列化,原始对象的值被还原。
基本类型和大多数 Java 类,包括String类和基本类型包装器,都是可序列化的。如果类具有自定义类型的字段,则必须通过实现java.io.Serizalizable使其可序列化。怎么做不在这本书的范围之内。现在,我们只使用可序列化类型。我们来看看这个类:
class SomeClass implements Serializable {
private int field1 = 42;
private String field2 = "abc";
}
我们必须告诉编译器它是可序列化的。否则,编译将失败。这样做是为了确保在声明类是可序列化的之前,程序员检查了所有字段并确保它们是可序列化的,或者已经实现了序列化所需的方法
在创建输入流并使用ObjectInputStream进行反序列化之前,我们需要先序列化对象。这就是为什么我们首先使用ObjectOutputStream和FileOutputStream来序列化一个对象并将其写入someClass.bin文件的原因,我们将在“类OutputStream及其子类”一节中详细讨论它们。然后我们使用FileInputStream读取文件,并使用ObjectInputStream反序列化文件内容:
String fileName = "someClass.bin";
try (ObjectOutputStream objectOutputStream =
new ObjectOutputStream(new FileOutputStream(fileName));
ObjectInputStream objectInputStream =
new ObjectInputStream(new FileInputStream(fileName))){
SomeClass obj = new SomeClass();
objectOutputStream.writeObject(obj);
SomeClass objRead = (SomeClass) objectInputStream.readObject();
System.out.println(objRead.field1); //prints: 42
System.out.println(objRead.field2); //prints: abc
} catch (Exception ex){
ex.printStackTrace();
}
请注意,在运行前面的代码之前,必须先创建文件。我们将在“创建文件和目录”一节中展示如何进行。并且,为了提醒您,我们使用了资源尝试语句,因为InputStream和OutputStream都实现了Closeable接口
管道输入流
管道输入流具有非常特殊的特化;它被用作线程之间通信的机制之一。一个线程从PipedInputStream对象读取数据,并将数据传递给另一个线程,该线程将数据写入PipedOutputStream对象。举个例子:
PipedInputStream pis = new PipedInputStream();
PipedOutputStream pos = new PipedOutputStream(pis);
或者,当一个线程从PipedOutputStream对象读取数据,而另一个线程向PipedInputStream对象写入数据时,数据可以反向移动,如下所示:
PipedOutputStream pos = new PipedOutputStream();
PipedInputStream pis = new PipedInputStream(pos);
在这方面工作的人都熟悉消息,“断管**,表示提供的数据管道流已经停止工作。
*管道流也可以在没有任何连接的情况下创建,稍后再连接,如下所示:
PipedInputStream pis = new PipedInputStream();
PipedOutputStream pos = new PipedOutputStream();
pos.connect(pis);
例如,这里有两个类将由不同的线程执行。首先,PipedOutputWorker类如下:
class PipedOutputWorker implements Runnable{
private PipedOutputStream pos;
public PipedOutputWorker(PipedOutputStream pos) {
this.pos = pos;
}
@Override
public void run() {
try {
for(int i = 1; i < 4; i++){
pos.write(i);
}
pos.close();
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
PipedOutputWorker类有run()方法(因为它实现了Runnable接口),将三个数字1、2和3写入流中,然后关闭。现在让我们看一下PipedInputWorker类,如下所示:
class PipedInputWorker implements Runnable{
private PipedInputStream pis;
public PipedInputWorker(PipedInputStream pis) {
this.pis = pis;
}
@Override
public void run() {
try {
int i;
while((i = pis.read()) > -1){
System.out.print(i + " ");
}
pis.close();
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
它还有run()方法(因为它实现了Runnable接口),从流中读取并打印出每个字节,直到流结束(由-1表示)。现在我们连接这些管道,执行这些类的run()方法:
PipedOutputStream pos = new PipedOutputStream();
PipedInputStream pis = new PipedInputStream();
try {
pos.connect(pis);
new Thread(new PipedOutputWorker(pos)).start();
new Thread(new PipedInputWorker(pis)).start(); //prints: 1 2 3
} catch (Exception ex) {
ex.printStackTrace();
}
如您所见,工作器的对象被传递到了Thread类的构造器中。Thread对象的start()方法执行传入的Runnable的run()方法。我们看到了我们预期的结果,PipedInputWorker打印了PipedOutputWorker写入管道流的所有字节。我们将在第 8 章“多线程和并发处理”中详细介绍线程。
序列输入流
SequenceInputStream类将传入以下构造器之一的输入流作为参数连接起来:
SequenceInputStream(InputStream s1, InputStream s2)SequenceInputStream(Enumeration<InputStream> e)
枚举是尖括号中所示类型的对象集合,称为T类型的泛型。SequenceInputStream类从第一个输入字符串读取,直到它结束,然后从第二个字符串读取,依此类推,直到最后一个流结束。例如,我们在hello.txt文件旁边的resources文件夹中创建一个howAreYou.txt文件(文本为How are you?)。SequenceInputStream类的用法如下:
try(FileInputStream fis1 =
new FileInputStream("src/main/resources/hello.txt");
FileInputStream fis2 =
new FileInputStream("src/main/resources/howAreYou.txt");
SequenceInputStream sis=new SequenceInputStream(fis1, fis2)){
int i;
while((i = sis.read()) > -1){
System.out.print((char)i); //prints: Hello!How are you?
}
} catch (Exception ex) {
ex.printStackTrace();
}
类似地,当输入流的枚举被传入时,每个流都被读取(在本例中被打印)直到结束。
过滤流
FilterInputStream类是在构造器中作为参数传递的InputStream对象周围的包装器。以下是FilterInputStream类的构造器和两个read()方法:
protected volatile InputStream in;
protected FilterInputStream(InputStream in) { this.in = in; }
public int read() throws IOException { return in.read(); }
public int read(byte b[]) throws IOException {
return read(b, 0, b.length);
}
InputStream类的所有其他方法都被类似地覆盖;函数被委托给分配给in属性的对象。
如您所见,构造器是受保护的,这意味着只有子级可以访问它。这样的设计对客户端隐藏了流的实际来源,并迫使程序员使用FilterInputStream类扩展之一:BufferedInputStream、CheckedInputStream、DataInputStream、PushbackInputStream、javax.crypto.CipherInputStream、java.util.zip.DeflaterInputStream、java.util.zip.InflaterInputStream、java.security.DigestInputStream或javax.swing.ProgressMonitorInputStream。或者,可以创建自定义扩展。但是,在创建自己的扩展之前,请查看列出的类,看看其中是否有一个适合您的需要。下面是一个使用BufferedInputStream类的示例:
try(FileInputStream fis =
new FileInputStream("src/main/resources/hello.txt");
FilterInputStream filter = new BufferedInputStream(fis)){
int i;
while((i = filter.read()) > -1){
System.out.print((char)i); //prints: Hello!
}
} catch (Exception ex) {
ex.printStackTrace();
}
BufferedInputStream类使用缓冲区来提高性能。当跳过或读取流中的字节时,内部缓冲区会自动重新填充所包含的输入流中所需的字节数。
CheckedInputStream类添加了所读取数据的校验和,允许使用getChecksum()方法验证输入数据的完整性。
DataInputStream类以独立于机器的方式将输入数据读取并解释为原始 Java 数据类型。
PushbackInputStream类增加了使用unread()方法倒推读取数据的功能,在代码具有分析刚刚读取的数据并决定未读取的逻辑的情况下非常有用,因此可以在下一步重新读取。
javax.crypto.CipherInputStream类将Cipher添加到read()方法中。如果Cipher初始化为解密,javax.crypto.CipherInputStream将在返回之前尝试解密数据。
java.util.zip.DeflaterInputStream类以 Deflate 压缩格式压缩数据。
类以 Deflate 压缩格式解压缩数据。
java.security.DigestInputStream类使用流经流的位来更新相关的消息摘要。on (boolean on)方法打开或关闭摘要功能。计算的摘要可使用getMessageDigest()方法检索。
javax.swing.ProgressMonitorInputStream类提供了对InputStream读取进度的监控。可以使用getProgressMonitor()方法访问监控对象。
javax.sound.sampled.AudioInputStream
AudioInputStream类表示具有指定音频格式和长度的输入流。它有以下两个构造器:
AudioInputStream (InputStream stream, AudioFormat format, long length):接受音频数据流、请求的格式和样本帧的长度AudioInputStream (TargetDataLine line):接受指示的目标数据行
javax.sound.sampled.AudioFormat类描述音频格式属性,如频道、编码、帧速率等。javax.sound.sampled.TargetDataLine类有open()方法打开指定格式的行,还有read()方法从数据行的输入缓冲区读取音频数据。
还有一个javax.sound.sampled.AudioSystem类,它的方法处理AudioInputStream对象。它们可用于读取音频文件、流或 URL,以及写入音频文件,还可用于将音频流转换为其他音频格式。
OutputStream类及其子类
OutputStream类是InputStream类的一个对等类,它是一个抽象类,在 Java 类库(JCL)中有以下直接实现:ByteArrayOutputStream、FilterOutputStream、ObjectOutputStream、PipedOutputStream、FileOutputStream
FileOutputStream类有以下直接扩展:BufferedOutputStream、CheckedOutputStream、DataOutputStream、PrintStream、javax.crypto.CipherOutputStream、java.util.zip.DeflaterOutputStream、java.security.DigestOutputStream和java.util.zip.InflaterOutputStream。
它们要么按原样使用,要么覆盖OutputStream类的以下方法:
void close():关闭流并释放资源void flush():强制写出剩余字节static OutputStream nullOutputStream():创建一个新的OutputStream,不写入任何内容void write(byte[] b):将提供的字节数组写入输出流void write(byte[] b, int off, int len):从off偏移量开始,将所提供字节数组的len字节写入输出流abstract void write(int b):将提供的字节写入输出流
唯一需要实现的方法是abstract void write(int b),但是OutputStream类的大多数后代也覆盖了许多其他方法
在学习了“类InputStream及其子类”部分中的输入流之后,除了PrintStream类之外的所有OutputStream实现都应该对您非常熟悉。所以,我们在这里只讨论PrintStream类。
打印流
PrintStream类向另一个输出流添加了将数据打印为字符的能力。实际上我们已经用过很多次了。System类将PrintStream类的对象设置为System.out公共静态属性。这意味着每次我们使用System.out打印东西时,我们都使用PrintStream类:
System.out.println("Printing a line");
让我们看另一个PrintStream类用法的例子:
String fileName = "output.txt";
try(FileOutputStream fos = new FileOutputStream(fileName);
PrintStream ps = new PrintStream(fos)){
ps.println("Hi there!");
} catch (Exception ex) {
ex.printStackTrace();
}
如您所见,PrintStream类接受FileOutputStream对象并打印它生成的字符,在这种情况下,它打印出FileOutputStream写入文件的所有字节,顺便说一下,不需要显式地创建目标文件。如果不存在,则会在FileOutputStream构造器中自动创建,如果在前面的代码运行后打开文件,则会看到其中一行:"Hi there!"
或者,也可以使用另一个PrintStream构造器来获得相同的结果,该构造器接受File对象,如下所示:
String fileName = "output.txt";
File file = new File(fileName);
try(PrintStream ps = new PrintStream(file)){
ps.println("Hi there!");
} catch (Exception ex) {
ex.printStackTrace();
}
使用以文件名为参数的PrintStream构造器的第三个变体可以创建一个更简单的解决方案:
String fileName = "output.txt";
try(PrintStream ps = new PrintStream(fileName)){
ps.println("Hi there!");
} catch (Exception ex) {
ex.printStackTrace();
}
前两个例子是可能的,因为PrintStream构造器在幕后使用FileOutputStream类,就像我们在PrintStream类用法的第一个例子中所做的一样。所以PrintStream类有几个构造器只是为了方便,但它们基本上都有相同的功能:
PrintStream(File file)PrintStream(File file, String csn)PrintStream(File file, Charset charset)PrintStream(String fileName)PrintStream(String fileName, String csn)PrintStream(String fileName, Charset charset)PrintStream(OutputStream out)PrintStream(OutputStream out, boolean autoFlush)PrintStream(OutputStream out, boolean autoFlush, String encoding)PrintStream(OutputStream out, boolean autoFlush, Charset charset)
一些构造器还采用一个Charset实例或其名称(String csn),这允许在 16 位 Unicode 代码单元序列和字节序列之间应用不同的映射。只需将所有可用的字符集打印出来即可查看它们,如下所示:
for (String chs : Charset.availableCharsets().keySet()) {
System.out.println(chs);
}
其他构造器以boolean autoFlush为参数。此参数表示(当true时)当写入数组或遇到符号行尾时,输出缓冲区应自动刷新。
一旦创建了一个PrintStream的对象,它就提供了如下所示的各种方法:
-
void print(T value):打印传入的任何T原始类型的值,而不移动到另一行 -
void print(Object obj):对传入对象调用toString()方法,打印结果,不移行;传入对象为null时不生成NullPointerException,而是打印null -
void println(T value):打印传入的任何T原始类型的值并移动到另一行 -
void println(Object obj):对传入对象调用toString()方法,打印结果,移到另一行;传入对象为null时不生成NullPointerException,而是打印null -
void println():移动到另一行 -
PrintStream printf(String format, Object... values):用提供的values替换提供的format字符串中的占位符,并将结果写入流中 -
PrintStream printf(Locale l, String format, Object... args):与前面的方法相同,但是使用提供的Local对象进行定位;如果提供的Local对象是null,则不进行定位,该方法的行为与前面的方法完全相同 -
PrintStream format(String format, Object... args)、PrintStream format(Locale l, String format, Object... args):与PrintStream printf(String format, Object... values)、PrintStream printf(Locale l, String format, Object... args)(已在列表中描述)行为相同,例如:
System.out.printf("Hi, %s!%n", "dear reader"); //prints: Hi, dear reader!
System.out.format("Hi, %s!%n", "dear reader"); //prints: Hi, dear reader!
在上例中,(%表示格式化规则。以下符号(s)表示String值,此位置的其他可能符号可以是(d(十进制)、(f(浮点)等。符号(n)表示新行(与(\n)转义符相同)。有许多格式规则。所有这些都在java.util.Formatter类的文档中进行了描述。
PrintStream append(char c)、PrintStream append(CharSequence c)、PrintStream append(CharSequence c, int start, int end):将提供的字符追加到流中。例如:
System.out.printf("Hi %s", "there").append("!\n"); //prints: Hi there!
System.out.printf("Hi ")
.append("one there!\n two", 4, 11); //prints: Hi there!
至此,我们结束了对OutputStream子类的讨论,现在将注意力转向另一个类层次结构Reader和Writer类及其子类。
Reader和Writer类及其子类
正如我们已经多次提到的,Reader和Writer类在功能上与InputStream和OutputStream类非常相似,但专门处理文本。它们将流字节解释为字符,并有自己独立的InputStream和OutputStream类层次结构。在没有Reader和Writer或它们的任何子类的情况下,可以将流字节作为字符进行处理。我们在前面描述InputStream和OutputStream类的章节中看到了这样的示例。但是,使用Reader和Writer类可以简化文本处理,代码更易于阅读。
Reader及其子类
类Reader是一个抽象类,它将流作为字符读取。它是对InputStream的模拟,有以下方法:
-
abstract void close():关闭流和其他使用的资源 -
void mark(int readAheadLimit):标记流中的当前位置 -
boolean markSupported():如果流支持mark()操作,则返回true -
static Reader nullReader():创建不读取字符的空读取器 -
int read():读一个字符 -
int read(char[] buf):将字符读入提供的buf数组,并返回读取字符的计数 -
abstract int read(char[] buf, int off, int len):从off索引开始将len字符读入数组 -
int read(CharBuffer target):尝试将字符读入提供的target缓冲区 -
boolean ready():当流准备好读取时返回true -
void reset():重新设置标记,但是不是所有的流都支持这个操作,有些流支持,但是不支持设置标记 -
long skip(long n):尝试跳过n个字符;返回跳过字符的计数 -
long transferTo(Writer out):从该读取器读取所有字符,并将字符写入提供的Writer对象
如您所见,唯一需要实现的方法是两个抽象的read()和close()方法。然而,这个类的许多子类也覆盖了其他方法,有时是为了更好的性能或不同的功能。JCL 中的Reader子类是:CharArrayReader、InputStreamReader、PipedReader、StringReader、BufferedReader和FilterReader。BufferedReader类有LineNumberReader子类,FilterReader类有PushbackReader子类。
Writer及其子类
抽象的Writer类写入字符流。它是OutputStream的一个模拟,具有以下方法:
Writer append(char c):将提供的字符追加到流中Writer append(CharSequence c):将提供的字符序列追加到流中Writer append(CharSequence c, int start, int end):将所提供的字符序列的子序列追加到流中abstract void close():刷新并关闭流和相关系统资源abstract void flush():冲流static Writer nullWriter():创建一个新的Writer对象,丢弃所有字符void write(char[] c):写入c字符数组abstract void write(char[] c, int off, int len):从off索引开始写入c字符数组的len元素void write(int c):写一个字void write(String str):写入提供的字符串void write(String str, int off, int len):从off索引开始,从提供的str字符串写入一个len长度的子字符串
如您所见,三个抽象方法:write(char[], int, int)、flush()和close()必须由这个类的子类实现,它们通常也覆盖其他方法。
JCL 中的Writer子类是:CharArrayWriter、OutputStreamWriter、PipedWriter、StringWriter、BufferedWriter、FilterWriter和PrintWriter。OutputStreamWriter类有一个FileWriter子类。
java.io包的其他类
java.io包的其他类别包括:
Console:允许与当前 JVM 实例关联的基于字符的控制台设备进行交互StreamTokenizer:获取一个输入流并将其解析为tokensObjectStreamClass:类的序列化描述符ObjectStreamField:可序列化类中可序列化字段的描述RandomAccessFile:允许对文件进行随机读写,但其讨论超出了本书的范围File:允许创建和管理文件和目录;在“文件管理”部分中描述
控制台
创建和运行执行应用的 Java 虚拟机(JVM)实例有几种方法,如果 JVM 是从命令行启动的,控制台窗口会自动打开,它允许从键盘在显示器上键入内容,但是 JVM 也可以通过后台进程启动。在这种情况下,不会创建控制台。
为了通过编程检查控制台是否存在,可以调用System.console()静态方法。如果没有可用的控制台设备,则调用该方法将返回null。否则,它将返回一个允许与控制台设备和应用用户交互的Console类的对象。
让我们创建以下ConsoleDemo类:
package com.packt.learnjava.ch05_stringsIoStreams;
import java.io.Console;
public class ConsoleDemo {
public static void main(String... args) {
Console console = System.console();
System.out.println(console);
}
}
如果我们像通常那样从 IDE 运行它,结果如下:

这是因为 JVM 不是从命令行启动的。为了做到这一点,让我们编译应用并通过在项目的根目录中执行mvn clean packageMaven 命令来创建一个.jar文件。删除target文件夹,然后重新创建,将所有.java文件编译成target文件夹中相应的.class文件,然后归档到.jar文件learnjava-1.0-SNAPSHOT.jar中。
现在我们可以使用以下命令从同一个项目根目录启动ConsoleDemo应用:
java -cp ./target/learnjava-1.0-SNAPSHOT.jar
com.packt.learnjava.ch05_stringsIoStreams.ConsoleDemo
前面的命令显示为两行,因为页面宽度不能容纳它。但是如果你想运行它,一定要把它作为一行。结果如下:

它告诉我们现在有了Console类对象。让我们看看能用它做些什么。该类具有以下方法:
-
String readLine():等待用户点击Enter并从控制台读取文本行 -
String readLine(String format, Object... args):显示提示(提供的格式将占位符替换为提供的参数后产生的消息),等待用户点击Enter,从控制台读取文本行;如果没有提供参数args,则显示格式作为提示 -
char[] readPassword():执行与readLine()相同的功能,但不回显键入的字符 -
char[] readPassword(String format, Object... args):执行与readLine(String format, Object... args)相同的功能,但不回显键入的字符
让我们用下面的例子来演示前面的方法:
Console console = System.console();
String line = console.readLine();
System.out.println("Entered 1: " + line);
line = console.readLine("Enter something 2: ");
System.out.println("Entered 2: " + line);
line = console.readLine("Enter some%s", "thing 3: ");
System.out.println("Entered 3: " + line);
char[] password = console.readPassword();
System.out.println("Entered 4: " + new String(password));
password = console.readPassword("Enter password 5: ");
System.out.println("Entered 5: " + new String(password));
password = console.readPassword("Enter pass%s", "word 6: ");
System.out.println("Entered 6: " + new String(password));
上例的结果如下:

另一组Console类方法可以与刚才演示的方法结合使用:
Console format(String format, Object... args):用提供的args值替换提供的format字符串中的占位符,并显示结果Console printf(String format, Object... args):与format()方法相同
例如,请看下面一行:
String line = console.format("Enter some%s", "thing:").readLine();
它产生与此行相同的结果:
String line = console.readLine("Enter some%s", "thing:");
最后,Console类的最后三个方法如下:
PrintWriter writer():创建一个与此控制台关联的PrintWriter对象,用于生成字符的输出流Reader reader():创建一个与此控制台相关联的Reader对象,用于将输入作为字符流读取void flush():刷新控制台并强制立即写入任何缓冲输出
以下是它们的用法示例:
try (Reader reader = console.reader()){
char[] chars = new char[10];
System.out.print("Enter something: ");
reader.read(chars);
System.out.print("Entered: " + new String(chars));
} catch (IOException e) {
e.printStackTrace();
}
PrintWriter out = console.writer();
out.println("Hello!");
console.flush();
上述代码的结果如下所示:

Reader和PrintWriter还可以用于创建我们在本节中讨论的其他Input和Output流。
流分词器
StreamTokenizer类解析输入流并生成令牌。它的StreamTokenizer(Reader r)构造器接受一个Reader对象,该对象是令牌的源。每次对StreamTokenizer对象调用int nextToken()方法时,都会发生以下情况:
- 下一个标记被解析
StreamTokenizer实例字段ttype由指示令牌类型的值填充:ttype值可以是以下整数常量之一:TT_WORD、TT_NUMBER、TT_EOL(行尾)或TT_EOF(流尾)- 如果
ttype值为TT_WORD,则StreamTokenizer实例sval字段由令牌的String值填充 - 如果
ttype值为TT_NUMBER,则StreamTokenizer实例字段nval由令牌的double值填充
StreamTokenizer实例的lineno()方法返回当前行号
在讨论StreamTokenizer类的其他方法之前,让我们先看一个例子。假设在项目resources文件夹中有一个tokens.txt文件,其中包含以下四行文本:
There
happened
42
events.
以下代码将读取文件并标记其内容:
String filePath = "src/main/resources/tokens.txt";
try(FileReader fr = new FileReader(filePath);
BufferedReader br = new BufferedReader(fr)){
StreamTokenizer st = new StreamTokenizer(br);
st.eolIsSignificant(true);
st.commentChar('e');
System.out.println("Line " + st.lineno() + ":");
int i;
while ((i = st.nextToken()) != StreamTokenizer.TT_EOF) {
switch (i) {
case StreamTokenizer.TT_EOL:
System.out.println("\nLine " + st.lineno() + ":");
break;
case StreamTokenizer.TT_WORD:
System.out.println("TT_WORD => " + st.sval);
break;
case StreamTokenizer.TT_NUMBER:
System.out.println("TT_NUMBER => " + st.nval);
break;
default:
System.out.println("Unexpected => " + st.ttype);
}
}
} catch (Exception ex){
ex.printStackTrace();
}
如果运行此代码,结果如下:

我们已经使用了BufferedReader类,这是提高效率的一个很好的实践,但是在我们的例子中,我们可以很容易地避免这样的情况:
FileReader fr = new FileReader(filePath);
StreamTokenizer st = new StreamTokenizer(fr);
结果不会改变。我们还使用了以下三种尚未描述的方法:
void eolIsSignificant(boolean flag):表示行尾是否作为令牌处理void commentChar(int ch):表示哪个字符开始一个注释,因此忽略行的其余部分int lineno():返回当前行号
使用StreamTokenizer对象可以调用以下方法:
void lowerCaseMode(boolean fl):表示单词标记是否应该小写void ordinaryChar(int ch)、void ordinaryChars(int low, int hi):表示必须作为普通处理的特定字符或字符范围(不能作为注释字符、词成分、字符串分隔符、空格或数字字符)void parseNumbers():表示具有双精度浮点数格式的字标记必须被解释为数字而不是字void pushBack():强制nextToken()方法返回ttype字段的当前值void quoteChar(int ch):表示提供的字符必须解释为字符串值的开头和结尾,该字符串值必须按原样(作为引号)处理void resetSyntax():重置此标记器的语法表,使所有字符都是普通void slashSlashComments(boolean flag):表示必须识别 C++ 风格的注释void slashStarComments(boolean flag):表示必须识别 C 风格的注释String toString():返回令牌的字符串表示和行号
void whitespaceChars(int low, int hi):表示必须解释为空白的字符范围void wordChars(int low, int hi):表示必须解释为单词的字符范围
如您所见,使用前面丰富的方法可以对文本解释进行微调。
ObjectStreamClass和ObjectStreamField
ObjectStreamClass和ObjectStreamField类提供对 JVM 中加载的类的序列化数据的访问。ObjectStreamClass对象可以使用以下查找方法之一找到/创建:
static ObjectStreamClass lookup(Class cl):查找可序列化类的描述符static ObjectStreamClass lookupAny(Class cl):查找任何类的描述符,无论是否可序列化
在找到ObjectStreamClass并且类是可序列化的(实现Serializable接口)之后,可以使用它访问ObjectStreamField对象,每个对象包含一个序列化字段的信息。如果该类不可序列化,则没有与任何字段关联的ObjectStreamField对象。
让我们看一个例子。以下是显示从ObjectStreamClass和ObjectStreamField对象获得的信息的方法:
void printInfo(ObjectStreamClass osc) {
System.out.println(osc.forClass());
System.out.println("Class name: " + osc.getName());
System.out.println("SerialVersionUID: " + osc.getSerialVersionUID());
ObjectStreamField[] fields = osc.getFields();
System.out.println("Serialized fields:");
for (ObjectStreamField osf : fields) {
System.out.println(osf.getName() + ": ");
System.out.println("\t" + osf.getType());
System.out.println("\t" + osf.getTypeCode());
System.out.println("\t" + osf.getTypeString());
}
}
为了演示它是如何工作的,我们创建了一个可序列化的Person1类:
package com.packt.learnjava.ch05_stringsIoStreams;
import java.io.Serializable;
public class Person1 implements Serializable {
private int age;
private String name;
public Person1(int age, String name) {
this.age = age;
this.name = name;
}
}
我们没有添加方法,因为只有对象状态是可序列化的,而不是方法。现在让我们运行以下代码:
ObjectStreamClass osc1 = ObjectStreamClass.lookup(Person1.class);
printInfo(osc1);
结果如下:

如您所见,有关于类名、所有字段名和类型的信息。使用ObjectStreamField对象还可以调用另外两个方法:
boolean isPrimitive():如果该字段有原始类型,则返回trueboolean isUnshared():如果此字段未共享(私有或只能从同一包访问),则返回true
现在让我们创建一个不可序列化的Person2类:
package com.packt.learnjava.ch05_stringsIoStreams;
public class Person2 {
private int age;
private String name;
public Person2(int age, String name) {
this.age = age;
this.name = name;
}
}
这次,我们将运行只查找类的代码,如下所示:
ObjectStreamClass osc2 = ObjectStreamClass.lookup(Person2.class);
System.out.println("osc2: " + osc2); //prints: null
正如预期的那样,使用lookup()方法找不到不可序列化的对象。为了找到一个不可序列化的对象,我们需要使用lookupAny()方法:
ObjectStreamClass osc3 = ObjectStreamClass.lookupAny(Person2.class);
printInfo(osc3);
如果我们运行前面的示例,结果如下:

从一个不可序列化的对象中,我们可以提取关于类的信息,但不能提取关于字段的信息。
java.util.Scanner类
java.util.Scanner类通常用于从键盘读取输入,但可以从实现Readable接口的任何对象读取文本(该接口只有int read(CharBuffer buffer)方法)。它用一个分隔符(空白是默认分隔符)将输入值拆分为使用不同方法处理的标记。
例如,我们可以从System.in读取一个输入—一个标准输入流,它通常表示键盘输入:
Scanner sc = new Scanner(System.in);
System.out.print("Enter something: ");
while(sc.hasNext()){
String line = sc.nextLine();
if("end".equals(line)){
System.exit(0);
}
System.out.println(line);
}
它接受许多行(每行在按下Enter键后结束),直到按如下方式输入行end:

或者,Scanner可以从文件中读取行:
String filePath = "src/main/resources/tokens.txt";
try(Scanner sc = new Scanner(new File(filePath))){
while(sc.hasNextLine()){
System.out.println(sc.nextLine());
}
} catch (Exception ex){
ex.printStackTrace();
}
如您所见,我们再次使用了tokens.txt文件。结果如下:

为了演示Scanner用分隔符打断输入,让我们运行以下代码:
String input = "One two three";
Scanner sc = new Scanner(input);
while(sc.hasNext()){
System.out.println(sc.next());
}
结果如下:

要使用另一个分隔符,可以按如下方式设置:
String input = "One,two,three";
Scanner sc = new Scanner(input).useDelimiter(",");
while(sc.hasNext()){
System.out.println(sc.next());
}
结果保持不变:

也可以使用正则表达式来提取标记,但是本主题不在本书的范围之内。
Scanner类有许多其他方法使其用法适用于各种源和所需结果。findInLine()、findWithinHorizon()、skip()和findAll()方法不使用分隔符,它们只是尝试匹配提供的模式。有关更多信息,请参阅扫描器文档。
文件管理
我们已经使用了一些方法来使用 JCL 类查找、创建、读取和写入文件。我们必须这样做,以支持输入/输出流的演示代码。在本节中,我们将更详细地讨论使用 JCL 的文件管理。
来自java.io包的File类表示底层文件系统。可以使用以下构造器之一创建File类的对象:
File(String pathname):根据提供的路径名新建File实例File(String parent, String child):根据提供的父路径名和子路径名新建File实例File(File parent, String child):基于提供的父File对象和子路径名创建一个新的File实例File(URI uri):根据提供的URI对象创建一个新的File实例,该对象表示路径名
我们现在将看到构造器在创建和删除文件时的用法示例。
创建和删除文件和目录
要在文件系统中创建文件或目录,首先需要使用“文件管理”部分中列出的一个构造器来构造一个新的File对象。例如,假设文件名为FileName.txt,则可以将File对象创建为new File("FileName.txt")。如果必须在目录中创建文件,则必须在文件名前面添加路径(当文件被传递到构造器时),或者必须使用其他三个构造器中的一个。例如:
String path = "demo1" + File.separator + "demo2" + File.separator;
String fileName = "FileName.txt";
File f = new File(path + fileName);
注意使用File.separator代替斜杠符号(/)或(\)。这是因为File.separator返回特定于平台的斜杠符号。下面是另一个File构造器用法的示例:
String path = "demo1" + File.separator + "demo2" + File.separator;
String fileName = "FileName.txt";
File f = new File(path, fileName);
另一个构造器可以如下使用:
String path = "demo1" + File.separator + "demo2" + File.separator;
String fileName = "FileName.txt";
File f = new File(new File(path), fileName);
但是,如果您喜欢或必须使用通用资源标识符(URI),您可以这样构造一个File对象:
String path = "demo1" + File.separator + "demo2" + File.separator;
String fileName = "FileName.txt";
URI uri = new File(path + fileName).toURI();
File f = new File(uri);
然后必须在新创建的File对象上调用以下方法之一:
-
boolean createNewFile():如果该名称的文件不存在,则新建一个文件,返回true,否则返回false -
static File createTempFile(String prefix, String suffix):在临时文件目录中创建一个文件 -
static File createTempFile(String prefix, String suffix, File directory):创建目录,提供的前缀和后缀用于生成目录名
如果要创建的文件必须放在尚不存在的目录中,则必须首先使用以下方法之一,在表示文件的文件系统路径的File对象上调用:
boolean mkdir():用提供的名称创建目录boolean mkdirs():用提供的名称创建目录,包括任何必要但不存在的父目录
在看代码示例之前,我们需要解释一下delete()方法是如何工作的:
boolean delete():删除文件或空目录,即可以删除文件,但不能删除所有目录,如下所示:
String path = "demo1" + File.separator + "demo2" + File.separator;
String fileName = "FileName.txt";
File f = new File(path + fileName);
f.delete();
让我们在下面的示例中看看如何克服此限制:
String path = "demo1" + File.separator + "demo2" + File.separator;
String fileName = "FileName.txt";
File f = new File(path + fileName);
try {
new File(path).mkdirs();
f.createNewFile();
f.delete();
path = StringUtils.substringBeforeLast(path, File.separator);
while (new File(path).delete()) {
path = StringUtils.substringBeforeLast(path, File.separator);
}
} catch (Exception e) {
e.printStackTrace();
}
这个例子创建和删除一个文件和所有相关的目录,注意我们在“字符串工具”一节中讨论的org.apache.commons.lang3.StringUtils类的用法。它允许我们从路径中删除刚刚删除的目录,并继续这样做,直到所有嵌套的目录都被删除,而顶层目录最后被删除
列出文件和目录
下列方法可用于列出其中的目录和文件:
String[] list():返回目录中文件和目录的名称File[] listFiles():返回File表示目录中文件和目录的对象static File[] listRoots():列出可用的文件系统根目录
为了演示前面的方法,假设我们已经创建了目录和其中的两个文件,如下所示:
String path1 = "demo1" + File.separator;
String path2 = "demo2" + File.separator;
String path = path1 + path2;
File f1 = new File(path + "file1.txt");
File f2 = new File(path + "file2.txt");
File dir1 = new File(path1);
File dir = new File(path);
dir.mkdirs();
f1.createNewFile();
f2.createNewFile();
之后,我们应该能够运行以下代码:
System.out.print("\ndir1.list(): ");
for(String d: dir1.list()){
System.out.print(d + " ");
}
System.out.print("\ndir1.listFiles(): ");
for(File f: dir1.listFiles()){
System.out.print(f + " ");
}
System.out.print("\ndir.list(): ");
for(String d: dir.list()){
System.out.print(d + " ");
}
System.out.print("\ndir.listFiles(): ");
for(File f: dir.listFiles()){
System.out.print(f + " ");
}
System.out.print("\nFile.listRoots(): ");
for(File f: File.listRoots()){
System.out.print(f + " ");
}
结果如下:

演示的方法可以通过向其添加以下过滤器来增强,因此它们将仅列出与过滤器匹配的文件和目录:
String[] list(FilenameFilter filter)File[] listFiles(FileFilter filter)File[] listFiles(FilenameFilter filter)
但是,对文件过滤器的讨论超出了本书的范围。
Apache 公共工具FileUtils和IOUtils
JCL 最流行的伙伴是 ApacheCommons 项目,它提供了许多库来补充 JCL 功能。org.apache.commons.io包的类包含在以下根包和子包中:
-
org.apache.commons.io根包包含用于常见任务的带有静态方法的工具类,例如分别在“类FileUtils”和“类IOUtils”小节中描述的流行的FileUtils和IOUtils类 -
org.apache.commons.io.input包包含支持基于InputStream和Reader实现的输入的类,如XmlStreamReader或ReversedLinesFileReader -
org.apache.commons.io.output包包含支持基于OutputStream和Writer实现的输出的类,如XmlStreamWriter或StringBuilderWriter -
org.apache.commons.io.filefilter包包含用作文件过滤器的类,如DirectoryFileFilter或RegexFileFilter -
org.apache.commons.io.comparator包包含java.util.Comparator的各种文件实现,如NameFileComparator -
org.apache.commons.io.serialization包提供了一个控制类反序列化的框架 -
org.apache.commons.io.monitor包允许监视文件系统并检查目录或文件的创建、更新或删除;可以将FileAlterationMonitor对象作为线程启动,并创建一个FileAlterationObserver对象,以指定的间隔检查文件系统中的更改
请参阅 Apache Commons 项目文档了解更多细节。
FileUtils类
一个流行的org.apache.commons.io.FileUtils类允许对您可能需要的文件执行所有可能的操作,如下所示:
- 写入文件
- 从文件读取
- 创建包含父目录的目录
- 复制文件和目录
- 删除文件和目录
- 与 URL 之间的转换
- 按过滤器和扩展名列出文件和目录
- 比较文件内容
- 获取文件上次更改日期
- 计算校验和
如果您计划以编程方式管理文件和目录,那么您必须学习 ApacheCommons 项目网站上的此类文档。
IOUtils类
org.apache.commons.io.IOUtils是另一个非常有用的工具类,提供以下通用 IO 流操作方法:
closeQuietly:关闭流的方法,忽略空值和异常toXxx/read:从流中读取数据的方法write:将数据写入流的方法copy:将所有数据从一个流复制到另一个流的方法contentEquals:比较两种流的含量的方法
该类中所有读取流的方法都在内部缓冲,因此不需要使用BufferedInputStream或BufferedReader类。copy方法都在幕后使用copyLarge方法,大大提高了它们的性能和效率。
这个类对于管理 IO 流是必不可少的。在 ApacheCommons 项目网站上可以看到关于这个类及其方法的更多细节。
总结
在本章中,我们讨论了允许分析、比较和转换字符串的String类方法。我们还讨论了 JCL 和 ApacheCommons 项目中流行的字符串工具。本章的两个主要部分专门介绍 JCL 和 ApacheCommons 项目中的输入/输出流和支持类。文中还讨论了文件管理类及其方法,并给出了具体的代码实例。
在下一章中,我们将介绍 Java 集合框架及其三个主要接口List、Set和Map,包括泛型的讨论和演示。我们还将讨论用于管理数组、对象和时间/日期值的工具类。
测验
- 下面的代码打印什么?
String str = "&8a!L";
System.out.println(str.indexOf("a!L"));
- 下面的代码打印什么?
String s1 = "x12";
String s2 = new String("x12");
System.out.println(s1.equals(s2));
- 下面的代码打印什么?
System.out.println("%wx6".substring(2));
- 下面的代码打印什么?
System.out.println("ab"+"42".repeat(2));
- 下面的代码打印什么?
String s = " ";
System.out.println(s.isBlank()+" "+s.isEmpty());
-
选择所有正确的语句:
- 流可以表示数据源
- 输入流可以写入文件
- 流可以表示数据目的地
- 输出流可以在屏幕上显示数据
-
选择所有关于
java.io包类的正确语句:- 读取器扩展
InputStream - 读取器扩展
OutputStream - 读取器扩展
java.lang.Object - 读取器扩展
java.lang.Input
- 读取器扩展
-
选择所有关于
java.io包类的正确语句:- 写入器扩展
FilterOutputStream - 写入器扩展
OutputStream - 写入器扩展
java.lang.Output - 写入器扩展
java.lang.Object
- 写入器扩展
-
选择所有关于
java.io包类的正确语句:PrintStream扩展FilterOutputStreamPrintStream扩展OutputStreamPrintStream扩展java.lang.ObjectPrintStream扩展java.lang.Output
-
下面的代码是做什么的?
String path = "demo1" + File.separator + "demo2" + File.separator;
String fileName = "FileName.txt";
File f = new File(path, fileName);
try {
new File(path).mkdir();
f.createNewFile();
} catch (Exception e) {
e.printStackTrace();
}
```*
# 六、数据结构、泛型和流行工具
本章介绍了 Java 集合框架及其三个主要接口:`List`、`Set`和`Map`,包括泛型的讨论和演示。`equals()`和`hashCode()`方法也在 Java 集合的上下文中讨论。用于管理数组、对象和时间/日期值的工具类也有相应的专用部分。
本章将讨论以下主题:
* `List`、`Set`、`Map`接口
* 集合工具
* 数组工具
* 对象工具
* `java.time`包
# 列表、集合和映射接口
**Java 集合框架**由实现集合数据结构的类和接口组成。集合在这方面类似于数组,因为它们可以保存对对象的引用,并且可以作为一个组进行管理。不同之处在于,数组需要先定义它们的容量,然后才能使用,而集合可以根据需要自动增减大小。一种是添加或删除对集合的对象引用,集合相应地改变其大小,另一个区别是集合的元素不能是原始类型,如`short`、`int`或`double`。如果需要存储这样的类型值,那么元素必须是相应的包装器类型,例如`Short`、`Integer`或`Double`。
Java 集合支持存储和访问集合元素的各种算法:有序列表、唯一集、Java 中称为**映射**的字典、栈、**队列**,Java 集合框架的所有类和接口都属于 Java 类库的`java.util`包。`java.util`包包含以下内容:
* 对`Collection`接口进行扩展的接口有:`List`、`Set`、`Queue`等
* 实现前面列出的接口的类:`ArrayList`、`HashSet`、`Stack`、`LinkedList`和其他一些类
* `Map`接口及其子接口:`ConcurrentMap`、`SortedMap`,以一对夫妇的名字命名
* 实现与`Map`相关的接口的类:`HashMap`、`HashTable`、`TreeMap`,这三个类是最常用的
要查看`java.util`包的所有类和接口,需要一本专门的书。因此,在本节中,我们将简要介绍三个主要接口:`List`、`Set`和`Map`——以及它们各自的一个实现类:`ArrayList`、`HashSet`和`HashMap`。我们从`List`和`Set`接口共享的方法开始。`List`和`Set`的主要区别在于`Set`不允许元素重复。另一个区别是`List`保留了元素的顺序,也允许对它们进行排序。
要标识集合中的元素,请使用`equals()`方法。为了提高性能,实现`Set`接口的类也经常使用`hashCode()`方法。它允许快速计算一个整数(称为**散列值**或**哈希码**),该整数在大多数时间(但并非总是)对每个元素都是唯一的。具有相同哈希值的元素被放置在相同的*桶*中。在确定集合中是否已经存在某个值时,检查内部哈希表并查看是否已经使用了这样的值就足够了。否则,新元素是唯一的。如果是,则可以将新元素与具有相同哈希值的每个元素进行比较(使用`equals()`方法)。这样的过程比逐个比较新元素和集合中的每个元素要快
这就是为什么我们经常看到类的名称有`Hash`前缀,表示类使用了哈希值,所以元素必须实现`hashCode()`方法,在实现时一定要确保`equals()`方法每次为两个对象返回`true`时,`hashCode()`方法返回的这两个对象的散列值也是相等的。否则,所有刚才描述的使用哈希值的算法都将不起作用。
最后,在讨论`java.util`接口之前,先谈一下泛型。
# 泛型
您最常在以下声明中看到它们:
```java
List<String> list = new ArrayList<String>();
Set<Integer> set = new HashSet<Integer>();
在前面的例子中,泛型是被尖括号包围的元素类型声明。如您所见,它们是多余的,因为它们在赋值语句的左侧和右侧重复。这就是为什么 Java 允许用空括号(<>)替换右侧的泛型,称为菱形:
List<String> list = new ArrayList<>();
Set<Integer> set = new HashSet<>();
泛型通知编译器集合元素的预期类型。这样编译器就可以检查程序员试图添加到声明集合中的元素是否是兼容类型。例如:
List<String> list = new ArrayList<>();
list.add("abc");
list.add(42); //compilation error
它有助于避免运行时错误。它还向程序员提示可能对集合元素进行的操作(因为程序员编写代码时 IDE 会编译代码)。
我们还将看到其他类型的泛型:
<? extends T>表示T或T的子类型,其中T是用作集合泛型的类型<? super T>表示T或其任何基(父)类,其中T是用作集合泛型的类型
那么,让我们从实现List或Set接口的类的对象的创建方式开始,或者换句话说,可以初始化List或Set类型的变量。为了演示这两个接口的方法,我们将使用两个类:ArrayList(实现List)和HashSet(实现Set)。
如何初始化列表和集合
由于 Java9,List或Set接口具有静态工厂方法of(),可用于初始化集合:
of():返回空集合。of(E... e):返回一个集合,其中包含调用期间传入的元素数。它们可以以逗号分隔的列表或数组形式传递。
以下是几个例子:
//Collection<String> coll = List.of("s1", null); //does not allow null
Collection<String> coll = List.of("s1", "s1", "s2");
//coll.add("s3"); //does not allow add element
//coll.remove("s1"); //does not allow remove element
((List<String>) coll).set(1, "s3"); //does not allow modify element
System.out.println(coll); //prints: [s1, s1, s2]
//coll = Set.of("s3", "s3", "s4"); //does not allow duplicate
//coll = Set.of("s2", "s3", null); //does not allow null
coll = Set.of("s3", "s4");
System.out.println(coll); //prints: [s3, s4]
//coll.add("s5"); //does not allow add element
//coll.remove("s2"); //does not allow remove
正如人们所料,Set的工厂方法不允许重复,因此我们已经注释掉了该行(否则,前面的示例将停止在该行运行)。不太令人期待的是,不能有一个null元素,也不能在使用of()方法之一初始化集合之后添加/删除/修改集合的元素。这就是为什么我们注释掉了前面示例中的一些行。如果需要在集合初始化后添加元素,则必须使用构造器或其他创建可修改集合的工具对其进行初始化(稍后我们将看到一个Arrays.asList()的示例)。
接口Collection提供了两种向实现了Collection(List和Set的父接口)的对象添加元素的方法,如下所示:
-
boolean add(E e):尝试将提供的元素e添加到集合中,成功返回true,无法完成返回false(例如Set中已经存在该元素) -
boolean addAll(Collection<? extends E> c):尝试将所提供集合中的所有元素添加到集合中;如果至少添加了一个元素,则返回true;如果无法将元素添加到集合中,则返回false(例如,当所提供集合c中的所有元素都已存在于Set中时)
以下是使用add()方法的示例:
List<String> list1 = new ArrayList<>();
list1.add("s1");
list1.add("s1");
System.out.println(list1); //prints: [s1, s1]
Set<String> set1 = new HashSet<>();
set1.add("s1");
set1.add("s1");
System.out.println(set1); //prints: [s1]
下面是一个使用addAll()方法的例子:
List<String> list1 = new ArrayList<>();
list1.add("s1");
list1.add("s1");
System.out.println(list1); //prints: [s1, s1]
List<String> list2 = new ArrayList<>();
list2.addAll(list1);
System.out.println(list2); //prints: [s1, s1]
Set<String> set = new HashSet<>();
set.addAll(list1);
System.out.println(set); //prints: [s1]
以下是add()和addAll()方法的功能示例:
List<String> list1 = new ArrayList<>();
list1.add("s1");
list1.add("s1");
System.out.println(list1); //prints: [s1, s1]
List<String> list2 = new ArrayList<>();
list2.addAll(list1);
System.out.println(list2); //prints: [s1, s1]
Set<String> set = new HashSet<>();
set.addAll(list1);
System.out.println(set); //prints: [s1]
Set<String> set1 = new HashSet<>();
set1.add("s1");
Set<String> set2 = new HashSet<>();
set2.add("s1");
set2.add("s2");
System.out.println(set1.addAll(set2)); //prints: true
System.out.println(set1); //prints: [s1, s2]
注意,在前面代码片段的最后一个示例中,set1.addAll(set2)方法返回true,尽管没有添加所有元素。要查看add()和addAll()方法返回false的情况,请看以下示例:
Set<String> set = new HashSet<>();
System.out.println(set.add("s1")); //prints: true
System.out.println(set.add("s1")); //prints: false
System.out.println(set); //prints: [s1]
Set<String> set1 = new HashSet<>();
set1.add("s1");
set1.add("s2");
Set<String> set2 = new HashSet<>();
set2.add("s1");
set2.add("s2");
System.out.println(set1.addAll(set2)); //prints: false
System.out.println(set1); //prints: [s1, s2]
ArrayList和HashSet类还有接受集合的构造器:
Collection<String> list1 = List.of("s1", "s1", "s2");
System.out.println(list1); //prints: [s1, s1, s2]
List<String> list2 = new ArrayList<>(list1);
System.out.println(list2); //prints: [s1, s1, s2]
Set<String> set = new HashSet<>(list1);
System.out.println(set); //prints: [s1, s2]
List<String> list3 = new ArrayList<>(set);
System.out.println(list3); //prints: [s1, s2]
现在,在我们了解了如何初始化集合之后,我们可以转向接口List和Set中的其他方法。
java.lang.Iterable接口
Collection接口扩展了java.lang.Iterable接口,这意味着那些直接或不直接实现Collection接口的类也实现了java.lang.Iterable接口。Iterable接口只有三种方式:
Iterator<T> iterator():返回实现接口java.util.Iterator的类的对象,允许集合在FOR语句中使用,例如:
Iterable<String> list = List.of("s1", "s2", "s3");
System.out.println(list); //prints: [s1, s2, s3]
for(String e: list){
System.out.print(e + " "); //prints: s1 s2 s3
}
default void forEach (Consumer<? super T> function):将提供的Consumer类型的函数应用于集合的每个元素,直到所有元素都处理完毕或函数抛出异常为止。什么是函数,我们将在第 13 章、“函数编程”中讨论;现在我们只提供一个例子:
Iterable<String> list = List.of("s1", "s2", "s3");
System.out.println(list); //prints: [s1, s2, s3]
list.forEach(e -> System.out.print(e + " ")); //prints: s1 s2 s3
default Spliterator<T> splititerator():返回实现java.util.Spliterator接口的类的对象,主要用于实现允许并行处理的方法,不在本书范围内
集合接口
如前所述,List和Set接口扩展了Collection接口,这意味着Collection接口的所有方法都被List和Set继承。这些方法如下:
boolean add(E e):尝试向集合添加元素boolean addAll(Collection<? extends E> c):尝试添加所提供集合中的所有元素boolean equals(Object o):将集合与提供的对象o进行比较;如果提供的对象不是集合,则返回false;否则将集合的组成与提供的集合的组成进行比较(作为对象o);如果是List,它还比较了元素的顺序;让我们用几个例子来说明:
Collection<String> list1 = List.of("s1", "s2", "s3");
System.out.println(list1); //prints: [s1, s2, s3]
Collection<String> list2 = List.of("s1", "s2", "s3");
System.out.println(list2); //prints: [s1, s2, s3]
System.out.println(list1.equals(list2)); //prints: true
Collection<String> list3 = List.of("s2", "s1", "s3");
System.out.println(list3); //prints: [s2, s1, s3]
System.out.println(list1.equals(list3)); //prints: false
Collection<String> set1 = Set.of("s1", "s2", "s3");
System.out.println(set1); //prints: [s2, s3, s1] or different order
Collection<String> set2 = Set.of("s2", "s1", "s3");
System.out.println(set2); //prints: [s2, s1, s3] or different order
System.out.println(set1.equals(set2)); //prints: true
Collection<String> set3 = Set.of("s4", "s1", "s3");
System.out.println(set3); //prints: [s4, s1, s3] or different order
System.out.println(set1.equals(set3)); //prints: false
-
int hashCode():返回集合的哈希值,用于集合是需要hashCode()方法实现的集合元素的情况 -
boolean isEmpty():如果集合中没有任何元素,则返回true -
int size():返回集合中元素的计数;当isEmpty()方法返回true时,此方法返回0 -
void clear():删除集合中的所有元素;调用此方法后,isEmpty()方法返回true,size()方法返回0 -
boolean contains(Object o):如果集合包含提供的对象o,则返回true;要使此方法正常工作,集合中的每个元素和提供的对象必须实现equals()方法,如果是Set,则需要实现hashCode()方法 -
boolean containsAll(Collection<?> c):如果集合包含所提供集合中的所有元素,则返回true,要使此方法正常工作,集合中的每个元素和所提供集合中的每个元素必须实现equals()方法,如果是Set,则应实现hashCode()方法 -
boolean remove(Object o):尝试从此集合中移除指定元素,如果存在则返回true;要使此方法正常工作,集合的每个元素和提供的对象必须实现方法equals(),如果是Set,则应实现hashCode()方法 -
boolean removeAll(Collection<?> c):尝试从集合中移除所提供集合的所有元素;与addAll()方法类似,如果至少移除了一个元素,则返回true,否则返回false,以便该方法正常工作,集合的每个元素和所提供集合的每个元素必须实现equals()方法,在Set的情况下,应该实现hashCode()方法 -
default boolean removeIf(Predicate<? super E> filter):尝试从集合中移除满足给定谓词的所有元素;我们将在第 13 章、“函数式编程”中描述的函数;如果至少移除了一个元素,则返回true -
boolean retainAll(Collection<?> c):试图在集合中只保留所提供集合中包含的元素;与addAll()方法类似,如果至少保留了一个元素,则返回true,否则返回false,以便该方法正常工作,集合的每个元素和所提供集合的每个元素必须实现equals()方法,在Set的情况下,应该实现hashCode()方法 -
Object[] toArray()、T[] toArray(T[] a):将集合转换成数组 -
default T[] toArray(IntFunction<T[]> generator):使用提供的函数将集合转换为数组;我们将在第 13 章、“函数式编程”中解释函数 -
default Stream<E> stream():返回Stream对象(我们在第 14 章、“Java 标准流”中谈到流) -
default Stream<E> parallelStream():返回一个可能并行的Stream对象(我们在第 14 章“Java 标准流”中讨论流)。
列表接口
List接口有几个不属于其父接口的其他方法:
- 静态工厂
of()方法“如何初始化列表和集合”小节中描述的方法 void add(int index, E element):在列表中提供的位置插入提供的元素static List<E> copyOf(Collection<E> coll):返回一个不可修改的List,其中包含给定Collection的元素并保留它们的顺序;下面是演示此方法功能的代码:
Collection<String> list = List.of("s1", "s2", "s3");
System.out.println(list); //prints: [s1, s2, s3]
List<String> list1 = List.copyOf(list);
//list1.add("s4"); //run-time error
//list1.set(1, "s5"); //run-time error
//list1.remove("s1"); //run-time error
Set<String> set = new HashSet<>();
System.out.println(set.add("s1"));
System.out.println(set); //prints: [s1]
Set<String> set1 = Set.copyOf(set);
//set1.add("s2"); //run-time error
//set1.remove("s1"); //run-time error
Set<String> set2 = Set.copyOf(list);
System.out.println(set2); //prints: [s1, s2, s3]
E get(int index):返回列表中指定位置的元素List<E> subList(int fromIndex, int toIndex):在fromIndex(包含)和toIndex(排除)之间提取子列表int indexOf(Object o):返回列表中指定元素的第一个索引(位置);列表中的第一个元素有一个索引(位置)0int lastIndexOf(Object o):返回列表中指定元素的最后一个索引(位置);列表中最后一个元素的索引(位置)等于list.size() - 1E remove(int index):删除列表中指定位置的元素;返回删除的元素E set(int index, E element):替换列表中指定位置的元素,返回被替换的元素default void replaceAll(UnaryOperator<E> operator):通过将提供的函数应用于每个元素来转换列表,UnaryOperator函数将在第 13 章、“函数式编程”中描述ListIterator<E> listIterator():返回允许向后遍历列表的ListIterator对象ListIterator<E> listIterator(int index):返回一个ListIterator对象,该对象允许向后遍历子列表(从提供的位置开始);例如:
List<String> list = List.of("s1", "s2", "s3");
ListIterator<String> li = list.listIterator();
while(li.hasNext()){
System.out.print(li.next() + " "); //prints: s1 s2 s3
}
while(li.hasPrevious()){
System.out.print(li.previous() + " "); //prints: s3 s2 s1
}
ListIterator<String> li1 = list.listIterator(1);
while(li1.hasNext()){
System.out.print(li1.next() + " "); //prints: s2 s3
}
ListIterator<String> li2 = list.listIterator(1);
while(li2.hasPrevious()){
System.out.print(li2.previous() + " "); //prints: s1
}
default void sort(Comparator<? super E> c):根据提供的Comparator生成的顺序对列表进行排序,例如:
List<String> list = new ArrayList<>();
list.add("S2");
list.add("s3");
list.add("s1");
System.out.println(list); //prints: [S2, s3, s1]
list.sort(String.CASE_INSENSITIVE_ORDER);
System.out.println(list); //prints: [s1, S2, s3]
//list.add(null); //causes NullPointerException
list.sort(Comparator.naturalOrder());
System.out.println(list); //prints: [S2, s1, s3]
list.sort(Comparator.reverseOrder());
System.out.println(list); //prints: [s3, s1, S2]
list.add(null);
list.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println(list); //prints: [null, S2, s1, s3]
list.sort(Comparator.nullsLast(Comparator.naturalOrder()));
System.out.println(list); //prints: [S2, s1, s3, null]
Comparator<String> comparator = (s1, s2) ->
s1 == null ? -1 : s1.compareTo(s2);
list.sort(comparator);
System.out.println(list); //prints: [null, S2, s1, s3]
对列表排序主要有两种方法:
- 使用
Comparable接口实现(称为自然顺序) - 使用
Comparator接口实现
Comparable接口只有compareTo()方法。在前面的例子中,我们已经在String类中的Comparable接口实现的基础上实现了Comparator接口。如您所见,此实现提供了与Comparator.nullsFirst(Comparator.naturalOrder())相同的排序顺序,这种实现方式称为函数式编程,我们将在第 13 章“函数式编程”中详细讨论。
*# 接口集
Set接口有以下不属于其父接口的方法:
- 静态
of()工厂方法,在“如何初始化列表和集合”小节中描述 static Set<E> copyOf(Collection<E> coll)方法:返回一个包含给定Collection元素的不可修改的Set,其工作方式与“接口列表”部分描述的static <E> List<E> copyOf(Collection<E> coll)方法相同
映射接口
Map接口有很多类似List、Set的方法:
int size()void clear()int hashCode()boolean isEmpty()boolean equals(Object o)default void forEach(BiConsumer<K,V> action)- 静态工厂方法:
of()、of(K k, V v)、of(K k1, V v1, K k2, V v2)等多种方法
然而,Map接口并不扩展Iterable、Collection或任何其他接口。通过键可以存储值*。*每个键都是唯一的,而同一个映射上不同的键可以存储几个相等的值。键和值的组合构成了一个Entry,是Map的内部接口。值和关键对象都必须实现equals()方法,关键对象也必须实现hashCode()方法
Map接口的很多方法与List和Set接口的签名和功能完全相同,这里不再赘述,我们只介绍Map的具体方法:
-
V get(Object key):按提供的键取值,如果没有该键返回null -
Set<K> keySet():从映射中检索所有键 -
Collection<V> values():从映射中检索所有值 -
boolean containsKey(Object key):如果映射中存在提供的键,则返回true -
boolean containsValue(Object value):如果提供的值存在于映射中,则返回true -
V put(K key, V value):将值及其键添加到映射中;返回使用相同键存储的上一个值 -
void putAll(Map<K,V> m):从提供的映射中复制所有键值对 -
default V putIfAbsent(K key, V value):存储所提供的值,如果映射尚未使用该键,则映射到所提供的键;将映射到所提供键的值返回到现有或新的值 -
V remove(Object key):从映射中删除键和值;如果没有键或值为null,则返回值或null -
default boolean remove(Object key, Object value):如果映射中存在键值对,则从映射中移除键值对 -
default V replace(K key, V value):如果提供的键当前映射到提供的值,则替换该值;如果被替换,则返回旧值;否则返回null -
default boolean replace(K key, V oldValue, V newValue):如果提供的键当前映射到oldValue,则用提供的newValue替换值oldValue;如果替换了oldValue,则返回true,否则返回false -
default void replaceAll(BiFunction<K,V,V> function):将提供的函数应用于映射中的每个键值对,并用结果替换,如果不可能,则抛出异常 -
Set<Map.Entry<K,V>> entrySet():返回一组所有键值对作为Map.Entry的对象 -
default V getOrDefault(Object key, V defaultValue):返回映射到提供键的值,如果映射没有提供键,则返回defaultValue -
static Map.Entry<K,V> entry(K key, V value):返回一个不可修改的Map.Entry对象,其中包含提供的key和value -
static Map<K,V> copy(Map<K,V> map):将提供的Map转换为不可修改的Map
以下Map方法对于本书的范围来说太复杂了,所以我们只是为了完整起见才提到它们。它们允许组合或计算多个值,并将它们聚集在Map中的单个现有值中,或创建一个新值:
default V merge(K key, V value, BiFunction<V,V,V> remappingFunction):如果提供的键值对存在且值不是null,则提供的函数用于计算新值;如果新计算的值是null,则删除键值对;如果提供的键值对不存在或值是null,则提供的非空值替换当前值;此方法可用于聚合多个值;例如,可用于连接字符串值:map.merge(key, value, String::concat);我们将在第 13 章、“函数式编程”中解释String::concat的含义default V compute(K key, BiFunction<K,V,V> remappingFunction):使用提供的函数计算新值default V computeIfAbsent(K key, Function<K,V> mappingFunction):仅当提供的键尚未与值关联或值为null时,才使用提供的函数计算新值default V computeIfPresent(K key, BiFunction<K,V,V> remappingFunction):仅当提供的键已经与值关联并且该值不是null时,才使用提供的函数计算新值
最后一组计算和合并方法很少使用。到目前为止最流行的是V put(K key, V value)和V get(Object key)方法,它们允许使用主要的Map功能来存储键值对并使用键检索值。Set<K> keySet()方法通常用于迭代映射的键值对,尽管entrySet()方法似乎是一种更自然的方法。举个例子:
Map<Integer, String> map = Map.of(1, "s1", 2, "s2", 3, "s3");
for(Integer key: map.keySet()){
System.out.print(key + ", " + map.get(key) + ", ");
//prints: 3, s3, 2, s2, 1, s1,
}
for(Map.Entry e: map.entrySet()){
System.out.print(e.getKey() + ", " + e.getValue() + ", ");
//prints: 2, s2, 3, s3, 1, s1,
}
前面代码示例中的第一个for循环使用更广泛的方法通过迭代键来访问映射的键对值。第二个for循环遍历条目集,我们认为这是一种更自然的方法。请注意,打印出来的值的顺序与我们在映射中的顺序不同。这是因为,自 Java9 以来,不可修改的集合(即of()工厂方法产生的集合)增加了Set元素顺序的随机化。它改变了不同代码执行之间元素的顺序。这样的设计是为了确保程序员不依赖于Set元素的特定顺序,而这对于一个集合是不保证的
不可修改的集合
请注意,of()工厂方法生成的集合在 Java9 中被称为不可变,在 Java10 中被称为不可修改。这是因为不可变意味着不能更改其中的任何内容,而实际上,如果集合元素是可修改的对象,则可以更改它们。例如,让我们构建一个Person1类的对象集合,如下所示:
class Person1 {
private int age;
private String name;
public Person1(int age, String name) {
this.age = age;
this.name = name == null ? "" : name;
}
public void setName(String name){ this.name = name; }
@Override
public String toString() {
return "Person{age=" + age +
", name=" + name + "}";
}
}
为简单起见,我们将创建一个只包含一个元素的列表,然后尝试修改该元素:
Person1 p1 = new Person1(45, "Bill");
List<Person1> list = List.of(p1);
//list.add(new Person1(22, "Bob")); //UnsupportedOperationException
System.out.println(list); //prints: [Person{age=45, name=Bill}]
p1.setName("Kelly");
System.out.println(list); //prints: [Person{age=45, name=Kelly}]
如您所见,尽管无法将元素添加到由of()工厂方法创建的列表中,但是如果对元素的引用存在于列表之外,则仍然可以修改其元素。
集合工具
有两个类具有处理集合的静态方法,它们非常流行并且非常有用:
java.util.Collectionsorg.apache.commons.collections4.CollectionUtils
这些方法是静态的,这意味着它们不依赖于对象状态,因此它们也被称为无状态方法或工具方法。
java.util.Collections类
Collections类中有许多方法可以管理集合、分析、排序和比较它们。其中有 70 多个,所以我们没有机会谈论所有这些问题。相反,我们将研究主流应用开发人员最常使用的:
static copy(List<T> dest, List<T> src):将src列表中的元素复制到dest列表中,并保留元素的顺序及其在列表中的位置;目的地dest列表大小必须等于或大于src列表大小,否则会引发运行时异常;此方法用法示例如下:
List<String> list1 = Arrays.asList("s1","s2");
List<String> list2 = Arrays.asList("s3", "s4", "s5");
Collections.copy(list2, list1);
System.out.println(list2); //prints: [s1, s2, s5]
static void sort(List<T> list):根据每个元素实现的compareTo(T)方法对列表进行排序(称为自然排序);只接受具有实现Comparable接口的元素的列表(需要实现compareTo(T)方法);在下面的示例中,我们使用List<String>因为类String机具Comparable:
//List<String> list = List.of("a", "X", "10", "20", "1", "2");
List<String> list = Arrays.asList("a", "X", "10", "20", "1", "2");
Collections.sort(list);
System.out.println(list); //prints: [1, 10, 2, 20, X, a]
请注意,我们不能使用List.of()方法创建列表,因为该列表是不可修改的,并且其顺序不能更改。另外,看看结果的顺序:数字排在第一位,然后是大写字母,然后是小写字母。这是因为String类中的compareTo()方法使用字符的代码点来建立顺序。下面是演示它的代码:
List<String> list = Arrays.asList("a", "X", "10", "20", "1", "2");
Collections.sort(list);
System.out.println(list); //prints: [1, 10, 2, 20, X, a]
list.forEach(s -> {
for(int i = 0; i < s.length(); i++){
System.out.print(" " + Character.codePointAt(s, i));
}
if(!s.equals("a")) {
System.out.print(","); //prints: 49, 49 48, 50, 50 48, 88, 97
}
});
如您所见,顺序是由组成字符串的字符的代码点的值定义的。
static void sort(List<T> list, Comparator<T> comparator):根据提供的Comparator对象对列表进行排序,不管列表元素是否实现了Comparable接口;例如,让我们对一个由Person类的对象组成的列表进行排序:
class Person {
private int age;
private String name;
public Person(int age, String name) {
this.age = age;
this.name = name == null ? "" : name;
}
public int getAge() { return this.age; }
public String getName() { return this.name; }
@Override
public String toString() {
return "Person{name=" + name + ", age=" + age + "}";
}
}
这里有一个Comparator类对Person对象列表进行排序:
class ComparePersons implements Comparator<Person> {
public int compare(Person p1, Person p2){
int result = p1.getName().compareTo(p2.getName());
if (result != 0) { return result; }
return p1.age - p2.getAge();
}
}
现在我们可以使用Person和ComparePersons类,如下所示:
List<Person> persons = Arrays.asList(new Person(23, "Jack"),
new Person(30, "Bob"), new Person(15, "Bob"));
Collections.sort(persons, new ComparePersons());
System.out.println(persons); //prints: [Person{name=Bob, age=15},
Person{name=Bob, age=30},
Person{name=Jack, age=23}]
正如我们已经提到的,Collections类中还有更多的工具,因此我们建议您至少查看一次它的文档并查看所有的功能。
ApacheCommons CollectionUtils类
ApacheCommons 项目中的org.apache.commons.collections4.CollectionUtils类包含静态无状态方法,这些方法是对java.util.Collections类方法的补充,它们有助于搜索、处理和比较 Java 集合。
要使用此类,您需要向 Mavenpom.xml配置文件添加以下依赖项:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.1</version>
</dependency>
这个类中有很多方法,随着时间的推移,可能会添加更多的方法。这些工具是在Collections方法之外创建的,因此它们更复杂、更细致,不适合本书的范围。为了让您了解CollectionUtils类中可用的方法,以下是根据功能分组的方法的简短说明:
- 从集合中检索元素的方法
- 向集合中添加元素或元素组的方法
- 将
Iterable元素合并到集合中的方法 - 带或不带条件移除或保留元素的方法
- 比较两个集合的方法
- 转换集合的方法
- 从集合中选择并过滤集合的方法
- 生成两个集合的并集、交集或差集的方法
- 创建不可变的空集合的方法
- 检查集合大小和空性的方法
- 反转数组的方法
最后一个方法可能属于处理数组的工具类。这就是我们现在要讨论的。
数组工具
有两个类具有处理集合的静态方法,它们非常流行并且非常有用:
java.util.Arraysorg.apache.commons.lang3.ArrayUtils
我们将简要回顾其中的每一项。
java.util.Arrays类
我们已经用过几次了。它是数组管理的主要工具类。这个工具类过去非常流行,因为有asList(T...a)方法。它是创建和初始化集合的最简洁的方法:
List<String> list = Arrays.asList("s0", "s1");
Set<String> set = new HashSet<>(Arrays.asList("s0", "s1");
它仍然是一种流行的创建可修改列表的方法。我们也使用它。但是,在引入了一个List.of()工厂方法之后,Arrays类的流行性大大下降。
不过,如果您需要管理数组,Arrays类可能会有很大帮助。它包含 160 多种方法。它们中的大多数都重载了不同的参数和数组类型。如果我们按方法名对它们进行分组,将有 21 个组。如果我们进一步按功能对它们进行分组,那么只有以下 10 组将涵盖所有的Arrays类功能:
asList():根据提供的数组或逗号分隔的参数列表创建ArrayList对象binarySearch():搜索一个数组或只搜索它的指定部分(按索引的范围)compare()、mismatch()、equals()和deepEquals():比较两个数组或它们的部分(根据索引的范围)copyOf()、copyOfRange():复制所有数组或只复制其中指定的(按索引范围)部分hashcode()、deepHashCode():根据提供的数组生成哈希码值toString()和deepToString():创建数组的String表示fill()、setAll()、parallelPrefix()、parallelSetAll():数组中每个元素的设定值(固定的或由提供的函数生成的)或由索引范围指定的值sort()和parallelSort():对数组中的元素进行排序或只对数组的一部分进行排序(由索引的范围指定)splititerator():返回Splititerator对象,对数组或数组的一部分进行并行处理(由索引的范围指定)stream():生成数组元素流或其中的一部分(由索引的范围指定);参见第 14 章、“Java 标准流”
所有这些方法都是有用的,但我们想提请您注意equals(a1, a2)方法和deepEquals(a1, a2)。它们对于数组比较特别有用,因为数组对象不能实现equals()自定义方法,而是使用Object类的实现(只比较引用)。equals(a1, a2)和deepEquals(a1, a2)方法不仅允许比较a1和a2引用,还可以使用equals()方法比较元素。以下是演示这些方法如何工作的代码示例:
String[] arr1 = {"s1", "s2"};
String[] arr2 = {"s1", "s2"};
System.out.println(arr1.equals(arr2)); //prints: false
System.out.println(Arrays.equals(arr1, arr2)); //prints: true
System.out.println(Arrays.deepEquals(arr1, arr2)); //prints: true
String[][] arr3 = {{"s1", "s2"}};
String[][] arr4 = {{"s1", "s2"}};
System.out.println(arr3.equals(arr4)); //prints: false
System.out.println(Arrays.equals(arr3, arr4)); //prints: false
System.out.println(Arrays.deepEquals(arr3, arr4)); //prints: true
如您所见,Arrays.deepEquals()每次比较两个相等的数组时,当一个数组的每个元素等于另一个数组在同一位置的元素时,返回true,而Arrays.equals()方法返回相同的结果,但只对一维数组。
ApacheCommons ArrayUtils类
org.apache.commons.lang3.ArrayUtils类是对java.util.Arrays类的补充,它向数组管理工具箱添加了新方法,并且在可能抛出NullPointerException的情况下能够处理null。要使用这个类,您需要向 Mavenpom.xml配置文件添加以下依赖项:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.8.1</version>
</dependency>
ArrayUtils类有大约 300 个重载方法,可以收集在以下 12 个组中:
add()、addAll()和insert():向数组添加元素clone():克隆数组,类似Arrays类的copyOf()方法和java.lang.System的arraycopy()方法getLength():当数组本身为null时,返回数组长度或0hashCode():计算数组的哈希值,包括嵌套数组contains()、indexOf()、lastIndexOf():搜索数组isSorted()、isEmpty、isNotEmpty():检查数组并处理nullisSameLength()和isSameType():比较数组nullToEmpty():将null数组转换为空数组remove()、removeAll()、removeElement()、removeElements()、removeAllOccurances():删除部分或全部元素reverse()、shift()、shuffle()、swap():改变数组元素的顺序subarray():根据索引的范围提取数组的一部分toMap()、toObject()、toPrimitive()、toString()、toStringArray():将数组转换为其他类型,并处理null值
对象工具
本节中描述的两个工具是:
java.util.Objectsorg.apache.commons.lang3.ObjectUtils
它们在类创建期间特别有用,因此我们将主要关注与此任务相关的方法。
java.util.Objects类
Objects类只有 17 个方法都是静态的。在将它们应用于Person类时,我们来看看其中的一些方法,假设这个类是集合的一个元素,这意味着它必须实现equals()和hashCode()方法:
class Person {
private int age;
private String name;
public Person(int age, String name) {
this.age = age;
this.name = name;
}
public int getAge(){ return this.age; }
public String getName(){ return this.name; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null) return false;
if(!(o instanceof Person)) return false;
Person person = (Person)o;
return age == person.getAge() &&
Objects.equals(name, person.getName());
}
@Override
public int hashCode(){
return Objects.hash(age, name);
}
}
注意,我们没有检查null的属性name,因为当任何参数为null时Object.equals()不会中断。它只是做比较对象的工作。如果其中只有一个是null,则返回false。如果两者都为空,则返回true。
使用Object.equals()是实现equals()方法的一种安全方法,但是如果需要比较可能是数组的对象,最好使用Objects.deepEquals()方法,因为它不仅像Object.equals()方法那样处理null,而且还比较所有数组元素的值,即使数组是多维的:
String[][] x1 = {{"a","b"},{"x","y"}};
String[][] x2 = {{"a","b"},{"x","y"}};
String[][] y = {{"a","b"},{"y","y"}};
System.out.println(Objects.equals(x1, x2)); //prints: false
System.out.println(Objects.equals(x1, y)); //prints: false
System.out.println(Objects.deepEquals(x1, x2)); //prints: true
System.out.println(Objects.deepEquals(x1, y)); //prints: false
Objects.hash()方法也处理空值。需要记住的一点是,equals()方法中比较的属性列表必须与作为参数传入Objects.hash()的属性列表相匹配。否则,两个相等的Person对象将具有不同的哈希值,这使得基于哈希的集合无法正常工作。
另一件值得注意的事情是,还有另一个与哈希相关的Objects.hashCode()方法,它只接受一个参数。但是它产生的值并不等于只有一个参数的Objects.hash()产生的值。例如:
System.out.println(Objects.hash(42) == Objects.hashCode(42));
//prints: false
System.out.println(Objects.hash("abc") == Objects.hashCode("abc"));
//prints: false
为避免此警告,请始终使用Objects.hash()。
另一个潜在的混淆表现在以下代码中:
System.out.println(Objects.hash(null)); //prints: 0
System.out.println(Objects.hashCode(null)); //prints: 0
System.out.println(Objects.hash(0)); //prints: 31
System.out.println(Objects.hashCode(0)); //prints: 0
如您所见,Objects.hashCode()方法为null和0生成相同的散列值,这对于一些基于散列值的算法来说是有问题的。
static <T> int compare (T a, T b, Comparator<T> c)是另一种流行的方法,它返回0(如果参数相等)或c.compare(a, b)的结果。它对于实现Comparable接口(为自定义对象排序建立自然顺序)非常有用。例如:
class Person implements Comparable<Person> {
private int age;
private String name;
public Person(int age, String name) {
this.age = age;
this.name = name;
}
public int getAge(){ return this.age; }
public String getName(){ return this.name; }
@Override
public int compareTo(Person p){
int result = Objects.compare(name, p.getName(),
Comparator.naturalOrder());
if (result != 0) {
return result;
}
return Objects.compare(age, p.getAge(),
Comparator.naturalOrder());
}
}
这样,您可以通过设置Comparator.reverseOrder()值或添加Comparator.nullFirst()或Comparator.nullLast()来轻松更改排序算法。
此外,我们在上一节中使用的Comparator实现可以通过使用Objects.compare()变得更加灵活:
class ComparePersons implements Comparator<Person> {
public int compare(Person p1, Person p2){
int result = Objects.compare(p1.getName(), p2.getName(),
Comparator.naturalOrder());
if (result != 0) {
return result;
}
return Objects.compare(p1.getAge(), p2.getAge(),
Comparator.naturalOrder());
}
}
最后,我们要讨论的Objects类的最后两个方法是生成对象的字符串表示的方法。当您需要对对象调用toString()方法,但不确定对象引用是否为null时,它们会很方便。例如:
List<String> list = Arrays.asList("s1", null);
for(String e: list){
//String s = e.toString(); //NullPointerException
}
在前面的例子中,我们知道每个元素的确切值。但是想象一下,列表作为参数传递到方法中。然后我们被迫写下如下内容:
void someMethod(List<String> list){
for(String e: list){
String s = e == null ? "null" : e.toString();
}
看来这没什么大不了的。但是在编写了十几次这样的代码之后,程序员自然会想到一种实用方法来完成所有这些,也就是说,当Objects类的以下两种方法有帮助时:
-
static String toString(Object o):当参数不是null时返回调用toString()的结果,当参数值为null时返回null -
static String toString(Object o, String nullDefault):当第一个参数不是null时,返回调用第一个参数toString()的结果;当第一个参数值是null时,返回第二个参数值nullDefault
下面的代码演示了这两种方法:
List<String> list = Arrays.asList("s1", null);
for(String e: list){
String s = Objects.toString(e);
System.out.print(s + " "); //prints: s1 null
}
for(String e: list){
String s = Objects.toString(e, "element was null");
System.out.print(s + " "); //prints: s1 element was null
}
在撰写本文时,Objects类有 17 种方法。我们建议您熟悉它们,以避免在已经存在相同工具的情况下编写自己的工具
ApacheCommons ObjectUtils类
上一节的最后一条语句适用于 ApacheCommons 库的org.apache.commons.lang3.ObjectUtils类,它补充了上一节中描述的java.util.Objects类的方法。本书的范围和分配的大小不允许对ObjectUtils类的所有方法进行详细的回顾,因此我们将按相关功能分组对它们进行简要的描述。要使用这个类,您需要在 Mavenpom.xml配置文件中添加以下依赖项:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.8.1</version>
</dependency>
ObjectUtils类的所有方法可分为七组:
-
对象克隆方法
-
比较两个对象的方法
-
比较两个对象是否相等的
notEqual()方法,其中一个或两个对象可以是null -
几个
identityToString()方法生成所提供对象的String表示,就像由toString()生成一样,这是Object基类的默认方法,并且可选地将其附加到另一个对象 -
分析
null的对象数组的allNotNull()和anyNotNull()方法 -
firstNonNull()和defaultIfNull()方法,它们分析一个对象数组并返回第一个非null对象或默认值 -
max()、min()、median()和mode()方法,它们分析一个对象数组并返回其中一个对应于方法名称的对象
java.time包
java.time包及其子包中有许多类。它们是作为处理日期和时间的其他(旧的包)的替代品引入的。新类是线程安全的(因此,更适合多线程处理),同样重要的是,它们的设计更加一致,更易于理解。此外,新的实现在日期和时间格式上遵循了国际标准组织(ISO),但也允许使用任何其他自定义格式。
我们将描述主要的五个类,并演示如何使用它们:
java.time.LocalDatejava.time.LocalTimejava.time.LocalDateTimejava.time.Periodjava.time.Duration
所有这些,以及java.time包的其他类,以及它的子包都有丰富的功能,涵盖了所有的实际案例。但我们不打算讨论所有这些问题;我们将只介绍基本知识和最流行的用例。
LocalDate类
LocalDate类不带时间。它表示 ISO 8601 格式的日期(YYYY-MM-DD):
System.out.println(LocalDate.now()); //prints: 2019-03-04
这是在这个地方写这篇文章时的当前日期。这个值是从计算机时钟中提取的。同样,您可以使用静态now(ZoneId zone)方法获取任何其他时区的当前日期。ZoneId对象可以使用静态ZoneId.of(String zoneId)方法构造,其中String zoneId是ZonId.getAvailableZoneIds()方法返回的任何字符串值:
Set<String> zoneIds = ZoneId.getAvailableZoneIds();
for(String zoneId: zoneIds){
System.out.println(zoneId);
}
前面的代码打印了近 600 个时区 ID。以下是其中一些:
Asia/Aden
Etc/GMT+9
Africa/Nairobi
America/Marigot
Pacific/Honolulu
Australia/Hobart
Europe/London
America/Indiana/Petersburg
Asia/Yerevan
Europe/Brussels
GMT
Chile/Continental
Pacific/Yap
CET
Etc/GMT-1
Canada/Yukon
Atlantic/St_Helena
Libya
US/Pacific-New
Cuba
Israel
GB-Eire
GB
Mexico/General
Universal
Zulu
Iran
Navajo
Egypt
Etc/UTC
SystemV/AST4ADT
Asia/Tokyo
让我们尝试使用"Asia/Tokyo",例如:
ZoneId zoneId = ZoneId.of("Asia/Tokyo");
System.out.println(LocalDate.now(zoneId)); //prints: 2019-03-05
LocalDate的对象可以表示过去的任何日期,也可以表示将来的任何日期,方法如下:
-
LocalDate parse(CharSequence text):从 ISO 8601 格式的字符串构造对象(YYYY-MM-DD) -
LocalDate parse(CharSequence text, DateTimeFormatter formatter):从字符串构造一个对象,格式由DateTimeFormatter对象指定,该对象具有丰富的模式系统和许多预定义的格式;下面是其中的一些: -
LocalDate of(int year, int month, int dayOfMonth):从年、月、日构造对象 -
LocalDate of(int year, Month month, int dayOfMonth):从年、月(枚举常量)和日构造对象 -
LocalDate ofYearDay(int year, int dayOfYear):从一年和一年中的某一天构造一个对象窗体
下面的代码演示了前面列出的方法:
LocalDate lc1 = LocalDate.parse("2020-02-23");
System.out.println(lc1); //prints: 2020-02-23
LocalDate lc2 =
LocalDate.parse("20200223", DateTimeFormatter.BASIC_ISO_DATE);
System.out.println(lc2); //prints: 2020-02-23
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate lc3 = LocalDate.parse("23/02/2020", formatter);
System.out.println(lc3); //prints: 2020-02-23
LocalDate lc4 = LocalDate.of(2020, 2, 23);
System.out.println(lc4); //prints: 2020-02-23
LocalDate lc5 = LocalDate.of(2020, Month.FEBRUARY, 23);
System.out.println(lc5); //prints: 2020-02-23
LocalDate lc6 = LocalDate.ofYearDay(2020, 54);
System.out.println(lc6); //prints: 2020-02-23
LocalDate对象可以提供各种值:
LocalDate lc = LocalDate.parse("2020-02-23");
System.out.println(lc); //prints: 2020-02-23
System.out.println(lc.getYear()); //prints: 2020
System.out.println(lc.getMonth()); //prints: FEBRUARY
System.out.println(lc.getMonthValue()); //prints: 2
System.out.println(lc.getDayOfMonth()); //prints: 23
System.out.println(lc.getDayOfWeek()); //prints: SUNDAY
System.out.println(lc.isLeapYear()); //prints: true
System.out.println(lc.lengthOfMonth()); //prints: 29
System.out.println(lc.lengthOfYear()); //prints: 366
LocalDate对象可以修改如下:
LocalDate lc = LocalDate.parse("2020-02-23");
System.out.println(lc.withYear(2021)); //prints: 2021-02-23
System.out.println(lc.withMonth(5)); //prints: 2020-05-23
System.out.println(lc.withDayOfMonth(5)); //prints: 2020-02-05
System.out.println(lc.withDayOfYear(53)); //prints: 2020-02-22
System.out.println(lc.plusDays(10)); //prints: 2020-03-04
System.out.println(lc.plusMonths(2)); //prints: 2020-04-23
System.out.println(lc.plusYears(2)); //prints: 2022-02-23
System.out.println(lc.minusDays(10)); //prints: 2020-02-13
System.out.println(lc.minusMonths(2)); //prints: 2019-12-23
System.out.println(lc.minusYears(2)); //prints: 2018-02-23
LocalDate对象可以比较如下:
LocalDate lc1 = LocalDate.parse("2020-02-23");
LocalDate lc2 = LocalDate.parse("2020-02-22");
System.out.println(lc1.isAfter(lc2)); //prints: true
System.out.println(lc1.isBefore(lc2)); //prints: false
在LocalDate类中还有许多其他有用的方法。如果您要处理日期,我们建议您阅读这个类的 API 和其他类的java.time包及其子包。
LocalTime类
LocalTime类包含没有日期的时间。它的方法与LocalDate类的方法类似,下面介绍如何创建LocalTime类的对象:
System.out.println(LocalTime.now()); //prints: 21:15:46.360904
ZoneId zoneId = ZoneId.of("Asia/Tokyo");
System.out.println(LocalTime.now(zoneId)); //prints: 12:15:46.364378
LocalTime lt1 = LocalTime.parse("20:23:12");
System.out.println(lt1); //prints: 20:23:12
LocalTime lt2 = LocalTime.of(20, 23, 12);
System.out.println(lt2); //prints: 20:23:12
时间值的每个分量可以从一个LocalTime对象中提取,如下所示:
LocalTime lt2 = LocalTime.of(20, 23, 12);
System.out.println(lt2); //prints: 20:23:12
System.out.println(lt2.getHour()); //prints: 20
System.out.println(lt2.getMinute()); //prints: 23
System.out.println(lt2.getSecond()); //prints: 12
System.out.println(lt2.getNano()); //prints: 0
LocalTime类的对象可以修改:
LocalTime lt2 = LocalTime.of(20, 23, 12);
System.out.println(lt2.withHour(3)); //prints: 03:23:12
System.out.println(lt2.withMinute(10)); //prints: 20:10:12
System.out.println(lt2.withSecond(15)); //prints: 20:23:15
System.out.println(lt2.withNano(300)); //prints: 20:23:12.000000300
System.out.println(lt2.plusHours(10)); //prints: 06:23:12
System.out.println(lt2.plusMinutes(2)); //prints: 20:25:12
System.out.println(lt2.plusSeconds(2)); //prints: 20:23:14
System.out.println(lt2.plusNanos(200)); //prints: 20:23:12.000000200
System.out.println(lt2.minusHours(10)); //prints: 10:23:12
System.out.println(lt2.minusMinutes(2)); //prints: 20:21:12
System.out.println(lt2.minusSeconds(2)); //prints: 20:23:10
System.out.println(lt2.minusNanos(200)); //prints: 20:23:11.999999800
LocalTime类的两个对象也可以比较:
LocalTime lt2 = LocalTime.of(20, 23, 12);
LocalTime lt4 = LocalTime.parse("20:25:12");
System.out.println(lt2.isAfter(lt4)); //prints: false
System.out.println(lt2.isBefore(lt4)); //prints: true
LocalTime类中还有很多其他有用的方法,如果您需要处理日期,我们建议您阅读这个类的 API 以及java.time包及其子包的其他类。
LocalDateTime
LocalDateTime类包含日期和时间,并且具有LocalDate和LocalTime类所具有的所有方法,因此我们不在这里重复它们。我们只展示如何创建LocalDateTime类的对象:
System.out.println(LocalDateTime.now());
//prints: 2019-03-04T21:59:00.142804
ZoneId zoneId = ZoneId.of("Asia/Tokyo");
System.out.println(LocalDateTime.now(zoneId));
//prints: 2019-03-05T12:59:00.146038
LocalDateTime ldt1 = LocalDateTime.parse("2020-02-23T20:23:12");
System.out.println(ldt1); //prints: 2020-02-23T20:23:12
DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss");
LocalDateTime ldt2 =
LocalDateTime.parse("23/02/2020 20:23:12", formatter);
System.out.println(ldt2); //prints: 2020-02-23T20:23:12
LocalDateTime ldt3 = LocalDateTime.of(2020, 2, 23, 20, 23, 12);
System.out.println(ldt3); //prints: 2020-02-23T20:23:12
LocalDateTime ldt4 =
LocalDateTime.of(2020, Month.FEBRUARY, 23, 20, 23, 12);
System.out.println(ldt4); //prints: 2020-02-23T20:23:12
LocalDate ld = LocalDate.of(2020, 2, 23);
LocalTime lt = LocalTime.of(20, 23, 12);
LocalDateTime ldt5 = LocalDateTime.of(ld, lt);
System.out.println(ldt5); //prints: 2020-02-23T20:23:12
LocalDateTime类中还有很多其他有用的方法,如果您需要处理日期,我们建议您阅读这个类的 API 以及java.time包及其子包的其他类。
Period和Duration类
java.time.Period和java.time.Duration类被设计为包含一定的时间量:
Period对象包含以年、月、日为单位的时间量Duration对象包含以小时、分钟、秒和纳秒为单位的时间量
下面的代码演示了它们在LocalDateTime类中的创建和使用,但是LocalDate类(对于Period)和LocalTime(对于Duration中存在相同的方法:
LocalDateTime ldt1 = LocalDateTime.parse("2020-02-23T20:23:12");
LocalDateTime ldt2 = ldt1.plus(Period.ofYears(2));
System.out.println(ldt2); //prints: 2022-02-23T20:23:12
以下方法的工作方式相同:
LocalDateTime ldt = LocalDateTime.parse("2020-02-23T20:23:12");
ldt.minus(Period.ofYears(2));
ldt.plus(Period.ofMonths(2));
ldt.minus(Period.ofMonths(2));
ldt.plus(Period.ofWeeks(2));
ldt.minus(Period.ofWeeks(2));
ldt.plus(Period.ofDays(2));
ldt.minus(Period.ofDays(2));
ldt.plus(Duration.ofHours(2));
ldt.minus(Duration.ofHours(2));
ldt.plus(Duration.ofMinutes(2));
ldt.minus(Duration.ofMinutes(2));
ldt.plus(Duration.ofMillis(2));
ldt.minus(Duration.ofMillis(2));
下面的代码演示了创建和使用Period对象的一些其他方法:
LocalDate ld1 = LocalDate.parse("2020-02-23");
LocalDate ld2 = LocalDate.parse("2020-03-25");
Period period = Period.between(ld1, ld2);
System.out.println(period.getDays()); //prints: 2
System.out.println(period.getMonths()); //prints: 1
System.out.println(period.getYears()); //prints: 0
System.out.println(period.toTotalMonths()); //prints: 1
period = Period.between(ld2, ld1);
System.out.println(period.getDays()); //prints: -2
Duration的对象可以类似地创建和使用:
LocalTime lt1 = LocalTime.parse("10:23:12");
LocalTime lt2 = LocalTime.parse("20:23:14");
Duration duration = Duration.between(lt1, lt2);
System.out.println(duration.toDays()); //prints: 0
System.out.println(duration.toHours()); //prints: 10
System.out.println(duration.toMinutes()); //prints: 600
System.out.println(duration.toSeconds()); //prints: 36002
System.out.println(duration.getSeconds()); //prints: 36002
System.out.println(duration.toNanos()); //prints: 36002000000000
System.out.println(duration.getNano()); //prints: 0
在Period和Duration类中还有很多其他有用的方法,如果您需要处理日期,我们建议您阅读这个类和java.time包及其子包的其他类的 API。
总结
本章向读者介绍了 Java 集合框架及其三个主要接口:List、Set和Map。讨论了每个接口,并用其中一个实现类演示了其方法。对泛型也进行了解释和演示。必须实现equals()和hashCode()方法,以便 Java 集合能够正确处理对象。
工具类Collections和CollectionUtils有许多有用的集合处理方法,并在示例中介绍了它们,以及Arrays、ArrayUtils、Objects和ObjectUtils。
java.time包的类的方法允许管理时间/日期值,这在特定的实际代码片段中得到了演示。
在下一章中,我们将概述 Java 类库和一些外部库,包括那些支持测试的库。具体来说,我们将探讨org.junit、org.mockito、org.apache.log4j、org.slf4j、org.apache.commons包及其子包。
测验
-
什么是 Java 集合框架?选择所有适用的选项:
- 框架集合
java.util包的类和接口- 接口
List、Set和Map - 实现集合数据结构的类和接口
-
集合中的泛型是什么?选择所有适用的选项:
-
收集
of()工厂方法的局限性是什么?选择所有适用的选项:- 不允许
null元素 - 不允许向初始化的集合添加元素
- 不允许删除初始化集合中的元素
- 不允许修改初始化集合的元素
- 不允许
-
java.lang.Iterable接口的实现允许什么?选择所有适用的选项:- 允许逐个访问集合的元素
- 允许在
FOR语句中使用集合 - 允许在
WHILE语句中使用集合 - 允许在
DO...WHILE语句中使用集合
-
接口
java.util.Collection的实现允许什么?选择所有适用的选项:- 将另一个集合的元素添加到集合中
- 从集合中删除另一个集合的元素
- 只修改属于另一个集合的元素
- 从集合中删除不属于其他集合的对象
-
选择
List接口方法的所有正确语句: -
选择
Set接口方法的所有正确语句: -
选择
Map接口方法的所有正确语句:int size():返回映射中存储的键值对的计数;当isEmpty()方法返回true时,该方法返回0V remove(Object key):从映射中删除键和值;返回值,如果没有键或值为null,则返回nulldefault boolean remove(Object key, Object value):如果映射中存在键值对,则删除键值对;如果删除键值对,则返回truedefault boolean replace(K key, V oldValue, V newValue):如果提供的键当前映射到oldValue,则用提供的newValue替换值oldValue;如果替换了oldValue,则返回true,否则返回false
-
选择关于
Collections类的static void sort(List<T> list, Comparator<T> comparator)方法的所有正确语句:- 如果列表元素实现了
Comparable接口,则对列表的自然顺序进行排序 - 它根据提供的
Comparator对象对列表的顺序进行排序 - 如果列表元素实现了
Comparable接口,则它会根据提供的Comparator对象对列表的顺序进行排序 - 它根据提供的
Comparator对象对列表的顺序进行排序,无论列表元素是否实现Comparable接口
- 如果列表元素实现了
-
以下代码执行的结果是什么?
List<String> list1 = Arrays.asList("s1","s2", "s3");
List<String> list2 = Arrays.asList("s3", "s4");
Collections.copy(list1, list2);
System.out.println(list1);
-
CollectionUtils类方法的功能是什么?选择所有适用的选项:
1. 匹配Collections类方法的功能,但处理null
2. 补充了Collections类方法的功能
3. 以Collections类方法所不具备的方式搜索、处理和比较 Java 集合
4. 复制Collections类方法的功能 -
以下代码执行的结果是什么?
Integer[][] ar1 = {{42}};
Integer[][] ar2 = {{42}};
System.out.print(Arrays.equals(ar1, ar2) + " ");
System.out.println(Arrays.deepEquals(arr3, arr4));
- 以下代码执行的结果是什么?
String[] arr1 = { "s1", "s2" };
String[] arr2 = { null };
String[] arr3 = null;
System.out.print(ArrayUtils.getLength(arr1) + " ");
System.out.print(ArrayUtils.getLength(arr2) + " ");
System.out.print(ArrayUtils.getLength(arr3) + " ");
System.out.print(ArrayUtils.isEmpty(arr2) + " ");
System.out.print(ArrayUtils.isEmpty(arr3));
- 以下代码执行的结果是什么?
String str1 = "";
String str2 = null;
System.out.print((Objects.hash(str1) ==
Objects.hashCode(str2)) + " ");
System.out.print(Objects.hash(str1) + " ");
System.out.println(Objects.hashCode(str2) + " ");
- 以下代码执行的结果是什么?
String[] arr = {"c", "x", "a"};
System.out.print(ObjectUtils.min(arr) + " ");
System.out.print(ObjectUtils.median(arr) + " ");
System.out.println(ObjectUtils.max(arr));
- 以下代码执行的结果是什么?
LocalDate lc = LocalDate.parse("1900-02-23");
System.out.println(lc.withYear(21));
- 以下代码执行的结果是什么?
LocalTime lt2 = LocalTime.of(20, 23, 12);
System.out.println(lt2.withNano(300));
- 以下代码执行的结果是什么?
LocalDate ld = LocalDate.of(2020, 2, 23);
LocalTime lt = LocalTime.of(20, 23, 12);
LocalDateTime ldt = LocalDateTime.of(ld, lt);
System.out.println(ldt);
- 以下代码执行的结果是什么?
LocalDateTime ldt = LocalDateTime.parse("2020-02-23T20:23:12");
System.out.print(ldt.minus(Period.ofYears(2)) + " ");
System.out.print(ldt.plus(Duration.ofMinutes(12)) + " ");
System.out.println(ldt);
```*
# 七、Java 标准和外部库
不使用标准库(也称为 **Java 类库**(**JCL**)就不可能编写 Java 程序。这就是为什么对这类库的深入了解对于成功编程来说就像对语言本身的了解一样重要。
还有*非标准*库,称为**外部库**或**第三方库**,因为它们不包括在 **Java 开发工具包**(**JDK**)发行版中。它们中的一些早已成为任何程序员工具包的永久固定装置。
要跟踪这些库中可用的所有功能并不容易。这是因为一个**集成开发环境**(**IDE**)给了您一个关于语言可能性的提示,但是它不能建议尚未导入的包的功能。唯一自动导入的包是`java.lang`。
本章的目的是向读者概述最流行的 JCL 包和外部库的功能
本章讨论的主题如下:
* Java 类库(JCL)
* `java.lang`
* `java.util`
* `java.time`
* `java.io`和`java.nio`
* `java.sql`和`javax.sql`
* `java.net`
* `java.lang.math`和`java.math`
* `java.awt`、`javax.swing`、``javafx``
* 外部库
* `org.junit`
* `org.mockito`
* `org.apache.log4j`和`org.slf4j`
* `org.apache.commons`
# Java 类库
JCL 是实现该语言的包的集合。更简单地说,它是 JDK 中包含并准备好使用的`.class`文件的集合。一旦安装了 Java,就可以将它们作为安装的一部分,并可以开始使用 JCL 类作为构建块来构建应用代码,这些构建块负责许多底层管道。JCL 的丰富性和易用性极大地促进了 Java 的普及。
为了使用 JCL 包,可以导入它,而无需向`pom.xml`文件添加新的依赖项。这就是标准库和外部库的区别;如果您需要在 Maven`pom.xml`配置文件中添加一个库(通常是一个`.jar`文件)作为依赖项,那么这个库就是一个外部库。否则,它就是一个标准库或 JCL
一些 JCL 包名以`java`开头。传统上,它们被称为**核心 Java 包**,而那些以`javax`开头的包则被称为“扩展”。之所以这样做,可能是因为这些扩展被认为是可选的,甚至可能独立于 JDK 发布。也有人试图推动前扩展库成为一个核心包。但这将需要将包名从`java`更改为`javax`,这将打破使用`javax`包的现有应用。因此,这个想法被抛弃了,所以核心和扩展之间的区别逐渐消失。
这就是为什么,如果你在 Oracle 官方网站上查看 Java API,你会发现不仅有`java`和`javax`包被列为标准,还有`jdk`、`com.sun`、`org.xml`以及其他一些包。这些额外的包主要由工具或其他专用应用使用。在我们的书中,我们将主要集中在主流 Java 编程上,只讨论`java`和`javax`包。
# `java.lang`
这个包非常重要,使用它不需要导入。JVM 作者决定自动导入它。它包含最常用的 JCL 类:
* `Object`类:其他 Java 类的基类
* `Class`类:在运行时携带每个加载类的元数据
* `String`、`StringBuffer`和`StringBuilder`类:支持类型为`String`的操作
* 所有原始类型的包装类:`Byte`、`Boolean`、`Short`、`Character`、`Integer`、`Long`、`Float`、`Double`
* `Number`类:前面列出的除`Boolean`之外的所有数值原始类型的包装类的基类
* `System`类:提供对重要系统操作和标准输入输出的访问(在本书的每个代码示例中,我们都使用了`System.out`对象)
* `Runtime`类:提供对执行环境的访问
* `Thread`和`Runnable`接口:创建 Java 线程的基础
* `Iterable`接口:由迭代语句使用
* `Math`类:提供基本数值运算的方法
* `Throwable`类:所有异常的基类
* `Error`类:一个异常类,它的所有子类都用来传递应用不应该捕捉到的系统错误
* `Exception`类:该类及其直接子类表示选中的异常
* `RuntimeException`类:这个类及其子类表示非受检异常,也称为运行时异常
* `ClassLoader`类:读取`.class`文件并将其放入(装入)内存;也可以用来构建定制的类装入器
* `Process`和`ProcessBuilder`类:允许创建其他 JVM 进程
* 许多其他有用的类和接口
# `java.util`
`java.util`包的大部分内容专门用于支持 Java 集合:
* `Collection`接口:集合的许多其他接口的基础接口,它声明了管理集合元素所需的所有基本方法:`size()`、`add()`、`remove()`、`contains()`、`stream()`等;它还扩展了`java.lang.Iterable`接口,继承了`iterator()`、`forEach()`等方法,这意味着`Collection`接口的任何实现或其任何子接口`List`、`Set`、`Queue`、`Deque`等也可以用于迭代语句中:`ArrayList`、`LinkedList`、`HashSet`、`AbstractQueue`、`ArrayDeque`等
* `Map`接口和实现它的类:`HashMap`、`TreeMap`等
* `Collections`类:提供许多静态方法来分析、操作和转换集合
* 许多其他集合接口、类和相关工具
我们在第 6 章、“数据结构、泛型和流行工具”中讨论了 Java 集合,并看到了它们的用法示例。
`java.util`包还包括几个其他有用的类:
* `Objects`:提供了各种与对象相关的实用方法,其中一些我们已经在第 6 章、“数据结构、泛型和流行工具”中进行了概述
* `Arrays`:包含 160 种静态数组操作方法,其中一些方法我们在第 6 章、“数据结构、泛型和流行工具”中进行了概述
* `Formatter`:允许格式化任何原始类型`String`、`Date`和其他类型;我们在第 6 章、“数据结构、泛型和流行工具”中演示了它的用法示例
* `Optional`、`OptionalInt`、`OptionalLong`和`OptionalDouble`:这些类通过包装实际值来避免`NullPointerException`,实际值可以是`null`,也可以不是`null`
* `Properties`:帮助读取和创建用于应用配置和类似目的的键值对
* `Random`:通过生成伪随机数流来补充`java.lang.Math.random()`方法
* `StringTokeneizer`:将`String`对象分解为由指定分隔符分隔的标记
* `StringJoiner`:构造一个字符序列,由指定的分隔符分隔,并可选地由指定的前缀和后缀包围
* 许多其他有用的工具类,包括支持国际化和 Base64 编码和解码的类
# `java.time`
`java.time`包包含用于管理日期、时间、时段和持续时间的类。包装包括以下内容:
* `Month`枚举
* `DayOfWeek`枚举
* `Clock`使用时区返回当前时刻、日期和时间的类
* `Duration`和`Period`类表示并比较不同时间单位中的时间量
* `LocalDate`、`LocalTime`和`LocalDateTime`类表示没有时区的日期和时间
* `ZonedDateTime`类表示带时区的日期时间
* `ZoneId`类标识时区,如`America/Chicago`
* `java.time.format.DateTimeFormatter`类允许按照**国际标准组织**(**ISO**)格式,如`YYYY-MM-DD`等格式显示日期和时间
* 其他一些支持日期和时间操作的类
我们在第 6 章、“数据结构、泛型和流行工具”中讨论了大多数此类。
# `java.io`以及`java.nio`
`java.io`和`java.nio`包包含支持使用流、序列化和文件系统读写数据的类和接口。这两种包装的区别如下:
* `java.io`包类允许在没有缓存的情况下读取/写入数据(我们在第 5 章、“字符串、输入/输出和文件”中讨论过),而`java.nio`包的类创建了一个缓冲区,允许在填充的缓冲区中来回移动
* `java.io`包类阻塞流直到所有数据被读写,而`java.nio`包的类以非阻塞方式实现(我们将在第 15 章、“反应式编程”中讨论非阻塞方式)
# `java.sql`以及`javax.sql`
这两个包组成了一个 **Java 数据库连接**(**JDBC**)API,它允许访问和处理存储在数据源(通常是关系数据库)中的数据。`javax.sql`包通过提供以下支持来补充`java.sql`包:
* `DataSource`接口作为`DriverManager`类的替代
* 连接和语句池
* 分布式事务
* 行集
我们将讨论这些包,并在第 10 章“管理数据库中的数据”中看到代码示例。
# `java.net`
`java.net`包包含支持以下两个级别的应用联网的类:
* **底层网络**,基于:
* IP 地址
* 套接字是基本的双向数据通信机制
* 各种网络接口
* **高层网络**,基于:
* **通用资源标识符**(**URI**)
* **通用资源定位器**(**URL**)
* URL 指向的资源的连接
我们将讨论这个包,并在第 11 章、“网络编程”中看到代码示例。
# `java.lang.math`以及`java.math`
`java.lang.math`包包含执行基本数值运算的方法,例如计算两个数值的最小值和最大值、绝对值、初等指数、对数、平方根、三角函数以及许多其他数学运算。
`java.math`包通过允许使用`BigDecimal`和`BigInteger`类处理更大的数字,补充了`java.lang`包的 Java 基本类型和包装类。
# `Java.awt`,`javax.swing`,和 JavaFX
第一个支持为桌面应用构建**图形用户界面**(**GUI**)的 Java 库是`java.awt`包中的**抽象窗口工具包**(**AWT**)。它为执行平台的本机系统提供了一个接口,允许创建和管理窗口、布局和事件。它还具有基本的 GUI 小部件(如文本字段、按钮和菜单),提供对系统托盘的访问,并允许启动 Web 浏览器和通过 Java 代码向客户端发送电子邮件。它对本机代码的高度依赖使得基于 AWT 的 GUI 在不同的平台上看起来不同。
1997 年,Sun 微系统公司和 Netscape 通信公司推出了 Java **基础类**,后来被称为 **Swing**,并将它们放在`javax.swing`包中。使用 Swing 构建的 GUI 组件能够模拟一些本机平台的外观,但也允许您插入不依赖于它运行的平台的外观。它通过添加选项卡面板、滚动窗格、表格和列表扩展了 GUI 可以拥有的小部件列表。Swing 组件被称为轻量级组件,因为它们不依赖于本机代码,并且完全用 Java 实现。
2007 年,Sun 微系统公司宣布创建 JavaFX,JavaFX 最终成为一个软件平台,用于在许多不同的设备上创建和交付桌面应用。它旨在取代 Swing 作为 JavaSE 的标准 GUI 库。JavaFX 框架位于以`javafx`开头的包中,支持所有主要的桌面操作系统(DOS)和多个移动操作系统,包括 Symbian 操作系统、Windows 移动操作系统和一些专有的实时操作系统。
JavaFX 基于**层叠样式表**(**CSS**),将平滑动画、Web 视图、音频和视频播放以及样式的支持添加到 GUI 开发人员的库中。但是,Swing 有更多的组件和第三方库,因此使用 JavaFX 可能需要创建很久以前在 Swing 中实现的自定义组件和管道。这就是为什么,尽管 JavaFX 被推荐为桌面 GUI 实现的首选,但根据 [Oracle 网站上的官方回应](http://www.oracle.com/technetwork/java/javafx/overview/faq-1446554.html#6),Swing 在可预见的未来仍将是 Java 的一部分。所以,可以继续使用 Swing,但如果可能,最好切换到 JavaFX。
我们将讨论 JavaFX,并在第 12 章、“Java GUI 编程”中看到代码示例。
# 外部库
最常用的第三方非 JCL 库的不同列表包括 20 到 100 个库。在本节中,我们将讨论这些列表中的大多数。所有这些都是开源项目。
# `org.junit`
`org.junit`包是开源测试框架 JUnit 的根包。它可以作为以下`pom.xml`依赖项添加到项目中:
```java
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
前面的dependency标记中的scope值告诉 Maven 只有在测试代码要运行时才包含库.jar文件,而不是包含在应用的生产.jar文件中。有了依赖关系,现在就可以创建测试了。您可以自己编写代码,也可以让 IDE 使用以下步骤为您编写代码:
-
右键单击要测试的类名
-
选择“转到”
-
选择“测试”
-
单击“创建新测试”
-
单击要测试的类的方法的复选框
-
使用
@Test注解为生成的测试方法编写代码 -
如有必要,添加带有
@Before和@After注解的方法
假设我们有以下类:
public class SomeClass {
public int multiplyByTwo(int i){
return i * 2;
}
}
如果您遵循前面列出的步骤,那么在test源代码树下将创建以下测试类:
import org.junit.Test;
public class SomeClassTest {
@Test
public void multiplyByTwo() {
}
}
现在您可以实现如下的void multiplyByTwo()方法:
@Test
public void multiplyByTwo() {
SomeClass someClass = new SomeClass();
int result = someClass.multiplyByTwo(2);
Assert.assertEquals(4, result);
}
一个单元是一段可以测试的最小代码,因此它的名字。最佳测试实践将方法视为最小的可测试单元。这就是为什么单元测试通常测试方法。
org.mockito
单元测试经常面临的问题之一是需要测试使用第三方库、数据源或其他类的方法的方法。在测试时,您希望控制所有的输入,以便可以预测测试代码的预期结果。在这一点上,模拟或模拟被测试代码与之交互的对象的行为的技术就派上了用场。
一个开源框架 Mockito(org.mockito根包名)允许完成模拟对象的创建。使用它非常简单和直接。这里有一个简单的例子。假设我们需要测试另一个SomeClass方法:
public class SomeClass {
public int multiplyByTwoTheValueFromSomeOtherClass(SomeOtherClass
someOtherClass){
return someOtherClass.getValue() * 2;
}
}
为了测试这个方法,我们需要确保getValue()方法返回一个特定的值,所以我们要模拟这个方法。为此,请执行以下步骤:
- 向 Maven
pom.xml配置文件添加依赖项:
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>2.23.4</version>
<scope>test</scope>
</dependency>
- 为需要模拟的类调用
Mockito.mock()方法:
SomeOtherClass mo = Mockito.mock(SomeOtherClass.class);
- 设置需要从方法返回的值:
Mockito.when(mo.getValue()).thenReturn(5);
- 现在,您可以将模拟对象作为参数传递到正在测试的调用模拟方法的方法中:
SomeClass someClass = new SomeClass();
int result = someClass.multiplyByTwoTheValueFromSomeOtherClass(mo);
- 模拟方法返回预定义的结果:
Assert.assertEquals(10, result);
- 完成上述步骤后,测试方法如下所示:
@Test
public void multiplyByTwoTheValueFromSomeOtherClass() {
SomeOtherClass mo = Mockito.mock(SomeOtherClass.class);
Mockito.when(mo.getValue()).thenReturn(5);
SomeClass someClass = new SomeClass();
int result =
someClass.multiplyByTwoTheValueFromSomeOtherClass(mo);
Assert.assertEquals(10, result);
}
Mockito 有一定的局限性。例如,不能模拟静态方法和私有方法。否则,通过可靠地预测所使用的第三方类的结果来隔离正在测试的代码是一个很好的方法
org.apache.log4j以及org.slf4j
在这本书中,我们使用System.out来显示结果。在实际应用中,也可以这样做,并将输出重定向到一个文件,例如,用于以后的分析。在做了一段时间之后,您会注意到您需要关于每个输出的更多细节:例如,每个语句的日期和时间以及生成日志语句的类名。随着代码库的增长,您会发现最好将不同子系统或包的输出发送到不同的文件,或者在一切正常时关闭一些消息,在检测到问题并且需要有关代码行为的更详细信息时再打开这些消息。您不希望日志文件的大小无法控制地增长。
您可以编写自己的代码来完成这一切。但是有几种框架是基于配置文件中的设置来实现的,您可以在每次需要更改日志记录行为时更改这些设置。最常用的两个框架是log4j(发音为 LOG-FOUR-JAY)和slf4j(发音为 S-L-F-FOUR-JAY)。
事实上,这两个框架并不是对手。slf4j框架是一个外观,提供对底层实际日志框架的统一访问,其中一个也可以是log4j。当程序员事先不知道使用库的应用将使用什么样的日志框架时,这种外观在库开发期间尤其有用。通过使用slf4j编写代码,程序员允许稍后将其配置为使用任何日志系统。
因此,如果您的代码将仅由您的团队开发的应用使用,那么仅使用log4j就足够了。否则,请考虑使用slf4j。
并且,与任何第三方库一样,在使用log4j框架之前,必须向 Mavenpom.xml配置文件添加相应的依赖关系:
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.11.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.11.1</version>
</dependency>
例如,以下是如何使用框架:
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class SomeClass {
static final Logger logger =
LogManager.getLogger(SomeClass.class.getName());
public int multiplyByTwoTheValueFromSomeOtherClass(SomeOtherClass
someOtherClass){
if(someOtherClass == null){
logger.error("The parameter should not be null");
System.exit(1);
}
return someOtherClass.getValue() * 2;
}
public static void main(String... args){
new SomeClass().multiplyByTwoTheValueFromSomeOtherClass(null);
}
}
如果我们运行前面的main()方法,结果如下:
18:34:07.672 [main] ERROR SomeClass - The parameter should not be null
Process finished with exit code 1
如您所见,如果项目中没有添加特定于log4j的配置文件,log4j将在DefaultConfiguration类中提供默认配置。默认配置如下:
- 日志消息将转到控制台
- 消息的模式将是
"%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n" logging的级别为Level.ERROR(其他级别为OFF、FATAL、WARN、INFO、DEBUG、TRACE、ALL)
通过使用以下内容将log4j2.xml文件添加到resources文件夹(Maven 将其放置在类路径上),可以获得相同的结果:
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="Console" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level
%logger{36} - %msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="error">
<AppenderRef ref="Console"/>
</Root>
</Loggers>
</Configuration>
如果这对您来说还不够好,可以将配置更改为记录不同级别的消息、不同的文件等等。阅读log4J文档。
org.apache.commons
org.apache.commons包是另一个流行的库,它是作为一个名为 Apache Commons 的项目开发的。它由一个名为 Apache 软件基金会的开源程序员社区维护。这个组织是 1999 年由阿帕奇集团成立的。自 1993 年以来,Apache 小组一直围绕 Apache HTTP 服务器的开发而发展。Apache HTTP 服务器是一个开源的跨平台 Web 服务器,自 1996 年 4 月以来一直是最流行的 Web 服务器。
Apache Commons 项目包括以下三个部分:
- Commons Sandbox:Java 组件开发的工作区;您可以为那里的开放源码工作做出贡献
- Commons Dormant:当前处于非活动状态的组件的存储库;您可以在那里使用代码,但必须自己构建组件,因为这些组件可能不会在不久的将来发布
- Commons Proper:可重用的 Java 组件,组成实际的
org.apache.commons库
我们讨论了第 5 章中的org.apache.commons.io包、“字符串、输入/输出和文件”。
在下面的小节中,我们将只讨论三个最受欢迎的通用包:
org.apache.commons.lang3org.apache.commons.collections4org.apache.commons.codec.binary
但是org.apache.commons下还有更多的包,其中包含数千个类,这些类很容易使用,可以帮助您的代码更加优雅和高效。
lang和lang3
org.apache.commons.lang3包实际上是org.apache.commons.lang包的版本 3。创建新包的决定是由于版本 3 中引入的更改是向后不兼容的,这意味着使用先前版本的org.apache.commons.lang包的现有应用在升级到版本 3 后可能会停止工作。但在大多数主流编程中,向import语句添加3(作为迁移到新版本的方法)通常不会破坏任何东西。
据文献记载,org.apache.commons.lang3包提供了高度可重用的静态实用方法,主要是为java.lang类增加价值。这里有几个值得注意的例子:
ArrayUtils类:允许搜索和操作数组;我们在第 6 章、“数据结构、泛型和流行工具”中讨论和演示了它ClassUtils类:提供类的元数据ObjectUtils类:检查null的对象数组,比较对象,以null安全的方式计算对象数组的中值和最小/最大值;我们在第 6 章、“数据结构、泛型和流行工具”中讨论并演示了它SystemUtils类:提供执行环境的相关信息ThreadUtils类:查找当前正在运行的线程的信息Validate类:验证单个值和集合,比较它们,检查null,匹配,并执行许多其他验证RandomStringUtils类:根据不同字符集的字符生成String对象StringUtils类:我们在第 5 章中讨论了“字符串、输入/输出和文件”
collections4
尽管从表面上看,org.apache.commons.collections4包的内容与org.apache.commons.collections包(即包的版本 3)的内容非常相似,但迁移到版本 4 可能不如在import语句中添加“4”那么顺利。版本 4 删除了不推荐使用的类,添加了泛型和其他与以前版本不兼容的特性。
要想得到一个在这个包或它的一个子包中不存在的集合类型或集合工具,必须很困难。以下只是包含的功能和工具的高级列表:
Bag集合接口,具有每个对象的多个副本- 实现
Bag接口的十几个类;例如,下面是如何使用HashBag类:
Bag<String> bag = new HashBag<>();
bag.add("one", 4);
System.out.println(bag); //prints: [4:one]
bag.remove("one", 1);
System.out.println(bag); //prints: [3:one]
System.out.println(bag.getCount("one")); //prints: 3
- 转换基于
Bag的集合的BagUtils类 BidiMap双向映射的接口,不仅可以按键检索值,还可以按值检索键;它有几个实现,例如:
BidiMap<Integer, String> bidi = new TreeBidiMap<>();
bidi.put(2, "two");
bidi.put(3, "three");
System.out.println(bidi); //prints: {2=two, 3=three}
System.out.println(bidi.inverseBidiMap());
//prints: {three=3, two=2}
System.out.println(bidi.get(3)); //prints: three
System.out.println(bidi.getKey("three")); //prints: 3
bidi.removeValue("three");
System.out.println(bidi); //prints: {2=two}
MapIterator提供简单快速的映射迭代接口,例如:
IterableMap<Integer, String> map =
new HashedMap<>(Map.of(1, "one", 2, "two"));
MapIterator it = map.mapIterator();
while (it.hasNext()) {
Object key = it.next();
Object value = it.getValue();
System.out.print(key + ", " + value + ", ");
//prints: 2, two, 1, one,
if(((Integer)key) == 2){
it.setValue("three");
}
}
System.out.println("\n" + map); //prints: {2=three, 1=one}
- 使元素保持一定顺序的有序映射和集合,如
List,例如:
OrderedMap<Integer, String> map = new LinkedMap<>();
map.put(4, "four");
map.put(7, "seven");
map.put(12, "twelve");
System.out.println(map.firstKey()); //prints: 4
System.out.println(map.nextKey(2)); //prints: null
System.out.println(map.nextKey(7)); //prints: 12
System.out.println(map.nextKey(4)); //prints: 7
- 引用映射;它们的键和/或值可以由垃圾收集器删除
Comparator接口的各种实现Iterator接口的各种实现- 将数组和枚举转换为集合的类
- 允许测试或创建集合的并集、交集和闭包的工具
CollectionUtils、ListUtils、MapUtils、MultiMapUtils、MultiSetUtils、QueueUtils、SetUtils以及许多其他特定于接口的工具类
阅读包装文件了解更多细节。
codec.binary
org.apache.commons.codec.binary包提供对 Base64、Base32、二进制和十六进制字符串编码和解码的支持。编码是必要的,以确保您跨不同系统发送的数据不会因为不同协议中字符范围的限制而在途中更改。此外,有些系统将发送的数据解释为控制字符(例如调制解调器)。
下面的代码片段演示了这个包的Base64类的基本编码和解码功能:
String encodedStr =
new String(Base64.encodeBase64("Hello, World!".getBytes()));
System.out.println(encodedStr); //prints: SGVsbG8sIFdvcmxkIQ==
System.out.println(Base64.isBase64(encodedStr)); //prints: true
String decodedStr =
new String(Base64.decodeBase64(encodedStr.getBytes()));
System.out.println(decodedStr); //prints: Hello, World!
您可以在 ApacheCommons 项目站点上阅读关于这个包的更多信息。
总结
在本章中,我们概述了 JCL 最流行的包的功能:java.lang、java.util、java.time、java.io和java.nio、java.sql、javax.sql、java.net、java.lang.math、java.math、java.awt、javax.swing和javafx。
最流行的外部库是由org.junit、org.mockito、org.apache.log4j、org.slf4j和org.apache.commons包表示的,当这些功能已经存在并且可以直接导入和使用时,它可以帮助读者避免编写自定义代码。
在下一章中,我们将讨论 Java 线程并演示它们的用法。我们还将解释并行处理和并发处理之间的区别。我们将演示如何创建线程以及如何执行、监视和停止它。它不仅对准备为多线程处理编写代码的读者非常有用,而且对那些希望提高对 JVM 工作原理的理解的读者也非常有用,这将是下一章的主题。
测验
-
什么是 Java 类库?选择所有适用的选项:
- 编译后的类的集合
- Java 安装附带的包
- Maven 自动添加到类路径的
.jar文件 - 任何用 Java 编写的库
-
什么是 Java 外部库?选择所有适用的选项:
-
java.lang包中包含哪些功能?选择所有适用的选项: -
java.util包中包含哪些功能?选择所有适用的选项: -
java.time包中包含哪些功能?选择所有适用的选项: -
java.io包中包含哪些功能?选择所有适用的选项: -
java.sql包中包含哪些功能?选择所有适用的选项: -
java.net包中包含哪些功能?选择所有适用的选项: -
java.math包中包含哪些功能?选择所有适用的选项: -
javafx包中包含哪些功能?选择所有适用的选项: -
org.junit包中包含哪些功能?选择所有适用的选项: -
org.mockito包中包含哪些功能?选择所有适用的选项: -
org.apache.log4j包中包含哪些功能?选择所有适用的选项: -
org.apache.commons.lang3包中包含哪些功能?选择所有适用的选项: -
org.apache.commons.collections4包中包含哪些功能?选择所有适用的选项: -
org.apache.commons.codec.binary包中包含哪些功能?选择所有适用的选项:*
八、多线程和并发处理
在本章中,我们将讨论通过使用并发处理数据的工作器(线程)来提高 Java 应用性能的方法。我们将解释 Java 线程的概念并演示它们的用法。我们还将讨论并行处理和并发处理的区别,以及如何避免由于并发修改共享资源而导致的不可预知的结果。
本章将讨论以下主题:
- 线程与进程
- 用户线程与守护进程
- 扩展线程类
- 实现
Runnable接口 - 扩展线程与实现
Runnable - 使用线程池
- 从线程获取结果
- 并行与并发处理
- 同一资源的并发修改
线程与进程
Java 有两个执行单元:进程和线程。一个进程通常代表整个 JVM,尽管应用可以使用java.lang.ProcessBuilder创建另一个进程。但是由于多进程的情况不在本书的讨论范围内,所以我们将重点讨论第二个执行单元,即一个线程,它与进程类似,但与其他线程的隔离度较低,执行所需资源较少。
一个进程可以有许多线程在运行,并且至少有一个线程称为主线程——启动应用的线程,我们在每个示例中都使用它。线程可以共享资源,包括内存和打开的文件,这样可以提高效率。但它的代价是,意外的相互干扰,甚至阻碍执行的风险更高。这就需要编程技巧和对并发技术的理解
用户线程与守护进程
有一种特殊的线程叫做守护进程(daemon)。
守护进程一词起源于古希腊语,意思是神与人之间的神性或超自然存在和内在或伴随的精神或激励力量。
在计算机科学中,术语守护进程有更普通的用法,用于作为后台进程运行,而不是由交互用户直接控制的计算机程序。这就是为什么 Java 中有以下两种类型的线程:
- 用户线程(默认),由应用启动(主线程就是这样一个例子)
- 在后台工作来支持用户线程活动的守护线程
这就是为什么所有守护线程在最后一个用户线程退出之后立即退出,或者在未处理的异常之后被 JVM 终止。
扩展Thread类
创建线程的一种方法是扩展java.lang.Thread类并覆盖其run()方法。例如:
class MyThread extends Thread {
private String parameter;
public MyThread(String parameter) {
this.parameter = parameter;
}
public void run() {
while(!"exit".equals(parameter)){
System.out.println((isDaemon() ? "daemon" : " user") +
" thread " + this.getName() + "(id=" + this.getId() +
") parameter: " + parameter);
pauseOneSecond();
}
System.out.println((isDaemon() ? "daemon" : " user") +
" thread " + this.getName() + "(id=" + this.getId() +
") parameter: " + parameter);
}
public void setParameter(String parameter) {
this.parameter = parameter;
}
}
如果未覆盖run()方法,则线程不执行任何操作。在我们的示例中,只要参数不等于字符串"exit",线程就会每秒打印它的名称和其他属性;否则它就会退出。pauseOneSecond()方法如下:
private static void pauseOneSecond(){
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
我们现在可以使用MyThread类来运行两个线程—一个用户线程和一个守护线程:
public static void main(String... args) {
MyThread thr1 = new MyThread("One");
thr1.start();
MyThread thr2 = new MyThread("Two");
thr2.setDaemon(true);
thr2.start();
pauseOneSecond();
thr1.setParameter("exit");
pauseOneSecond();
System.out.println("Main thread exists");
}
如您所见,主线程创建另外两个线程,暂停一秒钟,在用户线程上设置参数exit,再暂停一秒钟,最后退出(方法main()完成执行)。
如果我们运行前面的代码,我们会看到如下屏幕截图(线程id在不同的操作系统中可能不同):

前面的屏幕截图显示,只要最后一个用户线程(本例中的主线程)退出,守护线程就会自动退出。
实现Runnable接口
创建线程的第二种方法是使用实现java.lang.Runnable的类。下面是这样一个类的示例,它的功能与MyThread类几乎完全相同:
class MyRunnable implements Runnable {
private String parameter, name;
public MyRunnable(String name) {
this.name = name;
}
public void run() {
while(!"exit".equals(parameter)){
System.out.println("thread " + this.name +
", parameter: " + parameter);
pauseOneSecond();
}
System.out.println("thread " + this.name +
", parameter: " + parameter);
}
public void setParameter(String parameter) {
this.parameter = parameter;
}
}
不同的是没有isDaemon()方法、getId()或任何其他现成的方法。MyRunnable类可以是实现Runnable接口的任何类,因此我们无法打印线程是否为守护进程。这就是为什么我们添加了name属性,以便我们可以识别线程。
我们可以使用MyRunnable类来创建线程,就像我们使用MyThread类一样:
public static void main(String... args) {
MyRunnable myRunnable1 = new MyRunnable("One");
MyRunnable myRunnable2 = new MyRunnable("Two");
Thread thr1 = new Thread(myRunnable1);
thr1.start();
Thread thr2 = new Thread(myRunnable2);
thr2.setDaemon(true);
thr2.start();
pauseOneSecond();
myRunnable1.setParameter("exit");
pauseOneSecond();
System.out.println("Main thread exists");
}
下面的截图证明了MyRunnable类的行为与MyThread类的行为相似:

守护线程(名为Two的线程)在最后一个用户线程存在后退出,它与MyThread类的情况完全相同。
扩展线程与实现Runnable
Runnable的实现具有允许实现扩展另一个类的优点(在某些情况下是唯一可能的选择)。当您想向现有类添加类似线程的行为时,它特别有用。实现Runnable允许更灵活的使用。但除此之外,与Thread类的扩展相比,在功能上没有区别。
Thread类有几个构造器,允许设置线程名称及其所属的组。线程的分组有助于在多个线程并行运行的情况下对它们进行管理。Thread类还有几个方法,提供有关线程状态、属性的信息,并允许控制其行为。
如您所见,线程的 ID 是自动生成的。它不能更改,但可以在线程终止后重用。另一方面,可以使用相同的名称设置多个线程。
执行优先级也可以用一个介于Thread.MIN_PRIORITY和Thread.MAX_PRIORITY之间的值编程设置。值越小,允许线程运行的时间就越多,这意味着它具有更高的优先级。如果未设置,则优先级值默认为Thread.NORM_PRIORITY。
线程的状态可以具有以下值之一:
NEW:线程尚未启动时RUNNABLE:执行线程时BLOCKED:线程被阻塞,等待监视器锁定时WAITING:当一个线程无限期地等待另一个线程执行特定操作时TIMED_WAITING:当一个线程等待另一个线程执行某个操作时,等待时间长达指定的等待时间TERMINATED:线程退出时
线程和任何对象也可以使用java.lang.Object基类的wait()、notify()和notifyAll()方法彼此交谈。但是线程行为的这一方面超出了本书的范围。
使用线程池
每个线程都需要资源——CPU 和内存。这意味着必须控制线程的数量,其中一种方法是创建一个固定数量的线程池。此外,创建对象会产生开销,这对于某些应用可能非常重要
在本节中,我们将研究java.util.concurrent包中提供的Executor接口及其实现。它们封装了线程管理,最大限度地减少了应用开发人员在编写与线程生命周期相关的代码上花费的时间。
在java.util.concurrent包中定义了三个Executor接口:
-
基本
Executor接口:只有一个void execute(Runnable r)方法。 -
ExecutorService接口:对Executor进行了扩展,增加了四组方法来管理工作线程和执行器本身的生命周期:submit()将Runnable或Callable对象放入队列中执行的方法(Callable允许工作线程返回值);返回Future接口的对象,用于访问Callable返回的值,管理工作线程的状态invokeAll()方法,将接口Callable对象的集合放入队列中执行;当所有工作线程完成时返回Future对象的List(还有一个重载的invokeAll()方法超时)invokeAny()方法,将接口Callable对象的集合放入队列中执行;返回一个已完成的任何工作线程的Future对象(还有一个带超时的重载invokeAny()方法)- 方法管理工作线程的状态和服务本身,如下所示:
shutdown():防止新的工作线程提交到服务。shutdownNow():中断每个未完成的工作线程。工作线程应该被写入,这样它就可以周期性地检查自己的状态(例如使用Thread.currentThread().isInterrupted()),并自动正常关闭;否则,即使在调用shutdownNow()之后,它也会继续运行。isShutdown():检查执行器是否启动关机。awaitTermination(long timeout, TimeUnit timeUnit):等待关闭请求后所有工作线程执行完毕,或者超时,或者当前线程中断,以先发生的为准。isTerminated():检查关闭启动后是否所有工作线程都已完成。除非先调用了shutdown()或shutdownNow(),否则它永远不会返回true。
-
ScheduledExecutorService接口:它扩展了ExecutorService并添加了允许调度工作线程执行(一次性和周期性)的方法。
可以使用java.util.concurrent.ThreadPoolExecutor或java.util.concurrent.ScheduledThreadPoolExecutor类创建基于池的ExecutorService实现。还有一个java.util.concurrent.Executors工厂类,它涵盖了大多数实际案例。因此,在为工作线程池创建编写自定义代码之前,我们强烈建议您使用java.util.concurrent.Executors类的以下工厂方法:
newCachedThreadPool()创建一个线程池,根据需要添加一个新线程,除非之前创建了一个空闲线程;已经空闲 60 秒的线程将从池中删除- 创建一个按顺序执行工作线程的
ExecutorService(池)实例的newSingleThreadExecutor() newSingleThreadScheduledExecutor()创建一个单线程执行器,可以安排在给定的延迟后运行,或者定期执行newFixedThreadPool(int nThreads)创建一个线程池,该线程池重用固定数量的工作线程;如果在所有工作线程仍在执行时提交一个新任务,则该任务将被放入队列中,直到有一个工作线程可用为止newScheduledThreadPool(int nThreads)创建一个固定大小的线程池,可以计划在给定的延迟后运行,或者定期执行newWorkStealingThreadPool(int nThreads)创建一个线程池,该线程池使用ForkJoinPool使用的偷工算法,在工作线程生成其他线程时特别有用,例如在递归算法中;它还适应指定数量的 CPU,您可以将其设置为高于或低于计算机上的实际 CPU 数
工作窃取算法
工作窃取算法允许已完成分配任务的线程帮助其他仍忙于分配任务的任务。例如,请参见 Oracle Java 官方文档中对 Fork/Join 实现的描述。
这些方法中的每一个都有一个重载版本,允许在需要时传入一个用来创建新线程的ThreadFactory。让我们看看它在代码示例中是如何工作的。首先,我们运行另一个版本的MyRunnable类:
class MyRunnable implements Runnable {
private String name;
public MyRunnable(String name) {
this.name = name;
}
public void run() {
try {
while (true) {
System.out.println(this.name + " is working...");
TimeUnit.SECONDS.sleep(1);
}
} catch (InterruptedException e) {
System.out.println(this.name + " was interrupted\n" +
this.name + " Thread.currentThread().isInterrupted()="
+ Thread.currentThread().isInterrupted());
}
}
}
我们不能再使用parameter属性来告诉线程停止执行,因为线程生命周期现在将由ExecutorService控制,它的方式是调用interrupt()线程方法。另外,请注意,我们创建的线程有一个无限循环,因此它永远不会停止执行,除非强制执行(通过调用interrupt()方法)。让我们编写执行以下操作的代码:
- 创建一个包含三个线程的池
- 确保池不接受更多线程
- 等待一段固定的时间,让所有线程完成它们所做的事情
- 停止(中断)未完成任务的线程
- 退出
以下代码执行前面列表中描述的所有操作:
ExecutorService pool = Executors.newCachedThreadPool();
String[] names = {"One", "Two", "Three"};
for (int i = 0; i < names.length; i++) {
pool.execute(new MyRunnable(names[i]));
}
System.out.println("Before shutdown: isShutdown()=" + pool.isShutdown()
+ ", isTerminated()=" + pool.isTerminated());
pool.shutdown(); // New threads cannot be added to the pool
//pool.execute(new MyRunnable("Four")); //RejectedExecutionException
System.out.println("After shutdown: isShutdown()=" + pool.isShutdown()
+ ", isTerminated()=" + pool.isTerminated());
try {
long timeout = 100;
TimeUnit timeUnit = TimeUnit.MILLISECONDS;
System.out.println("Waiting all threads completion for "
+ timeout + " " + timeUnit + "...");
// Blocks until timeout, or all threads complete execution,
// or the current thread is interrupted, whichever happens first.
boolean isTerminated = pool.awaitTermination(timeout, timeUnit);
System.out.println("isTerminated()=" + isTerminated);
if (!isTerminated) {
System.out.println("Calling shutdownNow()...");
List<Runnable> list = pool.shutdownNow();
System.out.println(list.size() + " threads running");
isTerminated = pool.awaitTermination(timeout, timeUnit);
if (!isTerminated) {
System.out.println("Some threads are still running");
}
System.out.println("Exiting");
}
} catch (InterruptedException ex) {
ex.printStackTrace();
}
尝试在pool.shutdown()之后向池中添加另一个线程会生成java.util.concurrent.RejectedExecutionException。
执行上述代码会产生以下结果:

注意前面屏幕截图中的Thread.currentThread().isInterrupted()=false消息。线程被中断。我们知道是因为线程得到了InterruptedException。那么为什么isInterrupted()方法返回false?这是因为线程状态在收到中断消息后立即被清除。我们现在提到它是因为它是一些程序员错误的来源。例如,如果主线程监视MyRunnable线程并对其调用isInterrupted(),则返回值将为false,这可能会在线程中断后产生误导。
因此,在另一个线程可能正在监视MyRunnable线程的情况下,MyRunnable的实现必须更改为以下内容(注意在catch块中如何调用interrupt()方法):
class MyRunnable implements Runnable {
private String name;
public MyRunnable(String name) {
this.name = name;
}
public void run() {
try {
while (true) {
System.out.println(this.name + " is working...");
TimeUnit.SECONDS.sleep(1);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(this.name + " was interrupted\n" +
this.name + " Thread.currentThread().isInterrupted()="
+ Thread.currentThread().isInterrupted());
}
}
}
现在,如果我们再次使用相同的ExecutorService池运行这个线程,结果将是:

如您所见,现在由isInterrupted()方法返回的值是true,并与发生的事情相对应。公平地说,在许多应用中,一旦线程中断,就不会再次检查其状态。但是设置正确的状态是一种很好的做法,特别是在您不是创建线程的更高级别代码的作者的情况下。
在我们的示例中,我们使用了一个缓存线程池,它根据需要创建一个新线程,或者,如果可用的话,重用已经使用过的线程,但是该线程完成了它的任务并返回到池中进行新的分配。我们不担心创建太多线程,因为我们的演示应用最多有三个工作线程,而且它们的生命周期非常短。
但是,如果应用可能需要的工作线程没有固定的限制,或者没有很好的方法来预测线程可能需要多少内存或可以执行多长时间,那么设置工作线程计数的上限可以防止应用性能的意外降级、内存不足或资源耗尽工作线程使用的任何其他资源。如果线程行为极不可预测,那么单线程池可能是唯一的解决方案,可以选择使用自定义线程池执行器。但在大多数情况下,固定大小的线程池执行器是应用需求和代码复杂性之间的一个很好的实际折衷方案(在本节前面,我们列出了由Executors工厂类创建的所有可能的池类型)
将池的大小设置得过低可能会剥夺应用有效利用可用资源的机会。因此,在选择池大小之前,建议花一些时间监视应用,以确定应用行为的特性。事实上,为了适应和利用代码或执行环境中发生的更改,必须在应用的整个生命周期中重复“循环部署监视调整”。
考虑的第一个特征是系统中 CPU 的数量,因此线程池的大小至少可以与 CPU 的计数一样大。然后,您可以监视应用,查看每个线程占用 CPU 的时间以及占用其他资源(如 I/O 操作)的时间。如果不使用 CPU 所花费的时间与线程的总执行时间相当,则可以按以下比率增加池大小:不使用 CPU 的时间除以总执行时间。但这是在另一个资源(磁盘或数据库)不是线程间争用的主题的情况下。如果是后者,那么您可以使用该资源而不是 CPU 作为描述因子。
假设应用的工作线程不太大或执行时间不太长,并且属于典型工作线程的主流群体,这些线程在合理的短时间内完成其任务,通过将所需响应时间与线程使用 CPU 或其他最具争议的资源的时间之比(四舍五入)相加,可以增加池大小。这意味着,在期望的响应时间相同的情况下,线程使用 CPU 或另一个并发访问的资源的次数越少,池的大小就应该越大。如果有争议的资源有自己的能力来改进并发访问(如数据库中的连接池),请首先考虑使用该特性。
如果所需的同时运行的线程数在不同的情况下在运行时发生变化,则可以使池大小成为动态的,并使用新的大小创建一个新池(在所有线程完成后关闭旧池)。添加或删除可用资源后,可能还需要重新计算新池的大小。例如,您可以使用Runtime.getRuntime().availableProcessors()根据可用 CPU 的当前计数以编程方式调整池大小。
如果 JDK 附带的现成线程池执行器实现都不能满足特定应用的需要,那么在从头开始编写线程管理代码之前,请先尝试使用java.util.concurrent.ThreadPoolExecutor类。它有几个重载构造器。
为了让您了解它的功能,以下是具有最多选项的构造器:
ThreadPoolExecutor (int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
上述构造器的参数如下:
corePoolSize是池中要保留的线程数,即使它们是空闲的,除非用true值调用allowCoreThreadTimeOut(boolean value)方法maximumPoolSize是池中允许的最大线程数keepAliveTime:当线程数大于核心时,这是多余空闲线程等待新任务结束前的最长时间unit是keepAliveTime参数的时间单位workQueue是用于在任务执行之前保存任务的队列;此队列将只保存由execute()方法提交的Runnable对象threadFactory是执行器创建新线程时使用的工厂handler是由于达到线程边界和队列容量而阻止执行时要使用的处理器
在创建了ThreadPoolExecutor类的对象之后,除了workQueue之外,前面的每个构造器参数也可以通过相应的 setter 进行设置,从而允许对现有池特性进行更大的灵活性和动态调整。
从线程获取结果
在我们的示例中,到目前为止,我们使用了ExecutorService接口的execute()方法来启动线程。实际上,这个方法来自于Executor基本接口。同时,ExecutorService接口还有其他方法(在前面的“使用线程池”一节中列出)可以启动线程并返回线程执行结果。
带回线程执行结果的对象是类型Future——一个具有以下方法的接口:
V get():阻塞直到线程结束;返回结果(如果可用)V get(long timeout, TimeUnit unit):阻塞直到线程完成或提供的超时结束;返回结果(如果可用)boolean isDone():线程结束返回trueboolean cancel(boolean mayInterruptIfRunning):尝试取消线程的执行;如果成功则返回true;如果调用方法时线程已经正常完成,则返回falseboolean isCancelled():如果线程执行在正常完成之前被取消,则返回true
get()方法说明中的备注如果可用意味着,即使调用无参数的get()方法,结果原则上也不总是可用的。这完全取决于生成Future对象的方法。以下是返回Future对象的ExecutorService的所有方法的列表:
Future<?> submit(Runnable task):提交线程(任务)执行,返回一个代表任务的Future;返回的Future对象的get()方法返回null;例如,我们使用只工作 100 毫秒的MyRunnable类:
class MyRunnable implements Runnable {
private String name;
public MyRunnable(String name) {
this.name = name;
}
public void run() {
try {
System.out.println(this.name + " is working...");
TimeUnit.MILLISECONDS.sleep(100);
System.out.println(this.name + " is done");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(this.name + " was interrupted\n" +
this.name + " Thread.currentThread().isInterrupted()="
+ Thread.currentThread().isInterrupted());
}
}
}
并且,根据上一节的代码示例,让我们创建一个关闭池并在必要时终止所有线程的方法:
void shutdownAndTerminate(ExecutorService pool){
try {
long timeout = 100;
TimeUnit timeUnit = TimeUnit.MILLISECONDS;
System.out.println("Waiting all threads completion for "
+ timeout + " " + timeUnit + "...");
//Blocks until timeout or all threads complete execution,
// or the current thread is interrupted,
// whichever happens first.
boolean isTerminated =
pool.awaitTermination(timeout, timeUnit);
System.out.println("isTerminated()=" + isTerminated);
if (!isTerminated) {
System.out.println("Calling shutdownNow()...");
List<Runnable> list = pool.shutdownNow();
System.out.println(list.size() + " threads running");
isTerminated = pool.awaitTermination(timeout, timeUnit);
if (!isTerminated) {
System.out.println("Some threads are still running");
}
System.out.println("Exiting");
}
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
我们将在finally块中使用前面的shutdownAndTerminate()方法,以确保没有留下任何正在运行的线程。下面是我们要执行的代码:
ExecutorService pool = Executors.newSingleThreadExecutor();
Future future = pool.submit(new MyRunnable("One"));
System.out.println(future.isDone()); //prints: false
System.out.println(future.isCancelled()); //prints: false
try{
System.out.println(future.get()); //prints: null
System.out.println(future.isDone()); //prints: true
System.out.println(future.isCancelled());//prints: false
} catch (Exception ex){
ex.printStackTrace();
} finally {
shutdownAndTerminate(pool);
}
您可以在这个屏幕截图上看到这个代码的输出:

正如所料,Future对象的get()方法返回null,因为Runnable的run()方法不返回任何内容。从返回的Future中我们只能得到任务是否完成的信息。
Future<T> submit(Runnable task, T result):提交线程(任务)执行,返回一个Future代表任务,其中包含提供的result,例如,我们将使用下面的类作为结果:
class Result {
private String name;
private double result;
public Result(String name, double result) {
this.name = name;
this.result = result;
}
@Override
public String toString() {
return "Result{name=" + name +
", result=" + result + "}";
}
}
下面的代码演示了submit()方法返回的Future如何返回默认结果:
ExecutorService pool = Executors.newSingleThreadExecutor();
Future<Result> future = pool.submit(new MyRunnable("Two"),
new Result("Two", 42.));
System.out.println(future.isDone()); //prints: false
System.out.println(future.isCancelled()); //prints: false
try{
System.out.println(future.get()); //prints: null
System.out.println(future.isDone()); //prints: true
System.out.println(future.isCancelled()); //prints: false
} catch (Exception ex){
ex.printStackTrace();
} finally {
shutdownAndTerminate(pool);
}
如果执行前面的代码,输出如下:

正如所料,Future的get()方法返回作为参数传入的对象。
Future<T> submit(Callable<T> task):提交线程(任务)执行,返回一个Future,表示任务,返回结果由Callable接口的V call()方法生成并返回,即Callable方法接口唯一的一个方法。例如:
class MyCallable implements Callable {
private String name;
public MyCallable(String name) {
this.name = name;
}
public Result call() {
try {
System.out.println(this.name + " is working...");
TimeUnit.MILLISECONDS.sleep(100);
System.out.println(this.name + " is done");
return new Result(name, 42.42);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(this.name + " was interrupted\n" +
this.name + " Thread.currentThread().isInterrupted()="
+ Thread.currentThread().isInterrupted());
}
return null;
}
上述代码的结果如下:

如您所见,Future的get()方法返回由MyCallable类的call()方法生成的值
List<Future<T>> invokeAll(Collection<Callable<T>> tasks):执行所提供集合的所有Callable任务;返回Futures列表,其中包含已执行Callable对象生成的结果List<Future<T>> invokeAll(Collection<Callable<T>>:执行所提供集合的所有Callable任务;返回Futures列表,其中包含已执行的Callable对象产生的结果或超时过期,以先发生的为准T invokeAny(Collection<Callable<T>> tasks):执行所提供集合的所有Callable任务,如果有,返回一个已成功完成的任务的结果(即不抛出异常)T invokeAny(Collection<Callable<T>> tasks, long timeout, TimeUnit unit):执行所提供集合的所有Callable任务;如果在所提供的超时过期之前有一个任务成功完成,则返回该任务的结果(即不抛出异常)
如您所见,有许多方法可以从线程中获得结果。选择的方法取决于应用的特定需要。
并行与并发处理
当我们听到工作线程同时执行时,我们会自动地假设它们实际上做了编程所要并行执行的事情。只有在我们深入研究了这样一个系统之后,我们才意识到,只有当线程分别由不同的 CPU 执行时,这种并行处理才是可能的。否则,它们的时间共享相同的处理能力。我们认为他们在同一时间工作,只是因为他们使用的时间间隔非常短,只是我们在日常生活中使用的时间单位的一小部分。当线程共享同一个资源时,在计算机科学中,我们说它们同时进行。
同一资源的并发修改
两个或多个线程在其他线程读取同一值的同时修改该值,这是对并发访问问题之一的最一般描述。更微妙的问题包括线程干扰和内存一致性错误,这两种错误都会在看似良性的代码片段中产生意想不到的结果。在本节中,我们将演示此类情况以及避免此类情况的方法。
乍一看,解决方案似乎非常简单:一次只允许一个线程修改/访问资源,就这样。但是如果访问需要很长时间,就会产生一个瓶颈,可能会消除多线程并行工作的优势。或者,如果一个线程在等待访问另一个资源时阻塞了对一个资源的访问,而第二个线程在等待访问第一个资源时阻塞了对第二个资源的访问,则会产生一个称为死锁的问题。这是程序员在使用多线程时可能遇到的挑战的两个非常简单的例子。
首先,我们将重现由同一值的并发修改引起的问题。我们创建一个Calculator接口:
interface Calculator {
String getDescription();
double calculate(int i);
}
我们将使用getDescription()方法来捕获实现的描述。以下是第一个实现:
class CalculatorNoSync implements Calculator{
private double prop;
private String description = "Without synchronization";
public String getDescription(){ return description; }
public double calculate(int i){
try {
this.prop = 2.0 * i;
TimeUnit.MILLISECONDS.sleep(i);
return Math.sqrt(this.prop);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Calculator was interrupted");
}
return 0.0;
}
}
如您所见,calculate()方法将一个新值赋给prop属性,然后执行其他操作(我们通过调用sleep()方法来模拟它),然后计算分配给prop属性的值的平方根。"Without synchronization"描述描述了在没有任何协调或同步的情况下,每次调用calculate()方法时prop属性的值都在变化,当线程同时修改同一资源时,在线程之间进行协调时调用。
我们现在将在两个线程之间共享这个对象,这意味着prop属性将被同时更新和使用。因此,围绕prop属性进行某种线程同步是必要的,但我们已经决定,我们的第一个实现不会这样做。
下面是我们在执行我们要创建的每个Calculator实现时要使用的方法:
void invokeAllCallables(Calculator c){
System.out.println("\n" + c.getDescription() + ":");
ExecutorService pool = Executors.newFixedThreadPool(2);
List<Callable<Result>> tasks = List.of(new MyCallable("One", c),
new MyCallable("Two", c));
try{
List<Future<Result>> futures = pool.invokeAll(tasks);
List<Result> results = new ArrayList<>();
while (results.size() < futures.size()){
TimeUnit.MILLISECONDS.sleep(5);
for(Future future: futures){
if(future.isDone()){
results.add((Result)future.get());
}
}
}
for(Result result: results){
System.out.println(result);
}
} catch (Exception ex){
ex.printStackTrace();
} finally {
shutdownAndTerminate(pool);
}
}
如您所见,前面的方法执行以下操作:
- 打印传入的
Calculator实现的描述 - 为两个线程创建固定大小的池
- 创建两个
Callable任务的列表,这些任务是以下MyCallable类的对象:
class MyCallable implements Callable<Result> {
private String name;
private Calculator calculator;
public MyCallable(String name, Calculator calculator) {
this.name = name;
this.calculator = calculator;
}
public Result call() {
double sum = 0.0;
for(int i = 1; i < 20; i++){
sum += calculator.calculate(i);
}
return new Result(name, sum);
}
}
-
任务列表传入池的
invokeAll()方法,每个任务通过调用call()方法来执行;每个call()方法将传入的Calculator对象的calculate()方法应用到从 1 到 20 的 19 个数字中的每一个,并对结果进行汇总;结果和与MyCallable对象的名称一起返回到Result对象中 -
每个
Result对象最终返回到Future对象中 -
然后
invokeAllCallables()方法对Future对象列表进行迭代,检查每个对象是否完成任务;当任务完成时,结果被添加到List<Result> results中 -
所有任务完成后,
invokeAllCallables()方法将打印List<Result> results的所有元素并终止池
以下是我们运行invokeAllCallables(new CalculatorNoSync())得到的结果:

每次运行前面的代码时,实际的数字都略有不同,但是任务One的结果永远不会等于任务Two的结果,这是因为在设置prop字段的值和在calculate()方法中返回其平方根之间的时间段内,另一个线程设法分配了不同的值至prop。这是螺纹干涉的情况。
有几种方法可以解决这个问题。我们从一个原子变量开始,以此实现对属性的线程安全并发访问。然后我们还将演示两种线程同步方法。
原子变量
原子变量是一个仅当其当前值与期望值匹配时才能更新的变量。在我们的例子中,这意味着如果prop值已被另一个线程更改,则不应使用它。
java.util.concurrent.atomic包有十几个类支持这种逻辑:AtomicBoolean、AtomicInteger、AtomicReference和AtomicIntegerArray,举几个例子。这些类中的每一个都有许多方法可用于不同的同步需求。查看这些类的在线 API 文档。在演示中,我们将仅使用其中的两种方法:
V get():返回当前值boolean compareAndSet(V expectedValue, V newValue):如果当前值等于运算符(==),则将值设置为newValue;如果成功,则返回true,如果实际值不等于期望值,则返回false
下面是如何使用AtomicReference类来解决线程的干扰问题,同时使用这两种方法访问Calculator对象的prop属性:
class CalculatorAtomicRef implements Calculator {
private AtomicReference<Double> prop = new AtomicReference<>(0.0);
private String description = "Using AtomicReference";
public String getDescription(){ return description; }
public double calculate(int i){
try {
Double currentValue = prop.get();
TimeUnit.MILLISECONDS.sleep(i);
boolean b = this.prop.compareAndSet(currentValue, 2.0 * i);
//System.out.println(b); //prints: true for one thread
//and false for another thread
return Math.sqrt(this.prop.get());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Calculator was interrupted");
}
return 0.0;
}
}
如您所见,前面的代码确保在线程睡眠时,prop属性的currentValue不会更改。下面是我们运行invokeAllCallables(new CalculatorAtomicRef())时产生的消息截图:

现在线程产生的结果是相同的。
java.util.concurrent包的以下类也提供同步支持:
Semaphore:限制可以访问资源的线程数CountDownLatch:允许一个或多个线程等待,直到在其他线程中执行的一组操作完成CyclicBarrier:允许一组线程等待彼此到达公共屏障点Phaser:提供了一种更灵活的屏障形式,可用于控制多线程之间的阶段计算Exchanger:允许两个线程在一个集合点交换对象,在多个管道设计中非常有用
同步方法
另一种解决问题的方法是使用同步方法。这里是Calculator接口的另一个实现,它使用这种解决线程干扰的方法:
class CalculatorSyncMethod implements Calculator {
private double prop;
private String description = "Using synchronized method";
public String getDescription(){ return description; }
synchronized public double calculate(int i){
try {
this.prop = 2.0 * i;
TimeUnit.MILLISECONDS.sleep(i);
return Math.sqrt(this.prop);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Calculator was interrupted");
}
return 0.0;
}
}
我们刚刚在calculate()方法前面添加了synchronized关键字。现在,如果我们运行invokeAllCallables(new CalculatorSyncMethod()),两个线程的结果总是一样的:

这是因为在当前线程(已经进入同步方法的线程)退出同步方法之前,另一个线程无法进入同步方法。这可能是最简单的解决方案,但如果该方法需要很长时间才能执行,则此方法可能会导致性能下降。在这种情况下,可以使用同步块,它在一个原子操作中只包装几行代码。
同步块
以下是用于解决线程干扰问题的同步块的示例:
class CalculatorSyncBlock implements Calculator {
private double prop;
private String description = "Using synchronized block";
public String getDescription(){
return description;
}
public double calculate(int i){
try {
//there may be some other code here
synchronized (this) {
this.prop = 2.0 * i;
TimeUnit.MILLISECONDS.sleep(i);
return Math.sqrt(this.prop);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("Calculator was interrupted");
}
return 0.0;
}
}
如您所见,synchronized块在this对象上获取一个锁,该锁由两个线程共享,并且只有在线程退出块之后才释放它。在我们的演示代码中,该块覆盖了该方法的所有代码,因此在性能上没有差异。但是想象一下这个方法中有更多的代码(我们将位置注释为there may be some other code here。如果是这样的话,代码的同步部分就更小,因此成为瓶颈的机会就更少。
如果我们运行invokeAllCallables(new CalculatorSyncBlock()),结果如下:

如您所见,结果与前两个示例完全相同,在java.util.concurrent.locks包中组装了针对不同需求和不同行为的不同类型的锁
Java 中的每个对象都从基对象继承了wait()、notify()和notifyAll()方法。这些方法还可以用来控制线程的行为及其对锁的访问。
并发集合
解决并发性的另一种方法是使用来自java.util.concurrent包的线程安全集合,阅读 Javadoc 查看您的申请是否接受托收限制。以下是这些托收清单和一些建议:
-
ConcurrentHashMap<K,V>:支持检索的完全并发和更新的高期望并发,当并发要求很高,需要允许对写操作进行锁定但不需要锁定元素时使用。 -
ConcurrentLinkedQueue<E>:基于链接节点的线程安全队列,采用高效的非阻塞算法。 -
ConcurrentLinkedDeque<E>:基于链接节点的并发队列,当多个线程共享对一个公共集合的访问时,ConcurrentLinkedQueque和ConcurrentLinkedDeque都是合适的选择。 -
ConcurrentSkipListMap<K,V>:并发ConcurrentNavigableMap接口实现。 -
ConcurrentSkipListSet<E>:基于ConcurrentSkipListMap的并发NavigableSet实现。ConcurrentSkipListSet和ConcurrentSkipListMap类,根据 Javadoc,对包含、添加和删除操作及其变体,提供预期平均O(logn)时间成本。升序视图及其迭代器的速度比降序视图快;当您需要按特定顺序快速遍历元素时,请使用它们。 -
CopyOnWriteArrayList<E>:一种线程安全的ArrayList变体,所有的修改操作(add、set等)都是通过对底层数组进行一个新的拷贝来实现的;根据 Javadoc,CopyOnWriteArrayList类通常成本太高,但当遍历操作的数量远远超过修改时,它可能比其他方法更有效,当您不能或不想同步遍历,但需要排除并发线程之间的干扰时,它会很有用;当您不需要在不同位置添加新元素且不需要排序时,使用它;否则,使用ConcurrentSkipListSet。 -
CopyOnWriteArraySet<E>:所有操作都使用内部CopyOnWriteArrayList的集合。 -
PriorityBlockingQueue:当一个自然的顺序是可以接受的,并且您需要快速向尾部添加元素和快速从队列头部移除元素时,这是一个更好的选择;阻塞是指队列在检索元素时等待变为非空,在存储元素时等待队列中的空间变为可用。 -
ArrayBlockingQueue、LinkedBlockingQueue和LinkedBlockingDeque具有固定大小(有界);其他队列是无界的。
使用这些和指南中类似的特性和建议,但是在实现功能之前和之后执行全面的测试和性能度量。为了演示其中的一些收集功能,让我们使用CopyOnWriteArrayList<E>。首先,让我们看看当我们试图同时修改它时,ArrayList是如何工作的:
List<String> list = Arrays.asList("One", "Two");
System.out.println(list);
try {
for (String e : list) {
System.out.println(e); //prints: One
list.add("Three"); //UnsupportedOperationException
}
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println(list); //prints: [One, Two]
正如预期的那样,在对列表进行迭代时尝试修改列表会生成一个异常,并且该列表保持不变。
现在,让我们在同样的情况下使用CopyOnWriteArrayList<E>:
List<String> list =
new CopyOnWriteArrayList<>(Arrays.asList("One", "Two"));
System.out.println(list);
try {
for (String e : list) {
System.out.print(e + " "); //prints: One Two
list.add("Three"); //adds element Three
}
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println("\n" + list); //prints: [One, Two, Three, Three]
此代码生成的输出如下所示:

如您所见,该列表已被修改,没有异常,但不是当前迭代的副本。如果需要,您可以使用这种行为。
内存一致性错误
在多线程环境中,内存一致性错误可能有多种形式和原因。它们在java.util.concurrent包的 Javadoc 中有很好的讨论。在这里,我们将只提到最常见的情况,这是由于缺乏能见度造成的。
当一个线程更改属性值时,另一个线程可能不会立即看到更改,并且不能对原始类型使用synchronized关键字。在这种情况下,可以考虑对属性使用volatile关键字;它保证了不同线程之间的读/写可见性。
并发问题不容易解决。这就是为什么现在越来越多的开发人员采取更激进的方法也就不足为奇了。他们更喜欢在一组无状态操作中处理数据,而不是管理对象状态。我们将在第 13 章、“函数式编程”和第 14 章、“Java 标准流”中看到这些代码的示例。Java 和许多现代语言以及计算机系统似乎正朝着这个方向发展。
总结
在这一章中,我们讨论了多线程处理,以及如何组织它,以及如何避免由于并发修改共享资源而导致的不可预知的结果。我们向读者展示了如何创建线程并使用线程池执行它们。我们还演示了如何从成功完成的线程中提取结果,并讨论了并行处理和并发处理之间的区别
在下一章中,我们将让读者更深入地了解 JVM 及其结构和进程,并详细讨论防止内存溢出的垃圾收集过程。在本章的最后,读者将了解什么构成了 Java 应用执行、JVM 中的 Java 进程、垃圾收集以及 JVM 通常是如何工作的。
测验
-
选择所有正确的语句:
- JVM 进程可以有主线程
- 主线程是主进程
- 一个进程可以启动另一个进程
- 一个线程可以启动另一个线程
-
选择所有正确的语句:
- 守护进程是一个用户线程
- 守护线程在第一个用户线程完成后退出
- 守护线程在最后一个用户线程完成后退出
- 主线程是一个用户线程
-
选择所有正确的语句:
- 所有线程都有
java.lang.Thread作为基类 - 所有线程扩展
java.lang.Thread - 所有线程实现
java.lang.Thread - 守护线程不扩展
java.lang.Thread
- 所有线程都有
-
选择所有正确的语句:
- 任何类都可以实现
Runnable接口 Runnable接口实现是一个线程Runnable接口实现由线程使用Runnable接口只有一个方法
- 任何类都可以实现
-
选择所有正确的语句:
- 线程名称必须是唯一的
- 线程 ID 自动生成
- 可以设置线程名称
- 可以设置线程优先级
-
选择所有正确的语句:
- 线程池执行线程
- 线程池重用线程
- 某些线程池可以有固定的线程数
- 某些线程池可以有无限个线程
-
选择所有正确的语句:
Future对象是从线程获取结果的唯一方法Callable对象是从线程获取结果的唯一方法Callable对象允许从线程获取结果Future对象表示线程
-
选择所有正确的语句:
- 并发处理可以并行进行
- 只有在计算机上有几个 CPU 或内核的情况下,才能进行并行处理
- 并行处理是并发处理
- 没有多个 CPU,就不可能进行并发处理
-
选择所有正确的语句:
- 并发修改总是导致错误的结果
- 原子变量保护属性不受并发修改
- 原子变量保护属性不受线程干扰
- 原子变量是保护属性不受并发修改的唯一方法
-
选择所有正确的语句:
1. 同步方法是避免线程干扰的最佳方法
2.synchronized关键字可以应用于任何方法
3. 同步方法可能会造成处理瓶颈
4. 同步方法易于实现 -
选择所有正确的语句:
1. 同步块只有在小于方法时才有意义
2. 同步块需要共享锁
3. 每个 Java 对象都可以提供一个锁
4. 同步块是避免线程干扰的最佳方法 -
选择所有正确的语句:
1. 首选使用并发集合,而不是使用非并发集合
2. 使用并发集合会产生一些开销
3. 不是每个并发集合都适合每个并发处理场景
4. 可以通过调用Collections.makeConcurrent()方法来创建并发集合 -
选择所有正确的语句:
1. 避免内存一致性错误的唯一方法是声明volatile变量
2. 使用volatile关键字可以确保值在所有线程中的变化的可见性
3. 避免并发的方法之一是避免任何状态管理
4. 无状态工具方法不能有并发问题
九、JVM 结构与垃圾收集
本章向读者概述了 Java 虚拟机(JVM)的结构和行为,它们比您预期的要复杂。
JVM 只是根据编码逻辑执行指令的执行器。它还发现并将应用请求的.class文件加载到内存中,验证它们,解释字节码(也就是说,它将它们转换为特定于平台的二进制代码),并将生成的二进制代码传递给中央处理器(或多个处理器)执行。除了应用线程外,它还使用多个服务线程。其中一个服务线程,称为垃圾收集(GC),执行从未使用对象释放内存的重要任务
阅读本章之后,读者将更好地理解什么是 Java 应用执行、JVM 中的 Java 进程、GC 以及 JVM 通常是如何工作的。
本章将讨论以下主题:
- Java 应用的执行
- Java 进程
- JVM 结构
- 垃圾收集
Java 应用的执行
在深入了解 JVM 的工作原理之前,让我们回顾一下如何运行应用,记住以下语句是同义词:
- 运行/执行/启动主类
- 运行/执行/启动
main方法 - 运行/执行/启动/启动应用
- 运行/执行/启动/启动 JVM 或 Java 进程
也有几种方法。在第一章“Java12 入门”中,我们向您展示了如何使用 IntelliJ IDEA 运行main(String[])方法。在本章中,我们将重复已经说过的一些内容,并添加可能对您有所帮助的其他变体。
使用 IDE
任何 IDE 都允许运行main()方法。在 IntelliJ IDEA 中,可以通过三种方式完成:
- 单击
main()方法名称旁边的绿色三角形:

- 使用绿色三角形至少执行一次
main()方法后,类的名称将添加到下拉菜单(在绿色三角形左侧的顶行上):

选择它并单击菜单右侧的绿色三角形:

- 打开“运行”菜单并选择类的名称。有几种不同的选项可供选择:

在前面的屏幕截图中,您还可以看到编辑配置的选项。。。。它可用于设置在开始时传递给main()方法的程序参数和一些其他选项:

VM 选项字段允许设置java命令选项。例如输入-Xlog:gc,IDE 会形成如下java命令:
java -Xlog:gc -cp . com.packt.learnjava.ch09_jvm.MyApplication
-Xlog:gc选项要求显示 GC 日志。我们将在下一节中使用此选项来演示 GC 是如何工作的。-cp .选项(cp代表类路径)表示该类位于文件树上从当前目录(输入命令的目录)开始的文件夹中。在本例中,.class文件位于com/packt/learnjava/ch09_jvm文件夹中,其中com是当前目录的子文件夹。类路径可以包括许多位置,JVM 必须在这些位置查找应用执行所需的.class文件。
对于此演示,让我们按如下方式设置 VM 选项:

程序参数字段允许在java命令中设置参数。例如,我们在这个字段中设置one two three:

此设置将导致以下java命令:
java -DsomeParameter=42 -cp . \
com.packt.learnjava.ch09_jvm.MyApplication one two three
我们可以在main()方法中读取这些参数:
public static void main(String... args){
System.out.println("Hello, world!"); //prints: Hello, world!
for(String arg: args){
System.out.print(arg + " "); //prints: one two three
}
String p = System.getProperty("someParameter");
System.out.println("\n" + p); //prints: 42
}
编辑配置屏幕上的另一个可能设置是在环境变量字段中:

这是使用System.getenv()设置可从应用访问的环境变量的方法。例如,设置环境变量x和y如下:

如果按照前面的屏幕截图所示进行,则不仅可以在main()方法中读取x和y的值,而且可以在使用System.getenv("varName")方法的应用中的任何地方读取。在我们的例子中,x和y的值可以如下检索:
String p = System.getenv("x");
System.out.println(p); //prints: 42
p = System.getenv("y");
System.out.println(p); //prints: 43
java命令的其他参数也可以在编辑配置屏幕上设置。我们鼓励您在该屏幕上花费一些时间并查看可能的选项。
对类使用命令行
现在让我们从命令行运行MyApplication。为了提醒您,主类如下所示:
package com.packt.learnjava.ch09_jvm;
public class MyApplication {
public static void main(String... args){
System.out.println("Hello, world!"); //prints: Hello, world!
for(String arg: args){
System.out.print(arg + " "); //prints all arguments
}
String p = System.getProperty("someParameter");
System.out.println("\n" + p); //prints someParameter set
// as VM option -D
}
}
首先,必须使用javac命令来编译它。命令行如下所示(前提是您打开了项目根目录中pom.xml所在文件夹中的终端窗口):
javac src/main/java/com/packt/learnjava/ch09_jvm/MyApplication.java
这适用于 Linux 类型的平台。在 Windows 上,命令类似:
javac src\main\java\com\packt\learnjava\ch09_jvm\MyApplication.java
编译后的MyApplication.class文件与MyApplication.java放在同一文件夹中。现在我们可以用java命令执行编译后的类:
java -DsomeParameter=42 -cp src/main/java \
com.packt.learnjava.ch09_jvm.MyApplication one two three
注意,-cp指向文件夹src/main/java(路径是相对于当前文件夹的),主类的包从这里开始。结果是:

如果应用使用位于不同文件夹中的其他.class文件,则这些文件夹的所有路径(相对于当前文件夹)都可以列在-cp选项后面,用冒号(:分隔)。例如:
java -cp src/main/java:someOtherFolder/folder \
com.packt.learnjava.ch09_jvm.MyApplication
注意,-cp选项列出的文件夹可以包含任意数量的.class文件。这样,JVM 就可以找到它需要的东西。例如,我们在com.packt.learnjava.ch09_jvm包中创建一个子包example,其中包含ExampleClass类:
package com.packt.learnjava.ch09_jvm.example;
public class ExampleClass {
public static int multiplyByTwo(int i){
return 2 * i;
}
}
现在让我们在MyApplication类中使用它:
package com.packt.learnjava.ch09_jvm;
import com.packt.learnjava.ch09_jvm.example.ExampleClass;
public class MyApplication {
public static void main(String... args){
System.out.println("Hello, world!"); //prints: Hello, world!
for(String arg: args){
System.out.print(arg + " ");
}
String p = System.getProperty("someParameter");
System.out.println("\n" + p); //prints someParameter value
int i = ExampleClass.multiplyByTwo(2);
System.out.println(i);
}
}
我们将使用与前面相同的javac命令编译MyApplication类:
javac src/main/java/com/packt/learnjava/ch09_jvm/MyApplication.java
结果是以下错误:

这意味着编译器找不到ExampleClass.class文件。我们需要编译它并放在类路径上:
javac src/main/java/com/packt/learnjava/ch09_jvm/example/ExampleClass.java
javac -cp src/main/java \
src/main/java/com/packt/learnjava/ch09_jvm/MyApplication.java
如您所见,我们在类路径中添加了位置ExampleClass.class,即src/main/java。现在我们可以执行MyApplication.class:
java -cp src/main/java com.packt.learnjava.ch09_jvm.MyApplication
结果如下:

不需要列出包含 Java 类库(JCL)中的类的文件夹。JVM 知道在哪里可以找到它们。
对 JAR 文件使用命令行
将编译后的文件作为.class文件保存在一个文件夹中并不总是很方便的,特别是当同一框架的许多编译文件属于不同的包并且作为单个库分发时。在这种情况下,编译的.class文件通常一起归档在.jar文件中。此类档案的格式与.zip文件的格式相同。唯一的区别是,.jar文件还包含一个清单文件,其中包含描述存档的元数据(我们将在下一节中详细讨论清单)。
为了演示如何使用它,让我们使用以下命令创建一个包含ExampleClass.class文件的.jar文件和另一个包含MyApplication.class文件的.jar文件:
cd src/main/java
jar -cf myapp.jar com/packt/learnjava/ch09_jvm/MyApplication.class
jar -cf example.jar \
com/packt/learnjava/ch09_jvm/example/ExampleClass.class
注意,我们需要在.class文件包开始的文件夹中运行jar命令
现在我们可以按如下方式运行应用:
java -cp myapp.jar:example.jar \
com.packt.learnjava.ch09_jvm.MyApplication
.jar文件在当前文件夹中。如果我们想从另一个文件夹执行应用(让我们回到根目录,cd ../../..),命令应该如下所示:
java -cp src/main/java/myapp.jar:src/main/java/example.jar \
com.packt.learnjava.ch09_jvm.MyApplication
注意,每个.jar文件都必须单独列在类路径上。仅仅指定一个文件夹来存放所有的.jar文件(就像.class文件一样)是不够的。如果文件夹中只包含.jar个文件,则所有这些文件都可以包含在类路径中,如下所示:
java -cp src/main/java/* com.packt.learnjava.ch09_jvm.MyApplication
如您所见,必须在文件夹名称之后添加通配符。
对可执行 JAR 文件使用命令行
可以避免在命令行中指定主类。相反,我们可以创建一个可执行的.jar文件。它可以通过将主类的名称(您需要运行的主类和包含main()方法的主类)放入manifest文件中来实现。步骤如下:
- 创建一个文本文件,
manifest.txt(名称实际上并不重要,但是这个名称清楚地表明了意图),其中包含以下行:
Main-Class: com.packt.learnjava.ch09_jvm.MyApplication
冒号(:后面必须有一个空格,结尾必须有一个不可见的换行符,因此请确保您按了Enter键,并且光标已经跳到下一行的开头。
- 执行命令:
cd src/main/java
jar -cfm myapp.jar manifest.txt com/packt/learnjava/ch09_jvm/*.class \
com/packt/learnjava/ch09_jvm/example/*.class
注意jar命令选项的顺序(fm和以下文件的顺序:myapp.jar manifest.txt。它们必须相同,因为f代表jar命令将要创建的文件,m代表清单源。如果将选项包括为mf,则文件必须列为manifest.txt myapp.jar。
- 现在我们可以使用以下命令运行应用:
java -jar myapp.jar
另一种创建可执行文件.jar的方法要简单得多:
jar cfe myjar.jar com.packt.learnjava.ch09_jvm.MyApplication \
com/packt/learnjava/ch09_jvm/*.class \
com/packt/learnjava/ch09_jvm/example/*.class
该命令自动生成指定主类名的清单:c选项表示新建档案,选项f表示档案文件名,选项e表示应用入口点。
Java 进程
您可能已经猜到了,JVM 对 Java 语言和源代码一无所知。它只知道如何读取字节码。它从.class文件中读取字节码和其他信息,将字节码转换(解释)成特定于当前平台(JVM 运行的地方)的一系列二进制代码指令,并将生成的二进制代码传递给执行它的微处理器作为一个 Java 进程、或只是进程。
JVM 通常被称为 JVM 实例。这是因为每次执行一个java命令时,都会启动一个新的 JVM 实例,专用于将特定应用作为一个单独的进程运行,并使用它自己分配的内存(内存大小被设置为默认值或作为命令选项传入)。在这个 Java 进程中,有多个线程正在运行,每个线程都有自己分配的内存。一些是由 JVM 创建的服务线程;另一些是由应用创建和控制的应用线程。
这就是 JVM 执行编译代码的总体情况。但是如果仔细阅读 JVM 规范,就会发现与 JVM 相关的单词进程也被用来描述 JVM 内部进程。JVM 规范标识了 JVM 中运行的其他几个进程,程序员通常不会提及这些进程,除了可能的类加载进程。
这是因为在大多数情况下,我们可以成功地编写和执行 Java 程序,而不必了解任何内部 JVM 进程。但偶尔,对 JVM 内部工作的一些一般性理解有助于确定某些问题的根本原因。这就是为什么在本节中,我们将简要概述 JVM 中发生的所有进程。然后,在下面的部分中,我们将更详细地讨论 JVM 的内存结构及其功能的其他方面,这些方面可能对程序员有用。
有两个子系统运行所有 JVM 内部进程:
- 类加载器:读取
.class文件,用类相关数据填充 JVM 内存中的方法区:- 静态字段
- 方法字节码
- 描述类的元数据
- 执行引擎:使用以下方式执行字节码:
- 对象实例化的堆区域
- Java 和本机方法栈,用于跟踪调用的方法
- 回收内存的垃圾收集过程
在主 JVM 进程内运行的进程包括:
- 类加载器执行的进程包括:
- 类加载
- 类链接
- 类初始化
- 执行引擎执行的过程包括:
- 类实例化
- 方法执行
- 垃圾收集
- 应用终止
JVM 架构
JVM 架构可以描述为有两个子系统:类加载器和执行引擎,它们使用运行时数据存储区域(如方法区域、堆和应用线程栈)来运行服务进程和应用线程。线程是比 JVM 执行进程需要更少资源分配的轻量级进程。
该列表可能会给您这样的印象:这些过程是按顺序执行的。在某种程度上,这是真的,如果我们只谈论一个类。在加载之前,不可能对类执行任何操作。方法的执行只能在前面的所有进程都完成之后开始。但是,例如,GC 不会在停止使用对象后立即发生(请参阅“垃圾收集”部分)。此外,当发生未处理的异常或其他错误时,应用可以随时退出。
只有类加载器进程受 JVM 规范的控制。执行引擎的实现在很大程度上取决于每个供应商。它基于语言语义和实现作者设定的性能目标。
执行引擎的进程位于 JVM 规范未规定的领域中。有一些常识、传统、已知且经验证的解决方案,还有一个 Java 语言规范,可以指导 JVM 供应商的实现决策。但没有单一的监管文件。好消息是,最受欢迎的 jvm 使用类似的解决方案,或者至少,在高层次上是这样的
考虑到这一点,让我们更详细地讨论前面列出的七个过程中的每一个。
类加载
根据 JVM 规范,加载阶段包括按名称(在类路径上列出的位置)查找.class文件并在内存中创建其表示。
要加载的第一个类是在命令行中传递的类,其中包含了main(String[])方法。类加载器读取.class文件,对其进行解析,并用静态字段和方法字节码填充方法区域。它还创建了一个描述类的java.lang.Class实例。然后类加载器链接该类(参见“类链接”部分),对其进行初始化(参见“类初始化”部分),然后将其传递给执行引擎以运行其字节码。
main(String[])方法是进入应用的入口。如果它调用另一个类的方法,则必须在类路径上找到该类,然后加载、初始化,只有这样才能执行它的方法。如果这个刚刚加载的方法调用另一个类的方法,那么这个类也必须被找到、加载和初始化。等等。这就是 Java 应用如何启动和运行的。
main(String[])方法
每个类都可以有一个main(String[])方法,而且经常有。这种方法用于将类作为独立应用独立运行,以进行测试或演示。这种方法的存在不会使类成为main。只有在java命令行或.jar文件清单中标识为main时,类才会成为main。
也就是说,让我们继续讨论加载过程。
如果您查看java.lang.Class的 API,您将不会在那里看到公共构造器。类加载器自动创建它的实例,顺便说一句,它是您可以在任何 Java 对象上调用的getClass()方法返回的同一个实例。
它不携带类静态数据(在方法区域中维护),也不携带状态值(它们在执行期间创建的对象中)。它也不包含方法字节码(它们也存储在方法区域中)。相反,Class实例提供描述类的元数据—它的名称、包、字段、构造器、方法签名等等。元数据不仅对 JVM 有用,而且对应用也有用。
由类加载器在内存中创建并由执行引擎维护的所有数据称为类型为的二进制表示。
如果.class文件有错误或不符合某一格式,则进程终止。这意味着加载过程已经对加载的类格式及其字节码进行了一些验证。在下一个过程(称为类链接的过程)开始时,会进行更多的验证。
下面是加载过程的高级描述。它执行三项任务:
- 查找并读取
.class文件 - 根据内部数据结构将其解析到方法区域
- 用类元数据创建
java.lang.Class实例
类链接
根据 JVM 规范,链接解析加载类的引用,因此可以执行类的方法。
下面是链接过程的高级描述。它执行三项任务:
- 验证类或接口的二进制表示:
尽管 JVM 可以合理地预期.class文件是由 Java 编译器生成的,并且所有指令都满足该语言的约束和要求,但不能保证加载的文件是由已知的编译器实现或编译器生成的。
这就是为什么连接过程的第一步是验证。它确保类的二进制表示在结构上是正确的,这意味着:
- 方法区静态字段准备:
验证成功完成后,将在方法区域中创建接口或类(静态)变量,并将其初始化为其类型的默认值。其他类型的初始化,如程序员指定的显式赋值和静态初始化块,则延迟到称为类初始化的过程(参见“类初始化”部分)。
- 将符号引用分解为指向方法区域的具体引用:
如果加载的字节码引用其他方法、接口或类,则符号引用将解析为指向方法区域的具体引用,这由解析过程完成。如果引用的接口和类还没有加载,类加载器会找到它们并根据需要加载。
类初始化
根据 JVM 规范,初始化是通过执行类初始化方法来完成的。也就是说,当程序员定义的初始化(在静态块和静态赋值中)被执行时,除非该类已经在另一个类的请求下被初始化。
这个语句的最后一部分很重要,因为类可能被不同的(已经加载的)方法请求多次,而且 JVM 进程由不同的线程执行,并且可能并发地访问同一个类。因此,需要不同线程之间的协调(也称为同步),这使得 JVM 的实现变得非常复杂。
类实例化
这一步可能永远不会发生。从技术上讲,new操作符触发的实例化过程是执行的第一步。如果main(String[])方法(静态的)只使用其他类的静态方法,则不会发生实例化。这就是为什么将这个过程与执行分开是合理的。
此外,这项活动还有非常具体的任务:
- 为堆区域中的对象(其状态)分配内存
- 将实例字段初始化为默认值
- 为 Java 和本机方法创建线程栈
当第一个方法(不是构造器)准备好执行时,执行就开始了。对于每个应用线程,都会创建一个专用的运行时栈,其中每个方法调用都被捕获到栈帧中。例如,如果发生异常,我们在调用printStackTrace()方法时从当前栈帧获取数据。
方法执行
第一个应用线程(称为主线程)是在main(String[])方法开始执行时创建的。它可以创建其他应用线程。
执行引擎读取字节码,解释它们,并将二进制代码发送给微处理器执行。它还维护每个方法被调用的次数和频率的计数。如果计数超过某个阈值,执行引擎将使用一个编译器,称为实时(JIT)编译器,它将方法字节码编译为本机代码。这样,下次调用该方法时,就可以不用解释了。它大大提高了代码性能。
当前正在执行的指令和下一条指令的地址保存在程序计数器(PC)寄存器中。每个线程都有自己的专用 PC 寄存器。它还可以提高性能并跟踪执行情况。
垃圾收集
垃圾收集器(GC)运行一个进程,该进程标识不再被引用并且可以从内存中删除的对象。
有一个 Java 静态方法System.gc(),可以通过编程方式触发 GC,但不能保证立即执行。每个 GC 周期都会影响应用的性能,因此 JVM 必须在内存可用性和足够快地执行字节码的能力之间保持平衡。
应用终止
有几种方法可以通过编程方式终止应用(并停止或退出 JVM):
- 无错误状态代码的正常终止
- 由于未处理的异常而导致的异常终止
- 带或不带错误状态代码的程序强制退出
如果没有异常和无限循环,main(String[])方法通过一个return语句或在最后一个语句执行之后完成。一旦发生这种情况,主应用线程就会将控制流传递给 JVM,JVM 也停止执行。这就是幸福的结局,许多应用在现实生活中都享受到了这一点。我们的大多数例子,除了那些演示了异常或无限循环的例子外,也成功地退出了。
然而,Java 应用还有其他退出方式,其中一些方式也非常优雅,而另一些则不那么优雅。如果主应用线程创建了子线程,或者换句话说,程序员编写了生成其他线程的代码,那么即使优雅地退出也可能不容易。这完全取决于创建的子线程的类型。
如果其中任何一个是用户线程(默认值),那么 JVM 实例即使在主线程退出之后也会继续运行。只有在所有用户线程完成之后,JVM 实例才会停止。主线程可以请求子用户线程完成。但在退出之前,JVM 将继续运行。这意味着应用仍然在运行。
但是,如果所有子线程都是守护线程,或者没有子线程在运行,那么只要主应用线程退出,JVM 实例就会停止运行。
应用在异常情况下如何退出取决于代码设计。在讨论异常处理的最佳实践时,我们在第 4 章、“处理”中对此进行了讨论。如果线程捕获了main(String[])中try-catch块或类似高级方法中的所有异常,那么由应用(以及编写代码的程序员)决定如何最好地继续—尝试更改输入数据并重复生成异常的代码块,记录错误并继续,或者退出。
另一方面,如果异常保持未处理状态并传播到 JVM 代码中,则线程(发生异常的地方)停止执行并退出。接下来会发生什么,取决于线程的类型和其他一些条件。以下是四种可能的选择:
- 如果没有其他线程,JVM 将停止执行并返回错误代码和栈跟踪
- 如果包含未处理异常的线程不是主线程,则其他线程(如果存在)将继续运行
- 如果主线程抛出了未处理的异常,而子线程(如果存在)是守护进程,则它们也会退出
- 如果至少有一个用户子线程,JVM 将继续运行,直到所有用户线程退出
还有一些方法可以通过编程强制应用停止:
System.exit(0);Runtime.getRuntime().exit(0);Runtime.getRuntime().halt(0);
所有这些方法都会强制 JVM 停止执行任何线程,并以传入的状态代码作为参数退出(在我们的示例中为0):
- 零表示正常终止
- 非零值表示异常终止
如果 Java 命令是由某个脚本或另一个系统启动的,那么状态代码的值可以用于下一步决策的自动化。但这已经超出了应用和 Java 代码的范围。
前两种方法具有相同的功能,因为System.exit()就是这样实现的:
public static void exit(int status) {
Runtime.getRuntime().exit(status);
}
要查看 IDE 中的源代码,只需单击方法。
当某个线程调用Runtime或System类的exit()方法,或Runtime类的halt()方法时,JVM 退出,安全管理器允许退出或停止操作。exit()和halt()的区别在于halt()强制 JVM 立即退出,而exit()执行可以使用Runtime.addShutdownHook()方法设置的额外操作。但所有这些选项很少被主流程序员使用。
JVM 结构
JVM 结构可以用内存中的运行时数据结构和使用运行时数据的两个子系统(类加载器和执行引擎)来描述。
运行时数据区
JVM 内存的每个运行时数据区域都属于以下两个类别之一:
- 共享区域包括:
- 方法区:类元数据、静态字段、方法字节码
- 堆区:对象(状态)
- 专用于特定应用线程的非共享区域,包括:
- Java 栈:当前帧和调用方帧,每个帧保持 Java(非本机)方法调用的状态:
- 局部变量值
- 方法参数值
- 中间计算的操作数值(操作数栈)
- 方法返回值(如果有)
- PC 寄存器:下一条要执行的指令
- 本机方法栈:本机方法调用的状态
- Java 栈:当前帧和调用方帧,每个帧保持 Java(非本机)方法调用的状态:
我们已经讨论过,程序员在使用引用类型时必须小心,除非需要修改对象,否则不要修改对象本身。在多线程应用中,如果可以在线程之间传递对对象的引用,则必须格外小心,因为可能同时修改相同的数据。不过,从好的方面来看,这样一个共享区域可以而且经常被用作线程之间的通信方法
类加载器
类加载器执行以下三个功能:
- 读取
.class文件 - 填充方法区域
- 初始化程序员未初始化的静态字段
执行引擎
执行引擎执行以下操作:
- 实例化堆区域中的对象
- 使用程序员编写的初始化器初始化静态和实例字段
- 在 Java 栈中添加/删除帧
- 用下一条要执行的指令更新 PC 寄存器
- 维护本机方法栈
- 保持方法调用的计数并编译常用的方法调用
- 完成对象
- 运行垃圾收集
- 终止应用
垃圾收集
自动内存管理是 JVM 的一个重要方面,它使程序员不再需要以编程方式进行管理。在 Java 中,清理内存并允许其重用的过程称为垃圾收集。
响应能力、吞吐量和停止世界
GC 的有效性影响两个主要的应用特性–响应性和吞吐量:
- 响应性:这是通过应用对请求的响应速度(带来必要的数据)来衡量的;例如,网站返回页面的速度,或者桌面应用对事件的响应速度。响应时间越短,用户体验越好。
- 吞吐量:表示一个应用在一个时间单位内可以完成的工作量,例如一个 Web 应用可以服务多少个请求,或者数据库可以支持多少个事务。数字越大,应用可能产生的价值就越大,支持的用户请求也就越多。
同时,GC 需要移动数据,这在允许数据处理的情况下是不可能实现的,因为引用将发生变化。这就是为什么 GC 需要时不时地停止应用线程执行一段时间,称为停止世界。这些时间越长,GC 完成工作的速度就越快,应用冻结的持续时间也就越长,最终会变得足够大,从而影响应用的响应性和吞吐量。
幸运的是,可以使用 Java 命令选项优化 GC 行为,但这超出了本书的范围。相反,我们将集中在 GC 主要活动的高级视图上,检查堆中的对象并删除那些在任何线程栈中都没有引用的对象。
对象年龄和世代
基本的 GC 算法确定每个对象的年龄。术语年龄是指对象存活的收集周期数。
JVM 启动时,堆为空,分为三个部分:
- 新生代
- 老年代或永久代
- 用于容纳标准区域大小 50% 或更大的物体的巨大区域
新生代有三个方面:
- 伊甸
- 幸存者 0(S0)
- 幸存者 1(S1)
新创建的对象被放置在伊甸园中。当它充满时,一个小的 GC 过程开始。它删除未检索的和圆形的引用对象,并将其他对象移动到 S1 区域。在下一个小集合中,S0 和 S1 切换角色。参照对象从伊甸园和 S1 移动到 S0。
在每个小集合中,已达到某个年龄的对象都会被移动到老年代。这个算法的结果是,旧一代包含的对象比某个特定的年龄要老。这个地区比新生代大,正因为如此,这里的垃圾收集费用更高,而且不像新生代那样频繁。但它最终会被检查(经过几次小的收集)。将删除未引用的对象并对内存进行碎片整理。老年代的这种清洁被认为是一个主要的收集。
不可避免的停止世界
旧一代中的一些对象集合是同时完成的,而有些是使用“停止世界”停顿完成的。步骤包括:
- 初始标记:标记可能引用旧代对象的幸存区域(根区域)。这是通过“停止世界”停顿来完成的。
- 扫描:搜索幸存者区域,寻找旧世代的参考。这是在应用继续运行时并发完成的。
- 并发标记:标记整个堆上的活动对象。这是在应用继续运行时并发完成的。
- 备注:完成活动对象标记。这是通过“停止世界”停顿来完成的。
- 清理:计算活动对象和自由区域的年龄(使用“停止世界”)并将其返回到自由列表。这是同时进行的。
前面的序列可能会与新生代的撤离交织在一起,因为大多数物体都是短暂的,更频繁地扫描新生代更容易释放大量内存。还有一个混合阶段(当 G1 收集年轻人和老年人中已经标记为主要垃圾的区域时)和庞大的分配阶段(当大型物体被移动到庞大的区域或从庞大的区域撤离时)。
为了帮助 GC 调优,JVM 为垃圾收集器、堆大小和运行时编译器提供了依赖于平台的默认选择。但幸运的是,JVM 供应商一直在改进和调优 GC 过程,因此大多数应用都可以很好地使用默认的 GC 行为。
总结
在本章中,读者了解了如何使用 IDE 或命令行执行 Java 应用。现在您可以编写自己的应用,并以最适合给定环境的方式启动它们。了解 JVM 结构及其过程(类加载、链接、初始化、执行、垃圾收集和应用终止),可以更好地控制应用的执行,并透明地了解 JVM 的性能和当前状态。
在下一章中,我们将讨论并演示如何从 Java 应用管理数据库中的数据(插入、读取、更新和删除)。我们还将简要介绍 SQL 语言和基本数据库操作:如何连接到数据库,如何创建数据库结构,如何使用 SQL 编写数据库表达式,以及如何执行它们。
测验
-
选择所有正确的语句:
- IDE 执行 Java 代码而不编译它
- IDE 使用安装的 Java 来执行代码
- IDE 检查代码时不使用 Java 安装
- IDE 使用 Java 安装的编译器
-
选择所有正确的语句:
- 应用使用的所有类都必须列在类路径上
- 应用使用的所有类的位置都必须列在类路径上
- 如果类位于类路径上列出的文件夹中,编译器可以找到该类
- 主包的类不需要在类路径上列出
-
选择所有正确的语句:
- 应用使用的所有
.jar文件都必须列在类路径上 - 应用使用的所有
.jar文件的位置必须列在类路径上 - JVM 只能在类路径上列出的
.jar文件中找到类 - 每个类都可以有
main()方法
- 应用使用的所有
-
选择所有正确的语句:
- 每个有清单的
.jar文件都是可执行文件 - 如果
java命令使用了-jar选项,则忽略classpath选项 - 每个
.jar文件都有一个清单 - 可执行文件
.jar是带有清单的 ZIP 文件
- 每个有清单的
-
选择所有正确的语句:
- 类加载和链接可以在不同的类上并行工作
- 类加载将类移动到执行区域
- 类链接连接两个类
- 类链接使用内存引用
-
选择所有正确的语句:
- 类初始化为实例属性赋值
- 每次类被另一个类引用时,都会发生类初始化
- 类初始化为静态属性赋值
- 类初始化为
java.lang.Class实例提供数据
-
选择所有正确的语句:
- 类实例化可能永远不会发生
- 类实例化包括对象属性初始化
- 类实例化包括堆上的内存分配
- 类实例化包括执行构造器代码
-
选择所有正确的语句:
- 方法执行包括二进制代码生成
- 方法执行包括源代码编译
- 方法执行包括重用实时编译器生成的二进制代码
- 方法执行统计每个方法被调用的次数
-
选择所有正确的语句:
- 在调用
System.gc()方法后,垃圾收集立即开始 - 应用可以在有或没有错误代码的情况下终止
- 一旦抛出异常,应用就会退出
- 主线程是一个用户线程
- 在调用
-
选择所有正确的语句:
1. JVM 拥有跨所有线程共享的内存区域
2. JVM 没有跨线程共享的内存区域
3. 类元数据在所有线程之间共享
4. 方法参数值不在线程之间共享 -
选择所有正确的语句:
1. 类加载器填充方法区域
2. 类加载器在堆上分配内存
3. 类加载器写入.class文件
4. 类加载器解析方法引用 -
选择所有正确的语句:
1. 执行引擎在堆上分配内存
2. 执行引擎终止应用
3. 执行引擎运行垃圾收集
4. 执行引擎初始化程序员未初始化的静态字段 -
选择所有正确的语句:
1. 数据库每秒可支持的事务数是一种吞吐量度量
2. 当垃圾收集器暂停应用时,它被称为“停止一切”
3. 网站返回数据的速度有多慢是一个响应性指标
4. 垃圾收集器清除 CPU 队列中的作业 -
选择所有正确的语句:
1. 对象年龄是以创建后的秒数来衡量的
2. 对象越老,从内存中删除的可能性就越大
3. 清理老年代是大型收集
4. 将对象从新生代的一个区域移动到新生代的另一个区域是小型收集 -
选择所有正确的语句:
1. 垃圾收集器可以通过设置javac命令的参数进行调优
2. 垃圾收集器可以通过设置java命令的参数进行调优
3. 垃圾收集器使用自己的逻辑,不能基于设置的参数更改其行为
4. 清理老年代区域需要停止世界的停顿
十、管理数据库中的数据
本章解释并演示了如何使用 Java 应用管理(即,插入、读取、更新和删除)数据库中的数据。简要介绍了结构化查询语言(SQL)和数据库的基本操作,包括如何连接数据库、如何创建数据库结构、如何用 SQL 编写数据库表达式以及如何执行这些表达式。
本章将讨论以下主题:
- 创建数据库
- 创建数据库结构
- 连接到数据库
- 释放连接
- 对数据执行创建、读取、更新和删除(CRUD)操作
创建数据库
Java 数据库连接(JDBC)是一种 Java 功能,允许您访问和修改数据库中的数据。它受 JDBC API(包括java.sql、javax.sql和java.transaction.xa包)以及实现数据库访问接口的数据库特定类(称为数据库驱动程序)的支持,该接口由每个数据库供应商提供。
使用 JDBC 意味着编写 Java 代码,使用 JDBC API 的接口和类以及特定于数据库的驱动程序来管理数据库中的数据,该驱动程序知道如何与特定数据库建立连接。使用这个连接,应用就可以发出用 SQL 编写的请求。
当然,我们这里只指理解 SQL 的数据库。它们被称为关系型或表格型数据库管理系统(数据库管理系统),构成了目前使用的绝大多数数据库管理系统——尽管也使用了一些替代方法(例如导航数据库和 NoSQL)。
java.sql和javax.sql包包含在 Java 平台标准版(Java SE)中。javax.sql包包含支持语句池、分布式事务和行集的DataSource接口
创建数据库包括以下八个步骤:
- 按照供应商的说明安装数据库
- 创建数据库用户、数据库、模式、表、视图、存储过程以及支持应用的数据模型所必需的任何其他内容
- 向该应用添加对具有特定于数据库的驱动程序的
.jar文件的依赖关系 - 从应用连接到数据库
- 构造 SQL 语句
- 执行 SQL 语句
- 根据应用的需要使用执行结果
- 释放(即关闭)数据库连接和在该过程中打开的任何其他资源
步骤 1 到 3 仅在数据库设置期间和运行应用之前执行一次。应用根据需要重复执行步骤 4 到 8。实际上,步骤 5 到 7 可以在同一个数据库连接中重复多次。
对于我们的示例,我们将使用 PostgreSQL 数据库。您首先需要使用特定于数据库的说明自己执行步骤 1 到 3。要为演示创建数据库,我们使用以下命令:
create user student SUPERUSER;
create database learnjava owner student;
这些命令创建一个student用户,可以管理SUPERUSER数据库的所有方面,并使student用户成为learnjava数据库的所有者。我们将使用student用户访问和管理来自 Java 代码的数据。实际上,出于安全考虑,不允许应用创建或更改数据库表和数据库结构的其他方面。
此外,创建另一个名为纲要的逻辑层是一个很好的实践,它可以拥有自己的一组用户和权限。这样,可以隔离同一数据库中的多个模式,并且每个用户(其中一个是您的应用)只能访问某些模式。在企业级,通常的做法是为数据库模式创建同义词,以便任何应用都不能直接访问原始结构。然而,为了简单起见,我们在本书中不这样做。
创建数据库结构
创建数据库后,以下三条 SQL 语句将允许您创建和更改数据库结构。这是通过数据库实体完成的,例如表、函数或约束:
CREATE语句创建数据库实体ALTER语句更改数据库实体DROP语句删除数据库实体
还有各种 SQL 语句允许您查询每个数据库实体。这些语句是特定于数据库的,通常只在数据库控制台中使用。例如,在 PostgreSQL 控制台中,\d <table>可以用来描述一个表,而\dt列出了所有的表。有关详细信息,请参阅数据库文档
要创建表,可以执行以下 SQL 语句:
CREATE TABLE tablename ( column1 type1, column2 type2, ... );
表名、列名和可使用的值类型的限制取决于特定的数据库。下面是在 PostgreSQL 中创建person表的命令示例:
CREATE table person (
id SERIAL PRIMARY KEY,
first_name VARCHAR NOT NULL,
last_name VARCHAR NOT NULL,
dob DATE NOT NULL );
SERIAL关键字表示该字段是一个连续整数,每次创建新记录时数据库都会生成该整数。生成顺序整数的其他选项有SMALLSERIAL和BIGSERIAL;它们的大小和可能值的范围不同:
SMALLSERIAL: 2 bytes, range from 1 to 32,767
SERIAL: 4 bytes, range from 1 to 2,147,483,647
BIGSERIAL: 8 bytes, range from 1 to 922,337,2036,854,775,807
PRIMARY_KEY关键字表示这将是记录的唯一标识符,很可能用于搜索。数据库为每个主键创建一个索引,以加快搜索过程。索引是一种数据结构,有助于加速表中的数据搜索,而不必检查每个表记录。索引可以包含一个表的一列或多列。如果您请求表的描述,您将看到所有现有的索引。
或者,我们可以使用first_name、last_name和dob的组合来制作复合PRIMARY KEY关键字:
CREATE table person (
first_name VARCHAR NOT NULL,
last_name VARCHAR NOT NULL,
dob DATE NOT NULL,
PRIMARY KEY (first_name, last_name, dob) );
然而,有可能有两个人将有相同的名字,并在同一天出生。
NOT NULL关键字对字段施加约束:不能为空。每次试图用空字段创建新记录或从现有记录中删除值时,数据库都会引发错误。我们没有设置VARCHAR类型的列的大小,因此允许这些列存储任何长度的字符串值。
与这样一个记录匹配的 Java 对象可以用下面的Person类来表示:
public class Person {
private int id;
private LocalDate dob;
private String firstName, lastName;
public Person(String firstName, String lastName, LocalDate dob) {
if (dob == null) {
throw new RuntimeException("Date of birth cannot be null");
}
this.dob = dob;
this.firstName = firstName == null ? "" : firstName;
this.lastName = lastName == null ? "" : lastName;
}
public Person(int id, String firstName,
String lastName, LocalDate dob) {
this(firstName, lastName, dob);
this.id = id;
}
public int getId() { return id; }
public LocalDate getDob() { return dob; }
public String getFirstName() { return firstName;}
public String getLastName() { return lastName; }
}
您可能已经注意到,Person类中有两个构造器:有和没有id,我们将使用接受id的构造器基于现有记录构造一个对象,而另一个构造器将用于在插入新记录之前创建一个对象。
创建后,可以使用DROP命令删除表:
DROP table person;
也可以使用ALTERSQL 命令更改现有表;例如,我们可以添加列地址:
ALTER table person add column address VARCHAR;
如果您不确定该列是否已经存在,可以添加IF EXISTS或IF NOT EXISTS:
ALTER table person add column IF NOT EXISTS address VARCHAR;
但是,这种可能性仅在 PostgreSQL 9.6 及更高版本中存在。
在数据库表创建过程中需要注意的另一个重要问题是是否必须添加另一个索引(除了PRIMARY KEY)。例如,我们可以通过添加以下索引来允许对名字和姓氏进行不区分大小写的搜索:
CREATE index idx_names on person ((lower(first_name), lower(last_name));
如果搜索速度提高,我们会保留索引;如果没有,可以按如下方式删除索引:
DROP index idx_names;
我们删除它是因为索引有额外写入和存储空间的开销。
如果需要,我们还可以从表中删除列,如下所示:
ALTER table person DROP column address;
在我们的示例中,我们遵循 PostgreSQL 的命名约定。如果您使用不同的数据库,我们建议您查找它的命名约定并遵循它,以便您创建的名称与自动创建的名称对齐。
连接到数据库
到目前为止,我们已经使用了一个控制台来执行 SQL 语句。同样的语句也可以使用 JDBC API 从 Java 代码中执行。但是表只创建一次,所以编写一次性执行的程序没有多大意义。
然而,数据管理是另一回事。因此,从现在开始,我们将使用 Java 代码来操作数据库中的数据。为此,我们首先需要将以下依赖项添加到pom.xml文件中:
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.2.2</version>
</dependency>
这与我们安装的 PostgreSQL 9.6 版本相匹配。现在我们可以从 Java 代码创建一个数据库连接,如下所示:
String URL = "jdbc:postgresql://localhost/learnjava";
Properties prop = new Properties();
prop.put( "user", "student" );
// prop.put( "password", "secretPass123" );
try {
Connection conn = DriverManager.getConnection(URL, prop);
} catch (SQLException ex) {
ex.printStackTrace();
}
前面的代码只是如何使用java.sql.DriverManger类创建连接的示例。prop.put( "password", "secretPass123" )语句演示如何使用java.util.Properties类为连接提供密码。但是,我们在创建student用户时没有设置密码,所以我们不需要它
许多其他值可以传递给配置连接行为的DriverManager。传入属性的键的名称对于所有主要数据库都是相同的,但其中一些是特定于数据库的。因此,请阅读数据库供应商文档以了解更多详细信息。
或者,对于只通过user和password的情况,我们可以使用重载的DriverManager.getConnection(String url, String user, String password)版本。对密码进行加密是一种很好的做法。我们不打算演示如何做到这一点,但在互联网上有大量的指南,你可以参考。
另一种连接数据库的方法是使用javax.sql.DataSource接口。它的实现包含在与数据库驱动程序相同的.jar文件中。在PostgreSQL的情况下,有两个类实现DataSource接口:
org.postgresql.ds.PGSimpleDataSourceorg.postgresq l.ds.PGConnectionPoolDataSource
我们可以用这些类来代替DriverManager。下面的代码是使用PGSimpleDataSource类创建数据库连接的示例:
PGSimpleDataSource source = new PGSimpleDataSource();
source.setServerName("localhost");
source.setDatabaseName("learnjava");
source.setUser("student");
//source.setPassword("password");
source.setLoginTimeout(10);
try {
Connection conn = source.getConnection();
} catch (SQLException ex) {
ex.printStackTrace();
}
使用PGConnectionPoolDataSource类可以在内存中创建Connection对象池,如下所示:
PGConnectionPoolDataSource source = new PGConnectionPoolDataSource();
source.setServerName("localhost");
source.setDatabaseName("learnjava");
source.setUser("student");
//source.setPassword("password");
source.setLoginTimeout(10);
try {
PooledConnection conn = source.getPooledConnection();
Set<Connection> pool = new HashSet<>();
for(int i = 0; i < 10; i++){
pool.add(conn.getConnection())
}
} catch (SQLException ex) {
ex.printStackTrace();
}
这是首选方法,因为创建一个Connection对象需要时间。池允许您提前完成,然后在需要时重用所创建的对象。不再需要连接后,可以将其返回到池中并重新使用。池大小和其他参数可以在配置文件中设置(例如 PostgreSQL 的postgresql.conf)。
但是,您不需要自己管理连接池。有几种成熟的框架可以为您做到这一点,比如 HikariCP、Vibur 和公共 DBCP——可靠,使用方便。
无论我们选择哪种方法来创建数据库连接,我们都将把它隐藏在getConnection()方法中,并以相同的方式在所有代码示例中使用它。在获取了Connection类的对象之后,我们现在可以访问数据库来添加、读取、删除或修改存储的数据。
释放连接
保持数据库连接处于活动状态需要大量资源(如内存和 CPU),因此,关闭连接并在不再需要时释放分配的资源是一个好主意。在池的情况下,Connection对象在关闭时返回池并消耗更少的资源。
在 Java7 之前,通过调用finally块中的close()方法关闭连接:
try {
Connection conn = getConnection();
//use object conn here
} finally {
if(conn != null){
try {
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
无论是否抛出try块内的异常,finally块内的代码始终执行。然而,自 Java7 以来,资源尝试构造也在实现java.lang.AutoCloseable或java.io.Closeable接口的任何对象上执行任务。由于java.sql.Connection对象实现了AutoCloseable接口,我们可以将前面的代码段重写如下:
try (Connection conn = getConnection()) {
//use object conn here
} catch(SQLException ex) {
ex.printStackTrace();
}
因为AutoCloseable资源抛出java.sql.SQLException,所以需要使用catch子句。
操作数据
有四种 SQL 语句可以读取或操作数据库中的数据:
INSERT语句向数据库添加数据SELECT语句从数据库中读取数据UPDATE语句更改数据库中的数据DELETE语句从数据库中删除数据
可以在前面的语句中添加一个或多个不同的子句,以标识所请求的数据(例如WHERE子句)和返回结果的顺序(例如ORDER子句)。
JDBC 连接由java.sql.Connection表示。除此之外,它还具有创建三种类型的对象所需的方法,这些对象允许您执行为数据库端提供不同功能的 SQL 语句:
java.sql.Statement:这只是将语句发送到数据库服务器执行java.sql.PreparedStatement:将具有特定执行路径的语句缓存在数据库服务器上,允许使用不同的参数高效地执行多次java.sql.CallableStatement:执行数据库中的存储过程
在本节中,我们将回顾如何在 Java 代码中实现这一点。最佳实践是在以编程方式使用 SQL 语句之前,在数据库控制台中测试它。
INSERT语句
INSERT语句在数据库中创建(填充)数据,格式如下:
INSERT into table_name (column1, column2, column3,...)
values (value1, value2, value3,...);
或者,当需要添加多个记录时,可以使用以下格式:
INSERT into table_name (column1, column2, column3,...)
values (value1, value2, value3,... ),
(value21, value22, value23,...),
...;
SELECT语句
SELECT语句的格式如下:
SELECT column_name, column_name FROM table_name
WHERE some_column = some_value;
或者,当需要选择所有列时,可以使用以下格式:
SELECT * from table_name WHERE some_column=some_value;
WHERE条款更一般的定义如下:
WHERE column_name operator value
Operator:
= Equal
<> Not equal. In some versions of SQL, !=
> Greater than
< Less than
>= Greater than or equal
<= Less than or equal IN Specifies multiple possible values for a column
LIKE Specifies the search pattern
BETWEEN Specifies the inclusive range of values in a column
构造的column_name运算符值可以使用AND和OR逻辑运算符组合,并用括号( )分组。
例如,下面的方法从person表中获取所有名字值(用空格字符分隔):
String selectAllFirstNames() {
String result = "";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
ResultSet rs = st.executeQuery("select first_name from person");
while (rs.next()) {
result += rs.getString(1) + " ";
}
} catch (SQLException ex) {
ex.printStackTrace();
}
return result;
}
ResultSet接口的getString(int position)方法从1位置(在SELECT语句的列列表中的第一个)提取String值。所有原始类型都有类似的获取器:getInt(int position)、getByte(int position)等等。
也可以使用列名从ResultSet对象中提取值。在我们的例子中,它将是getString("first_name")。当SELECT语句如下时,这种获取值的方法特别有用:
select * from person;
但是,请记住,使用列名从ResultSet对象提取值的效率较低。但性能上的差异非常小,只有在多次操作时才变得重要。只有实际的测量和测试过程才能判断这种差异对您的应用是否重要。按列名提取值特别有吸引力,因为它提供了更好的代码可读性,这在应用维护期间从长远来看是值得的。
在ResultSet接口中还有许多其他有用的方法。如果您的应用从数据库读取数据,我们强烈建议您阅读SELECT语句和ResultSet接口的官方文档。
UPDATE语句
数据可以通过UPDATE语句进行更改,如下所示:
UPDATE table_name SET column1=value1,column2=value2,... WHERE clause;
我们可以使用此语句将其中一条记录中的名字从原始值John更改为新值Jim:
update person set first_name = 'Jim' where last_name = 'Adams';
没有WHERE子句,表的所有记录都会受到影响。
DELETE语句
要从表中删除记录,请使用DELETE语句,如下所示:
DELETE FROM table_name WHERE clause;
如果没有WHERE子句,则删除表中的所有记录。对于person表,我们可以使用以下 SQL 语句删除所有记录:
delete from person;
此外,此语句仅删除名为Jim的记录:
delete from person where first_name = 'Jim';
使用Statement
java.sql.Statement接口提供了以下执行 SQL 语句的方法:
-
boolean execute(String sql):如果被执行的语句返回可以通过java.sql.Statement接口的ResultSet getResultSet()方法检索的数据(在java.sql.ResultSet对象内部),则返回true。或者,如果执行的语句不返回数据(对于INSERT语句或UPDATE语句),则返回false,随后调用java.sql.Statement接口的int getUpdateCount()方法返回受影响的行数。 -
ResultSet executeQuery(String sql):以java.sql.ResultSet对象的形式返回数据(此方法使用的 SQL 语句通常是SELECT语句)。java.sql.Statement接口的ResultSet getResultSet()方法不返回数据,java.sql.Statement接口的int getUpdateCount()方法返回-1。 -
int executeUpdate(String sql):返回受影响的行数(执行的 SQL 语句应该是UPDATE语句或DELETE语句)。相同的号码由java.sql.Statement接口的int getUpdateCount()方法返回;后续调用java.sql.Statement接口的ResultSet getResultSet()方法返回null。
我们将演示这三种方法是如何在每个语句上工作的:INSERT、SELECT、UPDATE和DELETE。
execute(String sql)方法
让我们尝试执行每个语句;我们将从INSERT语句开始:
String sql = "insert into person (first_name, last_name, dob) " +
"values ('Bill', 'Grey', '1980-01-27')";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
System.out.println(st.execute(sql)); //prints: false
System.out.println(st.getResultSet() == null); //prints: true
System.out.println(st.getUpdateCount()); //prints: 1
} catch (SQLException ex) {
ex.printStackTrace();
}
System.out.println(selectAllFirstNames()); //prints: Bill
前面的代码向person表中添加了一条新记录。返回的false值表示执行语句没有返回数据,这就是getResultSet()方法返回null的原因。但是getUpdateCount()方法返回1,因为一条记录受到影响(添加)。selectAllFirstNames()方法证明插入了预期的记录。
现在执行SELECT语句,如下所示:
String sql = "select first_name from person";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
System.out.println(st.execute(sql)); //prints: true
ResultSet rs = st.getResultSet();
System.out.println(rs == null); //prints: false
System.out.println(st.getUpdateCount()); //prints: -1
while (rs.next()) {
System.out.println(rs.getString(1) + " "); //prints: Bill
}
} catch (SQLException ex) {
ex.printStackTrace();
}
前面的代码从person表中选择所有的名字。返回的true值表示有被执行语句返回的数据。这就是为什么getResultSet()方法不返回null,而是返回ResultSet对象。getUpdateCount()方法返回-1,因为没有记录受到影响(更改)。由于person表中只有一条记录,ResultSet对象只包含一个结果,rs.getString(1)返回Bill。
下面的代码使用UPDATE语句将person表的所有记录中的名字改为Adam:
String sql = "update person set first_name = 'Adam'";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
System.out.println(st.execute(sql)); //prints: false
System.out.println(st.getResultSet() == null); //prints: true
System.out.println(st.getUpdateCount()); //prints: 1
} catch (SQLException ex) {
ex.printStackTrace();
}
System.out.println(selectAllFirstNames()); //prints: Adam
在前面的代码中,返回的false值表示执行语句没有返回数据。这就是getResultSet()方法返回null的原因。但是getUpdateCount()方法返回1,因为person表中只有一条记录,一条记录受到了影响(更改)。selectAllFirstNames()方法证明对该记录进行了预期的更改。
下面的DELETE语句执行从person表中删除所有记录:
String sql = "delete from person";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
System.out.println(st.execute(sql)); //prints: false
System.out.println(st.getResultSet() == null); //prints: true
System.out.println(st.getUpdateCount()); //prints: 1
} catch (SQLException ex) {
ex.printStackTrace();
}
System.out.println(selectAllFirstNames()); //prints:
在前面的代码中,返回的false值表示执行语句没有返回数据。这就是为什么getResultSet()方法返回null。但是getUpdateCount()方法返回1,因为person表中只有一条记录,一条记录被影响(删除)。selectAllFirstNames()方法证明person表中没有记录。
executeQuery(String sql)方法
在本节中,我们将尝试执行execute(String sql)方法一节中演示execute()方法时使用的相同语句(作为查询),我们将从INSERT语句开始,如下所示:
String sql = "insert into person (first_name, last_name, dob) " +
"values ('Bill', 'Grey', '1980-01-27')";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
st.executeQuery(sql); //PSQLException
} catch (SQLException ex) {
ex.printStackTrace(); //prints: stack trace
}
System.out.println(selectAllFirstNames()); //prints: Bill
前面的代码生成了一个关于No results were returned by the query消息的异常,因为executeQuery()方法希望执行SELECT语句。然而,selectAllFirstNames()方法证明插入了预期的记录
现在执行SELECT语句,如下所示:
String sql = "select first_name from person";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
ResultSet rs1 = st.executeQuery(sql);
System.out.println(rs1 == null); //prints: false
ResultSet rs2 = st.getResultSet();
System.out.println(rs2 == null); //prints: false
System.out.println(st.getUpdateCount()); //prints: -1
while (rs1.next()) {
System.out.println(rs1.getString(1)); //prints: Bill
}
while (rs2.next()) {
System.out.println(rs2.getString(1)); //prints:
}
} catch (SQLException ex) {
ex.printStackTrace();
}
前面的代码从person表中选择所有的名字。返回的false值表示executeQuery()总是返回ResultSet对象,即使person表中没有记录。如您所见,从所执行语句获得结果似乎有两种方法。但是,rs2对象没有数据,因此,在使用executeQuery()方法时,请确保从ResultSet对象获取数据。
现在让我们尝试执行一个UPDATE语句,如下所示:
String sql = "update person set first_name = 'Adam'";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
st.executeQuery(sql); //PSQLException
} catch (SQLException ex) {
ex.printStackTrace(); //prints: stack trace
}
System.out.println(selectAllFirstNames()); //prints: Adam
前面的代码生成了一个与No results were returned by the query消息相关的异常,因为executeQuery()方法希望执行SELECT语句。然而,selectAllFirstNames()方法证明预期的更改是对记录进行的
在执行DELETE语句时,我们将得到相同的异常:
String sql = "delete from person";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
st.executeQuery(sql); //PSQLException
} catch (SQLException ex) {
ex.printStackTrace(); //prints: stack trace
}
System.out.println(selectAllFirstNames()); //prints:
尽管如此,selectAllFirstNames()方法证明了person表的所有记录都被删除了。
我们的演示表明,executeQuery()应该只用于SELECT语句。executeQuery()方法的优点是,当用于SELECT语句时,即使没有选择数据,它也返回一个非空的ResultSet对象,这简化了代码,因为不需要检查null的返回值。
executeUpdate(String sql)方法
我们将用INSERT语句开始演示executeUpdate()方法:
String sql = "insert into person (first_name, last_name, dob) " +
"values ('Bill', 'Grey', '1980-01-27')";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
System.out.println(st.executeUpdate(sql)); //prints: 1
System.out.println(st.getResultSet()); //prints: null
System.out.println(st.getUpdateCount()); //prints: 1
} catch (SQLException ex) {
ex.printStackTrace();
}
System.out.println(selectAllFirstNames()); //prints: Bill
如您所见,executeUpdate()方法返回受影响(在本例中是插入的)行数。相同的数字返回int getUpdateCount()方法,ResultSet getResultSet()方法返回null,selectAllFirstNames()方法证明插入了期望的记录。
executeUpdate()方法不能用于执行SELECT语句:
String sql = "select first_name from person";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
st.executeUpdate(sql); //PSQLException
} catch (SQLException ex) {
ex.printStackTrace(); //prints: stack trace
}
异常的消息是A result was returned when none was expected。
另一方面,UPDATE语句通过executeUpdate()方法执行得很好:
String sql = "update person set first_name = 'Adam'";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
System.out.println(st.executeUpdate(sql)); //prints: 1
System.out.println(st.getResultSet()); //prints: null
System.out.println(st.getUpdateCount()); //prints: 1
} catch (SQLException ex) {
ex.printStackTrace();
}
System.out.println(selectAllFirstNames()); //prints: Adam
executeUpdate()方法返回受影响(在本例中是更新的)行数。相同的数字返回int getUpdateCount()方法,而ResultSet getResultSet()方法返回null。selectAllFirstNames()方法证明预期记录已更新。
DELETE语句产生类似的结果:
String sql = "delete from person";
Connection conn = getConnection();
try (conn; Statement st = conn.createStatement()) {
System.out.println(st.executeUpdate(sql)); //prints: 1
System.out.println(st.getResultSet()); //prints: null
System.out.println(st.getUpdateCount()); //prints: 1
} catch (SQLException ex) {
ex.printStackTrace();
}
System.out.println(selectAllFirstNames()); //prints:
现在,您可能已经意识到,executeUpdate()方法更适合于INSERT、UPDATE和DELETE语句。
使用PreparedStatement
PreparedStatement是Statement接口的子接口。这意味着它可以在使用Statement接口的任何地方使用。不同之处在于PreparedStatement被缓存在数据库中,而不是每次被调用时都被编译。这样,它就可以针对不同的输入值高效地执行多次。与Statement类似,它可以通过prepareStatement()方法使用相同的Connection对象创建。
由于同一条 SQL 语句可以用于创建Statement和PreparedStatement,所以对于任何被多次调用的 SQL 语句,最好使用PreparedStatement,因为它比数据库端的Statement接口性能更好。为此,我们只需更改前面代码示例中的这两行:
try (conn; Statement st = conn.createStatement()) {
ResultSet rs = st.executeQuery(sql);
相反,我们可以使用PreparedStatement类,如下所示:
try (conn; PreparedStatement st = conn.prepareStatement(sql)) {
ResultSet rs = st.executeQuery();
要创建带参数的PreparedStatement类,可以用问号符号(?替换输入值;例如,我们可以创建以下方法:
List<Person> selectPersonsByFirstName(String searchName) {
List<Person> list = new ArrayList<>();
Connection conn = getConnection();
String sql = "select * from person where first_name = ?";
try (conn; PreparedStatement st = conn.prepareStatement(sql)) {
st.setString(1, searchName);
ResultSet rs = st.executeQuery();
while (rs.next()) {
list.add(new Person(rs.getInt("id"),
rs.getString("first_name"),
rs.getString("last_name"),
rs.getDate("dob").toLocalDate()));
}
} catch (SQLException ex) {
ex.printStackTrace();
}
return list;
}
数据库将PreparedStatement类编译为模板并存储它而不执行。然后,当应用稍后使用它时,参数值被传递给模板,模板可以立即执行,而无需编译开销,因为它已经完成了。
预备语句的另一个优点是可以更好地防止 SQL 注入攻击,因为值是使用不同的协议传入的,并且模板不基于外部输入。
如果准备好的语句只使用一次,它可能比常规语句慢,但差别可以忽略不计。如果有疑问,请测试性能,看看它是否适合您的应用—提高安全性是值得的
使用CallableStatement
CallableStatement接口(扩展了PreparedStatement接口)可以用来执行存储过程,尽管有些数据库允许您使用Statement或PreparedStatement接口调用存储过程。CallableStatement对象是通过prepareCall()方法创建的,可以有三种类型的参数:
IN输入值OUT结果IN OUT输入或输出值
IN参数的设置方式与PreparedStatement参数相同,而OUT参数必须通过CallableStatement的registerOutParameter()方法注册
值得注意的是,以编程方式从 Java 执行存储过程是标准化程度最低的领域之一。例如,PostgreSQL 不直接支持存储过程,但它们可以作为函数调用,为此,通过将OUT参数解释为返回值,对其进行了修改。另一方面,Oracle 也允许OUT参数作为函数。
这就是为什么数据库函数和存储过程之间的以下差异只能作为一般准则,而不能作为正式定义:
- 函数有返回值,但不允许使用
OUT参数(某些数据库除外),可以在 SQL 语句中使用。 - 存储过程没有返回值(某些数据库除外);它允许使用
OUT参数(对于大多数数据库),并且可以使用 JDBCCallableStatement接口执行。
您可以参考数据库文档来了解如何执行存储过程
由于存储过程是在数据库服务器上编译和存储的,CallableStatement的execute()方法对同一条 SQL 语句的性能优于Statement或PreparedStatement接口的相应方法。这就是为什么很多 Java 代码有时会被一个或多个存储过程(甚至包括业务逻辑)所取代的原因之一。然而,并不是每个案例和问题都有正确的答案,因此我们将避免提出具体的建议,只是重复一个熟悉的咒语,即测试的价值和您正在编写的代码的清晰性。
例如,让我们调用 PostgreSQL 安装附带的replace(string origText, from substr1, to substr2)函数。它搜索第一个参数(string origText),并使用第三个参数(string substr2提供的字符串替换其中与第二个参数(from substr1)匹配的所有子字符串。以下 Java 方法使用CallableStatement执行此函数:
String replace(String origText, String substr1, String substr2) {
String result = "";
String sql = "{ ? = call replace(?, ?, ? ) }";
Connection conn = getConnection();
try (conn; CallableStatement st = conn.prepareCall(sql)) {
st.registerOutParameter(1, Types.VARCHAR);
st.setString(2, origText);
st.setString(3, substr1);
st.setString(4, substr2);
st.execute();
result = st.getString(1);
} catch (Exception ex){
ex.printStackTrace();
}
return result;
}
现在我们可以如下调用此方法:
String result = replace("That is original text",
"original text", "the result");
System.out.println(result); //prints: That is the result
一个存储过程可以完全没有任何参数,可以只使用IN参数,也可以只使用OUT参数,或者两者都使用。结果可以是一个或多个值,也可以是一个ResultSet对象。您可以在数据库文档中找到用于创建函数的 SQL 语法
总结
在本章中,我们讨论并演示了如何在 Java 应用中填充、读取、更新和删除数据库中的数据。对 SQL 语言的简短介绍描述了如何使用Statement、PreparedStatement和CallableStatement创建数据库及其结构、如何修改数据库以及如何执行 SQL 语句。
在下一章中,我们将描述和讨论最流行的网络协议,演示如何使用它们,以及如何使用最新的 Java HTTP 客户端 API 实现客户端-服务器通信。所回顾的协议包括基于 TCP、UDP 和 URL 的通信协议的 Java 实现
测验
-
选择所有正确的语句:
- JDBC 代表 Java 数据库通信。
- JDBC API 包括
java.db包。 - JDBC API 随 Java 安装而来。
- JDBC API 包括所有主要 DBMSE 的驱动程序。
-
选择所有正确的语句:
- 可以使用
CREATE语句创建数据库表。 - 可以使用
UPDATE语句更改数据库表。 - 可以使用
DELETE语句删除数据库表。 - 每个数据库列都可以有一个索引。
- 可以使用
-
选择所有正确的语句:
- 要连接到数据库,可以使用
Connect类。 - 必须关闭每个数据库连接。
- 同一数据库连接可用于许多操作。
- 可以合并数据库连接。
- 要连接到数据库,可以使用
-
选择所有正确的语句:
- 可以使用资源尝试结构自动关闭数据库连接。
- 可以使用
finally块构造关闭数据库连接。 - 可以使用
catch块关闭数据库连接。 - 一个数据库连接可以在没有
try块的情况下关闭。
-
选择所有正确的语句:
INSERT语句包含一个表名。INSERT语句包括列名。INSERT语句包含值。INSERT语句包含约束。
-
选择所有正确的语句:
SELECT语句必须包含表名。SELECT语句必须包含列名。SELECT语句必须包含WHERE子句。SELECT语句可以包括ORDER子句。
-
选择所有正确的语句:
UPDATE语句必须包含表名。UPDATE语句必须包含列名。UPDATE语句可以包括WHERE子句。UPDATE语句可以包括ORDER子句。
-
选择所有正确的语句:
DELETE语句必须包含表名。DELETE语句必须包含列名。DELETE语句可以包括WHERE子句。DELETE语句可以包括ORDER子句。
-
选择
Statement接口的execute()方法的所有正确语句:- 它接收 SQL 语句。
- 它返回一个
ResultSet对象。 - 调用
execute()后,Statement对象可能返回数据。 - 调用
execute()后,Statement对象可能返回受影响的记录数
-
选择
Statement接口的executeQuery()方法的所有正确语句:
1. 它接收 SQL 语句。
2. 它返回一个ResultSet对象。
3. 调用executeQuery()后,Statement对象可能返回数据。
4. 调用executeQuery()后,Statement对象可能返回受影响的记录数。 -
选择接口
Statement的executeUpdate()方法的所有正确语句:
1. 它接收 SQL 语句。
2. 它返回一个ResultSet对象。
3. 调用executeUpdate()后,Statement对象可能返回数据。
4.Statement对象返回调用executeUpdate()后受影响的记录数。 -
选择所有关于
PreparedStatement接口的正确语句:
1. 它扩展自Statement。
2. 类型为PreparedStatement的对象是通过prepareStatement()方法创建的。
3. 它总是比Statement更有效。
4. 它导致数据库中的模板只创建一次。 -
选择所有关于
CallableStatement接口的正确语句:
1. 它扩展自PreparedStatement。
2. 类型为CallableStatement的对象是通过prepareCall()方法创建的。
3. 它总是比PreparedStatement更有效。
4. 它导致数据库中的模板只创建一次。
十一、网络编程
在本章中,我们将描述和讨论最流行的网络协议——用户数据报协议(UDP)、传输控制协议(TCP)、超文本传输协议(HTTP)和 WebSocket——以及来自 Java 类库(JCL)的支持。我们将演示如何使用这些协议以及如何用 Java 代码实现客户端——服务器通信。我们还将回顾基于统一资源定位器(URL)的通信和最新的 Java HTTP 客户端 API。
本章将讨论以下主题:
- 网络协议
- 基于 UDP 的通信
- 基于 TCP 的通信
- UDP 与 TCP 协议
- 基于 URL 的通信
- 使用 HTTP 2 客户端 API
网络协议
网络编程是一个广阔的领域。互联网协议(IP)套件由四层组成,每层都有十几个协议:
- 链路层:客户端物理连接到主机时使用的一组协议,三个核心协议包括地址解析协议(ARP)、反向地址解析协议(RARP),以及邻居发现协议(NDP)。
- 互联网层:一组由 IP 地址指定的用于将网络包从发起主机传输到目的主机的互联方法、协议和规范。这一层的核心协议是互联网协议版本 4(IPv4)和互联网协议版本 6(IPv6),IPv6 指定了一种新的数据包格式,并为点式 IP 地址分配 128 位,而 IPv4 是 32 位。IPv4 地址的一个例子是
10011010.00010111.11111110.00010001,其结果是 IP 地址为154.23.254.17。 - 传输层:一组主机对主机的通信服务。它包括 TCP,也称为 TCP/IP 协议和 UDP(我们稍后将讨论);这一组中的其他协议有数据报拥塞控制协议(DCCP)和流控制传输协议(SCTP)。
- 应用层:通信网络中主机使用的一组协议和接口方法。包括 Telnet、文件传输协议(FTP)、域名系统(DNS)、简单邮件传输协议(SMTP),轻量级目录访问协议(LDAP)、超文本传输协议(HTTP)、超文本传输协议安全(HTTPS)、安全外壳(SSH)。
链路层是最底层;它由互联网层使用,而互联网层又由传输层使用。然后,应用层使用该传输层来支持协议实现。
出于安全原因,Java 不提供对链路层和互联网层协议的访问。这意味着 Java 不允许您创建自定义传输协议,例如,作为 TCP/IP 的替代方案。因此,在本章中,我们将只回顾传输层(TCP 和 UDP)和应用层(HTTP)的协议。我们将解释并演示 Java 如何支持它们,以及 Java 应用如何利用这种支持。
Java 用java.net包的类支持 TCP 和 UDP 协议,而 HTTP 协议可以用java.net.http包的类在 Java 应用中实现(这是 Java11 引入的)。
TCP 和 UDP 协议都可以使用套接字在 Java 中实现。套接字由 IP 地址和端口号的组合标识,它们表示两个应用之间的连接。
基于 UDP 的通信
UDP 协议是由 David P. Reed 在 1980 年设计的。它允许应用使用简单的无连接通信模型发送名为数据报的消息,并使用最小的协议机制(如校验和)来保证数据完整性。它没有握手对话框,因此不能保证消息传递或保持消息的顺序。它适用于丢弃消息或混淆顺序而不是等待重传的情况。
数据报由java.net.DatagramPacket类表示。此类的对象可以使用六个构造器中的一个来创建;以下两个构造器是最常用的:
DatagramPacket(byte[] buffer, int length):此构造器创建一个数据报包,用于接收数据包;buffer保存传入的数据报,length是要读取的字节数。DatagramPacket(byte[] buffer, int length, InetAddress address, int port):创建一个数据报数据包,用于发送数据包;buffer保存数据包数据,length为数据包长度,address保存目的 IP 地址,port为目的端口号。
一旦构建,DatagramPacket对象公开了以下方法,这些方法可用于从对象中提取数据或设置/获取其属性:
void setAddress(InetAddress iaddr):设置目的 IP 地址。InetAddress getAddress():返回目的地或源 IP 地址。void setData(byte[] buf):设置数据缓冲区。void setData(byte[] buf, int offset, int length):设置数据缓冲区、数据偏移量、长度。void setLength(int length):设置包的长度。byte[] getData():返回数据缓冲区int getLength():返回要发送或接收的数据包的长度。int getOffset():返回要发送或接收的数据的偏移量。void setPort(int port):设置目的端口号。int getPort():返回发送或接收数据的端口号。
一旦创建了一个DatagramPacket对象,就可以使用DatagramSocket类来发送或接收它,该类表示用于发送和接收数据包的无连接套接字。这个类的对象可以使用六个构造器中的一个来创建;以下三个构造器是最常用的:
DatagramSocket():创建一个数据报套接字并将其绑定到本地主机上的任何可用端口。它通常用于创建发送套接字,因为目标地址(和端口)可以在包内设置(参见前面的DatagramPacket构造器和方法)。DatagramSocket(int port):创建一个数据报套接字,绑定到本地主机的指定端口。当任何本地机器地址(称为通配符地址)足够好时,它用于创建一个接收套接字。DatagramSocket(int port, InetAddress address):创建一个数据报套接字,绑定到指定的端口和指定的本地地址,本地端口必须在0和65535之间。它用于在需要绑定特定的本地计算机地址时创建接收套接字。
DatagramSocket对象的以下两种方法最常用于发送和接收消息(或包):
void send(DatagramPacket p):发送指定的数据包。void receive(DatagramPacket p):通过用接收到的数据填充指定的DatagramPacket对象的缓冲区来接收数据包。指定的DatagramPacket对象还包含发送方的 IP 地址和发送方机器上的端口号。
让我们看一个代码示例;下面是接收到消息后退出的 UDP 消息接收器:
public class UdpReceiver {
public static void main(String[] args){
try(DatagramSocket ds = new DatagramSocket(3333)){
DatagramPacket dp = new DatagramPacket(new byte[16], 16);
ds.receive(dp);
for(byte b: dp.getData()){
System.out.print(Character.toString(b));
}
} catch (Exception ex){
ex.printStackTrace();
}
}
}
如您所见,接收器正在监听端口3333上本地机器的任何地址上的文本消息(它将每个字节解释为一个字符)。它只使用一个 16 字节的缓冲区;一旦缓冲区被接收到的数据填满,接收器就打印它的内容并退出。
以下是 UDP 消息发送器的示例:
public class UdpSender {
public static void main(String[] args) {
try(DatagramSocket ds = new DatagramSocket()){
String msg = "Hi, there! How are you?";
InetAddress address = InetAddress.getByName("127.0.0.1");
DatagramPacket dp = new DatagramPacket(msg.getBytes(),
msg.length(), address, 3333);
ds.send(dp);
} catch (Exception ex){
ex.printStackTrace();
}
}
}
如您所见,发送方构造了一个包含消息、本地机器地址和与接收方使用的端口相同的端口的数据包。在构造的包被发送之后,发送方退出。
我们现在可以运行发送器,但是如果没有接收器运行,就没有人能收到消息。所以,我们先启动接收器。它在端口3333上监听,但是没有消息传来—所以它等待。然后,我们运行发送方,接收方显示以下消息:

因为缓冲区比消息小,所以只接收了部分消息—消息的其余部分丢失。我们可以创建一个无限循环,让接收器无限期地运行:
while(true){
ds.receive(dp);
for(byte b: dp.getData()){
System.out.print(Character.toString(b));
}
System.out.println();
}
通过这样做,我们可以多次运行发送器;如果我们运行发送器三次,则接收器将打印以下内容:

如您所见,所有三条消息都被接收;但是,接收器只捕获每条消息的前 16 个字节
现在让我们将接收缓冲区设置为大于消息:
DatagramPacket dp = new DatagramPacket(new byte[30], 30);
如果我们现在发送相同的消息,结果如下:

为了避免处理空的缓冲区元素,可以使用DatagramPacket类的getLength()方法,该方法返回消息填充的缓冲区元素的实际数量:
int i = 1;
for(byte b: dp.getData()){
System.out.print(Character.toString(b));
if(i++ == dp.getLength()){
break;
}
}
上述代码的结果如下:

这就是 UDP 协议的基本思想。发送方将消息发送到某个地址和端口,即使在该地址和端口上没有监听的套接字。它不需要在发送消息之前建立任何类型的连接,这使得 UDP 协议比 TCP 协议更快、更轻量级(TCP 协议要求您首先建立连接)。通过这种方式,TCP 协议将消息发送到另一个可靠性级别—通过确保目标存在并且消息可以被传递。
基于 TCP 的通信
TCP 由国防高级研究计划局(DARPA)于 1970 年代设计,用于高级研究计划局网络(ARPANET)。它是对 IP 的补充,因此也被称为 TCP/IP。TCP 协议,甚至其名称,都表明它提供了可靠的(即,错误检查或控制的)数据传输。它允许在 IP 网络中按顺序传递字节,广泛用于 Web、电子邮件、安全 Shell 和文件传输
使用 TCP/IP 的应用甚至不知道套接字和传输细节之间发生的所有握手,例如网络拥塞、流量负载平衡、复制,甚至一些 IP 数据包丢失。传输层的底层协议实现检测这些问题,重新发送数据,重建发送数据包的顺序,并最小化网络拥塞
与 UDP 协议不同,基于 TCP/IP 的通信侧重于准确的传输,而牺牲了传输周期。这就是为什么它不用于实时应用,比如 IP 语音,在这些应用中,需要可靠的传递和正确的顺序排序。然而,如果每一位都需要以相同的顺序准确地到达,那么 TCP/IP 是不可替代的
为了支持这种行为,TCP/IP 通信在整个通信过程中维护一个会话。会话由客户端地址和端口标识。每个会话都由服务器上表中的一个条目表示。它包含有关会话的所有元数据:客户端 IP 地址和端口、连接状态和缓冲区参数。但是这些细节通常对应用开发人员是隐藏的,所以我们在这里不再详细讨论。相反,我们将转向 Java 代码。
与 UDP 协议类似,Java 中的 TCP/IP 协议实现使用套接字。但是基于 TCP/IP 的套接字不是实现 UDP 协议的java.net.DatagramSocket类,而是由java.net.ServerSocket和java.net.Socket类表示。它们允许在两个应用之间发送和接收消息,其中一个是服务器,另一个是客户端。
ServerSocket和SocketClass类执行非常相似的任务。唯一的区别是,ServerSocket类有accept()方法,接受来自客户端的请求。这意味着服务器必须先启动并准备好接收请求。然后,连接由客户端启动,客户端创建自己的套接字来发送连接请求(来自Socket类的构造器)。然后服务器接受请求并创建一个连接到远程套接字的本地套接字(在客户端)。
建立连接后,数据传输可以使用 I/O 流进行,如第 5 章、“字符串、输入/输出和文件”所述。Socket对象具有getOutputStream()和getInputStream()方法,提供对套接字数据流的访问。来自本地计算机上的java.io.OutputStream对象的数据似乎来自远程机器上的java.io.InputStream对象。
现在让我们仔细看看java.net.ServerSocket和java.net.Socket类,然后运行它们的一些用法示例。
java.net.ServerSocket类
java.net.ServerSocket类有四个构造器:
ServerSocket():这将创建一个不绑定到特定地址和端口的服务器套接字对象。需要使用bind()方法绑定套接字。ServerSocket(int port):创建绑定到所提供端口的服务器套接字对象。port值必须在0和65535之间。如果端口号被指定为值0,这意味着需要自动绑定端口号。默认情况下,传入连接的最大队列长度为50。ServerSocket(int port, int backlog):提供与ServerSocket(int port)构造器相同的功能,允许您通过backlog参数设置传入连接的最大队列长度。ServerSocket(int port, int backlog, InetAddress bindAddr):这将创建一个服务器套接字对象,该对象类似于前面的构造器,但也绑定到提供的 IP 地址。当bindAddr值为null时,默认接受任何或所有本地地址的连接。
ServerSocket类的以下四种方法是最常用的,它们是建立套接字连接所必需的:
void bind(SocketAddress endpoint):将ServerSocket对象绑定到特定的 IP 地址和端口。如果提供的地址是null,则系统会自动获取一个端口和一个有效的本地地址(以后可以使用getLocalPort()、getLocalSocketAddress()和getInetAddress()方法检索)。另外,如果ServerSocket对象是由构造器创建的,没有任何参数,那么在建立连接之前需要调用此方法或下面的bind()方法。void bind(SocketAddress endpoint, int backlog):其作用方式与前面的方法类似,backlog参数是套接字上挂起的最大连接数(即队列的大小)。如果backlog值小于或等于0,则将使用特定于实现的默认值。void setSoTimeout(int timeout):设置调用accept()方法后套接字等待客户端的时间(毫秒)。如果客户端没有调用并且超时过期,则抛出一个java.net.SocketTimeoutException异常,但ServerSocket对象仍然有效,可以重用。0的timeout值被解释为无限超时(在客户端调用之前,accept()方法阻塞)。Socket accept():这会一直阻塞,直到客户端调用或超时期限(如果设置)到期。
该类的其他方法允许您设置或获取Socket对象的其他属性,它们可以用于更好地动态管理套接字连接。您可以参考该类的联机文档来更详细地了解可用选项。
以下代码是使用ServerSocket类的服务器实现的示例:
public class TcpServer {
public static void main(String[] args){
try(Socket s = new ServerSocket(3333).accept();
DataInputStream dis = new DataInputStream(s.getInputStream());
DataOutputStream dout = new DataOutputStream(s.getOutputStream());
BufferedReader console =
new BufferedReader(new InputStreamReader(System.in))){
while(true){
String msg = dis.readUTF();
System.out.println("Client said: " + msg);
if("end".equalsIgnoreCase(msg)){
break;
}
System.out.print("Say something: ");
msg = console.readLine();
dout.writeUTF(msg);
dout.flush();
if("end".equalsIgnoreCase(msg)){
break;
}
}
} catch(Exception ex) {
ex.printStackTrace();
}
}
}
让我们浏览前面的代码。在资源尝试语句中,我们基于新创建的套接字创建了Socket、DataInputStream和DataOutputStream对象,并创建了BufferedReader对象从控制台读取用户输入(我们将使用它输入数据)。在创建套接字时,accept()方法会阻塞,直到客户端尝试连接到本地服务器的端口3333
然后,代码进入无限循环。首先,它使用DataInputStream的readUTF()方法,将客户端发送的字节读取为以修改的 UTF-8 格式编码的 Unicode 字符串。结果以"Client said: "前缀打印。如果接收到的消息是一个"end"字符串,那么代码退出循环,服务器程序退出。如果消息不是"end",则控制台上显示"Say something: "提示,readLine()方法阻塞,直到用户键入内容并点击Enter
服务器从屏幕获取输入,并使用writeUtf()方法将其作为 Unicode 字符串写入输出流。正如我们已经提到的,服务器的输出流连接到客户端的输入流。如果客户端从输入流中读取数据,它将接收服务器发送的消息。如果发送的消息是"end",则服务器退出循环并退出程序。如果不是,则再次执行循环体。
所描述的算法假设客户端只有在发送或接收到"end"消息时才退出。否则,如果客户端随后尝试向服务器发送消息,则会生成异常。这说明了我们前面提到的 UDP 和 TCP 协议之间的区别–TCP 基于在服务器和客户端套接字之间建立的会话。如果一方掉下来,另一方马上就会遇到错误。
现在让我们回顾一个 TCP 客户端实现的示例。
java.net.Socket类
java.net.Socket类现在应该是您熟悉的了,因为它是在前面的示例中使用的。我们使用它来访问连接的套接字的输入和输出流。现在我们将系统地回顾Socket类,并探讨如何使用它来创建 TCP 客户端。Socket类有四个构造器:
Socket():这将创建一个未连接的套接字。它使用connect()方法将此套接字与服务器上的套接字建立连接。Socket(String host, int port):创建一个套接字并将其连接到host服务器上提供的端口。如果抛出异常,则无法建立到服务器的连接;否则,可以开始向服务器发送数据。Socket(InetAddress address, int port):其作用方式与前面的构造器类似,只是主机作为InetAddress对象提供。Socket(String host, int port, InetAddress localAddr, int localPort):这与前面的构造器的工作方式类似,只是它还允许您将套接字绑定到提供的本地地址和端口(如果程序在具有多个 IP 地址的机器上运行)。如果提供的localAddr值为null,则选择任何本地地址。或者,如果提供的localPort值是null,则系统在绑定操作中拾取自由端口。Socket(InetAddress address, int port, InetAddress localAddr, int localPort):其作用方式与前面的构造器类似,只是本地地址作为InetAddress对象提供。
下面是我们已经使用过的Socket类的以下两种方法:
-
InputStream getInputStream():返回一个表示源(远程套接字)的对象,并将数据(输入数据)带入程序(本地套接字)。 -
OutputStream getOutputStream():返回一个表示源(本地套接字)的对象,并将数据(输出)发送到远程套接字。
现在让我们检查一下 TCP 客户端代码,如下所示:
public class TcpClient {
public static void main(String[] args) {
try(Socket s = new Socket("localhost",3333);
DataInputStream dis = new DataInputStream(s.getInputStream());
DataOutputStream dout = new DataOutputStream(s.getOutputStream());
BufferedReader console =
new BufferedReader(new InputStreamReader(System.in))){
String prompt = "Say something: ";
System.out.print(prompt);
String msg;
while ((msg = console.readLine()) != null) {
dout.writeUTF( msg);
dout.flush();
if (msg.equalsIgnoreCase("end")) {
break;
}
msg = dis.readUTF();
System.out.println("Server said: " +msg);
if (msg.equalsIgnoreCase("end")) {
break;
}
System.out.print(prompt);
}
} catch(Exception ex){
ex.printStackTrace();
}
}
}
前面的TcpClient代码看起来与我们回顾的TcpServer代码几乎完全相同。唯一主要的区别是new Socket("localhost", 3333)构造器试图立即与"localhost:3333"服务器建立连接,因此它期望localhost服务器启动并监听端口3333,其余与服务器代码相同。
因此,我们需要使用ServerSocket类的唯一原因是允许服务器在等待客户端连接到它的同时运行;其他一切都可以使用Socket类来完成。
Socket类的其他方法允许您设置或获取 socket 对象的其他属性,它们可以用于更好地动态管理套接字连接。您可以阅读该类的在线文档,以更详细地了解可用选项。
运行示例
现在让我们运行TcpServer和TcpClient程序。如果我们先启动TcpClient,我们得到的java.net.ConnectException带有连接被拒绝的消息。所以,我们先启动TcpServer程序。当它启动时,不显示任何消息,而是等待客户端连接。因此,我们启动TcpClient并在屏幕上看到以下消息:

我们打招呼!点击Enter:

现在让我们看看服务器端屏幕:

我们打嗨!在服务器端屏幕上点击Enter:

在客户端屏幕上,我们看到以下消息:

我们可以无限期地继续此对话框,直到服务器或客户端发送结束消息。让客户去做;客户说结束然后退出:

然后,服务器执行以下操作:

这就是我们在讨论 TCP 协议时想要演示的全部内容。现在让我们回顾一下 UDP 和 TCP 协议之间的区别。
UDP 与 TCP 协议
UDP 和 TCP/IP 协议的区别如下:
- UDP 只发送数据,不管数据接收器是否启动和运行。这就是为什么 UDP 比许多其他使用多播分发的客户端更适合发送数据。另一方面,TCP 要求首先在客户端和服务器之间建立连接。TCP 客户端发送一个特殊的控制消息;服务器接收该消息并用确认消息进行响应。然后,客户端向服务器发送一条消息,确认服务器确认。只有这样,客户端和服务器之间的数据传输才有可能
- TCP 保证消息传递或引发错误,而 UDP 不保证,并且数据报数据包可能丢失。
- TCP 保证在传递时保留消息的顺序,而 UDP 不保证。
- 由于提供了这些保证,TCP 比 UDP 慢
- 此外,协议要求标头与数据包一起发送。TCP 数据包的头大小是 20 字节,而数据报数据包是 8 字节。UDP 标头包含
Length、Source Port、Destination Port、Checksum,TCP 标头除了 UDP 标头外,还包含Sequence Number、Ack Number、Data Offset、Reserved、Control Bit、Window、Urgent Pointer、Options、Padding - 有基于 TCP 或 UDP 协议的不同应用协议。基于 TCP 的协议有 HTTP、HTTPS、Telnet、FTP 和 SMTP。基于 UDP 的协议有动态主机配置协议(DHCP)、域名系统(DNS)、简单网络管理协议(SNMP),普通文件传输协议(TFTP)、引导协议(BOOTP),以及早期版本的网络文件系统(NFS)。
我们可以用一句话来描述 UDP 和 TCP 之间的区别:UDP 协议比 TCP 更快、更轻量级,但可靠性更低。就像生活中的许多事情一样,你必须为额外的服务付出更高的代价。但是,并非所有情况下都需要这些服务,因此请考虑手头的任务,并根据您的应用需求决定使用哪种协议。
基于 URL 的通信
如今,似乎每个人都对 URL 有了一些概念;那些在电脑或智能手机上使用浏览器的人每天都会看到 URL。在本节中,我们将简要解释组成 URL 的不同部分,并演示如何以编程方式使用 URL 从网站(或文件)请求数据或向网站发送(发布)数据。
URL 语法
一般来说,URL 语法遵循具有以下格式的统一资源标识符(URI)的语法:
scheme:[//authority]path[?query][#fragment]
方括号表示组件是可选的。这意味着 URI 将至少由scheme:path组成。scheme分量可以是http、https、ftp、mailto、file、data或其他值。path组件由一系列由斜线(/分隔的路径段组成。以下是仅由scheme和path组成的 URL 的示例:
file:src/main/resources/hello.txt
前面的 URL 指向本地文件系统上的一个文件,该文件相对于使用此 URL 的目录。我们将很快演示它的工作原理。
path组件可以是空的,但是这样 URL 看起来就没用了。然而,空路径通常与authority结合使用,其格式如下:
[userinfo@]host[:port]
唯一需要的授权组件是host,它可以是 IP 地址(例如137.254.120.50)或域名(例如oracle.com)。
userinfo组件通常与scheme组件的mailto值一起使用,因此userinfo@host表示电子邮件地址。
如果省略,port组件将采用默认值。例如,如果scheme值为http,则默认port值为80,如果scheme值为https,则默认port值为443。
URL 的可选query组件是由分隔符(&分隔的键值对序列:
key1=value1&key2=value2
最后,可选的fragment组件是 HTML 文档的一部分的标识符,这样浏览器就可以将该部分滚动到视图中。
需要指出的是,Oracle 的在线文档使用的术语略有不同:
protocol代替schemereference代替fragmentfile代替path[?query][#fragment]resource代替host[:port]path[?query][#fragment]
因此,从 Oracle 文档的角度来看,URL 由protocol和resource值组成。
现在让我们看看 Java 中 URL 的编程用法。
java.net.URL类
在 Java 中,URL 由java.net.URL类的一个对象表示,该对象有六个构造器:
URL(String spec):从 URL 创建一个URL对象作为字符串。URL(String protocol, String host, String file):根据提供的protocol、host、file(path、query的值,以及基于提供的protocol值的默认端口号,创建一个URL对象。URL(String protocol, String host, int port, String path):根据提供的protocol、host、port、file(path、query的值创建URL对象,port值为-1表示需要根据提供的protocol值使用默认端口号。URL(String protocol, String host, int port, String file, URLStreamHandler handler):这与前面的构造器的作用方式相同,并且允许您传入特定协议处理器的对象;所有前面的构造器都自动加载默认处理器。URL(URL context, String spec):这将创建一个URL对象,该对象扩展提供的URL对象或使用提供的spec值覆盖其组件,该值是 URL 或其某些组件的字符串表示。例如,如果两个参数中都存在方案,spec中的方案值将覆盖context和其他许多参数中的方案值。URL(URL context, String spec, URLStreamHandler handler):它的作用方式与前面的构造器相同,另外还允许您传入特定协议处理器的对象。
创建后,URL对象允许您获取基础 URL 的各个组件的值。InputStream openStream()方法提供对从 URL 接收的数据流的访问。实际上,它被实现为openConnection.getInputStream()。URL类的URLConnection openConnection()方法返回一个URLConnection对象,其中有许多方法提供与 URL 连接的详细信息,包括允许向 URL 发送数据的getOutputStream()方法。
让我们看一看代码示例;我们首先从一个hello.txt文件中读取数据,这个文件是我们在第 5 章中创建的本地文件,“字符串、输入/输出和文件”。文件只包含一行:“你好!”;下面是读取它的代码:
try {
URL url = new URL("file:src/main/resources/hello.txt");
System.out.println(url.getPath()); // src/main/resources/hello.txt
System.out.println(url.getFile()); // src/main/resources/hello.txt
try(InputStream is = url.openStream()){
int data = is.read();
while(data != -1){
System.out.print((char) data); //prints: Hello!
data = is.read();
}
}
} catch (Exception e) {
e.printStackTrace();
}
在前面的代码中,我们使用了file:src/main/resources/hello.txtURL。它基于相对于程序执行位置的文件路径。程序在我们项目的根目录中执行。首先,我们演示了getPath()和getFile()方法,返回的值没有区别,因为 URL 没有query组件值。否则,getFile()方法也会包括它。我们将在下面的代码示例中看到这一点。
前面代码的其余部分打开文件中的输入数据流,并将传入的字节打印为字符。结果显示在内联注释中。
现在,让我们演示 Java 代码如何从指向互联网上源的 URL 读取数据。让我们用一个Java关键字来调用谷歌搜索引擎:
try {
URL url = new URL("https://www.google.com/search?q=Java&num=10");
System.out.println(url.getPath()); //prints: /search
System.out.println(url.getFile()); //prints: /search?q=Java&num=10
URLConnection conn = url.openConnection();
conn.setRequestProperty("Accept", "text/html");
conn.setRequestProperty("Connection", "close");
conn.setRequestProperty("Accept-Language", "en-US");
conn.setRequestProperty("User-Agent", "Mozilla/5.0");
try(InputStream is = conn.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is))){
String line;
while ((line = br.readLine()) != null){
System.out.println(line);
}
}
} catch (Exception e) {
e.printStackTrace();
}
在这里,我们提出了https://www.google.com/search?q=Java&num=10URL,并在进行了一些研究和实验后要求属性。没有保证它总是有效的,所以如果它不返回我们描述的相同数据,不要感到惊讶。此外,它是一个实时搜索,因此结果可能随时变化
前面的代码还演示了由getPath()和getFile()方法返回的值之间的差异。您可以在前面的代码示例中查看内联注释。
与使用文件 URL 的示例相比,Google 搜索示例使用了URLConnection对象,因为我们需要设置请求头字段:
Accept告诉服务器调用者请求什么类型的内容(understands。Connection通知服务器收到响应后,连接将关闭。Accept-Language告诉服务器调用者请求哪种语言(understands)。User-Agent告诉服务器关于调用者的信息;否则,Google 搜索引擎(www.google.com响应 403(禁止)HTTP 代码。
上一个示例中的其余代码只是读取来自 URL 的输入数据流(HTML 代码),然后逐行打印它。我们捕获了结果(从屏幕上复制),将其粘贴到在线 HTML 格式化程序中,然后运行它。结果显示在以下屏幕截图中:

如您所见,它看起来像是一个典型的带有搜索结果的页面,只是在左上角没有返回 HTML 的 Google 图像。
类似地,也可以向 URL 发送(发布)数据;下面是一个示例代码:
try {
URL url = new URL("http://localhost:3333/something");
URLConnection conn = url.openConnection();
//conn.setRequestProperty("Method", "POST");
//conn.setRequestProperty("User-Agent", "Java client");
conn.setDoOutput(true);
OutputStreamWriter osw =
new OutputStreamWriter(conn.getOutputStream());
osw.write("parameter1=value1¶meter2=value2");
osw.flush();
osw.close();
BufferedReader br =
new BufferedReader(new InputStreamReader(conn.getInputStream()));
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
br.close();
} catch (Exception e) {
e.printStackTrace();
}
前面的代码要求在端口3333上的localhost服务器上运行一个服务器,该服务器可以用"/something"路径处理POST请求。如果服务器没有检查方法(是POST还是其他 HTTP 方法)并且没有检查User-Agent值,则不需要指定任何方法。因此,我们对设置进行注释,并将它们保留在那里,只是为了演示如何在需要时设置这些值和类似的值。
注意,我们使用了setDoOutput()方法来指示必须发送输出;默认情况下,它被设置为false。然后,让输出流将查询参数发送到服务器
前面代码的另一个重要方面是在打开输入流之前必须关闭输出流。否则,输出流的内容将不会发送到服务器。虽然我们显式地这样做了,但是更好的方法是使用资源尝试块,它保证调用close()方法,即使在块中的任何地方引发了异常。
以下是上述示例的更好版本:
try {
URL url = new URL("http://localhost:3333/something");
URLConnection conn = url.openConnection();
//conn.setRequestProperty("Method", "POST");
//conn.setRequestProperty("User-Agent", "Java client");
conn.setDoOutput(true);
try(OutputStreamWriter osw =
new OutputStreamWriter(conn.getOutputStream())){
osw.write("parameter1=value1¶meter2=value2");
osw.flush();
}
try(BufferedReader br =
new BufferedReader(new InputStreamReader(conn.getInputStream()))){
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
}
} catch (Exception ex) {
ex.printStackTrace();
}
为了演示这个示例是如何工作的,我们还创建了一个简单的服务器,它监听localhost的端口3333,并分配了一个处理器来处理"/something"路径中的所有请求:
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(3333),0);
server.createContext("/something", new PostHandler());
server.setExecutor(null);
server.start();
}
static class PostHandler implements HttpHandler {
public void handle(HttpExchange exch) {
System.out.println(exch.getRequestURI()); //prints: /something
System.out.println(exch.getHttpContext().getPath());///something
try(BufferedReader in = new BufferedReader(
new InputStreamReader(exch.getRequestBody()));
OutputStream os = exch.getResponseBody()){
System.out.println("Received as body:");
in.lines().forEach(l -> System.out.println(" " + l));
String confirm = "Got it! Thanks.";
exch.sendResponseHeaders(200, confirm.length());
os.write(confirm.getBytes());
} catch (Exception ex){
ex.printStackTrace();
}
}
}
为了实现服务器,我们使用了 JCL 附带的com.sun.net.httpserver包的类。为了证明 URL 没有参数,我们打印 URI 和路径。它们都有相同的"/something"值;参数来自请求的主体。
请求处理完成后,服务器发回消息“收到!谢谢。”让我们看看它是怎么工作的;我们先运行服务器。它开始监听端口3333并阻塞,直到请求带有"/something"路径。然后,我们执行客户端并在服务器端屏幕上观察以下输出:

如您所见,服务器成功地接收到参数(或任何其他消息)。现在它可以解析它们并根据需要使用它们。
如果我们查看客户端屏幕,将看到以下输出:

这意味着客户端从服务器接收到消息并按预期退出。注意,我们示例中的服务器不会自动退出,必须手动关闭。
URL和URLConnection类的其他方法允许您设置/获取其他属性,并且可以用于客户端-服务器通信的更动态的管理。在java.net包中还有HttpUrlConnection类(以及其他类),它简化并增强了基于 URL 的通信。您可以阅读java.net包的在线文档,以便更好地了解可用的选项。
使用 HTTP 2 客户端 API
HTTP 客户端 API 是在 Java9 中引入的,作为jdk.incubator.http包中的孵化 API,在 Java11 中被标准化并转移到java.net.http包中,它是一个比URLConnectionAPI 更丰富、更易于使用的替代品。除了所有与连接相关的基本功能外,它还使用CompletableFuture提供非阻塞(异步)请求和响应,并支持 HTTP1.1 和 HTTP2。
HTTP 2 为 HTTP 协议添加了以下新功能:
- 以二进制格式而不是文本格式发送数据的能力;二进制格式的解析效率更高,更紧凑,并且不易受到各种错误的影响。
- 它是完全多路复用的,因此允许使用一个连接同时发送多个请求和响应。
- 它使用头压缩,从而减少了开销。
- 如果客户端指示它支持 HTTP2,它允许服务器将响应推送到客户端的缓存中。
包包含以下类:
HttpClient:用于同步和异步发送请求和接收响应。可以使用带有默认设置的静态newHttpClient()方法创建实例,也可以使用允许您自定义客户端配置的HttpClient.Builder类(由静态newBuilder()方法返回)。一旦创建,实例是不可变的,可以多次使用。HttpRequest:创建并表示一个 HTTP 请求,其中包含目标 URI、头和其他相关信息。可以使用HttpRequest.Builder类(由静态newBuilder()方法返回)创建实例。一旦创建,实例是不可变的,可以多次发送。HttpRequest.BodyPublisher:从某个源(比如字符串、文件、输入流或字节数组)发布主体(对于POST、PUT、DELETE方法)。HttpResponse:表示客户端发送 HTTP 请求后收到的 HTTP 响应。它包含源 URI、头、消息体和其他相关信息。创建实例后,可以多次查询实例。HttpResponse.BodyHandler:接受响应并返回HttpResponse.BodySubscriber实例的函数式接口,可以处理响应体。HttpResponse.BodySubscriber:接收响应体(字节)并将其转换为字符串、文件或类型。
HttpRequest.BodyPublishers、HttpResponse.BodyHandlers和HttpResponse.BodySubscribers类是创建相应类实例的工厂类。例如,BodyHandlers.ofString()方法创建一个BodyHandler实例,将响应正文字节作为字符串进行处理,BodyHandlers.ofFile()方法创建一个BodyHandler实例,将响应正文保存在文件中。
您可以阅读java.net.http包的在线文档,以了解有关这些类和其他相关类及接口的更多信息。接下来,我们将看一看并讨论一些使用 HTTPAPI 的示例。
阻塞 HTTP 请求
以下代码是向 HTTP 服务器发送GET请求的简单 HTTP 客户端的示例:
HttpClient httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // default
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:3333/something"))
.GET() // default
.build();
try {
HttpResponse<String> resp =
httpClient.send(req, BodyHandlers.ofString());
System.out.println("Response: " +
resp.statusCode() + " : " + resp.body());
} catch (Exception ex) {
ex.printStackTrace();
}
我们创建了一个生成器来配置一个HttpClient实例。但是,由于我们只使用了默认设置,因此我们可以使用以下相同的结果:
HttpClient httpClient = HttpClient.newHttpClient();
为了演示客户端的功能,我们将使用与我们已经使用的相同的UrlServer类。作为提醒,这就是它如何处理客户的请求并用"Got it! Thanks."响应:
try(BufferedReader in = new BufferedReader(
new InputStreamReader(exch.getRequestBody()));
OutputStream os = exch.getResponseBody()){
System.out.println("Received as body:");
in.lines().forEach(l -> System.out.println(" " + l));
String confirm = "Got it! Thanks.";
exch.sendResponseHeaders(200, confirm.length());
os.write(confirm.getBytes());
System.out.println();
} catch (Exception ex){
ex.printStackTrace();
}
如果启动此服务器并运行前面的客户端代码,服务器将在其屏幕上打印以下消息:

客户端没有发送消息,因为它使用了 HTTPGET方法。不过,服务器会做出响应,客户端屏幕会显示以下消息:

在服务器返回响应之前,HttpClient类的send()方法被阻塞
使用 HTTPPOST、PUT或DELETE方法会产生类似的结果;现在让我们运行以下代码:
HttpClient httpClient = HttpClient.newBuilder()
.version(Version.HTTP_2) // default
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:3333/something"))
.POST(BodyPublishers.ofString("Hi there!"))
.build();
try {
HttpResponse<String> resp =
httpClient.send(req, BodyHandlers.ofString());
System.out.println("Response: " +
resp.statusCode() + " : " + resp.body());
} catch (Exception ex) {
ex.printStackTrace();
}
如您所见,这次客户端在那里发布消息“Hi!”,服务器屏幕显示以下内容:

在服务器返回相同响应之前,HttpClient类的send()方法被阻塞:

到目前为止,演示的功能与我们在上一节中看到的基于 URL 的通信没有太大区别。现在我们将使用 URL 流中不可用的HttpClient方法。
非阻塞(异步)HTTP 请求
HttpClient类的sendAsync()方法允许您向服务器发送消息而不阻塞。为了演示它的工作原理,我们将执行以下代码:
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:3333/something"))
.GET() // default
.build();
CompletableFuture<Void> cf = httpClient
.sendAsync(req, BodyHandlers.ofString())
.thenAccept(resp -> System.out.println("Response: " +
resp.statusCode() + " : " + resp.body()));
System.out.println("The request was sent asynchronously...");
try {
System.out.println("CompletableFuture get: " +
cf.get(5, TimeUnit.SECONDS));
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println("Exit the client...");
与使用send()方法(返回HttpResponse对象)的示例相比,sendAsync()方法返回CompletableFuture<HttpResponse>类的实例。如果您阅读了CompletableFuture<T>类的文档,您将看到它实现了java.util.concurrent.CompletionStage接口,该接口提供了许多可以链接的方法,并允许您设置各种函数来处理响应。
下面是在CompletionStage接口中声明的方法列表:acceptEither、acceptEitherAsync、acceptEitherAsync、applyToEither、applyToEitherAsync、applyToEitherAsync、handle、handleAsync、handleAsync、runAfterBoth、runAfterBothAsync、runAfterBothAsync、runAfterEither、runAfterEitherAsync、runAfterEitherAsync、thenAccept、thenAcceptAsync、thenAcceptAsync、thenAcceptBoth、thenAcceptBothAsync,thenAcceptBothAsync、thenApply、thenApplyAsync、thenApplyAsync、thenCombine、thenCombineAsync、thenCombineAsync、thenCompose、thenComposeAsync、thenComposeAsync、thenRun、thenRunAsync、thenRunAsync、whenComplete、whenCompleteAsync、whenCompleteAsync。
我们将在第 13 章、“函数式编程”中讨论函数以及如何将它们作为参数传递。现在,我们只需要提到,resp -> System.out.println("Response: " + resp.statusCode() + " : " + resp.body())构造表示与以下方法相同的功能:
void method(HttpResponse resp){
System.out.println("Response: " +
resp.statusCode() + " : " + resp.body());
}
thenAccept()方法将传入的功能应用于链的前一个方法返回的结果。
返回CompletableFuture<Void>实例后,前面的代码打印异步发送的请求…消息并在CompletableFuture<Void>对象的get()方法上阻塞。这个方法有一个重载版本get(long timeout, TimeUnit unit),有两个参数,TimeUnit unit和long timeout指定了单元的数量,指示该方法应该等待CompletableFuture<Void>对象表示的任务完成多长时间。在我们的例子中,任务是向服务器发送消息并获取响应(并使用提供的函数进行处理)。如果任务没有在分配的时间内完成,get()方法被中断(栈跟踪被打印在catch块中)。
Exit the client...消息应该在 5 秒内(在我们的例子中)或者在get()方法返回之后出现在屏幕上。
如果我们运行客户端,服务器屏幕会再次显示以下消息,并阻止 HTTPGET请求:

客户端屏幕显示以下消息:

如您所见,请求是异步发送的…消息在服务器返回响应之前出现。这就是异步调用的要点;向服务器发送的请求已发送,客户端可以继续执行任何其他操作。传入的函数将应用于服务器响应。同时,您可以传递CompletableFuture<Void>对象,并随时调用它来获得结果。在我们的例子中,结果是void,所以get()方法只是表示任务已经完成
我们知道服务器返回消息,因此我们可以使用CompletionStage接口的另一种方法来利用它。我们选择了thenApply()方法,它接受一个返回值的函数:
CompletableFuture<String> cf = httpClient
.sendAsync(req, BodyHandlers.ofString())
.thenApply(resp -> "Server responded: " + resp.body());
现在get()方法返回resp -> "Server responded: " + resp.body()函数产生的值,所以它应该返回服务器消息体;让我们运行下面的代码,看看结果:

现在,get()方法按预期返回服务器的消息,它由函数表示并作为参数传递给thenApply()方法。
同样,我们可以使用 HTTPPOST、PUT或DELETE方法发送消息:
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:3333/something"))
.POST(BodyPublishers.ofString("Hi there!"))
.build();
CompletableFuture<String> cf = httpClient
.sendAsync(req, BodyHandlers.ofString())
.thenApply(resp -> "Server responded: " + resp.body());
System.out.println("The request was sent asynchronously...");
try {
System.out.println("CompletableFuture get: " +
cf.get(5, TimeUnit.SECONDS));
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println("Exit the client...");
与上一个示例的唯一区别是,服务器现在显示接收到的客户端消息:

客户端屏幕显示与GET方法相同的消息:

异步请求的优点是可以快速发送,而不需要等待每个请求完成。HTTP 2 协议通过多路复用来支持它;例如,让我们发送三个请求,如下所示:
HttpClient httpClient = HttpClient.newHttpClient();
List<CompletableFuture<String>> cfs = new ArrayList<>();
List<String> nums = List.of("1", "2", "3");
for(String num: nums){
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:3333/something"))
.POST(BodyPublishers.ofString("Hi! My name is " + num + "."))
.build();
CompletableFuture<String> cf = httpClient
.sendAsync(req, BodyHandlers.ofString())
.thenApply(rsp -> "Server responded to msg " + num + ": "
+ rsp.statusCode() + " : " + rsp.body());
cfs.add(cf);
}
System.out.println("The requests were sent asynchronously...");
try {
for(CompletableFuture<String> cf: cfs){
System.out.println("CompletableFuture get: " +
cf.get(5, TimeUnit.SECONDS));
}
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println("Exit the client...");
服务器屏幕显示以下消息:

注意传入请求的任意序列;这是因为客户端使用一个Executors.newCachedThreadPool()线程池来发送消息。每个消息都由不同的线程发送,池有自己的使用池成员(线程)的逻辑。如果消息的数量很大,或者每个消息都占用大量内存,那么限制并发运行的线程数量可能是有益的
HttpClient.Builder类允许您指定用于获取发送消息的线程的池:
ExecutorService pool = Executors.newFixedThreadPool(2);
HttpClient httpClient = HttpClient.newBuilder().executor(pool).build();
List<CompletableFuture<String>> cfs = new ArrayList<>();
List<String> nums = List.of("1", "2", "3");
for(String num: nums){
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:3333/something"))
.POST(BodyPublishers.ofString("Hi! My name is " + num + "."))
.build();
CompletableFuture<String> cf = httpClient
.sendAsync(req, BodyHandlers.ofString())
.thenApply(rsp -> "Server responded to msg " + num + ": "
+ rsp.statusCode() + " : " + rsp.body());
cfs.add(cf);
}
System.out.println("The requests were sent asynchronously...");
try {
for(CompletableFuture<String> cf: cfs){
System.out.println("CompletableFuture get: " +
cf.get(5, TimeUnit.SECONDS));
}
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println("Exit the client...");
如果我们运行前面的代码,结果将是相同的,但是客户端将只使用两个线程来发送消息。随着消息数量的增加,性能可能会慢一些(与上一个示例相比)。因此,正如软件系统设计中经常出现的情况一样,您需要在使用的内存量和性能之间取得平衡。
与执行器类似,可以在HttpClient对象上设置其他几个对象,以配置连接来处理认证、请求重定向、Cookie 管理等。
服务器推送功能
与 HTTP1.1 相比,HTTP2 协议的第二个(在多路复用之后)显著优点是,如果客户端指示它支持 HTTP2,则允许服务器将响应推送到客户端的缓存中。以下是利用此功能的客户端代码:
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:3333/something"))
.GET()
.build();
CompletableFuture cf = httpClient
.sendAsync(req, BodyHandlers.ofString(),
(PushPromiseHandler) HttpClientDemo::applyPushPromise);
System.out.println("The request was sent asynchronously...");
try {
System.out.println("CompletableFuture get: " +
cf.get(5, TimeUnit.SECONDS));
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println("Exit the client...");
注意sendAsync()方法的第三个参数,它是一个处理来自服务器的推送响应的函数。如何实现此功能由客户端开发人员决定;下面是一个可能的示例:
void applyPushPromise(HttpRequest initReq, HttpRequest pushReq,
Function<BodyHandler, CompletableFuture<HttpResponse>> acceptor) {
CompletableFuture<Void> cf = acceptor.apply(BodyHandlers.ofString())
.thenAccept(resp -> System.out.println("Got pushed response "
+ resp.uri()));
try {
System.out.println("Pushed completableFuture get: " +
cf.get(1, TimeUnit.SECONDS));
} catch (Exception ex) {
ex.printStackTrace();
}
System.out.println("Exit the applyPushPromise function...");
}
这个函数的实现并没有什么作用。它只是打印出推送源的 URI。但是,如果需要的话,它可以用于从服务器接收资源(例如,支持提供的 HTML 的图像),而不需要请求它们。该解决方案节省了往返请求-响应模型,缩短了页面加载时间,并可用于页面信息的更新。
您可以找到许多发送推送请求的服务器的代码示例;所有主流浏览器也都支持此功能。
WebSocket 支持
HTTP 基于请求-响应模型。客户端请求资源,而服务器对此请求提供响应。正如我们多次演示的那样,客户端启动通信。没有它,服务器就不能向客户端发送任何内容。为了克服这个限制,这个想法首先在 HTML5 规范中作为 TCP 连接引入,并在 2008 年设计了 WebSocket 协议的第一个版本。
它在客户端和服务器之间提供全双工通信通道。建立连接后,服务器可以随时向客户端发送消息。与 JavaScript 和 HTML5 一起,WebSocket 协议支持允许 Web 应用呈现更动态的用户界面。
WebSocket 协议规范将 WebSocket(ws)和 WebSocket Secure(wss)定义为两种方案,分别用于未加密和加密连接。该协议不支持分段,但允许在“URL 语法”部分中描述的所有其他 URI 组件。
所有支持客户端 WebSocket 协议的类都位于java.net包中。要创建客户端,需要实现WebSocket.Listener接口,接口有以下几种方法:
onText():接收到文本数据时调用onBinary():接收到二进制数据时调用onPing():收到 Ping 消息时调用onPong():收到 Pong 消息时调用onError():发生错误时调用onClose():收到关闭消息时调用
此接口的所有方法都是default。这意味着您不需要实现所有这些功能,而只需要实现客户端为特定任务所需的功能:
class WsClient implements WebSocket.Listener {
@Override
public void onOpen(WebSocket webSocket) {
System.out.println("Connection established.");
webSocket.sendText("Some message", true);
Listener.super.onOpen(webSocket);
}
@Override
public CompletionStage onText(WebSocket webSocket,
CharSequence data, boolean last) {
System.out.println("Method onText() got data: " + data);
if(!webSocket.isOutputClosed()) {
webSocket.sendText("Another message", true);
}
return Listener.super.onText(webSocket, data, last);
}
@Override
public CompletionStage onClose(WebSocket webSocket,
int statusCode, String reason) {
System.out.println("Closed with status " +
statusCode + ", reason: " + reason);
return Listener.super.onClose(webSocket, statusCode, reason);
}
}
服务器也可以用类似的方式实现,但是服务器实现超出了本书的范围,为了演示前面的客户端代码,我们将使用echo.websocket.org网站提供的 WebSocket 服务器。它允许 WebSocket 连接并将接收到的消息发回;这样的服务器通常称为回送服务器。
我们希望我们的客户端在建立连接后发送消息。然后,它将从服务器接收(相同的)消息,显示它,并发回另一条消息,依此类推,直到它被关闭。以下代码调用我们创建的客户端:
HttpClient httpClient = HttpClient.newHttpClient();
WebSocket webSocket = httpClient.newWebSocketBuilder()
.buildAsync(URI.create("ws://echo.websocket.org"), new WsClient())
.join();
System.out.println("The WebSocket was created and ran asynchronously.");
try {
TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Normal closure")
.thenRun(() -> System.out.println("Close is sent."));
前面的代码使用WebSocket.Builder类创建WebSocket对象。buildAsync()方法返回CompletableFuture对象。CompletableFuture类的join()方法在完成时返回结果值,或者抛出异常。如果没有生成异常,那么正如我们已经提到的,WebSocket通信将继续,直到任何一方发送关闭消息。这就是为什么我们的客户端等待 200 毫秒,然后发送关闭消息并退出。如果运行此代码,将看到以下消息:

如您所见,客户端的行为符合预期。为了结束我们的讨论,我们想提到的是,所有现代 Web 浏览器都支持 WebSocket 协议。
总结
本章向读者介绍了最流行的网络协议:UDP、TCP/IP 和 WebSocket。讨论通过使用 JCL 的代码示例进行了说明。我们还回顾了基于 URL 的通信和最新的 Java HTTP2 客户端 API。
下一章将概述 JavaGUI 技术,并演示使用 JavaFX 的 GUI 应用,包括带有控制元素、图表、CSS、FXML、HTML、媒体和各种其他效果的代码示例。读者将学习如何使用 JavaFX 创建 GUI 应用。
测验
-
列出应用层的五个网络协议
-
说出传输层的两个网络协议。
-
哪个 Java 包包含支持 HTTP 协议的类?
-
哪个协议是基于交换数据报的?
-
数据报是否可以发送到没有服务器运行的 IP 地址?
-
哪个 Java 包包含支持 UDP 和 TCP 协议的类?
-
TCP 代表什么?
-
TCP 和 TCP/IP 协议之间有什么共同点?
-
如何识别 TCP 会话?
-
说出
ServerSocket和Socket功能之间的一个主要区别。 -
TCP 和 UDP 哪个更快?
-
TCP 和 UDP 哪个更可靠?
-
说出三个基于 TCP 的协议。
-
以下哪项是 URI 的组件?选择所有适用的选项:
-
scheme和protocol有什么区别? -
URI 和 URL 有什么区别?
-
下面的代码打印什么?
URL url = new URL("http://www.java.com/something?par=42");
System.out.print(url.getPath());
System.out.println(url.getFile());
- 列举两个 HTTP2 具有的、HTTP1.1 没有的新特性。
HttpClient类的完全限定名是什么?WebSocket类的完全限定名是什么?HttpClient.newBuilder().build()和HttpClient.newHttpClient()有什么区别?CompletableFuture类的完全限定名是什么?
十二、Java GUI 编程
本章概述了 Java 图形用户界面(GUI)技术,并演示了如何使用 JavaFX 工具包创建 GUI 应用。JavaFX 的最新版本不仅提供了许多有用的特性,还允许保留和嵌入遗留的实现和样式
本章将讨论以下主题:
- Java GUI 技术
- JavaFX 基础
- 你好,JavaFX
- 控制元素
- 图表
- 应用 CSS
- 使用 FXML
- 嵌入 HTML
- 播放媒体
- 添加效果
Java GUI 技术
名称 Java 基础类(JFC)可能会引起很多混淆。这意味着在 Java java T5 的基础上的类,而事实上,JFC 只包含与 GUI 相关的类和接口。准确地说,JFC 是三个框架的集合:抽象窗口工具包(AWT)、Swing 和 Java2d
JFC 是 Java 类库(JCL)的一部分,尽管 JFC 这个名字是 1997 年才出现的,而 AWT 从一开始就是 JCL 的一部分。当时,Netscape 开发了一个名为 互联网基础类(IFC)的 GUI 库,微软也为 GUI 开发创建了应用基础类(AFC)。因此,当 Sun Microsystems 和 Netscape 决定建立一个新的 GUI 库时,他们继承了Foundation这个词,并创建了 JFC。Swing 框架从 AWT 接管了 JavaGUI 编程,并成功地使用了近 20 年
Java8 中的 JCL 添加了一个新的 GUI 编程工具包 JavaFX。它是在 Java11 中从 JCL 中删除的,从那时起,它就作为一个开放源代码项目驻留在 Gluon 公司的支持下,作为 JDK 之外的一个可下载模块。JavaFX 使用与 AWT 和 Swing 稍有不同的 GUI 编程方法。它提供了一个更一致、更简单的设计,很有可能成为一个成功的 JavaGUI 编程工具包。
JavaFX 基础
纽约、伦敦、巴黎和莫斯科等城市有许多剧院,住在那里的人们几乎每周都会听到新的戏剧和作品。这使他们不可避免地熟悉戏剧术语,其中最常用的可能是舞台、场景、事件。这三个术语也是 Java 语言应用结构的基础。
JavaFX 中包含所有其他组件的顶级容器由javafx.stage.Stage类表示。所以,可以说,在 JavaFX 应用中,一切都发生在舞台上。从用户的角度来看,它是一个显示区域或窗口,所有控件和组件在其中执行它们的操作(就像剧院中的演员)。而且,与剧院中的演员类似,他们在场景的上下文中这样做,由javafx.scene.Scene类表示。因此,JavaFX 应用就像剧院中的戏剧一样,由Stage对象中呈现的Scene对象组成,一次呈现一个。每个Scene对象都包含一个图形,它定义了场景参与者的位置,在 JavaFX 中称为节点:控件、布局、组、形状等等。它们都扩展了抽象类javafx.scene.Node
一些节点控件与事件关联:例如,单击的按钮或选中的复选框。这些事件可以由与相应控制元素关联的事件处理器来处理
JavaFX 应用的主类必须扩展抽象类java.application.Application,它有几个生命周期方法。我们按照调用的顺序列出它们:launch()、init()、notifyPreloader()、start()、stop()。看来要记住的还真不少。但是,最有可能的是,您只需要实现一个方法start(),在这里构建并执行实际的 GUI。因此,我们将回顾所有方法的完整性:
-
static void launch(Class<? extends Application> appClass, String... args):启动应用,通常由main方法调用;直到Platform.exit()被调用或所有应用窗口关闭后才返回,appClass参数必须是Application的一个公共子类,具有一个公共的无参数构造器 -
static void launch(String... args):与前面的方法相同,假设Application的public子类是立即封闭的类,这是启动 JavaFX 应用最常用的方法,我们也将在示例中使用它 -
void init():这个方法是在Application类被加载后调用的,通常用于某种资源初始化,默认实现什么都不做,我们不打算使用它 -
void notifyPreloader(Preloader.PreloaderNotification info):初始化时间长时可以显示进度,我们不使用 -
abstract void start(Stage primaryStage):我们要实现的方法,init()方法返回后调用,primaryStage参数是应用呈现场景的阶段 -
void stop():当应用应该停止时调用,可以用来释放资源,默认实现什么都不做,我们不使用
JavaFX 工具包的 API 可以在网上找到。在撰写本文时,最新版本是 11。Oracle 也提供了大量的文档和代码示例。文档包括 Scene Builder(一个开发工具)的描述和用户手册,它提供了一个可视化的布局环境,让您无需编写任何代码就可以快速地为 JavaFX 应用设计用户界面。这个工具对于创建复杂的 GUI 可能很有用,而且很多人一直都在这么做。
要做到这一点,首先需要三个步骤:
- 将以下依赖项添加到
pom.xml文件:
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>11</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-fxml</artifactId>
<version>11</version>
</dependency>
- 从这个页面下载适用于您操作系统的 JavaFXSDK 并在任何目录中解压。
- 假设您已将 JavaFX SDK 解压到
/path/JavaFX/文件夹中,请将以下选项添加到将在 Linux 平台上启动 JavaFX 应用的 Java 命令中:
--module-path /path/JavaFX/lib -add-modules=javafx.controls,javafx.fxml
在 Windows 上,相同的选项如下所示:
--module-path C:\path\JavaFX\lib -add-modules=javafx.controls,javafx.fxml
/path/JavaFX/和C:\path\JavaFX\是占位符,您需要用包含 JavaFXSDK 的文件夹的实际路径替换它们。
假设应用的主类是HelloWorld,如果是 IntelliJ,则在VM options字段中输入前面的选项,如下所示:

这些选项必须添加到源代码包ch12_gui的HelloWorld、BlendEffect和OtherEffects类的运行/调试配置中。如果您喜欢不同的 IDE 或有不同的操作系统,您可以在openjfx.io文档中找到如何设置它的建议。
要从命令行运行HelloWorld、BlendEffect和OtherEffects类,请在 Linux 平台上的项目根目录(即pom.xml文件所在的目录)中使用以下命令:
mvn clean package
java --module-path /path/javaFX/lib --add-modules=javafx.controls,javafx.fxml -cp target/learnjava-1.0-SNAPSHOT.jar:target/libs/* com.packt.learnjava.ch12_gui.HelloWorld
java --module-path /path/javaFX/lib --add-modules=javafx.controls,javafx.fxml -cp target/learnjava-1.0-SNAPSHOT.jar:target/libs/* com.packt.learnjava.ch12_gui.BlendEffect
java --module-path /path/javaFX/lib --add-modules=javafx.controls,javafx.fxml -cp target/learnjava-1.0-SNAPSHOT.jar:target/libs/* com.packt.learnjava.ch12_gui.OtherEffects
在 Windows 上,相同的命令如下所示:
mvn clean package
java --module-path C:\path\JavaFX\lib --add-modules=javafx.controls,javafx.fxml -cp target\learnjava-1.0-SNAPSHOT.jar;target\libs\* com.packt.learnjava.ch12_gui.HelloWorld
java --module-path C:\path\JavaFX\lib --add-modules=javafx.controls,javafx.fxml -cp target\learnjava-1.0-SNAPSHOT.jar;target\libs\* com.packt.learnjava.ch12_gui.BlendEffect
java --module-path C:\path\JavaFX\lib --add-modules=javafx.controls,javafx.fxml -cp target\learnjava-1.0-SNAPSHOT.jar;target\libs\* com.packt.learnjava.ch12_gui.OtherEffects
HelloWorld、BlendEffect、OtherEffects每个类都有几个start()方法:start1()、start2()等,运行一次该类后,将start()重命名为start1()、start1()重命名为start(),再运行上述命令。然后将start()重命名为start2(),将start2()重命名为start(),再次运行上述命令。以此类推,直到所有的start()方法都被执行。这样,您将看到本章所有示例的结果。
这就是 JavaFX 的高级视图的全部内容。有了这些,我们进入了最激动人心的部分(对于任何程序员来说):编写代码。
你好,JavaFX
下面是显示文本 HelloWorld 的HelloWorldJavaFX 应用:
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.control.Button;
import javafx.scene.layout.Pane;
import javafx.scene.text.Text;
import javafx.scene.Scene;
import javafx.stage.Stage;
public class HelloWorld extends Application {
public static void main(String... args) {
launch(args);
}
@Override
public void start(Stage primaryStage) {
Text txt = new Text("Hello, world!");
txt.relocate(135, 40);
Button btn = new Button("Exit");
btn.relocate(155, 80);
btn.setOnAction(e:> {
System.out.println("Bye! See you later!");
Platform.exit();
});
Pane pane = new Pane();
pane.getChildren().addAll(txt, btn);
primaryStage.setTitle("The primary stage (top-level container)");
primaryStage.onCloseRequestProperty()
.setValue(e:> System.out.println("Bye! See you later!"));
primaryStage.setScene(new Scene(pane, 350, 150));
primaryStage.show();
}
}
如您所见,应用是通过调用静态方法Application.launch(String... args)来启动的。start(Stage primaryStage)方法创建一个Text节点,消息是 HelloWorld 位于绝对位置 135(水平)和 40(垂直)。然后创建另一个节点Button,退出文本位于 155(水平)和 80(垂直)的绝对位置。分配给按钮的操作(单击时)将打印“GoodBye”,并强制应用使用Platform.exit()方法退出。这两个节点作为子节点添加到允许绝对定位的布局窗格中
Stage对象指定了主阶段(顶级容器)标题。它还指定了单击窗口上角的关闭窗口符号(x 按钮)的操作。此符号在 Linux 系统上显示在左侧,在 Windows 系统上显示在右侧
在创建动作时,我们使用了 Lambda 表达式,我们将在第 13 章、“函数式编程”中讨论。
创建的布局窗格设置在Scene对象上。场景大小水平设置为 350,垂直设置为 150。场景对象放置在舞台上。然后通过调用show()方法显示舞台。
如果我们运行前面的应用,将弹出以下窗口:

单击上角的按钮或 x 按钮将显示预期的消息:

但是如果在点击 x 按钮并关闭窗口后需要执行其他操作,可以在HelloWorld类中添加stop()方法的实现,例如如下所示:
@Override
public void stop(){
System.out.println("Doing what has to be done before closing");
}
如果是,则单击 x 按钮后,显示屏将显示以下内容:

这个例子让您了解 JavaFX 是如何工作的。从现在开始,在回顾 JavaFX 功能的同时,我们将只展示start()方法中的代码。
这个工具箱有大量的包,每个包有许多类,每个类有许多方法,我们没有机会讨论所有这些。相反,我们将概述 JavaFX 功能的所有主要方面,并以最简单、最直接的方式展示它。
控制元素
控制元素包含在javafx.scene.control包装中。其中有 80 多个,包括按钮、文本字段、复选框、标签、菜单、进度条和滚动条等等。正如我们已经提到的,每个控制元素都是Node的一个子类,它有 200 多个方法。因此,您可以想象使用 JavaFX 可以构建多丰富、多精细的 GUI。然而,这本书的范围允许我们只涵盖一些元素及其方法。
我们已经看到一个按钮。现在让我们使用一个标签和一个文本字段来创建一个带有输入字段(名字、姓氏和年龄)和一个submit按钮的简单表单。我们将分步建造。以下所有代码片段都是start()方法的连续部分。
首先,让我们创建控件:
Text txt = new Text("Fill the form and click Submit");
TextField tfFirstName = new TextField();
TextField tfLastName = new TextField();
TextField tfAge = new TextField();
Button btn = new Button("Submit");
btn.setOnAction(e:> action(tfFirstName, tfLastName, tfAge));
正如你所猜测的,文本将被用作形式说明。其余部分非常简单,看起来与我们在HelloWolrd示例中看到的非常相似。action()是一个按以下方法实现的函数:
void action(TextField tfFirstName,
TextField tfLastName, TextField tfAge ) {
String fn = tfFirstName.getText();
String ln = tfLastName.getText();
String age = tfAge.getText();
int a = 42;
try {
a = Integer.parseInt(age);
} catch (Exception ex){}
fn = fn.isBlank() ? "Nick" : fn;
ln = ln.isBlank() ? "Samoylov" : ln;
System.out.println("Hello, " + fn + " " + ln + ", age " + a + "!");
Platform.exit();
}
此函数接受三个参数(javafx.scene.control.TextField对象),然后获取提交的输入值并打印它们。该代码确保始终有一些默认值可用于打印,并且输入非数字的年龄值不会中断应用。
在控件和操作就位后,我们使用类javafx.scene.layout.GridPane将它们放入网格布局中:
GridPane grid = new GridPane();
grid.setAlignment(Pos.CENTER);
grid.setHgap(15);
grid.setVgap(5);
grid.setPadding(new Insets(20, 20, 20, 20));
GridPane布局窗格有行和列,这些行和列构成可以在其中设置节点的单元格。节点可以跨越列和行,setAlignment()方法将网格的位置设置为场景的中心(默认位置为场景的左上角)。setHgap()和setVgap()方法设置列(水平)和行(垂直)之间的间距(以像素为单位)。setPadding()方法沿网格窗格的边界添加一些空间。Insets()对象按上、右、下、左的顺序设置值(以像素为单位)
现在我们将把创建的节点放在相应的单元格中(按两列排列):
int i = 0;
grid.add(txt, 1, i++, 2, 1);
GridPane.setHalignment(txt, HPos.CENTER);
grid.addRow(i++, new Label("First Name"), tfFirstName);
grid.addRow(i++, new Label("Last Name"), tfLastName);
grid.addRow(i++, new Label("Age"), tfAge);
grid.add(btn, 1, i);
GridPane.setHalignment(btn, HPos.CENTER);
add()方法接受三个或五个参数:
- 节点、列索引、行索引
- 节点、列索引、行索引、要跨多少列、要跨多少行
列和行索引从0开始
setHalignment()方法设置节点在小区中的位置。枚举HPos有值:LEFT、RIGHT、CENTER。方法addRow(int i, Node... nodes)接受行索引和节点变量。我们用它来放置Label和TextField对象
start()方法的其余部分与HellowWorld示例非常相似(只有标题和大小发生了变化):
primaryStage.setTitle("Simple form example");
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("Bye! See you later!"));
primaryStage.setScene(new Scene(grid, 300, 200));
primaryStage.show();
如果我们运行刚刚实现的start()方法,结果如下:

我们可以按如下方式填写数据,例如:

单击“提交”按钮后,将显示以下消息,并且应用已存在:

为了帮助可视化布局,特别是在更复杂的设计中,可以使用网格方法setGridLinesVisible(boolean v)使网格线可见。这有助于查看单元格的对齐方式。我们可以在示例中添加以下行:
grid.setGridLinesVisible(true);
我们再运行一次,结果如下:

如您所见,布局现在已明确列出,这有助于可视化设计。
javafx.scene.layout包包括 24 个布局类,例如Pane(我们在HelloWorld示例中看到过)、StackPane(允许我们覆盖节点)、FlowPane(允许节点的位置随着窗口大小的变化而流动)、AnchorPane(保留节点相对于其锚定点的位置),等等。VBox布局将在下一节“图表”中演示。
图表
JavaFX 为javafx.scene.chart包中的数据可视化提供了以下图表组件:
LineChart:在一系列数据点之间添加一条线;通常用于表示随时间变化的趋势AreaChart:与LineChart类似,但填充连接数据点的线和轴之间的区域;通常用于比较一段时间内累积的总和BarChart:以矩形条表示数据,用于离散数据的可视化PieChart:表示一个分为若干段的圆(用不同的颜色填充),每一段代表一个值占总数的比例,我们将在本节中演示BubbleChart:将数据呈现为二维椭圆形,称为气泡,允许呈现三个参数ScatterChart:按原样显示序列中的数据点;有助于识别是否存在聚类(数据相关性)
下面的示例演示如何将测试结果显示为饼图。每个段表示成功、失败或忽略的测试数:
Text txt = new Text("Test results:");
PieChart pc = new PieChart();
pc.getData().add(new PieChart.Data("Succeed", 143));
pc.getData().add(new PieChart.Data("Failed" , 12));
pc.getData().add(new PieChart.Data("Ignored", 18));
VBox vb = new VBox(txt, pc);
vb.setAlignment(Pos.CENTER);
vb.setPadding(new Insets(10, 10, 10, 10));
primaryStage.setTitle("A chart example");
primaryStage.onCloseRequestProperty()
.setValue(e:> System.out.println("Bye! See you later!"));
primaryStage.setScene(new Scene(vb, 300, 300));
primaryStage.show();
我们已经创建了两个节点-Text和PieChart,并将它们放置在VBox布局的单元格中,该布局将它们设置为一列,一个在另一列之上。我们在VBox窗格的边缘添加了 10 个像素的填充。请注意,VBox扩展了Node和Pane类,就像其他窗格一样。我们还使用setAlignment()方法将窗格放置在场景中心。其余部分与前面的所有示例相同,只是场景标题和大小不同
如果我们运行前面的示例,结果如下:

PieChart类以及任何其他图表都有许多其他方法,这些方法对于以用户友好的方式呈现更复杂和动态的数据非常有用。
应用 CSS
默认情况下,JavaFX 使用分发 Jar 文件附带的样式表。要覆盖默认样式,可以使用getStylesheets()方法将样式表添加到场景中:
scene.getStylesheets().add("/mystyle.css");
mystyle.css文件必须放在src/main/resources文件夹中。让我们这样做,并将具有以下内容的mystyle.css文件添加到HelloWorld示例中:
#text-hello {
:fx-font-size: 20px;
-fx-font-family: "Arial";
-fx-fill: red;
}
.button {
-fx-text-fill: white;
-fx-background-color: slateblue;
}
如您所见,我们希望以某种方式设置按钮节点和具有 IDtext-hello的Text节点的样式。我们还必须修改 HelloWorld 示例,将 ID 添加到Text元素中,并将样式表文件添加到场景中:
Text txt = new Text("Hello, world!");
txt.setId("text-hello");
txt.relocate(115, 40);
Button btn = new Button("Exit");
btn.relocate(155, 80);
btn.setOnAction(e -> {
System.out.println("Bye! See you later!");
Platform.exit();
});
Pane pane = new Pane();
pane.getChildren().addAll(txt, btn);
Scene scene = new Scene(pane, 350, 150);
scene.getStylesheets().add("/mystyle.css");
primaryStage.setTitle("The primary stage (top-level container)");
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("\nBye! See you later!"));
primaryStage.setScene(scene);
primaryStage.show();
如果现在运行此代码,结果如下:

或者,可以在将用于覆盖文件样式表的任何节点上设置内联样式,无论是否为默认样式
btn.setStyle("-fx-text-fill: white; -fx-background-color: red;");
如果我们再次运行该示例,结果如下:

浏览 JavaFXCSS 参考指南了解定制造型的种类和可能的选择。
使用 FXML
FXML 是一种基于 XML 的语言,它允许构建一个用户界面,并独立地维护应用(业务)逻辑的用户界面(就外观和感觉或其他与表示相关的更改而言)。使用 FXML,您甚至不用编写一行 Java 代码就可以设计用户界面。
FXML 没有模式,但其功能反映了用于构建场景的 JavaFX 对象的 API。这意味着您可以使用 API 文档来了解 FXML 结构中允许哪些标记和属性。大多数情况下,JavaFX 类可以用作标记,它们的属性可以用作属性。
除了 FXML 文件(视图)之外,控制器(Java 类)还可以用于处理模型和组织页面流。模型由视图和控制器管理的域对象组成。它还允许使用 CSS 样式和 JavaScript 的所有功能。但在本书中,我们将只能演示基本的 FXML 功能。剩下的和许多在线的好教程可以在 FXML 简介中找到。
为了演示 FXML 的用法,我们将复制在“控制元素”部分中创建的简单表单,然后通过添加页面流来增强它。以下是我们的名、姓和年龄表单如何在 FXML 中表达:
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.Scene?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.text.Text?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.control.TextField?>
<Scene fx:controller="com.packt.learnjava.ch12_gui.HelloWorldController"
xmlns:fx="http://javafx.com/fxml"
width="350" height="200">
<GridPane alignment="center" hgap="15" vgap="5">
<padding>
<Insets top="20" right="20" bottom="20" left="20"/>
</padding>
<Text id="textFill" text="Fill the form and click Submit"
GridPane.rowIndex="0" GridPane.columnSpan="2">
<GridPane.halignment>center</GridPane.halignment>
</Text>
<Label text="First name"
GridPane.columnIndex="0" GridPane.rowIndex="1"/>
<TextField fx:id="tfFirstName"
GridPane.columnIndex="1" GridPane.rowIndex="1"/>
<Label text="Last name"
GridPane.columnIndex="0" GridPane.rowIndex="2"/>
<TextField fx:id="tfLastName"
GridPane.columnIndex="1" GridPane.rowIndex="2"/>
<Label text="Age"
GridPane.columnIndex="0" GridPane.rowIndex="3"/>
<TextField fx:id="tfAge"
GridPane.columnIndex="1" GridPane.rowIndex="3"/>
<Button text="Submit"
GridPane.columnIndex="1" GridPane.rowIndex="4"
onAction="#submitClicked">
<GridPane.halignment>center</GridPane.halignment>
</Button>
</GridPane>
</Scene>
如您所见,它表达了您已经熟悉的所需场景结构,并指定了控制器类HelloWorldController,我们稍后将看到它。正如我们已经提到的,这些标记与我们用来仅用 Java 构建同一 GUI 的类名相匹配。我们将把helloWorld.fxml文件放入resources文件夹。
现在让我们看一下使用前面的FXML文件的HelloWorld类的start()方法实现:
try {
FXMLLoader lder = new FXMLLoader();
lder.setLocation(new URL("file:src/main/resources/helloWorld.fxml"));
Scene scene = lder.load();
primaryStage.setTitle("Simple form example");
primaryStage.setScene(scene);
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("\nBye! See you later!"));
primaryStage.show();
} catch (Exception ex){
ex.printStackTrace();
}
start()方法只是加载helloWorld.fxml文件并设置舞台,后者的操作与前面的示例完全相同。现在让我们看看HelloWorldController类,如果需要,我们可以启动只有以下内容的应用:
public class HelloWorldController {
@FXML
protected void submitClicked(ActionEvent e) {
}
}
表单将被显示,但按钮单击将不起任何作用。这就是我们在讨论独立于应用逻辑的用户界面开发时的意思。注意@FXML注解。它使用 FXML 标记的 ID 将方法和属性绑定到 FXML 标记。以下是完整控制器实现的外观:
@FXML
private TextField tfFirstName;
@FXML
private TextField tfLastName;
@FXML
private TextField tfAge;
@FXML
protected void submitClicked(ActionEvent e) {
String fn = tfFirstName.getText();
String ln = tfLastName.getText();
String age = tfAge.getText();
int a = 42;
try {
a = Integer.parseInt(age);
} catch (Exception ex) {
}
fn = fn.isBlank() ? "Nick" : fn;
ln = ln.isBlank() ? "Samoylov" : ln;
System.out.println("Hello, " + fn + " " + ln + ", age " + a + "!");
Platform.exit();
}
在大多数情况下,你应该很熟悉它。唯一的区别是我们并没有直接引用字段及其值(如前所述),而是使用带有注解@FXML的绑定。如果现在运行HelloWorld类,页面外观和行为将与我们在“控制元素”部分中描述的完全相同。
现在,我们添加另一个页面并修改代码,以便在点击Submit按钮后,控制器将提交的值发送到另一个页面并关闭表单。为了简单起见,新页面将只显示接收到的数据。以下是 FXML 的外观:
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.Scene?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.text.Text?>
<?import javafx.scene.layout.GridPane?>
<Scene fx:controller="com.packt.lernjava.ch12_gui.HelloWorldController2"
xmlns:fx="http://javafx.com/fxml"
width="350" height="150">
<GridPane alignment="center" hgap="15" vgap="5">
<padding>
<Insets top="20" right="20" bottom="20" left="20"/>
</padding>
<Text fx:id="textUser"
GridPane.rowIndex="0" GridPane.columnSpan="2">
<GridPane.halignment>center</GridPane.halignment>
</Text>
<Text id="textDo" text="Do what has to be done here"
GridPane.rowIndex="1" GridPane.columnSpan="2">
<GridPane.halignment>center</GridPane.halignment>
</Text>
</GridPane>
</Scene>
如您所见,页面只有两个只读的Text字段。第一个(带id="textUser")将显示上一页传递的数据。第二个只会显示消息“执行此处必须执行的操作”。这不是很复杂,但它演示了如何组织数据流和页面。
新页面使用不同的控制器,如下所示:
package com.packt.learnjava.ch12_gui;
import javafx.fxml.FXML;
import javafx.scene.text.Text;
public class HelloWorldController2 {
@FXML
public Text textUser;
}
正如您可能猜到的,公共字段textUser必须由第一个控制器HelloWolrdController填充值。我们开始吧。我们修改submitClicked()方法如下:
@FXML
protected void submitClicked(ActionEvent e) {
String fn = tfFirstName.getText();
String ln = tfLastName.getText();
String age = tfAge.getText();
int a = 42;
try {
a = Integer.parseInt(age);
} catch (Exception ex) {}
fn = fn.isBlank() ? "Nick" : fn;
ln = ln.isBlank() ? "Samoylov" : ln;
String user = "Hello, " + fn + " " + ln + ", age " + a + "!";
//System.out.println("\nHello, " + fn + " " + ln + ", age " + a + "!");
//Platform.exit();
goToPage2(user);
Node source = (Node) e.getSource();
Stage stage = (Stage) source.getScene().getWindow();
stage.close();
}
我们不只是打印提交的(或默认的)数据并退出应用(参见注释掉的两行),而是调用goToPage2()方法并将提交的数据作为参数传递。然后我们从事件中提取对当前窗口阶段的引用并关闭它
goToPage2()方法如下:
try {
FXMLLoader lder = new FXMLLoader();
lder.setLocation(new URL("file:src/main/resources/helloWorld2.fxml"));
Scene scene = lder.load();
HelloWorldController2 c = loader.getController();
c.textUser.setText(user);
Stage primaryStage = new Stage();
primaryStage.setTitle("Simple form example. Page 2.");
primaryStage.setScene(scene);
primaryStage.onCloseRequestProperty()
.setValue(e -> {
System.out.println("Bye! See you later!");
Platform.exit();
});
primaryStage.show();
} catch (Exception ex) {
ex.printStackTrace();
}
它加载helloWorld2.fxml文件,从中提取控制器对象,并在其上设置传入的值。其余的与您现在已经见过几次的舞台配置相同。唯一的区别是第 2 页被添加到标题中
如果我们现在执行HelloWorld类,我们将看到熟悉的表单并用数据填充它:

单击“提交”按钮后,此窗口将关闭并显示新窗口:

我们单击左上角的 x 按钮(或者在 Windows 上单击右上角),会看到与我们之前看到的相同的消息:

同级动作功能和stop()方法如预期效果。
至此,我们结束了对 FXML 的介绍,并进入下一个主题,即向 JavaFX 应用添加 HTML。
嵌入 HTML
向 JavaFX 添加 HTML 很容易。您所要做的就是使用javafx.scene.web.WebView类,该类提供了一个窗口,在该窗口中,添加的 HTML 的呈现方式与浏览器中的呈现方式类似。WebView类使用开源浏览器引擎 WebKit,因此支持完整的浏览功能。
与所有其他 JavaFX 组件一样,WebView类扩展了Node类,可以在 Java 代码中这样处理。此外,它有自己的属性和方法,允许通过设置窗口大小(最大值、最小值和首选高度和宽度)、字体比例、缩放率、添加 CSS、启用上下文(右键单击)菜单等来调整浏览器窗口以适应所包括的应用是的。它提供了加载 HTML 页面、导航页面、对加载的页面应用不同样式、访问页面浏览历史和文档模型以及执行 JavaScript 的功能
要开始使用javafx.scene.web包,必须首先采取两个步骤:
- 将以下依赖项添加到
pom.xml文件:
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>11.0.2</version>
</dependency>
javafx-web的版本通常与 Java 版本保持同步,但在撰写本文时,javafx-web的第 12 版尚未发布,因此我们使用的是最新的可用版本 11.0.2。
- 因为
javafx-web使用了从 Java9 中删除的包(com.sun.*),要从 Java9+ 访问com.sun.*包,除了设置--module-path和--add-modules之外,还要设置以下 VM 选项,在ch12_gui包的HtmlWebView类的“JavaFX 基础”部分的运行/调试配置中描述:
--add-exports javafx.graphics/com.sun.javafx.sg.prism=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.javafx.util=ALL-UNNAMED
--add-exports javafx.base/com.sun.javafx.logging=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.prism=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.glass.ui=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.javafx.geom.transform=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.glass.utils=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.javafx.font=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.javafx.application=ALL-UNNAMED
--add-exports javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.javafx.scene.input=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.prism.paint=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.scenario.effect=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.javafx.text=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.javafx.iio=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.scenario.effect.impl.prism=ALL-UNNAMED
--add-exports javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED
要从命令行执行类HtmlWebView,请使用以下命令:
mvn clean package
java --module-path /path/javaFX/lib --add-modules=javafx.controls,javafx.fxml --add-exports javafx.graphics/com.sun.javafx.sg.prism=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.util=ALL-UNNAMED --add-exports javafx.base/com.sun.javafx.logging=ALL-UNNAMED --add-exports javafx.graphics/com.sun.prism=ALL-UNNAMED --add-exports javafx.graphics/com.sun.glass.ui=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.geom.transform=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED --add-exports javafx.graphics/com.sun.glass.utils=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.font=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.application=ALL-UNNAMED --add-exports javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.scene.input=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED --add-exports javafx.graphics/com.sun.prism.paint=ALL-UNNAMED --add-exports javafx.graphics/com.sun.scenario.effect=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.text=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.iio=ALL-UNNAMED --add-exports javafx.graphics/com.sun.scenario.effect.impl.prism=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED -cp target/learnjava-1.0-SNAPSHOT.jar:target/libs/* com.packt.learnjava.ch12_gui.HtmlWebView
在 Windows 上,相同的命令如下所示:
mvn clean package
java --module-path C:\path\JavaFX\lib --add-modules=javafx.controls,javafx.fxml --add-exports javafx.graphics/com.sun.javafx.sg.prism=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.scene=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.util=ALL-UNNAMED --add-exports javafx.base/com.sun.javafx.logging=ALL-UNNAMED --add-exports javafx.graphics/com.sun.prism=ALL-UNNAMED --add-exports javafx.graphics/com.sun.glass.ui=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.geom.transform=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.tk=ALL-UNNAMED --add-exports javafx.graphics/com.sun.glass.utils=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.font=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.application=ALL-UNNAMED --add-exports javafx.controls/com.sun.javafx.scene.control=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.scene.input=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.geom=ALL-UNNAMED --add-exports javafx.graphics/com.sun.prism.paint=ALL-UNNAMED --add-exports javafx.graphics/com.sun.scenario.effect=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.text=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.iio=ALL-UNNAMED --add-exports javafx.graphics/com.sun.scenario.effect.impl.prism=ALL-UNNAMED --add-exports javafx.graphics/com.sun.javafx.scene.text=ALL-UNNAMED -cp target\learnjava-1.0-SNAPSHOT.jar;target\libs\* com.packt.learnjava.ch12_gui.HtmlWebView
类HtmlWebView也包含几个start()方法。按照“JavaFX 基础”一节中的描述,逐个重命名并执行它们。
现在我们来看几个例子。我们创建一个新的应用HtmlWebView,并使用前面描述的 VM 选项--module-path、--add-modules和--add-exports为其设置 VM 选项。现在我们可以编写并执行一个使用WebView类的代码。
首先,下面是如何将简单的 HTML 添加到 JavaFX 应用:
WebView wv = new WebView();
WebEngine we = wv.getEngine();
String html = "<html><center><h2>Hello, world!</h2></center></html>";
we.loadContent(html, "text/html");
Scene scene = new Scene(wv, 200, 60);
primaryStage.setTitle("My HTML page");
primaryStage.setScene(scene);
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("Bye! See you later!"));
primaryStage.show();
前面的代码创建一个WebView对象,从中获取WebEngine对象,使用获取的WebEngine对象加载 HTML,在场景中设置WebView对象,并配置舞台。loadContent()方法接受两个字符串:内容及其 MIME 类型。内容字符串可以在代码中构造,也可以通过读取.html文件来创建
如果我们运行前面的示例,结果如下:

如果需要,您可以在同一窗口中显示其他 JavaFX 节点以及WebView对象。例如,让我们在嵌入的 HTML 上面添加一个Text节点:
Text txt = new Text("Below is the embedded HTML:");
WebView wv = new WebView();
WebEngine we = wv.getEngine();
String html = "<html><center><h2>Hello, world!</h2></center></html>";
we.loadContent(html, "text/html");
VBox vb = new VBox(txt, wv);
vb.setSpacing(10);
vb.setAlignment(Pos.CENTER);
vb.setPadding(new Insets(10, 10, 10, 10));
Scene scene = new Scene(vb, 300, 120);
primaryStage.setScene(scene);
primaryStage.setTitle("JavaFX with embedded HTML");
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("Bye! See you later!"));
primaryStage.show();
如您所见,WebView对象不是直接设置在场景上,而是与txt对象一起设置在布局对象上。然后,在场景中设置布局对象。上述代码的结果如下:

对于更复杂的 HTML 页面,可以使用load()方法直接从文件加载。为了演示这种方法,我们在resources文件夹中创建form.htm文件,内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>The Form</title>
</head>
<body>
<form action="http://server:port/formHandler" metrod="post">
<table>
<tr>
<td><label for="firstName">Firts name:</label></td>
<td><input type="text" id="firstName" name="firstName"></td>
</tr>
<tr>
<td><label for="lastName">Last name:</label></td>
<td><input type="text" id="lastName" name="lastName"></td>
</tr>
<tr>
<td><label for="age">Age:</label></td>
<td><input type="text" id="age" name="age"></td>
</tr>
<tr>
<td></td>
<td align="center">
<button id="submit" name="submit">Submit</button>
</td>
</tr>
</table>
</form>
</body>
</html>
这个 HTML 呈现的表单与我们在Using FXML部分中创建的表单类似。单击Submit按钮后,表单数据被发布到服务器的\formHandlerURI 中。要在 JavaFX 应用中显示此表单,可以使用以下代码:
Text txt = new Text("Fill the form and click Submit");
WebView wv = new WebView();
WebEngine we = wv.getEngine();
File f = new File("src/main/resources/form.html");
we.load(f.toURI().toString());
VBox vb = new VBox(txt, wv);
vb.setSpacing(10);
vb.setAlignment(Pos.CENTER);
vb.setPadding(new Insets(10, 10, 10, 10));
Scene scene = new Scene(vb, 300, 200);
primaryStage.setScene(scene);
primaryStage.setTitle("JavaFX with embedded HTML");
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("Bye! See you later!"));
primaryStage.show();
如您所见,与其他示例的不同之处在于,我们现在使用File类及其toURI()方法直接访问src/main/resources/form.html文件中的 HTML,而无需先将内容转换为字符串。结果如下:

当您需要从 JavaFX 应用发送请求或发布数据时,此解决方案非常有用。但是当您希望用户填写的表单在服务器上已经可用时,您可以从 URL 加载它。例如,让我们将 Google 搜索合并到 JavaFX 应用中。我们可以通过将load()方法的参数值更改为要加载的页面的 URL 来实现:
Text txt = new Text("Enjoy searching the Web!");
WebView wv = new WebView();
WebEngine we = wv.getEngine();
we.load("http://www.google.com");
VBox vb = new VBox(txt, wv);
vb.setSpacing(20);
vb.setAlignment(Pos.CENTER);
vb.setStyle("-fx-font-size: 20px;-fx-background-color: lightblue;");
vb.setPadding(new Insets(10, 10, 10, 10));
Scene scene = new Scene(vb,750,500);
primaryStage.setScene(scene);
primaryStage.setTitle("JavaFX with the window to another server");
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("Bye! See you later!"));
primaryStage.show();
我们还为布局添加了一个样式,以便增加字体并为背景添加颜色,这样我们就可以看到嵌入呈现的 HTML 的区域的轮廓。运行此示例时,将出现以下窗口:

在此窗口中,您可以执行通常通过浏览器访问的搜索的所有方面。
而且,正如我们已经提到的,您可以放大呈现的页面。例如,如果我们将wv.setZoom(1.5)行添加到前面的示例中,结果如下:

同样,我们可以从文件中设置字体的比例,甚至样式:
wv.setFontScale(1.5);
we.setUserStyleSheetLocation("mystyle.css");
但是请注意,我们在WebView对象上设置了字体比例,而在WebEngine对象中设置了样式
我们也可以使用WebEngine类方法getDocument()访问(和操作)加载页面的 DOM 对象:
Document document = we.getDocument();
我们还可以访问浏览历史,获取当前索引,并前后移动历史:
WebHistory history = we.getHistory();
int currInd = history.getCurrentIndex();
history.go(-1);
history.go( 1);
对于历史记录的每个条目,我们可以提取其 URL、标题或上次访问日期:
WebHistory history = we.getHistory();
ObservableList<WebHistory.Entry> entries = history.getEntries();
for(WebHistory.Entry entry: entries){
String url = entry.getUrl();
String title = entry.getTitle();
Date date = entry.getLastVisitedDate();
}
阅读WebView和WebEngine类的文档,了解如何利用它们的功能。
播放媒体
向 JavaFX 应用的场景添加图像不需要com.sun.*包,因此不需要“添加 HTML”部分中列出的--add-exportVM 选项。但是不管怎样,拥有它们并没有什么坏处,所以如果您已经添加了它们,那么就保留--add-export选项。
可以使用类javafx.scene.image.Image和javafx.scene.image.ImageView将图像包括在场景中。为了演示如何做到这一点,我们将使用位于resources文件夹中的 Packt logopackt.png。下面是执行此操作的代码:
Text txt = new Text("What a beautiful image!");
FileInputStream input =
new FileInputStream("src/main/resources/packt.png");
Image image = new Image(input);
ImageView iv = new ImageView(image);
VBox vb = new VBox(txt, iv);
vb.setSpacing(20);
vb.setAlignment(Pos.CENTER);
vb.setPadding(new Insets(10, 10, 10, 10));
Scene scene = new Scene(vb, 300, 200);
primaryStage.setScene(scene);
primaryStage.setTitle("JavaFX with embedded HTML");
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("Bye! See you later!"));
primaryStage.show();
如果我们运行前面的代码,结果如下:

当前支持的图像格式有 BMP、GIF、JPEG 和 PNG。查看Image和ImageView类学习根据需要格式化和调整图像的多种方法。
现在让我们看看如何在 JavaFX 应用中使用其他媒体文件。播放音频或电影文件需要在“添加 HTML”部分中列出的--add-exportVM 选项
当前支持的编码如下:
- AAC:高级音频编码的音频压缩
- H.264/AVC:H.264/MPEG-4/AVC(高级视频编码)视频压缩
- MP3:原始 MPEG-1、2 和 2.5 音频;第一层、第二层和第三层
- PCM:未压缩的原始音频样本
您可以在 API 文档中看到对支持的协议、媒体容器和元数据标记的更详细的描述。
以下三个类允许构造可以添加到场景的媒体播放器:
javafx.scene.media.Media;
javafx.scene.media.MediaPlayer;
javafx.scene.media.MediaView;
Media类表示媒体的来源,MediaPlayer类提供了控制媒体播放的所有方法:play(),``stop()、pause()、setVolume()等。您还可以指定媒体播放的次数。MediaView类扩展了Node类,可以添加到场景中。它提供媒体播放器正在播放的媒体的视图。它负责在媒体上露面。
为了演示,让我们在HtmlWebView应用中添加另一个版本的start()方法,该方法播放位于resources文件夹中的jb.mp3文件:
Text txt1 = new Text("What a beautiful music!");
Text txt2 = new Text("If you don't hear music, turn up the volume.");
File f = new File("src/main/resources/jb.mp3");
Media m = new Media(f.toURI().toString());
MediaPlayer mp = new MediaPlayer(m);
MediaView mv = new MediaView(mp);
VBox vb = new VBox(txt1, txt2, mv);
vb.setSpacing(20);
vb.setAlignment(Pos.CENTER);
vb.setPadding(new Insets(10, 10, 10, 10));
Scene scene = new Scene(vb, 350, 100);
primaryStage.setScene(scene);
primaryStage.setTitle("JavaFX with embedded media player");
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("Bye! See you later!"));
primaryStage.show();
mp.play();
注意如何基于源文件构造一个Media对象;然后基于Media对象构造MediaPlayer对象,然后将其设置为MediaView类构造器的属性。MediaView对象与两个Text对象一起设置在场景中。我们使用VBox对象来提供布局。最后,在舞台上设置场景并且舞台变得可见之后(在show()方法完成之后),在MediaPlayer对象上调用play()方法。默认情况下,媒体播放一次。
如果执行上述代码,将出现以下窗口并播放jb.m3文件:

我们可以添加控件来停止、暂停和调整音量,但这将需要更多的代码,这不符合本书的预期大小。您可以在 Oracle 在线文档中找到有关如何执行此操作的指南。
sea.mp4电影文件可以类似地播放:
Text txt = new Text("What a beautiful movie!");
File f = new File("src/main/resources/sea.mp4");
Media m = new Media(f.toURI().toString());
MediaPlayer mp = new MediaPlayer(m);
MediaView mv = new MediaView(mp);
VBox vb = new VBox(txt, mv);
vb.setSpacing(20);
vb.setAlignment(Pos.CENTER);
vb.setPadding(new Insets(10, 10, 10, 10));
Scene scene = new Scene(vb, 650, 400);
primaryStage.setScene(scene);
primaryStage.setTitle("JavaFX with embedded media player");
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("Bye! See you later!"));
primaryStage.show();
mp.play();
唯一的区别是不同大小的场景需要显示这个特定剪辑的完整帧。经过几次试错调整,我们找到了必要的尺寸。或者,我们可以使用MediaView方法autosize()、preserveRatioProperty()、setFitHeight()、setFitWidth()、fitWidthProperty()、fitHeightProperty()以及类似的方法来调整嵌入窗口的大小并自动匹配场景的大小。如果执行上述示例,将弹出以下窗口并播放片段:

我们甚至可以同时播放音频和视频文件,从而为电影提供配乐:
Text txt1 = new Text("What a beautiful movie and sound!");
Text txt2 = new Text("If you don't hear music, turn up the volume.");
File fs = new File("src/main/resources/jb.mp3");
Media ms = new Media(fs.toURI().toString());
MediaPlayer mps = new MediaPlayer(ms);
MediaView mvs = new MediaView(mps);
File fv = new File("src/main/resources/sea.mp4");
Media mv = new Media(fv.toURI().toString());
MediaPlayer mpv = new MediaPlayer(mv);
MediaView mvv = new MediaView(mpv);
VBox vb = new VBox(txt1, txt2, mvs, mvv);
vb.setSpacing(20);
vb.setAlignment(Pos.CENTER);
vb.setPadding(new Insets(10, 10, 10, 10));
Scene scene = new Scene(vb, 650, 500);
primaryStage.setScene(scene);
primaryStage.setTitle("JavaFX with embedded media player");
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("Bye! See you later!"));
primaryStage.show();
mpv.play();
mps.play();
这是可能的,因为每个播放器都由自己的线程执行
有关javafx.scene.media包的更多信息,请在线阅读 API 和开发者指南:
- https://openjfx.io/javadoc/11/javafx.media/javafx/scene/media/package-summary.html
- https://docs.oracle.com/javafx/2/media/jfxpub-media.htm
添加效果
javafx.scene.effects包包含许多类,允许向节点添加各种效果:
Blend:使用一个预定义的BlendMode组合来自两个源(通常是图像)的像素Bloom:使输入图像更亮,使其看起来发光BoxBlur:为图像添加模糊ColorAdjust:允许调整图像的色调、饱和度、亮度和对比度ColorInput:呈现一个矩形区域,其中填充了给定的PaintDisplacementMap:将每个像素移动指定的距离DropShadow:在内容后面呈现给定内容的阴影GaussianBlur:使用特定(高斯)方法添加模糊Glow:使输入图像看起来发光InnerShadow:在帧内创建阴影Lighting:模拟光源照射在内容上;使平面对象看起来更逼真MotionBlur:模拟运动中看到的给定内容PerspectiveTransform:从一个角度转换内容Reflection:呈现低于实际输入内容的输入的反射版本SepiaTone:产生暗褐色的色调效果,类似于古董照片的外观Shadow:创建具有模糊边缘的内容的单色副本
所有效果共享父级-抽象类Effect。Node类具有setEffect(Effect e)方法,这意味着可以将任何效果添加到任何节点。这是将效果应用于节点的主要方式,演员在舞台上产生场景(如果我们回想一下本章开头介绍的类比)
唯一的例外是Blend效果,这使得它的使用比其他效果的使用更加复杂。除了使用setEffect(Effect e)方法外,一些Node类的子项还有setBlendMode(BlendMode bm)方法,可以调节图像重叠时如何相互融合。因此,可以以不同的方式设置不同的混合效果,以相互覆盖,并产生可能难以调试的意外结果。这就是为什么Blend效果的使用更加复杂,这就是为什么我们要开始概述Blend效果如何使用的原因。
有四个方面可以控制两个图像重叠区域的外观(我们在示例中使用两个图像使其更简单,但实际上,许多图像可以重叠):
- 不透明度属性的值:定义通过图像可以看到多少;不透明度值 0.0 表示图像是完全透明的,而不透明度值 1.0 表示后面看不到任何东西。
- 每种颜色的 alpha 值和强度:将颜色的透明度定义为 0.0-1.0 或 0-255 范围内的双倍值。
- 混合模式,由
enum BlendMode值定义:取决于每种颜色的模式、不透明度和 alpha 值,结果也可能取决于将图像添加到场景的顺序;第一个添加的图像称为底部输入,而重叠图像中的第二个称为顶部输入;如果顶部输入完全不透明,则底部输入被顶部输入隐藏。
重叠区域的结果外观是基于不透明度、颜色的 alpha 值、颜色的数值(强度)和混合模式计算的,混合模式可以是以下之一:
ADD:顶部输入的颜色和 alpha 分量与底部输入的颜色和 alpha 分量相加BLUE:将底部输入的蓝色分量替换为顶部输入的蓝色分量;其他颜色分量不受影响COLOR_BURN:将底部输入颜色分量的倒数除以顶部输入颜色分量,然后全部倒数以产生结果颜色COLOR_DODGE:将底部输入颜色分量除以顶部输入颜色分量的倒数,得到结果颜色DARKEN:选择来自两个输入的颜色分量中较暗的部分来产生结果颜色DIFFERENCE:将两个输入的颜色分量中较深的分量从较浅的分量中减去,得到结果颜色EXCLUSION:将两个输入的颜色分量相乘并加倍,然后从底部输入的颜色分量之和中减去,得到结果颜色GREEN:将底部输入的绿色分量替换为顶部输入的绿色分量;其他颜色分量不受影响HARD_LIGHT:根据顶部的输入颜色,输入颜色分量可以是相乘的,也可以是过滤的LIGHTEN:从两个输入中选择颜色分量中的较浅者来产生结果颜色MULTIPLY:第一次输入的颜色分量与第二次输入的颜色分量相乘OVERLAY:根据底部的输入颜色,输入颜色分量可以是相乘的,也可以是过滤的RED:将底部输入的红色分量替换为顶部输入的红色分量;其他颜色分量不受影响SCREEN:来自两个输入的颜色分量被反转,彼此相乘,并且该结果再次被反转以产生结果颜色SOFT_LIGHT:根据顶部的输入颜色,输入颜色组件要么变暗,要么变亮SRC_ATOP:顶部输入位于底部输入内部的部分与底部输入混合SRC_OVER:顶部输入与底部输入混合
为了演示Blend效果,让我们创建另一个名为BlendEffect的应用。它不需要com.sun.*包,因此不需要--add-exportVM 选项。编译和执行时只需设置--module-path和--add-modules选项,如“JavaFX 基础”一节所述
本书的范围不允许我们演示所有可能的组合,因此我们将创建一个红色圆圈和一个蓝色正方形:
Circle createCircle(){
Circle c = new Circle();
c.setFill(Color.rgb(255, 0, 0, 0.5));
c.setRadius(25);
return c;
}
Rectangle createSquare(){
Rectangle r = new Rectangle();
r.setFill(Color.rgb(0, 0, 255, 1.0));
r.setWidth(50);
r.setHeight(50);
return r;
}
我们使用Color.rgb(int red, int green, int blue, double alpha)方法来定义每个图形的颜色。但是还有很多方法可以做到。阅读Color类 API 文档了解更多详细信息。
为了重叠创建的圆和正方形,我们将使用Group节点:
Node c = createCircle();
Node s = createSquare();
Node g = new Group(s, c);
在前面的代码中,正方形是底部输入。我们还将创建一个组,其中正方形是顶部输入:
Node c = createCircle();
Node s = createSquare();
Node g = new Group(c, s);
区别很重要,因为我们将圆定义为半不透明,而正方形是完全不透明的。我们将在所有示例中使用相同的设置
让我们比较两种模式MULTIPLY和SRC_OVER。我们将使用setEffect()方法将它们设置在组上,如下所示:
Blend blnd = new Blend();
blnd.setMode(BlendMode.MULTIPLY);
Node c = createCircle();
Node s = createSquare();
Node g = new Group(s, c);
g.setEffect(blnd);
对于每个模式,我们创建两个组,一个顶部输入一个圆,另一个顶部输入一个正方形,然后我们将创建的四个组放置在一个GridPane布局中(详细信息请参见源代码)。如果我们运行BlendEffect应用,结果将是:

正如所料,当正方形位于顶部(右边的两个图像)时,重叠区域完全由不透明的正方形拍摄。但是,当圆是顶部输入(左边的两个图像)时,重叠区域在某种程度上是可见的,并基于混合效果进行计算。
但是,如果我们直接在组上设置相同的模式,结果会略有不同。让我们运行相同的代码,但在组上设置模式:
Node c = createCircle();
Node s = createSquare();
Node g = new Group(c, s);
g.setBlendMode(BlendMode.MULTIPLY);
如果再次运行应用,结果如下所示:

如您所见,圆圈的红色稍有变化,MULTIPLY和SRC_OVER模式之间没有区别。这就是我们在本节开头提到的场景中添加节点的顺序的问题。
结果也会根据设置效果的节点而变化。例如,与其在组上设置效果,不如仅在圆上设置混合效果:
Blend blnd = new Blend();
blnd.setMode(BlendMode.MULTIPLY);
Node c = createCircle();
Node s = createSquare();
c.setEffect(blnd);
Node g = new Group(s, c);
我们运行应用并看到以下内容:

右侧的两个图像与前面所有示例中的图像相同,但左侧的两个图像显示了重叠区域的新颜色。现在让我们在正方形而不是圆形上设置相同的混合效果,如下所示:
Blend blnd = new Blend();
blnd.setMode(BlendMode.MULTIPLY);
Node c = createCircle();
Node s = createSquare();
s.setEffect(blnd);
Node g = new Group(s, c);
结果将再次发生轻微变化,并显示在以下屏幕截图上:

MULTIPLY和SRC_OVER模式之间没有区别,但是红色与我们在圆上设置效果时的颜色不同
我们可以再次更改方法,并使用以下代码直接在圆上设置混合效果模式:
Node c = createCircle();
Node s = createSquare();
c.setBlendMode(BlendMode.MULTIPLY);
结果再次发生变化:

在正方形上设置混合模式只会再次消除MULTIPLY和SRC_OVER模式之间的差异:

为了避免混淆并使混合的结果更可预测,必须观察节点添加到场景的顺序以及应用混合效果的方式的一致性。
在随书提供的源代码中,您将找到javafx.scene.effects包中包含的所有效果的示例。它们都是通过并排比较来证明的。下面是一个例子:

为方便起见,提供了“暂停”和“继续”按钮,允许您暂停演示并查看混合效果上设置的不同不透明度值的结果。
为了演示所有其他效果,我们创建了另一个名为OtherEffects的应用,它也不需要com.sun.*包,因此不需要--add-exportVM 选项。演示的效果包括Bloom、BoxBlur、ColorAdjust、DisplacementMap、DropShadow、Glow、InnerShadow、Lighting、MotionBlur,PerspectiveTransform、Reflection、ShadowTone和SepiaTone。我们使用了两个图像来展示应用每种效果的结果,即 Packt 徽标和山湖景观:
FileInputStream inputP =
new FileInputStream("src/main/resources/packt.png");
Image imageP = new Image(inputP);
ImageView ivP = new ImageView(imageP);
FileInputStream inputM =
new FileInputStream("src/main/resources/mount.jpeg");
Image imageM = new Image(inputM);
ImageView ivM = new ImageView(imageM);
ivM.setPreserveRatio(true);
ivM.setFitWidth(300);
我们还添加了两个按钮,允许您暂停并继续演示(它会迭代效果及其参数值):
Button btnP = new Button("Pause");
btnP.setOnAction(e1 -> et.pause());
btnP.setStyle("-fx-background-color: lightpink;");
Button btnC = new Button("Continue");
btnC.setOnAction(e2 -> et.cont());
btnC.setStyle("-fx-background-color: lightgreen;");
et对象是EffectsThread线程的对象:
EffectsThread et = new EffectsThread(txt, ivM, ivP);
线程遍历效果列表,创建相应的效果 10 次(使用 10 个不同的效果参数值),每次在每个图像上设置创建的Effect对象,然后休眠一秒钟,让您有机会查看结果:
public void run(){
try {
for(String effect: effects){
for(int i = 0; i < 11; i++){
double d = Math.round(i * 0.1 * 10.0) / 10.0;
Effect e = createEffect(effect, d, txt);
ivM.setEffect(e);
ivP.setEffect(e);
TimeUnit.SECONDS.sleep(1);
if(pause){
while(true){
TimeUnit.SECONDS.sleep(1);
if(!pause){
break;
}
}
}
}
}
Platform.exit();
} catch (Exception ex){
ex.printStackTrace();
}
}
接下来,我们将在带有效果结果的屏幕截图下展示如何创建每个效果。为了呈现结果,我们使用了GridPane布局:
GridPane grid = new GridPane();
grid.setAlignment(Pos.CENTER);
grid.setVgap(25);
grid.setPadding(new Insets(10, 10, 10, 10));
int i = 0;
grid.add(txt, 0, i++, 2, 1);
GridPane.setHalignment(txt, HPos.CENTER);
grid.add(ivP, 0, i++, 2, 1);
GridPane.setHalignment(ivP, HPos.CENTER);
grid.add(ivM, 0, i++, 2, 1);
GridPane.setHalignment(ivM, HPos.CENTER);
grid.addRow(i++, new Text());
HBox hb = new HBox(btnP, btnC);
hb.setAlignment(Pos.CENTER);
hb.setSpacing(25);
grid.add(hb, 0, i++, 2, 1);
GridPane.setHalignment(hb, HPos.CENTER);
最后,创建的GridPane对象被传递到场景中,场景又被放置在您熟悉的舞台上,这些舞台来自我们前面的示例:
Scene scene = new Scene(grid, 450, 500);
primaryStage.setScene(scene);
primaryStage.setTitle("JavaFX effect demo");
primaryStage.onCloseRequestProperty()
.setValue(e3 -> System.out.println("Bye! See you later!"));
primaryStage.show();
下面的屏幕截图描述了 10 个参数值中的 1 个的效果示例。在每个屏幕截图下,我们展示了创建此效果的createEffect(String effect, double d, Text txt)方法的代码片段:

//double d = 0.9;
txt.setText(effect + ".threshold: " + d);
Bloom b = new Bloom();
b.setThreshold(d);

// double d = 0.3;
int i = (int) d * 10;
int it = i / 3;
txt.setText(effect + ".iterations: " + it);
BoxBlur bb = new BoxBlur();
bb.setIterations(i);

double c = Math.round((-1.0 + d * 2) * 10.0) / 10.0; // 0.6
txt.setText(effect + ": " + c);
ColorAdjust ca = new ColorAdjust();
ca.setContrast(c);

double h = Math.round((-1.0 + d * 2) * 10.0) / 10.0; // 0.6
txt.setText(effect + ": " + h);
ColorAdjust ca1 = new ColorAdjust();
ca1.setHue(h);

double st = Math.round((-1.0 + d * 2) * 10.0) / 10.0; // 0.6
txt.setText(effect + ": " + st);
ColorAdjust ca3 = new ColorAdjust();
ca3.setSaturation(st);

int w = (int)Math.round(4096 * d); //819
int h1 = (int)Math.round(4096 * d); //819
txt.setText(effect + ": " + ": width: " + w + ", height: " + h1);
DisplacementMap dm = new DisplacementMap();
FloatMap floatMap = new FloatMap();
floatMap.setWidth(w);
floatMap.setHeight(h1);
for (int k = 0; k < w; k++) {
double v = (Math.sin(k / 20.0 * Math.PI) - 0.5) / 40.0;
for (int j = 0; j < h1; j++) {
floatMap.setSamples(k, j, 0.0f, (float) v);
}
}
dm.setMapData(floatMap);

double rd = Math.round((127.0 * d) * 10.0) / 10.0; // 127.0
System.out.println(effect + ": " + rd);
txt.setText(effect + ": " + rd);
DropShadow sh = new DropShadow();
sh.setRadius(rd);

double rad = Math.round(12.1 * d *10.0)/10.0; // 9.7
double off = Math.round(15.0 * d *10.0)/10.0; // 12.0
txt.setText("InnerShadow: radius: " + rad + ", offset:" + off);
InnerShadow is = new InnerShadow();
is.setColor(Color.web("0x3b596d"));
is.setOffsetX(off);
is.setOffsetY(off);
is.setRadius(rad);

double sS = Math.round((d * 4)*10.0)/10.0; // 0.4
txt.setText(effect + ": " + sS);
Light.Spot lightSs = new Light.Spot();
lightSs.setX(150);
lightSs.setY(100);
lightSs.setZ(80);
lightSs.setPointsAtX(0);
lightSs.setPointsAtY(0);
lightSs.setPointsAtZ(-50);
lightSs.setSpecularExponent(sS);
Lighting lSs = new Lighting();
lSs.setLight(lightSs);
lSs.setSurfaceScale(5.0);

double r = Math.round((63.0 * d)*10.0) / 10.0; // 31.5
txt.setText(effect + ": " + r);
MotionBlur mb1 = new MotionBlur();
mb1.setRadius(r);
mb1.setAngle(-15);

// double d = 0.9;
txt.setText(effect + ": " + d);
PerspectiveTransform pt =
new PerspectiveTransform(0., 1\. + 50.*d, 310., 50\. - 50.*d,
310., 50\. + 50.*d + 1., 0., 100\. - 50\. * d + 2.);

// double d = 0.6;
txt.setText(effect + ": " + d);
Reflection ref = new Reflection();
ref.setFraction(d);

// double d = 1.0;
txt.setText(effect + ": " + d);
SepiaTone sep = new SepiaTone();
sep.setLevel(d);
本书提供了此演示的完整源代码,可以在 GitHub 中获得。
总结
在本章中,读者将了解 JavaFX 工具包、它的主要特性以及如何使用它创建 GUI 应用。涵盖的主题包括 JavaGUI 技术概述、JavaFX 控制元素、图表、使用 CSS、FXML、嵌入 HTML、播放媒体和添加效果。
下一章专门讨论函数式编程。它概述了 JDK 附带的函数式接口,解释了 Lambda 表达式是什么,以及如何在 Lambda 表达式中使用函数式接口。它还解释和演示了如何使用方法引用。
测验
- JavaFX 中的顶级内容容器是什么?
- JavaFX 中所有场景参与者的基类是什么?
- 说出 JavaFX 应用的基类。
- JavaFX 应用必须实现的一种方法是什么?
main方法必须调用哪个Application方法来执行 JavaFX 应用?- 执行 JavaFX 应用需要哪两个 VM 选项?
- 当使用上角的 x 按钮关闭 JavaFX 应用窗口时,调用哪个
Application方法? - 必须使用哪个类来嵌入 HTML?
- 说出三个必须用来播放媒体的类
- 要播放媒体,需要添加什么虚拟机选项?
- 说出五个 JavaFX 效果。
十三、函数式程序设计
本章将读者带入函数式编程的世界。它解释了什么是函数式接口,概述了 JDK 附带的函数式接口,定义并演示了 Lambda 表达式以及如何将它们用于函数式接口,包括使用方法引用。
本章将讨论以下主题:
- 什么是函数式编程?
- 标准函数式接口
- 函数管道
- Lambda 表达式限制
- 方法引用
什么是函数式编程?
在前面的章节中,我们实际使用了函数式编程。在第 6 章、“数据结构、泛型和流行工具”中,我们讨论了Iterable接口及其default void forEach (Consumer<T> function)方法,并提供了以下示例:
Iterable<String> list = List.of("s1", "s2", "s3");
System.out.println(list); //prints: [s1, s2, s3]
list.forEach(e -> System.out.print(e + " ")); //prints: s1 s2 s3
您可以看到一个Consumer e -> System.out.print(e + " ")函数如何被传递到forEach()方法中,并应用到列表中流入该方法的每个元素。我们将很快讨论Consumer函数。
我们还提到了Collection接口接受函数作为参数的两种方法:
default boolean remove(Predicate<E> filter)方法,它试图从集合中删除所有满足给定谓词的元素;Predicate函数接受集合中的一个元素并返回一个boolean值default T[] toArray(IntFunction<T[]> generator)方法,返回集合中所有元素的数组,使用提供的IntFunction生成器函数分配返回的数组
在同一章中,我们还提到了List接口的以下方法:
default void replaceAll(UnaryOperator<E> operator):将列表中的每个元素替换为将提供的UnaryOperator应用于该元素的结果;UnaryOperator是我们将在本章中回顾的函数之一。
我们描述了Map接口,它的方法default V merge(K key, V value, BiFunction<V,V,V> remappingFunction)以及如何使用它来连接String值:map.merge(key, value, String::concat)。BiFunction<V,V,V>接受两个相同类型的参数,并返回相同类型的值。String::concat构造称为方法引用,将在“方法引用”部分中解释。
我们提供了传递Comparator函数的以下示例:
list.sort(Comparator.naturalOrder());
Comparator<String> cmp = (s1, s2) -> s1 == null ? -1 : s1.compareTo(s2);
list.sort(cmp);
取两个String参数,然后将第一个参数与null进行比较。如果第一个参数是null,则返回-1,否则使用compareTo()方法比较第一个参数和第二个参数。
在第 11 章“网络编程”中,我们看了下面的代码:
HttpClient httpClient = HttpClient.newBuilder().build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:3333/something")).build();
try {
HttpResponse<String> resp =
httpClient.send(req, BodyHandlers.ofString());
System.out.println("Response: " +
resp.statusCode() + " : " + resp.body());
} catch (Exception ex) {
ex.printStackTrace();
}
BodyHandler对象(函数)由BodyHandlers.ofString()工厂方法生成,并作为参数传入send()方法。在方法内部,代码调用其apply()方法:
BodySubscriber<T> apply(ResponseInfo responseInfo)
最后,在第 12 章“Java GUI 编程”中,我们在下面的代码片段中使用了一个EventHandler函数作为参数:
btn.setOnAction(e -> {
System.out.println("Bye! See you later!");
Platform.exit();
}
);
primaryStage.onCloseRequestProperty()
.setValue(e -> System.out.println("Bye! See you later!"));
第一个函数是EventHanlder<ActionEvent>。它打印一条消息并强制应用退出。第二个是EventHandler<WindowEvent>函数。它只是打印信息。
所有这些例子都很好地说明了如何构造器并将其作为参数传递。这种能力构成了函数式编程。它存在于许多编程语言中。它不需要管理对象状态。函数是无状态的。它的结果只取决于输入数据,不管调用了多少次。这样的编码使得结果更加可预测,这是函数式编程最吸引人的方面。
从这种设计中受益最大的领域是并行数据处理。函数式编程允许将并行性的责任从客户端代码转移到库中。在此之前,为了处理 Java 集合的元素,客户端代码必须遍历集合并组织处理。在 Java8 中,添加了新的(默认)方法,这些方法接受一个函数作为参数,然后根据内部处理算法将其并行或不并行地应用于集合的每个元素。因此,组织并行处理是库的责任。
什么是函数式接口?
当我们定义一个函数时,实际上,我们提供了一个接口的实现,这个接口只有一个抽象方法。这就是 Java 编译器如何知道将提供的功能放在哪里的原因。编译器查看接口(Consumer、Predicate、Comparator、IntFunction、UnaryOperator、BiFunction、BodyHandler和EvenHandler在前面的示例中),只看到一个抽象方法,并使用传入的功能作为方法实现。唯一的要求是传入的参数必须与方法签名匹配。否则,将生成编译时错误。
这就是为什么只有一个抽象方法的接口被称为函数式接口。请注意,只有一个抽象方法的要求包括从父接口继承的方法。例如,考虑以下接口:
@FunctionalInterface
interface A {
void method1();
default void method2(){}
static void method3(){}
}
@FunctionalInterface
interface B extends A {
default void method4(){}
}
@FunctionalInterface
interface C extends B {
void method1();
}
//@FunctionalInterface
interface D extends C {
void method5();
}
A是一个函数式接口,因为它只有一个抽象方法method1()。B也是一个函数式接口,因为它只有一个抽象方法,即从A接口继承的相同method1()。C是一个函数式接口,因为它只有一个抽象方法method1(),它覆盖父接口A的抽象method1()。接口D不能是函数式接口,因为它有两个抽象方法-method1()来自父接口A和method5()。
为了避免运行时错误,Java8 中引入了@FunctionalInterface注解。它将意图告诉编译器,以便编译器可以检查并查看在带注解的接口中是否确实只有一个抽象方法。此注解还警告读代码的程序员,此接口故意只有一个抽象方法。否则,程序员可能会浪费时间将另一个抽象方法添加到接口中,结果在运行时发现它无法完成。
出于同样的原因,Runnable和Callable接口从 Java 早期版本开始就存在,在 Java8 中被注解为@FunctionalInterface。这种区别是明确的,并提醒用户,这些接口可用于创建函数:
@FunctionalInterface
interface Runnable {
void run();
}
@FunctionalInterface
interface Callable<V> {
V call() throws Exception;
}
与任何其他接口一样,函数式接口可以使用匿名类实现:
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("Hello!");
}
};
以这种方式创建的对象以后可以按如下方式使用:
runnable.run(); //prints: Hello!
如果我们仔细看前面的代码,就会发现有不必要的开销。首先,不需要重复接口名称,因为我们已经将其声明为对象引用的类型。其次,对于只有一个抽象方法的函数式接口,不需要指定必须实现的方法名。编译器和 Java 运行时可以解决这个问题。我们只需要提供新的功能。为此特别引入了 Lambda 表达式。
什么是 Lambda 表达式?
Lambda 一词来自 Lambda 演算,Lambda 演算是一种通用的计算模型,可以用来模拟任何图灵机。它是由数学家 Alonzo Church 在 20 世纪 30 年代提出的,Lambda 表达式是一个函数,在 Java 中作为匿名方法实现。它还允许省略修饰符、返回类型和参数类型。这是一个非常紧凑的符号。
Lambda 表达式的语法包括参数列表、箭头标记(->和正文。参数列表可以是空的,例如(),不带括号(如果只有一个参数),或者用逗号分隔的参数列表,用括号括起来。主体可以是单个表达式,也可以是大括号内的语句块({}。我们来看几个例子:
() -> 42;总是返回42。x -> x*42 + 42;将x值乘以42,再将42相加返回。(x, y) -> x * y;将传入的参数相乘,返回结果。s -> "abc".equals(s);比较变量s和文字"abc"的值,返回boolean结果值。s -> System.out.println("x=" + s);打印前缀为"x="的s值。(i, s) -> { i++; System.out.println(s + "=" + i); };增加输入整数并打印前缀为s + "="``s的新值,作为第二个参数的值。
如果没有函数式编程,在 Java 中,将某些功能作为参数传递的唯一方法是编写一个实现接口的类,创建其对象,然后将其作为参数传递。但即使是使用匿名类的最简单的样式也需要编写太多的样板代码。使用函数式接口和 Lambda 表达式可以使代码更短、更清晰、更具表现力。
例如,Lambda 表达式允许我们使用Runnable接口重新实现前面的示例,如下所示:
Runnable runnable = () -> System.out.println("Hello!");
如您所见,创建函数式接口很容易,尤其是使用 Lambda 表达式。但在此之前,请考虑使用包java.util.function中提供的 43 个函数式接口之一。这不仅可以让您编写更少的代码,还可以帮助其他熟悉标准接口的程序员更好地理解您的代码。
Lambda 参数的局部变量语法
在 Java11 发布之前,有两种方法可以显式和隐式声明参数类型。下面是一个明确的版本:
BiFunction<Double, Integer, Double> f = (Double x, Integer y) -> x / y;
System.out.println(f.apply(3., 2)); //prints: 1.5
以下是隐式参数类型定义:
BiFunction<Double, Integer, Double> f = (x, y) -> x / y;
System.out.println(f.apply(3., 2)); //prints: 1.5
在前面的代码中,编译器从接口定义推断参数的类型。
在 Java11 中,使用var类型占位符引入了另一种参数类型声明方法,类似于 Java10 中引入的局部变量类型占位符var(参见第 1 章、“Java12 入门”)。
以下参数声明在语法上与 Java11 之前的隐式声明完全相同:
BiFunction<Double, Integer, Double> f = (var x, var y) -> x / y;
System.out.println(f.apply(3., 2)); //prints: 1.5
新的局部变量样式语法允许我们添加注解,而无需显式定义参数类型。让我们向pom.xml文件添加以下依赖项:
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>16.0.2</version>
</dependency>
它允许我们将传入的变量定义为非空:
import javax.validation.constraints.NotNull;
import java.util.function.BiFunction;
import java.util.function.Consumer;
BiFunction<Double, Integer, Double> f =
(@NotNull var x, @NotNull var y) -> x / y;
System.out.println(f.apply(3., 2)); //prints: 1.5
注解将程序员的意图传达给编译器,因此如果违反了声明的意图,它可以在编译或执行过程中警告程序员。例如,我们尝试运行以下代码:
BiFunction<Double, Integer, Double> f = (x, y) -> x / y;
System.out.println(f.apply(null, 2));
它在运行时与NullPointerException一起失败。然后我们添加了如下注解:
BiFunction<Double, Integer, Double> f =
(@NotNull var x, @NotNull var y) -> x / y;
System.out.println(f.apply(null, 2));
运行上述代码的结果如下所示:
Exception in thread "main" java.lang.IllegalArgumentException:
Argument for @NotNull parameter 'x' of
com/packt/learnjava/ch13_functional/LambdaExpressions
.lambda$localVariableSyntax$1 must not be null
at com.packt.learnjava.ch13_functional.LambdaExpressions
.$$$reportNull$$$0(LambdaExpressions.java)
at com.packt.learnjava.ch13_functional.LambdaExpressions
.lambda$localVariableSyntax$1(LambdaExpressions.java)
at com.packt.learnjava.ch13_functional.LambdaExpressions
.localVariableSyntax(LambdaExpressions.java:59)
at com.packt.learnjava.ch13_functional.LambdaExpressions
.main(LambdaExpressions.java:12)
Lambda 表达式甚至没有执行。
当参数是具有很长名称的类的对象时,如果我们需要使用注解,那么在 Lambda 参数的情况下局部变量语法的优势就变得很明显了。在 Java11 之前,代码可能如下所示:
BiFunction<SomeReallyLongClassName,
AnotherReallyLongClassName, Double> f =
(@NotNull SomeReallyLongClassName x,
@NotNull AnotherReallyLongClassName y) -> x.doSomething(y);
我们必须显式声明变量的类型,因为我们要添加注解,而下面的隐式版本甚至无法编译:
BiFunction<SomeReallyLongClassName,
AnotherReallyLongClassName, Double> f =
(@NotNull x, @NotNull y) -> x.doSomething(y);
在 Java11 中,新的语法允许我们使用类型持有者var来使用隐式参数类型推断:
BiFunction<SomeReallyLongClassName,
AnotherReallyLongClassName, Double> f =
(@NotNull var x, @NotNull var y) -> x.doSomething(y);
这就是为 Lambda 参数的声明引入局部变量语法的优势和动机。否则,请考虑不要使用var。如果变量的类型很短,使用它的实际类型可以使代码更容易理解。
标准函数式接口
java.util.function包中提供的大部分接口是以下四种接口的特化:Consumer<T>、Predicate<T>、Supplier<T>和Function<T,R>。让我们回顾一下它们,然后简单地概述一下其他 39 个标准函数式接口。
消费者
通过查看Consumer<T>接口定义,您可能已经猜到这个接口有一个抽象方法,它接受一个T类型的参数,并且不返回任何东西。当只列出一个类型时,它可以定义返回值的类型,就像在Supplier<T>接口中一样。但接口名称作为线索,消费者名称表示该接口的方法只取值,不返回任何值,供应者返回值。这条线索并不精确,但有助于唤起记忆。
关于任何函数式接口的最佳信息源是java.util.function包 API 文档。如果我们读了它,就会知道Consumer<T>接口有一个抽象和一个默认方法:
void accept(T t):将操作应用于给定参数default Consumer<T> andThen(Consumer<T> after):返回一个组合的Consumer函数,该函数依次执行当前操作和after操作
这意味着,例如,我们可以实现并执行它,如下所示:
Consumer<String> printResult = s -> System.out.println("Result: " + s);
printResult.accept("10.0"); //prints: Result: 10.0
我们也可以使用工厂方法来创建函数,例如:
Consumer<String> printWithPrefixAndPostfix(String pref, String postf){
return s -> System.out.println(pref + s + postf);
现在我们可以使用它如下:
printWithPrefixAndPostfix("Result: ", " Great!").accept("10.0");
//prints: Result: 10.0 Great!
为了演示andThen()方法,让我们创建类Person:
public class Person {
private int age;
private String firstName, lastName, record;
public Person(int age, String firstName, String lastName) {
this.age = age;
this.lastName = lastName;
this.firstName = firstName;
}
public int getAge() { return age; }
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public String getRecord() { return record; }
public void setRecord(String fullId) { this.record = record; }
}
您可能已经注意到,record是唯一具有设置的属性。我们将使用它在消费函数中设置个人记录:
String externalData = "external data";
Consumer<Person> setRecord =
p -> p.setFullId(p.getFirstName() + " " +
p.getLastName() + ", " + p.getAge() + ", " + externalData);
setRecord函数获取Person对象属性的值和来自外部源的一些数据,并将结果值设置为record属性值。显然,它可以用其他几种方法来实现,但我们这样做只是为了演示。我们还要创建一个函数来打印record属性:
Consumer<Person> printRecord = p -> System.out.println(p.getRecord());
这两个函数的组合可以按如下方式创建和执行:
Consumer<Person> setRecordThenPrint = setRecord.andThen(printPersonId);
setRecordThenPrint.accept(new Person(42, "Nick", "Samoylov"));
//prints: Nick Samoylov, age 42, external data
这样,就可以创建一个完整的操作处理管道,用于转换通过管道的对象的属性
谓词
这个函数式接口Predicate<T>有一个抽象方法、五个默认值和一个允许谓词链接的静态方法:
boolean test(T t):评估提供的参数是否符合标准default Predicate<T> negate():返回当前谓词的否定static <T> Predicate<T> not(Predicate<T> target):返回所提供谓词的否定default Predicate<T> or(Predicate<T> other):从这个谓词和提供的谓词构造一个逻辑ORdefault Predicate<T> and(Predicate<T> other):从这个谓词和提供的谓词构造一个逻辑ANDstatic <T> Predicate<T> isEqual(Object targetRef):构造谓词,根据Objects.equals(Object, Object)判断两个参数是否相等
此接口的基本用法非常简单:
Predicate<Integer> isLessThan10 = i -> i < 10;
System.out.println(isLessThan10.test(7)); //prints: true
System.out.println(isLessThan10.test(12)); //prints: false
我们也可以将其与之前创建的printWithPrefixAndPostfix(String pref, String postf)函数结合起来:
int val = 7;
Consumer<String> printIsSmallerThan10 = printWithPrefixAndPostfix("Is "
+ val + " smaller than 10? ", " Great!");
printIsSmallerThan10.accept(String.valueOf(isLessThan10.test(val)));
//prints: Is 7 smaller than 10? true Great!
其他方法(也称为操作)也可以用于创建操作链(也称为管道),如下例所示:
Predicate<Integer> isEqualOrGreaterThan10 = isLessThan10.negate();
System.out.println(isEqualOrGreaterThan10.test(7)); //prints: false
System.out.println(isEqualOrGreaterThan10.test(12)); //prints: true
isEqualOrGreaterThan10 = Predicate.not(isLessThan10);
System.out.println(isEqualOrGreaterThan10.test(7)); //prints: false
System.out.println(isEqualOrGreaterThan10.test(12)); //prints: true
Predicate<Integer> isGreaterThan10 = i -> i > 10;
Predicate<Integer> is_lessThan10_OR_greaterThan10 =
isLessThan10.or(isGreaterThan10);
System.out.println(is_lessThan10_OR_greaterThan10.test(20)); // true
System.out.println(is_lessThan10_OR_greaterThan10.test(10)); // false
Predicate<Integer> isGreaterThan5 = i -> i > 5;
Predicate<Integer> is_lessThan10_AND_greaterThan5 =
isLessThan10.and(isGreaterThan5);
System.out.println(is_lessThan10_AND_greaterThan5.test(3)); // false
System.out.println(is_lessThan10_AND_greaterThan5.test(7)); // true
Person nick = new Person(42, "Nick", "Samoylov");
Predicate<Person> isItNick = Predicate.isEqual(nick);
Person john = new Person(42, "John", "Smith");
Person person = new Person(42, "Nick", "Samoylov");
System.out.println(isItNick.test(john)); //prints: false
System.out.println(isItNick.test(person)); //prints: true
谓词对象可以链接到更复杂的逻辑语句中,并包含所有必要的外部数据,如前面所示。
生产者
这个函数式接口Supplier<T>只有一个抽象方法T get(),返回一个值。基本用法如下:
Supplier<Integer> supply42 = () -> 42;
System.out.println(supply42.get()); //prints: 42
它可以与前面几节中讨论的函数链接:
int input = 7;
int limit = 10;
Supplier<Integer> supply7 = () -> input;
Predicate<Integer> isLessThan10 = i -> i < limit;
Consumer<String> printResult = printWithPrefixAndPostfix("Is " + input +
" smaller than " + limit + "? ", " Great!");
printResult.accept(String.valueOf(isLessThan10.test(supply7.get())));
//prints: Is 7 smaller than 10? true Great!
Supplier<T>函数通常用作数据进入处理管道的入口点。
函数
这个和其他返回值的函数式接口的表示法,包括作为泛型列表中最后一个的返回类型的列表(在本例中为R)和它前面的输入数据的类型(在本例中为T类型的输入参数)。因此,符号Function<T, R>表示此接口的唯一抽象方法接受T类型的参数并生成R类型的结果。让我们看看在线文档。
Function<T, R>接口有一个抽象方法R apply(T),还有两个操作链接方法:
default <V> Function<T,V> andThen(Function<R, V> after):返回一个组合函数,首先将当前函数应用于其输入,然后将after函数应用于结果。default <V> Function<V,R> compose(Function<V, T> before):返回一个组合函数,首先将before函数应用于其输入,然后将当前函数应用于结果。
还有一种identity()方法:
static <T> Function<T,T> identity():返回始终返回其输入参数的函数
让我们回顾一下所有这些方法以及如何使用它们。以下是Function<T,R>接口的基本用法示例:
Function<Integer, Double> multiplyByTen = i -> i * 10.0;
System.out.println(multiplyByTen.apply(1)); //prints: 10.0
我们还可以将其与前面几节中讨论的所有功能链接起来:
Supplier<Integer> supply7 = () -> 7;
Function<Integer, Double> multiplyByFive = i -> i * 5.0;
Consumer<String> printResult =
printWithPrefixAndPostfix("Result: ", " Great!");
printResult.accept(multiplyByFive.
apply(supply7.get()).toString()); //prints: Result: 35.0 Great!
andThen()方法允许从简单函数构造复杂函数。注意下面代码中的divideByTwo.amdThen()行:
Function<Double, Long> divideByTwo =
d -> Double.valueOf(d / 2.).longValue();
Function<Long, String> incrementAndCreateString =
l -> String.valueOf(l + 1);
Function<Double, String> divideByTwoIncrementAndCreateString =
divideByTwo.andThen(incrementAndCreateString);
printResult.accept(divideByTwoIncrementAndCreateString.apply(4.));
//prints: Result: 3 Great!
它描述了应用于输入值的操作顺序。注意divideByTwo()函数(Long的返回类型如何匹配incrementAndCreateString()函数的输入类型。
compose()方法实现相同的结果,但顺序相反:
Function<Double, String> divideByTwoIncrementAndCreateString =
incrementAndCreateString.compose(divideByTwo);
printResult.accept(divideByTwoIncrementAndCreateString.apply(4.));
//prints: Result: 3 Great!
现在,复合函数的组合顺序与执行顺序不匹配。如果函数divideByTwo()还没有创建,并且您想在线创建它,那么它可能非常方便。则以下构造将不编译:
Function<Double, String> divideByTwoIncrementAndCreateString =
(d -> Double.valueOf(d / 2.).longValue())
.andThen(incrementAndCreateString);
下面一行可以很好地编译:
Function<Double, String> divideByTwoIncrementAndCreateString =
incrementAndCreateString
.compose(d -> Double.valueOf(d / 2.).longValue());
它允许在构建函数管道时具有更大的灵活性,因此在创建下一个操作时,可以以流畅的方式构建它,而不会打断连续的行。
当您需要传入与所需函数签名匹配但不执行任何操作的函数时,identity()方法非常有用。但它只能替换返回与输入类型相同类型的函数。例如:
Function<Double, Double> multiplyByTwo = d -> d * 2.0;
System.out.println(multiplyByTwo.apply(2.)); //prints: 4.0
multiplyByTwo = Function.identity();
System.out.println(multiplyByTwo.apply(2.)); //prints: 2.0
为了演示其可用性,假设我们有以下处理管道:
Function<Double, Double> multiplyByTwo = d -> d * 2.0;
System.out.println(multiplyByTwo.apply(2.)); //prints: 4.0
Function<Double, Long> subtract7 = d -> Math.round(d - 7);
System.out.println(subtract7.apply(11.0)); //prints: 4
long r = multiplyByTwo.andThen(subtract7).apply(2.);
System.out.println(r); //prints: -3
然后,我们决定在某些情况下,multiplyByTwo()函数不应该做任何事情。我们可以给它添加一个条件关闭来打开/关闭它。但是,如果我们想保持函数的完整性,或者如果这个函数是从第三方代码传递给我们的,我们可以只执行以下操作:
Function<Double, Double> multiplyByTwo = d -> d * 2.0;
System.out.println(multiplyByTwo.apply(2.)); //prints: 4.0
Function<Double, Long> subtract7 = d -> Math.round(d - 7);
System.out.println(subtract7.apply(11.0)); //prints: 4
multiplyByTwo = Function.identity();
r = multiplyByTwo.andThen(subtract7).apply(2.);
System.out.println(r); //prints: -5
如您所见,multiplyByTwo()函数现在什么都不做,最终的结果是不同的。
其他标准函数式接口
java.util.function包中的其他 39 个函数式接口是我们刚刚回顾的四个接口的变体。创建这些变体是为了实现以下一个或任意组合:
- 通过显式使用
int、double或long原始类型来避免自动装箱和拆箱,从而获得更好的性能 - 允许两个输入参数和/或更短的符号
以下只是几个例子:
IntFunction<R>方法R apply(int)提供了一个较短的表示法(输入参数类型没有泛型),并通过要求原始类型int作为参数来避免自动装箱。- 方法
R apply(T,U)的BiFunction<T,U,R>允许两个输入参数;方法T apply(T,T)的BinaryOperator<T>允许两个类型为T的输入参数,并返回相同类型的值T。 - 方法为
int applAsInt(int,int)的IntBinaryOperator接受int类型的两个参数,并返回int类型的值。
如果您要使用函数式接口,我们鼓励您学习java.util.functional包的接口。
Lambda 表达式限制
我们想指出并澄清 Lambda 表达式的两个方面:
- 如果 Lambda 表达式使用在其外部创建的局部变量,则该局部变量必须是
final或有效final(不能在同一上下文中重新赋值)。 - Lambda 表达式中的
this关键字指的是封闭上下文,而不是 Lambda 表达式本身。
与在匿名类中一样,在 Lambda 表达式外部创建并在其中使用的变量实际上是final的,不能修改。以下是试图更改已初始化变量的值而导致的错误示例:
int x = 7;
//x = 3; //compilation error
Function<Integer, Integer> multiply = i -> i * x;
这种限制的原因是一个函数可以在不同的上下文(例如,不同的线程)中传递和执行,而同步这些上下文的尝试将破坏无状态函数的最初想法和表达式的计算,这仅取决于输入参数,而不是上下文变量。这就是为什么 Lambda 表达式中使用的所有局部变量都必须是有效的final,这意味着它们可以显式声明为final,也可以通过不改变值而变为final。
不过,对于这个限制,有一个可能的解决方法。如果局部变量是引用类型(而不是String或原始类型包装类型),则可以更改其状态,即使在 Lambda 表达式中使用此局部变量:
List<Integer> list = new ArrayList();
list.add(7);
int x = list.get(0);
System.out.println(x); // prints: 7
list.set(0, 3);
x = list.get(0);
System.out.println(x); // prints: 3
Function<Integer, Integer> multiply = i -> i * list.get(0);
由于在不同的上下文中执行 Lambda 可能会产生意外的副作用,因此应小心使用此解决方法。
匿名类中的this关键字是指匿名类的实例。相比之下,在 Lambda 表达式中,this关键字是指围绕该表达式的类的实例,也称为封闭实例、封闭上下文或封闭范围。
让我们创建一个ThisDemo类来说明区别:
class ThisDemo {
private String field = "ThisDemo.field";
public void useAnonymousClass() {
Consumer<String> consumer = new Consumer<>() {
private String field = "Consumer.field";
public void accept(String s) {
System.out.println(this.field);
}
};
consumer.accept(this.field);
}
public void useLambdaExpression() {
Consumer<String> consumer = consumer = s -> {
System.out.println(this.field);
};
consumer.accept(this.field);
}
}
如果执行上述方法,输出将如以下代码注解所示:
ThisDemo d = new ThisDemo();
d.useAnonymousClass(); //prints: Consumer.field
d.useLambdaExpression(); //prints: ThisDemo.field
如您所见,匿名类中的关键字this表示匿名类实例,而 Lambda 表达式中的this表示封闭类实例。Lambda 表达式没有字段,也不能有字段。Lambda 表达式不是类实例,this不能引用。根据 Java 的规范,这种方法通过将this与周围的上下文相同看待,为实现提供了更大的灵活性。
方法引用
到目前为止,我们所有的功能都是简短的一行。下面是另一个例子:
Supplier<Integer> input = () -> 3;
Predicate<Integer> checkValue = d -> d < 5;
Function<Integer, Double> calculate = i -> i * 5.0;
Consumer<Double> printResult = d -> System.out.println("Result: " + d);
if(checkValue.test(input.get())){
printResult.accept(calculate.apply(input.get()));
} else {
System.out.println("Input " + input.get() + " is too small.");
}
如果函数由两行或多行组成,我们可以按如下方式实现它们:
Supplier<Integer> input = () -> {
// as many line of code here as necessary
return 3;
};
Predicate<Integer> checkValue = d -> {
// as many line of code here as necessary
return d < 5;
};
Function<Integer, Double> calculate = i -> {
// as many lines of code here as necessary
return i * 5.0;
};
Consumer<Double> printResult = d -> {
// as many lines of code here as necessary
System.out.println("Result: " + d);
};
if(checkValue.test(input.get())){
printResult.accept(calculate.apply(input.get()));
} else {
System.out.println("Input " + input.get() + " is too small.");
}
当函数实现的大小超过几行代码时,这样的代码布局可能不容易阅读。它可能会模糊整个代码结构。为了避免此问题,可以将函数实现移到方法中,然后在 Lambda 表达式中引用此方法。例如,让我们向使用 Lambda 表达式的类添加一个静态方法和一个实例方法:
private int generateInput(){
// Maybe many lines of code here
return 3;
}
private static boolean checkValue(double d){
// Maybe many lines of code here
return d < 5;
}
另外,为了演示各种可能性,让我们用一个静态方法和一个实例方法创建另一个类:
class Helper {
public double calculate(int i){
// Maybe many lines of code here
return i* 5;
}
public static void printResult(double d){
// Maybe many lines of code here
System.out.println("Result: " + d);
}
}
现在我们可以将最后一个示例重写如下:
Supplier<Integer> input = () -> generateInput();
Predicate<Integer> checkValue = d -> checkValue(d);
Function<Integer, Double> calculate = i -> new Helper().calculate(i);
Consumer<Double> printResult = d -> Helper.printResult(d);
if(checkValue.test(input.get())){
printResult.accept(calculate.apply(input.get()));
} else {
System.out.println("Input " + input.get() + " is too small.");
}
如您所见,即使每个函数都由许多行代码组成,这样的结构也使代码易于阅读。然而,当一行 Lambda 表达式包含对现有方法的引用时,可以通过使用方法引用而不列出参数来进一步简化表示法。
方法引用的语法是Location::methodName,其中Location表示methodName方法属于哪个对象或类,两个冒号(::作为位置和方法名之间的分隔符。使用方法引用表示法,前面的示例可以重写如下:
Supplier<Integer> input = this::generateInput;
Predicate<Integer> checkValue = MethodReferenceDemo::checkValue;
Function<Integer, Double> calculate = new Helper()::calculate;
Consumer<Double> printResult = Helper::printResult;
if(checkValue.test(input.get())){
printResult.accept(calculate.apply(input.get()));
} else {
System.out.println("Input " + input.get() + " is too small.");
}
您可能已经注意到,为了演示各种可能性,我们特意使用了不同的位置、两个实例方法和两个静态方法。如果感觉太难记住,那么好消息是一个现代 IDE(IntelliJ IDEA 就是一个例子)可以帮您完成,并将您正在编写的代码转换为最紧凑的形式。你必须接受 IDE 的建议。
总结
本章通过解释和演示函数式接口和 Lambda 表达式的概念,向读者介绍函数式编程。JDK 附带的标准函数式接口概述帮助读者避免编写自定义代码,而方法引用表示法允许读者编写易于理解和维护的结构良好的代码。
在下一章中,我们将讨论数据流处理。我们将定义什么是数据流,并研究如何处理它们的数据以及如何在管道中链接流操作。具体来说,我们将讨论流的初始化和操作(方法),如何以流畅的方式连接它们,以及如何创建并行流。
测验
-
什么是函数式接口?选择所有适用的选项:
-
什么是 Lambda 表达式?选择所有适用的选项:
-
Consumer<T>接口的实现有多少个输入参数? -
Consumer<T>接口实现时返回值的类型是什么? -
Predicate<T>接口的实现有多少个输入参数? -
Predicate<T>接口实现时返回值的类型是什么? -
Supplier<T>接口的实现有多少个输入参数? -
Supplier<T>接口实现时返回值的类型是什么? -
Function<T,R>接口的实现有多少个输入参数? -
Function<T,R>接口实现时返回值的类型是什么? -
在 Lambda 表达式中,关键字
this指的是什么? -
什么是方法引用语法?
十四、Java 标准流
在本章中,我们将讨论数据流的处理,它不同于我们在第 5 章、“字符串、输入/输出和文件”中回顾的 I/O 流。我们将定义数据流是什么,如何使用java.util.stream.Stream对象的方法(操作)处理它们的元素,以及如何在管道中链接(连接)流操作。我们还将讨论流的初始化以及如何并行处理流。
本章将讨论以下主题:
- 作为数据和操作源的流
- 流初始化
- 操作(方法)
- 数字流接口
- 并行流
作为数据和操作源的流
上一章中描述和演示的 Lambda 表达式以及函数式接口为 Java 添加了强大的函数编程功能。它们允许将行为(函数)作为参数传递给为数据处理性能而优化的库。通过这种方式,程序员可以专注于所开发系统的业务方面,而将性能方面留给专家——库的作者。这样一个库的一个例子是包java.util.stream,这将是本章的重点。
在第 5 章“字符串、输入/输出和文件”中,我们谈到了 I/O 流作为数据源,但除此之外,它们对数据的进一步处理没有太大帮助。它们是基于字节或字符的,而不是基于对象的。只有先以编程方式创建并序列化对象之后,才能创建对象流。I/O 流只是到外部资源的连接,大部分是文件,其他的不多。然而,有时可以从 I/O 流转换到java.util.stream.Stream。例如,BufferedReader类的lines()方法将底层基于字符的流转换为Stream<String>对象。
另一方面,java.util.stream包的流面向对象集合的处理。在第 6 章“数据结构、泛型和流行工具”中,我们描述了Collection接口的两种方法,允许将集合元素作为流的元素读取:default Stream<E> stream()和default Stream<E> parallelStream()。我们还提到了java.util.Arrays的stream()方法。它有以下八个重载版本,用于将数组或数组的一部分转换为相应数据类型的流:
static DoubleStream stream(double[] array)static DoubleStream stream(double[] array, int startInclusive, int endExclusive)static IntStream stream(int[] array)static IntStream stream(int[] array, int startInclusive, int endExclusive)static LongStream stream(long[] array)static LongStream stream(long[] array, int startInclusive, int endExclusive)static <T> Stream<T> stream(T[] array)static <T> Stream<T> stream(T[] array, int startInclusive, int endExclusive)
现在让我们更仔细地看一下包java.util.stream的流。理解流的最好方法是将它与集合进行比较。后者是存储在内存中的数据结构。在将每个集合元素添加到集合之前,都会对其进行计算。相比之下,流发出的元素存在于源中的其他地方,并且是按需计算的。因此,集合可以是流的源。
一个Stream对象是一个接口Stream、IntStream、LongStream或DoubleStream的实现;最后三个被称为数字流。接口Stream的方法也可以在数字流中使用。一些数值流有一些特定于数值的额外方法,例如average()和sum()。在本章中,我们将主要讨论Stream接口及其方法,但是我们将要讨论的所有内容也同样适用于数字流。
流一旦处理了先前发射的元素,就产生(或发射)流元素。它允许对方法(操作)进行声明性表示,这些方法(操作)也可以并行地应用于发出的元素。今天,当大型数据集处理的机器学习需求变得无处不在时,这个特性加强了 Java 在为数不多的现代编程语言中的地位。
流初始化
创建和初始化流的方法有很多种,Stream类型的对象或任何数字接口。我们将它们按类和接口进行分组,这些类和接口具有Stream创建方法。我们这样做是为了方便读者,所以读者更容易记住和找到他们,如果需要的话。
流接口
这组Stream工厂由属于Stream接口的静态方法组成。
empty()
Stream<T> empty()方法创建一个不发射任何元素的空流:
Stream.empty().forEach(System.out::println); //prints nothing
Stream方法forEach()的作用类似于Collection方法forEach(),并将传入的函数应用于每个流元素:
new ArrayList().forEach(System.out::println); //prints nothing
结果与从空集合创建流相同:
new ArrayList().stream().forEach(System.out::println); //prints nothing
如果没有任何元素发射,什么都不会发生。我们将在“终端操作”部分讨论Stream方法forEach()。
of(T... values)
of(T... values)方法接受可变参数,也可以创建空流:
Stream.of().forEach(System.out::print); //prints nothing
但它通常用于初始化非空流:
Stream.of(1).forEach(System.out::print); //prints: 1
Stream.of(1,2).forEach(System.out::print); //prints: 12
Stream.of("1 ","2").forEach(System.out::print); //prints: 1 2
注意用于调用println()和print()方法的方法引用。
使用of(T... values)方法的另一种方法如下:
String[] strings = {"1 ", "2"};
Stream.of(strings).forEach(System.out::print); //prints: 1 2
如果没有为Stream对象指定类型,则编译器不会抱怨数组是否包含混合类型:
Stream.of("1 ", 2).forEach(System.out::print); //prints: 1 2
添加声明预期元素类型的泛型会在至少一个列出的元素具有不同类型时导致异常:
//Stream<String> stringStream = Stream.of("1 ", 2); //compile error
泛型可以帮助程序员避免许多错误,因此应该尽可能地添加泛型。
of(T... values)方法也可用于多个流的连接。例如,假设我们有以下四个流,我们希望将它们连接成一个流:
Stream<Integer> stream1 = Stream.of(1, 2);
Stream<Integer> stream2 = Stream.of(2, 3);
Stream<Integer> stream3 = Stream.of(3, 4);
Stream<Integer> stream4 = Stream.of(4, 5);
我们希望将它们连接到一个新的流中,该流将发出值1,2,2,3,3,4,4,5。首先,我们尝试以下代码:
Stream.of(stream1, stream2, stream3, stream4)
.forEach(System.out::print);
//prints: java.util.stream.ReferencePipeline$Head@58ceff1j
它没有达到我们所希望的。它将每个流视为Stream接口实现中使用的内部类java.util.stream.ReferencePipeline的对象。因此,我们需要添加flatMap()操作来将每个流元素转换为一个流(我们在“中间操作”部分中描述):
Stream.of(stream1, stream2, stream3, stream4)
.flatMap(e -> e).forEach(System.out::print); //prints: 12233445
我们作为参数(e -> e传入flatMap()的函数看起来好像什么都没做,但这是因为流的每个元素已经是一个流了,所以不需要对它进行转换。通过返回一个元素作为flatMap()操作的结果,我们告诉管道将返回值视为Stream对象。
ofNullable(T)
如果传入的参数t不是null,则ofNullable(T t)方法返回一个发出单个元素的Stream<T>,否则返回一个空的Stream。为了演示ofNullable(T t)方法的用法,我们创建了以下方法:
void printList1(List<String> list){
list.stream().forEach(System.out::print);
}
我们已经执行了两次这个方法——参数列表等于null和List对象。结果如下:
//printList1(null); //NullPointerException
List<String> list = List.of("1 ", "2");
printList1(list); //prints: 1 2
注意第一次调用printList1()方法是如何生成NullPointerException的。为了避免异常,我们可以实现如下方法:
void printList1(List<String> list){
(list == null ? Stream.empty() : list.stream())
.forEach(System.out::print);
}
用ofNullable(T t)方法也可以得到同样的结果:
void printList2(List<String> list){
Stream.ofNullable(list).flatMap(l -> l.stream())
.forEach(System.out::print);
}
注意我们如何添加了flatMap(),否则,流入forEach()的Stream元素将是List对象。我们将在“中间操作”一节中详细介绍flatMap()方法。前面代码中传递给flatMap()操作的函数也可以表示为方法引用:
void printList4(List<String> list){
Stream.ofNullable(list).flatMap(Collection::stream)
.forEach(System.out::print);
}
iterate(T, UnaryOperator<T>)
Stream接口的两种静态方法允许使用类似于传统for循环的迭代过程生成值流:
Stream<T> iterate(T seed, UnaryOperator<T> func):基于第二参数、函数func对第一参数seed的迭代应用,创建无限序列流,产生值流seed、f(seed)、f(f(seed))等Stream<T> iterate(T seed, Predicate<T> hasNext, UnaryOperator<T> next):基于第三个参数函数next对第一个参数seed的迭代应用,创建一个有限的序列流,只要第三个参数函数hasNext返回true,就会产生一个值流seed、f(seed)、f(f(seed))等等
以下代码演示了这些方法的用法:
Stream.iterate(1, i -> ++i).limit(9)
.forEach(System.out::print); //prints: 123456789
Stream.iterate(1, i -> i < 10, i -> ++i)
.forEach(System.out::print); //prints: 123456789
请注意,我们被迫在第一个管道中添加一个中间运算符limit(int n),以避免生成无穷多的生成值。我们将在“中间操作”一节中详细讨论此方法
concat(Stream<> a, Stream<T> b)
Stream接口的Stream<T> concat(Stream<> a, Stream<T> b)静态方法基于作为参数传入的两个流a和b创建一个值流。新创建的流包括第一个参数a的所有元素,然后是第二个参数b的所有元素。以下代码演示了此方法:
Stream<Integer> stream1 = List.of(1, 2).stream();
Stream<Integer> stream2 = List.of(2, 3).stream();
Stream.concat(stream1, stream2)
.forEach(System.out::print); //prints: 1223
注意,元素2在两个原始流中都存在,因此由结果流发射两次。
generate(Supplier<T> )
接口Stream的静态方法Stream<T> generate(Supplier<T> supplier)创建一个无限流,其中每个元素由提供的函数Supplier<T>生成。以下是两个示例:
Stream.generate(() -> 1).limit(5)
.forEach(System.out::print); //prints: 11111
Stream.generate(() -> new Random().nextDouble()).limit(5)
.forEach(System.out::println); //prints: 0.38575117472619247
// 0.5055765386778835
// 0.6528038976983277
// 0.4422354489467244
// 0.06770955839148762
如果运行此代码,可能会得到不同的结果,因为生成的值具有随机(伪随机)性质。
由于创建的流是无限的,所以我们添加了一个只允许指定数量的流元素通过的limit(int n)操作,我们将在“中间操作”部分详细介绍这个方法
流生成器接口
Stream.Builder<T> builder()静态方法返回可用于构造Stream对象的内部(位于接口Stream内部)接口Builder。接口Builder扩展了Consumer接口,有如下方法:
default Stream.Builder<T> add(T t):调用accept(T)方法并返回(Builder对象),从而允许以流畅的点连接样式链接add(T t)方法void accept(T t):在流中添加一个元素(这个方法来自Consumer接口)Stream<T> build():将此生成器从构造状态转换为built状态;调用此方法后,不能向该流添加新元素
add(T t)方法的用法很简单:
Stream.<String>builder().add("cat").add(" dog").add(" bear")
.build().forEach(System.out::print); //prints: cat dog bear
请注意我们是如何将泛型<String>添加到builder()方法前面的。这样,我们告诉构建器我们正在创建的流将具有String类型的元素。否则,它会将元素添加为Object类型,并且不会确保添加的元素是String类型。
accept(T t)方法在生成器作为Consumer<T>类型的参数传递时使用,或者不需要链接添加元素的方法时使用。例如,下面是一个代码示例:
Stream.Builder<String> builder = Stream.builder();
List.of("1", "2", "3").stream().forEach(builder);
builder.build().forEach(System.out::print); //prints: 123
forEach(Consumer<T> consumer)方法接受具有accept(T t)方法的Consumer函数。每次流发出一个元素时,forEach()方法接收它并将它传递给Builder对象的accept(T t)方法。然后,当在下一行中调用build()方法时,将创建Stream对象,并开始发射前面由accept(T t)方法添加的元素。发出的元素被传递到forEach()方法,然后由该方法逐个打印它们。
下面是一个明确使用accept(T t)方法的例子:
List<String> values = List.of("cat", " dog", " bear");
Stream.Builder<String> builder = Stream.builder();
for(String s: values){
if(s.contains("a")){
builder.accept(s);
}
}
builder.build().forEach(System.out::print); //prints: cat bear
这一次,我们决定不向流中添加所有的列表元素,而只添加那些包含字符a的元素,正如所料,创建的流只包含cat和bear元素。另外,请注意我们如何使用<String>泛型来确保所有流元素都是String类型。
其他类和接口
在 Java8 中,java.util.Collection接口增加了两个默认方法:
Stream<E> stream():返回此集合的元素流Stream<E> parallelStream():返回(可能)此集合元素的并行流;可能,因为 JVM 试图将流拆分为几个块并并行(如果有多个 CPU)或实际上并行(使用 CPU 的分时)处理;但这并不总是可能的,并且部分取决于请求处理的性质
这意味着所有扩展这个接口的集合接口,包括Set和List,都有这些方法。例如:
List.of("1", "2", "3").stream().forEach(builder);
List.of("1", "2", "3").parallelStream().forEach(builder);
我们将在“并行处理”部分讨论并行流。
我们已经在“作为数据和操作源的流”部分的开头描述了java.util.Arrays类的八个静态重载方法stream()。下面是使用数组的子集创建流的另一种方法的示例:
int[] arr = {1, 2, 3, 4, 5};
Arrays.stream(arr, 2, 4).forEach(System.out::print); //prints: 34
java.util.Random类允许创建伪随机值的数字流:
DoubleStream doubles():在0(包含)和1(排除)之间创建一个不受限制的double值流IntStream ints()和LongStream longs():创建对应类型值的无限流DoubleStream doubles(long streamSize):在0(含)和1(不含)之间创建double值的流(指定大小)IntStream ints(long streamSize)和LongStream longs(long streamSize):创建相应类型值的指定大小的流IntStream ints(int randomNumberOrigin, int randomNumberBound):在randomNumberOrigin(包含)和randomNumberBound(排除)之间创建一个不受限制的int值流LongStream longs(long randomNumberOrigin, long randomNumberBound):在randomNumberOrigin(包含)和randomNumberBound(排除)之间创建一个不受限制的long值流DoubleStream doubles(long streamSize, double randomNumberOrigin, double randomNumberBound):创建一个在randomNumberOrigin(包括)和randomNumberBound(不包括)之间具有指定大小的double值的流
以下是上述方法之一的示例:
new Random().ints(5, 8).limit(5)
.forEach(System.out::print); //prints: 56757
java.nio.file.Files类有六个静态方法创建线和路径流:
Stream<String> lines(Path path):从提供的路径指定的文件创建行流Stream<String> lines(Path path, Charset cs):从提供的路径指定的文件创建行流;使用提供的字符集将文件中的字节解码为字符Stream<Path> list(Path dir):在指定目录中创建文件和目录流Stream<Path> walk(Path start, FileVisitOption... options):创建以Path start开头的文件树的文件和目录流Stream<Path> walk(Path start, int maxDepth, FileVisitOption... options):创建文件树的文件和目录流,从Path start开始,一直到指定的深度maxDepthStream<Path> find(Path start, int maxDepth, BiPredicate<Path, BasicFileAttributes> matcher, FileVisitOption... options):创建文件树的文件和目录流(与提供的谓词匹配),从Path start开始,向下到maxDepth值指定的深度
创建流的其他类和方法包括:
java.util.BitSet类有IntStream stream()方法,该方法创建一个索引流,这个BitSet包含一个处于设置状态的位。java.io.BufferedReader类有Stream<String> lines()方法,它从这个BufferedReader对象(通常是从一个文件)创建一个行流。java.util.jar.JarFile类具有创建 ZIP 文件条目流的Stream<JarEntry> stream()方法。java.util.regex.Pattern类具有Stream<String> splitAsStream(CharSequence input)方法,该方法根据提供的序列围绕此模式的匹配创建流。java.lang.CharSequence接口有两种方式:default IntStream chars():创建一个扩展char值的int零流default IntStream codePoints():根据该序列创建代码点值流
还有一个java.util.stream.StreamSupport类,它包含库开发人员使用的静态低级工具方法。但我们不会再讨论它,因为这超出了本书的范围。
操作(方法)
Stream接口的许多方法,那些以函数式接口类型作为参数的方法,被称为操作,因为它们不是作为传统方法实现的。它们的功能作为函数传递到方法中。这些操作只是调用函数式接口的方法的 Shell,该函数式接口被指定为参数方法的类型。
例如,让我们看一下Stream<T> filter (Predicate<T> predicate)方法。它的实现是基于对Predicate<T>函数的方法boolean test(T t)的调用。因此,与其说使用Stream对象的filter()方法来选择一些流元素并跳过其他流元素,程序员更喜欢说,应用一个操作过滤器,允许一些流元素通过并跳过其他流元素。它描述动作(操作)的性质,而不是特定的算法,在方法接收到特定函数之前,算法是未知的。Stream接口有两组操作:
- 中间操作:返回
Stream对象的实例方法 - 终端操作:返回
Stream以外类型的实例方法
流处理通常被组织为使用 Fluent(点连接)样式的管道。一个Stream创建方法或另一个流源启动这样一个管道。终端操作产生最终结果或副作用,并结束管道,因此命名为。中间操作可以放在起始Stream对象和终端操作之间。
中间操作处理流元素(或不处理,在某些情况下)并返回修改(或不修改)Stream对象,因此可以应用下一个中间或终端操作。中间操作示例如下:
Stream<T> filter(Predicate<T> predicate):仅选择与标准匹配的元素Stream<R> map(Function<T,R> mapper):根据传入的函数转换元素;请注意返回的Stream对象的类型可能与输入类型有很大的不同Stream<T> distinct():删除重复项Stream<T> limit(long maxSize):将流限制为指定的元素数Stream<T> sorted():按一定顺序排列流元素- 我们将在“中间操作”部分讨论其他一些中间操作。
流元素的处理实际上只有在终端操作开始执行时才开始。然后所有中间操作(如果存在)按顺序开始处理。一旦终端操作完成执行,流就会关闭并且无法重新打开。
终端操作的例子有forEach()、findFirst()、reduce()、collect()、sum()、max()以及Stream接口的其他不返回Stream对象的方法。我们将在“终端操作”部分讨论。
所有的Stream操作都支持并行处理,这在多核计算机上处理大量数据的情况下尤其有用。我们将在“并行流”部分讨论。
中间操作
正如我们已经提到的,中间操作返回一个Stream对象,该对象发出相同或修改的值,甚至可能与流源的类型不同。
中间操作可以按其功能分为四类操作,分别执行过滤、映射、排序或窥视。
过滤
此组包括删除重复项、跳过某些元素、限制已处理元素的数量以及仅选择通过某些条件的元素进行进一步处理的操作:
Stream<T> distinct():使用method Object.equals(Object)比较流元素并跳过重复项Stream<T> skip(long n):忽略首先发出的流元素的提供数量Stream<T> limit(long maxSize):只允许处理提供数量的流元素Stream<T> filter(Predicate<T> predicate):只允许被提供的Predicate函数处理时产生true的元素被处理default Stream<T> dropWhile(Predicate<T> predicate):在所提供的Predicate函数处理时,跳过流中导致true的第一个元素default Stream<T> takeWhile(Predicate<T> predicate):只允许流的第一个元素在被提供的Predicate函数处理时产生true
下面的代码演示了刚才描述的操作是如何工作的:
Stream.of("3", "2", "3", "4", "2").distinct()
.forEach(System.out::print); //prints: 324
List<String> list = List.of("1", "2", "3", "4", "5");
list.stream().skip(3).forEach(System.out::print); //prints: 45
list.stream().limit(3).forEach(System.out::print); //prints: 123
list.stream().filter(s -> Objects.equals(s, "2"))
.forEach(System.out::print); //prints: 2
list.stream().dropWhile(s -> Integer.valueOf(s) < 3)
.forEach(System.out::print); //prints: 345
list.stream().takeWhile(s -> Integer.valueOf(s) < 3)
.forEach(System.out::print); //prints: 12
注意,我们可以重用源List<String>对象,但不能重用Stream对象。一旦Stream对象被关闭,它就不能被重新打开。
映射
这一组可以说包括最重要的中间操作。它们是修改流元素的唯一中间操作。它们将(转换)原始流元素值映射到新的流元素值:
Stream<R> map(Function<T, R> mapper):将提供的函数应用于流的T类型的每个元素,并生成R类型的新元素值IntStream mapToInt(ToIntFunction<T> mapper):将提供的函数应用于流的T类型的每个元素,并生成int类型的新元素值LongStream mapToLong(ToLongFunction<T> mapper):将提供的函数应用于流的T类型的每个元素,并生成long类型的新元素值DoubleStream mapToDouble(ToDoubleFunction<T> mapper):将提供的函数应用于流的T类型的每个元素,并生成double类型的新元素值Stream<R> flatMap(Function<T, Stream<R>> mapper):将提供的函数应用于流的T类型的每个元素,并生成一个Stream<R>对象,该对象发出R类型的元素IntStream flatMapToInt(Function<T, IntStream> mapper):将提供的函数应用于流的T类型的每个元素,并生成一个IntStream对象,该对象发出int类型的元素LongStream flatMapToLong(Function<T, LongStream> mapper):将提供的函数应用于流的T类型的每个元素,并生成一个LongStream对象,该对象发出long类型的元素DoubleStream flatMapToDouble(Function<T, DoubleStream> mapper):将提供的函数应用于流的T类型的每个元素,并生成一个DoubleStream对象,该对象发出double类型的元素
以下是使用这些操作的示例:
List<String> list = List.of("1", "2", "3", "4", "5");
list.stream().map(s -> s + s)
.forEach(System.out::print); //prints: 1122334455
list.stream().mapToInt(Integer::valueOf)
.forEach(System.out::print); //prints: 12345
list.stream().mapToLong(Long::valueOf)
.forEach(System.out::print); //prints: 12345
list.stream().mapToDouble(Double::valueOf)
.mapToObj(Double::toString)
.map(s -> s + " ")
.forEach(System.out::print); //prints: 1.0 2.0 3.0 4.0 5.0
list.stream().mapToInt(Integer::valueOf)
.flatMap(n -> IntStream.iterate(1, i -> i < n, i -> ++i))
.forEach(System.out::print); //prints: 1121231234
list.stream().map(Integer::valueOf)
.flatMapToInt(n ->
IntStream.iterate(1, i -> i < n, i -> ++i))
.forEach(System.out::print); //prints: 1121231234
list.stream().map(Integer::valueOf)
.flatMapToLong(n ->
LongStream.iterate(1, i -> i < n, i -> ++i))
.forEach(System.out::print); //prints: 1121231234
list.stream().map(Integer::valueOf)
.flatMapToDouble(n ->
DoubleStream.iterate(1, i -> i < n, i -> ++i))
.mapToObj(Double::toString)
.map(s -> s + " ")
.forEach(System.out::print);
//prints: 1.0 1.0 2.0 1.0 2.0 3.0 1.0 2.0 3.0 4.0
在上一个示例中,将流转换为DoubleStream,我们将每个数值转换为一个String对象,并添加空格,这样就可以用数字之间的空格打印结果。这些示例非常简单:只需进行最小处理即可进行转换。但在现实生活中,每个map()或flatMap()操作通常都接受一个更复杂的函数,该函数做一些更有用的事情。
排序
以下两个中间操作对流元素进行排序:
Stream<T> sorted():按自然顺序排序流元素(根据它们的Comparable接口实现)Stream<T> sorted(Comparator<T> comparator):根据提供的Comparator<T>对象对流元素进行排序
当然,在所有元素发出之前,这些操作无法完成,因此这种处理会产生大量开销,降低性能,并且必须用于小规模流
下面是演示代码:
List<String> list = List.of("2", "1", "5", "4", "3");
list.stream().sorted().forEach(System.out::print); //prints: 12345
list.stream().sorted(Comparator.reverseOrder())
.forEach(System.out::print); //prints: 54321
窥探
中间的Stream<T> peek(Consumer<T> action)操作将提供的Consumer<T>函数应用于每个流元素,但不改变流值(函数Consumer<T>返回void。此操作用于调试。下面的代码显示了它的工作原理:
List<String> list = List.of("1", "2", "3", "4", "5");
list.stream()
.peek(s -> System.out.print("3".equals(s) ? 3 : 0))
.forEach(System.out::print); //prints: 0102330405
终端操作
终端操作是流的最重要的操作。不使用任何其他操作就可以完成其中的所有操作。
我们已经使用了forEach(Consumer<T>)终端操作来打印每个元素。它不返回一个值,因此用于它的副作用。但是Stream接口有许多更强大的终端操作,它们返回值。
其中最主要的是collect()操作,它有R collect(Collector<T, A, R> collector)和R collect(Supplier<R> supplier, BiConsumer<R, T> accumulator, BiConsumer<R, R> combiner)两种形式。它允许组合几乎任何可以应用于流的进程。经典的例子如下:
List<String> list = Stream.of("1", "2", "3", "4", "5")
.collect(ArrayList::new,
ArrayList::add,
ArrayList::addAll);
System.out.println(list); //prints: [1, 2, 3, 4, 5]
在这个例子中,它的使用方式适合于并行处理。collect()操作的第一个参数是基于流元素生成值的函数。第二个参数是累积结果的函数。第三个参数是组合处理流的所有线程的累积结果的函数。
但是只有一个这样的通用终端操作会迫使程序员重复编写相同的函数。这就是 API 作者添加类Collectors的原因,该类生成许多专门的Collector对象,而无需为每个collect()操作创建三个函数。
除此之外,API 作者还在接口Stream中添加了各种更专门的终端操作,这些操作更简单、更易于使用。在本节中,我们将回顾Stream接口的所有终端操作,并在Collect小节中查看Collectors类生成的大量Collector对象。我们从最简单的终端操作开始,它允许一次处理这个流的每个元素。
在我们的示例中,我们将使用以下类:Person:
public class Person {
private int age;
private String name;
public Person(int age, String name) {
this.age = age;
this.name = name;
}
public int getAge() {return this.age; }
public String getName() { return this.name; }
@Override
public String toString() {
return "Person{" + "name='" + this.name + "'" +
", age=" + age + "}";
}
}
处理每个元素
此组中有两个终端操作:
void forEach(Consumer<T> action):为该流的每个元素应用提供的操作void forEachOrdered(Consumer<T> action):按照源定义的顺序为该流的每个元素应用提供的操作,而不管该流是连续的还是并行的
如果需要处理元素的顺序很重要,并且必须是顺序值在源代码处排列,请使用第二种方法,特别是如果您可以预见您的代码可能会在具有多个 CPU 的计算机上执行。否则,请使用第一个,就像我们在所有示例中所做的那样。
让我们看一个使用forEach()操作从文件中读取逗号分隔的值(年龄和名称)并创建Person对象的示例。我们已将以下文件persons.csv(csv代表逗号分隔值)放在resources文件夹中:
23 , Ji m
2 5 , Bob
15 , Jill
17 , Bi ll
我们在值的内部和外部添加了空格,以便借此机会向您展示一些处理实际数据的简单但非常有用的技巧。
首先,我们将读取文件并逐行显示其内容,但只显示包含字母J的行:
Path path = Paths.get("src/main/resources/persons.csv");
try (Stream<String> lines = Files.newBufferedReader(path).lines()) {
lines.filter(s -> s.contains("J"))
.forEach(System.out::println); //prints: 23 , Ji m
// 15 , Jill
} catch (IOException ex) {
ex.printStackTrace();
}
这是使用forEach()操作的一种典型方式:独立地处理每个元素。此代码还提供了一个资源尝试构造的示例,该构造自动关闭BufferedReader对象
下面是一个没有经验的程序员如何编写代码,从Stream<String> lines对象读取流元素,并创建Person对象列表:
List<Person> persons = new ArrayList<>();
lines.filter(s -> s.contains("J")).forEach(s -> {
String[] arr = s.split(",");
int age = Integer.valueOf(StringUtils.remove(arr[0], ' '));
persons.add(new Person(age, StringUtils.remove(arr[1], ' ')));
});
您可以看到split()方法是如何使用逗号分隔各行的,以及org.apache.commons.lang3.StringUtils.remove()方法是如何从每个值中删除空格的。尽管此代码在单核计算机上的小示例中运行良好,但它可能会在长流和并行处理中产生意外的结果。
这就是 Lambda 表达式要求所有变量都是final或有效final的原因,因为同一个函数可以在不同的上下文中执行。
以下是上述代码的正确实现:
List<Person> persons = lines.filter(s -> s.contains("J"))
.map(s -> s.split(","))
.map(arr -> {
int age = Integer.valueOf(StringUtils.remove(arr[0], ' '));
return new Person(age, StringUtils.remove(arr[1], ' '));
}).collect(Collectors.toList());
为了提高可读性,我们可以创建一个方法来进行映射:
private Person createPerson(String[] arr){
int age = Integer.valueOf(StringUtils.remove(arr[0], ' '));
return new Person(age, StringUtils.remove(arr[1], ' '));
}
现在我们可以使用它如下:
List<Person> persons = lines.filter(s -> s.contains("J"))
.map(s -> s.split(","))
.map(this::createPerson)
.collect(Collectors.toList());
如您所见,我们使用了collect()操作符和Collectors.toList()方法创建的Collector函数。我们将在“收集”小节中看到更多由类Collectors创建的函数。
计算所有元素
Stream接口的long count()终端操作看起来简单而良性。它返回此流中的元素数。那些习惯于使用集合和数组的人可以不用三思而后行地使用count()操作。以下代码段演示了一个警告:
long count = Stream.of("1", "2", "3", "4", "5")
.peek(System.out::print)
.count();
System.out.print(count); //prints: 5
如果我们运行前面的代码,结果如下所示:

如您所见,实现count()方法的代码能够在不执行所有管道的情况下确定流大小。peek()操作没有打印任何内容,这证明元素没有被发射。因此,如果您希望看到打印的流的值,您可能会感到困惑,并希望代码有某种缺陷
另一个警告是,并不总是能够在源位置确定流的大小。此外,这条河可能是无限的。所以,你必须小心使用count()。
确定流大小的另一种可能方法是使用collect()操作:
long count = Stream.of("1", "2", "3", "4", "5")
.peek(System.out::print) //prints: 12345
.collect(Collectors.counting());
System.out.println(count); //prints: 5
下面的屏幕截图显示了在运行前面的代码示例后发生的情况:

如您所见,collect()操作不计算源处的流大小。这是因为collect()操作没有count()操作专业化。它只是将传入的收集器应用于流。收集器只计算由collect()操作提供给它的元素。
全部匹配,任何匹配,无匹配
有三个看起来非常相似的终端操作,允许我们评估所有、任何或没有一个流元素具有特定的值:
boolean allMatch(Predicate<T> predicate):当每个流元素在用作所提供的Predicate<T>函数的参数时返回true时,返回trueboolean anyMatch(Predicate<T> predicate):当其中一个流元素作为所提供的Predicate<T>函数的参数返回true时,返回trueboolean noneMatch(Predicate<T> predicate):当作为提供的Predicate<T>函数的参数使用时,当没有一个流元素返回true时,返回true
以下是它们的用法示例:
List<String> list = List.of("1", "2", "3", "4", "5");
boolean found = list.stream()
.peek(System.out::print) //prints: 123
.anyMatch(e -> "3".equals(e));
System.out.println(found); //prints: true
boolean noneMatches = list.stream()
.peek(System.out::print) //prints: 123
.noneMatch(e -> "3".equals(e));
System.out.println(noneMatches); //prints: false
boolean allMatch = list.stream()
.peek(System.out::print) //prints: 1
.allMatch(e -> "3".equals(e));
System.out.println(allMatch); //prints: false
请注意,所有这些操作都进行了优化,以便在可以提前确定结果的情况下不会处理所有流元素。
找到任何一个或第一个
以下终端操作允许相应地查找流的任何或第一个元素:
Optional<T> findAny():返回一个包含流的任何元素的值的Optional,如果流为空,则返回一个空的OptionalOptional<T> findFirst():返回一个包含流的第一个元素的值的Optional,如果流是空的,则返回一个空的Optional
以下示例说明了这些操作:
List<String> list = List.of("1", "2", "3", "4", "5");
Optional<String> result = list.stream().findAny();
System.out.println(result.isPresent()); //prints: true
System.out.println(result.get()); //prints: 1
result = list.stream()
.filter(e -> "42".equals(e))
.findAny();
System.out.println(result.isPresent()); //prints: false
//System.out.println(result.get()); //NoSuchElementException
result = list.stream().findFirst();
System.out.println(result.isPresent()); //prints: true
System.out.println(result.get()); //prints: 1
在前面的第一个和第三个示例中,findAny()和findFirst()操作产生相同的结果:它们都找到流的第一个元素。但在并行处理中,结果可能不同。
当流被分成若干部分进行并行处理时,findFirst()操作总是返回流的第一个元素,findAny()操作只返回其中一个处理线程中的第一个元素。
现在让我们更详细地谈谈class java.util.Optional。
Optional类
java.util.Optional的宾语用于避免返回null(因为它可能导致NullPointerException)。相反,Optional对象提供的方法允许检查值的存在,如果返回值是null,则用预定义的值替换它。例如:
List<String> list = List.of("1", "2", "3", "4", "5");
String result = list.stream()
.filter(e -> "42".equals(e))
.findAny()
.or(() -> Optional.of("Not found"))
.get();
System.out.println(result); //prints: Not found
result = list.stream()
.filter(e -> "42".equals(e))
.findAny()
.orElse("Not found");
System.out.println(result); //prints: Not found
Supplier<String> trySomethingElse = () -> {
//Code that tries something else
return "43";
};
result = list.stream()
.filter(e -> "42".equals(e))
.findAny()
.orElseGet(trySomethingElse);
System.out.println(result); //prints: 43
list.stream()
.filter(e -> "42".equals(e))
.findAny()
.ifPresentOrElse(System.out::println,
() -> System.out.println("Not found")); //prints: Not found
如您所见,如果Optional对象是空的,那么以下情况适用:
Optional类的or()方法允许返回另一个Optional对象。orElse()方法允许返回替代值。orElseGet()方法允许提供Supplier函数,该函数返回一个可选值。ifPresentOrElse()方法允许提供两个函数:一个消耗Optional对象的值,另一个在Optional对象为空的情况下执行其他操作。
最小值和最大值
以下终端操作返回流元素的最小值或最大值(如果存在):
Optional<T> min(Comparator<T> comparator):使用提供的Comparator对象返回该流的最小元素Optional<T> max(Comparator<T> comparator):使用提供的Comparator对象返回该流的最大元素
下面的代码演示了这一点:
List<String> list = List.of("a", "b", "c", "c", "a");
String min = list.stream()
.min(Comparator.naturalOrder())
.orElse("0");
System.out.println(min); //prints: a
String max = list.stream()
.max(Comparator.naturalOrder())
.orElse("0");
System.out.println(max); //prints: c
如您所见,在非数值的情况下,最小元素是根据提供的比较器从左到右排序时的第一个元素。因此,最大值是最后一个元素。对于数值,最小值和最大值只是:流元素中的最小值和最大值:
int mn = Stream.of(42, 77, 33)
.min(Comparator.naturalOrder())
.orElse(0);
System.out.println(mn); //prints: 33
int mx = Stream.of(42, 77, 33)
.max(Comparator.naturalOrder())
.orElse(0);
System.out.println(mx); //prints: 77
让我们看另一个例子,使用Person类。任务是在以下列表中找到最年长的人:
List<Person> persons = List.of(new Person(23, "Bob"),
new Person(33, "Jim"),
new Person(28, "Jill"),
new Person(27, "Bill"));
为了做到这一点,我们可以创建下面的Compartor<Person>,只按年龄比较Person对象:
Comparator<Person> perComp = (p1, p2) -> p1.getAge() - p2.getAge();
然后,使用这个比较器,我们可以找到最年长的人:
Person theOldest = persons.stream()
.max(perComp)
.orElse(null);
System.out.println(theOldest); //prints: Person{name='Jim', age=33}
到数组
以下两个终端操作生成包含流元素的数组:
Object[] toArray():创建一个对象数组;每个对象都是流的一个元素A[] toArray(IntFunction<A[]> generator):使用提供的函数创建流元素数组
让我们看一些例子:
List<String> list = List.of("a", "b", "c");
Object[] obj = list.stream().toArray();
Arrays.stream(obj).forEach(System.out::print); //prints: abc
String[] str = list.stream().toArray(String[]::new);
Arrays.stream(str).forEach(System.out::print); //prints: abc
第一个例子很简单。它将元素转换为相同类型的数组。至于第二个例子,IntFunction作为String[]::new的表示可能并不明显,所以让我们来看看它。String[]::new是表示 Lambda 表达式i -> new String[i]的方法引用,因为toArray()操作从流接收的不是元素,而是它们的计数:
String[] str = list.stream().toArray(i -> new String[i]);
我们可以通过添加i值的打印来证明:
String[] str = list.stream()
.toArray(i -> {
System.out.println(i); //prints: 3
return new String[i];
});
i -> new String[i]表达式是一个IntFunction<String[]>,根据它的文档,它接受一个int参数并返回指定类型的结果。可以使用匿名类定义它,如下所示:
IntFunction<String[]> intFunction = new IntFunction<String[]>() {
@Override
public String[] apply(int i) {
return new String[i];
}
};
java.util.Collection接口有一个非常类似的方法,可以将集合转换为数组:
List<String> list = List.of("a", "b", "c");
String[] str = list.toArray(new String[lits.size()]);
Arrays.stream(str).forEach(System.out::print); //prints: abc
唯一的区别是Stream接口的toArray()接受一个函数,而Collection接口的toArray()接受一个数组。
归约
这种终端操作被称为reduce,因为它处理所有流元素并产生一个值,从而将所有流元素减少为一个值。但这并不是唯一的行动。collect操作将流元素的所有值也减少为一个结果。而且,在某种程度上,所有的终端操作都是还原的。它们在处理许多元素后产生一个值。
因此,您可以将reduce和collect视为同义词,它们有助于为Stream接口中的许多可用操作添加结构和分类。此外,reduce组中的操作可以被视为collect操作的专用版本,因为collect()可以定制为提供与reduce()操作相同的功能。
也就是说,让我们看一组reduce操作:
Optional<T> reduce(BinaryOperator<T> accumulator):使用提供的聚合元素的关联函数来减少流中的元素;返回一个包含减少值(如果可用)的OptionalT reduce(T identity, BinaryOperator<T> accumulator):提供与先前reduce()版本相同的功能,但使用identity参数作为累加器的初始值,或者在流为空时使用默认值U reduce(U identity, BiFunction<U,T,U> accumulator, BinaryOperator<U> combiner):提供与先前reduce()版本相同的功能,但是,当此操作应用于并行流时,使用combiner函数来聚合结果;如果流不是并行的,则不使用combiner函数
为了演示reduce()操作,我们将使用之前使用的Person类和Person对象的相同列表作为流示例的源:
List<Person> persons = List.of(new Person(23, "Bob"),
new Person(33, "Jim"),
new Person(28, "Jill"),
new Person(27, "Bill"));
让我们使用reduce()操作来查找列表中最年长的人:
Person theOldest = list.stream()
.reduce((p1, p2) -> p1.getAge() > p2.getAge() ? p1 : p2)
.orElse(null);
System.out.println(theOldest); //prints: Person{name='Jim', age=33}
它的实现有点令人惊讶,不是吗?reduce()操作需要一个累加器,但它似乎没有积累任何东西。相反,它比较所有流元素。累加器保存比较的结果,并将其作为下一个比较(与下一个元素)的第一个参数。在本例中,可以说累加器累加了前面所有比较的结果
现在让我们明确地积累一些东西。让我们把所有人的名字集中在一个逗号分隔的列表中:
String allNames = list.stream()
.map(p -> p.getName())
.reduce((n1, n2) -> n1 + ", " + n2)
.orElse(null);
System.out.println(allNames); //prints: Bob, Jim, Jill, Bill
在这种情况下,积累的概念更有意义,不是吗?
现在让我们使用identity值来提供一些初始值:
String all = list.stream()
.map(p -> p.getName())
.reduce("All names: ", (n1, n2) -> n1 + ", " + n2);
System.out.println(all); //prints: All names: , Bob, Jim, Jill, Bill
注意,reduce()操作的这个版本返回value,而不是Optional对象。这是因为,通过提供初始值,我们可以保证,如果流结果为空,结果中至少会出现这个值。但最终的字符串看起来并不像我们希望的那么漂亮。显然,所提供的初始值被视为任何其他流元素,并且我们创建的累加器会在它后面添加一个逗号。为了使结果看起来更漂亮,我们可以再次使用第一个版本的reduce()操作,并通过以下方式添加初始值:
String all = "All names: " + list.stream()
.map(p -> p.getName())
.reduce((n1, n2) -> n1 + ", " + n2)
.orElse(null);
System.out.println(all); //prints: All names: Bob, Jim, Jill, Bill
或者我们可以用空格代替逗号作为分隔符:
String all = list.stream()
.map(p -> p.getName())
.reduce("All names:", (n1, n2) -> n1 + " " + n2);
System.out.println(all); //prints: All names: Bob Jim Jill Bill
现在结果看起来更好了。在下一小节中演示collect()操作的同时,我们将展示一种更好的方法来创建以逗号分隔的带有前缀的值列表。
同时,让我们继续回顾一下reduce()操作,看看它的第三种形式:有三个参数的形式:identity、accumulator和combiner。将组合器添加到reduce()操作不会改变结果:
String all = list.stream()
.map(p -> p.getName())
.reduce("All names:", (n1, n2) -> n1 + " " + n2,
(n1, n2) -> n1 + " " + n2 );
System.out.println(all); //prints: All names: Bob Jim Jill Bill
这是因为流不是并行的,并且组合器仅与并行流一起使用。如果我们使流平行,结果会改变:
String all = list.parallelStream()
.map(p -> p.getName())
.reduce("All names:", (n1, n2) -> n1 + " " + n2,
(n1, n2) -> n1 + " " + n2 );
System.out.println(all);
//prints: All names: Bob All names: Jim All names: Jill All names: Bill
显然,对于并行流,元素序列被分解成子序列,每个子序列独立地处理,其结果由组合器聚合。在执行此操作时,组合器将初始值(标识)添加到每个结果中。即使我们移除合并器,并行流处理的结果仍然是相同的,因为提供了默认的合并器行为:
String all = list.parallelStream()
.map(p -> p.getName())
.reduce("All names:", (n1, n2) -> n1 + " " + n2);
System.out.println(all);
//prints: All names: Bob All names: Jim All names: Jill All names: Bill
在前两种形式的reduce()操作中,累加器使用同一值。在第三种形式中,标识值由组合器使用(注意,U类型是组合器类型)。为了消除结果中的重复标识值,我们决定从组合器中的第二个参数中删除它(以及尾随空格):
String all = list.parallelStream().map(p->p.getName())
.reduce("All names:", (n1, n2) -> n1 + " " + n2,
(n1, n2) -> n1 + " " + StringUtils.remove(n2, "All names: "));
System.out.println(all); //prints: All names: Bob Jim Jill Bill
结果如预期。
到目前为止,在我们基于字符串的示例中,标识不仅仅是一个初始值。它还充当结果字符串中的标识符(标签)。但是当流的元素是数字时,标识看起来更像是一个初始值。让我们看看下面的例子:
List<Integer> ints = List.of(1, 2, 3);
int sum = ints.stream()
.reduce((i1, i2) -> i1 + i2)
.orElse(0);
System.out.println(sum); //prints: 6
sum = ints.stream()
.reduce(Integer::sum)
.orElse(0);
System.out.println(sum); //prints: 6
sum = ints.stream()
.reduce(10, Integer::sum);
System.out.println(sum); //prints: 16
sum = ints.stream()
.reduce(10, Integer::sum, Integer::sum);
System.out.println(sum); //prints: 16
前两个管道完全相同,只是第二个管道使用方法引用。第三和第四条管道也具有相同的功能。它们都使用初始值10。现在第一个参数作为初始值比恒等式更有意义,不是吗?在第四个管道中,我们添加了一个组合器,但由于流不是平行的,所以没有使用它。让我们把它平行,看看会发生什么:
List<Integer> ints = List.of(1, 2, 3);
int sum = ints.parallelStream()
.reduce(10, Integer::sum, Integer::sum);
System.out.println(sum); //prints: 36
结果是36,因为10的初始值加了三次,每次都是部分结果。很明显,这条河被分成了三个子序列。但情况并非总是如此,因为子序列的数量随着流的增长而变化,计算机上的 CPU 数量也随之增加。这就是为什么不能依赖于固定数量的子序列,最好不要对并行流使用非零初始值:
List<Integer> ints = List.of(1, 2, 3);
int sum = ints.parallelStream()
.reduce(0, Integer::sum, Integer::sum);
System.out.println(sum); //prints: 6
sum = 10 + ints.parallelStream()
.reduce(0, Integer::sum, Integer::sum);
System.out.println(sum); //prints: 16
如您所见,我们已经将identity设置为0,所以每个子序列都会得到它,但是当组合器组装所有处理线程的结果时,总数不受影响。
收集
collect()操作的一些用法非常简单,任何初学者都很容易掌握,而其他情况可能很复杂,即使对于一个经验丰富的程序员来说也不容易理解。加上已经讨论过的操作,我们在这一节中介绍的最流行的collect()用法足以满足初学者的所有需求,并将涵盖更有经验的专业人士的大多数需求。与数字流的操作(见下一节“数字流接口”)一起,它们涵盖了主流程序员的所有需求。
正如我们已经提到的,collect()操作非常灵活,允许我们定制流处理。它有两种形式:
R collect(Collector<T, A, R> collector):使用提供的Collector处理T类型的流元素,通过A类型的中间累加产生R类型的结果R collect(Supplier<R> supplier, BiConsumer<R, T> accumulator, BiConsumer<R, R> combiner):使用提供的函数处理T类型的流元素:Supplier<R> supplier:新建结果容器BiConsumer<R, T> accumulator:向结果容器添加元素的无状态函数BiConsumer<R, R> combiner:合并两个部分结果容器的无状态函数:将第二个结果容器中的元素添加到第一个结果容器中
让我们先来看第二种形式的collect()操作。它非常类似于我们刚才演示的三个参数的reduce()操作:supplier、accumulator和combiner。最大的区别在于,collect()操作中的第一个参数不是一个标识或初始值,而是一个容器,一个对象,它将在函数之间传递并保持处理的状态。
让我们通过从Person对象列表中选择最年长的人来演示它是如何工作的。对于下面的示例,我们将使用熟悉的Person类作为容器,但向其中添加一个没有参数的构造器和两个设置器:
public Person(){}
public void setAge(int age) { this.age = age;}
public void setName(String name) { this.name = name; }
添加一个没有参数和设置器的构造器是必要的,因为作为容器的Person对象应该可以在任何时候创建,而不需要任何参数,并且应该能够接收和保留部分结果:迄今为止年龄最大的人的姓名和年龄。collect()操作将在处理每个元素时使用此容器,并且在处理最后一个元素后,将包含最年长者的姓名和年龄。
我们将再次使用相同的人员名单:
List<Person> list = List.of(new Person(23, "Bob"),
new Person(33, "Jim"),
new Person(28, "Jill"),
new Person(27, "Bill"));
下面是一个collect()操作,用于查找列表中最年长的人:
BiConsumer<Person, Person> accumulator = (p1, p2) -> {
if(p1.getAge() < p2.getAge()){
p1.setAge(p2.getAge());
p1.setName(p2.getName());
}
};
BiConsumer<Person, Person> combiner = (p1, p2) -> {
System.out.println("Combiner is called!");
if(p1.getAge() < p2.getAge()){
p1.setAge(p2.getAge());
p1.setName(p2.getName());
}
};
Person theOldest = list.stream()
.collect(Person::new, accumulator, combiner);
System.out.println(theOldest); //prints: Person{name='Jim', age=33}
我们尝试在操作调用中内联函数,但是看起来有点难读,所以我们决定先创建函数,然后在collect()操作中使用它们。容器,一个Person对象,在处理第一个元素之前只创建一次。在这个意义上,它类似于reduce()操作的初始值。然后将其传递给累加器,累加器将其与第一个元素进行比较。容器中的age字段被初始化为默认值 0,因此,第一个元素的age和name在容器中被设置为迄今为止最老的人的参数。当第二个流元素(Person对象)被发射时,它的age值与当前存储在容器中的age值进行比较,依此类推,直到流的所有元素都被处理。结果显示在前面的注释中。
当流是连续的时,从不调用组合器。但是当我们使它并行(list.parallelStream())时,消息合并器被调用!打印了三次。好吧,在reduce()操作的情况下,部分结果的数量可能会有所不同,这取决于 CPU 的数量和collect()操作实现的内部逻辑。因此,消息组合器被称为!可打印任意次数
现在让我们看一下collect()操作的第一种形式。它需要实现java.util.stream.Collector<T,A,R>接口的类的对象,其中T是流类型,A是容器类型,R是结果类型。您可以使用以下方法之一of()(来自Collector接口)来创建必要的Collector对象:
static Collector<T,R,R> of(Supplier<R> supplier,
BiConsumer<R,T> accumulator,
BinaryOperator<R> combiner,
Collector.Characteristics... characteristics)
或者
static Collector<T,A,R> of(Supplier<A> supplier,
BiConsumer<A,T> accumulator,
BinaryOperator<A> combiner,
Function<A,R> finisher,
Collector.Characteristics... characteristics).
必须传递给前面方法的函数与我们已经演示过的函数类似。但我们不打算这么做,有两个原因。首先,它涉及的内容更多,将我们推到了本书的范围之外;其次,在此之前,您必须查看java.util.stream.Collectors类,它提供了许多现成的收集器。
正如我们已经提到的,连同到目前为止讨论的操作和我们将在下一节中介绍的数字流操作,即用收集器涵盖了主流编程中的绝大多数处理需求,并且很有可能您永远不需要创建自定义收集器
收集器
java.util.stream.Collectors类提供了 40 多个创建Collector对象的方法。我们将只演示最简单和最流行的:
Collector<T,?,List<T>> toList():创建一个收集器,从流元素生成一个List对象Collector<T,?,Set<T>> toSet():创建一个收集器,从流元素生成一个Set对象Collector<T,?,Map<K,U>> toMap (Function<T,K> keyMapper, Function<T,U> valueMapper):创建一个收集器,从流元素生成一个Map对象Collector<T,?,C> toCollection (Supplier<C> collectionFactory):创建一个收集器,该收集器生成Supplier<C> collectionFactory所提供类型的Collection对象Collector<CharSequence,?,String> joining():创建一个收集器,通过连接流元素生成一个String对象Collector<CharSequence,?,String> joining (CharSequence delimiter):创建一个收集器,该收集器生成一个分隔符,将String对象与流元素分开Collector<CharSequence,?,String> joining (CharSequence delimiter, CharSequence prefix, CharSequence suffix):创建一个收集器,该收集器生成一个分隔符,将String对象与流元素分开,并添加指定的prefix和suffixCollector<T,?,Integer> summingInt(ToIntFunction<T>):创建一个收集器,计算应用于每个元素的所提供函数生成的结果之和;对于long和double类型,存在相同的方法Collector<T,?,IntSummaryStatistics> summarizingInt(ToIntFunction<T>):创建一个收集器,用于计算应用于每个元素的所提供函数生成的结果的总和、最小值、最大值、计数和平均值;对于long和double类型,存在相同的方法Collector<T,?,Map<Boolean,List<T>>> partitioningBy (Predicate<? super T> predicate):创建一个收集器,使用提供的Predicate函数分离元素Collector<T,?,Map<K,List<T>>> groupingBy(Function<T,U>):创建一个收集器,将元素分组到一个Map,其中包含所提供函数生成的键
下面的演示代码演示如何使用由所列方法创建的收集器。首先,我们演示toList()、toSet()、toMap()和toCollection()方法的用法:
List<String> ls = Stream.of("a", "b", "c")
.collect(Collectors.toList());
System.out.println(ls); //prints: [a, b, c]
Set<String> set = Stream.of("a", "a", "c")
.collect(Collectors.toSet());
System.out.println(set); //prints: [a, c]
List<Person> list = List.of(new Person(23, "Bob"),
new Person(33, "Jim"),
new Person(28, "Jill"),
new Person(27, "Bill"));
Map<String, Person> map = list.stream()
.collect(Collectors
.toMap(p -> p.getName() + "-" +
p.getAge(), p -> p));
System.out.println(map); //prints: {Bob-23=Person{name='Bob', age:23},
// Bill-27=Person{name='Bill', age:27},
// Jill-28=Person{name='Jill', age:28},
// Jim-33=Person{name='Jim', age:33}}
Set<Person> personSet = list.stream()
.collect(Collectors
.toCollection(HashSet::new));
System.out.println(personSet); //prints: [Person{name='Bill', age=27},
// Person{name='Jim', age=33},
// Person{name='Bob', age=23},
// Person{name='Jill', age=28}]
joining()方法允许将分隔列表中的Character和String值与prefix和suffix连接起来:
List<String> list1 = List.of("a", "b", "c", "d");
String result = list1.stream()
.collect(Collectors.joining());
System.out.println(result); //prints: abcd
result = list1.stream()
.collect(Collectors.joining(", "));
System.out.println(result); //prints: a, b, c, d
result = list1.stream()
.collect(Collectors.joining(", ", "The result: ", ""));
System.out.println(result); //prints: The result: a, b, c, d
result = list1.stream()
.collect(Collectors.joining(", ", "The result: ", ". The End."));
System.out.println(result); //prints: The result: a, b, c, d. The End.
现在让我们转到summingInt()和summarizingInt()方法。它们创建收集器,计算应用于每个元素的所提供函数产生的int值的总和和其他统计信息:
List<Person> list2 = List.of(new Person(23, "Bob"),
new Person(33, "Jim"),
new Person(28, "Jill"),
new Person(27, "Bill"));
int sum = list2.stream()
.collect(Collectors.summingInt(Person::getAge));
System.out.println(sum); //prints: 111
IntSummaryStatistics stats = list2.stream()
.collect(Collectors.summarizingInt(Person::getAge));
System.out.println(stats); //prints: IntSummaryStatistics{count=4,
//sum=111, min=23, average=27.750000, max=33}
System.out.println(stats.getCount()); //prints: 4
System.out.println(stats.getSum()); //prints: 111
System.out.println(stats.getMin()); //prints: 23
System.out.println(stats.getAverage()); //prints: 27.750000
System.out.println(stats.getMax()); //prints: 33
还有summingLong()、summarizingLong()、summingDouble()和summarizingDouble()方法。
partitioningBy()方法创建一个收集器,该收集器根据提供的条件对元素进行分组,并将这些组(列表)放在一个Map对象中,boolean值作为键:
Map<Boolean, List<Person>> map2 = list2.stream()
.collect(Collectors.partitioningBy(p -> p.getAge() > 27));
System.out.println(map2);
//{false=[Person{name='Bob', age=23}, Person{name='Bill', age=27},
// true=[Person{name='Jim', age=33}, Person{name='Jill', age=28}]}
如您所见,使用p.getAge() > 27标准,我们可以将所有人分为两组:一组低于或等于age的27年(键为false),另一组高于27(键为true)。
最后,groupingBy()方法允许按一个值对元素进行分组,并将这些组(列表)放入一个Map对象中,该值作为键:
List<Person> list3 = List.of(new Person(23, "Bob"),
new Person(33, "Jim"),
new Person(23, "Jill"),
new Person(33, "Bill"));
Map<Integer, List<Person>> map3 = list3.stream()
.collect(Collectors.groupingBy(Person::getAge));
System.out.println(map3);
// {33=[Person{name='Jim', age=33}, Person{name='Bill', age=33}],
// 23=[Person{name='Bob', age=23}, Person{name='Jill', age=23}]}
为了能够演示这个方法,我们更改了Person对象的列表,将每个对象上的age设置为23或33。结果是两组按age排序。
还有重载的toMap()、groupingBy()和partitioningBy()方法,以及以下创建相应Collector对象的方法(通常也是重载的):
counting()reducing()filtering()toConcurrentMap()collectingAndThen()maxBy()``minBy()mapping()``flatMapping()averagingInt()``averagingLong()``averagingDouble()toUnmodifiableList()``toUnmodifiableMap()``toUnmodifiableSet()
如果在本书中讨论的操作中找不到所需的操作,请先搜索CollectorsAPI,然后再构建自己的Collector对象。
数字流接口
如前所述,三个数字接口IntStream、LongStream和DoubleStream的方法都与接口Stream中的方法相似,包括接口Stream.Builder中的方法。这意味着我们在本章中讨论的所有内容都同样适用于任何数字流接口。这就是为什么在本节中我们只讨论那些在Stream接口中不存在的方法:
- 接口
IntStream和LongStream中的range(lower,upper)和rangeClosed(lower,upper)方法允许从指定范围内的值创建流 - 中间操作
boxed()和mapToObj()将数字流转换为Stream - 中间操作
mapToInt()、mapToLong()和mapToDouble()将一种类型的数字流转换为另一种类型的数字流 - 中间操作
flatMapToInt()、flatMapToLong()和flatMapToDouble()将流转换为数字流 - 终端操作
sum()和average()计算数字流元素的总和和平均值
创建流
除了创建流的Stream接口的方法之外,接口IntStream和LongStream还允许从指定范围内的值创建流。
range(),rangeClosed()
range(lower, upper)方法依次生成所有值,从lower值开始,以upper前的值结束:
IntStream.range(1, 3).forEach(System.out::print); //prints: 12
LongStream.range(1, 3).forEach(System.out::print); //prints: 12
rangeClosed(lower, upper)方法依次生成所有值,从lower值开始,到upper值结束:
IntStream.rangeClosed(1, 3).forEach(System.out::print); //prints: 123
LongStream.rangeClosed(1, 3).forEach(System.out::print); //prints: 123
中间操作
除了Stream接口的中间操作外,接口IntStream、LongStream、DoubleStream还具有若干特定的中间操作:boxed()、mapToObj()、mapToInt()、mapToLong()、mapToDouble()、flatMapToInt()、flatMapToLong()、flatMapToDouble()。
boxed(),mapToObj()
中间操作boxed()将原始类型数字类型的元素转换为相应的包装类型:
//IntStream.range(1, 3).map(Integer::shortValue) //compile error
// .forEach(System.out::print);
IntStream.range(1, 3)
.boxed()
.map(Integer::shortValue)
.forEach(System.out::print); //prints: 12
//LongStream.range(1, 3).map(Long::shortValue) //compile error
// .forEach(System.out::print);
LongStream.range(1, 3)
.boxed()
.map(Long::shortValue)
.forEach(System.out::print); //prints: 12
//DoubleStream.of(1).map(Double::shortValue) //compile error
// .forEach(System.out::print);
DoubleStream.of(1)
.boxed()
.map(Double::shortValue)
.forEach(System.out::print); //prints: 1
在前面的代码中,我们已经注释掉了生成编译错误的行,因为range()方法生成的元素是原始类型。boxed()操作将原始类型值转换为相应的包装类型,因此可以将其作为引用类型进行处理。中间操作mapToObj()做了类似的转换,但它不像boxed()操作那样专业化,允许使用原始类型的元素来生成任何类型的对象:
IntStream.range(1, 3)
.mapToObj(Integer::valueOf)
.map(Integer::shortValue)
.forEach(System.out::print); //prints: 12
IntStream.range(42, 43)
.mapToObj(i -> new Person(i, "John"))
.forEach(System.out::print); //prints: Person{name='John', age=42}
LongStream.range(1, 3)
.mapToObj(Long::valueOf)
.map(Long::shortValue)
.forEach(System.out::print); //prints: 12
DoubleStream.of(1)
.mapToObj(Double::valueOf)
.map(Double::shortValue)
.forEach(System.out::print); //prints: 1
在前面的代码中,我们添加了map()操作,只是为了证明mapToObj()操作完成了任务,并按照预期创建了一个包装类型的对象。另外,通过添加产生Person对象的管道,我们已经演示了如何使用mapToObj()操作来创建任何类型的对象
mapToInt(),mapToLong(),mapToDouble()
中间操作mapToInt()、mapToLong()、mapToDouble()允许将一种类型的数字流转换为另一种类型的数字流。出于演示目的,我们通过将每个String值映射到其长度,将String值列表转换为不同类型的数字流:
List<String> list = List.of("one", "two", "three");
list.stream()
.mapToInt(String::length)
.forEach(System.out::print); //prints: 335
list.stream()
.mapToLong(String::length)
.forEach(System.out::print); //prints: 335
list.stream()
.mapToDouble(String::length)
.forEach(d -> System.out.print(d + " ")); //prints: 3.0 3.0 5.0
list.stream()
.map(String::length)
.map(Integer::shortValue)
.forEach(System.out::print); //prints: 335
创建的数字流的元素属于原始类型:
//list.stream().mapToInt(String::length)
// .map(Integer::shortValue) //compile error
// .forEach(System.out::print);
而且,正如我们在本主题中所讨论的,如果您想将元素转换为数字包装类型,中间的map()操作是实现这一点的方法(而不是mapToInt()):
list.stream().map(String::length)
.map(Integer::shortValue)
.forEach(System.out::print); //prints: 335
flatMapToInt(),flatMapToLong(),flatMapToDouble()
中间操作flatMapToInt()、flatMapToLong()、flatMapToDouble()产生相应类型的数字流:
List<Integer> list = List.of(1, 2, 3);
list.stream()
.flatMapToInt(i -> IntStream.rangeClosed(1, i))
.forEach(System.out::print); //prints: 112123
list.stream()
.flatMapToLong(i -> LongStream.rangeClosed(1, i))
.forEach(System.out::print); //prints: 112123
list.stream()
.flatMapToDouble(DoubleStream::of)
.forEach(d -> System.out.print(d + " ")); //prints: 1.0 2.0 3.0
正如您在前面的代码中看到的,我们在原始流中使用了int值。但它可以是任何类型的流:
List.of("one", "two", "three")
.stream()
.flatMapToInt(s -> IntStream.rangeClosed(1, s.length()))
.forEach(System.out::print); //prints: 12312312345
终端操作
特定于数字的终端操作非常简单。其中有两个:
sum():计算数字流元素的和average():计算数值流元素的平均值
求和,平均
如果需要计算数值流元素值的总和或平均值,则流的唯一要求是它不应是无限的。否则,计算永远不会结束。以下是这些操作用法的示例:
int sum = IntStream.empty()
.sum();
System.out.println(sum); //prints: 0
sum = IntStream.range(1, 3)
.sum();
System.out.println(sum); //prints: 3
double av = IntStream.empty()
.average()
.orElse(0);
System.out.println(av); //prints: 0.0
av = IntStream.range(1, 3)
.average()
.orElse(0);
System.out.println(av); //prints: 1.5
long suml = LongStream.range(1, 3)
.sum();
System.out.println(suml); //prints: 3
double avl = LongStream.range(1, 3)
.average()
.orElse(0);
System.out.println(avl); //prints: 1.5
double sumd = DoubleStream.of(1, 2)
.sum();
System.out.println(sumd); //prints: 3.0
double avd = DoubleStream.of(1, 2)
.average()
.orElse(0);
System.out.println(avd); //prints: 1.5
如您所见,在空流上使用这些操作不是问题。
并行流
我们已经看到,如果没有为处理并行流而编写和测试代码,那么从顺序流更改为并行流可能会导致不正确的结果。以下是与并行流相关的更多考虑事项。
无状态和有状态操作
有无状态操作,例如filter()、map()和flatMap(),它们在从一个流元素到下一个流元素的处理过程中不保留数据(不维护状态)。并且有状态操作,例如distinct()、limit()、sorted()、reduce()和collect(),可以将状态从先前处理的元素传递到下一个元素的处理。
在从顺序流切换到并行流时,无状态操作通常不会造成问题。每个元素都是独立处理的,流可以被分解成任意数量的子流进行独立处理。对于有状态操作,情况就不同了。首先,将它们用于无限流可能永远无法完成处理。此外,在讨论有状态操作reduce()和collect()时,我们已经演示了如果在没有考虑并行处理的情况下设置初始值(或标识),那么切换到并行流如何产生不同的结果。
还有性能方面的考虑。有状态操作通常需要使用缓冲在多个过程中处理所有流元素。对于大型流,它可能会占用 JVM 资源,并且会减慢(如果不是完全关闭)应用的速度。
这就是为什么程序员不应该轻率地从顺序流切换到并行流的原因。如果涉及到有状态操作,则必须对代码进行设计和测试,以便能够在没有负面影响的情况下执行并行流处理。
顺序处理还是并行处理?
正如我们在上一节中所指出的,并行处理可能会也可能不会产生更好的性能。在决定使用并行流之前,您必须测试每个用例。并行性可以产生更好的性能,但代码必须经过设计和可能的优化才能做到这一点。每个假设都必须在尽可能接近生产的环境中进行测试。
但是,在决定顺序处理和并行处理时,您可以考虑以下几点:
- 小数据流通常按顺序处理得更快(那么,对于您的环境来说,什么是小应该通过测试和测量性能来确定)
- 如果有状态的操作不能被无状态的操作所替代,那么请仔细设计并行处理的代码,或者干脆避免它
- 对于需要大量计算的过程,请考虑并行处理,但要考虑将部分结果合并到一起以获得最终结果
总结
在本章中,我们讨论了数据流处理,它不同于我们在第 5 章、“字符串、输入/输出和文件”中回顾的处理 I/O 流。我们定义了数据流是什么,如何使用流操作处理它们的元素,以及如何在管道中链接(连接)流操作。我们还讨论了流初始化以及如何并行处理流
在下一章中,读者将介绍反应式宣言,它的主旨,以及它的实现示例。我们将讨论无功和响应系统的区别,以及什么是异步和非阻塞处理。我们还将讨论反应流和 RxJava。
测验
-
I/O 流和
java.util.stream.Stream有什么区别?选择所有适用的选项:- I/O 流面向数据传送,
Stream面向数据处理 - 一些 I/O 流可以转换成
Stream - I/O 流可以从文件中读取,而
Stream不能 - I/O 流可以写入文件,
Stream不能
- I/O 流面向数据传送,
-
Stream方法empty()和of(T... values)有什么共同点? -
由
Stream.ofNullable(Set.of(1,2,3 )流发射的元素是什么类型的? -
下面的代码打印什么?
Stream.iterate(1, i -> i + 2)
.limit(3)
.forEach(System.out::print);
- 下面的代码打印什么?
Stream.concat(Set.of(42).stream(),
List.of(42).stream()).limit(1)
.forEach(System.out::print);
- 下面的代码打印什么?
Stream.generate(() -> 42 / 2)
.limit(2)
.forEach(System.out::print);
Stream.Builder是函数式接口吗?- 下面的流发出多少元素?
new Random().doubles(42).filter(d -> d >= 1)
- 下面的代码打印什么?
Stream.of(1,2,3,4)
.skip(2)
.takeWhile(i -> i < 4)
.forEach(System.out::print);
- 以下代码中的
d值是多少?
double d = Stream.of(1, 2)
.mapToDouble(Double::valueOf)
.map(e -> e / 2)
.sum();
- 在下面的代码中,
s字符串的值是多少?
String s = Stream.of("a","X","42").sorted()
.collect(Collectors.joining(","));
- 以下代码的结果是什么?
List.of(1,2,3).stream()
.peek(i -> i > 2 )
.forEach(System.out::print);
peek()操作在下面的代码中打印多少个流元素?
List.of(1,2,3).stream()
.peek(System.out::println)
.noneMatch(e -> e == 2);
- 当
Optional对象为空时,or()方法返回什么? - 在下面的代码中,
s字符串的值是多少?
String s = Stream.of("a","X","42")
.max(Comparator.naturalOrder())
.orElse("12");
IntStream.rangeClosed(42, 42)流发出多少元素?- 说出两个无状态操作。
- 说出两个有状态操作。
十五、反应式程序设计
在本章中,读者将被介绍到反应式宣言和反应式编程的世界。我们从定义和讨论主要的相关概念开始—异步、非阻塞和响应。利用它们,我们定义并讨论了反应式编程,主要的反应式框架,并对 RxJava 进行了详细的讨论。
本章将讨论以下主题:
- 异步处理
- 非阻塞 API
- 反应式–响应迅速、弹性十足、富有弹性、信息驱动
- 反应流
- RxJava
异步处理
异步是指请求者立即得到响应,但结果不存在。相反,请求者等待结果发送给他们,或者保存在数据库中,或者,例如,作为允许检查结果是否准备好的对象呈现。如果是后者,请求者会周期性地调用这个对象的某个方法,当结果就绪时,使用同一对象上的另一个方法检索它。异步处理的优点是请求者可以在等待时做其他事情。
在第 8 章“多线程和并发处理”中,我们演示了如何创建子线程。这样的子线程然后发送一个非异步(阻塞)请求,并等待其返回而不做任何操作。同时,主线程继续执行并定期调用子线程对象,以查看结果是否就绪。这是最基本的异步处理实现。事实上,当我们使用并行流时,我们已经使用了它。
在幕后创建子线程的并行流操作将流分解为多个段,并将每个段分配给一个专用线程进行处理,然后将所有段的部分结果聚合为最终结果。在上一章中,我们甚至编写了执行聚合任务的函数。提醒一下,这个函数被称为一个组合器。
让我们用一个例子来比较顺序流和并行流的性能。
顺序流和并行流
为了演示顺序处理和并行处理之间的区别,让我们设想一个从 10 个物理设备(传感器)收集数据并计算平均值的系统。以下是从由 ID 标识的传感器收集测量值的get()方法:
double get(String id){
try{
TimeUnit.MILLISECONDS.sleep(100);
} catch(InterruptedException ex){
ex.printStackTrace();
}
return id * Math.random();
}
我们设置了 100 毫秒的延迟来模拟从传感器收集测量值所需的时间。至于得到的测量值,我们使用Math.random()方法。我们将使用方法所属的MeasuringSystem类的对象来调用这个get()方法
然后我们要计算一个平均值,以抵消单个设备的误差和其他特性:
void getAverage(Stream<Integer> ids) {
LocalTime start = LocalTime.now();
double a = ids.mapToDouble(id -> new MeasuringSystem().get(id))
.average()
.orElse(0);
System.out.println((Math.round(a * 100.) / 100.) + " in " +
Duration.between(start, LocalTime.now()).toMillis() + " ms");
}
注意我们如何使用mapToDouble()操作将 IDs 流转换为DoubleStream,以便应用average()操作。average()操作返回一个Optional<Double>对象,我们调用它的orElse(0)方法,该方法返回计算值或零(例如,如果测量系统无法连接到它的任何传感器并返回一个空流)
getAverage()方法的最后一行打印结果以及计算结果所用的时间。在实际代码中,我们将返回结果并将其用于其他计算。但是,为了演示,我们只是打印出来。
现在我们可以比较顺序流处理和并行处理的性能:
List<Integer> ids = IntStream.range(1, 11)
.mapToObj(i -> i)
.collect(Collectors.toList());
getAverage(ids.stream()); //prints: 2.99 in 1030 ms
getAverage(ids.parallelStream()); //prints: 2.34 in 214 ms
如果运行此示例,结果可能会有所不同,因为您可能还记得,我们将收集的测量值模拟为随机值。
如您所见,并行流的处理速度是顺序流的处理速度的五倍。结果是不同的,因为每次测量产生的结果都略有不同
虽然在幕后,并行流使用异步处理,但这并不是程序员在谈论请求的异步处理时所考虑的。从应用的角度来看,它只是并行(也称为并发)处理。它比顺序处理要快,但是主线程必须等到所有的调用都被发出并且数据被检索出来。如果每个调用至少需要 100 毫秒(在我们的例子中是这样),那么所有调用的处理就不能在更短的时间内完成。
当然,我们可以创建一个子线程,让它进行所有调用,并等待调用完成,而主线程则执行其他操作。我们甚至可以创建一个这样做的服务,所以应用只需告诉这样的服务必须做什么,然后继续做其他事情。稍后,主线程可以再次调用服务并获得结果或在某个商定的位置获取结果。
这将是程序员们谈论的真正的异步处理。但是,在编写这样的代码之前,让我们先看看位于java.util.concurrent包中的CompletableFuture类。它完成了所描述的一切,甚至更多。
使用CompletableFuture对象
使用CompletableFuture对象,我们可以通过从CompletableFuture对象得到结果,将请求单独发送到测量系统。这正是我们在解释什么是异步处理时描述的场景。让我们在代码中演示一下:
List<CompletableFuture<Double>> list =
ids.stream()
.map(id -> CompletableFuture.supplyAsync(() ->
new MeasuringSystem().get(id)))
.collect(Collectors.toList());
supplyAsync()方法不会等待对测量系统的调用返回。相反,它会立即创建一个CompletableFuture对象并返回它,以便客户可以在以后的任何时候使用该对象来检索测量系统返回的值:
LocalTime start = LocalTime.now();
double a = list.stream()
.mapToDouble(cf -> cf.join().doubleValue())
.average()
.orElse(0);
System.out.println((Math.round(a * 100.) / 100.) + " in " +
Duration.between(start, LocalTime.now()).toMillis() + " ms");
//prints: 2.92 in 6 ms
也有一些方法允许检查是否返回了值,但这并不是本演示的重点,演示如何使用CompletableFuture类来组织异步处理。
创建的CompletableFuture对象列表可以存储在任何地方,并且处理速度非常快(在本例中为 6 毫秒),前提是已经收到测量结果。在创建CompletableFuture对象列表和处理它们之间,系统没有阻塞,可以做其他事情。
CompletableFuture类有许多方法,并支持其他几个类和接口。例如,可以添加固定大小的线程池以限制线程数:
ExecutorService pool = Executors.newFixedThreadPool(3);
List<CompletableFuture<Double>> list = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() ->
new MeasuringSystem().get(id), pool))
.collect(Collectors.toList());
有许多这样的池用于不同的目的和不同的性能。但这一切并没有改变整个系统的设计,所以我们省略了这些细节。
如您所见,异步处理的功能非常强大。异步 API 还有一个变体,称为非阻塞 API,我们将在下一节中讨论。
非阻塞 API
非阻塞 API 的客户端希望能够快速返回结果,也就是说,不会被阻塞很长时间。因此,非阻塞 API 的概念意味着一个高度响应的应用。它可以同步或异步地处理请求—这对客户端并不重要。但实际上,这通常意味着应用使用异步处理,这有助于提高吞吐量和性能。
术语非阻塞与java.nio包一起使用。非阻塞输入/输出(NIO)支持密集的输入/输出操作。它描述了应用的实现方式:它不为每个请求指定一个执行线程,而是提供多个轻量级工作线程,这些线程以异步和异步方式进行处理同时
java.io包与java.nio包
向外部存储器(例如硬盘驱动器)写入数据和从外部存储器(例如硬盘驱动器)读取数据的操作要比仅在存储器中进行的操作慢得多。java.io包中已经存在的类和接口工作得很好,但偶尔会成为性能瓶颈。创建新的java.nio包是为了提供更有效的 I/O 支持。
java.io的实现是基于 I/O 流处理的,如前所述,即使后台发生某种并发,基本上也是一个阻塞操作。为了提高速度,引入了基于对内存中的缓冲区进行读写的java.nio实现。这样的设计使得它能够将填充/清空缓冲区的缓慢过程与快速读取/写入缓冲区的过程分离开来。
在某种程度上,它类似于我们在CompletableFuture用法示例中所做的。在缓冲区中有数据的另一个优点是,可以检查数据,沿着缓冲区往返,这在从流中顺序读取时是不可能的。它在数据处理过程中提供了更大的灵活性。此外,java.nio实现引入了另一个中间过程,称为通道,用于与缓冲区之间的批量数据传输。
读取线程从一个通道获取数据,只接收当前可用的数据,或者什么都不接收(如果通道中没有数据)。如果数据不可用,线程可以执行其他操作,而不是保持阻塞状态,例如,读取/写入其他通道,就像我们的CompletableFuture示例中的主线程可以自由执行测量系统从传感器读取数据时必须执行的操作一样。
这样,几个工作线程就可以服务于多个 I/O 进程,而不是将一个线程专用于一个 I/O 进程。这种解决方案被称为非阻塞 I/O,后来被应用到其他进程中,最突出的是事件循环中的事件处理,也称为运行循环。
事件/运行循环
许多非阻塞系统基于事件(或运行)循环—一个持续执行的线程。它接收事件(请求、消息),然后将它们分派给相应的事件处理器(工作器)。事件处理器没有什么特别之处。它们只是程序员专用于处理特定事件类型的方法(函数)。
这种设计被称为反应器设计模式。围绕处理并发事件和服务请求而构建,并命名为反应式编程和反应式系统,即对事件做出反应并对其进行并发处理。
基于事件循环的设计广泛应用于操作系统和图形用户界面中。它在 Spring5 的 SpringWebFlux 中可用,并用 JavaScript 及其流行的执行环境实现节点.JS. 最后一个使用事件循环作为其处理主干。工具箱 Vert.x 也是围绕事件循环构建的。
在采用事件循环之前,为每个传入请求分配一个专用线程,这与我们演示的流处理非常相似。每个线程都需要分配一定数量的非请求特定的资源,因此一些资源(主要是内存分配)被浪费了。然后,随着请求数量的增长,CPU 需要更频繁地将上下文从一个线程切换到另一个线程,以允许或多或少地并发处理所有请求。在负载下,切换上下文的开销足以影响应用的性能。
实现事件循环解决了这两个问题。它避免了为每个请求创建一个专用线程,并在处理请求之前一直保留该线程,从而消除了资源浪费。有了事件循环,每个请求只需分配更小的内存就可以捕获其细节,这使得在内存中保留更多的请求成为可能,以便它们可以并发处理。由于上下文大小的减小,CPU 上下文切换的开销也变得更小了。
非阻塞 API 是实现请求处理的方式。它使系统能够处理更大的负载,同时保持高度的响应性和弹性。
反应式
术语反应式通常用于反应式编程和反应式系统的上下文中。反应式编程(也称为 Rx 编程)基于异步数据流(也称为反应式流)。介绍为 Java 的反应式扩展(RX),又称 RxJava。后来,RX 支持被添加到了 Java9 的java.util.concurrent包中。它允许Publisher生成一个数据流,而Subscriber可以异步订阅该数据流。
反应流和标准流(也称为位于java.util.stream包中的 Java8 流)之间的一个主要区别是,反应流的源(发布者)以自己的速率将元素推送到订户,而在标准流中,新元素仅在前一个元素被推送之后才被推送和发射已处理
如您所见,即使没有这个新的 API,我们也可以通过使用CompletableFuture异步处理数据。但是在编写了几次这样的代码之后,您注意到大多数代码只是管道,所以您会觉得必须有一个更简单、更方便的解决方案。这就是反应流倡议的方式,工作范围定义如下:
“反应流的范围是找到最小的接口,方法和协议集,以描述实现目标所需的必要操作和实体–具有无阻塞背压的异步数据流。”
术语无阻塞背压是指异步处理的问题之一:在不需要停止(阻塞)数据输入的情况下,协调传入数据的速率与系统处理它们的能力。解决办法是通知消息来源,消费者很难跟上输入。此外,处理应该以比仅仅阻塞流更灵活的方式对传入数据的速率的变化作出反应,因此名称为反应式。
已经有几个库实现了 ReactiveStreamsAPI:RxJava、Reactor、Akka 和 Vertx 是最有名的。使用 RxJava 或另一个异步流库构成了“反应式编程”。它实现了反应宣言中宣布的目标:构建响应、弹性、弹性、消息驱动的反应式系统。
响应式
这个词似乎是不言自明的。及时作出反应的能力是任何系统的基本素质之一。有很多方法可以实现。即使是由足够多的服务器和其他基础设施支持的传统阻塞 API,也可以在不断增长的负载下实现良好的响应。
反应式编程有助于减少硬件的使用。它是有代价的,因为被动代码需要改变我们对控制流的思考方式。但过了一段时间,这种新的思维方式就和其他熟悉的技能一样自然了。
我们将在下面几节中看到许多反应式编程的例子。
可恢复的
失败是不可避免的。硬件崩溃、软件有缺陷、接收到意外数据或采用了未经测试的执行路径—这些事件中的任何一个或它们的组合都可能随时发生。弹性是系统在意外情况下继续交付预期结果的能力。
例如,可以使用可部署组件和硬件的冗余、系统各部分的隔离以降低多米诺效应的可能性、设计具有自动可更换部件的系统、发出警报以便合格人员能够进行干预。我们还讨论了分布式系统作为设计弹性系统的一个很好的例子。
分布式架构消除了单点故障。此外,将系统分解为许多专门的组件,这些组件使用消息相互通信,可以更好地调整最关键部分的重复,并为它们的隔离和潜在故障的遏制创造更多的机会。
弹性的
承受最大可能负载的能力通常与可伸缩性有关。但是,在变化的载荷下,而不仅仅是在增长的载荷下,保持相同性能特征的能力被称为弹性。
弹性系统的客户不应注意到空闲周期和峰值负载周期之间的任何差异。非阻塞的反应式实现风格促进了这种质量。此外,将程序分解为更小的部分,并将它们转换为可以独立部署和管理的服务,这样就可以对资源分配进行微调。
这种小型服务被称为微服务,它们中的许多一起可以组成一个既可伸缩又有弹性的反应式系统。我们将在下面的部分和下一章更详细地讨论这种架构。
消息驱动
我们已经确定组件隔离和系统分布是帮助保持系统响应性、弹性和弹性的两个方面。松散和灵活的连接也是支持这些品质的重要条件。反应式系统的异步特性并没有给设计者留下其他选择,而是在消息上构建组件之间的通信。
它在每个部件周围创造了一个喘息的空间,如果没有这个空间,系统将是一个紧密耦合的整体,容易出现各种问题,更不用说维护的噩梦了。
在下一章中,我们将研究可用于将应用构建为使用消息进行通信的松散耦合微服务集合的架构样式。
反应流
Java9 中引入的反应流 API 由以下四个接口组成:
@FunctionalInterface
public static interface Flow.Publisher<T> {
public void subscribe(Flow.Subscriber<T> subscriber);
}
public static interface Flow.Subscriber<T> {
public void onSubscribe(Flow.Subscription subscription);
public void onNext(T item);
public void onError(Throwable throwable);
public void onComplete();
}
public static interface Flow.Subscription {
public void request(long numberOfItems);
public void cancel();
}
public static interface Flow.Processor<T,R>
extends Flow.Subscriber<T>, Flow.Publisher<R> {
}
一个Flow.Subscriber对象可以作为参数传递到Flow.Publisher<T>的subscribe()方法中。然后发布者调用订阅者的onSubscribe()方法,并将Flow.Subsctiption对象作为参数传递给它。现在订阅者可以调用订阅对象上的request(long numberOfItems)向发布者请求数据。这就是拉取模式的实现方式,它让订户决定何时请求另一个项目进行处理。订阅者可以通过调用订阅时的cancel()方法取消对发布者服务的订阅。
作为回报,发布者可以通过调用订阅者的onNext()方法将新项目传递给订阅者。当不再有数据到来时(源中的所有数据都已发出),发布者调用订阅者的onComplete()方法。另外,通过调用订阅者的onError()方法,发布者可以告诉订阅者它遇到了问题
Flow.Processor接口描述了一个既可以充当订阅者又可以充当发布者的实体。它允许创建此类处理器的链(管道),以便订阅者可以从发布者接收项目,对其进行转换,然后将结果传递给下一个订阅者或处理器。
在推送模式中,发布者可以在没有来自订户的任何请求的情况下调用onNext()。如果处理速度低于项目发布速度,订阅者可以使用各种策略来缓解压力。例如,它可以跳过项目或为临时存储创建一个缓冲区,希望项目生产速度会减慢,订户能够赶上
这是 ReactiveStreams 计划为支持具有非阻塞背压的异步数据流而定义的最小接口集。如您所见,它允许订阅者和发布者相互交谈并协调传入数据的速率,从而为我们在“反应式”部分讨论的背压问题提供了多种解决方案。
有许多方法可以实现这些接口。目前,在 JDK9 中,只有一个接口的实现:SubmissionPublisher类实现了Flow.Publisher。原因是这些接口不应该由应用开发人员使用。它是一个服务提供者接口(SPI),由反应流库的开发人员使用。如果需要的话,可以使用已经实现了我们已经提到的 ReactiveStreamsAPI 的现有工具箱之一:RxJava、Reactor、Akka Streams、Vert.x 或任何其他您喜欢的库。
RxJava
我们将使用 RxJava2.2.7 在我们的例子中。可以通过以下依赖项将其添加到项目中:
<dependency>
<groupId>io.reactivex.rxjava2</groupId>
<artifactId>rxjava</artifactId>
<version>2.2.7</version>
</dependency>
我们首先比较一下使用java.util.stream包和io.reactivex包实现相同功能的两个实现。示例程序将非常简单:
- 创建整数流
1、2、3、4、5。 - 只过滤偶数(
2和4)。 - 计算每个过滤后数字的平方根。
- 计算所有平方根的和。
下面是如何使用java.util.stream包实现的:
double a = IntStream.rangeClosed(1, 5)
.filter(i -> i % 2 == 0)
.mapToDouble(Double::valueOf)
.map(Math::sqrt)
.sum();
System.out.println(a); //prints: 3.414213562373095
使用 RxJava 实现的相同功能如下所示:
Observable.range(1, 5)
.filter(i -> i % 2 == 0)
.map(Math::sqrt)
.reduce((r, d) -> r + d)
.subscribe(System.out::println); //prints: 3.414213562373095
RxJava 基于Observable对象(扮演Publisher角色)和Observer,订阅Observable并等待数据发出。
相比之下,对于Stream功能,Observable具有显著不同的功能。例如,流一旦关闭,就不能重新打开,Observable对象可以再次使用。举个例子:
Observable<Double> observable = Observable.range(1, 5)
.filter(i -> i % 2 == 0)
.doOnNext(System.out::println) //prints 2 and 4 twice
.map(Math::sqrt);
observable
.reduce((r, d) -> r + d)
.subscribe(System.out::println); //prints: 3.414213562373095
observable
.reduce((r, d) -> r + d)
.map(r -> r / 2)
.subscribe(System.out::println); //prints: 1.7071067811865475
在前面的示例中,您可以从注释中看到,doOnNext()操作被调用了两次,这意味着可观察对象发出了两次值,每个处理管道一次:

如果我们不想让Observable运行两次,我们可以通过添加cache()操作来缓存它的数据:
Observable<Double> observable = Observable.range(1,5)
.filter(i -> i % 2 == 0)
.doOnNext(System.out::println) //prints 2 and 4 only once
.map(Math::sqrt)
.cache();
observable
.reduce((r, d) -> r + d)
.subscribe(System.out::println); //prints: 3.414213562373095
observable
.reduce((r, d) -> r + d)
.map(r -> r / 2)
.subscribe(System.out::println); //prints: 1.7071067811865475
如您所见,相同的Observable的第二次使用利用了缓存的数据,因此允许更好的性能:

RxJava 提供了如此丰富的功能,我们无法在本书中详细地回顾它。相反,我们将尝试介绍最流行的 API。API 描述了可使用Observable对象调用的方法。此类方法通常也称为操作(与标准 Java8 流的情况一样)或操作符(主要用于反应流)。我们将使用这三个术语、方法、操作和运算符作为同义词
可观察对象的类型
谈到 RxJava2API(请注意,它与 RxJava1 有很大的不同),我们将使用可以在这个页面中找到的在线文档。
观察者订阅接收来自可观察对象的值,该对象可以表现为以下类型之一:
- 阻塞:等待结果返回
- 非阻塞:异步处理所发射的元素
- 冷:根据观察者的要求发射一个元素
- 热:无论观察者是否订阅,发射元素
可观察对象可以是io.reactivex 包的以下类别之一的对象:
Observable<T>:可以不发射、一个或多个元素;不支持背压。Flowable<T>:可以不发射、一个或多个元素;支持背压。Single<T>:可以发出一个元素或错误;背压的概念不适用。Maybe<T>:表示延迟计算;可以不发出值、一个值或错误;背压的概念不适用。Completable:表示没有任何值的延迟计算;表示任务完成或错误;背压的概念不适用。
这些类中的每一个的对象都可以表现为阻塞、非阻塞、冷或热可观察。它们的不同之处在于可以发出的值的数量、延迟返回结果的能力或仅返回任务完成标志的能力,以及它们处理背压的能力。
阻塞与非阻塞
为了演示这种行为,我们创建了一个可观察的对象,它发出五个连续整数,从1开始:
Observable<Integer> obs = Observable.range(1,5);
Observable的所有阻塞方法(操作符)都以“阻塞”开头,所以blockingLast()是阻塞操作符之一,阻塞管道直到最后一个元素被释放:
Double d2 = obs.filter(i -> i % 2 == 0)
.doOnNext(System.out::println) //prints 2 and 4
.map(Math::sqrt)
.delay(100, TimeUnit.MILLISECONDS)
.blockingLast();
System.out.println(d2); //prints: 2.0
在本例中,我们只选择偶数,打印所选元素,然后计算平方根并等待 100 毫秒(模拟长时间运行的计算)。此示例的结果如下所示:

相同功能的非阻塞版本如下所示:
List<Double> list = new ArrayList<>();
obs.filter(i -> i % 2 == 0)
.doOnNext(System.out::println) //prints 2 and 4
.map(Math::sqrt)
.delay(100, TimeUnit.MILLISECONDS)
.subscribe(d -> {
if(list.size() == 1){
list.remove(0);
}
list.add(d);
});
System.out.println(list); //prints: []
我们使用List对象来捕获结果,因为您可能还记得,Lambda 表达式不允许使用非final变量。
如您所见,结果列表为空。这是因为执行管道计算时没有阻塞(异步)。因此,由于 100 毫秒的延迟,控件同时转到打印列表内容的最后一行,该行仍然是空的。我们可以在最后一行前面设置延迟:
try {
TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(list); //prints: [2.0]
延迟必须至少为 200ms,因为管道处理两个元素,每个元素的延迟为 100ms。现在,如您所见,该列表包含一个期望值2.0
这基本上就是阻塞运算符和非阻塞运算符之间的区别。其他表示可观察对象的类也有类似的阻塞运算符。下面是阻塞Flowable、Single和Maybe的示例:
Flowable<Integer> obs = Flowable.range(1,5);
Double d2 = obs.filter(i -> i % 2 == 0)
.doOnNext(System.out::println) //prints 2 and 4
.map(Math::sqrt)
.delay(100, TimeUnit.MILLISECONDS)
.blockingLast();
System.out.println(d2); //prints: 2.0
Single<Integer> obs2 = Single.just(42);
int i2 = obs2.delay(100, TimeUnit.MILLISECONDS).blockingGet();
System.out.println(i2); //prints: 42
Maybe<Integer> obs3 = Maybe.just(42);
int i3 = obs3.delay(100, TimeUnit.MILLISECONDS).blockingGet();
System.out.println(i3); //prints: 42
Completable类具有允许设置超时的阻塞运算符:
(1) Completable obs = Completable.fromRunnable(() -> {
System.out.println("Running..."); //prints: Running...
try {
TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
(2) Throwable ex = obs.blockingGet();
(3) System.out.println(ex); //prints: null
//(4) ex = obs.blockingGet(15, TimeUnit.MILLISECONDS);
// java.util.concurrent.TimeoutException:
// The source did not signal an event for 15 milliseconds.
(5) ex = obs.blockingGet(150, TimeUnit.MILLISECONDS);
(6) System.out.println(ex); //prints: null
(7) obs.blockingAwait();
(8) obs.blockingAwait(15, TimeUnit.MILLISECONDS);
上述代码的结果显示在以下屏幕截图中:

第一条运行消息来自第 2 行,响应阻塞blockingGet()方法的调用。第一条空消息来自第 3 行。第 4 行抛出异常,因为超时设置为 15 毫秒,而实际处理设置为 100 毫秒延迟。第二条运行消息来自第 5 行,响应于blockingGet()方法调用。这一次,超时被设置为 150 毫秒,也就是超过 100 毫秒,并且该方法能够在超时结束之前返回。
最后两行(7 和 8)演示了有无超时的blockingAwait()方法的用法。此方法不返回值,但允许可观察管道运行其过程。有趣的是,即使将超时设置为小于管道完成所需时间的值,它也不会因异常而中断。显然,它是在管道处理完成之后开始等待的,除非它是一个稍后将被修复的缺陷(文档对此并不清楚)。
尽管存在阻塞操作(我们将在下面的章节中讨论每种可观察的类型时对这些操作进行更多的回顾),但是它们仅在不可能仅使用非阻塞操作实现所需功能的情况下使用。反应式编程的主要目的是努力以非阻塞方式异步处理所有请求
冷还是热
到目前为止,我们看到的所有示例都只演示了一个可观察的冷,即那些仅在处理前一个值已经被处理之后,才应处理管道的请求提供下一个值的示例。下面是另一个例子:
Observable<Long> cold = Observable.interval(10, TimeUnit.MILLISECONDS);
cold.subscribe(i -> System.out.println("First: " + i));
pauseMs(25);
cold.subscribe(i -> System.out.println("Second: " + i));
pauseMs(55);
我们已经使用方法interval()创建了一个Observable对象,该对象表示每个指定间隔(在我们的例子中,每 10ms)发出的序列号流。然后我们订阅创建的对象,等待 25ms,再次订阅,再等待 55ms,pauseMs()方法如下:
void pauseMs(long ms){
try {
TimeUnit.MILLISECONDS.sleep(ms);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
如果我们运行前面的示例,输出将如下所示:

正如您所看到的,每个管道都处理了冷可观察到的辐射的每个值。
为了将冷可观察物转换成热可观察物,我们使用publish()方法将可观察物转换成扩展Observable的ConnectableObservable对象:
ConnectableObservable<Long> hot =
Observable.interval(10, TimeUnit.MILLISECONDS).publish();
hot.connect();
hot.subscribe(i -> System.out.println("First: " + i));
pauseMs(25);
hot.subscribe(i -> System.out.println("Second: " + i));
pauseMs(55);
如您所见,我们必须调用connect()方法,以便ConnectableObservable对象开始发出值。输出如下所示:

输出显示第二个管道没有收到前三个值,因为它订阅了后面的可观察对象。因此,可观察物发出的值与观察者处理它们的能力无关。如果处理落后,并且新的值不断出现,而之前的值还没有完全处理完,Observable类将它们放入缓冲区。如果这个缓冲区足够大,JVM 可能会耗尽内存,因为正如我们已经提到的,Observable类没有背压管理的能力。
在这种情况下,Flowable类是可观察的更好的候选对象,因为它确实具有处理背压的能力。举个例子:
PublishProcessor<Integer> hot = PublishProcessor.create();
hot.observeOn(Schedulers.io(), true)
.subscribe(System.out::println, Throwable::printStackTrace);
for (int i = 0; i < 1_000_000; i++) {
hot.onNext(i);
}
PublishProcessor类扩展了Flowable,并有onNext(Object o)方法强制它发出传入的对象。在调用它之前,我们已经使用Schedulers.io()线程订阅了observate。我们将在“多线程(调度器)”部分讨论调度器。
subscribe()方法有几个重载版本。我们决定使用一个接受两个Consumer函数的函数:第一个处理传入的值,第二个处理由任何管道操作引发的异常(类似于Catch块)。
如果我们运行前面的示例,它将成功打印前 127 个值,然后抛出MissingBackpressureException,如下面的屏幕截图所示:

异常中的消息提供了一个线索:Could not emit value due to lack of requests。显然,这些值的发射率高于消耗率,内部缓冲区只能保存 128 个元素。如果我们增加延迟(模拟更长的处理时间),结果会更糟:
PublishProcessor<Integer> hot = PublishProcessor.create();
hot.observeOn(Schedulers.io(), true)
.delay(10, TimeUnit.MILLISECONDS)
.subscribe(System.out::println, Throwable::printStackTrace);
for (int i = 0; i < 1_000_000; i++) {
hot.onNext(i);
}
即使是前 128 个元素也无法通过,输出只有MissingBackpressureException
为了解决这个问题,必须制定背压策略。例如,让我们删除管道无法处理的每个值:
PublishProcessor<Integer> hot = PublishProcessor.create();
hot.onBackpressureDrop(v -> System.out.println("Dropped: "+ v))
.observeOn(Schedulers.io(), true)
.subscribe(System.out::println, Throwable::printStackTrace);
for (int i = 0; i < 1_000_000; i++) {
hot.onNext(i);
}
注意,策略必须在observeOn()操作之前设置,因此它将被创建的Schedulers.io()线程拾取。
输出显示许多发出的值被丢弃。下面是一个输出片段:

我们将在“操作符”一节中相应操作符的概述中讨论其他背压策略。
可处理对象
请注意,subscribe()方法实际上返回一个Disposable对象,可以查询该对象来检查管道处理是否已完成(并已处理):
Observable<Integer> obs = Observable.range(1,5);
List<Double> list = new ArrayList<>();
Disposable disposable =
obs.filter(i -> i % 2 == 0)
.doOnNext(System.out::println) //prints 2 and 4
.map(Math::sqrt)
.delay(100, TimeUnit.MILLISECONDS)
.subscribe(d -> {
if(list.size() == 1){
list.remove(0);
}
list.add(d);
});
System.out.println(list); //prints: []
System.out.println(disposable.isDisposed()); //prints: false
try {
TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(disposable.isDisposed()); //prints: true
System.out.println(list); //prints: [2.0]
还可以强制处理管道,从而有效地取消处理:
Observable<Integer> obs = Observable.range(1,5);
List<Double> list = new ArrayList<>();
Disposable disposable =
obs.filter(i -> i % 2 == 0)
.doOnNext(System.out::println) //prints 2 and 4
.map(Math::sqrt)
.delay(100, TimeUnit.MILLISECONDS)
.subscribe(d -> {
if(list.size() == 1){
list.remove(0);
}
list.add(d);
});
System.out.println(list); //prints: []
System.out.println(disposable.isDisposed()); //prints: false
disposable.dispose();
try {
TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(disposable.isDisposed()); //prints: true
System.out.println(list); //prints: []
如您所见,通过添加对disposable.dispose()的调用,我们已经停止了处理:列表内容,即使经过 200 毫秒的延迟,仍然是空的(前面示例的最后一行)。
这种强制处理方法可以用来确保没有失控的线程。每个创建的Disposable对象都可以按照finally块中释放资源的相同方式进行处理。CompositeDisposable类帮助以协调的方式处理多个Disposable对象。
当onComplete或onError事件发生时,管道自动处理。
例如,您可以使用add()方法,将新创建的Disposable对象添加到CompositeDisposable对象中。然后,必要时,可以对CompositeDisposable对象调用clear()方法。它将删除收集的Disposable对象,并对每个对象调用dispose()方法。
创建可观察对象
在我们的示例中,您已经看到了一些创建可观察对象的方法。在Observable、Flowable、Single、Maybe和Completable中还有许多其他工厂方法。但并不是所有下列方法都可以在这些接口中使用(参见注释;所有表示所有列出的接口都有它):
create():通过提供完整实现(所有)创建一个Observable对象defer():每次订阅一个新的Observer时创建一个新的Observable对象(所有)empty():创建一个空的Observable对象,该对象在订阅后立即完成(除Single外的所有对象)never():创建一个Observable对象,它不发射任何东西,也不做任何事情;甚至不完成(所有)error():创建一个Observable对象,该对象在订阅时立即发出异常(所有)fromXXX():创建一个Observable对象,其中XXX可以为Callable、Future(所有)、Iterable、Array、Publisher(Observable和Flowable、Action、Runnable(Maybe和Completable);这意味着它基于提供的函数或对象创建一个Observable对象generate():创建一个冷Observable对象,该对象基于提供的函数或对象生成值(仅限Observable和Flowable)range(), rangeLong(), interval(), intervalRange():创建一个Observable对象,该对象发出连续的int或long值,这些值受指定范围的限制或不受指定时间间隔的限制(仅限Observable和Flowable)just():根据提供的对象或一组对象(除Completable外的所有对象)创建一个Observable对象timer():创建一个Observable对象,该对象在指定的时间之后发出0L信号(所有),然后完成Observable和Flowable的操作
还有许多其他有用的方法,如repeat()、startWith()等。我们只是没有足够的空间来列出它们。参考在线文档。
让我们看一个create()方法用法的例子。Observable的create()方法如下:
public static Observable<T> create(ObservableOnSubscribe<T> source)
传入的对象必须是函数式接口ObservableOnSubscribe<T>的实现,它只有一个抽象方法subscribe():
void subscribe(ObservableEmitter<T> emitter)
ObservableEmitter<T>接口包含以下方法:
boolean isDisposed():如果处理管道被处理或发射器被终止,则返回trueObservableEmitter<T> serialize():提供基类Emitter中onNext()、onError()、onComplete()调用使用的序列化算法void setCancellable(Cancellable c):在这个发射器上设置Cancellable实现(只有一个方法cancel()的函数式接口)void setDisposable(Disposable d):在这个发射器上设置Disposable实现(有isDispose()和dispose()两种方法的接口)boolean tryOnError(Throwable t):处理错误条件,尝试发出提供的异常,如果不允许发出则返回false
为了创建一个可观察的接口,所有前面的接口可以实现如下:
ObservableOnSubscribe<String> source = emitter -> {
emitter.onNext("One");
emitter.onNext("Two");
emitter.onComplete();
};
Observable.create(source)
.filter(s -> s.contains("w"))
.subscribe(v -> System.out.println(v),
e -> e.printStackTrace(),
() -> System.out.println("Completed"));
pauseMs(100);
让我们更仔细地看一下前面的例子。我们创建了一个ObservableOnSubscribe函数source并实现了发射器:我们让发射器在第一次调用onNext()时发出一个,在第二次调用onNext()时发出两个,然后再调用onComplete()。我们将source函数传递到create()方法中,并构建管道来处理所有发出的值。
为了让它更有趣,我们添加了filter()操作符,它只允许进一步传播具有w字符的值。我们还选择了具有三个参数的subscribe()方法版本:函数Consumer onNext、Consumer onError和Action onComplete。第一个在每次到达方法的下一个值时调用,第二个在发出异常时调用,第三个在源发出onComplete()信号时调用。在创建管道之后,我们暂停了 100 毫秒,以便让异步进程有机会完成。结果如下:

如果我们从发射器实现中删除行emitter.onComplete(),则只会显示消息 2。
这些是如何使用create()方法的基础。如您所见,它允许完全定制。在实践中,它很少被使用,因为有许多更简单的方法来创建一个可观察的。我们将在以下几节中对它们进行回顾。
您将在本章其他部分的示例中看到其他工厂方法的示例。
运算符
在每一个可观察的接口中,Observable、Flowable、Single、Maybe或Completable都有成百上千(如果我们计算所有重载版本)的操作符可用
在Observable和Flowable中,方法的数量超过 500 个。这就是为什么在本节中,我们将提供一个概述和几个例子,帮助读者浏览可能的选项迷宫。
为了帮助了解全局,我们将所有操作符分为十类:转换、过滤、组合、从 XXX 转换、异常处理、生命周期事件处理、工具、条件和布尔、背压和可连接。
请注意,这些不是所有可用的运算符。您可以在在线文档中看到更多信息。
转化
这些运算符转换可观察对象发出的值:
buffer():根据提供的参数或使用提供的函数将发出的值收集到包裹中,并定期一次发出一个包裹flatMap():基于当前可观察对象生成可观察对象,并将其插入到当前流中,这是最流行的操作符之一groupBy():将当前Observable分为若干组可观察对象(GroupedObservables个对象)map():使用提供的函数转换发出的值scan():将所提供的函数应用于每个值,并结合先前将相同函数应用于先前值所产生的值window():发出一组类似于buffer()但作为可观察对象的值,每个值都发出原始可观察对象的一个子集,然后以onCompleted()结束
下面的代码演示了map()、flatMap()和groupBy()的用法:
Observable<String> obs = Observable.fromArray("one", "two");
obs.map(s -> s.contains("w") ? 1 : 0)
.forEach(System.out::print); //prints: 01
List<String> os = new ArrayList<>();
List<String> noto = new ArrayList<>();
obs.flatMap(s -> Observable.fromArray(s.split("")))
.groupBy(s -> "o".equals(s) ? "o" : "noto")
.subscribe(g -> g.subscribe(s -> {
if (g.getKey().equals("o")) {
os.add(s);
} else {
noto.add(s);
}
}));
System.out.println(os); //prints: [o, o]
System.out.println(noto); //prints: [n, e, t, w]
过滤
以下运算符(及其多个重载版本)选择哪些值将继续流经管道:
debounce():仅当指定的时间跨度已过而可观察到的对象未发出另一个值时才发出一个值distinct():仅选择唯一值elementAt(long n):只在流中指定的n位置发出一个值filter():仅发出符合指定条件的值firstElement():仅发射第一个值ignoreElements():不发数值,只有onComplete()信号通过lastElement():仅发出最后一个值sample():发出指定时间间隔内发出的最新值skip(long n):跳过第一个n值take(long n):只发出第一个n值
以下是刚刚列出的一些运算符的用法示例:
Observable<String> obs = Observable.just("onetwo")
.flatMap(s -> Observable.fromArray(s.split("")));
// obs emits "onetwo" as characters
obs.map(s -> {
if("t".equals(s)){
NonBlockingOperators.pauseMs(15);
}
return s;
})
.debounce(10, TimeUnit.MILLISECONDS)
.forEach(System.out::print); //prints: eo
obs.distinct().forEach(System.out::print); //prints: onetw
obs.elementAt(3).subscribe(System.out::println); //prints: t
obs.filter(s -> s.equals("o"))
.forEach(System.out::print); //prints: oo
obs.firstElement().subscribe(System.out::println); //prints: o
obs.ignoreElements().subscribe(() ->
System.out.println("Completed!")); //prints: Completed!
Observable.interval(5, TimeUnit.MILLISECONDS)
.sample(10, TimeUnit.MILLISECONDS)
.subscribe(v -> System.out.print(v + " ")); //prints: 1 3 4 6 8
pauseMs(50);
连接
以下运算符(及其多个重载版本)使用多个源可观察对象创建新的可观察对象:
concat(src1, src2):创建一个Observable发出src1的所有值,然后将src2的所有值全部释放出来combineLatest(src1, src2, combiner):创建一个Observable,该值由两个源中的任何一个发出,并使用提供的函数组合器将每个源发出的最新值组合起来join(src2, leftWin, rightWin, combiner):根据combiner函数,将leftWin和rightWin时间窗内两个可见光发射的值合并merge():将多个可观察对象合并为一个可观察对象,与concat()不同的是,它可以对多个可观察对象进行合并,而concat()从不对不同可观察对象的发射值进行合并startWith(T item):在从可观察源发出值之前,添加指定值startWith(Observable<T> other):在从源可观察对象发出值之前,将指定可观察对象的值相加switchOnNext(Observable<Observable> observables):创建一个新的Observable,该新的Observable发出指定可观察对象的最近发出的值zip():使用提供的函数组合指定的可观察对象
以下代码演示了其中一些运算符的使用:
Observable<String> obs1 = Observable.just("one")
.flatMap(s -> Observable.fromArray(s.split("")));
Observable<String> obs2 = Observable.just("two")
.flatMap(s -> Observable.fromArray(s.split("")));
Observable.concat(obs2, obs1, obs2)
.subscribe(System.out::print); //prints: twoonetwo
Observable.combineLatest(obs2, obs1, (x,y) -> "("+x+y+")")
.subscribe(System.out::print); //prints: (oo)(on)(oe)
System.out.println();
obs1.join(obs2, i -> Observable.timer(5, TimeUnit.MILLISECONDS),
i -> Observable.timer(5, TimeUnit.MILLISECONDS),
(x,y) -> "("+x+y+")").subscribe(System.out::print);
//prints: (ot)(nt)(et)(ow)(nw)(ew)(oo)(no)(eo)
Observable.merge(obs2, obs1, obs2)
.subscribe(System.out::print); //prints: twoonetwo
obs1.startWith("42")
.subscribe(System.out::print); //prints: 42one
Observable.zip(obs1, obs2, obs1, (x,y,z) -> "("+x+y+z+")")
.subscribe(System.out::print); //prints: (oto)(nwn)(eoe)
从XXX转换
这些运算符非常简单。以下是Observable类的从XXX转换操作符列表:
fromArray(T... items):从可变参数创建ObservablefromCallable(Callable<T> supplier):从Callable函数创建ObservablefromFuture(Future<T> future):从Future对象创建ObservablefromFuture(Future<T> future, long timeout, TimeUnit unit):从Future对象创建Observable,超时参数应用于futurefromFuture(Future<T> future, long timeout, TimeUnit unit, Scheduler scheduler):从Future对象创建Observable,超时参数应用于future和调度器(建议使用Schedulers.io(),请参阅“多线程(调度器)”部分)fromFuture(Future<T> future, Scheduler scheduler):从指定调度器上的Future对象创建一个Observable(Schedulers.io()推荐,请参阅“多线程(调度器)”部分)fromIterable(Iterable<T> source):从可迭代对象创建Observable(例如List)fromPublisher(Publisher<T> publisher):从Publisher对象创建Observable
异常处理
subscribe()操作符有一个重载版本,它接受处理管道中任何地方引发的异常的Consumer<Throwable>函数。它的工作原理类似于包罗万象的try-catch块。如果您将这个函数传递给subscribe()操作符,您可以确定这是所有异常结束的唯一地方。
但是,如果您需要在管道中间处理异常,以便值流可以由引发异常的操作符之后的其他操作符恢复和处理,那么以下操作符(及其多个重载版本)可以帮助您:
onErrorXXX():捕捉到异常时恢复提供的序列;XXX表示运算符的操作:onErrorResumeNext()、onErrorReturn()或onErrorReturnItem()retry():创建一个Observable,重复源发出的发射;如果调用onError(),则重新订阅源Observable
演示代码如下所示:
Observable<String> obs = Observable.just("one")
.flatMap(s -> Observable.fromArray(s.split("")));
Observable.error(new RuntimeException("MyException"))
.flatMap(x -> Observable.fromArray("two".split("")))
.subscribe(System.out::print,
e -> System.out.println(e.getMessage())//prints: MyException
);
Observable.error(new RuntimeException("MyException"))
.flatMap(y -> Observable.fromArray("two".split("")))
.onErrorResumeNext(obs)
.subscribe(System.out::print); //prints: one
Observable.error(new RuntimeException("MyException"))
.flatMap(z -> Observable.fromArray("two".split("")))
.onErrorReturnItem("42")
.subscribe(System.out::print); //prints: 42
生命周期事件处理
这些操作符在管道中任何位置发生的特定事件上都被调用。它们的工作方式类似于“处理”部分中描述的操作符。
这些操作符的格式是doXXX(),其中XXX是事件的名称:onComplete、onNext、onError等。并不是所有的类都有,有些类在Observable、Flowable、Single、Maybe或Completable上略有不同。但是,我们没有空间列出所有这些类的所有变体,我们的概述将局限于Observable类的生命周期事件处理操作符的几个示例:
doOnSubscribe(Consumer<Disposable> onSubscribe):当观察者订阅时执行doOnNext(Consumer<T> onNext):当源可观测调用onNext时,应用提供的Consumer功能doAfterNext(Consumer<T> onAfterNext):将提供的Consumer功能推送到下游后应用于当前值doOnEach(Consumer<Notification<T>> onNotification):对每个发出的值执行Consumer函数doOnEach(Observer<T> observer):为每个发出的值及其发出的终端事件通知一个ObserverdoOnComplete(Action onComplete):在源可观察对象生成onComplete事件后,执行提供的Action函数doOnDispose(Action onDispose):管道被下游处理后执行提供的Action功能doOnError(Consumer<Throwable> onError):发送onError事件时执行doOnLifecycle(Consumer<Disposable> onSubscribe, Action onDispose):对相应的事件调用相应的onSubscribe或onDispose函数doOnTerminate(Action onTerminate):当源可观测对象生成onComplete事件或引发异常(onError事件)时,执行提供的Action函数doAfterTerminate(Action onFinally):在源可观测对象生成onComplete事件或引发异常(onError事件)后,执行提供的Action函数doFinally(Action onFinally):在源可观测对象生成onComplete事件或引发异常(onError事件)或下游处理管道后,执行提供的Action函数
下面是演示代码:
Observable<String> obs = Observable.just("one")
.flatMap(s -> Observable.fromArray(s.split("")));
obs.doOnComplete(() -> System.out.println("Completed!"))
.subscribe(v -> {
System.out.println("Subscribe onComplete: " + v);
});
pauseMs(25);
如果我们运行此代码,输出将如下所示:

您还将在“多线程(调度器)”部分中看到这些运算符用法的其他示例。
公共操作
可以使用各种有用的操作符(及其多个重载版本)来控制管道行为:
delay():将发射延迟一段时间materialize():创建一个Observable,它表示发出的值和发送的通知dematerialize():反转materialize()运算符的结果observeOn():指定Observer应遵守Observable的Scheduler(螺纹)(见“多线程(调度器)”部分)serialize():强制序列化发出的值和通知subscribe():订阅一个可观测对象的发射和通知;各种重载版本接受用于各种事件的回调,包括onComplete、onError;只有在调用subscribe()之后,值才开始流经管道subscribeOn():使用指定的Scheduler异步订阅Observer到Observable(参见“多线程(调度器)”部分)timeInterval(), timestamp():将发出值的Observable<T>转换为Observable<Timed<T>>,然后相应地发出两次发射之间经过的时间量或时间戳timeout():重复源Observable的发射;如果在指定的时间段后没有发射,则生成错误using():创建一个与Observable一起自动处理的资源;工作方式类似于资源尝试构造
下面的代码包含一些在管道中使用的操作符的示例:
Observable<String> obs = Observable.just("one")
.flatMap(s -> Observable.fromArray(s.split("")));
obs.delay(5, TimeUnit.MILLISECONDS)
.subscribe(System.out::print); //prints: one
pauseMs(10);
System.out.println(); //used here just to break the line
Observable source = Observable.range(1,5);
Disposable disposable = source.subscribe();
Observable.using(
() -> disposable,
x -> source,
y -> System.out.println("Disposed: " + y) //prints: Disposed: DISPOSED
)
.delay(10, TimeUnit.MILLISECONDS)
.subscribe(System.out::print); //prints: 12345
pauseMs(25);
如果我们运行所有这些示例,输出将如下所示:

如您所见,管道完成后,会将处理后的信号发送给using操作符(第三个参数),因此我们作为第三个参数传递的Consumer函数可以处理管道使用的资源
条件与布尔
以下运算符(及其多个重载版本)允许求值一个或多个可观察对象或发射值,并相应地更改处理逻辑:
all(Predicate criteria):返回Single<Boolean>和true值,如果所有发出的值都符合提供的条件amb():接受两个或多个源可观察对象,并仅从第一个开始发射的源可观察对象发射值contains(Object value):如果被观察物发出所提供的值,则返回Single<Boolean>和truedefaultIfEmpty(T value):如果源Observable没有发出任何东西,则发出提供的值sequenceEqual():如果提供的源发出相同的序列,则返回Single<Boolean>和true;重载版本允许提供用于比较的相等函数skipUntil(Observable other):丢弃发出的值,直到提供的Observable other发出值为止skipWhile(Predicate condition):只要所提供的条件保持true,则丢弃发射值takeUntil(Observable other):在提供的Observable other发出值之后丢弃发出的值takeWhile(Predicate condition):在提供的条件变成false后丢弃发射值
此代码包含几个演示示例:
Observable<String> obs = Observable.just("one")
.flatMap(s -> Observable.fromArray(s.split("")));
Single<Boolean> cont = obs.contains("n");
System.out.println(cont.blockingGet()); //prints: true
obs.defaultIfEmpty("two")
.subscribe(System.out::print); //prints: one
Observable.empty().defaultIfEmpty("two")
.subscribe(System.out::print); //prints: two
Single<Boolean> equal = Observable.sequenceEqual(obs,
Observable.just("one"));
System.out.println(equal.blockingGet()); //prints: false
equal = Observable.sequenceEqual(Observable.just("one"),
Observable.just("one"));
System.out.println(equal.blockingGet()); //prints: true
equal = Observable.sequenceEqual(Observable.just("one"),
Observable.just("two"));
System.out.println(equal.blockingGet()); //prints: false
背压
我们在“冷与热”一节讨论并论证了背压效应和可能的下降策略。另一种策略如下:
Flowable<Double> obs = Flowable.fromArray(1.,2.,3.);
obs.onBackpressureBuffer().subscribe();
//or
obs.onBackpressureLatest().subscribe();
缓冲策略允许定义缓冲区大小,并提供在缓冲区溢出时可以执行的函数。最新的策略告诉值生产者暂停(当消费者不能及时处理发出的值时),并根据请求发出下一个值。
背压操作器仅在Flowable类中可用。
连接
此类运算符允许连接可观察对象,从而实现更精确控制的订阅动态:
publish():将Observable对象转换为ConnectableObservable对象replay():返回一个ConnectableObservable对象,该对象在每次订阅新的Observer时重复所有发出的值和通知connect():指示ConnectableObservable开始向订户发送值refCount():将ConnectableObservable转换为Observable
我们已经演示了ConnectableObservable如何在“冷与热”部分工作。ConnectiableObservable和Observable之间的一个主要区别是ConnectableObservable在调用其connect操作符之前不会开始发出值。
多线程(调度器)
默认情况下,RxJava 是单线程的。这意味着源可观测对象及其所有操作符都会通知调用了subscribe()操作符的同一线程上的观察者。
这里有两个操作符,observeOn()和subscribeOn(),允许将单个操作的执行移动到不同的线程。这些方法以一个Scheduler对象作为参数,该对象调度要在不同线程上执行的各个操作。
subscribeOn()操作符声明哪个调度器应该发出这些值。
observeOn()操作符声明哪个调度器应该观察和处理值。
Schedulers类包含工厂方法,这些方法创建具有不同生命周期和性能配置的Scheduler对象:
computation():基于有限的线程池创建一个调度器,其大小为可用处理器的数量;它应该用于 CPU 密集型计算;使用Runtime.getRuntime().availableProcessors()避免使用比可用处理器更多的此类调度器;否则,由于线程上下文切换的开销,性能可能会下降io():基于用于 I/O 相关工作的无边界线程池创建调度器,例如当与源的交互本质上是阻塞的时,通常使用文件和数据库;否则避免使用它,因为它可能会旋转太多线程,并对性能和内存使用产生负面影响newThread():每次创建一个新线程,不使用任何池;创建线程的成本很高,所以您应该知道使用它的原因single():创建一个基于单个线程的调度器,该线程按顺序执行所有任务;在执行顺序很重要时非常有用trampoline():创建以先进先出方式执行任务的调度器;用于执行递归算法from(Executor executor):根据提供的执行器(线程池)创建一个调度器,允许控制线程的最大数量及其生命周期。我们在第 8 章、“多线程和并发处理”中讨论了线程池。为了提醒您,以下是我们讨论过的池:
Executors.newCachedThreadPool();
Executors.newSingleThreadExecutor();
Executors.newFixedThreadPool(int nThreads);
Executors.newScheduledThreadPool(int poolSize);
Executors.newWorkStealingPool(int parallelism);
如您所见,Schedulers类的一些其他工厂方法由这些线程池中的一个提供支持,并充当线程池声明的一个更简单、更简短的表达式。为了使示例更简单和更具可比性,我们将只使用一个computation()调度器。让我们看看 RxJava 中并行/并发处理的基础知识。
以下代码是将 CPU 密集型计算委派给专用线程的示例:
Observable.fromArray("one","two","three")
.doAfterNext(s -> System.out.println("1: " +
Thread.currentThread().getName() + " => " + s))
.flatMap(w -> Observable.fromArray(w.split(""))
.observeOn(Schedulers.computation())
//.flatMap(s -> {
// CPU-intensive calculations go here
// }
.doAfterNext(s -> System.out.println("2: " +
Thread.currentThread().getName() + " => " + s))
)
.subscribe(s -> System.out.println("3: " + s));
pauseMs(100);
在本例中,我们决定从每个发出的单词创建一个子字符流,并让一个专用线程处理每个单词的字符。此示例的输出如下所示:

如您所见,主线程用于发出单词,每个单词的字符由专用线程处理。请注意,尽管在本例中,subscribe()操作的结果顺序与单词和字符发出的顺序相对应,但在实际情况中,每个值的计算时间将不相同,因此不能保证结果将以相同的顺序出现。
如果需要,我们也可以把每个单词放在一个专用的非主线程上,这样主线程就可以自由地做其他可以做的事情。例如,
Observable.fromArray("one","two","three")
.observeOn(Schedulers.computation())
.doAfterNext(s -> System.out.println("1: " +
Thread.currentThread().getName() + " => " + s))
.flatMap(w -> Observable.fromArray(w.split(""))
.observeOn(Schedulers.computation())
.doAfterNext(s -> System.out.println("2: " +
Thread.currentThread().getName() + " => " + s))
)
.subscribe(s -> System.out.println("3: " + s));
pauseMs(100);
该示例的输出如下:

如您所见,主线程不再发出单词。
在 RxJava2.0.5 中,引入了一种新的更简单的并行处理方法,类似于标准 Java8 流中的并行处理。使用ParallelFlowable可以实现如下相同的功能:
ParallelFlowable src =
Flowable.fromArray("one","two","three").parallel();
src.runOn(Schedulers.computation())
.doAfterNext(s -> System.out.println("1: " +
Thread.currentThread().getName() + " => " + s))
.flatMap(w -> Flowable.fromArray(((String)w).split("")))
.runOn(Schedulers.computation())
.doAfterNext(s -> System.out.println("2: " +
Thread.currentThread().getName() + " => " + s))
.sequential()
.subscribe(s -> System.out.println("3: " + s));
pauseMs(100);
如您所见,ParallelFlowable对象是通过将parallel()操作符应用于常规的Flowable而创建的。然后,runOn()操作符告诉创建的可观察对象使用computation()调度器来发送值。请注意,不再需要在flatMap()操作符中设置另一个调度器(用于处理字符)。它可以设置在它的外部-只是在主管道中,这使得代码更简单。结果如下:

至于subscribeOn()运算符,其在管道中的位置不起任何作用。不管它放在哪里,它仍然告诉可观察的调度器应该发出值。举个例子:
Observable.just("a", "b", "c")
.doAfterNext(s -> System.out.println("1: " +
Thread.currentThread().getName() + " => " + s))
.subscribeOn(Schedulers.computation())
.subscribe(s -> System.out.println("2: " +
Thread.currentThread().getName() + " => " + s));
pauseMs(100);
结果如下:

即使我们改变subscribeOn()操作符的位置,如下面的例子所示,结果也不会改变:
Observable.just("a", "b", "c")
.subscribeOn(Schedulers.computation())
.doAfterNext(s -> System.out.println("1: " +
Thread.currentThread().getName() + " => " + s))
.subscribe(s -> System.out.println("2: " +
Thread.currentThread().getName() + " => " + s));
pauseMs(100);
最后,这是两个运算符的示例:
Observable.just("a", "b", "c")
.subscribeOn(Schedulers.computation())
.doAfterNext(s -> System.out.println("1: " +
Thread.currentThread().getName() + " => " + s))
.observeOn(Schedulers.computation())
.subscribe(s -> System.out.println("2: " +
Thread.currentThread().getName() + " => " + s));
pauseMs(100);
结果现在显示使用了两个线程:一个用于订阅,另一个用于观察:

这就结束了我们对 RxJava 的简短概述,RxJava 是一个巨大的、仍在增长的库,有很多可能性,其中许多我们在本书中没有足够的篇幅来回顾。我们鼓励您尝试并学习它,因为反应式编程似乎是现代数据处理的发展方向。
总结
在本章中,读者了解了什么是反应式编程及其主要概念:异步、非阻塞、响应式等等。简单地介绍和解释了反应流,以及 RxJava 库,这是第一个支持反应编程原则的可靠实现。
在下一章中,我们将讨论微服务作为创建反应式系统的基础,并将回顾另一个成功支持反应式编程的库:我们将用它来演示如何构建各种微服务。
测验
-
选择所有正确的语句:
-
不使用线程池就可以使用
CompletableFuture吗? -
java.nio中的nio代表什么? -
event循环是唯一支持非阻塞 API 的设计吗? -
RxJava 中的
Rx代表什么? -
Java 类库(JCL)的哪个 Java 包支持反应流?
-
从以下列表中选择可以表示反应流中可观察到的所有类:
FlowableProbablyCompletableFutureSingle
-
您如何知道
Observable类的特定方法(运算符)是阻塞的? -
冷和热之间的区别是什么?
-
Observable的subscribe()方法返回Disposable对象。当对这个对象调用dispose()方法时会发生什么? -
选择创建
Observable对象的所有方法的名称: -
说出两个变换的
Observable操作符。 -
说出两个过滤
Observable操作符。 -
列举两种背压处理策略。
-
指定两个允许向管道处理添加线程的
Observable操作符。
十六、微服务
在本章中,您将了解什么是微服务,它们与其他架构样式的区别,以及现有的微服务框架如何支持消息驱动架构。我们还将帮助您决定微服务的大小,并讨论服务大小是否对将其标识为微服务起到任何作用。在本章结束时,您将了解如何构建微服务,并将它们用作创建反应式系统的基础组件。我们将通过使用 Vert.x 工具箱构建的小型反应式系统的详细代码演示来支持讨论。
本章将讨论以下主题:
- 什么是微服务?
- 微服务的大小
- 微服务之间的通信方式
- 微服务反应式系统的一个例子
什么是微服务?
随着处理负载的不断增加,解决这个问题的传统方法是添加更多具有相同部署的.ear或.war文件的服务器,然后将它们连接到一个集群中。这样,故障服务器可以自动替换为另一个服务器,系统性能不会下降。支持所有集群服务器的数据库通常也是集群的
然而,增加集群服务器的数量对于可伸缩性来说是一个过于粗粒度的解决方案,特别是当处理瓶颈仅局限于应用中运行的许多过程中的一个时。想象一下,一个特定的 CPU 或 I/O 密集型进程会降低整个应用的速度;添加另一个服务器只是为了缓解应用的一个部分的问题,可能会带来太多的开销
减少开销的一种方法是将应用分为三层:前端(或 Web 层)、中间层(或应用层)和后端(或后端层)。每一层都可以使用自己的服务器集群独立部署,这样每一层都可以水平增长并保持独立于其他层。这样的解决方案使得可伸缩性更加灵活;然而,与此同时,这使得部署过程复杂化,因为需要处理更多的可部署单元。
另一种保证每一层顺利部署的方法是一次在一台服务器上部署新代码,特别是在设计和实现新代码时考虑到了向后兼容性。这种方法对于前端和中间层都很好,但是对于后端可能没有那么顺利。此外,部署过程中的意外中断是由人为错误、代码缺陷、纯事故或所有这些因素的组合造成的,因此,很容易理解为什么很少有人期待在生产过程中部署一个主要版本。
然而,将应用划分为多个层可能仍然过于粗糙。在这种情况下,应用的一些关键部分,特别是那些需要比其他部分更大扩展性的部分,可以部署在它们自己的服务器集群中,并且只需向系统的其他部分提供服务。
事实上,面向服务架构(SOA)就是这样诞生的。当独立部署的服务不仅通过它们对可伸缩性的需求,而且通过它们中的代码更改的频率来确定时,增加可部署单元的数量所引起的复杂性被部分抵消。在设计过程中尽早识别这一点可以简化部署,因为与系统的其他部分相比,只有少数部分需要更频繁地更改和重新部署。不幸的是,预测未来的系统将如何演变并不容易。这就是为什么一个独立的部署单元通常被认为是一种预防措施,因为在设计阶段这样做比以后更容易。这反过来又导致可部署部队的规模不断缩小
不幸的是,维护和协调一个松散的服务系统是要付出代价的。每个参与者都必须负责维护其 API,不仅在形式上(比如名称和类型),而且在精神上:相同服务的新版本产生的结果在规模上必须相同。在类型上保持相同的值,然后在规模上使其变大或变小,这对于服务的客户来说可能是不可接受的。因此,尽管声明了独立性,但服务作者必须更清楚他们的客户是谁,他们的需求是什么
幸运的是,将应用拆分为可独立部署的单元带来了一些意想不到的好处,这些好处增加了将系统拆分为更小服务的动机。物理隔离允许在选择编程语言和实现平台时具有更大的灵活性。它还可以帮助您选择最适合该工作的技术,并聘请能够实现该技术的专家。这样,您就不必受为系统其他部分所做的技术选择的约束。这也有助于招聘人员在寻找必要人才时更加灵活,这是一个很大的优势,因为对工作的需求继续超过流入就业市场的专家。
每一个独立的部分(服务)都能够以自己的速度发展,并且变得更加复杂,只要与系统其他部分的契约没有改变或者以一种协调良好的方式引入。微服务就是这样产生的,后来被 Netflix、Google、Twitter、eBay、Amazon 和 Uber 等数据处理巨头投入使用。现在让我们谈谈这项努力的结果和经验教训。
微服务的大小
微服务必须有多小? 对于这个问题没有一个普遍的答案,一般共识与微服务的以下特征一致(没有特定顺序):
- 源代码的大小应该小于 SOA 架构中服务的大小。
- 一个开发团队应该能够支持多个微服务,团队的规模应该是两个比萨饼足够为整个团队提供午餐。
- 它必须是可部署的,并且独立于其他微服务,假设契约(即 API)没有变化。
- 每个微服务都必须有自己的数据库(或模式,或至少是一组表)–尽管这是一个有争议的话题,特别是在多个微服务能够修改同一个数据集的情况下;如果同一个团队维护所有这些数据集,那么在同时修改同一数据时更容易避免冲突。
- 它必须是无状态且幂等的;如果微服务的一个实例失败了,那么另一个实例应该能够完成失败的微服务所期望的。
- 它应该提供一种检查其健康状况的方法,证明服务已启动并正在运行,拥有所有必要的资源,并且准备好执行该工作
在设计过程、开发和部署后需要考虑资源共享,并在从不同过程访问同一资源时对干扰程度(例如阻塞)假设的验证进行监控。在修改同一持久性数据的过程中也需要特别小心,无论是在数据库、模式之间共享,还是在同一模式中的表之间共享。如果最终的一致性是可接受的(这通常是用于统计目的的较大数据集的情况),则需要采取特殊措施。但是,对事务完整性的需求常常带来一个难题。
支持跨多个微服务的事务的一种方法是创建一个充当分布式事务管理器(DTM)角色的服务。通过这种方式,其他服务可以将数据修改请求传递给它。DTM 服务可以将并发修改的数据保存在自己的数据库表中,只有在数据变得一致后,才能在一个事务中将结果移动到目标表中。例如,只有当相应的金额被另一个微服务添加到分类账时,一个微服务才能将钱添加到一个账户。
如果访问数据所花费的时间是一个问题,或者如果您需要保护数据库不受过多并发连接的影响,那么将数据库专用于微服务可能是一个解决方案。或者,内存缓存可能是一种方法。添加一个提供对缓存的访问的服务会增加服务的隔离,但是需要在管理同一缓存的对等方之间进行同步(这有时很困难)。
在回顾了所有列出的要点和可能的解决方案之后,每个微服务的大小应该取决于这些考虑的结果,而不是作为强加给所有服务的大小的空白声明。这有助于避免毫无成效的讨论,并产生一个适合于解决特定项目及其需求的结果
微服务之间的通信方式
目前有十多种框架用于构建微服务。最流行的两种是 SpringBoot和 MicroFile,其目标是优化基于微服务架构的企业 Java。轻量级开源微服务框架,KumuluzEE 符合 MicroFile。
以下是其他框架的列表(按字母顺序排列):
- Akka:这是一个为 Java 和 Scala 构建高度并发、分布式和弹性的消息驱动应用的工具箱(
akka.io。 - Bootique:这是一个针对可运行 Java 应用的最低限度的固执己见的框架(
bootique.io。 - Dropwizard:这是一个 Java 框架,用于开发操作友好、高性能和 RESTful Web 服务(www.dropwizard.io)。
- Jodd:这是一套 1.7MB 以下的 Java 微框架、工具和工具(jodd.org 网站)。
- Lightbend Lagom:这是一个基于 Akka 和 Play(的固执己见的微服务框架 www.lightbend.com)。
- Ninja:这是一个 Java 的全栈框架。
- Spotify-Apollo:这是 Spotify 用来编写微服务的一组 Java 库(Spotify/Apollo)。
- Vert.x:这是一个在 JVM(
vertx.io上构建反应式应用的工具箱。
所有这些框架都支持微服务之间基于 REST 的通信;其中一些还具有发送消息的附加方式
为了演示与传统通信方法相比的替代方法,我们将使用 Vert.x,它是一个事件驱动的非阻塞轻量级多语言工具包。它允许您用 Java、JavaScript、Groovy、Ruby、Scala、Kotlin 和 Ceylon 编写组件。它支持一个异步编程模型和一个分布式事件总线,可以访问浏览器内的 JavaScript,从而允许创建实时 Web 应用。但是,由于本书的重点,我们将只使用 Java。
Vert.xAPI 有两个源代码树:第一个源代码树以io.vertx.core开头,第二个源代码树以io.vertx.rxjava.core开头。第二个源树是io.vertx.core类的反应版本。事实上,无功源树是基于非无功源的,所以这两个源树并不是不兼容的。相反,除了非反应式实现还提供了反应式版本。因为我们的讨论集中在反应式编程上,所以我们将主要使用io.vertx.rxjava源代码树的类和接口,也称为 RX 化的 Vert.x API。
首先,我们将向pom.xml文件添加以下依赖项,如下所示:
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web</artifactId>
<version>3.6.3</version>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-rx-java</artifactId>
<version>3.6.3</version>
</dependency>
实现io.vertx.core.Verticle接口的类作为基于 Vert.x 的应用的构建块。io.vertx.core.Verticle接口有四个抽象方法:
Vertx getVertx();
void init(Vertx var1, Context var2);
void start(Future<Void> var1) throws Exception;
void stop(Future<Void> var1) throws Exception;
为了使编码在实践中更容易,有一个抽象的io.vertx.rxjava.core.AbstractVerticle类实现了所有的方法,但是它们是空的,不做任何事情。它允许通过扩展AbstractVerticle类并只实现应用所需的Verticle接口的那些方法来创建垂直体。在大多数情况下,仅仅实现start()方法就足够了。
Vert.x 有自己的系统,通过事件总线交换消息(或事件)。通过使用io.vertx.rxjava.core.eventBus.EventBus类的rxSend(String address, Object msg)方法,任何垂直体都可以向任何地址发送消息(只是一个字符串):
Single<Message<String>> reply = vertx.eventBus().rxSend(address, msg);
vertx对象(它是AbstractVerticle的受保护属性,可用于每个垂直方向)允许访问事件总线和rxSend()调用方法。Single<Message<String>>返回值表示响应消息可以返回的回复;您可以订阅它,或者以任何其他方式处理它。
Verticle 还可以注册为特定地址的消息接收器(使用者):
vertx.eventBus().consumer(address);
如果多个 Verticle 注册为同一地址的消费者,那么rxSend()方法使用循环算法只将消息传递给这些消费者中的一个。
或者,publish()方法可用于向使用相同地址注册的所有消费者传递消息:
EventBus eb = vertx.eventBus().publish(address, msg);
返回的对象是EventBus对象,它允许您在必要时添加其他EventBus方法调用。
如您所记得的,消息驱动异步处理是由微服务组成的反应式系统的弹性、响应性和弹性的基础。因此,在下一节中,我们将演示如何构建一个既使用基于 REST 的通信又使用基于 Vert.xEventBus的消息的反应式系统。
微服务的反应式系统
为了演示如果使用 Vert.x 实现,微服务的反应式系统会是什么样子,我们将创建一个 HTTP 服务器,它可以接受系统中基于 REST 的请求,向另一个 Verticle 发送基于EventBus的消息,接收回复,并将响应发送回原始请求。
为了演示这一切是如何工作的,我们还将编写一个程序,向系统生成 HTTP 请求,并允许您从外部测试系统。
HTTP 服务器
让我们假设进入反应式系统演示的入口点是一个 HTTP 调用。这意味着我们需要创建一个充当 HTTP 服务器的 Verticle。Vert.x 使这变得非常简单;下面垂直线中的三行就可以做到这一点:
HttpServer server = vertx.createHttpServer();
server.requestStream().toObservable()
.subscribe(request -> request.response()
.setStatusCode(200)
.end("Hello from " + name + "!\n")
);
server.rxListen(port).subscribe();
如您所见,创建的服务器监听指定的端口,并用 Hello…响应每个传入的请求。默认情况下,主机名为localhost。如有必要,可以使用相同方法的重载版本为主机指定另一个地址:
server.rxListen(port, hostname).subscribe();
下面是我们创建的垂直体的完整代码:
package com.packt.learnjava.ch16_microservices;
import io.vertx.core.Future;
import io.vertx.rxjava.core.AbstractVerticle;
import io.vertx.rxjava.core.http.HttpServer;
public class HttpServerVert extends AbstractVerticle {
private int port;
public HttpServerVert(int port) { this.port = port; }
public void start(Future<Void> startFuture) {
String name = this.getClass().getSimpleName() +
"(" + Thread.currentThread().getName() +
", localhost:" + port + ")";
HttpServer server = vertx.createHttpServer();
server.requestStream().toObservable()
.subscribe(request -> request.response()
.setStatusCode(200)
.end("Hello from " + name + "!\n")
);
server.rxListen(port).subscribe();
System.out.println(name + " is waiting...");
}
}
我们可以使用以下代码部署此服务器:
Vertx vertx = Vertx.vertx();
RxHelper.deployVerticle(vertx, new HttpServerVert(8082));
结果如下:

请注意,…is waiting…消息会立即出现,甚至在任何请求传入之前也会出现–这是此服务器的异步特性。name前缀被构造成包含类名、线程名、主机名和端口。注意,线程名称告诉我们服务器监听事件循环线程0。
现在我们可以使用curl命令向部署的服务器发出请求,响应如下:

如您所见,我们已经发出了 HTTPGET(默认)请求,并用预期的名称返回了预期的 Hello…消息
以下代码是start()方法的更现实版本:
Router router = Router.router(vertx);
router.get("/some/path/:name/:address/:anotherParam")
.handler(this::processGetSomePath);
router.post("/some/path/send")
.handler(this::processPostSomePathSend);
router.post("/some/path/publish")
.handler(this::processPostSomePathPublish);
vertx.createHttpServer()
.requestHandler(router::handle)
.rxListen(port)
.subscribe();
System.out.println(name + " is waiting...");
现在我们使用Router类,根据 HTTP 方法(GET或POST和路径向不同的处理器发送请求。它要求您向pom.xml文件添加以下依赖项:
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web</artifactId>
<version>3.6.3</version>
</dependency>
第一条路由为/some/path/:name/:address/:anotherParam路径,包含三个参数(name、address、anotherParam)。HTTP 请求在RoutingContext对象内传递给以下处理器:
private void processGetSomePath(RoutingContext ctx){
ctx.response()
.setStatusCode(200)
.end("Got into processGetSomePath using " +
ctx.normalisedPath() + "\n");
}
处理器只返回一个 HTTP 代码200和一个硬编码消息,该消息设置在 HTTP 响应对象上,并由response()方法返回。在幕后,HTTP 响应对象来自 HTTP 请求。为了清晰起见,我们已经使处理器的第一个实现变得简单。稍后,我们将以更现实的方式重新实现它们。
第二条路径为/some/path/send路径,处理器如下:
private void processPostSomePathSend(RoutingContext ctx){
ctx.response()
.setStatusCode(200)
.end("Got into processPostSomePathSend using " +
ctx.normalisedPath() + "\n");
第三条路径为/some/path/publish路径,处理器如下:
private void processPostSomePathPublish(RoutingContext ctx){
ctx.response()
.setStatusCode(200)
.end("Got into processPostSomePathPublish using " +
ctx.normalisedPath() + "\n");
}
如果我们再次部署服务器并发出 HTTP 请求以命中每个路由,我们将看到以下屏幕截图:

前面的屏幕截图说明了我们向第一个 HTTPGET请求发送了预期的消息,但在响应第二个 HTTPGET请求时未找到接收到的资源。这是因为我们的服务器中没有 HTTPGET请求的/some/path/send路由。然后,我们切换到 HTTPPOST请求,并接收两个POST请求的预期消息。
从路径名可以猜到我们将使用/some/path/send路由发送EventBus消息,使用/some/path/publish路由发布EventBus消息,但是在实现相应的路由处理器之前,我们先创建一个接收EventBus消息的垂直体。
EventBus消息接收器
消息接收器的实现非常简单:
vertx.eventBus()
.consumer(address)
.toObservable()
.subscribe(msgObj -> {
String body = msgObj.body().toString();
String msg = name + " got message '" + body + "'.";
System.out.println(msg);
String reply = msg + " Thank you.";
msgObj.reply(reply);
}, Throwable::printStackTrace );
可以通过vertx对象访问EventBus对象。EventBus类的consumer(address)方法允许您设置与此消息接收器关联的地址并返回MessageConsumer<Object>。然后我们将这个对象转换成Observable并订阅它,等待异步接收消息。subscribe()方法有几个重载版本。我们选择了一个接受两个函数的函数:第一个函数为每个发出的值(在我们的例子中,为每个接收到的消息)调用;第二个函数在管道中的任何地方抛出异常时调用(即,它的行为类似于包罗万象的try...catch块)。MessageConsumer<Object>类表示,原则上消息可以由任何类的对象表示。如您所见,我们决定发送一个字符串,所以我们将消息体转换为String。MessageConsumer<Object>类还有一个reply(Object)方法,允许您将消息发送回发送者。
消息接收垂直的完整实现如下:
package com.packt.learnjava.ch16_microservices;
import io.vertx.core.Future;
import io.vertx.rxjava.core.AbstractVerticle;
public class MessageRcvVert extends AbstractVerticle {
private String id, address;
public MessageRcvVert(String id, String address) {
this.id = id;
this.address = address;
}
public void start(Future<Void> startFuture) {
String name = this.getClass().getSimpleName() +
"(" + Thread.currentThread().getName() +
", " + id + ", " + address + ")";
vertx.eventBus()
.consumer(address)
.toObservable()
.subscribe(msgObj -> {
String body = msgObj.body().toString();
String msg = name + " got message '" + body + "'.";
System.out.println(msg);
String reply = msg + " Thank you.";
msgObj.reply(reply);
}, Throwable::printStackTrace );
System.out.println(name + " is waiting...");
}
}
我们可以用部署HttpServerVert垂直的方式部署此眩晕:
String address = "One";
Vertx vertx = Vertx.vertx();
RxHelper.deployVerticle(vertx, new MessageRcvVert("1", address));
如果运行此代码,将显示以下消息:

如您所见,到达并执行了MessageRcvVert的最后一行,而创建的管道和我们传递给它的操作符的函数正在等待消息的发送。所以,我们现在就开始吧。
EventBus消息发送器
正如我们所承诺的,我们现在将以更现实的方式重新实现HttpServerVert垂直面的处理器。GET方法处理器现在看起来像以下代码块:
private void processGetSomePath(RoutingContext ctx){
String caller = ctx.pathParam("name");
String address = ctx.pathParam("address");
String value = ctx.pathParam("anotherParam");
System.out.println("\n" + name + ": " + caller + " called.");
vertx.eventBus()
.rxSend(address, caller + " called with value " + value)
.toObservable()
.subscribe(reply -> {
System.out.println(name +
": got message\n " + reply.body());
ctx.response()
.setStatusCode(200)
.end(reply.body().toString() + "\n");
}, Throwable::printStackTrace);
}
如您所见,RoutingContext类提供了pathParam``()方法,该方法从路径中提取参数(如果它们被标记为:,如我们的示例所示)。然后,我们再次使用EventBus对象向作为参数提供的地址异步发送消息。subscribe()方法使用提供的函数来处理来自消息接收器的应答,并将应答发送回原始请求到 HTTP 服务器。
现在让我们部署两个垂直点,HttpServerVert和MessageRcvVert垂直点:
String address = "One";
Vertx vertx = Vertx.vertx();
RxHelper.deployVerticle(vertx, new MessageRcvVert("1", address));
RxHelper.deployVerticle(vertx, new HttpServerVert(8082));
运行上述代码时,屏幕显示以下消息:

请注意,每个 Verticle 都在自己的线程上运行。现在我们可以使用curl命令提交 HTTPGET请求,结果如下:

这就是如何从我们的演示系统之外看待交互。在内部,我们还可以看到以下消息,这些消息允许我们跟踪我们的眩晕是如何相互作用和发送消息的:

结果与预期完全一致。
现在,/some/path/send路径的处理器如下:
private void processPostSomePathSend(RoutingContext ctx){
ctx.request().bodyHandler(buffer -> {
System.out.println("\n" + name + ": got payload\n " + buffer);
JsonObject payload = new JsonObject(buffer.toString());
String caller = payload.getString("name");
String address = payload.getString("address");
String value = payload.getString("anotherParam");
vertx.eventBus()
.rxSend(address, caller + " called with value " + value)
.toObservable()
.subscribe(reply -> {
System.out.println(name +
": got message\n " + reply.body());
ctx.response()
.setStatusCode(200)
.end(reply.body().toString() + "\n");
}, Throwable::printStackTrace);
});
}
对于 HTTPPOST请求,我们希望发送 JSON 格式的有效负载,其值与我们作为 HTTPGET请求的参数发送的值相同。该方法的其余部分与processGetSomePath()实现非常相似。让我们再次部署HttpServerVert和MessageRcvVert Verticles,然后用有效负载发出 HTTPPOST请求,结果如下:

这看起来与设计的 HTTPGET请求的结果一模一样。在后端,将显示以下消息:

这些消息中也没有什么新内容,只是显示了 JSON 格式。
最后,我们来看一下/some/path/publish路径的 HTTPPOST请求的处理器:
private void processPostSomePathPublish(RoutingContext ctx){
ctx.request().bodyHandler(buffer -> {
System.out.println("\n" + name + ": got payload\n " + buffer);
JsonObject payload = new JsonObject(buffer.toString());
String caller = payload.getString("name");
String address = payload.getString("address");
String value = payload.getString("anotherParam");
vertx.eventBus()
.publish(address, caller + " called with value " + value);
ctx.response()
.setStatusCode(202)
.end("The message was published to address " +
address + ".\n");
});
}
这一次,我们使用了publish()方法来发送消息。请注意,此方法无法接收答复。这是因为,正如我们已经提到的,publish()方法将消息发送给所有注册到此地址的接收器。如果我们使用/some/path/publish路径发出一个 HTTPPOST请求,结果看起来略有不同:

此外,后端上的消息看起来也不同:

所有这些差异都与服务器无法获得回复这一事实有关,即使接收方发送回复的方式与响应由rxSend()方法发送的消息的方式完全相同。
在下一节中,我们将部署几个发送者和接收器的实例,并通过rxSend()和publish()方法检查消息分布之间的差异。
反应式系统演示
现在,让我们使用上一节中创建的 Verticles 来组装和部署一个小型反应式系统:
package com.packt.learnjava.ch16_microservices;
import io.vertx.rxjava.core.RxHelper;
import io.vertx.rxjava.core.Vertx;
public class ReactiveSystemDemo {
public static void main(String... args) {
String address = "One";
Vertx vertx = Vertx.vertx();
RxHelper.deployVerticle(vertx, new MessageRcvVert("1", address));
RxHelper.deployVerticle(vertx, new MessageRcvVert("2", address));
RxHelper.deployVerticle(vertx, new MessageRcvVert("3", "Two"));
RxHelper.deployVerticle(vertx, new HttpServerVert(8082));
}
}
如您所见,我们将部署两个使用相同的One地址接收消息的 Verticle 和一个使用Two地址的 Verticle。如果我们运行上述程序,屏幕将显示以下消息:

现在开始向系统发送 HTTP 请求。首先,我们发送三次相同的 HTTPGET请求:

如前所述,如果有多个注册在同一地址的垂直站点,rxSend()方法使用循环算法来选择应该接收下一条消息的垂直站点。第一个请求通过ID="1"发送给接收器,第二个请求通过ID="2"发送给接收器,第三个请求再次通过ID="1"发送给接收器。
我们使用对/some/path/send路径的 HTTPPOST请求得到相同的结果:

同样,使用循环算法旋转消息的接收器。
现在,让我们向系统发布两次消息:

由于接收方的回复无法传播回系统用户,因此我们需要查看登录到后端的消息:

如您所见,publish()方法将消息发送到注册到指定地址的所有 Verticle。注意,带有ID="3"(注册为Two地址)的 Verticle 从未收到消息。
在我们结束这个被动系统演示之前,值得一提的是,Vert.x 允许您轻松地对 Verticle 进行集群。您可以在 Vert.x 文档中阅读此功能。
总结
本章向读者介绍了微服务的概念,以及如何使用微服务创建反应式系统。我们讨论了应用大小的重要性,以及它如何影响您将其转换为微服务的决策。您还了解了现有的微服务框架如何支持消息驱动架构,并有机会在实践中使用其中的一个工具 Vert.x 工具包。
在下一章中,我们将探讨 Java 微基准线束(JMH)项目,它允许您测量代码性能和其他参数。我们将定义什么是 JMH,如何创建和运行基准,基准参数是什么,以及支持的 IDE 插件。
测验
-
选择所有正确的语句:
-
微服务能比一些单一应用更大吗?
-
微服务如何相互通信?
-
列举两个为支持微服务而创建的框架。
-
Vert.x 中微服务的主要构建块是什么?
-
Vert.x 中的
send和publish事件总线消息有什么区别? -
事件总线的
send方法如何决定在 Vert.x 中发送消息的接收器? -
Vert.x verticles 可以集群吗?
-
在哪里可以找到有关 Vert.x 的更多信息?
十七、Java 微基准线束
在本章中,读者将介绍一个允许测量各种代码性能特征的 Java 微基准线束(JMH)项目。如果性能是应用的一个重要问题,那么这个工具可以帮助您识别瓶颈,精确到方法级别。使用它,读者不仅能够测量代码的平均执行时间和其他性能值(例如吞吐量),而且能够以一种受控的方式进行测量,不管是否有 JVM 优化、预热运行等等。
除了理论知识,读者还将有机会使用实际的演示示例和建议来运行 JMH。
本章将讨论以下主题:
- 什么是 JMH?
- 创建 JMH 基准
- 运行基准测试
- 使用 IDE 插件
- JMH 基准参数
- JMH 使用示例
什么是 JMH?
根据字典,基准是一个标准或参照点,可以对事物进行比较或评估。在编程中,它是比较应用性能的一种方法,或者只是比较方法。微基准关注的是后者较小的代码片段,而不是整个应用。JMH 是衡量单个方法性能的框架。
这似乎非常有用。我们能不能不只是在一个循环中运行一个方法一千次或十万次,测量它所用的时间,然后计算方法性能的平均值?我们可以。问题是 JVM 是一个比代码执行机器复杂得多的程序。它的优化算法专注于使应用代码尽可能快地运行。
例如,让我们看看下面的类:
class SomeClass {
public int someMethod(int m, int s) {
int res = 0;
for(int i = 0; i < m; i++){
int n = i * i;
if (n != 0 && n % s == 0) {
res =+ n;
}
}
return res;
}
}
我们用代码填充了someMethod()方法,这些代码没有多大意义,但使方法保持忙碌。要测试此方法的性能,很有可能将代码复制到某个测试方法中并在循环中运行:
public void testCode() {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
int xN = 100_000;
int m = 1000;
for(int x = 0; i < xN; x++) {
int res = 0;
for(int i = 0; i < m; i++){
int n = i * i;
if (n != 0 && n % 250_000 == 0) {
res += n;
}
}
}
System.out.println("Average time = " +
(stopWatch.getTime() / xN /m) + "ms");
}
但是,JVM 将看到从未使用过res结果,并将计算限定为死代码(从未执行的代码部分)。那么,为什么还要执行这些代码呢?
您可能会惊讶地发现,算法的显著复杂性或简化并不影响性能。这是因为,在每种情况下,代码都不是实际执行的
您可以更改测试方法,并通过返回它来假装使用了结果:
public int testCode() {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
int xN = 100_000;
int m = 1000;
int res = 0;
for(int x = 0; i < xN; x++) {
for(int i = 0; i < m; i++){
int n = i * i;
if (n != 0 && n % 250_000 == 0) {
res += n;
}
}
}
System.out.println("Average time = " +
(stopWatch.getTime() / xN / m) + "ms");
return res;
}
这可能会说服 JVM 每次都执行代码,但不能保证。JVM 可能会注意到,输入到计算中的数据并没有改变,这个算法每次运行都会产生相同的结果。因为代码是基于常量输入的,所以这种优化称为常量折叠。此优化的结果是,此代码可能只执行一次,并且每次运行都假定相同的结果,而不实际执行代码。
但实际上,基准测试通常是围绕一个方法而不是一块代码构建的。例如,测试代码可能如下所示:
public void testCode() {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
int xN = 100_000;
int m = 1000;
SomeClass someClass = new SomeClass();
for(int x = 0; i < xN; x++) {
someClass.someMethod(m, 250_000);
}
System.out.println("Average time = " +
(stopWatch.getTime() / xN / m) + "ms");
}
但即使是这段代码也容易受到我们刚才描述的 JVM 优化的影响。
JMH 的创建是为了帮助避免这种情况和类似的陷阱。在“JMH 用法示例”部分,我们将向您展示如何使用 JMH 来解决死代码和常量折叠优化问题,使用@State注解和Blackhole对象。
此外,JMH 不仅可以测量平均执行时间,还可以测量吞吐量和其他性能特性。
创建 JMH 基准
要开始使用 JMH,必须将以下依赖项添加到pom.xml文件中:
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.21</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.21</version>
</dependency>
第二个.jar文件annprocess的名称提示 JMH 使用注解。如果你这么猜的话,你是对的。以下是为测试算法性能而创建的基准的示例:
public class BenchmarkDemo {
public static void main(String... args) throws Exception{
org.openjdk.jmh.Main.main(args);
}
@Benchmark
public void testTheMethod() {
int res = 0;
for(int i = 0; i < 1000; i++){
int n = i * i;
if (n != 0 && n % 250_000 == 0) {
res += n;
}
}
}
}
请注意@Benchmark注解。它告诉框架必须测量此方法的性能。如果您运行前面的main()方法,您将看到类似于以下内容的输出:

这只是包含不同条件下的多次迭代的广泛输出的一部分,目的是避免或抵消 JVM 优化。它还考虑了一次运行代码和多次运行代码之间的差异。在后一种情况下,JVM 开始使用即时编译器,它将经常使用的字节码编译成本地二进制代码,甚至不读取字节码。预热周期就是为了达到这个目的而执行的,代码在没有测量其性能的情况下就被作为一个空运行来执行,这个空运行会使 JVM 升温。
还有一些方法可以告诉 JVM 编译哪个方法并直接作为二进制文件使用,每次编译哪个方法,以及提供类似的指令来禁用某些优化。我们将很快讨论这个问题。
现在让我们看看如何运行基准测试。
运行基准测试
正如您可能已经猜测的,运行基准的一种方法就是执行main()方法。可以直接使用java命令或使用 IDE 完成。我们在第 1 章、“从 Java12 开始”中讨论了它。然而,有一种更简单、更方便的方法来运行基准:使用 IDE 插件。
使用 IDE 插件
所有主要的支持 Java 的 IDE 都有这样一个插件。我们将演示如何使用安装在 MacOS 计算机上的 IntelliJ 插件,但它同样适用于 Windows 系统。
以下是要遵循的步骤:
- 要开始安装插件,请同时按
Cmd键和逗号(,),或者只需单击顶部水平菜单中的扳手符号(带有悬停文本首选项):

- 它将在左窗格中打开一个包含以下菜单的窗口:

- 选择“插件”,如前面的屏幕截图所示,并观察具有以下顶部水平菜单的窗口:

- 选择“市场”,在“市场”输入框的“搜索插件”中输入
JMH,然后按Enter。如果您有互联网连接,它将显示一个 JMH 插件符号,类似于此屏幕截图中显示的符号:

- 单击“安装”按钮,然后在它变为“重新启动 IDE”后,再次单击它:

- IDE 重新启动后,插件就可以使用了。现在,您不仅可以运行
main()方法,而且如果您有几个带有@Benchmark注解的方法,还可以选择要执行的基准测试方法。要执行此操作,请从“运行”下拉菜单中选择“运行…”:

- 它将弹出一个窗口,其中包含可运行的方法选择:

- 选择一个你想运行的,它将被执行。至少运行一次方法后,只需右键单击它并从弹出菜单中执行它:

- 也可以使用每个菜单项右侧显示的快捷方式。
现在让我们回顾一下可以传递给基准的参数。
JMH 基准参数
有许多基准参数允许为手头任务的特定需要微调度量。我们只介绍主要的。
模式
第一组参数定义了特定基准必须测量的性能方面(模式):
Mode.AverageTime:测量平均执行时间Mode.Throughput:通过在迭代中调用基准方法来测量吞吐量Mode.SampleTime:采样执行时间,而不是平均执行时间;允许我们推断分布、百分位数等Mode.SingleShotTime:测量单个方法调用时间;允许在不连续调用基准方法的情况下测试冷启动
这些参数可以在注解@BenchmarkMode中指定。例如:
@BenchmarkMode(Mode.AverageTime)
可以组合多种模式:
@BenchmarkMode({Mode.Throughput, Mode.AverageTime, Mode.SampleTime, Mode.SingleShotTime}
也可以要求所有人:
@BenchmarkMode(Mode.All)
所描述的参数以及我们将在本章后面讨论的所有参数都可以在方法和/或类级别进行设置。方法级别集值覆盖类级别值。
输出时间单位
用于呈现结果的时间单位可以使用@OutputTimeUnit注解指定:
@OutputTimeUnit(TimeUnit.NANOSECONDS)
可能的时间单位来自java.util.concurrent.TimeUnit枚举。
迭代
另一组参数定义了用于预热和测量的迭代。例如:
@Warmup(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS)
分叉
在运行多个测试时,@Fork注解允许您将每个测试设置为在单独的进程中运行。例如:
@Fork(10)
传入的参数值指示 JVM 可以分叉到独立进程的次数。默认值为-1,如果在测试中使用多个实现同一接口的类,并且这些类相互影响,那么如果没有它,测试的性能可能是混合的。
warmups参数是另一个参数,可以设置为指示基准必须执行多少次而不收集测量值:
@Fork(value = 10, warmups = 5)
它还允许您向java命令行添加 Java 选项。例如:
@Fork(value = 10, jvmArgs = {"-Xms2G", "-Xmx2G"})
JMH 参数的完整列表以及如何使用它们的示例可以在openjdk项目中找到。例如,我们没有提到@Group、@GroupThreads、@Measurement、@Setup、@Threads、@Timeout、@TearDown或@Warmup。
JMH 使用示例
现在让我们运行一些测试并比较它们。首先,我们运行以下测试方法:
@Benchmark
@BenchmarkMode(Mode.All)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public void testTheMethod0() {
int res = 0;
for(int i = 0; i < 1000; i++){
int n = i * i;
if (n != 0 && n % 250_000 == 0) {
res += n;
}
}
}
如您所见,我们要求度量所有性能特征,并在呈现结果时使用纳秒。在我们的系统上,测试执行大约需要 20 分钟,最终结果摘要如下所示:

现在我们将测试更改为:
@Benchmark
@BenchmarkMode(Mode.All)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public void testTheMethod1() {
SomeClass someClass = new SomeClass();
int i = 1000;
int s = 250_000;
someClass.someMethod(i, s);
}
如果我们现在运行testTheMethod1(),结果会略有不同:

在采样和单次运行方面,结果相差较大。你可以玩这些方法,并改变分叉和数量的热身
使用@State注解
这个 JMH 特性允许您对 JVM 隐藏数据源,从而防止死代码优化。您可以添加一个类作为输入数据的源,如下所示:
@State(Scope.Thread)
public static class TestState {
public int m = 1000;
public int s = 250_000;
}
@Benchmark
@BenchmarkMode(Mode.All)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public int testTheMethod3(TestState state) {
SomeClass someClass = new SomeClass();
return someClass.someMethod(state.m, state.s);
}
Scope值用于在测试之间共享数据。在我们的例子中,只有一个测试使用了TestCase类对象,我们不需要共享。否则,该值可以设置为Scope.Group或Scope.Benchmark,这意味着我们可以在TestState类中添加设置器,并在其他测试中读取/修改它。
当我们运行此版本的测试时,得到以下结果:

数据又变了。注意,平均执行时间增加了三倍,这表明没有应用更多的 JVM 优化。
使用黑洞对象
这个 JMH 特性允许模拟结果使用情况,从而防止 JVM 进行优化:
@Benchmark
@BenchmarkMode(Mode.All)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public void testTheMethod4(TestState state, Blackhole blackhole) {
SomeClass someClass = new SomeClass();
blackhole.consume(someClass.someMethod(state.m, state.s));
}
如您所见,我们刚刚添加了一个参数Blackhole对象,并在其上调用了consume()方法,从而假装使用了测试方法的结果。
当我们运行此版本的测试时,得到以下结果:

这一次,结果看起来没什么不同。显然,即使在添加Blackhole用法之前,恒定折叠优化也被中和了
使用@CompilerControl注解
调整基准测试的另一种方法是告诉编译器编译、内联(或不内联)和从代码中排除(或不排除)特定方法。例如,考虑以下类:
class SomeClass{
public int oneMethod(int m, int s) {
int res = 0;
for(int i = 0; i < m; i++){
int n = i * i;
if (n != 0 && n % s == 0) {
res = anotherMethod(res, n);
}
}
return res;
}
@CompilerControl(CompilerControl.Mode.EXCLUDE)
private int anotherMethod(int res, int n){
return res +=n;
}
}
假设我们对方法anotherMethod()编译/内联如何影响性能感兴趣,我们可以将其CompilerControl模式设置为:
Mode.INLINE:强制此方法内联Mode.DONT_INLINE:为了避免这种方法内联Mode.EXCLUDE:为了避免这种方法编译
使用@Param注解
有时,有必要对不同的输入数据集运行相同的基准测试。在这种情况下,@Param注解非常有用。
@Param is a standard Java annotation used by various frameworks, for example, JUnit. It identifies an array of parameter values. The test with the @Param annotation will be run as many times as there are values in the array. Each test execution picks up a different value from the array.
举个例子:
@State(Scope.Benchmark)
public static class TestState1 {
@Param({"100", "1000", "10000"})
public int m;
public int s = 250_000;
}
@Benchmark
@BenchmarkMode(Mode.All)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public void testTheMethod6(TestState1 state, Blackhole blackhole) {
SomeClass someClass = new SomeClass();
blackhole.consume(someClass.someMethod(state.m, state.s));
}
testTheMethod6()基准将与参数m的每个列出的值一起使用。
一句警告
所描述的工具消除了程序员度量性能的大部分顾虑。然而,几乎不可能涵盖 JVM 优化、概要文件共享和 JVM 实现的类似方面的所有情况,特别是如果我们考虑到 JVM 代码在不同的实现之间不断发展和不同的话。JMH 的作者通过打印以下警告以及测试结果来确认这一事实:

剖面仪的说明及其用法见openjdk项目。在相同的示例中,您将看到 JMH 基于注解生成的代码的描述。
如果您想深入了解代码执行和测试的细节,没有比研究生成的代码更好的方法了。它描述了 JMH 为运行所请求的基准测试所做的所有步骤和决策。您可以在target/generated-sources/annotations中找到生成的代码。
这本书的范围不允许进入如何阅读它的太多细节,但它不是很难,特别是如果你从一个简单的案例开始只测试一个方法。我们祝你在这一努力中一切顺利。
总结
在本章中,读者了解了 JMH 工具,并能够将其用于特定的实际案例,类似于他们在编程应用时遇到的那些案例。读者已经学习了如何创建和运行基准,如何设置基准参数,以及如何在需要时安装 IDE 插件。我们也提供了实用的建议和参考资料供进一步阅读。
在下一章中,读者将介绍设计和编写应用代码的有用实践。我们将讨论 Java 习惯用法、它们的实现和用法,并提供实现equals()、hashCode()、compareTo()和clone()方法的建议。我们还将讨论StringBuffer和StringBuilder类的用法之间的区别、如何捕获异常、最佳设计实践以及其他经验证的编程实践。
测验
-
选择所有正确的语句:
-
列出开始使用 JMH 所需的两个步骤。
-
说出运行 JMH 的四种方法。
-
列出两种可以与 JMH 一起使用(测量)的模式(性能特征)。
-
列出两个可用于显示 JMH 测试结果的时间单位。
-
如何在 JMH 基准之间共享数据(结果、状态)?
-
如何告诉 JMH 使用枚举的值列表为参数运行基准测试?
-
如何强制或关闭方法的编译?
-
如何关闭 JVM 的常量折叠优化?
-
如何以编程方式为运行特定基准测试提供 Java 命令选项?
十八、编写高质量代码的最佳实践
当程序员相互交谈时,他们经常使用非程序员无法理解的术语,或者不同编程语言的程序员模糊理解的术语。但是那些使用相同编程语言的人彼此理解得很好。有时也可能取决于程序员的知识水平。一个新手可能不明白一个有经验的程序员在说什么,而一个有经验的同事则点头以示回应
在本章中,读者将了解一些 Java 编程术语,即描述某些特性、功能、设计解决方案等的 Java 习惯用法。读者还将学习设计和编写应用代码的最流行和最有用的实践。在本章结束时,读者将对其他 Java 程序员在讨论他们的设计决策和使用的功能时所谈论的内容有一个坚实的理解。
本章将讨论以下主题:
- Java 习惯用法及其实现和用法
equals()、hashCode()、compareTo()和clone()方法StringBuffer和StringBuilder类try、catch、finally条款- 最佳设计实践
- 代码是为人编写的
- 测试:通往高质量代码的最短路径
Java 习惯用法及其实现和用法
除了服务于专业人员之间的交流方式之外,编程习惯用法也是经过验证的编程解决方案和常用实践,它们不是直接从语言规范中派生出来的,而是从编程经验中产生的,我们将讨论最常用的习惯用法,您可以在 Java 官方文档中找到并研究完整的习惯用法列表。
equals()和hashCode()方法
java.lang.Object类中equals()和hashCode()方法的默认实现如下:
public boolean equals(Object obj) {
return (this == obj);
}
/**
* Whenever it is invoked on the same object more than once during
* an execution of a Java application, the hashCode method
* must consistently return the same integer...
* As far as is reasonably practical, the hashCode method defined
* by class Object returns distinct integers for distinct objects.
*/
@HotSpotIntrinsicCandidate
public native int hashCode();
如您所见,equals()方法的默认实现只比较指向存储对象的地址的内存引用。类似地,您可以从注释(引用自源代码)中看到,hashCode()方法为同一个对象返回相同的整数,为不同的对象返回不同的整数。让我们用Person类来演示它:
public class Person {
private int age;
private String firstName, lastName;
public Person(int age, String firstName, String lastName) {
this.age = age;
this.lastName = lastName;
this.firstName = firstName;
}
public int getAge() { return age; }
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
}
下面是默认的equals()和hashCode()方法的行为示例:
Person person1 = new Person(42, "Nick", "Samoylov");
Person person2 = person1;
Person person3 = new Person(42, "Nick", "Samoylov");
System.out.println(person1.equals(person2)); //prints: true
System.out.println(person1.equals(person3)); //prints: false
System.out.println(person1.hashCode()); //prints: 777874839
System.out.println(person2.hashCode()); //prints: 777874839
System.out.println(person3.hashCode()); //prints: 596512129
person1和person2引用及其哈希码是相等的,因为它们指向相同的对象(内存的相同区域和相同的地址),而person3引用指向另一个对象。
但实际上,正如我们在第 6 章、“数据结构、泛型和流行工具”中所描述的,我们希望对象的相等性基于所有或部分对象属性的值,因此这里是equals()和hashCode()方法的典型实现:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null) return false;
if(!(o instanceof Person)) return false;
Person person = (Person)o;
return getAge() == person.getAge() &&
Objects.equals(getFirstName(), person.getFirstName()) &&
Objects.equals(getLastName(), person.getLastName());
}
@Override
public int hashCode() {
return Objects.hash(getAge(), getFirstName(), getLastName());
}
它以前更复杂,但是使用java.util.Objects工具会更容易,特别是当您注意到Objects.equals()方法也处理null时。
我们已经将所描述的equals()和hashCode()方法的实现添加到Person1类中,并执行了相同的比较:
Person1 person1 = new Person1(42, "Nick", "Samoylov");
Person1 person2 = person1;
Person1 person3 = new Person1(42, "Nick", "Samoylov");
System.out.println(person1.equals(person2)); //prints: true
System.out.println(person1.equals(person3)); //prints: true
System.out.println(person1.hashCode()); //prints: 2115012528
System.out.println(person2.hashCode()); //prints: 2115012528
System.out.println(person3.hashCode()); //prints: 2115012528
如您所见,我们所做的更改不仅使相同的对象相等,而且使具有相同属性值的两个不同对象相等。此外,哈希码值现在也基于相同属性的值。
在第 6 章、“数据结构、泛型和流行工具”中,我们解释了在实现equals()方法的同时实现hasCode()方法的重要性。
在equals()方法中建立等式和在hashCode()方法中进行散列计算时,使用完全相同的属性集是非常重要的。
将@Override注解放在这些方法前面可以确保它们确实覆盖Object类中的默认实现。否则,方法名中的输入错误可能会造成一种假象,即新的实现被使用了,而实际上它并没有被使用。事实证明,调试这种情况比仅仅添加@Override注解要困难和昂贵得多,如果该方法不覆盖任何内容,就会产生错误。
compareTo()方法
在第 6 章、“数据结构、泛型和流行工具”中,我们广泛使用了compareTo()方法(Comparable接口的唯一方法),并指出基于该方法建立的顺序(通过集合元素实现)称为自然顺序。
为了证明这一点,我们创建了Person2类:
public class Person2 implements Comparable<Person2> {
private int age;
private String firstName, lastName;
public Person2(int age, String firstName, String lastName) {
this.age = age;
this.lastName = lastName;
this.firstName = firstName;
}
public int getAge() { return age; }
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
@Override
public int compareTo(Person2 p) {
int result = Objects.compare(getFirstName(),
p.getFirstName(), Comparator.naturalOrder());
if (result != 0) {
return result;
}
result = Objects.compare(getLastName(),
p.getLastName(), Comparator.naturalOrder());
if (result != 0) {
return result;
}
return Objects.compare(age, p.getAge(),
Comparator.naturalOrder());
}
@Override
public String toString() {
return firstName + " " + lastName + ", " + age;
}
}
然后我们组成一个Person2对象列表并对其进行排序:
Person2 p1 = new Person2(15, "Zoe", "Adams");
Person2 p2 = new Person2(25, "Nick", "Brook");
Person2 p3 = new Person2(42, "Nick", "Samoylov");
Person2 p4 = new Person2(50, "Ada", "Valentino");
Person2 p6 = new Person2(50, "Bob", "Avalon");
Person2 p5 = new Person2(10, "Zoe", "Adams");
List<Person2> list = new ArrayList<>(List.of(p5, p2, p6, p1, p4, p3));
Collections.sort(list);
list.stream().forEach(System.out::println);
结果如下:

有三件事值得注意:
- 根据
Comparable接口,compareTo()方法必须返回负整数、零或正整数,因为对象小于、等于或大于另一个对象。在我们的实现中,如果两个对象的相同属性的值不同,我们会立即返回结果。我们已经知道这个对象是大或小,不管其他属性是什么。但是比较两个对象的属性的顺序对最终结果有影响。它定义属性值影响顺序的优先级。 - 我们已将
List.of()的结果放入new ArrayList()对象中。我们这样做是因为,正如我们在第 6 章、“数据结构、泛型和流行工具”中已经提到的,工厂方法of()创建的集合是不可修改的。不能在其中添加或删除任何元素,也不能更改元素的顺序,同时需要对创建的集合进行排序。我们使用了of()方法,只是因为它更方便并且提供了更短的表示法 - 最后,使用
java.util.Objects进行属性比较,使得实现比定制编码更简单、更可靠。
在实现compareTo()方法时,重要的是确保不违反以下规则:
- 只有当返回值为
0时,obj1.compareTo(obj2)才返回与obj2.compareTo(obj1)相同的值。 - 如果返回值不是
0,则obj1.compareTo(obj2)与obj2.compareTo(obj1)符号相反。 - 如果
obj1.compareTo(obj2) > 0和obj2.compareTo(obj3) > 0,那么obj1.compareTo(obj3) > 0。 - 如果
obj1.compareTo(obj2) < 0和obj2.compareTo(obj3) < 0,那么obj1.compareTo(obj3) < 0。 - 若
obj1.compareTo(obj2) == 0,则obj2.compareTo(obj3)与obj1.compareTo(obj3) > 0符号相同。 obj1.compareTo(obj2)和obj2.compareTo(obj1)抛出相同的异常(如果有的话)。
也建议,但并非总是要求,如果obj1.equals(obj2)那么obj1.compareTo(obj2) == 0,同时,如果obj1.compareTo(obj2) == 0那么obj1.equals(obj2)。
clone()方法
java.lang.Object类中的clone()方法实现如下:
@HotSpotIntrinsicCandidate
protected native Object clone() throws CloneNotSupportedException;
注释指出:
/**
* Creates and returns a copy of this object. The precise meaning
* of "copy" may depend on the class of the object.
***
此方法的默认结果按原样返回对象字段的副本,如果值是原始类型,则可以这样做。但是,如果对象属性包含对另一个对象的引用,则只复制引用本身,而不复制引用的对象本身。这就是为什么这种拷贝被称为浅拷贝。要获得一个深度副本,必须覆盖clone()方法并克隆引用对象的每个对象属性
在任何情况下,为了能够克隆一个对象,它必须实现Cloneable接口,并确保继承树上的所有对象(以及作为对象的属性)也实现Cloneable接口(除了java.lang.Object类)。Cloneable接口只是一个标记接口,它告诉编译器程序员有意识地决定允许克隆这个对象(无论是因为浅拷贝足够好还是因为clone()方法被覆盖)。试图对未实现Cloneable接口的对象调用clone()将导致CloneNotSupportedException。
这看起来已经很复杂了,但实际上,还有更多的陷阱。例如,假设Person类具有Address类类型的address属性。Person对象p1的浅拷贝p2将引用Address的同一对象,因此p1.address == p2.address。下面是一个例子。Address类如下:
class Address {
private String street, city;
public Address(String street, String city) {
this.street = street;
this.city = city;
}
public void setStreet(String street) { this.street = street; }
public String getStreet() { return street; }
public String getCity() { return city; }
}
Person3类这样使用它:
class Person3 implements Cloneable{
private int age;
private Address address;
private String firstName, lastName;
public Person3(int age, String firstName,
String lastName, Address address) {
this.age = age;
this.address = address;
this.lastName = lastName;
this.firstName = firstName;
}
public int getAge() { return age; }
public Address getAddress() { return address; }
public String getLastName() { return lastName; }
public String getFirstName() { return firstName; }
@Override
public Person3 clone() throws CloneNotSupportedException{
return (Person3) super.clone();
}
}
请注意,方法clone执行浅层复制,因为它不克隆address属性。下面是使用这种方法实现的结果:
Person3 p1 = new Person3(42, "Nick", "Samoylov",
new Address("25 Main Street", "Denver"));
Person3 p2 = p1.clone();
System.out.println(p1.getAge() == p2.getAge()); // true
System.out.println(p1.getLastName() == p2.getLastName()); // true
System.out.println(p1.getLastName().equals(p2.getLastName())); // true
System.out.println(p1.getAddress() == p2.getAddress()); // true
System.out.println(p2.getAddress().getStreet()); //prints: 25 Main Street
p1.getAddress().setStreet("42 Dead End");
System.out.println(p2.getAddress().getStreet()); //prints: 42 Dead End
如您所见,克隆完成后,对源对象的address属性所做的更改将反映在克隆的相同属性中。这不是很直观,是吗?克隆的时候我们希望有独立的拷贝,不是吗?
为了避免共享Address对象,还需要显式地克隆它。为了做到这一点,必须使Address对象可克隆,如下所示:
public class Address implements Cloneable{
private String street, city;
public Address(String street, String city) {
this.street = street;
this.city = city;
}
public void setStreet(String street) { this.street = street; }
public String getStreet() { return street; }
public String getCity() { return city; }
@Override
public Address clone() throws CloneNotSupportedException {
return (Address)super.clone();
}
}
有了这个实现,我们现在可以添加address属性克隆:
class Person4 implements Cloneable{
private int age;
private Address address;
private String firstName, lastName;
public Person4(int age, String firstName,
String lastName, Address address) {
this.age = age;
this.address = address;
this.lastName = lastName;
this.firstName = firstName;
}
public int getAge() { return age; }
public Address getAddress() { return address; }
public String getLastName() { return lastName; }
public String getFirstName() { return firstName; }
@Override
public Person4 clone() throws CloneNotSupportedException{
Person4 cl = (Person4) super.clone();
cl.address = this.address.clone();
return cl;
}
}
现在,如果我们运行相同的测试,结果将与我们最初预期的一样:
Person4 p1 = new Person4(42, "Nick", "Samoylov",
new Address("25 Main Street", "Denver"));
Person4 p2 = p1.clone();
System.out.println(p1.getAge() == p2.getAge()); // true
System.out.println(p1.getLastName() == p2.getLastName()); // true
System.out.println(p1.getLastName().equals(p2.getLastName())); // true
System.out.println(p1.getAddress() == p2.getAddress()); // false
System.out.println(p2.getAddress().getStreet()); //prints: 25 Main Street
p1.getAddress().setStreet("42 Dead End");
System.out.println(p2.getAddress().getStreet()); //prints: 25 Main Street
因此,如果应用希望深度复制所有属性,那么所有涉及的对象都必须是可克隆的。只要没有相关的对象,无论是当前对象中的属性还是父类中的属性(以及它们的属性和父对象),在不使它们可克隆的情况下不获取新的对象属性,并且在容器对象的clone()方法中显式克隆,这是可以的。最后一句话很复杂。其复杂性的原因是克隆过程的潜在复杂性。这就是为什么程序员经常远离使对象可克隆的原因。
相反,如果需要,他们更喜欢手动克隆对象。例如:
Person4 p1 = new Person4(42, "Nick", "Samoylov",
new Address("25 Main Street", "Denver"));
Address address = new Address(p1.getAddress().getStreet(),
p1.getAddress().getCity());
Person4 p2 = new Person4(p1.getAge(), p1.getFirstName(),
p1.getLastName(), address);
System.out.println(p1.getAge() == p2.getAge()); // true
System.out.println(p1.getLastName() == p2.getLastName()); // true
System.out.println(p1.getLastName().equals(p2.getLastName())); // true
System.out.println(p1.getAddress() == p2.getAddress()); // false
System.out.println(p2.getAddress().getStreet()); //prints: 25 Main Street
p1.getAddress().setStreet("42 Dead End");
System.out.println(p2.getAddress().getStreet()); //prints: 25 Main Street
如果向任何相关对象添加了另一个属性,这种方法仍然需要更改代码。但是,它提供了对结果的更多控制,并且发生意外后果的可能性更小。
幸运的是,clone()方法并不经常使用。事实上,你可能永远不会遇到使用它的需要。
StringBuffer和StringBuilder类
我们在第 6 章、“数据结构、泛型和流行工具”中讨论了StringBuffer和StringBuilder类之间的区别。我们这里不重复了。相反,我们只会提到,在单线程进程(这是绝大多数情况下)中,StringBuilder类是首选,因为它更快。
try-catch-finally
本书包含第 4 章、“处理”,专门介绍try、catch、finally子句的用法,这里不再赘述。我们只想再次重申,使用资源尝试语句是释放资源的首选方法(传统上是在finally块中完成的)。遵从库使代码更简单、更可靠。
最佳设计实践
术语最佳通常是主观的和上下文相关的。这就是为什么我们要披露以下建议是基于主流节目中的绝大多数案例。但是,不应盲目和无条件地遵循这些原则,因为在某些情况下,有些做法是无用的,甚至是错误的。在跟随他们之前,试着理解他们背后的动机,并将其作为你的决策指南。例如,大小很重要。如果应用不会超过几千行代码,那么一个简单的带有洗衣单样式代码的整体就足够了。但是,如果有复杂的代码包和几个人在处理它,如果一个特定的代码区域需要比其他区域更多的资源,那么将代码分解成专门的片段将有利于代码理解、维护甚至扩展。
我们将从没有特定顺序的更高层次的设计决策开始。
确定松散耦合的功能区域
这些设计决策可以很早就做出,仅仅是基于对未来系统的主要部分、它们的功能以及它们产生和交换的数据的一般理解。这样做有几个好处:
- 对未来系统结构的识别,对进一步的设计步骤和实现有影响
- 部件的专业化和深入分析
- 部件并行开发
- 更好地理解数据流
将功能区划分为传统层
在每个功能区就绪后,可以根据所使用的技术方面和技术进行特化。技术专业化的传统分离是:
- 前端(用户图形或 Web 界面)
- 具有广泛业务逻辑的中间层
- 后端(数据存储或数据源)
这样做的好处包括:
- 按层部署和扩展
- 基于专业知识的程序员专业化
- 部件并行开发
面向接口编程
基于前两小节中描述的决策的专用部分必须在隐藏实现细节的接口中描述。这种设计的好处在于面向对象编程的基础,在第 2 章、“Java 面向对象编程(OOP)”中有详细的描述,所以这里不再重复。
使用工厂
我们在第二章“Java 面向对象编程(OOP)”中也谈到了这一点。根据定义,接口不描述也不能描述实现接口的类的构造器。使用工厂可以缩小这个差距,只向客户端公开一个接口
优先组合而不是继承
最初,面向对象编程的重点是继承,作为在对象之间共享公共功能的方式。继承甚至是我们在第 2 章、“Java 面向对象编程(OOP)”中所描述的四个面向对象编程原则之一。然而,实际上,这种功能共享方法在同一继承行中包含的类之间创建了太多的依赖关系。应用功能的演化通常是不可预测的,继承链中的一些类开始获取与类链的原始目的无关的功能。我们可以说,有一些设计解决方案允许我们不这样做,并保持原始类完好无损。但是,在实践中,这样的事情总是发生,子类可能会突然改变行为,仅仅因为它们通过继承获得了新的功能。我们不能选择我们的父项,对吗?此外,封装方式是封装的另一个基础原则。
另一方面,组合允许我们选择和控制类的哪些功能可以使用,哪些可以忽略。它还允许对象保持轻,而不受继承的负担。这样的设计更灵活、更可扩展、更可预测。
使用库
在整本书中,我们多次提到使用 Java 类库(JCL)、Java 开发工具包(JDK)和外部 Java 库可以使编程变得更简单,并生成更高质量的代码。甚至还有一个专门的章节,第 7 章、“Java 标准和外部库”,其中概述了最流行的 Java 库。创建库的人会投入大量的时间和精力,所以你应该随时利用他们。
在第 13 章、“函数式编程”中,我们描述了驻留在 JCL 的java.util.function包中的标准函数式接口。这是另一种利用库的方法,使用一组众所周知的共享接口,而不是定义自己的接口。
这最后一句话是本章下一个主题的一个很好的过渡,这个主题是关于编写其他人容易理解的代码。
代码是为人编写的
最初几十年的编程需要编写机器命令,以便电子设备能够执行这些命令。这不仅是一项繁琐且容易出错的工作,而且还要求您以产生最佳性能的方式编写指令,因为计算机速度很慢,而且根本没有进行太多代码优化。
从那时起,我们在硬件和编程方面都取得了很大的进步。现代编译器在使提交的代码尽可能快地工作方面走了很长的路,即使程序员没有考虑它。我们在上一章第 1 章第 7 章“Java 微基准线束”中用具体的例子进行了讨论
它允许程序员编写更多的代码行,而不用考虑太多优化问题。但是传统和许多关于编程的书籍仍然需要它,一些程序员仍然担心他们的代码性能,而不是它产生的结果。遵循传统比脱离传统容易。这就是为什么程序员往往更关注他们编写代码的方式,而不是他们自动化的业务,尽管实现错误业务逻辑的好代码是无用的。
不过,回到话题上来。在现代 JVM 中,程序员对代码优化的需求不像以前那么迫切了。如今,程序员必须主要关注全局,以避免导致代码性能差和代码被多次使用的结构性错误。当 JVM 变得更复杂时,后者就变得不那么紧迫了,实时地观察代码,当用相同的输入多次调用相同的代码块时,只返回结果(不执行)。
这给我们留下了唯一可能的结论:在编写代码时,我们必须确保它对人类来说是容易阅读和理解的,而不是对计算机来说。那些在这个行业工作了一段时间的人对几年前自己编写的代码感到困惑。一种是通过清晰和透明的意图来改进代码编写风格。
我们可以讨论注释的必要性,直到奶牛回到谷仓。我们绝对不需要注释来直接响应代码的功能。例如:
//Initialize variable
int i = 0;
解释意图的注释更有价值:
// In case of x > 0, we are looking for a matching group
// because we would like to reuse the data from the account.
// If we find the matching group, we either cancel it and clone,
// or add the x value to the group, or bail out.
// If we do not find the matching group,
// we create a new group using data of the matched group.
注释代码可能非常复杂。好的注释解释了意图并提供了帮助我们理解代码的指导。然而,程序员通常不会费心去写注释。反对写注释的论据通常包括两种:
- 注释必须与代码一起维护和发展,否则,它们可能会产生误导,但是没有工具可以提示程序员在更改代码的同时调整注释。因此,注释是危险的。
- 代码本身的编写(包括变量和方法的名称选择)不需要额外的解释。
这两种说法都是正确的,但注释也确实非常有用,尤其是那些抓住意图的注释。此外,这样的注释往往需要较少的调整,因为代码意图不会经常更改(如果有的话)。
测试是获得高质量代码的最短路径
我们将讨论的最后一个最佳实践是这样的陈述:测试不是一项开销或一项负担;它是程序员成功的指南。唯一的问题是什么时候写测试
有一个令人信服的论点,要求在编写任何一行代码之前编写一个测试。如果你能做到,那就太好了。我们不会劝你放弃的。但是,如果您不这样做,请尝试在编写完一行或所有被指定编写的代码之后开始编写测试。
实际上,许多经验丰富的程序员发现在实现了一些新功能之后开始编写测试代码是很有帮助的,因为这是程序员更好地理解新代码如何适合现有上下文的时候。他们甚至可能尝试对一些值进行编码,以查看新代码与调用新方法的代码集成的程度。在确保新代码集成良好之后,程序员可以继续实现和调优新的代码,并根据调用代码上下文中的需求测试新实现。
必须添加一个重要的限定条件:在编写测试时,最好不是由您来设置输入数据和测试标准,而是由分配给您任务的人或测试人员来设置。根据代码生成的结果设置测试是众所周知的程序员陷阱。客观的自我评估并不容易,如果可能的话
总结
在本章中,我们讨论了主流程序员每天遇到的 Java 习惯用法。我们还讨论了最佳设计实践和相关建议,包括代码编写风格和测试。
在本章中,读者了解了与某些特性、功能和设计解决方案相关的最流行的 Java 习惯用法。这些习语通过实际例子进行了演示,读者已经学会了如何将它们融入到自己的代码中,以及如何使用专业语言与其他程序员进行交流
在下一章中,我们将向读者介绍为 Java 添加新特性的四个项目:Panama、Valhalla、Amber 和 Loom。我们希望它能帮助读者了解 Java 开发,并设想未来版本的路线图。
测验
-
选择所有正确的语句:
- 习语可以用来传达代码意图。
- 习语可以用来解释代码的作用。
- 习语可能被误用,使谈话的主题模糊不清。
- 为了表达清楚,应该避免使用习语。
-
是否每次执行
equals()时都需要执行hasCode()? -
如果
obj1.compareTo(obj2)返回负值,这是什么意思? -
深度复制概念是否适用于克隆期间的原始类型值?
-
哪个更快,
StringBuffer还是StringBuilder? -
面向接口编程有什么好处?
-
使用组合和继承有什么好处?
-
与编写自己的代码相比,使用库的优势是什么?
-
你的代码的目标受众是谁?
-
是否需要测试?
十九、Java 新特性
在本章中,读者将了解当前最重要的项目,这些项目将为 Java 添加新特性并在其他方面增强它。阅读本章之后,读者将了解如何遵循 Java 开发,并将设想未来 Java 发行版的路线图。如果需要,读者也可以成为 JDK 源代码贡献者。
本章将讨论以下主题:
- Java 的继续发展
- Panama 项目
- Valhalla 项目
- Amber 项目
- Loom 项目
- Skara 项目
Java 继续发展
这对任何 Java 开发人员来说都是最好的消息:Java 得到了积极的支持,并不断得到增强,以跟上行业的最新需求。这意味着,无论你听到什么关于其他语言和最新技术的消息,你都会很快得到添加到 Java 中的最佳特性和功能。每半年发布一次新的时间表,你可以放心,新增加的内容一旦证明是有用和实用的,就会发布
在考虑设计一个新的应用或新的功能以添加到现有的应用时,了解 Java 在不久的将来如何增强是很重要的。这些知识可以帮助您设计新代码,使之更容易适应新的 Java 函数,并使您的应用更简单、更强大。对于一个主流程序员来说,遵循所有的 JDK 增强建议(JEP)可能是不切实际的,因为必须遵循太多不同的讨论和开发线程。相比之下,掌握您感兴趣的领域中的一个 Java 增强项目要容易得多。你甚至可以尝试作为某一领域的专家或只是作为感兴趣的一方为这样的项目做出贡献。
在本章中,我们将回顾我们认为最重要的五个 Java 增强项目:
- Panama 项目:关注与非 Java 库的互操作性
- Valhalla 项目:围绕引入新的值类型和相关的泛型增强而构思
- Amber 项目:包括 Java 语言扩展的各种工作,包括数据类、模式匹配、原始字符串文本、简明方法体和 Lambda 增强,这些都是最重要的子项目
- Loom 项目:解决了名为纤程的轻量级线程的创建问题,并简化了异步编码
Panama 项目
在整本书中,我们建议使用各种 Java 库—标准的 Java 类库(JCL)和外部 Java 库,这些库有助于提高代码质量并缩短开发时间。但是应用也可能需要非 Java 外部库。近年来,随着人们对使用机器学习算法进行数据处理的需求不断增长,这种需求也随之增加。例如,将这些算法移植到 Java 并不总是能跟上人脸识别、视频中人类行为分类和跟踪摄像机运动等领域的最新成果。
现有的利用不同语言编写的库的机制是 Java 本机接口(JNI)、Java 本机访问(JNA)和 Java 本机运行时(JNR)。尽管有这些功能,访问本机代码(为特定平台编译的其他语言的代码)并不像使用 Java 库那么容易。此外,它限制了 Java 虚拟机(JVM)的代码优化,经常需要用 C 语言编写代码
Panama 项目为了解决这些问题,包括 C++ 功能的支持。作者使用的术语是外部库。这个术语包括所有其他语言的库。新方法背后的思想是使用一个名为 jextract 的工具将本机头翻译成相应的 Java 接口。生成的接口允许直接访问本机方法和数据结构,而无需编写 C 代码。
毫不奇怪,支持类计划存储在java.foreign包中。
在撰写本文时(2019 年 3 月),Panama 早期的 access 构建基于不完整的 Java13 版本,面向专家用户。预计它可以将为本机库创建 Java 绑定的工作量减少 90%,生成的代码的执行速度至少是 JNI 的四到五倍。
Valhalla 项目
Valhalla 项目源于这样一个事实,即自从 Java 在大约 25 年前首次引入以来,硬件已经发生了变化,当时做出的决定在今天会有不同的结果。例如,从内存获取值的操作和算术操作在性能时间方面产生的成本大致相同。如今,情况发生了变化。内存访问比算术运算长 200 到 1000 倍。这意味着涉及原始类型的操作要比基于它们的包装类型的操作便宜得多。
当我们使用两个基本类型做一些事情时,我们获取值并在操作中使用它们。当我们对包装器类型执行相同的操作时,我们首先使用引用来访问对象(相对于 20 年前的操作本身,对象现在要长得多),只有这样我们才能获取值。这就是为什么 Valhalla 项目试图为引用类型引入一个新的值类型,它提供了对值的访问,而无需使用引用,就像原始类型通过值可用一样。
它还将节省内存消耗和包装数组的效率。现在,每个元素将由一个值表示,而不是由引用表示。
这样的解决方案从逻辑上引出了泛型问题。今天,泛型只能用于包装类型。我们可以写List<Integer>,但不能写List<int>。这也是 Valhalla 项目准备解决的问题。它将扩展泛型类型,以支持泛型类和接口在原始类型上的特化。扩展也允许在泛型中使用原始类型。
Amber 项目
Amber 项目专注于小型 Java 语法增强,使其更具表现力、简洁性和简单性。这些改进将提高 Java 程序员的工作效率,并使他们的代码编写更加愉快
Amber 项目创建的两个 Java 特性已经交付,我们讨论了它们:
- 类型保持架
var(参见第 1 章、“Java12 入门”)从 Java10 开始使用。 - Java11 中增加了 Lambda 参数的局部变量语法(参见第 13 章、“函数式编程”。
- 不太详细的
switch语句(参见第 1 章、“Java12 入门”)是作为 Java12 的预览特性引入的。
未来的 Java 版本还将发布其他新特性。在下面的小节中,我们将只仔细研究其中的五个:
- 数据类
- 模式匹配
- 原始字符串
- 简明方法体
- Lambda 表达式
数据类
有些类只携带数据。它们的目的是将几个值放在一起,而不是其他值。例如:
public class Person {
public int age;
public String firstName, lastName;
public Person(int age, String firstName, String lastName) {
this.age = age;
this.lastName = lastName;
this.firstName = firstName;
}
}
它们还可能包括equals()、hashCode()和toString()方法的标准集,如果是这样的话,为什么还要为这些方法编写实现呢?它们可以自动生成—就像您的 IDE 现在可以这样做一样。这就是名为数据类的新实体背后的思想,可以简单地定义如下:
record Person(int age, String firstName, String lastName) {}
默认情况下,其余的将假定为存在
但是,正如 Brian Goetz 所写,问题来了:
“它们是可扩展的吗?字段是可变的吗?我可以控制生成的方法的行为或字段的可访问性吗?我可以添加其他字段和构造器吗?”
——布莱恩·戈茨
正是在这种情况下,这一思想的当前状态正处于试图限制范围,并仍然为语言提供价值的中间阶段。
敬请关注
模式匹配
几乎每个程序员都会时不时地遇到需要根据值的类型切换到不同的值处理的情况。例如:
SomeClass someObj = new SomeClass();
Object value = someOtherObject.getValue("someKey");
if (value instanceof BigDecimal) {
BigDecimal v = (BigDecimal) value;
BigDecimal vAbs = v.abs();
...
} else if (value instanceof Boolean) {
Boolean v = (Boolean)value;
boolean v1 = v.booleanValue();
...
} else if (value instanceof String) {
String v = (String) value;
String s = v.substring(3);
...
}
...
在编写这样的代码时,您很快就会感到厌烦。这就是模式匹配要解决的问题。实现该功能后,可以将前面的代码示例更改为如下所示:
SomeClass someObj = new SomeClass();
Object value = someOtherObject.getValue("someKey");
if (value instanceof BigDecimal v) {
BigDecimal vAbs = v.abs();
...
} else if (value instanceof Boolean v) {
boolean v1 = v.booleanValue();
...
} else if (value instanceof String v) {
String s = v.substring(3);
...
}
...
很好,不是吗?它还将支持内联版本,如以下版本:
if (value instanceof String v && v.length() > 4) {
String s = v.substring(3);
...
}
这个新的语法将首先在一个if语句中被允许,然后再添加到一个switch语句中。
原始字符串
偶尔,您可能希望缩进一个输出,因此它看起来像这样,例如:

要实现这一点,代码如下所示:
String s = "The result:\n" +
" - the symbol A was not found;\n" +
" - the type of the object was not Integer either.";
System.out.println(s);
添加新的原始字符串字面值后,相同的代码可以更改为如下所示:
String s = `The result:
- the symbol A was not found;
- the type of the object was not Integer either.
`;
System.out.println(s);
这样,代码看起来就不那么杂乱,更容易编写。也可以使用align()方法将原始字符串文本与左边距对齐,使用indent(int n)方法设置缩进值,并使用align(int indent)方法设置对齐后的缩进值。
类似地,将字符串放在符号(`)内将允许我们避免使用转义指示符反斜杠(\)。例如,在执行命令时,当前代码可能包含以下行:
Runtime.getRuntime().exec("\"C:\\Program Files\\foo\" bar");
使用原始字符串字面值,可以将同一行更改为以下内容:
Runtime.getRuntime().exec(`"C:\Program Files\foo" bar`);
同样,它更容易写和读。
简明方法体
Lambda 表达式语法终止了这个特性的概念,它可以非常紧凑。例如:
Function<String, Integer> f = s -> s.length();
或者,使用方法引用,可以表示得更短:
Function<String, Integer> f = String::length;
这种方法的逻辑扩展是:为什么不对标准获取器应用相同的速记风格呢?看看这个方法:
String getFirstName() { return firstName; }
可以简单地缩短为以下形式:
String getFirstName() -> firstName;
或者,考虑该方法使用其他方法时的情况:
int getNameLength(String name) { return name.length(); }
也可以通过方法引用来缩短,如下所示:
int getNameLength(String name) = String::length;
但是,在撰写本文(2019 年 3 月)时,该提案仍处于早期阶段,在最终版本中,许多内容可以更改。
Lambda 改进
Amber 项目计划向 Lambda 表达式语法添加三个内容:
- 隐藏局部变量
- 函数表达式的更好消歧
- 使用下划线表示未使用的参数
使用下划线而不是参数名
在许多其他编程语言中,Lambda 表达式中的下划线(_表示未命名的参数。在 Java9 将下划线用作标识符定为非法之后,Amber 项目计划在当前实现实际上不需要该参数的情况下将其用作 Lambda 参数。例如,看看这个函数:
BiFunction<Integer, String, String> f = (i, s) -> s.substring(3);
参数(i在函数体中没有使用,但是我们仍然提供标识符作为占位符
使用新的添加项,可以将其替换为下划线,从而避免使用标识符并指示从未使用参数:
BiFunction<Integer, String, String> f = (_, s) -> s.substring(3);
这样,就很难忽略一个输入值没有被使用的事实。
隐藏局部变量
目前,不可能为 Lambda 表达式的参数指定与在本地上下文中用作标识符的名称相同的名称。例如:
int i = 42;
//some other code
BiFunction<Integer, String, String> f = (i, s) -> s.substring(3); //error
在将来的版本中,这样的名称重用是可能的。
更好地消除函数表达式的歧义
在撰写本文时,可以按如下方式重载方法:
void method(Function<String, String> fss){
//do something
}
void method(Predicate<String> predicate){
//do something
}
但是,只能通过显式定义传入函数的类型来使用它:
Predicate<String> pred = s -> s.contains("a");
method(pred);
尝试将其与内联 Lambda 表达式一起使用将失败:
method(s -> s.contains("a")); // compilation error
编译器抱怨,因为它无法解决一个歧义,因为两个函数都有一个相同类型的输入参数,并且只有在涉及到return类型时才不同。
Amber 项目可能会解决这个问题,但是最终的决定还没有做出,因为这取决于这个建议对编译器实现的影响
Loom 项目
Loom 可能是本章中列出的能够提升 Java 能力的最重要的项目。从大约 25 年前的早期开始,Java 就提供了一个相对简单的多线程模型和一个定义良好的同步机制。我们在第 8 章、“多线程和并发处理”中进行了描述。这种简单性,以及 Java 的整体简单性和安全性,是 Java 成功的主要因素之一。Java Servlet 允许处理许多并发请求,并且是基于 Java 的 HTTP 服务器的基础。
Java 中的线程是基于 OS 内核线程的,这是一个通用线程。但是内核操作系统线程也被设计用来执行许多不同的系统任务。它使得这样的线程对于特定应用的业务需求来说过于繁重(需要太多的资源)。满足应用接收的请求所需的实际业务操作通常不需要所有线程功能。这意味着当前的线程模型限制了应用的能力。为了估计这个限制有多强,我们可以观察到,现在的 HTTP 服务器可以处理超过一百万个并发打开的套接字,而 JVM 不能处理超过几千个。
这就是引入异步处理的动机,尽量少地使用线程,而引入轻量级处理工作者。我们在第 15 章、“反应式编程”和第 16 章、“微服务”中讨论过。异步处理模型工作得很好,但是它的编程并不像其他 Java 编程那样简单。它还需要大量的工作来与基于线程的遗留代码集成,甚至需要更多的工作来迁移遗留代码以采用新模型
添加这样的复杂性使得 Java 不像以前那么容易学习,而 Loom 项目将通过使 Java 更加轻量级来重新使用 Java 并发处理的简单性。
该项目计划向 Java 添加一个新类Fiber,以支持由 JVM 管理的轻量级线程构造。纤程占用的资源要少得多。它们也几乎没有或几乎没有上下文切换的开销,当一个线程挂起时,另一个线程必须启动或继续它自己的由于 CPU 时间共享或类似原因而挂起的作业。当前线程的上下文切换是性能受限的主要原因之一。
为了让您了解与线相比,纤程有多轻,织机开发商罗恩·普雷斯勒(Ron Pressler)和艾伦·贝特曼(Alan Bateman)提供了以下数字:
- 线程:
- 通常为栈保留 1 MB+16 KB 的内核数据结构
- 每个启动线程约 2300 字节,包括虚拟机(VM)元数据
- 纤程:
- 延续栈:数百字节到 KBs
- 当前原型中每个纤程 200-240 字节
如您所见,我们希望并行处理的性能会有显著的改进。
术语延续并不新鲜。在纤程之前使用。它表示一个顺序执行的指令序列,可以挂起自身。并发处理器的另一部分是调度器,它将延续分配给 CPU 核心,将暂停的一个替换为准备运行的另一个,并确保准备恢复的延续最终将分配给 CPU 核心。当前的线程模型也有一个延续和一个调度器,即使它们并不总是作为 API 公开。Loom 项目打算将延续和调度器分开,并在它们之上实现 Java 纤程。现有的ForkJoinPool可能会用作纤程
您可以在项目提案中阅读更多关于 Loom 项目动机和目标的信息,这对于任何 Java 开发人员来说都是一本相对简单且非常有启发性的读物。
Skara 项目
Skara 没有向 Java 添加新特性。它的重点是改进对 JDK 的 Java 源代码的访问
现在要访问源代码,需要从 Mercurial 存储库下载并手动编译。Skara 项目的目标是将源代码迁移到 Git,因为 Git 现在是最流行的源代码存储库,而且许多程序员已经在使用它了。如您所知,本书中示例的源代码也存储在 GitHub 上
你可以在 GitHub 中看到 Skara 项目的当前结果。它仍然使用 JDK Mercurial 存储库的镜像。但是,在未来,它将变得更加独立。
总结
在本章中,读者了解了增强 JDK 的当前最重要的项目。我们希望您能够理解如何遵循 Java 开发,并且已经设想了未来 Java 发行版的路线图 https://openjdk.java.net/projects)你也可以看看。我们还希望您对成为一名高效的 JDK 源代码贡献者和活跃的社区成员的前景感到足够的兴奋。欢迎光临!
二十、答案
第 1 章:Java12 入门
- c) Java 开发工具包
- b) Java 类库
- d) Java 标准版
- b) 集成开发环境
- a) 项目建设,b)项目配置,c)项目文件
- a) 布尔值,b)数字
- a)
long,c)short,d)byte - d) 值表示
- a)
\\,b)2_0,c)2__0f,d)\f - a)
%、c)&、d)-> - a) 0
- b) 否,否
- d) 4
- c) 编译错误
- b) 2
- a、c、d
- d)
20 -1 - c)
x值在 11 范围内 - c) 结果为 32
- a) 可以声明变量,b)可以指定变量
- b) 选择语句,d)增量语句
第 2 章:Java 面向对象编程(OOP)
- a、d
- b、c、d
- a、b、c
- a、c、d
- d
- c、d
- a、b
- b、d
- d
- b
- a、c
- b、c、d
- a、b
- b、c
- b、c、d
- b、c
- c
- a、b、c
- b、c、d
- a、c
- a、c、d
第 3 章:Java 基础
- a、d
- c、d
- a、b、d
- a、c、d
- a、c
- a、b、d
- a、b、c、d
- c、d
- d
- c
- b
- c
第 4 章:异常处理
- a、b、c
- b
- c
- a、b、c、d
- 1
- a、c
- d
第 5 章:字符串、输入/输出和文件
- b
- c
- b
- 1
- d
- a、c、d
- c
- d
- a、b、c
- c、d(注意使用
mkdir()方法代替mkdirs())
第 6 章:数据结构、泛型和流行工具
- d
- b、d
- a、b、c、d
- a、b、c、d
- a、b、d
- a、b、c
- c
- a、b、c、d
- b、d
- b
- b、c
- 1
- c
- d
- b
- c
- 1
- b
- c
第 7 章:Java 标准和外部库
- a、b、c
- a、b、d
- b、c
- b、d
- a、c
- a、b、c、d
- b、c、d
- b、c
- b
- c、d
- a、c
- b、d
- a、d
- b、c、d
- a、b、d
- b、d
第 8 章:多线程和并发处理
- a、c、d
- b、c、d
- 1
- a、c、d
- b、c、d
- a、b、c、d
- c、d
- a、b、c
- b、c
- b、c、d
- a、b、c
- b、c
- b、c
第 9 章:JVM 结构和垃圾收集
- b、d
- c
- d
- b、c
- a、d
- c
- a、b、c、d
- a、c、d
- b、d
- a、b、c、d
- 1
- a、b、c
- a、c
- a、c、d
- b、d
第 10 章:管理数据库中的数据
- c
- a、d
- b、c、d
- a、b、c、d
- a、b、c
- a、d
- a、b、c
- a、c
- a、c、d
- a、b
- a、d
- a、b、d
- a、b、c
第 11 章:网络编程
- 正确答案可能包括 FTP、SMTP、HTTP、HTTPS、WebSocket、SSH、Telnet、LDAP、DNS 或其他一些协议
- 正确的答案可能包括 UDP、TCP、SCTP、DCCP 或其他协议
java.net.http- UDP 协议
- 是的
java.net- 传输控制协议
- 它们是同义词
- 按源的 IP 地址和端口以及目标的 IP 地址和端口
ServerSocket无需客户端运行即可使用。它只是在指定的端口上“监听”- UDP 协议
- 传输控制协议
- 正确答案可能包括 HTTP、HTTPS、Telnet、FTP 或 SMTP
- a、c、d
- 它们是同义词
- 它们是同义词
/something/something?par=42- 正确答案可能包括二进制格式、标头压缩、多路复用或推送功能
java.net.http.HttpClientjava.net.http.WebSocket- 不是区别
java.util.concurrent.CompletableFuture
第 12 章:Java GUI 编程
- 舞台
- 节点
- 应用
void start(Stage pm)static void launch(String... args)--module-path和--add-modulesvoid stop()WebViewMedia MediaPlayer MediaView--add-exports- 以下列表中的任意五个:
Blend、Bloom、BoxBlur、ColorAdjust、DisplacementMap、DropShadow、Glow、InnerShadow、Lighting、MotionBlur、PerspectiveTransform、Reflection、ShadowTone、SepiaTone
第 13 章:函数式编程
- c
- a、d
- 1
void- 1
boolean- 不是
T- 1
R- 闭包上下文
Location::methodName
第 14 章:Java 标准流
- a、b
of()无参数产生空流java.util.Set- 135
- 42
- 2121
- 不是,但是它扩展了函数式接口
Consumer,可以这样传递 - 不是
- 3
- 1.5
"42, X, a"- 编译错误,因为
peek()不能返回任何内容 - 2
- 另一个目标
"a"- 1
filter()、map()和flatMap()中的任何一个distinct()、limit()、sorted()、reduce()和collect()中的任何一个
第 15 章:反应式编程
-
a、b、c
-
是的
-
无阻塞输入/输出
-
不
-
反应式扩展
-
java.util.concurrent -
a、d
-
阻塞运算符名称以“阻塞”开头
-
一个热的可观测物体以它自己的速度发射值。一个冷的可观察对象在上一个值到达终端操作符之后发出下一个值
-
可观察对象停止发射值,管道停止运行
-
a、c、d
-
例如,以下任意两个:
buffer()、flatMap()、groupBy()、map()、scan()、window() -
例如,以下任意两个:
debounce()、distinct()、elementAt(long n)、filter()、firstElement()、ignoreElements()、lastElement()、sample()、skip()、take() -
删除过多的值,获取最新值,使用缓冲区
-
subscribeOn()``observeOn()``fromFuture()
第 16 章:微服务
- a、c
- 是的
- 与传统应用的方式相同,而且它们通常有自己的通信方式(例如,使用事件总线)
- 列表中的任意两个:Akka,Dropwizard,Jodd,Lightbend Lagom,Ninja,Spotify Apollo,Vert.x。
- 实现接口
io.vertx.core.Verticle的类 Send只向一个注册地址的接收器发送消息;publish向所有注册地址相同的接收器发送消息- 它使用循环算法
- 是的
- https://vertx.io/
第 17 章:Java 微基准线束
-
b、c、d
-
将对 JMH 的依赖添加到项目中(如果手动运行,则添加类路径),并将注解
@Benchmark添加到要测试性能的方法中 -
作为
main方法使用带有显式命名的主类的 Java 命令,作为main方法使用带有可执行的.jar文件的 Java 命令,并且使用 IDE 运行作为main方法或者使用插件并运行单个方法 -
以下任意两项:
Mode.AverageTime、Mode.Throughput、Mode.SampleTime、Mode.SingleShotTime -
以下任意两项:
TimeUnit.NANOSECONDS、TimeUnit.MICROSECONDS、TimeUnit.MILLISECONDS、TimeUnit.SECONDS、TimeUnit.MINUTES、TimeUnit.HOURS、TimeUnit.DAYS -
使用带有注解
@State的类的对象 -
使用
state属性前面的注解@Param -
使用注解
@CompilerConrol -
使用消耗生成结果的类型为
Blackhole的参数 -
使用注解
@Fork
第 18 章:编写高质量代码的最佳实践
- a、b、c
- 一般来说,这是推荐的,但不是必需的。但在某些情况下,例如,将要在基于哈希的数据结构中放置和搜索类的对象时,它是必需的
obj1小于obj2- 不
StringBuilder- 允许在不更改客户端代码的情况下更改实现
- 对代码演化的更多控制和适应变化的代码灵活性
- 更可靠的代码,更快的编写,更少的测试,更容易让其他人理解
- 其他将要维护您的代码的程序员,以及稍后的您
- 不,但对你很有帮助
第 1 节:Java 编程概述
本书的第一部分将读者带入 Java 编程的世界。它从基本的 Java 相关定义和主要术语开始,引导读者安装必要的工具和 Java 本身,并解释如何运行(执行)Java 程序和本书提供的代码示例。
在掌握了基础知识之后,我们将解释和讨论面向对象编程(OOP)的原理,Java 如何实现它们,以及程序员如何利用它们编写易于维护的高质量代码。
本书继续介绍 Java 作为一种语言的更详细的视图。它解释了如何在包中组织代码、定义所有主要类型以及保留关键字和限制关键字的列表。所有的讨论都用具体的代码示例来说明。
本节包含以下章节:
第 1 章“Java12 入门”
第 2 章"Java 面向对象编程"
第 3 章“Java 基础”
第 2 节:Java 的构建块
本书的第二部分构成了 Java 演示的主要部分。它讨论了主要的 Java 组件和结构,以及算法和数据结构。详细回顾了 Java 的异常系统,还介绍了字符串类和 I/O 流,以及允许管理文件的类。
本文讨论并演示了 Java 集合和三个主要接口——List、Set和Map——并解释了泛型,接着介绍了用于管理数组、对象和时间/日期值的工具类。这些类属于 Java 类库(JCL),我们也讨论了其中最流行的包。第三方库在编程专业人士中很受欢迎,对它们进行了补充。
所提供的资料引发了对编程方面的讨论,如性能、并发处理和垃圾收集,这些都是 Java 设计的核心。它与有关图形用户界面和数据库管理的专门章节一起,涵盖了所有强大 Java 应用的所有三个层次:前端、中间和后端。有关网络协议和应用相互通信方式的一章完整地描述了应用可以进行的所有主要交互。
本节包含以下章节:
第 4 章,“处理”
第 5 章、“字符串、输入/输出和文件”
第 6 章、“数据结构、泛型和流行工具”
第 7 章“Java 标准和外部库”
第 8 章“多线程并发处理”
第 9 章“JVM 结构及垃圾收集”
第 10 章“管理数据库中的数据”
第 11 章“网络编程”
第 12 章“Java GUI 编程”
第 3 节:高级 Java
本书的最后一部分介绍了现代 Java 编程的最高级主题。它可以让新手在他们对专业的理解上有一个坚实的基础,对于那些已经在这个领域工作的人来说,可以扩展他们的技能和专长。最近对 Java 的流和函数编程的添加使得 Java 中的异步处理几乎和传统的同步方式一样简单。它提高了 Java 应用的性能,读者将学习如何利用它并欣赏所提供解决方案的美丽和强大。
读者还将学习反应式编程(异步、非阻塞和响应式)的新术语和相关概念,它们是大数据处理和机器学习的前沿。反应式系统的构建块是一个微服务,它是使用 Vert.x 工具箱演示的。
本书最后解释了基准工具、最佳编程实践和当前正在进行的开放 Java 项目。这些项目结束后,将为 Java 作为一种语言和创建现代、大规模数据处理系统的工具带来更大的力量。读者将有机会了解 Java 的未来,甚至成为它的一部分。
本节包含以下章节:
第 13 章“函数式编程”
第 14 章“Java 标准流”
第 15 章“反应式编程”
第 16 章“微服务”
第 17 章“Java 微基准线束”
第 18 章“编写高质量代码的最佳实践”
第 19 章“Java 新特性”


浙公网安备 33010602011771号