Java9-高性能指南-全-
Java9 高性能指南(全)
原文:
zh.annas-archive.org/md5/9f13d9d94c7022ae23122382814df111译者:飞龙
前言
这本书是关于 Java 9 的,它是最受欢迎的应用开发语言之一。最新发布的 Java 9 版本带来了一系列新特性和新的 API,包括大量现成的组件,用于构建高效和可扩展的应用程序。流式处理、并行和异步处理、多线程、JSON 支持、响应式编程和微服务构成了现代编程的标志,并且现在已完全集成到 JDK 中。
因此,如果您想将您的 Java 知识提升到另一个层次,并希望提高您应用程序的性能,您就走在正确的道路上。
我能从中得到什么?
地图对于您的旅程至关重要,尤其是在您在其他大陆度假时。当涉及到学习时,一个路线图可以帮助您为朝着目标前进提供明确的路径。因此,在您开始旅程之前,您将看到一条路线图。
这本书是精心设计和开发的,旨在为您提供关于 Java 的所有正确和相关信息。我们为您创建了这个学习路径,它包括五个课程:
第 1 课,《学习 Java 9 底层性能改进》,涵盖了 Java 9 的激动人心的新特性,这些特性将提高您应用程序的性能。它侧重于模块化开发及其对应用程序性能的影响。
第 2 课,《提高生产力和快速应用程序的工具》,描述了 Java 9 中添加的两个新工具——JShell 和提前编译(AOT)编译器——这些工具可以提高您的生产力,并提高您应用程序的整体性能。
第 3 课,《多线程和响应式编程》,展示了如何使用命令行工具编程地监控 Java 应用程序。您还将探索如何通过多线程提高应用程序性能,以及在学习了通过监控发现的瓶颈之后如何调整 JVM 本身。
第 4 课,《微服务》,描述了在应对负载下的灵活扩展时,许多行业领导者所采用的一种解决方案。它讨论了通过将应用程序拆分为多个微服务来添加更多工作者,每个微服务独立部署,并使用多线程和响应式编程以获得更好的性能、响应、可扩展性和容错性。
第 5 课,《利用新 API 改进您的代码》,描述了编程工具的改进,包括流过滤器、堆栈跟踪 API、创建不可变集合的新便捷静态工厂方法、支持异步处理的新强大 CompletableFuture 类以及 JDK 9 流 API 的改进。
我将从这本书中获得什么?
-
熟悉模块化开发及其对性能的影响
-
学习各种与字符串相关的性能改进,包括紧凑字符串和 indify 字符串连接
-
探索各种底层编译器改进,例如分层属性和即时(AOT)编译
-
学习安全管理的改进
-
理解图形光栅化器的增强功能
-
使用命令行工具加速应用程序开发
-
学习如何实现多线程和响应式编程
-
在 Java 9 中构建微服务
-
实现 API 以改进应用程序代码
先决条件
本书面向希望构建可靠且高性能应用程序的 Java 开发者。在开始本书之前,你需要满足以下一些先决条件:
- 假设具备 Java 编程知识
第一章:学习 Java 9 的底层性能改进
当你认为你已经掌握了 lambda 和 Java 8 中所有与性能相关的特性时,Java 9 就出现了。以下是一些被纳入 Java 9 的功能,你可以使用它们来帮助提高应用程序的性能。这些功能不仅限于字节级别的变化,如字符串存储或垃圾回收的变化,这些变化你几乎无法控制。此外,忽略那些针对更快对象锁定的实现变化,因为你不需要做任何不同的事情,你将自动获得这些改进。相反,有新的库功能和全新的命令行工具,可以帮助你快速创建应用程序。
在本课中,我们将涵盖以下主题:
-
模块化开发及其对性能的影响
-
多种与字符串相关的性能改进,包括紧凑字符串和 indify 字符串连接
-
并发性的进步
-
各种底层编译器改进,例如分层属性和即时编译(AOT)
-
安全管理器的改进
-
图形光栅化器的增强
介绍 Java 9 的新特性
在本课中,我们将探讨许多在新的环境中运行应用程序时自动获得的性能改进。内部,字符串的变化也极大地减少了在不需要全面 Unicode 支持的字符字符串时的内存占用需求。如果你的大多数字符串可以编码为 ISO-8859-1 或 Latin-1(每个字符 1 个字节),它们在 Java 9 中将存储得更加高效。因此,让我们深入核心库,了解底层性能改进。
模块化开发及其影响
在软件工程中,模块化是一个重要的概念。从性能和可维护性的角度来看,创建称为模块的独立单元非常重要。这些模块可以组合在一起形成一个完整的系统。模块提供了封装,其中实现被隐藏在其他模块中。每个模块都可以暴露独特的 API,这些 API 可以作为连接器,以便其他模块可以与之通信。这种设计很有用,因为它促进了松散耦合,有助于关注单一功能以使其具有凝聚力,并允许在隔离状态下进行测试。它还减少了系统复杂性并优化了应用程序开发过程。提高每个模块的性能有助于提高整体应用程序的性能。因此,模块化开发是一个非常重要的概念。
我知道你可能正在想,等等,Java 不是已经模块化了吗?Java 的面向对象特性不是已经提供了模块化操作吗?嗯,面向对象确实在数据封装的同时强加了唯一性。它只推荐松耦合,但并不强制执行。此外,它未能提供对象级别的标识,也没有为接口提供任何版本控制方案。现在你可能想知道,关于 JAR 文件呢?它们不是模块化的吗?嗯,尽管 JAR 文件在某种程度上提供了模块化,但它们没有模块化所需的唯一性。它们确实有指定版本号的方案,但这很少使用,并且隐藏在 JAR 的清单文件中。
因此,我们需要一个与我们已有的不同的设计。简单来说,我们需要一个模块化系统,其中每个模块可以包含多个包,并且与标准 JAR 文件相比提供更强大的封装。
这就是 Java 9 模块系统提供的内容。除此之外,它还通过显式声明依赖项来替换了易出错的类路径机制。这些增强提高了整体应用程序的性能,因为开发者现在可以优化单个自包含单元,而不会影响整个系统。
这也使得应用程序更具可扩展性并提供高完整性。
让我们来看看模块系统的一些基本概念以及它是如何整合在一起的。首先,你可以运行以下命令来查看模块系统的结构:
$java --list-modules

如果你对某个特定模块感兴趣,你只需在命令末尾添加模块名称即可,如下面的命令所示:
$java --list-modules java.base

之前的命令将显示来自基本模块的包中的所有导出项。Java 的基本模块是系统的核心。
这将显示所有图形用户界面包。这也会显示requires,即依赖项:
$java --list-modules java.desktop

到目前为止一切顺利,对吧?现在你可能想知道,我已经开发了我的模块,但如何将它们集成在一起呢?让我们来看看。Java 9 的模块系统附带了一个名为JLink的工具。我知道你现在可以猜到我接下来要说什么。你说对了,它将一组模块链接起来并创建一个运行时镜像。现在想象一下它所能提供的可能性。你可以创建自己的可执行系统,并使用自己的自定义模块。我希望你的生活将变得更加有趣!哦,另一方面,你将能够控制执行并移除不必要的依赖项。
让我们看看如何将模块链接在一起。嗯,这非常简单。只需运行以下命令:
$jlink --module-path $JAVA_HOME/jmods:mlib --add-modules java.desktop --output myawesomeimage
这个链接器命令将为你链接所有模块并创建一个运行时镜像。你需要提供一个模块路径,然后添加你想要生成图像的模块,并给出一个名称。这不是很简单吗?
现在,让我们检查之前的命令是否正确执行。让我们验证图中的模块:
$myawesomeimage/bin/java --list-modules
输出看起来像这样:

通过这种方式,你现在将能够与你的应用程序一起分发一个快速的运行时。这真是太棒了,不是吗?现在你可以看到我们是如何从一个相对单一的设计转变为一个自包含且一致的设计。每个模块都包含自己的导出和依赖项,JLink 允许你创建自己的运行时。有了这个,我们就得到了我们的模块化平台。
注意,本节的目的只是向你介绍模块化系统。还有很多东西可以探索,但这超出了本书的范围。在本书中,我们将关注性能提升的领域。
模块快速入门
我相信,在了解了模块化平台之后,你一定很兴奋地想要深入了解模块架构,看看如何开发一个模块。请保持你的兴奋,我很快就会带你进入模块的精彩世界。
如你所猜,每个模块都有一个属性 name,并且按照包进行组织。每个模块作为一个自包含的单元,可能包含原生代码、配置、命令、资源等等。模块的详细信息存储在一个名为 module-info.java 的文件中,该文件位于模块源代码的根目录下。在该文件中,模块可以被定义为如下:
module <name>{
}
为了更好地理解它,让我们通过一个例子来讲解。假设,我们的模块名称是 PerformanceMonitor。这个模块的目的是监控应用程序的性能。输入连接器将接受方法名称和该方法的所需参数。这个方法将从我们的模块中调用以监控模块的性能。输出连接器将为给定模块提供性能反馈。让我们在我们的性能应用程序的根目录下创建一个 module-info.java 文件,并插入以下部分:
module com.java9highperformance.PerformanceMonitor{
}
太棒了!你已经完成了第一个模块声明。但是等等,它现在还没有做任何事情。别担心,我们只是为它创建了一个骨架。让我们在这个骨架上添加一些内容。假设我们的模块需要与我们的其他(出色的)模块进行通信,这些模块我们已经创建并命名为--PerformanceBase、StringMonitor、PrimitiveMonitor、GenericsMonitor等等。换句话说,我们的模块有一个外部依赖。你可能想知道,我们如何在模块声明中定义这种关系?好吧,请耐心等待,这就是我们现在要看到的:
module com.java9highperformance.PerformanceMonitor{
exports com.java9highperformance.StringMonitor;
exports com.java9highperformance.PrimitiveMonitor;
exports com.java9highperformance.GenericsMonitor;
requires com.java9highperformance.PerformanceBase;
requires com.java9highperformance.PerformanceStat;
requires com.java9highperformance.PerformanceIO;
}
是的,我知道你已经注意到了两个子句,那就是 exports 和 requires。我敢肯定你对它们的意义以及为什么要在那里使用它们感到好奇。我们将首先讨论这些子句及其在模块声明中的含义:
-
exports:当你的模块依赖于另一个模块时使用这个子句。它表示此模块仅向其他模块公开公共类型,且内部包不可见。在我们的例子中,模块com.java9highperformance.PerformanceMonitor依赖于com.java9highperformance.StringMonitor、com.java9highperformance.PrimitiveMonitor和com.java9highperformance.GenericsMonitor。这些模块分别导出它们的 API 包com.java9highperformance.StringMonitor、com.java9highperformance.PrimitiveMonitor和com.java9highperformance.GenericsMonitor。 -
requires:这个子句表示模块在编译和运行时都依赖于声明的模块。在我们的例子中,com.java9highperformance.PerformanceBase、com.java9highperformance.PerformanceStat和com.java9highperformance.PerformanceIO模块被我们的com.java9highperformance.PerformanceMonitor模块所需要。模块系统随后定位所有可观察的模块以递归地解决所有依赖关系。这个传递闭包给我们一个模块图,它显示了两个依赖模块之间的有向边。
注意
注意:每个模块即使没有明确声明,也依赖于 java.base。正如你所知道的那样,Java 中的所有东西都是一个对象。
现在你已经了解了模块及其依赖关系。那么,让我们绘制一个模块表示图来更好地理解它。以下图显示了依赖于 com.java9highperformance.PerformanceMonitor 的各种包。

底部的模块是 exports 模块,右边的模块是 requires 模块。
现在让我们探索一个称为 可读性关系 的概念。可读性关系是两个模块之间的关系,其中一个模块依赖于另一个模块。这种可读性关系是可靠配置的基础。所以,在我们的例子中,我们可以说 com.java9highperformance.PerformanceMonitor 读取 com.java9highperformance.PerformanceStat。
让我们看看 com.java9highperformance.PerformanceStat 模块的描述文件 module-info.java:
module com.java9highperformance.PerformanceStat{
requires transitive java.lang;
}
这个模块依赖于 java.lang 模块。让我们详细看看 PerformanceStat 模块:
package com.java9highperformance.PerformanceStat;
import java.lang.*;
public Class StringProcessor{
public String processString(){...}
}
在这种情况下,com.java9highperformance.PerformanceMonitor 只依赖于 com.java9highperformance.PerformanceStat,但 com.java9highperformance.PerformanceStat 依赖于 java.lang。com.java9highperformance.PerformanceMonitor 模块没有意识到来自 com.java9highperformance.PerformanceStat 模块的 java.lang 依赖。这种类型的问题由模块系统处理。它增加了一个新的修饰符,称为 transitive。如果你查看 com.java9highperformance.PerformanceStat,你会发现它需要传递的 java.lang。这意味着任何依赖于 com.java9highperformance.PerformanceStat 的模块都会读取 java.lang。
看以下图,它显示了可读性图:

现在,为了编译com.java9highperformance.PerformanceMonitor模块,系统必须能够解决所有依赖项。这些依赖项可以从模块路径中找到。这很明显,不是吗?然而,不要将类路径与模块路径混淆。它们是完全不同的类型。它没有包所存在的问题。
字符串操作性能
如果你不是编程新手,字符串必须是你迄今为止最好的朋友。在许多情况下,你可能比配偶或伴侣更喜欢它。众所周知,没有字符串你无法生活,实际上,没有使用字符串你甚至无法完成你的应用程序。好吧,关于字符串我们已经说得够多了,我甚至已经因为字符串的使用而感到头晕,就像早期版本的 JVM 一样。玩笑归玩笑,让我们谈谈 Java 9 中发生了哪些变化,这将有助于你的应用程序性能提升。尽管这是一个内部变化,但作为一个应用程序开发者,了解这个概念是很重要的,这样你知道在哪里集中精力进行性能改进。
Java 9 朝着提高字符串性能迈出了第一步。如果你曾经遇到过 JDK 6 的失败尝试UseCompressedStrings,那么你一定在寻找提高字符串性能的方法。由于UseCompressedStrings是一个实验性特性,容易出错,并且设计得不是很好,它在 JDK 7 中被移除了。对此不要感到难过,我知道它很糟糕,但就像往常一样,黄金时代最终会到来。JEP 团队经历了巨大的痛苦,添加了一个紧凑字符串特性,这将减少字符串及其相关类的占用空间。
紧凑字符串将改善字符串的占用空间,并有助于高效地使用内存空间。它还保留了与所有相关 Java 和本地接口的兼容性。第二个重要特性是Indify 字符串连接,它将在运行时优化字符串。
在本节中,我们将深入探讨这两个特性及其对整体应用程序性能的影响。
紧凑字符串
在我们讨论这个特性之前,了解我们为什么关心这一点是很重要的。让我们深入 JVM(或者像任何星球大战粉丝可能会说的,力量的黑暗面)的地下世界。首先,让我们了解 JVM 是如何处理我们心爱的字符串的,这将帮助我们理解这个新的闪亮的紧凑字符串改进。让我们进入堆的神奇世界。事实上,没有关于这个神秘世界的讨论,任何性能书籍都是不完整的。
堆的世界
每次 JVM 启动时,它从底层操作系统获取一些内存。这些内存被分为两个不同的区域,称为堆空间和Permgen。这些区域是所有应用程序资源的家。正如生活中所有美好的事物一样,这个家的大小是有限的。这个大小是在 JVM 初始化时设置的;然而,你可以通过指定 JVM 参数-Xmx和-XX:MaxPermSize来增加或减少这个大小。
堆大小被分为两个区域,即幼年空间或年轻空间和旧空间。正如其名所示,年轻空间是新生对象的家园。这一切听起来都很棒,但每个房子都需要清理。因此,JVM 有最有效的清洁工,称为垃圾收集器(最有效?好吧……让我们先不谈这个)。像任何有生产力的清洁工一样,垃圾收集器有效地收集所有未使用的对象并回收内存。当年轻空间因新对象而填满时,垃圾收集器接管并移动那些在年轻空间中存活足够长的时间的对象。这样,年轻空间中总是有空间容纳更多对象。
同样地,如果旧空间被填满,垃圾收集器将回收使用的内存。
为什么需要压缩字符串?
现在你对堆有了一些了解,让我们看看String类以及字符串在堆上的表示方式。如果你剖析你的应用程序的堆,你会注意到有两个对象,一个是 Java 语言的String对象,它引用第二个对象char[],后者实际处理数据。char数据类型是 UTF-16,因此占用多达 2 个字节。让我们看看以下示例,看看两种不同语言的字符串是如何表示的:
2 byte per char[]
Latin1 String : 1 byte per char[]
所以你可以看到,Latin1 String只占用 1 个字节,因此我们在这里损失了大约 50%的空间。有机会以更密集的形式表示它并改进占用空间,这最终有助于加快垃圾回收的速度。
在对这一部分进行任何更改之前,了解其对实际应用的影响非常重要。了解应用程序是否使用每个char[]字符串 1 个字节或 2 个字节是至关重要的。
为了得到这个答案,JPM 团队分析了大量真实世界数据的堆转储。结果显示,大多数堆转储中大约有 18%到 30%的整个堆被chars[]消耗,这些chars[]来自字符串。此外,很明显,大多数字符串都是由每个char[]的单个字节表示的。因此,很明显,如果我们尝试通过单个字节来改进字符串的占用空间,这将显著提高许多实际应用的性能。
他们做了什么?
在经过许多不同的解决方案之后,JPM 团队最终决定在字符串构建过程中采用压缩策略。首先,乐观地尝试以 1 字节压缩,如果失败,则将其复制为 2 字节。有一些可能的捷径,例如,使用特殊的 ISO-8851-1 编码器,它将始终输出 1 字节。
这个实现比 JDK 6 的UseCompressedStrings实现要好得多,后者仅对少数应用程序有帮助,因为它在每次实例上重新打包和拆包字符串进行压缩。因此,性能提升来自于它现在可以处理这两种形式。
什么是逃生路线?
尽管听起来很棒,但如果它只使用 2 字节来表示每个char[]字符串,它可能会影响应用程序的性能。在这种情况下,不使用之前提到的检查,直接以每个char[]的 2 字节存储字符串是有意义的。因此,JPM 团队提供了一个“关闭开关”--XX: -CompactStrings,使用它可以禁用此功能。
什么是性能提升?
之前的优化会影响堆,因为我们之前看到字符串是在堆中表示的。因此,它正在影响应用程序的内存占用。为了评估性能,我们真的需要关注垃圾收集器。我们将在稍后探讨垃圾收集主题,但现在,让我们只关注运行时性能。
简化字符串连接
我相信您一定对刚刚学到的紧凑字符串功能感到兴奋。现在让我们看看字符串最常见的使用方式,即连接。您是否曾经想过当我们尝试连接两个字符串时,实际上会发生什么?让我们来探索。以下是一个例子:
public static String getMyAwesomeString(){
int javaVersion = 9;
String myAwesomeString = "I love " + "Java " + javaVersion + " high performance book by Mayur Ramgir";
return myAwesomeString;
}
在前面的例子中,我们正在尝试使用int值连接几个字符串。然后编译器将初始化一个新的StringBuilder实例,并将所有这些单独的字符串附加到它上面。看看以下由javac生成的字节码。我使用了ByteCode Outline插件来可视化此方法的反汇编字节码。您可以从andrei.gmxhome.de/bytecode/index.html下载它:
// access flags 0x9
public static getMyAwesomeString()Ljava/lang/String;
L0
LINENUMBER 10 L0
BIPUSH 9
ISTORE 0
L1
LINENUMBER 11 L1
NEW java/lang/StringBuilder
DUP
LDC "I love Java "
INVOKESPECIAL java/lang/StringBuilder.<init> (Ljava/lang/String;)V
ILOAD 0
INVOKEVIRTUAL java/lang/StringBuilder.append (I)Ljava/lang/StringBuilder;
LDC " high performance book by Mayur Ramgir"
INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
ASTORE 1
L2
LINENUMBER 12 L2
ALOAD 1
ARETURN
L3
LOCALVARIABLE javaVersion I L1 L3 0
LOCALVARIABLE myAwesomeString Ljava/lang/String; L2 L3 1
MAXSTACK = 3
MAXLOCALS = 2
快速笔记:我们如何解释这个?
-
INVOKESTATIC: 这对于调用静态方法很有用 -
INVOKEVIRTUAL: 这用于调用公共和受保护的非静态方法,使用动态调度 -
INVOKEINTERFACE: 这与INVOKEVIRTUAL非常相似,只是方法调度是基于接口类型的 -
INVOKESPECIAL: 这对于调用构造函数、超类的方法和私有方法很有用
然而,在运行时,由于 JIT 编译器中包含了-XX:+OptimizeStringConcat,它现在可以识别StringBuilder的追加和toString链。如果匹配被识别,将生成用于最佳处理的低级代码。计算所有参数的长度,确定最终容量,分配存储空间,复制字符串,并对原始数据进行就地转换。之后,无需复制即可将此数组传递给String实例。这是一个有利可图的优化。
但在连接方面也存在一些缺点。一个例子是,对于包含长字符串或双精度浮点数的连接字符串,它无法正确优化。这是因为编译器必须首先执行 .getChar,这增加了开销。
此外,如果你将int追加到String,那么它工作得很好;然而,如果你有一个增量运算符,如i++,那么它就会失效。背后的原因是你需要回滚到表达式的开头并重新执行,所以你实际上是在做++两次。现在,Java 9 紧凑字符串最重要的变化。长度拼写为value.length >> coder;C2无法像处理 IR 那样优化它。
因此,为了解决编译器优化和运行时支持的问题,我们需要控制字节码,我们不能期望javac来处理这个问题。
我们需要延迟在运行时可以执行哪种连接的决定。我们是否可以有这样一个String.concat方法,它将完成魔法般的操作。好吧,不要急于行动,因为你怎么设计concat方法。让我们看看。一种方法是可以接受一个String实例的数组:
public String concat(String... n){
//do the concatenation
}
然而,这种方法不适用于原始数据类型,因为你现在需要将每个原始数据类型转换为String实例,而且,正如我们之前看到的,问题是长字符串和双精度浮点数连接将不允许我们优化它。我知道,我能感觉到你脸上的光芒,就像你得到了一个解决这个痛苦问题的绝妙想法。你是在想用Object实例而不是String实例,对吧?正如你所知,Object实例是通用的。让我们看看你的绝妙想法:
public String concat(Object... n){
//do the concatenation
}
首先,如果你使用Object实例,那么编译器需要进行自动装箱。此外,你正在传递一个varargs数组,所以它不会表现最佳。所以,我们是不是陷入了僵局?这意味着我们无法使用字符串连接的突出紧凑字符串功能?让我们再思考一下;也许我们可以让javac处理连接,只给我们优化后的字节码。这听起来是个好主意。等等,我知道你也在想同样的事情。如果 JDK 10 进一步优化这个功能,这意味着什么?这意味着当我升级到新 JDK 时,我必须重新编译我的代码并重新部署它吗?在某些情况下,这不是问题,在其他情况下,这是一个大问题。所以,我们又回到了起点。
我们需要能够在运行时处理的东西。好吧,这意味着我们需要能够动态调用方法的东西。嗯,这让我想起了什么。如果我们回到 JDK 7 时代的起点,它给了我们invokedynamic。我知道你能看到解决方案,我能感觉到你眼中的光芒。是的,你是对的,invokedynamic可以帮我们解决这个问题。如果你不了解invokedynamic,让我们花点时间来理解它。对于那些已经掌握了这个主题的人来说,你可以跳过这部分,但我建议你再次阅读。
Invokedynamic
invokedynamic特性是 Java 历史上最显著的特征。我们不再受限于 JVM 字节码,现在我们可以定义自己的操作方式。那么什么是invokedynamic呢?简单来说,它是由用户定义的字节码。这个字节码(而不是 JVM)决定了执行和优化策略。它提供了各种方法指针和适配器,这些指针以方法处理 API 的形式存在。然后 JVM 根据字节码中给出的指针进行工作,并使用类似反射的方法指针来优化它。这样,作为开发者的你,可以完全控制代码的执行和优化。
它本质上是由用户定义的字节码(被称为字节码 + 引导)和方法句柄混合而成的。我知道你也在想关于方法句柄的问题——它们是什么,如何使用?好吧,我听到了你的声音,让我们来谈谈方法句柄。
方法句柄提供各种指针,包括字段、数组和方法,用于传递数据和返回结果。有了这个,你可以进行参数操作和流程控制。从 JVM 的角度来看,这些是原生指令,它可以像字节码一样进行优化。然而,你可以选择程序性地生成这种字节码。
让我们放大方法句柄,看看它们是如何相互关联的。主包的名称是java.lang.invoke,它包含MethodHandle、MethodType和MethodHandles。MethodHandle是用于调用函数的指针。MethodType是方法中参数集合和返回值的表示。实用类MethodHandles将作为指向方法的指针,它将获取MethodHandle的实例并映射参数。
我们不会深入探讨这一部分,因为我们的目标只是让你了解invokedynamic特性是什么以及它是如何工作的,这样你就能理解字符串连接解决方案。所以,这就是我们回到关于字符串连接讨论的地方。我知道,你在享受invokedynamic的讨论,但我猜我给你提供了足够的洞察力,让你理解 Indify 字符串连接的核心思想。
让我们回到我们寻找解决方案以连接我们出色的紧凑字符串的部分。为了连接紧凑字符串,我们需要注意类型和方法的数量,这正是 invokedynamic 给我们的。
所以让我们为 concat 使用 invokedynamic。嗯,别那么快,我的朋友。这种方法有一个基本问题。我们不能简单地使用 invokedynamic 来解决这个问题。为什么?因为存在循环引用。concat 函数需要 java.lang.invoke,它又使用了 concat。这样继续下去,最终你会得到 StackOverflowError。
看看下面的代码:
String concat(int i, long l, String s){
return s + i + l
}
所以如果我们在这里使用 invokedynamic,invokedynamic 调用将看起来像这样:
InvokeDynamic #0: makeConcat(String, int, long)
需要打破循环引用。然而,在当前的 JDK 实现中,你无法控制 java.invoke 从完整的 JDK 库中调用什么。此外,从 java.invoke 中移除完整的 JDK 库引用会产生严重的副作用。我们只需要 java.base 模块来进行 Indify 字符串连接,如果我们能找到一种方法只调用 java.base 模块,那么这将显著提高性能并避免不愉快的异常。我知道你在想什么。我们刚刚学习了 Java 9 中最酷的新增功能,Project Jigsaw。它提供了模块化源代码,现在我们只能接受 java.base 模块。这解决了我们在连接两个字符串、原始数据等方面面临的最大问题。
经过几个不同的策略后,Java 性能管理团队已经确定以下策略:
-
对所有引用参数调用
toString()方法。 -
调用
tolength()方法,或者由于所有底层方法都已公开,只需在每个参数上调用T.stringSize(T t)。 -
找出编码者并对所有引用参数调用
coder()。 -
分配
byte[]存储空间,然后复制所有参数。然后,就地转换原始数据。 -
通过传递用于连接的数组来调用私有的
String构造函数。
通过这种方式,我们能够在相同的代码中而不是在 C2 IR 中获得优化的字符串连接。这种策略给我们带来了 2.9 倍的性能提升和 6.4 倍的垃圾减少。
在 CDS 存档中存储内部字符串
这个特性的主要目标是减少由在每一个 JVM 进程中创建新的字符串实例所造成的内存占用。在任何 JVM 进程中加载的所有类都可以通过 Class Data Sharing (CDS) 存档与其他 JVM 进程共享。
哦,我没有告诉你关于 CDS 的信息。我认为花些时间了解 CDS 是很重要的,这样你才能理解底层性能提升。
许多时候,特别是小型应用程序在启动操作上花费的时间相对较长。为了减少启动时间,引入了一个称为 CDS 的概念。CDS 允许在 JRE 安装期间将系统 JAR 文件中加载的一组类共享到一个私有的内部表示中。这非常有帮助,因为这样任何进一步的 JVM 调用都可以利用这些已加载类的表示,而不是再次加载这些类。这些类的相关元数据在多个 JVM 进程之间共享。
CDS 以 UTF-8 的形式在常量池中存储字符串。当从这些加载的类开始初始化过程时,这些 UTF-8 字符串会根据需要转换为 String 对象。在这个结构中,每个受限字符串中的每个字符在 String 对象中占用 2 个字节,在 UTF-8 中占用 1 到 3 个字节,这实际上浪费了内存。由于这些字符串是动态创建的,不同的 JVM 进程无法共享这些字符串。
共享字符串需要一个称为固定区域的功能才能利用垃圾收集器。由于唯一支持固定功能的 HotSpot 垃圾收集器是 G1;它只与 G1 垃圾收集器一起工作。
并发性能
多线程是一个非常流行的概念。它允许程序同时运行多个任务。这些多线程程序可能有多个可以并发运行的单元。每个单元可以处理不同的任务,保持可用资源的最佳使用。这可以通过并行运行的多个线程来管理。
Java 9 改进了竞争锁。你可能想知道什么是竞争锁。让我们来探讨一下。每个对象都有一个可以被一个线程一次拥有的监视器。监视器是并发的基石。为了使一个线程能够执行在对象上标记为同步的代码块或对象声明的同步方法,它必须拥有这个对象的监视器。由于有多个线程试图访问提到的监视器,JVM 需要协调这个过程,并且一次只允许一个线程访问。这意味着其余的线程进入等待状态。这个监视器因此被称为竞争的。因为这个规定,程序在等待状态下浪费了时间。
此外,Java 虚拟机(JVM)在协调锁竞争方面做了一些工作。此外,它还需要管理线程,所以一旦现有线程完成其执行,它就可以允许一个新的线程进入。这无疑增加了开销,并会对性能产生不利影响。Java 9 已经采取了一些步骤来改进这个领域。这个规定细化了 JVM 的协调,这将最终导致在高度竞争的代码中性能的提高。
以下基准测试和测试可以用来检查竞争的 Java 对象监视器的性能改进:
-
CallTimerGrid(这更像是一个压力测试而不是基准测试) -
Dacapo-bach(早期的 dacapo2009) -
_ avrora -
_ batik -
_ fop -
_ h2 -
_ luindex -
_ lusearch -
_ pmd -
_ sunflow -
_ tomcat -
_ tradebeans -
_ tradesoap -
_ xalan -
DerbyContentionModelCounted -
高冲突模拟器 -
LockLoops-JSR166-Doug-Sept2009(早期版本 LockLoops) -
PointBase -
SPECjbb2013-critical(早期版本 specjbb2005) -
SPECjbb2013-max -
specjvm2008 -
volano29(早期版本 volano2509)
编译器改进
已经做出了几项努力来提高编译器的性能。在本节中,我们将重点关注编译器方面的改进。
层级归因
提供编译器改进的第一个和最重要的变化与层级归因(TA)相关。这个变化更多地与 lambda 表达式相关。目前,多态表达式的类型检查是通过多次对同一棵树进行不同目标类型检查来完成的。这个过程被称为推测性归因(SA),它允许使用不同的重载解析目标来检查 lambda 表达式。
这种类型检查的方式,虽然是一种稳健的技术,但会对性能产生显著的不利影响。例如,采用这种方法,每个重载候选者都会在重载阶段对相同的参数表达式进行一次检查,直到重载的严格、宽松和可变参数阶段,检查次数达到 *n * 3。此外,还有一个最终的检查阶段。当 lambda 返回多态方法调用结果时,会导致归因调用组合爆炸,这会引起巨大的性能问题。因此,我们确实需要为多态表达式寻找不同的类型检查方法。
核心思想是确保每次方法调用都为每个多态参数表达式创建自下而上的结构化类型,这些类型将用于在执行重载解析适用性检查之前。
总结来说,性能提升能够通过减少尝试的总次数来实现给定表达式的属性。
预编译
编译器改进的第二个显著变化是预编译。如果你不熟悉这个术语,让我们看看 AOT 是什么。正如你可能知道的,任何语言的每个程序都需要一个运行时环境来执行。Java 也有自己的运行时,被称为Java 虚拟机(JVM)。我们大多数人使用的典型运行时是一个字节码解释器,它也是一个即时编译器。这个运行时被称为HotSpot JVM。
这个 HotSpot JVM 因其通过即时编译(JIT)以及自适应优化来提高性能而闻名。到目前为止一切顺利。然而,这并不是每个应用程序在实践中的最佳选择。假设你有一个非常轻的程序,比如说一个单独的方法调用?在这种情况下,即时编译对你帮助不大。你需要一些能更快加载的东西。这就是 AOT 能帮到你的地方。与 JIT 相反,你不仅可以编译成字节码,还可以编译成本地机器码。然后运行时会使用这个本地机器码来管理新对象的 malloc 调用以及文件访问的系统调用。这可以提高性能。
安全管理器改进
好吧,让我们谈谈安全。如果你不是那些在发布新功能时更关心应用程序安全而不是安全的人,那么你脸上的表情可能就是啊!那是什么?如果你是那些人之一,那么让我们首先了解安全的重要性,并找到一种方法将其纳入你的应用程序开发任务中。在当今以 SaaS 主导的世界里,一切都被暴露在外部世界。一个有决心的个人(用一种好听的方式来说,就是一个恶意黑客),可以访问你的应用程序并利用你因疏忽而引入的安全漏洞。我很乐意深入讨论应用程序安全,因为这又是一个我非常感兴趣的领域。然而,应用程序安全不在这个书的范围之内。我们在这里谈论它的原因是因为 JPM 团队已经采取了一项改进现有安全管理器的举措。因此,在谈论安全管理器之前,首先了解安全的重要性是很重要的。
希望这一行描述能激发你对安全编程的兴趣。然而,我明白有时你可能因为时间紧迫而没有足够的时间来实现一个完整的安全编程模型。所以,让我们找到一个适合你紧张日程的方法。让我们思考一下;有没有自动化的安全方法?我们能否有一种创建蓝图并要求我们的程序在边界内运行的方法?好吧,你很幸运,Java 确实有一个叫做安全管理器的功能。它不过是一个定义应用程序安全策略的策略管理器。听起来很吸引人,不是吗?但是这个策略看起来是什么样子?它包含什么?这两个问题都是合理的。这个安全策略基本上声明了那些本质上是危险或敏感的操作。如果你的应用程序不遵守这个策略,那么安全管理器会抛出SecurityException。另一方面,你可以让应用程序调用这个安全管理器来了解允许的操作。现在,让我们详细看看安全管理器。
在网络小程序的情况下,浏览器提供安全管理者,或者 Java Web Start 插件运行此策略。在许多情况下,除了网络小程序之外的应用程序在没有安全管理者的情况下运行,除非那些应用程序实现了它。如果既没有安全管理者也没有安全策略附加,应用程序将无限制地运行。
现在我们对安全管理者有了一些了解,让我们看看这个领域的性能改进。据 Java 团队所说,一个运行带有安全管理者安装的应用程序可能会降低 10%到 15%的性能。然而,不可能完全消除所有性能瓶颈,但缩小这个差距不仅可以提高安全性,还可以提高性能。
Java 9 团队考虑了一些优化,包括执行安全策略和权限评估,这将有助于提高使用安全管理器的整体性能。在性能测试阶段,突出指出尽管权限类是线程安全的,但它们在 HotSpot 中显示出来。已经做出了许多改进,以减少线程竞争并提高吞吐量。
java.security.CodeSource的hashCode方法已经改进,使用代码源 URL 的字符串形式来避免可能昂贵的 DNS 查找。此外,包含包检查算法的java.lang.SecurityManager的checkPackageAccess方法也得到了改进。
安全管理者改进的其他一些显著变化如下:
-
第一个明显的改变是,用
ConcurrentHashMap代替Collections.synchronizedMap有助于提高Policy.implie方法的吞吐量。请看以下来自 OpenJDK 网站的图表,它突出了使用ConcurrentHashMap后吞吐量的显著提升:![安全管理者改进]()
-
此外,
HashMap,它一直被用于在java.security.SecureClassLoader中维护CodeSource的内部集合,已经被ConcurrentHashMap所取代。 -
还有一些其他的小改进,比如通过从
getPermissions方法(CodeSource)中移除兼容性代码来提高吞吐量,该方法在身份上进行了同步。 -
使用
ConcurrentHashMap而不是在权限检查代码中用同步块包围的HashMap,实现了性能的显著提升,这带来了更高的线程性能。
图形光栅化器
如果你热衷于 Java 2D 并且使用 OpenJDK,你会赞赏 Java 9 团队所做的努力。Java 9 主要与图形光栅化器相关,它是当前 JDK 的一部分。OpenJDK 使用 Pisces,而 Oracle JDK 使用 Ductus。Oracle 的闭源 Ductus 光栅化器比 OpenJDK 的 Pisces 性能更好。
这些图形光栅化器对抗锯齿渲染很有用,除了字体。因此,对于图形密集型应用程序,这个光栅化器的性能非常重要。然而,Pisces 在许多方面都失败了,其性能问题非常明显。因此,团队决定用名为 Marlin 图形渲染器的不同光栅化器来替换它。
Marlin 是用 Java 开发的,最重要的是,它是 Pisces 光栅化器的分支。对它进行了各种测试,结果非常令人鼓舞。它始终比 Pisces 表现得更好。它展示了多线程的可伸缩性,甚至在单线程应用程序中优于闭源 Ductus 光栅化器。
摘要
在本课中,我们看到了一些令人兴奋的特性,这些特性将提高您的应用程序性能,而无需您做出任何努力。
在下一课中,我们将学习关于 JShell 和 即时编译器(AOT)(AOT)以及 读取-评估-打印循环(REPL)工具。
评估
-
JLink 是 Java 9 模块化系统的 _________。
-
两个模块之间的关系是什么,其中一个模块依赖于另一个模块?
-
可读性关系
-
可操作性关系
-
模块关系
-
实体关系
-
-
判断以下陈述是对还是错:每次 JVM 启动时,它从底层操作系统获取一些内存。
-
以下哪个执行了一些协调锁竞争的工作?
-
锁定区域
-
可读性关系
-
Java 虚拟机
-
类数据共享
-
-
以下哪个允许使用不同的重载解析目标来检查 lambda 表达式?
-
分层分配
-
HotSpot JVM
-
投机性分配
-
Permgen
-
第二章. 提高生产力和快速应用工具
/list), and /-<n> allow re-running of the snippets that have been run previously.
JShell was able to provide the suggestion because the JAR file with the compiled Pair class was on the classpath (set there by default as part of JDK libraries). You can also add to the classpath any other JAR file with the compiled classes you need for your coding. You can do it by setting it at JShell startup by the option --class-path (can be also used with one dash -class-path):

