精通 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 步 - 调优
最后一步是比较并行和顺序算法的吞吐量。如果结果不如预期,您必须检查算法,寻找并行算法性能不佳的原因。
您还可以测试算法的不同参数(例如,粒度或任务数量)以找到最佳配置。
有不同的指标来衡量并行化算法可能获得的性能改进。最流行的三个指标是:
- 加速比:这是衡量并行和顺序算法版本之间相对性能改进的指标:
这里,T [顺序] 是顺序算法版本的执行时间,T [并发] 是并行版本的执行时间。
- 阿姆达尔定律:这用于计算通过算法并行化获得的最大预期改进:
这里,P是可以并行化的代码的百分比,N是您将执行算法的计算机的核心数。
例如,如果您可以并行化 75%的代码并且您有四个核心,最大加速比将由以下公式给出:
- 古斯塔夫森-巴西斯定律:阿姆达尔定律有一个限制。它假设在增加核心数时,您拥有相同的输入数据集,但通常,当您拥有更多核心时,您希望处理更多数据。古斯塔夫森定律提出,当您有更多可用的核心时,可以使用以下公式在相同时间内解决更大的问题:
这里,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()方法中可以找到这种文档的一个很好的例子。
采取懒惰初始化的预防措施
懒惰初始化是一种延迟对象创建的机制,直到对象在应用程序中首次使用。它的主要优点是最小化内存使用,因为您只创建真正需要的对象,但在并发应用程序中可能会出现问题。
如果您有一个初始化对象的方法,并且这个方法同时被两个不同的任务调用,那么您可以初始化两个不同的对象。例如,这可能是单例类的问题,因为您只想创建这些类的一个对象。
这个问题的一个优雅的解决方案已经实现,就像延迟初始化持有者习惯(en.wikipedia.org/wiki/Initia…
避免在关键部分内部使用阻塞操作
阻塞操作是那些阻塞调用它们的任务直到事件发生的操作。例如,当您从文件中读取数据或向控制台写入数据时,调用这些操作的任务必须等待它们完成。
如果您将这些操作之一包含在关键部分中,那么您会降低应用程序的性能,因为想要执行该关键部分的任务都无法执行它。在关键部分内部的任务正在等待 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 新闻阅读器。