Java9-高性能应用-全-
Java9 高性能应用(全)
原文:
zh.annas-archive.org/md5/051c92f3ddab22ee9b33739e7a959dd3
译者:飞龙
前言
这本书是关于 Java 9 的,它是最受欢迎的应用程序开发语言之一。最新发布的 Java 9 版本带来了许多新功能和新 API,具有大量可用的组件,可用于构建高效和可扩展的应用程序。流式处理、并行和异步处理、多线程、JSON 支持、响应式编程和微服务构成了现代编程的特点,并已完全集成到 JDK 中。
因此,如果您想将您的 Java 知识提升到另一个水平,并且想要改进您的应用程序性能,您选择了正确的路径。
对我有什么好处?
地图对您的旅程至关重要,特别是当您在另一个大陆度假时。在学习方面,路线图可以帮助您确定前进目标的明确路径。因此,在开始旅程之前,我们为您提供了一张路线图。
这本书经过精心设计和开发,旨在为您提供有关 Java 的所有正确和相关信息。我们为您创建了这个学习路径,其中包括五课:
第 1 课,学习 Java 9 底层性能改进,涵盖了 Java 9 的令人兴奋的功能,这些功能将改善应用程序的性能。它侧重于模块化开发及其对应用程序性能的影响。
第 2 课,提高生产力和加快应用程序,描述了 Java 9 中新增的两个工具--JShell 和 Ahead-of-Time(AOT)编译器--它们可以提高您的生产力,同时改善应用程序的整体性能。
第 3 课,多线程和响应式编程,展示了如何使用命令行工具以编程方式监视 Java 应用程序。您还将探索如何通过多线程来提高应用程序性能,并在了解监视后如何调整 JVM 本身。
第 4 课,微服务,描述了许多行业领袖在应对负载下的灵活扩展时采用的解决方案。它讨论了通过将应用程序拆分为多个微服务并独立部署每个微服务,并使用多线程和响应式编程来实现更好的性能、响应、可扩展性和容错性。
第 5 课,利用新 API 改进您的代码,描述了编程工具的改进,包括流过滤器、堆栈遍历 API、用于创建不可变集合的新便捷静态工厂方法、支持异步处理的强大的 CompletableFuture 类以及 JDK 9 流 API 的改进。
我将从这本书中得到什么?
-
熟悉模块化开发及其对性能的影响
-
学习各种与字符串相关的性能改进,包括紧凑字符串和字符串连接
-
探索各种底层编译器改进,如分层归因和 Ahead-of-Time(AOT)编译
-
学习安全管理器的改进
-
了解图形光栅化器的增强功能
-
使用命令行工具加快应用程序开发
-
学习如何实现多线程和响应式编程
-
在 Java 9 中构建微服务
-
实现 API 以改进应用程序代码
先决条件
这本书是为想要构建可靠和高性能应用程序的 Java 开发人员而设计的。在开始阅读本书之前,需要具备一些先决条件:
- 假定具有先前的 Java 编程知识
第一章:学习 Java 9 的底层性能改进
就在你以为你已经掌握了 Java 8 的 lambda 和所有与性能相关的功能时,Java 9 就出现了。接下来是 Java 9 中的一些功能,可以帮助改进应用程序的性能。这些功能超越了像字符串存储或垃圾收集变化这样的字节级变化,这些变化你几乎无法控制。还有,忽略实现变化,比如用于更快的对象锁定的变化,因为你不需要做任何不同的事情,你会自动获得这些改进。相反,有新的库功能和全新的命令行工具,可以帮助你快速创建应用程序。
在本课程中,我们将涵盖以下主题:
-
模块化开发及其对性能的影响
-
各种与字符串相关的性能改进,包括紧凑字符串和字符串连接的改进
-
并发的进步
-
各种底层编译器改进,如分层归因和提前编译(AOT)编译
-
安全管理器的改进
-
图形光栅化器的增强
介绍 Java 9 的新功能
在本课程中,我们将探讨许多在新环境中运行应用程序时自动获得的性能改进。在内部,字符串的改变也大大减少了在不需要完整的 Unicode 支持的字符字符串时的内存占用。如果你的大部分字符串可以被编码为 ISO-8859-1 或 Latin-1(每个字符 1 个字节),它们将在 Java 9 中存储得更有效。因此,让我们深入研究核心库,并学习底层性能改进。
模块化开发及其影响
在软件工程中,模块化是一个重要的概念。从性能和可维护性的角度来看,创建称为模块的自主单元非常重要。这些模块可以被绑定在一起以构建完整的系统。模块提供了封装,其中实现对其他模块隐藏。每个模块可以暴露出不同的 API,可以作为连接器,使其他模块可以与之通信。这种设计有助于促进松散耦合,有助于专注于单一功能以使其具有内聚性,并使其能够在隔离环境中进行测试。它还减少了系统复杂性并优化了应用程序开发过程。改进每个模块的性能有助于提高整体应用程序性能。因此,模块化开发是一个非常重要的概念。
我知道你可能会想,等一下,Java 不是已经是模块化的了吗?Java 的面向对象性质不是已经提供了模块化操作吗?嗯,面向对象确实强调了独特性和数据封装。它只建议松散耦合,但并不严格执行。此外,它未能在对象级别提供标识,并且也没有接口的版本控制。现在你可能会问,JAR 文件呢?它们不是模块化的吗?嗯,尽管 JAR 文件在一定程度上提供了模块化,但它们缺乏模块化所需的独特性。它们确实有规定版本号的规定,但很少被使用,而且也隐藏在 JAR 的清单文件中。
因此,我们需要与我们已有的不同的设计。简单来说,我们需要一个模块化系统,其中每个模块可以包含多个包,并且相对于标准的 JAR 文件,提供了强大的封装。
这就是 Java 9 的模块化系统所提供的。除此之外,它还通过明确声明依赖关系来取代了不可靠的类路径机制。这些增强功能提高了整体应用程序的性能,因为开发人员现在可以优化单个自包含单元,而不会影响整体系统。
这也使得应用程序更具可扩展性并提供高度的完整性。
让我们来看一下模块系统的一些基础知识以及它们是如何联系在一起的。首先,您可以运行以下命令来查看模块系统的结构:
$java --list-modules
如果您对特定模块感兴趣,您可以简单地在命令的末尾添加模块名称,如下命令所示:
$java --list-modules java.base
之前的命令将显示基本模块中包的所有导出。Java base 是系统的核心。
这将显示所有图形用户界面包。这也将显示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
,你会发现它需要 transitivejava.lang
。这意味着任何依赖于com.java9highperformance.PerformanceStat
的模块都会读取java.lang
。
请看下面的图表,显示了可读性图:
现在,为了编译com.java9highperformance.PerformanceMonitor
模块,系统必须能够解析所有依赖关系。这些依赖关系可以从模块路径中找到。这是显而易见的,不是吗?然而,不要将类路径误解为模块路径。它是一个完全不同的品种。它没有包的问题。
字符串操作性能
如果你不是编程新手,字符串一定是你迄今为止最好的朋友。在许多情况下,你可能会更喜欢它而不是你的配偶或伴侣。我们都知道,没有字符串你无法生存,事实上,甚至没有一个字符串的使用你都无法完成你的应用程序。好了,关于字符串已经表达得足够多了,我已经感到头晕,就像早期版本的 JVM 一样。开玩笑的,让我们谈谈 Java 9 中发生了什么改变,将帮助你的应用程序表现更好。虽然这是一个内部变化,但作为应用程序开发人员,了解这个概念很重要,这样你就知道在哪里集中精力进行性能改进。
Java 9 已经迈出了改善字符串性能的一步。如果你曾经遇到过 JDK 6 的失败尝试UseCompressedStrings
,那么你一定在寻找改善字符串性能的方法。由于UseCompressedStrings
是一个实验性功能,容易出错且设计不太好,它在 JDK 7 中被移除了。不要为此感到难过,我知道这很糟糕,但金色时代终将到来。JEP 团队经历了巨大的痛苦,添加了一项紧凑字符串功能,将减少字符串及其相关类的占用空间。
紧凑字符串将改善字符串的占用空间,并帮助高效使用内存空间。它还保留了所有相关的 Java 和本地接口的兼容性。第二个重要的特性是Indify String Concatenation,它将在运行时优化字符串。
在这一部分,我们将仔细研究这两个特性及其对整体应用程序性能的影响。
紧凑字符串
在我们谈论这个特性之前,了解为什么我们要关心这个问题是很重要的。让我们深入了解 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%的空间。有机会以更密集的形式表示它并改进占用空间,这最终也将有助于加快垃圾回收的速度。
现在,在对此进行任何更改之前,了解其对现实应用的影响是很重要的。了解应用程序是使用 1 个字节还是 2 个字节的char[]
字符串是至关重要的。
为了得到这个答案,JPM 团队分析了大量真实数据的堆转储。结果表明,大多数堆转储中有大约 18%到 30%的整个堆被chars[]
占用,这些来自字符串。此外,大多数字符串由char[]
的单个字节表示。因此,很明显,如果我们尝试改进单字节字符串的占用空间,将会显著提高许多现实应用的性能。
他们做了什么?
经过了许多不同的解决方案,JPM 团队最终决定制定一项在构建过程中压缩字符串的策略。首先,乐观地尝试以 1 个字节压缩,如果不成功,再复制为 2 个字节。还有一些可能的捷径,例如使用像 ISO-8851-1 这样的特殊情况编码器,它总是输出 1 个字节。
这个实现比 JDK 6 的UseCompressedStrings
实现要好得多,因为它只对少数应用有帮助,因为它在每个实例上都对字符串进行重新打包和解包。因此,性能的提升来自于它现在可以同时处理两种形式。
逃逸路线是什么?
尽管这一切听起来很棒,但如果你的应用程序只使用 2 个字节的char[]
字符串,它可能会影响应用程序的性能。在这种情况下,不使用前面提到的检查,直接将字符串存储为 2 个字节的char[]
是有意义的。因此,JPM 团队提供了一个关闭开关--XX: -CompactStrings
,你可以使用它来禁用这个功能。
性能提升是什么?
前面的优化影响了堆,因为我们之前看到字符串是在堆中表示的。因此,它影响了应用程序的内存占用。为了评估性能,我们真的需要关注垃圾收集器。我们将稍后探讨垃圾收集的主题,但现在让我们专注于运行时性能。
Indify 字符串连接
我相信你一定对我们刚刚学到的紧凑字符串功能感到兴奋。现在让我们来看看字符串最常见的用法,即连接。你是否曾经想过当我们尝试连接两个字符串时到底发生了什么?让我们来探索一下。看下面的例子:
public static String getMyAwesomeString(){
int javaVersion = 9;
String myAwesomeString = "I love " + "Java " + javaVersion + " high performance book by Mayur Ramgir";
return myAwesomeString;
}
在前面的例子中,我们试图连接几个带有int
值的字符串。编译器将获取你的精彩字符串,初始化一个新的StringBuilder
实例,然后追加所有这些单独的字符串。让我们看看javac
生成的以下字节码。我使用了Eclipse的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
:这对于调用构造函数、超类方法和私有方法非常有用
然而,在运行时,由于将-XX:+-OptimizeStringConcat
包含到 JIT 编译器中,它现在可以识别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
处理连接而不是使用runtime
方法,并为我们提供优化的字节码。这听起来是个好主意。等一下,我知道你也在想同样的事情。如果 JDK 10 进一步优化这一点怎么办?这是否意味着当我升级到新的 JDK 时,我必须重新编译我的代码并再次部署?在某些情况下,这不是问题,但在其他情况下,这是一个大问题。所以,我们又回到了原点。
我们需要一些可以在运行时处理的东西。好吧,这意味着我们需要一些可以动态调用方法的东西。嗯,这让人想起了什么。如果我们回到时光机,回到 JDK 7 时代的黎明,它给了我们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
,而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 倍。
将 Interned Strings 存储在 CDS 存档中
这个功能的主要目标是减少每个 JVM 进程中创建新字符串实例所造成的内存占用。在任何 JVM 进程中加载的所有类都可以通过类数据共享(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
-
HighContentionSimulator
-
LockLoops-JSR166-Doug-Sept2009(早期的 LockLoops)
-
PointBase
-
SPECjbb2013-critical(早期的 specjbb2005)
-
SPECjbb2013-max
-
specjvm2008
-
volano29(早期的 volano2509)
编译器改进
已经做出了一些努力来改进编译器的性能。在本节中,我们将重点关注编译器方面的改进。
Tiered Attribution
提供编译器改进的首要变化与Tiered Attribution(TA)有关。这个改变更多地涉及到 lambda 表达式。目前,多态表达式的类型检查是通过多次对同一树针对不同目标进行类型检查来完成的。这个过程被称为Speculative Attribution(SA),它使得可以使用不同的重载解析目标来检查 lambda 表达式。
尽管这种类型检查方式是一种强大的技术,但它对性能有显著的不利影响。例如,采用这种方法,n个重载候选者将在每个重载阶段对相同的参数表达式进行检查,严格、宽松和可变参数分别进行一次,总共n3 次。除此之外,还有一个最终的检查阶段。当 lambda 返回一个多态方法调用结果时,会导致属性调用的组合爆炸,这会造成巨大的性能问题。因此,我们确实需要一种不同的多态表达式类型检查方法。
核心思想是确保方法调用为每个多态参数表达式创建自下而上的结构类型,其中包含每个细节,这将在执行重载解析适用性检查之前执行重载解析时需要。
因此,总的来说,性能改进能够通过减少尝试的总次数来实现对给定表达式的属性。
提前编译
用于编译器改进的第二个显著变化是提前编译。如果你对这个术语不熟悉,让我们看看 AOT 是什么。你可能知道,任何语言中的程序都需要一个运行时环境来执行。Java 也有自己的运行时环境,被称为Java 虚拟机(JVM)。我们大多数人使用的典型运行时是一个字节码解释器,也是 JIT 编译器。这个运行时被称为HotSpot JVM。
这个 HotSpot JVM 以通过 JIT 编译和自适应优化来提高性能而闻名。到目前为止一切都很好。然而,这并不适用于每个单独的应用程序。如果你有一个非常轻量的程序,比如一个单独的方法调用,那该怎么办呢?在这种情况下,JIT 编译将帮助不大。你需要一些能够更快加载的东西。这就是 AOT 将会帮助你的地方。与 JIT 相反,AOT 不是编译成字节码,而是编译成本地机器代码。运行时然后使用这个本地机器代码来管理对新对象的调用,将其分配到 malloc 中,以及对文件访问的系统调用。这可以提高性能。
安全管理器改进
好的,让我们谈谈安全性。如果你不是那些更关心在发布中推出更多功能而不是应用程序安全的人,那么你的表情可能会像嗯!那是什么?如果你是其中之一,那么让我们首先了解安全性的重要性,并找到一种方法来考虑在应用程序开发任务中。在今天由 SaaS 主导的世界中,一切都暴露在外部世界。一个决心的个人(委婉地说,一个恶意黑客)可以访问你的应用程序,并利用你可能由于疏忽而引入的安全漏洞。我很乐意深入讨论应用程序安全,因为这是我非常感兴趣的另一个领域。然而,应用程序安全超出了本书的范围。我们在这里谈论它的原因是 JPM 团队已经采取了改进现有安全管理器的举措。因此,在谈论安全管理器之前,首先了解安全性的重要性是很重要的。
希望这一行描述可能已经引起了您对安全编程的兴趣。然而,我理解有时候您可能没有足够的时间来实现完整的安全编程模型,因为时间安排很紧。因此,让我们找到一种可以适应您紧张时间表的方法。让我们思考一分钟;有没有办法自动化安全?我们是否可以有一种方法来创建一个蓝图,并要求我们的程序保持在边界内?好吧,你很幸运,Java 确实有一个名为安全管理器的功能。它只是一个为应用程序定义安全策略的策略管理器。听起来很令人兴奋,不是吗?但这个策略是什么样的?它包含什么?这两个问题都是合理的提问。这个安全策略基本上规定了具有危险或敏感性质的行为。如果您的应用程序不符合这个策略,那么安全管理器会抛出SecurityException
。另一方面,您可以让您的应用程序调用这个安全管理器来了解允许的操作。现在,让我们详细了解安全管理器。
在 Web 小程序的情况下,浏览器提供了安全管理器,或者 Java Web Start 插件运行此策略。在许多情况下,除了 Web 小程序之外的应用程序都没有安全管理器,除非这些应用程序实现了一个。毫无疑问地说,如果没有安全管理器和没有附加安全策略,应用程序将无限制地运行。
现在我们对安全管理器有了一些了解,让我们来看看这一领域的性能改进。根据 Java 团队的说法,安装了安全管理器的应用程序可能会导致性能下降 10%至 15%。然而,虽然不可能消除所有性能瓶颈,但缩小这一差距可以有助于改善安全性和性能。
Java 9 团队研究了一些优化措施,包括执行安全策略和评估权限,这将有助于改善使用安全管理器的整体性能。在性能测试阶段,突出显示了即使权限类是线程安全的,它们也会显示为 HotSpot。已经进行了许多改进,以减少线程争用并提高吞吐量。
改进了java.security.CodeSource
的hashcode
方法,以使用代码源 URL 的字符串形式,以避免潜在昂贵的 DNS 查找。此外,java.lang.SecurityManager
的checkPackageAccess
方法,其中包含包检查算法,已经得到改进。
安全管理器改进中的一些其他显着变化如下:
-
第一个显著的变化是,使用
ConcurrentHashMap
代替Collections.synchronizedMap
有助于提高Policy.implie
方法的吞吐量。看看下面的图表,摘自 OpenJDK 网站,突出显示了使用ConcurrentHashMap
时吞吐量的显著增加: -
除此之外,在
java.security.SecureClassLoader
中用于维护CodeSource
内部集合的HashMap
已被ConcurrentHashMap
替换。 -
还有一些其他小的改进,比如通过从
getPermissions
方法(CodeSource
)中删除兼容性代码来提高吞吐量,该方法在身份上进行同步。 -
使用
ConcurrentHashMap
代替在权限检查代码中被同步块包围的HashMap
可以显著提高线程性能,从而实现了性能的显著增加。
图形光栅化器
如果您对 Java 2D 和使用 OpenJDK 感兴趣,您将会欣赏 Java 9 团队所做的努力。Java 9 主要与图形光栅化器有关,这是当前 JDK 的一部分。OpenJDK 使用 Pisces,而 Oracle JDK 使用 Ductus。Oracle 的闭源 Ductus 光栅化器的性能优于 OpenJDK 的 Pisces。
这些图形光栅化器对于抗锯齿渲染非常有用,除了字体。因此,对于图形密集型应用程序,这种光栅化器的性能非常重要。然而,Pisces 在许多方面都表现不佳,其性能问题非常明显。因此,团队决定将其替换为一个名为 Marlin Graphics Renderer 的不同光栅化器。
Marlin 是用 Java 开发的,最重要的是,它是 Pisces 光栅化器的分支。对其进行了各种测试,结果非常令人期待。它的性能始终优于 Pisces。它展示了多线程可伸缩性,甚至在单线程应用程序中也优于闭源的 Ductus 光栅化器。
总结
在这节课中,我们已经看到了一些令人兴奋的功能,可以在不费吹灰之力的情况下提高您的应用程序性能。
在下一课中,我们将学习 JShell 和提前(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
默认情况下,在启动时导入了几个常见的包。您可以通过键入/l -start
或/l -all
命令来查看它们:
/l s5command, for example, it will retrieve the snippet with ID 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 Runtime Environment(JRE)的实现来实现的,因此通过 Java 编译器(javac
工具)从源代码生成的字节码可以在安装了 JRE 的任何地方执行,前提是编译器javac
的版本与 JRE 的版本兼容。
JRE 的最初版本主要是字节码的解释器,性能比一些其他语言和它们的编译器(如 C 和 C++)要慢。然而,随着时间的推移,JRE 得到了大幅改进,现在产生的结果相当不错,与许多其他流行的系统一样。在很大程度上,这要归功于 JIT 动态编译器,它将最常用的方法的字节码转换为本机代码。一旦生成,编译后的方法(特定于平台的机器代码)将根据需要执行,而无需任何解释,从而减少执行时间。
为了利用这种方法,JRE 需要一些时间来找出应用程序中最常用的方法。在这个编程领域工作的人称之为热方法。这种发现期直到达到最佳性能通常被称为 JVM 的预热时间。对于更大更复杂的 Java 应用程序,这个时间更长,对于较小的应用程序可能只有几秒钟。然而,即使在达到最佳性能之后,由于特定输入的原因,应用程序可能会开始利用以前从未使用过的执行路径,并调用尚未编译的方法,从而突然降低性能。当代码尚未编译的部分属于在某些罕见的关键情况下调用的复杂过程时,这可能尤为重要,这正是需要最佳性能的时候。
自然的解决方案是允许程序员决定应用程序的哪些组件必须预编译成本机机器代码--那些经常使用的(从而减少应用程序的预热时间),以及那些不经常使用但必须尽快执行的(以支持关键情况和整体稳定性能)。这就是Java Enhancement ProposalJEP 295: Ahead-of-Time Compilation的动机:
JIT 编译器速度快,但 Java 程序可能变得非常庞大,以至于 JIT 完全预热需要很长时间。很少使用的 Java 方法可能根本不会被编译,可能因为重复的解释调用而导致性能下降。
值得注意的是,即使在 JIT 编译器中,也可以通过设置编译阈值来减少预热时间--一个方法被调用多少次后才将其编译成本机代码。默认情况下,这个数字是 1500。因此,如果我们将其设置为小于这个值,预热时间将会更短。可以使用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 Micro Edition(ME)中长期得到支持,但在Java Standard Edition(SE)中 AOT 的更多用例尚待确定,这是实验性 AOT 实现随 JDK 9 发布的原因之一--以便促进社区尝试并反馈实际需求。
AOT 命令和程序
JDK 9 中的底层 AOT 编译基于 Oracle 项目Graal
,这是一个在 JDK 8 中引入的开源编译器,旨在改进 Java 动态编译器的性能。 AOT 组不得不对其进行修改,主要是围绕常量处理和优化。他们还添加了概率性分析和特殊的内联策略,从而使 Grall 更适合静态编译。
除了现有的编译工具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
工具执行生成的库(在与执行jaotc
的平台规格相同的平台上),并使用-XX:AOTLibrary
选项:
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 <file>
:这是输出文件名(默认情况下为unnamed.so
) -
--class-name <class names>
:这是要编译的 Java 类列表 -
--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
:这是要使用的编译线程数(默认情况下,使用较小值 16 和可用 CPU 的数量) -
--ignore-errors:这忽略在类加载期间抛出的所有异常(默认情况下,如果类加载抛出异常,则在编译时退出)
-
--exit-on-error:这在编译错误时退出(默认情况下,跳过编译失败,而其他方法的编译继续)
-
--info:这打印有关编译阶段的信息
-
--verbose:这打印有关编译阶段的更多细节
-
--debug:这打印更多细节
-
--help:这打印帮助信息
-
--version:这打印版本信息
-
-J
:这将一个标志直接传递给 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
-
/drop
-
/dr
-
/dp
-
判断真假:Shell 是一种著名的 Ahead-of-Time 工具,适用于那些使用 Scala、Ruby 编程的人。它接受用户输入,对其进行评估,并在一段时间后返回结果。
-
以下哪个命令用于列出您在 JShell 中键入的源代码?
-
/l [
|-all|-start] -
/m [
|-all|-start]L -
/t [
|-all|-start] -
/v [
|-all|-start] -
以下哪个正则表达式忽略在类加载期间抛出的所有异常?
-
--exit-on-error
-
–ignores-errors
-
--ignore-errors
-
--exits-on-error
第三章:多线程和响应式编程
在本课中,我们将探讨一种通过在多个工作线程之间编程地分割任务来支持应用程序高性能的方法。这就是 4,500 年前建造金字塔的方法,自那时以来,这种方法从未失败。但是,可以参与同一项目的劳动者数量是有限制的。共享资源为工作人员的增加提供了上限,无论资源是以平方英尺和加仑(如金字塔时代的居住区和水)计算,还是以千兆字节和千兆赫(如计算机的内存和处理能力)计算。
生活空间和计算机内存的分配、使用和限制非常相似。但是,我们对人力和 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()
方法,它会调用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()
方法计算前 99,999 个整数的平均平方根,并将结果分配给可以随时访问的属性。以下代码演示了我们如何使用它:
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
线程,这个名称是自动分配给执行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
接口及其实现。它们封装了线程管理,并最大程度地减少了应用程序开发人员在编写与线程生命周期相关的代码上所花费的时间。
Executor
接口在java.util.concurrent
包中定义了三个。第一个是基本的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()
方法后添加以下代码来测试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 : "") + ".");
}
前面代码的输出将如下所示:
这意味着每个工作线程完成计算所需的时间为 100 毫秒。(请注意,如果您尝试在您的计算机上重现这些数据,由于性能差异,结果可能会略有不同,因此您需要调整超时时间。)
当我们将等待时间减少到 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()
方法已经阻塞了主线程的执行,直到第一个线程完成。与此同时,第二个线程也已经完成。如果我们在shutdown()
方法之后调用printFuture()
方法,那么两个线程在那时已经完成了,因为我们设置了 1 秒的等待时间(参见pool.awaitTermination()
方法),这足够让它们完成工作。
如果您认为这不是从线程监视的角度来看的太多信息,java.util.concurrent
包通过Callable
接口提供了更多功能。这是一个允许通过Future
对象返回任何对象(包含工作线程计算结果的结果)的功能接口,使用ExecutiveService
方法--submit()
、invokeAll()
和invokeAny()
。例如,我们可以创建一个包含工作线程结果的类:
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 课中,利用新的 API 改进您的代码,我们将介绍随 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 <process id>
或(对于远程应用程序)jconsole <hostname>:<port>
,其中port
是使用启用 JMX 代理的 JVM 启动命令指定的端口号。 -
jdb
实用程序是一个示例命令行调试器。它可以附加到 JVM 进程并允许您检查线程。 -
jstack
命令行实用程序可以附加到 JVM 进程并打印所有线程的堆栈跟踪,包括 JVM 内部线程,还可以选择打印本地堆栈帧。它还允许您检测死锁。 -
Java Flight Recorder(JFR)提供有关 Java 进程的信息,包括等待锁的线程,垃圾收集等。它还允许获取线程转储,这类似于使用
Thread.print
诊断命令或使用 jstack 工具生成的线程转储。如果满足条件,可以设置Java Mission Control(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
将变量 result 的值带到 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()
方法),因此如果没有收到信号,线程将在超时后恢复,有关更多详细信息,请参阅Condition
API
本书的范围不允许我们展示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
)提供了垃圾收集选择的初始指南:
-
如果应用程序的数据集很小(大约 100MB 以下),则选择带有
-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)的数量,因为它们非常消耗资源,因此可能会减慢应用程序的速度。这是由老年代区域的占用率过高引起的。在日志中,它被识别为Pause Full (Allocation Failure)
。以下是减少 Full GC 机会的可能步骤:
-
使用
-Xmx
增加堆的大小。但要确保它不超过物理内存的大小。最好留一些 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,我们也能够异步处理数据--通过旋转工作线程和使用线程池和可调用对象(正如我们在前面的部分中所描述的)或通过传递回调(即使偶尔在谁调用谁的迷宫中迷失)。但是,在几次编写这样的代码之后,人们会注意到大多数这样的代码只是一个可以包装在框架中的管道,可以显著简化异步处理。这就是响应式流倡议(www.reactive-streams.org
)的创建背景和努力的范围定义如下:
响应式流的范围是找到一组最小的接口、方法和协议,描述必要的操作和实体以实现异步数据流和非阻塞背压。
术语非阻塞背压是重要的,因为它确定了现有异步处理的问题之一--协调传入数据的速率与系统处理这些数据的能力,而无需停止(阻塞)数据输入。解决方案仍然会包括一些背压,通过通知源消费者在跟不上输入时存在困难,但新框架应该以更灵活的方式对传入数据的速率变化做出反应,而不仅仅是阻止流动,因此称为响应式。
响应式流 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.Publisher
对象的subscribe()
方法中将Flow.Subscriber
对象作为参数传递后,Flow.Subscriber
对象成为Flow.Publisher
对象产生的数据的订阅者。发布者(Flow.Publisher
对象)调用订阅者的onSubscribe()
方法,并将Flow.Subscription
对象作为参数传递。现在,订阅者可以通过调用订阅的request()
方法从发布者那里请求numberOffItems
个数据。这是实现拉模型的方式,订阅者决定何时请求另一个项目进行处理。订阅者可以通过调用cancel()
订阅方法取消订阅发布者的服务。
作为回报(或者如果实现者决定这样做,那将是一种推送模型),发布者可以通过调用订阅者的onNext()
方法向订阅者传递一个新项目。发布者还可以告诉订阅者,项目生产遇到了问题(通过调用订阅者的onError()
方法)或者不会再有数据传入(通过调用订阅者的onComplete()
方法)。
Flow.Processor
接口描述了一个既可以充当订阅者又可以充当发布者的实体。它允许创建这些处理器的链(管道),因此订阅者可以从发布者那里接收一个项目,对其进行调整,然后将结果传递给下一个订阅者。
这是 Reactive Streams 倡议定义的最小接口集(现在是 JDK 9 的一部分),支持非阻塞背压的异步数据流。正如您所看到的,它允许订阅者和发布者相互交流和协调,如果需要的话,协调传入数据的速率,从而使我们在开始讨论时所讨论的背压问题有可能有各种解决方案。
有许多实现这些接口的方法。目前,在 JDK 9 中,只有一个接口实现的例子——SubmissionPublisher
类实现了Flow.Publisher
。但已经存在几个其他库实现了 Reactive Streams API:RxJava、Reactor、Akka Streams 和 Vert.x 是其中最知名的。我们将在我们的示例中使用 RxJava 2.1.3。您可以在reactivex.io
上找到 RxJava 2.x API,名称为 ReactiveX,代表 Reactive Extension。
在这样做的同时,我们也想解释一下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/
)中,该宣言将响应式系统描述为新一代高性能软件解决方案。建立在异步消息驱动进程和响应式流上,这些系统能够展示响应式宣言中声明的特性:
-
弹性:具有根据负载需要扩展和收缩的能力
-
更好的响应性:在这里,处理可以使用异步调用进行并行化
-
弹性:在这里,系统被分解为多个(通过消息松耦合)组件,从而促进灵活的复制、封装和隔离
使用响应式流来编写响应式系统的代码,以实现先前提到的特性,构成了响应式编程。这种系统今天的典型应用是微服务,下一课将对此进行描述。
摘要
在本课中,我们讨论了通过使用多线程来改善 Java 应用程序性能的方法。我们描述了如何通过使用线程池和适用于不同处理需求的各种类型的线程池来减少创建线程的开销。我们还提出了用于选择池大小的考虑因素,以及如何同步线程,使它们不会相互干扰,并产生最佳性能结果。我们指出,对性能改进的每个决定都必须通过直接监视应用程序进行制定和测试,并讨论了通过编程和使用各种外部工具进行此类监视的可能选项。最后一步,JVM 调优,可以通过我们在相应部分列出并评论的 Java 工具标志来完成。采用响应式编程的概念可能会使 Java 应用程序的性能获得更多收益,我们将其作为朝着高度可伸缩和高性能 Java 应用程序的最有效举措之一。
在下一课中,我们将讨论通过将应用程序拆分为多个微服务来添加更多的工作线程,每个微服务都独立部署,并且每个微服务都使用多个线程和响应式编程以获得更好的性能、响应、可伸缩性和容错性。
评估
-
命名一个方法,计算前 99,999 个整数的平均平方根,并将结果分配给可以随时访问的属性。
-
以下哪种方法创建了一个固定大小的线程池,可以在给定延迟后安排命令运行,或定期执行:
-
新的调度线程池()
-
新的工作窃取线程池()
-
新的单线程调度执行器()
-
新的固定线程池()
-
陈述是否正确:可以利用
Runnable
接口是一个函数式接口,并将必要的处理函数作为 lambda 表达式传递到新线程中。 -
在调用
__________
方法之后,不能再向池中添加更多的工作线程。 -
shutdownNow()
-
shutdown()
-
isShutdown()
-
isShutdownComplete()
-
________ 基于
Observable
对象,它扮演着发布者的角色。
第四章:微服务
只要我们一直在谈论一个过程的设计、实施和调优,我们就能够用生动的形象(尽管只存在于我们的想象中)来说明它,比如金字塔建筑。基于平等原则的多线程管理,也具有集中规划和监督的意义。不同的优先级是根据程序员经过深思熟虑后根据预期负载进行编程分配的,并在监控后进行调整。可用资源的上限是固定的,尽管在一个相对较大的集中决策后可以增加。
这些系统取得了巨大的成功,仍然构成当前部署到生产环境的大多数 Web 应用程序。其中许多是单体应用,封装在一个.ear
或.war
文件中。对于相对较小的应用程序和相应的团队规模来说,这样做效果很好。它们易于(如果代码结构良好)维护、构建,并且如果生产负载不是很高,它们可以很容易地部署。如果业务不增长或对公司的互联网存在影响不大,它们将继续发挥作用,可能在可预见的未来也是如此。许多服务提供商急于通过收取少量费用来托管这样的网站,并解除网站所有者与业务无直接关系的生产维护的技术烦恼。但并非所有情况都是如此。
负载越高,扩展就会变得越困难和昂贵,除非代码和整体架构进行重构,以使其更灵活和能够承受不断增长的负载。本课程描述了许多行业领袖在解决这个问题时采取的解决方案以及背后的动机。
我们将在本课程中讨论微服务的特定方面,包括以下内容:
-
微服务兴起的动机
-
最近开发的支持微服务的框架
-
微服务开发过程,包括实际示例,以及在微服务构建过程中的考虑和决策过程
-
无容器、自包含和容器内三种主要部署方法的优缺点
为什么要使用微服务?
一些企业由于需要跟上更大量的流量,对部署计划有更高的需求。对这一挑战的自然回答是并且已经是将具有相同.ear
或.war
文件部署的服务器加入到集群中。因此,一个失败的服务器可以自动被集群中的另一个服务器替换,用户不会感受到服务中断。支持所有集群服务器的数据库也可以进行集群化。连接到每个集群的连接都通过负载均衡器,确保没有一个集群成员比其他成员工作更多。
Web 服务器和数据库集群有所帮助,但只能在一定程度上,因为随着代码库的增长,其结构可能会产生一个或多个瓶颈,除非采用可扩展的设计来解决这些问题。其中一种方法是将代码分成层:前端(或 Web 层)、中间层(或应用层)和后端(或后端层)。然后,每个层都可以独立部署(如果层之间的协议没有改变),并在自己的服务器集群中,因为每个层都可以根据需要独立地水平扩展。这样的解决方案提供了更多的扩展灵活性,但使部署计划更加复杂,特别是如果新代码引入了破坏性变化。其中一种方法是创建一个将托管新代码的第二个集群,然后逐个从旧集群中取出服务器,部署新代码,并将它们放入新集群。只要每个层中至少有一个服务器具有新代码,新集群就会启动。这种方法对 Web 和应用层效果很好,但对后端来说更复杂,因为偶尔需要数据迁移和类似的愉快练习。加上部署过程中由人为错误、代码缺陷、纯粹的意外或所有前述因素的组合引起的意外中断,很容易理解为什么很少有人喜欢将主要版本发布到生产环境。
程序员天生是问题解决者,他们尽力防止早期的情景,通过编写防御性代码、弃用而不是更改、测试等。其中一种方法是将应用程序分解为更独立部署的部分,希望避免同时部署所有内容。他们称这些独立单元为服务,面向服务的架构(SOA)应运而生。
很不幸,在许多公司中,代码库的自然增长没有及时调整到新的挑战。就像那只最终在慢慢加热的水壶里被煮熟的青蛙一样,他们从来没有时间通过改变设计来跳出热点。向现有功能的一团泥中添加另一个功能总是比重新设计整个应用程序更便宜。时间到市场和保持盈利始终是决策的主要标准,直到结构不良的源代码最终停止工作,将所有业务交易一并拖垮,或者,如果公司幸运的话,让他们度过风暴并显示重新设计的重要性。
由此产生的结果是,一些幸运的公司仍然在经营中,他们的单片应用程序仍然如预期般运行(也许不久,但谁知道),一些公司倒闭,一些从错误中吸取教训,进入新挑战的勇敢世界,另一些则从错误中吸取教训,并从一开始就设计他们的系统为 SOA。
有趣的是观察社会领域中类似的趋势。社会从强大的中央集权政府转向更松散耦合的半独立国家联盟,通过相互有利的经济和文化交流联系在一起。
不幸的是,维护这样一种松散的结构是有代价的。每个参与者都必须在维护合同(社会上的社会合同,在软件上的 API)方面更加负责,不仅在形式上,而且在精神上。否则,例如,来自一个组件新版本的数据流,虽然类型正确,但在值上可能对另一个组件是不可接受的(太大或太小)。保持跨团队的理解和责任重叠需要不断保持文化的活力和启发。鼓励创新和冒险,这可能导致业务突破,与来自同一业务人员的稳定和风险规避的保护倾向相矛盾。
从单一团队开发的整体式系统转变为多个团队和基于独立组件的系统需要企业各个层面的努力。你所说的“不再有质量保证部门”是什么意思?那么谁来关心测试人员的专业成长呢?IT 团队又怎么办?你所说的“开发人员将支持生产”是什么意思?这些变化影响人的生活,并不容易实施。这就是为什么 SOA 架构不仅仅是一个软件原则。它影响公司中的每个人。
与此同时,行业领袖们已经成功地超越了我们十年前所能想象的任何事情,他们被迫解决更加艰巨的问题,并带着他们的解决方案回到软件社区。这就是我们与金字塔建筑的类比不再适用的地方。因为新的挑战不仅仅是建造以前从未建造过的如此庞大的东西,而且要快速完成,不是几年的时间,而是几周甚至几天。而且结果不仅要持续千年,而且必须能够不断演变,并且足够灵活,以适应实时的新、意想不到的需求。如果功能的某个方面发生了变化,我们应该能够重新部署只有这一个服务。如果任何服务的需求增长,我们应该能够只扩展这一个服务,并在需求下降时释放资源。
为了避免全体人员参与的大规模部署,并接近持续部署(缩短上市时间,因此得到业务支持),功能继续分割成更小的服务块。为了满足需求,更复杂和健壮的云环境、部署工具(包括容器和容器编排)以及监控系统支持了这一举措。在前一课中描述的反应流开始发展之前,甚至在反应宣言出台之前,它们就已经开始发展,并在现代框架堆栈中插入了一个障碍。
将应用程序拆分为独立的部署单元带来了一些意想不到的好处,增加了前进的动力。服务的物理隔离允许更灵活地选择编程语言和实施平台。这不仅有助于选择最适合工作的技术,还有助于雇佣能够实施它的人,而不受公司特定技术堆栈的约束。它还帮助招聘人员扩大网络,利用更小的单元引入新的人才,这对于可用专家数量有限、快速增长的数据处理行业的无限需求来说是一个不小的优势。
此外,这样的架构强化了复杂系统各个部分之间的接口讨论和明确定义,从而为进一步的增长和调整提供了坚实的基础。
这就是微服务如何出现并被 Netflix、Google、Twitter、eBay、亚马逊和 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 Web 服务的 Java 框架(www.dropwizard.io)
-
Jodd:这是一组 Java 微框架、工具和实用程序,大小不到 1.7MB(jodd.org)
-
Lightbend Lagom:这是一个基于 Akka 和 Play 构建的主观微服务框架(www.lightbend.com)
-
Ninja:这是一个用于 Java 的全栈 Web 框架(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(从而允许创建实时 Web 应用程序)。
通过创建实现io.vertx.core.Verticle
接口的Verticle
类来开始使用 Vert.x:
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 Core API 的入口点。它提供了构建微服务所需的以下功能:
-
创建 TCP 和 HTTP 客户端和服务器
-
创建 DNS 客户端
-
创建数据报套接字
-
创建周期性服务
-
提供对事件总线和文件系统 API 的访问
-
提供对共享数据 API 的访问
-
部署和取消部署 verticles
使用这个 Vertx 对象,可以部署各种 verticles,它们彼此通信,接收外部请求,并像其他任何 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>
其他 Vert.x 功能可以根据需要添加其他 Maven 依赖项。
使Vert.x
Verticle
具有反应性的是事件循环(线程)的底层实现,它接收事件并将其传递给Handler
(我们将展示如何为其编写代码)。当Handler
获得结果时,事件循环将调用回调函数。
注意
如您所见,重要的是不要编写阻塞事件循环的代码,因此 Vert.x 的黄金规则是:不要阻塞事件循环。
如果没有阻塞,事件循环将非常快速地工作,并在短时间内传递大量事件。这称为反应器模式(en.wikipedia.org/wiki/Reactor_pattern
)。这种事件驱动的非阻塞编程模型非常适合响应式微服务。对于某些本质上是阻塞的代码类型(JDBC 调用和长时间计算是很好的例子),可以异步执行工作 verticle(不是由事件循环,而是使用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
允许您指定要作为应用程序起点的 verticle。以下是来自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
您可以使用任何 verticle 来代替此示例中使用的io.vertx.blog.first.MyFirstVerticle
,但必须有io.vertx.core.Starter
,因为那是知道如何读取清单并执行指定 verticle 的Vert.x
类的名称。现在,您可以运行以下命令:
java -jar target/my-first-app-1.0-SNAPSHOT-fat.jar
此命令将执行MyFirstVerticle
类的start()
方法,就像我们的示例中执行main()
方法一样,我们将继续使用它来简化演示。
为了补充 HTTP 服务器,我们也可以创建一个 HTTP 客户端。但是,首先,我们将修改server
verticle 中的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“客户端”verticle,每秒发送一个请求并打印出响应,持续 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());
}
});
}
}
假设我们部署两个 verticle 如下:
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
verticle 来启动我们的反应式系统。
RxHelper.deployVerticle(vertx(), new DbServiceHttp(8082));
如果我们这样做,输出中将会看到以下代码行:
DbServiceHttp(8082) starts...
Table processed created
Table who_called created
在另一个窗口中,我们可以发出生成 HTTP 请求的命令:
如果现在读取处理过的记录,应该没有:
日志消息显示如下:
现在,我们可以请求处理现有记录,然后再次读取结果:
原则上,已经足够构建一个反应式系统。我们可以在不同端口部署许多DbServiceHttp
微服务,或者将它们集群化以增加处理能力、韧性和响应能力。我们可以将其他服务包装在 HTTP 客户端或 HTTP 服务器中,让它们相互通信,处理输入并将结果传递到处理管道中。
然而,Vert.x 还具有一个更适合消息驱动架构(而不使用 HTTP)的功能。它被称为事件总线。任何 verticle 都可以访问事件总线,并可以使用send()
方法(在响应式编程的情况下使用rxSend()
)或publish()
方法向任何地址(只是一个字符串)发送任何消息。一个或多个 verticle 可以注册自己作为某个地址的消费者。
如果许多 verticle 是相同地址的消费者,那么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)
接收。这就是轮询算法(我们之前提到过的)的运行方式。输出的最后部分看起来像这样:
我们可以部署所需数量的相同类型的顶点。例如,让我们部署四个发送消息到地址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 进程中运行。如果需要,Vert.x 实例可以部署在不同的 JVM 进程中,并通过在运行命令中添加-cluster
选项进行集群化。因此,它们共享事件总线,地址对所有 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)或应用程序运行所需的任何其他外部框架和服务器也包含在 fat 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 或类似产品)的人可能会认为我们在说容器不仅可以包含和执行包含的代码,还可以将其移动到不同位置而不对包含的代码进行任何更改。如果是这样,那将是一个相当恰当的假设。虚拟机确实允许所有这些,而现代容器可以被认为是轻量级虚拟机,因为它也允许分配资源并提供独立机器的感觉。然而,容器并不是一个完全隔离的虚拟计算机。
关键区别在于作为 VM 传递的捆绑包包含了整个操作系统(部署的应用程序)。因此,运行两个 VM 的物理服务器可能在其上运行两个不同的操作系统。相比之下,运行三个容器化应用程序的物理服务器(或 VM)只运行一个操作系统,并且两个容器共享(只读)操作系统内核,每个容器都有自己的访问(挂载)以写入它们不共享的资源。这意味着,例如,启动时间更短,因为启动容器不需要我们引导操作系统(与 VM 的情况相反)。
举个例子,让我们更仔细地看看 Docker,这是容器领域的社区领袖。2015 年,一个名为Open Container Project的倡议被宣布,后来更名为Open Container Initiative(OCI),得到了 Google、IBM、亚马逊、微软、红帽、甲骨文、VMware、惠普、Twitter 等许多公司的支持。它的目的是为所有平台开发容器格式和容器运行时软件的行业标准。Docker 捐赠了大约 5%的代码库给该项目,因为其解决方案被选为起点。
Docker 有广泛的文档,网址为:docs.docker.com
。使用 Docker,可以将所有的 Java EE 容器和应用程序作为 Docker 镜像打包,实现与自包含部署基本相同的结果。然后,你可以通过在 Docker 引擎中启动 Docker 镜像来启动你的应用程序,使用以下命令:
$ docker run mygreatapplication
它启动一个看起来像在物理计算机上运行操作系统的进程,尽管它也可以在云中的一个运行在物理 Linux 服务器上的 VM 中发生,该服务器由许多不同的公司和个人共享。这就是为什么在不同的部署模型之间选择时,隔离级别(在容器的情况下几乎与 VM 一样高)可能是至关重要的。
典型的建议是将一个微服务放入一个容器中,但没有什么阻止你将多个微服务放入一个 Docker 镜像(或者任何其他容器)。然而,在容器管理系统(在容器世界中称为编排)中已经有成熟的系统可以帮助你进行部署,因此拥有许多容器的复杂性,虽然是一个有效的考虑因素,但如果韧性和响应性受到威胁,这并不应该是一个大障碍。一个名为Kubernetes的流行编排支持微服务注册表、发现和负载均衡。Kubernetes 可以在任何云或私有基础设施中使用。
容器允许在几乎任何当前的部署环境中进行快速、可靠和一致的部署,无论是你自己的基础设施还是亚马逊、谷歌或微软的云。它们还允许应用程序在开发、测试和生产阶段之间轻松移动。这种基础设施的独立性允许你在必要时在开发和测试中使用公共云,而在生产中使用自己的计算机。
一旦创建了基本的操作镜像,每个开发团队都可以在其上构建他们的应用程序,从而避免环境配置的复杂性。容器的版本也可以在版本控制系统中进行跟踪。
使用容器的优势如下:
-
与无容器和自包含部署相比,隔离级别最高。此外,最近还投入了更多的精力来为容器增加安全性。
-
每个容器都由相同的一组命令进行管理、分发、部署、启动和停止。
-
无需预先安装 JRE,也不会出现所需版本不匹配的风险。
-
你可以完全控制容器中包含的依赖关系。
-
通过添加/删除容器实例,很容易扩展/缩小每个微服务。
使用容器的缺点如下:
- 你和你的团队必须学习一整套新的工具,并更深入地参与到生产阶段中。另一方面,这似乎是近年来的一般趋势。
总结
微服务是一个新的架构和设计解决方案,用于高负载处理系统,在被亚马逊、谷歌、Twitter、微软、IBM 等巨头成功用于生产后变得流行起来。不过这并不意味着你必须也采用它,但你可以考虑这种新方法,看看它是否能帮助你的应用程序更具韧性和响应性。
使用微服务可以提供实质性的价值,但并非免费。它会带来更多单元的管理复杂性,从需求和开发到测试再到生产的整个生命周期。在承诺全面采用微服务架构之前,通过实施一些微服务并将它们全部移至生产环境来尝试一下。然后,让它运行一段时间并评估经验。这将非常具体化您的组织。任何成功的解决方案都不应盲目复制,而应根据您特定的需求和能力进行采用。
通过逐步改进已经存在的内容,通常可以实现更好的性能和整体效率,而不是通过彻底的重新设计和重构。
在下一课中,我们将讨论并演示新的 API,可以通过使代码更易读和更快速执行来改进您的代码。
评估
-
使用 _________ 对象,可以部署各种垂直,它们彼此交流,接收外部请求,并像任何其他 Java 应用程序一样处理和存储数据,从而形成微服务系统。
-
容器化部署的以下哪一项是优势?
-
每个 JAR 文件都需要特定版本或更高版本的 JVM,这可能会迫使您出于这个原因启动一个新的物理或虚拟机,以部署一个特定的 JAR 文件
-
在您无法控制的环境中,您的代码可能会使用正确版本的 JVM 部署,这可能会导致不可预测的结果
-
在同一 JVM 中的进程竞争资源,这在由不同团队或不同公司共享的环境中尤其难以管理
-
它的占地面积很小,因为除了应用程序本身或一组微服务之外,它不包括任何其他东西
-
判断是 True 还是 False:支持跨多个微服务的事务的一种方法是创建一个扮演并行事务管理器角色的服务。
-
以下哪些是包含在 Java 9 中的 Java 框架?
-
Akka
-
忍者
-
橙色
-
Selenium
-
判断是 True 还是 False:与无容器和自包含部署相比,容器中的隔离级别最高。
第五章:利用新的 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"
)的过滤器,然后再使用选择只投票的过滤器。这样,第二个过滤器只用于大约一半的元素。否则,如果首先放置选择只投票的过滤器,它将应用于Senate
的全部 100 名成员。
结果如下:
同样,我们可以计算每个党派有多少成员在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 操作进行过滤
或者,或者除了前一节中描述的基本过滤之外,其他操作(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.uti.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);
如果我们对相同的参议院组成运行这两个示例,前面两个示例的输出结果是相同的:
当我们需要捕获流发出的第一个元素时,我们可以应用另一种过滤。这意味着在发出第一个元素后终止流。例如,让我们找到Party1
中投了issue 3
上的第一位参议员:
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
的任何senator
:
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
的senator
:
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 个问题上都投了赞成票的人。
同样,我们可以找到在所有 10 个问题上都投了反对票的参议员:
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()
方法打印出第一位参议员,直到我们遇到投票超过四次的参议员,然后停止打印:
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()
方法可以用于相反的效果,即过滤掉,跳过前几位参议员,直到我们遇到投票超过四次的参议员,然后继续打印剩下的所有参议员:
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 会捕获整个堆栈(除了隐藏的堆栈帧),这可能会影响性能。
这就是引入 JDK 9 中的java.lang.StackWalker
类、其嵌套的Option
类和StackWalker.StackFrame
接口的动机。
更好的堆栈遍历方式
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 比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());
});
前面代码的输出如下:
注意StackFrameInfo
类实现了StackWalker.StackFrame
接口并实际执行了任务。该 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)
还有一组生成已完成 future 的方法,因此返回的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)
-
判断是 True 还是 False:
CompletableFuture
API 由许多方法组成,这些方法是CompletionStage
接口的实现,并且是Future
的实现。 -
以下哪种方法用于在流中需要进行过滤类型以跳过所有重复元素并仅选择唯一元素。
-
distinct()
-
unique()
-
selectall()
-
filtertype()
-
判断是 True 还是 False:
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 | 真 |