Java17-编程学习指南-全-

Java17 编程学习指南(全)

原文:zh.annas-archive.org/md5/b8cb30873c6993b2982e2e2c253f6340

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书的目的是为读者提供对 Java 基础知识的扎实理解,并引导他们通过一系列从基础知识到实际编程的实际步骤。讨论和示例旨在通过使用经过验证的编程原则和实践来激发读者的专业直觉。本书从基础知识开始,将读者带到最新的编程技术,这些技术被视为专业水平。

完成本书后,您将能够做到以下事项:

  • 安装并配置您的 Java 开发环境。

  • 安装并配置您的集成开发环境(IDE)——本质上,您的编辑器。

  • 编写、编译和执行 Java 程序和测试。

  • 理解和使用 Java 语言基础知识。

  • 理解并应用面向对象设计原则。

  • 掌握最常用的 Java 构造。

  • 学习如何在 Java 应用程序中访问和管理数据库中的数据。

  • 提高您对网络编程的理解。

  • 学习如何为您的应用程序添加图形用户界面以实现更好的交互。

  • 熟悉函数式编程。

  • 理解最先进的数据处理技术——流,包括并行和响应式流。

  • 学习和实践创建微服务和构建响应式系统。

  • 学习最佳的设计和编程实践。

  • 展望 Java 的未来,并学习您如何成为其中的一部分。

本书面向的对象

本书适用于那些希望开始现代 Java 编程职业的新手,以及那些已经从事这一职业并希望更新他们对最新 Java 及相关技术和理念知识的读者。

本书涵盖的内容

第一章Java 17 入门,从基础知识开始,首先解释“Java”是什么以及定义其主要术语,然后继续介绍如何安装编写和运行(执行)程序所需的工具。本章还描述了基本的 Java 语言构造,并通过可以立即执行的示例来说明。

第二章Java 面向对象编程(OOP),介绍了面向对象编程的概念以及它们如何在 Java 中实现。每个概念都通过具体的代码示例进行演示。详细讨论了 Java 语言中的类和接口构造,以及重载、重写、隐藏和 final 关键字的使用。本章的最后部分致力于展示多态的力量。

第三章Java 基础,向读者展示了 Java 作为语言的更详细视图。它从包中的代码组织开始,描述了类(接口)及其方法和属性(字段)的可访问级别。作为 Java 面向对象特性的主要类型,引用类型被详细地介绍,随后列出了保留和限制性关键字及其用法讨论。本章最后介绍了原始类型之间的转换方法,以及从原始类型到相应的引用类型及其返回的转换方法。

第四章异常处理,向读者介绍了与 Java 异常处理相关的构造的语法以及处理异常的最佳实践。本章最后讨论了可用于在生产环境中调试应用程序代码的断言语句。

第五章字符串、输入/输出和文件,讨论了 String 类的方法,以及来自标准库和 Apache Commons 项目的流行字符串实用工具。随后是 Java 输入/输出流及其 java.io 包中的相关类的概述,以及 org.apache.commons.io 包中的某些类。文件管理类及其方法在专用部分中描述。

第六章数据结构、泛型和常用工具类,介绍了 Java 集合框架及其三个主要接口:List、Set 和 Map,包括泛型的讨论和演示。在 Java 集合的上下文中还讨论了equals()hashCode()方法。用于管理数组、对象和时间/日期值的实用类也有相应的专用部分。

第七章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 包代表。本章帮助读者避免在存在此类功能且可以即插即用的情况下编写自定义代码。

第八章, 多线程与并发处理,介绍了通过使用处理数据并发的工作者(线程)来提高 Java 应用程序性能的方法。它解释了 Java 线程的概念,并展示了它们的用法。它还讨论了并行处理和并发处理之间的区别,以及如何避免由共享资源的并发修改引起的不可预测的结果。

第九章, JVM 结构和垃圾回收,向读者概述了 JVM 的结构和行为,这些比我们通常预期的要复杂。其中一个服务线程,称为垃圾回收,执行释放未使用对象内存的重要任务。阅读本章后,读者将更好地理解构成 Java 应用程序执行、JVM 内部的 Java 进程、垃圾回收以及 JVM 总体工作方式的内容。

第十章, 在数据库中管理数据,解释并演示了如何从 Java 应用程序中管理数据库中的数据——即插入、读取、更新和删除数据。它还简要介绍了 SQL 语言和基本数据库操作:如何连接到数据库,如何创建数据库结构,如何使用 SQL 编写数据库表达式,以及如何执行它们。

第十一章, 网络编程,描述和讨论了最流行的网络协议,用户数据报协议(UDP)、传输控制协议(TCP)、超文本传输协议(HTTP)和 WebSocket,以及它们对 JCL 的支持。它展示了如何使用这些协议,以及如何在 Java 代码中实现客户端-服务器通信。所审查的 API 包括基于 URL 的通信和最新的 Java HTTP 客户端 API。

第十二章, Java GUI 编程,概述了 Java GUI 技术,并展示了如何使用 JavaFX 工具包创建 GUI 应用程序。JavaFX 的最新版本不仅提供了许多有用的功能,还允许保留和嵌入遗留实现和样式。

第十三章, 函数式编程,解释了什么是函数式接口,概述了随 JDK 一起提供的函数式接口,并定义和演示了 lambda 表达式及其如何与函数式接口一起使用,包括使用方法引用。

第十四章Java 标准流,讨论了数据流的处理,这些流与第五章字符串、输入/输出和文件中回顾的 I/O 流不同。它定义了数据流是什么,如何使用 java.util.stream.Stream 对象的方法(操作)处理它们的元素,以及如何在管道中链(连接)流操作。它还讨论了流的初始化以及如何并行处理流。

第十五章响应式编程,介绍了响应式宣言和响应式编程的世界。它从定义和讨论主要相关概念——如“异步”、“非阻塞”、“响应式”等——开始。使用这些概念,它接着定义和讨论响应式编程、主要响应式框架,并更详细地讨论 RxJava。

第十六章Java 微基准测试工具,介绍了 Java 微基准测试工具(JMH)项目,它允许我们测量各种代码性能特征。它定义了 JMH 是什么,如何创建和运行基准测试,基准测试参数是什么,并概述了支持的 IDE 插件。本章以一些实际演示示例和建议结束。

第十七章编写高质量代码的最佳实践,介绍了 Java 惯用语句以及设计和应用代码中最流行和有用的实践。

要充分利用这本书

系统地阅读各章节,并在每章末尾回答测验问题。克隆或仅下载源代码仓库(见以下章节)并运行所有演示讨论主题的代码示例。对于提高编程速度,没有什么比执行提供的示例、修改它们并尝试自己的想法更好的了。代码即真理。

如果您正在使用这本书的数字版,我们建议您亲自输入代码或从书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Learn-Java-17-Programming)下载这本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,这些资源可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表的彩色 PDF 文件。您可以从这里下载:packt.link/CQqKD

使用的约定

在本书中使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 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(如果您已经安装了 JDK 12,则为 java 版本 12)的值,然后点击下一步”

小贴士或重要注意事项

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《Java 17 编程学习》,我们很乐意听到您的想法!请点击此处直接访问此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

第一部分:Java 编程概述

通过实际示例和示例程序,掌握 Java 和面向对象编程(OOP)的基本概念。这将建立一个基础,帮助读者开始学习 Java 的一些核心/高级概念。

本部分包含以下章节:

  • 第一章Java 17 入门

  • 第二章Java 面向对象编程(OOP)

  • 第三章Java 基础

第一章:Java 17 入门

本章介绍如何开始学习 Java 17 以及 Java 的一般知识。我们将从基础知识开始,首先解释什么是 Java 以及其主要术语,然后介绍如何安装编写和运行(执行)程序所需的工具。在这方面,Java 17 与之前的 Java 版本没有太大区别,因此本章的内容也适用于旧版本。

我们将描述并演示构建和配置 Java 编程环境所需的所有必要步骤。这是您计算机上开始编程所需的最基本内容。我们还描述了基本的 Java 语言结构,并通过可以立即执行的示例来展示它们。

学习一种编程语言——或者任何语言,最好的方式是使用它,本章将指导读者如何使用 Java 来实现这一点。在本章中,我们将涵盖以下主题:

  • 如何安装和运行 Java

  • 如何安装和运行集成开发环境(IDE

  • Java 原始类型和运算符

  • 字符串类型和字面量

  • 标识符IDs)和变量

  • Java 语句

技术要求

要能够执行本章提供的代码示例,您需要以下内容:

  • 配有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高版本

  • 一个 IDE 或您偏好的代码编辑器

如何设置 Java examples/src/main/java/com/packt/learnjava/ch01_start 文件夹的说明。

如何安装和运行 Java

当有人说“Java”时,他们可能意味着完全不同的事情。他们可能指的是以下任何一个:

  • Java 编程语言:一种高级编程语言,允许用人类可读的格式表达意图(程序),该格式可以被翻译成计算机可执行的二进制代码

  • Java 编译器:一个程序,可以读取用 Java 编程语言编写的文本,并将其翻译成可以被 Java 虚拟机(JVM)解释的二进制代码,该代码可以被计算机执行

  • JVM:一个程序,读取编译好的 Java 程序的字节码,并将其解释成计算机可执行的二进制代码

  • Java 开发工具包JDK):一组程序(工具和实用程序),包括 Java 编译器、JVM 和支持库,允许用 Java 语言编写的程序进行编译和执行

以下部分将指导您安装 Java 17 的 JDK 以及相关的基本术语和命令。

JDK 是什么以及为什么我们需要它?

如我们之前提到的,JDK 包括 Java 编译器和 JVM。编译器的任务是读取包含用 Java 编写的程序文本的.java文件(称为源代码),并将其转换(编译)成存储在.class文件中的字节码。然后 JVM 可以读取.class文件,将字节码解释成二进制代码,并将其发送到操作系统执行。编译器和 JVM 都必须从命令行显式调用。

Java 程序使用的语言层次结构如下:

  • 你编写 Java 代码(.java文件)。

  • 编译器将你的 Java 代码转换成字节码(.class文件)。

  • JVM 将字节码转换成机器级汇编指令(在硬件上运行)。

看看下面的例子:

int a = b + c;

当你编写前面的代码时,编译器会将以下字节码添加到.class文件中:

ILOAD b
ILOAD c
IADD
ISTORE a

一次编写,到处运行 是最著名的编程营销口号,推动了全球的采用。Oracle 声称超过 1000 万开发者使用 Java,它在 130 亿台设备上运行。你编写 Java 代码,并将其编译成.class文件中的字节码。对于 Windows、Mac、Unix、Linux 等操作系统,都有不同的 JVM,但相同的.class文件可以在所有这些操作系统上运行。

为了支持.java文件的编译及其字节码执行,JDK 安装还包括称为Java 类库JCL)的标准 Java 库。如果程序使用第三方库,它必须在编译和执行期间存在。它必须从调用编译器的相同命令行引用,后来当字节码由 JVM 执行时也是如此。另一方面,JCL 不需要显式引用。假设标准 Java 库位于 JDK 安装的默认位置,以便编译器和 JVM 知道它们的位置。

如果你不需要编译 Java 程序,只想运行已经编译好的.class文件,你可以下载并安装Java 运行环境JRE)。例如,它包括 JDK 的一个子集,但不包括编译器。

有时候,JDK 被称为软件开发工具包SDK),这是一个集合软件工具和支持库的通用名称,允许使用某种编程语言编写的源代码创建可执行版本。因此,JDK 是 Java 的 SDK。这意味着可以将 JDK 称为 SDK。

你也可能听到将Java 平台Java 版本应用于 JDK 的术语。一个典型的平台是一个操作系统,它允许软件开发和执行。由于 JDK 提供了自己的操作系统环境,因此它也被称为平台。版本是针对特定目的组装的 Java 平台(JDK)的变体。这里有四个 Java 平台版本,如下所示:

  • Java 平台 SE (Java SE): 这包括 JVM、JCL 以及其他工具和实用程序。

  • Java 平台企业版 (Java EE): 这包括 Java SE、服务器(为应用程序提供服务的计算机程序)、JCL、其他库、代码示例、教程和其他用于开发部署大规模、多层和安全的网络应用程序的文档。

  • Java 平台微型版 (Java ME): 这是 Java SE 的一个子集,包含一些用于开发并将 Java 应用程序部署到嵌入式和移动设备(如手机、个人数字助理、机顶盒、打印机和传感器)的专用库。Java ME 的一个变体(具有自己的 JVM 实现)称为 Android SDK,它是 Google 为 Android 编程开发的。

  • Java Card: 这是 Java 版本中最小的一个,旨在开发并将 Java 应用程序部署到小型嵌入式设备上,例如智能卡。它有两个版本:Java Card 经典版,用于智能卡(基于国际标准化组织 (ISO) 7816 和 ISO 14443 通信),以及 Java Card 连接版,它支持 Web 应用程序模型,并以传输控制协议/互联网协议 (TCP/IP)作为基本协议,运行在高性能安全微控制器上。

  • 因此,安装 Java 意味着安装 JDK,这也意味着在列出的版本之一上安装 Java 平台。在这本书中,我们将讨论并使用 Java SE(它包括 JVM、JCL 以及其他将您的 Java 程序编译成字节码、解释成二进制代码并将其自动发送到您的操作系统以执行所需的工具和实用程序)。

安装 Java SE

所有最近发布的 JDK 都在官方 Oracle 页面上列出,请参阅www.oracle.com/java/technologies/downloads/#java17(我们将在后续章节中将其称为安装主页)。

安装 Java SE 需要遵循以下步骤:

  1. 使用您的操作系统选择 Java SE 选项卡。

  2. 点击适合您操作系统和熟悉格式的(扩展名)安装程序的链接。

  3. 如果有疑问,请点击下面的安装说明链接,并阅读您操作系统的安装说明。

  4. 按照与您的操作系统对应的步骤进行操作。

  5. 当您的计算机上的java -version命令显示正确的 Java 版本时,JDK 安装成功,如下面的示例截图所示:

命令、工具和实用程序

如果你遵循安装说明,你可能已经注意到一个链接(bin 目录包含构成 Java 命令、工具和实用程序的 所有可执行程序。如果 bin 目录没有自动添加到 PATH 环境变量中,请考虑手动添加,这样你就可以从任何目录启动 Java 可执行程序。

在上一节中,我们已经展示了 java -version Java 命令。其他可用的 Java 可执行程序(命令、工具和实用程序)的列表可以在 Java SE 文档中找到(www.oracle.com/technetwork/java/javase/documentation/index.html),通过点击 Java 平台标准版技术文档 网站链接,然后点击下一页上的 工具参考链接。你可以通过点击其链接来了解更多关于每个可执行工具的信息。

你也可以使用以下选项之一在你的计算机上运行列出的每个可执行程序:

-?, -h, --help-help

这些将显示可执行程序的简要描述及其所有选项。

这里列出了最重要的 Java 命令:

  • javac: 这将读取 .java 文件,编译它,并创建一个或多个相应的 .class 文件,具体取决于 .java 文件中定义了多少个 Java 类。

  • java: 这将执行 .class 文件。

这些是使编程成为可能命令。每个 Java 程序员都必须对其结构和功能有良好的理解,但如果你是 Java 编程的新手并使用 IDE(见 如何安装和运行 IDE 部分),你不需要立即掌握这些命令。一个好的 IDE 通过每次你对其 .java 文件进行更改时自动编译它来隐藏它们。它还提供了一个图形元素,每次你点击它时都会运行程序。

另一个非常有用的 Java 工具是 jcmd。它便于与任何当前运行的 Java 进程(JVM)进行通信和诊断,并且有许多选项。但在最简单的形式下,不使用任何选项,它将列出所有当前运行的 Java 进程及其 进程 IDPID)。你可以用它来查看你是否存在失控的 Java 进程。如果有,你可以使用提供的 PID 来终止这样的进程。

如何安装和运行 IDE

最初只是一个专门编辑器,允许以与 Word 编辑器检查英语句子语法相同的方式检查编写程序的语法,逐渐演变成集成开发环境(IDE)。它的主要功能体现在名称上。它将编写、编译和执行程序所需的所有工具集成在一个 图形用户界面GUI)下。利用 Java 编译器的功能,IDE 可以立即识别语法错误,并通过提供上下文相关的帮助和建议来帮助提高代码质量。

选择 IDE

对于 Java 程序员来说,有多个 IDE 可用,例如 NetBeans、Eclipse、IntelliJ IDEA、BlueJ、DrJava、JDeveloper、JCreator、jEdit、JSource 和 jCRASP 等。您可以通过以下链接阅读顶级 Java IDE 的评测和每个 IDE 的详细信息:www.softwaretestinghelp.com/best-java-ide-and-online-compilers。最受欢迎的当属 NetBeans、Eclipse 和 IntelliJ IDEA。

NetBeans 的开发始于 1996 年,当时在布拉格的查尔斯大学作为 Java IDE 学生项目启动。1999 年,该项目及其周围的公司被 Sun Microsystems 收购。在 Oracle 收购 Sun Microsystems 之后,NetBeans 成为了开源项目,许多 Java 开发者随后为该项目做出了贡献。它随 JDK 8 一起捆绑发布,并成为了 Java 开发的官方 IDE。2016 年,Oracle 将其捐赠给了 Apache 软件基金会。

NetBeans IDE 支持 Windows、Linux、Mac 和 Oracle Solaris 系统。它支持多种编程语言,并且可以通过插件进行扩展。截至撰写本文时,NetBeans 仅与 JDK 8 捆绑,但 NetBeans 8.2 也可以与 JDK 9 一起使用,并使用 JDK 9 引入的特性,例如 Jigsaw。在 netbeans.apache.org 上,您可以了解更多关于 NetBeans IDE 的信息,并下载最新版本,撰写本文时版本为 12.5。

Eclipse 是最广泛使用的 Java IDE。为 IDE 添加新功能的插件列表不断增长,因此无法一一列举 IDE 的所有功能。Eclipse IDE 项目自 2001 年以来作为 开源软件OSS)进行开发。2004 年,成立了非营利性、会员支持的 Eclipse 基金会,以提供基础设施(版本控制系统VCSs)、代码审查系统、构建服务器、下载站点等)和结构化流程。Eclipse 基金会的 30 多名员工中没有人在 150 个 Eclipse 支持的项目上工作。

Eclipse IDE 插件的数量和种类繁多,对于初学者来说构成了一定的挑战,因为您需要熟悉不同实现方式相同或类似的功能,这些功能有时可能不兼容,可能需要进行深入调查,并清楚了解所有依赖关系。尽管如此,Eclipse IDE 非常受欢迎,并且拥有坚实的社区支持。您可以在 www.eclipse.org/ide 上了解 Eclipse IDE 并下载最新版本。

IntelliJ IDEA 有两个版本:付费版和免费社区版。付费版一直被评为最佳 Java IDE,但社区版也被列为三大领先 Java IDE 之一。开发该 IDE 的 JetBrains 软件公司在布拉格、圣彼得堡、莫斯科、慕尼黑、波士顿和诺沃西比尔斯克设有办事处。该 IDE 以其深入智能而闻名,正如作者在网站上描述产品时所说:“在任何上下文中提供相关建议:即时且聪明的代码补全、即时代码分析以及可靠的重构工具”。在安装和配置 IntelliJ IDEA部分,我们将向您介绍 IntelliJ IDEA 社区版的安装和配置过程。

安装和配置 IntelliJ IDEA

以下是你需要遵循的步骤来下载和安装 IntelliJ IDEA:

  1. www.jetbrains.com/idea/download 下载 IntelliJ IDEA 社区版的安装程序。

  2. 启动安装程序并接受所有默认值。

  3. 安装选项屏幕上选择.java。我们假设你已经安装了 JDK,所以你不需要勾选下载并安装 JRE选项。

  4. 最后一个安装屏幕有一个运行 IntelliJ IDEA复选框,你可以勾选以自动启动 IDE。或者,你可以在安装完成后手动启动 IDE,不勾选复选框。

  5. 当 IDE 首次启动时,它会提供一个导入 IntelliJ IDEA 设置选项。如果你之前没有使用过 IntelliJ IDEA,请勾选不导入设置复选框。

  6. 接下来的几个屏幕会询问你是否接受JetBrains 隐私政策,以及你是否愿意为许可证付费或更喜欢继续使用免费社区版或免费试用版(这取决于你下载的具体版本)。

  7. 按照你喜欢的任何方式回答问题,如果你接受隐私政策,自定义 IntelliJ IDEA屏幕将要求你选择一个主题:白色(IntelliJ)深色(Darcula)

  8. 接受默认设置。

  9. 如果你决定更改设置值,你可以在稍后通过选择最顶部的菜单文件 | 设置(在 Windows 上)或首选项(在 Linux 和 macOS 上)来更改。

创建项目

在你开始编写程序之前,你需要创建一个项目。在 IntelliJ IDEA 中创建项目有几种方法,这与任何 IDE 都相同,如下所示:

  1. 新建项目:这将从零开始创建一个新项目。

  2. 打开:这有助于从文件系统中读取现有项目。

  3. 从版本控制系统获取:这有助于从版本控制系统读取现有项目。

在本书中,我们将向您介绍第一个选项——使用 IDE 提供的引导步骤序列。选项 23 包含许多由导入具有这些设置的现有项目自动设置的设置。一旦你学会了如何从头创建新项目,IDE 中启动项目的其他方式对你来说将会非常容易。

首先点击 新建项目 链接,然后按照以下步骤继续操作:

  1. 在左侧面板中选择 Maven,并为 项目 SDK(如果你已经安装了 JDK 17,则为 Java 版本 17)选择一个值,然后点击 下一步

  2. Maven 是一个项目配置工具,其主要功能是管理项目依赖。我们将在稍后讨论它。现在,我们将使用它的另一个职责:使用三个 工件坐标 属性(见下文)定义和保存项目代码身份。

  3. 输入项目名称——例如,myproject

  4. 位置字段 设置中选择所需的项目位置(这是你的新代码将驻留的地方)。

  5. 点击 GroupId:这是标识组织内部或开源社区中一组项目的基包名。在我们的案例中,让我们输入 com.mywork

  6. ArtifactId:用于标识组内的特定项目。将其保留为 myproject

  7. 版本:用于标识项目的版本。将其保留为 1.0-SNAPSHOT

主要目标是使项目的身份在世界所有项目中独一无二。为了避免 GroupId 冲突,约定要求你从组织域名反向开始构建。例如,如果一家公司有一个 company.com 域名,其项目的 GroupId 属性应该以 com.company 开头。这就是为什么在这个演示中我们使用 com.mywork,而在本书中的代码,我们使用 com.packt.learnjavaGroupID 值。

  1. 点击 完成

  2. 你将看到以下项目结构和生成的 pom.xml 文件:

现在,如果有人想在他们的应用程序中使用你的项目代码,他们将通过显示的三个值来引用它,并且 Maven(如果他们使用它)将把它引入(当然,如果你将你的项目上传到公开共享的 Maven 仓库的话)。有关 Maven 的更多信息,请参阅 maven.apache.org/guidesGroupId 值的另一个功能是定义包含你的项目代码的文件夹树根目录。main 下的 java 文件夹将包含应用程序代码,而 test 下的 java 文件夹将包含测试代码。

让我们按照以下步骤创建我们的第一个程序:

  1. 右键点击 java,选择 新建,然后点击 ,如图所示:

  1. com.mywork.myproject 中按下 Enter

你应该在左侧面板中看到以下一组新文件夹:

  1. 右键单击com.mywork.myproject,选择新建,然后点击Java 类,如下面的截图所示:

  1. 在提供的输入窗口中,键入HelloWorld,如下所示:

  2. 按下Enter键,您将看到在com.mywork.myproject包中创建的第一个 Java 类HelloWorld,如下面的截图所示:

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

这必须具有以下属性:

  • public:可以从包外部自由访问

  • static:应该能够在不创建它所属的类对象的情况下调用

它还应该有以下内容:

  • 返回void(无)

接受一个String数组作为输入,或者varargs,正如我们所做的那样。我们将在第二章Java 面向对象编程(OOP)中讨论varargs。现在,只需说String[] argsString... args基本上定义了相同的输入格式。

使用命令行执行示例部分,我们解释了如何使用命令行运行main类。您可以在官方 Oracle 文档中了解更多关于 Java 命令行参数的信息:docs.oracle.com/javase/tutorial/essential/environment/cmdLineArgs.html。您也可以从 IntelliJ IDEA 中运行这些示例。

注意下一张截图左侧的两个绿色三角形。通过单击其中的任何一个,您可以执行main()方法。例如,让我们显示Hello, world!

为了做到这一点,请在main()方法中键入以下行:

System.out.println("Hello, world!");

以下截图显示了程序运行后的样子:

然后,点击一个绿色三角形,您应该在终端区域得到以下输出:

从现在开始,每次我们将讨论代码示例时,我们都会以相同的方式运行它们,即使用main()方法。在这样做的时候,我们不会捕获截图,而是将结果放在注释中,因为这种风格更容易跟随。例如,以下代码片段显示了之前的代码演示将以这种方式看起来:

System.out.println("Hello, world!"); //prints: Hello, world!

可以在代码行的右侧添加注释(任何文本),用双斜杠//分隔。编译器不会读取此文本,只是将其保持原样。注释的存在不会影响性能,并且用于向人类解释程序员的意图。

导入项目

我们将使用本书的源代码演示项目导入。我们假设您已在计算机上安装了 Maven(maven.apache.org/install.html)和 Git(gist.github.com/derhuerst/1b15ff4652a867391f03),并且可以使用它们。我们还假设您已安装了 JDK 17,正如在安装 Java SE部分所述。

要导入本书的代码示例项目,请按照以下步骤操作:

  1. 前往源代码库(github.com/PacktPublishing/Learn-Java-17-Programming),点击代码下拉菜单,如图所示图片

  2. 复制提供的统一资源定位符URL)(点击 URL 右侧的复制符号),如图所示:

图片

  1. 选择您希望在计算机上放置源代码的目录,然后运行git clone https://github.com/PacktPublishing/Learn-Java-17-Programming.git Git 命令,并观察以下截图所示的类似输出:

图片

  1. 创建了一个新的Learn-Java-17-Programming文件夹。

或者,您可以选择不克隆,而是使用截图所示的前一个“下载 ZIP”链接下载源代码作为.zip文件。在您希望在计算机上放置源代码的目录中解压缩下载的源代码,然后通过从其名称中移除-master后缀来重命名新创建的文件夹,确保文件夹的名称为Learn-Java-17-Programming

  1. 新的Learn-Java-17-Programming文件夹包含本书的所有源代码。如果您愿意,可以随意重命名此文件夹。在我们的例子中,我们将其重命名为LearnJava以简化。

  2. 现在,运行 IntelliJ IDEA,点击LearnJava(在我们的例子中),然后点击打开按钮。

  3. 如果在右下角出现以下弹出窗口,请点击加载

图片

  1. 此外,点击信任项目...,如图所示:

图片

  1. 然后,点击弹出的窗口中的信任项目按钮图片

  2. 现在,转到项目结构(右上角的齿轮符号)并确保已选择 Java 17 作为 SDK,如图所示:

图片

  1. 点击应用并确保默认的项目 SDK设置为 Java 版本 17项目语言级别设置为17,如图所示图片

  2. 通过选择LearnJava模块并点击"-",如下所示:

图片

  1. 在弹出的窗口中通过点击确认移除LearnJava模块,如下所示:

图片

  1. 最终模块列表应如下所示图片

在右下角点击确定按钮,然后返回到您的项目。在左侧面板中点击 examples,继续向下查看源树,直到您看到以下类列表:

图片

在右侧面板中点击绿色箭头并执行任何类中的main()方法。例如,让我们执行PrimitiveTypes类的main()方法。您将在运行窗口中看到的输出应该类似于以下内容:

图片

从命令行执行示例

要从命令行执行示例,请转到包含pom.xml文件的examples文件夹,并运行mvn clean package命令。如果命令执行成功,您可以从命令行运行examples文件夹中任何程序的main()方法。例如,要执行ControlFlow.java文件中的main()方法,请按以下命令执行:

java -cp target/examples-1.0-SNAPSHOT.jar   com.packt.learnjava.ch01_start.ControlFlow

您将看到以下结果:

图片

这样,您可以运行任何包含main()方法的类。main()方法的内容将被执行。

Java 原始类型和运算符

在所有主要的编程工具都已就绪的情况下,我们可以开始讨论 Java 作为一种语言。该语言的语法由Java 语言规范定义,您可以在docs.oracle.com/javase/specs找到它。不要犹豫,每次需要澄清时都参考它——它并不像许多人想象的那样令人畏惧。

Java 中的所有值分为两类:引用类型和原始类型。我们以原始类型和运算符作为任何编程语言的自然入口点。在本章中,我们还将讨论一个名为String的引用类型(见String 类型和字面量部分)。

所有原始类型可以分为两组:布尔类型和数值类型。

布尔类型

Java 中只有两个布尔类型值:truefalse。这样的值只能分配给boolean类型的变量,如下例所示:

boolean b = true;

boolean类型的变量通常用于控制流语句中,我们将在Java 语句部分讨论这些。以下是一个例子:

boolean b = x > 2;
if(b){ 
    //do something
}

在前面的代码中,我们将x > 2表达式的评估结果赋值给b变量。如果x的值大于2,则b变量将获得分配的值,true。然后,花括号{}内的代码将被执行。

数值类型

Java 数值类型分为两组:整数类型(bytecharshortintlong)和浮点类型(floatdouble)。

整数类型

整数类型消耗以下内存量:

  • byte: 8 位

  • char: 16 位

  • short: 16 位

  • int: 32 位

  • long: 64 位

char类型是一个无符号整数,可以存储一个值(称为码点),范围从 0 到 65,535(包含)。它表示一个 Unicode 字符,这意味着有 65,536 个 Unicode 字符。以下是基本拉丁字符集的三个记录:

图片

以下代码演示了char类型的属性(执行com.packt.learnjava.ch01_start.PrimitiveTypes类的main()方法——查看charType()方法):

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 常量中检索到每种原始类型的最大和最小值,如下所示(执行com.packt.learnjava.ch01_start.PrimitiveTypes类的main()方法——查看minMax()方法):

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)是使用类型转换运算符的示例。它强制将一个值从一种类型转换为另一种类型,在这种情况下,这种转换并不总是保证成功。正如您从我们的示例中看到的那样,某些类型允许比其他类型更大的值。但是,程序员可能知道某个变量的值永远不会超过目标类型的最大值,类型转换运算符是程序员强制其意见于编译器的方式。否则,如果没有类型转换运算符,编译器将引发错误,不允许赋值。然而,程序员可能会犯错误,值可能会变得更大。在这种情况下,执行时将引发运行时错误。

虽然原则上某些类型不能转换为其他类型,或者至少不能转换为所有类型——例如,布尔类型值不能转换为整型值。

浮点类型

在这个原始类型组中有两种类型——floatdouble。它们消耗以下内存量:

  • float: 32 位

  • double: 64 位

它们的正最大和最小可能值如下所示(执行com.packt.learnjava.ch01_start.PrimitiveTypes类的main()方法——查看minMax()方法):

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_VALUEDouble.MIN_VALUE的值不是最小值,而是相应类型的精度。对于每个浮点类型,零值可以是 0.0 或-0.0。

浮点类型的一个特殊特性是存在一个点 (.),它将数字的整数部分和小数部分分开。在 Java 中,默认情况下,带点的数字被认为是 double 类型。例如,以下被认为是 double 值:

42.3

这意味着以下赋值会导致编译错误:

float f = 42.3;

要表示您希望将其视为 float 类型,您需要添加 fF。例如,以下赋值不会导致错误(执行 com.packt.learnjava.ch01_start.PrimitiveTypes 类的 main() 方法——请参阅 casting() 方法):

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;

如您可能已从前面的示例中注意到,dD 表示 double 类型,但我们能够将它们转换为 float 类型,因为我们确信 42.3 在可能的 float 类型值范围内。

原始类型的默认值

在某些情况下,即使程序员不希望这样做,变量也必须被赋予一个值。我们将在 第二章Java 面向对象编程 (OOP) 中讨论此类情况。在这种情况下,原始类型的默认值如下概述:

  • byte, short, int, 和 long 类型具有默认值 0。

  • char 类型具有默认值 \u0000,代码点为 0。

  • floatdouble 类型具有默认值 0.0。

  • boolean 类型具有默认值 false

原始类型字面量

值的表示称为字面量。boolean 类型有两个字面量:truefalsebyteshortintlong 整数类型的字面量默认为 int 类型,如下所示:

byte b = 42;
short s = 42;
int i = 42;
long l = 42;

此外,为了表示 long 类型的字面量,您可以在末尾附加字母 lL,如下所示:

long l1 = 42l;
long l2 = 42L;

字母 l 容易与数字 1 混淆,因此使用 L(而不是 l)来表示此目的是一种良好的做法。

到目前为止,我们已使用十进制数系统表示整数字面量。同时,byteshortintlong 类型的字面量也可以用二进制(基数为 2,数字为 0-1)、八进制(基数为 8,数字为 0-7)和十六进制(基数为 16,数字为 0-9 和 a-f)数制表示。二进制字面量以 0b(或 0B)开头,后跟用二进制系统表示的值。例如,十进制的 42 表示为 101010 = 2⁰0 + 2¹1 + 2²0 + 2³ 1 + 2⁴ 0 + 2⁵ 1(我们从右边开始计数 0)。八进制字面量以 0 开头,后跟用八进制系统表示的值,因此 42 表示为 52 = 8⁰2+ 8¹5。十六进制字面量以 0x(或 0X)开头,后跟用十六进制系统表示的值。因此,42 表示为 2a = 16⁰a + 16¹2,因为在十六进制系统中,符号 af(或 AF)映射到十进制值 10 到 15。以下为演示代码(执行 com.packt.learnjava.ch01_start.PrimitiveTypes 类的 main() 方法——请参阅 literals() 方法):

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 转义 \u005c

在八个转义序列中,只有最后三个由符号表示。它们在无法以其他方式显示此符号时使用。例如,观察以下内容:

System.out.println("\"");   //prints: "
System.out.println('\'');   //prints: '
System.out.println('\\');   //prints: \

其余的更多用作控制代码,指导输出设备执行某些操作,如下例所示:

System.out.println("The back\bspace");
                                        //prints: The backspace
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 类以各种格式呈现数字。它还允许调整格式以适应提供的格式,包括区域设置。Java 12 中添加到此类的新功能称为紧凑或短数字格式。

它以区域特定、人类可读的形式表示数字。例如,观察以下内容(执行 com.packt.learnjava.ch01_start.PrimitiveTypes 类的 main() 方法——见 newNumberFormat() 方法):

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 个运算符。以下表格列出了它们:

图片

我们将不会描述不常使用的 &=, |=, ^=, <<=, >>=, >>>= 赋值运算符和位运算符,但你可以在 Java 规范中阅读有关它们的内容(docs.oracle.com/javase/specs)。箭头(->)和方法引用(::)运算符将在 第十四章Java 标准流 中描述。new 实例创建运算符、. 字段访问/方法调用运算符和 instanceof 类型比较运算符将在 第二章Java 面向对象编程 (OOP) 中讨论。至于类型转换运算符,我们已经在 整型 部分中描述过。

算术一元(+ 和 -)和二元(+、-、*、/ 和%)运算符

大多数算术运算符和正负号(一元运算符)对我们来说都很熟悉。取模运算符(%)将左操作数除以右操作数并返回余数,如下所示(执行 com.packt.learnjava.ch01_start.Operators 类的 main() 方法——见 integerDivision() 方法):

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。如果放在变量之前(前缀),则在返回变量值之前改变其值。但如果是放在变量之后(后缀),则在返回变量值之后改变其值。以下是一些示例(执行 com.packt.learnjava.ch01_start.Operators 类的 main() 方法——见 incrementDecrement() 方法):

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

等于运算符(== 和 !=)

== 运算符表示等于,而 != 运算符表示不等于。它们用于比较相同类型的值,如果操作数的值相等,则返回 true 布尔值,否则返回 false。例如,观察以下内容(执行 com.packt.learnjava.ch01_start.Operators 类的 main() 方法——见 equality() 方法):

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...,并且最终取决于精度实现(这是一个超出本书范围的主题)。

关系运算符(<、>、<= 和 >=)

关系操作符比较值并返回一个布尔值。观察以下示例,例如(执行 com.packt.learnjava.ch01_start.Operators 类的 main() 方法——见 relational() 方法):

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

下面是一个示例(执行 com.packt.learnjava.ch01_start.Operators 类的 main() 方法——见 logical() 方法):

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

条件操作符(&&、|| 和 ? 😃

&&|| 操作符产生与刚刚演示的 &| 逻辑操作符相同的结果,如下所示(执行 com.packt.learnjava.ch01_start.Operators 类的 main() 方法——见 conditional() 方法):

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 += 42x 赋值为 x = x + 42 加法操作的结果。

  • x -= 42x 赋值为 x = x - 42 减法操作的结果。

  • x *= 42x 赋值为 x = x * 42 乘法操作的结果。

  • x /= 42x 赋值为 x = x / 42 除法操作的结果。

  • x %= 42x 赋值为 x = x + x % 42 除法操作的余数。

这里是如何使用这些操作符的(执行 com.packt.learnjava.ch01_start.Operators 类的 main() 方法——见 assignment() 方法):

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运算符创建的。我们将在第二章Java 面向对象编程(OOP)中更详细地讨论类和对象。

在本章中,我们将讨论一种称为String的引用类型。它由java.lang.String类表示,正如你所看到的,它属于 JDK 最基础的包java.lang。我们之所以这么早引入String类,是因为它在某些方面与原始类型非常相似,尽管它是一个引用类型。

引用类型之所以被称为引用类型,是因为在代码中,我们并不直接处理这种类型的值。引用类型的值比原始类型的值更复杂。它被称为对象,需要更复杂的内存分配,因此引用类型变量包含一个内存引用。它指向(引用)对象所在的内存区域,因此得名。

当将引用类型变量作为参数传递给方法时,这种引用类型的特性需要特别注意。我们将在第三章Java 基础知识中更详细地讨论这个问题。现在,我们将看看String作为引用类型,如何通过只存储每个String值一次来帮助优化内存使用。

字符串字面量

String类在 Java 程序中表示字符字符串。我们已经看到了几个这样的字符串。例如,我们看到了Hello, world!。这是一个String字面量。

字面量的另一个例子是null。任何引用类型都可以引用null字面量。它表示一个不指向任何对象的引用值。对于String类型,它看起来是这样的:

String s = null;

但由双引号括起来的字面量(例如"abc""123",和"a42%$#")只能为String类型。在这方面,String类作为引用类型,与原始类型有共同之处。所有String字面量都存储在内存的一个专用部分,称为字符串池中,两个字面量如果拼写相同则表示池中的相同值(执行com.packt.learnjava.ch01_start.StringClass类的main()方法——参见compareReferences()方法):

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运算符创建)。这就是为什么当需要通过拼写(和大小写)比较两个字符串时,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类的其他方法。

另一个使字符串字面量和对象看起来像原始值的功能是,它们可以使用+算术运算符进行相加,如下所示(执行com.packt.learnjava.ch01_start.StringClass类的main()方法——查看operatorAdd()方法):

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 

不能将其他算术运算符应用于字符串字面量或对象。

Java 15 引入了一个新的字符串字面量,称为文本块。它便于保留缩进和换行,而不在引号中添加空白。例如,以下是程序员在 Java 15 之前如何添加缩进以及如何使用\n来换行的示例:

String html = "<html>\n" +
              "   <body>\n" +
              "       <p>Hello World.</p>\n" +
              "   </body>\n" +
              "</html>\n";

下面是如何使用 Java 15 实现相同结果的示例:

String html = """
               <html>
                   <body>
                       <p>Hello World.</p>
                   </body>
               </html>
              """;

要查看它是如何工作的,请执行com.packt.learnjava.ch01_start.StringClass类的main()方法——查看textBlock()方法。

字符串不可变性

由于所有字符串字面量都可以共享,JVM 的作者确保一旦存储,字符串变量就不能更改。这不仅有助于避免代码不同地方对同一值的并发修改问题,还能防止对字符串值(通常代表用户名或密码)的未授权修改。

以下代码看起来像是对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

如您所见,r1r2变量指向不同的内存,它们所指向的对象的拼写也不同。

我们将在第五章字符串、输入/输出和文件中更多地讨论字符串。

IDs 和变量

从我们的学校时代起,我们就对变量有一个直观的理解。我们把它看作是一个代表值的名称。我们使用像x加仑的水或n英里的距离这样的变量来解决问题,等等。在 Java 中,变量的名称被称为 ID,可以按照某些规则构建。使用 ID,可以声明(定义)并初始化变量。

ID

根据Java 语言规范(docs.oracle.com/javase/specs),ID(变量名)可以是一系列表示字母、数字 0-9、美元符号($)或下划线(_)的 Unicode 字符。

其他限制在此概述:

  • ID 的第一个符号不能是数字。

  • ID 不能与关键字有相同的拼写(参见第三章Java 基础)中的Java 关键字部分)。

  • 它不能被拼写为truefalse布尔字面量,也不能被拼写为null字面量。

  • 并且自从 Java 9 以来,ID 不能只是一个下划线(_)。

这里有一些不寻常但合法的 ID 示例:

$
_42
αρετη
String

变量声明(定义)和初始化

变量有一个名称(ID)和一个类型。通常,它指的是存储值的内存,但也可能什么也不指(即null)或者根本不指任何东西(那么,它就没有初始化)。它可以代表类属性、数组元素、方法参数和局部变量。最后一个是使用最频繁的变量类型。

在变量可以使用之前,它必须被声明和初始化。在其他一些编程语言中,变量也可以被定义,因此 Java 程序员有时使用单词定义作为声明的同义词,这并不完全正确。

这里有一个术语回顾和示例:

int x;      //declaration 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.;

var 类型持有者

在 Java 10 中,引入了一种类型持有者,varJava 语言规范这样定义它:“var 不是一个关键字,但是一个具有特殊意义的标识符,用作局部变量声明的类型。”

在实际应用中,它让编译器能够确定声明的变量的性质,如下所示(参见com.packt.learnjava.ch01_start.PrimitiveTypes类中的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 语句类型列表:

  • 一个只包含一个符号(;,分号)的空语句

  • 一个类或接口声明语句(我们将在第二章Java 面向对象编程(OOP))中讨论这个问题。

  • 一个局部变量声明语句:int x;

  • 一个同步语句:这超出了本书的范围

  • 一个表达式语句

  • 一个控制流语句

一个表达式语句可以是以下之一:

  • 一个方法调用语句:someMethod();

  • 一个赋值语句:n = 23.42f;

  • 一个对象创建语句:new String("abc");

  • 一个一元增量或减量语句:++x;或--x;或x++;或x--;

我们将在表达式语句部分更多地讨论表达式语句。

一个控制流语句可以是以下之一:

  • 一个选择语句:if-elseswitch-case

  • 一个迭代语句:forwhiledo-while

  • 一个异常处理语句:throwtry-catchtry-catch-finally

  • 一个分支语句:breakcontinuereturn

我们将在控制流语句部分更多地讨论控制语句。

表达式语句

一个表达式语句由一个或多个表达式组成。一个表达式通常包括一个或多个运算符。它可以被评估,这意味着它可以产生以下类型之一的结果:

  • 一个变量:例如x = 1

  • 一个值:例如2*2

当表达式是一个返回void的方法调用时,它不返回任何内容。这样的方法被称为只产生副作用:例如void someMethod()

考虑以下表达式:

x = y++; 

前面的表达式将一个值赋给x变量,并且副作用是将y变量的值增加 1。

另一个例子是一个打印行的方法,如下所示:

System.out.println(x); 

println()方法不返回任何内容,并且有一个打印某些内容的副作用。

从其形式来看,一个表达式可以是以下之一:

  • 一个原始表达式:一个字面量、一个新的对象创建、一个字段或方法访问(调用)。

  • 一个一元运算符表达式:例如x++

  • 一个二元运算符表达式:例如x*y

  • 三元运算符表达式:例如 x > y ? true : false

  • 一个 lambda 表达式:x -> x + 1(见第十四章Java 标准流)。

  • 如果一个表达式由其他表达式组成,通常使用括号来清楚地标识每个表达式。这样,更容易理解并设置表达式的优先级。

控制流语句

当 Java 程序执行时,它是逐语句执行的。一些语句必须根据表达式评估的结果有条件地执行。这类语句被称为控制流语句,因为在计算机科学中,控制流(或控制流程)是指单个语句执行或评估的顺序。

控制流语句可以是以下之一:

  • 选择语句:if-elseswitch-case

  • 一个迭代语句:forwhiledo-while

  • 一个异常处理语句:throwtry-catchtry-catch-finally

  • 一个分支语句:breakcontinuereturn

选择语句

选择语句基于表达式评估,有四种变体,如下概述:

  • if (表达式)

  • if (表达式) {执行某些操作} else

  • if (表达式) {执行某些操作} else if {执行其他操作} 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 语句。否则,将执行所有后续的情况。

在 Java 14 中,引入了一种新的 switch...case 语句,其形式更为简洁,如下所示:

void switchStatement(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 语句。

执行 com.packt.learnjava.ch01_start.ControlFlow 类的 main() 方法——查看调用 switchStatement() 方法并传递不同参数的 selection() 方法,如下所示:

switchStatement(1);    //prints: 1 or 3: 1
switchStatement(2);    //prints: Not 1,3,4,5,6: 2
switchStatement(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");
}

Java 14 的 switch...case 语句甚至可以返回一个值,从而实际上成为了一个 switch 表达式。例如,以下是一个基于 switch...case 语句结果来分配另一个变量的情况:

void switchExpression1(int i){
    boolean b = switch(i) {
        case 0, 1 -> false;
        case 2 -> true;
        default -> false;
    };
    System.out.println(b);
}

如果我们执行 switchExpression1() 方法(请参阅 com.packt.learnjava.ch01_start.ControlFlow 类的 selection() 方法),结果将如下所示:

switchExpression1(0);    //prints: false
switchExpression1(1);    //prints: false
switchExpression1(2);    //prints: true

以下 switch 表达式的示例基于一个常量:

static final String ONE = "one", TWO = "two", THREE = "three", 
                    FOUR = "four", FIVE = "five";
void switchExpression2(String number){
    var res = switch(number) {
        case ONE, TWO -> 1;
        case THREE, FOUR, FIVE -> 2;
        default -> 3;
    };
    System.out.println(res);
}

如果我们执行 switchExpression2() 方法(请参阅 com.packt.learnjava.ch01_start.ControlFlow 类的 selection() 方法),结果将如下所示:

switchExpression2(TWO);            //prints: 1
switchExpression2(FOUR);           //prints: 2
switchExpression2("blah");         //prints: 3

这里是另一个 switch 表达式的示例,这次基于 enum 值:

enum Num { ONE, TWO, THREE, FOUR, FIVE }
void switchExpression3(Num number){
    var res = switch(number) {
        case ONE, TWO -> 1;
        case THREE, FOUR, FIVE -> 2;
    };
    System.out.println(res);
}

如果我们执行 switchExpression3() 方法(请参阅 com.packt.learnjava.ch01_start.ControlFlow 类的 selection() 方法),结果将看起来像这样:

switchExpression3(Num.TWO);        //prints: 1
switchExpression3(Num.FOUR);       //prints: 2
//switchExpression3("blah"); //does not compile

如果需要根据特定的输入值执行一段代码块,则不能使用 return 语句,因为它已经被保留用于从方法返回值。这就是为什么,要从代码块返回一个值,我们必须使用 yield 语句,如下面的示例所示:

void switchExpression4(Num number){
    var res = switch(number) {
        case ONE, TWO -> 1;
        case THREE, FOUR, FIVE -> {
            String s = number.name();
            yield s.length();
        }
    };
    System.out.println(res);
}

如果我们执行 switchExpression4() 方法(请参阅 com.packt.learnjava.ch01_start.ControlFlow 类的 selection() 方法),结果将看起来像这样:

switchExpression4(Num.TWO);        //prints: 1
switchExpression4(Num.THREE);      //prints: 5

迭代语句

迭代语句可以采取以下三种形式之一:

  • while 语句

  • do...while 语句

  • for 语句,也称为循环语句

while 语句看起来是这样的:

while (boolean expression){
      //do something
}

下面是一个具体的示例(执行 com.packt.learnjava.ch01_start.ControlFlow 类的 main() 方法——请参阅 iteration() 方法):

int n = 0;
while(n < 5){
 System.out.print(n + " "); //prints: 0 1 2 3 4 
 n++;
}

在某些示例中,我们使用 print() 方法而不是 println() 方法,print() 方法不会换行(不会在其输出末尾添加换行控制)。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 nothing
    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 语句的工作方式:

  1. init 语句初始化一个变量。

  2. 使用当前变量的值评估布尔表达式:如果为 true,则执行语句块;否则,for 语句退出。

  3. update 语句更新变量,然后使用这个新值再次评估布尔表达式:如果为 true,则执行语句块;否则,for 语句退出。

  4. 除非退出,否则最后一步会重复。

如您所见,如果不小心,可能会进入无限循环:

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
}

以下示例演示了多个初始化和 update 语句:

for (int x = 0, y = 0; x < 3 && y < 3; ++x, ++y){
    System.out.println(x + " " + y);
}

以下是前面代码的变体,用于演示目的:

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
}

我们将在第六章数据结构、泛型和常用工具中讨论集合。

异常处理语句

在 Java 中,有一些称为异常的类,它们代表中断正常执行流程的事件。它们通常以Exception结尾:NullPointerExceptionClassCastExceptionArrayIndexOutOfBoundsException等,仅举几例。

所有的异常类都扩展了java.lang.Exception类,该类反过来又扩展了java.lang.Throwable类(我们将在第二章Java 面向对象编程(OOP)中解释这意味着什么)。这就是为什么所有异常对象都有共同的行为。它们包含有关异常条件的原因及其起源位置(源代码的行号)的信息。

每个异常对象都可以由 JVM 或使用throw关键字的应用代码自动生成(抛出)。如果代码块抛出异常,可以使用try-catchtry-catch-finally结构来捕获抛出的异常对象,并将执行流程重定向到另一段代码。如果周围的代码没有捕获异常对象,它将一直传播到应用程序中的 JVM,并强制其退出(并中止应用程序执行)。因此,在可能抛出异常且不希望应用程序中止执行的所有地方使用try-catchtry-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,则不会执行正常的处理流程。相反,将执行do what has to be done块。但是,在x <= 10的情况下,将运行正常的处理流程块,而do what has to be done块将被忽略。

有时,无论是否抛出/捕获异常,都需要执行一段代码。而不是在两个地方重复相同的代码块,可以将它放在finally块中,如下所示(执行com.packt.learnjava.ch01_start.ControlFlow类的main()方法——请参阅exception()方法):

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
}

我们将在第四章异常处理中更详细地讨论异常处理。

分支语句

分支语句允许中断当前执行流程,并从当前块之后的第一个语句或控制流程的某个(标记的)点继续执行。

分支语句可以是以下之一:

  • break

  • continue

  • return

我们已经看到了breakswitch-case语句中的应用。这里还有一个例子(执行com.packt.learnjava.ch01_start.ControlFlow类的main()方法——请参阅branching()方法):

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" 的列表元素,我们可以在 s.contains("3") 条件评估为 true 时立即停止执行。剩余的列表元素将被忽略。

在更复杂的场景中,有嵌套的 for 循环时,可以设置一个标签(使用 a : column),指明哪个 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 语句,每个 return 语句在不同的环境下返回不同的值。如果方法不返回任何内容(void),则不需要 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;
    }
}

通过运行 com.packt.learnjava.ch01_start.ControlFlow 类的 main() 方法来执行 returnDemo() 方法(请参阅 branching() 方法)。结果将如下所示:

String r = returnDemo(3);
System.out.println(r);      //prints: Not enough
r = returnDemo(10);
System.out.println(r);      //prints: Exactly right 
r = returnDemo(12);
System.out.println(r);      //prints: More than enough

语句是 Java 编程的构建块。它们就像英语中的句子——可以执行的动作的完整意图表达。它们可以被编译和执行。编程就像在语句中表达一个行动计划。

通过这种方式,Java 基础的讲解就结束了。恭喜您完成了这一过程!

摘要

本章向您介绍了激动人心的 Java 编程世界。我们首先解释了主要术语,然后解释了如何安装必要的工具——JDK 和 IDE——以及如何配置和使用它们。

在建立开发环境后,我们向读者提供了 Java 作为编程语言的基础知识。我们描述了 Java 原始类型、String 类型及其字面量。我们还定义了什么是 ID 以及什么是变量,并以 Java 语句的主要类型结束,所有讨论点都通过具体的代码示例进行了说明。

在下一章中,我们将讨论 final 关键字。

测验

  1. JDK 代表什么?

    1. Java 文档克洛诺斯

    2. 六月开发空手道

    3. Java 开发工具包

    4. Java 开发工具包

  2. JCL 代表什么?

    1. Java 古典库

    2. Java 类库

    3. 初级古典自由

    4. Java 类库

  3. Java SE 代表什么?

    1. Java 高级版

    2. Java 星版

    3. Java 结构选举

    4. Java 标准版

  4. IDE 代表什么?

    1. 初始开发版

    2. 集成开发环境

    3. 国际开发版

    4. 集成开发版

  5. Maven 的功能有哪些?

    1. 项目构建

    2. 项目配置

    3. 项目文档

    4. 项目取消

  6. 以下哪些是 Java 原始类型?

    1. 布尔

    2. 数值

    3. 整数

    4. 字符串

  7. 以下哪些是 Java 数值类型?

    1. 字节

  8. 什么是 字面量

    1. 基于字母的字符串

    2. 基于数字的字符串

    3. 变量的表示

    4. 值的表示

  9. 以下哪些是字面量?

    1. \\

    2. 2_0

    3. 2__0f

    4. \f

  10. 以下哪些是 Java 运算符?

    1. %

    2. $

    3. &

    4. ->

  11. 以下代码片段打印什么?

    int i = 0; System.out.println(i++);
    
    1. 0

    2. 1

    3. 2

    4. 3

  12. 以下代码片段打印什么?

    boolean b1 = true;
     boolean b2 = false;
     System.out.println((b1 & b2) + " " + (b1 && b2));
    
    1. false true

    2. false false

    3. true false

    4. true true

  13. 以下代码片段打印什么?

    int x = 10;
     x %= 6;
     System.out.println(x);
    
    1. 1

    2. 2

    3. 3

    4. 4

  14. 以下代码片段的结果是什么?

    System.out.println("abc" - "bc");
    
    1. a

    2. abc-bc

    3. 编译错误

    4. 执行错误

  15. 以下代码片段打印什么?

    System.out.println("A".repeat(3).lastIndexOf("A"));
    
    1. 1

    2. 2

    3. 3

    4. 4

  16. 以下哪些是正确的 ID?

    1. int __ (两个下划线)

    2. 2a

    3. a2

    4. $

  17. 以下代码片段打印什么?

    for (int i=20, j=-1; i < 23 && j < 0; ++i, ++j){
             System.out.println(i + " " + j + " ");
     }
    
    1. 20 -1 21 0

    2. 无限循环

    3. 21 0

    4. 20 -1

  18. 以下代码片段打印什么?

    int x = 10;
    try {
        if(x++ > 10){
            throw new RuntimeException("The x value is out of the range: " + x);
        }
        System.out.println("The x value is within the range: " + x);
    } catch (RuntimeException ex) {
        System.out.println(ex.getMessage());
    }
    
    1. 编译错误

    2. x的值超出范围:11

    3. x的值在范围内:11

    4. 执行时间错误

  19. 以下代码片段打印什么?

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);
  1. result = 22

  2. result = 23

  3. result = 32

  4. result = 33

  5. 选择以下所有正确的语句:

    1. 变量可以被声明。

    2. 变量可以被赋值。

    3. 变量可以被定义。

    4. 变量可以被确定。

  6. 从以下选项中选择所有正确的 Java 语句类型:

    1. 一个可执行语句

    2. 选择语句

    3. 方法结束语句

    4. 增量语句

第二章:Java 面向对象编程(OOP)

面向对象编程OOP)的诞生源于更好地控制共享数据并发修改的必要性,这是面向对象编程之前的诅咒。这个想法的核心不是允许直接访问数据,而是只通过一个专门的代码层来访问。由于数据需要在过程中传递和修改,因此产生了对象的概念。在最一般的意义上,一个对象是一组可以传递和通过传递的方法访问的数据。这些数据被称为对象状态,而方法构成了对象行为。对象状态被隐藏(封装)以防止直接访问。

每个对象都是基于称为的特定模板构建的。换句话说,类定义了一类对象。每个对象都有一个特定的接口,这是其他对象如何与之交互的正式定义。最初,一个对象会通过调用另一个对象的方法来向另一个对象发送消息。但这个术语并不适用,尤其是在引入了基于消息的协议和系统之后。

为了避免代码重复,引入了对象之间的父子关系——一个类可以从另一个类继承行为。在这种关系中,第一个类被称为子类子类,而第二个类被称为父类基类超类

之间定义了另一种形式的关系——一个类可以实现一个接口。由于接口描述了如何与对象交互,但没有描述对象如何响应交互,因此不同的对象在实现相同接口时可以表现出不同的行为。

在 Java 中,一个类只能有一个直接父类,但可以实现多个接口。

能够像其任何祖先一样表现并遵守多个接口的能力称为多态性

在本章中,我们将探讨这些 OOP 概念以及它们如何在 Java 中实现。讨论的主题包括以下内容:

  • 面向对象编程(OOP)概念

  • 接口

  • 超载、重写和隐藏

  • 最终变量、方法和类

  • 记录和密封类

  • 多态性实例

技术要求

要能够执行本章提供的代码示例,您需要以下条件:

  • 配有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高

  • 您偏好的 IDE 或代码编辑器

本书第一章,“Java 17 入门”,提供了如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明。本章的代码示例文件可在 GitHub 仓库github.com/PacktPublishing/Learn-Java-17-Programming.gitexamples/src/main/java/com/packt/learnjava/ch02_oop文件夹中找到。

面向对象编程概念

如我们在引言中已经所述,主要的面向对象编程(OOP)概念如下:

  • :这定义了基于此类的对象的属性和行为(方法)。

  • 对象:这定义了状态(数据)为其属性的值,添加了从类中取出的行为(方法),并将它们组合在一起。

  • 继承:这通过父-child 关系将行为传播到类链中。

  • 接口:这描述了如何访问对象数据和行为。它将对象的(外观)与其实现(行为)隔离开来(抽象化)。

  • 封装:这隐藏了状态和实现的细节。

  • 多态:这允许对象假设实现接口的外观并表现出任何祖先类的行为。

对象/类

从原则上讲,你可以通过最小化使用类和对象来创建一个非常强大的应用程序。在 Java 8 中添加了函数式编程之后,这变得更加容易,因为 JDK 允许你将行为作为一个函数传递。然而,传递数据(状态)仍然需要类/对象。这意味着 Java 作为面向对象编程语言的地位仍然保持不变。

一个类定义了所有内部对象属性的类型,这些属性持有对象状态。一个类还定义了由方法代码表达的对象行为。可能存在没有状态或行为的类/对象。Java 还有一个静态访问行为的条款——无需创建对象。但这些可能性不过是向为了保持状态和行为在一起而引入的对象/类概念中添加的补充。

为了说明这个概念,例如,一个Vehicle类在原则上定义了车辆的性质和行为。让我们使模型简单化,并假设一个车辆只有两个属性——重量和一定功率的引擎。它还可以具有某种行为——在特定时间内达到一定的速度,这取决于其两个属性的价值。这种行为可以通过一个方法来表达,该方法计算车辆在特定时间内可以达到的速度。Vehicle类的每个对象都将具有特定的状态(其属性值)和速度计算将在相同的时间段内产生不同的速度。

所有的 Java 代码都包含在方法中。方法是一组具有(可选的)输入参数并返回一个值(也是可选的)的语句集合。此外,每个方法都可以有副作用——它可以显示一条消息或将数据写入数据库,例如。类/对象的行为是在方法中实现的。

要遵循我们的示例,速度计算可以放在一个double calculateSpeed(float seconds)方法中,例如。正如你可以猜到的,方法的名字是calculateSpeed。它接受一个带有小数部分的秒数作为参数,并返回速度值作为double类型。

继承

正如我们已经提到的,对象可以通过这种方式建立父子关系并共享属性和行为。例如,我们可以创建一个继承自Vehicle类的Car类,继承其属性(例如重量)和行为(例如速度计算)。此外,类可以有自己的属性(例如乘客数量)和特定于汽车的行为(例如软冲击吸收)。但是,如果我们创建一个作为车辆子类的Truck类,它的附加特定于卡车的属性(例如载重)和行为(例如硬冲击吸收)将是不同的。

据说CarTruck类的每个对象都有一个Vehicle类的父对象。但是CarTruck类的对象并不共享特定的Vehicle对象(每次创建子对象时,首先创建一个新的父对象)。它们只共享父的行为。这就是为什么所有子对象都可以有相同的行为但不同的状态。这是实现代码重用的一种方式,但当对象行为需要动态变化时,可能不够灵活。在这种情况下,对象组合(从其他类引入行为)或函数式编程更为合适(参见第十三章函数式编程)。

有可能使子对象的行为与继承的行为不同。为了实现这一点,捕获该行为的可以在类中重新实现该方法。据说子对象可以重写继承的行为。我们将在稍后解释如何做到这一点(参见重载、重写和隐藏部分)。例如,如果Car类有自己的速度计算方法,则Vehicle父类的相应方法不会被继承,而是使用在类中实现的新的速度计算方法。

父类属性也可以被继承(但不能被重写)。然而,类属性通常声明为私有;它们不能被继承——这就是封装的目的。请参阅第三章中关于各种访问级别——publicprotecteddefaultprivate——的描述,访问修饰符部分。

如果父类从另一个类继承了一些行为,那么 child 类也会获得(继承)这种行为,除非,当然,父类重写了它。继承链的长度没有限制。

Java 中使用 extends 关键字表达父子关系:

class A { }
class B extends A { }
class C extends B { }
class D extends C { }

在此代码中,ABCD 类有以下关系:

  • D 类继承自 ABC 类。

  • C 类继承自 AB 类。

  • B 类继承自 A 类。

A 类的所有非私有方法都被 BCD 类继承(如果未被重写)。

B 类的所有非私有方法都被 CD 类继承(如果未被重写)。

C 类的所有非私有方法都被 D 类继承(如果未被重写)。

抽象/接口

方法名称及其参数类型列表被称为 CarTruck(在我们的例子中),可以访问。这样的描述与 return 类型一起呈现为一个接口。它并没有说关于执行计算的代码——只关于方法名称、参数类型、它们在参数列表中的位置以及结果类型。所有实现细节都被隐藏(封装)在实现此接口的类中。

如我们之前提到的,一个类可以实现许多不同的接口。但即使两个不同的类(及其对象)实现了相同的接口,它们的行为也可能不同。

类似于类,接口也可以使用 extends 关键字建立父子关系:

interface A { }
interface B extends A {}
interface C extends B {}
interface D extends C {}

在此代码中,ABCD 接口有以下关系:

  • D 接口继承自 ABC 接口。

  • C 接口继承自 AB 接口。

  • B 接口继承自 A 接口。

A 接口的所有非私有方法都被 BCD 接口继承。

B 接口的所有非私有方法都被 CD 接口继承。

C 接口的所有非私有方法都被 D 接口继承。

抽象/接口还可以减少代码不同部分之间的依赖性,从而提高其可维护性。只要接口保持不变,每个类都可以更改,而无需与其客户端协调。

封装

封装通常被定义为数据隐藏或一组公开可访问的方法和私有可访问的数据。在广义上,封装是对对象属性访问的控制。

对象属性值的快照称为 对象状态。这是封装的数据。因此,封装解决了推动面向对象编程创建的主要问题——更好地管理对共享数据的并发访问,例如以下内容:

class A {
  private String prop = "init value";
  public void setProp(String value){
     prop = value;
  }
  public String getProp(){
     return prop;
  }
}

如您所见,由于private访问修饰符,我们不能直接访问prop属性的值来读取或修改它。相反,我们只能通过setProp(String value)getProp()方法来执行它。

多态

多态是对象以不同类或不同接口的实现的行为的能力。它归功于之前提到的所有概念——继承、接口和封装。没有它们,多态将不可能存在。

继承允许一个对象获取或覆盖其所有祖先的行为。接口隐藏了实现它的类的名称。封装防止暴露对象状态。

在接下来的章节中,我们将演示所有这些概念的实际应用,并在多态的实际应用部分查看多态的具体用法。

Java 程序是一系列表达可执行动作的语句。这些语句组织在方法中,方法组织在类中。一个或多个类存储在.java文件中。它们可以通过javac Java 编译器编译(从 Java 语言转换为字节码),并存储在.class文件中。每个.class文件只包含一个编译后的类,并且可以被 JVM 执行。

java命令启动 JVM 并告诉它哪个类是main类,即具有名为main()的方法的类。main方法有特定的声明——它必须是public static,必须返回void,名称为main,并接受一个String类型数组的单个参数。

JVM 将主类加载到内存中,找到main()方法,并逐句执行它。java命令还可以传递参数(参数)给main()方法,这些参数作为String值数组的参数接收。如果 JVM 遇到需要执行另一个类中方法的语句,那么该类(其.class文件)也会被加载到内存中,并执行相应的方法。因此,Java 程序流程完全是关于加载类和执行它们的方法。

这里是main类的一个示例:

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 关键字使得值仅可以从类内部,从其方法中访问。public 关键字使得属性或方法可以被任何其他类访问。

方法

正如我们已经提到的,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 中数组是什么。

数组是一种数据结构,它包含相同类型的元素。元素通过一个数值索引来引用。这就是我们现在需要知道的所有内容。我们将在第六章数据结构、泛型和常用工具中更详细地讨论数组。

让我们从例子开始。让我们使用 varargs 声明方法参数:

String someMethod(String s, int i, double... arr){
 //statements that compose method body
}

当调用 someMethod 方法时,Java 编译器从左到右匹配参数。一旦它到达最后一个 varargs 参数,它就会创建一个剩余参数的数组并将其传递给方法。以下是一个演示代码:

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;
}

如您所见,varargs 参数类似于指定类型的数组。它可以作为方法的最后一个或唯一参数列出。这就是为什么有时您会看到像前面示例中那样的 main 方法被声明。

构造函数

当创建一个对象时,JVM 为整型使用 0,为浮点型使用 0.0,为布尔型使用 false。对于其他 Java 引用类型(见第三章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的对象时,它将被设置为int类型的默认值0。另外,请注意,anotherProp属性被显式初始化为值"abc"。否则,它将被初始化为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 constructor
        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运算符通过为新对象的属性分配内存来创建一个类的对象(也可以说它是实例化一个类创建一个类的实例),并返回对该内存的引用。这个内存引用被分配给一个与创建对象的类或其父类相同类型的变量:

TheChildClass ref1 = new TheChildClass("something"); 
TheParentClass ref2 = new TheChildClass("something");

这里有一个有趣的观察。在代码中,ref1ref2这两个对象引用都可以访问TheChildClassTheParentClass的方法。例如,我们可以向这些类添加方法,如下所示:

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的对象就不再可访问了。这就是垃圾收集器所注意到的,它会释放这个对象占用的内存。我们将在第九章中讨论垃圾收集过程,JVM 结构和垃圾收集

java.lang.Object

在 Java 中,所有类默认都是Object类的子类,即使你没有显式地指定。Object类声明在标准 JDK 库的java.lang包中。我们将在包、导入和访问部分定义什么是,并在第七章中描述库,Java 标准库和外部库

让我们回顾一下我们在继承部分提供的示例:

class A { }
class B extends A {}
class C extends B {}
class D extends C {}

所有类,ABCD,都是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 的服务中。

我们将在第六章中更详细地讨论hashCode()equals()方法,数据结构、泛型和常用工具

getClass()clone()方法使用得不太频繁。getClass()方法返回一个Class类的对象,该对象有许多提供各种系统信息的方法。最常用的方法是返回当前对象类名的那个方法。clone()方法可以用来复制当前对象。只要当前对象的所有属性都是基本类型,它就能正常工作。但是,如果存在引用类型属性,clone()方法必须被重新实现,以便正确地复制引用类型。否则,只会复制引用,而不是对象本身。这种复制被称为protected关键字表示只有类的子类可以访问它。请参阅包、导入和访问部分。

Object的最后一个五个方法用于线程之间的通信——轻量级进程用于并发处理。它们通常不会被重新实现。

实例和静态属性和方法

到目前为止,我们主要看到的方法只能在一个类的(实例)对象上调用。这类方法被称为静态方法,并且可以在不创建对象的情况下调用。一个这样的方法的例子是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";
}

注意静态属性前面的final关键字。它告诉编译器和 JVM,这个值一旦分配,就不能改变。尝试这样做会生成错误。它有助于保护值并清楚地表达将此值作为常量的意图。当人类试图理解代码的工作方式时,这样的看似微小的细节使代码更容易理解。

话虽如此,考虑使用接口来达到这样的目的。自从 Java 1.8 以来,接口中声明的所有字段都是隐式静态和最终的,因此你忘记声明一个值是最终的几率更小。我们很快就会谈到接口。

当一个对象被声明为静态最终类属性时,并不意味着它的所有属性都会自动变为最终。它只保护属性不被分配相同类型的另一个对象。我们将在第八章中讨论对象属性并发访问的复杂过程,多线程和并发处理。尽管如此,程序员经常使用静态最终对象来存储只通过它们在应用程序中的使用方式读取的值。一个典型的例子就是应用程序配置信息。一旦从磁盘读取后创建,即使可以改变,也不会改变。此外,从外部资源获取的数据缓存。

再次提醒,在将此类属性用于此目的之前,考虑使用提供更多默认行为以支持只读功能的接口。

与静态属性类似,静态方法可以在不创建类实例的情况下调用。例如,考虑以下类:

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(); 

如果接口的所有抽象方法都没有被实现,则该类必须被声明为抽象类,并且不能被实例化。请参阅接口与抽象类部分。

接口不描述如何创建类的对象。要发现这一点,您必须查看类并查看它有哪些构造函数。接口也不描述静态类方法。因此,接口只是类实例(对象)的公共面孔。

在 Java 8 中,接口不仅获得了具有抽象方法(没有方法体)的能力,还获得了真正实现的方法的能力。根据 Java 语言规范,“接口的主体可以声明接口的成员,即字段、方法、类和接口。”这样的广泛声明引发了一个问题,接口和类之间的区别是什么?我们已经指出的一个主要区别是——接口不能被实例化;只有类可以被实例化。

另一个区别是,接口内部实现的非静态方法被声明为defaultprivate。相比之下,类方法中不可用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"
System.out.println(sc.method2(22)); //prints: abc
sc.method3();    //returns: 42
System.out.println(sc.method3());   //prints: 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
System.out.println(sc.method3());      //prints: 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;
    }
}

这种私有方法的概念与类中的私有方法没有区别(参见包、导入和访问部分)。私有方法不能从接口外部访问。

静态字段和方法

自 Java 8 以来,接口中声明的所有字段都是隐式公共的、静态的和最终的常量。这就是为什么接口是常量的首选位置。你不需要在它们的声明中添加public static final

至于静态方法,它们在接口中的功能与在类中相同:

interface SomeInterface{
   static String someMethod() {
      return "abc";
   }
}

注意,没有必要将接口方法标记为public。所有非私有接口方法默认都是公共的。

我们可以使用接口名称来调用前面的方法:

System.out.println(SomeInetrface.someMethod()); //prints: abc

接口与抽象类

我们已经提到,一个类可以被声明为abstract。它可能是一个我们不希望实例化的常规类,或者它可能是一个包含(或继承)抽象方法的类。在后一种情况下,我们必须将此类声明为abstract以避免编译错误。

在许多方面,抽象类与接口非常相似。它强制每个扩展它的类实现抽象方法。否则,子类无法实例化,必须将其声明为抽象。

然而,接口与抽象类之间的一些主要差异使它们在不同的场景中都有用:

  • 抽象类可以有构造函数,而接口则不能。

  • 抽象类可以有状态,而接口则不能。

  • 抽象类的字段可以是publicprivateprotected,可以是static也可以不是,可以是final也可以不是,而在接口中,字段始终是publicstaticfinal

  • 抽象类中的方法可以是publicprivateprotected,而接口方法只能是publicprivate

  • 如果你想要修改的类已经扩展了另一个类,你不能使用抽象类,但你可以实现一个接口,因为一个类只能扩展一个其他类,但可以实现多个接口。

你将在多态的实际应用部分看到一个抽象使用的例子。

资重载、覆盖和隐藏

我们已经在继承抽象/接口章节中提到了覆盖。它是在父类中实现的非静态方法与子类中具有相同签名的方法的替换。接口的默认方法也可以在扩展它的接口中被覆盖。隐藏与覆盖类似,但仅适用于静态方法和静态属性以及实例属性。

赋值重载是指在同一个类或接口中创建具有相同名称但参数不同(因此,签名不同)的多个方法。

在本节中,我们将讨论所有这些概念,并演示它们在类和接口中的应用方式。

赋值重载

在同一个接口或类中不可能有两个具有相同签名的不同方法。为了有不同的签名,新方法必须具有新的名称或不同的参数类型列表(参数类型的顺序也很重要)。具有相同名称但参数类型列表不同的两个方法构成了赋值重载。以下是一些在接口中合法的赋值重载方法的示例:

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; }
}

注意,前面提到的任何两种方法都没有相同的签名,包括默认和静态方法。否则,编译器将生成错误。无论是默认还是静态的指定都不会影响赋值重载。返回类型也不会影响赋值重载。我们在这里使用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 method(){
        System.out.println("interface B");
    }
}

如果发生这种情况,编译器会生成一个错误,因为没有metod()方法可以覆盖。如果没有@Override注解,程序员可能不会注意到这个错误,结果会完全不同:

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 中的常量是一个变量,一旦初始化,就不能重新分配另一个值。接口字段默认是常量,因为接口中的任何字段都是final(参见“最终属性、方法和类”部分)。

如果我们从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;
    }
}

如果我们对实例属性运行与CD类相同的测试,结果将是这样的:

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关键字来访问,这意味着当前对象

final变量、方法和类

我们多次提到final属性与 Java 中常量的概念相关,但这只是使用final关键字的一个例子。它可以应用于任何变量。类似地,可以将类似约束应用于方法甚至类,从而防止方法被重写和类被扩展。

final变量

在变量声明前放置final关键字使得该变量在初始化后不可变,例如以下所示:

final String s = "abc";

初始化甚至可以延迟:

final String s;
s = "abc";

对于object属性,这种延迟只能持续到对象被创建。这意味着属性可以在构造函数中初始化,如下所示:

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";
    }
}

在接口中,所有字段始终是最终的,即使它们没有被声明为最终。由于接口中不允许有构造函数或初始化块,初始化接口字段的唯一方法是在声明期间。未能这样做会导致编译错误:

interface I {
    String s1;  //error
    String s2 = "abc";
}

最终方法

声明为final的方法不能在类中重写,或者在静态方法的情况下隐藏。例如,Java 中所有类的祖先java.lang.Object类,其中一些方法被声明为final

public final Class getClass()x
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。这个特性用于安全或当程序员想要确保类功能不能因为其他设计考虑而被重写、覆盖或隐藏。

记录类

record类是在 Java 16 中添加到 SDK 的。这是一个长期期待的功能。它允许你在需要不可变类(只有 getters)的情况下避免编写样板代码,如下面的Person类所示(请参阅ch02_oop文件夹中的Record类):

final class Person {
    private int age;
    private String name;
    public Person(int age, String name) {
        this.age = age;
        this.name = name;
    }
    public int age() { return age; }
    public String name() { return name; }
    @Override
    public boolean equals(Object o) {
        //implementation not shown for brevity
    }
    @Override
    public int hashCode() {
        //implementation not shown for brevity
    }
    @Override
    public String toString() {
        //implementation not shown for brevity
    }

请注意,前面上面的 getters 没有get前缀。这是故意为之,因为在不可变类的情况下,没有必要区分 getters 和 setters,因为如果我们想使类真正不可变,那么 setters 既不应该也不应该存在。这就是此类与 JavaBeans 之间的主要区别,JavaBeans 是可变的,既有 setters 也有 getters。

record类允许你只用一行代码替换前面的实现:

record Person(int age, String name){}

我们可以用以下代码来演示:

record PersonR(int age, String name){} //We added suffix "R" 
                 //to distinguish this class from class Person
Person person = new Person(25, "Bill");
System.out.println(person);  
                          //prints: Person{age=25, name='Bill'}
System.out.println(person.name());            //prints: Bill
Person person1 = new Person(25, "Bill");
System.out.println(person.equals(person1));   //prints: true
PersonR personR = new PersonR(25, "Bill");
System.out.println(personR);   
                         //prints: PersonR{age=25, name='Bill'}
System.out.println(personR.name());           //prints: Bill
PersonR personR1 = new PersonR(25, "Bill");
System.out.println(personR.equals(personR1)); //prints: true
System.out.println(personR.equals(person));   //prints: false

除了是final(不可扩展)和不可变之外,record不能扩展另一个类,因为它已经扩展了java.lang.Record,但它可以实现另一个接口,如下面的示例所示:

interface Student{
    String getSchoolName();
}
record StudentImpl(String name, String school) implements Student{
    @Override
    public String getSchoolName() { return school(); }
}

可以向record添加一个static方法,如下面的代码片段所示:

record StudentImpl(String name, String school) implements Student{
    public static String getSchoolName(Student student) {
         return student.getSchoolName();
    }
}

静态方法既不能也不能访问实例属性,只能利用传递给它的参数值。

record可以有一个额外的构造函数,例如可以添加如下:

record StudentImpl(String name, String school) implements Student{
    public StudentImpl(String name) {
        this(name, "Unknown");
    } 
}

正如你可能已经注意到的,无法向record添加另一个属性或 setter,而所有额外的 getters 都必须只使用record已提供的 getters。

密封类和接口

一个final类不能被扩展,而非公共类或接口的访问权限有限。然而,有时一个类或接口需要从任何地方都可以访问,但只能由某个特定的类或接口扩展,或者在接口的情况下,只能由某些类实现。这就是在 Java 17 中向 SDK 添加密封类和接口的动机。

密封类或接口与final类或接口之间的区别在于,密封类或接口总是有一个permits关键字,后面跟着允许扩展密封类或接口的现有直接子类列表,或者在接口的情况下,实现它的类。请注意,这里的词existing。在permits关键字后面的子类必须在编译时存在于与密封类相同的模块中,或者在默认(未命名的)模块中,如果是在同一个包中。

密封类的子类必须标记为sealedfinalnon-sealed密封接口的子类必须标记为sealednon-sealed,因为接口不能是final

让我们先来看一个密封接口的例子:

sealed interface Engine permits EngineBrand {
    int getHorsePower();
}
sealed interface EngineBrand extends Engine permits Vehicle {
    String getBrand();
} 
non-sealed class Vehicle implements EngineBrand {
    private final String make, model, brand;
    private final int horsePower;
    public Vehicle(String make, String model, 
                   String brand, int horsePower) {
        this.make = make;
        this.model = model;
        this.brand = brand;
        this.horsePower = horsePower;
    }
    public String getMake() { return make; }
    public String getModel() { return model; }
    public String getBrand() { return brand; }
    public int getHorsePower() { return horsePower; }
}

正如你所看到的,EngineBrand接口扩展了Engine接口,并允许(允许)Vehicle实现。或者,我们也可以允许Vehicle类直接实现Engine接口,如下面的例子所示:

sealed interface Engine permits EngineBrand, Vehicle {
    int getHorsePower();
}
sealed interface EngineBrand extends Engine permits Vehicle {
    String getBrand();
} 
non-sealed class Vehicle implements Engine, EngineBrand {...}

现在,让我们来看一个密封类的例子:

sealed class Vehicle permits Car, Truck {
    private final String make, model;
    private final int horsePower;
    public Vehicle(String make, String model, int horsePower) {
        this.make = make;
        this.model = model;
        this.horsePower = horsePower;
    }
    public String getMake() { return make; }
    public String getModel() { return model; }
    public int getHorsePower() { return horsePower; }
}

以下是一个CarTruck作为Vehicle 密封类允许的子类的例子:

final class Car extends Vehicle {
    private final int passengerCount;
    public Car(String make, String model, int horsePower, 
      int passengerCount) {
        super(make, model, horsePower);
        this.passengerCount = passengerCount;
    }
    public int getPassengerCount() { return passengerCount; }
}
non-sealed class Truck extends Vehicle {
    private final int payloadPounds;
    public Truck(String make, String model, int horsePower, 
      int payloadPounds) {
        super(make, model, horsePower);
        this.payloadPounds = payloadPounds;
    }
    public int getPayloadPounds() { return payloadPounds; }
}

为了支持密封类,Java 17 中的 Java Reflections API 有两个新方法,isSealed()getPermittedSubclasses()。以下是他们使用的一个例子:

Vehicle vehicle = new Vehicle("Ford", "Taurus", 300);
System.out.println(vehicle.getClass().isSealed());  
                                                 //prints: true
System.out.println(Arrays.stream(vehicle.getClass()
                .getPermittedSubclasses())
                .map(Objects::toString).toList());
                             //prints list of permitted classes
Car car = new Car("Ford", "Taurus", 300, 4);
System.out.println(car.getClass().isSealed());  //prints: false
System.out.println(car.getClass().getPermittedSubclasses());
                                                 //prints: null

密封接口与record很好地集成,因为recordfinal的,可以被列为允许的实现。

多态的实际应用

多态是面向对象编程(OOP)中最强大和最有用的特性。它使用了我们迄今为止所展示的所有其他 OOP 概念和特性。它是掌握 Java 编程道路上的最高概念点。在讨论完它之后,本书的其余部分将主要关于 Java 语言语法和 JVM 功能。

正如我们在OOP 概念部分所提到的,多态是对象能够表现为不同类或不同接口的实现的能力。如果你在网上搜索单词polymorphism,你会发现它是以几种不同形式出现的状态。变形是通过自然或超自然手段将事物或人的形式或本质改变为完全不同的一种形式。所以,Java 多态是对象能够表现为好像经历变形,并在不同条件下表现出完全不同的行为的能力。

我们将通过实际动手的方式来展示这个概念,使用一个对象工厂——工厂模式的具体编程实现,它是一个返回不同原型或类的对象的方法en.wikipedia.org/wiki/Factory_(object-oriented_programming)。

对象工厂

对象工厂背后的理念是创建一种方法,在特定条件下返回某种类型的新对象。例如,看看CalcUsingAlg1CalcUsingAlg2类:

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 运算符

不幸的是,生活并不总是那么简单,有时候程序员不得不处理由不相关的类组装的代码,甚至来自不同的框架。在这种情况下,使用多态可能不是一个选择。然而,你可以使用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 calcInput1){
            return new CalcUsingAlg1().calculate(calcInput1);
        } else if (input instanceof CalcInput2 calcInput2){
            return new CalcUsingAlg2().calculate(calcInput2);
        } else {
            throw new RuntimeException("Unknown input type " + 
                          input.getClass().getCanonicalName());
        }
    }
}

如您所见,它使用instanceof运算符来选择合适的算法。通过使用Object类作为输入类型,Calculator类也利用了多态性,但它的实现大部分与此无关。然而,从外部看,它看起来是多态的,确实如此,但只是程度不同。

概述

本章向您介绍了面向对象的概念以及它们如何在 Java 中实现。它解释了每个概念,并通过具体的代码示例演示了如何使用它们。详细讨论了classinterface的 Java 语言构造。您还学习了什么是重载、重写和隐藏,以及如何使用final关键字来保护方法不被重写。

多态性实践部分,您了解了 Java 强大的多态特性。本节将所有展示的材料汇总在一起,展示了多态性如何成为面向对象的核心。

在下一章中,您将熟悉 Java 语言的语法,包括包、导入、访问修饰符、保留和限制性关键字,以及 Java 引用类型的一些方面。您还将学习如何使用thissuper关键字,了解原始类型宽化和窄化转换、装箱和拆箱、原始类型和引用类型赋值,以及引用类型equals()方法的工作原理。

测验

  1. 从以下列表中选择所有正确的面向对象概念:

    1. 封装

    2. 隔离

    3. 传粉

    4. 继承

  2. 从以下列表中选择所有正确的陈述:

    1. 一个 Java 对象有状态。

    2. 一个 Java 对象有行为。

    3. 一个 Java 对象有状态。

    4. 一个 Java 对象有方法。

  3. 从以下列表中选择所有正确的陈述:

    1. 一个 Java 对象的行为可以被继承。

    2. 一个 Java 对象的行为可以被重写。

    3. 一个 Java 对象的行为可以被重载。

    4. 一个 Java 对象的行为可以被覆盖。

  4. 从以下列表中选择所有正确的陈述:

    1. 不同类的 Java 对象可以具有相同的行为。

    2. 不同类的 Java 对象共享父对象的状态。

    3. 不同类的 Java 对象有一个相同类的父对象。

    4. 不同类的 Java 对象可以共享行为。

  5. 从以下列表中选择所有正确的陈述:

    1. 方法签名包括返回类型。

    2. 如果返回类型不同,方法签名会不同。

    3. 如果两个相同类型的参数交换位置,方法签名会改变。

    4. 如果两个不同类型的参数交换位置,方法签名会改变。

  6. 从以下列表中选择所有正确的陈述:

    1. 封装隐藏了类名。

    2. 封装隐藏了行为。

    3. 封装允许仅通过方法访问数据。

    4. 封装不允许直接访问状态。

  7. 从以下列表中选择所有正确的陈述:

    1. 类在.java文件中声明。

    2. 类的字节码存储在.class文件中。

    3. 父类存储在.base文件中。

    4. child类存储在.sub文件中。

  8. 从以下列表中选择所有正确的陈述:

    1. 一个方法定义了对象的状态。

    2. 一个方法定义了对象的行为。

    3. 没有参数的方法标记为void

    4. 一个方法可以有多个return语句。

  9. 从以下列表中选择所有正确的陈述:

    1. Varargs声明为var类型。

    2. Varargs代表各种参数

    3. Varargs是一个String数组。

    4. Varargs可以作为指定类型的数组。

  10. 从以下列表中选择所有正确的陈述:

    1. 构造函数是一个创建状态的方法。

    2. 构造函数的主要责任是初始化状态。

    3. JVM 始终提供默认构造函数。

    4. 可以使用parent关键字调用父类构造函数。

  11. 从以下列表中选择所有正确的陈述:

    1. new运算符为对象分配内存。

    2. new运算符为对象属性分配默认值。

    3. new运算符首先创建一个父对象。

    4. new运算符首先创建一个子对象。

  12. 从以下列表中选择所有正确的陈述:

    1. Object类属于java.base包。

    2. Object类属于java.lang包。

    3. Object类属于 Java 类库的包。

    4. Object类自动导入。

  13. 从以下列表中选择所有正确的陈述:

    1. 实例方法使用对象调用。

    2. 静态方法使用类调用。

    3. 实例方法使用类调用。

    4. 静态方法使用对象调用。

  14. 从以下列表中选择所有正确的陈述:

    1. 接口中的方法隐式为publicstaticfinal

    2. 接口可以有方法可以在不实现于类的情况下调用。

    3. 接口可以有字段可以在没有任何类的情况下使用。

    4. 接口可以被实例化。

  15. 从以下列表中选择所有正确的陈述:

    1. 接口的默认方法始终默认调用。

    2. 接口中的私有方法只能通过默认方法调用。

    3. 接口静态方法可以在不实现于类的情况下调用。

    4. 默认方法可以增强实现接口的类。

  16. 从以下列表中选择所有正确的陈述:

    1. 一个抽象类可以有一个默认方法。

    2. 一个抽象类可以声明而没有抽象方法。

    3. 任何类都可以声明为抽象。

    4. 接口是一个没有构造函数的抽象类。

  17. 从以下列表中选择所有正确的陈述:

    1. 只能在接口中重载。

    2. 只在有一个类扩展另一个类时才能重载。

    3. 可以在任何类中重载。

    4. 重载的方法必须有相同的签名。

  18. 从以下列表中选择所有正确的陈述:

    1. 只能在child类中重写。

    2. 可以在接口中重载。

    3. 被重写的方法必须具有相同的名称。

    4. Object类的任何方法都不能被重写。

  19. 从以下列表中选择所有正确的陈述:

    1. 任何方法都可以被隐藏。

    2. 变量可以隐藏属性。

    3. 静态方法可以被隐藏。

    4. 公共实例属性可以被隐藏。

  20. 从以下列表中选择所有正确的陈述:

    1. 任何变量都可以被声明为 final。

    2. 公共方法不能被声明为 final。

    3. 受保护的可以声明为 final。

    4. 类可以被声明为 protected。

  21. 从以下列表中选择所有正确的陈述:

    1. 多态行为可以基于继承。

    2. 多态行为可以基于重载。

    3. 多态行为可以基于重写。

    4. 多态行为可以基于接口。

第三章:Java 基础知识

本章向您展示了 Java 作为语言的更详细视图。它从包中的代码组织以及类(接口)及其方法、属性(字段)的可访问性级别描述开始。作为 Java 面向对象性质的主要类型,引用类型也进行了详细说明,随后列出了保留和受限关键字及其用法讨论。本章最后介绍了不同原始类型之间的转换方法,以及从原始类型到相应引用类型及其反向转换的方法。

这些是 Java 语言的基本术语和功能。理解它们的重要性不容忽视。没有它们,您无法编写任何 Java 程序。因此,请尽量不要急于翻阅本章,并确保您理解了所展示的所有内容。

本章将涵盖以下主题:

  • 包、导入和访问

  • Java 引用类型

  • 保留和受限关键字

  • thissuper 关键字的用法

  • 在原始类型之间进行转换

  • 在原始类型和引用类型之间进行转换

技术要求

要执行本章提供的代码示例,您需要以下内容:

  • 一台装有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高版本

  • 您偏好的 IDE 或代码编辑器

本书在第一章“Java 17 入门”中提供了如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明。本章的代码示例文件可在 GitHub 仓库的 examples/src/main/java/com/packt/learnjava/ch03_fundamentals 文件夹中找到,网址为 github.com/PacktPublishing/Learn-Java-17-Programming.git

包、导入和访问

如您所知,包名反映了目录结构,从包含 .java 文件的项目目录开始。每个 .java 文件的名字必须与其中声明的顶层类名相同(此类可以包含其他类)。.java 文件的第一行是包声明,以 package 关键字开始,后跟实际的包名——该文件在目录路径中的斜杠被点号替换。

包名和类名一起组成完全限定类名。它唯一地标识了该类,但通常太长且不便使用。这时,导入就派上用场,允许只指定一次完全限定名,然后只通过类名来引用该类。

从另一个类的类方法中调用一个类的方法只有在调用者可以访问该类及其方法时才可能。publicprotectedprivate访问修饰符定义了可访问性级别,并允许(或不允许)某些方法、属性,甚至类本身对其他类可见。

当前节将详细讨论所有这些方面。

让我们看看我们称之为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属性(作为一个提醒,接口属性默认是公共和静态的),你通常可以这样引用它:

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
    }
}

但这种技术应该明智地使用,因为它可能会给人留下这样的印象:静态导入的方法或属性属于当前类。

访问修饰符

我们已经在我们的例子中使用了三种访问修饰符——publicprotectedprivate——它们从外部(来自其它类或接口)调节对类、接口及其成员的访问。还有一个第四个隐含的(也称为默认修饰符包私有)修饰符,当没有指定这三个显式访问修饰符时应用。

他们的使用效果相当直接:

  • 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 {}
}

请注意,静态嵌套类无法访问顶层类的其他成员。

内部类的另一个特别之处在于,它可以访问顶层类的所有成员,包括私有成员,反之亦然。为了演示这一特性,让我们在顶层类和一个private内部类中创建以下私有属性和方法:

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();  //compiler 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 会在一个称为垃圾回收的过程中将其从内存中移除。我们将在第九章JVM 结构和垃圾回收中描述这个过程。例如,一个对象在方法执行期间被创建,并被局部变量引用。这个引用将在方法执行完毕后消失。

您已经看到了自定义类和接口的示例,我们之前也已经讨论了String类(见第一章Java 17 入门)。在本节中,我们还将描述两种其他的 Java 引用类型——数组和枚举——并演示如何使用它们。

类和接口

使用相应的类名声明一个类类型的变量:

<Class name> identifier;

可以赋值给此类变量的值可以是以下之一:

  • null字面量引用类型(这意味着变量可以被使用,但不指向任何对象)

  • 对同一类或其任何子类的对象的引用(因为子类继承了所有祖先的类型)

这种最后的赋值类型称为java.lang.Object,以下赋值可以对任何类进行:

Object obj = new AnyClassName();

这种赋值也称为向上转型,因为它将变量的类型向上移动到继承线上(这就像任何家谱一样,通常将最古老的祖先放在顶部)。

在这样的向上转型之后,可以使用(type)类型转换运算符进行缩窄赋值:

AnyClassName anyClassName = (AnyClassName)obj;

这种赋值也称为instanceof运算符(见第二章Java 面向对象编程(OOP))来检查引用类型。

同样,如果一个类实现了某个接口,其对象引用可以被赋值给这个接口或接口的任何祖先:

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}};

唯一的要求是,在可以使用之前,必须初始化维度。

Enum

java.lang.Enum类,它反过来扩展了java.lang.Object。它允许指定一组有限的常量,每个常量都是同一类型的实例。此类集合的声明以enum关键字开始。以下是一个示例:

enum Season { SPRING, SUMMER, AUTUMN, WINTER }

列出的每个项目——SPRINGSUMMERAUTUMNWINTER——都是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常量对象。

  • values(): 这是一个静态方法,在valueOff()方法的文档中描述如下:“可以通过调用该类的隐式public static T[] values()方法来获取一个enum类的所有常量。”

为了演示前面的方法,我们将使用已经熟悉的enumSeason

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()方法,让我们创建Season1 enum

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 + ")";
    }
}

如果我们遍历Season2 enum的值,结果将如下所示:

for(Season2 s: Season2.values()){
    System.out.print(s.toString() + " "); 
          //prints: Spring(42) Summer(67) Autumn(32) Winter(20)
}

在标准的 Java 库中,有几个enum类——例如,java.time.Monthjava.time.DayOfWeekjava.util.concurrent.TimeUnit

默认值和字面量

正如我们已经看到的,引用类型的默认值是null。一些资料称之为null

唯一具有除null字面量之外字面量的引用类型是String类。我们已经在第一章Java 17 入门中讨论了字符串。

将引用类型作为方法参数

当一个原始类型值传递到方法中时,我们使用它。如果我们不喜欢传递到方法中的值,我们可以随意更改它,并且不会对此多加思考:

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

这确实是一个很大的区别,不是吗?因此,你必须小心不要修改传入的对象,以避免产生不希望的效果。然而,这种效果偶尔被用来返回结果。但这并不属于最佳实践列表,因为它会使代码的可读性降低。修改传入的对象就像使用一个难以察觉的秘密通道。所以,只有在必要时才使用它。

即使传入的对象是一个包装原始值的类,这种效果仍然存在(我们将在 *“在 转换原始类型和引用类型之间”部分讨论原始值包装类型)。以下是 DemoClass1modifyParameter() 方法的重载版本:

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 值并未改变。这正是我们在 第一章,*《Java 17 入门》中讨论的 String 值不可变特性的目的。

equals() 方法

等号运算符(==),当应用于引用类型的变量时,比较的是引用本身,而不是对象的内容(状态)。但是,即使它们具有相同的内容,两个对象也总是有不同的内存引用。即使用于 String 对象,如果至少有一个是通过 new 运算符创建的,运算符(==)也会返回 false(参见 第一章,*《Java 17 入门》中关于 String 值不可变的讨论)。

要比较内容,可以使用 equals() 方法。在 String 类和数值类型包装类(IntegerFloat 等)中的实现正是这样——比较对象的内容。

然而,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 可以帮助我们完成这项工作,如下所示:

  1. 在类中右键单击关闭括号 } 之前的位置。

  2. 选择 生成 然后按照提示操作。

最终,将生成并添加到类中的两个方法:

@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() 方法通过内容比较字符串,并因此服务于这个目的,因为 DemoClassgetProp() 方法返回一个字符串。

hashCode() 方法——此方法返回的整数唯一地标识了该特定对象(但请不要期望它在应用程序的不同运行之间是相同的)。如果只需要 equals() 方法,则不需要实现此方法。尽管如此,出于以防万一,如果此类对象将被收集到 Set 或基于哈希码的其他集合中,建议实现它(我们将在 第六章数据结构、泛型和常用工具)中讨论 Java 集合)。

这两个方法都在 Object 中实现,因为许多算法使用 equals()hashCode() 方法,并且如果您的应用程序没有实现这些方法,则可能无法正常工作。同时,您的对象可能不需要它们在您的应用程序中。然而,一旦您决定实现 equals() 方法,实现 hashCode() 方法也是一个好主意。此外,如您所见,IDE 可以在不产生任何开销的情况下完成这项工作。

保留和限制关键字

关键字是对于编译器有特定意义的单词,不能用作标识符。截至 Java 17,有 52 个保留关键字、5 个保留标识符、3 个保留词和 10 个限制性关键字。保留关键字不能在任何 Java 代码中使用作标识符,而限制性关键字只能在模块声明上下文中用作标识符。

保留关键字

以下是一份所有 Java 保留关键字的列表:

图片

到目前为止,你应该对前面的大部分关键字都很熟悉。作为一个练习,你可以通过列表检查你记得多少。到目前为止,我们还没有讨论以下八个关键字:

  • constgoto是保留的,但尚未使用。

  • assert关键字用于assert语句中(我们将在第四章异常处理)中使用。

  • synchronized关键字用于并发编程(我们将在第八章多线程与并发处理)中使用。

  • volatile关键字使得变量的值不可缓存。

  • transient关键字使得变量的值不可序列化。

  • strictfp关键字限制了浮点数计算,使得在执行浮点变量操作时,每个平台上的结果都相同。

  • native关键字声明了一个在平台相关代码(如 C 或 C++)中实现的方法。

保留标识符

Java 中的五个保留标识符如下:

  • permits

  • record

  • sealed

  • var

  • yield

文字值的保留字

Java 中的三个保留字如下:

  • true

  • false

  • null

受限关键字

Java 中的 10 个受限关键字如下:

  • open

  • module

  • requires

  • transitive

  • exports

  • opens

  • to

  • uses

  • provides

  • with

它们被称为受限,因为它们不能在模块声明上下文中作为标识符,我们将在本书中不讨论这一点。在其他所有地方,它们都可以用作标识符,例如以下内容:

String to = "To";
String with = "abc";

虽然你可以这样做,但即使在不进行模块声明的情况下,不将它们用作标识符也是一个好习惯。

thissuper关键字的用法

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
    }
}

在第1行添加this允许将值分配给实例属性。在第2行添加this没有区别,但每次使用instance属性时都使用this关键字是一个好习惯。这使得代码更易读,并有助于避免难以追踪的错误,例如我们刚才演示的那个。

我们也看到了this关键字在equals()方法中的使用:

@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());
}

并且,为了提醒你,以下是我们曾在第二章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(),这意味着父类有一个不带参数的构造函数。

超级关键字的使用

super 关键字指的是父对象。我们在构造函数中已经看到了它的用法,在 超级关键字的使用 部分,它必须仅在第一行使用,因为父类对象必须在当前对象创建之前创建。如果构造函数的第一行不是 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
    }
}

随着我们在这本书中的进展,我们将看到更多使用 thissuper 关键字的例子。

原始类型之间的转换

一个数值类型可以持有的最大数值取决于为其分配的位数。以下是对每种数值类型表示的位数:

  • byte:8 位

  • char:16 位

  • short:16 位

  • int:32 位

  • long:64 位

  • float:32 位

  • double:64 位

当一个数值类型的值赋给另一个数值类型的变量,并且新类型可以容纳更大的数字时,这种转换称为 cast 操作符。

宽泛转换

根据 Java 语言规范,共有 19 种宽泛的原始类型转换:

  • byteshortintlongfloatdouble

  • shortintlongfloatdouble

  • charintlongfloatdouble

  • intlongfloatdouble

  • longfloatdouble

  • floatdouble

在整数类型之间的宽泛转换,以及某些整数类型到浮点类型的转换中,结果值与原始值完全匹配。然而,从 intfloat、从 longfloat 或从 longdouble 的转换可能会导致精度损失。根据 Java 语言规范,结果浮点值可以使用 IEEE 754 四舍五入到最接近的模式 正确舍入。以下是一些演示精度损失的例子:

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 

如你所见,从 intdouble 的转换会保留值,但 longfloatlongdouble 可能会丢失精度。这取决于值的大小。所以,如果你在计算中需要精确度,请注意并允许一些精度损失。

狭义转换

Java 语言规范确定了 22 种狭义原始类型转换:

  • shortbytechar

  • charbyteshort

  • intbyteshortchar

  • longbyteshortcharint

  • floatbyteshortcharintlong

  • doublebyteshortcharintlongfloat

与拓宽转换类似,缩窄转换可能会导致精度丢失,甚至可能丢失值的大小。缩窄转换比拓宽转换更复杂,我们不会在本书中讨论它。重要的是要记住,在执行缩窄之前,必须确保原始值小于目标类型的最大值。否则,您可能会得到一个完全不同的值(丢失了大小)。请看以下示例:

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类型与byteshort类型之间的转换是一个更为复杂的程序,因为char类型是一个无符号数值类型,而byteshort类型是有符号数值类型,所以即使一个值看起来似乎可以适应目标类型,也可能发生信息丢失。

转换方法

除了类型转换之外,每种原始类型都有一个相应的引用类型(称为booleanchar。所有的包装类都属于java.lang包):

  • java.lang.Boolean

  • java.lang.Byte

  • java.lang.Character

  • java.lang.Short

  • java.lang.Integer

  • java.lang.Long

  • java.lang.Float

  • java.lang.Double

其中除了BooleanCharacter类之外,它们都扩展了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

在示例中,请注意接受property参数的两个方法。这两个方法以及其他包装类的类似方法将系统属性(如果存在)转换为相应的原始类型。

每个包装类都有一个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 

注意,自动装箱仅在相关包装类型之间才可能。否则,编译器会生成错误。

ByteShort包装类的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.0
long l3 = i1;                //implicit unboxing
System.out.println(l3);      //prints: 42
double d3 = i1;              //implicit unboxing
System.out.println(d3);      //prints: 42.0

如您从示例中的注释中可以看到,从包装类型到相应原始类型的转换不被称为自动拆箱;而是称为隐式拆箱。与自动装箱不同,即使在包装类型和与之不匹配的原始类型之间,也可以使用隐式拆箱。

摘要

在本章中,您学习了 Java 包是什么以及它们在组织代码和类可访问性方面所起的作用,包括import语句和访问修饰符。您还熟悉了引用类型——类、接口、数组和枚举。任何引用类型的默认值都是null,包括String类型。

您现在应该理解了引用类型是如何通过引用传递给方法的,以及equals()方法的使用和重写方式。您还有机会研究完整的保留和限制关键字列表,并学习了thissuper关键字的意义和用法。

本章通过描述原始类型、包装类型和String字面量之间的转换过程和方法来结束。

在下一章中,我们将讨论 Java 异常框架,检查和未检查(运行时)异常,try-catch-finally块,throwsthrow语句,以及异常处理的最佳实践。

测验

  1. 选择所有正确的语句:

    1. Package语句描述了类或接口的位置。

    2. Package语句描述了类或接口的名称。

    3. Package是一个完全限定名。

    4. Package名称和类名组成类的完全限定名。

  2. 选择所有正确的语句:

    1. Import语句允许使用完全限定名。

    2. Import语句必须在.java文件的第一行。

    3. Group import语句仅引入一个包中的类(和接口)。

    4. Import statement允许避免使用完全限定名。

  3. 选择所有正确的语句:

    1. 没有访问修饰符,类只能被同一包中的其他类和接口访问。

    2. 私有类的私有方法可以被同一 .java 文件中声明的其他类访问。

    3. 私有类的公共方法可以被同一包中但不在同一 .java 文件中声明的其他类访问。

    4. 受保护的(protected)方法只能被类的后代访问。

  4. 选择所有正确的陈述:

    1. 私有方法可以被重载但不能被覆盖。

    2. 受保护的(protected)方法可以被覆盖但不能被重载。

    3. 没有访问修饰符的方法既可以被重载也可以被覆盖。

    4. 私有方法可以访问同一类的私有属性。

  5. 选择所有正确的陈述:

    1. 狭义和向下转型是同义词。

    2. 扩展和向下转型是同义词。

    3. 扩展和向上转型是同义词。

    4. 扩展和狭义与向上转型和向下转型没有共同之处。

  6. 选择所有正确的陈述:

    1. Array 是一个对象。

    2. Array 有一个表示它能容纳的元素数量的长度。

    3. 数组的第一个元素具有索引 1。

    4. 数组的第二个元素具有索引 1。

  7. 选择所有正确的陈述:

    1. Enum 包含常量。

    2. Enum 总是有一个构造函数,无论是默认的还是显式的。

    3. 枚举(enum)常量可以有属性。

    4. Enum 可以有任何引用类型的常量。

  8. 选择所有正确的陈述:

    1. 任何作为参数传入的引用类型都可以被修改。

    2. 作为参数传入的 new String() 对象可以被修改。

    3. 作为参数传入的对象引用值不能被修改。

    4. 作为参数传入的数组可以有不同的值分配给其元素。

  9. 选择所有正确的陈述:

    1. 保留关键字不能使用。

    2. 限制关键字不能用作标识符。

    3. 保留的 identifier 关键字不能用作标识符。

    4. 保留关键字不能用作标识符。

  10. 选择所有正确的陈述:

    1. this 关键字指的是当前类。

    2. super 关键字指的是超类。

    3. thissuper 关键字指的是对象。

    4. thissuper 关键字指的是方法。

  11. 选择所有正确的陈述:

    1. 原始类型的扩展会使值变大。

    2. 原始类型的狭义总是改变值的类型。

    3. 原始类型的扩展只能在转换狭义之后进行。

    4. 狭义会使值变小。

  12. 选择所有正确的陈述:

    1. 装箱(Boxing)对值有限制。

    2. 解箱(Unboxing)创建了一个新值。

    3. 装箱(Boxing)创建了一个引用类型对象。

    4. 解箱(Unboxing)删除了一个引用类型对象。

第二部分:Java 的构建块

本节将通过一个示例程序,帮助你更深入地理解核心编程概念。这将使你能够进入下一个步骤,即构建 Java 项目。

本部分包含以下章节:

  • 第四章, 异常处理

  • 第五章, 字符串、输入/输出和文件

  • 第六章, 数据结构、泛型和常用工具

  • 第七章, Java 标准和外部库

  • 第八章, 多线程与并发处理

  • 第九章, JVM 结构和垃圾回收

  • 第十章, 数据库中的数据管理

  • 第十一章, 网络编程

  • 第十二章, Java GUI 编程

第四章:异常处理

第一章Java 17 入门中,我们简要介绍了异常。在本章中,我们将更系统地探讨这个主题。Java 中有两种异常:检查型和非检查型。我们将演示每种类型,并解释两者之间的区别。此外,你还将了解与异常处理相关的 Java 构造的语法以及处理这些异常的最佳实践。本章将以断言语句的相关主题结束,断言语句可用于在生产环境中调试代码。

在本章中,我们将涵盖以下主题:

  • Java 异常框架

  • 检查型和非检查型(运行时)异常

  • trycatchfinally

  • throws语句

  • throw语句

  • assert语句

  • 异常处理的最佳实践

那么,让我们开始吧!

技术要求

要能够执行本章提供的代码示例,你需要以下内容:

  • 搭载 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高

  • 你偏好的 IDE 或代码编辑器

第一章Java 17 入门中提供了如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明。本章的代码示例文件可在 GitHub 的github.com/PacktPublishing/Learn-Java-17-Programming.git仓库中找到。请在examples/src/main/java/com/packt/learnjava/ch04_exceptions文件夹中搜索。

Java 异常框架

第一章Java 17 入门所述,一个意外的条件可以导致catch子句,即如果异常是在try块内部抛出的。让我们看一个例子。考虑以下方法:

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,它不指向任何对象。让我们看看接下来会发生什么。

让我们运行以下代码(即Framework类中的catchException1()方法):

try {
    method(null);
} catch (Exception ex){
    System.out.println("catchException1():");
    System.out.println(ex.getClass().getCanonicalName());  
                       //prints: java.lang.NullPointerException
    waitForStackTrace();
    ex.printStackTrace();  //prints: see the screenshot
    if(ex instanceof NullPointerException){
        //do something
    } else {
        //do something else
    }
}

上述代码包括waitForStackTrace()方法,允许你等待一段时间直到生成堆栈跟踪。否则,输出将会顺序混乱。此代码的输出如下所示:

catchException1():                                                  
java.lang.NullPointerException                                      
java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because "s" is null                                                 
     at com.packt.learnjava.ch04_exceptions.Framework.method(Framework.java:14)
     at com.packt.learnjava.ch04_exceptions.Framework.catchException1(Framework.java:24)
     at com.packt.learnjava.ch04_exceptions.Framework.main(Framework.java:8)

如您所见,该方法打印出异常类的名称,然后是堆栈跟踪堆栈跟踪这个名字来源于方法调用在 JVM 内存中存储的方式(作为一个堆栈):一个方法调用另一个方法,然后这个方法又调用另一个方法,依此类推。在最内层方法返回后,堆栈被回溯,返回的方法(堆栈帧)从堆栈中移除。我们将在第九章中更详细地讨论 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.Throwable类。通常,错误由 JVM 抛出,根据官方文档,表示合理的应用程序不应该尝试捕获的严重问题。以下是一些例子:

  • OutOfMemoryError: 当 JVM 耗尽内存且无法使用垃圾回收清理内存时抛出。

  • StackOverflowError:当为方法调用栈分配的内存不足以存储另一个栈帧时,会抛出此异常。

  • NoClassDefFoundError:当 JVM 无法找到当前加载的类请求的类定义时,会抛出此异常。

框架的作者假设应用程序无法自动从这些错误中恢复,这证明是一个大体正确的假设。这就是为什么程序员通常不捕获错误,但这超出了本书的范围。

另一方面,异常通常与特定于应用程序的问题相关,并且通常不需要我们关闭应用程序并允许恢复。通常,这就是程序员捕获它们并实现应用程序逻辑的替代(主流程)路径,或者至少在不关闭应用程序的情况下报告问题。以下是一些示例:

  • ArrayIndexOutOfBoundsException:当代码尝试通过等于或大于数组长度的索引访问元素时,会抛出此异常(请记住,数组的第一个元素具有索引0,因此索引等于数组长度指向数组外部)。

  • ClassCastException:当代码尝试将引用转换为与变量所引用的对象不关联的类或接口时,会抛出此异常。

  • NumberFormatException:当代码尝试将字符串转换为数值类型,但字符串不包含必要的数字格式时,会抛出此异常。

所有异常都扩展了java.lang.Exception类,而该类反过来又扩展了java.lang.Throwable类。这就是为什么通过捕获java.lang.Exception类的对象,代码可以捕获任何异常类型的对象。在Java 异常框架部分,我们通过以相同的方式捕获java.lang.NullPointerException来演示了这一点。

其中一个异常是java.lang.RuntimeException。扩展它的异常被称为NullPointerExceptionArrayIndexOutOfBoundsExceptionClassCastExceptionNumberFormatException。它们被称为运行时异常的原因很明显;它们被称为非受检异常的原因将在下一节中变得清晰。

那些没有java.lang.RuntimeException在其祖先中的异常被称为方法的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
    }
}

在前面的示例中,一个带有NullPointerExceptioncatch块被放置在带有Exception的块之前,因为NullPointerException扩展了RuntimeException,而RuntimeException又扩展了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子句捕获。要捕获它们,你应该添加一个catch子句来捕获Error(在任何位置)或Throwable(在前一个示例中的最后一个catch子句之后)。然而,通常程序员不会这样做,并允许错误传播到 JVM。

为每种异常类型都拥有一个catch块允许我们提供特定的异常类型处理。然而,如果没有异常处理上的差异,你可以简单地使用一个带有Exception基类的catch块来捕获所有类型的异常:

void someMethod(String s){
    try {
        method(s);
    } catch (Exception ex){
        //do something
    }
}

如果没有任何子句捕获异常,它将被进一步抛出,直到它被方法调用者中的一个try...catch语句处理,或者传播到应用程序代码之外。在这种情况下,JVM 将终止应用程序并退出。

添加finally块不会改变描述的行为。如果存在,它总是会被执行,无论是否已生成异常。通常,finally块用于释放资源,关闭数据库连接、文件或类似资源。然而,如果资源实现了Closeable接口,最好使用try-with-resources语句,它允许你自动释放资源。以下是如何使用 Java 7 实现的示例:

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()方法)connrs对象。

Java 9 通过允许在try块外部创建表示资源的对象,并在try-with-resources语句中使用它们,增强了try-with-resources语句的功能,如下所示:

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语句与try-with-resources语句结合使用。

抛出语句

我们必须处理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 { 
        try {
           if(conn != null) {
              conn.close();
           }
        } finally {
           if(rs != null) {
               rs.close();
           }
        }
    }
}

我们去掉了捕获子句,但我们需要finally块来关闭创建的 conn 和 rs 对象。

请注意,我们如何在 try 块中包含了关闭 conn 对象的代码,在 finally 块中包含了关闭 rs 对象的代码。这样我们确保在关闭 conn 对象时发生的异常不会阻止我们关闭 rs 对象。

这段代码比我们在上一节中演示的try-with-resources语句看起来更不清楚。我们展示它只是为了演示所有可能性以及如何避免可能的危险(不关闭资源),如果你决定自己这样做,而不是让try-with-resources语句自动为你做。

但让我们回到对throws语句的讨论。

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 happened"); 
throw new RuntimeException("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,则程序停止执行。

assert() 方法仅在 JVM 使用 -ea 选项运行时执行。在生产环境中不应使用 -ea 标志,除非可能是为了测试目的而临时使用。这是因为它会产生额外的开销,从而影响应用程序的性能。

异常处理最佳实践

已检查异常是为了在应用程序可以自动采取措施修正或绕过问题时使用的可恢复条件而设计的。在实践中,这种情况并不常见。通常,当捕获到异常时,应用程序会记录堆栈跟踪并中止当前操作。根据记录的信息,应用程序支持团队修改代码以解决未记录的条件或防止其未来发生。

每个应用程序都是不同的,因此最佳实践取决于特定的应用程序需求、设计和上下文。一般来说,开发社区似乎达成了一致,避免使用已检查异常并尽量减少它们在应用程序代码中的传播。以下是一些已被证明有用的其他建议列表:

  • 总是捕获所有接近源头的已检查异常。

  • 如果有疑问,捕获接近源头的未检查异常。

  • 尽可能地在源头附近处理异常,因为那里的上下文最为具体,根本原因也位于那里。

  • 除非你真的需要,否则不要抛出已检查异常,因为这会强制为可能永远不会发生的情况构建额外的代码。

  • 如果你必须,通过将它们作为带有相应消息的RuntimeException重新抛出,将第三方已检查异常转换为未检查异常。

  • 除非你真的需要,否则不要创建自定义异常。

  • 除非你真的需要,否则不要通过异常处理机制来驱动业务逻辑。

  • 通过使用消息系统和可选的enum类型而不是使用异常类型来传达错误原因,自定义通用的RuntimeException异常。

有许多其他可能的提示和建议;然而,如果你遵循这些,你很可能在大多数情况下都会做得很好。因此,我们结束这一章。

摘要

在本章中,你被介绍了 Java 异常处理框架,并学习了两种类型的异常——已检查的和未检查的(运行时)——以及如何使用try-catch-finallythrows语句来处理它们。你还学习了如何生成(抛出)异常以及如何创建自己的(自定义)异常。本章以异常处理的最佳实践结束,如果始终如一地遵循这些实践,将有助于你编写干净、清晰的代码,这样的代码易于编写、理解和维护。

在下一章中,我们将详细讨论字符串及其处理,以及输入/输出流和文件读写技术。

问答

  1. 什么是堆栈跟踪?选择所有适用的:

    1. 当前加载的类列表

    2. 当前正在执行的列表

    3. 当前正在执行的代码行列表

    4. 当前使用的变量列表

  2. 有哪些类型的异常?选择所有适用的:

    1. 编译异常

    2. 运行时异常

    3. 读取异常

    4. 编写异常

  3. 以下代码的输出是什么?

    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 ");
    }
    
    1. 一个RuntimeException错误

    2. Exception Error Finally

    3. RuntimeException Finally

    4. Throwable Finally

  4. 以下哪个方法可以无错误地编译?

    void method1() throws Exception { throw null; }
    void method2() throws RuntimeException { throw null; }
    void method3() throws Throwable { throw null; }
    void method4() throws Error { throw null; }
    
    1. method1()

    2. method2()

    3. method3()

    4. method4()

  5. 以下哪个语句可以无错误地编译?

    throw new NullPointerException("Hi there!"); //1
    throws new Exception("Hi there!");          //2
    throw RuntimeException("Hi there!");       //3
    throws RuntimeException("Hi there!");     //4
    
    1. 1

    2. 2

    3. 3

    4. 4

  6. 假设int x = 4,以下哪个语句可以无错误地编译?

    assert (x > 3); //1
    assert (x = 3); //2
    assert (x < 4); //3
    assert (x = 4); //4
    
    1. 1

    2. 2

    3. 3

    4. 4

  7. 以下列表中哪些是最佳实践?

    1. 总是捕获所有异常和错误。

    2. 总是捕获所有异常。

    3. 永远不要抛出未检查的异常。

    4. 除非你不得不这样做,否则尽量不要抛出受检异常。

第五章:字符串、输入/输出和文件

在本章中,您将更详细地了解 String 类的方法。我们还将讨论标准库和 Apache Commons 项目中的流行字符串实用工具。接下来将概述 Java 输入/输出流和 java.io 包的相关类,以及 org.apache.commons.io 包的一些类。文件管理类及其方法将在专门的章节中描述。完成本章学习后,您将能够编写使用标准 Java API 和 Apache Commons 实用工具处理字符串和文件的代码。

本章将涵盖以下主题:

  • 字符串处理

  • I/O 流

  • 文件管理

  • Apache Commons 的 FileUtilsIOUtils 实用工具

技术要求

要执行本章提供的代码示例,您需要以下内容:

  • 拥有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高版本

  • 您偏好的 IDE 或代码编辑器

在本书中,关于如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明提供在 第一章开始使用 Java 17,其中。本章的代码示例文件可在 GitHub 仓库的 github.com/PacktPublishing/Learn-Java-17-Programming.git 中的 examples/src/main/java/com/packt/learnjava/ch05_stringsIoStreams 文件夹中找到。

字符串处理

在主流编程中,String 可能是最受欢迎的类。在 第一章开始使用 Java 17,我们学习了该类、其字面量以及其称为 String 类方法和实用工具类的特定功能,特别是来自 org.apache.commons.lang3 包的 StringUtils 类。

String 类的方法

String 类有超过 70 个方法,可以用于分析、修改和比较字符串,以及将数字字面量转换为相应的字符串字面量。要查看 String 类的所有方法,请参阅 Java API 在线文档 docs.oracle.com/en/java/javase

字符串分析

length() 方法返回字符串中的字符数,如下面的代码所示:

String s7 = "42";
System.out.println(s7.length());    //prints: 2
System.out.println("0 0".length()); //prints: 3

以下 isEmpty() 方法在字符串长度(字符数)为 0 时返回 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

正则表达式超出了本书的范围。您可以在 www.regular-expressions.info 了解它们。在上面的示例中,[a-z]+ 表达式仅匹配一个或多个字母。

字符串比较

第三章Java 基础 中,我们讨论了 equals() 方法,它仅在两个 String 对象或字面量拼写完全相同的情况下返回 true。以下代码片段演示了它是如何工作的:

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() 仅比较字符序列(内容)。字符序列可以由 StringStringBuilderStringBufferCharBuffer 或任何实现 CharSequence 接口的类表示。尽管如此,如果两个序列包含相同的字符,则 contentEquals() 方法将返回 true,而如果其中一个序列不是由 String 类创建的,则 equals() 方法将返回 false

contains() 方法返回 true 如果 string 包含某个子字符串,如下所示:

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() 方法返回字符串中指定位置的字符的代码点。代码点在 第一章整数类型 部分进行了描述,Java 17 入门

字符串转换

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() 方法使用传入的第一个参数作为模板,并按顺序将其他参数插入模板的相应位置。以下代码示例打印了句子 Hey, Nick! Give me 2 apples, please! 三次:

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 类。

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

上述代码的第一行将所有 "bc" 实例替换为 "42"。第二行只替换第一个 "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(它们的代码点)。我们将在 第十四章 中讨论流,Java 标准流

Java 11 添加的方法

Java 11 在 String 类中引入了几个新方法。

repeat() 方法允许你根据相同字符串的多次连接创建一个新的 String 值,如下面的代码所示:

System.out.println("ab".repeat(3)); //prints: ababab
System.out.println("ab".repeat(1)); //prints: ab
System.out.println("ab".repeat(0)); //prints:

isBlank() 方法如果字符串长度为 0 或仅由空白字符组成,则返回 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); 

上述代码的输出如下:

我们将在 第十四章 中讨论流,Java 标准流

字符串实用工具

除了 String 类之外,还有许多其他类具有处理 String 值的方法。其中最有用的是来自名为 pom.xml 文件的项目中的 org.apache.commons.lang3 包的 StringUtils 类:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.8.1</version>
</dependency>

StringUtils 类是许多程序员的宠儿。它通过提供以下安全操作(当方法以这种方式实现时——例如,通过检查 null 值——不会抛出 NullPointerException)来补充 String 类的方法:

  • isBlank(CharSequence cs): 如果输入值是空白、空 ("") 或 null,则返回 true

  • isNotBlank(CharSequence cs): 当前面的方法返回 true 时返回 false

  • isEmpty(CharSequence cs): 如果输入值是空 ("") 或 null,则返回 true

  • isNotEmpty(CharSequence cs): 当前面的方法返回 true 时返回 false

  • trim(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 ")+"'");  
                                               // prints: '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)trimToNull(String str)trimToEmpty(String str) 方法产生相同的结果,但使用更广泛的空白定义(基于 Character.isWhitespace(int codepoint)),因此移除与 trim(String str)trimToNull(String str)trimToEmpty(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): 从 StringString[] 数组元素的部分移除特定的字符

  • 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: 从相对位置返回一个子字符串

  • splitjoin: 分别分割或连接一个值

  • removedelete: 删除一个子字符串

  • replaceoverlay: 替换一个值

  • chompchop: 移除末尾

  • appendIfMissing: 如果不存在,则添加一个值

  • prependIfMissing: 如果不存在,则将前缀添加到 String 值的开头

  • leftPadrightPadcenterrepeat:添加填充

  • upperCaselowerCaseswapCasecapitalizeuncapitalize:更改大小写

  • countMatches: 返回子字符串出现的次数

  • isWhitespaceisAsciiPrintableisNumericisNumericSpaceisAlphaisAlphaNumericisAlphaSpaceisAlphaNumericSpace:检查是否存在某种类型的字符

  • isAllLowerCaseisAllUpperCase:检查大小写

  • defaultStringdefaultIfBlankdefaultIfEmpty:如果为 null 则返回默认值

  • rotate: 使用循环移位旋转字符

  • reversereverseDelimited:反转字符或分隔的字符组

  • abbreviateabbreviateMiddle:使用省略号或其他值缩略值

  • difference: 返回值之间的差异

  • getLevenshteinDistance: 返回将一个值转换为另一个值所需的变化次数

如您所见,StringUtils 类提供了一套非常丰富的(我们并未列出所有)用于字符串分析、比较和转换的方法,这些方法与 String 类的方法相辅相成。

I/O 流

任何软件系统都必须接收和产生某种可以组织为一系列独立的输入/输出或数据流的类型的数据。一个流可以是有限的或无限的。程序可以从流中读取(称为 输入流)或向流中写入(称为 输出流)。Java I/O 流要么基于字节,要么基于字符,这意味着其数据要么被解释为原始字节,要么被解释为字符。

java.io 包包含支持许多但不是所有可能数据源的类。它主要围绕从文件、网络流和内部内存缓冲区输入和输出构建。它不包含许多必要的网络通信类。它们属于 java.netjavax.net 和其他 Java 网络 API 的包。只有当网络源或目的地建立(例如网络套接字)之后,程序才能使用 java.io 包中的 InputStreamOutputStream 类读取和写入数据。

java.nio 包的类几乎与 java.io 包的类具有相同的功能。但是,除此之外,它们还可以在 非阻塞 模式下工作,这在某些情况下可以显著提高性能。我们将在 第十五章 响应式编程 中讨论非阻塞处理。

流数据

输入数据至少必须是二进制的一——用 0 和 1 表示——因为这是计算机可以读取的格式。数据可以一次读取或写入一个字节,或者一次读取或写入几个字节的数组。这些字节可以是二进制格式,也可以被解释为字符。

在第一种情况下,InputStreamOutputStream类的后代,如(如果类属于java.io包则省略包名)ByteArrayInputStreamByteArrayOutputStreamFileInputStreamFileOutputStreamObjectInputStreamObjectOutputStreamjavax.sound.sampled.AudioInputStreamorg.omg.CORBA.portable.OutputStream,可以将它们作为字节或字节数组读取;你使用哪一个取决于数据源或目的地。InputStreamOutputStream类本身是抽象的,不能被实例化。

在第二种情况下,可以解释为字符的数据被称为ReaderWriter,它们也是抽象类。它们的子类示例包括CharArrayReaderCharArrayWriterInputStreamReaderOutputStreamWriterPipedReaderPipedWriter以及StringReaderStringWriter

你可能已经注意到我们成对地列出了类。但并非每个输入类都有对应的输出特殊化——例如,有PrintStreamPrintWriter类支持输出到打印设备,但没有相应的输入伙伴,至少不是按名称。然而,有一个java.util.Scanner类可以解析已知格式的输入文本。

还有一组配备了缓冲区的类,通过一次读取或写入更大的数据块来提高性能,尤其是在访问源或目的地耗时较长的情况下。

在本节的其余部分,我们将回顾java.io包中的类以及来自其他包的一些流行的相关类。

InputStream类及其子类

在 Java 类库中,InputStream抽象类有以下直接实现:ByteArrayInputStreamFileInputStreamObjectInputStreamPipedInputStreamSequenceInputStreamFilterInputStreamjavax.sound.sampled.AudioInputStream

所有这些都可以直接使用,或者覆盖InputStream类的以下方法:

  • int available(): 返回可读取的字节数

  • void close(): 关闭流并释放资源

  • void mark(int readlimit): 在流中标记一个位置并定义可以读取的字节数

  • boolean markSupported(): 如果标记被支持则返回true

  • static 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): 将len或更少的字节读取到b缓冲区的off偏移量

  • byte[] readNBytes(int len): 将len或更少的字节读取到b缓冲区

  • void reset(): 将读取位置重置为mark()方法上次调用时的位置

  • long skip(long n): 跳过n或更少的字节流;返回实际跳过的字节数

  • long transferTo(OutputStream out): 从输入流读取并逐字节写入提供的输出流;返回实际传输的字节数

abstract int read()是唯一必须实现的方法,但这个类的许多子类也覆盖了许多其他方法。

ByteArrayInputStream

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类从文件系统中的文件获取数据——例如图像的原始字节。它有以下三个构造函数:

  • FileInputStream(File file)

  • FileInputStream(String name)

  • FileInputStream(FileDescriptor fdObj)

每个构造函数都打开指定的文件。第一个构造函数接受File对象;第二个,文件系统中的文件路径;第三个,表示文件系统中的实际文件连接的文件描述符对象。让我们看看以下示例:

String file =  classLoader.getResource("hello.txt").getFile();
try(FileInputStream fis = new FileInputStream(file)){
    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!。前面示例的输出如下:

图形用户界面自动生成的描述

在从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

ObjectInputStream类的方法集比任何其他InputStream实现的方法集都要大。这是因为它是围绕读取各种类型的对象字段值构建的。为了使ObjectInputStream能够从数据输入流中构造一个对象,该对象必须是可反序列化的,这意味着它首先必须是可序列化的——也就是说,可以转换成字节流。通常,这是为了在网络中传输对象。在目的地,序列化的对象被反序列化,原始对象的值被恢复。

基本数据类型和大多数 Java 类,包括String类和基本数据类型包装器,都是可序列化的。如果一个类有自定义类型的字段,它们必须通过实现java.io.Serializable来使其可序列化。如何实现这一点超出了本书的范围。现在,我们将只使用可序列化类型。让我们看看这个类:

class SomeClass implements Serializable {
    private int field1 = 42;
    private String field2 = "abc";
}

我们必须告诉编译器它是可以序列化的。否则,编译将失败。这样做是为了确保在声明类是可序列化之前,程序员要么审查了所有字段并确保它们是可序列化的,要么实现了序列化所需的必要方法。

在我们可以创建输入流并使用ObjectInputStream进行反序列化之前,我们需要首先序列化对象。这就是为什么我们首先使用ObjectOutputStreamFileOutputStream来序列化一个对象并将其写入someClass.bin文件。我们将在输出流类及其子类部分更多地讨论它们。然后,我们使用FileInputStream从文件中读取,并使用ObjectInputStream反序列化文件内容:

String fileName = "someClass.bin";
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(fileName));
     ObjectInputStream ois = new ObjectInputStream(new 
                             FileInputStream(fileName))){
    SomeClass obj = new SomeClass();
    oos.writeObject(obj);
    SomeClass objRead = (SomeClass) ois.readObject();
    System.out.println(objRead.field1);  //prints: 42
    System.out.println(objRead.field2);  //prints: abc
} catch (Exception ex){
    ex.printStackTrace();
}

注意,必须在运行前面的代码之前先创建文件。我们将在 创建文件和目录 部分展示如何做到这一点。并且,作为提醒,我们使用了 try-with-resources 语句,因为 InputStreamOutputStream 都实现了 Closeable 接口。

PipedInputStream

管道输入流有一个非常特殊的专业化;它被用作线程之间通信的机制之一。一个线程从 PipedInputStream 对象读取并传递数据给另一个线程,该线程将数据写入 PipedOutputStream 对象。以下是一个示例:

PipedInputStream pis = new PipedInputStream();
PipedOutputStream pos = new PipedOutputStream(pis);

或者,当一个线程从 PipedOutputStream 对象读取数据,另一个线程将数据写入 PipedInputStream 对象时,数据可以沿相反方向移动,如下所示:

PipedOutputStream pos = new PipedOutputStream();
PipedInputStream pis = new PipedInputStream(pos);

在这个领域工作的人熟悉消息 Broken pipe,这意味着提供数据的管道流已停止工作。

管道流也可以在没有连接的情况下创建,稍后连接,如下所示:

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 接口),将数字 123 写入流,然后关闭。现在,让我们看看 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() 方法执行传入的 Runnablerun() 方法。我们看到的结果正如预期——PipedInputWorker 打印出 PipedOutputWorker 写入管道流的所有字节。我们将在 第八章多线程和并发处理 中更详细地介绍线程。

SequenceInputStream

SequenceInputStream 类将输入流连接到以下构造函数之一作为参数:

  • SequenceInputStream(InputStream s1, InputStream s2)

  • SequenceInputStream(Enumeration<InputStream> e)

SequenceInputStream 类从第一个输入字符串开始读取,直到其结束,然后从第二个字符串读取,依此类推,直到最后一个流的结束。例如,让我们在 hello.txt 文件旁边的 resources 文件夹中创建一个 howAreYou.txt 文件(内容为 How are you?)。然后可以使用 SequenceInputStream 类如下:

String file1 = classLoader.getResource("hello.txt").getFile();
String file2 = classLoader.getResource("howAreYou.txt").getFile();
try(FileInputStream fis1 = 
                    new FileInputStream(file1);
    FileInputStream fis2 = 
                    new FileInputStream(file2);
    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

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 类的扩展之一:BufferedInputStreamCheckedInputStreamDataInputStreamPushbackInputStreamjavax.crypto.CipherInputStreamjava.util.zip.DeflaterInputStreamjava.util.zip.InflaterInputStreamjava.security.DigestInputStreamjavax.swing.ProgressMonitorInputStream。或者,您可以创建一个自定义扩展。但在创建自己的扩展之前,查看列出的类,看看其中是否有符合您需求的类。以下是一个使用 BufferedInputStream 类的示例:

String file = classLoader.getResource("hello.txt").getFile();
try(FileInputStream  fis = new FileInputStream(file);
    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() 方法推回读取数据的 ability。这在代码具有分析刚读取的数据并决定将其推回以便在下一步重新读取的逻辑时很有用。

javax.crypto.CipherInputStream 类将 Cipher 添加到 read() 方法中。如果 Cipher 被初始化为解密,javax.crypto.CipherInputStream 将尝试在返回之前解密数据。

java.util.zip.DeflaterInputStream 类在 deflate 压缩格式中压缩数据。

java.util.zip.InflaterInputStream 类在 deflate 压缩格式中解压缩数据。

java.security.DigestInputStream 类使用通过流的位更新相关的消息摘要。on (boolean on) 方法打开或关闭 digest 函数。可以通过 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 类的同伴,它写入数据而不是读取。它是一个抽象类,在 ByteArrayOutputStreamFilterOutputStreamObjectOutputStreamPipedOutputStreamFileOutputStream 中有如下直接实现。

FileOutputStream 类有以下直接扩展:BufferedOutputStreamCheckedOutputStreamDataOutputStreamPrintStreamjavax.crypto.CipherOutputStreamjava.util.zip.DeflaterOutputStreamjava.security.DigestOutputStreamjava.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 类的大部分子类也覆盖了许多其他方法。

在学习了 The InputStream class and its subclasses 部分的输入流之后,除了 PrintStream 类之外的所有 OutputStream 实现都应该对你来说很直观。因此,我们在这里只讨论 PrintStream 类。

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!"”。

或者,可以使用另一个接受File对象的PrintStream构造函数来达到相同的结果,如下所示:

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): 将提供的format字符串中的占位符替换为提供的values,并将结果写入流

  • PrintStream printf(Locale l, String format, Object... args): 与前面的方法相同,但使用提供的Locale对象应用本地化;如果提供的Locale对象为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子类的讨论,现在我们将注意力转向另一个类层次结构——来自 JCL 的ReaderWriter类及其子类。

ReaderWriter类及其子类

正如我们多次提到的,ReaderWriter类在功能上与InputStreamOutputStream类非常相似,但专门用于处理文本。它们将流字节解释为字符,并有自己的独立InputStreamOutputStream类层次结构。可以在没有ReaderWriter或其任何子类的情况下将流字节作为字符处理。我们已经在前面几节描述InputStreamOutputStream类的部分中看到了这样的例子。然而,使用ReaderWriter类可以使文本处理更简单,代码更容易阅读。

Reader及其子类

Reader类是一个抽象类,它以字符的形式读取流。它是InputStream的类似物,具有以下方法:

  • abstract void close(): 关闭流和其他使用的资源

  • void mark(int readAheadLimit): 标记流中的当前位置

  • boolean markSupported(): 如果流支持mark()操作,则返回true

  • static Reader nullReader(): 创建一个空的Reader,不读取任何字符

  • 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 子类包括 CharArrayReaderInputStreamReaderPipedReaderStringReaderBufferedReaderFilterReaderBufferedReader 类有一个 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): 从提供的 str 字符串中写入一个长度为 len 的子串,从 off 索引开始

如您所见,这个类的三个抽象方法 write(char[], int, int)flush()close() 必须由这个类的子类实现。它们通常也会重写其他方法。

JCL 中的 Writer 子类包括 CharArrayWriterOutputStreamWriterPipedWriterStringWriterBufferedWriterFilterWriterPrintWriterOutputStreamWriter 类有一个 FileWriter 子类。

java.io 包的其他类

java.io 包的其他类包括以下内容:

  • Console: 允许与基于字符的控制台设备进行交互,与当前 Java 虚拟机实例相关联

  • StreamTokenizer: 接收一个输入流并将其解析为 tokens

  • ObjectStreamClass: 类的序列化描述符

  • ObjectStreamField: 可序列化类的可序列化字段的描述

  • RandomAccessFile: 允许随机访问文件进行读取和写入,但其讨论超出了本书的范围

  • File: 允许创建和管理文件和目录;在 文件管理 部分中描述

Console

创建和运行执行应用程序的 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 package Maven 命令来创建一个 .jar 文件。(我们假设您已经在您的计算机上安装了 Maven。)它将删除 target 文件夹,然后重新创建它,将所有 .java 文件编译成 target 文件夹中的相应 .class 文件,然后将其存档到 .jar 文件中,learnjava-1.0-SNAPSHOT.jar

现在,我们可以使用以下命令从同一项目根目录启动 ConsoleDemo 应用程序:

java -cp ./target/examples-1.0-SNAPSHOT.jar  
          com.packt.learnjava.ch05_stringsIoStreams.ConsoleDemo

之前的 –cp 命令选项表示类路径,因此在我们的情况下,我们告诉 JVM 在 target 文件夹中的 .jar 文件中查找类。命令分为两行显示,因为页面宽度无法容纳它。但如果你要运行它,请确保你将其作为一行运行。结果将如下所示:

图片

这告诉我们我们现在有了 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) 相同的功能,但不回显输入的字符

要单独运行以下每个代码段,您需要在 main 方法中注释掉 console1() 调用,并取消注释 console2()console3(),使用 mvn package 重新编译,然后重新运行之前显示的 java 命令。

让我们通过以下示例(console2() 方法)演示上述方法:

Console console = System.console();
System.out.print("Enter something 1: "); 
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);
System.out.print("Enter password: ");
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));

上述示例的结果如下:

一些 IDE 无法运行这些示例并抛出 NullPointerException。如果是这种情况,请按照之前所述从命令行运行与控制台相关的示例。别忘了每次更改代码时都运行 maven package 命令。

另一组 Console 类方法可以与之前演示的方法一起使用:

  • Console format(String format, Object... args): 将提供的 format 字符串中的占位符替换为提供的 args 值,并显示结果

  • 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(): 清空控制台并强制任何缓冲的输出立即写入

这里是他们使用的一个示例(console3() 方法):

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();

上述代码的结果如下:

ReaderPrintWriter 也可以用来创建本节中提到的其他 InputOutput 流。

StreamTokenizer

StreamTokenizer 类解析输入流并生成令牌。其 StreamTokenizer(Reader r) 构造函数接受一个 Reader 对象,它是令牌的来源。每次在 StreamTokenizer 对象上调用 int nextToken() 方法时,都会发生以下情况:

  1. 解析下一个令牌。

  2. StreamTokenizer 实例字段 ttype 由表示令牌类型的值填充:

    • ttype 值可以是以下整数常量之一:TT_WORDTT_NUMBERTT_EOL(行尾)或 TT_EOF(流尾)。

    • 如果 ttype 值是 TT_WORD,则 StreamTokenizer 实例的 sval 字段由令牌的 String 值填充。

    • 如果 ttype 值是 TT_NUMBER,则 StreamTokenizer 实例字段 nval 由令牌的 double 值填充。

  3. StreamTokenizer 实例的 lineno() 方法返回当前行号。

在讨论StreamTokenizer类的其他方法之前,让我们看看一个例子。假设在项目resources文件夹中有一个tokens.txt文件,它包含以下四行文本:

There
happened
42
events.

以下代码将读取文件并标记其内容(InputOutputStream类的streamTokenizer()方法):

String file =  classLoader.
                           getResource("tokens.txt").getFile();
try(FileReader fr = new FileReader(file);
   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

ObjectStreamClassObjectStreamField类提供了对在 JVM 中加载的类的序列化数据的访问。可以使用以下查找方法之一找到/创建ObjectStreamClass对象:

  • static ObjectStreamClass lookup(Class cl): 查找可序列化类的描述符

  • static ObjectStreamClass lookupAny(Class cl): 查找任何类的描述符,无论是否可序列化

在找到ObjectStreamClass并且类是可序列化的(实现Serializable接口)之后,可以使用它来访问包含有关一个序列化字段信息的ObjectStreamField对象。如果类不可序列化,则与任何字段都不关联ObjectStreamField对象。

让我们来看一个例子。以下是从ObjectStreamClassObjectStreamField对象中获取信息的显示方法:

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(): 如果此字段具有原始类型,则返回true

  • boolean 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 file =  classLoader.getResource("tokens.txt").getFile();
try(Scanner sc = new Scanner(new File(file))){
    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() 方法不使用分隔符;它们只是尝试匹配提供的模式。有关更多信息,请参阅 Scanner 文档 (docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Scanner.html)。

文件管理

我们已经使用了一些方法来使用 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")。如果文件必须创建在目录中,则必须在文件名之前添加一个路径(当它传递给构造函数时),或者必须使用其他三个构造函数之一,例如以下示例(请参阅 Files 类中的 createFile2() 方法):

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);

然而,如果您更喜欢或必须使用这样的 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 utilities 部分讨论了它。它允许我们从路径中删除刚刚删除的目录,并继续这样做,直到所有嵌套目录都被删除,最后删除顶级目录。

列出文件和目录

以下方法可用于列出目录及其中的文件:

  • 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 Commons 的 FileUtils 和 IOUtils 工具

JCL 的一个流行伴侣是 Apache Commons 项目 (commons.apache.org),该项目提供了许多补充 JCL 功能的库。org.apache.commons.io 包的类包含在以下根包和子包中:

  • org.apache.commons.io 根包包含具有静态方法的实用工具类,用于常见任务,例如流行的 FileUtilsIOUtils 类,分别在 FileUtils 类Class IOUtils 类 部分进行描述。

  • org.apache.commons.io.input 包包含基于 InputStreamReader 实现的输入支持类,例如 XmlStreamReaderReversedLinesFileReader

  • org.apache.commons.io.output 包包含基于 OutputStreamWriter 实现的输出支持类,例如 XmlStreamWriterStringBuilderWriter

  • org.apache.commons.io.filefilter 包包含充当文件过滤器类的实现,例如 DirectoryFileFilterRegexFileFilter

  • org.apache.commons.io.comparator 包包含各种 java.util.Comparator 的实现,用于文件,例如 NameFileComparator

  • org.apache.commons.io.serialization 包提供了一个控制类反序列化的框架。

  • org.apache.commons.io.monitor 包允许监控文件系统,检查目录,以及文件的创建、更新或删除。你可以将 FileAlterationMonitor 对象作为线程启动,并创建一个 FileAlterationObserver 对象,该对象在指定的时间间隔内检查文件系统中的更改。

参考 Apache Commons 项目文档(commons.apache.org/)以获取更多详细信息。

FileUtils

流行的 org.apache.commons.io.FileUtils 类允许你执行所有可能的文件操作,如下所示:

  • 向文件写入

  • 从文件读取

  • 创建目录,包括父目录

  • 复制文件和目录

  • 删除文件和目录

  • 将内容转换为 URL 和从 URL 转换

  • 通过过滤器扩展列出文件和目录

  • 比较文件内容

  • 获取文件最后修改日期

  • 计算校验和

如果你计划以编程方式管理文件和目录,那么研究 Apache Commons 项目网站上的这个类的文档是必不可少的 (commons.apache.org/proper/commons-io/javadocs/api-2.7/org/apache/commons/io/FileUtils.html)。

IOUtils

org.apache.commons.io.IOUtils 是另一个非常有用的实用工具类,它提供了以下通用的 I/O 流操作方法:

  • 关闭流,忽略 null 和异常的 closeQuietly 方法

  • 读取流数据的 toXxx/read 方法

  • 写入数据到流的 write 方法

  • 复制方法,将所有数据从一 个流复制到另一个流

  • 比较两个流内容的 contentEquals 方法

这个类中所有读取流的方 法都内部进行了缓冲,因此不需要使用 BufferedInputStreamBufferedReader 类。copy 方法在幕后都使用了 copyLarge 方法,这大大提高了它们的性能和效率。

这个类对于管理 I/O 流是必不可少的。有关这个类及其方法的更多详细信息,请参阅 Apache Commons 项目网站 (commons.apache.org/proper/commons-io/javadocs/api-2.7/org/apache/commons/io/IOUtils.html)。

概述

在本章中,我们讨论了允许分析、比较和转换字符串的String类方法。我们还讨论了 JCL 和 Apache Commons 项目中的流行字符串实用工具。本章的两个大节专门用于输入/输出流以及 JCL 和 Apache Commons 项目中的支持类。文件管理类及其方法也在特定的代码示例中进行了讨论和演示。现在,你应该能够编写使用标准 Java API 和 Apache Commons 实用工具处理字符串和文件的代码。

在下一章中,我们将介绍 Java 集合框架及其三个主要接口,ListSetMap,包括泛型的讨论和演示。我们还将讨论用于管理数组、对象以及时间/日期值的实用工具类。

测验

  1. 以下代码打印什么?

    String str = "&8a!L";
    System.out.println(str.indexOf("a!L"));
    
    1. 3

    2. 2

    3. 1

    4. 0

  2. 以下代码打印什么?

    String s1 = "x12";
    String s2 = new String("x12");
    System.out.println(s1.equals(s2)); 
    
    1. Error

    2. Exception

    3. true

    4. false

  3. 以下代码打印什么?

    System.out.println("%wx6".substring(2));
    
    1. wx

    2. x6

    3. %w

    4. Exception

  4. 以下代码打印什么?

    System.out.println("ab"+"42".repeat(2));
    
    1. ab4242

    2. ab42ab42

    3. ab422

    4. Error

  5. 以下代码打印什么?

    String s = "  ";
    System.out.println(s.isBlank()+" "+s.isEmpty());
    
    1. false false

    2. false true

    3. true true

    4. true false

  6. 选择所有正确陈述:

    1. 流可以表示数据源。

    2. 输入流可以写入文件。

    3. 流可以表示数据目的地。

    4. 输出流可以在屏幕上显示数据。

  7. 选择关于java.io包中类的所有正确陈述:

    1. Reader扩展了InputStream

    2. 读者扩展了OutputStream

    3. Reader扩展了java.lang.Object

    4. Reader扩展了java.lang.Input

  8. 选择关于java.io包中类的所有正确陈述:

    1. Writer扩展了FilterOutputStream

    2. Writer扩展了OutputStream

    3. Writer扩展了java.lang.Output

    4. Writer扩展了java.lang.Object

  9. 选择关于java.io包中类的所有正确陈述:

    1. PrintStream扩展了FilterOutputStream

    2. PrintStream扩展了OutputStream

    3. PrintStream扩展了java.lang.Object

    4. PrintStream扩展了java.lang.Output

  10. 以下代码做什么?

    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();
    } 
    
    1. demo2目录中创建两个目录和一个文件

    2. 在其中创建一个目录和一个文件

    3. 不创建任何目录

    4. Exception

第六章:数据结构、泛型和常用工具

本章介绍了 Java 集合框架及其三个主要接口 ListSetMap,包括泛型的讨论和演示。在 Java 集合的上下文中还讨论了 equals()hashCode() 方法。管理数组、对象和时间/日期值的实用工具类也有相应的专用章节。学习完本章后,您将能够在您的程序中使用所有主要的数据结构。

本章将涵盖以下主题:

  • ListSetMap 接口

  • Collections 工具

  • Arrays 工具

  • Object 工具

  • java.time

让我们开始吧!

技术要求

要能够执行本章提供的代码示例,您需要以下内容:

  • 一台装有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java 标准版SE)版本 17 或更高版本

  • 一个 集成开发环境IDE)或您偏好的代码编辑器

如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明提供在本书的 第一章 中,Java 17 入门。本章的代码示例文件可在 GitHub 的 github.com/PacktPublishing/Learn-Java-17-Programming.git 仓库中的 examples/src/main/java/com/packt/learnjava/ch06_collections 文件夹中找到。

列表、集合和映射接口

shortintdouble。如果您需要存储此类类型值,元素必须是相应的包装类型,例如 ShortIntegerDouble

Java 集合支持存储和访问集合元素的多种算法:一个有序列表,一个唯一集合,一个字典(称为 java.util 包的 java.util 包包含以下内容:

  • 扩展 Collection 接口的接口:ListSetQueue,最常见的是

  • 实现先前列出的接口的类:ArrayListHashSetStackLinkedList 和一些其他类

  • Map 接口及其 ConcurrentMapSortedMap 子接口,例如

  • 实现 Map 相关接口的类:HashMapHashTableTreeMap,例如最常用的三个

审查 java.util 包中的所有类和接口需要一本专门的书籍。因此,在本节中,我们将简要概述三个主要接口——ListSetMap——以及每个接口的一个实现类——ArrayListHashSetHashMap。我们首先介绍 ListSet 接口共有的方法。ListSet 之间的主要区别是 Set 不允许元素重复。另一个区别是 List 保留元素的顺序,并允许对它们进行排序。

要在集合中识别一个元素,使用equals()方法。为了提高性能,实现Set接口的类通常也会使用hashCode()方法。这有助于快速计算具有相同哈希值的每个元素的整数(称为equals()方法)。这种方法比逐个比较集合中的每个元素要快。

正因如此,我们经常看到类的名字前面有一个hash前缀,表示该类使用哈希值,因此元素必须实现hashCode()方法。在这样做的时候,你必须确保它被实现,以便每次equals()方法返回true时,这两个对象通过hashCode()方法返回的哈希值也相等。否则,使用哈希值描述的算法将不会工作。

最后,在讨论java.util接口之前,先说几句关于泛型的话。

泛型

你最常见到这些泛型声明,如下所示:

List<String> list = new ArrayList<String>();
Set<Integer> set = new HashSet<Integer>();

在前面的例子中,<>被称为菱形,如下面的代码片段所示:

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>表示类型是TT的子类,其中T是作为集合泛型的类型。

  • <? super T>表示类型T或其任何基类(父类),其中T是作为集合泛型的类型。

有了这些,让我们从如何创建实现ListSet接口的类的对象开始——或者说,初始化ListSet类型的变量。为了演示这两个接口的方法,我们将使用两个类:ArrayList(实现List)和HashSet(实现Set)。

如何初始化 List 和 Set

自 Java 9 以来,ListSet接口有了静态的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] or [s4, s3]
//coll.add("s5");         //does not allow add element
//coll.remove("s2");   //does not allow remove element

如您所预期的那样,Set 类型的工厂方法不允许重复元素,因此我们已将该行注释掉(否则,前面的示例会在该行停止运行)。但您可能不会想到,您不能有 null 元素,并且在使用 of() 方法之一初始化集合后,您不能添加/删除/修改集合的元素。这就是为什么我们注释掉了前面示例中的一些行。如果您需要在集合初始化后添加元素,您必须使用构造函数或其他创建可修改集合的实用工具来初始化它(我们很快将看到一个 Arrays.asList() 的示例)。

Collection 接口为实现了 Collection 接口(ListSet 的父接口)的对象提供了两种添加元素的方法,其形式如下:

  • boolean add(E e):这尝试将提供的元素 e 添加到集合中;如果成功,则返回 true,如果无法完成(例如,当该元素已存在于 Set 接口中),则返回 false

  • boolean addAll(Collection<? extends E> c):这尝试将提供的集合中的所有元素添加到集合中;如果至少添加了一个元素,则返回 true,如果无法将元素添加到集合中(例如,当提供的集合 c 中的所有元素已存在于 Set 接口中),则返回 false

下面是一个使用 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]

ArrayListHashSet 类也有接受集合的构造函数,如下面的代码片段所示:

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]

现在,在我们学习了如何初始化集合之后,我们可以转向 ListSet 接口中的其他方法。

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类型的函数应用于集合中的每个元素,直到所有元素都被处理或函数抛出异常。我们将在第十三章中讨论什么是函数,函数式编程;现在,我们在这里只提供一个示例:

    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接口的类的对象;主要用于实现允许并行处理的方法,并且超出了本书的范围。

集合接口

如我们之前提到的,ListSet接口扩展了Collection接口,这意味着ListSet继承了Collection接口的所有方法。这些方法在此列出:

  • 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): 这尝试从集合中删除所有满足给定谓词的元素;这是我们将在第十三章函数式编程中描述的函数。如果至少删除了一个元素,则返回true

  • boolean retainAll(Collection<?> c): 这尝试在集合中仅保留提供的集合中包含的元素。类似于addAll()方法,如果至少保留了一个元素,则此方法返回true;否则返回false。为了正确执行此方法,集合的每个元素和提供的集合的每个元素都必须实现equals()方法,在Set的情况下,应实现hashCode()方法。

  • Object[] toArray()T[] toArray(T[] a): 这将集合转换为数组。

  • default T[] toArray(IntFunction<T[]> generator): 这使用提供的函数将集合转换为数组。我们将在第十三章函数式编程中解释函数。

  • default Stream<E> stream(): 这返回一个Stream对象(我们将在第十四章Java 标准流中讨论流)。

  • default Stream<E> parallelStream(): 这返回一个可能并行的Stream对象(我们将在第十四章Java 标准流中讨论流)。

List接口

List接口有几个不属于其任何父接口的其他方法,如下所述:

  • 静态工厂of()方法,在如何初始化 List 和 Set子节中描述。

  • void add(int index, E element): 这将在列表中提供的位置插入提供的元素。

  • static List<E> copyOf(Collection<E> coll): 这返回一个包含给定Collection接口元素的不可修改的List接口,并保留它们的顺序。以下代码片段演示了此方法的功能:

    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): 这返回列表中指定元素的第一个索引(位置);列表中的第一个元素具有索引(位置)0

  • int lastIndexOf(Object o): 这返回列表中指定元素的最后索引(位置);列表中的最后一个元素具有 list.size() - 1 的索引位置。

  • E remove(int index): 这将删除列表中指定位置处的元素;它返回被删除的元素。

  • E set(int index, E element): 这将替换列表中指定位置处的元素;它返回被替换的元素。

  • default void replaceAll(UnaryOperator<E> operator): 这通过将提供的函数应用于每个元素来转换列表。UnaryOperator 函数将在 第十三章函数式编程 中描述。

  • 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]
    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()) 相同的排序顺序。这种实现方式称为 函数式编程,我们将在 第十三章函数式编程 中更详细地讨论。

Set 接口

Set 接口有以下不属于其任何父接口的方法:

  • 静态 of() 工厂方法,在 如何初始化列表和集合 子部分中描述。

  • static Set<E> copyOf(Collection<E> coll) 方法:此方法返回一个包含给定 Collection 元素的不可修改的 Set 接口;它的工作方式与在 列表接口 部分描述的 static <E> List<E> copyOf(Collection<E> coll) 方法相同。

Map 接口

Map 接口有许多与 ListSet 方法类似的方法,如下所示:

  • int size()

  • void clear()

  • int hashCode()

  • boolean isEmpty()

  • boolean equals(Object o)

  • default void forEach(BiConsumer<K,V> action)

  • 静态工厂方法:of()of(K, V v)of(K k1, V v1, K k2, V v2) 以及许多其他方法

然而,Map 接口并没有扩展 IterableCollection 或其他任何接口。它被设计成能够存储 Entry,这是 Map 的内部接口。valuekey 对象都必须实现 equals() 方法。key 对象还必须实现 hashCode() 方法。

Map 接口中的许多方法与 ListSet 接口中的签名和功能完全相同,因此我们在这里不再重复。我们只将遍历 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 值,则将 oldValue 值替换为提供的 newValue 值。如果替换了 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): 这返回一个包含提供的 key 对象和 value 对象的不可修改的 Map.Entry 对象。

  • static Map<K,V> copy(Map<K,V> map): 这将提供的 Map 接口转换为不可修改的一个。

以下 Map 方法对于本书的范围来说过于复杂,所以我们只是为了完整性而提及它们。它们允许在 Map 接口中将多个值组合或计算并聚合到单个现有值中,或者创建一个新的值:

  • default V merge(K key, V value, BiFunction<V,V,V> remappingFunction): 如果提供的键值对存在且值不是 null,则使用提供的函数来计算一个新的值;如果新计算出的值是 null,则删除键值对。如果提供的键值对不存在或值是 null,则提供的非 null 值替换当前的值。此方法可用于聚合多个值;例如,它可以用于连接以下字符串值:map.merge(key, value, String::concat)。我们将在 第十三章函数式编程 中解释 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 循环遍历了条目集合,这在我们的观点中是一个更自然的方式来完成这个任务。请注意,打印出来的值并不与我们放入映射中的顺序相同。这是因为,从 Java 9 开始,不可修改的集合(即 of() 工厂方法产生的)已经向 Set 元素的顺序中添加了随机化,这改变了不同代码执行之间元素的顺序。这样的设计是为了确保程序员不会依赖于 Set 元素的特定顺序,这对于集合来说是不保证的。

不可修改的集合

请注意,of() 工厂方法产生的集合曾经被称为 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() 工厂方法创建的列表中添加元素,但如果存在指向该元素的引用,则其元素仍然可以被修改。

Collections 工具类

有两个处理集合的静态方法类非常流行且有用,如下所示:

  • java.util.Collections

  • org.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 + "}";
        }
    }
    
  • 以下是用于对 Person 对象列表进行排序的 Comparator 类:

    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();
        }
    }
    

现在,我们可以使用 PersonComparePersons 类,如下所示:

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 类中还有许多其他实用工具,所以我们建议您至少查阅一次相关文档,并了解其所有功能。

CollectionUtils

Apache Commons项目中,org.apache.commons.collections4.CollectionUtils类包含静态无状态方法,这些方法补充了java.util.Collections类的方法。它们有助于搜索、处理和比较 Java 集合。

要使用这个类,您需要在 Maven 的pom.xml配置文件中添加以下依赖项:

 <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-collections4</artifactId>
    <version>4.4</version>
 </dependency>

这个类中有许多方法,并且可能随着时间的推移添加更多方法。这些实用程序是在Collections方法之外创建的,因此它们更加复杂和微妙,不适合本书的范围。为了给您一个关于CollectionUtils类中可用方法的概述,以下是按功能分组的方法的简要描述:

  • 从集合中检索元素的方法

  • 向集合中添加元素或一组元素的方法

  • Iterable元素合并到集合中的方法

  • 根据条件移除或保留元素的方法

  • 比较两个集合的方法

  • 转换集合的方法

  • 从集合中选择并过滤的方法

  • 生成两个集合的并集、交集或差集的方法

  • 创建不可变空集合的方法

  • 检查集合大小和空的方法

  • 反转数组的方法

最后一个方法可能属于处理数组的实用程序类,这正是我们现在要讨论的。

数组实用程序

有两个类提供了处理集合的静态方法,它们非常流行且非常有用,如下所示:

  • java.util.Arrays

  • org.apache.commons.lang3.ArrayUtils

我们将简要回顾每一个。

java.util.Arrays 类

我们已经多次使用了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(): 这生成数组元素或其中一些元素的流(根据索引范围指定);参见 第十四章Java 标准流

所有这些方法都很有用,但我们想引起您的注意,特别是 equals(a1, a2)deepEquals(a1, a2) 方法。它们在数组比较方面特别有用,因为数组对象不能实现自定义的 equals() 方法,而是使用 Object 类的实现(仅比较引用)。equals(a1, a2)deepEquals(a1, a2) 方法允许比较不仅 a1a2 引用,而且还使用 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() 方法执行相同的操作,但仅适用于 一维1D)数组。

ArrayUtils 类

org.apache.commons.lang3.ArrayUtils 类通过向数组管理工具添加新方法,并处理在可能抛出 NullPointerException 的情况下能够处理 null,从而补充了 java.util.Arrays 类。要使用此类,您需要将以下依赖项添加到 Maven 的 pom.xml 配置文件中:

<dependency>
   <groupId>org.apache.commons</groupId>
   <artifactId>commons-lang3</artifactId>
   <version>3.12.0</version>
</dependency>

ArrayUtils 类大约有 300 个重载方法,可以归纳为以下 12 组:

  • add(), addAll(), 和 insert(): 这些向数组添加元素。

  • clone(): 这克隆数组,类似于 Arrays 类的 copyOf() 方法以及 java.lang.Systemarraycopy() 方法。

  • getLength(): 这返回数组长度或 0,当数组本身为 null 时。

  • hashCode(): 这计算数组的哈希值,包括嵌套数组。

  • contains(), indexOf(), 和 lastIndexOf(): 这些用于搜索数组。

  • isSorted(), isEmpty, 和 isNotEmpty(): 这些检查数组并处理 null

  • isSameLength()isSameType(): 这些用于比较数组。

  • nullToEmpty(): 这将 null 数组转换为空数组。

  • remove(), removeAll(), removeElement(), removeElements(), 和 removeAllOccurances(): 这些方法会移除某些或所有元素。

  • reverse(), shift(), shuffle(), 和 swap(): 这些方法会改变数组元素的顺序。

  • subarray(): 这个方法根据索引范围提取数组的一部分。

  • toMap(), toObject(), toPrimitive(), toString(), 和 toStringArray(): 这些方法将数组转换为其他类型并处理 null 值。

对象实用工具

本节中描述了以下两个实用工具:

  • java.util.Objects

  • org.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)o;
        return age == person.getAge() &&
                Objects.equals(name, person.getName()); 
    }
    @Override
    public int hashCode(){
        return Objects.hash(age, name);
    }
}

注意,我们不会检查 name 属性是否为 null,因为 Object.equals() 在任何参数为 null 时不会中断,它只是执行比较对象的工作。如果只有一个参数为 null,则返回 false。如果两个都是 null,则返回 true

使用 Object.equals() 是实现 equals() 方法的安全方式;然而,如果你需要比较可能为数组的对象,最好使用 Objects.deepEquals() 方法,因为它不仅处理 null,就像 Object.equals() 方法一样,而且还比较所有数组元素的值,即使数组是多维的,如下所示:

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() 方法也处理 null 值。需要记住的一个重要事项是,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() 方法为 null0 生成相同的哈希值,这可能会对基于哈希值的某些算法造成问题。

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 个方法。我们建议您熟悉它们,以便在存在相同实用程序的情况下避免编写自己的实用程序。

ObjectUtils

前一节中的最后一条语句适用于 Apache Commons 库中的 org.apache.commons.lang3.ObjectUtils 类,该库补充了前一节中描述的 java.util.Objects 类的方法。本书的范围和分配的大小不允许对 ObjectUtils 类下的所有方法进行详细审查,因此我们将根据它们的相关功能将它们简要地分组描述。要使用此类,您需要在 Maven 的 pom.xml 配置文件中添加以下依赖项:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.12.0</version>
</dependency>

ObjectUtils 类的所有方法可以分为七个组,如下所示:

  • 对象克隆方法

  • 支持比较两个对象的方法

  • notEqual() 方法,用于比较两个对象的不相等性,其中一个或两个对象可能是 null

  • 几个 identityToString() 方法,它们生成提供的对象的 String 表示形式,就像由 Object 基类的默认方法生成一样,并可选地将其附加到另一个对象上。

  • allNotNull()anyNotNull() 方法,用于分析对象数组中的 null

  • firstNonNull()defaultIfNull()方法,这些方法分析一个对象数组,并返回第一个非null对象或默认值

  • max()min()median()mode()方法,这些方法分析一个对象数组,并返回与方法名称相对应的对象

java.time

java.time包及其子包中有很多类。它们被引入作为替代其他(较旧的包)处理日期和时间的包。新类是线程安全的(因此,更适合多线程处理),而且也很重要的是,它们的设计更加一致,更容易理解。此外,新实现遵循国际标准化组织ISO)关于日期和时间格式的标准,但也允许使用任何其他自定义格式。

我们将描述以下五个主要类,并演示如何使用它们:

  • java.time.LocalDate

  • java.time.LocalTime

  • java.time.LocalDateTime

  • java.time.Period

  • java.time.Duration

所有这些以及java.time包及其子包中的其他类都功能丰富,涵盖了所有实际案例。但我们不会讨论所有这些;我们只会介绍基础知识以及最常用的用例。

LocalDate

LocalDate类不携带时间。它以ISO 8601格式(yyyy-MM-dd)表示日期,如下面的代码片段所示:

System.out.println(LocalDate.now()); 
                    //prints: current date in format yyyy-MM-dd

那是撰写时的当前位置的当前日期。该值是从计算机时钟中获取的。同样,您可以使用那个静态的now(ZoneId zone)方法获取任何其他时区的当前日期。可以使用静态ZoneId.of(String zoneId)方法构造一个ZoneId对象,其中String zoneIdZoneId.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.of("Asia/Tokyo");
System.out.println(LocalDate.now(zoneId)); 
           //prints: current date in Tokyo in format yyyy-MM-dd

一个LocalDate对象可以使用以下方法表示过去或未来的任何日期:

  • LocalDate parse(CharSequence text): 这将从ISO 8601格式(yyyy-MM-dd)的字符串中构造一个对象。

  • LocalDate parse(CharSequence text, DateTimeFormatter formatter): 这将从由 DateTimeFormatter 对象指定的格式中的字符串构造一个对象,该对象具有丰富的模式系统以及许多预定义的格式——这里有一些例子:

    • BASIC_ISO_DATE—例如,20111203

    • ISO_LOCAL_DATE ISO—例如,2011-12-03

    • ISO_OFFSET_DATE—例如,2011-12-03+01:00

    • ISO_DATE—例如,2011-12-03+01:00; 2011-12-03

    • ISO_LOCAL_TIME—例如,10:15:30

    • ISO_OFFSET_TIME—例如,10:15:30+01:00

    • ISO_TIME—例如,10:15:30+01:00; 10:15:30

    • ISO_LOCAL_DATE_TIME—例如,2011-12-03T10:15:30

  • LocalDate of(int year, int month, int dayOfMonth): 从年、月和日构建一个对象。

  • LocalDate of(int year, Month, int dayOfMonth): 从年、月(枚举常量)和日构建一个对象。

  • LocalDate ofYearDay(int year, int dayOfYear): 从年和年内的日构建一个对象。

以下代码片段演示了前面提到的列表中的方法:

LocalDate lc1 = LocalDate.parse("2023-02-23");
System.out.println(lc1);           //prints: 2023-02-23
LocalDate lc2 = LocalDate.parse("20230223",
                     DateTimeFormatter.BASIC_ISO_DATE);
System.out.println(lc2);           //prints: 2023-02-23
DateTimeFormatter frm =
              DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate lc3 =  LocalDate.parse("23/02/2023", frm);
System.out.println(lc3);           //prints: 2023-02-23
LocalDate lc4 =  LocalDate.of(2023, 2, 23);
System.out.println(lc4);           //prints: 2023-02-23
LocalDate lc5 =  LocalDate.of(2023, Month.FEBRUARY, 23);
System.out.println(lc5);           //prints: 2023-02-23
LocalDate lc6 = LocalDate.ofYearDay(2023, 54);
System.out.println(lc6);           //prints: 2023-02-23

LocalDate对象可以提供各种值,如下面的代码片段所示:

LocalDate lc = LocalDate.parse("2023-02-23");
System.out.println(lc);                  //prints: 2023-02-23
System.out.println(lc.getYear());        //prints: 2023
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: THURSDAY
System.out.println(lc.isLeapYear());     //prints: false
System.out.println(lc.lengthOfMonth());  //prints: 28
System.out.println(lc.lengthOfYear());   //prints: 365

一个LocalDate对象可以被修改,如下所示:

LocalDate lc = LocalDate.parse("2023-02-23");
System.out.println(lc.withYear(2024));     //prints: 2024-02-23
System.out.println(lc.withMonth(5));       //prints: 2023-05-23
System.out.println(lc.withDayOfMonth(5));  //prints: 2023-02-05
System.out.println(lc.withDayOfYear(53));  //prints: 2023-02-22
System.out.println(lc.plusDays(10));       //prints: 2023-03-05
System.out.println(lc.plusMonths(2));      //prints: 2023-04-23
System.out.println(lc.plusYears(2));       //prints: 2025-02-23
System.out.println(lc.minusDays(10));      //prints: 2023-02-13
System.out.println(lc.minusMonths(2));     //prints: 2022-12-23
System.out.println(lc.minusYears(2));      //prints: 2021-02-23

一个LocalDate对象可以进行比较,如下所示:

LocalDate lc1 = LocalDate.parse("2023-02-23");
LocalDate lc2 = LocalDate.parse("2023-02-22");
System.out.println(lc1.isAfter(lc2));       //prints: true
System.out.println(lc1.isBefore(lc2));      //prints: false

LocalDate类中有许多其他有用的方法。如果您必须处理日期,我们建议您阅读java.time包及其子包。

LocalTime 类

LocalTime类包含没有日期的时间。它具有与LocalDate类类似的方法。以下是如何创建LocalTime类对象的示例:

System.out.println(LocalTime.now()); //prints: 21:15:46.360904
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类中有许多其他有用的方法。如果您必须处理日期,我们建议您阅读此类以及其他java.time包及其子包的类的 API。

LocalDateTime 类

LocalDateTime类包含日期和时间,并且具有LocalDateLocalTime类所有的所有方法,所以我们在这里不会重复它们。我们只会展示如何创建一个LocalDateTime类的对象,如下所示:

System.out.println(LocalDateTime.now());       
                     //prints: 2019-03-04T21:59:00.142804
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类中有许多其他有用的方法。如果您必须处理日期,我们建议您阅读此类以及其他java.time包及其子包的类的 API。

Period 和 Duration 类

java.time.Periodjava.time.Duration类被设计用来包含一定的时间量,如下所述:

  • Period对象包含以年、月和日为单位的时间量。

  • Duration对象包含小时、分钟、秒和纳秒的时间量。

以下代码片段演示了使用LocalDateTime类创建和使用它们的方法,但相同的方法也存在于LocalDate(对于Period)和LocalTime(对于Duration)类中:

LocalDateTime ldt1 = LocalDateTime.parse("2023-02-23T20:23:12");
LocalDateTime ldt2 = ldt1.plus(Period.ofYears(2));
System.out.println(ldt2);      //prints: 2025-02-23T20:23:12

以下方法与LocalTime类的方法以相同的方式工作:

LocalDateTime ldt = LocalDateTime.parse("2023-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("2023-02-23");
LocalDate ld2 =  LocalDate.parse("2023-03-25");
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.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.

PeriodDuration类中有许多其他有用的方法。如果您必须处理日期,我们建议您阅读此类以及其他java.time包及其子包的类的 API。

一天的 Period

Java 16 包含了一种新的时间格式,它将一天中的时间段显示为 AMin the morning 等类似的形式。以下两个方法演示了使用 DateTimeFormatter.ofPattern() 方法与 LocalDateTimeLocalTime 类的使用:

void periodOfDayFromDateTime(String time, String pattern){
   LocalDateTime date = LocalDateTime.parse(time);
   DateTimeFormatter frm =
            DateTimeFormatter.ofPattern(pattern);
   System.out.print(date.format(frm));
} 
void periodOfDayFromTime(String time, String pattern){
   LocalTime date = LocalTime.parse(time);
   DateTimeFormatter frm =
           DateTimeFormatter.ofPattern(pattern);
   System.out.print(date.format(frm));
}

以下代码演示了 "h a""h B" 模式的效果:

periodOfDayFromDateTime("2023-03-23T05:05:18.123456", 
           "MM-dd-yyyy h a"); //prints: 03-23-2023 5 AM
periodOfDayFromDateTime("2023-03-23T05:05:18.123456", 
       "MM-dd-yyyy h B"); //prints: 03-23-2023 5 at night
periodOfDayFromDateTime("2023-03-23T06:05:18.123456", 
                  "h B");   //prints: 6 in the morning
periodOfDayFromTime("11:05:18.123456", "h B"); 
                            //prints: 11 in the morning
periodOfDayFromTime("12:05:18.123456", "h B"); 
                          //prints: 12 in the afternoon
periodOfDayFromTime("17:05:18.123456", "h B"); 
                          //prints: 5 in the afternoon
periodOfDayFromTime("18:05:18.123456", "h B"); 
                          //prints: 6 in the evening
periodOfDayFromTime("20:05:18.123456", "h B"); 
                          //prints: 8 in the evening
periodOfDayFromTime("21:05:18.123456", "h B"); 
                         //prints: 9 at night

您可以使用 "h a""h B" 模式使时间表示更友好。

概述

本章向您介绍了 Java 集合框架及其三个主要接口:ListSetMap。每个接口都进行了讨论,并使用实现类演示了其方法。泛型也得到了解释和演示。为了使对象能够被 Java 集合正确处理,必须实现 equals()hashCode() 方法。

CollectionsCollectionUtils 工具类提供了许多用于集合处理的实用方法,并在示例中展示了这些方法,包括 ArraysArrayUtilsObjectsObjectUtils 类。

java.time 包的类方法允许管理时间/日期值,并在特定的实际代码片段中进行了演示。

您现在可以在您的程序中使用本章讨论的所有主要数据结构。

在下一章中,我们将概述 JCL 以及一些外部库,包括支持测试的库。具体来说,我们将探索 org.junitorg.mockitoorg.apache.log4jorg.slf4jorg.apache.commons 包及其子包。

测验

  1. Java 集合框架是什么?选择所有适用的选项:

    1. 集合框架

    2. java.util 包的类和接口

    3. ListSetMap 接口

    4. 实现集合数据结构的类和接口

  2. 在集合中,“泛型”指的是什么?选择所有适用的选项:

    1. 集合结构定义

    2. 元素类型声明

    3. 类型泛化

    4. 提供编译时安全性的机制

  3. of() 工厂方法集合的限制是什么?选择所有适用的选项:

    1. 它不允许 null 元素。

    2. 它不允许向初始化的集合中添加元素。

    3. 它不允许修改与初始化集合相关的元素。

  4. 实现 java.lang.Iterable 接口允许什么?选择所有适用的选项:

    1. 它允许逐个访问集合的元素。

    2. 它允许集合在 FOR 语句中使用。

    3. 它允许集合在 WHILE 语句中使用。

    4. 它允许集合在 DO...WHILE 语句中使用。

  5. 实现 java.util.Collection 接口允许什么?选择所有适用的选项:

    1. 向集合中添加来自另一个集合的元素

    2. 从另一个集合中移除集合中的对象

    3. 仅修改属于另一个集合的集合中的元素

    4. 从不属于另一个集合的对象集合中删除

  6. 选择所有与 List 接口方法相关的正确陈述:

    1. z get(int index): 这个方法返回列表中指定位置的元素。

    2. E remove(int index): 这个方法从列表中删除指定位置的元素;它返回被删除的元素。

    3. static List<E> copyOf(Collection<E> coll): 这个方法返回一个包含给定 Collection 接口元素的不可修改的 List 接口,并保留它们的顺序。

    4. int indexOf(Object o): 这个方法返回列表中指定元素的位置。

  7. 选择所有与 Set 接口方法相关的正确陈述:

    1. E get(int index): 这个方法返回列表中指定位置的元素。

    2. E remove(int index): 这个方法从列表中删除指定位置的元素;它返回被删除的元素。

    3. static Set<E> copyOf(Collection<E> coll): 这个方法返回一个包含给定 Collection 接口元素的不可修改的 Set 接口。

    4. int indexOf(Object o): 这个方法返回列表中指定元素的位置。

  8. 选择所有与 Map 接口方法相关的正确陈述:

    1. int size(): 这个方法返回存储在映射中的键值对数量;当 isEmpty() 方法返回 true 时,此方法返回 0

    2. V remove(Object key): 这个方法从映射中删除键和值;如果没有这样的键或值是 null,则返回 valuenull

    3. default boolean remove(Object key, Object value): 如果映射中存在这样的键值对,则删除键值对;如果值被删除,则返回 true

    4. default boolean replace(K key, V oldValue, V newValue): 如果提供的键当前映射到 oldValue 值,则用提供的 newValue 值替换 oldValue 值——如果替换了 oldValue 值,则返回 true;否则返回 false

  9. 选择所有与 Collections 类的 static void sort(List<T> list, Comparator<T> comparator) 方法相关的正确陈述:

    1. 如果列表元素实现了 Comparable 接口,它将根据列表的自然顺序对列表进行排序。

    2. 它根据提供的 Comparator 对象对列表的顺序进行排序。

    3. 如果列表元素实现了 Comparable 接口,它将根据提供的 Comparator 对象对列表的顺序进行排序。

    4. 不论列表元素是否实现了 Comparable 接口,它都将根据提供的 Comparator 对象对列表的顺序进行排序。

  10. 执行以下代码的结果是什么?

    List<String> list1 = Arrays.asList("s1","s2", "s3");
    List<String> list2 = Arrays.asList("s3", "s4");
    Collections.copy(list1, list2);
    System.out.println(list1);    
    
    1. [s1, s2, s3, s4]

    2. [s3, s4, s3]

    3. [s1, s2, s3, s3, s4]

    4. [s3, s4]

  11. CollectionUtils 类方法的功能是什么?选择所有适用的:

    1. 它通过处理 null 来匹配 Collections 类方法的函数。

    2. 它补充了 Collections 类方法的函数。

    3. 它以 Collections 类方法不进行的方式搜索、处理和比较 Java 集合。

    4. 它重复了Collections类方法的功能

  12. 执行以下代码的结果是什么?

    Integer[][] ar1 = {{42}};
    Integer[][] ar2 = {{42}};
    System.out.print(Arrays.equals(ar1, ar2) + " "); 
    System.out.println(Arrays.deepEquals(arr3, arr4)); 
    
    1. false true

    2. false

    3. true false

    4. true

  13. 执行以下代码的结果是什么?

    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));
    
    1. 1 2 0 false true

    2. 2 1 1 false true

    3. 2 1 0 false true

    4. 2 1 0 true false

  14. 执行以下代码的结果是什么?

     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) + " "); 
    
    1. true 0 0

    2. Error

    3. false -1 0

    4. false 31 0

  15. 执行以下代码的结果是什么?

    String[] arr = {"c", "x", "a"};
    System.out.print(ObjectUtils.min(arr) + " ");
    System.out.print(ObjectUtils.median(arr) + " ");
    System.out.println(ObjectUtils.max(arr));
    
    1. c x a

    2. a c x

    3. x c a

    4. a x c

  16. 执行以下代码的结果是什么?

    LocalDate lc = LocalDate.parse("1900-02-23");
    System.out.println(lc.withYear(21)); 
    
    1. 1921-02-23

    2. 21-02-23

    3. 0021-02-23

    4. Error

  17. 执行以下代码的结果是什么?

    LocalTime lt2 = LocalTime.of(20, 23, 12);
    System.out.println(lt2.withNano(300));      
    
    1. 20:23:12.000000300

    2. 20:23:12.300

    3. 20:23:12:300

    4. Error

  18. 执行以下代码的结果是什么?

    LocalDate ld = LocalDate.of(2020, 2, 23);
    LocalTime lt = LocalTime.of(20, 23, 12);
    LocalDateTime ldt = LocalDateTime.of(ld, lt);
    System.out.println(ldt);                
    
    1. 2020-02-23 20:23:12

    2. 2020-02-23T20:23:12

    3. 2020-02-23:20:23:12

    4. Error

  19. 执行以下代码的结果是什么?

    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);
    
    1. 2020-02-23T20:23:12 2020-02-23T20:23:12

    2. 2020-02-23T20:23:12 2020-02-23T20:35:12

    3. 2018-02-23T20:23:12 2020-02-23T20:35:12 2020-02-23T20:23:12

    4. 2018-02-23T20:23:12 2020-02-23T20:35:12 2018-02-23T20:35:12

第七章:Java 标准库和外部库

没有使用标准库(也称为 Java 类库JCL))就无法编写 Java 程序。这就是为什么对这类库的熟悉程度对于成功的编程来说与了解语言本身一样重要。

此外,还有非标准库,也称为外部库或第三方库,因为它们不包括在 Java 开发工具包JDK)的发行版中。其中一些已经成为任何程序员工具箱中的永久性固定装置。

跟踪这些库中所有可用的功能并不容易。这是因为一个 java.lang

本章的目的是为您提供一个关于 JCL 最受欢迎的包以及外部库的功能概述。

本章将涵盖以下主题:

  • Java 类库JCL

  • 外部库

技术要求

要能够执行本章中的代码示例,您需要以下内容:

  • 搭载 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高版本

  • 您选择的 IDE 或代码编辑器

第一章“Java 17 入门”中提供了如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明。包含本章代码示例的文件可在 GitHub 的 github.com/PacktPublishing/Learn-Java-17-Programming.git 仓库中的 examples/src/main/java/com/packt/learnjava/ch07_libraries 文件夹中找到。

Java 类库(JCL)

JCL 是一组实现语言的包。用更简单的话说,它是一组包含在 JDK 中的 .class 文件集合,可供使用。一旦您安装了 Java,您就会作为安装的一部分获得它们,并可以使用 JCL 类作为构建块开始构建您的应用程序代码,这些构建块负责处理大量的底层管道。JCL 的丰富性和易用性在很大程度上促进了 Java 的普及。

要使用 JCL 包,您可以在不向 pom.xml 文件添加新依赖项的情况下导入它。Maven 会自动将 JCL 添加到类路径中。这就是标准库和外部库的区别;如果您需要将库(通常是 .jar 文件)作为依赖项添加到 Maven pom.xml 配置文件中,则该库是外部库。否则,它是一个标准库或 JCL。

一些 JCL 包名以 java 开头。传统上,它们被称为 核心 Java 包,而以 javax 开头的那些曾经被称为 扩展包。这样做是因为扩展被认为是可选的,甚至可能独立于 JDK 发布。也曾尝试将前一个扩展库提升为核心包。但这将需要将包名从 java 更改为 javax,这将破坏已经使用 javax 包的应用程序。因此,这个想法被放弃了,所以核心包和扩展包之间的区别逐渐消失了。

因此,如果你查看 Oracle 网站上的官方 Java API,你将看到不仅列出了 javajavax 包,还包括 jdkcom.sunorg.xml 以及其他一些包。这些额外的包主要用于其他专用应用程序的工具。在这本书中,我们将主要关注主流 Java 编程,并只讨论 javajavax 包。

java.lang

这个包非常基础,因此在使用它时不需要导入。JVM 作者决定自动导入它。它包含了 JCL 中最常用的类:

  • Object:任何其他 Java 类的基类。

  • Class:在运行时携带每个加载类的元数据。

  • StringStringBufferStringBuilder:支持 String 类型的操作。

  • 所有原始类型的包装类:ByteBooleanShortCharacterIntegerLongFloatDouble

  • Number:数值原始类型包装类的基类——所有之前列出的类,除了 Boolean

  • System:提供对重要系统操作以及标准输入和输出的访问(我们在本书的每个代码示例中都使用了 System.out 对象)。

  • Runtime:提供对执行环境的访问。

  • ThreadRunnable 接口:对于创建 Java 线程是基本的。

  • Iterable 接口:用于迭代语句。

  • Math:提供基本数值操作的方法。

  • Throwable:所有异常的基类。

  • Error:这是一个 exception 类,因为所有它的子类都用于传达系统错误,这些错误不能被应用程序捕获。

  • Exception:这个类及其直接子类代表受检异常。

  • RuntimeException:这个类及其子类代表非受检异常,也称为运行时异常。

  • ClassLoader:这个类读取 .class 文件并将它们(加载)放入内存;它也可以用来构建定制的类加载器。

  • ProcessProcessBuilder:这些类允许你创建其他 JVM 进程。

还有很多其他有用的类和接口也都可以使用。

java.util

java.util 包的大部分内容都是用来支持 Java 集合的:

  • Collection 接口:许多其他集合接口的基接口,它声明了管理集合元素所需的所有基本方法;例如,size()add()remove()contains()stream()等。它还扩展了java.lang.Iterable接口并继承了其方法,包括iterator()forEach(),这意味着Collection接口的任何实现或其子接口(ListSetQueueDeque等)也可以用于迭代语句,例如ArrayListLinkedListHashSetAbstractQueueArrayDeque等。

  • Map 接口及其实现类:HashMapTreeMap等。

  • Collections 类:此类提供了许多静态方法,用于分析、操作和转换集合。

许多其他集合接口、类和相关实用工具也可用。

我们在第六章中讨论了 Java 集合,并展示了它们的使用示例第六章数据结构、泛型和常用工具

java.util 包还包括几个其他有用的类:

  • Objects:提供各种与对象相关的实用方法,其中一些我们在第六章中讨论过,数据结构、泛型和常用工具

  • Arrays:包含 160 个静态方法来操作数组,其中一些我们在第六章中讨论过,数据结构、泛型和常用工具

  • Formatter:这允许你格式化任何原始类型,包括StringDate和其他类型;我们曾在第六章中学习过如何使用它,数据结构、泛型和常用工具

  • OptionalOptionalIntOptionalLongOptionalDouble:这些类通过包装可能为 null 或非 null 的实际值来帮助避免NullPointerException

  • Properties:帮助读取和创建用于应用程序配置和类似目的的键值对。

  • Random:通过生成伪随机数流来补充java.lang.Math.random()方法。

  • StringTokenizer:将String对象分割成由指定分隔符分隔的标记。

  • StringJoiner: 构建一个由指定分隔符分隔的字符序列。可选地,它被指定的前缀和后缀包围。

许多其他有用的实用类也可用,包括支持国际化、Base64 编码和解码的类。

java.time

java.time 包包含用于管理日期、时间、期间和持续时间的类。该包包括以下内容:

  • Month 枚举。

  • DayOfWeek 枚举。

  • Clock 类,它使用时区返回当前的瞬间、日期和时间。

  • DurationPeriod 类表示和比较不同时间单位的时间量。

  • LocalDateLocalTimeLocalDateTime 类表示不带时区的日期和时间。

  • ZonedDateTime 类表示带时区的日期和时间。

  • ZoneId 类识别一个时区,例如 America/Chicago。

  • java.time.format.DateTimeFormatter 类允许您按照 国际标准化组织 (ISO) 格式呈现日期和时间,例如 YYYY-MM-DD 模式。

  • 一些支持日期和时间操作的其它类。

我们在 第六章数据结构、泛型和常用工具 中讨论了这些类中的大多数。

java.io 和 java.nio

java.iojava.nio 包包含支持使用流、序列化和文件系统读取和写入数据的类和接口。这两个包之间的区别如下:

  • java.io 包的类允许您在不缓存数据的情况下读取/写入数据(正如我们在 第五章字符串、输入/输出和文件)中讨论的那样),而 java.nio 包的类创建缓冲区,允许您在填充的缓冲区中来回移动。

  • java.io 包的类在读取或写入所有数据之前会阻塞流,而 java.nio 包的类以非阻塞方式实现(我们将在 第十五章响应式编程)中讨论非阻塞方式)。

java.sql 和 javax.sql

这两个包构成了 javax.sql 包,它通过提供以下支持来补充 java.sql 包:

  • DataSource 接口作为 DriverManager 类的替代方案

  • 连接和语句池

  • 分布式事务

  • 行集

我们将在 第十章数据库中的数据管理 中讨论这些包并展示代码示例。

java.net

java.net 包包含支持以下两个级别的应用程序网络功能的类:

  • 低级网络,基于以下:

    • IP 地址

    • 套接字,作为基本的双向数据通信机制

    • 各种网络接口

  • 高级网络,基于以下:

    • 通用资源标识符 (URI)

    • 通用资源定位符 (URL)

    • 连接到由 URL 指向的资源

我们将在 第十二章网络编程 中讨论这个包并展示其代码示例。

java.lang.math 和 java.math

java.lang.math 包包含执行基本数值操作的方法,例如计算两个数值的最小值和最大值、绝对值、基本指数、对数、平方根、三角函数以及许多其他数学运算。

java.math 包补充了 java.lang 包的原生类型和包装类,因为你可以使用 BigDecimalBigInteger 类处理更大的数字。

java.awt, javax.swing, 和 javafx

第一个支持构建 java.awt 包的 Java 库。它提供了一个接口,允许你访问执行平台的本地系统,从而创建和管理窗口、布局和事件。它还提供了基本的 GUI 小部件(如文本字段、按钮和菜单),提供了对系统托盘的访问,并允许你从 Java 代码中启动网页浏览器和发送电子邮件。它对本地代码的依赖性使得基于 AWT 的 GUI 在不同的平台上看起来不同。

1997 年,Sun Microsystems 和 Netscape Communications Corporation 引入了 Java 基础类库,后来称为 Swing,并将它们放在了 javax.swing 包中。使用 Swing 构建的 GUI 组件能够模拟某些本地平台的样式和感觉,同时也允许你插入一个不依赖于其运行平台的样式和感觉。它通过添加选项卡面板、滚动面板、表格和列表来扩展了 GUI 可以拥有的小部件列表。Swing 组件是轻量级的,因为它们不依赖于本地代码,并且完全用 Java 实现。

2007 年,Sun Microsystems 宣布创建了 JavaFX,它最终成为了一个跨多种不同设备创建和交付桌面应用程序的软件平台。它旨在取代 Swing 成为 Java SE 的标准 GUI 库。JavaFX 框架位于以 javafx 开头的包中,支持所有主要的桌面 操作系统OSs)和多个移动操作系统,包括 Symbian OS、Windows Mobile 和一些专有实时操作系统。

JavaFX 为 GUI 开发者的工具箱增加了对平滑动画、网页视图、音频和视频播放以及样式的支持,这些都是基于 层叠样式表CSS)。然而,Swing 有更多的组件和第三方库,因此使用 JavaFX 可能需要创建在 Swing 中早已实现的定制组件和管道。这就是为什么,尽管 JavaFX 被推荐为桌面 GUI 实现的首选,但 Swing 仍将在可预见的未来成为 Java 的一部分,根据 Oracle 网站的官方回应(www.oracle.com/technetwork/java/javafx/overview/faq-1446554.html#6)。因此,继续使用 Swing 是可能的,但如果可能的话,最好切换到 JavaFX。

我们将在 第十二章 Java GUI 编程 中讨论 JavaFX 并展示其代码示例。

外部库

最常用的第三方非 JCL 库列表之间包括 20 到 100 个库。在本节中,我们将讨论那些包含在大多数此类列表中的库。所有这些都是开源项目。

org.junit

org.junit 包是开源测试框架 JUnit 的根包。它可以作为以下 pom.xml 依赖项添加到项目中:

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.2</version>
    <scope>test</scope>
</dependency>

前一个依赖项标签中的 scope 值告诉 Maven 在测试代码将要运行时包含库 .jar 文件,但不在应用程序的生产 .jar 文件中。有了这个依赖项,您可以创建一个测试。您可以自己编写代码,或者通过以下操作让 IDE 帮您完成:

  1. 右键点击您想要测试的类名。

  2. 选择 转到

  3. 选择 测试

  4. 点击 创建新测试

  5. 选择您想要测试的类的相关方法复选框。

  6. 使用 @Test 注解编写生成的测试方法的代码。

  7. 如果需要,添加带有 @Before@After 注解的方法。

假设我们有一个以下类:

public class Class1 {
    public int multiplyByTwo(int i){
        return i * 2;
    }
}

如果您遵循前面的步骤,以下测试类将在测试源树下创建:

import org.junit.Test;
public class Class1Test {
    @Test
    public void multiplyByTwo() {
    }
}

现在,您可以按照以下方式实现 void multiplyByTwo() 方法:

@Test
public void multiplyByTwo() {
    Class1 class1 = new Class1();
    int result = class1.multiplyByTwo(2);
    Assert.assertEquals(4, result);
}

单元是一个最小的可测试的代码片段,因此得名。最佳测试实践将方法视为最小的可测试单元。这就是为什么单元测试通常测试一个方法。

org.mockito

单元测试经常遇到的一个问题是需要测试一个使用第三方库、数据源或另一个类的方法。在测试时,您想要控制所有输入,以便您可以预测测试代码的预期结果。这就是模拟或模拟测试代码交互的对象的行为技术派上用场的时候。

开源 Mockito 框架(org.mockito 根包名)允许您做到这一点——创建模拟对象。使用它相当简单。这里有一个简单的例子。假设我们需要测试另一个 Class1 方法:

public class Class1 {
    public int multiplyByTwo2(Class2 class2){
        return class2.getValue() * 2;
    }
}

要测试此方法,我们需要确保 getValue() 方法返回某个特定的值,因此我们将模拟此方法。为此,请按照以下步骤操作:

  1. 在 Maven 的 pom.xml 配置文件中添加一个依赖项:

       <dependency>
           <groupId>org.mockito</groupId>
           <artifactId>mockito-core</artifactId>
           <version>4.2.0</version>
           <scope>test</scope>
       </dependency>
    
  2. 为您需要模拟的类调用 Mockito.mock() 方法:

    Class2 class2Mock = Mockito.mock(Class2.class);
    
  3. 设置您需要从方法返回的值:

    Mockito.when(class2Mock.getValue()).thenReturn(5);
    
  4. 现在,您可以将模拟对象作为参数传递到调用模拟方法的测试方法中:

    Class1 class1 = new Class1();
    int result = class1.multiplyByTwo2(class2Mock);
    
  5. 模拟的方法返回您预定义的结果:

    Assert.assertEquals(10, result);
    
  6. @Test 方法应如下所示:

    @Test
    public void multiplyByTwo2() {
        Class2 class2Mock = Mockito.mock(Class2.class);
        Mockito.when(class2Mock.getValue()).thenReturn(5);
        Class1 class1 = new Class1();
        int result = class1.multiplyByTwo2(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 框架之前,你必须将相应的依赖项添加到 Maven 的pom.xml配置文件中:

<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-api</artifactId>
    <version>2.17.0</version>
</dependency>
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.17.0</version>
</dependency>

例如,以下是框架的使用方法:

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class Class1 {
   static final Logger logger = 
               LogManager.getLogger(Class1.class.getName());
    public static void main(String... args){
        new Class1().multiplyByTwo2(null);
    }
    public int multiplyByTwo2(Class2 class2){
        if(class2 == null){
            logger.error("The parameter should not be null");
            System.exit(1);
        }
        return class2.getValue() * 2;
    }
}

如果我们运行前面的main()方法,我们将得到以下输出:

18:34:07.672 [main] ERROR Class1 - 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

  • 日志级别将是Level.ERROR(其他级别包括OFFFATALWARNINFODEBUGTRACEALL)。

通过将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 文档以了解更多信息:logging.apache.org

org.apache.commons

org.apache.commons 包是另一个由名为 Apache Commons 的项目开发的流行库。它由一个名为 Apache 软件基金会 的开源程序员社区维护。该组织由 Apache Group 于 1999 年成立。Apache Group 自 1993 年以来一直围绕 Apache HTTP Server 的发展壮大。Apache HTTP Server 是一个开源的跨平台 Web 服务器,自 1996 年 4 月以来一直是最受欢迎的 Web 服务器。

Apache Commons 项目有以下三个组件:

  • Commons Sandbox:一个用于 Java 组件开发的工位;你可以在那里为开源工作做出贡献。

  • Commons Dormant:一个存放当前不活跃组件的仓库;你可以使用那里的代码,但由于这些组件可能不会很快发布,你必须自己构建这些组件。

  • org.apache.commons 库。

我们在第 第五章字符串、输入/输出和文件 中讨论了 org.apache.commons.io 包。

在以下小节中,我们将讨论 Commons Proper 的三个最受欢迎的包:

  • org.apache.commons.lang3

  • org.apache.commons.collections4

  • org.apache.commons.codec.binary

然而,在 org.apache.commons 下还有许多其他包,包含数千个类,可以轻松地用于使你的代码更加优雅和高效。

lang 和 lang3

org.apache.commons.lang3 包是 org.apache.commons.lang 包的第三个版本。创建新包的决定是由引入的版本 3 的更改导致的向后不兼容,这意味着使用 org.apache.commons.lang 包的先前版本的现有应用程序在升级到版本 3 后可能会停止工作。但在主流编程的大多数情况下,将 3 添加到 import 语句中(作为迁移到新版本的方式)通常不会破坏任何东西。

根据文档,org.apache.commons.lang3 包提供了高度可重用的静态实用方法,主要关注为 java.lang 类增加价值。以下是一些显著的例子:

  • ArrayUtils 类:允许你搜索和操作数组;我们在 第六章数据结构、泛型和常用工具 中讨论并演示了这一点。

  • ClassUtils 类:提供有关类的某些元数据。

  • ObjectUtils 类:检查对象数组是否为 null,比较对象,并以安全的方式计算对象数组的平均值和最小/最大值;我们在 第六章数据结构、泛型和常用工具 中讨论并演示了这一点。

  • SystemUtils 类:提供有关执行环境的信息。

  • ThreadUtils 类:查找有关当前正在运行的线程的信息。

  • Validate 类:验证单个值和集合,比较它们,检查空值和匹配,并执行许多其他验证。

  • RandomStringUtils 类:从各种字符集的字符生成 String 对象。

  • StringUtils 类:我们在第五章字符串、输入/输出和文件中讨论了这个类。

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
    
  • BagUtils 类,用于转换基于 Bag 的集合。

  • 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 接口的多种实现。

  • 将数组枚举转换为集合的类。

  • 允许您测试或创建集合的并集、交集或闭包的实用工具。

  • CollectionUtilsListUtilsMapUtilsMultiMapUtils 类,以及许多其他特定接口的实用类。

读取该包的文档(commons.apache.org/proper/commons-collections)以获取更多详细信息。

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!

你可以在 Apache Commons 项目网站上了解更多关于此包的信息:commons.apache.org/proper/commons-codec

摘要

在本章中,我们概述了 JCL(Java 类库)中最受欢迎的包的功能——即 java.langjava.utiljava.timejava.iojava.niojava.sqljavax.sqljava.netjava.lang.mathjava.mathjava.awtjavax.swingjavafx

最受欢迎的外部库由 org.junitorg.mockitoorg.apache.log4jorg.slf4jorg.apache.commons 包表示。这些库可以帮助你在功能已经存在且可以直接导入和使用的情况下避免编写自定义代码。

在下一章中,我们将讨论 Java 线程并演示其用法。我们还将解释并行处理和并发处理之间的区别。然后,我们将向您展示如何创建线程以及如何执行、监控和停止它。这对那些将要编写多线程处理代码的人来说将很有用,同时也对那些希望提高对 JVM 工作原理理解的人来说很有用,这将是下一章的主题。

测验

回答以下问题以测试你对本章知识的掌握:

  1. Java 类库是什么?选择所有适用的选项:

    1. 编译类的集合

    2. 随 Java 安装一起提供的包

    3. Maven 自动添加到类路径中的 .jar 文件

    4. 任何用 Java 编写的库

  2. Java 外部库是什么?选择所有适用的选项:

    1. 不包含在 Java 安装中的 .jar 文件

    2. 必须在 pom.xml 中添加为依赖项的 .jar 文件,才能使用它

    3. 不是 JVM 作者编写的类

    4. 不属于 JCL 的类

  3. java.lang 包中包含哪些功能?选择所有适用的选项:

    1. 它是唯一包含 Java 语言实现的包。

    2. 它包含 JCL 中最常用的类。

    3. 它包含 Object 类,这是任何 Java 类的基类。

    4. 它包含 Java 语言规范中列出的所有类型。

  4. java.util 包中包含哪些功能?选择所有适用的选项:

    1. Java 集合接口的所有实现

    2. Java 集合框架的所有接口

    3. JCL 的所有实用工具

    4. 类、数组、对象和属性

  5. java.time 包中包含哪些功能?选择所有适用的选项:

    1. 管理日期的类。

    2. 它是唯一管理时间的包。

    3. 表示日期和时间的类。

    4. 它是唯一管理日期的包。

  6. java.io 包中包含哪些功能?选择所有适用的选项:

    1. 处理二进制数据流

    2. 处理字符流

    3. 处理字节流

    4. 处理数字流

  7. java.sql 包中包含哪些功能?选择所有适用的选项:

    1. 支持数据库连接池

    2. 支持执行数据库语句

    3. 提供从数据库读取/写入数据的能力

    4. 支持数据库事务

  8. java.net包中包含哪些功能?选择所有适用的:

    1. 支持.NET 编程

    2. 支持套接字通信

    3. 支持基于 URL 的通信

    4. 支持基于 RMI 的通信

  9. java.math包中包含哪些功能?选择所有适用的:

    1. 支持最小和最大计算

    2. 支持大数

    3. 支持对数

    4. 支持开方计算

  10. javafx包中包含哪些功能?选择所有适用的:

    1. 支持发送传真消息

    2. 支持接收传真消息

    3. 支持 GUI 编程

    4. 支持动画

  11. org.junit包中包含哪些功能?选择所有适用的:

    1. 支持测试 Java 类

    2. 支持 Java 度量单位

    3. 支持单元测试

    4. 支持组织统一

  12. org.mockito包中包含哪些功能?选择所有适用的:

    1. 支持 Mockito 协议

    2. 允许你模拟方法的行为

    3. 支持静态方法模拟

    4. 生成类似第三方类的对象

  13. org.apache.log4j包中包含哪些功能?选择所有适用的:

    1. 支持将消息写入文件

    2. 支持从文件中读取消息

    3. 支持 Java 的 log4j 协议

    4. 支持控制日志文件的数量和大小

  14. org.apache.commons.lang3包中包含哪些功能?选择所有适用的:

    1. 支持 Java 语言版本 3

    2. 补充java.lang

    3. 包含ArrayUtilsObjectUtilsStringUtils

    4. 包含SystemUtils

  15. org.apache.commons.collections4包中包含哪些功能?选择所有适用的:

    1. Java 集合框架接口的各种实现

    2. Java 集合框架实现的多种实用工具

    3. Vault 接口及其实现

    4. 包含CollectionUtils

  16. org.apache.commons.codec.binary包中包含哪些功能?选择所有适用的:

    1. 支持在网络中发送二进制数据

    2. 允许你编码和解码数据

    3. 支持数据加密

    4. 包含StringUtils

第八章:多线程与并发处理

在本章中,我们将讨论通过使用并发处理数据的工作者(线程)来提高 Java 应用程序性能的方法。我们将解释 Java 线程的概念并演示其用法。我们还将讨论并行处理与并发处理之间的区别以及如何避免由共享资源的并发修改引起的不可预测的结果。

完成本章后,您将能够编写多线程处理的代码——创建和执行线程,并在并行和并发情况下使用线程池。

本章将涵盖以下主题:

  • 线程与进程

  • 用户线程与守护线程

  • 扩展Thread

  • 实现Runnable接口

  • 扩展Thread类与实现Runnable接口

  • 使用线程池

  • 从线程获取结果

  • 并行处理与并发处理

  • 同一资源的并发修改

技术要求

要执行本章提供的代码示例,您需要以下内容:

  • 一台装有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java 标准版SE)17 或更高版本

  • 一个集成开发环境IDE)或您偏好的代码编辑器

第一章“Java 17 入门”中提供了如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明。本章的代码示例文件可在 GitHub 的github.com/PacktPublishing/Learn-Java-17-Programming.git仓库中的examples/src/main/java/com/packt/learnjava/ch08_threads文件夹找到。

线程与进程

Java 有两个执行单元——进程和线程。一个java.lang.ProcessBuilder。但由于多进程案例超出了本书的范围,我们将关注第二个执行单元——即线程,它类似于进程,但与其他线程的隔离性较低,执行所需的资源也更少。

一个进程可以运行多个线程,并且至少有一个称为主线程的线程——启动应用程序的那个线程——我们在每个示例中都使用它。线程可以共享资源,包括内存和打开的文件,这可以提高效率,但这也带来了代价:更高的意外互斥风险,甚至可能阻塞执行。这就是需要编程技能和对并发技术理解的地方。

用户线程与守护线程

有一种特殊的线程称为守护线程。

注意

词语守护线程起源于古希腊,意为介于神与人之间的自然神祇或超自然存在,以及内在或伴随的精神或灵感之源。

在计算机科学中,术语守护有更普通的用法,它被应用于作为后台进程运行的计算机程序,而不是在交互式用户的直接控制之下。这就是为什么 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_PRIORITYThread.MAX_PRIORITY 之间的值来程序化设置。值越小,线程被允许运行的时间越长,这意味着它具有更高的优先级。如果没有设置,优先级值默认为 Thread.NORM_PRIORITY

线程的状态可以有以下之一:

  • NEW: 当一个线程尚未启动

  • RUNNABLE: 当一个线程正在执行

  • BLOCKED: 当一个线程被阻塞并正在等待监视器锁

  • WAITING: 当一个线程正在无限期地等待另一个线程执行特定动作

  • TIMED_WAITING: 当一个线程正在等待另一个线程执行动作,最长等待指定的时间

  • TERMINATED: 当一个线程已退出

线程——以及任何对象——也可以使用 java.lang.Object 基类中的 wait()notify()notifyAll() 方法相互“交谈”,但线程行为的这一方面超出了本书的范围。

使用线程池

每个线程都需要资源——中央处理单元CPU)和内存。这意味着必须控制线程的数量,而创建固定数量的线程是一种方法。此外,创建对象会产生开销,这可能在某些应用程序中是显著的。

在本节中,我们将探讨 Executor 接口及其在 java.util.concurrent 包中提供的实现。它们封装了线程管理,并最小化了应用程序开发者编写与线程生命周期相关的代码所需的时间。

java.util.concurrent 包中定义了三个 Executor 接口,如下所示:

  • 基础 Executor 接口:它只包含一个 void execute(Runnable r) 方法。

  • ExecutorService 接口:它扩展了 Executor 并添加了四个管理工作线程和执行器本身生命周期的方法组,如下所示:

    • submit() 方法,它将 RunnableCallable 对象放入执行队列中,并返回 Future 接口的对象,可以用来访问 Callable 对象返回的值以及管理工作线程的状态

    • invokeAll() 方法,它将 Callable 接口的对象集合放入执行队列中,当所有工作线程完成时(也存在一个带有超时的重载 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.ThreadPoolExecutorjava.util.concurrent.ScheduledThreadPoolExecutor 类创建基于池的 ExecutorService 实现。还有一个 java.util.concurrent.Executors 工厂类,它涵盖了大多数实际案例。因此,在为工作线程池创建编写自定义代码之前,我们强烈建议查看 java.util.concurrent.Executors 类的以下工厂方法:

  • newCachedThreadPool():创建一个线程池,根据需要添加新线程,除非在之前创建了一个空闲线程;空闲了 60 秒的线程将从池中移除

  • newSingleThreadExecutor():创建一个执行工作线程的 ExecutorService(池)实例,这些工作线程是顺序执行的

  • newSingleThreadScheduledExecutor():创建一个单线程执行器,可以调度在给定延迟后运行,或周期性地执行

  • newFixedThreadPool(int nThreads): 创建一个重用固定数量工作线程的线程池;如果当所有工作线程仍在执行时提交新任务,它将被放置在队列中,直到有工作线程可用

  • newScheduledThreadPool(int nThreads): 创建一个固定大小的线程池,可以安排在给定延迟后运行,或者定期执行

  • newWorkStealingThreadPool(int nThreads): 创建一个使用ForkJoinPool中使用的工作窃取算法的线程池,这在工作线程生成其他线程的情况下特别有用,例如在递归算法中;它还可以适应指定的 CPU 数量,你可以将其设置为高于或低于你计算机上的实际 CPU 数量

    工作窃取算法

    工作窃取算法允许完成分配任务的线程帮助其他仍在执行任务的作业。例如,请参阅官方 Oracle Java 文档中关于 fork/join 实现的描述(docs.oracle.com/javase/tutorial/essential/concurrency/forkjoin.html)。

这些方法都有重载版本,允许传递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线程池运行此线程,这将得到以下结果:

图 8.4

如您所见,isInterrupted()方法返回的值现在是true,这与所发生的情况相对应。公平地说,在许多应用中,一旦线程被中断,其状态就不会再次检查。但设置正确的状态是一种良好的实践,尤其是在那些你并非创建特定线程的更高级代码的作者的案例中。

在我们的示例中,我们使用了一个缓存线程池,该线程池根据需要创建新线程,或者如果可用,则重用已经使用的线程,但这些线程已经完成了任务并返回到线程池以进行新的分配。我们不必担心创建过多的线程,因为我们的演示应用最多只有三个工作线程,而且它们的生命周期相当短。

但在应用没有固定的工作线程数量限制,或者没有好的方法来预测线程可能占用多少内存或可以执行多长时间的情况下,对工作线程数量设置上限可以防止应用性能意外下降、内存耗尽或任何其他工作线程使用的资源耗尽。如果线程行为极其不可预测,单个线程池可能是唯一的解决方案,可以选择使用自定义线程池执行器。但在大多数情况下,固定大小的线程池执行器是在应用需求和代码复杂性之间的一种良好的实用折衷方案(在本节前面,我们列出了由Executors工厂类创建的所有可能的池类型)。

将池的大小设置得太低可能会剥夺应用程序有效利用可用资源的机会。因此,在选择池大小之前,建议花些时间监控应用程序,目标是识别应用程序行为的特殊性。实际上,部署-监控-调整周期必须在整个应用程序的生命周期中重复进行,以便适应并利用代码或执行环境中发生的变化。

您首先考虑的第一个特征是您系统中的 CPU 数量,因此线程池的大小至少应该与 CPU 数量一样大。然后,您可以监控应用程序,看看每个线程占用 CPU 的时间有多长,以及它使用其他资源(如输入/输出I/O)操作)的时间有多长。如果未使用 CPU 的时间与线程的总执行时间相当,那么您可以按以下比例增加池大小:未使用 CPU 的时间除以总执行时间,但这是在另一个资源(磁盘或数据库)不是线程之间争用对象的情况下。如果后者是情况,那么您可以使用该资源而不是 CPU 作为划分因素。

假设您的应用程序的工作线程不太大或执行时间不太长,并且属于典型工作线程的主流群体,这些工作线程在合理短的时间内完成工作,您可以通过添加(向上取整)所需响应时间与线程使用 CPU 或另一个最争用资源的时间的比例来增加池大小。这意味着,在相同的所需响应时间下,线程使用 CPU 或另一个并发访问的资源越少,池大小就应该越大。如果争用资源具有提高并发访问能力的能力(例如数据库中的连接池),请首先考虑利用该功能。

如果在运行时不同情况下同时运行的线程数量发生变化,您可以使池大小动态,并创建一个新的池,具有新的大小(在所有线程完成后关闭旧池)。在添加或删除可用资源后,重新计算新池的大小也可能是必要的。您可以使用Runtime.getRuntime().availableProcessors()根据当前可用的 CPU 数量编程调整池大小,例如。

如果java.util.concurrent.ThreadPoolExecutor类提供的任何现成的线程池执行器实现都不适用。它有几个重载的构造函数。

为了让您了解其功能,这里有一个具有最多选项的构造函数:

ThreadPoolExecutor (int corePoolSize, 
                    int maximumPoolSize, 
                    long keepAliveTime, 
                    TimeUnit unit, 
                    BlockingQueue<Runnable> workQueue, 
                    ThreadFactory threadFactory, 
                    RejectedExecutionHandler handler)

这些是前一个构造函数的参数:

  • corePoolSize是池中要保留的线程数,即使它们是空闲的,除非调用allowCoreThreadTimeOut(boolean value)方法并传入true值。

  • maximumPoolSize是允许在池中的最大线程数。

  • keepAliveTime:当线程数量大于核心线程数时,这是超出空闲线程等待新任务的最大时间,然后终止。

  • unitkeepAliveTime参数的时间单位。

  • workQueue是用于在执行之前持有任务的队列;这个队列将只持有通过execute()方法提交的Runnable对象。

  • threadFactory是当执行器创建新线程时使用的工厂。

  • handler是在线程因为达到线程界限和队列容量而阻塞时使用的处理器。

除了workQueue之外,之前的所有构造参数也可以在创建ThreadPoolExecutor类的对象之后通过相应的 setter 设置,从而允许更灵活和动态调整现有池的特性。

从线程获取结果

在我们之前的例子中,我们使用了ExecutorService接口的execute()方法来启动一个线程。实际上,这个方法来自Executor基接口。同时,ExecutorService接口还有其他方法(在之前的使用线程池部分列出),可以启动线程并获取线程执行的返回结果。

返回线程执行结果的对象是Future类型——一个具有以下方法的接口:

  • V get():阻塞直到线程完成;返回结果(如果可用)

  • V get(long timeout, TimeUnit unit):阻塞直到线程完成或提供的超时时间到达;返回结果(如果可用)

  • boolean isDone():如果线程已完成,则返回true

  • boolean cancel(boolean mayInterruptIfRunning):尝试取消线程的执行;如果成功,则返回true;如果线程在调用该方法时已经正常完成,则也返回false

  • boolean isCancelled():如果线程在正常完成之前被取消,则返回true

get()方法的描述中的可用说明意味着结果原则上并不总是可用,即使调用不带参数的get()方法也是如此。这完全取决于产生Future对象的方法。以下是返回Future对象(s)的所有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,因为 Runnablerun() 方法不返回任何内容。我们可以从返回的 Future 对象中获取的唯一信息是任务是否完成。

  • Future<T> submit(Runnable task, T result): 提交线程(任务)以执行;返回一个包含提供 resultFuture 对象表示的任务;例如,我们将使用以下类作为结果:

    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 任务;返回一个包含由执行 Callable 对象产生的结果的 Future 对象列表

  • List<Future<T>> invokeAll(Collection<Callable<T>>: 执行提供的集合中的所有 Callable 任务;返回一个包含由执行 Callable 对象产生的结果的 Future 对象列表,或者在超时到期之前发生,以先到者为准。

  • 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 个数字,并将结果相加。结果总和在Result对象中返回,并附带MyCallable对象的名字。

  • 每个Result对象最终都包含在一个Future对象中。

  • 然后invokeAllCallables()方法遍历Future对象列表,并检查每个任务是否已完成。当任务完成时,结果被添加到List<Result> results中。

  • 所有任务完成后,invokeallCallables()方法将打印List<Result> results的所有元素,并终止线程池。

这里是我们从invokeAllCallables(new CalculatorNoSync())的一次运行中得到的成果:

每次运行前面的代码时,实际数字都会略有不同,但One任务的结果永远不会等于Two任务的结果。这是因为,在设置prop字段值和calculate()方法中返回其平方根之间,其他线程设法将不同的值赋给prop。这是一个线程干扰的例子。

有几种方法可以解决这个问题。我们从一个原子变量开始,作为实现线程安全并发访问属性的一种方式。然后,我们还将演示两种线程同步方法。

原子变量

如果prop值已被其他线程更改,则不应使用它。

java.util.concurrent.atomic包有十几个类支持这种逻辑:AtomicBooleanAtomicIntegerAtomicReferenceAtomicIntegerArray等。这些类中的每一个都有许多可用于不同同步需求的方法。请检查每个类的在线应用程序编程接口API)文档(docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/atomic/package-summary.html)。为了演示,我们将只使用其中所有类都存在的两个方法,如下所述:

  • V get(): 返回当前值

  • boolean compareAndSet(V expectedValue, V newValue): 如果当前值通过==运算符等于expectedValue值,则将值设置为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 {
           //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;
    }
}

我们刚刚在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;
    }
}

如您所见,同步块获取了this对象的锁,该锁由两个线程共享,并且仅在线程退出块后释放。在我们的演示代码中,该块覆盖了方法的所有代码,因此性能上没有差异。但想象一下,方法中还有更多的代码(我们在注释中标记了位置为there may be some other code here)。如果是这样,代码的同步部分较小,因此成为瓶颈的机会更少。

如果我们运行invokeAllCallables(new CalculatorSyncBlock()),结果看起来像这样:

图片

如您所见,结果与前面两个例子完全相同。不同类型的锁针对不同的需求,并且具有不同的行为,都被组装在java.util.concurrent.locks包中。

Java 中的每个对象都从基对象继承了wait()notify()notifyAll()方法。这些方法也可以用来控制线程的行为以及它们对锁的访问。

并发集合

解决并发问题的另一种方法是使用java.util.concurrent包中的线程安全集合。在选择使用哪个集合之前,请阅读Javadoc文档(docs.oracle.com/en/java/javase/17/docs/api/index.html),以查看集合的限制是否适合您的应用程序。以下是一些这些集合的列表和一些推荐:

  • ConcurrentHashMap<K,V>:支持完全并发检索和高预期的更新并发性;当并发需求非常严格且您需要允许在写入操作上锁定但不需要锁定元素时使用它。

  • ConcurrentLinkedQueue<E>:基于链表的线程安全队列;采用高效的非阻塞算法。

  • ConcurrentLinkedDeque<E>:基于链表的并发队列;当许多线程共享对公共集合的访问时,ConcurrentLinkedQuequeConcurrentLinkedDeque都是合适的选择。

  • ConcurrentSkipListMap<K,V>:并发ConcurrentNavigableMap接口实现。

  • ConcurrentSkipListSet<E>:基于ConcurrentSkipListMap类的并发NavigableSet实现。根据Javadoc文档,ConcurrentSkipListSetConcurrentSkipListMap类“提供预期的平均对数(n)时间成本,对于包含、添加和删除操作及其变体。升序视图及其迭代器比降序视图更快。”当您需要快速按特定顺序遍历元素时使用它们。

  • CopyOnWriteArrayList<E>ArrayList的线程安全变体,其中所有可变操作(添加、设置等)都是通过创建底层数组的全新副本来实现的。根据Javadoc文档,CopyOnWriteArrayList类“通常成本较高,但在遍历操作远多于突变时可能比其他替代方案更有效,当您无法或不想同步遍历,但仍需要防止并发线程之间的干扰时很有用。”当您不需要在不同位置添加新元素且不需要排序时使用它;否则,使用ConcurrentSkipListSet

  • CopyOnWriteArraySet<E>:使用内部CopyOnWriteArrayList类执行所有操作的集合。

  • PriorityBlockingQueue:当可以接受自然顺序且需要快速向队列尾部添加元素和快速从队列头部移除元素时,这是一个更好的选择。阻塞意味着在检索元素时队列等待变为非空,在存储元素时等待队列中有空间可用。

  • ArrayBlockingQueueLinkedBlockingQueueLinkedBlockingDeque具有固定大小(有界);其他队列是无界的。

根据指南使用这些和类似的特点和建议,但在实现功能前后进行全面的测试和性能测量。为了展示这些集合的一些功能,让我们使用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关键字来指定属性,因为这保证了它在不同线程之间的读写可见性。

并发问题不容易解决,这也是为什么越来越多的开发者现在采取更激进的方法并不令人惊讶。他们不是管理对象状态,而是更喜欢在一系列无状态操作中处理数据。我们将在第十三章“函数式编程”和第十四章“Java 标准流”中看到此类代码的示例。看起来 Java 和许多现代语言以及计算机系统都在这个方向上发展。

摘要

在本章中,我们讨论了多线程处理、组织它的方法以及避免由共享资源的并发修改引起的不可预测的结果。我们向您展示了如何创建线程并使用线程池来执行它们。我们还演示了如何从成功完成的线程中提取结果,并讨论了并行处理和并发处理之间的区别。

在下一章中,我们将为您提供对 JVM 及其结构和过程的更深入理解,并将详细讨论防止内存溢出的垃圾回收过程。到本章结束时,您将了解构成 Java 应用程序执行、JVM 内部的 Java 进程、垃圾回收以及 JVM 一般工作原理的内容。

习题

  1. 选择所有正确的陈述:

    1. JVM 进程可以有主线程。

    2. 主线程是主进程。

    3. 一个进程可以启动另一个进程。

    4. 一个线程可以启动另一个线程。

  2. 选择所有正确的陈述:

    1. 守护线程是一个用户线程。

    2. 守护线程在第一个用户线程完成后退出。

    3. 守护线程在最后一个用户线程完成后退出。

    4. 主线程是一个用户线程。

  3. 选择所有正确的陈述:

    1. 所有线程都以java.lang.Thread为基类。

    2. 所有线程都扩展自java.lang.Thread

    3. 所有线程都实现了java.lang.Thread

    4. 守护线程不扩展自java.lang.Thread

  4. 选择所有正确的陈述:

    1. 任何类都可以实现Runnable接口。

    2. Runnable接口的实现是一个线程。

    3. Runnable接口的实现被线程使用。

    4. Runnable接口只有一个方法。

  5. 选择所有正确的陈述:

    1. 线程名称必须是唯一的。

    2. 线程 ID 是自动生成的。

    3. 可以设置线程名称。

    4. 可以设置线程优先级。

  6. 选择所有正确的陈述:

    1. 线程池执行线程。

    2. 线程池会重用线程。

    3. 一些线程池可以有固定数量的线程。

    4. 一些线程池可以有无限数量的线程。

  7. 选择所有正确的陈述:

    1. 从线程获取结果的方法只有Future对象。

    2. 从线程获取结果的唯一方法是Callable对象。

    3. Callable对象允许我们从线程获取结果。

    4. Future对象代表一个线程。

  8. 选择所有正确的陈述:

    1. 并发处理可以在并行中进行。

    2. 只有当计算机上有多个 CPU 或核心时,才可能进行并行处理。

    3. 并行处理是并发处理。

    4. 没有多个 CPU,并发处理是不可能的。

  9. 选择所有正确的陈述:

    1. 并发修改总是导致结果不正确。

    2. 原子变量可以保护属性免受并发修改。

    3. 原子变量可以保护属性免受线程干扰。

    4. 原子变量是保护属性免受并发修改的唯一方式。

  10. 选择所有正确的陈述:

    1. synchronized方法是避免线程干扰的最佳方式。

    2. synchronized关键字可以应用于任何方法。

    3. synchronized方法可以创建处理瓶颈。

    4. synchronized方法易于实现。

  11. 选择所有正确的陈述:

    1. 只有当synchronized块小于方法时才有意义。

    2. synchronized块需要一个共享锁。

    3. 每个 Java 对象都可以提供一个锁。

    4. synchronized块是避免线程干扰的最佳方式。

  12. 选择所有正确的陈述:

    1. 使用并发集合比使用非并发集合更受欢迎。

    2. 使用并发集合会带来一些开销。

    3. 并非每个并发集合都适合每个并发处理场景。

    4. 我们可以通过调用Collections.makeConcurrent()方法来创建并发集合。

  13. 选择所有正确的陈述:

    1. 避免内存一致性错误的唯一方法是声明volatile变量。

    2. 使用volatile关键字可以保证所有线程都能看到值的变化。

    3. 避免并发的一种方法是不进行任何状态管理。

    4. 无状态工具方法不会有并发问题。

第九章:JVM 结构和垃圾回收

本章将为你提供一个关于Java 虚拟机JVM)结构和行为的概述,这些结构和行为可能比你想象的要复杂。

JVM 根据编码逻辑执行指令。它还会找到并加载应用程序请求的.class文件到内存中,验证它们,解释字节码(即,将它们转换为特定平台的二进制代码),并将生成的二进制代码传递给中央处理器(或多个处理器)以执行。除了应用程序线程外,它还使用几个服务线程。其中一个服务线程被称为垃圾回收GC),执行释放未使用对象内存的重要步骤。

通过完成本章,你将了解构成 Java 应用程序执行的内容,JVM 内部的 Java 进程和 GC,以及 JVM 的一般工作原理。

在本章中,我们将涵盖以下主题:

  • Java 应用程序执行

  • Java 进程

  • JVM 的结构

  • 垃圾回收

技术要求

要执行本章提供的代码示例,你需要以下内容:

  • 配备 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高

  • 你选择的 IDE 或代码编辑器

第一章“Java 17 入门”中提供了如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明。本章的代码示例文件可在 GitHub 的github.com/PacktPublishing/Learn-Java-17-Programming.git仓库中的examples/src/main/java/com/packt/learnjava/ch09_jvm文件夹找到。

Java 应用程序执行

在我们学习 JVM 的工作原理之前,让我们回顾一下如何运行应用程序,同时记住以下语句是同义词:

  • 运行/执行/启动主类。

  • 运行/执行/启动main()方法。

  • 运行/执行/启动/启动应用程序。

  • 运行/执行/启动/启动 JVM 或 Java 进程。

还有几种方法可以做到这一点。在第一章“Java 17 入门”中,我们展示了如何使用 IntelliJ IDEA 运行main(String[])方法。在本章中,我们将重复一些已经说过的话,并添加一些可能对你有帮助的变体。

使用 IDE

任何 IDE 都允许你运行main()方法。在 IntelliJ IDEA 中,可以通过以下三种方式实现:

  1. 点击main()方法名称旁边的绿色三角形:

图片

  1. 一旦你至少使用绿色三角形执行了main()方法一次,类的名称将被添加到下拉菜单中(在顶部行,绿色三角形的左侧):图片

  2. 打开运行菜单并选择类的名称。你可以选择以下几种选项:

图片

在前面的截图中,您还可以看到开始的 main() 方法,以及一些其他选项:

图片

程序参数 字段允许在 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 ,如下所示:

图片

如果像前面的截图所示完成,x 和 y 的值不仅可以在 main() 方法中读取,还可以在任何使用 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 命令选项。例如,如果您输入 -Xlog:gc,IDE 将形成以下 java 命令:

java -Xlog:gc -cp . com.packt.learnjava.ch09_jvm.MyApplication

-Xlog:gc 选项需要显示 GC 日志。我们将在下一节中使用此选项来演示 GC 的工作原理。-cp . 选项(.class 文件位于 com/packt/learnjava/ch09_jvm 文件夹中,其中 com 是当前目录的子文件夹。类路径可以包括 JVM 必须查找的 .class 文件的位置,这些文件对于应用程序的执行是必要的)。

使用 修改选项 链接来显示以下 虚拟机选项

图片

对于这个演示,让我们在 虚拟机选项 字段中设置值 -DsomeParameter=42,如下面的截图所示:

图片

现在,someParameter 的值不仅可以在 main() 方法中读取,还可以在任何应用程序代码中如下所示:

String p = System.getProperty("someParameter");
System.out.println("\n" + p);    
                     //prints someParameter set as VM option -D

编辑配置 屏幕上,还可以设置其他 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 命令进行编译。在 Linux 类型的平台上,命令行看起来如下(假设您在项目的根目录中打开终端窗口,在 pom.xml 所在的文件夹中):

javac src/main/java/com/packt/learnjava/ch09_jvm/MyApplication.java

在 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 文件夹(路径相对于当前文件夹),其中主类的包开始。结果是如下所示:

图片

我们也可以将这两个编译后的类放入一个 .jar 文件中,并从那里运行它们。

使用命令行与 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 文件的情况一样)是不够的。你还必须添加一个星号(通配符符号,*),如下所示:

java -cp "src/main/java/*" \
           com.packt.learnjava.ch09_jvm.MyApplication

注意文件夹路径周围的引号,包含 .jar 文件的文件夹路径。如果没有引号,这将不会工作。

使用可执行 JAR 文件的命令行

有可能避免在命令行中指定主类。相反,我们可以创建一个可执行的 .jar 文件。这可以通过将主类的名称放入清单文件中实现——你需要运行的那个包含 main() 方法的类。以下是步骤:

  1. 创建一个名为 manifest.txt 的文本文件(名称不重要,但这个名字可以使意图更清晰),其中包含以下行:

    Main-Class: com.packt.learnjava.ch09_jvm.MyApplication 
    

在冒号(:)之后必须有一个空格,并且在末尾必须有一个不可见的换行符号,所以请确保你已经按下了 Enter 键,并且你的光标已经跳到了下一行的开头。

  1. 执行以下命令:

    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

  1. 现在,我们可以使用以下命令运行应用程序:

         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 进程 或简称为 进程

当执行 java 命令时,通常会启动一个新的 JVM 实例,该实例专门用于以单独的进程运行特定应用程序,并为其分配自己的内存(内存大小设置为默认值或作为命令选项传入)。在这个 Java 进程内部,多个线程正在运行,每个线程都有自己的分配内存。有些是 JVM 创建的服务线程;其他的是由应用程序创建和控制的线程。

这就是 JVM 执行编译代码的大致情况。但如果你仔细观察并阅读 JVM 规范,你会发现,关于 JVM,"进程" 这个词也被用来描述 JVM 内部进程。JVM 规范确定了 JVM 内部运行的几个其他进程,通常程序员不会提及,除非是 类加载过程

这是因为大多数时候,我们可以成功地编写和执行 Java 程序,而不必了解 JVM 内部进程的任何信息。但偶尔,对 JVM 内部工作原理的一些基本理解有助于我们识别某些问题的根本原因。这就是为什么在本节中,我们将简要概述 JVM 内部发生的所有进程。然后,在接下来的章节中,我们将讨论 JVM 的内存结构及其功能的其他方面,这些可能对程序员有所帮助。

两个子系统运行 JVM 的内部进程:

  • .class 文件并在 JVM 内存中的方法区域填充与类相关的数据:

    • 静态字段

    • 方法字节码

    • 描述类的类元数据

  • 执行引擎:它使用以下属性执行字节码:

    • 用于对象实例化的堆区域

    • 用于跟踪已调用方法的 Java 和本地方法栈

    • 一个回收内存的 GC 进程

在主 JVM 进程中运行的某些进程如下:

  • 类加载器执行的进程,如下所示:

    • 类加载

    • 类链接

    • 类初始化

执行引擎执行的进程,如下所示:

  • 类实例化

  • 方法执行

  • GC

  • 应用程序终止

    JVM 架构

    JVM 架构可以描述为有两个子系统——类加载器执行引擎——它们使用运行时数据内存区域(如方法区、堆和应用线程栈)来运行服务进程和应用线程。线程是轻量级进程,比 JVM 执行过程需要的资源分配更少。

这个列表可能会给你一种印象,这些过程是按顺序执行的。在某种程度上,这是真的,如果我们只谈论一个类的话。在加载类之前,我们无法对类做任何事情。我们只能在所有前面的过程完成后执行一个方法。然而,例如,GC 并不是一旦一个对象停止使用就立即发生(参见 垃圾回收 部分)。此外,当发生未处理的异常或其他错误时,应用程序可以随时退出。

只有类加载器过程受 JVM 规范的约束。执行引擎的实现主要取决于每个供应商。它基于实现作者设定的语言语义和性能目标。

执行引擎的过程处于一个不受 JVM 规范约束的领域。这里有常识、传统、已知和经过验证的解决方案,以及一个可以指导 JVM 供应商实现决策的 Java 语言规范。但是没有单一的管理文件。好消息是,最流行的 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,你将不会在那里看到一个公共构造函数。类加载器会自动创建其实例。这就是getClass()方法返回的实例,你可以在任何 Java 对象上调用它。

它不携带类的静态数据(这些数据存储在方法区),也不携带状态值(它们存储在执行过程中创建的对象中)。它也不包含方法字节码(这些也存储在方法区)。相反,Class实例提供了描述类的元数据——它的名称、包、字段、构造函数、方法签名等。这些元数据不仅对 JVM 有用,对应用程序也有用。

注意

由类加载器在内存中创建并由执行引擎维护的所有数据称为类型的二进制表示

如果.class文件包含错误或不符合某种格式,则终止该过程。这意味着加载过程已经验证了加载的类格式及其字节码。在下一个过程的开始处还有更多的验证,称为类链接

这里是对加载过程的概述。它执行三个任务:

  1. 查找并读取.class文件

  2. 根据方法区内部数据结构解析它

  3. 创建一个带有类元数据的java.lang.Class实例

类链接

根据 JVM 规范,类链接解决了加载类的引用,以便可以执行类的方法。

这里是对链接过程的概述。它执行三个任务:

  • .class文件是由 Java 编译器生成的,并且所有指令都满足语言的约束和要求,但这并不能保证加载的文件是由已知的编译器实现生成的,或者是由编译器生成的。这就是为什么链接过程的第一步是验证。这确保了类的二进制表示在结构上是正确的,这意味着以下内容:

    • 每个方法调用的参数与方法描述符兼容。

    • 返回指令与其方法的返回类型匹配。

    • 一些其他检查和验证过程,这些过程因 JVM 供应商而异。

  • 准备方法区中的静态字段:一旦验证完成,接口或类(静态)变量将在方法区中创建并初始化为其类型的默认值。其他类型的初始化,例如程序员指定的显式赋值和静态初始化块,将延迟到称为类初始化的过程(参见类初始化部分)。

  • 将符号引用解析为指向方法区的具体引用:如果加载的字节码引用了其他方法、接口或类,符号引用将被解析为指向方法区的具体引用,这是通过解析过程完成的。如果引用的接口和类尚未加载,类加载器将根据需要找到并加载它们。

类初始化

根据 JVM 规范,初始化是通过执行类初始化方法来完成的。这发生在程序员定义的初始化(在静态块和静态赋值中)执行时,除非类已经被另一个类的请求初始化。

这句话的最后部分很重要,因为类可能被不同的(已经加载的)方法多次请求,也因为 JVM 进程是由不同的线程执行的,并且可能并发访问同一个类。因此,需要在不同线程之间进行协调(也称为同步),这大大增加了 JVM 实现的复杂性。

类实例化

这一步可能永远不会发生。技术上,由new运算符触发的实例化过程是执行过程的第一步。如果main(String[])方法(它是静态的)只使用其他类的静态方法,则这种实例化永远不会发生。这就是为什么将这个过程识别为与执行过程分开是合理的。

这个活动有非常具体的任务:

  • 在堆区域为对象(其状态)分配内存

  • 将实例字段初始化为默认值

  • 为 Java 和本地方法创建线程栈

当第一个方法(不是构造函数)准备好执行时,执行开始。对于每个应用程序线程,都会创建一个专用的运行时栈,其中每个方法调用都捕获在一个栈帧中。例如,如果发生异常,当我们调用printStackTrace()方法时,我们会从当前的栈帧中获取数据。

方法执行

第一个应用程序线程(称为main(String[])方法开始执行。它可以创建其他应用程序线程。

执行引擎读取字节码,解释它,并将二进制代码发送到微处理器执行。它还维护了一个计数器,记录每个方法被调用的次数和频率。如果计数器超过某个阈值,执行引擎会使用一个称为即时编译器JIT)的编译器,将方法字节码编译成本地代码。这样,下次调用该方法时,它将无需解释即可准备好。这大大提高了代码性能。

当前正在执行的指令和下一条指令的地址被保存在程序计数器PC)寄存器中。每个线程都有专门的 PC 寄存器。这也提高了性能并跟踪执行情况。

垃圾收集

垃圾收集器识别出不再被引用的对象,并可以从内存中移除。

有一个 Java 静态方法System.gc(),可以用来程序化地触发 GC,但其立即执行并不保证。每次 GC 周期都会影响应用程序的性能,因此 JVM 必须在内存可用性和快速执行字节码的能力之间保持平衡。

应用程序终止

应用程序可以通过多种方式被终止(以及 JVM 停止或退出):

  • 正常终止,没有错误状态码

  • 非正常终止,由于未处理的异常

  • 强制程序化退出,带或不带错误状态码

如果没有异常和无限循环,main(String[])方法会通过返回语句或在其最后一条语句执行后完成。一旦发生这种情况,主应用程序线程将控制流传递给 JVM,JVM 也会停止执行。这就是美好的结局,许多应用程序在现实生活中也享受到了它。我们的大部分示例,除了我们演示异常或无限循环的情况外,也都成功退出了。

然而,Java 应用程序还有其他退出方式,其中一些相当优雅——而另一些则不然。如果主应用程序线程创建了子线程,或者说,程序员编写的代码生成了其他线程,即使是优雅的退出也可能不容易。这完全取决于创建的子线程类型。

如果其中任何一个是一个用户线程(默认),那么即使主线程退出后,JVM 实例也会继续运行。只有当所有用户线程都完成后,JVM 实例才会停止。主线程可以请求子用户线程完成。但在它退出之前,JVM 会继续运行。这意味着应用程序仍在运行。

但如果所有子线程都是守护线程,或者没有子线程正在运行,那么一旦主应用程序线程退出,JVM 实例就会停止运行。

应用程序在异常情况下的退出方式取决于代码设计。我们在第四章“异常处理”中讨论了异常处理的最佳实践时提到了这一点。如果线程在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 中查看源代码,只需单击方法。

当某个线程调用RuntimeSystem类的exit()方法,或者Runtime类的halt()方法,并且安全管理器允许退出或挂起操作时,JVM 退出。exit()halt()之间的区别在于halt()强制 JVM 立即退出,而exit()执行一些额外的操作,这些操作可以使用Runtime.addShutdownHook()方法设置。但主流程序员很少使用这些选项。

JVM 的结构

可以用内存中的运行时数据结构及其使用的两个子系统(类加载器和执行引擎)来描述 JVM 的结构。

运行时数据区域

JVM 内存的每个运行时数据区域属于以下两个类别之一:

  • 共享区域,包括以下内容:

    • 方法区:类元数据、静态字段和方法字节码

    • 堆区:对象(状态)

  • 未共享区域,这些区域专门用于特定的应用程序线程,包括以下内容:

    • Java 栈:当前和调用帧,每个帧保持 Java(非本地)方法调用的状态:

      1. 局部变量的值

      2. 方法参数值

      3. 中间计算的操作数值(操作数栈)

      4. 方法返回值(如果有)

  • 程序计数器:下一个要执行的指令

  • 本地方法栈:本地方法调用的状态

我们已经讨论过,程序员在使用引用类型时必须小心,除非需要修改对象本身,否则不要修改对象。在多线程应用程序中,如果对象的引用可以在线程之间传递,我们必须格外小心,因为可能存在相同数据被并发修改的可能性。然而,从积极的一面来看,这样的共享区域可以——并且通常被用作线程之间通信的方法。

类加载器

类加载器执行以下三个功能:

  • 读取 .class 文件

  • 填充方法区

  • 初始化程序员未初始化的静态字段

执行引擎

执行引擎执行以下操作:

  • 在堆区域实例化对象

  • 使用程序员编写的初始化器初始化静态和实例字段

  • 向/从 Java 栈中添加/删除帧

  • 更新程序计数器,以执行下一个指令

  • 维护本地方法栈

  • 记录方法调用次数并编译流行的调用

  • 终止对象

  • 运行 GC

  • 终止应用程序

垃圾收集

自动内存管理是 JVM 的重要方面,它减轻了程序员需要程序化地执行此操作的负担。在 Java 中,清理内存并允许其重用的过程称为 GC

响应性、吞吐量和停止世界

GC 的有效性影响两个主要的应用程序特性——响应性吞吐量

  • 响应性:这是通过应用程序响应速度(提供必要的数据)来衡量的;例如,网站返回页面有多快,或者桌面应用程序对事件的响应有多快。响应时间越短,用户体验越好。

  • 吞吐量:这表示应用程序在单位时间内可以完成的工作量;例如,Web 应用程序可以处理多少请求,或者数据库可以支持多少事务。数字越大,应用程序潜在的价值越高,它可以支持的用户请求也越多。

同时,垃圾回收(GC)需要移动数据,这在允许数据处理进行的同时是不可能的,因为引用将会改变。这就是为什么 GC 需要时不时地停止应用程序线程执行一段时间。这被称为停止世界。这些周期越长,GC 完成其工作的速度越快,应用程序冻结的时间越长,这最终可能足够大,以至于影响应用程序的响应性和吞吐量。

幸运的是,可以通过 Java 命令选项调整 GC 的行为,但这超出了本书的范围。相反,我们将提供一个 GC 主要活动的概述——检查堆中的对象,并移除那些在任何线程堆栈中没有引用的对象。

对象年龄和代际

基本的 GC 算法确定每个对象的年龄。术语年龄指的是对象存活了多少次收集周期。

当 JVM 启动时,堆是空的,并被分为三个部分:

  • 年轻一代

  • 老一代或资深一代

  • 用来存放大小为标准区域 50%或更大的物体的巨大区域

年轻一代有三个区域:

  • 一个伊甸园空间

  • 幸存者 0(S0)

  • 幸存者 1(S1)

新创建的对象被放置在伊甸园。当它开始填满时,一个小的 GC 过程开始。它移除未引用的和循环引用的对象,并将其他对象移动到 S1 区域。在下一次小收集期间,S0 和 S1 交换角色。引用对象从伊甸园和 S1 移动到 S0。

在每次小收集期间,达到一定年龄的对象被移动到老一代。由于这个算法,老一代包含比一定年龄更老的对象。这个区域比年轻一代大,因此 GC 过程更昂贵,并且不像在年轻一代那样频繁发生。但最终会进行检查(在几次小收集之后)。未引用的对象被移除,内存被碎片化。清理老一代被认为是主要收集。

当停止世界不可避免时

一些对象在老一代并发收集,而另一些则使用停止世界的暂停进行收集。步骤如下:

  1. 初始标记:这标记了可能包含指向老一代对象引用的幸存区域(根区域)。这是通过停止世界的暂停完成的。

  2. 扫描:这搜索幸存区域以查找对老一代的引用。这是在应用程序继续运行的同时并行的。

  3. 并发标记:这标记了整个堆中的活动对象,并且是在应用程序继续运行的同时并行的。

  4. 重标记:在这个阶段,活动对象已经被标记,这是通过停止世界的暂停完成的。

  5. 清理:这计算活动对象的生命周期,释放区域(使用停止世界),并将它们返回到空闲列表。这是并发执行的。

为了帮助 GC 调整,JVM 为垃圾收集器、堆大小和运行时编译器提供了平台相关的默认选择。但幸运的是,JVM 供应商一直在改进和调整 GC 过程,所以大多数应用程序使用默认的 GC 行为都能正常工作。

摘要

在本章中,你学习了如何使用 IDE 或命令行执行 Java 应用程序。现在,你可以根据给定环境以适当的方式编写应用程序并启动它们。关于 JVM 结构和其过程(类加载、链接、初始化、执行、GC 和应用终止)的知识,为你提供了更好的应用程序执行控制和对 JVM 性能和当前状态的透明度。

在下一章中,我们将讨论并演示如何从 Java 应用程序中管理数据库中的数据——插入、读取、更新和删除。我们还将简要介绍 SQL 语言及其基本数据库操作,包括如何连接到数据库、如何创建数据库结构、如何使用 SQL 编写数据库表达式以及如何执行它们。

测验

回答以下问题以测试你对本章知识的了解:

  1. 选择所有正确的陈述:

    1. 一个 IDE 可以在不编译的情况下执行 Java 代码。

    2. 一个 IDE 使用已安装的 Java 来执行代码。

    3. 一个 IDE 在不需要 Java 安装的情况下检查代码。

    4. 一个集成开发环境(IDE)使用 Java 安装的编译器。

  2. 选择所有正确的陈述:

    1. 应用程序使用的所有类必须在类路径上列出。

    2. 应用程序使用的所有类的位置必须在类路径上列出。

    3. 如果类在类路径上列出的文件夹中,编译器可以找到该类。

    4. 主包中的类不需要在类路径上列出。

  3. 选择所有正确的陈述:

    1. 应用程序使用的所有.jar文件必须在类路径上列出。

    2. 应用程序使用的所有.jar文件的位置必须在类路径上列出。

    3. 如果类在类路径上列出的.jar文件中,JVM 才能找到该类。

    4. 每个类都可以包含main()方法。

  4. 选择所有正确的陈述:

    1. 包含清单的每个.jar文件都是可执行的。

    2. 如果java命令使用-jar选项,则忽略类路径选项。

    3. 每个.jar文件都有一个清单。

    4. 可执行的.jar文件是一个包含清单的 ZIP 文件。

  5. 选择所有正确的陈述:

    1. 类加载和链接可以在不同的类上并行工作。

    2. 类加载将类移动到执行区域。

    3. 类链接连接两个类。

    4. 类链接使用内存引用。

  6. 选择所有正确的陈述:

    1. 类初始化为实例属性赋值。

    2. 每当另一个类引用该类时,都会发生类初始化。

    3. 类初始化为静态属性赋值。

    4. 类初始化为java.lang.Class的实例提供数据。

  7. 选择所有正确的陈述:

    1. 类实例化可能永远不会发生。

    2. 类实例化包括对象属性初始化。

    3. 类实例化包括在堆上进行内存分配。

    4. 类实例化包括执行构造函数代码。

  8. 选择所有正确的陈述:

    1. 方法执行包括二进制代码生成。

    2. 方法执行包括源代码编译。

    3. 方法执行包括重用 JIT 编译器产生的二进制代码。

    4. 方法执行统计每个方法被调用的次数。

  9. 选择所有正确的陈述:

    1. 在调用System.gc()方法后,垃圾回收立即开始。

    2. 应用程序可以带错误代码或不带错误代码终止。

    3. 一旦抛出异常,应用程序就会立即退出。

    4. 主线程是一个用户线程。

  10. 选择所有正确的陈述:

    1. JVM 具有所有线程共享的内存区域。

    2. JVM 具有线程之间不共享的内存区域。

    3. 类元数据在所有线程之间共享。

    4. 方法参数值在所有线程之间不共享。

  11. 选择所有正确的陈述:

    1. 类加载器填充方法区。

    2. 类加载器在堆上分配内存。

    3. 类加载器写入.class文件。

    4. 类加载器解析方法引用。

  12. 选择所有正确的陈述:

    1. 执行引擎在堆上分配内存。

    2. 执行引擎终止应用程序。

    3. 执行引擎运行垃圾回收。

    4. 执行引擎初始化程序员未初始化的静态字段。

  13. 选择所有正确的陈述:

    1. 数据库每秒可以支持的交易数量是一个吞吐量度量。

    2. 当垃圾回收器暂停应用程序时,它被称为停止一切。

    3. 网站返回数据有多慢是一个响应性度量。

    4. 垃圾回收器清除作业的 CPU 队列。

  14. 选择所有正确的陈述:

    1. 对象年龄是通过对象创建以来的秒数来衡量的。

    2. 对象越老,它被从内存中移除的可能性就越大。

    3. 清理旧生代是一项主要收集。

    4. 将对象从一个年轻代区域移动到另一个年轻代区域是轻微的收集。

  15. 选择所有正确的陈述:

    1. 可以通过设置javac命令的参数来调整垃圾回收器。

    2. 垃圾回收器可以通过设置java命令的参数进行调整。

    3. 垃圾回收器根据其逻辑工作,不能根据设置的参数改变其行为。

    4. 清理旧生代区域需要停止世界的暂停。

第十章:数据库中的数据管理

本章解释并演示了如何使用 Java 应用程序管理数据库中的数据——也就是说,插入、读取、更新和删除数据。它还简要介绍了 结构化查询语言SQL)和基本数据库操作,包括如何连接到数据库、如何创建数据库结构、如何使用 SQL 编写数据库表达式以及如何执行这些表达式。

本章将涵盖以下主题:

  • 创建数据库

  • 创建数据库结构

  • 连接到数据库

  • 释放连接

  • 创建、读取、更新和删除CRUD)数据操作

  • 使用共享库 JAR 文件访问数据库

到本章结束时,你将能够创建和使用数据库来存储、更新和检索数据,以及创建和使用共享库。

技术要求

要能够执行本章提供的代码示例,你需要以下内容:

  • 拥有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高

  • 你偏好的 IDE 或代码编辑器

关于如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明在 第一章**, Java 17 入门 中提供。本章的代码示例文件可在 GitHub 上找到(github.com/PacktPublishing/Learn-Java-17-Programming.git),位于 examples/src/main/java/com/packt/learnjava/ch10_database 文件夹中,以及 database 文件夹中,作为共享库的独立项目。

创建数据库

java.sql, javax.sql, 和 java.transaction.xa 包以及实现数据库访问接口(称为 数据库驱动程序)的特定数据库类,这些由每个数据库供应商提供。

使用 JDBC 意味着编写 Java 代码,使用 JDBC API 的接口和类以及特定数据库的驱动程序来管理数据库中的数据,该驱动程序知道如何与特定数据库建立连接。使用此连接,应用程序可以随后发出用 SQL 编写的请求。

自然地,我们这里只指的是理解 SQL 的数据库。它们被称为关系型或表格 数据库管理系统DBMS),构成了目前使用的 DBMS 中的绝大多数——尽管也使用了一些替代方案(例如,导航数据库和 NoSQL)。

java.sqljavax.sql 包包含在 javax.sql 包中,它包含支持语句池、分布式事务和行集的 DataSource 接口。

创建数据库涉及以下八个步骤:

  1. 按照供应商的说明安装数据库。

  2. 打开 PL/SQL 终端并创建数据库用户、数据库、模式、表、视图、存储过程以及支持应用程序数据模型所需的一切。

  3. 向此应用程序添加对具有数据库特定驱动程序的.jar文件的依赖。

  4. 从应用程序连接到数据库。

  5. 构建 SQL 语句。

  6. 执行 SQL 语句。

  7. 根据您的应用程序需求使用执行结果。

  8. 释放(即关闭)数据库连接以及在此过程中打开的任何其他资源。

步骤 13在数据库设置期间以及应用程序运行之前只执行一次。步骤 48根据需要由应用程序重复执行。实际上,步骤 57可以使用相同的数据库连接重复多次。

对于我们的示例,我们将使用 PostgreSQL 数据库。您首先需要根据数据库特定的说明自行执行步骤 13。为了创建我们的演示数据库,我们使用以下 PL/SQL 命令:

create user student SUPERUSER;
create database learnjava owner student;

这些命令创建了一个可以管理SUPERUSER数据库所有方面的student用户,并将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 关键字表示该字段是一个由数据库在创建新记录时生成的顺序整数。生成顺序整数的其他选项有 SMALLSERIALBIGSERIAL;它们在大小和可能值的范围内有所不同:

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_namelast_namedob 的组合来创建一个复合 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 的。我们将使用接受 id 的构造函数来根据现有记录构建一个对象,而另一个构造函数将用于在插入新记录之前创建一个对象。

一旦创建,可以使用 DROP 命令来删除表格:

DROP table person;

可以使用 ALTER SQL 命令更改现有表格;例如,我们可以添加一个地址列:

ALTER table person add column address VARCHAR;

如果您不确定是否已经存在此类列,您可以添加 IF EXISTSIF 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 代码来操作数据库中的数据。为了做到这一点,我们首先需要将以下依赖项添加到database项目的pom.xml文件中:

<dependency> 
    <groupId>org.postgresql</groupId> 
    <artifactId>postgresql</artifactId> 
    <version>42.3.2</version> 
</dependency>

example项目也获得了对这个依赖的访问权限,因为在example项目的pom.xml文件中,我们有以下对数据库.jar文件的依赖项:

<dependency> 
    <groupId>com.packt.learnjava</groupId>
    <artifactId>database</artifactId>
    <version>1.0-SNAPSHOT</version> 
</dependency>

在运行任何示例之前,请确保通过在database文件夹中执行"mvn clean install"命令来安装database项目。

现在,我们可以从 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传递许多其他值来配置连接行为。传入属性的键名对所有主要数据库都是相同的,但其中一些是数据库特定的。所以,阅读你的数据库供应商文档以获取更多详细信息。

或者,如果我们只想传递userpassword,可以使用重载的DriverManager.getConnection(String url, String user, String password)版本。保持密码加密是一个好习惯。我们不会演示如何做,但互联网上有许多可参考的指南。

连接到数据库的另一种方式是使用javax.sql.DataSource接口。它的实现包含在数据库驱动程序的同一.jar文件中。在PostgreSQL的情况下,有两个类实现了DataSource接口:

  • org.postgresql.ds.PGSimpleDataSource

  • org.postgresql.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 (github.com/brettwooldridge/HikariCP)、Vibur (www.vibur.org) 和 Commons DBCP (commons.apache.org/proper/commons-dbcp) - 这些框架可靠且易于使用。

无论我们选择哪种创建数据库连接的方法,我们都会将其隐藏在 getConnection() 方法中,并在所有代码示例中以相同的方式使用它。一旦获取了 Connection 类的对象,我们就可以访问数据库,以添加、读取、删除或修改存储的数据。

释放连接

保持数据库连接活跃需要大量的资源,例如内存和 CPU,因此,当你不再需要它们时,关闭连接并释放分配的资源是一个好主意。在连接池的情况下,当 Connection 对象关闭时,它会被返回到池中,并消耗更少的资源。

在 Java 7 之前,通过在 finally 块中调用 close() 方法来关闭连接:

try {
    Connection conn = getConnection();
    //use object conn here
} finally { 
    if(conn != null){
        try {
            conn.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    } 
}

finally 块中的代码总是会被执行,无论 try 块中的异常是否被抛出。然而,自从 Java 7 以来,try-with-resources 构造也可以在实现 java.lang.AutoCloseablejava.io.Closeable 接口的对象上完成这项工作。由于 java.sql.Connection 对象实现了 AutoCloseable 接口,我们可以重写之前的代码片段,如下所示:

try (Connection conn = getConnection()) {
    //use object conn here
} catch(SQLException ex) {
    ex.printStackTrace();
}    

catch 子句是必要的,因为 AutoCloseable 资源会抛出 java.sql.SQLException

CRUD 数据

有四种类型的 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 操作符值可以使用 ANDOR 逻辑运算符组合,并通过括号分组,( )

例如,以下方法从 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) 方法从位置 1SELECT 语句中列列表中的第一个)提取 String 值。对于所有原始类型都有类似的获取器:getInt(int position)getByte(int position) 等。

还可以从 ResultSet 对象中按列名提取值。在我们的例子中,它将是 getString("first_name")。当 SELECT 语句如下时,这种方法获取值特别有用:

select * from person;

然而,请注意,使用列名从 ResultSet 对象中提取值效率较低。然而,性能差异非常小,只有在操作多次时才会变得重要。只有实际的测量和测试过程才能告诉你这种差异是否对你的应用程序具有重要意义。通过列名提取值特别吸引人,因为它提供了更好的代码可读性,这在长期的应用程序维护中会带来回报。

ResultSet 接口中还有许多其他有用的方法。如果你的应用程序从数据库中读取数据,我们强烈建议你阅读你使用的数据库版本的 SELECT 语句和 ResultSet 接口的官方文档 (www.postgresql.org/docs)。

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';

使用语句

java.sql.Statement 接口提供了以下方法来执行 SQL 语句:

  • boolean execute(String sql):如果执行语句返回数据(在 java.sql.ResultSet 对象中),并且可以使用 java.sql.Statement 接口的 ResultSet getResultSet() 方法检索,则返回 true。否则,如果执行语句不返回数据(对于 INSERT 语句或 UPDATE 语句),并且对 java.sql.Statement 接口的 int getUpdateCount() 方法的后续调用返回受影响的行数,则返回 false

  • 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

我们将演示这三个方法如何在每个语句上工作:INSERTSELECTUPDATEDELETE

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) 方法

在本节中,我们将尝试执行与在 The execute(String sql) method 部分中演示 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

前面的代码由于查询没有返回结果而抛出异常,因为 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

前面的代码由于查询没有返回结果而抛出异常,因为 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() 方法返回 nullselectAllFirstNames() 方法证明了预期的记录已被插入。

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() 方法返回 nullselectAllFirstNames() 方法证明了预期的记录已被更新。

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() 方法更适合 INSERTUPDATEDELETE 语句。

使用 PreparedStatement

PreparedStatementStatement 接口的一个子接口。这意味着它可以在任何使用 Statement 接口的地方使用。PreparedStatement 的优点是它在数据库中缓存,而不是每次调用时都编译。这样,它可以针对不同的输入值高效地执行多次。可以通过使用相同的 Connection 对象的 prepareStatement() 方法来创建它。

由于相同的 SQL 语句可以用于创建 StatementPreparedStatement,因此对于任何多次调用的 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 对象,可以将输入值替换为问号符号(?);例如,我们可以创建以下方法(参见 database 项目中的 Person 类):

private static final String SELECT_BY_FIRST_NAME = 
            "select * from person where first_name = ?";
static List<Person> selectByFirstName(Connection conn, 
                                     String searchName) {
    List<Person> list = new ArrayList<>();
    try (PreparedStatement st = 
         conn.prepareStatement(SELECT_BY_FIRST_NAME)) {
       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 接口)可以用来执行存储过程,尽管一些数据库允许你使用 StatementPreparedStatement 接口来调用存储过程。CallableStatement 对象是通过 prepareCall() 方法创建的,并且可以有三个类型的参数:

  • IN 用于输入值

  • OUT 用于结果

  • IN OUT用于输入或输出值

IN参数可以像PreparedStatement的参数一样设置,而OUT参数必须通过CallableStatementregisterOutParameter()方法进行注册。

值得注意的是,从 Java 程序中执行存储过程是标准化程度最低的领域之一。例如,PostgreSQL 不支持存储过程,但它们可以作为已经为此目的修改过的函数来调用,将OUT参数解释为返回值。另一方面,Oracle 允许将OUT参数作为函数使用。

这就是为什么数据库函数和存储过程之间的以下差异只能作为一般性指南,而不能作为正式定义:

  • 函数有一个返回值,但它不允许OUT参数(除了某些数据库)并且可以在 SQL 语句中使用。

  • 存储过程没有返回值(除了某些数据库);它允许OUT参数(对于大多数数据库)并且可以使用 JDBC 的CallableStatement接口执行。

您可以参考数据库文档来了解如何执行存储过程。

由于存储过程是在数据库服务器上编译和存储的,因此CallableStatementexecute()方法对于相同的 SQL 语句比StatementPreparedStatement接口的相应方法性能更好。这就是为什么很多 Java 代码有时被一个或多个包含业务逻辑的存储过程所取代的原因之一。然而,对于每一个案例和问题都没有一个正确的答案,所以我们不会提出具体的建议,除了重复熟悉的咒语关于测试的价值和您所编写的代码的清晰性:

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 语法的语法。

使用共享库 JAR 文件访问数据库

事实上,我们已经开始使用database项目的 JAR 文件来访问数据库驱动程序,将其设置为database项目的pom.xml文件中的依赖项。现在,我们将演示如何使用database项目 JAR 文件来操作数据库中的数据。这种使用的示例在UseDatabaseJar类中给出。

为了支持 CRUD 操作,数据库表通常代表一个对象类。此类表中的每一行都包含一个对象的一个类的属性。在创建数据库结构部分,我们展示了Person类和person表之间此类映射的示例。为了说明如何使用 JAR 文件进行数据操作,我们创建了一个单独的database项目,该项目只有一个Person类。除了创建数据库结构部分中显示的属性外,它还包含所有 CRUD 操作的静态方法。以下是对insert()方法的实现:

static final String INSERT = "insert into person " +
  "(first_name, last_name, dob) values (?, ?, ?::date)";
static void insert(Connection conn, Person person) {
   try (PreparedStatement st = 
                       conn.prepareStatement(INSERT)) {
            st.setString(1, person.getFirstName());
            st.setString(2, person.getLastName());
            st.setString(3, person.getDob().toString());
            st.execute();
   } catch (SQLException ex) {
            ex.printStackTrace();
   }
}

以下是对selectByFirstName()方法的实现:

private static final String SELECT = 
          "select * from person where first_name = ?";
static List<Person> selectByFirstName(Connection conn, 
                                    String firstName) {
   List<Person> list = new ArrayList<>();
   try (PreparedStatement st = conn.prepareStatement(SELECT)) {
        st.setString(1, firstName);
        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;
}

以下是对updateFirstNameById()方法的实现:

private static final String UPDATE = 
      "update person set first_name = ? where id = ?";
public static void updateFirstNameById(Connection conn, 
                           int id, String newFirstName) {
   try (PreparedStatement st = conn.prepareStatement(UPDATE)) {
            st.setString(1, newFirstName);
            st.setInt(2, id);
            st.execute();
   } catch (SQLException ex) {
            ex.printStackTrace();
   }
}

以下是对deleteById()方法的实现:

private static final String DELETE = 
                       "delete from person where id = ?";
public static void deleteById(Connection conn, int id) {
   try (PreparedStatement st = conn.prepareStatement(DELETE)) {
            st.setInt(1, id);
            st.execute();
   } catch (SQLException ex) {
            ex.printStackTrace();
   }
}

如您所见,所有前面的方法都接受Connection对象作为参数,而不是在每个方法内部创建和销毁它。我们决定这样做是因为它允许多个操作与每个Connection对象相关联,以防我们希望它们一起提交到数据库或如果其中一个失败则回滚(请参阅您选择的数据库文档中的事务管理部分)。此外,由database项目生成的 JAR 文件可以被不同的应用程序使用,因此数据库连接参数将是特定于应用程序的,这就是为什么Connection对象必须创建在使用 JAR 文件的应用程序中。以下代码演示了这种用法(请参阅UseDatabaseJar类)。

在运行以下示例之前,请确保您已在database文件夹中执行了mvn clean install命令。

1 try(Connection conn = getConnection()){
2    cleanTablePerson(conn);
3    Person mike = new Person("Mike", "Brown", 
                             LocalDate.of(2002, 8, 14));
4    Person jane = new Person("Jane", "McDonald", 
                             LocalDate.of(2000, 3, 21));
5    Person jill = new Person("Jill", "Grey", 
                             LocalDate.of(2001, 4, 1));
6    Person.insert(conn, mike);
7    Person.insert(conn, jane);
8    Person.insert(conn, jane);
9    List<Person> persons = 
           Person.selectByFirstName(conn, jill.getFirstName());
10   System.out.println(persons.size());      //prints: 0
11   persons = Person.selectByFirstName(conn, 
                                          jane.getFirstName());
12   System.out.println(persons.size());      //prints: 2
13   Person person = persons.get(0);
14   Person.updateFirstNameById(conn, person.getId(),
                                          jill.getFirstName());
15   persons = Person.selectByFirstName(conn, 
                                          jane.getFirstName());
16   System.out.println(persons.size());      //prints: 1 
17   persons = Person.selectByFirstName(conn, 
                                          jill.getFirstName());
18   System.out.println(persons.size());      //prints: 1
19   persons = Person.selectByFirstName(conn, 
                                          mike.getFirstName());
20   System.out.println(persons.size());      //prints: 1
21   for(Person p: persons){
22      Person.deleteById(conn, p.getId());
23   }
24   persons = Person.selectByFirstName(conn, 
                                          mike.getFirstName());
25   System.out.println(persons.size());      //prints: 0
26 } catch (SQLException ex){
27       ex.printStackTrace();
28 }

让我们逐步分析前面的代码片段。第1行和第2628行构成了处理Connection对象并捕获在此块执行过程中可能发生的所有异常的try–catch块。

2行只是为了在运行演示代码之前清理person表中的数据。以下是对cleanTablePerson()方法的实现:

void cleanTablePerson(Connection conn) {
   try (Statement st = conn.createStatement()) {
       st.execute("delete from person");
   } catch (SQLException ex) {
       ex.printStackTrace();
   }
}

在第345行,我们创建了三个Person类的对象,然后在第678行,我们使用它们在person表中插入记录。

在第9行,我们查询数据库以获取一个来自jill对象的首个名字的记录,在第10行,我们打印出结果计数,该计数为0(因为我们没有插入这样的记录)。

在第11行,我们查询数据库以获取一个首个名字设置为Jane的记录,在第12行,我们打印出结果计数,该计数为2(因为我们确实插入了两个具有此值的记录)。

在第13行,我们提取前一个查询返回的两个对象中的第一个,在第14行,我们使用来自jill对象的不同值更新相应的记录的首个名字。

在第15行,我们重复查询名字设置为Jane的记录,在第16行,我们打印出结果计数,这次是1(正如预期的那样,因为我们已经将其中一个记录的名字从Jane改为了Jill)。

在第17行,我们选择所有名字设置为Jill的记录,在第18行,我们打印出结果计数,这次是1(正如预期的那样,因为我们已经将其中一个原本名字为Jane的记录的名字改为了Jill)。

在第19行,我们选择所有名字设置为Mike的记录,在第20行,我们打印出结果计数,这次是1(正如预期的那样,因为我们只创建了一个这样的记录)。

在第2123行,我们在循环中删除所有检索到的记录。

因此,当我们再次在第24行选择名字为Mike的所有记录时,在第25行我们得到的结果计数为0(正如预期的那样,因为已经没有这样的记录了)。

在这个阶段,当这段代码片段执行完毕,并且UseDatabseJar类的main()方法完成后,数据库中的所有更改都会自动保存。

这就是 JAR 文件(允许修改数据库中的数据)可以被任何将其作为依赖项的应用程序使用的原理。

摘要

在本章中,我们讨论并演示了如何在 Java 应用程序中填充、读取、更新和删除数据库中的数据。对 SQL 语言的简要介绍说明了如何创建数据库及其结构,如何修改它,以及如何使用StatementPreparedStatementCallableStatement执行 SQL 语句。

现在,你可以创建和使用数据库来存储、更新和检索数据,以及创建和使用共享库。

在下一章中,我们将描述和讨论最流行的网络协议,演示如何使用它们,以及如何使用最新的 Java HTTP 客户端 API 实现客户端-服务器通信。所审查的协议包括基于 TCP、UDP 和 URL 的通信协议的 Java 实现。

问答

  1. 选择所有正确的语句:

    1. JDBC 代表 Java 数据库通信。

    2. JDBC API 包括java.db包。

    3. JDBC API 与 Java 安装一起提供。

    4. JDBC API 包括了所有主要数据库管理系统(DBMS)的驱动程序。

  2. 选择所有正确的语句:

    1. 可以使用CREATE语句创建数据库表。

    2. 可以使用UPDATE语句更改数据库表。

    3. 可以使用DELETE语句删除数据库表。

    4. 每个数据库列都可以有一个索引。

  3. 选择所有正确的语句:

    1. 要连接到数据库,你可以使用Connect类。

    2. 每个数据库连接都必须关闭。

    3. 同一个数据库连接可以用于许多操作。

    4. 数据库连接可以被池化。

  4. 选择所有正确的语句:

    1. 可以使用try-with-resources构造自动关闭数据库连接。

    2. 可以使用 finally 块结构关闭数据库连接。

    3. 可以使用 catch 块关闭数据库连接。

    4. 可以在不使用 try 块的情况下关闭数据库连接。

  5. 选择所有正确的说法:

    1. INSERT 语句包含一个表名。

    2. INSERT 语句包含列名。

    3. INSERT 语句包含值。

    4. INSERT 语句包含约束。

  6. 选择所有正确的说法:

    1. SELECT 语句必须包含一个表名。

    2. SELECT 语句必须包含一个列名。

    3. SELECT 语句必须包含 WHERE 子句。

    4. SELECT 语句可能包含 ORDER 子句。

  7. 选择所有正确的说法:

    1. UPDATE 语句必须包含一个表名。

    2. UPDATE 语句必须包含一个列名。

    3. UPDATE 语句可能包含 WHERE 子句。

    4. UPDATE 语句可能包含 ORDER 子句。

  8. 选择所有正确的说法:

    1. DELETE 语句必须包含一个表名。

    2. DELETE 语句必须包含一个列名。

    3. DELETE 语句可能包含 WHERE 子句。

    4. DELETE 语句可能包含 ORDER 子句。

  9. 选择关于 Statement 接口的 execute() 方法的正确说法:

    1. 它接收一个 SQL 语句。

    2. 它返回一个 ResultSet 对象。

    3. 在调用 execute() 后,Statement 对象可能返回数据。

    4. 在调用 execute() 后,Statement 对象可能返回受影响记录的数量。

  10. 选择关于 Statement 接口的 executeQuery() 方法的正确说法:

    1. 它接收一个 SQL 语句。

    2. 它返回一个 ResultSet 对象。

    3. 在调用 executeQuery() 后,Statement 对象可能返回数据。

    4. 在调用 executeQuery() 后,Statement 对象可能返回受影响记录的数量。

  11. 选择关于 Statement 接口的 executeUpdate() 方法的正确说法:

    1. 它接收一个 SQL 语句。

    2. 它返回一个 ResultSet 对象。

    3. 在调用 executeUpdate() 后,Statement 对象可能返回数据。

    4. 在调用 executeUpdate() 后,Statement 对象返回受影响记录的数量。

  12. 选择关于 PreparedStatement 接口的正确说法:

    1. 它扩展了 Statement

    2. 通过 prepareStatement() 方法创建 PreparedStatement 类型的对象。

    3. 它总是比 Statement 更高效。

    4. 它导致数据库中只创建一次模板。

  13. 选择关于 CallableStatement 接口的正确说法:

    1. 它扩展了 PreparedStatement

    2. 通过 prepareCall() 方法创建 CallableStatement 类型的对象。

    3. 它总是比 PreparedStatement 更高效。

    4. 它导致数据库中只创建一次模板。

第十一章:网络编程

在本章中,我们将描述和讨论最流行的网络协议——用户数据报协议UDP)、传输控制协议TCP)、超文本传输协议HTTP)和WebSocket——以及它们对Java 类库JCL)的支持。我们将演示如何使用这些协议以及如何在 Java 代码中实现客户端-服务器通信。我们还将回顾基于统一资源定位符URL)的通信和最新的Java HTTP 客户端 API。学习完本章后,你将能够创建使用UDPTCPHTTP协议以及WebSocket进行通信的服务器和客户端应用程序。

本章将涵盖以下主题:

  • 网络协议

  • 基于 UDP 的通信

  • 基于 TCP 的通信

  • UDP 与 TCP 协议

  • 基于 URL 的通信

  • 使用 HTTP 2 客户端 API

  • 创建独立应用程序的 HTTP 服务器

到本章结束时,你将能够使用所有最流行的协议在客户端和服务器之间发送/接收消息。你还将学习如何创建作为独立项目的服务器以及如何创建和使用公共共享库。

技术要求

要执行本章提供的代码示例,你需要以下内容:

  • 拥有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高

  • 你选择的 IDE 或代码编辑器

关于如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明提供在本书的第一章开始使用 Java 17,链接为Chapter 1。本章的代码示例文件可在 GitHub 的github.com/PacktPublishing/Learn-Java-17-Programming.git存储库中找到,位于examples/src/main/java/com/packt/learnjava/ch11_network文件夹中,以及commonserver文件夹中,作为独立的项目。

网络协议

网络编程是一个庞大的领域。互联网协议IP)套件由四层组成,每一层都有十几个或更多的协议:

  • 链路层:当客户端物理连接到主机时使用的协议组;三个核心协议包括地址解析协议ARP)、反向地址解析协议RARP)和邻居发现协议NDP)。

  • 10011010.00010111.11111110.00010001,这导致 IP 地址为154.23.254.17。本章的示例使用 IPv4。然而,行业正在缓慢地转向 IPv6。一个 IPv6 地址的例子是594D:1A1B:2C2D:3E3F:4D4A:5B5A:6B4E:7FF2

  • 传输层:一组主机间通信服务。它包括 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 应用程序中实现(该包是在 Java 11 中引入的)。

TCP 和 UDP 协议都可以使用套接字在 Java 中实现。套接字由 IP 地址和端口号的组合标识,它们代表两个应用程序之间的连接。由于 UDP 协议比 TCP 协议简单一些,我们将从 UDP 开始。

基于 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): 这创建一个数据报套接字并将其绑定到指定的端口和指定的本地地址;本地端口必须在 065535 之间。它用于创建接收套接字,当需要绑定特定本地机器地址时。

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,但没有消息到来——所以它等待。然后,我们运行发送者,接收者显示以下消息:

图片

由于缓冲区小于消息,所以只部分接收了消息——其余的消息丢失了。这就是为什么我们将缓冲区大小增加到 30 的原因。此外,我们可以创建一个无限循环,并让接收者无限期地运行(参见UdpReceiver2类):

public class UdpReceiver2 {
 public static void main(String[] args){
    try(DatagramSocket ds = new DatagramSocket(3333)){
       DatagramPacket dp = 
                          new DatagramPacket(new byte[30], 30);
       while(true){
          ds.receive(dp);
          for(byte b: dp.getData()){
              System.out.print(Character.toString(b));
          }
          System.out.println(); //added here to have end-of-
             // line after receiving (and printing) the message
       }
    } catch (Exception ex){
            ex.printStackTrace();
    }
  }
}

通过这样做,我们可以多次运行发送者。以下是当我们运行发送者三次时,接收者UdpReceiver2打印的内容:

图片

如您所见,所有三条消息都已接收。如果您运行UdpReceiver2接收者,不要忘记在您不再需要运行它时手动停止它。否则,它将继续无限期地运行。

因此,这就是 UDP 协议的基本思想。发送者即使没有套接字在这个地址和端口上监听,也会向特定的地址和端口发送消息。在发送消息之前,它不需要建立任何类型的连接,这使得 UDP 协议比 TCP 协议(需要你首先建立连接)更快、更轻量。这样,TCP 协议通过确保目的地存在并且消息可以被投递,将消息发送提升到了另一个可靠性的层次。

基于 TCP 的通信

TCP 是在 20 世纪 70 年代由国防高级研究计划局DARPA)为在高级研究计划局网络ARPANET)中使用而设计的。它补充了 IP,因此也被称为 TCP/IP。从其名称来看,TCP 协议表明它提供可靠(即,经过错误检查或控制的)数据传输。它允许在 IP 网络中按顺序交付字节,并被广泛应用于网页、电子邮件、安全外壳和文件传输。

使用 TCP/IP 的应用程序甚至不知道在套接字和传输细节之间发生的所有握手过程——例如网络拥塞、流量负载均衡、重复以及甚至某些 IP 数据包的丢失。传输层的底层协议实现检测到这些问题,重新发送数据,重建发送数据包的顺序,并最小化网络拥塞。

与 UDP 协议相比,基于 TCP/IP 的通信更注重准确交付,而牺牲了交付周期。这就是为什么它不适用于需要可靠交付和正确顺序的实时应用,如 IP 语音。然而,如果每个比特都需要精确地按照发送的顺序到达,那么 TCP/IP 是不可或缺的。

为了支持这种行为,TCP/IP 通信在整个通信过程中保持一个会话。会话由客户端地址和端口标识。每个会话在服务器上的表中都有一个条目。这包含有关会话的所有元数据:客户端 IP 地址和端口、连接状态和缓冲区参数。然而,这些细节通常对应用程序开发者是隐藏的,所以我们不会在这里进一步详细说明。相反,我们将转向 Java 代码。

与 UDP 协议类似,Java 中的 TCP/IP 协议实现使用套接字。但与实现 UDP 协议的java.net.DatagramSocket类不同,基于 TCP/IP 的套接字由java.net.ServerSocketjava.net.Socket类表示。它们允许两个应用程序之间发送和接收消息,其中一个作为服务器,另一个作为客户端。

ServerSocketSocketClass类执行非常相似的任务。唯一的区别是ServerSocket类有accept()方法,它接受来自客户端的请求。这意味着服务器必须首先启动并准备好接收请求。然后,客户端通过创建自己的套接字并发送连接请求(来自Socket类的构造函数)来发起连接。服务器随后接受请求并创建一个连接到远程套接字(在客户端端)的本地套接字。

在建立连接后,可以使用如第五章中描述的 I/O 流进行数据传输,即字符串、输入/输出和文件Socket对象具有getOutputStream()getInputStream()方法,这些方法提供了对套接字数据流的访问。来自本地计算机的java.io.OutputStream对象看起来像是来自远程机器的java.io.InputStream对象。

现在我们将更详细地研究java.net.ServerSocketjava.net.Socket类,然后运行一些它们使用示例。

java.net.ServerSocket

java.net.ServerSocket类有四个构造函数:

  • ServerSocket(): 这将创建一个未绑定到特定地址和端口的服务器套接字对象。它需要使用bind()方法来绑定套接字。

  • ServerSocket(int port):这创建了一个绑定到提供的端口的服务器套接字对象。port值必须在065535之间。如果端口号指定为0的值,这意味着端口号需要自动绑定。然后可以通过调用getLocalPort()来检索此端口号。默认情况下,传入连接的最大队列长度为50。这意味着默认情况下最大并行传入连接为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对象仍然有效并且可以被重用。timeout值为0表示无限超时(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();
    }
  }
}

让我们逐步分析前面的代码。在 try-with-resources 语句中,我们根据新创建的 socket 创建了SocketDataInputStreamDataOutputStream对象,以及BufferedReader对象来读取控制台的用户输入(我们将用它来输入数据)。在创建 socket 时,accept()方法会阻塞,直到客户端尝试连接到本地服务器的3333端口。

然后,代码进入一个无限循环。首先,它使用DataInputStreamreadUTF()方法读取客户端发送的字节,并将其作为修改后的 UTF-8 格式的 Unicode 字符字符串读取。结果带有"Client said: "前缀打印出来。如果接收到的消息是"end"字符串,则代码退出循环,服务器程序退出。如果消息不是"end",则控制台显示"Say something: "提示,readLine()方法会阻塞,直到用户输入一些内容并点击Enter

服务器从屏幕读取输入,并使用writeUtf()方法将其作为 Unicode 字符字符串写入输出流。正如我们之前提到的,服务器的输出流连接到客户端的输入流。如果客户端从输入流中读取,它会接收到服务器发送的消息。如果发送的消息是"end",则服务器退出循环和程序。如果不是,则再次执行循环体。

描述的算法假设客户端只有在发送或接收"end"消息时才会退出。否则,如果客户端在之后尝试向服务器发送消息,则会生成异常。这展示了我们之前提到的 UDP 和 TCP 协议之间的区别——TCP 基于服务器和客户端 socket 之间建立的会话。如果任一方断开连接,另一方会立即遇到错误。

现在,让我们回顾一个 TCP 客户端实现的例子。

The java.net.Socket class

由于在先前的例子中已经使用过,java.net.Socket类现在应该对您来说很熟悉。我们使用它来访问已连接 socket 的输入和输出流。现在我们将系统地回顾Socket类,并探讨如何使用它来创建 TCP 客户端。Socket类有五个构造函数:

  • Socket(): 这将创建一个未连接的 socket。它使用connect()方法来建立此 socket 与服务器上 socket 的连接。

  • Socket(String host, int port): 这将创建一个 socket 并将其连接到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 对象的其他属性,并且它们可以用于更好地动态管理套接字连接。您可以阅读该类的在线文档以详细了解可用的选项。

运行示例

现在让我们运行 TcpServerTcpClient 程序。如果我们首先启动 TcpClient,我们会先遇到 TcpServer 程序的 java.net.ConnectException。当它启动时,不会显示任何消息。相反,它只是等待客户端连接。因此,我们随后启动 TcpClient 并在屏幕上看到以下消息:

我们输入 Hello! 然后按 Enter 键:

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

我们在服务器端屏幕上输入 Hi! 并按 Enter 键:

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

我们可以无限期地继续这个对话,直到服务器或客户端发送消息 end。让我们让客户端来完成这个操作;客户端说 end 然后退出:

然后,服务器也照此办理:

这就是我们讨论 TCP 协议时想要展示的所有内容。现在让我们回顾 UDP 和 TCP 协议之间的区别。

UDP 与 TCP 协议对比

UDP 和 TCP/IP 协议之间的区别可以列举如下:

  • UDP 简单地发送数据,无论数据接收器是否正在运行。这就是为什么 UDP 比许多使用多播分发的其他客户端更适合发送数据。另一方面,TCP 需要首先在客户端和服务器之间建立连接。TCP 客户端发送一个特殊的控制消息;服务器接收它并以确认响应。然后客户端向服务器发送一个消息以确认服务器的确认。只有在完成这些步骤后,客户端和服务器之间才能进行数据传输。

  • TCP 保证消息的交付或引发错误,而 UDP 则不保证,数据报文可能丢失。

  • TCP 保证在交付时消息的顺序,而 UDP 则不保证。

  • 由于这些提供的保证,TCP 比 UDP 慢。

  • 此外,协议需要将头部信息与数据包一起发送。TCP 数据包的头部大小为 20 字节,而数据报文为 8 字节。UDP 头部包含长度源端口目的端口校验和,而 TCP 头部包含序列号确认号数据偏移保留位控制位窗口紧急指针选项填充,除了 UDP 头部之外。

  • 不同的应用程序协议基于 TCP 或 UDP 协议。基于TCP的协议有HTTPHTTPSTelnetFTPSMTP。基于UDP的协议有动态主机配置协议DHCP)、DNS简单网络管理协议SNMP)、简单文件传输协议TFTP)、引导协议BOOTP)和网络文件系统NFS)的早期版本。

我们可以用一句话概括 UDP 和 TCP 之间的区别:UDP 协议比 TCP 更快、更轻量,但可靠性较低。就像生活中的许多事情一样,你必须为额外的服务支付更高的代价。然而,并非所有这些服务在所有情况下都是必需的,所以考虑手头的任务,并根据应用程序需求决定使用哪种协议。

基于 URL 的通信

现在,似乎每个人对 URL 都有一些概念;那些在电脑或智能手机上使用浏览器的用户会每天看到 URL。在本节中,我们将简要解释构成 URL 的不同部分,并演示如何通过编程方式从网站(或文件)请求数据或向网站发送(发布)数据。

URL 语法

一般而言,URL 语法符合以下格式的统一资源标识符URI)语法:

scheme:[//authority]path[?query][#fragment]

方括号表示该组件是可选的。这意味着 URI 至少由scheme:path组成。scheme组件可以是httphttpsftpmailtofiledata或另一个值。path组件由一系列由斜杠(/)分隔的路径段组成。以下是一个只包含schemepath的 URL 示例:

file:src/main/resources/hello.txt

前面的 URL 指向一个位于使用此 URL 的目录中的本地文件系统上的文件。以下是一些您更熟悉的例子:www.google.comwww.packtpub.com。我们将很快演示它是如何工作的。

path组件可以是空的,但这样 URL 看起来就没什么用了。尽管如此,空路径通常与authority一起使用,其格式如下:

[userinfo@]host[:port]

authority的唯一必需组件是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代替scheme

  • reference代替fragment

  • file代替path[?query][#fragment]

  • resource代替host[:port]path[?query][#fragment]

所以,从 Oracle 文档的角度来看,URL 由protocolresource值组成。

现在我们来看看 Java 中 URL 的编程使用。

Java 的java.net.URL

在 Java 中,URL 由java.net.URL类的对象表示,该类有六个构造函数:

  • URL(String spec): 从 URL 字符串创建一个URL对象。

  • URL(String protocol, String host, String file): 从提供的protocolhostfilepathquery)值创建一个URL对象,并基于提供的protocol值使用默认端口号。

  • URL(String protocol, String host, int port, String path): 从提供的protocolhostportfilepathquery)值创建一个URL对象。port值为-1表示需要根据提供的protocol值使用默认端口号。

  • URL(String protocol, String host, int port, String file, URLStreamHandler handler): 这个构造函数与前面的构造函数作用相同,并且还允许您传递特定协议处理器的对象;所有前面的构造函数都会自动加载默认处理器。

  • URL(URL context, String spec): 这个构造函数创建了一个URL对象,它扩展了提供的URL对象或使用提供的spec值覆盖其组件,其中spec是 URL 或其组件的字符串表示。例如,如果方案在两个参数中都存在,则spec中的方案值会覆盖context中的方案值以及许多其他值。

  • URL(URL context, String spec, URLStreamHandler handler): 这个构造函数与前面的构造函数作用相同,并且还允许您传递特定协议处理器的对象。

一旦创建,URL对象允许您获取底层 URL 的各个组件的值。InputStream openStream()方法提供了从 URL 接收到的数据流的访问权限。实际上,它是作为openConnection.getInputStream()实现的。URL类的openConnection()方法返回一个URLConnection对象,该对象具有许多方法,可以提供有关与 URL 的连接的详细信息,包括允许您向 URL 发送数据的getOutputStream()方法。

让我们看一下UrlFileReader代码示例,它从hello.txt文件读取数据,这是一个我们在第五章,“字符串、输入/输出和文件”中创建的本地文件。该文件只包含一行:Hello!;以下是读取它的代码:

try {
  ClassLoader classLoader = 
              Thread.currentThread().getContextClassLoader(); 
  String file = classLoader.getResource("hello.txt").getFile(); 
  URL url = new URL(file);
     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();
}

在前面的代码中,我们使用类加载器访问资源(hello.txt文件)并构建一个指向它的 URL。

接下来的代码是打开来自文件的数据输入流,并将接收到的字节作为字符打印出来。结果在内联注释中显示。

现在,让我们演示一下 Java 代码如何从指向互联网上源点的 URL 读取数据。让我们使用Java关键字调用 Google 搜索引擎(UrlSiteReader类):

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();
}

在这里,我们经过一些研究和实验后提出了www.google.com/search?q=Java&num=10 URL,并请求了其属性。不能保证它总是有效,所以如果它没有返回我们描述的数据,请不要感到惊讶。此外,这是一个实时搜索,结果可能会随时改变。当它有效时,Google 会返回包含数据的页面。

上述代码还演示了getPath()getFile()方法返回值的差异。您可以在前面的代码示例中查看内联注释。

与使用文件 URL 的示例相比,Google 搜索示例使用了URLConnection对象,因为我们需要设置请求头字段:

  • Accept 告诉服务器调用者请求的内容类型(理解)。

  • Connection 告诉服务器在收到响应后关闭连接。

  • Accept-Language 告诉服务器调用者请求的语言(理解)。

  • User-Agent 告诉服务器有关调用者的信息;否则,谷歌搜索引擎(www.google.com)会以 403(禁止)HTTP 状态码响应。

上述示例中的剩余代码只是从来自 URL 的数据(HTML 代码)输入流中读取并逐行打印。我们捕获了结果(从屏幕上复制),将其粘贴到在线 HTML 格式化工具(jsonformatter.org/html-pretty-print)中,并运行它。结果在以下屏幕截图中展示,运行结果可能会有所不同,因为谷歌功能随着时间的推移而不断发展:

图片

如您所见,它看起来像典型的页面,带有搜索结果,只是没有返回的 HTML 中的谷歌图片。

重要提示

注意,如果你多次执行此代码,谷歌可能会阻止你的 IP 地址。

类似地,可以向 URL 发送(POST)数据。以下是一个示例代码:

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);
    OutputStream os = conn.getOutputStream()
    OutputStreamWriter osw = new OutputStreamWriter(os);
    osw.write("parameter1=value1&parameter2=value2");
    osw.flush();
    osw.close();
    InputStream is = conn.getInputStream();
    BufferedReader br = 
               new BufferedReader(new InputStreamReader(is));
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
    br.close();
} catch (Exception e) {
    e.printStackTrace();
}

上述代码期望在 localhost 服务器上运行,端口为 3333,能够处理带有 "/something" 路径的 POST 请求。如果服务器没有检查方法(它是 POST 还是其他 HTTP 方法)并且没有检查 User-Agent 值,则无需指定任何内容。因此,我们注释了这些设置,只是为了演示如果需要,这些以及类似值可以如何设置。

注意,我们使用了 setDoOutput() 方法来指示必须发送输出;默认情况下,它设置为 false。然后,我们让输出流将查询参数发送到服务器。

上述代码的另一个重要方面是,在打开输入流之前必须关闭输出流。否则,输出流的内容将不会发送到服务器。虽然我们明确地这样做,但更好的方法是使用 try-with-resources 块,该块保证即使在块中引发异常,也会调用 close() 方法。

这是上述示例的更好版本(使用 try-with-resources 块)在 UrlPost 类中:

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 (OutputStream os = conn.getOutputStream();
         OutputStreamWriter osw = new OutputStreamWriter(os)) {
       osw.write("parameter1=value1&parameter2=value2");
       osw.flush();
    }
    try (InputStream is = conn.getInputStream();
         BufferedReader br = 
                new BufferedReader(new InputStreamReader(is))) {
       String line;
       while ((line = br.readLine()) != null) {
           System.out.println(line);  //prints server response 
       }
    }
} catch (Exception ex) {
    ex.printStackTrace();
}

如您所见,此代码在端口 3333localhost 服务器上调用 URI something,并带有查询参数 parameter1=value1&parameter2=value2。然后,它立即读取服务器的响应,打印它,并退出。

为了演示这个示例的工作原理,我们还创建了一个简单的服务器,该服务器监听 localhost3333 端口,并分配了一个处理所有带有 "/something" 路径的请求的处理程序(请参考 server 文件夹中单独项目中的 Server 类):

private static Properties properties;
public static void main(String[] args){
   ClassLoader classLoader =  
                Thread.currentThread().getContextClassLoader();
   properties = Prop.getProperties(classLoader, 
                                             "app.properties");
   int port = Prop.getInt(properties, "port");
   try {
      HttpServer server = 
             HttpServer.create(new InetSocketAddress(port), 0);
      server.createContext("/something", new PostHandler());
      server.setExecutor(null);
      server.start();
   } catch (IOException e) {
        e.printStackTrace();
   }
} 
private static class PostHandler implements HttpHandler {
    public void handle(HttpExchange exch) {
       System.out.println(exch.getRequestURI());   
                                        //prints: /something  
       System.out.println(exch.getHttpContext().getPath());
                                        //prints: /something
       try (InputStream is = exch.getRequestBody();
            BufferedReader in = 
               new BufferedReader(new InputStreamReader(is));
            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();
       }
    }
}

为了实现服务器,我们使用了 Java 类库中随 JDK 提供的com.sun.net.httpserver包中的类。它开始监听端口3333,并阻塞直到带有"/something"路径的请求到来。

我们使用了位于common文件夹中的common库(一个独立的项目),其中包含Prop类,该类提供了对resources文件夹中属性文件的访问。请注意,如何将此库作为依赖项包含在server项目的pom.xml文件中:

        <dependency> 
            <groupId>com.packt.learnjava</groupId> 
            <artifactId>common</artifactId> 
            <version>1.0-SNAPSHOT</version> 
        </dependency> 

Prop类包含两个方法:

public static Properties getProperties(ClassLoader classLoader,
                                               String fileName){
    String file = classLoader.getResource(fileName).getFile();
    Properties properties = new Properties();
    try(FileInputStream fis = new FileInputStream(file)){
         properties.load(fis);
    } catch (Exception ex) {
         ex.printStackTrace();
    }
    return properties;
}
public static int getInt(Properties properties, String name){
    return Integer.parseInt(properties.getProperty(name));
}

我们使用Prop类从server项目的app.properties文件中获取port属性的值。

server项目中,内部PostHandler类的实现展示了 URL 没有参数:我们打印了 URI 和路径。它们都有相同的"/something"值;参数来自请求体。

请求处理完毕后,服务器会发送回消息“收到了!谢谢。”让我们看看它是如何工作的;我们首先运行服务器。这也可以用两种方式来完成:

  1. 只需使用您的 IDE 在Server类中运行main()方法。点击以下截图中的任意一个绿色三角形:

  1. 进入common文件夹并执行以下 Maven 命令:

    mvn clean package
    

此命令在common项目中编译代码,并在target子目录中构建了common-1.0-SNAPSHOT.jar文件。现在,在server文件夹中重复相同的命令,并在server文件夹中运行以下命令:

java -cp target/server-1.0-SNAPSHOT.jar:          \
         ../common/target/common-1.0-SNAPSHOT.jar \
         com.packt.learnjava.network.http.Server

如您所见,前面的命令在类路径上列出了两个.jar文件(我们刚刚构建的),并运行了Server类的main()方法。

结果是服务器正在等待客户端代码调用它。

现在,让我们执行客户端(UrlPost类)。我们也可以用两种方式来做这件事:

  1. 只需使用您的 IDE 在UrlPost类中运行main()方法。点击以下截图中的任意一个绿色三角形:

  1. 进入examples文件夹并执行以下 Maven 命令:

    mvn clean package
    

此命令在examples项目中编译代码,并在target子目录中构建了examples-1.0-SNAPSHOT.jar文件。

现在,在examples文件夹中运行以下命令:

java -cp target/examples-1.0-SNAPSHOT.jar:       \
         com.packt.learnjava.ch11_network.UrlPost

运行客户端代码后,在服务器端屏幕上观察以下输出:

如您所见,服务器成功接收到了参数(或者任何其他消息)。现在它可以解析它们并按需使用。

如果我们查看客户端屏幕,我们会看到以下输出:

这意味着客户端从服务器接收到了消息,并按预期退出了。

注意到在我们的例子中,服务器不会自动退出,必须手动停止。

URLURLConnection类的其他方法允许您设置/获取其他属性,并可用于更动态地管理客户端-服务器通信。java.net包中还有HttpUrlConnection类(以及其他类),它简化并增强了基于 URL 的通信。您可以阅读java.net包的在线文档以更好地了解可用的选项。

使用 HTTP 2 客户端 API

Java 9 中引入了 HTTP 客户端 API,作为jdk.incubator.http包中的孵化 API。在 Java 11 中,它被标准化并移动到java.net.http包。它是一个比URLConnectionAPI 更丰富且更易于使用的替代品。除了所有基本连接相关功能外,它还提供了使用CompletableFuture的非阻塞(异步)请求和响应,并支持 HTTP 1.1 和 HTTP 2。

HTTP 2 向 HTTP 协议添加了以下新功能:

  • 能够以二进制格式发送数据而不是文本格式;二进制格式在解析上更高效,更紧凑,且更不易受到各种错误的影响。

  • 它是完全多路复用的,因此只需使用一个连接就可以并发地发送多个请求和响应。

  • 它使用头部压缩,从而减少了开销。

  • 如果客户端表示支持 HTTP 2,它允许服务器将响应推送到客户端的缓存。

该包包含以下类:

  • HttpClient:这用于同步和异步地发送请求和接收响应。可以使用具有默认设置的静态newHttpClient()方法或使用HttpClient.Builder类(由静态newBuilder()方法返回)创建一个实例,该类允许您自定义客户端配置。一旦创建,该实例是不可变的,并且可以被多次使用。

  • HttpRequest:这创建并代表一个带有目标 URI、头部和其他相关信息的 HTTP 请求。可以使用HttpRequest.Builder类(由静态newBuilder()方法返回)创建一个实例。一旦创建,该实例是不可变的,并且可以被多次发送。

  • HttpRequest.BodyPublisher:它从某个来源(如字符串、文件、输入流或字节数组)发布一个体(用于POSTPUTDELETE方法)。

  • HttpResponse:这代表客户端在发送 HTTP 请求后收到的 HTTP 响应。它包含原始 URI、头部、消息体和其他相关信息。一旦创建,该实例可以被多次查询。

  • HttpResponse.BodyHandler:这是一个函数式接口,它接受响应并返回一个HttpResponse.BodySubscriber实例,该实例可以处理响应体。

  • HttpResponse.BodySubscriber:它接收响应体(其字节)并将其转换为字符串、文件或类型。

HttpRequest.BodyPublishersHttpResponse.BodyHandlersHttpResponse.BodySubscribers类是工厂类,用于创建相应类的实例。例如,BodyHandlers.ofString()方法创建一个BodyHandler实例,该实例将响应体字节作为字符串处理,而BodyHandlers.ofFile()方法创建一个BodyHandler实例,该实例将响应体保存到文件中。

您可以阅读java.net.http包的在线文档,了解更多关于这些以及其他相关类和接口的信息。接下来,我们将查看并讨论一些 HTTP API 使用的示例。

阻塞 HTTP 请求

以下是一个简单 HTTP 客户端的示例,它向 HTTP 服务器发送一个GET请求(请参阅HttpClientDemo类中的get()方法):

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();

为了展示客户端的功能,我们将使用之前已经使用过的相同的Server类。提醒一下,这是它处理客户端请求并响应"Got it! Thanks."的方式:

try (InputStream is = exch.getRequestBody();
     BufferedReader in = 
            new BufferedReader(new InputStreamReader(is));
     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();
}

如果我们启动这个服务器并运行前面的客户端代码,服务器会在其屏幕上打印以下信息:

图片

客户端没有发送消息,因为它使用了 HTTP 的GET方法。尽管如此,服务器仍然响应,客户端的屏幕显示了以下信息:

图片

HttpClient类的send()方法在收到来自服务器的响应之前会阻塞。

使用 HTTP 的POSTPUTDELETE方法会产生类似的结果;现在让我们运行以下代码(请参阅HttpClientDemo类中的post()方法):

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 there!,服务器的屏幕显示了以下内容:

图片

HttpClient类的send()方法在收到相同的响应后才会解除阻塞:

图片

到目前为止,所展示的功能与我们在上一节中看到的基于 URL 的通信并没有太大的不同。现在我们将使用HttpClient方法,这些方法在 URL 流中是不可用的。

非阻塞(异步)HTTP 请求

HttpClient类的sendAsync()方法允许您在不阻塞的情况下向服务器发送消息。为了演示它是如何工作的,我们将执行以下代码(请参阅HttpClientDemo类中的getAsync1()方法):

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

我们将在第十三章“函数式编程”中讨论函数以及它们如何作为参数传递。现在,我们只是提到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 unitlong timeout,它们指定了单位的数量,表示该方法应该等待由CompletableFuture<Void>对象表示的任务完成多长时间。在我们的例子中,任务是向服务器发送消息并获取响应(并使用提供的函数进行处理)。如果任务在指定的时间内未完成,get()方法将被中断(并在catch块中打印堆栈跟踪)。

“退出客户端...”的消息应该在屏幕上出现,要么是 5 秒后(在我们的例子中),要么在get()方法返回之后。

如果我们运行客户端,服务器的屏幕上会再次显示以下消息,这是由于阻塞的 HTTP GET 请求:

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

如您所见,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()函数产生的值,因此它应该返回服务器消息体;让我们运行此代码(请参阅HttpClientDemo类中的getAsync2()方法)并查看结果:

图 11.21

现在,get()方法按预期返回服务器的消息,并通过函数呈现,并将其作为参数传递给thenApply()方法。

类似地,我们可以使用 HTTP 的POSTPUTDELETE方法来发送消息(请参阅HttpClientDemo类中的postAsync()方法):

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...");

与上一个示例的唯一区别是,现在服务器显示了接收到的客户端消息:

图 11.22

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

图 11.23

异步请求的优点是它们可以快速发送,而且不需要等待每个请求完成。HTTP 2 协议通过多路复用来支持它;例如,让我们发送三个请求如下(请参阅HttpClientDemo类中的postAsyncMultiple()方法):

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...");

服务器的屏幕显示以下消息:

图 11.24

注意传入请求的任意顺序;这是因为客户端使用Executors.newCachedThreadPool()线程池来发送消息。每条消息由不同的线程发送,线程池有自己的逻辑来使用池成员(线程)。如果消息数量很大,或者每条消息消耗了大量的内存,限制并发运行的线程数量可能是有益的。

HttpClient.Builder类允许您指定用于获取发送消息的线程的池(请参阅HttpClientDemo类中的postAsyncMultipleCustomPool()方法):

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 管理等功能。

服务器推送功能

HTTP/2 协议相对于 HTTP/1.1 的第二个(在多路复用之后)显著优势是允许服务器在客户端表明它支持 HTTP/2 的情况下将响应推送到客户端的缓存。以下是利用此功能的客户端代码(请参阅 HttpClientDemo 类中的 push() 方法):

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。这意味着您不需要实现所有这些方法,而只需实现客户端为特定任务所需的方法(请参阅 HttpClientDemo 类中的私有 WsClient 类):

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.events网站提供的 WebSocket 服务器。它允许 WebSocket 连接并将接收到的消息发送回;这样的服务器通常被称为回声服务器

我们预计我们的客户端将在建立连接后发送消息。然后,它将从服务器接收(相同的)消息,显示它,并发送另一条消息,依此类推,直到它关闭。以下代码调用了我们创建的客户端(请参阅HttpClientDemo类中的webSocket()方法):

HttpClient httpClient = HttpClient.newHttpClient();
WebSocket webSocket = httpClient.newWebSocketBuilder()
    .buildAsync(URI.create("ws://echo.websocket.events"), 
                           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 毫秒,然后发送关闭消息并退出。如果我们运行此代码,我们将看到以下消息:

图片

如您所见,客户端表现如预期。为了完成我们的讨论,我们想提到所有现代网络浏览器都支持 WebSocket 协议的事实。

摘要

在本章中,您被介绍了一些最流行的网络协议的描述:UDP、TCP/IP 和 WebSocket。讨论通过使用 JCL 的代码示例进行了说明。我们还回顾了基于 URL 的通信和最新的 Java HTTP 2 客户端 API。

现在,您可以使用基本的互联网协议在客户端和服务器之间发送/接收消息,并且还知道如何创建一个作为独立项目的服务器,以及如何创建和使用公共共享库。

下一章提供了 Java GUI 技术的概述,并演示了一个使用 JavaFX 的 GUI 应用程序,包括带有控件、图表、CSS、FXML、HTML、媒体和各种其他效果的代码示例。您将学习如何使用 JavaFX 创建 GUI 应用程序。

测验

  1. 列出应用层五个网络协议。

  2. 列出两个传输层的网络协议。

  3. 哪个 Java 包包括支持 HTTP 协议的类?

  4. 哪个协议是基于交换数据报文的?

  5. 是否可以向没有运行服务器的 IP 地址发送数据报?

  6. 哪个 Java 包包含支持 UDP 和 TCP 协议的类?

  7. TCP 代表什么?

  8. TCP 和 TCP/IP 协议有什么共同之处?

  9. 如何识别 TCP 会话?

  10. ServerSocketSocket的功能之间有一个主要区别是什么?

  11. TCP 和 UDP 哪个更快?

  12. TCP 和 UDP 哪个更可靠?

  13. 列出三个基于 TCP 的协议。

  14. 以下哪些是 URI 的组成部分?选择所有适用的选项:

    1. 分节

    2. 标题

    3. 权限

    4. 查询

  15. schemeprotocol之间的区别是什么?

  16. URI 和 URL 之间的区别是什么?

  17. 以下代码会打印什么?

      URL url = new URL("http://www.java.com/something?par=42");
      System.out.print(url.getPath());  
      System.out.println(url.getFile());   
    
  18. 列举 HTTP 2 相对于 HTTP 1.1 的两个新特性。

  19. HttpClient类的完全限定名称是什么?

  20. WebSocket类的完全限定名称是什么?

  21. HttpClient.newBuilder().build()HttpClient.newHttpClient()之间的区别是什么?

  22. CompletableFuture类的完全限定名称是什么?

第十二章:Java GUI 编程

本章提供了 Java 图形用户界面GUI)技术的概述,并演示了如何使用 JavaFX 工具包创建 GUI 应用程序。JavaFX 的最新版本不仅提供了许多有用的功能,还允许保留和嵌入旧版实现和样式。

在某种程度上,GUI 是应用程序最重要的部分。它直接与用户交互。如果 GUI 不方便、不吸引人或不清晰,即使是最优秀的后端解决方案也可能无法说服用户使用这个应用程序。相比之下,一个经过深思熟虑、直观且设计精良的 GUI 有助于保留用户,即使应用程序的工作效果不如竞争对手。

本章的议程要求我们涵盖以下主题:

  • Java GUI 技术概述

  • JavaFX 基础知识

  • 使用 JavaFX 的 HelloWorld

  • 控制元素

  • 图表

  • 应用 CSS

  • 使用 FXML

  • 嵌入 HTML

  • 播放媒体

  • 添加效果

到本章结束时,您将能够使用 Java GUI 技术创建用户界面,以及创建和使用用户界面项目作为独立应用程序。

技术要求

要能够执行本章提供的代码示例,您需要以下内容:

  • 一台装有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高版本

  • 您选择的 IDE 或代码编辑器

如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明在 第一章,“开始使用 Java 17”中提供。本章的代码示例文件可在 GitHub 上找到,网址为 github.com/PacktPublishing/Learn-Java-17-Programming.git,在 examples/src/main/java/com/packt/learnjava/ch12_gui 文件夹和 gui 文件夹中,其中包含一个独立的 GUI 应用程序。

Java GUI 技术概述

Java 基础类JFC)的名称可能是一个引起许多混淆的来源。它暗示了 Java 的基础类,而实际上,JFC 只包括与 GUI 相关的类和接口。为了更精确,JFC 是三个框架的集合:抽象窗口工具包AWT)、Swing 和 Java 2D。

JFC 是 Java 类库JCL)的一部分,尽管 JFC 的名称直到 1997 年才出现,而 AWT 从一开始就是 JCL 的一部分。当时,Netscape 开发了一个名为 Internet Foundation ClassesIFC)的 GUI 库,Microsoft 也为 GUI 开发创建了 Application Foundation ClassesAFC)。因此,当 Sun Microsystems 和 Netscape 决定创建一个新的 GUI 库时,他们继承了单词 Foundation 并创建了 JFC。Swing 框架接管了从 AWT 到 Java GUI 编程,并且成功使用了近二十年。

在 Java 8 中,Java 控制台库(JCL)中添加了一个新的 GUI 编程工具包,JavaFX。它在 Java 11 中被从 JCL 中移除,从那时起,它作为由公司 Gluon 支持的开源项目的一部分,以可下载模块的形式存在,除了 JDK。JavaFX 在 GUI 编程方面采用了与 AWT 和 Swing 略有不同的方法。它提供了一个更一致和更简单的设计,并且有很大的机会成为获胜的 Java GUI 编程工具包。

JavaFX 基础知识

纽约、伦敦、巴黎和莫斯科等城市拥有众多剧院,居住在那里的人们不可避免地会听到几乎每周都会发布的新剧本和制作。这使得他们不可避免地熟悉了剧院术语,其中舞台场景事件这些术语可能被使用得最为频繁。这三个术语也是 JavaFX 应用程序结构的基础。

JavaFX 中代表所有其他组件的最高级容器是由javafx.stage.Stage类表示的。因此,可以说在 JavaFX 应用程序中,所有事情都是在舞台上发生的。从用户的角度来看,这是一个显示区域或窗口,所有控件和组件都在这里执行它们的动作(就像剧院中的演员一样)。而且,与剧院中的演员类似,它们在场景的上下文中执行,由javafx.scene.Scene类表示。因此,JavaFX 应用程序,就像剧院中的戏剧一样,是由在Stage对象内部一次呈现一个的Scene对象组成的。每个Scene对象包含一个图,定义了场景演员(称为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类的公共子类是立即封装的类。这是启动 JavaFX 应用程序最常用的方法;我们也将要在我们的示例中使用它。

  • void init(): 在Application类加载后调用此方法;通常用于某种类型的资源初始化。默认实现不执行任何操作,我们也不会使用它。

  • void notifyPreloader(Preloader.PreloaderNotification info): 当初始化耗时较长时,可以使用此方法来显示进度;我们不会使用它。

  • abstract void start(Stage primaryStage): 我们将要实现的方法。在init()方法返回后,以及系统准备好执行主要工作后调用。primaryStage参数是应用程序将要展示其场景的阶段。

  • void stop(): 当应用程序应该停止时调用此方法,可以用来释放资源。默认实现不执行任何操作,我们也不会使用它。

JavaFX 工具包的 API 可以在网上找到(openjfx.io/javadoc/18//)。截至写作时,最新版本是18。Oracle 还提供了广泛的文档和代码示例(docs.oracle.com/javafx/2//)。文档包括 Scene Builder(一个提供可视化布局环境并允许您快速为 JavaFX 应用程序设计用户界面的开发工具)的描述和用户手册。这个工具对于创建复杂和精细的 GUI 可能很有用,许多人一直在使用它。然而,在这本书中,我们将专注于不使用此工具的 JavaFX 代码编写。

要能够做到这一点,以下是一些必要的步骤:

  1. 将以下依赖项添加到pom.xml文件中:

    <dependency>
       <groupId>org.openjfx</groupId>
       <artifactId>javafx-controls</artifactId>
       <version>18</version>
    </dependency>
    <dependency>
       <groupId>org.openjfx</groupId>
       <artifactId>javafx-fxml</artifactId>
       <version>18</version>
    </dependency>
    
  2. gluonhq.com/products/javafx/(截至写作时的openjfx-18_osx-x64_bin-sdk.zip文件)下载适用于您的操作系统的 JavaFX SDK,并将其解压缩到任何目录中。

  3. 假设您已将 JavaFX SDK 解压缩到/path/javafx-sdk/文件夹中,请将以下选项添加到 Java 命令中,这将启动 Linux 平台上的 JavaFX 应用程序:

    --module-path /path/javafx-sdk/lib   
    --add-modules=javafx.controls,javafx.fxml
    

在 Windows 上,这些选项看起来如下:

--module-path C:\path\javafx-sdk\lib  
--add-modules=javafx.controls,javafx.fxml

/path/JavaFX/C:\path\JavaFX\是您需要用包含 JavaFX SDK 的文件夹的实际路径替换的占位符。

假设应用程序的主类是HelloWorld,在 IntelliJ 中,将前面的选项输入到VM options字段中,如下所示(示例为 Linux):

这些选项必须添加到源代码中ch12_gui包的HelloWorldBlendEffectOtherEffects类的Run/Debug Configurations中。如果您更喜欢不同的 IDE 或使用不同的操作系统,您可以在openjfx.io文档中找到如何设置的推荐方法(openjfx.io/openjfx-docs)。

要从命令行运行 HelloWorldBlendEffectOtherEffects 类,请在 Linux 平台上的项目根目录(pom.xml 文件所在位置)使用以下命令:

mvn clean package
java --module-path /path/javafx-sdk/lib                 \
     --add-modules=javafx.controls,javafx.fxml          \
     -cp target/examples-1.0-SNAPSHOT.jar:target/libs/* \
      com.packt.learnjava.ch12_gui.HelloWorld
java --module-path /path/javafx-sdk/lib                  \
     --add-modules=javafx.controls,javafx.fxml           \
     -cp target/examples-1.0-SNAPSHOT.jar:target/libs/*  \ 
      com.packt.learnjava.ch12_gui.BlendEffect
java --module-path /path/javafx-sdk/lib                  \
     --add-modules=javafx.controls,javafx.fxml           \
     -cp target/examples-1.0-SNAPSHOT.jar:target/libs/*  \ 
      com.packt.learnjava.ch12_gui.OtherEffects

在 Windows 上,相同的命令如下所示:

mvn clean package
java --module-path C:\path\javafx-sdk\lib                \
     --add-modules=javafx.controls,javafx.fxml           \
     -cp target\examples-1.0-SNAPSHOT.jar;target\libs\*  \
      com.packt.learnjava.ch12_gui.HelloWorld
java --module-path C:\path\javafx-sdk\lib                 \
     --add-modules=javafx.controls,javafx.fxml            \
     -cp target\examples-1.0-SNAPSHOT.jar;target\libs\*   \
      com.packt.learnjava.ch12_gui.BlendEffect
java --module-path C:\path\javafx-sdk\lib                  \
     --add-modules=javafx.controls,javafx.fxml             \
     -cp target\examples-1.0-SNAPSHOT.jar;target\libs\*    \
      com.packt.learnjava.ch12_gui.OtherEffects

HelloWorldBlendEffectOtherEffects 每个类都有两个 start() 方法:start1()start2()。运行该类一次后,将 start() 重命名为 start1(),将 start1() 重命名为 start(),然后再次运行前面的命令。然后,将 start() 重命名为 start2(),将 start2() 重命名为 start(),再次运行前面的命令。依此类推,直到所有 start() 方法都执行完毕。这样你将看到本章中所有示例的结果。

这就完成了 JavaFX 的高级介绍。有了这个,我们将转向最激动人心(对于任何程序员来说)的部分:编写代码。

HelloWorld with JavaFX

下面是显示Hello, World!和“退出”文本的 HelloWorld JavaFX 应用程序:

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 节点,文本“退出”位于绝对位置 155(水平)和 80(垂直)。当点击 Button(当它被点击时)时,分配的动作将打印 Platform.exit() 方法。这两个节点被添加为布局面板的子节点,允许绝对定位。

Stage 对象被分配了标题“主舞台(顶级容器)”。它还被分配了一个在窗口右上角点击关闭窗口符号(Linux 系统的左上角和 Windows 系统的右上角)的动作。

在创建动作时,我们使用了 Lambda 表达式,我们将在第十三章“函数式编程”中讨论。

创建的布局面板被设置在 Scene 对象上。场景大小设置为水平 350 像素和垂直 150 像素。Scene 对象被放置在舞台上。然后,通过调用 show() 方法显示舞台。

如果我们运行前面的应用程序(HellowWorld 类的 start() 方法),将弹出以下窗口:

点击 退出 按钮将显示预期的消息:

但是,如果你需要在 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 包 (openjfx.io/javadoc/11/javafx.controls/javafx/scene/control/package-summary.html)。其中包含超过 80 个类,包括按钮、文本字段、复选框、标签、菜单、进度条和滚动条等。正如我们之前提到的,每个控件元素都是 Node 的子类,它有超过 200 个方法。因此,你可以想象使用 JavaFX 构建的 GUI 是多么丰富和精细。然而,本书的范围仅允许我们涵盖一些元素及其方法。

在上一节中的示例中,我们已经实现了一个按钮。现在,让我们使用一个标签和一个文本字段来创建一个简单的表单,其中包含输入字段(名字,姓氏和年龄)以及在 HelloWorld 类中的 start() 方法(将之前的 start() 方法重命名为 start1(),将 start2() 方法重命名为 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));

如你所猜,文本将用作表单说明。其余部分相当直接,看起来与我们在 HelloWorld 示例中看到的内容非常相似。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 对象),然后获取提交的输入值并直接打印它们。代码确保始终有一些默认值可用于打印,并且输入非数字的 age 值不会破坏应用程序。

在放置了控件和动作之后,我们使用 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 枚举有 LEFTRIGHTCENTER 三个值。addRow(int i, Node... nodes) 方法接受行索引和节点变量的参数。我们使用它来放置 LabelTextField 对象。

start() 方法的其余部分与 HelloWorld 示例非常相似(只有标题和大小有所变化):

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:以系列中的数据点原样展示。有助于识别是否存在聚类(数据相关性)。

以下示例(HellowWorld 类的 start3() 方法)演示了如何将测试结果以饼图的形式展示。每个部分代表测试成功、失败或忽略的数量:

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();

我们创建了两个节点——TextPieChart,并将它们放置在VBox布局的单元格中,使它们按列排列,一个在上一个下面。我们在VBox面板的边缘添加了 10 像素的填充。请注意,VBox 扩展了NodePane类,就像其他面板一样。我们还使用setAlignment()方法将面板定位在场景的中心。其余的与所有其他先前的示例相同,只是场景标题和大小不同。

如果我们运行这个示例(将之前的start()方法重命名为start2(),将start3()方法重命名为start()),结果将如下所示:

图片

PieChart类以及任何其他图表都有几个其他方法,这些方法可以用于以用户友好的方式展示更复杂和动态的数据。

现在,让我们讨论如何通过使用层叠样式表CSS)的力量来丰富您应用程序的外观和感觉。

应用 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;
}

如您所见,我们希望以某种方式样式化具有text-hello ID 的Button节点和Text节点。我们还必须通过向Text元素添加 ID 并将样式表文件添加到场景中(start4()方法)来修改HelloWorld示例:

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();

如果我们运行此代码(将之前的start()方法重命名为start3(),将start4()方法重命名为start()),结果将如下所示:

图片

或者,可以在任何将要使用的节点上设置内联样式,以覆盖文件样式表,无论是默认的还是其他样式。让我们向最新的HelloWorld示例中添加(取消注释)以下行:

btn.setStyle("-fx-text-fill: white; -fx-background-color: red;");

如果我们再次运行这个示例,结果将如下所示:

图片

查阅 JavaFX CSS 参考指南(docs.oracle.com/javafx/2/api/javafx/scene/doc-files/cssref.html),以了解自定义样式的多样性和可能选项。

现在,让我们讨论一种为 FX 应用程序构建用户界面的替代方法,这种方法不需要编写 Java 代码,而是通过使用FX 标记语言FXML)。

使用 FXML

FXML是一种基于 XML 的语言,允许独立于应用程序(业务)逻辑(就外观和感觉而言,或其他与展示相关的更改)构建用户界面和维护它。使用 FXML,您可以在不写一行 Java 代码的情况下设计用户界面。

FXML 没有模式,但其功能反映了用于构建场景的 JavaFX 对象的 API。这意味着您可以使用 API 文档来了解在 FXML 结构中允许哪些标签和属性。大多数时候,JavaFX 类可以用作标签,它们的属性可以用作属性。

除了 FXML 文件(视图)之外,控制器(Java 类)还可以用于处理模型和组织页面流程。模型由视图和控制器管理的域对象组成。它还允许使用 CSS 样式和 JavaScript 的全部功能。但是,在这本书中,我们只能演示基本的 FXML 功能。其余的可以在 FXML 介绍(docs.oracle.com/javafx/2/api/javafx/fxml/doc-files/introduction_to_fxml.html)和许多在线的优秀教程中找到。

为了演示 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 时使用的类名相匹配。我们将前面的 FXML 代码(作为helloWorld.fxml文件)放入resources文件夹。

现在,让我们看看HelloWorld类的start5()方法(将其重命名为start()),它使用helloWorld.fxml文件:

try {
  ClassLoader classLoader =
             Thread.currentThread().getContextClassLoader();
  String file =
        classLoader.getResource("helloWorld.fxml").getFile();
  FXMLLoader lder = new FXMLLoader();
  lder.setLocation(new URL("file:" + file));
  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注解。它使用它们的 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;
    String hello = "Hello, " + fn + " " + ln + ", age " + 
                                                       a + "!";
    System.out.println(hello);
    Platform.exit();
}

对于大部分内容来说,它应该非常熟悉。唯一的区别是我们不是直接(如之前那样)引用字段及其值,而是使用带有@FXML注解的绑定来引用。如果我们现在运行HelloWorld类(别忘了将start5()方法重命名为start()),页面外观和行为将与我们之前在控制元素部分描述的完全相同:

如果点击了右上角的x按钮,屏幕上会显示以下输出:

如果点击提交按钮,输出会显示以下消息:

现在,让我们看看作为单独项目在gui文件夹中实现的具有两个页面的独立 GUI 应用程序:

图片

如您所见,此应用程序由主GuiApp类、两个Controller类、User类和两个页面(.fxml文件)组成。让我们从.fxml文件开始。为了简单起见,page01.fxml文件几乎与上一节中描述的helloWorld.fxml文件内容完全相同。唯一的区别是它引用了Controller01类,该类实现了与之前描述的start5()方法相同的start()方法。主GuiApp类看起来非常简单:

public class GuiApp extends Application {
    public static void main(String... args) {
        launch(args);
    }
    @Override
    public void stop(){
        System.out.println("Doing what has to be done...");
    }
    public void start(Stage primaryStage) {
        Controller01.start(primaryStage);
    }
}

如您所见,它只是调用了Controller01类中的start()方法,该方法反过来显示您熟悉的页面:

图片

在表单填写完毕后,将Controller01类传递给Controller02类,使用Controller01类的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;
        Controller02.goToPage2(new User(a, fn, ln));
        Node source = (Node) e.getSource();
        Stage stage = (Stage) source.getScene().getWindow();
        stage.close();
    }

Controller02.goToPage2()方法看起来如下:

public static void goToPage2(User user) {
  try {
    ClassLoader classLoader =
             Thread.currentThread().getContextClassLoader();
    String file = classLoader.getResource("fxml" + 
                  File.separator + "page02.fxml").getFile();
    FXMLLoader loader = new FXMLLoader();
    loader.setLocation(new URL("file:" + file));
    Scene scene = loader.load();
    Controller02 c = loader.getController();
    String hello = "Hello, " + user.getFirstName() + " " + 
        user.getLastName() + ", age " + user.getAge() + "!";
    c.textHello.setText(hello);
    Stage primaryStage = new Stage();
    primaryStage.setTitle("Second page of GUI App");
    primaryStage.setScene(scene);
    primaryStage.onCloseRequestProperty()
        .setValue(e -> {
                          System.out.println("\nBye!");
                          Platform.exit();
                       });
    primaryStage.show();
  } catch (Exception ex) {
       ex.printStackTrace();
  }
}

第二个页面仅显示接收到的数据。以下是其 FXML 的样式(page2.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.gui.Controller02"
       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="textHello"
              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="textHello")显示从上一页传递的数据。第二个仅显示消息,“在这里完成必须做的事情”。这并不复杂,但它展示了数据流和页面如何组织。

如果我们执行GuiApp类,我们将看到熟悉的表单并可以填写数据:

图片

在我们点击提交按钮后,此窗口将关闭,新的窗口将出现:

图片

现在,我们可以点击左上角(或在 Windows 上的右上角)的x按钮,并看到以下信息:

图片

stop()方法按预期工作。

有了这个,我们就结束了 FXML 的介绍,并转向下一个主题,即向 JavaFX 应用程序添加 HTML。

嵌入 HTML

向 JavaFX 添加 HTML 很容易。您只需使用javafx.scene.web.WebView类即可,该类提供了一个窗口,其中添加的 HTML 以类似于浏览器中的方式渲染。WebView类使用 WebKit,开源浏览器引擎,因此支持完整的浏览功能。

与所有其他 JavaFX 组件一样,WebView类扩展了Node类,可以在 Java 代码中这样处理。此外,它还具有自己的属性和方法,允许通过设置窗口大小(最大、最小和首选高度和宽度)、字体缩放、缩放率、添加 CSS、启用上下文(右键单击)菜单等方式调整浏览器窗口以适应包含的应用程序。getEngine()方法返回一个与它关联的javafx.scene.web.WebEngine对象。它提供了加载 HTML 页面、导航它们、对加载的页面应用不同的样式、访问它们的浏览历史和文档模型以及执行 JavaScript 的能力。

要开始使用javafx.scene.web包,首先需要执行以下两个步骤:

  1. 将以下依赖项添加到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

  1. 由于javafx-web使用已被从 Java 9 中移除的com.sun.*包(docs.oracle.com/javase/9/migrate/toc.htm#JSMIG-GUID-F7696E02-A1FB-4D5A-B1F2-89E7007D4096),要从 Java 9+访问com.sun.*包,除了在Run/Debug Configurationch12_gui包的HtmlWebView类的 JavaFX 基础知识部分描述的--module-path--add-modules之外,还需要设置以下 VM 选项(对于 Windows,将斜杠符号改为反斜杠):

    --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
    
  2. 要从命令行执行HtmlWebView类,请转到examples文件夹,并使用以下命令(不要忘记将/path/JavaFX替换为包含 JavaFX SDK 的实际路径):

    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/examples-1.0-SNAPSHOT.jar com.packt.learnjava.ch12_gui.HtmlWebView
    
  3. 在 Windows 上,相同的命令如下(不要忘记将C:\path\JavaFX替换为包含 JavaFX SDK 的实际路径):

    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=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\examples-1.0-SNAPSHOT.jar com.packt.learnjava.ch12_gui.HtmlWebView
    

HtmlWebView类还包含几个start()方法。按照JavaFX 基础知识部分所述,逐个重命名并执行它们。

现在,让我们看看一些示例。我们创建一个新的应用程序,HtmlWebView,并使用我们描述的 VM 选项(--module-path--add-modules--add-exports)为它设置 VM 选项。现在,我们可以编写和执行使用WebView类的代码。

首先,这是如何在 JavaFX 应用程序中添加简单的 HTML(HtmlWebView类中的start()方法):

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文件中创建。

如果我们运行HtmlWebView类,结果将如下所示:

如果需要,你可以在同一个窗口中显示其他 JavaFX 节点,与WebView对象一起。例如,让我们在嵌入的 HTML 上方添加一个Text节点(HtmlWebView类的start2()方法):

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.html文件,内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>The Form</title>
</head>
<body>
<form action="http://someServer:port/formHandler" method="post">
  <table>
    <tr>
      <td><label for="firstName">First 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 展示了一个与我们在使用 FXML部分创建的表单类似的表单。在\formHandler URI(见<form> HTML 标签)之后。要在 JavaFX 应用程序中展示这个表单,可以使用以下代码:

ClassLoader classLoader =
              Thread.currentThread().getContextClassLoader();
String file = classLoader.getResource("form.html").getFile();
Text txt = new Text("Fill the form and click Submit");
WebView wv = new WebView();
WebEngine we = wv.getEngine();
File f = new File(file);
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,而不必先将内容转换为字符串。如果你运行HtmlWebView类的start3()方法(已重命名为start()),结果如下所示:

当你需要从你的 JavaFX 应用程序发送请求或提交数据时,这个解决方案很有用。但是,当你想要用户填写的表单已经在服务器上可用时,你只需从 URL 加载它。

例如,让我们在 JavaFX 应用程序中集成一个 Google 搜索。我们可以通过更改load()方法的参数值为我们想要加载的页面 URL(HtmlWebView类的start4()方法)来实现:

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 嵌入区域的轮廓。当我们运行这个示例时(别忘了将start4()方法重命名为start()),以下窗口将出现:

在这个窗口中,你可以执行所有通常通过浏览器访问的搜索操作。

此外,正如我们之前提到的,你可以放大渲染的页面。例如,如果我们向前面的示例中添加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();
}

阅读关于 WebViewWebEngine 类的文档,以获取更多关于如何利用它们功能性的想法。

播放媒体

在 JavaFX 应用程序的场景中添加图像不需要 com.sun.* 包,因此 嵌入 HTML 部分中列出的 --add-export 虚拟机选项不是必需的。但是,即使如此也没有坏处,所以如果你已经添加了这些选项,请保留 --add-export 选项。

可以使用 javafx.scene.image.Imagejavafx.scene.image.ImageView 类将图像包含在场景中。为了演示如何做到这一点,我们将使用位于 resources 文件夹中的 Packt 标志,packt.png。以下是实现此功能的代码(HelloWorld 类的 start6() 方法):

ClassLoader classLoader =
             Thread.currentThread().getContextClassLoader();
String file = classLoader.getResource("packt.png").getFile();
Text txt = new Text("What a beautiful image!");
FileInputStream input = new FileInputStream(file);
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。通过查看 ImageImageView 类的 API (openjfx.io/javadoc/11/javafx.graphics/javafx/scene/image/package-summary.html) 来了解图像可以以多种方式格式化和调整。

现在,让我们看看如何在 JavaFX 应用程序中使用其他媒体文件。播放音频或电影文件需要 嵌入 HTML 部分中列出的 --add-export 虚拟机选项。

当前支持的编码如下:

  • AAC: 高级音频编码 音频压缩

  • H.264/AVC: H.264/MPEG-4 Part 10 / AVC (高级视频编码) 视频压缩

  • MP3: 原始的 MPEG-1, 2, 和 2.5 音频;层 I, II, 和 III

  • PCM: 未压缩的原始音频样本

你可以在 API 文档中看到对支持的协议、媒体容器和元数据标签的更详细描述 (openjfx.io/javadoc/11/javafx.media/javafx/scene/media/package-summary.html)。

以下三个类允许构建一个可以添加到场景中的媒体播放器:

javafx.scene.media.Media;
javafx.scene.media.MediaPlayer;
javafx.scene.media.MediaView;

Media 类表示媒体的来源。MediaPlayer 类提供了控制媒体播放的所有方法:play()stop()pause()setVolume() 以及类似的方法。你还可以指定媒体应该播放的次数。MediaView 类扩展了 Node 类,可以添加到场景中。它提供了媒体播放器播放的媒体视图,并负责媒体的外观。

为了演示,让我们运行 HtmlWebView 类的 start5() 方法,它播放位于 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.");
ClassLoader classLoader =
             Thread.currentThread().getContextClassLoader();
String file = classLoader.getResource("jb.mp3").getFile();
File f = new File(file);
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 对象是如何基于源文件构建的。MediaPlayer 对象是基于 Media 对象构建的,然后设置为 MediaView 类构造函数的属性。MediaView 对象与两个 Text 对象一起设置在场景上。我们使用 VBox 对象来提供布局。最后,在场景设置在舞台并舞台变得可见(在 show() 方法完成后)之后,对 MediaPlayer 对象调用 play() 方法。默认情况下,媒体只播放一次。

如果我们执行此代码,以下窗口将出现,并播放 jb.m3 文件:

图片

我们可以添加停止、暂停和调整音量的控件,但这将需要更多的代码,并且会超出本书的范围。您可以在 Oracle 在线文档中找到如何操作的指南(docs.oracle.com/javafx/2/media/jfxpub-media.htm)。

一个 sea.mp4 视频文件可以类似地播放(HtmlWebView 类的 start6() 方法):

Text txt = new Text("What a beautiful movie!");
ClassLoader classLoader =
             Thread.currentThread().getContextClassLoader();
String file = classLoader.getResource("sea.mp4").getFile(); 
File f = new File(file);
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() 和类似方法)来调整嵌入窗口的大小,并自动匹配场景的大小。如果我们执行前面的示例,以下窗口将弹出并播放剪辑:

图片

我们甚至可以将播放音频和视频文件并行进行,从而提供带有音轨的电影(HtmlWebView 类的 start7() 方法):

Text txt1 = new Text("What a beautiful movie and sound!");
Text txt2 = new Text("If you don't hear music, turn up the volume.");
ClassLoader classLoader =
             Thread.currentThread().getContextClassLoader();
String file = classLoader.getResource("jb.mp3").getFile(); 
File fs = new File(file);
Media ms = new Media(fs.toURI().toString());
MediaPlayer mps = new MediaPlayer(ms);
MediaView mvs = new MediaView(mps);
File fv = 
     new File(classLoader.getResource("sea.mp4").getFile());
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 和开发者指南,以下提供了链接:

添加效果

javafx.scene.effects 包包含许多类,允许向节点添加各种效果:

  • Blend: 使用预定义的 BlendModes 之一将两个来源(通常是图像)的像素合并

  • Bloom: 使输入图像更亮,使其看起来像在发光

  • BoxBlur: 为图像添加模糊效果

  • ColorAdjust: 允许调整图像的色调、饱和度、亮度和对比度

  • ColorInput: 渲染一个填充给定油漆的矩形区域

  • 位移图:将每个像素移动指定的距离

  • 阴影:在给定内容后面渲染阴影

  • 高斯模糊:使用特定的(高斯)方法添加模糊

  • 发光:使输入图像看起来像在发光

  • 内部阴影:在框架内部创建阴影

  • 照明:模拟光源照射在内容上,使平面物体看起来更真实

  • 运动模糊:模拟运动中的给定内容

  • 透视变换:以透视方式变换内容

  • 反射:在实际输入内容下方渲染输入的反射版本

  • 棕褐色调:产生棕褐色调效果,类似于古董照片的外观

  • 阴影:创建具有模糊边缘的单色内容副本

所有效果都有一个父类,即Effect抽象类。Node类有setEffect(Effect e)方法,这意味着任何效果都可以添加到任何节点上。这是将效果应用于节点的主要方式——在舞台上产生场景的演员(如果我们回忆起本章开头引入的类比)。

唯一的例外是混合效果,这使得其使用比其他效果更复杂。除了使用setEffect(Effect e)方法外,Node类的一些子类也具有setBlendMode(BlendMode bm)方法,这允许调节图像重叠时如何相互混合。因此,可以以不同的方式设置不同的混合效果,这些效果可以相互覆盖并产生难以调试的意外结果。这就是混合效果使用更复杂的原因,这就是为什么我们将从如何使用混合效果开始概述。

三个方面调节两个图像重叠区域的外观(我们在示例中使用两个图像以使其更简单,但在实践中,许多图像可以重叠):

  • 不透明度属性的价值:这定义了可以通过图像看到多少内容;不透明度值 0.0 表示图像完全透明,而不透明度值 1.0 表示其后面没有任何内容可见。

  • 每个颜色的 alpha 值和强度:这定义了颜色的透明度,作为 0.0-1.0 或 0-255 范围内的双值。

  • 混合模式,由 BlendMode 枚举值定义:根据模式、每个颜色的不透明度和 alpha 值,结果也可能取决于图像被添加到场景中的顺序;首先添加的图像称为底部输入,而重叠图像中的第二个称为顶部输入。如果顶部输入完全不透明,则底部输入被顶部输入隐藏。

重叠区域的结果外观是根据不透明度、颜色的 alpha 值、颜色的数值(强度)和混合模式计算的,混合模式可以是以下之一:

  • ADD: 顶部输入的颜色和 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-export虚拟机选项。只需设置JavaFX 基础知识部分中描述的--module-path--add-modules选项即可用于编译和执行。

本书范围不允许我们展示所有可能的组合,因此我们将创建一个红色圆圈和一个蓝色正方形(见BlendEffect类):

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 文档以获取更多详细信息 (openjfx.io/javadoc/11/javafx.graphics/javafx/scene/paint/Color.html)。

为了重叠创建的圆和正方形,我们将使用 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);

这种区别很重要,因为我们定义圆为半不透明,而正方形是完全不透明的。我们将在所有示例中都使用相同的设置。

让我们比较两种模式,MULTIPLYSRC_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);

在被调用的 BlendEffect 类的 start() 方法中,对于每种模式,我们创建两个组,一个是在圆在正方形顶部时的输入,另一个是在正方形在圆顶部时的输入,我们将四个创建的组放入 GridPane 布局中(详细信息请参阅源代码)。如果我们运行 BlendEffect 应用程序,结果将如下所示:

如预期的那样,当正方形在顶部(右侧的两个图像)时,重叠区域完全由不透明的正方形占据。但是,当圆是顶层输入(左侧的两个图像)时,重叠区域部分可见,并且基于混合效果进行计算。

然而,如果我们直接在组上设置相同的模式,结果会有所不同。让我们运行相同的代码,但将模式设置为组:

Node c = createCircle();
Node s = createSquare();
Node g = new Group(c, s);
g.setBlendMode(BlendMode.MULTIPLY);

start() 方法中定位以下代码:

      Node[] node = setEffectOnGroup(bm1, bm2);
      //Node[] node = setModeOnGroup(bm1, bm2);

然后将其更改为以下内容:

      //Node[] node = setEffectOnGroup(bm1, bm2);
      Node[] node = setModeOnGroup(bm1, bm2);

如果我们再次运行 BlendEffect 类,结果将如下所示:

如您所见,圆的红色略有变化,MULTIPLYSRC_OVER 模式之间没有区别。这正是我们在本节开头提到的添加节点到场景顺序的问题。

结果也会根据效果设置在哪个节点上而改变。例如,不是在组上设置效果,而是只设置在圆上:

Blend blnd = new Blend();
blnd.setMode(BlendMode.MULTIPLY);
Node c = createCircle();
Node s = createSquare();
c.setEffect(blnd);
Node g = new Group(s, c);

start() 方法中定位以下代码:

      Node[] node = setModeOnGroup(bm1, bm2);
      //Node[] node = setEffectOnCircle(bm1, bm2);

然后将其更改为以下内容:

      //Node[] node = setModeOnGroup(bm1, bm2);
      Node[] node = setEffectOnCircle(bm1, bm2);

我们运行应用程序并看到以下内容:

右侧的两个图像与所有之前的示例相同,但左侧的两个图像显示了重叠区域的新颜色。现在,让我们将相同的效果应用于正方形而不是圆,如下所示:

Blend blnd = new Blend();
blnd.setMode(BlendMode.MULTIPLY);
Node c = createCircle();
Node s = createSquare();
s.setEffect(blnd);
Node g = new Group(s, c);

start() 方法中定位以下代码:

      Node[] node = setEffectOnCircle(bm1, bm2);
      //Node[] node = setEffectOnSquare(bm1, bm2);

然后将其更改为以下内容:

      //Node[] node = setEffectOnCircle(bm1, bm2);
      Node[] node = setEffectOnSquare(bm1, bm2); 

结果还会略有变化,如下面的截图所示:

MULTIPLYSRC_OVER模式之间没有区别,但红色与我们在圆形上设置效果时的颜色不同。

我们可以再次改变方法,直接在圆形上设置混合模式,如下所示:

Node c = createCircle();
Node s = createSquare();
c.setBlendMode(BlendMode.MULTIPLY);

start()方法中定位以下代码:

      Node[] node = setEffectOnSquare(bm1, bm2);
      //Node[] node = setModeOnCircle(bm1, bm2);

然后将其更改为以下内容:

      //Node[] node = setEffectOnSquare(bm1, bm2);
      Node[] node = setModeOnCircle(bm1, bm2);

结果再次改变:

仅在正方形上设置混合模式再次消除了MULTIPLYSRC_OVER模式之间的差异。

start()方法中定位以下代码:

      Node[] node = setModeOnCircle(bm1, bm2);
      //Node[] node = setModeOnSquare(bm1, bm2);

然后将其更改为以下内容:

      //Node[] node = setModeOnCircle(bm1, bm2);
      Node[] node = setModeOnSquare(bm1, bm2);

结果如下:

为了避免混淆并使混合结果更可预测,您必须注意节点添加到场景中的顺序以及混合效果应用的一致性。

在本书提供的源代码中,您将找到包含在javafx.scene.effects包中的所有效果的示例。它们都是通过并排比较来演示的。以下是一个示例:

为了您的方便,提供了暂停继续按钮,允许您暂停演示并查看设置在混合效果上的不同透明度值的结果。

为了演示所有其他效果,我们创建了另一个名为OtherEffects的应用程序,它也不需要com.sun.*包,因此不需要--add-export虚拟机选项。演示的效果包括BloomBoxBlurColorAdjustDisplacementMapDropShadowGlowInnerShadowLightingMotionBlurPerspectiveTransformReflectionShadowToneSepiaTone。我们使用了两张图片来展示应用每个效果的结果(Packt 标志和山湖景观):

ClassLoader classLoader = 
              Thread.currentThread().getContextClassLoader(); 
String file = classLoader.getResource("packt.png").getFile(); FileInputStream inputP = new FileInputStream(file);
Image imageP = new Image(inputP);
ImageView ivP = new ImageView(imageP);
String file2 = classLoader.getResource("mount.jpeg").getFile(); FileInputStream inputM = new FileInputStream(file2);
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对象设置在每个图像上,然后暂停 1 秒钟,以便您有机会查看结果:

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();

以下截图展示了 13 个参数值各自的效果示例。在每个截图下方,我们展示了从createEffect(String effect, double d, Text txt)方法中创建此效果的代码片段:

  • 参数值 1 的效果:

//double d = 0.9;
txt.setText(effect + ".threshold: " + d);
Bloom b = new Bloom();
b.setThreshold(d);
  • 参数值 2 的效果:

// 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);
  • 参数值 3 的影响:

图片

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);
  • 参数值 4 的影响:

图片

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);
  • 参数值 5 的影响:

图片

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);
  • 参数值 6 的影响:

图片

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);
  • 参数值 7 的影响:

图片

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);
  • 参数值 8 的影响:

图片

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);
  • 参数值 9 的影响:

图片

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);
  • 参数值 10 的影响:

图片

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);
  • 参数值 11 的影响:

图片

// 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.);
  • 参数值 12 的影响:

图片

// double d = 0.6;
txt.setText(effect + ": " + d);
Reflection ref = new Reflection();
ref.setFraction(d);
  • 参数值 13 的影响:

图片

// double d = 1.0;
txt.setText(effect + ": " + d);
SepiaTone sep = new SepiaTone();
sep.setLevel(d);

本书提供了此演示的完整源代码,并在 GitHub 上可用。

摘要

在本章中,您介绍了 JavaFX 工具包,其主要功能和如何使用它来创建 GUI 应用程序。涵盖的主题包括 Java GUI 技术概述、JavaFX 控件元素、图表、使用 CSS、FXML、嵌入 HTML、播放媒体和添加特效。

现在,您可以使用 Java GUI 技术创建用户界面,以及创建和使用作为独立应用程序的用户界面项目。

下一章专门介绍函数式编程。它概述了 JDK 中的函数式接口,解释了 Lambda 表达式是什么,以及如何在 Lambda 表达式中使用函数式接口。它还解释并演示了如何使用方法引用。

测验

  1. JavaFX 中的顶级内容容器是什么?

  2. JavaFX 中所有场景参与者的基础类是什么?

  3. 命名 JavaFX 应用程序的基本类。

  4. JavaFX 应用程序必须实现的一个方法是什么?

  5. main 方法必须调用哪个 Application 方法来执行 JavaFX 应用程序?

  6. 执行 JavaFX 应用程序需要哪些两个 VM 选项?

  7. 当使用右上角的 x 按钮关闭 JavaFX 应用程序窗口时,会调用哪个 Application 方法?

  8. 嵌入 HTML 需要使用哪个类?

  9. 列出必须用于播放媒体的三种类。

  10. 为了播放媒体,需要添加哪个 VM 选项?

  11. 列出五个 JavaFX 特效。

第三部分:高级 Java

本节将在构建一个示例项目的同时,扩展 Java 编程中的某些高级概念,该示例项目是在上一节结束时创建的。到本节结束时,读者将拥有一个使用本节涵盖的概念构建的独立项目。

本节包含以下章节:

  • 第十三章, 函数式编程

  • 第十四章, Java 标准流

  • 第十五章, 响应式编程

  • 第十六章, Java 微基准测试工具

  • 第十七章, 编写高质量代码的最佳实践

第十三章:函数式编程

本章将带你进入函数式编程的世界。它解释了什么是函数式接口,概述了随 JDK 一起提供的函数式接口,并定义和演示了 Lambda 表达式以及如何使用函数式接口使用它们,包括使用方法引用

本章将涵盖以下主题:

  • 什么是函数式编程?

  • 标准函数式接口

  • 功能型管道

  • Lambda 表达式限制

  • 方法引用

到本章结束时,你将能够编写函数并将它们用于 Lambda 表达式,以便将它们作为方法参数传递。

技术要求

要能够执行本章提供的代码示例,你需要以下内容:

  • 一台装有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高版本

  • 你喜欢的 IDE 或任何代码编辑器

第一章,“开始使用 Java 17”中,提供了如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明。本章的代码示例文件可在 GitHub 上找到,网址为 github.com/PacktPublishing/Learn-Java-17-Programming.git,在 examples/src/main/java/com/packt/learnjava/ch13_functional 文件夹中。

什么是函数式编程?

在我们提供定义之前,让我们回顾一下在前几章中已经使用过的具有函数式编程元素的代码。所有这些示例都给你一个很好的想法,了解一个函数是如何构建并作为参数传递的。

第六章,“数据结构、泛型和常用工具”中,我们讨论了 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 函数接受集合中的一个元素并返回一个布尔值。

  • 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() 方法比较第一个参数和第二个参数。

第十一章网络编程 中,我们查看以下代码:

  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)

最后,在 第十二章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!"));

第一个函数是 EventHandler<ActionEvent>。这个函数会打印一条消息并强制应用程序退出。第二个是 EventHandler<WindowEvent> 函数。它只是打印一条消息。

将函数作为参数传递的能力构成了函数式编程。这在许多编程语言中都有体现,并且不需要管理对象状态。函数是无状态的。它的结果仅取决于输入数据,无论调用多少次。这种编码使得结果更加可预测,这是函数式编程最具吸引力的方面。

从这种设计中获得最大好处的是并行数据处理。函数式编程允许将并行责任从客户端代码转移到库。在此之前,为了处理 Java 集合的元素,客户端代码必须遍历集合并组织处理。在 Java 8 中,添加了新的(默认)方法,这些方法接受一个函数作为参数,然后将其应用于集合的每个元素,是否并行取决于内部处理算法。因此,组织并行处理的责任在于库。

什么是函数式接口?

当我们定义一个函数时,我们提供了一个只包含一个抽象方法的接口实现。这就是 Java 编译器知道将提供的功能放在哪里的方式。编译器查看接口(如前例中的ConsumerPredicateComparatorIntFunctionUnaryOperatorBiFunctionBodyHandlerEventHandler),在那里只看到一个抽象方法,并使用传入的功能作为方法实现。唯一的要求是传入的参数必须与方法签名匹配。否则,将生成编译时错误。

因此,任何只有一个抽象方法的接口都被称为函数式接口。请注意,只有一个抽象方法的要求包括从父接口继承的方法。例如,考虑以下接口:

@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接口继承的同一个方法。C接口是一个函数式接口,因为它只有一个抽象方法method1(),它覆盖了父接口A的抽象方法。D接口不能是一个函数式接口,因为它有两个抽象方法——从父接口A继承的method1()method5()

为了帮助避免运行时错误,Java 8 中引入了@FunctionalInterface注解。它告诉编译器意图,以便编译器可以检查并查看注解接口中是否确实只有一个抽象方法。此注解还警告阅读代码的程序员,这个接口有意只有一个抽象方法。否则,程序员可能会浪费时间向接口添加另一个抽象方法,结果在运行时发现无法实现。

同样的原因,自 Java 早期版本以来就存在的RunnableCallable接口在 Java 8 中被标注为@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 演算——一种通用的计算模型,可以用来模拟任何图灵机。它在 20 世纪 30 年代由数学家 Alonzo Church 提出。Lambda 表达式是一个函数,在 Java 中实现为匿名方法。它还允许省略修饰符、返回类型和参数类型。这使得符号非常紧凑。

Lambda 表达式的语法包括参数列表、箭头符号 (->) 和主体。参数列表可以是空的,例如 (),如果没有括号(如果只有一个参数),或者是一个用括号包围的、以逗号分隔的参数列表。主体可以是一个单独的表达式,或者是一个花括号 {} 内的语句块。让我们看看几个例子:

  • () -> 42; 总是返回 42

  • x -> x*42 + 42;x 的值乘以 42,然后将 42 添加到结果中并返回。

  • (x, y) -> x * y; 将传入的参数相乘并返回结果。

  • s -> "abc".equals(s); 比较 s 变量和字面量 "abc" 的值;它返回一个布尔结果值。

  • 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 参数的局部变量语法

直到 Java 11 的发布,声明参数类型有两种方式——显式和隐式。以下是一个显式版本:

  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

在前面的代码中,编译器从接口定义中推断参数的类型。

在 Java 11 中,引入了另一种使用 var 类型持有器的参数类型声明方法,这与 Java 10 中引入的 var 局部变量类型持有器类似(参见 第一章Java 17 入门)。

以下参数声明在 Java 11 之前的语法上与隐式声明完全相同:

  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>22.0.0</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 参数的局部变量语法的优势就变得明显了。在 Java 11 之前,代码可能看起来像以下这样:

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);

使用 Java 11,新的语法允许我们使用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

通过查看Consumer<T>接口定义,<indexentry content="standard functional interfaces:Consumer">,你可以猜出这个接口有一个接受类型为T的参数且不返回任何内容的抽象方法。嗯,当只列出一个类型时,它可能定义了返回值的类型,例如Supplier<T>接口的情况。但接口名称是一个线索:consumer名称表明这个接口的方法只是接受值并返回空,而supplier返回值。这个线索并不精确,但有助于唤起记忆。

关于任何函数式接口的最佳信息来源是java.util.function包的 API 文档(docs.oracle.com/en/java/javase/12/docs/api/java.base/java/util/function/package-summary.html)。如果我们阅读它,我们会了解到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

这个函数式接口,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): 从这个谓词和提供的谓词构建一个逻辑“或”

  • default Predicate<T> and(Predicate<T> other): 从这个谓词和提供的谓词构建一个逻辑“与”

  • static <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

predicate 对象可以被链入更复杂的逻辑语句,并包含所有必要的外部数据,正如之前所演示的那样。

Supplier

这个函数式接口,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> 函数通常用作数据处理管道的数据入口点。

Function<T, R>

这种和其它返回值的函数接口的表示法包括将返回类型作为泛型列表中的最后一个(在这种情况下是 R)和在其前面(在这种情况下是一个类型为 T 的输入参数)列出输入数据类型。所以,Function<T, R> 表示法意味着该接口的唯一抽象方法接受类型为 T 的参数并产生类型为 R 的结果。让我们看看在线文档(docs.oracle.com/en/java/javase/12/docs/api/java.base/java/util/function/Function.html)。

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.andThen() 行:

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
long r = multiplyByTwo.andThen(subtract7).apply(2.);
System.out.println(r);                       //prints: -3 
multiplyByTwo = Function.identity();
System.out.println(multiplyByTwo.apply(2.)); //prints: 2.0;
r = multiplyByTwo.andThen(subtract7).apply(2.);
System.out.println(r);                      //prints: -5

如您所见,multiplyByTwo() 函数现在不执行任何操作,最终结果也不同。

其他标准函数式接口

java.util.function 包中的其他 39 个函数式接口是我们刚刚审查的四个接口的变体。这些变体是为了实现以下一个或多个目标而创建的:

  • 通过显式使用 intdoublelong 原始类型来避免自动装箱和拆箱,从而提高性能

  • 允许两个输入参数和/或简短的表达方式

这里只是几个例子:

  • IntFunction<R> 具有带有 R apply(int) 方法的 R,提供了一种更简短的表达方式(对于输入参数类型没有泛型),并通过要求原始 int 作为参数来避免自动装箱。

  • BiFunction<T, U, R> 具有带有 R apply(T, U) 方法的 R 类型的参数,允许两个输入参数;BinaryOperator<T> 具有带有 T apply(T, T) 方法的 T 类型的参数,允许两个 T 类型的输入参数,并返回相同类型的值,T

  • IntBinaryOperator 具有带有 int applAsInt(int, int) 方法的 int 类型的参数,并且也返回 int 类型的值。

如果您打算使用函数式接口,我们鼓励您研究 java.util.functional 包的接口 API (docs.oracle.com/en/java/javase/12/docs/api/java.base/java/util/function/package-summary.html).

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 在不同上下文中执行可能存在意外副作用的风险,因此应谨慎使用此解决方案。

anonymous 类内部的 this 关键字指向 anonymous 类的实例。相比之下,在 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

如您所见,anonymous 类内部的 this 关键字指向 anonymous 类实例,而 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 表达式,以便将它们作为方法参数传递。

在下一章中,我们将讨论数据流处理。我们将定义什么是数据流,并探讨如何处理它们的数据以及如何在管道中链式操作流操作。具体来说,我们将讨论流的初始化和操作(方法),如何以流畅的方式连接它们,以及如何创建并行流。

问答

  1. 什么是函数式接口?选择所有适用的:

    1. 函数集合

    2. 只有一个方法的接口

    3. 任何只有一个抽象方法的接口

    4. 任何用 Java 编写的库

  2. 什么是 Lambda 表达式?选择所有适用的:

    1. 一个作为匿名方法实现的函数,没有修饰符、返回类型和参数类型

    2. 函数式接口的实现

    3. 任何以 Lambda 计算风格实现的实现

    4. 一种包含参数列表、箭头符号(->)和由单个语句或语句块组成的主体符号的表示法

  3. Consumer<T>接口的实现有多少个输入参数?

  4. Consumer<T>接口的实现中返回值的类型是什么?

  5. Predicate<T>接口的实现有多少个输入参数?

  6. Predicate<T>接口的实现中返回值的类型是什么?

  7. Supplier<T>接口的实现有多少个输入参数?

  8. Supplier<T>接口的实现中返回值的类型是什么?

  9. Function<T,R>接口的实现有多少个输入参数?

  10. Function<T,R>接口的实现中返回值的类型是什么?

  11. 在 Lambda 表达式中,this关键字指的是什么?

  12. 方法引用语法是什么?

第十四章:Java 标准流

在本章中,我们将讨论数据处理流,这些流与我们在第五章,“字符串、输入/输出和文件”中回顾的 I/O 流不同。我们将定义什么是数据流,如何使用java.util.stream.Stream对象的方法(操作)处理它们的元素,以及如何在管道中链式(连接)流操作。我们还将讨论流的初始化以及如何并行处理流。

本章将涵盖以下主题:

  • 流作为数据和操作源

  • 流初始化

  • 操作(方法)

  • 数字流接口

  • 并行流

  • 创建独立的流处理应用程序

到本章结束时,你将能够编写处理数据流以及创建作为独立项目的流处理应用程序的代码。

技术要求

要能够执行本章提供的代码示例,你需要以下内容:

  • 配有 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高版本

  • 你选择的 IDE 或代码编辑器

第一章,“开始使用 Java 17”中提供了如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明。本章的代码示例文件可在 GitHub 仓库github.com/PacktPublishing/Learn-Java-17-Programming.gitexamples/src/main/java/com/packt/learnjava/ch14_streams文件夹和streams文件夹中找到,其中包含一个独立的流处理应用程序。

流作为数据和操作源

在上一章中描述和演示的 Lambda 表达式,以及函数式接口,为 Java 添加了强大的函数式编程能力。它们允许将行为(函数)作为参数传递给针对数据处理性能优化的库。这样,应用程序程序员可以专注于开发系统的业务方面,将性能方面留给专家——库的作者。这样的库的一个例子是java.util.stream,这将是本章的重点。

第五章字符串、输入/输出和文件中,我们讨论了 I/O 流作为数据源,但除此之外,它们在进一步处理数据方面帮助不大。此外,它们是基于字节或字符的,而不是基于对象的。只有当对象首先以编程方式创建和序列化后,才能创建对象流。I/O 流只是连接到外部资源,主要是文件,没有其他用途。然而,有时可以从 I/O 流过渡到java.util.stream.Stream。例如,BufferedReader类有一个lines()方法,它将底层的基于字符的流转换为Stream<String>对象。

另一方面,java.util.stream包中的流是面向处理对象集合的。在第二章数据结构、泛型和常用工具中,我们描述了Collection接口的两个方法,允许您将集合元素作为流元素读取:default Stream<E> stream()default Stream<E> parallelStream()。我们还提到了java.util.Arraysstream()方法。它有以下八个重载版本,可以将数组或其部分转换为相应数据类型的流:

  • 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接口、IntStreamLongStreamDoubleStream的实现;后三个称为Stream接口,也在数值流中可用。一些数值流有一些额外的方法,如average()sum(),它们是特定的数值。在本章中,我们将主要讨论Stream接口及其方法,但我们所涵盖的一切同样适用于数值流。

一旦之前发射的元素被处理,流就会产生(或发射)流元素。它允许以声明性方式呈现可以应用于发射元素的方法(操作),也可以并行执行。如今,随着大数据集处理机器学习需求变得普遍,这一特性加强了 Java 作为少数现代编程语言之一的选择地位。

说了这么多,我们将从流初始化开始。

流初始化

创建和初始化流有许多方法——Stream类型的对象或任何数值接口。我们根据具有流创建方法的类和接口对它们进行了分组。我们这样做是为了方便您记忆,并在需要时更容易找到它们。

Stream 接口

这个Stream工厂组由属于Stream接口的静态方法组成。

empty()

Stream<T> empty()方法创建一个不发射任何元素的空流:

Stream.empty().forEach(System.out::println);   //prints nothing

forEach() Stream方法与forEach() Collection方法类似,并将传入的函数应用于流中的每个元素:

new ArrayList().forEach(System.out::println);  //prints nothing

结果与从空集合创建流相同:

new ArrayList().stream().forEach(System.out::println);  
                                               //prints nothing

如果没有元素被发射,则不会发生任何操作。我们将在终端操作子节中讨论forEach() Stream方法。

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

我们传递给flatMap()作为参数的函数(e -> e)看起来像是在做无用功,但这是因为流中的每个元素本身就是一个流,所以没有必要对其进行转换。通过将元素作为flatMap()操作的返回值,我们告诉管道将返回值作为Stream对象处理。

ofNullable(T t)

ofNullable(T t)方法返回Stream<T>,如果传入的t参数不是null,则发出单个元素;否则,它返回一个空的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);
}

迭代(对象和一元操作符)

Stream接口的两种静态方法允许您使用类似于传统for循环的迭代过程生成值流,如下所示:

  • Stream<T> iterate(T seed, UnaryOperator<T> func): 这根据第二个参数func函数对第一个seed参数的迭代应用创建了一个无限顺序流,产生一个值流:seedf(seed)f(f(seed)),等等。

  • Stream<T> iterate(T seed, Predicate<T> hasNext, UnaryOperator<T> next): 这根据第三个参数next函数对第一个seed参数的迭代应用创建了一个有限顺序流,产生一个值流:seedf(seed)f(f(seed)),等等,只要第三个参数hasNext函数返回true

以下代码演示了这些方法的用法,如下所示:

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),以避免生成无限数量的生成值。我们将在中间操作子节中更多地讨论此方法。

连接(流 a 和流 b)

Stream<T> concat(Stream<> a, Stream<T> b)的静态方法Stream接口根据传入的参数ab创建一个值流。新创建的流由第一个参数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元素在原始流中都有,因此结果流会发出两次。

生成(供应商)

Stream接口的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接口

Stream.Builder<T> builder()静态方法返回一个内部(位于Stream接口内部)的Builder接口,可以用来构建Stream对象。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

注意我们如何在builder()方法前添加了<String>泛型。这样,我们告诉构建器,我们创建的流将包含String类型的元素。否则,它将添加Object类型的元素,并且无法确保添加的元素是String类型。

当构建器作为Consumer<T>类型的参数传递,或者你不需要链式调用添加元素的方法时,会使用accept(T 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字符的元素。正如预期的那样,创建的流只包含catbear元素。同时,注意我们如何使用<String>泛型来确保所有流元素都是String类型。

其他类和接口

在 Java 8 中,向java.util.Collection接口添加了两个默认方法,如下所示:

  • Stream<E> stream(): 这将返回此集合的元素流。

  • Stream<E> parallelStream(): 这将返回(可能)一个并行流,包含此集合的元素 – 我们说“可能”,因为 JVM 尝试将流分割成几个块,并在多个 CPU 上并行处理(如果有多个 CPU)或使用 CPU 时间共享进行虚拟并行处理。然而,这并不总是可能的,部分取决于请求处理的性质。

这意味着所有扩展此接口的集合接口,包括 SetList,都有这些方法,如本示例所示:

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(): 创建一个无限流,包含 double 值,介于 0(包含)和 1(不包含)

  • IntStream ints()LongStream longs(): 创建一个无限流,包含相应类型的值

  • DoubleStream doubles(long streamSize): 创建一个大小为指定值的 double 值流,介于 0(包含)和 1(不包含)

  • IntStream ints(long streamSize)LongStream longs(long streamSize): 创建一个大小为指定类型值的流

  • IntStream ints(int randomNumberOrigin, int randomNumberBound): 创建一个无限流,包含 int 值,介于 randomNumberOrigin(包含)和 randomNumberBound(不包含)

  • LongStream longs(long randomNumberOrigin, long randomNumberBound): 创建一个无限流,包含 long 值,介于 randomNumberOrigin(包含)和 randomNumberBound(不包含)

  • DoubleStream doubles(long streamSize, double randomNumberOrigin, double randomNumberBound): 创建一个大小为指定值的 double 值流,这些值介于 randomNumberOrigin(包含)和 randomNumberBound(不包含)

以下是一个前面方法的示例:

new Random().ints(5, 8).limit(5) 
            .forEach(System.out::print);    //prints: 56757 

由于使用了 random,每次执行都可能(并且很可能)生成不同的结果。

java.nio.file.Files 类有六个静态方法创建行和路径流,如下所示:

  • Stream<String> lines(Path path): 从提供的路径指定的文件创建行流

  • Stream<String> lines(Path path, Charset cs): 从提供的路径指定的文件创建行流;使用提供的 charset 将文件字节解码为字符

  • 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 开始的文件和目录的文件树流,直到指定的 maxDepth 深度

  • Stream<Path> find(Path start, int maxDepth, BiPredicate<Path, BasicFileAttributes> matcher, FileVisitOption... options): 创建从 Path start 开始的文件和目录(匹配提供的谓词)的文件树流,直到指定的 maxDepth 深度

其他创建流的类和方法包括以下内容:

  • java.util.BitSet 类具有 IntStream stream() 方法,该方法创建一个索引流,对于这个 BitSet,它包含在 set 状态下的位。

  • java.io.BufferedReader 类具有 Stream<String> lines() 方法,该方法从这个 BufferedReader 对象创建一个流,通常是来自文件。

  • java.util.jar.JarFile 类具有 Stream<JarEntry> stream() 方法,该方法创建一个 ZIP 文件条目的流。

  • java.util.regex.Pattern 类具有 Stream<String> splitAsStream(CharSequence input) 方法,该方法从提供的序列中创建一个流,围绕此模式的匹配项。

java.lang.CharSequence 接口有两个方法,如下所示:

  • default IntStream chars(): 创建一个 int 流,将 char 值零扩展

  • default IntStream codePoints(): 从这个序列创建一个代码点值的流

此外,还有一个 java.util.stream.StreamSupport 类,它包含用于库开发者的静态低级实用方法。然而,我们不会对其进行审查,因为这超出了本书的范围。

操作(方法)

Stream 接口中的许多方法,包括那些参数为函数接口类型的方法,被称为 parameter 方法。

例如,让我们看看 Stream<T> filter (Predicate<T> predicate) 方法。它的实现基于对 Predicate<T> 函数的 test(T t) 方法 Boolean 的调用。因此,与其说“我们使用 Stream 对象的 filter() 方法来选择一些流元素并跳过其他元素”,程序员更愿意说“我们应用一个过滤操作,允许一些流元素通过并跳过其他元素”。这描述了操作(操作)的本质,而不是特定的算法,直到方法接收到特定的函数才知道。Stream 接口中有两组操作,如下所示:

  • Stream 对象

  • Stream

流处理通常组织成一个管道,使用流畅(点连接)风格。一个创建 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(): 使用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 的元素,并生成一个发出类型为 R 的元素的 Stream<R> 对象

  • IntStream flatMapToInt(Function<T, IntStream> mapper): 将提供的函数应用于流中每个类型为 T 的元素,并生成一个发出类型为 int 的元素的 IntStream 对象

  • LongStream flatMapToLong(Function<T, LongStream> mapper): 将提供的函数应用于流中每个类型为 T 的元素,并生成一个发出类型为 longLongStream 对象

  • DoubleStream flatMapToDouble(Function<T, DoubleStream> mapper): 将提供的函数应用于流中每个类型为 T 的元素,并生成一个发出类型为 doubleDoubleStream 对象

以下是一些使用这些操作的示例,如下所示:

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类,它可以在不需要为每个collect()操作创建三个函数的情况下生成许多专门的Collector对象。

除了这些,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文件(resources文件夹中):

23 , Ji m
    2 5 , Bob
  15 , Jill
17 , Bi ll

我们在值内部和外部添加了空格,以便利用这个机会向您展示一些简单但非常实用的技巧,用于处理现实生活中的数据。

首先,我们将只读取文件并逐行显示其内容,但只显示包含字母J的行(调整路径值或将其设置为绝对路径,如果代码找不到persons.csv文件):

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()操作的方式——独立处理每个元素。此代码还提供了一个 try-with-resources 结构的示例,该结构会自动关闭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时,返回true

  • boolean anyMatch(Predicate<T> predicate): 当流中的某个元素作为提供的Predicate<T>函数的参数返回true时,返回true

  • boolean 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值,如果流为空则返回一个空的Optional

  • Optional<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"));

为了做到这一点,我们可以创建以下 Comparator<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 是一个方法引用,它表示 i -> new String[i] lambda 表达式,因为 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操作也将流元素的值减少到一个结果。从某种意义上说,所有终端操作都是减少性的。它们在处理许多元素后产生一个值。

因此,你可以将reducecollect视为同义词,它们有助于为Stream接口中许多操作添加结构和分类。此外,reduce组中的操作可以看作是collect操作的专用版本,因为collect()可以根据需要提供与reduce()操作相同的功能。

说了这么多,让我们看看一组reduce操作,如下所示:

  • Optional<T> reduce(BinaryOperator<T> accumulator): 使用提供的关联函数来减少流元素,该函数聚合元素;如果可用,返回包含减少值的Optional

  • T reduce(T identity, BinaryOperator<T> accumulator): 提供与之前reduce()版本相同的功能,但使用身份参数作为累加器的初始值或如果流为空时的默认值

  • 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() 操作的前两种形式中,单位元值被累加器使用。在第三种形式中,identity 值被组合器使用(注意,U 类型是组合器类型)。为了从结果中去除重复的 identity 值,我们决定从组合器的第二个参数中移除它(以及尾随空格):

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

正如你所见,我们将恒等值设置为 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() 操作非常相似:supplieraccumulatorcombiner。最大的区别是 collect() 操作的第一个参数不是恒等函数或初始值,而是一个容器,一个对象,它将在函数之间传递并保持处理状态。

让我们通过从 Person 对象列表中选择最年长的人来演示它是如何工作的。在以下示例中,我们将使用熟悉的 Person 类作为容器,但为其添加一个无参数的构造函数和两个设置器:

public Person(){}
public void setAge(int age) { this.age = age;}
public void setName(String name) { this.name = name; }

添加一个不带参数的构造函数和设置器是必要的,因为Person对象作为一个容器,应该能够在任何时刻不带任何参数地创建,并且能够接收并保持部分结果:迄今为止最年长的人的nameage。在处理每个元素时,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字段被初始化为零的默认值,因此第一个元素的agename被设置为迄今为止最年长的人的参数。当第二个流元素(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对象,并添加指定的prefixsuffix

  • Collector<T,?,Integer> summingInt(ToIntFunction<T>): 创建一个收集器,计算由提供的函数应用于每个元素生成的结果的总和;对于longdouble类型也存在相同的方法

  • Collector<T,?,IntSummaryStatistics> summarizingInt(ToIntFunction<T>): 创建一个收集器,该收集器计算由提供的函数应用于每个元素生成的结果的总和、最小值、最大值、计数和平均值;对于longdouble类型也存在相同的方法

  • 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()方法允许您使用prefixsuffix将分隔列表中的CharacterString值连接起来:

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对象中,以布尔值作为键:

Map<Boolean, List<Person>> map2 = list2.stream()
     .collect(Collectors.partitioningBy(p -> p.getAge() > 27));
System.out.println(map2); //prints: {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 条件,我们能够将所有人分为两组:一组年龄在 27 岁以下或等于 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);  
//prints: {33=[Person{name='Jim', age=33}, Person{name='Bill', //age=33}], 23=[Person{name='Bob', age=23}, Person{name='Jill', //age=23}]} 

为了能够演示此方法,我们通过将每个 Person 对象的 age 设置为 2333 来更改我们的 Person 对象列表。结果是按年龄排序的两个组。

此外,还有重载的 toMap()groupingBy()partitioningBy() 方法以及以下方法,这些方法通常被重载,用于创建相应的 Collector 对象,如下所示:

  • counting()

  • reducing()

  • filtering()

  • toConcurrentMap()

  • collectingAndThen()

  • maxBy(), minBy()

  • mapping(), flatMapping()

  • averagingInt(), averagingLong(), averagingDouble()

  • toUnmodifiableList(), toUnmodifiableMap(), toUnmodifiableSet()

如果您在本书中找不到所需的操作,请在构建自己的 Collector 对象之前,首先在 Collectors API 中进行搜索。

数值流接口

如我们之前提到的,所有三个数值接口 IntStream, LongStream, 和 DoubleStream 都有与 Stream 接口中的方法类似的方法,包括 Stream.Builder 接口中的方法。这意味着我们在这章中讨论的所有内容都同样适用于任何数值流接口。这就是为什么在本节中,我们只会讨论那些不在 Stream 接口中的方法,如下所示:

  • IntStreamLongStream 接口中的 range(lower,upper)rangeClosed(lower,upper) 方法允许您从指定范围内的值创建一个流。

  • boxed()mapToObj() 中间操作将数值流转换为 Stream

  • mapToInt(), mapToLong(), 和 mapToDouble() 中间操作将一种类型的数值流转换为另一种类型的数值流。

  • flatMapToInt(), flatMapToLong(), 和 flatMapToDouble() 中间操作将流转换为数值流。

  • sum()average() 终端操作计算数值流元素的求和和平均值。

创建一个流

除了创建流的 Stream 接口方法之外,IntStreamLongStream 接口还允许您从指定范围内的值创建一个流。

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 接口的中间操作外,IntStreamLongStreamDoubleStream 接口还有特定于数字的中间操作:boxed()mapToObj()mapToInt()mapToLong()mapToDouble()flatMapToInt()flatMapToLong()flatMapToDouble()

boxed() 和 mapToObj()

boxed() 中间操作将(装箱)原始数值类型的元素转换为相应的包装类型:

    //IntStream.range(1, 3).map(Integer::shortValue) 
                                                   //comp 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(): 计算数值流元素的平均值

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 资源并减慢速度,如果不是很完全地关闭应用程序。

这就是为什么程序员不应该轻率地从顺序流切换到并行流。如果涉及到有状态操作,代码必须被设计和测试,以便能够执行并行流处理而不会产生负面影响。

顺序或并行处理?

如我们在上一节中指出的,并行处理可能或可能不会产生更好的性能。在使用并行流之前,你必须测试每一个用例。

然而,在决定使用顺序处理还是并行处理时,你可以考虑以下一些因素:

  • 小型流通常以顺序方式处理得更快(尽管,对于你的环境来说什么是“小”应该通过测试和性能测量来确定)。

  • 如果无法用无状态操作替换有状态操作,请仔细设计你的代码以进行并行处理,或者简单地避免它。

考虑对需要进行大量计算的程序使用并行处理,但也要考虑将部分结果汇总以得到最终结果。查看streams文件夹。它包含一个独立的流处理应用程序。为了模拟数据流,我们创建了一个包含标题和 14 行的input.csv文件,每行代表一个人的数据:名字、姓氏、年龄、街道地址、城市、州和邮政编码。

应用程序将此文件作为行流读取,跳过第一行(标题),并将其余行转换为Person类对象进行处理:

List<Person> getInputPersonList(File file) throws IOException {
  return Files.lines(file.toPath())
              .skip(1)
              .parallel()
              .map(Main::validLine)
              .map(l -> {
                    Person person = 
                       new Person(Integer.parseInt(l.get(2)), 
                                          l.get(0), l.get(1));
                    person.setAddress(l.get(3), l.get(4), 
                        l.get(5), Integer.parseInt(l.get(6)));
                    return person;
              }).toList();
}

由于处理行的顺序不影响结果,我们可以并行处理行流。此外,请注意,如果某行数据不足或某些数据不符合预期的格式,我们将停止处理(通过抛出异常):

List<String> validLine(String line){
   String[] arr = line.split(",");
   if(arr.length != 7){
     throw new RuntimeException(EXPECTED + " 7 column: " + 
                                                         line);
   }
   List<String> values = Arrays.stream(arr)
     .parallel()
     .map(s -> {
          String val = s.trim();
          if(val.isEmpty()){
            throw new RuntimeException(EXPECTED + 
                            " only non-empty values: " + line);
          }
          return val;
   }).toList();
   try {
         Integer.valueOf(values.get(2));
         Integer.valueOf(values.get(6));
   } catch (Exception e) {
     throw new RuntimeException(EXPECTED + 
                       " numbers in columns 3 and 7: " + line);
   }
   if(values.get(6).length() != 5){
     throw new RuntimeException(EXPECTED + 
                           " zip code 5 digits only: " + line);
   }
   return values;
}

然后,我们按照以下方式处理Person类对象的列表:

   Set<String> cities = new HashSet<>();
   Set<String> states = new HashSet<>();
   Set<Integer> zips = new HashSet<>();
   Map<Integer, Integer> oldestByZip = new HashMap<>();
   Map<Integer, String> oldestNameByZip = new HashMap<>();
   URL url = Main.class.getClassLoader().getResource(
                                                  "input.csv");
   File file = new File(url.toURI());
   List<Person> list = getInputPersonList(file);
   list.stream()
       .forEach(p -> {
            cities.add(p.getCity());
            states.add(p.getState());
            zips.add(p.getZip());
            int age = oldestByZip.getOrDefault(p.getZip(), 0);
            if(p.getAge() > age){
              oldestByZip.put(p.getZip(), p.getAge());
              oldestNameByZip.put(p.getZip(), 
                             p.getAge() + ": " + p.getName());
            } else if (p.getAge() == age){
              oldestNameByZip.put(p.getZip(), 
                    oldestNameByZip.get(p.getZip()) + 
                                          ", " + p.getName());
            }
   });

在前面的代码中,我们创建了包含我们稍后打印的结果的SetMap对象,如下所示:

System.out.println("cities: " +
  cities.stream().sorted().collect(Collectors.joining(", ")));
System.out.println("states: " +
  states.stream().sorted().collect(Collectors.joining(", ")));
System.out.println("zips: " + zips.stream().sorted()
                              .map(i -> String.valueOf(i))
                          .collect(Collectors.joining(", ")));
System.out.println("Oldest in each zip: " +
            oldestNameByZip.keySet().stream().sorted()
              .map(i -> i + "=>" + oldestNameByZip.get(i))
                          .collect(Collectors.joining("; ")));

输出结果在下面的屏幕截图中进行展示:

图片

如您所见,它按字母顺序显示了input.csv文件中列出的所有城市、所有州和所有邮政编码,以及每个邮政编码的最年长者。

通过使用for循环而不是此应用程序中的每个流,也可以达到相同的结果,所以使用 Java 标准流更多的是一种风格问题,而不是必要性。我们更喜欢使用流,因为它可以使代码更加紧凑。在第十五章“响应式编程”中,我们将介绍并讨论另一种类型的流(称为响应式流),这种流不能轻易地被for循环所替代。响应式流主要用于异步处理,这将在下一章中探讨。

摘要

在本章中,我们讨论了数据流处理,这与我们在第五章“字符串、输入/输出和文件”中回顾的 I/O 流处理不同。我们定义了数据流是什么,如何使用流操作处理它们的元素,以及如何在管道中链式(连接)流操作。我们还讨论了流初始化以及如何并行处理流。

现在,你已经知道了如何编写处理数据流代码,以及如何创建一个作为独立项目的流处理应用程序。

在下一章中,你将了解到响应式宣言、其目的以及其实施示例。我们将讨论响应式系统和响应式系统之间的区别,以及异步非阻塞处理是什么。我们还将讨论响应式流RxJava

测验

  1. I/O 流和java.util.stream.Stream之间的区别是什么?选择所有适用的:

    1. I/O 流面向数据传输,而Stream面向数据处理。

    2. 一些 I/O 流可以被转换成Stream

    3. I/O 流可以从文件中读取,而Stream不能。

    4. I/O 流可以写入文件,而Stream不能。

  2. empty()of(T... values) Stream方法有什么共同点?

  3. Stream.ofNullable(Set.of(1,2,3 )流发出的元素类型是什么?

  4. 以下代码打印了什么?

    Stream.iterate(1, i -> i + 2)
          .limit(3)
          .forEach(System.out::print);
    
  5. 以下代码打印了什么?

    Stream.concat(Set.of(42).stream(), 
                 List.of(42).stream()).limit(1)
                                 .forEach(System.out::print);
    
  6. 以下代码打印了什么?

    Stream.generate(() -> 42 / 2)
          .limit(2)
          .forEach(System.out::print);
    
  7. Stream.Builder是否是一个函数式接口?

  8. 以下流发出了多少个元素?

    new Random().doubles(42).filter(d -> d >= 1)
    
  9. 以下代码打印了什么?

    Stream.of(1,2,3,4)
            .skip(2)
            .takeWhile(i -> i < 4)
            .forEach(System.out::print);
    
  10. 在以下代码中,d的值是多少?

    double d = Stream.of(1, 2)
                     .mapToDouble(Double::valueOf)
                     .map(e -> e / 2)
                     .sum();
    
  11. 在以下代码中,s字符串的值是多少?

    String s = Stream.of("a","X","42").sorted()
     .collect(Collectors.joining(","));
    
  12. 以下代码的结果是什么?

    List.of(1,2,3).stream()
                  .peek(i -> i > 2 )
                  .forEach(System.out::print);
    
  13. 在以下代码中,peek()操作打印了多少个流元素?

    List.of(1,2,3).stream()
                  .peek(System.out::println)
                  .noneMatch(e -> e == 2);
    
  14. Optional对象为空时,or()方法返回什么?

  15. 在以下代码中,s字符串的值是多少?

    String s = Stream.of("a","X","42")
     .max(Comparator.naturalOrder())
     .orElse("12");
    
  16. IntStream.rangeClosed(42, 42)流发出了多少个元素?

  17. 列出两个无状态操作。

  18. 列出两个有状态操作。

第十五章:反应式编程

在本章中,您将了解反应式宣言和反应式编程的世界。我们首先定义并讨论反应式编程的主要概念——异步、非阻塞和响应性。使用这些概念,我们接下来定义并讨论反应式编程、主要反应式框架,并更详细地讨论RxJava

在本章中,我们将涵盖以下主题:

  • 异步处理

  • 非阻塞 API

  • 反应式——响应性、弹性、可恢复性和消息驱动系统

  • 反应式流

  • RxJava

到本章结束时,您将能够使用反应式编程编写异步处理的代码。

技术要求

要能够执行本章提供的代码示例,您需要以下条件:

  • 配备操作系统的计算机:Microsoft Windows、Apple macOS 或 Linux

  • Java SE 版本 17 或更高

  • 您喜欢的任何 IDE 或代码编辑器

本书第一章“Java 17 入门”中提供了如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明。本章的文件和代码示例可以从 GitHub 仓库github.com/PacktPublishing/Learn-Java-17-Programming.git获取。您可以在examples/src/main/java/com/packt/learnjava/ch15_reactive文件夹中找到它们。

异步处理

异步意味着请求者立即获得响应,但结果尚未出现。相反,请求者等待结果发送给他们、保存到数据库中,或者例如以允许您检查结果是否准备好的对象形式呈现。如果后者是情况,请求者将定期调用该对象上的某个方法,当结果准备好时,使用该对象上的另一个方法检索它。异步处理的优势在于请求者可以在等待时做其他事情。

第八章“多线程与并发处理”中,我们展示了如何创建一个子线程。然后,这个子线程发送一个非异步(阻塞)请求并等待其返回,什么都不做。同时,主线程继续执行并定期调用子线程对象以查看结果是否已准备好。这是异步处理实现中最基本的。实际上,我们在使用并行流时已经使用了它。

在幕后创建子线程的并行流操作将流拆分为段,将每个段分配给一个专用线程进行处理,然后将所有段的局部结果汇总为最终结果。在前一章中,我们甚至编写了执行汇总工作的函数。作为提醒,该函数被命名为combiner

让我们通过一个示例比较顺序流和并行流的性能。

顺序流和并行流

为了演示顺序处理和并行处理之间的差异,让我们想象一个从 10 个物理设备(如传感器)收集数据并计算平均值的系统。以下是一个get()方法,它从由其 ID 标识的传感器收集测量值:

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()操作将 ID 流转换为DoubleStream,以便我们可以应用average()操作。average()操作返回一个Optional<Double>对象,我们调用它的orElse(0)方法,该方法返回计算值或零(例如,如果测量系统无法连接到其任何传感器并返回一个空流)。

getAverage()方法的最后一行打印结果及其计算所需的时间。在实际代码中,我们会返回结果并用于其他计算。然而,出于演示目的,我们只打印它。

现在我们可以比较顺序流处理与并行处理的性能(请参阅MeasuringSystem类和compareSequentialAndParallelProcessing()方法):

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对象获取结果分开。这正是我们在解释异步处理时描述的场景。让我们在代码中演示它(参见MeasuringSystem类和completableFuture()方法):

List<CompletableFuture<Double>> list = ids.stream()
     .map(id -> CompletableFuture.supplyAsync(() ->
  new MeasuringSystem().get(id))).collect(Collectors.toList());

supplyAsync()方法不会等待调用测量系统的返回。相反,它立即创建一个CompletableFuture对象并返回它。这样,客户端可以在稍后任何时候使用此对象来检索测量系统返回的结果。以下代码获取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 毫秒),前提是已经收到了测量值(所有get()方法都被调用并返回了值)。在创建CompletableFuture对象列表并在处理之前,系统不会被阻塞,可以执行其他操作。这正是异步处理的优势。

CompletableFuture类有许多方法,并支持几个其他类和接口。例如,可以添加一个固定大小的线程池来限制线程数量(参见MeasuringSystem类和threadPool()方法):

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 的概念意味着一个高度响应的应用程序。请求的处理(即获取结果)可以是同步的或异步的——对客户端来说无关紧要。然而,在实践中,通常应用程序使用异步处理来提高 API 的吞吐量和性能。

java.nio包术语。非阻塞输入/输出NIO)为密集的输入/输出I/O)操作提供支持。它描述了应用程序的实现方式:它不为每个请求分配一个执行线程,而是提供几个轻量级的工作线程,它们异步和并发地执行处理。

Java.io 包与 Java.nio 包

将数据写入和读取到外部内存(例如,硬盘)的操作比仅内存处理慢得多。最初,java.io包中已经存在的类和接口运行良好,但偶尔会创建性能瓶颈。新的java.nio包被创建以提供更有效的 I/O 支持。

java.io实现基于 I/O 流处理。正如我们在上一节所看到的,本质上,即使幕后发生某种并发,这仍然是一个阻塞操作。为了提高速度,引入了基于内存中缓冲区读写操作的java.nio实现。这种设计允许它将填充/清空缓冲区的缓慢过程与快速读写操作分开。

在某种程度上,这与我们在CompletableFuture使用示例中所做的是相似的。拥有缓冲区中数据的额外优势是,可以检查数据,在缓冲区中来回移动,这在从流中顺序读取时是不可能的。它在数据处理期间提供了更多的灵活性。此外,java.nio实现引入了另一个中间过程,称为通道,用于大量数据在缓冲区之间传输。

读取线程从通道获取数据,并且只接收当前可用的数据或什么也不接收(如果没有数据在通道中)。如果数据不可用,线程不会保持阻塞,而是可以执行其他操作——例如,以与我们的CompletableFuture示例中的主线程相同的方式读取/写入其他通道。

这样,不再为每个 I/O 过程分配一个线程,而是几个工作线程可以服务许多 I/O 过程。这种解决方案最终被称为 NIO,后来应用于其他过程,最突出的是在事件循环中的事件处理,这也可以称为运行循环

事件/运行循环

许多非阻塞系统基于事件(或运行)循环——一个持续执行的线程。它接收事件(请求和消息),然后将它们分发给相应的事件处理器(工作者)。事件处理器并没有什么特别之处。它们只是程序员为处理特定事件类型而指定的方法(函数)。

这种设计被称为反应器设计模式。它是围绕处理事件和服务请求并发构建的。此外,它还为响应式编程响应式系统命名,这些系统对事件做出反应并并发处理它们。

基于事件循环的设计在操作系统和图形用户界面中得到了广泛应用。自 Spring 5 以来,Spring WebFlux 就提供了这种功能,并且可以在 JavaScript 和流行的执行环境 Node.js 中实现。后者使用事件循环作为其处理的核心。工具包 Vert.x 也是围绕事件循环构建的。

在采用事件循环之前,每个传入的请求都会分配一个专用线程——就像我们在流处理演示中所做的那样。每个线程都需要分配一定数量的资源,这些资源不是针对请求的,因此一些资源——主要是内存分配——被浪费了。然后,随着请求数量的增加,CPU 需要更频繁地在线程之间切换上下文,以便更或更少地并发处理所有请求。在负载下,切换上下文的开销足够大,足以影响应用程序的性能。

实现事件循环解决了这两个问题。它通过避免为每个请求创建线程来消除资源浪费,并移除了切换上下文的开销。有了事件循环,每个请求捕获其具体信息所需的内存分配会更小,这使得能够将更多的请求保留在内存中,以便它们可以并发处理。由于上下文大小的减小,CPU 上下文切换的开销也变得非常小。

非阻塞 API 是一种处理请求的方式,使得系统能够在保持高度响应性和弹性的同时处理更大的负载。

响应式

通常,术语java.util.concurrent包。它允许一个Publisher生成一个数据流,Subscriber可以异步订阅。

反应式流与标准流(也称为java.util.stream包)的一个主要区别在于,反应式流中的源(发布者)会以自己的速率将元素推送给订阅者,而在标准流中,只有在处理完前一个元素之后,才会拉取并发射新的元素(实际上,它就像一个for循环)。

正如你所见,我们甚至没有使用这个新 API,也能通过使用CompletableFuture来异步处理数据。但写了几次这样的代码后,你可能会注意到大部分代码只是管道,所以你会觉得肯定有一个更简单、更方便的解决方案。这就是反应式流倡议(www.reactive-streams.org)产生的原因。该努力的范畴如下定义:

反应式流的范畴是找到一组最小的接口、方法和协议,这些将描述必要的操作和实体,以实现目标——具有非阻塞背压的异步数据流。

术语非阻塞背压指的是异步处理中存在的问题之一:协调输入数据的速度与系统处理这些数据的能力,无需停止(阻塞)数据输入。解决方案是通知源,消费者难以跟上输入。此外,处理应该比仅仅阻塞流更灵活地响应输入数据速率的变化,因此得名反应式

已经有几个库实现了反应式流 API:RxJava (reactivex.io)、Reactor (projectreactor.io)、Akka Streams (akka.io/docs)和 Vert.x (vertx.io/)是最著名的。使用 RxJava 或其他异步流库编写代码构成了反应式编程。它通过构建响应式弹性可伸缩消息驱动的系统来实现反应式宣言(www.reactivemanifesto.org)中声明的目标。

响应式

这个术语相对容易理解。及时响应的能力是任何系统的主要品质之一。实现它的方法有很多。即使是传统阻塞 API,只要服务器和其他基础设施足够强大,也能在增长负载下实现良好的响应性。

反应式编程通过使用更少的硬件来帮助实现这一点。这需要我们改变对控制流的传统思考方式。但经过一段时间,这种新的思维方式会像其他任何熟悉的技能一样自然。

在接下来的章节中,我们将看到许多反应式编程的示例。

弹性

失败是不可避免的。硬件崩溃、软件有缺陷、收到意外数据或采取了未经测试的执行路径——任何这些事件,或它们的组合,都可能随时发生。弹性是指系统在意外情况下继续提供预期结果的能力。

例如,可以通过部署组件和硬件的冗余、通过隔离系统的一部分以减少多米诺效应的可能性、通过设计具有自动可替换部件的系统,或者通过发出警报以便合格人员介入来实现。此外,我们已经讨论了分布式系统作为设计良好的弹性系统的好例子。

分布式架构消除了单点故障。此外,将系统分解成许多相互通过消息通信的专用组件,可以更好地调整最关键部分的复制,并创造更多隔离和潜在故障控制的机会。

弹性

通常,能够承受最大可能的负载的能力与可扩展性相关联。但能够在变化负载下保持相同的性能特征,而不仅仅是增长负载下,这种能力被称为弹性

弹性系统的客户端不应在空闲期间和高峰负载期间之间注意到任何差异。非阻塞的反应式实现风格促进了这一品质。此外,将程序分解成更小的部分并将它们转换为可以独立部署和管理的服务,允许对资源分配进行微调。

这样的小型服务被称为微服务,许多微服务组合在一起可以构成一个既可扩展又具有弹性的反应式系统。我们将在接下来的章节和下一章中更详细地讨论这种架构。

消息驱动

我们已经确定,组件隔离和系统分布是帮助保持系统响应性、弹性和弹性的两个方面。松散和灵活的连接也是支持这些品质的重要条件。反应式系统的异步特性也迫使设计者别无选择,只能构建组件之间的通信和消息。

它为每个组件周围创造了呼吸空间,没有这些空间,系统将变成一个紧密耦合的单体,容易受到各种问题的困扰,更不用说维护噩梦了。

在下一章中,我们将探讨一种可以用来构建应用作为松散耦合的微服务集合的架构风格,这些微服务通过消息进行通信。

反应式流

Java 9 中引入的反应式流 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.Subscription 对象作为参数传递给它。现在,订阅者可以在订阅对象上调用 request(long numberOfItems) 来请求从发布者那里获取数据。这就是订阅上的 cancel() 方法的工作方式。

作为回报,发布者可以通过调用订阅者的 onNext() 方法将新项目传递给订阅者。当没有更多数据将到来(即,所有来自源的数据都已发出)时,发布者调用订阅者的 onComplete() 方法。此外,通过调用订阅者的 onError() 方法,发布者可以告诉订阅者它遇到了问题。

Flow.Processor 接口描述了一个可以同时作为订阅者和发布者的实体。它允许你创建这样的处理器的链(或管道),因此订阅者可以从发布者那里接收一个项目,对其进行转换,然后将结果传递给下一个订阅者或处理器。

在推送模型中,发布者可以在没有订阅者请求的情况下调用 onNext()。如果处理速度低于发布项目的速度,订阅者可以使用各种策略来缓解压力。例如,它可以跳过项目或创建一个用于临时存储的缓冲区,希望项目生产速度会减慢,订阅者能够赶上。

这是反应式流倡议定义的接口的最小集合,以支持具有非阻塞背压的异步数据流。正如你所见,它允许订阅者和发布者相互通信并协调数据流入的速度;因此,它使得我们讨论的 Reactive 部分中提到的背压问题有各种解决方案。

实现这些接口的方法有很多。目前,在 JDK 9 中,只有一个接口的实现:SubmissionPublisher 类实现了 Flow.Publisher。这是因为这些接口不应该被应用程序开发者使用。它是一个 服务提供者接口 (SPI),由反应式流库的开发者使用。如果需要,可以使用现有的工具包之一来实现我们之前提到的反应式流 API:RxJava、Reactor、Akka Streams、Vert.x 或任何其他你偏好的库。

RxJava

在我们的示例中,我们将使用 RxJava 2.2.21 (reactivex.io)。它可以通过以下依赖项添加到项目中:

<dependency>
    <groupId>io.reactivex.rxjava2</groupId>
    <artifactId>rxjava</artifactId>
    <version>2.2.21</version>
</dependency>

首先,让我们比较使用 java.util.stream 包和 io.reactivex 包实现的相同功能的两种实现。示例程序将会非常简单:

  • 创建一个整数流:12345

  • 只过滤偶数(即,24)。

  • 计算每个过滤数字的平方根。

  • 计算所有平方根的总和。

这是如何使用java.util.stream包实现的(请参阅ObservableIntro类和squareRootSum()方法):

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)和订阅Observable对象并等待数据发射的Observer

Stream功能相比,Observable具有显著不同的能力。例如,一旦流关闭,就不能重新打开,而Observable对象可以再次使用。以下是一个示例(请参阅ObservableIntro类和reuseObservable()方法):

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()操作来缓存其数据(请参阅ObservableIntro类和cacheObservableData()方法):

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 描述了使用Observable对象可调用的方法。这些方法也被称为操作(如在标准 Java 8 流的情况下)或算子(这个术语主要与响应式流相关联)。我们将将这些三个术语——方法、操作和算子——互换使用,作为同义词。

可观察类型

讨论 RxJava 2 API(请注意,它与 RxJava 1 相当不同),我们将使用在线文档,该文档可以在reactivex.io/RxJava/2.x/javadoc/index.html找到。

观察者订阅以接收来自可观察对象的价值,它可以表现为以下类型之一:

  • 阻塞: 这会等待直到结果返回。

  • 非阻塞: 这以异步方式处理发射的元素。

  • 冷处理: 这会在观察者的请求下发射一个元素。

  • : 无论观察者是否已订阅,都会发射元素。

一个可观察的对象可以是io.reactivex包中以下类之一:

  • Observable<T>:这可以发射零个、一个或多个元素;它不支持背压。

  • Flowable<T>:这可以发射零个、一个或多个元素;它支持背压。

  • Single<T>:这可以发射一个元素或一个错误;不适用背压的概念。

  • Maybe<T>:这表示一个延迟的计算。它可以发出没有值、一个值或一个错误;背压的概念不适用。

  • Completable:这表示一个没有值的延迟计算。这表示任务的完成或错误;背压的概念不适用。

每个这些类的对象都可以作为阻塞、非阻塞、冷或热可观察对象的行为。它们之间的区别在于可以发出的值的数量、延迟返回结果或仅返回任务完成标志的能力,以及处理背压的能力。

阻塞与非阻塞

为了展示这种行为,我们创建了一个可观察对象,该对象发出五个连续的整数,从1开始(参见BlockingOperators类和observableBlocking1()方法):

Observable<Integer> obs = Observable.range(1,5);

Observable的所有阻塞方法(操作符)都以“blocking.”开头。例如,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 毫秒(模拟长时间运行的计算)。这个例子的结果如下:

相同功能的非阻塞版本如下(参见BlockingOperators类和observableBlocking1()方法的第二部分):

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 毫秒的延迟(模拟耗时处理),但没有阻塞操作,所以控制流到下一行,打印列表内容,仍然是空的。

为了防止控制过早地转到这一行,我们可以在它前面设置一个延迟(参见BlockingOperators类和observableBlocking2()方法):

try {
    TimeUnit.MILLISECONDS.sleep(250);
} catch (InterruptedException e) {
    e.printStackTrace();
}
System.out.println(list);   //prints: [2.0]

注意,延迟至少需要 200 毫秒,因为管道处理两个元素,每个元素都有 100 毫秒的延迟。现在你可以看到列表中有一个预期的值2.0

实质上,这就是阻塞和非阻塞操作符之间的区别。其他表示observable的类也有类似的阻塞操作符。以下是一些阻塞的FlowableSingleMaybe的示例(参见BlockingOperators类和flowableBlocking()singleBlocking()maybeBlocking()方法):

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类有阻塞操作符,允许我们设置超时(参见BlockingOperators类和completableBlocking()方法的第二部分):

(1) Completable obs = Completable.fromRunnable(() -> {
         System.out.println("Run");           //prints: Run
         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);

前述代码的结果如下所示:

第一个Run消息来自第 2 行,是对阻塞blockingGet()方法调用的响应。第一个null消息来自第 3 行。第 4 行抛出异常,因为超时设置为 15 毫秒,而实际处理设置为 100 毫秒的延迟。第二个Run消息来自第 5 行,是对blockingGet()方法调用的响应。这次,超时设置为 150 毫秒,这比 100 毫秒多,所以方法能够在超时之前返回。

最后两行,第 7 行和第 8 行,展示了在有无超时的情况下使用blockingAwait()方法的使用方法。此方法不返回任何值,但允许可观察管道运行其流程。有趣的是,即使超时设置得比管道完成所需的时间短,它也不会抛出异常。显然,它会在管道完成处理之后开始等待,除非这是一个将在以后修复的缺陷(关于这一点,文档并不明确)。

尽管存在阻塞操作(我们将在以下各节中讨论每个可观察类型时回顾更多),但它们应该只在使用非阻塞操作无法实现所需功能的情况下使用。响应式编程的主要目标是努力以非阻塞方式异步处理所有请求。

冷与热

到目前为止,我们所看到的所有示例都只展示了冷可观察对象,它只在处理完前一个值后,在处理管道的请求下提供下一个值。这里还有一个示例(参见ColdObservable类和main()方法):

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()方法创建了一个表示每指定间隔(在我们的情况下,每 10 毫秒)发出序列数字的可观察对象。然后,我们订阅创建的对象,等待 25 毫秒,再次订阅,并等待另一个 55 毫秒。pauseMs()方法如下:

void pauseMs(long ms){
    try {
        TimeUnit.MILLISECONDS.sleep(ms);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

如果我们运行前面的示例,输出将类似于以下内容:

图片

如您所见,每个管道都处理了冷可观察对象发出的每个值。

要将可观察对象转换为可观察对象,我们使用publish()方法,该方法将可观察对象转换为扩展Observable对象的ConnectableObservable对象(参见HotObservable类和hot1()方法):

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类是更好的候选者,因为它确实具有处理背压的能力。以下是一个示例(参见HotObservable类和hot2()方法):

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()线程订阅了可观察对象。我们将在多线程(调度器)部分讨论调度器。

subscribe()方法有几个重载版本。我们决定使用接受两个Consumer函数的那个版本:第一个处理传入的值,第二个处理如果管道操作抛出异常的情况(它的工作方式类似于Catch块)。

如果我们运行前面的示例,它将成功打印前 127 个值,然后抛出MissingBackpressureException,如下面的截图所示:

图片

异常中的消息提供了一个线索:“由于请求不足,无法发出值”。显然,发出值的速率高于消费它们的速率,而内部缓冲区只能保持 128 个元素。如果我们添加延迟(以模拟更长的处理时间),结果会更糟(参见HotObservable类和hot3()方法):

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

为了解决这个问题,必须设置一个背压策略。例如,让我们丢弃管道未能处理的每个值(参见HotObservable类和hot4()方法):

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对象,可以查询以检查管道处理是否已完成并已处置(参见DisposableUsage类和disposable1()方法):

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(disposable.isDisposed()); //prints: false
System.out.println(list);                    //prints: []
try {
    TimeUnit.MILLISECONDS.sleep(200);
} catch (InterruptedException e) {
    e.printStackTrace();
}
System.out.println(disposable.isDisposed());  //prints: true
System.out.println(list);                     //prints: [2.0]

此外,还可以强制销毁管道,从而有效地取消处理(参见 DisposableUsage 类和 disposable2() 方法)。

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(disposable.isDisposed()); //prints: false
System.out.println(list);                    //prints: []
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 对象。

当发生 onCompleteonError 事件时,管道会自动销毁。

例如,您可以使用 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 可以是 CallableFuture(所有),IterableArrayPublisherObservableFlowable),ActionRunnableMaybeCompletable);这意味着它基于提供的函数或对象创建一个 Observable 对象。

  • generate(): 这将创建一个基于提供的函数或对象的冷 Observable 对象,生成值(仅限 ObservableFlowable)。

  • range(), rangeLong(), interval(), intervalRange(): 这将创建一个发出连续 intlong 值的 Observable 对象,这些值可能或可能不被指定的范围限制,并且由指定的时间间隔分隔(仅限 ObservableFlowable)。

  • just(): 这将基于提供的对象或一组对象创建一个 Observable 对象(所有,除了 Completable)。

  • timer(): 这将创建一个Observable对象,在指定时间后,发出一个0L信号(所有)然后对ObservableFlowable完成。

此外,还有很多其他有用的方法,例如repeat()startWith()等。我们只是没有足够的空间列出所有这些方法。请参阅在线文档(reactivex.io/RxJava/2.x/javadoc/index.html)。

让我们看看create()方法的使用示例。Observablecreate()方法如下:

public static Observable<T> create(ObservableOnSubscribe<T> source)

传入的对象必须实现ObservableOnSubscribe<T>功能接口,该接口只有一个抽象方法subscribe()

void subscribe(ObservableEmitter<T> emitter)

ObservableEmitter<T>接口包含以下方法:

  • boolean isDisposed(): 如果处理管道被销毁或发射器被终止,则返回true

  • ObservableEmitter<T> serialize(): 这提供了Emitter基类中位于onNext()onError()onComplete()调用中使用的序列化算法。

  • void setCancellable(Cancellable c): 这在此发射器上设置一个Cancellable实现(即只有一个方法cancel()的功能接口)。

  • void setDisposable(Disposable d): 这在此发射器上设置一个Disposable实现(这是一个有两个方法isDispose()dispose()的接口)。

  • boolean tryOnError(Throwable t): 这处理错误条件,尝试发出提供的异常,如果发出不被允许则返回false

要创建一个可观察对象,所有前面的接口都可以按以下方式实现(参见CreateObservable类和main()方法):

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()时发出One,在第二次调用onNext()时发出Two,然后调用onComplete()。我们将source函数传递给create()方法并构建处理所有发出值的管道。

为了让它更有趣,我们添加了filter()运算符,它只允许你使用字符*w*进一步传播值。此外,我们选择了带有三个参数的subscribe()方法版本:Consumer onNextConsumer onErrorAction onComplete函数。第一个在每次下一个值达到方法时被调用,第二个在发出异常时被调用,第三个在源发出onComplete()信号时被调用。在创建管道后,我们暂停了 100 毫秒,以给异步过程一个完成的机会。结果是如下所示:

如果我们从发射器实现中移除emitter.onComplete()这一行,则只会显示消息Two

因此,这些都是create()方法的基本用法。正如你所见,它允许完全自定义。在实践中,它很少被使用,因为创建可观察对象有更简单的方法。我们将在接下来的章节中回顾它们。

此外,你还将看到其他在本章其他部分示例中使用的工厂方法的示例。

操作符

在每个可观察接口中,ObservableFlowableSingleMaybeCompletable,实际上都有数百(如果我们计算所有重载版本)的操作符可用。

ObservableFlowable接口中,方法数量超过 500。这就是为什么在本节中,我们将只提供一个概述和一些示例,帮助你导航可能的选项迷阵。

我们已经将所有操作符分为 10 个类别:转换、过滤、组合、从 XXX 转换、异常处理、生命周期事件处理、实用工具、条件性和布尔值、背压和可连接。

请注意,这些并不是所有可用的操作符。你可以在在线文档中查看更多(reactivex.io/RxJava/2.x/javadoc/index.html)。

转换

以下操作符将转换由可观察对象发出的值:

  • buffer():根据提供的参数或使用提供的函数收集发出的值。它定期逐个发出这些包。

  • flatMap():根据当前可观察对象产生可观察对象,并将它们插入到当前流中;这是最受欢迎的操作符之一。

  • groupBy():将当前的Observable对象划分为可观察对象(GroupedObservables对象)的组。

  • map():使用提供的函数转换发出的值。

  • scan():将提供的函数应用于每个值,并结合前一次应用相同函数到前一个值产生的结果。

  • window():发出与buffer()类似的值组,但作为可观察对象,每个可观察对象都发出原始可观察对象的一个子集的值,然后通过onCompleted()终止。

以下代码演示了map()flatMap()groupBy()的使用(请参阅NonBlockingOperators类和transforming()方法):

Observable<String> obs = Observable.fromArray("one", "two");
obs.map(s -> s.contains("w") ? 1 : 0)
   .forEach(System.out::print);              //prints: 01
System.out.println();
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个值。

以下代码展示了上述操作符的一些用法示例(请参阅NonBlockingOperators类和filtering()方法):

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对象,它发出两个源中任一源发出的值,并结合每个源最近发出的值,使用提供的combiner函数。

  • join(src2, leftWin, rightWin, combiner): 这根据combiner函数,在leftWinrightWin时间窗口内结合两个可观察对象发出的值。

  • merge(): 这将多个可观察对象合并为一个;与concat()不同,它可能会交错它们,而concat()永远不会交错来自不同可观察对象的发出值。

  • startWith(T item): 这在从源可观察对象发出值之前,添加指定的值。

  • startWith(Observable<T> other): 这将在从源可观察对象发出值之前,添加指定可观察对象的值。

  • switchOnNext(Observable<Observable> observables): 这创建一个新的Observable对象,它发出指定可观察对象最近发出的值。

  • zip(): 这使用提供的函数组合指定可观察对象的值。

以下代码演示了这些操作符的一些用法(请参阅NonBlockingOperators类和combined()方法):

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类的 from-XXX 操作符列表:

  • fromArray(T... items): 这从一个可变参数创建一个Observable对象。

  • fromCallable(Callable<T> supplier): 这从一个Callable函数创建一个Observable对象。

  • fromFuture(Future<T> future): 这从一个Future对象创建一个Observable对象。

  • fromFuture(Future<T> future, long timeout, TimeUnit unit): 这将从带有超时参数应用于futureFuture对象创建一个Observable对象。

  • fromFuture(Future<T> future, long timeout, TimeUnit unit, Scheduler scheduler): 这将从带有超时参数应用于future和调度器的Future对象创建一个Observable对象(注意Schedulers.io()被推荐;请参阅多线程(调度器)部分)。

  • fromFuture(Future<T> future, Scheduler scheduler): 从指定调度器上的 Future 对象创建一个 Observable 对象(注意,推荐使用 Schedulers.io();请参阅 多线程(调度器) 部分)。

  • fromIterable(Iterable<T> source): 从可迭代对象(例如,List)创建一个 Observable 对象。

  • fromPublisher(Publisher<T> publisher): 从 Publisher 对象创建一个 Observable 对象。

异常处理

subscribe() 算子有一个重载版本,它接受 Consumer<Throwable> 函数,该函数处理管道中引发的异常。它的工作方式类似于全面的 try-catch 块。如果您将此函数传递给 subscribe() 算子,您可以确信这是所有异常最终结束的唯一地方。

然而,如果您需要在管道中间处理异常,值流可以被恢复并由其余算子处理,即算子抛出异常之后。以下算子(及其多个重载版本)可以帮助做到这一点:

  • onErrorXXX(): 当捕获到异常时恢复提供的序列;XXX 表示算子执行的操作:onErrorResumeNext()onErrorReturn()onErrorReturnItem()

  • retry(): 这将创建一个重复从源发出的发射的 Observable 对象;如果它调用 onError(),则重新订阅源 Observable

演示代码如下(请参阅 NonBlockingOperators 类和 exceptions() 方法):

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 是事件的名称:onCompleteonNextonError 等。并非所有这些类都提供所有这些,其中一些在 ObservableFlowableSingleMaybeCompletable 中略有不同。然而,我们没有空间列出所有这些类的所有变体,因此我们将概述限制在 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): 对每个发出的值和它发出的终端事件通知一个 Observer 对象。

  • doOnComplete(Action onComplete): 在源可观察对象生成onComplete事件后,执行提供的Action函数。

  • doOnDispose(Action onDispose): 在管道被下游处置后,执行提供的Action函数。

  • doOnError(Consumer<Throwable> onError): 当发送onError事件时执行。

  • doOnLifecycle(Consumer<Disposable> onSubscribe, Action onDispose): 为相应的事件调用相应的onSubscribeonDispose函数。

  • doOnTerminate(Action onTerminate): 当源可观察对象生成onComplete事件或抛出异常(onError事件)时,执行提供的Action函数。

  • doAfterTerminate(Action onFinally): 在源可观察对象生成onComplete事件或抛出异常(onError事件)后,执行提供的Action函数。

  • doFinally(Action onFinally): 在源可观察对象生成onComplete事件或抛出异常(onError事件)后,或管道被下游处置后,执行提供的Action函数。

这里是演示代码(见NonBlockingOperators类和events()方法):

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(): 订阅来自可观察对象的发射和通知;各种重载版本接受用于各种事件的回调,包括onCompleteonError;只有在调用subscribe()之后,值才开始通过管道流动。

  • subscribeOn(): 使用指定的Scheduler(线程)异步地将Observer订阅到Observable对象(见多线程(调度器)部分)。

  • timeInterval(), timestamp(): 将发射值的Observable<T>类转换为Observable<Timed<T>>,它反过来发射发射之间的时间间隔或相应的时间戳。

  • timeout(): 重复源Observable的发射;如果在指定时间段内没有发生发射,则生成错误。

  • using(): 这创建一个资源,它将与Observable对象一起自动释放;它的工作方式类似于 try-with-resources 构造。

以下代码包含一些这些操作符在管道中使用示例(请参阅NonBlockingOperators类和utilities()方法):

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);

如果我们运行所有这些示例,输出将如下所示:

图片

如您所见,当完成时,管道将DISPOSED信号发送到using操作符(第三个参数),因此我们作为第三个参数传递的Consumer函数可以释放管道使用的资源。

Conditional and Boolean

以下操作符(及其多个重载版本)允许您评估一个或多个可观察对象或发出的值,并相应地更改处理逻辑:

  • all(Predicate criteria): 这返回带有true值的Single<Boolean>,即如果所有发出的值都符合提供的标准。

  • amb(): 这接受两个或更多源可观察对象,并只从它们中发出开始发出的第一个值。

  • contains(Object value): 这返回带有trueSingle<Boolean>,即如果可观察对象发出提供的值。

  • defaultIfEmpty(T value): 如果源Observable没有发出任何内容,则发出提供值。

  • sequenceEqual(): 这返回带有trueSingle<Boolean>,即如果提供的源发出相同的序列;一个重载版本允许我们提供用于比较的相等函数。

  • skipUntil(Observable other): 这将丢弃发出的值,直到提供的Observable other发出一个值。

  • skipWhile(Predicate condition): 只要提供的条件保持true,就会丢弃发出的值。

  • takeUntil(Observable other): 在提供的Observable other发出值后,将丢弃发出的值。

  • takeWhile(Predicate condition): 在提供的条件变为false后,将丢弃发出的值。

以下代码包含一些演示示例(请参阅NonBlockingOperators类和conditional()方法):

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

Backpressure

因此,我们在冷与热部分讨论并演示了背压效果和可能的丢弃策略。另一种策略可能如下:

Flowable<Double> obs = Flowable.fromArray(1.,2.,3.);
obs.onBackpressureBuffer().subscribe();
//or
obs.onBackpressureLatest().subscribe();

缓冲策略允许您定义缓冲区大小,并提供一个在缓冲区溢出时可以执行的功能。最新的策略告诉值生产者在消费者无法及时处理发出的值时暂停,并在请求时发出下一个值。

注意,背压操作符仅在Flowable类中可用。

Connectable

此类操作符允许我们连接可观察对象,从而实现更精确的订阅动态控制:

  • publish(): 这将Observable对象转换为ConnectableObservable对象。

  • replay(): 这返回一个ConnectableObservable对象,每次新的Observer订阅时都会重复所有发出的值和通知。

  • connect(): 这指示ConnectableObservable对象开始向订阅者发出值。

  • refCount(): 这将ConnectableObservable对象转换为Observable对象。

我们已经在“冷与热”部分展示了ConnectableObservable的工作原理。ConnectableObservableObservable之间的一个主要区别是,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): 这根据提供的执行器(线程池)创建一个调度器,这允许更好地控制最大线程数及其生命周期。

第八章多线程和并发处理中,我们讨论了线程池。为了提醒您,以下是讨论的线程池:

          Executors.newCachedThreadPool();
          Executors.newSingleThreadExecutor();
          Executors.newFixedThreadPool(int nThreads);
          Executors.newScheduledThreadPool(int poolSize);
          Executors.newWorkStealingPool(int parallelism);

如您所见,Schedulers 类的一些其他工厂方法背后是这些线程池之一,它们只是线程池声明的更简单、更简短的表示。为了使示例更简单、更具有可比性,我们只将使用 computation() 调度器。让我们看看 RxJava 中并行/并发处理的基本知识。

以下代码是将 CPU 密集型计算委托给专用线程的示例(参见 Scheduler 类和 parallel1() 方法):

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() 操作的结果序列与单词和字符发出的序列相对应,但在实际情况下,每个值的计算时间可能不同。因此,不能保证结果将以相同的顺序到来。

如果需要,我们还可以将每个单词的发出放在一个专用的非主线程上,这样主线程就可以自由地做其他任何事情。例如,注意以下内容(参见 Scheduler 类和 parallel2() 方法):

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);

本例的输出如下:

图片

如您所见,主线程不再发出单词。

在 RxJava 2.0.5 中,引入了一种新的、更简单的并行处理方式,类似于标准 Java 8 流中的并行处理。使用 ParallelFlowable,可以以以下方式实现相同的功能(参见 Scheduler 类和 parallel3() 方法):

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() 操作符,它在管道中的位置不起任何作用。无论它放在哪里,它仍然告诉观察者哪个调度器应该发出值。以下是一个示例(参见 Scheduler 类和 subscribeOn1() 方法):

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()操作符的位置改变,如以下示例所示,结果也不会改变(参见Scheduler类和subscribeOn2()方法):

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);

最后,这是包含两个操作符的示例(参见Scheduler类和subscribeOnAndObserveOn()方法):

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 是一个庞大且仍在增长的库,具有许多可能性,其中许多我们在这本书中没有空间进行回顾。我们鼓励你尝试并学习它,因为似乎响应式编程是现代数据处理的发展方向。

在以下章节中,我们将演示如何使用 Spring Boot 和 Vert.x 构建响应式应用程序(微服务)。

摘要

在本章中,你学习了响应式编程是什么以及其主要概念:异步、非阻塞、响应式等。以简单术语介绍了响应式流,并解释了 RxJava 库,这是第一个支持响应式编程原则的稳定实现。

现在,你可以使用响应式编程编写异步处理代码。

在下一章中,我们将讨论微服务作为创建响应式系统的基石,并回顾另一个成功支持响应式编程的库:Vert.x。我们将使用它来演示如何构建各种微服务。

测验

  1. 选择所有正确的陈述:

    1. 异步处理总是比阻塞调用提供结果晚。

    2. 异步处理总是能快速提供响应。

    3. 异步处理可以使用并行处理。

    4. 异步处理总是比阻塞调用提供结果更快。

  2. 可以在不使用线程池的情况下使用CompletableFuture吗?

  3. java.nio中的nio代表什么?

  4. event循环是唯一支持非阻塞 API 的设计吗?

  5. RxJava 中的Rx代表什么?

  6. Java 类库(JCL)的哪个 Java 包支持响应式流?

  7. 从以下列表中选择所有可以表示响应式流中可观察对象的类:

    1. Flowable

    2. Probably

    3. CompletableFuture

    4. Single

  8. 你如何知道Observable类的特定方法(操作符)是阻塞的?

  9. 冷和热可观察对象之间的区别是什么?

  10. Observablesubscribe()方法返回一个Disposable对象。当在这个对象上调用dispose()方法时会发生什么?

  11. 选择所有创建Observable对象的方法的名称:

    1. interval()

    2. new()

    3. generate()

    4. defer()

  12. 列出两个转换Observable操作符。

  13. 列出两个过滤Observable操作符。

  14. 列出两种背压处理策略。

  15. 列出两个允许你向管道处理中添加线程的Observable操作符。

第十六章:Java 微基准工具

在本章中,你将了解一个Java 微基准工具JMH)项目,它允许测量各种代码性能特征。如果你的应用程序性能是一个重要问题,这个工具可以帮助你精确地识别瓶颈,甚至到方法级别。

除了理论知识,你还有机会通过实际演示示例和建议来运行 JMH。

本章将涵盖以下主题:

  • 什么是 JMH?

  • 创建 JMH 基准

  • 运行基准测试

  • 使用 IDE 插件

  • JMH 基准参数

  • JMH 使用示例

到本章结束时,你不仅能够测量应用程序的平均执行时间和其他性能值(例如吞吐量),而且还能以受控的方式进行测量——无论是带有还是不带 JVM 优化、预热运行等。

技术要求

为了能够执行本章提供的代码示例,你需要以下内容:

  • 搭载 Microsoft Windows、Apple macOS 或 Linux 操作系统的计算机

  • Java SE 版本 17 或更高版本

  • 你偏好的 IDE 或代码编辑器

如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明已在 第一章Java 17 入门中提供。本章的代码示例文件可在 GitHub 的 github.com/PacktPublishing/Learn-Java-17-Programming.git 仓库的 examples/src/main/java/com/packt/learnjava/ch16_microbenchmark 文件夹中找到。

什么是 JMH?

根据《牛津高阶英汉双解大词典》,基准一个标准或参考点,可以用来比较或评估事物。在编程中,它是比较应用程序性能或方法性能的方式。微基准专注于后者——较小的代码片段而不是整个应用程序。JMH 是一个用于测量单个方法性能的框架。

这可能看起来非常有用。我们能否只是将一个方法循环运行 1,000 或 100,000 次,测量它花费的时间,然后计算方法的平均性能?我们可以。问题是 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 来完成。我们已经在第一章,“Java 17 入门”中讨论过。但还有更简单、更方便的方法来运行基准:通过使用 IDE 插件。

使用 IDE 插件

所有主要的 Java 支持 IDE 都拥有这样的插件。我们将演示如何在 macOS 计算机上安装 IntelliJ 的插件,但它同样适用于 Windows 系统。

这里是遵循的步骤:

  1. 要开始安装插件,按住command键和逗号(,)一起,或者只需点击顶部水平菜单中的扳手符号(悬停文本为首选项图 16.2

  2. 它将打开一个窗口,左侧面板中有以下菜单:

图 16.3

  1. 如前图所示,选择插件,并观察以下顶部水平菜单:

图 16.4

  1. 在市场插件中搜索输入字段中选择JMH,然后按Enter。如果你有互联网连接,它将显示一个JMH 插件符号,类似于以下截图所示图 16.5

  2. 点击安装按钮,然后,当它变为重启 IDE时,再次点击它:

图 16.6

  1. 在 IDE 重启后,插件就准备好使用了。现在你不仅可以运行main()方法,如果你有多个带有@Benchmark注解的方法,你也可以选择并执行其中的基准方法。要做到这一点,从运行下拉菜单中选择运行...

图 16.7

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

图 16.8

  1. 选择你想要运行的,它将被执行。在你至少运行了一个方法之后,你只需右键单击它,然后从弹出菜单中执行它:

图 16.9

  1. 你也可以使用每个菜单项右侧显示的快捷键。

现在,让我们回顾一下可以传递给基准的参数。

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项目中找到(hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-samples/src/main/java/org/openjdk/jmh/samples)。例如,我们没有提到@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 分钟,最终结果摘要如下所示:

图 16.10

现在我们将测试修改如下:

@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(),结果将略有不同:

图 16.11

结果在采样和单次运行方面大多不同。你可以玩转这些方法,并更改分叉和预热的次数。

使用@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.GroupScope.Benchmark,这意味着我们可以向TestState类添加设置器,并在其他测试中读取/修改它。

当我们运行这个版本的测试时,我们得到了以下结果:

图 16.12

数据再次发生了变化。注意,执行的平均时间增加了三倍,这表明没有应用更多的 JVM 优化。

使用 Blackhole 对象

这个 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()方法,从而假装测试方法的结果是已被使用。

当我们运行这个版本的测试时,我们得到了以下结果:

图 16.13

这次,结果看起来并没有太大的不同。显然,在添加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是一个标准的 Java 注解,被各种框架使用,例如 JUnit。它标识了一个参数值的数组。带有@Param注解的测试将根据数组中的值运行多次。每次测试执行都会从数组中选取不同的值。

这里有一个例子:

@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 的作者通过在测试结果中打印以下警告来承认这一事实:

图 16.14

分析器的描述及其使用方法可以在openjdk项目中找到(hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-samples/src/main/java/org/openjdk/jmh/samples)。在相同的样本中,您将遇到 JMH 基于注解生成的代码的描述。

如果您想深入了解代码执行和测试的细节,没有比研究生成的代码更好的方法了。它描述了 JMH 为了运行请求的基准测试所采取的所有步骤和决策。您可以在target/generated-sources/annotations中找到生成的代码。

由于本书的范围不允许过多地详细介绍如何阅读,但它并不困难,尤其是如果您从仅测试一个方法的简单案例开始。我们希望您在这项努力中一切顺利。

摘要

在本章中,您学习了 JMH 工具,并能够将其用于您的应用程序。您学习了如何创建和运行基准测试,如何设置基准测试参数,以及如果需要如何安装 IDE 插件。我们还提供了实际的建议和进一步阅读的参考。

现在您不仅可以测量应用程序的平均执行时间和其他性能值(例如吞吐量),还可以以受控的方式进行测量——无论是带有还是不带 JVM 优化、预热运行等。

在下一章中,您将学习设计和编写应用程序代码的有用实践。我们将讨论 Java 惯用语的实现和使用,并提供实现equals()hashCode()compareTo()clone()方法的建议。我们还将讨论StringBufferStringBuilder类使用上的区别,如何捕获异常,最佳设计实践以及其他经过验证的编程实践。

测验

  1. 选择所有正确的陈述:

    1. 由于 JMH 在非生产环境中运行方法,因此它毫无用处。

    2. JMH 能够绕过一些 JVM 优化。

    3. JMH 不仅可以测量平均性能时间,还可以测量其他性能特征。

    4. JMH 也可以用来测量小型应用程序的性能。

  2. 列出开始使用 JMH 的两个必要步骤。

  3. 列出四种运行 JMH 的方式。

  4. 列出两种可以使用(测量)JMH 的模式(性能特征)。

  5. 列出两种可以用来表示 JMH 测试结果的时间单位。

  6. 如何在 JMH 基准测试之间共享数据(结果、状态)?

  7. 如何告诉 JMH 为具有枚举值列表的参数运行基准测试?

  8. 如何强制或关闭方法的编译?

  9. 如何关闭 JVM 的常量折叠优化?

  10. 如何以编程方式提供 Java 命令选项以运行特定的基准测试?

第十七章:编写高质量代码的最佳实践

当程序员之间交流时,他们经常使用非程序员无法理解或不同编程语言程序员模糊理解的术语。但使用相同编程语言的人可以很好地理解彼此。有时,这也可能取决于程序员的了解程度。新手可能无法理解经验丰富的程序员在谈论什么,而经验丰富的同事则会点头并相应地回应。本章旨在填补这一差距,提高不同水平程序员之间的理解。在本章中,我们将讨论一些 Java 编程术语——描述某些特性、功能、设计解决方案等的 Java 习惯用法。你还将了解设计和编写应用程序代码最流行和最有用的实践。

本章将涵盖以下主题:

  • Java 习惯用法、它们的实现和它们的用法

  • equals(), hashCode(), compareTo(), 和 clone() 方法

  • StringBufferStringBuilder

  • trycatchfinally 子句

  • 最佳设计实践

  • 编写的代码是为了给人看

  • 使用经过良好建立的框架和库

  • 测试是通往高质量代码的最短路径

到本章结束时,你将深刻理解其他 Java 程序员在讨论他们的设计决策和使用的功能时所谈论的内容。

技术要求

要执行本章提供的代码示例,你需要以下内容:

  • 拥有 Microsoft Windows、Apple macOS 或 Linux 的计算机

  • Java SE 版本 17 或更高版本

  • 你选择的 IDE 或代码编辑器

如何设置 Java SE 和 IntelliJ IDEA 编辑器的说明已在 第一章**, 开始使用 Java 17 中提供。本章的代码示例文件可在 GitHub 上找到,网址为 github.com/PacktPublishing/Learn-Java-17-Programming.git,位于 examples/src/main/java/com/packt/learnjava/ch17_bestpractices 文件夹以及 springreactive 文件夹中。

Java 习惯用法、它们的实现和它们的用法

除了作为专业人士之间交流的手段外,编程习惯用法也是经过证明的编程解决方案和常见实践,这些解决方案并非直接来自语言规范,而是源于编程经验。在本节中,我们将讨论最常用的那些。你可以在官方 Java 文档中找到并研究完整的习惯用法列表(docs.oracle.com/javase/tutorial)。

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

您的系统中的输出可能会有所不同。

person1person2引用及其哈希码相等,因为它们指向同一个对象(相同的内存区域和相同的地址),而person3引用指向另一个对象。

然而,在实践中,正如我们在第六章数据结构、泛型和常用工具中所描述的,我们希望对象的相等性基于对象的所有属性或某些属性值。因此,下面是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

如您所见,我们所做的更改不仅使相同的对象相等,而且使具有相同属性值的两个不同对象也相等。此外,哈希码值现在也基于相同的属性值。

第六章数据结构、泛型和常用工具中,我们解释了为什么在实现equals()方法的同时实现hashCode()方法很重要。

equals()方法中建立相等性和在hashCode()方法中进行哈希计算时必须使用相同的一组属性。

在这些方法前加上@Override注解可以确保它们覆盖了Object类中的默认实现。否则,方法名称中的错误可能会造成一种假象,即新实现正在被使用,而实际上并没有。调试此类情况比仅仅添加@Override注解要困难得多,而且成本也更高,因为如果方法没有覆盖任何内容,@Override注解会生成一个错误。

compareTo()方法

第六章数据结构、泛型和常用工具中,我们广泛使用了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() 对象中。我们这样做是因为,如我们已在 第六章 中提到的,数据结构、泛型和常用工具,由 of() 工厂方法创建的集合是不可修改的。无法向其中添加或删除元素,也无法更改元素的顺序,而我们需要对创建的集合进行排序。我们只使用 of() 方法,因为它更方便,提供了更短的表示法。

  • 最后,使用 java.util.Objects 来比较属性,使得实现比自定义编码更容易、更可靠。

在实现 compareTo() 方法时,重要的是要确保以下规则不被违反:

  • obj1.compareTo(obj2) 返回与 obj2.compareTo(obj1) 相同的值,但只有当返回值是 0 时。

  • 如果返回值不是 0,则 obj1.compareTo(obj2) 的符号与 obj2.compareTo(obj1) 相反。

  • 如果 obj1.compareTo(obj2) > 0obj2.compareTo(obj3) > 0,那么 obj1.compareTo(obj3) > 0

  • 如果 obj1.compareTo(obj2) < 0obj2.compareTo(obj3) < 0,那么 obj1.compareTo(obj3) < 0

  • 如果 obj1.compareTo(obj2) == 0,那么 obj2.compareTo(obj3)obj1.compareTo(obj3) 具有相同的符号。

  • 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属性,其类型为AddressPerson对象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属性。以下是使用这种clone()方法实现的示例:

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()方法并不常用。您可能永远不会遇到需要使用它的需求。

StringBufferStringBuilder

我们在第六章,“数据结构、泛型和常用工具”中讨论了StringBufferStringBuilder类的区别。我们在这里不再重复。相反,我们只想提到,在单线程过程中(这是大多数情况),StringBuilder类是首选,因为它更快。

try、catch 和 finally 子句

第四章,“异常处理”专门讨论了使用trycatchfinally子句,所以我们在这里不再重复。我们想重申,使用try-with-resources语句是释放资源(传统上在finally块中完成)的首选方式。推迟库使代码更简单、更可靠。

最佳设计实践

术语最佳通常是主观的和情境依赖的。这就是为什么我们想透露,以下建议是基于主流编程中绝大多数情况。然而,它们不应该盲目和无条件地遵循,因为有些情况下,这些实践中的某些,在某些情境下,可能是无用的,甚至是错误的。在遵循它们之前,试着理解它们背后的动机,并将其用作你决策的指南。例如,大小很重要。如果应用程序不会增长到几千行代码,一个简单的单体,具有清单式代码就足够了。但如果存在复杂的代码块,并且有几个人在处理它,将代码分解成专门的片段将有利于代码理解、维护,甚至如果某个特定的代码区域需要比其他区域更多的资源,还有助于扩展。

我们将无特定顺序地从更高层次的设计决策开始。

识别松散耦合的功能区域

这些设计决策可以在非常早期就做出,仅基于对未来系统主要部分的总体理解,它们的功能,以及它们产生和交换的数据。这样做有几个好处:

  • 你可以识别未来系统的结构,这对后续的设计步骤和实施有影响

  • 你可以专门分析和深入研究部分

  • 你可以并行开发部分

  • 你可以更好地理解数据流

将功能区域分解为传统的层级

在每个功能区域就位后,可以根据技术方面和技术使用专门化。传统的技术专门化分离如下:

  • 前端(用户图形或网络界面)

  • 具有广泛业务逻辑的中层

  • 后端(数据存储或数据源)

做这件事的好处包括以下内容:

  • 你可以按层级部署和扩展

  • 你可以根据你的专业知识获得程序员的专业化

  • 你可以并行开发部分

面向接口编码

根据前两个小节中描述的决策,必须在一个隐藏实现细节的接口中描述专门的部分。这种设计的优势在于面向对象编程OOP)的基础,这在第二章《Java 面向对象编程(OOP)》中已经详细描述,所以我们在这里不再重复。

使用工厂

我们在第二章《Java 面向对象编程(OOP)》中也讨论了这一点。根据定义,接口不能也不能描述实现该接口的类的构造函数。使用工厂允许你关闭这个差距,并向客户端暴露一个接口。

优先使用组合而非继承

最初,OOP 侧重于继承作为在对象之间共享公共功能的方法。继承甚至是我们描述在第二章《Java 面向对象编程(OOP)》中的四个 OOP 原则之一。然而,在实践中,这种功能共享方法在同一个继承线上的类之间产生了过多的依赖。应用程序功能的演变往往是不可预测的,继承链中的某些类开始获得与类链原始目的无关的功能。我们可以争论,有一些设计解决方案可以让我们不做这样的事情,并保持原始类不变。但是,在实践中,这样的事情经常发生,子类可能会突然改变行为,仅仅是因为它们通过继承获得了新的功能。我们无法选择我们的父母,对吧?此外,这样会破坏封装,这是 OOP 的另一个基础原则。

另一方面,组合允许我们选择和控制使用类的哪些功能以及忽略哪些功能。它还允许对象保持轻量级,不被继承所负担。这种设计更加灵活、可扩展和可预测。

使用库

在整本书中,我们提到使用Java 类库JCL)和外部(相对于Java 开发工具包JDK))的 Java 库可以使编程更加容易,并产生更高质量的代码。第七章《Java 标准库和外部库》概述了最流行的 Java 库。创建库的人投入了大量的时间和精力,因此你应该在可能的情况下利用它们。

第十三章《函数式编程》中,我们描述了位于 JCL 的java.util.function包中的标准函数式接口。这是利用库的另一种方式——通过使用其一系列已知和共享的接口,而不是定义自己的接口。

这最后一点很好地过渡到下一个关于编写其他人容易理解的代码的主题。

代码是为人类编写的

编程的前几十年需要编写机器命令,以便电子设备能够执行它们。这不仅是一项繁琐且容易出错的任务,而且还需要你以产生最佳性能的方式编写指令。这是因为计算机速度慢,几乎没有进行代码优化。

从那时起,我们在硬件和编程方面都取得了很大的进步。现代编译器在很大程度上使提交的代码尽可能快地运行,即使程序员没有考虑这一点。我们在上一章中通过具体例子讨论了这一点。

这使得程序员可以写出更多的代码行,而不必过多考虑优化。但传统和许多关于编程的书籍仍然要求这样做,一些程序员仍然担心他们的代码性能——甚至比结果更重要。遵循传统比打破传统更容易。这就是为什么程序员往往更关注他们编写代码的方式,而不是他们自动化的业务,尽管实现错误业务逻辑的好代码是无用的。

然而,回到主题。随着现代 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.

注释的代码可能非常复杂。好的注释能够解释意图并提供帮助,使我们理解代码。然而,程序员往往懒得写注释。反对写注释的论点通常包括以下两点:

  • 注释必须与代码一起维护和更新;否则,它们可能会产生误导。然而,没有任何工具能够提示程序员在更改代码的同时调整注释。因此,注释是有风险的。

  • 代码本身必须编写(包括变量和方法名称的选择)以便不需要额外的解释。

这两个陈述都是正确的,但事实也是,注释可以非常有帮助,尤其是那些捕捉意图的注释。此外,这样的注释通常需要更少的调整,因为代码意图不经常改变,甚至从不改变。

使用成熟的框架和库

程序员并不总是有机会选择框架和库来开发软件。通常,公司更愿意继续使用他们已经用于其他项目的软件和开发工具集。但是,当您有机会选择时,可用的产品种类可能会让人感到不知所措。选择编程社区中最新流行的产品也可能很有吸引力。然而,经验一次又一次地证明,最佳的行动方案是选择一些成熟且经过证明的生产级产品。此外,使用历史悠久且稳固的软件通常需要编写更少的样板代码。

为了说明这一点,我们创建了两个项目:

  • 使用 Spring Boot 框架

  • 使用 Vert.x 工具包

我们从 Spring Boot 开始。它是一个开源的基于 Java 的框架,由 Pivotal 团队开发,用于构建独立的生产级应用程序。默认情况下,它不需要外部 web 服务器,因为它内置了一个 web 服务器(Tomcat 或 Netty)。因此,Spring Boot 用户不需要编写任何非业务代码。您甚至不需要创建配置,就像在 Spring 中那样。您只需定义您需要的非业务功能(例如健康检查、指标或 Swagger 文档等)使用属性文件,并通过注解进行调整。

自然地,因为幕后有如此多的实现,Spring Boot 非常具有意见。但您很难找到一个不能用来生成稳固高效应用程序的案例。最有可能的是,Spring Boot 的限制将在大型项目中显现出来。使用 Spring Boot 的最佳方法就是接受它的做事方式,因为这样做您可以节省大量时间,并获得一个健壮且经过优化的解决方案。

为了简化依赖管理,Spring Boot 提供了所谓的 starter JAR 文件中每个类型应用程序所需的第三方依赖项。例如,spring-boot-starter-web 将所有必要的库引入到项目中,用于 Spring MVC(模型-视图-控制器)和 Tomcat web 服务器。基于所选的 starter 包,Spring Boot 自动配置应用程序。

您可以在 spring.io/projects/spring-boot 找到针对所有级别程序员的全面且编写良好的信息——从初学者到经验丰富的专业人士。如果您计划在工作中使用 Spring Boot,我们鼓励您阅读它。

为了展示 Spring Boot 的功能和优势,我们在 spring 文件夹中创建了一个项目。要运行此示例应用程序,您需要运行此书第 10 章,在数据库中管理数据 中创建的数据库。该示例应用程序管理(创建、读取、更新、删除)数据库中的人员记录。此功能可以通过用户界面(HTML 页面)访问,面向人类。此外,我们还实现了通过 RESTful 服务访问相同功能,这些服务可以被其他应用程序使用。

您可以通过执行 Application 类从 IDE 运行应用程序。或者,您可以从命令行启动应用程序。在 spring 文件夹中有两个命令文件:mvnw(用于 Unix/Linux/Mac 系统)和 mvnw.cmd(用于 Windows)。它们可以用来启动应用程序,如下所示:

  • 对于 Unix/Linux/Mac 系统:

    ./mvnw spring-boot:run 
    
  • 对于 Windows:

    .\mvnw.cmd spring-boot:run 
    

当您第一次这样做时,可能会出错:

java.lang.ClassNotFoundException: org.apache.maven.wrapper.MavenWrapperMain 

如果发生这种情况,请通过执行以下命令安装 Maven 包装器:

mvn -N io.takari:maven:wrapper 

或者,您也可以构建可执行的 .jar 文件:

  • 对于 Unix/Linux/Mac 系统:

    ./mvnw clean package 
    
  • 对于 Windows:

    .\mvnw.cmd clean package 
    

然后,您可以将创建的 .jar 文件放在任何已安装 Java 17 的计算机上并运行它,使用以下命令:

java -jar target/spring-0.0.1-SNAPSHOT.jar 

应用程序运行后,执行以下命令:

curl –XPOST localhost:8083/ws/new                  \ 
       -H 'Content-Type: application/json'         \ 
       -d '{"dob":"2002-08-14",                    \ 
            "firstName":"Mike","lastName":"Brown"}' 

curl 命令需要应用程序创建一个新的个人记录。预期的响应如下(每次运行此命令时 id 值都会不同):

  Person{id=42, dob=2002-08-14, firstName='Mike', 
                        lastName='Brown'} successfully updated. 

要查看响应中的 HTTP 状态码,请将选项 -v 添加到命令中。HTTP 状态码 200 表示请求处理成功。

现在让我们执行 update 命令:

curl –XPUT localhost:8083/ws/update              \ 
   -H 'Content-Type: application/json'           \ 
   -d '{"id":42,"dob":"2002-08-14",              \ 
            "firstName":"Nick","lastName":"Brown"}' 

应用程序会以以下方式响应此命令:

  Person{id=42, dob=2002-08-14, firstName='Nick', 
                        lastName='Brown'} successfully updated. 

注意,不是所有字段都必须在有效负载中填充。只需要 id 值,并且必须与现有记录之一匹配。应用程序通过提供的 id 值检索当前的 Person 记录,并仅更新提供的属性。

delete 端点构建方式类似。区别在于数据(Person 记录身份号码 id)作为 URL 的一部分传递。现在让我们执行以下命令:


curl localhost:8083/ws/delete/1 

应用程序会以以下消息响应此命令:

  Person{id=42, dob=2002-08-14, firstName='Nick', 
                        lastName='Brown'} successfully deleted. 

可以使用以下命令检索所有现有记录的列表:

curl localhost:8083/ws/list 

所有的先前功能都可以通过用户界面访问。在浏览器中输入 URL http://localhost:8083/ui/list 并点击相应的链接。

您也可以在浏览器 URL 中输入 http://localhost:8083 并访问以下页面:

然后,再次点击任何可用的链接。Home 页面展示了当前应用程序版本及其健康状况。http://localhost:8083/swagger-ui.html URL 会显示所有应用程序端点列表。

我们强烈建议你研究应用程序代码并阅读spring.io/projects/spring-boot网站上的 Spring Boot 文档。

现在,让我们查看reactive文件夹中的项目。它展示了使用 Vert.x 的响应式通信方法,Vert.x 是一个事件驱动的非阻塞轻量级多语言工具包。它允许你使用 Java、JavaScript、Groovy、Ruby、Scala、Kotlin 和 Ceylon 编写组件。它支持异步编程模型和分布式事件总线,该总线延伸到 JavaScript 浏览器,从而允许创建实时 Web 应用程序。然而,由于本书的焦点,我们将只使用 Java。

为了展示如果使用Vert.x 工具包实现微服务响应式系统可能看起来是什么样子,我们创建了一个可以接受基于 REST 的请求到系统的 HTTP 服务器,向另一个verticleVert.x 工具包中的基本处理单元,可以被部署)发送基于 EventBus 的消息,接收回复,并将响应发送回原始请求。

我们创建了两个 verticles:

  • HttpServerVert,它作为一个服务器并接收 HTTP 消息,然后将这些消息通过 EventBus(一个轻量级的分布式消息系统)发送到特定的地址

  • MessageRcvVert,它监听特定的事件总线地址上的消息

现在,我们可以按照以下方式部署它们:

public class ReactiveSystemDemo { 
   public static void main(String... args) { 
     Vertx vertx = Vertx.vertx(); 
     RxHelper.deployVerticle(vertx,  
                    new MessageRcvVert("1", "One")); 
     RxHelper.deployVerticle(vertx,  
                    new MessageRcvVert("2", "One")); 
     RxHelper.deployVerticle(vertx,  
                    new MessageRcvVert("3", "Two")); 
     RxHelper.deployVerticle(vertx, 
                    new HttpServerVert(8082)); 
   } 
 } 

要执行此代码,运行ReactiveSystemDemo类。预期的结果是如下所示:

图片

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

图片

如果有多个 verticles 注册了相同的地址(正如我们的情况:我们注册了两个具有相同One地址的 verticles),系统将使用轮询算法选择应该接收下一个消息的 verticle。

第一次请求发送到了ID="1"的接收者,第二次请求发送到了ID="2"的接收者,第三次请求又发送到了ID="1"的接收者。

使用 HTTP POST请求对/some/path/send路径,我们得到相同的结果:

图片

再次,消息的接收者使用轮询算法进行轮换。现在,让我们向我们的系统发布两次消息。

由于接收者的回复不能传播回系统用户,我们需要查看后端记录的消息:

图片

正如你所见,publish()方法将消息发送到注册到指定地址的所有 verticles。请注意,具有ID="3"(在Two地址上注册)的 verticle 从未收到任何消息。

在我们结束这个反应式系统演示之前,值得提一下,Vert.x工具包允许你轻松地集群 verticles。你可以在vertx.io/docs/vertx-core/javaVert.x文档中了解更多关于这个功能的信息。

这两个例子展示了如果你使用一个成熟的框架,创建一个完整的网络应用所需的代码量是多么少。这并不意味着你不能探索最新和最好的框架。无论如何,你应该这样做,以跟上你所在行业的进步。只需记住,一个新产品要成熟并变得足够可靠和有用,以创建一个强大的生产级软件解决方案,需要一些时间。

测试是通往高质量代码的捷径

我们将要讨论的最后一条最佳实践是这一声明:测试不是额外的负担;它是程序员成功的指南。唯一的问题是何时编写测试。

有一个强有力的论点要求在编写任何代码行之前编写测试。如果你能这样做,那很好。我们不会试图说服你放弃。但如果你不这样做,在你编写了一行或所有分配给你的代码之后,尝试开始编写测试。

在实践中,许多经验丰富的程序员发现,在实现一些新功能之后开始编写测试代码是有帮助的。这是因为这时程序员能更好地理解新代码如何融入现有环境。他们甚至可能尝试硬编码一些值,以查看新代码与调用新方法的代码的集成情况。在确保新代码良好集成后,程序员可以继续实现和调整它,同时测试新实现与调用代码上下文中的要求。

必须添加一个重要条件——在编写测试时,最好由分配任务给你的人或测试员来设置输入数据和测试标准。根据代码产生的结果来设置测试是一个众所周知的程序员陷阱。客观的自我评估并不容易,如果可能的话。

摘要

在本章中,我们讨论了主流程序员在日常工作中遇到的 Java 惯用法。我们还讨论了最佳设计实践和相关建议,包括代码编写风格和测试。

你还了解了一些与特定功能、功能性和设计解决方案相关的最流行的 Java 惯用法。这些惯用法通过实际示例进行了演示,你学习了如何将它们融入你的代码和与程序员交流的专业语言。

本章总结了关于 Java 17 及其在编写有效应用程序代码中使用的书籍。如果你已经阅读了全部内容,那么你应该对这个主题有一个很好的概述,并获得了可以立即应用于专业领域的宝贵编程知识和技能。如果你觉得这些材料很有价值,那么让我们知道我们已经实现了我们的目标。感谢阅读。

小测验

回答以下问题以测试你对本章知识的了解:

  1. 选择所有正确的陈述:

    1. 习语可以用来传达代码的意图。

    2. 习语可以用来解释代码的功能。

    3. 习语可能会被误用,从而模糊谈话的主题。

    4. 为了清晰地表达思想,应避免使用习语。

  2. 每次实现equals()时是否必须实现hasCode()

  3. 如果obj1.compareTo(obj2)返回负值,这意味着什么?

  4. 深拷贝的概念在克隆原始值时适用吗?

  5. StringBufferStringBuilder哪个更快?

  6. 遵循接口编码有哪些好处?

  7. 使用组合与继承相比有哪些好处?

  8. 使用库与编写自己的代码相比有什么优势?

  9. 你的代码的目标受众是谁?

  10. 是否需要测试?

第十八章:评估

第一章 – Java 17 入门

  1. c) Java 开发工具包

  2. b) Java 类库

  3. d) Java 标准版

  4. b) 集成开发环境

  5. a) 项目构建, b) 项目配置, c) 项目文档

  6. a) boolean, b) numeric

  7. a) long, c) short, d) byte

  8. d) 值表示

  9. a) \ , b) 2_0 , c) 2__0f , d) \f

  10. a) % , c) & , d) ->

  11. a) 0

  12. b) false, false

  13. d) 4

  14. c) 编译错误

  15. b) 2

  16. a), c), d)

  17. d) 20 -1

  18. c) x 值在 11 范围内

  19. c) result = 32

  20. a) 可以声明一个变量, b) 可以分配一个变量

  21. b) 选择语句, d) 增量语句

第二章 – Java 面向对象编程(OOP)

  1. a), d)

  2. b), c), d)

  3. a), b), c)

  4. a), c), d)

  5. d)

  6. c), d)

  7. a), b)

  8. b), d)

  9. d)

  10. b)

  11. a), c)

  12. b), c), d)

  13. a), b)

  14. b), c)

  15. b), c), d)

  16. b), c)

  17. c)

  18. a), b), c)

  19. b), c), d)

  20. a), c)

  21. a), c), d)

第三章 – Java 基础知识

  1. a), d)

  2. c), d)

  3. a), b), d)

  4. a), c), d)

  5. a), c)

  6. a), b), d)

  7. a), b), c), d)

  8. c), d)

  9. d)

  10. c)

  11. b)

  12. c)

第四章 – 异常处理

  1. a), b), c)

  2. b)

  3. c)

  4. a), b), c), d)

  5. a)

  6. a), c)

  7. d)

第五章 – 字符串、输入/输出和文件

  1. b)

  2. c)

  3. b)

  4. a)

  5. d)

  6. a), c), d)

  7. c)

  8. d)

  9. a), b), c)

  10. c), d) (注意使用 mkdir() 方法,而不是 mkdirs() 方法)

第六章 – 数据结构、泛型和常用工具

  1. d)

  2. b), d)

  3. a), b), c), d)

  4. a), b), c), d)

  5. a), b), d)

  6. a), b), c)

  7. c)

  8. a), b), c), d)

  9. b), d)

  10. b)

  11. b), c)

  12. a)

  13. c)

  14. d)

  15. b)

  16. c)

  17. a)

  18. b)

  19. c)

第七章 – Java 标准和外部库

  1. a), b), c)

  2. a), b), d)

  3. b), c)

  4. b), d)

  5. a), c)

  6. a), b), c), d)

  7. b), c), d)

  8. b), c)

  9. b)

  10. c), d)

  11. a), c)

  12. b), d)

  13. a), d)

  14. b), c), d)

  15. a), b), d)

  16. b), d)

第八章 – 多线程和并发处理

  1. a), c), d)

  2. b), c), d)

  3. a)

  4. a), c), d)

  5. b), c), d)

  6. a), b), c), d)

  7. c), d)

  8. a), b), c)

  9. b), c)

  10. b), c), d)

  11. a), b), c)

  12. b), c)

  13. b), c)

第九章 – JVM 结构和垃圾回收

  1. b), d)

  2. c)

  3. d)

  4. b), c)

  5. a), d)

  6. c)

  7. a), b), c), d)

  8. a), c), d)

  9. b), d)

  10. a), b), c), d)

  11. a)

  12. a), b), c)

  13. a), c)

  14. a), c), d)

  15. b), d)

第十章 – 数据库中的数据管理

  1. c)

  2. a), d)

  3. b), c), d)

  4. a), b), c), d)

  5. a), b), c)

  6. a), d)

  7. a), b), c)

  8. a), c)

  9. a), c), d)

  10. a), b)

  11. a), d)

  12. a), b), d)

  13. a), b), c)

第十一章 – 网络编程

  1. 正确答案可能包括 FTP、SMTP、HTTP、HTTPS、WebSocket、SSH、Telnet、LDAP、DNS 或其他协议

  2. 正确答案可能包括 UDP、TCP、SCTP、DCCP 或其他协议

  3. java.net.http

  4. UDP

  5. java.net

  6. 传输控制协议

  7. 它们是同义词

  8. TCP 会话由源 IP 地址和端口号以及目标 IP 地址和端口号标识

  9. ServerSocket 可以在客户端未运行的情况下使用。它只是在指定的端口上监听

  10. UDP

  11. TCP

  12. 正确答案可能包括 HTTP、HTTPS、Telnet、FTP 或 SMTP

  13. a), c), d)

  14. 它们是同义词

  15. 它们是同义词

  16. /something/something?par=42

  17. 正确答案可能包括二进制格式、头部压缩、多路复用或推送功能

  18. java.net.http.HttpClient

  19. java.net.http.WebSocket

  20. 没有区别

  21. java.util.concurrent.CompletableFuture

第十二章 – Java GUI 编程

  1. 阶段

  2. 节点

  3. 应用程序

  4. void start(Stage pm)

  5. static void launch(String... args)

  6. --module-path--add-modules

  7. void stop()

  8. WebView

  9. MediaMediaPlayerMediaView

  10. --add-exports

  11. 从以下列表中选择任意五个:BlendBloomBoxBlurColorAdjustDisplacementMapDropShadowGlowInnerShadowLightingMotionBlurPerspectiveTransformReflectionShadowToneSepiaTone

第十三章 – 函数式编程

  1. c)

  2. a), d)

  3. 一个

  4. void

  5. 一个

  6. boolean

  7. None

  8. T

  9. 一个

  10. R

  11. 封闭的上下文

  12. Location::methodName

第十四章 – Java 标准流

  1. a), b)

  2. of(),不带参数,产生一个空流

  3. java.util.Set

  4. 135

  5. 42

  6. 2121

  7. 不是,但它扩展了功能接口 Consumer,可以像这样传递

  8. None

  9. 3

  10. 1.5

  11. 42, X, a

  12. 编译错误,因为 peek() 不能返回任何内容

  13. 2

  14. 一个替代的 Optional 对象

  15. a

  16. 一个

  17. filter()map()flatMap() 中的任何一个

  18. distinct()limit()sorted()reduce()collect() 中的任何一个

第十五章 – 反应式编程

  1. a), b), c)

  2. 是的

  3. 非阻塞输入/输出

  4. 反应式扩展

  5. java.util.concurrent

  6. a), d)

  7. 阻塞操作符名称以 blocking 开头

  8. 热观察者以自己的节奏发出值。冷观察者在前一个值到达终端操作符后发出下一个值

  9. 观察者停止发出值,管道停止操作

  10. a), c), d)

  11. 例如,以下两个中的任意两个:buffer()flatMap()groupBy()map()scan()window()

  12. 例如,以下两个中的任意两个:debounce()distinct()elementAt(long n)filter()firstElement()ignoreElements()lastElement()sample()skip()take()

  13. 丢弃过时的值,取最新的,使用缓冲区

  14. subscribeOn()observeOn()fromFuture()

第十六章 – Java 微基准测试工具

  1. b), c), d)

  2. 将 JMH 的依赖项添加到项目中(或类路径,如果手动运行)并将 @Benchmark 注解添加到您想测试性能的方法上

  3. 作为使用带有显式命名主类的 Java 命令的主方法,作为使用可执行 .jar 文件的主方法,以及使用作为主方法运行的 IDE 或使用插件并运行单个方法

  4. 以下两个中的任意两个:Mode.AverageTimeMode.ThroughputMode.SampleTimeMode.SingleShotTime

  5. 以下两个中的任意两个:TimeUnit.NANOSECONDSTimeUnit.MICROSECONDSTimeUnit.MILLISECONDSTimeUnit.SECONDSTimeUnit.MINUTESTimeUnit.HOURSTimeUnit.DAYS

  6. 使用带有注解@State的类的对象

  7. state属性前使用注解@Param

  8. 使用注解@CompilerConrol

  9. 使用类型为Blackhole的参数,该参数消耗产生的结果

  10. 使用注解@Fork

第十七章 – 编写高质量代码的最佳实践

  1. a)、b)、c)

  2. 通常推荐但不强制,在某些情况下是必需的,例如,当类的对象将被放置和搜索在基于哈希的数据结构中时

  3. obj1小于obj2

  4. StringBuilder

  5. 允许实现更改而不更改客户端代码

  6. 对代码演化和代码灵活性有更多控制,以适应变化

  7. 更可靠的代码,编写更快,测试更少,其他人更容易理解

  8. 将来维护您的代码的其他程序员以及您自己

  9. 不,但这对您非常有帮助

posted @ 2025-09-10 15:08  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报