Java17-零基础入门手册-全-

Java17 零基础入门手册(全)

原文:Java 17 for Absolute Beginners

协议:CC BY-NC-SA 4.0

一、Java 及其历史简介

根据谷歌搜索,在 2020 年底,据报道有 9492 家公司在他们的技术栈中使用 Java,包括谷歌和我,本书的作者,在本书写作时工作的公司。即使过了 25 年,Java 仍然是最有影响力的编程语言之一。这一切都始于 1990 年,当时一家引领计算机行业革命的美国公司决定召集其最优秀的工程师设计和开发一种产品,让他们成为新兴互联网世界的重要参与者。这些工程师中有詹姆斯·亚瑟·高斯林,他是一位加拿大计算机科学家,被公认为 Java 编程语言之父。这需要五年的设计、编程和一次重命名(因为商标问题,从 Oak 改为 Java),但最终在 1996 年 1 月, 1 Java 1.0 发布,适用于 Linux、Solaris、Mac 和 Windows。

阅读技术书籍的一般倾向是完全跳过介绍性章节。但在这种情况下,我认为这将是一个错误。在写这本书之前,我对 Java 的历史并不感兴趣。我知道詹姆斯·高斯林是创造者,甲骨文买下了太阳,差不多就是这样。我从来不太关心语言是如何发展的,灵感来自哪里,或者一个版本与另一个版本有什么不同。我从 1.5 版本开始学习 Java,我把语言中的很多东西都当成了理所当然。所以当我被分配到一个运行在 Java 1.4 上的项目时,我非常困惑,因为我不知道为什么我写的部分代码不能编译。尽管 IT 行业发展非常迅速,但总会有一个客户端拥有遗留应用。了解每个 Java 版本的特性是一个优势,因为您知道执行迁移时的问题。

当我开始为这本书做研究时,我被迷住了。Java 的历史很有趣,因为这是一个难以置信的成长故事,一个技术成功的故事,也是一个管理上的自我冲突几乎杀死创造它的公司的例子。目前,Java 是软件开发中最常用的技术,而诞生它的公司已经不存在了,这简直是自相矛盾。

本章描述了 Java 的每个版本,跟踪了语言和 Java 虚拟机的发展。

这本书是给谁的

大多数面向初学者的 Java 书籍都是从典型的 Hello World 开始的!清单 1-1 中描述的示例。

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

Listing 1-1The Most Common Java Beginner Code Sample

这段代码在执行时会打印出 Hello World!在控制台中。但是如果你已经买了这本书,它假定你想用 Java 开发真正的应用,并且在申请 Java 开发人员的职位时有真正的机会。如果这是你想要的,如果这是你,一个有充分利用这种语言的能力的智慧和愿望的初学者,那么这本书就是给你的。这就是为什么我会给你们足够的信任,让你们用一个更复杂的例子来开始这本书。

Java 是一种基于英语的语法可读的语言。因此,如果你有逻辑思维并且对英语有一点了解,你应该很清楚清单 1-2 中的代码在不执行它的情况下做了什么。

package com.apress.ch.one.hw;

import java.util.List;

public class Example01 {
    public static void main(String[] args) {
        List<String> items = List.of("1", "a", "2", "a", "3", "a");

        items.forEach(item -> {
            if (item.equals("a")) {
                System.out.println("A");
            } else {
                System.out.println("Not A");
            }
        });
    }
}

Listing 1-2The Java Beginner Code Sample a Smart Beginner Deserves

在此代码示例中,声明了一个文本值列表;然后遍历列表,当一个文本等于“A”时,在控制台中打印字母“A”;否则,将打印“非 A”。

如果你是一个绝对的编程初学者,这本书是为你准备的,特别是因为这本书附带的源代码利用了编程中常用的算法和设计模式。所以,如果你的计划是进入编程领域,学习一门高级编程语言,读一读这本书,运行例子,写你自己的代码,你应该会有一个好的开端。

如果你已经了解 Java,你也可以使用这本书,因为它涵盖了 Java 版本 17(早期访问程序或 EAP 2 版本)的语法和内部细节,你肯定会发现一些你不知道的东西。

这本书的结构

您目前正在阅读的这一章是介绍性的,涵盖了 Java 历史的一小部分,向您展示了该语言是如何发展的,并提供了对其未来的一瞥。此外,还将介绍执行 Java 应用的机制,以便您为第章 2 做好准备。下一章将向你展示如何建立你的开发环境,并向你介绍第一个简单的应用。

从第章 3 开始,将涵盖语言的基础部分:包、模块、类、接口、注释对象、操作符、数据类型、记录、语句、流、lambda 表达式等等。

从第章 8 开始,涵盖了与外部数据源的交互:读取和写入文件、序列化/反序列化对象、测试和创建用户界面。

章节 12 完全致力于 Java 9 中引入的发布-订阅框架和反应式编程。

第十三章第十五章将垃圾收集器盖上。

****本书列表中使用的所有资源,以及一些因为本书必须保持合理大小而未能使用的资源,都是名为java-17-for-absolute-beginners的项目的一部分。这个项目由模块组成(因此它是一个多模块项目),这些模块相互链接,并且必须由一个叫做 Maven 的东西来管理。Maven 是我们开发人员称为构建工具的东西,它提供了构建包含大量源代码的项目的能力。构建项目意味着将编写的代码转换成可以执行的东西。我选择为我写的书使用多模块项目,因为它更容易构建,还因为公共元素可以组合在一起,保持项目的配置简单且不重复。此外,通过将所有的源代码组织在一个多模块项目中,您可以尽快获得关于源代码是否工作的反馈,并且您可以联系作者并要求他们更新它们。我知道拥有一个构建工具会带来一定程度的复杂性,但是它让您有机会适应一个非常类似于您将作为一名员工工作的开发环境。

约定

这本书使用了许多格式约定,应该会使它更容易阅读。为此,本书使用了以下约定:

  • 段落中的代码或概念名称如下:java.util.List

  • 代码列表如下所示:

  • 控制台输出中的日志将如下所示:

public static void main(String[] args) {
    System.out.println("Hello World!");
}

  • {xx}是占位符;xx值是一个伪值,给出了应该在命令或语句中使用的真实值的提示。

  • img/463938_2_En_1_Figa_HTML.jpg出现在你要特别注意的段落前面。提示和警告也有类似的图标。

  • 斜体字体用于幽默的隐喻和表达。

  • 粗体字体用于章节参考和重要术语。

01:24:07.809 [main] INFO c.a.Application - Starting Application
01:24:07.814 [main] DEBUG c.a.p.c.Application - Running in debug mode

至于我的写作风格,我喜欢用与同事和朋友进行技术对话的方式来写书:通篇散布笑话,给出生产实例,并与非编程情况进行类比。因为编程不过是模拟现实世界的另一种方式。

当 Java 被太阳微系统公司拥有时

Java 的第一个稳定版本发布于 1996 年。在那之前,有一个名为 Green Team 的小团队在开发一个名为 Oak 的原型语言,该语言通过一个工作演示介绍给了世界——一个名为 Star7 的交互式手持家庭娱乐控制器。动画触摸屏用户界面的明星是一个名为杜克的卡通人物,由团队的图形艺术家之一乔·帕朗创作。多年来,杜克(图 1-1 )已经成为 Java 技术官方吉祥物,每一届 JavaOne 大会(由 Oracle 每年举办一次)都有自己的杜克吉祥物个性。

img/463938_2_En_1_Fig1_HTML.jpg

图 1-1

公爵,Java 官方吉祥物(图片来源:https:// oracle。com)

绿色团队通过互联网向世界发布 Java,因为这是创造广泛采用的最快方式。你可以想象,每当有人下载它时,他们会高兴得跳起来,因为这意味着人们对它感兴趣。软件开源还有一些其他的优势,比如贡献和反馈是由来自世界各地的许多人提供的。因此,对于 Java 来说,这是最好的决定,因为它塑造了今天许多开发人员使用的语言。即使过了 25 年,Java 仍然是最常用的三种编程语言之一。

绿色团队成立于 1982 年,为一家名为太阳微系统的美国公司工作。它通过销售计算机、计算机零件和软件引导了计算机革命。他们最伟大的成就之一是 Java 编程语言。在图 1-2 中可以看到公司 logo3从 Java 诞生那一年开始使用,直到 2010 年被甲骨文收购。

img/463938_2_En_1_Fig2_HTML.png

图 1-2

太阳微系统公司的标志(图片来源:en . Wikipedia . org/wiki/Sun _ Microsystems)

很难找到关于 Java 第一版的信息,但是见证了它诞生的专注的开发人员,当 Web 还很小并且充满静态页面时,确实创建了博客并与世界分享他们的经验。对于 Java 来说,显示与用户交互的动态内容的小程序很容易就能大放异彩。但是因为开发团队想得更大,Java 不仅仅是一种 Web 编程语言。在尝试让小程序在任何浏览器上运行的过程中,该团队找到了一个常见问题的解决方案:可移植性。

现在的开发人员在开发应该在任何操作系统上运行的软件时面临着许多令人头痛的问题。随着移动革命的到来,事情变得非常棘手。在图 1-3 中,你可以看到一张被认为是第一个 Java 徽标的抽象画。

img/463938_2_En_1_Fig3_HTML.jpg

图 1-3

第一个 Java logo,1996–2003(图片来源: oracle. com/ )

Java 1.0 在第一届 JavaOne 大会上发布,有 6000 多名与会者。Java 最初是一种名为 Oak 的语言。这种语言非常类似于 C++,是为手持设备和机顶盒设计的。它演变成了 Java 的第一个版本,为开发人员提供了一些 C++所没有的优势:

  • 安全性:在 Java 中,当意外超过数组的大小时,没有读取假数据的危险。

  • 自动内存管理:Java 开发人员不必检查是否有足够的内存分配给一个对象,然后显式地取消分配;这些操作由垃圾收集器自动处理。这也意味着指针不是必需的。

  • 简单性:没有指针、联合、模板和结构。Java 中的大多数东西都可以被声明为一个类。此外,通过修改继承模型和不允许多重类继承,避免了使用多重继承时的混淆。

  • 支持多线程执行 : Java 从一开始就是为了支持多线程软件的开发而设计的。

  • 可移植性:最广为人知的 Java 格言之一是一次编写,随处运行 (WORA)。这是由 Java 虚拟机实现的。

所有这些使得 Java 对开发人员很有吸引力,到 1997 年 Java 1.1 发布时,世界上已经有大约 400,000 名 Java 开发人员。那年 JavaOne 大会有 10,000 名与会者。通往伟大的道路已经确定。在进一步分析每个 Java 版本之前,让我们澄清一些事情。

Java 是如何移植的?

我几次提到 Java 是可移植的,Java 程序可以在任何操作系统上运行。是时候解释这是如何可能的了。让我们从一张简单的图开始,如图 1-4 中的那张。

img/463938_2_En_1_Fig4_HTML.jpg

图 1-4

在多个平台上运行 Java 程序

Java 是我们所说的高级编程语言,它允许开发者编写独立于特定类型计算机的程序。高级语言更容易读、写和维护。但他们的代码必须由编译器翻译或解释成机器语言(人类无法阅读,因为它是由数字组成的)才能执行,因为这是计算机理解的唯一语言。

在图 1-4 中,注意在操作系统之上,需要一个 JVM 来执行一个 Java 程序。JVM 代表 Java 虚拟机,它是一种抽象的计算机器,使计算机能够运行 Java 程序。它是一个独立于平台的执行环境,将 Java 代码转换成机器语言并执行。

那么 Java 和其他高级语言有什么区别呢?其他高级语言将源代码直接编译成机器码,这些机器码是为在特定的微处理器体系结构或操作系统上运行而设计的,如 Windows 或 UNIX。JVM 所做的是模仿 Java 处理器,使 Java 程序有可能被解释为任何处理器上的一系列动作或操作系统调用,而不管操作系统是什么。当然,编译步骤使 Java 比纯编译语言(如 C++)慢,但它的优势过去是,现在仍然是美丽的。此外,Java 不是 JVM 语言家族的唯一成员。Groovy、Scala、Kotlin 和 Clojure 都是运行在 JVM 上的非常流行的编程语言。

因为提到了 Java 编译器,我们不得不回到 Java 1.1,即使在新版本发布时,它仍被广泛使用。它附带了一个改进的抽象窗口工具包(AWT)图形 API(用于构建小程序的组件集合)、内部类、数据库连接类(JDBC 模型)、远程调用类(RMI)、一个名为 JIT 5 编译器(用于JustInTime)的微软平台专用编译器、对国际化的支持以及 Unicode。Java 被广泛接受的另一个原因是,在 Java 发布后不久,微软就对它进行了许可,并开始使用它创建应用。反馈有助于 Java 的进一步发展,因此 Java 1.1 在当时的所有浏览器上都得到支持,这也是它被如此广泛部署的原因。

本书导言中使用的许多术语现在可能对你来说是陌生的,但随着你阅读本书,更多的信息被引入,这些词将开始变得更有意义。现在,只要记住每一个新的 Java 版本都比前一个版本有更多的东西,在那个时候,每一个新的组件都是新奇的。

那么,在实际执行之前,开发人员编写的 Java 代码到底发生了什么呢?该过程如图 1-5 所示。

img/463938_2_En_1_Fig5_HTML.jpg

图 1-5

从 Java 代码到机器代码

Java 代码被编译并转换成字节码,然后由 JVM 在底层操作系统上解释和执行。

Java 是一种经过编译和解释的通用编程语言,它具有许多特性,非常适合 web。

既然我们已经介绍了 Java 代码是如何执行的,让我们再回顾一些历史。

太阳微系统公司的 Java 版本

Sun Microsystems 发布的第一个稳定的 Java 版本可以从网站上下载,名为 JDK,,当时的版本是 1.0.2。JDK 是 ??、??、发展和科技的首字母缩写。这是用于开发 Java 应用和小程序的软件开发环境。它包括JavaRuntimeEn environment(JRE)、解释器(loader)、编译器、归档器、文档生成器以及 Java 开发所需的其他工具。我们将在关于在你的计算机上安装 JDK 的章节中深入探讨这个问题。

从 1998 年发布的 1.2 版本开始,Java 版本被赋予了代码名称。6Java 1.2 版本的代号是游乐场。这是一个大规模的发布,这是人们开始谈论 Java 2 平台的时刻。从这个版本开始,J2SE 5.0 之前的版本都被重新命名,J2SE取代了 JDK,因为 Java 平台现在由三部分组成:

  • J2SE (Java 2 平台,标准版),后来成为 JSE,一个为桌面和服务器环境开发和部署可移植代码的计算平台。

  • J2EE (Java 2 平台,企业版),后来成为 JEE,一组扩展 Java SE 的规范,用于分布式计算和 web 服务等企业特性。

  • J2ME (Java 2 平台,微型版),后来成为 JME,一个为嵌入式和移动设备开发和部署可移植代码的计算平台。

在这个版本中,JIT 编译器成为 Sun Microsystem 的 JVM 的一部分(这基本上意味着将代码转换为可执行代码成为一种更快的操作,并且生成的可执行代码得到了优化),Swing 图形 API 作为 AWT 的一种奇特的替代方法被引入(引入了创建奇特的桌面应用的新组件),并且引入了 Java Collections 框架(用于处理数据集)。

J2SE 1.3 于 2000 年发布,代号为 Kestrel (可能是指新引入的 Java 声音类)。这个版本还包含了 Java XML APIs。

J2SE 1.4 于 2002 年发布,代号为梅林。这是 Java 社区过程成员第一年参与决定这个版本应该包含哪些特性,因此这个版本相当一致。这是在 Java 社区过程下开发的 Java 平台的第一个版本,名为 JSR 59。 7 以下特性值得一提:

  • 对 IPv6 的支持:基本上,现在可以使用网络协议 IPv6 编写运行在网络上的应用。

  • 非阻塞 IO : IO 是 input-output 的缩写,指的是读写数据——一种非常慢的操作。使 IO 不阻塞意味着优化这些操作,以提高运行应用的速度。

  • Logging API :需要将执行的操作报告给一个文件或资源,在失败的情况下可以读取该文件或资源,以确定原因并找到解决方案。这个过程被称为日志记录,显然只有在这个版本中引入了支持这个操作的组件。

  • 图像处理 API :组件开发者可以用这个用 Java 代码来操作图像。

Java 的咖啡杯标志在 2003 年的 JavaOne 大会上首次出现(在 1.4 和 5.0 版本之间)。在图 1-6 中可以看到。 8

img/463938_2_En_1_Fig6_HTML.jpg

图 1-6

Java 官方 logo 2003-2006(图片来源: oracle。com

J2SE 5.0 于 2004 年发布,代号为老虎。最初,它遵循典型的版本控制,并被命名为 1.5,但因为这是一个具有大量新功能的主要版本,证明了 J2SE 在成熟度、稳定性、可扩展性和安全性方面的重大改进,所以该版本被标记为 5.0,并以这种方式向公众发布,即使内部仍使用 1.5。对于这个版本和接下来的两个版本,我们认为 1.x = x.0。让我们列出这些特性,因为它们中的大多数都包含在本书中:

  • 泛型为集合提供了编译时(静态)类型安全支持,并消除了对大多数类型转换的需要(这意味着在特定上下文中使用的类型是在应用运行时决定的,我们在第 章 5 中有一整节关于这一点)。

  • 注释,也称为元数据,用于标记类和方法,以允许支持元数据的实用程序处理它们(这意味着一个组件被标记为另一个组件可以识别并对其执行特定操作的东西)。

  • 自动装箱/拆箱是指原语类型和匹配对象类型(包装器)之间的自动转换,在章节5 中也有涉及。

  • 枚举使用enum关键字定义静态的最终有序值集;章节 章节章节章节章节。

  • Varargs 为支持一种类型的任意数量参数的方法提供了一种简写方式。方法的最后一个参数是使用类型名后跟三个点(例如,String...)来声明的,这意味着可以提供该类型的任意数量的参数并将其放入数组中;章节 第三章 。

  • 为每个循环增强:也用于迭代集合和数组,在章节 5 中也有介绍。

  • 改进多线程 Java 程序的语义,在第章 第七章 中介绍。

  • 静态进口也涵盖在章 4 中。

  • 对 RMI 的改进(书中未涉及)、Swing ( 章 10 )、并发实用程序(章 7 )、以及Scanner类(章11)的介绍。

Java 5 是苹果 Mac OS X 10.4 的第一个可用版本,也是苹果 Mac OS X 10.5 的默认版本。到 2015 年为止,这个版本发布了很多更新 9 ,以修复与安全和性能相关的问题。这是一个错误百出的版本,这是可以理解的,因为很多特性都是在两年内开发出来的。

2006 年, Java SE 6 稍微延迟发布,代号野马。是的,这又是一次重命名,是的,又一次在相当短的时间内实现了大量的特性。之后需要大量的更新来解决现存的问题。这是 Sun Microsystems 发布的最后一个主要 Java 版本,因为 Oracle 在 2010 年 1 月收购了该公司。下面列出了此版本中最重要的功能。

  • 核心平台的显著性能提升(应用运行速度更快,执行时需要的内存或 CPU 更少)。

  • 改进的 web 服务支持(开发 web 应用所需的优化组件)。

  • JDBC 4.0(使用数据库开发应用所需的优化组件)。

  • Java 编译器 API(您可以从代码中调用用于编译代码的组件)。

  • 许多 GUI 改进,例如在 API 中集成了SwingWorker,表格排序和过滤,以及真正的 Swing 双缓冲(消除了灰色区域效应);总的来说,改进了用于创建桌面应用界面的组件。

在(Java 术语)之后不久,2008 年 12 月, JavaFX 1.0 SDK 发布。JavaFX 适合为任何平台创建图形用户界面。最初的版本是一种脚本语言。直到 2008 年,在 Java 中有两种创建用户界面的方法:

  • 使用 AWT (抽象窗口工具包)组件,这些组件由特定于底层操作系统的本机对等组件呈现和控制;这就是 AWT 组件也被称为重量级组件的原因。

  • 使用 Swing 组件,之所以称之为轻量级,是因为它们不需要在操作系统的窗口工具包中分配本机资源。Swing API 是 AWT 的补充扩展。

对于第一个版本,JavaFX 是否真的有前途,是否会取代 Swing,从来都不清楚。Sun 内部的管理混乱也无助于为这个项目确定一条清晰的道路。

甲骨文接管

尽管 Sun Microsystems 赢得了对微软的诉讼,他们同意支付 2000 万美元,因为没有完全实现 Java 1.1 标准,但在 2008 年,该公司的状况非常糟糕,以至于与 IBM 和惠普的合并谈判开始了。2009 年,甲骨文和 Sun 宣布他们就价格达成一致:甲骨文将以每股 9.50 美元的现金收购 Sun,这相当于 56 亿美元的报价。影响是巨大的。很多工程师辞职了,包括 Java 之父詹姆斯·高斯林,这让很多开发者质疑 Java 平台的未来。

Java 7

Java SE 7,代号 Dolphin ,是甲骨文在 2011 年发布的第一个 Java 版本。它是 Oracle 工程师和全球 Java 社区成员广泛合作的结果,如 OpenJDK 社区和 Java 社区进程(JCP)。它包含了很多变化,但比开发人员预期的要少很多。考虑到两次发布之间的长时间间隔,期望值相当高。Project Lambda,它应该允许在 Java 中使用 Lambda 表达式(这在某些情况下会导致相当大的语法简化),Jigsaw(使 JVM 和 Java 应用模块化;中有一段 章 3 关于他们)被撤掉了。两者都在未来版本中发布。

以下是 Java 7 中最显著的特性:

  • JVM 通过新的 invoke 动态字节码支持动态语言(基本上,Java 代码可以使用用 Python、Ruby、Perl、Javascript 和 Groovy 等非 Java 语言实现的代码)。

  • 压缩的 64 位指针(JVM 的内部优化,因此消耗的内存更少)

  • 在项目下分组的小型语言更改硬币:

    • switch 语句中的字符串(包含在章节7T5)

    • try-statement 中的自动资源管理(包含在章节 5 中)

    • 泛型的改进类型推理—菱形<>操作符(在章节 5 中介绍)

  • 二进制整数文字:整数可以直接表示为二进制数,使用形式为 0b(或 0B)后跟一个或多个二进制数字(0 或 1)(在章 5 中介绍)。

    • 多个异常处理改进(在章 5 中涉及)
  • 并发性改进

  • 新的 I/O 库(添加了新的类来从文件中读取/写入数据,在章 8 中介绍)

  • Timsort引入算法来排序对象的集合和数组,而不是合并排序,因为它具有更好的性能。更好的性能通常意味着减少消耗的资源:内存和/或 CPU,或者减少执行所需的时间。

在几乎没有原始开发团队参与的情况下继续开发一个项目一定是一项非常艰难的工作。这是显而易见的,因为随后有 161 个更新;他们中的大多数人需要修复安全问题和漏洞。

JavaFX 2.0 随 Java 7 一起发布。这证实了 JavaFX 项目与 Oracle 的合作前景。作为一个主要的变化,JavaFX 不再是一个脚本语言,而成为一个 Java API。这意味着 Java 语言语法知识足以开始用它构建用户图形界面。JavaFX 开始超越 Swing,因为它的硬件加速图形引擎 Prism ?? 在渲染方面做得更好。

img/463938_2_En_1_Figd_HTML.jpg从 Java 7 开始,OpenJDK 诞生了,它是 Java SE 平台版的开源参考实现。这是 Java 开发人员社区为提供一个不受 Oracle 许可的 JDK 版本所做的努力,因为人们认为 Oracle 将为 JDK 引入更严格的许可以从中获利。

Java 8

Java SE 8,代号 Spider ,于 2014 年发布,包含了最初打算成为 Java 7 一部分的功能。迟到总比不到好,对吧?历经三年的发展,Java 8 包含了以下关键特性:

  • 语言语法变化

    • lambda 表达式的语言级支持(函数式编程特性)

    • 对接口中默认方法的支持(在章节 4 中涉及)

    • 新的日期和时间 API(包含在章节 5 中)

    • 使用 streams 进行并行处理的新方法(在第章 第八章 中介绍)

  • 改进了与 JavaScript 的集成(Nashorn 项目)。JavaScript 是一种 web 脚本语言,在开发社区中很受欢迎,所以用 Java 提供对它的支持可能会为 Oracle 赢得一些新的支持者。

  • 垃圾收集过程的改进

从 Java 8 开始,代号被取消,以避免任何商标法纠纷;取而代之的是,采用了一种易于区分主要版本、次要版本和安全更新版本的语义版本。 10 版本号匹配以下模式:$MAJOR.$MINOR.$SECURITY。

当在终端中执行java -version时(如果您安装了 Java 8),您会看到类似于清单 1-3 中的日志。

$ java -version
java version "1.8.0_162"
JavaTM SE Runtime Environment build 1.8.0_162-b12
Java HotSpotTM 64-Bit Server VM build 25.162-b12, mixed mode

Listing 1-3Java 8 Log for Execution of java -version

在此日志中,版本号具有以下含义:

  • 1 表示主版本号,对于包含新版本的 Java SE 平台规范中指定的重要新功能的主发行版,主版本号会增加。

  • 8 代表次要版本号,次要更新版本会增加,可能包含兼容的错误修复、标准 API 的修订和其他小功能。

  • 0 表示包含关键修补程序(包括提高安全性所必需的修补程序)的安全更新版本的安全级别。当\(MINOR 递增时,\)SECURITY 不会重置为零,这让用户知道这个版本更安全。

  • 162 是内部版本号。

  • b12 表示附加的构建信息。

这种版本控制风格在 Java 应用中很常见,所以采用这种版本控制风格是为了与一般的行业实践保持一致。

Java 9

Java SE 9 于 2017 年 9 月发布。期待已久的拼图项目终于来了。Java 平台终于模块化了。

对于 Java 世界来说,这是一个巨大的变化;这不是语法上的变化,也不是什么新功能。这是平台设计的改变。我认识的一些有经验的开发人员从 Java 诞生的第一年就开始使用它,他们很难适应。它应该修复 Java 已经存在多年的一些严重问题(在章 3 中有所涉及)。您很幸运,因为作为初学者,您是从零开始的,所以您不需要改变开发应用的方式。

除了引入 Java 模块之外,以下是最重要的特性:

  • Java Shell 工具,一个交互式命令行界面,用于用 Java 编写的评估声明、语句和表达式(在第章 第三章 中介绍)

  • 相当多的安全更新

  • private方法现在在接口中得到支持(在章节 4 中涉及)

  • 强化try-with-resources:最终变量现在可以作为资源使用(在章节 5 中有所涉及)

  • “_”从合法标识符名称集中删除(包含在章 4 中)

  • 对垃圾优先(G1)垃圾收集器的增强;这就变成了默认的垃圾收集器(在章13T5)中有介绍)

  • 在内部,使用了一种新的更紧凑的字符串表示法(在第章 5 中介绍)

  • 并发更新(与并行执行相关,在章节 5 中提到过)

  • 集合的工厂方法(包含在章 5 中)

  • 更新图像处理 API 优化用于编写处理图像的代码的组件

Java 9 遵循与 Java 8 相同的版本控制方案,有一点小的变化。JDK 名字中包含的 Java 版本号最终成为版本方案中的$MAJOR号。因此,如果您安装了 Java 9,当在终端中执行java -version时,您会看到类似于清单 1-4 中的日志。

$ java -version
java version "9.0.4"
JavaTM SE Runtime Environment build 9.0.4+11
Java HotSpotTM 64-Bit Server VM build 9.0.4+11, mixed mode

Listing 1-4Java 9 Log for Execution of java -version

Java 10

Java SE 10(又名 Java 18.3)于 2018 年 3 月 20 日发布。Oracle 改变了 Java 发布风格,每六个月发布一个新版本。Java 10 还使用了 Oracle 建立的新版本约定:版本号跟随\(年。\)月格式。这种版本控制风格是为了让开发人员和最终用户更容易计算出一个版本的年龄,这样他们就可以判断是否要升级到一个具有最新安全补丁和附加特性的新版本。

以下是 Java 10 的一些特性。 12

  • 一个局部变量类型推理来增强语言,将类型推理扩展到局部变量(这是最令人期待的特性,在第章 5 中有所介绍)

  • 针对垃圾收集的更多优化(在第章 第十三章 中介绍)

  • 应用类数据共享,通过跨进程共享公共类元数据来减少内存占用(这是一个高级特性,本书不会涉及)

  • 更多并发更新(与并行执行相关,在章节5中提到)

  • 备用内存设备上的堆分配(JVM 运行 Java 程序所需的内存—称为堆内存—可以在备用内存设备上分配,因此堆也可以在易失性和非易失性 ram 之间划分。更多关于 Java 应用使用的内存可以在章节 5 中阅读。)

安装 JDK 10 后,在终端中运行 java -version 会显示一个类似于清单 1-5 中的日志。

$ java -version
java version "10" 2018-03-20
JavaTM SE Runtime Environment 18.3 build 10+46
Java HotSpotTM 64-Bit Server VM 18.3 build 10+46, mixed mode

Listing 1-5Java 10 Log for Execution of java -version

Java 11

2018 年 9 月 25 日发布的 Java SE 11(又名 Java 18.9), 13 ,包含以下特性:

  • 删除用于构建企业 Java 应用和 Corba(用于远程调用的非常老的技术,允许您的应用与安装在不同计算机上的应用通信)模块的 JEE 高级组件。

  • lambda 参数的局部变量语法允许在声明隐式类型 lambda 表达式的形式参数时使用 var 关键字。

  • Epsilon,一个低开销的垃圾收集器(一个 no-GC,所以基本上你可以在没有 GC 的情况下运行一个应用),基本上对垃圾收集进行了更多的优化(在章 13 中有所涉及)。

  • 更多的并发更新(与并行执行相关,在章节5 中提到)。

  • Nashorn JavaScript 脚本引擎和 API 被标记为不推荐使用,以便在未来的版本中删除它们。ECMAScript 语言结构发展得相当快,所以 Nashorn 变得难以维护。

除了这些变化之外,还推测应该引入一个新的版本变化,因为$YEAR.$MONTH格式与开发人员不太合拍。(为什么这么多版本命名变化?这真的这么重要吗?很明显,是的。)提出的版本控制变化类似于 Java 9 中引入的版本控制变化。 14

当安装 JDK 11 时,在终端中运行 java -version 会显示一个类似于清单 1-6 中的日志。

$ java -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, mixed mode)

Listing 1-6Java 11 Log for Execution of java -version

JDK 11 是一个长期支持版本,计划提供几年的支持。这就是版本名中的 LTS 的意思。

随着 JDK 11 的发布,甲骨文宣布他们将开始对 Java SE 8 许可证收费,因此试图降低软件成本的小企业开始寻找替代方案。AdoptOpenJDK 从一组完全开源的构建脚本和基础设施中为多个平台提供预构建的 OpenJDK 二进制文件。

OpenJDK 与 OracleJDK 具有相同的代码,这取决于您使用的提供者。

另一个优势是,虽然甲骨文,JDK 不能修改,以适应业务应用的需要;OpenJDK 可以修改,因为它是在 GNU 通用公共许可证下授权的,这是相当宽松的。

此外,如果钱不是问题,亚马逊的 Coretto、Azul Zulu 和 GraalVM 都是以某种方式优化的替代 JDK。

Java 12

Java SE 12,152019 年 3 月 29 日发布,包含以下重要特性:

  • 一个名为 Shenandoah 的新的实验性垃圾收集器(GC)算法减少了 GC 暂停时间。

  • 修改了switch语句的语法,允许它也作为表达式使用。它还消除了对break语句的需要(在章节 7 中有所涉及)。

  • JVM 常量 API,对关键类文件和运行时工件的名义描述进行建模。这个 API 对操作类和方法的工具很有帮助。

  • 对 G1 垃圾收集器的小改进(包含在第章 第十三章 )。

  • 改进 JDK 构建过程的 CDS 档案。

  • 大约 100 个微基准 16 被添加到 JDK 源中。

安装 JDK 12 后,在终端中运行 java -version 会显示一个类似于清单 1-7 中的日志。

$ java -version
java version "12.0.2" 2019-07-16
Java(TM) SE Runtime Environment (build 12.0.2+10)
Java HotSpot(TM) 64-Bit Server VM (build 12.0.2+10, mixed mode, sharing)

Listing 1-7Java 12 Log for Execution of java -version

JDK 12 是甲骨文 2017 年 9 月随 JDK 9 推出的为期六个月的发布节奏的一部分。JDK 12 是一个支持期限很短的功能版本。这个版本已经发布了两个补丁。

Java 13

2019 年 9 月 17 日发布的 Java SE 13, 17 ,包含了一些重要的特性,数百个较小的增强,以及数千个 bug 修复。这个版本最重要的特点是:

  • 动态 CSD 存档(JDK 12 中增加的对 CDS 存档支持的改进)

  • z 垃圾收集器增强功能(包含在章13T5)

  • 传统套接字 API 的新实现

  • 对switch表情的更多改进(包含在章节 7 )

  • 对文本块的支持(在章 5 中介绍)

安装 JDK 13 后,在终端中运行 java -version 会显示一个类似于清单 1-8 中的日志。

$ java -version
java version "13.0.2" 2020-01-14
Java(TM) SE Runtime Environment (build 13.0.2+8)
Java HotSpot(TM) 64-Bit Server VM (build 13.0.2+8, mixed mode, sharing)

Listing 1-8Java 13 Log for Execution of java -version

JDK 13 是一个功能版本,支持期限也很短。这个版本已经发布了两个补丁。

Java 14

Java SE 14, 18 于 2020 年 3 月 17 日发布,包含了一个重要特性、增强功能和错误修复的大列表。这个版本最重要的特点是:

  • instanceof运算符的模式匹配(包含在章节 7 中)

  • JFR 事件流 API,用于收集 Java 应用和 JVM 运行时的分析和诊断数据

  • G1 垃圾收集器的更多改进(包含在第章 第十三章 )

  • CMS(并发标记清除)垃圾收集器已被删除。

  • 支持 macOS 的 Z 垃圾收集器(在第章 第十三章 中介绍)

  • Records的引入是为了提供一个简洁的语法来声明类,这些类是浅不可变数据的透明持有者(在章节 5 中讨论)

  • 外部内存访问 API 支持 Java 程序安全有效地访问 Java 堆外的外部内存

  • 对NullPointerException类的改进,提供了更精确的细节,以便于识别变量null

  • 引入jpackage工具是为了提供对本地打包格式的支持,给最终用户一种自然的安装体验

安装 JDK 14 时,在终端中运行 java -version 会显示一个类似于清单 1-9 中的日志。

$ java -version
java version "14.0.2" 2020-07-14
Java(TM) SE Runtime Environment (build 14.0.2+12-46)
Java HotSpot(TM) 64-Bit Server VM (build 14.0.2+12-46, mixed mode, sharing)

Listing 1-9Java 14 Log for Execution of java -version

即使这个版本包含了很多新特性,但其中大部分仅在预览模式下可用,或者被认为处于incubation阶段,这使得这个版本不稳定,不能作为长期支持的候选。

Java 15

2020 年 9 月 15 日发布的 Java SE 15, 19 ,包含了对之前版本中添加的项目的相当大的改进。这个版本最显著的特点是:

  • 删除 Nashorn JavaScript 引擎

  • 添加密封和隐藏类(包含在章节4T5)

  • 加密签名现在支持爱德华兹曲线数字签名算法(EdDSA)

  • 对传统 DatagramSocket API 的更多增强

  • 偏向锁定被禁用和废弃,这导致多线程应用的性能提高

安装 JDK 15 后,在终端中运行 java -version 会显示一个类似于清单 1-10 中的日志。

$ java -version
java version "15" 2020-09-15
Java(TM) SE Runtime Environment (build 15+36-1562)
Java HotSpot(TM) 64-Bit Server VM (build 15+36-1562, mixed mode, sharing)

Listing 1-10Java 15 Log for Execution of java -version

JDK 15 只是一个短期版本,在 JDK 16 于 2021 年 3 月推出之前,Oracle 标准服务将提供六个月的支持。

Java 16

2021 年 3 月 16 日发布的 Java SE 16, 20 ,是标准 Java set 追随 JDK 15 的版本的参考实现。这意味着在 JDK 15 不稳定的一切在 JDK 16 预计会更稳定。除此之外,这个版本最显著的特点是:

  • 引入矢量 API,以表达矢量计算,在受支持的 CPU 架构上编译成最佳矢量硬件指令,从而实现优于同等标量计算的性能

  • 默认情况下 JDK 内部构件的强封装(涵盖在章 3 )

  • 引入外部链接器 API 是为了提供对本机代码的静态类型的纯 Java 访问

  • 引入弹性元空间,促进将未使用的热点类元数据(即元空间)内存更迅速地返回给操作系统

  • 增加了对 C++ 14 语言特性的支持

当安装了 JDK 16 后,在终端中运行java -version会显示一个类似于清单 1-11 中的日志。

$ java -version
openjdk version "16-ea" 2021-03-16
OpenJDK Runtime Environment (build 16-ea+30-2130)
OpenJDK 64-Bit Server VM (build 16-ea+30-2130, mixed mode, sharing)

Listing 1-11Java 16 Log for Execution of java -version

JDK 16 只是一个短期版本,在 JDK 17 于 2021 年 9 月推出之前,Oracle 标准服务将提供六个月的支持。在写这一章的时候,JDK 16 只能通过早期访问计划获得,这就是为什么“ea”字符串出现在版本名称中。

Java 17

JDK17、21接下来的长期支持发布,将由甲骨文支持八年。它于 2021 年 9 月 14 日发布,按照甲骨文针对 Java SE 版本的六个月发布节奏。

在写这一章的时候,JDK 17 只能通过早期访问程序获得,这就是为什么“ea”字符串出现在版本名称中;意思是提前进入。它很难使用,因为还没有任何编辑器或其他构建工具支持它。特性列表也不完整,仍然欢迎 Java 社区对缺陷修复和特性提出建议。

到这本书发行的时候,Java 17 已经稳定并可以使用了。这本书将全面涵盖这个版本的所有重要的稳定功能。不包括预览功能,因为它们对该项目的稳定性有风险。

  • JDK 16 版中引入的 Vector API 的性能和实现改进

  • 对密封类和接口的改进

  • 开关表达式的模式匹配介绍(特征预览)

  • macOS 的特定改进

  • 伪随机数生成器的增强:引入了伪随机数生成器(PRNG)的新接口和实现,包括可跳转的 prng 和一类新的可拆分 PRNG 算法(LXM)

  • 封装 JDK 内部构件的改进

  • 弃用 Applet API(准备在 JDK 18 中移除)

  • 反对安全管理器(准备在 JDK 18 中删除)

  • 外部函数和内存 API 合并了两个先前孵化的 API:外部内存访问 API 和外部链接器 API,允许开发人员调用本地库和处理本地数据,而没有 JNI 的风险

JDK 17 的特性列表集中在 JVM 内部,以提高性能和废弃旧的 API。

当安装 JDK 17 时,在终端中运行 java -version 会显示一个类似于清单 1-12 中的日志。

openjdk version "17" 2021-09-14
OpenJDK Runtime Environment (build 17+35-2724)
OpenJDK 64-Bit Server VM (build 17+35-2724, mixed mode, sharing)

Listing 1-12Java 17 Log for Execution of java -version

细节到此为止。如果你想知道更多关于前 25 年的信息,你可以很容易地在网上找到。 22

先决条件

在结束本章之前,公平地说,要学习 Java,你需要几样东西:

  • 了解操作系统,如 Windows、Linux 或 macOS。

  • 如何完善你的搜索标准,因为与你的操作系统相关的信息在书中没有涉及;如果你有问题,你必须自己解决。

  • 互联网连接。

如果您已经了解 Java,并且出于好奇或为了模块章节而购买了这本书,那么了解像 Maven 或 Gradle 这样的构建工具是很有帮助的,因为源代码被组织在一个多模块项目中,可以用一个简单的命令完全构建。我选择使用构建工具是因为在这个时代,没有构建工具学习 Java 是没有意义的;你申请的任何一家公司肯定都会用到它。

除了我列出的先决条件,您还需要安装一个 JDK 和一个 Java 编辑器。这在章2中有所涉及。你不需要知道数学、算法或设计模式(尽管在你读完这本书后你可能会知道一些)。

话虽如此,我们还是深入探讨一下吧。

摘要

Java 已经统治这个行业超过 25 年了。它并不总是处于最常用的开发技术的顶端,但也从未离开过前五名。即使有了 Node.js 这样的服务器端 JavaScript 智能框架,繁重的工作还是留给了 Java。像 Scala 和 Kotlin 这样的新兴编程语言运行在 JVM 上,所以为了竞争,Java 编程语言可能会经历严重的蜕变,但它仍然会在这里。

版本 9 中引入的模块化功能为在更小的设备上安装 Java 应用打开了大门,因为要运行 Java 应用,我们不再需要整个运行时—只需要它的核心加上构建应用所用的模块。

此外,有许多应用是用 Java 编写的,尤其是在金融领域,所以 Java 仍然会存在,因为遗留的原因,并且因为将这些应用移植到另一种技术是一项不可能完成的任务。然而,这些应用中的大多数都停留在 JDK 8 上,因为它们很复杂,并且有许多需要升级的依赖项,而这并不总是可能的。

Java 可能会存活下来,并在未来的 10 到 15 年内处于领先地位。它是一项非常成熟的技术,有一个巨大的社区围绕着它,这确实有所帮助。非常容易学习和开发人员友好使它仍然是大多数公司的首选。因此,此时您可能会得出结论,学习 Java 和购买这本书是一项不错的投资。

这一章有很多参考资料。它们是有趣的读物,但是它们不是理解本书内容的强制性要求。其余章节也是如此。

****

二、准备您的开发环境

要开始学习 Java,你的电脑需要设置成 Java 开发机。因此,要求如下:

  • 你的电脑对 Java 的支持是强制性的。

  • 集成开发环境,也称为 IDE,它基本上是一个用来编写代码的应用。IDE 在编写代码、编译代码和执行代码时为您提供帮助。

    • 本书推荐的 IDE 是 IntelliJ IDEA 。你可以去他们的网站获取免费的社区版;就本书的目的而言,这就够了。

    • 或者可以选择 Java 开发最流行的免费 IDE:Eclipse。

    • 或者你可以试试 NetBeans ,1 这是大多数初学者的默认选择,因为在版本 8 之前,它是与 JDK 捆绑在一起的。是从 Java 9 的 JDK 里拿出来的,现在可以从这里下载: https://netbeans.org/ 。

  • Maven 是一个构建工具,用于组织项目,轻松处理依赖关系,并使您在大型多模块项目中的工作更容易。(这是强制性的,因为本书中的项目是用 Maven 设置组织和构建的。)

  • Git 是一个版本控制系统,你可以用它来获得这本书的源代码,你可以用它来做实验并创建你自己的版本。它是可选的,因为 GitHub 是本章源代码的宿主,支持直接下载它们作为存档。 1

要编写和执行 Java 程序/应用,你只需要安装好JavaDdevelopmentKit(JDK)。没有什么能阻止你在记事本中编写 Java 代码,如果这是你想要的。我在这里列出的所有其他工具只是为了让您的工作更容易,并让您熟悉真正的开发工作。

如果你为所有用户安装这些应用,你可能需要管理权限。对于 Windows 10,你甚至可能需要一个特殊的程序来授予你的用户管理权限,以便你可以安装必要的工具。这本书提供了如何安装所有东西的说明——假设你的用户有必要的权限。如果你需要更多的信息,互联网可以帮助你。

如果看起来很多,不要气馁;本章包含如何安装和验证每个工具是否正常工作的说明。让我们从确保你的电脑支持 Java 开始。

安装 Java

现在你有了电脑,你迫不及待地开始编写 Java 应用。但是首先你需要给自己弄一个 JDK 并安装它。为此,你需要一个互联网连接。打开浏览器,进入 https://developer.oracle.com/java 。菜单应该有一个下载部分。展开它,选择 Java SE,如图 2-1 所示。

img/463938_2_En_2_Fig1_HTML.jpg

图 2-1

浏览 Oracle 站点以找到所需的 JDK

在 Oracle 网站上,您可以找到最新的稳定 Java 版本。单击所需版本下的下载链接。您应该会被重定向到一个类似于图 2-2 中的下载页面。

img/463938_2_En_2_Fig2_HTML.jpg

图 2-2

您可以在 Oracle 页面下载所需的 JDK

JDK 可用于一些操作系统。你应该下载和你的相匹配的。为了写这本书和写源代码,我用的是 macOS 电脑,这意味着我会用 *下载 JDK。dmg 分机。

您需要接受许可协议,才能使用 Java 进行开发。好奇的可以看一下,但基本上它告诉你,只要不修改它原来的组件,就允许你使用 Java。它还告诉你,你要对你如何使用它负责,所以如果你用它来编写或执行邪恶的应用,你要独自承担法律等方面的责任。

如果你想得到一个尚未正式发布的 JDK 早期版本,这是你必须去的页面: http://openjdk.java.net/projects/jdk/ 。在写这一章的时候,在那个页面上,在版本下,版本 16 和 17 被列为开发中的,一个早期访问(不稳定)的 JDK 17 可供下载。

*这本书将涵盖 Java 语法和 17 版本的细节。在撰写本章时,该版本还有八个月的时间,因此一些图片和细节可能会过时(例如,Oracle 可能会更改其网站的主题)。从一个版本到另一个版本,有一些相同的细节。JDK 不再被称为 JDK 的可能性很小。这些将不会被审查和改变,因为唯一不同的是版本号。由于本书计划在 Java 17 发布后发布,建议下载该版本的 JDK,以确保源代码的完全兼容性。

下载完 JDK 后,下一步是安装它。只需双击它,然后单击下一步,直到完成。这将适用于 Windows 和 macOS。JDK 安装在特定的位置。

在 Windows 中,这是C:\Program Files\Java\jdk-17。

在 macOS 中这是/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home。

在 Linux 系统上,根据发行版的不同,JDK 的安装位置也会有所不同。我的首选方法是从 Oracle 站点获取包含 JDK 全部内容的*.tar.gz,将其解压缩,并复制到一个特定的位置。另外,我在 Linux 上的首选位置是/home/iuliana.cosmina/tools/jdk-17.jdk。

使用 PPA(储存库;也称为软件包管理器)安装程序会自动将 JDK 文件放在它们在 Linux 上应该放的位置,并在发布新版本时使用 Linux(全局)更新程序自动更新它们。但是如果你熟练地使用 Linux,你现在可能已经发现可以跳过这一节了。

在 Linux 或 Unix 系统上简化事情的另一种方法是使用 SDKMAN。从这里得到: https://sdkman.io/ 。

如果你去那个地方,你可以检查 JDK 的内容。在图 2-3 中,左边是 JDK 17 的内容,右边是 JDK 8 的内容。

img/463938_2_En_2_Fig3_HTML.jpg

图 2-3

8 版和 17 版内容对比

我选择进行这种比较是因为从 Java 9 开始,JDK 的内容以不同的方式组织。在 Java 8 之前,JDK 包含一个名为jre的目录,其中包含 JDK 使用的 Java 运行时环境(JRE)。对于只对运行 Java 应用感兴趣的人来说,JRE 可以单独下载。

lib目录包含开发工具所需的 Java 库和支持文件。

从 Java 9 开始,JRE 不再被隔离在自己的目录中。从版本 11 开始,Java 变得完全模块化。这意味着可以使用运行应用所需的特定模块来创建定制的 JRE 发行版。这意味着从 Java 11 开始,Oracle 站点上没有 JRE 可供下载。

关于 JDK,您需要知道的最重要的事情是,bin目录包含编译、执行和审计 Java 代码所必需的可执行文件和命令行启动器。其他目录是jmods目录和include目录,前者包含编译后的模块定义,后者包含 C 语言头文件,支持使用 Java 本地接口(JNI)和 Java 虚拟机(JVM)调试接口进行本地代码编程。

JAVA_HOME 环境变量

JDK 中最重要的目录是bin目录,因为该目录必须添加到系统的路径中。这允许您从任何地方调用 Java 可执行文件。这允许其他应用也调用它们,而不需要额外的配置步骤。大多数用于处理 2 Java 代码的 ide 都是用 Java 编写的,它们需要知道 JDK 安装在哪里才能运行。这是通过声明一个名为 JAVA_HOME 的环境变量来实现的,该变量指向 JDK 目录的位置。要使 Java 可执行文件可以从系统中的任何位置调用,必须将 bin 目录添加到系统路径中。接下来的三个部分解释了如何在三种最常见的操作系统上实现这一点。

Windows 上的 JAVA_HOME

要在 Windows 系统上声明 JAVA_HOME 环境变量,需要打开设置系统变量的对话框。在 Windows 系统上,点击开始按钮。在菜单中,有一个搜索框。在更近的版本中,水平工具栏上有一个搜索框;你也可以用这个。在那里输入单词环境(单词的前三个字母就足够了)。该选项应该可以点击。在 Windows 10 上,这些步骤如图 2-4 所示。

img/463938_2_En_2_Fig4_HTML.jpg

图 2-4

用于配置环境变量的 Windows 菜单项

点击该菜单项后,应会打开如图 2-5 所示的窗口。

img/463938_2_En_2_Fig5_HTML.jpg

图 2-5

在 Windows 上设置环境变量的第一个对话框

点击环境变量按钮(有硬边的那个)。另一个对话窗口打开,分为两部分:用户变量和系统变量。你对系统变量感兴趣,因为那是我们声明 JAVA_HOME 的地方。只需点击新建按钮,就会出现一个小的对话窗口,里面有两个文本字段;一个要求您输入变量名,在本例中为JAVA_HOME,另一个要求您输入路径,在本例中为 JDK 路径。第二个窗口和变量信息弹出对话框窗口如图 2-6 所示。

img/463938_2_En_2_Fig6_HTML.jpg

图 2-6

在 Windows 10 上将 JAVA_HOME 声明为系统变量

在定义了JAVA_HOME变量之后,您需要将可执行文件添加到系统路径中。这可以通过编辑Path变量来完成。只需从系统变量列表中选择并点击编辑按钮。从 Windows 10 开始,Path变量的每一部分都显示在不同的行上,所以你可以添加一个新行,在上面添加%JAVA_HOME%\bin。这种语法很实用,因为它从JAVA_HOME变量包含的任何位置获取 bin 目录的位置。对话窗口如图 2-7 所示。

img/463938_2_En_2_Fig7_HTML.jpg

图 2-7

在 Windows 10 上,将 JDK 可执行文件目录声明为系统路径变量的一部分

在旧的 Windows 系统上,Path的内容显示在一个文本字段中。这意味着您必须在变量文本字段中添加%JAVA_HOME%\bin表达式,并使用分号(;).

无论你用的是哪种 Windows 系统,你都可以通过打开命令提示符并执行set命令来检查你的设置是否正确。这将列出所有系统变量及其值。JAVA_HOME和Path应该有期望的值。对于本节提出的设置,当执行set时,输出如图 2-8 所示。

img/463938_2_En_2_Fig8_HTML.jpg

图 2-8

使用“set”命令列出的 Windows 10 系统变量

如果您执行了前面的命令并看到了预期的输出,现在您可以通过在命令提示符窗口中执行java -version来测试您的 Java 安装,它会打印出预期的结果,类似于清单 2-1 的内容。

$ java -version
openjdk version "17-ea" 2021-09-14
OpenJDK Runtime Environment (build 17-ea+3-125)
OpenJDK 64-Bit Server VM (build 17-ea+3-125, mixed mode, sharing)

Listing 2-1Java 17 Log for Execution of java -version

macOS 上的 JAVA_HOME

JDK 安装的位置是/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home。你的JAVA_HOME should指向这个位置。要为当前用户执行此操作,您可以执行以下操作:

  • 在/Users/{your.user}目录中, 3 创建一个名为.bash_profile的文件,如果它还不存在的话。

  • 在该文件中,编写以下内容:

export JAVA_HOME=$(/usr/libexec/java_home -v17)
export PATH=$JAVA_HOME/bin:$PATH

如果您使用不同的 shell,只需在它自己的配置文件中添加相同的两行。

在 macOS 上,可以同时安装多个 Java 版本。您可以通过调用/usr/libexec/java_home命令并给出您感兴趣的 Java 版本作为参数来获取所需版本的 JDK 位置,从而设置系统上当前使用的版本。执行命令的结果存储为JAVA_HOME变量的值。

在我的系统上,我安装了 JDK 8 到 17。我可以通过执行/usr/libexec/java_home命令并提供每个版本作为参数来检查每个 JDK 的位置。清单 2-2 中描述了版本 8 和 17 的命令和输出。

$ /usr/libexec/java_home -v17
/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home

$ /usr/libexec/java_home -v1.8
/Library/Java/JavaVirtualMachines/jdk1.8.0_162.jdk/Contents/Home

Listing 2-2Java 8 and 17 Locations Obtaind By Calling /usr/libexec/java_home

img/463938_2_En_2_Figc_HTML.jpg使用 SDKMAN 可以避免手动安装 Java 和声明JAVA_HOME环境变量。

行 export PATH=$JAVA_HOME/bin:$PATH将 bin 目录的内容从 JDK 位置添加到系统补丁中。这意味着我可以打开一个终端并在它下面执行任何 Java 可执行文件。例如,我可以通过执行java –version来验证为我的用户设置的默认 Java 版本是否是期望的版本。

根据作为参数给出的版本,将返回不同的 JDK 位置。如果您想测试JAVA_HOME的值,那么echo命令可以帮助您。清单 2-3 描述了echo和java –version命令的输出。

$ echo $JAVA_HOME
/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home

$ java -version
openjdk version "17-ea" 2021-09-14
OpenJDK Runtime Environment (build 17-ea+3-125)
OpenJDK 64-Bit Server VM (build 17-ea+3-125, mixed mode, sharing)

Listing 2-3echo and java –version commands to check JAVA_HOME value and the Java version installed

Linux 上的 JAVA_HOME

如果你熟练地使用 Linux,你要么使用 PPA,要么使用 SDKMAN,所以你可以跳过这一节。但是,如果您喜欢控制 JDK 的位置并定义自己的环境变量,请继续阅读。

Linux 系统是类似 Unix 的操作系统。这和 macOS 类似,都是基于 Unix 的。根据您的 Linux 发行版,安装 Java 可以通过特定的包管理器来完成,或者直接从 Oracle 官方网站下载 JDK * . tar . gz 档案文件。

如果使用包管理器安装 Java,必要的可执行文件通常会在安装时自动放在系统路径中。这就是为什么在本书中,我们只讨论手动完成所有事情的情况,并选择只为当前用户在某个位置安装 Java,比如/home/{your.user}/tools/jdk-17.jdk,因为讨论包管理器不是本书的目的。4

从 Oracle 站点下载 JDK 归档文件并在/home/{your.user}/tools/jdk-17.jdk解包后,您需要在您的用户主目录中创建一个名为.bashrc或.bash_profile的文件。在某些 Linux 发行版上,这些文件可能已经存在,您只需要编辑它们。将以下内容添加到行中:

    export JAVA_HOME=/home/{your.user}/tools/jdk-17.jdk
    export PATH=$JAVA_HOME/bin:$PATH

如你所见,语法类似于 macOS。为了检查 JDK 和 Java 版本的位置,使用了 macOS 部分中提到的相同命令。

安装 Maven

这本书第一版的资料来源被组织在一个多模块项目中。根据读者的要求,本书的源代码被组织在一个 Maven 多模块项目中。

本书附带的源代码被组织成小项目,可以使用 Apache Maven 编译和执行。您可以下载并在其官方页面上了解更多信息: https://maven.apache.org 。Apache Maven 是一个软件项目管理和理解工具。之所以选择它作为本书的构建工具,是因为它易于安装(XML 现在几乎无处不在),也是因为它与 Java 的长期关系。学习构建工具是很实用的,因为对于中型和大型项目来说,它们是必备的,因为它们方便了依赖项的声明、下载和升级。

安装 Maven 相当容易。只需下载它,在某个地方解包,并声明M2_HOME环境变量。如何做到这一点的说明是官方网站的一部分,或者你可以使用 SDKMAN。

一旦安装了 Maven,就可以通过打开终端(或 Windows 上的命令提示符)并执行mvn -version来检查它是否安装成功,以及它是否使用了您期望的 JDK 版本。输出应该与清单 2-4 中的输出非常相似。

$  mvn --version
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: /Users/iulianacosmina/.sdkman/candidates/maven/current
Java version: 17-ea, vendor: Oracle Corporation, runtime: /Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
Default locale: en_GB, platform encoding: UTF-8
OS name: "mac os x", version: "10.16", arch: "x86_64", family: "mac"

Listing 2-4Output of Command mvn -version on macOS

如果你想从事 Java 开发,熟悉构建工具是一个很有价值的优势。大多数使用 Java 的公司都有大型项目,这些项目被组织在相互依赖的模块中,没有构建工具就无法管理这些模块。Apache Maven 长期以来一直是 Java 事实上的构建工具,所以您可能想熟悉一下它。

安装 Git

这是一个可选的部分,但是作为一个开发人员,熟悉版本控制系统是很重要的,所以在这里。要在你的系统上安装 Git,只需进入官方页面 https://git-scm.com/downloads 下载安装程序。打开安装程序,点击下一个,直到完成。这适用于 Windows 和 macOS。 5 没错,就是这个容易;你不需要做任何其他事情。在 Linux 上,可以使用 PPA 来完成。

以防万一,这里有一页是关于如何为所有操作系统安装 Git 的说明: https://gist.github.com/derhuerst/1b15ff4652a867391f03

要测试 Git 是否成功安装在您的系统上,请打开一个终端(Windows 中的命令提示符,以及您在 macOS 和 Linux 上安装的任何类型的终端)并运行git --version来查看打印的结果。它应该是您刚刚安装的 Git 的版本。预期输出应类似于清单 2-5 。

$ git --version
git version 2.20.1

Listing 2-5Output of Command git –version to Verify Git Installation

现在已经安装了 Git,您可以通过在终端中克隆官方的 Git 存储库或者直接从 IDE 中获得这本书的源代码。

安装 Java IDE

基于我十多年的经验,我推荐的编辑器是 IntelliJ IDEA。它由一家名为 JetBrains 的公司生产。你可以从他们的官方网站 https://www.jetbrains.com/idea/download/ 下载这个 IDE。终极版 30 天后过期;除此之外,还需要付费许可证。还有一个社区版,可以在没有许可证的情况下使用。对于便于学习 Java 的简单项目,这个版本就足够了。

下载 IntelliJ IDEA 归档文件后,双击它进行安装。之后,启动它并首先配置您的插件。点击插件菜单项,在窗口的右侧会出现一个插件列表。在已安装选项卡上的插件列表是您可能想要检查的(如图 2-9 所示)。

img/463938_2_En_2_Fig9_HTML.jpg

图 2-9

IntelliJ IDEA Community Edition 配置插件窗口

在 IntelliJ IDEA 中,不同风格的 Java 项目所需的插件是默认启用的。您可以修改该列表并禁用不需要的列表。这将减少 IntelliJ IDEA 运行所需的内存量。

默认情况下,Maven 插件是启用的;Git 插件也是如此。这意味着您的 IDE 适合立即使用。这意味着你需要得到这本书的来源。有三种方法可以获得这本书的来源:

  • 直接从 GitHub 下载压缩包。

  • 使用以下命令,使用终端(或 Windows 中的 Git Bash Shell)克隆存储库:

  • 使用 IntelliJ IDEA 克隆项目。

$ git clone https://github.com/Apress/java-17-for-absolute-beginners.git

当使用存储库的 HTTPS URL 时,从命令行或 IntelliJ IDEA 克隆不需要 GitHub 用户。图 2-10 显示了为这本书克隆 GitHub 项目的两个必要步骤。

img/463938_2_En_2_Fig10_HTML.jpg

图 2-10

IntelliJ IDEA 社区版克隆自 VCS windows

第一次打开 IntelliJ IDEA 时,选择项目菜单项,然后点击克隆自 VCS 按钮。将出现一个新的对话窗口,您可以在其中插入存储库 URL 和源文件的复制位置。点击克隆按钮后,项目将被复制,IntelliJ IDEA 将打开它,并指出它使用了 Maven。

如果您使用命令行克隆了项目,您可以使用打开按钮并选择克隆操作创建的目录,在 IntelliJ IDEA 中导入它。

IntelliJ IDEA 有自己的内部 Maven 包。如果你想告诉 IntelliJ IDEA 使用你的本地安装,只需打开首选项菜单项,转到构建、执行、部署 ➤ 构建工具 ➤ Maven 部分,选择外部 Maven 安装目录。

这就是了。从下一章开始,将会出现一些代码片段,所以继续构建这个项目。这可以通过在 IntelliJ IDEA Maven 视图中双击maven install阶段来执行,如图 2-11 所示。

img/463938_2_En_2_Fig11_HTML.jpg

图 2-11

IntelliJ IDEA 专家视图

期望在编辑器的底部打开一个窗口,描述构建进度,如果源代码也没问题,这个过程应该以打印消息BUILD SUCCESS结束。

img/463938_2_En_2_Figi_HTML.jpg如果在 IntelliJ ideA 中构建失败,并且您想要识别问题,第一步是在 IDE 之外运行。您可以通过执行mvn clean install在终端(或 Windows 上的命令提示符)中运行构建。如果构建在终端中通过,那么源代码和您的设置是正确的,编辑器配置肯定有问题。

Maven 构建遵循特定的生命周期,将 Java 项目从源代码转换成可以在应用服务器上执行或部署的东西。各个阶段以特定的顺序执行。使用mvn {phase}命令运行一个特定的阶段会执行许多名为 goals 的步骤,每个步骤负责一个特定的任务。

之前推荐的mvn clean install命令执行一个clean阶段,删除之前生成的字节码文件,然后执行一个install阶段,将 Java 文件编译成字节码,执行测试,如果有的话,将它们打包到 Java 档案(*。jar 文件)并将它们复制到本地 Maven 存储库中。如果你想了解更多关于 Maven 的内容,只需查看官方网站: https://maven.apache.org ,但对于本书的范围来说,一切都变得非常容易,正如在章 3 中所解释的那样。

摘要

如果任何说明对你来说不清楚(或者我错过了什么),不要犹豫使用万维网寻找答案。本章介绍的所有软件技术都有全面的官方网站和渴望提供帮助的庞大开发者社区的支持。在最坏的情况下,当你一无所获时,你可以在 Apress GitHub 官方知识库上为这本书创建一个问题,或者给我发邮件。如果需要的话,我会尽力支持你。

但是我想你会没事的。Java 几乎不是火箭科学。 6

*

三、开始干活

本章涵盖了 Java 语言的基本构件和术语。虽然它可以被认为是另一个介绍性的章节,但它是相当重要的。前一章为您提供了一个为编写 Java 代码而配置的完整开发环境。是时候利用它了。本章包括以下主题:

  • 核心语法部分

  • 使用 JShell

  • Java 基础构件:包、模块和类

  • 用 IntelliJ IDEA 创建 Java 项目

  • Java 代码的编译和执行

  • 将 Java 应用打包到可执行的 jar 中

  • 使用 Maven

核心语法部分

编写 Java 代码很容易,但是在这之前,有几个基本的语法规则是必要的。让我们分析本书开头的代码示例,现在在清单 3-1 中描述。

package com.apress.ch.one.hw;

import java.util.List;

public class Example01 {
    public static void main(String[] args) {
        List<String> items = List.of("1", "a", "2", "a", "3", "a");

        items.forEach(item -> {
            if (item.equals("a")) {
                System.out.println("A");
            } else {
                System.out.println("Not A");
            }
        });
    }
}

Listing 3-1The Java Beginner Code Sample a Smart Beginner Deserves

下一个列表解释了具有相同用途的每一行或一组行:

  • ;(分号)用来标记一个语句或声明的结束。

  • package com.apress.ch.one.hw;是一个包声明。您可以将此语句视为该类在文件中声明的地址。

  • import java.util.List;是一个导入语句。JDK 提供了许多在编写代码时使用的类。这些类也组织在包中,当您想要使用其中一个时,您必须指定要使用的类及其包,因为两个类可能有相同的名称,但在不同的包中声明。当编译器编译你的代码时,它需要准确地知道需要哪个类。

  • public class Example01是类声明语句。它包含一个访问器(public)、类型(class)和类名(Example01)。一个类有一个用花括号括起来的主体。

  • { ... }(花括号)用于将语句组合成代码块。块不需要以;结束。代码块可以代表一个类的主体、一个方法,或者只是一些必须组合在一起的语句。

  • public static void main(String[] args)是一个方法声明语句。它包含一个访问器(public)、一个保留关键字(static)(将在后面解释)、方法名(main)和一个声明参数的部分((String[] args))。

  • List<String> items = List.of("1", "a", "2", "a", "3", "a");是声明类型为List<String>的名为items的变量并将该语句返回的值赋给它的语句:List.of("1", "a", "2", "a", "3", "a")。

  • items.forEach(... )是包含对items变量的函数调用的语句,用于遍历列表变量中的所有值。

  • item -> { ... }是一个λ表达式。它声明了要为列表中的每一项执行的代码块。

  • if (<condition>) { ... } else { ... }是决定性的陈述。正在执行的代码块是通过评估条件来决定的。

  • System.out.println(<text>);是用来打印传递给它的参数的语句。

在书中开始详细解释前面列表中的所有内容还为时过早,但编写 Java 代码时最重要的规则是,除了包声明和导入语句,所有代码都必须在一个块内。此外,如果一个语句不是多行的,它必须以“;”结尾,否则代码将无法编译。

在开始编写更详细的 Java 类之前,最好先编写简单的 Java 语句,并习惯语法。从 Java 9 开始,这可以通过使用 JShell 来实现,JShell 是一个用于学习 Java 编程语言和构建 Java 代码原型的交互式工具。因此,不用在类中编写代码、编译代码并执行字节码,您可以直接使用 JShell 来执行语句。

使用 JShell

JShell 加入得相当晚,因为 Python 和 Node 等脚本语言在几年前就引入了类似的实用程序,Scala、Clojure 和 Groovy 等 JVM 语言紧随其后。但迟做总比不做好。

JShell 是一个读取-求值-打印循环(REPL ),它在输入声明、语句和表达式时对它们进行求值,然后立即显示结果。快速尝试新的想法和技术是切实可行的,不需要完整的开发环境,也不需要执行代码的完整上下文。

JShell 是 JDK 的标准组件。要启动的可执行文件位于 JDK 安装目录下的bin目录中。这意味着你所要做的就是打开一个终端(Windows 中的命令提示符)并键入jshell。如果将bin目录的内容添加到系统路径中,您应该会在您的系统上看到包含 JDK 版本的欢迎消息。此外,你的终端的根目录变为jshell>,让你知道你现在正在使用jshell。

在清单 3-2 中,jshell 通过调用jshell -v以详细模式启动,这使得能够为所有执行的语句提供详细的反馈,直到会话结束。

$ jshell -v
|  Welcome to JShell -- Version 17-ea
|  For an introduction type: /help intro

jshell>

Listing 3-2Output of Command jshell -v

如果您在阅读本书时正在执行命令,请继续并输入/help查看所有可用动作和命令的列表。假设你不是,列表 3-3 描述了预期的输出。

jshell> /help
|  Type a Java language expression, statement, or declaration.
|  Or type one of the following commands:
|  /list [<name or id>|-all|-start]
|   list the source you have typed
|  /edit <name or id>
|   edit a source entry
|  /drop <name or id>
|   delete a source entry
|  /save [-all|-history|-start] <file>
|   Save snippet source to a file
...
|  /exit [<integer-expression-snippet>]
|   exit the jshell tool
...

Listing 3-3Output of Command /help in jshell

在 Java 中,值被分配给名为变量的字符组。(在章节 4 中有更多关于如何选择和使用它们的内容)。)要开始使用 JShell,我们将声明一个名为six的变量,并将值 6 赋给它(我知道,聪明吧?)。清单 3-4 中描述了语句和jshell日志。

jshell> int six = 6;
six ==> 6
|  created variable six : int

Listing 3-4Declaring a Variable Using jshell

如您所见,日志消息很清楚,它告诉我们我们的命令执行成功,并且创建了一个名为six的类型为int的变量。six == > 6 让我们知道值 6 被赋给了我们刚刚创建的变量。

您可以创建任意数量的变量,并执行数学运算、字符串连接和任何需要快速执行的操作。只要 JShell 会话没有关闭,变量就存在并且可以使用。清单 3-5 描述了用 JShell 执行的一些语句及其结果。

jshell> int six = 6
six ==> 6
|  modified variable six : int
|    update overwrote variable six : int
jshell> six = six + 1
six ==> 7
|  assigned to six : int
jshell> six +1
$14 ==> 8
|  created scratch variable $14 : int
jshell> System.out.println("Current val: " + six)
Current val: 7

Listing 3-5jshell Various Statements and Outputs

前面的代码清单中描述的$1 4 == > 8 显示了值 8 被赋给一个名为$14的变量。这个变量是由 jshell 创建的。当语句的结果没有赋给开发人员命名的变量时,jshell 会生成一个临时变量,其名称由$(美元)字符和一个表示该变量内部索引的数字组成。文档中没有明确说明,但是根据我在使用 jshell 时的观察,索引值似乎是导致创建它的语句的编号。

Java 代码最重要的组成部分之一是类。类是模拟现实世界对象和事件的代码片段。类包含两种类型的成员:建模状态,即类变量,也称为字段或属性,以及建模行为,称为方法。

JDK 提供了许多类,这些类对创建大多数应用所需的基本组件进行建模。在下一章中会更详细地介绍这些类。即使有些概念现在看起来很陌生,只要耐心等待,让它积累;它们以后会变得更有意义。

最重要的 JDK 类之一是java.lang.String,它用来表示文本对象。这个类提供了一组丰富的方法来操作一个String变量的值。清单 3-6 描述了在String类型的声明变量上调用的一些方法。

jshell> String lyric = "twice as much ain't twice as good"
lyric ==> "twice as much ain't twice as good"
|  created variable lyric : String

jshell> lyric.toUpperCase()
$18 ==> "TWICE AS MUCH AIN'T TWICE AS GOOD"
|  created scratch variable $18 : String
jshell> lyric.length()
$20 ==> 33
|  created scratch variable $20 : int

Listing 3-6jshell Method Calling Examples with String Variable

使用 JDK 类型的变量在jshell中编写 Java 代码的任务可能看起来很复杂,因为您不知道要调用什么方法,对吗?jshell非常有用,因为它会告诉您方法何时不存在。当试图调用一个方法时,您可以按下<Tab>键,显示可用方法列表。这被称为代码完成,智能 Java 编辑器也提供这种功能。

在清单 3-7 中,你可以看到当你试图调用一个不存在的方法时jshell打印出的错误信息,以及如何显示和过滤某个类型可用的方法。

jshell> lyric.toupper()
|  Error:
|  cannot find symbol
|    symbol:   method toupper()
|  lyric.toupper()
|  ^-----------^

jshell> lyric.to # <Tab>
toCharArray() toLowerCase( toString() toUpperCase(
jshell> lyric. # <Tab>
charAt( chars() codePointAt(
codePointBefore( codePointCount( codePoints()
...

Listing 3-7More jshell Method Calling Examples with String Variable

JShell 很明显地告诉我们,toupper()方法对于String类是未知的。

当列出可能的方法时,以(结尾的方法不需要参数。以单个左括号结尾的方法不带或带多个参数,并且有多种形式。要查看这些表单,只需在变量上编写方法,然后再次按下<Tab>。清单 3-8 描述了indexOf方法的多种形式。

jshell> lyric.indexOf( # <Tab>
$1      $14     $18     $19     $2      $20     $5      $9      lyric   six

Signatures:
int String.indexOf(int ch)
int String.indexOf(int ch, int fromIndex)
int String.indexOf(String str)
int String.indexOf(String str, int fromIndex)

<press tab again to see documentation>

Listing 3-8jshell Listing All the Forms of the indexOf Method in the String Class

在第lyric.indexOf(行之后,jshell 列出了会话期间创建的变量,让您可以轻松选择现有的参数。

你可以在 Java 项目中写任何东西,你也可以在jshell中写。这样做的好处是,你可以把你的程序分成一系列的语句,立即执行它们来检查结果,并根据需要进行调整。还有其他事情jshell可以为你做,最重要的是这本书的一部分。

您在 JShell 会话中声明的所有变量都通过执行/vars命令列出。清单 3-9 描述了本章会话中声明的变量。

jshell> /vars
|    int $1 = 5
|    int $2 = 42
|    int $5 = 8
|    int $9 = 8
|    int six = 7
|    int $14 = 8
|    String lyric = "twice as much ain't twice as good"
|    String $18 = "TWICE AS MUCH AIN'T TWICE AS GOOD"
|    int $19 = 9
|    int $20 = 33

Listing 3-9jshell> /vars Output Sample for a Small Coding Session

如果想保存 JShell 会话中的所有输入,可以通过执行/save {filename}.java. 1 来实现

假设所有语句都是有效的 Java 语句,那么可以使用/open {filename}.java命令将结果文件中的语句执行到一个新的 JShell 会话中。

如果您有兴趣尝试它提供的每一个命令和每一个特性,Oracle 官方网站上有一个 JShell 完整用户指南 2 。

Java 基础构件

这是对 Java 平台的一贯介绍。要自信地编写代码,您需要了解幕后发生了什么,构建模块是什么,以及您必须配置/编写它们的顺序。如果你愿意,你可以完全跳过下一节,但同样的,一些新司机在自信地抓住方向盘之前需要了解一点发动机的工作原理,一些人如果了解一点机械原理,在编程时可能会感到更加自信和可控。所以我想确保每个阅读这本书的人都有一个合适的开始。

要编写 Java 应用,开发人员必须熟悉 Java 程序的 Java 构件。你可以这样想:如果你想制造一辆汽车,你必须知道轮子是什么,它们放在哪里,对吗?这就是我在本书中试图为 Java 实现的目标:解释所有的组件及其用途。

这个生态系统的核心是类。Java 中还有其他的对象类型 3 ,但是类是最重要的,因为它们代表组成应用的对象的模板。一个类主要包含字段和方法。创建对象时,字段的值定义对象的状态,方法描述其行为。

Java 对象是现实世界对象的模型。因此,如果我们选择用 Java 建模汽车,我们将选择定义描述汽车的字段:制造商、型号名称、生产年份、颜色和速度。我们的汽车类的方法描述了汽车做什么。汽车主要做两件事:加速和刹车,所以任何方法都应该描述与这两件事相关的动作。

包装

当您编写 Java 代码时,您是在编写描述现实世界项目的状态和行为的代码。代码必须组织在类和其他类型中,这些类和类型一起用于构建应用。所有类型都在扩展名为.java的文件中描述。对象类型被组织在包中。

包是类型的逻辑集合:有些类型在包外是可见的,有些是不可见的,这取决于它们的作用域。

为了理解包裹的工作方式,想象一个包含其他盒子的盒子。那些盒子可能被其他盒子填满,或者它们可能被一些不是盒子的东西填满。为了这个例子,让我们假设这些物品是乐高积木。这个类比很管用,因为 Java 类型可以像乐高积木一样组合。

包名必须是唯一的,并且它们的名字应该遵循一定的模板。该模板通常由从事该项目的公司定义。良好的实践表明,为了确保唯一性和意义,您通常以组织的 Internet 域名逆序开始名称,然后添加各种分组标准。

在这个项目中,包名遵循这里描述的模板:com.apress.bgn.[<star>]+。该模板以一个出版社的反向域名( www. apress.com )开始,然后添加一个标识该书的术语( bgn 是初学者的快捷方式),最后,<star>替换源(通常)匹配的包的编号。

考虑到之前介绍的盒子和乐高类比,com包就是包含apress盒子的大盒子。它也可以包含其他乐高玩具,但是在这个例子中没有。

apress框代表com.apress包,包含bgn框。

bgn代表com.apress.bgn包装盒,包含特定于每个章节的盒子,包含其他盒子和/或乐高积木。乐高积木是 Java 文件,包含 Java 代码。图 3-1 展示了这些盒子和乐高积木以及它们嵌套的方式。

img/463938_2_En_3_Fig1_HTML.jpg

图 3-1

Java 包,源代码表示为嵌套的盒子和乐高积木

在您的计算机上,包是目录的层次结构。每个目录包含其他目录和/或 Java 文件。这完全取决于你的组织能力。这种组织很重要,因为任何 Java 对象类型都可以使用包名和它自己的名称来唯一地标识。

如果我们要在一个名为HelloWorld.java的文件中编写一个名为HelloWorld的类,并将这个文件放在包com.apress.bgn.one中,在 Java 项目中com.apress.bgn.one.HelloWorld头韵是作为这个类的唯一标识符的完整类名。您可以将包名视为该类的地址。

从 Java 5 开始,在每个包中可以创建一个名为package-info.java的文件,其中包含包声明、包注释、包注释和 Javadoc 注释。注释被导出到该项目的开发文档中,也称为 Javadoc 。章节 9 讲述了如何使用 Maven 生成项目 Javadoc。package-info.java必须位于包中的最后一个目录下。所以如果我们定义一个com.apress.bgn.one包,Java 项目的整体结构和内容看起来如图 3-2 所示。 4

img/463938_2_En_3_Fig2_HTML.jpg

图 3-2

Java 包内容

package-info.java的内容可能类似于清单 3-10 的内容。

/**
 * Contains classes used for reading information from various sources.
 * @author iuliana.cosmina
 * @version 1.0-SNAPSHOT
 */
package com.apress.bgn.one;

Listing 3-10package-info.java Contents

将包含类型定义的扩展名为.java的文件编译成扩展名为.class的文件,这些文件按照相同的包结构组织,并打包成一个或多个JARs(JavaArchives)。 5 对于上一个例子,如果我们在编译和链接之后解包 JAR 结果,你会看到如图 3-3 所示的内容。

img/463938_2_En_3_Fig3_HTML.jpg

图 3-3

样品罐的内容物

package-info.java文件也被编译,即使它只包含关于包的信息,没有行为或类型。

img/463938_2_En_3_Fige_HTML.jpg package-info.java文件不是强制性的;没有它们也可以定义包。它们主要用于文档目的。

一个包的内容可以跨越多个 jar,这意味着如果您的项目中有多个子项目,那么您可以在多个包含不同类的包中使用相同的包名。图 3-4 描述了这种情况的符号表示。

img/463938_2_En_3_Fig4_HTML.jpg

图 3-4

跨越多个 jar 的包内容示例

库是一个 jar6的集合,包含用于实现特定功能的类。例如,JUnit 是一个非常著名的 Java 框架,它提供了多个类来帮助编写 Java 单元测试。

一个中等复杂的 Java 应用引用一个或多个库。要运行应用,它的所有依赖项(所有 jar)都必须在类路径中。这是什么意思?这意味着为了运行 Java 应用,需要 JDK、依赖项(外部 jar)和应用 jar。图 3-5 非常清楚地描述了这一点。

img/463938_2_En_3_Fig5_HTML.jpg

图 3-5

应用类路径

这里,我们假设应用运行在编写它的同一个环境中,因此使用 JDK 来运行应用。在 JDK 11 之前,任何 Java 应用都可以使用 JRE 运行。但是从版本 11 开始,Java 变得完全模块化。这意味着定制的“JRE”发行版只能从运行应用所需的模块中创建。间接地,这意味着最终的 JRE 将包含最少数量的 JDK 编译类。

组成应用类路径的 jar(显然)并不总是相互独立的。21 年来,这种组织方式已经足够了,但是在复杂的应用中,由于以下原因导致了许多复杂情况:

  • 分散在多个罐子里的包裹。(还记得图 3-4 ?)这可能会导致代码重复和循环依赖。

  • jar 之间的可传递依赖关系有时会导致同一类的不同版本出现在类路径中。这可能会导致不可预测的应用行为。

  • 缺少传递依赖和可访问性问题。这可能会导致应用崩溃。

所有这些问题都被归为一类:罐子地狱。 7 这个问题在 Java 9 中通过引入另一个层次来组织包得到了解决:模块。或者至少这是他们的意图。然而,业界一直不愿意采用 Java 模块。在写这一章的时候,大多数 Java 生产应用不仅停留在 Java 8 上,而且开发人员也像躲避瘟疫一样躲避模块。

然而,在介绍模块之前,应该提到访问修饰符。Java 类型和它们的成员在包中被声明为具有一定的访问权限,在开始编码之前,理解这一点非常重要。

访问修饰符

当在 Java 中声明一个类型时——现在让我们坚持使用class——因为这是到目前为止唯一提到的类型,您可以使用访问修饰符来配置它的作用域。

访问修饰符可以用来指定对类的访问,在这种情况下,我们说它们在top-level使用。

它们也可以用来指定对类成员的访问,在这种情况下,它们用在member-level. 8

在top-level只能使用两个访问修饰符:public和 none。

声明为public的top-level类必须在同名的 Java 文件中定义。清单 3-11 描述了一个名为Base的类,它定义在位于包com.apress.bgn.zero中的名为Base.java的文件中。

package com.apress.bgn.zero;

// top-level access modifier
public class Base {
    // code omitted
}

Listing 3-11Base Class

这个类的内容暂时不描述,用...代替,以免你失去焦点。公共类对应用中任何地方的所有类都是可见的。不同包中的不同类可以创建这种类型的对象,如清单 3-12 所示:

package com.apress.bgn.three;

import com.apress.bgn.zero.Base;

public class Main {
    public static void main(String... args) {
        // creating an object of type Base
        Base base = new Base();
    }
}

Listing 3-12Creating an Object Using the Base Class

线Base base = new Base();是创建对象的地方。new关键字代表一个类的名为instantiation的操作,这意味着一个对象是基于代表Base类的代码所描述的规范而创建的。

img/463938_2_En_3_Figg_HTML.jpg一个类就是一个模板。使用该模板创建对象,称为instances。

现在,让这种肯定深入人心:公共类对任何地方的所有类都是可见的。

当没有提到显式的访问修饰符时,就说这个类被声明为默认的或者是包私有的。我知道有两种方式来谈论缺少访问修饰符似乎令人困惑,但是由于您可能会阅读其他涉及这种情况的书籍或博客帖子,因此最好在这里列出所有的可能性。

这意味着如果一个类没有访问修饰符,那么这个类只能被同一个包中定义的类用来创建对象。它的范围仅限于定义它的包。没有访问修饰符的类可以在任何 Java 文件中定义:同名的文件,或者紧挨着给文件命名的类。

当在同一个文件中声明了多个类时,公共类必须与定义它的文件同名,因此这就是命名文件的类。

为了测试这一点,让我们在前面介绍的Base.java文件中添加一个名为HiddenBase的类,如清单 3-13 所示。

package com.apress.bgn.zero;

public class Base {
    // code omitted
}

class HiddenBase {
    // you cannot see me
}

Listing 3-13Class with No Access Modifier

注意,Base类是在com.apress.bgn.zero包中声明的。如果我们试图在包com.apress.bgn.three中声明的类中创建一个HiddenBase类型的对象,IDE 将通过读取文本并拒绝提供任何代码完成来警告我们。更有甚者,会打开一个列出当前文件问题的标签,并带有一个非常明显的错误信息,如图 3-6 所示。

img/463938_2_En_3_Fig6_HTML.jpg

图 3-6

没有访问器修饰符错误的 Java 类

现在,接受这个肯定,并让它深入人心:一个没有访问修饰符的类对同一个包中的所有类(和其他类型)都是可见的。

在一个类中,定义了类成员:字段和方法。 9 在member-level处,除了前面提到的两个修改器之外,还可以应用另外两个修改器:private和protected。在member-level处,访问修饰符具有以下效果:

  • public与顶级相同;可以从任何地方访问该成员。

  • private表示该成员只能在声明了的类中访问。

  • protected表示该成员只能在声明包含它的类的包中访问,或者由另一个包中该类的任何子类访问。

  • none表示该成员只能从其自己的包中访问。

这看起来很复杂,但是一旦你开始写代码,你就会习惯了。在 Oracle 官方文档页面上,甚至还有一个成员可见性的表格,如表 3-1 所示。 10

表 3-1

成员级访问器范围

|

修饰语

|

班级

|

包裹

|

亚纲

|

世界

|
| --- | --- | --- | --- | --- |
| 公众的 | 是 | 是 | 是 | 是 |
| 保护 | 是 | 是 | 是 | 不 |
| 无(默认/包专用) | 是 | 是 | 不 | 不 |
| 私人的 | 是 | 不 | 不 | 不 |

为了全面了解该表如何应用到代码中,清单 3-14 中的类非常有用。

package com.apress.bgn.three.same;

public class PropProvider {
    public int publicProp;
    protected int protectedProp;
    /* default */ int defaultProp;
    private int privateProp;

    public PropProvider(){
        privateProp = 0;
    }
}

Listing 3-14PropProvider

a Java class with Members Decorated with Various Accessors

类PropProvider声明了四个字段/属性,每个都有不同的访问修饰符。字段privateProp只能在该类的主体中修改。这意味着该类的所有其他成员都可以读取并更改该属性的值。

在本书的这一点上,只有方法被认为是其他类型的成员。

但是类可以在另一个类的主体中声明。这样的类被称为嵌套类,可以访问包装在它周围的类的所有成员,包括私有成员。图 3-7 描述了修改后的PropProvider类,增加了一个额外的方法,名为printPrivate。该方法读取私有字段的值并打印出来。还声明了一个名为LocalPropRequester的嵌套类,私有字段在这个类中被修改(第 56 行)。

img/463938_2_En_3_Fig7_HTML.jpg

图 3-7

表 3-1 ,Java 代码中的列类访问器

图 3-7 是在 IntelliJ IDEA 中查看 Java 代码的截图。如果任何字段不可访问,则显示为红色。

表 3-1 中的第二列,即Package列,包含了与类PropProvider在同一个包中声明的类可访问的字段。图 3-8 描述了一个名为PropRequester的类试图修改类PropProvider中的所有字段。请注意,私有字段显示为鲜红色。这意味着该字段是不可访问的,IntelliJ IDEA 对此非常清楚。

img/463938_2_En_3_Fig8_HTML.jpg

图 3-8

表 3-1 ,Java 代码中的列包访问器

表 3-1 中的第三列,即Subclass列,包含了类别PropProvider的子类可访问的字段。一个subclass从一个被称为它的superclass的类中继承状态和行为。子类是使用extends关键字和超类名创建的。图 3-9 描绘了一个名为SubClassedProvider class 的类试图修改从PropProvider继承的所有字段。请注意,私有字段和没有访问器的字段以鲜红色显示。这意味着这些字段是不可访问的,IntelliJ IDEA 对此表现得非常明显。

img/463938_2_En_3_Fig9_HTML.jpg

图 3-9

表 3-1 ,Java 代码中的列子类访问器

img/463938_2_En_3_Figk_HTML.jpg在前面的例子中,没有访问器的字段是不可访问的,因为子类是在不同的包中声明的。如果子类在同一个包中移动,那么表 3-1 中Package列的规则适用。

表 3-1 中的第三列World适用于声明类PropProvider的包之外的所有类,它们不是该类的子类。图 3-10 描绘了一个名为AnotherPropRequester的类,它试图访问PropProvider中声明的所有字段。正如预期的那样,只有公共字段是可访问的,其余的都以红色显示。

img/463938_2_En_3_Fig10_HTML.jpg

图 3-10

表 3-1 ,Java 代码中的列世界访问器

img/463938_2_En_3_Figl_HTML.jpg如果你试图在像 IntelliJ IDEA 这样的智能编辑器之外构建一个项目,这是行不通的。这些错误消息会让您知道有一个编译错误,原因是什么,以及在哪里。例如,使用 Maven 构建工具构建包含AnotherPropRequester类的chapter03子项目会失败。终端中显示以下错误消息:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.8.1:compile (default-compile) on project chapter03: Compilation failure: Compilation failure:
[ERROR] /Users/iulianacosmina/apress/workspace/java-17-for-absolute-beginners/chapter03/src/main/java/com/apress/bgn/three/other/AnotherPropRequester.java:[42,17] protectedProp has protected access in com.apress.bgn.three.same.PropProvider
[ERROR] /Users/iulianacosmina/apress/workspace/java-17-for-absolute-beginners/chapter03/src/main/java/com/apress/bgn/three/other/AnotherPropRequester.java:[44,17] defaultProp is not public in com.apress.bgn.three.same.PropProvider; cannot be accessed from outside package
[ERROR] /Users/iulianacosmina/apress/workspace/java-17-for-absolute-beginners/chapter03/src/main/java/com/apress/bgn/three/other/AnotherPropRequester.java:[46,17] privateProp has private access in com.apress.bgn.three.same.PropProvider

构建工具和编辑器非常善于让您知道 Java 代码中什么地方出错了。学会用好它们,信任它们,它们会提高你的生产力。当然,有打嗝,但没有那么多。

开始编写 Java 代码后,您可能会回到表 3-1 一两次。即使引入了模块(如果您正确地配置了模块访问),刚才提到的一切仍然有效。

模块

从 Java 9 开始,引入了一个新概念:modules. 11 Java 模块代表了一种更强大的机制来组织和聚合包。这一新概念的实施花了 10 多年时间。关于模块的讨论始于 2005 年,希望它们能在 Java 7 中实现。2008 年,一个名为“拼图计划”的探索阶段终于开始了。Java 开发者希望 Java 8 能提供模块化的 JDK,但这并没有发生。

经过三年的工作(差不多七年的分析),Java 9 终于推出了模块。支持他们把 Java 9 的正式发布日期推迟到了 2017 年 9 月。 12

Java 模块是对包进行分组和对包内容进行更细粒度访问的一种方式。Java 模块是一组唯一命名的、可重用的包和资源(例如,XML 文件和其他类型的非 Java 文件),由位于源目录根目录下的名为module-info.java的文件描述。该文件包含以下信息:

  • 模块的名称

  • 模块的依赖项(即该模块依赖的其他模块)

  • 它显式地使其他模块可以使用的包(模块中的所有其他包隐式地对其他模块不可用)

  • 它提供的服务

  • 服务 it 消费者

  • 它允许反射到哪些其他模块

  • 本机代码

  • 资源

  • 配置数据

理论上,模块命名类似于包命名,并遵循反向域名约定。在实践中,只需确保模块名不包含任何数字,并且清楚地揭示了它的用途。module-info.java文件被编译成一个模块描述符,它是一个名为module-info.class的文件,与类一起打包成一个普通的旧 JAR 文件。该文件位于 Java 源目录的根目录下,在任何包之外。对于前面介绍的chapter03项目,module-info.java文件位于src/main/java目录下,与com目录处于同一层级;com.apress.bgn.three的根包图 3-11 。

img/463938_2_En_3_Fig11_HTML.jpg

图 3-11

module-info.java 文件的位置

任何带的文件。java 扩展,module-info.java被编译成。类文件。由于模块声明不是 Java 类型声明的一部分,module不是 Java 关键字,所以在为 Java 类型编写代码时仍然可以使用;例如,作为变量名。对于package来说,情况有所不同,因为每个 Java 类型声明都必须以包声明开始。看看清单 3-15 中声明的SimpleReader类就知道了。

package com.apress.bgn.three;

public class SimpleReader {
    private String source;

    // code omitted
}

Listing 3-15SimpleReader Class

可以看到包声明,但是模块在哪里?嗯,模块是一个抽象的概念,用module-info.java来描述。所以从 Java 9 开始,如果你在你的应用中配置 Java 模块,图 3-4 演变成图 3-12 。

img/463938_2_En_3_Fig12_HTML.png

图 3-12

可视化表示的 Java 模块

Java 模块是一种逻辑分组属于一起的 Java 包的方法。

模块的引入也允许将 JDK 划分成模块。java --list-modules命令列出了本地 JDK 安装中的所有模块。清单 3-16 描述了在我的个人电脑上执行的这个命令的输出,我的电脑目前安装了 JDK 17-ea。

$ java --list-modules
java.base@17-ea
java.compiler@17-ea
java.datatransfer@17-ea
java.desktop@17-ea

# output omitted

Listing 3-16JDK 17-ea Modules

在前面的清单中,每个模块名称后面都有一个版本字符串@17-ea,这意味着该模块属于 Java 版本 17-ea。

因此,如果一个 Java 应用不需要所有的模块,那么可以只使用它需要的模块来创建运行时,这就减小了运行时的大小。构建一个为应用定制的小型运行时的工具叫做jlink,是 JDK 可执行程序的一部分。这允许更高级别的可伸缩性和更高的性能。如何使用jlink不是本书的研究对象。这本书的重点是学习 Java 编程语言,因此 Java 平台的技术细节将保持在最低限度——仅够自信地开始编写和执行代码。

引入模块有很多好处,更有经验的开发人员已经等了很多年来利用这些好处。但是为更大更复杂的项目配置模块不是在公园散步,大多数软件公司要么坚持 JDK 8 要么完全避免配置模块。

module-info.java的内容可以简单到模块名和两个包含主体的括号,如清单 3-17 所示。

module chapter.three {
}

Listing 3-17A Simple module-info.java Configuration

高级模块配置

Java 模块声明体包含一个或多个使用表 3-2 中的关键字构建的directives。这些指令表示模块中包含的包和类的访问配置和依赖性要求。

这本书包含一个附录,提供了所有指令的例子。为了学习 Java 语言,你真正需要的只有requires和exports。在这本书的这一版中,我将在上下文允许完全理解它们的情况下深入解释每一个指令,并且我将添加与真实世界事件和场景的类比,以确保思想得到理解。在本章中,首先只解释两个主要指令。

模块可以相互依赖。本书的项目由 13 个模块组成,其中大部分依赖于模块chapter.zero。该模块包含用于在其他模块中构建更复杂组件的基本组件。例如,模块chapter.three中的类需要访问模块chapter.zero中的包和类。声明模块依赖关系是通过使用requires指令来完成的,如清单 3-18 所示。

module chapter.three {
    requires chapter.zero;
}

Listing 3-18A Simple module-info.java Configuration

前面的依赖关系是一个显式的依赖关系。但是也有隐含的依赖性。例如,开发者声明的任何模块都隐式需要 JDK java.base模块。这个模块包含了 Java SE 平台的基础 API,没有它就不能编写 Java 应用。这个隐式指令确保了对 Java 类型的最小集合的访问,因此可以编写基本的 Java 代码。清单 3-18 等同于清单 3-19 。

module chapter.three {
    requires java.base;

    requires chapter.zero;
}

Listing 3-19A Simple module-info.java Configuration with an Explicit Directive of requires java.base

img/463938_2_En_3_Figm_HTML.jpg根据需要声明一个模块,意味着当代码被编译时需要这个模块——通常称为编译时,当代码被执行时——通常称为运行时。如果一个模块只在运行时需要,需要静态的关键字用于声明依赖关系。现在只要记住这一点,当我们谈论 web 应用时,它就有意义了。

现在chapter.three依赖于模块chapter.zero。但这是否意味着chapter.three可以访问模块chapter.zero中所有包中的所有public类型(及其嵌套的public和protected类型)?如果你认为这还不够,那你就对了。仅仅因为一个模块依赖于另一个模块,并不意味着它可以访问它实际需要的包和类。所需模块必须配置为暴露其内部。如何做到这一点?在我们的例子中,我们需要确保模块chapter.zero能够访问所需的包。这是通过添加exports指令,后跟必要的包名,为这个模块定制module-info.java来完成的。清单 3-20 描述了chapter.zero模块的module-info.java文件,该文件公开了它的单个包。

module chapter.zero {
    exports com.apress.bgn.zero;
}

Listing 3-20The module-info.java Configuration File for the chapter.zero Module

请这样想:你在房间里裁剪圣诞装饰品,你需要一个装饰品模板。你的室友有所有的模板。但是你需要它并不代表它会神奇的出现。你需要去和你的室友谈谈。需要你室友的帮助可以被视为要求室友指令。和你室友聊完之后,他大概会说:当然可以,进来吧,它们在桌子上!你需要多少就拿多少。这可以被认为是出口所有桌面模板指令。桌子可能是包裹的一个很好的比喻。

使用清单 3-20 中的配置,我们刚刚授予了对com.apress.bgn.zero包的访问权,对任何配置了requires module.zero;指令的模块的访问权。如果我们不想这样呢?(考虑到前面的提示,您的室友只是让他房间的门开着,所以任何人都可以进入并获得那些模板!)

如果我们想将对模块内容的访问仅限于chapter.three模块呢?(所以你室友要把他的模板只给你。)这可以通过在模块名后添加to关键字来实现,以表明只有这个模块被允许访问组件。这是表 3-2 中提到的exports指令的合格版本。

表 3-2

Java 模块指令

|

管理的

|

目的

|
| --- | --- |
| 需要 | 指定该模块依赖于另一个模块。 |
| 电子竞技 | 模块的一个包,它的public类型(以及它们的nested public和protected类型)应该可以被所有其他模块中的代码访问。 |
| 出口…到 | 这是exports指令的合格版本。它允许在逗号分隔的列表中精确地指定哪个模块或模块代码可以访问导出的包。 |
| 打开 | 用于模块级声明(open module mm {}),并允许反射访问所有模块包。Java 反射是在运行时分析和修改一个类的所有功能的过程,也适用于私有类型和成员。所以在 Java 9 之前,没有什么是真正封装的。 |
| 打开 | 在模块声明体中使用,通过反射有选择地配置对特定包的访问。 |
| 打开…到 | 这是opens指令的合格版本。它允许在逗号分隔的列表中精确地指定哪个模块或模块代码可以反射性地访问它的包。 |
| 使用 | 指定该模块使用的服务—使该模块成为服务消费者。在这种情况下,服务代表另一个模块为其提供实现的接口/抽象类的全名。 |
| 为…提供 | 指定模块提供具有特定实现的服务——使模块成为服务提供者。 |
| 过渡的 | 与requires一起使用,指定对另一个模块的依赖,并确保读取你的模块的其他模块也读取该依赖——被称为隐含可读性。 |

如果你很好奇并阅读了推荐的 Jar Hell 文章,你会注意到使用 Jar 中打包的 Java 源代码的一个问题是安全性。这是因为即使不访问 Java 源代码,通过添加一个 jar 作为应用的依赖项,也可以检查、扩展和实例化对象。因此,除了提供可靠的配置、更好的可伸缩性、平台的完整性和改进的性能,引入模块的目标实际上是更好的安全性。

清单 3-21 描述了chapter.zero模块的module-info.java文件,它只向chapter.three模块公开它的单个包。

module chapter.zero {
    exports com.apress.bgn.zero to chapter.three;
}

Listing 3-21Advanced module-info.java Configuration File for the chapter.zero Module

通过用逗号分隔列出所需的模块,可以指定多个模块具有访问权限,如清单 3-22 所示。

module chapter.zero {
    exports com.apress.bgn.zero to chapter.two, chapter.three;
}

Listing 3-22Advanced module-info.java Configuration File for the chapter.zero Module with Multiple Modules

模块的顺序并不重要,如果有很多模块,你可以把它们放在多行上。只要确保用一个。(分号)。

这就是在本书的这个阶段所能介绍的关于模块的全部内容,但是不用担心:所有其他的指令将会在适当的时候介绍。

如何确定 Java 项目的结构

有几种方法可以构建 Java 项目,这取决于以下几点:

  • 项目范围

  • 使用的构建工具

您可能想知道为什么项目范围会影响它的结构,并且您可能期望这应该有一个标准,对吗?标准不止一个,这取决于项目范围。创建 Java 项目的原因会影响它的大小。如果一个项目很小,它可能不需要您将源代码分成子项目,也可能不需要构建工具,因为构建工具自带了组织项目的标准方式。让我们从有史以来最小的 Java 项目开始,它应该只打印“Hello World!”到控制台。

“HelloWorld!”IntelliJ 理念中的项目

顺便说一下,你甚至不需要一个项目,因为你有jshell。只需打开一个终端(Windows 的命令提示符),打开jshell,并输入System.out.print("Hello World!")语句,如清单 3-23 所示。

jshell>
|  Welcome to JShell -- Version 17-ea
|  For an introduction type: /help intro

jshell> System.out.print("Hello World!")
Hello World!

Listing 3-23jshell Hello World!

既然您已经安装了 IntelliJ IDEA,那么让我们创建一个 Java 项目,并检查编辑器为我们选择了什么样的项目结构。从第一个 IntelliJ IDEA 对话窗口开始,点击Create New Project option。第二个对话窗口将出现在顶部,左侧列出了您可以创建的项目类型。这里提到的两个对话框如图 3-13 所示。

img/463938_2_En_3_Fig13_HTML.jpg

图 3-13

创建 IntelliJ IDEA 项目配置启动对话框窗口

从左边选择 Java 项目类型,点击Next,不选择右边列出的任何附加库和框架。

下一个对话窗口允许您为项目选择一个模板。我们将通过点击Next跳过它。

下一个对话窗口允许您选择项目名称和位置。由于我们使用的是 Java 17,您可以注意到底部有一个用于配置 Java 模块的部分。该配置窗口如图 3-14 所示。

img/463938_2_En_3_Fig14_HTML.jpg

图 3-14

IntelliJ IDEA 项目名称和位置配置对话框窗口

使用sandbox作为项目名和模块名,并点击Finish。下一个窗口是编辑器窗口。这是你写代码的地方。如果您展开左边的sandbox节点(该部分被称为项目视图,您可以看到该项目是使用您已经安装的 JDK(在本例中是 17)构建的。已经为您创建了一个src目录。你的项目应该看起来很像图 3-15 中描述的那个。

img/463938_2_En_3_Fig15_HTML.jpg

图 3-15

IntelliJ IDEA 项目视图

在编写代码之前,让我们看看还有哪些项目设置可用。IntelliJ IDEA 允许您通过File > Project Structure菜单项查看和编辑项目属性。如果点击它,将会打开一个类似于图 3-16 所示的对话框。

img/463938_2_En_3_Fig16_HTML.jpg

图 3-16

IntelliJ IDEA 项目设置选项卡

默认情况下,Project设置选项卡打开。在上图中,有两个箭头将您的注意力吸引到了Project SDK:部分和Project language level:部分,前者实际上描述了一个 Java 项目的 JDK 版本。在写这一章的时候,JDK 17 EA 是最新的版本。IntelliJ IDEA 的最新版本支持 Java 17 的语法和代码完成,这也是这里描述的原因。这就是项目语言级别设置的意义。

如果切换到名为Modules的选项卡,您将看到图 3-17 中描述的信息。

img/463938_2_En_3_Fig17_HTML.jpg

图 3-17

IntelliJ IDEA 项目模块选项卡

之前的图像需要澄清。除了将包包装在一起的 Java 模块之外,模块也是将 Java 源代码和资源文件包装在一起的一种方式,在项目中具有共同的目的。在 Oracle 引入模块概念作为模块化 Java 应用的方法之前,组成这些应用的代码已经被需要以某种实用方式构建大型项目的开发人员模块化了。

在Modules选项卡中,您可以看到一个项目有多少部分(模块)以及每个部分的设置。项目有一个部分,一个名为sandbox,的模块,这个模块的源代码包含在src目录中。如果我们想写一个打印“Hello World!”名为 HelloWorld.java 的文件必须放在它下面。如果右键单击src目录,出现图 3-18 所示的菜单。

img/463938_2_En_3_Fig18_HTML.jpg

图 3-18

IntelliJ IDEA 菜单列出了可以在 src 目录中创建的 Java 对象

除了Java Class选项之外,还有几个红色箭头向您展示了在src目录中还有哪些其他组件。让我们继续创建我们的类。点击Java Class菜单选项,引入类名后,从测试字段下方的列表中选择Class。在图 3-19 中,你可以看到你可以创建的所有 Java 类型。

img/463938_2_En_3_Fig19_HTML.jpg

图 3-19

创建 Java 类型的 IntelliJ IDEA 对话框窗口

在本章的开始,提到了 Java 应用的核心构件是class,但是除此之外,Java 中还有其他类型。上图中的列表显示了列出的五种 Java 类型。每一个都将在后面详细解释;现在,请注意在src目录下创建了一个名为HelloWorld.java的文件,该文件的内容如清单 3-24 所示。

public class HelloWorld {

}

Listing 3-24The HelloWorld Class

您已经在第一个非常简单的 Java 项目中创建了第一个 Java 类。它还没有做任何事情。通过从 IntelliJ IDEA Build 菜单中选择Build Project选项,或者按下每个操作系统不同的组合键来编译该类。编译该类会产生包含字节码的HelloWorld.class文件。默认情况下,IntelliJ IDEA 将编译结果存储在一个名为out/production的目录中。编译项目的菜单选项和结果如图 3-20 所示。菜单选项以绿色突出显示。

img/463938_2_En_3_Fig20_HTML.jpg

图 3-20

IntelliJ IDEA:如何编译 Java 项目

是时候让我们的班级出版 Hello World 了!为此,我们需要给这个类添加一个特殊的方法。任何 Java 桌面应用都有一个名为main的特殊方法,必须在顶级类中声明。JRE 调用这个方法来运行 Java 程序/应用,我称它为入口点。如果没有这样的方法,Java 项目只是一个不可运行、不能执行、不能执行某个功能的类的集合。

img/463938_2_En_3_Figp_HTML.jpg想象一下:这就像有一辆汽车,但你无法启动它,因为点火锁芯不见了。从所有的意图和目的来看,它是一辆汽车,但它不能执行汽车的主要目的,即实际上带你去某个地方。您可以将main方法想象成点火锁圆柱体,JRE 将在其中插入钥匙以使您的应用运行。让我们将该方法添加到HelloWorld类中。

img/463938_2_En_3_Figq_HTML.jpg因为 IntelliJ IDEA 是一个很棒的编辑器,你可以通过输入psvm并按下<tab>键来生成main方法。这四个字母代表方法声明的所有组件的起始字母:【p】ublic、sstatic、 v oid、 m ain。

带有打印文本 Hello World 的main方法的HelloWorld类!如清单 3-25 所示。

public class HelloWorld {
    public static void main(String... args) {
        System.out.println();
    }
}

Listing 3-25The HelloWorld Class with the main Method

现在我们有了一个main方法,可以执行(或运行)代码了。为此,在 IntelliJ IDEA 中,您还有两个选项:

  • 从Run菜单中选择,选项Run [ClassName]

  • 或者在类体上单击右键,从出现的菜单中选择Run [ClassName].main()。 13

图 3-21 描述了你可以用来执行类的两个菜单项,以及执行的结果。

img/463938_2_En_3_Fig21_HTML.jpg

图 3-21

IntelliJ IDEA:如何执行 Java 类

这是 Java 项目最基本的结构。这个项目非常简单,也可以从命令行手动编译。让我们开始吧!

“HelloWorld!”从命令行编译和执行的项目

你可能已经注意到了 IntelliJ 想法中的Terminal按钮。如果你点击那个按钮,在编辑器里会打开一个终端。对于 Windows,它将是一个命令提示符实例,对于 Linux,macOs将是默认的 shell。IntelliJ 将在项目根目录下打开您的终端。接下来你要做的是:

  • 通过执行以下命令进入src目录:cd src

  • cd是一个也可以在 Windows 和 Unix 系统下工作的命令,是更改目录的缩写

  • 通过执行javac HelloWorld.java编译HelloWorld.java文件

  • 是一个用于编译 Java 文件的 JDK 可执行文件,IntelliJ IDEA 也在后台调用它

  • 通过执行:java HelloWorld运行从HelloWorld.class文件得到的字节码

图 3-22 描述了在 IntelliJ IDEA 的终端中这些命令的执行。

img/463938_2_En_3_Fig22_HTML.jpg

图 3-22

在 IntelliJ IDEA 内部的终端中手动编译和运行 HelloWorld 类

看起来很简单,对吧?实际上就是这么简单,因为没有定义包或 Java 模块。但是等等,这可能吗?是的。如果您没有定义一个包,该类仍然是一个未命名的默认包的一部分,该默认包由 JSE 平台默认提供,用于开发小型临时教育应用,就像您在这里构建的应用一样。

所以让我们把我们的项目变得复杂一点,为我们的类添加一个命名的包。

将“HelloWorld”类放在一个包中

在图 3-18 中,所列菜单中包含一个Package选项。右键单击src目录并选择它。一个小的对话框将会出现,你必须在这里输入包名。输入com.sandbox。在图 3-23 中描述了对话窗口。如果您尝试创建的包已经存在,则会以红色显示一条错误消息。

img/463938_2_En_3_Fig23_HTML.jpg

图 3-23

在 IntelliJ IDEA 中创建重复的包

现在我们有一个包,但是类不在其中。要将类放在那里,只需点击并拖动它。将出现另一个对话框,确认这是您真正想要做的,如图 3-24 所示。

img/463938_2_En_3_Fig24_HTML.jpg

图 3-24

在 IntelliJ IDEA 中将类移动到包中

点击Refactor按钮,查看课程发生了什么变化。这个类现在应该以一个package com.sandbox;声明开始。如果您重新构建您的项目,然后查看production目录,您将会看到类似于图 3-25 中描述的内容。

img/463938_2_En_3_Fig25_HTML.jpg

图 3-25

添加com.sandbox包后的新目录结构

显然,如果您手动编译并执行该类,您现在必须考虑这个包,因此您的命令将变为:

~/sandbox/src/> javac com/sandbox/HelloWorld.java
~/sandbox/src/> java com/sandbox/HelloWorld

那么当模块也被配置时会发生什么呢?有一个默认的未命名模块,所有 jar(无论是否模块化)和类路径上的类都将包含在其中。这个默认且未命名的模块导出所有包并读取所有其他模块。因为它没有名称,所以命名的应用模块不能请求和读取它。因此,即使您的小项目似乎可以与版本 9 或更高版本的 JDK 一起工作,它也不能被其他模块访问,但是它可以工作,因为它可以访问其他模块。(这确保了向后兼容旧版本的 JDK。)也就是说,让我们在项目中添加一个模块。

配置“com.sandbox”模块

配置一个模块就像在src目录下添加一个module-info.java文件一样简单。在图 3-18 中,菜单包含一个module-info.java选项,如果你选择了它,IDE 会为你生成文件。一切都很好,如果您不喜欢为您生成的模块名,您可以更改它。我把它改成了com.sandbox,以尊重 Oracle 开发人员建立的模块命名约定。文件最初是空的,如清单 3-26 所示。

module com.sandbox {
}

Listing 3-26The com.sandbox Module Configuration File

现在我们有了一个模块,会发生什么呢?从 IDEs 的角度来看不多。但是如果你想手动编译一个模块,你必须知道一些事情。我使用清单 3-27 中的命令编译了我们的模块。

~/sandbox/src/> javac -d ../out/com.sandbox \
    module-info.java \
    com/sandbox/HelloWorld.java

Listing 3-27Manually Compiling a Package Enclosed within a Module

img/463938_2_En_3_Figr_HTML.jpg“”是一个 macOS/Linux 分隔符。在 Windows 上,要么在一行中写下整个命令,要么用“^".”替换“\”

前面的命令是根据清单 3-28 中的模板构建的。

javac -d [destination location]/[module name] \
    [source location]/module-info.java \
    [java files...]

Listing 3-28Template for Command to Manually Compile a Package Enclosed Within a Module

-d [destination]决定了执行结果应该保存在哪里。清单 3-27 中的命令行将输出文件夹指定为/out/com.sandbox的原因是为了明确com.sandbox是封闭模块。在这个目录下,我们将拥有com.sandbox包的正常结构。out 目录的内容如图 3-26 所示。

img/463938_2_En_3_Fig26_HTML.jpg

图 3-26

手工编译的 Java 模块com.sandbox

正如您在本例中注意到的,在我们编译源代码之前,模块并不真正存在,因为 Java 模块更多的是封装由module-info.class描述符描述的包的逻辑模式。创建com.sandbox目录的唯一原因是我们在javac -d命令中将它指定为参数。

既然我们已经成功编译了一个模块,清单 3-29 向您展示了当HelloWorld类被封装在一个模块中时如何运行它。

~/sandbox/> java --module-path out \
  --module com.sandbox/com.sandbox.HelloWorld
  Hello World!  # result

Listing 3-29Manually Executing a Class Enclosed Within a Module

前面的命令是根据清单 3-30 中的模板构建的。

java --module-path [destination] \
    --module [module name] /[package name].HelloWorld

Listing 3-30Template for Command to Manually Execute a Class Enclosed Within a Module

2017 年 9 月的 Oracle Magazine edition 第一次提到了示例,尽管 Oracle 开发人员已经决定模块名称应该遵循与包相同的规则,但对我来说,这似乎有点多余,特别是在复杂的项目中,包名称往往会变得很长。模块名应该一样长吗?

事实是,人们制定了标准,大多数时候,实用性成为了标准。自 2007 年以来,成功采用模块的项目选择了更简单、更实用的模块名称。例如,创建 Spring 框架的团队决定将他们的模块命名为spring.core而不是org.springframework.core,命名为spring.beans而不是org.springframework.beans,等等。因此,只要避免使用特殊字符和数字,就可以随心所欲地命名模块。

使用构建工具的 Java 项目,主要是 Maven

Apache Maven 是一个主要用于 Java 项目的构建自动化工具。尽管 Gradle 正在取得进展,Maven 仍然是最常用的构建工具之一。Maven 和 Gradle 等工具用于将应用的源代码组织在相互依赖的项目模块中,并配置一种自动编译、验证、生成源代码、测试和生成工件的方式。工件是一个文件,通常是一个 JAR,它被部署到 Maven 存储库中。Maven 仓库是硬盘上的一个位置,jar 保存在一个特殊的目录结构中。

任何关于构建工具的讨论都必须从 Maven 开始,因为这个构建工具标准化了我们今天在开发中使用的许多术语。分割成多个子项目的项目可以从 GitHub 下载,在命令行中构建,或者导入 IntelliJ IDEA。这种方法将确保您获得可以一次性编译的高质量源代码。这也很实用,因为我想你不希望每次开始阅读新的章节时都在 IntelliJ IDEA 中加载新的项目。此外,它使我更容易维护源代码并使它们适应新的 JDK,由于 Oracle 发布如此频繁,我需要能够快速完成这项工作。

您将用来测试本书中编写的代码(如果您愿意,也可以编写自己的代码)的项目称为java-17-for-absolute-beginners。这是一个多模块的 Maven 项目。项目的第一级是java-17-for-absolute-beginners project,它有一个名为pom.xml的配置文件。在该文件中,列出了所有依赖项及其版本。第二层的子项目是这个项目的模块。我们称它们为子项目,因为它们从父项目继承了那些依赖项和模块。在它们的配置文件中,我们可以从父类中定义的列表中指定需要哪些依赖项。

这些模块实际上是将每章的源代码打包在一起的一种方法,这就是为什么这些模块被命名为chapter00、chapter01等等。如果一个项目很大,需要编写大量的代码,那么这些代码会被拆分到另一个模块级别。模块chapter05就是这样一个例子,它被配置为其下项目的父模块。在图 3-27 中你可以看到这个项目在 IntelliJ IDEA 中加载后的样子,模块chapter05被展开,所以你可以看到第三层的模块。每一层都标有相应的数字。

img/463938_2_En_3_Fig27_HTML.jpg

图 3-27

Maven 多级项目结构

如果你已经像在第章 2 中被教导的那样将它加载到 IntelliJ IDEA 中,你可以通过构建它来确保一切正常工作。你可以这样做:

img/463938_2_En_3_Fig28_HTML.jpg

图 3-28

Maven 项目视图

  • 你可以使用 IntelliJ IDEA 编辑器来完成,在它的右上方你应该有一个名为 Maven 的标签。如果项目如图 3-28 所示加载,则项目加载正确。如果 Maven 选项卡不可见,只需查找类似标有(1)的标签,然后单击它。展开java-17-for-absolute-beginners(根)节点,直到找到标有(2)的构建任务。如果双击它,在编辑器底部的视图中看不到任何错误,那么您的所有项目都已成功构建。所以,是的,您肯定会看到构建成功(3)的消息。

确保 Maven 项目按预期运行的第二种方法是从命令行构建它。打开一个 IntelliJ IDEA 终端,如果你按照章节 2 的说明在系统路径上安装了 Maven,只需键入mvn并点击<Enter>即可。

img/463938_2_En_3_Figs_HTML.jpg位于项目根目录下的主pom.xml文件有一个默认的目标,通过下面一行进行配置:

<defaultGoal>clean install</defaultGoal>

它声明了构建这个项目所需的两个 Maven 执行阶段。如果配置中没有这个元素,构建项目的两个阶段将在命令中指定,例如:mvn clean install。

在命令行中,如果 JDK 17 在你读到这本书时仍然不稳定,你可能会看到一些警告,但只要执行以构建成功结束,一切都没问题。

除了sandbox项目之外,这个项目非常简单,您可以自己创建,本节中提到的所有类、模块和包都是这个项目的一部分。chapter00和chapter01并不真正包含特定于这些章节的类;我只需要他们能够构建 Java 模块示例。IntelliJ IDEA 按照字母顺序对模块进行排序,所以章节模块的命名是这样选择的,这样它们就按照您应该使用它们的正常顺序排列。

到目前为止,本章一直关注 Java 应用的构建模块,我们创建了一个类来打印 Hello World!按照说明操作,但并没有解释所有的细节。让我们现在就这样做,甚至用新的细节来丰富课程。

解释和丰富“你好世界!”班级

之前,我们在sandbox项目中编写了一个名为HelloWorld的类。这个类被复制到包com.apress.bgn.three.helloworld中的chapter03项目中。本章从一个类的主要组件列表开始。HelloWorld类包含了一些将被详细解释的元素。在图 3-29 中,IntelliJ IDEA 编辑器中描述了HelloWorld类。

img/463938_2_En_3_Fig29_HTML.jpg

图 3-29

java-17 绝对初学者项目中的HelloWorld类

这些行包含不同的语句,这些语句在下面的列表中进行了解释,并且行号与列表中的编号相匹配。

  1. 包声明:当类是包的一部分时,它们的代码必须以声明包含它们的包的这一行开始。package是 Java 中的保留关键字,除了声明包之外不能用于任何其他用途。

    <空为了方便> : 留空,这样画面看起来更好

  2. 类声明:这是我们声明类型的那一行:

    • 它是public,所以从任何地方都能看到它

    • 这是一个class

    • 它被命名为HelloWord

    • 它有一个用花括号括起来的主体,左括号在这一行。但是它也可以在下一个上,因为空白空间被忽略。

  3. **main()方法声明 : 在 Java 中方法名及其参数的个数、类型、顺序被称为方法签名。一个方法也有一个返回类型,就像它返回的结果类型一样。但是也有一种特殊的类型可以用来声明一个方法不返回任何东西。按照出现的顺序,下面是main()方法的每个术语所代表的含义:

    • 公共方法访问器:main方法必须是public否则 JRE 无法访问和调用。

    • 静态:还记得本章开始时提到一个类有成员(字段和方法)吗?当创建该类类型的对象时,它具有该类声明的字段和方法。类是创建对象的模板。但是,因为有了static关键字,main方法不与类类型的对象相关联,而是与类本身相关联。下一章会有更多的细节。

    • void: 这个关键字在这里用来告诉我们main方法不返回任何东西,所以它就像是对 no type 的替换,因为如果不返回任何东西,就不需要类型。

    • string… args 或 String[] args: 方法有时被声明为接收一些输入数据;String[] args表示文本值的数组。三个点是指定一个方法可以有多个相同类型的参数的另一种方式。三点符号只能在方法参数中使用,称为 varargs 。(varargs 参数也必须是该方法的唯一参数,或者是最后一个参数,否则解析参数就成了不可能完成的工作。)这意味着您可以传入一个参数数组,而无需显式创建该数组。数组是固定长度的数据集,在数学上它们被称为一维矩阵或向量。String是 Java 中表示文本对象的类。[]是阵列的意思,args是它的名字。但是等等,我们以前运行过这个方法,我们不需要提供任何东西!这不是强制性的,但是您将看到如何在这个列表之后为它提供参数(提供给方法的值,将由代码在其主体中使用)。

    **
    *** system . out . println(" hello world!");是用来写 Hello World 的语句!在控制台中。

    * } 是main方法体的右括号。

    * } 是类体的右括号。

    **

如果我们执行这个类,我们将会看到Hello World!被打印在控制台中。在图 3-21 中,你已经看到了如何执行一个包含main()方法的类。以这种方式执行一个类后,IntelliJ IDEA 会自动将该执行的配置保存在一个运行配置**中,并在一个下拉列表中显示它,旁边是一个三角形绿色按钮,通过单击它可以用来执行该类,这两个按钮都位于 IDE 的标题上,并在图 3-29 中夸张地指向您。

这两个元素非常重要,因为可以编辑运行配置,并且可以为 JVM 和main方法添加参数。让我们首先修改main方法,先用参数做些事情。

public class HelloWorld {
    public static void main(String... args) {
        System.out.println("Hello " + args[0] + "!");
    }
}

Listing 3-31Main Method with varargs

使用数组元素的索引来访问数组,在 Java 中从 0 开始计数。因此,数组的第一个成员位于 0,第二个成员位于 1,依此类推。但是数组可以是空的,所以在前面的代码片段中,如果没有指定参数,程序的执行将会崩溃,并且在控制台中,将以红色显示一条显式消息。img/463938_2_En_3_Figv_HTML.gif

当一个 Java 程序因为执行期间的错误而结束时,我们说抛出了一个异常。

当我们试图访问一个空数组或者一个不存在的数组元素时,JVM 抛出一个类型为ArrayIndexOutOfBoundsException的对象,该对象包含发生故障的行和我们试图访问的元素的索引。JVM 使用异常对象来通知开发人员 Java 执行没有按预期工作时的异常情况,这些对象包含有关代码中发生异常的位置以及导致问题的原因的详细信息。

我们在前面的代码片段中所做的修改将在执行该类时打印作为参数提供的文本值。让我们修改这个类的运行配置并添加一个参数。如果点击运行配置名称旁边的灰色小箭头,将会出现一个菜单。点击编辑配置并查看向您描述的对话窗口。图 3-30 描绘了菜单和对话框窗口。

img/463938_2_En_3_Fig30_HTML.jpg

图 3-30

自定义运行配置

在图像中,关键元素以浅蓝色突出显示。IntelliJ IDEA 保存了您之前的一些执行,包括 Maven 构建任务,因此您只需单击一下就可以再次运行它们。在运行/调试配置对话窗口的左侧,IntelliJ IDEA 组按其类型运行配置。默认情况下,最后一次运行配置在窗口右侧打开,在本例中为HelloWorld类的运行配置。正如您所看到的,有许多选项可以为执行进行配置,并且大多数选项都是由 IDE 自动决定的。程序参数或main()方法的参数在用红色标记的文本字段中介绍。在图中我们引入了 JavaDeveloper ,所以如果你点击Apply然后Ok按钮,然后在控制台中执行这个类,而不是 Hello World!你现在应该看到了你好 JavaDeveloper!

我们班还能做什么?还记得这本书开头的代码吗?我们姑且称之为这个类中的main()方法。清单 3-32 中再次描述了代码。

package com.apress.bgn.three.helloworld;

import java.util.List;

public class HelloWorld {
    public static void main(String... args) {
        //System.out.println("Hello " + args[0] + "!");
        List<String> items = List.of("1", "a", "2", "a", "3", "a");

        items.forEach(item -> {
            if (item.equals("a")) {
                System.out.println("A");
            } else {
                System.out.println("Not A");
            }
        });
    }
}

Listing 3-32A More Complex main Method

import java.util.List;语句是包和类声明之间唯一存在的语句类型。这个语句告诉 Java 编译器一个对象类型java.util.List将在程序中使用。import关键字后面是类型的完全限定名。类型的完全限定名由包名(java.util)、点号(.)和类型的简单名(List)组成。没有它,HelloWorld类将无法编译。试试看:只要在语句前面加上“//”就行了,这会把这一行变成一个注释,被编译器忽略。你会看到编辑抱怨任何与列表相关的代码都是鲜红色的。

语句List<String> items = List.of("1", "a", "2", "a", "3", "a");创建一个文本值列表。Java 9 中引入了这种创建列表的方式。Java 5 中引入了使用<T>来指定列表中元素的类型,它被称为泛型。然后通过forEach方法逐个遍历列表中的元素,并对每个元素进行测试,看它们是否等于“ a 字符。用来做这件事的整个表达式被称为 lambda 表达式,这种类型的语法是在 Java 8 中与forEach方法一起引入的。

如果你现在运行这个类,在控制台中你应该看到一系列的、和而不是被打印出来,每一行都有一个。

Not A
A
Not A
A
Not A
A

到目前为止,我们编写的代码使用了相当多类型的对象在控制台中打印一些简单的消息。List对象用于保存几个String对象。使用在out对象上调用的println方法打印消息,该对象是System类中的一个静态字段。这些只是你在代码中看到的对象。在底层,List<T>元素由一个Consumer<T>对象处理,该对象是在 lambda 表达式为了简单起见而隐藏的地方创建的,因此前面的代码可以扩展,如清单 3-33 所示。

package com.apress.bgn.three.helloworld;

import java.util.List;
import java.util.function.Consumer;

public class HelloWorld {
    public static void main(String... args) {
        List<String> items = List.of("1", "a", "2", "a", "3", "a");

        items.forEach(new Consumer<String>() {
            @Override
            public void accept(String item) {
                if (item.equals("a")) {
                    System.out.println("A");
                } else {
                    System.out.println("Not A");
                }
            }
        });
    }
}

Listing 3-33A More Complex main Method

在结束这一章之前,我想给你看另一个有趣的东西。forEach块的内容可以写成一行:

items.forEach(item → System.out.println(item.equals("a") ? "A" : "Not A"));

通过使用一个叫做方法引用的东西,前面的代码行可以变得更加简单。但在本书后面会有更多的介绍。

现在看起来可能很可怕,但我保证这本书在清晰的背景下介绍了每个概念,并与现实世界的物体和事件进行了比较,所以你可以很容易地理解它。如果这不起作用,总会有更多的书,更多的博客,当然还有每个 JDK 的官方甲骨文页面,那里有相当好的教程。有志者事竟成。

img/463938_2_En_3_Figt_HTML.jpg还有,利用你的 IDE!通过在按下 Control/Command 键的同时单击代码中的任何对象类型,打开对象类的代码,您可以看到该类是如何编写的,并且可以直接在编辑器中阅读它的文档。作为一个练习,为forEach方法和System类做这件事。

大多数真正聪明的编辑器都有键位图:一组组合键,当它们被按在一起时会执行某些操作,比如导航、代码生成、执行等等。打印 IntelliJ IDEA 键图参考并熟悉它。你的大脑非常快,在编码时,目标是尽可能以你认为的速度打字。😃

摘要

本章向您介绍了 Java 应用的基本模块。还学习了如何使用 JShell 在应用的上下文之外执行 Java 语句。您了解了如何手工编译声明包和模块的 Java 代码。

你在学习本章时所做的许多事情,你可能会在获得一份 Java 开发人员的工作后每天都做(除了你将花费在搜索和修复现有代码中的 bug 的那几天)。您可能还会花很多时间阅读文档,因为 JDK 有很多类,其中包含您可以用来编写应用的字段和方法。随着每个版本的发布,事情都在变化,你必须让自己跟上时代。

大脑的容量有限;没有雇主会期望你知道每一个 JDK 类和方法,但是要聪明地工作,让这个 URL https://docs.oracle.com/en/java/javase/17/docs/api/index.html (或者与 JDK 版本匹配的那个)一直在你的浏览器中打开。当你对一个 JDK 类或方法有疑问时,只需当场阅读它。

**

四、Java 语法

语言是人与人之间口头或书面的交流方式。不管它们是自然的还是人工的,它们都是由如何使用它们来完成交流任务的术语和规则组成的。编程语言是与计算机交流的手段。与计算机的通信是书面通信;基本上,开发人员定义一些要执行的指令,通过中介将它们传递给计算机,如果计算机理解它们,则执行一组操作,并且根据应用类型,将某种类型的回复返回给开发人员。

在 Java 语言中,通信是通过一个中介——Java 虚拟机来完成的。定义术语应该如何连接以产生可理解的通信单元的一组编程规则被称为语法。Java 借用了另一种叫做 C++的编程语言的大部分语法。C++有一个基于 C 语言语法的语法。c 语法借用了在它之前的其他语言的元素和规则,但本质上所有这些语言都是基于自然英语的。也许由于 lambda 表达式的引入,Java 在版本 8 中变得有点神秘,但是,当编写 Java 程序时,如果你用英语正确地命名你的术语,结果应该是一个易读的代码,就像一个故事。

一些细节已经在章 3 中有所涉及。包和模块的内容足以让你对它们的目的有一个坚实的理解,以避免对项目组织的混淆,并避免在试图执行书中提到的代码时漫无目的地摸索代码。但是在其他话题上,表面几乎没有触及。因此,让我们开始深入研究 Java。

编写 Java 代码的基本规则

在编写 Java 代码之前,让我们列出一些您应该遵循的规则,以确保您的代码不仅可以工作,而且易于理解,从而可以维护或扩展。让我们通过添加一些细节来描绘一下我们结束时使用的类章 3 :

01\. package com.apress.bgn.four.basic;
02.
03\. import java.util.List;
04.
05./**
06\.  * this is a JavaDoc comment
07\. */
08\. public class HelloWorld {
09\.     public static void main(String... args) {
10\.         //this is a one-line comment
11\.         List<String> items = List.of("1", "a", "2", "a", "3", "a");
12.
13\.         items.forEach(item -> {
14\.             /* this is a
15\.                 multi-line
16\.             comment */
17\.             if (item.equals("a")) {
18\.                 System.out.println("A");
19\.             } else {
20\.                 System.out.println("Not A");
21\.             }
22\.         });
23\.     }
24\. }

Listing 4-1The HelloWorld Class with Comments

代码的每一部分在本章中都有自己的章节。让我们从第一行开始。

包装声明

如果文件中声明的类型是在一个包中声明的,那么 Java 文件以包声明开始。包名可以包含字母和数字,用点分隔。每个部分都将路径中的一个目录匹配到其中包含的类型,如章 3 所示。包声明应该揭示应用的名称和包中类的用途。就拿这本书的来源所用的包命名来说:com.apress.bgn.four.basic。如果我们将包名分成几部分,这是每一部分的含义:

  • com.apress表示应用的域,或者在这种情况下谁拥有该应用。

  • bgn代表代码的范围,在本例中是为谁写的书:初学者。

  • four代表类的用途,与章 4 一起使用。

  • basic表示类的目的的更精细的级别;这些类很简单,用来描述基本的 Java 概念。

像这里介绍的由更多部分组成的包名称为合格包名。它有一个层次结构,包com是根包。假设在这个包中声明了一个类型MyType,使用这个 import 语句:import com.MyType;在其他包的类中引用这个类型。

包apress是包com的一个成员,由一个名字来标识,这个名字由它自己的名字加上一个点组成。假设在这个包中声明了一个类型MyType,使用这个 import 语句:import com.apress.MyType;在其他包的类中引用这个类型。

这同样适用于包bgn,它是包apress及其类型成员的成员,依此类推。

你可以把软件包想象成俄罗斯套娃的编程等价物。

因此,一个类型通过它的完全限定名在其他类型中被引用。类型的完全限定名是通过在类型名前面加上包的限定名和一个点形成的。图 4-1 应该让事情变得非常清楚。

img/463938_2_En_4_Fig1_HTML.jpg

图 4-1

剖析 Java 类型的完全限定名

进口部分

在包裹申报之后,接着是导入部分。本节包含文件中使用的所有类、接口和枚举的完全限定名。请看清单 4-2 中的代码示例。

package java.lang;

import java.io.ObjectStreamField;
import java.io.UnsupportedEncodingException;
import java.lang.annotation.Native;
import java.lang.invoke.MethodHandles;
import java.lang.constant.Constable;
import java.lang.constant.ConstantDesc;
import java.nio.charset.Charset;
import java.util.ArrayList;
// the rest of import statements omitted

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence,
               Constable, ConstantDesc {

    private static final ObjectStreamField[] serialPersistentFields =
    new ObjectStreamField[0];

    // the rest of the code omitted
}

Listing 4-2Small Code Snippet from the java.lang.String Class

这是官方 Java String类的一个片段。每个 import 语句代表一个对包的引用,以及在String类体内使用的类名。

特殊的导入语句可以用来导入static变量和static方法。在 JDK,有一个用于数学过程的课程。它包含静态变量和方法,开发人员可以使用它们来实现解决数学问题的代码。您可以使用它的变量和方法,而不需要创建这种类型的对象,因为静态成员不属于某个类型的对象,而是属于该类型本身。检查清单 4-3 中的代码。

package com.apress.bgn.four;

import static java.lang.Math.PI;
import static java.lang.Math.sqrt;

public class MathSample {
    public static void main(String... args) {
        System.out.println("PI value =" + PI);
        double result = sqrt(5.0);
        System.out.println("SQRT value =" + result);
    }
}

Listing 4-3Using Static Imports for Members of Class Math

通过将import和static放在一起,我们可以声明一个类的完全限定名以及我们感兴趣的在代码中使用的方法或变量。这允许我们直接使用变量或方法,而不需要声明它的类名。如果没有静态导入,代码将不得不重写,如清单 4-4 所示:

package com.apress.bgn.four;

import java.lang.Math;

public class MathSample {
    public static void main(String... args) {
        System.out.println("PI value =" + Math.PI);
        double result = Math.sqrt(5.0);
        System.out.println("SQRT value =" + result);
    }
}

Listing 4-4Using Import of Class Math

完全限定名是一种强大的东西。包名在一个模块中是唯一的,但是包名在一个应用中并不总是唯一的。类型名在应用中也不总是唯一的,但是由两者组合而成的完全限定类型名在应用中是唯一的。

你也可以把包裹想象成家庭地址,把类型想象成人。两个人可以有相同的地址,但不能有相同的名字。两个人可以有相同的名字,住在不同的地址。例如,在英国或美国,银行和其它机构就是这样识别个人身份的。

完全限定名不限于 import 语句。当两个类型具有相同的名称并且都用于声明第三个类型时,能够在类型体中告诉编译器您打算使用哪个类型的唯一方法是使用完全限定名。清单 4-5 中显示了一个这样的例子,其中包com.apress.bgn.four.math中的类Math被用在一个类的主体中,该主体中也使用了java.lang.Math类的成员。

package com.apress.bgn.four.math;

import java.lang.Math;

public class Sample {
    public static void main(String... args) {
        System.out.println("PI value =" + Math.PI);

        System.out.println("My PI value= " + com.apress.bgn.four.math.Math.PI);
    }
}

Listing 4-5Using a Member of Class com.apress.bgn.four.math.Math

当同一个包中使用了多个类型时,类型名可以用一个*(星号)代替,这意味着包中任何可见的类型都可以用于正在编写的类型的代码中。这些被称为紧凑型进口报表。当使用同一个包中的多个类编写代码,或者使用同一个类中的多个静态变量和方法时,建议压缩导入。这样做时,文件的导入部分变得冗长,难以阅读。这就是压缩发挥作用的地方。压缩导入意味着用通配符替换同一个包中的所有类或者同一个类中的变量和方法,因此只需要一条 import 语句。它也适用于静态导入。所以之前的MathSample类变成了清单 4-6 中的那个。

package com.apress.bgn.four;

import static java.lang.Math.*;

public class MathSample {
    public static void main(String... args) {
        System.out.println("PI value =" + PI);
        double result = sqrt(5.0);
        System.out.println("SQRT value =" + result);
    }
}

Listing 4-6Using Compacted Imports

Java 语法

Java 语言是区分大小写的 ,,这意味着我们可以编写一段类似清单 4-7 中描述的代码,并且代码可以成功编译和执行。

package com.apress.bgn.four;

public class Sample {
    public static void main(String... args) {
        int mynumber = 0;
        int myNumber = 1;
        int Mynumber = 2;
        int MYNUMBER = 3;
        System.out.println(mynumber);
        System.out.println(myNumber);
        System.out.println(Mynumber);
        System.out.println(MYNUMBER);
    }
}

Listing 4-7Java Code Proving Its Case Sensitivity

四个变量都不同,最后四行打印出数字:0 1 2 3。显然,你不能在同一个上下文中声明两个同名的变量(例如,在一个方法体中),因为你基本上是在重新声明同一个变量。Java 编译器不允许这样做。如果你试图这样做,你的代码将不会编译,甚至 IntelliJ IDEA 会试图让你看到你的方式的错误,用红色下划线给你显示相关的消息,如图 4-2 ,其中变量mynumber被声明了两次。

img/463938_2_En_4_Fig2_HTML.jpg

图 4-2

同一个变量名使用了两次

在 Java 代码中,有一组 Java 关键字只能用于固定和预定义的目的。其中几个已经介绍过了:import、package、public和class,但其余的将在本章末尾的表格 4-2 和 4-3 中对其进行简要说明。

img/463938_2_En_4_Figd_HTML.jpg Java 关键字不能在开发者编写的代码中作为标识符,所以不能作为变量、类、接口、对象等的名称。

一个 Java 源文件中可以声明一个或多个类型。无论是class、interface(或@interface)、enum,还是class,类型的声明都必须用花括号( {} )括起来。这些被称为块分隔符。import和package语句不是类型体的一部分。如果你看一下清单 4-1 中的代码,你会注意到括号被用来包含以下内容:

  • 类的内容,也称为类的主体(第 08 行和第 23 行的括号)

  • 方法的内容,也称为方法体(第 09 行和第 22 行的括号)

  • 要一起执行的一组指令(第 13 行和第 21 行的括号)

行终止符:代码行在 Java 中通常以分号(;)符号或 ASCII 字符 CR、LF 或 CR LF。分号用于终止完整的功能语句,如第 11 行中的列表声明。在小型监视器上,当编写代码时,您可能被迫将该语句分成两行,以保持代码的可读性。末尾的分号告诉编译器,只有把所有这些放在一起,这个语句才是正确的。看一下图 4-3 :

img/463938_2_En_4_Fig3_HTML.jpg

图 4-3

不同的陈述示例

前三个List声明是等价的。当以这种方式声明一个List时,您甚至可以将它的元素分成多行。然而,第 46 行的声明故意写错了。第 46 行添加了一个分号,该行结束该语句。该语句是无效的,当您试图通过打印一个异常来编译该类时,编译器会对此进行抱怨:

错误:(13,46) java:表达式的非法开始。

如果错误消息似乎不符合示例,请这样想:编译器的问题不是错误地终止了语句,而是在“=”符号之后,编译器期望找到某种表达式来生成badList变量的值,但是却什么也没有找到。

Java 标识符和变量

一个标识符是你在 Java 代码中给一个项目起的名字:类、变量、方法等等。标识符必须遵守一些允许代码编译的规则,以及常识性的编程规则,称为 Java 编码约定。下面列出了其中的一些:

  • 标识符不能是 Java 保留字之一,否则代码将无法编译。

  • 标识符不能是布尔文字(true、false)或null文字,否则代码将无法编译。

  • 标识符可以由字母、数字和_(underscore)、$(dollar sign).中的任何一个组成

  • 标识符不能以数字开头

  • 从 Java 9 开始,单个_(下划线)不能再用作标识符,因为它变成了关键字。这可能是因为在 Java 7 中引入了数字文字,多位数的数字可以用更易读的方式书写(例如,int i = 10_000;)。

  • 开发人员应该按照 camel case 的书写风格声明他们的标识符,确保标识符名称中间的每个单词或缩写都以大写字母开头(例如StringBuilder、isAdult)。

一个变量是一组可以与一个值相关联的字符。它有一个类型,基于该类型,可以分配给它的值集被限制在某个区间、值组,或者必须遵循该类型定义的某个格式。例如:清单 4-1 中第 11 行声明的项是一个List类型的变量,与之关联的值是一个值列表。

在 Java 中有三种类型的变量:

  • 字段(也称为属性)是在方法体之外的类体中定义的变量,它们前面没有关键字static。

  • 局部变量是在方法体内声明的变量,它们只在那个上下文中相关。

  • 静态变量是在类体内声明的变量,前面有关键字static。如果它们被声明为public,那么无论封闭类型在哪里,它们都可以在应用中被访问。(除非模块不在声明它们的地方导出包,也就是说。)

Java 注释

Java 注释指的是不属于正在执行的代码的一部分并且被编译器忽略的解释性文本。有三种方法可以在 Java 代码中添加注释,这取决于用来声明注释的字符。清单 4-1 中使用了所有三种类型的注释,下面的列表解释了每种注释的用途:

  • //用于单行注释(第 10 行)。开发人员使用这种类型的注释来添加 TODO 语句或解释为什么需要某段代码。这些评论主要是为从事该项目的团队成员准备的。

  • /** ... */ JavaDoc 注释,使用特殊工具导出到名为 JavaDoc API 的项目文档中的特殊注释(第 05 到 07 行)。开发人员使用这种类型的注释来记录他们的代码。有一些构建工具的插件可以从项目中提取 JavaDoc 作为网站,然后可以公开托管,以帮助其他开发人员使用您的项目。

  • /* ... */用于多行注释(第 14 行到第 16 行)。开发人员使用这种类型的注释来添加 TODO 语句或解释为什么需要某段代码,当解释相当长时。这些评论主要是为从事该项目的团队成员准备的。

Java 类型

在章节 3 中介绍 Java 构建模块时,为了简单起见,只提到了class。前面提到 Java 中还有其他类型,本节将介绍所有类型。类是最重要的,所以会先覆盖。

班级

前面提到过,类只是创建对象的模板。基于类创建一个对象被称为实例化。产生的对象被称为,是那个类的一个实例。实例被命名为对象,因为默认情况下,如果没有声明其他超类,开发者编写的任何类都会隐式扩展类java.lang.Object。这意味着在 Java 中,所有的类都有一个基本的模板,这个模板由java.lang.Object类表示。默认情况下,任何类都是这个类的扩展,所以清单 4-8 中的类声明等同于清单 4-9 中的类声明。

package com.apress.bgn.four.basic;

public class Sample extends Object {
}

Listing 4-9Simple Sample Class Explictly Extending the java.lang.Object Class

package com.apress.bgn.four.basic;

public class Sample {
}

Listing 4-8Simple Sample Class Implicitly Extending the java.lang.Object Class

另外,请注意导入java.lang包是不必要的,因为Object类是 Java 层次结构的根类,所有的类(包括数组)都必须能够扩展它。因此java.lang包也是隐式导入的。

img/463938_2_En_4_Fige_HTML.jpg在章节 ** 3 ** 中提到,在任何声明了module-info.java的 Java 项目中,都会根据需要隐式添加java.base模块。该模块导出包含编写 Java 代码的核心组件的java.lang包。

img/463938_2_En_4_Figf_HTML.jpg每一个人都是由一个包含 23 对染色体的 DNA 分子定义的。他们宣称一个人应该拥有的器官和肢体看起来和功能就像一个…。人类。您可以将Object类视为 DNA 分子,它声明了一个类在 Java 应用中作为一个类应该具有的外观和功能的所有组件。

还有其他模板类型可用于在 Java 中创建对象。在下面的章节中,我们将介绍它们并解释它们的用途。但是让我们在一个背景下这样做。我们将创建一系列模板来定义人类。大多数 Java 教程使用车辆或几何形状的模板。我想建立一个任何人都能容易理解和联系的模型。下面几节的目的是开发可以用来为不同类型的人建模的 Java 模板。到目前为止提到的第一个 Java 模板是类,所以让我们继续。

菲尔茨

创建实例的操作被称为实例化。要设计一个模拟普通人类的类,我们应该考虑两件事:人类特征和人类行为。所有人类的共同点是什么?很多,但是为了本节的目的,让我们选择三个通用属性:它们有一个名字、年龄和身高。这些属性在 Java 类中映射到名为字段或属性的变量。清单 4-10 中描述了第一个版本的Human类。

package com.apress.bgn.four.base;

public class Human {
    String name;
    int age;
    float height;
}

Listing 4-10Simple Human Class

在前面的代码示例中,字段具有不同的类型,这取决于应该与哪些值相关联。例如,name 可以与一个文本值相关联,比如“Alex”,而文本在 Java 中由String类型表示。年龄可以与数字整数值相关联,类型int也是如此。在本节中,我们认为人的身高是一个像 1.9 这样的有理数,所以我们对这种值使用了特殊的 Java 类型:float。

所以现在我们有一个类来模拟人类的一些基本属性。我们如何使用它?我们需要一个main(..)方法,我们需要创建一个这种类型的对象:我们需要实例化这个类。在清单 4-11 中,创建了一个名为“亚历克斯”的人。

package com.apress.bgn.four.base;

public class BasicHumanDemo {
    public static void main(String... args) {
        Human human = new Human();
        human.name = "Alex";
        human.age = 40;
        human.height = 1.91f;
    }
}

Listing 4-11Simple Human Object Being Created

为了创建一个Human实例,我们使用了new关键字。在 new 关键字之后,我们调用一个叫做构造函数的特殊方法。我们以前提到过方法,但是这个方法比较特殊。一些开发人员甚至不认为它是一种方法。最显而易见的原因是,它在人类的身体中没有被定义。那么它是从哪里来的呢?它是一个没有参数的默认构造函数,由编译器自动生成,除非声明了一个显式的构造函数(有或没有参数)。没有构造函数,类就不能存在,否则它就不能被实例化,这就是为什么如果没有显式声明,编译器会生成一个构造函数。默认构造函数调用super(),,后者调用Object无参数构造函数,用默认值初始化所有字段。清单 4-12 中的代码示例对此进行了测试:

package com.apress.bgn.four.base;

public class BasicHumanDemo {
    public static void main(String... args) {
        Human human = new Human();
        System.out.println("name: " + human.name);
        System.out.println("age: " + human.age);
        System.out.println("height: " + human.height);
    }
}

Listing 4-12Simple Human Object Being Created Without Setting Values or Its Fields

你认为会发生什么?如果你认为某些默认值(中性)会被打印出来,那你绝对是对的。清单 4-13 描述了当清单 4-12 中的代码被执行时,在控制台中打印的输出。

name: null
age: 0
height: 0.0

Listing 4-13Default Values for the Fields of a Simple Human Object

注意,数值变量用 0 初始化,String值用null初始化。原因是数字类型是原始数据类型,而String是对象数据类型。String类是java.lang package的一部分,是用于创建String类型对象的预定义 Java 类之一。它是一种特殊的数据类型,用于表示文本对象。我们将在下一章深入探讨数据类型。

类别变量

除了每个人所特有的特征之外,所有人都有一个共同点:寿命,在以后的时间里被认为是 100 岁。声明一个名为 lifetime 的字段是多余的,因为它必须与所有人类实例的相同值相关联。因此,我们将在Human类中使用static关键字声明一个字段,该字段对于所有人工实例都具有相同的值,并且只初始化一次。我们还可以更进一步,通过在声明前添加final修饰符,确保该值在程序执行过程中不会改变。这样,我们创建了一个特殊类型的变量,称为常数。清单 4-14 中描述了新的Human类:

package com.apress.bgn.four.base;

public class Human {
    static final int LIFESPAN = 100;
    String name;
    int age;
    float height;
}

Listing 4-14Simple Human Class with a Constant Member

LIFESPAN变量也被称为类变量,因为它不与实例相关,而是与类相关。(它被设置为 100,这是一个非常乐观的值。)清单 4-15 中的代码清楚地表明了这一点:

package com.apress.bgn.four.base;

public class BasicHumanDemo {
    public static void main(String... args) {
        Human alex = new Human();
        alex.name = "Alex";
        alex.age = 40;
        alex.height = 1.91f;

        Human human = new Human();
        System.out.println("Alex’s lifespan = " + alex.LIFESPAN);  // prints 100
        System.out.println("human’s lifespan = " + human.LIFESPAN);  // prints 100
        System.out.println("Human lifespan = " + Human.LIFESPAN);  // prints 100
    }
}

Listing 4-15Code Sample Testing a Constant

封装数据

我们定义的类没有在字段上使用访问修饰符,这是不可接受的。Java 是众所周知的面向对象编程语言,因此用 Java 编写的代码必须遵守 面向对象编程(OOP) 的原则。遵守这些编码原则可以确保编写的代码质量良好,并且完全符合基本的 Java 风格。OOP 的原则之一是封装。封装原则是指通过使用称为访问器(getters)和赋值器(setters)的特殊方法限制对数据的访问来隐藏数据实现。

基本上,类的任何字段都应该有私有访问,对它的访问应该由方法控制,这些方法可以被拦截、测试和跟踪,以查看它们被调用的位置。使用对象时,Getters 和 setters 是一种常见的做法;大多数 ide 都有默认选项来生成它们,包括 IntelliJ IDEA。只需在类体内单击鼠标右键,选择 Generate 选项查看所有可能性,并选择getter 和 setter为您生成方法。菜单如图 4-4 所示。

img/463938_2_En_4_Fig4_HTML.jpg

图 4-4

IntelliJ IDEA 代码生成菜单:生成➤ Getter 和 Setter 子菜单

在将字段私有并生成 getter 和 setter 之后,Human类现在看起来如清单 4-16 所示。

package com.apress.bgn.four.base;

public class Human {
    public static final int LIFESPAN = 100;
    private String name;
    private int age;
    private float height;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public float getHeight() {
        return height;
    }

    public void setHeight(float height) {
        this.height = height;
    }
}

Listing 4-16Simple Human Class with Getters and Setters

看了前面的代码清单后,您可能想知道this关键字的用途是什么。正如关键字提示的那样,它是对当前对象的引用。所以this.name实际上是当前对象的字段名的值,也称为实例变量。在类体内,当方法中有同名的参数时,这用于访问当前对象的字段。正如你所看到的,IntelliJ IDEA 生成的设置器和获取器具有与字段完全相同的参数名称。

getter 是最简单的方法,声明时不带任何参数,返回与它们相关联的字段的值,以及它们的名称的编码约定,名称由前缀get和它们访问的字段的名称组成,首字母大写。

Setters 是不返回任何内容的方法,它将需要与字段关联的相同类型的变量声明为参数。它们的名称由前缀set和它们访问的字段名称组成,首字母大写。当编辑器生成 setter 时,参数名与实例变量名匹配,需要使用关键字this在 setter 主体的上下文中区分这两者。

图 4-5 描述了名称字段的设置器和获取器。

img/463938_2_En_4_Fig5_HTML.jpg

图 4-5

用于name字段的 Setter 和 getter 方法

这意味着当创建一个Human实例时,我们必须使用 setters 来设置字段值,并在访问它们时使用 getters。因此我们的类BasicHumanDemo变成了清单 4-17 中描述的那个。

package com.apress.bgn.four.base;

public class BasicHumanDemo {
    public static void main(String... args) {
        Human alex = new Human();
        alex.setName("Alex");
        alex.setAge(40);
        alex.setHeight(1.91f);

        System.out.println("name: " + alex.getName());
        System.out.println("age: " + alex.getAge());
        System.out.println("height: " + alex.getHeight());
    }
}

Listing 4-17BasicHumanDemo Class

Using Human Instance with Getters and Setters

大多数 Java 框架在类中寻找 getters 和 setters 来初始化或读取对象字段的值。Setters 和 getters 被大多数开发人员认为是样板代码(或者只是样板);在多个地方重复出现的代码段几乎没有变化。这就是为什么龙目 1 库诞生了——在运行时生成它们,这样开发者就不必用它们污染他们的代码。科特林语把它们完全删除了。

Java 在版本 14 中做了类似的事情,引入了记录。记录将在本章的稍后部分讨论。

方法

既然 getters 和 setters 都是方法 ,那么也是时候开始讨论方法了。方法是一个代码块,通常由返回的类型、名称和参数(需要时)来表征,它描述了由对象完成的或在对象上完成的动作,该动作利用了它的字段和/或提供的参数的值。清单 4-18 中描述了一个 Java 方法的抽象模板。

[accessor] [returned type] [name] (type1 param1, type2 param2, ...) {
// code
[ [maybe] return val]
}

Listing 4-18Method Declaration Template

接下来,让我们为类Human创建一个方法,通过使用人类的年龄和LIFESPAN constant来计算和打印人类还能活多久。因为该方法不返回任何东西,所以使用的返回类型将是void。void是一种特殊类型,它告诉编译器该方法不返回任何东西,因此在方法体中不存在 return 语句。清单 4-19 中描述了该方法的代码。

package com.apress.bgn.four.base;

public class Human {
    static final int LIFESPAN = 100;
    private String name;
    private int age;
    private float height;

    /**
     * compute and prints time to live
     */
    public void computeAndPrintTtl(){
        int ttl = LIFESPAN - this.age;
        System.out.println("Time to live: " + ttl);
    }

    // some code omitted
}

Listing 4-19Human#computeAndPrintTtl Method

with No Return Value

Java 中有一个关于常量命名的编码约定,建议只使用大写字母、下划线(替换组合名称中的空格字符)和数字来组成它们的名称。

前面的方法定义没有声明任何参数,所以可以在一个Human实例上调用它,如清单 4-20 所示。

package com.apress.bgn.four.base;

public class BasicHumanDemo {
    public static void main(String... args) {
        Human alex = new Human();
        alex.setName("Alex");
        alex.setAge(40);
        alex.setHeight(1.91f);
        alex.computeAndPrintTtl();
    }
    // some code omitted
}

Listing 4-20The computeAndPrintTtl() Method Call

当执行清单 4-20 中的代码时,控制台中会打印出“生存时间:60”。

可以修改前面的方法来返回生存时间值,而不是打印它。必须修改方法以声明返回值的类型,在这种情况下,类型是 int,与方法体中计算的值的类型相同。清单 4-21 中描述了该实现。

package com.apress.bgn.four.base;

public class Human {
    static final int LIFESPAN = 100;
    private String name;
    private int age;
    private float height;

    /**
     * @return time to live
     */
    public int getTimeToLive(){
        int ttl = LIFESPAN - this.age;
        return ttl;
    }

    // some code omitted
}

Listing 4-21The getTimeToLive() Method

with Return Value

在这种情况下,调用该方法没有任何作用,所以我们必须修改代码来保存返回值并打印它,如清单 4-22 所示。

package com.apress.bgn.four.base;

public class BasicHumanDemo {
    public static void main(String... args) {
        Human alex = new Human();
        alex.setName("Alex");
        alex.setAge(40);
        alex.setHeight(1.91f);
        int timeToLive = alex.getTimeToLive();
        System.out.println("Time to live: " + timeToLive);
    }
    // some code omitted
}

Listing 4-22Using the getTimeToLive() Method

这里介绍的两种方法都没有声明参数,所以它们在调用时没有提供任何参数。我们不会讨论带参数的方法,因为设置器非常明显。让我们跳过前面。

构造器

现在我们已经完成了,我们不能再在其他类中使用alex.name而编译器不会抱怨不能访问那个属性。此外,在调用所有这些 setters 时,仅仅设置这些属性就很烦人,所以应该对此做些什么。还记得隐式构造函数吗?开发人员也可以显式声明构造函数,一个类可以有多个构造函数。可以用每个感兴趣的字段的参数来声明构造函数。清单 4-23 描述了一个Human类的构造函数,它用参数值初始化类字段。

package com.apress.bgn.four.base;

public class Human {
    static final int LIFESPAN = 100;
    private String name;
    private int age;
    private float height;

    /**
     * Constructs a Human instance initialized with the given parameters.
     * @param name - the name for the Human instance
     * @param age - the age for the Human instance
     * @param height - the height for the Human instance
     */
    public Human(String name, int age, float height) {
        this.name = name;
        this.age = age;
        this.height = height;
    }

    // some code omitted
}

Listing 4-23Human class

with Explicit Constructor

构造函数不需要return语句,即使调用构造函数的结果是创建一个对象。构造函数在那方面不同于方法。通过声明显式构造函数,不再生成默认构造函数。因此,通过调用默认的构造函数来创建一个Human实例,如前面的代码清单所描述的,不再有效。代码不再编译,因为不再生成默认构造函数。为了创建一个Human实例,我们现在必须调用新的构造函数并提供合适的参数来代替形参,使用正确的类型并遵守它们的声明顺序。

Human human = new Human("John", 40, 1.91f);  // this works
Human human = new Human();  // this no longer works

但是如果我们不想被迫使用这个构造函数来设置所有的字段呢?很简单:我们用我们感兴趣的参数定义另一个。让我们定义一个只为Human实例设置名称和年龄的构造函数,如清单 4-24 所示。

package com.apress.bgn.four.base;

public class Human {
    static final int LIFESPAN = 100;
    private String name;
    private int age;
    private float height;

    public Human(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public Human(String name, int age, float height) {
        this.name = name;
        this.age = age;
        this.height = height;
    }

    // some code omitted
}

Listing 4-24Human Class with Explicit Constructors

在这里,我们偶然发现了另一个 OOP 原则,叫做多态性。这个术语是希腊语,翻译过来就是一个名字,多种形式。多态性适用于具有多个同名方法,但签名和功能略有不同的代码设计。它也适用于构造函数。多态性有两种基本类型:覆盖,也称为运行时多态性,稍后将在讲述继承原则时讲述;以及重载,被称为编译时多态。

第二种类型的多态性适用于前面的构造函数,因为我们有两个构造函数:一个具有不同的参数集,看起来像是简单构造函数的扩展。

在最近的清单中值得注意的第二件事是,两个构造函数包含两个相同的代码行。有一个常识性的编程原理名叫干, 2 这是的简称不要重复自己!很明显,最近的清单中的代码没有遵守它,所以让我们以一种新的有趣的方式使用前面介绍的this关键字来解决这个问题,如清单 4-25 所示。

package com.apress.bgn.four.base;

public class Human {
    static final int LIFESPAN = 100;
    private String name;
    private int age;
    private float height;

    public Human(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public Human(String name, int age, float height) {
        this(name,age);
        this.height = height;
    }

    // some code omitted
}

Listing 4-25Human Class with Better Explicit Constructors

构造函数可以通过使用this(... )相互调用。这对于避免两次编写相同的代码非常有用,从而提高了代码的可重用性。

所以现在两个构造函数都提供了创建Human实例的方法。如果我们使用一个不设置高度的字段,那么 height 字段将隐式初始化为 float 类型的默认值(0.0)。

现在我们的类非常基本,我们甚至可以说它以一种非常抽象的方式建模了一个Human。如果我们试图用某些技能或能力来模拟人类,我们必须创造新的职业。假设我们想模仿音乐家和演员。这意味着我们需要创建两个新的类。清单 4-26 中描述了Musician级;跳过字段的 getter 和 setter。

package com.apress.bgn.four.classes;

public class Musician {
    static final int LIFESPAN = 100;
    private String name;
    private int age;
    private float height;
    private String musicSchool;
    private String genre;
    private List<String> songs;

// other code omitted
}

Listing 4-26Musician Class

清单 4-27 中描述了Actor类;跳过字段的 getter 和 setter。

package com.apress.bgn.four.classes;

public class Actor {
    static final int LIFESPAN = 100;
    private String name;
    private int age;
    private float height;
    private String actingSchool;
    private List<String> films;

    // other code omitted
}

Listing 4-27Actor Class

正如您所看到的,这两个类之间有很多共同的元素。我们之前提到过,干净编码原则之一要求开发人员避免代码重复。这可以通过遵循另外两个 OOP 原则来设计类来实现:继承(已经简单提到过)和抽象。

抽象和继承

抽象是管理复杂性的 OOP 原则。抽象用于分解复杂的实现,并定义可重用的核心部分。在我们的例子中,类音乐家和演员的公共字段可以归入我们在本章前面定义的人类类。人类类可以被视为一种抽象,因为这个世界上的任何人类都不仅仅是他们的名字、年龄和身高。没有必要创建Human实例,因为任何人都会被其他东西所代表,比如激情、目的或技能。一个不需要实例化的类,只是将字段和方法组合在一起供其他类继承或提供具体实现,在 Java 中由一个抽象类建模。因此, Human 类被修改成抽象的。因为我们抽象了这个类,所以我们将使生命周期常量成为公共的,以使它可以从任何地方访问,并使getTimeToLive()方法成为抽象的,以将其实现委托给扩展类。类别内容如清单 4-28 所示。

package com.apress.bgn.four.classes;

public abstract class Human {
    public static final int LIFESPAN = 100;
    protected String name;
    protected int age;
    protected float height;

    public Human(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public Human(String name, int age, float height) {
        this(name, age);
        this.height = height;
    }

    /**
     * @return time to live
     */
    public abstract int getTimeToLive();

    // getters and setters omitted
}

Listing 4-28Human Abstract Class

抽象方法是一种缺少主体的方法,就像前面代码清单中声明的getTimeToLive()方法一样。这意味着在Human类中没有这个方法的具体实现,只有一个框架,一个模板。这个方法的具体实现必须由扩展类提供。

哦,但是等等,我们保留了构造函数!如果不允许我们再使用它们,我们为什么要这样做呢?我们没有,因为这是 IntelliJ IDEA 对图 4-6 中的BasicHumanDemo类所做的事情:

img/463938_2_En_4_Fig6_HTML.jpg

图 4-6

尝试实例化抽象类时出现 Java 编译器错误

是的,这是一个编译错误。可以保留构造函数,因为它们可以进一步帮助抽象行为。必须重写类Musician和Actor来扩展Human类。这是通过在声明类和指定要扩展的类时使用extends关键字来完成的,也称为父类或超类。产生的类被称为子类。

当扩展一个类时,子类继承超类中声明的所有字段和具体方法。(对它们的访问由第章 3 中的访问修饰符定义)。)例外是抽象方法,子类被迫提供具体的实现。

img/463938_2_En_4_Figi_HTML.jpg子类必须声明自己的构造函数,这些构造函数使用超类中声明的构造函数。使用关键字super调用超类的构造函数。这同样适用于方法和字段,除非它们有一个禁止访问的访问修饰符。

你能猜出是哪一个吗?是private。子类不能访问超类的私有成员。如果你不知道答案,你可能想复习一下章 3 。

清单 4-29 描述了利用抽象和继承编写的Musician类的一个版本。

package com.apress.bgn.four.classes;

public class Musician extends Human {
    private String musicSchool;
    private String genre;
    private List<String> songs;

    public Musician(String name, int age, float height,
                    String musicSchool, String genre) {
        super(name, age, height);
        this.musicSchool = musicSchool;
        this.genre = genre;
    }

    public int getTimeToLive() {
        return (LIFESPAN - getAge()) / 2;
    }

    // getters and setters omitted
}

Listing 4-29Musician Class That Extends Human

为了简单起见,songs字段没有在构造函数中用作参数。

如您所见,Musician构造函数调用超类中的构造函数来设置那里定义的属性。另外,请注意为getTimeToLive()方法提供的完整实现。以类似的方式重写了Actor类。这本书的源代码中有一个提议实现,但是在查看classes包之前,请尝试编写您自己的实现。在图 4-7 中,描述了由 IntelliJ IDEA 生成的Human类的层次结构。方法被省略以保持图像简单。

img/463938_2_En_4_Fig7_HTML.jpg

图 4-7

IntelliJ IDEA 生成的 UML 图

UML 图清楚地显示了每个类的成员,箭头指向超类。UML 图是设计组件层次结构和定义应用逻辑的有用工具。如果你想阅读更多关于它们和 UML 图的种类,你可以点击这里: https://www.uml-diagrams.org 。

在介绍了这么多关于类和如何创建对象的内容之后,我们需要介绍其他 Java 重要组件,它们可以用来创建更详细的对象。我们的Human类缺少很多属性,比如性别。对一个人的性别进行建模的字段只能包含一组固定值中的值。它曾经是两个,但因为我们生活在一个非常喜欢政治正确的勇敢的新世界,我们不能将性别的价值集限制为两个,所以我们将引入第三个,称为未指定的,用于替代一个人确定为什么。这意味着我们必须引入一个新的类来表示性别,这个类只能被实例化三次。对于一个典型的类来说,这很难做到,这也是为什么在 Java 版本中引入了enums的原因。

枚举数

enum类型是一种特殊的类类型。它用于定义一个特殊类型的类,该类只能被实例化固定的次数。枚举声明将该枚举的所有实例组合在一起。它们都是常量。可以如清单 4-30 所示定义Gender枚举。

package com.apress.bgn.four.classes;

public enum Gender {
    FEMALE,
    MALE,
    UNSPECIFIED
}

Listing 4-30Gender Enum

枚举不能在外部实例化。枚举默认为final,因此不能扩展。还记得默认情况下 Java 中的每个类是如何隐式扩展类Object的吗?Java 中的每个枚举都隐式扩展了类java.lang.Enum<E>,这样做的时候,每个枚举实例都继承了在使用枚举编写代码时有用的特殊方法。

作为一个特殊类型的类,enum 可以有字段和构造函数,它只能是私有的,因为 enum 实例不能在外部创建。private 修饰符不是显式需要的,因为编译器知道该做什么。清单 4-31 显示了通过添加一个整数字段和一个String字段实现的Gender枚举,整数字段将是每个性别的数字表示,而String字段将是文本表示。要访问枚举属性,需要 getters。

package com.apress.bgn.four.classes;

public enum Gender {
    FEMALE(1, "f"),
    MALE(2, "m") ,
    UNSPECIFIED(3, "u");

    private int repr;
    private String descr;

    Gender(int repr, String descr) {
        this.repr = repr;
        this.descr = descr;
    }

    public int getRepr() {
        return repr;
    }

    public String getDescr() {
        return descr;
    }
}

Listing 4-31A More Complex Gender Enum

但是,什么会阻止我们声明 setters 和修改字段值呢?嗯,没什么。如果那是你需要做的,你可以做。但这并不是一个好的做法。

枚举实例应该是常量。所以一个正确的枚举设计不应该声明 setters,并确保字段的值永远不会因为声明它们而改变final。当我们这样做时,初始化字段的唯一方法是调用构造函数,因为构造函数不能从外部调用,所以数据的完整性得到了保证。清单 4-32 中描述了一个良好的 enum 设计示例。

package com.apress.bgn.four.classes;

public enum Gender {
    FEMALE(1, "f"),
    MALE(2, "m") ,
    UNSPECIFIED(3, "u");

    private final int repr;
    private final String descr;

    Gender(int repr, String descr) {
        this.repr = repr;
        this.descr = descr;
    }

    public int getRepr() {
        return repr;
    }
    public String getDescr() {
        return descr;
    }
}

Listing 4-32Proper Gender Enum

方法可以添加到枚举中,每个实例都可以重写它们。因此,如果我们将名为comment()的方法添加到Gender枚举中,每个实例都将继承它。但是实例可以覆盖它,如清单 4-33 所示。

package com.apress.bgn.four.classes;

public enum Gender {
    FEMALE(1, "f"),
    MALE(2, "m") ,
    UNSPECIFIED(3, "u"){
        @Override
        public String comment() {
            return "to be decided later: " + getRepr() + ", " + getDescr();
        }
    };

    private final int repr;
    private final String descr;

    Gender(int repr, String descr) {
        this.repr = repr;
        this.descr = descr;
    }

    public int getRepr() {
        return repr;
    }
    public String getDescr() {
        return descr;
    }

    public String comment() {
        return repr + ": " + descr;
    }
}

Listing 4-33Proper Gender Enum with Extra Method

img/463938_2_En_4_Figk_HTML.jpg这怎么可能呢?实例如何重写其类类型的方法?嗯,这不。UNSPECIFIED枚举实际上扩展了Gender类并覆盖了comment()方法。

这可以通过迭代枚举值并打印从返回对象运行时类型的Object类继承的getClass()方法返回的结果很容易地证明。为了获得一个枚举的所有实例,每个枚举都隐式扩展的类java.lang.Enum<E >提供了一个名为values().的方法

清单 4-34 显示了这样做的代码及其输出。

package com.apress.bgn.four.classes;

public class BasicHumanDemo {
   public static void main(String... args) {
       for (Gender value : Gender.values()) {
           System.out.println(value.getClass());
       }
   }
}
// Output expected in the console
// class com.apress.bgn.four.classes.Gender
// class com.apress.bgn.four.classes.Gender
// class com.apress.bgn.four.classes.Gender$1

Listing 4-34Code Sample Listing Enum Items Classes

注意为UNSPECIFIED元素打印的值。Gender$1 符号意味着编译器通过扩展原始的 enum 类并使用UNSPECIFIED元素声明中提供的方法覆盖 comment()方法来创建内部类。

在未来的例子中,我们也将使用枚举。只要记住,每当你需要将一个类的实现限制在固定数量的实例中,或者将相关的常数组合在一起,枚举就是你的工具。因为我们引入了枚举,我们的Human类现在可以有一个类型为Gender的字段,如清单 4-35 所示。

package com.apress.bgn.four.classes;

public abstract class Human {
    public static final int LIFESPAN = 100;
    protected String name;
    protected int age;
    protected float height;

    private Gender gender;

    public Human(String name, int age, Gender gender) {
        this.name = name;
        this.age = age;
        this.gender = gender;
    }

    public Human(String name, int age, float height, Gender gender) {
        this(name, age, gender);
        this.height = height;
    }

    // other code omitted

    public Gender getGender() {
        return gender;
    }

    public void setGender(Gender gender) {
        this.gender = gender;
    }
}

Listing 4-35Human Class with a Gender Field

在前面的章节中,接口被提到是用于创建对象的 Java 组件之一。是我们扩展这个主题的时候了。

接口

【Java 面试最常见的一个问题是:接口和抽象类有什么区别?本节将为您提供该问题的最详细的答案。

一个接口不是一个类,但是它确实有助于创建类。接口是完全抽象的;它没有字段,只有抽象方法定义。我也喜欢称它们为骷髅。当一个类被声明实现一个接口时,除非这个类是抽象的,否则它必须为所有的框架方法提供具体的实现。

img/463938_2_En_4_Figm_HTML.jpg名称 skeleton method 在 Java 8+版本中非常重要,因为从这个版本开始,接口得到了丰富,使得static、default和private方法可以成为它们的一部分。

接口内部的框架方法是隐式的public和abstract,因为框架方法必须是抽象的,以强制类提供实现,并且必须是公共的,所以类实际上可以访问实现。在 Java 8 之前,接口中唯一有具体实现的方法是static方法。

在 Java 8 中,引入了接口中的默认方法,在 Java 9 中引入了接口中的私有方法。

无法实例化接口;它们没有构造函数。

没有声明方法定义的接口被称为标记接口,用于标记特定用途的类。最著名的 Java 标记接口是java.io.Serializable,它标记可以序列化的对象,这样它们的状态就可以保存到二进制文件或另一个数据源,并通过网络发送出去,以便进行反序列化和使用。接口可以在自己的文件中声明为顶级组件,也可以嵌套在另一个组件中。接口有两种:普通接口和注解。

抽象类和接口之间的区别,以及何时应该使用其中的一个,在继承的上下文中变得相关。

Java 只支持单一继承。这意味着一个类只能有一个超类。

单一继承似乎是一种限制,但是,请考虑下面的例子。让我们修改前面的层次结构,想象一个名为Performer的类,它应该扩展音乐家和演员类。如果你需要一个可以被这个类模仿的真实的人,想想大卫·杜楚尼(他是一个演员和音乐家)。

在图 4-8 中,描述了前面提到的类层次结构。

img/463938_2_En_4_Fig8_HTML.jpg

图 4-8

钻石等级体系

上图中的层次结构引入了一种叫做菱形问题的东西,这个名字显然是受了类之间的关系所形成的形状的启发。设计到底有什么问题?显而易见,如果Musician和Actor都扩展了Human并继承了它的所有成员,那么Performer会继承什么,从哪里继承?它显然不能两次继承Human的成员,这将使这个类变得无用和无效。我们如何辨别具有相同签名的方法呢?那么 Java 中的解决方案是什么呢?正如您可能想象的那样,考虑到本节的重点:接口。(算是吧,大部分时候是接口的组合,需要一个名为 composition 的编程概念。)

要做的是将类Musician和Actor中的方法转换成方法框架,并将那些类转换成接口。来自Musician的行为将被移到一个名为Guitarist的类中,它将扩展Human类并实现Musician接口。对于Actor类,可以做一些类似的事情,但是我们将把它作为一个练习留给你。图 4-9 中的层次结构提供了一些帮助。

img/463938_2_En_4_Fig9_HTML.jpg

图 4-9

具有执行者类接口的 Java 层次结构

因此,Musician接口只包含映射音乐家所做事情的方法框架。它没有详细描述如何建模。对于Actor接口也是如此。在清单 4-36 中,你可以看到两个接口的主体。

//Musician.java
package com.apress.bgn.four.interfaces;
import java.util.List;

public interface Musician {
    String getMusicSchool();
    void setMusicSchool(String musicSchool);
    List<String> getSongs();
    void setSongs(List<String> songs);
    String getGenre();
    void setGenre(String genre);
}

//Actor.java
package com.apress.bgn.four.interfaces;
import java.util.List;

public interface Actor {
    String getActingSchool();
    void setActingSchool(String actingSchool);
    List<String> getFilms();
    void setFilms(List<String> films);
    void addFilm(String filmName);
}

Listing 4-36Musician and Actor interfaces

如您所见,字段已经被删除,因为它们不能成为接口的一部分,剩下的就是方法框架。清单 4-37 中描述了Performer类。

package com.apress.bgn.four.interfaces;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.classes.Human;

import java.util.List;

public class Performer extends Human implements Musician, Actor {

    // fields specific to musician
    private String musicSchool;
    private String genre;
    private List<String> songs;

    // fields specific to actor
    private String actingSchool;
    private List<String> films;

    public Performer(String name, int age, float height, Gender gender) {
        super(name, age, height, gender);
    }

    // from Human
    @Override
    public int getTimeToLive() {
        return (LIFESPAN - getAge()) / 2;
    }

    // from Musician
    public String getMusicSchool() {
        return musicSchool;
    }

    public void setMusicSchool(String musicSchool) {
        this.musicSchool = musicSchool;
    }

    // from Actor
    public String getActingSchool() {
        return actingSchool;
    }

    public void setActingSchool(String actingSchool) {
        this.actingSchool = actingSchool;
    }

    // other methods omitted
}

Listing 4-37Performer Class Implementing Two Interfaces

从这个例子中你应该明白,在某种程度上使用接口和多重继承在 Java 中是可能的,并且类只扩展一个类,可以实现一个或多个接口。

继承也适用于接口。例如,Musician和Actor接口都可以扩展一个名为Artist的接口,该接口包含两者通用的行为模板。例如,我们可以将音乐学校和表演学校合并成一个通用学校,并将它的 setters 和 getters 定义为方法框架。清单 4-38 中描述了Artist接口以及Musician。

package com.apress.bgn.four.interfaces;

// Artist.java
public interface Artist {
    String getSchool();
    void setSchool(String school);
}

// Musician.java
import java.util.List;

public interface Musician extends Artist {
    List<String> getSongs();
    void setSongs(List<String> songs);
    String getGenre();
    void setGenre(String genre);
}

Listing 4-38Artist and Musician Interface

s

希望您理解了多重继承的概念,以及在设计您的应用时什么时候使用类,什么时候使用接口是合适的,因为现在是时候履行在本节开始时所做的承诺,并列出抽象类和接口之间的区别了。您可以在表 4-1 中找到它们。

表 4-1

Java 中抽象类和接口的区别

|

抽象类

|

连接

|
| --- | --- |
| 可以有非抽象的方法。 | 只能有静态、抽象、默认和私有方法。 |
| 单一继承:一个类只能扩展一个类。 | 多重继承:一个类可以实现多个接口。此外,一个接口可以扩展一个或多个接口。 |
| 可以有最终的、非最终的、静态的和非静态的变量。 | 只能有静态和最终字段。 |
| 用抽象类声明。 | 用接口声明。 |
| 可以使用关键字扩展另一个类,并使用关键字实现接口。 | 只能使用关键字扩展来扩展其他接口(一个或多个)。 |
| 可以有非抽象、包私有(默认)、受保护或私有成员。 | 默认情况下,所有成员都是抽象的和公共的。(从 Java 9 开始的默认和私有方法除外。) |
| 如果一个类有一个抽象方法,它必须声明自己是抽象的。 | (无对应)。 |

接口中的默认方法

接口的一个问题是,如果您修改它们的主体来添加新方法,代码将停止编译。要使它编译,您必须在实现该接口的每个类中为新添加的接口方法添加一个具体的实现。这是开发者多年来的痛。接口是一个契约,它保证了类的行为方式。当在您的项目中使用第三方库时,您可以通过设计您的代码来遵守这些约定。当切换到一个新版本的库时,如果契约改变,您的代码将不再编译。

img/463938_2_En_4_Figp_HTML.jpg这种情况和苹果把他们电脑和手机的充电口从一个版本换到另一个版本很像。如果你买了新的 Mac,试着用旧的充电器,那就不合适了。

当然,一个解决方案是在一个新的接口中声明新的方法,然后创建实现新的和旧的接口的新类(这被称为组合,因为两个接口被组合来表示一个契约)。一个接口暴露出来的方法组成了一个 API(应用编程接口),开发应用的时候,目的就是要设计出稳定的 API。这个规则用一个叫做开闭原则的编程原则来描述。这是 5 个坚实的编程原则之一。这个原则声明你应该能够扩展一个类而不用修改它。因此,修改一个类实现的接口也需要修改这个类。修改接口往往会导致违反这个原则。

除了前面提到的接口组合,在 Java 8 中引入了一个解决方案:默认方法。从 Java 8 开始,具有完整实现的方法可以在接口中声明,只要它们是使用default关键字声明的。默认方法是隐式公共的。它们的主要目的是修改 API 以允许新的实现覆盖它们,但不破坏现有的实现。

让我们考虑一下Artist接口。任何艺术家都应该能创作出一些东西,对吗?所以他们应该有创造的天性。鉴于我们生活的这个世界,我不会说出名字,但我们的一些艺术家实际上是这个行业的产物,他们自己并没有创造力。在我们决定了图 4-10 中描绘的等级之后,我们意识到我们应该有一种方法来告诉我们一个艺术家是否有创造性。

img/463938_2_En_4_Fig10_HTML.jpg

图 4-10

具有更多执行者类接口的 Java 层次结构

如果我们向Artist接口添加一个新的抽象方法,Performer类将无法编译。IntelliJ IDEA 将通过用红色显示很多东西来清楚地表明我们的应用不再工作了,如图 4-11 所示。

img/463938_2_En_4_Fig11_HTML.jpg

图 4-11

由于接口中的新方法,Java 破坏了层次结构

我们看到的编译器错误是由我们决定向Artist接口添加一个名为isCreative的新抽象方法引起的,如果您将鼠标悬停在类声明上,就可以看到原因。清单 4-39 描述了破解代码的抽象方法。

package com.apress.bgn.four.hierarchy;

public interface Artist {
    String getSchool();

    void setSchool(String school);

    boolean isCreative();
}

Listing 4-39New Abstract Method Added to the Artist Interface

为了消除编译错误,我们将把isCreative抽象方法转换成返回true的default方法,因为每个艺术家都应该有创造力。默认方法在默认情况下是公共的,因此可以在实现声明该方法的接口的类型的每个对象上调用它们。清单 4-40 描述了默认方法的主体。

package com.apress.bgn.four.hierarchy;

public interface Artist {
    String getSchool();

    void setSchool(String school);

    default boolean isCreative(){
        return true;
    }
}

Listing 4-40New default Method Added to the Artist Interface

现在代码应该可以再次编译了。默认方法非常实用,因为它们允许修改由接口表示的契约,而无需修改实现该接口的现有类。这将确保为该接口的旧版本编写的代码的二进制兼容性。

实现包含默认方法的接口的类可以使用现有的默认实现,或者为默认方法提供新的实现(可以重写它们)。为了说明这一点,清单 4-41 中显示了一个名为MiliVanili的类,它为Artist接口中的默认方法提供了一个新的实现。

package com.apress.bgn.four.hierarchy;

import java.util.List;

public class MiliVanili implements Artist {

    @Override
    public boolean isCreative() {
        return false; // dooh!
    }

    // other code omitted
}

Listing 4-41Default Method Being Overriden in Class Implementing the Artist Interface

可以编写扩展其他接口的接口来执行以下任何操作(为了更清楚起见,扩展的接口将被称为超级接口):

  • 声明它们自己的抽象方法和默认方法

  • 将超接口中的默认方法重新声明为抽象方法,强制扩展该接口的类提供实现

  • 从超接口重新定义默认方法

  • 声明一个默认方法,该方法为超接口中的抽象方法提供实现

    对于一本绝对的 Java 初学者书籍来说,提供所有这些场景的代码样本有点太多了。如果您对代码看起来像什么以及测试这些断言的有效性感兴趣,请查看com.ampress.bgn.four.interfaces.extensions包的内容。

接口中的静态方法和常数

在 Java 版本 1 中,接口只能包含抽象方法和静态常量。自版本 1 以来,接口发生了很大变化,最重要的变化是对default和private方法的支持。

常量,或者一旦初始化就不会改变的变量,不需要实现,所以允许开发人员在接口体中声明它们是有意义的,对吗?使用枚举也可以做到这一点,但是有时您可能希望将相关的组件放在一起。在前面的例子中,Human类中声明了一个LIFESPAN常量。由于任何实现Artist的类都可能需要LIFESPAN来进行某种计算,我们可以在Artist接口中移动这个常量,并在任何类中使用它,如清单 4-42 所示。

// Artist.java
package com.apress.bgn.four.hierarchy;

public interface Artist {
    public static final int LIFESPAN = 100;

    // other code omitted
}

// Performer.java
package com.apress.bgn.four.hierarchy;

public class Performer extends Human implements Musician, Actor {

    @Override
    public int getTimeToLive() {
        return (LIFESPAN - getAge()) / 2;
    }

    // other code omitted
}

Listing 4-42The Constant LIFESPAN in the Artist Interface

当在接口中声明常量时,三个访问器public static final是多余的,因为它们是隐含的。对每种情况的解释都很简单:

  • 接口不能有可变字段,所以默认情况下它们必须是final。

  • 因为接口不能被实例化;它们不能有将成为实例属性的字段,所以它们必须是static。

  • 因为接口体中的任何东西都必须是实现类可访问的,所以它们必须是public.

至于接口中的静态方法,它们通常是特定于接口所属层次结构中某些操作的实用方法。让我们添加一个静态方法,该方法检查作为参数提供的名称是否大写,如果不是,就将其大写。清单 4-43 中描述了代码,方法capitalize在Artist接口中声明并在Performer类中使用。

// Artist.java
package com.apress.bgn.four.hierarchy;

public interface Artist {

    public static String capitalize(String name){
        Character c = name.charAt(0);
        if(Character.isLowerCase(c)) {
            Character upperC = Character.toUpperCase(c);
            name.replace(c, upperC);
        }
        return name;
    }

    // other code omitted
}

// Performer.java
package com.apress.bgn.four.hierarchy;

public class Performer extends Human implements Musician, Actor {

    public String getCapitalizedName() {
        return Artist.capitalize(this.name);
    }

    // other code omitted
}

Listing 4-43Public Static Method in Interface

在 Java 8 中,由于前面提到的原因,任何带有未声明为default的主体的方法都必须声明为 public 和 static。如果default或static方法共享大量代码,那么default或静态method可以将代码分组,让其他人调用,对吗?唯一的问题出现在需要代码私有的时候。这在 Java 8 中是不可能的,因为默认情况下,接口主体中的所有内容都是public,但是在 Java 9 中这变成了可能。

接口中的私有方法

从 Java 9 开始,引入了对接口中的private和private static方法的支持。这意味着通过调用一个private方法,由默认的isCreative()方法执行的动作可以被修改,以记录对返回值的解释,如清单 4-44 所示。

package com.apress.bgn.four.hierarchy;

public interface Artist {
    String getSchool();

    void setSchool(String school);

    default boolean isCreative(){
        explain();
        return true;
    }

    private void explain(){
        System.out.println("A true artist has a creative nature.");
    }
}

Listing 4-44New private Method Added to the Artist Interface

对于静态方法也可以这样做,如果有一段代码值得私有,就在一个private static方法中声明它。

当在一个具体的项目中进行开发时,你会发现自己在使用类、接口、枚举等等。如何设计和组织代码取决于你自己。只要确保避免重复,并保持它的干净、非耦合和可测试性。

记录

Java record是一种特殊类型的类,具有清晰的语法来定义不可变的纯数据类。Java 编译器获取记录的代码,并生成构造函数、获取函数和其他专门的方法,如toString()、hashCode(),和equals()。

img/463938_2_En_4_Figr_HTML.jpghashCode()和equals()专用方法在Object类中定义,因此它们在每个 Java 类中都被隐式定义。它们对于建立实例的身份非常重要,将在章节 ** 5 ** “集合”一节中介绍。

在 C#、Scala 或 Kotlin 等其他编程语言中引入类似类型的结构很久之后,Java 记录作为预览功能在 JDK 14 中引入。Java 开发人员通过使用 Lombok 等库避免了编写大量样板代码的麻烦。Lombok 已经在本章的封装数据一节中提到,其中也列出了使用它的一些缺点。

Lombok 需要用特殊的注释来注释类,这些注释告诉它的注释处理器在编译时生成所需的字节码。它使用 Java 记录生成现在支持的所有组件。

清单 4-45 展示了如何使用 Lombok 编写Human类。

package com.apress.bgn.four.lombok;

import com.apress.bgn.four.classes.Gender;
import lombok.*;

@NoArgsConstructor
@AllArgsConstructor
@RequiredArgsConstructor
@ToString
@EqualsAndHashCode
public class Human {
    @Getter @Setter
    @NonNull
    private String name;

    @Getter @Setter
    @NonNull
    private int age;

    @Getter @Setter
    private float height;

    @Getter @Setter
    private Gender gender;
}

Listing 4-45Human Class: the Lombok Version

Lombok 的另一个问题是它在使用模块的 Java 项目中变得不可预测。在编译时操作代码以注入额外的功能是一项敏感的操作,需要访问 JDK 内部,出于安全原因,可能不会导出这些内容。例如,在编写本节时,使用 Lombok 编译项目不起作用,因为 Lombok 需要从不导出com.sun.tools.javac.processing包的模块jdk.compiler访问类com.sun.tools.javac.processing.JavacProcessingEnvironment。

如果没有 Lombok,清单 4-45 中的类将会有更多的行,因为代码片段中的所有注释本质上都替换了开发人员本来应该编写的方法:

  • @NoArgsConstructor告诉 Lombok 为Human类的默认无参数构造函数生成字节码。

  • @AllArgsConstructor告诉 Lombok 为一个构造函数生成字节码,这个构造函数需要为Human类的每个字段提供一个参数。

  • @RequiredArgsConstructor告诉 Lombok 为一个构造函数生成字节码,该构造函数要求所有必需字段(用@NotNull标注的字段)都有一个参数。

  • @ToString告诉 Lombok 为toString()方法生成字节码。该方法的实现由Lombok基于类中的所有字段决定。

  • @EqualsAndHashCode告诉 Lombok 为equals()和hashCode()方法生成字节码。这些方法的实现由Lombok基于类中的所有字段决定。

随着记录的引入,只要项目在 JDK 15 上编译和运行,就不再需要 Lombok 了。你不需要你的实例是不可变的。产生的类是不可变的纯数据类,所以没有设置器,但是在勇敢的反应性新世界中,拥有不可变的记录是必须的。清单 4-46 显示了Human类的record实现,以及实例化Human所需的代码。

// Human.java
package com.apress.bgn.four.records;

import com.apress.bgn.four.classes.Gender;

public record Human(String name, int age, float height, Gender gender) { }

// RecordDemo.java
package com.apress.bgn.four.records;

import com.apress.bgn.four.classes.Gender;

public class RecordDemo {

    public static void main(String... args) {
        Human john = new Human("John Mayer", 44, 1.9f, Gender.MALE);

        System.out.println("John as string: " + john);
        System.out.println("John's hashCode: " + john.hashCode());
        System.out.println("John's name: " + john.name());
    }
}

Listing 4-46Simple Human Record and Class Where Used

如您所见,记录可以用与类相同的方式进行实例化,方法是使用new关键字调用构造函数。毕竟,他们只是另一种类型的类。此外,由于不需要 setters,因为对象是不可变的,getters 也没有多大意义。因此,为了访问属性值,会生成与字段同名的方法来返回字段值。

这可以通过使用 IntelliJ IDEA 查看生成的Human.class文件中的字节码来证明。只需在chapter04/target/classes目录中查找这个文件,然后选择它,并从菜单中选择查看➤显示字节码。应该会弹出一个窗口,内容与图 4-12 所示非常相似。

img/463938_2_En_4_Fig12_HTML.jpg

图 4-12

人类记录的字节码

从字节码中,我们可以发现关于记录的另一件重要的事情:字节码中显示的类是final,因此记录不能被扩展。同样,所有记录类都隐式扩展了类java.lang.Record。

不可能创建子记录。

运行RecordDemo类中的main(..)方法会产生以下结果:

John as string: Human[name=John Mayer, age=44, height=1.9, gender=MALE]
John's hashCode: -1637990649
John's name: John Mayer

记录的实现已经足够好了。john实例的属性值易于阅读和理解。

可以定制记录。没有什么可以阻止您为toString()、equals(),hashCode()方法提供自定义实现,并在记录体中提供各种构造函数,就像您为一个类所做的那样。唯一的问题是构造函数必须使用this关键字调用记录的默认构造函数。在清单 4-47 中,您可以看到一个构造函数被添加,它只需要名字和年龄。

package com.apress.bgn.four.records;

public record Human(String name, int age, Float height, Gender gender) {
    public Human(String name, int age) {
        this(name, age, null, null);
    }
}

Listing 4-47Simple Human Record with an Additional Constructor

由于为记录生成的默认构造函数和其他方法依赖于记录的参数,因此不能在记录体中声明额外的字段。但是,支持静态变量和方法。图 4-13 描绘了一个记录,它有一个额外的常量和一个在其主体中声明的字段,编辑器不喜欢后者。

img/463938_2_En_4_Fig13_HTML.jpg

图 4-13

用常数和字段记录

当数据不变性是一个需求时,记录是非常实用的,大多数时候是这样的(例如,用于在软件应用子系统之间传输数据的 d to 或数据传输对象)。没有记录也可以,但是需要开发者付出很多努力。这是像我这样的老派开发者在任何必要的时候都会做的努力,但是你们年轻人不知道现在有多容易!

密封类

密封类是 JDK 15 中的预览功能,也是 JDK 16 中的预览功能。在写这一章的时候,Java 17 的特性列表仍然很小,而且没有提到密封类。但是希望它们能成为 Java 17 的官方特性,所以它们值得在本书中提及。

开发人员面临的一个常见问题是为他们的类和接口选择范围修饰符。安全性始终是一个问题,对于一些项目来说,当需要扩展类时,使它们成为公共的或受保护的是有风险的。这就是sealed修改器及其整个家族应该派上用场的地方。它允许seal一个类来防止它被扩展,除了一些使用permits关键字声明的子类。当然,当新的子类被添加到项目中时,超类似乎注定要被更新很多次,但是拥有一个更安全的应用是一个可以接受的折衷。考虑到这一点,让我们密封我们的Human类的一个版本,只允许Performer类扩展它。清单 4-48 描述了这两个类。

// Human.java
package com.apress.bgn.four.sealed;

import com.apress.bgn.four.classes.Gender;

// Human.java
public sealed class Human
        permits Performer {
    protected String name;

    protected int age;

    protected float height;

    // other code omitted
}

// Performer.java
package com.apress.bgn.four.sealed;

import com.apress.bgn.four.classes.Gender;

public final class Performer extends Human {
    // other code omitted
}

Listing 4-48Sealed Class and Allowed Subclass

如果扩展类是在同一个源文件中声明的,就不需要在关键字permits后面列出它们。如果文件外部没有扩展类,那么可以完全省略permits关键字。

允许扩展密封类的类本身应该是密封的或最终的。如果我们需要这些类中的一个允许被未知类扩展,non-sealed修饰符允许这样做。清单 4-49 显示了声明为non-sealed的类Engineer;这个类必须从Human类添加到permits指令的列表中。

package com.apress.bgn.four.sealed;

import com.apress.bgn.four.classes.Gender;

public non-sealed class  Engineer extends Human {
    public Engineer(String name, int age, Gender gender) {
        super(name, age, gender);
    }

    public Engineer(String name, int age, float height, Gender gender) {
        super(name, age, height, gender);
    }
}

Listing 4-49Sealed Class and Allowed Subclass

sealed修饰符也可以应用于接口。关键字permits指定了允许实现密封接口的类。

img/463938_2_En_4_Figs_HTML.jpg你可能希望permits关键字也支持扩展密封接口的接口,但是在 JDK 的当前版本中没有。(愿意的话可以试试。)

同样的规则也适用于密封接口:实现密封接口的类应该是密封的、非密封的或最终的。

清单 4-50 显示了密封的Mammal接口,它是由密封的Human类实现的。

package com.apress.bgn.four.sealed;

public sealed interface Mammal permits Human {

}

public sealed class Human
        implements Mammal
        permits Performer, Engineer {
    // rest of the code ommitted
}

Listing 4-50sealed Mammal Interface and the Sealed Human Class

密封类和接口的一个限制是任何子类和实现类都需要在同一个模块中。

另外,如果不明显的话,permits关键字之后出现的任何类都必须扩展密封类/实现密封接口。如果在permits关键字之后指定了一个类,并且没有扩展密封的类/实现,编译器不会喜欢这个密封的接口。

密封类对records有好处,因为记录默认是最终的。

隐藏类

对于致力于开发 Hibernate 或 Spring 等框架的开发人员来说,隐藏类是一个有趣的特性。它允许他们创建不能被其他类的字节码直接使用的类,因为它们注定只能被框架内部使用。内部类应该用hidden修饰符声明,并且它们不应该是可发现的。它们可以由框架动态生成,具有较短的生命周期,并在不再需要时被丢弃,这将提高在 JVM 上运行的应用的性能。

在写这一章的时候,隐藏类更多的是一个概念而不是现实。

注释类型

一个annotation的定义类似于一个接口;不同之处在于接口关键字前面有一个at符号(@)。注释类型是接口的一种形式,大多数时候用作标记(看看前面的 Lombok 例子)。例如,您可能已经注意到了@Override注释。当类扩展类或实现接口时,这个注释被放在由智能 ide 自动生成的方法上。清单 4-51 中的代码片段描述了它在 JDK 的声明:

package java.lang;

import java.lang.annotation.*;

/**
 * documentation omitted
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override { }

Listing 4-51The JDK @Override Declaration

不声明任何属性的注释称为marker或informative注释。它们只需要通知应用中的其他类,或者开发人员它们所在的组件的用途。它们不是强制性的,没有它们代码也能编译。

在 Java 8 中,引入了名为@FunctionalInterface的注释。这个注释被放在所有只包含一个抽象方法的 Java 接口上,并且可以在 lambda 表达式中使用。除了单一的抽象方法,一个接口可以包含常量和其他静态成员。

λ表达式

Java 8 中也引入了 Lambda 表达式,它们代表了一种从 Groovy 和 Ruby 等语言借鉴来的简洁实用的代码编写方式。清单 4-52 描述了@FunctionalInterface声明。

package java.lang;

import java.lang.annotation.*;

/**
 * documentation omitted
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface FunctionalInterface {}

Listing 4-52The JDK @FunctionalInterface Declaration

函数接口是声明单个抽象方法的接口。因此,该方法的实现可以当场提供,而不需要创建一个类来定义具体的实现。让我们想象以下场景:我们创建一个名为Operation的接口,它包含一个方法。我们可以通过创建一个名为Addition的类来为这个接口提供一个实现,或者我们可以使用一个 lambda 表达式当场实现它。清单 4-53 描述了Operation接口、Addition类和一个名为OperationDemo的类,显示了在main(..)方法中声明和使用的现场实现。

package com.apress.bgn.four.lambda;

@FunctionalInterface
interface Operation {
    int execute(int a, int b);
}

class Addition  implements Operation {
    @Override
    public int execute(int a, int b) {
        return a + b;
    }
}

public class OperationDemo {
    public static void main(String... args) {
        // using the Addition class
        Addition addition = new Addition();
        int result = addition.execute(2,5);
        System.out.println("Result is " + result);

        // implementation on the spot using a Lambda Expression
        Operation addition2 = (a, b) -> a + b;
        int result2 = addition2.execute(2, 5);
        System.out.println("Lambda Result is " + result2);
    }
}

Listing 4-53Explicit Interface Implementation Compared to Lambda Expression

通过使用 lambda 表达式,不再需要类Addition,这导致可读代码越来越少。Lambda 表达式可以用于很多事情,我们将在本书后面更多地讨论它们,只要使用它们可以以更实用的方式编写代码。

例外

异常是特殊的 Java 类,用于在程序执行期间拦截特殊的意外情况,以便开发人员可以实现正确的操作过程。这些类按照图 4-14 所示的层次结构进行组织。Throwable是类层次结构的根类,用于表示 Java 应用中的意外情况。

img/463938_2_En_4_Fig14_HTML.jpg

图 4-14

Java 异常层次结构

Java 应用中的异常情况可能由于多种原因而发生:

  • 编写代码时的人为错误

  • 硬件原因(试图从损坏的数据磁盘读取文件)

  • 缺少资源(试图读取不存在的文件)

  • 还有更多。

    草率的开发人员,当有疑问时,倾向于编写总是能捕捉到Throwable的代码。显然你应该尽量避免这种情况,因为类Error是用来通知开发者系统无法恢复的情况已经发生,并且是Throwable的子类。

让我们从一个简单的例子开始。在清单 4-54 中,我们定义了一个调用自己的方法(它的技术名称是recursive),但是我们会把它设计得很糟糕,永远调用自己,导致 JVM 耗尽内存。

package com.apress.bgn.four.exceptions;

/**
 * Created by iuliana.cosmina on 29/03/2021
 */
public class ExceptionsDemo {
    // bad method
    static int rec(int i){
        return rec(i*i);
    }
    public static void main(String... args) {
        rec(1000);
        System.out.println("ALL DONE.");
    }
}

Listing 4-54Bad Recursive Method

如果我们运行ExceptionsDemo类,那么全部完成不会被打印出来。相反,程序将通过抛出一个StackOverflowError异常结束,并提到问题所在的行(在我们的例子中是递归方法调用自身的行)。

Exception in thread "main" java.lang.StackOverflowError
at chapter.four/com.apress.bgn.four.ex.ExceptionsDemo.recExceptionsDemo.java:7
at chapter.four/com.apress.bgn.four.ex.ExceptionsDemo.recExceptionsDemo.java:7
...

StackOverflowError间接是Error的子类,显然是由被调用的有缺陷的递归方法引起的。我们可以修改代码,处理这种异常情况,并执行接下来必须执行的任何事情,如清单 4-55 所示。

package com.apress.bgn.four.exceptions;
public class ExceptionsDemo {
    // other code omitted
    public static void main(String... args) {
        try {
            rec(1000);
        } catch (Throwable r) { }
        System.out.println("ALL DONE.");
    }
}

Listing 4-55Another Bad Recursive Method

在控制台中,只打印出 ALL DONE 消息,没有错误的痕迹。这是意料之中的,因为我们发现了它,并决定不发表任何有关它的信息。

img/463938_2_En_4_Figv_HTML.jpg这也是一种不好的做法,叫做异常吞咽,千万不要这样做!

此外,系统不应该恢复,因为抛出Error后的任何操作结果都是不可靠的。

img/463938_2_En_4_Figw_HTML.jpg这就是为什么,抓一个Throwable是很不好的做法!

Exception类是所有可以被捕获和处理的异常的超类,系统可以从中恢复。任何不是RuntimeException的子类的Exception类的子类都是检查异常。这些类型的异常在编译时是已知的,因为它们是方法声明的一部分。任何被声明为抛出检查异常的方法,当在代码中使用时,要么强制进一步传播异常,要么要求开发人员编写代码来处理异常。

RuntimeException类是在程序执行过程中抛出的异常的超类,所以在编写代码时它们被抛出的可能性是未知的。考虑清单 4-56 中的代码示例。

package com.apress.bgn.four.exceptions;

import com.apress.bgn.four.hierarchy.Performer;

public class AnotherExceptionsDemo {
    public static void main(String... args){
        Performer p = PerformerGenerator.get("John");

        System.out.println("TTL: " + p.getTimeToLive());
    }
}

Listing 4-56Code Sample That Might Throw an Exception

假设我们不能访问PerformerGenerator类的代码,所以我们看不到它的代码。我们只知道用名字调用get(..)方法应该返回一个Performer实例。因此,我们编写了前面的代码,并尝试打印表演者的生存时间。如果由于get("John")调用返回空值,变量p实际上没有用正确的对象初始化,会发生什么?

下一段代码描述了结果:

Exception in thread "main" java.lang.NullPointerException:
 Cannot invoke "com.apress.bgn.four.hierarchy.Performer.getTimeToLive()" because "p" is null at com.apress.bgn.four.exceptions.AnotherExceptionsDemo.main(AnotherExceptionsDemo.java:39)

正如您所看到的,异常消息告诉您什么是错误的,这是非常明确的。它实际上比这本书的前一版更明确。更精确的 NullPointerExceptions 是 Java 17 的一个特性。

但是作为聪明的开发者(或者有点偏执),我们为这种情况做好了准备。根据应用的要求,我们可以做以下三件事情中的任何一件。

1。捕捉异常并打印一条适当的消息并退出应用。使用 try/catch 块来捕获异常。语法非常简单,行为可以解释如下:JVM 试图执行 try 块中的语句;如果抛出的异常与 catch 块声明中的类型匹配,则执行该块中的代码。

当没有一个Performer实例就无法执行剩余代码时,推荐使用这种方法,如清单 4-57 所示。

package com.apress.bgn.four.exceptions;

import com.apress.bgn.four.hierarchy.Performer;

public class AnotherExceptionsDemo {
    public static void main(String... args){
        Performer p = PerformerGenerator.get("John2");

        try {
            System.out.println("TTL: " + p.getTimeToLive());
        } catch (Exception e) {
            System.out.println("The performer was not initialised properly because of: " + e.getMessage() );
        }
    }
}

Listing 4-57Code Sample That Might Throw an Exception

这里抛出的异常属于类型NullPointerException,一个扩展RuntimeException的类,所以 try/catch 块不是强制的。这种类型的异常被称为未检查异常,因为开发人员没有义务检查它们。

img/463938_2_En_4_Figx_HTML.jpgNullPointerException是 Java 初学者非常纠结的例外类型,因为他们没有足够好的“偏执狂意识”,在使用之前总是测试未知来源的对象。

2。抛出适当的异常类型。当有不同的类调用有问题的代码时,这是合适的,并且该类将适当地处理异常,如清单 4-58 所示。

// ExtraCallerExceptionsDemo.java
package com.apress.bgn.four.exceptions;

import com.apress.bgn.four.hierarchy.Performer;

class Caller {

    public void printTTL(String name) throws EmptyPerformerException   { //   thrown exception declaration
        try {
            Performer p = PerformerGenerator.get(name);
            System.out.println("TTL: " + p.getTimeToLive());
        } catch (Exception e) {
            throw new EmptyPerformerException("There is no performer named " + name, e); // wrapping happens here
        }
    }
}

public class ExtraCallerExceptionsDemo {

    public static void main(String... args){
        Caller caller = new Caller();
        try {
            caller.printTTL("John2");
        } catch (EmptyPerformerException  e) {
            System.out.println(e.getMessage());
        }
    }
}

//  EmptyPerformerException.java
package com.apress.bgn.four.exceptions;

public class EmptyPerformerException extends Exception {

    public EmptyPerformerException(String message, Throwable cause) {
        super(message, cause);
    }
}

Listing 4-58Code Sample That Wraps the Exception Into a Custom Exception Type

注意这个EmptyPerformerException类。这是一个简单的自定义类,它扩展了java.lang.Exception类,使其成为一个可检查的异常。它们被声明为由方法显式抛出,正如您在代码的第一个粗体行中看到的那样。在这种情况下,当调用该方法时,编译器将强制开发人员处理该异常或将其向前抛出。如果在没有throws EmptyPerformerException片段的情况下声明printTTL(..)方法,将会抛出一个编译时错误,并且代码不会被执行。IntelliJ IDEA 是一个非常聪明的编辑器,使用 JVM 编译器来验证你的代码,它会用红线下划线来通知你代码中有什么地方不正常。这种情况如图 4-15 所示,其中throws EmptyPerformerException被注释以显示编译器完全不同意这种情况。

img/463938_2_En_4_Fig15_HTML.jpg

图 4-15

由于检查到的异常未被声明为由 printTTL 引发而导致的编译错误(..)方法

同样,在main(..)方法中,需要一个try/catch块来捕捉和处理这种类型的异常,如清单 4-56 所示。main(..)方法也必须用throws EmptyPerformerException声明,才能被允许进一步传递异常,在本例中是传递给 JVM。

你可以把例外想象成卷曲饮料中的二氧化碳气泡:如果没有过滤器的阻挡,它们往往会浮到表面。在 Java 中,表面由运行应用的 JVM 表示。当 JVM 遇到异常时,它会停止运行应用。

注意在创建EmptyPerformerException对象的那一行中,按照构造函数声明,原始异常是如何作为参数提供的。这样做是为了使其消息不会丢失,并且可以用于调试意外情况,因为它将直接指向有问题的行。

3。执行虚拟初始化。当有问题的调用之后的代码根据返回的执行者实例做不同的事情时,这是合适的,如清单 4-59 所示。

package com.apress.bgn.four.exceptions;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.hierarchy.Performer;

class DummyInitializer {

    public Performer getPerformer(String name) {
        Performer p = PerformerGenerator.get(name);
        try {
            System.out.println("Test if valid: " + p.getName());
        } catch (Exception e) {
            p = new Performer("Dummy", 0, 0.0f, Gender.UNSPECIFIED); // exception swallowing happens here
        }
       return p;
    }
}

public class DummyInitExceptionDemo {
    public static void main(String... args) {
        DummyInitializer initializer = new DummyInitializer();
        Performer p = initializer.getPerformer("John2");

        if("Dummy".equals(p.getName())) { // different behaviour based on performer name
            System.out.println("Nothing to do.");
        } else {
            System.out.println("TTL: " + p.getTimeToLive());
        }
    }
}

Listing 4-59Code Sample That Performs a Dummy Initialization

请注意,这里的原始异常在任何地方都没有使用;它正在被吞噬,因而在出现麻烦的情况下,问题的根源就隐藏起来了。在最初的异常并不严重的应用中,会打印一条有组织的警告日志消息,以通知开发人员有一些应该注意的行为。

img/463938_2_En_4_Figz_HTML.jpg请记住,本节中列出的所有更改都适用于调用PerformerGenerator.get("John")方法的代码,因为我们假定不能修改这个类的内容。如果该类是可访问的,可以修改该方法以返回一个Optional<Performer>。更多关于这种类型的对象可以在以后的章节中读到。

既然我们在讨论异常,那么 try/catch 块可以用 finally 块来完成。如果异常与 catch 块中声明的任何类型都不匹配(catch 块中可以声明多个类型,这将在本书后面讨论),并且被进一步抛出,或者如果方法正常返回,则执行finally块的内容。唯一不执行finally块的情况是当程序出错结束时。清单 4-60 是清单 4-58 中所示代码的一个增强版本,它包括了一个用于Caller示例的 finally 块。

package com.apress.bgn.four.exceptions;

public class FinallyBlockDemo {
    public static void main(String... args) {
        try {
            Caller caller = new Caller();
            caller.printTTL("John");
        } catch (EmptyPerformerException e) {
            System.out.println("Cannot use an empty performer!");
        } finally {
            System.out.println("All went as expected!");
        }
    }
}

Listing 4-60Code Sample That Shows a finally Block

在本书的后面,将在异常情况下结束的代码有时会被用作示例,以便当您的知识更深入时,有机会进一步扩展异常主题。

无商标消费品

到本章的这一点为止,我们只讨论了用于创建对象的对象类型和 Java 模板。但是如果我们需要设计一个具有适用于多种类型对象的功能的类呢?因为 Java 中的每个类都扩展了Object类,所以我们可以用一个接收类型为Object的参数的方法来创建一个类,并且在这个方法中我们可以测试对象类型。这会很麻烦,但是可以做到,后面会讲到。

在 Java 5 中,引入了在创建对象时使用类型作为参数的可能性。被开发来处理其他类的类被称为泛型。有很多泛型的例子,但是我将从学习 Java 时首先需要的那个开始。

在编写 Java 应用时,您很可能需要将不同类型的值配对。清单 4-61 显示了一个Pair类的最简单版本,它可以保存任意类型的实例对。

package com.apress.bgn.four.generics;

public class Pair<X, Y> {

    protected X x;
    protected Y y;

    private Pair(X x, Y y) {
        this.x = x;
        this.y = y;
    }

    public X x() {
        return x;
    }

    public Y y() {
        return y;
    }

    public void x(X x) {
        this.x = x;
    }

    public void y(Y y) {
        this.y = y;
    }

    public static <X, Y> Pair<X, Y> of(X x, Y y) {
        return new Pair<>(x, y);
    }

    @Override public String toString() {
        return "Pair{" + x.toString() +", " + y.toString() + '}';
    }
}

Listing 4-61Generic Class Pair<X,Y>

我们现在有了一个通用的Pair类声明。x 和 Y 代表应用中的任何 Java 类型。toString()方法继承自Object类,并在 Pair 类中被覆盖以打印字段的值。接下来就是使用它了。为了证明Pair类可以用于耦合任何类型的实例,在清单 4-62 中,创建了以下对象对:

  • 一对Performers,我们只能假设他们一起唱歌,因为变量被命名为duet。

  • 一对Performer实例和一个Double实例代表这个执行者的净值;这个变量被命名为netWorth。

  • 一对代表表演者类型的String实例和一个Performer实例;这个变量被命名为johnsGenre。

package com.apress.bgn.four.generics;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.hierarchy.Performer;

public class GenericsDemo {

    public static void main(String... args) {
        Performer  john = new Performer("John", 40, 1.91f, Gender.MALE);
        Performer jane = new Performer("Jane", 34, 1.591f, Gender.FEMALE);

        Pair<Performer, Performer> duet = Pair.of(john, jane);
        System.out.println(duet);

        Pair<Performer, Double> netWorth = Pair.of(john, 34_000_000.03);
        System.out.println(netWorth);

        Pair<String, Performer> johnsGenre = Pair.of("country-pop", john);
        System.out.println(johnsGenre);

    }
}

Listing 4-62Using the Pair<X,Y> Generic Class

当执行前面的类时,控制台中会显示以下消息。

Pair{com.apress.bgn.four.hierarchy.Performer@279f2327, com.apress.bgn.four.hierarchy.Performer@2ff4acd0}
Pair{com.apress.bgn.four.hierarchy.Performer@279f2327, 3.400000003E7}
Pair{country-pop, com.apress.bgn.four.hierarchy.Performer@279f2327}

println(... )方法期望它的参数是一个String实例,如果不是,那么toString()方法将在作为参数给出的对象上被调用。如果toString()方法没有在扩展Object的类中被覆盖,那么来自Object类的方法将被调用,返回类的全限定名和一个叫做 hashcode 的东西,它是对象的数字表示。

JDK 中有很多泛型类可以用来编写代码,其中一些将在后面介绍。这一节只是向您介绍典型的泛型语法。这将有助于您轻松识别它们,并了解它们的使用方法。

var和钻石算符

在 Java 10 中,开发人员多年来一直要求的事情发生了:声明没有类型的变量的可能性,让编译器推断这是通过引入var关键字实现的。Python、Groovy 和 JavaScript 等语言多年来一直提供这种功能,Java 开发人员也希望如此。

写起来并不费力:

String message = "random message";

代替

var message = "random message";  // compiler infers type String

但是当涉及到多层泛型类型时,var会变得更有帮助。例如,这个语句:

HashMap<Long, Map<String, ? extends Performer>> performers = new HashMap<Long, Map<String, ? extends Performer>>() ;

可以写成

var performers = new HashMap<Long, Map<String, ? extends Performer>>() ;

同样的语句可以通过使用 Java 7 中引入的菱形操作符来简化。菱形运算符允许省略实例化变量时使用的泛型类型的名称,如果编译器可以从声明中推断出这些名称。所以前面也可以写成:

HashMap<Long, Map<String, ? extends Performer>> performers3 =
     new HashMap<>();

img/463938_2_En_4_Figaa_HTML.jpg像var performers = new HashMap<>();这样的语句是有效的,但是编译器无法决定可以添加到执行者映射中的实例的类型。所以像performers.put(null, null);这样的语句是正确的,因为null没有类型,但是像performers.put("john", "mayer");这样的语句会导致编译错误。

var关键字可以简化用 Java 编写的代码,但它还有很长的路要走。目前,只允许在方法体、增强循环的索引、lambda 表达式、构造函数以及循环和初始化块中使用它。它不能用在类字段声明或常数中。因此,编译器只能推断局部变量的类型。

var不能用来声明未初始化的变量,因为这不会给编译器任何关于变量类型的信息。所以var list;语句会导致编译器错误。但是var list = new ArrayList<String>();工作得很好。

img/463938_2_En_4_Figab_HTML.jpg虽然var不能用作标识符,但这并不能使它成为关键字。例如,这就是为什么可以声明一个名为var的类字段。由于它替换了变量的类型名,var实际上是一个保留的类型名。

摘要

本章介绍了 Java 语言中最常用的元素。希望在这一章之后,你不会在未来的代码示例中发现太多让你惊讶的代码,所以你可以专注于正确地学习语言。如果在这一点上有些事情看起来不清楚,不要担心;随着你对这门语言理解的加深,它们会变得更加清晰。以下是你读完这一章后应该留下的东西:

  • 语法错误会阻止 Java 代码被转换成可执行代码。这意味着代码没有编译。

  • 使用静态导入语句时可以直接使用静态变量。这同样适用于静态方法。

  • Java 标识符必须遵守命名规则。单下划线_不是可接受的 Java 标识符。

  • 编译器会忽略注释,Java 中有三种类型的注释。

  • 类、接口和枚举是用来创建对象的 Java 组件。

  • 枚举是特殊类型的类,只能实例化固定的次数。

  • 记录是用于创建数据不可变对象的特殊类型的类。

  • 抽象类不能被实例化,即使它们可以有构造函数。

  • 在 Java 第 8 版引入默认方法之前,接口只能包含框架(抽象)和静态方法。

  • 从 Java 9 开始,接口中允许私有方法和私有静态方法。

  • 在 Java 中,没有使用类的多重继承。

  • 接口可以扩展其他接口。

  • Java 定义了固定数量的名为的关键字,保留关键字只能用于特定目的,不能作为标识符使用。Java 关键字列表在 Java 版本之间往往保持不变。对于大于 Java 17 的版本,这个列表是不完整的。下一节将介绍保留关键字。

Java 关键字

在这一章的开始,提到有一个 Java 关键字的列表,这些关键字在语言中只能用于它们固定的和预定义的目的。这意味着它们不能用作标识符:不能用作变量、类、接口、枚举或方法的名称。你可以在表格 4-2 和 4-3 中找到它们。

表 4-2

Java 关键词(第一部分)

|

关键字

|

描述

|
| --- | --- |
| abstract | 用于将类或方法声明为抽象的,例如,任何扩展或实现类都必须提供具体的实现。 |
| assert | 用于测试关于代码的假设。它是在 Java 1.4 中引入的,被 JVM 忽略,除非程序用"-ea "选项运行。 |
| boolean byte``char``short``int``long``float double | 基本类型名。 |
| break | 语句来立即终止循环。 |
| continue | 语句,以立即跳转到下一次迭代。 |
| switch | 语句名,用于根据一组称为 cases 的值测试相等性。 |
| case | 用于在switch语句中定义事例值的语句。 |
| default | 用于在switch语句中声明默认情况。从 Java 8 开始,它可以用于在接口中声明默认方法。 |
| try``catch finally throw throws | 异常处理中使用的关键字。 |
| class interface enum | 用于声明类、接口和枚举的关键字。 |
| extends implements | 用于扩展类和实现接口的关键字。 |
| const | 在 Java 中实际上没有使用,是从C借用的一个关键字,在这里它被用来声明常量,被赋值的变量,在程序执行期间不能被改变。 |
| final | 相当于 Java 中的const关键字。用这个修饰符定义的任何东西,在最终初始化后都不能改变。final 类不能扩展。不能重写 final 方法。最终变量的值与程序执行过程中初始化的值相同。任何修改最终项的代码都会导致编译器错误。 |

img/463938_2_En_4_Figac_HTML.jpg令人惊讶的是,record并不是关键词。

表 4-3

Java 关键词(第二部分)

|

关键字

|

描述

|
| --- | --- |
| do``for``while | 用于创建循环的关键字:do{..} while(condition),while(condition){..},for(initialisation;condition;incrementation){..} |
| goto | 另一个关键字借用了C,但目前在 Java 中没有使用,因为它可以被标记的break和continue语句所取代。 |
| if else | 用于创建条件语句:if(condition) {..},else {..},else if (condition ) {..} |
| import | 用于使类和接口在当前源代码中可用。 |
| instanceof | 用于测试条件表达式中的实例类型。 |
| native | 此修饰符用于指示使用 JNI (Java 本机接口)在本机代码中实现的方法。 |
| new | 用于创建 Java 实例。 |
| package | 用于声明类/接口/枚举/注释/记录所属的包。它应该是第一个 Java 语句行。 |
| public private protected | Java 项目(模板、字段或方法)的访问级别修饰符。 |
| return | 在方法中使用的关键字,用于返回调用它的代码。该方法还可以向调用代码返回值。 |
| static | 这个修饰符可以应用于变量、方法、块和嵌套类。它声明了一个在声明的类的所有实例之间共享的项。 |
| stricfp | 用于限制浮点计算以确保可移植性。在 Java 1.2 中添加。 |
| super | 在类内部使用的关键字,用于访问超类的成员。 |
| this | 用于访问当前对象成员的关键字。 |
| synchronized | 用于确保在任何给定时间只有一个线程执行一个代码块。这是用来避免竞争条件引起的问题。 4 |
| transient | 用于标记不应序列化的数据。 |
| volatile | 用于确保所有访问变量值的线程都可以访问对变量值所做的更改。 |
| void | 在将方法声明为返回类型时使用,以指示该方法不返回值。 |
| _(underscore) | 不能用作从 Java 9 开始的标识符。 |

重要提示:

  • true和false是布尔文字,但不是保留关键字。例如,true和false是有效的包名。

  • var是保留的类型名。例如,var可以用作字段名或包名。

  • null也不是保留关键字。它是一个用于表示缺失对象的文字,但它是一个包的有效名称,例如。

  • yield和record不是保留关键字,而是受限标识符。

  • 在模块被添加后,单词module和所有指令的名字变成了受限关键字。它们是特殊的词,仅用于声明和配置模块的唯一目的。

五、数据类型

在前一章中写了很多 Java 代码,但是在设计类的时候只使用了最简单的数据类型:一些数字和文本。在 JDK 中,许多数据类型被声明用于多种目的:为日历日期建模;用于表示多种类型的数字;以及处理文本、集合、文件、数据库连接等等。除了 JDK,还有其他人创建的提供更多功能的库。JDK 提供的数据类型是基本类型,是构建每个 Java 应用的基础。根据您正在构建的应用的类型,您可能不需要所有这些组件。例如,我从来没有机会使用java.util.logging.Logger类。我开发的大多数应用在我来的时候已经由不同的团队建立了,他们使用外部库,比如Log4j、Logback,或者日志抽象,比如Slf4j。

本节将介绍编写任何 Java 应用的 80%都需要的基本 Java 数据类型。

栈和堆内存

Java 类型可以分为两大类:基本类型和引用类型。Java 代码文件存储在硬盘上,Java 字节码文件也是如此。Java 程序运行在 JVM 上,JVM 通过执行java可执行文件作为一个进程启动。在执行过程中,所有的数据都存储在两种不同类型的内存中,分别名为:栈和堆,它们是由操作系统为程序的执行而分配的。

在执行期间(也称为在运行时)使用栈内存来存储方法原语局部变量和对存储在堆中的对象的引用。栈也是一种数据结构,由只能在一端访问的值列表表示,也称为 LIFO 顺序,是后进先出的缩写。这个名字很贴切,因为每次调用一个方法,都会在栈内存中创建一个新的块来保存该方法的局部变量:原语和对该方法中其他对象的引用。当调用结束时,该块被移除(弹出),并为之后调用的方法创建新的块。

栈数据结构非常类似于一堆盘子:你只能在顶部添加或移除额外的盘子。栈中的第一个元素称为头。在栈上执行的操作有特定的名称:将一个元素添加到栈中被称为推送操作,检查栈中的第一个元素被称为 peek 或 top ,提取栈中的第一个元素,它的头,被称为 pop 。通过反复调用 pop 来清空栈,直到其大小为零。

每个 JVM 执行线程都有自己的栈内存,其大小可以使用 JVM 参数-Xss(或者等效且更显式的- XX:ThreadStackSize)来指定。如果分配了太多变量——或者被调用的方法是递归的并且设计得很差——返回的条件永远不会满足,因此会一直调用自己,您将会遇到一个java.lang.StackOverflowError,这意味着没有剩余的栈内存,因为每个方法调用都会导致在栈上创建一个新的块。栈内存的大小取决于运行 JVM 的平台,对于基于 Unix 的系统(Linux 和 macOS)是 1024KB,对于 Windows,它取决于虚拟内存。有一种方法可以在你的电脑上检查它的大小。只需打开一个终端或命令提示符,运行这个命令:java -XX:+PrintFlagsFinal -version。该命令返回一个 JVM 配置列表,称为标志。其中一些用于配置允许 JVM 管理的内存。

清单 5-1 显示了在我的 macOS 电脑上执行的命令。grep命令根据作为参数提供的标准过滤输出,从而产生一个更干净和有范围的输出。

> java -XX:+PrintFlagsFinal -version | grep ThreadStack
  #  Data Type  # Flag Name  #  = # Flag Value
     intx ThreadStackSize       = 1024

Listing 5-1Showing the Stack Size Default Values on macOS

堆内存在运行时用于为对象和 JRE 类分配内存。对象是 JDK 类或开发人员定义的类的实例。用 new 创建的任何对象都将存储在堆内存中。应用的所有线程都可以访问堆内存中创建的对象。堆内存的访问和管理稍微复杂一些,将在第章 13 中详细介绍。JVM 参数用于在执行过程中为 Java 程序设置堆内存的初始和最大大小。堆的大小可能根据程序创建的对象数量而变化,如果分配给 Java 程序的所有堆内存都满了,那么就会抛出一个java.lang.OutOfMemoryError。堆内存的默认大小取决于运行 JVM 的计算机的物理可用内存,它的最小值和最大值以及其他附加数据也可以从java -XX:+PrintFlagsFinal -version的输出中提取。

清单 5-2 显示了在我的计算机上执行的命令,这是一台 macOS,总物理内存为 16GB。grep命令根据作为参数提供的标准过滤输出,从而产生一个更干净和有范围的输出。

> java -XX:+PrintFlagsFinal -version | grep HeapSize
#  Data Type  # Flag Name  #  = # Flag Value
   size_t MaxHeapSize         = 4294967296
   size_t MinHeapSize         = 8388608

Listing 5-2Showing the Heap Size Default Values on macOS

img/463938_2_En_5_Figb_HTML.jpg如果你想了解更多关于 JVM 标志的知识,这个技术博客上有用的 JVM 标志系列文章是一个很好的来源: https://blog.codecentric.de/en/?s=JVM+Flags&x=0&y=0 。

img/463938_2_En_5_Figc_HTML.jpg虽然现在看到这些信息的重要性还为时过早,但是有许多 Java 命令行选项可能会对您在实际应用中的工作有所帮助。所以把这个链接也加入到你的必备收藏中: https://docs.oracle.com/en/java/javase/17/docs/api/index.html 。

java.lang.String类是 Java 编程语言中最常用的类。因为应用中的文本值可能具有相同的值,所以出于效率原因,这种类型的对象在堆中的管理方式略有不同。在堆中有一个名为字符串池的特殊内存区域,所有的字符串实例都由 JVM 存储在这里。这里必须提到这一点,因为下面这段解释 Java 如何管理内存的代码包含了一个String实例的定义,但是字符串池和其他关于String数据类型的细节将在本章后面的章节中详细介绍。

让我们考虑一下清单 5-3 中的可执行类,想象一下在它执行期间内存是如何组织的。

01\. package com.apress.bgn.five;
02.
03\. import java.util.Date;
04.
05\. public class PrimitivesDemo {
06\.     public static void main(String... args) {
07\.         int i = 5;
08\.         int j = 7;
09\.         Date d = new Date();
10\.         int result = add(i, j);
11\.         System.out.print(result);
12\.         d = null;
13\.     }
14.
15\.     static int add(int a, int b) {
16\.        String mess = new String("performing add ...");
17\.        return a + b;
18\.     }
19\. }

Listing 5-3Code Sample Used to Discuss Memory Usage

仅仅看一下这段代码,你就能判断出哪些变量保存在栈上,哪些保存在堆上吗?让我们一行一行地检查程序,看看发生了什么:

img/463938_2_En_5_Fig1_HTML.jpg

图 5-1

Java 栈和堆内存,在声明了两个原始变量之后

  • 程序一启动,JVM 需要的运行时类就被加载到堆内存中。

  • 第 06 行发现了main(..)方法,因此创建了一个栈内存,在执行该方法时使用。

  • 第 07 行的原始局部变量i=5被创建并存储在main(..)方法的栈存储器中。

  • 第 08 行的原始局部变量j=7被创建并存储在main(..)方法的栈存储器中。此时,程序存储器看起来如图 5-1 所示。

img/463938_2_En_5_Fig2_HTML.jpg

图 5-2

Java 栈和堆内存,在声明了两个原始变量和一个对象之后

  • 在第 09 行声明了一个类型为java.util.Date的对象,所以这个对象被创建并存储在堆内存中,一个名为 d 的引用被保存在栈中。此时,程序存储器看起来如图 5-2 所示。

img/463938_2_En_5_Fig3_HTML.jpg

图 5-3

Java 栈和堆内存,在调用 add(..)方法

  • 在第 10 行,方法add(..)用参数i和j调用。这意味着它们的值将被复制到这个名为a和b的方法的局部变量中,这两个变量将被存储在这个方法的内存块中。

  • 在第 16 行的add(..)方法体内,声明了一个String实例。因此,String对象在堆内存中创建,在字符串池内存块中,名为mess的引用存储在栈中,在这个方法的内存块中。此时,程序存储器看起来如图 5-3 所示。

  • 同样在第 10 行,方法add(..)的执行结果被存储到名为result的局部变量中。此时,add(..)方法已经完成了它的执行,所以它的栈块被丢弃了。因此,我们可以得出结论,只要创建变量的函数在运行,存储在栈中的变量就会一直存在。结果变量保存在main(..)方法的栈存储器中。

  • 在第 11 行中,调用了 print 方法,但是为了简单起见,我们将跳过对这一行的解释。

  • 在第 12 行中,d引用被赋予了一个null值,这意味着类型为Date的对象现在只在堆中,没有以任何方式链接到main(..)方法的执行。在这一行中,JVM 被告知不再需要该对象,因此可以安全地将其丢弃。这意味着包含它的空间可以被收集起来用于存放其他物品。

此时,程序存储器看起来如图 5-4 所示。

img/463938_2_En_5_Fig4_HTML.jpg

图 5-4

Java 栈和堆内存,在主(..)方法执行

显然,程序执行结束后,所有内存内容都将被丢弃。

从一个版本到另一个版本,Java 进行内存管理的方式有了一些小的变化——决定如何以及何时在堆中分配和释放空间的算法得到了优化——但是总的内存组织在过去几年中并没有太大的变化。

img/463938_2_En_5_Figd_HTML.jpg在申请 Java 开发人员职位时,你很可能会被问到栈内存和堆内存的区别。所以如果前一节没有为你澄清这两个观念,请随时查阅其他资源,比如这篇非常好的文章: https://www.journaldev.com/4098/java-heap-space-vs-stack-memory 。

Java 数据类型简介

正如您在前面的例子中注意到的,在 Java 中,数据类型可以根据它们在执行过程中存储的位置和方式分成两大组:原语类型和引用类型。先简单介绍一下,稍后再解释他们最重要的成员。

原始数据类型

原始类型由 Java 编程语言定义为没有支持类的特殊类型,并由它们的保留关键字命名。这些类型的变量保存在栈存储器中,当使用=(等于)运算符给它们赋值时,值实际上被复制了。因此,如果我们声明两个类型为int的原始变量,如清单 5-4 所示:

package com.apress.bgn.five;

public class AnotherPrimitivesDemo {

    public static void main(String... args) {
        int k = 42;
        int q = k;
        System.out.println("k = " + k);
        System.out.println("q = " + q);
    }
}

Listing 5-4Code Sample Used to Discuss Primitives

我们最终得到两个变量,k和q,它们都有相同的值:42。当作为参数传递给其他方法时,原始值的值被复制和使用,而不修改初始变量。

这意味着在 Java 方法中,原始参数是通过值传递的。

这可以通过创建一个交换两个int变量的值的方法来证明。清单 5-5 中描述了该方法的代码以及如何使用它。

package com.apress.bgn.five;

public class SwappingPrimitivesDemo {
    public static void main(String... args) {
        int k = 42;
        int q = 44;
        swap(k, q);
        System.out.println("k = " + k);
        System.out.println("q = " + q);
    }

    static void swap(int a, int b) {
        int temp = a;
        a = b;
        b = temp;
    }
}

Listing 5-5Code Sample Used to Show Primitives Are Passed By Value

那么你认为什么会被打印成k和q的值呢?如果您认为输出与这里列出的一样,那么您是正确的。

k = 42
q = 44

发生这种情况是因为在 Java 中,向方法传递参数是通过它们的值来完成的,这意味着对于原语来说,改变形参(方法参数)的值不会影响实际参数的值。如果你读了前一节,你已经可以想象在栈上发生了什么。当调用swap(..)方法时,会创建一个新的栈内存块来保存该方法使用的值。在方法执行期间,这些值可能会更改,但是如果它们没有被返回并赋给调用方法中的变量,则在方法执行结束时,这些值会丢失。图 5-5 描述了在前面列出的代码执行过程中发生在栈上的变化。

img/463938_2_En_5_Fig5_HTML.jpg

图 5-5

Java 通过值传递原始参数

参考数据类型

Java 中有 6 个引用类型:

  • 类别类型

  • 接口类型

  • 枚举数

  • 数组类型

  • 记录

  • 附注

引用类型不同于基本类型,因为这些类型是可实例化的(接口和注释除外)。这些类型的实例(对象)是通过调用构造函数创建的。这些类型的变量实际上只是对存储在堆中的对象的引用。因为引用也存储在栈中,所以即使我们修改前面的代码来使用引用,行为也是一样的。

清单 5-6 引入了一个名为IntContainer的类,唯一的目的是将int原始值包装到对象中。

package com.apress.bgn.five;

public class IntContainer {
    private int value;
    public IntContainer(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
    public void setValue(int value) {
        this.value = value;
    }
}

Listing 5-6Code Sample Used to Show IntContainer

清单 5-7 展示了这种类型的两个对象和它们的两个引用的创建,以及交换方法的新版本。

package com.apress.bgn.five;

public class ReferencesDemo {

    public static void main(String... args) {
        IntContainer k = new IntContainer(42);
        IntContainer q = new IntContainer(44);
        swap(k,q);
        System.out.println("k = " + k.getValue());
        System.out.println("q = " + q.getValue());
    }

    static void swap(IntContainer a, IntContainer b) {
        IntContainer temp = a;
        a = b;
        b = temp;
    }
}

Listing 5-7Code Sample Used to Show Swap of Two int Values Using References

如果我们运行main(..)方法,您会注意到我们仍然得到:

k = 42
q = 44

这怎么解释?Java 仍然使用相同风格的参数传递,通过值传递,只是这次引用的值是被传递的值。图 5-6 描述了在 JVM 管理的内存中执行前面代码的情况。

img/463938_2_En_5_Fig6_HTML.jpg

图 5-6

Java 通过值传递引用参数

对对象的引用在swap(..)方法的主体中互换,但是它们对 k 和 q 引用没有影响,对它们在堆中指向的对象也没有影响。为了真正交换值,我们需要通过使用一个新的对象来交换对象的内容。看看清单 5-8 中描述的swap(..)方法的新版本。

package com.apress.bgn.five;

public class ReferencesSwapDemo {

    public static void main(String... args) {
        IntContainer k = new IntContainer(42);
        IntContainer q = new IntContainer(44);
        swap(k,q);
        System.out.println("k = " + k.getValue());
        System.out.println("q = " + q.getValue());
    }
    static void swap(IntContainer a, IntContainer b) {
        IntContainer temp = new IntContainer(a.getValue());
        a.setValue(b.getValue());
        b.setValue(temp.getValue());
    }
}

Listing 5-8Code Sample Used to Show Swap of Two int Values Using References That Actually Swaps the Values

通过使用 setters 和 getters,我们可以交换对象的值,因为引用在方法体中不会被修改。图 5-7 描述了前一段代码执行期间内存中发生的情况。

img/463938_2_En_5_Fig7_HTML.jpg

图 5-7

Java 通过值传递引用参数,交换对象内容

也许这个例子介绍得太早了,但是它很重要,因此您可以尽早看到原始类型和引用类型之间的主要区别。我们将在总结中列出所有的差异;在此之前,我们先介绍一下 Java 中最常用的数据类型。

如果我们运行清单 5-8 中的main(..)方法,您会注意到k和q的值被交换了,如这里描述的输出所示。

k = 44
q = 42

Java 原始类型

基本类型是 Java 中的基本数据类型。这种类型的变量可以通过直接分配该类型的值来创建,这样它们就不会被实例化。在 Java 中有八种基本类型:其中六种用于表示数字,一种表示字符,一种表示boolean值。基本类型是 Java 语言中预定义的,它们的名称是保留关键字。原始变量只能在为该类型预定义的区间或数据集中有值。当在实例化时被声明为类的字段时,特定于该类型的默认值被赋给该字段。原始值不与其他原始值共享状态。

大多数 Java 教程首先介绍数字类型,然后介绍最后两种,但是本书将从非数字类型开始。

boolean型

这种类型的变量只能有两个可接受的值之一:true和false。如果你听说过布尔逻辑,这应该是你熟悉的。在 Java 中,这种类型的值用于设置/取消设置标志和设计执行流。值true和false本身是保留关键字。

img/463938_2_En_5_Figf_HTML.jpg变量boolean的默认值为假。

另一个观察:当一个字段是类型boolean时,它的 getter 有不同的语法。不是以get为前缀,而是以is为前缀。Java IDEs 尊重这一点,并按预期生成 getters。这是有意义的,因为布尔值是用来做什么的。它们对于仅使用两个值的属性建模非常有用。例如,假设我们正在编写一个类来模拟一个转换过程。布尔字段可用于将流程状态标记为已完成或仍在进行中。如果字段的名称是done,一个名为getDone()的 getter 将会非常不直观和愚蠢,但是一个名为isDone()的 getter 将会完全相反。

清单 5-9 描述了那个类,还展示了一个main(... )方法来测试done字段的默认值。

package com.apress.bgn.five;

public class ConvertProcessDemo {
    /* other fields and methods */
    private boolean done;
    public boolean isDone() {
        return done;
    }
    public void setDone(boolean done) {
        this.done = done;
    }
    public static void main(String... args) {
        ConvertProcessDemo cp = new ConvertProcessDemo();
        System.out.println("Default value = " + cp.isDone());
    }
}

Listing 5-9Code Sample Used to Show Usage of boolean Fields

和预期的一样,打印的输出是:

Default value = false

布尔类型与任何其他基元类型都不兼容;通过简单赋值(使用=)将布尔值赋给 int 变量是不可能的。显式转换也是不可能的。所以写这样的东西:

boolean f = false;
int fi = (int) f;

导致如下所示的编译错误。

> javac com/apress/bgn/five/PrimitivesDemo.java
com/apress/bgn/five/PrimitivesDemo.java:39: error: incompatible types: boolean cannot be converted to int
        int fi = (int) f;
                       ^
1 error

我们将在第章 第六章 中添加更多关于这种类型的信息。

char型

char类型用于表示字符。这些值是表示 UTF-16 代码单元的 16 位无符号整数。char 变量的可能值的区间是从' \u0000 '到' \ uffff '包括这两个值;作为数字,这意味着从 0 到 65535。这意味着我们实际上可以尝试打印完整的值集。由于字符的表示是数字,这意味着我们可以将前面提到的区间值转换成字符值。

清单 5-10 打印 char 区间的所有数值及其匹配字符。

package com.apress.bgn.five;

public class CharListerDemo {
    public static void main(String... args) {
        for (int i = 0; i < 65536; ++i ) {
            char c = (char) i;
            System.out.println("c[" + i + "]=" + c);
        }
    }
}

Listing 5-10Code Sample Used to Print All char Values

img/463938_2_En_5_Figg_HTML.jpg循环语句输出的最后一个char值是 65535。65536 值仅用作最大值上限。所以如果i=65336,那么什么都不打印,语句执行结束。for回路将在章节 ** 7 ** 中详细介绍。

根据操作系统的不同,某些字符可能不受支持。这意味着它们不会被显示,或者它们将被替换为虚假字符。空白字符也是如此。

如果你觉得专用于表示字符的区间太大,只要滚动控制台就明白为什么了。UTF-16 字符集包含所有数字字符、所有分隔符、中文和阿拉伯语字符以及更多符号。 1

数字原始类型

在到目前为止介绍 Java 语言基础的代码示例中,我们主要使用了类型为int的变量,但是在 Java 中有不止一种数字原语类型。Java 定义了六种基本的数字类型。它们中的每一个在一定数量的位上都有特定的内部表示,这显然意味着它们受到最小值和最大值的限制。有四种数值类型表示整数值,两种数值类型表示实数。在图 5-8 中,你可以看到整数(非实数)类型和每种类型值的区间。

img/463938_2_En_5_Fig8_HTML.jpg

图 5-8

Java 数字整数(非实数)类型

计算机中的任何东西都是用比特信息来表示的;每一位只能有 1 或 0 的值,这也是它被称为二进制表示的原因。二进制表示不是本书的重点,但会简短提及,因为它很重要。你可能想知道为什么我们的计算机选择了二进制表示。这主要是因为数据(在内存和硬盘上)是使用一系列 1(开)和 0(关)二进制表示来存储的;此外,二进制运算真的很容易做到,这使得计算机非常快。

让我们以数学为例:我们广泛使用十进制,它由从 0 到 9 的 10 个唯一的数字组成。计算机内部使用二进制系统,只使用两个数字:0 和 1。为了表示大于 1 的数字,我们需要更多的位。所以在十进制系统中,我们有 0,1,2,3,4,5,6,7,8,9,10,11 等等。在二进制系统中,我们只有两位数来表示数字,所以我们会有 0,1,10,11,100,101,110,111,1000 等等。如果你想象一个盒子,里面只能放 1 和 0,像计算机一样表示数字,随着数字变大,你需要越来越多。由于一个位只能有两个值,所以要表示的值的数量由 2 的幂来定义。只看图 5-9 。

img/463938_2_En_5_Fig9_HTML.jpg

图 5-9

二进制数字表示

在一位上我们可以表示两个值,就是 21;在两位上我们可以表示四个值,也就是 22;等等。这就是我们将如何引用 Java 原始数字类型表示边界,有时还包括一个符号位。

Java 整数基本类型

下面的列表包含整数基元类型及其边界。

  • byte用于表示-2 7 和 2 7 -1 之间的数([-128,127])。字节字段的默认值为 0,用 8 位表示。

  • short用于表示-2 15 和 2 15 -1 之间的数字([-32768,32767])。此类型的间隔是byte间隔的超集;因此,字节值可以安全地赋给短变量,而不需要显式转换。这适用于所有区间为字节类型的超集的类型。在下一个代码片段中,一个字节值被赋给一个短变量,代码编译并在执行时打印 23。short字段的默认值为 0,用 16 位表示。

  • int用于表示-2 31 和 2 31 -1 之间的整数([-2147483648,2147483647])。int字段的默认值为 0,用 32 位表示。

  • long用于表示-2 63 和 2 63 -1 之间的整数([-9223372036854775808,9223372036854775807])long字段的默认值为 0,用 64 位表示。

    img/463938_2_En_5_Figh_HTML.jpg在实践中,有时需要处理区间之外的整数。对于这些情况,Java 中有一个名为BigInteger的特殊类(一个类,而不是一个基本类型),它分配存储任意大小的数字所需的内存。使用BigInteger的操作可能会很慢,但这是处理大量数据的代价。

byte bv = 23;
short sbv = bv;
System.out.println("byte to short: " +   sbv);

Java 实数原始类型

说到算术,除了整数之外,我们还有real数,它们非常有用,因为大多数价格和程序执行的大多数算术运算都不会产生整数。实数包含小数点和小数点后的小数。为了在 Java 中表示实数,定义了两种基本类型,称为浮点类型。浮点类型有float和double。这里将详细介绍每一项功能:

  • float用于表示二进制浮点算术 IEEE 标准 ANSI/IEEE 标准 754-1985 (IEEE,纽约)中规定的单精度 32 位格式 IEEE 754 值。默认值为 0.0。与相同位宽的定点变量相比,浮点变量可以表示更大范围的数字,但精度会受到影响。类型为int或long的值可以分配给类型为float的变量。实际发生了什么,为什么精度会下降?一个数用浮点数和指数表示,指数实际上是 10 的幂。因此,当浮点数乘以 10 的这个指数幂时,应该会得到初始数。让我们取最大的 long 值,并将其赋给一个 float 变量,然后检查打印的内容。
float maxLongF = Long.MAX_VALUE;
System.out.println("max long= " + Long.MAX_VALUE);
System.out.println("float max long= " + maxLongF);

漫长的。MAX_VALUE 是一个最终的静态变量,它被赋予了最大的 long 值:9223372036854775807。前面的代码会打印出什么?以下内容:

max long= 9223372036854775807
float max long= 9.223372E18

如你所见,maxLongF 数应该等于 9223372036854775807,但是因为它被表示为一个更小的数和 10 的幂,所以失去了精度。如果我们将 9.223372 乘以 10 18 来重构整数,我们将得到 922337200000000000。很接近,但还不够接近。那么 float 的区间边缘是什么呢?Float 用来表示 1.4E -45 和 2 128 * 10 38 之间的实数。

  • double用于表示 IEEE 二进制浮点运算标准 ANSI/IEEE 标准 754-1985 (IEEE,New York)中规定的单精度 64 位格式 IEEE 754 值,用于表示 4.9E -324 和 2 127 * 10 308 之间的数字。默认值为 0.0。

img/463938_2_En_5_Figu_HTML.jpg值0和0.0(double)在 Java 中是不同的。对于普通用户来说,它们都意味着零,但是在数学中,带小数点的那个更精确。尽管如此,在 Java 中我们被允许比较一个int值和一个float值,如果我们比较0和0.0,结果将是它们相等。同样,正零和负零被认为是相等的;因此,比较结果0.0==-0.0也为真。

在本节的最后,还应该强调的是,开发人员不能通过从头开始定义或扩展现有的基元类型来定义基元类型。类型名是保留的 Java 关键字,不能被开发者重新定义。禁止声明以这些类型命名的字段、方法或类名。

正如您到目前为止所注意到的,我们打算使用的变量必须先声明,然后再使用。当它被声明时,也可以关联一个值。对于原始值,一个数可以有多种写法。在清单 5-11 中,你可以看到一些例子,当变量被初始化或赋值后,数值是如何被写入的。

package com.apress.bgn.five;

public class NumericDemo {
    private byte b;   // default value 0
    private short s;  // default value 0
    private int i;    // default value 0
    private long l;   // default value 0
    private float f;  // default value 0.0
    private double d; // default value 0.0

    public static void main(String... args) {
        NumericDemo nd = new NumericDemo();

        nd.b = 0b1100;
        System.out.println("Byte binary value: " + nd.b);

        nd.i = 42 ;  // decimal case

        nd.i = 045 ; // octal case - base 8
        System.out.println("Int octal value: " + nd.i);

        nd.i = 0xcafe ; // hexadecimal case - base 16
        System.out.println("Int hexadecimal value: " + nd.i);

        nd.i = 0b10101010101010101010101010101011;
        System.out.println("Int binary value: " + nd.i);

        //Starting with Java 7 ‘_’ can be used in numeric values
        nd.i = 0b1010_1010_1010_1010_1010_1010_1010_1011;
        System.out.println("Int binary value: " + nd.i);

        nd.l = 1000_000l; // equivalent to 1000_000L
        System.out.println("Long value: " + nd.l);

        nd.f = 5;
        System.out.println("Integer value assigned to a float variable: " + nd.f);

        nd.f = 2.5f; // equivalent to nd.f = 2.5F;
        System.out.println("Decimal value assigned to a float variable: " + nd.f);

        nd.d = 2.5d; // equivalent to nd.d = 2.5D;
        System.out.println("Decimal value assigned to a double variable: " + nd.f);
    }
}

Listing 5-11Code Sample Used to Print Primitive Values in Multiple Ways

从前面的清单中可以看出,整数在 Java 中有四种表示方式:

  • 十进制:以 10 为基数,用数字 0 到 9 书写。

  • 八进制:基数 8,用数字 0 到 7 书写,前缀为 0(零);这意味着数字 8 在八进制中表示为 010 位。

  • 十六进制:基数 16,用数字 0 到 9 和字母 A 到 F 书写,小写或大写,前缀为0x或0X;这意味着数字 10 以十六进制表示为 0x00A,11 表示为 0x00B,依此类推,直到集合中的字母结束,16 表示为 0x010。

  • 二进制(基数 2) :基数 2,用数字 0 和 1 书写,前缀为0b或0B。这一点在解释 bits 时已经讲过了。

你可以在计算机编程书籍中读到更多关于数字表示的内容,但是除非你最终从事一些需要你做数学运算的项目,否则你很少会用到十进制以外的表示。

从 Java 7 开始,在声明数值时允许使用“_”(下划线),以便将数字组合在一起并增加清晰度。有一些限制,比如我们:

  • “_”不能用在数值的开头或结尾。

  • “_”不能用于字节值。

  • “_”不能用在表示基数的数字或符号旁边(0b/0B 表示二进制,0 表示八进制,0x/0X 表示十六进制)。

  • “_”不能用在小数点旁边。

清单 5-11 中被执行的代码的输出在清单 5-12 中描述。

Byte binary value: 12
Int octal value: 37
Int hexadecimal value: 51966
Int binary value: -1431655765
Int binary value: -1431655765
Long value: 1000000
Integer value assigned to a float variable: 5.0
Decimal value assigned to a float variable: 2.5
Decimal value assigned to a double variable: 2.5

Listing 5-12Output Resulted by Executing the Code in Listing 5-11

由于打印变量时没有格式化,控制台中显示的值是十进制的。

目前,这就是关于基本类型的所有内容。每个基本类型都有一个在 JDK 中定义的匹配的引用类型,这将在本章后面提到。

Java 引用类型

前面给出了 Java 引用类型的简短描述,以尽早强调原始类型和引用类型之间的区别。现在是时候扩展这个描述了,并给出一些编程时最常用的 JDK 引用类型的例子。

对象或实例是使用new关键字创建的,后跟对构造函数的调用。构造函数是一个类的特殊成员,用于通过用默认值或作为参数接收的值初始化类的所有字段来创建对象。通过调用类构造函数(其中之一,因为在类中可能定义了多个构造函数)来创建类实例。考虑我们在第四章Performer类中的例子,为了声明对Performer类型对象的引用,使用了下面的表达式:

Performer human = new Performer("John", 40, 1.91f, Gender.MALE);

接口引用类型不能被实例化,但是实现该接口的类类型的对象可以被分配给该接口类型的引用。第四章中使用的层级如图 5-10 所示。

img/463938_2_En_5_Fig10_HTML.jpg

图 5-10

类和接口层次结构

基于这个层次结构,清单 5-13 中的四个语句是有效的;他们编译,代码可以成功执行。

package com.apress.bgn.five;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.hierarchy.*;

public class ReferencesDemo {

    public static void main(String... args) {
        Performer performer = new Performer("John", 40, 1.91f, Gender.MALE);
        Human human = new Performer("Jack", 40, 1.91f, Gender.MALE);
        Actor actor = new Performer("Jean", 40, 1.91f, Gender.MALE);
        Musician musician = new Performer("Jodie", 40, 1.71f, Gender.FEMALE);
    }
}

Listing 5-13Code Sample Showing Different Reference Types

在前面的例子中,我们创建了四个类型为Performer的对象,并将它们分配给不同的引用类型,包括两个接口引用类型。如果我们用前面的方法检查栈和堆的内容,我们会发现下面的内容(图 5-11 ):

img/463938_2_En_5_Fig11_HTML.jpg

图 5-11

多个引用类型

前一个例子中的所有引用都指向堆中的不同对象。让不同类型的引用指向同一个对象也是可能的,如清单 5-14 所示。

package com.apress.bgn.five;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.hierarchy.*;

public class ReferencesDemo {

    public static void main(String... args) {
        Performer performer = new Performer("John", 40, 1.91f, Gender.MALE);
        Human human = performer;
        Actor actor = performer;
        Musician musician = performer;
    }
}

Listing 5-14Code Sample Showing Different Reference Types Pointing to the Same Object

在前面的代码片段中,我们只创建了一个对象,但是对它有多个不同类型的引用。如果我们再次检查栈和堆的内容,对于前面的方法,我们会发现下面的内容(图 5-12 ):

img/463938_2_En_5_Fig12_HTML.jpg

图 5-12

多个引用类型,第二个示例

引用只能是被赋值对象的类型或超类型,所以清单 5-15 中的赋值不会被编译。

package com.apress.bgn.five;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.hierarchy.*;

public class BadReferencesDemo {

    public static void main(String... args) {
        Performer performer = new Performer("John", 40, 1.91f, Gender.MALE);
        Human human = performer;
        Actor actor = performer;
        Musician musician = performer;
        //these will not compile!!!
        performer = musician;
        //or
        performer = human;
        //or
        performer = actor;
    }
}

Listing 5-15Code Sample Showing Assignments Failing at Compile Time

这是因为这些方法是在引用类型上调用的,所以引用指向的对象必须有这些方法。这就是为什么 Java 编译器会报错,这就是为什么智能编辑器会用红线在语句下画线来通知您。在前面的例子中,修复编译错误最简单的方法是显式转换为Performer类型。但这并不意味着代码将可运行。

证明这一点最简单的方法是创建一个名为Fiddler的类,它实现了Musician,并将这个类的一个实例分配给一个Performer引用。将Fiddler实例显式转换为Performer是欺骗编译器接受该代码为有效代码所必需的,如清单 5-16 中的标记行所示。

package com.apress.bgn.five;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.hierarchy.*;

public class BadReferencesDemo {

    public static void main(String... args) {
        Musician fiddler = new Fiddler(true);
        Performer performer = (Performer) fiddler;
        System.out.println("Learned the skill at: " + performer.getSchool());
        System.out.println("Appeared in movies: " + performer.getFilms());
    }
}

class Fiddler implements Musician {
    private boolean ownsFiddle = false;

    public Fiddler(boolean ownsFiddle) {
        this.ownsFiddle = ownsFiddle;
    }

    @Override
    public String getSchool() {
        return "Irish Conservatory";
    }

// other methods omitted
}

Listing 5-16Code Sample Showing Assignments Failing at Runtime

Fiddler实例被显式转换为Performer,编译器接受了这一点,因为它假设我们知道自己在做什么。然后将转换后的实例赋给类型为Performer的引用,然后在其上调用方法getSchool()和.getFilms()。

运行前面的代码时,您会期望performer.getSchool()方法被正确执行,并在控制台中打印出“在:爱尔兰音乐学院学到的技能”,因为毕竟类Fiddler实现了Musician,并为getSchool()提供了具体的实现。您还会期望在执行下一行时抛出一个异常;调用performer.getFilms()是不可能的,因为类Fiddler没有实现Actor,也没有为getFilms()方法提供具体的实现。

但是这不是 JVM 做事的方式。实际上,在运行这段代码时,在执行转换行时会抛出一个异常,因为一个Fiddler实例不能转换成一个Performer实例。类似这样的消息将在控制台中以红色打印出来。

Exception in thread "main" java.lang.ClassCastException:
    class com.apress.bgn.five.Fiddler cannot be cast to class com.apress.bgn.four.hierarchy.Performer (com.apress.bgn.five.Fiddler is in module chapter.five.primitives of loader 'app'; com.apress.bgn.four.hierarchy.Performer is in module chapter.four of loader 'app') at chapter.five.primitives/com.apress.bgn.five.BadReferencesDemo.main(BadReferencesDemo.java:56)

数组

关键字new也可以用来创建arrays,与创建对象的方式类似。array是一种将一组值保存在一起的数据结构。它的大小是在创建时定义的,不能更改。可以使用索引来访问每个变量,索引从 0 开始,一直到数组-1 的长度。数组可以保存原始值和引用值。清单 5-17 包含一个类,该类声明了一个数组字段,该字段将int的值组合在一起。

package com.apress.bgn.five;

public class ArrayDemo {

    int array[];

    public static void main(String... args) {
        ArrayDemo ad = new ArrayDemo();
        System.out.println("array was initialized with " + ad.array);
    }
}

Listing 5-17Class with int Array Field

根据括号的位置,有两种方法来声明数组:在数组名之后或在数组元素类型之后:

int array[];

int[] array;

了解这一点很重要,因为如果你有兴趣获得 Java 知识认证, 2 考试可能会包含关于声明数组的正确方法的问题。

当前面的代码被执行时,你认为会在控制台中打印出什么?如果您假设ad.array字段将以null开头,并且打印的消息将是“array was initialized with null”,那么您的假设非常正确。

数组是引用类型,即使它们包含原始类型的元素,因此当让 JVM 用默认值初始化这种类型的字段时,将使用null,因为这是引用类型的典型默认值。前面提到了null这个关键词,不过还是强调一下它的重要性吧。null关键字用于表示一个不存在的值。被赋予此值的引用没有被赋予具体的对象;它不指向堆中的对象。这就是为什么在编写代码时,如果一个对象在初始化之前被使用(通过它的引用),就会抛出一个NullPointerException。这就是为什么开发人员在使用对象(或数组)之前要测试是否等于null。

前面的代码片段可以写得更好一点,考虑到数组成为null的可能性,并使用return关键字优雅地退出main(..)方法,如清单 5-18 所示。

package com.apress.bgn.five;

public class ArrayDemo {

    int array[];

    public static void main(String... args) {
        ArrayDemo ad = new ArrayDemo();
        if (ad.array == null) {
            System.out.println("Array unusable. Nothing to do.");
            return;
        }
    }
}

Listing 5-18Class with int Array Field That Can Be Null

当使用 void 关键字声明一个方法不返回任何东西时,可以通过返回来强制执行来自该方法的正确返回;没有值的语句。回归;语句在前面的代码示例中并不真正必要,它只是作为一个示例,说明如何编写代码以在显式执行点从方法返回。

为什么我们需要null关键字来标记还不存在的东西?因为编程中的惯例是首先声明一个引用,只有在第一次使用时才初始化它。这对于需要分配大量内存的大型对象尤其有用。这种编程技术被称为惰性加载(也称为异步加载)。

清单 5-19 描述了一个更加进化版本的ArrayDemo类,其中数组字段被初始化,并为其设置大小。

01\. package com.apress.bgn.five;
02.
03\. public class ArrayDemo {
04.
05\.   int array[] = new int[2];
06.
07\.   public static void main(String... args) {
08\.        ArrayDemo ad = new ArrayDemo();
09\.        if (ad.array == null) {
10\.            System.out.println("Array unusable. Nothing to do.");
11\.            return;
12\.        }
13.
14\.        for (int i = 0; i < ad.array.length; ++i) {
15\.            System.out.println("array["+ i +"]= " + ad.array[i]);
16\.        }
17\.    }
18\. }

Listing 5-19Class with int Array Field That is Initialized Properly

array的初始化发生在第 5 行。数组的大小是 2。数组的大小是作为一个参数给定的,它看起来像是一个构造函数调用,只是用方括号代替了圆括号,并以数组组合在一起的元素的类型为前缀。通过将数组的维数设置为 2,我们告诉 JVM 必须为这个对象留出(分配)两个相邻的内存位置来存储两个int值。因为没有值被指定为数组内容,您认为当数组被创建时,它们将被填充什么?这是一个简单的例子:前面的数组被定义为由两个int值组成,所以当数组被初始化时,将使用int类型的默认值。

图 5-13 描述了当前面的代码被执行时,在栈和堆内存中会发生什么。

img/463938_2_En_5_Fig13_HTML.jpg

图 5-13

声明一个大小为 2 的 int 数组

在第 14 到 16 行中,for 循环用于打印数组的值。int i变量就是我们所说的索引变量,用于遍历数组的所有值,在循环的每一步中递增 1。array.length是包含数组大小的属性——数组包含多少个元素。正如您可能预料的那样,控制台中打印的输出是:

array[0]= 0
array[1]= 0

要将一些值放入数组中,我们有以下几种选择:

  • 我们直接访问元素,并设置值:

  • 我们用想要存储的值显式初始化数组:

array[0] = 5;
array[1] = 7;
//or
for (int i = 0; i < array.length; ++i) {
    array[i] = i;
}

int another[] = {1,4,3,2};

数组也可以对引用进行分组。清单 5-20 描述了如何声明和使用Performer数组。

package com.apress.bgn.five;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.hierarchy.Performer;

public class PerformerArrayDemo {
    public static void main(String... args) {
        Performer[] array = new Performer[2];
        for (int i = 0; i < array.length; ++i) {
            System.out.println("performer[" + i + "]= " + array[i] );
        }
        array[0] = new Performer("Julianna", 35, 1.61f, Gender.FEMALE);
        array[1] = new Performer("John", 40, 1.91f, Gender.MALE);
        for (int i = 0; i < array.length; ++i) {
            System.out.println("performer[" + i + "]= " + array[i].getName() );
        }
    }
}

Listing 5-20Class Creating a Performer Array

在显式初始化之前,数组的元素用Performer类型的默认值初始化。因为Performer是一个引用类型,所以这个值是null。

因为描述内存内容使得数组和对象发生的事情更加明显,我给你图 5-14 。

img/463938_2_En_5_Fig14_HTML.jpg

图 5-14

声明大小为 2 的执行者数组

所以是的,我们实际上有一个引用数组,它们指向的对象可以在程序中改变。

这里我需要介绍的最后一件事是数组可以是多维的。如果你学过高等数学,你可能还记得矩阵的概念,它是按行和列排列的矩形阵列。在 Java 中,你可以通过使用数组来模拟矩阵。如果你想要一个有行和列的简单矩阵,你只需要定义一个二维的数组。清单 5-21 中描述了一个非常简单的例子。

package com.apress.bgn.five;

public class MatrixDemo {

    public static void main(String... args) {
        // bi-dimensional array: 2 rows, 2 columns
        int[][] intMatrix = {{1, 0}, {0, 1}};
        int[][] intMatrix2 = new int[2][2];
        for (int i = 0; i < intMatrix2.length; ++i) {
            for (int j = 0; j < intMatrix2[i].length; ++j) {
                intMatrix2[i][j] = i + j;
                System.out.print(intMatrix[i][j] + " ");
            }
            System.out.println();
        }
    }
}

Listing 5-21Class Modelling a Matrix Using a 2-Dimensional Array

你甚至可以去多维,并定义尽可能多的坐标。在清单 5-22 中,我们用一个三维数组模拟了一个立方体。

package com.apress.bgn.five;

public class CubeDemo {

    public static void main(String... args) {
        // three-dimensional array with three coordinates
        int[][][] intMatrix3 = new int[2][2][2];
        for (int i = 0; i < intMatrix3.length; ++i) {
            for (int j = 0; j < intMatrix3[i].length; ++j) {
                for (int k = 0; k < intMatrix3[i][j].length; ++k) {
                    intMatrix3[i][j][k] = i + j + k;
                    System.out.print("["+i+", "+j+", " + k + "]");
                }
                System.out.println();
            }
            System.out.println();
        }
    }
}

Listing 5-22Class Modelling a Cube Using a 3-Dimensional Array

对于数组,在内存允许的情况下,根据需要将它们设置得尽可能大,但是要确保对它们进行初始化,并确保在代码中不尝试访问超出允许范围的索引。如果一个数组的大小是 N ,那么它的最后一个索引是 N-1 ,第一个是 0 。试图访问该范围之外的任何索引,将在运行时抛出类型为java.lang.ArrayIndexOutOfBoundsException的异常。所以如果你像这样写代码:

int array = new int[2];
array[5] =7;

虽然它可以编译,但是执行失败,因为抛出了一个异常。控制台中将打印以下内容:

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 2 at chapter.five.arrays@1.0-SNAPSHOT/com.apress
    .bgn.five.ArrayDemo.main(ArrayDemo.java:49)

为了更容易地处理 Java 中的数组,有一个特殊的类:java.util.Arrays。这个类提供了一些实用的方法来排序和比较数组,搜索元素,或者将它们的内容转换成文本或流(章节 8 ),这样它们就可以被打印出来,而不需要编写示例中使用的冗长的for循环。清单 5-23 描述了其中一些实用方法。

package com.apress.bgn.five;

import java.util.Arrays;

public class ArrayUtilitiesDemo {
    public static void main(String... args) {
        int[] array =  {4, 2};
        System.out.println(Arrays.toString(array));
        // or
        Arrays.stream(array).forEach(ai -> System.out.println(ai));
        // or using a method reference
        Arrays.stream(array).forEach(System.out::println);

        Arrays.sort(array);

        array = new int[]{4, 2, 1, 5, 7};
        int foundAt = Arrays.binarySearch(array, 5);
        System.out.println("Key found at: " + foundAt);
    }
}
// output
[4, 2]
4
2
4
2
Key found at: -11

Listing 5-23java.util.Arrays
Useful Methods

在下面的列表中可以找到对前面代码清单中每个语句的简短解释:

  1. int[] array = {4, 2}是数组声明和初始化。不需要new int[],因为编译器可以从数组的声明中计算出元素的类型,从为初始化提供的一组值的大小中计算出数组的大小。

  2. Arrays.toString(array)返回指定数组内容的字符串表示。元素字符串表示由逗号分隔,结果字符串用方括号括起来([])。

  3. Arrays.stream(array)返回一个以指定数组为源的序列IntStream。流包含在一个专门的章节中(章节 8 ),这些类提供了逐个处理元素的方法,不需要for循环。在前面的代码片段中,结果流的元素是使用System.out.println(..)方法处理的,这意味着它们被一个接一个地打印在控制台中。

  4. Arrays.sort(array)将指定数组按数字升序排序。此方法不返回新的排序数组,因此元素在原始数组中的位置会发生变化。用于执行排序的算法被称为双枢纽快速排序,是最有效的排序算法之一。 3

  5. array = new int[]{4, 2, 1, 5, 7}是数组的重新初始化。这意味着一个新的数组值被分配给array引用。所以声明必须指定new关键字以及类型和数组大小,除非一组元素用于初始化;这正是该语句的情况,因此大小不是强制性的。

  6. Arrays.binarySearch(array, 5)在数组中搜索作为参数提供的值(在本例中为 5),并返回一个表示元素在数组中的位置的值(因此,它的索引)。用于搜索的算法被称为二分搜索法,其工作原理是将数组重复分成两部分,直到找到元素。这种技术被称为 Divide-et-Impera(或 Divide-and-conquer ),它涉及到将一个大问题反复(递归地)分割成更小的问题,直到它们可以被轻松解决。对数组进行排序时,数组上的二分搜索法效率最高。

    请随意在网上搜索本节提到的算法,因为当你需要开发自己的解决方案时,理解这些算法是很有用的。章节 7 将向您展示如何按照几个简单且众所周知的算法编写代码。

String型

我们列表中的下一个特殊 Java 数据类型是String。与原语int一起,这是 Java 中最常用的类型之一。String实例用于对文本建模并对其执行各种操作。String 类型是一种特殊的类型,因为 JVM 对这种类型的对象进行了特殊处理。如果您还记得第一个带有内存内容的映像,那么String对象被分配到堆中一个叫做字符串池的特殊位置。在本节中,我们将详细介绍这种类型,到目前为止,您可能会遇到的许多问题都有望得到解答。

直到现在String变量在本书中被声明,如清单 5-24 所示:

package com.apress.bgn.five;

public class SimpleStringDemo {
    public static void main(String... args) {
01\.        String text1 = null;
02.
03\.        String text21 = "two";
04\.        String text22 = "two";
05\.        String text23 = new String ("two");
06.
07\.        String piece1 = "t";
08\.        String piece2 = "wo";
09\.        String text24 = piece1 + piece2;
10.
11\.        char[] twoCh = {'t', 'w', 'o'};
12\.        String text25 = new String(twoCh);
    }
}

Listing 5-24A few String Statements Used in This Book

如您所见,第 3、4、5、9 和 12 行中的每一行都定义了一个具有相同内容【两个】的String对象。我故意这样做的原因很快就会变得显而易见。在现实世界的应用中,尤其是在这个大数据炒作时期,应用处理大量数据,其中大部分是文本形式的。因此,能够压缩数据并重用它将减少内存消耗。减少内存访问尝试通过减少处理来提高速度,从而降低成本。

String变量可以直接用文本值初始化(第 3 行和第 4 行)。在这种情况下,JVM 首先在字符串池中寻找具有相同值的String对象。如果找到了,新的String变量用对它的引用来初始化。如果没有找到,就分配内存,将文本值写入其中,并用对它的引用初始化新的String变量。

在第 5 行,类String的构造函数被用来创建一个String对象。注意这里使用了new关键字。这意味着正在显式请求分配内存来存储作为参数提供的文本。

在继续这一节之前,我们必须做一个小而重要的旁注,提及对象相等在 Java 中的含义。在 Java 中,对象是通过引用它们的内存位置来处理的。== (double equals)运算符比较引用所指向的内存位置,因此当且仅当两个对象存储在同一个内存地址时,它们才是相等的。这就是为什么应该使用equals(..)方法来比较对象。这是一个从Object类继承的特殊方法,但是每个类都必须提供自己的实现,这个实现真正与自己的结构相关。正如所料,Object 类中的equals(..)实现默认为==行为。

想一想两个红色的球。它们有相同的直径,相同的颜色,由相同的材料制成。它们是相同的,这翻译成 Java 是相等的,但它们不是同一个球;它们只是使用相同的规格创建的。如果你随便找两个孩子,比如吉姆和简,每个人都可以玩自己的球。但是如果 Jim 和 Jane 玩同一个球,只是把它从一个球扔向另一个球,这与 Java 中的引用相等非常相似。图 5-15 是这种情况的抽象表示。

img/463938_2_En_5_Fig15_HTML.jpg

图 5-15

用红球显示等号和==之间的区别

清单 5-25 描述了一个简单版本的Ball类,以及一个可执行代码样本,它创建了两个独立的球对象并对它们进行比较,但也创建了一个球来测试引用的相等性。因为 Jim 和 Jane 可以被认为是对一个球的引用,所以代码是这样写的。

package com.apress.bgn.five;

import java.util.Objects;

public class EqualsDemo {

    public static void main(String... args) {

        Ball jim = new Ball(10, "red", "rubber");
        Ball jane = new Ball(10, "red", "rubber");

        System.out.println("-- Playing with different balls -- ");
        System.out.println("Jim and Jane have equal balls? A:" + jim.equals(jane));
        System.out.println("Jim and Jane have the same ball? A:" + (jim == jane));

        System.out.println("-- Playing with the same ball -- ");
        Ball  extra = new Ball(10, "red", "rubber");
        jim= extra;
        jane = extra;
        System.out.println("Jim and Jane have equal balls? A:" + jim.equals(jane));
        System.out.println("Jim and Jane have the same ball? A:" + (jim == jane));
    }
}

class Ball {
    int diameter;
    String color;
    String material;

    @Override
    public boolean equals(Object o) {
        Ball ball = (Ball) o;
        return diameter == ball.diameter
            && Objects.equals(color, ball.color)
            && Objects.equals(material, ball.material);
    }

    // other code omitted
}

Listing 5-25Code Sample Showing Differences Between equals(..) and == on References

执行前面清单中的代码应该会产生以下输出:

-- Playing with different balls --
Jim and Jane have equal balls? A:true
Jim and Jane have the same ball? A:false
-- Playing with the same ball --
Jim and Jane have equal balls? A:true
Jim and Jane have the same ball? A:true

前面的代码示例很好地指出了' == '操作符和引用上的equals(..)方法之间的区别:==操作符测试引用的相等性,而equals(..)方法测试这些引用所指向的对象的相等性。这里介绍的equals(..)方法实现是幼稚的,因为应该考虑可空性和与不同类型对象的比较。然后还有一个hashCode()方法,当equals(..)为时,它必须被实现,否则你的类将不能和一些集合类一起正常工作,这将在本章后面讨论。但是现在,我真的希望对象相等和引用相等之间的区别是清楚的,这样接下来的String部分才有意义。

对象相等括号现在已关闭。

在 Java 中String实例是不可变的,这意味着它们一旦被创建就不能被改变。String类也被声明为final,所以开发者不能扩展它。String实例在 Java 中不可变有多种原因,其中一些与应用的安全性有关,但这些原因太枯燥,本书无法一一介绍。在这一节中,重点是最明显的原因。

由于String实例一旦创建就不能更改,这意味着 JVM 可以重用已经分配的现有值来形成新的String值,而不消耗额外的内存。这个过程叫做实习。每个文本值(文字)的一个副本被保存到一个叫做字符串池的特殊内存区域。当一个新的String变量被创建并被赋值时,JVM 首先在池中搜索一个等值的字符串。如果找到,将返回对该内存地址的引用,而不分配额外的内存。如果没有找到,它将被添加到池中,其引用将被返回。也就是说,考虑到清单 5-24 (等式括号前的那个)中的示例代码,我们期望text21和text22变量指向池中相同的String对象,这意味着引用也是相等的。清单 5-26 描述了测试假设的代码。

package com.apress.bgn.five;

public class SimpleStringDemo {
    public static void main(String... args) {
        String text21 = "two";
        String text22 = "two";

        if (text21 == text22) {
            System.out.println("Equal References");
        } else {
            System.out.println("Different References");
        }
        if (text21.equals(text22)) {
            System.out.println("Equal Objects");
        } else {
            System.out.println("Different Objects");
        }
    }
}

Listing 5-26Code Sample Showing Differences Between equals(..) and == on String References

当运行前面的代码时,控制台中会显示以下内容,证明前面的确认和字符串池的存在。

Equal References
Equal Objects

在图 5-16 中,你可以看到前面代码执行时内存内容的抽象表示。

img/463938_2_En_5_Fig16_HTML.jpg

图 5-16

在堆内存中创建的字符串池区域的抽象表示

当使用new操作符创建一个新的String对象时,JVM 将为这个新对象分配新的内存,并将其存储在堆中,因此字符串池不会被使用。这导致每个像这样创建的String对象都有自己的内存区域和自己的地址。

我认为在这一点上很明显,使用字符串构造函数(不止一个)来创建字符串对象实际上相当于浪费内存。

这就是为什么如果我们要比较初始代码样本中的变量text22和变量text23,我们会期望它们的引用是不同的,但是对象应该是相同的。清单 5-27 描述了测试这个假设的代码。

package com.apress.bgn.five;

public class SimpleStringDemo {
    public static void main(String... args) {
        String text22 = "two";
        String text23 = new String ("two");

        if (text22 == text23) {
            System.out.println("Equal References");
        } else {
            System.out.println("Different References");
        }
        if (text22.equals(text23)) {
            System.out.println("Equal Objects");
        } else {
            System.out.println("Different Objects");
        }
    }
}

Listing 5-27Code Sample Showing Differences Between equals(..) and == on String References

运行上述代码时,控制台中将显示以下内容,证明所有假设都是正确的:

Different References
Equal Objects

我让您来想象前一个例子中栈和堆内存的样子。 4

在 Java 6 之前,字符串池的默认大小是 1009 个条目。从此版本开始,可以使用-XX:StringTableSize命令行选项修改其大小。由于大小因 Java 版本和程序可用内存的不同而不同,我的建议是只调用run java -XX:+PrintFlagsFinal -version并在返回的输出中查找StringTableSize,以获得机器上字符串池的实际大小。

在清单 5-24 中,第 11 行和第 12 行描述了如何从一个char[3]数组创建一个String实例。直到 Java 8,在内部这是String值的初始表示——字符数组。一个字符用 2 个字节表示,这意味着Strings消耗了大量内存。在 Java 9 中引入了一种新的表示法,叫做压缩String,,它根据内容使用byte[]或char[]。这意味着从 Java 9 开始,String处理应用消耗的内存将显著减少。

String类提供了大量操作字符串的方法;一整本书可能只写这种类型的 Java。这些方法将在书中解释,但如果你好奇,你可以在这里查阅在线 JavaDoc(https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/String.html)或使用你的智能编辑器查找它们。

转义字符

有一些特殊字符不能作为String值的一部分。您可能已经注意到,String值被定义在双引号("sample")之间,这使得"(double quote)字符不能作为值使用。为了能够将它用作String值或其中的一部分,它必须被转义。除了这个角色,还有\(backslash)、\a(alert),和其他几个角色。在图 5-17 中,你可以看到 IntelliJ IDEA 试图告诉你不能在字符串值的内容中使用这些字符。

img/463938_2_En_5_Fig17_HTML.jpg

图 5-17

包含特殊字符的代码示例

所以单个的\(backslash)不允许成为String值的一部分,但是其中两个是,并且它告诉编译器字符串值包含一个\(backslash)字符。

System.out.println(" Example using \\.")
//Prints
 Example using \.

至于\a头韵,在String值中是不允许的,因为\(backslash)是用来构造转义序列的,而\a不是转义序列。

(单引号)在用作字符值时也必须转义。

char quote = '\";

有一些 Java 转义序列可以用在String值中以获得某种效果,表 5-1 中列出了最重要的一些。

表 5-1

Java 转义序列

|

换码顺序

|

影响

|
| --- | --- |
| \n | 创建一个新行(通常称为换行符)。 |
| \t | 创建一个制表符。 |
| \b | 创建退格字符(这可能会删除前面的字符,具体取决于输出设备)。 |
| \r | 返回到该行的开头(但不要新建一行,相当于键盘上的Home键)。 |
| \f | 换页(对于打印机,移到下一页的顶部)。 |
| \s | 创建一个空格字符。 |
| \ | 行结束符。 |

img/463938_2_En_5_Figo_HTML.jpg字符串值中需要转义的字符的完整列表可以在 Java 语言规范文档中找到,这里: https://docs.oracle.com/javase/specs/jls/se16/html/jls-3.html#jls-3.10.7 。

img/463938_2_En_5_Figp_HTML.jpg根据 JLS,如果转义序列中反斜杠后面的字符不是\或 ASCII b,s,t,n,f,r,“,',\,0,1,2,3,4,5,6 或 7,则为编译时错误。

换行符\n和制表符\t在编程中经常被用来正确格式化控制台输出。如果我们声明一个像这样的String实例:

String perf = "The singers performing tonight are: \n\t Paolo Nutini \n\t Seth MacFarlane\n\t John Mayer";

在控制台中打印时,文本将被格式化,如下所示:

今晚表演的歌手是:

       Paolo Nutini
       Seth MacFarlane
       John Mayer

关于 Java String最后应该提到的是,在 JDK 15 中,引入了对文本块的支持。这意味着不用将一个大的String值拆分成写在多行上的多个较小的值,然后将它们连接起来以保持代码可读,现在您可以声明一个单独的文本块并将其分配给一个字符串引用。在 Java 15 之前,如果您想声明一个多行字符串值,有几种选择,包括连接(使用'+'操作符)、显式行终止符和分隔符。清单 5-28 中描述了其中一些选项。根据您正在构建的解决方案,您可以选择其中任何一种,关于效率和缺点的讨论不在本书的讨论范围之内。

package com.apress.bgn.five;

import java.io.PrintWriter;
import java.io.StringWriter;

public class MultiLineDemo {

    public static void main(String... args) {
        // this statement extracts the newline character specific to the
        // operating system
        String newLineCh = System.getProperty("line.separator");

        // method 1: simple concatenation using the '+' operator
        String multilineStr = "line one of the text block" +
                newLineCh +
                "line two of the text block" +
                newLineCh +
                "last line of the text block" ;

        // or method 2 using `String#concat(..)` method
        multilineStr = "line one of the text block"
                .concat(newLineCh)
                .concat("line two of the text block")
                .concat(newLineCh)
                .concat("last line of the text block") ;

        // or method 3 using `String.join` utility method
        multilineStr = String.join("line one of the text block" ,
                newLineCh ,
                "line two of the text block" ,
                newLineCh ,
                "last line of the text block");

        // or method 4 using a StringBuffer instance
        multilineStr = new StringBuffer("line one of the text block")
                .append(newLineCh)
                .append("line two of the text block")
                .append(newLineCh)
                .append("last line of the text block").toString();

        // or method 5 using a StringBuilder instance
        multilineStr = new StringBuilder("line one of the text block")
                .append(newLineCh)
                .append("line two of the text block")
                .append(newLineCh)
                .append("last line of the text block").toString();

        // or method 5 using a StringWriter instance
        StringWriter stringWriter = new StringWriter();
        stringWriter.write("line one of the text block");
        stringWriter.write(newLineCh);
        stringWriter.write("line two of the text block");
        stringWriter.write(newLineCh);
        stringWriter.write("last line of the text block");
        multilineStr = stringWriter.toString();

        // or method 6 using a StringWriter and PrintWriter instance
        stringWriter = new StringWriter();
        PrintWriter printWriter = new PrintWriter(stringWriter);
        printWriter .println("line one of the text block");
        printWriter.println("line two of the text block");
        printWriter.println("last line of the text block");
        multilineStr = stringWriter.toString();

        System.out.println(multilineStr);
    }
}

Listing 5-28Multiline Java String Value Before JDK 15

img/463938_2_En_5_Figq_HTML.jpg StringBuffer表示一个线程安全的、可变的字符序列。这意味着对StringBuffer的任何操作都是在确保单次访问后执行的。这就是为什么使用StringBuffer连接字符串比使用StringBuilder慢的原因,?? 是非线程安全的等价物。所以在设计你的代码时,除非你的字符串连接块有被多线程并行执行的风险,否则就用StringBuilder。

在 JDK 15 中,添加了对声明文本块的支持,这使得可以在代码中完全按照原样嵌入多行文本,而无需修改它们来添加行终止符、分隔符或连接运算符。因此,文本块是 Java 字符串表示的另一种形式,它以三个双引号字符开始,后跟一个行结束符,以三个双引号字符结束。因此,前面的多行文本可以用新语法编写,如下所示:

String multilineStr = """
        line one of the text block
        line two of the text block
        last line of the text block
    """;

新语法仅用于声明多行文本,因此不能用于声明单行文本。这样做会导致编译错误。如果开始的三个双引号字符后跟文本而不是预期的行结束符,也会发生同样的情况。图 5-18 描述了声明多行文本块的两种错误方式,以及 IDE 提供的解释。

img/463938_2_En_5_Fig18_HTML.jpg

图 5-18

声明多行文本的语法无效

几个例子:

  • 在多行文本块中不需要对"(double-quote)进行转义,除非在值中有三个组合在一起。在这种情况下,编译器可能会对文本块在哪里结束感到有点困惑,所以在这种情况下,必须对其中至少一个进行转义。

  • 当组成文本块的行需要缩进时,应该使用空格或制表符;同时使用它们可能会导致不可预知的结果(例如,不规则的缩进会破坏 YAML 配置)

  • 文本块支持两种额外的转义序列:

  • \<line-terminator>禁止包含隐含的新行字符。例如,如前所示声明的文本块相当于:

    String multilineStr = "line one of the text block" +
                    "\n" +
                    "line two of the text block" +
                    "\n" +
                    "last line of the text block" +
                    "\n" ;
    
    

    如果不需要最后一个新行,有两个选项。文本块可以在文本的最后一行内指定终止符。

    String multilineStr = """
        line one of the text block
        line two of the text block
        last line of the text block""";
    
    

    但是不推荐这样做,因为这可能会影响缩进。推荐的方法是使用\<line-terminator>转义字符,因为这样可以更好地组织文本块,并允许结束分隔符管理缩进。

    String multilineStr = """
            line one of the text block
            line two of the text block
            last line of the text block\
        """;
    
    
  • \s转义序列翻译成空格。当我们希望文本块中的行尾有一些空格时,这很有用。

    String multilineStr = """
            line one of the text block\s
            line two of the text block\s
            last line of the text block\
        """;
    
    

在 Oracle 官方文档中,有一节专门介绍了 JDK 15 中添加的新多行块。如果你需要更多的信息,这里是最好的地方:https://docs.oracle.com/en/java/javase/17/text-blocks/index.html——新——转义——序列。

基本类型的包装类

在本章的原语部分提到过,每个原语类型都有一个对应的引用类型。在介绍它们以及为什么需要它们之前,请查看表 5-2 。

表 5-2

Java 基本类型和等效的引用类型

|

原语类型

|

参考类型

|
| --- | --- |
| char | java.lang.Character |
| boolean | java.lang.Boolean |
| byte | java.lang.Byte |
| short | java.lang.Short |
| int | java.lang.Integer |
| long | java.lang.Long |
| float | java.lang.Float |
| double | java.lang.Double |

Java 包装器类用相同的名称包装原始类型的值。此外,这些类提供了将原始值转换为String的方法,反之亦然,以及在处理原始类型时有用的常量和方法,这些类型需要被视为对象。数字包装类是相关的,它们都扩展了Number类,如图 5-19 所示。

img/463938_2_En_5_Fig19_HTML.jpg

图 5-19

Java 原语和等效的引用类型

下面的代码示例将主要使用Integer类,但是其他数字包装类也可以以类似的方式使用。将一个原始值转换成它的等价引用被称为装箱;相反的过程被称为拆箱。JVM 在大多数情况下自动完成这些转换,术语自动装箱被引入来指代装箱过程,而出于某种原因,自动拆箱仍然被称为拆箱。

清单 5-29 中描述的代码样本包含了一些关于Integer和 int 值的操作。

package com.apress.bgn.five;

public class WrapperDemo {
    public static void main(String... args) {
        // upper interval boundary for int
        Integer max = Integer.MAX_VALUE;
        System.out.println(max);

        //autoboxing Integer -> int
        int pmax = max;

        //autoboxing int -> Integer
        Integer io = 10;

        //creating primitive utility method
        //exception is thrown, if string is not a number
        int i1 = Integer.parseInt("11");

        //constructor deprecated in Java 9
        //exception is thrown, if string is not a number
        Integer i2 = new Integer("12");

        //exception is thrown, if string is not a number
        Integer i3 = Integer.valueOf("12");

        //convert int into to String
        String s0 = Integer.toString(13);

        //convert int to float
        float f0 = Integer.valueOf(14).floatValue();

        //creating string with binary representation of number 9 (1001)
        String s1 = Integer.toBinaryString(9);

        //introduced in Java 1.8
        Integer i4 = Integer.parseUnsignedInt("+15");

        //method to add to integers
        int sum = Integer.sum(2, 3);

        //method to get the bigger value
        int maximum = Integer.max(2, 7);
    }
}

Listing 5-29Autoboxing and Unboxing in Action

Character和Boolean类型略有不同,因为这些类型不是数值型的,所以它们不能被转换成任何数值。它们也不能相互转换。Oracle 为它的类提供了很好的文档,所以如果你对使用这两种类型感兴趣,只需在 https://docs.oracle.com/en/java/javase/17/docs/api/index.html 查看 JDK 官方 API 文档。

日期时间 API

许多应用利用日历日期类型来打印当前日期、截止日期和生日。无论您决定构建什么样的应用,您都很可能需要使用日历日期。在 Java 8 之前,建模日历日期的主要类是java.util.Date。这个类和其他处理日历日期的类有一些问题。但是在我们开始之前,看一下清单 5-30 ,看看我们如何获得当前日期,创建一个自定义日期并打印某些细节。

package com.apress.bgn.five;

import java.text.SimpleDateFormat;
import java.util.Date;

public class DateDemo {
    public static void main(String... args){
        SimpleDateFormat sdf = new SimpleDateFormat("dd-MM-yyyy");
        Date currentDate = new Date();
        System.out.println("Today: " + sdf.format(currentDate));

        //deprecated since 1.1
        Date johnBirthday = new Date(77, 9, 16);
        System.out.println("John’s Birthday: " + sdf.format(johnBirthday));

        int day = johnBirthday.getDay();
        System.out.println("Day: " + day);
        int month = johnBirthday.getMonth() + 1;
        System.out.println("Month: " + month);
        int year = johnBirthday.getYear();
        System.out.println("Year: " + year);
    }
}

Listing 5-30java.util.Date Code Sample

获取系统上设置的当前日期很简单;只需调用Date类的默认构造函数:

Date currentDate = new Date();

可以直接显示currentDate的内容,但是通常使用java.text.SimpleDateFormat的一个实例,将日期格式化为特定于国家的模式,或者只是更具可读性。格式化程序也可以用于将特定格式的String转换成日期实例。如果文本与格式化程序的模式不匹配,将抛出一个特定的异常(类型:java.text.ParseException)

try {
    Date johnBirthday = sdf.parse("16-10-1977");
} catch (ParseException e) {
    // do something with the exception
}

要从代表日期的数字(年、月和日)创建一个Date实例,可以使用一个将这些值作为参数的构造函数。然而,这个构造函数从 Java 1.1 开始就被弃用了,所以一些开发人员更喜欢使用sdf.parse(..)方法。构造函数的参数有一些特殊性:

  • year 参数必须是年值–1900。

  • 月份从 0 开始计数,因此作为参数提供的月份必须是我们想要的月份-1。

    这里描述了从年、月和日的数值构建一个Date的代码:

//deprecated since 1.1
Date johnBirthday = new Date(77, 9, 16);
System.out.println("John's Birthday: " + sdf.format(johnBirthday));
//it prints: John’s Birthday: 16-10-1977

如果我们想从日期中提取年、月和月中的日,有很多方法可以做到这一点,只有一个特点:提取月中的日的方法被命名为getDate()。还要记住,因为月份是从 0 到 11 编号的,所以对于实际的月份值,您必须在由getMonth()返回的结果上加 1。清单 5-31 显示了创建一个Date实例的代码,提取,然后是日、月和年并打印它们。

package com.apress.bgn.five;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

public class PrintDateDemo {
    public static void main(String... args) {

        try {
            SimpleDateFormat sdf = new SimpleDateFormat("dd-MM-yyyy");
            Date johnBirthday = sdf.parse("16-10-1977");
            System.out.println("John’s Birthday: " + sdf.format(johnBirthday));

            //day of the month
            int day = johnBirthday.getDate();
            System.out.println("Day: " + day);

            int month = johnBirthday.getMonth() + 1;
            System.out.println("Month: " + month);

            int year = johnBirthday.getYear();
            System.out.println("Year: " + year);

        } catch (ParseException e) {
            e.printStackTrace();
        }
    }
}

Listing 5-31Printing Components of a Calendar Date

img/463938_2_En_5_Figr_HTML.jpgjava.util.Data类有两个容易混淆的方法。

getDate()方法返回一个日期对象的一个月中的某一天。

方法返回一个日期对象的星期几。

从 JDK 版本 1.1 开始,这两种方法都被弃用,本节稍后将介绍提取这些信息的更好、更简单的方法。

如果您在 IntelliJ IDEA 编辑器中查看本节的演示类,您会注意到有些构造函数和方法是用删除线字体编写的。这意味着它们已被弃用,可能会在 Java 的未来版本中被删除,因此不应该使用它们。这就是为什么有另一种方法来完成这一切:通过使用java.util.Calendar class。与清单 5-31 相同的代码,但是使用日历类在清单 5-32 中描述。

package com.apress.bgn.five;

import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;

public class CalendarDateDemo {
    public static void main(String... args) {
        SimpleDateFormat sdf = new SimpleDateFormat("dd-MM-yyyy");
        Calendar calendar = new GregorianCalendar();
        Date currentDate = calendar.getTime();
        System.out.println("Today: " + sdf.format(currentDate));

        calendar.set(1977, 9, 16);
        Date johnBirthday = calendar.getTime();
        System.out.println("John’s Birthday: " + sdf.format(johnBirthday));

        int day = calendar.get(Calendar.DAY_OF_MONTH);
        System.out.println("Day: " + day);
        int month = calendar.get(Calendar.MONTH);
        System.out.println("Month: " + month);
        int year = calendar.get(Calendar.YEAR);
        System.out.println("Year: " + year);
    }
}

Listing 5-32Code Sample for Handling Calendar Dates Using the Calendar Class

不幸的是,前面提到的一些特性仍然存在,因为表示日期的中心类仍然是java.util.Date,但至少我们不再使用不推荐的方法了。

java.util.Date类和java.text.SimpleDateFormat类不是线程安全的,因此在具有多个执行线程的复杂应用中,开发人员必须显式同步对这些类型对象的访问。这些类型的对象不是不可变的,使用时区是一件痛苦的事情。这就是为什么在 Java 8 中引入了一个新的 API 来模拟日历日期操作的主要原因,这个 API 设计得更好,并且日期实例是线程安全和不可变的。

API 的核心类是java.time.LocalDate和java.time.LocalDateTime,用于建模日历日期和日历日期与时间。清单 5-33 展示了如何获取当前日期以及如何用新的 API 创建一个定制日期。

package com.apress.bgn.five;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Month;

public class NewCalendarDateDemo {

    public static void main(String... args) {
        LocalDateTime currentTime = LocalDateTime.now();
        System.out.println("Current DateTime: " + currentTime);
        LocalDate today = currentTime.toLocalDate();
        System.out.println("Today: " + today);

        LocalDate johnBd = LocalDate.of(1977, Month.OCTOBER, 16);
        System.out.println("John’s Birthday: " + johnBd);

        int day = johnBd.getDayOfMonth();
        System.out.println("Day: " + day + ", " + johnBd.getDayOfWeek());
        int month = johnBd.getMonthValue();

        System.out.println("Month: " + month + ", " + johnBd.getMonth());
        int year = johnBd.getYear();
        System.out.println("Year: " + year);
    }
}

Listing 5-33Code Sample for Handling Calendar Dates Using the New DateTime API Introduced in the JDK 8

为了获得当前的日期和时间,调用名为now()的静态方法,该方法返回类型为LocalDateTime的实例。这个实例可以通过调用toLocalDate()来获取当前日期。该方法将当前日期作为类型LocalDate的实例返回。这个类有一个toString()方法,根据系统上设置的默认地区打印格式化的日期。要创建自定义日期,实际的年份和月份可以用作参数,月份可以使用java.time.Month枚举的值之一来指定。通过调用具有直观名称的方法,可以很容易地提取关于日期的信息。看看前面代码片段中的getDayOfMonth()和getDayOfWeek()方法就知道了。它们的名字准确地反映了它们返回的数据。如您所见,类LocalDate和LocalDateTime简化了不需要时区的开发。使用时区是一个相当高级的主题,所以它不会在本书中讨论。

收集

在 JDK,你可能会经常用到的最重要的类型之一是收藏品。collections 系列中的类和接口用于建模常见的数据集合,如集合、列表和映射。所有的类都存储在包java.util下,可以分为两类:元组和键值对集合。元组是一维数据集:如果值是唯一的,那么任何实现了java.util.Set接口的类都应该被用来对它们建模;如果不是,应该使用任何实现了java.util.List接口的类。对于键值对类的集合,应该使用实现java.util.Maps。从 Java 版本 1.5 开始,集合变得通用,这使得开发人员在使用它们时更加精确和安全。在 Java 1.5 之前,集合可以包含任何类型的对象。开发人员仍然可以编写清单 5-34 中描述的代码:

package com.apress.bgn.five;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.hierarchy.Performer;

import java.util.ArrayList;
import java.util.List;

public class CollectionsBasicDemo {

    public static void main(String... args) {
        List objList = new ArrayList();
        objList.add("temp");
        objList.add(Integer.valueOf(5));
        objList.add(new Performer("John", 40, 1.91f, Gender.MALE));
    }
}

Listing 5-34Code Using Collections Up to Java 1.5

您可能看不出这有什么问题;编译器当然不会,但是当你迭代这个列表时,如果没有复杂的代码分析每个对象的类型,就很难确定你在处理哪些对象。这是之前在章 4 结尾介绍泛型的时候提到的。清单 5-35 中描述了迭代列表并根据元素的类型对其进行不同处理的代码,这只是为了向您展示为什么在当今的 Java 时代这是一个糟糕的想法和糟糕的实践。

package com.apress.bgn.five;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.hierarchy.Performer;

import java.util.ArrayList;
import java.util.List;

public class CollectionsBasicDemo {

    public static void main(String... args) {
        List objList = new ArrayList();
        objList.add("temp");
        objList.add(Integer.valueOf(5));
        objList.add(new Performer("John", 40, 1.91f, Gender.MALE));

        for (Object obj : objList) {
            if (obj instanceof String) {
                System.out.println("String object = " + obj.toString());
            } else if (obj instanceof Integer) {
                Integer i = (Integer)obj;
                System.out.println("Integer object = " + i.intValue());
            } else {
                Performer p = (Performer) obj;
                System.out.println("Performer object = " + p.getName());
            }
        }
    }
}

Listing 5-35More Code Using Collections Up to Java 1.5

也许你现在还不清楚,但是为了能够使用列表的内容,你必须确切地知道放在列表中的对象的所有类型。当你独自在一个项目中工作时,这可能是可行的,但是在一个更大的项目中,当多个开发人员参与时,这可能会很快变得混乱。

这就是仿制药的用武之地。泛型有助于在编译时定义应该将什么类型的对象放入集合中,因此,如果将错误的对象类型添加到集合中,代码将不再编译。列表和集合都实现了相同的接口:java.util.Collection<T>,这意味着它们的 API 几乎相同。包含编程中最常用的类和接口的集合的简化层次结构如图 5-20 所示。

img/463938_2_En_5_Fig20_HTML.jpg

图 5-20

集合层次结构

清单 5-36 描述了String值的List的创建,以及遍历它并打印其元素所需的循环语句。

package com.apress.bgn.five;

import java.util.ArrayList;
import java.util.List;

public class GenericListDemo {
    public static void main(String... args) {
        List<String> stringList = new ArrayList<String>();
        stringList.add("one");
        stringList.add("two");
        stringList.add("three");

        for (String s : stringList) {
            System.out.println(s);
        }
    }
}

Listing 5-36Code Using Collections Starting with Java 1.5

一个List包含一个未排序的非唯一数据集合,包括null个元素。在前面的例子中,我们声明了一个类型为List<T>的引用和一个类型为ArrayList<T>的对象。我们这样做是因为所有的实现都有相同的 API 我们可以很容易地将ArrayList<T>换成LinkedList<T>,代码仍然可以工作。

List<String> stringList = new ArrayList<String>();
stringList = new LinkedList<String>();

声明抽象引用是一个好的编程实践,因为它增加了代码的灵活性。

前面示例中的语法是 Java 1.7 之前的版本。在 Java1.7 中引入了<>(菱形运算符)。这使得集合初始化更加简单,因为它只需要在引用声明中声明列表中元素的类型。因此,前面代码片段中的两行变成了:

List<String> stringList = new ArrayList<>();
stringList = new LinkedList<>();

从 Java 1.5 开始,每个新的 Java 版本都对集合框架进行了修改。在 Java 1.8 中,通过在java.lang.Iterable<T>接口(图 5-20 )中添加一个名为forEach的默认方法,增加了对 lambda 表达式的支持,该方法由java.lang.Collection<T>扩展。因此,打印列表中所有值的代码,就像我们之前使用的for循环一样,可以替换为:

stringList.forEach(element -> System.out.println(element));

在 Java 9 中,引入了另一个改进:集合的工厂方法。我们的集合是通过重复调用add(..)来填充元素的,这有点多余,特别是因为我们已经有了想要放入列表的元素的完整集合。这就是为什么在 Java 9 中引入了在一行代码中创建集合对象的方法。例如:

List<String> stringList = List.of("one", "two", "three");

结果列表是一个不可变的集合:它不能再被修改,也不能从其中添加或删除元素。

更近一步,在 Java 10 中增加了对局部变量类型推断的支持,这意味着我们不再需要显式地指定引用类型,因为它将根据对象类型自动进行推断,所以下面的声明:

List<String> stringList = List.of("one", "two", "three");

这变成了:

var stringList = List.of("one", "two", "three");

类似的代码可以用Set<T>、HashSet<T>和TreeSet<T>编写,类似的方法也存在于这个类家族中。

img/463938_2_En_5_Figt_HTML.jpg收藏是 Java 入门级职位面试中的一个常见话题,所以如果有人问你List<T>和Set<T>有什么区别,不要惊讶。

当使用Set<T>实现时,您只需确保添加到集合中的对象正确实现了equals(..)和hashCode()。这样做的原因是Set<T>对数学集合抽象建模,不允许有重复的元素。

equals(..)表示作为参数传递的对象是否“等于”当前实例。由Object类提供的默认实现认为两个对象是相等的,如果它们存储在相同的内存位置。

hashCode(..)返回对象内存地址的整数表示。由Object类提供的默认实现返回一个随机整数,该整数对于每个实例都是唯一的。该值可能会在应用的多次执行之间发生变化。当对象用作哈希表中的键时,此方法很有用,因为它优化了从对象中检索元素。如果你想了解更多关于散列表的知识,互联网是你的最佳选择;至于 Java,散列表可以用一个java.util.HashMap<K,V>的实例来建模。

根据官方文档,如果两个对象相等,那么对它们中的每一个调用hashCode()都会产生相同的结果。但是两个不相等的对象不一定要有不同的 hashCodes。

话虽如此,前面介绍的Ball类将用于创建一些 ball 实例;将它们添加到一个Set中。清单 5-37 中的代码示例展示了一个版本的Ball类,它包含了equals(..)和hashCode()的正确实现;

package com.apress.bgn.five;

import java.util.HashSet;
import java.util.Set;

public class SetDemo {

    public static void main(String... args) {
        Set<Ball> ballSet = new HashSet<>();
        ballSet.add(new Ball(2, "RED", "rubber"));
        ballSet.add(new Ball(4, "BLUE", "cotton"));

        System.out.println("Set size: " +  ballSet.size());
        Ball duplicate = new Ball(2, "RED", "rubber");
        boolean wasAdded = ballSet.add(duplicate);
        if(!wasAdded) {
            System.out.println("Duplicate ball not added to the set. ");
            System.out.println("Set size: " +  ballSet.size());
        }
    }
}

class Ball {
    private int diameter;
    private String color;
    private String material;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Ball ball = (Ball) o;
        return diameter == ball.diameter &&
                color.equals(ball.color) &&
                material.equals(ball.material);
    }

    @Override
    public int hashCode() {
        int result = 17 * diameter;
        result = 31 * result + (color == null ? 0 : color.hashCode());
        result = 31 * result + (material == null ? 0 : material.hashCode());
        return result;
    }
    // other code omitted
}

Listing 5-37Basic equals(..) and hashCode() Implementations

运行前面清单中的代码会产生以下输出:

Set size: 2
Duplicate ball not added to the set.
Set size: 2

在 Java 1.7 之前,开发人员必须为所有可能在Set<T>中使用或在Map<K,V>中作为键使用的类编写类似于前面清单中的equals(..)和hashCode()实现。实现必须基于类中最重要的字段的值。17 和 31 只是用来计算 hashCode 值的两个随机整数。

在 Java 1.7 中引入了类java.util.Objects,它提供了一些实用方法,使得实现这些方法变得更加容易。清单 5-38 描述了 Java 1.7 之后的equals(..)和hashCode()实现。

package com.apress.bgn.five;

import java.util.Objects;

class Ball {
    private int diameter;
    private String color;
    private String material;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Ball ball = (Ball) o;
        return diameter == ball.diameter &&
                Objects.equals(color, ball.color) &&
                Objects.equals(material, ball.material);
    }

    @Override
    public int hashCode() {
        return Objects.hash(diameter, color, material);
    }

    // other code omitted
}

Listing 5-38Basic equals(..) and hashCode() Implementations After Java 1.7

从 Java 14 开始,事情变得更加简单,因为现在像Ball这样的类可以写成记录,如清单 5-39 所示。

package com.apress.bgn.five;

import java.util.HashSet;
import java.util.Set;

record Ball(int diameter, String colour, String material) {}

public class RecordSetDemo {
    public static void main(String... args) {
        // same as Listing 5-37
    }
}

Listing 5-39Class Ball Written as a Record to Avoid Implementing equals(..) and hashCode()

执行前面清单中的代码会产生与前面相同的结果,从而证明 Java 编译器生成的equals(..)方法是有效的。

实现有一些不同,因为它们建模了键值对的集合。清单 5-40 中的代码描述了使用类型Ball的键和类型Integer的值的映射的创建和初始化。您可以想象这个 map 实例表示一个桶中相同球的数量。

package com.apress.bgn.five;

import java.util.HashMap;
import java.util.Map;

public class MapDemo {
    public static void main(String... args) {
        Map<Ball, Integer> ballMap = new HashMap<Ball, Integer>();
        ballMap.put(new Ball(2, "RED", "rubber"), 5);
        ballMap.put(new Ball(4, "BLUE", "cotton"), 7);

        for (Map.Entry<Ball, Integer> entry : ballMap.entrySet()) {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        }
    }
}

Listing 5-40Map<Ball, Integer> Code Sample

正如您从 for 循环中注意到的,您可以推断出地图实际上是Map.Entry<K, V>元素的集合。如果我们继续使用 Java 1.7 语法,通过应用<>(菱形)操作符,映射的声明会变得更简单:

Map<Ball, Integer> ballMap = new HashMap<>();

进一步到 Java 1.8,map 中的遍历和打印值也变得更加实用,因为引入了forEach(..)方法和 lambda 表达式:

ballMap.forEach((k,v) -> System.out.println(k + ": " + v));

在 Java 9 中,声明和填充映射也变得更加容易。

Map<Ball, Integer> ballMap = Map.of(new Ball(2, "RED", "rubber"), 5, new Ball(4, "BLUE", "cotton"), 7);

Java 10 增加了var来进一步简化声明。

var ballMap = Map.of(new Ball(2, "RED", "rubber"), 5, new Ball(4, "BLUE", "cotton"), 7);

在结束本节之前,需要提到的另一件事是,当一个键对值被添加到映射中并且这个键已经存在时会发生什么。正如您可能预料的那样,映射中现有的密钥对被覆盖。在 Java 8 之前,当一组值丢失时,编写代码来防止这种情况需要检查密钥是否存在,如果不存在,则添加新的密钥对,如清单 5-41 所示。

package com.apress.bgn.five;

import java.util.HashMap;
import java.util.Map;

public class MapDemo {
    public static void main(String... args) {
        Map<Ball, Integer> ballMap = new HashMap<>();
        Ball redBall = new Ball(2, "RED", "rubber");

        ballMap.put( redBall, 5);
        ballMap.put(new Ball(4, "BLUE", "cotton"), 7);

         //ballMap.put( redBall, 3); // this overrides entry <redBall, 5>

        if(!ballMap.containsKey(redBall)) {
            ballMap.put(redBall, 3);
        }

        for (Map.Entry<Ball, Integer> entry : ballMap.entrySet()) {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        }
    }
}

Listing 5-41Preventing Key-Pair Overwriting Before Java 8

在 Java 8 中,一组实用的实用方法被添加到了Map<K,V>接口中,以简化使用 maps 编写的代码,包括清单 5-42 中描述的方法putIfAbsent(..),它替换了前面代码清单中标记的语句。

package com.apress.bgn.five;

import java.util.HashMap;
import java.util.Map;

public class MapDemo {
    public static void main(String... args) {
        Map<Ball, Integer> ballMap = new HashMap<>();
        Ball redBall = new Ball(2, "RED", "rubber");

        ballMap.put( redBall, 5);
        ballMap.put(new Ball(4, "BLUE", "cotton"), 7);

        ballMap.putIfAbsent(redBall, 3);

        for (Map.Entry<Ball, Integer> entry : ballMap.entrySet()) {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        }
    }
}

Listing 5-42Preventing Key-Pair Overwriting Before Java 8

用于处理集合的 JDK 类涵盖了广泛的功能,例如排序、搜索、合并集合、交集、数组之间的转换等等。随着本书的进展,代码示例的范围将会扩大,我们将能够使用集合来解决现实世界的问题。

并发特定类型

在这本书的前面,不时提到一个 Java 程序可以有多个执行线程。默认情况下,当执行 Java 程序时,会为从main(..)方法调用的代码创建一个线程。为 JVM 相关的事情创建并并行执行一些其他实用程序线程。使用在java.lang.Thread类中定义的静态实用方法可以很容易地访问这些线程。清单 5-43 中的代码就是这么做的:它提取对Thread实例的引用,并将它们的名称打印到控制台。

package com.apress.bgn.five;

public class ListJvmThreads {
    public static void main(String... args) {
        var threadSet = Thread.getAllStackTraces().keySet();
        var threadArray = threadSet.toArray(new Thread[threadSet.size()]);
        for (int i = 0; i < threadArray.length; ++i) {
            System.out.println("thread name: " + threadArray[i].getName());
        }
    }
}

/// Output
thread name: main
thread name: Finalizer
thread name: Common-Cleaner
thread name: Monitor Ctrl-Break
thread name: Signal Dispatcher
thread name: Reference Handler
thread name: Notification Thread

Listing 5-43Code Used to Show All Threads Necessary to Run a Simple Java Application and Its Output

清单 5-43 中显示的输出是在 IntelliJ IDEA 的 macOS 计算机上运行 JDK 17-ea 上的代码时产生的。列出的线程具有以下职责:

  • 名为main的线程是执行开发者编写代码的线程。开发人员可以编写代码从主线程启动自己的线程。

  • 名为Reference Handler的线程获取未使用的对象,并将它们添加到队列中进行回收。

  • 名为Finalizer的线程是一个低优先级的 JVM 线程,它执行队列中等待从内存中被逐出的每个对象的finalize()方法。开发人员可以重写此方法,以显式释放链接到即将被收回的对象的资源。

  • 名为Common-Cleaner的线程也是一个低优先级的 JVM 线程,负责在不使用终结的情况下轻量级清理对象。

  • 名为Monitor Ctrl-Break的线程是由 IntelliJ IDEA 创建的线程,因为代码是使用这个编辑器执行的。

  • 名为Signal Dispatcher的线程处理操作系统发送给 JVM 的本地信号。

  • 名为Notification Thread的线程是操作系统发送给 JVM 的线程处理通知。

除了main、Monitor Ctrl-Break(不是 JVM 应用线程)、Common-Cleaner,其他三个都是系统线程,保证 JVM 与操作系统协作。除了main之外,其他所有线程都称为守护线程。它们的优先级很低,它们为用户线程提供服务,这就是main线程。这是 Java 中仅有的两种线程。

开发人员可以编写代码从主线程启动自己的线程。创建自定义线程最简单的方法是创建一个扩展Thread类的类。

Thread类实现了一个名为Runnable的接口,该接口声明了一个名为run()的方法。

Thread类声明了一个名为start()的方法。当这个方法被调用时,run()方法的主体在一个独立的执行线程中执行,而不是在调用start()的线程中执行。5

因此,当扩展Thread类或直接实现Runnable接口时,run()方法必须被覆盖。

清单 5-44 中的例子描述了一个名为RandomDurationThread的类。通过调用Thread.sleep(..)实用程序方法,run()方法的内容会随机暂停执行。该方法的主体被包装在两行打印线程名称的代码中:开始消息和结束消息。Thread.sleep(..)确保每个线程执行有不同的持续时间,这样我们可以清楚地看到它们是并行执行的。

package com.apress.bgn.five;

public class RandomDurationThread extends Thread {

    @Override
    public void run() {
        System.out.println(this.getName() + " started...");
        for (int i = 0; i < 10; ++i) {
            try {
                Thread.sleep(i * 10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(this.getName() + " ended.");
    }
}

Listing 5-44Code Sample Declaring Threads with Random Execution Durations By Extending the Thread Class

清单 5-45 中显示了使用RandomDurationThread创建多线程并启动它们的代码。

package com.apress.bgn.five;

public class MultipleUserThreadsDemo {
    public static void main(String... args) {
        for (int i = 0; i < 10; ++i) {
            new RandomDurationThread().start();
        }
    }
}

Listing 5-45Code Sample to Run Multiple Threads in Parallel

在前面的代码清单中,创建了类RandomDurationThread的 10 个实例,并为每个实例调用了start()方法。当前面的代码被执行时,类似于清单 5-46 中描述的日志应该被打印在控制台中。

Thread-6 started...
Thread-4 started...
Thread-2 started...
Thread-1 started...
Thread-3 started...
Thread-7 started...
Thread-5 started...
Thread-0 started...
Thread-8 started...
Thread-9 started...
Thread-2 ended.
Thread-0 ended.
Thread-4 ended.
Thread-3 ended.
Thread-8 ended.
Thread-1 ended.
Thread-9 ended.
Thread-7 ended.
Thread-6 ended.
Thread-5 ended.

Listing 5-46Output Resulted By Running the Code in Listing 5-45

从这个输出可以明显看出,线程以随机的顺序开始和结束。

另一种创建线程的方法是创建一个实现Runnable接口的类。当我们想要扩展另一个类时,或者考虑到Runnable声明了一个方法,lambda 表达式也可以使用,这很有用。清单 5-47 显示了RandomDurationThread的等效Runnable实现。

package com.apress.bgn.five;

public class RandomDurationRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " started...");
        for (int i = 0; i < 10; ++i) {
            try {
                Thread.sleep(i * 10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(Thread.currentThread().getName() + " ended.");
    }
}

Listing 5-47Code Sample Declaring Threads with Random Execution Durations By Implementing the Runnable Interface

因为我们不再能够访问线程的名称,所以要打印它,我们必须使用另一个名为Thread.currentThread()的实用方法来检索对正在执行的当前线程的引用,这样我们就可以得到它的名称。

Thread类提供了一个带有类型Runnable参数的构造函数,这意味着它可以被实现Runnable类型的任何参数调用。因此,要使用我们之前声明的RandomDurationRunnable创建线程,可以编写类似于清单 5-48 中的代码。

package com.apress.bgn.five;

public class RunnableDemo {
    public static void main(String... args) {
        for (int i = 0; i < 10; ++i) {
            new Thread(new RandomDurationRunnable()).start();
        }
    }
}

Listing 5-48Code Sample to Run Multiple Threads in Parallel Using a Class Implementing Runnable

运行前面清单中的代码产生的输出与清单 5-46 中的输出一样随机。

前面提到过,这个特例是使用 lambda 表达式的一个很好的候选,因为Runnable可以当场实现。这意味着清单 5-48 和清单 5-47 中的代码可以按照清单 5-49 中的描述进行组合。

package com.apress.bgn.five;

import static java.lang.Thread.currentThread;
import static java.lang.Thread.sleep;

public class LambdaThreadsDemo {
    public static void main(String... args) {
        for (int i = 0; i < 10; ++i) {
            new Thread(
                    //Runnable implemented on the spot
                    () -> {
                        System.out.println(currentThread().getName() + " started...");
                        for (int j = 0; j < 10; ++j) {
                            try {
                                sleep(j * 10);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                        }
                        System.out.println(currentThread().getName() + " ended.");
                    }).start();
        }
    }
}

Listing 5-49Code Sample to Run Multiple Threads in Parallel Using Lambda Expressions

Java 提供了可以创建和管理线程的线程管理类,所以开发人员不必显式声明线程。对于这本书来说,并发框架是一个太高级的主题,但如果这一节让你如此好奇,你想了解更多,你可以在这里看看 Oracle 并发教程: https://docs.oracle.com/javase/tutorial/essential/concurrency/index.html 。

摘要

在本章中,我们学习了 JVM 如何管理 Java 程序的内存,以及最常用的 Java 数据类型的基础知识。下面列出了本章中您应该记住的一些重要细节:

  • JVM 管理两种类型的内存:栈和堆。

  • 基元类型和引用类型的区别。

  • 原始值存储在栈内存中,对象值存储在堆中。

  • Java 中有八种原始数据类型:boolean、char、short、byte、int、long、float、double。

  • 引用只能是已分配对象的超类型。

  • 数组的大小是在创建时定义的,以后不能更改。

  • 在 Java 中String实例是不可变的,这意味着它们一旦被创建就不能被改变。

  • 如果需要处理日历日期,请使用新的 DateTime API。

  • null有用而强大。

  • 集合可以将对象类型组合成元组或键值对。

  • Java 中的并发性很有趣。

本章中的一些例子可能看起来很复杂,但是不要气馁。如果不提供您可以自己执行、测试甚至修改的工作代码,就很难解释某些概念。不幸的是,这需要使用将在后面章节中介绍的概念(比如for和if语句)。只要把现在不清楚的每一个概念和页码都记下来,等你在书中后面详细读到这个概念后再回到这一章。

六、运算符

前面的章节已经涵盖了 Java 编程的基本概念。你学会了如何组织你的代码,你的文件应该如何命名,你可以使用哪些数据类型,这取决于你要解决的问题。您将学习如何声明字段、变量和方法,以及如何将它们存储在内存中,从而帮助您设计解决方案,使资源消耗达到最优。

在声明变量之后,在这一章中你将学会使用操作符来组合它们。大多数 Java 操作符都是您从数学中学到的,但是因为编程涉及到数字以外的其他类型,所以添加了具有特定用途的额外操作符。在表 6-1 中,列出了所有 Java 操作符及其类别和作用域。

表 6-1

Java 转义序列

|

种类

|

操作员

|

范围

|
| --- | --- | --- |
| 铸造 | (类型) | 显式类型转换。 |
| 一元,后缀 | expr++,expr—— | 后期递增/递减。 |
| 一元,前缀 | ++exp,exp | 预递增/递减。 |
| 一元的,逻辑的 | ! | 否定。 |
| 一元、按位 | ~ | 按位补码对整数值执行逐位反转。 |
| 乘法、二进制 | *, /, % | 对于数值类型:乘、除、除并返回余数。 |
| 加法、二进制 | +, - | 对于数值类型:加法、减法。“+”也用于String连接。 |
| 二进制移位 | >>, >>, >>> | 对于数值类型:乘以和除以 2 的幂,有符号和无符号。 |
| 条件的、关系的 | instanceof | 测试对象是否是指定类型(类、子类或接口)的实例。 |
| 条件的、关系的 | ==, !=, <, >, <=, >= | 等于、不同于、小于、大于、小于或等于、大于或等于。 |
| 二进制 | & | 按位逻辑与。 |
| 二进制异或 | ^ | 双态逻辑异或。 |
| 包含或,二元 | &#124; | Bitewise 逻辑 OR。 |
| 条件逻辑 AND | && | 逻辑与。 |
| 条件、逻辑或 | &#124;&#124; | 逻辑或。 |
| 条件的、三元的 | ? : | 也被称为猫王操作员。 |
| 作业 | =, +=, -=, *=, /= %=, &=, ^=, <<=,>>=, >>>= ,&#124;= | 简单作业,组合作业。 |

让我们从编程中最常见的操作符开始这一章:赋值操作符“=”。

赋值运算符

“=”赋值操作符显然是编程中使用最多的,因为没有它什么也做不了。你创建的任何变量,不管是什么类型,原语还是引用,都必须在程序中的某一点被赋予一个值。使用赋值操作符设置值非常简单:在“=”操作符的左边是变量名,右边是一个值。赋值生效的唯一条件是值与变量的类型匹配。

为了测试这个操作符,你可以使用jshell来玩一会儿:只要确保你在详细模式下启动它,这样你就可以看到你的赋值的效果。本章执行的语句如清单 6-1 所示。

jshell -v
|  Welcome to JShell -- Version 17-ea
|  For an introduction type: /help intro

jshell> int i = 0;
i ==> 0
|  created variable i : int

jshell> i = -4;
i ==> -4
|  assigned to i : int

jshell> String sample = "text";
sample ==> "text"
|  created variable sample : String

jshell> List<String> list = new ArrayList<>();
list ==> []
|  created variable list : List<String>

jshell> list = new LinkedList<>();
list ==> []
|  assigned to list : List<String>

Listing 6-1jshell Play

在前面的例子中,我们声明了原始值和引用值,并给它们赋值和重新赋值。不允许对类型与初始类型不匹配的值进行赋值。在清单 6-2 中的代码示例中,我们试图将一个文本值赋给一个先前声明为int类型的变量。

jshell> i = -5;
i ==> -5
|  assigned to i : int

jshell> i = "you are not allowed";
|  Error:
|  incompatible types: java.lang.String cannot be converted to int
|  i = "you are not allowed";
|      ^-------------------^

Listing 6-2More jshell Play

JDK 10 中引入的类型推断对此没有影响,变量的类型将根据第一个赋值的类型来推断。显然,这意味着您不能在没有指定初始值的情况下使用var关键字声明变量。这显然排除了null值,因为它没有类型。

这可以通过将null值强制转换成我们感兴趣的类型来实现,如清单 6-3 所示。

jshell> var j;
|  Error:
|  cannot infer type for local variable j
|    (cannot use 'var' on variable without initializer)
|  var j;
|  ^----^

jshell> var j = 5;
j ==> 5
|  created variable j : int

jshell> var sample2 = "bubulina";
sample2 ==> "bubulina"
|  created variable sample2 : String
// this does not work, obviously
jshell> var funny = null;
|  Error:
|  cannot infer type for local variable funny
|    (variable initializer is 'null')
|  var funny = null;
|  ^---------------^

// yes, this actually works !
jshell> var funny = (Integer) null;
funny ==> null
|  created variable funny : Integer

Listing 6-3jshell Failed Variable Declaration

显式类型转换(type)和instanceof

这两个操作符放在一起讨论,因为提供与真实场景中可能需要编写的代码非常相似的代码样本更容易。

之前在书中提到过,最好尽可能保持引用类型的通用性,以便在不破坏代码的情况下改变具体的实现。这就是所谓的型多态性。类型多态性是为不同类型的实体提供一个单一的接口,或者使用一个单一的符号来表示多个不同的类型。

有时我们可能需要将对象组合在一起,但是根据它们的类型执行不同的代码。还记得上一章提到的Performer层级吗?我们将在这里利用这些类型来展示如何使用这些操作符。如果你不想回到上一章去记住层次结构,在图 6-1 中它又出现了,但是有了一点变化:一个名为Graphician的额外类被添加到层次结构中,它实现了接口Artist并扩展了类Human 1 。

img/463938_2_En_6_Fig1_HTML.jpg

图 6-1

人类等级制度

在下面的代码示例中,创建了一个类型为Musician的对象和一个类型为Graphician的对象,它们都被添加到包含类型为Artist的引用的列表中。我们可以这样做,因为两种类型都实现了接口Artist。清单 6-4 中的代码显示了这个层次结构中的几个类,它们被用来创建添加到同一个列表中的对象,然后从列表中提取,并测试它们的类型。

package com.apress.bgn.six;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.hierarchy.*;

import java.util.ArrayList;
import java.util.List;

public class OperatorDemo {

    public static void main(String... args) {
        List<Artist> artists = new ArrayList<>();

        Musician john = new Performer("John", 40, 1.91f, Gender.MALE);
        List<String> songs = List.of("Gravity");
        john.setSongs(songs);
        artists.add(john);

        Graphician diana = new Graphician("Diana", 23, 1.62f, Gender.FEMALE, "MacOs");
        artists.add(diana);

        for (Artist artist : artists) {
            if (artist instanceof Musician) {          // (*)
                Musician musician = (Musician) artist; // (**)
                System.out.println("Songs: " + musician.getSongs());
            } else {
                System.out.println("Other Type: " +  artist.getClass());
            }
        }
    }
}

Listing 6-4Code Sample Showing instanceof

and (type) Operators

标有(*)的行显示了如何使用instanceof操作符。该运算符用于测试对象是否是指定类型(类、超类或接口)的实例。它用于编写条件来决定应该执行哪个代码块。

标有(**)的行进行一个引用的显式转换,也称为转换操作。由于instanceof操作符有助于确定引用所指向的对象属于Musician类型,我们现在可以将引用转换为适当的类型,这样就可以调用类Musician的方法。

注意如何使用instanceof操作符来测试类型,然后,为了使用引用,需要编写一个显式转换。从 Java 14 开始,instanceof操作符被丰富成包含了转换,这使得语法更加清晰和简单,如清单 6-5 所示。

 for (Artist artist : artists) {
    if (artist instanceof Musician musician) {
        System.out.println("Songs: " + musician.getSongs());
    } else {
        System.out.println("Other Type: " +  artist.getClass());
    }
}

Listing 6-5Java 14 New instanceof Syntax

但是如果显式转换失败了会发生什么呢?为此,我们将尝试将之前声明的Graphician引用转换为音乐家。下面一行可以添加到前面的代码清单中,它不会阻止代码编译。

Musician fake = (Musician) diana;

Graphician类与Musician类型没有关系,所以代码不会运行。控制台中会抛出一个特殊的异常,告诉您发生了什么问题。控制台中打印的错误消息将非常明确,并在下一个日志片段中描述。

Exception in thread "main" java.lang.ClassCastException: class com.apress.bgn.six.Graphician cannot be cast to class com.apress.bgn.four.hierarchy.Musician (com.apress.bgn.six.Graphician is in module chapter.six of loader 'app'; com.apress.bgn.four.hierarchy.Musician is in module chapter.four@1.0-SNAPSHOT of loader 'app')
    at chapter.six/com.apress.bgn.six.OperatorDemo.main(OperatorDemo.java:25)

该消息明确指出这两种类型不兼容,并且包含了包和模块名称。

显式转换不限于引用类型;它也适用于原语。在前一章中提到,任何具有较小区间值的类型变量都可以转换为具有较大区间值的类型,而无需显式转换。通过使用显式转换,反过来也是可能的,但是如果值太大,位将丢失,并且值将是意外的。看看清单 6-6 中描述的 byte 和 int 之间转换的例子。

jshell> byte b = 2;
b ==> 2
|  created variable b : byte

jshell> int i = 10;
i ==> 10
|  modified variable i : int
|    update overwrote variable i : int

jshell> i = b
i ==> 2
|  assigned to i : int

jshell> b = i
|  Error:  \\
|  incompatible types: possible lossy conversion from int to byte
|  b = i
|      ^

jshell> b = (byte) i
b ==> 2
|  assigned to b : byte

jshell> i = 300_000
i ==> 300000
|  assigned to i : int

jshell> b = (byte) i
b ==> -32  // oops! value outside of byte interval
|  assigned to b : byte

Listing 6-6jshell Conversions Examples

一般来说,只需使用显式转换来扩大变量的范围,而不是缩小变量的范围,因为缩小变量的范围会导致异常或精度损失。

数值运算符

本节将数字类型上最常用的所有运算符组合在一起。你从 math 上知道的数值运算符:+, -, /, *和比较器在编程中也有,但是可以组合起来得到不同的效果。

一元运算符

一元运算符只需要一个操作数,它们影响应用它们的变量。

增量和减量

在 Java(和其他一些编程语言)中,有一个一元运算符,名为 incrementors( ++)和 decimator(--)。这些运算符放在变量的前面或后面,用于将变量的值增加或减少 1。它们通常在循环中用作计数器,以调节循环的终止。当它们放在变量之前时,叫做前缀,当它们放在变量之后时,叫做后缀。

当它们有前缀时,在下一条语句中使用变量之前,先对变量执行操作。这意味着在清单 6-7 中,i变量的值将递增,然后赋给j。

package com.apress.bgn.six;
public class UnaryOperatorsDemo {
    public static void main(String... args) {
        int i = 1;
        int j = ++i;
        System.out.println("j is " + j + ", i is " + i);
    }
}

Listing 6-7Prefixed Incrementor Example

前面代码的预期结果是j=2,因为i变量的值在赋给j之前被修改为 2。因此,预期输出为j is 2, i is 2。

当它们是后缀时,在下一个语句中使用该变量之后,对该变量执行操作。这意味着在清单 6-8 中,i的值首先赋给j,之后递增。

package com.apress.bgn.six;
public class UnaryOperatorsDemo {
    public static void main(String... args) {
        int i = 1;
        int j = i++;
        System.out.println("j is " + j + ", i is " + i);
    }
}

Listing 6-8Prefixed Incrementor Example

前面代码的预期结果是j=1,因为i变量的值在赋值给j后被修改为 2。因此,预期输出为j is 1, i is 2。

递减运算符也可以同样的方式使用;唯一的影响是变量减少了 1。

尝试修改UnaryOperatorsDemo以使用--操作符。

符号运算符

数学运算符+(plus)可以用在单个运算符上,表示一个数是正数(相当多余,大多数情况下从不使用)。所以基本上:

int i = 3;

与以下内容相同:

int i = +3;

数学运算符可用于声明负数。

jshell> int i = -3
i ==> -3
| created variable i : int

或者否定一个表达式:

[jshell> int i = -3
i ==> -3
| created variable i : int
[jshell> int j = - ( i + 4 )
j ==> -1
| created variable j : int

正如你在前面的例子中看到的,( i + 4 )的结果是 1,因为i = -3,但是因为圆括号前面的-,最终赋给j变量的结果是-1。

否定运算符

还有一个一元运算符,它的作用是对变量求反。运算符"!"适用于布尔变量,用于求反。所以true变成了false,而false变成了true,如清单 [6-9 所示。

jshell> boolean t = true
t ==> true
| created variable t : boolean
[jshell> boolean f = !t
f ==> false
| created variable f : boolean
[jshell> boolean t2 = !f
t2 ==> true
| created variable t2 : boolean

Listing 6-9Negating Boolean Values in jshell

二元运算符

二元运算符相当多,有些甚至可以组合起来执行新的运算。这部分从你可能从数学中知道的那些开始。

+(加/加/串联)运算符

"+"用于将两个数值变量相加,如清单 [6-10 中的语句所示。

jshell> int i = 4
i ==> 4
|  created variable i : int

jshell> int j = 6
j ==> 6
|  created variable j : int

jshell> int k = i + j
k ==> 10
|  created variable k : int

jshell> int i = i + 2
i ==> 6
|  modified variable i : int
|    update overwrote variable i : int

Listing 6-10Adding Numeric Values in jshell

最后一个语句 int i = i + 2的作用是将i的值增加 2,正如你所看到的,这里有一点冗余。这个语句可以写成两次不提到i,因为它的作用是把 I 的值增加 2。这可以通过使用由赋值和加法操作符组成的+=操作符来完成。最佳说法是i += 2。

+操作符也可以用来连接String实例,或者将String实例与其他类型的实例连接起来。JVM 根据上下文决定如何使用+操作符。例如,试着猜测正在执行的清单 6-11 中代码的输出。

package com.apress.bgn.six;

public class ConcatenationDemo {
    public static void main(String... args) {
        int i1 = 0;
        int i2 = 1;
        int i3 = 2;
        System.out.println(i1 + i2 + i3);
        System.out.println("Result1 = " + (i1 + i2) + i3);
        System.out.println("Result2 = " + i1 + i2 + i3);
        System.out.println("Result3 = " + (i1 + i2 + i3));
    }
}

Listing 6-11Concatenating String and int Values

猜得怎么样了?

如果代码被执行,下面的内容将显示在控制台中。

1\. 3
2\. Result1 = 12
3\. Result2 = 012
4\. Result3 = 3

此输出中每一行的解释如下所示:

  • 第 1 行的结果可以解释如下:所有操作数的类型都是int,所以 JVM 将这些项作为int值相加,System.out.println方法打印出这个结果。

  • 第 2 行的结果可以解释如下:括号隔离了两个术语(i1+i2)的相加。因此,JVM 执行圆括号之间的加法,就像对int值的普通加法一样。但是在那之后,我们剩下的是"Result1 = " + 1 + i3,并且这个操作包括一个String操作数,这意味着+操作符必须被用作连接操作符,因为添加一个带有文本值的数字没有其他作用。

  • 此时,第 3 行解释中的结果应该是显而易见的:我们有三个int操作数和一个String操作数,因此 JVM 决定操作的上下文不能是数字,所以需要连接。

  • 第 4 行的结果可以用与第 2 行类似的方式来解释。括号确保运算的上下文是数字的,因此添加了三个操作数。

这是一个典型的例子,展示了 JVM 如何决定涉及到+操作符的操作的上下文,您也可以在其他 Java 教程中找到这个例子。但是 int 变量可以被替换为float或double,其行为是相似的。串联也适用于引用类型,因为 any Java 类型默认是Object的扩展,因此可以通过调用其toString()方法转换为String。清单 6-12 显示了一个String和一个Performer实例之间的连接。

package com.apress.bgn.six;

import com.apress.bgn.four.classes.Gender;
import com.apress.bgn.four.hierarchy.Musician;
import com.apress.bgn.four.hierarchy.Performer;

public class ReferenceConcatenationDemo {
    public static void main(String... args) {
        Musician john = new Performer("John", 43, 1.91f, Gender.MALE);

        System.out.println("Singer: " + john);
        // or convert explicitly
        System.out.println("Singer: " + john.toString());
    }
}

Listing 6-12Concatenating String and Performer Values

-(减)运算符

数学运算符-(minus)用于减去两个变量或从一个变量中减去一个值。在清单 6-13 中,您可以看到这个操作符和由赋值操作符组成的-=操作符以及减法操作符是如何使用的。

jshell> int i = 4
i ==> 4
|  created variable i : int

jshell> int j = 2
j ==> 2
|  created variable j : int

jshell> int k = i - j
k ==> 2
|  created variable k : int

jshell> int i = 4
i ==> 4
|  modified variable i : int
|    update overwrote variable i : int

jshell> i  = i - 3
i ==> 1
|  assigned to i : int

jshell> int i = 4
i ==> 4
|  modified variable i : int
|    update overwrote variable i : int

jshell> i -=3
$7 ==> 1
|  created scratch variable $7 : int

Listing 6-13Subtracting Numeric Values in jshell

*(乘)运算符

“*”(乘法)运算符用于将两个变量相乘或将一个值与一个变量相乘。可以用在类似于"+"和-的语句中,还有一个复合运算符“*=”,可以用来将一个变量的值相乘并就地赋值。在清单 6-14 中,您可以看到这个操作符在工作。

jshell>  int i = 4
i ==> 4
|  created variable i : int

jshell> int j = 2
j ==> 2
|  created variable j : int

jshell> int k = i * j
k ==> 8
|  created variable k : int

jshell> int i = 4
i ==> 4
|  modified variable i : int
|    update overwrote variable i : int

jshell> i  = i * 3
i ==> 12
|  assigned to i : int

jshell> int i = 4
i ==> 4
|  modified variable i : int
|    update overwrote variable i : int

jshell>  i *= 3
$7 ==> 12
|  created scratch variable $7 : int

Listing 6-14Multiplying Numeric Values in jshell

/(除法)运算符

The "/"(divide)运算符用于将两个变量相除或将一个值除以一个变量。可以在类似语句中使用为“+”、-,还有一个复合运算符“/=”,可以用来对一个变量的值进行除法运算,并当场赋值。

除法的结果被命名为商,,它被赋给赋值运算符(" = ")左侧的变量。当操作数是整数时,结果也是整数,余数被丢弃。在清单 6-15 中,您可以看到这个操作符在工作。

jshell> int i = 4
i ==> 4
|  created variable i : int

jshell> int j = 2
j ==> 2
|  created variable j : int

jshell> int k = i / j
k ==> 2
|  created variable k : int

jshell> int i = 4
i ==> 4
|  modified variable i : int
|    update overwrote variable i : int

jshell> int i = i / 3
i ==> 1
|  modified variable i : int
|    update overwrote variable i : int

jshell> int i = 4
i ==> 4
|  modified variable i : int
|    update overwrote variable i : int

jshell> i /= 3
$7 ==> 1
|  created scratch variable $7 : int

Listing 6-15Divide Numeric Values in jshell

%(模数)运算符

"%"也称为模数运算符,用于将两个变量相除,但结果是相除的余数。这个操作叫做模块化 、,还有一个复合操作符%=,可以用来除一个变量的值,并当场分配余数。在清单 6-16 中,您可以看到这个操作符在工作。

jshell> int i = 4
i ==> 4
|  created variable i : int

jshell> int j = 3
j ==> 3
|  created variable j : int

jshell> int k = i % j
k ==> 1
|  created variable k : int

jshell> int i = 4
i ==> 4
|  modified variable i : int
|    update overwrote variable i : int

jshell> i = i % 3
i ==> 1
|  assigned to i : int

jshell> int i = 4
i ==> 4
|  modified variable i : int
|    update overwrote variable i : int

jshell> i %= 3
$7 ==> 1
|  created scratch variable $7 : int

Listing 6-16Modulus Numeric Values in jshell

模数运算符返回余数,但是当操作数是实数时会发生什么呢?

简而言之,浮点数的运算很复杂。它取决于小数点后的位数,以及用于除法的操作数。看一下清单 6-17 。

jshell> double d = 5.28d
d ==> 5.28
|  created variable d : double

jshell> d / 2
$2 ==> 2.64
|  created scratch variable $2 : double

jshell> d % 2
$4 ==> 1.2800000000000002
|  created scratch variable $4 : double

Listing 6-17Modulus Numeric Operations with Floating Point Numbers in jshell

对上述结果的解释是由于浮点数的内部表示方式导致精度损失。

此外,如果余数是一个小数点后有无限小数位数的实数,表示它是不可能的,所以一些四舍五入是必要的。这显示在清单 6-18 中。

jshell> float f = 1.9f
f ==> 1.9
|  created variable f : float

jshell> float g = 0.4f
g ==> 0.4
|  created variable g : float

jshell> float h = f % g
h ==> 0.29999995    // remainder
|  created variable h : float

Listing 6-18Loss of Precision in jshell for a Remainder with an Infinite Number of Decimals After the Decimal Point

jshell中返回的提醒是0.29999995,有些情况下可以四舍五入为0.3。但是,当数据用于敏感操作时,四舍五入可能是危险的,例如确定机器人手术的肿瘤体积或发射到火星的火箭的完美轨道。

浮点数的舍入是有问题的,因为它会导致精度的损失。

使用浮点数时精度的损失不是 Java 的问题,因为根据 IEEE754 2 算法的规则支持使用浮点数的运算。

如果一个项目需要更高精度的数学运算,java.lang.Math类提供了不同类型的舍入和其他类型的浮点数运算的方法。

关系运算

在某些情况下,当设计问题的解决方案时,您需要引入条件来驱动和控制执行流。条件要求使用比较运算符对两个项之间的比较进行评估。本节描述了 Java 中使用的所有比较运算符,并提供了代码示例。我们继续吧。

==等于运算符

测试术语的相等性。因为在 Java 中,单个等号(" = ")用于赋值,所以引入了双等号来测试相等性并避免混淆。该操作符经常用于控制执行流。控制执行流是下一章的主题,但是为了说明如何使用"=="操作符,本章将介绍几个简单的代码示例,包括if和for等控制语句。在清单 6-19 中,你可以看到一个测试"=="比较器在数组中搜索值 2 的例子。如果找到该值,则在控制台中打印找到该值的索引。

package com.apress.bgn.six;

public class ComparisonOperatorsDemo {
    public static void main(String... args) {
        int[] values = {1, 7, 9, 2, 6,};
        for (int i = 0; i < values.length; ++i) {
            if (values[i] == 2) {
                System.out.println("Fount 2 at index: " + i);
            }
        }
    }
}

Listing 6-19Example for Using the "==" Operator to Test a Value in an Array

对标记行中的条件进行评估,结果是一个布尔值。当结果为false时,什么都不做,但是如果结果为true则打印索引。因为结果是布尔类型的,如果您犯了一个错误,使用了=而不是==,代码将无法编译。

在比较布尔值时,你必须格外小心。清单 5-20 中的代码可以编译并运行,但是它不能像预期的那样工作。

package com.apress.bgn.six;

public class BadAssignementDemo {
    public static void main(String... args) {
        boolean testVal = false;

        if(testVal = true) {
            System.out.println("TestVal got initialized incorrectly!");
        } else {
            System.out.println("TestVal is false? " + (testVal == false));
        }
    }
}

Listing 6-20Example of an Unexpected Initialization of a Boolean Variable Instead of an Evaluation of Its Value

“==”符号对于原语来说很好。对于引用类型,在解释栈和堆内存的区别时,需要使用本书前面章节 开头的equals()方法。

其他比较运算符

其他比较运算符只作用于基本类型。由于没有太多关于它们的内容,本节将一一介绍。

  • 测试术语的不相等性。它与==运算符相反。这个操作符也作用于引用类型,但是它比较的是引用值而不是对象本身,就像==一样。

作为一个练习,修改清单 6-19 中的例子,当数组元素值不等于 2 时打印一条消息。

  • <和<=的用途和你可能在数学课上学过的一样。第一个(<)测试操作符左边的项目是否小于右边的项目。下一个(<=)测试运算符左边的项目是否小于或等于右边的项目。此运算符不能用于引用类型。

  • >和>=的用途和你可能在数学课上学过的一样。第一个(>)测试操作符左边的项目是否大于右边的项目。下一个(>=)测试运算符左边的项目是否大于或等于右边的项目。此运算符不能用于引用类型。

几乎所有数值运算符都可以用于不同类型的变量,因为它们会自动转换为具有更宽区间表示的类型。清单 6-21 中的代码反映了一些情况,但是在实践中,你可能需要做出更加极端的事情,这些事情并不总是遵守编程的常识规则,也没有遵循好的实践。不过,如果可以的话,尽量避免这样做!

package com.apress.bgn.six;

public class MixedOperationsDemo {
    public static void main(String... args) {
        byte b = 1;
        short s = 2;
        int i = 3;
        long l = 4;

        float f = 5;
        double d = 6;
        int ii = 6;

        double resd = l + d;
        long resl = s + 3;
        //etc

        if (b <= s) {
            System.out.println("byte val < short val");
        }
        if (i >= b) {
            System.out.println("int val >= byte val");
        }
        if (l > b) {
            System.out.println("long val > byte val");
        }
        if(d > i) {
            System.out.println("double val > byte val");
        }
        if(i == i) {
            System.out.println("double val == int val");
        }
    }
}

Listing 6-21Different Primitive Types Comparison Examples

只要确保你曾经处于这样一种情况,你需要做一些可疑的事情(非优化代码构造)像这些来进行大量的测试,并且认为你的转换是好的,特别是当涉及到浮点类型的时候。这是因为(例如)清单 6-22 中的这段代码可能会产生意想不到的结果。

package com.apress.bgn.six;

public class BadDecimalPointDemo {
    public static void main(String... args) {
        float f1 = 2.2f;
        float f2 = 2.0f;
        float f3 = f1 * f2;
        if (f3 == 4.4) {
            System.out.println("expected float value of 4.4");
        } else {
            System.out.println("!! unexpected value of " + f3);
        }
    }
}

Listing 6-22Unexpected Comparison Results with Floating Numbers

如果您期望控制台中打印出消息预期浮点值为 4.4 ,您将会非常惊讶。

任何 IEEE 754 浮点数表示都会出现问题,因为一些数字在十进制中看起来有固定的小数位数,实际上在二进制中有无限多的小数位数。所以显然我们不能用==来比较浮点数和双精度数。最容易实现的解决方案之一是使用包装类提供的比较方法,在本例中是Float.compare,如清单 6-23 所示。

package com.apress.bgn.six;

public class GoodDecimalPointDemo {
    public static void main(String... args) {
        float f1 = 2.2f;
        float f2 = 2.0f;
        float f3 = f1 * f2;
        if (Float.compare(f3,4.4f) == 0) {
            System.out.println("expected float value of 4.4");
        } else {
            System.out.println("!!unexpected value of " + f3);
        }
    }
}

Listing 6-23Correct Comparison Results with Float.compare

使用前面的例子,预期的消息现在被打印在控制台中:预期的浮点值 4.4 。

按位运算符

在 Java 中,有几个操作符用于位级操作数值类型的变量。按位运算符用于改变操作数中的各个位。由于减少了资源的使用,位运算速度更快,通常使用的 CPU 处理能力也更少。它们在编程视觉应用(例如游戏)时最有用,在这些应用中,颜色、鼠标点击和移动应该被快速确定,以确保令人满意的体验。

按位非

运算符~有点像二元运算符的反运算符。Is 执行整数值的逐位反转。这会影响用于表示该值的所有位。所以如果我们宣布

byte b1 = 10;

二进制表示是00001010。Integer类提供了一个名为toBinaryString()的方法,可以用来打印之前定义的变量的二进制表示,但是它不会打印所有的位,因为这个方法不知道我们想要多少位的表示。所以我们需要使用一个特殊的String方法来格式化输出。清单 6-24 中描述的方法可以用来打印 8 位二进制的b1值,正如前面提到的。

public static void print8Bits(byte arg) {
    System.out.println("decimal:" + arg);
    String str =
       String.format("%8s", Integer.toBinaryString(arg)).replace(' ', '0');
    System.out.println("binary:" + str);
}

Listing 6-24Method Used to Print Each Bit of a byte Value

如果我们对b1值应用~操作符,得到的二进制值就是11110101。如果您没有注意到,该值超出了byte间隔范围,并自动转换为int。这就是负数在 Java 中的内部表示方式——根据 Java 语言规范,这种表示方式称为 2 的补码。(这一点将在本章末尾讨论。)

因此结果将是-11,如清单 6-25 中的代码所示:

package com.apress.bgn.six;
BitwiseDemo

public class BitwiseDemo {
    public static void main(String... args) {
        byte b1 = 10;
        print8Bits(b1);
        byte b2 = (byte) ~b1;
        print8Bits(b2);
    }

    // print8Bits method omitted
}

// execution result
decimal:10
binary:00001010
decimal:-11
binary:11111111111111111111111111110101

Listing 6-25Testing the ~ Bitwise Negator Operator

在前面的代码清单中,您可能注意到了这个语句字节b2 = (byte) ~b1,并且希望得到解释。按位补码表达式运算符需要一个可转换为基元整数类型的操作数,否则会发生编译时错误。在内部,Java 使用一个或多个字节来表示值。~运算符将其操作数转换为int类型,因此在进行补码运算时可以使用 32 位;这是避免精度损失所必需的。这就是为什么在前面的例子中需要显式转换为byte。因为有了图像,一切都变得更加清晰,在图 6-2 中,你可以看到~对b1变量位的影响,与其值平行。

img/463938_2_En_6_Fig2_HTML.jpg

图 6-2

negator 运算符对字节值中每一位的影响

按位 AND

按位AND运算符由&表示,它逐位比较两个数字。如果相同位置上的位的值为1,则结果中的位将为1。清单 6-26 中的代码示例描述了&操作符的结果。

package com.apress.bgn.six;

public class BitwiseDemo {
    public static void main(String... args) {
        byte b1 = 117;  // 01110101
        print8Bits(b1);
        byte b2 = 95;   // 01011111
        print8Bits(b2);
        byte result  = (byte) (b1 & b2);  // 01010101
        print8Bits(result);
    }

    // print8Bits method omitted
}

// execution result
decimal:117
binary:01110101
decimal:95
binary:01011111
decimal:85
binary:01010101

Listing 6-26Testing the & Bitwise AND Operator

在图 6-3 中可以更好地看到&操作符的效果。01010101值是十进制数 85 的二进制表示。

img/463938_2_En_6_Fig3_HTML.jpg

图 6-3

&运算符对每一位的影响

此外,出于实际原因,Java 中提供了组合操作符&=,以便可以对结果所赋给的同一个变量进行按位AND操作,如清单 6-27 所示。这样做的好处是结果会自动转换成byte,所以不需要显式转换。

jshell> byte b1 = 117
b1 ==> 117
|  created variable b1 : byte

jshell> b1 &= 95
$2 ==> 85
|  created scratch variable $2 : byte

Listing 6-27Testing the &= Bitwise AND Operator in jshell

按位异或

按位OR运算符(也称为包含 or)由|(管道)表示,它逐位比较两个数字,如果至少一个位为 1,则结果中的位被设置为 1。清单 6-28 中的代码描述了|操作符的结果。

package com.apress.bgn.six;

public class BitwiseDemo {
    public static void main(String... args) {
        byte b1 = 117; // 01110101
        print8Bits(b1);
        byte b2 = 95; // 01011111
        print8Bits(b2);
        byte result  = (byte) (b1 | b2); // 01111111
        print8Bits(result);
    }

    // print8Bits method omitted
}

// execution result
decimal:117
binary:01110101
decimal:95
binary:01011111
decimal:127
binary:01111111

Listing 6-28Testing the | Bitwise OR Operator

在图 6-4 中可以更好地看到|操作符的效果。01111111值是数字 127 的二进制表示。

img/463938_2_En_6_Fig4_HTML.jpg

图 6-4

|运算符对每一位的影响

此外,出于实际原因,Java 中提供了复合运算符|=,以便可以对结果所赋给的同一个变量进行按位异或运算,如清单 6-29 所示。这样做的好处是结果会自动转换成byte,所以不需要显式转换。

jshell>  byte b1 = 117
b1 ==> 117
|  created variable b1 : byte

jshell> b1 |= 95
$2 ==> 127
|  created scratch variable $2 : byte

Listing 6-29Testing the |= Bitwise OR Operator in jshell

按位异或

按位异或或异或运算符由^表示,它逐位比较两个数,如果两位的值不同,则结果中的位被设置为 1。清单 6-30 中的代码示例描述了^操作符的结果。

package com.apress.bgn.six;

public class BitwiseDemo {
    public static void main(String... args) {
        byte b1 = 117; // 01110101
        print8Bits(b1);
        byte b2 = 95; // 01011111
        print8Bits(b2);
        byte result  = (byte) (b1 ^ b2); // 00101010
        print8Bits(result);
    }

    // print8Bits method omitted
}

// execution result
decimal:117
binary:01110101
decimal:95
binary:01011111
decimal:42
binary:00101010

Listing 6-30Testing the ^ Bitwise XOR Operator

在图 6-5 中可以更好地看到^操作符的效果。00101010值是数字 42 的二进制表示。

img/463938_2_En_6_Fig5_HTML.jpg

图 6-5

^运算符对每一位的影响

此外,出于实际原因,Java 中提供了复合运算符^=,以便可以对结果所赋给的同一个变量进行按位异或运算,如清单 6-31 所示。这样做的好处是结果会自动转换成byte,所以不需要显式转换。

jshell> byte b1 = 117
b1 ==> 117
|  created variable b1 : byte

jshell> b1 ^= 95
$2 ==> 42
|  created scratch variable $2 : byte

Listing 6-31Testing the ^= Bitwise OR Operator in jshell

逻辑运算符

当设计用于控制程序执行流程的条件时,有时需要编写复杂的条件:由多个表达式构造的复合条件。有四种运算符可用于构造复杂的条件。其中两个是可以重用的位运算&(AND)和|(OR),但是它们需要对条件的所有部分进行求值。其他操作符&&(AND)和||(OR)与前面提到的操作符具有完全相同的效果,但不同之处在于它们不需要对所有表达式求值,这就是为什么它们也被称为快捷操作符。为了解释这些操作符的行为,有一个典型的例子可以使用。

在清单 6-32 中,我们声明了一个包含 10 个术语的列表(其中一些是null)和一个生成随机索引的方法,该索引用于从列表中选择一个条目。然后我们测试从列表中选择的元素,看看它是否不是null并且等于一个期望值。如果两个条件都为真,则在控制台中打印一条消息。让我们看看第一个例子。

package com.apress.bgn.six;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class LogicalDemo {
    static List<String> terms = new ArrayList<>() {{
        add("Rose");
        add(null);
        add("River");
        add("Clara");
        add("Vastra");
        add("Psi");
        add("Cas");
        add(null);
        add("Nardhole");
        add("Strax");
    }};

    public static void main(String... args) {
        for (int i = 0; i < 20; ++i) {
            int index = getRandomIndex(terms.size());
            String term = terms.get(index);
            System.out.println("Generated index: " + index);
            if (term != null & term.equals("Rose")) {
                System.out.println("Rose was found");
            }
        }
    }

    private static int getRandomIndex(int listSize) {
        Random r = new Random();
        return r.nextInt(listSize);
    }
}

Listing 6-32Testing the & Operator to Control the Execution Flow

为了确保得到预期的结果,我们将从列表中选择一个随机元素的操作重复 20 次。正如您可能注意到的,在标记的行中,按位&用于组合两个表达式。只有当变量term的值不是null并且等于Rose时,您才会期望控制台中打印出文本“Rose found”。但是,当运行前面的代码时,会打印出以下内容:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because "term" is null
    at chapter.six/com.apress.bgn.six.LogicalDemo.main(LogicalDemo.java:56)

这是因为两个表达式都被求值。但是想想吧!如果term变量是null,我们甚至应该评估它与Rose的相等性吗,尤其是在调用null对象的方法会导致运行时错误的情况下?显然不是,这就是为什么&不适合这种情况。如果 term 是null,那么它不满足第一个条件,对第二个条件求值也没有意义,所以输入&&快捷操作符,它就能做到这一点。这是因为当使用逻辑AND操作符时,如果第一个表达式被求值为false,那么第二个表达式被求值为什么并不重要;结果永远是false。因此,我们可以将前面的代码示例更正为清单 6-33 中的代码示例。

package com.apress.bgn.six;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class LogicalDemo {
    static List<String> terms = new ArrayList<>() {{
        /* list elements omitted */}};

    public static void main(String... args) {
        for (int i = 0; i < 20; ++i) {
            int index = getRandomIndex(terms.size());
            String term = terms.get(index);
            System.out.println("Generated index: " + index);
            if (term != null && term.equals("Rose")) {
                System.out.println("Rose was found");
            }
        }
    }

   // getRandomIndex method omitted
}

Listing 6-33Testing the && Operator to Control the Execution Flow

当执行代码时,不会抛出异常,因为如果term是null,则第二个表达式不会被求值。因此,这段代码在技术上更有效,因为它评估的条件更少,但它也设计得更好,因为它避免了失败。

现在,让我们修改前面的代码示例,这一次,如果我们找到了null或Rose,我们将打印一条消息。为此需要一个 or 运算符,所以我们将首先尝试使用按位版本(清单 6-34 ):

package com.apress.bgn.six;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class LogicalDemo {
    static List<String> terms = new ArrayList<>() {{
        /* list elements omitted */}};

    public static void main(String... args) {
        for (int i = 0; i < 20; ++i) {
            int index = getRandomIndex(terms.size());
            String term = terms.get(index);
            System.out.println("Generated index: " + index);
            if (term == null | term.equals("Rose")) {
                System.out.println("null or Rose was found");
            }
        }
    }

   // getRandomIndex method omitted
}

Listing 6-34Testing the && Operator to Control the Execution Flow

如果我们运行前面的代码,当随机索引恰好匹配列表中的一个null元素的索引时,就会抛出一个NullPointerException。这是因为|操作符要求对两个表达式都求值,所以如果term为空,调用term.equals(..)将导致抛出异常。因此,为了确保代码按预期工作,必须用||替换|,这简化了条件,并且不计算其中的第二个表达式,除非第一个条件的计算结果是false。这是因为当使用逻辑 OR 操作符时,如果第一个表达式的计算结果是true,那么第二个表达式的计算结果是什么并不重要,结果总是true。我们将把它作为一个练习留给你。

条件可以由多个表达式和多个运算符组成,无论是&&还是||。清单 6-35 中的代码描述了一些复杂的情况。

package com.apress.bgn.six;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class ComplexConditionsDemo {
    static List<String> terms = new ArrayList<>()  {{
        /* list elements omitted */}};

    public static void main(String... args) {
        for (int i = 0; i < 20; ++i) {
            int rnd = getRandomIndex(terms.size());
            if (rnd == 0 || rnd == 1 || rnd <= 3) {
                System.out.println(rnd + ": this works...");
            }
            if (rnd > 3 && rnd <=6 || rnd < 3 && rnd > 0) {
                System.out.println(rnd + ": this works too...");
            }
        }
    }

    private static int getRandomIndex(int listSize) {
        Random r = new Random();
        return r.nextInt(listSize);
    }
}

Listing 6-35Complex Conditions Composed from Multiple Expressions

当心变得太复杂的情况;确保用大量测试覆盖这段代码。在编写复杂的条件时,一些表达式可能会变得多余,IntelliJ IDEA 和其他智能编辑器会在多余和未使用的表达式上显示死代码警告,以帮助开发人员改进代码设计。

移位运算符

移位操作符是在比特级工作的操作符。因为移动位是一个敏感的操作,所以这些操作数的要求是参数必须是整数。运算符左边的操作数是将要移位的数字,运算符右边的操作数是将要移位的位数。

Java 中有三个移位操作符,每一个都可以和赋值操作符组合起来进行移位,并将结果就地赋给原变量。本节通过简单的例子和图像来分析所有的移位操作符,使事情变得清晰。

<<左移运算符

顾名思义,给定一个用二进制表示的数,这个运算符用于向左移位。清单 6-36 中的代码显示了运行中的<<左移操作符。

package com.apress.bgn.six;

public class ShiftDemo {
    public static void main(String... args) {
        byte b1 = 12; // 00001100
        print8Bits(b1);
        byte b2 = (byte) (b1 << 3); // 01100000
        print8Bits(b2);
    }

    // print8Bits method omitted
}

// execution result
decimal:12
binary:00001100
decimal:96
binary:01100000

Listing 6-36Testing the << Operator

当位向左移位时,剩余的位置用 0 填充。同样,数字变大了,新值是它的旧值乘以2^N,其中N是第二个操作数。

清单 6-36 中的代码可以像b1 <<= 3一样编写,使用复合操作符,不需要声明另一个变量。结果是12 * 2³。如图 6-6 所示移位。

img/463938_2_En_6_Fig6_HTML.jpg

图 6-6

`<

img/463938_2_En_6_Figc_HTML.jpg移位运算符将byte值提升为int,以避免精度损失。在前面的代码示例中,要移位的位数很小,足以产生一个位于byte类型区间内的值。这就是为什么显式转换为byte有效,并且结果仍然有效。这并不总是可能的,正如您将在本节中进一步看到的那样。

>>符号右移运算符

顾名思义,给定一个用二进制表示的数,这个运算符用于向右移位。清单 6-37 中的代码显示了运行中的>>右移操作符。

package com.apress.bgn.six;

public class ShiftDemo {
    public static void main(String... args) {
        byte b1 = 96; // 01100000
        print8Bits(b1);
        byte b2 = (byte) (b1 >> 3); // 00001100
        print8Bits(b2);
    }

    // print8Bits method omitted
}

// execution result
decimal:96
binary:01100000
decimal:12
binary:00001100

Listing 6-37Testing the >> Operator

当位向右移位时,如果数字为正数,则剩余的位置用 0 填充。如果数字为负数,则剩余的位置将被替换为 1。这样做是为了保留数字的符号。同样,数字变小,新值是它的旧值除以2^N,其中N是第二个操作数。

清单 6-37 中的代码可以写成b1 >>= 3,使用复合运算符,无需声明另一个变量。结果是12 * 2³。如图 6-7 所示移位。

img/463938_2_En_6_Fig7_HTML.jpg

图 6-7

>>运算符的作用

图 6-7 和清单 6-37 都显示了应用于正数的右移运算符。当涉及到负数时,事情就变得复杂了,因为负数在内部被表示为 2 的补码。这是什么意思?这意味着,为了得到负数的表示,我们得到正数的表示,我们翻转这些位,然后加 1。图 6-8 描绘了从7的表示开始,获取-7的内部表示的过程。

img/463938_2_En_6_Fig8_HTML.jpg

图 6-8

用二进制补码在内部表示负数

2 的补码表示中的-7值在byte范围之外,因此内部负数表示为整数。这意味着print8Bits(..)方法需要被替换为打印所有 32 位int值的版本。清单 6-38 显示了应用于负数的>>无符号右移运算符。

package com.apress.bgn.six;

public class ShiftDemo {
    public static void main(String... args) {
        System.out.println( " -- ");
        int i1 = -96;
        print32Bits(i1);
        int i2 =  i1 >> 3;
        print32Bits(i2);
    }

    public static void print32Bits(int arg) {
      System.out.println("decimal:" + arg);
      String str = arg > 0 ?
       String.format("%32s", Integer.toBinaryString(arg)).replace(' ', '0'):
       String.format("%32s", Integer.toBinaryString(arg)).replace(' ', '1');
      System.out.println("binary:" + str);
    }
}

// execution result
decimal:-96
binary:11111111111111111111111110100000
decimal:-12
binary:11111111111111111111111111110100

Listing 6-38Testing the >> Operator with Negative Numbers

二进制补码表示的一个优点是,算术运算对于有符号和无符号运算符是相同的,这意味着 cpu 的算术逻辑单元需要一半的电路。

img/463938_2_En_6_Figd_HTML.jpg二进制补码表示的一个奇特之处在于-Integer.MAX_VALUE和Integer.MIN_VALUE以相同的方式表示。

>>>无符号右移运算符

>>>无符号右移运算符也叫逻辑移位。给定一个用二进制表示的数,该运算符用于将位向右移位,剩余的位置用 0 替换,而不管该值是正还是负。这就是为什么结果总是正数。

清单 6-39 显示了>>>无符号右移操作符对负值的作用。

package com.apress.bgn.six;

public class ShiftDemo {
    public static void main(String... args) {
        int i1 = -16;
        print32Bits(i1);
        int i2 = i1 >>> 1;
        print32Bits(i2);
    }

    // print32Bits method omitted
}

// execution result
decimal:-16
binary:11111111111111111111111111110000
decimal:2147483640
binary:01111111111111111111111111111000

Listing 6-39Testing the >>> Operator with Negative Values

清单 6-39 中的代码可以像i1 >>>= 1一样编写,使用组合操作符,不需要声明另一个变量。结果是一个非常大的正数。如图 6-9 所示移位。

img/463938_2_En_6_Fig9_HTML.jpg

图 6-9

>>>运算符对负值的影响

与所有按位运算符一样,移位运算符将char、byte或short类型变量提升为int,这就是为什么显式转换是必要的。你可能已经注意到,对负数进行移位是很棘手的;结果数字很容易超出类型允许值的区间,显式转换会导致精度损失,甚至严重异常。那么为什么要使用它们呢?因为他们速度很快。只要确保在使用移位运算符时进行密集测试即可。

猫王接线员

****猫王运算符是 Java 中唯一的三元运算符。它的功能相当于一个 Java 方法,该方法评估一个条件,并根据结果返回值。Elvis 操作员的模板如下所示:

variable = (condition) ? val1 : val2

清单 6-40 中描述了与该运算符等效的方法。

variable = methodName(..);

type methodName(..) {
    if (condition) {
        return val1;
    } else {
        return val2;
    }
}

Listing 6-40The Elvis Operator Equivalent Method

这个运算符被命名为 Elvis 运算符的原因是,问号类似于 Elvis Presley 的头发,而冒号类似于眼睛。Elvis 操作员可以在jshell中轻松测试,如清单 6-41 所示。

jshell> int a = 4
a ==> 4
|  created variable a : int

jshell> int result = a > 4 ? 3 : 1;
result ==> 1
|  created variable result : int

jshell> String a2 = "test"
a2 ==> "test"
|  created variable a2 : String

jshell> var a3 = a2.length() > 3 ? "hello" : "bye-bye"
a3 ==> "hello"
|  created variable a3 : String

Listing 6-41The Elvis Operator Being Tested in jshell

当您有一个简单的if语句,每个分支只包含一个表达式时,这个操作符非常实用,因为使用这个操作符,您可以将所有内容压缩到一个表达式、一行代码中。只要确保在使用它时,代码的可读性得到了提高。从性能的角度来看,if语句和等效的 Elvis 操作符表达式没有区别。使用 Elvis 操作符的另一个优点是表达式可以用来初始化变量。

摘要

在本章中,我们了解到:

  • Java 有很多操作符,简单的,复合的。

  • 按位运算速度很快,但是很危险。

  • 负数在内部用二进制补码表示。

  • 操作符在不同的上下文中做不同的事情。

  • Java 有一个三元运算符,它接受三个操作数:一个布尔表达式和两个相同类型的对象。布尔表达式的求值结果决定了哪个操作数是语句的结果。

本章的目的只是让你熟悉整本书中用到的所有操作符,帮助你理解提供的解决方案,甚至设计和编写你自己的解决方案。

**

七、控制流程

前面几章已经介绍了创建语句的方法,以及根据操作数类型使用什么运算符。在前面的章节中,有时会添加一些逻辑元素来使代码可以运行,本章将详细解释如何使用基本的编程条件语句和重复语句来操作代码的执行。一个解决方案,一个算法可以用流程图来表示。

到本章为止,我们所做的大多数编程都包含声明和打印语句,简单的单步执行语句。看看清单 7-1 中的这段代码。

package com.apress.bgn.seven;

public class Main {
    public static void main(String... args) {
        String text = "sample";
        System.out.println(text);
    }
}

Listing 7-1Java Code Made of a Few Statements

如果我们要为它设计一个流程图,这个模式将是简单的和线性的,没有决策,也没有重复,如图 7-1 所示。

img/463938_2_En_7_Fig1_HTML.jpg

图 7-1

简单流程图示例

解决现实世界的问题通常需要比这更复杂的逻辑,因此更复杂的语句是必要的。在此之前,让我们描述一下流程图的组成部分,因为在本章中我们会用到很多。在表 7-1 中,列出了所有流程图元素,并解释了它们的用途。

表 7-1

流程图元素

|

形状

|

名字

|

范围

|
| --- | --- | --- |
| img/463938_2_En_7_Figa_HTML.gif | 末端的 | 指示程序的开始或结束,并包含与其范围相关的文本。 |
| img/463938_2_En_7_Figb_HTML.gif | 流线 | 表示程序的流程和操作的顺序。 |
| img/463938_2_En_7_Figc_HTML.gif | 输入/输出 | 指示变量声明和输出值。 |
| img/463938_2_En_7_Figd_HTML.gif | 过程 | 简单的流程语句:赋值、值的改变等等。 |
| img/463938_2_En_7_Fige_HTML.gif | 决定 | 显示了决定特定执行路径的条件操作。 |
| img/463938_2_En_7_Figf_HTML.gif | 预定义流程 | 此元素表示在别处定义的流程。 |
| img/463938_2_En_7_Figg_HTML.gif | 页面连接器 | 该元素通常带有标签,表示同一页面上的流的延续。 |
| img/463938_2_En_7_Figh_HTML.gif | 离页连接器 | 该元素通常带有标签,表示流在不同页面上的延续。 |
| img/463938_2_En_7_Figi_HTML.gif | 注释(或注解) | 当一个流或一个元素需要额外的解释时,就使用这种类型的元素来引入它。 |

上表中的流程图元素非常标准;您可能会在任何编程课程或教程中发现非常相似的元素。经过这种一致的介绍,才适合进入其中。

if-else声明

Java 中最简单的决策流语句是if-else语句(可能在其他语言中也是如此)。你可能已经在前面章节的代码示例中看到过if-else语句;这是无法避免的,因为提供可运行的代码来鼓励您编写自己的代码是非常重要的。在本节中,重点将严格放在这种类型的陈述上。

让我们想象这样一个场景:我们用用户提供的数字参数运行一个 Java 程序。如果数字是偶数,我们在控制台中打印偶数;否则,我们打印奇数。图 7-2 描述了与该场景相匹配的流程图。

img/463938_2_En_7_Fig2_HTML.jpg

图 7-2

if-else流程图示例

该条件被评估为一个boolean值:如果结果为true,则执行对应于if分支的语句,如果结果为false,则执行对应于 else 分支的语句。

清单 7-2 中描述了实现该流程图所描述的过程的 Java 代码。

package com.apress.bgn.seven;

public class IfFlowDemo {
    public static void main(String... args) {
        int a = Integer.parseInt(args[0]);

        if (a % 2 == 0) { // is even
            //Display EVEN
            System.out.println("EVEN");
        } else {
            //Display ODD
            System.out.println("ODD");
        }
    }
}

Listing 7-2Java Code with if-else Statement

要用不同的参数运行这个类,你必须创建一个 IntelliJ 启动器,并将你的参数添加到Program arguments文本字段,就像本书开头解释的那样。前面代码片段中的每个 Java 语句都配有一个与流程图元素匹配的注释,以使实现显而易见。有趣的是,并不是一条if语句的两个分支都是强制的,else分支并不总是必要的。

有时,如果一个值正好匹配某个条件,您只想打印一些内容,而对其他情况不感兴趣。例如,给定一个用户提供的参数,如果数字是负数,我们只想打印一条消息,但是如果数字是正数,我们对打印或做任何事情都不感兴趣。其流程图如图 7-3 所示。

img/463938_2_En_7_Fig3_HTML.jpg

图 7-3

if流程图示例,缺少 else 分支

清单 7-3 中描述了 Java 代码。

package com.apress.bgn.seven;

public class IfFlowDemo {
    public static void main(String... args) {
        int a = Integer.parseInt(args[0]);

        if (a < 0) {
            System.out.println("Negative");
        }
    }
}

Listing 7-3Java Code with if Statements

同样的,语句也可以变得简单,同样的,如果我们需要,我们可以将更多的if-else语句链接在一起。让我们考虑下面的例子:用户插入一个从 1 到 12 的数字,我们必须打印该数字对应的月份的季节。流程图会是什么样子?你认为图 7-4 符合这个场景吗?

img/463938_2_En_7_Fig4_HTML.png

图 7-4

复杂if-else流程图示例

img/463938_2_En_7_Figj_HTML.jpg此外,当if或else的代码块包含一条语句时,花括号不是强制性的,但大多数开发人员保留它们是为了代码清晰,并帮助 ide 正确缩进代码。

看起来很复杂,对吧?等待直到您看到代码,如清单 7-4 所示。

package com.apress.bgn.seven;

public class SeasonDemo {

    public static void main(String... args) {
        int a = Integer.parseInt(args[0]);

        if(a == 12 || (a>=1 && a<= 2)) {
            System.out.println("Winter");
        } else {
            if (a>2 && a <= 5 ) {
                System.out.println("Spring");
            } else {
                if (a>5 && a <= 8 ) {
                    System.out.println("Summer");
                } else {
                    if (a>8 && a <= 11 ) {
                        System.out.println("Autumn");
                    } else {
                        System.out.println("Error");
                    }
                }
            }
        }
    }
}

Listing 7-4Java Code with a Lot of if-else Statements

看起来很丑吧?幸运的是,Java 提供了一种简化它的方法,特别是因为拥有这么多只包含另一个if语句的else块实在没有意义。简化的代码将else语句与包含的if(s)语句连接起来。代码最终看起来如清单 7-5 所示。

package com.apress.bgn.seven;

public class CompactedSeasonDemo {
    public static void main(String... args) {
        int a = Integer.parseInt(args[0]);

        if (a == 12 || (a >= 1 && a <= 2)) {
            System.out.println("Winter");
        } else if (a > 2 && a <= 5) {
            System.out.println("Spring");
        } else if (a > 5 && a <= 8) {
            System.out.println("Summer");
        } else if (a > 8 && a <= 11) {
            System.out.println("Autumn");
        } else {
            System.out.println("Error");
        }
    }
}

Listing 7-5Java Code with Compacted if-else Statements

用户提供的不是[1,12]的任何参数都会导致程序打印错误。您可以通过修改 IntelliJ Idea 启动器来亲自测试它。图 7-5 中强调了需要关注的要素。

img/463938_2_En_7_Fig5_HTML.jpg

图 7-5

IntelliJ IDEA 启动器和参数

switch声明

当一个值需要对一组固定的值进行不同的操作时,if可能会变得更复杂,这组值增加得越多。在这种情况下,更合适的语句是switch语句。让我们先看看清单 7-6 中的代码,然后看看还有哪些可以改进的地方。

package com.apress.bgn.seven.switchst;

public class SeasonSwitchDemo {
    public static void main(String... args) {
        int a = Integer.parseInt(args[0]);

        var season = "";
        switch (a) {
            case 1:
                season = "Winter";
                break;
            case 2:
                season = "Winter";
                break;
            case 3:
                season = "Spring";
                break;
            case 4:
                season = "Spring";
                break;
            case 5:
                season = "Spring";
                break;
            case 6:
                season = "Summer";
                break;
            case 7:
                season = "Summer";
                break;
            case 8:
                season = "Summer";
                break;
            case 9:
                season = "Autumn";
                break;
            case 10:
                season = "Autumn";
                break;
            case 11:
                season = "Autumn";
                break;
            case 12:
                season = "winter";
                break;
            default:
                System.out.println("Error");
        }
        System.out.println(season);
    }
}

Listing 7-6Java Code with Detailed switch Statement

这看起来不太实际,至少对于这个场景来说是这样。在展示如何以不同的方式编写switch语句之前,让我们先解释一下它的结构和逻辑。清单 7-7 中描述了switch语句的通用模板:

switch ([onvar]) {
    case [option]:
        [statement;]
        break;
    ...
    default:
        [statement;]
}

Listing 7-7General Template of the switch Statement

方括号中的术语在下面的列表中有详细说明:

  • [onvar]是根据 case 语句测试以选择语句的变量。它可以是任何原始类型、枚举,从 Java 7、String开始。显然,switch 语句不受评估为布尔结果的条件的限制,这允许很大的灵活性。

  • case [option]是前面提到的变量的一个值,根据它来决定要执行的语句。一个案例,如关键词所述。

  • [statement]是在[onvar] == [option]时执行的一条或一组语句。考虑到没有else分支,我们必须确保只执行与第一个匹配相对应的语句,这就是break;语句的用武之地。break语句停止当前的执行路径,并将执行点移到包含它的语句之外的下一条语句。没有break;语句,行为切换到fall through,这意味着匹配后的每个case语句都被执行,直到找到break;。我们将在这一章的后面详细介绍它。如果没有它,在第一次匹配后,将遍历所有后续事例,并执行与之对应的语句。

  • 如果我们执行前面的程序并提供数字 7 作为参数,文本 Summer 将被打印出来。但是,如果对案例 7 和 8 的 break 语句进行注释,输出将变为秋季。

  • default [statement;]是当在case上没有找到匹配时执行的语句;default案不需要break声明。如果前一个程序以[1-12]间隔之外的任何数字运行,将打印错误,因为将执行默认语句。

既然你已经理解了switch是如何工作的,那么让我们来看看如何减少前面的语句。月份的例子在这里是合适的,因为它可以进一步修改,以显示如何简化 switch 语句,当一个语句应该执行多种情况时。在我们的代码中,每个赋值语句写三次有点多余。还有很多break;的说法。有两种方法可以改进前面的switch陈述。

简化清单 7-6 中 switch 语句的第一种方法是将返回值相同的情况组合在一起,如清单 7-8 所示。

package com.apress.bgn.seven.switchst;

public class SimplifiedSwitchDemo {
    public static void main(String... args) {
        int a = Integer.parseInt(args[0]);

        var season = "";
        switch (a) {
            case 1:
            case 2:
            case 12:
                season = "winter";
                break;
            case 3:
            case 4:
            case 5:
                season = "Spring";
                break;
            case 6:
            case 7:
            case 8:
                season = "Summer";
                break;
            case 9:
            case 10:
            case 11:
                season = "Autumn";
                break;
            default:
                System.out.println("Error");
        }
        System.out.println(season);
    }
}

Listing 7-8Simplified switch Statement

这种情况下的分组表示需要执行相同语句的情况的对齐。这看起来仍然有点奇怪,但是它减少了语句的重复。前一种情况下的行为是可能的,因为每个没有break语句的case后面都跟着下一个case语句。

第二种方法是使用 Java 12 中引入的一个switch表达式。switch直接返回季节,而不是将它存储在变量中,这样可以简化语法,如清单 7-9 所示。

package com.apress.bgn.seven.switchst;

public class ExpessionSwitchDemo {
    public static void main(String... args) {
        int a = Integer.parseInt(args[0]);

        String season =  switch (a) {
            case 1 -> "Winter";
            case 2 -> "Winter";
            case 3 -> "Spring";
            case 4 -> "Spring";
            case 5 -> "Spring";
            case 6 -> "Summer";
            case 7 -> "Summer";
            case 8 -> "Summer";
            case 9 -> "Autumn";
            case 10 -> "Autumn";
            case 11 -> "Autumn";
            case 12 -> "winter";
            default -> "Error";
        };
        System.out.println(season);
    }
}

Listing 7-9switch Expression Example

switch表达式的引入是为了将 switch 语句视为一个表达式,对其求值并在语句中使用。switch表达式不需要break;语句来防止失败。当在与一个case值匹配后执行代码块时,使用 Java 13 中引入的yield语句返回该值。

清单 7-10 中的代码显示了之前switch表达式的不同版本,其中需要相同结果的case值被分组,并添加了额外的System.out.println(..)以显示yield的用法。返回值由System.out.println(..)括起开关表达式。

package com.apress.bgn.seven.switchst;

public class AnotherSwitchExpressionDemo {
    public static void main(String... args) {
        int a = Integer.parseInt(args[0]);

        System.out.println( switch (a) {
            case 1, 2, 12 -> {
                System.out.println("One of 1,2,12 is tested.");
                yield "Winter";
            }
            case 3,4,5 -> {
                System.out.println("One of 3,4,5 is tested.");
                yield "Spring";
            }
            case 6,7,8 -> {
                System.out.println("One of 6,7,8 is tested.");
                yield "Summer";
            }
            case 9,10,11 -> {
                System.out.println("One of 9,10,11 is tested.");
                yield "Autumn";
            }
            default ->
                throw new IllegalStateException("Unexpected value");
        });
    }
}

Listing 7-10switch Expression Example Using yield Statements

在 Java 7 中,switch语句开始支持String值。支持String值的switch的主要问题是,总是有可能出现意外的行为,因为equals(..)方法用于查找匹配,显然,该方法是区分大小写的。前面的示例被修改为要求用户输入表示月份的文本。switch语句用于决定打印的季节,除非case选项中的文本与用户输入的文本完全匹配,否则打印的文本为错误。此外,由于提到了switch表达式,代码变为清单 7-11 中的代码。

package com.apress.bgn.seven.switchst;

public class StringSwitchSeasonDemo {
    public static void main(String... args) {
        //Read a
        String a = args[0];
        var season = "";
        switch (a) {
            case "january", "february", "december" -> season = "winter";
            case "march", "april", "may" -> season = "Spring";
            case "june", "july", "august" -> season = "Summer";
            case "september", "october", "november" -> season = "Autumn";
            default -> System.out.println("Error");
        }
        System.out.println(season);
    }
}

Listing 7-11switch Statement Using String Values

如果我们用参数一月运行前面的程序,winter 将被打印在控制台中。如果我们用一月或null,错误将被打印在控制台上。

在支持String值之前,switch语句也支持枚举值。当值被分组到一个固定的集合中时,例如一年中月份的名称,这是很实用的。通过使用枚举,可以实现对String值的支持。用户以文本值的形式输入月份。该值被转换为大写,并用于提取相应的枚举值。这允许在switch语句中支持不区分大小写的String值。清单 7-12 中的代码展示了这样一个实现。

package com.apress.bgn.seven.switchst;

public class EnumSwitchDemo {

    enum Month {
        JANUARY, FEBRUARY, MARCH, APRIL, MAY, JUNE, JULY, AUGUST,
        SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER
    }

    public static void main(String... args) {
        //Read a
        String a = args[0];
        try {
            Month month = Month.valueOf(a.toUpperCase());
            var season = "";
            switch (month) {
                case JANUARY:
                case FEBRUARY:
                case DECEMBER:
                    season = "Winter";
                    break;
                case MARCH:
                case APRIL:
                case MAY:
                    season = "Spring";
                    break;
                case JUNE:
                case JULY:
                case AUGUST:
                    season = "Summer";
                    break;
                case SEPTEMBER:
                case OCTOBER:
                case NOVEMBER:
                    season = "Autumn";
                    break;
            }
            System.out.println(season);
        } catch(IllegalArgumentException iae) {
            System.out.println("Unrecognized enum value: " + a);
        }
    }
}

Listing 7-12switch Statement Using Enums Values

注意如何使用 enums,返回相同的季节为一月、一月、一月等等。此外,不需要default选项,因为如果找不到与用户提供的数据匹配的枚举值,就会抛出异常。

这就是关于 switch 语句的全部内容。在实践中,根据您试图开发的解决方案,您可能会决定结合使用if和switch语句。不幸的是,由于其特殊的逻辑和灵活的选项数量,很难为switch语句绘制流程图,但尽管如此,我还是尝试了,如图 7-6 所示。

img/463938_2_En_7_Fig6_HTML.jpg

图 7-6

switch语句流程图

循环语句

有时在编程中,我们需要涉及相同变量的重复步骤。为了完成工作而一遍又一遍地写同样的语句是荒谬的。让我们以对整数值数组进行排序为例。实现这一点的最著名的算法,也是编程课程中首先教授的算法,因为它很简单,被称为冒泡排序。该算法两个两个地比较数组的元素,如果它们的顺序不正确,它就交换它们。它会一次又一次地遍历数组,直到不再需要交换为止。该算法的效果如图 7-7 所示。

img/463938_2_En_7_Fig7_HTML.jpg

图 7-7

冒泡排序阶段和效果

该算法执行两种类型的循环:一种是使用索引迭代数组的每个元素。重复这种遍历,直到不需要交换为止。在 Java 中,这个算法可以用不同的循环语句以多种方式编写。但是我们会到达那里;让我们慢慢来。

Java 中有三种类型的循环语句:

  • for声明

  • while声明

  • do-while声明

for循环语句是最常用的,但是while和do-while也有它们的用途。

for声明

对于可计数的数组和集合等对象,建议使用 For 进行迭代。例如,遍历一个数组并打印它的每个值就像清单 7-13 中描述的那样简单。

package com.apress.bgn.seven.forloop;

public class ForLoopDemo {
    public static void main(String... args) {
        int arr[] = {5, 1, 4, 2, 3};
        for (int i = 0; i < arr.length; ++i) {
            System.out.println("arr[" + i + "] = " + arr[i]);
        }
    }
}

Listing 7-13Simple for Loop

基于前面的例子,可以画出for语句的流程图,如图 7-8 所示。

img/463938_2_En_7_Fig8_HTML.png

图 7-8

for语句流程图

清单 7-14 中的代码片段描述了for循环模板:

for ([int_expr]; [condition];[step]){
    [code_block]
}

Listing 7-14The for Loop Template

方括号中的每个术语都有特定的用途,下面的列表对此进行了解释:

  • [init_expr]是初始化表达式,用于设置该循环使用的计数器的初始值。它以;结束,并且不是强制性的,因为声明初始化可以在语句之外完成,特别是如果我们想在代码的后面和语句之外使用 counter 变量。前面的代码可以写得很好,如清单 7-15 所示:

  • [condition]是循环的终止条件;只要这个条件被评估为真,循环将继续执行。条件以;结束,有趣的是它也不是强制性的,因为终止条件可以放在循环重复执行的代码中。因此,前面的代码可以进一步修改,如清单 7-16 所示:

package com.apress.bgn.seven.forloop;

public class AnotherForLoopDemo {
    public static void main(String... args) {
        int arr[] = {5, 1, 4, 2, 3};
        int i = 0;
        for (; i < arr.length; ++i) {
            System.out.println("arr[" + i + "] = " + arr[i]);
        }
        System.out.println("Loop exited with index: " + i);
    }
}

Listing 7-15The for Loop with Termination Condition and Counter Modification Expression

  • [step]是步长表达式或增量,这是在循环的每一步增加计数器的表达式。作为最后一个学期,它没有结束;。正如您可能已经预料到的,它也不是强制性的,因为没有什么可以阻止开发人员操作代码块内部的计数器。所以前面的代码也可以写成清单 7-17 :
package com.apress.bgn.seven.forloop;

public class AndAnotherForLoopDemo {
    public static void main(String... args) {
        int arr[] = {5, 1, 4, 2, 3};
        int i = 0;
        for (; ; ++i) {
            if (i >= arr.length) {
                break;
            }
            System.out.println("arr[" + i + "] = " + arr[i]);
        }
        System.out.println("Loop exited with index: " + i);
    }
}

Listing 7-16The for Loop with Only Counter Modification Statement

package com.apress.bgn.seven.forloop;

public class YeyAnotherForLoopDemo {
    public static void main(String... args) {
        int arr[] = {5, 1, 4, 2, 3};
        int i = 0;
        for (; ;) {
            if (i >= arr.length) {
                break;
            }
            System.out.println("arr[" + i + "] = " + arr[i]);
            ++i;
        }
        System.out.println("Loop exited with index: " + i);
    }
}

Listing 7-17The for Loop with No Initialization, Condition, or Counter Modification Expression

计数器的修改甚至不必在步骤表达式内部完成;这可以在终止条件下完成。必须相应地修改初始化表达式和终止条件,以便仍然符合目的。清单 7-18 中描述的代码与之前的所有示例具有相同的效果。

package com.apress.bgn.seven.forloop;

public class LastForLoopDemo {
    public static void main(String... args) {
        int arr[] = {5, 1, 4, 2, 3};
        int i;
        for (i = -1; i++ < arr.length -1;) {
            System.out.println("arr[" + i + "] = " + arr[i]);
        }
        System.out.println("Loop exited with index: " + i);
    }
}

Listing 7-18The for Loop with Counter Modification in Termination Condition

你也应该知道步进表达式并不一定是递增的。它可以是修改计数器值的任何表达式。如果数组或集合从一个较大的索引开始向一个较低的索引遍历,可以使用i= i+1或i=i+3,甚至递减,而不是++i或i++。任何保持计数器在类型边界和集合边界内的数学运算都可以安全使用。

  • [code_block]是在循环的每一步重复执行的代码块。如果这段代码中没有退出条件,那么只要计数器通过终止条件,就会执行这段代码。

    当代码块包含一个语句时,花括号不是强制性的,但是大多数开发人员保留它们是为了代码清晰,并帮助 ide 正确缩进代码。

    img/463938_2_En_7_Figl_HTML.jpg由于提到初始化表达式、终止条件和迭代表达式是可选的,这意味着下面是有效的for语句:

    for ( ; ; ) {

    \\ statement(s) here

    }

    像这样使用 for 语句时要小心。代码块必须包含终止条件,以避免无限循环。

这是 for 循环语句的基本形式,但是在 Java 中还有其他方法来迭代一组值。比方说,我们必须遍历一个列表,而不是数组,如清单 7-19 所示。

package com.apress.bgn.seven.forloop;

import java.util.List;

public class ListLoopDemo {
    public static void main(String... args) {
        List<Integer> list = List.of(5, 1, 4, 2, 3);
        for (int j = 0; j < list.size(); ++j) {
            System.out.println("list[" + j + "] = " + list.get(j));
        }
    }
}

Listing 7-19The for Loop Over a List

代码看起来有些不切实际,这就是为什么可以用另一种类型的 for 语句遍历List<E>实例,这种语句在 Java 8 之前被称为forEach。你马上就会明白为什么,但首先让我们看看清单 7-20 中的forEach在起作用。

package com.apress.bgn.seven.forloop;

import java.util.List;

public class ForEachLoopDemo {
    public static void main(String... args) {
        List<Integer> list = List.of(5, 1, 4, 2, 3);
        for (Integer item : list) {
            System.out.println(item);
        }
    }
}

Listing 7-20The forEach Loop Over a List<E>

这种类型的for语句也被称为具有增强的语法,并为其表达式中使用的集合中的每个项目执行代码块。这意味着它可以在Collection<E>接口的任何实现上工作,也可以在数组上工作。因此,到目前为止作为示例给出的代码也可以如清单 7-21 所示编写。

package com.apress.bgn.seven.forloop;

import java.util.List;

public class ForLoopDemo {
    public static void main(String... args) {
        int arr[] = {5, 1, 4, 2, 3};
        for (int item : arr) {
            System.out.println(item);
        }
    }
}

Listing 7-21The forEach Loop

Over an Array

显然,这种情况下最好的部分是我们不再需要终止条件或计数器。从 Java 8 开始,名称forEach不能再用于具有增强语法的 for 语句,因为默认方法forEach被添加到所有的Collection<E>实现中。结合 lambda 表达式,打印列表元素的代码就变成了清单 7-22 中的代码。

package com.apress.bgn.seven.forloop;

import java.util.List;

public class ForLoopDemo {
    public static void main(String... args) {
        List<Integer> list = List.of(5, 1, 4, 2, 3);
        list.forEach(item -> System.out.println(item));
        //or
        list.forEach(System.out::println);
    }
}

Listing 7-22The forEach Method Used to Loop Over a List<E>

很漂亮,对吧?但是等等,还有更多:它也适用于数组,但是首先需要将它转换成合适的java.util.stream.BaseStream实现。这是由Arrays实用程序类提供的,它在 Java 8 中用支持 lambda 表达式的方法进行了丰富。所以是的,到目前为止编写的带有arr数组的代码可以从 Java 8 开始编写,如清单 7-23 所示。

package com.apress.bgn.seven.forloop;

import java.util.List;

public class ForLoopDemo {
    public static void main(String... args) {
        int arr[] = {5, 1, 4, 2, 3};
        Arrays.stream(arr).forEach(System.out::println);
    }
}

Listing 7-23The forEach Method Used to Loop Over an Array

在 Java 17 中,前面所有的例子都可以很好地编译和执行,所以在编写解决方案时,可以使用您最喜欢的语法。

while声明

while语句不同于for语句。不存在必须执行的固定数量的步骤,因此并不总是需要计数器。一个while语句执行的重复次数只取决于控制这个次数的延续条件被评估为真的次数。清单 7-24 中描述了该声明的通用模板。

while ([eval(condition)] == true) {
    [code_block]
}

Listing 7-24The while Statement Template

一个while语句实际上也不需要初始化语句,但是如果需要的话,它可以在while代码块内部或者外部。while语句可以代替 for 语句,但是for语句的优点是它将初始化、终止条件和计数器的修改封装在一个块中,因此更加简洁。可以使用while语句重写数组遍历代码示例。代码如清单 7-25 所示:

package com.apress.bgn.seven.whileloop;

public class WhileLoopDemo {
    public static void main(String... args) {
        int arr[] = {5, 1, 4, 2, 3};
        int i = 0;
        while(i < arr.length) {
            System.out.println("arr[" + i + "] = " + arr[i]);
            ++i;
        }
    }
}

Listing 7-25The while Statement Used to Loop Over an Array

如您所见,计数器变量int i = 0;的声明和初始化是在while代码块之外完成的。计数器的递增是在要重复的代码块内完成的。此时,如果我们为这个场景设计流程图,它将与图 7-8 中描述的for语句看起来一样。虽然听起来不可思议,但是[condition]也不是强制的,因为它可以直接用true替换,但是在这种情况下,您必须确保在一定会执行的代码块中有一个退出条件,否则执行很可能会以错误结束,因为 JVM 不允许无限循环。这个条件必须放在代码块的开头,以防止有用的逻辑在不应该执行的情况下执行。对于我们这个简单的例子,很明显我们不希望为一个索引在数组范围之外的元素调用System.out.println,如清单 7-26 所示。

package com.apress.bgn.seven.whileloop;

public class AnotherLoopDemo {
    public static void main(String... args) {
        int arr[] = {5, 1, 4, 2, 3};
        int i=0;
        while(true){
            if (i >= arr.length) {
                break;
            }
            System.out.println("arr[" + i + "] = " + arr[i]);
            ++i;
        }
    }
}

Listing 7-26The while Statement Used to Loop Over an Array, Without a Continuation Expression

当我们使用不总是在线的资源时,最好使用while语句。假设我们在一个不稳定的网络中为我们的应用使用一个远程数据库。第一次超时后,我们可以尝试直到成功,而不是放弃保存数据,对吗?这是通过使用一个while语句来完成的,该语句将不断尝试在其代码块中初始化一个连接对象。代码大致如清单 7-27 所示。

package com.apress.bgn.seven.whileloop;

import java.sql.*;

public class WhileConnectionTester {
    public static void main(String... args) throws Exception {
        Connection con = null;
        while (con == null) {
            try {
                Class.forName("com.mysql.cj.jdbc.Driver");
                con = DriverManager.getConnection(
                        "jdbc:mysql://localhost:3306/mysql",
                        "root", "mypass");
            } catch (Exception e) {
                System.out.println("Connection refused. Retrying in 5 seconds ...");
                Thread.sleep(5000);
            }
        }
        // con != null, do something
        Statement stmt = con.createStatement();
        ResultSet rs = stmt.executeQuery("select * from user");
        while (rs.next()) {
            System.out.println(rs.getString(1) + " " + rs.getString(2));
        }
        con.close();
    }
}

Listing 7-27The while Statement Used to Repeatedly Try to Obtain a Database Connection

这段代码的问题是它将永远运行。如果我们想在一段时间后放弃尝试,我们必须引入一个变量来计算尝试次数,并使用一个break;语句退出循环,如清单 7-28 所示。

package com.apress.bgn.seven.whileloop;

import java.sql.*;

public class AnotherWhileConnectionTester {
    public static final int MAX_TRIES = 10;

    public static void main(String... args) throws Exception {
        int cntTries = 0;
        Connection con = null;
        while (con == null && cntTries < MAX_TRIES) {
            try {
                Class.forName("com.mysql.cj.jdbc.Driver");
                con = DriverManager.getConnection(
                        "jdbc:mysql://localhost:3306/mysql",
                        "root", "mypass");
            } catch (Exception e) {
                ++cntTries;
                System.out.println("Connection refused. Retrying in 5 seconds ...");
                Thread.sleep(5000);
            }
        }
        if (con != null) {
            // con != null, do something
            Statement stmt = con.createStatement();
            ResultSet rs = stmt.executeQuery("select * from user");
            while (rs.next()) {
                System.out.println(rs.getString(1) + " " + rs.getString(2));
            }
            con.close();
        } else {
            System.out.println("Could not connect!");
        }
    }
}

Listing 7-28The while Statement Used to Repeatedly Try to Obtain a Database Connection Until the Number of Tries Expires

根据经验,在使用循环语句时,一定要确保存在退出条件。

既然我们现在已经涵盖了实现图 7-7 中描述的Bubble sort算法所需的所有语句,让我们看看代码是什么样子的。请注意,该算法可以用多种方式编写,但下面的代码最符合前面提供的解释。因此,当数组中的元素顺序不正确时,数组会被一次又一次地遍历,相邻的元素会被交换以符合所需的顺序,在本例中是升序。清单 7-29 中描述了最简单的Bubble sort算法。

package com.apress.bgn.seven;

import java.util.Arrays;

public class BubbleSortDemo {
    public static final int arr[] = {5, 1, 4, 2, 3};

    public static void main(String... args) {
        boolean swapped = true;
        while (swapped) {
            swapped = false;
            for (int i = 0; i < arr.length - 1; ++i) {
                if (arr[i] > arr[i + 1]) {
                    int temp = arr[i];
                    arr[i] = arr[i + 1];
                    arr[i + 1] = temp;
                    swapped = true;
                }
            }
        }
        Arrays.stream(arr).forEach(System.out::println);
    }
}

Listing 7-29The Simplest Version of the Bubble sort Algorithm

运行时,前面的代码交换了arr数组的元素,直到它们都按升序排列,因此前面代码的最后一行打印了修改后的arr:

1
2
3
4
5

do-while声明

do-while语句类似于while语句,有一点不同:在执行代码块后,继续条件被求值。这会导致代码块至少执行一次,这对于显示菜单很有用,例如,除非其中嵌入了阻止执行的条件。清单 7-30 中描述了该声明的通用模板。

do {
    [code_block]
} while ([eval(condition)] == true)

Listing 7-30The do-while Statement Template

大多数情况下,语句while和do-while可以很容易地互换,并且代码块的逻辑变化很小或没有变化。例如,遍历一个数组并打印其元素的值也可以使用do-while来编写,根本不需要改变代码块。在图 7-9 中,你可以看到两个并行的实现,while 在左边,do-while 在右边。

img/463938_2_En_7_Fig9_HTML.jpg

图 7-9

用于打印数组元素的 while 和 do-while 实现

然而,这两个例子的流程图非常不同,并且揭示了两个语句的不同逻辑。您可以通过查看图 7-10 来比较它们。

img/463938_2_En_7_Fig10_HTML.png

图 7-10

while 和 do-while 语句流程图的比较

在图 7-9 的例子中,如果数组为空,do-while语句导致抛出ArrayIndexOutOfBoundsException异常,因为代码块的内容被执行,即使它们不应该被执行,因为索引值等于数组长度(零),但是没有索引等于 0 的元素,因为数组为空。但是,因为条件是在代码块之后计算的,所以没有办法知道。在图 7-11 中,你可以看到前面的代码样本被修改为用一个空数组运行,并且它们的输出是并排的。

img/463938_2_En_7_Fig11_HTML.jpg

图 7-11

用于打印空数组元素的 while 和 do-while 实现

为了使do-while实现具有与while实现相同的行为,代码块的执行必须受到至少有一个元素的数组的限制。清单 7-31 展示了一种方法。

package com.apress.bgn.seven.whileloop;

public class DoWhileLoopDemo {
    public static void main(String... args) {
        int arr[] = new int[0];
        int i = 0;
        do {
            if(arr.length >=1) {
                System.out.println("arr[" + i + "] = " + arr[i]);
                ++i;
            }
        } while (i < arr.length);
    }
}

Listing 7-31do-while Statement Implementation That Works Correctly for an Empty Array Too

img/463938_2_En_7_Fign_HTML.jpg当代码块必须至少执行一次时,do-while语句工作得最好,否则我们不必要地评估一次条件。

前面介绍的冒泡排序算法就是一个很好的例子,其中while和do-while语句可以互换使用,不需要额外的代码修改。

既然已经提到有不止一种方法来编写这个算法,清单 7-32 显示了一个改进的版本,它不仅使用了do-while,而且减少了每次遍历的数组的大小。这是可能的,因为根据图 7-7 ,在每次遍历之后,数组的最后一个索引保存了被遍历子集的最大数量。

package com.apress.bgn.seven;

import java.util.Arrays;

public class BubbleSortDemo {
    public static final int arr[] = {5, 1, 4, 2, 3};

    public static void main(String... args) {
       boolean swapped = true;
        do {
            swapped = false;
            for (int i = 0, n = arr.length -1; i < n - 1; ++i, --n) {
                if (arr[i] > arr[i + 1]) {
                    int temp = arr[i];
                    arr[i] = arr[i + 1];
                    arr[i + 1] = temp;
                    swapped = true;
                }
            }
        } while (swapped);
        Arrays.stream(arr).forEach(System.out::println);
    }
}

Listing 7-32Optimized Version of the Bubble Sort Algorithm Using do-while Statement

img/463938_2_En_7_Figo_HTML.jpgfor语句中的初始化和步骤表达式允许用“,”分隔多个术语。所以下面的代码是有效的,并且运行良好。

for (int j = 0, k =2; j < 10; ++j, ++k) {

System.out.println("composed indexes: [" + j + ", " + k + "]");

}

还记得试图连接到不稳定网络中的数据库的代码示例吗(清单 7-27 )?当使用while时,执行开始于测试连接是否不为空,但是连接甚至还没有用有效值初始化。进行那个测试是不合逻辑的,对吧?参见清单 7-33 中所示的片段。

Connection con = null;
while (con == null) {
    try {
        Class.forName("com.mysql.cj.jdbc.Driver");
        con = DriverManager.getConnection(
                "jdbc:mysql://localhost:3306/mysql", "root", "mypass");
    // some code omitted

Listing 7-33while Implementation to Check Connection to a Database

这种实现虽然很实用,但有点多余,而且逻辑并没有真正遵循最佳编程实践。一个do-while实现是最合适的,因为它避免了初始测试,如果con实例是null,当没有其他方法时。清单 7-34 中描述了编写代码的一种变体。

package com.apress.bgn.seven.whileloop;

import java.sql.*;

public class DoWhileConnectionTester {
    public static final int MAX_TRIES = 10;

    public static void main(String... args) throws Exception {
        int cntTries = 0;
        Connection con = null;
        do {
            try {
                Class.forName("com.mysql.cj.jdbc.Driver");
                con = DriverManager.getConnection(
                        "jdbc:mysql://localhost:3306/mysql",
                        "root", "mypass");
            } catch (Exception e) {
                ++cntTries;
                System.out.println("Connection refused. Retrying in 5 seconds ...");
                Thread.sleep(5000);
            }
        } while (con == null && cntTries < MAX_TRIES);

        if (con != null) {
            Statement stmt = con.createStatement();
            ResultSet rs = stmt.executeQuery("select * from user");
            while (rs.next()) {
                System.out.println(rs.getString(1) + " " + rs.getString(2));
            }
            con.close();
        } else {
            System.out.println("Could not connect!");
        }
    }
}

Listing 7-34do-while Implementation to Check Connection to a Database

当然,跳过一次条件的评估并不是一个大的优化,但是在一个大的应用中,每一个小的优化都很重要。

打破循环和跳过步骤

在前面的例子中,我们提到了使用break;语句退出循环,并承诺返回并添加更多细节。有三种方法可以操纵循环的行为:

  • break语句退出循环,如果带有标签,将会中断带有标签的循环;当我们有更多的嵌套循环时,这很有用,因为我们可以从任何嵌套循环中断开,而不仅仅是包含语句的循环。

*** continue语句跳过其后任何代码的执行,继续下一步。

*** return语句用于退出一个方法,所以如果循环或者 if 或者switch语句在一个方法的主体内,它也可以用于退出循环。

img/463938_2_En_7_Figp_HTML.jpg至于最佳实践,不应该滥用return语句来退出方法,因为它们可能会使执行流程难以遵循。****

****### break声明

break语句只能在switch、for、while和do-while语句中使用。您已经看到了如何在switch语句中使用它,所以让我们向您展示如何在所有其他语句中使用它。使用break语句可以中断for、while或do-while循环,但必须由退出条件控制,否则不会执行任何步骤。在清单 7-35 中,我们只打印一个数组中的前三个元素,即使for循环被设计为遍历所有元素。如果我们得到的指数等于 3,我们退出循环。

package com.apress.bgn.seven.forloop;

public class BreakingForDemo {
    public static final int arr[] = {5, 1, 4, 2, 3};

    public static void main(String... args) {
        for (int i = 0; i < arr.length ; ++i) {
            if (i == 3) {
                System.out.println("Bye bye!");
                break;
            }
            System.out.println("arr[" + i + "] = " + arr[i]);
        }
    }
}

Listing 7-35Breaking Out of a for Loop

如果我们有一个嵌套循环,标签可以用来决定循环语句的中断。例如,在清单 7-36 中,我们有三个嵌套的 for 循环,当所有索引都相等时,我们退出中间的循环。

package com.apress.bgn.seven.forloop;

public class BreakingNestedForLoopDemo {
    public static final int arr[] = {5, 1, 4, 2, 3};

    public static void main(String... args) {
        for (int i = 0; i < 2; ++i) {
            HERE: for (int j = 0; j < 2; ++j) {
                for (int k = 0; k < 2; ++k) {
                    if (i == j && j == k) {
                        break HERE;
                    }
                    System.out.println("(i, j, k) = (" + i + "," + j + "," + k + ")");
                }
            }
        }
    }
}

Listing 7-36Breaking Out of a Nested for Loop

前一个代码示例中使用的标签名为HERE,它在满足条件时退出的for语句之前声明。break 语句后面是相同的标签。在开发中,使用全大写字母编写标签名称被认为是一种最佳实践,因为这样可以避免在阅读代码时将标签与变量或类名混淆。

用标签来打破循环实际上是很不可取的,因为它会导致代码跳转,并使执行流程更难跟踪。因此,如果你必须这样做,确保你的标签是可见的。

为了确保这一点,你可以看看控制台。您应该会看到(I,j,k)的一些组合(包括带有i = j = k的组合)丢失了。这里列出了输出。

(i, j, k) = (1,0,0)
(i, j, k) = (1,0,1)
(i, j, k) = (1,1,0)

continue声明

continue语句不会中断循环,但可用于根据条件跳过某些步骤。实际上,continue语句停止了循环当前步骤的执行,并移动到下一步,所以你可以说这个语句继续了循环。让我们继续试验数组遍历的例子,这一次,让我们跳过使用continue语句打印奇数索引的元素。代码如清单 7-37 所示。

package com.apress.bgn.seven.forloop;

public class ContinueForDemo {
    public static final int arr[] = {5, 1, 4, 2, 3};

    public static void main(String... args) {
        for (int i = 0; i < arr.length; ++i) {
            if (i % 2 != 0) {
                continue;
            }
            System.out.println("arr[" + i + "] = " + arr[i]);
        }
    }
}

Listing 7-37Skipping Printing Elements with Odd Indexes Using a for Loop and continue Statement

显然,这个语句必须是有条件的,否则,循环将只是无用地迭代。

continue语句也可以和标签一起使用。让我们举一个与前面使用的三个for嵌套循环类似的例子,但是这一次,当k索引等于 1 时,什么都不打印,我们跳到包含k循环的循环的下一步。代码如清单 7-38 所示。

package com.apress.bgn.seven.forloop;

public class ContinueNestedForLoopDemo {
    public static final int arr[] = {5, 1, 4, 2, 3};

    public static void main(String... args) {
        for (int i = 0; i < 3; ++i) {
        HERE: for (int j = 0; j < 3; ++j) {
                for (int k = 0; k < 3; ++k) {
                    if (k == 1) {
                        continue HERE;
                    }
                    System.out.println("(i, j, k) = (" + i + "," + j + "," + k + ")");
                }
            }
        }
    }
}

Listing 7-38Continue a Nested for Loop

为了确保这一点,您可以在控制台中查看打印了哪些组合,我们清楚地注意到没有打印带有k=1或k=2的组合。这里列出了输出。

(i, j, k) = (0,0,0)
(i, j, k) = (0,1,0)
(i, j, k) = (0,2,0)
(i, j, k) = (1,0,0)
(i, j, k) = (1,1,0)
(i, j, k) = (1,2,0)
(i, j, k) = (2,0,0)
(i, j, k) = (2,1,0)
(i, j, k) = (2,2,0)

在 Java 社区中,使用标签来打破循环是不被允许的,因为跳转到标签类似于在某些老式编程语言中可以找到的goto语句。goto是 Java 保留的关键字,因为这个语句曾经存在于 JVM 的第一个版本中,但后来被删除了。使用跳转会降低代码的可读性和可测试性,并导致糟糕的设计。这就是为什么goto在以后的版本中被删除了,但是任何需要这种操作的都可以通过break和continue语句实现。

return声明

return语句很简单:如前所述,它可以用于退出方法体的执行。如果方法返回一个值,那么return语句会伴随着返回的值。return 语句可用于退出本节提到的任何语句。它可以代表一种快捷执行方法的非常聪明的方式,因为当前方法的执行停止,处理从调用该方法的代码点继续。

我们来看几个例子。清单 7-39 中的代码展示了一个寻找数组中第一个偶数元素的方法。如果找到,该方法返回其索引;否则,它返回-1。

package com.apress.bgn.seven;

public class ReturnDemo {
    public static final int arr[] = {5, 1, 4, 2, 3};

    public static void main(String... args) {
        int foundIdx = findEvenUsingFor(arr);

        if (foundIdx != -1) {
            System.out.println("First even is at: " + foundIdx);
        }
    }

    public static int findEvenUsingFor(int ... arr) {
        for (int i = 0; i < arr.length; ++i) {
            if (arr[i] %2 == 0) {
                return i;
            }
        }
        return -1;
    }
}

Listing 7-39Finding an Even Number Using the do-while Statement

同样的方法可以用一个while语句编写,但是return语句的目的是一样的。代码如清单 7-40 所示。

// enclosing class omitted
public static int findEvenUsingWhile(int ... arr) {
    int i = 0;
    while (i < arr.length) {
        if (arr[i] % 2 == 0) {
            return i;
        }
        ++i;
    }
    return -1;
}

Listing 7-40Finding an Even Number Using the while Statement

如你所见,return语句可以用在任何情况下,当我们想要在一个条件满足时终止一个方法的执行。

使用try-catch结构控制流程

本书之前提到过异常和try-catch语句,但不是作为控制流程执行的工具。在我们跳到解释和例子之前,让我们先讨论一下try-catch-finally语句的通用模板。该模板如清单 7-41 所示。

try {
    [code_block]
} catch ([exception_block]} {
    [handling_code_block]
} finally {
    [cleanup_code_block]
}

Listing 7-41try-catch-finally Statement Template

下面的列表解释了该模板的组件:

  • [code_block]是要执行的代码块。

  • [exception_block]是一个或多个异常类型的声明,可以由[code_block]抛出。

  • 被抛出的异常标志着必须处理的意外情况;一旦捕获到异常,就执行这段代码来处理它,要么尝试将系统恢复到正常状态,要么记录关于异常原因的详细信息。

  • [clean_up_code]用于释放资源或将对象设置为空,使其有资格被收集。如果存在,无论是否抛出异常,都会执行该代码块。

现在您已经知道了一个try-catch-finally是如何工作的,您大概可以想象如何使用它来控制执行流。在[code_block]中,您可以显式抛出异常,并决定如何处理它们。

考虑到我们一直使用的数组,我们将再次基于它来设计我们的代码。清单 7-42 显示了一段代码,当发现一个偶数值时,它抛出一个异常。

package com.apress.bgn.seven.ex;

public class ExceptionFlowDemo {
    public static final int arr[] = {5, 1, 4, 2, 3};

    public static void main(String... args) {
        try {
            checkNotEven(arr);
            System.out.println("Not found, all good!");
        } catch (EvenException e) {
            System.out.println(e.getMessage());
        } finally {
            System.out.println("Cleaning up arr");
            for (int i = 0; i < arr.length; ++i) {
                arr[i] = 0;
            }
        }
    }

    public static int checkNotEven(int... arr) throws EvenException {
        for (int i = 0; i < arr.length; ++i) {
            if (arr[i] % 2 == 0) {
                throw new EvenException("Did not expect an even number at " + i);
            }
        }
        return -1;
    }
}

Listing 7-42Controlling Flow Using Exceptions

EvenException类型是为这个特定示例编写的定制异常类型,它的实现在这里不相关。如果我们执行这段代码,将会打印以下内容:

Did not expect an even number at 2
Cleaning up arr

如你所见,通过抛出一个异常,我们将执行指向了处理代码,所以“没有找到,一切正常!”不打印,因为有一个finally块,它也被执行。是的,您可以混合使用:使用不同类型的异常,并且您可以拥有多个 catch 块来解决您的问题。在我之前工作的一家公司,我们有一段代码验证一个文档,并根据没有通过的验证检查抛出不同类型的异常,在finally块中,我们有一段代码将错误对象转换为 PDF。代码看起来类似于清单 7-43 中的代码。

ErrorContainter errorContainer = new ErrorContainter();
try {
    validate(report);
} catch (FileNotFoundException | NotParsable e) {
    errorContainer.addBadFileError(e);
} catch (InvestmentMaxException e) {
    errorContainer.addInvestmentError(e);
} catch (CreditIncompatibilityException e) {
    errorContainer.addIncompatibilityError(e);
} finally {
    if (errorContainer.isEmpty()) {
        printValidationPassedDocument();
    } else {
        printValidationFailedDocument(errorContainer);
    }
}

Listing 7-43Code Sample Showing a try-multi-catch Statement

finally代码块中的代码很复杂,完全不建议放在那里。然而,有时在现实世界中,解决方案并不总是尊重最佳实践,甚至是常识性的实践。当处理遗留代码时,你可能会发现自己在编写蹩脚但功能强大的代码来解决客户的问题——因为当然,编程是令人敬畏的,但在一些经理眼中,结果更重要。如果你足够幸运地在一家公司找到一份工作,这家公司希望在未来构建代码或者将它交给其他团队成员,你可能最终会遇到一个喜欢最佳实践的经理。只要记得尽力而为,把一切都记录妥当,就没问题了。

try-catch-finally格挡相当厉害。它们是一种有用的结构,用于指导执行流和打印关于应用整体状态和最终问题来源的有用信息。如果设计得当,异常处理可以提高代码的质量和可读性。在设计它们时,有一些规则要遵循:

img/463938_2_En_7_Fig12_HTML.jpg

图 7-12

IntelliJ IDEA 编译错误和显示 try-catch 块中异常类型顺序错误的消息

  • 尽量避免使用多个 catch 块,除非使用它们来区别对待不同类型的异常。

  • 使用|(pipe)符号将处理方式相同的相似类型的异常分组在一起。Java 7 中增加了对此的支持。

  • 捕捉相关类型的异常时要小心。第一个匹配异常类型的 catch 处理异常,因此超类应该在 catch 列表中处于较低的位置。如果顺序不正确,编译器甚至会很不高兴,如图 7-12 所示。

当然,您还应该遵守本书前面提到的避免异常吞咽和捕获Throwable的基本规则。

摘要

这一章涵盖了开发中最重要的事情之一:如何设计你的解决方案,以及它的逻辑。还向您介绍了什么是流程图及其组件,它们是决定如何编写代码以及如何控制执行路径的工具。最后,您已经学习了在什么时候使用哪些语句,并且提到了一些 Java 最佳实践,这样您将能够设计出最适合您的问题的解决方案。Java 提供了以下功能:

  • 编写if语句的简单和更复杂的方法。

  • 一个switch语句,适用于任何原始类型、枚举,从 Java 7、String实例开始。

  • 一个返回一个值的switch表达式,可用于编写更复杂的语句。

  • 写for语句的几种方法。

  • 如何使用forEach方法和流来遍历一组值。

  • while语句,当必须重复一个步骤直到满足一个条件时使用。

  • do-while语句,当一个步骤必须重复直到满足一个条件,并且该步骤至少重复一次时使用,因为继续条件在它之后计算。

  • 如何使用break、continue、return等语句操纵循环行为。

  • 如何使用try-catch-finally结构控制执行流程?****

八、流 API

术语“流”有一个以上的含义,如 dictionary.com 中所解释的:

  1. 在河道或水道中流动的水体,如河流、小溪或小溪

  2. 稳定的水流,如在河流或海洋中

  3. 水或其他液体或流体的任何流动

  4. 空气、气体等的气流或流动

  5. 任何事物的连续不断的流动或连续

  6. 主导方向;漂流

  7. 数字技术 数据流,作为音频广播、电影或实时视频,从一个源平稳、连续地传输到计算机、移动设备等。

当谈到编程时,与流更接近的定义是前面列表中的数字 5 和数字 7 的一部分。实际上,流是来自支持聚合操作的源的对象序列。你现在可能会说,这是一个收藏?好吧。。。不完全是。

流简介

考虑一个非常大的歌曲集合,我们想要分析并找到持续时间至少为 300 秒的所有歌曲。对于这些歌曲,我们希望将名称保存在一个列表中,并按照持续时间的降序对它们进行排序。假设我们已经有了列表中的歌曲,代码看起来像清单 8-1 :

// non-relevant code omitted

List<Song> songList = loadSongs();
List<Song> resultedSongs = new ArrayList<>();

//find all songs with duration of at least 300 seconds
for (Song song: songList) {
    if (song.getDuration() >= 300) {
        resultedSongs.add(song);
    }
}

Collections.sort(resultedSongs, new Comparator<Song>(){
    public int compare(Song s1, Song s2){
        return s2.getDuration().compareTo(s1.getDuration());
    }
});

System.out.println(resultedSongs);
List<String> finalList0 = new ArrayList<>();
for (Song song: resultedSongs) {
    finalList0.add(song.getTitle()); // only the song title is required
}
System.out.println("Before Java 8: " + finalList0);

Listing 8-1Java Code Made of a Few Statements

这段代码的一个问题是处理大型集合的效率并不高。此外,我们一遍又一遍地遍历列表,并执行检查以获得最终结果。如果我们能够在每个元素上一个接一个地执行所有这些操作,而不重复遍历,岂不是更有效率?是的,而且从 Java 8 开始就有可能做到。

Java 8 中引入的新的流抽象表示可以顺序或并行处理的元素序列,并支持聚合操作。由于硬件开发的最新进展,CPU 变得更加强大和复杂,包含多个可以并行处理信息的内核。为了利用这些硬件能力,Java 中引入了 Fork Join 框架。在 Java 8 中,引入了流 API 来支持并行数据处理,而不需要定义和同步线程的锅炉代码。

流 API 的中央接口是java.util.stream.BaseStream。任何具有流功能的对象都属于扩展它的类型。流本身不存储元素;它不是一个数据结构,只是用来计算元素,并根据需要将它们提供给一个操作或一组聚合操作。

聚合操作是流 API 中的特殊方法,具有以下特征:

  • 它们支持行为作为参数。大多数聚合操作都支持 lambda 表达式作为参数。

  • 他们使用内部迭代。内部迭代不会按顺序遍历元素,因此可以利用并行计算。内部迭代将一个问题分解成子问题,同时解决它们,然后组合结果。

  • 它们处理来自流的元素,而不是直接来自流的源头。

为序列中的元素提供服务涉及内部自动迭代。返回流的操作可以链接在一个管道中,称为中间操作。操作处理流中的元素,并将结果作为流返回给管道中的下一个操作。返回非流结果的操作被称为终端操作,通常出现在流水线的末端。在深入之前,举个简单的例子,使用 streams,清单 8-1 中的代码被写成清单 8-2 中所描述的那样。

List<String> finalList = songList.stream()
    .filter(s -> s.getDuration() >= 300)
    .sorted(Comparator.comparing(Song::getDuration).reversed())
    .map(Song::getTitle)
    .collect(Collectors.toList());
System.out.println(finalList);

Listing 8-2Code in Listing 8-1 Rewritten with Streams

是的,用流编程很棒。 Stream API 概念允许开发人员将集合转换成流,并编写代码来并行处理数据,然后将结果转换成新的集合。

使用流是一种非常敏感的编程方式,建议在设计代码时考虑到每种可能性。NullPointerException是 Java 中最常见的异常之一。

在 Java 8 中,引入了类Optional<T>来避免这种类型的异常。Stream<T>实例用于存储类型T的无限个实例,而Optional<T>实例可能包含也可能不包含类型T的实例。因为这两个实现基本上都是其他类型的包装器,所以它们将一起讨论。

img/463938_2_En_8_Figa_HTML.jpg出于实际原因,Stream实例在本章中将被称为流,类似于List实例被称为列表,集合实例被称为集合等等。

img/463938_2_En_8_Figb_HTML.jpg你可能注意到术语函数被引入,用来指代被证明为流操作的参数的行为。这是因为使用流允许以函数式编程风格编写 Java 代码。本书开头提到 Java 是一种面向对象的编程语言,对象是它的核心术语。在函数式编程中,核心术语是纯函数,通过组合纯函数来编写代码,这允许避免共享状态,利用不可变数据,从而避免处理污染的副作用。 1

纯函数是数学函数的软件模拟,具有以下特性:

  • 纯函数为相同的参数返回相同的值。该实现不涉及任何随机值或非最终的全局变量,这些变量可能会导致为相同的参数返回不同的值。纯函数必须产生一致的结果。

  • 函数的返回值只取决于传递给函数的输入参数。

  • 纯函数没有副作用。(没有局部静态变量、非局部变量、可变引用参数或输入/输出流的变异)。

流、纯函数和 lambda 表达式的组合有助于编写 Java 声明性代码。在这一章中,我们留下了典型的面向对象的命令式编码风格,其中算法的每一步都是一个接一个地声明的,流程由boolean条件控制。我们开始设计应用于流元素的纯函数链。

创建流

在享受乐趣和使用流优化我们的代码之前,让我们看看如何创建它们。要创建一个流,我们显然需要一个源。这个源可以是任何东西:一个集合(列表、集合或映射)、一个数组或用作输入的 I/O 资源(比如文件、数据库或任何可以转换成实例序列的东西)。

流不会修改其源,因此可以从同一个源创建多个流实例,并用于不同的操作。

集合和流之间的最大区别在于,流发出的元素由操作消耗,因此流不能被多次使用。这里的代码被 Java 编译器接受。

int[] arr = { 50, 10, 250, 100};
IntStream intStream = Arrays.stream(arr);

intStream.forEach(System.out::println);
intStream.forEach(System.out::println);

然而,当我们第二次尝试遍历流时,会在运行时抛出一个IllegalStateException。

Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed at java.base/java.util.stream.AbstractPipeline.
sourceStageSpliterator(AbstractPipeline.java:279) at java.base/java.util.
stream.IntPipeline$Head.forEach(IntPipeline.java:617) at chapter.eigth/com.
apress.bgn.eight.StreamRecyclingDemo.main(StreamRecyclingDemo.java:44)

如果您需要两次处理一个流的元素,您必须再次从源重新创建它。

从集合创建流

在本章的介绍中,清单 8-2 中的代码片段描述了一种从列表中创建流的方法。从 Java 8 开始,所有集合接口和类都增加了返回流的默认方法。在清单 8-3 中,我们获取了一个整数列表,并通过调用stream()方法将其转换成一个流。有了流之后,我们使用forEach(..)方法遍历它,打印流中的值,以及执行该代码的执行线程的名称。你会问,为什么是线程名?你很快就会看到。

package com.apress.bgn.eigth;

import java.util.List;

public class IntegerStreamDemo {

    public static void main(String... args) {
        List<Integer> bigList = List.of(50, 10, 250, 100 /*, ... */);

        bigList.stream()
            .forEach(i ->
                    System.out.println(Thread.currentThread().getName() + ": " + i)
        );
    }
}

Listing 8-3Creating a Stream of Integer Values from a List of Integers

上面的代码创建了一个整数元素流。Stream<T>接口公开了一组方法,每个Stream<T>实现为这些方法提供了一个具体的实现。最常用的是forEach(..)方法,它遍历流中的元素。forEach(..)方法需要一个java.util.function.Consumer<T>类型的参数。

img/463938_2_En_8_Figc_HTML.jpg一个消费者在本书中我们称之为java.util.function.Consumer<T>功能接口的内联实现。该接口声明了一个抽象方法,实现它的类必须为该方法提供一个具体的实现。出于同样的原因,这个接口用@FunctionalInterface进行了注释。该方法被命名为accept(T t),并被称为功能方法。它将 T 类型的元素作为参数,处理它,不返回任何内容。由于这个原因,消费者函数适合于功能管道的末端。

为流中的每个元素调用此使用者方法。实现类基本上是内联声明的,只需要提到方法的主体。由于 lambda 表达式的魔力,JVM 会完成剩下的工作。没有它们,您将不得不编写类似清单 8-4 中的代码。

package com.apress.bgn.eigth;

import java.util.List;
import java.util.function.Consumer;

public class IntegerStreamDemo {

    public static void main(String... args) {
        List<Integer> bigList = List.of(50, 10, 250, 100 /*, ... */);

        bigList.stream().forEach(
            new Consumer<Integer>() {
                @Override
                public void accept(Integer i) {
                    System.out.println(Thread.currentThread().getName() + ": " + i);
                }
            });
    }
}

Listing 8-4Expanded Declaration of a Consumer

在 Java 8 中引入 lambda 表达式之前,这是您编写代码的方式。当类以这种方式实现接口时,内联,使用看起来很像使用接口类型的构造函数调用的语法;它们被称为匿名类,因为它们没有名字,并且它们被准确地用在声明的地方。Lambda 表达式极大地简化了这个过程,但是只针对定义一个方法的接口,这些接口被命名为 functional interfaces 。从 Java 8 开始,这些接口用@FunctionalInterface注释进行了注释。在前面的示例中,代码打印了线程名称和元素的值。运行该代码的结果如下所示:

main: 50
main: 10
main: 250
main: 100
...

每个数字都以main为前缀,这意味着流中的所有整数都由同一个线程(应用的主线程)顺序处理。

img/463938_2_En_8_Figd_HTML.jpg出于实际原因,对于集合来说,当顺序流只需要遍历时,不需要调用stream(),因为为它们定义的forEach(..)方法已经很好地完成了这项工作。因此,前面的代码可以简化为:

bigList.forEach(i ->

System.out.println(Thread.currentThread().getName() + ": " + i)

);

打印线程的名称是因为有另一种创建流的方法:通过调用parallelStream()方法。唯一的区别是返回的流是并行流。这意味着流中的每个元素都在不同的线程上处理。这意味着Consumer<T>的实现必须是线程安全的,并且不包含涉及不打算在线程间共享的实例的代码。打印流元素值的代码不会影响流返回的元素值,也不会影响其他外部对象,因此并行化是安全的。清单 8-5 描述了使用parallelStream()而不是stream()来创建流,并使用相同的Consumer 实现来打印流的元素。输出显示在代码片段的底部。

package com.apress.bgn.eigth;

import java.util.List;
import java.util.function.Consumer;

public class IntegerStreamDemo {

    public static void main(String... args) {
        List<Integer> bigList = List.of(50, 10, 250, 100 /*, ... */);

        bigList.parallelStream()
            .forEach(i ->
                System.out.println(Thread.currentThread().getName() + ": " + i)
        );
    }
}
// output
main: 83
ForkJoinPool.commonPool-worker-1: 23
main: 33
ForkJoinPool.commonPool-worker-1: 45
ForkJoinPool.commonPool-worker-2: 50
main: 67
...

Listing 8-5Creating a Parallel Stream of Integer Values from a List of Integers

您将注意到的第一件事是线程名称:我们不再有一个线程名称,但是很多线程都被命名为forkjoinpool . common pool-worker-* *。主线程仍然打印一些值,但是其他线程也做一些工作,打印值的顺序——或者更像是无序的——表明线程是并行运行的。这些线程有相似的名字,很明显它们都属于同一个线程池。在这种情况下,JVM 创建了一个线程池来包含一些线程实例,用于并行处理流中的所有元素。使用线程池的优点是线程可以被重用,因此不需要创建新的线程实例,这稍微优化了执行时间。

img/463938_2_En_8_Fige_HTML.jpg对于更复杂的解决方案,使用parallelStream()时的性能提升变得明显。对于这个简单的例子,创建一个线程池并管理线程实际上是对 CPU 和内存的浪费。因此,除非对流中每个元素执行的操作足够复杂,可以通过并行执行来提高性能,否则应避免使用parallelStream()。

如果您查看与每个线程关联的数字,即线程名称末尾的数字,您会发现这些数字有时会重复。这基本上意味着同一线程被重用来处理另一个流元素。

从数组创建流

对于前面的代码示例,我们的流的源由一个List<T>实例表示。相同的语法也用于Set<T>实例。

但是流也可以从数组中创建。看看清单 8-6 :

package com.apress.bgn.eigth;

import java.util.Arrays;

public class ArrayStreamDemo {
    public static void main(String... args) {
        int[] arr = { 50, 10, 250, 100 /* ... */};

        Arrays.stream(arr).forEach(
                i -> System.out.println(Thread.currentThread().getName() + ": " + i)
        );
    }
}

Listing 8-6Creating a Stream of Integer Values from an Array of Integers

静态方法stream(int[] array)被添加到 Java 1.8 中的java.util.Arrays实用程序类中,并在前面的代码清单中用于创建原语流。

对于包含对象的数组,调用的方法是stream(T[] array),其中T是一个泛型类型,它替换任何引用类型(也是在 Java 1.8 中添加的)。通过调用相同的parallel()方法,从数组生成的流可以被并行化。

数组的新颖之处在于,通过指定数组块的起始和结束索引,可以从数组的一部分创建流。清单 8-7 中的代码展示了从数组的一部分创建一个流,以及使用一个简单的消费者打印结果流的元素的输出。

package com.apress.bgn.eigth;

import java.util.Arrays;

public class ArrayStreamDemo {
    public static void main(String... args) {
        int[] arr = { 50, 10, 250, 100, 23, 45, 33, 55 /* ... */};

        Arrays.stream(arr, 3,6).forEach(
                i -> System.out.println(Thread.currentThread().getName() + ": " + i)
        );
    }
}
// output
main: 100
main: 23
main: 45

Listing 8-7Creating a Stream of Integer Values from an Array of Integers

创建空流

在编写 Java 代码时,一个好的做法是编写返回对象的方法,避免返回null.以减少抛出NullPointerExceptions的可能性。当方法返回流时,首选方式是返回空流。这可以通过调用由Stream<T>接口提供的静态Stream.empty()方法来完成。

清单 8-8 中的代码片段描述了一个方法,该方法接受一系列Song实例参数,并使用它作为源返回一个流。如果列表为null或空,则返回一个空流。产生的流在main(..)方法中被遍历,没有额外的验证。如果流为空,则不会打印任何内容。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.*;

import java.util.List;
import java.util.stream.Stream;

public class SongStreamDemo {
    public static void main(String... args) {
        System.out.println(" -- Testing 'getAsStream(..)' method with null -- ");
        getAsStream(null).forEach(System.out::println);

        System.out.println(" -- Testing 'getAsStream(..)' method with empty list --");
        getAsStream(List.of()).forEach(System.out::println);

        System.out.println(" -- Testing 'getAsStream(..)' method with a list -- ");
        getAsStream(StreamMediaLoader.loadSongsAsList()).forEach(System.out::println);
    }

    public static Stream<Song> getAsStream(List<Song> songList) {
        if(songList == null || songList.isEmpty()) {
            return Stream.empty();
        } else {
            return songList.stream();
        }
    }
}
// output
 -- Testing 'getAsStream(..)' method with null --
 -- Testing 'getAsStream(..)' method with empty list --
 -- Testing 'getAsStream(..)' method with a list --
Song{id=1, singer='John Mayer', title='New Light', duration=206, audioType=FLAC}
Song{id=2, singer='John Mayer', title='My Stupid Mouth', duration=225, audioType=M4A}
...

Listing 8-8Creating a Stream of Integer Values from an Array of Integers

运行前面的代码会导致前两条消息一条接一条地打印出来,中间没有任何内容,因为方法返回的流是空的。

创建有限的流

除了从实际来源创建流之外,还可以通过调用流实用程序方法(如Stream.generate(..)或Stream.builder())来当场创建流。当用一组固定的已知值构建一个有限的流时,应该使用builder()方法。这个方法返回一个java.util.stream.Stream.Builder<T>的实例,这是一个内部接口,声明一个名为add(T t)的默认方法,需要调用这个方法来添加流的元素。要创建Stream 实例,必须最终调用它的 build()方法。add(T t)方法返回对Builder<T>实例的引用,因此它可以与该接口的任何其他方法链接。清单 8-9 中的代码是如何使用builder()方法创建各种值的有限流的示例。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.AudioType;
import com.apress.bgn.eigth.util.Song;

import java.util.stream.Stream;

public class FiniteStreamsDemo {
    public static void main(String... args) {
        Stream<Integer> built = Stream.<Integer>builder()
                .add(50).add(10).add(250)
                .build();

        Stream<String> lyrics = Stream.<String>builder()
                .add("In a world where people never meet,")
                .add("They fall in love looking at some screen")
                .add("And love can only be one-sided")
                .add("Bitter, burning unrequited.")
                .build();

        Stream<Song> songs = Stream.<Song>builder()
                .add (new Song("John Mayer", "New Light", 206, AudioType.FLAC))
                .add (new Song("Ben Barnes", "You find me", 420, AudioType.FLAC))
                .build();

        Stream data = Stream.builder() // compiler warns about raw use of parameterized class 'Stream'
                .add("Vultures")
                .add(3)
                .add(List.of("aa"))
                .build();

    }
}

Listing 8-9Creating Streams from Finite Sets of Values

由于Builder<T>接口是一个通用接口,因此必须指定一个类型参数,作为流中元素的类型。此外,builder()方法是通用的,需要在调用前将类型作为参数提供。如果没有指定类型,则使用默认的Object,任何类型的实例都可以添加到流中(如第四个流声明所示)。然而,编译器警告不要使用参数化类“Stream”。

要创建一个流,还有另一个名为generate(..)的方法。这个方法需要一个类型为java.util.function.Supplier<T>的参数。

img/463938_2_En_8_Figf_HTML.jpg一个供应商在本书中我们称之为java.util.function.Supplier<T>功能接口的内联实现。该接口需要为其名为get()的单一方法提供一个具体的实现。此方法应返回要添加到流中的元素。

因此,如果我们想生成一个整数流,一个合适的get()实现应该返回一个随机整数。清单 8-10 中描述了扩展的代码。Lambda 表达式并不用于说明generate(..)方法接收现场创建的Supplier<Integer>实例作为参数。

package com.apress.bgn.eigth;

import java.util.stream.Stream;

public class FiniteStreamsDemo {
    public static void main(String... args) {
        Stream<Integer> generated = Stream.generate(
        new Supplier<Integer>() {
            @Override
            public Integer get() {
                Random rand = new Random();
                return rand.nextInt(300) + 1;
            }
        }).limit(15);
    }
}

Listing 8-10Creating Stream Using a Supplier

limit(15)方法将供应商生成的元素数量限制为 15 个,否则生成的流将是无限的。清单 8-10 中的代码可以通过使用清单 8-11 中描述的 lambda 表达式来简化。

package com.apress.bgn.eigth;

import java.util.stream.Stream;

public class FiniteStreamsDemo {
    public static void main(String... args) {
        Stream<Integer> generated = Stream.generate(
        () -> {
            Random rand = new Random();
            return rand.nextInt(300) + 1;
        }).limit(15);
    }
}

Listing 8-11Creating Stream Using a Supplier and Lambda Expressions

如果Supplier<Integer>.get()总是返回相同的数字,不管这样的流可能多么无用,前面的声明变成:

Stream<Integer> generated = Stream.generate( () -> 5).limit(15);

如果需要对由Stream<T>实例发出的元素进行更多的控制,可以使用iterate(..)方法。这个方法有两个版本,一个是在 Java 8 中添加的,一个是在 Java 9 中添加的。使用这些方法中的任何一种都类似于让 for 语句为流生成条目。

Java 8 版本用于生成无限流。这个版本的方法接收一个名为seed的初始值和一个迭代步骤作为参数。

Java 9 版本用于生成有限流。这个版本的方法接收一个名为seed的初始值、一个决定迭代何时停止的predicate和一个迭代步骤作为参数。

img/463938_2_En_8_Figg_HTML.jpg谓词是函数接口java.util.function.Predicate<T>的内联实现,它声明了一个返回布尔值的方法named test(T t)。这个方法的实现应该根据一个条件来测试它的类型为T的单个参数,如果条件满足就返回true,否则返回false。

img/463938_2_En_8_Figh_HTML.jpg迭代步骤是函数接口java.util.function.UnaryOperator<T>的内联实现,用于表示对单个操作数的操作,该操作产生与其操作数相同类型的结果。

在下面的示例中,stream 元素从 0 开始生成,步长为 5,只要值小于 50,就会生成这些元素,如谓词所定义的。

Stream<Integer> iterated = Stream.iterate(0, i -> i < 50 , i -> i + 5);

就像使用for语句一样,终止条件不是强制性的,如果没有它,您将调用 Java 8 中引入的这个方法的版本,但是在这种情况下,必须使用limit(..)方法来确保流是有限的。

Stream<Integer> iterated = Stream.iterate(0, i -> i + 5).limit(15);

在 Java 9 中,除了limit(..)方法之外,还有另一种方法来控制流中值的数量:takeWhile(..)方法。该方法从原始流中获取与作为参数接收的谓词相匹配的最长元素集,从第一个元素开始。这对于有序的流来说很好,但是如果流是无序的,那么结果是匹配谓词的任何元素集,包括一个空元素。为了解释通过调用takeWhile(..)重新定义的不同流,必须首先讨论流的顺序概念。

表达式遇到顺序表示Stream<T>遇到数据的顺序。流的相遇顺序由源操作和中间操作定义。例如:如果将一个数组用作源,则流的相遇顺序由数组中的排序来定义。如果一个列表被用作源,那么相遇顺序就是列表的迭代顺序。如果一个集合被用作源,那么就没有相遇顺序,因为一个集合本来就是无序的。

流管道中的每个中间操作都作用于相遇顺序,其效果如下:

  • 可以在输出中加入相遇顺序。例如,sorted()操作在无序的流上强加了一个相遇顺序。

  • 遭遇战顺序保持不变。像filter(..)这样的操作可能会删除一些元素,但是原始顺序不会受到影响。

  • 遭遇战秩序被破坏。例如,sorted()操作在有序流上强加了一个相遇顺序,替换了现有的顺序。

如果将元素累积到具有相遇顺序的容器中,收集器操作会保留相遇顺序。顺序流和并行流在排序方面具有相同的属性。

清单 8-12 显示了takeWhile(Predicate<? super T> predicate)方法的两种用法。

package com.apress.bgn.eight;

import java.util.stream.Stream;

public class FiniteStreamsDemo {
    public static void main(String... args) {
        // (1)
        Stream<Integer> orderedStream = List.of( 3, 6, 9, 11, 12, 13, 15).stream();
        Stream<Integer> result = orderedStream.takeWhile(s -> s % 3 == 0);
        result.forEach(s -> System.out.print(s + " "));
        // output: 3 6 9

        // (2)
        Stream<Integer> unorderedStream = Set.of(3, 6, 9, 2, 4, 8, 12, 36, 18, 42, 11, 13).stream();
        result = unorderedStream
.parallel()    // this does not affect results
.takeWhile(s -> s % 3 == 0);
        result.forEach(s -> System.out.print(s + " "));
        // output (maybe): 3 12 36
    }
}

Listing 8-12Creating Stream Using a Supplier and the takeWhile(..) Method

第一个代码块对整数的有序流使用takeWhile(..),并返回一个元素被 3 除的流。得到的流包含元素 3 6 9 ,因为这是匹配给定谓词的第一组元素。

如果在第二个代码块中描述的无序流(并行或不并行)上调用takeWhile(..),结果将是不可预测的。结果可能是 3 12 36 或 12 36 18 42,因为结果是匹配谓词的任何元素的子集。此外,由于顺序不固定,代码块最终可能会打印出 6 3 9 或 18 42,依此类推。所以无序流上的takeWhile(..)的结果是不确定的。

takeWhile(..)操作是dropWhile(..)的“姐妹”,也是在 Java 9 中引入的。顾名思义,这与takeWhile(..)所做的正好相反:对于一个有序流,它在丢弃与谓词匹配的最长元素集之后,返回一个由元素组成的新流。对于无序的流,只有混乱,任何匹配谓词的元素子集都可以被丢弃,包括空流。清单 8-13 显示了dropWhile(..)方法的两种用法。

package com.apress.bgn.eigth;

import java.util.stream.Stream;

public class FiniteStreamsDemo {
    public static void main(String... args) {
        Stream.of( 3, 6, 9, 11, 12, 13, 15)
            .dropWhile(s -> s % 3 == 0 )
            .forEach(s -> System.out.print(s + " "));
        // output: 11 12 13 15

        Stream.of(3, 6, 9, 2, 4, 8, 12, 36, 18, 42, 11, 13)
            .dropWhile(s -> s % 3 == 0 )
      .parallel() // this does not affect results
            .forEach(s -> System.out.print(s + " "));
        // output (maybe): 11 9 8 6 4 2 42 13 36
    }
}

Listing 8-13Creating Stream Using a Supplier and the dropWhile(..) Method

如果这两个操作应用于并行流,唯一改变的是元素的打印顺序,但是结果集将包含相同的元素。

原语流和字符串流

当我们第一次创建原语流时,我们使用了一个int[]数组作为源。然而,原语流可以通过多种方式创建,因为 Stream API 包含更多带有默认方法的接口,使得用流编程变得切实可行。在图 8-1 中,你可以看到流接口的层次结构。

img/463938_2_En_8_Fig1_HTML.jpg

图 8-1

流 API 接口

看了前面的图片后,您可能会想到,IntStream接口可以用来创建整数的原始流。这个接口公开了许多这样做的方法,其中一些继承自BaseStream<T,S>。通过使用builder()、generate(..)或iterate(..)方法或使用range*(..)方法的,可以从现场指定的几个值创建一个IntStream实例,如清单 8-14 所示。

package com.apress.bgn.eigth;

import java.util.Random;
import java.util.stream.IntStream;

public class NumericStreamsDemo {
    public static void main(String... args) {
        IntStream intStream0 = IntStream.builder().add(0).add(1).add(2).add(5).build();
        IntStream intStream1 = IntStream.of(0,1,2,3,4,5);

        IntStream intStream2 = IntStream.range(0, 10);
        IntStream intStream3 = IntStream.rangeClosed(0, 10);

        Random random = new Random();
        IntStream intStream4 = random.ints(5);
    }
}

Listing 8-14Creating IntStream Instances

Using Various Methods

通过将间隔的开始和结束作为参数提供给range(..)和rangeClosed(..)方法,可以创建一个IntStream实例。它们都为流生成元素,步长为 1,只有最后一个元素包含区间的上界作为值。

此外,在 Java 1.8 中,java.util.Random类增加了一个名为ints(..)的方法,可以生成一个随机整数流。它声明了一个参数,该参数表示要生成并放入流中的元素数量,但是有一种形式的该方法没有生成无限流的参数。

为IntStream提到的所有方法都可以用来生成LongStream实例,因为等效的方法也在这个接口中定义。

对于DoubleStream,没有范围方法,但是有of(..)方法、builder()、generate(..)等等。此外,java.util.Random类在 Java 1.8 中用生成随机双精度值流的doubles(..)方法得到了丰富。它声明了一个参数,该参数表示要生成并放入流中的元素数量,但是有一种形式的该方法没有生成无限流的参数。在清单 8-15 中,描述了几种创建双精度流的方法。

package com.apress.bgn.eigth;

import java.util.Random;
import java.util.stream.DoubleStream;

public class NumericStreamsDemo {
    public static void main(String... args) {
        DoubleStream doubleStream0 = DoubleStream.of(1, 2, 2.3, 3.4, 4.5, 6);

        Random random = new Random();
        DoubleStream doubleStream1 = random.doubles(3);

        DoubleStream doubleStream2 = DoubleStream.iterate(2.5, d -> d = d + 0.2).limit(10);
    }
}

Listing 8-15Creating Numeric Stream Instances Using Various Methods

对于char值的流,没有特殊的接口,但是IntStream可以很好地使用。

IntStream intStream = IntStream.of(’a’,’b’,’c’,’d’);
intStream.forEach(c -> System.out.println((char) c));

创建 char 值流的另一种方法是使用 String 实例作为流源。

IntStream charStream = "sample".chars();
charStream.forEach(c -> System.out.println((char) c));

在 Java 8 中,java.util.regex.Pattern也用特定于流的方法来丰富;作为一个用于处理String实例的类,它毕竟是添加这些方法的合适位置。一个Pattern实例对于拆分一个现有的字符串实例并使用splitAsStream(..)方法将片段作为一个流返回是很有用的。

Stream<String> stringStream = Pattern.compile(" ")
    .splitAsStream("live your life");

使用Files.lines(..)实用程序方法,文件的内容也可以作为字符串流返回。

String inputPath = "chapter08/src/main/resources/songs.csv";
Stream<String> stringStream = Files.lines(Path.of(inputPath));

到目前为止,这几节已经展示了如何创建所有类型的流,下一节将向您展示如何使用它们来处理数据。

如果你觉得有必要将流实例与真实对象联系起来,以使它们有意义,我推荐如下:想象一个有限的流(就像一个从集合中创建的流),就像倾斜时从杯子中滴落的水。杯子里的水最终会用完,但当水滴落时,它形成了一条小溪。一条无限的溪流就像一条有喷泉头的河流,它不停地流动(当然,除非严重的干旱使河流干涸)。

Optional<T>简介

java.util.Optional<T>实例是 Java 语言的薛定谔 2 盒。它们非常有用,因为它们可以用作方法的返回类型,以避免返回一个null值,并导致抛出一个可能的NullPointerException,或者开发人员使用该方法编写额外的代码来处理抛出异常的可能性。Optional<T>实例可以以类似于流的方式创建。

有一个empty()方法可以创建不包含任何内容的任何类型的可选值。

Optional<Song> empty = Optional.empty();

有一个of()方法用于将现有对象包装到一个Optional<T>中。

Optional<Long> value = Optional.of(5L);

考虑到这些类型的实例被设计成不允许使用null值,先前创建Optional<T>实例的方式阻止了我们编写类似这样的内容:

Song song = null;
Optional<Song> nonNullable = Optional.of(song);

编译器并不介意,但是在运行时执行代码时,会抛出一个NullPointerException。不过,如果我们真的需要一个Optional<T>实例来允许null值,这是可能的;Java 9 中为此引入了一个名为ofNullable(T t)的实用方法:

Song song = null;
Optional<Song> nullable = Optional.ofNullable(song);

现在我们有了Optional<T>实例,我们可以用它们做什么呢?我们使用它们。看看清单 8-16 中的代码。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.MediaLoader;
import com.apress.bgn.eigth.util.Song;

import java.util.List;

public class NonOptionalDemo {
    public static void main(String... args) {
        List<Song> songs = MediaLoader.loadSongs();
        Song song = findFirst(songs, "B.B. King");
        if(song != null && song.getSinger().equals("The Thrill Is Gone")) {
            System.out.println("Good stuff!");
        } else {
            System.out.println("not found!");
        }
    }

    public static Song findFirst(List<Song> songs, String singer) {
        for (Song song: songs) {
            if (singer.equals(song.getSinger())) {
                return song;
            }
        }
        return null;
    }
}

Listing 8-16Code Showing the Necessity of Optional<T>

findFirst(..)方法查找列表中歌手等于“B.B. King”的第一首歌曲,如果找到则返回并打印一条消息,如果没有找到则打印另一条消息。请注意前面代码清单中的列表的可空性测试和迭代。在 Java 8 中,这两者都不再必要。清单 8-17 描述了清单 8-16 中的代码被重新设计以使用Optional<T>。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.MediaLoader;
import com.apress.bgn.eigth.util.Song;

import java.util.List;
import java.util.Optional;

public class OptionalDemo {
    public static void main(String... args) {
        List<Song> songs = MediaLoader.loadSongs();
        Optional<Song> opt = songs.stream()
                .filter(s -> "B.B. King".equals(s.getSinger()))
                .findFirst();
        opt.ifPresent(r -> System.out.println(r.getTitle()));
    }
}

Listing 8-17Code Showing Usage of Optional.ifPresent(..)

如果Optional<T>实例不为空,将打印歌曲标题;否则,将不打印任何内容,代码将从该点继续运行,不会引发异常。但是如果我们想在Optional<T>实例为空时打印一些东西呢?在 Java 11 中我们可以做些什么,因为引入了一个名为isEmpty()的方法来测试Optional<T>实例内容(清单 8-18 )。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.MediaLoader;
import com.apress.bgn.eigth.util.Song;

import java.util.List;
import java.util.Optional;

public class OptionalDemo {
    public static void main(String... args) {
        List<Song> songs = MediaLoader.loadSongs();
        Optional<Song> opt1 = songs.stream()
                .filter(s -> "B.B. King".equals(s.getSinger()))
                .findFirst();
        if(opt1.isEmpty()) {
            System.out.println("Not found!");
        }
    }
}

Listing 8-18Code Showing Usage of Optional.isEmpty()

但是等等这有点。。。不对。难道我们不能有一个方法来调用一个Optional<T>来获得作为一个if-else语句的确切行为吗?从 Java 9 开始,这是可能的;当不为空时,ifPresentOrElse(..)方法以一个Consumer<T>作为参数处理Optional<T>实例的内容,当Optional<T>实例为空时,以一个Runnable实例作为参数执行(清单 8-19 )。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.MediaLoader;
import com.apress.bgn.eigth.util.Song;

import java.util.List;
import java.util.Optional;

public class OptionalDemo {
    public static void main(String... args) {
        List<Song> songs = MediaLoader.loadSongs();
        Optional<Song> opt2 = songs.stream()
                .filter(ss -> "B.B. King".equals(ss.getSinger()))
                .findFirst();
        opt2.ifPresentOrElse(
                r -> System.out.println(r.getTitle()),
                () -> System.out.println("Not found!")) ;
    }
}

Listing 8-19Code Showing Usage of Optional.ifPresentOrElse(..)

如果Optional<T>实例不为空,可以通过调用get()方法提取其内容(清单 8-20 )。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.MediaLoader;
import com.apress.bgn.eigth.util.Song;

import java.util.List;
import java.util.Optional;

public class OptionalDemo {
    public static void main(String... args) {
        List<Song> songs = MediaLoader.loadSongs();
        Optional<Song> opt3 = songs.stream()
                .filter(ss -> "Rob Thomas".equals(ss.getSinger()))
                .findFirst();
        System.out.println("Found Song " + opt3.get());
    }
}

Listing 8-20Code Showing Usage of Optional.get()

当没有找到想要的对象时,前面的代码不打印任何东西,因为Optional<T>是空的。但是如果我们想要打印一个默认值,我们也可以使用一个名为orElse(..)的方法(清单 8-21 )。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.MediaLoader;
import com.apress.bgn.eigth.util.Song;

import java.util.List;
import java.util.Optional;

public class OptionalDemo {
    public static void main(String... args) {
        List<Song> songs = MediaLoader.loadSongs();
        Optional<Song> opt4 = songs.stream()
                .filter(ss -> "B.B. King".equals(ss.getSinger()))
                .findFirst();
        opt4.ifPresent(r -> System.out.println(r.getTitle()));

        Song defaultSong = new Song();
        defaultSong.setTitle("Untitled");
        Song s = opt4.orElse(defaultSong);
        System.out.println("Found: " + s.getTitle());
    }
}

Listing 8-21Code Showing Usage of Optional.orElse(..)

orElse(T t)方法接收由Optional<T>包装的类型的实例作为参数。它的另一个版本采用了一个返回所需类型对象的Supplier<T>。使用该方法的代码片段如下所示:

Song fromSupplier =
     opt4.orElseGet(() -> new Song("None", "Untitled", 0, null));
System.out.println("Found: " + fromSupplier.getTitle());

如果我们想在Optional<T>为空时抛出一个特定的异常,那么也有一个方法,名为orElseThrow(..)(清单 8-22 )。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.MediaLoader;
import com.apress.bgn.eigth.util.Song;

import java.util.List;
import java.util.Optional;

public class OptionalDemo {
    public static void main(String... args) {
        List<Song> songs = MediaLoader.loadSongs();
        Optional<Song> opt5 = songs.stream()
                .filter(st -> "B.B. King".equals(st.getSinger()))
                .findFirst();
        Song song = opt5.orElseThrow(IllegalArgumentException::new);
    }
}

Listing 8-22Code Showing Usage of Optional.orElseThrow(..)

正如您在前面的代码示例中可能注意到的那样,Optional<T>和Stream<T>可以结合起来编写实用的代码来解决复杂的解决方案。由于有许多方法可以应用于Optional<T>和Stream<T>实例,下一节将介绍它们在流中的应用,并随机引用Optional<T>。

如何像专业人士一样使用流

创建流之后,接下来要处理流中的数据。该处理的结果可以是另一个流,该流可以根据需要被进一步处理多次。有很多方法可以用来处理一个流并将结果作为另一个流返回。这些方法被称为中间操作。不返回流而是返回实际数据结构或者什么都不返回的方法被命名为终端操作。所有这些都在Stream<T>接口中定义。流的关键特征是,仅当终端操作被启动时,才使用流处理数据,并且仅在需要时使用来自源的元素。所以你可以说整个流过程实际上是懒惰的。延迟加载源元素并在需要时处理它们可以实现显著的优化。

在前面的断言之后,您可能意识到以前经常使用的从流中打印值的forEach(..)方法实际上是一个终端操作。但是还有相当多的其他终端操作,其中一些是大多数 commons 实现最可能需要的,将在本章剩余部分的例子中使用。

本章从一个处理Song实例流的例子开始,但是没有显示Song类。你可以在清单 8-23 中看到它的字段。

package com.apress.bgn.eigth;

public class Song {
    private Long id;
    private String singer;
    private String title;
    private Integer duration;
    private AudioType audioType;

    //getters and setters
    // toString
}

Listing 8-23Fields of Class Song

AudioType是一个包含音频文件类型的枚举,如清单 8-24 所示。

package com.apress.bgn.eigth;

public enum AudioType {
    MP3,
    FLAC,
    OGG,
    AAC,
    M4A,
    WMA,
    MP4
}

Listing 8-24AudioType Enum

既然下面的流示例将使用数据类型,那么也应该描述数据。在书中的例子中,数据包含在一个名为songs.csv的文件中。CSV 扩展名表示一个逗号分隔的文件,每个Song实例匹配文件中的一行。每行包含每个Song实例的所有属性值,由列分隔。值的顺序必须与构造函数参数的顺序相匹配。可以使用其他分离器;这里使用分号是出于实际原因(这是读取数据的库支持的默认设置)。文件内容如清单 8-25 所示。

01;John Mayer;New Light;206;FLAC
02;John Mayer;My Stupid Mouth;225;M4A
03;John Mayer;Vultures;247;FLAC
04;John Mayer;Edge of Desire;333;MP3
05;John Mayer;In Repair;372;MP3
05;Rob Thomas;Paper Dolls;185;MP3
07;The Script;Mad Love;207;MP3
08;Seth MacFarlane;No One Ever Tells You;244;MP3
09;Nat King Cole;Orange Colored Sky;154;MP3
10;Vertical Horizon;Forever;246;MP3
11;Mario Lanza;Temptation;141;M4A
12;Jack Radics;No Matter;235;MP3
13;George Michael;Fastlove;306;MP3
14;Childish Gambino;Freaks And Geeks;227;M4A
15;Bill Evans;Lover Man;304;MP3
16;Darren Hayes;Like It Or Not;381;MP3
17;Stevie Wonder;Superstition;284;MP3
18;Tony Bennett;It Had To Be You;196;MP3
19;Tarja Turunen;An Empty Dream;322;MP3
20;Lykke Li;Little bit;231;M4A
21;Ben Barnes;You find me;420;FLAC

Listing 8-25Song Entries in the sonds.csv File

通过使用名为 JSefa 的库中的类,文件中的每一行都将被转换成一个Song实例。 3 这个库不是本书的主题,但如果你感兴趣,可以使用脚注中的链接从官方网站获取更多详细信息。

终端功能:forEach和forEachOrdered

现在你已经准备好开始玩流了。假设 songs 流将提供前面清单中声明的所有Song实例,让我们首先打印流中的所有元素。清单 8-26 中的代码使用一个简单的消费者在一个Stream<Song>上打印所有的Song实例。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;

import java.util.stream.Stream;

public class MediaStreamTester {
    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();
        songs.forEach(song -> System.out.println(song));
    }
}

Listing 8-26Using a Stream to Print Song Instances

Java 8 中引入了方法引用。对于 lambda 表达式除了调用方法之外什么也不做的情况,方法引用是一种快捷方式,因此可以通过名称直接引用方法。所以这一行:

songs.forEach(song -> System.out.println(song));

变成了:

songs.forEach(System.out::println);

forEach(..)方法接收一个Consumer<T>实例作为参数。在前面的两个例子中,accept()方法的实现只包含了对System.out.println(song),的调用,这就是代码如此紧凑的原因,因为可以使用方法引用。但是如果这个方法的实现需要包含更多的语句,那么先前编写的紧凑代码就不可能了。

让我们先将歌手的名字大写,而不是直接打印歌曲,如清单 8-27 所示。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;

import java.util.function.Consumer;
import java.util.stream.Stream;

public class MediaStreamTester {
    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();
        songs.forEach(new Consumer<Song>() {
            @Override
            public void accept(Song song) {
                song.setSinger(song.getSinger().toUpperCase());
                System.out.println(song);
            }
        });
    }
}

Listing 8-27Using a Consumer Anonymous Class to Print Song Instances

可以用 lambda 表达式简化,但是因为方法体有两行,所以看起来还是不好。所以另一种方法是声明一个消费者字段,并使用 lambda 为每首歌曲调用它的accept(..)方法,如清单 8-28 所示。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;

import java.util.function.Consumer;
import java.util.stream.Stream;

public class MediaStreamTester {
    public static Consumer<Song> myConsumer = song -> {
        song.setSinger(song.getSinger().toUpperCase());
        System.out.println(song);
    };

    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();

        songs.forEach(song -> myConsumer.accept(song));
    }
}

Listing 8-28Using a Consumer Field to Print Song Instances

姐妹函数forEachOrdered(..)与forEach(..)做同样的事情,有一点不同,确保流上的元素将按照相遇顺序处理,如果这样的顺序是为流定义的,即使流是并行的。所以基本上,下面两行将以相同的顺序打印歌曲:

songs.forEachOrdered (System.out::println);
songs.parallel().forEachOrdered(System.out::println);

中间运行:filter和终端运行:toArray

在下面的例子中,我们将选择所有的 MP3 歌曲,并将它们保存到一个数组中。使用filter(..)方法选择所有 MP3 歌曲。该方法接收一个类型为Predicate<T>的参数,该参数用于定义一个条件,流的元素必须通过该条件才能放入数组中,该数组是通过调用名为toArray(..)的终端方法得到的。

toArray(..)接收类型为IntFunction<A[]>的参数。这种类型的函数也被称为生成器,它接受一个整数作为参数,并生成该大小的数组。在大多数情况下,最合适的生成器是数组构造函数引用。

清单 8-29 中描述了过滤 MP3 条目并将它们放入类型为Song[]的数组中的代码:

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;

import java.util.function.Consumer;
import java.util.stream.Stream;

public class MediaStreamTester {

    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();
        Song[] sarray = songs.filter(s -> s.getAudioType() == AudioType.MP3).toArray(Song[]::new);  // array constructor reference
        Arrays.stream(sarray).forEach(System.out::println);
    }
}

Listing 8-29Using a Generator Function to Collect Stream Elements Into an Array

中间运行:map、flatMap和终端运行:collect

在下面的例子中,我们将处理所有的歌曲,并以分钟为单位计算持续时间。为此,我们将使用map(..)方法为流发出的每个歌曲实例调用一个 pure 函数,返回以分钟为单位的持续时间。这将产生一个新的Integer值流。

它的所有元素将使用collect(..)方法添加到一个List<Integer>中。当元素被处理成一个Collection<Integer>实例时,这个方法累积这些元素。清单 8-30 展示了正在使用的这些方法。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;

import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class SongTransformer {
    public static int processDuration(Song song) {
        int secs = song.getDuration();
        return secs/60;
    }

    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();

        List<Integer> durationAsMinutes = songs
                .map(SongTransformer::processDuration) // method reference call
                .collect(Collectors.toList());

        durationAsMinutes.forEach(System.out::println);
    }
}

Listing 8-30Using map(..) and collect(..) Methods

map(..)方法接收一个类型为Function<T,R> (T 输入类型,R 结果类型)的参数,它基本上是一个应用于流中每个元素的函数的引用。我们在前面的示例中应用的函数从流中获取一个歌曲元素,获取它的持续时间,将其转换为分钟并返回它。前面清单中的代码可以重写,如清单 8-31 所示,其中方法processDuration被声明为类型Function<T,R>的字段。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;

import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class SongTransformer {

   public static Function<Song, Integer> processDuration = song -> song.getDuration()/60;

    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();

        List<Integer> durationAsMinutes = songs
                .map(processDuration)
                .collect(Collectors.toList());

        durationAsMinutes.forEach(System.out::println);
    }
}

Listing 8-31Using a Field of Type Function<T,R> to Process Stream Elements

Function<T,R>的第一个泛型类型是被处理元素的类型,第二个是结果的类型。

上一节中提到的filter(..)方法的一个版本也是为Optional<T>类型定义的,可以用来避免编写复杂的if语句,以及map(..)方法。让我们假设我们有一个Song实例,我们想检查它是否长于 3 分钟而短于 10 分钟。我们可以使用一个Optional<Song>和这两个方法来做同样的事情,而不是用一个AND操作符连接两个条件来编写一个if语句,如清单 8-32 所示。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;

public class SongTransformer {

    public static void main(String... args) {
        Song song0 = new Song("Ben Barnes", "You find me", 420, AudioType.FLAC);
        if(isMoreThan3MinsAndLessThenTen(song0)) {
            System.out.println("This song is just right!");
        }
    }

     public static boolean isMoreThan3MinsAndLessThenTen(Song song) {
        return Optional.ofNullable(song).map(SongTransformer::processDuration)
                .filter(d -> d >= 3)
                .filter(d -> d <= 10)
                .isPresent();
    }
}

Listing 8-32Using filter(..) and map(..) to Avoid Writing if Statements

就性能而言,前面的实现可能并不理想,但是如果您愿意,可以编写这样的代码。只是确保在滥用流操作之前正确阅读文档。

所以map(..)还是挺厉害的,但是有个小瑕疵。如果我们看一下它在Stream.java文件中的签名,我们会看到:

<R> Stream<R> map(Function<? super T, ? extends R> mapper);

因此,如果应用于流中每个元素的map(..)函数参数返回一个包含结果的流,该结果被放入另一个包含所有结果的流中,那么collect(... )方法实际上是在一个Stream<Stream<R>>上调用的。Optional<T>也是如此;终端方法将在<Optional<Optional<T>>上被调用。当对象很简单时,比如这些书籍代码样本中使用的Song实例,map(..)方法工作得很好,但是如果原始流中的对象更复杂,比如List<List<T>>,事情可能会变得复杂。展示flatMap(..)效果的最简单方法是将其应用到List<List<T>>上。让我们看看清单 8-33 中的例子。

package com.apress.bgn.eigth;

import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

public class MoreStreamsDemo {
    public static void main(String... args) {
        List<List<Integer>> testList = List.of (List.of(2,3), List.of(4,5), List.of(6,7));
        System.out.println(processList(testList));
    }

    public static List<Integer> processList( List<List<Integer>> list) {
        List<Integer> result = list
                .stream()
                .flatMap(Collection::stream)
                .collect(Collectors.toList());
        return result;
    }
}

Listing 8-33Using flatMap(..) to Unwrap Stream Elements

flatMap(..)方法接收对一个方法的引用作为参数,该方法获取一个集合并将其转换成一个流,这是创建Stream<Stream<Integer>>的最简单方法。flatMap(..)施展魔法,结果被转换成Stream<Integer>,元素被collect(..)方法收集成List<Integer>。去除无用流包装器的操作叫做展平。如果仍然不清楚发生了什么,图 8-2 应该会使事情更清楚。

img/463938_2_En_8_Fig2_HTML.jpg

图 8-2

flatMap(..)效果的可视化描述

在前面的代码示例中,使用map(..)方法没有产生预期的结果。如果把flatMap(..)换成map(..),最终结果不是List<Integer>,而是List<Stream<Integer>>。IntelliJ 足够聪明来找出它,它提供适当的消息来帮助你选择正确的调用方法,如图 8-3 所示。

img/463938_2_En_8_Fig3_HTML.jpg

图 8-3

使用map(..)代替flatMap(..)时的 IntelliJ IDEA 错误消息

查看flatMap(..)方法效果的另一种方式是用Optional<T>编写一个更简单的例子。假设我们需要一个将String值转换成Integer值的函数。如果String值不是一个有效的数字,我们希望避免返回null。这意味着我们的函数必须接受一个String并返回Optional<Integer>。清单 8-34 中显示的代码包含一个显式展平和一个用flatMap(..)完成的展平。

package com.apress.bgn.eigth;

import java.util.Optional;
import java.util.function.Function;

public class MoreStreamsDemo {

    public static void main(String... args) {
        Optional<String> str = Optional.of("42");
        Optional<Optional<Integer>> resInt = str.map(toIntOpt);

        // explicit flattening
        Optional<String> str0 = Optional.of("42");
        Optional<Optional<Integer>> resInt0 = str0.map(toIntOpt);
        Optional<Integer> desiredRes0 = resInt0.orElse(Optional.empty());
        System.out.println("finally: " + desiredRes0.get());

        // flatMap(..) flattening
        Optional<String> str1 = Optional.of("42");
        Optional<Integer> desiredRes1 = str1.flatMap(toIntOpt);
        System.out.println("boom: " + desiredRes1.get());
    }

    // converts a String to int, returns Optional<Integer> with the result,
    //Optional.empty if it cannot be converted
    public static Function<String, Optional<Integer>> toIntOpt = str -> {
        try {
            return Optional.of(Integer.parseInt(str));
        } catch (NumberFormatException e) {
            return Optional.empty();
        }
    };
}

Listing 8-34Flattening of a Optional<Optional<T>>

所以,是的,map(..)和flatMap(..)之间有细微的差别,虽然在大多数情况下你会使用前者,但知道后者也存在也是很好的。

中间运行:sorted和终端运行:findFirst

顾名思义,sorted()方法与排序、元素排序有关。当在一个流上调用时,它用初始流的所有元素创建另一个流,但是按照它们的自然顺序排序。如果流中元素的类型不可比(该类型不实现java.lang.Comparable<T>),则会抛出java.lang.ClassCastException。由于我们将使用这个方法来获得一个排序元素流,我们将使用findFirst()来获得流中的第一个元素。这个方法返回一个Optional<T>,因为流可能是空的,因此没有第一个元素。这意味着要获取值,必须调用get()方法。对于流可能为空的情况,可以使用orElse(..)或orElseGet(..)方法在缺少第一个元素的情况下返回默认值。清单 8-35 描述了这两种情况。

package com.apress.bgn.eigth;

import java.util.List;

public class MoreStreamsDemo {

    public static void main(String... args) {
        // non empty stream, result 'ever'
        List<String> pieces = List.of("some","of", "us", "we’re", "hardly", "ever", "here");
        String first0 = pieces.stream().sorted().findFirst().get();
        System.out.println("First from sorted list: " + first0);

        // empty stream, result 'none'
        pieces = List.of();
        String first1 = pieces.stream().sorted().findFirst().orElse("none");
        System.out.println("First from sorted list: " + first1);
    }

}

Listing 8-35Extracting the First Element in an Ordered Stream

中间运行:distinct()和终端运行:count()

distinct()方法获取一个流,并生成一个包含原始流的所有不同元素的流。因为在本书的例子中,我们耦合了中介和终端操作,所以让我们使用count(),它对流的元素进行计数。清单 8-36 中描述了一个小例子。

package com.apress.bgn.eigth;

public class MoreStreamsDemo {

    public static void main(String... args) {
        List<String> pieces = List.of("as","long", "as", "there", "is", "you", "there", "is", "me");
        long count = pieces.stream().distinct().count();
        System.out.println("Elements in the stream: " + count);
    }
}

Listing 8-36Counting Elements of a Stream, After Removing Duplicate Elements

运行时,代码打印流中的元素:6 个,因为在删除了重复的项之后,还有,剩下 6 个项。如果初始流是空的,count()方法返回 0(零)。

中间作业:limit(..)和终端作业:min(..)、max(..)

本章之前使用了limit(..)方法将无限流转换为有限流。当它将一个流转换成另一个流时,显然这是一个中间函数。本节介绍的终端方法模拟了两个数学函数:

  • 计算流中元素的最小值:min(..)

  • 计算流中元素的最大值:max(..)。

流中元素的类型必须实现java.util.Comparator<T>,否则无法计算最小值和最大值。清单 8-37 中描述了limit(..)、min(..)和max(..)功能的共同使用。

package com.apress.bgn.eigth;

import java.util.stream.Stream;

public class MoreStreamsDemo {

    public static void main(String... args) {
        Stream<Integer> ints0 = Stream.of(5,2,7,9,8,1,12,7,2);
        ints0.limit(4).min(Integer::compareTo)
                .ifPresent(min -> System.out.println("Min is: " + min));
        // Prints "Min is: 2"

        Stream<Integer> ints1 = Stream.of(5,2,7,9,8,1,12,7,2);
        ints1.limit(4).max(Integer::compareTo)
                .ifPresent(max -> System.out.println("Max is: " + max));
        // Prints "Max is: 9"
    }
}

Listing 8-37Computing the Maximum and Minimum Value in a Stream

终端操作:sum()和reduce(..)

让我们考虑这个场景:我们有一个有限的Song值流,我们想计算它们的持续时间的总和。有两种流终止符方法可以用来实现这一点:sum(..)方法和reduce(..)方法。清单 8-38 中描述了这样做的代码。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;
import java.util.stream.Stream;

public class MediaStreamTester {
    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();
        Integer totalDuration0 = songs
                .mapToInt(Song::getDuration)
                .sum();
        System.out.println("Total duration using sum: " + totalDuration0);

        songs = StreamMediaLoader.loadSongs();
        Integer totalDuration1 = songs
                .mapToInt(Song::getDuration)
                .reduce(0, (a, b) -> a + b);
        System.out.println("Total duration using reduce: " + totalDuration1);
    }
}

Listing 8-38Adding the Elements of a Stream

reduce(..)操作的版本有两个参数:

  • identity 参数表示归约的初始结果,如果流中没有元素,则表示默认结果。

  • 累加器函数接受两个参数,运算应用到这两个参数上以获得部分结果(在本例中是这两个元素的相加)。

reduce(..)运算累加器是java.util.function.BinaryOperator<T>的一个实例,它表示对两个相同类型的操作数的运算,产生与操作数相同类型的结果。在一个IntStream上,就像由mapToInt(..)操作返回的一样,reduce(..)操作累加器是java.util.function.IntBinaryOperator的一个实例,?? 是一个定制函数,它接受两个 int 参数并返回一个 int 结果。

实际上,每次处理流中的一个元素时,累加器都会返回一个新值,在本例中,这个新值是将已处理的元素与之前的部分结果相加的结果。因此,如果进程的结果是一个集合,累加器的结果是一个集合,所以每次处理一个流元素都会创建一个新的集合。这是相当低效的,所以在涉及集合的场景中,collect(..)操作更合适。

中间操作:peek(..)

这个函数很特别,因为它不会以任何方式影响流结果。peek()函数返回一个由它所调用的流的元素组成的流,同时还对每个元素执行由它的Consumer<T>参数指定的操作。这意味着该函数可用于调试流操作,使用运行时打印信息的日志记录语句。

让我们来看一下我们的Song实例流,根据它们的持续时间过滤它们,选择持续时间为> 300 秒的所有实例,然后获取它们的标题并将其收集到一个列表中。清单 8-39 中描述了这样做的代码。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;
import java.util.stream.Stream;

public class MediaStreamTester {
    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();
        List<String> result = songs.filter(s -> s.getDuration() > 300)
            .map(Song::getTitle)
            .collect(Collectors.toList());
    }
}

Listing 8-39Calling a Simple map(..) on Stream Elements

在前面的代码中,在调用map(..)之前,可以引入一个peek(..)调用来检查被过滤的元素是否是您所期望的。在检查映射值之后,可以引入另一个peek(..)调用,如清单 8-40 所示。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;
import java.util.stream.Stream;

public class MediaStreamTester {
    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();
        List<String> result = songs.filter(s -> s.getDuration() > 300)
            .peek(e -> System.out.println("\t Filtered value: " + e))
            .map(Song::getTitle)
            .peek(e -> System.out.println("\t Mapped value: " + e))
            .collect(Collectors.toList());
    }
}

Listing 8-40Showing What peek(..) Can Do

中间运行:skip(..)和终端运行:findAny()、anyMatch(..)、allMatch(..)和noneMatch(..)

这些是本章将要讨论的最后一个操作,所以它们被耦合在一起,因为skip(..)操作可能会影响其他操作的结果。

findAny()返回一个包含流中某个元素的Optimal<T>实例,或者当流为空时返回一个空的Optimal<T>实例。

img/463938_2_En_8_Figk_HTML.jpg这个操作的行为是明确不确定的;可以自由选择流中的任何元素。当应用于无序流时,它的行为与findFirst()相同。所以选择使用哪一个取决于被调用的流的类型。

因为findAny()是不确定的,所以它的结果是不可预测的,所以将其应用于并行流与将其应用于顺序流是一样的。findAny()操作应用于并行Song流,如清单 8-41 所示。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;
import java.util.stream.Stream;

public class MediaStreamTester {
    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();
        Optional<Song> optSong = songs.parallel().findAny();
        optSong.ifPresent(System.out::println);
    }
}

Listing 8-41Example Using findAny() on Parallel Stream

anyMatch(..)方法接收类型为Predicate<T>的参数,如果流中有任何元素匹配谓词,则返回布尔值true,否则返回false。它也适用于并行流。清单 8-42 中的代码返回true,如果我们的流中有任何歌曲的标题包含单词Paper。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;
import java.util.stream.Stream;

public class MediaStreamTester {
    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();
        boolean b0 = songs.anyMatch(s -> s.getTitle().contains("Paper"));
        System.out.println("Are there songs with title containing ’Paper’? " + b0);
    }
}

Listing 8-42Example Using anyMatch(..)

前面的代码将打印出true,因为在那个名为Paper Dolls的流中有一首歌曲。但是如果我们想要改变结果,我们所要做的就是通过调用清单 8-43 中描述的skip(6)来跳过原始流中处理的前 6 个元素。这个方法也适用于并行流。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;
import java.util.stream.Stream;

public class MediaStreamTester {
    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();
        boolean b1 = songs.parallel()
            .skip(6)
            .anyMatch(s -> s.getTitle().contains("Paper"));
        System.out.println("Are there songs with title containing `Paper`? " + b1);
    }
}

Listing 8-43Example Using skip(..) and anyMatch(..)

如果原始流中的前六个元素没有被处理,前面的代码返回false。还有一个函数分析流的所有元素,检查它们是否都匹配一个谓词,这个方法被命名为allMatch(..)。在清单 8-44 中,我们检查是否所有Song实例的持续时间都大于 300。该函数返回一个布尔值,如果所有的Song实例都匹配谓词,则该值为true,否则为false。对于本章示例中使用的数据集,预期结果是一个false值,因为不是所有的Song实例都有大于 300 的持续时间字段值。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;
import java.util.stream.Stream;

public class MediaStreamTester {
    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();
        boolean b2 = songs.allMatch(s -> s.getDuration() > 300);
        System.out.println("Are all songs longer than 5 minutes? " + b2);
    }
}

Listing 8-44Showing What allMatch(..) Can Do

这个函数的姐妹是一个名为noneMatch(..)的函数,它做完全相反的事情:接受一个谓词作为参数并返回一个布尔值,但是如果没有一个流元素匹配作为参数提供的谓词,则值为true,否则为false。在清单 8-45 中,我们使用noneMatch(..)检查是否没有持续时间为> 300 的Song实例,并且我们期望结果也是false。

package com.apress.bgn.eigth;

import com.apress.bgn.eigth.util.Song;
import com.apress.bgn.eigth.util.StreamMediaLoader;
import java.util.stream.Stream;

public class MediaStreamTester {
    public static void main(String... args) {
        Stream<Song> songs = StreamMediaLoader.loadSongs();
        boolean b3 = songs.noneMatch(s -> s.getDuration() > 300);
        System.out.println("Are all songs shorter than 5 minutes? " + b3);
    }
}

Listing 8-45Showing What noneMatch(..) Can Do

调试流代码

如前所述,peek(..)方法可以用于轻度调试,更像是记录流元素在一个流方法调用和另一个流方法调用之间发生的变化。调试用流编写的代码的另一个简单方法是实现谓词、消费者和提供者,并在它们的 main 方法中添加日志记录语句。

这些简单的方法并不总是足够的,尤其是当代码是大量用户同时访问的大型应用的一部分时。它们实现起来可能也很乏味,因为日志记录语句必须在开发过程中添加,然后在将应用投入生产之前删除,以避免污染应用日志并(可能)降低它的速度。

IntelliJ IDEA 编辑器提供了一种更高级的调试流的方法;从 2017 年 5 月 11 日开始,该编辑器包括一个用于流调试的专门插件,名为 Java Stream Debugger。 4

如果您正在阅读这本书,并且没有使用 IntelliJ IDEA 作为编辑器来测试代码,您可以跳过这一节,为您选择的编辑器研究一个 Steam 调试器插件。这本书主要关注 Java 语言,为了方便起见,在这里增加了这一部分。

要使用 Java 流调试器,必须在定义流处理链的行上放置一个断点。在图 8-4 中,你可以看到一段代码,代表正在调试中执行的Song实例流的处理,在第 44 行有一个断点暂停了执行。当执行暂停时,可以通过单击红色矩形中的按钮来打开流调试器视图。

img/463938_2_En_8_Fig4_HTML.jpg

图 8-4

按钮来启动 Java 流调试器

如果您单击上图中的 debugger 按钮,将会出现一个弹出窗口,其中包含流处理的每个操作的选项卡。在图 8-5 中,你可以看到标签和它们的方法加了下划线并相互链接。

img/463938_2_En_8_Fig5_HTML.jpg

图 8-5

Java 流调试器窗口

在操作选项卡中,左侧的文本框包含原始流中的元素。右边的文本框包含结果流及其元素。本章中的下图显示了各种操作的选项卡。对于减少元素数量或改变元素顺序的操作,存在从一组元素到另一组元素的线。第一个map(..)方法将歌曲标题转换成大写版本。第二个map(..)方法以分钟为单位转换歌曲的持续时间,并返回一个整数流。

distinct(..)方法产生一个新的流,该流只包含前一个流中的不同元素,这个操作的效果在调试器和图 8-6 中描述得非常好。

img/463938_2_En_8_Fig6_HTML.jpg

图 8-6

IntelliJ IDEA 流调试器中的distinct()操作

下一个操作是sorted(),它将对由distinct()操作返回的流中的条目进行排序。在调试器和图 8-7 中也描述了元素的重新排序和将它们添加到新的流中。

img/463938_2_En_8_Fig7_HTML.jpg

图 8-7

IntelliJ IDEA 流调试器中的sorted()操作

在调试器中检查结果后,即使您想继续执行,这也是不可能的,因为原始流和结果流中的所有元素实际上都被调试器使用了,所以控制台中将会显示以下异常:

OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
    at java.base/java.util.stream.AbstractPipeline.<init>(AbstractPipeline.java:203)
    at java.base/java.util.stream.ReferencePipeline.<init>(ReferencePipeline.java:96)
    at java.base/java.util.stream.ReferencePipeline$StatelessOp.<init>(ReferencePipeline.java:800)
    at java.base/java.util.stream.ReferencePipeline$3.<init>(ReferencePipeline.java:191)
    at java.base/java.util.stream.ReferencePipeline.map(ReferencePipeline.java:190)
    at chapter.eigth/com.apress.bgn.eigth.StreamDebugerDemo.main(StreamDebugerDemo.java:45)

摘要

读完这一章并运行提供的代码样本后,应该很明显为什么 Stream API 如此棒了。我个人最喜欢三样东西:

  • 可以编写更紧凑、更简单的代码来解决问题,而不会失去可读性(可以避免 ifs 和循环)

  • 只要从性能角度考虑,在没有 Java 8 之前所需的样板代码的情况下,数据的并行处理是可能的

  • 可以用函数式编程风格编写代码

此外,stream API 更像是一种声明性的编程方式,因为大多数 stream 方法接受类型为Consumer<T>、Predicate<T>、Supplier<T>、Function<T>等的参数,这些参数声明应该为每个 Stream 元素做什么,但是除非 Stream 上有元素,否则不会显式调用这些方法。

使用 Java Stream API 编写代码就像设计一台 Rube Goldberg 机器。在一个事件启动整个装置之前,什么都不会发生。Rube Goldberg 机器被有意设计成以间接和过于复杂的方式执行简单的任务。根据您试图解决的问题,使用流编写的代码也可能变得复杂。最后,作为一名开发人员,你需要决定在多大程度上依赖流。

本章还讲述了如何使用Optional<T>实例来避免NullPointerExceptions和编写if语句。

读完这一章后,你应该对以下内容有了很好的了解:

  • 如何从集合中创建顺序流和并行流

  • 空流有什么用

  • 关于流要记住的术语:

    • 元素序列

    • 述语

    • 消费者

    • 供应者

    • 方法参考

    • 来源

    • 聚合操作

    • 中间操作

    • 终端操作

    • 管道铺设

    • 内部自动迭代

  • 如何创建和使用Optional<T>实例

九、调试、测试和日志

开发工作不仅仅要求你为一个问题设计解决方案并为其编写代码。为了确保您的解决方案能够解决问题,您必须对其进行测试。测试包括确保组成您的解决方案的每个组件在预期和意外情况下都能正常运行。

测试代码最实用的方法是通过记录中间变量的值,并在特定情况下在控制台中打印出来。

当解决方案很复杂时,调试提供了暂停执行和检查变量状态的机会。调试有时会涉及到断点,并且需要一个 IDE。顾名思义,断点是应用暂停执行和检查变量的地方。

在确保您的解决方案符合需求之后,您必须记录它,尤其是当正在解决的问题需要复杂的代码来解决时。或者如果您的解决方案可能是其他应用的先决条件,那么您有责任向其他开发人员解释如何使用它。

这一章将会介绍一些实现这些的方法,因为这些是开发人员的关键才能。

排除故障

调试是发现并解决计算机程序中的缺陷或问题的过程。有更多的调试策略,根据应用的复杂性,可以使用一种或多种。这里列出了这些技术:

  • 记录流程中涉及的对象的中间状态并分析日志文件

  • 使用断点来暂停程序的执行并检查进程中涉及的对象的中间状态的交互式调试

  • 测试

  • 应用或系统级监控

  • 内存转储项目分析是一种动态程序分析形式,它测量程序占用的内存、使用的 CPU、方法调用的持续时间等等。

先说最简单的调试方式:日志。

记录

在现实世界中,伐木是一个破坏性的过程,是对树木进行砍伐和加工以生产木材。在软件编程中,日志记录意味着写日志文件,以后可以用来识别代码中的问题。记录信息最简单的方法是使用System.out.print*(..)方法族,如图 9-1 所示。

img/463938_2_En_9_Fig1_HTML.jpg

图 9-1

System.out.print法族

本章中的示例使用了一个类层次结构,这些类提供了对整数数组进行排序的方法。图 9-2 中描述了类的层次结构。

img/463938_2_En_9_Fig2_HTML.jpg

图 9-2

分类层次结构排序

在下一个代码示例中,MergeSort类的内容被修改,以添加System.out.print(..)语句来记录算法的步骤。

Merge-Sort 是一种性能优于冒泡排序(在前一章介绍过)的排序算法的名字。Merge-Sort 将数组排序描述为以下一系列步骤:

  • 数组被分成两半,每一半再被分成两半,直到得到的数组是一个容易排序的数组,然后排序后的数组被重复合并,直到结果是一个排序后的数组。

这种重复分割数组直到排序成为可管理的操作的方法被称为 divide et impera ,也被称为分治。有更多的算法遵循相同的方法来解决问题,合并排序只是本书将讨论的第一个算法。在图 9-3 中,你可以看到合并排序算法的每一步都发生了什么。

img/463938_2_En_9_Fig3_HTML.jpg

图 9-3

合并排序算法步骤

在算法的每一步中,识别数组的中间索引。然后调用sort(..)方法来处理被索引从中间分开的数组。这种情况一直持续到没有中间索引为止,因为数组只有一个元素。也就是调用merge(..)方法的时候;除了合并数组片段之外,它还在合并过程中对它们进行排序。

图 9-3 以与System.out.print(..)语句生成的输出非常相似的方式描述了该算法。既然提到该算法是基于分治法,图 9-4 更好地显示了运算的顺序。

img/463938_2_En_9_Fig4_HTML.jpg

图 9-4

合并排序算法步骤显示为树

为了编写模拟合并排序算法的代码,我们需要编写两个方法sort(array, low, high)和merge(array, low, high, middle)。清单 9-1 中描述了提议的实施方案。

package com.apress.bgn.nine.algs;

import java.util.logging.Logger;

public class MergeSort implements IntSorter {
    private static final Logger log = Logger.getLogger(MergeSort.class.getName());

    public void sort(int[] arr, int low, int high) {
        if (low < high) {

            int middle = (low + high) / 2;

            //sort lower half of the interval
            sort(arr, low, middle);

            //sort upper half of the interval
            sort(arr, middle + 1, high);

            // merge the two intervals
            merge(arr, low, high, middle);
        }
    }

    private void merge(int arr[], int low, int high, int middle) {
        int leftLength = middle - low + 1;
        int rightLength = high - middle;

        int left[] = new int[leftLength];
        int right[] = new int[rightLength];

        for (int i = 0; i < leftLength; ++i) {
            left[i] = arr[low + i];
        }
        for (int i = 0; i < rightLength; ++i) {
            right[i] = arr[middle + 1 + i];
        }

        int i = 0, j = 0;

        int k = low;
        while (i < leftLength && j < rightLength) {
            if (left[i] <= right[j]) {
                arr[k] = left[i];
                i++;
            } else {
                arr[k] = right[j];
                j++;
            }
            k++;
        }

        while (i < leftLength) {
            arr[k] = left[i];
            i++;
            k++;
        }

        while (j < rightLength) {
            arr[k] = right[j];
            j++;
            k++;
        }
    }
}

Listing 9-1Merge-Sort Proposed Implementation

用System.out.print记录

前面的代码可能看起来很吓人,但是它确实做了图 9-3 中描述的事情。虽然需要很多变量来引用所有的索引,这些索引用于以适当的顺序排列我们的元素。为了确保我们的解决方案被正确实现,查看每个方法被调用的值和被处理的数组片段将会很有用。我们可以通过简单地修改我们的方法并添加一些System.out.print语句来做到这一点,如清单 9-2 所示。

package com.apress.bgn.nine.algs;

public class MergeSort implements IntSorter {

    public void sort(int[] arr, int low, int high) {
        System.out.print("Call sort of [low,high]: [" + low + " " + high + "] ");
        for (int i = low; i <= high; ++i) {
            System.out.print(arr[i] + " ");
        }
        System.out.println();

        if (low < high) {

            int middle = (low + high) / 2;

            //sort lower half of the interval
            sort(arr, low, middle);

            //sort upper half of the interval
            sort(arr, middle + 1, high);

            // merge the two intervals
            merge(arr, low, high, middle);
        }
    }

    private void merge(int arr[], int low, int high, int middle) {
        int leftLength = middle - low + 1;
        int rightLength = high - middle;

        int left[] = new int[leftLength];
        int right[] = new int[rightLength];

        for (int i = 0; i < leftLength; ++i) {
            left[i] = arr[low + i];
        }
        for (int i = 0; i < rightLength; ++i) {
            right[i] = arr[middle + 1 + i];
        }

        int i = 0, j = 0;

        int k = low;
        while (i < leftLength && j < rightLength) {
            if (left[i] <= right[j]) {
                arr[k] = left[i];
                i++;
            } else {
                arr[k] = right[j];
                j++;
            }
            k++;
        }

        while (i < leftLength) {
            arr[k] = left[i];
            i++;
            k++;
        }

        while (j < rightLength) {
            arr[k] = right[j];
            j++;
            k++;
        }
        System.out.print("Called merge of [low, high, middle]: [" + low + " " + high + " " + middle + "]) ");
        for (int z = low; z <= high; ++z) {
            System.out.print(arr[z] + " ");
        }
        System.out.println();
    }
}

Listing 9-2Merge-Sort Proposed Implementation with Logging Using System.out.print Statements

System.out.print(..)和System.out.println(..)语句的组合格式化输出,以显示算法的进度。为了测试输出,我们需要一个包含执行算法的main(..)方法的类,类似于清单 9-3 中描述的那种。

package com.apress.bgn.nine;

import com.apress.bgn.nine.algs.IntSorter;
import com.apress.bgn.nine.algs.MergeSort;
import java.util.Arrays;

public class SortingDemo {

    public static void main(String... args) {
        int arr[] = {5,1,4,2,3};

        IntSorter mergeSort = new MergeSort();
        mergeSort.sort(arr, 0, arr.length - 1);

        System.out.print("Sorted: ");
        Arrays.stream(arr).forEach(i -> System.out.print(i+ " "));
    }

}

Listing 9-3Main Class to Execute the Merge-Sort Proposed Implementation

如果我们运行前面的类,提供给方法sort(..)和merge(..)的参数将打印在控制台中。被排序的值和被合并的数组片段也是如此。输出应该如清单 9-4 所示。

Call sort of [low,high]: [0 4] 5 1 4 2 3
Call sort of [low,high]: [0 2] 5 1 4
Call sort of [low,high]: [0 1] 5 1
Call sort of [low,high]: [0 0] 5
Call sort of [low,high]: [1 1] 1
Called merge of [low, high, middle]: [0 1 0]) 1 5
Call sort of [low,high]: [2 2] 4
Called merge of [low, high, middle]: [0 2 1]) 1 4 5
Call sort of [low,high]: [3 4] 2 3
Call sort of [low,high]: [3 3] 2
Call sort of [low,high]: [4 4] 3
Called merge of [low, high, middle]: [3 4 3]) 2 3
Called merge of [low, high, middle]: [0 4 2]) 1 2 3 4 5
Sorted: 1 2 3 4 5

Listing 9-4Values Being Printed During the Execution of the Merge-Sort Proposed Implementation

您可以看到控制台输出与图 9-3 中描述的算法步骤相匹配,因此输出清楚地证明了解决方案的预期效果。

虽然一切看起来都很好,但是这段代码有一个问题:每次调用sort(..)方法时,都会执行那些打印语句。

如果排序只是一个更复杂的解决方案的一个步骤,那么输出并不是真正必要的,甚至会污染更大的解决方案的输出。此外,如果数组非常大,打印输出可能会影响整个解决方案的性能。

因此,应该考虑一种不同的方法,一种可以定制并决定是否打印输出的方法。这就是日志库的用武之地。

用 JUL 记录

JUL 是 JDK 提供的日志后端的名称,是java.util.logging的缩写。JDK 提供了自己的记录器类,这些类被分组到这个包中。一个Logger实例用于编写消息。创建Logger实例时,应该为其提供一个名称,并通过调用在不同级别打印消息的专用方法来打印日志消息。对于 JUL 模块,这里列出了级别及其范围,但是其他日志库也有类似的日志级别。

  • 应该使用 OFF 来关闭所有日志记录

  • 严重最高级别,消息指示严重故障

  • 警告表示由于潜在问题,此消息正在打印中

  • INFO 表示这是一条信息性消息

  • CONFIG 表示这是一条包含配置信息的消息

  • FINE 表示这是一条提供跟踪信息的消息

  • FINER 表示这是一条相当详细的跟踪消息

  • FINEST 表示这是一条非常详细的跟踪消息

  • 应该打印所有日志消息

记录器可使用XML或properties文件进行配置,其输出可定向至外部文件。对于前面介绍的代码示例,MergeSort类中的所有System.out.print语句都被 logger 调用替换了。清单 9-5 描述了运行算法的主类。

package com.apress.bgn.nine;

// some imports omitted
import java.util.logging.Level;
import java.util.logging.LogManager;
import java.util.logging.Logger;

public class SortingJulDemo {

    private static final Logger log = Logger.getLogger(SortingJulDemo.class.getName());

    static {
        try {
            LogManager logManager = LogManager.getLogManager();
            logManager.readConfiguration(new FileInputStream("./chapter09/logging-jul/src/main/resources/logging.properties"));
        } catch (IOException exception) {
            log.log(Level.SEVERE, "Error in loading configuration", exception);
        }
    }

    public static void main(String... args) {
        int arr[] = {5,1,4,2,3};

        final StringBuilder sb = new StringBuilder("Sorting  an array with merge sort: ");
        Arrays.stream(arr).forEach(i -> sb.append(i).append(" "));
        log.info(sb.toString());

        IntSorter mergeSort = new MergeSort();
        mergeSort.sort(arr, 0, arr.length - 1);

        final StringBuilder sb2 = new StringBuilder("Sorted: ");
        Arrays.stream(arr).forEach(i -> sb2.append(i).append( " "));
        log.info(sb2.toString());
    }
}

Listing 9-5Main Class to Run the Merge-Sort Proposed Implementation with JUL Logging Statements

这个类中的日志语句不多。

类的主体从Logger实例的声明和初始化开始。实例不是通过调用构造函数创建的,而是通过调用在Logger类中声明的getLogger(..)静态方法获得的。此方法查找名称作为参数提供的记录器实例,如果找到,则返回该实例,否则创建并返回具有该名称的实例。在本例中,记录器实例的名称是完全限定的类名,通过调用SortingJulDemo.class.getName().获得

在这个语句之后,有一个用于从logging.properties文件配置记录器的static块。该文件的内容如清单 9-6 所示。

handlers=java.util.logging.ConsoleHandler
java.util.logging.ConsoleHandler.level=ALL
java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter
java.util.logging.SimpleFormatter.format=[%1$tF %1$tT] [%4$-4s] %5$s %n

Listing 9-6Properties Used to Configure the JUL Logger Declared in the logging.properties File

该字段包含格式为propertyName=propertyValue的值列表,表示 JUL 记录器的配置。它们的值指定了以下内容:

  • 用来打印日志消息的类:java.util.logging.ConsoleHandler在控制台中打印消息。

  • 用于格式化日志消息的类:java.util.logging.SimpleFormatter

  • 打印日志信息的模板:[%1$tF %1$tT] [%4$-4s] %5$s %n

  • 打印的日志消息的级别,在本例中,所有级别的日志消息都是由于ALL值的缘故。

通过调用静态方法Logger.getLogger(..)来创建Logger实例。推荐的做法是将记录器命名为它记录消息的类。在没有任何附加配置的情况下,每条打印有log.info(..)的消息前面都会打印完整的系统日期、完整的类名和方法名。可以想象,结果相当冗长,这就是派上用场的logging.properties文件和从中配置的LogManager。LogManager读取定制Logger实例的配置。

对于这一部分,所有的System.out.print语句都被替换为MergeSort类中的记录器调用。引入了一个StringBuilder来构造更长的消息,然后用log.info([message]),编写它们,这相当于调用log.log(Level.INFO, [message])。该算法的结果代码如清单 9-7 所示。

package com.apress.bgn.nine.algs;

import java.util.logging.Logger;

public class MergeSort implements IntSorter {
    private static final Logger log = Logger.getLogger(MergeSort.class.getName());

    public void sort(int[] arr, int low, int high) {
        StringBuilder sb = new StringBuilder("Call sort of ")
                .append("[low,high]: [")
                    .append(low).append(" ").append(high)
                .append("] ");
        for (int i = low; i <= high; ++i) {
            sb.append(arr[i]).append(" ");
        }
        log.info(sb.toString());

        if (low < high) {

            int middle = (low + high) / 2;

            //sort lower half of the interval
            sort(arr, low, middle);

            //sort upper half of the interval
            sort(arr, middle + 1, high);

            // merge the two intervals
            merge(arr, low, high, middle);
        }
    }

    private void merge(int arr[], int low, int high, int middle) {
        int leftLength = middle - low + 1;
        int rightLength = high - middle;

        int left[] = new int[leftLength];
        int right[] = new int[rightLength];

        for (int i = 0; i < leftLength; ++i) {
            left[i] = arr[low + i];
        }
        for (int i = 0; i < rightLength; ++i) {
            right[i] = arr[middle + 1 + i];
        }

        int i = 0, j = 0;

        int k = low;
        while (i < leftLength && j < rightLength) {
            if (left[i] <= right[j]) {
                arr[k] = left[i];
                i++;
            } else {
                arr[k] = right[j];
                j++;
            }
            k++;
        }

        while (i < leftLength) {
            arr[k] = left[i];
            i++;
            k++;
        }

        while (j < rightLength) {
            arr[k] = right[j];
            j++;
            k++;
        }
        StringBuilder sb = new StringBuilder("Called merge of [low, high, middle]: [")
                    .append(low).append(" ").append(high).append(" ").append(middle)
                .append("]) ");
        for (int z = low; z <= high; ++z) {
            sb.append(arr[z]).append(" ");
        }
        log.info(sb.toString());
    }
}

Listing 9-7Merge-Sort Proposed Implementation with Logging Using JUL Statements

运行SortingJulDemo会产生清单 9-8 中所示的输出。

[2021-06-06 11:36:06] [INFO] Sorting  an array with merge sort: 5 1 4 2 3
[2021-06-06 11:36:06] [INFO] Call sort of [low,high]: [0 4] 5 1 4 2 3
[2021-06-06 11:36:06] [INFO] Call sort of [low,high]: [0 2] 5 1 4
[2021-06-06 11:36:06] [INFO] Call sort of [low,high]: [0 1] 5 1
[2021-06-06 11:36:06] [INFO] Call sort of [low,high]: [0 0] 5
[2021-06-06 11:36:06] [INFO] Call sort of [low,high]: [1 1] 1
[2021-06-06 11:36:06] [INFO] Called merge of [low, high, middle]: [0 1 0]) 1 5
[2021-06-06 11:36:06] [INFO] Call sort of [low,high]: [2 2] 4
[2021-06-06 11:36:06] [INFO] Called merge of [low, high, middle]: [0 2 1]) 1 4 5
[2021-06-06 11:36:06] [INFO] Call sort of [low,high]: [3 4] 2 3
[2021-06-06 11:36:06] [INFO] Call sort of [low,high]: [3 3] 2
[2021-06-06 11:36:06] [INFO] Call sort of [low,high]: [4 4] 3
[2021-06-06 11:36:06] [INFO] Called merge of [low, high, middle]: [3 4 3]) 2 3
[2021-06-06 11:36:06] [INFO] Called merge of [low, high, middle]: [0 4 2]) 1 2 3 4 5
[2021-06-06 11:36:06] [INFO] Sorted: 1 2 3 4 5

Listing 9-8Values Being Printed During the Execution of the Merge-Sort Proposed Implementation When Logging is Done Using JUL with a Custom Configuration

没有定制如何显示日志消息的静态初始化块,用于指定在哪里打印日志消息的默认类是java.util.logging.ConsoleHandler,java.util.logging.SimpleFormatter配置了由jdk.internal.logger.SimpleConsoleLogger. Formatting.DEFAULT_FORMAT声明的相当冗长的默认格式。

这个常量的值是%1$tb %1$td, %1$tY %1$tl:%1$tM:%1$tS %1$Tp %2$s%n%4$s: %5$s%6$s%n,这使得日志记录器在日志消息前面加上一行,其中包含以可读方式格式化的系统日期和时间、完整的类名、方法名,以及一个包含日志级别的新行。要测试这一点,只需注释static初始化块并运行SortingJulDemo类。控制台中的日志消息现在被打印出来,如清单 9-9 所示。

Jun 06, 2021 11:40:46 AM com.apress.bgn.nine.SortingJulDemo main
INFO: Sorting  an array with merge sort: 5 1 4 2 3
Jun 06, 2021 11:40:46 AM com.apress.bgn.nine.algs.MergeSort sort
INFO: Call sort of [low,high]: [0 4] 5 1 4 2 3
Jun 06, 2021 11:40:46 AM com.apress.bgn.nine.algs.MergeSort sort
INFO: Call sort of [low,high]: [0 2] 5 1 4
Jun 06, 2021 11:40:46 AM com.apress.bgn.nine.algs.MergeSort sort
INFO: Call sort of [low,high]: [0 1] 5 1
Jun 06, 2021 11:40:46 AM com.apress.bgn.nine.algs.MergeSort sort
INFO: Call sort of [low,high]: [0 0] 5
# other log messages omitted

Listing 9-9Values Being Printed During the Execution of the Merge-Sort Proposed Implementation When Logging is Done Using JUL with the Default Configuration

除了 thew SimpleFormatter之外,还有另一个名为XMLFormatter的类可以用来格式化日志消息,它将消息格式化为 XML(可扩展标记语言)。编写数据的 XML 格式是由一组规则定义的,这些规则用于编码人类可读和机器可读的数据。此外,这组规则使得验证和发现错误变得容易。 1 因为对于 XML 来说,将消息写入控制台是没有意义的,所以应该使用FileHandler类将日志消息定向到一个文件。清单 9-10 中描述了要添加到配置文件中的修改。

handlers=java.util.logging.FileHandler
java.util.logging.FileHandler.pattern=chapter09/out/chapter09-log.xml
.level=ALL
java.util.logging.ConsoleHandler.formatter=java.util.logging.XMLFormatter

Listing 9-10Properties Used to Configure the JUL Logger to Write Log Messages as XML to a File

当运行SortingJulDemo类时,使用清单 9-10 中所示内容的配置文件,在chapter09/out下生成一个名为chapter09-log.xml的文件,其中包含类似于清单 9-11 中所示的条目:

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE log SYSTEM "logger.dtd">
<log>
    <record>
      <date>2021-06-06T11:03:44.200054Z</date>
      <millis>1622977424200</millis>
      <nanos>54000</nanos>
      <sequence>0</sequence>
      <logger>com.apress.bgn.nine.SortingJulDemo</logger>
      <level>INFO</level>
      <class>com.apress.bgn.nine.SortingJulDemo</class>
      <method>main</method>
      <thread>1</thread>
      <message>Sorting  an array with merge sort: 5 1 4 2 3 </message>
    </record>
<!-- other log messages omitted-->
</log>

Listing 9-11Logging Messages as XML

日志输出也可以通过提供一个定制类来定制,唯一的条件是该类扩展java.util.logging.Formatter类,或者它的任何 JDK 子类。

在前面的代码示例中,只使用了log.info(..)调用,因为代码非常简单;几乎没有意外发生的空间(不涉及可能不可用的外部资源)。

可以修改代码以允许用户插入数组的元素。当用户不提供任何数据时处理这种情况的代码和当用户插入错误数据时处理这种情况的代码应该添加到类中。例如,如果用户没有提供任何数据,应该打印一条SEVERE日志消息,应用应该终止。如果用户引入了无效数据,则应该使用有效数据,并且应该为非整数元素输出警告。这意味着SortingJulDemo类的变化如清单 9-12 所示。

package com.apress.bgn.nine;

// imports omitted

public class SortingJulDemo {

    private static final Logger log = Logger.getLogger(SortingJulDemo.class.getName());

    static {
        try {
            LogManager logManager = LogManager.getLogManager();
            logManager.readConfiguration(new FileInputStream("./chapter09/logging-jul/src/main/resources/logging.properties"));
        } catch (IOException exception) {
            log.log(Level.SEVERE, "Error in loading configuration", exception);
        }
    }

    public static void main(String... args) {
        if (args.length == 0) {
            log.severe("No data to sort!");
            return;
        }
        int[] arr = getInts(args);

        final StringBuilder sb = new StringBuilder("Sorting  an array with merge sort: ");
        Arrays.stream(arr).forEach(i -> sb.append(i).append(" "));
        log.info(sb.toString());

        IntSorter mergeSort = new MergeSort();
        mergeSort.sort(arr, 0, arr.length - 1);

        final StringBuilder sb2 = new StringBuilder("Sorted: ");
        Arrays.stream(arr).forEach(i -> sb2.append(i).append( " "));
        log.info(sb2.toString());
    }

    /**
     * Transforms a String[] to an int[] array
     * @param args
     * @return an array of integers
     */
    private static int[] getInts(String[] args) {
        List<Integer> list = new ArrayList<>();
        for (String arg : args) {
            try {
                int toInt = Integer.parseInt(arg);
                list.add(toInt);
            } catch (NumberFormatException nfe) {
                log.warning("Element " + arg + " is not an integer and cannot be added to the array!");
            }
        }
        int[] arr = new int[list.size()];
        int j = 0;
        for (Integer elem : list) {
            arr[j++] = elem;
        }
        return arr;
    }
}

Listing 9-12SortingJulDemo
Using an Array of Elements Provided as Argument for the main(..) Method

正如您所看到的,arr数组不再被硬编码在main(..)方法中,但是该方法作为参数接收的值变成了要排序的数组,并通过getInts(..)方法从String值转换为int值。执行这个程序的人可以从命令行提供参数,但是因为我们使用的是 IntelliJ IDEA,所以有一个更简单的方法。如果您现在运行该程序而不提供任何参数,控制台中将显示以下内容:

[2021-06-06 12:16:14] [SEVERE] No data to sort!

执行在这里停止,因为没有要排序的东西。由于您可能已经运行过这个类几次,IntelliJ IDEA 可能已经为您创建了一个启动器配置,您可以自定义它并为执行提供参数。只需看一下图 9-5 并尝试编辑您的配置,如那里所示,通过添加推荐值作为程序参数。

img/463938_2_En_9_Fig5_HTML.jpg

图 9-5

用于SortingJulDemo类的 IntelliJ IDEA 启动器

在配置了默认控制台日志记录的情况下运行这个版本的SortingJulDemo会产生一些额外的日志消息,如清单 9-13 所示。

[2021-06-06 12:21:35] [WARNING] Element a is not an integer and cannot be added to the array!
[2021-06-06 12:21:35] [WARNING] Element b is not an integer and cannot be added to the array!
[2021-06-06 12:21:35] [WARNING] Element - is not an integer and cannot be added to the array!
[2021-06-06 12:21:35] [WARNING] Element ds is not an integer and cannot be added to the array!
[2021-06-06 12:21:35] [INFO] Sorting an array with merge sort: 5 3 2 1 4
[2021-06-06 12:21:35] [INFO] Call sort of [low,high]: [0 4] 5 3 2 1 4
# other log messages omitted

Listing 9-13Logging Messages of Level WARNING Being Shown During Execution of the New Version of SortingJulDemo

我们在上一节中提到,在某些情况下,写日志会影响性能。当应用在生产系统中运行时,我们可能希望细化日志配置,过滤掉不太重要的日志消息,只保留那些通知问题风险的消息。在前面的配置示例中,有一个配置行可以打印所有日志消息:

java.util.logging.ConsoleHandler.level=ALL

或者适用于任何java.util.logging.Handler子类的更通用的格式:

.level=ALL

如果该属性值更改为OFF,则不会打印任何内容。日志级别分配有整数值,这些值可用于比较消息的严重性。通常,如果您配置了特定级别的消息,也会打印更严重的消息。因此,如果我们将该属性设置为INFO,WARNING消息也将被打印。消息严重性级别的值在java.util.logging.Level类中定义,如果在编辑器中打开该类,可以看到分配给它们的整数值,如清单 9-14 所示。

package java.util.logging;
// import statements omitted

public class Level implements java.io.Serializable {

    public static final Level OFF = new Level("OFF",Integer.MAX_VALUE, defaultBundle);
    public static final Level SEVERE = new Level("SEVERE",1000, defaultBundle);
    public static final Level WARNING = new Level("WARNING", 900, defaultBundle);
    public static final Level INFO = new Level("INFO", 800, defaultBundle);
    public static final Level CONFIG = new Level("CONFIG", 700, defaultBundle);
    public static final Level FINE = new Level("FINE", 500, defaultBundle);
    public static final Level FINER = new Level("FINER", 400, defaultBundle);
    public static final Level FINEST = new Level("FINEST", 300, defaultBundle);
    public static final Level ALL = new Level("ALL", Integer.MIN_VALUE, defaultBundle);
    // other comments and code omitted
}

Listing 9-14The Integer Valued Specific to the Log Levels

在之前的配置中,通过将.level=ALL更改为.level=WARNING,我们可以看到警告和严重级别的所有日志消息。使用前面的参数运行SortingJulDemo类,我们应该只看到WARNING级别的消息,如清单 9-15 所示。

[2021-06-06 17:12:29] [WARNING] Element a is not an integer and cannot be added to the array!
[2021-06-06 17:12:29] [WARNING] Element b is not an integer and cannot be added to the array!
[2021-06-06 17:12:29] [WARNING] Element - is not an integer and cannot be added to the array!
[2021-06-06 17:12:29] [WARNING] Element ds is not an integer and cannot be added to the array!

Listing 9-15Only Logging Messages of Level WARNING Being Shown During Execution of the SortingJulDemo

要定义日志消息格式,有更多的方法:可以使用系统属性,或者通过编程方式,可以在 logger 实例上实例化和设置格式化程序。这实际上取决于应用的具体情况。然而,这不会在书中涉及,如果你有兴趣阅读更多关于 JUL 的 Java 日志,我推荐这个教程: https://www.vogella.com/tutorials/Logging/article.html 。这是因为众所周知,与其他日志库相比,JUL 的性能较差。您必须考虑的另一件事是,如果您正在构建的应用是一个具有许多依赖项的复杂应用,这些依赖项可能会使用不同的日志库。你如何配置和使用它们?这就是日志外观证明有用的地方。下一节将向您展示如何使用最著名的 Java 日志门面:SLF4J。

使用 SLF4J 和 Logback 进行记录

最著名的 Java 日志 facade 是 Java 的简单日志 Facade(SLF4J), 2 ,它是各种日志框架的日志抽象。这意味着在您的代码中,您将使用 SLF4J 接口和类,并且在幕后,所有的工作将由类路径中的具体日志实现来完成。最精彩的部分?您可以随时更改日志实现,您的代码仍将正确编译和执行,并且不需要对其进行任何更改。

在本章到目前为止涵盖的代码示例中,代码与 JUL 相关联;如果我们出于某种原因想要更改日志库,我们也需要更改现有的代码。第一步是更改我们的代码,以使用 SLF4J 应用编程接口(API)。 3 使用 SLF4J 的另一个好处是,如果日志配置文件在类路径中,那么配置会被自动读取。这意味着我们在 JUL 需要的LogManager初始化块对于 SLF4J 是不需要的,只要配置文件是根据所使用的具体日志实现的标准命名的。本节从清单 9-5 中的主SortingJulDemo类到清单 9-16 中所示的SortingSlf4jDemo的转换开始,用 SLF4J 特有的语句替换 JUL 配置和日志语句。

package com.apress.bgn.nine;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// other imports omitted

public class SortingSlf4jDemo {

    private static final Logger log = LoggerFactory.getLogger(SortingSlf4jDemo.class);

    public static void main(String... args) {
        if (args.length == 0) {
            log.error("No data to sort!");
            return;
        }

        final StringBuilder sb = new StringBuilder ("Sorting an array with merge sort: ");
        Arrays.stream(arr).forEach(i -> sb.append(i).append(" "));
        log.debug(sb.toString());

        IntSorter mergeSort = new MergeSort();
        mergeSort.sort(arr, 0, arr.length - 1);

        final StringBuilder sb2 = new StringBuilder("Sorted: ");
        Arrays.stream(arr).forEach(i -> sb2.append(i).append( " "));
        log.info(sb2.toString());
    }
}

Listing 9-16The SortingSlf4jDemo Class

SLf4J 定义了一个 API,它映射到一个日志库提供的具体实现,这个日志库还没有提到。SLf4J 日志语句看起来非常相似,但是日志级别略有不同。以下列表解释了最常见的 SLf4J 日志语句:

  • log.error(..)用于记录ERROR级别的消息;通常,当应用出现严重故障,无法继续正常执行时,会使用这些消息。这个方法有不止一种形式,异常和对象可以作为参数传递给它,这样就可以评估应用在失败时的状态。

  • log.warn(..)用于记录WARN级别的消息;通常会打印这些消息,通知应用不能正常运行,并且可能有理由担心,与前面的方法一样,它有多种形式,并且可以将异常和对象作为参数传递,以便更好地评估应用的当前状态。

  • log.info(..)用于记录INFO级别的消息;这种类型的消息是信息性的,让用户知道一切正常,并按预期工作。

  • log.debug(..)用于记录DEBUG级别的消息;通常,这些消息用于打印应用的中间状态,以检查事情是否按预期进行,并且最终在出现故障的情况下,您可以跟踪应用对象的演变。

  • log.trace(..)用于记录TRACE级别的消息;这种类型的消息是非常不重要的信息。

本例中使用的日志具体实现称为 Logback, 4 ,它被选作本书的前一版本,因为在 Java 9 中引入模块后,它是唯一一个与 SLF4J 一起工作的库。

Logback 被视为 Log4j, 5 的继任者,这是另一种流行的日志实现。

img/463938_2_En_9_Figb_HTML.jpg 趣闻 Log4j、SLF4j、Logback 都是由同一个人创立的:Ceki Gülcü。他目前从事后两者。至于 Log4j,它目前正被 Log4j2 取代,这是一个对其前身进行了重大改进的升级。

Logback 在本地实现了 SLF4J,不需要添加另一个桥库,并且速度更快,因为 Logback 内部已经被重写,以便在关键执行点上执行得更快。在修改我们的类以使用 SLF4J 之后,我们所要做的就是添加 Logback 作为我们应用的依赖项,并在src/main/resources目录下添加一个配置文件。配置文件可以用 XML 或 Groovy 编写,标准要求将其命名为logback.xml。清单 9-17 描述了本节示例文件的内容:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} %-5level %logger{5} - %msg%n</pattern>
        </encoder>
    </appender>
    <logger name="com.apress.bgn.nine" level="debug"/>
    <root level="info">
        <appender-ref ref="console" />
    </root>
</configuration>

Listing 9-17The Contents of the logback.xml Configuration File

ch.qos.logback.core.ConsoleAppender类在控制台中写入日志消息,而<pattern>元素值定义了日志消息的格式。Lobgack 可以通过将包名缩短为它们的首字母来格式化完全限定的类名;因此,它允许在不丢失细节的情况下进行紧凑的记录。这使得 Logback 成为目前 Java 开发世界中最受欢迎的日志实现之一。

如果包名由多个部分组成,则减少到每个部分的第一个字母。MergeSort类中的日志调用都被替换成了log.debug(..),因为这些消息是中间的,并不是真正的信息,只是应用在流程执行期间使用的对象的状态样本。可以使用<root>元素将应用的一般日志记录级别设置为所需的级别,但是可以使用<logger>元素为类、包或包的子集设置不同的日志记录级别。

使用前面的配置,运行SortingSlf4jDemo会产生如清单 9-18 所示的输出。

18:59:32.473 WARN  c.a.b.n.SortingSlf4jDemo - Element a is not an integer and cannot be added to the array!
18:59:32.475 WARN  c.a.b.n.SortingSlf4jDemo - Element b is not an integer and cannot be added to the array!
18:59:32.475 WARN  c.a.b.n.SortingSlf4jDemo - Element - is not an integer and cannot be added to the array!
18:59:32.475 WARN  c.a.b.n.SortingSlf4jDemo - Element ds is not an integer and cannot be added to the array!
18:59:32.477 DEBUG c.a.b.n.SortingSlf4jDemo - Sorting  an array with merge sort: 5 3 2 1 4
18:59:32.479 DEBUG c.a.b.n.a.MergeSort - Call sort of : [0 4] 5 3 2 1 4
18:59:32.479 DEBUG c.a.b.n.a.MergeSort - Call sort of : [0 2] 5 3 2
18:59:32.479 DEBUG c.a.b.n.a.MergeSort - Call sort of : [0 1] 5 3
18:59:32.480 DEBUG c.a.b.n.a.MergeSort - Call sort of : [0 0] 5
18:59:32.480 DEBUG c.a.b.n.a.MergeSort - Call sort of : [1 1] 3
18:59:32.480 DEBUG c.a.b.n.a.MergeSort - Called merge of: [0 1 0],) 3 5
18:59:32.480 DEBUG c.a.b.n.a.MergeSort - Call sort of : [2 2] 2
18:59:32.480 DEBUG c.a.b.n.a.MergeSort - Called merge of: [0 2 1],) 2 3 5
18:59:32.480 DEBUG c.a.b.n.a.MergeSort - Call sort of : [3 4] 1 4
18:59:32.480 DEBUG c.a.b.n.a.MergeSort - Call sort of : [3 3] 1
18:59:32.480 DEBUG c.a.b.n.a.MergeSort - Call sort of : [4 4] 4
18:59:32.480 DEBUG c.a.b.n.a.MergeSort - Called merge of: [3 4 3],) 1 4
18:59:32.480 DEBUG c.a.b.n.a.MergeSort - Called merge of: [0 4 2],) 1 2 3 4 5
18:59:32.481 INFO  c.a.b.n.SortingSlf4jDemo - Sorted: 1 2 3 4 5

Listing 9-18Log Messages Printed by SLF4J + Logback

如您所见,完全限定类名com.apress.bgn.nine.SortingSlf4jDemo被缩短为c.a.b.n.SortingSlf4jDemo。配置文件可以作为 VM 参数提供给程序,这意味着可以在外部配置日志记录格式。当启动这个类时,如果你想提供一个不同的日志文件,就使用-Dlogback.configurationFile=\temp\ext-logback.xml作为 VM 参数。

Logback 也可以将输出定向到文件,我们所要做的就是使用ch.qos.logback.core.FileAppender类添加一个配置,并通过在<root>配置中添加一个<appender>元素将输出定向到文件。清单 9-19 中描述了一个配置示例。

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <appender name="file" class="ch.qos.logback.core.FileAppender">
        <file>chapter09/logging-slf4j/out/output.log</file>
        <append>true</append>
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} %-5level %logger{5} - %msg%n</pattern>
        </encoder>
    </appender>
    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <charset>UTF-8</charset>
            <pattern>%d{HH:mm:ss.SSS} %-5level %logger{5} - %msg%n</pattern>
        </encoder>
    </appender>
    <logger name="com.apress.bgn.nine" level="debug"/>
    <root level="info">
        <appender-ref ref="file"/>
        <appender-ref ref="console" />
    </root>
</configuration>

Listing 9-19Logback Configuration to Direct Log Messages to a File

在前面的示例中,保留了原始配置,以便在控制台中也打印日志消息。因此,证明了日志消息可以同时指向两个目的地。

如果日志文件变得太大而无法打开怎么办?有一种方法可以解决这个问题。另一个名为ch.qos.logback.core.rolling.RollingFileAppender的类可以被配置为写入一个文件,直到达到配置的大小限制,然后启动另一个文件。RollingFileAppender需要两个参数:

  • 实现ch.qos.logback.core.rolling.RollingPolicy的类型的实例,它提供写入新日志文件的功能(操作也称为翻转)

  • 以及一个实现ch.qos.logback.core.rolling.TriggeringPolicy<E>的类型实例,该实例配置翻转发生的条件。

此外,实现这两个接口的类型的单个实例可用于配置记录器。滚动日志文件意味着根据配置重命名日志文件,通常是将文件的最后访问日期添加到其名称中,然后创建一个新的日志文件,并配置名为的日志文件(没有日期后缀,以明确这是当前转储日志的文件)。清单 9-20 中描述了这种回退配置。

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true">

    <appender name="r_file" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>chapter09/logging-slf4j/out/output.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>chapter09/logging-slf4j/out/output_%d{yyyy-MM-dd}.%i.log</fileNamePattern>

            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>10MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <maxHistory>30</maxHistory>
        </rollingPolicy>

        <encoder>
            <charset>UTF-8</charset>
            <pattern>%d{HH:mm:ss.SSS} %-5level %logger{5} - %msg%n</pattern>
        </encoder>
    </appender>

    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} %-5level %logger{5} - %msg%n</pattern>
        </encoder>
    </appender>

    <logger name="com.apress.bgn.nine" level="info"/>

    <root level="info">
        <appender-ref ref="r_file"/>
        <appender-ref ref="console" />
    </root>
</configuration>

Listing 9-20Logback Configuration to Direct Log Messages to a File of a Reasonable Limit

在前面的配置中,<file>元素配置日志文件的位置和名称。

使用<fileNamePattern>元素,<rollingPolicy>元素配置当日志消息不再写入日志文件时,日志文件将接收的名称。

例如,在前面的配置中,output.log文件将被重命名为output_2020-07-22.log,然后将为应用运行的第二天创建一个新的output.log文件。

<timeBasedFileNamingAndTriggeringPolicy>元素配置何时应该创建新的日志文件,以及在创建新文件之前output.log文件应该有多大。上例中配置的大小是 10MB。如果日志文件在一天结束前超过 10MB,该文件将被重命名为output_2018-07-22.1.log。一个索引被添加到名称中,并且一个新的output.log被创建。

<maxHistory>元素配置日志文件的生命周期,在本例中是 30 天。

如果使用得当,日志记录是一个强大的工具。如果使用不当,很容易导致性能问题。此外,记录所有内容并不是真正有用的,因为在一个大的日志文件中寻找问题就像大海捞针一样。

另一件值得注意的事情是,在前面的代码中,StringBuilder实例用于构造大的日志消息,这些消息将在特定的级别打印。如果通过配置禁用了该级别的日志记录,会发生什么情况?如果您认为创建这些消息会消耗大量的时间和内存,即使它们没有被记录,那么您是对的。那我们该怎么办?SLF4J 的创建者也考虑到了这一点,并添加了一些方法来测试是否启用了某个日志级别,以及创建详细日志消息的语句是否可以封装在一个if语句中。也就是说,SortingSlf4jDemo.main(..)方法可以通过重写变得更有效,如清单 9-21 所示。

package com.apress.bgn.nine;
// import statements omitted

public class SortingSlf4jDemo {

    private static final Logger log = LoggerFactory.getLogger(SortingSlf4jDemo.class);

    public static void main(String... args) {
         if (args.length == 0) {
            log.error("No data to sort!");
            return;
        }
        int[] arr = getInts(args);

        if (log.isDebugEnabled()) {
            final StringBuilder sb = new StringBuilder("Sorting  an array with merge sort: ");
            Arrays.stream(arr).forEach(i -> sb.append(i).append(" "));
            log.debug(sb.toString());
        }

        IntSorter mergeSort = new MergeSort();
        mergeSort.sort(arr, 0, arr.length - 1);

        if (log.isInfoEnabled()) {
            final StringBuilder sb2 = new StringBuilder("Sorted: ");
            Arrays.stream(arr).forEach(i -> sb2.append(i).append(" "));
            log.info(sb2.toString());
        }
    }
}

Listing 9-21Logging Efficiently in Class SortingSlf4jDemo

在前面的代码示例中,如果com.apress.bgn.nine包的 SLF4J 配置被设置为 info,那么以使用合并排序对数组进行排序开始的消息:… 将不再被创建和打印,因为log.isDebugEnabled()返回false,所以包含在if语句中的代码将不再被执行。Logger类包含任何日志级别的if..Enabled()方法。

这就是本节中关于日志的全部内容。请记住,您应该适度地使用它,当您决定在循环中记录消息时要特别注意,对于大型应用,请始终使用日志外观;在 Java 中,对于 99%的项目来说,这个门面就是 SLF4J。

使用断言调试

调试代码的另一种方法是使用断言。如果你还记得关于 Java 关键字的部分,你可能还记得assert关键字。assert关键字用于编写一个断言语句,它只是对你在程序执行上的假设的一个测试。在前面的例子中,我们让用户为我们的排序程序提供输入,所以为了让我们的程序做正确的事情,假设用户将提供正确的输入;这意味着数组的大小大于 1,因为对单个数字运行算法没有意义。那么这个断言在代码中是什么样子的呢?这个问题的答案在清单 9-22 中描述。

package com.apress.bgn.nine;
// other import statements omitted
import static com.apress.bgn.nine.SortingSlf4jDemo.getInts;

public class AssertionDemo {
    public static void main(String... args) {
        int[] arr = getInts(args);

        assert arr.length > 1;

        IntSorter mergeSort = new QuickSort();
        mergeSort.sort(arr, 0, arr.length - 1);

        final StringBuilder sb2 = new StringBuilder("Sorted: ");
        Arrays.stream(arr).forEach(i -> sb2.append(i).append(" "));
        System.out.println(sb2);
    }
}

Listing 9-22Asserting the Size of User-Provided Array

即使我们在程序中有一个断言语句,也可以在不向程序提供任何参数的情况下运行前面的代码。不出所料,它什么也不做,因为没有要排序的数组。

img/463938_2_En_9_Figc_HTML.jpg这样做的原因是需要使用 VM 参数来启用断言:-ea。

要指定此参数,您可以在从命令行执行时将其添加到命令中,但由于我们到目前为止一直使用编辑器,您可以将其添加到 IntelliJ IDEA launcher 的 VM options 文本框中,如图 9-6 所示。

img/463938_2_En_9_Fig6_HTML.jpg

图 9-6

设置了-ea VM 参数的 AssertionDemo 类的 IntelliJ IDEA 启动器

当断言被启用时,运行前面的代码以抛出java.lang.AssertionError结束,因为断言的表达式被评估为false,因为当没有提供参数时,显然arr.length不大于 1。断言有两种形式。有一个简单的形式,当他们有刚刚的表达式来评估,假设来测试:

assertion [expression];

在这种情况下,被抛出的java.lang.AssertionError只打印程序当前运行中断言假设的那一行,以及模块和完整的类名:

Exception in thread "main" java.lang.AssertionError
at chapter.nine.slf4j/com.apress.bgn.ch9.AssertionDemo.main(AssertionDemo.java:48)

最复杂的断言形式是添加另一个要计算的表达式或栈中要使用的值,以告诉用户哪个假设是错误的。

assertion [expression1] : [expression2];

所以如果我们替换:

assert arr.length > 1;

随着

assert arr.length > 1 : "Not enough data to sort!";

当抛出java.lang.AssertionError时,现在它也描述了“没有足够的数据来排序!”消息,它说明了断言语句阻止其余代码执行的原因。

Exception in thread "main" java.lang.AssertionError: Not enough data to sort!
at chapter.nine.slf4j/com.apress.bgn.nine.AssertionDemo.main(AssertionDemo.java:47)

或者我们可以只打印数组的大小:

assert arr.length > 1 : arr.length;

或者两者都有:

assert arr.length > 1 : "Not enough data to sort! Number of values: " + arr.length;

断言可以用在需要调试的代码的前后。在前一种情况下,断言被用作执行的前提条件,因为断言的失败阻止了代码的执行。

断言也可以用作后置条件,以测试执行一段代码的结果。

在前面的代码片段中,断言用于测试用户提供的输入的正确性。在这种情况下,无论是否启用断言,都应该遵守有效输入的限制。当然,如果我们的数组是空的或者只包含一个元素,这不是问题,因为算法没有被执行,这不会导致技术上的失败。在使用断言编写代码时,有一些规则需要遵守或注意,如下所示:

  • 断言不应该被用来检查提供给公共方法的参数的正确性。参数的正确性应该在代码中进行测试,并且应该抛出适当的RuntimeException。验证公共方法参数不应该是不可避免的。

    不幸的是,为了简单起见,前面展示断言如何工作的代码样本打破了这条规则。毕竟,main(..) is 方法的有效参数的存在是使用断言来检查的。

  • 断言不应该被用来做你的应用正常运行所需的工作。其主要原因很明显,断言在默认情况下是禁用的,禁用断言会导致代码无法执行,因此应用的其余部分实际上将因为缺少代码而无法正常工作。假设在前面的例子中没有向main(..)方法提供参数,断言可以用来用默认值初始化正在处理的数组。但这并不意味着你应该!类似下一行的代码是不好的,因为禁用断言会用默认值删除数组的初始化。

  • 出于性能原因,不要在断言中使用评估开销很大的表达式。这条规则不需要解释,即使断言在默认情况下是禁用的,想象一下有人在生产应用中错误地启用了它们,那将是非常不幸的,不是吗?下一个示例显示了一个断言表达式,如果不支持,则在等待五分钟后用默认值初始化数组。接下来的断言打破了所有三个规则。

assert arr.length > 1 : arr = new int[]{1, 2, 3};

assert arr.length > 1 : sleepFiveMinsThenInit.apply(5L);

//the function body
Function<Long, int[]> sleepFiveMinsThenInit  =  aLong ->   {
    try {Thread.sleep(Duration.ofMinutes(aLong).toMillis()); } catch (InterruptedException e) {}
    return new int[]{1, 2, 3};
};

如果你对使用断言感兴趣,只要记住这三条规则,你应该没问题。

逐步调试

如果您不想写日志消息或使用断言,但仍想在程序执行过程中检查变量值,有一种方法可以做到这一点,这在前面的章节中提到过:使用断点暂停执行,使用 IDE 检查变量内容或执行简单的语句来检查您的程序是否按预期执行。

断点是在代码的可执行行上设置的标记(不是注释行,也不是空行,也不是声明)。在 IntelliJ IDEA 中,要设置断点,你只需点击你感兴趣的行上的装订线区域,或者选择该行并从运行菜单中选择切换行断点。当断点就位时,在装订线部分的行上会出现一个红色气泡。在图 9-7 中,你可以看到 IntelliJ IDEA 中的一些断点。

img/463938_2_En_9_Fig7_HTML.jpg

图 9-7

IntelliJ IDEA 断点

一旦断点就位,当应用在调试模式下运行时,它将在每一个标记行暂停。在暂停期间,您可以逐步继续执行,检查变量的值,甚至在正在运行的应用的上下文中评估表达式。IntelliJ IDEA 在这方面很有帮助,因为它向您展示了当前正在执行的每一行代码中每个变量的内容。在图 9-8 中,SortingSlf4jDemo类正在调试模式下运行,并在执行过程中使用断点暂停。

img/463938_2_En_9_Fig8_HTML.jpg

图 9-8

IntelliJ IDEA SortingSlf4jDemo类在执行过程中暂停

要在调试模式下运行应用,不需要正常启动启动器,您可以通过单击绿色的 bug 形图标(标有 1。在上图中)按钮,它紧挨着用于正常运行应用的绿色三角形按钮。

应用运行并停止在标有断点的第一行。从那时起,开发人员可以做以下事情:

  • 通过读取该处编辑器描述的值,检查断点所在行中使用的变量值。

  • 点击调试部分的绿色三角形,继续执行,直到下一个断点,在上图中用 2 标记。

  • 点击调试部分的红色方形按钮,停止执行,在上图中标记为 2。

  • 点击调试部分的红色气泡切割对角线按钮,禁用所有断点,在上图中标记为 2。

  • 通过点击调试器部分中带有 90 度角按钮的蓝色箭头,继续执行下一行代码,在上图中标记为 3。

  • 在调试器部分,点击带有向下蓝色箭头的按钮,在当前代码行输入方法,继续执行,在前面的图片中用 3 标记。

    向下的红色箭头用于进入第三方库提供的方法。Intellij 试图找到该方法的源代码。如果它找不到源代码,那么它可能会向您显示基于字节码/库自动生成的存根。蓝色箭头只跳转到项目中的方法。

  • 在调试器部分,点击带有向上蓝色箭头的按钮,跳出当前方法,继续执行,在上图中标记为 3。

  • 或者点击调试器部分中带有一个指向光标符号的斜箭头的按钮,继续执行到光标所指的行,在上图中标记为 3。

  • 通过将您自己的表情添加到手表部分来评估它们,在上图中标记为 4。唯一的条件是表达式只使用在断点行的上下文中可访问的变量(例如,是同一方法体或类体的一部分,并且访问器不重要,私有字段也可以被检查)。

在当前运行的应用的上下文中评估表达式的另一种方法是在当前暂停执行的文件上单击右键,并从打开的菜单中选择选项评估表达式。打开一个对话窗口,在这里可以编写复杂的表达式并当场求值,如图 9-9 所示。

img/463938_2_En_9_Fig9_HTML.jpg

图 9-9

调试会话期间的 IntelliJ IDEA 表达式计算

大多数 Java 智能编辑器提供了在调试模式下运行 Java 应用的方法;只是要确保不时清理你的手表区。如果 watches 部分中添加的表达式的计算开销很大,则可能会影响应用的性能。此外,要注意使用流的表达式,因为这些可能会使应用失败,正如上一章所证明的那样。

使用 Java 工具检查正在运行的应用

除了编译 Java 代码和执行或打包 Java 字节代码的可执行文件之外,JDK 还提供了一组实用的可执行文件,可用于调试和检查正在运行的 Java 应用的状态。本节涵盖了其中最有用的部分。事不宜迟,我们开始吧!

jps

正在运行的 Java 应用有一个唯一的进程 id。这就是操作系统跟踪同时并行运行的所有应用的方式。你可以在 Windows 的 Process Explorer 和 macOs 的 Activity Monitor 等实用程序中看到进程 id,但如果你对在控制台中工作感到足够舒适,你可能会更喜欢使用jps(Java 虚拟机进程状态工具的缩写),这是 JDK 提供的可执行文件,因为它只关注 Java 进程。

当从控制台调用jps时,将列出所有 Java 进程 id,以及主类名或应用 API 公开的一些细节,这将帮助您识别正在运行的应用。当应用崩溃,但进程仍处于挂起状态时,这很有用。当应用使用文件或网络端口等资源时,这可能会很痛苦,因为它可能会阻塞这些资源并阻止您使用它们。当在我的电脑(我有一台 Mac)上执行jps时,我看到正在运行的 Java 进程如下:

> jps
41066
51099 Launcher
51100 SortingSlf4jDemo
51101 Jps

从前面的清单中可以看到,jps在输出中包含了它自己,因为它毕竟是一个 Java 进程。id 为 51100 的进程显然是SortingSlf4jDemo类的执行。51099 进程是一个启动器应用,IntelliJ IDEA 用它来启动SortingSlf4jDemo类的执行。41066 进程没有任何描述,但是此时我可以自己识别该进程,因为我知道我已经打开了 IntelliJ IDEA,它本身就是一个 Java 应用。能够知道进程 id 的好处是,当它们最终挂起和阻塞资源时,您可以杀死它们。让我们假设由执行SortingSlf4jDemo开始的进程最终被挂起。要终止所有操作系统的进程,请提供一个版本的 kill 命令。对于 macOS 和 Linux,您应该执行以下命令:

kill -9 [process_id]

对于这个例子,如果我调用kill -9 51100然后调用 jps,我可以看到SortingSlf4jDemo进程不再列出。

> jps
41066
51099 Launcher
51183 Jps

我仍然有启动器进程,但是它是 IntelliJ IDEA 的子进程,所以没有必要杀死它,因为下次我在 IDE 中运行主类时,该进程将再次启动。

对于这个特定的目的来说,jps是一个非常简单的工具,但是有时当应用被安装在服务器上时,只需要很少的设置,它可能就是你所拥有的全部。所以知道它的存在很好。

jcmd

jcmd 是另一个有用的 JDK 工具。它可以用于向 JVM 发送诊断命令请求,这有助于对 JVM 和正在运行的 Java 应用进行故障排除和诊断。它必须在运行 JVM 的同一台机器上使用,不带任何参数调用它的结果是,它显示当前在机器上运行的所有 Java 进程,包括它自己。在进程 id 旁边,jcmd还显示用来开始它们执行的命令。

> jcmd
51205 org.jetbrains.jps.cmdline.Launcher /Applications/IntelliJ IDEA 2021.1 EAP.app/Contents/lib/util.jar:
...
# IntelliJ IDEA command details omitted
51206 chapter.nine.slf4j/com.apress.bgn.nine.SortingSlf4jDemo 5 a 3 - 2 b 1 ds 4
51207 jdk.jcmd/sun.tools.jcmd.JCmd

当使用 Java 进程 id 和文本help作为参数运行jcmd时,它会显示您可以在该进程上使用的所有附加命令。如果应用当前正在运行并且没有使用断点暂停,这将起作用。当我写这篇文章的时候,SortingSlf4jDemo正在暂停;此外,它的执行花费的时间太少,以至于jcmd无法使用。另一个为运行对 100.000.000 随机生成的数字进行排序的BigSortingSlf4jDemo类而创建的 Java 进程被用作产生清单 9-23 中描述的输出的示例。

> jcmd 51301 help
51301:
The following commands are available:
Compiler.CodeHeap_Analytics
Compiler.codecache
Compiler.codelist
Compiler.directives_add
Compiler.directives_clear
Compiler.directives_print
Compiler.directives_remove
Compiler.queue
GC.class_histogram
GC.finalizer_info
GC.heap_dump
GC.heap_info
GC.run
GC.run_finalization
JFR.check
JFR.configure
JFR.dump
JFR.start
JFR.stop
JVMTI.agent_load
JVMTI.data_dump
ManagementAgent.start
ManagementAgent.start_local
ManagementAgent.status
ManagementAgent.stop
Thread.print
VM.class_hierarchy
VM.classloader_stats
VM.classloaders
VM.command_line
VM.dynlibs
VM.events
VM.flags
VM.info
VM.log
VM.metaspace
VM.native_memory
VM.print_touched_methods
VM.set_flag
VM.stringtable
VM.symboltable
VM.system_properties
VM.systemdictionary
VM.uptime
VM.version
help

Listing 9-23The Output of jcmd [pid] help on a Java Process Doing Some Serious Work

本书的目的不是要涵盖所有这些,因为这些都是 Java 的高级特性,但是您可能对每个命令的范围有一个基本的概念。例如,在清单 9-24 中,您可以看到调用jcmd 51301 GC.heap_info的输出:

> jcmd 51301 GC.heap_info
51301:
 garbage-first heap   total 3923968K, used 2534849K [0x0000000700000000, 0x0000000800000000)
  region size 2048K, 766 young (1568768K), 1 survivors (2048K)
 Metaspace       used 5386K, committed 5504K, reserved 1056768K
  class space    used 595K, committed 640K, reserved 1048576K

Listing 9-24The Output of jcmd [pid] GC.heap_info on a Java Process Doing Some Serious Work

如果您还记得,在第章 5 中,我们讨论了 JVM 使用的不同类型的内存,而堆是存储应用使用的所有对象的内存。这个命令打印堆的详细信息,使用了多少,保留了多少,区域有多大,等等。所有这些细节将在第章 13 中详细介绍。

jconsole

jconsole 是一个 JDK 工具,可以用来检查各种 JVM 统计数据。要使用它,您只需从命令行启动它,并将其连接到已经运行的 Java 应用。这个应用非常有用,因为它可以监控本地和远程 JVM。它还可以监控和管理应用。应用必须公开一个端口供jconsole连接。

要启动 Java 应用并为外部应用公开一个端口,只需使用以下 VM 参数启动应用:

-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=1044

transport=dt_socket指示 JVM 调试器将通过套接字进行连接。address=1044参数通知它端口号将是 1044。port可以是任何大于 1024 的端口,因为它们受到操作系统的限制。suspend=y指示 JVM 暂停执行,直到一个调试器如jconsole连接到它。为了避免这种情况,应该使用suspend=n。

对于我们的简单示例,考虑到我们将使用jconsole在同一台机器上调试 Java 应用,我们不需要所有这些。我们只需要从命令行启动jconsole,查看本地进程部分,并确定我们感兴趣调试的 Java 进程。

在图 9-10 中,您可以看到第一个jconsole对话框。

img/463938_2_En_9_Fig10_HTML.jpg

图 9-10

jconsole第一个对话窗口

当进程在本地运行时,可以很容易地识别它,因为它将使用模块和完全限定的主类名来命名。对于像我们这样简单的应用,我们需要做一些调整,以确保我们可以在应用运行期间看到一些统计数据。添加了一些Thread.sleep(..)语句来暂停执行,以便jconsole进行连接。此外,我们将使用大量数据来确保统计数据的相关性。清单 9-25 中描述了BigSortingSlf4jDemo级。

package com.apress.bgn.nine;

// import statements omitted

public class BigSortingSlf4jDemo {

    private static final Logger log = LoggerFactory.getLogger(BigSortingSlf4jDemo.class);

    public static void main(String... args) throws InterruptedException {
        Thread.sleep(3000);

        Random random = new Random(5);
        IntStream intStream = random.ints(100_000_000,0,350);

        int[] arr =  intStream.toArray();

        if (log.isDebugEnabled()) {
            final StringBuilder sb = new StringBuilder("Sorting  an array with merge sort: ");
            Arrays.stream(arr).forEach(i -> sb.append(i).append(" "));
            log.debug(sb.toString());
        }

        Thread.sleep(3000);

        IntSorter mergeSort = new MergeSort();
        mergeSort.sort(arr, 0, arr.length - 1);

        if (log.isInfoEnabled()) {
            final StringBuilder sb2 = new StringBuilder("Sorted: ");
            Arrays.stream(arr).forEach(i -> sb2.append(i).append(" "));
            log.info(sb2.toString());
        }
    }
}

Listing 9-25The Contents of the BigSortingSlf4jDemo Class

通过这种修改,该类可以正常执行并连接jconsole到它。成功连接后,类似图 9-11 中的窗口打开,显示 JVM 内存消耗、加载的类线程数和 CPU 使用情况的图表。

img/463938_2_En_9_Fig11_HTML.jpg

图 9-11

jconsole统计窗口

这些统计信息中的每一个都有一个选项卡提供更多的信息,在更复杂的应用中,这些信息可以用来提高性能、识别潜在的问题,甚至估计应用在期望情况下的行为。对于我们的小应用来说,jconsole图并没有透露太多,但是如果你真的想看到有价值的统计数据,安装一个像 mucommander 6 这样的应用,不用关闭它就可以使用一段时间,然后将jconsole连接到它,玩得开心。

使用 jmc

是 JDK 任务控制中心的简称。jmc命令启动一个高级 Oracle 应用,用于调试和分析正在运行的应用的 JVM 统计数据。它的官方描述是“JMC 是一个工具套件,用于管理、监控、分析和排除 Java 应用的故障,从版本 7 开始,它成为 JDK 实用工具家族的一部分。”(如果您感兴趣,可以在 Oracle 官方网站上阅读更多相关内容。)

与以前的工具类似,这个实用程序识别当前运行的 Java 进程,并提供检查它们在执行期间的特定时间需要多少内存、在给定时刻有多少线程并行运行、JVM 加载的类以及运行 JAVA 应用需要多少 cpu 处理能力的可能性。JMC 有一个更友好的界面,它最重要的组件之一是 Java 飞行记录器,可以用来记录应用运行时的所有 JVM 活动。在定制的执行时间内收集的所有数据对于诊断和分析应用都很有用。

为了在应用运行时对其进行检查,我们通过从命令行运行jmc来打开 JMC,然后根据与前面相同的规则选择我们认为是运行BigSortingSlf4jDemo主类的进程。我们寻找一个包含模块名和完全分类类名的进程名,当我们找到它时,右键单击它,并选择启动 JMX 控制台。您应该会看到类似于图 9-12 中描绘的图像。

img/463938_2_En_9_Fig12_HTML.png

图 9-12

jmc JMX 控制台

您可能已经注意到,界面肯定更加友好,提供的统计数据肯定更加详细。使用 JMC,应用和 JVM 在运行期间发生的一切都可以被记录下来,并在以后进行分析,即使应用已经停止运行。图底部的内存选项卡提供了大量关于应用使用的内存的信息,包括哪些类型的对象正在占用它。记录 Java 进程的详细信息需要从

-XX:+UnlockCommercialFeatures -XX:+FlightRecorder.

OpenJDK 和 early access JDKs 没有商业功能或飞行记录器。这些是 Oracle JDK 的一部分,设计用于商业目的,需要付费订阅。

JMC 的主题对这一节来说太深奥了;或许可以写一整本书来讲述它的用法以及如何解释统计数据,所以我们就此打住。 7

访问 Java 流程 API

除了 Jigsaw 模块之外,Java 9 还有很多其他的改进,其中之一是一个新的改进的流程 API。Java Process API 允许您启动、检索信息和管理本机操作系统进程。操纵进程的能力在以前的 Java 版本中就有了,但是还很初级。清单 9-26 展示了在 Java 5:

package com.apress.bgn.nine;
// import section omitted

public class ProcessCreationDemo {

    private static final Logger log =
            LoggerFactory.getLogger(ProcessCreationDemo.class);
    public static void main(String... args) {
        try {
            Process exec = Runtime.getRuntime()
                    .exec(new String[] { "/bin/sh", "-c", "echo Java home: $JAVA_HOME" });
            exec.waitFor();
            InputStream is = exec.getInputStream();
            StringBuilder textBuilder = new StringBuilder();
            Reader reader = new BufferedReader(new InputStreamReader
                    (is, Charset.forName(StandardCharsets.UTF_8.name())));
            try {
                int c = 0;
                while ((c = reader.read()) != -1) {
                    textBuilder.append((char) c);
                }
            } finally {
                reader.close();
            }
            log.info("Process output -> {}", textBuilder.toString());
            log.info("process result: {}", exec.exitValue());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Listing 9-26Creating a Process Using Pre-Java 5 API

截取已启动流程的输出是一件痛苦的事情,您也可以看到我们需要在连接到流程正常输出的InputStream实例周围包装一个BufferedReader实例。

过程 API 让事情变得更加实际。它的核心有几个类和接口,它们的名字都以流程术语开头。到目前为止,我们对 Java 可执行文件所做的工作可以通过编写 Java 代码直接完成。提供访问本地进程的 API 的接口名为 ProcessHandle,是核心 Java java.lang包的一部分。类似于Thread类,有一个名为current的静态方法在这个接口上调用,以检索当前运行进程的ProcessHandle实例。一旦我们有了这个,我们就可以使用它的方法来访问更多的流程细节。ProcessHandle提供了几个静态实用方法来访问本地进程。可以编写 Java 代码来列出计算机上运行的所有进程,并且可以根据各种标准对它们进行排序。清单 2-27 中的这段代码列出了通过运行java命令创建的所有进程。

package com.apress.bgn.nine;
// import section omitted

public class ProcessListingDemo {

    private static final Logger log = LoggerFactory.getLogger(ProcessListingDemo.class);

    public static void main(String... args) {
        Optional<String> currUser = ProcessHandle.current().info().user();

        ProcessHandle.allProcesses().filter(ph -> ph.info().user().equals(currUser) && ph.info().commandLine().isPresent())
                .filter(ph -> ph.info().commandLine().get().contains("java"))
                .forEach(p -> {
                    log.info("PID: " + p.pid());
                    p.info().arguments().ifPresent(s -> Arrays.stream(s).forEach(a -> log.info("   {}", a)));
                    p.info().command().ifPresent(c -> log.info("\t Command: {}", c));
                });
    }
}

Listing 9-27Listing All java Processes Using Java 9 Process API

前面列出的代码通过获取当前正在运行的进程的句柄并调用info()来获取ProcessHandle.Info的实例,该接口声明了一组由ProcessHandleImpl.Info类实现的方法,以访问有关进程的快照信息,作为用于创建进程的命令和参数。运行前面代码的输出打印在控制台上,可能看起来与清单 9-28 中列出的输出非常相似。不同的用户,不同的流程。;)

INFO  c.a.b.n.ProcessListingDemo - PID: 58820
INFO  c.a.b.n.ProcessListingDemo -    -javaagent:/Applications/IntelliJ IDEA.app/Contents/lib/idea_rt.jar=55299:/Applications/IntelliJ IDEA.app/Contents/bin
INFO  c.a.b.n.ProcessListingDemo -    -Dfile.encoding=UTF-8
INFO  c.a.b.n.ProcessListingDemo -    -p
INFO  c.a.b.n.ProcessListingDemo -    /workspace/java-17-for-absolute-beginners/chapter09/processapi/target/classes...*.jar
INFO  c.a.b.n.ProcessListingDemo -    -m
INFO  c.a.b.n.ProcessListingDemo -    chapter.nine.processapi/com.apress.bgn.nine.ProcessListingDemo
INFO  c.a.b.n.ProcessListingDemo -   Command: /Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home/bin/java
INFO  c.a.b.n.ProcessListingDemo - PID: 58819
INFO  c.a.b.n.ProcessListingDemo -    -Xmx700m
INFO  c.a.b.n.ProcessListingDemo -    -Djava.awt.headless=true
// some output omitted
INFO  c.a.b.n.ProcessListingDemo -    -classpath
INFO  c.a.b.n.ProcessListingDemo -    /Applications/IntelliJ IDEA.app/Contents/plugins/java/lib/jps-launcher.jar
INFO  c.a.b.n.ProcessListingDemo -    org.jetbrains.jps.cmdline.Launcher
INFO  c.a.b.n.ProcessListingDemo -    /Applications/IntelliJ IDEA.app/Contents/lib/netty-common-4.1.52.Final...*.jar
INFO  c.a.b.n.ProcessListingDemo -    org.jetbrains.jps.cmdline.BuildMain
INFO  c.a.b.n.ProcessListingDemo -    127.0.0.1
INFO  c.a.b.n.ProcessListingDemo -    52130
INFO  c.a.b.n.ProcessListingDemo -    de98ca31-a7d8-4fe3-b268-44545198d08b
INFO  c.a.b.n.ProcessListingDemo -    /Users/iulianacosmina/Library/Caches/JetBrains/IntelliJIdea2020.3/compile-server
INFO  c.a.b.n.ProcessListingDemo -   Command: /Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home/bin/java

Listing 9-28Output Produced By Running the Code in Listing 9-27

在之前的日志中,只描述了用于运行ProcessListingDemo类的 IntelliJ IDEA 启动器和为运行它而产生的进程,但是输出可能要大得多。一些论点被一起跳过,因为用你自己可以产生的日志来浪费书页是没有用的。然而,如果您永远不会自己运行代码,那么对日志格式的一些描述是必要的。

前面的代码示例大致向您展示了如何访问本机进程并打印关于它们的信息。使用改进的 Java 进程 API,可以创建新的进程,并且可以启动底层操作系统的命令。例如,我们可以创建一个进程来打印JAVA_HOME环境变量的值,并捕获输出以在 IntelliJ 控制台中显示它,如清单 9-29 所示。(这段代码可以在 macOS 和 Linux 上运行,对于 Windows,应该使用等效的 PowerShell 命令。)

package com.apress.bgn.nine;

// other import statements omitted
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class NewApiProcessCreationDemo {
    private static final Logger log = LoggerFactory.getLogger(NewApiProcessCreationDemo.class);

    public static void main(String... args) throws IOException, InterruptedException, ExecutionException {
        ProcessBuilder processBuilder = new ProcessBuilder();
        processBuilder.command("/bin/sh", "-c", "echo Java home: $JAVA_HOME");
        processBuilder.inheritIO();

        Process process = processBuilder.start();
        CompletableFuture<Process> future = process.onExit();
        int result = future.get().exitValue();
        log.info("Process result: " + result);

        CompletableFuture<ProcessHandle> futureHandle = process.toHandle().onExit();
        ProcessHandle processHandle = futureHandle.get();
        log.info("Process ID: {}", processHandle.pid());
        ProcessHandle.Info info = processHandle.info();
        info.arguments().ifPresent(s -> Arrays.stream(s).forEach(a -> log.info("   {}", a)));
        info.command().ifPresent(c -> log.info("\t Command: {}", c));
    }
}

Listing 9-29Java Sample Code to Create a Process

可以通过使用ProcessBuilder的实例来创建新的进程,这些实例接收命令和参数列表作为参数。该类有许多带有不同签名的构造函数和方法,可用于轻松创建和启动进程。inheritIO()方法用于将子流程标准 I/O 的源和目的地设置为与当前流程相同。这意味着流程输出直接打印在控制台上,不需要使用InputStream来读取。Process类中的onExit()方法返回一个CompletableFuture<Process>,可以用来在流程执行结束时访问该流程,以检索流程的退出值。对于正常终止的进程,该值应为 0(零)。ProcessHandle类中的onExit()方法返回一个CompletableFuture<ProcessHandle>,可以用来访问进程,可以用来等待进程终止,也可能触发依赖动作。

当 Java 程序创建一个进程时,该进程就成为创建它的进程的子进程。为了能够列出所有子进程,我们需要确保它们持续一段时间,因为一旦终止,它们显然就不再存在了。清单 9-30 中的代码示例创建了三个相同的进程,每个进程执行三个 linux shell 命令:

  • echo "start"通知流程已经开始执行

  • sleep 3暂停 3 秒钟

  • echo "done."正好在父进程完成其执行之前被执行。

一旦一个进程被启动,它就不能再被控制,所以为了确保子进程真正完成它们的执行,我们将通过添加一个System.in.read();语句让用户按一个键来决定当前进程何时结束。

package com.apress.bgn.nine;

// import statements omitted

public class ThreeProcessesDemo {
    private static final Logger log =
            LoggerFactory.getLogger(ThreeProcessesDemo.class);

    public static void main(String... args) {
        try {
            List<ProcessBuilder> builders = List.of(
                    new ProcessBuilder("/bin/sh", "-c",
                            "echo \"start...\" ; sleep 3; echo \"done.\"").inheritIO(),
                    new ProcessBuilder("/bin/sh", "-c",
                            "echo \"start...\" ; sleep 3; echo \"done.\"").inheritIO(),
                    new ProcessBuilder("/bin/sh", "-c",
                            "echo \"start...\" ; sleep 3; echo \"done.\"").inheritIO()
            );
            builders.parallelStream().forEach(pbs -> {
                try {
                    pbs.start();
                } catch (Exception e) {
                    log.error("Oops, could not start process!", e);
                }
            });
            ProcessHandle ph = ProcessHandle.current();
            ph.children().forEach(pc -> {
                log.info("Child PID: {}", pc.pid());
                pc.parent().ifPresent(parent ->
                        log.info(" Parent PID: {}", parent.pid()));
            });
            System.out.println("Press any key to exit!");
            System.in.read();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Listing 9-30Java Sample Code to Create Three Processes

如您所见,我们将ProcessBuilders分组到一个列表中,并使用并行流处理实例,以确保所有进程几乎同时启动。我们在终止后打印了每个项目的结果,以确保所有项目都正确执行。children()方法返回一个包含 ProcessHandle 实例的流,这些实例对应于当前 Java 进程启动的进程。

为每个子ProcessHandle实例调用了parent()方法,以获得与创建它的进程相对应的ProcessHandle(如果有的话)。当在控制台中运行前面的代码时,您应该会看到类似于清单 9-31 中所示的输出。(如果你在 Mac 或 Linux 上运行的话。Windows 可能不知道被要求做什么。)

start...
start...
start...
INFO  c.a.b.n.ThreeProcessesDemo - Child PID: 59368
INFO  c.a.b.n.ThreeProcessesDemo -  Parent PID: 59365
INFO  c.a.b.n.ThreeProcessesDemo - Child PID: 59366
INFO  c.a.b.n.ThreeProcessesDemo -  Parent PID: 59365
INFO  c.a.b.n.ThreeProcessesDemo - Child PID: 59367
INFO  c.a.b.n.ThreeProcessesDemo -  Parent PID: 59365
Press any key to exit!
done.
done.
done.

Listing 9-31Output of a Java Application That Creates Three Processes

过去,需要在更高级别上处理流程的开发人员需要求助于本机代码。改进的 Java Process API 提供了对运行和衍生过程的更多控制,所以如果您需要它,现在您知道它的存在了。Java 9 中添加的 Java process API 改进的完整列表可以在这里找到: https://docs.oracle.com/javase/9/core/process-api1.htm 。

测试

调试是名为测试的软件过程的一部分,包括识别和纠正代码错误。但是仅仅避免技术错误是不够的,测试一个应用意味着更多。甚至有一个组织为软件测试人员的培训和认证提供了非常好的材料。国际软件测试资格委员会是一个国际化运作的软件测试资格认证组织。它为软件测试建立了一个教学大纲,一个资格等级和指导方针。如果你认为你对软件测试更感兴趣,那么你应该考虑获得 ISTQB 认证。

ISTQB 对测试的定义是“包含所有静态和动态生命周期活动的过程,涉及软件和相关工作产品的计划、准备和评估,以确定它们满足指定的要求,证明它们适合目的并检测缺陷。”

前面是一个技术性的、学术性的定义。我提出的定义是“验证一个实现在预期的时间内以可接受的资源消耗做了它应该做的事情的过程。”

测试是开发过程中必不可少的一部分,应该尽早开始,因为修复缺陷的工作量会随着发现缺陷的时间呈指数增长。 9

在开发阶段,除了编写实际的解决方案,您还可以编写代码来测试您的解决方案。这些测试可以手动运行,也可以在您生成项目时由生成工具运行。编写代码时,除了考虑如何编写代码以使解决方案解决问题之外,还应该考虑如何测试解决方案。这种方法被命名为 TDD ( 测试驱动开发),这是一种编程范式,它指出,在实现解决方案之前,您应该考虑如何测试您的解决方案,因为如果很难测试,它很可能很难实现,很难长期维护,也很难扩展到解决相关问题。

你可以编写的最简单的测试叫做单元测试 ,,它们是测试小功能单元的非常简单的方法。如果单元测试不容易编写,你的设计可能会很糟糕。单元测试是防止失败的第一道防线。如果单元测试失败了,你的解决方案的基础就不好了。

跨越多个组件的测试,测试功能单元之间的通信以及它们相对于预期结果的交互结果,被称为集成测试。

开发人员应该编写的最后一种测试是回归测试,这是定期运行的测试,以确保先前测试的代码在更改后仍能正确执行。这种类型的测试对于由大量开发人员编写代码的大型项目至关重要,因为有时组件之间的依赖关系并不明显,开发人员编写的代码可能会破坏其他人的代码。

本节将只向您展示如何使用名为 JUnit 的 Java 框架编写单元测试,并描述开发人员可以构建的几个典型测试组件,以建立单元测试的上下文。因此,正如我的苏格兰同事所说的那样:让我们开始吧!

测试代码位置

你可能还记得,在章节 3 中解释了java-bgn项目结构。关于测试的讨论必须从项目最底层模块的结构开始,这些模块包含源代码和测试。在图 9-13 中,您可以看到模块的结构,其中包含本节所用模块的源代码和测试代码。

img/463938_2_En_9_Fig13_HTML.jpg

图 9-13

Maven (Gradle too)模块结构

前面示例中描述的结构可以解释如下:

  • src 目录包含项目的所有代码和资源。内容被分成两个目录main和test。
    • main目录包含源代码和应用配置文件,分为两个目录。java目录包含 Java 源代码,resources目录包含配置文件、不可执行的文本文件(可以按照各种格式编写:XML、SQL、CSV 等)、媒体文件、pdf 等。当应用被构建并打包到一个 jar(或 war,或 ear)中时,只考虑 Java 目录中的文件;*.class文件和配置文件一起打包。

    • test目录包含用于测试src目录中源代码的代码。Java 文件保存在java目录下,在resources目录中包含了构建测试上下文所需的配置文件。test目录中的类是项目的一部分,可以访问main目录中声明的类,如章节 3 中的访问器所述。然而,test目录的内容并不是将要交付给客户的项目的一部分。它们的存在只是为了在开发过程中帮助测试应用。test/resources目录中的文件通常会覆盖main/resources中的配置文件,为测试类提供一个隔离的、更小的执行上下文。

构建要测试的应用

对于本节中的示例,我们将构建一个简单的应用,它使用嵌入式 Derby 10 数据库来存储数据。这将是生产数据库。对于测试环境,数据库将被替换为模拟数据库行为的各种伪结构。

这个应用非常简单。一个AccountService实现从输入中获取数据,并使用它来管理Account实例。Account类是一个非常抽象的银行账户的不切实际的实现。它有一个作为账户所有者的holder字段、一个accountNumber字段和一个金额字段。AccountService实现使用AccountRepo实现来执行所有与使用DBConnection实现的Account实例相关的数据库操作。组成这个简单应用的类和接口以及它们之间的关系如图 9-14 所示。

img/463938_2_En_9_Fig14_HTML.png

图 9-14

简单的帐户管理应用组件(如 IntelliJ IDEA 所示)

这些类的实现与本节无关,但是如果您有兴趣,可以在本书的官方资源库中找到完整的代码。所以我们开始测试吧。最简单的方法是编写一个主类并执行一些帐户操作。然而,一旦应用投入生产,那么做是没有用的,因为在其上测试新特性会带来数据损坏的风险。此外,生产数据库通常托管在昂贵的产品上,如 Oracle RDBMS (Oracle 关系数据库管理系统)或 Microsoft SQL Server。它们并不真正适合于开发或测试。目的是在自动化构建过程中自动运行测试,因此内存中或可以实例化的实现更合适。所以让我们从测试AccountRepoImpl开始。

JUnit 简介

JUnit 无疑是 Java 开发世界中使用最多的测试框架。2017 年底,JUnit 5 11 发布,这是这个框架的下一代。它带有一个新的引擎,兼容 Java 9+,并带有许多基于 lambda 的功能。JUnit 提供注释来标记自动执行的测试方法、初始化和销毁测试上下文的注释,以及实际实现测试方法的实用方法。您可以使用多个 JUnit 注释,但是其中五个注释和一个实用程序类代表了 JUnit 框架的核心,这是开始学习测试的最佳地方。下面的列表用简短的描述介绍了每一种方法,以便对如何使用 JUnit 测试您的应用有一个大致的了解:

  • 来自包org.junit.jupiter.api的@BeforeAll用于返回 void 的非私有静态方法,void 用于初始化当前类中所有测试方法使用的对象和变量。在类中的所有测试方法之前,这个方法只被调用一次,所以测试方法不应该修改这些对象,因为它们的状态是共享的,这可能会影响测试结果。最终,由带注释的方法初始化的静态字段可以被声明为 final,因此一旦初始化,就不能再更改。在一个测试类中可以声明不止一个用@BeforeAll注释的方法,但是这有什么意义呢?

  • @AfterAll from package org.junit.jupiter.api是@BeforeAll的对应。它还用于注释返回void的非私有静态方法,它们的目的是破坏测试方法运行的上下文并执行清理操作。

  • 包org.junit.jupiter.api中的@BeforeEach用于返回 void 的非私有、非静态方法,用它标注的方法在每个用@Test标注的方法之前执行。这些方法可用于进一步定制测试上下文,以使用测试方法中用于测试断言的值来填充对象。

  • 包org.junit.jupiter.api中的@AfterEach用于返回 void 的非私有、非静态方法,用它标注的方法在每个用@Test标注的方法之后执行。

  • 来自包org.junit.jupiter.api的@Test用于返回 void 的非私有、非静态方法,用它标注的方法是测试方法。一个测试类可以有一个或多个,这取决于被测试的类。

  • 实用程序类org.junit.jupiter.api.Assertions提供了一组支持在测试中断言条件的方法。

您可能有兴趣知道它存在的另一个注释是@DisplayName。它在与所有其他组件相同的包中声明,并接收一个表示测试显示名称的文本参数,该名称将由 IDE 显示,并显示在由生成工具创建的结果报告中。清单 9-32 显示了一个伪测试类,因此您可以对测试类的外观有所了解。

package com.apress.bgn.nine.pseudo;

import org.junit.jupiter.api.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class PseudoTest {

    private static final Logger log = LoggerFactory.getLogger(PseudoTest.class);

    @BeforeAll
    static void loadCtx() {
        log.info("Loading general test context.");
    }

    @BeforeEach
    void setUp(){
        log.info("Prepare  single test context.");
    }

    @Test
    @DisplayName("test one")
    void testOne() {
        log.info("Executing test one.");
        assertTrue(true);
    }

    @Test
    @DisplayName("test two")
    void testTwo() {
        log.info("Executing test two.");
        assertFalse(false);
    }

    @AfterEach
    void tearDown(){
        log.info("Destroy  single test context.");
    }

    @AfterAll
    static void unloadCtx(){
        log.info("UnLoading general test context.");
    }
}

Listing 9-32Pseudo Test Class Using JUnit Annotations

记住您现在拥有的关于这些注释的信息,当运行这个类时,我们希望每个方法打印的日志消息都按照我们定义的顺序,因为这些方法已经被有策略地放在前面的代码中,所以 JUnit 的执行顺序得到了尊重。唯一不能保证的是测试的执行顺序。通过在test\resources下添加一个名为 junit-platform.properties 的文件,可以并行执行测试,该文件包含以下属性:

junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = same_thread

前一组属性表示顺序执行顶级类的配置参数,但它们的方法是并行的。JUnit 官方文档中提供了更多的配置示例。

大多数 Java 智能编辑器,如 IntelliJ IDEA,都为您提供了一个选项,当您右键单击该类时,就可以这样做。在图 9-15 中,你可以看到在 IntelliJ IDEA 中执行测试类的菜单选项。

img/463938_2_En_9_Fig15_HTML.jpg

图 9-15

在 IntelliJ IDEA 中执行测试类的菜单选项

右键单击类后,从出现的菜单中选择 Run PseudoTest ,测试类被执行。创建了一个启动器,因此您也可以从典型的启动菜单中启动它。测试类也可以在调试和断点中执行。当执行前面的类时,即使测试方法是并行运行的,输出也与匹配前面提到的注释规范的方法的顺序一致。为了确保测试方法被并行执行,记录器被配置为打印线程 id。清单 9-33 中描述了一个样本输出。

[1-worker-1] INFO  c.a.b.n.p.PseudoTest - Loading general test context.
Jun 12, 2021 1:44:13 PM org.junit.jupiter.engine.config.EnumConfigurationParameterConverter get
INFO: Using parallel execution mode 'CONCURRENT' set via the 'junit.jupiter.execution.parallel.mode.default' configuration parameter.
[1-worker-1] INFO  c.a.b.n.p.PseudoTest - Prepare  single test context.
[1-worker-2] INFO  c.a.b.n.p.PseudoTest - Prepare  single test context.
[1-worker-1] INFO  c.a.b.n.p.PseudoTest - Executing test two.
[1-worker-2] INFO  c.a.b.n.p.PseudoTest - Executing test one.
[1-worker-2] INFO  c.a.b.n.p.PseudoTest - Destroy  single test context.
[1-worker-1] INFO  c.a.b.n.p.PseudoTest - Destroy  single test context.
[1-worker-1] INFO  c.a.b.n.p.PseudoTest - UnLoading general test context.

Listing 9-33Output of the Execution of PseudoTest

请注意,有一条关于正在配置的并行执行测试方法的日志消息。testOne()方法包含这个语句:assertTrue(true);,它向您展示了断言方法的样子。真实值被替换为真实测试中的条件。这同样适用于方法textTwo()中的assertFalse(false);断言。这就是我们在本书中能够献给 JUnit 的全部篇幅。我的建议是深入研究它,因为一个开发人员可以编写代码,但是一个好的开发人员知道如何确保它也能工作。

使用假货

一个假的对象是一个有工作实现的对象,但与生产对象不同。为实现这种对象而编写的代码简化了产品中部署的功能。

为了测试AccountRepoImpl类,我们必须用一个FakeDBConnection来代替DerbyDBConnection,它不是由数据库来备份,而是由更简单、更容易访问的东西来备份,比如Map<?,?>。DerbyDBConnection使用一个java.sql.Connection和该包中的其他类在 Derby 数据库上执行数据操作。

FakeDBConnection将实现 DBConnection 接口,这样它可以被传递给一个AccountRepoImpl并且它的所有方法都将在它上面被调用。

编写测试和测试支持类的经验法则是将它们与被测试或替换的对象放在同一个包中,但是放在test/java目录中。这是因为测试类必须访问被测试的类,在module-info.java中不需要额外的配置。使用 fakes 测试应用类的支持类在com.apress.bgn.nine.fake包中声明。

编写测试的另一个经验法则是编写一个方法来测试被测试方法的正确结果,编写一个方法来测试不正确的行为。在出现意外数据的意外情况下,您的应用会以意外的方式运行,因此尽管这看起来很矛盾,但您必须预料到意外情况并为其编写测试。

AccountRepoImpl类实现了在数据库中持久保存或删除Account实例的基本方法。清单 9-34 中描述了该实现。

package com.apress.bgn.nine.repo;

import com.apress.bgn.nine.Account;
import com.apress.bgn.nine.db.DbConnection;

import java.util.List;
import java.util.Optional;

public class AccountRepoImpl implements AccountRepo {

    private DbConnection conn;

    public AccountRepoImpl(DbConnection conn) {
        this.conn = conn;
    }

    @Override
    public Account save(Account account) {
        Account dbAcc = conn.findByHolder(account.getHolder());
        if(dbAcc == null) {
            return conn.insert(account);
        }
        return conn.update(account);
    }

    @Override
    public Optional<Account> findOne(String holder) {
        Account acc = conn.findByHolder(holder);
        if(acc != null) {
            return Optional.of(acc);
        }
        return Optional.empty();
    }

    @Override
    public List<Account> findAll() {
        return conn.findAll();
    }

    @Override
    public int deleteByHolder(String holder) {
        Account acc = conn.findByHolder(holder);
        conn.delete(holder);
        if(acc != null) {
            return 0;
        }
        return 1;
    }
}

Listing 9-34The AccountRepoImpl Implementation

AccountRepoImpl中的deleteByHolder(..)方法用于删除账户。如果条目存在,则删除它并返回 0,否则返回 1。下一段代码描述了deleteByHolder(..)方法。

为了测试这个类,我们需要提供一个模拟数据库连接的DbConnection实现。这就是前面提到的FakeDBConnection的用武之地。代码如清单 9-35 所示。

package com.apress.bgn.nine.fake.db;

import com.apress.bgn.nine.Account;
import com.apress.bgn.nine.db.DBException;
import com.apress.bgn.nine.db.DbConnection;

import java.util.*;

public class FakeDBConnection implements DbConnection {
     // pseudo-database {@code Map<holder, Account>}
    Map<String, Account> database = new HashMap<>();

    @Override
    public void connect() {
        // no implementation needed
    }

    @Override
    public Account insert(Account account) {
        if (database.containsKey(account.getHolder())) {
            throw new DBException("Could not insert " + account);
        }
        database.put(account.getHolder(), account);
        return account;
    }

    @Override
    public Account findByHolder(String holder) {
        return database.get(holder);
    }

    @Override
    public List<Account> findAll() {
        List<Account> result = new ArrayList<>();
        result.addAll(database.values());
        return result;
    }

    @Override
    public Account update(Account account) {
        if (!database.containsKey(account.getHolder())) {
            throw new DBException("Could not find account for " + account.getHolder());
        }
        database.put(account.getHolder(), account);
        return account;
    }

    @Override
    public void delete(String holder) {
        database.remove(holder);
    }

    @Override
    public void disconnect() {
        // no implementation needed
    }
}

Listing 9-35The FakeDBConnection Implementation

FakeDBConnection的行为就像一个连接对象,可以用来将条目保存到数据库中、搜索条目或删除条目,只是它不是由数据库而是由Map<String, Account>来备份。映射键将是持有者的名字,因为在我们的数据库中,持有者的名字被使用,并且是表中一个Account条目的唯一标识符。现在我们有了假对象,我们可以测试我们的AccountRepoImpl是否如预期的那样运行。由于实际原因,本节只测试一种方法,但是完整的代码可以在本书的官方 GitHub repo 上找到。

清单 9-36 显示了一个测试类,它测试验证findOne(..)方法行为的方法。当有符合标准的条目时,它包含一个肯定的测试方法,当没有符合标准的条目时,它包含一个否定的测试方法。

package com.apress.bgn.nine;

// other import statements omitted

import static org.junit.jupiter.api.Assertions.*;

public class FakeAccountRepoTest {
    private static final Logger log = LoggerFactory.getLogger(FakeAccountRepoTest.class);
    private static DbConnection conn;

    private AccountRepo repo;

    @BeforeAll
    static void prepare() {
        conn = new FakeDBConnection();
    }

    @BeforeEach
    public void setUp(){
        repo = new AccountRepoImpl(conn);

        // inserting an entry so we can test update/findOne
        repo.save(new Account("Pedala", 200, "2345"));
    }

    @Test
    public void testFindOneExisting() {
        Optional<Account> expected = repo.findOne("Pedala");
        assertTrue(expected.isPresent());
    }

    @Test
    public void testFindOneNonExisting() {
        Optional<Account> expected = repo.findOne("Dorel");
        assertFalse(expected.isPresent());
    }

        @AfterEach
    void tearDown(){
        // delete the entry
        repo.deleteByHolder("Pedala");
    }

    @AfterAll
    public static void cleanUp(){
        conn = null;
        log.info("All done!");
    }

}

Listing 9-36The FakeAccountRepoTest Test Class

请注意我们是如何创建一个条目并添加到我们的假数据库中的。

既然我们已经确定 repository 类正确地完成了它的工作,下一个要测试的是AccountServiceImpl。为了测试这个类,我们将研究一种不同的方法。Fakes 是有用的,但是从开发时间的角度来看,为一个具有复杂功能的类编写一个 Fakes 是非常不划算的。那么有哪些选择呢?有几个。在下一节中,我们将看看存根。

使用存根

一个存根是一个保存预定义数据并使用它来回答测试调用的对象。AccountServiceImpl的实例使用AccountRepo的实例从数据库中检索数据或将数据保存到数据库中。当编写这个类的单元测试时,每个测试方法必须覆盖服务类中方法的功能,这样我们就可以编写一个存根类来模拟AccountRepo的行为。为了让AccountServiceImpl实例能够使用它,存根必须实现AccountRepo。在本节中,测试将涵盖方法createAccount(..),因为该方法可能会在许多方面失败。因此,可以为它编写不止一个测试方法。清单 9-37 显示了createAccount(..)方法。

package com.apress.bgn.nine.service;
// import section omitted

public class AccountServiceImpl implements AccountService {

    AccountRepo repo;

    public AccountServiceImpl(AccountRepo repo) {
        this.repo = repo;
    }

    @Override
    public Account createAccount(String holder, String accountNumber, String amount) {
        int intAmount;
        try {
            intAmount = Integer.parseInt(amount);
        } catch (NumberFormatException nfe) {
            throw new InvalidDataException("Could not create account with invalid amount!");
        }

        if (accountNumber == null || accountNumber.isEmpty() || accountNumber.length() < 5 || intAmount < 0) {
            throw new InvalidDataException("Could not create account with invalid account number or invalid amount!");
        }

        Optional<Account> existing = repo.findOne(holder);
        if (existing.isPresent()) {
            throw new AccountCreationException("Account already exists for holder " + holder);
        }
        Account acc = new Account(holder, intAmount, accountNumber);
        return repo.save(acc);
    }
    // other code omitted
}

Listing 9-37The AccountServiceImpl#createAccount(..) Method

createAccount(..)方法将持有人姓名、要创建的账号和初始金额作为参数。所有这些都是有意作为String实例提供的,因此方法体包含一点需要认真测试的逻辑。让我们分析前一个方法的行为,并列出所有可能的返回值和返回的异常:

  • 如果amount不是一个数字,则抛出一个InvalidDataException。(InvalidDataException是专门为此项目创建的定制类型的异常,目前与此无关。)

  • 如果accountNumber参数为空,则抛出一个InvalidDataException。

  • 如果accountNumber参数为空,则抛出一个InvalidDataException。

  • 如果accountNumber参数少于 5 个字符,则抛出InvalidDataException。

  • 如果转换为数字的amount参数为负,则抛出InvalidDataException。

  • 如果holder参数的account已经存在,就会抛出一个AccountCreationException。

  • 如果所有的输入都是有效的,并且没有 holder 参数的帐户,则创建一个Account实例,保存到数据库中,并返回结果。

如果我们真的痴迷于测试,我们将不得不为所有这些情况编写一个测试场景。在软件世界中,有一种叫做测试覆盖,的东西,它是一个决定测试用例是否覆盖应用代码以及覆盖多少代码的过程。结果是一个百分比值,公司通常定义一个测试覆盖率百分比 12 来代表应用的质量保证。在展示createAccount(..)方法的测试方法之前,请看一下清单 9-38 ,它展示了回购存根代码。

package com.apress.bgn.nine.service.stub;
// other import statements omitted
import com.apress.bgn.nine.repo.AccountRepo;

public class AccountRepoStub implements AccountRepo {

    private Integer option = 0;

    public synchronized void set(int val) {
        option = val;
    }

    @Override
    public Account save(Account account) {
        return account;
    }

    @Override
    public Optional<Account> findOne(String holder) {
        if (option == 0) {
            return Optional.of(new Account(holder, 100, "22446677"));
        }
        return Optional.empty();
    }

    @Override
    public List<Account> findAll() {
        return List.of(new Account("sample", 100, "22446677"));
    }

    @Override
    public int deleteByHolder(String holder) {
        return option;
    }
}

Listing 9-38The AccountRepoStub Class

option字段可以用来改变存根的行为,以覆盖更多的测试用例。因为我们有一个存根存储库,这意味着并行运行时测试可能会失败,但是对于这个简单存根的例子,它是有效的。

根据所使用的assert*(..)语句,有两种方法可以使用 JUnit 编写测试。清单 9-39 显示了两个否定的测试方法,当一个无效的数量作为参数被提供时,它们验证行为。

package com.apress.bgn.nine.service;

import com.apress.bgn.nine.Account;
import com.apress.bgn.nine.service.stub.AccountRepoStub;
import org.junit.jupiter.api.*;

import static org.junit.jupiter.api.Assertions.*;

public class AccountServiceTest {

    private static AccountRepoStub repo;
    private AccountService service;

    @BeforeAll
    static void prepare() {
        repo = new AccountRepoStub();
    }

    @BeforeEach
    void setUp() {
        service = new AccountServiceImpl(repo);
    }

    @Test
    void testNonNumericAmountVersionOne() {
        assertThrows(InvalidDataException.class,
                () -> {
                    service.createAccount("Gigi", "223311", "2I00");
                });
    }

    @Test
    void testNonNumericAmountVersionTwo() {
        InvalidDataException expected = assertThrows(
                InvalidDataException.class, () -> {
                    service.createAccount("Gigi", "223311", "2I00");
                }
        );
        assertEquals("Could not create account with invalid amount!", expected.getMessage());
    }

    @AfterEach
    void tearDown() {
        repo.set(0);
    }

    @AfterAll
    static void destroy() {
        repo = null;
    }
}

Listing 9-39The AccountServiceTest Unit Test Class Using a Stub Repo

testNonNumericAmountVersionOne()方法利用了接收两个参数的assertThrows(..):当类型为Executable的第二个参数被执行时预期抛出的异常类型。Executable是在org.junit.jupiter.api.function中定义的函数接口,可以在 lambda 表达式中使用,以获得您在清单 9-39 中看到的紧凑测试。

testNonNumericAmountVersionTwo()方法保存assertThrows(..)调用的结果,这也允许测试异常消息,以确保执行流完全按照预期工作。

可以编写类似的方法来测试所有其他服务方法。本书存储库上的AccountServiceTest类描述了一些其他的测试方法。请随意添加您自己的内容,以涵盖被忽略的情况。

本章介绍的最后一项测试技术:使用模拟编写测试。

使用模拟

模拟对象是注册它们收到的调用的对象。在测试的执行过程中,使用 assert utility 方法,测试所有预期动作都在模拟上执行的假设。幸运的是,Mock 的代码不必由开发人员编写,有三个著名的库提供了使用 Mock 进行测试所需的类类型:Mockito、JMock 和 EasyMock。 13 此外,如果你需要模仿静态方法——最常见的原因是糟糕的设计——还有 PowerMock。 14

使用模拟,您可以直接跳到编写测试。清单 9-40 显示了对createAccount(..)方法的两个测试,集中在实际调用其方法的存储库类上,因为存储库类是被 mock 替换的那个。

package com.apress.bgn.nine.mock;
// other import statements omitted
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
public class AccountServiceTest {

    private AccountService service;

    @Mock
    private AccountRepo mockRepo;

    @BeforeEach
    public void checkMocks() {
        assertNotNull(mockRepo);
  service = new AccountServiceImpl(mockRepo);
    }

    @Test
    public void testCreateAccount() {
        Account expected = new Account("Gigi", 2100, "223311");
        when(mockRepo.findOne("Gigi")).thenReturn(Optional.empty());
        when(mockRepo.save(any(Account.class))).thenReturn(expected);

        Account result = service.createAccount("Gigi", "223311", "2100");
        assertEquals(expected, result);
    }

    @Test
    public void testCreateAccountAlreadyExists() {
        Account expected = new Account("Gigi", 2100, "223311");
        when(mockRepo.findOne("Gigi")).thenReturn(Optional.of(expected));

        assertThrows(AccountCreationException.class,
                () -> service.createAccount("Gigi", "223311", "2100"));
    }
}

Listing 9-40The AccountServiceTest Unit Test Class Using a Mock Repo

这些测试是不言自明的,并且Mockito实用程序方法的名称使得理解测试执行过程中实际发生的事情变得容易。等等,你可能会问,模拟是如何创建和注入的?谁会这么做?对于 JUnit 5 测试来说,@ExtendWith(MockitoExtension.class)是支持Mockito注释所必需的。没有它,像@Mock这样的注释对代码没有影响。@Mock注释将用于引用由Mockito创建的模拟。使用 mock 的首选方式是指定一个接口类型的引用,该接口类型由真实对象类型和将为测试场景创建的 mock 实现。但是@Mock也可以放在具体的类型引用上,创建的 mock 将是该类的子类。在要测试的对象上使用了@InjectMocks注释,因此Mockito知道创建这个对象并注入模拟而不是依赖项。这就是开始在测试中使用 Mockito mocks 所需要知道的全部内容。声明要用模拟替换的对象和要注入的对象是包含使用模拟的单元测试的类所需要的唯一设置。

使用模拟的测试方法主体也有一个典型的结构。第一行必须声明对象和变量,这些对象和变量作为参数传递给被测试对象上调用的方法,或者作为参数传递给 Mockito 实用程序方法,这些方法声明 mocks 将什么作为参数以及它们返回什么。接下来的几行建立了待测试对象调用 mock 方法时的行为。

下面两行为findOne(..)方法描述了这一点。第一行创建了一个account对象。第二行定义了模拟的行为。当调用mockRepo.findOne("Gigi")时,之前创建的账户实例将被包装在Optional<T>实例中返回。

Account expected = new Account("Gigi", 2100, "223311");
when(mockRepo.findOne("Gigi")).thenReturn(Optional.of(expected));

还有许多其他的库可以让开发人员尽可能轻松地编写测试,像 Spring 这样的大型框架也提供了自己的测试库来帮助开发人员使用这个框架编写应用测试。像 Ant、Maven 和 Gradle 这样的构建工具可以用来在构建项目时自动运行测试,并生成与失败相关的有用报告。

使用 Maven,可以通过在控制台中调用mvn clean install来构建项目。所有在测试模块中声明的测试类,如果它们被命名为*Test.java,将被自动选取。当编写测试并且不改变应用代码时,您可以只通过调用mvn test来运行测试。这是一个可以通过配置在pom.xml文件中配置的 Maven Surefire 测试插件来改变的配置。

在 Maven 项目中,测试由maven-surefire-plugin运行。Maven 测试结果以txt和XML格式保存,文件位于target/surefire-reports目录下。通过将maven-surefire-report-plugin添加到项目配置中,并将其配置为在测试阶段运行,可以将测试结果分组到一个 HTML 报告中。这很实用,因为它通过运行mvn clean install或mvn test来生成报告。生成的报告是可读的,但是如果不是为项目生成的站点的一部分,就没有 css 样式。该报告由位于target/site目录下名为surefire-report.html的文件表示。

在下面的例子中,一个测试失败被故意引入,并且构建被修改以记录失败而不使整个构建失败。否则,不会生成报告。这都是通过 Maven 配置完成的,您可以在本书的代码中一窥端倪。您可以在图 9-16 中看到生成的报告。

img/463938_2_En_9_Fig16_HTML.jpg

图 9-16

带有测试失败和无样式的 Maven 测试报告

它看起来不漂亮,但它是可读的。修复测试后,报告变得更简单,并且最后两个部分没有生成。当使用maven-site-plugin(在本章的下一节中使用)将生成的报告包含到为此项目生成的站点中时,生成的报告看起来要好得多,如图 9-17 所示。

img/463938_2_En_9_Fig17_HTML.jpg

图 9-17

没有测试失败的 Maven 测试报告和典型的 Maven 生成的站点样式

本书的上一版使用了 Gradle 来构建这个项目。由于与较新版本的 Java 的各种不兼容性和配置的困难,它被放弃了,取而代之的是 Maven,它被广泛使用并且相当稳定。不幸的是,生成测试报告需要多个 Maven 插件,而且报告并不美观。

作为本节的结论,请记住:无论一个开发团队有多好,如果没有一个优秀的测试团队,最终的应用实际上可能离可接受的质量标准还很远。因此,如果你曾经遇到过没有专门的测试团队的公司,或者在代码审查和编写测试等技术上妥协的公司文化,在接受这份工作之前要三思。

文件编制

在软件世界里,有一个关于文档的笑话,可能不是每个人都喜欢,但是值得一提。

文档就像性。好的时候,真的,真的很好。糟糕的时候,总比没有好。

编程的一个常识性规则和最佳实践是编写自我解释的代码,以避免编写文档。基本上,如果你需要写太多的文档,你就做错了。您可以做很多事情来避免编写文档,例如为类和变量使用有意义的名称,遵守语言代码约定,等等。然而,当您构建一组将被其他开发人员使用的类时,您需要为主要的 API 提供一些文档。如果您的解决方案需要编写一个非常复杂的算法,您可能希望在这里或那里添加关于它的注释,尽管在这种情况下,也应该编写带有模式和图表的适当的技术文档。

在章节 3 中,提到了不同类型的注释,并承诺会回来提供更多关于 Javadoc 注释的细节。Javadoc 块注释,也称为文档注释,可以与公共类、接口、方法体、公共字段相关联,有时甚至是受保护的或私有的,如果真的需要的话。Javadoc 注释包含特殊的标记,这些标记将文档化的元素链接在一起,或者标记不同类型的信息。Javadoc 注释及其相关代码可以由 Javadoc 工具进行处理、提取并打包到一个 HTML 站点中,该站点称为项目的 Javadoc API。这个项目的 Maven 配置声明了几个报告插件和前面提到的maven-site-plugin,它被配置为将所有报告打包到一个静态的项目站点中,这个站点可以在target/site下找到。

img/463938_2_En_9_Figi_HTML.jpg执行mvn site生成项目现场。

img/463938_2_En_9_Figj_HTML.jpg不出所料,开发和维护 Maven 插件的团队很难跟上 Oracle JDK 的发布进度。在设置此模块的配置时,注意到了几个错误。 15 这似乎并没有影响建造的成功,或者说它的成果太多了。希望如此,但是在这本书发行的时候,运行这个版本时不会有警告和错误消息。

智能编辑器可以下载和访问项目的文档,并在开发人员试图使用文档化的组件编写代码时显示出来,因此好的代码文档可以大大提高开发过程的速度。让我们从 Javadoc 注释的几个例子开始,来解释所使用的最重要的标签。

每当我们创建一个类或接口时,我们都应该添加 Javadoc 注释来解释它们的用途,添加它们时添加应用的版本,并最终链接一些现有的资源。在本章的开始,我们提到了IntSorter层次结构,这是一个实现了IntSorter接口的类层次结构,它提供了不同排序算法的实现。当这些类被其他开发人员使用时,他们中的一个可能想要在我们的层次结构中添加一个定制的算法。一些关于IntSorter界面的信息会对他们设计合适的解决方案有很大帮助。清单 9-41 显示了添加到IntSorter接口的 Javadoc 注释。

/**
 * Interface {@code IntSorter} is an interface that needs to be implemented
 * by classes that provide a method to sort an array of {@code int} values. <p>
 *
 * {@code int[]} was chosen as a type because this type
 * of values are always sortable. ({@link Comparable})
 *
 * @author Iuliana Cosmina
 * @since 1.0
 */
public interface IntSorter {
    // interface body omitted
}

Listing 9-41The Documentation Comment on the IntSorter Interface

在 Javadoc 注释中,HTML 标记可以用来格式化信息。在前面的代码中,使用了一个<p>元素来确保注释将由多个段落组成。@author标签是在 JDK 1.0 中引入的,当开发团队相当大时,它非常有用,因为如果你最终使用了别人的代码,你知道如果出现问题该找谁。@since标签用于在添加该接口时提供应用的版本。对于一个开发和发布周期很长的应用,这个标签可以用来标记元素(方法、类、字段等)。)以便使用应用代码库的开发人员知道元素是何时添加的,并且在回滚到先前版本的情况下,将知道编译时错误将出现在应用中的什么位置。

这里最好的例子是 Java 官方 Javadoc 我们上String课吧。它是在 Java 版本 1.0 中引入的,但是随着每个 Java 版本的发布,更多的构造函数和方法被添加到其中。它们中的每一个都标有特定的版本。清单 9-42 描述了证明前面断言的代码片段和文档注释。

package java.lang;
// import section omitted

/**
 * ...
 * @since 1.0
 */
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence,
               Constable, ConstantDesc {

    // some code omitted

    /**
     * ...
     * @since  1.1
     */
    public String(byte[] bytes, int offset, int length, String charsetName) {
         // method body omitted
    }

    /**
     * ...
     * @since  1.4
     */
    public boolean contentEquals(StringBuffer sb) {
        // method body omitted
    }

    /**
     * ...
     * @since  1.5
     */
    public String(int[] codePoints, int offset, int count) {
        // method body omitted
    }

    /**
     * ...
     * @since  1.6
     */
    public String(byte[] bytes, int offset, int length, Charset charset) {
       // method body omitted
    }

    /**
     * ...
     * @since 1.8
     */
    public static String join(CharSequence delimiter, CharSequence... elements) {
        // method body omitted
    }

     /**
     * ...
     * @since 9
     */
    @Override
    public IntStream codePoints() {
        // method body omitted
    }

    /**
     * ...
     * @since 11
     */
    public String strip() {
        // method body omitted
    }

    /**
     * ...
     * @since 12
     */
    public String indent(int n) {
        // method body omitted
    }

    /**
     * ...
     * @since 15
     */
    public String stripIndent() {
        // method body omitted
    }
}

Listing 9-42The Documentation Comments in the String Class

在IntSorter的例子中,你可能已经注意到了@code标签。这个标记是在 Java 1.5 版本中引入的,用于以代码形式显示文本,使用特殊的字体和转义可能破坏 HTML 语法的符号。(例如:<或>)。

@link标签是在 Java 1.2 中添加的,用于插入相关文档的可导航链接。

清单 9-43 展示了一个更好的文档版本的IntSorter接口,它包含了方法的文档注释,这样实现它的开发者就知道应该如何使用它的方法。

/**
 * Interface {@code IntSorter} is an interface that needs to be implemented
 * by classes that provide a method to sort an array of {@code int} values. <p>
 *
 * {@code int[]} was chosen as a type because this type
 * of values are always sortable. ({@link Comparable})
 *
 * @author Iuliana Cosmina
 * @since 1.0
 */
public interface IntSorter {

    /**
     * Sorts {@code arr}
     *
     * @param arr int array to be sorted
     * @param low lower limit of the interval to be sorted
     * @param high higher limit of the interval to be sorted
     */
    void sort(int[] arr, int low, int high);

     /**
     * Implement this method to provide a sorting solution that does not require pivots.
     * @deprecated As of version 0.1, because the
     *             {@link #sort(int[], int, int) ()} should be used instead.<p>
     * To be removed in version 3.0.
     * @param arr int array to be sorted
     */
    @Deprecated (since= "0.1", forRemoval = true)
    default void sort(int[] arr) {
        System.out.println("Do not use this! This is deprecated!!");
    }
}

Listing 9-43The Documentation Comments for Method in the IntSorter Interface

IntelliJ IDEA 编辑器(和其他智能编辑器)可以为您生成小部分 Javadoc。一旦你声明了一个你想要记录的类或方法体,输入/**并按下<Enter>。生成的注释块包含可以从组件声明中推断出的所有内容的条目。下表描述了最常见的:

  • 一个或多个@param标签以及参数名称。开发人员要做的就是添加额外的文档来解释它们的用途。

  • 如果该方法返回一个不同于 void 类型的值,则生成一个@return标签。开发人员必须提供文档来解释结果代表什么,以及是否存在返回某个值的特殊情况。

  • 如果方法声明抛出一个异常,一个@throws标签和异常类型一起生成,开发人员的工作是解释什么时候以及为什么抛出这种类型的异常。

清单 9-44 描述了包含filter(..)方法及其文档注释的Optional<T>类的一个片段。

/**
 * ...
 * @param predicate the predicate to apply to a value, if present
 * @return an {@code Optional} describing the value of this
 *         {@code Optional}, if a value is present and the value matches the
 *         given predicate, otherwise an empty {@code Optional}
 * @throws NullPointerException if the predicate is {@code null}
 */
public Optional<T> filter(Predicate<? super T> predicate) {
    Objects.requireNonNull(predicate);
    if (!isPresent()) {
        return this;
    } else {
        return predicate.test(value) ? this : empty();
    }
}

Listing 9-44The Documentation Comments for Optional<T>#filter(..) Method

@link to 可以用来创建一个类的文档链接,或者那个类或接口中的一个方法,一个方法文档部分,一个字段,甚至一个外部网页。清单 9-45 描述了一个实现IntSorter的类。它的文档注释包含一个到IntSorter接口中抽象方法的链接。

package com.apress.bgn.nine.algs;

/**
 * The {@code InsertionSort} class contains a single method that is a concrete implementation of {@link IntSorter#sort(int[])}.<p>
 * Instances of this class can be used to sort an {@code int[] } array using the insertion-sort algorithm.
 *
 * @author Iuliana Cosmina
 * since 1.0
 * @see IntSorter
 */
public class InsertionSort implements IntSorter {
     // class body omitted
}

Listing 9-45The Documentation Comments for the InsertionSort Class

@ see 标签是对@link的简单替代,它应该将开发人员的注意力引向特定于该标签引用的元素的文档。标签@deprecated用于添加一段文字,解释弃用的原因,当组件要被删除时的版本,以及用什么来代替。Javadoc 生成工具将获取该标签的文本,使用斜体显示,并将其添加到组件(类、字段、方法等)的主要描述中。除了这个标签,Java 1.5 中还引入了@Deprecated注释。用它来注释一个组件应该会阻止开发人员使用它。这种注释的优点是,当在非弃用代码中使用或覆盖了弃用的组件时,编译器会拾取它并发出警告。这个注释可以用在任何 Java 语言组件上,包括模块。

像 IntelliJ IDEA 一样,智能 Java IDEs 知道@deprecated标签和@Deprecated注释,并以删除线格式显示不推荐使用的组件,以警告开发人员不要使用它们。负责编译 Java 源代码的 Maven maven-compiler-plugin提供了显示或隐藏弃用警告的配置选项。所有这些都在图 9-18 中进行了描述。

img/463938_2_En_9_Fig18_HTML.png

图 9-18

IntelliJ IDEA 可以识别@deprecated标签。Maven build 被配置为显示弃用警告

至此,我们已经涵盖了编写 Javadoc 注释时最常用的标记。如果你想查看完整的列表,你可以在这里找到: https://www.oracle.com/java/technologies/javase/javadoc-tool.html#javadocdocuments 。Javadoc 文档也是一个广泛的主题,可以为整本书提供素材。在这一节中,我们只是触及了皮毛,涵盖了基础知识,以便您对它有一个很好的理解。

img/463938_2_En_9_Figk_HTML.jpg为项目生成站点的 Maven 插件配置是一个高级主题,不适合本书。然而,Maven 插件已经被提到了名字,并且在pom.xml文件中添加了一些注释来解释它们的用途和配置,如果您对这些细节感兴趣的话。

为logging-jul模块生成 HTML 站点,打开 Maven 项目视图,导航到章节 09 :使用 SLF4J ➤生命周期节点进行日志记录,在其下,我们会找到site阶段,如图 9-19 所示。

img/463938_2_En_9_Fig19_HTML.jpg

图 9-19

Maven 站点阶段和logging-jul模块的结果

在 IDE 中双击它与在控制台中执行mvn site是一样的。它触发 Maven 站点生成阶段及其依赖的所有阶段的执行,构建的结果是一个名为site的目录,位于target目录下。它包含一个静态站点,其起始页面名为index.html。该站点非常简单,因为使用了默认配置。

右击该文件,从出现的上下文菜单中选择在浏览器中打开,并选择您的首选浏览器。

项目的主页描述了来自pom.xml的信息,比如项目名称、描述等等。在页面的左侧有一个包含几个条目的菜单,其中一个名为Project Reports。展开此菜单项,会显示一个项目列表,其中一个名为Javadoc。单击会将您定向到项目 Javadoc 页面。如果您认为该页面类似于 JDK 官方 Javadoc 页面,那么您不是在想象它;同样的 Doclet API 也被用来生成官方的。项目菜单和 Javadoc 主页如图 9-20 所示。

img/463938_2_En_9_Fig20_HTML.png

图 9-20

Maven 项目站点和用于logging-slf4j模块的主 Javadoc 站点

文档并不是特别丰富,但是很有用。

前面提到过,Javadoc 文档(如果存在的话)由 IntelliJ IDEA 和其他智能编辑器获取并在现场描述,而开发人员在代码中使用文档化的组件。更聪明的编辑器在选择类、方法名、接口方法等时,会提供某种包含 F1 的组合键,开发人员必须按 F1 键才能在弹出窗口中显示文档。在 IntelliJ IDEA 中,只需单击一个元素并按 F1,Javadoc 文档就会显示在一个弹出窗口中,格式非常好,如图 9-21 所示。

img/463938_2_En_9_Fig21_HTML.png

图 9-21

IntelliJ IDEA 中描述的 Javadoc 信息

只要代码是开源的,并且模块导出适当的包,您就可以在智能编辑器中查看项目的任何依赖项(包括 JDK 类)的 Javadoc 信息。

在 Java 9 中,用于生成 Javadoc 的 Doclet API 得到了升级和改进。在 Java 9 之前,开发人员抱怨旧版本的性能问题,晦涩的 API,缺乏支持,以及整体上的浅薄。在 Java 9 中,大多数问题都被处理和解决了。详细描述和改进列表可以在这里找到: http://openjdk.java.net/jeps/221 。

文档真的很有价值,真的好的时候可以让开发变得实用愉快。因此,在编写代码时,按照您对项目依赖关系的预期来记录它。你可能听说过这个短语 RTFM ,它是的缩写,请阅读该死的手册!有经验的开发人员在与新手开发人员一起工作时,会经常使用这个表达。问题是,没有手册的时候你该怎么办?大多数公司在最后期限内可能倾向于分配很少或没有时间来记录项目,所以这一部分被添加到本书中,以强调良好的文档在软件开发中的重要性,并教你如何在编写代码时编写文档,因为你以后可能没有时间做它。

摘要

本章涵盖了重要的开发工具和技术,JDK 中为它们提供支持的类,以及重要的 Java 库和框架,您很可能最终会使用它们来使您的开发工作变得实用和愉快。一个完整的主题列表在下面的列表中,希望在读完这一章后,你有一个很好的起点去使用它们。

  • 如何在 Java 应用中配置和使用日志记录?

  • 如何在控制台中记录消息。

  • 如何将消息记录到文件中。

  • 如何使用 Java 日志?

  • 什么是日志门面,为什么推荐使用它。

  • 配置并使用 SLF4J 和回退功能。

  • 如何使用断言编程?

  • 如何使用 IntelliJ IDEA 一步一步调试?

  • 如何在应用运行时使用各种 JDK 工具监控和检查 JVM 统计数据,例如:jps、jcmd、jconsole和jmc。

  • 如何使用流程 API 来监控和创建流程。

  • 如何使用 JUnit 测试应用?

  • 如何用假货写测试?

  • 如何使用存根编写测试?

  • 如何使用模拟编写测试?

  • 如何使用 Maven 编写 Java 应用文档并生成 HTML 格式的文档。

十、让您的应用具有交互性

到目前为止,我们的 Java 程序数据的输入是通过在代码内部初始化的数组或变量或者通过程序参数提供的。然而,在现实生活中,大多数应用都需要与用户进行交互。可以通过输入用户名和密码向用户提供访问,并且用户有时需要键入细节来确认他/她的身份或者指示应用做什么。Java 支持多种方法来读取用户输入。在这一章中,我们将介绍几种构建交互式 Java 应用的方法。交互式 Java 应用从控制台、Java 构建的界面以及桌面或 web 获取输入。

JShell 是一个命令行界面,开发者可以在这里输入变量声明和单行语句,当按下时就会执行这些声明。像bash这样的命令行界面外壳和像来自 Windows 的命令提示符这样的终端可以用来以连续文本行的形式向程序发出命令。JShell 在本书的开始部分已经介绍过了,原因很简单,因为它是 Java 9 的一个新事物。以下部分将介绍如何使用命令行界面读取用户提供的数据和指令。之后的章节将集中在构建带有桌面/web 界面的 Java 应用上。

从命令行读取数据

本节专门介绍如何从命令行读取用户输入,无论是 IntelliJ IDEA 控制台还是从特定于操作系统的任何终端的可执行 jar 中运行程序。在 JDK 中,有两个类可以用来从命令行读取用户数据:java.util.Scanner和java.io.Console,本节将详细介绍这两个类。事不宜迟,我们开始吧。

使用System.in读取用户数据

在章节 9 介绍在System.out下的控制台方法中记录打印数据之前,它们在本书的代码示例中经常使用。还有一个名为System.in的对应实用程序对象,用于从控制台读取数据:程序用户引入的用于控制应用流的数据。您可能已经注意到,到目前为止,所有 Java 程序在执行时都会被启动,会处理数据,会执行声明的语句,然后它们会终止,优雅地退出,或者在出错时出现异常。将终止决策传递给用户的最简单、最常见的方式是通过调用System.in.read()来结束 main 方法。该方法从输入流中读取数据的下一个字节,程序暂停,直到用户输入一个值;当值返回时,我们甚至可以保存并打印它。清单 10-1 显示了使用System.in.read读取用户输入的代码。

package com.apress.bgn.ten;

import java.io.IOException;

public class ReadingFormStdinDemo {

    public static void main(String... args) throws IOException {
        System.out.print("Press any key to terminate:");
        byte[] b = new byte[3];
        int read = System.in.read(b);
        for (int i = 0; i < b.length; ++i) {
            System.out.println(b[i]);
        }
        System.out.println("Key pressed: " + read);
    }
}

Listing 10-1Reading a Value Provided By the User in the Console

用户输入保存在byte[] b数组中;它的大小是任意的。你可以输入任何你想要的东西。只有前三个字节会保存在数组中。然而,这种阅读信息的方式并没有真正的用处,不是吗?我的意思是,看看下面的代码片段,它描述了前面执行的代码和插入的随机文本。

Press any key to terminate: ini mini miny moo.  # inserted text
32
105
110
Key pressed: 3

让我们看看如何从用户那里读取全文:输入 class java.util.Scanner。

使用java.util.Scanner

System.in变量的类型是java.io.InputStream,这是一个 JDK 特殊类型,由所有表示输入字节流的类扩展。你会在第章 11 中了解到更多关于InputStream这个职业的信息。这意味着System.in可以包装在任何java.io.Reader扩展中(阅读章节 11 了解更多信息),因此字节可以作为可读数据读取。真正重要的是包java.util中一个名为Scanner的类。这种类型的实例可以通过调用其构造函数并提供System.in作为参数来创建。Scanner类提供了许多next*()方法,可以用来从控制台读取几乎任何类型。在图 10-1 中可以看到next*()方法列表。

img/463938_2_En_10_Fig1_HTML.jpg

图 10-1

读取各种类型数据的扫描仪方法

使用Scanner从控制台读取数据的优点是,读取的值在可能的情况下会自动转换为适当的类型;如果不是,则抛出一个java.util.InputMismatchException。

下面这段代码旨在让您可以通过插入文本然后插入值来选择要读取的值的类型。在清单 10-2 中,Scanner 实例的适当方法被调用来读取值。

package com.apress.bgn.ten;

import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

public class ReadingFromStdinUsingScannerDemo {

    public static final String EXIT = "exit";
    public static final String HELP = "help";
    public static final String BYTE = "byte";
    public static final String SHORT = "short";
    public static final String INT = "int";
    public static final String BOOLEAN = "bool";
    public static final String DOUBLE = "double";
    public static final String LINE = "line";
    public static final String BIGINT = "bigint";
    public static final String TEXT = "text";
    public static final String LONGS = "longs";

    public static void main(String... args) {
        Scanner sc = new Scanner(System.in);
        String help = getHelpString();
        System.out.println(help);

        String input;
        do {
            System.out.print("Enter option: ");
            input = sc.nextLine();

            switch (input) {
                case HELP:
                    System.out.println(help);
                    break;
                case EXIT:
                    System.out.println("Hope you had fun. Buh-bye!");
                    break;
                case BYTE:
                    byte b = sc.nextByte();
                    System.out.println("Nice byte there: " + b);
                    sc.nextLine();
                    break;
                case SHORT:
                    short s = sc.nextShort();
                    System.out.println("Nice short there: " + s);
                    sc.nextLine();
                    break;
                case INT:
                    int i = sc.nextInt();
                    System.out.println("Nice int there: " + i);
                    sc.nextLine();
                    break;
                case BOOLEAN:
                    boolean bool = sc.nextBoolean();
                    System.out.println("Nice boolean there: " + bool);
                    sc.nextLine();
                    break;
                case DOUBLE:
                    double d = sc.nextDouble();
                    System.out.println("Nice double there: " + d);
                    sc.nextLine();
                    break;
                case LINE:
                    String line = sc.nextLine();
                    System.out.println("Nice line of text there: " + line);
                    break;
                case BIGINT:
                    BigInteger bi = sc.nextBigInteger();
                    System.out.println("Nice big integer there: " + bi);
                    sc.nextLine();
                    break;
                case TEXT:
                    String text = sc.next();
                    System.out.println("Nice text there: " + text);
                    sc.nextLine();
                    break;
                default:
                    System.out.println("No idea what you want bruh!");
            }

        } while (!input.equalsIgnoreCase(EXIT));
    }

    private static String getHelpString() {
        return new StringBuilder("This application helps you test various usage of Scanner. Enter type to be read next:")
                .append("\n\t help >  displays this help")
                .append("\n\t exit >  leave the application")
                .append("\n\t byte > read a byte")
                .append("\n\t short > read a short")
                .append("\n\t int > read an int")
                .append("\n\t bool > read a boolean")
                .append("\n\t double > read a double")
                .append("\n\t line > read a line of text")
                .append("\n\t bigint > read a BigInteger")
                .append("\n\t text > read a text value").toString();
    }
}

Listing 10-2Reading a Value Provided By the User in the Console Using java.util.Scanner

您可能已经注意到,在前面的代码示例中,大多数 scanner 方法都是用一个nextLine()一起调用的。这是因为您提供的每个输入都是由实际的令牌和一个新的行字符组成的(按下< Enter >结束您的输入),在您可以输入下一个值之前,您还需要从流中取出那个字符。

清单 10-3 描述了清单 10-2 中的代码被用来读取一些用户值。

This application helps you test various usage of Scanner. Enter type to be read next:
     help >  displays this help
     exit >  leave the application
     byte > read a byte
     short > read a short
     int > read an int
     bool > read a boolean
     double > read a double
     line > read a line of text
     bigint > read a BigInteger
     text > read a text value
Enter option: byte
12
Nice byte there: 12
Enter option: bool
true
Nice boolean there: true
Enter option: line
some of us are hardly ever here
Nice line of text there: some of us are hardly ever here
Enter option: text
john
Nice text there: john
Enter option: text
the rest of us are made to disappear...
Nice text there: the
Enter option: double
4.2
Nice double there: 4.2
Enter option: int
AAAA
Exception in thread "main" java.util.InputMismatchException
    at java.base/java.util.Scanner.throwFor(Scanner.java:939)
    at java.base/java.util.Scanner.next(Scanner.java:1594)
    at java.base/java.util.Scanner.nextInt(Scanner.java:2258)
    at java.base/java.util.Scanner.nextInt(Scanner.java:2212)
    at chapter.ten.scanner/com.apress.bgn.ten.ReadingFromStdinUsingScannerDemo.main(ReadingFromStdinUsingScannerDemo.java:80)

Listing 10-3Running the ReadingFromStdinUsingScannerDemo Class

前面清单中突出显示的输出代表了next()方法的测试用例。这个方法应该用于读取单个String令牌。下一个标记被转换成一个String实例,很明显,当遇到空格时,标记结束。这就是为什么在前面的例子中,唯一读取的文本最终是的。在最后一种情况下,期望的选项是一个整数值,但是输入了 AAAA ,这就是抛出异常的原因。

当您需要从控制台重复读取相同类型的值时,您可以查看您想要读取的值,并在读取之前检查它,以避免抛出InputMismatchException。对于这个特定的场景,每个next*()方法都有一个名为hasNext*()的配对方法。为了展示如何使用这些方法的示例,让我们在前面的代码中添加一个选项,以便能够读取长值列表,如清单 10-4 所示。

...
public static final String LONGS = "longs";
...
    String input;
    do {
        System.out.print("Enter option: ");
        input = sc.nextLine();
        switch (input) {
            case LONGS:
                List<Long> longList = new ArrayList<>();
                while (sc.hasNextLong()) {
                    longList.add(sc.nextLong());
                }
                System.out.println("Nice long list there: " + longList);
                // else all done
                sc.nextLine();
                sc.nextLine();
                break;
            default:
                System.out.println("No idea what you want bruh!");
        }
    } while (!input.equalsIgnoreCase(EXIT));
...

Listing 10-4Using java.util.Scanner to Read a List of Long Values

虽然看起来很怪异,但是我们需要调用两次nextLine()方法:一次是针对无法转换为long的字符,所以while循环结束,一次是针对行尾字符,所以下一次读取的是下面读取值的类型。

在Scanner类中还有一些其他的方法可以用来过滤输入和只读所需的令牌,但是本节中列出的方法是您将会使用最多的。

使用java.io.Console

java.io.Console类是在比Scanner,晚一个版本的 Java 版本 1.6 中引入的,它提供了访问与当前 Java 虚拟机关联的基于字符的控制台设备(如果有的话)的方法。

因此,类java.io.Console的方法也可以用于写入控制台,而不仅仅是读取用户输入。如果从后台进程或 Java 编辑器启动 JVM,控制台将不可用,因为编辑器会将标准输入和输出流重定向到它自己的窗口。这就是为什么如果我们要使用Console编写代码,我们只能通过从终端运行类或 jar,通过调用java ReadingUsingConsoleDemo.class或java -jar using-console-1.0-SNAPSHOT.jar来测试它。JVM 的控制台,如果可用的话,在代码中由类Console的单个实例来表示,它可以通过调用System.console()来获得。

在图 10-2 中,您可以看到可以在控制台实例上调用的方法。

img/463938_2_En_10_Fig2_HTML.jpg

图 10-2

读取各种类型数据的扫描仪方法

显然,read*(..)方法用于从控制台读取用户输入,printf(..)和format(..)用于在控制台打印文本。这里的特例是两个readPassword(..)方法,它们允许从控制台读取文本,但在写的时候不显示。这意味着可以在没有任何实际用户界面的情况下编写支持身份验证的 Java 应用。清单 10-5 描述了一个示例代码来查看所有的操作。

package com.apress.bgn.ten;

import java.io.Console;
import java.util.Calendar;
import java.util.GregorianCalendar;

public class ReadingUsingConsoleDemo {

    public static void main(String... args) {
        Console console = System.console();
        if (console == null) {
            System.err.println("No console found.");
            return;
        } else {
            console.writer().print("Hello there! (reply to salute)\n");
            console.flush();
            String hello = console.readLine();
            console.printf("You replied with: '" + hello + "'\n");

            Calendar calendar = new GregorianCalendar();
            console.format("Today is : %1$tm %1$te,%1$tY\n", calendar);
            char[] passwordChar = console.readPassword("Please provide password: ");
            String password =  new String(passwordChar);
            console.printf("Your password starts with '" + password.charAt(0) + "' and ends with '" + password.charAt(password.length()-1) + "'\n");
        }
    }
}

Listing 10-5Using java.io.Console to Read and Write Values

在前面的代码示例中,有意使用了各种使用控制台读写数据的方法,以向您展示应该如何使用它们。

console.writer()返回一个java.io.PrintWriter的实例,它可以用来将消息打印到控制台。问题是直到console.flush()被调用,消息才被打印出来。这意味着更多的消息可以被java.io.PrintWriter实例排队,并且只有当flush()被调用或者当它的内部缓冲区满了的时候才被打印。调用console.format(..)来打印格式化的消息,在本例中,使用一个Calendar实例来提取当前日期,并根据下面的模板打印出来:dd mm,yyyy由这个参数%1$tm %1$te,%1$tY定义。使用格式化程序的Console方法接受的模板在类java.util.Formatter中定义。

复杂的部分:在 IntelliJ 中运行这段代码是不可能的,所以我们必须要么执行类,要么执行 jar。

img/463938_2_En_10_Figa_HTML.jpg为了避免在运行代码时创建新的操作系统控制台窗口,大多数 ide,比如 IntelliJ IDEA,都使用无窗口 Java。因为没有窗口,所以没有供用户访问和插入数据的控制台。所以使用 java . io . Console 的应用必须在命令行中执行。

最简单的方法是配置 Maven maven-jar-plugin来创建一个可执行的 jar,要执行的主类是ReadingUsingConsoleDemo。Maven 生产的罐子可以在这里找到:/chapter10/using-console/target/using-console-2.0-SNAPSHOT.jar。如果你想的话,只需在 IntelliJ IDEA 中打开一个终端,点击终端按钮,进入target目录。一旦到了那里,执行java -jar using-console-2.0-SNAPSHOT.jar并享受其中的乐趣。在清单 10-6 中,你可以看到我用来测试程序的条目。

> cd chapter10/using-console/target
> java -jar using-console-2.0-SNAPSHOT.jar
Hello there! (reply to salute)
Salut!
You replied with: 'Salut!'
Today is: 06 21,2021
Please provide password:
Your password starts with 'g' and ends with 'a'

Listing 10-6Running the Class ReadingUsingConsoleDemo

这是关于使用控制台的所有值得介绍的内容,因为一旦您在一个真正的生产就绪项目中工作,您可能永远都不需要它。

使用 Swing 构建应用

Swing 是一个用于 Java 的 GUI 部件工具包。从版本 1.2 开始,它是 JDK 的一部分,旨在为构建具有各种按钮、进度条、可选列表等复杂界面的用户应用提供更加美观和实用的组件。Swing 是基于一个叫做 AWT(简称抽象窗口工具包 ) 的东西的早期版本,它是最初的 Java 用户界面小部件工具包。AWT 非常简单,有一组图形界面组件,可以在任何平台上使用,这意味着 AWT 是可移植的,但这并不意味着在一个平台上编写的 AWT 代码实际上可以在另一个平台上工作,因为平台特定的限制。AWT 组件依赖于本机等效组件,这就是它们被命名为重量级组件的原因。在图 10-3 中,你可以看到一个简单的 Java AWT 应用。

img/463938_2_En_10_Fig3_HTML.jpg

图 10-3

简单的 Java AWT 应用

这是一个简单的窗口,包含一个列表、一个文本区域和一个按钮。主题,也称为应用的外观和感觉,与构建它的操作系统是同一个主题——在本章的例子中是 macOS。由于前面提到的原因,它不能被改变:AWT 接入 OS 本地图形界面。如果你在 Windows 机器上运行相同的代码,窗口看起来会不同,因为它将使用 Windows 主题。

Swing 组件是用 Java 构建的,遵循 AWT 模型,但是提供了一个可插拔的外观。Swing 完全用 Java 实现,包含 AWT 的所有特性,但是它们不再依赖于原生 GUI 这就是为什么它们被称为轻型组件。Swing 提供了 AWT 所做的一切,并且用更高级的组件扩展了组件集,比如树形视图、列表框和选项卡式窗格。此外,外观和感觉以及主题是可插拔的,可以很容易地改变。这显然意味着比 AWT 应用更好的可移植性:用非特定于平台的组件编写更复杂的应用设计的可能性,并且因为 Swing 是 AWT 的替代方案,所以在它上面做了更多的开发。

当 web 应用飞速发展时,它们的用户界面非常简单,因为浏览器的功能非常有限。引入 AWT 是为了构建名为 applets 的 Java web 应用。Java 小程序是从浏览器启动的小应用,然后在安装在用户操作系统上的 JVM 中,在独立于浏览器本身的进程中执行。这就是为什么小应用可以在网页框架、新的应用窗口或为测试小应用而设计的独立工具中运行。Java 小程序使用操作系统的 GUI,这使得它们比当时 HTML 笨重的初始外观更漂亮。它们现在已被弃用,并计划在 Java 11 中删除。

至于用 Swing 或 AWT 编写的 Java 桌面应用,它们已经很少使用了,你可能会在学校里学着构建一个,但在其他方面已经过时了。然而,某些机构和公司使用的遗留应用已经在他们的业务中运行了很长时间,并且是用 Swing 构建的。我见过餐馆使用 Swing 应用来管理桌子和订单,我认为大多数超市也使用 Swing 应用来管理购物项目。这就是本书中存在这一部分的原因,因为你可能最终要维护这样的应用,了解基础知识是有好处的,因为 Swing 仍然是 JDK 的一部分。所有 Swing 组件(AWT 也是)都是java.desktop模块的一部分。所以如果你想使用 Swing 组件,你必须声明对这个模块的依赖。在清单 10-7 中,显示了一个配置片段。你可以看到我们项目中使用 Swing 的模块通过在其 module-info.java 中使用requires指令声明了它对java.desktop模块的依赖。

module chapter.ten.swing {
    requires java.desktop;
}

Listing 10-7Module Configuration for the using-swing Project

图 10-3 中描述的应用是使用 AWT 构建的。本节将介绍在 Swing 中构建类似的东西,甚至向它添加更多组件。任何 Swing 应用的主类都被命名为JFrame,该类型的实例用于创建带有边框和标题的窗口。清单 10-8 中的代码就是这么做的。

package com.apress.bgn.ten;

import javax.swing.*;
import java.awt.*;

public class BasicSwingDemo extends JFrame {
    public static void main(String... args) {
        BasicSwingDemo swingDemo = new BasicSwingDemo();
        swingDemo.setTitle("Swing Demo Window");
        swingDemo.setSize(new Dimension(500,500));

        swingDemo.setVisible(true);
    }
}

Listing 10-8Swing Application with a Simple Title

在前面的代码中,创建了一个javax.swing.JFrame的实例,为它设置了一个标题,我们还设置了一个大小,这样当窗口创建时,我们就可以看到一些东西。要真正显示窗口,必须在 JFrame 实例上调用setVisible(true)。运行前面的代码时,显示一个如图 10-4 所示的窗口。

img/463938_2_En_10_Fig4_HTML.jpg

图 10-4

简单的 Java Swing 应用

默认情况下,窗口位于主监视器的左上角,但是可以通过使用一些 Swing 组件来计算相对于屏幕大小的位置来进行更改。确定一个相对于屏幕大小的摆动窗口的大小和位置,仅仅受限于你愿意投入的数学量。

此时,如果我们关闭显示的窗口,应用将继续运行。默认情况下,关闭窗口只是通过调用setVisible(false)使其不可见。如果我们想改变默认行为退出应用,我们必须改变关闭时的默认操作。这可以通过在创建 JFrame 实例后添加以下代码行来轻松完成。

swingDemo.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

JFrame.EXIT_ON_CLOSE常量是定义窗口关闭时应用行为的一组常量的一部分。这个用来声明当窗口关闭时应用应该退出。其他相关选项包括:

  • 不执行任何操作,包括关闭窗口。

  • HIDE_ON_CLOSE是导致调用setVisible(false)的默认选项。

  • 当一个应用有多个窗口时,使用DISPOSE_ON_CLOSE;此选项用于在最后一个可显示窗口关闭时退出应用。

大多数 Swing 应用都是通过扩展JFrame类来获得对其组件的更多控制而编写的,因此前面的代码也可以如清单 10-9 所示来编写:

package com.apress.bgn.ten;

import javax.swing.*;
import java.awt.*;

public class ExitingSwingDemo extends JFrame {

    public static void main(String... args) {
        ExitingSwingDemo swingDemo = new ExitingSwingDemo();
        swingDemo.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        swingDemo.setTitle("Swing Demo Window");
        swingDemo.setSize(new Dimension(500,500));

        swingDemo.setVisible(true);
    }
}

Listing 10-9Swing Application That Exits When Closed

现在我们有了一个窗口,让我们开始添加组件,因为如果我们没有更多的组件来让我们注意到变化,那么改变外观是没有意义的。每个 Swing 应用至少有一个JFrame是根,是所有其他窗口的父窗口,因为窗口也可以通过使用JDialog类来创建。JDialog是创建对话窗口的主类,这是一种特殊类型的窗口,主要包含消息和选择选项的按钮。开发者可以使用这个类来创建自定义的对话框窗口,或者使用JOptionPane类方法来创建各种对话框窗口。

回到将组件添加到JFrame实例:通过将组件添加到容器中,将组件添加到JFrame中。对JFrame容器的引用可以通过调用getContentPane()来检索。默认的内容窗格是一个简单的中间容器,继承自JComponent,它扩展了java.awt.Container (Swing 是 AWT 的扩展,它的大部分组件都是 AWT 扩展)。对于JFrame,默认的内容窗格实际上是JPanel的一个实例。这个类有一个类型为java.awt.LayoutManager的字段,它定义了其他组件如何在JPanel中排列。一个JFrame实例的默认内容窗格使用一个java.awt.BorderLayout作为它的布局管理器,将一个窗格分成五个区域:东、西、北、南和中心。每个区域都可以被一个常量引用,该常量具有在BorderLayout中定义的匹配名称,所以如果我们想在我们的应用中添加一个退出按钮,我们可以通过编写清单 10-10 中描述的代码将其添加到南部区域。

package com.apress.bgn.ten;

import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

public class LayeredSwingDemo extends JFrame {
    private JPanel mainPanel;
    private JButton exitButton;

    public LayeredSwingDemo(String title) {
        super(title);
        mainPanel = (JPanel) this.getContentPane();
        exitButton = new JButton("Bye Bye!");
        exitButton.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                System.exit(0);
            }
        });
        mainPanel.add(exitButton, BorderLayout.SOUTH);
    }

    public static void main(String... args) {
        LayeredSwingDemo swingDemo = new LayeredSwingDemo("Swing Demo Window");
        swingDemo.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
        swingDemo.setSize(new Dimension(500, 500));
        swingDemo.setVisible(true);
    }
}

Listing 10-10Swing Application using BorderLayout

to Arrange Components

在图 10-5 中你可以看到修改后的应用。我们已经在内容窗格的南部区域添加了一个退出按钮,并为BorderLayout的整体区域安排加了下划线。

img/463938_2_En_10_Fig5_HTML.jpg

图 10-5

边界布局区域

此外,因为 new 按钮必须是退出应用的唯一方式,所以

setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

被替换为

setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);

按钮附带了一个java.awt.event.ActionListener实例,这样它就可以记录按钮被点击的事件并做出相应的反应,在本例中是退出应用。大多数 Swing 组件支持侦听器,可以定义侦听器来捕获用户在对象上执行的事件,并以某种方式做出反应。我们可以看到,按钮扩展并填充了该区域的整个空间,因为它继承了该区域的维度。为了避免这种情况,按钮应该放在另一个容器中,这个容器应该使用不同的布局:FlowLayout。顾名思义,这种布局允许在定向流中添加 Swing 组件,就像在段落中一样。可以像文本文档中的文本格式一样进行调整,并为要对齐的组件定义常量:居中、左对齐等等。在前面的例子中,我们将把exitButton包装在另一个利用FlowLayout的JPanel中。清单 10-11 展示了如何使用FlowLayout在JFrame实例的右上角放置一个按钮。

...
public LayeredSwingDemo(String title) {
        super(title);
        mainPanel = (JPanel) this.getContentPane();
        exitButton = new JButton("Bye Bye!");
        exitButton.addActionListener(e -> System.exit(0));
        JPanel exitPanel = new JPanel();
        FlowLayout flowLayout = new FlowLayout();
        flowLayout.setAlignment(FlowLayout.RIGHT);
        exitPanel.setLayout(flowLayout);
        exitPanel.setComponentOrientation(ComponentOrientation.RIGHT_TO_LEFT);
        exitPanel.add(exitButton);
        mainPanel.add(exitPanel, BorderLayout.SOUTH);
    }
...

Listing 10-11Swing Application Using BorderLayout and FlowLayout to Arrange Components

还可以使用更多的布局,但是让我们通过添加一个包含许多条目的列表来完成应用,并向其中添加一个侦听器,这样当您单击时,一个元素就会添加到添加到框架中心的文本区域中。swing 列表可以通过实例化JList<T>类来创建。这将创建一个显示对象列表的对象,并允许用户选择一个或多个项目。swing JList<T>类包含一个类型为ListModel<T>的字段,用于管理列表显示的数据内容。当创建和添加元素时,每个对象都与一个索引相关联,当用户选择一个对象时,该索引也可以用于处理。在下一个代码片段中,声明并初始化了JList对象,一个 ListSelectionListener 与之相关联,以定义当从列表中选择一个元素时要执行的操作。在我们的例子中,元素值必须加到一个JTextArea中。清单 10-12 中描述了这个对象。

private static String[] data = {"John Mayer", "Frank Sinatra",
    "Seth MacFarlane", "Nina Simone", "BB King", "Peggy Lee"};
private JList<String> list;
private JTextArea textArea;
...
    textArea = new JTextArea(50, 10);
    //NORTH
    list = new JList<>(data);
    list.addListSelectionListener(new ListSelectionListener() {
        @Override
        public void valueChanged(ListSelectionEvent e) {
            if (!e.getValueIsAdjusting()) {
                textArea.append(list.getSelectedValue() + "\n");
            }
        }
    });
    mainPanel.add(list, BorderLayout.NORTH);
    //CENTER
    JScrollPane txtPanel = new JScrollPane(textArea);
    textArea.setBackground(Color.LIGHT_GRAY);
    mainPanel.add(txtPanel, BorderLayout.CENTER);
...

Listing 10-12Swing Application Using Layouts and JTextArea

to Arrange Components

当点击一个列表元素时,会发生两件事:前一个元素被取消选择,最近被点击的元素被选中,所以被选中的元素会改变。getValueIsAdjusting()方法返回这是否是一系列多个事件中的一个(选择事件,点击事件,任何支持的事件),其中更改仍在进行,我们测试该方法是否返回 false 以检查选择是否已经进行,因此我们可以获取当前选择的元素的值并将其添加到文本区域。

关于JTextArea实例,这个实例被添加到一个JScrollPane实例中,这个实例允许textArea的内容仍然可见,因为它通过提供一两个滚动条来填充文本,这取决于配置。JScrollPane也可以包装在一个包含太多条目的列表中,以确保所有条目都是可访问的。此外,由于我们对用户通过文本区域提供的输入不感兴趣,所以调用了setEditable(false);方法。

既然我们已经有了一个更复杂的应用,那么是时候改变应用的外观了。到目前为止,我们一直使用底层操作系统提供的默认设置。使用 Swing,可以将外观配置为 JDK 支持的默认外观之一,或者可以使用额外的自定义外观,这些外观作为项目类路径中的依赖项提供,或者开发人员可以创建自己的外观。为了明确地指定外观,在创建任何 swing 组件之前,必须在 main 方法中添加以下代码行:

UIManager.setLookAndFeel(..).

该方法接收一个String值作为参数,该值代表适当的外观子类的完全限定名。这个类必须扩展抽象javax.Swing.LookAndFeel。虽然没有必要,但是您可以通过调用以下命令来明确指定您想要使用本机 GUI:

UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());

了解了这些,我们来做一些有趣的事情。UIManager类包含实用方法和嵌套类,用于管理 swing 应用的外观。其中一个方法是getInstalledLookAndFeels(),它提取支持的外观列表,并将其作为LookAndFeelInfo[]返回。了解了这一点,让我们做以下事情:列出所有支持的外观,将它们添加到我们的列表中,当用户选择其中一个时,让我们应用它们。不幸的是,由于现在很少使用 swing,所以在我们的应用中没有太多可以使用的自定义外观,所以唯一要做的就是使用 JDK 的产品。清单 10-13 中的代码用外观和感觉完全限定的类名初始化数据数组。

private static String[] data;
...
    public static void main(String... args) throws Exception {
        UIManager.setLookAndFeel(UIManager.getCrossPlatformLookAndFeelClassName());
        UIManager.LookAndFeelInfo[] looks = UIManager.getInstalledLookAndFeels();
        data = new String[looks.length];
        int i =0;
        for (UIManager.LookAndFeelInfo look : looks) {
            data[i++] = look.getClassName();
        }
        SwingDemo swingDemo = new SwingDemo("Swing Demo Window");
        swingDemo.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
        swingDemo.setSize(new Dimension(500, 500));
        swingDemo.setVisible(true);
    }
...

Listing 10-13Code Sample to Initialize the List of Supported Look-and-Feels

现在ListSelectionListener的实现变得有点复杂,因为在选择了一个新的外观和感觉类后,我们必须调用JFrame实例上的repaint()来应用新的外观和感觉,所以我们将把声明放到它自己的类中,并提供SwingDemo对象作为参数,这样就可以在valueChanged(..)方法中调用repaint()。清单 10-14 中描述了代码片段。

private class LFListener implements ListSelectionListener {
    private JFrame parent;
    public LFListener(JFrame swingDemo) {
        parent = swingDemo;
    }
    @Override
    public void valueChanged(ListSelectionEvent e) {
        if (!e.getValueIsAdjusting()) {
            textArea.append(list.getSelectedValue() + "\n");
            try {
                UIManager.setLookAndFeel(list.getSelectedValue());
                Thread.sleep(1000);
                parent.repaint();
            } catch (Exception ee) {
                System.err.println(" Could not set look and feel! ");
            }
        }
    }
}

Listing 10-14Code Sample Showing repaint() Being Called

如果我们运行修改后的程序,并逐个选择列表中的每一项,我们应该会看到窗口外观有一点变化。在图 10-6 中你可以看到所有并排的窗口;差异几乎不明显,但它们确实存在。

img/463938_2_En_10_Fig6_HTML.jpg

图 10-6

JDK 提供不同的外观和感觉

这就是用几行代码就可以对 Swing 组件做的事情。Swing 库中有更多的组件,但是 Swing 已经不那么常用了。因为现在的焦点是在移动和网络应用上,所以这部分就到此为止了。如果您需要创建或维护一个 Swing 应用,Oracle 提供了相当丰富的教程,其中包含大量示例,您可以直接复制粘贴并根据需要进行修改。 1

JavaFX 简介

JavaFX Script 是 Sun Microsystems 设计的脚本语言,是 Java 平台上 JavaFX 技术家族的一部分。它是在 2008 年 12 月 JDK 6 发布后不久发布的,有一段时间开发者预计它会被放弃,因为它真的没有那么流行,是一种完全不同的语言。在收购 Sun Microsystems 之后,Oracle 决定保留它,并将其转变为 JavaFX 库,Java FX 库是一组图形和媒体包,开发人员可以使用它们来设计、创建、测试、调试和部署能够跨不同平台(包括移动平台)一致运行的富客户端应用。JavaFX 旨在取代 Swing 成为 JDK 的主要 GUI 库,但到目前为止,Swing 和 Java FX 都是所有 JDK 版本的一部分,直到 10。这在 JDK 11 中改变了。从 JDK 11 开始,JavaFX 作为一个独立的模块提供,与 JDK 分离。JavaFX 的使用仍然没有 Oracle 希望的那么多,将它从 JDK 中分离出来可能会鼓励 OpenJFX 社区贡献一些创新的想法,这些想法可能会将这个库转变为市场上其他现有 GUI 工具包的实际竞争对手(例如,Eclipse SWT)。 2

在被 JDK 排除后,Java FX 已经独立发展,与发布的 Java 版本保持同步。在撰写本章时,有一个 Java FX 17 EAP 版本可以在 https://openjfx.io 下载。

img/463938_2_En_10_Figb_HTML.jpg下载适合您系统的版本后,解压归档文件。里面至少应该有一个 legal 和 lib 目录。lib 目录包含打包成 JAR 文件的 JavaFX 二进制文件。根据操作系统的不同,lib 可能包含其他库文件。对于本章中的示例,您必须复制以下三个文件:javafx.base.jar、javafx.controls.jar 和 chapter10/using-javafx/libs 中的 javafx.graphics.jar。

img/463938_2_En_10_Figc_HTML.jpg在一些电脑上,比如新的 macOS 笔记本电脑,这些例子可能无法运行,因为一些库文件必须复制到一个特定的位置。要找出复制它们的位置,运行带有-Dprism.verbose=true VM 参数的主JavaFxDemo类。这将导致错误日志更加详细,并告诉您库文件必须复制到哪里。

例如,对于 macOS,目录是/Users/[user]/Library/Java/Extensions,要复制的文件是来自javafx-sdk-17/lib目录的所有扩展名为dylib的文件。

Java FX 曾经是 JDK 的一部分,所以它有类和其他组件。Java FX 代码目前是普通的 Java 代码,所以不再编写脚本。Java FX 组件是在一列java.fx模块下定义的。在下面的配置片段中,你可以看到我们项目中使用 Java FX 的模块通过在清单 10-15 中使用requires指令声明了它对几个java.fx模块的依赖。

module chapter.ten.javafx {
    requires javafx.base;
    requires javafx.graphics;
    requires javafx.controls;
    opens com.apress.bgn.ten to javafx.graphics;
}

Listing 10-15Configuration Sample for a Project Using java.fx Modules

Java FX Application launcher 使用反射来启动应用,因此我们需要打开com.apress.bgn.ten包来允许使用opens指令进行反射。如果没有这个指令,就会抛出一个java.lang.IllegalAccessException,应用不会启动。

最容易的开始是一个简单的窗口,只有一个关闭选项和解释。清单 10-16 中描述了显示一个普通方形窗口的代码。

package com.apress.bgn.ten;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

public class JavaFxDemo extends Application {
    public static void main(String... args) {
        launch(args);
    }

    @Override
    public void start(Stage primaryStage) {
        primaryStage.setTitle("Java FX Demo Window!");
        StackPane root = new StackPane();
        primaryStage.setScene(new Scene(root, 500, 500));
        primaryStage.show();
    }
}

Listing 10-16Simple JavaFx Application

你需要知道的第一件事是应用的主类必须扩展javafx.application.Application类,因为这是 Java FX 应用的入口点。这是必需的,因为 JAVA FX 应用是由位于 JVM 之上的名为 Prism 的新性能图形引擎运行的。除了图形引擎 Prism,Java FX 还自带名为 Glass 的窗口系统、媒体引擎和网络引擎。它们不公开暴露;开发人员唯一可用的是 Java FX API,它提供了对任何组件的访问,您可能需要这些组件来构建具有漂亮接口的应用。所有这些引擎都通过 Quantum 工具包连接在一起,该工具包是这些引擎和栈中上层之间的接口。Quantum 工具包是管理执行线程和渲染的工具。

launch(..)方法是Application类中的静态方法,用于启动独立的应用。它通常从 main 方法中调用,并且只能调用一次,否则会抛出一个java.lang.IllegalStateException。直到通过关闭所有窗口或调用Platform.exit()退出应用,launch 方法才返回。launch 方法创建一个 JavaFxDemo 实例,在其上调用init()方法,然后调用start(..)。start(..)方法在Application类中被声明为抽象的,所以开发者被迫提供一个具体的实现。

Java FX 应用是使用在javafx.scene下定义的组件构建的,具有层次结构。javafx.scene包的核心类是javafx.scene.Node,它是Scene层次结构的根。该层次结构中的类为应用用户界面的所有可视元素提供实现。因为它们都有Node作为根类,可视元素被称为节点,这使得应用成为节点的场景图,并且这个图的初始节点被称为根。每个节点都有一个唯一的标识符、一个样式类和一个包围体,除了根节点之外,图中的每个节点都有一个父节点和零个或多个子节点。除此之外,节点还具有以下属性:

  • 当您将鼠标悬停在界面上以确保您单击了正确的组件时,模糊和阴影等效果非常有用。

  • 不透明。

  • 改变视觉状态或位置的变换。

  • 事件处理程序类似于 Swing 中的监听器,用于定义对鼠标、按键和输入法的反应。

  • 特定于应用的状态。

场景图大大简化了丰富界面的构建,因为它还包括矩形、文本、图像和媒体等基本图形,动画各种图形可以通过包javax.animation的动画 API 来完成。如果你对 Java FX 有兴趣,这里有一篇关于它的详细文章: https://docs.oracle.com/javafx/2/architecture/jfxpub-architecture.htm 。这本书的重点是如何做事情,而不是现在他们的工作,所以阅读这篇文章可能有助于你未来解决方案的设计。

我们从一个简单的窗口开始。第一步是添加一个退出应用的按钮。由于渲染 Java FX 应用涉及到渲染引擎,这意味着它必须正常关闭,所以调用System.exit(0)并不是一个好的选择。start(..)方法的内容必须调用一个特殊的 JavaFX 方法来优雅地关闭应用。代码如清单 10-17 所示。

package com.apress.bgn.ten;

import javafx.application.Application;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.scene.*;
import javafx.stage.*;

public class SimpleJavaFxDemo extends Application {
    public static void main(String... args) {
        launch(args);
    }

    @Override
    public void start(Stage primaryStage) {
        primaryStage.setTitle("Java FX Demo Window!");

        Button btn = new Button();
        btn.setText("Bye bye! ");
        btn.setOnAction(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent event) {
                Platform.exit();
            }
        });

        StackPane root = new StackPane();
        root.getChildren().add(btn);
        primaryStage.setScene(new Scene(root, 300, 300));
        primaryStage.show();
    }
}

Listing 10-17Simple JavaFx Application with a Button

运行SimpleJavaFxDemo类会在你的屏幕上弹出如图 10-7 所示的窗口,如果你点击Bye, bye!按钮,应用会因为Platform.exit()调用而优雅地关闭。

img/463938_2_En_10_Fig7_HTML.jpg

图 10-7

JavaFX 窗口演示

这个按钮只是放在窗口里面,默认情况下放在中间,因为没有编写代码来定位它。Java FX 支持以类似于 Swing 的方式在窗口中排列节点 3 ,但是 Java FX 提供了支持几种不同风格布局的布局窗格。JavaFX 中带有BorderLayout管理器的JPanel的等价物是一个名为BorderPane的内置布局名称。BorderPane提供了五个放置节点的区域,分布与BorderLayout相似,但名称不同。清单 10-18 显示了将我们的按钮放置在右下角底部区域的代码,然后讨论更多关于它的内容。

package com.apress.bgn.ten;

import javafx.application.*;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.*;
import javafx.stage.*;

public class PannedJavaFxDemo extends Application {
    public static void main(String... args) {
        launch(args);
    }

    @Override
    public void start(Stage primaryStage) {
        primaryStage.setTitle("Java FX Demo Window!");

        Button exitButton = new Button();
        exitButton.setText("Bye bye! ");
        exitButton.setOnAction(event -> Platform.exit());

        BorderPane borderPane = new BorderPane();
        HBox box = new HBox();
        box.setPadding(new Insets(10, 12, 10, 12));
        box.setSpacing(10);
        box.setAlignment(Pos.BASELINE_RIGHT);
        box.setStyle("-fx-background-color: #85929e;");
        box.getChildren().add(exitButton);
        borderPane.setBottom(box);

        StackPane root = new StackPane();
        root.getChildren().add(borderPane);
        primaryStage.setScene(new Scene(root, 500, 500));
        primaryStage.show();
    }
}

Listing 10-18Simple JavaFx Application with a Properly Positioned Button

运行PannedJavaFxDemo类导致图 10-8 中描述的窗口在你的屏幕上弹出,该图已经被修改以显示一个BorderPane的区域。

img/463938_2_En_10_Fig8_HTML.jpg

图 10-8

带有BorderPane演示的 JavaFX 窗口

如您所见,决定按钮位置的方法与 Swing 相似,只是有一些不同。BorderPane有五个区域,分别命名为:Top、Bottom、Center、Left和Right。为了在每个区域中放置一个节点,已经为每个区域定义了一个set*(..)方法:setTop(..)、setBottom(..)、setCenter(..)、setLeft(..)和setRight(..)。为了进一步定制节点的位置,它应该被放置在一个HBox节点中,另一个JavaFX元素可以被非常广泛地定制。从代码中可以看出,我们正在使用 CSS 样式元素设置背景。我们通过使用类Insets的一个实例定制其中的节点和包含节点的边界之间的空间,并通过调用box.setAlignment(Pos.BASELINE_RIGHT)定制包含节点的对齐方式。还有更多HBox支持的东西,所以你能用一个盒子做的事情(大部分)只受你想象力的限制。

因此,除了在前面的代码示例中制作漂亮的代码之外,还做了以下事情:根节点成为了一个BorderPane节点的父节点,在BorderPane的底部区域添加了一个HBox,这个HBox实例成为了一个Button的父节点。正如您所看到的,这个组织是分层的,按钮是层次结构中的最后一个节点。

我们还通过正确设计HBox节点的样式来避免使用层窗格。

现在是时候向我们的应用添加最后一个功能了,即文本区域和带有可选元素的列表。选中后,该值将被添加到文本区域。在 JavaFX 中创建文本区域很简单。这个类有一个非常明显的名字:TextArea。我们可以直接在BorderPane的中心区域添加节点,因为 JavaFX 文本区域默认是可滚动的。所以没有必要把它放在一个ScrollPane中,尽管这个类确实存在于javafx.scene.control包中,并且对于显示它内部的节点很有用,这些节点构成了一个比窗口大的窗体。清单 10-19 中的三行代码创建了一个TextArea类型的节点,声明它不可编辑,并将其添加到BorderPane的中心区域。清单 10-19 中的代码显示了完成这项工作的代码。

TextArea textArea = new TextArea();
textArea.setEditable(false);
borderPane.setCenter(textArea);

Listing 10-19Creating and Configuring a JavaFX TextArea

下一个是名单。列表稍微复杂一点,但也更有趣,因为通过使用 JavaFX,您可以对列表做很多事情。需要实例化以创建列表对象的类被命名为ComboBox<T>。这个类只是用来创建列表的更大的类家族中的一个,根类是抽象类ComboBoxBase<T>。根据列表的预期行为,如果我们希望支持单选或多选,如果我们希望列表可编辑或不可编辑,应该选择适当的实现。在我们的例子中,ComboBox<T>类符合以下要求:我们需要一个支持单元素部分的不可编辑列表。一个ComboBox<T>有一个返回当前用户输入的valueProperty()方法。当列表可编辑时,用户输入可以基于从下拉列表中的选择或由用户手动提供的输入。清单 10-20 展示了如何在BorderPane的顶部添加一个列表,并添加一个监听器来记录所选的值,并保存到我们之前声明的TextArea中。

import javafx.scene.control.ComboBox;
...
private static String data = {"John Mayer", "Frank Sinatra",
    "Seth MacFarlane", "Nina Simone", "BB King", "Peggy Lee"};
...
ComboBox<String> comboBox = new ComboBox<>();
comboBox.getItems().addAll(data);
borderPane.setTop(comboBox);

comboBox.valueProperty().addListener(
    new ChangeListener<String>() {
        @Override
        public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
            textArea.appendText(newValue + "\n");
        }
});

Listing 10-20Creating and Configuring a JavaFX ComboBox<T>

ComboBox<T>值字段是一个ObservableValue<T>实例。侦听器被添加到这个实例中,当它的值发生变化并且它的changed(..)方法被调用时,它会得到通知。如您所见,changed(..)方法也接收前面的列表选择值作为参数,因为可能我们有一些逻辑需要这两者。

在 AWT 和 Swing 中,您无法直观地处理列表。你有那种外观和感觉,就是这样。JavaFX 支持更多的节点可视化定制,因为它甚至支持 CSS。这就是为什么在下一部分我们会让我们的ComboBox<T>列表变得有趣。在 Java FX 中,列表中的每个条目都是一个单元格,可以用不同的方式绘制。为此,我们必须向这个类添加一个CellFactory<T>,它将为列表中的每一项创建一个ListCell<T>的实例。

如果没有指定CellFactory<T>,将使用默认样式创建单元格。清单 10-21 显示了定制一个ComboBox<T>的代码。

comboBox.setCellFactory(
    new Callback<>() {
        @Override
        public ListCell<String> call(ListView<String> param) {
            return new ListCell<>() {
            {
                super.setPrefWidth(200);
            }

                @Override
                public void updateItem(String item, boolean empty) {
                    super.updateItem(item, empty);
                    if (item != null) {
                        setText(item);
                        if (item.contains("John") || item.contains("BB")) {
                            setTextFill(Color.RED);
                        } else if (item.contains("Frank") || item.contains("Peggy")) {
                            setTextFill(Color.GREEN);
                        } else if (item.contains("Seth")) {
                            setTextFill(Color.BLUE);
                        } else {
                            setTextFill(Color.BLACK);
                        }
                    } else {
                        setText(null);
                    }
                }
            };
        }
});

Listing 10-21Creating and Customizing Colors of Cells of a JavaFX ComboBox<T>

javafx.util.Callback接口是一个实用的工具接口,每次在某个动作之后,需要回调的时候都可以使用。在这种情况下,在将一个String值添加到ComboBox<T>节点的ListView(ListView顾名思义是可视化的,显示水平或垂直项目列表的ComboBox<T>的接口类型)之后,创建了一个单元格,并在那里插入了一些逻辑,以根据其值决定单元格中描述的文本的颜色。

在ListCell<T>声明中,有一段代码似乎不合适。

{
    super.setPrefWidth(200);
}

前面的代码块是一种有趣的方式,可以在匿名类的声明中从父类调用方法。这里调用setPrefWidth(200)是为了确保所有的ListCell<T>实例具有相同的大小。updateItem(..)中的逻辑非常明显,因此不需要任何扩展的解释。添加细胞工厂的结果如图 10-9 所示。

img/463938_2_En_10_Fig9_HTML.jpg

图 10-9

JavaFX 彩色组合框演示

国际化

交互式应用通常被创建为部署在多个服务器上,并且在多个位置 24/7 可用。因为不是所有人都说同一种语言,所以说服人们成为你的客户并使用你的应用的关键是用多种语言构建它。设计一个应用以满足多个国家的用户需求并轻松适应这些需求的过程称为国际化。例如,以最初的谷歌页面为例。根据访问它的位置,它会根据该区域改变语言。创建帐户时,您可以选择自己喜欢的语言。这并不意味着谷歌已经为每个地区建立了一个网络应用;这是一个单一的 web 应用,根据用户的位置以不同的语言显示文本。国际化应该在应用的设计阶段就考虑到,因为以后添加它是相当困难的。我们没有 web 应用,但我们将国际化本章中迄今为止构建的 Java FX 应用。

当您开始阅读关于国际化的内容时,您可能会注意到包含国际化属性文件的文件或目录被命名为 i18n ,这是因为在英语字母表中 i 和 n 之间有 18 个字母。

国际化是基于地区的。 Locale 是一个术语,指的是语言和地区的组合。应用语言环境决定了将使用哪个国际化文件来定制应用。语言环境的概念是由 Java 中的java.util.Locale类实现的,一个Locale实例代表一个地理、政治或文化区域。当一个应用依赖于地区时,我们说它是地区敏感的,正如现在大多数应用一样。选择地区也是用户必须做的事情。每个Locale都可以用来选择相应的语言环境资源,这些资源是包含语言环境特定配置的文件。这些文件按地区分组,通常可以在resources目录下找到。这些资源用于配置java.util.ResourceBundle的一个实例,该实例可用于管理特定于地区的资源。

为了构建一个合适的本地化用例,前面的 JavaFX 应用将被修改;该列表将包含一个动物名称列表,而不是歌手姓名,这些动物名称带有可以翻译成各种语言的标签。还将添加一个包含可用语言的列表,当从该列表中选择一种语言时,将使用相应的区域设置设置一个Locale静态变量,并且窗口将被重新初始化,以便所有标签都可以被翻译成新的语言。让我们从创建资源文件开始。

资源文件是扩展名为properties的文件,顾名思义,它包含属性和值的列表。每一行都遵循下面的模式:property_name=property_value,如果不是这样,它就被认为是一个国际化资源文件。每个属性名在文件中必须是唯一的;如果有重复的,它会被忽略,IntelliJ IDEA 会用红色下划线来表示。对于需要支持的每种语言,我们需要创建一个属性文件,它包含相同的属性名,但不同的值,因为这些值将代表每种语言中该值的事务。所有文件的名称都必须包含一个通用后缀,并以语言名称和国家/地区结尾,用下划线分隔,因为这是创建语言环境实例所需的两个元素。对于我们的 JavaFX 应用,我们有三个文件,如图 10-10 所示。

img/463938_2_En_10_Fig10_HTML.jpg

图 10-10

包含三个资源文件的资源包

后缀是global,这也将是我们的资源包名称。IntelliJ IDEA 让这一点变得非常明显,它指出我们的文件是用来做什么的,并以最明显的方式描述它们。文件内容如表 10-1 所示。

表 10-1

资源文件的内容

|

属性名称

|

global_en_GB 中的属性值

|

global_fr_FR 中的属性值

|

global_it_IT 中的属性值

|
| --- | --- | --- | --- |
| 英语 | 英语 | 英语怎么说 | 英语怎么说 |
| 法语 | 法语 | 法国人 | 弗朗西丝 |
| 意大利的 | 意大利的 | 义大利语 | 意大利语 |
| 猫 | 猫 | 闲谈 | 高谭市 |
| 狗 | 狗 | 钱 | 手杖 |
| 鹦鹉 | 鹦鹉 | 钱 | 小鹦鹉 |
| 老鼠 | 老鼠 | 笑一个 | 地形图 |
| 母牛 | 母牛 | 鹦鹉!鹦鹉 | 牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛啊牛 |
| 猪 | 猪 | Porc | 腰子 |
| 窗口标题 | Java FX 演示窗口! | Java FX 演示窗口! | Java FX 演示窗口! |
| 拜拜 | 拜拜! | 拜拜! | 再见! |
| 选择宠物 | 选择宠物: | 选择宠物: | 选择宠物: |
| 选择语言 | 选择语言: | 选择语言: | 选择语言: |

IntelliJ IDEA 可以帮助您轻松地编辑资源包文件,并通过为它们提供一个特殊的视图来确保您不会遗漏任何键。当您打开一个资源文件时,在左下角您应该看到两个选项卡:一个是命名文本,单击它允许您将属性文件作为普通文本文件进行编辑;另一个是命名资源包,单击它将打开一个特殊视图,左侧是资源文件中的所有属性名称,右侧是包含所选属性名称值的所有资源文件的视图。在图 10-11 中,您可以看到这个视图和属性 ChooseLanguage 的值。

img/463938_2_En_10_Fig11_HTML.jpg

图 10-11

资源包 IntelliJ 想法编辑器

属性名可以包含特殊字符,如下划线和点来分隔它们的各个部分。在本书的例子中,属性名很简单,因为我们只有很少的属性名。在更大的应用中,属性名通常包含一个与其用途相关的前缀;例如,如果属性值是一个标题,该名称将带有前缀title。我们文件中的属性名可以被更改为清单 10-22 中列出的名称。

English --> label.lang.english
French --> label.lang.french
Italian --> label.lang.italian
Cat --> label.pet.cat
Dog --> label.pet.dog
Parrot --> label.pet.parrot
Mouse --> label.pet.mouse
Cow --> label.pet.cow
Pig --> label.pet.pig
WindowTitle --> title.window
Byebye --> label.button.byebye
ChoosePet --> label.choose.pet
ChooseLanguage --> label.choose.language

Listing 10-22Recommended Internationalization Property Names

既然我们已经介绍了资源文件应该如何编写,那么让我们看看如何使用它们。要创建一个ResourceBundle,我们首先需要一个场所。应用有一个默认的区域设置,可以通过调用Locale.getDefault()来获得,一个ResourceBundle实例可以通过使用一个包名和一个区域设置实例来获得,如下面的代码片段所示:

Locale locale = Locale.getDefault();
ResourceBundle labels = ResourceBundle.getBundle("global", locale);

当获得一个有效的ResourceBundle时,可以用它来替换所有硬编码的String实例,调用从匹配所选区域设置的资源文件中返回文本值。所以每次我们需要为一个节点设置标签时,我们不使用实际的文本,而是使用对:resourceBundle.getString("[property_name]")的调用来获取本地化的文本。

重新加载 JavaFX 窗口时,会重新创建它的所有节点。为了能够影响方式,我们需要添加几个静态属性来保持所选的区域设置。对于我们到目前为止构建的应用,在将其国际化后,代码看起来如清单 10-23 所示。

package com.apress.bgn.ten;

import javafx.application.*;
import javafx.geometry.*;
import javafx.scene.*;
import javafx.stage.*;

import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Locale;
import java.util.ResourceBundle;

public class JavaFxDemo extends Application {

    private static final String BUNDLE_LOCATION = "chapter10/using-javafx/src/main/resources";

    private static ResourceBundle resourceBundle = null;
    private static Locale locale = new Locale("en", "GB");
    private static int selectedLang = 0;

    public static void main(String... args) {
        Application.launch(args);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        loadLocale(locale);
        primaryStage.setTitle(resourceBundle.getString("WindowTitle"));

        String[] data = {resourceBundle.getString("Cat"),
                resourceBundle.getString("Dog"),
                resourceBundle.getString("Parrot"),
                resourceBundle.getString("Mouse"),
                resourceBundle.getString("Cow"),
                resourceBundle.getString("Pig")};

        BorderPane borderPane = new BorderPane();

        //Top
        final ComboBox<String> comboBox = new ComboBox<>();
        comboBox.getItems().addAll(data);

        final ComboBox<String> langList = new ComboBox<>();
        String[] languages = {
                resourceBundle.getString("English"),
                resourceBundle.getString("French"),
                resourceBundle.getString("Italian")};

        langList.getItems().addAll(languages);
        langList.getSelectionModel().select(selectedLang);

        GridPane gridPane = new GridPane();
        gridPane.setHgap(10);
        gridPane.setVgap(10);

        Label labelLang = new Label(resourceBundle.getString("ChooseLanguage"));
        gridPane.add(labelLang, 0, 0);
        gridPane.add(langList, 1, 0);

        Label labelPet = new Label(resourceBundle.getString("ChoosePet"));
        gridPane.add(labelPet, 0, 1);
        gridPane.add(comboBox, 1, 1);

        borderPane.setTop(gridPane);

        //Center
        final TextArea textArea = new TextArea();
        textArea.setEditable(false);
        borderPane.setCenter(textArea);

        comboBox.valueProperty().addListener((observable, oldValue, newValue)
                -> textArea.appendText(newValue + "\n"));

        langList.valueProperty().addListener((observable, oldValue, newValue)
                -> {
            int idx = langList.getSelectionModel().getSelectedIndex();
            selectedLang = idx;
            if (idx == 0) {
                //locale = Locale.getDefault();
                new Locale("en", "GB");
            } else if (idx == 1) {
                locale = new Locale("fr", "FR");
            } else {
                locale = new Locale("it", "IT");
            }

            primaryStage.close();
            Platform.runLater(() -> {
                try {
                    new JavaFxDemo().start(new Stage());
                } catch (Exception e) {
                    System.err.println("Could not reload application!");
                }
            });
        });

        HBox box = new HBox();
        box.setPadding(new Insets(10, 12, 10, 12));
        box.setSpacing(10);
        box.setAlignment(Pos.BASELINE_RIGHT);
        box.setStyle("-fx-background-color: #85929e;");
        Button exitButton = new Button();
        exitButton.setText(resourceBundle.getString("Byebye"));
        exitButton.setOnAction(event -> Platform.exit());
        box.getChildren().add(exitButton);
        borderPane.setBottom(box);

        //Bottom
        StackPane root = new StackPane();
        root.getChildren().add(borderPane);
        primaryStage.setScene(new Scene(root, 500, 500));
        primaryStage.show();
    }

    private void loadLocale(Locale locale) throws Exception {

        File file = new File(BUNDLE_LOCATION);
        URL[] url = {file.toURI().toURL()};
        ClassLoader loader = new URLClassLoader(url);

        resourceBundle = ResourceBundle.getBundle("global", locale, loader);
    }
}

Listing 10-23JavaFX Internationalized Application

您可能想知道为什么我们使用另一种方式加载资源包,以及为什么使用包位置的完整相对路径。如果我们希望应用可以从 IntelliJ 接口运行,我们必须提供一个相对于应用执行上下文的路径。当应用在可运行的 Java 档案中构建和打包时,资源文件是它的一部分,位于类路径中。当通过在 Java IDE 中执行main()方法来运行应用时,类路径相对于项目的实际位置。

清单 10-24 中的代码片段通过关闭Stage来重启场景,然后实例化一个 JavaFxDemo 对象并调用start(..)。这意味着整个层次节点结构被重新创建,唯一保留的状态是静态对象中定义的状态。这是区域设置所需要的,因为start(..)方法的执行现在从调用loadLocale(locale)开始,?? 选择应用的区域并加载ResourceBundle,这样所有节点都可以用它返回的文本进行标记。

primaryStage.close();
Platform.runLater(() -> {
    try {
        new JavaFxDemo().start(new Stage());
    } catch (Exception e) {
        System.err.println("Could not reload application!");
    }
});

Listing 10-24JavaFX Code Snippet to Restart the Scene

到目前为止,我们构建并使用的应用非常简单。如果您需要构建更复杂的接口,并且需要国际化,这将意味着需要配置更多的翻译。您可能需要不同数字和日期格式的文件,或者多个资源包。国际化是一个很大的话题,也是一个很重要的话题,因为现在很少构建一个应用在一个地区使用。对于一个 Java 初学者来说,知道支持类是什么以及如何使用它们是一个很好的起点。

构建 Web 应用

现在,我们正在构建一个 web 应用。web 应用是运行在服务器上的应用,可以使用浏览器进行访问。直到最近,大多数 Java 应用都需要像 Apache Tomcat 或 Glassfish 这样的 Web 服务器,或者像 JBoss(目前称为 WildFly)或 TomEE 这样的企业服务器来托管,以便可以访问它们。您将使用类和 HTML 或 JSP 文件编写 web 应用,将其打包在 WAR (Web 归档)或 EAR(企业归档)中,将其部署到服务器,然后启动服务器。服务器将提供应用的上下文,并将请求映射到提供答案作为响应的类。假设应用将部署在 Tomcat 服务器上,在图 10-12 中,您可以看到一个已部署应用功能的抽象模式。

img/463938_2_En_10_Fig12_HTML.jpg

图 10-12

部署在 Apache Tomcat 服务器上的 Web 应用

对 web 应用的请求可以来自浏览器以外的其他客户端(例如,移动应用),但是因为本节涵盖了 web 应用,所以我们将假设对我们的应用的所有请求都来自浏览器。

先稍微解释一下互联网。互联网是一个信息系统,由许多连接在一起的计算机组成。一些计算机托管提供对应用的访问的应用服务器,一些计算机访问这些应用,而一些计算机两者都做。这些计算机之间的通信是通过一系列协议在网络上完成的:HTTP、FTP、SMTP、POP 等等。最流行的协议是 HTTP,代表超文本传输协议,它是一种不对称的请求-响应客户端-服务器协议,这意味着客户端向服务器发出请求,然后服务器发送响应。后续的请求彼此不了解,它们不共享任何状态,因此它们是无状态的。HTTP 请求可以有不同的类型,根据它们要求服务器上的应用执行的动作来分类,但是有四种类型是开发人员更常用的(图 10.12 中的请求箭头中列出的一种)。我们不会深入讨论关于请求组件的细节,因为这与 Java 并不真正相关,但是我们将只讨论足够多的细节来理解 web 应用是如何工作的。下面列出了四种请求类型和服务器为每一种请求返回的响应类型。

img/463938_2_En_10_Fig13_HTML.png

图 10-13

Firefox 中的网络调试器视图

  • GET :每当用户在浏览器中输入一个 URL,比如 http://my-site.com/index.html ,浏览器就把这个地址转换成一个请求消息,发送给 web 服务器。在 Firefox 中打开调试器视图,点击网络标签,尝试访问 https://www.google.com/ ,就可以轻松查看浏览器做了什么。在图 10-13 中,您可以看到 Firefox 调试器视图显示了被请求的 URL 和请求消息的内容。

在图像的右边,您可以看到被请求的 URL、请求的类型(也称为请求方法,在本例中是 GET)以及请求被发送到的服务器的远程地址。还有一个名为 Raw headers 的按钮,它将打开一个视图,以文本形式显示请求和响应的内容。GET 请求用于从服务器中检索某些东西,在本例中是一个网页。如果可以找到该网页,则发送包含浏览器要显示的页面和其他属性(如状态代码)的响应,以表明一切正常。有一个 HTTP 状态代码列表,最重要的是 200 代码,这意味着一切正常。在前面的图像中,您可以看到,在最初的请求得到回复后,为了显示页面,完成了许多额外的请求,并且所有后续的请求都是成功的,因为服务器返回的状态放在表中的第一列,并且总是 200。

  • PUT :当数据被发送到服务器用于更新现有数据时,使用这种类型的请求。在企业应用中,PUT 请求被解释为更新现有对象的请求。该请求包含对象的更新版本和识别它的方法。成功的 PUT 请求会生成一个状态代码为 204 的响应。

  • POST :当服务器需要被指示保存数据以便存储时,使用这种类型的请求。与 PUT 请求不同的是,这个数据在服务器上还不存在。在企业应用中,POST 请求或者用于发送凭证以便对用户进行身份验证,或者用于发送将用于创建新对象的数据。当 POST 请求用于发送凭据时,当用户通过身份验证时,响应状态代码为 200;当用户凭据不良时,响应状态代码为 401(未授权);当 POST 请求用于发送要保存的数据时,如果创建了对象,则返回 201 状态代码。

  • DELETE :当要求服务器删除数据时,使用这种类型的请求。当一切正常时,响应代码是 200,如果不正常,则是与原因相关的任何其他错误代码。

在更复杂的应用中还有一些其他的 HTTP 方法。如果你对请求方法、状态码和 HTTP 基础知识更感兴趣,我很自信地推荐你看看这个教程: http://www.steves-internet-guide.com/http-basics 。

现在让我们回到编写 Java Web 应用上来。

我们已经提到,直到不久前,我们需要一个服务器来托管一个 web 应用。从几年前开始,情况就不再是这样了。随着用于测试目的的数据库和具有最低功能的应用被嵌入式数据库所取代,web 服务器也发生了同样的情况。如果您想快速编写一个简单的 web 应用,现在可以选择使用像 Jetty 或 Tomcat Embedded 这样的嵌入式服务器。用嵌入式服务器支持复杂的页面非常困难,但是嵌入式服务器通常用于只需要简单 REST APIs 的微服务。

带有嵌入式服务器的 Java Web 应用

对于本章的这一节,嵌入式 Tomcat 服务器用于显示几个简单的网页,使用 Java servlet(耐心年轻的学徒,稍后会解释)。使用 Tomcat 10.0.7 版本,意味着支持 Java 模块。使用嵌入式 Apache Tomcat 服务器的优点是,您可以通过执行 main 方法来运行 web 应用。

清单 10-25 中描述了代码,它声明了一个非常简单的 servlet,作为应用的主页。

package com.apress.bgn.ten;

import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.startup.Tomcat;
// other import statements omitted

public class WebDemo {

    private static final Logger LOGGER = Logger.getLogger(Main.class.getName());

    public static final Integer PORT = Optional.ofNullable(System.getenv("PORT")).map(Integer::parseInt).orElse(8080);
    public static final String TMP_DIR = Optional.ofNullable(System.getenv("TMP_DIR")).orElse("/tmp/tomcat-tmp");
    public static final String STATIC_DIR = Optional.ofNullable(System.getenv("STATIC_DIR")).orElse("/tmp/tomcat-static");

    public static void main(String... args) throws IOException, LifecycleException {
        Tomcat tomcat = new Tomcat();
        tomcat.setBaseDir(TMP_DIR);
        tomcat.setPort(PORT);
        tomcat.getConnector();

        tomcat.setAddDefaultWebXmlToWebapp(false);

        String contextPath = ""; // root context
        boolean createDirs =  new File(STATIC_DIR).mkdirs();
        if(createDirs) {
            LOGGER.info("Tomcat static directory created successfully.");
        } else {
            LOGGER.severe("Tomcat static directory could not be created.");
        }
        String docBase = new File(STATIC_DIR).getCanonicalPath();
        Context context = tomcat.addWebapp(contextPath, docBase);

        addIndexServlet(tomcat, contextPath, context); // omitted

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            try {
                tomcat.getServer().stop();
            } catch (LifecycleException e) {
                e.printStackTrace();
            }
        }));

        tomcat.start();
        tomcat.getServer().await();
    }
}

Listing 10-25Simple Java Application with an Embedded Server

例如,当您不需要利用模板库(如 JSP)生成复杂的 web 页面时,使用嵌入式 Tomcat 服务器编写应用是非常容易的。清单 10-24 中的代码片段只需要tomcat-embed-core库作为依赖项,创建服务器的步骤非常简单,在这里解释如下:

  • 创建一个org.apache.catalina.startup.Tomcat实例并选择端口来公开它。在这种情况下,它是 8080,PORT变量的默认值,除非使用同名的系统环境变量声明。

  • 为Tomcat实例设置一个基本目录,运行的服务器将在这里保存它生成的各种文件,比如日志。在这种情况下,目录被配置为/tmp/tomcat-tmp,除非使用带有TMP_DIR名称的系统环境变量进行声明。运行应用的用户应该对该位置拥有写权限。

  • 设置Tomcat的静态文件所在的目录。在这种情况下,目录被配置为/tmp/tomcat-static,除非使用带有STATIC_DIR名称的系统环境变量进行声明。运行应用的用户应该对该位置拥有写权限。

  • 通过调用tomcat.setAddDefaultWebXmlToWebapp(false)禁用Tomcat的默认配置。在这种情况下,这会阻止org.apache.jasper.servlet.JspServlet被注册。这个 servlet 支持在 webapp 中使用 JSP 文件,但是在配置时会自动接管并假设任何请求都必须解析为 JSP 页面,因此 Java Servlets 会被忽略。因为我们想保持应用的简单性并使用 Java servlets,所以我们禁用了它。

  • 通过添加关闭挂钩,确保服务器在应用关闭时正常关闭。

  • 编写一个简单的 servlet 来显示应用的主页,以测试服务器是否正确启动并按预期工作。这是通过前面的清单中省略的addIndexServlet(..)方法来完成的,以确保焦点在Tomcat实例上。方法如清单 10-26 所示。

    private static void addIndexServlet(Tomcat tomcat, String contextPath, Context context) {
        HttpServlet indexServlet = new HttpServlet() {
            @Override
            protected void doGet(HttpServletRequest req, HttpServletResponse resp)
                    throws IOException {
                PrintWriter writer = resp.getWriter();
                writer.println("<html><title>Welcome</title><body style=\"background-color:black\">");
                writer.println("<h1 style=\"color:#ffd200\"> Embedded Tomcat 10.0.7 says hi!</h1>");
                writer.println("</body></html>");
            }
        };
        String servletName = "IndexServlet";
        String urlPattern = "/";
        tomcat.addServlet(contextPath, servletName, indexServlet);
        context.addServletMappingDecoded(urlPattern, servletName);
    }

Listing 10-26A Simple Method That Creates a Very Simple Servlet and Registers It with a Tomcat Instance

servlet 实例必须与一个名称和一个 URL 模式相关联,当用户试图打开serverURL/contextPath/urlPattern页面时,调用doGet(..)方法,返回在其主体中构造的响应。

部署在服务器(甚至是嵌入式服务器)上的 Java web 应用需要一个上下文路径。上下文路径值是访问应用的 URL 的一部分。URL 由四部分组成:

  • protocol:客户端和服务器进行通信所使用的应用层协议,如http、https、ftp等。

  • hostname:DNS 域名(如 www.google.com )或 ip 地址(如 192.168.0.255)或网络中可识别的任何别名。例如,当从同一台计算机上访问应用时,可以使用安装在127.0.0.1、本地主机或0.0.0.0上的服务器。

  • path and filename:资源的名称和位置,在服务器文档基目录下。用户通常会请求查看服务器上托管的特定页面,这就是为什么 URL 看起来像这样: https://docs.oracle.com/index.html 。出于安全原因,一种非常常用的做法是通过使用内部映射(称为 URL 重定向)来隐藏路径和文件名。

那么前面提到的contextPath值是从哪里来的呢?当我们像前面的代码示例一样声明了一个嵌入式服务器时,它托管的任何文件都可以通过使用http://localhost:8080/来访问。但是在专用服务器上,可以同时运行多个应用,必须有一种方法将它们分开,对吗?这就是contextPath值派上用场的地方。通过将上下文路径设置为/demo而不是空字符串,可以在http://localhost:8080/demo/访问WebDemo应用及其提供给用户的资源。

总之,回到 Java web 应用。Java Web 应用是动态的;使用Servlets和JSP(Java Server Pages)页面从 Java 代码生成页面。因此,Java Web 应用不是在服务器上运行,而是在服务器的 Web 容器中运行。(这就是为什么 Tomcat 或 Jetty 有时被称为 Servlet 容器。)web 容器为 Java Web 应用提供了 Java 运行时环境。Apache Tomcat 就是这样一个运行在 JVM 中的容器,它支持 servlets 和 JSP 页面的执行。一个 servlet 是一个 Java 类,是jakarta.servlet.http.HttpServlet的子类。这种类型的实例在 web 容器中响应 HTTP 请求。

img/463938_2_En_10_Figd_HTML.jpg Apache Tomcat 10.x 是 Jakarta EE(正式的 Java EE)技术子集的开源软件实现。Tomcat 基于 Servlet 5.0、JSP 3.0、EL 4.0、WS 2.0 和 JASIC 2.0。在 Tomcat 9.x 之前,servlet 是一个 Java 类,是javax.servlet.http.HttpServlet的子类。Tomcat 10.x 需要从javax.包迁移到jakarta.</emphasis>,以将 Oracle 官方 Java 产品与使用 Eclipse 构建服务器构建的开源产品分开。 4

一个 JSP 页面是一个带有。包含 HTML 和 Java 代码的 jsp 扩展。JSP 页面在第一次被访问时被 web 容器编译成 servlet。本质上,servlet 是 Java Web 应用的核心元素。服务器还必须知道 servlet 的存在以及如何识别它,这就是调用tomcat.addServlet(contextPath, servletName, servlet)的来源。它基本上是将名为servletName的 servlet 添加到带有contextPath值上下文路径的应用上下文中,然后将一个 URL 模式关联到 servlet,调用context.addServletMapping(urlPattern, servletName)。

当 Java Web 应用运行时,它的所有 servlets 和 JSP 都在它的上下文中运行,但是它们必须在代码中添加到上下文中,并映射到 URL 模式。与该 URL 模式匹配的请求 URL 将访问该 servlet。在清单 10-26 中,servlet 是通过实例化HttpServlet抽象类并产生一个匿名 servlet 实例而当场创建的。清单清单 10-27 描述了一个名为SampleServlet的具体类,它扩展了HttpServlet类。这样做的好处是 URL 模式和 servlet 名称可以成为这个类的属性,从而简化了将它们添加到应用上下文的语法。

package com.apress.bgn.ten;

import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.logging.Logger;

public class SampleServlet  extends HttpServlet {
    private static final Logger LOGGER = Logger.getLogger(SampleServlet.class.getName());

    private final String servletName = "sampleServlet";
    private final String urlPattern = "/sample";

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        PrintWriter writer = resp.getWriter();
        try {
            writer.println(WebResourceUtils.readResource("index.html"));
        } catch (Exception e) {
            LOGGER.warning("Could not read static file : " + e.getMessage());
        }
    }

    @Override
    public String getServletName() {
        return servletName;
    }

    public String getUrlPattern() {
        return urlPattern;
    }
}

Listing 10-27The SampleServlet Class

出于实际原因,将urlPattern属性添加到该类中,以便将与该 servlet 相关的所有内容保存在一个地方。servletName也是一样。如果打算多次实例化这个类来创建多个 servlets,那么这两个属性应该声明为可配置的。将这个 servlet 添加到应用非常容易。需要创建一个这种类型的对象,然后必须调用tomcat.addServlet(..)和context.addServletMappingDecoded(..),如清单 10-28 所示。

SampleServlet sampleServlet = new SampleServlet();
tomcat.addServlet(contextPath, sampleServlet.getServletName(), sampleServlet);
context.addServletMappingDecoded(sampleServlet.getUrlPattern(), sampleServlet.getServletName());

Listing 10-28Adding the SampleServlet Class to the Web Application

在doGet(..)方法中,index.html文件的内容被读取(使用WebResourceUtils,它是本章项目的一部分,但与本章无关),并使用响应PrintWriter写入响应对象。

如您所见,doGet(..)方法接收两个对象作为参数:HttpServletRequest实例被读取,从客户端发送的请求的所有内容都可以使用适当的方法和 HttpServletResponse 实例进行访问,后者用于向响应添加信息。在前面的代码示例中,我们只是编写从另一个文件读取的 HTML 代码。可以调用的额外方法是设置响应状态的response.setStatus(HttpServletResponse.SC_OK)。

除了doGet(..)方法,还有与每个 HTTP 方法匹配的do*(..)方法,它们声明相同类型的参数。

从 Servlet 3.0 开始,可以使用@WebServlet注释来编写 Servlet,这消除了显式添加到 web 应用并在清单 10-28 所示的上下文中映射的必要性,因为它们是在 Tomcat 启动时自动选取的。此外,也不需要实例化 servlet 类。

Servlet 3.0 之后的SampleServlet类如清单 10-29 所示。

package com.apress.bgn.ten;

import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.*;
// other import statements omitted

@WebServlet(
        name = "sampleServlet",
        urlPatterns = {"/sample"}
)
public class SampleServlet  extends HttpServlet {
    private static final Logger LOGGER = Logger.getLogger(SampleServlet.class.getName());

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        PrintWriter writer = resp.getWriter();
        try {
            writer.println(WebResourceUtils.readResource("index.html"));
        } catch (Exception e) {
            LOGGER.warning("Could not read static file : " + e.getMessage());
        }
    }
}

Listing 10-29Adding the SampleServlet Class to the Web Application

这就是我们处理 servlets 的方式,但是我们如何使用嵌入式服务器处理 JSP 页面呢?不是不可能,但也不容易。这就是为什么,对于这个任务,人们通常会选择更聪明的框架,比如 Spring Boot。 5

为了避免经历大量的设置代码和细节,JSP 语法将在一个 WEB 应用上解释,该应用必须部署在一个 Tomcat 服务器的独立实例上。

独立服务器上的 Java Web 应用

设计用于部署在应用服务器上的 Java 应用被打包为 Web 档案(war)或企业应用档案(ear)。这些是特殊类型的 Java 档案(jar ),用于将其他 jar、JSP(Java 服务器页面)、Java 类、静态页面和 web 应用中的其他资源组合在一起。有一个叫maven-war-plugin的 maven 插件,把一个神器打包成一场战争。EAR 是 Jakarta EE 使用的一种文件格式,用于将一个或多个模块打包到应用服务器上的单个部署中;它基本上将一组 jar 和 wars 链接到一个应用中。

在本章中,我们构建了一个非常简单的 web 应用,打包成一个 war,包含 Java 服务器页面,并部署到一个 Apache Tomcat 服务器的独立实例中。

要在本地安装 Apache Tomcat 服务器,请访问位于 https://tomcat.apache.org/download-10.cgi 的官方网站,下载 Apache Tomcat 版本 10.0.7,并按照您的操作系统的说明进行操作。因为 Apache Tomcat 是作为一个档案文件提供的,所以安装过程应该很简单,只需在您的计算机上的某个地方打开它。在本章中,IntelliJ IDEA 使用 Tomcat launcher 来运行 web 应用,因此与服务器的交互将是最小的。

Java web 应用的结构与典型的 Java 应用不同。它包含典型的main和test目录,但也包含一个包含 web 资源的webapp目录。项目结构如图 10-14 所示。

img/463938_2_En_10_Fig14_HTML.jpg

图 10-14

Web 应用结构变化

注意位于WEB-INF目录下的web.xml文件。这个文件定义了 web 应用的结构。在 Servlet 3.0 之前,这个文件是将 Servlet 映射到 urlPatterns 并将其配置为应用的一部分的唯一方法。在 Servlet 3.0 和注释的引入之后,这个文件大部分是空的。

当构建 web 应用时,应用的字节码保存在WEB-INF/classes下。如果应用使用第三方库,它们都被保存到WEB-INF/lib中。

现在,回到 Java 服务器页面。

有两种编写 JSP 页面的方法。最简单的一种方法是使用JSP script let,这种方法现在很少使用,因为它将 HTML 代码与 Java 代码结合在一起。JSP script let 是使用指令标签嵌入在 HTML 代码中的 Java 代码片段。有三种类型指令标签:

  • <%@ page ... %>声明性指令用于向容器提供指令。使用此指令声明的指令属于当前页面,可以在页面中的任何位置使用。这种指令可用于导入 Java 类型或定义页面属性。示例:

  • <%@ include ... %> declarative 用于在翻译阶段包含一个文件。因此,使用该指令的当前 JSP 文件是其内容和使用该指令声明的文件内容的组合。

  • <%@ include file = "footer.jsp" >

  • <%@ taglib ... %> declarative 用于声明一个标签库,其中包含将在 JSP 页面中使用的元素。这个声明性很重要,因为它用于导入一个带有自定义标记和元素的库,这些标记和元素将用于编写 JSP 页面。这些标签提供动态功能,而不需要 scriptlets。

<%@ page import="java.util.Date" %>
<%@ page language="java" contentType="text/html; charset=US-ASCII" pageEncoding="US-ASCII" %>

图 10-14 中的index.jsp箭头非常简单,其内容在清单 10-30 中描述:

<%@ page import="java.util.Date" %>
<%@ page language="java" contentType="text/html; charset=US-ASCII" pageEncoding="US-ASCII" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
        <head><title>Web Application Demo JSP Page</title></head>
        <body style="background-color:black">
                <h1 style="color:#ffd200"> Today is <%= new Date() %></h1>
        </body>
</html>

Listing 10-30The Very Simple index.jsp Page Contents

它只打印今天的日期,并通过调用new Date()来完成。如你所见,我们在一个看起来像 HTML 的页面中使用了 Java 代码。因为那些指令在那里,扩展名是.jsp,,所以容器知道这个文件必须被编译成 servlet。当 web 应用的根域被访问时,它打开的默认页面是一个名为index.html或index.htm或index.jsp的文件。除了在WEB-INF目录中添加名为index.jsp的文件并确保容器可以找到它之外,剩下要做的就是在 IntelliJ IDEA 中配置一个 Apache Tomcat 启动器,并在启动 Tomcat 之前配置它来部署构建这个应用时产生的 war。

要配置 Apache Tomcat 启动器,IntelliJ IDEA 需要启用 Tomcat 和 TomEE 插件。如果您安装 IntelliJ IDEA 时没有对其进行自定义,则默认情况下会安装该插件。如果你设法卸载了它,只需打开 IntelliJ IDE 首选项窗口,选择插件,在市场中找到它并勾选它的复选框,如图 10-15 所示。

img/463938_2_En_10_Fig15_HTML.png

图 10-15

在 IntelliJ IDEA 中启用 Tomcat 和 TomEE 插件

一旦插件安装完毕,点击启动部分并选择编辑配置...,从左侧列表中选择 Tomcat 服务器➤本地,如图 10-16 所示。

img/463938_2_En_10_Fig16_HTML.jpg

图 10-16

在 IntelliJ IDEA 中创建 Apache Tomcat 启动器

一个新的对话窗口打开。点击配置按钮,选择本地 Apache Tomcat,点击确定,如图 10-17 所示。

img/463938_2_En_10_Fig17_HTML.jpg

图 10-17

在 IntelliJ IDEA 中创建 Apache Tomcat 启动器:选择 Tomcat 服务器

选择服务器后,点击修复按钮或者部署标签,点击+符号选择工件。IntelliJ IDEA 将识别项目中的所有 web 应用,并提供一个列表供选择。选择simple-webapp,如图 10-18 所示。

img/463938_2_En_10_Fig18_HTML.jpg

图 10-18

在 IntelliJ IDEA 中创建 Apache Tomcat 启动器:选择要部署的 web 应用

随意编辑启动器的名称和上下文路径,如图 10-19 所示。

img/463938_2_En_10_Fig19_HTML.jpg

图 10-19

在 IntelliJ IDEA 中创建 Apache Tomcat 启动器:选择要部署的 web 应用

这样配置启动器后,启动服务器并在浏览器中打开http://localhost:8080/demo页面。您应该会在页面上看到这样一条简单的消息:

Today is Mon Aug 20 01:41:29 BST 2018

当您自己运行该应用时,所描述的日期将是您系统上的日期。

既然已经提到了 taglibs ,那我们也来谈一点。最基本的标签库被命名为 JSTL ,代表 JSP 标准标签库。其他更先进的标记库由 JSF (Java Server Faces)、百里香或 Spring 提供。该库中定义的标签可用于编写根据请求属性改变行为的 JSP 页面,可用于迭代、测试值、国际化和格式化。基于所提供的 JSTL 函数,标签被分成五类,只有在指定了适当的指令之后,它们才能在 JSP 页面中使用。下面列出了五个指令以及标签涵盖的主题:

  • <%@ taglib uri=" http://java.sun.com/jsp/jstl/core " prefix="c" %> JSTL 核心标签提供对显示值、迭代、条件逻辑、捕捉异常、url、转发或重定向响应的支持。

  • <%@ taglib uri=" http://java.sun.com/jsp/jstl/fmt " prefix="fmt" %> JSTL 格式化标签通过地区和资源包提供数字、日期和 i18n 支持的格式化。

  • <%@ taglib uri=" http://java.sun.com/jsp/jstl/sql " prefix="sql" %> JSTL SQL 标签为与关系数据库的交互提供支持,但从不在网页中使用 SQL,因为它非常容易被黑客攻击(只需在 Google 上查找术语 SQL Injection)。

  • <%@ taglib uri=" http://java.sun.com/jsp/jstl/xml " prefix="x" %> JSTL XML 标签为处理 XML 文档、解析、转换和 XPath 表达式求值提供支持。

  • <%@ taglib uri=" http://java.sun.com/jsp/jstl/functions " prefix="fn" %> JSTL 函数标签提供了许多函数,可用于执行文本操作等常见操作。

现在我们知道了基本的标签类别,你认为我们需要使用哪些类别来重新设计我们的index.jsp页面?如果你想过和的核心,你就对了。此外,使用标记库的 JSP 页面通常由一个 servlet 进行备份,该 servlet 为将在 JSP 页面中使用的请求设置适当的属性。所以让我们修改一下index.jsp页面,如清单 10-31 所示。

<%@ page language="java" contentType="text/html;charset=US-ASCII" pageEncoding="US-ASCII"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/fmt" prefix="fmt" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
    <head>
        <title>Web Application Demo JSP Page</title>
    </head>
    <body style="background-color:black">
        <fmt:formatDate value="${requestScope.today}" pattern="dd/MM/yyyy" var="todayFormatted"/>
        <p style="color:#ffd200"> Today is <c:out value="${todayFormatted}" /> </p>
    </body>
</html>

Listing 10-31Using FMT adn Core Taglibs to Rewrite index.jsp

现在,让我们重新命名它,以清楚它的用途:to date.jsp并编写一个名为DateServlet的 servlet 类,将today属性添加到请求中,该属性将由<fmt:formatDate>标记格式化,结果保存到todayFormatted变量中,稍后由<c:out>标记打印出来。清单 10-32 中描述了DateServlet。

package com.apress.bgn.ten;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.util.Date;

@WebServlet(
        name = "dateServlet",
        urlPatterns = {"/date"}
)
public class DateServlet extends HttpServlet {

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {
        System.out.println(" ->>> Getting date ");
        request.setAttribute("today", new Date());
        RequestDispatcher rd = getServletContext().getRequestDispatcher("/date.jsp");
        rd.forward(request, response);
    }
}

Listing 10-32DateServlet Class That Provides the today Attribute for the date.jsp Page

现在我们只需重启应用,第一页将显示Today is 06/07/2021,您将在系统上看到代码运行的日期。

如果你认为编写 Java web 应用很麻烦,那么你是对的。对于这样的任务,纯 Java 是相当乏味的。专业的 Java web 应用通常是使用框架编写的,这些框架可以轻松地创建页面并将其链接到后端。更有甚者,现在的趋势是使用强大的 JavaScript 框架,如 Angular 和 React,用 JavaScript 创建界面;使用高级 CSS4,许多 UI 设计现在也可以 100%在 CSS3 或 CSS4 中完成,并使用 Web 服务调用(通常是 REST)与企业服务器上托管的 Java 后端应用通信。反正好奇就去查;这个主题非常广泛,但是像 Spring 这样的框架使得设置环境和开始开发变得非常容易。只是不要陷入在不了解框架的基础上使用框架的陷阱。

摘要

本章介绍了重要的开发工具和技术,JDK 中为它们提供支持的类,以及重要的 Java 库,这些库可能会让你的开发工作变得实用而愉快。JDK 从未在 GUI 支持方面大放异彩,但 JavaFX 是从 AWT 和 Swing 发展而来的,可能会有未来。本章主题的完整列表如下:

  • 如何编写交互式控制台应用

  • 如何编写一个带有 Swing 界面的交互式应用

  • JavaFX 架构的基础

  • 如何用 JavaFX 接口编写交互式应用

  • 如何国际化您的应用

  • 如何使用嵌入式服务器编写 web 应用

  • 什么是 servlet

  • 什么是 JSP Scriptlet

  • 如何使用标记库编写 JSP 页面

  • 如何将 Java web 应用部署到 Apache Tomcat

**

十一、使用文件

软件最重要的功能之一是信息组织和存储,目的是使用和共享信息。信息写在纸上,存储在现实生活中有组织的柜子里,可以从那里检索。软件应用也做类似的事情。信息被写在文件中,文件被组织在目录中,最终甚至在更复杂的结构中,被命名为数据库。Java 提供了从文件和数据库读取信息的类,以及写入文件和向数据库写入信息的类。在前面的章节中已经提到了数据库,在第章 9 中,介绍了一个使用 Derby 内存数据库的简单例子,向您展示像数据库这样的严重依赖是如何被模拟的,以允许单元测试。本章不是关于使用数据库,因为编写 Java 应用来使用数据库需要安装额外的软件。相反,这一章着重于读写文件,以及有多少种方法可以做到这一点。

Java IO 和 NIO APIs

在开始向您展示如何读写文件之前,我们需要向您展示如何从代码中访问它们,如何检查它们是否存在,检查它们的大小并列出它们的属性,等等。Java 中用于文件处理的核心包被命名为java.io和java.nio. 1 包名很好地暗示了它们包含的组件。java.io是 Java 输入/输出和分组组件的缩写,旨在通过数据流和序列化促进访问文件系统的输入和输出操作。java.nio是 Java 非阻塞输入/输出的缩写。这个包是在版本 1.4 中引入的,是一个 Java 编程语言 API 的集合,为密集的 I/O 操作提供特性。在 JDK 1.7 中增加了一个名为java.nio.file的包,其中包含一组实用程序类,为文件 I/O 和访问文件系统提供全面的支持。

Java NIO 和 IO 的主要区别在于 IO 是面向流的,而 NIO 是面向缓冲区的。这意味着对于旧的 Java IO API,文件是一次从一个流中读取一个或多个字节。字节不在任何地方缓存,流遍历是单向的。所以一旦溪流枯竭,就没有办法再穿越了。如果需要双向遍历流,数据必须首先存储在缓冲区中。

使用 Java NIO,数据被直接读入缓冲区,这意味着字节被缓存在 web 浏览器中,并且浏览器支持双向操作。这在处理过程中提供了更多的灵活性,但是需要额外的检查来确保缓冲区包含处理所需的所有数据。

第二个主要区别是 Java IO 操作是阻塞的。一旦调用了读取或写入文件的方法,线程就会被阻塞,直到不再有数据要读取或数据被完全写入。

Java NIO 操作是非阻塞的。线程可以经由开放通道从资源(例如,文件)请求数据,并且仅获得当前可用的数据,或者如果当前没有数据可用,则什么都不得到。线程可以先做些别的事情,然后检查数据缓冲区是否被填充,而不是等到有了一些数据才开始。

第三个区别与其说是区别,不如说是 Java NIO 额外增加的东西:选择器。这些组件允许一个线程监视多个输入通道,并只选择那些有可用数据的通道进行处理。相比之下,传统的 Java IO 则不能做到这一点,因为在文件操作完成之前,线程会一直阻塞。

根据您试图解决的问题,您可以使用其中的一种,但这都是从一个文件处理程序开始的。

文件处理程序

在 Java 中处理文件时最重要的类是java.io.File class。这个类是文件和目录路径名的抽象表示。这个类的实例被命名为文件处理程序,因为它们允许开发者使用这种类型的引用来处理 Java 代码中的文件和目录,而不是完整的路径名。可以使用不同的参数创建一个File实例。

最简单的方法是使用构造函数,它接收包含绝对文件路径名的字符串值作为参数。在清单 11-1 的代码示例中,printFileStats(..)方法用于打印文件细节。

package com.apress.bgn.eleven.io;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;

public class Main {
    private static final Logger log = LoggerFactory.getLogger(Main.class);

    public static void main(String... args) {
        // replace [workspace] with your workspace path
        var file = new File("[workspace]/java-17-for-absolute-beginners/README.adoc");
        printFileStats(file);
    }

    private static void printFileStats(File f) {
        if (f.exists()) {
            log.info("File Details:");
            log.info("Type : {}", f.isFile() ? "file" : "directory or symlink");
            log.info("Location :{}", f.getAbsolutePath());
            log.info("Parent :{}", f.getParent());
            log.info("Name : {}", f.getName());

            double kilobytes = f.length() / (double)1024;
            log.info("Size : {} ", kilobytes);

            log.info("Is Hidden : {}", f.isHidden());
            log.info("Is Readable? : {}", f.canRead());
            log.info("Is Writable? : {}", f.canWrite());
        }
    }
}

Listing 11-1Printing File Details

在前面的示例中,文件处理程序实例是通过提供我的电脑上的绝对文件路径名来创建的。如果要在计算机上运行前面的代码,必须提供计算机上某个文件的路径名。如果您使用的是 Windows,请记住路径名将包含“\”字符,这是 Java 中的一个特殊字符,必须通过将它加倍来进行转义。

printFileStats(..)方法利用了许多可以在文件处理程序上调用的方法。您可以调用的方法的完整列表更大,您可以在官方 API 文档中看到它们: https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/io/File.html 。下面的列表解释了这些方法:

  • 如果路径名指向一个文件,则isFile()返回true,如果路径名指向一个目录或一个符号链接,则返回false(一种特殊类型的文件,存在的唯一目的是链接到另一个文件,当您想要缩短文件的路径名时非常有用,在路径名长度限制为 256 个字符的 Windows 上非常有用)。在前面的代码示例中,该方法返回true,日志显示:
INFO  c.a.b.e.Main - Type : file

如果我们想知道这个方法是否适用于一个目录,只需从路径名中删除文件名。

File file = new File("/[workspace]/java-17-for-absolute-beginners /");和日志打印

  • 返回文件或目录的绝对路径名。创建文件处理程序时,并不总是需要绝对路径名,但是如果您稍后需要在代码中使用它或者确保正确解析了相对路径,这种方法正是您所需要的。下面这段代码通过使用相对于根项目目录(在我们的例子中是java-17-for-absolute-beginners目录)的路径来创建一个指向resources目录中的文件的文件处理程序。
INFO  c.a.b.e.Main - Type : directory or symlink

File d = new File("chapter11/read-write-file/src/main/resources/input/");

getAbsolutePath()方法返回完整的路径名,它由 log 语句打印出来,如下所示:

INFO  c.a.b.e.Main - Location :/[workspace]/java-17-for-absolute-beginners/chapter11/read-write-file/src/main/resources/input

Java File类相当强大;它可用于指向另一台计算机上的共享文件。有一个特殊的构造函数用于接收类型为java.net.URI的参数,其中 URI 代表统一资源标识符。要测试这个构造函数,只需在您的计算机上选择一个文件,并在 web 浏览器中打开它,这样您就可以从浏览器地址栏中获得它的 URI。清单 11-2 中的代码描述了使用本地 URI 实例化的File类。

package com.apress.bgn.eleven.io;

import java.net.URI;
import java.net.URISyntaxException;
// other imports omitted

public class Main {
    public static void main(String... args) {
        try{
             // replace [workspace] with your workspace path
            var localUri = new URI("file:///[workspace]/java-17-for-absolute-beginners/README.adoc");
            var localFile =  new File (localUri);
            printFileStats(localFile);
        } catch (URISyntaxException use) {
            log.error("Malformed URI, no file there", use);
        }
    }
}

Listing 11-2Printing File Details for a File Instance Create Using an URI

因为 URI 可能有一个不正确的前缀或者没有准确地指向一个文件,URI 构造函数被声明抛出一个java.net.URISyntaxException,所以在代码中你也必须处理这个问题。在使用 URI 创建文件处理程序的情况下,getAbsolutePath()方法返回文件在计算机和文件所在的驱动器上的绝对路径名。

  • getParent()返回包含文件的目录的绝对路径,因为在层次结构上,一个文件不能有另一个文件作为父文件。

  • getName()返回文件名。文件名包含扩展名作为调用"."后的后缀,用于指示文件的类型和用途。

  • length()返回文件的长度,以字节为单位。此方法不适用于目录,因为目录可能包含仅限于执行程序的用户使用的文件,并且可能会引发异常。所以如果你需要一个目录的大小,你必须自己写代码。

  • isHidden()返回true是文件对当前用户不可见,否则返回false。在 macOs/ Linux 系统上,文件名以"."开头的文件是隐藏的,所以如果我们想看到那个方法返回true,我们必须创建一个系统配置文件的处理程序,比如.gitconfig。因此,在使用隐藏文件的路径名创建的文件处理程序上调用printFileStats(..)会产生类似于清单 11-3 中的输出:

  • canRead()和canWrite()是显而易见的,因为普通用户可以保护文件。当用户对文件拥有特定权限时,这两种方法都返回 true,否则返回 false。

INFO  c.a.b.e.Main - File Details:
INFO  c.a.b.e.Main - Type : file
INFO  c.a.b.e.Main - Location :/Users/[userDir]/.gitconfig
INFO  c.a.b.e.Main - Parent :/Users/[userDir]
INFO  c.a.b.e.Main - Name : .gitconfig
INFO  c.a.b.e.Main - Size : 3.865234375
INFO  c.a.b.e.Main - Is Hidden : true
INFO  c.a.b.e.Main - Is Readable? : true
INFO  c.a.b.e.Main - Is Writable? : true

Listing 11-3Printing File Details for a Hidden File

可以为指向目录的路径名创建文件处理程序,这意味着可以调用特定于目录的方法。对目录最常见的操作是列出它的内容。list()方法返回一个String数组,包含这个目录下的文件(和目录)的名称。Lambda 表达式使得打印目录中的项目变得非常实用。

var d = new File("/[workspace]/java-17-for-absolute-beginners");
Arrays.stream(Objects.requireNonNull(d.list())).forEach(ff -> log.info("\t File Name : {}", ff));

文件名在大多数情况下并不真正有用;拥有一个带有文件处理程序的File数组会更好。这就是为什么在 1.2 版本中增加了listFiles()方法。

Arrays.stream(Objects.requireNonNull(d.listFiles())).forEach(ff → log.info("\t File : {}", ff.getAbsolutePath()));

这个方法有不止一种形式,因为当用一个FileFilter的实例调用时,它可以用来过滤文件并只返回符合特定要求的文件或目录的文件处理程序。清单 11-4 中的代码示例过滤目录下的条目,只保留名称以“章节”开头的目录。

package com.apress.bgn.eleven.io;

import java.io.File;
import java.io.FileFilter;
// other imports omitted

public class Main {
    public static void main(String... args) {
         // replace [workspace] with your workspace path
        var d = new File("/[workspace]/java-17-for-absolute-beginners");
        Arrays.stream(d.listFiles(new FileFilter() {
            @Override
            public boolean accept(File childFile) {
                return childFile.isDirectory() && childFile.getName().startsWith("chapter");
            }
        })).forEach(ff -> log.info("Chapter Source : {}", ff.getName()));
    }
}

Listing 11-4Filtering Content of a Directory Using a FileFilter Instance

前面的代码示例是以扩展的形式编写的,目的是为了清楚地表明您应该为accept(..)方法提供一个具体的实现。使用 lambda 表达式,可以简化前面的代码,甚至使其不容易抛出异常。

Arrays.stream(
    Objects.requireNonNull(d.listFiles(
        childFile -> childFile.isDirectory() && childFile.getName().startsWith("chapter")))
    ).forEach(ff -> log.info("Chapter Source : {}", ff.getName())
);

在前面的例子中,我们实现了accept(..)来根据文件类型和名称进行过滤,但是过滤器可以包含任何内容。当您需要的过滤器严格涉及文件名时,您可以减少使用该方法的另一个版本,它接收一个FilenameFilter实例作为参数。

Arrays.stream(Objects.requireNonNull(d.listFiles(new FilenameFilter() {
        @Override
        public boolean accept(File dir, String name) {
            return dir.getName().startsWith("chapter");
        }
}))).forEach(ff -> log.info("\t File : {}", ff.getAbsolutePath()));

除了列出文件的属性,文件处理程序也可以用来创建文件。要创建一个文件,必须在创建一个具有特定路径名的文件处理程序后调用createNewFile()方法,如清单 11-5 所示。

package com.apress.bgn.eleven.io;

import java.io.IOException;
// other imports omitted

public class Main {
    public static void main(String... args) {
        var created = new File(
                "chapter11/read-write-file/src/main/resources/output/created.txt");
        if (!created.exists()) {
            try {
                created.createNewFile();
            } catch (IOException e) {
                log.error("Could not create file.", e);
            }
        }
    }
}

Listing 11-5Creating a File

当文件句柄与一个具体的文件或目录相关联时,exists()方法返回true,否则返回false。它可以用来测试我们试图创建的文件是否已经存在。如果该文件存在,则该方法无效。如果用户没有适当的权限在指定的路径名创建文件,将抛出一个SecurityException。在某些情况下,我们可能需要创建一个只在程序执行期间使用的文件。这意味着我们要么创建文件并显式删除它,要么创建一个临时文件。通过调用createTempFile(prefix, suffix)创建临时文件,它们被创建在为操作系统定义的临时目录中。prefix 参数的类型为 String,创建的文件将以其值开始命名。后缀参数也是字符串类型,它可以用来指定文件的扩展名。文件名的其余部分由操作系统生成。清单 11-6 中描述了创建临时文件的代码。

package com.apress.bgn.eleven.io;

import java.io.IOException;
// other imports omitted

public class Main {
    public static void main(String... args) {
        try {
            File temp = File.createTempFile("java_bgn_", ".tmp");
            log.info("File created.txt at: {}", temp.getAbsolutePath());
            temp.deleteOnExit();
        } catch (IOException e) {
            log.error("Could not create temporary file.", e);
        }
    }
}

Listing 11-6Creating a Temporary File

操作系统的临时目录中的文件会被操作系统定期删除,但是如果您想确保它会被删除,您可以在临时文件的文件处理程序上显式调用deleteOnExit()。在前面的代码示例中,打印了文件的绝对路径,以显示创建临时文件的确切位置,并且在 macOS 系统上,完整路径名看起来与此非常相似:

/var/folders/gg/nm_cb2lx72q1lz7xwwdh7tnc0000gn/T/java_bgn_14652264510049064218.tmp

也可以使用 Java 文件处理程序重命名文件,有一个名为rename(f)的方法,使用文件处理程序参数调用该方法,指向文件应该具有的位置和所需名称。如果重命名成功,该方法返回true,否则返回 false。清单 11-7 中描述了这样做的代码。

package com.apress.bgn.eleven.io;

import java.io.IOException;
// other imports omitted

public class Main {
    public static void main(String... args) {
        var file = new File(
                "chapter11/read-write-file/src/main/resources/output/created.txt");
        var renamed = new File(
                "chapter11/read-write-file/src/main/resources/output/renamed.txt");
        boolean result = file.renameTo(renamed);
        log.info("Renaming succeeded? : {} ", result);
    }
}

Listing 11-7Renaming a File

类File中的大多数方法都会抛出IOException,因为操作文件可能会因为硬件问题或操作系统问题而失败。这种类型的异常是检查异常,使用文件处理程序的开发人员被迫捕捉和处理这种类型的异常。

需要特殊权限才能访问文件的方法抛出SecurityException。这个类型扩展了RuntimeException,所以不检查异常。当应用运行时,它们变得很明显。

既然已经介绍了使用文件处理程序的所有基础,现在是下一节的时候了。

路径处理程序

Java 1.7 中引入了java.nio.file.Path接口以及实用程序类java.nio.file.Files和java.nio.file.Paths,以提供新的、更实用的方式来处理文件。Path实例可用于定位文件系统中的文件,因此代表系统相关的文件路径。Path实例比File更实用,因为它们可以提供访问路径组件、组合路径和比较路径的方法。Path不能直接创建实例,因为接口不能被实例化,但是接口提供了静态实用方法来创建它们,类Paths也是如此。根据你的情况使用你想要的。

创建一个Path实例最简单的方法是从一个文件处理程序开始并调用Paths.get(fileURI),如清单 11-8 所示。

package com.apress.bgn.eleven.io;

// other imports omitted
import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;

public class PathDemo {
    private static final Logger log = LoggerFactory.getLogger(PathDemo.class);

    public static void main(String... args) {
         // replace [workspace] with your workspace path
        File file = new File(
                "/[workspace]/java-17-for-absolute-beginners/README.adoc");
        Path path = Paths.get(file.toURI());
        log.info(path.toString());
    }
}

Listing 11-8Creating a Path Instance

从 Java 11 开始,Paths.get(file.toURI())可以替换为Path.of(file.toURI())。创建Path实例的另一种方法是使用另一种形式的Paths.get(..),,它接收多段路径作为参数。

Path composedPath = Paths.get("/[workspace]",
    "java-17-for-absolute-beginners",
    "README.adoc");
log.info(composedPath.toString());

之前创建的两条路径指向同一个位置,因此如果使用compareTo(..)方法相互比较(因为Path扩展了接口Comparable<Path>,返回的结果将是 0(零),这意味着路径相等。

log.info("Is the same path? : {} ", path.compareTo(composedPath) ==0 ? "yes" : "no");
// prints : INFO com.apress.bgn.eleven.PathDemo - Is the same path? : yes

在下一个代码示例中,在 path 实例上调用了一些Path方法。清单 11-9 中描述了代码。

package com.apress.bgn.eleven.io;
// import section omitted

public class PathDemo {
    private static final Logger log = LoggerFactory.getLogger(PathDemo.class);

    public static void main(String... args) {
        var path = Paths.get("/[workspace]",
                "java-17-for-absolute-beginners",
                "README.adoc");
        printPathDetails(path);
    }

    private static void printPathDetails(Path path) {
        log.info("Location :{}", path.toAbsolutePath());
        log.info("Is Absolute? : {}", path.isAbsolute());
        log.info("Parent :{}", path.getParent());
        log.info("Root :{}", path.getRoot());
        log.info("FileName : {}", path.getFileName());
        log.info("FileSystem : {}", path.getFileSystem());
        log.info("IsFileReadOnly : {}", path.getFileSystem().isReadOnly());
    }
}

Listing 11-9Inspecting Path Details

以下列表解释了每种方法及其结果:

  • toAbsolutePath()返回表示该路径绝对路径的 Path 实例。当在先前创建的 path 实例上调用时,由于它已经是绝对的,该方法将只返回调用该方法的 path 对象。同样,调用path.isAbsolute()将返回true。

  • getParent()返回父Path实例。在 path 实例上调用此方法将打印:

  • INFO com.apress.bgn.eleven.PathDemo - Parent :/[workspace]/java-17-for-absolute-beginners

  • getRoot()返回该路径的根组件作为一个Path实例。在 Linux 或 macOS 系统上打印"/",在 Windows 上类似于"C:\"。

  • getFileName()返回由该路径表示的文件或目录的名称作为Path实例;基本上,路径被系统路径分隔符拆分,离根元素最远的返回。

  • getFileSystem()返回创建该对象的文件系统,对于 macOS,它是类型sun.nio.fs.MacOSXFileSystem的实例。

另一个有用的Path方法是resolve(..)。这个方法采用一个代表路径的String实例,并根据它被调用的路径实例来解析它。这意味着添加了路径分隔符来根据程序运行的操作系统组合两个路径,并将返回一个Path实例。这在清单 11-10 中有所描述。

package com.apress.bgn.eleven.io;
// import section omitted

public class PathDemo {
    private static final Logger log = LoggerFactory.getLogger(PathDemo.class);

    public static void main(String... args) {
         // replace [workspace] with your workspace path
        var chapterPath = Paths.get("/[workspace]",
                "java-17-for-absolute-beginners/chapter11");
        Path filePath = chapterPath.resolve(
                "read-write-file/src/main/resources/input/data.txt");
        log.info("Resolved Path :{}", filePath.toAbsolutePath());
    }
}

Listing 11-10Resolving a Path Instance

前面的示例代码将打印以下内容:

INFO c.a.b.e.PathDemo - Resolved Path :/[workspace]/java-17-for-absolute-beginners/chapter11/read-write-file/src/main/resources/input/data.txt

使用Path实例,结合Files实用程序方法,编写管理文件或检索其属性的代码变得更加容易。清单 11-11 中的代码示例使用了其中的一些方法来打印文件的属性,就像我们之前使用File处理程序一样。

package com.apress.bgn.eleven.io;
// import section omitted

public class PathDemo {
    private static final Logger log = LoggerFactory.getLogger(PathDemo.class);

    public static void main(String... args) {
         try {
            var outputPath = FileSystems.getDefault()
                .getPath("/[workspace]" +
                        "java-17-for-absolute-beginners/chapter11/read-write-file/src/main/resources/output/sample");
            Path dirPath = Files.createDirectory(outputPath);
            printPathStats(dirPath);
        } catch (FileAlreadyExistsException faee) {
            log.error("Directory already exists.", faee);
        } catch (IOException e) {
            log.error("Could not create directory.", e);
        }
    }

    private static void printPathStats(Path path) {
        if (Files.exists(path)) {
            log.info("Path Details:");
            log.info("Type: {}", Files.isDirectory(path) ? "yes" : "no");
            log.info("Type: {}", Files.isRegularFile(path) ? "yes" : "no");
            log.info("Type: {}", Files.isSymbolicLink(path) ? "yes" : "no");
            log.info("Location :{}", path.toAbsolutePath());
            log.info("Parent :{}", path.getParent());
            log.info("Name : {}", path.getFileName());

            try {
                double kilobytes = Files.size(path) / (double)1024;
                log.info("Size : {} ", kilobytes);
                log.info("Is Hidden: {}", Files.isHidden(path) ? "yes" : "no");
            } catch (IOException e) {
                log.error("Could not access file.", e);
            }
            log.info("Is Readable: {}", Files.isReadable(path) ? "yes" : "no");
            log.info("Is Writable: {}", Files.isWritable(path) ? "yes" : "no");
        }
    }
}

Listing 11-11Printing a Path Details

如您所见,Files类提供了与File类相同的功能。这个类只包含对文件、目录或其他类型的文件进行操作的静态方法。它是在 Java 1.7 中引入的,其优点是语法更清晰。在管理文件、创建文件、重命名文件、删除文件以及读写文件时,使用java.nio类的功能和实用性更加明显。清单 11-12 中的代码示例展示了使用 NIO 类创建、重命名和删除文件。

package com.apress.bgn.eleven.io;
// import section omitted
import java.nio.FileAlreadyExistsException;

public class PathDemo {
    private static final Logger log = LoggerFactory.getLogger(PathDemo.class);

    public static void main(String... args) {
        Path filePath = chapterPath.resolve(
                "read-write-file/src/main/resources/input/data.txt");
        Path copyFilePath = Paths.get(outputPath.toAbsolutePath().toString(), "data.adoc");
        try {
            Files.copy(filePath, copyFilePath);
            log.info("Exists? : {}", Files.exists(copyFilePath)? "yes": "no");
            log.info("File copied to: {}", copyFilePath.toAbsolutePath());
        } catch (FileAlreadyExistsException faee) {
            log.error("File already exists.", faee);
        } catch (IOException e) {
            log.error("Could not copy file.", e);
        }
        Path movedFilePath = Paths.get(outputPath.toAbsolutePath().toString(), "copy-data.adoc");
        try {
            Files.move(copyFilePath, movedFilePath);
            log.info("File moved to: {}", movedFilePath.toAbsolutePath());
            Files.deleteIfExists(copyFilePath);
        } catch (FileAlreadyExistsException faee) {
            log.error("File already exists.", faee);
        }  catch (IOException e) {
            log.error("Could not move file.", e);
        }
    }
}

Listing 11-12Managing Files Using NIO Classes

请注意FileAlreadyExistsException,这是 Java 1.7 中添加的一个异常类型,它扩展了IOException(间接通过FileSystemException),用于提供更多关于文件操作失败的情况的数据。通过createDirectory(..)、createFile(..),和move(..).方法得出

如果要删除的文件不存在,前面的代码示例中没有使用的The delete(..)方法会抛出一个java.nio.file.NoSuchFileException。为了避免抛出异常,在前面的代码示例中使用了deleteIfExists(..)。

方法的列表甚至更大,但是由于本章的篇幅有限,您可以在官方的 Javadoc API 中亲自查看。

读取文件

文件是硬盘上一连串的位。一个File处理程序不提供读取文件内容的方法,但是一组其他类可以用来这样做,但是它们都是使用文件处理程序实例创建的。根据对文件内容的实际需要,在 Java 中有多种读取文件内容的方法。有很多方法,本节将介绍最常见的方法。

使用Scanner读取文件

之前使用了Scanner类从命令行读取输入。System.in可以替换为File,可以使用Scanner方法读取文件内容,如清单 11-13 所示。

package com.apress.bgn.eleven.io;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.Scanner;

public class ScannerDemo {
    private static final Logger log = LoggerFactory.getLogger(ScannerDemo.class);

    public static void main(String... args) {
        try {
            var scanner = new Scanner(new File("chapter11/read-write-file/src/main/resources/input/data.txt"));
            var content = "";
            while (scanner.hasNextLine()) {
                content += scanner.nextLine() + "\n";
            }
            scanner.close();
            log.info("Read with Scanner --> {}", content);
        } catch (IOException e) {
            log.error("Something went wrong! ", e);
        }
    }
}

Listing 11-13Using Scanner to Read a File

也可以使用一个java.nio.file.Path实例来代替文件:

scanner = new Scanner(Paths.get(new File("chapter11/read-write-file/src/main/resources/input/data.txt").toURI()), StandardCharsets.UTF_8.name());

文件可以使用不同的字符集编写,在 Java 中由java.nio.charset.Charset实例引用。为了确保它们被正确读取,使用相同的字符集读取它们是一个很好的做法。有一个扫描器构造函数,它接收一个字符集名称作为参数。调用StandardCharsets.UTF_8.name()方法来提取 UTF-8 字符集的名称。

使用Files实用程序方法读取文件

清单 11-14 中的第一个代码示例展示了读取文件的最简单方法。

package com.apress.bgn.eleven.io;
// import section omitted

public class FilesReadDemo {
    private static final Logger log = LoggerFactory.getLogger(FilesReadDemo.class);

    public static void main(String... args) {
        try {
            var file= new File("chapter11/read-write-file/src/main/resources/input/data.txt");
            var content = new String(Files.readAllBytes(Paths.get(file.toURI())));
            log.info("Read with Files.readAllBytes --> {}", content);
        } catch (IOException e) {
            log.info("Something went wrong! ", e);
        }
    }
}

Listing 11-14The Simplest Way to Read a File

当文件大小可以近似时(文件大小可以估计,并且相对较小),这种方法工作得很好,并且将其存储到一个String对象中不会有问题。

使用Files.readAllBytes(..)的优点是不需要循环,我们不必一行一行地构造String值,因为这个方法只是读取文件中所有可以作为参数给String构造函数的字节。缺点是没有使用Charset,所以文本值可能不是我们所期望的。有一种方法可以克服这个问题,通过调用将文件内容作为一列String值返回的Files.readAllLines(..),并有两个表单,其中一个将Charset声明为参数。清单 11-15 中描述了读取文件的这个版本。

package com.apress.bgn.eleven.io;
// import section omitted

public class FilesReadDemo {
    private static final Logger log = LoggerFactory.getLogger(FilesReadDemo.class);

    public static void main(String... args) {
        try {
            var file= new File("chapter11/read-write-file/src/main/resources/input/data.txt");
            List<String> lyricList = Files.readAllLines(Paths.get(file.toURI()), StandardCharsets.UTF_8);
            lyricList.forEach(System.out::println);
        } catch (IOException e) {
            log.info("Something went wrong! ", e);
        }
    }
}

Listing 11-15A Simple Way to Read a File Specifying a Charset

但是如果我们不需要一个List<String>,而是需要一个String实例呢?在 Java 11 中为此引入了一个方法,叫做readString(..)。清单 11-16 中显示了使用它的代码示例。

package com.apress.bgn.eleven.io;
// import section omitted

public class FilesReadDemo {
    private static final Logger log = LoggerFactory.getLogger(FilesReadDemo.class);

    public static void main(String... args) {
         try {
            var content = Files.readString(Paths.get(file.toURI()), StandardCharsets.UTF_8);
            log.info("Read with Files.readString --> {}", content);
        } catch (IOException e) {
            log.info("Something went wrong! ", e);
        }
    }
}

Listing 11-16The Simplest Way to Read a File Specifying a Charset

使用Readers读取文件

在引入Files类和它的奇特方法之前,有其他读取文件的方法。奇特的方法也不是为读取大文件或只读取文件的一部分而设计的。让我们回到过去,慢慢分析事情是如何演变的。

在 Java 1.6 之前,要逐行读取文件,您必须编写一个类似清单 11-17 中的装置。

package com.apress.bgn.eleven.io;

import java.io.BufferedReader;
import java.io.FileReader;
// other imports omitted

public class ReadersDemo {
    private static final Logger log = LoggerFactory.getLogger(ReadersDemo.class);

    public static void main(String... args) {
        BufferedReader reader = null;
        try {
            reader = new BufferedReader(new FileReader(new File("chapter11/read-write-file/src/main/resources/input/data.txt")));
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append("\n");
            }
            log.info("Read with BufferedReader --> {}", sb.toString());
        } catch (Exception e) {
            log.error("File could not be read! ", e);
        } finally {
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException ioe) {
                    log.error("Something went wrong! ", ioe);
                }
            }
        }
    }
}

Listing 11-17Reading a File Line By Line, Before Java 1.6

哇,那是什么,对吗?在 Java 1.6 之后,语法有所简化,但是最大的变化出现在 1.7。在 Java 1.7 之前,如果您想逐行读取文件,您必须编写以下代码:

  • 您必须创建一个File处理程序。

  • 然后,您需要将文件处理程序包装到一个FileReader中。这种类型的实例可以完成读取的工作,但是只能读取大块的char[],当您需要实际的文本时,这不是很有用。

  • 需要将FileReader实例包装到BufferedReader实例中,通过读取内部缓冲区中的字符来提供该功能。它的工作方式是,当这个方法返回null时,调用reader.readLine()直到因为到达了文件的结尾而没有更多要读取的内容。

  • 在读取结束时,需要显式调用reader.close(),否则文件可能会被锁定,直到重新启动后才可读。

在 Java 1.7 中,引入了许多变化来减少处理文件所需的样板文件。其中之一是,所有用于访问文件内容和可以保持文件锁定的类都通过声明实现java.io.Closeable接口来丰富,该接口将这些类型的资源标记为可关闭的,并且在执行结束之前,JVM 会调用一个close()方法来透明地释放资源。同样,在 Java 7 中,引入了try-with-resources语句。利用所有这些特性,前面的代码可以如清单 11-18 所示编写。

package com.apress.bgn.eleven.io;
// other imports omitted

public class ReadersDemo {
    private static final Logger log = LoggerFactory.getLogger(ReadersDemo.class);

    public static void main(String... args) {
           try (var br = new BufferedReader(new FileReader(new File("chapter11/read-write-file/src/main/resources/input/data.txt")))){
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = br.readLine()) != null) {
                sb.append(line).append("\n");
            }
            log.info("Read with BufferedReader --> {}", sb.toString() );
        } catch (Exception e) {
            log.info("Something went wrong! ", e);
        }
    }
}

Listing 11-18Reading a File Line By Line, Starting with Java 1.7

代码可以进一步简化,因为FileReader可以将文件的绝对路径作为参数String。但是不能使代码考虑编码。这在 Java 1.8 中成为可能,当时为接受Charset参数的FileReader类引入了一个构造函数。尽管如此,在前面的例子中我们有嵌套的构造函数调用,这是相当难看的。通过引入Files.newBufferedReader(Path)和Files.newBufferedReader(Path, Charset)方法,Java 8 来拯救我们了。

所以前面的代码可以写成清单 11-19 所示。

package com.apress.bgn.eleven.io;
// other imports omitted

public class ReadersDemo {
    private static final Logger log = LoggerFactory.getLogger(ReadersDemo.class);

    public static void main(String... args) {
       File file = new File("chapter11/read-write-file/src/main/resources/input/data.txt");
        try (var br = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)){
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = br.readLine()) != null) {
                sb.append(line).append("\n");
            }
            log.info("Read with BufferedReader --> {}", sb.toString() );
        } catch (Exception e) {
            log.info("Something went wrong! ", e);
        }
    }
}

Listing 11-19Reading a File Line By Line, Taking Encoding Into Consideration Starting with Java 1.8

如果已知文件的大小是可管理的,并且我们不感兴趣的只是记录内容,而是保存单独的行以供进一步处理,那么最简单的方法就是使用结合了 lambda 表达式的Files.readAllLines(..)方法。可以在混合中添加流,因此可以在现场过滤或处理这些线,如下所示:

List<String> dataList = Files.readAllLines(Paths.get(file.toURI()), StandardCharsets.UTF_8)
    .stream()
    .filter(line -> line!= null && !line.isBlank())
    .map(line -> line.toUpperCase())
    .collect(Collectors.toList());

或者我们可以用另一种方式编写,使用也是在 Java 1.8 中引入的Files.lines(..)方法,并直接以流的形式获取所有内容:

List<String> dataList = Files.lines(Paths.get(file.toURI()), StandardCharsets.UTF_8)
    .filter(line -> line!= null && !line.isBlank() )
    .map(line -> line.toUpperCase())
    .collect(Collectors.toList());

总之,回到文件阅读器。如果是扩展了Reader类的类组的成员,则为BufferedReader类。Reader类是一个用于读取字符流的抽象类,是java.io包的一部分。图 11-1 描述了显示最常用实现的简化层次结构。

img/463938_2_En_11_Fig1_HTML.png

图 11-1

Reader类层次结构(如 IntelliJ IDEA 所示)

字符流可以有不同的来源,文件是最常见的。它们提供对存储在文件中的数据的顺序访问。BufferedReader不支持字符编码,但是BufferedReader基于另一个Reader实例。正如您在前面的例子中注意到的,在实例化一个BufferedReader时,一个FileReader实例被用作参数,并且在 Java 1.8 中FileReader被修改以支持字符编码。在 Java 1.8 之前,为了从文件中读取并考虑字符编码,使用了一个InputStreamReader实例,如清单 11-20 所示。

package com.apress.bgn.eleven.io;

import java.io.FileInputStream;
import java.io.InputStreamReader;
// other imports omitted

public class ReadersDemo {
    private static final Logger log = LoggerFactory.getLogger(ReadersDemo.class);

    public static void main(String... args) {
        File file = new File("chapter11/read-write-file/src/main/resources/input/data.txt");
        try (var br = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))){
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = br.readLine()) != null) {
                    sb.append(line).append("\n");
            }
            log.info("Read with BufferedReader(InputStreamReader(FileInputStream(..))) --> {}", sb.toString() );
        } catch (Exception e) {
            log.info("Something went wrong! ", e);
        }
    }
}

Listing 11-20Reading a File Line By Line, Taking Encoding Into Consideration Before Java 1.8

在 Java 11 中,Reader类用nullReader()方法进行了丰富,该方法返回一个不做任何事情的Reader实例。这是开发人员出于测试目的而要求的,只不过是一个伪阅读器实现。

使用InputStream读取文件

Reader系列中的类是将数据作为文本读取的高级类,但从技术上讲,文件只是一个字节序列,所以这些类本身是用于读取字节流的类系列中的类的包装器。当试图使用正确的字符编码时,以及当使用BufferedReader读取文本时(如前一节末尾所示),这变得非常明显,因为作为参数给出的InputStreamReader实例是基于java.io.FileInputStream实例的,而后者是java.io.InputStream的子类。

这个层次的根类是java.io.InputStream。图 11-2 描述了显示最常用实现的简化层次结构。

img/463938_2_En_11_Fig2_HTML.png

图 11-2

InputStream类层次结构(如 IntelliJ IDEA 所示)

类BufferedInputStream相当于用于读取字节流的BufferedReader。我们之前用来从控制台读取用户数据的System.in就是这种类型,Scanner实例将来自其缓冲区的字节转换成用户可理解的数据。当我们感兴趣的数据不是使用 Unicode 惯例存储的文本,而是原始数字数据(图像、媒体文件、pdf 等二进制文件)时。)使用字节流的类更合适。只是为了向您展示它是如何完成的,我们将使用FileInputStream来读取data.txt文件的内容。代码如清单 11-21 所示。

package com.apress.bgn.eleven.io;

import java.io.FileInputStream;
// other imports omitted

public class FileInputStreamReadingDemo {
    private static final Logger log = LoggerFactory.getLogger(FileInputStreamReadingDemo.class);

    public static void main(String... args) {
        File file = new File("chapter11/read-write-file/src/main/resources/input/data.txt");

        try {
            FileInputStream fis = new FileInputStream(file);
            byte[] buffer = new byte[1024];
            StringBuilder sb = new StringBuilder();
            while (fis.read(buffer) != -1) {
                sb.append(new String(buffer));
                buffer = new byte[1024];
            }
            fis.close();

            log.info("Read with FileInputStream --> {}", sb.toString() );
        } catch (IOException e) {
            log.error("Something went wrong! ", e);
        }
    }
}

Listing 11-21Reading a File Using FileInputStream

如果您运行前面的代码,您会注意到在控制台中将会打印出预期的输出,但是您可能会注意到一些奇怪的事情:在打印出文本之后,还会打印出一组奇怪的字符。在 macOS 系统上,它们看起来如图 11-3 所示。

img/463938_2_En_11_Fig3_HTML.jpg

图 11-3

用FileInputStream阅读文本

你知道这些字符可能是什么吗?

没想法也没关系;我第一次使用FileInputStream读取文件时也没有。这些字符出现在那里是因为文件大小不是 1024 的倍数,所以FileInputReader最终用零填充最后一个缓冲区的剩余部分。解决这个问题的方法包括计算文件的字节大小,并确保我们相应地调整byte[] buffer的大小。如果你有心情写一些代码,你可以试着把它作为一个练习。既然我们已经向您展示了如何以多种方式读取文件,我们可以继续向您展示如何编写文件,因为您已经知道如何创建它们。

在 Java 11 中,InputStream还增加了一个方法,返回一个什么也不做的InputStream。它被命名为nullInputStream()方法,是为测试目的而设计的,只不过是一个伪InputStream实现.

到目前为止介绍的所有类都是您在 Java 中处理文件时最常遇到的。如果您需要更专业的读者,请随意阅读官方文档或使用第三方库(如 Apache Commons IO)提供的自定义实现。 2

写文件

用 Java 写文件和读文件非常相似,只是必须使用不同的类,因为流是单向的。用于读取数据的流也不能用于写入数据。几乎任何读取文件的类或方法都有一个用于写入文件的类或方法。事不宜迟,我们开始吧。

使用文件实用程序方法编写文件

从 Java 1.7 开始,使用Files.write(Path, byte[], OpenOption... options)方法可以很容易地编写较小的文件。它有两个参数:一个代表文件位置的Path和一个代表要写入的数据的字节数组。当需要写入的数据足够小时,这种方法是一个实用的一行程序。最后一个参数实际上是在第章 4 中引入的 Varargs ,它表示打开文件的一个或多个操作。如清单 11-22 所示,可以在不指定任何该类型参数的情况下使用该方法。

package com.apress.bgn.eleven.io;
// other import statements omitted
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class FilesWritingDemo {
    private static final Logger log = LoggerFactory.getLogger(FilesWritingDemo.class);

    public static void main(String... args) {
        var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");

        byte[] data = "Some of us, we’re hardly ever here".getBytes();
        try {
            Path dataPath = Files.write(file.toPath(), data);
            log.info("String written to {}", dataPath.toAbsolutePath());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Listing 11-22Writing a String to a File Starting with Java 1.7

如果文件已经存在,内容将被简单地覆盖。这意味着,由于没有指定参数来配置我们想要对文件做什么,默认的行为是打开文件进行写入,将其大小截断为零,并从那里开始写入,从而覆盖它。可用选项列表由java.nio.file.StandardOpenOption枚举中的值建模。默认行为对应的值是TRUNCATE_EXISTING。所以上例中的这一行:

Path dataPath = Files.write(file.toPath(), data);

相当于

import java.nio.file.StandardOpenOption
...​
Path dataPath = Files.write(file.toPath(), data, StandardOpenOption.TRUNCATE_EXISTING);

如果想要的行为是修改一个文件(如果它存在的话)并在末尾追加新数据,那么用作Files.write(..)方法的参数的选项是APPEND。Path dataPath = Files.write(file.toPath(), data, StandardOpenOption.APPEND);

此外,请注意字符串在写入之前需要如何转换为字节数组。在 Java 11 中这不再是必要的,因为最终一些 JDK 开发者认为大多数人可能会写一个简单的String到一个文件中,强迫他们显式地调用getBytes()是非常愚蠢的。结果是引入了Files.writeString(..)方法,其中一个还支持指定编码。在清单 11-23 中可以看到一个将字符串写入文件的方法的例子。

package com.apress.bgn.eleven.io;
// import statements omitted

public class FilesWritingDemo {
    private static final Logger log = LoggerFactory.getLogger(FilesWritingDemo.class);

    public static void main(String... args) {
        var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");
         try {
            Path dataPath = Files.writeString(file.toPath(),
                    "\nThe rest of us, we're born to disappear",
                    StandardCharsets.UTF_8,
                    APPEND);
            log.info("String written to {}", dataPath.toAbsolutePath());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Listing 11-23Writing a String to a File Starting with Java 11

另一个版本的Files.write(..)接受一个类型为Iterable<? extends CharSequence>的参数,这意味着可以用它来编写一个String值的列表,如清单 11-24 所示。

package com.apress.bgn.eleven.io;
// import statements omitted

public class FilesWritingDemo {
    private static final Logger log = LoggerFactory.getLogger(FilesWritingDemo.class);

    public static void main(String... args) {
        var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");

         List<String> dataList = List.of(
                "How do I stop myself from",
                "Being just a number?");
        try {
            Path dataPath = Files.write(file.toPath(), dataList,
                    StandardCharsets.UTF_8,
                    APPEND);
            log.info("String written to {}", dataPath.toAbsolutePath());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Listing 11-24Writing a List<String> to a File Using Files.write(..)

接下来,我们将研究如何使用Writer层次结构中的类来编写文件。

使用Writer写文件

类似于读取文件的Reader层次结构,有一个名为Writer的抽象类,但是在我们开始之前,让我们先介绍一下BufferedWriter,它是BufferedReader的通讯器,用于写文件,因为这是实践中使用最多的一个。这个类也有一个内部缓冲区,当调用 write 方法时,参数被存储到缓冲区,当缓冲区满了,它的内容被写入文件。通过调用flush()方法可以提前清空缓冲区。绝对建议在调用close()之前显式调用这个方法,以确保所有输出都被写入文件。清单 11-25 中的代码片段描述了如何将String实例列表写入文件。

package com.apress.bgn.eleven.io;
// other import statements omitted
import java.io.BufferedWriter;
import java.io.FileWriter;

public class FilesWritingDemo {
    private static final Logger log = LoggerFactory.getLogger(FilesWritingDemo.class);

    public static void main(String... args) {
        var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");

        var dataList = List.of ("How will I hold my head" ,
                "To keep from going under");
        BufferedWriter writer = null;
        try {
            writer = new BufferedWriter(new FileWriter(file));
            for (String entry : dataList) {
                writer.write(entry);
                writer.newLine();
            }
        } catch (IOException e) {
            log.info("Something went wrong! ", e);
        } finally {
            if(writer!= null) {
                try {
                    writer.flush();
                    writer.close();
                } catch (IOException e) {
                    log.info("Something went wrong! ", e);
                }
            }
        }
    }
}

Listing 11-25Writing a List<String> to a File \Using BufferedWriter

还需要另一个代码装置,因为写文件是一个敏感的操作,可能会因为许多原因而失败。前面清单中的代码是您在 Java 1.7 之前必须编写的,当时try-with-resources减少了样板文件,并允许减少前面的代码,如清单 11-26 所示。

package com.apress.bgn.eleven.io;
// other import statements omitted
import java.io.BufferedWriter;
import java.io.FileWriter;

public class FilesWritingDemo {
    private static final Logger log = LoggerFactory.getLogger(FilesWritingDemo.class);

    public static void main(String... args) {
        var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");

        var dataList = List.of ("How will I hold my head" ,
                "To keep from going under");
        try (final BufferedWriter wr = new BufferedWriter(new FileWriter(file))){
            dataList.forEach(entry -> {
                try {
                    wr.write(entry);
                    wr.newLine();
                } catch (IOException e) {
                    log.info("Something went wrong! ", e);
                }
            });
            wr.flush();
        } catch (IOException e) {
            log.info("Something went wrong! ", e);
        }
    }
}

Listing 11-26Writing a List<String> to a File Using BufferedWriter

注意为什么不需要调用wr.close(),因为在 Java 1.7 中java.io.Closeable接口被修改为扩展java.lang.AutoCloseable,它声明了在退出try-with-resources块时自动调用的close()方法的一个版本。尽管如此,代码看起来相当乏味,对不对?尤其是因为需要声明一个BufferedWriter并需要包装一个FileWriter实例。这在 Java 1.8 中得到了简化,增加了Files实用程序类,它包含一个名为newBufferedWriter(Path path)的方法,该方法返回一个BufferedWriter实例,因此开发人员不再需要显式地编写代码。因此清单 11-26 中try-with-resources的初始化表达式可以替换为:

final BufferedWriter wr = Files.newBufferedWriter(file.toPath())

此外,该方法还有一个版本采用 charset 参数:

final BufferedWriter wr = Files.newBufferedWriter(file.toPath(),StandardCharsets.UTF_8)

在引入这种方法之前,用指定的字符集将文本写入文件需要一个java.io.OutputStreamWriter实例。

final OutputStreamWriter wr = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)

这个方法还有一个版本,它采用类型为OpenOption的参数,允许您指定应该如何打开文件。

final BufferedWriter wr = Files.newBufferedWriter(file.toPath(),StandardCharsets.UTF_8, StandardOpenOption.APPEND)

这非常有用,因为显式创建的BufferedWriter(没有指定文件选项)会覆盖现有文件,除非将回绕的FileWriter配置为将数据追加到现有文件,如下所示:

final BufferedWriter wr = new BufferedWriter(new FileWriter(file, true))

第二个参数是一个布尔值,表示是否应该打开文件以追加文本(true)或不追加文本(false)。

既然已经介绍了使用BufferedWriter的基本知识,现在该见见图 11-4 中描绘的Writer家族中最有用的成员了。

img/463938_2_En_11_Fig4_HTML.jpg

图 11-4

Writer阶级阶层

Writer类是抽象的,所以不能直接使用;附加的 API 来自于Writer实现的java.io.Appendable接口。其他Writer类用于不同的目的。正如我们已经看到的,OutputStreamWriter是用来用一种特殊的字符编码写文本的。

PrintWriter用于将对象的格式化表示写入文本输出流(在前一章中,我们已经用它编写了 HTML 代码)。

StringWriter用于将输出收集到其内部缓冲区中,并将其写入一个String实例。

在 Java 11 中,Writer类用nullWriter()方法进行了丰富,该方法返回一个不做任何事情的Writer实例。这是开发人员出于测试目的而要求的。

使用OutputStream写文件

Writer系列中的类是使用字符流将数据作为文本写入的高级类,但本质上,在数据被写入之前,它被转换成字节。这显然意味着也可以使用字节流来编写文件。当试图在使用OutputStreamWriter编写文本时使用正确的字符编码时,这可能变得很明显,因为作为参数给出的OutputStreamWriter实例是基于FileOutputStream实例的,这是一种用于将字节流写入文件的类型。

这个层次的根类是java.io.OutputStream,层次中最常见的成员如图 11-5 所示。

img/463938_2_En_11_Fig5_HTML.jpg

图 11-5

OutputStream阶级阶层

既然已经提到了FileOutputStream,清单 11-27 展示了如何使用它来编写一个String条目的列表。

package com.apress.bgn.eleven.io;
// other import statements omitted
import java.io.FileNotFoundException;
import java.io.FileOutputStream;

public class OutputStreamWritingDemo {
    private static final Logger log = LoggerFactory.getLogger(OutputStreamWritingDemo.class);

    public static void main(String... args) {
        var file = new File("chapter11/read-write-file/src/main/resources/output/data.txt");

        var dataList = List.of("Down to the wire" ,
                "I wanted water but" ,
                "I'll walk through the fire" ,
                "If this is what it takes");

        try (FileOutputStream output = new FileOutputStream(file)){
            dataList.forEach(entry -> {
                try {
                    output.write(entry.getBytes());
                    output.write("\n".getBytes());
                } catch (IOException e) {
                    log.info("Something went wrong! ", e);
                }
            });
            output.flush();
        } catch (FileNotFoundException e) {
            log.info("Something went wrong! ", e);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Listing 11-27Writing a List<String> to a File Using FileOutputStream

OutputStream family 类用于写入代表用户无法直接读取的原始数据的字节流,例如包含在图像、媒体、pdf 等二进制文件中的字节流。例如,清单 11-28 中的代码使用FileInputStream读取图像并使用FileOutputStream写入副本来制作图像的副本。

package com.apress.bgn.eleven.io;
// other import statements missing
import java.io.*;

public class DuplicateImageDemo {
    private static final Logger log = LoggerFactory.getLogger(DuplicateImageDemo.class);

    public static void main(String... args) {
        File src = new File(
                "chapter11/read-write-file/src/main/resources/input/the-beach.jpg");
        File dest = new File(
                "chapter11/read-write-file/src/main/resources/output/copy-the-beach.jpg");
        try(FileInputStream fis = new FileInputStream(src);
            FileOutputStream fos = new FileOutputStream(dest)) {
            int content;
            while ((content = fis.read()) != -1) {
                fos.write(content);
            }
        } catch (FileNotFoundException e) {
            log.error("Something bad happened.", e);
        } catch (IOException e) {
            log.error("Something bad happened.", e);
        }
    }
}

Listing 11-28Making a Copy of an Image File Using FileOutputStream

然而,由于 Java 1.7 中引入了Files.copy(src.toPath(), dest.toPath())方法,因此不再需要像这样编写代码。

在 Java 11 中,OutputStream增加了 nullOutputStream()方法,该方法返回一个不做任何事情的OutputStream实例。这是开发人员出于测试目的而要求的,也是为测试目的而设计的,只不过是一个伪输出流实现。

使用 NIO 管理文件

本章开头介绍了java.nio包与java.io包的对比。本书这一节用到的大多数类和方法都是java.io包的一部分,当数据被读写时会阻塞主线程。上一节介绍的实用程序类java.nio.file.Paths和java.nio.file.Files包含了利用java.nio包和java.io包中的类的方法。是时候向您展示如何使用java.nio类来操作文件了。

使用java.nio操作文件需要一个java.nio.channels.FileChannel的实例。这是一个特殊的抽象类,描述了读取、写入、映射和操作文件的通道。一个FileChannel实例连接到一个文件,并在文件中保存一个可以被查询和修改的位置。

要使用FileChannel实例从文件中读取数据,需要以下内容:

  • 文件处理程序实例

  • 通道基于的FileInputStream实例

  • 一个实例

  • 一个实例

由于是非阻塞的,线程可以请求通道从缓冲区读取数据,然后执行其他操作,直到数据可用。Java NIO 的缓冲区允许根据需要在缓冲区中来回移动。数据被读入缓冲区并缓存在那里,直到被处理。在java.nio包中有所有原语类型的缓冲实现,根据数据的用途,你可以使用其中的任何一个。清单 11-29 展示了如何将数据从一个文件读入一个ByteBuffer。由于可以用初始大小实例化ByteBuffer,通过将ByteBuffer的字节数配置为与文件大小相同,可以一次性读取文件。

package com.apress.bgn.eleven.nio;
// other import statements omitted
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class ChannelDemo {
    private static final Logger log = LoggerFactory.getLogger(ChannelDemo.class);

    public static void main(String... args) {
        var sb = new StringBuilder();
        try (FileInputStream is = new FileInputStream("chapter11/read-write-file/src/main/resources/input/data.txt");
            FileChannel inChannel = is.getChannel()) {
            long fileSize = inChannel.size();
            ByteBuffer buffer = ByteBuffer.allocate((int)fileSize);
            inChannel.read(buffer);
            buffer.flip();
            while(buffer.hasRemaining()){
                sb.append((char) buffer.get());
            }
        }  catch (IOException e) {
            log.error("File could not be read! ", e);
        }
        log.info("Read with FileChannel --> {}", sb.toString());
    }
}

Listing 11-29Reading a file Using FileChannel Using a ByteBuffer

方法getChannel()返回与这个文件输入流相关联的唯一的FileChannel对象。前面代码示例中最重要的语句是buffer.flip()调用。调用这个方法翻转缓冲区,意味着缓冲区从写模式切换到读模式。这意味着最初通道能够在缓冲区中写入数据,因为它处于写入模式,但是在缓冲区满了之后,缓冲区切换到读取模式,因此主线程可以读取其内容。

在读取一个缓冲区的内容后,如果需要再做一次,buffer.rewind()方法将位置设置为零。

如果文件很大,可以多次重新初始化ByteBuffer,但在这种情况下,必须在通道写入新数据之前清空缓冲区,这可以通过调用buffer.close()来完成。此外,使用FileInputStream来获取通道不是正确的方法,因为它限制了从文件中读取。但是通道可以读写文件,所以推荐的方法是使用一个java.io.RandomAccessFile实例作为文件处理程序,如清单 11-30 所示。

package com.apress.bgn.eleven.nio;
// other import statements omitted
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class ChannelDemo {
    private static final Logger log = LoggerFactory.getLogger(ChannelDemo.class);

    public static void main(String... args) {
        var sb = new StringBuilder();
        sb = new StringBuilder();
        try (RandomAccessFile file = new RandomAccessFile("chapter11/read-write-file/src/main/resources/input/data.txt", "r");
             FileChannel inChannel = file.getChannel()) {

            ByteBuffer buffer = ByteBuffer.allocate(48);
            while(inChannel.read(buffer) > 0) {
                buffer.flip();
                for (int i = 0; i < buffer.limit(); i++) {
                    sb.append((char) buffer.get());
                }
                buffer.clear();
            }
        }  catch (IOException e) {
            log.error("File could not be read! ", e);
        }
        log.info("Read with FileChannel --> {}", sb.toString());
    }
}

Listing 11-30Reading a File Using FileChannel Using a Smaller ByteBuffer

制作文件的副本也很简单;它只是使用缓冲区将数据从一个通道移动到另一个通道,如清单 11-31 所示。

package com.apress.bgn.eleven.nio;
// other import statements omitted
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class DuplicateImageDemo {
    private static final Logger log = LoggerFactory.getLogger(DuplicateImageDemo.class);

    public static void main(String... args){
        final String inDir = "chapter11/read-write-file/src/main/resources/input/";
        final String outDir = "chapter11/read-write-file/src/main/resources/output/";
        try(FileChannel source =
                    new RandomAccessFile(inDir + "the-beach.jpg", "r").getChannel();
                FileChannel dest =
                    new RandomAccessFile(outDir + "copy-the-beach.jpg", "rw").getChannel()) {
            ByteBuffer buffer = ByteBuffer.allocateDirect(48);
            while (source.read(buffer) != -1) {
                buffer.flip();
                while (buffer.hasRemaining()) {
                    dest.write(buffer);
                }
                buffer.clear();
            }
        } catch (Exception e) {
            log.error("Image could not be copied! ", e);
        }
    }
}

Listing 11-31Duplicating an Image Using FileChannel and a ByteBuffer

另一种方法是使用专用的ReadableByteChannel和WritableByteChannel,如清单 11-32 所示。

package com.apress.bgn.eleven.nio;
// other import statements omitted
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;

public class DuplicateImageDemo {
    private static final Logger log = LoggerFactory.getLogger(DuplicateImageDemo.class);

    public static void main(String... args){
        final String inDir = "chapter11/read-write-file/src/main/resources/input/";
        final String outDir = "chapter11/read-write-file/src/main/resources/output/";
        try(ReadableByteChannel source = new FileInputStream (inDir + "the-beach.jpg").getChannel();
            WritableByteChannel dest = new FileOutputStream (outDir + "2nd-copy-the-beach.jpg").getChannel()) {
            ByteBuffer buffer = ByteBuffer.allocateDirect(48);
            while (source.read(buffer) != -1) {
                buffer.flip();
                while (buffer.hasRemaining()) {
                    dest.write(buffer);
                }
                buffer.clear();
            }
        } catch (Exception e) {
            log.error("Image could not be copied! ", e);
        }
    }
}

Listing 11-32Duplicating an Image Using ReadableByteChannel
and a ByteBuffer

由于它们的非阻塞特性,Java 通道适合于处理由多个数据源提供的数据的应用。这种用户应用通过网络管理与多个源的连接。图 11-6 描绘了Channel层级中最重要的成员。

img/463938_2_En_11_Fig6_HTML.jpg

图 11-6

channel类/接口层次结构(如 IntelliJ IDEA 所示)

DatagramChannel可以通过 UDP 在网络上读写数据。SocketChannel可以通过 TCP 在网络上读写数据,ServerSocketChannel 允许您像 web 服务器一样监听传入的 TCP 连接。为每个传入连接创建一个 SocketChannel。

引入 NIO 组件(接口和类)是为了补充现有的 IO 功能。Java IO 一次读取或写入一个字节或字符。缓冲利用 Java 堆内存,当使用相当大的文件时,这可能会成为问题。当 NIO 发布时,有一种说法是 NI0 比纯 Java I/O 更高效,性能更好,但这完全取决于您试图构建的应用。NIO 引入了批量处理原始字节的可能性、异步操作的可能性以及堆外缓冲。缓冲区是在 JVM 的中央内存之外创建的,位于不由垃圾收集器处理的内存部分。这允许创建更大的缓冲区,因此可以读取更大的文件,而没有因为 JVM 内存不足而抛出OutOfMemoryException的危险。

如果您发现自己需要处理大量的数据,请务必仔细阅读 JDK NIO 文档,因为这一节只是触及了皮毛。

序列化和反序列化

序列化是将对象的状态转换为字节序列的操作的名称。在这种格式中,它可以通过网络发送或写入文件,然后还原成该对象的副本。将字节序列转换回对象的操作被称为反序列化。Java 序列化一直是一个有争议的话题,Java 平台首席架构师 Mark Reinhold 将其描述为 1997 年犯下的一个可怕的错误。显然,大多数 Java 漏洞都与 Java 中序列化的方式有关,有一个名为 Amber 3 的项目致力于完全移除 Java 序列化,并允许开发人员以他们选择的格式选择序列化。

目前,JAVA 的情况很不稳定;在短时间内引入了相当多的变化,这是一个沉迷于向后兼容的行业无法适应的。下一节中的源代码可能不稳定,但我会尽最大努力让它们在书出版时至少是可编译的,我会维护资源库并尽可能多地回答问题。

字节序列化

java.io.Serializable接口没有方法或字段,只用于将类标记为可序列化。当对象被序列化时,标识对象类型的信息也被序列化。大多数 Java 类都是可序列化的。默认情况下,可序列化类的任何子类都被认为是可序列化的。如果任何字段不可序列化,那么将抛出类型为NotSerializableException的异常。开发人员编写的包含不可序列化字段的类必须实现Serializable接口,并为清单 11-33 中所示的方法提供具体的实现。

private void writeObject(java.io.ObjectOutputStream out)
    throws IOException;
private void readObject(java.io.ObjectInputStream in)
    throws IOException, ClassNotFoundException;
private void readObjectNoData()
    throws ObjectStreamException;

Listing 11-33Methods That Need to Be Emplemented to Make a Custom Class Serializable

这些方法不是特定 Java 接口的一部分,所以在这个上下文中实现它们只是意味着在您希望使之可序列化的类中为它们编写一个主体。在前面的清单中对它们进行分组的原因是为了描述这些方法的特征。

writeObject(..)方法用于写入对象的状态,以便readObject(..)方法可以恢复它。readObjectNoData()方法用于在反序列化操作由于某种原因失败时初始化对象的状态,因此尽管存在问题(例如,不完整的流、客户端应用无法识别反序列化的类等),该方法仍会提供默认状态。).如果你是一个乐观主义者,这个方法并不是必须的。

此外,当使类可序列化时,必须添加 long 类型的静态字段作为该类的唯一标识符,以确保以字节流形式发送对象的应用和接收该对象的客户端应用具有相同的加载类。如果接收字节流的应用有一个不同标识符的类,将抛出一个java.io.InvalidClassException。当这种情况发生时,这意味着应用没有更新,或者您甚至可能怀疑黑客的一些不法行为。该字段必须命名为serialVersionUID,如果开发人员没有显式添加,序列化运行时将会添加。清单 11-34 中的以下代码片段描述了一个名为Singer的类,它包含前面代码片段中提到的序列化和反序列化方法。

package com.apress.bgn.eleven;

import java.io.*;
import java.time.LocalDate;
import java.util.Objects;

public class Singer  implements Serializable {
    private static final long serialVersionUID = 42L;

    private String name;

    private Double rating;

    private LocalDate birthDate;

    public Singer() {
        /* required for deserialization */
    }

    public Singer(String name, Double rating, LocalDate birthDate) {
        this.name = name;
        this.rating = rating;
        this.birthDate = birthDate;
    }

    private void writeObject(ObjectOutputStream out) throws IOException {
        out.defaultWriteObject();
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
    }

    private void readObjectNoData() throws ObjectStreamException {
        this.name = "undefined";
        this.rating = 0.0;
        this.birthDate = LocalDate.now();
    }

    @Override
    public String toString() {
        return "Singer{" +
                "name='" + name + '\'' +
                ", rating=" + rating +
                ", birthDate=" + birthDate +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Singer singer = (Singer) o;
        return Objects.equals(name, singer.name) &&
                Objects.equals(rating, singer.rating) &&
                Objects.equals(birthDate, singer.birthDate);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, rating, birthDate);
    }
}

Listing 11-34Serializable Singer Class

现在我们有了类,让我们实例化它,序列化它,保存到一个文件,然后将文件的内容反序列化到另一个对象中,我们将与初始对象进行比较。清单 11-35 中描述了所有这些操作。

package com.apress.bgn.eleven;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.*;
import java.time.LocalDate;
import java.time.Month;

public class SerializationDemo {
    private static final Logger log = LoggerFactory.getLogger(SerializationDemo.class);

    public static void main(String... args) throws ClassNotFoundException {
        LocalDate johnBd = LocalDate.of(1977, Month.OCTOBER, 16);
        Singer john = new Singer("John Mayer", 5.0, johnBd);
        File file = new File("chapter11/serialization/src/test/resources/output/john.txt");
        try (var out = new ObjectOutputStream(new FileOutputStream(file))){
            out.writeObject(john);
        } catch (IOException e) {
            log.info("Something went wrong! ", e);
        }
        try(var in = new ObjectInputStream(new FileInputStream(file))){
            Singer copyOfJohn = (Singer) in.readObject();
            log.info("Are objects equal? {}", copyOfJohn.equals(john));
            log.info("--> {}", copyOfJohn);
        } catch (IOException e) {
            log.info("Something went wrong! ", e);
        }
    }
}

Listing 11-35Serializing and Deserializing a Singer Class

运行前面的代码时,一切正常,分别由ObjectOutputStream、ObjectInputStream调用writeObject(..)和readObject(..)。如果您想测试它们是否被真正调用,您可以添加日志记录,或者您可以在它们内部放置断点并在调试中运行程序。如果你打开john.txt,你将无法理解太多。那里写的文本没有多大意义,因为它是二进制的原始数据。如果你打开文件,你可能会看到如图 11-7 所示的内容。

img/463938_2_En_11_Fig7_HTML.jpg

图 11-7

序列化的Singer实例

XML 序列化

然而,Java 序列化并不一定会产生加密文件。对象可以序列化为可读格式。最常用的序列化格式之一是 XML,JDK 提供了将对象转换为 XML 以及将 XML 转换回初始对象的类。Java Architecture for XML Binding(JAXB)用于提供一种快速便捷的方式来绑定 XML schemas 和 Java 表示,使 Java 开发人员可以轻松地将 XML 数据和处理功能合并到 Java 应用中。将对象序列化为 XML 的操作被命名为编组。反序列化对象形式 XML 的操作被称为解组。对于一个可序列化为 XML 的类,它必须用 JAXB 特定的注释来修饰:

  • @XmlRootElement(name = "... ")是放置在类级别的顶级注释,告诉 JAXB 类名将在序列化时成为 XML 元素;如果 XML 元素需要不同的名称,可以通过 name 属性来指定。

  • @XmlElement(name = "..")是一个方法或字段级注释,用于告诉 JAXB 字段或方法名称将在序列化时成为 XML 元素;如果 XML 元素需要不同的名称,可以通过 name 属性来指定。

  • @XmlAttribute(name = "..")是一个方法或字段级别的注释,用于告诉 JAXB 字段或方法名称将在序列化时成为 XML 属性;如果 XML 属性需要不同的名称,可以通过 name 属性来指定。

JAXB 已从 JDK 11 中移除,因此如果您想使用它,必须添加外部依赖项。 4 当这本书的前一个版本写出来的时候,这个库不仅仅是有点不稳定。com.sun.xml.internal.bind.v2.ContextFactory是jaxb-impl库的一部分,当时在任何公共存储库中都找不到,至少不是用 Java 11 编译的版本。这使得配置模块成为一件痛苦的事情,因为多个依赖项导出相同的包。然而,代码在当时是有效的,因为在实践中你可能碰巧在老的项目上工作,所以知道它的存在是很好的。

清单 11-36 中描述了用 JAXB 使Singer类可序列化的代码。注意前面列出的注释是如何在类头和类公共 getters 上使用的。

package com.apress.bgn.eleven.xml;

import javax.xml.bind.annotation.XmlAttribute;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Objects;

@XmlRootElement(name = "singer")
public class Singer implements Serializable {
    private static final long serialVersionUID = 42L;

    private String name;

    private Double rating;

    private LocalDate birthDate;

    public Singer() {
        /* required for deserialization */
    }

    public Singer(String name, Double rating, LocalDate birthDate) {
        this.name = name;
        this.rating = rating;
        this.birthDate = birthDate;
    }

    @XmlAttribute(name = "name")
    public String getName() {
        return name;
    }

    @XmlAttribute(name = "rating")
    public Double getRating() {
        return rating;
    }

    @XmlElement(name = "birthdate")
    public LocalDate getBirthDate() {
        return birthDate;
    }
    // other code omitted
}

Listing 11-36A Singer Class with JAXB Annotations

清单 11-37 描述了序列化Singer类实例所需的代码。

package com.apress.bgn.eleven.xml;
// other imports omitted
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Marshaller;
import javax.xml.bind.Unmarshaller;

public class JAXBSerializationDemo {

    private static final Logger log = LoggerFactory.getLogger(JAXBSerializationDemo.class);

    public static void main(String... args) throws ClassNotFoundException, JAXBException {
        LocalDate johnBd = LocalDate.of(1977, Month.OCTOBER, 16);
        Singer john = new Singer("John Mayer", 5.0, johnBd);

        File file = new File("chapter11/serialization/src/main/resources/output/john.xml");
        JAXBContext jaxbContext = JAXBContext.newInstance(Singer.class);

        try {
            Marshaller marshaller = jaxbContext.createMarshaller();
            marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
            marshaller.marshal(john, file);
        } catch (Exception e) {
            log.info("Something went wrong! ", e);
        }

        try {
            Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();
            Singer copyOfJohn = (Singer) unmarshaller.unmarshal(file);
            log.info("Are objects equal? {}", copyOfJohn.equals(john));
            log.info("--> {}", copyOfJohn.toString());
        } catch (Exception e) {
            log.info("Something went wrong! ", e);
        }
    }
}

Listing 11-37Marshalling and Unmarshalling a Singer Class with JAXB

在 JDK 17 中使用 JAXB 不是一个选项,因为社区库自 2018 年以来一直没有维护。在本书的这个版本中,选择使用最稳定、最通用和最新的库之一:Jackson 来引入 XML 序列化。 5

Jackson 在 Java 生态系统中以终极 Java JSON 库而闻名,但它有支持多种格式序列化的模块,其中包括 XML、JSON、CSV、TAML 和 YAML。只需查看项目页面;如果有一种新的引人注目的序列化格式出现,可能已经有一个模块了。

当使用 Jackson 序列化为 XML 时,需要记住一些事情。

  • 有一组不同的注释可供使用,下面列出了最重要的注释:

    • @JacksonXmlRootElement(localName = "... ")是一个顶级注释,放置在类级别,告诉 Jackson 类名将在序列化时成为 XML 元素;如果 XML 元素需要不同的名称,可以通过localName属性来指定。

    • @JacksonXmlProperty(localName = "... ")是一个方法或者字段级的注释,用来告诉 Jackson 字段或者方法名在序列化的时候会变成一个 XML 元素;如果 XML 元素需要不同的名称,可以通过localName属性来指定。

    • 当属性被配置为 XML 属性时,使用带有isAttribute = true参数的@JacksonXmlProperty(localName = "...", isAttribute = true)。

  • 为了用 Jackson 进行序列化和反序列化,使用了一个实例com.fasterxml.jackson.dataformat.xml.XmlMapper。

  • 必须配置XmlMapper实例来支持特殊类型,比如新的 Java 8 Date API 类型,这是通过注册和配置com.fasterxml.jackson.datatype.jsr310.JavaTimeModule来完成的。

  • 当使用 Java 模块时,你必须确保它们配置正确。异常并不总是容易理解的,解决它们可能需要结合 Maven 和模块配置来解决。

也就是说,让我们从清单 11-38 中显示的模块配置开始。

module chapter.eleven.serialization {
    requires org.slf4j;
    requires com.fasterxml.jackson.databind;
    requires com.fasterxml.jackson.dataformat.xml;
    requires com.fasterxml.jackson.datatype.jsr310;

    opens com.apress.bgn.eleven.xml to com.fasterxml.jackson.databind;
}

Listing 11-38Module Configuration for XML Serialization with Jackson

需要前两个requires com.fasterxml.jackson.*指令,以便可以使用 Jackson 注释和XmlMapper。Java 8 日期 API 类型的序列化需要jsr310。

最后一个语句opens com.apress.bgn.eleven.xml to com.fasterxml.jackson.databind是必要的,这样 Jackson 就可以访问包com.apress.bgn.eleven.xml中的类,因为使用 Jackson 注释编写的Singer类的版本就位于那里。清单 11-39 中描述了该类。

package com.apress.bgn.eleven.xml;
// other imports omitted
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;

@JacksonXmlRootElement(localName = "singer")
public class Singer implements Serializable {
    private static final long serialVersionUID = 42L;

    private String name;

    private Double rating;

    private LocalDate birthDate;

    public Singer() {
        /* required for deserialization */
    }

    public Singer(String name, Double rating, LocalDate birthDate) {
        this.name = name;
        this.rating = rating;
        this.birthDate = birthDate;
    }

    @JacksonXmlProperty(localName = "name", isAttribute = true)
    public String getName() {
        return name;
    }

    @JacksonXmlProperty(localName = "rating", isAttribute = true)
    public Double getRating() {
        return rating;
    }

    @JacksonXmlProperty(localName = "birthdate")
    public LocalDate getBirthDate() {
        return birthDate;
    }
    // other code omitted
}

Listing 11-39A Singer Class with Jackson XML Annotations

请注意放置注释的位置。基于在序列化john对象时注释的位置和它们在前面代码中的配置,预计john.xml文件将包含清单 11-40 中描述的代码片段。

<singer name="John Mayer" rating="5.0">
  <birthdate>1977-10-16</birthdate>
</singer>

Listing 11-40The john Singer Instance in XML Format

比二进制版本可读性更强,对吧?清单 11-41 描述了将Singer实例保存到john.xml文件的代码,然后它将它加载回一个副本,然后比较这两个实例。

package com.apress.bgn.eleven.xml;
// some import statements omitted
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

public class XMLSerializationDemo {

    private static final Logger log = LoggerFactory.getLogger(XMLSerializationDemo.class);

    public static void main(String... args) {
        LocalDate johnBd = LocalDate.of(1977, Month.OCTOBER, 16);
        Singer john = new Singer("John Mayer", 5.0, johnBd);

        var xmlMapper = new XmlMapper();
        xmlMapper.registerModule(new JavaTimeModule());
        xmlMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
        xmlMapper.enable(SerializationFeature.INDENT_OUTPUT);

        try {
            String xml = xmlMapper.writeValueAsString(john);
            Files.writeString(Path.of("chapter11/serialization/src/test/resources/output/john.xml"), xml,
                    StandardCharsets.UTF_8);
        } catch (Exception e) {
            log.info("Serialization to XML failed! ", e);
        }

        try {
            Singer copyOfJohn = xmlMapper.readValue(Path.of("chapter11/serialization/src/test/resources/output/john.xml").toFile(), Singer.class);
            log.info("Are objects equal? {}", copyOfJohn.equals(john));
            log.info("--> {}", copyOfJohn);
        } catch (IOException e) {
            log.info("Deserialization of XML failed! ", e);
        }
    }
}

Listing 11-41Serializing and Deserializing a Singer Class with Jackson’s XmlMapper

XmlMapper实例可以用来序列化项目中包含 Jackson 注释的任何类。在前面的示例中,它还被配置为支持 Java 8 Date API 类型的默认序列化,并保持类型可读,方法是不使用以下两行将它们转换为数字时间戳:

xmlMapper.registerModule(new JavaTimeModule());
xmlMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

因为选择的格式是 XML,如果全部写在一行中,看起来会很难看,所以使用这个语句xmlMapper.enable(SerializationFeature.INDENT_OUTPUT)支持缩进格式。

XML 序列化多年来一直主导着开发领域,被用于大多数 web 服务和远程通信。然而,随着 XML 文件变得越来越大,它们往往变得拥挤、冗余,阅读起来也很痛苦,所以一种新的格式抢了风头:JSON。

JSON 序列化

JSON 是一种轻量级的数据交换格式。它对于人类来说是可读的,对于机器来说是容易解析和生成的。JSON 是 JavaScript 应用和基于 REST 的应用中最受欢迎的数据格式,也是许多 NoSQL 数据库使用的内部格式。因此,我们向您展示如何使用这种格式来序列化/反序列化 Java 对象是非常合适的。将 Java 对象序列化为 JSON 的优点是,有多个库提供这样做的类,这意味着至少有一个库在 Java 9+版本中是稳定的。

JSON 格式本质上是密钥对值的集合。这些值可以是数组,也可以是密钥对本身的集合。JSON 序列化最喜欢的库也是 Jackson 库,因为它可以在 Java 对象和 JSON 对象之间来回转换,而不需要编写太多代码。本章最好的部分是相同的模块配置也可以用于 JSON。我们所需要的只是改变所使用的注释和用来进行序列化/反序列化的映射器的类型。Jackson 支持 JSON 序列化的大量注释,但是对于本书中的简单例子,我们真的不需要任何注释。Jackson com.fasterxml.jackson.databind.json.JsonMapper实例足够智能,可以自动检测一个类的公共可访问属性(公共字段,或者带有公共 getters 的私有字段),并在序列化/反序列化该类的实例时使用它们。

包com.fasterxml.jackson.annotation中的@JsonAutoDetect注释可以用来注释一个类。可以配置它来告诉映射器应该序列化哪些类成员。有几个选项,集中在注释体中声明的Visibility枚举中:

  • 所有类型的访问修饰符(公共的、受保护的、私有的)都会被自动检测。

  • NON_PRIVATE 自动检测除了private以外的所有修改器。

  • PROTECTED_AND_PUBLIC,只有protected和public修饰符被自动检测。

  • 自动检测 PUBLIC _ ONLY】修饰符。

  • NONE 禁用字段或方法的自动检测。在这种情况下,必须使用字段上的@JsonProperty注释来明确完成配置。

  • 根据上下文(有时从父级继承),应用默认的缺省规则。

放置在Singer类上的这个注释与适当的映射器和JavaTimeModule相结合,确保了Singer类的一个实例可以正确地序列化为 JSON 也是从 JSON 反序列化而来的。清单 11-42 显示了Singer类的简单配置(即使是冗余的)。

package com.apress.bgn.eleven.json;
// some import statements omitted
import com.fasterxml.jackson.annotation.JsonAutoDetect;

@JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.PUBLIC_ONLY)
public class Singer  implements Serializable {
    private static final long serialVersionUID = 42L;
    private String name;
    private Double rating;
    private LocalDate birthDate;

    public String getName() { // auto-detected
        return name;
    }

    public Double getRating() { // auto-detected
        return rating;
    }

    public LocalDate getBirthDate() { // auto-detected
        return birthDate;
    }
    // other code omitted
}

Listing 11-42Annotating a Singer Class with Jackson @JsonAutoDetect Just to Show How It’s Done

为了序列化一个Singer实例,需要一个JsonMapper实例。这个类是在 Jackson 版本 2.10 中引入的。在那个版本之前,com.fasterxml.jackson.databind. ObjectMapper号被用于同样的目的。ObjectMapper旨在成为未来版本中所有映射器的根类。上一节使用的XmlMapper也扩展了ObjectMapper。JsonMapper是一个特定于 JSON 格式的ObjectMapper实现,旨在取代通用实现,清单 11-43 描述了一个如何使用它来序列化/反序列化Singer实例的示例。

package com.apress.bgn.eleven.json;
// other import statements omitted
import com.apress.bgn.eleven.xml.Singer;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

public class JSONSerializationDemo {
    private static final Logger log = LoggerFactory.getLogger(JSONSerializationDemo.class);

    public static void main(String... args) {
        LocalDate johnBd = LocalDate.of(1977, Month.OCTOBER, 16);
        com.apress.bgn.eleven.xml.Singer john = new Singer("John Mayer", 5.0, johnBd);

        JsonMapper jsonMapper = new JsonMapper();
        jsonMapper.registerModule(new JavaTimeModule());
        jsonMapper.enable(SerializationFeature.INDENT_OUTPUT);
        jsonMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

        try {
            String xml = jsonMapper.writeValueAsString(john);
            Files.writeString(Path.of("chapter11/serialization/src/test/resources/output/john.json"), xml,
                    StandardCharsets.UTF_8);
        } catch (Exception e) {
            log.info("Serialization to XML failed! ", e);
        }

        try {
            Singer copyOfJohn = jsonMapper.readValue(Path.of("chapter11/serialization/src/test/resources/output/john.json").toFile(), Singer.class);
            log.info("Are objects equal? {}", copyOfJohn.equals(john));
            log.info("--> {}", copyOfJohn);
        } catch (IOException e) {
            log.info("Deserialization of XML failed! ", e);
        }
    }
}

Listing 11-43Serializing and Deserializing a Singer Class with Jackson’s JsonMapper

正如您所看到的,除了映射器用户的类型之外,在从 XML 进行转换时,这个代码示例中没有多少变化。杰克逊很棒,对吧?

类Singer中字段birthDate是类型java.time.LocalDate。注册JavaTimeModule允许控制如何在映射器级别序列化/反序列化这种类型的字段。另一种方法是为这种类型的数据声明一个定制的序列化器和反序列化器类,并通过用@JsonSerialize和@JsonDeserialize注释注释birthDate来配置它们。清单 11-44 显示了在 birthdate 字段上配置的定制序列化器和反序列化器类。

package com.apress.bgn.eleven.json2;
// other import statements omitted
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;

@JsonAutoDetect(getterVisibility = JsonAutoDetect.Visibility.PUBLIC_ONLY)
public class Singer  implements Serializable {
    private static final long serialVersionUID = 42L;

    private String name;

    private Double rating;

    @JsonSerialize(converter = LocalDateTimeToStringConverter.class)
    @JsonDeserialize(converter = StringToLocalDatetimeConverter.class)
    private LocalDate birthDate;
    // other code omitted
}

Listing 11-44Configuring Custom Serialization and Deserialization for java.time.LocalDate Fields

清单 11-45 显示了两个序列化器和反序列化器类的定制实现。

package com.apress.bgn.eleven.json2;

import com.fasterxml.jackson.databind.util.StdConverter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;

class LocalDateTimeToStringConverter extends StdConverter<LocalDateTime, String> {
    static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG);
    @Override
    public String convert(LocalDateTime value) {
        return value.format(DATE_FORMATTER);
    }
}

class StringToLocalDatetimeConverter extends StdConverter<String, LocalDateTime> {
    @Override
    public LocalDateTime convert(String value) {
        return LocalDateTime.parse(value, LocalDateTimeToStringConverter.DATE_FORMATTER);
    }
}

Listing 11-45Custom Serialization and Deserialization Classes

关于与 Jackson 的 JSON 序列化,只能说这些。如果这个主题对你有吸引力,你可以自己多读一些。

img/463938_2_En_11_Figa_HTML.jpg还有一个用于将 Java 实例序列化到 YAML 的 Jackson 库,它是在配置文件方面的新人。该库被命名为jackson-dataformat-yaml。

媒体 API

除了文本数据,Java 还可以用来操作二进制文件,比如图像。Java Media API 包含一组图像编码器/解码器(codec)类,用于几种流行的图像存储格式:BMP、GIF(仅限解码器)、FlashPix(仅限解码器)、JPEG、PNG、PNM3、TIFF 和 WBMP。

在 Java 9 中,Java media API 也进行了转换,增加了将许多不同分辨率的图像封装成多分辨率图像的功能。Java Media API 的核心是java.awt.Image抽象类,它是用于表示图形图像的所有类的超类。图 11-8 中描述了代表类的最重要的图像以及它们之间的关系。

img/463938_2_En_11_Fig8_HTML.jpg

图 11-8

Image类层次结构(如 IntelliJ IDEA 所示)

虽然java.awt.Image类是这个层次结构中的根类,但使用最多的是java.awt.BufferedImage,它是一个具有可访问图像数据缓冲区的实现。它提供了许多方法,可以用来创建图像、设置图像大小和内容、提取图像内容并进行分析,等等。在这一节中,我们将利用这个类来读写图像。

图像文件是一个复杂的文件。除了图片本身,它还包含许多附加信息;如今最重要的是这幅画的创作地点。如果你想知道一个社交网络是如何为你发布的图片建议一个签到位置的,这里就是找到信息的地方。这可能看起来不那么重要,但是贴一张在你家拍的猫的照片会让全世界都知道你的位置。我不知道你怎么想,但对我来说这太可怕了。我曾经把我的猫舒适地坐在我写这本书的电脑上的照片贴在我的个人博客上,这意味着我基本上把我和一台相当昂贵的笔记本电脑的位置暴露给了全世界。当然,大多数人不关心我的猫,也不关心我的笔记本电脑,但一些想轻松赚钱的人可能会。因此,在一位友好而博学的读者给我发了一封私人邮件,告诉我一种叫做EXIF的数据,以及他是如何因为我在博客上发布的最后一张照片而知道我住在哪里之后,我调查了一下。一张照片的EXIF数据包含大量关于你的相机和照片拍摄地点的信息(GPS 坐标)。大多数智能手机将EXIF数据嵌入到相机拍摄的照片中。

在图 11-9 中,你可以看到 macOS 预览应用描述的EXIF信息。

img/463938_2_En_11_Fig9_HTML.jpg

图 11-9

JPG 图像上的 EXIF 信息

请注意,EXIF信息包含照片拍摄的确切位置、纬度和经度。EXIF代表可交换图像文件格式,有实用程序可以删除它,但当你在博客上发布大量图片时(像我一样),逐一清理它们会花费太多时间。这就是 Java 的用武之地,我将与你分享我用来清理我的EXIF数据图片的一段代码(清单 11-46 )。

package com.apress.bgn.eleven;
// some import statement omitted
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BaseMultiResolutionImage;
import java.awt.image.BufferedImage;
import java.awt.image.MultiResolutionImage;

/**
 * Created by iuliana.cosmina on 23/07/2021
 */
public class MediaDemo {
    private static final Logger log = LoggerFactory.getLogger(MediaDemo.class);

    public static void main(String... args) {
        File src = new File("chapter11/media-handling/src/main/resources/input/the-beach.jpg");

        try {
            log.info(" --- Removing EXIF info ---");
            File destNoExif = new File("chapter11/media-handling/src/main/resources/output/the-beach.jpg");
            removeExifTag(src, destNoExif);
             } catch (Exception e) {
            log.error("Something bad happened.", e);
        }
    }

    private static void removeExifTag(final File src, final File dest) throws Exception {
        BufferedImage originalImage = ImageIO.read(src);
        ImageIO.write(originalImage, "jpg", dest);
    }
}

Listing 11-46Code Snippet to Strip EXIF Data from Images

删除EXIF数据非常容易,因为javax.imageio.ImageIO不会在图像文件中保存EXIF信息或任何其他没有链接到实际图像的信息。

在本书的前一版本中,使用了阿帕奇 Sanselan。这个实用程序库提供了能够以更好的性能剥离EXIF信息的类,但不幸的是,它目前没有被维护,并且不能用于模块化应用。

removeExifTag(..)方法作为一个参数给出,即图像的来源和一个管理新图像保存位置的File处理程序。要测试生成的图像是否没有EXIF数据,只需在图像查看器中打开它。任何显示EXIF的选项要么被禁用,要么不显示任何内容。在 macOS 的预览图像浏览器中,该选项是灰色的。

现在我们已经解决了这个问题,让我们调整原始图像的大小。为了调整图像的大小,我们需要从原始图像创建一个BufferedImage实例来获取图像的尺寸。之后,我们修改维度并使用它们作为参数来创建一个新的BufferedImage,它将由一个java.awt.Graphics2D实例填充数据,这是一个特殊类型的类,用于呈现二维形状、文本和图像。清单 11-47 中描述了代码。调用该方法创建一个缩小 25%的图像、一个缩小 50%的图像和一个缩小 75%的图像。

package com.apress.bgn.eleven;

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BaseMultiResolutionImage;
import java.awt.image.BufferedImage;

public class MediaDemo {
    private static final Logger log = LoggerFactory.getLogger(MediaDemo.class);

    public static void main(String... args) {
        File src = new File("chapter11/media-handling/src/main/resources/input/the-beach.jpg");

        try {
            log.info(" --- Creating 25% image ---");
            File dest25 = new File("chapter11/media-handling/src/main/resources/output/the-beach_25.jpg");
            resize(dest25, src, 0.25f);

            log.info(" --- Creating 50% image ---");
            File dest50 = new File("chapter11/media-handling/src/main/resources/output/the-beach_50.jpg");
            resize(dest50, src, 0.5f);

            log.info(" --- Creating 75% image ---");
            File dest75 = new File("chapter11/media-handling/src/main/resources/output/the-beach_75.jpg");
            resize(dest75, src, 0.75f);
        } catch (Exception e) {
            log.error("Something bad happened.", e);
        }
    }

    private static void resize(final File dest, final File src, final float percent) throws IOException {
        BufferedImage originalImage = ImageIO.read(src);
        int scaledWidth = (int) (originalImage.getWidth() * percent);
        int scaledHeight = (int) (originalImage.getHeight() * percent);

        BufferedImage outputImage = new BufferedImage(scaledWidth, scaledHeight, originalImage.getType());

        Graphics2D g2d = outputImage.createGraphics();
        g2d.drawImage(originalImage, 0, 0, scaledWidth, scaledHeight, null);
        g2d.dispose();
        outputImage.flush();

        ImageIO.write(outputImage, "jpg", dest);
    }
}

Listing 11-47Code Snippet to Resize an Image

为了使事情变得更简单,ImageIO类实用程序方法在从文件中读取图像或将图像写入特定位置时非常方便。如果您想测试一下调整大小是否有效,您可以查看一下resources目录。输出文件已经被相应地命名,但是为了确保正确,您可以在文件查看器中仔细检查。你应该会看到类似于图 11-10 中描述的东西。

img/463938_2_En_11_Fig10_HTML.jpg

图 11-10

使用 Java 代码调整大小的图像

结果图像的质量不如原始图像高,因为压缩像素不会产生高质量,但它们确实符合我们的预期大小。

现在我们有了同一幅图像的所有版本,我们可以使用 Java 9 中引入的类BaseMultiResolutionImage来创建多分辨率图像。这个类的一个实例是从一组图像中创建的,所有图像都是一个图像的副本,但是分辨率不同。这就是为什么之前我们创建了不止一个图片的副本。一个BaseMultiResolutionImage可以用来检索基于特定屏幕分辨率的图像,它适用于设计为从多个设备访问的应用。让我们先看看代码,然后解释结果(列出 11-48 )。

package com.apress.bgn.eleven;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BaseMultiResolutionImage;
import java.awt.image.BufferedImage;
import java.awt.image.MultiResolutionImage;
import java.io.File;
import java.io.IOException;

public class MediaDemo {
    private static final Logger log = LoggerFactory.getLogger(MediaDemo.class);

    public static void main(String... args) {
        File src = new File("chapter11/media-handling/src/main/resources/input/the-beach.jpg");

        try {
            // code to create images omitted, check previous Listing
            Image[] imgList = new Image[]{
                    ImageIO.read(dest25), // 500 x 243
                    ImageIO.read(dest50), // 1000 x 486
                    ImageIO.read(dest75), // 1500 x 729
                    ImageIO.read(src) // 2000 x 972
            };

            log.info(" --- Creating multi-resolution image ---");
            File destVariant = new File("chapter11/media-handling/src/main/resources/output/the-beach-variant.jpg");
            createMultiResImage(destVariant, imgList);

            BufferedImage variantImg = ImageIO.read(destVariant);
            log.info("Variant width x height :  {} x {}", variantImg.getWidth(), variantImg.getHeight());

            BufferedImage dest25Img = ImageIO.read(dest25);
            log.info("dest25Img width x height :  {} x {}", dest25Img.getWidth(), dest25Img.getHeight());
            log.info("Are identical? {}", variantImg.equals(dest25Img));
        } catch (Exception e) {
            log.error("Something bad happened.", e);
        }
    }

    private static void createMultiResImage(final File dest, final Image[] imgList) throws IOException {
        MultiResolutionImage mrImage = new BaseMultiResolutionImage(0,imgList);
        var variants = mrImage.getResolutionVariants();
        variants.forEach(i -> log.info(i.toString()));

        Image img = mrImage.getResolutionVariant(500, 200);
        log.info("Most fit to the requested size<{},{}>: <{},{}>", 500, 200, img.getWidth(null), img.getHeight(null));

        if (img instanceof BufferedImage) {
            ImageIO.write((BufferedImage) img, "jpg", dest);
        }
    }
}

Listing 11-48Code Snippet to Create a Multiresolution Image

从一组Image实例中创建BaseMultiResolutionImage实例。这个类是 MultiResolutionImage 接口的一个实现,它是一个可选的附加 API,由 Image 的一些实现支持,允许它们为各种渲染分辨率提供替代图像。

为了清楚地显示哪张图像将被选中,每张图像的分辨率都被放在旁边的注释中。当调用getResolutionVariant(..)时,将参数与相应的图像属性进行比较,即使两者都小于等于其中一个图像,也将返回该图像。在清单 11-49 中,描述了BaseMultiResolutionImage.getResolutionVariant(..)的代码:

@Override
public Image getResolutionVariant(double destImageWidth,
                                  double destImageHeight) {

    checkSize(destImageWidth, destImageHeight);

    for (Image rvImage : resolutionVariants) {
        if (destImageWidth <= rvImage.getWidth(null)
                && destImageHeight <= rvImage.getHeight(null)) {
            return rvImage;
        }
    }
    return resolutionVariants[resolutionVariants.length - 1];
}

Listing 11-49Code for Getting an Image Variant Based on Size

代码看起来符合它的目的。如果您调用mrImage.getResolutionVariant(500, 200),您将获得分辨率为 500 x 243 的dest25图像。如果您调用mrImage.getResolutionVariant(500, 300),您将得到分辨率为 1000 x 486 的dest50图像,因为destImageHeight参数是 300,大于 243,所以返回列表中宽度和高度大于参数的下一个图像。但是——这是一个很大的问题——只有当数组中的图像按大小排序时,这种方法才有效。如果imgList被修改为:

Image[] imgList = new Image[]{
                ImageIO.read(src), // 2000 x 972
                ImageIO.read(dest25), // 500 x 243
                ImageIO.read(dest50), // 1000 x 486
                ImageIO.read(dest75) // 1500 x 729
        };

然后两个调用都返回原始图像,因为这是列表中的第一个图像,宽度大于 500,高度大于 200 和 300。

因此,如果算法效率不高,并且它依赖于用于创建多分辨率图像的数组中图像的顺序,那么可以做些什么呢?很简单:我们可以创建自己的MultiResolutionImage实现,扩展BaseMultiResolutionImage并覆盖getResolutionVariant()方法。因为我们知道所有的图像都是相同图像的副本,这意味着宽度和高度是成比例的。因此,可以编写一种算法,该算法将总是返回最适合所需分辨率的图像变体,而不会真正关心图像在阵列中的顺序,并且将返回最适合的图像。该实现可能看起来与清单 11-50 中的非常相似。

package com.apress.bgn.eleven;
// other import statements omitted
import java.awt.image.BaseMultiResolutionImage;

public class SmartMultiResolutionImage extends BaseMultiResolutionImage {

    public SmartMultiResolutionImage(int baseImageIndex, Image... resolutionVariants) {
        super(baseImageIndex, resolutionVariants);
    }

    @Override
    public Image getResolutionVariant(double destImageWidth,
                                      double destImageHeight) {

        checkSize(destImageWidth, destImageHeight);
        Map<Double, Image> result = new HashMap<>();

        for (Image rvImage : getResolutionVariants()) {
            double widthDelta = Math.abs(destImageWidth - rvImage.getWidth(null));
            double heightDelta = Math.abs(destImageHeight - rvImage.getHeight(null));
            double delta = widthDelta + heightDelta;
            result.put(delta, rvImage);
        }
        java.util.List<Double> deltaList = new ArrayList<>(result.keySet());
        deltaList.sort(Double::compare);

        return result.get(deltaList.get(0));
    }

    private static void checkSize(double width, double height) {
        if (width <= 0 || height <= 0) {
            throw new IllegalArgumentException(String.format(
                    "Width (%s) or height (%s) cannot be <= 0", width, height));
        }

        if (!Double.isFinite(width) || !Double.isFinite(height)) {
            throw new IllegalArgumentException(String.format(
                    "Width (%s) or height (%s) is not finite", width, height));
        }
    }
}

Listing 11-50Better Code for Getting an Image Variant Based on Size

必须复制checkSize(..)方法,因为它是私有的,并且在getResolutionVariant(..)内部使用,所以不能在超类内部调用,但是这对于具有适当行为的实现来说是一个小小的不便。有了前面的实现,我们不再需要排序后的数组,因此对getResolutionVariant(500, 200), getResolutionVariant(500, 300)、getResolutionVariant(400, 300),和getResolutionVariant(600, 300)的调用都返回图像dest25。

要使用这个新类,在清单 11-48 中的这一行:

MultiResolutionImage mrImage = new BaseMultiResolutionImage(0,imgList);

必须替换为

MultiResolutionImage mrImage = new SmartMultiResolutionImage(0, imgList);

如果你想正确地测试它,你也可以在imgList数组中重新定位图像。然后运行MediaDemo类产生清单 11-51 中描述的输出。

[main] INFO MediaDemo -  --- Creating multi-resolution image ---
[main] INFO MediaDemo - BufferedImage@47c62251: type = 5 ColorModel: #pixelBits = 24 numComponents = 3 color space = java.awt.color.ICC_ColorSpace@e25b2fe transparency = 1 has alpha = false isAlphaPre = false ByteInterleavedRaster: width = 2000 height = 972 #numDataElements 3 dataOff[0] = 2
[main] INFO MediaDemo - BufferedImage@3c0ecd4b: type = 5 ColorModel: #pixelBits = 24 numComponents = 3 color space = java.awt.color.ICC_ColorSpace@e25b2fe transparency = 1 has alpha = false isAlphaPre = false ByteInterleavedRaster: width = 500 height = 243 #numDataElements 3 dataOff[0] = 2
[main] INFO MediaDemo - BufferedImage@14bf9759: type = 5 ColorModel: #pixelBits = 24 numComponents = 3 color space = java.awt.color.ICC_ColorSpace@e25b2fe transparency = 1 has alpha = false isAlphaPre = false ByteInterleavedRaster: width = 1000 height = 486 #numDataElements 3 dataOff[0] = 2
[main] INFO MediaDemo - BufferedImage@5f341870: type = 5 ColorModel: #pixelBits = 24 numComponents = 3 color space = java.awt.color.ICC_ColorSpace@e25b2fe transparency = 1 has alpha = false isAlphaPre = false ByteInterleavedRaster: width = 1500 height = 729 #numDataElements 3 dataOff[0] = 2
[main] INFO MediaDemo - Most fit to the requested size<500,200>: <500,243>
[main] INFO MediaDemo - Are identical? false

Listing 11-51Output Produced By Running the MediaDemo

等等什么?为什么图像不一样?它们确实有相同的分辨率,但作为对象,它们并不相同,因为绘制像素并不真的那么精确。但是如果你真的想确定,你可以打印两幅图像的宽度和高度,用图像浏览器打开它们,用肉眼你会看到它们看起来是一样的,使用这样的代码:

log.info("variant width x height : {} x {}", variantImg.getWidth(),
    variantImg.getHeight());
log.info("dest25Img width x height : {} x {}", dest25Img.getWidth(),
    dest25Img.getHeight());

前面的代码打印了两个图像的宽度和高度,很明显这两个图像具有相同的尺寸,正如预期的那样。

[main] INFO MediaDemo - variant width x height :  500 x 243
[main] INFO MediaDemo - dest25Img width x height :  500 x 243

无论如何,正如你已经注意到的,大多数图像类都是旧的java.awt的一部分,现在很少使用,而且众所周知非常慢。因此,如果您想要构建一个应用,并且需要图像处理,您可能需要寻找替代方案。其中一种方法是使用 JavaFx,这将在下一节中介绍。

使用 JavaFX 图像类

除了以java.awt包的组件为中心的 Java Media API 之外,JavaFX 还提供了另一种显示和编辑图像的方法。javafx.scene.image包的核心类名为Image,可以用来处理几种常见格式的图像:PNG、JPEG、BMP、GIF 等。JavaFX 应用使用一个javafx.scene.image.ImageView实例显示图像,我最喜欢这个类的一点是,图像也可以缩放显示,而不需要修改原始图像。

要创建一个javafx.scene.image.Image实例,我们需要的要么是一个从用户提供的位置读取图像的FileInputStream实例,要么是一个给定为String的 URL 位置。清单 11-52 中的代码片段创建了一个 JavaFX 应用,该应用显示具有原始宽度和高度的图像,可以使用类javafx.scene.image.Image中的方法来访问该图像。

package com.apress.bgn.eleven;

import javafx.application.Application;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

import java.io.File;
import java.io.FileInputStream;

public class JavaFxMediaDemo extends Application {

    public static void main(String... args) {
        Application.launch(args);
    }

    @Override
    public void start(Stage primaryStage) throws Exception {
        primaryStage.setTitle("JavaFX Image Demo");
        File src = new File("chapter11/media-handling/src/main/resources/cover.png");
        Image image = new Image(new FileInputStream(src));
        ImageView imageView = new ImageView(image);
        imageView.setFitHeight(image.getHeight());
        imageView.setFitWidth(image.getWidth());
        imageView.setPreserveRatio(true);
        //Creating a Group object
        StackPane root = new StackPane();
        root.getChildren().add(imageView);
        primaryStage.setScene(new Scene(root,
        image.getWidth()+10,
        image.getHeight()+10));
        primaryStage.show();
    }
}

Listing 11-52Using JavaFX to Display Images

不能将Image实例直接添加到 JavaFX 实例的Scene中,因为它没有扩展构成JavaFxApplication的所有 JavaFX 元素需要实现的Node抽象类。这就是为什么这个实例必须被包装在一个javafx.scene.image.ImageView实例中,这个实例是一个扩展Node,的类,它是一个用于渲染加载了Image类的图像的专用类。

这个类通过调用带有适当参数的setPreserveRatio(..)方法来调整显示图像的大小,保留或不保留原始纵横比,调用true来保持原始纵横比,否则调用false。

img/463938_2_En_11_Figc_HTML.jpg查看章节 ** 10 ** 了解如何为您的系统安装 JavaFX,以便本章中的示例可以正确运行。

如你所见,在前面的代码中,我们使用由image.getWidth()和image.getHeight()返回的值来设置ImageView对象的大小和Scene实例的大小。但是让我们发挥创意,显示缩放后的图像,仍然保持纵横比,并且在使用smooth(..)方法缩放图像时使用更高质量的过滤算法,如下所示。

...
ImageView imageView = new ImageView(image);
imageView.setFitWidth(100);
imageView.setPreserveRatio(true);
imageView.setSmooth(true);
...

ImageView类可以做的另一件事是支持一个可以用来旋转图像的Rectangle2D视口。

...
ImageView imageView = new ImageView(image);
Rectangle2D viewportRect = new Rectangle2D(2, 2, 600, 600);
imageView.setViewport(viewportRect);
imageView.setRotate(90);
...

作为 node ImageView的一个实现,它支持点击事件,并且很容易编写一些代码来在点击时调整图像的大小。看看清单 11-53 中的代码就知道了。

...
    ImageView imageView = new ImageView(image);
    imageView.setFitHeight(image.getHeight());
    imageView.setFitWidth(image.getWidth());
    imageView.setPreserveRatio(true);
    root.getChildren().add(imageView);
    imageView.setPickOnBounds(true);
    imageView.setOnMouseClicked(mouseEvent -> {
        if(imageView.getFitWidth() > 100) {
            imageView.setFitWidth(100);
            imageView.setPreserveRatio(true);
            imageView.setSmooth(true);
        } else {
            imageView.setFitHeight(image.getHeight());
            imageView.setFitWidth(image.getWidth());
            imageView.setPreserveRatio(true);
        }
    });
...

Listing 11-53Using JavaFX to Resize Images on Click Events

在前面的代码片段中,我们通过调用setOnMouseClicked(..)将一个EventHandler<? super MouseEvent>实例附加到 imageView 上的鼠标点击事件。EventHandler<T extends MouseEvent>是一个函数接口,包含一个名为 handle 的方法,它的具体实现是前面代码清单中 lambda 表达式的主体。

由于 JavaFX 已从 JDK 11 中删除,所以在本节中讨论更多的图像处理类没有什么实际价值。但是如果你有兴趣学习更多关于这个主题的知识,甲骨文的这个教程应该可以胜任这个工作: https://docs.oracle.com/javafx/2/image_ops/jfxpub-image_ops.htm 。此外,作为练习,您可以尝试根据书中的代码编写自己的代码,以添加旋转图像的鼠标事件。这是我们在 Java 中可以用来玩图像的所有空间。我希望这一节对您有用,并且您将来可能有机会测试您的 Java Media API 技能——如果不是为了别的,至少是为了从您的图像中清除 EXIF 数据。

摘要

本章涵盖了您需要了解的大多数细节,以便能够处理各种类型的文件,如何序列化 Java 对象并将它们保存到文件中,然后通过反序列化恢复它们。在编写 Java 应用时,你很可能需要将数据保存到文件中或者从文件中读取数据,这一章提供了大量的组件来完成这些工作。这是本章的简短总结:

  • 如何使用文件和路径实例

  • 如何在文件和路径中使用实用程序方法

  • 如何在二进制、XML 和 JSON 之间序列化/反序列化 Java 对象

  • 如何使用 Java Media API 调整图像大小和修改图像

  • 如何在 JavaFX 应用中使用图像

十二、发布-订阅框架

到目前为止,书中解释的所有编程概念都涉及到需要处理的数据。不管数据是以什么形式提供的,到目前为止我们编写的 Java 程序都是获取数据、修改数据并打印出结果,无论是控制台、文件还是其他软件组件。可以说,所有这些组件都在相互通信,并将处理过的数据从一个组件传递到另一个组件。比如图 12-1 ,它抽象地描述了程序中 Java 组件之间的交互。

img/463938_2_En_12_Fig1_HTML.png

图 12-1

程序中 Java 组件之间的交互

每一个箭头上都标有从一个箭头传到另一个箭头的信息类型。在该图中,您可以识别信息进入程序的起点(由Reader读取)和终点(由Printer将信息打印到某个输出组件)。你可以说,Reader提供数据,Filter和DocumentCreator是一些内部处理器,处理数据,Printer是数据的消费者。

到目前为止描述的是类似于点对点(p2p)消息模型的东西,它描述了一个消息被发送给一个消费者的概念。p2p 模型特定于一个名为 Java 消息服务(JMS)的 Java API,它支持网络中计算机之间的正式通信,即消息传递。在本章开始的例子中,我们做了一个类比来说明 Java 程序组件之间的通信是以类似的方式工作的。通过考虑链接到消息传递风格通信模型中的所有组件,可以创建实现上图所描述的流程的解决方案设计。

有不止一种通信模型:生产者/消费者、发布/订阅和发送者/接收者,每一种都有其自身的特性, 1 但是本章关注的是发布/订阅,因为这是反应式编程所基于的模型。

反应式编程和反应式宣言

反应式编程是一种声明式编程风格,涉及使用数据流和传播变化。反应式编程是用异步数据流编程。Reactive streams 是一项倡议,旨在为具有非阻塞背压的异步流处理提供标准。它们对于解决需要跨线程边界复杂协调的问题非常有用。操作符允许您将数据收集到所需的线程上,并确保线程安全操作,在大多数情况下,不需要过多使用synchronized和volatile构造。

在版本 8 中引入 Streams API 之后,Java 向反应式编程迈进了一步,但是反应式流直到版本 9 才可用。我们已经在几章前学习了如何使用 streams(章 8 ) ,所以我们离目标又近了一步。现在我们所要做的就是了解如何使用反应流来进行一些反应式编程。

使用反应流并不是一个新的想法。反应宣言于 2014 年首次公开, 2 它要求软件以这样的方式开发:系统是响应性的、有弹性的、有弹性的和消息驱动的——简而言之,它们应该是反应性的。

这里对这四个术语分别进行了简要说明:

  • 快速响应:应提供快速一致的响应时间。

  • 弹性:出现故障时应保持响应,并能够恢复。

  • 弹性:应保持反应灵敏,能够应对各种工作负载。

  • 消息驱动:应该使用异步消息通信,避免阻塞,必要时施加反压力。

以这种方式设计的系统应该更加灵活、松散耦合和可伸缩,但同时它们应该更容易开发、可修改和更能容忍失败。为了能够完成所有这些,系统需要一个通用的 API 来进行通信。Reactive streams 是一个为异步、非阻塞流处理提供这样一个标准 API 的倡议,它也支持背压。我们一会儿会解释背压的意思。让我们从反应式流处理的基础开始。

任何类型的流处理都涉及数据的生产者、数据的消费者以及它们之间处理数据的组件。显然,数据流的方向是从生产者到消费者。图 12-2 描述了到目前为止所描述的系统的抽象模式。

img/463938_2_En_12_Fig2_HTML.png

图 12-2

生产者/消费者系统

当生产者比消费者更快时,系统可能会陷入困境,因此必须处理无法处理的额外数据。做这件事的方法不止一种:

img/463938_2_En_12_Fig3_HTML.png

图 12-3

反应式生产者/消费者系统

  • 多余的数据被丢弃(这是在网络硬件中完成的)。

  • 生产者被阻止,因此消费者有时间赶上。

  • 数据被缓冲,但是缓冲区是有限的,如果我们有一个快速的生产者和一个慢速的消费者,就有缓冲区溢出的危险。

  • 应用反压力,这包括赋予消费者监管生产者和控制产生多少数据的权力。背压可以被看作是从消费者发送到生产者的信息,让生产者知道它必须降低其数据生产率。记住这一点,我们可以完成前一个图像中的设计,这将产生图 12-3 。

如果生产者、处理器和消费者不同步,通过阻塞直到每个都准备好处理来解决太多数据的问题是不可行的,因为这会将系统转换成同步的系统。丢弃它也不是一个选项,而且缓冲是不可预测的,所以我们留给反应式系统的只有应用非阻塞背压。

如果软件的例子对你来说太令人费解,想象一下下面的场景。你有一个叫吉姆的朋友。你还有一桶不同颜色的球。吉姆告诉你把所有的红球都给他。有两种方法可以做到这一点:

  1. 你把所有的红球都捡起来,放到另一个桶里,然后把桶递给吉姆。这是典型的请求-完整响应模型。这是一个异步模型,如果选择红球需要很长时间,Jim 会去做其他事情,而你会进行分类,当你完成时,你只需通知他一桶红球准备好了。它是异步的,因为 Jim 不会被你阻止去整理球,而是去做其他的事情,当球准备好的时候再去拿球。

  2. 你只要从你的桶里一个接一个地拿出红色的球,然后扔向吉姆。在这种情况下,这是你的数据流,或者球流。如果你找到它们并扔出去的速度比吉姆接住它们的速度快,你就有障碍。所以吉姆让你慢下来。这是他在调节球的流动,这相当于现实世界中的背压。

在版本 9 之前,用 Java 编写可以在反应式系统中聚合的应用是不可能的,所以开发人员不得不用外部库来凑合。反应式应用必须根据反应式编程的原理来设计,并使用反应式流来处理数据。反应式编程的标准 API 首先由反应流库描述,该库也可以用于 Java 8。在 Java 9 中,标准 API 被添加到了 JDK 中,下一版本的反应流包含了一组嵌套在org.reactivestreams.FlowAdapters类中的类,它们代表了两个 API(反应流 API 和反应流流 API)中类似组件之间的桥梁。

在图 12-4 中,你可以看到接口是由具有先前定义的角色的组件实现的。

img/463938_2_En_12_Fig4_HTML.png

图 12-4

反应流接口(如 IntelliJ IDEA 所示)

反应流 API 由四个非常简单的接口组成:

  • 接口Publisher<T>公开了一个名为void subscribe(Subscriber<? super T>)的方法,该方法被调用来添加一个Subscriber<T>实例,并产生类型为T的元素,这些元素将被Subscriber<T>使用。Publisher<T>实现的目的是根据从其订户接收到的需求来发布值。

  • 接口Subscriber<T>,消耗来自Publisher<T>的元素,并根据Publisher<T>实例接收的事件类型,公开定义实例具体行为所必须实现的四个方法。

    • void onSubscribe(Subscription)是在订户上调用的第一个方法,这是使用Subscription参数将Publisher<T>链接到Subscriber<T>实例的方法;如果此方法引发异常,则不能保证以下行为。

    • void onNext(T)是由Subscription的下一个项目调用的方法,用于接收数据;如果抛出异常,Subscription可能会被取消。

    • void onError(Throwable)是在Publisher<T>或Subscription<T>遇到不可恢复的错误时调用的方法。

    • void onComplete()是当没有更多的数据消耗时调用的方法,因此不会发生额外的Subscriber<T>方法调用。

  • 接口Processor<T,R> extends both Publisher<T> and Subscriber<R>,因为它需要消费数据并产生数据以将其发送到更上游。

  • 接口Subscription的实现链接了Publisher<T>和Subscriber<T>,可以通过调用request(long)来设置要生产并发送给消费者的商品数量,从而应用反压力。它还允许取消一个流,通过调用cancel()方法告诉Subscriber<T>停止接收消息。

在 JDK 中,前面列出的所有接口都在java.util.concurrent.Flow类中定义。这个类的名字在本质上是显而易见的,因为前面的接口用于创建流控制的组件,这些组件可以链接在一起创建一个反应式应用。除了这四个接口之外,还有一个 JDK 实现:实现Publisher<T>的java.util.concurrent.SubmissionPublisher<T>类,它是生成项目并使用该类中的方法发布项目的子类的方便基础。

Flow接口非常基本,可以在编写反应式应用时使用,但这需要大量的工作。目前,不同的团队有多种实现,为开发反应式应用提供了更实用的方法。使用这些接口的实现,您可以编写反应式应用,而无需编写处理数据的线程的同步逻辑。

下面的列表包含最著名的 reactive streams API 实现(还有更多的实现,因为在大数据世界中,反应式处理不再是奢侈品,而是必需品):

使用 JDK 反应流 API

由于 JDK 为反应式编程提供的接口非常基础,实现起来非常麻烦,但是在这一部分中还是做了尝试。在这一节中,我们将尝试构建一个生成无限数量的整数值的应用。过滤这些值并选择小于 127 的值。对于 98 和 122 之间的偶数,减去 32(基本上是将小写字母转换成大写字母)。然后将它们转换成字符并打印出来。清单 12-1 中描述了最基本的解决方案,没有反应流。

package com.apress.bgn.twelve.dummy;

// some input statements omitted
import java.security.SecureRandom;

public class BasicIntTransformer {
    private static final Logger log = LoggerFactory.getLogger(BasicIntTransformer.class);
    private static final SecureRandom random = new SecureRandom();

    public static void main(String... args) {

        while (true){
            int rndNo = random.nextInt(130);
            if (rndNo < 127) {
                log.info("Initial value: {} ", rndNo);
                if(rndNo % 2 == 0 && rndNo >=98 && rndNo <=122) {
                    rndNo -=32;
                }
                char res = (char) rndNo;
                log.info("Result: {}", res);
            } else {
                log.debug("Number {} discarded.", rndNo);
            }
        }
    }
}

Listing 12-1Generating an Infinite Number of Integers <127

前面代码清单中的每一行代码都有一个目的,一个期望的结果。这种方法被称为命令式编程,因为它顺序执行一系列语句来产生所需的输出。

然而,这不是我们的目标。在本节中,我们将使用 JDK 反应式接口的实现来实现反应式解决方案,因此我们需要以下内容:

  • 利用无限流生成随机整数值的发布器组件。该类应该实现Flow.Publisher<Integer>接口。

  • 一个只选择可以转换成可见字符的整数值的处理器,比方说代码在[0,127]之间的所有字符。该类应该实现Flow.Processor<Integer, Integer>。

  • 一种处理器,通过减去 32 来修改接收到的 98 和 122 之间的偶数元素。这个类也应该实现Flow.Processor<Integer, Integer>。

  • 一种将整数元素转换成等价字符的处理器。这是一个特殊的类型或处理器,它将一个值映射到另一个类型的另一个值,并且应该实现Flow.Processor<Integer, Character>。

  • 将打印从链中最后一个处理器收到的元素的订户。这个类将实现Flow.Subscriber<Character>接口。

让我们从声明Publisher<T>开始,它将环绕一个无限流以产生要消费的值。我们将通过提供一个完整的具体实现来异步提交元素,从而实现Flow.Publisher<Integer>接口。为了在需要时缓冲它们,需要添加大量代码。幸运的是SubmissionPublisher<T>类已经这样做了,所以在我们的类内部,我们将使用一个SubmissionPublisher<Integer>对象。清单 12-2 中描述了发布者的代码。

package com.apress.bgn.twelve.jdkstreams;

import java.util.Random;
import java.util.concurrent.Flow;
import java.util.concurrent.SubmissionPublisher;
import java.util.stream.IntStream;

public class IntPublisher implements Flow.Publisher<Integer> {
    private static final Random random = new Random();
    protected final IntStream intStream;

    public IntPublisher(int limit) {
        intStream = limit == 0 ? IntStream.generate(() -> random.nextInt(150)) :
                IntStream.generate(() -> random.nextInt(150)).limit(30);
    }

    private final SubmissionPublisher<Integer> submissionPublisher = new SubmissionPublisher<>();

    @Override
    public void subscribe(Flow.Subscriber<? super Integer> subscriber) {
        submissionPublisher.subscribe(subscriber);
    }

    public void start() {
        intStream.forEach(element -> {
            submissionPublisher.submit(element);
            sleep();
        });
    }

    private void sleep() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException("could not sleep!");
        }
    }
}

Listing 12-2Publisher Generating an Infinite Number of Integers

img/463938_2_En_12_Figb_HTML.gif注意IntPublisher类的构造函数是如何接受一个参数的。如果在实例化时作为参数提供的值是 0(零),则创建无限流。如果参数值不为 0,则创建一个有限流。如果您希望运行示例而不是强制停止执行,这将非常有用。

正如所料,我们已经为subscribe()方法提供了一个实现,在这种情况下,我们要做的只是将subscriber转发给内部的submissionPublisher。因为我们已经通过包装submissionPublisher创建了我们的 publisher,所以这是必要的,否则我们的流程将不会像预期的那样工作。此外,我们还添加了一个start()方法,该方法从无限IntStream中获取元素,并使用内部submissionPublisher提交它们。

IntStream利用一个Random实例在[0,150]区间生成整数值。选择这个时间间隔是为了让我们看到连接到发布者的第一个Flow.Processor<T,R>实例是如何丢弃大于 127 的值的。为了能够减缓元素提交的速度,我们添加了一个对Thread.sleep(1000)的调用,这基本上保证了每秒一个元素会被向上链转发。

第一个处理器的名称是FilterCharProcessor,它将利用一个内部的SubmissionPublisher<Integer>实例将它处理的元素向前发送到下一个处理器。

抛出的异常也将使用SubmissionPublisher<Integer>转发。处理器既充当发布者,也充当订阅者,因此对onNext(..)方法的实现必须包括对subscription.request(..)的调用,以施加反压力。从本章前面提供的图中,您可以看到处理器基本上是一个允许数据双向流动的组件,它通过实现Publisher<T>和Subscriber<T>来实现这一点。

处理器必须订阅发布者,并且当发布者subscribe(..)方法被调用时,将导致onSubscribe(Flow.Subscription subscription)方法被调用。订阅必须存储在本地,以便可以用来施加反压力。但是在接受订阅时,我们必须确保该字段尚未初始化,因为根据 reactive streams 规范,一个发布者只能有一个订阅者,否则结果是不可预测的。如果有新的订阅到达,必须取消,这是通过打电话cancel()完成的。清单 12-3 中描述了处理器的完整代码。

package com.apress.bgn.twelve.jdkstreams;

import java.util.concurrent.Flow;
import java.util.concurrent.SubmissionPublisher;
// some input statements omitted

public class FilterCharProcessor implements Flow.Processor<Integer, Integer> {
    private static final Logger log = LoggerFactory.getLogger(FilterCharProcessor.class);

    private final SubmissionPublisher<Integer> submissionPublisher = new SubmissionPublisher<>();
    private Flow.Subscription subscription;
    @Override
    public void subscribe(Flow.Subscriber<? super Integer> subscriber) {
        submissionPublisher.subscribe(subscriber);
    }
    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        if (this.subscription == null) {
            this.subscription = subscription;
            // apply back pressure - request one element
            this.subscription.request(1);
        } else {
            subscription.cancel();
        }
    }
    @Override
    public void onNext(Integer element) {

        if (element >=0 && element < 127){
            submit(element);
        } else {
            log.debug("Element {} discarded.", element);
        }
        subscription.request(1);
    }
    @Override
    public void onError(Throwable throwable) {
        submissionPublisher.closeExceptionally(throwable);
    }

    @Override
    public void onComplete() {
        submissionPublisher.close();
    }
    protected void submit(Integer element){
        submissionPublisher.submit(element);
    }
}

Listing 12-3Flow.Processor<T,R> Implementation FilterCharProcessor<Integer,Integer> That Filters Integers > 127

这个处理器非常专用,一个处理流程通常需要不止一个。在这个场景中,我们需要几个,因为除了onNext(..)方法之外,实现的其余部分主要是样板代码,允许处理器在我们设计的流程中链接在一起,将这些代码包装在一个AbstractProcessor中会更实用,这个解决方案需要的所有处理器都可以扩展这个AbstractProcessor。

由于流程中的最后一个处理器需要将接收到的Integer值转换为Character,因此该实现的返回类型将保持通用。清单 12-4 中描述了代码。

package com.apress.bgn.twelve.jdkstreams;

import java.util.concurrent.Flow;
import java.util.concurrent.SubmissionPublisher;

public abstract class AbstractProcessor<T> implements Flow.Processor<Integer, T> {
    protected final SubmissionPublisher<T> submissionPublisher = new SubmissionPublisher<>();
    protected Flow.Subscription subscription;

    @Override
    public void subscribe(Flow.Subscriber<? super T> subscriber) {
        submissionPublisher.subscribe(subscriber);
    }

    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        if (this.subscription == null) {
            this.subscription = subscription;
            // apply back pressure - ask one or more than one
            this.subscription.request(1);
        } else {
            // avoid more than one Publisher sending elements to this Subscriber
            // do not accept other subscriptions
            subscription.cancel();
        }
    }

    @Override
    public void onError(Throwable throwable) {
        submissionPublisher.closeExceptionally(throwable);
    }

    @Override
    public void onComplete() {
        submissionPublisher.close();
    }

    protected void submit(T element) {

        submissionPublisher.submit(element);
    }
}

Listing 12-4AbstractProcessor<Integer,T> Implementation

这也简化了FilterCharProcessor<Integer, Integer>和其他处理器的实现。清单 12-5 中描述了FilterCharProcessor<Integer, Integer>的简化实现。

package com.apress.bgn.twelve.jdkstreams;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class FilterCharProcessor extends AbstractProcessor<Integer> {
    private static final Logger log = LoggerFactory.getLogger(FilterCharProcessor.class);

    @Override
    public void onNext(Integer element) {
        if (element >= 0 && element < 127) {
            submit(element);
        } else {
            log.debug("Element {} discarded.", element);
        }
        subscription.request(1);
    }
}

Listing 12-5FilterCharProcessor

Extending AbstractProcessor<Integer>

我们有一个出版商和一个处理器,那么现在呢?当然,我们把它们联系起来。清单 12-6 中的点(..))取代了本节中尚未建立的所有相互连接的处理器和用户。

package com.apress.bgn.twelve.jdkstreams;

public class ReactiveDemo {
    public static void main(String... args) {
        IntPublisher publisher = new IntPublisher(0);
        FilterCharProcessor filterCharProcessor = new FilterCharProcessor();

        publisher.subscribe(filterCharProcessor);
        // ..
        publisher.start();
    }
}

Listing 12-6Executing a Reactive Flow

下一个处理器实现是通过减去 32 将较小的字母转换成较大的字母。通过扩展AbstractProcessor<Integer, T>也可以很容易地实现,清单 12-7 中描述了该实现。

package com.apress.bgn.twelve.jdkstreams;

public class TransformerProcessor extends AbstractProcessor<Integer>{
    @Override
    public void onNext(Integer element) {
        if(element % 2 == 0 && element >=98 && element <=122) {
            element -=32;
        }
        submit(element);
        subscription.request(1);
    }
}

Listing 12-7The TransformerProcessor

Implementation

要在流程中插入这个处理器,我们只需要实例化它,调用filterCharProcessor.subscribe(..)并提供这个实例作为参数。清单 12-8 展示了创建我们的反应流的下一步。

package com.apress.bgn.twelve.jdkstreams;

public class ReactiveDemo {
    public static void main(String... args) {
        IntPublisher publisher = new IntPublisher(0);
        FilterCharProcessor filterCharProcessor = new FilterCharProcessor();
        TransformerProcessor transformerProcessor = new TransformerProcessor();

        publisher.subscribe(filterCharProcessor);
        filterCharProcessor.subscribe(transformerProcessor);
        // ..
        publisher.start();
    }
}

Listing 12-8A TransformerProcessor

Instance Being Added to a Reactive Flow

下一个要实现的是我们这个解决方案需要的最终处理器,它将一个Integer值转换成一个String值。为了尽可能保持实现的声明性,将向处理器提供映射函数作为参数。代码如清单 12-9 所示。

package com.apress.bgn.twelve.jdkstreams;

import java.util.function.Function;

public class MappingProcessor extends AbstractProcessor<Character> {
    private final Function<Integer, Character> function;

    public MappingProcessor(Function<Integer, Character> function) {
        this.function = function;
    }
    @Override
    public void onNext(Integer element) {
        submit(function.apply(element));
        subscription.request(1);
    }
}

Listing 12-9The MappingProcessor

Implementation

在清单 12-10 中,您可以看到一个MappingProcessor实例被添加到反应流中。

package com.apress.bgn.twelve.jdkstreams;

public class ReactiveDemo {
    public static void main(String... args) {
       IntPublisher publisher = new IntPublisher();
        FilterCharProcessor filterCharProcessor = new FilterCharProcessor();
        TransformerProcessor transformerProcessor = new TransformerProcessor();
        MappingProcessor mappingProcessor =
                        new MappingProcessor(element -> (char) element.intValue());
        publisher.subscribe(filterCharProcessor);
        filterCharProcessor.subscribe(transformerProcessor);
        transformerProcessor.subscribe(mappingProcessor);
        //...
        publisher.start();
    }
}

Listing 12-10A MappingProcessor

Instance Being Added to a Reactive Flow

这个流程的最后一个组成部分是订户。订户是流中最重要的组件;在添加订阅者并创建一个Subscription实例之前,实际上什么都不会发生。我们的订户实现了Flow.Subscriber<Character>,,它的大部分与我们在AbstractProcessor<T>中隔离的代码相同,这看起来可能有点多余,但也使事情变得非常简单。清单 12-11 描述了Subscriber的实现。

package com.apress.bgn.twelve.jdkstreams;
// some import statements omitted
import java.util.concurrent.Flow;

public class CharPrinter implements Flow.Subscriber<Character> {
    private static final Logger log = LoggerFactory.getLogger(CharPrinter.class);
    private Flow.Subscription subscription;

    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        if (this.subscription == null) {
            this.subscription = subscription;
            this.subscription.request(1);
        } else {
            subscription.cancel();
        }
    }

    @Override
    public void onNext(Character element) {
        log.info("Result: {}", element);

        subscription.request(1);
    }

    @Override
    public void onError(Throwable throwable) {

        log.error("Something went wrong.", throwable);
    }

    @Override
    public void onComplete() {
        log.info("Printing complete.");
    }
}

Listing 12-11Subscriber<Character> Implementation

使用这个订户类,现在可以完成如清单 12-12 所示的流程。

package com.apress.bgn.twelve.jdkstreams;

public class ReactiveDemo {
    public static void main(String... args) {
        IntPublisher publisher = new IntPublisher(0);
        FilterCharProcessor filterCharProcessor = new FilterCharProcessor();
        TransformerProcessor transformerProcessor = new TransformerProcessor();
        MappingProcessor mappingProcessor = new MappingProcessor(element -> (char) element.intValue());
        CharPrinter charPrinter = new CharPrinter();

        publisher.subscribe(filterCharProcessor);
        filterCharProcessor.subscribe(transformerProcessor);
        transformerProcessor.subscribe(mappingProcessor);
        mappingProcessor.subscribe(charPrinter);
        publisher.start();
    }
}

Listing 12-12Reactive Pipeline Complete Implementation

如果subscribe(..)方法能够返回调用者实例,以便我们能够链接subscribe(..)调用,那就太好了,但是我们使用的是提供给我们的东西。当前面的代码运行时,类似于清单 12-13 中描述的日志被打印在控制台中:

...
INFO  c.a.b.t.j.CharPrinter - Result: .
INFO  c.a.b.t.j.CharPrinter - Result: ,
INFO  c.a.b.t.j.CharPrinter - Result: A
DEBUG c.a.b.t.j.FilterCharProcessor - Element 147 discarded.
DEBUG c.a.b.t.j.FilterCharProcessor - Element 127 discarded.
INFO  c.a.b.t.j.CharPrinter - Result: E
INFO  c.a.b.t.j.CharPrinter - Result: Z
...

Listing 12-13Console Output of a Reactive Flow Being Executed

前面的例子使用一个无限的IntStream来生成要发布、处理和消费的元素。这导致执行程序永远运行,所以你必须手动停止它。另一个结果是onComplete()方法永远不会被调用。如果我们想要使用它,我们必须确保被发布的条目数量是有限的,但是用一个不同于 0(零)的值初始化IntPublisher。

还有一点要提的是,背压处理更多的是在概念上做的。Flow API 没有提供任何机制来通知背压或处理背压。所以subscription.request(1)只是确保当onNext(..)被调用时,元素生产率减少到 1。基于用户的微调,可以设计各种策略来处理背压,但是很难在一个非常简单的例子中显示类似的东西,这个例子不涉及两个微服务相互反应性地交互。

在 JDK 中,对反应流的支持非常少,即使在 2021 年 9 月 14 日发布的版本 17 中也是如此。预计在未来的版本中会添加更多有用的类,但显然 Oracle 专注于其他方面,如重组模块结构和决定如何更好地利用 JDK 赚钱。这就是为什么本章的最后一节介绍了一个用 Project Reactor 库完成的反应式编程的简短示例。

反应流技术兼容性套件

当构建使用反应式流的应用时,很多事情可能会出错。为了确保事情按预期进行, Reactive Streams 技术兼容性工具包项目,也称为 TCK、 3 是编写测试非常有用的库。这个库包含一些类,可以用来根据反应流规范测试反应实现。TCK 旨在验证 JDK java.util.concurrent.Flow类中包含的接口,出于某种原因,创建该库的团队决定使用 TestNG 作为测试库。

img/463938_2_En_12_Figc_HTML.jpg在版本 1.0.3 中修改了 TCK,以验证包含在反应流 API 中的接口。

等等,什么?你可能会惊呼。

那么如何用它来验证 JDK java.util.concurrent.Flow 类中包含的接口呢?

耐心等待年轻的学徒,一切都会在适当的时候得到解释。

TCK 包含四个类,必须实现它们来提供它们的Flow.Publisher<T>、Flow.Subscriber<T>和Flow.Processor<T,R>实现,以便测试工具进行验证。这四个类别是:

  • org.reactivestreams.tck.PublisherVerification<T>用于测试Publisher<T>的实现

  • org.reactivestreams.tck.SubscriberWhiteboxVerification<T>用于白盒测试Subscriber<T>实现和Subscription实例

  • org.reactivestreams.tck.SubscriberBlackboxVerification<T>用于黑盒测试Subscriber<T>实现和Subscription实例

  • org.reactivestreams.tck.IdentityProcessorVerification<T>用于测试Processor<T,R>的实现

为了使每个测试的目的变得明显,库测试方法的名称遵循这种模式:TYPE_spec#_DESC其中TYPE是required, optional, stochastic,或untested,中的一个,表示被测试规则的重要性。spec</emphasis></emphasis>#中的散列符号代表规则号,第一个是 1 代表Publisher<T>实例,2 代表Subscriber<T>实例。DESC是对测试目的的简短说明。

让我们看看如何测试我们之前定义的IntPublisher。PublisherVerification<T>类需要实现两个测试方法:一个测试发出大量元素的工作中的Publisher<T>(createPublisher(..)方法)实例,另一个测试“失败的”Publisher<T>(createFailedPublisher(..))实例,该实例无法初始化它需要发出元素的连接。

由createPublisher(..)测试的实例是通过传递一个不同于 0(零)的参数创建的,因此IntPublisher实例发出一组有限的元素,测试执行也是有限的。

清单 12-14 中描述了PublisherVerification<Integer>的实现。

package com.apress.bgn.twelve.jdkstreams;

import org.reactivestreams.FlowAdapters;
import org.reactivestreams.Publisher;
import org.reactivestreams.tck.PublisherVerification;
import org.reactivestreams.tck.TestEnvironment;
import java.util.concurrent.Flow;
// other import statements omitted

public class IntPublisherTest extends PublisherVerification<Integer> {
    private static final Logger log = LoggerFactory.getLogger(IntPublisherTest.class);

    public IntPublisherTest() {
        super(new TestEnvironment(300));
    }

    @Override
    public Publisher<Integer> createPublisher(final long elements) {
        return FlowAdapters.toPublisher(new IntPublisher(30) {
            @Override
            public void subscribe(Flow.Subscriber<? super Integer> subscriber) {
                intStream.forEach(subscriber::onNext);
                subscriber.onComplete();
            }
        });
    }

    @Override
    public Publisher<Integer> createFailedPublisher() {
        return FlowAdapters.toPublisher(new IntPublisher(0) {
            @Override
            public void subscribe(Flow.Subscriber<? super Integer> subscriber) {
                subscriber.onError(new RuntimeException("There be dragons! (this is a failed publisher)"));
            }
        });
    }
}

Listing 12-14TestNG Test Class for Testing a IntPublisher Instance

关于前面的测试类,应该提到的另一件事是,由于实现是为使用反应流 API 而设计的,所以它不能用于测试基于 JDK 的IntPublisher。然而,前面提到过,在版本 1.0.3 中,反应流 API 增加了一组类,用作反应流和 JDK 反应流 API 之间的桥梁。因此,IntPublisher必须作为一个参数提供给FlowAdapters.toPublisher(..)方法,该方法将它转换成一个等价的org.reactivestreams.Publisher,以便IntPublisherTest可以测试。

由于特定于您正在构建的应用的设计决策,一个Publisher<T>实现可能无法通过所有的测试。在我们的例子中,IntPublisher的实现非常简单,当运行createPublisher(..)方法时,在所有执行的测试中,通过的并不多,大多数都被忽略了,如图 12-5 所示。

img/463938_2_En_12_Fig5_HTML.png

图 12-5

TestNG 反应式发布器

测试没有通过或被忽略的原因是我们的实现没有实现那些特定测试所针对的行为(例如,maySupportMultiSubscribe、maySignalLessThanRequestedAndTerminateSubscription、mustSignalOnMethodsSequentially)。

我们还可以通过扩展前面提到的测试类来测试我们在上一节中定义的处理器和订阅者,但是我们将把它作为一个练习留给您,因为在这一章中我们还想介绍一件更有趣的事情。

使用 Project Reactor

如前所述,JDK 对反应式编程的支持非常少。发布者、处理者和订阅者应该异步运行,所有这些行为都必须由开发人员来实现,这可能有点麻烦。目前,JDK 唯一适合的是在所有其他已经存在的实现之间提供一个公共接口。它们有很多,为更专业的反应组件和实用方法提供了更多有用的类,以便更容易地创建和连接它们。作为 Spring 爱好者,我个人最喜欢的一个是 Project Reactor ,也是 Spring 开发团队最喜欢的一个。

Project Reactor 是第一批用于反应式编程的库之一,它的类为构建反应式应用提供了一个非阻塞的稳定基础和高效的需求管理。它适用于 Java 8,但是为 JDK9+反应流类提供了适配器类。

Project reactor 适用于微服务应用,并提供了比 JDK 更多的类,旨在使编程反应式应用更实用。Project reactor 提供了两个主要的发布器实现:reactor.core.publisher.Mono<T>,它是一个反应式流发布器,仅限于发布零个或一个元素,以及reactor.core.publisher.Flux<T>,它是一个反应式流发布器,具有基本的流操作符。

使用 Project React 的优点是我们有更多的类和方法可以使用,有静态工厂可以用来创建发布者,操作可以更容易地链接起来。

项目反应堆小组不喜欢Processor,这个名字,所以中间组件被命名为操作员。

如果你查阅官方文档,你很可能会遇到图 12-6 中的模式。 4

img/463938_2_En_12_Fig6_HTML.jpg

图 12-6

项目反应堆通量发布器实施

这是一个关于Flux<T> publisher 如何工作的抽象模式。Flux<T>发出元素,可以抛出异常,并在没有更多元素要发布时完成,与之前解释的行为相同,Project Reactor 团队只是找到了一种更漂亮的方式来绘制它。

Mono实现的绘图非常相似(参见 http://projectreactor.io/docs/core/release/api/reactor/core/publisher/Mono.html )。

但是让我们把它放在一边,看几个代码示例。使用该类中的多个实用方法创建Flux<T>实例非常容易。在开始发布元素之前,让我们设计一个除了打印值之外什么也不做的普通订阅者,因为我们将需要它来确保我们的Flux<T>发布器工作。

要使用 Project Reactor API 编写订阅者,您有多种选择。您可以直接实现org.reactivestreams.Subscriber<T>,如清单 12-15 所示。

package com.apress.bgn.twelve.reactor;

import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
// other import statements omitted

public class GenericSubscriber<T> implements Subscriber<T> {
    private static final Logger log = LoggerFactory.getLogger(GenericSubscriber.class);
    private Subscription subscription;
    @Override
    public void onSubscribe(Subscription subscription) {
        if (this.subscription == null) {
            this.subscription = subscription;
            this.subscription.request(1);
        } else {
            subscription.cancel();
        }
    }
    @Override
    public void onNext(T element) {
        log.info("consumed {} ", element);
        subscription.request(1);
    }
    @Override
    public void onError(Throwable t) {
        log.error("Unexpected issue!", t);
    }
    @Override
    public void onComplete() {
        log.info("All done!");
    }
}

Listing 12-15org.reactivestreams.Subscriber<T> Implementation

为了避免用样板代码实现那么多方法,还可以选择实现reactor.core.CoreSubscriber<T>,它是订阅者的 reactor 基本接口,或者更好,通过扩展提供基本订阅者功能的reactor.core.publisher.BaseSubscriber<T>类。订户典型方法的行为可以通过覆盖具有相同名称但前缀为hook的方法来修改。在清单 12-16 中,您可以看到使用 project reactor 编写订阅者是多么容易。

package com.apress.bgn.twelve.reactor;

import reactor.core.publisher.BaseSubscriber;
// other import statements omitted

public class GenericSubscriber<T> extends BaseSubscriber<T> {
    private static final Logger log = LoggerFactory.getLogger(GenericSubscriber.class);

    @Override
    protected void hookOnNext(T value) {
        log.info("consumed {} ", value);
        super.hookOnNext(value);
    }

    @Override
    protected void hookOnComplete() {
        log.info("call done.");
        super.hookOnComplete();
    }
}

Listing 12-16reactor.core.publisher.BaseSubscriber<T> Extension

哒哒!现在我们有了一个 subscriber 类,所以让我们创建一个反应式发布器来服务来自无限整数流的整数,以使用这个类的一个实例。实现如清单 12-17 所示。

package com.apress.bgn.twelve.reactor;

import reactor.core.publisher.Flux;
import java.util.Random;
import java.util.stream.Stream;

public class ReactorDemo {

    private static final Random random = new Random();

    public static void main(String... args) {
        Flux<Integer> intFlux = Flux.fromStream(
                Stream.generate(() -> random.nextInt(150))
        );
        intFlux.subscribe(new GenericSubscriber<>());
    }
}

Listing 12-17Creating a Reactive Publisher Using Project Reactor’s Flux<T>

如果您运行前面的代码,您将看到所有生成的整数值都由订阅者打印出来。可以从多个来源创建一个Flux<T>,包括数组和其他发布者。对于特殊情况,为了避免返回空值,可以通过调用empty()方法创建一个空的Flux<T>。

String[] names = {"Joy", "John", "Anemona", "Takeshi"};
Flux.fromArray(names).subscribe(new GenericSubscriber<>());

Flux<Integer> intFlux = Flux.empty();
intFlux.subscribe(new GenericSubscriber<>());

我认为最棒的方法叫做just(..),,它同时适用于通量和单声道。它接受一个或多个值,并根据被调用的类型返回一个发布者、Flux<T>或Mono<T>。

Flux<String> dummyStr = Flux.just("one", "two", "three");
Flux<Integer> dummyInt = Flux.just(1,2,3);

Mono<Integer> one = Mono.just(1);
Mono<String> empty = Mono.empty();

您可能会发现另一个有用的方法是concat(..),它允许您连接两个Flux<T>实例。

String[] names = {"Joy", "John", "Anemona", "Takeshi"};
Flux<String> namesFlux = Flux.fromArray(names);

String[] names2 = {"Hanna", "Eugen", "Anthony", "David"};
Flux<String> names2Flux = Flux.fromArray(names2);
Flux<String> combined = Flux.concat(namesFlux, names2Flux);
combined.subscribe(new GenericSubscriber<>());

你可能会喜欢的另一件事是:还记得如何使用Thread.sleep(1000)调用来降低IntPublisher类的速度吗?使用Flux<T>,你不需要这样做,因为有两个实用方法结合起来会导致相同的行为。

Flux<Integer> infiniteFlux = Flux.fromStream(
        Stream.generate(() -> random.nextInt(150))
    );

Flux<Long> delay = Flux.interval(Duration.ofSeconds(1));
Flux<Integer> delayedInfiniteFlux = infiniteFlux.zipWith(delay, (s,l) -> s);
delayedInfiniteFlux.subscribe(new GenericSubscriber<>());

interval(..)方法创建一个发布器,它发出从 0 开始的长值,并在全局计时器上以指定的时间间隔递增;它接收 Duration 类型的参数,在前面的示例中使用了秒。zipWith(..)方法压缩作为参数接收的Flux<T>实例。zip操作是一个特定的流操作,它翻译为两个发布者发出一个元素并使用java.util.function.BiFunction<T, U, R>组合这些元素。在我们的例子中,该函数只是丢弃第二个元素,并返回调用流中被第二个流生成的秒数减慢的元素。

project reactor 提供的组件的优点是,它们返回的对象类型大多与被调用的对象类型相同,这意味着它们可以很容易地被链接起来。

可以用 reactor API 编写一段与之前实现的基于 JDK 的实现等价的反应式代码,如清单 12-18 所示。

Flux<Integer> infiniteFlux = Flux.fromStream(
        Stream.generate(() -> random.nextInt(150))
    );

Flux<Long> delay = Flux.interval(Duration.ofSeconds(1));
Flux<Integer> delayedInfiniteFlux = infiniteFlux.zipWith(delay, (s, l) -> s);

delayedInfiniteFlux
    .filter(element -> (element >= 0 && element < 127))
    .map(item -> {
        if (item % 2 == 0 && item >= 98 && item <= 122) {
            item -= 32;
        }
        return item;
    })
.map(element -> (char) element.intValue())
.subscribe(new GenericSubscriber<>());

Listing 12-18Writing a Reactive Pipeline Using Project Reactor

您所记得的 Stream API 中的大多数函数都是为了在 project Reactor 中被动使用而实现的,所以如果前面的代码看起来很熟悉,这就是原因。

关于 Project Reactor API,如果你需要一个反应库,你可以首先考虑这个。你可以在 http://projectreactor.io/docs/core/milestone/reference/ 找到官方文档,相当不错,例子也很多。如果 Oracle 决定提供自己的丰富 API 来使用反应式流编程反应式应用,他们可能会有点太晚了。

摘要

反应式编程不是一个简单的话题,但它似乎是编程的未来。这本书将需要进入真正先进的主题,以显示反应式解决方案的真正力量。作为一本完全面向 Java 初学者的书,这不是一个适合它的主题。然而,读完这本书后,如果你有兴趣了解更多关于构建反应式应用的知识,Apress 在 2021 年 1 月出版的Pro Spring MVC with web flux5这本书有几个很棒的章节是关于用 Spring 和 Project Reactor 构建反应式应用的。

你必须记住的是,对于非反应性的实现,反应性的实现是毫无用处的。设计和使用电抗组件和非电抗组件是没有用的,因为实际上你可能会引入故障点并减慢速度。例如,如果您使用的是 Oracle 数据库,那么定义一个使用反应式流返回元素的存储库类是没有意义的,因为 Oracle 数据库不支持反应式访问。您只是添加了一个增加额外实现的反应层,因为在这种情况下没有真正的好处。但是如果您选择的数据库是 MongoDB,您可以放心地使用反应式编程,因为 MongoDB 数据库支持反应式访问。此外,如果您正在构建一个具有 ReactJS 或 angular 接口的 web 应用,那么您可以设计控制器类,以反应性地提供要由接口显示的数据。

本章的内容可以概括如下:

  • 解释了反应式编程

  • 解释了反应流的行为

  • 涵盖了 JDK 反应流支持

  • 介绍了如何使用 Reactive Streams 技术兼容性套件测试您的反应式解决方案

  • 提供了用于构建反应应用的项目反应器组件的小介绍

十三、垃圾收集

当执行 Java 代码时,从内存中反复创建、使用和丢弃对象。丢弃未使用的 Java 对象的过程被称为内存管理,但通常被称为垃圾收集(GC) 。在章节 5 ,中提到了垃圾收集,因为需要它来解释原语和引用类型之间的区别,但是在这一章中,我们将深入 JVM 的内部来解决运行中的 Java 应用的另一个秘密。

当 Java 垃圾收集器正常工作时,内存在新对象创建之前就被清理了,不会填满,所以你可以说分配给程序的内存被回收了。低复杂度的程序,就像我们到目前为止所写的,不需要那么多内存就能运行,但是取决于它们的设计(还记得递归吗?)它们最终可能会使用比可用内存更多的内存。在 Java 中,垃圾收集器是自动运行的。在 C/C++等更低级的语言中,没有自动内存管理,开发人员负责编写代码来按需分配内存,并在不再需要时释放内存。尽管自动内存管理看起来很实用,但是如果管理不当,垃圾收集器会是一个问题。本章提供了足够的关于垃圾收集器的信息,以确保它被明智地使用,并且当问题出现时,至少您将有一个好的地方开始修复它们。

虽然将介绍一些调优垃圾收集器的方法,但是请记住,垃圾收集调优不是必需的;应该以这样的方式编写程序:它只创建执行其功能所需的对象,正确管理引用,应该在应用投入生产之前估计服务器运行应用的内存容量,并且应该在此之前知道并配置应用所需的最大内存量。如果分配给一个 Java 程序的内存不够用,通常是实现中有什么地方烂了。

垃圾收集基础知识

Java 自动垃圾收集是 Java 编程语言的主要特性之一。正如本书开头所提到的,JVM 是一种用于执行 Java 程序的虚拟机。Java 程序使用 JVM 在其上运行的系统资源,因此它必须有一种方法来安全地释放这些资源。这项工作由垃圾收集器完成。

为了理解垃圾收集器的位置,我们必须看一看 JVM 架构。

Oracle Hotspot JVM 架构

多年来,一些大公司已经开发了他们自己的 JVM 版本(例如 IBM ),现在 Java 正在进入模块时代和快速交付风格,越来越多的公司将会维护特定版本的 JDK/JVM(例如 Azul、Amazon Coretto、GraalVM ),因为对于具有遗留依赖性的大型应用来说,迁移到 9+是很困难的。

另一个重要的经济因素是,在两年宽限期后,所有 LTS 版本的 Java 支持将于 2019 年 1 月支付,因此公司最终将不得不为运行其基于 Java 的软件的 JDK 付费。学习编码或构建小项目的开发人员可以在个人电脑上使用甲骨文官方 JDK,但要在服务器上运行他们的软件,访问成熟的 JMC 等企业功能,并使该软件盈利需要付费订阅。

目前,Oracle 的 HotSpot 仍然是许多应用使用的最常见的 JVM。谈到垃圾收集,这个 JVM 提供了一套成熟的垃圾收集选项。其架构的抽象表示如图 13-1 所示。

img/463938_2_En_13_Fig1_HTML.jpg

图 13-1

Oracle HotSpot JVM 架构(抽象表示)

堆内存区域由垃圾收集器管理,并被分成多个区域。对象在这些区域之间移动,直到被丢弃。图 13-2 中描述的区域是针对旧式垃圾收集器和新式垃圾收集器的,这两种类型的垃圾收集器可能会遵循 JDK 当前使用的默认垃圾收集器的模型,G1GC,它是在 JDK 8 中引入的。

img/463938_2_En_13_Fig2_HTML.jpg

图 13-2

堆结构

G1GC 是为拥有大量资源的机器设计的下一代垃圾收集器,这就是它对堆进行分区的方法不同的原因。它的堆被划分成一组大小相等的堆区域,每个堆区域都是一个连续的虚拟内存范围。某些区域集被分配了与旧收集器相同的角色(eden、survivor、old ),但是它们没有固定的大小。这为内存使用提供了更大的灵活性。在下一节中,您可以阅读更多关于不同类型的垃圾收集器的内容,因为现在的重点仍然是堆内存及其被命名为代的区域。

当一个应用运行时,它创建的对象被存储在年轻一代区域中。当一个对象被创建时,它在这一代的一个被命名为伊甸园空间的细分中开始它的生命。当 eden 空间被填满时,这触发了一个次要垃圾收集(次要 GC 运行),它清除这个区域中未被引用的对象,并将被引用的对象移动到第一个幸存者空间(S0) 。下一次 eden 空间被填满时,另一个小的 GC 运行被触发,它再次删除未被引用的对象,被引用的对象被移动到下一个残存空间(S1) 。

S0 中的对象已经在那里运行了一次较小的 GC,因此它们的年龄会增加。他们也被转移到 S1,所以 S0 和伊甸园可以被清理。

在下一次次要的 GC 运行时,再次执行该操作,但是这次引用的对象被保存到空的 S0 中。来自 S1 的旧对象增加了它们的年龄,也移到了 S0,所以 S1 和伊甸园可以被清理。

在幸存者空间中的对象达到某个年龄(特定于每种垃圾收集器的值)后,在较小的 GC 运行期间,它们被移动到****旧代空间。

**前述步骤在图像 13-3 中描述,对象o1和o2被老化,直到它们被移动到旧生成区域。

img/463938_2_En_13_Fig3_HTML.jpg

图 13-3

小 GC 运行在年轻一代的空间

少量的 GC 收集将会发生,直到旧的层代空间被填满。这时会触发主垃圾收集(主垃圾收集运行),这将删除未引用的对象并压缩内存,四处移动对象,这样剩下的空内存就是一个大的压缩空间。

次要垃圾收集事件是一个停止世界的事件。这个进程基本上接管了应用的运行并暂停了它的执行,因此它可以释放内存。由于年轻一代的空间非常小(您将在下一节看到这一点),应用暂停通常可以忽略不计。如果在一次小规模的 GC 运行之后,没有内存可以从新生成区域回收,就会触发一次大规模的 GC 运行。

永久生成区域是为 JVM 元数据(如类和方法)保留的。这个区域也经常被清理,以删除应用中不再使用的类。当堆中没有更多的对象时,这个区域的清理被触发。

刚刚描述的垃圾收集过程是特定于分代垃圾收集器的,比如 G1GC。在 JDK 8 之前,垃圾收集是使用旧的垃圾收集器完成的,它使用一种叫做并发标记清除的算法。这种垃圾收集器与标记已用和未用内存区域的应用并行运行。然后,它会删除未引用的对象,并通过移动对象将内存压缩到一个连续的区域中。这一过程非常低效和耗时。随着越来越多的对象被创建,垃圾收集需要越来越多的时间来执行,但是由于大多数对象的寿命都很短,这实际上并不是问题。所以 CMS 垃圾收集器暂时还可以。

G1GC 有一个类似的方法,但是在标记阶段结束后,G1 将注意力集中在大部分是空的区域,以尽可能多地回收未使用的内存。这就是为什么这个垃圾收集器也被命名为垃圾优先。G1 还使用暂停预测模型,根据为应用设置的暂停时间来决定可以处理多少内存区域。来自已处理区域的对象被复制到堆的单个区域,从而同时实现了内存压缩。此外,G1GC 没有固定大小的 eden 和 survivor 空间,它在每次运行较小的 GC 后决定它们的大小。

有多少垃圾收集工?

垃圾收集器 Oracle HotSpot JVM 提供了以下类型的垃圾收集器:

  • ****串行收集器:所有垃圾收集事件在一个线程中串行进行。内存压缩发生在每次垃圾收集之后。

*** ****并行收集器:多线程用于少量垃圾收集。单个线程用于主要的垃圾收集和旧代压缩。

*** CMS(并发标记清除 ) :多线程用于少量垃圾收集,使用与并行 GC 相同的算法。主要的垃圾收集也是多线程的,但是 CMS 与应用进程同时运行,以最小化 stop world 事件。不进行内存压缩。这种类型的垃圾收集器适用于需要较短垃圾收集暂停时间的应用,并且在应用运行时能够与垃圾收集器共享处理器资源。这是默认的垃圾收集器,直到 Java 8 引入了默认的 G1。

* G1(垃圾优先 ) :在 Oracle JDK 7 中引入,update 4 旨在永久取代 CMS GC,适用于可以与 CMS 收集器并发运行、需要内存压缩、需要更可预测的 GC 暂停持续时间且不需要大得多的堆的应用。垃圾优先(G1)收集器是一个服务器风格的垃圾收集器,目标是具有大内存的多处理器机器,但考虑到大多数笔记本电脑现在至少有 8 个内核和 16GB RAM,它也非常适合它们。G1 具有并发(与应用线程一起运行,例如细化、标记、清理)和并行(多线程,例如停止运行)两个阶段。完全垃圾收集仍然是单线程的,但是如果调整得当,您的应用应该可以避免完全垃圾收集。

* Z 垃圾收集器:Z 垃圾收集器(ZGC)是 Java 11 中引入的一个可伸缩的低延迟垃圾收集器。ZGC 可以同时执行所有开销较大的工作,不会停止应用线程的执行超过 10 毫秒,因此非常适合要求低延迟和/或使用非常大的堆(数万亿字节)的应用

* Shenandoah 垃圾收集器 : Shenandoah 是 Java 12 中引入的低暂停时间垃圾收集器,它通过与正在运行的 Java 程序并发执行更多垃圾收集工作来减少 GC 暂停时间。Shenandoah 并发执行大部分 GC 工作,包括并发压缩,这意味着它的暂停时间不再与堆的大小成正比。

* Epsilon 无操作收集器:在 Java 11 中引入,这种类型的收集器实际上是一个虚拟 GC,它不回收或清理内存。当堆满时,JVM 就会关闭。这种类型的收集器可用于性能测试、内存分配分析、VM 接口测试,以及寿命极短的作业和应用,这些作业和应用在内存使用方面非常有限,开发人员必须尽可能准确地估计应用内存占用。

img/463938_2_En_13_Figa_HTML.jpg并发标记清除垃圾收集器已从 JDK 中移除,并且不再识别-XX:+UseConcMarkSweepGC VM 选项。****

****我们已经列出了垃圾收集器的类型,但是我们如何知道本地 JVM 使用的是哪一种呢?方法不止一个。最简单的方法是在用main(..)方法运行一个简单的类时,添加-verbose:gc作为 VM 选项。

使用没有任何其他配置的 Java 17 JDK,会显示以下输出:

[0.011s][info][gc] Using G1

很明显,默认情况下,使用 G1 垃圾收集器。为了显示这个垃圾收集器的更多细节,在运行 Java 类时,可以将-Xlog:gc* 1 添加到 VM 参数中。对于只包含一个System.out.println语句的简单类com.apress.bgn.thirteen.ShowGCDemo,当使用前面提到的两个 VM 选项执行该类时,清单 13-1 中显示的输出会打印在控制台中。

[0.010s][info][gc] Using G1
[0.012s][info][gc,init] Version: 17+35-2724 (release)
[0.012s][info][gc,init] CPUs: 8 total, 8 available
[0.012s][info][gc,init] Memory: 16384M
[0.012s][info][gc,init] Large Page Support: Disabled
[0.012s][info][gc,init] NUMA Support: Disabled
[0.012s][info][gc,init] Compressed Oops: Enabled (Zero based)
[0.012s][info][gc,init] Heap Region Size: 2M
[0.012s][info][gc,init] Heap Min Capacity: 8M
[0.012s][info][gc,init] Heap Initial Capacity: 256M
[0.012s][info][gc,init] Heap Max Capacity: 4G
[0.012s][info][gc,init] Pre-touch: Disabled
[0.012s][info][gc,init] Parallel Workers: 8
[0.012s][info][gc,init] Concurrent Workers: 2
[0.012s][info][gc,init] Concurrent Refinement Workers: 8
[0.012s][info][gc,init] Periodic GC: Disabled
[0.012s][info][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800bd0000-0x0000000800bd0000), size 12386304, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[0.012s][info][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.012s][info][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
Hey ma' look the GC!
[0.123s][info][gc,heap,exit] Heap
[0.123s][info][gc,heap,exit]  garbage-first heap   total 266240K, used 6098K [0x0000000700000000, 0x0000000800000000)
[0.123s][info][gc,heap,exit]   region size 2048K, 3 young (6144K), 0 survivors (0K)
[0.123s][info][gc,heap,exit]  Metaspace       used 397K, committed 576K, reserved 1056768K
[0.123s][info][gc,heap,exit]   class space    used 20K, committed 128K, reserved 1048576K

Listing 13-1Showing G1GC details Using -verbose:gc -Xlog:gc* as VM Arguments When Running ShowGCDemo

我们可以看到堆的最大大小(4G)、内存区域大小(2M)以及每一代的大小和占用情况。

在章节 5 中,引入了java -XX:+PrintFlagsFinal -version命令来显示所有的 JVM 标志。过滤由“GC”和“NewSize”返回的结果显示所有 GC 特定的标志及其值。有不少,如清单 13-2 所示。

$ java -XX:+PrintFlagsFinal -version | grep 'GC\|NewSize'
    uintx AdaptiveSizeMajorGCDecayTimeScale   = 10           {product} {default}
     uint ConcGCThreads                       = 2            {product} {ergonomic}
     bool DisableExplicitGC                   = false        {product} {default}
     bool ExplicitGCInvokesConcurrent         = false        {product} {default}
    uintx G1MixedGCCountTarget                = 8            {product} {default}
    uintx G1PeriodicGCInterval                = 0            {manageable} {default}
     bool G1PeriodicGCInvokesConcurrent       = true         {product} {default}
   double G1PeriodicGCSystemLoadThreshold     = 0.000000     {manageable} {default}
    uintx GCDrainStackTargetSize              = 64           {product} {ergonomic}
    uintx GCHeapFreeLimit                     = 2            {product} {default}
    uintx GCLockerEdenExpansionPercent        = 5            {product} {default}
    uintx GCPauseIntervalMillis               = 201          {product} {default}
    uintx GCTimeLimit                         = 98           {product} {default}
    uintx GCTimeRatio                         = 12           {product} {default}
     bool HeapDumpAfterFullGC                 = false        {manageable} {default}
     bool HeapDumpBeforeFullGC                = false        {manageable} {default}
   size_t HeapSizePerGCThread                 = 43620760     {product} {default}
    uintx MaxGCMinorPauseMillis               = 18446744..   {product} {default}
    uintx MaxGCPauseMillis                    = 200          {product} {default}
   size_t MaxNewSize                          = 2575302656   {product} {ergonomic}
   size_t NewSize                             = 1363144      {product} {default}
   size_t NewSizeThreadIncrease               = 5320         {pd product} {default}
      int ParGCArrayScanChunk                 = 50           {product} {default}
    uintx ParallelGCBufferWastePct            = 10           {product} {default}
     uint ParallelGCThreads                   = 8            {product} {default}
     bool PrintGC                             = false        {product} {default}
     bool PrintGCDetails                      = false        {product} {default}
     bool ScavengeBeforeFullGC                = false        {product} {default}
     bool UseAdaptiveSizeDecayMajorGCCost     = true         {product} {default}
     bool UseAdaptiveSizePolicyWithSystemGC   = false        {product} {default}
     bool UseDynamicNumberOfGCThreads         = true         {product} {default}
     bool UseG1GC                          = true        {product}                                                          {ergonomic}
     bool UseGCOverheadLimit                  = true         {product} {default}
     bool UseMaximumCompactionOnSystemGC      = true         {product} {default}
     bool UseParallelGC                       = false        {product} {default}
     bool UseSerialGC                         = false        {product} {default}
     bool UseShenandoahGC                     = false        {product} {default}
     bool UseZGC                              = false        {product} {default}

Listing 13-2Showing G1GC Flags Using java -XX:+PrintFlagsFinal -version | grep 'GC\|NewSize'

默认情况下,UseG1GC设置为 true,这意味着当 JVM 用于执行 Java 应用时,将使用 G1 垃圾收集器。新尺寸过滤器挑选具有与年轻代尺寸相关的值的标志。当运行一个应用来定制 GC 行为或在日志中显示额外的细节时,所有这些标志都可以用作由-XX:+处理的 VM 选项。例如,我们可以通过使用特定的 VM 选项来指示 JVM 使用前面列出的任何垃圾收集器:

  • -XX:+UseSerialGC要使用串行 GC,在这种情况下添加-verbose:gc -Xlog:gc*作为 VM 选项也会产生清单 13-3 中的输出(注意缺少并行、并发工作器和不同的堆结构)。

  • -XX:+UseParallelGC要使用并行 GC,在这种情况下添加-verbose:gc -Xlog:gc*作为 VM 选项也会产生清单 13-4 中的输出(注意并行工作器和不同的堆结构)。

[0.013s][info][gc] Using Serial
[0.013s][info][gc,init] Version: 17+35-2724 (release)
[0.013s][info][gc,init] CPUs: 8 total, 8 available
[0.013s][info][gc,init] Memory: 16384M
[0.013s][info][gc,init] Large Page Support: Disabled
[0.013s][info][gc,init] NUMA Support: Disabled
[0.013s][info][gc,init] Compressed Oops: Enabled (Zero based)
[0.013s][info][gc,init] Heap Min Capacity: 8M
[0.013s][info][gc,init] Heap Initial Capacity: 256M
[0.013s][info][gc,init] Heap Max Capacity: 4G
[0.013s][info][gc,init] Pre-touch: Disabled
[0.014s][info][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800bd0000-0x0000000800bd0000), size 12386304, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[0.014s][info][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.014s][info][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
Hey ma' look the GC!
[0.180s][info][gc,heap,exit] Heap
[0.180s][info][gc,heap,exit]  def new generation   total 78656K, used 9946K [0x0000000700000000, 0x0000000705550000, 0x0000000755550000)
[0.180s][info][gc,heap,exit]   eden space 69952K,  14% used [0x0000000700000000, 0x00000007009b6a70, 0x0000000704450000)
[0.180s][info][gc,heap,exit]   from space 8704K,   0% used [0x0000000704450000, 0x0000000704450000, 0x0000000704cd0000)
[0.180s][info][gc,heap,exit]   to   space 8704K,   0% used [0x0000000704cd0000, 0x0000000704cd0000, 0x0000000705550000)
[0.180s][info][gc,heap,exit]  tenured generation   total 174784K, used 0K [0x0000000755550000, 0x0000000760000000, 0x0000000800000000)
[0.180s][info][gc,heap,exit]    the space 174784K,   0% used [0x0000000755550000, 0x0000000755550000, 0x0000000755550200, 0x0000000760000000)
[0.180s][info][gc,heap,exit]  Metaspace       used 774K, committed 960K, reserved 1056768K
[0.180s][info][gc,heap,exit]   class space    used 67K, committed 192K, reserved 1048576K

Listing 13-3Showing Serial GC Details

  • 默认的垃圾收集器已经涵盖了这一点。

  • -XX:+UseShenandoahGC使用 Shenandoah GC。虽然这个标志存在,但是 Oracle 选择不构建 Shenandoah,但是它可以在 Shenandoah 官方文档中列出的各种 OpenJDK 构建中使用: https://wiki.openjdk.java.net/display/shenandoah/Main#Main-JDKSupport 。

  • -XX:+UseZGC为了使用 ZGC,在这种情况下,添加-verbose:gc -Xlog:gc*作为 VM 选项也会产生清单 13-5 中的输出(注意 GC 和运行时工作器以及不同的堆结构)。

[0.016s][info][gc] Using Parallel
[0.018s][info][gc,init] Version: 17+35-2724 (release)
[0.018s][info][gc,init] CPUs: 8 total, 8 available
[0.018s][info][gc,init] Memory: 16384M
[0.018s][info][gc,init] Large Page Support: Disabled
[0.018s][info][gc,init] NUMA Support: Disabled
[0.018s][info][gc,init] Compressed Oops: Enabled (Zero based)
[0.018s][info][gc,init] Alignments: Space 512K, Generation 512K, Heap 2M
[0.018s][info][gc,init] Heap Min Capacity: 8M
[0.018s][info][gc,init] Heap Initial Capacity: 256M
[0.018s][info][gc,init] Heap Max Capacity: 4G
[0.018s][info][gc,init] Pre-touch: Disabled
[0.018s][info][gc,init] Parallel Workers: 8
[0.018s][info][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800bd0000-0x0000000800bd0000), size 12386304, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[0.018s][info][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.018s][info][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
Hey ma' look the GC!
[0.187s][info][gc,heap,exit] Heap
[0.187s][info][gc,heap,exit]  PSYoungGen      total 76288K, used 9337K [0x00000007aab00000, 0x00000007b0000000, 0x0000000800000000)
[0.187s][info][gc,heap,exit]   eden space 65536K, 14% used [0x00000007aab00000,0x00000007ab41e680,0x00000007aeb00000)
[0.187s][info][gc,heap,exit]   from space 10752K, 0% used [0x00000007af580000,0x00000007af580000,0x00000007b0000000)
[0.187s][info][gc,heap,exit]   to   space 10752K, 0% used [0x00000007aeb00000,0x00000007aeb00000,0x00000007af580000)
[0.187s][info][gc,heap,exit]  ParOldGen       total 175104K, used 0K [0x0000000700000000, 0x000000070ab00000, 0x00000007aab00000)
[0.187s][info][gc,heap,exit]   object space 175104K, 0% used [0x0000000700000000,0x0000000700000000,0x000000070ab00000)
[0.187s][info][gc,heap,exit]  Metaspace       used 746K, committed 896K, reserved 1056768K
[0.187s][info][gc,heap,exit]   class space    used 65K, committed 128K, reserved 1048576K

Listing 13-4Showing Parallel GC Details

  • -XX:+UseEpsilonGC,无操作垃圾收集器。如果在控制台中,您会看到一条消息,要求您也在启用 Epsilon 垃圾收集器的选项前添加-XX:+UnlockExperimentalVMOptions,请这样做。这个 VM 选项是解锁实验性特性所必需的,在本书写作的时候,这个垃圾收集器还是一个实验性的特性。添加-verbose:gc -Xlog:gc*作为 VM 选项也会产生清单 13-6 中的输出(注意缺少任何 workers 和 TLAB 选项)。
[0.031s][info][gc,init] Initializing The Z Garbage Collector
[0.031s][info][gc,init] Version: 17+35-2724 (release)
[0.031s][info][gc,init] NUMA Support: Disabled
[0.031s][info][gc,init] CPUs: 8 total, 8 available
[0.031s][info][gc,init] Memory: 16384M
[0.031s][info][gc,init] Large Page Support: Disabled
[0.031s][info][gc,init] GC Workers: 2 (dynamic)
[0.031s][info][gc,init] Address Space Type: Contiguous/Unrestricted/Complete
[0.031s][info][gc,init] Address Space Size: 65536M x 3 = 196608M
[0.032s][info][gc,init] Min Capacity: 8M
[0.032s][info][gc,init] Initial Capacity: 256M
[0.032s][info][gc,init] Max Capacity: 4096M
[0.032s][info][gc,init] Medium Page Size: 32M
[0.032s][info][gc,init] Pre-touch: Disabled
[0.032s][info][gc,init] Uncommit: Enabled
[0.032s][info][gc,init] Uncommit Delay: 300s
[0.032s][info][gc,init] Runtime Workers: 5
[0.032s][info][gc     ] Using The Z Garbage Collector
[0.033s][info][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800ba4000-0x0000000800ba4000), size 12206080, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[0.033s][info][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.033s][info][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
Hey ma' look the GC!
[0.283s][info][gc,heap,exit] Heap
[0.283s][info][gc,heap,exit]  ZHeap           used 10M, capacity 256M, max capacity 4096M
[0.283s][info][gc,heap,exit]  Metaspace       used 754K, committed 896K, reserved 1056768K
[0.283s][info][gc,heap,exit]   class space    used 66K, committed 128K, reserved 1048576K

Listing 13-5Showing ZGC Details

[0.012s][info][gc] Using Epsilon
[0.012s][info][gc,init] Version: 17+35-2724 (release)
[0.012s][info][gc,init] CPUs: 8 total, 8 available
[0.012s][info][gc,init] Memory: 16384M
[0.012s][info][gc,init] Large Page Support: Disabled
[0.012s][info][gc,init] NUMA Support: Disabled
[0.012s][info][gc,init] Compressed Oops: Enabled (Zero based)
[0.012s][info][gc,init] Heap Min Capacity: 6656K
[0.012s][info][gc,init] Heap Initial Capacity: 256M
[0.012s][info][gc,init] Heap Max Capacity: 4G
[0.012s][info][gc,init] Pre-touch: Disabled
[0.012s][warning][gc,init] Consider setting -Xms equal to -Xmx to avoid resizing hiccups
[0.012s][warning][gc,init] Consider enabling -XX:+AlwaysPreTouch to avoid memory commit hiccups
[0.012s][info   ][gc,init] TLAB Size Max: 4M
[0.012s][info   ][gc,init] TLAB Size Elasticity: 1.10x
[0.012s][info   ][gc,init] TLAB Size Decay Time: 1000ms
[0.013s][info   ][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800bd0000-0x0000000800bd0000), size 12386304, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
[0.013s][info   ][gc,metaspace] Compressed class space mapped at: 0x0000000800c00000-0x0000000840c00000, reserved size: 1073741824
[0.013s][info   ][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
Hey ma' look the GC!
[0.179s][info   ][gc,heap,exit] Heap
[0.179s][info   ][gc,heap,exit] Epsilon Heap
[0.179s][info   ][gc,heap,exit] Allocation space:
[0.179s][info   ][gc,heap,exit]  space 262144K,   1% used [0x0000000700000000, 0x00000007003364a0, 0x0000000710000000)
[0.180s][info   ][gc,heap,exit]  Metaspace       used 751K, committed 896K, reserved 1056768K
[0.180s][info   ][gc,heap,exit]   class space    used 65K, committed 128K, reserved 1048576K
[0.180s][info   ][gc          ] Heap: 4096M reserved, 256M (6.25%) committed, 3289K (0.08%) used
[0.180s][info   ][gc,metaspace] Metaspace: 1032M reserved, 896K (0.08%) committed, 752K (0.07%) used

Listing 13-6Showing Epsilon GC Details

正如您所看到的,为这些垃圾收集器打印的数据有共同的元素,比如堆的大小,在应用开始时总是 256M,在我的系统上最大大小为 4GB。伊甸园和年轻一代之间也有所不同,G1 只为年轻一代使用 4096K,而 CMS 需要 78656K。(更多)

这里最有趣的是 Epislon 垃圾收集器,因为正如预期的那样,它没有将堆分成生成区域,因为这种类型的垃圾收集器根本不执行垃圾收集。 TLAB 是线程本地分配缓冲区的缩写,是存储对象的内存区域。只有较大的对象存储在 TLABs 之外。TLABs 在每个线程单独执行期间动态调整大小。因此,如果一个线程分配了大量内存,那么它从堆中获得的新 TLABs 的大小将会增加。可以使用 VM -XX:MinTLABSize选项来控制 TLAB 的最小大小。

对于我们使用前面的 VM 选项运行的小型空类,这个输出实际上并不相关,但是您可以在运行下一节的代码时使用这些选项,因为此时这里打印的统计数据具有一定的相关性。

此外,还有一个名为-XX:+PrintCommandLineFlags的 VM 选项,当运行一个类来描述垃圾收集器的配置时,可以使用这个选项,比如它使用的线程数量、堆大小等等。这些选项如清单 13-7 所示。

-XX:ConcGCThreads=2
-XX:G1ConcRefinementThreads=8
-XX:GCDrainStackTargetSize=64
-XX:InitialHeapSize=268435456
-XX:MarkStackSize=4194304
-XX:MaxHeapSize=4294967296
-XX:MinHeapSize=6815736
-XX:+PrintCommandLineFlags
-XX:ReservedCodeCacheSize=251658240
-XX:+SegmentedCodeCache
-XX:+UseCompressedClassPointers
-XX:+UseCompressedOops
-XX:+UseG1GC

Listing 13-7G1GC VM Options

这些 VM 选项中的大多数都有明显的名字,允许开发者自己推断它们的用途;对于那些不知道的人,有一个来自 Oracle 的官方文档。如果你曾经需要剖析 Oracle 内存管理,这篇文章非常适合这个: https://www.oracle.com/java/technologies/javase/javase-core-technologies-apis.html 。

从代码中使用 GC

对于大多数应用来说,垃圾收集并不是开发人员必须真正考虑的事情。JVM 不时地启动一个 GC 线程,它通常在不妨碍应用执行的情况下完成工作。对于不仅仅想掌握 Java 基本技能的开发人员来说,理解 Java 垃圾收集的工作原理以及如何对其进行调优是必须的。关于 Java 垃圾收集,开发人员必须接受的第一件事是它不能在运行时被控制。正如您将在下一节中看到的,有一种方法可以建议 JVM 进行一些内存清理,但是不能保证内存清理真的会被执行。当一个对象被丢弃时,唯一能做的事情就是指定一些要运行的代码。

使用finalize()方法

在本书的开始,提到了每个 Java 类都自动是 JDK java.lang.Object类的子类。这个类是 JDK 层次结构的根,也是应用中所有类的根。它提供了许多有用的方法,可以扩展或覆盖这些方法来实现特定于子类的行为。前面已经提到了equals()、hashcode()和toString()。在 Java 9 中不赞成使用finalize()方法,但是为了向后兼容,它还没有从 JDK 中删除。终结机制有些问题。终结可能会导致性能问题、死锁和挂起。终结器中的错误会导致资源泄漏,如果不再需要,也没有办法取消终结。

由于一些开发人员可能最终会使用早期版本的 JDK 来处理 Java 项目,所以知道这个方法的存在是有好处的,以防您可能需要它,或者只是知道在哪里可以找到奇怪的 bug。

当代码中不再有对该对象的任何引用时,垃圾回收器将调用此方法。在我们继续之前,看一下清单 13-8 中的代码。

package com.apress.bgn.thirteen;

import com.apress.bgn.thirteen.util.NameGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.LocalDate;
import java.util.Random;

public class InfiniteSingerGenerator {
    private static final Logger log = LoggerFactory.getLogger(InfiniteSingerGenerator.class);
    private static NameGenerator nameGenerator = new NameGenerator();
    private static final Random random = new Random();

    public static void main(String... args) {
        while (true) {
            genSinger();
        }
    }

    private static void genSinger() {
        Singer s = new Singer(nameGenerator.genName(), random.nextDouble(), LocalDate.now());
        log.info("JVM created: {}", s.getName());
    }
}

Listing 13-8Class Generating an Infinite Number of Singer Instances

即使不知道NameGenerator或Singer类是什么样子,前面代码执行的动作也应该是显而易见的。main 方法在无限循环中调用genSinger()方法。这意味着创建了无限的Singer实例。那么会发生什么呢?代码会运行吗?多久?如果你能在心里回答这些问题,我在这里的工作就完成了;你现在可以不看这本书了。☺

在章 5 中有一些数字代表一个小程序的内存内容。图 13-4 展示了在前一个程序执行期间 Java 堆和栈内存的样子。

img/463938_2_En_13_Fig4_HTML.jpg

图 13-4

在执行InfiniteSingerGenerator类期间的 Java 栈和堆内存

由于显而易见的原因,只显示了一个genSinger()调用和一个Singer实例。正如你所看到的,当调用main(..)方法时,静态实例的引用被创建,这将与程序相关,直到它的执行结束。然后,genSinger()方法被调用。这些方法中的每一个都有自己的栈,其中保存了对在该方法上下文中创建的对象的引用,在本例中是Singer实例。该引用仅用于打印在该方法主体中创建的 Singer 实例的名称。则该方法存在,不返回引用。这意味着创建的实例不再是必需的,因为它被创建来仅在该方法的上下文中使用。当 genSinger()方法的执行结束时,对Singer的引用将从栈中丢弃。Singer实例仍然存在于堆内存中,但是不能再从程序中访问,因此它不再是必需的。现在,它只是保持一个内存块被自己的内容和对其他实例的引用占用,在这种情况下,是一个String、一个Double和一个LocalDate。

考虑到genString()被调用了无数次(在图中我们用(*n)来表示),更多的Singer实例将被创建,它们将保持内存被占用,程序将无法在某个时候创建其他实例,因为没有更多的可用内存了。

这就是垃圾收集器发挥作用的地方。不再被程序引用的Singer实例被认为是垃圾,(现在你知道这个名字是从哪里来的了):它们不再是必需的,可以安全地清理内存。垃圾收集器是一个清理线程,它与主执行线程并行运行,并不时地开始删除堆内存中未被引用的对象。因为finalize()方法仍然可用,我们将为Singer类型覆盖它以打印日志消息,所以当垃圾收集器销毁实例时,我们可以直接在控制台中看到,因为在此之前会调用finalize()方法。清单 13-9 中的代码片段描述了我们的Singer实例。

package com.apress.bgn.thirteen;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.LocalDate;
import java.util.Objects;

public class Singer {
    private static final Logger log = LoggerFactory.getLogger(Singer.class);
    private static final long serialVersionUID = 42L;
    private final long birthtime;

    private String name;

    private Double rating;

    private LocalDate birthDate;

    public Singer(String name, Double rating, LocalDate birthDate) {
        this.name = name;
        this.rating = rating;
        this.birthDate = birthDate;
        this.birthtime = System.nanoTime();
    }

    // some code omittted

    @Override
    protected void finalize() throws Throwable {
        try {
            long deathtime = System.nanoTime();
            long lifespan = (deathtime - birthtime) / 1_000_000_000;
            log.info("GC Destroyed: {} after {} seconds", name, lifespan);

        } finally {
            super.finalize();
        }
    }
}

Listing 13-9The Singer Class with the Overriden finalize() Method

添加字段birthtime只是为了能够计算调用实例的构造函数和垃圾收集器调用finalize()方法之间经过的时间。由于时间是以纳秒为单位计算的,所以我们用 10 9 除以这个差值来得到以秒为单位的时间。

本节中使用的代码示例让垃圾收集器做了很多工作,因为每个被创建的Singer实例在被丢弃之前都很少被使用。如果您运行该代码,您将在控制台中看到许多日志消息:首先是许多关于正在创建的对象的消息,如果您稍待片刻,也会出现关于对象正在被丢弃的消息。所有输出都定向到一个文件,因为 IntelliJ IDEA 控制台基于一个缓冲区,该缓冲区会不时重置以防止编辑器崩溃。您必须手动停止程序,因为 while(true)永远不会结束,因为它的条件永远不会计算为false。当你停止程序后,你会在下面的位置看到一个日志文件:/chapter13/out/gc.log。如果没有,请修改该类的 IntelliJ IDEA 启动器,并添加以下 VM 选项:

-Dlogback.configurationFile=chapter13/src/main/resources/logback.xml并再次运行。

gc.log的内容看起来应该很像清单 13-10 中描述的片段:

INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Acnefqlspvwekzq
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: izyfkluhimlpkt
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Tcyrpvgyfbpobym
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Akmvyeazowdavpy
INFO  c.a.b.t.Singer - GC Destroyed: Kjidllzezjjdjge after 1 seconds
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Llsghambpgetl c
INFO  c.a.b.t.Singer - GC Destroyed: Bffmcezvrzflhlh after 1 seconds
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Pg vjmfwzhujzv
INFO  c.a.b.t.Singer - GC Destroyed: wrlaqutybuzvsj after 1 seconds
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Kdzlsyiteskleka
INFO  c.a.b.t.Singer - GC Destroyed: Lqzdgeqqguitbgg after 1 seconds
INFO  c.a.b.t.Singer - GC Destroyed: Ddpzqlbiryelzvr after 1 seconds
INFO  c.a.b.t.Singer - GC Destroyed: Ozkzfubi  vpmj  after 1 seconds
INFO  c.a.b.t.InfiniteSingerGenerator - JVM created: Uegz isigjcrlfj
...

Listing 13-10The gc.log File Showing the finalize() Method

in Class Singer Being Called

当你有了这个文件,你可以打开它并开始分析它的内容,但是因为 IntelliJ 可能不会打开这么大的文件,试着用一个专门的文本编辑器打开它,比如 Notepad++或者 Sublime。或者,如果您使用 Unix/Linux 操作系统,只需打开您的控制台并使用 grep 命令,如下所示:

grep -a 'seconds' gc.log

这将显示调用finalize()方法时打印的所有日志条目。然后,您可以选择一个实例的名称,并执行如下操作:

$ grep -a 'Lybhpococssuoz' gc.log
INFO c.a.b.c.Main - JVM created: Lybhpococssuoz
INFO c.a.b.c.Singer - GC Destroyed: Lybhpococssuoz after 7 seconds

正如您所看到的,从堆中删除一个Singer实例所需的时间各不相同,这是因为 GC 是随机调用的;开发者对此没有控制权。有一种方法可以明确地请求进行垃圾收集——嗯,有两种方法。可以拨打:System.gc()或者

Runtime.getRuntime().gc().

System.gc()反正叫Runtime.getRuntime().gc()。

但是,这并不意味着 GC 会立即开始清理内存;这更像是建议 JVM 努力回收未使用的对象和未使用的内存,因为它们是需要的。

现在,回到finalize()方法。提到过在 Java 9 中被标记为不推荐使用。此方法旨在由处理存储在堆外的资源的类重写。这里最明显的例子是 I/O 处理类,用于将资源作为文件或 URL 和数据库读取。当一个对象不再能被正在运行的应用的任何活动线程访问时,JVM 将调用finalize(),以确保那些资源被释放并可供其他外部和不相关的程序使用。

在 Windows 上旧版本的 Apache Tomcat(一个基于 Java 的 web 服务器)中,有一个与资源释放有关的错误。当服务器崩溃或被强制停止时,它无法再次启动,因为它的一些日志文件处理程序没有正确释放,新的服务器实例无法访问它们以开始写入新的日志条目。(这是我很久很久以前在 Windows 上使用 Apache Tomcat 时的个人经验。)

随着 JDK 1.7 中java.lang.AutoCloseable接口的引入,finalize()方法变得越来越少使用。前面已经提到了这种方法的一些问题,但是下面的列表给出了更多的上下文:

  • JVM 不能保证哪个线程将为任何给定的对象调用这个方法,因此任何可以访问它的线程都可以调用它,我们可能会在仍然需要该对象时释放资源。该方法是公共的,因此可以在代码中显式调用它,即使它应该只由 GC 线程调用。

  • 如果自定义实现不正确,抛出异常,或者没有正确释放资源,会发生什么?

  • JVM 应该只调用一次finalize()方法,但这不能保证。

  • 另一个缺点是finalize()调用不会被自动链接,所以finalize()方法的自定义实现必须总是显式调用超类的finalize()方法。

  • 之前提到的另一个问题是:一旦调用了finalize(),就没有办法停止方法的执行或撤销其效果,所以基本上只剩下对一个不再存在的对象的引用。

现在您可能已经发现,在实现这种方法时,开发人员有很大的自由,这意味着有很大的空间发生错误。

这就是为什么 Java 中的终结机制是有缺陷的,并且在 JDK 9 中被否决以阻止它的使用。不当的finalize()实施可能导致:

  • 内存泄漏(内存内容不会被丢弃)

  • 死锁(资源被两个进程阻塞)

  • 挂起(进程处于等待状态,无法退出)

为了有助于内存管理,Java 9 中引入了java.lang.ref.Cleaner类。在此之前,我必须向您展示如何通过编程来检查您的内存状态。

堆内存统计信息

当程序运行时,试图与 JVM 内部交互时,Runtime类非常有用。正如本章前面提到的,可以调用它的gc()方法来建议 JVM 应该清理内存,几章前我们使用了这个类中的方法来从 Java 代码中启动进程。这个类中有三个方法对于查看分配给 Java 程序的内存的状态很有用:

  • runtime.maxMemory()返回 JVM 在需要时试图为其堆使用的最大内存量。此方法返回的值因机器而异,并且被隐式设置为机器上现有 RAM 总内存的四分之一,除非设置了,否则它是通过使用以下 JVM 选项-Xmx后跟内存量来显式设置的(例如,-Xmx8G将允许 JVM 使用最大 8 GB 的内存)。

  • runtime.totalMemory()返回 JVM 的内存总量。该方法返回的值也因机器而异,并且是依赖于实现的,除非通过使用下面的 JVM 选项-Xms后跟内存量来显式设置(例如,-Xms1G将告诉 JVM 其堆内存的初始大小应该是 1 GB 内存)。

  • runtime.freeMemory()返回 Java 虚拟机空闲内存量的近似值。使用runtime.totalMemory()和runtime.freeMemory()方法,我们可以写一些代码来检查在程序执行的不同时刻我们的内存被占用了多少。为此,创建了一个名为MemAudit的类,该类将使用当前的记录器来打印内存值。这个类的实现如清单 13-11 所示。

package com.apress.bgn.thirteen.util;

import org.slf4j.Logger;

public class MemAudit {
    private static final long MEGABYTE = 1024L * 1024L;
    private static final Runtime runtime = Runtime.getRuntime();

    public static void printBusyMemory(Logger log) {
        long memory = runtime.totalMemory() - runtime.freeMemory();
        log.info("Occupied memory: {} MB", (memory / MEGABYTE));
    }
    public static void printTotalMemory(Logger log) {
        log.info("Total Program memory: {} MB", (runtime.totalMemory()/MEGABYTE));
        log.info("Max Program memory: {} MB", (runtime.maxMemory()/MEGABYTE));
    }
}

Listing 13-11The MemAudit Class

Shown Memory Statistics During the Execution of a Java Application

这个类的方法将在我们的程序执行期间被调用,如清单 13-12 所示。

package com.apress.bgn.thirteen;

// some imports omitted
import static com.apress.bgn.thirteen.MemAudit.*;

public class MemAuditDemo {
    private static final Logger log = LoggerFactory.getLogger(MemAuditDemo.class);
    private static NameGenerator nameGenerator = new NameGenerator();
    private static final Random random = new Random();

    public static void main(String... args) {
        printTotalMemory(log);
        int count =0;
        while (true) {
            genSinger();
            count++;
            if (count % 1000 == 0) {
                printBusyMemory(log);
            }
        }
    }
    private static void genSinger() {
        Singer s = new Singer(nameGenerator.genName(), random.nextDouble(), LocalDate.now());
        log.info("JVM created: {}", s.getName());
    }
}

Listing 13-12The MemAuditDemo Class

Using the Class in Listing 13-11 to Print Memory Statistics in the Console

删除旧的日志文件后,我们应该运行这个类,并让它运行一段时间。因为不可能再看到输出,所以这个命令

grep -a 'memory' gc.log

对于提取包含“memory”一词的所有行非常有用,结果应该与清单 13-13 中的结果非常相似。

$  grep -a 'memory' gc.log
INFO  c.a.b.t.MemAuditDemo - Total Program memory: 260 MB
INFO  c.a.b.t.MemAuditDemo - Max Program memory: 4096 MB
INFO  c.a.b.t.MemAuditDemo - Occupied memory: 21 MB
INFO  c.a.b.t.MemAuditDemo - Occupied memory: 7 MB
INFO  c.a.b.t.MemAuditDemo - Occupied memory: 12 MB
...
INFO  c.a.b.t.MemAuditDemo - Occupied memory: 98 MB
INFO  c.a.b.t.MemAuditDemo - Occupied memory: 104 MB
...

Listing 13-13Memory Statistics Printed By Methods in the MemAudit Class

During Java Application Execution

最大内存是 4096MB,这意味着我的机器总共有 16 GB 的 RAM,占用的内存非常少,甚至不到 JVM 最初使用的 260MB。如果我们希望看到真实内存被占用,我们可以修改genSinger()方法来返回创建的引用,并将它们添加到一个列表中。因为在主类中引用了Singer实例,所以内存不再被清空。前述修改如清单 13-14 所示。

import com.apress.bgn.thirteen.util.NameGenerator;
// some import statements omitted
import java.util.ArrayList;
import java.util.List;
import static com.apress.bgn.thirteen.util.MemAudit.*;

public class MemoryConsumptionDemo {
    private static final Logger log = LoggerFactory.getLogger(MemoryConsumptionDemo.class);
    private static NameGenerator nameGenerator = new NameGenerator();
    private static final Random random = new Random();

    public static void main(String... args) {
        printTotalMemory(log);
        List<Singer> singers = new ArrayList<>();
        for (int i = 0; i < 1_000_000; ++i) {
            singers.add(genSinger());
            if (i % 1000 == 0) {
                printBusyMemory(log);
            }
        }
    }
    private static Singer genSinger() {
        Singer s = new Singer(nameGenerator.genName(), random.nextDouble(), LocalDate.now());
        log.info("JVM created: {}", s.getName());
        return s;
    }
}

Listing 13-14Saving the Singer Instances

to a List to Avoid Them Being Collected by the GC and the Memory Cleared

运行前面的程序后,我们实际上可以看到正在使用的内存逐渐增加。查看一下被 grep 神奇过滤的日志,我们会发现程序一直占用内存直到结束,因为引用现在保存到了List<Singer>实例中,如清单 13-15 所示。

$ grep -a 'memory' gc.log
INFO  c.a.b.t.MemoryConsumptionDemo - Total Program memory: 260 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Max Program memory: 4096 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 14 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 17 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 19 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 22 MB
...
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 99 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 101 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 104 MB
...
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 474 MB
INFO  c.a.b.t.MemoryConsumptionDemo - Occupied memory: 477 MB

Listing 13-15Memory Statistics Printed By Methods in the MemAudit Class

During a Java Application Execution Where Instances Are Saved to a List<Singer>

当我们每 1000 步打印一次被占用的内存时,我们可以得出结论,1000 个Singer实例大约占用 2 MB。前面的代码不再使用无限循环来生成实例;如果出现这种情况,程序会在某个时候突然崩溃,抛出以下异常:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at chapter.thirteen/com.apress.bgn.thirteen.MemoryConsumptionDemo
        .genSinger(MemoryConsumptionDemo.java:64)
    at chapter.thirteen/com.apress.bgn.thirteen.MemoryConsumptionDemo
        .main(MemoryConsumptionDemo.java:55)

还记得runtime.maxMemory()返回的值吗?在我的机器上,它是 4096MB。如果我在控制台中查看,就在刚才描述的异常之前,我会看到以下内容:

INFO c.a.b.c.MemoryConsumptionDemo - Occupied memory: 4094 MB
INFO c.a.b.c.MemoryConsumptionDemo - Occupied memory: 4094 MB
INFO c.a.b.c.MemoryConsumptionDemo - Occupied memory: 4095 MB
INFO c.a.b.c.MemoryConsumptionDemo - Occupied memory: 4095 MB
INFO c.a.b.c.MemoryConsumptionDemo - Occupied memory: 4095 MB

因此 JVM 努力创建另一个 Singer 实例,但是没有剩余的内存。在异常之前打印的最后一个值是4095MB,比允许 JVM 使用的最大内存量4096MB少 1 MB。所以可怜的 JVM 崩溃了,因为没有更多的堆内存可用。如果一个程序以这样的方式结束,问题总是出在解决方案的设计上。JVM 的总内存和最大内存的值也会影响 GC 的行为。前面介绍的-Xms和-Xmx非常重要,因为它们决定了堆内存的初始大小和最大大小。正确配置它们可以提高性能,但当值不合适时,它们会产生负面影响。例如,不要将堆的初始大小设置得太小,因为如果没有足够的空间来容纳应用创建的所有对象,JVM 就必须分配更多的内存,基本上就是在程序执行期间重复地重建堆。因此,如果在应用运行期间发生几次这种情况,总的时间消耗将会受到影响。堆的最大大小非常重要:分配太少应用会崩溃,分配太多可能会阻碍其他程序运行。决定这些值通常是通过反复实验完成的,从 JDK 11 号开始,新的 Epsilon 垃圾收集器在这方面非常方便。

如果你想了解更多关于 GC 调优的知识,通常最好的文档是官方的( https://docs.oracle.com/en/java/javase/17/gctuning )。

既然您已经知道了 GC 会带来什么,那么让我们来看看定制其行为的其他方法,这样可以避免问题。

使用清洁剂

因为需要确保向后兼容性,所以还不清楚finalize()方法何时会从 JDK 中移除。如果需要,可以开发类来实现java.lang.AutoCloseable并为close()方法提供一个实现,并确保在try-with-resources语句中使用你的对象。如果你想避免实现这个接口,还有一个方法:使用一个java.lang.ref.Cleaner对象。这个类可以被实例化,当对象被垃圾收集器丢弃时,对象可以和要执行的动作一起注册到这个类中。使用一个Cleaner实例,前面的代码可以如清单 13-16 所示编写:

package com.apress.bgn.thirteen.cleaner;
// some import statements omitted
import java.lang.ref.Cleaner;

public class CleanerDemo {
    private static final Logger log = LoggerFactory.getLogger(CleanerDemo.class);
    public static final Cleaner cleaner = Cleaner.create();
    private static NameGenerator nameGenerator = new NameGenerator();

    public static void main(String... args) {
        printTotalMemory(log);
        int count = 0;
        for (int i = 0; i < 100_000; ++i) {
            genActor();
            count++;
            if (count % 1000 == 0) {
                printBusyMemory(log);
                System.gc();
            }
        }

        //filling memory with arrays of String to force GC to clean up Actor objects

        for (int i = 1; i <= 10_000; i++) {
            String[] s = new String[10_000];
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
            }
        }

    }

    private static Cleaner.Cleanable genActor() {
        Actor a = new Actor(nameGenerator.genName(), LocalDate.now());
        log.info("JVM created: {}", a.getName());
        Cleaner.Cleanable handle = cleaner.register(a, new ActorRunnable(a.getName(), log));
        return handle;
    }

    static class ActorRunnable implements Runnable {
        private final String actorName;
        private final Logger log;

        public ActorRunnable(String actorName, Logger log) {
            this.actorName = actorName;
            this.log = log;
        }

        @Override
        public void run() {
            log.info("GC Destroyed: {} ", actorName);
        }
    }
}

Listing 13-16Using a Cleaner Instance

因为我们想让你更容易浏览代码,因为所有这些源代码都是同一个项目的一部分,所以我们在这里使用一个类来模拟一个Actor而不是一个Singer——但是不用担心,实现是非常相似的。Cleaner实例有一个名为register(..)的方法,该方法被调用来注册当对象被清理时要执行的动作。要执行的动作被指定为一个Runnable实例,并且决定通过实现它来创建一个类,在这个例子中是ActorRunnable,这样我们可以将要销毁的对象的名称保存到一个字段中,而不需要实际保存对要销毁的对象的引用,否则在程序执行期间 GC 将不会使用Cleaner.Cleanable句柄,因为该对象看起来好像仍然有对它的引用。通过调用clean()方法,cleaner.register(..)方法返回一个类型为Cleaner.Cleanable的实例,该实例可用于显式执行操作。当不再使用对象时,从内存中删除该对象,JVM 就会调用这个方法。如果您运行前面的代码,打印的日志看起来将与清单 13-17 中的非常相似。

INFO  c.a.b.t.c.CleanerDemo - Total Program memory: 260 MB
INFO  c.a.b.t.c.CleanerDemo - Max Program memory: 4096 MB
INFO  c.a.b.t.c.CleanerDemo - JVM created: Nuyktryvtkewiwd
INFO  c.a.b.t.c.CleanerDemo - JVM created: Brqivlsbvmteihz
INFO  c.a.b.t.c.CleanerDemo - JVM created: Qzvopg ophjcyho
...
INFO  c.a.b.t.c.CleanerDemo - Occupied memory: 17 MB
INFO  c.a.b.t.c.CleanerDemo - JVM created: Jrliwbjadztvwdm
INFO  c.a.b.t.c.CleanerDemo - JVM created: Evdteelpzinfcfh
INFO  c.a.b.t.c.CleanerDemo - JVM created: Hozfatszogfvzfz
...
INFO  c.a.b.t.c.CleanerDemo - GC Destroyed: Giqojswtuqzs s
INFO  c.a.b.t.c.CleanerDemo - GC Destroyed: Lzdjorokvyzwdu
INFO  c.a.b.t.c.CleanerDemo - JVM created: Igmzjiypo ttkzw
INFO  c.a.b.t.c.CleanerDemo - JVM created: Ljmksqzhzzhuzwl
INFO  c.a.b.t.c.CleanerDemo - GC Destroyed: Fny tnsffvyuisp
INFO  c.a.b.t.c.CleanerDemo - GC Destroyed: Qzillviekynpkec
...

Listing 13-17Log Printed By an Execution Using a Cleaner Instance to Free Up Memory

因此获得了与使用finalize()相同的结果,但是没有实现一个不赞成使用的方法。

img/463938_2_En_13_Figc_HTML.jpg作为一个很好的实践,如果你正在使用 Java 9+编写你的应用,避免使用finalize(),因为这种方法很明显正在被移除。使用Cleaner,在升级您的应用正在使用的 Java 版本时,您可能会少一些麻烦。

防止 GC 删除对象

在前两节中,我们重点关注了适合垃圾收集的对象。在应用中,有些对象在程序运行时不应该被丢弃,因为它们是需要的。在我们的类中,只有在执行结束时才被丢弃的最明显的引用是静态字段,它们是最终的,所以不能被重新初始化。

private static final Logger log = LoggerFactory.getLogger(CleanerDemo.class);
public static final Cleaner cleaner = Cleaner.create();
private static NameGenerator nameGenerator = new NameGenerator();
private static final Random random = new Random();

然而,这些静态值的问题是它们占用了内存。如果您的应用使用一个大的Map<K,V>包含一个字典,而这个字典在应用启动时并不需要,该怎么办?要解决这个问题,进入Singleton设计模式。Singleton模式是对一个类的特定设计,确保该类在程序执行期间只被实例化一次。这是通过隐藏构造函数(将其声明为私有)并声明类类型的静态引用和返回它的静态方法来实现的。根据Singleton模式编写类的方法不止一种,但是清单 13-18 中描述了最常用的方法。

package com.apress.bgn.thirteen;
// some import statements omitted
import java.util.HashMap;
import java.util.Map;

public final class SingletonDictionary {
    private static final Logger log = LoggerFactory.getLogger(SingletonDictionary.class);
    private Map<String, String> dictionary = new HashMap<>();

    private static final SingletonDictionary instance = new SingletonDictionary();

    private SingletonDictionary() {
        // init dictionary
        log.info("Starting to create dictionary: {}", System.currentTimeMillis());
        final NameGenerator keyGen = new NameGenerator(20);
        final NameGenerator valGen = new NameGenerator(200);
        for (int i = 0; i < 100_000; ++i) {
            dictionary.put(keyGen.genName(), valGen.genName());
        }
        log.info("Done creating dictionary: {}", System.currentTimeMillis());
    }

    public synchronized static SingletonDictionary getInstance(){
        return instance;
    }
}

Listing 13-18SingletonDictionary Class

在前面的代码中,我们模拟了一个包含 100,000 个条目的字典,所有条目都是由修改后的NameGenerator类生成的。创建实例时,日志消息被打印在构造函数中,这一点非常明显。关于Singleton模式,你必须记住四件事:

  • 构造函数必须是私有的,因为它不应该在类外被调用

  • 该类必须包含对其类型的对象的静态引用,该对象可以通过调用私有构造函数就地初始化

  • 必须定义一个方法来检索这个实例,所以它必须是静态的

  • 检索静态实例的方法也必须是同步的,这样就不会有两个线程同时调用它并获得对实例的访问,因为单例模式的核心思想是在程序执行期间只允许类被实例化一次,并确保不允许并发访问,因为这可能会导致意外的行为。有多种方式来初始化和使用单例,请随意做自己的研究。

在单例类中,创建了一个对实例的静态引用,这个静态引用防止垃圾收集器在程序执行期间清理这个实例。这是因为静态引用是一个类变量,而类是最后被 GC 删除的,在程序执行的最后。为了测试这一点,我们将编写一个主类,声明一个Cleaner实例,并为SingletonDictionary实例注册一个Cleanable。main 方法将创建大量的String数组来填充内存,试图说服 GC 删除SingletonDictionary实例,我们甚至将自己对它的引用设置为null,如清单 13-19 所示。

package com.apress.bgn.thirteen;
// import statements omitted

public class SingletonDictionaryDemo {
    public static final Cleaner cleaner = Cleaner.create();
    private static final Logger log = LoggerFactory.getLogger(SingletonDictionaryDemo.class);

    public static void main(String... args) {
        log.info("Testing SingletonDictionary...");
        //filling memory with arrays of String to force GC
        for (int i = 1; i <= 10_000; i++) {
            String[] s = new String[10_000];
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
            }
        }
        SingletonDictionary singletonDictionary = SingletonDictionary.getInstance();

        cleaner.register(singletonDictionary, ()-> {
            log.info("Cleaned up the dictionary!");
        });
        // we delete the reference
        singletonDictionary = null;

        //filling memory with arrays of String to force GC
        for (int i = 1; i <= 10_000; i++) {
            String[] s = new String[10_000];
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
            }
        }
        log.info("DONE.");
    }
}

Listing 13-19SingletonDictionaryDemo Class

如果我们运行前面的代码并期望看到“清理了字典!”控制台里的信息,我们的期待是徒劳的。在程序结束之前,SingletonDictionary中的静态引用不允许 GC 接触该对象。我们在类SingletonDictionary中的静态引用也被称为强引用,因为它防止对象从内存中被丢弃。

使用弱引用

显然,如果有强引用,我们也应该能够使用弱引用,对于我们实际上想要清理的对象,对吗?没错。

在 Java 中,有三个类可以用来保存对一个对象的引用,该对象不会保护该对象免于垃圾收集。这对于太大的对象很有用,并且将它们保存在内存中效率很低。对于这种类型的对象,重新初始化所耗费的时间是值得的,因为将它们保存在内存中会降低应用的整体性能。

这三个类别是:

  • java.lang.ref.SoftReference<T>:这种类型的引用所引用的对象由垃圾收集器根据内存需求自行清除。软引用最常用于实现对内存敏感的缓存。

  • java.lang.ref.WeakReference<T>:由这种类型的引用所引用的对象并不妨碍它们的被引用对象被终结化、终结化,然后被回收。弱引用最常用于实现规范化映射。规范化映射指的是容器,弱引用可以保存在容器中,并且可以被其他对象访问,但是它们到容器的链接不会阻止它们被收集。

  • java.lang.ref.PhantomReference<T>:由这些类型的引用所引用的对象在收集器确定它们的引用对象可能被回收后被排队。幻像引用最常用于计划事后清理操作。

我们的SingletonDictionary包含一个Map<K,V>实际上是存储在内存中的大对象。这个映射可以包装在一个WeakReference中,因为弱引用最常用于实现规范化映射。我们可以写一些逻辑,当字典实例被访问时,如果它不存在,它应该被重新初始化。因为我们需要访问地图,所以除了将Map<K,V>包装成一个 WeakReference 之外,实现会有一些变化。清单 13-20 中描述了名为WeakDictionary,的新类。

package com.apress.bgn.thirteen.util;
// other import statements omitted
import java.lang.ref.WeakReference;

public class WeakDictionary {
    private static final Logger log = LoggerFactory.getLogger(WeakDictionary.class);
    private static WeakDictionary instance = new WeakDictionary();
    private static Cleaner cleaner;
    private WeakReference<Map<Integer, String>> dictionary;

    private WeakDictionary() {
        cleaner = Cleaner.create();
        dictionary = new WeakReference<>(initDictionary());
    }

    public synchronized String getExplanationFor(Integer key) {
        Map<Integer, String> dict = dictionary.get();
        if (dict == null) {
            dict = initDictionary();
            dictionary = new WeakReference<>(dict);
            return dict.get(key);
        } else {
            return dict.get(key);
        }
    }

    public WeakReference<Map<Integer, String>> getDictionary() {
        return dictionary;
    }

    public synchronized static WeakDictionary getInstance() {
        return instance;
    }

    private Map<Integer, String> initDictionary() {
        final Map<Integer, String> dict = new HashMap<>();
        log.info("Starting to create dictionary: {}", System.currentTimeMillis());
        final NameGenerator valGen = new NameGenerator(200);
        for (int i = 0; i < 100_000; ++i) {
            dict.put(i, valGen.genName());
        }
        log.info("Done creating dictionary: {}", System.currentTimeMillis());
        cleaner.register(dict, ()-> log.info("Cleaned up the dictionary!"));
        return dict;
    }
}

Listing 13-20WeakDictionary Class

getExplanationFor(..)用于访问地图并获取与某个键对应的值。然而,在此之前,我们必须检查Map<K,V>是否还在。这是通过在类型为WeakReference<Map<Integer, String>>的字典引用上调用get()方法来完成的。如果 GC 没有收集映射,则提取并返回密钥;否则,Map<K,V>被重新初始化,弱引用被重新创建。这里也使用了Cleaner实例,并为Map<K,V>注册了一个Cleanable,因此我们可以看到正在收集的地图。那么我们如何测试这个呢?与我们测试SingletonDictionary的方式类似。WeakDictionaryDemo类并没有那么大的不同。代码如清单 13-21 所示。

package com.apress.bgn.thirteen;

import com.apress.bgn.thirteen.util.WeakDictionary;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class WeakDictionaryDemo {
    private static final Logger log = LoggerFactory.getLogger(WeakDictionaryDemo.class);

    public static void main(String... args) {
        log.info("Testing WeakDictionaryDemo...");
        //filling memory with arrays of String to force GC
        for (int i = 1; i <= 10_000; i++) {
            String[] s = new String[10_000];
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
            }
        }
        WeakDictionary weakDictionary = WeakDictionary.getInstance();

        //filling memory with arrays of String to force GC
        for (int i = 1; i <= 10_000; i++) {
            String[] s = new String[10_000];
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
            }
        }
        log.info("Getting val for 3 =  {}", weakDictionary.getExplanationFor(3));
        log.info("DONE.");
    }
}

Listing 13-21WeakDictionaryDemo Class

在检索到WeakDictionary引用后,创建了许多String数组来强制 GC 从内存中删除地图。之后,我们尝试访问有问题的地图。有用吗?

INFO  c.a.b.t.WeakDictionaryDemo - Testing WeakDictionaryDemo...
INFO  c.a.b.t.u.WeakDictionary - Starting to create dictionary: 1629635325234
INFO  c.a.b.t.u.WeakDictionary - Done creating dictionary: 1629635325485
INFO  c.a.b.t.u.WeakDictionary - Cleaned up the dictionary!
INFO  c.a.b.t.u.WeakDictionary - Starting to create dictionary: 1629635337852
INFO  c.a.b.t.u.WeakDictionary - Done creating dictionary: 1629635338093
INFO  c.a.b.t.WeakDictionaryDemo - Getting val for 3 =  Lqcnaowqotkzlhckqepogpjdlgkjzenyzzoaunebjsc z nervebnbc yjjlmuqkjaemmbtjbqzstjsssrwubwvfeoqfynyisba zclhf   lep fdbsnm cagubzodfpkepblslpypjwsybmwgptyznuymzgcdhkfydtibkjwgojjalctkrloatluakwwzppledhzdi
INFO  c.a.b.t.WeakDictionaryDemo - DONE.

Listing 13-22WeakDictionaryDemo Log

前面的日志证明了这一点,不仅如此,我们还可以看到 GC 丢弃了 map,然后在需要时重新初始化。这就是软引用力量。

垃圾收集过程是不确定的,因为它不能从代码中得到很好的控制。Java 程序不能告诉它开始、暂停或停止,但是通过使用适当的 VM 选项,我们可以控制它所拥有的资源。使用正确的实现,从代码中我们可以告诉它收集什么或不收集什么,大多数时候这就足够了。 2

垃圾收集异常和原因

前面提到过,如果对象不能从内存中丢弃,将会抛出类型为OutOfMemoryError的异常。我不确定你是否注意到了,但是OutOfMemoryError实际上并没有扩展java.lang.Exception,所以称它为异常是错误的。第章 5 中提到了异常类的层次结构。在那个层次结构中,有一个名为java.lang.Error的类,它实现了java.lang.Throwable,,它提到了当出现程序无法恢复的严重问题时,程序会抛出这些类型的对象。这里描述了java.lang.OutOfMemoryError的完整层级。

java.lang.Object
    java.lang.Throwable
        java.lang.Error
            java.lang.VirtualMachineError
                java.lang.OutOfMemoryError

实际上是那些你不希望在你的程序运行时抛出的丑陋的东西之一,因为这意味着你的程序实际上不再运行了。它没有运行的原因是因为它没有剩余的内存来存储正在创建的新对象。

当内存管理出错时,JVM 会抛出这个错误。尽管最常见的原因是堆内存耗尽,但还有其他原因。当分配给 JVM 的堆内存耗尽时,错误消息如下:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

但是您可能会看到另一条消息:

Exception in thread "main" java.lang.OutOfMemoryError: GC Overhead Limit Exceeded

这条消息仍然与堆大小有关。当程序的数据刚好适合堆的大小时,就会抛出这个错误,所以堆几乎满了,这允许 GC 运行,但是因为它不能赎回任何内存,所以 GC 一直运行,实际上阻碍了应用的正常执行。当 GC 花费 98%的执行时间而应用花费另外 2%的时间时,该消息被添加到错误中。

当 GC 由于某种原因无法正常工作时,这两个是您将会看到的最常见的错误消息。完整的列表可以在 https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/memleaks002.html 找到,但是由于大多数 GC 问题都与堆大小有关,G1GC 主要抛出 Java 堆空间消息的错误。

摘要

这一节是本书的结尾。谈到 Java 生态系统,互联网上有大量的书籍和教程。这本书只是触及了表面,给你作为 Java 开发人员的一个好的起点,整个团队都希望它能满足你的需求,并激发你的好奇心,以获得更多的资金。请记住,无论应用的范围如何,都没有万能的解决方案来确保内存始终得到正确的管理。如果您遇到麻烦,试验总是为您的 JVM 确定合适的收集器的一个步骤。

本章涵盖了以下主题:

  • 什么是垃圾收集以及涉及的步骤

  • 堆内存是如何构造的

  • Oracle HotSpot JVM 中有多少种垃圾收集器,我们如何在它们之间切换

  • 如何列出所有 GC 标志并将它们用作 VM 选项

  • 如何使用虚拟机选项查看垃圾收集器配置和统计信息

  • 如何使用 finalize 和Cleaner查看正在进行的垃圾收集

  • 如何阻止垃圾收集器收集重要对象

  • 如何使用软引用创建易于收集的对象

******

posted @ 2024-08-06 16:34  绝不原创的飞龙  阅读(413)  评论(0)    收藏  举报