Shift + *Tab* and then *I* as described earlier.
`<name or id>`: This is the name or ID of a specific snippet or method or type or variable (we will see examples later)`-start`: This shows snippets or methods or types or variables loaded at the JShell start (we will see later how to do it)`-all`: This shows snippets or methods or types or variables loaded at the JShell start and entered later during the session
s5:

pair), saved the session entries in the file mysession.jsh (in the home directory), and closed the session. Let's look in the file mysession.jsh now:

7:

/o <file> that opens the file as the source input.
命令 /en、/res 和 /rel 具有重叠的功能:
-
/en [options]: 这允许查看或更改评估上下文 -
/res [options]: 这将丢弃所有输入的片段并重新启动会话 -
/rel[options]: 这以与命令/en相同的方式重新加载会话
有关更多详细信息和建议选项,请参阅官方 Oracle 文档(docs.oracle.com/javase/9/tools/jshell.htm)。
命令 [/se [setting] 设置配置信息,包括外部编辑器、启动设置和反馈模式。此命令还用于创建具有自定义提示、格式和截断值的自定义反馈模式。如果没有输入设置,则显示编辑器、启动设置和反馈模式的当前设置。前面提到的文档详细描述了所有可能的设置。
当 JShell 集成到 IDE 中时,它将更加有用,这样程序员就可以即时评估表达式,甚至更好的是,它们可以像编译器今天评估语法一样自动评估。
预编译(AOT)
Java 的宏伟主张是“一次编写,到处运行”。这是通过为几乎所有平台创建 Java 运行时环境(JRE)的实现来实现的,因此 Java 编译器(javac工具)从源代码生成的一次字节码可以在安装了 JRE 的任何地方执行,前提是编译器javac的版本与 JRE 的版本兼容。
JRE 的最初版本主要是字节码的解析器,其性能比 C 和 C++等一些语言及其编译器要慢。然而,随着时间的推移,JRE 得到了显著改进,现在能够产生相当不错的成果,与许多其他流行的系统相当。在很大程度上,这归功于即时编译器(JIT),它将最常用方法的字节码转换为本地代码。一旦生成,编译的方法(特定平台的机器代码)在需要时执行,无需任何解释,从而减少了执行时间。
为了利用这种方法,JRE 需要一些时间来确定应用程序中使用最频繁的方法。在这个编程领域工作的人称它们为“热点方法”。这个发现期,直到达到峰值性能,通常被称为 JVM 的预热时间。对于更大、更复杂的 Java 应用程序来说,这个时间更长,而对于较小的应用程序来说,可能只有几秒钟。然而,即使达到了峰值性能,由于特定的输入,应用程序可能会开始使用之前从未使用过的执行路径,调用尚未编译的方法,从而突然降低性能。当尚未编译的代码属于在罕见的关键情况下调用的复杂过程时,这可能会特别严重,这正是需要最佳性能的时候。
自然解决方案是允许程序员决定哪些应用程序组件需要预先编译成本地机器代码——那些使用频率较高的(从而减少应用程序的预热时间),以及那些虽然使用频率不高但需要尽可能快地执行(以支持关键情况和整体稳定性能)的组件。这就是Java 增强提案 JEP 295:提前编译的动机:
即时编译器速度快,但 Java 程序可能会变得非常大,以至于即时编译器完全预热需要很长时间。不常用的 Java 方法可能根本不会被编译,这可能会因为重复的解释调用而造成性能损失。
值得注意的是,即使在即时编译器(JIT compiler)中,通过设置编译阈值(即一个方法被调用多少次后才会编译成本地代码)也可以减少预热时间。默认情况下,这个数字是 1,500。因此,如果我们将其设置为低于这个数字,预热时间将会缩短。这可以通过使用java工具的-XX:CompileThreshold选项来实现。例如,我们可以将阈值设置为 500,如下所示(其中Test是包含main()方法的编译后的 Java 类):
java -XX:CompileThreshold=500 -XX:-TieredCompilation Test
选项-XX:-TieredCompilation被添加来禁用分层编译,因为它默认启用且不遵守编译阈值。可能的缺点是 500 的阈值可能太低,导致编译的方法太多,从而降低性能并增加预热时间。这个选项的最佳值会因应用程序而异,甚至可能取决于同一应用程序的特定数据输入。
静态编译与动态编译
许多高级编程语言,如 C 或 C++,从一开始就使用了 AOT 编译。它们也被称作静态编译语言。由于 AOT(或静态)编译器不受性能要求的限制(至少不像运行时的解释器那样受限制,也称为动态编译器),它们可以承担起花费时间产生复杂代码优化的成本。另一方面,静态编译器没有运行时(配置文件)数据,这在动态类型语言的情况下尤其受限,Java 就是其中之一。由于 Java 中动态类型的能力——向下转型到子类型、查询对象以获取其类型以及其他类型操作——是面向对象编程(多态原则)的支柱,因此 Java 的 AOT 编译变得更加有限。Lambda 表达式对静态编译又提出了另一个挑战,并且目前尚不支持。
动态编译器的另一个优点是它可以做出假设并相应地优化代码。如果假设被证明是错误的,编译器可以尝试另一个假设,直到达到性能目标。这样的过程可能会减慢应用程序的速度和/或增加预热时间,但长期来看可能会带来更好的性能。基于配置文件的优化可以帮助静态编译器沿着这条路径前进,但它与动态编译器相比,在优化的机会上始终有限。
话虽如此,我们不应该对当前 JDK 9 中的 AOT(即时编译)实现是实验性的以及仅限于 64 位 Linux 系统感到惊讶,因为目前它只支持并行或 G1 垃圾收集,并且唯一支持的模块是 java.base。此外,AOT 编译应该在执行生成的机器代码的同一系统或配置相同的系统上执行。尽管如此,JEP 295 中声明:
性能测试表明,一些应用程序从 AOT 编译的代码中受益,而其他应用程序则明显出现性能下降。
值得注意的是,AOT 编译在 Java 微版(ME)中已经得到了长期支持,但 Java 标准版(SE)中 AOT 的用例尚未确定,这也是实验性 AOT 实现在 JDK 9 中发布的原因之一——为了便于社区尝试并报告实际需求。
AOT 命令和过程
JDK 9 中底层的 AOT 编译基于 Oracle 项目 Graal——这是一个与 JDK 8 一起引入的开源编译器,旨在提高 Java 动态编译器的性能。AOT 小组不得不对其进行修改,主要围绕常量处理和优化。他们还添加了概率配置文件和特殊的内联策略,从而使 Graal 更适合静态编译。
除了现有的编译工具 javac 之外,JDK 9 安装中还包括一个新的 jaotc 工具。使用 libelf 库生成的 AOT 共享库 .so 文件。该依赖关系将在未来的版本中删除。
要开始 AOT 编译,用户必须启动 jaotc 并指定需要编译的类、JAR 文件或模块。输出库(包含生成的机器代码)的名称也可以作为 jaotc 参数传递。如果没有指定,输出将默认为 unnamed.so。以下是一个例子,看看 AOT 编译器如何与 HelloWorld 类一起工作:
public class HelloWorld {
public static void main(String... args) {
System.out.println("Hello, World!");
}
}
首先,我们将使用 javac 生成字节码并生成 HelloWorld.class:
javac HelloWorld.java
然后,我们将使用 HelloWorld.class 文件中的字节码将机器代码生成到库 libHelloWorld.so 中:
jaotc --output libHelloWorld.so HelloWorld.class
现在,我们可以使用 java 工具和 -XX:AOTLibrary 选项执行生成的库(在 jaotc 执行的平台具有相同规范的情况下):
java -XX:AOTLibrary=./libHelloWorld.so HelloWorld
-XX:AOTLibrary 选项允许我们通过逗号分隔列出几个 AOT 库。
注意,java 工具除了需要所有应用程序的字节码外,还需要其一些组件的原生代码。这一事实削弱了静态编译的所谓优势,一些 AOT 爱好者声称它可以更好地保护代码免遭反编译。在将来,如果相同的类或方法已经在 AOT 库中,字节码可能不再需要在运行时使用。然而,截至今天,情况并非如此。
要查看是否使用了 AOT 编译的方法,可以添加 -XX:+PrintAOT 选项:
java -XX:AOTLibrary=./libHelloWorld.so -XX:+PrintAOT HelloWorld
它将允许你在输出中看到加载的行 ./libHelloWorld.so AOT 库。
如果一个类的源代码已更改但未通过 jaotc 工具推送到 AOT 库中,JVM 将在运行时注意到这一点,因为每个编译类的指纹都存储在 AOT 库中与其原生代码一起。然后 JIT 将忽略 AOT 库中的代码,而使用字节码。
JDK 9 中的 java 工具支持一些其他与 AOT 相关的标志和选项:
-
-XX:+/-UseAOT告诉 JVM 使用或忽略 AOT 编译的文件(默认设置为使用 AOT) -
-XX:+/-UseAOTStrictLoading打开/关闭 AOT 严格加载;如果打开,它将指示 JVM 如果任何 AOT 库是在与当前运行时配置不同的配置平台上生成的,则退出。
JEP 295 描述了 jaotc 工具的命令格式如下:
jaotc <options> <name or list>
name 是类名或 JAR 文件。list 是冒号 : 分隔的类名、模块、JAR 文件或包含类文件的目录列表。options 是以下列表中的一个或多个标志:
-
--output <文件>: 这是输出文件名(默认为unnamed.so) -
--class-name <类名>: 这是需要编译的 Java 类列表 -
--jar <jar 文件>: 这是需要编译的 JAR 文件列表 -
--module <modules>: 这是要编译的 Java 模块列表 -
--directory <dirs>: 这是您可以搜索编译文件的目录列表 -
--search-path <dirs>: 这是搜索指定文件的目录列表 -
--compile-commands <file>: 这是包含编译命令的文件名;以下是一个示例:exclude sun.util.resources..*.TimeZoneNames_.*.getContents\(\)\[\[Ljava/lang/Object; exclude sun.security.ssl.* compileOnly java.lang.String.*
AOT 目前识别两个编译命令:
-
exclude: 排除指定方法的编译 -
compileOnly: 仅编译指定的方法
正则表达式用于指定类和方法,这里提到的有:
-
--compile-for-tiered: 为分层编译生成分析代码(默认情况下,不生成分析代码) -
--compile-with-assertions: 生成带有 Java 断言的代码(默认情况下,不生成断言代码) -
--compile-threads <number>: 这是要使用的编译线程数(默认情况下,为 16 和可用 CPU 数中的较小值) -
--ignore-errors: 忽略在类加载期间抛出的所有异常(默认情况下,如果类加载抛出异常,则退出编译) -
--exit-on-error: 在编译错误时退出(默认情况下,跳过失败的编译,而其他方法的编译继续) -
--info: 打印编译阶段的信息 -
--verbose: 打印编译阶段的更多详细信息 -
--debug: 打印更多详细信息 -
--help: 打印帮助信息 -
--version: 打印版本信息 -
-J<flag>: 将标志直接传递给 JVM 运行时系统
如我们之前提到的,一些应用程序可以使用 AOT 提高性能,而其他应用程序可能会变慢。只有测试才能为每个应用程序关于 AOT 有用性的问题提供明确的答案。在任何情况下,提高性能的一种方法是通过编译和使用java.base模块的 AOT 库:
jaotc --output libjava.base.so --module java.base
在运行时,AOT 初始化代码会在$JAVA_HOME/lib目录或由-XX:AOTLibrary选项列出的库中查找共享库。如果找到共享库,它们将被选中并使用。如果没有找到共享库,AOT 将被关闭。
摘要
在本课中,我们介绍了两个可以帮助开发者提高生产力的新工具(JShell 工具)以及如何通过监控了解瓶颈后提高 Java 应用程序性能(jaotc工具)。使用它们的示例和步骤将帮助您了解它们使用的优势,并在您决定尝试时让您入门。
在下一课中,我们将讨论如何使用命令行工具程序化地监控 Java 应用程序。我们还将探讨如何通过多线程提高应用程序性能,以及如何在监控了解瓶颈后调整 JVM 本身。
评估
-
________ 编译器将 Java 字节码转换为本地机器代码,以便生成的二进制文件可以本地执行。
-
以下哪个命令用于根据名称或 ID 删除引用的片段?
-
/d <name or id> -
/drop <name or id> -
/dr <name or id> -
/dp <name or id>
-
-
判断对错:Shell 是一种预编译工具,对于 Scala 和 Ruby 程序员来说非常知名。它接收用户输入,评估它,并在一段时间后返回结果。
-
以下哪个命令用于列出你在 JShell 中输入的源代码?
-
/l [<name or id>|-all|-start] -
/m [<name or id>|-all|-start]L -
/t [<name or id>|-all|-start] -
/v [<name or id>|-all|-start]
-
-
以下哪个正则表达式在类加载过程中忽略所有抛出的异常?
-
--exit-on-error -
–ignores-errors -
--ignore-errors -
--exits-on-error
-
第三章。多线程和响应式编程
在本课中,我们将探讨一种通过编程方式在几个工作者之间分割任务以支持应用程序高性能的方法。这就是 4500 年前建造金字塔的方式,这种方法自那时以来一直有效。但是,对可以召集到同一项目上的劳工数量有一个限制。共享资源为劳动力增加的上限提供了一个天花板,无论是按平方英尺和加仑(如金字塔时代的居住区和水源)计算,还是按千兆字节和千兆赫兹(如计算机的内存和处理能力)计算。
生活空间和计算机内存的分配、使用和限制非常相似。然而,我们对人力和 CPU 的处理能力有不同的感知。历史学家告诉我们,成千上万的古埃及人在同一时间从事切割和移动巨大的石块。即使我们知道这些工人一直在轮换,其中一些人暂时休息或处理其他事务,然后回来替换完成年度任务的人,其他人死亡或受伤并被新招募的工人替换,我们也不会对他们的意思有任何疑问。
但在计算机数据处理的情况下,当我们听到关于同时执行的工作线程时,我们自然会假设它们确实按照编程的方式并行执行。只有当我们揭开这样的系统的盖子后,我们才意识到这种并行处理只有在每个线程由不同的 CPU 执行时才可能。否则,它们共享相同的处理能力,我们之所以认为它们同时工作,仅仅是因为它们使用的时隙非常短——只是我们日常生活中所用时间单位的一小部分。当线程共享相同的资源时,在计算机科学中我们说它们是并发执行的。
在本课中,我们将讨论通过使用并发处理数据的工作者(线程)来提高 Java 应用程序性能的方法。我们将展示如何通过池化线程来有效地使用线程,如何同步访问的数据,如何在运行时监控和调整工作者线程,以及如何利用响应式编程的概念。
但在这样做之前,让我们回顾一下在同一个 Java 进程中创建和运行多个线程的基本知识。
先决条件
创建工作者线程主要有两种方式——通过扩展java.lang.Thread类和通过实现java.lang.Runnable接口。当我们扩展java.lang.Thread类时,我们不需要实现任何内容:
class MyThread extends Thread {
}
我们的MyThread类继承了具有自动生成值的name属性和start()方法。我们可以运行这个方法并检查name:
System.out.print("demo_thread_01(): ");
MyThread t1 = new MyThread();
t1.start();
System.out.println("Thread name=" + t1.getName());
如果我们运行这段代码,结果将如下所示:

如你所见,生成的name是Thread-0。如果我们在这个 Java 进程中创建另一个线程,name将是Thread-1,依此类推。start()方法什么都不做。源代码显示,如果实现了run()方法,它会调用这个方法。
我们可以像下面这样向MyThread类添加任何其他方法:
class MyThread extends Thread {
private double result;
public MyThread(String name){ super(name); }
public void calculateAverageSqrt(){
result = IntStream.rangeClosed(1, 99999)
.asDoubleStream()
.map(Math::sqrt)
.average()
.getAsDouble();
}
public double getResult(){ return this.result; }
}
calculateAverageSqrt()方法计算前 99999 个整数的平均平方根,并将结果分配给一个可以在任何时候访问的属性。以下代码演示了我们可以如何使用它:
System.out.print("demo_thread_02(): ");
MyThread t1 = new MyThread("Thread01");
t1.calculateAverageSqrt();
System.out.println(t1.getName() + ": result=" + t1.getResult());
运行这段代码会得到以下结果:

正如你所预期的那样,calculateAverageSqrt()方法会阻塞,直到计算完成。它在主线程中执行,没有利用多线程的优势。为了做到这一点,我们将run()方法中的功能移动:
class MyThread01 extends Thread {
private double result;
public MyThread01(String name){ super(name); }
public void run(){
result = IntStream.rangeClosed(1, 99999)
.asDoubleStream()
.map(Math::sqrt)
.average()
.getAsDouble();
}
public double getResult(){ return this.result; }
}
现在我们再次调用start()方法,就像第一个例子一样,并期望得到计算结果:
System.out.print("demo_thread_03(): ");
MyThread01 t1 = new MyThread01("Thread01");
t1.start();
System.out.println(t1.getName() + ": result=" + t1.getResult());
然而,这段代码的输出可能会让你感到惊讶:

这意味着主线程在新的t1线程完成计算之前就访问(并打印)了t1.getResult()函数。我们可以通过实验和修改run()方法的实现来查看t1.getResult()函数是否可以得到部分结果:
public void run() {
for (int i = 1; i < 100000; i++) {
double s = Math.sqrt(1\. * i);
result = result + s;
}
result = result / 99999;
}
然而,如果我们再次运行demo_thread_03()方法,结果仍然是相同的:

创建一个新的线程并让它开始运行需要时间。同时,main线程立即调用t1.getResult()函数,因此还没有得到任何结果。
为了给新的(子)线程足够的时间来完成计算,我们添加以下代码:
try {
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
你已经注意到我们暂停了主线程 100 毫秒,并添加了打印当前线程名称,以说明我们所说的“主线程”,即自动分配给执行main()方法的线程的名称。前一段代码的输出如下:

100 毫秒的延迟足以让t1线程完成计算。这是创建多线程计算线程的两种方法中的第一种。第二种方法是实现Runnable接口。如果执行计算的类已经扩展了其他某个类,并且由于某些原因不能或不想使用组合,这可能就是唯一可行的方法。Runnable接口是一个功能接口(只有一个抽象方法),其中包含必须实现的run()方法:
@FunctionalInterface
public interface Runnable {
/**
* When an object implementing interface <code>Runnable</code> is used
* to create a thread, starting the thread causes the object's
* <code>run</code> method to be called in that separately executing
* thread.
*/
public abstract void run();
我们在MyRunnable类中实现这个接口:
class MyRunnable01 implements Runnable {
private String id;
private double result;
public MyRunnable01(int id) {
this.id = String.valueOf(id);
}
public String getId() { return this.id; }
public double getResult() { return this.result; }
public void run() {
result = IntStream.rangeClosed(1, 99999)
.asDoubleStream()
.map(Math::sqrt)
.average()
.getAsDouble();
}
}
它具有与之前Thread01类相同的功能,我们还添加了一个 id,如果需要的话可以用来识别线程,因为Runnable接口没有像Thread类那样的内置getName()方法。
同样,如果我们像这样执行这个类而不暂停main线程:
System.out.print("demo_runnable_01(): ");
MyRunnable01 myRunnable = new MyRunnable01(1);
Thread t1 = new Thread(myRunnable);
t1.start();
System.out.println("Worker " + myRunnable.getId()
+ ": result=" + myRunnable.getResult());
输出将如下所示:

我们现在将添加暂停,如下所示:
System.out.print("demo_runnable_02(): ");
MyRunnable01 myRunnable = new MyRunnable01(1);
Thread t1 = new Thread(myRunnable);
t1.start();
try {
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Worker " + myRunnable.getId()
+ ": result=" + myRunnable.getResult());
结果与Thread01类产生的结果完全相同:

所有的前例都将生成的结果存储在类属性中。但情况并不总是如此。通常,工作线程要么将其值传递给另一个线程,要么将其存储在数据库或其他外部位置。在这种情况下,可以利用Runnable接口作为函数式接口,并将必要的处理函数作为 lambda 表达式传递给新线程:
System.out.print("demo_lambda_01(): ");
String id = "1";
Thread t1 =
new Thread(() -> IntStream.rangeClosed(1, 99999)
.asDoubleStream().map(Math::sqrt).average()
.ifPresent(d -> System.out.println("Worker "
+ id + ": result=" + d)));
t1.start();
try {
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
结果将完全相同,如下所示:

根据首选风格,你可以重新排列代码并将 lambda 表达式隔离在变量中,如下所示:
Runnable r = () -> IntStream.rangeClosed(1, 99999)
.asDoubleStream().map(Math::sqrt).average()
.ifPresent(d -> System.out.println("Worker "
+ id + ": result=" + d));
Thread t1 = new Thread(r);
或者,你也可以将 lambda 表达式放在一个单独的方法中:
void calculateAverage(String id) {
IntStream.rangeClosed(1, 99999)
.asDoubleStream().map(Math::sqrt).average()
.ifPresent(d -> System.out.println("Worker "
+ id + ": result=" + d));
}
void demo_lambda_03() {
System.out.print("demo_lambda_03(): ");
Thread t1 = new Thread(() -> calculateAverage("1"));
...
}
结果将和这里显示的一样:

在对线程创建的基本理解到位之后,我们现在可以回到关于使用多线程构建高性能应用的讨论。换句话说,在了解每个工作线程所需的能力和资源之后,我们现在可以讨论将众多线程引入如此大规模项目(如吉萨金字塔)的物流问题。
编写管理工作线程生命周期及其对共享资源访问的代码是可能的,但这在不同的应用中是相同的。这就是为什么在 Java 的几个版本之后,线程管理的基础设施成为了标准 JDK 库的一部分,即java.util.concurrent包。这个包包含大量支持多线程和并发的接口和类。我们将在后续章节中讨论如何使用这些功能的大部分,同时讨论线程池、线程监控、线程同步及相关主题。
线程池
在本节中,我们将探讨java.util.concurrent包中定义的Executor接口及其实现。它们封装了线程管理,并最小化了应用开发者编写与线程生命周期相关的代码所需的时间。
在java.util.concurrent包中定义了三个Executor接口。第一个是基本的Executor接口,其中只有一个void execute(Runnable r)方法。它基本上替换了以下内容:
Runnable r = ...;
(new Thread(r)).start()
然而,我们也可以通过从池中获取它来避免创建新线程。
第二个是ExecutorService接口,它扩展了Executor并添加了以下一组方法,用于管理工作线程和执行器本身的生命周期:
-
submit(): 将Runnable接口或Callable接口的对象放入队列以执行(允许工作线程返回一个值);返回Future接口的对象,可以用来访问Callable返回的值以及管理工作线程的状态 -
invokeAll(): 将一组接口Callable对象放入执行队列,当所有工作线程完成时返回Future对象列表(也有带超时的重载invokeAll()方法) -
invokeAny(): 将一组接口Callable对象放入执行队列;返回任意一个工作线程完成的Future对象(也有带超时的重载invokeAny()方法)
管理工作线程状态和服务的相关方法:
-
shutdown(): 防止将新工作线程提交到服务中 -
isShutdown(): 检查执行器的关闭是否被启动 -
awaitTermination(long timeout, TimeUnit timeUnit): 在发出关闭请求后等待所有工作线程完成执行,或者超时发生,或者当前线程被中断,以先发生者为准 -
isTerminated(): 检查在启动关闭后所有工作线程是否已完成;除非首先调用shutdown()或shutdownNow(),否则它永远不会返回true -
shutdownNow(): 中断所有未完成的工作线程;工作线程应该被编写成定期检查自己的状态(例如使用Thread.currentThread().isInterrupted()),并优雅地自行关闭;否则,即使在调用shutdownNow()之后,它也会继续运行
第三个接口是 ScheduledExecutorService,它扩展了 ExecutorService 并添加了允许调度工作线程执行(一次性或周期性)的方法。
可以使用 java.util.concurrent.ThreadPoolExecutor 或 java.util.concurrent.ScheduledThreadPoolExecutor 类创建基于池的 ExecutorService 实现。还有一个 java.util.concurrent.Executors 工厂类,涵盖了大多数实际案例。因此,在为工作线程池编写自定义代码之前,我们强烈建议查看 java.util.concurrent.Executors 类的以下工厂方法:
-
newSingleThreadExecutor(): 创建一个ExecutorService(池)实例,按顺序执行工作线程 -
newFixedThreadPool(): 创建一个线程池,重用固定数量的工作线程;如果所有工作线程仍在执行时提交新任务,它将被放入队列中,直到有工作线程可用 -
newCachedThreadPool(): 创建一个线程池,根据需要添加新线程,除非在之前创建了空闲线程;空闲六十秒的线程将从缓存中移除 -
newScheduledThreadPool(): 创建一个固定大小的线程池,可以调度在给定延迟后运行或周期性执行的命令 -
newSingleThreadScheduledExecutor(): 创建一个单线程执行器,可以调度在给定延迟后运行或周期性执行的命令 -
newWorkStealingThreadPool():这创建了一个使用与ForkJoinPool相同的窃取工作机制的线程池,这对于工作线程生成其他线程的情况特别有用,例如在递归算法中。
这些方法都有重载版本,允许传入一个ThreadFactory,当需要时用于创建新线程。让我们通过代码示例看看这一切是如何工作的。
首先,我们创建一个实现Runnable的MyRunnable02类——我们未来的工作线程:
class MyRunnable02 implements Runnable {
private String id;
public MyRunnable02(int id) {
this.id = String.valueOf(id);
}
public String getId(){ return this.id; }
public void run() {
double result = IntStream.rangeClosed(1, 100)
.flatMap(i -> IntStream.rangeClosed(1, 99999))
.takeWhile(i ->
!Thread.currentThread().isInterrupted())
.asDoubleStream()
.map(Math::sqrt)
.average()
.getAsDouble();
if(Thread.currentThread().isInterrupted()){
System.out.println(" Worker " + getId()
+ ": result=ignored: " + result);
} else {
System.out.println(" Worker " + getId()
+ ": result=" + result);
}
}
注意这个实现与之前示例的重要区别——takeWhile(i -> !Thread.currentThread().isInterrupted())操作允许只要工作线程的状态没有被设置为中断(在调用shutdownNow()方法时发生),流就可以继续流动。一旦takeWhile()的谓词返回false(工作线程被中断),线程就会停止产生结果(仅忽略当前的result值)。在真实系统中,这相当于跳过将result值存储到数据库,例如。
值得注意的是,在前面的代码中使用interrupted()状态方法来检查线程状态可能会导致结果不一致。由于interrupted()方法返回正确的状态值然后清除线程状态,因此对该方法的第二次调用(或者在调用interrupted()方法之后的isInterrupted()方法的调用)总是返回false。
虽然在这个代码中不是这种情况,但我们想在这里提到一些开发者在实现工作线程中的try/catch块时可能会犯的错误。例如,如果工作线程需要暂停并等待中断信号,代码通常看起来像这样:
try {
Thread.currentThread().wait();
} catch (InterruptedException e) {}
// Do what has to be done
The better implementation is as follows:
try {
Thread.currentThread().wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Do what has to be done
join() method, we did not need to do that because that was the main code (the highest level code) that had to be paused.
现在,我们可以展示如何使用ExecutiveService池的缓存池实现来执行之前的MyRunnable02类(其他类型的线程池以类似方式使用)。首先,我们创建池,提交三个MyRunnable02类的实例以供执行,然后关闭池:
ExecutorService pool = Executors.newCachedThreadPool();
IntStream.rangeClosed(1, 3).
forEach(i -> pool.execute(new MyRunnable02(i)));
System.out.println("Before shutdown: isShutdown()="
+ pool.isShutdown() + ", isTerminated()="
+ pool.isTerminated());
pool.shutdown(); // New threads cannot be submitted
System.out.println("After shutdown: isShutdown()="
+ pool.isShutdown() + ", isTerminated()="
+ pool.isTerminated());
如果我们运行这些行,我们将看到以下输出:

没有惊喜!在调用shutdown()方法之前,isShutdown()方法返回false值,之后返回true值。isTerminated()方法返回false值,因为还没有任何工作线程完成。
让我们在shutdown()方法之后添加以下代码来测试它:
try {
pool.execute(new MyRunnable02(100));
} catch(RejectedExecutionException ex){
System.err.println("Cannot add another worker-thread to the service queue:\n" + ex.getMessage());
}
现在的输出将包含以下消息(截图可能太大,无法适应这个页面,或者当适应页面时无法阅读):
Cannot add another worker-thread to the service queue:
Task com.packt.java9hp.ch09_threads.MyRunnable02@6f7fd0e6
rejected from java.util.concurrent.ThreadPoolExecutor
[Shutting down, pool size = 3, active threads = 3,
queued tasks = 0, completed tasks = 0]
如预期,在调用shutdown()方法之后,不能再向池中添加更多的工作线程。
现在,让我们看看在启动关闭之后我们能做什么:
long timeout = 100;
TimeUnit timeUnit = TimeUnit.MILLISECONDS;
System.out.println("Waiting for all threads completion "
+ timeout + " " + timeUnit + "...");
// Blocks until timeout or all threads complete execution
boolean isTerminated =
pool.awaitTermination(timeout, timeUnit);
System.out.println("isTerminated()=" + isTerminated);
if (!isTerminated) {
System.out.println("Calling shutdownNow()...");
List<Runnable> list = pool.shutdownNow();
printRunningThreadIds(list);
System.out.println("Waiting for threads completion "
+ timeout + " " + timeUnit + "...");
isTerminated =
pool.awaitTermination(timeout, timeUnit);
if (!isTerminated){
System.out.println("Some threads are running...");
}
System.out.println("Exiting.");
}
printRunningThreadIds()方法看起来像这样:
void printRunningThreadIds(List<Runnable> l){
String list = l.stream()
.map(r -> (MyRunnable02)r)
.map(mr -> mr.getId())
.collect(Collectors.joining(","));
System.out.println(l.size() + " thread"
+ (l.size() == 1 ? " is" : "s are") + " running"
+ (l.size() > 0 ? ": " + list : "") + ".");
}
上述代码的输出将如下所示:

这意味着每个工作线程有足够的时间完成计算。(注意,如果您尝试在您的计算机上重现这些数据,结果可能会有所不同,因为性能的差异,因此您需要调整超时时间。)
当我们将等待时间减少到 75 毫秒时,输出如下所示:

在我们的计算机上,75 毫秒不足以让所有线程完成,因此它们被shutdownNow()中断,并且它们的部分结果被忽略。
现在让我们从MyRunnable01类中移除对中断状态的检查:
class MyRunnable02 implements Runnable {
private String id;
public MyRunnable02(int id) {
this.id = String.valueOf(id);
}
public String getId(){ return this.id; }
public void run() {
double result = IntStream.rangeClosed(1, 100)
.flatMap(i -> IntStream.rangeClosed(1, 99999))
.asDoubleStream()
.map(Math::sqrt)
.average()
.getAsDouble();
System.out.println(" Worker " + getId()
+ ": result=" + result);
}
没有检查,即使我们将超时时间减少到 1 毫秒,结果也会如下所示:

这是因为工作线程从未注意到有人试图中断它们,并且已经完成了分配的计算。这个最后的测试展示了在工作线程中监视中断状态的重要性,以避免许多可能的问题,即数据损坏和内存泄漏。
展示的缓存池在工作线程执行短期任务且数量不会过大时运行良好,不会出现任何问题。如果您需要更严格控制任何时刻运行的最多工作线程数,请使用固定大小的线程池。我们将在本课的后续部分讨论如何选择池大小。
单线程池非常适合按特定顺序执行任务,或者当每个任务都需要如此多的资源,以至于不能与其他任务并行执行时。使用单线程执行的其他情况可能是,当工作线程修改相同的数据,但数据无法通过其他方式保护免受并行访问时。线程同步将在本课的后续部分更详细地讨论。
在我们的示例代码中,到目前为止,我们只包括了Executor接口的execute()方法。我们将在本课的后续部分讨论线程监控时演示ExecutorService池的其他方法。
本节的最后一点。工作线程不一定是同一类的对象。它们可能代表完全不同的功能,但仍可由一个池管理。
监视线程
监视线程有两种方式,一种是程序化方式,另一种是使用外部工具。我们已经看到了如何检查工作计算的结果。让我们回顾一下那段代码。我们还将稍微修改我们的工作实现:
class MyRunnable03 implements Runnable {
private String name;
private double result;
public String getName(){ return this.name; }
public double getResult() { return this.result; }
public void run() {
this.name = Thread.currentThread().getName();
double result = IntStream.rangeClosed(1, 100)
.flatMap(i -> IntStream.rangeClosed(1, 99999))
.takeWhile(i -> !Thread.currentThread().isInterrupted())
.asDoubleStream().map(Math::sqrt).average().getAsDouble();
if(!Thread.currentThread().isInterrupted()){
this.result = result;
}
}
}
对于工作线程的标识,我们不再使用自定义 ID,而是现在使用在执行时自动分配的线程名(这就是为什么我们在run()方法中分配name属性,该方法在执行上下文中被调用,当线程获得其名称时)。新的类MyRunnable03可以这样使用:
void demo_CheckResults() {
ExecutorService pool = Executors.newCachedThreadPool();
MyRunnable03 r1 = new MyRunnable03();
MyRunnable03 r2 = new MyRunnable03();
pool.execute(r1);
pool.execute(r2);
try {
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Worker " + r1.getName() + ": result=" + r1.getResult());
System.out.println("Worker " + r2.getName() + ": result=" + r2.getResult());
shutdown(pool);
}
shutdown()方法包含以下代码:
void shutdown(ExecutorService pool) {
pool.shutdown();
try {
if(!pool.awaitTermination(1, TimeUnit.SECONDS)){
pool.shutdownNow();
}
} catch (InterruptedException ie) {}
}
如果我们运行前面的代码,输出将如下所示:

如果您计算机上的结果不同,请尝试增加 sleepMs() 方法的输入值。
另一种获取应用程序工作线程信息的方法是使用 Future 接口。我们可以通过 ExecutorService 池的 submit() 方法访问此接口,而不是使用 execute()、invokeAll() 或 invokeAny() 方法。以下代码展示了如何使用 submit() 方法:
ExecutorService pool = Executors.newCachedThreadPool();
Future f1 = pool.submit(new MyRunnable03());
Future f2 = pool.submit(new MyRunnable03());
printFuture(f1, 1);
printFuture(f2, 2);
shutdown(pool);
printFuture() 方法具有以下实现:
void printFuture(Future future, int id) {
System.out.println("printFuture():");
while (!future.isCancelled() && !future.isDone()){
System.out.println(" Waiting for worker "
+ id + " to complete...");
sleepMs(10);
}
System.out.println(" Done...");
}
sleepMs() 方法包含以下代码:
void sleepMs(int sleepMs) {
try {
TimeUnit.MILLISECONDS.sleep(sleepMs);
} catch (InterruptedException e) {}
}
我们更喜欢这种实现方式而不是传统的 Thread.sleep(),因为它明确指出了使用的时间单位。
如果我们执行前面的代码,结果将类似于以下内容:

printFuture() 方法阻塞了主线程的执行,直到第一个线程完成。同时,第二个线程也已经完成。如果我们调用 printFuture() 方法在 shutdown() 方法之后,到那时两个线程都已经完成了,因为我们已经设置了 1 秒的等待时间(见 pool.awaitTermination() 方法),这对于它们完成工作来说是足够的:

如果您认为从线程监控的角度来看信息不多,java.util.concurrent 包通过 Callable 接口提供了更多功能。它是一个函数式接口,允许通过 ExecutiveService 方法(submit()、invokeAll() 和 invokeAny())使用 Future 对象返回任何对象(包含工作线程计算的结果)。例如,我们可以创建一个包含工作线程结果的类:
class Result {
private double result;
private String workerName;
public Result(String workerName, double result) {
this.result = result;
this.workerName = workerName;
}
public String getWorkerName() { return workerName; }
public double getResult() { return result;}
}
我们还包含了工作线程的名称,以便监控哪个线程生成了展示的结果。实现 Callable 接口的类可能看起来像这样:
class MyCallable01<T> implements Callable {
public Result call() {
double result = IntStream.rangeClosed(1, 100)
.flatMap(i -> IntStream.rangeClosed(1, 99999))
.takeWhile(i -> !Thread.currentThread().isInterrupted())
.asDoubleStream().map(Math::sqrt).average().getAsDouble();
String workerName = Thread.currentThread().getName();
if(Thread.currentThread().isInterrupted()){
return new Result(workerName, 0);
} else {
return new Result(workerName, result);
}
}
}
下面是使用 MyCallable01 类的代码:
ExecutorService pool = Executors.newCachedThreadPool();
Future f1 = pool.submit(new MyCallable01<Result>());
Future f2 = pool.submit(new MyCallable01<Result>());
printResult(f1, 1);
printResult(f2, 2);
shutdown(pool);
printResult() 方法包含以下代码:
void printResult(Future<Result> future, int id) {
System.out.println("printResult():");
while (!future.isCancelled() && !future.isDone()){
System.out.println(" Waiting for worker "
+ id + " to complete...");
sleepMs(10);
}
try {
Result result = future.get(1, TimeUnit.SECONDS);
System.out.println(" Worker "
+ result.getWorkerName() + ": result = "
+ result.getResult());
} catch (Exception ex) {
ex.printStackTrace();
}
}
此代码的输出可能如下所示:

早期输出显示,与前面的示例一样,printResult() 方法等待第一个工作线程完成,因此第二个线程设法同时完成其工作。如您所见,使用 Callable 的优点是,如果我们需要,我们可以从 Future 对象中检索实际结果。
invokeAll() 和 invokeAny() 方法的用法看起来相似:
ExecutorService pool = Executors.newCachedThreadPool();
try {
List<Callable<Result>> callables =
List.of(new MyCallable01<Result>(),
new MyCallable01<Result>());
List<Future<Result>> futures =
pool.invokeAll(callables);
printResults(futures);
} catch (InterruptedException e) {
e.printStackTrace();
}
shutdown(pool);
printResults() 方法正在使用您已经知道的 printResult() 方法:
void printResults(List<Future<Result>> futures) {
System.out.println("printResults():");
int i = 1;
for (Future<Result> future : futures) {
printResult(future, i++);
}
}
如果我们运行前面的代码,输出将如下所示:

如您所见,不再需要等待工作线程完成工作。这是因为 invokeAll() 方法在所有工作完成后返回 Future 对象的集合。
invokeAny()方法的行为类似。如果我们运行以下代码:
System.out.println("demo_InvokeAny():");
ExecutorService pool = Executors.newCachedThreadPool();
try {
List<Callable<Result>> callables =
List.of(new MyCallable01<Result>(),
new MyCallable01<Result>());
Result result = pool.invokeAny(callables);
System.out.println(" Worker "
+ result.getWorkerName()
+ ": result = " + result.getResult());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
shutdown(pool);
下面的将是输出:

这些是程序化监控线程的基本技术,但可以轻松扩展我们的示例以涵盖更复杂的案例,这些案例针对特定应用程序的需求。在第 5 课,利用新 API 改进代码中,我们还将讨论使用在 JDK 8 中引入并在 JDK 9 中扩展的java.util.concurrent.CompletableFuture类以程序化方式监控工作线程的另一种方法。
如果需要,可以使用java.lang.Thread类获取有关应用程序工作线程的信息,以及 JVM 进程中的所有其他线程:
void printAllThreads() {
System.out.println("printAllThreads():");
Map<Thread, StackTraceElement[]> map = Thread.getAllStackTraces();
for(Thread t: map.keySet()){
System.out.println(" " + t);
}
现在,让我们按如下方式调用此方法:
void demo_CheckResults() {
ExecutorService pool = Executors.newCachedThreadPool();
MyRunnable03 r1 = new MyRunnable03();
MyRunnable03 r2 = new MyRunnable03();
pool.execute(r1);
pool.execute(r2);
sleepMs(1000);
printAllThreads();
shutdown(pool);
}
结果看起来像这样:

我们利用了Thread类的toString()方法,该方法只打印线程名称、优先级以及它所属的线程组。我们看到在名称为pool-1-thread-1和pool-1-thread-2的列表下,我们明确创建的两个应用程序线程(除了main线程)。但是,如果我们调用shutdown()方法之后的printAllThreads()方法,输出将如下所示:

我们在列表中不再看到pool-1-thread-1和pool-1-thread-2线程,因为ExecutorService池已被关闭。
我们可以轻松地添加从同一映射中提取的堆栈跟踪信息:
void printAllThreads() {
System.out.println("printAllThreads():");
Map<Thread, StackTraceElement[]> map
= Thread.getAllStackTraces();
for(Thread t: map.keySet()){
System.out.println(" " + t);
for(StackTraceElement ste: map.get(t)){
System.out.println(" " + ste);
}
}
}
然而,这将在书页上占用太多空间。在第 5 课中,当我们介绍 JDK 9 带来的新 Java 功能时,我们还将讨论通过java.lang.StackWalker类访问堆栈跟踪的更好方法。
Thread类对象有几个其他方法,可以提供有关线程的信息,如下所示:
-
dumpStack(): 这将堆栈跟踪打印到标准错误流 -
enumerate(Thread[] arr): 这将当前线程的线程组及其子组中的活动线程复制到指定的数组arr -
getId(): 这提供了线程的 ID -
getState(): 这读取线程的状态;enum Thread.State的可能值可以是以下之一:-
NEW: 这是尚未启动的线程 -
RUNNABLE: 这是当前正在执行的线程 -
BLOCKED: 这是等待监视器锁释放而被阻塞的线程 -
WAITING: 这是等待中断信号的线程 -
TIMED_WAITING: 这是等待指定等待时间中断信号的线程 -
TERMINATED: 这是已经退出的线程
-
-
holdsLock(Object obj): 这表示线程是否持有指定对象的监视器锁 -
interrupted()或isInterrupted(): 这表示线程是否被中断(收到中断信号,意味着中断标志被设置为true) -
isAlive(): 这表示线程是否存活 -
isDaemon(): 这表示线程是否是守护线程。
java.lang.management 包为监控线程提供了类似的功能。让我们运行这个代码片段,例如:
void printThreadsInfo() {
System.out.println("printThreadsInfo():");
ThreadMXBean threadBean =
ManagementFactory.getThreadMXBean();
long ids[] = threadBean.getAllThreadIds();
Arrays.sort(ids);
ThreadInfo[] tis = threadBean.getThreadInfo(ids, 0);
for (ThreadInfo ti : tis) {
if (ti == null) continue;
System.out.println(" Id=" + ti.getThreadId()
+ ", state=" + ti.getThreadState()
+ ", name=" + ti.getThreadName());
}
}
为了更好的展示,我们利用了线程 ID 的列表,并且,如你之前所见,我们按 ID 对输出进行了排序。如果我们调用 shutdown() 方法之前的 printThreadsInfo() 方法,输出将如下所示:

然而,如果我们调用 shutdown() 方法之后的 printThreadsInfo() 方法,输出将不再包括我们的工作线程,这与使用 Thread 类 API 的情况完全相同:

java.lang.management.ThreadMXBean 接口提供了许多关于线程的其他有用数据。你可以参考 Oracle 网站上关于此接口的官方 API 获取更多信息,请查看此链接:docs.oracle.com/javase/8/docs/api/index.html?java/lang/management/ThreadMXBean.html。
在前面提到的线程列表中,你可能已经注意到了 Monitor Ctrl-Break 线程。这个线程为在 JVM 进程中监控线程提供了另一种方式。在 Windows 上按下 Ctrl 和 Break 键会导致 JVM 将线程转储打印到应用程序的标准输出。在 Oracle Solaris 或 Linux 操作系统上,同样的效果可以通过 Ctrl 键和反斜杠 ** 组合实现。这使我们来到了用于线程监控的外部工具。
如果你无法访问源代码或更喜欢使用外部工具进行线程监控,JDK 安装中提供了几个诊断工具。在以下列表中,我们只提到了允许进行线程监控的工具,并且只描述了这些工具的这一功能(尽管它们还有其他广泛的功能):
-
jcmd工具通过 JVM 进程 ID 或主类名向同一台机器上的 JVM 发送诊断命令请求:jcmd <process id/main class> <command> [options],其中Thread.print选项打印进程中所有线程的堆栈跟踪。 -
JConsole 监控工具使用 JVM 内置的 JMX 仪器来提供有关运行应用程序的性能和资源消耗的信息。它有一个线程选项卡,显示了随时间变化的线程使用情况、当前活动线程数以及自 JVM 启动以来最高活动线程数。可以选择线程及其名称、状态和堆栈跟踪,以及对于阻塞线程,线程等待获取的同步器以及拥有锁的线程。使用 死锁检测 按钮来识别死锁。运行此工具的命令是
jconsole <进程 ID>或(对于远程应用程序)jconsole <主机名>:<端口号>,其中端口号是 JVM 启动命令中指定的启用 JMX 代理的端口号。 -
jdb实用程序是一个示例命令行调试器。它可以附加到 JVM 进程,并允许您检查线程。 -
jstack命令行实用程序可以附加到 JVM 进程,并打印所有线程的堆栈跟踪,包括 JVM 内部线程,以及可选的本地堆栈帧。它还允许检测死锁。 -
Java 飞行记录器(JFR)提供了有关 Java 进程的信息,包括等待锁的线程、垃圾回收等。它还允许获取线程转储,这些转储类似于由
Thread.print诊断命令或使用 jstack 工具生成的转储。如果满足条件,可以设置 Java 任务控制(JMC)以转储飞行记录。JMC UI 包含有关线程、锁竞争和其他延迟的信息。尽管 JFR 是一个商业功能,但在开发人员桌面/笔记本电脑以及测试、开发和生产环境中的评估目的上是免费的。
注意
您可以在官方 Oracle 文档中找到有关这些和其他诊断工具的更多详细信息,网址为 docs.oracle.com/javase/9/troubleshoot/diagnostic-tools.htm。
线程池执行器大小
在我们的示例中,我们使用了一个缓存线程池,根据需要创建新线程,或者如果可用,则重用已经使用但已完成任务的线程,并将其返回到池中以进行新的分配。我们不必担心创建过多的线程,因为我们的演示应用程序最多只有两个工作线程,而且它们的生命周期相当短。
但在应用没有固定的工作线程数量限制,或者没有好的方法来预测线程可能占用的内存量或执行时间的情况下,对工作线程数量设置上限可以防止应用性能意外下降、内存耗尽或任何其他工作线程使用的资源耗尽。如果线程行为极不可预测,单个线程池可能是唯一的解决方案,可以选择使用自定义线程池执行器(关于这个选项的更多内容将在后面解释)。但在大多数情况下,固定大小的线程池执行器是在应用需求和代码复杂度之间的一种良好的实用折衷方案。根据具体要求,这样的执行器可能是以下三种类型之一:
-
一个简单、固定大小的
ExecutorService.newFixedThreadPool(int nThreads)线程池,它不会超过指定的大小,但也不会采用其他方式 -
几个允许使用不同延迟或执行周期调度不同线程组的
ExecutorService.newScheduledThreadPool(int nThreads)线程池 -
ExecutorService.newWorkStealingPool(int parallelism),它适应指定的 CPU 数量,你可以将其设置为高于或低于你电脑上实际 CPU 数量的值
在任何先前的池中设置固定大小过低可能会剥夺应用有效利用可用资源的机会。因此,在选择池大小之前,建议花些时间对其进行监控和调整 JVM(参见本课程的一个部分中如何操作),目标是识别应用行为的特殊性。实际上,部署-监控-调整的周期必须在应用生命周期内重复进行,以便适应并利用代码或执行环境中的变化。
你首先考虑的第一个参数是系统中的 CPU 数量,因此线程池大小至少应该与 CPU 的数量一样大。然后,你可以监控应用,看看每个线程占用 CPU 的时间以及它使用其他资源(如 I/O 操作)的时间。如果未使用 CPU 的时间与线程的总执行时间相当,那么你可以通过未使用 CPU 的时间/总执行时间来增加池大小。但这是在另一个资源(磁盘或数据库)不是线程之间的争用对象的情况下。如果后者是情况,那么你可以使用该资源而不是 CPU 作为划分因素。
假设您的应用程序的工作线程不是太大或执行时间太长,属于典型工作线程的主流人群,它们在合理短的时间内完成工作,您可以通过添加(向上取整)所需响应时间与线程使用 CPU 或其他最具有争议性资源的时间的比率来增加池大小。这意味着,在相同的所需响应时间下,线程使用 CPU 或其他并发访问的资源越少,池大小应该越大。如果争议性资源具有提高并发访问能力的能力(如数据库中的连接池),首先考虑利用该功能。
如果在运行时由于不同情况,同时运行的线程数发生变化,您可以使池大小动态,并在所有线程完成后关闭旧池,创建一个新的具有新大小的池。在添加或删除可用资源后,可能还需要重新计算新池的大小。您可以使用Runtime.getRuntime().availableProcessors()根据当前可用的 CPU 数量程序化地调整池大小,例如。
如果 JDK 附带的可用线程池执行器实现没有满足特定应用程序的需求,在从头编写线程管理代码之前,首先尝试使用java.util.concurrent.ThreadPoolExecutor类。它有几个重载的构造函数。
为了让您了解其功能,以下是一个具有最多选项的构造函数:
ThreadPoolExecutor (int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)
之前提到的参数如下(引用自 JavaDoc):
-
corePoolSize: 这是池中要保留的线程数,即使它们处于空闲状态,除非设置了allowCoreThreadTimeOut -
maximumPoolSize: 这是池中允许的最大线程数 -
keepAliveTime: 当线程数大于核心数时,这是超出空闲线程等待新任务以终止的最大时间 -
unit: 这是keepAliveTime参数的时间单位 -
workQueue: 这是用于在执行之前持有任务的队列,此队列将仅保留通过execute方法提交的Runnable任务 -
threadFactory: 这是在执行器创建新线程时使用的工厂 -
handler: 当执行因为线程边界和队列容量达到而阻塞时,这是要使用的处理器
除了workQueue参数之外,之前的构造函数参数也可以在创建ThreadPoolExecutor对象后通过相应的 setter 进行设置,从而使得对现有池特性的动态调整更加灵活。
线程同步
我们已经收集了足够的人力和资源,如食物、水和工具,用于金字塔建设。我们将人员分成团队,并为每个团队分配一项任务。有一群人(一个池)住在附近的村庄,处于待命状态,随时准备替换那些在任务中生病或受伤的人。我们调整了劳动力数量,以确保只有少数人将在村庄中闲置。我们通过工作-休息周期轮换团队,以保持项目以最大速度进行。我们监控了整个过程,并调整了团队数量和所需供应品的流动,以确保没有明显的延误,整个项目有稳定的可测量进度。然而,整体上有很多移动部件,以及各种大小不一的意外事件和问题不断发生。
为了确保工人和团队不会相互干扰,并且存在某种交通规则,以便在上一阶段完成之前不会开始下一技术阶段,主要建筑师派遣他的代表到建筑工地的所有关键点。这些代表确保任务以预期的质量按规定的顺序执行。他们有权在上一团队未完成之前阻止下一团队开始工作。他们就像交通警察或可以关闭或允许进入工作场所的锁,如果/当必要时。
这些代表所做的工作可以用现代语言定义为执行单元动作的协调或同步。没有它,成千上万工人努力的成果将是不可预测的。从万米高空看的大局将看起来平稳和谐,就像从飞机窗户看到的农民田地。但如果没有更仔细的检查和对关键细节的关注,这个看似完美的画面可能会带来微薄的收成,如果有的话。
同样,在多线程执行环境的安静电子空间中,如果工作线程共享对同一工作场所的访问,它们必须进行同步。例如,让我们为线程创建以下类-工作者:
class MyRunnable04 implements Runnable {
private int id;
public MyRunnable04(int id) { this.id = id; }
public void run() {
IntStream.rangeClosed(1, 5)
.peek(i -> System.out.println("Thread "+id+": "+ i))
.forEach(i -> Demo04Synchronization.result += i);
}
}
正如你所见,它按顺序将 1、2、3、4、5(因此,预期的结果是 15)添加到Demo04Synchronization类的静态属性中:
public class Demo04Synchronization {
public static int result;
public static void main(String... args) {
System.out.println();
demo_ThreadInterference();
}
private static void demo_ThreadInterference(){
System.out.println("demo_ThreadInterference: ");
MyRunnable04 r1 = new MyRunnable04(1);
Thread t1 = new Thread(r1);
MyRunnable04 r2 = new MyRunnable04(2);
Thread t2 = new Thread(r2);
t1.start();
sleepMs(100);
t2.start();
sleepMs(100);
System.out.println("Result=" + result);
}
private static void sleepMs(int sleepMs) {
try {
TimeUnit.MILLISECONDS.sleep(sleepMs);
} catch (InterruptedException e) {}
}
}
在早期的代码中,当主线程第一次暂停 100 毫秒时,线程t1将变量的值增加到 15,然后线程t2再增加 15,得到总和 30。以下是输出:

如果我们移除第一次 100 毫秒的暂停,线程将并发工作:

最终结果仍然是 30。我们对这段代码感到满意,并将其作为经过良好测试的代码部署到生产环境中。然而,如果我们将加数的数量从 5 增加到 250,例如,结果就会变得不稳定,并且每次运行的结果都不同。以下是第一次运行(我们为了节省空间,在每个线程中注释掉了打印输出):

这里是另一次运行的输出:

这证明了Demo04Synchronization.result += i操作不是原子的。这意味着它由几个步骤组成,从result属性中读取值,向其添加一个值,然后将结果和赋值回result属性。这允许以下场景,例如:
-
两个线程都读取了
result的当前值(因此每个线程都有一个相同的原始result值的副本) -
每个线程都向同一个原始整数添加另一个整数
-
第一个线程将和赋值给
result属性 -
第二个线程将其和赋值给
result属性
正如你所见,第二个线程并不知道第一个线程所做的加法,并覆盖了第一个线程分配给result属性的值。但这样的线程交错并不总是发生。这只是一种机会游戏。这就是为什么我们只使用五个数字时没有看到这样的效果。但随着并发动作数量的增加,这种情况发生的概率会增加。
在金字塔构建过程中也可能发生类似的情况。第二个团队可能在第一个团队完成他们的任务之前开始做某事。我们肯定需要一个同步器,它由synchronized关键字提供。使用它,我们可以在Demo04Synchronization类中创建一个方法(一个建筑师代表),该方法将控制对result属性的访问,并添加这个关键字:
private static int result;
public static synchronized void incrementResult(int i){
result += i;
}
现在我们还必须在工作线程的run()方法中进行修改:
public void run() {
IntStream.rangeClosed(1, 250)
.forEach(Demo04Synchronization::incrementResult);
}
现在的输出显示每次运行都有相同的最终数字:

synchronized关键字告诉 JVM 一次只允许一个线程进入这个方法。所有其他线程都将等待,直到当前访问者从该方法退出。
通过在代码块中添加synchronized关键字也可以达到相同的效果:
public static void incrementResult(int i){
synchronized (Demo04Synchronization.class){
result += i;
}
}
区别在于块同步需要一个对象——在静态属性同步的情况下(如我们的例子),这是一个类对象;在实例属性同步的情况下,可以是任何其他对象。每个对象都有一个内在的锁或监视器锁,通常简单地称为监视器。一旦一个线程获取了对一个对象的锁,其他线程就不能在同一个对象上获取锁,直到第一个线程在从锁定代码的正常退出后释放锁,或者如果代码抛出异常。
事实上,在同步方法的情况下,一个对象(方法所属的对象)也用于锁定。这只是在幕后自动发生,不需要程序员显式使用对象的锁。
如果您无法访问main类代码(如前面的示例所示),您可以保持result属性为公共的,并为工作线程添加一个同步方法(而不是像我们之前所做的那样添加到类中):
class MyRunnable05 implements Runnable {
public synchronized void incrementResult(int i){
Demo04Synchronization.result += i;
}
public void run() {
IntStream.rangeClosed(1, 250)
.forEach(this::incrementResult);
}
}
在这种情况下,MyRunnable05工作类的对象默认提供其内锁。这意味着,您需要为所有线程使用MyRunnable05类的相同对象:
void demo_Synchronized(){
System.out.println("demo_Synchronized: ");
MyRunnable05 r1 = new MyRunnable05();
Thread t1 = new Thread(r1);
Thread t2 = new Thread(r1);
t1.start();
t2.start();
sleepMs(100);
System.out.println("Result=" + result);
}
上述代码的输出与之前相同:

可以争论说,这种最后的实现更可取,因为它将同步的责任分配给了线程(及其代码的作者),而不是共享资源。这样,同步的需要会随着线程实现的发展而变化,前提是客户端代码(使用相同或不同对象进行线程的)也可以根据需要更改。
在某些操作系统中可能还存在另一个可能的并发问题。根据线程缓存如何实现,一个线程可能会保留属性result的本地副本,并且在另一个线程更改其值后不更新它。通过在共享(线程间的)属性上添加volatile关键字,可以保证其当前值始终从主内存中读取,因此每个线程都将看到其他线程所做的更新。在我们的前一个示例中,我们只是将Demo04Synchronization类属性设置为private static volatile int result,向同一类或线程添加一个同步的incrementResult()方法,就不再需要担心线程相互干扰了。
描述的线程同步通常对于主流应用程序来说是足够的。但是,更高的性能和高度并发的处理通常需要更仔细地查看线程转储,这通常表明方法同步比块同步更有效。当然,这也取决于方法的大小和块的大小。由于所有尝试访问同步方法或块的线程都将停止执行,直到当前访问方法或块的访问者退出它,因此,尽管存在开销,一个小同步块可能比大同步方法提供更好的性能。
对于某些应用程序,默认的内置锁的行为,即仅阻塞直到锁被释放,可能不适合。如果是这种情况,请考虑使用 java.util.concurrent.locks 包中的锁。与使用默认内置锁相比,基于该包中锁的访问控制有几个不同之处。这些差异可能对您的应用程序有利或提供不必要的复杂性,但了解它们很重要,这样您就可以做出明智的决定:
-
同步代码块不需要属于一个方法;它可以跨越多个方法,由对实现
Lock接口的对象调用lock()和unlock()方法(调用)界定 -
当创建一个名为
ReentrantLock的Lock接口对象时,可以将一个fair标志传递给构造函数,这使得锁能够首先授予等待时间最长的线程访问权限,这有助于避免饥饿(当低优先级线程永远无法获得锁时) -
允许线程在承诺被阻塞之前测试锁是否可访问
-
允许中断等待锁的线程,使其不会无限期地保持阻塞状态
-
您可以使用您应用程序需要的任何功能自行实现
Lock接口
Lock 接口的使用典型模式如下:
Lock lock = ...;
...
lock.lock();
try {
// the fragment that is synchronized
} finally {
lock.unlock();
}
...
}
注意到 finally 块。这是保证最终释放 lock 的方式。否则,try-catch 块内的代码可能会抛出异常,而锁永远不会被释放。
除了 lock() 和 unlock() 方法之外,Lock 接口还有以下方法:
-
lockInterruptibly(): 如果当前线程没有被中断,则获取锁。类似于lock()方法,该方法在等待直到获取锁时阻塞,与lock()方法不同的是,如果另一个线程中断了等待线程,则此方法会抛出InterruptedException异常 -
tryLock(): 如果在调用时锁是空闲的,则立即获取锁 -
tryLock(long time, TimeUnit unit): 如果在给定的等待时间内锁是空闲的,并且当前线程没有被中断,则获取锁 -
newCondition(): 这将返回一个与该Lock实例绑定的新Condition实例,在获取锁之后,线程可以释放它(在Condition对象上调用await()方法)直到其他线程在同一个Condition对象上调用signal()或signalAll(),也可以通过使用重载的await()方法指定超时时间,如果在此期间没有收到信号,线程将在超时后恢复,有关更多详细信息,请参阅ConditionAPI
本书范围不允许我们展示 java.util.concurrent.locks 包中提供的所有线程同步的可能性。描述所有这些功能需要几节课。但即使从这简短的描述中,你也可以看出,很难找到一个无法使用 java.util.concurrent.locks 包解决的同步问题。
当需要将几行代码作为一个原子(全部或无)操作隔离时,方法或代码块的同步才有意义。但在简单赋值给变量或数字的增减(如我们之前的例子所示)的情况下,使用 java.util.concurrent.atomic 包中的类来同步此操作是一个更好的方法,这些类支持对单个变量的无锁线程安全编程。这些类的多样性涵盖了所有数字,甚至包括数组以及如 AtomicBoolean、AtomicInteger、AtomicIntegerArray、AtomicReference 和 AtomicReferenceArray 这样的引用类型。
总共有 16 个类。根据值类型,每个类都允许一个完整的可想象的操作范围,即 set()、get()、addAndGet()、compareAndSet()、incrementAndGet()、decrementAndGet() 以及许多其他操作。每个操作都比使用 synchronized 关键字实现的相同操作更高效。而且不需要 volatile 关键字,因为它在底层使用它。
如果并发访问的资源是一个集合,java.util.concurrent 包提供了各种线程安全实现,这些实现比同步的 HashMap、Hashtable、HashSet、Vector 和 ArrayList(如果我们比较相应的 ConcurrentHashMap、CopyOnWriteArrayList 和 CopyOnWriteHashSet)表现更好。传统的同步集合锁定整个集合,而并发集合使用诸如锁剥离等高级技术来实现线程安全。并发集合在读取更多而更新较少的情况下特别出色,并且它们比同步集合具有更高的可伸缩性。但如果共享集合的大小较小且写入占主导地位,并发集合的优势并不那么明显。
调整 JVM
任何大型项目一样,每个金字塔建筑都经历了相同的设计、规划、执行和交付的生命周期。在整个这些阶段中,都在进行持续的调整,复杂项目之所以被称为复杂,是有原因的。在这一点上,软件系统并无不同。我们设计、规划并构建它,然后持续地进行更改和调整。如果我们幸运的话,新的更改不会退回到初始阶段,也不需要更改设计。为了防止这种极端的步骤,我们使用原型(如果使用瀑布模型)或迭代交付(如果采用敏捷流程)来早期发现可能的问题。就像年轻的父母一样,我们总是保持警惕,日夜监控我们孩子的进展。
正如我们在前面的某个部分提到的,每个 JDK 9 安装都附带了一些诊断工具,或者可以在它们的基础上使用,以监控你的 Java 应用程序。这些工具的完整列表(以及如果需要如何创建自定义工具的建议)可以在 Oracle 网站上找到的官方 Java SE 文档中找到:docs.oracle.com/javase/9/troubleshoot/diagnostic-tools.htm。
使用这些工具可以识别应用程序的瓶颈,并通过编程方式或调整 JVM 本身或两者兼之来解决它。最大的收益通常来自于良好的设计决策和使用某些编程技术和框架,其中一些我们在其他部分已经描述过。在本节中,我们将探讨在应用了所有可能的代码更改之后或更改代码不是选项时,可用的选项,因此我们所能做的就是调整 JVM 本身。
努力的目标取决于应用程序配置文件的结果以及以下非功能性需求:
-
延迟,即应用程序对输入的响应速度
-
吞吐量,即应用程序在给定时间单位内完成的工作量
-
内存占用,即应用程序所需的内存量
其中一个方面的改进通常只能以牺牲其他一个或两个方面的代价为前提。内存消耗的减少可能会降低吞吐量和延迟,而降低延迟通常只能通过增加内存占用来实现,除非你能引入更快的 CPU,从而提高所有三个特性。
应用程序配置文件可能显示某个特定操作在循环中持续分配大量内存。如果你可以访问代码,你可以尝试优化这段代码,从而减轻 JVM 的压力。或者,它可能显示涉及 I/O 或其他与低设备交互的情况,而在代码中你无法做任何事情来改善它。
定义应用程序和 JVM 调优的目标需要建立指标。例如,众所周知,将延迟的传统度量作为平均响应时间隐藏了比揭示的更多关于性能的信息。更好的延迟指标将是最大响应时间与 99%最佳响应时间的结合。对于吞吐量,一个好的指标将是单位时间内的交易数量。通常,这个指标的倒数(每笔交易的时间)密切反映了延迟。对于内存占用,最大分配的内存(在负载下)允许进行硬件规划,并设置防止可怕的OutOfMemoryError异常的防护措施。避免完全(停止世界)垃圾回收周期将是理想的。然而,在实践中,如果Full GC不经常发生,不会明显影响性能,并在几个周期后最终达到大约相同的堆大小,那就足够好了。
不幸的是,这种要求的简单性在实践中并不常见。现实生活总是带来更多的问题,如下所示:
-
目标延迟(响应时间)是否可能超过?
-
如果是,那么多频繁以及增加多少?
-
响应时间差的周期可以持续多长时间?
-
谁或什么在生产中测量延迟?
-
目标性能是峰值性能吗?
-
预期的峰值负载是多少?
-
预期的峰值负载将持续多长时间?
只有在回答了所有这些问题并建立了指标(反映非功能性要求)之后,我们才能开始调整代码,运行它,并反复进行性能分析,然后调整代码并重复循环。这项活动必须消耗大部分努力,因为与代码更改带来的性能提升相比,JVM 本身的调优只能带来一小部分性能提升。
然而,为了防止浪费努力并尝试在配置不当的环境中强制执行代码,必须尽早进行几次 JVM 调优。JVM 配置必须尽可能慷慨,以便代码可以利用所有可用资源。
首先,从 JVM 9 支持的四种垃圾收集器中选择,如下所示:
-
串行收集器:这使用单个线程来执行所有的垃圾回收工作
-
并行收集器:这使用多个线程来加速垃圾回收
-
并发标记清除(CMS)收集器:这以牺牲更多处理器时间为代价,使用较短的垃圾回收暂停时间
-
垃圾优先(G1)收集器:这是为具有大内存的多处理器机器设计的,但以高概率满足垃圾收集暂停时间目标,同时实现高吞吐量。
官方的 Oracle 文档(docs.oracle.com/javase/9/gctuning/available-collectors.htm)提供了以下垃圾收集选择的初始指南:
-
如果应用程序的数据集很小(最多约 100 MB),那么选择带有
-XX:+UseSerialGC选项的串行收集器 -
如果应用程序将在单个处理器上运行且没有暂停时间要求,那么选择带有
-XX:+UseSerialGC选项的串行收集器 -
如果(a)峰值应用程序性能是首要任务,并且(b)没有暂停时间要求或一秒或更长的暂停是可以接受的,那么让虚拟机选择收集器或使用
-XX:+UseParallelGC选择并行收集器 -
如果响应时间比整体吞吐量更重要,并且垃圾收集暂停必须短于大约一秒,那么选择带有
-XX:+UseG1GC 或 -XX:+UseConcMarkSweepGC的并发收集器
但如果您还没有特定的偏好,让 JVM 选择垃圾收集器,直到您更多地了解应用程序的需求。在 JDK 9 中,某些平台上默认选择 G1,如果您使用的硬件有足够的资源,这是一个良好的开始。
Oracle 还推荐使用默认设置的 G1,然后使用 -XX:MaxGCPauseMillis 选项调整不同的暂停时间目标,并使用 -Xmx 选项设置最大 Java 堆大小。增加暂停时间目标或堆大小通常会导致更高的吞吐量。延迟也会受到暂停时间目标变化的影响。
在调整垃圾回收(GC)时,保持 -Xlog:gc*=debug 记录选项是有益的。它提供了关于垃圾回收活动的许多有用细节。JVM 调整的第一个目标是减少完全堆 GC 周期(Full GC)的数量,因为它们非常消耗资源,从而可能减慢应用程序的速度。这是由于旧年代区域占用过高造成的。在日志中,它被标识为“暂停完全(分配失败)”。以下是一些减少 Full GC 发生概率的可能步骤:
-
使用
-Xmx增加堆的大小。但请确保它不超过物理 RAM 的大小。更好的是,为其他应用程序留出一些 RAM 空间。 -
明确使用
-XX:ConcGCThreads增加并发标记线程的数量。 -
如果大对象占用了太多的堆空间(注意
gc+heap=info记录显示大对象区域旁边的数字),尝试使用-XX: G1HeapRegionSize增加区域大小。 -
观察 GC 日志并修改代码,以确保您应用程序创建的几乎所有对象都不会移动到年轻代之外(死亡年轻)。
-
一次添加或更改一个选项,这样您可以清楚地了解 JVM 行为变化的原因。
这几个步骤将帮助你创建一个试错循环,这将使你对所使用的平台、应用程序的需求以及 JVM 和所选 GC 对不同选项的敏感性有更深入的理解。具备这些知识后,你将能够通过更改代码、调整 JVM 或重新配置硬件来满足非功能性性能要求。
响应式编程
在经历了几次失败尝试和几次灾难性的中断,以及英雄般的恢复之后,金字塔建造的过程逐渐成形,古代建造者能够完成几个项目。最终形状有时并不完全符合预期(最初的金字塔最终变成了弯曲的),但无论如何,金字塔至今仍在沙漠中装饰着。这种经验代代相传,设计和过程经过足够的调整,能够在 4000 多年后仍然产生令人惊叹且令人愉悦的景象。
软件实践也随着时间的推移而改变,尽管自图灵先生编写第一个现代程序以来,我们只有大约 70 年的时间。起初,当世界上只有少数程序员时,计算机程序通常是一系列连续的指令。函数式编程(将函数当作一等公民来推动)也引入得很早,但并没有成为主流。相反,GOTO指令允许你在意大利面般的代码中滚动。随后是结构化编程,然后是面向对象编程,函数式编程在某个领域甚至蓬勃发展。异步处理由按下的按键生成的事件成为许多程序员的常规操作。JavaScript 试图使用所有最佳实践,获得了大量力量,尽管这牺牲了程序员在调试(有趣)阶段的挫败感。最后,随着线程池和 lambda 表达式成为 JDK SE 的一部分,将响应式流 API 添加到 JDK 9 使 Java 成为允许使用异步数据流的响应式编程家族的一员。
公平地说,即使没有这个新 API,我们也能够异步处理数据——通过旋转工作线程和使用线程池以及可调用对象(正如我们在前面的章节中所描述的)或者通过传递回调(即使有时在“谁调用谁”的迷宫中丢失)。但是,编写了几次这样的代码之后,人们会注意到,大多数这样的代码只是管道,可以封装在一个框架中,这可以显著简化异步处理。这就是 Reactive Streams 倡议(www.reactive-streams.org)得以创立的原因,该倡议的范围如下定义:
Reactive Streams 的范围是找到一个最小的接口、方法和协议集,以描述实现目标所需的必要操作和实体——具有非阻塞背压的异步数据流。
非阻塞背压 这个术语非常重要,因为它标识了现有异步处理中的一个问题——协调传入数据的速率与系统处理它们的能力,无需停止(阻塞)数据输入。解决方案仍然包括通过通知源消费者难以跟上输入来提供一些背压,但新的框架应该比仅仅阻塞流更灵活地响应传入数据速率的变化,因此得名 reactive。
Reactive Streams API 包含了类中包含的五个接口,分别是 java.util.concurrent.Flow、Publisher、Subscriber、Subscription 和 Processor:
@FunctionalInterface
public static interface Flow.Publisher<T> {
public void subscribe(Flow.Subscriber<? super T> subscriber);
}
public static interface Flow.Subscriber<T> {
public void onSubscribe(Flow.Subscription subscription);
public void onNext(T item);
public void onError(Throwable throwable);
public void onComplete();
}
public static interface Flow.Subscription {
public void request(long numberOfItems);
public void cancel();
}
public static interface Flow.Processor<T,R>
extends Flow.Subscriber<T>, Flow.Publisher<R> {
}
Flow.Subscriber 对象在将 Flow.Subscriber 对象作为参数传递给 subscribe() 方法后,成为 Flow.Publisher 对象产生的数据的订阅者。发布者(Flow.Publisher 对象)调用订阅者的 onSubscribe() 方法,并传递一个 Flow.Subscription 对象作为参数。现在,订阅者可以通过调用订阅的 request() 方法从发布者请求 numberOffItems 的数据。这就是当订阅者决定何时请求另一个项目进行处理时实现拉模型的途径。订阅者可以通过调用 cancel() 订阅方法从发布者服务中取消订阅。
作为回报(或者如果没有请求,如果实现者决定这样做,那将是一个推送模型),发布者可以通过调用订阅者的 onNext() 方法将新项目传递给订阅者。发布者还可以通过调用订阅者的 onError() 方法告诉订阅者项目生产遇到了问题,或者通过调用订阅者的 onComplete() 方法告诉订阅者没有更多数据将到来。
Flow.Processor 接口描述了一个可以同时作为订阅者和发布者的实体。它允许创建这样的处理器的链(管道),因此订阅者可以从发布者那里接收一个项目,对其进行调整,然后将结果传递给下一个订阅者。
这是 Reactive Streams 初始化计划定义的最小接口集(现在它是 JDK 9 的一部分),以支持具有非阻塞背压的异步数据流。正如你所见,它允许订阅者和发布者相互通信并协调,如果需要的话,协调传入数据的速率,从而使得我们讨论开始时提到的背压问题有各种解决方案。
实现这些接口的方法有很多。目前,在 JDK 9 中,只有一个接口的实现示例——SubmissionPublisher类实现了Flow.Publisher接口。但已经存在几个其他库实现了响应式流 API:RxJava、Reactor、Akka Streams 和 Vert.x 是最知名的。在我们的例子中,我们将使用 RxJava 2.1.3。您可以在reactivex.io下找到 RxJava 2.x API,其名称为 ReactiveX,代表响应式扩展。
在做这件事的同时,我们还想讨论java.util.stream包中的流和响应式流(例如在 RxJava 中实现)之间的区别。使用任何流都可以编写非常相似的代码。让我们看看一个例子。这是一个遍历五个整数、只选择偶数(2 和 4)、将它们每个都进行转换(取所选数字的平方根)然后计算两个平方根的平均值的程序。它基于传统的for循环。
让我们从相似性开始。使用任何流都可以实现相同的功能。例如,这里有一个遍历五个整数的方法,只选择偶数(在这种情况下是 2 和 4),将它们每个都进行转换(取每个偶数的平方根),然后计算两个平方根的平均值。它基于传统的for循环:
void demo_ForLoop(){
List<Double> r = new ArrayList<>();
for(int i = 1; i < 6; i++){
System.out.println(i);
if(i%2 == 0){
System.out.println(i);
r.add(doSomething(i));
}
}
double sum = 0d;
for(double d: r){ sum += d; }
System.out.println(sum / r.size());
}
static double doSomething(int i){
return Math.sqrt(1.*i);
}
如果我们运行这个程序,结果将如下所示:

可以使用包java.util.stream以以下方式实现相同的功能(具有相同的输出):
void demo_Stream(){
double a = IntStream.rangeClosed(1, 5)
.peek(System.out::println)
.filter(i -> i%2 == 0)
.peek(System.out::println)
.mapToDouble(i -> doSomething(i))
.average().getAsDouble();
System.out.println(a);
}
可以使用 RxJava 实现相同的功能:
void demo_Observable1(){
Observable.just(1,2,3,4,5)
.doOnNext(System.out::println)
.filter(i -> i%2 == 0)
.doOnNext(System.out::println)
.map(i -> doSomething(i))
.reduce((r, d) -> r + d)
.map(r -> r / 2)
.subscribe(System.out::println);
}
RxJava 基于Observable对象(充当Publisher的角色)和订阅Observable并等待数据发出的Observer。从Observable到Observer的每个发出数据项(在链式操作中)都可以通过流畅风格中的操作进行处理(参见前面的代码)。每个操作都接受一个 lambda 表达式。操作功能从其名称中显而易见。
尽管可以表现得与流相似,但Observable具有显著不同的功能。例如,一旦流关闭,就不能重新打开,而Observable可以被重用。以下是一个例子:
void demo_Observable2(){
Observable<Double> observable = Observable
.just(1,2,3,4,5)
.doOnNext(System.out::println)
.filter(i -> i%2 == 0)
.doOnNext(System.out::println)
.map(Demo05Reactive::doSomething);
observable
.reduce((r, d) -> r + d)
.map(r -> r / 2)
.subscribe(System.out::println);
observable
.reduce((r, d) -> r + d)
.subscribe(System.out::println);
}
在前面的代码中,我们使用了两次Observable——一次用于计算平均值,一次用于求所有偶数平方根的总和。输出如下所示:

如果我们不希望Observable运行两次,我们可以通过添加.cache()操作来缓存其数据:
void demo_Observable2(){
Observable<Double> observable = Observable
.just(1,2,3,4,5)
.doOnNext(System.out::println)
.filter(i -> i%2 == 0)
.doOnNext(System.out::println)
.map(Demo05Reactive::doSomething)
.cache();
observable
.reduce((r, d) -> r + d)
.map(r -> r / 2)
.subscribe(System.out::println);
observable
.reduce((r, d) -> r + d)
.subscribe(System.out::println);
}
之前代码的结果如下:

您可以看到,第二次使用相同的Observable利用了缓存的数据,从而提高了性能。
另一个 Observable 的优点是异常可以被 Observer 捕获:
subscribe(v -> System.out.println("Result=" + v),
e -> {
System.out.println("Error: " + e.getMessage());
e.printStackTrace();
},
() -> System.out.println("All the data processed"));
subscribe() 方法被重载,允许传入一个、两个或三个函数:
-
第一个是用于成功的情况
-
第二个是在发生异常时使用
-
第三个是在所有数据处理完毕后调用
Observable 模型还允许对多线程处理有更多的控制。在流中使用 .parallel() 不允许你指定要使用的线程池。但在 RxJava 中,你可以使用 Observable 中的 subscribeOn() 方法设置你喜欢的线程池类型:
observable.subscribeOn(Schedulers.io())
.subscribe(System.out::println);
subscribeOn() 方法告诉 Observable 在哪个线程上放置数据。Schedulers 类有生成线程池的方法,这些方法主要处理 I/O 操作(如我们的示例所示),或者计算密集型(方法 computation()),或者为每个工作单元创建一个新线程(方法 newThread()),以及其他几个,包括传入自定义线程池(方法 from(Executor executor))。
本书格式不允许我们描述 RxJava API 和其他响应式流实现的全部丰富性。它们的主要思想反映在响应式宣言([www.reactivemanifesto.org/](http://www.reactivemanifesto.org/))中,该宣言描述响应式系统作为新一代高性能软件解决方案。建立在异步消息驱动过程和响应式流之上,这些系统能够展示响应式宣言中声明的特性:
-
弹性:这可以根据需要根据负载进行扩展和收缩
-
更好的响应性:在这里,处理可以通过异步调用并行化
-
弹性:在这里,系统被分解成多个(通过消息松散耦合)组件,从而便于灵活的复制、包含和隔离
使用响应式流来实现之前提到的特性来编写响应式系统的代码构成了响应式编程。这种系统在当今的典型应用是微服务,这将在下一课中描述。
摘要
在本课中,我们讨论了通过使用多线程来提高 Java 应用程序性能的方法。我们描述了如何通过线程池减少创建线程的开销,以及适用于不同处理需求的多种类型的线程池。我们还提出了选择池大小时考虑的因素,以及如何同步线程,以确保它们不会相互干扰并产生最佳性能结果。我们指出,每个关于性能改进的决定都必须通过直接监控应用程序来做出和测试,我们讨论了通过编程和利用各种外部工具进行此类监控的可能选项。最后一步,JVM 调优,可以通过我们在相应部分列出的 Java 工具标志来完成。通过采用反应式编程的概念,还可以进一步提高 Java 应用程序的性能,我们将其作为最有效的向高度可扩展和高度性能的 Java 应用程序迈进的主要竞争者之一。
在下一课中,我们将讨论通过将应用程序拆分为几个微服务来添加更多工作者,每个微服务独立部署,并使用多个线程和反应式编程来提高性能、响应、可扩展性和容错性。
评估
-
命名一个计算前 99999 个整数的平均平方根并将结果分配给一个可以随时访问的属性的方法。
-
以下哪个方法创建了一个固定大小的线程池,可以安排在给定延迟后运行命令,或者定期执行:
-
newscheduledThreadPool() -
newWorkStealingThreadPool() -
newSingleThreadScheduledExecutor() -
newFixedThreadPool()
-
-
判断是否为 True 或 False:可以利用
Runnable接口是一个函数式接口,并将必要的处理函数作为 lambda 表达式传递到新线程中。 -
在调用
__________方法后,不能再向池中添加更多工作线程。-
shutdownNow() -
shutdown() -
isShutdown() -
isShutdownComplete()
-
-
________ 基于
Observable对象,该对象扮演发布者的角色。
第四章:微服务
只要我们一直在谈论一个过程的设计、实施和调整,我们就能用金字塔建造的生动图像(尽管只是我们想象中的)来展示它。基于线程池成员之间平等民主原则的多线程管理,也有一种集中规划和监督的感觉。线程的优先级被程序性地分配,在程序员经过深思熟虑后(对于大多数情况)硬编码,并根据预期的负载进行调整。可用的资源上限是固定的,尽管在再次做出相对较大的集中决策后,它们可以被增加。
这些系统取得了巨大的成功,并且仍然构成了目前部署到生产中的大多数网络应用。其中许多是单体应用,密封在单个.ear或.war文件中。这对相对较小的应用和相应的支持团队规模来说效果很好。如果代码结构良好,它们易于维护、构建,并且如果生产负载不是非常高,它们可以轻松部署。如果业务没有增长或对公司的互联网存在影响不大,它们将继续完成工作,并且可能会在可预见的未来继续这样做。许多服务提供商都渴望通过收取少量费用来托管此类网站,从而减轻网站所有者与业务无关的生产维护的技术担忧。但并非所有人都如此。
负载越高,扩展就越困难、成本越高,除非代码和整体架构被重构以变得更加灵活和能够应对不断增长的压力。本课程描述了行业领导者们在解决该问题及其背后的动机时采用的解决方案。
在本课程中我们将讨论的微服务的特定方面包括以下内容:
-
微服务兴起的动机
-
最近为支持微服务而开发的框架
-
带有实际案例的微服务开发过程,包括在构建微服务时的考虑和决策过程
-
三种主要部署方法(如无容器、自包含和在容器内)的优缺点
为什么选择微服务?
一些企业对部署计划的需求更高,因为需要跟上更大流量的需求。应对这一挑战的自然答案,并且也是实际的做法,就是添加具有相同 .ear 或 .war 文件的服务器,并将所有服务器加入到一个集群中。这样,一个失败的服务器可以被集群中的另一个服务器自动替换,而网站用户永远不会体验到服务的断开。支持所有集群服务器的数据库也可以进行集群。每个集群的连接都通过负载均衡器进行,确保集群中的任何成员都不会比其他成员工作得更多。
网络服务器和数据库集群虽然有所帮助,但仅限于一定程度,因为随着代码库的增长,其结构可能会产生一个或多个瓶颈,除非用可扩展的设计来解决类似问题。实现这一目标的一种方法是将代码分成层:前端(或网络层)、中间层(或应用层)和后端(或后端层)。然后,再次,每一层都可以独立部署(如果层之间的协议没有改变),并在其自己的服务器集群中部署,因为每一层可以根据需要独立地水平扩展,而不依赖于其他层。这种解决方案为扩展提供了更多的灵活性,但使得部署计划更加复杂,尤其是在新代码引入破坏性更改的情况下。其中一种方法是为新代码创建第二个集群,然后逐个从旧集群中取出服务器,部署新代码,并将它们放入新集群。一旦每个层的至少一个服务器都部署了新代码,新集群就会启动。这种方法对于网络和应用层来说效果良好,但对于后端来说则更为复杂,后端偶尔需要数据迁移和类似的愉快练习。再加上在部署过程中由于人为错误、代码缺陷、纯粹的事故或上述所有因素的某种组合而导致的意外中断,就不难理解为什么很少有人喜欢将主要版本发布到生产环境中了。
程序员作为天生的问题解决者,通过编写防御性代码、弃用而不是更改、测试等方式,尽可能地防止早期场景的发生。其中一种方法是将应用程序分解成更多独立部署的部分,希望避免同时部署所有内容。他们将这些独立单元称为服务,从而诞生了面向服务的架构(SOA)。
不幸的是,在许多公司中,代码库的自然增长并没有及时调整以适应新的挑战。就像最终在慢慢加热的水壶中被煮青蛙一样,他们从未有时间通过改变设计跳出热点。总是比重新设计整个应用程序更便宜,只需向现有功能的块中添加另一个功能。当时的时间到市场指标和保持底线不变的业务指标一直是决策的主要标准,直到结构不良的源代码最终停止工作,带着所有的商业交易一起崩溃,或者,如果公司运气好,让他们度过风暴,并显示出对重新设计的投资的重要性。
由于所有这些,一些幸运的公司仍然在业务中,他们的单体应用程序仍然按预期运行(也许不会太久,但谁知道呢),一些公司倒闭了,一些从错误中学习并进步到充满新挑战的勇敢世界,还有一些从错误中学习并设计他们的系统,从一开始就是 SOA。
在社会领域观察类似的趋势也很有趣。社会从强大的中央政府转向了更松散耦合的、由互惠互利的经济和文化交流联系在一起的半独立国家联盟。
不幸的是,维持这种松散的结构是要付出代价的。每个参与者都必须在维护合同(在社会的情况下是社交,在软件的情况下是 API)方面更加负责,不仅形式上,而且在精神上。否则,例如,从一个组件的新版本中流出的数据,虽然按类型正确,但可能因为值(太大或太小)而被另一个组件不接受。保持跨团队的理解和责任重叠需要不断保持文化的活力和启迪。鼓励创新和冒险,这可能导致商业突破,与来自同一商业人士的稳定性和风险规避的倾向相矛盾。
从单体单团队开发转向多团队和基于独立组件的系统需要企业在所有层面上的努力。你说的不再有质量保证部门是什么意思?那么谁会关心测试人员的职业发展?至于 IT 团队呢?你说的开发者将支持生产是什么意思?这样的变化影响人们的生活,并且不容易实施。这就是为什么 SOA 架构不仅仅是一个软件原则。它影响公司中的每个人。
同时,那些成功发展到我们十年前无法想象的地步的行业领导者,被迫解决更多令人畏惧的问题,并带着他们的解决方案回到了软件社区。这就是我们的金字塔建造类比不再适用之处。因为新的挑战不仅仅是建造一个前所未有的巨大事物,而且还要快速完成,不是以年为单位,而是以几周甚至几天为单位。结果必须能够持续不断进化,并且足够灵活,能够实时适应新的、意外的需求。如果只有功能的一个方面发生了变化,我们应当能够仅重新部署这一项服务。如果对任何服务的需求增加,我们应当能够仅在这一项服务上进行扩展,并在需求下降时释放资源。
为了避免大规模部署和全员参与,并更接近持续部署(这减少了上市时间,因此得到了商业的支持),功能继续细分为更小的服务块。为了应对需求,更复杂和稳健的云环境、部署工具(包括容器和容器编排)以及监控系统支持了这一转变。在前一课中描述的响应式流,甚至在响应式宣言发布之前就开始发展,并填补了现代框架堆栈中的漏洞。
将应用程序拆分为独立的部署单元带来了几个并非完全预期的益处,这些益处增加了继续前进的动力。服务的物理隔离使得在选择编程语言和实现平台方面具有更大的灵活性。这不仅有助于选择最适合工作的技术,而且还能雇佣能够实现这些技术的人员,而不会受到公司特定技术栈的限制。这也帮助招聘人员扩大搜索范围,并使用更小的团队来吸引新人才,这对于可用专家数量有限且数据处理行业需求无限增长的情况来说,是一个不小的优势。
此外,这种架构强制对复杂系统较小部分的接口进行讨论和明确定义,从而为处理复杂性的进一步增长和调整奠定了坚实的基础。
正是这样,微服务进入了视野,并被像 Netflix、Google、Twitter、eBay、Amazon 和 Uber 这样的流量巨头所采用。现在,让我们谈谈这项努力的成果和学到的教训。
构建微服务
在深入构建过程之前,让我们回顾一下,一段代码必须具备哪些特性才能被认定为微服务。我们将按无特定顺序进行:
-
一个微服务的源代码大小应该小于 SOA,一个开发团队应该能够支持多个微服务。
-
它必须独立于其他服务进行部署。
-
每个微服务都应该有自己的数据库(或模式或一组表),尽管这个说法仍在争议中,特别是在多个服务修改相同数据集或相互依赖的数据集的情况下;如果同一个团队拥有所有相关的服务,那么完成它更容易。否则,我们将在稍后讨论几种可能的策略。
-
它必须是无状态的且幂等的。如果一个服务的实例失败了,另一个应该能够完成该服务预期的任务。
-
它应该提供一种检查其健康状态的方法,这意味着该服务正在运行并且准备好执行工作。
在设计、开发和部署后,必须考虑资源共享,并在部署后进行监控以验证假设。在上一课中,我们讨论了线程同步。你可以看到这个问题并不容易解决,我们已经提出了几种可能的解决方案。类似的方法也可以应用于微服务。尽管它们在不同的进程中运行,但在需要时它们可以相互通信,因此它们可以协调和同步它们的行为。
在修改跨数据库、模式或同一模式内的表中的相同持久数据时,必须特别小心。如果最终一致性是可以接受的(例如,对于用于统计目的的大型数据集通常是这种情况),则不需要采取特殊措施。然而,事务完整性的需求提出了一个更困难的问题。
支持跨多个微服务的交易的一种方法是为扮演分布式事务管理器(DTM)角色的服务创建一个服务。需要协调的其他服务会将新的修改后的值传递给它。DTM 服务可以将并发修改的数据暂时存储在数据库表中,并在所有数据准备(并且一致)后,在一个事务中将它移动到主表(们)中。
如果访问数据的时间是一个问题,或者你需要保护数据库免受过多并发连接的影响,为某些服务分配一个数据库可能是一个解决方案。或者,如果你想尝试另一种选择,内存缓存可能是可行的。添加一个提供对缓存访问(并在需要时更新它)的服务可以增加与使用它的服务的隔离性,但需要在管理同一缓存的对等体之间进行(有时是困难的)同步。
在考虑了所有数据共享的选项和可能的解决方案之后,重新审视为每个微服务创建自己的数据库(或模式)的想法通常是有帮助的。人们可能会发现,与动态同步数据相比,数据隔离(以及在数据库层面的后续同步)的努力看起来不再那么令人畏惧。
话虽如此,让我们来看看微服务实现框架的领域。当然可以从头开始编写微服务,但在这样做之前,总是值得看看已经存在的东西,即使最终发现没有什么适合您的特定需求。
目前用于构建微服务的框架超过一打。其中最受欢迎的两个是 Spring Boot (projects.spring.io/spring-boot/) 和原生 J2EE。J2EE 社区成立了 MicroProfile (microprofile.io/) 初始化项目,其目标是优化企业 Java以适应微服务架构。KumuluzEE (ee.kumuluz.com/) 是一个轻量级的开源微服务框架,与 MicroProfile 集成。
一些其他框架的列表如下(按字母顺序排列):
-
Akka:这是一个用于构建高度并发、分布式和健壮的消息驱动应用的 Java 和 Scala 工具包 (akka.io)
-
Bootique:这是一个对可运行 Java 应用有最小观点的框架 (bootique.io)
-
Dropwizard:这是一个用于开发操作友好、高性能、RESTful 网络服务的 Java 框架 (www.dropwizard.io)
-
Jodd:这是一个包含 Java 微框架、工具和实用程序的集合,大小不到 1.7 MB (jodd.org)
-
Lightbend Lagom:这是一个基于 Akka 和 Play 的有观点的微服务框架 (www.lightbend.com)
-
Ninja:这是一个全栈式 Java 网络框架 (www.ninjaframework.org)
-
Spotify Apollo:这是一套 Spotify 用于编写微服务的 Java 库 (spotify.github.io/apollo)
-
Vert.x:这是一个用于在 JVM 上构建反应式应用的工具包 (vertx.io)
所有框架都支持微服务之间的 HTTP/JSON 通信;其中一些还提供了发送消息的额外方式。如果不是后者,可以使用任何轻量级消息系统。我们在这里提到它是因为,如您所回忆的那样,消息驱动的异步处理是微服务组成的反应式系统弹性和响应性的基础。
为了演示微服务构建的过程,我们将使用 Vert.x,这是一个事件驱动、非阻塞、轻量级和多种语言(组件可以用 Java、JavaScript、Groovy、Ruby、Scala、Kotlin 和 Ceylon 编写)的工具包。它支持异步编程模型和分布式事件总线,甚至可以扩展到浏览器中的 JavaScript(从而允许创建实时网络应用程序)。
开始使用 Vert.x,通过创建一个实现 io.vertx.core.Verticle 接口的 Verticle 类:
package io.vertx.core;
public interface Verticle {
Vertx getVertx();
void init(Vertx vertx, Context context);
void start(Future<Void> future) throws Exception;
void stop(Future<Void> future) throws Exception;
}
之前提到的方法名称是自解释的。getVertex() 方法提供了访问 Vertx 对象的途径,这是 Vert.x 核心 API 的入口点。它提供了构建微服务所需的功能:
-
创建 TCP 和 HTTP 客户端和服务器
-
创建 DNS 客户端
-
创建数据报套接字
-
创建周期性服务
-
提供对事件总线文件系统 API 的访问
-
提供对共享数据 API 的访问
-
部署和卸载 verticle
使用这个 Vertx 对象,可以部署各种 verticle,它们相互通信,接收外部请求,并像任何其他 Java 应用程序一样处理和存储数据,从而形成一个微服务系统。使用来自 io.vertx.rxjava 包的 RxJava 实现,我们将展示如何创建一个反应式微服务系统。
在 Vert.x 世界中,一个垂直(verticle)是一个构建块。它可以很容易地通过扩展 io.vertx.rxjava.core.AbstractVerticle 类来创建:
package io.vertx.rxjava.core;
import io.vertx.core.Context;
import io.vertx.core.Vertx;
public class AbstractVerticle
extends io.vertx.core.AbstractVerticle {
protected io.vertx.rxjava.core.Vertx vertx;
public void init(Vertx vertx, Context context) {
super.init(vertx, context);
this.vertx = new io.vertx.rxjava.core.Vertx(vertx);
}
}
之前提到的类反过来又扩展了 io.vertx.core.AbstractVerticle:
package io.vertx.core;
import io.vertx.core.json.JsonObject;
import java.util.List;
public abstract class AbstractVerticle
implements Verticle {
protected Vertx vertx;
protected Context context;
public Vertx getVertx() { return vertx; }
public void init(Vertx vertx, Context context) {
this.vertx = vertx;
this.context = context;
}
public String deploymentID() {
return context.deploymentID();
}
public JsonObject config() {
return context.config();
}
public List<String> processArgs() {
return context.processArgs();
}
public void start(Future<Void> startFuture)
throws Exception {
start();
startFuture.complete();
}
public void stop(Future<Void> stopFuture)
throws Exception {
stop();
stopFuture.complete();
}
public void start() throws Exception {}
public void stop() throws Exception {}
}
通过扩展类 io.vertx.core.AbstractVerticle 也可以创建 verticle。然而,我们将编写反应式微服务,因此我们将扩展其 rx-fied 版本,io.vertx.rxjava.core.AbstractVerticle。
要使用 Vert.x 并运行提供的示例,你需要做的只是添加以下依赖项:
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-web</artifactId>
<version>${vertx.version}</version>
</dependency>
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-rx-java</artifactId>
<version>${vertx.version}</version>
</dependency>
根据需要,可以通过包含其他 Maven 依赖项来添加其他 Vert.x 功能。
使 Vert.x 的 Verticle 具有反应性的是其底层事件循环(一个线程)的实现,该循环接收事件并将其传递给 Handler(我们将展示如何编写该代码)。当 Handler 获取结果时,事件循环将调用回调。
注意
如你所见,不要编写阻塞事件循环的代码是很重要的,这是 Vert.x 的黄金法则:不要阻塞事件循环。
如果没有被阻塞,事件循环运行得非常快,在短时间内处理大量事件。这被称为反应器模式 (en.wikipedia.org/wiki/Reactor_pattern)。这种事件驱动的非阻塞编程模型非常适合反应式微服务。对于某些本质上阻塞的代码类型(JDBC 调用和长时间计算是很好的例子),可以通过 vertx.executeBlocking() 方法异步执行工作端点(不是通过事件循环,而是通过一个单独的线程),这样就可以保持黄金法则。
让我们看看几个示例。以下是一个作为 HTTP 服务器的 Verticle 类:
import io.vertx.rxjava.core.http.HttpServer;
import io.vertx.rxjava.core.AbstractVerticle;
public class Server extends AbstractVerticle{
private int port;
public Server(int port) {
this.port = port;
}
public void start() throws Exception {
HttpServer server = vertx.createHttpServer();
server.requestStream().toObservable()
.subscribe(request -> request.response()
.end("Hello from " +
Thread.currentThread().getName() +
" on port " + port + "!\n\n")
);
server.rxListen(port).subscribe();
System.out.println(Thread.currentThread().getName()
+ " is waiting on port " + port + "...");
}
}
在前面的代码中,服务器被创建,可能请求的数据流被包装在一个 Observable 中。然后我们订阅了来自 Observable 的数据,并传递了一个函数(一个请求处理器),该函数将处理请求并生成必要的响应。我们还告诉服务器要监听哪个端口。使用这个 Verticle,我们可以部署多个监听不同端口的 HTTP 服务器实例。以下是一个示例:
import io.vertx.rxjava.core.RxHelper;
import static io.vertx.rxjava.core.Vertx.vertx;
public class Demo01Microservices {
public static void main(String... args) {
RxHelper.deployVerticle(vertx(), new Server(8082));
RxHelper.deployVerticle(vertx(), new Server(8083));
}
}
如果我们运行这个应用程序,输出将如下所示:

正如你所见,相同的线程正在监听两个端口。如果我们现在向每个运行的服务器发送请求,我们将得到我们硬编码的响应:

我们从 main() 方法运行了我们的示例。一个插件 maven-shade-plugin 允许你指定你希望作为应用程序起点的端点。以下是从 vertx.io/blog/my-first-vert-x-3-application 的一个示例:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<manifestEntries>
<Main-Class>io.vertx.core.Starter</Main-Class>
<Main-Verticle>io.vertx.blog.first.MyFirstVerticle</Main-Verticle>
</manifestEntries>
</transformer>
</transformers>
<artifactSet/>
<outputFile>${project.build.directory}/${project.artifactId}-${project.version}-fat.jar</outputFile>
</configuration>
</execution>
</executions>
</plugin>
现在,运行以下命令:
mvn package
它将生成一个指定的 JAR 文件(在这个例子中称为 target/my-first-app-1.0-SNAPSHOT-fat.jar)。它被称为 fat,因为它包含了所有必要的依赖项。此文件还将包含 MANIFEST.MF,其中包含以下条目:
Main-Class: io.vertx.core.Starter
Main-Verticle: io.vertx.blog.first.MyFirstVerticle
你可以使用任何端点代替此示例中使用的 io.vertx.blog.first.MyFirstVerticle,但 io.vertx.core.Starter 必须存在,因为它是知道如何读取清单并执行指定端点的 start() 方法的 Vert.x 类的名称。现在,你可以运行以下命令:
java -jar target/my-first-app-1.0-SNAPSHOT-fat.jar
此命令将以与我们的示例中 main() 方法执行相同的方式执行 MyFirstVerticle 类的 start() 方法,我们将继续使用它以简化演示。
为了补充 HTTP 服务器,我们也可以创建一个 HTTP 客户端。然而,首先,我们将修改 server 端点的 start() 方法以接受参数 name:
public void start() throws Exception {
HttpServer server = vertx.createHttpServer();
server.requestStream().toObservable()
.subscribe(request -> request.response()
.end("Hi, " + request.getParam("name") +
"! Hello from " +
Thread.currentThread().getName() +
" on port " + port + "!\n\n")
);
server.rxListen(port).subscribe();
System.out.println(Thread.currentThread().getName()
+ " is waiting on port " + port + "...");
}
现在,我们可以创建一个 HTTP 客户端 端点,该端点每秒发送一个请求并打印出响应,持续 3 秒后停止:
import io.vertx.rxjava.core.AbstractVerticle;
import io.vertx.rxjava.core.http.HttpClient;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
public class Client extends AbstractVerticle {
private int port;
public Client(int port) {
this.port = port;
}
public void start() throws Exception {
HttpClient client = vertx.createHttpClient();
LocalTime start = LocalTime.now();
vertx.setPeriodic(1000, v -> {
client.getNow(port, "localhost", "?name=Nick",
r -> r.bodyHandler(System.out::println));
if(ChronoUnit.SECONDS.between(start,
LocalTime.now()) > 3 ){
vertx.undeploy(deploymentID());
}
});
}
}
假设我们以以下方式部署两个端点:
RxHelper.deployVerticle(vertx(), new Server2(8082));
RxHelper.deployVerticle(vertx(), new Client(8082));
输出将如下所示:

在这个最后的例子中,我们展示了如何创建一个 HTTP 客户端和周期性服务。现在,让我们给我们的系统添加更多功能。例如,让我们添加另一个 verticle,它将与数据库交互并通过我们已创建的 HTTP 服务器使用它。
首先,我们需要添加这个依赖项:
<dependency>
<groupId>io.vertx</groupId>
<artifactId>vertx-jdbc-client</artifactId>
<version>${vertx.version}</version>
</dependency>
新添加的 JAR 文件允许我们创建一个内存数据库和一个处理器来访问它:
public class DbHandler {
private JDBCClient dbClient;
private static String SQL_CREATE_WHO_CALLED =
"CREATE TABLE IF NOT EXISTS " +
"who_called ( name VARCHAR(10), " +
"create_ts TIMESTAMP(6) DEFAULT now() )";
private static String SQL_CREATE_PROCESSED =
"CREATE TABLE IF NOT EXISTS " +
"processed ( name VARCHAR(10), " +
"length INTEGER, " +
"create_ts TIMESTAMP(6) DEFAULT now() )";
public DbHandler(Vertx vertx){
JsonObject config = new JsonObject()
.put("driver_class", "org.hsqldb.jdbcDriver")
.put("url", "jdbc:hsqldb:mem:test?shutdown=true");
dbClient = JDBCClient.createShared(vertx, config);
dbClient.rxGetConnection()
.flatMap(conn ->
conn.rxUpdate(SQL_CREATE_WHO_CALLED)
.doAfterTerminate(conn::close) )
.subscribe(r ->
System.out.println("Table who_called created"),
Throwable::printStackTrace);
dbClient.rxGetConnection()
.flatMap(conn ->
conn.rxUpdate(SQL_CREATE_PROCESSED)
.doAfterTerminate(conn::close) )
.subscribe(r ->
System.out.println("Table processed created"),
Throwable::printStackTrace);
}
}
熟悉 RxJava 的人可以观察到 Vert.x 代码紧密遵循 RxJava 的风格和命名约定。尽管如此,我们仍然鼓励您阅读 Vert.x 文档,因为它拥有一个非常丰富的 API,涵盖了比演示中展示的更多情况。在前面的代码中,操作 flatMap() 接收运行脚本的函数,然后关闭连接。在这种情况下,doAfterTerminate() 操作相当于在传统代码中的 finally 块内执行,无论成功与否都会关闭连接。subscribe() 方法有几个重载版本。对于我们的代码,我们选择了接受两个函数的版本,一个在成功情况下执行(我们打印关于创建表的消息),另一个在异常情况下执行(我们只打印堆栈跟踪)。
要使用创建的数据库,我们可以向 DbHandler 添加 insert()、process() 和 readProcessed() 方法,这将允许我们展示如何构建一个响应式系统。insert() 方法的代码可能看起来像这样:
private static String SQL_INSERT_WHO_CALLED =
"INSERT INTO who_called(name) VALUES (?)";
public void insert(String name, Action1<UpdateResult>
onSuccess, Action1<Throwable> onError){
printAction("inserts " + name);
dbClient.rxGetConnection()
.flatMap(conn ->
conn.rxUpdateWithParams(SQL_INSERT_WHO_CALLED,
new JsonArray().add(name))
.doAfterTerminate(conn::close) )
.subscribe(onSuccess, onError);
}
insert() 方法,以及我们将要编写的其他方法,充分利用了 Java 函数式接口。它在 who_called 表中创建一个记录(使用传入的参数 name)。然后,subscribe() 操作执行调用此方法的代码传入的两个函数之一。我们只使用 printAction() 方法来提高可追踪性:
private void printAction(String action) {
System.out.println(this.getClass().getSimpleName()
+ " " + action);
}
方法 process() 也接受两个函数,但不需要其他参数。它处理表 who_called 中尚未处理的全部记录(未列入表 processed):
private static String SQL_SELECT_TO_PROCESS =
"SELECT name FROM who_called w where name not in " +
"(select name from processed) order by w.create_ts " +
"for update";
private static String SQL_INSERT_PROCESSED =
"INSERT INTO processed(name, length) values(?, ?)";
public void process(Func1<JsonArray, Observable<JsonArray>>
process, Action1<Throwable> onError) {
printAction("process all records not processed yet");
dbClient.rxGetConnection()
.flatMapObservable(conn ->
conn.rxQueryStream(SQL_SELECT_TO_PROCESS)
.flatMapObservable(SQLRowStream::toObservable)
.flatMap(process)
.flatMap(js ->
conn.rxUpdateWithParams(SQL_INSERT_PROCESSED, js)
.flatMapObservable(ur->Observable.just(js)))
.doAfterTerminate(conn::close))
.subscribe(js -> printAction("processed " + js), onError);
}
如果两个线程正在读取 who_called 表以选择尚未处理的记录,SQL 查询中的 for update 子句确保只有一个线程获取每个记录,因此它们不会被处理两次。process() 方法代码的显著优势是其对 rxQUeryStream() 操作的使用,该操作一次发出一个找到的记录,这样它们就可以独立地处理。在存在大量未处理记录的情况下,这种解决方案保证了结果的平稳交付,而不会导致资源消耗的峰值。接下来的 flatMap() 操作使用传入的函数进行处理。对该函数的唯一要求是它必须返回一个整数值(在 JsonArray 中),该值将用作 SQL_INSERT_PROCESSED 语句的参数。因此,决定处理性质的代码取决于调用此方法的代码。其余的代码与 insert() 方法类似。代码缩进有助于跟踪操作的嵌套。
方法 readProcessed() 的代码看起来与 insert() 方法的代码非常相似:
private static String SQL_READ_PROCESSED =
"SELECT name, length, create_ts FROM processed
order by create_ts desc limit ?";
public void readProcessed(String count, Action1<ResultSet>
onSuccess, Action1<Throwable> onError) {
printAction("reads " + count +
" last processed records");
dbClient.rxGetConnection()
.flatMap(conn ->
conn.rxQueryWithParams(SQL_READ_PROCESSED,
new JsonArray().add(count))
.doAfterTerminate(conn::close) )
.subscribe(onSuccess, onError);
}
前面的代码读取指定数量的最新处理的记录。与 process() 方法不同的是,readProcessed() 方法将所有读取的记录作为一个结果集返回,因此用户必须决定如何批量或逐个处理结果。我们展示所有这些可能性只是为了演示可能的选项的多样性。有了 DbHandler 类,我们就准备好使用它并创建 DbServiceHttp 微服务,该服务通过围绕它包装一个 HTTP 服务器来允许远程访问 DbHandler 的功能。以下是新微服务的构造函数:
public class DbServiceHttp extends AbstractVerticle {
private int port;
private DbHandler dbHandler;
public DbServiceHttp(int port) {
this.port = port;
}
public void start() throws Exception {
System.out.println(this.getClass().getSimpleName() +
"(" + port + ") starts...");
dbHandler = new DbHandler(vertx);
Router router = Router.router(vertx);
router.put("/insert/:name").handler(this::insert);
router.get("/process").handler(this::process);
router.get("/readProcessed")
.handler(this::readProcessed);
vertx.createHttpServer()
.requestHandler(router::accept).listen(port);
}
}
在之前提到的代码中,你可以看到在 Vert.x 中如何进行 URL 映射。对于每个可能的路由,都会分配一个相应的 Verticle 方法,每个方法都接受包含 HTTP 上下文所有数据的 RoutingContext 对象,包括 HttpServerRequest 和 HttpServerResponse 对象。一系列便利方法使我们能够轻松访问 URL 参数和其他处理请求所需的数据。以下是 start() 方法中提到的 insert() 方法:
private void insert(RoutingContext routingContext) {
HttpServerResponse response = routingContext.response();
String name = routingContext.request().getParam("name");
printAction("insert " + name);
Action1<UpdateResult> onSuccess =
ur -> response.setStatusCode(200).end(ur.getUpdated() +
" record for " + name + " is inserted");
Action1<Throwable> onError = ex -> {
printStackTrace("process", ex);
response.setStatusCode(400)
.end("No record inserted due to backend error");
};
dbHandler.insert(name, onSuccess, onError);
}
它所做的只是从请求中提取参数 name,并构建调用我们之前讨论过的 DbHandler 的 insert() 方法的两个必要函数。方法 process() 与之前的方法 insert() 非常相似:
private void process(RoutingContext routingContext) {
HttpServerResponse response = routingContext.response();
printAction("process all");
response.setStatusCode(200).end("Processing...");
Func1<JsonArray, Observable<JsonArray>> process =
jsonArray -> {
String name = jsonArray.getString(0);
JsonArray js =
new JsonArray().add(name).add(name.length());
return Observable.just(js);
};
Action1<Throwable> onError = ex -> {
printStackTrace("process", ex);
response.setStatusCode(400).end("Backend error");
};
dbHandler.process(process, onError);
}
之前提到的函数 process 定义了在 DbHandler 类的 process() 方法内部,对来自 SQL_SELECT_TO_PROCESS 语句的记录应该执行的操作。在我们的例子中,它计算调用者名称的长度,并将它作为参数(连同名称本身作为返回值)传递给下一个 SQL 语句,该语句将结果插入到 processed 表中。
下面是 readProcessed() 方法:
private void readProcessed(RoutingContext routingContext) {
HttpServerResponse response = routingContext.response();
String count = routingContext.request().getParam("count");
printAction("readProcessed " + count + " entries");
Action1<ResultSet> onSuccess = rs -> {
Observable.just(rs.getResults().size() > 0 ?
rs.getResults().stream().map(Object::toString)
.collect(Collectors.joining("\n")) : "")
.subscribe(s -> response.setStatusCode(200).end(s) );
};
Action1<Throwable> onError = ex -> {
printStackTrace("readProcessed", ex);
response.setStatusCode(400).end("Backend error");
};
dbHandler.readProcessed(count, onSuccess, onError);
}
那就是之前代码中在 onSuccess() 函数中从查询 SQL_READ_PROCESSED 读取结果集并用于构建响应的地方。请注意,我们首先创建一个 Observable,然后订阅它,并将订阅的结果作为响应传递到 end() 方法中。否则,响应可以在不等待构建响应的情况下返回。
现在,我们可以通过部署 DbServiceHttp 纵列来启动我们的响应式系统:
RxHelper.deployVerticle(vertx(), new DbServiceHttp(8082));
如果我们这样做,在输出中我们将看到以下代码行:
DbServiceHttp(8082) starts...
Table processed created
Table who_called created
在另一个窗口中,我们可以发出生成 HTTP 请求的命令:

如果我们现在读取已处理的记录,应该没有:

日志消息显示以下内容:

现在,我们可以请求处理现有记录,然后再次读取结果:

在原则上,已经足够构建一个响应式系统。我们可以在不同的端口上部署许多 DbServiceHttp 微服务,或者将它们集群起来以增加处理能力、弹性和响应性。我们可以在 HTTP 客户端或 HTTP 服务器中包装其他服务,并让它们相互通信,处理输入并通过处理管道传递结果。
然而,Vert.x 还有一个功能,它更适合消息驱动架构(而不使用 HTTP)。它被称为事件总线。任何纵列都可以访问事件总线,并可以使用 send() 方法(在响应式编程中为 rxSend())或 publish() 方法将任何消息发送到任何地址(它只是一个字符串)。一个或多个纵列可以注册自己作为某个地址的消费者。
如果许多纵列是同一地址的消费者,那么 send() 方法(在响应式编程中为 rxSend())只将消息发送给其中之一(使用轮询算法选择下一个消费者)。正如预期的那样,publish() 方法将消息发送到具有相同地址的所有消费者。让我们通过使用已经熟悉的 DbHandler 作为主要工作马来看一个例子。
基于事件总线的一个微服务看起来与我们之前讨论的基于 HTTP 协议的微服务非常相似:
public class DbServiceBus extends AbstractVerticle {
private int id;
private String instanceId;
private DbHandler dbHandler;
public static final String INSERT = "INSERT";
public static final String PROCESS = "PROCESS";
public static final String READ_PROCESSED
= "READ_PROCESSED";
public DbServiceBus(int id) { this.id = id; }
public void start() throws Exception {
this.instanceId = this.getClass().getSimpleName()
+ "(" + id + ")";
System.out.println(instanceId + " starts...");
this.dbHandler = new DbHandler(vertx);
vertx.eventBus().consumer(INSERT).toObservable()
.subscribe(msg -> {
printRequest(INSERT, msg.body().toString());
Action1<UpdateResult> onSuccess
= ur -> msg.reply(...);
Action1<Throwable> onError
= ex -> msg.reply("Backend error");
dbHandler.insert(msg.body().toString(),
onSuccess, onError);
});
vertx.eventBus().consumer(PROCESS).toObservable()
.subscribe(msg -> {
.....
dbHandler.process(process, onError);
});
vertx.eventBus().consumer(READ_PROCESSED).toObservable()
.subscribe(msg -> {
...
dbHandler.readProcessed(msg.body().toString(),
onSuccess, onError);
});
}
我们通过跳过一些部分(这些部分与 DbServiceHttp 类非常相似)并尝试突出代码结构来简化了前面的代码。为了演示目的,我们将部署这个类的两个实例,并向地址 INSERT、PROCESS 和 READ_PROCESSED 发送三条消息:
void demo_DbServiceBusSend() {
Vertx vertx = vertx();
RxHelper.deployVerticle(vertx, new DbServiceBus(1));
RxHelper.deployVerticle(vertx, new DbServiceBus(2));
delayMs(200);
String[] msg1 = {"Mayur", "Rohit", "Nick" };
RxHelper.deployVerticle(vertx,
new PeriodicServiceBusSend(DbServiceBus.INSERT, msg1, 1));
String[] msg2 = {"all", "all", "all" };
RxHelper.deployVerticle(vertx,
new PeriodicServiceBusSend(DbServiceBus.PROCESS, msg2, 1));
String[] msg3 = {"1", "1", "2", "3" };
RxHelper.deployVerticle(vertx,
new PeriodicServiceBusSend(DbServiceBus.READ_PROCESSED,
msg3, 1));
}
注意我们使用 delayMs() 方法插入的 200 毫秒延迟:
void delayMs(int ms){
try {
TimeUnit.MILLISECONDS.sleep(ms);
} catch (InterruptedException e) {}
}
延迟是必要的,以便让 DbServiceBus 纵列被部署并启动(以及与地址注册的消费者)。否则,发送消息的尝试可能会失败,因为消费者尚未在地址上注册。PeriodicServiceBusSend() 纵列代码如下:
public class PeriodicServiceBusSend
extends AbstractVerticle {
private EventBus eb;
private LocalTime start;
private String address;
private String[] caller;
private int delaySec;
public PeriodicServiceBusSend(String address,
String[] caller, int delaySec) {
this.address = address;
this.caller = caller;
this.delaySec = delaySec;
}
public void start() throws Exception {
System.out.println(this.getClass().getSimpleName()
+ "(" + address + ", " + delaySec + ") starts...");
this.eb = vertx.eventBus();
this.start = LocalTime.now();
vertx.setPeriodic(delaySec * 1000, v -> {
int i = (int)ChronoUnit.SECONDS.between(start,
LocalTime.now()) - 1;
System.out.println(this.getClass().getSimpleName()
+ " to address " + address + ": " + caller[i]);
eb.rxSend(address, caller[i]).subscribe(reply -> {
System.out.println(this.getClass().getSimpleName()
+ " got reply from address " + address
+ ":\n " + reply.body());
if(i + 1 >= caller.length ){
vertx.undeploy(deploymentID());
}
}, Throwable::printStackTrace);
});
}
}
之前的代码会在每delaySec秒向一个地址发送一次消息,发送次数与数组caller[]的长度相同,然后卸载这个垂直(即自身)。如果我们运行这个演示,输出结果的开头将如下所示:

如您所见,对于每个地址,只有DbServiceBus(1)是第一条消息的接收者。第二条发送到同一地址的消息被DbServiceBus(2)接收。这就是我们之前提到的轮询算法(round-robin algorithm)在起作用。输出结果的最后部分看起来如下所示:

我们可以根据需要部署相同类型的垂直。例如,让我们部署四个发送消息到地址INSERT的垂直:
String[] msg1 = {"Mayur", "Rohit", "Nick" };
RxHelper.deployVerticle(vertx,
new PeriodicServiceBusSend(DbServiceBus.INSERT, msg1, 1));
RxHelper.deployVerticle(vertx,
new PeriodicServiceBusSend(DbServiceBus.INSERT, msg1, 1));
RxHelper.deployVerticle(vertx,
new PeriodicServiceBusSend(DbServiceBus.INSERT, msg1, 1));
RxHelper.deployVerticle(vertx,
new PeriodicServiceBusSend(DbServiceBus.INSERT, msg1, 1));
为了查看结果,我们还将要求读取垂直读取最后八条记录:
String[] msg3 = {"1", "1", "2", "8" };
RxHelper.deployVerticle(vertx,
new PeriodicServiceBusSend(DbServiceBus.READ_PROCESSED,
msg3, 1));
结果(输出结果的最后部分)将如预期的那样:

四个垂直发送了相同的消息,所以每个名称发送了四次,这就是我们在之前的输出中看到的内容。
现在,我们将回到一个插入周期垂直,但将其从使用rxSend()方法更改为publish()方法:
PeriodicServiceBusPublish(String address, String[] caller, int delaySec) {
...
vertx.setPeriodic(delaySec * 1000, v -> {
int i = (int)ChronoUnit.SECONDS.between(start,
LocalTime.now()) - 1;
System.out.println(this.getClass().getSimpleName()
+ " to address " + address + ": " + caller[i]);
eb.publish(address, caller[i]);
if(i + 1 == caller.length ){
vertx.undeploy(deploymentID());
}
});
}
这个更改意味着消息必须发送到在该地址注册为消费者的所有垂直。现在,让我们运行以下代码:
Vertx vertx = vertx();
RxHelper.deployVerticle(vertx, new DbServiceBus(1));
RxHelper.deployVerticle(vertx, new DbServiceBus(2));
delayMs(200);
String[] msg1 = {"Mayur", "Rohit", "Nick" };
RxHelper.deployVerticle(vertx,
new PeriodicServiceBusPublish(DbServiceBus.INSERT,
msg1, 1));
delayMs(200);
String[] msg2 = {"all", "all", "all" };
RxHelper.deployVerticle(vertx,
new PeriodicServiceBusSend(DbServiceBus.PROCESS,
msg2, 1));
String[] msg3 = {"1", "1", "2", "8" };
RxHelper.deployVerticle(vertx,
new PeriodicServiceBusSend(DbServiceBus.READ_PROCESSED,
msg3, 1));
我们为发布垂直发送消息留出 200 毫秒的延迟,以便发布垂直有时间发送消息。输出(在最后部分)现在显示每条消息都被处理了两次:

这是因为部署了两个消费者DbServiceBus(1)和DbServiceBus(2),每个都收到了发送到地址INSERT的消息并将其插入到who_called表中。
我们之前运行的所有示例都是在单个 JVM 进程中运行的。如果需要,可以通过在运行命令中添加-cluster选项将 Vert.x 实例部署在不同的 JVM 进程中并实现集群。因此,它们共享事件总线,地址对所有 Vert.x 实例都是可见的。这样,可以根据需要向每个地址添加资源。例如,我们可以仅增加处理微服务的数量,以补偿负载的增加。
我们之前提到的其他框架也有类似的功能。它们使微服务的创建变得容易,并可能鼓励将应用程序分解成微小的单方法操作,期望组装一个非常健壮和响应迅速的系统。
然而,这些并非是衡量高质量的唯一标准。系统分解增加了其部署的复杂性。此外,如果一个开发团队负责许多微服务,那么在不同阶段(开发、测试、集成测试、认证、预发布、生产)对这些多个部分进行版本控制可能会引起混淆,并导致一个极具挑战性的部署过程,这反过来可能会减缓系统与市场需求保持同步所需的变更速度。
除了开发微服务之外,还必须解决许多其他方面的问题以支持反应式系统:
-
必须设计一个监控系统,以便深入了解应用程序的状态,但它不应过于复杂,以至于将开发资源从主要应用程序中拉走。
-
必须安装警报来及时提醒团队关于可能和实际问题的信息,以便在影响业务之前得到解决。
-
如果可能的话,必须实施自我纠正的自动化流程。例如,系统应该能够根据当前负载添加和释放资源;重试逻辑必须实现一个合理的尝试上限,在宣布失败之前。
-
必须有一层断路器来保护系统免受单点故障的连锁反应,当一个组件的故障剥夺了其他组件所需资源时。
-
内嵌的测试系统应该能够引入中断并模拟处理负载,以确保应用程序的弹性和响应性不会随着时间的推移而降低。例如,Netflix 团队引入了一个名为 混沌猴子 的系统,该系统能够关闭生产系统的各个部分以测试恢复能力。他们甚至在生产环境中使用它,因为生产环境有一个特定的配置,而在其他环境中进行的任何测试都无法保证发现所有可能的问题。
反应式系统设计的主要考虑之一是选择部署方法,这可以是无容器、自包含或在容器内。我们将在本课程的后续部分探讨这些方法的优缺点。
无容器部署
人们使用术语容器来指代非常不同的事物。在原始用法中,容器是一种将内容从一个地方运送到另一个地方而不改变内容内部任何东西的东西。然而,当服务器被引入时,只强调了一个方面,即能够容纳应用程序以包含它。此外,还增加了一个含义,提供生命支持的基础设施,以便容器的内容(一个应用程序)不仅能够生存,而且能够活跃并对外部请求做出响应。这种重新定义的容器概念被应用于 Web 服务器(servlet 容器)、应用程序服务器(带有或没有 EJB 容器的应用程序容器)以及其他提供应用程序支持环境的软件设施。有时,JVM 本身甚至被称为容器,但这种联系可能没有持续下去,可能是因为积极参与(执行)内容的能力与容器的原始含义不太一致。
正因如此,后来当人们开始谈论无容器部署时,他们通常指的是将应用程序直接部署到 JVM 中的能力,而无需首先安装 WebSphere、WebLogic、JBoss 或任何其他提供应用程序运行环境的中间件软件。
在前面的章节中,我们描述了许多框架,这些框架允许我们构建和部署应用程序(或者更确切地说,是一个无容器微服务系统),而无需使用 JVM 之外的任何其他容器。你所需做的只是构建一个包含所有依赖项(除了来自 JVM 本身的依赖项)的胖 JAR 文件,然后作为一个独立的 Java 进程运行它:
$ java -jar myfatjar.jar
嗯,你还需要确保你的 JAR 文件中的MANIFEST.MF有一个指向具有main()方法的完全限定类名并将在启动时运行的main类的条目。我们已经在之前的章节中描述了如何做到这一点,构建微服务。
这就是 Java 承诺的一次编译,到处运行的特点,这里的“到处”指的是安装了某个版本或更高版本的 JVM 的任何地方。这种方法的优点和缺点有很多。我们将讨论它们,而不是相对于传统服务器容器部署的情况。不使用传统容器进行部署的优点非常明显,从更少的(如果有的话)许可成本开始,到更轻量级的部署和可扩展性过程,更不用说资源消耗更少了。相反,我们将比较无容器部署,不是与传统的部署相比,而是与几年前开发的新一代容器中的自包含和在容器内的部署相比。
它们不仅允许包含和执行包含的代码,这与传统的容器也做到了,而且还可以将其移动到不同的位置,而无需对包含的代码进行任何更改。从现在起,当我们提到容器时,我们只指新的容器。
无容器部署的优点如下:
-
很容易在同一个物理(或虚拟或云)机器内部或在新硬件上添加更多的 Java 进程。
-
进程之间的隔离级别很高,这在共享环境中尤为重要,当你无法控制其他共同部署的应用程序时,可能有一个恶意应用程序会试图渗透相邻的执行环境。
-
它的占用空间很小,因为它不包含除应用程序本身或一组微服务之外的其他任何内容。
无容器部署的缺点如下:
-
每个 JAR 文件都需要特定版本或更高版本的 JVM,这可能会迫使你仅仅因为这个原因就启动一个新的物理或虚拟机来部署特定的 JAR 文件。
-
在你无法控制的环境中,你的代码可能会使用错误的 JVM 版本部署,这可能导致不可预测的结果。
-
在同一个 JVM 中的进程会竞争资源,这在由不同团队或不同公司共享的环境中尤其难以管理。
-
当多个微服务捆绑到同一个 JAR 文件中时,它们可能需要不同版本的第三方库,甚至是不兼容的库。
微服务可以单独每个 JAR 部署,也可以由团队、相关服务、规模单元或使用其他标准打包在一起。其中最不可忽视的考虑因素是此类 JAR 文件的总数。随着这个数字的增长(谷歌今天一次处理数十万个部署单元),可能无法通过简单的 bash 脚本来处理部署,而需要复杂的流程,以便对可能的不兼容性负责。如果是这种情况,那么考虑使用虚拟机或容器(在其新形态中,见下文)以实现更好的隔离和管理是合理的。
自包含的微服务
自包含的微服务看起来与无容器部署非常相似。唯一的区别是,JVM(或 JRE,实际上)或任何其他必要的应用程序运行的外部框架和服务器都包含在胖 JAR 文件中。有许多方法可以构建这样的全包含 JAR 文件。
例如,Spring Boot 提供了一个方便的 GUI 复选框列表,允许你选择你想要打包的 Spring Boot 应用程序和外部工具的部分。同样,WildFly Swarm 允许你选择你想要与应用程序捆绑的 Java EE 组件的部分。或者,你也可以使用javapackager工具自己完成。它将应用程序和 JRE 编译并打包在同一 JAR 文件中(也可以是.exe或.dmg)以进行分发。你可以在 Oracle 网站上了解有关该工具的更多信息docs.oracle.com/javase/9/tools/javapackager.htm,或者你可以在安装了 JDK 的计算机上运行javapackager命令(它也包含在 Java 8 中),你将获得工具选项及其简要描述的列表。
基本上,要使用javapackager工具,你需要做的就是准备一个包含你想要打包在一起的所有内容的工程,包括所有依赖项(打包在 JAR 文件中),然后运行带有必要选项的javapackager命令,这些选项允许你指定你想要的输出类型(例如.exe或.dmg),你想要捆绑的 JRE 位置,要使用的图标,MANIFEST.MF的main类入口,等等。还有 Maven 插件可以使打包命令更简单,因为大部分设置都需要在pom.xml中配置。
自包含部署的优点如下:
-
它是一个文件(包含构成反应式系统的所有微服务或其部分),这对于用户和分销商来说都更简单
-
没有必要预先安装 JRE,也没有版本不匹配的风险
-
隔离级别很高,因为你的应用程序有一个专用的 JRE,所以来自共同部署的应用程序的入侵风险最小
-
你可以完全控制包含在捆绑包中的依赖项
缺点如下:
-
文件的大小更大,这可能会成为下载时的障碍
-
配置比无容器 JAR 文件的配置更复杂
-
捆绑包必须在与目标平台匹配的平台上生成,如果你无法控制安装过程,这可能会导致不匹配
-
在同一硬件或虚拟机上部署的其他进程可能会占用对应用程序需求至关重要的资源,如果你的应用程序不是由开发它的团队下载和运行,这将特别难以管理
容器内部署
那些熟悉虚拟机(VM)但不熟悉现代容器(如 Docker、CoreOS 的 Rocket、VMware Photon 或类似技术)的人可能会产生这样的印象:我们在谈论容器时,实际上是在谈论虚拟机,因为容器不仅能包含和执行代码,还能将其移动到不同的位置,而不会对包含的代码进行任何更改。如果是这样,那将是一个非常恰当的假设。虚拟机确实允许所有这些操作,而现代容器可以被视为轻量级的虚拟机,因为它也允许分配资源并提供独立机器的感觉。然而,容器并不是一个完整的独立虚拟计算机。
关键的区别在于,可以作为虚拟机传递的捆绑包包括整个操作系统(以及部署的应用程序)。因此,一个物理服务器上运行两个虚拟机可能会运行两个不同的操作系统。相比之下,一个物理服务器(或虚拟机)上运行三个容器化应用程序只有一个操作系统在运行,这两个容器共享(只读)操作系统内核,每个容器都有其自己的访问(挂载)权限,用于写入它们不共享的资源。这意味着,例如,启动时间会大大缩短,因为启动容器不需要我们引导操作系统(如虚拟机的情况)。
以社区容器领导者 Docker 为例,让我们更深入地了解一下。2015 年,一个名为开放容器项目的倡议被宣布,后来更名为开放容器倡议(OCI),得到了谷歌、IBM、亚马逊、微软、红帽、甲骨文、VMware、惠普、推特和许多其他公司的支持。其目的是为所有平台开发容器格式和容器运行时软件的行业标准。Docker 将其大约 5%的代码库捐赠给了该项目,因为其解决方案被选为起点。
在docs.docker.com有大量的 Docker 文档。使用 Docker,可以将 Java EE 容器和应用程序作为一个 Docker 镜像打包,从而实现与自包含部署基本相同的结果。然后,您可以通过在 Docker 引擎中启动 Docker 镜像来启动您的应用程序,使用以下命令:
$ docker run mygreatapplication
它启动的过程类似于在物理计算机上运行操作系统,尽管它也可能发生在云中,在运行在物理 Linux 服务器上的虚拟机中,该服务器由许多不同的公司和个人共享。这就是为什么隔离级别(在容器的情况下,几乎与虚拟机一样高)在选择不同的部署模型时可能至关重要。
一个典型的建议是将每个微服务放入一个容器中,但没有任何东西阻止你将多个微服务放入一个 Docker 镜像(或者任何其他容器)。然而,已经存在成熟的容器管理系统(在容器世界中称为编排),可以帮助你进行部署,因此拥有许多容器的复杂性,尽管是一个有效的考虑因素,但如果涉及到弹性和响应性,不应该成为一个大的障碍。其中一种流行的编排称为Kubernetes,支持微服务注册、发现和负载均衡。Kubernetes 可以在任何云或私有基础设施中使用。
容器允许在几乎任何当前的部署环境中快速、可靠和一致地部署,无论是你的基础设施还是亚马逊、谷歌或微软的云。它们还允许应用程序轻松地在开发、测试和生产阶段之间移动。这种基础设施独立性允许你在必要时使用公共云进行开发和测试,并使用你自己的计算机进行生产。
一旦创建了一个基础操作系统镜像,每个开发团队就可以在其上构建他们的应用程序,从而避免了环境配置的复杂性。容器版本也可以在版本控制系统中跟踪。
使用容器的优点如下:
-
与无容器和自包含部署相比,隔离级别是最高的。此外,最近还投入了更多努力来增强容器的安全性。
-
每个容器都由同一组命令进行管理、分发、部署、启动和停止。
-
没有必要预先安装 JRE,也无需担心版本不匹配的风险。
-
你可以完全控制容器中包含的依赖项。
-
通过添加/删除容器实例,可以轻松地向上/向下扩展每个微服务。
使用容器的缺点如下:
- 你和你的团队必须学习一套全新的工具,并在生产阶段更加深入地参与。另一方面,这似乎是近年来的一般趋势。
摘要
微服务是针对高度负载处理系统的一种新的架构和设计解决方案,在亚马逊、谷歌、推特、微软、IBM 等巨头在生产中成功应用后变得流行。但这并不意味着你必须采用它,但你可以考虑这种新方法,看看它是否可以帮助你的应用程序更加弹性和响应。
使用微服务可以提供巨大的价值,但这并非没有代价。它伴随着管理更多单元的复杂性,这些单元需要从需求、开发、测试到生产的整个生命周期进行管理。在全面采用微服务架构之前,先尝试实现几个微服务并将它们全部部署到生产环境中。然后,让它运行一段时间并评估经验。这将对您的组织非常具体。任何成功的解决方案都不能盲目复制,而应该根据您的特定需求和能力进行采用。
通过逐步改进现有的内容,通常可以比通过彻底的重设计和重构实现更好的性能和整体效率。
在下一课中,我们将讨论和演示新的 API,它可以通过使代码更具可读性和更快的性能来改进您的代码。
评估
-
使用该 _________ 对象,可以部署各种 verticles,它们相互通信,接收外部请求,并以任何其他 Java 应用程序的方式处理和存储数据,从而形成一个微服务系统。
-
以下哪项是无容器部署的优势?
-
每个 JAR 文件都需要一定版本或更高版本的 JVM,这可能会迫使您仅为此原因启动一个新的物理或虚拟机来部署特定的 JAR 文件。
-
在您无法控制的环境中,您的代码可能会使用正确的 JVM 版本进行部署,这可能导致不可预测的结果。
-
同一个 JVM 中的进程会竞争资源,这在由不同团队或不同公司共享的环境中尤其难以管理。
-
由于它不包括除应用程序本身或一组微服务之外的其他任何内容,因此它具有很小的占用空间。
-
-
判断对错:支持跨多个微服务的交易的一种方法是为扮演并行事务管理器角色的服务创建一个服务。
-
以下哪些是 Java 9 中包含的 Java 框架?
-
Akka
-
Ninja
-
Orange
-
Selenium
-
-
判断对错:与无容器和自包含部署相比,容器中的隔离级别是最高的。
第五章:利用新 API 改进您的代码
在之前的课程中,我们讨论了提高 Java 应用程序性能的可能方法——从使用新的命令和监控工具到添加多线程,引入响应式编程,甚至将当前解决方案彻底重构为混乱而灵活的一组小型独立部署单元和微服务。由于不了解您的具体情况,我们无法猜测提供的建议中哪些建议对您有帮助。这就是为什么在本课中,我们将描述一些对您也有帮助的 JDK 的最新补充。正如我们在之前的课程中提到的,性能和整体代码改进的收益并不总是需要我们彻底重新设计。有时,小的增量变化可以带来比我们预期的更显著的改进。
以我们构建金字塔的类比为例,与其试图改变将石头运送到最终目的地的物流——为了缩短建设时间——通常更明智的做法是首先仔细看看建造者使用的工具。如果每个操作都可以在半数时间内完成,那么整个项目的交付时间可以相应缩短,即使每个石块移动的距离相同,甚至更长。
这些是我们将在本课中讨论的编程工具的改进:
-
使用流上的过滤器作为找到所需内容并减少工作负载的方法
-
一种新的堆栈跟踪 API,作为以编程方式分析堆栈跟踪以应用自动纠正的方法
-
新的方便的静态工厂方法,用于创建紧凑、不可修改的集合实例
-
新的
CompletableFuture类作为访问异步处理结果的方法 -
JDK 9 流 API 的改进,可以在加快处理速度的同时使您的代码更易于阅读
过滤流
java.util.streams.Stream接口是在 Java 8 中引入的。它发出元素并支持基于这些元素执行计算的各种操作。流可以是有限的或无限的,缓慢或快速发出。自然地,总是存在一个担忧,即新发出的元素的速率可能高于处理速率。此外,跟上输入的能力反映了应用程序的性能。《Stream》实现通过使用缓冲区和各种其他技术调整发出和处理速率来解决背压(当元素处理的速率低于它们的发出速率时)。此外,如果应用程序开发者确保尽可能早地做出处理或跳过每个特定元素的决定,那么这总是有帮助的,这样就不会浪费处理资源。根据情况,可以使用不同的操作来过滤数据。
基本过滤
执行过滤的第一个且最直接的方法是使用filter()操作。为了展示所有以下功能,我们将使用Senator类:
public class Senator {
private int[] voteYes, voteNo;
private String name, party;
public Senator(String name, String party,
int[] voteYes, int[] voteNo) {
this.voteYes = voteYes;
this.voteNo = voteNo;
this.name = name;
this.party = party;
}
public int[] getVoteYes() { return voteYes; }
public int[] getVoteNo() { return voteNo; }
public String getName() { return name; }
public String getParty() { return party; }
public String toString() {
return getName() + ", P" +
getParty().substring(getParty().length() - 1);
}
}
如您所见,这个类捕捉了参议员的姓名、所属党派以及他们如何对每个问题进行投票(0表示否,1表示是)。如果对于特定问题i,voteYes[i]=0且voteNo[i]=0,这意味着参议员缺席。对于同一问题,不可能同时有voteYes[i]=1和voteNo[i]=1。
假设有 100 位参议员,每位参议员属于两个党派之一:Party1或Party2。我们可以使用这些对象收集参议员在过去 10 个问题上的投票统计信息,使用Senate类:
public class Senate {
public static List<Senator> getSenateVotingStats(){
List<Senator> results = new ArrayList<>();
results.add(new Senator("Senator1", "Party1",
new int[]{1,0,0,0,0,0,1,0,0,1},
new int[]{0,1,0,1,0,0,0,0,1,0}));
results.add(new Senator("Senator2", "Party2",
new int[]{0,1,0,1,0,1,0,1,0,0},
new int[]{1,0,1,0,1,0,0,0,0,1}));
results.add(new Senator("Senator3", "Party1",
new int[]{1,0,0,0,0,0,1,0,0,1},
new int[]{0,1,0,1,0,0,0,0,1,0}));
results.add(new Senator("Senator4", "Party2",
new int[]{1,0,1,0,1,0,1,0,0,1},
new int[]{0,1,0,1,0,0,0,0,1,0}));
results.add(new Senator("Senator5", "Party1",
new int[]{1,0,0,1,0,0,0,0,0,1},
new int[]{0,1,0,0,0,0,1,0,1,0}));
IntStream.rangeClosed(6, 98).forEach(i -> {
double r1 = Math.random();
String name = "Senator" + i;
String party = r1 > 0.5 ? "Party1" : "Party2";
int[] voteNo = new int[10];
int[] voteYes = new int[10];
IntStream.rangeClosed(0, 9).forEach(j -> {
double r2 = Math.random();
voteNo[j] = r2 > 0.4 ? 0 : 1;
voteYes[j] = r2 < 0.6 ? 0 : 1;
});
results.add(new Senator(name,party,voteYes,voteNo));
});
results.add(new Senator("Senator99", "Party1",
new int[]{0,0,0,0,0,0,0,0,0,0},
new int[]{1,1,1,1,1,1,1,1,1,1}));
results.add(new Senator("Senator100", "Party2",
new int[]{1,1,1,1,1,1,1,1,1,1},
new int[]{0,0,0,0,0,0,0,0,0,0}));
return results;
}
public static int timesVotedYes(Senator senator){
return Arrays.stream(senator.getVoteYes()).sum();
}
}
我们为前五位参议员硬编码了统计数据,以便在测试过滤器时获得可预测的结果,并验证过滤器是否工作。我们还为最后两位参议员硬编码了投票统计数据,以便在寻找只对十个问题中的每一个投了是或只投了否的参议员时,有一个可预测的计数。我们还添加了timesVotedYes()方法,它提供了给定senator投是的次数。
现在,我们可以从Senate类中收集一些数据。例如,让我们看看每个党派的成员在Senate类中占多少比例:
List<Senator> senators = Senate.getSenateVotingStats();
long c1 = senators.stream()
.filter(s -> s.getParty() == "Party1").count();
System.out.println("Members of Party1: " + c1);
long c2 = senators.stream()
.filter(s -> s.getParty() == "Party2").count();
System.out.println("Members of Party2: " + c2);
System.out.println("Members of the senate: " + (c1 + c2));
前述代码的结果因我们在Senate类中使用的随机值生成器而有所不同,因此不要期望在尝试运行示例时看到完全相同的数字。重要的是两个党派成员的总数应该等于 100--Senate类中参议员的总数:

表达式s -> s.getParty()=="Party1"是过滤条件,只过滤出属于Party1的参议员。因此,Party2的元素(Senator对象)无法通过,并且不计入总数。这相当直接。
现在,让我们看看一个更复杂的过滤示例。让我们计算每个党派的参议员在issue 3上投票的人数:
int issue = 3;
c1 = senators.stream()
.filter(s -> s.getParty() == "Party1")
.filter(s -> s.getVoteNo()[issue] != s.getVoteYes()[issue])
.count();
System.out.println("Members of Party1 who voted on Issue" +
issue + ": " + c1);
c2 = senators.stream()
.filter(s -> s.getParty() == "Party2" &&
s.getVoteNo()[issue] != s.getVoteYes()[issue])
.count();
System.out.println("Members of Party2 who voted on Issue" +
issue + ": " + c2);
System.out.println("Members of the senate who voted on Issue"
+ issue + ": " + (c1 + c2));
对于Party1,我们使用了两个过滤器。对于Party2,我们将它们结合起来,只是为了展示另一种可能的解决方案。这里的重要点是首先使用按党派过滤(s -> s.getParty() == "Party1"),然后再使用只选择那些投票的人的过滤器。这样,第二个过滤器只应用于大约一半的元素。否则,如果将只选择那些投票的人的过滤器放在第一位,它将应用于所有的 100 个Senate成员。
结果看起来像这样:

同样,我们可以计算每个党派有多少成员在issue 3上投了是:
c1 = senators.stream()
.filter(s -> s.getParty() == "Party1" &&
s.getVoteYes()[issue] == 1)
.count();
System.out.println("Members of Party1 who voted Yes on Issue"
+ issue + ": " + c1);
c2 = senators.stream()
.filter(s -> s.getParty() == "Party2" &&
s.getVoteYes()[issue] == 1)
.count();
System.out.println("Members of Party2 who voted Yes on Issue"
+ issue + ": " + c2);
System.out.println("Members of the senate voted Yes on Issue"
+ issue + ": " + (c1 + c2));
前述代码的结果如下:

我们可以通过利用 Java 函数式编程能力(使用 lambda 表达式)对前述示例进行重构,并创建countAndPrint()方法:
long countAndPrint(List<Senator> senators,
Predicate<Senator> pred1, Predicate<Senator> pred2,
String prefix) {
long c = senators.stream().filter(pred1::test)
.filter(pred2::test).count();
System.out.println(prefix + c);
return c;
}
现在所有之前的代码都可以用更紧凑的方式表达:
int issue = 3;
Predicate<Senator> party1 = s -> s.getParty() == "Party1";
Predicate<Senator> party2 = s -> s.getParty() == "Party2";
Predicate<Senator> voted3 =
s -> s.getVoteNo()[issue] != s.getVoteYes()[issue];
Predicate<Senator> yes3 = s -> s.getVoteYes()[issue] == 1;
long c1 = countAndPrint(senators, party1, s -> true,
"Members of Party1: ");
long c2 = countAndPrint(senators, party2, s -> true,
"Members of Party2: ");
System.out.println("Members of the senate: " + (c1 + c2));
c1 = countAndPrint(senators, party1, voted3,
"Members of Party1 who voted on Issue" + issue + ": ");
c2 = countAndPrint(senators, party2, voted3,
"Members of Party2 who voted on Issue" + issue + ": ");
System.out.println("Members of the senate who voted on Issue"
+ issue + ": " + (c1 + c2));
c1 = countAndPrint(senators, party1, yes3,
"Members of Party1 who voted Yes on Issue" + issue + ": ");
c2 = countAndPrint(senators, party2, yes3,
"Members of Party2 who voted Yes on Issue" + issue + ": ");
System.out.println("Members of the senate voted Yes on Issue"
+ issue + ": " + (c1 + c2));
我们创建了四个谓词,party1、party2、voted3和yes3,并将它们中的每一个作为countAndPrint()方法的参数使用了几次。此代码的输出与先前的示例相同:

使用Stream接口的filter()方法是过滤的最流行方式。但也可以使用其他Stream方法来达到相同的效果。
使用其他流操作进行过滤
或者,除了上一节中描述的基本过滤之外,还可以使用其他操作(Stream接口的方法)来进行选择和过滤发出的流元素。
例如,让我们使用flatMap()方法过滤掉参议院的成员,根据他们的党派成员资格:
long c1 = senators.stream()
.flatMap(s -> s.getParty() == "Party1" ?
Stream.of(s) : Stream.empty())
.count();
System.out.println("Members of Party1: " + c1);
此方法利用了Stream.of()(产生一个元素的流)和Stream.empty()工厂方法(它产生一个没有元素的流,因此不会进一步发出任何内容)。或者,可以使用在 Java 9 中引入的新工厂方法Stream.ofNullable()达到相同的效果。
c1 = senators.stream().flatMap(s ->
Stream.ofNullable(s.getParty() == "Party1" ? s : null))
.count();
System.out.println("Members of Party1: " + c1);
Stream.ofNullable()方法在非null的情况下创建一个包含一个元素的流;否则,它创建一个空流,就像之前的例子一样。如果我们以相同的参议院组成运行这两个先前的代码片段,它们会产生相同的输出:

然而,可以使用可能包含或不包含值的java.util.Optional类来达到相同的结果。如果存在值(且不是null),则其isPresent()方法返回true,而get()方法返回该值。以下是如何使用它来过滤掉一个党派的成员:
long c2 = senators.stream()
.map(s -> s.getParty() == "Party2" ?
Optional.of(s) : Optional.empty())
.flatMap(o -> o.map(Stream::of).orElseGet(Stream::empty))
.count();
System.out.println("Members of Party2: " + c2);
首先,我们将一个元素(Senator对象)映射(转换)为一个带有或不带有值的Optional对象。然后,我们使用flatMap()方法生成一个包含单个元素的流或空流,然后计算通过这些元素的个数。在 Java 9 中,Optional类获得了一个新的工厂方法stream(),如果Optional对象包含非空值,则它产生一个包含一个元素的流;否则,它产生一个空流。使用这个新方法,我们可以将之前的代码重写如下:
long c2 = senators.stream()
.map(s -> s.getParty() == "Party2" ?
Optional.of(s) : Optional.empty())
.flatMap(Optional::stream)
.count();
System.out.println("Members of Party2: " + c2);
如果我们以相同的参议院组成运行这两个先前的示例,它们会产生相同的输出:

当我们需要捕获流中发出的第一个元素时,我们可以应用另一种类型的过滤。这意味着我们在第一个元素发出后终止流。例如,让我们找到在issue 3上投票Yes的Party1的第一个参议员:
senators.stream()
.filter(s -> s.getParty() == "Party1" &&
s.getVoteYes()[3] == 1)
.findFirst()
.ifPresent(s -> System.out.println("First senator "
"of Party1 found who voted Yes on issue 3: "
+ s.getName()));
findFirst() method, which does the described job. It returns the Optional object, so we have added another ifPresent() operator that is invoked only if the Optionalobject contains a non-null value. The resulting output is as follows:

这正是我们在Senate类中播种数据时预期的结果。
同样,我们可以使用findAny()方法来找出在issue 3上投了Yes票的任何参议员:
senators.stream().filter(s -> s.getVoteYes()[3] == 1)
.findAny()
.ifPresent(s -> System.out.println("A senator " +
"found who voted Yes on issue 3: " + s));
结果也正如预期的那样:

这通常是(但不一定是)流中的第一个元素。但不应依赖于这个假设,尤其是在并行处理的情况下。
Stream接口也有三个match方法,尽管它们返回一个布尔值,但如果不需要特定的对象,我们只需要确定这样的对象是否存在,也可以用于过滤。这些方法的名称是anyMatch()、allMatch()和noneMatch()。每个方法都接受一个谓词并返回一个布尔值。让我们首先演示anyMatch()方法。我们将使用它来找出是否有至少一位Party1的参议员在issue 3上投了Yes票:
boolean found = senators.stream()
.anyMatch(s -> (s.getParty() == "Party1" &&
s.getVoteYes()[3] == 1));
String res = found ?
"At least one senator of Party1 voted Yes on issue 3"
: "Nobody of Party1 voted Yes on issue 3";
System.out.println(res);
运行前一段代码的结果应该如下所示:

为了演示allMatch()方法,我们将使用它来找出Senate类中Party1的所有成员是否都在issue 3上投了Yes票:
boolean yes = senators.stream()
.allMatch(s -> (s.getParty() == "Party1" &&
s.getVoteYes()[3] == 1));
String res = yes ?
"All senators of Party1 voted Yes on issue 3"
: "Not all senators of Party1 voted Yes on issue 3";
System.out.println(res);
前一段代码的结果可能如下所示:

三种match方法中的最后一种——noneMatch()方法——将用于确定Party1中的某些参议员是否在issue 3上投了Yes票:
boolean yes = senators.stream()
.noneMatch(s -> (s.getParty() == "Party1" &&
s.getVoteYes()[3] == 1));
String res = yes ?
"None of the senators of Party1 voted Yes on issue 3"
: "Some of senators of Party1 voted Yes on issue 3";
System.out.println(res);
早期示例的结果如下所示:

然而,在现实生活中,情况可能非常不同,因为Senate类中的许多问题都是按党派投票的。
当我们需要跳过流中的所有重复元素并仅选择唯一元素时,就需要另一种类型的过滤。distinct()方法就是为了这个目的设计的。我们将使用它来找出在Senate类中有成员的党派的名称:
senators.stream().map(s -> s.getParty())
.distinct().forEach(System.out::println);
结果,正如预期的那样,如下所示:

嗯,这并不令人惊讶?
我们还可以使用limit()方法过滤掉stream中的所有元素,除了前几个特定的数量:
System.out.println("These are the first 3 senators "
+ "of Party1 in the list:");
senators.stream()
.filter(s -> s.getParty() == "Party1")
.limit(3)
.forEach(System.out::println);
System.out.println("These are the first 2 senators "
+ "of Party2 in the list:");
senators.stream().filter(s -> s.getParty() == "Party2")
.limit(2)
.forEach(System.out::println);
如果你记得我们是如何设置列表中的前五位参议员的,你可以预测结果如下所示:

现在,让我们在流中找到唯一的一个元素——最大的一个。为此,我们可以使用Stream接口的max()方法和Senate.timeVotedYes()方法(我们将对每位参议员应用它):
senators.stream()
.max(Comparator.comparing(Senate::timesVotedYes))
.ifPresent(s -> System.out.println("A senator voted "
+ "Yes most of times (" + Senate.timesVotedYes(s)
+ "): " + s));
timesVotedYes() method to select the senator who voted Yes most often. You might remember, we have assigned all instances of Yes to Senator100. Let's see if that would be the result:

是的,我们已经将Senator100过滤为在所有 10 个问题上都投了Yes票的人。
同样,我们可以找到在所有 10 个问题上都投了No票的参议员:
senators.stream()
.min(Comparator.comparing(Senate::timesVotedYes))
.ifPresent(s -> System.out.println("A senator voted "
+ "Yes least of times (" + Senate.timesVotedYes(s)
+ "): " + s));
我们预计结果将是Senator99,以下是结果:

因此,我们在Senate类中硬编码了几个统计信息,以便我们可以验证我们的查询是否正确工作。
由于前两种方法可以帮助我们进行过滤,我们将演示 JDK 9 中引入的takeWhile()和dropWhile()方法。我们首先将打印前五位参议员的数据,然后使用takeWhile()方法打印直到我们遇到投票Yes超过四次的第一位参议员,然后停止打印:
System.out.println("Here is count of times the first "
+ "5 senators voted Yes:");
senators.stream().limit(5)
.forEach(s -> System.out.println(s + ": "
+ Senate.timesVotedYes(s)));
System.out.println("Stop printing at a senator who "
+ "voted Yes more than 4 times:");
senators.stream().limit(5)
.takeWhile(s -> Senate.timesVotedYes(s) < 5)
.forEach(s -> System.out.println(s + ": "
+ Senate.timesVotedYes(s)));
上一段代码的结果如下:

dropWhile()方法可以用于相反的效果,即过滤掉,跳过直到我们遇到投票Yes超过四次的第一位参议员,然后继续打印所有其他参议员:
System.out.println("Here is count of times the first "
+ "5 senators voted Yes:");
senators.stream().limit(5)
.forEach(s -> System.out.println(s + ": "
+ Senate.timesVotedYes(s)));
System.out.println("Start printing at a senator who "
+ "voted Yes more than 4 times:");
senators.stream().limit(5)
.dropWhile(s -> Senate.timesVotedYes(s) < 5)
.forEach(s -> System.out.println(s + ": "
+ Senate.timesVotedYes(s)));
System.out.println("...");
结果将如下所示:

这就结束了我们对元素流过滤方式的演示。我们希望你已经学到了足够的知识,能够为你的任何过滤需求找到解决方案。尽管如此,我们鼓励你自己学习和实验 Stream API,这样你可以保留到目前为止所学到的知识,并形成自己对 Java 9 丰富 API 的个人看法。
堆栈跟踪 API
异常确实会发生,尤其是在开发期间或软件稳定期。但在一个大型复杂系统中,即使在生产环境中,出现异常的可能性也是可能的,尤其是在将多个第三方系统组合在一起并需要程序化分析堆栈跟踪以应用自动纠正时。在本节中,我们将讨论如何实现这一点。
Java 9 之前的堆栈分析
使用java.lang.Thread和java.lang.Throwable类的对象的传统读取堆栈跟踪是通过从标准输出捕获来完成的。例如,我们可以在代码的任何部分包含以下行:
Thread.currentThread().dumpStack();
上一行将产生以下输出:

同样,我们可以在代码中包含以下行:
new Throwable().printStackTrace();
输出将如下所示:

这种输出可以被捕获、读取和分析,但需要编写相当多的自定义代码。
JDK 8 通过使用流使这一过程变得简单。以下是从流中读取堆栈跟踪的代码:
Arrays.stream(Thread.currentThread().getStackTrace())
.forEach(System.out::println);
上一行将产生以下输出:

或者,我们可以使用以下代码:
Arrays.stream(new Throwable().getStackTrace())
.forEach(System.out::println);
上一段代码的输出以类似的方式显示了堆栈跟踪:

例如,如果你想找到调用者的完全限定名称,你可以使用以下方法之一:
new Throwable().getStackTrace()[1].getClassName();
Thread.currentThread().getStackTrace()[2].getClassName();
这样的编码是可能的,因为getStackTrace()方法返回一个java.lang.StackTraceElement类的对象数组,每个对象代表堆栈跟踪中的一个堆栈帧。每个对象都携带可通过getFileName()、getClassName()、getMethodName()和getLineNumber()方法访问的堆栈跟踪信息。
为了演示它是如何工作的,我们创建了三个类,Clazz01、Clazz02和Clazz03,它们相互调用:
public class Clazz01 {
public void method(){ new Clazz02().method(); }
}
public class Clazz02 {
public void method(){ new Clazz03().method(); }
}
public class Clazz03 {
public void method(){
Arrays.stream(Thread.currentThread()
.getStackTrace()).forEach(ste -> {
System.out.println();
System.out.println("ste=" + ste);
System.out.println("ste.getFileName()=" +
ste.getFileName());
System.out.println("ste.getClassName()=" +
ste.getClassName());
System.out.println("ste.getMethodName()=" +
ste.getMethodName());
System.out.println("ste.getLineNumber()=" +
ste.getLineNumber());
});
}
}
现在,让我们调用Clazz01的method()方法:
public class Demo02StackWalking {
public static void main(String... args) {
demo_walking();
}
private static void demo_walking(){
new Clazz01().method();
}
}
这里是前述代码打印出的六个堆栈跟踪帧中的两个(第二个和第三个):

在原则上,每个被调用类都可以访问这些信息。但要找出哪个类调用了当前类可能并不容易,因为你需要确定哪个帧代表调用者。此外,为了提供这些信息,JVM 捕获整个堆栈(除了隐藏的堆栈帧),这可能会影响性能。
这就是引入java.lang.StackWalker类、其嵌套的Option类和StackWalker.StackFrame接口在 JDK 9 中的动机。
新的更好的堆栈遍历方式
StackWalker类有四个getInstance()静态工厂方法:
-
getInstance(): 这返回一个配置为跳过所有隐藏帧和调用者类引用的StackWalker类实例 -
getInstance(StackWalker.Option option): 这创建一个具有给定选项的StackWalker类实例,指定它可以访问的堆栈帧信息 -
getInstance(Set<StackWalker.Option> options): 这创建一个具有给定选项集的StackWalker类实例 -
getInstance(Set<StackWalker.Option> options, int estimatedDepth): 这允许你传入estimatedDepth参数,该参数指定此实例将要遍历的堆栈帧的估计数量,以便 Java 机器可以分配它可能需要的适当缓冲区大小
作为选项传入的值可以是以下之一:
-
StackWalker.Option.RETAIN_CLASS_REFERENCE -
StackWalker.Option.SHOW_HIDDEN_FRAMES -
StackWalker.Option.SHOW_REFLECT_FRAMES
StackWalker类的其他三个方法如下:
-
T walk(Function<Stream<StackWalker.StackFrame>, T> function): 这将传入的函数应用于堆栈帧流,第一个帧表示调用此walk()方法的调用方法 -
void forEach(Consumer<StackWalker.StackFrame> action): 这对当前线程流中的每个元素(StalkWalker.StackFrame接口类型)执行传入的操作 -
Class<?> getCallerClass(): 这获取调用者类的Class类对象
如您所见,它允许更直接的堆栈跟踪分析。让我们使用以下代码修改我们的演示类,并在一行中访问调用者名称:
public class Clazz01 {
public void method(){
System.out.println("Clazz01 was called by " +
StackWalker.getInstance(StackWalker
.Option.RETAIN_CLASS_REFERENCE)
.getCallerClass().getSimpleName());
new Clazz02().method();
}
}
public class Clazz02 {
public void method(){
System.out.println("Clazz02 was called by " +
StackWalker.getInstance(StackWalker
.Option.RETAIN_CLASS_REFERENCE)
.getCallerClass().getSimpleName());
new Clazz03().method();
}
}
public class Clazz03 {
public void method(){
System.out.println("Clazz01 was called by " +
StackWalker.getInstance(StackWalker
.Option.RETAIN_CLASS_REFERENCE)
.getCallerClass().getSimpleName());
}
}
之前的代码将产生以下输出:

你可以欣赏这个解决方案的简单性。如果我们需要查看整个堆栈跟踪,我们可以在Clazz03中的代码中添加以下行:
StackWalker.getInstance().forEach(System.out::println);
最终的输出将如下所示:

再次强调,仅用一行代码,我们就得到了更易读的输出。我们可以通过使用walk()方法达到相同的结果:
StackWalker.getInstance().walk(sf -> {
sf.forEach(System.out::println); return null;
});
我们不仅打印StackWalker.StackFrame,如果需要的话,还可以使用其 API 进行更深入的分析,该 API 比java.lang.StackTraceElement的 API 更广泛。让我们运行打印每个堆栈帧及其信息的代码示例:
StackWalker stackWalker =
StackWalker.getInstance(Set.of(StackWalker
.Option.RETAIN_CLASS_REFERENCE), 10);
stackWalker.forEach(sf -> {
System.out.println();
System.out.println("sf="+sf);
System.out.println("sf.getFileName()=" +
sf.getFileName());
System.out.println("sf.getClass()=" + sf.getClass());
System.out.println("sf.getMethodName()=" +
sf.getMethodName());
System.out.println("sf.getLineNumber()=" +
sf.getLineNumber());
System.out.println("sf.getByteCodeIndex()=" +
sf.getByteCodeIndex());
System.out.println("sf.getClassName()=" +
sf.getClassName());
System.out.println("sf.getDeclaringClass()=" +
sf.getDeclaringClass());
System.out.println("sf.toStackTraceElement()=" +
sf.toStackTraceElement());
});
上一段代码的输出如下:

注意实现StackWalker.StackFrame接口并实际执行工作的StackFrameInfo类。API 还允许将它们转换回熟悉的StackTraceElement对象,以实现向后兼容性,以及让那些习惯于它且不想改变他们的代码和习惯的人感到愉快。
相比之下,在内存中生成并存储完整的堆栈跟踪(如传统堆栈跟踪实现的情况),StackWalker类只带来了请求的元素。这是其引入的另一个动机,除了演示的使用简单性之外。有关StackWalker类 API 及其使用的更多详细信息,请参阅docs.oracle.com/javase/9/docs/api/java/lang/StackWalker.html。
集合的便利工厂方法
随着 Java 中函数式编程的引入,对不可变对象的需求增加。传递给方法的功能可能在与它们创建时显著不同的上下文中执行,因此减少意外副作用的可能性使得不可变性更有说服力。此外,Java 创建不可修改集合的方式本身就相当冗长,因此这个问题在 Java 9 中得到了解决。以下是一个在 Java 8 中创建不可变Set接口集合的代码示例:
Set<String> set = new HashSet<>();
set.add("Life");
set.add("is");
set.add("good!");
set = Collections.unmodifiableSet(set);
在多次执行之后,方便方法的必要性自然地作为任何软件专业人士背景思维中始终存在的重构考虑因素而出现。在 Java 8 中,之前的代码可以修改为以下形式:
Set<String> immutableSet =
Collections.unmodifiableSet(new HashSet<>(Arrays
.asList("Life", "is", "good!")));
或者,如果你是流的朋友,你可以写如下代码:
Set<String> immutableSet = Stream.of("Life","is","good!")
.collect(Collectors.collectingAndThen(Collectors.toSet(),
Collections::unmodifiableSet));
之前代码的另一个版本如下:
Set<String> immutableSet =
Collections.unmodifiableSet(Stream.of("Life","is","good!")
.collect(Collectors.toSet()));
然而,它比你要封装的值有更多的样板代码。因此,在 Java 9 中,之前代码的简短版本成为可能:
Set<String> immutableSet = Set.of("Life","is","good!");
类似的生产厂被引入来生成不可变的List接口和Map接口的集合:
List<String> immutableList = List.of("Life","is","good!");
Map<Integer,String> immutableMap1 =
Map.of(1, "Life", 2, "is", 3, "good!");
Map<Integer,String> immutableMap2 =
Map.ofEntries(entry(1, "Life "), entry(2, "is"),
entry(3, "good!");
Map.Entry<Integer,String> entry1 = Map.entry(1,"Life");
Map.Entry<Integer,String> entry2 = Map.entry(2,"is");
Map.Entry<Integer,String> entry3 = Map.entry(3,"good!");
Map<Integer,String> immutableMap3 =
Map.ofEntries(entry1, entry2, entry3);
为什么需要新的工厂方法?
能够以更紧凑的方式表达相同的功能非常有帮助,但这可能不足以成为引入这些新工厂的动机。更重要的是解决Collections.unmodifiableList()、Collections.unmodifiableSet()和Collections.unmodifiableMap()现有实现的弱点。尽管使用这些方法创建的集合在尝试修改或添加/删除其元素时将抛出UnsupportedOperationException异常,但它们只是传统可修改集合的包装,因此可能容易受到修改的影响,这取决于它们的构建方式。让我们通过示例来阐述这一点。顺便说一下,现有不可修改实现的另一个弱点是它不会改变源集合的构建方式,因此List、Set和Map之间的差异——它们可以构建的方式——仍然存在,这可能是程序员使用它们时出现错误或甚至挫败感的来源。新的工厂方法也解决了这个问题,通过仅使用of()工厂方法(以及为Map提供的附加ofEntries()方法)提供了一种更统一的方法。话虽如此,让我们回到示例。看看以下代码片段:
List<String> list = new ArrayList<>();
list.add("unmodifiableList1: Life");
list.add(" is");
list.add(" good! ");
list.add(null);
list.add("\n\n");
List<String> unmodifiableList1 =
Collections.unmodifiableList(list);
//unmodifiableList1.add(" Well..."); //throws exception
//unmodifiableList1.set(2, " sad."); //throws exception
unmodifiableList1.stream().forEach(System.out::print);
list.set(2, " sad. ");
list.set(4, " ");
list.add("Well...\n\n");
unmodifiableList1.stream().forEach(System.out::print);
直接修改unmodifiableList1的元素将导致UnsupportedOperationException异常。尽管如此,我们可以通过底层的list对象来修改它们。如果我们运行前面的示例,输出将如下所示:

即使使用Arrays.asList()来创建源列表,它也只会保护创建的集合不被添加新元素,但不会阻止修改现有元素。以下是一个代码示例:
List<String> list2 =
Arrays.asList("unmodifiableList2: Life",
" is", " good! ", null, "\n\n");
List<String> unmodifiableList2 =
Collections.unmodifiableList(list2);
//unmodifiableList2.add(" Well..."); //throws exception
//unmodifiableList2.set(2, " sad."); //throws exception
unmodifiableList2.stream().forEach(System.out::print);
list2.set(2, " sad. ");
//list2.add("Well...\n\n"); //throws exception
unmodifiableList2.stream().forEach(System.out::print);
如果我们运行前面的代码,输出将如下所示:

我们还添加了一个null元素来展示现有实现如何处理它们,因为相比之下,新的不可变集合工厂不允许包含null。顺便说一下,它们也不允许在Set中包含重复元素(而现有实现只是忽略它们),但我们将稍后在代码示例中使用新的工厂方法来展示这一点。
公平起见,使用现有实现也可以创建一个真正的不可变List接口集合。看看以下代码:
List<String> immutableList1 =
Collections.unmodifiableList(new ArrayList<>() {{
add("immutableList1: Life");
add(" is");
add(" good! ");
add(null);
add("\n\n");
}});
//immutableList1.set(2, " sad."); //throws exception
//immutableList1.add("Well...\n\n"); //throws exception
immutableList1.stream().forEach(System.out::print);
创建不可变列表的另一种方法如下:
List<String> immutableList2 =
Collections.unmodifiableList(Stream
.of("immutableList2: Life"," is"," good! ",null,"\n\n")
.collect(Collectors.toList()));
//immutableList2.set(2, " sad."); //throws exception
//immutableList2.add("Well...\n\n"); //throws exception
immutableList2.stream().forEach(System.out::print);
以下是对前面代码的变体:
List<String> immutableList3 =
Stream.of("immutableList3: Life",
" is"," good! ",null,"\n\n")
.collect(Collectors.collectingAndThen(Collectors.toList(),
Collections::unmodifiableList));
//immutableList3.set(2, " sad."); //throws exception
//immutableList3.add("Well...\n\n"); //throws exception
immutableList3.stream().forEach(System.out::print);
如果我们运行前面的三个示例,我们将看到以下输出:

注意,尽管我们无法修改这些列表的内容,但我们可以在其中放置null。
Set的情况与之前看到的列表非常相似。以下是展示如何修改不可修改的Set接口集合的代码:
Set<String> set = new HashSet<>();
set.add("unmodifiableSet1: Life");
set.add(" is");
set.add(" good! ");
set.add(null);
Set<String> unmodifiableSet1 =
Collections.unmodifiableSet(set);
//unmodifiableSet1.remove(" good! "); //throws exception
//unmodifiableSet1.add("...Well..."); //throws exception
unmodifiableSet1.stream().forEach(System.out::print);
System.out.println("\n");
set.remove(" good! ");
set.add("...Well...");
unmodifiableSet1.stream().forEach(System.out::print);
System.out.println("\n");
即使我们将原始集合从数组转换为列表,然后再转换为集合,Set 接口的集合仍然可以被修改,如下所示:
Set<String> set2 =
new HashSet<>(Arrays.asList("unmodifiableSet2: Life",
" is", " good! ", null));
Set<String> unmodifiableSet2 =
Collections.unmodifiableSet(set2);
//unmodifiableSet2.remove(" good! "); //throws exception
//unmodifiableSet2.add("...Well..."); //throws exception
unmodifiableSet2.stream().forEach(System.out::print);
System.out.println("\n");
set2.remove(" good! ");
set2.add("...Well...");
unmodifiableSet2.stream().forEach(System.out::print);
System.out.println("\n");
运行前两个示例的输出如下:

如果你没有在 Java 9 中使用过集合,你可能会对输出中集合元素的不寻常混乱顺序感到惊讶。实际上,这是 JDK 9 中引入的集合和映射的新特性之一。在过去,Set 和 Map 的实现并没有保证保留元素的顺序。但通常情况下,顺序是保留的,一些程序员编写了依赖于它的代码,从而将一个令人烦恼的不一致且难以复现的缺陷引入了应用程序。新的 Set 和 Map 实现更频繁地改变顺序,如果不是每次代码的新运行。这样,它可以在开发早期暴露潜在缺陷,并减少其传播到生产环境的机会。
与列表类似,即使不使用 Java 9 的新不可变集合工厂,我们也可以创建不可变集合。一种实现方式如下:
Set<String> immutableSet1 =
Collections.unmodifiableSet(new HashSet<>() {{
add("immutableSet1: Life");
add(" is");
add(" good! ");
add(null);
}});
//immutableSet1.remove(" good! "); //throws exception
//immutableSet1.add("...Well..."); //throws exception
immutableSet1.stream().forEach(System.out::print);
System.out.println("\n");
此外,与列表的情况类似,这里还有另一种实现方式:
Set<String> immutableSet2 =
Collections.unmodifiableSet(Stream
.of("immutableSet2: Life"," is"," good! ", null)
.collect(Collectors.toSet()));
//immutableSet2.remove(" good!"); //throws exception
//immutableSet2.add("...Well..."); //throws exception
immutableSet2.stream().forEach(System.out::print);
System.out.println("\n");
之前代码的另一种变体如下:
Set<String> immutableSet3 =
Stream.of("immutableSet3: Life"," is"," good! ", null)
.collect(Collectors.collectingAndThen(Collectors.toSet(),
Collections::unmodifiableSet));
//immutableList5.set(2, "sad."); //throws exception
//immutableList5.add("Well..."); //throws exception
immutableSet3.stream().forEach(System.out::print);
System.out.println("\n");
如果我们运行我们刚刚介绍的创建不可变 iSet 接口集合的三个示例,结果如下:

使用 Map 接口,我们只能找到一种修改 unmodifiableMap 对象的方法:
Map<Integer, String> map = new HashMap<>();
map.put(1, "unmodifiableleMap: Life");
map.put(2, " is");
map.put(3, " good! ");
map.put(4, null);
map.put(5, "\n\n");
Map<Integer, String> unmodifiableleMap =
Collections.unmodifiableMap(map);
//unmodifiableleMap.put(3, " sad."); //throws exception
//unmodifiableleMap.put(6, "Well..."); //throws exception
unmodifiableleMap.values().stream()
.forEach(System.out::print);
map.put(3, " sad. ");
map.put(4, "");
map.put(5, "");
map.put(6, "Well...\n\n");
unmodifiableleMap.values().stream()
.forEach(System.out::print);
之前代码的输出如下:

我们找到了四种不使用 Java 9 增强功能创建不可变 Map 接口集合的方法。以下是第一个示例:
Map<Integer, String> immutableMap1 =
Collections.unmodifiableMap(new HashMap<>() {{
put(1, "immutableMap1: Life");
put(2, " is");
put(3, " good! ");
put(4, null);
put(5, "\n\n");
}});
//immutableMap1.put(3, " sad. "); //throws exception
//immutableMap1.put(6, "Well..."); //throws exception
immutableMap1.values().stream().forEach(System.out::print);
第二个示例有点复杂:
String[][] mapping =
new String[][] {{"1", "immutableMap2: Life"},
{"2", " is"}, {"3", " good! "},
{"4", null}, {"5", "\n\n"}};
Map<Integer, String> immutableMap2 =
Collections.unmodifiableMap(Arrays.stream(mapping)
.collect(Collectors.toMap(a -> Integer.valueOf(a[0]),
a -> a[1] == null? "" : a[1])));
immutableMap2.values().stream().forEach(System.out::print);
null value in the source array:
String[][] mapping =
new String[][]{{"1", "immutableMap3: Life"},
{"2", " is"}, {"3", " good! "}, {"4", "\n\n"}};
Map<Integer, String> immutableMap3 =
Collections.unmodifiableMap(Arrays.stream(mapping)
.collect(Collectors.toMap(a -> Integer.valueOf(a[0]),
a -> a[1])));
//immutableMap3.put(3, " sad."); //throws Exception
//immutableMap3.put(6, "Well..."); //throws exception
immutableMap3.values().stream().forEach(System.out::print);
之前代码的另一种变体如下:
mapping[0][1] = "immutableMap4: Life";
Map<Integer, String> immutableMap4 = Arrays.stream(mapping)
.collect(Collectors.collectingAndThen(Collectors
.toMap(a -> Integer.valueOf(a[0]), a -> a[1]),
Collections::unmodifiableMap));
//immutableMap4.put(3, " sad."); //throws exception
//immutableMap4.put(6, "Well..."); //throws exception
immutableMap4.values().stream().forEach(System.out::print);
在运行完所有四个最后示例之后,输出如下:

在对现有的集合实现进行修订之后,我们现在可以讨论和欣赏 Java 9 中集合的新工厂方法。
新工厂方法的应用
在回顾了现有的集合创建方法之后,我们现在可以回顾并享受 Java 9 中引入的相关 API。与前面的章节一样,我们从 List 接口开始。以下是使用新的 List.of() 工厂方法创建不可变列表的简单和一致的方式:
List<String> immutableList =
List.of("immutableList: Life",
" is", " is", " good!\n\n"); //, null);
//immutableList.set(2, "sad."); //throws exception
//immutableList.add("Well..."); //throws exception
immutableList.stream().forEach(System.out::print);
如前述代码注释所示,新的工厂方法不允许将 null 包含在列表值中。
immutableSet 的创建看起来如下:
Set<String> immutableSet =
Set.of("immutableSet: Life", " is", " good!");
//, " is" , null);
//immutableSet.remove(" good!\n\n"); //throws exception
//immutableSet.add("...Well...\n\n"); //throws exception
immutableSet.stream().forEach(System.out::print);
System.out.println("\n");
如前述代码注释所示,Set.of() 工厂方法在创建不可变 Set 接口集合时不允许添加 null 或重复的元素。
Map 接口的不可变集合也有类似的格式:
Map<Integer, String> immutableMap =
Map.of(1</span>, "immutableMap: Life", 2, " is", 3, " good!");
//, 4, null);
//immutableMap.put(3, " sad."); //throws exception
//immutableMap.put(4, "Well..."); //throws exception
immutableMap.values().stream().forEach(System.out::print);
System.out.println("\n");
Map.of()方法也不允许null作为值。Map.of()方法的另一个特性是它允许在编译时检查元素类型,这减少了运行时问题的可能性。
对于那些喜欢更紧凑代码的人来说,这里有另一种表达相同功能的方法:
Map<Integer, String> immutableMap3 =
Map.ofEntries(entry(1, "immutableMap3: Life"),
entry(2, " is"), entry(3, " good!"));
immutableMap3.values().stream().forEach(System.out::print);
System.out.println("\n");
如果我们运行所有之前使用新工厂方法的示例,以下是输出结果:

如我们之前提到的,拥有不可变集合的能力,包括空集合,对于函数式编程非常有帮助,因为这个特性确保了这样的集合不能作为副作用被修改,并且不会引入意外且难以追踪的缺陷。新的工厂方法包括多达 10 个显式条目,以及一个可以包含任意数量元素的条目。以下是List接口的示例:
static <E> List<E> of()
static <E> List<E> of(E e1)
static <E> List<E> of(E e1, E e2)
static <E> List<E> of(E e1, E e2, E e3)
static <E> List<E> of(E e1, E e2, E e3, E e4)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9)
static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9, E e10)
static <E> List<E> of(E... elements)
Set的工厂方法看起来类似:
static <E> Set<E> of()
static <E> Set<E> of(E e1)
static <E> Set<E> of(E e1, E e2)
static <E> Set<E> of(E e1, E e2, E e3)
static <E> Set<E> of(E e1, E e2, E e3, E e4)
static <E> Set<E> of(E e1, E e2, E e3, E e4, E e5)
static <E> Set<E> of(E e1, E e2, E e3, E e4, E e5, E e6)
static <E> Set<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7)
static <E> Set<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8)
static <E> Set<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9)
static <E> Set<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9, E e10)
static <E> Set<E> of(E... elements)
此外,Map的工厂方法也遵循同样的模式:
static <K,V> Map<K,V> of()
static <K,V> Map<K,V> of(K k1, V v1)
static <K,V> Map<K,V> of(K k1, V v1, K k2, V v2)
static <K,V> Map<K,V> of(K k1, V v1, K k2, V v2, K k3, V v3)
static <K,V> Map<K,V> of(K k1, V v1, K k2, V v2, K k3, V v3, K k4, V v4)
static <K,V> Map<K,V> of(K k1, V v1, K k2, V v2, K k3, V v3, K k4, V v4, K k5, V v5
static <K,V> Map<K,V> of(K k1, V v1, K k2, V v2, K k3, V v3, K k4, V v4, K k5, V v5, K k6, V v6)
static <K,V> Map<K,V> of(K k1, V v1, K k2, V v2, K k3, V v3, K k4, V v4, K k5, V v5, K k6, V v6, K k7, V v7
static <K,V> Map<K,V> of(K k1, V v1, K k2, V v2, K k3, V v3, K k4, V v4, K k5, V v5, K k6, V v6, K k7, V v7,
K k8, V v8)
static <K,V> Map<K,V> of(K k1, V v1, K k2, V v2, K k3, V v3, K k4, V v4, K k5, V v5, K k6, V v6, K k7, V v7,
K k8, V v8, K k9, V v9)
static <K,V> Map<K,V> of(K k1, V v1, K k2, V v2, K k3, V v3, K k4, V v4, K k5, V v5, K k6, V v6, K k7, V v7,
K k8, V v8, K k9, V v9, K k10, V v10)
static <K,V> Map<K,V> ofEntries(Map.Entry<? extends K,? extends V>... entries
决定不添加新的不可变集合接口,使得它们容易在程序员假设可以调用add()或put()时造成偶尔的混淆。这种假设如果没有经过测试,将导致运行时错误,抛出UnsupportedOperationException。尽管存在这种潜在的风险,但新的不可变集合创建工厂方法仍然是 Java 中非常有用的补充。
支持异步处理的 CompletableFuture
java.util.concurrent.CompletableFuture<T>类首次在 Java 8 中引入。它是java.util.concurrent.Future<T>接口异步调用控制的下一级。它实际上实现了Future,以及java.util.concurrent.CompletionStage<T>。在 Java 9 中,CompletableFuture通过添加新的工厂方法、支持延迟和超时以及改进子类化得到了增强——我们将在接下来的章节中更详细地讨论这些特性。但首先,让我们先概述一下CompletableFuture API。
CompletableFuture API 概述
CompletableFuture API 包含超过 70 个方法,其中 38 个是CompletionStage接口的实现,5 个是Future的实现。因为CompletableFuture类实现了Future接口,所以它可以被当作Future来使用,而不会破坏基于Future API 的现有功能。
因此,API 的大部分内容来自CompletionStage。大多数方法返回CompletableFuture(在CompletionStage接口中,它们返回CompletionStage,但在CompletableFuture类中实现时,它们被转换为CompletableFuture),这意味着它们允许像Stream方法那样进行操作链。每个方法都有一个接受函数的签名。一些方法接受Function<T,U>,它将被应用于传入的值T并返回结果U。其他方法接受Consumer<T>,它接受传入的值并返回void。还有其他方法接受Runnable,它不接受任何输入并返回void。以下是一组这些方法:
thenRun(Runnable action)
thenApply(Function<T,U> fn)
thenAccept(Consumer<T> action)
它们都返回CompletableFuture,它携带函数的结果或 void(在Runnable和Consumer的情况下)。每个方法都有两个伴随方法,它们以异步方式执行相同的功能。例如,让我们以thenRun(Runnable action)方法为例。以下是其伴随方法:
-
thenRunAsync(Runnable action)方法,它使用默认的ForkJoinPool.commonPool()池中的另一个线程来运行操作 -
thenRun(Runnable action, Executor executor)方法,它使用作为参数传入的池 executor 中的另一个线程来运行操作
因此,我们已经涵盖了CompletionStage接口的九种方法。
另一组方法包括以下内容:
thenCompose(Function<T,CompletionStage<U>> fn)
applyToEither(CompletionStage other, Function fn)
acceptEither(CompletionStage other, Consumer action)
runAfterBoth(CompletionStage other, Runnable action)
runAfterEither(CompletionStage other, Runnable action)
thenCombine(CompletionStage<U> other, BiFunction<T,U,V> fn)
thenAcceptBoth(CompletionStage other, BiConsumer<T,U> action)
这些方法在CompletableFuture(或CompletionStage)对象产生一个结果并用作操作输入之后执行传入的操作。这里的“两者”指的是提供方法的CompletableFuture和作为方法参数传入的CompletableFuture。从这些方法的名称中,你可以相当可靠地猜测它们的意图。我们将在下面的示例中演示其中的一些。这七个方法中的每一个也有两个用于异步处理的方法。这意味着我们已经描述了CompletionStage接口的 30 种方法(共 38 种)。
有两组方法通常用作终端操作,因为它们可以处理前一个方法的结果(作为T传入)或异常(作为Throwable传入):
handle(BiFunction<T,Throwable,U> fn)
whenComplete(BiConsumer<T,Throwable> action)
我们将在稍后的示例中看到这些方法的用法。当链中的方法抛出异常时,直到遇到第一个handle()方法或whenComplete()方法,链中的其余方法都会被跳过。如果链中不存在这两种方法之一,则异常将像任何其他 Java 异常一样向上冒泡。这两个方法也有异步伴随方法,这意味着我们已经讨论了CompletionStage接口的 36 种方法(共 38 种)。
还有一个仅处理异常的方法(类似于传统编程中的 catch 块):
exceptionally(Function<Throwable,T> fn)
此方法没有异步的配套方法,就像最后一个剩余的方法一样:
toCompletableFuture()
它只是返回一个与该阶段具有相同属性的CompletableFuture对象。这样,我们就描述了CompletionStage接口的所有 38 个方法。
在CompletableFuture类中也有一些不属于任何实现接口的 30 个方法。其中一些在异步执行提供的函数后返回CompletableFuture对象:
runAsync(Runnable runnable)
runAsync(Runnable runnable, Executor executor)
supplyAsync(Supplier<U> supplier)
supplyAsync(Supplier<U> supplier, Executor executor)
其他对象并行执行几个CompletableFuture:
allOf(CompletableFuture<?>... cfs)
anyOf(CompletableFuture<?>... cfs)
还有一组方法可以生成完成的未来,因此返回的CompletableFuture对象的get()方法将不再阻塞:
complete(T value)
completedStage(U value)
completedFuture(U value)
failedStage(Throwable ex)
failedFuture(Throwable ex)
completeAsync(Supplier<T> supplier)
completeExceptionally(Throwable ex)
completeAsync(Supplier<T> supplier, Executor executor)
completeOnTimeout(T value, long timeout, TimeUnit unit)
其余的方法执行各种其他可能很有用的功能:
join()
defaultExecutor()
newIncompleteFuture()
getNow(T valueIfAbsent)
getNumberOfDependents()
minimalCompletionStage()
isCompletedExceptionally()
obtrudeValue(T value)
obtrudeException(Throwable ex)
orTimeout(long timeout, TimeUnit unit)
delayedExecutor(long delay, TimeUnit unit)
请参阅官方 Oracle 文档,其中描述了这些以及其他CompletableFuture API 的方法,链接为download.java.net/java/jdk9/docs/api/index.html?java/util/concurrent/CompletableFuture.html。
Java 9 中 CompletableFuture API 的增强
Java 9 对CompletableFuture引入了几个增强:
-
CompletionStage<U> failedStage(Throwable ex)工厂方法返回一个带有给定异常的CompletionStage对象 -
CompletableFuture<U> failedFuture(Throwable ex)工厂方法返回一个带有给定异常的CompletableFuture对象 -
新的
CompletionStage<U> completedStage(U value)工厂方法返回一个带有给定U值的CompletionStage对象 -
CompletableFuture<T> completeOnTimeout(T value, long timeout, TimeUnit unit)如果在未来给定超时时间内未完成,则使用给定的T值完成CompletableFuture任务 -
CompletableFuture<T> orTimeout(long timeout, TimeUnit unit)如果在未来给定超时时间内未完成,则使用java.util.concurrent.TimeoutException完成CompletableFuture -
现在可以重写
defaultExecutor()方法以支持另一个默认的执行器 -
新的方法
newIncompleteFuture()使得子类化CompletableFuture类变得更加容易
使用 Future 的问题和解决方案
为了展示和欣赏CompletableFuture的强大功能,让我们从一个仅使用Future实现的问题开始,然后看看如何更有效地使用CompletableFuture来解决它。让我们想象一下,我们被分配了一个建模由四个阶段组成的建筑的任务:
-
收集地基、墙壁和屋顶的材料
-
安装地基
-
竖起墙壁
-
构建和完成屋顶
在传统的单线程顺序编程中,模型看起来是这样的:
StopWatch stopWatch = new StopWatch();
Stage failedStage;
String SUCCESS = "Success";
stopWatch.start();
String result11 = doStage(Stage.FoundationMaterials);
String result12 = doStage(Stage.Foundation, result11);
String result21 = doStage(Stage.WallsMaterials);
String result22 = doStage(Stage.Walls,
getResult(result21, result12));
String result31 = doStage(Stage.RoofMaterials);
String result32 = doStage(Stage.Roof,
getResult(result31, result22));
System.out.println("House was" +
(isSuccess(result32)?"":" not") + " built in "
+ stopWatch.getTime()/1000\. + " sec");
在这里,Stage是一个枚举:
enum Stage {
FoundationMaterials,
WallsMaterials,
RoofMaterials,
Foundation,
Walls,
Roof
}
doStage()方法有两个重载版本。以下是第一个:
String doStage(Stage stage) {
String result = SUCCESS;
boolean failed = stage.equals(failedStage);
if (failed) {
sleepSec(2);
result = stage + " were not collected";
System.out.println(result);
} else {
sleepSec(1);
System.out.println(stage + " are ready");
}
return result;
}
第二个版本如下:
String doStage(Stage stage, String previousStageResult) {
String result = SUCCESS;
boolean failed = stage.equals(failedStage);
if (isSuccess(previousStageResult)) {
if (failed) {
sleepSec(2);
result = stage + " stage was not completed";
System.out.println(result);
} else {
sleepSec(1);
System.out.println(stage + " stage is completed");
}
} else {
result = stage + " stage was not started because: "
+ previousStageResult;
System.out.println(result);
}
return result;
}
sleepSec()、isSuccess()和getResult()方法看起来如下:
private static void sleepSec(int sec) {
try {
TimeUnit.SECONDS.sleep(sec);
} catch (InterruptedException e) {
}
}
boolean isSuccess(String result) {
return SUCCESS.equals(result);
}
String getResult(String result1, String result2) {
if (isSuccess(result1)) {
if (isSuccess(result2)) {
return SUCCESS;
} else {
return result2;
}
} else {
return result1;
}
}
如果我们不将任何值分配给failedStage变量而运行之前的代码,成功的房屋建造看起来是这样的:

如果我们将failedStage设置为Stage.Walls,结果将如下所示:

使用Future,我们可以缩短建造房屋所需的时间:
ExecutorService execService = Executors.newCachedThreadPool();
Callable<String> t11 =
() -> doStage(Stage.FoundationMaterials);
Future<String> f11 = execService.submit(t11);
List<Future<String>> futures = new ArrayList<>();
futures.add(f11);
Callable<String> t21 = () -> doStage(Stage.WallsMaterials);
Future<String> f21 = execService.submit(t21);
futures.add(f21);
Callable<String> t31 = () -> doStage(Stage.RoofMaterials);
Future<String> f31 = execService.submit(t31);
futures.add(f31);
String result1 = getSuccessOrFirstFailure(futures);
String result2 = doStage(Stage.Foundation, result1);
String result3 =
doStage(Stage.Walls, getResult(result1, result2));
String result4 =
doStage(Stage.Roof, getResult(result1, result3));
这里,getSuccessOrFirstFailure()方法看起来是这样的:
String getSuccessOrFirstFailure(
List<Future<String>> futures) {
String result = "";
int count = 0;
try {
while (count < futures.size()) {
for (Future<String> future : futures) {
if (future.isDone()) {
result = getResult(future);
if (!isSuccess(result)) {
break;
}
count++;
} else {
sleepSec(1);
}
}
if (!isSuccess(result)) {
break;
}
}
} catch (Exception ex) {
ex.printStackTrace();
}
return result;
}
由于材料收集是并行的,现在成功建造房屋的速度更快:

通过利用 Java 函数式编程,我们可以将实现的后半部分改为以下内容:
Supplier<String> supplier1 =
() -> doStage(Stage.Foundation, result1);
Supplier<String> supplier2 =
() -> getResult(result1, supplier1.get());
Supplier<String> supplier3 =
() -> doStage(Stage.Walls, supplier2.get());
Supplier<String> supplier4 =
() -> getResult(result1, supplier3.get());
Supplier<String> supplier5 =
() -> doStage(Stage.Roof, supplier4.get());
System.out.println("House was" +
(isSuccess(supplier5.get()) ? "" : " not") +
" built in " + stopWatch.getTime() / 1000\. + " sec");
之前嵌套函数的链是由最后一行的supplier5.get()触发的。它将阻塞,直到所有函数按顺序完成,因此没有性能提升:

到此,我们使用Future所能做到的也就这样了。现在让我们看看是否可以使用CompletableFuture改进之前的代码。
使用 CompletableFuture 的解决方案
这是使用CompletableFuture API 链式调用相同操作的方法:
stopWatch.start();
ExecutorService pool = Executors.newCachedThreadPool();
CompletableFuture<String> cf1 =
CompletableFuture.supplyAsync(() ->
doStageEx(Stage.FoundationMaterials), pool);
CompletableFuture<String> cf2 =
CompletableFuture.supplyAsync(() ->
doStageEx(Stage.WallsMaterials), pool);
CompletableFuture<String> cf3 =
CompletableFuture.supplyAsync(() ->
doStageEx(Stage.RoofMaterials), pool);
CompletableFuture.allOf(cf1, cf2, cf3)
.thenComposeAsync(result ->
CompletableFuture.supplyAsync(() -> SUCCESS), pool)
.thenApplyAsync(result ->
doStage(Stage.Foundation, result), pool)
.thenApplyAsync(result ->
doStage(Stage.Walls, result), pool)
.thenApplyAsync(result ->
doStage(Stage.Roof, result), pool)
.handleAsync((result, ex) -> {
System.out.println("House was" +
(isSuccess(result) ? "" : " not") + " built in "
+ stopWatch.getTime() / 1000\. + " sec");
if (result == null) {
System.out.println("Because: " + ex.getMessage());
return ex.getMessage();
} else {
return result;
}
}, pool);
System.out.println("Out!!!!!");
为了让它工作,我们必须将其中一个doStage()方法的实现改为doStageEx()方法:
String doStageEx(Stage stage) {
boolean failed = stage.equals(failedStage);
if (failed) {
sleepSec(2);
throw new RuntimeException(stage +
" stage was not completed");
} else {
sleepSec(1);
System.out.println(stage + " stage is completed");
}
return SUCCESS;
}
Out!!!!!) came out first, which means that all the chains of the operations related to building the house were executed asynchronously
现在,让我们看看如果收集材料的第一个阶段之一失败(failedStage = Stage.WallsMaterials),系统将如何表现:

异常是由WallsMaterials阶段抛出并由handleAsync()方法捕获的,正如预期的那样。而且,再次,打印出Out!!!!!消息后,处理是异步完成的。
CompletableFuture 的其他有用功能
CompletableFuture的一个巨大优点是它可以作为一个对象传递并多次使用以启动不同的操作链。为了展示这种能力,让我们创建几个新的操作:
String getData() {
System.out.println("Getting data from some source...");
sleepSec(1);
return "Some input";
}
SomeClass doSomething(String input) {
System.out.println(
"Doing something and returning SomeClass object...");
sleepSec(1);
return new SomeClass();
}
AnotherClass doMore(SomeClass input) {
System.out.println("Doing more of something and " +
"returning AnotherClass object...");
sleepSec(1);
return new AnotherClass();
}
YetAnotherClass doSomethingElse(AnotherClass input) {
System.out.println("Doing something else and " +
"returning YetAnotherClass object...");
sleepSec(1);
return new YetAnotherClass();
}
int doFinalProcessing(YetAnotherClass input) {
System.out.println("Processing and finally " +
"returning result...");
sleepSec(1);
return 42;
}
AnotherType doSomethingAlternative(SomeClass input) {
System.out.println("Doing something alternative " +
"and returning AnotherType object...");
sleepSec(1);
return new AnotherType();
}
YetAnotherType doMoreAltProcessing(AnotherType input) {
System.out.println("Doing more alternative and " +
"returning YetAnotherType object...");
sleepSec(1);
return new YetAnotherType();
}
int doFinalAltProcessing(YetAnotherType input) {
System.out.println("Alternative processing and " +
"finally returning result...");
sleepSec(1);
return 43;
}
这些操作的结果将由myHandler()方法处理:
int myHandler(Integer result, Throwable ex) {
System.out.println("And the answer is " + result);
if (result == null) {
System.out.println("Because: " + ex.getMessage());
return -1;
} else {
return result;
}
}
注意操作返回的所有不同类型。现在我们可以在某个点将链分成两个分支:
ExecutorService pool = Executors.newCachedThreadPool();
CompletableFuture<SomeClass> completableFuture =
CompletableFuture.supplyAsync(() -> getData(), pool)
.thenApplyAsync(result -> doSomething(result), pool);
completableFuture
.thenApplyAsync(result -> doMore(result), pool)
.thenApplyAsync(result -> doSomethingElse(result), pool)
.thenApplyAsync(result -> doFinalProcessing(result), pool)
.handleAsync((result, ex) -> myHandler(result, ex), pool);
completableFuture
.thenApplyAsync(result -> doSomethingAlternative(result), pool)
.thenApplyAsync(result -> doMoreAltProcessing(result), pool)
.thenApplyAsync(result -> doFinalAltProcessing(result), pool)
.handleAsync((result, ex) -> myHandler(result, ex), pool);
System.out.println("Out!!!!!");
这个示例的结果如下所示:

CompletableFuture API 提供了一个非常丰富且经过深思熟虑的 API,它支持许多其他功能,包括反应式微服务的最新趋势,因为它允许完全异步地处理传入的数据,如果需要,可以分割流,并扩展以适应输入的增加。我们鼓励您研究示例(本书附带的代码中提供了更多示例)并查看 API download.java.net/java/jdk9/docs/api/index.html?java/util/concurrent/CompletableFuture.html。
Stream API 改进
Java 9 中的大多数新Stream API 特性已在描述Stream过滤的章节中演示。为了提醒你,以下是我们在 JDK 9 的Stream API 改进基础上演示的例子:
long c1 = senators.stream()
.flatMap(s -> Stream.ofNullable(s.getParty()
== "Party1" ? s : null))
.count();
System.out.println("OfNullable: Members of Party1: " + c1);
long c2 = senators.stream()
.map(s -> s.getParty() == "Party2" ? Optional.of(s)
: Optional.empty())
.flatMap(Optional::stream)
.count();
System.out.println("Optional.stream(): Members of Party2: "
+ c2);
senators.stream().limit(5)
.takeWhile(s -> Senate.timesVotedYes(s) < 5)
.forEach(s -> System.out.println("takeWhile(<5): "
+ s + ": " + Senate.timesVotedYes(s)));
senators.stream().limit(5)
.dropWhile(s -> Senate.timesVotedYes(s) < 5)
.forEach(s -> System.out.println("dropWhile(<5): "
+ s + ": " + Senate.timesVotedYes(s)));
我们还没有提到的是新的重载iterate()方法:
static <T> Stream<T> iterate(T seed, UnaryOperator<T> f)
其用法的一个例子如下:
String result =
IntStream.iterate(1, i -> i + 2)
.limit(5)
.mapToObj(i -> String.valueOf(i))
.collect(Collectors.joining(", "));
System.out.println("Iterate: " + result);
我们不得不添加limit(5),因为此版本的iterate()方法创建了一个无限流整数。前述代码的结果如下:

在 Java 9 中,添加了重载的iterate()方法:
static <T> Stream<T> iterate(T seed,
Predicate<? super T> hasNext, UnaryOperator<T> next)
如你所见,它现在有一个作为参数的Predicate函数式接口,允许根据需要限制流。例如,以下代码产生了与之前使用limit(5)的例子完全相同的结果:
String result =
IntStream.iterate(1, i -> i < 11, i -> i + 2)
.mapToObj(i -> String.valueOf(i))
.collect(Collectors.joining(", "));
System.out.println("Iterate: " + result);
注意,流元素的类型不需要是整数。它可以是由源产生的任何类型。因此,新的iterate()方法可以用来提供任何类型数据的流终止条件。
摘要
在本课中,我们深入探讨了 Java 9 引入的新特性领域。首先,我们探讨了多种流过滤方法,从基本的filter()方法开始,最终使用 JDK 9 的Stream API 新增功能。然后,你学习了使用新的StackWalker类分析堆栈跟踪的更好方法。讨论通过具体的例子进行说明,帮助你看到真正的运行代码。
在介绍创建不可变集合的新便捷工厂方法和CompletableFuture类及其在 JDK 9 中的增强功能的新异步处理能力时,我们使用了相同的方法。
我们通过列举Stream API 的改进来结束本课——那些我们在过滤代码示例和新iterate()方法中演示的改进。
通过这种方式,我们来到了这本书的结尾。你现在可以尝试将所学到的技巧和技术应用到你的项目中,或者如果它不适合那样做,可以构建自己的 Java 项目以实现高性能。在这样做的时候,尝试解决实际问题。这将迫使你学习新的技能和框架,而不仅仅是应用你已经拥有的知识,尽管后者也很有帮助——它使你的知识保持新鲜和实用。
最佳的学习方式是亲自实践。随着 Java 的持续改进和扩展,请关注 Packt 出版的本和类似书籍的新版本。
评估
-
Java 8 引入了
_______接口,用于发出元素,并支持基于流元素执行计算的各种操作。 -
以下哪个
StackWalker类的工厂方法创建了一个具有指定堆栈帧信息的StackWalker类实例?-
getInstance() -
getInstance(StackWalker.Option option) -
getInstance(Set<StackWalker.Option> options) -
getInstance(Set<StackWalker.Option> options, int estimatedDepth)
-
-
判断对错:
CompletableFutureAPI 包含许多方法,这些方法是CompletionStage接口的实现,并且是Future的实现。 -
在以下方法中,哪种方法用于在流中需要过滤掉所有重复元素并仅选择唯一元素时使用。
-
distinct() -
unique() -
selectall() -
filtertype()
-
-
判断对错:
CompletableFuture的一个巨大优点是它可以作为一个对象传递,并且可以多次使用来启动不同的操作链。
附录 A. 评估答案
第 1 课:学习 Java 9 底层性能改进
| 问题编号 | 答案 |
|---|---|
| 1 | 工具 |
| 2 | 1 |
| 3 | 是 |
| 4 | 3 |
| 5 | 3 |
第 2 课:提高生产力和加快应用程序的工具
| 问题编号 | 答案 |
|---|---|
| 1 | 预编译时 |
| 2 | 1 |
| 3 | 否 |
| 4 | 1 |
| 5 | 3 |
第 3 课:多线程和响应式编程
| 问题编号 | 答案 |
|---|---|
| 1 | calculateAverageSqrt() |
| 2 | 3 |
| 3 | 错误 |
| 4 | 2 |
| 5 | RxJava |
第 4 课:微服务
| 问题编号 | 答案 |
|---|---|
| 1 | Vertx |
| 2 | 4 |
| 3 | 否 |
| 4 | 1,2 |
| 5 | 是 |
第 5 课:利用新 API 改进您的代码
| 问题编号 | 答案 |
|---|---|
| 1 | java.util.streams.Stream |
| 2 | 2 |
| 3 | 是 |
| 4 | 1 |
| 5 | 是 |



浙公网安备 33010602011771号