精通-Java8-并发编程-全-
精通 Java8 并发编程(全)
原文:
zh.annas-archive.org/md5/BFECC9856BE4118734A8147A2EEBA11A译者:飞龙
前言
如今,计算机系统(以及其他相关系统,如平板电脑或智能手机)允许您同时执行多个任务。这是可能的,因为它们具有并发操作系统,可以同时控制多个任务。如果您使用喜爱的编程语言的并发 API,还可以有一个应用程序执行多个任务(读取文件,显示消息或通过网络读取数据)。Java 包括一个非常强大的并发 API,可以让您轻松实现任何类型的并发应用程序。该 API 在每个版本中都增加了程序员提供的功能。现在,在 Java 8 中,它已经包括了流 API 和新的方法和类,以便于实现并发应用程序。本书涵盖了 Java 并发 API 的最重要元素,向您展示如何在实际应用程序中使用它们。这些元素如下:
-
执行者框架,用于控制大量任务的执行
-
Phaser 类,用于执行可以分为阶段的任务
-
Fork/Join 框架,用于使用分而治之技术执行解决问题的任务
-
流 API,用于处理大数据源
-
并发数据结构,用于在并发应用程序中存储数据
-
同步机制,用于组织并发任务
但它包括更多内容:设计并发应用程序的方法,设计模式,实现良好的并发应用程序的技巧和窍门,以及测试并发应用程序的工具和技术。
本书涵盖的内容
第一章,“第一步-并发设计原则”,将教您并发应用程序的设计原则。他们还将学习并发应用程序的可能问题以及设计它们的方法,然后是一些设计模式,技巧和窍门。
第二章,“管理大量线程-执行者”,将教您执行者框架的基本原理。该框架允许您处理大量线程而无需创建或管理它们。您将实现 k 最近邻算法和基本的客户端/服务器应用程序。
第三章,“从执行者中获得最大效益”,将教您执行者的一些高级特性,包括取消和安排任务在延迟后执行任务或每隔一段时间执行任务。您将实现一个高级客户端/服务器应用程序和一个新闻阅读器。
第四章,“从任务中获取数据-可调用和未来接口”,将教您如何在执行者中使用返回结果的任务,使用可调用和未来接口。您将实现最佳匹配算法和构建倒排索引的应用程序。
第五章,“将任务分为阶段运行-Phaser 类”,将教您如何使用 Phaser 类以并发方式执行可以分为阶段的任务。您将实现关键词提取算法和遗传算法。
第六章,“优化分治解决方案-分叉/加入框架”,将教您如何使用一种特殊的执行程序,该执行程序经过优化,可以使用分治技术解决的问题:分叉/加入框架及其工作窃取算法。您将实现 k 均值聚类算法、数据过滤算法和归并排序算法。
第七章,“使用并行流处理大型数据集-映射和减少模型”,将教您如何使用流来处理大型数据集。在本章中,您将学习如何使用流 API 实现映射和减少应用程序以及流的许多其他功能。您将实现一个数值汇总算法和一个信息检索搜索工具。
第八章,“使用并行流处理大型数据集-映射和收集模型”,将教您如何使用流 API 的 collect()方法将数据流进行可变减少为不同的数据结构,包括 Collectors 类中定义的预定义收集器。您将实现一个无需索引的数据搜索工具、一个推荐系统以及一个计算社交网络中两个人的共同联系人列表的算法。
第九章,“深入并发数据结构和同步实用程序”,将教您如何使用最重要的并发数据结构(可在并发应用程序中使用而不会引起数据竞争条件的数据结构)以及 Java 并发 API 中包含的所有同步机制来组织任务的执行。
第十章,“片段集成和替代方案的实现”,将教您如何使用共享内存或消息传递使用其自己的并发技术的并发应用程序片段实现一个大型应用程序。您还将学习书中介绍的不同实现替代方案。
第十一章,“测试和监视并发应用程序”,将教您如何获取有关某些 Java 并发 API 元素(线程、锁、执行程序等)状态的信息。您还将学习如何使用 Java VisualVM 应用程序监视并发应用程序,以及如何使用 MultithreadedTC 库和 Java Pathfinder 应用程序测试并发应用程序。
您需要为这本书做好准备
要跟上这本书,您需要对 Java 编程语言有基本的了解。对并发概念的基本了解也是受欢迎的。
这本书是为谁准备的
如果您是一名 Java 开发人员,了解并发编程的基本原则,但希望获得 Java 并发 API 的专业知识,以开发利用计算机所有硬件资源的优化应用程序,那么这本书适合您。
约定
在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是这些样式的一些示例以及它们的含义解释。
文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:"Product类存储有关产品的信息。"
代码块设置如下:
if (problem.size() > DEFAULT_SIZE) {
divideTasks();
executeTask();
taskResults=joinTasksResult();
return taskResults;
} else {
taskResults=solveBasicProblem();
return taskResults;
}
新术语和重要词汇以粗体显示。例如,您在屏幕上看到的单词,比如菜单或对话框中的单词,会以这样的形式出现在文本中:"保留默认值,然后点击下一步按钮。"
注意
警告或重要说明会出现在这样的框中。
提示
提示和技巧会出现在这样的形式中。
读者反馈
我们非常欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者的反馈对我们很重要,因为它可以帮助我们开发您真正能够从中受益的书籍。
如需向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在主题中提及书籍的标题。
如果您在某个专题上有专业知识,并且有兴趣撰写或为一本书作出贡献,请参阅我们的作者指南,网址为www.packtpub.com/authors。
客户支持
现在您是 Packt 书籍的自豪所有者,我们有很多东西可以帮助您充分利用您的购买。
下载示例代码
您可以从您在www.packtpub.com的账户中下载您购买的所有 Packt Publishing 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接发送到您的电子邮件。
您可以按照以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持选项卡上。
-
点击代码下载和勘误。
-
在搜索框中输入书名。
-
选择您要下载代码文件的书籍。
-
从下拉菜单中选择您购买这本书的地点。
-
点击代码下载。
下载文件后,请确保使用最新版本的解压缩软件解压缩文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
勘误
尽管我们已经尽一切努力确保内容的准确性,但错误是难免的。如果您在我们的书籍中发现错误——可能是文本或代码中的错误——我们将不胜感激,如果您能向我们报告。通过这样做,您可以帮助其他读者避免挫折,并帮助我们改进本书的后续版本。如果您发现任何勘误,请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表链接,并输入您的勘误详情。一旦您的勘误被验证,您的提交将被接受,并且勘误将被上传到我们的网站或添加到该书籍的勘误列表中的勘误部分。
要查看先前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索框中输入书名。所需信息将出现在勘误部分下方。
盗版
互联网上的盗版行为是所有媒体的持续问题。在 Packt,我们非常重视版权和许可的保护。如果您在互联网上发现我们作品的任何非法副本,请立即向我们提供位置地址或网站名称,以便我们采取补救措施。
请通过链接<copyright@packtpub.com>与我们联系,提供涉嫌盗版材料的链接。
我们感谢您帮助我们保护我们的作者和我们提供有价值内容的能力。
电子书、折扣优惠等
您知道 Packt 提供每本出版书籍的电子书版本,包括 PDF 和 ePub 文件吗?您可以在www.PacktPub.com升级到电子书版本,作为印刷书的客户,您有资格享受电子书折扣。欢迎通过<customercare@packtpub.com>与我们联系以获取更多详情。
在www.PacktPub.com,您还可以阅读一系列免费的技术文章,订阅各种免费的新闻简报,并获得 Packt 图书和电子书的独家折扣和优惠。
问题
如果您对本书的任何方面有问题,可以通过<questions@packtpub.com>与我们联系,我们将尽力解决问题。
第一章:第一步-并发设计原则
计算机系统的用户总是在寻求系统的更好性能。他们希望获得更高质量的视频、更好的视频游戏和更快的网络速度。几年前,处理器通过提高速度为用户提供了更好的性能。但现在,处理器不再提高速度。相反,它们增加了更多的核心,以便操作系统可以同时执行多个任务。这被称为并发。并发编程包括所有工具和技术,以便在计算机中同时运行多个任务或进程,它们之间进行通信和同步,而不会丢失数据或不一致。在本章中,我们将涵盖以下主题:
-
基本并发概念
-
并发应用中可能出现的问题
-
设计并发算法的方法论
-
Java 并发 API
-
Java 内存模型
-
并发设计模式
-
设计并发算法的技巧和窍门
基本并发概念
首先,让我们介绍并发的基本概念。你必须理解这些概念才能继续阅读本书的其余部分。
并发与并行
并发和并行是非常相似的概念。不同的作者对这些概念给出了不同的定义。最被接受的定义是,当你在单个处理器上有多个任务,并且操作系统的任务调度程序快速地从一个任务切换到另一个任务时,就会出现并发,因此似乎所有任务都在同时运行。同样的定义也提到,当你有多个任务在不同的计算机、处理器或处理器内的不同核心上同时运行时,就会出现并行。
另一个定义提到,当你的系统上有多个任务(不同的任务)同时运行时,就会出现并发。另一个定义讨论了当你在数据集的不同部分上同时运行相同任务的不同实例时,就会出现并行。
我们包含的最后一个定义提到,当你的系统中有多个任务同时运行时,就会出现并行,并且提到并发来解释程序员们用来与任务同步和访问共享资源的不同技术和机制。
正如你所看到的,这两个概念非常相似,并且随着多核处理器的发展,这种相似性已经增加。
同步
在并发中,我们可以将同步定义为协调两个或多个任务以获得期望的结果。我们有两种同步方式:
-
控制同步:例如,一个任务依赖于另一个任务的结束,第二个任务在第一个任务完成之前不能开始
-
数据访问同步:当两个或更多任务访问共享变量,且在任何给定时间只有一个任务可以访问该变量
与同步密切相关的一个概念是关键部分。关键部分是一段代码,因为其对共享资源的访问,只能由一个任务在任何给定时间执行。互斥是用来保证这一要求的机制,并且可以通过不同的方式实现。
请记住,同步可以帮助你避免一些并发任务可能出现的错误(它们将在本章后面描述),但它会给你的算法引入一些开销。你必须非常仔细地计算可以在并行算法中独立执行而不需要相互通信的任务数量。这就是你并发算法的粒度。如果你有粗粒度的粒度(大任务低相互通信),同步的开销会很低。然而,也许你无法充分利用系统的所有核心。如果你有细粒度的粒度(高相互通信的小任务),同步的开销会很高,也许你的算法的吞吐量不会很好。
在并发系统中有不同的机制来实现同步。从理论上讲,最流行的机制有:
-
信号量:信号量是一种可以用来控制对一个或多个资源单元的访问的机制。它有一个变量来存储可以使用的资源数量,以及两个原子操作来管理变量的值。互斥锁(mutual exclusion的缩写)是一种特殊类型的信号量,它只能取两个值(资源空闲和资源忙碌),只有设置互斥锁为忙碌的进程才能释放它。
-
监视器:监视器是一种获得共享资源的互斥的机制。它有一个互斥锁、一个条件变量和两个等待条件和信号条件的操作。一旦你发出条件,只有一个等待它的任务可以继续执行。
与同步相关的最后一个概念是线程安全。如果所有共享数据的用户都受到同步机制的保护,非阻塞的比较和交换(CAS)原语或数据是不可变的,那么一段代码(或一个方法或一个对象)就是线程安全的,这样你就可以在并发应用中使用该代码而不会出现任何问题。
不可变对象
不可变对象是一个具有非常特殊特性的对象。在初始化后,你不能修改它的可见状态(属性的值)。如果你想修改一个不可变对象,你必须创建一个新的对象。
它的主要优点是它是线程安全的。你可以在并发应用中使用它而不会出现任何问题。
不可变对象的一个例子是 Java 中的String类。当你给一个String对象赋一个新值时,你实际上是创建了一个新的字符串。
原子操作和变量
原子操作是一种看起来对程序的其他任务瞬间发生的操作。在并发应用中,你可以使用同步机制来实现一个原子操作的整个操作。
原子变量是一种具有原子操作来设置和获取其值的变量。你可以使用同步机制来实现原子变量,或者使用不需要任何同步的 CAS 来以无锁的方式实现原子变量。
共享内存与消息传递
任务可以使用两种不同的方法来相互通信。第一种是共享内存,通常在任务在同一台计算机上运行时使用。任务使用相同的内存区域来写入和读取值。为了避免问题,对这个共享内存的访问必须在由同步机制保护的临界区域内。
另一个同步机制是消息传递,通常在任务在不同计算机上运行时使用。当一个任务需要与另一个任务通信时,它发送遵循预定义协议的消息。这种通信可以是同步的,如果发送者被阻塞等待响应,或者是异步的,如果发送者在发送消息后继续执行。
并发应用程序中可能出现的问题
编写并发应用程序并不是一件容易的工作。如果您错误地使用同步机制,您的应用程序中的任务可能会出现不同的问题。在本节中,我们描述了其中一些问题。
数据竞争
在应用程序中,当有两个或更多任务在没有使用任何同步机制的情况下写入共享变量时,您可能会发生数据竞争(也称为竞争条件)。
在这种情况下,您的应用程序的最终结果可能取决于任务的执行顺序。看下面的例子:
package com.packt.java.concurrency;
public class Account {
private float balance;
public void modify (float difference) {
float value=this.balance;
this.balance=value+difference;
}
}
想象一下,两个不同的任务在同一个Account对象中执行“modify()”方法。根据任务中句子的执行顺序,最终结果可能会有所不同。假设初始余额为 1000,两个任务都使用 1000 作为参数调用“modify()”方法。最终结果应该是 3000,但是如果两个任务同时执行第一句,然后同时执行第二句,最终结果将是 2000。正如您所看到的,“modify()”方法不是原子的,Account类也不是线程安全的。
死锁
在您的并发应用程序中存在死锁,当有两个或更多任务等待必须从其他任务中释放的共享资源时,因此它们都无法获得所需的资源并将被无限期地阻塞。它发生在系统中同时发生四个条件。它们是Coffman 的条件,如下所示:
-
互斥排斥:死锁中涉及的资源必须是不可共享的。一次只有一个任务可以使用资源。
-
持有和等待条件:一个任务拥有一个资源的互斥,并且正在请求另一个资源的互斥。在等待时,它不会释放任何资源。
-
不可抢占:资源只能由持有它们的任务释放。
-
循环等待:任务 1 正在等待任务 2 持有的资源,而任务 2 正在等待任务 3 持有的资源,依此类推,直到有任务 n 等待任务 1 持有的资源。
有一些机制可以用来避免死锁:
-
忽略它们:这是最常用的机制。您假设在您的系统上永远不会发生死锁,如果发生了,您可以看到停止应用程序的后果,并不得不重新执行它。
-
检测:系统有一个特殊的任务,分析系统的状态以检测是否发生了死锁。如果它检测到死锁,它可以采取行动来解决问题。例如,完成一个任务或强制释放资源。
-
预防:如果您想要在系统中预防死锁,您必须预防 Coffman 的一个或多个条件。
-
避免:如果您在任务开始执行之前了解使用的资源的信息,可以避免死锁。当任务想要开始执行时,您可以分析系统中空闲的资源以及任务需要的资源,以决定它是否可以开始执行。
活锁
当您的系统中有两个任务始终由于对方的操作而改变其状态时,就会发生活锁。因此,它们处于状态更改循环中,无法继续。
例如,您有两个任务——任务 1 和任务 2——都需要两个资源:资源 1 和资源 2。假设任务 1 锁定了资源 1,任务 2 锁定了资源 2。由于它们无法获得所需的资源,它们释放资源并重新开始循环。这种情况可能无限期地持续下去,因此任务永远不会结束执行。
资源匮乏
资源饥饿发生在系统中有一个任务永远无法获得需要继续执行的资源时。当有多个任务等待资源并且资源被释放时,系统必须选择下一个可以使用它的任务。如果你的系统没有一个好的算法,可能会有线程长时间等待资源。
公平性是解决这个问题的方法。所有等待资源的任务必须在一定时间内获得资源。一种选择是实现一个算法,考虑任务等待资源的时间,以选择下一个将持有资源的任务。然而,公平实现锁需要额外的开销,可能会降低程序的吞吐量。
优先级反转
优先级反转发生在低优先级任务持有高优先级任务需要的资源时,因此低优先级任务在高优先级任务之前完成执行。
设计并发算法的方法论
在这一部分,我们将提出一个五步方法论,以获得顺序算法的并发版本。这是基于英特尔在其《线程方法论:原理与实践》文档中提出的方法。
起点 - 算法的顺序版本
我们实现并发算法的起点将是它的顺序版本。当然,我们可以从头开始设计一个并发算法,但我认为算法的顺序版本会给我们带来两个优势:
-
我们可以使用顺序算法来测试我们的并发算法是否生成正确的结果。当它们接收相同的输入时,两个算法必须生成相同的输出,这样我们可以检测并发版本中的一些问题,比如数据竞争或类似的情况。
-
我们可以测量两种算法的吞吐量,看看并发使用是否真的能在响应时间或算法在一定时间内处理的数据量方面给我们带来真正的改进。
第一步 - 分析
在这一步中,我们将分析算法的顺序版本,寻找可以以并行方式执行的代码部分。我们应该特别注意那些大部分时间执行或执行更多代码的部分,因为通过实现这些部分的并发版本,我们将获得更大的性能改进。
这个过程的好候选者是循环,其中一个步骤独立于其他步骤,或者代码的部分独立于代码的其他部分(例如,初始化应用程序的算法,打开与数据库的连接,加载配置文件,初始化一些对象。所有前面的任务彼此独立)。
第二步 - 设计
一旦你知道要并行化的代码部分,你必须决定如何进行并行化。
代码的变化将影响应用程序的两个主要部分:
-
代码结构
-
数据结构的组织
你可以采取两种不同的方法来完成这个任务:
-
任务分解:当你将代码分割成两个或更多独立的任务可以同时执行时,你进行任务分解。也许其中一些任务必须按照给定的顺序执行,或者必须在同一点等待。你必须使用同步机制来实现这种行为。
-
数据分解:当你有多个相同任务的实例,它们使用数据集的一个子集时,你进行数据分解。这个数据集将是一个共享资源,所以如果任务需要修改数据,你必须通过实现临界区来保护对它的访问。
另一个重要的要点是要记住您解决方案的粒度。实现算法的并行版本的目标是实现改进的性能,因此您应该使用所有可用的处理器或核心。另一方面,当您使用同步机制时,您会引入一些必须执行的额外指令。如果您将算法分解为许多小任务(细粒度粒度),同步引入的额外代码可能导致性能下降。如果您将算法分解为少于核心数的任务(粗粒度粒度),则没有充分利用所有资源。此外,您必须考虑每个线程必须执行的工作,特别是如果您实现了细粒度粒度。如果您有一个比其他任务更长的任务,该任务将决定应用程序的执行时间。您必须在这两个点之间找到平衡。
第 3 步 - 实现
下一步是使用编程语言和(如果必要)线程库实现并行算法。在本书的示例中,您将使用 Java 来实现所有算法。
第 4 步 - 测试
在完成实现后,您必须测试并行算法。如果您有算法的顺序版本,您可以比较两种算法的结果以验证您的并行实现是否正确。
测试和调试并行实现是困难的任务,因为应用程序的不同任务的执行顺序不能保证。在第十一章中,测试和监视并发应用程序,您将学习到有效执行这些任务的技巧和工具。
第 5 步 - 调优
最后一步是比较并行和顺序算法的吞吐量。如果结果不如预期,您必须检查算法,寻找并行算法性能不佳的原因。
您还可以测试算法的不同参数(例如,粒度或任务数量)以找到最佳配置。
有不同的指标来衡量并行化算法可能获得的性能改进。最流行的三个指标是:
- 加速比:这是衡量并行和顺序算法版本之间相对性能改进的指标:
![第 5 步 - 调优]()
这里,T [顺序] 是顺序算法版本的执行时间,T [并发] 是并行版本的执行时间。
- 阿姆达尔定律:这用于计算通过算法并行化获得的最大预期改进:
![第 5 步 - 调优]()
这里,P是可以并行化的代码的百分比,N是您将执行算法的计算机的核心数。
例如,如果您可以并行化 75%的代码并且您有四个核心,最大加速比将由以下公式给出:

- 古斯塔夫森-巴西斯定律:阿姆达尔定律有一个限制。它假设在增加核心数时,您拥有相同的输入数据集,但通常,当您拥有更多核心时,您希望处理更多数据。古斯塔夫森定律提出,当您有更多可用的核心时,可以使用以下公式在相同时间内解决更大的问题:
![第 5 步 - 调优]()
这里,N是核心数,P是可并行化代码的百分比。
如果我们使用与之前相同的示例,由古斯塔夫森定律计算得出的加速比为:

结论
在这一部分,您学习了在想要并行化顺序算法时必须考虑的一些重要问题。
首先,不是每个算法都可以并行化。例如,如果你必须执行一个循环,其中迭代的结果取决于前一次迭代的结果,那么你无法并行化该循环。递归算法是另一个例子,由于类似的原因可以并行化。
另一个重要的事情是,性能更好的顺序算法的顺序版本可能不是并行化的一个好的起点。如果你开始并行化一个算法,并且发现自己陷入困境,因为你不容易找到代码的独立部分,你必须寻找算法的其他版本,并验证该版本是否可以更容易地并行化。
最后,当你实现一个并发应用程序(从头开始或基于顺序算法),你必须考虑以下几点:
-
效率:并行算法必须在比顺序算法更短的时间内结束。并行化算法的第一个目标是其运行时间比顺序算法短,或者它可以在相同的时间内处理更多的数据。
-
简单性:当你实现一个算法(并行或非并行)时,你必须尽量保持简单。这样更容易实现、测试、调试和维护,而且错误更少。
-
可移植性:你的并行算法应该在不同的平台上执行,只需进行最小的更改。在本书中你将使用 Java,这一点将非常容易。使用 Java,你可以在每个操作系统上执行你的程序,而不需要任何更改(如果你按照必须的方式实现程序)。
-
可扩展性:如果增加核心的数量,你的算法会发生什么?如前所述,你应该使用所有可用的核心,因此你的算法应该准备利用所有可用的资源。
Java 并发 API
Java 编程语言拥有非常丰富的并发 API。它包含了管理并发的基本元素的类,如Thread、Lock和Semaphore,以及实现非常高级的同步机制的类,如执行器框架或新的并行StreamAPI。
在本节中,我们将介绍构成并发 API 的基本类。
基本的并发类
Java 并发 API 的基本类包括:
-
Thread类:这个类代表执行并发 Java 应用程序的所有线程 -
Runnable接口:这是在 Java 中创建并发应用程序的另一种方式 -
ThreadLocal类:这是一个用于在线程本地存储变量的类 -
ThreadFactory接口:这是你可以用来创建自定义线程的工厂设计模式的基础
同步机制
Java 并发 API 包括不同的同步机制,允许你:
-
定义访问共享资源的临界区
-
在一个共同点同步不同的任务
以下机制被认为是最重要的同步机制:
-
synchronized关键字:synchronized关键字允许你在代码块或整个方法中定义临界区。 -
Lock接口:Lock提供了比synchronized关键字更灵活的同步操作。有不同种类的锁:ReentrantLock,用于实现可以与条件关联的锁;ReentrantReadWriteLock,用于分离读写操作;以及StampedLock,这是 Java 8 的一个新特性,包括三种模式来控制读/写访问。 -
Semaphore类:实现经典信号量以实现同步的类。Java 支持二进制和一般信号量。 -
CountDownLatch类:允许任务等待多个操作的完成。 -
CyclicBarrier类:允许多个线程在一个共同点同步的类。 -
Phaser类:一个允许你控制分阶段执行任务的类。在所有任务完成当前阶段之前,没有一个任务会进入下一个阶段。
执行器
执行器框架是一种允许你分离线程创建和管理以实现并发任务的机制。你不必担心线程的创建和管理,只需要创建任务并将它们发送到执行器。参与该框架的主要类有:
-
Executor和ExecutorService接口:它们包括所有执行器的常用方法。 -
ThreadPoolExecutor:这是一个允许你获取一个具有线程池的执行器,并可选择定义最大并行任务数的类 -
ScheduledThreadPoolExecutor:这是一种特殊类型的执行器,允许你在延迟后或定期执行任务 -
Executors:这是一个简化执行器创建的类 -
Callable接口:这是Runnable接口的一种替代方式,它是一个可以返回值的独立任务 -
Future接口:这是一个包括获取Callable接口返回值和控制其状态的方法的接口
Fork/Join 框架
Fork/Join 框架定义了一种特殊类型的执行器,专门用于使用分而治之技术解决问题。它包括一种机制来优化解决这类问题的并发任务的执行。Fork/Join 特别适用于细粒度的并行性,因为它在将新任务放入队列和执行排队任务方面的开销非常低。参与该框架的主要类和接口有:
-
ForkJoinPool:这是一个实现将运行任务的执行器的类 -
ForkJoinTask:这是一个可以在ForkJoinPool类中执行的任务 -
ForkJoinWorkerThread:这是一个将在ForkJoinPool类中执行任务的线程
并行流
流和Lambda 表达式可能是 Java 8 版本中最重要的两个新特性。流已经作为Collection接口和其他数据源的一个方法添加,允许处理数据结构的所有元素,生成新的结构,过滤数据,并使用映射和减少技术实现算法。
一种特殊类型的流是并行流,它以并行方式实现其操作。使用并行流涉及的最重要的元素有:
-
Stream接口:这是一个定义你可以在流上执行的所有操作的接口。 -
Optional:这是一个可能包含非空值的容器对象。 -
Collectors:这是一个实现减少操作的类,可以作为流操作序列的一部分使用。 -
Lambda 表达式:流被设计为与 Lambda 表达式一起工作。大多数流方法接受 Lambda 表达式作为参数。这允许你实现更紧凑的操作版本。
并发数据结构
Java API 的普通数据结构(ArrayList,Hashtable等)在并发应用中不适合工作,除非你使用外部同步机制。如果你使用它,将会为你的应用程序增加大量的额外计算时间。如果你不使用它,你的应用程序可能会出现竞争条件。如果你从多个线程修改它们并发生竞争条件,可能会出现各种异常抛出(如ConcurrentModificationException和ArrayIndexOutOfBoundsException),可能会出现静默数据丢失,或者你的程序甚至可能会陷入无限循环。
Java 并发 API 包括许多可以在并发应用中使用而不会有风险的数据结构。我们可以将它们分类为两组:
-
阻塞数据结构:这些包括在数据结构为空并且您想要获取一个值时,阻止调用任务的方法。
-
非阻塞数据结构:如果操作可以立即完成,它不会阻止调用任务。否则,它会返回
null值或抛出异常。
以下是一些数据结构:
-
ConcurrentLinkedDeque:这是一个非阻塞列表 -
ConcurrentLinkedQueue:这是一个非阻塞队列 -
LinkedBlockingDeque:这是一个阻塞列表 -
LinkedBlockingQueue:这是一个阻塞队列 -
PriorityBlockingQueue:这是一个根据优先级排序其元素的阻塞队列 -
ConcurrentSkipListMap:这是一个非阻塞可导航映射 -
ConcurrentHashMap:这是一个非阻塞哈希映射 -
AtomicBoolean、AtomicInteger、AtomicLong和AtomicReference:这些是基本 Java 数据类型的原子实现
并发设计模式
在软件工程中,设计模式是对一个常见问题的解决方案。这个解决方案已经被多次使用,并且已经被证明是解决问题的最佳方案。您可以使用它们来避免每次解决这些问题时都要“重新发明轮子”。单例或工厂是几乎每个应用程序中使用的常见设计模式的例子。
并发性也有自己的设计模式。在本节中,我们描述了一些最有用的并发设计模式及其在 Java 语言中的实现。
信号
这个设计模式解释了如何实现一个任务必须通知另一个任务的事件的情况。实现这个模式的最简单方法是使用 Java 语言的ReentrantLock或Semaphore类,甚至是Object类中包含的wait()和notify()方法。
看下面的例子:
public void task1() {
section1();
commonObject.notify();
}
public void task2() {
commonObject.wait();
section2();
}
在这些情况下,section2()方法将始终在section1()方法之后执行。
会合
这个设计模式是信号模式的一般化。在这种情况下,第一个任务等待第二个任务的事件,第二个任务等待第一个任务的事件。解决方案类似于信号,但在这种情况下,您必须使用两个对象而不是一个。
看下面的例子:
public void task1() {
section1_1();
commonObject1.notify();
commonObject2.wait();
section1_2();
}
public void task2() {
section2_1();
commonObject2.notify();
commonObject1.wait();
section2_2();
}
在这些情况下,section2_2()总是在section1_1()之后执行,section1_2()在section2_1()之后执行,要注意的是,如果在调用notify()方法之前调用wait()方法,会导致死锁。
互斥
互斥是一种机制,您可以使用它来实现临界区,确保互斥。也就是说,一次只有一个任务可以执行由互斥保护的代码部分。在 Java 中,您可以使用synchronized关键字(允许您保护代码部分或整个方法)、ReentrantLock类或Semaphore类来实现临界区。
看下面的例子:
public void task() {
preCriticalSection();
lockObject.lock() // The critical section begins
criticalSection();
lockObject.unlock(); // The critical section ends
postCriticalSection();
}
多路复用
多路复用设计模式是互斥的一般化。在这种情况下,确定数量的任务可以同时执行临界区。例如,当您有多个资源的副本时,这是有用的。在 Java 中实现这个设计模式的最简单方法是使用初始化为可以同时执行临界区的任务数量的Semaphore类。
看下面的例子:
public void task() {
preCriticalSection();
semaphoreObject.acquire();
criticalSection();
semaphoreObject.release();
postCriticalSection();
}
屏障
这个设计模式解释了如何实现需要在一个共同点同步一些任务的情况。在所有任务到达同步点之前,没有一个任务可以继续执行。Java 并发 API 提供了CyclicBarrier类,这是这个设计模式的一个实现。
看下面的例子:
public void task() {
preSyncPoint();
barrierObject.await();
postSyncPoint();
}
双重检查锁定
这种设计模式提供了解决在获取锁并检查条件时发生的问题的方法。如果条件为假,您理想情况下已经获得了锁的开销。这种情况的一个例子是对象的延迟初始化。如果您有一个实现Singleton设计模式的类,可能会有类似以下的代码:
public class Singleton{
private static Singleton reference;
private static final Lock lock=new ReentrantLock();
public static Singleton getReference() {
lock.lock();
try {
if (reference==null) {
reference=new Object();
}
} finally {
lock.unlock();
}
return reference;
}
}
一个可能的解决方案是在条件中包含锁:
public class Singleton{
private Object reference;
private Lock lock=new ReentrantLock();
public Object getReference() {
if (reference==null) {
lock.lock();
try {
if (reference == null) {
reference=new Object();
}
} finally {
lock.unlock();
}
}
return reference;
}
}
这种解决方案仍然存在问题。如果两个任务同时检查条件,将创建两个对象。解决此问题的最佳方法不使用任何显式同步机制:
public class Singleton {
private static class LazySingleton {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getSingleton() {
return LazySingleton.INSTANCE;
}
}
读写锁
当您使用锁来保护对共享变量的访问时,只有一个任务可以访问该变量,无论您要对其执行什么操作。有时,您会有一些您多次修改但多次读取的变量。在这种情况下,锁提供了较差的性能,因为所有读取操作都可以并发进行而不会出现任何问题。为解决这个问题,存在读写锁设计模式。该模式定义了一种特殊类型的锁,具有两个内部锁:一个用于读操作,另一个用于写操作。该锁的行为如下:
-
如果一个任务正在执行读操作,另一个任务想要执行另一个读操作,它可以执行。
-
如果一个任务正在执行读操作,另一个任务想要执行写操作,它将被阻塞,直到所有读取操作完成。
-
如果一个任务正在执行写操作,另一个任务想要执行操作(读或写),它将被阻塞,直到写操作完成。
Java 并发 API 包括实现此设计模式的ReentrantReadWriteLock类。如果要从头开始实现此模式,必须非常小心读任务和写任务之间的优先级。如果存在太多的读任务,写任务可能会等待太久。
线程池
这种设计模式试图消除为要执行的任务创建线程引入的开销。它由一组线程和要执行的任务队列组成。线程组通常具有固定大小。当线程接近执行任务时,它不会完成执行;它会查找队列中的另一个任务。如果有另一个任务,它会执行它。如果没有,线程将等待,直到队列中插入任务,但不会被销毁。
Java 并发 API 包括一些实现ExecutorService接口的类,它们在内部使用线程池。
线程本地存储
这种设计模式定义了如何在任务中本地使用全局或静态变量。当类中有静态属性时,类的所有对象都访问属性的相同实例。如果使用线程本地存储,每个线程访问变量的不同实例。
Java 并发 API 包括ThreadLocal类来实现此设计模式。
Java 内存模型
当您在具有多个核心或处理器的计算机上执行并发应用程序时,可能会遇到内存缓存的问题。它们非常有用,可以增加应用程序的性能,但可能会导致数据不一致。当一个任务修改变量的值时,它在缓存中被修改,但在主内存中并没有立即修改。如果另一个任务在变量更新到主内存之前读取该变量的值,它将读取变量的旧值。
并发应用程序可能存在的其他问题是编译器和代码优化器引入的优化。有时,它们重新排列指令以获得更好的性能。在顺序应用程序中,这不会造成任何问题,但在并发应用程序中可能会导致意外结果。
为了解决这样的问题,编程语言引入了内存模型。内存模型描述了个别任务如何通过内存相互交互,以及一个任务所做的更改何时对另一个任务可见。它还定义了允许的代码优化以及在什么情况下允许。
有不同的内存模型。其中一些非常严格(所有任务始终可以访问相同的值),而其他一些不那么严格(只有一些指令更新主内存中的值)。内存模型必须为编译器和优化器开发人员所知,并且对其他程序员是透明的。
Java 是第一种定义自己内存模型的编程语言。JVM 中最初定义的内存模型存在一些问题,并在 Java 5 中重新定义。该内存模型在 Java 8 中是相同的。它在 JSR 133 中定义。基本上,Java 内存模型定义如下:
-
它定义了 volatile、synchronized 和 final 关键字的行为。
-
它确保一个正确同步的并发程序在所有架构上都能正确运行。
-
它创建了volatile read,volatile write,lock和unlock指令的部分排序,称为happens-before。任务同步也帮助我们建立 happens-before 关系。如果一个动作 happens-before 另一个动作,那么第一个动作对第二个动作是可见的并且有序的。
-
当任务获取监视器时,内存缓存被作废。
-
当任务释放监视器时,缓存数据被刷新到主内存中。
-
对于 Java 程序员来说是透明的。
Java 内存模型的主要目标是,正确编写的并发应用程序将在每个Java 虚拟机(JVM)上都正确运行,而不受操作系统、CPU 架构和 CPU 核心数量的影响。
设计并发算法的技巧和窍门
在这一部分,我们总结了一些设计良好的并发应用程序时必须牢记的技巧和窍门。
确定正确的独立任务
您只能执行彼此独立的并发任务。如果您有两个或更多具有顺序依赖性的任务,也许您对尝试并发执行它们并包括同步机制以保证执行顺序没有兴趣。任务将以顺序方式执行,并且您将不得不克服同步机制。另一种情况是当您有一个具有一些先决条件的任务,但这些先决条件彼此独立。在这种情况下,您可以并发执行先决条件,然后使用同步类来控制在所有先决条件完成后执行任务。
另一个不能使用并发的情况是当您有一个循环,并且所有步骤使用前一步生成的数据,或者有一些状态信息从一步到下一步。
在尽可能高的级别实现并发
丰富的线程 API,如 Java 并发 API,为您提供了不同的类来在应用程序中实现并发。在 Java 的情况下,您可以使用Thread或Lock类来控制线程的创建和同步,但它还为您提供了高级并发对象,如执行器或 Fork/Join 框架,允许您执行并发任务。这种高级机制为您带来以下好处:
-
您不必担心线程的创建和管理。您只需创建任务并将它们发送执行。Java 并发 API 控制线程的创建和管理。
-
它们被优化以比直接使用线程提供更好的性能。例如,它们使用线程池来重用线程并避免为每个任务创建线程。您可以从头开始实现这些机制,但这将花费您很多时间,而且这将是一个复杂的任务。
-
它们包括使 API 更加强大的高级功能。例如,在 Java 中,您可以使用执行器执行返回
Future对象形式的结果的任务。同样,您可以从头开始实现这些机制,但这是不建议的。 -
您的应用程序将更容易从一个操作系统迁移到另一个操作系统,并且它将更具可伸缩性。
-
您的应用程序可能会在未来的 Java 版本中变得更快。Java 开发人员不断改进内部,JVM 优化可能更适合 JDK API。
总之,出于性能和开发时间的原因,在实现并发算法之前,分析线程 API 提供的高级机制。
考虑可伸缩性
实现并发算法的主要目标之一是充分利用计算机的所有资源,特别是处理器或核心的数量。但是这个数量可能随时间而变化。硬件不断发展,每年成本都在降低。
当您使用数据分解设计并发算法时,不要假设应用程序将在多少核心或处理器上执行。动态获取系统信息(例如,在 Java 中,您可以使用Runtime.getRuntime().availableProcessors()方法获取),并使您的算法使用该信息来计算它将要执行的任务数量。这个过程会增加算法的执行时间,但您的算法将更具可伸缩性。
如果您使用任务分解设计并发算法,情况可能会更加困难。您取决于算法中独立任务的数量,强制增加任务数量将增加同步机制引入的开销,并且应用程序的全局性能甚至可能更差。详细分析算法,以确定是否可以具有动态任务数量。
使用线程安全的 API
如果您需要在并发应用程序中使用 Java 库,请先阅读其文档,了解它是否是线程安全的。如果它是线程安全的,您可以在应用程序中使用它而不会出现任何问题。如果不是,您有以下两个选择:
-
如果存在线程安全的替代方案,您应该使用它
-
如果不存在线程安全的替代方案,您应该添加必要的同步以避免所有可能的问题情况,特别是数据竞争条件
例如,如果您需要在并发应用程序中使用 List,如果您将从多个线程更新它,就不应该使用ArrayList类,因为它不是线程安全的。在这种情况下,您可以使用ConcurrentLinkedDeque, CopyOnWriteArrayList或LinkedBlockingDeque等线程安全类。如果您想要使用的类不是线程安全的,首先必须寻找线程安全的替代方案。可能,与您可以实现的任何替代方案相比,使用并发更加优化。
永远不要假设执行顺序
在不使用任何同步机制的并发应用程序中执行任务是不确定的。任务的执行顺序以及处理器在执行每个任务之前的时间由操作系统的调度程序确定。它不在乎您是否观察到执行顺序在多次执行中是相同的。下一次可能会有所不同。
这种假设的结果过去常常是数据竞争问题。您的算法的最终结果取决于任务的执行顺序。有时,结果可能是正确的,但其他时候可能是错误的。很难检测数据竞争条件的原因,因此您必须小心不要忘记所有必要的同步元素。
第二章:在可能的情况下,优先使用本地线程变量而不是静态和共享变量
线程本地变量是一种特殊类型的变量。每个任务都将拥有该变量的独立值,因此您不需要任何同步机制来保护对该变量的访问。
这可能听起来有点奇怪。每个对象都有类的属性的副本,那么为什么我们需要线程本地变量呢?考虑这种情况。您创建了一个Runnable任务,并且希望执行该任务的多个实例。您可以为要执行的每个线程创建一个Runnable对象,但另一种选择是创建一个Runnable对象,并使用该对象创建所有线程。在最后一种情况下,除非您使用ThreadLocal类,否则所有线程将可以访问类属性的相同副本。ThreadLocal类保证每个线程将访问其自己的变量实例,而无需使用锁、信号量或类似的类。
另一种情况可以利用线程本地变量的是静态属性。类的所有实例共享静态属性,但您可以使用ThreadLocal类来声明它们。在这种情况下,每个线程将可以访问其自己的副本。
您还可以选择使用类似ConcurrentHashMap<Thread, MyType>的东西,并像var.get(Thread.currentThread())或var.put(Thread.currentThread(), newValue)这样使用它。通常,这种方法比ThreadLocal慢得多,因为可能会有争用(ThreadLocal根本没有争用)。不过它也有一个优点:您可以完全清除映射,值将对每个线程消失;因此,有时使用这种方法是有用的。
找到算法更容易并行化的版本
我们可以将算法定义为解决问题的一系列步骤。解决同一个问题有不同的方法。有些更快,有些使用更少的资源,还有些更适合输入数据的特殊特征。例如,如果您想对一组数字进行排序,您可以使用已实现的多种排序算法之一。
在本章的前一节中,我们建议您使用顺序算法作为实现并发算法的起点。这种方法有两个主要优点:
-
您可以轻松测试并行算法的结果的正确性
-
你可以通过使用并发来衡量性能的改进。
但并非每个算法都可以并行化,至少不那么容易。您可能认为最佳起点是具有解决您想要并行化的问题的最佳性能的顺序算法,但这可能是一个错误的假设。您应该寻找一个可以轻松并行化的算法。然后,您可以将并发算法与具有最佳性能的顺序算法进行比较,以查看哪个提供了最佳吞吐量。
在可能的情况下使用不可变对象
在并发应用程序中,你可能会遇到的一个主要问题是数据竞争条件。正如我们之前解释过的,当两个或更多任务修改共享变量中存储的数据,并且对该变量的访问没有在关键部分内实现时,就会发生这种情况。
例如,当您使用 Java 等面向对象的语言时,您将应用程序实现为一组对象。每个对象都有一些属性和一些方法来读取和更改属性的值。如果一些任务共享一个对象并调用一个方法来更改该对象的属性的值,并且该方法没有受到同步机制的保护,那么您可能会遇到数据不一致的问题。
有一种特殊类型的对象称为不可变对象。它们的主要特征是初始化后无法修改任何属性。如果要修改属性的值,必须创建另一个对象。Java 中的String类是不可变对象的最佳示例。当您使用运算符(例如=或+)来改变 String 的值时,实际上是创建了一个新对象。
在并发应用中使用不可变对象有两个非常重要的优点:
-
您不需要任何同步机制来保护这些类的方法。如果两个任务想要修改相同的对象,它们将创建新对象,因此永远不会发生两个任务同时修改同一个对象的情况。
-
由于第一点的结论,您不会遇到任何数据不一致的问题。
不可变对象也有一个缺点。如果创建了太多对象,这可能会影响应用程序的吞吐量和内存使用。如果有一个简单的对象没有内部数据结构,通常将其设置为不可变是没有问题的。然而,使不可变的复杂对象,其中包含其他对象的集合,通常会导致严重的性能问题。
通过对锁进行排序来避免死锁
在并发应用程序中避免死锁情况的最佳机制之一是强制任务始终以相同的顺序获取共享资源。一个简单的方法是为每个资源分配一个编号。当任务需要多个资源时,必须按顺序请求它们。
例如,如果有两个任务 T1 和 T2,两者都需要两个资源 R1 和 R2,您可以强制两者首先请求 R1 资源,然后请求 R2 资源。您永远不会发生死锁。
另一方面,如果 T1 首先请求 R1,然后请求 R2,T2 首先请求 R2,然后请求 R1,就可能发生死锁。
例如,这个提示的不良使用如下。您有两个需要获取两个Lock对象的任务。它们尝试以不同的顺序获取锁:
public void operation1() {
lock1.lock();
lock2.lock();
….
}
public void operation2() {
lock2.lock();
lock1.lock();
…..
}
operation1()执行其第一句和operation2()也执行其第一句,因此它们将等待另一个Lock,从而导致死锁。
您可以通过以相同的顺序获取锁来避免这种情况。如果更改operation2(),则永远不会发生死锁,如下所示:
public void operation2() {
lock1.lock();
lock2.lock();
…..
}
使用原子变量而不是同步
当您需要在两个或多个任务之间共享数据时,必须使用同步机制来保护对该数据的访问,并避免任何数据不一致的问题。
在某些情况下,您可以使用volatile关键字而不使用同步机制。如果只有一个任务修改数据,其余任务读取数据,您可以使用volatile关键字而不会出现任何同步或数据不一致的问题。在其他情况下,您需要使用锁、synchronized关键字或任何其他同步方法。
在 Java 5 中,并发 API 包括一种称为原子变量的新类型变量。这些变量是支持单个变量上的原子操作的类。它们包括一个方法,称为compareAndSet(oldValue, newValue),其中包括一种机制来检测是否在一步中将新值分配给变量。如果变量的值等于oldValue,则将其更改为newValue并返回 true。否则,返回false。还有更多类似方式工作的方法,例如getAndIncrement()或getAndDecrement()。这些方法也是原子的。
这种解决方案是无锁的;也就是说,它不使用锁或任何同步机制,因此其性能比任何同步解决方案都要好。
您可以在 Java 中使用的最重要的原子变量是:
-
AtomicInteger -
AtomicLong -
AtomicReference -
AtomicBoolean -
LongAdder -
DoubleAdder
尽可能短地持有锁
锁,就像任何其他同步机制一样,允许您定义一个只有一个任务可以执行的关键部分。当一个任务执行关键部分时,想要执行它的其他任务被阻塞,并且必须等待关键部分的释放。应用程序是以顺序方式工作的。
您必须特别注意您在关键部分中包含的指令,因为您可能会降低应用程序的性能而没有意识到。您必须尽可能地使关键部分尽可能小,并且它必须只包含与其他任务共享数据的指令,这样应用程序以顺序方式执行的时间将最小化。
避免在关键部分内执行您无法控制的代码。例如,您正在编写一个接受用户定义的Callable的库,有时需要启动它。您不知道Callable中确切的内容。也许它会阻塞输入/输出,获取一些锁,调用库的其他方法,或者工作时间很长。因此,尽可能在您的库不持有任何锁时执行它。如果对您的算法来说这是不可能的,那么请在您的库文档中指定这种行为,并可能指定用户提供的代码的限制(例如,它不应该获取任何锁)。ConcurrentHashMap类的compute()方法中可以找到这种文档的一个很好的例子。
采取懒惰初始化的预防措施
懒惰初始化是一种延迟对象创建的机制,直到对象在应用程序中首次使用。它的主要优点是最小化内存使用,因为您只创建真正需要的对象,但在并发应用程序中可能会出现问题。
如果您有一个初始化对象的方法,并且这个方法同时被两个不同的任务调用,那么您可以初始化两个不同的对象。例如,这可能是单例类的问题,因为您只想创建这些类的一个对象。
这个问题的一个优雅的解决方案已经实现,就像延迟初始化持有者习惯(https://en.wikipedia.org/wiki/Initialization-on-demand_holder_idiom)。
避免在关键部分内部使用阻塞操作
阻塞操作是那些阻塞调用它们的任务直到事件发生的操作。例如,当您从文件中读取数据或向控制台写入数据时,调用这些操作的任务必须等待它们完成。
如果您将这些操作之一包含在关键部分中,那么您会降低应用程序的性能,因为想要执行该关键部分的任务都无法执行它。在关键部分内部的任务正在等待 I/O 操作的完成,而其他任务则在等待关键部分。
除非必须,不要在关键部分内包含阻塞操作。
总结
并发编程包括所有必要的工具和技术,使多个任务或进程可以在计算机中同时运行,彼此通信和同步,而不会丢失数据或不一致。
我们通过介绍并发的基本概念开始了本章。您必须了解并理解并发、并行和同步等术语,才能充分理解本书的示例。然而,并发可能会产生一些问题,如数据竞争条件、死锁、活锁等。您还必须了解并发应用程序的潜在问题。这将帮助您识别和解决这些问题。
我们还解释了英特尔引入的将顺序算法转换为并发算法的简单五步方法,并向您展示了一些在 Java 语言中实现的并发设计模式以及在实现并发应用程序时需要考虑的一些提示。
最后,我们简要解释了 Java 并发 API 的组件。这是一个非常丰富的 API,具有低级和非常高级的机制,可以让您轻松实现强大的并发应用程序。我们还描述了 Java 内存模型,它决定了并发应用程序如何管理内存和内部指令的执行顺序。
在下一章中,您将学习如何使用执行器框架实现使用大量线程的应用程序。这允许您通过控制您使用的资源并减少线程创建引入的开销(它重用Thread对象来执行不同的任务)来执行大量线程。
第二章:管理大量线程-执行程序
当您实现简单的并发应用程序时,您会为每个并发任务创建和执行一个线程。这种方法可能会有一些重要问题。自Java 版本 5以来,Java 并发 API 包括执行程序框架,以提高具有大量并发任务的并发应用程序的性能。在本章中,我们将介绍以下内容:
-
执行程序介绍
-
第一个示例- k 最近邻算法
-
第二个示例-客户端/服务器环境中的并发性
执行程序介绍
在 Java 中实现并发应用程序的基本机制是:
-
实现 Runnable 接口的类:这是您想以并发方式实现的代码
-
Thread 类的实例:这是将以并发方式执行代码的线程
通过这种方法,您负责创建和管理Thread对象,并实现线程之间的同步机制。但是,它可能会有一些问题,特别是对于具有大量并发任务的应用程序。如果创建了太多的线程,可能会降低应用程序的性能,甚至挂起整个系统。
Java 5 包括执行程序框架,以解决这些问题并提供有效的解决方案,这将比传统的并发机制更容易供程序员使用。
在本章中,我们将通过使用执行程序框架实现以下两个示例来介绍执行程序框架的基本特性:
-
k 最近邻算法:这是一种基本的机器学习算法,用于分类。它根据训练数据集中k个最相似示例的标签确定测试示例的标签。
-
客户端/服务器环境中的并发性:为数千或数百万客户端提供信息的应用程序现在至关重要。在最佳方式下实现系统的服务器端是至关重要的。
在第三章中,从执行程序中获取最大值,和第四章中,从任务中获取数据- Callable 和 Future 接口,我们将介绍执行程序的更高级方面。
执行程序的基本特性
执行程序的主要特点是:
-
您不需要创建任何
Thread对象。如果要执行并发任务,只需创建任务的实例(例如,实现Runnable接口的类),并将其发送到执行程序。它将管理执行任务的线程。 -
执行程序通过重用线程来减少线程创建引入的开销。在内部,它管理一个名为worker-threads的线程池。如果您将任务发送到执行程序并且有一个空闲的 worker-thread,执行程序将使用该线程来执行任务。
-
很容易控制执行程序使用的资源。您可以限制执行程序的 worker-threads 的最大数量。如果发送的任务多于 worker-threads,执行程序会将它们存储在队列中。当 worker-thread 完成任务的执行时,它会从队列中取出另一个任务。
-
您必须显式完成执行程序的执行。您必须指示执行程序完成其执行并终止创建的线程。如果不这样做,它将无法完成其执行,您的应用程序也将无法结束。
执行程序具有更多有趣的特性,使其非常强大和灵活。
执行程序框架的基本组件
执行程序框架具有各种接口和类,实现了执行程序提供的所有功能。框架的基本组件包括:
-
Executor 接口:这是执行器框架的基本接口。它只定义了一个允许程序员将
Runnable对象发送到执行器的方法。 -
ExecutorService 接口:这个接口扩展了
Executor接口,并包括更多的方法来增加框架的功能,例如: -
执行返回结果的任务:
Runnable接口提供的run()方法不返回结果,但使用执行器,你可以有返回结果的任务。 -
使用单个方法调用执行任务列表
-
完成执行器的执行并等待其终止
-
ThreadPoolExecutor 类:这个类实现了
Executor和ExecutorService接口。此外,它包括一些额外的方法来获取执行器的状态(工作线程数、执行任务数等),建立执行器的参数(最小和最大工作线程数、空闲线程等待新任务的时间等),以及允许程序员扩展和调整其功能的方法。 -
Executors 类:这个类提供了创建
Executor对象和其他相关类的实用方法。
第一个例子 - k 最近邻算法
k 最近邻算法是一种简单的用于监督分类的机器学习算法。该算法的主要组成部分是:
-
一个训练数据集:这个数据集由一个或多个属性定义每个实例以及一个特殊属性组成,该属性确定实例的示例或标签
-
一个距离度量标准:这个度量标准用于确定训练数据集的实例与你想要分类的新实例之间的距离(或相似性)
-
一个测试数据集:这个数据集用于衡量算法的行为
当它必须对一个实例进行分类时,它会计算与这个实例和训练数据集中所有实例的距离。然后,它会取最近的 k 个实例,并查看这些实例的标签。具有最多实例的标签将被分配给输入实例。
在本章中,我们将使用UCI 机器学习库的银行营销数据集,你可以从archive.ics.uci.edu/ml/datasets/Bank+Marketing下载。为了衡量实例之间的距离,我们将使用欧几里得距离。使用这个度量标准,我们实例的所有属性必须具有数值。银行营销数据集的一些属性是分类的(也就是说,它们可以取一些预定义的值),所以我们不能直接使用欧几里得距离。可以为每个分类值分配有序数;例如,对于婚姻状况,0 表示单身,1 表示已婚,2 表示离婚。然而,这将意味着离婚的人比已婚更接近单身,这是值得商榷的。为了使所有分类值等距离,我们创建单独的属性,如已婚、单身和离婚,它们只有两个值:0(否)和 1(是)。
我们的数据集有 66 个属性和两个可能的标签:是和否。我们还将数据分成了两个子集:
-
训练数据集:有 39,129 个实例
-
测试数据集:有 2,059 个实例
正如我们在第一章中解释的那样,第一步 - 并发设计原则,我们首先实现了算法的串行版本。然后,我们寻找可以并行化的算法部分,并使用执行器框架来执行并发任务。在接下来的章节中,我们将解释 k 最近邻算法的串行实现和两个不同的并发版本。第一个版本具有非常细粒度的并发性,而第二个版本具有粗粒度的并发性。
K 最近邻 - 串行版本
我们已经在KnnClassifier类中实现了算法的串行版本。在内部,这个类存储了训练数据集和数字k(我们将用来确定实例标签的示例数量):
public class KnnClassifier {
private List <? extends Sample> dataSet;
private int k;
public KnnClassifier(List <? extends Sample> dataSet, int k) {
this.dataSet=dataSet;
this.k=k;
}
KnnClassifier类只实现了一个名为classify的方法,该方法接收一个Sample对象,其中包含我们要分类的实例,并返回一个分配给该实例的标签的字符串:
public String classify (Sample example) {
这种方法有三个主要部分 - 首先,我们计算输入示例与训练数据集中所有示例之间的距离:
Distance[] distances=new Distance[dataSet.size()];
int index=0;
for (Sample localExample : dataSet) {
distances[index]=new Distance();
distances[index].setIndex(index);
distances[index].setDistance (EuclideanDistanceCalculator.calculate(localExample, example));
index++;
}
然后,我们使用Arrays.sort()方法将示例按距离从低到高排序:
Arrays.sort(distances);
最后,我们统计 k 个最近示例中出现最多的标签:
Map<String, Integer> results = new HashMap<>();
for (int i = 0; i < k; i++) {
Sample localExample = dataSet.get(distances[i].getIndex());
String tag = localExample.getTag();
results.merge(tag, 1, (a, b) -> a+b);
}
return Collections.max(results.entrySet(), Map.Entry.comparingByValue()).getKey();
}
为了计算两个示例之间的距离,我们可以使用一个辅助类中实现的欧几里得距离。这是该类的代码:
public class EuclideanDistanceCalculator {
public static double calculate (Sample example1, Sample example2) {
double ret=0.0d;
double[] data1=example1.getExample();
double[] data2=example2.getExample();
if (data1.length!=data2.length) {
throw new IllegalArgumentException ("Vector doesn't have the same length");
}
for (int i=0; i<data1.length; i++) {
ret+=Math.pow(data1[i]-data2[i], 2);
}
return Math.sqrt(ret);
}
}
我们还使用Distance类来存储Sample输入和训练数据集实例之间的距离。它只有两个属性:训练数据集示例的索引和输入示例的距离。此外,它实现了Comparable接口以使用Arrays.sort()方法。最后,Sample类存储一个实例。它只有一个双精度数组和一个包含该实例标签的字符串。
K 最近邻 - 细粒度并发版本
如果你分析 k 最近邻算法的串行版本,你会发现以下两个点可以并行化算法:
-
距离的计算:计算输入示例与训练数据集中一个示例之间的距离的每次循环迭代都是独立的
-
距离的排序:Java 8 在
Arrays类中包含了parallelSort()方法,以并发方式对数组进行排序。
在算法的第一个并发版本中,我们将为我们要计算的示例之间的每个距离创建一个任务。我们还将使并发排序数组的产生成为可能。我们在一个名为KnnClassifierParrallelIndividual的类中实现了这个算法的版本。它存储了训练数据集、k参数、ThreadPoolExecutor对象来执行并行任务、一个属性来存储我们想要在执行器中拥有的工作线程数量,以及一个属性来存储我们是否想要进行并行排序。
我们将创建一个具有固定线程数的执行器,以便我们可以控制此执行器将使用的系统资源。这个数字将是系统中可用处理器的数量,我们使用Runtime类的availableProcessors()方法获得,乘以构造函数中名为factor的参数的值。它的值将是从处理器获得的线程数。我们将始终使用值1,但您可以尝试其他值并比较结果。这是分类的构造函数:
public class KnnClassifierParallelIndividual {
private List<? extends Sample> dataSet;
private int k;
private ThreadPoolExecutor executor;
private int numThreads;
private boolean parallelSort;
public KnnClassifierParallelIndividual(List<? extends Sample> dataSet, int k, int factor, boolean parallelSort) {
this.dataSet=dataSet;
this.k=k;
numThreads=factor* (Runtime.getRuntime().availableProcessors());
executor=(ThreadPoolExecutor) Executors.newFixedThreadPool(numThreads);
this.parallelSort=parallelSort;
}
要创建执行程序,我们使用了Executors实用类及其newFixedThreadPool()方法。此方法接收您希望在执行程序中拥有的工作线程数。执行程序的工作线程数永远不会超过您在构造函数中指定的数量。此方法返回一个ExecutorService对象,但我们将其转换为ThreadPoolExecutor对象,以便访问类提供的方法,而这些方法不包含在接口中。
该类还实现了classify()方法,该方法接收一个示例并返回一个字符串。
首先,我们为需要计算的每个距离创建一个任务并将它们发送到执行程序。然后,主线程必须等待这些任务的执行结束。为了控制最终化,我们使用了 Java 并发 API 提供的同步机制:CountDownLatch类。该类允许一个线程等待,直到其他线程到达其代码的确定点。它使用两种方法:
-
getDown():此方法减少您必须等待的线程数。 -
await():此方法挂起调用它的线程,直到计数器达到零
在这种情况下,我们使用任务数初始化CountDownLatch类。主线程调用await()方法,并在完成计算时为每个任务调用getDown()方法:
public String classify (Sample example) throws Exception {
Distance[] distances=new Distance[dataSet.size()];
CountDownLatch endController=new CountDownLatch(dataSet.size());
int index=0;
for (Sample localExample : dataSet) {
IndividualDistanceTask task=new IndividualDistanceTask(distances, index, localExample, example, endController);
executor.execute(task);
index++;
}
endController.await();
然后,根据parallelSort属性的值,我们调用Arrays.sort()或Arrays.parallelSort()方法。
if (parallelSort) {
Arrays.parallelSort(distances);
} else {
Arrays.sort(distances);
}
最后,我们计算分配给输入示例的标签。此代码与串行版本相同。
KnnClassifierParallelIndividual类还包括一个调用其shutdown()方法关闭执行程序的方法。如果不调用此方法,您的应用程序将永远不会结束,因为执行程序创建的线程仍然活着,等待执行新任务。先前提交的任务将被执行,并且新提交的任务将被拒绝。该方法不会等待执行程序的完成,它会立即返回:
public void destroy() {
executor.shutdown();
}
这个示例的一个关键部分是IndividualDistanceTask类。这是一个计算输入示例与训练数据集示例之间距离的类。它存储完整的距离数组(我们将仅为其之一的位置设置值),训练数据集示例的索引,两个示例和用于控制任务结束的CountDownLatch对象。它实现了Runnable接口,因此可以在执行程序中执行。这是该类的构造函数:
public class IndividualDistanceTask implements Runnable {
private Distance[] distances;
private int index;
private Sample localExample;
private Sample example;
private CountDownLatch endController;
public IndividualDistanceTask(Distance[] distances, int index, Sample localExample,
Sample example, CountDownLatch endController) {
this.distances=distances;
this.index=index;
this.localExample=localExample;
this.example=example;
this.endController=endController;
}
run()方法使用之前解释的EuclideanDistanceCalculator类计算两个示例之间的距离,并将结果存储在距离的相应位置:
public void run() {
distances[index] = new Distance();
distances[index].setIndex(index);
distances[index].setDistance (EuclideanDistanceCalculator.calculate(localExample, example));
endController.countDown();
}
提示
请注意,尽管所有任务共享距离数组,但我们不需要使用任何同步机制,因为每个任务将修改数组的不同位置。
K 最近邻 - 粗粒度并发版本
在上一节中介绍的并发解决方案可能存在问题。您正在执行太多任务。如果停下来想一想,在这种情况下,我们有超过 29,000 个训练示例,因此您将为每个要分类的示例启动 29,000 个任务。另一方面,我们已经创建了一个最大具有numThreads工作线程的执行程序,因此另一个选项是仅启动numThreads个任务并将训练数据集分成numThreads组。我们使用四核处理器执行示例,因此每个任务将计算输入示例与大约 7,000 个训练示例之间的距离。
我们已经在KnnClassifierParallelGroup类中实现了这个解决方案。它与KnnClassifierParallelIndividual类非常相似,但有两个主要区别。首先是classify()方法的第一部分。现在,我们只有numThreads个任务,我们必须将训练数据集分成numThreads个子集:
public String classify(Sample example) throws Exception {
Distance distances[] = new Distance[dataSet.size()];
CountDownLatch endController = new CountDownLatch(numThreads);
int length = dataSet.size() / numThreads;
int startIndex = 0, endIndex = length;
for (int i = 0; i < numThreads; i++) {
GroupDistanceTask task = new GroupDistanceTask(distances, startIndex, endIndex, dataSet, example, endController);
startIndex = endIndex;
if (i < numThreads - 2) {
endIndex = endIndex + length;
} else {
endIndex = dataSet.size();
}
executor.execute(task);
}
endController.await();
在长度变量中计算每个任务的样本数量。然后,我们为每个线程分配它们需要处理的样本的起始和结束索引。对于除最后一个线程之外的所有线程,我们将长度值添加到起始索引以计算结束索引。对于最后一个线程,最后一个索引是数据集的大小。
其次,这个类使用GroupDistanceTask而不是IndividualDistanceTask。这两个类之间的主要区别是第一个处理训练数据集的子集,因此它存储了完整的训练数据集以及它需要处理的数据集的第一个和最后一个位置:
public class GroupDistanceTask implements Runnable {
private Distance[] distances;
private int startIndex, endIndex;
private Sample example;
private List<? extends Sample> dataSet;
private CountDownLatch endController;
public GroupDistanceTask(Distance[] distances, int startIndex, int endIndex, List<? extends Sample> dataSet, Sample example, CountDownLatch endController) {
this.distances = distances;
this.startIndex = startIndex;
this.endIndex = endIndex;
this.example = example;
this.dataSet = dataSet;
this.endController = endController;
}
run()方法处理一组示例而不仅仅是一个示例:
public void run() {
for (int index = startIndex; index < endIndex; index++) {
Sample localExample=dataSet.get(index);
distances[index] = new Distance();
distances[index].setIndex(index);
distances[index].setDistance(EuclideanDistanceCalculator
.calculate(localExample, example));
}
endController.countDown();
}
比较解决方案
让我们比较我们实现的 k 最近邻算法的不同版本。我们有以下五个不同的版本:
-
串行版本
-
具有串行排序的细粒度并发版本
-
具有并发排序的细粒度并发版本
-
具有串行排序的粗粒度并发版本
-
具有并发排序的粗粒度并发版本
为了测试算法,我们使用了 2,059 个测试实例,这些实例来自银行营销数据集。我们使用 k 的值为 10、30 和 50,对所有这些示例使用了算法的五个版本进行分类,并测量它们的执行时间。我们使用了JMH 框架(openjdk.java.net/projects/code-tools/jmh/),它允许您在 Java 中实现微基准测试。使用基准测试框架比简单地使用currentTimeMillis()或nanoTime()方法来测量时间更好。以下是结果:
| 算法 | K | 执行时间(秒) |
|---|---|---|
| 串行 | 10 | 100.296 |
| 30 | 99.218 | |
| 50 | 99.458 | |
| 细粒度串行排序 | 10 | 108.150 |
| 30 | 105.196 | |
| 50 | 109.797 | |
| 细粒度并发排序 | 10 | 84.663 |
| 30 | 85,392 | |
| 50 | 83.373 | |
| 粗粒度串行排序 | 10 | 78.328 |
| 30 | 77.041 | |
| 50 | 76.549 | |
| 粗粒度并发排序 | 10 | 54,017 |
| 30 | 53.473 | |
| 50 | 53.255 |
我们可以得出以下结论:
-
所选的 K 参数值(10、30 和 50)不影响算法的执行时间。这五个版本对于这三个值呈现出类似的结果。
-
正如预期的那样,使用
Arrays.parallelSort()方法的并发排序在算法的细粒度和粗粒度并发版本中都大大提高了性能。 -
算法的细粒度版本与串行算法给出了相同或略差的结果。并发任务的创建和管理引入的开销导致了这些结果。我们执行了太多的任务。
-
另一方面,粗粒度版本提供了很大的性能改进,无论是串行还是并行排序。
因此,算法的最佳版本是使用并行排序的粗粒度解决方案。如果我们将其与计算加速度的串行版本进行比较:

这个例子显示了一个并发解决方案的良好选择如何给我们带来巨大的改进,而糟糕的选择会给我们带来糟糕的性能。
第二个例子 - 客户端/服务器环境中的并发性
客户端/服务器模型是一种软件架构,将应用程序分为两部分:提供资源(数据、操作、打印机、存储等)的服务器部分和使用服务器提供的资源的客户端部分。传统上,这种架构在企业世界中使用,但随着互联网的兴起,它仍然是一个实际的话题。您可以将 Web 应用程序视为客户端/服务器应用程序,其中服务器部分是在 Web 服务器中执行的应用程序的后端部分,Web 浏览器执行应用程序的客户端部分。SOA(面向服务的架构的缩写)是客户端/服务器架构的另一个例子,其中公开的 Web 服务是服务器部分,而消费这些服务的不同客户端是客户端部分。
在客户端/服务器环境中,通常有一个服务器和许多客户端使用服务器提供的服务,因此服务器的性能是设计这些系统时的关键方面之一。
在本节中,我们将实现一个简单的客户端/服务器应用程序。它将对世界银行的世界发展指标进行数据搜索,您可以从这里下载:data.worldbank.org/data-catalog/world-development-indicators。这些数据包含了 1960 年至 2014 年间世界各国不同指标的数值。
我们服务器的主要特点将是:
-
客户端和服务器将使用套接字连接
-
客户端将以字符串形式发送其查询,服务器将以另一个字符串形式回复结果
-
服务器可以用三种不同的查询进行回复:
-
查询:此查询的格式为
q;codCountry;codIndicator;year,其中codCountry是国家的代码,codIndicator是指标的代码,year是一个可选参数,表示您要查询的年份。服务器将以单个字符串形式回复信息。 -
报告:此查询的格式为
r;codIndicator,其中codIndicator是您想要报告的指标的代码。服务器将以单个字符串形式回复所有国家在多年间该指标的平均值。 -
停止:此查询的格式为
z;。服务器在收到此命令时停止执行。 -
在其他情况下,服务器会返回错误消息。
与之前的示例一样,我们将向您展示如何实现此客户端/服务器应用程序的串行版本。然后,我们将向您展示如何使用执行器实现并发版本。最后,我们将比较这两种解决方案,以查看在这种情况下使用并发的优势。
客户端/服务器 - 串行版本
我们的服务器应用程序的串行版本有三个主要部分:
-
DAO(数据访问对象的缩写)部分,负责访问数据并获取查询结果
-
命令部分,由每种查询类型的命令组成
-
服务器部分,接收查询,调用相应的命令,并将结果返回给客户端
让我们详细看看这些部分。
DAO 部分
如前所述,服务器将对世界银行的世界发展指标进行数据搜索。这些数据在一个 CSV 文件中。应用程序中的 DAO 组件将整个文件加载到内存中的 List 对象中。它实现了一个方法来处理它将处理的每个查询,以便查找数据。
我们不在此处包含此类的代码,因为它很容易实现,而且不是本书的主要目的。
命令部分
命令部分是 DAO 和服务器部分之间的中介。我们实现了一个基本的抽象 Command 类,作为所有命令的基类:
public abstract class Command {
protected String[] command;
public Command (String [] command) {
this.command=command;
}
public abstract String execute ();
}
然后,我们为每个查询实现了一个命令。查询在 QueryCommand 类中实现。execute() 方法如下:
public String execute() {
WDIDAO dao=WDIDAO.getDAO();
if (command.length==3) {
return dao.query(command[1], command[2]);
} else if (command.length==4) {
try {
return dao.query(command[1], command[2], Short.parseShort(command[3]));
} catch (Exception e) {
return "ERROR;Bad Command";
}
} else {
return "ERROR;Bad Command";
}
}
报告是在ReportCommand中实现的。execute()方法如下:
@Override
public String execute() {
WDIDAO dao=WDIDAO.getDAO();
return dao.report(command[1]);
}
停止查询是在StopCommand类中实现的。其execute()方法如下:
@Override
public String execute() {
return "Server stopped";
}
最后,错误情况由ErrorCommand类处理。其execute()方法如下:
@Override
public String execute() {
return "Unknown command: "+command[0];
}
服务器部分
最后,服务器部分是在SerialServer类中实现的。首先,它通过调用getDAO()方法来初始化 DAO。主要目标是 DAO 加载所有数据:
public class SerialServer {
public static void main(String[] args) throws IOException {
WDIDAO dao = WDIDAO.getDAO();
boolean stopServer = false;
System.out.println("Initialization completed.");
try (ServerSocket serverSocket = new ServerSocket(Constants.SERIAL_PORT)) {
之后,我们有一个循环,直到服务器接收到停止查询才会执行。这个循环执行以下四个步骤:
-
接收来自客户端的查询
-
解析和拆分查询的元素
-
调用相应的命令
-
将结果返回给客户端
这四个步骤显示在以下代码片段中:
do {
try (Socket clientSocket = serverSocket.accept();
PrintWriter out = new PrintWriter (clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));) {
String line = in.readLine();
Command command;
String[] commandData = line.split(";");
System.out.println("Command: " + commandData[0]);
switch (commandData[0]) {
case "q":
System.out.println("Query");
command = new QueryCommand(commandData);
break;
case "r":
System.out.println("Report");
command = new ReportCommand(commandData);
break;
case "z":
System.out.println("Stop");
command = new StopCommand(commandData);
stopServer = true;
break;
default:
System.out.println("Error");
command = new ErrorCommand(commandData);
}
String response = command.execute();
System.out.println(response);
out.println(response);
} catch (IOException e) {
e.printStackTrace();
}
} while (!stopServer);
客户端/服务器-并行版本
服务器的串行版本有一个非常重要的限制。在处理一个查询时,它无法处理其他查询。如果服务器需要大量时间来响应每个请求,或者某些请求,服务器的性能将非常低。
使用并发可以获得更好的性能。如果服务器在收到请求时创建一个线程,它可以将所有查询的处理委托给线程,并且可以处理新的请求。这种方法也可能存在一些问题。如果我们收到大量查询,我们可能会通过创建太多线程来饱和系统。但是,如果我们使用具有固定线程数的执行程序,我们可以控制服务器使用的资源,并获得比串行版本更好的性能。
要将我们的串行服务器转换为使用执行程序的并发服务器,我们必须修改服务器部分。DAO 部分是相同的,我们已更改实现命令部分的类的名称,但它们的实现几乎相同。只有停止查询发生了变化,因为现在它有更多的责任。让我们看看并发服务器部分的实现细节。
服务器部分
并发服务器部分是在ConcurrentServer部分实现的。我们添加了两个在串行服务器中未包括的元素:一个缓存系统,实现在ParallelCache类中,以及一个日志系统,实现在Logger类中。首先,它通过调用getDAO()方法来初始化 DAO 部分。主要目标是 DAO 加载所有数据并使用Executors类的newFixedThreadPool()方法创建ThreadPoolExecutor对象。此方法接收我们服务器中要使用的最大工作线程数。执行程序永远不会有超过这些工作线程。要获取工作线程数,我们使用Runtime类的availableProcessors()方法获取系统的核心数:
public class ConcurrentServer {
private static ThreadPoolExecutor executor;
private static ParallelCache cache;
private static ServerSocket serverSocket;
private static volatile boolean stopped = false;
public static void main(String[] args) {
serverSocket=null;
WDIDAO dao=WDIDAO.getDAO();
executor=(ThreadPoolExecutor) Executors.newFixedThreadPool (Runtime.getRuntime().availableProcessors());
cache=new ParallelCache();
Logger.initializeLog();
System.out.println("Initialization completed.");
stopped布尔变量声明为 volatile,因为它将从另一个线程更改。volatile关键字确保当stopped变量被另一个线程设置为true时,这种更改将在主方法中可见。没有volatile关键字,由于 CPU 缓存或编译器优化,更改可能不可见。然后,我们初始化ServerSocket以侦听请求:
serverSocket = new ServerSocket(Constants.CONCURRENT_PORT);
我们不能使用 try-with-resources 语句来管理服务器套接字。当我们收到stop命令时,我们需要关闭服务器,但服务器正在serverSocket对象的accept()方法中等待。为了强制服务器离开该方法,我们需要显式关闭服务器(我们将在shutdown()方法中执行),因此我们不能让 try-with-resources 语句为我们关闭套接字。
之后,我们有一个循环,直到服务器接收到停止查询才会执行。这个循环有三个步骤,如下所示:
-
接收来自客户端的查询
-
创建一个处理该查询的任务
-
将任务发送给执行程序
这三个步骤显示在以下代码片段中:
do {
try {
Socket clientSocket = serverSocket.accept();
RequestTask task = new RequestTask(clientSocket);
executor.execute(task);
} catch (IOException e) {
e.printStackTrace();
}
} while (!stopped);
最后,一旦服务器完成了执行(退出循环),我们必须等待执行器的完成,使用 awaitTermination() 方法。这个方法将阻塞主线程,直到执行器完成其 execution() 方法。然后,我们关闭缓存系统,并等待一条消息来指示服务器执行的结束,如下所示:
executor.awaitTermination(1, TimeUnit.DAYS);
System.out.println("Shutting down cache");
cache.shutdown();
System.out.println("Cache ok");
System.out.println("Main server thread ended");
我们添加了两个额外的方法:getExecutor() 方法,返回用于执行并发任务的 ThreadPoolExecutor 对象,以及 shutdown() 方法,用于有序地结束服务器的执行器。它调用执行器的 shutdown() 方法,并关闭 ServerSocket:
public static void shutdown() {
stopped = true;
System.out.println("Shutting down the server...");
System.out.println("Shutting down executor");
executor.shutdown();
System.out.println("Executor ok");
System.out.println("Closing socket");
try {
serverSocket.close();
System.out.println("Socket ok");
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("Shutting down logger");
Logger.sendMessage("Shutting down the logger");
Logger.shutdown();
System.out.println("Logger ok");
}
在并发服务器中,有一个重要的部分:RequestTask 类,它处理客户端的每个请求。这个类实现了 Runnable 接口,因此可以以并发方式在执行器中执行。它的构造函数接收 Socket 参数,用于与客户端通信。
public class RequestTask implements Runnable {
private Socket clientSocket;
public RequestTask(Socket clientSocket) {
this.clientSocket = clientSocket;
}
run() 方法做了与串行服务器相同的事情来响应每个请求:
-
接收客户端的查询
-
解析和拆分查询的元素
-
调用相应的命令
-
将结果返回给客户端
以下是它的代码片段:
public void run() {
try (PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader( clientSocket.getInputStream()));) {
String line = in.readLine();
Logger.sendMessage(line);
ParallelCache cache = ConcurrentServer.getCache();
String ret = cache.get(line);
if (ret == null) {
Command command;
String[] commandData = line.split(";");
System.out.println("Command: " + commandData[0]);
switch (commandData[0]) {
case "q":
System.err.println("Query");
command = new ConcurrentQueryCommand(commandData);
break;
case "r":
System.err.println("Report");
command = new ConcurrentReportCommand(commandData);
break;
case "s":
System.err.println("Status");
command = new ConcurrentStatusCommand(commandData);
break;
case "z":
System.err.println("Stop");
command = new ConcurrentStopCommand(commandData);
break;
default:
System.err.println("Error");
command = new ConcurrentErrorCommand(commandData);
break;
}
ret = command.execute();
if (command.isCacheable()) {
cache.put(line, ret);
}
} else {
Logger.sendMessage("Command "+line+" was found in the cache");
}
System.out.println(ret);
out.println(ret);
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
命令部分
在命令部分,我们已经重命名了所有的类,就像你在前面的代码片段中看到的那样。实现是一样的,除了 ConcurrentStopCommand 类。现在,它调用 ConcurrentServer 类的 shutdown() 方法,以有序地终止服务器的执行。这是 execute() 方法:
@Override
public String execute() {
ConcurrentServer.shutdown();
return "Server stopped";
}
此外,现在 Command 类包含一个新的 isCacheable() 布尔方法,如果命令结果存储在缓存中则返回 true,否则返回 false。
并发服务器的额外组件
我们在并发服务器中实现了一些额外的组件:一个新的命令来返回有关服务器状态的信息,一个缓存系统来存储命令的结果,当请求重复时节省时间,以及一个日志系统来写入错误和调试信息。以下各节描述了这些组件的每个部分。
状态命令
首先,我们有一个新的可能的查询。它的格式是 s;,由 ConcurrentStatusCommand 类处理。它获取服务器使用的 ThreadPoolExecutor,并获取有关执行器状态的信息:
public class ConcurrentStatusCommand extends Command {
public ConcurrentStatusCommand (String[] command) {
super(command);
setCacheable(false);
}
@Override
public String execute() {
StringBuilder sb=new StringBuilder();
ThreadPoolExecutor executor=ConcurrentServer.getExecutor();
sb.append("Server Status;");
sb.append("Actived Threads: ");
sb.append(String.valueOf(executor.getActiveCount()));
sb.append(";");
sb.append("Maximum Pool Size: ");
sb.append(String.valueOf(executor.getMaximumPoolSize()));
sb.append(";");
sb.append("Core Pool Size: ");
sb.append(String.valueOf(executor.getCorePoolSize()));
sb.append(";");
sb.append("Pool Size: ");
sb.append(String.valueOf(executor.getPoolSize()));
sb.append(";");
sb.append("Largest Pool Size: ");
sb.append(String.valueOf(executor.getLargestPoolSize()));
sb.append(";");
sb.append("Completed Task Count: ");
sb.append(String.valueOf(executor.getCompletedTaskCount()));
sb.append(";");
sb.append("Task Count: ");
sb.append(String.valueOf(executor.getTaskCount()));
sb.append(";");
sb.append("Queue Size: ");
sb.append(String.valueOf(executor.getQueue().size()));
sb.append(";");
sb.append("Cache Size: ");
sb.append(String.valueOf (ConcurrentServer.getCache().getItemCount()));
sb.append(";");
Logger.sendMessage(sb.toString());
return sb.toString();
}
}
我们从服务器获取的信息是:
-
getActiveCount(): 这返回了执行我们并发任务的近似任务数量。池子中可能有更多的线程,但它们可能是空闲的。 -
getMaximumPoolSize(): 这返回了执行器可以拥有的最大工作线程数。 -
getCorePoolSize(): 这返回了执行器将拥有的核心工作线程数。这个数字决定了池子将拥有的最小线程数。 -
getPoolSize(): 这返回了池子中当前的线程数。 -
getLargestPoolSize(): 这返回了池子在执行期间的最大线程数。 -
getCompletedTaskCount(): 这返回了执行器已执行的任务数量。 -
getTaskCount(): 这返回了曾被调度执行的任务的近似数量。 -
getQueue().size(): 这返回了等待在任务队列中的任务数量。
由于我们使用 Executor 类的 newFixedThreadPool() 方法创建了我们的执行器,因此我们的执行器将具有相同的最大和核心工作线程数。
缓存系统
我们在并行服务器中加入了一个缓存系统,以避免最近进行的数据搜索。我们的缓存系统有三个元素:
-
CacheItem 类:这个类代表缓存中存储的每个元素。它有四个属性:
-
缓存中存储的命令。我们将把
query和report命令存储在缓存中。 -
由该命令生成的响应。
-
缓存中该项的创建日期。
-
缓存中该项上次被访问的时间。
-
CleanCacheTask 类:如果我们将所有命令存储在缓存中,但从未删除其中存储的元素,缓存的大小将无限增加。为了避免这种情况,我们可以有一个删除缓存中元素的任务。我们将实现这个任务作为一个
Thread对象。有两个选项: -
您可以在缓存中设置最大大小。如果缓存中的元素多于最大大小,可以删除最近访问次数较少的元素。
-
您可以从缓存中删除在预定义时间段内未被访问的元素。我们将使用这种方法。
-
ParallelCache 类:这个类实现了在缓存中存储和检索元素的操作。为了将数据存储在缓存中,我们使用了
ConcurrentHashMap数据结构。由于缓存将在服务器的所有任务之间共享,我们必须使用同步机制来保护对缓存的访问,避免数据竞争条件。我们有三个选项: -
我们可以使用一个非同步的数据结构(例如
HashMap),并添加必要的代码来同步对这个数据结构的访问类型,例如使用锁。您还可以使用Collections类的synchronizedMap()方法将HashMap转换为同步结构。 -
使用同步数据结构,例如
Hashtable。在这种情况下,我们没有数据竞争条件,但性能可能会更好。 -
使用并发数据结构,例如
ConcurrentHashMap类,它消除了数据竞争条件的可能性,并且在高并发环境中进行了优化。这是我们将使用ConcurrentHashMap类的对象来实现的选项。
CleanCacheTask类的代码如下:
public class CleanCacheTask implements Runnable {
private ParallelCache cache;
public CleanCacheTask(ParallelCache cache) {
this.cache = cache;
}
@Override
public void run() {
try {
while (!Thread.currentThread().interrupted()) {
TimeUnit.SECONDS.sleep(10);
cache.cleanCache();
}
} catch (InterruptedException e) {
}
}
}
该类有一个ParallelCache对象。每隔 10 秒,它执行ParallelCache实例的cleanCache()方法。
ParallelCache类有五种不同的方法。首先是类的构造函数,它初始化缓存的元素。它创建ConcurrentHashMap对象并启动一个将执行CleanCacheTask类的线程:
public class ParallelCache {
private ConcurrentHashMap<String, CacheItem> cache;
private CleanCacheTask task;
private Thread thread;
public static int MAX_LIVING_TIME_MILLIS = 600_000;
public ParallelCache() {
cache=new ConcurrentHashMap<>();
task=new CleanCacheTask(this);
thread=new Thread(task);
thread.start();
}
然后,有两种方法来存储和检索缓存中的元素。我们使用put()方法将元素插入HashMap中,使用get()方法从HashMap中检索元素:
public void put(String command, String response) {
CacheItem item = new CacheItem(command, response);
cache.put(command, item);
}
public String get (String command) {
CacheItem item=cache.get(command);
if (item==null) {
return null;
}
item.setAccessDate(new Date());
return item.getResponse();
}
然后,CleanCacheTask类使用的清除缓存的方法是:
public void cleanCache() {
Date revisionDate = new Date();
Iterator<CacheItem> iterator = cache.values().iterator();
while (iterator.hasNext()) {
CacheItem item = iterator.next();
if (revisionDate.getTime() - item.getAccessDate().getTime() > MAX_LIVING_TIME_MILLIS) {
iterator.remove();
}
}
}
最后,关闭缓存的方法中断执行CleanCacheTask类的线程,并返回缓存中存储的元素数量的方法是:
public void shutdown() {
thread.interrupt();
}
public int getItemCount() {
return cache.size();
}
日志系统
在本章的所有示例中,我们使用System.out.println()方法在控制台中写入信息。当您实现一个将在生产环境中执行的企业应用程序时,最好使用日志系统来写入调试和错误信息。在 Java 中,log4j是最流行的日志系统。在这个例子中,我们将实现我们自己的日志系统,实现生产者/消费者并发设计模式。将使用我们的日志系统的任务将是生产者,而将日志信息写入文件的特殊任务(作为线程执行)将是消费者。这个日志系统的组件有:
-
LogTask:这个类实现了日志消费者,每隔 10 秒读取队列中存储的日志消息并将其写入文件。它将由一个
Thread对象执行。 -
Logger:这是我们日志系统的主要类。它有一个队列,生产者将在其中存储信息,消费者将读取信息。它还包括将消息添加到队列中的方法以及获取队列中存储的所有消息并将它们写入磁盘的方法。
为了实现队列,就像缓存系统一样,我们需要一个并发数据结构来避免任何数据不一致的错误。我们有两个选择:
-
使用阻塞数据结构,当队列满时会阻塞线程(在我们的情况下,它永远不会满)或为空时
-
使用非阻塞数据结构,如果队列满或为空,则返回一个特殊值
我们选择了一个非阻塞数据结构,ConcurrentLinkedQueue类,它实现了Queue接口。我们使用offer()方法向队列中插入元素,使用poll()方法从中获取元素。
LogTask类的代码非常简单:
public class LogTask implements Runnable {
@Override
public void run() {
try {
while (Thread.currentThread().interrupted()) {
TimeUnit.SECONDS.sleep(10);
Logger.writeLogs();
}
} catch (InterruptedException e) {
}
Logger.writeLogs();
}
}
该类实现了Runnable接口,在run()方法中调用Logger类的writeLogs()方法,每 10 秒执行一次。
Logger类有五种不同的静态方法。首先是一个静态代码块,用于初始化和启动执行LogTask的线程,并创建用于存储日志数据的ConcurrentLinkedQueue类:
public class Logger {
private static ConcurrentLinkedQueue<String> logQueue = new ConcurrentLinkedQueue<String>();
private static Thread thread;
private static final String LOG_FILE = Paths.get("output", "server.log").toString();
static {
LogTask task = new LogTask();
thread = new Thread(task);
}
然后,有一个sendMessage()方法,它接收一个字符串作为参数,并将该消息存储在队列中。为了存储消息,它使用offer()方法:
public static void sendMessage(String message) {
logQueue.offer(new Date()+": "+message);
}
该类的一个关键方法是writeLogs()方法。它使用ConcurrentLinkedQueue类的poll()方法获取并删除队列中存储的所有日志消息,并将它们写入文件:
public static void writeLogs() {
String message;
Path path = Paths.get(LOG_FILE);
try (BufferedWriter fileWriter = Files.newBufferedWriter(path,StandardOpenOption.CREATE,
StandardOpenOption.APPEND)) {
while ((message = logQueue.poll()) != null) {
fileWriter.write(new Date()+": "+message);
fileWriter.newLine();
}
} catch (IOException e) {
e.printStackTrace();
}
}
最后,两种方法:一种是截断日志文件,另一种是完成日志系统的执行器,中断正在执行LogTask的线程:
public static void initializeLog() {
Path path = Paths.get(LOG_FILE);
if (Files.exists(path)) {
try (OutputStream out = Files.newOutputStream(path,
StandardOpenOption.TRUNCATE_EXISTING)) {
} catch (IOException e) {
e.printStackTrace();
}
}
thread.start();
}
public static void shutdown() {
thread.interrupt();
}
比较两种解决方案
现在是时候测试串行和并发服务器,看看哪个性能更好了。我们已经实现了四个类来自动化测试,这些类向服务器发出查询。这些类是:
-
SerialClient:这个类实现了一个可能的串行服务器客户端。它使用查询消息进行九次请求,并使用报告消息进行一次查询。它重复这个过程 10 次,因此它请求了 90 个查询和 10 个报告。 -
MultipleSerialClients:这个类模拟了同时存在多个客户端的情况。为此,我们为每个SerialClient创建一个线程,并同时执行它们,以查看服务器的性能。我们已经测试了从一个到五个并发客户端。 -
ConcurrentClient:这个类类似于SerialClient类,但它调用的是并发服务器,而不是串行服务器。 -
MultipleConcurrentClients:这个类类似于MultipleSerialClients类,但它调用的是并发服务器,而不是串行服务器。
要测试串行服务器,可以按照以下步骤进行:
-
启动串行服务器并等待其初始化。
-
启动
MultipleSerialClients类,它启动一个、两个、三个、四个,最后是五个SerialClient类。
您可以使用类似的过程来测试并发服务器:
-
启动并等待并发服务器的初始化。
-
启动
MultipleConcurrentClients类,它启动一个、两个、三个、四个,最后是五个ConcurrentClient类。
为了比较两个版本的执行时间,我们使用 JMH 框架(openjdk.java.net/projects/code-tools/jmh/)实现了一个微基准测试。我们基于SerialClient和ConcurrentClient任务实现了两次执行。我们重复这个过程 10 次,计算每个并发客户端的平均时间。我们在一个具有四个核心处理器的计算机上进行了测试,因此它可以同时执行四个并行任务。
这些执行的结果如下:
| 并发客户端 | 串行服务器 | 并发服务器 | 加速比 |
|---|---|---|---|
| 1 | 7.404 | 5.144 | 1.43 |
| 2 | 9.344 | 4.491 | 2.08 |
| 3 | 19.641 | 9.308 | 2.11 |
| 4 | 29.180 | 12.842 | 2.27 |
| 5 | 30.542 | 16.322 | 1.87 |
单元格的内容是每个客户端的平均时间(以秒为单位)。我们可以得出以下结论:
-
两种类型服务器的性能都受到并发客户端发送请求的数量的影响
-
在所有情况下,并发版本的执行时间远远低于串行版本的执行时间
其他有趣的方法
在本章的页面中,我们使用了 Java 并发 API 的一些类来实现执行程序框架的基本功能。这些类还有其他有趣的方法。在本节中,我们整理了其中一些。
Executors类提供其他方法来创建ThreadPoolExecutor对象。这些方法是:
-
newCachedThreadPool(): 此方法创建一个ThreadPoolExecutor对象,如果空闲,则重用工作线程,但如果有必要,则创建一个新线程。没有工作线程的最大数量。 -
newSingleThreadExecutor(): 此方法创建一个只使用单个工作线程的ThreadPoolExecutor对象。您发送到执行程序的任务将存储在队列中,直到工作线程可以执行它们。 -
CountDownLatch类提供以下附加方法: -
await(long timeout, TimeUnit unit): 它等待直到内部计数器到达零或者经过参数中指定的时间。如果时间过去,方法返回false值。 -
getCount(): 此方法返回内部计数器的实际值。
Java 中有两种类型的并发数据结构:
-
阻塞数据结构:当您调用一个方法并且库无法执行该操作(例如,尝试获取一个元素,而数据结构为空),它们会阻塞线程,直到操作可以完成。
-
非阻塞数据结构:当您调用一个方法并且库无法执行该操作(因为结构为空或已满)时,该方法会返回一个特殊值或抛出异常。
有些数据结构同时实现了这两种行为,有些数据结构只实现了其中一种。通常,阻塞数据结构也实现了具有非阻塞行为的方法,而非阻塞数据结构不实现阻塞方法。
实现阻塞操作的方法有:
-
put(),putFirst(),putLast(): 这些在数据结构中插入一个元素。如果已满,它会阻塞线程,直到有空间。 -
take(),takeFirst(),takeLast(): 这些返回并移除数据结构的一个元素。如果为空,它会阻塞线程,直到有元素。
实现非阻塞操作的方法有:
-
add(),addFirst(),addLast(): 这些在数据结构中插入一个元素。如果已满,方法会抛出IllegalStateException异常。 -
remove(),removeFirst(),removeLast(): 这些方法从数据结构中返回并移除一个元素。如果为空,方法会抛出IllegalStateException异常。 -
element(),getFirst(),getLast(): 这些从数据结构中返回但不移除一个元素。如果为空,方法会抛出IllegalStateException异常。 -
offer(),offerFirst(),offerLast(): 这些在数据结构中插入一个元素值。如果已满,它们返回false布尔值。 -
poll(),pollFirst(),pollLast(): 这些从数据结构中返回并移除一个元素。如果为空,它们返回 null 值。 -
peek(),peekFirst(),peekLast(): 这些从数据结构中返回但不移除一个元素。如果为空,它们返回 null 值。
在第九章中,深入并发数据结构和同步工具,我们将更详细地描述并发数据结构。
摘要
在简单的并发应用程序中,我们使用Runnable接口和Thread类执行并发任务。我们创建和管理线程并控制它们的执行。在大型并发应用程序中,我们不能采用这种方法,因为它可能会给我们带来许多问题。对于这些情况,Java 并发 API 引入了执行器框架。在本章中,我们介绍了构成此框架的基本特征和组件。首先是Executor接口,它定义了将Runnable任务发送到执行器的基本方法。该接口有一个子接口,即ExecutorService接口,该接口包括将返回结果的任务发送到执行器的方法(这些任务实现了Callable接口,正如我们将在第四章中看到的,从任务中获取数据-Callable 和 Future 接口)以及任务列表。
ThreadPoolExecutor类是这两个接口的基本实现:添加额外的方法来获取有关执行器状态和正在执行的线程或任务数量的信息。创建此类的对象的最简单方法是使用Executors实用程序类,该类包括创建不同类型的执行器的方法。
我们向您展示了如何使用执行器,并使用执行器实现了两个真实世界的例子,将串行算法转换为并发算法。第一个例子是 k 最近邻算法,将其应用于 UCI 机器学习存储库的银行营销数据集。第二个例子是一个客户端/服务器应用程序,用于查询世界银行的世界发展指标。
在这两种情况下,使用执行器都为我们带来了很大的性能改进。
在下一章中,我们将描述如何使用执行器实现高级技术。我们将完成我们的客户端/服务器应用程序,添加取消任务和在低优先级任务之前执行具有更高优先级的任务的可能性。我们还将向您展示如何实现定期执行任务,实现一个 RSS 新闻阅读器。
第三章:从执行者中获得最大效益
在第二章中,管理大量线程-执行者,我们介绍了执行者的基本特性,作为改进执行大量并发任务的并发应用程序性能的一种方式。在本章中,我们将进一步解释执行者的高级特性,使它们成为您并发应用程序的强大工具。在本章中,我们将涵盖以下内容:
-
执行者的高级特性
-
第一个例子-高级服务器应用程序
-
第二个例子-执行周期性任务
-
有关执行者的其他信息
执行者的高级特性
执行者是一个允许程序员执行并发任务而不必担心线程的创建和管理的类。程序员创建Runnable对象并将它们发送到执行者,执行者创建和管理必要的线程来执行这些任务。在第二章中,管理大量线程-执行者,我们介绍了执行者框架的基本特性:
-
如何创建执行者以及我们创建执行者时的不同选项
-
如何将并发任务发送到执行者
-
如何控制执行者使用的资源
-
执行者在内部如何使用线程池来优化应用程序的性能
但是,执行者可以为您提供更多选项,使其成为并发应用程序中的强大机制。
取消任务
您可以在将任务发送到执行者后取消任务的执行。使用submit()方法将Runnable对象发送到执行者时,它返回Future接口的实现。这个类允许您控制任务的执行。它具有cancel()方法,尝试取消任务的执行。它接收一个布尔值作为参数。如果它采用true值并且执行者正在执行此任务,则将中断执行任务的线程。
以下是您希望取消的任务无法取消的情况:
-
任务已经被取消
-
任务已经完成执行
-
任务正在运行,并且您向
cancel()方法提供了false作为参数 -
API 文档中未指定的其他原因
cancel()方法返回一个布尔值,指示任务是否已取消。
安排任务的执行
ThreadPoolExecutor类是Executor和ExecutorService接口的基本实现。但是,Java 并发 API 提供了这个类的扩展,以允许执行计划任务。这是ScheduledThreadPoolExeuctor类,您可以:
-
在延迟后执行任务
-
定期执行任务;这包括以固定速率或固定延迟执行任务
重写执行者方法
执行者框架是一个非常灵活的机制。您可以实现自己的执行者,扩展现有类(ThreadPoolExecutor或ScheduledThreadPoolExecutor)以获得所需的行为。这些类包括使更改执行者工作方式变得容易的方法。如果您重写ThreadPoolExecutor,可以重写以下方法:
-
beforeExecute():此方法在执行者中的并发任务执行之前调用。它接收将要执行的Runnable对象和将执行它们的Thread对象。此方法接收的Runnable对象是FutureTask类的实例,而不是使用submit()方法将Runnable对象发送到执行者的Runnable对象。 -
afterExecute(): 这个方法在执行器中的并发任务执行后被调用。它接收到已执行的Runnable对象和一个存储可能在任务内部抛出的异常的Throwable对象。与beforeExecute()方法一样,Runnable对象是FutureTask类的一个实例。 -
newTaskFor(): 这个方法创建将要执行submit()方法发送的Runnable对象的任务。它必须返回RunnableFuture接口的一个实现。默认情况下,Open JDK 8 和 Oracle JDK 8 返回FutureTask类的一个实例,但这种情况在将来的实现中可能会改变。
如果您扩展了ScheduledThreadPoolExecutor类,可以重写decorateTask()方法。这个方法类似于用于计划任务的newTaskFor()方法。它允许您重写执行器执行的任务。
更改一些初始化参数
您还可以通过更改创建时的一些参数来更改执行器的行为。最有用的是:
-
BlockingQueue<Runnable>: 每个执行器都使用内部的BlockingQueue来存储等待执行的任务。您可以将此接口的任何实现作为参数传递。例如,您可以更改执行任务的默认顺序。 -
ThreadFactory: 您可以指定ThreadFactory接口的一个实现,执行器将使用该工厂来创建执行任务的线程。例如,您可以使用ThreadFactory接口来创建Thread类的扩展,该扩展保存有关任务执行时间的日志信息。 -
RejectedExecutionHandler: 在调用shutdown()或shutdownNow()方法之后,发送到执行器的所有任务都将被拒绝。您可以指定RejectedExecutionHandler接口的一个实现来管理这种情况。
第一个示例 - 高级服务器应用程序
在第二章中,管理大量线程 - 执行器,我们介绍了一个客户端/服务器应用程序的示例。我们实现了一个服务器来搜索世界银行的世界发展指标数据,并且一个客户端对该服务器进行多次调用以测试执行器的性能。
在本节中,我们将扩展该示例以添加以下特性:
-
您可以使用新的取消查询取消服务器上的查询执行。
-
您可以使用优先级参数控制查询的执行顺序。具有更高优先级的任务将首先执行。
-
服务器将计算使用服务器的不同用户使用的任务数量和总执行时间。
为了实现这些新特性,我们对服务器进行了以下更改:
-
我们为每个查询添加了两个参数。第一个是发送查询的用户的名称,另一个是查询的优先级。查询的新格式如下:
-
查询:
q;username;priority;codCountry;codIndicator;year,其中username是用户的名称,priority是查询的优先级,codCountry是国家代码,codIndicator是指标代码,year是一个可选参数,用于查询的年份。 -
报告:
r;username;priority;codIndicator,其中username是用户的名称,priority是查询的优先级,codIndicator是您要报告的指标代码。 -
状态:
s;username;priority,其中username是用户的名称,priority是查询的优先级。 -
停止:
z;username;priority,其中username是用户的名称,priority是查询的优先级。 -
我们已经实现了一个新的查询:
-
取消:
c;username;priority,其中username是用户的名称,priority是查询的优先级。 -
我们实现了自己的执行器来:
-
计算每个用户的服务器使用情况
-
按优先级执行任务
-
控制任务的拒绝
-
我们已经调整了
ConcurrentServer和RequestTask以考虑服务器的新元素
服务器的其余元素(缓存系统、日志系统和DAO类)都是相同的,因此不会再次描述。
ServerExecutor 类
正如我们之前提到的,我们实现了自己的执行器来执行服务器的任务。我们还实现了一些额外但必要的类来提供所有功能。让我们描述这些类。
统计对象
我们的服务器将计算每个用户在其上执行的任务数量以及这些任务的总执行时间。为了存储这些数据,我们实现了ExecutorStatistics类。它有两个属性来存储信息:
public class ExecutorStatistics {
private AtomicLong executionTime = new AtomicLong(0L);
private AtomicInteger numTasks = new AtomicInteger(0);
这些属性是AtomicVariables,支持对单个变量的原子操作。这允许您在不使用任何同步机制的情况下在不同的线程中使用这些变量。然后,它有两种方法来增加任务数量和执行时间:
public void addExecutionTime(long time) {
executionTime.addAndGet(time);
}
public void addTask() {
numTasks.incrementAndGet();
}
最后,我们添加了获取这两个属性值的方法,并重写了toString()方法以便以可读的方式获取信息:
@Override
public String toString() {
return "Executed Tasks: "+getNumTasks()+". Execution Time: "+getExecutionTime();
}
被拒绝的任务控制器
当您创建一个执行器时,可以指定一个类来管理其被拒绝的任务。当您在执行器中调用shutdown()或shutdownNow()方法后提交任务时,执行器会拒绝该任务。
为了控制这种情况,我们实现了RejectedTaskController类。这个类实现了RejectedExecutionHandler接口,并实现了rejectedExecution()方法:
public class RejectedTaskController implements RejectedExecutionHandler {
@Override
public void rejectedExecution(Runnable task, ThreadPoolExecutor executor) {
ConcurrentCommand command=(ConcurrentCommand)task;
Socket clientSocket=command.getSocket();
try {
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(),true);
String message="The server is shutting down."
+" Your request can not be served."
+" Shutting Down: "
+String.valueOf(executor.isShutdown())
+". Terminated: "
+String.valueOf(executor.isTerminated())
+". Terminating: "
+String.valueOf(executor.isTerminating());
out.println(message);
out.close();
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
rejectedExecution()方法每拒绝一个任务调用一次,并接收被拒绝的任务和拒绝任务的执行器作为参数。
执行器任务
当您向执行器提交一个Runnable对象时,它不会直接执行该Runnable对象。它会创建一个新对象,即FutureTask类的实例,正是这个任务由执行器的工作线程执行。
在我们的情况下,为了测量任务的执行时间,我们在ServerTask类中实现了我们自己的FutureTask实现。它扩展了FutureTask类,并实现了Comparable接口,如下所示:
public class ServerTask<V> extends FutureTask<V> implements Comparable<ServerTask<V>>{
在内部,它将要执行的查询存储为ConcurrentCommand对象:
private ConcurrentCommand command;
在构造函数中,它使用FutureTask类的构造函数并存储ConcurrentCommand对象:
public ServerTask(ConcurrentCommand command) {
super(command, null);
this.command=command;
}
public ConcurrentCommand getCommand() {
return command;
}
public void setCommand(ConcurrentCommand command) {
this.command = command;
}
最后,它实现了compareTo()操作,比较两个ServerTask实例存储的命令。这可以在以下代码中看到:
@Override
public int compareTo(ServerTask<V> other) {
return command.compareTo(other.getCommand());
}
执行器
现在我们有了执行器的辅助类,我们必须实现执行器本身。我们实现了ServerExecutor类来实现这个目的。它扩展了ThreadPoolExecutor类,并具有一些内部属性,如下所示:
-
startTimes:这是一个ConcurrentHashMap,用于存储每个任务的开始日期。类的键将是ServerTask对象(一个Runnable对象),值将是一个Date对象。 -
executionStatistics:这是一个ConcurrentHashMap,用于存储每个用户的使用统计。键将是用户名,值将是一个ExecutorStatistics对象。 -
CORE_POOL_SIZE,MAXIMUM_POOL_SIZE和KEEP_ALIVE_TIME:这些是用于定义执行器特性的常量。 -
REJECTED_TASK_CONTROLLER: 这是一个RejectedTaskController类的属性,用于控制执行器拒绝的任务。
这可以通过以下代码来解释:
public class ServerExecutor extends ThreadPoolExecutor {
private ConcurrentHashMap<Runnable, Date> startTimes;
private ConcurrentHashMap<String, ExecutorStatistics> executionStatistics;
private static int CORE_POOL_SIZE = Runtime.getRuntime().availableProcessors();
private static int MAXIMUM_POOL_SIZE = Runtime.getRuntime().availableProcessors();
private static long KEEP_ALIVE_TIME = 10;
private static RejectedTaskController REJECTED_TASK_CONTROLLER = new RejectedTaskController();
public ServerExecutor() {
super(CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new PriorityBlockingQueue<>(), REJECTED_TASK_CONTROLLER);
startTimes = new ConcurrentHashMap<>();
executionStatistics = new ConcurrentHashMap<>();
}
该类的构造函数调用父类构造函数,创建一个PriorityBlockingQueue类来存储将在执行器中执行的任务。该类根据compareTo()方法的执行结果对元素进行排序(因此存储在其中的元素必须实现Comparable接口)。使用此类将允许我们按优先级执行任务。
然后,我们重写了ThreadPoolExecutor类的一些方法。首先是beforeExecute()方法。该方法在每个任务执行之前执行。它接收ServerTask对象作为参数,以及将要执行任务的线程。在我们的情况下,我们使用ConcurrentHashMap存储每个任务的开始日期:
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
startTimes.put(r, new Date());
}
下一个方法是afterExecute()方法。该方法在执行器中每个任务执行后执行,并接收已执行的ServerTask对象作为参数和一个Throwable对象。只有在任务执行过程中抛出异常时,最后一个参数才会有值。在我们的情况下,我们将使用此方法来:
-
计算任务的执行时间。
-
以以下方式更新用户的统计信息:
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
ServerTask<?> task=(ServerTask<?>)r;
ConcurrentCommand command=task.getCommand();
if (t==null) {
if (!task.isCancelled()) {
Date startDate = startTimes.remove(r);
Date endDate=new Date();
long executionTime= endDate.getTime() - startDate.getTime();
;
ExecutorStatistics statistics = executionStatistics.computeIfAbsent (command.getUsername(), n -> new ExecutorStatistics());
statistics.addExecutionTime(executionTime);
statistics.addTask();
ConcurrentServer.finishTask (command.getUsername(), command);
}
else {
String message="The task" + command.hashCode() + "of user" + command.getUsername() + "has been cancelled.";
Logger.sendMessage(message);
}
} else {
String message="The exception "
+t.getMessage()
+" has been thrown.";
Logger.sendMessage(message);
}
}
最后,我们重写了newTaskFor()方法。该方法将被执行,将我们通过submit()方法发送到执行器的Runnable对象转换为由执行器执行的FutureTask实例。在我们的情况下,我们将默认的FutureTask类替换为我们的ServerTask对象:
@Override
protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
return new ServerTask<T>(runnable);
}
我们在执行器中包含了一个额外的方法,用于将执行器中存储的所有统计信息写入日志系统。此方法将在服务器执行结束时调用,稍后您将看到。我们有以下代码:
public void writeStatistics() {
for(Entry<String, ExecutorStatistics> entry: executionStatistics.entrySet()) {
String user = entry.getKey();
ExecutorStatistics stats = entry.getValue(); Logger.sendMessage(user+":"+stats);
}
}
命令类
命令类执行您可以发送到服务器的不同查询。您可以向我们的服务器发送五种不同的查询:
-
查询:这是用于获取有关国家、指标和可选年份的信息的命令。由
ConcurrentQueryCommand类实现。 -
报告:这是用于获取有关指标的信息的命令。由
ConcurrentReportCommand类实现。 -
状态:这是用于获取服务器状态信息的命令。由
ConcurrentStatusCommand类实现。 -
取消:这是用于取消用户任务执行的命令。由
ConcurrentCancelCommand类实现。 -
停止:这是用于停止服务器执行的命令。由
ConcurrentStopCommand类实现。
我们还有ConcurrentErrorCommand类,用于处理服务器接收到未知命令的情况,以及ConcurrentCommand类,它是所有命令的基类。
ConcurrentCommand 类
这是每个命令的基类。它包括所有命令共有的行为,包括以下内容:
-
调用实现每个命令特定逻辑的方法
-
将结果写入客户端
-
关闭通信中使用的所有资源
该类扩展了Command类,并实现了Comparable和Runnable接口。在第二章的示例中,命令是简单的类,但在这个示例中,并发命令是将发送到执行器的Runnable对象:
public abstract class ConcurrentCommand extends Command implements Comparable<ConcurrentCommand>, Runnable{
它有三个属性:
-
username:这是用于存储发送查询的用户的名称。 -
priority:这是用于存储查询的优先级。它将确定查询的执行顺序。 -
socket:这是与客户端通信中使用的套接字。
该类的构造函数初始化了这些属性:
private String username;
private byte priority;
private Socket socket;
public ConcurrentCommand(Socket socket, String[] command) {
super(command);
username=command[1];
priority=Byte.parseByte(command[2]);
this.socket=socket;
}
这个类的主要功能在抽象的execute()方法中,每个具体命令都将通过该方法来计算和返回查询的结果,并且在run()方法中。run()方法调用execute()方法,将结果存储在缓存中,将结果写入套接字,并关闭通信中使用的所有资源。我们有以下内容:
@Override
public abstract String execute();
@Override
public void run() {
String message="Running a Task: Username: "
+username
+"; Priority: "
+priority;
Logger.sendMessage(message);
String ret=execute();
ParallelCache cache = ConcurrentServer.getCache();
if (isCacheable()) {
cache.put(String.join(";",command), ret);
}
try {
PrintWriter out = new PrintWriter(socket.getOutputStream(),true);
out.println(ret);
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
System.out.println(ret);
}
最后,compareTo()方法使用优先级属性来确定任务的顺序。这将被PriorityBlockingQueue类用来对任务进行排序,因此具有更高优先级的任务将首先执行。请注意,当getPriority()方法返回较低的值时,任务的优先级更高。如果任务的getPriority()返回1,那么该任务的优先级将高于getPriority()方法返回2的任务:
@Override
public int compareTo(ConcurrentCommand o) {
return Byte.compare(o.getPriority(), this.getPriority());
}
具体命令
我们对实现不同命令的类进行了微小的更改,并添加了一个由ConcurrentCancelCommand类实现的新命令。这些类的主要逻辑包含在execute()方法中,该方法计算查询的响应并将其作为字符串返回。
新的ConcurrentCancelCommand的execute()方法调用ConcurrentServer类的cancelTasks()方法。此方法将停止与作为参数传递的用户相关的所有待处理任务的执行:
@Override
public String execute() {
ConcurrentServer.cancelTasks(getUsername());
String message = "Tasks of user "
+getUsername()
+" has been cancelled.";
Logger.sendMessage(message);
return message;
}
ConcurrentReportCommand的execute()方法使用WDIDAO类的query()方法来获取用户请求的数据。在第二章中,管理大量线程-执行者,您可以找到此方法的实现。实现几乎相同。唯一的区别是命令数组索引如下:
@Override
public String execute() {
WDIDAO dao=WDIDAO.getDAO();
if (command.length==5) {
return dao.query(command[3], command[4]);
} else if (command.length==6) {
try {
return dao.query(command[3], command[4], Short.parseShort(command[5]));
} catch (NumberFormatException e) {
return "ERROR;Bad Command";
}
} else {
return "ERROR;Bad Command";
}
}
ConcurrentQueryCommand的execute()方法使用WDIDAO类的report()方法来获取数据。在第二章中,管理大量线程-执行者,您还可以找到此方法的实现。这里的实现几乎相同。唯一的区别是命令数组索引:
@Override
public String execute() {
WDIDAO dao=WDIDAO.getDAO();
return dao.report(command[3]);
}
ConcurrentStatusCommand在其构造函数中有一个额外的参数:Executor对象,它将执行命令。此命令使用此对象来获取有关执行程序的信息,并将其作为响应发送给用户。实现几乎与第二章中的相同。我们使用相同的方法来获取Executor对象的状态。
ConcurrentStopCommand和ConcurrentErrorCommand与第二章中的相同,因此我们没有包含它们的源代码。
服务器部分
服务器部分接收来自服务器客户端的查询,并创建执行查询的命令类,并将其发送到执行程序。由两个类实现:
-
ConcurrentServer类:它包括服务器的main()方法和取消任务以及完成系统执行的其他方法。 -
RequestTask类:此类创建命令并将其发送到执行程序
与第二章的示例管理大量线程-执行器的主要区别是RequestTask类的作用。在SimpleServer示例中,ConcurrentServer类为每个查询创建一个RequestTask对象并将其发送到执行器。在这个例子中,我们只会有一个RequestTask的实例,它将作为一个线程执行。当ConcurrentServer接收到一个连接时,它将把用于与客户端通信的套接字存储在一个并发的待处理连接列表中。RequestTask线程读取该套接字,处理客户端发送的数据,创建相应的命令,并将命令发送到执行器。
这种改变的主要原因是只在执行器中留下查询的代码,并将预处理的代码留在执行器之外。
ConcurrentServer 类
ConcurrentServer类需要一些内部属性才能正常工作:
-
一个
ParallelCache实例用于使用缓存系统。 -
一个
ServerSocket实例用于接收来自客户端的连接。 -
一个
boolean值用于知道何时停止执行。 -
一个
LinkedBlockingQueue用于存储发送消息给服务器的客户端的套接字。这些套接字将由RequestTask类处理。 -
一个
ConcurrentHashMap用于存储与执行器中的每个任务相关的Future对象。键将是发送查询的用户的用户名,值将是另一个Map,其键将是ConcurrenCommand对象,值将是与该任务相关联的Future实例。我们使用这些Future实例来取消任务的执行。 -
一个
RequestTask实例用于创建命令并将其发送到执行器。 -
一个
Thread对象来执行RequestTask对象。
这段代码如下:
public class ConcurrentServer {
private static ParallelCache cache;
private static volatile boolean stopped=false;
private static LinkedBlockingQueue<Socket> pendingConnections;
private static ConcurrentMap<String, ConcurrentMap<ConcurrentCommand, ServerTask<?>>> taskController;
private static Thread requestThread;
private static RequestTask task;
这个类的main()方法初始化这些对象,并打开ServerSocket实例以监听来自客户端的连接。此外,它创建RequestTask对象并将其作为线程执行。它将循环执行,直到shutdown()方法改变了 stopped 属性的值。之后,它等待Executor对象的完成,使用RequestTask对象的endTermination()方法,并使用finishServer()方法关闭Logger系统和RequestTask对象:
public static void main(String[] args) {
WDIDAO dao=WDIDAO.getDAO();
cache=new ParallelCache();
Logger.initializeLog();
pendingConnections = new LinkedBlockingQueue<Socket>();
taskController = new ConcurrentHashMap<String, ConcurrentHashMap<Integer, Future<?>>>();
task=new RequestTask(pendingConnections, taskController);
requestThread=new Thread(task);
requestThread.start();
System.out.println("Initialization completed.");
serverSocket= new ServerSocket(Constants.CONCURRENT_PORT);
do {
try {
Socket clientSocket = serverSocket.accept();
pendingConnections.put(clientSocket);
} catch (Exception e) {
e.printStackTrace();
}
} while (!stopped);
finishServer();
System.out.println("Shutting down cache");
cache.shutdown();
System.out.println("Cache ok" + new Date());
}
它包括两种方法来关闭服务器的执行器。shutdown()方法改变stopped变量的值,并关闭serverSocket实例。finishServer()方法停止执行器,中断执行RequestTask对象的线程,并关闭Logger系统。我们将这个过程分成两部分,以便在服务器的最后一条指令之前使用Logger系统:
public static void shutdown() {
stopped=true;
try {
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
private static void finishServer() {
System.out.println("Shutting down the server...");
task.shutdown();
System.out.println("Shutting down Request task");
requestThread.interrupt();
System.out.println("Request task ok");
System.out.println("Closing socket");
System.out.println("Shutting down logger");
Logger.sendMessage("Shutting down the logger");
Logger.shutdown();
System.out.println("Logger ok");
System.out.println("Main server thread ended");
}
服务器包括取消与用户关联的任务的方法。正如我们之前提到的,Server类使用嵌套的ConcurrentHashMap来存储与用户关联的所有任务。首先,我们获取一个用户的所有任务的Map,然后我们处理这些任务的所有Future对象,调用Future对象的cancel()方法。我们将值true作为参数传递,因此如果执行器正在运行该用户的任务,它将被中断。我们已经包括了必要的代码来避免ConcurrentCancelCommand的取消:
public static void cancelTasks(String username) {
ConcurrentMap<ConcurrentCommand, ServerTask<?>> userTasks = taskController.get(username);
if (userTasks == null) {
return;
}
int taskNumber = 0;
Iterator<ServerTask<?>> it = userTasks.values().iterator();
while(it.hasNext()) {
ServerTask<?> task = it.next();
ConcurrentCommand command = task.getCommand();
if(!(command instanceof ConcurrentCancelCommand) && task.cancel(true)) {
taskNumber++;
Logger.sendMessage("Task with code "+command.hashCode()+"cancelled: "+command.getClass().getSimpleName());
it.remove();
}
}
String message=taskNumber+" tasks has been cancelled.";
Logger.sendMessage(message);
}
最后,我们已经包括了一个方法,当任务正常执行完毕时,从我们的ServerTask对象的嵌套映射中消除与任务相关的Future对象。这就是finishTask()方法:
public static void finishTask(String username, ConcurrentCommand command) {
ConcurrentMap<ConcurrentCommand, ServerTask<?>> userTasks = taskController.get(username);
userTasks.remove(command);
String message = "Task with code "+command.hashCode()+" has finished";
Logger.sendMessage(message);
}
RequestTask 类
RequestTask类是ConcurrentServer类与客户端连接和Executor类执行并发任务之间的中介。它与客户端打开套接字,读取查询数据,创建适当的命令,并将其发送到执行器。
它使用一些内部属性:
-
LinkedBlockingQueue,ConcurrentServer类在其中存储客户端套接字 -
ServerExecutor用于执行命令作为并发任务。 -
使用
ConcurrentHashMap存储与任务相关的Future对象
该类的构造函数初始化了所有这些对象:
public class RequestTask implements Runnable {
private LinkedBlockingQueue<Socket> pendingConnections;
private ServerExecutor executor = new ServerExecutor();
private ConcurrentMap<String, ConcurrentMap<ConcurrentCommand, ServerTask<?>>> taskController;
public RequestTask(LinkedBlockingQueue<Socket> pendingConnections, ConcurrentHashMap<String, ConcurrentHashMap<Integer, Future<?>>> taskController) {
this.pendingConnections = pendingConnections;
this.taskController = taskController;
}
该类的主要方法是run()方法。它执行一个循环,直到线程被中断,处理存储在pendingConnections对象中的套接字。在该对象中,ConcurrentServer类存储了与发送查询到服务器的不同客户端通信的套接字。它打开套接字,读取数据,并创建相应的命令。它还将命令发送到执行器,并将Future对象存储在与任务的hashCode和发送查询的用户相关联的双重ConcurrentHashMap中:
public void run() {
try {
while (!Thread.currentThread().interrupted()) {
try {
Socket clientSocket = pendingConnections.take();
BufferedReader in = new BufferedReader(new InputStreamReader (clientSocket.getInputStream()));
String line = in.readLine();
Logger.sendMessage(line);
ConcurrentCommand command;
ParallelCache cache = ConcurrentServer.getCache();
String ret = cache.get(line);
if (ret == null) {
String[] commandData = line.split(";");
System.out.println("Command: " + commandData[0]);
switch (commandData[0]) {
case "q":
System.out.println("Query");
command = new ConcurrentQueryCommand(clientSocket, commandData);
break;
case "r":
System.out.println("Report");
command = new ConcurrentReportCommand (clientSocket, commandData);
break;
case "s":
System.out.println("Status");
command = new ConcurrentStatusCommand(executor, clientSocket, commandData);
break;
case "z":
System.out.println("Stop");
command = new ConcurrentStopCommand(clientSocket, commandData);
break;
case "c":
System.out.println("Cancel");
command = new ConcurrentCancelCommand (clientSocket, commandData);
break;
default:
System.out.println("Error");
command = new ConcurrentErrorCommand(clientSocket, commandData);
break;
}
ServerTask<?> controller = (ServerTask<?>)executor.submit(command);
storeContoller(command.getUsername(), controller, command);
} else {
PrintWriter out = new PrintWriter (clientSocket.getOutputStream(),true);
out.println(ret);
clientSocket.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
} catch (InterruptedException e) {
// No Action Required
}
}
storeController()方法是将Future对象存储在双重ConcurrentHashMap中的方法:
private void storeContoller(String userName, ServerTask<?> controller, ConcurrentCommand command) {
taskController.computeIfAbsent(userName, k -> new ConcurrentHashMap<>()).put(command, controller);
}
最后,我们包含了两个方法来管理Executor类的执行,一个是调用shutdown()方法来关闭执行器,另一个是等待其完成。请记住,您必须显式调用shutdown()或shutdownNow()方法来结束执行器的执行。否则,程序将无法终止。请看下面的代码:
public void shutdown() {
String message="Request Task: "
+pendingConnections.size()
+" pending connections.";
Logger.sendMessage(message);
executor.shutdown();
}
public void terminate() {
try {
executor.awaitTermination(1,TimeUnit.DAYS);
executor.writeStatistics();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
客户端部分
现在是测试服务器的时候了。在这种情况下,我们不会太担心执行时间。我们测试的主要目标是检查新功能是否正常工作。
我们将客户端部分分为以下两个类:
-
ConcurrentClient 类:这实现了服务器的单个客户端。该类的每个实例都有不同的用户名。它进行了 100 次查询,其中 90 次是查询类型,10 次是报告类型。查询查询的优先级为 5,报告查询的优先级较低(10)。
-
MultipleConcurrentClient 类:这测试了多个并发客户端的行为。我们已经测试了具有一到五个并发客户端的服务器。该类还测试了取消和停止命令。
我们已经包含了一个执行器来执行对服务器的并发请求,以增加客户端的并发级别。
在下图中,您可以看到任务取消的结果:

在这种情况下,USER_2用户的四个任务已被取消。
以下图片显示了关于每个用户的任务数量和执行时间的最终统计数据:

第二个示例 - 执行周期性任务
在之前的执行器示例中,任务只执行一次,并且尽快执行。执行器框架包括其他执行器实现,使我们对任务的执行时间更加灵活。ScheduledThreadPoolExecutor类允许我们周期性执行任务,并在延迟后执行任务。
在本节中,您将学习如何执行周期性任务,实现RSS 订阅阅读器。这是一个简单的情况,您需要定期执行相同的任务(阅读 RSS 订阅的新闻)。我们的示例将具有以下特点:
-
将 RSS 源存储在文件中。我们选择了一些重要报纸的世界新闻,如纽约时报、每日新闻或卫报。
-
我们为每个 RSS 源向执行器发送一个
Runnable对象。每次执行器运行该对象时,它会解析 RSS 源并将其转换为包含 RSS 内容的CommonInformationItem对象列表。 -
我们使用生产者/消费者设计模式将 RSS 新闻写入磁盘。生产者将是执行器的任务,它们将每个
CommonInformationItem写入缓冲区。只有新项目将存储在缓冲区中。消费者将是一个独立的线程,它从缓冲区中读取新闻并将其写入磁盘。 -
任务执行结束和下一次执行之间的时间将是一分钟。
我们还实现了示例的高级版本,其中任务执行之间的时间可以变化。
共同部分
正如我们之前提到的,我们读取一个 RSS 源并将其转换为对象列表。为了解析 RSS 文件,我们将其视为 XML 文件,并在RSSDataCapturer类中实现了一个SAX(简单 XML API)解析器。它解析文件并创建一个CommonInformationItem列表。这个类为每个 RSS 项存储以下信息:
-
标题:RSS 项的标题。
-
日期:RSS 项的日期。
-
链接:RSS 项的链接。
-
描述:RSS 项的文本。
-
ID:RSS 项的 ID。如果该项不包含 ID,我们将计算它。
-
来源:RSS 来源的名称。
我们使用生产者/消费者设计模式将新闻存储到磁盘中,因此我们需要一个缓冲区来存储新闻和一个Consumer类,该类从缓冲区中读取新闻并将其存储到磁盘中。
我们在NewsBuffer类中实现了缓冲区。它有两个内部属性:
-
LinkedBlockingQueue:这是一个带有阻塞操作的并发数据结构。如果我们想从列表中获取一个项目,而它是空的,调用方法的线程将被阻塞,直到列表中有元素为止。我们将使用这个结构来存储
CommonInformationItems。 -
ConcurrentHashMap:这是
HashMap的并发实现。我们将使用它来在缓冲区中存储之前存储的新闻项的 ID。
我们只会将以前未插入的新闻插入到缓冲区中:
public class NewsBuffer {
private LinkedBlockingQueue<CommonInformationItem> buffer;
private ConcurrentHashMap<String, String> storedItems;
public NewsBuffer() {
buffer=new LinkedBlockingQueue<>();
storedItems=new ConcurrentHashMap<String, String>();
}
在NewsBuffer类中有两个方法:一个用于将项目存储在缓冲区中,并检查该项目是否已经插入,另一个用于从缓冲区中获取下一个项目。我们使用compute()方法将元素插入ConcurrentHashMap中。这个方法接收一个 lambda 表达式作为参数,其中包含与该键关联的实际值(如果键没有关联的值,则为 null)。在我们的情况下,如果该项以前没有被处理过,我们将把该项添加到缓冲区中。我们使用add()和take()方法来向队列中插入、获取和删除元素:
public void add (CommonInformationItem item) {
storedItems.compute(item.getId(), (id, oldSource) -> {
if(oldSource == null) {
buffer.add(item);
return item.getSource();
} else {
System.out.println("Item "+item.getId()+" has been processed before");
return oldSource;
}
});
}
public CommonInformationItem get() throws InterruptedException {
return buffer.take();
}
缓冲区的项目将由NewsWriter类写入磁盘,该类将作为一个独立的线程执行。它只有一个内部属性,指向应用程序中使用的NewsBuffer类:
public class NewsWriter implements Runnable {
private NewsBuffer buffer;
public NewsWriter(NewsBuffer buffer) {
this.buffer=buffer;
}
这个Runnable对象的run()方法从缓冲区中获取CommonInformationItem实例并将它们保存到磁盘中。由于我们使用了阻塞方法take,如果缓冲区为空,这个线程将被阻塞,直到缓冲区中有元素为止。
public void run() {
try {
while (!Thread.currentThread().interrupted()) {
CommonInformationItem item=buffer.get();
Path path=Paths.get ("output\\"+item.getFileName());
try (BufferedWriter fileWriter = Files.newBufferedWriter(path, StandardOpenOption.CREATE)) {
fileWriter.write(item.toString());
} catch (IOException e) {
e.printStackTrace();
}
}
} catch (InterruptedException e) {
//Normal execution
}
}
基本读取器
基本读取器将使用标准的ScheduledThreadPoolExecutor类来定期执行任务。我们将为每个 RSS 源执行一个任务,并且在一个任务执行的终止和下一个任务执行的开始之间将有一分钟的时间。这些并发任务在NewsTask类中实现。它有三个内部属性来存储 RSS 源的名称、其 URL 和存储新闻的NewsBuffer类:
public class NewsTask implements Runnable {
private String name;
private String url;
private NewsBuffer buffer;
public NewsTask (String name, String url, NewsBuffer buffer) {
this.name=name;
this.url=url;
this.buffer=buffer;
}
这个Runnable对象的run()方法简单地解析 RSS 源,获取CommonItemInterface实例的列表,并将它们存储在缓冲区中。这个方法将定期执行。在每次执行中,run()方法将从头到尾执行:
@Override
public void run() {
System.out.println(name+": Running. " + new Date());
RSSDataCapturer capturer=new RSSDataCapturer(name);
List<CommonInformationItem> items=capturer.load(url);
for (CommonInformationItem item: items) {
buffer.add(item);
}
}
在这个例子中,我们还实现了另一个线程来实现执行器和任务的初始化以及等待执行的结束。我们将这个类命名为NewsSystem。它有三个内部属性,用于存储带有 RSS 源的文件路径,用于存储新闻的缓冲区,以及用于控制其执行结束的CountDownLatch对象。CountDownLatch类是一种同步机制,允许您使一个线程等待一个事件。我们将在第九章中详细介绍这个类的使用,深入并发数据结构和同步工具。我们有以下代码:
public class NewsSystem implements Runnable {
private String route;
private ScheduledThreadPoolExecutor executor;
private NewsBuffer buffer;
private CountDownLatch latch=new CountDownLatch(1);
public NewsSystem(String route) {
this.route = route;
executor = new ScheduledThreadPoolExecutor (Runtime.getRuntime().availableProcessors());
buffer=new NewsBuffer();
}
在run()方法中,我们读取所有的 RSS 源,为每一个创建一个NewsTask类,并将它们发送到我们的ScheduledThreadPool执行器。我们使用Executors类的newScheduledThreadPool()方法创建了执行器,并使用scheduleAtFixedDelay()方法将任务发送到执行器。我们还启动了NewsWriter实例作为一个线程。run()方法等待有人告诉它结束执行,使用CountDownLatch类的await()方法,并结束NewsWriter任务和ScheduledExecutor的执行。
@Override
public void run() {
Path file = Paths.get(route);
NewsWriter newsWriter=new NewsWriter(buffer);
Thread t=new Thread(newsWriter);
t.start();
try (InputStream in = Files.newInputStream(file);
BufferedReader reader = new BufferedReader(
new InputStreamReader(in))) {
String line = null;
while ((line = reader.readLine()) != null) {
String data[] = line.split(";");
NewsTask task = new NewsTask(data[0], data[1], buffer);
System.out.println("Task "+task.getName());
executor.scheduleWithFixedDelay(task,0, 1, TimeUnit.MINUTES);
}
} catch (Exception e) {
e.printStackTrace();
}
synchronized (this) {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("Shutting down the executor.");
executor.shutdown();
t.interrupt();
System.out.println("The system has finished.");
}
我们还实现了shutdown()方法。这个方法将使用CountDownLatch类的countDown()方法通知NewsSystem类结束执行。这个方法将唤醒run()方法,因此它将关闭运行NewsTask对象的执行器:
public void shutdown() {
latch.countDown();
}
这个例子的最后一个类是实现了例子的main()方法的Main类。它启动了一个NewsSystem实例作为一个线程,等待 10 分钟,然后通知线程完成,从而结束整个系统的执行,如下所示:
public class Main {
public static void main(String[] args) {
// Creates the System an execute it as a Thread
NewsSystem system=new NewsSystem("data\\sources.txt");
Thread t=new Thread(system);
t.start();
// Waits 10 minutes
try {
TimeUnit.MINUTES.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
// Notifies the finalization of the System
(
system.shutdown();
}
当您执行这个例子时,您会看到不同的任务是如何周期性地执行的,以及新闻项目是如何写入磁盘的,如下面的截图所示:

高级读者
基本新闻阅读器是ScheduledThreadPoolExecutor类的一个使用示例,但我们可以更进一步。与ThreadPoolExecutor一样,我们可以实现自己的ScheduledThreadPoolExecutor以获得特定的行为。在我们的例子中,我们希望周期性任务的延迟时间根据一天中的时间而变化。在这一部分,您将学习如何实现这种行为。
第一步是实现一个告诉我们周期性任务两次执行之间延迟的类。我们将这个类命名为Timer类。它只有一个名为getPeriod()的静态方法,它返回一个执行结束和下一个开始之间的毫秒数。这是我们的实现,但您也可以自己制作:
public class Timer {
public static long getPeriod() {
Calendar calendar = Calendar.getInstance();
int hour = calendar.get(Calendar.HOUR_OF_DAY);
if ((hour >= 6) && (hour <= 8)) {
return TimeUnit.MILLISECONDS.convert(1, TimeUnit.MINUTES);
}
if ((hour >= 13) && (hour <= 14)) {
return TimeUnit.MILLISECONDS.convert(1, TimeUnit.MINUTES);
}
if ((hour >= 20) && (hour <= 22)) {
return TimeUnit.MILLISECONDS.convert(1, TimeUnit.MINUTES);
}
return TimeUnit.MILLISECONDS.convert(2, TimeUnit.MINUTES);
}
}
接下来,我们必须实现执行器的内部任务。当您将一个Runnable对象发送到执行器时,从外部来看,您会将这个对象视为并发任务,但执行器会将这个对象转换为另一个任务,即FutureTask类的一个实例,其中包括run()方法来执行任务以及Future接口的方法来管理任务的执行。为了实现这个例子,我们必须实现一个扩展FutureTask类的类,并且,由于我们将在计划执行器中执行这些任务,它必须实现RunnableScheduledFuture接口。这个接口提供了getDelay()方法,返回到下一个任务执行的剩余时间。我们在ExecutorTask类中实现了这些内部任务。它有四个内部属性:
-
ScheduledThreadPoolExecutor类创建的原始RunnableScheduledFuture内部任务 -
将执行任务的计划执行器
-
任务的下一次执行的开始日期
-
RSS 订阅的名称
代码如下:
public class ExecutorTask<V> extends FutureTask<V> implements RunnableScheduledFuture<V> {
private RunnableScheduledFuture<V> task;
private NewsExecutor executor;
private long startDate;
private String name;
public ExecutorTask(Runnable runnable, V result, RunnableScheduledFuture<V> task, NewsExecutor executor) {
super(runnable, result);
this.task = task;
this.executor = executor;
this.name=((NewsTask)runnable).getName();
this.startDate=new Date().getTime();
}
在这个类中,我们重写或实现了不同的方法。首先是getDelay()方法,正如我们之前告诉过你的,它返回给定单位时间内任务下一次执行的剩余时间:
@Override
public long getDelay(TimeUnit unit) {
long delay;
if (!isPeriodic()) {
delay = task.getDelay(unit);
} else {
if (startDate == 0) {
delay = task.getDelay(unit);
} else {
Date now = new Date();
delay = startDate - now.getTime();
delay = unit.convert(delay, TimeUnit.MILLISECONDS);
}
}
return delay;
}
接下来的是compareTo()方法,它比较两个任务,考虑到任务的下一次执行的开始日期:
@Override
public int compareTo(Delayed object) {
return Long.compare(this.getStartDate(), ((ExecutorTask<V>)object).getStartDate());
}
然后,isPeriodic()方法返回true如果任务是周期性的,如果不是则返回false:
@Override
public boolean isPeriodic() {
return task.isPeriodic();
}
最后,我们有run()方法,它实现了这个示例的最重要部分。首先,我们调用FutureTask类的runAndReset()方法。这个方法执行任务并重置它的状态,这样它就可以再次执行。然后,我们使用Timer类计算下一次执行的开始日期,最后,我们必须再次将任务插入ScheduledThreadPoolExecutor类的队列中。如果不执行这最后一步,任务将不会再次执行,如下所示:
@Override
public void run() {
if (isPeriodic() && (!executor.isShutdown())) {
super.runAndReset();
Date now=new Date();
startDate=now.getTime()+Timer.getPeriod();
executor.getQueue().add(this);
System.out.println("Start Date: "+new Date(startDate));
}
}
一旦我们有了执行器的任务,我们就必须实现执行器。我们实现了NewsExecutor类,它扩展了ScheduledThreadPoolExecutor类。我们重写了decorateTask()方法。通过这个方法,你可以替换调度执行器使用的内部任务。默认情况下,它返回RunnableScheduledFuture接口的默认实现,但在我们的情况下,它将返回ExecutorClass实例的一个实例:
public class NewsExecutor extends ScheduledThreadPoolExecutor {
public NewsExecutor(int corePoolSize) {
super(corePoolSize);
}
@Override
protected <V> RunnableScheduledFuture<V> decorateTask(Runnable runnable,
RunnableScheduledFuture<V> task) {
ExecutorTask<V> myTask = new ExecutorTask<>(runnable, null, task, this);
return myTask;
}
}
我们必须实现NewsSystem和Main类的其他版本来使用NewsExecutor。我们为此目的实现了NewsAdvancedSystem和AdvancedMain。
现在你可以运行高级新闻系统,看看执行之间的延迟时间如何改变。
有关执行器的附加信息
在本章中,我们扩展了ThreadPoolExecutor和ScheduledThreadPoolExecutor类,并重写了它们的一些方法。但是,如果需要更特定的行为,你可以重写更多的方法。以下是一些你可以重写的方法:
-
shutdown(): 你必须显式调用这个方法来结束执行器的执行。你可以重写它来添加一些代码,以释放你自己的执行器使用的额外资源。 -
shutdownNow():shutdown()方法和shutdownNow()方法的区别在于,shutdown()方法等待所有等待在执行器中的任务的最终处理。 -
submit(),invokeall(), 或invokeany(): 你可以调用这些方法将并发任务发送到执行器中。如果需要在任务插入执行器的任务队列之前或之后执行一些操作,可以重写它们。请注意,在任务入队之前或之后添加自定义操作与在任务执行之前或之后添加自定义操作是不同的,我们在重写beforeExecute()和afterExecute()方法时已经做过。
在新闻阅读器示例中,我们使用scheduleWithFixedDelay()方法将任务发送到执行器。但是ScheduledThreadPoolExecutor类还有其他方法来执行周期性任务或延迟任务:
-
schedule(): 这个方法在给定的延迟之后执行一次任务。 -
scheduleAtFixedRate(): 这个方法以给定的周期执行周期性任务。与scheduleWithFixedDelay()方法的区别在于,在后者中,两次执行之间的延迟从第一次执行结束到第二次执行开始,而在前者中,两次执行之间的延迟在两次执行的开始之间。
总结
在本章中,我们介绍了两个示例,探讨了执行器的高级特性。在第一个示例中,我们延续了第二章中的客户端/服务器示例,管理大量线程 - 执行器。我们实现了自己的执行器,扩展了ThreadPoolExecutor类,以按优先级执行任务,并测量每个用户任务的执行时间。我们还包括了一个新的命令,允许取消任务。
在第二个示例中,我们解释了如何使用ScheduledThreadPoolExecutor类来执行周期性任务。我们实现了两个版本的新闻阅读器。第一个版本展示了如何使用ScheduledExecutorService的基本功能,第二个版本展示了如何覆盖ScheduledExecutorService类的行为,例如,更改任务两次执行之间的延迟时间。
在下一章中,您将学习如何执行返回结果的Executor任务。如果您扩展Thread类或实现Runnable接口,run()方法不会返回任何结果,但执行器框架包括Callable接口,允许您实现返回结果的任务。
第四章:从任务中获取数据 - Callable 和 Future 接口
在第二章,管理大量线程 - 执行程序,和第三章,从执行程序中获得最大效益,我们介绍了执行程序框架,以提高并发应用程序的性能,并向您展示了如何实现高级特性以使该框架适应您的需求。在这些章节中,执行程序执行的所有任务都基于Runnable接口及其不返回值的run()方法。然而,执行程序框架允许我们执行基于Callable和Future接口的返回结果的其他类型的任务。在本章中,我们将涵盖以下主题:
-
Callable 和 Future 接口介绍
-
第一个例子 - 用于单词的最佳匹配算法
-
第二个例子 - 构建文档集合的倒排索引
介绍 Callable 和 Future 接口
执行程序框架允许程序员在不创建和管理线程的情况下执行并发任务。您创建任务并将它们发送到执行程序。它会创建和管理必要的线程。
在执行程序中,您可以执行两种类型的任务:
-
基于 Runnable 接口的任务:这些任务实现了不返回任何结果的
run()方法。 -
基于 Callable 接口的任务:这些任务实现了
call()接口,返回一个对象作为结果。call()方法返回的具体类型由Callable接口的泛型类型参数指定。为了获取任务返回的结果,执行程序将为每个任务返回一个Future接口的实现。
在之前的章节中,您学习了如何创建执行程序,将基于Runnable接口的任务发送到其中,并个性化执行程序以适应您的需求。在本章中,您将学习如何处理基于Callable和Future接口的任务。
Callable 接口
Callable接口与Runnable接口非常相似。该接口的主要特点是:
-
它是一个泛型接口。它有一个单一类型参数,对应于
call()方法的返回类型。 -
它声明了
call()方法。当执行程序运行任务时,该方法将被执行。它必须返回声明中指定类型的对象。 -
call()方法可以抛出任何已检查异常。您可以通过实现自己的执行程序并覆盖afterExecute()方法来处理异常。
Future 接口
当您将一个Callable任务发送到执行程序时,它将返回一个Future接口的实现,允许您控制任务的执行和状态,并获取结果。该接口的主要特点是:
-
您可以使用
cancel()方法取消任务的执行。该方法有一个boolean参数,用于指定是否要在任务运行时中断任务。 -
您可以通过
isCancelled()方法检查任务是否已被取消,或者通过isDone()方法检查任务是否已完成。 -
您可以使用
get()方法获取任务返回的值。此方法有两个变体。第一个没有参数,并返回任务执行完成后的返回值。如果任务尚未执行完成,它会挂起执行线程,直到任务完成。第二个变体接受两个参数:一段时间和该时间段的TimeUnit。与第一个的主要区别在于线程等待作为参数传递的时间段。如果时间段结束,任务尚未执行完成,该方法会抛出TimeoutException异常。
第一个示例-用于单词的最佳匹配算法
单词的最佳匹配算法的主要目标是找到与作为参数传递的字符串最相似的单词。要实现这些算法之一,您需要以下内容:
-
单词列表:在我们的案例中,我们使用了为填字游戏社区编制的英国高级谜语词典(UKACD)。它有 250,353 个单词和习语。可以从
www.crosswordman.com/wordlist.html免费下载。 -
衡量两个单词相似性的度量标准:我们使用了 Levenshtein 距离,用于衡量两个字符序列之间的差异。Levenshtein 距离是将第一个字符串转换为第二个字符串所需的最小插入、删除或替换次数。您可以在
en.wikipedia.org/wiki/Levenshtein_distance中找到对此度量标准的简要描述。
在我们的示例中,您将实现两个操作:
-
第一个操作使用 Levenshtein 距离返回与字符序列最相似的单词列表。
-
第二个操作使用 Levenshtein 距离确定字符序列是否存在于我们的字典中。如果使用
equals()方法会更快,但我们的版本对于本书的目标来说是一个更有趣的选择。
您将实现这些操作的串行和并发版本,以验证并发在这种情况下是否有帮助。
常见类
在此示例中实现的所有任务中,您将使用以下三个基本类:
-
WordsLoader类将单词列表加载到String对象列表中。 -
LevenshteinDistance类计算两个字符串之间的 Levenshtein 距离。 -
BestMatchingData类存储最佳匹配算法的结果。它存储单词列表以及这些单词与输入字符串的距离。
UKACD 在一个文件中,每行一个单词,因此WordsLoader类实现了load()静态方法,该方法接收包含单词列表的文件的路径,并返回一个包含 250,353 个单词的字符串对象列表。
LevenshteinDistance类实现了calculate()方法,该方法接收两个字符串对象作为参数,并返回这两个单词之间的距离的int值。这是这个分类的代码:
public class LevenshteinDistance {
public static int calculate (String string1, String string2) {
int[][] distances=new int[string1.length()+1][string2.length()+1];
for (int i=1; i<=string1.length();i++) {
distances[i][0]=i;
}
for (int j=1; j<=string2.length(); j++) {
distances[0][j]=j;
}
for(int i=1; i<=string1.length(); i++) {
for (int j=1; j<=string2.length(); j++) {
if (string1.charAt(i-1)==string2.charAt(j-1)) {
distances[i][j]=distances[i-1][j-1];
} else {
distances[i][j]=minimum(distances[i-1][j], distances[i][j-1],distances[i-1][j-1])+1;
}
}
}
return distances[string1.length()][string2.length()];
}
private static int minimum(int i, int j, int k) {
return Math.min(i,Math.min(j, k));
}
}
BestMatchingData类只有两个属性:一个字符串对象列表,用于存储单词列表,以及一个名为距离的整数属性,用于存储这些单词与输入字符串的距离。
最佳匹配算法-串行版本
首先,我们将实现最佳匹配算法的串行版本。我们将使用此版本作为并发版本的起点,然后我们将比较两个版本的执行时间,以验证并发是否有助于提高性能。
我们已经在以下两个类中实现了最佳匹配算法的串行版本:
-
BestMatchingSerialCalculation类计算与输入字符串最相似的单词列表 -
BestMatchingSerialMain包括main()方法,执行算法,测量执行时间,并在控制台中显示结果
让我们分析一下这两个类的源代码。
BestMatchingSerialCalculation类
这个类只有一个名为getBestMatchingWords()的方法,它接收两个参数:一个带有我们作为参考的序列的字符串,以及包含字典中所有单词的字符串对象列表。它返回一个BestMatchingData对象,其中包含算法的结果:
public class BestMatchingSerialCalculation {
public static BestMatchingData getBestMatchingWords(String word, List<String> dictionary) {
List<String> results=new ArrayList<String>();
int minDistance=Integer.MAX_VALUE;
int distance;
在内部变量初始化之后,算法处理字典中的所有单词,计算这些单词与参考字符串之间的 Levenshtein 距离。如果一个单词的计算距离小于实际最小距离,我们清除结果列表并将实际单词存储到列表中。如果一个单词的计算距离等于实际最小距离,我们将该单词添加到结果列表中:
for (String str: dictionary) {
distance=LevenshteinDistance.calculate(word,str);
if (distance<minDistance) {
results.clear();
minDistance=distance;
results.add(str);
} else if (distance==minDistance) {
results.add(str);
}
}
最后,我们创建了BestMatchingData对象来返回算法的结果:
BestMatchingData result=new BestMatchingData();
result.setWords(results);
result.setDistance(minDistance);
return result;
}
}
BestMachingSerialMain类
这是示例的主要类。它加载 UKACD 文件,使用作为参数接收的字符串调用getBestMatchingWords(),并在控制台中显示结果,包括算法的执行时间。
public class BestMatchingSerialMain {
public static void main(String[] args) {
Date startTime, endTime;
List<String> dictionary=WordsLoader.load("data/UK Advanced Cryptics Dictionary.txt");
System.out.println("Dictionary Size: "+dictionary.size());
startTime=new Date();
BestMatchingData result= BestMatchingSerialCalculation.getBestMatchingWords (args[0], dictionary);
List<String> results=result.getWords();
endTime=new Date();
System.out.println("Word: "+args[0]);
System.out.println("Minimum distance: " +result.getDistance());
System.out.println("List of best matching words: " +results.size());
results.forEach(System.out::println);
System.out.println("Execution Time: "+(endTime.getTime()- startTime.getTime()));
}
}
在这里,我们使用了一个名为方法引用的新的 Java 8 语言构造和一个新的List.forEach()方法来输出结果。
最佳匹配算法 - 第一个并发版本
我们实现了两个不同的并发版本的最佳匹配算法。第一个是基于Callable接口和AbstractExecutorService接口中定义的submit()方法。
我们使用了以下三个类来实现算法的这个版本:
-
BestMatchingBasicTask类实现了实现Callable接口的任务,并将在执行器中执行 -
BestMatchingBasicConcurrentCalculation类创建执行器和必要的任务,并将它们发送到执行器 -
BestMatchingConcurrentMain类实现了main()方法,用于执行算法并在控制台中显示结果
让我们来看看这些类的源代码。
BestMatchingBasicTask类
如前所述,这个类将实现将获得最佳匹配单词列表的任务。这个任务将实现参数化为BestMatchingData类的Callable接口。这意味着这个类将实现call()方法,而这个方法将返回一个BestMatchingData对象。
每个任务将处理字典的一部分,并返回该部分获得的结果。我们使用了四个内部属性,如下所示:
-
字典的第一个位置(包括)
-
它将分析的字典的最后位置(不包括)
-
作为字符串对象列表的字典
-
参考输入字符串
这段代码如下:
public class BestMatchingBasicTask implements Callable <BestMatchingData > {
private int startIndex;
private int endIndex;
private List < String > dictionary;
private String word;
public BestMatchingBasicTask(int startIndex, int endIndex, List < String > dictionary, String word) {
this.startIndex = startIndex;
this.endIndex = endIndex;
this.dictionary = dictionary;
this.word = word;
}
call()方法处理startIndex和endIndex属性之间的所有单词,并计算这些单词与输入字符串之间的 Levenshtein 距离。它只会返回距离输入字符串最近的单词。如果在过程中找到比之前更接近的单词,它会清除结果列表并将新单词添加到该列表中。如果找到一个与目前找到的结果距离相同的单词,它会将该单词添加到结果列表中,如下所示:
@Override
public BestMatchingData call() throws Exception {
List<String> results=new ArrayList<String>();
int minDistance=Integer.MAX_VALUE;
int distance;
for (int i=startIndex; i<endIndex; i++) {
distance = LevenshteinDistance.calculate (word,dictionary.get(i));
if (distance<minDistance) {
results.clear();
minDistance=distance;
results.add(dictionary.get(i));
} else if (distance==minDistance) {
results.add(dictionary.get(i));
}
}
最后,我们创建了一个BestMatchingData对象,其中包含我们找到的单词列表及其与输入字符串的距离,并返回该对象。
BestMatchingData result=new BestMatchingData();
result.setWords(results);
result.setDistance(minDistance);
return result;
}
}
基于Runnable接口的任务与run()方法中包含的返回语句的主要区别。run()方法不返回值,因此这些任务无法返回结果。另一方面,call()方法返回一个对象(该对象的类在实现语句中定义),因此这种类型的任务可以返回结果。
BestMatchingBasicConcurrentCalculation 类
这个类负责创建处理完整字典所需的任务,执行器来执行这些任务,并控制执行器中任务的执行。
它只有一个方法getBestMatchingWords(),接收两个输入参数:完整单词列表的字典和参考字符串。它返回一个包含算法结果的BestMatchingData对象。首先,我们创建并初始化了执行器。我们使用机器的核心数作为我们想要在其上使用的最大线程数。
public class BestMatchingBasicConcurrentCalculation {
public static BestMatchingData getBestMatchingWords(String word, List<String> dictionary) throws InterruptedException, ExecutionException {
int numCores = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(numCores);
然后,我们计算每个任务将处理的字典部分的大小,并创建一个Future对象的列表来存储任务的结果。当您将基于Callable接口的任务发送到执行器时,您将获得Future接口的实现。您可以使用该对象来:
-
知道任务是否已执行
-
获取任务执行的结果(
call()方法返回的对象) -
取消任务的执行
代码如下:
int size = dictionary.size();
int step = size / numCores;
int startIndex, endIndex;
List<Future<BestMatchingData>> results = new ArrayList<>();
然后,我们创建任务,使用submit()方法将它们发送到执行器,并将该方法返回的Future对象添加到Future对象的列表中。submit()方法立即返回。它不会等待任务执行。我们有以下代码:
for (int i = 0; i < numCores; i++) {
startIndex = i * step;
if (i == numCores - 1) {
endIndex = dictionary.size();
} else {
endIndex = (i + 1) * step;
}
BestMatchingBasicTask task = new BestMatchingBasicTask(startIndex, endIndex, dictionary, word);
Future<BestMatchingData> future = executor.submit(task);
results.add(future);
}
一旦我们将任务发送到执行器,我们调用执行器的shutdown()方法来结束其执行,并迭代Future对象的列表以获取每个任务的结果。我们使用不带任何参数的get()方法。如果任务已经完成执行,该方法将返回call()方法返回的对象。如果任务尚未完成,该方法将使当前线程休眠,直到任务完成并且结果可用。
我们用任务的结果组成一个结果列表,因此我们将只返回与参考字符串最接近的单词列表如下:
executor.shutdown();
List<String> words=new ArrayList<String>();
int minDistance=Integer.MAX_VALUE;
for (Future<BestMatchingData> future: results) {
BestMatchingData data=future.get();
if (data.getDistance()<minDistance) {
words.clear();
minDistance=data.getDistance();
words.addAll(data.getWords());
} else if (data.getDistance()==minDistance) {
words.addAll(data.getWords());
}
}
最后,我们创建并返回一个BestMatchingData对象,其中包含算法的结果:
BestMatchingData result=new BestMatchingData();
result.setDistance(minDistance);
result.setWords(words);
return result;
}
}
注意
BestMatchingConcurrentMain类与之前介绍的BestMatchingSerialMain非常相似。唯一的区别是使用的类(BestMatchingBasicConcurrentCalculation而不是BestMatchingSerialCalculation),因此我们不在这里包含源代码。请注意,我们既没有使用线程安全的数据结构,也没有同步,因为我们的并发任务在独立的数据片段上工作,并且在并发任务终止后,最终结果是以顺序方式合并的。
最佳匹配算法 - 第二个并发版本
我们使用AbstractExecutorService的invokeAll()方法(在ThreadPoolExecutorClass中实现)实现了最佳匹配算法的第二个版本。在之前的版本中,我们使用了接收Callable对象并返回Future对象的submit()方法。invokeAll()方法接收Callable对象的List作为参数,并返回Future对象的List。第一个Future与第一个Callable相关联,依此类推。这两种方法之间还有另一个重要的区别。虽然submit()方法立即返回,但invokeAll()方法在所有Callable任务结束执行时返回。这意味着所有返回的Future对象在调用它们的isDone()方法时都将返回true。
为了实现这个版本,我们使用了前面示例中实现的BestMatchingBasicTask类,并实现了BestMatchingAdvancedConcurrentCalculation类。与BestMatchingBasicConcurrentCalculation类的区别在于任务的创建和结果的处理。在任务的创建中,现在我们创建一个列表并将其存储在我们要执行的任务上:
for (int i = 0; i < numCores; i++) {
startIndex = i * step;
if (i == numCores - 1) {
endIndex = dictionary.size();
} else {
endIndex = (i + 1) * step;
}
BestMatchingBasicTask task = new BestMatchingBasicTask(startIndex, endIndex, dictionary, word);
tasks.add(task);
}
为了处理结果,我们调用invokeAll()方法,然后遍历返回的Future对象列表:
results = executor.invokeAll(tasks);
executor.shutdown();
List<String> words = new ArrayList<String>();
int minDistance = Integer.MAX_VALUE;
for (Future<BestMatchingData> future : results) {
BestMatchingData data = future.get();
if (data.getDistance() < minDistance) {
words.clear();
minDistance = data.getDistance();
words.addAll(data.getWords());
} else if (data.getDistance()== minDistance) {
words.addAll(data.getWords());
}
}
BestMatchingData result = new BestMatchingData();
result.setDistance(minDistance);
result.setWords(words);
return result;
}
为了执行这个版本,我们实现了BestMatchingConcurrentAdvancedMain。它的源代码与之前的类非常相似,因此不包括在内。
单词存在算法-串行版本
作为这个示例的一部分,我们实现了另一个操作,用于检查一个字符串是否存在于我们的单词列表中。为了检查单词是否存在,我们再次使用 Levenshtein 距离。如果一个单词与列表中的一个单词的距离为0,我们认为这个单词存在。如果我们使用equals()或equalsIgnoreCase()方法进行比较,或者将输入单词读入HashSet并使用contains()方法进行比较(比我们的版本更有效),会更快,但我们认为我们的版本对于本书的目的更有用。
与之前的示例一样,首先我们实现了操作的串行版本,以便将其作为实现并发版本的基础,并比较两个版本的执行时间。
为了实现串行版本,我们使用了两个类:
-
ExistSerialCalculation类实现了existWord()方法,将输入字符串与字典中的所有单词进行比较,直到找到它 -
ExistSerialMain类,启动示例并测量执行时间
让我们分析这两个类的源代码。
ExistSerialCalculation类
这个类只有一个方法,即existWord()方法。它接收两个参数:我们要查找的单词和完整的单词列表。它遍历整个列表,计算输入单词与列表中的单词之间的 Levenshtein 距离,直到找到单词(距离为0)为止,此时返回true值,或者在没有找到单词的情况下完成单词列表,此时返回false值。
public class ExistSerialCalculation {
public static boolean existWord(String word, List<String> dictionary) {
for (String str: dictionary) {
if (LevenshteinDistance.calculate(word, str) == 0) {
return true;
}
}
return false;
}
}
ExistSerialMain类
这个类实现了main()方法来调用exist()方法。它将主方法的第一个参数作为我们要查找的单词,并调用该方法。它测量其执行时间并在控制台中显示结果。我们有以下代码:
public class ExistSerialMain {
public static void main(String[] args) {
Date startTime, endTime;
List<String> dictionary=WordsLoader.load("data/UK Advanced Cryptics Dictionary.txt");
System.out.println("Dictionary Size: "+dictionary.size());
startTime=new Date();
boolean result=ExistSerialCalculation.existWord(args[0], dictionary);
endTime=new Date();
System.out.println("Word: "+args[0]);
System.out.println("Exists: "+result);
System.out.println("Execution Time: "+(endTime.getTime()- startTime.getTime()));
}
}
单词存在算法-并发版本
要实现这个操作的并发版本,我们必须考虑它最重要的特点。我们不需要处理整个单词列表。当我们找到单词时,我们可以结束列表的处理并返回结果。这种不处理整个输入数据并在满足某些条件时停止的操作称为短路操作。
AbstractExecutorService接口定义了一个操作(在ThreadPoolExecutor类中实现),与这个想法完美契合。它是invokeAny()方法。这个方法将Callable任务列表发送到执行器,并返回第一个完成执行而不抛出异常的任务的结果。如果所有任务都抛出异常,这个方法会抛出ExecutionException异常。
与之前的示例一样,我们实现了不同的类来实现这个算法的版本:
-
ExistBasicTask类实现了我们将在执行器中执行的任务 -
ExistBasicConcurrentCalculation类创建执行器和任务,并将任务发送到执行器。 -
ExistBasicConcurrentMain类执行示例并测量其运行时间
ExistBasicTasks 类
这个类实现了将要搜索这个单词的任务。它实现了参数化为Boolean类的Callable接口。如果任务找到单词,call()方法将返回true值。它使用四个内部属性:
-
完整的单词列表
-
列表中任务将处理的第一个单词(包括)
-
任务将处理的列表中的最后一个单词(不包括)
-
任务将要查找的单词
我们有以下代码:
public class ExistBasicTask implements Callable<Boolean> {
private int startIndex;
private int endIndex;
private List<String> dictionary;
private String word;
public ExistBasicTask(int startIndex, int endIndex, List<String> dictionary, String word) {
this.startIndex=startIndex;
this.endIndex=endIndex;
this.dictionary=dictionary;
this.word=word;
}
call方法将遍历分配给该任务的列表部分。它计算输入单词与列表中单词之间的 Levenshtein 距离。如果找到单词,它将返回true值。
如果任务处理了所有的单词但没有找到这个单词,它将抛出一个异常以适应invokeAny()方法的行为。如果任务在这种情况下返回false值,invokeAny()方法将立即返回false值,而不会等待其他任务。也许另一个任务会找到这个单词。
我们有以下代码:
@Override
public Boolean call() throws Exception {
for (int i=startIndex; i<endIndex; i++) {
if (LevenshteinDistance.calculate(word, dictionary.get(i))==0) {
return true;
}
}
if (Thread.interrupted()) {
return false;
}
throw new NoSuchElementException("The word "+word+" doesn't exists.");
}
ExistBasicConcurrentCalculation 类
这个类将在完整的单词列表中执行输入单词的搜索,创建并执行必要的任务。它只实现了一个名为existWord()的方法。它接收两个参数,输入字符串和完整的单词列表,并返回一个布尔值,指示单词是否存在。
首先,我们创建执行任务的执行器。我们使用Executor类,并创建一个ThreadPoolExecutor类,最大线程数由机器的可用硬件线程数确定,如下所示:
public class ExistBasicConcurrentCalculation {
public static boolean existWord(String word, List<String> dictionary) throws InterruptedException, ExecutionException{
int numCores = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(numCores);
然后,我们创建与执行器中运行的线程数相同数量的任务。每个任务将处理单词列表的一个相等部分。我们创建任务并将它们存储在一个列表中:
int size = dictionary.size();
int step = size / numCores;
int startIndex, endIndex;
List<ExistBasicTask> tasks = new ArrayList<>();
for (int i = 0; i < numCores; i++) {
startIndex = i * step;
if (i == numCores - 1) {
endIndex = dictionary.size();
} else {
endIndex = (i + 1) * step;
}
ExistBasicTask task = new ExistBasicTask(startIndex, endIndex, dictionary,
word);
tasks.add(task);
}
然后,我们使用invokeAny()方法在执行器中执行任务。如果方法返回布尔值,则单词存在。我们返回该值。如果方法抛出异常,则单词不存在。我们在控制台打印异常并返回false值。在这两种情况下,我们调用执行器的shutdown()方法来终止其执行,如下所示:
try {
Boolean result=executor.invokeAny(tasks);
return result;
} catch (ExecutionException e) {
if (e.getCause() instanceof NoSuchElementException)
return false;
throw e;
} finally {
executor.shutdown();
}
}
}
ExistBasicConcurrentMain 类
这个类实现了这个示例的main()方法。它与ExistSerialMain类相同,唯一的区别是它使用ExistBasicConcurrentCalculation类而不是ExistSerialCalculation,因此它的源代码没有包含。
比较解决方案
让我们比较我们在本节中实现的两个操作的不同解决方案(串行和并发)。为了测试算法,我们使用了 JMH 框架(openjdk.java.net/projects/code-tools/jmh/),它允许您在 Java 中实现微基准测试。使用基准测试框架比简单地使用currentTimeMillis()或nanoTime()方法来测量时间更好。我们在一个四核处理器的计算机上执行了 10 次,并计算了这 10 次的中等执行时间。让我们分析执行结果。
最佳匹配算法
在这种情况下,我们实现了算法的三个版本:
-
串行版本
-
并发版本,一次发送一个任务
-
并发版本,使用
invokeAll()方法
为了测试算法,我们使用了三个不在单词列表中的不同字符串:
-
Stitter -
Abicus -
Lonx
这些是最佳匹配算法对每个单词返回的单词:
-
Stitter:sitter、skitter、slitter、spitter、stilter、stinter、stotter、stutter和titter -
Abicus:abacus和amicus -
Lonx:lanx、lone、long、lox和lynx
下表讨论了中等执行时间及其毫秒标准偏差:
| 算法 | Stitter | Abicus | lonx |
|---|---|---|---|
| 串行 | 467.01 ± 23.40 | 408.03 ± 14.66 | 317.60 ± 28.78 |
并发:submit()方法 |
209.72 ± 74.79 | 184.10 ± 90.47 | 155.61 ± 65.43 |
并发:invokeAll()方法 |
217.66 ± 65.46 | 188.28 ± 81.28 | 160.43 ± 65.14 |
我们可以得出以下结论:
-
算法的并发版本比串行版本获得更好的性能。
-
算法的并发版本之间获得了类似的结果。所有并发版本的标准偏差值都非常高。我们可以使用单词
lonx的加速度比来比较并发版本方法和串行版本,以了解并发如何提高算法的性能:![最佳匹配算法]()
存在的算法
在这种情况下,我们实现了两个版本的算法:
-
串行版本
-
使用
invokeAny()方法的并发版本
为了测试算法,我们使用了一些字符串:
-
在单词列表中不存在的单词
xyzt -
在单词列表的末尾附近存在的单词
stutter -
在单词列表的开始附近存在的单词
abacus -
在单词列表的后半部分之后存在的单词
lynx
毫秒中的中等执行时间和它们的标准偏差显示在下表中:
| 算法 | 单词 | 执行时间(毫秒) |
|---|---|---|
| 串行 | abacus |
50.70 ± 13.95 |
lynx |
194.41 ± 26.02 | |
stutter |
398.11 ± 23.4 | |
xyzt |
315.62 ± 28.7 | |
| 并发 | abacus |
50.72 ± 7.17 |
lynx |
69.15 ± 62.5 | |
stutter |
126.74 ± 104.52 | |
xyzt |
203.37 ± 76.67 |
我们可以得出以下结论:
-
一般来说,并发版本的算法比串行版本提供更好的性能。
-
单词在列表中的位置是一个关键因素。对于单词
abacus,它出现在列表的开头,两种算法给出了类似的执行时间,但对于单词stutter,差异非常大。 -
并发情况下的标准偏差非常大。
如果我们使用加速度比较并发版本和串行版本的单词lynx,结果是:

第二个例子 - 为文档集合创建倒排索引
在信息检索领域,倒排索引是一种常用的数据结构,用于加速对文档集合中文本的搜索。它存储文档集合的所有单词以及包含该单词的文档列表。
要构建索引,我们必须解析集合中的所有文档,并以增量方式构建索引。对于每个文档,我们提取该文档的重要单词(删除最常见的单词,也称为停用词,可能应用词干算法),然后将这些单词添加到索引中。如果单词存在于索引中,我们将文档添加到与该单词关联的文档列表中。如果单词不存在,则将单词添加到索引的单词列表中,并将文档与该单词关联。您可以添加参数到关联中,如单词在文档中的词频,这将为您提供更多信息。
当您在文档集合中搜索一个单词或一组单词时,您使用倒排索引来获取与每个单词关联的文档列表,并创建一个包含搜索结果的唯一列表。
在本节中,您将学习如何使用 Java 并发工具来为文档集合构建倒排索引文件。作为文档集合,我们已经获取了包含有关电影信息的维基百科页面,以构建一组 100,673 个文档。我们已经将每个维基百科页面转换为文本文件。您可以下载包含有关该书的所有信息的文档集合。
为了构建倒排索引,我们不删除任何单词,也不使用任何词干算法。我们希望尽可能简单地保持算法,以便将注意力集中在并发工具上。
这里解释的相同原则可以用于获取关于文档集合的其他信息,例如,每个文档的向量表示,可以作为聚类算法的输入,正如您将在第六章中学到的,优化分治解决方案 - 分叉/加入框架。
与其他示例一样,您将实现这些操作的串行和并发版本,以验证并发在这种情况下是否有帮助。
通用类
串行和并发版本都共同使用类将文档集合加载到 Java 对象中。我们使用了以下两个类:
-
存储在文档中的单词列表的
Document类 -
DocumentParse类将存储在文件中的文档转换为文档对象
让我们分析这两个类的源代码。
Document 类
Document类非常简单。它只有两个属性和用于获取和设置这些属性值的方法。这些属性是:
-
文件名,作为字符串。
-
词汇表(即文档中使用的单词列表)作为
HashMap。键是单词,值是单词在文档中出现的次数。
DocumentParser 类
正如我们之前提到的,这个类将存储在文件中的文档转换为Document对象。它将这个单词分成三个方法。第一个是parse()方法,它接收文件路径作为参数,并返回该文档的词汇HashMap。这个方法逐行读取文件,并使用parseLine()方法将每一行转换为一个单词列表,并将它们添加到词汇中,如下所示:
public class DocumentParser {
public Map<String, Integer> parse(String route) {
Map<String, Integer> ret=new HashMap<String,Integer>();
Path file=Paths.get(route);
try ( BufferedReader reader = Files.newBufferedReader(file)) {
String line = null;
while ((line = reader.readLine()) != null) {
parseLine(line,ret);
}
} catch (IOException x) {
x.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
return ret;
}
parseLine()方法处理提取其单词的行。我们认为一个单词是一个字母序列,以便继续这个例子的简单性。我们已经使用了Pattern类来提取单词,使用Normalizer类将单词转换为小写并删除元音的重音,如下所示:
private static final Pattern PATTERN = Pattern.compile("\\P{IsAlphabetic}+");
private void parseLine(String line, Map<String, Integer> ret) {
for(String word: PATTERN.split(line)) {
if(!word.isEmpty())
ret.merge(Normalizer.normalize(word, Normalizer.Form.NFKD).toLowerCase(), 1, (a, b) -> a+b);
}
}
串行版本
这个示例的串行版本是在SerialIndexing类中实现的。这个类有一个main()方法,它读取所有文档,获取其词汇,并以增量方式构建倒排索引。
首先,我们初始化必要的变量。文档集合存储在数据目录中,因此我们将所有文档存储在File对象的数组中。我们还初始化了invertedIndex对象。我们使用HashMap,其中键是单词,值是包含该单词的文件名的字符串对象列表,如下所示:
public class SerialIndexing {
public static void main(String[] args) {
Date start, end;
File source = new File("data");
File[] files = source.listFiles();
Map<String, List<String>> invertedIndex=new HashMap<String,List<String>> ();
然后,我们使用DocumentParse类解析所有文档,并使用updateInvertedIndex()方法将从每个文档获得的词汇添加到倒排索引中。我们测量整个过程的执行时间。我们有以下代码:
start=new Date();
for (File file : files) {
DocumentParser parser = new DocumentParser();
if (file.getName().endsWith(".txt")) {
Map<String, Integer> voc = parser.parse (file.getAbsolutePath());
updateInvertedIndex(voc,invertedIndex, file.getName());
}
}
end=new Date();
最后,我们在控制台上显示执行结果:
System.out.println("Execution Time: "+(end.getTime()- start.getTime()));
System.out.println("invertedIndex: "+invertedIndex.size());
}
updateInvertedIndex()方法将文档的词汇添加到倒排索引结构中。它处理构成词汇的所有单词。如果单词存在于倒排索引中,我们将文档的名称添加到与该单词关联的文档列表中。如果单词不存在,我们将单词添加并将文档与该单词关联,如下所示:
private static void updateInvertedIndex(Map<String, Integer> voc, Map<String, List<String>> invertedIndex, String fileName) {
for (String word : voc.keySet()) {
if (word.length() >= 3) {
invertedIndex.computeIfAbsent(word, k -> new ArrayList<>()).add(fileName);
}
}
}
第一个并发版本 - 每个文档一个任务
现在是时候实现文本索引算法的并发版本了。显然,我们可以并行处理每个文档的过程。这包括从文件中读取文档并处理每一行以获取文档的词汇表。任务可以将该词汇表作为它们的结果返回,因此我们可以基于Callable接口实现任务。
在前面的例子中,我们使用了三种方法将Callable任务发送到执行程序:
-
提交()
-
调用所有()
-
调用任意()
我们必须处理所有文档,因此我们必须放弃invokeAny()方法。另外两种方法都不方便。如果我们使用submit()方法,我们必须决定何时处理任务的结果。如果我们为每个文档发送一个任务,我们可以处理结果:
-
在发送每个任务之后,这是不可行的
-
在所有任务完成后,我们必须存储大量的
Future对象 -
在发送一组任务后,我们必须包含代码来同步这两个操作。
所有这些方法都有一个问题:我们以顺序方式处理任务的结果。如果我们使用invokeAll()方法,我们就处于类似于第 2 点的情况。我们必须等待所有任务完成。
一个可能的选择是创建其他任务来处理与每个任务相关的Future对象,而 Java 并发 API 为我们提供了一种优雅的解决方案,即使用CompletionService接口及其实现,即ExecutorCompletionService类。
CompletionService对象是一个具有执行程序的机制,它允许您解耦任务的生产和对这些任务结果的消费。您可以使用submit()方法将任务发送到执行程序,并在任务完成时使用poll()或take()方法获取任务的结果。因此,对于我们的解决方案,我们将实现以下元素:
-
一个
CompletionService对象来执行任务。 -
每个文档一个任务,解析文档并生成其词汇表。这个任务将由
CompletionService对象执行。这些任务在IndexingTask类中实现。 -
两个线程来处理任务的结果并构建倒排索引。这些线程在
InvertedIndexTask类中实现。 -
一个
main()方法来创建和执行所有元素。这个main()方法是在ConcurrentIndexingMain类中实现的。
让我们分析这些类的源代码。
IndexingTask 类
这个类实现了解析文档以获取其词汇表的任务。它实现了参数化为Document类的Callable接口。它有一个内部属性来存储代表它必须解析的文档的File对象。看一下下面的代码:
public class IndexingTask implements Callable<Document> {
private File file;
public IndexingTask(File file) {
this.file=file;
}
在call()方法中,它简单地使用DocumentParser类的parse()方法来解析文档并获取词汇表,并创建并返回包含获取的数据的Document对象:
@Override
public Document call() throws Exception {
DocumentParser parser = new DocumentParser();
Map<String, Integer> voc = parser.parse(file.getAbsolutePath());
Document document=new Document();
document.setFileName(file.getName());
document.setVoc(voc);
return document;
}
}
InvertedIndexTask 类
这个类实现了获取IndexingTask对象生成的Document对象并构建倒排索引的任务。这些任务将作为Thread对象执行(在这种情况下我们不使用执行程序),因此它们基于Runnable接口。
InvertedIndexTask类使用三个内部属性:
-
一个参数化为
Document类的CompletionService对象,以访问IndexingTask对象返回的对象。 -
一个
ConcurrentHashMap来存储倒排索引。键是单词,值是ConcurrentLinkedDeque,其中包含文件的名称。在这种情况下,我们必须使用并发数据结构,而串行版本中使用的数据结构没有同步。 -
一个布尔值来指示任务可以完成其工作。
其代码如下:
public class InvertedIndexTask implements Runnable {
private CompletionService<Document> completionService;
private ConcurrentHashMap<String, ConcurrentLinkedDeque<String>> invertedIndex;
public InvertedIndexTask(CompletionService<Document> completionService,
ConcurrentHashMap<String, ConcurrentLinkedDeque<String>> invertedIndex) {
this.completionService = completionService;
this.invertedIndex = invertedIndex;
}
run()方法使用CompletionService的take()方法获取与任务关联的Future对象。我们实现一个循环,直到线程被中断为止。一旦线程被中断,它将使用take()方法再次处理所有未决的Future对象。我们使用take()方法返回的对象更新倒排索引,使用updateInvertedIndex()方法。我们有以下方法:
public void run() {
try {
while (!Thread.interrupted()) {
try {
Document document = completionService.take().get();
updateInvertedIndex(document.getVoc(), invertedIndex, document.getFileName());
} catch (InterruptedException e) {
break;
}
}
while (true) {
Future<Document> future = completionService.poll();
if (future == null)
break;
Document document = future.get();
updateInvertedIndex(document.getVoc(), invertedIndex, document.getFileName());
}
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
最后,updateInvertedIndex方法接收从文档中获取的词汇表、倒排索引和已处理文件的名称作为参数。它处理词汇表中的所有单词。如果单词不存在,我们使用computeIfAbsent()方法将单词添加到invertedIndex中:
private void updateInvertedIndex(Map<String, Integer> voc, ConcurrentHashMap<String, ConcurrentLinkedDeque<String>> invertedIndex, String fileName) {
for (String word : voc.keySet()) {
if (word.length() >= 3) {
invertedIndex.computeIfAbsent(word, k -> new ConcurrentLinkedDeque<>()).add(fileName);
}
}
}
并发索引类
这是示例中的主要类。它创建和启动所有组件,等待其完成,并在控制台中打印最终执行时间。
首先,它创建并初始化了所有需要执行的变量:
-
一个执行器来运行
InvertedTask任务。与之前的示例一样,我们使用机器的核心数作为执行器中工作线程的最大数量,但在这种情况下,我们留出一个核心来执行独立线程。 -
一个
CompletionService对象来运行任务。我们使用之前创建的执行程序来初始化这个对象。 -
一个
ConcurrentHashMap来存储倒排索引。 -
一个
File对象数组,其中包含我们需要处理的所有文档。
我们有以下方法:
public class ConcurrentIndexing {
public static void main(String[] args) {
int numCores=Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor executor=(ThreadPoolExecutor) Executors.newFixedThreadPool(Math.max(numCores-1, 1));
ExecutorCompletionService<Document> completionService=new ExecutorCompletionService<>(executor);
ConcurrentHashMap<String, ConcurrentLinkedDeque<String>> invertedIndex=new ConcurrentHashMap <String,ConcurrentLinkedDeque<String>> ();
Date start, end;
File source = new File("data");
File[] files = source.listFiles();
然后,我们处理数组中的所有文件。对于每个文件,我们创建一个InvertedTask对象,并使用submit()方法将其发送到CompletionService类:
start=new Date();
for (File file : files) {
IndexingTask task=new IndexingTask(file);
completionService.submit(task);
}
然后,我们创建两个InvertedIndexTask对象来处理InvertedTask任务返回的结果,并将它们作为普通的Thread对象执行:
InvertedIndexTask invertedIndexTask=new InvertedIndexTask(completionService,invertedIndex);
Thread thread1=new Thread(invertedIndexTask);
thread1.start();
InvertedIndexTask invertedIndexTask2=new InvertedIndexTask(completionService,invertedIndex);
Thread thread2=new Thread(invertedIndexTask2);
thread2.start();
一旦我们启动了所有元素,我们等待执行器的完成,使用shutdown()和awaitTermination()方法。awaitTermination()方法将在所有InvertedTask任务完成执行时返回,因此我们可以完成执行InvertedIndexTask任务的线程。为此,我们中断这些线程(参见我关于InvertedIndexTask的评论)。
executor.shutdown();
try {
executor.awaitTermination(1, TimeUnit.DAYS);
thread1.interrupt();
thread2.interrupt();
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
最后,我们在控制台中写入倒排索引的大小和整个过程的执行时间:
end=new Date();
System.out.println("Execution Time: "+(end.getTime()- start.getTime()));
System.out.println("invertedIndex: "+invertedIndex.size());
}
}
第二个并发版本 - 每个任务处理多个文档
我们实现了这个示例的第二个并发版本。基本原则与第一个版本相同,但在这种情况下,每个任务将处理多个文档而不是只有一个。每个任务处理的文档数量将是主方法的输入参数。我们已经测试了每个任务处理 100、1,000 和 5,000 个文档的结果。
为了实现这种新方法,我们将实现三个新类:
-
MultipleIndexingTask类,相当于IndexingTask类,但它将处理一个文档列表,而不是只有一个 -
MultipleInvertedIndexTask类,相当于InvertedIndexTask类,但现在任务将检索一个Document对象的列表,而不是只有一个 -
MultipleConcurrentIndexing类,相当于ConcurrentIndexing类,但使用新的类
由于大部分源代码与之前的版本相似,我们只展示不同之处。
多重索引任务类
正如我们之前提到的,这个类与之前介绍的IndexingTask类相似。主要区别在于它使用一个File对象的列表,而不是只有一个文件:
public class MultipleIndexingTask implements Callable<List<Document>> {
private List<File> files;
public MultipleIndexingTask(List<File> files) {
this.files = files;
}
call()方法返回一个Document对象的列表,而不是只有一个:
@Override
public List<Document> call() throws Exception {
List<Document> documents = new ArrayList<Document>();
for (File file : files) {
DocumentParser parser = new DocumentParser();
Hashtable<String, Integer> voc = parser.parse (file.getAbsolutePath());
Document document = new Document();
document.setFileName(file.getName());
document.setVoc(voc);
documents.add(document);
}
return documents;
}
}
多重倒排索引任务类
正如我们之前提到的,这个类与之前介绍的InvertedIndexClass类相似。主要区别在于run()方法。poll()方法返回的Future对象返回一个Document对象列表,因此我们必须处理整个列表。
@Override
public void run() {
try {
while (!Thread.interrupted()) {
try {
List<Document> documents = completionService.take().get();
for (Document document : documents) {
updateInvertedIndex(document.getVoc(), invertedIndex, document.getFileName());
}
} catch (InterruptedException e) {
break;
}
}
while (true) {
Future<List<Document>> future = completionService.poll();
if (future == null)
break;
List<Document> documents = future.get();
for (Document document : documents) {
updateInvertedIndex(document.getVoc(), invertedIndex, document.getFileName());
}
}
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
MultipleConcurrentIndexing 类
正如我们之前提到的,这个类与ConcurrentIndexing类相似。唯一的区别在于利用新类和使用第一个参数来确定每个任务处理的文档数量。我们有以下方法:
start=new Date();
List<File> taskFiles=new ArrayList<>();
for (File file : files) {
taskFiles.add(file);
if (taskFiles.size()==NUMBER_OF_TASKS) {
MultipleIndexingTask task=new MultipleIndexingTask(taskFiles);
completionService.submit(task);
taskFiles=new ArrayList<>();
}
}
if (taskFiles.size()>0) {
MultipleIndexingTask task=new MultipleIndexingTask(taskFiles);
completionService.submit(task);
}
MultipleInvertedIndexTask invertedIndexTask=new MultipleInvertedIndexTask (completionService,invertedIndex);
Thread thread1=new Thread(invertedIndexTask);
thread1.start();
MultipleInvertedIndexTask invertedIndexTask2=new MultipleInvertedIndexTask (completionService,invertedIndex);
Thread thread2=new Thread(invertedIndexTask2);
thread2.start();
比较解决方案
让我们比较一下我们实现的三个版本的解决方案。正如我们之前提到的,就像文档集合一样,我们已经获取了包含有关电影信息的维基百科页面,构建了一组 100,673 个文档。我们已经将每个维基百科页面转换成了一个文本文件。您可以下载包含有关该书的所有信息的文档集合。
我们执行了五个不同版本的解决方案:
-
串行版本
-
每个文档一个任务的并发版本
-
具有多个任务的并发版本,每个文档 100、1,000 和 5,000 个文档
以下表格显示了五个版本的执行时间:
| 算法 | 执行时间(毫秒) |
|---|---|
| 串行 | 69,480.50 |
| 并发:每个任务一个文档 | 49,655.49 |
| 并发:每个任务 100 个文档 | 48,438.14 |
| 并发:每个任务 1,000 个文档 | 49,362.37 |
| 并发:每个任务 5,000 个文档 | 58,362.22 |
我们可以得出以下结论:
-
并发版本总是比串行版本获得更好的性能
-
对于并发版本,如果我们增加每个任务的文档数量,结果会变得更糟。
如果我们使用加速比将并发版本与串行版本进行比较,结果如下:

其他感兴趣的方法
在本章中,我们使用了AbstractExecutorService接口(在ThreadPoolExecutor类中实现)和CompletionService接口(在ExecutorCompletionService中实现)的一些方法来管理Callable任务的结果。但是,我们还有其他版本的方法和其他要在这里提到的方法。
关于AbstractExecutorService接口,让我们讨论以下方法:
-
invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit):此方法在所有任务完成其执行或第二个和第三个参数指定的超时到期时,返回与作为参数传递的Callable任务列表相关联的Future对象列表。 -
invokeAny(Collection<? Extends Callable<T>> tasks, long timeout, TimeUnit unit):此方法返回作为参数传递的Callable任务列表中第一个任务的结果,如果它在第二个和第三个参数指定的超时之前完成执行而不抛出异常,则超时后抛出TimeoutException异常。
关于CompletionService接口,让我们讨论以下方法:
-
poll()方法:我们使用了带有两个参数的此方法的版本,但也有一个不带参数的版本。从内部数据结构来看,此版本检索并删除自上次调用poll()或take()方法以来已完成的下一个任务的Future对象。如果没有任务完成,其执行返回null值。 -
“take()”方法:此方法类似于上一个方法,但如果没有任务完成,它会使线程休眠,直到一个任务完成其执行。
总结
在本章中,您学习了可以用来处理返回结果的任务的不同机制。这些任务基于Callable接口,该接口声明了call()方法。这是一个由call方法返回的类的参数化接口。
当您在执行器中执行Callable任务时,您将始终获得Future接口的实现。您可以使用此对象来取消任务的执行,了解任务是否已完成其执行或获取“call()”方法返回的结果。
您可以使用三种不同的方法将Callable任务发送到执行器。使用“submit()”方法,您发送一个任务,并且将立即获得与此任务关联的Future对象。使用“invokeAll()”方法,您发送一个任务列表,并在所有任务完成执行时获得Future对象列表。使用“invokeAny()”方法,您发送一个任务列表,并且将接收第一个完成而不抛出异常的任务的结果(不是Future对象)。其余任务将被取消。
Java 并发 API 提供了另一种机制来处理这些类型的任务。这种机制在CompletionService接口中定义,并在ExecutorCompletionService类中实现。该机制允许您解耦任务的执行和其结果的处理。CompletionService接口在内部使用执行器,并提供“submit()”方法将任务发送到CompletionService接口,并提供“poll()”和“take()”方法来获取任务的结果。这些结果以任务完成执行的顺序提供。
您还学会了如何在两个真实世界的例子中实现这些概念:
-
使用 UKACD 数据集的最佳匹配算法
-
使用从维基百科提取的有关电影的信息的数据集的倒排索引构造器
在下一章中,您将学习如何以并发方式执行可以分为阶段的算法,例如关键词提取算法。您可以按照以下三个步骤实现该算法:
-
第一步 - 解析所有文档并提取所有单词。
-
第二步 - 计算每个文档中每个单词的重要性。
-
第三步 - 获取最佳关键词。
这些步骤的主要特点是,您必须在开始下一个步骤之前完全完成一个步骤。Java 并发 API 提供了Phaser类来促进这些算法的并发实现。它允许您在阶段结束时同步涉及其中的所有任务,因此在所有任务完成当前任务之前,没有一个任务会开始下一个任务。
第五章:分阶段运行任务-Phaser 类
并发 API 中最重要的元素是为程序员提供的同步机制。同步是协调两个或多个任务以获得期望的结果。当必须按预定义顺序执行两个或多个任务时,或者当只有一个线程可以同时执行代码片段或修改一块内存块时,可以同步两个或多个任务的执行,或者同步对共享资源的访问。Java 8 并发 API 提供了许多同步机制,从基本的synchronized关键字或Lock接口及其实现来保护关键部分,到更高级的CyclicBarrier或CountDownLatch类,允许您同步不同任务的执行顺序。在 Java 7 中,并发 API 引入了Phaser类。该类提供了一个强大的机制(phaser)来执行分阶段的任务。任务可以要求 Phaser 类等待直到所有其他参与者完成该阶段。在本章中,我们将涵盖以下主题:
-
Phaser类的介绍 -
第一个示例-关键词提取算法
-
第二个示例-遗传算法
Phaser类的介绍
Phaser类是一种同步机制,旨在以并发方式控制可以分阶段执行的算法。如果您有一个具有明确定义步骤的过程,因此您必须在开始第一个步骤之前完成它,然后依此类推,您可以使用此类来制作过程的并发版本。Phaser类的主要特点包括:
-
Phaser 必须知道它需要控制的任务数量。Java 将此称为参与者的注册。参与者可以随时在 phaser 中注册。
-
任务必须在完成阶段时通知 phaser。Phaser 将使该任务休眠,直到所有参与者完成该阶段为止。
-
在内部,phaser 保存一个整数,用于存储该阶段已经进行的阶段变化次数。
-
参与者可以随时离开 phaser 的控制。Java 将此称为参与者的注销。
-
当 phaser 进行阶段变化时,您可以执行自定义代码。
-
您可以控制 phaser 的终止。如果 phaser 被终止,将不会接受新的参与者,并且任务之间也不会进行同步。
-
您可以使用一些方法来了解 phaser 的状态和参与者数量。
参与者的注册和注销
正如我们之前提到的,phaser 必须知道它需要控制的任务数量。它必须知道有多少不同的线程正在执行分阶段算法,以正确地控制同时的阶段变化。
Java 将此过程称为参与者的注册。通常情况下,参与者在执行开始时注册,但是参与者可以随时注册。
您可以使用不同的方法注册参与者:
-
当您创建
Phaser对象时:Phaser类提供了四种不同的构造函数。其中两种是常用的: -
Phaser():此构造函数创建一个没有参与者的 phaser -
Phaser(int parties):此构造函数创建一个具有给定参与者数量的 phaser -
明确地,使用其中一种方法:
-
bulkRegister(int parties):同时注册给定数量的新参与者 -
register():注册一个新的参与者
当由 phaser 控制的任务之一完成其执行时,它必须从 phaser 中注销。如果不这样做,phaser 将在下一个阶段变化中无休止地等待它。要注销参与者,可以使用arriveAndDeregister()方法。您可以使用此方法指示 phaser,该任务已完成当前阶段,并且不会参与下一个阶段。
同步阶段变化
phaser 的主要目的是以并发方式清晰地划分为阶段的算法的实现。在所有任务完成前一个阶段之前,没有一个任务可以进入下一个阶段。Phaser类提供了三种方法来表示任务已完成阶段:arrive()、arriveAndDeregister()和arriveAndAwaitAdvance()。如果其中一个任务没有调用这些方法之一,其他参与者任务将被 phaser 无限期地阻塞。要进入下一个阶段,使用以下方法:
-
arriveAndAwaitAdvance(): 任务使用此方法向 phaser 指示,它已完成当前阶段,并希望继续下一个阶段。phaser 将阻塞任务,直到所有参与者任务调用了同步方法之一。 -
awaitAdvance(int phase): 任务使用此方法向 phaser 指示,如果传递的数字和 phaser 的当前阶段相等,则希望等待当前阶段的完成。如果它们不相等,此方法将立即返回。
其他功能
当所有参与者任务完成一个阶段的执行并在继续下一个阶段之前,Phaser类执行onAdvance()方法。此方法接收以下两个参数:
-
phase:这是已完成的阶段编号。第一个阶段是编号零 -
registeredParties:这表示参与者任务的数量
如果您想在两个阶段之间执行一些代码,例如对数据进行排序或转换,可以实现自己的 phaser,扩展Phaser类并覆盖此方法。
phaser 可以处于两种状态:
-
活动:当创建 phaser 并注册新参与者并继续进行直到终止时,phaser 进入此状态。在此状态下,它接受新的参与者并按照之前的说明工作。
-
终止:当
onAdvance()方法返回true值时,phaser 进入此状态。默认情况下,当所有参与者已注销时,它返回true值。
注意
当 phaser 处于终止状态时,新参与者的注册不起作用,并且同步方法会立即返回。
最后,Phaser类提供了一些方法来获取有关 phaser 状态和参与者的信息:
-
getRegisteredParties(): 此方法返回 phaser 中的参与者数量 -
getPhase(): 此方法返回当前阶段的编号 -
getArrivedParties(): 此方法返回已完成当前阶段的参与者数量 -
getUnarrivedParties(): 此方法返回尚未完成当前阶段的参与者数量 -
isTerminated(): 如果 phaser 处于终止状态,则此方法返回true值,否则返回false
第一个示例 - 关键字提取算法
在本节中,您将使用 phaser 来实现关键字提取算法。这类算法的主要目的是从文本文档或文档集合中提取单词,以更好地定义文档在集合中的文档。这些术语可用于总结文档、对其进行聚类或改进信息搜索过程。
从集合中提取文档关键字的最基本算法(但现在仍然常用)是基于TF-IDF度量,其中:
-
TF(代表词项频率)是单词在文档中出现的次数。
-
DF(代表文档频率)是包含单词的文档数量。IDF(代表逆文档频率)度量了单词提供的信息,以区分文档与其他文档。如果一个词很常见,它的 IDF 将很低,但如果这个词只出现在少数文档中,它的 IDF 将很高。
单词t在文档d中的 TF-IDF 可以使用以下公式计算:

上述公式中使用的属性可以解释如下:
-
F**[t,d]是单词t在文档d中出现的次数
-
N是集合中文档的数量
-
n**[t]是包含单词t的文档数量
要获取文档的关键词,可以选择 TF-IDF 值较高的单词。
您将要实现的算法将执行以下阶段,计算文档集合中的最佳关键词:
-
第一阶段:解析所有文档并提取所有单词的 DF。请注意,只有在解析所有文档后,您才会获得确切的值。
-
第二阶段:计算所有文档中所有单词的 TF-IDF。选择每个文档的 10 个关键词(TF-IDF 值最高的 10 个单词)。
-
第三阶段:获取最佳关键词列表。我们认为那些是出现在更多文档中的单词。
为了测试算法,我们将使用维基百科关于电影信息的页面作为文档集合。我们在第四章中使用了相同的集合,从任务中获取数据 - Callable 和 Future 接口。该集合由 100,673 个文档组成。我们已经将每个维基百科页面转换为文本文件。您可以下载包含有关该书的所有信息的文档集合。
您将要实现算法的两个不同版本:基本的串行版本和使用Phaser类的并发版本。之后,我们将比较两个版本的执行时间,以验证并发性能更好。
常见类
算法的两个版本共享一些通用功能,用于解析文档并存储有关文档、关键词和单词的信息。这些通用类包括:
-
存储包含文档名称和构成文档的单词的
Document类 -
存储单词字符串和该单词的度量(TF,DF 和 TF-IDF)的
Word类 -
存储单词字符串和该单词作为关键词出现在的文档数量的
Keyword类 -
提取文档中的单词的
DocumentParser类
让我们更详细地看看这些类。
单词类
Word类存储有关单词的信息。这些信息包括整个单词以及影响它的度量,即它在文档中的 TF,它的全局 DF 和结果 TF-IDF。
这个类实现了Comparable接口,因为我们将对单词数组进行排序,以获取 TF-IDF 值较高的单词。参考以下代码:
public class Word implements Comparable<Word> {
然后,我们声明了该类的属性并实现了 getter 和 setter(这些未包含在内):
private String word;
private int tf;
private int df;
private double tfIdf;
我们已经实现了其他感兴趣的方法如下:
-
该类的构造函数,初始化单词(使用参数接收的单词)和
df属性(值为1)。 -
addTf()方法,增加tf属性。 -
merge()方法接收一个Word对象并合并来自两个不同文档的相同单词。它将两个对象的tf和df属性相加。
然后,我们实现了setDf()方法的特殊版本。它接收df属性的值和集合中文档的总数,并计算tfIdf属性:
public void setDf(int df, int N) {
this.df = df;
tfIdf = tf * Math.log(Double.valueOf(N) / df);
}
最后,我们实现compareTo()方法。我们希望单词按tfIdf属性从高到低排序:
@Override
public int compareTo(Word o) {
return Double.compare(o.getTfIdf(), this.getTfIdf());
}
}
关键词类
Keyword类存储有关关键词的信息。这些信息包括整个单词以及该单词作为关键词出现在的文档数量。
与Word类一样,它实现了Comparable接口,因为我们将对关键字数组进行排序以获得最佳关键字:
public class Keyword implements Comparable<Keyword> {
然后,我们声明了类的属性并实现了方法来建立和返回其值(这些方法在此处未包括):
private String word;
private int df;
最后,我们实现了compareTo()方法。我们希望关键词按文档数量从高到低排序:
@Override
public int compareTo(Keyword o) {
return Integer.compare(o.getDf(), this.getDf());
}
}
Document 类
Document类存储有关集合中文档的信息(请记住我们的集合有 100,673 个文档),包括文件名和构成文档的单词集。该单词集通常称为文档的词汇,它以整个单词作为字符串作为键,并以Word对象作为值实现为HashMap:
public class Document {
private String fileName;
private HashMap <String, Word> voc;
我们实现了一个构造函数,创建了HashMap和方法来获取和设置文件名以及返回文档的词汇(这些方法未包括)。我们还实现了一个方法来在词汇中添加单词。如果单词不存在,则将其添加到其中。如果单词存在于词汇中,则增加单词的tf属性。我们使用了voc对象的computeIfAbsent()方法。该方法如果单词不存在,则将单词插入HashMap中,然后使用addTf()方法增加tf:
public void addWord(String string) {
voc.computeIfAbsent(string, k -> new Word(k)).addTf();
}
}
HashMap类不是同步的,但我们可以在并发应用程序中使用它,因为它不会在不同任务之间共享。一个Document对象只会被一个任务生成,因此我们不会在并发版本中出现由HashMap类的使用导致的竞争条件。
DocumentParser 类
DocumentParser类读取文本文件的内容并将其转换为Document对象。它将文本拆分为单词并将它们存储在Document对象中以生成类的词汇。该类有两个静态方法。第一个是parse()方法,它接收一个带有文件路径的字符串并返回一个Document对象。它打开文件并逐行读取,使用parseLine()方法将每行转换为一系列单词,并将它们存储到Document类中:
public class DocumentParser {
public static Document parse(String path) {
Document ret = new Document();
Path file = Paths.get(path);
ret.setFileName(file.toString());
try (BufferedReader reader = Files.newBufferedReader(file)) {
for(String line : Files.readAllLines(file)) {
parseLine(line, ret);
}
} catch (IOException x) {
x.printStackTrace();
}
return ret;
}
parseLine()方法接收要解析的行和Document对象以存储单词作为参数。
首先,使用Normalizer类删除行的重音符号,并将其转换为小写:
private static void parseLine(String line, Document ret) {
// Clean string
line = Normalizer.normalize(line, Normalizer.Form.NFKD);
line = line.replaceAll("[^\\p{ASCII}]", "");
line = line.toLowerCase();
然后,我们使用StringTokenizer类将行拆分为单词,并将这些单词添加到Document对象中:
private static void parseLine(String line, Document ret) {
// Clean string
line = Normalizer.normalize(line, Normalizer.Form.NFKD);
line = line.replaceAll("[^\\p{ASCII}]", "");
line = line.toLowerCase();
// Tokenizer
for(String w: line.split("\\W+")) {
ret.addWord(w);
}
}
}
串行版本
我们在SerialKeywordExtraction类中实现了关键字算法的串行版本。它定义了您将执行以测试算法的main()方法。
第一步是声明以下必要的内部变量来执行算法:
-
两个
Date对象,用于测量执行时间 -
一个字符串,用于存储包含文档集合的目录的名称
-
一个
File对象数组,用于存储文档集合中的文件 -
一个
HashMap,用于存储文档集合的全局词汇 -
一个
HashMap,用于存储关键字 -
两个
int值,用于测量执行的统计数据
以下包括这些变量的声明:
public class SerialKeywordExtraction {
public static void main(String[] args) {
Date start, end;
File source = new File("data");
File[] files = source.listFiles();
HashMap<String, Word> globalVoc = new HashMap<>();
HashMap<String, Integer> globalKeywords = new HashMap<>();
int totalCalls = 0;
int numDocuments = 0;
start = new Date();
然后,我们已经包含了算法的第一阶段。我们使用DocumentParser类的parse()方法解析所有文档。该方法返回一个包含该文档词汇的Document对象。我们使用HashMap类的merge()方法将文档词汇添加到全局词汇中。如果单词不存在,则将其插入HashMap中。如果单词存在,则合并两个单词对象,求和Tf和Df属性:
if(files == null) {
System.err.println("Unable to read the 'data' folder");
return;
}
for (File file : files) {
if (file.getName().endsWith(".txt")) {
Document doc = DocumentParser.parse (file.getAbsolutePath());
for (Word word : doc.getVoc().values()) {
globalVoc.merge(word.getWord(), word, Word::merge);
}
numDocuments++;
}
}
System.out.println("Corpus: " + numDocuments + " documents.");
在这个阶段之后,globalVocHashMap类包含了文档集合中所有单词及其全局 TF(单词在集合中出现的总次数)和 DF。
然后,我们包括了算法的第二阶段。我们将使用 TF-IDF 度量来计算每个文档的关键词,正如我们之前解释的那样。我们必须再次解析每个文档以生成其词汇表。我们必须这样做,因为我们无法将由 100,673 个文档组成的文档集合的词汇表存储在内存中。如果您使用的是较小的文档集合,可以尝试仅解析一次文档并将所有文档的词汇表存储在内存中,但在我们的情况下,这是不可能的。因此,我们再次解析所有文档,并且对于每个单词,我们使用存储在globalVoc中的值来更新Df属性。我们还构建了一个包含文档中所有单词的数组:
for (File file : files) {
if (file.getName().endsWith(".txt")) {
Document doc = DocumentParser.parse(file.getAbsolutePath());
List<Word> keywords = new ArrayList<>( doc.getVoc().values());
int index = 0;
for (Word word : keywords) {
Word globalWord = globalVoc.get(word.getWord());
word.setDf(globalWord.getDf(), numDocuments);
}
现在,我们有了关键词列表,其中包含文档中所有单词的 TF-IDF 计算结果。我们使用Collections类的sort()方法对列表进行排序,将 TF-IDF 值较高的单词排在第一位。然后我们获取该列表的前 10 个单词,并使用addKeyword()方法将它们存储在globalKeywordsHashMap中。
选择前 10 个单词没有特殊原因。您可以尝试其他选项,比如单词的百分比或 TF-IDF 值的最小值,并观察它们的行为:
Collections.sort(keywords);
int counter = 0;
for (Word word : keywords) {
addKeyword(globalKeywords, word.getWord());
totalCalls++;
}
}
}
最后,我们包括了算法的第三阶段。我们将globalKeywordsHashMap转换为Keyword对象的列表,使用Collections类的sort()方法对该数组进行排序,获取 DF 值较高的关键词并将其写入控制台的前 100 个单词。
参考以下代码:
List<Keyword> orderedGlobalKeywords = new ArrayList<>();
for (Entry<String, Integer> entry : globalKeywords.entrySet()) {
Keyword keyword = new Keyword();
keyword.setWord(entry.getKey());
keyword.setDf(entry.getValue());
orderedGlobalKeywords.add(keyword);
}
Collections.sort(orderedGlobalKeywords);
if (orderedGlobalKeywords.size() > 100) {
orderedGlobalKeywords = orderedGlobalKeywords.subList(0, 100);
}
for (Keyword keyword : orderedGlobalKeywords) {
System.out.println(keyword.getWord() + ": " + keyword.getDf());
}
与第二阶段一样,选择前 100 个单词没有特殊原因。如果您愿意,可以尝试其他选项。
在主方法结束时,我们在控制台中写入执行时间和其他统计数据:
end = new Date();
System.out.println("Execution Time: " + (end.getTime() - start.getTime()));
System.out.println("Vocabulary Size: " + globalVoc.size());
System.out.println("Keyword Size: " + globalKeywords.size());
System.out.println("Number of Documents: " + numDocuments);
System.out.println("Total calls: " + totalCalls);
}
SerialKeywordExtraction类还包括addKeyword()方法,用于更新globalKeywordsHashMap类中关键词的信息。如果单词存在,该类会更新其 DF;如果单词不存在,则插入它。
private static void addKeyword(Map<String, Integer> globalKeywords, String word) {
globalKeywords.merge(word, 1, Integer::sum);
}
}
并发版本
为了实现这个示例的并发版本,我们使用了两个不同的类,如下所示:
-
KeywordExtractionTasks类实现了以并发方式计算关键词的任务。我们将以Thread对象的形式执行这些任务,因此这个类实现了Runnable接口。 -
ConcurrentKeywordExtraction类提供了main()方法来执行算法,并创建、启动和等待任务完成。
让我们详细看看这些类。
关键词提取任务类
正如我们之前提到的,这个类实现了计算最终关键词列表的任务。它实现了Runnable接口,因此我们可以将其作为Thread执行,并且在内部使用一些属性,其中大部分属性在所有任务之间是共享的:
-
两个 ConcurrentHashMap 对象用于存储全局词汇表和全局关键词:我们使用
ConcurrentHashMap,因为这些对象将由所有任务更新,所以我们必须使用并发数据结构来避免竞争条件。 -
两个 ConcurrentLinkedDeque 的 File 对象,用于存储构成文档集合的文件列表:我们使用
ConcurrentLinkedDeque类,因为所有任务都将同时提取(获取和删除)列表的元素,所以我们必须使用并发数据结构来避免竞争条件。如果我们使用普通的List,同一个File可能会被不同的任务解析两次。我们有两个ConcurrentLinkedDeque,因为我们必须两次解析文档集合。正如我们之前提到的,我们从数据结构中提取File对象来解析文档集合,因此,当我们解析完集合时,数据结构将为空。 -
一个 Phaser 对象来控制任务的执行:正如我们之前解释的那样,我们的关键词提取算法是在三个阶段中执行的。在所有任务完成前,没有一个任务会进入下一个阶段。我们使用
Phaser对象来控制这一点。如果我们不控制这一点,我们将得到不一致的结果。 -
最终步骤必须由一个线程执行:我们将使用布尔值区分一个主任务和其他任务。这些主任务将执行最终阶段。
-
集合中的文档总数:我们需要这个值来计算 TF-IDF 度量。
我们已经包括了一个构造函数来初始化所有这些属性:
public class KeywordExtractionTask implements Runnable {
private ConcurrentHashMap<String, Word> globalVoc;
private ConcurrentHashMap<String, Integer> globalKeywords;
private ConcurrentLinkedDeque<File> concurrentFileListPhase1;
private ConcurrentLinkedDeque<File> concurrentFileListPhase2;
private Phaser phaser;
private String name;
private boolean main;
private int parsedDocuments;
private int numDocuments;
public KeywordExtractionTask(
ConcurrentLinkedDeque<File> concurrentFileListPhase1,
ConcurrentLinkedDeque<File> concurrentFileListPhase2,
Phaser phaser, ConcurrentHashMap<String, Word> globalVoc,
ConcurrentHashMap<String, Integer> globalKeywords,
int numDocuments, String name, boolean main) {
this.concurrentFileListPhase1 = concurrentFileListPhase1;
this.concurrentFileListPhase2 = concurrentFileListPhase2;
this.globalVoc = globalVoc;
this.globalKeywords = globalKeywords;
this.phaser = phaser;
this.main = main;
this.name = name;
this.numDocuments = numDocuments;
}
run() 方法实现了算法的三个阶段。首先,我们调用 phaser 的 arriveAndAwaitAdvance() 方法等待其他任务的创建。所有任务将在同一时刻开始执行。然后,就像我们在算法的串行版本中解释的那样,我们解析所有文档,并使用所有单词和它们的全局 TF 和 DF 值构建 globalVocConcurrentHashMap 类。为了完成第一阶段,我们再次调用 arriveAndAwaitAdvance() 方法,等待其他任务在执行第二阶段之前的最终化:
@Override
public void run() {
File file;
// Phase 1
phaser.arriveAndAwaitAdvance();
System.out.println(name + ": Phase 1");
while ((file = concurrentFileListPhase1.poll()) != null) {
Document doc = DocumentParser.parse(file.getAbsolutePath());
for (Word word : doc.getVoc().values()) {
globalVoc.merge(word.getWord(), word, Word::merge);
}
parsedDocuments++;
}
System.out.println(name + ": " + parsedDocuments + " parsed.");
phaser.arriveAndAwaitAdvance();
如您所见,为了获取要处理的 File 对象,我们使用 ConcurrentLinkedDeque 类的 poll() 方法。这个方法检索并删除Deque的第一个元素,所以下一个任务将获得一个不同的文件进行解析,不会有文件被解析两次。
第二阶段计算 globalKeywords 结构,就像我们在算法的串行版本中解释的那样。首先,计算每个文档的最佳 10 个关键词,然后将它们插入 ConcurrentHashMap 类。代码与串行版本相同,只是将串行数据结构更改为并发数据结构:
// Phase 2
System.out.println(name + ": Phase 2");
while ((file = concurrentFileListPhase2.poll()) != null) {
Document doc = DocumentParser.parse(file.getAbsolutePath());
List<Word> keywords = new ArrayList<>(doc.getVoc().values());
for (Word word : keywords) {
Word globalWord = globalVoc.get(word.getWord());
word.setDf(globalWord.getDf(), numDocuments);
}
Collections.sort(keywords);
if(keywords.size() > 10) keywords = keywords.subList(0, 10);
for (Word word : keywords) {
addKeyword(globalKeywords, word.getWord());
}
}
System.out.println(name + ": " + parsedDocuments + " parsed.");
最终阶段对于主任务和其他任务是不同的。主任务使用 Phaser 类的 arriveAndAwaitAdvance() 方法等待所有任务的第二阶段最终化,然后在控制台中写入整个集合中最佳的 100 个关键词。最后,它使用 arriveAndDeregister() 方法从 phaser 中注销。
其他任务使用 arriveAndDeregister() 方法标记第二阶段的最终化,从 phaser 中注销,并完成它们的执行。
当所有任务都完成了他们的工作,它们都从 phaser 中注销了自己。phaser 将没有任何 parties,并且进入终止状态。
if (main) {
phaser.arriveAndAwaitAdvance();
Iterator<Entry<String, Integer>> iterator = globalKeywords.entrySet().iterator();
Keyword orderedGlobalKeywords[] = new Keyword[globalKeywords.size()];
int index = 0;
while (iterator.hasNext()) {
Entry<String, AtomicInteger> entry = iterator.next();
Keyword keyword = new Keyword();
keyword.setWord(entry.getKey());
keyword.setDf(entry.getValue().get());
orderedGlobalKeywords[index] = keyword;
index++;
}
System.out.println("Keyword Size: " + orderedGlobalKeywords.length);
Arrays.parallelSort(orderedGlobalKeywords);
int counter = 0;
for (int i = 0; i < orderedGlobalKeywords.length; i++){
Keyword keyword = orderedGlobalKeywords[i];
System.out.println(keyword.getWord() + ": " + keyword.getDf());
counter++;
if (counter == 100) {
break;
}
}
}
phaser.arriveAndDeregister();
System.out.println("Thread " + name + " has finished.");
}
ConcurrentKeywordExtraction 类
ConcurrentKeywordExtraction 类初始化了共享对象,创建了任务,执行它们,并等待它们的最终化。它实现了一个 main() 方法,可以接收一个可选参数。默认情况下,我们根据 Runtime 类的 availableProcessors() 方法确定任务的数量,该方法返回 Java 虚拟机 (JVM) 可用的硬件线程数。如果我们收到一个参数,我们将其转换为整数,并将其用作可用处理器数量的乘数,以确定我们将创建的任务数量。
首先,我们初始化所有必要的数据结构和参数。为了填充两个ConcurrentLinkedDeque结构,我们使用File类的listFiles()方法来获取以txt后缀结尾的文件的File对象数组。
我们还使用不带参数的构造函数创建Phaser对象,因此所有任务必须显式地在屏障中注册自己。参考以下代码:
public class ConcurrentKeywordExtraction {
public static void main(String[] args) {
Date start, end;
ConcurrentHashMap<String, Word> globalVoc = new ConcurrentHashMap<>();
ConcurrentHashMap<String, Integer> globalKeywords = new ConcurrentHashMap<>();
start = new Date();
File source = new File("data");
File[] files = source.listFiles(f -> f.getName().endsWith(".txt"));
if (files == null) {
System.err.println("The 'data' folder not found!");
return;
}
ConcurrentLinkedDeque<File> concurrentFileListPhase1 = new ConcurrentLinkedDeque<>(Arrays.asList(files));
ConcurrentLinkedDeque<File> concurrentFileListPhase2 = new ConcurrentLinkedDeque<>(Arrays.asList(files));
int numDocuments = files.length();
int factor = 1;
if (args.length > 0) {
factor = Integer.valueOf(args[0]);
}
int numTasks = factor * Runtime.getRuntime().availableProcessors();
Phaser phaser = new Phaser();
Thread[] threads = new Thread[numTasks];
KeywordExtractionTask[] tasks = new KeywordExtractionTask[numTasks];
然后,我们使用true作为主参数创建第一个任务,其余使用false作为主参数。在创建每个任务之后,我们使用Phaser类的register()方法来注册新的参与者到屏障中,如下所示:
for (int i = 0; i < numTasks; i++) {
tasks[i] = new KeywordExtractionTask(concurrentFileListPhase1, concurrentFileListPhase2, phaser, globalVoc, globalKeywords, concurrentFileListPhase1.size(), "Task" + i, i==0);
phaser.register();
System.out.println(phaser.getRegisteredParties() + " tasks arrived to the Phaser.");
}
然后,我们创建并启动运行任务的线程对象,并等待其完成:
for (int i = 0; i < numTasks; i++) {
threads[i] = new Thread(tasks[i]);
threads[i].start();
}
for (int i = 0; i < numTasks; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
最后,我们在控制台中写入有关执行的一些统计信息,包括执行时间:
System.out.println("Is Terminated: " + phaser.isTerminated());
end = new Date();
System.out.println("Execution Time: " + (end.getTime() - start.getTime()));
System.out.println("Vocabulary Size: " + globalVoc.size());
System.out.println("Number of Documents: " + numDocuments);
}
}
比较两种解决方案
让我们比较我们的关键词提取 100,673 个文档的串行和并发版本。我们使用 JMH 框架(openjdk.java.net/projects/code-tools/jmh/)执行示例,该框架允许您在 Java 中实现微基准测试。使用基准测试框架比简单地使用currentTimeMillis()或nanoTime()等方法测量时间更好。我们在一个四核处理器的计算机上执行了 10 次,并计算了这 10 次的平均执行时间。
| 算法 | 因子 | 执行时间(秒) |
|---|---|---|
| 串行 | N/A | 194.45 |
| 并发 | 1 | 64.52 |
| 2 | 65.55 | |
| 3 | 68,23 |
我们可以得出以下结论:
-
算法的并发版本提高了串行版本的性能。
-
如果我们使用的任务数量超过了可用的硬件线程数量,我们不会得到更好的结果。只会稍微差一点,因为额外的同步工作必须由屏障执行。
我们比较并发和串行版本的算法,使用以下公式计算加速比:

第二个例子 - 遗传算法
遗传算法是基于自然选择原理的自适应启发式搜索算法,用于生成优化和搜索问题的良好解决方案。它们处理问题的可能解决方案,称为个体或表型。每个个体都有一个由一组属性组成的表示,称为染色体。通常,个体由一系列位表示,但你可以选择最适合你问题的表示形式。
你还需要一个确定解决方案好坏的函数,称为适应度函数。遗传算法的主要目标是找到最大化或最小化该函数的解决方案。
遗传算法从一组可能的问题解决方案开始。这组可能的解决方案被称为种群。你可以随机生成这个初始集,或者使用某种启发式函数来获得更好的初始解决方案。
一旦你有了初始种群,你就开始一个迭代过程,包括三个阶段。该迭代过程的每一步被称为一代。每一代的阶段包括:
-
选择: 你选择种群中更好的个体。这些个体在适应度函数中具有更好的值。
-
交叉: 你交叉选择在上一步中选定的个体,以生成新的个体,形成新的一代。这个操作需要两个个体,并生成两个新的个体。这个操作的实现取决于你想要解决的问题以及你选择的个体的表示。
-
突变:您可以应用突变运算符来改变个体的值。通常,您会将该操作应用于非常少量的个体。虽然突变是找到良好解决方案的一个非常重要的操作,但我们不会在简化示例中应用它。
您重复这三个操作,直到满足您的完成标准。这些完成标准可以是:
-
固定数量的世代
-
预定义的适应度函数值
-
找到符合预定义标准的解决方案
-
时间限制
-
手动停止
通常,您会在种群之外存储您在整个过程中找到的最佳个体。这个个体将是算法提出的解决方案,通常情况下,它会是一个更好的解决方案,因为我们会生成新的世代。
在本节中,我们将实现一个遗传算法来解决著名的旅行推销员问题(TSP)。在这个问题中,您有一组城市和它们之间的距离,您希望找到一条最佳路线,穿过所有城市并最小化旅行的总距离。与其他示例一样,我们实现了一个串行版本和一个并发版本,使用了Phaser类。应用于 TSP 问题的遗传算法的主要特征是:
-
个体:个体表示城市的遍历顺序。
-
交叉:在交叉操作之后,您必须创建有效的解决方案。您必须只访问每个城市一次。
-
适应度函数:算法的主要目标是最小化穿过城市的总距离。
-
完成标准:我们将执行预定义数量的世代算法。
例如,您可以有一个包含四个城市的距离矩阵,如下表所示:
| 城市 1 | 城市 2 | 城市 3 | 城市 4 | |
|---|---|---|---|---|
| 城市 1 | 0 | 11 | 6 | 9 |
| 城市 2 | 7 | 0 | 8 | 2 |
| 城市 3 | 7 | 3 | 0 | 3 |
| 城市 4 | 10 | 9 | 4 | 0 |
这意味着城市 2 和城市 1 之间的距离是 7,但城市 1 和城市 2 之间的距离是 11。一个个体可以是(2,4,3,1),其适应度函数是 2 和 4 之间的距离、4 和 3 之间的距离、3 和 1 之间的距离以及 1 和 2 之间的距离的总和,即 2+4+7+11=24。
如果您想在个体(1,2,3,4)和(1,3,2,4)之间进行交叉,您不能生成个体(1,2,2,4),因为您访问了城市 2 两次。您可以生成个体(1,2,4,3)和(1,3,4,2)。
为了测试算法,我们使用了两个城市距离数据集的例子(people.sc.fsu.edu/~jburkardt/datasets/cities/cities.html),分别为 15 个城市(lau15_dist)和 57 个城市(kn57_dist)。
常见类
两个版本都使用以下三个常见类:
-
DataLoader类从文件加载距离矩阵。我们不在这里包括该类的代码。它有一个静态方法,接收文件名并返回一个int[][]矩阵,其中存储了城市之间的距离。距离存储在 csv 文件中(我们对原始格式进行了一些小的转换),因此很容易进行转换。 -
Individual类存储种群中个体的信息(问题的可能解决方案)。为了表示每个个体,我们选择了一个整数值数组,它存储您访问不同城市的顺序。 -
GeneticOperators类实现了种群或个体的交叉、选择和评估。
让我们看看Individual和GeneticOperators类的详细信息。
个体类
这个类存储了我们 TSP 问题的每个可能解。我们称每个可能解为一个个体,它的表示是染色体。在我们的情况下,我们将每个可能解表示为一个整数数组。该数组包含推销员访问城市的顺序。这个类还有一个整数值来存储适应函数的结果。我们有以下代码:
public class Individual implements Comparable<Individual> {
private Integer[] chromosomes;
private int value;
我们包括了两个构造函数。第一个接收你必须访问的城市数量,然后创建一个空数组。另一个接收一个Individual对象,并将其染色体复制如下:
public Individual(int size) {
chromosomes=new Integer[size];
}
public Individual(Individual other) {
chromosomes = other.getChromosomes().clone();
}
我们还实现了compareTo()方法,使用适应函数的结果来比较两个个体:
@Override
public int compareTo(Individual o) {
return Integer.compare(this.getValue(), o.getValue());
}
最后,我们已经包括了获取和设置属性值的方法。
遗传算法操作类
这是一个复杂的类,因为它实现了遗传算法的内部逻辑。它提供了初始化、选择、交叉和评估操作的方法,就像在本节开头介绍的那样。我们只描述了这个类提供的方法,而没有描述它们是如何实现的,以避免不必要的复杂性。你可以获取示例的源代码来分析这些方法的实现。
这个类提供的方法有:
-
initialize(int numberOfIndividuals, int size): 这将创建一个新的种群。种群的个体数量将由numberOfIndividuals参数确定。染色体的数量(在我们的情况下是城市)将由大小参数确定。它返回一个Individual对象的数组。它使用初始化方法(Integer[])来初始化每个个体。 -
initialize(Integer[] chromosomes): 它以随机方式初始化个体的染色体。它生成有效的个体(你必须只访问每个城市一次)。 -
selection(Individual[] population): 这个方法实现了选择操作,以获取种群中最好的个体。它以一个数组的形式返回这些个体。数组的大小将是种群大小的一半。你可以测试其他标准来确定选择的个体数量。我们选择适应函数最好的个体。 -
crossover(Individual[] selected, int numberOfIndividuals, int size): 这个方法接收上一代选择的个体作为参数,并使用交叉操作生成下一代的种群。下一代的个体数量将由同名的参数确定。每个个体的染色体数量将由大小参数确定。它使用交叉方法(Individual,Individual,Individual,Individual)从两个选择的个体生成两个新的个体。 -
crossover(Individual parent1, Individual parent2, Individual individual1, Individual individual2): 这个方法执行交叉操作,以获取parent1和parent2个体生成下一代的individual1和individual2个体。 -
evaluate(Individual[] population, int [][] distanceMatrix): 这将使用接收的距离矩阵对种群中的所有个体应用适应函数。最后,它将种群从最佳到最差的解进行排序。它使用评估方法(Individual,int[][])来评估每个个体。 -
evaluate(Individual individual, int[][] distanceMatrix): 这将适用于一个个体的适应函数。
有了这个类和它的方法,你就有了实现解决 TSP 问题的遗传算法所需的一切。
串行版本
我们使用以下两个类实现了算法的串行版本:
-
实现算法的
SerialGeneticAlgorithm类 -
SerialMain类执行算法,并测量执行时间
让我们详细分析这两个类。
SerialGeneticAlgorithm 类
这个类实现了我们遗传算法的串行版本。在内部,它使用以下四个属性:
-
包含所有城市之间距离的距离矩阵
-
代的数量
-
种群中的个体数
-
每个个体中的染色体数
该类还有一个构造函数来初始化所有属性:
private int[][] distanceMatrix;
private int numberOfGenerations;
private int numberOfIndividuals;
private int size;
public SerialGeneticAlgorithm(int[][] distanceMatrix,
int numberOfGenerations, int numberOfIndividuals) {
this.distanceMatrix = distanceMatrix;
this.numberOfGenerations = numberOfGenerations;
this.numberOfIndividuals = numberOfIndividuals;
size = distanceMatrix.length;
}
该类的主要方法是calculate()方法。首先,使用initialize()方法创建初始种群。然后,评估初始种群,并将其最佳个体作为算法的第一个解决方案:
public Individual calculate() {
Individual best;
Individual[] population = GeneticOperators.initialize(
numberOfIndividuals, size);
GeneticOperators.evaluate(population, distanceMatrix);
best = population[0];
然后,它执行一个由numberOfGenerations属性确定的循环。在每个周期中,它使用selection()方法获取选定的个体,使用crossover()方法计算下一代,评估这个新一代,并且如果新一代的最佳解决方案比到目前为止的最佳个体更好,我们就替换它。当循环结束时,我们将最佳个体作为算法提出的解决方案返回:
for (int i = 1; i <= numberOfGenerations; i++) {
Individual[] selected = GeneticOperators.selection(population);
population = GeneticOperators.crossover(selected, numberOfIndividuals, size);
GeneticOperators.evaluate(population, distanceMatrix);
if (population[0].getValue() < best.getValue()) {
best = population[0];
}
}
return best;
}
SerialMain 类
该类为本节中使用的两个数据集执行遗传算法——包含 15 个城市的lau15和包含 57 个城市的kn57。
main()方法必须接收两个参数。第一个是我们想要创建的代数,第二个参数是我们想要每一代中拥有的个体数:
public class SerialMain {
public static void main(String[] args) {
Date start, end;
int generations = Integer.valueOf(args[0]);
int individuals = Integer.valueOf(args[1]);
对于每个示例,我们使用DataLoader类的load()方法加载距离矩阵,创建SerialGeneticAlgorith对象,执行calculate()方法并测量执行时间,并将执行时间和结果写入控制台:
for (String name : new String[] { "lau15_dist", "kn57_dist" }) {
int[][] distanceMatrix = DataLoader.load(Paths.get("data", name + ".txt"));
SerialGeneticAlgorithm serialGeneticAlgorithm = new SerialGeneticAlgorithm(distanceMatrix, generations, individuals);
start = new Date();
Individual result = serialGeneticAlgorithm.calculate();
end = new Date();
System.out.println ("=======================================");
System.out.println("Example:"+name);
System.out.println("Generations: " + generations);
System.out.println("Population: " + individuals);
System.out.println("Execution Time: " + (end.getTime() - start.getTime()));
System.out.println("Best Individual: " + result);
System.out.println("Total Distance: " + result.getValue());
System.out.println ("=======================================");
}
并发版本
我们已经实现了遗传算法的并发版本不同的类:
-
SharedData类存储所有任务之间共享的对象 -
GeneticPhaser类扩展了Phaser类,并覆盖了它的onAdvance()方法,以在所有任务完成一个阶段时执行代码 -
ConcurrentGeneticTask类实现了遗传算法阶段的任务 -
ConcurrentGeneticAlgorithm类将使用前面的类实现遗传算法的并发版本 -
ConcurrentMain类将在我们的两个数据集中测试遗传算法的并发版本
在内部,ConcurrentGeneticTask类将执行三个阶段。第一个阶段是选择阶段,只有一个任务执行。第二个阶段是交叉阶段,所有任务将使用选定的个体构建新一代,最后一个阶段是评估阶段,所有任务将评估新一代的个体。
让我们详细看看这些类中的每一个。
SharedData 类
正如我们之前提到的,这个类包含了所有任务共享的对象。这包括以下内容:
-
种群数组,包含一代中的所有个体。
-
选定的数组与选定的个体。
-
一个名为
index的原子整数。这是唯一的线程安全对象,用于知道任务必须生成或处理的个体的索引。 -
所有代中的最佳个体将作为算法的解决方案返回。
-
包含城市之间距离的距离矩阵。
所有这些对象将被所有线程共享,但我们只需要使用一个并发数据结构。这是唯一一个有效被所有任务共享的属性。其余的对象将只被读取(距离矩阵),或者每个任务将访问对象的不同部分(种群和选定的数组),因此我们不需要使用并发数据结构或同步机制来避免竞争条件:
public class SharedData {
private Individual[] population;
private Individual selected[];
private AtomicInteger index;
private Individual best;
private int[][] distanceMatrix;
}
该类还包括获取器和设置器,用于获取和建立这些属性的值。
GeneticPhaser 类
我们需要在任务的阶段变化时执行代码,因此我们必须实现自己的阶段器并重写onAdvance()方法,该方法在所有参与方完成一个阶段之后执行,然后开始执行下一个阶段。GeneticPhaser类实现了这个阶段器。它存储SharedData对象以便与其一起工作,并将其作为构造函数的参数接收:
public class GeneticPhaser extends Phaser {
private SharedData data;
public GeneticPhaser(int parties, SharedData data) {
super(parties);
this.data=data;
}
onAdvance()方法将接收阶段器的阶段号和注册方的数量作为参数。阶段器内部将阶段号作为整数存储,随着每次阶段变化而递增。相反,我们的算法只有三个阶段,将被执行很多次。我们必须将阶段器的阶段号转换为遗传算法的阶段号,以了解任务是否将执行选择、交叉或评估阶段。为此,我们计算阶段器阶段号除以三的余数,如下所示:
protected boolean onAdvance(int phase, int registeredParties) {
int realPhase=phase%3;
if (registeredParties>0) {
switch (realPhase) {
case 0:
case 1:
data.getIndex().set(0);
break;
case 2:
Arrays.sort(data.getPopulation());
if (data.getPopulation()[0].getValue() < data.getBest().getValue()) {
data.setBest(data.getPopulation()[0]);
}
break;
}
return false;
}
return true;
}
如果余数为零,则任务已经完成了选择阶段,并将执行交叉阶段。我们用值零初始化索引对象。
如果余数为一,则任务已经完成了交叉阶段,并将执行评估阶段。我们用值零初始化索引对象。
最后,如果余数为二,则任务已经完成了评估阶段,并将重新开始选择阶段。我们根据适应度函数对种群进行排序,并在必要时更新最佳个体。
请注意,这个方法只会由一个线程执行,与任务无关。它将在任务的线程中执行,这个任务是最后完成上一个阶段的(在arriveAndAwaitAdvance()调用中)。其余的任务将处于睡眠状态,等待阶段器。
ConcurrentGeneticTask 类
这个类实现了协作执行遗传算法的任务。它们执行算法的三个阶段(选择、交叉和评估)。选择阶段将只由一个任务执行(我们称之为主任务),而其余的阶段将由所有任务执行。
在内部,它使用了四个属性:
-
一个
GeneticPhaser对象,用于在每个阶段结束时同步任务 -
一个
SharedData对象来访问共享数据 -
它必须计算的代数
-
指示是否为主任务的布尔标志
所有这些属性都在类的构造函数中初始化:
public class ConcurrentGeneticTask implements Runnable {
private GeneticPhaser phaser;
private SharedData data;
private int numberOfGenerations;
private boolean main;
public ConcurrentGeneticTask(GeneticPhaser phaser, int numberOfGenerations, boolean main) {
this.phaser = phaser;
this.numberOfGenerations = numberOfGenerations;
this.main = main;
this.data = phaser.getData();
}
run()方法实现了遗传算法的逻辑。它有一个循环来生成指定的代数。正如我们之前提到的,只有主任务才会执行选择阶段。其余的任务将使用arriveAndAwaitAdvance()方法等待此阶段的完成。参考以下代码:
@Override
public void run() {
Random rm = new Random(System.nanoTime());
for (int i = 0; i < numberOfGenerations; i++) {
if (main) {
data.setSelected(GeneticOperators.selection(data
.getPopulation()));
}
phaser.arriveAndAwaitAdvance();
第二阶段是交叉阶段。我们使用SharedData类中存储的AtomicInteger变量索引来获取每个任务将计算的种群数组中的下一个位置。正如我们之前提到的,交叉操作会生成两个新个体,因此每个任务首先在种群数组中保留两个位置。为此,我们使用getAndAdd(2)方法,它返回变量的实际值并将其值增加两个单位。它是一个原子变量,因此我们不需要使用任何同步机制。这是原子变量固有的。参考以下代码:
// Crossover
int individualIndex;
do {
individualIndex = data.getIndex().getAndAdd(2);
if (individualIndex < data.getPopulation().length) {
int secondIndividual = individualIndex++;
int p1Index = rm.nextInt (data.getSelected().length);
int p2Index;
do {
p2Index = rm.nextInt (data.getSelected().length);
} while (p1Index == p2Index);
Individual parent1 = data.getSelected() [p1Index];
Individual parent2 = data.getSelected() [p2Index];
Individual individual1 = data.getPopulation() [individualIndex];
Individual individual2 = data.getPopulation() [secondIndividual];
GeneticOperators.crossover(parent1, parent2, individual1, individual2);
}
} while (individualIndex < data.getPopulation().length);
phaser.arriveAndAwaitAdvance();
当新种群的所有个体都生成时,任务使用arriveAndAwaitAdvance()方法来同步阶段的结束。
最后一个阶段是评估阶段。我们再次使用AtomicInteger索引。每个任务都会得到变量的实际值,该值代表种群中个体的位置,并使用getAndIncrement()方法递增其值。一旦所有个体都被评估,我们使用arriveAndAwaitAdvance()方法来同步这个阶段的结束。请记住,当所有任务都完成了这个阶段时,GeneticPhaser类将执行对种群数组的排序,并根据需要更新最佳个体变量,如下所示:
// Evaluation
do {
individualIndex = data.getIndex().getAndIncrement();
if (individualIndex < data.getPopulation().length) {
GeneticOperators.evaluate(data.getPopulation() [individualIndex], data.getDistanceMatrix());
}
} while (individualIndex < data.getPopulation().length);
phaser.arriveAndAwaitAdvance();
}
phaser.arriveAndDeregister();
}
最后,当所有代数都被计算时,任务使用arriveAndDeregister()方法来指示其执行的结束,因此 phaser 将进入最终状态。
ConcurrentGeneticAlgorithm 类
这个类是遗传算法的外部接口。在内部,它创建、启动并等待计算不同代数的任务的完成。它使用四个属性:代数的数量、每一代中个体的数量、每个个体的染色体数量和距离矩阵,如下所示:
public class ConcurrentGeneticAlgorithm {
private int numberOfGenerations;
private int numberOfIndividuals;
private int[][] distanceMatrix;
private int size;
public ConcurrentGeneticAlgorithm(int[][] distanceMatrix, int numberOfGenerations, int numberOfIndividuals) {
this.distanceMatrix=distanceMatrix;
this.numberOfGenerations=numberOfGenerations;
this.numberOfIndividuals=numberOfIndividuals;
size=distanceMatrix.length;
}
calculate()方法执行遗传算法并返回最佳个体。首先,它使用initialize()方法创建初始种群,评估该种群,并创建和初始化一个带有所有必要数据的SharedData对象,如下所示:
public Individual calculate() {
Individual[] population= GeneticOperators.initialize(numberOfIndividuals,size);
GeneticOperators.evaluate(population,distanceMatrix);
SharedData data=new SharedData();
data.setPopulation(population);
data.setDistanceMatrix(distanceMatrix);
data.setBest(population[0]);
然后,它创建任务。我们使用计算机的可用硬件线程数,该数由Runtime类的availableProcessors()方法返回,作为我们将要创建的任务数。我们还创建了一个GeneticPhaser对象来同步这些任务的执行,如下所示:
int numTasks=Runtime.getRuntime().availableProcessors();
GeneticPhaser phaser=new GeneticPhaser(numTasks,data);
ConcurrentGeneticTask[] tasks=new ConcurrentGeneticTask[numTasks];
Thread[] threads=new Thread[numTasks];
tasks[0]=new ConcurrentGeneticTask(phaser, numberOfGenerations, true);
for (int i=1; i< numTasks; i++) {
tasks[i]=new ConcurrentGeneticTask(phaser, numberOfGenerations, false);
}
然后,我们创建Thread对象来执行任务,启动它们,并等待它们的完成。最后,我们返回存储在ShareData对象中的最佳个体,如下所示:
for (int i=0; i<numTasks; i++) {
threads[i]=new Thread(tasks[i]);
threads[i].start();
}
for (int i=0; i<numTasks; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return data.getBest();
}
}
ConcurrentMain 类
这个类执行了遗传算法,用于本节中使用的两个数据集——有 15 个城市的lau15和有 57 个城市的kn57。它的代码类似于SerialMain类,但是使用ConcurrentGeneticAlgorithm代替SerialGeneticAlgorithm。
比较两种解决方案
现在是时候测试两种解决方案,看看它们哪个性能更好。正如我们之前提到的,我们使用了城市距离数据集(people.sc.fsu.edu/~jburkardt/datasets/cities/cities.html)中的两个数据集——有 15 个城市的lau15和有 57 个城市的kn57。我们还测试了不同规模的种群(100、1,000 和 10,000 个个体)和不同代数的数量(10、100 和 1,000)。为了测试算法,我们使用了 JMH 框架(openjdk.java.net/projects/code-tools/jmh/),它允许您在 Java 中实现微基准测试。使用基准测试框架比简单地使用currentTimeMillis()或nanoTime()等方法来测量时间更好。我们在一个四核处理器的计算机上执行了 10 次,并计算了这 10 次的中间执行时间。
Lau15 数据集
第一个数据集的执行时间(毫秒)为:
| 人口 | |
|---|---|
| 100 | |
| 代数 | 串行 |
| 10 | 8.42 |
| 100 | 25.848 |
| 1,000 | 117.929 |
Kn57 数据集
第二个数据集的执行时间(毫秒)为:
| 人口 | |
|---|---|
| 100 | |
| Generations | Serial |
| 10 | 19.205 |
| 100 | 75.129 |
| 1,000 | 676.390 |
结论
算法的行为与两个数据集相似。您可以看到,当个体数量和世代数量较少时,算法的串行版本具有更好的执行时间,但是当个体数量或世代数量增加时,并发版本具有更好的吞吐量。例如,对于包含 1,000 代和 10,000 个体的kn57数据集,加速比为:

摘要
在本章中,我们解释了 Java 并发 API 提供的最强大的同步机制之一:phaser。其主要目标是在执行分阶段算法的任务之间提供同步。在其余任务完成前,没有一个任务可以开始执行下一个阶段。
Phaser 必须知道有多少任务需要同步。您必须使用构造函数、bulkRegister()方法或register()方法在 phaser 中注册您的任务。
任务可以以不同的方式与 phaser 同步。最常见的是使用arriveAndAwaitAdvance()方法通知 phaser 已经完成一个阶段的执行,并希望继续下一个阶段。此方法将使线程休眠,直到其余任务完成当前阶段。但是还有其他方法可以用来同步您的任务。arrive()方法用于通知 phaser 您已经完成当前阶段,但不等待其余任务(使用此方法时要非常小心)。arriveAndDeregister()方法用于通知 phaser 您已经完成当前阶段,并且不希望继续在 phaser 中(通常是因为您已经完成了工作)。最后,awaitAdvance()方法可用于等待当前阶段的完成。
您可以使用onAdvance()方法控制相位变化,并在所有任务完成当前阶段并开始新阶段之前执行代码。此方法在两个阶段的执行之间调用,并接收相位编号和相位中参与者的数量作为参数。您可以扩展Phaser类并覆盖此方法以在两个阶段之间执行代码。
Phaser 可以处于两种状态:活动状态,当它正在同步任务时;终止状态,当它完成了其工作时。当所有参与者调用arriveAndDeregister()方法或onAdvance()方法返回true值时(默认情况下,它总是返回false),Phaser 将进入终止状态。当Phaser类处于终止状态时,它将不再接受新的参与者,并且同步方法将立即返回。
我们使用Phaser类来实现两种算法:关键词提取算法和遗传算法。在这两种情况下,我们都得到了与这些算法的串行版本相比的重要吞吐量增加。
在下一章中,您将学习如何使用另一个 Java 并发框架来解决特殊类型的问题。这就是 Fork/Join 框架,它已经被开发出来以并发方式执行那些可以使用分而治之算法解决的问题。它基于具有特殊工作窃取算法的执行程序,以最大化执行程序的性能。
第六章:优化分而治之解决方案-Fork/Join 框架
在第二章中,管理大量线程-执行者,第三章,从执行者中获得最大效益,和第四章,从任务中获取数据-Callable 和 Future 接口,您学会了如何使用执行者作为一种机制来提高并发应用程序的性能,执行大量并发任务。Java 7 并发 API 引入了一种特殊类型的执行者,通过 Fork/Join 框架。该框架旨在实现使用分而治之设计范例解决问题的最佳并发解决方案。在本章中,我们将涵盖以下主题:
-
Fork/Join 框架简介
-
第一个示例- k 均值聚类算法
-
第二个示例-数据过滤算法
-
第三个示例-归并排序算法
Fork/Join 框架简介
在 Java 5 中引入的执行者框架提供了一种执行并发任务的机制,而无需创建、启动和完成线程。该框架使用一个线程池来执行您发送给执行者的任务,并重用它们执行多个任务。这种机制为程序员提供了一些优势,如下所示:
-
编写并发应用程序更容易,因为您不必担心创建线程。
-
更容易控制执行者和应用程序使用的资源。您可以创建一个只使用预定义数量线程的执行者。如果发送更多任务,执行者会将它们存储在队列中,直到有线程可用。
-
执行者通过重用线程减少了线程创建引入的开销。在内部,它管理一个线程池,重用线程执行多个任务。
分而治之算法是一种非常流行的设计技术。使用这种技术解决问题,您将其分解为更小的问题。您以递归方式重复这个过程,直到您要解决的问题足够小,可以直接解决。这种类型的问题可以使用执行者解决,但为了以更有效的方式解决它们,Java 7 并发 API 引入了 Fork/Join 框架。
该框架基于ForkJoinPool类,这是一种特殊类型的执行者,两个操作,fork()和join()方法(及其不同的变体),以及一个名为工作窃取算法的内部算法。在本章中,您将学习 Fork/Join 框架的基本特征、限制和组件,实现以下三个示例:
-
应用于一组文档聚类的 k 均值聚类算法
-
一个数据过滤算法,以获取符合某些条件的数据
-
归并排序算法以高效的方式对大量数据进行排序
Fork/Join 框架的基本特征
正如我们之前提到的,Fork/Join 框架必须用于实现基于分而治之技术的问题的解决方案。您必须将原始问题分解为更小的问题,直到它们足够小,可以直接解决。使用该框架,您将实现主要方法类似于以下内容的任务:
if ( problem.size() > DEFAULT_SIZE) {
divideTasks();
executeTask();
taskResults=joinTasksResult();
return taskResults;
} else {
taskResults=solveBasicProblem();
return taskResults;
}
最重要的部分是允许您以高效的方式分割和执行子任务,并获取这些子任务的结果以计算父任务的结果。这个功能由ForkJoinTask类提供的两个方法支持,如下所示:
-
fork()方法:此方法允许您向 Fork/Join 执行者发送子任务 -
join()方法:此方法允许您等待子任务的完成并返回其结果
这些方法有不同的变体,正如您将在示例中看到的那样。Fork/Join 框架还有另一个关键部分:工作窃取算法,它确定要执行哪些任务。当一个任务正在等待使用 join()方法等待子任务的完成时,执行该任务的线程会从等待的任务池中取出另一个任务并开始执行。这样,Fork/Join 执行器的线程总是通过执行任务来提高应用程序的性能。
Java 8 在 Fork/Join 框架中包含了一个新特性。现在每个 Java 应用程序都有一个名为 common pool 的默认 ForkJoinPool。您可以通过调用 ForkJoinPool.commonPool()静态方法来获取它。您不需要显式创建一个(尽管您可以)。这个默认的 Fork/Join 执行器将默认使用计算机可用处理器确定的线程数。您可以通过更改系统属性 java.util.concurrent.ForkJoinPool.common.parallelism 的值来更改此默认行为。
Java API 的一些特性使用 Fork/Join 框架来实现并发操作。例如,Arrays 类的 parallelSort()方法以并行方式对数组进行排序,以及 Java 8 中引入的并行流(稍后将在第七章和第八章中描述)使用了这个框架。
Fork/Join 框架的限制
由于 Fork/Join 框架被设计用来解决一种确定类型的问题,因此在使用它来实现您的问题时,您必须考虑一些限制,如下所示:
-
您不打算细分的基本问题不应该太大,但也不应该太小。根据 Java API 文档,它应该在 100 到 10,000 个基本计算步骤之间。
-
您不应该使用阻塞 I/O 操作,比如读取用户输入或等待网络套接字中的数据可用。这样的操作会导致 CPU 核心空闲,降低并行级别,因此您将无法实现完全的性能。
-
您不能在任务中抛出已检查的异常。您必须包含处理它们的代码(例如,包装成未检查的 RuntimeException)。未检查的异常有特殊处理,正如您将在示例中看到的那样。
Fork/Join 框架的组件
Fork/Join 框架中有五个基本类:
-
ForkJoinPool 类:该类实现了 Executor 和 ExecutorService 接口,它是您要使用来执行 Fork/Join 任务的 Executor 接口。Java 为您提供了一个默认的 ForkJoinPool 对象(名为 common pool),但如果您愿意,您可以使用一些构造函数来创建一个。您可以指定并行级别(最大运行并行线程数)。默认情况下,它使用可用处理器的数量作为并发级别。
-
ForkJoinTask类:这是所有 Fork/Join 任务的基本抽象类。它是一个抽象类,提供了fork()和join()方法以及它们的一些变体。它还实现了Future接口,并提供了方法来确定任务是否以正常方式完成,是否被取消,或者是否抛出未检查的异常。RecursiveTask、RecursiveAction和CountedCompleter类提供了compute()抽象方法,应该在子类中实现以执行实际的计算。 -
RecursiveTask类:这个类扩展了ForkJoinTask类。它也是一个抽象类,应该是实现返回结果的 Fork/Join 任务的起点。 -
RecursiveAction类:这个类扩展了ForkJoinTask类。它也是一个抽象类,应该是实现不返回结果的 Fork/Join 任务的起点。 -
CountedCompleter类:这个类扩展了ForkJoinTask类。这是 Java 8 API 的一个新特性,应该是实现任务在完成时触发其他任务的起点。
第一个例子 - k 均值聚类算法
k 均值聚类算法是一种聚类算法,用于将一组未经分类的项目分组到预定义数量的 k 个集群中。在数据挖掘和机器学习领域非常受欢迎,以无监督的方式组织和分类数据。
每个项目通常由一组特征或属性的向量来定义。所有项目具有相同数量的属性。每个集群也由具有相同数量属性的向量来定义,表示所有分类到该集群的项目。这个向量被称为质心。例如,如果项目由数值向量定义,那么集群由分类到该集群的项目的平均值来定义。
基本上,这个算法有四个步骤:
-
初始化:在第一步中,你需要创建代表 K 个集群的初始向量。通常,你会随机初始化这些向量。
-
分配:然后,你将每个项目分类到一个集群中。为了选择集群,你需要计算项目与每个集群之间的距离。你将使用欧几里得距离作为距离度量来计算代表项目的向量与代表集群的向量之间的距离。你将把项目分配给距离最短的集群。
-
更新:一旦所有项目被分类,你需要重新计算定义每个集群的向量。正如我们之前提到的,通常计算分类到集群的所有向量的平均值。
-
结束:最后,你要检查是否有任何项目改变了分配的集群。如果有任何改变,你需要再次进行分配步骤。否则,算法结束,你的项目被分类了。
这个算法有以下两个主要限制:
-
如果你对集群的初始向量进行随机初始化,就像我们之前建议的那样,对同一组项目进行两次执行可能会得到不同的结果。
-
集群的数量是预先定义的。选择这个属性不好会导致分类结果不佳。
尽管如此,该算法非常受欢迎,可用于对不同类型的项目进行聚类。为了测试我们的算法,您将实现一个应用程序来对一组文档进行聚类。作为文档集合,我们使用了我们在第四章中介绍的有关电影语料库的维基百科页面的缩减版本,从任务获取数据 - Callable 和 Future 接口。我们只取了 1,000 个文档。为了表示每个文档,我们必须使用向量空间模型表示。通过这种表示,每个文档都表示为一个数值向量,其中向量的每个维度表示一个单词或术语,其值是定义该单词或术语在文档中重要性的度量。
当您使用向量空间模型表示文档集合时,向量的维度将与整个集合中不同单词的数量一样多,因此向量将具有许多零值,因为每个文档并不包含所有单词。您可以使用更优化的内存表示来避免所有这些零值,并节省内存,从而提高应用程序的性能。
在我们的情况下,我们选择词项频率-逆文档频率(tf-idf)作为定义每个词的重要性的度量标准,并选择具有更高 tf-idf 的 50 个词作为代表每个文档的词语。
我们使用两个文件:movies.words文件存储了向量中使用的所有单词的列表,而movies.data存储了每个文档的表示。movies.data文件的格式如下:
10000202,rabona:23.039285705435507,1979:8.09314752937111,argentina:7.953798614698405,la:5.440565539075689,argentine:4.058577338363469,editor:3.0401515284855267,spanish:2.9692083275217134,image_size:1.3701158713905104,narrator:1.1799670194306195,budget:0.286193223652206,starring:0.25519156764102785,cast:0.2540127604060545,writer:0.23904044207902764,distributor:0.20430284744786784,cinematography:0.182583823735518,music:0.1675671228903468,caption:0.14545085918028047,runtime:0.127767002869991,country:0.12493801913495534,producer:0.12321749670640451,director:0.11592975672109682,links:0.07925582303812376,image:0.07786973207561361,external:0.07764427108746134,released:0.07447174080087617,name:0.07214163435745059,infobox:0.06151153983466272,film:0.035415118094854446
在这里,10000202是文档的标识符,文件的其余部分遵循word:tfxidf的格式。
与其他示例一样,我们将实现串行和并发版本,并执行两个版本以验证 Fork/Join 框架是否提高了该算法的性能。
常见的类
串行和并发版本之间有一些共享的部分。这些部分包括:
-
VocabularyLoader:这是一个加载构成我们语料库词汇表的单词列表的类。 -
Word,Document和DocumentLoader:这三个类用于加载有关文档的信息。这些类在串行和并发版本的算法之间有一些差异。 -
DistanceMeasure:这是一个计算两个向量之间的欧几里得距离的类。 -
DocumentCluster:这是一个存储有关聚类信息的类。
让我们详细看看这些类。
VocabularyLoader 类
正如我们之前提到的,我们的数据存储在两个文件中。其中一个文件是movies.words文件。该文件存储了文档中使用的所有单词的列表。VocabularyLoader类将该文件转换为HashMap。HashMap的键是整个单词,值是该单词在列表中的索引的整数值。我们使用该索引来确定表示每个文档的向量空间模型中单词的位置。
该类只有一个名为load()的方法,该方法接收文件路径作为参数并返回HashMap:
public class VocabularyLoader {
public static Map<String, Integer> load (Path path) throws IOException {
int index=0;
HashMap<String, Integer> vocIndex=new HashMap<String, Integer>();
try(BufferedReader reader = Files.newBufferedReader(path)){
String line = null;
while ((line = reader.readLine()) != null) {
vocIndex.put(line,index );
index++;
}
}
return vocIndex;
}
}
Word,Document 和 DocumentLoader 类
这些类存储了我们算法中将使用的所有文档信息。首先,Word类存储了文档中单词的信息。它包括单词的索引和文档中该单词的 tf-idf。该类仅包括这些属性(分别为int和double),并实现了Comparable接口,以使用它们的 tf-idf 值对两个单词进行排序,因此我们不包括此类的源代码。
Document类存储有关文档的所有相关信息。首先是一个包含文档中单词的Word对象数组。这是我们的向量空间模型的表示。我们只存储文档中使用的单词,以节省大量内存空间。然后是一个包含存储文档的文件名的String,最后是一个DocumentCluster对象,用于知道与文档关联的聚类。它还包括一个用于初始化这些属性的构造函数和用于获取和设置它们的值的方法。我们只包括setCluster()方法的代码。在这种情况下,此方法将返回一个布尔值,以指示此属性的新值是否与旧值相同或新值。我们将使用该值来确定是否停止算法:
public boolean setCluster(DocumentCluster cluster) {
if (this.cluster == cluster) {
return false;
} else {
this.cluster = cluster;
return true;
}
}
最后,DocumentLoader类加载有关文档的信息。它包括一个静态方法load(),该方法接收文件的路径和包含词汇表的HashMap,并返回Document对象的Array。它逐行加载文件并将每行转换为Document对象。我们有以下代码:
public static Document[] load(Path path, Map<String, Integer> vocIndex) throws IOException{
List<Document> list = new ArrayList<Document>();
try(BufferedReader reader = Files.newBufferedReader(path)) {
String line = null;
while ((line = reader.readLine()) != null) {
Document item = processItem(line, vocIndex);
list.add(item);
}
}
Document[] ret = new Document[list.size()];
return list.toArray(ret);
}
要将文本文件的一行转换为Document对象,我们使用processItem()方法:
private static Document processItem(String line,Map<String, Integer> vocIndex) {
String[] tokens = line.split(",");
int size = tokens.length - 1;
Document document = new Document(tokens[0], size);
Word[] data = document.getData();
for (int i = 1; i < tokens.length; i++) {
String[] wordInfo = tokens[i].split(":");
Word word = new Word();
word.setIndex(vocIndex.get(wordInfo[0]));
word.setTfidf(Double.parseDouble(wordInfo[1]));
data[i - 1] = word;
}
Arrays.sort(data);
return document;
}
正如我们之前提到的,行中的第一项是文档的标识符。我们从tokens[0]获取它,并将其传递给Document类的构造函数。然后,对于其余的标记,我们再次拆分它们以获取每个单词的信息,包括整个单词和 tf-idf 值。
DistanceMeasurer类
该类计算文档与聚类(表示为向量)之间的欧氏距离。在对我们的单词数组进行排序后,单词按照与质心数组相同的顺序排列,但有些单词可能不存在。对于这样的单词,我们假设 tf-idf 为零,因此距离就是来自质心数组的相应值的平方:
public class DistanceMeasurer {
public static double euclideanDistance(Word[] words, double[] centroid) {
double distance = 0;
int wordIndex = 0;
for (int i = 0; i < centroid.length; i++) {
if ((wordIndex < words.length) (words[wordIndex].getIndex() == i)) {
distance += Math.pow( (words[wordIndex].getTfidf() - centroid[i]), 2);
wordIndex++;
} else {
distance += centroid[i] * centroid[i];
}
}
return Math.sqrt(distance);
}
}
文档聚类类
该类存储算法生成的每个聚类的信息。此信息包括与该聚类关联的所有文档的列表以及表示该聚类的向量的质心。在这种情况下,该向量的维度与词汇表中的单词数量相同。该类具有两个属性,一个用于初始化它们的构造函数,以及用于获取和设置它们的值的方法。它还包括两个非常重要的方法。首先是calculateCentroid()方法。它计算聚类的质心,作为表示与该聚类关联的文档的向量的平均值。我们有以下代码:
public void calculateCentroid() {
Arrays.fill(centroid, 0);
for (Document document : documents) {
Word vector[] = document.getData();
for (Word word : vector) {
centroid[word.getIndex()] += word.getTfidf();
}
}
for (int i = 0; i < centroid.length; i++) {
centroid[i] /= documents.size();
}
}
第二种方法是initialize()方法,它接收一个Random对象,并使用随机数初始化聚类的质心向量如下:
public void initialize(Random random) {
for (int i = 0; i < centroid.length; i++) {
centroid[i] = random.nextDouble();
}
}
串行版本
一旦我们描述了应用程序的共同部分,让我们看看如何实现 k-means 聚类算法的串行版本。我们将使用两个类:SerialKMeans,它实现了该算法,以及SerialMain,它实现了执行该算法的main()方法。
SerialKMeans类
SerialKMeans类实现了 k-means 聚类算法的串行版本。该类的主要方法是calculate()方法。它接收以下参数:
-
包含有关文档的
Document对象的数组 -
您想要生成的聚类数
-
词汇表的大小
-
随机数生成器的种子
该方法返回DocumentCluster对象的Array。每个聚类将有与之关联的文档列表。首先,文档通过numberClusters参数确定Array的聚类,并使用initialize()方法和Random对象对它们进行初始化,如下所示:
public class SerialKMeans {
public static DocumentCluster[] calculate(Document[] documents, int clusterCount, int vocSize, int seed) {
DocumentCluster[] clusters = new DocumentCluster[clusterCount];
Random random = new Random(seed);
for (int i = 0; i < clusterCount; i++) {
clusters[i] = new DocumentCluster(vocSize);
clusters[i].initialize(random);
}
然后,我们重复分配和更新阶段,直到所有文档都留在同一个集群中。最后,我们返回具有文档最终组织的集群数组如下:
boolean change = true;
int numSteps = 0;
while (change) {
change = assignment(clusters, documents);
update(clusters);
numSteps++;
}
System.out.println("Number of steps: "+numSteps);
return clusters;
}
分配阶段在assignment()方法中实现。该方法接收Document和DocumentCluster对象数组。对于每个文档,它计算文档与所有集群之间的欧几里德距离,并将文档分配给距离最近的集群。它返回一个布尔值,指示一个或多个文档是否从一步到下一步更改了其分配的集群。我们有以下代码:
private static boolean assignment(DocumentCluster[] clusters, Document[] documents) {
boolean change = false;
for (DocumentCluster cluster : clusters) {
cluster.clearClusters();
}
int numChanges = 0;
for (Document document : documents) {
double distance = Double.MAX_VALUE;
DocumentCluster selectedCluster = null;
for (DocumentCluster cluster : clusters) {
double curDistance = DistanceMeasurer.euclideanDistance(document.getData(), cluster.getCentroid());
if (curDistance < distance) {
distance = curDistance;
selectedCluster = cluster;
}
}
selectedCluster.addDocument(document);
boolean result = document.setCluster(selectedCluster);
if (result)
numChanges++;
}
System.out.println("Number of Changes: " + numChanges);
return numChanges > 0;
}
更新步骤在update()方法中实现。它接收具有集群信息的DocumentCluster数组,并简单地重新计算每个集群的质心。
private static void update(DocumentCluster[] clusters) {
for (DocumentCluster cluster : clusters) {
cluster.calculateCentroid();
}
}
}
SerialMain类包括main()方法来启动 k-means 算法的测试。首先,它从文件中加载数据(单词和文档):
public class SerialMain {
public static void main(String[] args) {
Path pathVoc = Paths.get("data", "movies.words");
Map<String, Integer> vocIndex=VocabularyLoader.load(pathVoc);
System.out.println("Voc Size: "+vocIndex.size());
Path pathDocs = Paths.get("data", "movies.data");
Document[] documents = DocumentLoader.load(pathDocs, vocIndex);
System.out.println("Document Size: "+documents.length);
然后,它初始化我们要生成的集群数量和随机数生成器的种子。如果它们不作为main()方法的参数传入,我们将使用默认值如下:
if (args.length != 2) {
System.err.println("Please specify K and SEED");
return;
}
int K = Integer.valueOf(args[0]);
int SEED = Integer.valueOf(args[1]);
}
最后,我们启动算法,测量其执行时间,并写入每个集群的文档数量。
Date start, end;
start=new Date();
DocumentCluster[] clusters = SerialKMeans.calculate(documents, K ,vocIndex.size(), SEED);
end=new Date();
System.out.println("K: "+K+"; SEED: "+SEED);
System.out.println("Execution Time: "+(end.getTime()- start.getTime()));
System.out.println(
Arrays.stream(clusters).map (DocumentCluster::getDocumentCount).sorted (Comparator.reverseOrder())
.map(Object::toString).collect( Collectors.joining(", ", "Cluster sizes: ", "")));
}
}
并发版本
为了实现算法的并发版本,我们使用了 Fork/Join 框架。我们基于RecursiveAction类实现了两个不同的任务。正如我们之前提到的,当您希望使用 Fork/Join 框架处理不返回结果的任务时,我们实现了分配和更新阶段作为要在 Fork/Join 框架中执行的任务。
为了实现 k-means 算法的并发版本,我们将修改一些常见类以使用并发数据结构。然后,我们将实现两个任务,最后,我们将实现实现算法的并发版本的ConcurrentKMeans和用于测试的ConcurrentMain类。
Fork/Join 框架的两个任务 - AssignmentTask 和 UpdateTask
正如我们之前提到的,我们已经将分配和更新阶段实现为 Fork/Join 框架中要实现的任务。
分配阶段将文档分配给与文档具有最小欧几里德距离的集群。因此,我们必须处理所有文档并计算所有文档和所有集群的欧几里德距离。我们将使用任务需要处理的文档数量作为控制是否需要拆分任务的度量标准。我们从需要处理所有文档的任务开始,直到我们将它们拆分为需要处理小于预定义大小的文档数量的任务。
AssignmentTask类具有以下属性:
-
具有集群数据的
ConcurrentDocumentCluster对象数组 -
具有文档数据的
ConcurrentDocument对象数组 -
有两个整数属性
start和end,确定任务需要处理的文档数量 -
一个
AtomicInteger属性numChanges,存储从上次执行到当前执行更改其分配的集群的文档数量 -
一个整数属性
maxSize,存储任务可以处理的最大文档数量
我们已经实现了一个构造函数来初始化所有这些属性和方法来获取和设置它的值。
这些任务的主要方法(与每个任务一样)是compute()方法。首先,我们检查任务需要处理的文档数量。如果小于或等于maxSize属性,则处理这些文档。我们计算每个文档与所有聚类之间的欧氏距离,并选择距离最小的聚类。如果有必要,我们使用incrementAndGet()方法增加numChanges原子变量。原子变量可以在不使用同步机制的情况下由多个线程同时更新,而不会导致任何内存不一致。参考以下代码:
protected void compute() {
if (end - start <= maxSize) {
for (int i = start; i < end; i++) {
ConcurrentDocument document = documents[i];
double distance = Double.MAX_VALUE;
ConcurrentDocumentCluster selectedCluster = null;
for (ConcurrentDocumentCluster cluster : clusters) {
double curDistance = DistanceMeasurer.euclideanDistance (document.getData(), cluster.getCentroid());
if (curDistance < distance) {
distance = curDistance;
selectedCluster = cluster;
}
}
selectedCluster.addDocument(document);
boolean result = document.setCluster(selectedCluster);
if (result) {
numChanges.incrementAndGet();
}
}
如果任务需要处理的文档数量太大,我们将该集合分成两部分,并创建两个新任务来处理每一部分,如下所示:
} else {
int mid = (start + end) / 2;
AssignmentTask task1 = new AssignmentTask(clusters, documents, start, mid, numChanges, maxSize);
AssignmentTask task2 = new AssignmentTask(clusters, documents, mid, end, numChanges, maxSize);
invokeAll(task1, task2);
}
}
为了在 Fork/Join 池中执行这些任务,我们使用了invokeAll()方法。该方法将在任务完成执行时返回。
更新阶段重新计算每个聚类的质心作为所有文档的平均值。因此,我们必须处理所有聚类。我们将使用任务需要处理的聚类数量作为控制任务是否需要分割的度量。我们从需要处理所有聚类的任务开始,并将其分割,直到我们有需要处理的聚类数量低于预定义大小的任务。
UpdateTask类具有以下属性:
-
包含聚类数据的
ConcurrentDocumentCluster对象数组 -
确定任务需要处理的聚类数量的整数属性
start和end -
一个整数属性
maxSize,用于存储任务可以处理的最大聚类数
我们已经实现了一个构造函数来初始化所有这些属性和方法来获取和设置其值。
compute()方法首先检查任务需要处理的聚类数量。如果该数量小于或等于maxSize属性,则处理这些聚类并更新它们的质心。
@Override
protected void compute() {
if (end - start <= maxSize) {
for (int i = start; i < end; i++) {
ConcurrentDocumentCluster cluster = clusters[i];
cluster.calculateCentroid();
}
如果任务需要处理的聚类数量太大,我们将把任务需要处理的聚类集合分成两部分,并创建两个任务来处理每一部分,如下所示:
} else {
int mid = (start + end) / 2;
UpdateTask task1 = new UpdateTask(clusters, start, mid, maxSize);
UpdateTask task2 = new UpdateTask(clusters, mid, end, maxSize);
invokeAll(task1, task2);
}
}
并发 K 均值类
ConcurrentKMeans类实现了并发版本的 k 均值聚类算法。与串行版本一样,该类的主要方法是calculate()方法。它接收以下参数:
-
包含有关文档信息的
ConcurrentDocument对象数组 -
您想要生成的聚类数量
-
词汇量的大小
-
随机数生成器的种子
-
Fork/Join 任务在不分割任务的情况下处理的最大项目数
calculate()方法返回一个包含聚类信息的ConcurrentDocumentCluster对象数组。每个聚类都有与之关联的文档列表。首先,文档根据numberClusters参数创建聚类数组,并使用initialize()方法和Random对象进行初始化:
public class ConcurrentKMeans {
public static ConcurrentDocumentCluster[] calculate(ConcurrentDocument[] documents int numberCluster int vocSize, int seed, int maxSize) {
ConcurrentDocumentCluster[] clusters = new ConcurrentDocumentCluster[numberClusters];
Random random = new Random(seed);
for (int i = 0; i < numberClusters; i++) {
clusters[i] = new ConcurrentDocumentCluster(vocSize);
clusters[i].initialize(random);
}
然后,我们重复分配和更新阶段,直到所有文档都留在同一个聚类中。在循环之前,我们创建一个将执行该任务及其所有子任务的ForkJoinPool。一旦循环结束,与其他Executor对象一样,我们必须使用shutdown()方法来结束 Fork/Join 池的执行。最后,我们返回具有文档最终组织的聚类数组:
boolean change = true;
ForkJoinPool pool = new ForkJoinPool();
int numSteps = 0;
while (change) {
change = assignment(clusters, documents, maxSize, pool);
update(clusters, maxSize, pool);
numSteps++;
}
pool.shutdown();
System.out.println("Number of steps: "+numSteps); return clusters;
}
分配阶段在assignment()方法中实现。该方法接收聚类数组、文档数组和maxSize属性。首先,我们删除所有聚类的关联文档列表:
private static boolean assignment(ConcurrentDocumentCluster[] clusters, ConcurrentDocument[] documents, int maxSize, ForkJoinPool pool) {
boolean change = false;
for (ConcurrentDocumentCluster cluster : clusters) {
cluster.clearDocuments();
}
然后,我们初始化必要的对象:一个AtomicInteger来存储已更改其分配簇的文档数量,以及将开始该过程的AssignmentTask。
AtomicInteger numChanges = new AtomicInteger(0);
AssignmentTask task = new AssignmentTask(clusters, documents, 0, documents.length, numChanges, maxSize);
然后,我们使用ForkJoinPool的execute()方法以异步方式执行池中的任务,并使用AssignmentTask对象的join()方法等待其完成,如下所示:
pool.execute(task);
task.join();
最后,我们检查已更改其分配的簇的文档数量。如果有更改,我们返回true值。否则,我们返回false值。我们有以下代码:
System.out.println("Number of Changes: " + numChanges);
return numChanges.get() > 0;
}
更新阶段在update()方法中实现。它接收簇数组和maxSize参数。首先,我们创建一个UpdateTask对象来更新所有簇。然后,我们在ForkJoinPool对象中执行该任务,方法接收如下参数:
private static void update(ConcurrentDocumentCluster[] clusters, int maxSize, ForkJoinPool pool) {
UpdateTask task = new UpdateTask(clusters, 0, clusters.length, maxSize, ForkJoinPool pool);
pool.execute(task);
task.join();
}
}
ConcurrentMain类
ConcurrentMain类包括main()方法,用于启动 k-means 算法的测试。其代码与SerialMain类相同,但将串行类更改为并发类。
比较解决方案
为了比较这两种解决方案,我们执行了不同的实验,改变了三个不同参数的值。
-
k 参数将确定我们要生成的簇的数量。我们已使用值 5、10、15 和 20 测试了算法。
-
Random数生成器的种子。此种子确定初始质心位置。我们已使用值 1 和 13 测试了算法。 -
对于并发算法,
maxSize参数确定任务在不被拆分为其他任务的情况下可以处理的最大项目(文档或簇)数量。我们已使用值 1、20 和 400 测试了算法。
我们使用 JMH 框架(openjdk.java.net/projects/code-tools/jmh/)执行了实验,该框架允许在 Java 中实现微基准测试。使用基准测试框架比简单使用currentTimeMillis()或nanoTime()等方法测量时间更好。我们在具有四核处理器的计算机上执行了 10 次,并计算了这 10 次的平均执行时间。以下是我们以毫秒为单位获得的执行时间:
| 串行 | 并发 | ||
|---|---|---|---|
| K | Seed | MaxSize=1 | |
| 5 | 1 | 6676.141 | 4696.414 |
| 10 | 1 | 6780.088 | 3365.731 |
| 15 | 1 | 12936.178 | 5308.734 |
| 20 | 1 | 19824.729 | 7937.820 |
| 5 | 13 | 3738.869 | 2714.325 |
| 10 | 13 | 9567.416 | 4693.164 |
| 15 | 13 | 12427.589 | 5598.996 |
| 20 | 13 | 18157.913 | 7285.565 |
我们可以得出以下结论:
-
种子对执行时间有重要且不可预测的影响。有时,种子 13 的执行时间较低,但其他时候种子 1 的执行时间较低。
-
当增加簇的数量时,执行时间也会增加。
-
maxSize参数对执行时间影响不大。参数 K 或 seed 对执行时间影响更大。如果增加参数值,将获得更好的性能。1 和 20 之间的差异比 20 和 400 之间的差异更大。 -
在所有情况下,并发版本的算法性能均优于串行版本。
例如,如果我们将参数 K=20 和 seed=13 的串行算法与参数 K=20、seed=13 和 maxSize=400 的并发版本进行比较,使用加速比,我们将获得以下结果:

第二个例子 - 数据过滤算法
假设您有大量描述物品列表的数据。例如,您有很多人的属性(姓名、姓氏、地址、电话号码等)。通常需要获取满足某些条件的数据,例如,您想获取住在特定街道或具有特定姓名的人的数据。
在这一部分,您将实现其中一个过滤程序。我们使用了 UCI 的Census-Income KDD数据集(您可以从archive.ics.uci.edu/ml/datasets/Census-Income+%28KDD%29下载),其中包含了从美国人口普查局 1994 年和 1995 年进行的加权人口普查数据。
在这个示例的并发版本中,您将学习如何取消在 Fork/Join 池中运行的任务,以及如何处理任务中可能抛出的未经检查的异常。
共同部分
我们已经实现了一些类来从文件中读取数据和过滤数据。这些类被算法的串行和并发版本使用。这些类包括:
-
CensusData类:这个类存储了定义每个人的 39 个属性。它定义了获取和设置它们值的属性和方法。我们将通过数字来标识每个属性。这个类的evaluateFilter()方法包含了数字和属性名称之间的关联。您可以查看文件archive.ics.uci.edu/ml/machine-learning-databases/census-income-mld/census-income.names来获取每个属性的详细信息。 -
CensusDataLoader类:这个类从文件中加载人口普查数据。它有一个load()方法,接收文件路径作为输入参数,并返回一个包含文件中所有人的信息的CensusData数组。 -
FilterData类:这个类定义了数据的过滤器。过滤器包括属性的编号和属性的值。 -
Filter类:这个类实现了确定CensusData对象是否满足一系列过滤条件的方法。
我们不包括这些类的源代码。它们非常简单,您可以查看示例的源代码。
串行版本
我们已经在两个类中实现了过滤算法的串行版本。SerialSearch类进行数据过滤。它提供了两种方法:
-
findAny()方法:它接收CensusData对象数组作为参数,其中包含来自文件的所有数据,以及一系列过滤器,并返回一个CensusData对象,其中包含满足所有过滤器条件的第一个人的数据。 -
findAll()方法:它接收CensusData对象数组作为参数,其中包含来自文件的所有数据,以及一系列过滤器,并返回一个CensusData对象数组,其中包含满足所有过滤器条件的所有人的数据。
SerialMain类实现了这个版本的main()方法,并对其进行了测试,以测量在某些情况下该算法的执行时间。
SerialSearch 类
如前所述,这个类实现了数据的过滤。它提供了两种方法。第一个方法findAny()查找满足过滤器条件的第一个数据对象。当它找到第一个数据对象时,它就结束了执行。参考以下代码:
public class SerialSearch {
public static CensusData findAny (CensusData[] data, List<FilterData> filters) {
int index=0;
for (CensusData censusData : data) {
if (Filter.filter(censusData, filters)) {
System.out.println("Found: "+index);
return censusData;
}
index++;
}
return null;
}
第二个方法findAll()返回一个CensusData对象数组,其中包含满足过滤器条件的所有对象,如下所示:
public static List<CensusData> findAll (CensusData[] data, List<FilterData> filters) {
List<CensusData> results=new ArrayList<CensusData>();
for (CensusData censusData : data) {
if (Filter.filter(censusData, filters)) {
results.add(censusData);
}
}
return results;
}
}
SerialMain 类
您将使用这个类来测试不同情况下的过滤算法。首先,我们从文件中加载数据,如下所示:
public class SerialMain {
public static void main(String[] args) {
Path path = Paths.get("data","census-income.data");
CensusData data[]=CensusDataLoader.load(path);
System.out.println("Number of items: "+data.length);
Date start, end;
我们要测试的第一种情况是使用findAny()方法来查找数组的前几个位置中存在的对象。您构建一个过滤器列表,然后使用文件的数据和过滤器列表调用findAny()方法:
List<FilterData> filters=new ArrayList<>();
FilterData filter=new FilterData();
filter.setIdField(32);
filter.setValue("Dominican-Republic");
filters.add(filter);
filter=new FilterData();
filter.setIdField(31);
filter.setValue("Dominican-Republic");
filters.add(filter);
filter=new FilterData();
filter.setIdField(1);
filter.setValue("Not in universe");
filters.add(filter);
filter=new FilterData();
filter.setIdField(14);
filter.setValue("Not in universe");
filters.add(filter);
start=new Date();
CensusData result=SerialSearch.findAny(data, filters);
System.out.println("Test 1 - Result: "+result.getReasonForUnemployment());
end=new Date();
System.out.println("Test 1- Execution Time: "+(end.getTime()-start.getTime()));
我们的过滤器寻找以下属性:
-
32:这是出生父亲的国家属性 -
31:这是出生母亲的国家属性 -
1:这是工人属性的类;Not in universe是它们可能的值之一 -
14:这是失业原因属性;Not in universe是它们可能的值之一
我们将按以下方式测试其他情况:
-
使用
findAny()方法查找数组中最后几个位置中存在的对象 -
使用
findAny()方法尝试查找一个不存在的对象 -
在错误情况下使用
findAny()方法 -
使用
findAll()方法获取满足一系列过滤器的所有对象 -
在错误情况下使用
findAll()方法
并发版本
我们将在我们的并发版本中包含更多元素:
-
任务管理器:当您使用 Fork/Join 框架时,您从一个任务开始,然后将该任务分成两个(或更多)子任务,然后再次分割,直到您的问题达到所需的大小。有时您希望完成所有这些任务的执行。例如,当您实现
findAny()方法并找到满足所有条件的对象时,您就不需要继续执行其余任务。 -
一个
RecursiveTask类来实现findAny()方法:它是扩展了RecursiveTask的IndividualTask类。 -
一个
RecursiveTask类来实现findAll()方法:它是扩展了RecursiveTask的ListTask类。
让我们看看所有这些类的细节。
任务管理器类
我们将使用这个类来控制任务的取消。我们将在以下两种情况下取消任务的执行:
-
您正在执行
findAny()操作,并且找到一个满足要求的对象 -
您正在执行
findAny()或findAll()操作,并且其中一个任务出现了未经检查的异常
该类声明了两个属性:ConcurrentLinkedDeque用于存储我们需要取消的所有任务,以及AtomicBoolean变量来保证只有一个任务执行cancelTasks()方法:
public class TaskManager {
private Set<RecursiveTask> tasks;
private AtomicBoolean cancelled;
public TaskManager() {
tasks = ConcurrentHashMap.newKeySet();
cancelled = new AtomicBoolean(false);
}
它定义了添加任务到ConcurrentLinkedDeque,从ConcurrentLinkedDeque中删除任务以及取消其中存储的所有任务的方法。要取消任务,我们使用ForkJoinTask类中定义的cancel()方法。如果任务正在运行,则true参数会强制中断任务的执行,如下所示:
public void addTask(RecursiveTask task) {
tasks.add(task);
}
public void cancelTasks(RecursiveTask sourceTask) {
if (cancelled.compareAndSet(false, true)) {
for (RecursiveTask task : tasks) {
if (task != sourceTask) {
if(cancelled.get()) {
task.cancel(true);
}
else {
tasks.add(task);
}
}
}
}
}
public void deleteTask(RecursiveTask task) {
tasks.remove(task);
}
cancelTasks()方法接收一个RecursiveTask对象作为参数。我们将取消除调用此方法的任务之外的所有任务。我们不想取消已经找到结果的任务。compareAndSet(false, true)方法将AtomicBoolean变量设置为true,并且仅当当前值为false时返回true。如果AtomicBoolean变量已经有一个true值,则返回false。整个操作是原子性执行的,因此可以保证即使从不同的线程并发调用cancelTasks()方法多次,if 语句的主体也最多只会执行一次。
个人任务类
IndividualTask类扩展了参数化为CensusData任务的RecursiveTask类,并实现了findAny()操作。它定义了以下属性:
-
一个包含所有
CensusData对象的数组 -
确定它需要处理的元素的
start和end属性 -
size属性确定任务在不分割的情况下将处理的最大元素数量 -
一个
TaskManager类来取消任务(如果有必要) -
以下代码提供了要应用的过滤器列表:
private CensusData[] data;
private int start, end, size;
private TaskManager manager;
private List<FilterData> filters;
public IndividualTask(CensusData[] data, int start, int end, TaskManager manager, int size, List<FilterData> filters) {
this.data = data;
this.start = start;
this.end = end;
this.manager = manager;
this.size = size;
this.filters = filters;
}
该类的主要方法是compute()方法。它返回一个CensusData对象。如果任务需要处理的元素数量少于 size 属性,则直接查找对象。如果方法找到所需的对象,则返回该对象并使用cancelTasks()方法取消其余任务的执行。如果方法找不到所需的对象,则返回 null。我们有以下代码:
if (end - start <= size) {
for (int i = start; i < end && ! Thread.currentThread().isInterrupted(); i++) {
CensusData censusData = data[i];
if (Filter.filter(censusData, filters)) {
System.out.println("Found: " + i);
manager.cancelTasks(this);
return censusData;
}
}
return null;
}
如果它需要处理的项目数量超过 size 属性,则创建两个子任务来处理一半的元素:
} else {
int mid = (start + end) / 2;
IndividualTask task1 = new IndividualTask(data, start, mid, manager, size, filters);
IndividualTask task2 = new IndividualTask(data, mid, end, manager, size, filters);
然后,我们将新创建的任务添加到任务管理器中,并删除实际的任务。如果我们想要取消任务,我们只想取消正在运行的任务:
manager.addTask(task1);
manager.addTask(task2);
manager.deleteTask(this);
然后,我们使用fork()方法将任务发送到ForkJoinPool,以异步方式发送它们,并使用quietlyJoin()方法等待其完成。join()和quietlyJoin()方法之间的区别在于,join()方法在任务被取消或方法内部抛出未检查的异常时会抛出异常,而quietlyJoin()方法不会抛出任何异常。
task1.fork();
task2.fork();
task1.quietlyJoin();
task2.quietlyJoin();
然后,我们按以下方式从TaskManager类中删除子任务:
manager.deleteTask(task1);
manager.deleteTask(task2);
现在,我们使用join()方法获取任务的结果。如果任务抛出未检查的异常,它将被传播而不进行特殊处理,并且取消将被忽略,如下所示:
try {
CensusData res = task1.join();
if (res != null)
return res;
manager.deleteTask(task1);
} catch (CancellationException ex) {
}
try {
CensusData res = task2.join();
if (res != null)
return res;
manager.deleteTask(task2);
} catch (CancellationException ex) {
}
return null;
}
}
ListTask 类
ListTask类扩展了参数为List的CensusData的RecursiveTask类。我们将使用这个任务来实现findAll()操作。它与IndividualTask任务非常相似。两者都使用相同的属性,但在compute()方法中有所不同。
首先,我们初始化一个List对象来返回结果并检查任务需要处理的元素数量。如果任务需要处理的元素数量少于 size 属性,则将满足过滤器指定条件的所有对象添加到结果列表中:
@Override
protected List<CensusData> compute() {
List<CensusData> ret = new ArrayList<CensusData>();
if (end - start <= size) {
for (int i = start; i < end; i++) {
CensusData censusData = data[i];
if (Filter.filter(censusData, filters)) {
ret.add(censusData);
}
}
如果它需要处理的项目数量超过 size 属性,则创建两个子任务来处理一半的元素:
int mid = (start + end) / 2;
ListTask task1 = new ListTask(data, start, mid, manager, size, filters);
ListTask task2 = new ListTask(data, mid, end, manager, size, filters);
然后,我们将新创建的任务添加到任务管理器中,并删除实际的任务。实际任务不会被取消;其子任务将被取消,如下所示:
manager.addTask(task1);
manager.addTask(task2);
manager.deleteTask(this);
然后,我们使用fork()方法将任务发送到ForkJoinPool,以异步方式发送它们,并使用quietlyJoin()方法等待其完成:
task1.fork();
task2.fork();
task2.quietlyJoin();
task1.quietlyJoin();
然后,我们将从TaskManager中删除子任务:
manager.deleteTask(task1);
manager.deleteTask(task2);
现在,我们使用join()方法获取任务的结果。如果任务抛出未检查的异常,它将被传播而不进行特殊处理,并且取消将被忽略:
try {
List<CensusData> tmp = task1.join();
if (tmp != null)
ret.addAll(tmp);
manager.deleteTask(task1);
} catch (CancellationException ex) {
}
try {
List<CensusData> tmp = task2.join();
if (tmp != null)
ret.addAll(tmp);
manager.deleteTask(task2);
} catch (CancellationException ex) {
}
ConcurrentSearch 类
ConcurrentSearch 类实现了findAny()和findAll()方法。它们与串行版本的方法具有相同的接口。在内部,它们初始化了TaskManager对象和第一个任务,并使用execute方法发送到默认的ForkJoinPool;它们等待任务的完成并写入结果。这是findAny()方法的代码:
public class ConcurrentSearch {
public static CensusData findAny (CensusData[] data, List<FilterData> filters, int size) {
TaskManager manager=new TaskManager();
IndividualTask task=new IndividualTask(data, 0, data.length, manager, size, filters);
ForkJoinPool.commonPool().execute(task);
try {
CensusData result=task.join();
if (result!=null) {
System.out.println("Find Any Result: "+result.getCitizenship());
return result;
} catch (Exception e) {
System.err.println("findAny has finished with an error: "+task.getException().getMessage());
}
return null;
}
这是findAll()方法的代码:
public static CensusData[] findAll (CensusData[] data, List<FilterData> filters, int size) {
List<CensusData> results;
TaskManager manager=new TaskManager();
ListTask task=new ListTask(data,0,data.length,manager, size,filters);
ForkJoinPool.commonPool().execute(task);
try {
results=task.join();
return results;
} catch (Exception e) {
System.err.println("findAny has finished with an error: " + task.getException().getMessage());
}
return null;
}
ConcurrentMain 类
ConcurrentMain类用于测试对象过滤的并行版本。它与SerialMain类相同,但使用操作的并行版本。
比较两个版本
比较过滤算法的串行和并行版本,我们在六种不同的情况下对它们进行了测试:
-
测试 1:我们测试
findAny()方法,查找存在于CensusData数组的第一个位置的对象 -
测试 2:我们测试
findAny()方法,查找存在于CensusData数组的最后位置的对象 -
测试 3:我们测试
findAny()方法,查找不存在的对象 -
测试 4:我们测试
findAny()方法在错误情况下 -
测试 5:我们测试
findAll()方法在正常情况下 -
测试 6:我们测试
findAll()方法在错误情况下
对于算法的并发版本,我们测试了确定任务在不分成两个子任务的情况下可以处理的最大元素数量的大小参数的三个不同值。我们测试了 10、200 和 2,000。
我们使用 JMH 框架(openjdk.java.net/projects/code-tools/jmh/)执行了测试,该框架允许您在 Java 中实现微基准测试。使用基准测试框架比仅使用currentTimeMillis()或nanoTime()等方法来测量时间更好。我们在具有四核处理器的计算机上执行了 10 次测试,并计算了这 10 次的平均执行时间。与其他示例一样,我们以毫秒为单位测量执行时间:
| 测试用例 | 串行 | 并发大小=10 | 并发大小=200 | 并发大小=2000 | 最佳 |
|---|---|---|---|---|---|
| 测试 1 | 1.177 | 8.124 | 4.547 | 4.073 | 串行 |
| 测试 2 | 95.237 | 157.412 | 34.581 | 35.691 | 并发 |
| 测试 3 | 66.616 | 41.916 | 74.829 | 37.140 | 并发 |
| 测试 4 | 0.540 | 25869.339 | 643.144 | 9.673 | 串行 |
| 测试 5 | 61.752 | 37.349 | 40.344 | 22.911 | 并发 |
| 测试 6 | 0.802 | 31663.607 | 231.440 | 7.706 | 串行 |
我们可以得出以下结论:
-
算法的串行版本在处理较少数量的元素时性能更好。
-
当我们需要处理所有元素或其中一部分元素时,并发版本的算法性能更好。
-
在错误情况下,串行版本的算法性能优于并发版本。当
size参数的值较小时,并发版本在这种情况下性能非常差。
在这种情况下,并发并不总是能提高性能。
第三个示例 - 归并排序算法
归并排序算法是一种非常流行的排序算法,总是使用分而治之的技术实现,因此它是使用 Fork/Join 框架进行测试的一个很好的候选者。
为了实现归并排序算法,我们将未排序的列表分成一个元素的子列表。然后,我们合并这些未排序的子列表以产生有序的子列表,直到我们处理完所有子列表,我们只剩下原始列表,但其中所有元素都已排序。
为了使我们的算法的并发版本,我们使用了 Java 8 版本引入的新 Fork/Join 任务,CountedCompleter任务。这些任务最重要的特点是它们包括一个方法,在所有子任务完成执行时执行。
为了测试我们的实现,我们使用了亚马逊产品共购买网络元数据(您可以从snap.stanford.edu/data/amazon-meta.html下载)。特别是,我们创建了一个包含 542,184 个产品销售排名的列表。我们将测试我们的算法版本,对这个产品列表进行排序,并将执行时间与Arrays类的sort()和parallelSort()方法进行比较。
共享类
正如我们之前提到的,我们已经构建了一个包含 542,184 个亚马逊产品的列表,其中包含每个产品的信息,包括 ID、标题、组、销售排名、评论数量、相似产品数量和产品所属的类别数量。我们已经实现了AmazonMetaData类来存储产品的信息。这个类声明了必要的属性和获取和设置它们的方法。这个类实现了Comparable接口来比较这个类的两个实例。我们想要按销售排名升序排序元素。为了实现compare()方法,我们使用Long类的compare()方法来比较这两个对象的销售排名,如下所示:
public int compareTo(AmazonMetaData other) {
return Long.compare(this.getSalesrank(), other.getSalesrank());
}
我们还实现了AmazonMetaDataLoader,它提供了load()方法。这个方法接收一个包含数据的文件路径作为参数,并返回一个包含所有产品信息的AmazonMetaData对象数组。
注意
我们不包括这些类的源代码,以便专注于 Fork/Join 框架的特性。
串行版本
我们在SerialMergeSort类中实现了归并排序算法的串行版本,该类实现了算法和SerialMetaData类,并提供了main()方法来测试算法。
SerialMergeSort 类
SerialMergeSort类实现了归并排序算法的串行版本。它提供了mergeSort()方法,接收以下参数:
-
我们想要排序的包含所有数据的数组
-
方法必须处理的第一个元素(包括)
-
方法必须处理的最后一个元素(不包括)
如果方法只需要处理一个元素,它就返回。否则,它会对mergeSort()方法进行两次递归调用。第一次调用将处理元素的前一半,第二次调用将处理元素的后一半。最后,我们调用merge()方法来合并两半元素并得到一个排序好的元素列表:
public void mergeSort (Comparable data[], int start, int end) {
if (end-start < 2) {
return;
}
int middle = (end+start)>>>1;
mergeSort(data,start,middle);
mergeSort(data,middle,end);
merge(data,start,middle,end);
}
我们使用(end+start)>>>1运算符来获取中间元素以分割数组。例如,如果你有 15 亿个元素(在现代内存芯片中并不那么不可能),它仍然适合 Java 数组。然而,(end+start)/2会溢出,导致数组为负数。你可以在googleresearch.blogspot.ru/2006/06/extra-extra-read-all-about-it-nearly.html找到这个问题的详细解释。
merge()方法合并两个元素列表以获得一个排序好的列表。它接收以下参数:
-
我们想要排序的包含所有数据的数组
-
确定我们要合并和排序的数组的两部分(start-mid,mid-end)的三个元素(
start、mid和end)
我们创建一个临时数组来对元素进行排序,对数组中的元素进行排序,处理列表的两部分,并将排序后的列表存储在原始数组的相同位置。检查以下代码:
private void merge(Comparable[] data, int start, int middle, int end) {
int length=end-start+1;
Comparable[] tmp=new Comparable[length];
int i, j, index;
i=start;
j=middle;
index=0;
while ((i<middle) && (j<end)) {
if (data[i].compareTo(data[j])<=0) {
tmp[index]=data[i];
i++;
} else {
tmp[index]=data[j];
j++;
}
index++;
}
while (i<middle) {
tmp[index]=data[i];
i++;
index++;
}
while (j<end) {
tmp[index]=data[j];
j++;
index++;
}
for (index=0; index < (end-start); index++) {
data[index+start]=tmp[index];
}
}
}
SerialMetaData 类
SerialMetaData类提供了main()方法来测试算法。我们将执行每种排序算法 10 次,以计算平均执行时间。首先,我们从文件中加载数据并创建数组的副本:
public class SerialMetaData {
public static void main(String[] args) {
for (int j=0; j<10; j++) {
Path path = Paths.get("data","amazon-meta.csv");
AmazonMetaData[] data = AmazonMetaDataLoader.load(path);
AmazonMetaData data2[] = data.clone();
然后,我们使用Arrays类的sort()方法对第一个数组进行排序:
Date start, end;
start = new Date();
Arrays.sort(data);
end = new Date();
System.out.println("Execution Time Java Arrays.sort(): " + (end.getTime() - start.getTime()));
然后,我们使用自己实现的归并排序算法对第二个数组进行排序:
SerialMergeSort mySorter = new SerialMergeSort();
start = new Date();
mySorter.mergeSort(data2, 0, data2.length);
end = new Date();
System.out.println("Execution Time Java SerialMergeSort: " + (end.getTime() - start.getTime()));
最后,我们检查排序后的数组是否相同:
for (int i = 0; i < data.length; i++) {
if (data[i].compareTo(data2[i]) != 0) {
System.err.println("There's a difference is position " + i);
System.exit(-1);
}
}
System.out.println("Both arrays are equal");
}
}
}
并发版本
正如我们之前提到的,我们将使用新的 Java 8 CountedCompleter类作为 Fork/Join 任务的基类。这个类提供了一个机制,当所有子任务都完成执行时执行一个方法。这就是onCompletion()方法。因此,我们使用compute()方法来划分数组,使用onCompletion()方法来将子列表合并成一个有序列表。
您要实现的并发解决方案有三个类:
-
扩展
CountedCompleter类并实现执行归并排序算法的任务的MergeSortTask类 -
ConcurrentMergeSort任务启动第一个任务 -
提供
main()方法来测试并发版本的归并排序算法的ConcurrentMetaData类
MergeSortTask类
正如我们之前提到的,这个类实现了将执行归并排序算法的任务。这个类使用以下属性:
-
我们想要排序的数据数组
-
任务必须排序的数组的起始和结束位置
该类还有一个构造函数来初始化其参数:
public class MergeSortTask extends CountedCompleter<Void> {
private Comparable[] data;
private int start, end;
private int middle;
public MergeSortTask(Comparable[] data, int start, int end,
MergeSortTask parent) {
super(parent);
this.data = data;
this.start = start;
this.end = end;
}
如果compute()方法中开始和结束索引之间的差大于或等于1024,我们将任务分成两个子任务来处理原始集合的两个子集。两个任务都使用fork()方法以异步方式将任务发送到ForkJoinPool。否则,我们执行SerialMergeSorg.mergeSort()来对数组的一部分进行排序(其中有1024个或更少的元素),然后调用tryComplete()方法。当子任务完成执行时,此方法将在内部调用onCompletion()方法。请查看以下代码:
@Override
public void compute() {
if (end - start >= 1024) {
middle = (end+start)>>>1;
MergeSortTask task1 = new MergeSortTask(data, start, middle, this);
MergeSortTask task2 = new MergeSortTask(data, middle, end, this);
addToPendingCount(1);
task1.fork();
task2.fork();
} else {
new SerialMergeSort().mergeSort(data, start, end);
tryComplete();
}
在我们的情况下,我们将使用onCompletion()方法来进行合并和排序操作以获得排序后的列表。一旦任务完成onCompletion()方法的执行,它会在其父任务上调用tryComplete()来尝试完成该任务。onCompletion()方法的源代码与算法的串行版本的merge()方法非常相似。请参考以下代码:
@Override
public void onCompletion(CountedCompleter<?> caller) {
if (middle==0) {
return;
}
int length = end - start + 1;
Comparable tmp[] = new Comparable[length];
int i, j, index;
i = start;
j = middle;
index = 0;
while ((i < middle) && (j < end)) {
if (data[i].compareTo(data[j]) <= 0) {
tmp[index] = data[i];
i++;
} else {
tmp[index] = data[j];
j++;
}
index++;
}
while (i < middle) {
tmp[index] = data[i];
i++;
index++;
}
while (j < end) {
tmp[index] = data[j];
j++;
index++;
}
for (index = 0; index < (end - start); index++) {
data[index + start] = tmp[index];
}
}
ConcurrentMergeSort类
在并发版本中,这个类非常简单。它实现了mergeSort()方法,该方法接收要排序的数据数组以及开始索引(始终为 0)和结束索引(始终为数组的长度)作为参数来对数组进行排序。我们选择保持相同的接口而不是串行版本。
该方法创建一个新的MergeSortTask,使用invoke()方法将其发送到默认的ForkJoinPool,当任务完成执行并且数组已排序时返回。
public class ConcurrentMergeSort {
public void mergeSort (Comparable data[], int start, int end) {
MergeSortTask task=new MergeSortTask(data, start, end,null);
ForkJoinPool.commonPool().invoke(task);
}
}
并发版本的ConcurrentMetaData类
ConcurrentMetaData类提供了main()方法来测试并发版本的归并排序算法。在我们的情况下,代码与SerialMetaData类的代码相同,但使用类的并发版本和Arrays.parallelSort()方法而不是Arrays.sort()方法,因此我们不包括该类的源代码。
比较两个版本
我们已经执行了我们的串行和并发版本的归并排序算法,并比较了它们之间以及与Arrays.sort()和Arrays.parallelSort()方法的执行时间。我们使用了 JMH 框架(openjdk.java.net/projects/code-tools/jmh/)来执行这四个版本,该框架允许您在 Java 中实现微基准测试。使用基准测试框架比简单地使用currentTimeMillis()或nanoTime()等方法来测量时间更好。我们在一个四核处理器的计算机上执行了 10 次,并计算了这 10 次的平均执行时间。这是我们在对包含 542,184 个对象的数据集进行排序时获得的执行时间(毫秒):
| Arrays.sort() | 串行归并排序 | Arrays.parallelSort() | 并发归并排序 | |
|---|---|---|---|---|
| 执行时间(毫秒) | 561.324 | 711.004 | 261.418 | 353.846 |
我们可以得出以下结论:
-
Arrays.parallelSort()方法获得了最佳结果。对于串行算法,Arrays.sort()方法获得的执行时间比我们的实现更好。 -
对于我们的实现,算法的并发版本比串行版本具有更好的性能。
我们可以使用加速比来比较归并排序算法的串行和并发版本:

Fork/Join 框架的其他方法
在本章的三个示例中,我们使用了 Fork/Join 框架的类的许多方法,但还有其他有趣的方法您需要了解。
我们使用了ForkJoinPool类的execute()和invoke()方法将任务发送到池中。我们可以使用另一个名为submit()的方法。它们之间的主要区别在于,execute()方法将任务发送到ForkJoinPool并立即返回一个 void 值,invoke()方法将任务发送到ForkJoinPool并在任务完成执行时返回,submit()方法将任务发送到ForkJoinPool并立即返回一个Future对象以控制任务的状态并获取其结果。
在本章的所有示例中,我们使用了基于ForkJoinTask类的类,但您也可以使用基于Runnable和Callable接口的ForkJoinPool任务。为此,您可以使用接受Runnable对象、带有结果的Runnable对象和Callable对象的版本的submit()方法。
ForkJoinTask类提供了get(long timeout, TimeUnit unit)方法来获取任务返回的结果。该方法等待参数中指定的时间段以获取任务的结果。如果任务在此时间段之前完成执行,则方法返回结果。否则,它会抛出TimeoutException异常。
ForkJoinTask提供了invoke()方法的替代方法。它是quietlyInvoke()方法。两个版本之间的主要区别在于invoke()方法返回任务执行的结果,或者在必要时抛出任何异常。quietlyInvoke()方法不返回任务的结果,也不抛出任何异常。它类似于示例中使用的quietlyJoin()方法。
总结
分而治之的设计技术是解决不同类型问题的一种非常流行的方法。您将原始问题分解为较小的问题,然后将这些问题分解为更小的问题,直到我们有足够简单的问题直接解决它。在版本 7 中,Java 并发 API 引入了一种专门针对这些问题优化的Executor。它就是 Fork/Join 框架。它基于以下两个操作:
-
fork:这允许您创建一个新的子任务
-
join:这允许您等待子任务的完成并获取其结果
使用这些操作,Fork/Join 任务具有以下外观:
if ( problem.size() > DEFAULT_SIZE) {
childTask1=new Task();
childTask2=new Task();
childTask1.fork();
childTask2.fork();
childTaskResults1=childTask1.join();
childTaskResults2=childTask2.join();
taskResults=makeResults(childTaskResults1, childTaskResults2);
return taskResults;
} else {
taskResults=solveBasicProblem();
return taskResults;
}
在本章中,您已经使用了 Fork/Join 框架解决了三种不同的问题,如 k 均值聚类算法、数据过滤算法和归并排序算法。
您已经使用了 API 提供的默认ForkJoinPool(这是 Java 8 版本的新功能),并创建了一个新的ForkJoinPool对象。您还使用了三种类型的ForkJoinTask:
-
RecursiveAction类,用作那些不返回结果的ForkJoinTasks的基类。 -
RecursiveTask类,用作那些返回结果的ForkJoinTasks的基类。 -
CountedCompleter类,引入于 Java 8,并用作那些需要在所有子任务完成执行时执行方法或启动另一个任务的ForkJoinTasks的基类。
在下一章中,您将学习如何使用新的 Java 8 并行流来使用 MapReduce 编程技术,以获得处理非常大数据集的最佳性能。
第七章:使用并行流处理大型数据集-映射和减少模型
毫无疑问,Java 8 引入的最重要的创新是 lambda 表达式和 stream API。流是可以按顺序或并行方式处理的元素序列。我们可以应用中间操作来转换流,然后执行最终计算以获得所需的结果(列表、数组、数字等)。在本章中,我们将涵盖以下主题:
-
流的介绍
-
第一个例子-数字摘要应用程序
-
第二个例子-信息检索搜索工具
流的介绍
流是一系列数据(不是数据结构),允许您以顺序或并行方式应用一系列操作来过滤、转换、排序、减少或组织这些元素以获得最终对象。例如,如果您有一个包含员工数据的流,您可以使用流来:
-
计算员工的总数
-
计算居住在特定地方的所有员工的平均工资
-
获取未达到目标的员工列表
-
任何涉及所有或部分员工的操作
流受到函数式编程的极大影响(Scala 编程语言提供了一个非常类似的机制),并且它们被设计用于使用 lambda 表达式。流 API 类似于 C#语言中可用的 LINQ(Language-Integrated Query)查询,在某种程度上可以与 SQL 查询进行比较。
在接下来的章节中,我们将解释流的基本特性以及您将在流中找到的部分。
流的基本特性
流的主要特点是:
-
流不存储它的元素。流从其源获取元素,并将它们发送到形成管道的所有操作中。
-
您可以在并行中使用流而无需额外工作。创建流时,您可以使用
stream()方法创建顺序流,或使用parallelStream()创建并发流。BaseStream接口定义了sequential()方法以获取流的顺序版本,以及parallel()以获取流的并发版本。您可以将顺序流转换为并行流,将并行流转换为顺序流,反复多次。请注意,当执行终端流操作时,所有流操作将根据最后的设置进行处理。您不能指示流按顺序执行某些操作,同时按并发方式执行其他操作。在 Oracle JDK 8 和 Open JDK 8 中,内部使用 Fork/Join 框架的实现来执行并发操作。 -
流受到函数式编程和 Scala 编程语言的极大影响。您可以使用新的 lambda 表达式来定义在流操作中执行的算法。
-
流不能重复使用。例如,当您从值列表中获取流时,您只能使用该流一次。如果您想对相同的数据执行另一个操作,您必须创建一个新的流。
-
流对数据进行延迟处理。直到必要时才获取数据。正如您将在后面学到的,流有一个起源、一些中间操作和一个终端操作。直到终端操作需要它,数据才会被处理,因此流处理直到执行终端操作才开始。
-
您无法以不同的方式访问流的元素。当您有一个数据结构时,您可以访问其中存储的一个确定的元素,例如指定其位置或其键。流操作通常统一处理元素,因此您唯一拥有的就是元素本身。您不知道元素在流中的位置和相邻元素。在并行流的情况下,元素可以以任何顺序进行处理。
-
流操作不允许您修改流源。例如,如果您将列表用作流源,可以将处理结果存储到新列表中,但不能添加,删除或替换原始列表的元素。尽管听起来很受限制,但这是一个非常有用的功能,因为您可以返回从内部集合创建的流,而不必担心列表将被调用者修改。
流的部分
流有三个不同的部分:
-
一个源,生成流所消耗的数据。
-
零个或多个中间操作,生成另一个流作为输出。
-
一个终端操作,生成一个对象,可以是一个简单对象或一个集合,如数组,列表或哈希表。还可以有不产生任何显式结果的终端操作。
流的源
流的源生成将由Stream对象处理的数据。您可以从不同的源创建流。例如,Collection接口在 Java 8 中包含了stream()方法来生成顺序流,parallelStream()来生成并行流。这使您可以生成一个流来处理几乎所有 Java 中实现的数据结构的数据,如列表(ArrayList,LinkedList等),集合(HashSet,EnumSet)或并发数据结构(LinkedBlockingDeque,PriorityBlockingQueue等)。另一个可以生成流的数据结构是数组。Array类包括stream()方法的四个版本,用于从数组生成流。如果您将int数组传递给该方法,它将生成IntStream。这是一种专门用于处理整数的流(您仍然可以使用Stream<Integer>而不是IntStream,但性能可能会显着下降)。类似地,您可以从long[]或double[]数组创建LongStream或DoubleStream。
当然,如果您将对象数组传递给stream()方法,您将获得相同类型的通用流。在这种情况下,没有parallelStream()方法,但是一旦您获得了流,您可以调用BaseStream接口中定义的parallel()方法,将顺序流转换为并发流。
Stream API 提供的另一个有趣的功能是,您可以生成并流来处理目录或文件的内容。Files类提供了使用流处理文件的不同方法。例如,find()方法返回一个流,其中包含满足某些条件的文件树中的Path对象。list()方法返回一个包含目录内容的Path对象的流。walk()方法返回一个使用深度优先算法处理目录树中所有对象的Path对象流。但最有趣的方法是lines()方法,它创建一个包含文件行的String对象流,因此您可以使用流来处理其内容。不幸的是,除非您有成千上万的元素(文件或行),这里提到的所有方法都无法很好地并行化。
此外,您可以使用Stream接口提供的两种方法来创建流:generate()和iterate()方法。generate()方法接收一个参数化为对象类型的Supplier作为参数,并生成该类型的对象的无限顺序流。Supplier接口具有get()方法。每当流需要一个新对象时,它将调用此方法来获取流的下一个值。正如我们之前提到的,流以一种懒惰的方式处理数据,因此流的无限性质并不成问题。您将使用其他方法将该流转换为有限方式。iterate()方法类似,但在这种情况下,该方法接收一个种子和一个UnaryOperator。第一个值是将UnaryOperator应用于种子的结果;第二个值是将UnaryOperator应用于第一个结果的结果,依此类推。在并发应用程序中应尽量避免使用此方法,因为它们的性能问题。
还有更多的流来源如下:
-
String.chars(): 返回一个IntStream,其中包含String的char值。 -
Random.ints()、Random.doubles()或Random.longs(): 分别返回IntStream、DoubleStream和LongStream,具有伪随机值。您可以指定随机数之间的范围,或者您想要获取的随机值的数量。例如,您可以使用new Random.ints(10,20)生成 10 到 20 之间的伪随机数。 -
SplittableRandom类:这个类提供了与Random类相同的方法,用于生成伪随机的int、double和long值,但更适合并行处理。您可以查看 Java API 文档以获取该类的详细信息。 -
Stream.concat()方法:这个方法接收两个流作为参数,并创建一个新的流,其中包含第一个流的元素,后跟第二个流的元素。
您可以从其他来源生成流,但我们认为它们不重要。
中间操作
中间操作的最重要特征是它们将另一个流作为它们的结果返回。输入流和输出流的对象可以是不同类型的,但中间操作总是会生成一个新的流。在流中可以有零个或多个中间操作。Stream接口提供的最重要的中间操作是:
-
distinct(): 这个方法返回一个具有唯一值的流。所有重复的元素将被消除 -
filter(): 这个方法返回一个满足特定条件的元素的流 -
flatMap(): 这个方法用于将流的流(例如,列表流,集合流等)转换为单个流 -
limit(): 这个方法返回一个包含最多指定数量的原始元素的流,按照首个元素的顺序开始 -
map(): 这个方法用于将流的元素从一种类型转换为另一种类型 -
peek(): 这个方法返回相同的流,但它执行一些代码;通常用于编写日志消息 -
skip(): 这个方法忽略流的前几个元素(具体数字作为参数传递) -
sorted(): 这个方法对流的元素进行排序
终端操作
终端操作返回一个对象作为结果。它永远不会返回一个流。一般来说,所有流都将以一个终端操作结束,该操作返回所有操作序列的最终结果。最重要的终端操作是:
-
collect(): 这个方法提供了一种方法来减少源流的元素数量,将流的元素组织成数据结构。例如,您想按任何标准对流的元素进行分组。 -
count(): 返回流的元素数量。 -
max(): 返回流的最大元素。 -
min(): 这返回流的最小元素。 -
reduce(): 这种方法将流的元素转换为表示流的唯一对象。 -
forEach()/forEachOrdered(): 这些方法对流中的每个元素应用操作。如果流有定义的顺序,第二种方法使用流的元素顺序。 -
findFirst()/findAny(): 如果存在,分别返回1或流的第一个元素。 -
anyMatch()/allMatch()/noneMatch(): 它们接收一个谓词作为参数,并返回一个布尔值,指示流的任何、所有或没有元素是否与谓词匹配。 -
toArray(): 这种方法返回流的元素数组。
MapReduce 与 MapCollect
MapReduce 是一种编程模型,用于在具有大量机器的集群中处理非常大的数据集。通常由两种方法实现两个步骤:
-
Map: 这过滤和转换数据。
-
Reduce: 这对数据应用汇总操作
要在分布式环境中执行此操作,我们必须拆分数据,然后分发到集群的机器上。这种编程模型在函数式编程世界中已经使用了很长时间。谷歌最近基于这一原则开发了一个框架,在Apache 基金会中,Hadoop项目作为这一模型的开源实现非常受欢迎。
Java 8 与流允许程序员实现与此非常相似的东西。Stream接口定义了中间操作(map(), filter(), sorted(), skip()等),可以被视为映射函数,并且它提供了reduce()方法作为终端操作,其主要目的是对流的元素进行减少,就像 MapReduce 模型的减少一样。
reduce操作的主要思想是基于先前的中间结果和流元素创建新的中间结果。另一种减少的方式(也称为可变减少)是将新的结果项合并到可变容器中(例如,将其添加到ArrayList中)。这种减少是通过collect()操作执行的,我们将其称为MapCollect模型。
本章我们将看到如何使用 MapReduce 模型,以及如何在第八章中使用 MapCollect 模型。使用并行流处理大规模数据集-Map 和 Collect 模型。
第一个示例-数值汇总应用程序
当您拥有大量数据集时,最常见的需求之一是处理其元素以测量某些特征。例如,如果您有一个商店中购买的产品集合,您可以计算您销售的产品数量,每种产品的销售单位数,或者每位客户在其上花费的平均金额。我们称这个过程为数值汇总。
在本章中,我们将使用流来获取UCI 机器学习库的银行营销数据集的一些度量,您可以从archive.ics.uci.edu/ml/datasets/Bank+Marketing下载。具体来说,我们使用了bank-additional-full.csv文件。该数据集存储了葡萄牙银行机构营销活动的信息。
与其他章节不同的是,在这种情况下,我们首先解释使用流的并发版本,然后说明如何实现串行等效版本,以验证并发对流的性能也有所改进。请注意,并发对程序员来说是透明的,正如我们在本章的介绍中提到的那样。
并发版本
我们的数值汇总应用程序非常简单。它具有以下组件:
-
Record:这个类定义了文件中每条记录的内部结构。它定义了每条记录的 21 个属性和相应的get()和set()方法来建立它们的值。它的代码非常简单,所以不会包含在书中。 -
ConcurrentDataLoader:这个类将加载bank-additional-full.csv文件中的数据,并将其转换为Record对象的列表。我们将使用流来加载数据并进行转换。 -
ConcurrentStatistics:这个类实现了我们将用来对数据进行计算的操作。 -
ConcurrentMain:这个类实现了main()方法,调用ConcurrentStatistics类的操作并测量其执行时间。
让我们详细描述最后三个类。
ConcurrentDataLoader类
ConcurrentDataLoader类实现了load()方法,加载银行营销数据集的文件并将其转换为Record对象的列表。首先,我们使用Files方法的readAllLines()方法加载文件并将其内容转换为String对象的列表。文件的每一行将被转换为列表的一个元素:
public class ConcurrentDataLoader {
public static List<Record> load(Path path) throws IOException {
System.out.println("Loading data");
List<String> lines = Files.readAllLines(path);
然后,我们对流应用必要的操作来获取Record对象的列表:
List<Record> records = lines
.parallelStream()
.skip(1)
.map(l -> l.split(";"))
.map(t -> new Record(t))
.collect(Collectors.toList());
我们使用的操作有:
-
parallelStream():我们创建一个并行流来处理文件的所有行。 -
skip(1):我们忽略流的第一个项目;在这种情况下,文件的第一行,其中包含文件的标题。 -
map (l → l.split(";")):我们将每个字符串转换为String[]数组,通过;字符分割行。我们使用 lambda 表达式,其中l表示输入参数,l.split()将生成字符串数组。我们在字符串流中调用此方法,它将生成String[]流。 -
map(t → new Record(t)):我们使用Record类的构造函数将每个字符串数组转换为Record对象。我们使用 lambda 表达式,其中t表示字符串数组。我们在String[]流中调用此方法,并生成Record对象流。 -
collect(Collectors.toList()):这个方法将流转换为列表。我们将在第八章中更详细地讨论collect方法,使用并行流处理大型数据集-映射和收集模型。
正如你所看到的,我们以一种紧凑、优雅和并发的方式进行了转换,而没有使用任何线程、任务或框架。最后,我们返回Record对象的列表,如下所示:
return records;
}
}
ConcurrentStatistics类
ConcurrentStatistics类实现了对数据进行计算的方法。我们有七种不同的操作来获取关于数据集的信息。让我们描述每一个。
订阅者的工作信息
这个方法的主要目标是获取订阅了银行存款(字段 subscribe 等于yes)的人员职业类型(字段 job)的人数。
这是这个方法的源代码:
public class ConcurrentStatistics {
public static void jobDataFromSubscribers(List<Record> records) {
System.out.println ("****************************************");
System.out.println("Job info for Deposit subscribers");
ConcurrentMap<String, List<Record>> map = records.parallelStream()
.filter(r -> r.getSubscribe().equals("yes"))
.collect(Collectors.groupingByConcurrent (Record::getJob));
map.forEach((k, l) -> System.out.println(k + ": " + l.size()));
System.out.println ("****************************************");
}
该方法接收Record对象的列表作为输入参数。首先,我们使用流来获取一个ConcurrentMap<String, List<Record>>对象,其中包含不同的工作类型和每种工作类型的记录列表。该流以parallelStream()方法开始,创建一个并行流。然后,我们使用filter()方法选择那些subscribe属性为yes的Record对象。最后,我们使用collect()方法传递Collectors.groupingByConcurrent()方法,将流的实际元素按照工作属性的值进行分组。请注意,groupingByConcurrent()方法是一个无序收集器。收集到列表中的记录可能是任意顺序的,而不是原始顺序(不像简单的groupingBy()收集器)。
一旦我们有了ConcurrentMap对象,我们使用forEach()方法将信息写入屏幕。
订阅者的年龄数据
该方法的主要目标是从银行存款的订阅者的年龄(字段 subscribe 等于yes)中获取统计信息(最大值、最小值和平均值)。
这是该方法的源代码:
public static void ageDataFromSubscribers(List<Record> records) {
System.out.println ("****************************************");
System.out.println("Age info for Deposit subscribers");
DoubleSummaryStatistics statistics = records.parallelStream()
.filter(r -> r.getSubscribe().equals("yes"))
.collect(Collectors.summarizingDouble (Record::getAge));
System.out.println("Min: " + statistics.getMin());
System.out.println("Max: " + statistics.getMax());
System.out.println("Average: " + statistics.getAverage());
System.out.println ("****************************************");
}
该方法接收Record对象的列表作为输入参数,并使用流来获取带有统计信息的DoubleSummaryStatistics对象。首先,我们使用parallelStream()方法获取并行流。然后,我们使用filter()方法获取银行存款的订阅者。最后,我们使用带有Collectors.summarizingDouble()参数的collect()方法来获取DoubleSummaryStatistics对象。该类实现了DoubleConsumer接口,并在accept()方法中收集接收到的值的统计数据。accept()方法由流的collect()方法在内部调用。Java 还提供了IntSummaryStatistics和LongSummaryStatistics类,用于从int和long值获取统计数据。在这种情况下,我们使用max()、min()和average()方法分别获取最大值、最小值和平均值。
订阅者的婚姻数据
该方法的主要目标是获取银行存款订阅者的不同婚姻状况(字段婚姻)。
这是该方法的源代码:
public static void maritalDataFromSubscribers(List<Record> records) {
System.out.println ("****************************************");
System.out.println("Marital info for Deposit subscribers");
records.parallelStream()
.filter(r -> r.getSubscribe().equals("yes"))
.map(r -> r.getMarital())
.distinct()
.sorted()
.forEachOrdered(System.out::println);
System.out.println ("****************************************");
}
该方法接收Record对象的列表作为输入参数,并使用parallelStream()方法获取并行流。然后,我们使用filter()方法仅获取银行存款的订阅者。接下来,我们使用map()方法获取所有订阅者的婚姻状况的String对象流。使用distinct()方法,我们只取唯一的值,并使用sorted()方法按字母顺序排序这些值。最后,我们使用forEachOrdered()打印结果。请注意,不要在这里使用forEach(),因为它会以无特定顺序打印结果,这将使sorted()步骤变得无用。当元素顺序不重要且可能比forEachOrdered()更快时,forEach()操作对于并行流非常有用。
非订阅者的联系人数据
当我们使用流时,最常见的错误之一是尝试重用流。我们将通过这个方法展示这个错误的后果,该方法的主要目标是获取最大联系人数(属性 campaign)。
该方法的第一个版本是尝试重用流。以下是其源代码:
public static void campaignDataFromNonSubscribersBad (List<Record> records) {
System.out.println ("****************************************");
System.out.println("Number of contacts for Non Subscriber");
IntStream stream = records.parallelStream()
.filter(Record::isNotSubscriber)
.mapToInt(r -> r.getCampaign());
System.out
.println("Max number of contacts: " + stream.max().getAsInt());
System.out
.println("Min number of contacts: " + stream.min().getAsInt());
System.out.println ("****************************************");
}
该方法接收Record对象的列表作为输入参数。首先,我们使用该列表创建一个IntStream对象。使用parallelStream()方法创建并行流。然后,我们使用filter()方法获取非订阅者,并使用mapToInt()方法将Record对象流转换为IntStream对象,将每个对象替换为getCampaign()方法的值。
我们尝试使用该流获取最大值(使用max()方法)和最小值(使用min()方法)。如果执行此方法,我们将在第二次调用中获得IllegalStateException,并显示消息流已经被操作或关闭。
我们可以通过创建两个不同的流来解决这个问题,一个用于获取最大值,另一个用于获取最小值。这是此选项的源代码:
public static void campaignDataFromNonSubscribersOk (List<Record> records) {
System.out.println ("****************************************");
System.out.println("Number of contacts for Non Subscriber");
int value = records.parallelStream()
.filter(Record::isNotSubscriber)
.map(r -> r.getCampaign())
.mapToInt(Integer::intValue)
.max()
.getAsInt();
System.out.println("Max number of contacts: " + value);
value = records.parallelStream()
.filter(Record::isNotSubscriber)
.map(r -> r.getCampaign())
.mapToInt(Integer::intValue)
.min()
.getAsInt();
System.out.println("Min number of contacts: " + value);
System.out.println ("****************************************");
}
另一个选项是使用summaryStatistics()方法获取一个IntSummaryStatistics对象,就像我们在之前的方法中展示的那样。
多数据过滤
该方法的主要目标是获取满足以下条件之一的记录数量:
-
defaultCredit属性取值为true -
housing属性取值为false -
loan属性取值为false
实现此方法的一种解决方案是实现一个过滤器,检查元素是否满足这些条件之一。您还可以使用Stream接口提供的concat()方法实现其他解决方案。这是源代码:
public static void multipleFilterData(List<Record> records) {
System.out.println ("****************************************");
System.out.println("Multiple filter");
Stream<Record> stream1 = records.parallelStream()
.filter(Record::isDefaultCredit);
Stream<Record> stream2 = records.parallelStream()
.filter(r -> !(r.isHousing()));
Stream<Record> stream3 = records.parallelStream()
.filter(r -> !(r.isLoan()));
Stream<Record> complete = Stream.concat(stream1, stream2);
complete = Stream.concat(complete, stream3);
long value = complete.parallel().unordered().distinct().count();
System.out.println("Number of people: " + value);
System.out.println ("****************************************");
}
该方法接收Record对象列表作为输入参数。首先,我们创建三个满足每个条件的元素流,然后使用concat()方法生成单个流。concat()方法只创建一个流,其中包含第一个流的元素,然后是第二个流的元素。因此,对于最终流,我们使用parallel()方法将最终流转换为并行流,unordered()方法获取无序流,这将在使用并行流的distinct()方法中提供更好的性能,distinct()方法获取唯一值,以及count()方法获取流中的元素数量。
这不是最优的解决方案。我们使用它来向您展示concat()和distinct()方法的工作原理。您可以使用以下代码以更优化的方式实现相同的功能
public static void multipleFilterDataPredicate (List<Record> records) {
System.out.println ("****************************************");
System.out.println("Multiple filter with Predicate");
Predicate<Record> p1 = r -> r.isDefaultCredit();
Predicate<Record> p2 = r -> !r.isHousing();
Predicate<Record> p3 = r -> !r.isLoan();
Predicate<Record> pred = Stream.of(p1, p2, p3)
.reduce(Predicate::or).get();
long value = records.parallelStream().filter(pred).count();
System.out.println("Number of people: " + value);
System.out.println ("****************************************");
}
我们创建了三个谓词的流,并通过Predicate::or操作将它们减少为一个复合谓词,当输入谓词之一为true时,该复合谓词为true。您还可以使用Predicate::and减少操作来创建一个谓词,当所有输入谓词都为true时,该谓词为true。
非订阅者的持续时间数据
该方法的主要目标是获取最长的 10 次电话通话(持续时间属性),这些通话最终没有订阅银行存款(字段 subscribe 等于no)。
这是此方法的源代码:
public static void durationDataForNonSubscribers(List<Record> records) {
System.out.println ("****************************************");
System.out.println("Duration data for non subscribers");
records.parallelStream().filter(r -> r.isNotSubscriber()) .sorted(Comparator.comparingInt (Record::getDuration) .reversed()).limit(10) .forEachOrdered(
r -> System.out.println("Education: " + r.getEducation() + "; Duration: " + r.getDuration()));
System.out.println ("****************************************");
}
该方法接收Record对象列表作为输入参数,并使用parallelStream()方法获取并行流。我们使用filter()方法获取非订阅者。然后,我们使用sorted()方法并传递一个比较器。比较器是使用Comparator.comparingInt()静态方法创建的。由于我们需要按照相反的顺序排序(最长持续时间优先),我们只需将reversed()方法添加到创建的比较器中。sorted()方法使用该比较器来比较和排序流的元素,因此我们可以按照我们想要的方式获取排序后的元素。
元素排序后,我们使用limit()方法获取前 10 个结果,并使用forEachOrdered()方法打印结果。
年龄在 25 到 50 岁之间的人
该方法的主要目标是获取文件中年龄在 25 到 50 岁之间的人数。
这是此方法的源代码:
public static void peopleBetween25and50(List<Record> records) {
System.out.println ("****************************************");
System.out.println("People between 25 and 50");
int count=records.parallelStream() .map(r -> r.getAge()) .filter(a -> (a >=25 ) && (a <=50)) .mapToInt(a -> 1) .reduce(0, Integer::sum);
System.out.println("People between 25 and 50: "+count);
System.out.println ("****************************************");
}
该方法接收Record对象的列表作为输入参数,并使用parallelStream()方法获取并行流。然后,我们使用map()方法将Record对象流转换为int值流,将每个对象替换为其年龄属性的值。然后,我们使用filter()方法仅选择年龄在 25 到 50 岁之间的人,并再次使用map()方法将每个值转换为1。最后,我们使用reduce()方法对所有这些1进行求和,得到 25 到 50 岁之间的人的总数。reduce()方法的第一个参数是身份值,第二个参数是用于从流的所有元素中获得单个值的操作。在这种情况下,我们使用Integer::sum操作。第一次求和是在流的初始值和第一个值之间进行的,第二次求和是在第一次求和的结果和流的第二个值之间进行的,依此类推。
ConcurrentMain类
ConcurrentMain类实现了main()方法来测试ConcurrentStatistic类。首先,我们实现了measure()方法,用于测量任务的执行时间:
public class ConcurrentMain {
static Map<String, List<Double>> totalTimes = new LinkedHashMap<>();
static List<Record> records;
private static void measure(String name, Runnable r) {
long start = System.nanoTime();
r.run();
long end = System.nanoTime();
totalTimes.computeIfAbsent(name, k -> new ArrayList<>()).add((end - start) / 1_000_000.0);
}
我们使用一个映射来存储每个方法的所有执行时间。我们将执行每个方法 10 次,以查看第一次执行后执行时间的减少。然后,我们包括main()方法的代码。它使用measure()方法来测量每个方法的执行时间,并重复这个过程 10 次:
public static void main(String[] args) throws IOException {
Path path = Paths.get("data\\bank-additional-full.csv");
for (int i = 0; i < 10; i++) {
records = ConcurrentDataLoader.load(path);
measure("Job Info", () -> ConcurrentStatistics.jobDataFromSubscribers (records));
measure("Age Info", () -> ConcurrentStatistics.ageDataFromSubscribers (records));
measure("Marital Info", () -> ConcurrentStatistics.maritalDataFromSubscribers (records));
measure("Multiple Filter", () -> ConcurrentStatistics.multipleFilterData(records));
measure("Multiple Filter Predicate", () -> ConcurrentStatistics.multipleFilterDataPredicate (records));
measure("Duration Data", () -> ConcurrentStatistics.durationDataForNonSubscribers (records));
measure("Number of Contacts Bad: ", () -> ConcurrentStatistics .campaignDataFromNonSubscribersBad(records));
measure("Number of Contacts", () -> ConcurrentStatistics .campaignDataFromNonSubscribersOk(records));
measure("People Between 25 and 50", () -> ConcurrentStatistics.peopleBetween25and50(records));
}
最后,我们在控制台中写入所有执行时间和平均执行时间,如下所示:
times.stream().map(t -> String.format("%6.2f", t)).collect(Collectors.joining(" ")), times .stream().mapToDouble (Double::doubleValue).average().getAsDouble()));
}
}
串行版本
在这种情况下,串行版本几乎等于并行版本。我们只需将所有对parallelStream()方法的调用替换为对stream()方法的调用,以获得顺序流而不是并行流。我们还必须删除我们在其中一个示例中使用的parallel()方法的调用,并将对groupingByConcurrent()方法的调用更改为groupingBy()。
比较两个版本
我们已经执行了操作的两个版本,以测试并行流的使用是否提供更好的性能。我们使用了 JMH 框架(openjdk.java.net/projects/code-tools/jmh/)来执行它们,该框架允许您在 Java 中实现微基准测试。使用基准测试框架比简单地使用currentTimeMillis()或nanoTime()等方法来测量时间更好。我们在一个四核处理器的计算机上执行了它们 10 次,并计算了这 10 次的平均执行时间。请注意,我们已经实现了一个特殊的类来执行 JMH 测试。您可以在源代码的com.javferna.packtpub.mastering.numericalSummarization.benchmark包中找到这些类。以下是以毫秒为单位的结果:
| 操作 | 顺序流 | 并行流 |
|---|---|---|
| 作业信息 | 13.704 | 9.550 |
| 年龄信息 | 7.218 | 5.512 |
| 婚姻信息 | 8.551 | 6.783 |
| 多重过滤 | 27.002 | 23.668 |
| 具有谓词的多重过滤 | 9.413 | 6.963 |
| 数据持续时间 | 41.762 | 23.641 |
| 联系人数 | 22.148 | 13.059 |
| 年龄在 25 到 50 岁之间的人 | 9.102 | 6.014 |
我们可以看到,并行流始终比串行流获得更好的性能。这是所有示例的加速比:
| 操作 | 加速比 |
|---|---|
| 作业信息 | 1.30 |
| 年龄信息 | 1.25 |
| 婚姻信息 | 1.16 |
| 多重过滤 | 1.08 |
| 数据持续时间 | 1.51 |
| 联系人数 | 1.64 |
| 年龄在 25 到 50 岁之间的人 | 1.37 |
第二个示例 - 信息检索搜索工具
根据维基百科(en.wikipedia.org/wiki/Information_retrieval),信息检索是:
“从信息资源集合中获取与信息需求相关的信息资源。”
通常,信息资源是一组文档,信息需求是一组单词,这总结了我们的需求。为了在文档集合上进行快速搜索,我们使用了一种名为倒排索引的数据结构。它存储了文档集合中的所有单词,对于每个单词,都有一个包含该单词的文档列表。在第四章中,从任务中获取数据 - Callable 和 Future 接口,您构建了一个由维基百科页面构成的文档集合的倒排索引,其中包含有关电影的信息,构成了一组 100,673 个文档。我们已经将每个维基百科页面转换为一个文本文件。这个倒排索引存储在一个文本文件中,每一行包含单词、它的文档频率,以及单词在文档中出现的所有文档,以及单词在文档中的tfxidf属性的值。文档按照tfxidf属性的值进行排序。例如,文件的一行看起来像这样:
velankanni:4,18005302.txt:10.13,20681361.txt:10.13,45672176.txt:10 .13,6592085.txt:10.13
这一行包含了velankanni一词,DF 为4。它出现在18005302.txt文档中,tfxidf值为10.13,在20681361.txt文档中,tfxidf值为10.13,在45672176.txt文档中,tfxidf值为10.13,在6592085.txt文档中,tfxidf值为10.13。
在本章中,我们将使用流 API 来实现我们的搜索工具的不同版本,并获取有关倒排索引的信息。
减少操作的介绍
正如我们在本章前面提到的,reduce操作将一个摘要操作应用于流的元素,生成一个单一的摘要结果。这个单一的结果可以与流的元素相同类型,也可以是其他类型。reduce操作的一个简单例子是计算一系列数字的总和。
流 API 提供了reduce()方法来实现减少操作。这个方法有以下三个不同的版本:
-
reduce(accumulator): 此版本将accumulator函数应用于流的所有元素。在这种情况下没有初始值。它返回一个Optional对象,其中包含accumulator函数的最终结果,如果流为空,则返回一个空的Optional对象。这个accumulator函数必须是一个associative函数,它实现了BinaryOperator接口。两个参数可以是流元素,也可以是之前累加器调用返回的部分结果。 -
reduce(identity, accumulator): 当最终结果和流的元素具有相同类型时,必须使用此版本。身份值必须是accumulator函数的身份值。也就是说,如果你将accumulator函数应用于身份值和任何值V,它必须返回相同的值V: accumulator(identity,V)=V。该身份值用作累加器函数的第一个结果,并且如果流没有元素,则作为返回值。与另一个版本一样,累加器必须是一个实现BinaryOperator接口的associative函数。 -
reduce(identity, accumulator, combiner): 当最终结果的类型与流的元素不同时,必须使用此版本。identity 值必须是combiner函数的标识,也就是说,combiner(identity,v)=v。combiner函数必须与accumulator函数兼容,也就是说,combiner(u,accumulator(identity,v))=accumulator(u,v)。accumulator函数接受部分结果和流的下一个元素以生成部分结果,combiner 接受两个部分结果以生成另一个部分结果。这两个函数必须是可结合的,但在这种情况下,accumulator函数是BiFunction接口的实现,combiner函数是BinaryOperator接口的实现。
reduce()方法有一个限制。正如我们之前提到的,它必须返回一个单一的值。你不应该使用reduce()方法来生成一个集合或复杂对象。第一个问题是性能。正如流 API 的文档所指定的,accumulator函数在处理一个元素时每次都会返回一个新值。如果你的accumulator函数处理集合,每次处理一个元素时都会创建一个新的集合,这是非常低效的。另一个问题是,如果你使用并行流,所有线程将共享 identity 值。如果这个值是一个可变对象,例如一个集合,所有线程将在同一个集合上工作。这与reduce()操作的理念不符。此外,combiner()方法将始终接收两个相同的集合(所有线程都在同一个集合上工作),这也不符合reduce()操作的理念。
如果你想进行生成集合或复杂对象的减少,你有以下两个选项:
-
使用
collect()方法进行可变减少。第八章,“使用并行流处理大型数据集 - 映射和收集模型”详细解释了如何在不同情况下使用这种方法。 -
创建集合并使用
forEach()方法填充集合所需的值。
在这个例子中,我们将使用reduce()方法获取倒排索引的信息,并使用forEach()方法将索引减少到查询的相关文档列表。
第一种方法 - 完整文档查询
在我们的第一种方法中,我们将使用与一个单词相关联的所有文档。我们搜索过程的实现步骤如下:
-
我们在倒排索引中选择与查询词对应的行。
-
我们将所有文档列表分组成一个单一列表。如果一个文档与两个或更多不同的单词相关联,我们将这些单词在文档中的
tfxidf值相加,以获得文档的最终tfxidf值。如果一个文档只与一个单词相关联,那么该单词的tfxidf值将成为该文档的最终tfxidf值。 -
我们按照
tfxidf值对文档进行排序,从高到低。 -
我们向用户展示具有更高
tfxidf值的 100 个文档。
我们在ConcurrentSearch类的basicSearch()方法中实现了这个版本。这是该方法的源代码:
public static void basicSearch(String query[]) throws IOException {
Path path = Paths.get("index", "invertedIndex.txt");
HashSet<String> set = new HashSet<>(Arrays.asList(query));
QueryResult results = new QueryResult(new ConcurrentHashMap<>());
try (Stream<String> invertedIndex = Files.lines(path)) {
invertedIndex.parallel() .filter(line -> set.contains(Utils.getWord(line))) .flatMap(ConcurrentSearch::basicMapper) .forEach(results::append);
results .getAsList() .stream() .sorted() .limit(100) .forEach(System.out::println);
System.out.println("Basic Search Ok");
}
}
我们接收一个包含查询词的字符串对象数组。首先,我们将该数组转换为一个集合。然后,我们使用invertedIndex.txt文件的行进行try-with-resources流处理,该文件包含倒排索引。我们使用try-with-resources,这样我们就不必担心打开或关闭文件。流的聚合操作将生成一个具有相关文档的QueryResult对象。我们使用以下方法来获取该列表:
-
parallel(): 首先,我们获取并行流以提高搜索过程的性能。 -
filter(): 我们选择将单词与查询中的单词相关联的行。Utils.getWord()方法获取行的单词。 -
flatMap(): 我们将包含倒排索引每一行的字符串流转换为Token对象流。每个标记包含文件中单词的tfxidf值。对于每一行,我们将生成与包含该单词的文件数量相同的标记。 -
forEach(): 我们使用该类的add()方法生成QueryResult对象。
一旦我们创建了QueryResult对象,我们使用以下方法创建其他流来获取最终结果列表:
-
getAsList():QueryResult对象返回一个包含相关文档的列表 -
stream(): 创建一个流来处理列表 -
sorted(): 按其tfxidf值对文档列表进行排序 -
limit(): 获取前 100 个结果 -
forEach(): 处理 100 个结果并将信息写入屏幕
让我们描述一下示例中使用的辅助类和方法。
basicMapper()方法
该方法将字符串流转换为Token对象流。正如我们将在后面详细描述的那样,标记存储文档中单词的tfxidf值。该方法接收一个包含倒排索引行的字符串。它将行拆分为标记,并生成包含包含该单词的文档数量的Token对象。该方法在ConcurrentSearch类中实现。以下是源代码:
public static Stream<Token> basicMapper(String input) {
ConcurrentLinkedDeque<Token> list = new ConcurrentLinkedDeque();
String word = Utils.getWord(input);
Arrays .stream(input.split(","))
.skip(1) .parallel() .forEach(token -> list.add(new Token(word, token)));
return list.stream();
}
首先,我们创建一个ConcurrentLinkedDeque对象来存储Token对象。然后,我们使用split()方法拆分字符串,并使用Arrays类的stream()方法生成一个流。跳过第一个元素(包含单词的信息),并并行处理其余的标记。对于每个元素,我们创建一个新的Token对象(将单词和具有file:tfxidf格式的标记传递给构造函数),并将其添加到流中。最后,我们使用ConcurrenLinkedDeque对象的stream()方法返回一个流。
Token 类
正如我们之前提到的,这个类存储文档中单词的tfxidf值。因此,它有三个属性来存储这些信息,如下所示:
public class Token {
private final String word;
private final double tfxidf;
private final String file;
构造函数接收两个字符串。第一个包含单词,第二个包含文件和file:tfxidf格式中的tfxidf属性,因此我们必须按以下方式处理它:
public Token(String word, String token) {
this.word=word;
String[] parts=token.split(":");
this.file=parts[0];
this.tfxidf=Double.parseDouble(parts[1]);
}
最后,我们添加了一些方法来获取(而不是设置)三个属性的值,并将对象转换为字符串,如下所示:
@Override
public String toString() {
return word+":"+file+":"+tfxidf;
}
QueryResult 类
这个类存储与查询相关的文档列表。在内部,它使用一个映射来存储相关文档的信息。键是存储文档的文件的名称,值是一个Document对象,它还包含文件的名称和该文档对查询的总tfxidf值,如下所示:
public class QueryResult {
private Map<String, Document> results;
我们使用类的构造函数来指示我们将使用的Map接口的具体实现。我们在并发版本中使用ConcurrentHashMap,在串行版本中使用HashMap:
public QueryResult(Map<String, Document> results) {
this.results=results;
}
该类包括append方法,用于将标记插入映射,如下所示:
public void append(Token token) {
results.computeIfAbsent(token.getFile(), s -> new Document(s)).addTfxidf(token.getTfxidf());
}
我们使用computeIfAbsent()方法来创建一个新的Document对象,如果没有与文件关联的Document对象,或者如果已经存在,则获取相应的对象,并使用addTfxidf()方法将标记的tfxidf值添加到文档的总tfxidf值中。
最后,我们包含了一个将映射作为列表获取的方法,如下所示:
public List<Document> getAsList() {
return new ArrayList<>(results.values());
}
Document类将文件名存储为字符串,并将总tfxidf值存储为DoubleAdder。这个类是 Java 8 的一个新特性,允许我们在不担心同步的情况下从不同的线程对变量进行求和。它实现了Comparable接口,以按其tfxidf值对文档进行排序,因此具有最大tfxidf值的文档将排在前面。它的源代码非常简单,所以没有包含在内。
第二种方法 - 减少文档查询
第一种方法为每个单词和文件创建一个新的Token对象。我们注意到常见单词,例如the,有很多相关联的文档,而且很多文档的tfxidf值很低。我们已经改变了我们的映射方法,只考虑每个单词的前 100 个文件,因此生成的Token对象数量将更少。
我们在ConcurrentSearch类的reducedSearch()方法中实现了这个版本。这个方法与basicSearch()方法非常相似。它只改变了生成QueryResult对象的流操作,如下所示:
invertedIndex.parallel() .filter(line -> set.contains(Utils.getWord(line))) .flatMap(ConcurrentSearch::limitedMapper) .forEach(results::append);
现在,我们将limitedMapper()方法作为flatMap()方法中的函数使用。
limitedMapper()方法
这个方法类似于basicMapper()方法,但是,正如我们之前提到的,我们只考虑与每个单词相关联的前 100 个文档。由于文档按其tfxidf值排序,我们使用了单词更重要的 100 个文档,如下所示:
public static Stream<Token> limitedMapper(String input) {
ConcurrentLinkedDeque<Token> list = new ConcurrentLinkedDeque();
String word = Utils.getWord(input);
Arrays.stream(input.split(",")) .skip(1) .limit(100) .parallel() .forEach(token -> {
list.add(new Token(word, token));
});
return list.stream();
}
与basicMapper()方法的唯一区别是limit(100)调用,它获取流的前 100 个元素。
第三种方法 - 生成包含结果的 HTML 文件
在使用网络搜索引擎(例如 Google)的搜索工具时,当您进行搜索时,它会返回您的搜索结果(最重要的 10 个),并且对于每个结果,它会显示文档的标题和包含您搜索的单词的片段。
我们对搜索工具的第三种方法是基于第二种方法,但是通过添加第三个流来生成包含搜索结果的 HTML 文件。对于每个结果,我们将显示文档的标题和其中出现查询词的三行。为了实现这一点,您需要访问倒排索引中出现的文件。我们已经将它们存储在一个名为docs的文件夹中。
这第三种方法是在ConcurrentSearch类的htmlSearch()方法中实现的。构造QueryResult对象的方法的第一部分与reducedSearch()方法相同,如下所示:
public static void htmlSearch(String query[], String fileName) throws IOException {
Path path = Paths.get("index", "invertedIndex.txt");
HashSet<String> set = new HashSet<>(Arrays.asList(query));
QueryResult results = new QueryResult(new ConcurrentHashMap<>());
try (Stream<String> invertedIndex = Files.lines(path)) {
invertedIndex.parallel() .filter(line -> set.contains(Utils.getWord(line))) .flatMap(ConcurrentSearch::limitedMapper) .forEach(results::append);
然后,我们创建文件以写入输出和其中的 HTML 标头:
path = Paths.get("output", fileName + "_results.html");
try (BufferedWriter fileWriter = Files.newBufferedWriter(path, StandardOpenOption.CREATE)) {
fileWriter.write("<HTML>");
fileWriter.write("<HEAD>");
fileWriter.write("<TITLE>");
fileWriter.write("Search Results with Streams");
fileWriter.write("</TITLE>");
fileWriter.write("</HEAD>");
fileWriter.write("<BODY>");
fileWriter.newLine();
然后,我们包括生成 HTML 文件中结果的流:
results.getAsList()
.stream()
.sorted()
.limit(100)
.map(new ContentMapper(query)).forEach(l -> {
try {
fileWriter.write(l);
fileWriter.newLine();
} catch (IOException e) {
e.printStackTrace();
}
});
fileWriter.write("</BODY>");
fileWriter.write("</HTML>");
}
我们使用了以下方法:
-
getAsList()获取与查询相关的文档列表。 -
stream()生成一个顺序流。我们不能并行化这个流。如果我们尝试这样做,最终文件中的结果将不会按文档的tfxidf值排序。 -
sorted()按其tfxidf属性对结果进行排序。 -
map()使用ContentMapper类将Result对象转换为每个结果的 HTML 代码字符串。我们稍后会解释这个类的细节。 -
forEach()将map()方法返回的String对象写入文件。Stream对象的方法不能抛出已检查的异常,所以我们必须包含将抛出异常的 try-catch 块。
让我们看看ContentMapper类的细节。
ContentMapper类
ContentMapper类是Function接口的实现,它将Result对象转换为包含文档标题和三行文本的 HTML 块,其中包括一个或多个查询词。
该类使用内部属性存储查询,并实现构造函数来初始化该属性,如下所示:
public class ContentMapper implements Function<Document, String> {
private String query[];
public ContentMapper(String query[]) {
this.query = query;
}
文档的标题存储在文件的第一行。我们使用 try-with-resources 指令和 Files 类的 lines()方法来创建和流式传输文件行的 String 对象,并使用 findFirst()获取第一行作为字符串:
public String apply(Document d) {
String result = "";
try (Stream<String> content = Files.lines(Paths.get("docs",d.getDocumentName()))) {
result = "<h2>" + d.getDocumentName() + ": "
+ content.findFirst().get()
+ ": " + d.getTfxidf() + "</h2>";
} catch (IOException e) {
e.printStackTrace();
throw new UncheckedIOException(e);
}
然后,我们使用类似的结构,但在这种情况下,我们使用 filter()方法仅获取包含一个或多个查询单词的行,使用 limit()方法获取其中三行。然后,我们使用 map()方法为段落添加 HTML 标签(
),并使用 reduce()方法完成所选行的 HTML 代码:
try (Stream<String> content = Files.lines(Paths.get ("docs",d.getDocumentName()))) {
result += content
.filter(l -> Arrays.stream(query).anyMatch (l.toLowerCase()::contains))
.limit(3)
.map(l -> "<p>"+l+"</p>")
.reduce("",String::concat);
return result;
} catch (IOException e) {
e.printStackTrace();
throw new UncheckedIOException(e);
}
}
第四种方法-预加载倒排索引
前三种解决方案在并行执行时存在问题。正如我们之前提到的,使用常见的 Java 并发 API 提供的 Fork/Join 池执行并行流。在第六章中,优化分治解决方案-分支/加入框架,您学到了不应在任务内部使用 I/O 操作,如从文件中读取或写入数据。这是因为当线程阻塞读取或写入文件中的数据时,框架不使用工作窃取算法。由于我们使用文件作为流的源,因此我们正在惩罚我们的并发解决方案。
解决这个问题的一个方法是将数据读取到数据结构中,然后从该数据结构创建流。显然,与其他方法相比,这种方法的执行时间会更短,但我们希望比较串行和并行版本,以查看(正如我们所期望的那样)并行版本是否比串行版本具有更好的性能。这种方法的不好之处在于你需要将数据结构保存在内存中,因此你需要大量的内存。
这第四种方法是在 ConcurrentSearch 类的 preloadSearch()方法中实现的。该方法接收查询作为 String 的 Array 和 ConcurrentInvertedIndex 类的对象(稍后我们将看到该类的详细信息)作为参数。这是此版本的源代码:
public static void preloadSearch(String[] query, ConcurrentInvertedIndex invertedIndex) {
HashSet<String> set = new HashSet<>(Arrays.asList(query));
QueryResult results = new QueryResult(new ConcurrentHashMap<>());
invertedIndex.getIndex()
.parallelStream()
.filter(token -> set.contains(token.getWord()))
.forEach(results::append);
results
.getAsList()
.stream()
.sorted()
.limit(100)
.forEach(document -> System.out.println(document));
System.out.println("Preload Search Ok.");
}
ConcurrentInvertedIndex 类具有 List
与其他方法一样,我们使用两个流:第一个流获取 Result 对象的 ConcurrentLinkedDeque,其中包含整个结果列表,第二个流将结果写入控制台。第二个流与其他版本相同,但第一个流不同。我们在这个流中使用以下方法:
-
getIndex(): 首先,我们获取 Token 对象的列表 -
parallelStream(): 然后,我们创建一个并行流来处理列表的所有元素 -
filter(): 我们选择与查询中的单词相关联的标记 -
forEach(): 我们处理标记列表,使用 append()方法将它们添加到 QueryResult 对象中
ConcurrentFileLoader 类
ConcurrentFileLoader 类将 invertedIndex.txt 文件的内容加载到内存中,其中包含倒排索引的信息。它提供了一个名为 load()的静态方法,该方法接收存储倒排索引的文件路径,并返回一个 ConcurrentInvertedIndex 对象。我们有以下代码:
public class ConcurrentFileLoader {
public ConcurrentInvertedIndex load(Path path) throws IOException {
ConcurrentInvertedIndex invertedIndex = new ConcurrentInvertedIndex();
ConcurrentLinkedDeque<Token> results=new ConcurrentLinkedDeque<>();
我们使用 try-with-resources 结构打开文件并创建一个流来处理所有行:
try (Stream<String> fileStream = Files.lines(path)) {
fileStream
.parallel()
.flatMap(ConcurrentSearch::limitedMapper)
.forEach(results::add);
}
invertedIndex.setIndex(new ArrayList<>(results));
return invertedIndex;
}
}
我们在流中使用以下方法:
-
parallel(): 我们将流转换为并行流 -
flatMap(): 我们使用 ConcurrentSearch 类的 limitedMapper()方法将行转换为 Token 对象的流 -
forEach(): 我们处理 Token 对象的列表,使用 add()方法将它们添加到 ConcurrentLinkedDeque 对象中
最后,我们将ConcurrentLinkedDeque对象转换为ArrayList,并使用setIndex()方法将其设置在InvertedIndex对象中。
第五种方法-使用我们自己的执行器
为了进一步说明这个例子,我们将测试另一个并发版本。正如我们在本章的介绍中提到的,并行流使用了 Java 8 中引入的常见 Fork/Join 池。然而,我们可以使用一个技巧来使用我们自己的池。如果我们将我们的方法作为 Fork/Join 池的任务执行,流的所有操作将在同一个 Fork/Join 池中执行。为了测试这个功能,我们已经在ConcurrentSearch类中添加了executorSearch()方法。该方法接收查询作为String对象数组的参数,InvertedIndex对象和ForkJoinPool对象。这是该方法的源代码:
public static void executorSearch(String[] query, ConcurrentInvertedIndex invertedIndex, ForkJoinPool pool) {
HashSet<String> set = new HashSet<>(Arrays.asList(query));
QueryResult results = new QueryResult(new ConcurrentHashMap<>());
pool.submit(() -> {
invertedIndex.getIndex()
.parallelStream()
.filter(token -> set.contains(token.getWord()))
.forEach(results::append);
results
.getAsList()
.stream()
.sorted()
.limit(100)
.forEach(document -> System.out.println(document));
}).join();
System.out.println("Executor Search Ok.");
}
我们使用submit()方法将该方法的内容及其两个流作为 Fork/Join 池中的任务执行,并使用join()方法等待其完成。
从倒排索引获取数据-ConcurrentData类
我们已经实现了一些方法,使用ConcurrentData类中的reduce()方法获取有关倒排索引的信息。
获取文件中的单词数
第一个方法计算文件中的单词数。正如我们在本章前面提到的,倒排索引存储了单词出现的文件。如果我们想知道出现在文件中的单词,我们必须处理整个倒排索引。我们已经实现了这个方法的两个版本。第一个版本实现在getWordsInFile1()中。它接收文件的名称和InvertedIndex对象作为参数,如下所示:
public static void getWordsInFile1(String fileName, ConcurrentInvertedIndex index) {
long value = index
.getIndex()
.parallelStream()
.filter(token -> fileName.equals(token.getFile()))
.count();
System.out.println("Words in File "+fileName+": "+value);
}
在这种情况下,我们使用getIndex()方法获取Token对象的列表,并使用parallelStream()方法创建并行流。然后,我们使用filter()方法过滤与文件相关的令牌,最后,我们使用count()方法计算与该文件相关的单词数。
我们已经实现了该方法的另一个版本,使用reduce()方法而不是count()方法。这是getWordsInFile2()方法:
public static void getWordsInFile2(String fileName, ConcurrentInvertedIndex index) {
long value = index
.getIndex()
.parallelStream()
.filter(token -> fileName.equals(token.getFile()))
.mapToLong(token -> 1)
.reduce(0, Long::sum);
System.out.println("Words in File "+fileName+": "+value);
}
操作序列的开始与前一个相同。当我们获得了文件中单词的Token对象流时,我们使用mapToInt()方法将该流转换为1的流,然后使用reduce()方法来求和所有1的数字。
获取文件中的平均 tfxidf 值
我们已经实现了getAverageTfxidf()方法,它计算集合中文件的单词的平均tfxidf值。我们在这里使用了reduce()方法来展示它的工作原理。您可以在这里使用其他方法来获得更好的性能:
public static void getAverageTfxidf(String fileName, ConcurrentInvertedIndex index) {
long wordCounter = index
.getIndex()
.parallelStream()
.filter(token -> fileName.equals(token.getFile()))
.mapToLong(token -> 1)
.reduce(0, Long::sum);
double tfxidf = index
.getIndex()
.parallelStream()
.filter(token -> fileName.equals(token.getFile()))
.reduce(0d, (n,t) -> n+t.getTfxidf(), (n1,n2) -> n1+n2);
System.out.println("Words in File "+fileName+": "+(tfxidf/wordCounter));
}
我们使用了两个流。第一个计算文件中的单词数,其源代码与getWordsInFile2()方法相同。第二个计算文件中所有单词的总tfxidf值。我们使用相同的方法来获取文件中单词的Token对象流,然后我们使用reduce方法来计算所有单词的tfxidf值的总和。我们将以下三个参数传递给reduce()方法:
-
O: 这作为标识值传递。 -
(n,t) -> n+t.getTfxidf(): 这作为accumulator函数传递。它接收一个double数字和一个Token对象,并计算数字和令牌的tfxidf属性的总和。 -
(n1,n2) -> n1+n2: 这作为combiner函数传递。它接收两个数字并计算它们的总和。
获取索引中的最大和最小 tfxidf 值
我们还使用reduce()方法在maxTfxidf()和minTfxidf()方法中计算倒排索引的最大和最小tfxidf值:
public static void maxTfxidf(ConcurrentInvertedIndex index) {
Token token = index
.getIndex()
.parallelStream()
.reduce(new Token("", "xxx:0"), (t1, t2) -> {
if (t1.getTfxidf()>t2.getTfxidf()) {
return t1;
} else {
return t2;
}
});
System.out.println(token.toString());
}
该方法接收ConcurrentInvertedIndex作为参数。我们使用getIndex()来获取Token对象的列表。然后,我们使用parallelStream()方法在列表上创建并行流,使用reduce()方法来获取具有最大tfxidf的Token。在这种情况下,我们使用两个参数的reduce()方法:一个身份值和一个accumulator函数。身份值是一个Token对象。我们不关心单词和文件名,但是我们将其tfxidf属性初始化为值0。然后,accumulator函数接收两个Token对象作为参数。我们比较两个对象的tfxidf属性,并返回具有更大值的对象。
minTfxidf()方法非常相似,如下所示:
public static void minTfxidf(ConcurrentInvertedIndex index) {
Token token = index
.getIndex()
.parallelStream()
.reduce(new Token("", "xxx:1000000"), (t1, t2) -> {
if (t1.getTfxidf()<t2.getTfxidf()) {
return t1;
} else {
return t2;
}
});
System.out.println(token.toString());
}
主要区别在于,在这种情况下,身份值用非常高的值初始化了tfxidf属性。
ConcurrentMain 类
为了测试前面部分中解释的所有方法,我们实现了ConcurrentMain类,该类实现了main()方法来启动我们的测试。在这些测试中,我们使用了以下三个查询:
-
query1,使用单词james和bond -
query2,使用单词gone,with,the和wind -
query3,使用单词rocky
我们已经使用三个版本的搜索过程测试了三个查询,测量了每个测试的执行时间。所有测试都类似于这样的代码:
public class ConcurrentMain {
public static void main(String[] args) {
String query1[]={"james","bond"};
String query2[]={"gone","with","the","wind"};
String query3[]={"rocky"};
Date start, end;
bufferResults.append("Version 1, query 1, concurrent\n");
start = new Date();
ConcurrentSearch.basicSearch(query1);
end = new Date();
bufferResults.append("Execution Time: "
+ (end.getTime() - start.getTime()) + "\n");
要将倒排索引从文件加载到InvertedIndex对象中,您可以使用以下代码:
ConcurrentInvertedIndex invertedIndex = new ConcurrentInvertedIndex();
ConcurrentFileLoader loader = new ConcurrentFileLoader();
invertedIndex = loader.load(Paths.get("index","invertedIndex.txt"));
要创建用于executorSearch()方法的Executor,您可以使用以下代码:
ForkJoinPool pool = new ForkJoinPool();
串行版本
我们已经实现了这个示例的串行版本,使用了SerialSearch,SerialData,SerialInvertendIndex,SerialFileLoader和SerialMain类。为了实现该版本,我们进行了以下更改:
-
使用顺序流而不是并行流。您必须删除使用
parallel()方法将流转换为并行流的用法,或者将parallelStream()方法替换为stream()方法以创建顺序流。 -
在
SerialFileLoader类中,使用ArrayList而不是ConcurrentLinkedDeque。
比较解决方案
让我们比较我们实现的所有方法的串行和并行版本的解决方案。我们使用 JMH 框架(openjdk.java.net/projects/code-tools/jmh/)执行它们,该框架允许您在 Java 中实现微基准测试。使用基准测试框架比仅使用currentTimeMillis()或nanoTime()等方法测量时间更好。我们在具有四核处理器的计算机上执行了 10 次,因此并行算法在理论上可以比串行算法快四倍。请注意,我们已经实现了一个特殊的类来执行 JMH 测试。您可以在源代码的com.javferna.packtpub.mastering.irsystem.benchmark包中找到这些类。
对于第一个查询,使用单词james和bond,这些是以毫秒为单位获得的执行时间:
| 串行 | 并行 | |
|---|---|---|
| 基本搜索 | 3516.674 | 3301.334 |
| 减少搜索 | 3458.351 | 3230.017 |
| HTML 搜索 | 3298.996 | 3298.632 |
| 预加载搜索 | 153.414 | 105.195 |
| 执行器搜索 | 154.679 | 102.135 |
对于第二个查询,使用单词gone,with,the和wind,这些是以毫秒为单位获得的执行时间:
| 串行 | 并行 | |
|---|---|---|
| 基本搜索 | 3446.022 | 3441.002 |
| 减少搜索 | 3249.930 | 3260.026 |
| HTML 搜索 | 3299.625 | 3379.277 |
| 预加载搜索 | 154.631 | 113.757 |
| 执行器搜索 | 156.091 | 106.418 |
对于第三个查询,使用单词rocky,这些是以毫秒为单位获得的执行时间:
| 串行 | 并行 | |
|---|---|---|
| 基本搜索 | 3271.308 | 3219.990 |
| 减少搜索 | 3318.343 | 3279.247 |
| HTML 搜索 | 3323.345 | 3333.624 |
| 预加载搜索 | 151.416 | 97.092 |
| 执行器搜索 | 155.033 | 103.907 |
最后,这是返回有关倒排索引信息的方法的平均执行时间(毫秒):
| 串行 | 并发 | |
|---|---|---|
getWordsInFile1 |
131.066 | 81.357 |
getWordsInFile2 |
132.737 | 84.112 |
getAverageTfxidf |
253.067 | 166.009 |
maxTfxidf |
90.714 | 66.976 |
minTfxidf |
84.652 | 68.158 |
我们可以得出以下结论:
-
当我们读取倒排索引以获取相关文档列表时,执行时间变得更糟。在这种情况下,并发和串行版本之间的执行时间非常相似。
-
当我们使用倒排索引的预加载版本时,算法的并发版本在所有情况下都给我们更好的性能。
-
对于给我们提供倒排索引信息的方法,并发版本的算法总是给我们更好的性能。
我们可以通过速度提升来比较并行和顺序流在这个结束的三个查询中的表现:

最后,在我们的第三种方法中,我们生成了一个包含查询结果的 HTML 网页。这是查询james bond的第一个结果:

对于查询gone with the wind,这是第一个结果:

最后,这是查询rocky的第一个结果:

摘要
在本章中,我们介绍了流,这是 Java 8 中引入的一个受函数式编程启发的新功能,并准备好使用新的 lambda 表达式。流是一系列数据(不是数据结构),允许您以顺序或并发的方式应用一系列操作来过滤、转换、排序、减少或组织这些元素以获得最终对象。
您还学习了流的主要特征,当我们在顺序或并发应用程序中使用流时,我们必须考虑这些特征。
最后,我们在两个示例中使用了流。在第一个示例中,我们使用了Stream接口提供的几乎所有方法来计算大型数据集的统计数据。我们使用了 UCI 机器学习库的银行营销数据集,其中包含 45211 条记录。在第二个示例中,我们实现了不同的方法来搜索倒排索引中与查询相关的最相关文档。这是信息检索领域中最常见的任务之一。为此,我们使用reduce()方法作为流的终端操作。
在下一章中,我们将继续使用流,但更专注于collect()终端操作。
第八章:使用并行流处理大型数据集-映射和收集模型
在第七章中,使用并行流处理大型数据集-映射和减少模型,我们介绍了流的概念,这是 Java 8 的新功能。流是可以以并行或顺序方式处理的元素序列。在本章中,您将学习如何处理流,内容包括以下主题:
-
collect()方法
-
第一个例子-没有索引的搜索数据
-
第二个例子-推荐系统
-
第三个例子-社交网络中的常见联系人
使用流来收集数据
在第七章中,使用并行流处理大型数据集-映射和减少模型,我们对流进行了介绍。让我们记住它们最重要的特点:
-
流的元素不存储在内存中
-
流不能重复使用
-
流对数据进行延迟处理
-
流操作不能修改流源
-
流允许您链接操作,因此一个操作的输出是下一个操作的输入
流由以下三个主要元素组成:
-
生成流元素的源
-
零个或多个生成另一个流作为输出的中间操作
-
生成结果的一个终端操作,可以是简单对象、数组、集合、映射或其他任何东西
Stream API 提供了不同的终端操作,但有两个更重要的操作,因为它们具有灵活性和强大性。在第七章中,使用并行流处理大型数据集-映射和减少模型,您学习了如何使用 reduce()方法,在本章中,您将学习如何使用 collect()方法。让我们介绍一下这个方法。
collect()方法
collect()方法允许您转换和分组流的元素,生成一个新的数据结构,其中包含流的最终结果。您可以使用最多三种不同的数据类型:输入数据类型,来自流的输入元素的数据类型,用于在 collect()方法运行时存储元素的中间数据类型,以及 collect()方法返回的输出数据类型。
collect()方法有两个不同的版本。第一个版本接受以下三个函数参数:
-
供应商:这是一个创建中间数据类型对象的函数。如果您使用顺序流,此方法将被调用一次。如果您使用并行流,此方法可能会被多次调用,并且必须每次产生一个新的对象。
-
累加器:此函数用于处理输入元素并将其存储在中间数据结构中。
-
组合器:此函数用于将两个中间数据结构合并为一个。此函数仅在并行流中调用。
这个版本的 collect()方法使用两种不同的数据类型:来自流的元素的输入数据类型和将用于存储中间元素并返回最终结果的中间数据类型。
collect()方法的第二个版本接受实现 Collector 接口的对象。您可以自己实现这个接口,但使用 Collector.of()静态方法会更容易。此方法的参数如下:
-
供应商:此函数创建中间数据类型的对象,并且它的工作方式如前所述
-
累加器:调用此函数来处理输入元素,必要时对其进行转换,并将其存储在中间数据结构中
-
组合器:调用此函数将两个中间数据结构合并为一个,它的工作方式如前所述
-
完成器:如果需要进行最终转换或计算,则调用此函数将中间数据结构转换为最终数据结构
-
特征:您可以使用这个最终变量参数来指示您正在创建的收集器的一些特征
实际上,这两个版本之间有轻微的区别。三参数 collect 接受一个组合器,即BiConsumer,它必须将第二个中间结果合并到第一个中间结果中。与此不同的是,这个组合器是BinaryOperator,应该返回组合器。因此,它有自由地将第二个合并到第一个中间结果中,或者将第一个合并到第二个中间结果中,或者创建一个新的中间结果。of()方法还有另一个版本,它接受相同的参数,除了完成器;在这种情况下,不执行完成转换。
Java 为您提供了Collectors工厂类中的一些预定义收集器。您可以使用其中的一个静态方法获取这些收集器。其中一些方法是:
-
averagingDouble(),averagingInt()和averagingLong():这将返回一个收集器,允许您计算double,int或long函数的算术平均值。 -
groupingBy(): 这将返回一个收集器,允许您根据对象的属性对流的元素进行分组,生成一个映射,其中键是所选属性的值,值是具有确定值的对象的列表。 -
groupingByConcurrent(): 这与前一个方法类似,除了两个重要的区别。第一个区别是它在并行模式下可能比groupingBy()方法更快,但在顺序模式下可能更慢。第二个最重要的区别是groupingByConcurrent()函数是一个无序的收集器。列表中的项目不能保证与流中的顺序相同。另一方面,groupingBy()收集器保证了顺序。 -
joining(): 这将返回一个Collector工厂类,将输入元素连接成一个字符串。 -
partitioningBy(): 这将返回一个Collector工厂类,根据谓词的结果对输入元素进行分区。 -
summarizingDouble(),summarizingInt()和summarizingLong():这些返回一个Collector工厂类,用于计算输入元素的摘要统计信息。 -
toMap(): 这将返回一个Collector工厂类,允许您根据两个映射函数将输入元素转换为一个映射。 -
toConcurrentMap(): 这与前一个方法类似,但是以并发方式进行。没有自定义合并器,toConcurrentMap()对于并行流只是更快。与groupingByConcurrent()一样,这也是一个无序的收集器,而toMap()使用遇到的顺序进行转换。 -
toList(): 这将返回一个Collector工厂类,将输入元素存储到一个列表中。 -
toCollection(): 这个方法允许你将输入元素累积到一个新的Collection工厂类(TreeSet,LinkedHashSet等)中,按照遇到的顺序。该方法接收一个Supplier接口的实现作为参数,用于创建集合。 -
maxBy()和minBy():这将返回一个Collector工厂类,根据传递的比较器产生最大和最小的元素。 -
toSet(): 这将返回一个Collector,将输入元素存储到一个集合中。
第一个例子 - 在没有索引的情况下搜索数据
在第七章中,使用并行流处理大规模数据集 - 映射和归约模型,您学习了如何实现搜索工具,以查找与输入查询类似的文档,使用倒排索引。这种数据结构使搜索操作更容易和更快,但会有情况,您将不得不对大量数据进行搜索操作,并且没有倒排索引来帮助您。在这些情况下,您必须处理数据集的所有元素才能获得正确的结果。在本例中,您将看到其中一种情况以及Stream API 的reduce()方法如何帮助您。
为了实现这个例子,您将使用亚马逊产品共购买网络元数据的子集,其中包括亚马逊销售的 548,552 个产品的信息,包括标题、销售排名以及相似产品、分类和评论列表。您可以从snap.stanford.edu/data/amazon-meta.html下载这个数据集。我们已经取出了前 20,000 个产品,并将每个产品记录存储在单独的文件中。我们已更改了一些字段的格式,以便简化数据处理。所有字段都具有property:value格式。
基本类
我们有一些在并发和串行版本之间共享的类。让我们看看每个类的细节。
Product 类
Product类存储有关产品的信息。以下是Product类:
-
id:这是产品的唯一标识符。 -
asin:这是亚马逊的标准识别号。 -
title:这是产品的标题。 -
group:这是产品的组。该属性可以取值Baby Product、Book、CD、DVD、Music、Software、Sports、Toy、Video或Video Games。 -
salesrank:这表示亚马逊的销售排名。 -
similar:这是文件中包含的相似商品的数量。 -
categories:这是一个包含产品分类的String对象列表。 -
reviews:这是一个包含产品评论(用户和值)的Review对象列表。
该类仅包括属性的定义和相应的getXXX()和setXXX()方法,因此其源代码未包含在内。
评论类
正如我们之前提到的,Product类包括一个Review对象列表,其中包含用户对产品的评论信息。该类将每个评论的信息存储在以下两个属性中:
-
user:进行评论的用户的内部代码 -
value:用户对产品给出的评分
该类仅包括属性的定义和相应的getXXX()和setXXX()方法,因此其源代码未包含在内。
ProductLoader 类
ProductLoader类允许您从文件加载产品的信息到Product对象中。它实现了load()方法,该方法接收一个包含产品信息文件路径的Path对象,并返回一个Product对象。以下是其源代码:
public class ProductLoader {
public static Product load(Path path) {
try (BufferedReader reader = Files.newBufferedReader(path)) {
Product product=new Product();
String line=reader.readLine();
product.setId(line.split(":")[1]);
line=reader.readLine();
product.setAsin(line.split(":")[1]);
line=reader.readLine();
product.setTitle(line.substring (line.indexOf(':')+1));
line=reader.readLine();
product.setGroup(line.split(":")[1]);
line=reader.readLine();
product.setSalesrank(Long.parseLong (line.split(":")[1]));
line=reader.readLine();
product.setSimilar(line.split(":")[1]);
line=reader.readLine();
int numItems=Integer.parseInt(line.split(":")[1]);
for (int i=0; i<numItems; i++) {
line=reader.readLine();
product.addCategory(line.split(":")[1]);
}
line=reader.readLine();
numItems=Integer.parseInt(line.split(":")[1]);
for (int i=0; i<numItems; i++) {
line=reader.readLine();
String tokens[]=line.split(":");
Review review=new Review();
review.setUser(tokens[1]);
review.setValue(Short.parseShort(tokens[2]));
product.addReview(review);
}
return product;
} catch (IOException x) {
throw newe UncheckedIOException(x);
}
}
}
第一种方法 - 基本搜索
第一种方法接收一个单词作为输入查询,并搜索存储产品信息的所有文件,无论该单词是否包含在定义产品的字段中的一个中。它只会显示包含该单词的文件的名称。
为了实现这种基本方法,我们实现了ConcurrentMainBasicSearch类,该类实现了main()方法。首先,我们初始化查询和存储所有文件的基本路径:
public class ConcurrentMainBasicSearch {
public static void main(String args[]) {
String query = args[0];
Path file = Paths.get("data");
我们只需要一个流来生成以下结果的字符串列表:
try {
Date start, end;
start = new Date();
ConcurrentLinkedDeque<String> results = Files
.walk(file, FileVisitOption.FOLLOW_LINKS)
.parallel()
.filter(f -> f.toString().endsWith(".txt"))
.collect(ArrayList<String>::new,
new ConcurrentStringAccumulator (query),
List::addAll);
end = new Date();
我们的流包含以下元素:
-
我们使用
Files类的walk()方法启动流,将我们文件集合的基本Path对象作为参数传递。该方法将返回所有文件和存储在该路径下的目录作为流。 -
然后,我们使用
parallel()方法将流转换为并发流。 -
我们只对以
.txt扩展名结尾的文件感兴趣,因此我们使用filter()方法对它们进行过滤。 -
最后,我们使用
collect()方法将Path对象的流转换为ConcurrentLinkedDeque对象,其中包含文件名的String对象。
我们使用collect()方法的三个参数版本,使用以下功能参数:
-
供应商:我们使用
ArrayList类的new方法引用来为每个线程创建一个新的数据结构,以存储相应的结果。 -
累加器:我们在
ConcurrentStringAccumulator类中实现了自己的累加器。稍后我们将描述这个类的细节。 -
组合器:我们使用
ConcurrentLinkedDeque类的addAll()方法来连接两个数据结构。在这种情况下,第二个集合中的所有元素将被添加到第一个集合中。第一个集合将用于进一步组合或作为最终结果。
最后,我们在控制台中写入流获得的结果:
System.out.println("Results for Query: "+query);
System.out.println("*************");
results.forEach(System.out::println);
System.out.println("Execution Time: "+(end.getTime()- start.getTime()));
} catch (IOException e) {
e.printStackTrace();
}
}
}
每当我们要处理流的路径以评估是否将其名称包含在结果列表中时,累加器功能参数将被执行。为了实现这个功能,我们实现了ConcurrentStringAccumulator类。让我们看看这个类的细节。
ConcurrentStringAccumulator 类
ConcurrentStringAccumulator类加载包含产品信息的文件,以确定是否包含查询的术语。它实现了BiConsumer接口,因为我们希望将其用作collect()方法的参数。我们已经使用List<String>和Path类对该接口进行了参数化:
public class ConcurrentStringAccumulator implements BiConsumer<List<String>, Path> {
它将查询定义为内部属性,在构造函数中初始化如下:
private String word;
public ConcurrentStringAccumulator (String word) {
this.word=word.toLowerCase();
}
然后,我们实现了BiConsumer接口中定义的accept()方法。该方法接收两个参数:ConcurrentLinkedDeque<String>类和Path类中的一个。
为了加载文件并确定它是否包含查询,我们使用以下流:
@Override
public void accept(List<String> list, Path path) {
boolean result;
try (Stream<String> lines = Files.lines(path)) {
result = lines
.parallel()
.map(l -> l.split(":")[1].toLowerCase())
.anyMatch(l -> l.contains(word))
我们的流包含以下元素:
-
我们使用
Files类的lines()方法创建String对象的流,在 try-with-resources 语句中。该方法接收一个指向文件的Path对象作为参数,并返回文件的所有行的流。 -
然后,我们使用
parallel()方法将流转换为并发流。 -
然后,我们使用
map()方法获取每个属性的值。正如我们在本节的介绍中提到的,每行都具有property:value格式。 -
最后,我们使用
anyMatch()方法来知道是否有任何属性的值包含查询词。
如果计数变量的值大于0,则文件包含查询词,我们将文件名包含在结果的ConcurrentLinkedDeque类中:
if (counter>0) {
list.add(path.toString());
}
} catch (Exception e) {
System.out.println(path);
e.printStackTrace();
}
}
}
第二种方法-高级搜索
我们的基本搜索有一些缺点:
-
我们在所有属性中寻找查询词,但也许我们只想在其中一些属性中寻找,例如标题
-
我们只显示文件的名称,但如果我们显示额外信息,如产品的标题,将更具信息性
为了解决这些问题,我们将实现实现main()方法的ConcurrentMainSearch类。首先,我们初始化查询和存储所有文件的基本Path对象:
public class ConcurrentMainSearch {
public static void main(String args[]) {
String query = args[0];
Path file = Paths.get("data");
然后,我们使用以下流生成Product对象的ConcurrentLinkedDeque类:
try {
Date start, end;
start=new Date();
ConcurrentLinkedDeque<Product> results = Files
.walk(file, FileVisitOption.FOLLOW_LINKS)
.parallel()
.filter(f -> f.toString().endsWith(".txt"))
.collect(ArrayList<Product>::new,
new ConcurrentObjectAccumulator (query),
List::addAll);
end=new Date();
这个流与我们在基本方法中实现的流具有相同的元素,有以下两个变化:
-
在
collect()方法中,我们在累加器参数中使用ConcurrentObjectAccumulator类 -
我们使用
Product类参数化ConcurrentLinkedDeque类
最后,我们将结果写入控制台,但在这种情况下,我们写入每个产品的标题:
System.out.println("Results");
System.out.println("*************");
results.forEach(p -> System.out.println(p.getTitle()));
System.out.println("Execution Time: "+(end.getTime()- start.getTime()));
} catch (IOException e) {
e.printStackTrace();
}
}
}
您可以更改此代码以写入有关产品的任何信息,如销售排名或类别。
这个实现与之前的实现之间最重要的变化是ConcurrentObjectAccumulator类。让我们看看这个类的细节。
ConcurrentObjectAccumulator 类
ConcurrentObjectAccumulator类实现了参数化为ConcurrentLinkedDeque<Product>和Path类的BiConsumer接口,因为我们希望在collect()方法中使用它。它定义了一个名为word的内部属性来存储查询词。这个属性在类的构造函数中初始化:
public class ConcurrentObjectAccumulator implements
BiConsumer<List<Product>, Path> {
private String word;
public ConcurrentObjectAccumulator(String word) {
this.word = word;
}
accept()方法的实现(在BiConsumer接口中定义)非常简单:
@Override
public void accept(List<Product> list, Path path) {
Product product=ProductLoader.load(path);
if (product.getTitle().toLowerCase().contains (word.toLowerCase())) {
list.add(product);
}
}
}
该方法接收指向我们要处理的文件的Path对象作为参数,并使用ConcurrentLinkedDeque类来存储结果。我们使用ProductLoader类将文件加载到Product对象中,然后检查产品的标题是否包含查询词。如果包含查询词,我们将Product对象添加到ConcurrentLinkedDeque类中。
示例的串行实现
与本书中的其他示例一样,我们已经实现了搜索操作的两个版本的串行版本,以验证并行流是否能够提高性能。
您可以通过删除Stream对象中的parallel()调用来实现前面描述的四个类的串行等效版本,以使流并行化。
我们已经包含了书籍的源代码,其中包括SerialMainBasicSearch、SerialMainSearch、SerialStringAccumulator和SerialObjectAccumulator类,它们是串行版本的等效类,其中包括前面注释的更改。
比较实现
我们已经测试了我们的实现(两种方法:串行和并行版本)以比较它们的执行时间。为了测试它们,我们使用了三个不同的查询:
-
模式
-
Java
-
树
对于每个查询,我们已经执行了串行和并行流的两个搜索操作(基本和对象)。我们使用了 JMH 框架(openjdk.java.net/projects/code-tools/jmh/)来执行它们,该框架允许您在 Java 中实现微基准测试。使用基准测试框架比简单地使用currentTimeMillis()或nanoTime()等方法来测量时间更好。我们在一个四核处理器的计算机上执行了 10 次,并计算了这 10 次的平均执行时间。以下是以毫秒为单位的结果:
| 字符串搜索 | 对象搜索 | |
|---|---|---|
| Java | 模式 | |
| 串行 | 4318.551 | 4372.565 |
| 并行 | 32402.969 | 2428.729 |
我们可以得出以下结论:
-
不同查询的结果非常相似
-
使用串行流,字符串搜索的执行时间比对象搜索的执行时间更好
-
使用并行流,对象搜索的执行时间比字符串搜索的执行时间更好
-
并行流在所有情况下都比串行流获得更好的性能
例如,如果我们比较并行和串行版本,对于使用速度提升的查询模式进行对象搜索,我们会得到以下结果:

第二个示例 - 推荐系统
推荐系统根据客户购买/使用的产品/服务以及购买/使用与他相同服务的用户购买/使用的产品/服务向客户推荐产品或服务。
我们已经使用了前一节中解释的示例来实现推荐系统。每个产品的描述都包括一些客户对产品的评论。这个评论包括客户对产品的评分。
在这个例子中,您将使用这些评论来获取对客户可能感兴趣的产品的列表。我们将获取客户购买的产品列表。为了获取该列表,我们对购买这些产品的用户列表以及这些用户购买的产品列表进行排序,使用评论中给出的平均分数。这将是用户的建议产品。
通用类
我们已经添加了两个新的类到前一节中使用的类中。这些类是:
-
ProductReview:这个类通过添加两个新属性扩展了产品类 -
ProductRecommendation:这个类存储了对产品的推荐的信息
让我们看看这两个类的细节。
ProductReview 类
ProductReview类通过添加两个新属性扩展了Product类:
-
buyer:这个属性存储产品的客户的姓名 -
value:这个属性存储客户在评论中给产品的评分
该类包括属性的定义:相应的getXXX()和setXXX()方法,一个从Product对象创建ProductReview对象的构造函数,以及新属性的值。它非常简单,所以它的源代码没有包含在内。
ProductRecommendation 类
ProductRecommendation类存储了产品推荐所需的信息,包括以下内容:
-
title:我们正在推荐的产品的标题 -
value:该推荐的分数,计算为该产品所有评论的平均分数
这个类包括属性的定义,相应的getXXX()和setXXX()方法,以及compareTo()方法的实现(该类实现了Comparable接口),这将允许我们按照其值的降序对推荐进行排序。它非常简单,所以它的源代码没有包含在内。
推荐系统 - 主类
我们已经在ConcurrentMainRecommendation类中实现了我们的算法,以获取推荐给客户的产品列表。这个类实现了main()方法,该方法接收客户的 ID 作为参数,我们想要获取推荐的产品。我们有以下代码:
public static void main(String[] args) {
String user = args[0];
Path file = Paths.get("data");
try {
Date start, end;
start=new Date();
我们已经使用不同的流来转换最终解决方案中的数据。第一个加载整个Product对象列表的流来自其文件:
List<Product> productList = Files
.walk(file, FileVisitOption.FOLLOW_LINKS)
.parallel()
.filter(f -> f.toString().endsWith(".txt"))
.collect(ConcurrentLinkedDeque<Product>::new
,new ConcurrentLoaderAccumulator(), ConcurrentLinkedDeque::addAll);
这个流有以下元素:
-
我们使用
Files类的walk()方法开始流。这个方法将创建一个流来处理数据目录下的所有文件和目录。 -
然后,我们使用
parallel()方法将流转换为并发流。 -
然后,我们只获取扩展名为
.txt的文件。 -
最后,我们使用
collect()方法来获取ConcurrentLinkedDeque类的Product对象。它与前一节中使用的方法非常相似,不同之处在于我们使用了另一个累加器对象。在这种情况下,我们使用ConcurrentLoaderAccumulator类,稍后我们将对其进行描述。
一旦我们有了产品列表,我们将使用客户的标识符作为地图的键来组织这些产品。我们使用ProductReview类来存储产品的客户信息。我们将创建与Product有关的评论数量相同的ProductReview对象。我们使用以下流进行转换:
Map<String, List<ProductReview>> productsByBuyer=productList
.parallelStream()
.<ProductReview>flatMap(p -> p.getReviews().stream().map(r -> new ProductReview(p, r.getUser(), r.getValue())))
.collect(Collectors.groupingByConcurrent( p -> p.getBuyer()));
这个流有以下元素:
-
我们使用
productList对象的parallelStream()方法开始流,因此我们创建了一个并发流。 -
然后,我们使用
flatMap()方法将我们拥有的Product对象流转换为唯一的ProductReview对象流。 -
最后,我们使用
collect()方法生成最终的映射。在这种情况下,我们使用Collectors类的groupingByConcurrent()方法生成的预定义收集器。返回的收集器将生成一个映射,其中键将是买家属性的不同值,值将是购买该用户的产品信息的ProductReview对象列表。如方法名称所示,此转换将以并发方式完成。
下一个流是此示例中最重要的流。我们获取客户购买的产品,并为该客户生成推荐。这是一个由一个流完成的两阶段过程。在第一阶段,我们获取购买原始客户购买的产品的用户。在第二阶段,我们生成一个包含这些客户购买的产品以及这些客户所做的所有产品评论的映射。以下是该流的代码:
Map<String,List<ProductReview>> recommendedProducts=productsByBuyer.get(user)
.parallelStream()
.map(p -> p.getReviews())
.flatMap(Collection::stream)
.map(r -> r.getUser())
.distinct()
.map(productsByBuyer::get)
.flatMap(Collection::stream)
.collect(Collectors.groupingByConcurrent(p -> p.getTitle()));
在该流中,我们有以下元素:
-
首先,我们获取用户购买的产品列表,并使用
parallelStream()方法生成并发流。 -
然后,我们使用
map()方法获取该产品的所有评论。 -
此时,我们有一个
List<Review>流。我们将该流转换为Review对象的流。现在我们有了一个包含用户购买产品的所有评论的流。 -
然后,我们将该流转换为包含进行评论的用户名称的
String对象流。 -
然后,我们使用
distinct()方法获取用户的唯一名称。现在我们有一个包含购买与原始用户相同产品的用户名称的String对象流。 -
然后,我们使用
map()方法将每个客户转换为其购买产品的列表。 -
此时,我们有一个
List<ProductReview>对象的流。我们使用flatMap()方法将该流转换为ProductReview对象的流。 -
最后,我们使用
collect()方法和groupingByConcurrent()收集器生成产品的映射。映射的键将是产品的标题,值将是先前获得的客户所做的评论的ProductReview对象列表。
要完成我们的推荐算法,我们需要最后一步。对于每个产品,我们想计算其评论的平均分,并按降序对列表进行排序,以便将评分最高的产品显示在第一位。为了进行该转换,我们使用了额外的流:
List<ProductRecommendation> recommendations = recommendedProducts
.entrySet()
.parallelStream()
.map(entry -> new
ProductRecommendation(
entry.getKey(),
entry.getValue().stream().mapToInt(p -> p.getValue()).average().getAsDouble()))
.sorted()
.collect(Collectors.toList());
end=new Date();
recommendations. forEach(pr -> System.out.println (pr.getTitle()+": "+pr.getValue()));
System.out.println("Execution Time: "+(end.getTime()- start.getTime()));
} catch (IOException e) {
e.printStackTrace();
}
}
}
我们处理前一步得到的映射。对于每个产品,我们处理其评论列表,生成一个ProductRecommendation对象。该对象的值是使用mapToInt()方法将ProductReview对象的流转换为整数流,并使用average()方法获取字符串中所有数字的平均值来计算每个评论的平均值。
最后,在推荐ConcurrentLinkedDeque类中,我们有一个ProductRecommendation对象列表。我们使用另一个带有sorted()方法的流对该列表进行排序。我们使用该流将最终列表写入控制台。
ConcurrentLoaderAccumulator 类
为了实现此示例,我们使用了ConcurrentLoaderAccumulator类作为collect()方法中的累加器函数,将包含所有要处理文件路径的Path对象流转换为Product对象的ConcurrentLinkedDeque类。以下是该类的源代码:
public class ConcurrentLoaderAccumulator implements
BiConsumer<ConcurrentLinkedDeque<Product>, Path> {
@Override
public void accept(ConcurrentLinkedDeque<Product> list, Path path) {
Product product=ProductLoader.load(path);
list.add(product);
}
}
它实现了BiConsumer接口。accept()方法使用ProducLoader类(在本章前面已经解释过)从文件中加载产品信息,并将生成的Product对象添加到作为参数接收的ConcurrentLinkedDeque类中。
串行版本
与本书中的其他示例一样,我们实现了此示例的串行版本,以检查并行流是否提高了应用程序的性能。要实现此串行版本,我们必须按照以下步骤进行:
-
将
ConcurrentLinkedDeque数据结构替换为List或ArrayList数据结构 -
将
parallelStrem()方法替换为stream()方法 -
将
gropingByConcurrent()方法替换为groupingBy()方法
您可以在本书的源代码中看到此示例的串行版本。
比较两个版本
为了比较我们的推荐系统的串行和并行版本,我们已经为三个用户获取了推荐的产品:
-
A2JOYUS36FLG4Z -
A2JW67OY8U6HHK -
A2VE83MZF98ITY
对于这三个用户,我们使用 JMH 框架(openjdk.java.net/projects/code-tools/jmh/)执行了两个版本,该框架允许您在 Java 中实现微基准测试。使用基准测试框架比简单地使用currentTimeMillis()或nanoTime()等方法来测量时间更好。我们在一个四核处理器的计算机上执行了 10 次,并计算了这 10 次的中位执行时间。以下是以毫秒为单位的结果:
| A2JOYUS36FLG4Z | A2JW67OY8U6HHK | A2VE83MZF98ITY | |
|---|---|---|---|
| 串行 | 4848.672 | 4830.051 | 4817.216 |
| 并行 | 2454.003 | 2458.003 | 2527.194 |
我们可以得出以下结论:
-
所得结果对于这三个用户来说非常相似
-
并行流的执行时间始终优于顺序流的执行时间
如果我们比较并行和串行版本,例如使用加速比的第二个用户,我们得到以下结果:

第三个例子 - 社交网络中的共同联系人
社交网络正在改变我们的社会以及人们之间的关系方式。Facebook、Linkedin、Twitter 或 Instagram 拥有数百万用户,他们使用这些网络与朋友分享生活时刻,建立新的职业联系,推广自己的专业品牌,结识新朋友,或者了解世界上的最新趋势。
我们可以将社交网络视为一个图,其中用户是图的节点,用户之间的关系是图的弧。与图一样,有些社交网络(如 Facebook)中用户之间的关系是无向的或双向的。如果用户A与用户B连接,用户B也与A连接。相反,有些社交网络(如 Twitter)中用户之间的关系是有向的。在这种情况下,我们说用户A关注用户B,但反之则不一定成立。
在这一部分,我们将实现一个算法来计算社交网络中每对用户的共同联系人,这些用户之间存在双向关系。我们将实现stevekrenzel.com/finding-friends-with-mapreduce中描述的算法。该算法的主要步骤如下。
我们的数据源将是一个文件,其中我们存储了每个用户及其联系人:
A-B,C,D,
B-A,C,D,E,
C-A,B,D,E,
D-A,B,C,E,
E-B,C,D,
这意味着用户A的联系人是B、C和D。请注意,关系是双向的,因此如果A是B的联系人,A也将是B的联系人,并且这两种关系都必须在文件中表示。因此,我们有以下两个部分的元素:
-
用户标识符
-
该用户的联系人列表
接下来,我们为每个元素生成一个由三部分组成的元素集。这三部分是:
-
用户标识符
-
朋友的用户标识符
-
该用户的联系人列表
因此,对于用户A,我们将生成以下元素:
A-B-B,C,D
A-C-B,C,D
A-D-B,C,D
我们对所有元素采取相同的处理过程。我们将按字母顺序存储两个用户标识符。因此,对于用户B,我们生成以下元素:
A-B-A,C,D,E
B-C-A,C,D,E
B-D-A,C,D,E
B-E-A,C,D,E
一旦我们生成了所有新元素,我们就将它们分组为两个用户标识符。例如,对于元组A-B,我们将生成以下组:
A-B-(B,C,D),(A,C,D,E)
最后,我们计算两个列表之间的交集。结果列表是两个用户之间的共同联系人。例如,用户A和B共同拥有联系人C和D。
为了测试我们的算法,我们使用了两个数据集:
-
之前呈现的测试样本。
-
社交圈:您可以从
snap.stanford.edu/data/egonets-Facebook.html下载的 Facebook 数据集包含来自 Facebook 的 4,039 个用户的联系信息。我们已将原始数据转换为我们示例使用的数据格式。
基类
与书中其他示例一样,我们实现了此示例的串行和并发版本,以验证并行流改进了我们应用程序的性能。两个版本共享一些类。
人员类
Person类存储了社交网络中每个人的信息,包括以下内容:
-
用户 ID,存储在 ID 属性中
-
该用户的联系人列表,存储为
String对象列表,存储在 contacts 属性中
该类声明了属性和相应的getXXX()和setXXX()方法。我们还需要一个构造函数来创建列表,以及一个名为addContact()的方法,用于将单个联系人添加到联系人列表中。该类的源代码非常简单,因此不会在此处包含。
PersonPair 类
PersonPair类扩展了Person类,添加了存储第二个用户标识符的属性。我们将此属性称为otherId。该类声明了属性并实现了相应的getXXX()和setXXX()方法。我们需要一个额外的方法,名为getFullId(),它返回一个由逗号分隔的两个用户标识符的字符串。该类的源代码非常简单,因此不会在此处包含。
数据加载器类
DataLoader类加载包含用户及其联系人信息的文件,并将其转换为Person对象列表。它只实现了一个名为load()的静态方法,该方法接收文件路径作为String对象参数,并返回Person对象列表。
如前所述,文件的格式如下:
User-C1,C2,C3...CN
在这里,User是用户的标识符,C1、C2、C3….CN是该用户的联系人的标识符。
该类的源代码非常简单,因此不会在此处包含。
并发版本
首先,让我们分析此算法的并发版本。
通用人员映射器类
CommonPersonMapper类是一个辅助类,稍后将使用它。它将从Person对象生成所有可能的PersonPair对象。该类实现了使用Person和List<PersonPair>类参数化的Function接口。
它实现了Function接口中定义的apply()方法。首先,我们初始化要返回的List<PersonPair>对象,并获取并对该人的联系人列表进行排序:
public class CommonPersonMapper implements Function<Person, List<PersonPair>> {
@Override
public List<PersonPair> apply(Person person) {
List<PersonPair> ret=new ArrayList<>();
List<String> contacts=person.getContacts();
Collections.sort(contacts);
然后,我们处理整个联系人列表,为每个联系人创建一个PersonPair对象。如前所述,我们将两个联系人按字母顺序排序。较小的联系人存储在 ID 字段中,另一个存储在otherId字段中:
for (String contact : contacts) {
PersonPair personExt=new PersonPair();
if (person.getId().compareTo(contact) < 0) {
personExt.setId(person.getId());
personExt.setOtherId(contact);
} else {
personExt.setId(contact);
personExt.setOtherId(person.getId());
}
最后,我们将联系人列表添加到新对象中,然后将对象添加到结果列表中。处理完所有联系人后,我们返回结果列表:
personExt.setContacts(contacts);
ret.add(personExt);
}
return ret;
}
}
ConcurrentSocialNetwork 类
ConcurrentSocialNetwork是这个示例的主要类。它只实现了一个名为bidirectionalCommonContacts()的静态方法。该方法接收社交网络中的人员列表及其联系人,并返回一个PersonPair对象列表,其中包含每对联系人之间的共同联系人。
在内部,我们使用两个不同的流来实现我们的算法。我们使用第一个流将Person对象的输入列表转换为映射。该映射的键将是每对用户的两个标识符,值将是包含两个用户联系人的PersonPair对象列表。因此,这些列表始终有两个元素。我们有以下代码:
public class ConcurrentSocialNetwork {
public static List<PersonPair> bidirectionalCommonContacts(
List<Person> people) {
Map<String, List<PersonPair>> group = people.parallelStream()
.map(new CommonPersonMapper())
.flatMap(Collection::stream)
.collect(Collectors.groupingByConcurrent (PersonPair::getFullId));
该流具有以下组件:
-
我们使用输入列表的
parallelStream()方法创建流。 -
然后,我们使用
map()方法和前面解释的CommonPersonMapper类来将每个Person对象转换为包含该对象所有可能性的PersonPair对象列表。 -
此时,我们有一个
List<PersonPair>对象的流。我们使用flatMap()方法将该流转换为PersonPair对象的流。 -
最后,我们使用
collect()方法使用groupingByConcurrent()方法返回的收集器生成映射,使用getFullId()方法返回的值作为映射的键。
然后,我们使用Collectors类的of()方法创建一个新的收集器。该收集器将接收一个字符串集合作为输入,使用AtomicReference<Collection<String>>作为中间数据结构,并返回一个字符串集合作为返回类型。
Collector<Collection<String>, AtomicReference<Collection<String>>, Collection<String>> intersecting = Collector.of(
() -> new AtomicReference<>(null), (acc, list) -> {
acc.updateAndGet(set -> set == null ? new ConcurrentLinkedQueue<>(list) : set).retainAll(list);
}, (acc1, acc2) -> {
if (acc1.get() == null)
return acc2;
if (acc2.get() == null)
return acc1;
acc1.get().retainAll(acc2.get());
return acc1;
}, (acc) -> acc.get() == null ? Collections.emptySet() : acc.get(), Collector.Characteristics.CONCURRENT, Collector.Characteristics.UNORDERED);
of()方法的第一个参数是 supplier 函数。当我们需要创建数据的中间结构时,总是调用此 supplier。在串行流中,此方法只调用一次,但在并发流中,此方法将根据线程数调用一次。
() -> new AtomicReference<>(null),
在我们的例子中,我们只需创建一个新的AtomicReference来存储Collection<String>对象。
of()方法的第二个参数是累加器函数。此函数接收中间数据结构和输入值作为参数:
(acc, list) -> {
acc.updateAndGet(set -> set == null ? new ConcurrentLinkedQueue<>(list) : set).retainAll(list);
},
在我们的例子中,acc参数是AtomicReference,list参数是ConcurrentLinkedDeque。我们使用AtomicReference的updateAndGet()方法。此方法更新当前值并返回新值。如果AtomicReference为 null,则使用列表的元素创建一个新的ConcurrentLinkedDeque。如果AtomicReference不为 null,则它将存储一个ConcurrentLinkedDeque。我们使用retainAll()方法添加列表的所有元素。
of()方法的第三个参数是 combiner 函数。此函数仅在并行流中调用,并接收两个中间数据结构作为参数,以生成一个中间数据结构。
(acc1, acc2) -> {
if (acc1.get() == null)
return acc2;
if (acc2.get() == null)
return acc1;
acc1.get().retainAll(acc2.get());
return acc1;
},
在我们的例子中,如果其中一个参数为 null,则返回另一个。否则,我们使用acc1参数中的retainAll()方法并返回结果。
of()方法的第四个参数是 finisher 函数。该函数将最终的中间数据结构转换为我们想要返回的数据结构。在我们的例子中,中间和最终的数据结构是相同的,因此不需要转换。
(acc) -> acc.get() == null ? Collections.emptySet() : acc.get(),
最后,我们使用最后一个参数来指示收集器是并发的,也就是说,累加器函数可以从多个线程同时调用相同的结果容器,并且是无序的,也就是说,此操作不会保留元素的原始顺序。
现在我们已经定义了收集器,我们必须将第一个流生成的映射转换为具有每对用户的共同联系人的PersonPair对象列表。我们使用以下代码:
List<PersonPair> peopleCommonContacts = group.entrySet()
.parallelStream()
.map((entry) -> {
Collection<String> commonContacts =
entry.getValue()
.parallelStream()
.map(p -> p.getContacts())
.collect(intersecting);
PersonPair person = new PersonPair();
person.setId(entry.getKey().split(",")[0]);
person.setOtherId(entry.getKey().split (",")[1]);
person.setContacts(new ArrayList<String> (commonContacts));
return person;
}).collect(Collectors.toList());
return peopleCommonContacts;
}
}
我们使用entySet()方法处理映射的所有元素。我们创建一个parallelStream()方法来处理所有Entry对象,然后使用map()方法将每个PersonPair对象列表转换为具有共同联系人的唯一PersonPair对象。
对于每个条目,键是由用户对的标识符连接而成的,作为分隔符,值是两个PersonPair对象的列表。第一个包含一个用户的联系人,另一个包含另一个用户的联系人。
我们为该列表创建一个流,以生成具有以下元素的两个用户的共同联系人:
-
我们使用列表的
parallelStream()方法创建流 -
我们使用
map()方法来替换其中存储的联系人列表的每个PersonPair()对象 -
最后,我们使用我们的收集器生成带有共同联系人的
ConcurrentLinkedDeque
最后,我们创建一个新的PersonPair对象,其中包含两个用户的标识符和共同联系人列表。我们将该对象添加到结果列表中。当映射的所有元素都被处理时,我们可以返回结果列表。
ConcurrentMain 类
ConcurrentMain类实现了main()方法来测试我们的算法。正如我们之前提到的,我们已经使用以下两个数据集进行了测试:
-
一个非常简单的数据集,用于测试算法的正确性
-
基于 Facebook 真实数据的数据集
这是这个类的源代码:
public class ConcurrentMain {
public static void main(String[] args) {
Date start, end;
System.out.println("Concurrent Main Bidirectional - Test");
List<Person> people=DataLoader.load("data","test.txt");
start=new Date();
List<PersonPair> peopleCommonContacts= ConcurrentSocialNetwork.bidirectionalCommonContacts (people);
end=new Date();
peopleCommonContacts.forEach(p -> System.out.println (p.getFullId()+": "+getContacts(p.getContacts())));
System.out.println("Execution Time: "+(end.getTime()- start.getTime()));
System.out.println("Concurrent Main Bidirectional - Facebook");
people=DataLoader.load("data","facebook_contacts.txt");
start=new Date();
peopleCommonContacts= ConcurrentSocialNetwork.bidirectionalCommonContacts (people);
end=new Date();
peopleCommonContacts.forEach(p -> System.out.println (p.getFullId()+": "+getContacts(p.getContacts())));
System.out.println("Execution Time: "+(end.getTime()- start.getTime()));
}
private static String formatContacts(List<String> contacts) {
StringBuffer buffer=new StringBuffer();
for (String contact: contacts) {
buffer.append(contact+",");
}
return buffer.toString();
}
}
串行版本
与本书中的其他示例一样,我们实现了这个示例的串行版本。这个版本与并发版本相同,做出以下更改:
-
用
stream()方法替换parallelStream()方法 -
用
ArrayList数据结构替换ConcurrentLinkedDeque数据结构 -
用
groupingBy()方法替换groupingByConcurrent()方法 -
不要在
of()方法中使用最终参数
比较两个版本
我们使用 JMH 框架(openjdk.java.net/projects/code-tools/jmh/)执行了两个版本和两个数据集。该框架允许您在 Java 中实现微基准测试。使用基准测试框架比简单使用currentTimeMillis()或nanoTime()等方法测量时间更好。我们在具有四核处理器的计算机上执行了 10 次,并计算了这 10 次的中等执行时间。以下是以毫秒为单位的结果:
| 示例 | ||
|---|---|---|
| 串行 | 0.861 | 7002.485 |
| 并发 | 1.352 | 5303.990 |
我们可以得出以下结论:
-
对于示例数据集,串行版本获得了更好的执行时间。这个结果的原因是示例数据集的元素很少。
-
对于 Facebook 数据集,并发版本获得了更好的执行时间。
如果我们比较 Facebook 数据集的并发和串行版本,我们会得到以下结果:

摘要
在本章中,我们使用Stream框架提供的不同版本的collect()方法来转换和分组Stream的元素。这和第七章,“使用并行流处理大型数据集 - 映射和归约模型”,教你如何使用整个流 API。
基本上,collect() 方法需要一个收集器来处理流的数据,并生成由流形成的一组聚合操作返回的数据结构。收集器与三种不同的数据结构一起工作——输入元素的类,用于处理输入元素的中间数据结构,以及返回的最终数据结构。
我们使用了不同版本的collect()方法来实现一个搜索工具,该工具必须在没有倒排索引的文件集中查找查询,一个推荐系统,以及一个工具来计算社交网络中两个用户之间的共同联系人。
在下一章中,我们将深入研究 Java 并发 API 提供的并发数据结构和同步机制。
第九章:深入研究并发数据结构和同步实用程序
在每个计算机程序中最重要的元素之一是数据结构。数据结构允许我们根据需要以不同的方式存储我们的应用程序读取、转换和写入的数据。选择适当的数据结构是获得良好性能的关键点。糟糕的选择可能会显着降低算法的性能。Java 并发 API 包括一些设计用于在并发应用程序中使用的数据结构,而不会引起数据不一致或信息丢失。
并发应用程序中另一个关键点是同步机制。您可以使用它们通过创建临界区来实现互斥,也就是说,只能由一个线程执行的代码段。但您还可以使用同步机制来实现线程之间的依赖关系,例如,并发任务必须等待另一个任务的完成。Java 并发 API 包括基本的同步机制,如synchronized关键字和非常高级的实用程序,例如CyclicBarrier类或您在第五章中使用的Phaser类,分阶段运行任务 - Phaser 类。
在本章中,我们将涵盖以下主题:
-
并发数据结构
-
同步机制
并发数据结构
每个计算机程序都使用数据。它们从数据库、文件或其他来源获取数据,转换数据,然后将转换后的数据写入数据库、文件或其他目的地。程序使用存储在内存中的数据,并使用数据结构将数据存储在内存中。
当您实现并发应用程序时,您必须非常小心地使用数据结构。如果不同的线程可以修改唯一数据结构中存储的数据,您必须使用同步机制来保护该数据结构上的修改。如果不这样做,可能会出现数据竞争条件。您的应用程序有时可能会正常工作,但下一次可能会因为随机异常而崩溃,在无限循环中卡住,或者悄悄地产生不正确的结果。结果将取决于执行的顺序。
为了避免数据竞争条件,您可以:
-
使用非同步数据结构,并自行添加同步机制
-
使用 Java 并发 API 提供的数据结构,它在内部实现了同步机制,并经过优化,可用于并发应用程序
第二个选项是最推荐的。在本节的页面中,您将回顾最重要的并发数据结构,特别关注 Java 8 的新功能。
阻塞和非阻塞数据结构
Java 并发 API 提供了两种类型的并发数据结构:
-
阻塞数据结构:这种数据结构提供了在其中插入和删除数据的方法,当操作无法立即完成时(例如,如果您想取出一个元素而数据结构为空),发出调用的线程将被阻塞,直到操作可以完成
-
非阻塞数据结构:这种数据结构提供了在其中插入和删除数据的方法,当操作无法立即完成时,返回一个特殊值或抛出异常
有时,我们对阻塞数据结构有非阻塞等价物。例如,ConcurrentLinkedDeque类是一个非阻塞数据结构,而LinkedBlockingDeque是阻塞等价物。阻塞数据结构具有类似非阻塞数据结构的方法。例如,Deque接口定义了pollFirst()方法,如果双端队列为空,则不会阻塞并返回null。每个阻塞队列实现也实现了这个方法。
Java 集合框架(JCF)提供了一组可以在顺序编程中使用的不同数据结构。Java 并发 API 扩展了这些结构,提供了可以在并发应用程序中使用的其他结构。这包括:
-
接口:这扩展了 JCF 提供的接口,添加了一些可以在并发应用程序中使用的方法
-
类:这些类实现了前面的接口,提供了可以在应用程序中使用的实现
在以下部分,我们介绍了并发应用程序中可以使用的接口和类。
接口
首先,让我们描述并发数据结构实现的最重要的接口。
BlockingQueue
队列是一种线性数据结构,允许您在队列末尾插入元素并从开头获取元素。它是一种先进先出(FIFO)的数据结构,队列中引入的第一个元素是被处理的第一个元素。
JCF 定义了Queue接口,该接口定义了队列中要实现的基本操作。该接口提供了以下方法:
-
在队列末尾插入元素
-
从队列头部检索并删除元素
-
从队列头部检索但不删除元素
该接口定义了这些方法的两个版本,当方法可以完成时具有不同的行为(例如,如果要从空队列中检索元素):
-
抛出异常的方法
-
返回特殊值的方法,例如
false或null
下表包括了每个操作的方法名称:
| 操作 | 异常 | 特殊值 |
|---|---|---|
| 插入 | add() |
offer() |
| 检索和删除 | remove() |
poll() |
| 检索但不删除 | element() |
peek() |
BlockingDeque接口扩展了Queue接口,添加了在操作可以完成时阻塞调用线程的方法。这些方法包括:
| 操作 | 阻塞 |
|---|---|
| 插入 | put() |
| 检索和删除 | take() |
| 检索但不删除 | N/A |
BlockingDeque
双端队列是一种线性数据结构,类似于队列,但允许您从数据结构的两侧插入和删除元素。JCF 定义了扩展Queue接口的Deque接口。除了Queue接口提供的方法之外,它还提供了在两端插入、检索和删除以及在两端检索但不删除的方法:
| 操作 | 异常 | 特殊值 |
|---|---|---|
| 插入 | addFirst(),addLast() |
offerFirst(),offerLast() |
| 检索和删除 | removeFirst(),removeLast() |
pollFirst(),pollLast() |
| 检索但不删除 | getFirst(),getLast() |
peekFirst(),peekLast() |
BlockingDeque接口扩展了Deque接口,添加了在操作无法完成时阻塞调用线程的方法:
| 操作 | 阻塞 |
|---|---|
| 插入 | putFirst(),putLast() |
| 检索和删除 | takeFirst(),takeLast() |
| 检索但不删除 | N/A |
ConcurrentMap
映射(有时也称为关联数组)是一种数据结构,允许您存储(键,值)对。JCF 提供了Map接口,该接口定义了与映射一起使用的基本操作。这包括插入、检索和删除以及检索但不删除的方法:
-
put(): 将(键,值)对插入到映射中 -
get(): 返回与键关联的值 -
remove(): 移除与指定键关联的(键,值)对 -
containsKey()和containsValue(): 如果映射包含指定的键或值,则返回 true
这个接口在 Java 8 中已经修改,包括以下新方法。您将在本章后面学习如何使用这些方法:
-
forEach(): 这个方法对映射的所有元素执行给定的函数。 -
compute(),computeIfAbsent()和computeIfPresent(): 这些方法允许您指定计算与键关联的新值的函数。 -
merge(): 这个方法允许你指定将(键,值)对合并到现有的映射中。如果键不在映射中,它会直接插入。如果不是,执行指定的函数。
ConcurrentMap扩展了Map接口,为并发应用程序提供相同的方法。请注意,在 Java 8 中(不像 Java 7),ConcurrentMap接口没有向Map接口添加新方法。
TransferQueue
这个接口扩展了BlockingQueue接口,并添加了从生产者传输元素到消费者的方法,其中生产者可以等待直到消费者取走它的元素。这个接口添加的新方法是:
-
transfer(): 将一个元素传输给消费者,并等待(阻塞调用线程),直到元素被消费。 -
tryTransfer(): 如果有消费者在等待,就传输一个元素。如果没有,这个方法返回false值,并且不会将元素插入队列。
Classes
Java 并发 API 提供了之前描述的接口的不同实现。其中一些不添加任何新特性,但其他一些添加了新的有趣功能。
LinkedBlockingQueue
这个类实现了BlockingQueue接口,提供了一个具有阻塞方法的队列,可以选择具有有限数量的元素。它还实现了Queue、Collection和Iterable接口。
ConcurrentLinkedQueue
这个类实现了Queue接口,提供了一个线程安全的无限队列。在内部,它使用非阻塞算法来保证在您的应用程序中不会出现数据竞争。
LinkedBlockingDeque
这个类实现了BlockingDeque接口,提供了一个具有阻塞方法的双端队列,可以选择具有有限数量的元素。它比LinkedBlockingQueue具有更多的功能,但可能有更多的开销,因此当不需要双端队列功能时应该使用LinkedBlockingQueue。
ConcurrentLinkedDeque
这个类实现了Deque接口,提供了一个线程安全的无限双端队列,允许您在队列的两端添加和删除元素。它比ConcurrentLinkedQueue具有更多的功能,但可能有更多的开销,就像LinkedBlockingDeque一样。
ArrayBlockingQueue
这个类实现了BlockingQueue接口,提供了一个基于数组的有限元素数量的阻塞队列实现。它还实现了Queue、Collection和Iterable接口。与非并发的基于数组的数据结构(ArrayList和ArrayDeque)不同,ArrayBlockingQueue在构造函数中分配一个固定大小的数组,并且不会调整大小。
DelayQueue
这个类实现了BlockingDeque接口,提供了一个具有阻塞方法和无限元素数量的队列实现。这个队列的元素必须实现Delayed接口,因此它们必须实现getDelay()方法。如果该方法返回负值或零值,延迟已经过期,元素可以从队列中取出。队列的头部是延迟值最负的元素。
LinkedTransferQueue
这个类提供了TransferQueue接口的实现。它提供了一个具有无限元素数量的阻塞队列,并且可以将它们用作生产者和消费者之间的通信通道,其中生产者可以等待消费者处理他们的元素。
PriorityBlockingQueue
这个类提供了BlockingQueue接口的实现,其中元素可以根据它们的自然顺序或在类的构造函数中指定的比较器进行轮询。这个队列的头部由元素的排序顺序确定。
ConcurrentHashMap
这个类提供了ConcurrentMap接口的实现。它提供了一个线程安全的哈希表。除了 Java 8 版本中添加到Map接口的方法之外,这个类还添加了其他方法:
-
search(),searchEntries(),searchKeys(), andsearchValues(): 这些方法允许您在(键,值)对、键或值上应用搜索函数。搜索函数可以是 lambda 表达式,当搜索函数返回非空值时,方法结束。这就是方法执行的结果。 -
reduce(),reduceEntries(),reduceKeys(), 和reduceValues(): 这些方法允许您应用reduce()操作来转换(键,值)对、键或条目,就像流中发生的那样(参见第八章,“使用并行流处理大型数据集 - Map 和 Collect 模型”了解有关reduce()方法的更多细节)。
已添加更多方法(forEachValue,forEachKey等),但这里不涉及它们。
使用新特性
在本节中,您将学习如何使用 Java 8 中引入的并发数据结构的新特性。
ConcurrentHashMap 的第一个示例
在第八章中,您实现了一个应用程序,从 20,000 个亚马逊产品的数据集中进行搜索。我们从亚马逊产品共购买网络元数据中获取了这些信息,其中包括 548,552 个产品的标题、销售排名和类似产品的信息。您可以从snap.stanford.edu/data/amazon-meta.html下载这个数据集。在那个示例中,您使用了一个名为productsByBuyer的ConcurrentHashMap<String, List<ExtendedProduct>>来存储用户购买的产品的信息。这个映射的键是用户的标识符,值是用户购买的产品的列表。您将使用该映射来学习如何使用ConcurrentHashMap类的新方法。
forEach()方法
这个方法允许您指定一个函数,该函数将在每个ConcurrentHashMap的(键,值)对上执行。这个方法有很多版本,但最基本的版本只有一个BiConsumer函数,可以表示为 lambda 表达式。例如,您可以使用这个方法来打印每个用户购买了多少产品的代码:
productsByBuyer.forEach( (id, list) -> System.out.println(id+": "+list.size()));
这个基本版本是通常的Map接口的一部分,并且总是按顺序执行。在这段代码中,我们使用了 lambda 表达式,其中id是元素的键,list是元素的值。
在另一个示例中,我们使用了forEach()方法来计算每个用户给出的平均评分。
productsByBuyer.forEach( (id, list) -> {
double average=list.stream().mapToDouble(item -> item.getValue()).average().getAsDouble();
System.out.println(id+": "+average);
});
在这段代码中,我们还使用了 lambda 表达式,其中id是元素的键,list是其值。我们使用了应用于产品列表的流来计算平均评分。
此方法的其他版本如下:
-
forEach(parallelismThreshold, action): 这是您在并发应用程序中必须使用的方法的版本。如果地图的元素多于第一个参数中指定的数量,则此方法将并行执行。 -
forEachEntry(parallelismThreshold, action): 与之前相同,但在这种情况下,操作是Consumer接口的实现,它接收一个带有元素的键和值的Map.Entry对象。在这种情况下,您也可以使用 lambda 表达式。 -
forEachKey(parallelismThreshold, action): 与之前相同,但在这种情况下,操作仅应用于ConcurrentHashMap的键。 -
forEachValue(parallelismThreshold, action): 与之前相同,但在这种情况下,操作仅应用于ConcurrentHashMap的值。
当前实现使用通用的ForkJoinPool实例来执行并行任务。
search()方法
此方法将搜索函数应用于ConcurrentHashMap的所有元素。此搜索函数可以返回空值或非空值。search()方法将返回搜索函数返回的第一个非空值。此方法接收两个参数:
-
parallelismThreshold: 如果地图的元素多于此参数指定的数量,则此方法将并行执行。 -
searchFunction: 这是BiFunction接口的实现,可以表示为 lambda 表达式。此函数接收每个元素的键和值作为参数,并且如前所述,如果找到您要搜索的内容,则必须返回非空值,如果找不到,则必须返回空值。
例如,您可以使用此函数找到包含某个单词的第一本书:
ExtendedProduct firstProduct=productsByBuyer.search(100,
(id, products) -> {
for (ExtendedProduct product: products) {
if (product.getTitle() .toLowerCase().contains("java")) {
return product;
}
}
return null;
});
if (firstProduct!=null) {
System.out.println(firstProduct.getBuyer()+":"+ firstProduct.getTitle());
}
在这种情况下,我们使用 100 作为parallelismThreshold,并使用 lambda 表达式来实现搜索函数。在此函数中,对于每个元素,我们处理列表的所有产品。如果我们找到包含单词java的产品,我们返回该产品。这是search()方法返回的值。最后,我们在控制台中写入产品的买家和标题。
此方法还有其他版本:
-
searchEntries(parallelismThreshold, searchFunction): 在这种情况下,搜索函数是Function接口的实现,它接收一个Map.Entry对象作为参数 -
searchKeys(parallelismThreshold, searchFunction): 在这种情况下,搜索函数仅应用于ConcurrentHashMap的键 -
searchValues(parallelismThreshold, searchFunction): 在这种情况下,搜索函数仅应用于ConcurrentHashMap的值
reduce()方法
此方法类似于Stream框架提供的reduce()方法,但在这种情况下,您直接使用ConcurrentHashMap的元素。此方法接收三个参数:
-
parallelismThreshold: 如果ConcurrentHashMap的元素多于此参数中指定的数量,则此方法将并行执行。 -
transformer: 此参数是BiFunction接口的实现,可以表示为 lambda 函数。它接收一个键和一个值作为参数,并返回这些元素的转换。 -
reducer: 此参数是BiFunction接口的实现,也可以表示为 lambda 函数。它接收 transformer 函数返回的两个对象作为参数。此函数的目标是将这两个对象分组为一个对象。
作为这种方法的一个例子,我们将获得一个产品列表,其中包含值为1的评论(最差的值)。我们使用了两个辅助变量。第一个是transformer。它是一个BiFunction接口,我们将用作reduce()方法的transformer元素:
BiFunction<String, List<ExtendedProduct>, List<ExtendedProduct>> transformer = (key, value) -> value.stream().filter(product -> product.getValue() == 1).collect(Collectors.toList());
此函数将接收键,即用户的id,以及用户购买的产品的ExtendedProduct对象列表。我们处理列表中的所有产品,并返回评分为一的产品。
第二个变量是 reducer BinaryOperator。我们将其用作reduce()方法的 reducer 函数:
BinaryOperator<List<ExtendedProduct>> reducer = (list1, list2) ->{
list1.addAll(list2);
return list1;
};
reduce 接收两个ExtendedProduct列表,并使用addAll()方法将它们连接成一个单一的列表。
现在,我们只需实现对reduce()方法的调用:
List<ExtendedProduct> badReviews=productsByBuyer.reduce(10, transformer, reducer);
badReviews.forEach(product -> {
System.out.println(product.getTitle()+":"+ product.getBuyer()+":"+product.getValue());
});
reduce()方法还有其他版本:
-
reduceEntries(),reduceEntriesToDouble(),reduceEntriesToInt()和reduceEntriesToLong():在这种情况下,转换器和 reducer 函数作用于Map.Entry对象。最后三个版本分别返回double,int和long值。 -
reduceKeys(),reduceKeysToDouble()和reduceKeysToInt(),reduceKeysToLong():在这种情况下,转换器和 reducer 函数作用于映射的键。最后三个版本分别返回double,int和long值。 -
reduceToInt(),reduceToDouble()和reduceToLong():在这种情况下,转换器函数作用于键和值,reducer 方法分别作用于int,double或long数。这些方法返回int,double和long值。 -
reduceValues(),reduceValuesToDouble(),reduceValuesToInt()和reduceValuesToLong():在这种情况下,转换器和 reducer 函数作用于映射的值。最后三个版本分别返回double,int和long值。
compute()方法
此方法(在Map接口中定义)接收元素的键和可以表示为 lambda 表达式的BiFunction接口的实现作为参数。如果键存在于ConcurrentHashMap中,则此函数将接收元素的键和值,否则为null。该方法将用函数返回的值替换与键关联的值,如果不存在,则将它们插入ConcurrentHashMap,或者如果对于先前存在的项目返回null,则删除该项目。请注意,在BiFunction执行期间,一个或多个映射条目可能会被锁定。因此,您的BiFunction不应该工作太长时间,也不应该尝试更新同一映射中的任何其他条目。否则可能会发生死锁。
例如,我们可以使用此方法与 Java 8 中引入的新原子变量LongAdder一起计算与每个产品关联的不良评论数量。我们创建一个名为 counter 的新ConcurrentHashMap。键将是产品的标题,值将是LongAdder类的对象,用于计算每个产品有多少不良评论。
ConcurrentHashMap<String, LongAdder> counter=new ConcurrentHashMap<>();
我们处理在上一节中计算的badReviews ConcurrentLinkedDeque的所有元素,并使用compute()方法创建和更新与每个产品关联的LongAdder。
badReviews.forEach(product -> {
counter.computeIfAbsent(product.getTitle(), title -> new LongAdder()).increment();
});
counter.forEach((title, count) -> {
System.out.println(title+":"+count);
});
最后,我们将结果写入控制台。
另一个使用 ConcurrentHashMap 的例子
ConcurrentHashMap类中添加的另一种方法并在 Map 接口中定义。这是merge()方法,允许您将(键,值)对合并到映射中。如果键不存在于ConcurrentHashMap中,则直接插入。如果键存在,则必须定义从旧值和新值中关联的键的新值。此方法接收三个参数:
-
我们要合并的键。
-
我们要合并的值。
-
可以表示为 lambda 表达式的
BiFunction的实现。此函数接收旧值和与键关联的新值作为参数。该方法将用此函数返回的值与键关联。BiFunction在映射的部分锁定下执行,因此可以保证它不会同时为相同的键并发执行。
例如,我们已经将上一节中使用的亚马逊的 20,000 个产品按评论年份分成文件。对于每一年,我们加载ConcurrentHashMap,其中产品是键,评论列表是值。因此,我们可以使用以下代码加载 1995 年和 1996 年的评论:
Path path=Paths.get("data\\amazon\\1995.txt");
ConcurrentHashMap<BasicProduct, ConcurrentLinkedDeque<BasicReview>> products1995=BasicProductLoader.load(path);
showData(products1995);
path=Paths.get("data\\amazon\\1996.txt");
ConcurrentHashMap<BasicProduct, ConcurrentLinkedDeque<BasicReview>> products1996=BasicProductLoader.load(path);
System.out.println(products1996.size());
showData(products1996);
如果我们想将ConcurrentHashMap的两个版本合并成一个,可以使用以下代码:
products1996.forEach(10,(product, reviews) -> {
products1995.merge(product, reviews, (reviews1, reviews2) -> {
System.out.println("Merge for: "+product.getAsin());
reviews1.addAll(reviews2);
return reviews1;
});
});
我们处理了 1996 年的ConcurrentHashMap的所有元素,并且对于每个(键,值)对,我们在 1995 年的ConcurrentHashMap上调用merge()方法。merge函数将接收两个评论列表,因此我们只需将它们连接成一个。
使用 ConcurrentLinkedDeque 类的示例
Collection接口在 Java 8 中还包括了新的方法。大多数并发数据结构都实现了这个接口,因此我们可以在它们中使用这些新特性。其中两个是第七章和第八章中使用的stream()和parallelStream()方法。让我们看看如何使用ConcurrentLinkedDeque和我们在前面章节中使用的 20,000 个产品。
removeIf() 方法
此方法在Collection接口中有一个默认实现,不是并发的,并且没有被ConcurrentLinkedDeque类覆盖。此方法接收Predicate接口的实现作为参数,该接口将接收Collection的元素作为参数,并应返回true或false值。该方法将处理Collection的所有元素,并将删除那些使用谓词获得true值的元素。
例如,如果您想删除所有销售排名高于 1,000 的产品,可以使用以下代码:
System.out.println("Products: "+productList.size());
productList.removeIf(product -> product.getSalesrank() > 1000);
System.out.println("Products; "+productList.size());
productList.forEach(product -> {
System.out.println(product.getTitle()+": "+product.getSalesrank());
});
spliterator() 方法
此方法返回Spliterator接口的实现。spliterator定义了Stream API 可以使用的数据源。您很少需要直接使用 spliterator,但有时您可能希望创建自己的 spliterator 来为流生成自定义源(例如,如果您实现自己的数据结构)。如果您有自己的 spliterator 实现,可以使用StreamSupport.stream(mySpliterator, isParallel)在其上创建流。这里,isParallel是一个布尔值,确定创建的流是否是并行的。分割器类似于迭代器,您可以使用它来遍历集合中的所有元素,但可以将它们分割以以并发方式进行遍历。
分割器有八种不同的特征来定义其行为:
-
CONCURRENT: 分割器源可以安全并发修改 -
DISTINCT: 分割器返回的所有元素都是不同的 -
IMMUTABLE: 分割器源不可修改 -
NONNULL: 分割器永远不会返回null值 -
ORDERED: 分割器返回的元素是有序的(这意味着它们的顺序很重要) -
SIZED: 分割器能够使用estimateSize()方法返回确切数量的元素 -
SORTED: 分割器源已排序 -
SUBSIZED: 如果使用trySplit()方法来分割这个分割器,生成的分割器将是SIZED和SUBSIZED
此接口最有用的方法是:
-
estimatedSize(): 此方法将为您提供分割器中元素数量的估计。 -
forEachRemaining(): 这个方法允许您对尚未被处理的 spliterator 的元素应用Consumer接口的实现,可以用 lambda 函数表示。 -
tryAdvance(): 这个方法允许您对 spliterator 要处理的下一个元素应用Consumer接口的实现,可以用 lambda 函数表示,如果有的话。 -
trySplit(): 这个方法尝试将 spliterator 分成两部分。调用者 spliterator 将处理一些元素,返回的 spliterator 将处理其他元素。如果 spliterator 是ORDERED,返回的 spliterator 必须处理元素的严格前缀,调用必须处理严格后缀。 -
hasCharacteristics(): 这个方法允许您检查 spliterator 的属性。
让我们看一个使用ArrayList数据结构的例子,有 20,000 个产品。
首先,我们需要一个辅助任务,它将处理一组产品,将它们的标题转换为小写。这个任务将有一个Spliterator作为属性:
public class SpliteratorTask implements Runnable {
private Spliterator<Product> spliterator;
public SpliteratorTask (Spliterator<Product> spliterator) {
this.spliterator=spliterator;
}
@Override
public void run() {
int counter=0;
while (spliterator.tryAdvance(product -> {
product.setTitle(product.getTitle().toLowerCase());
})) {
counter++;
};
System.out.println(Thread.currentThread().getName() +":"+counter);
}
}
正如您所看到的,这个任务在执行完毕时会写入处理的产品数量。
在主方法中,一旦我们用 20,000 个产品加载了ConcurrentLinkedQueue,我们就可以获得 spliterator,检查它的一些属性,并查看它的估计大小。
Spliterator<Product> split1=productList.spliterator();
System.out.println(split1.hasCharacteristics (Spliterator.CONCURRENT));
System.out.println(split1.hasCharacteristics (Spliterator.SUBSIZED));
System.out.println(split1.estimateSize());
然后,我们可以使用trySplit()方法分割 spliterator,并查看两个 spliterator 的大小:
Spliterator<Product> split2=split1.trySplit();
System.out.println(split1.estimateSize());
System.out.println(split2.estimateSize());
最后,我们可以在执行器中执行两个任务,一个用于 spliterator,以查看每个 spliterator 是否真的处理了预期数量的元素。
ThreadPoolExecutor executor=(ThreadPoolExecutor) Executors.newCachedThreadPool();
executor.execute(new SpliteratorTask(split1));
executor.execute(new SpliteratorTask(split2));
在下面的截图中,您可以看到这个例子的执行结果:

您可以看到,在分割 spliterator 之前,estimatedSize()方法返回 20,000 个元素。在trySplit()方法执行后,两个 spliterator 都有 10,000 个元素。这些是每个任务处理的元素。
原子变量
Java 1.5 引入了原子变量,以提供对integer、long、boolean、reference和Array对象的原子操作。它们提供了一些方法来增加、减少、建立值、返回值,或者在当前值等于预定义值时建立值。
在 Java 8 中,新增了四个新类。它们是DoubleAccumulator、DoubleAdder、LongAccumulator和LongAdder。在前面的部分,我们使用了LongAdder类来计算产品的差评数量。这个类提供了类似于AtomicLong的功能,但是当您频繁地从不同线程更新累积和并且只在操作结束时请求结果时,它的性能更好。DoubleAdder函数与之相等,但是使用双精度值。这两个类的主要目标是拥有一个可以由不同线程一致更新的计数器。这些类的最重要的方法是:
-
add(): 用指定的值增加计数器的值 -
increment(): 等同于add(1) -
decrement(): 等同于add(-1) -
sum(): 这个方法返回计数器的当前值
请注意,DoubleAdder类没有increment()和decrement()方法。
LongAccumulator和DoubleAccumulator类是类似的,但它们有一个非常重要的区别。它们有一个构造函数,您可以在其中指定两个参数:
-
内部计数器的身份值
-
一个将新值累积到累加器中的函数
请注意,函数不应依赖于累积的顺序。在这种情况下,最重要的方法是:
-
accumulate(): 这个方法接收一个long值作为参数。它将函数应用于当前值和参数来增加或减少计数器的值。 -
get(): 返回计数器的当前值。
例如,以下代码将在所有执行中在控制台中写入 362,880:
LongAccumulator accumulator=new LongAccumulator((x,y) -> x*y, 1);
IntStream.range(1, 10).parallel().forEach(x -> accumulator.accumulate(x));
System.out.println(accumulator.get());
我们在累加器内部使用可交换操作,因此无论输入顺序如何,结果都是相同的。
同步机制
任务的同步是协调这些任务以获得期望的结果。在并发应用程序中,我们可以有两种同步方式:
-
进程同步:当我们想要控制任务的执行顺序时,我们使用这种同步。例如,一个任务必须在开始执行之前等待其他任务的完成。
-
数据同步:当两个或多个任务访问相同的内存对象时,我们使用这种同步。在这种情况下,您必须保护对该对象的写操作的访问。如果不这样做,您可能会遇到数据竞争条件,程序的最终结果会因每次执行而异。
Java 并发 API 提供了允许您实现这两种类型同步的机制。Java 语言提供的最基本的同步机制是synchronized关键字。这个关键字可以应用于一个方法或一段代码。在第一种情况下,只有一个线程可以同时执行该方法。在第二种情况下,您必须指定一个对象的引用。在这种情况下,只有一个由对象保护的代码块可以同时执行。
Java 还提供其他同步机制:
-
Lock接口及其实现类:这种机制允许您实现一个临界区,以确保只有一个线程将执行该代码块。 -
Semaphore类实现了由Edsger Dijkstra引入的著名的信号量同步机制。 -
CountDownLatch允许您实现一个情况,其中一个或多个线程等待其他线程的完成。 -
CyclicBarrier允许您在一个公共点同步不同的任务。 -
Phaser允许您实现分阶段的并发任务。我们在第五章中对这种机制进行了详细描述,分阶段运行任务 - Phaser 类。 -
Exchanger允许您在两个任务之间实现数据交换点。 -
CompletableFuture,Java 8 的一个新特性,扩展了执行器任务的Future机制,以异步方式生成任务的结果。您可以指定在生成结果后要执行的任务,因此可以控制任务的执行顺序。
在接下来的部分中,我们将向您展示如何使用这些机制,特别关注 Java 8 版本中引入的CompletableFuture机制。
CommonTask 类
我们实现了一个名为CommonTask类的类。这个类将使调用线程在0和10秒之间的随机时间内休眠。这是它的源代码:
public class CommonTask {
public static void doTask() {
long duration = ThreadLocalRandom.current().nextLong(10);
System.out.printf("%s-%s: Working %d seconds\n",new Date(),Thread.currentThread().getName(),duration);
try {
TimeUnit.SECONDS.sleep(duration);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在接下来的部分中,我们将使用这个类来模拟其执行时间。
Lock 接口
最基本的同步机制之一是Lock接口及其实现类。基本实现类是ReentrantLock类。您可以使用这个类来轻松实现临界区。例如,以下任务在其代码的第一行使用lock()方法获取锁,并在最后一行使用unlock()方法释放锁。在同一时间只有一个任务可以执行这两个语句之间的代码。
public class LockTask implements Runnable {
private static ReentrantLock lock = new ReentrantLock();
private String name;
public LockTask(String name) {
this.name=name;
}
@Override
public void run() {
try {
lock.lock();
System.out.println("Task: " + name + "; Date: " + new Date() + ": Running the task");
CommonTask.doTask();
System.out.println("Task: " + name + "; Date: " + new Date() + ": The execution has finished");
} finally {
lock.unlock();
}
}
}
例如,您可以通过以下代码在执行器中执行十个任务来检查这一点:
public class LockMain {
public static void main(String[] args) {
ThreadPoolExecutor executor=(ThreadPoolExecutor) Executors.newCachedThreadPool();
for (int i=0; i<10; i++) {
executor.execute(new LockTask("Task "+i));
}
executor.shutdown();
try {
executor.awaitTermination(1, TimeUnit.DAYS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在下面的图片中,您可以看到这个示例的执行结果。您可以看到每次只有一个任务被执行。

Semaphore 类
信号量机制是由 Edsger Dijkstra 于 1962 年引入的,用于控制对一个或多个共享资源的访问。这个机制基于一个内部计数器和两个名为wait()和signal()的方法。当一个线程调用wait()方法时,如果内部计数器的值大于 0,那么信号量会减少内部计数器,并且线程获得对共享资源的访问。如果内部计数器的值为 0,线程将被阻塞,直到某个线程调用signal()方法。当一个线程调用signal()方法时,信号量会查看是否有一些线程处于waiting状态(它们已经调用了wait()方法)。如果没有线程在等待,它会增加内部计数器。如果有线程在等待信号量,它会选择其中一个线程,该线程将返回到wait()方法并访问共享资源。其他等待的线程将继续等待它们的轮到。
在 Java 中,信号量是在Semaphore类中实现的。wait()方法被称为acquire(),signal()方法被称为release()。例如,在这个例子中,我们使用了一个Semaphore类来保护它的代码:
public class SemaphoreTask implements Runnable{
private Semaphore semaphore;
public SemaphoreTask(Semaphore semaphore) {
this.semaphore=semaphore;
}
@Override
public void run() {
try {
semaphore.acquire();
CommonTask.doTask();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
}
}
}
在主程序中,我们执行了共享一个Semaphore类的十个任务,该类初始化了两个共享资源,因此我们将同时运行两个任务。
public static void main(String[] args) {
Semaphore semaphore=new Semaphore(2);
ThreadPoolExecutor executor=(ThreadPoolExecutor) Executors.newCachedThreadPool();
for (int i=0; i<10; i++) {
executor.execute(new SemaphoreTask(semaphore));
}
executor.shutdown();
try {
executor.awaitTermination(1, TimeUnit.DAYS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
以下截图显示了这个例子执行的结果。你可以看到两个任务同时在运行:

CountDownLatch 类
这个类提供了一种等待一个或多个并发任务完成的机制。它有一个内部计数器,必须初始化为我们要等待的任务数量。然后,await()方法使调用线程休眠,直到内部计数器到达零,countDown()方法减少内部计数器。
例如,在这个任务中,我们使用countDown()方法来减少CountDownLatch对象的内部计数器,它在构造函数中接收一个参数。
public class CountDownTask implements Runnable {
private CountDownLatch countDownLatch;
public CountDownTask(CountDownLatch countDownLatch) {
this.countDownLatch=countDownLatch;
}
@Override
public void run() {
CommonTask.doTask();
countDownLatch.countDown();
}
}
然后,在main()方法中,我们在执行器中执行任务,并使用CountDownLatch的await()方法等待它们的完成。该对象被初始化为我们要等待的任务数量。
public static void main(String[] args) {
CountDownLatch countDownLatch=new CountDownLatch(10);
ThreadPoolExecutor executor=(ThreadPoolExecutor) Executors.newCachedThreadPool();
System.out.println("Main: Launching tasks");
for (int i=0; i<10; i++) {
executor.execute(new CountDownTask(countDownLatch));
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.
executor.shutdown();
}
以下截图显示了这个例子执行的结果:

CyclicBarrier 类
这个类允许你在一个共同点同步一些任务。所有任务都将在那个点等待,直到所有任务都到达。在内部,它还管理一个内部计数器,记录还没有到达那个点的任务。当一个任务到达确定的点时,它必须执行await()方法等待其余的任务。当所有任务都到达时,CyclicBarrier对象唤醒它们,使它们继续执行。
这个类允许你在所有参与方到达时执行另一个任务。要配置这个,你必须在对象的构造函数中指定一个可运行的对象。
例如,我们实现了以下的 Runnable,它使用了一个CyclicBarrier对象来等待其他任务:
public class BarrierTask implements Runnable {
private CyclicBarrier barrier;
public BarrierTask(CyclicBarrier barrier) {
this.barrier=barrier;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+": Phase 1");
CommonTask.doTask();
try {
barrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+": Phase 2");
}
}
我们还实现了另一个Runnable对象,当所有任务都执行了await()方法时,它将被CyclicBarrier执行。
public class FinishBarrierTask implements Runnable {
@Override
public void run() {
System.out.println("FinishBarrierTask: All the tasks have finished");
}
}
最后,在main()方法中,我们在执行器中执行了十个任务。你可以看到CyclicBarrier是如何初始化的,它与我们想要同步的任务数量以及FinishBarrierTask对象一起:
public static void main(String[] args) {
CyclicBarrier barrier=new CyclicBarrier(10,new FinishBarrierTask());
ThreadPoolExecutor executor=(ThreadPoolExecutor) Executors.newCachedThreadPool();
for (int i=0; i<10; i++) {
executor.execute(new BarrierTask(barrier));
}
executor.shutdown();
try {
executor.awaitTermination(1, TimeUnit.DAYS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
以下截图显示了这个例子执行的结果:

你可以看到当所有任务都到达调用await()方法的点时,FinishBarrierTask被执行,然后所有任务继续执行。
CompletableFuture 类
这是 Java 8 并发 API 中引入的一种新的同步机制。它扩展了Future机制,赋予它更多的功能和灵活性。它允许您实现一个事件驱动模型,链接任务,只有在其他任务完成时才会执行。与Future接口一样,CompletableFuture必须使用操作返回的结果类型进行参数化。与Future对象一样,CompletableFuture类表示异步计算的结果,但CompletableFuture的结果可以由任何线程建立。它具有complete()方法,在计算正常结束时建立结果,以及completeExceptionally()方法,在计算异常结束时建立结果。如果两个或更多线程在同一个CompletableFuture上调用complete()或completeExceptionally()方法,只有第一次调用会生效。
首先,您可以使用其构造函数创建CompletableFuture。在这种情况下,您必须使用complete()方法来建立任务的结果,就像我们之前解释的那样。但您也可以使用runAsync()或supplyAsync()方法来创建一个。runAsync()方法执行一个Runnable对象并返回CompletableFuture<Void>,因此计算不会返回任何结果。supplyAsync()方法执行一个Supplier接口的实现,该接口参数化了此计算将返回的类型。Supplier接口提供get()方法。在该方法中,我们必须包含任务的代码并返回其生成的结果。在这种情况下,CompletableFuture的结果将是Supplier接口的结果。
这个类提供了许多方法,允许您组织任务的执行顺序,实现一个事件驱动模型,其中一个任务直到前一个任务完成后才开始执行。以下是其中一些方法:
-
thenApplyAsync(): 这个方法接收Function接口的实现作为参数,可以表示为 lambda 表达式。当调用的CompletableFuture完成时,将执行此函数。此方法将返回CompletableFuture以获取Function的结果。 -
thenComposeAsync(): 这个方法类似于thenApplyAsync,但在提供的函数也返回CompletableFuture时很有用。 -
thenAcceptAsync(): 这个方法类似于前一个方法,但参数是Consumer接口的实现,也可以指定为 lambda 表达式;在这种情况下,计算不会返回结果。 -
thenRunAsync(): 这个方法与前一个方法相同,但在这种情况下,它接收一个Runnable对象作为参数。 -
thenCombineAsync(): 这个方法接收两个参数。第一个是另一个CompletableFuture实例。另一个是BiFunction接口的实现,可以指定为 lambda 函数。当两个CompletableFuture(调用方和参数)都完成时,将执行此BiFunction。此方法将返回CompletableFuture以获取BiFunction的结果。 -
runAfterBothAsync(): 这个方法接收两个参数。第一个是另一个CompletableFuture。另一个是Runnable接口的实现,当两个CompletableFuture(调用方和参数)都完成时将执行。 -
runAfterEitherAsync(): 这个方法等同于前一个方法,但当CompletableFuture对象之一完成时,将执行Runnable任务。 -
allOf(): 这个方法接收一个CompletableFuture对象的可变列表作为参数。它将返回一个CompletableFuture<Void>对象,当所有CompletableFuture对象都完成时,它将返回其结果。 -
anyOf(): 这个方法等同于前一个方法,但是返回的CompletableFuture在其中一个CompletableFuture完成时返回其结果。
最后,如果你想要获取CompletableFuture返回的结果,你可以使用get()或join()方法。这两种方法都会阻塞调用线程,直到CompletableFuture完成并返回其结果。这两种方法之间的主要区别在于,get()会抛出ExecutionException,这是一个受检异常,而join()会抛出RuntimeException(这是一个未检查的异常)。因此,在不抛出异常的 lambda 表达式(如Supplier、Consumer或Runnable)中使用join()更容易。
前面解释的大部分方法都有Async后缀。这意味着这些方法将使用ForkJoinPool.commonPool实例以并发方式执行。那些没有Async后缀版本的方法将以串行方式执行(也就是说,在执行CompletableFuture的同一个线程中),而带有Async后缀和一个执行器实例作为额外参数。在这种情况下,CompletableFuture将在传递的执行器中异步执行。
使用 CompletableFuture 类
在这个例子中,您将学习如何使用CompletableFuture类以并发方式实现一些异步任务的执行。我们将使用亚马逊的 2 万个产品集合来实现以下任务树:

首先,我们将使用这些例子。然后,我们将执行四个并发任务。第一个任务将搜索产品。当搜索完成时,我们将把结果写入文件。第二个任务将获取评分最高的产品。第三个任务将获取销量最高的产品。当这两个任务都完成时,我们将使用另一个任务来连接它们的信息。最后,第四个任务将获取购买了产品的用户列表。main()程序将等待所有任务的完成,然后写入结果。
让我们看看实现的细节。
辅助任务
在这个例子中,我们将使用一些辅助任务。第一个是LoadTask,它将从磁盘加载产品信息,并返回一个Product对象的列表。
public class LoadTask implements Supplier<List<Product>> {
private Path path;
public LoadTask (Path path) {
this.path=path;
}
@Override
public List<Product> get() {
List<Product> productList=null;
try {
productList = Files.walk(path, FileVisitOption.FOLLOW_LINKS).parallel()
.filter(f -> f.toString().endsWith(".txt")) .map(ProductLoader::load).collect (Collectors.toList());
} catch (IOException e) {
e.printStackTrace();
}
return productList;
}
}
它实现了Supplier接口以作为CompletableFuture执行。在内部,它使用流来处理和解析所有文件,获取产品列表。
第二个任务是SearchTask,它将在Product对象列表中实现搜索,查找标题中包含某个词的产品。这个任务是Function接口的实现。
public class SearchTask implements Function<List<Product>, List<Product>> {
private String query;
public SearchTask(String query) {
this.query=query;
}
@Override
public List<Product> apply(List<Product> products) {
System.out.println(new Date()+": CompletableTask: start");
List<Product> ret = products.stream()
.filter(product -> product.getTitle() .toLowerCase().contains(query))
.collect(Collectors.toList());
System.out.println(new Date()+": CompletableTask: end: "+ret.size());
return ret;
}
}
它接收包含所有产品信息的List<Product>,并返回符合条件的产品的List<Product>。在内部,它在输入列表上创建流,对其进行过滤,并将结果收集到另一个列表中。
最后,WriteTask将把搜索任务中获取的产品写入一个File。在我们的例子中,我们生成了一个 HTML 文件,但是可以随意选择其他格式来写入这些信息。这个任务实现了Consumer接口,所以它的代码应该类似于下面这样:
public class WriteTask implements Consumer<List<Product>> {
@Override
public void accept(List<Product> products) {
// implementation is omitted
}
}
main()方法
我们在main()方法中组织了任务的执行。首先,我们使用CompletableFuture类的supplyAsync()方法执行LoadTask。
public class CompletableMain {
public static void main(String[] args) {
Path file = Paths.get("data","category");
System.out.println(new Date() + ": Main: Loading products");
LoadTask loadTask = new LoadTask(file);
CompletableFuture<List<Product>> loadFuture = CompletableFuture
.supplyAsync(loadTask);
然后,使用结果的CompletableFuture,我们使用thenApplyAsync()在加载任务完成后执行搜索任务。
System.out.println(new Date() + ": Main: Then apply for search");
CompletableFuture<List<Product>> completableSearch = loadFuture
.thenApplyAsync(new SearchTask("love"));
一旦搜索任务完成,我们希望将执行结果写入文件。由于这个任务不会返回结果,我们使用了thenAcceptAsync()方法:
CompletableFuture<Void> completableWrite = completableSearch
.thenAcceptAsync(new WriteTask());
completableWrite.exceptionally(ex -> {
System.out.println(new Date() + ": Main: Exception "
+ ex.getMessage());
return null;
});
我们使用了 exceptionally()方法来指定当写入任务抛出异常时我们想要做什么。
然后,我们在completableFuture对象上使用thenApplyAsync()方法执行任务,以获取购买产品的用户列表。我们将此任务指定为 lambda 表达式。请注意,此任务将与搜索任务并行执行。
System.out.println(new Date() + ": Main: Then apply for users");
CompletableFuture<List<String>> completableUsers = loadFuture
.thenApplyAsync(resultList -> {
System.out.println(new Date()
+ ": Main: Completable users: start");
List<String> users = resultList.stream()
.flatMap(p -> p.getReviews().stream())
.map(review -> review.getUser())
.distinct()
.collect(Collectors.toList());
System.out.println(new Date()
+ ": Main: Completable users: end");
return users;
});
与这些任务并行进行的是,我们还使用thenApplyAsync()方法执行任务,以找到最受欢迎的产品和最畅销的产品。我们也使用 lambda 表达式定义了这些任务。
System.out.println(new Date()
+ ": Main: Then apply for best rated product....");
CompletableFuture<Product> completableProduct = loadFuture
.thenApplyAsync(resultList -> {
Product maxProduct = null;
double maxScore = 0.0;
System.out.println(new Date()
+ ": Main: Completable product: start");
for (Product product : resultList) {
if (!product.getReviews().isEmpty()) {
double score = product.getReviews().stream()
.mapToDouble(review -> review.getValue())
.average().getAsDouble();
if (score > maxScore) {
maxProduct = product;
maxScore = score;
}
}
}
System.out.println(new Date()
+ ": Main: Completable product: end");
return maxProduct;
});
System.out.println(new Date()
+ ": Main: Then apply for best selling product....");
CompletableFuture<Product> completableBestSellingProduct = loadFuture
.thenApplyAsync(resultList -> {
System.out.println(new Date() + ": Main: Completable best selling: start");
Product bestProduct = resultList
.stream()
.min(Comparator.comparingLong (Product::getSalesrank))
.orElse(null);
System.out.println(new Date()
+ ": Main: Completable best selling: end");
return bestProduct;
});
正如我们之前提到的,我们希望连接最后两个任务的结果。我们可以使用thenCombineAsync()方法来指定一个任务,在两个任务都完成后执行。
CompletableFuture<String> completableProductResult = completableBestSellingProduct
.thenCombineAsync(
completableProduct, (bestSellingProduct, bestRatedProduct) -> {
System.out.println(new Date() + ": Main: Completable product result: start");
String ret = "The best selling product is " + bestSellingProduct.getTitle() + "\n";
ret += "The best rated product is "
+ bestRatedProduct.getTitle();
System.out.println(new Date() + ": Main: Completable product result: end");
return ret;
});
最后,我们使用allOf()和join()方法等待最终任务的结束,并使用get()方法编写结果以获取它们。
System.out.println(new Date() + ": Main: Waiting for results");
CompletableFuture<Void> finalCompletableFuture = CompletableFuture
.allOf(completableProductResult, completableUsers,
completableWrite);
finalCompletableFuture.join();
try {
System.out.println("Number of loaded products: "
+ loadFuture.get().size());
System.out.println("Number of found products: "
+ completableSearch.get().size());
System.out.println("Number of users: "
+ completableUsers.get().size());
System.out.println("Best rated product: "
+ completableProduct.get().getTitle());
System.out.println("Best selling product: "
+ completableBestSellingProduct.get() .getTitle());
System.out.println("Product result: "+completableProductResult.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
在下面的截图中,您可以看到此示例的执行结果:

首先,main()方法执行所有配置并等待任务的完成。任务的执行遵循我们配置的顺序。
总结
在本章中,我们回顾了并发应用程序的两个组件。第一个是数据结构。每个程序都使用它们来存储需要处理的信息。我们已经快速介绍了并发数据结构,以便详细描述 Java 8 并发 API 中引入的新功能,这些功能影响了ConcurrentHashMap类和实现Collection接口的类。
第二个是同步机制,允许您在多个并发任务想要修改数据时保护数据,并在必要时控制任务的执行顺序。在这种情况下,我们也快速介绍了同步机制,并详细描述了CompletableFuture,这是 Java 8 并发 API 的一个新功能。
在下一章中,我们将向您展示如何实现完整的并发系统,集成也可以是并发的不同部分,并使用不同的类来实现其并发性。
第十章:片段集成和替代方案的实现
从第二章到第八章,您使用了 Java 并发 API 的最重要部分来实现不同的示例。通常,这些示例是真实的,但大多数情况下,这些示例可以是更大系统的一部分。例如,在第四章中,从任务中获取数据 - Callable 和 Future 接口,您实现了一个应用程序来构建一个倒排索引,用于信息检索系统。在第六章中,优化分治解决方案 - Fork/Join 框架,您实现了 k 均值聚类算法来对一组文档进行聚类。然而,您可以实现一个完整的信息检索应用程序,该应用程序读取一组文档,使用向量空间模型表示它们,并使用 K-NN 算法对它们进行聚类。在这些情况下,您可能会使用不同的并发技术(执行器、流等)来实现不同的部分,但它们必须在它们之间同步和通信以获得所需的结果。
此外,本书中提出的所有示例都可以使用 Java 并发 API 的其他组件来实现。我们也将讨论其中一些替代方案。
在这一章中,我们将涵盖以下主题:
-
大块同步机制
-
文档聚类应用示例
-
实现替代方案
大块同步机制
大型计算机应用程序由不同的组件组成,这些组件共同工作以获得所需的功能。这些组件必须在它们之间进行同步和通信。在第九章中,深入并发数据结构和同步实用程序,您学到了可以使用不同的 Java 类来同步任务并在它们之间进行通信。但是当您要同步的组件也是可以使用不同机制来实现并发的并发系统时,这个任务组织就更加复杂了。例如,您的应用程序中有一个组件使用 Fork/Join 框架生成其结果,这些结果被使用Phaser类同步的其他任务使用。
在这些情况下,您可以使用以下两种机制来同步和通信这些组件:
-
共享内存:系统共享数据结构以在它们之间传递信息。
-
消息传递:系统之一向一个或多个系统发送消息。有不同的实现方式。在诸如 Java 之类的面向对象编程语言中,最基本的消息传递机制是一个对象调用另一个对象的方法。您还可以使用Java 消息服务(JMS)、缓冲区或其他数据结构。您可以有以下两种消息传递技术:
-
同步:在这种情况下,发送消息的类会等待接收者处理其消息
-
异步:在这种情况下,发送消息的类不等待处理其消息的接收者。
在这一部分,您将实现一个应用程序,用于对由四个子系统组成的文档进行聚类,这些子系统之间进行通信和同步以对文档进行聚类。
一个文档聚类应用的示例
该应用程序将读取一组文档,并使用 k-means 聚类算法对其进行组织。为了实现这一点,我们将使用四个组件:
-
Reader 系统:该系统将读取所有文档,并将每个文档转换为
String对象列表。 -
Indexer 系统:该系统将处理文档并将其转换为单词列表。同时,它将生成包含所有出现在文档中的单词的全局词汇表。
-
Mapper 系统:该系统将把每个单词列表转换为数学表示,使用向量空间模型。每个项目的值将是Tf-Idf(术语频率-逆文档频率)度量。
-
聚类系统:该系统将使用 k-means 聚类算法对文档进行聚类。
所有这些系统都是并发的,并使用自己的任务来实现它们的功能。让我们看看如何实现这个例子。
k-means 聚类的四个系统
让我们看看如何实现 Reader、Indexer、Mapper 和 Clustering 系统。
Reader 系统
我们已经在DocumentReader类中实现了这个系统。这个类实现了Runnable接口,并且内部使用了三个属性:
-
一个
ConcurrentLinkedDeque类的String对象,其中包含您需要处理的文件的所有名称 -
一个
ConcurrentLinkedQueue类的TextFile对象,用于存储文档 -
一个
CountDownLatch对象,用于控制任务执行的结束
类的构造函数初始化这些属性(三个属性由构造函数作为参数接收),这里给出的run()方法实现了所有功能:
String route;
System.out.println(Thread.currentThread().getName()+": Reader start");
while ((route = files.pollFirst()) != null) {
Path file = Paths.get(route);
TextFile textFile;
try {
textFile = new TextFile(file);
buffer.offer(textFile);
} catch (IOException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName()+": Reader end: "+buffer.size());
readersCounter.countDown();
}
}
首先,我们读取所有文件的内容。对于每个文件,我们创建一个TextFile类的对象。这个类包含文本文件的名称和内容。它有一个构造函数,接收一个包含文件路径的Path对象。最后,我们在控制台中写入一条消息,并使用CountDownLatch对象的countDown()方法来指示该任务的完成。
这是TextFile类的代码。在内部,它有两个属性来存储文件名和其内容。它使用Files类的readAllLines()方法将文件内容转换为List<String>数据结构:
public class TextFile {
private String fileName;
private List<String> content;
public TextFile(String fileName, List<String> content) {
this.fileName = fileName;
this.content = content;
}
public TextFile(Path path) throws IOException {
this(path.getFileName().toString(), Files.readAllLines(path));
}
public String getFileName() {
return fileName;
}
public List<String> getContent() {
return content;
}
}
Indexer 系统
这个系统是在Indexer类中实现的,该类还实现了Runnable接口。在这种情况下,我们使用五个内部属性,如下所示:
-
一个
ConcurrentLinkedQueue,其中包含所有文档内容的TextFile -
一个
ConcurrentLinkedDeque,其中包含形成每个文档的单词列表的Document对象 -
一个
CountDownLatch对象,用于控制Reader系统的完成 -
一个
CountDownLatch对象,用于指示该系统任务的完成 -
一个
Vocabulary对象,用于存储构成文档集合的所有单词
类的构造函数初始化了这些属性(接收所有这些属性作为参数):
public class Indexer implements Runnable {
private ConcurrentLinkedQueue<TextFile> buffer;
private ConcurrentLinkedDeque<Document> documents;
private CountDownLatch readersCounter;
private CountDownLatch indexersCounter;
private Vocabulary voc;
run()方法实现了所有功能,如下所示:
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+": Indexer start");
do {
TextFile textFile= buffer.poll();
if (textFile!=null) {
Document document= parseDoc(textFile);
首先,它从队列中获取TextFile,如果不是null,则使用parseDoc()方法将其转换为Document对象。然后,它处理文档的所有单词,将它们存储在全局词汇表对象中,并将文档存储在文档列表中,如下面的代码所示:
document.getVoc().values()
.forEach(voc::addWord);
documents.offer(document);
}
} while ((readersCounter.getCount()>0) || (!buffer.isEmpty()));
countDown() method of the CountDownLatch object to indicate that this task has finished its execution:
indexersCounter.countDown();
System.out.println(Thread.currentThread().getName()+": Indexer end");
}
parseDoc()方法接收包含文档内容的List<String>,并返回一个Document对象。它创建一个Document对象,使用forEach()方法处理所有行,如下所示:
private Document parseDoc(TextFile textFile) {
Document doc=new Document();
doc.setName(textFile.getFileName());
textFile.getContent().forEach(line -> parseLine(line,doc));
return doc;
}
parseLine()方法将行分割成单词,并将它们存储在doc对象中,如下所示:
private static void parseLine(String inputLine, Document doc) {
// Clean string
String line=new String(inputLine);
line = Normalizer.normalize(line, Normalizer.Form.NFKD);
line = line.replaceAll("[^\\p{ASCII}]", "");
line = line.toLowerCase();
// Tokenizer
StringTokenizer tokenizer = new StringTokenizer(line,
" ,.;:-{}[]¿?¡!|\\=*+/()\"@\t~#<>", false);
while (tokenizer.hasMoreTokens()) {
doc.addWord(tokenizer.nextToken());
}
}
您可以在之前呈现的代码中包含一个优化,即预编译replaceAll()方法中使用的正则表达式:
static final Pattern NON_ASCII = Pattern.compile("[^\\p{ASCII}]");
line = NON_ASCII.matcher(line).replaceAll("");
}
映射器系统
该系统是在Mapper类中实现的,该类还实现了Runnable接口。在内部,它使用以下两个属性:
-
一个包含所有文档信息的
ConcurrentLinkedDeque对象 -
包含整个集合中所有单词的
Vocabulary对象
其代码如下:
public class Mapper implements Runnable {
private ConcurrentLinkedDeque<Document> documents;
private Vocabulary voc;
类的构造函数初始化了这些属性,run()方法实现了该系统的功能:
public void run() {
Document doc;
int counter=0;
System.out.println(Thread.currentThread().getName()+": Mapper start");
while ((doc=documents.pollFirst())!=null) {
counter++;
首先,它从Deque对象中使用pollFirst()方法获取一个文档。然后,它处理文档中的所有单词,计算tfxidf度量,并创建一个新的Attribute对象来存储这些值。这些属性被存储在一个列表中。
List<Attribute> attributes=new ArrayList<>();
doc.getVoc().forEach((key, item)-> {
Word word=voc.getWord(key);
item.setTfxidf(item.getTfxidf()/word.getDf());
Attribute attribute=new Attribute();
attribute.setIndex(word.getIndex());
attribute.setValue(item.getTfxidf());
attributes.add(attribute);
});
最后,我们将列表转换为一个Attribute对象数组,并将该数组存储在Document对象中:
Collections.sort(attributes);
doc.setExample(attributes);
}
System.out.println(Thread.currentThread().getName()+": Mapper end: "+counter);
}
聚类系统
该系统实现了 k 均值聚类算法。您可以使用第五章中介绍的元素,将任务分为阶段运行-Phaser 类,来实现该系统。该实现具有以下元素:
-
DistanceMeasurer 类:这个类计算包含文档信息的
Attribute对象数组与簇的质心之间的欧氏距离 -
DocumentCluster 类:这个类存储了关于一个簇的信息:质心和该簇的文档
-
AssigmentTask 类:这个类扩展了 Fork/Join 框架的
RecursiveAction类,并执行算法的分配任务,其中我们计算每个文档与所有簇之间的距离,以决定每个文档的簇 -
UpdateTask 类:这个类扩展了 Fork/Join 框架的
RecursiveAction类,并执行算法的更新任务,重新计算每个簇的质心,作为存储在其中的文档的平均值 -
ConcurrentKMeans 类:这个类有一个静态方法
calculate(),执行聚类算法并返回一个包含所有生成的簇的DocumentCluster对象数组
我们只添加了一个新类,ClusterTask类,它实现了Runnable接口,并将调用ConcurrentKMeans类的calculate()方法。在内部,它使用两个属性如下:
-
一个包含所有文档信息的
Document对象数组 -
包含集合中所有单词的
Vocabulary对象
构造函数初始化了这些属性,run()方法实现了任务的逻辑。我们调用ConcurrentKMeans类的calculate()方法,传递五个参数如下:
-
包含所有文档信息的
Document对象数组。 -
包含集合中所有单词的
Vocabulary对象。 -
我们想要生成的簇的数量。在这种情况下,我们使用
10作为簇的数量。 -
用于初始化簇质心的种子。在这种情况下,我们使用
991作为种子。 -
在 Fork/Join 框架中用于将任务分割成子任务的参考大小。在这种情况下,我们使用
10作为最小大小。
这是该类的代码:
@Override
public void run() {
System.out.println("Documents to cluster: "+documents.length);
ConcurrentKMeans.calculate(documents, 10, voc.getVocabulary().size(), 991, 10);
}
文档聚类应用程序的主类
一旦我们实现了应用程序中使用的所有元素,我们必须实现系统的main()方法。在这种情况下,这个方法非常关键,因为它负责启动系统并创建需要同步它们的元素。Reader和Indexer系统将同时执行。它们将使用一个缓冲区来共享信息。当读取器读取一个文档时,它将在缓冲区中写入String对象的列表,然后继续处理下一个文档。它不会等待处理该List的任务。这是异步消息传递的一个例子。Indexer系统将从缓冲区中取出文档,处理它们,并生成包含文档所有单词的Vocabulary对象。Indexer系统执行的所有任务共享Vocabulary类的同一个实例。这是共享内存的一个例子。
主类将使用CountDownLatch对象的await()方法以同步的方式等待Reader和Indexer系统的完成。该方法会阻塞调用线程的执行,直到其内部计数器达到 0。
一旦两个系统都完成了它们的执行,Mapper系统将使用Vocabulary对象和Document信息来获取每个文档的向量空间模型表示。当Mapper完成执行后,Clustering系统将对所有文档进行聚类。我们使用CompletableFuture类来同步Mapper系统的结束和Clustering系统的开始。这是两个系统之间异步通信的另一个例子。
我们已经在ClusteringDocs类中实现了主类。
首先,我们创建一个ThreadPoolExecutor对象,并使用readFileNames()方法获取包含文档的文件的ConcurrentLinkedDeque:
public class ClusteringDocs {
private static int NUM_READERS = 2;
private static int NUM_WRITERS = 4;
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor executor=(ThreadPoolExecutor) Executors.newCachedThreadPool();
ConcurrentLinkedDeque<String> files=readFiles("data");
System.out.println(new Date()+":"+files.size()+" files read.");
然后,我们创建文档的缓冲区ConcurrentLinkedDeque,用于存储Document对象、Vocabulary对象和两个CountDownLatch对象——一个用于控制Reader系统任务的结束,另一个用于控制Indexer系统任务的结束。我们有以下代码:
ConcurrentLinkedQueue<List<String>> buffer=new ConcurrentLinkedQueue<>();
CountDownLatch readersCounter=new CountDownLatch(2);
ConcurrentLinkedDeque<Document> documents=new ConcurrentLinkedDeque<>();
CountDownLatch indexersCounter=new CountDownLatch(4);
Vocabulary voc=new Vocabulary();
然后,我们启动两个任务来执行DocumentReader类的Reader系统,另外四个任务来执行Indexer类的Indexer系统。所有这些任务都在我们之前创建的Executor对象中执行:
System.out.println(new Date()+":"+"Launching the tasks");
for (int i=0; i<NUM_READERS; i++) {
DocumentReader reader=new DocumentReader(files,buffer,readersCounter);
executor.execute(reader);
}
for (int i=0; i<NUM_WRITERS; i++) {
Indexer indexer=new Indexer(documents, buffer, readersCounter, indexersCounter, voc);
executor.execute(indexer);
}
然后,main()方法等待这些任务的完成;首先是DocumentReader任务,然后是Indexer任务,如下所示:
System.out.println(new Date()+":"+"Waiting for the readers");
readersCounter.await();
System.out.println(new Date()+":"+"Waiting for the indexers");
indexersCounter.await();
然后,我们将ConcurrentLinkedDeque类的Document对象转换为数组:
Document[] documentsArray=new Document[documents.size()];
documentsArray=documents.toArray(documentsArray);
我们启动Indexer系统,使用CompletableFuture类的runAsync()方法执行Mapper类的四个任务,如下所示:
System.out.println(new Date()+":"+"Launching the mappers");
CompletableFuture<Void>[] completables = Stream.generate(() -> new Mapper(documents, voc))
.limit(4)
.map(CompletableFuture::runAsync)
.toArray(CompletableFuture[]::new);
然后,我们启动Clustering系统,启动ClusterTask类的一个任务(请记住,这些任务将启动其他任务来执行算法)。main()方法使用CompletableFuture类的allOf()方法等待Mapper任务的完成,然后使用thenRunAsync()方法在Mapper系统完成后启动聚类算法:
System.out.println(new Date()+":"+"Launching the cluster calculation");
CompletableFuture<Void> completableMappers= CompletableFuture.allOf(completables);
ClusterTask clusterTask=new ClusterTask(documentsArray, voc);
CompletableFuture<Void> completableClustering= completableMappers.thenRunAsync(clusterTask);
最后,我们使用get()方法等待Clustering系统的完成,并按以下方式结束程序的执行:
System.out.println(new Date()+":"+"Wating for the cluster calculation");
try {
completableClustering.get();
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
System.out.println(new Date()+":"+"Execution finished");
executor.shutdown();
}
readFileNames()方法接收一个字符串作为参数,该字符串必须是存储文档集合的目录的路径,并生成一个包含该目录中文件名称的ConcurrentLinkedDeque类的String对象。
测试我们的文档聚类应用程序
为了测试这个应用程序,我们使用了来自维基百科的有关电影的 100,673 个文档中的 10,052 个文档的子集作为文档集。在下图中,您可以看到执行的第一部分的结果-从执行开始到索引器执行结束为止:

以下图片显示了示例执行的其余部分:

您可以看到任务如何在本章前面同步。首先,Reader和Indexer任务以并发方式执行。当它们完成时,映射器对数据进行转换,最后,聚类算法组织示例。
使用并发编程实现替代方案
本书中大多数示例都可以使用 Java 并发 API 的其他组件来实现。在本节中,我们将描述如何实现其中一些替代方案。
k 最近邻算法
您已经在第二章中使用执行器实现了 k 最近邻算法,管理大量线程-执行器,这是一种用于监督分类的简单机器学习算法。您有一组先前分类的示例的训练集。要获得新示例的类别,您需要计算此示例与示例的训练集之间的距离。最近示例中的大多数类别是为示例选择的类别。您还可以使用并发 API 的以下组件之一实现此算法:
-
线程:您可以使用
Thread对象实现此示例。您必须使用普通线程执行执行器中执行的任务。每个线程将计算示例与训练集子集之间的距离,并将该距离保存在所有线程之间共享的数据结构中。当所有线程都完成时,您可以使用距离对数据结构进行排序并计算示例的类别。 -
Fork/Join 框架:与先前的解决方案一样,每个任务将计算示例与训练集子集之间的距离。在这种情况下,您定义了这些子集中示例的最大数量。如果一个任务需要处理更多的示例,您将该任务分成两个子任务。在加入了两个任务之后,您必须生成一个包含两个子任务结果的唯一数据结构。最后,您将获得一个包含所有距离的数据结构,可以对其进行排序以获得示例的类别。
-
流:您可以从训练数据创建一个流,并将每个训练示例映射到一个包含要分类的示例与该示例之间距离的结构中。然后,您对该结构进行排序,使用
limit()获取最接近的示例,并计算最终的结果类别。
构建文档集的倒排索引
我们已经在第四章中使用执行器实现了此示例,从任务中获取数据-Callable 和 Future 接口。倒排索引是信息检索领域中用于加速信息搜索的数据结构。它存储了文档集中出现的单词,对于每个单词,存储了它们出现的文档。当您搜索信息时,您无需处理文档。您查看倒排索引以提取包含您插入的单词的文档,并构建结果列表。您还可以使用并发 API 的以下组件之一实现此算法:
-
线程:每个线程将处理一部分文档。这个过程包括获取文档的词汇并更新一个共同的数据结构与全局索引。当所有线程都完成执行后,可以按顺序创建文件。
-
Fork/Join 框架:您定义任务可以处理的文档的最大数量。如果一个任务必须处理更多的文档,您将该任务分成两个子任务。每个任务的结果将是一个包含由这些任务或其子任务处理的文档的倒排索引的数据结构。在合并两个子任务后,您将从其子任务的倒排索引构造一个唯一的倒排索引。
-
流:您创建一个流来处理所有文件。您将每个文件映射到其词汇对象,然后将减少该词汇流以获得倒排索引。
单词的最佳匹配算法
您已经在第四章中实现了这个例子,从任务中获取数据 - Callable 和 Future 接口。这个算法的主要目标是找到与作为参数传递的字符串最相似的单词。您还可以使用并发 API 的以下组件之一来实现此算法:
-
线程:每个线程将计算搜索词与整个词列表的子列表之间的距离。每个线程将生成一个部分结果,这些结果将合并到所有线程之间共享的最终结果中。
-
Fork/Join 框架:每个任务将计算搜索词与整个词列表的子列表之间的距离。如果列表太大,必须将任务分成两个子任务。每个任务将返回部分结果。在合并两个子任务后,任务将把两个子列表整合成一个。原始任务将返回最终结果。
-
流:您为整个单词列表创建一个流,将每个单词与包括搜索词与该单词之间距离的数据结构进行映射,对该列表进行排序,并获得结果。
遗传算法
您已经在第五章中实现了这个例子,分阶段运行任务 - Phaser 类。遗传算法是一种基于自然选择原则的自适应启发式搜索算法,用于生成优化和搜索问题的良好解决方案。有不同的方法可以使用多个线程来进行遗传算法。最经典的方法是创建岛屿。每个线程代表一个岛屿,其中一部分种群会进化。有时,岛屿之间会发生迁移,将一些个体从一个岛屿转移到另一个岛屿。算法完成后,选择跨所有岛屿的最佳物种。这种方法大大减少了争用,因为线程很少彼此交流。
还有其他方法在许多出版物和网站上有很好的描述。例如,这份讲义集在cw.fel.cvut.cz/wiki/_media/courses/a0m33eoa/prednasky/08pgas-handouts.pdf上很好地总结了这些方法。
您还可以使用并发 API 的以下组件之一来实现此算法:
-
线程:所有个体的种群必须是一个共享的数据结构。您可以按以下方式实现三个阶段:选择阶段以顺序方式进行;交叉阶段使用线程,其中每个线程将生成预定义数量的个体;评估阶段也使用线程。每个线程将评估预定义数量的个体。
-
执行者:您可以实现类似于之前的内容,将任务在执行者中执行,而不是独立的线程。
-
Fork/Join 框架:主要思想是相同的,但在这种情况下,您的任务将被分割,直到它们处理了预定义数量的个体。在这种情况下,加入部分不起作用,因为任务的结果将存储在共同的数据结构中。
关键词提取算法
您已经在第五章中实现了这个例子,分阶段运行任务-Phaser 类。我们使用这种算法来提取描述文档的一小组词语。我们尝试使用 Tf-Idf 等度量标准找到最具信息量的词语。您还可以使用并发 API 的以下组件来实现此示例:
-
线程:您需要两种类型的线程。第一组线程将处理文档集以获得每个词的文档频率。您需要一个共享的数据结构来存储集合的词汇表。第二组线程将再次处理文档,以获得每个文档的关键词,并更新一个维护整个关键词列表的结构。
-
Fork/Join 框架:主要思想与以前的版本类似。您需要两种类型的任务。第一个任务是获得文档集的全局词汇表。每个任务将计算子集文档的词汇表。如果子集太大,任务将执行两个子任务。在加入子任务后,它将将获得的两个词汇表合并为一个。第二组任务将计算关键词列表。每个任务将计算子集文档的关键词列表。如果子集太大,它将执行两个子任务。当这些任务完成时,父任务将使用子任务返回的列表生成关键词列表。
-
流:您创建一个流来处理所有文档。您将每个文档与包含文档词汇表的对象进行映射,并将其减少以获得全局词汇表。您生成另一个流来再次处理所有文档,将每个文档与包含其关键词的对象进行映射,并将其减少以生成最终的关键词列表。
一个 k 均值聚类算法
您已经在第六章中实现了这个算法,优化分治解决方案-Fork/Join 框架。这个算法将一组元素分类到先前定义的一定数量的集群中。您对元素的类别没有任何信息,因此这是一种无监督学习算法,它试图找到相似的项目。您还可以使用并发 API 的以下组件来实现此示例:
-
线程:您将有两种类型的线程。第一种将为示例分配一个集群。每个线程将处理示例集的子集。第二种线程将更新集群的质心。集群和示例必须是所有线程共享的数据结构。
-
执行者:您可以实现之前提出的想法,但是在执行任务时使用执行者,而不是使用独立的线程。
一个过滤数据算法
您已经在第六章中实现了这个算法,优化分治解决方案-Fork/Join 框架。这个算法的主要目标是从一个非常大的对象集中选择满足某些条件的对象。您还可以使用并发 API 的以下组件来实现此示例:
-
线程:每个线程将处理对象的一个子集。如果您正在寻找一个结果,当找到一个线程时,它必须暂停其余的执行。如果您正在寻找一个元素列表,那个列表必须是一个共享的数据结构。
-
执行器:与之前相同,但在执行器中执行任务,而不是使用独立线程。
-
流:您可以使用
Stream类的filter()方法来对对象进行搜索。然后,您可以将这些结果减少到您需要的格式。
搜索倒排索引
您已经在第七章中实现了这个算法,使用并行流处理大型数据集-映射和减少模型。在之前的例子中,我们讨论了如何实现创建倒排索引以加速信息搜索的算法。这是执行信息搜索的算法。您还可以使用并发 API 的以下组件来实现此示例:
-
线程:这是一个共同数据结构中的结果列表。每个线程处理倒排索引的一部分。每个结果都按顺序插入以生成一个排序的数据结构。如果您获得了足够好的结果列表,您可以返回该列表并取消任务的执行。
-
执行器:这与前一个类似,但在执行器中执行并发任务。
-
Fork/Join framework:这与前一个类似,但每个任务将倒排索引的部分划分为更小的块,直到它们足够小。
数字摘要算法
您已经在第七章中实现了这个例子,使用并行流处理大型数据集-映射和减少模型。这种类型的算法希望获得关于非常大的数据集的统计信息。您还可以使用并发 API 的以下组件来实现此示例:
-
线程:我们将有一个对象来存储线程生成的数据。每个线程将处理数据的一个子集,并将该数据的结果存储在共同的对象中。也许,我们将不得不对该对象进行后处理,以生成最终结果。
-
执行器:这与前一个类似,但在执行器中执行并发任务。
-
Fork/Join framework:这与前一个类似,但每个任务将倒排索引的部分划分为更小的块,直到它们足够小。
没有索引的搜索算法
您已经在第八章中实现了这个例子,使用并行流处理大型数据集-映射和收集模型。当您没有倒排索引来加速搜索时,该算法会获取满足某些条件的对象。在这些情况下,您必须在进行搜索时处理所有元素。您还可以使用并发 API 的以下组件来实现此示例:
-
线程:每个线程将处理一个对象(在我们的案例中是文件)的子集,以获得结果列表。结果列表将是一个共享的数据结构。
-
执行器:这与前一个类似,但并发任务将在执行器中执行。
-
Fork/Join framework:这与前一个类似,但任务将倒排索引的部分划分为更小的块,直到它们足够小。
使用映射和收集模型的推荐系统
您已经在第八章中实现了这个例子,使用并行流处理大型数据集 - 映射和收集模型。推荐系统根据客户购买/使用的产品/服务以及购买/使用与他购买/使用相同服务的用户购买/使用的产品/服务向客户推荐产品或服务。您还可以使用并发 API 的 Phaser 组件来实现这个例子。该算法有三个阶段:
-
第一阶段:我们需要将带有评论的产品列表转换为购买者与他们购买的产品的列表。每个任务将处理产品的一个子集,并且购买者列表将是一个共享的数据结构。
-
第二阶段:我们需要获得购买了与参考用户相同产品的用户列表。每个任务将处理用户购买的产品项目,并将购买了该产品的用户添加到一个共同的用户集合中。
-
第三阶段:我们获得了推荐的产品。每个任务将处理前一个列表中的用户,并将他购买的产品添加到一个共同的数据结构中,这将生成最终的推荐产品列表。
总结
在本书中,您实现了许多真实世界的例子。其中一些例子可以作为更大系统的一部分。这些更大的系统通常有不同的并发部分,它们必须共享信息并在它们之间进行同步。为了进行同步,我们可以使用三种机制:共享内存,当两个或更多任务共享一个对象或数据结构时;异步消息传递,当一个任务向另一个任务发送消息并且不等待其处理时;以及同步消息传递,当一个任务向另一个任务发送消息并等待其处理时。
在本章中,我们实现了一个用于聚类文档的应用程序,由四个子系统组成。我们使用了早期介绍的机制来在这四个子系统之间同步和共享信息。
我们还修改了书中提出的一些例子,讨论了它们的其他实现方法。
在下一章中,您将学习如何获取并发 API 组件的调试信息,以及如何监视和测试并发应用程序。
第十一章:测试和监控并发应用程序
软件测试是每个开发过程的关键任务。每个应用程序都必须满足最终用户的要求,测试阶段是证明这一点的地方。它必须在可接受的时间内以指定的格式生成有效的结果。测试阶段的主要目标是尽可能多地检测软件中的错误,以便纠正错误并提高产品的整体质量。
传统上,在瀑布模型中,测试阶段在开发阶段非常先进时开始,但如今越来越多的开发团队正在使用敏捷方法,其中测试阶段集成到开发阶段中。主要目标是尽快测试软件,以便在流程早期检测错误。
在 Java 中,有许多工具,如JUnit或TestNG,可以自动执行测试。其他工具,如JMeter,允许您测试有多少用户可以同时执行您的应用程序,还有其他工具,如Selenium,您可以用来在 Web 应用程序中进行集成测试。
测试阶段在并发应用程序中更为关键和更为困难。您可以同时运行两个或更多个线程,但无法控制它们的执行顺序。您可以对应用程序进行大量测试,但无法保证不存在执行不同线程的顺序引发竞争条件或死锁的情况。这种情况也导致了错误的再现困难。您可能会发现只在特定情况下发生的错误,因此很难找到其真正的原因。在本章中,我们将涵盖以下主题,以帮助您测试并发应用程序:
-
监控并发对象
-
监控并发应用程序
-
测试并发应用程序
监控并发对象
Java 并发 API 提供的大多数并发对象都包括了用于了解它们状态的方法。此状态可以包括正在执行的线程数、正在等待条件的线程数、已执行的任务数等。在本节中,您将学习可以使用的最重要的方法以及您可以从中获取的信息。这些信息对于检测错误的原因非常有用,特别是如果错误只在非常罕见的情况下发生。
监控线程
线程是 Java 并发 API 中最基本的元素。它允许您实现原始任务。您可以决定要执行的代码(扩展Thread类或实现Runnable接口)、何时开始执行以及如何与应用程序的其他任务同步。Thread类提供了一些方法来获取有关线程的信息。以下是最有用的方法:
-
getId(): 此方法返回线程的标识符。它是一个long正数,且是唯一的。 -
getName(): 此方法返回线程的名称。默认情况下,它的格式为Thread-xxx,但可以在构造函数中或使用setName()方法进行修改。 -
getPriority(): 此方法返回线程的优先级。默认情况下,所有线程的优先级都为五,但您可以使用setPriority()方法进行更改。具有较高优先级的线程可能优先于具有较低优先级的线程。 -
getState(): 此方法返回线程的状态。它返回一个EnumThread.State的值,可以取值:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING和TERMINATED。您可以查看 API 文档以了解每个状态的真正含义。 -
getStackTrace(): 此方法以StackTraceElement对象的数组形式返回此线程的调用堆栈。您可以打印此数组以了解线程所做的调用。
例如,您可以使用类似以下的代码片段来获取线程的所有相关信息:
System.out.println("**********************");
System.out.println("Id: " + thread.getId());
System.out.println("Name: " + thread.getName());
System.out.println("Priority: " + thread.getPriority());
System.out.println("Status: " + thread.getState());
System.out.println("Stack Trace");
for(StackTraceElement ste : thread.getStackTrace()) {
System.out.println(ste);
}
System.out.println("**********************\n");
使用此代码块,您将获得以下输出:

监视锁
锁是 Java 并发 API 提供的基本同步元素之一。它在Lock接口和ReentrantLock类中定义。基本上,锁允许您在代码中定义临界区,但Lock机制比其他机制更灵活,如同步关键字(例如,您可以有不同的锁来进行读写操作或具有非线性的临界区)。ReentrantLock类具有一些方法,允许您了解Lock对象的状态:
-
getOwner(): 此方法返回一个Thread对象,其中包含当前拥有锁的线程,也就是执行临界区的线程。 -
hasQueuedThreads(): 此方法返回一个boolean值,指示是否有线程在等待获取此锁。 -
getQueueLength(): 此方法返回一个int值,其中包含等待获取此锁的线程数。 -
getQueuedThreads(): 此方法返回一个Collection<Thread>对象,其中包含等待获取此锁的Thread对象。 -
isFair(): 此方法返回一个boolean值,指示公平属性的状态。此属性的值用于确定下一个获取锁的线程。您可以查看 Java API 信息,以获取有关此功能的详细描述。 -
isLocked(): 此方法返回一个boolean值,指示此锁是否被线程拥有。 -
getHoldCount(): 此方法返回一个int值,其中包含此线程获取锁的次数。如果此线程未持有锁,则返回值为零。否则,它将返回当前线程中调用lock()方法的次数,而未调用匹配的unlock()方法。
getOwner()和getQueuedThreads()方法受到保护,因此您无法直接访问它们。为解决此问题,您可以实现自己的Lock类,并实现提供该信息的方法。
例如,您可以实现一个名为MyLock的类,如下所示:
public class MyLock extends ReentrantLock {
private static final long serialVersionUID = 8025713657321635686L;
public String getOwnerName() {
if (this.getOwner() == null) {
return "None";
}
return this.getOwner().getName();
}
public Collection<Thread> getThreads() {
return this.getQueuedThreads();
}
}
因此,您可以使用类似以下的代码片段来获取有关锁的所有相关信息:
System.out.println("************************\n");
System.out.println("Owner : " + lock.getOwnerName());
System.out.println("Queued Threads: " + lock.hasQueuedThreads());
if (lock.hasQueuedThreads()) {
System.out.println("Queue Length: " + lock.getQueueLength());
System.out.println("Queued Threads: ");
Collection<Thread> lockedThreads = lock.getThreads();
for (Thread lockedThread : lockedThreads) {
System.out.println(lockedThread.getName());
}
}
System.out.println("Fairness: " + lock.isFair());
System.out.println("Locked: " + lock.isLocked());
System.out.println("Holds: "+lock.getHoldCount());
System.out.println("************************\n");
使用此代码块,您将获得类似以下的输出:

监视执行器
执行器框架是一种机制,允许您执行并发任务,而无需担心线程的创建和管理。您可以将任务发送到执行器。它具有一个内部线程池,用于执行任务。执行器还提供了一种机制来控制任务消耗的资源,以便您不会过载系统。执行器框架提供了Executor和ExecutorService接口以及一些实现这些接口的类。实现它们的最基本的类是ThreadPoolExecutor类。它提供了一些方法,允许您了解执行器的状态:
-
getActiveCount(): 此方法返回正在执行任务的执行器线程数。 -
getCompletedTaskCount(): 此方法返回已由执行器执行并已完成执行的任务数。 -
getCorePoolSize(): 此方法返回核心线程数。此数字确定池中的最小线程数。即使执行器中没有运行任务,池中的线程数也不会少于此方法返回的数字。 -
getLargestPoolSize(): 此方法返回执行器池中同时存在的最大线程数。 -
getMaximumPoolSize(): 此方法返回池中可以同时存在的最大线程数。 -
getPoolSize(): 此方法返回池中当前线程的数量。 -
getTaskCount(): 此方法返回已发送到执行程序的任务数量,包括等待、运行和已完成的任务。 -
isTerminated(): 如果已调用shutdown()或shutdownNow()方法并且Executor已完成所有待处理任务的执行,则此方法返回true。否则返回false。 -
isTerminating(): 如果已调用shutdown()或shutdownNow()方法但执行程序仍在执行任务,则此方法返回true。
您可以使用类似以下代码片段来获取ThreadPoolExecutor的相关信息:
System.out.println ("*******************************************");
System.out.println("Active Count: "+executor.getActiveCount());
System.out.println("Completed Task Count: "+executor.getCompletedTaskCount());
System.out.println("Core Pool Size: "+executor.getCorePoolSize());
System.out.println("Largest Pool Size: "+executor.getLargestPoolSize());
System.out.println("Maximum Pool Size: "+executor.getMaximumPoolSize());
System.out.println("Pool Size: "+executor.getPoolSize());
System.out.println("Task Count: "+executor.getTaskCount());
System.out.println("Terminated: "+executor.isTerminated());
System.out.println("Is Terminating: "+executor.isTerminating());
System.out.println ("*******************************************");
使用此代码块,您将获得类似于以下内容的输出:

监控 Fork/Join 框架
Fork/Join 框架提供了一种特殊的执行程序,用于可以使用分而治之技术实现的算法。它基于工作窃取算法。您创建一个必须处理整个问题的初始任务。此任务创建其他处理问题较小部分的子任务,并等待其完成。每个任务将要处理的子问题的大小与预定义大小进行比较。如果大小小于预定义大小,则直接解决问题。否则,将问题分割为其他子任务,并等待它们返回的结果。工作窃取算法利用正在执行等待其子任务结果的线程来执行其他任务。ForkJoinPool类提供了允许您获取其状态的方法:
-
getParallelism(): 此方法返回为池设定的期望并行级别。 -
getPoolSize(): 此方法返回池中线程的数量。 -
getActiveThreadCount(): 此方法返回当前正在执行任务的池中线程数量。 -
getRunningThreadCount(): 此方法返回不在等待其子任务完成的线程数量。 -
getQueuedSubmissionCount(): 此方法返回已提交到池中但尚未开始执行的任务数量。 -
getQueuedTaskCount(): 此方法返回此池的工作窃取队列中的任务数量。 -
hasQueuedSubmissions(): 如果已提交到池中但尚未开始执行的任务,则此方法返回true。否则返回false。 -
getStealCount(): 此方法返回 Fork/Join 池执行工作窃取算法的次数。 -
isTerminated(): 如果 Fork/Join 池已完成执行,则此方法返回true。否则返回false。
您可以使用类似以下代码片段来获取ForkJoinPool类的相关信息:
System.out.println("**********************");
System.out.println("Parallelism: "+pool.getParallelism());
System.out.println("Pool Size: "+pool.getPoolSize());
System.out.println("Active Thread Count: "+pool.getActiveThreadCount());
System.out.println("Running Thread Count: "+pool.getRunningThreadCount());
System.out.println("Queued Submission: "+pool.getQueuedSubmissionCount());
System.out.println("Queued Tasks: "+pool.getQueuedTaskCount());
System.out.println("Queued Submissions: "+pool.hasQueuedSubmissions());
System.out.println("Steal Count: "+pool.getStealCount());
System.out.println("Terminated : "+pool.isTerminated());
System.out.println("**********************");
其中pool是一个ForkJoinPool对象(例如ForkJoinPool.commonPool())。使用此代码块,您将获得类似于以下内容的输出:

监控 Phaser
Phaser是一种同步机制,允许您执行可以分为阶段的任务。此类还包括一些方法来获取 Phaser 的状态:
-
getArrivedParties(): 此方法返回已完成当前阶段的注册方数量。 -
getUnarrivedParties(): 此方法返回尚未完成当前阶段的注册方数量。 -
getPhase(): 此方法返回当前阶段的编号。第一个阶段的编号为0。 -
getRegisteredParties(): 此方法返回 Phaser 中注册方的数量。 -
isTerminated(): 此方法返回一个boolean值,指示 Phaser 是否已完成执行。
您可以使用类似以下代码片段来获取 Phaser 的相关信息:
System.out.println ("*******************************************");
System.out.println("Arrived Parties: "+phaser.getArrivedParties());
System.out.println("Unarrived Parties: "+phaser.getUnarrivedParties());
System.out.println("Phase: "+phaser.getPhase());
System.out.println("Registered Parties: "+phaser.getRegisteredParties());
System.out.println("Terminated: "+phaser.isTerminated());
System.out.println ("*******************************************");
使用此代码块,您将获得类似于此的输出:

监视流
流机制是 Java 8 引入的最重要的新功能之一。它允许您以并发方式处理大量数据集,以简单的方式转换数据并实现映射和减少编程模型。这个类没有提供任何方法(除了返回流是否并行的isParallel()方法)来了解流的状态,但包括一个名为peek()的方法,您可以将其包含在方法管道中,以记录有关在流中执行的操作或转换的日志信息。
例如,此代码计算前 999 个数字的平方的平均值:
double result=IntStream.range(0,1000)
.parallel()
.peek(n -> System.out.println (Thread.currentThread().getName()+": Number "+n))
.map(n -> n*n)
.peek(n -> System.out.println (Thread.currentThread().getName()+": Transformer "+n))
.average()
.getAsDouble();
第一个peek()方法写入流正在处理的数字,第二个写入这些数字的平方。如果您执行此代码,由于以并发方式执行流,您将获得类似于此的输出:

监视并发应用程序
当您实现 Java 应用程序时,通常会使用诸如 Eclipse 或 NetBeans 之类的 IDE 来创建项目并编写源代码。但是JDK(Java 开发工具包的缩写)包括可以用于编译、执行或生成 Javadoc 文档的工具。其中之一是Java VisualVM,这是一个图形工具,可以显示有关在 JVM 中执行的应用程序的信息。您可以在 JDK 安装的 bin 目录中找到它(jvisualvm.exe)。您还可以安装 Eclipse 的插件(Eclipse VisualVM 启动器)以集成其功能。
如果您执行它,您将看到一个类似于这样的窗口:

在屏幕的左侧,您可以看到应用程序选项卡,其中将显示当前用户在系统中正在运行的所有 Java 应用程序。如果您在其中一个应用程序上双击,您将看到五个选项卡:
-
概述:此选项卡显示有关应用程序的一般信息。
-
监视器:此选项卡显示有关应用程序使用的 CPU、内存、类和线程的图形信息。
-
线程:此选项卡显示应用程序线程随时间的演变。
-
采样器:此选项卡显示有关应用程序内存和 CPU 利用率的信息。它类似于分析器选项卡,但以不同的方式获取数据。
-
分析器:此选项卡显示有关应用程序内存和 CPU 利用率的信息。它类似于采样器选项卡,但以不同的方式获取数据。
在接下来的部分,您将了解每个选项卡中可以获得的信息。您可以在visualvm.java.net/docindex.html上查阅有关此工具的完整文档。
概述选项卡
如前所述,此选项卡显示有关应用程序的一般信息。此信息包括:
-
PID:应用程序的进程 ID。
-
主机:执行应用程序的计算机名称。
-
主类:实现
main()方法的类的完整名称。 -
参数:您传递给应用程序的参数列表。
-
JVM:执行应用程序的 JVM 版本。
-
Java:您正在运行的 Java 版本。
-
Java 主目录:系统中 JDK 的位置。
-
JVM 标志:与 JVM 一起使用的标志。
-
JVM 参数:此选项卡显示我们(或 IDE)传递给 JVM 以执行应用程序的参数。
-
系统属性:此选项卡显示系统属性和属性值。您可以使用
System.getProperties()方法获取此信息。
这是访问应用程序数据时的默认选项卡,并且外观类似于以下截图:

监视器选项卡
正如我们之前提到的,此选项卡向您显示了有关应用程序使用的 CPU、内存、类和线程的图形信息。您可以看到这些指标随时间的演变。此选项卡的外观类似于这样:

在右上角,您有一些复选框可以选择要查看的信息。CPU图表显示了应用程序使用的 CPU 的百分比。堆图表显示了堆的总大小以及应用程序使用的堆的大小。在这部分,您可以看到有关元空间(JVM 用于存储类的内存区域)的相同信息。类图表显示了应用程序使用的类的数量,线程图表显示了应用程序内运行的线程数量。您还可以在此选项卡中使用两个按钮:
-
执行 GC:立即在应用程序中执行垃圾回收
-
堆转储:它允许您保存应用程序的当前状态以供以后检查
当您创建堆转储时,将会有一个新的选项卡显示其信息。它的外观类似于这样:

您有不同的子选项卡来查询您进行堆转储时应用程序的状态。
线程选项卡
正如我们之前提到的,在线程选项卡中,您可以看到应用程序线程随时间的演变。它向您展示了以下信息:
-
活动线程:应用程序中的线程数量。
-
守护线程:应用程序中标记为守护线程的线程数量。
-
时间线:线程随时间的演变,包括线程的状态(使用颜色代码),线程运行的时间以及线程存在的时间。在
总计列的右侧,您可以看到一个箭头。如果单击它,您可以选择在此选项卡中看到的列。
其外观类似于这样:

此选项卡还有线程转储按钮。如果单击此按钮,您将看到一个新的选项卡,其中包含应用程序中每个正在运行的线程的堆栈跟踪。其外观类似于这样:

采样器选项卡
采样器选项卡向您展示了应用程序使用的 CPU 和内存的利用信息。为了获取这些信息,它获取了应用程序的所有线程的转储,并处理了该转储。该选项卡类似于分析器选项卡,但正如您将在下一节中看到的,它们之间的区别在于它们用于获取信息的方式。
此选项卡的外观类似于这样:

您有两个按钮:
-
CPU:此按钮用于获取有关 CPU 使用情况的信息。如果单击此按钮,您将看到两个子选项卡:
-
CPU 样本:在此选项卡中,您将看到应用程序类的 CPU 利用率
-
线程 CPU 时间:在此选项卡中,您将看到每个线程的 CPU 利用率
-
内存:此按钮用于获取有关内存使用情况的信息。如果单击此按钮,您将看到另外两个子选项卡:
-
堆直方图:在此选项卡中,您将看到按数据类型分配的字节数
-
每个线程分配:在此选项卡中,您可以看到每个线程使用的内存量
分析器选项卡
分析器选项卡向您展示了使用仪器 API 的应用程序的 CPU 和内存利用信息。基本上,当 JVM 加载方法时,此 API 会向方法添加一些字节码以获取这些信息。此信息会随时间更新。
此选项卡的外观类似于这样:

默认情况下,此选项卡不会获取任何信息。您必须启动分析会话。为此,您可以使用CPU按钮来获取有关 CPU 利用率的信息。这包括每个方法的执行时间和对这些方法的调用次数。您还可以使用内存按钮。在这种情况下,您可以看到每种数据类型的内存量和对象数量。
当您不需要获取更多信息时,可以使用停止按钮停止分析会话。
测试并发应用程序
测试并发应用程序是一项艰巨的任务。您的应用程序的线程在计算机上运行,没有任何保证它们的执行顺序(除了您包含的同步机制),因此很难(大多数情况下是不可能的)测试所有可能发生的情况。您可能会遇到无法重现的错误,因为它只在罕见或独特的情况下发生,或者因为 CPU 内核数量的不同而在一台机器上发生而在其他机器上不会发生。为了检测和重现这种情况,您可以使用不同的工具:
-
调试:您可以使用调试器来调试应用程序。如果应用程序中只有几个线程,您必须逐步进行每个线程的调试,这个过程将非常繁琐。您可以配置 Eclipse 或 NetBeans 来测试并发应用程序。
-
MultithreadedTC:这是一个Google Code的存档项目,可以用来强制并发应用程序的执行顺序。
-
Java PathFinder:这是 NASA 用于验证 Java 程序的执行环境。它包括验证并发应用程序的支持。
-
单元测试:您可以创建一堆单元测试(使用 JUnit 或 TestNG),并启动每个测试,例如,1,000 次。如果每个测试都成功,那么即使您的应用程序存在竞争,它们的机会也不是很高,可能对生产是可以接受的。您可以在代码中包含断言来验证它是否存在竞争条件。
在接下来的部分中,您将看到使用 MultithreadedTC 和 Java PathFinder 工具测试并发应用程序的基本示例。
使用 MultithreadedTC 测试并发应用程序
MultithreadedTC 是一个存档项目,您可以从code.google.com/p/multithreadedtc/下载。它的最新版本是 2007 年的,但您仍然可以使用它来测试小型并发应用程序或大型应用程序的部分。您不能用它来测试真实的任务或线程,但您可以用它来测试不同的执行顺序,以检查它们是否引起竞争条件或死锁。
它基于一个内部时钟,使用允许您控制不同线程的执行顺序的滴答声。以测试该执行顺序是否会引起任何并发问题。
首先,您需要将两个库与您的项目关联起来:
-
MultithreadedTC 库:最新版本是 1.01 版本
-
JUnit 库:我们已经测试了这个例子,使用的是 4.12 版本
要使用 MultithreadedTC 库实现测试,您必须扩展MultithreadedTestCase类,该类扩展了 JUnit 库的Assert类。您可以实现以下方法:
-
initialize(): 这个方法将在测试执行开始时执行。如果需要执行初始化代码来创建数据对象、数据库连接等,您可以重写它。 -
finish(): 这个方法将在测试执行结束时执行。您可以重写它来实现测试的验证。 -
threadXXX(): 您必须为测试中的每个线程实现一个以thread关键字开头的方法。例如,如果您想要进行一个包含三个线程的测试,您的类将有三个方法。
MultithreadedTestCase提供了waitForTick()方法。此方法接收等待的时钟周期数作为参数。此方法使调用线程休眠,直到内部时钟到达该时钟周期。
第一个时钟周期是时钟周期编号0。MultithreadedTC 框架每隔一段时间检查测试线程的状态。如果所有运行的线程都在waitForTick()方法中等待,它会增加时钟周期编号并唤醒所有等待该时钟周期的线程。
让我们看一个使用它的例子。假设您想要测试具有内部int属性的Data对象。您希望一个线程增加值,另一个线程减少值。您可以创建一个名为TestClassOk的类,该类扩展了MultithreadedTestCase类。我们使用数据对象的三个属性:我们将用于增加和减少数据的数量以及数据的初始值:
public class TestClassOk extends MultithreadedTestCase {
private Data data;
private int amount;
private int initialData;
public TestClassOk (Data data, int amount) {
this.amount=amount;
this.data=data;
this.initialData=data.getData();
}
我们实现了两种方法来模拟两个线程的执行。第一个线程在threadAdd()方法中实现:
public void threadAdd() {
System.out.println("Add: Getting the data");
int value=data.getData();
System.out.println("Add: Increment the data");
value+=amount;
System.out.println("Add: Set the data");
data.setData(value);
}
它读取数据的值,增加其值,并再次写入数据的值。第二个线程在threadSub()方法中实现:
public void threadSub() {
waitForTick(1);
System.out.println("Sub: Getting the data");
int value=data.getData();
System.out.println("Sub: Decrement the data");
value-=amount;
System.out.println("Sub: Set the data");
data.setData(value);
}
}
首先,我们等待1时钟周期。然后,我们获取数据的值,减少其值,并重新写入数据的值。
要执行测试,我们可以使用TestFramework类的runOnce()方法:
public class MainOk {
public static void main(String[] args) {
Data data=new Data();
data.setData(10);
TestClassOk ok=new TestClassOk(data,10);
try {
TestFramework.runOnce(ok);
} catch (Throwable e) {
e.printStackTrace();
}
}
}
当测试开始执行时,两个线程(threadAdd()和threadSub())以并发方式启动。threadAdd()开始执行其代码,threadSub()在waitForTick()方法中等待。当threadAdd()完成其执行时,MultithreadedTC 的内部时钟检测到唯一运行的线程正在等待waitForTick()方法,因此它将时钟值增加到1并唤醒执行其代码的线程。
在下面的屏幕截图中,您可以看到此示例的执行输出。在这种情况下,一切都很顺利。

但是,您可以更改线程的执行顺序以引发错误。例如,您可以实现以下顺序,这将引发竞争条件:
public void threadAdd() {
System.out.println("Add: Getting the data");
int value=data.getData();
waitForTick(2);
System.out.println("Add: Increment the data");
value+=amount;
System.out.println("Add: Set the data");
data.setData(value);
}
public void threadSub() {
waitForTick(1);
System.out.println("Sub: Getting the data");
int value=data.getData();
waitForTick(3);
System.out.println("Sub: Decrement the data");
value-=amount;
System.out.println("Sub: Set the data");
data.setData(value);
}
在这种情况下,执行顺序确保两个线程首先读取数据的值,然后执行其操作,因此最终结果将不正确。
在下面的屏幕截图中,您可以看到此示例的执行结果:

在这种情况下,assertEquals()方法会抛出异常,因为期望值和实际值不相等。
该库的主要限制是,它仅适用于测试基本的并发代码,并且在实现测试时无法用于测试真正的Thread代码。
使用 Java Pathfinder 测试并发应用程序
Java Pathfinder或 JPF 是来自 NASA 的开源执行环境,可用于验证 Java 应用程序。它包括自己的虚拟机来执行 Java 字节码。在内部,它检测代码中可能存在多个执行路径的点,并执行所有可能性。在并发应用程序中,这意味着它将执行应用程序中运行的线程之间的所有可能的执行顺序。它还包括允许您检测竞争条件和死锁的工具。
该工具的主要优势是,它允许您完全测试并发应用程序,以确保它不会出现竞争条件和死锁。该工具的不便之处包括:
-
您必须从源代码安装它
-
如果您的应用程序很复杂,您将有成千上万种可能的执行路径,测试将非常漫长(如果应用程序很复杂,可能需要很多小时)
在接下来的几节中,我们将向您展示如何使用 Java Pathfinder 测试并发应用程序。
安装 Java Pathfinder
正如我们之前提到的,您必须从源代码安装 JPF。该代码位于 Mercurial 存储库中,因此第一步是安装 Mercurial,并且由于我们将使用 Eclipse IDE,因此还需要安装 Eclipse 的 Mercurial 插件。
您可以从www.mercurial-scm.org/wiki/Download下载 Mercurial。您可以下载提供安装助手的安装程序,在计算机上安装 Mercurial 后可能需要重新启动系统。
您可以从 Eclipse 菜单中使用Help > Install new software下载 Eclipse 的 Mercurial 插件,并使用 URL mercurialeclipse.eclipselabs.org.codespot.com/hg.wiki/update_site/stable 作为查找软件的 URL。按照其他插件的步骤进行操作。
您还可以在 Eclipse 中安装 JPF 插件。您可以从babelfish.arc.nasa.gov/trac/jpf/wiki/install/eclipse-plugin下载。
现在您可以访问 Mercurial 存储库资源管理器透视图,并添加 Java Pathfinder 的存储库。我们将仅使用存储在babelfish.arc.nasa.gov/hg/jpf/jpf-core中的核心模块。您无需用户名或密码即可访问存储库。创建存储库后,您可以右键单击存储库并选择Clone repository选项,以在计算机上下载源代码。该选项将打开一个窗口以选择一些选项,但您可以保留默认值并单击Next按钮。然后,您必须选择要加载的版本。保留默认值并单击Next按钮。最后,单击Finish按钮完成下载过程。Eclipse 将自动运行ant来编译项目。如果有任何编译问题,您必须解决它们并重新运行ant。
如果一切顺利,您的工作区将有一个名为jpf-core的项目,如下面的截图所示:

最后的配置步骤是创建一个名为site.properties的文件,其中包含 JPF 的配置。如果您访问Window | Preferences中的配置窗口,并选择JPF Preferences选项,您将看到 JPF 插件正在查找该文件的路径。如果需要,您可以更改该路径。

由于我们只使用核心模块,因此文件将只包含到jpf-core项目的路径:
jpf-core = D:/dev/book/projectos/jpf-core
运行 Java Pathfinder
安装了 JPF 后,让我们看看如何使用它来测试并发应用程序。首先,我们必须实现一个并发应用程序。在我们的情况下,我们将使用一个带有内部int值的Data类。它将初始化为0,并且将具有一个increment()方法来增加该值。
然后,我们将有一个名为NumberTask的任务,它实现了Runnable接口,将增加一个Data对象的值 10 次。
public class NumberTask implements Runnable {
private Data data;
public NumberTask (Data data) {
this.data=data;
}
@Override
public void run() {
for (int i=0; i<10; i++) {
data.increment(10);
}
}
}
最后,我们有一个实现了main()方法的MainNumber类。我们将启动两个将修改同一个Data对象的NumberTasks对象。最后,我们将获得Data对象的最终值。
public class MainNumber {
public static void main(String[] args) {
int numTasks=2;
Data data=new Data();
Thread threads[]=new Thread[numTasks];
for (int i=0; i<numTasks; i++) {
threads[i]=new Thread(new NumberTask(data));
threads[i].start();
}
for (int i=0; i<numTasks; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(data.getValue());
}
}
如果一切顺利,没有发生竞争条件,最终结果将是 200,但我们的代码没有使用任何同步机制,所以可能会发生这种情况。
如果我们想要使用 JPF 执行此应用程序,我们需要在项目内创建一个具有.jpf扩展名的配置文件。例如,我们已经创建了NumberJPF.jpf文件,其中包含我们可以使用的最基本的配置文件:
+classpath=${config_path}/bin
target=com.javferna.packtpub.mastering.testing.main.MainNumber
我们修改了 JPF 的类路径,添加了我们项目的bin目录,并指定了我们应用程序的主类。现在,我们准备通过 JPF 执行应用程序。为此,我们右键单击.jpf文件,然后选择验证选项。我们将看到在控制台中可以看到大量输出消息。每个输出消息都来自应用程序的不同执行路径。

当 JPF 结束所有可能的执行路径的执行时,它会显示有关执行的统计信息:

JPF 执行显示未检测到错误,但我们可以看到大多数结果与 200 不同,因此我们的应用程序存在竞争条件,正如我们所预期的那样。
在本节的介绍中,我们说 JPF 提供了检测竞争条件和死锁的工具。JPF 将此实现为实现Observer模式以响应代码执行中发生的某些事件的Listener机制。例如,我们可以使用以下监听器:
-
精确竞争检测器:使用此监听器来检测竞争条件
-
死锁分析器:使用此监听器来检测死锁情况
-
覆盖分析器:使用此监听器在 JPF 执行结束时编写覆盖信息
您可以在.jpf文件中配置要在执行中使用的监听器。例如,我们通过添加PreciseRaceDetector和CoverageAnalyzer监听器扩展了先前的测试在NumberListenerJPF.jpf文件中:
+classpath=${config_path}/bin
target=com.javferna.packtpub.mastering.testing.main.MainNumber
listener=gov.nasa.jpf.listener.PreciseRaceDetector,gov.nasa.jpf.li stener.CoverageAnalyzer
如果我们通过 JPF 使用验证选项执行此配置文件,您将看到应用程序在检测到第一个竞争条件时结束,并在控制台中显示有关此情况的信息:

您还将看到CoverageAnalyzer监听器也会写入信息:

JPF 是一个非常强大的应用程序,包括更多的监听器和更多的扩展机制。您可以在babelfish.arc.nasa.gov/trac/jpf/wiki找到其完整文档。
总结
测试并发应用程序是一项非常艰巨的任务。线程的执行顺序没有保证(除非在应用程序中引入了同步机制),因此您应该测试比串行应用程序更多的不同情况。有时,您的应用程序会出现错误,您可以重现这些错误,因为它们只会在非常罕见的情况下发生,有时,您的应用程序会出现错误,只会在特定的机器上发生,因为其硬件或软件配置。
在本章中,您已经学会了一些可以帮助您更轻松测试并发应用程序的机制。首先,您已经学会了如何获取有关 Java 并发 API 的最重要组件(如Thread、Lock、Executor或Stream)状态的信息。如果需要检测错误的原因,这些信息可能非常有用。然后,您学会了如何使用 Java VisualVM 来监视一般的 Java 应用程序和特定的并发应用程序。最后,您学会了使用两种不同的工具来测试并发应用程序。
通过本书的章节,您已经学会了如何使用 Java 并发 API 的最重要组件,如执行器框架、Phaser类、Fork/Join 框架以及 Java 8 中包含的新流 API,以支持对实现机器学习、数据挖掘或自然语言处理的元素流进行函数式操作的真实应用程序。您还学会了如何使用并发数据结构和同步机制,以及如何同步大型应用程序中的不同并发块。最后,您学会了并发应用程序的设计原则以及如何测试它们,这是确保成功使用这些应用程序的两个关键因素。
实现并发应用程序是一项艰巨的任务,但也是一项激动人心的挑战。我希望本书对您成功应对这一挑战有所帮助。






浙公网安备 33010602011771号