Java性能调优13——线程与同步的性能(上)

本系列均取自《Java性能权威指南》这里只是一个个人备忘笔录

从刚问世起,Java 的部分魅力就来自其多线程。即便在多核和多 CPU 系统司空见惯之前,能够轻松编写多线程程序也是 Java 的一个标志性特征。

Java 性能方面的吸引力显而易见:如果有两个 CPU 可用,那么一个应用能够完成的工作量可能是原来的 2 倍,或者是以快 1 倍的时间完成相同的工作量。当然,这是在假设任务可以分解成离散的片段的前提之下的,因为 Java 不是能自动找出算法性部分并实现并行化的语言。幸运的是,今日所见之计算,往往是离散性的任务:服务器处理来自离散的客户端的同步请求,批处理作业在一系列数据上执行相同的操作,数学算法可以分节成多个组成部分,诸如此类。

本章探讨的主题是,如何挖掘出 Java 线程和同步设施的最大性能。

线程池与ThreadPoolExecutor

在 Java 中,线程可以使用定制的代码来管理;应用也可以利用线程池。Java EE 应用服务器就是围绕用一个或多个线程池处理请求这一概念构建的:对服务器上 Servlet 的每个调用都是通过池中的线程处理的(也有可能不同)。类似地,其他应用可以使用 Java 的 ThreadPoolExecutor 并行执行任务。

事实上,有些 Java EE 应用服务器就是使用 ThreadPoolExecutor 类的实例来管理其任务的,尽管有些应用服务器编写了自己的线程池,不过一般也仅仅是因为当时 Java API 中还没有加入 ThreadPoolExecutor 类而已。不过在这些情况下,线程池的实现可能会有所不同,但基本概念是一样的,本节都会予以讨论。

在使用线程池时,有一个因素非常关键:调节线程池的大小对获得最好的性能至关重要。线程池的性能会随线程池大小这一基本选择而有所不同,在某些条件下,线程池过大对性能也有很大的不利影响。

所有线程池的工作方式本质是一样的:有一个队列,任务被提交到这个队列中。(可以有不止一个队列,概念是一样的。)一定数量的线程会从该队列中取任务,然后执行。任务的结果可以发回客户端(比如应用服务器的情况下),或保存到数据库中,或保存到某个内部数据结构中,等等。但是在执行完任务后,这个线程会返回任务队列,检索另一个任务并执行(如果没有更多任务要执行,该线程会等待下一个任务)。

线程池有最小线程数和最大线程数。池中会有最小数目的线程随时待命,等待任务指派给它们。因为创建线程的成本非常高昂,这样可以提高任务提交时的整体性能:已有的线程会拿到该任务并处理。另一方面,线程需要一些系统资源,包括栈所需的原生内存,如果空闲线程太多,就会消耗本来可以分配给其他进程的资源。最大线程数还是一个必要的限流阀,防止一次执行太多线程。

ThreadPoolExecutor 和相关的类将最小线程数称作核心池大小,大部分应用服务器会使用类似 minimum(最小值)的术语(如 MinThreads)。不要被术语所迷惑:它们是同一个概念。然而,在决定何时调整线程池大小的方式上,ThreadPoolExecutor 和大部分 Java EE 应用服务器有些重要的差别。本节后面会探讨这些差别。现在,考虑 ThreadPoolExecutor 的最简单的情况,大部分 Java EE 应用服务器也是这么工作的:如果有个任务要执行,而所有的并发线程都在忙于执行另一个任务,就启动一个新线程(直到创建的线程达到最大线程数)。

设置最大线程数

先来设定最大线程数:对于给定硬件上的给定负载,最大线程数设置为多少最好呢?这个问题回答起来并不简单;它取决于负载特性以及底层硬件。特别是,最优线程数还与每个任务阻塞的频率有关。

为方便讨论,假设 JVM 有 4 个 CPU 可用。至于是系统只有 4 个 CPU,还是说有 128 个硬件线程但我们只想利用其中的 4 个,并不重要,因为我们的目标就是最大化这 4 个 CPU 的利用率。

很明显,最大线程数至少要设置为 4。的确,除了处理这些任务,JVM 中还有些线程要做其他的事,但是它们几乎从来不会占用一个完整的 CPU。如果使用的是前面所讨论的并发垃圾收集器,这是个例外,后台线程必须有足够的 CPU 来运行,以免在处理堆这方面落后。

如果线程数多于 4,会有帮助吗?这时就要看负载特性了。考虑最简单的情况,假定任务都是计算密集型的:没有外部网络调用(比如不会访问数据库),也不会激烈地竞争内部锁。在使用模实体管理器(mock entity manager)的情况下,股价历史批处理程序就是一个这样的应用:实体上的数据完全可以并行计算。

下面就使用线程池计算一下 10 000 个模股票实体的历史,假设机器有 4 个 CPU,使用不同的线程数测试,具体的性能数据见下表。如果池中只有 1 个线程,计算数据集需要 255.6 秒;用 4 个线程,则只需要 77 秒。如果线程数超过 4 个,随着线程数的增加,需要的时间会稍多一些。

线程数所需秒数与基准的百分比
1255.6100%
2134.852.7%
477.030.1
881.731.9%
1585.633.5%

如果应用中的任务是完全并行的,则在有 2 个线程时,“与基准的百分比”这列为 50%;在有 4 个线程时,这列为 25%。但是这种完全线性的比例不可能出现,原因有这么几点:如果没有其他线程帮助,这些线程必须自己来协同,实现从运行队列中选取任务(一般而言,通常会有更多同步)。到了使用 4 个线程的时候,系统会 100% 消耗可用的 CPU,尽管机器可能没有运行其他用户级的应用,但是会有各种系统级的进程进来,并使用 CPU,从而使得 JVM 无法 100% 地使用所有 CPU 周期。

尽管如此,这个应用在伸缩性方面表现还不错,且即使池中的线程数被显著高估,性能损失也比较轻微。

不过在其他情况下,性能损失可能会很大。在 Servlet 版的股票历史计算程序中,线程太多的话,影响会很大,如表中所示。应用服务器分别配置成不同的线程数,有一个负载生成器会向该服务器发送 20 个同步的(simultaneous)请求。

线程数每秒操作数与基准的百分比
477.43100%
875.9398.8%
1671.6592.5%
3269.3489.5%
6460.4478.1%

在这个例子中,一旦应用服务器成为瓶颈(也就是说,线程数达到 4 个时),向服务器增加负载是非常有害的——即使只是在客户端加了几个线程。

这个例子看上去可能有点有意为之。如果服务器已经是 CPU 密集型的,谁还会加入更多线程呢?之所以使用这个例子,只是因为它容易理解,而且仅使用了 Java 程序。这意味着读者自己就可以运行,并理解它是如何工作的,而不必设置数据库连接、模式(Schema)等选项。

需要指出的是,对于还要向 CPU 密集型或 I/O 密集型的机器发送数据库请求的应用服务器而言,同样的原则也成立。你可能只关注应用服务器 CPU,看到小于 100% 就感觉不错;看到有多余的请求要处理,就假定增加应用服务器的线程数是个不错的主意。结果会让人大吃一惊,因为在那种情况下增加线程数,实际上会降低整体吞吐量(影响可能非常明显),就像前面那个只有 Java 程序的例子一样。

了解系统真正瓶颈之所在非常重要的另一个原因是:如果还向瓶颈处增加负载,性能会显 著下降。相反,如果减少了当前瓶颈处的负载,性能可能会上升。

这也是设计自我调优的线程池非常困难的原因所在。线程池通常对挂起了多少工作有所了解,甚至有多少 CPU 可用也可以知道,但是它们通常看不到所在的整个环境的其他方面。因此,当有工作挂起时,增加线程(这是很多自我调优的线程池的一个核心特性,也是 ThreadPoolExecutor 的某些配置)往往是完全错误的。

遗憾的是,设置最大线程数更像是艺术而非科学,原因也在于此。在现实中,测试条件下自我调优的线程池会实现可能性能的 80%~90%;而且就算高估了所需线程数,也可能只有很小的损失。但是当设置线程数大小这方面出了问题时,系统可能会在很大程度上出现

问题。就此而言,充足的测试仍然非常关键。

设置最小线程数

一旦确定了线程池的最大线程数,就该确定所需的最小线程数了。大部分情况下,开发者会直截了当地将它们设置为同一个值。

将最小线程数设置为其他某个值(比如 1),出发点是防止系统创建太多线程,以节省系统资源。因为每个线程都需要一定量的内存,特别是线程的栈(本章后面会讨论)。根据前面的一般原则之一,所设置的系统大小应该能够处理预期的最大吞吐量,而要达到最大吞吐量,系统将需要创建所有那些线程。如果系统做不到这一点,那选择一个最小线程数也没什么帮助:如果系统达到了这样的条件——需要按所设置的最大线程数启动所有线程,而又无法满足,系统将陷入困境。创建最终可能会需要的所有线程,并确保系统可以处理预期的最大负载,这样更好。

另一方面,指定一个最小线程数的负面影响相当小。如果第一次就有很多任务要执行,会有负面影响:这时线程池需要创建一个新线程。创建线程对性能不利,这也是为什么起初需要线程池的原因,不过这种一次性的成本在性能测试中很可能察觉不到(只要这个线程仍然在池中)。

在批处理应用中,线程是在创建线程池时分配(如果将最大线程数和最小线程数设置为同一个值,就会出现这种情况),还是按需分配,并不重要:执行应用所需的时间是一样的。在其他应用中,新线程可能会在预热阶段分配(分配线程的总时间还是一样的),对性能的影响可以忽略不计。即使线程创建发生在可以测量的周期内,只要此类操作有限,也很有可能测不出来。

另一个可以调优的地方是线程的空闲时间。比如,某个线程池的最小线程数为 1,最大线程数为 4。现在假设一般会有一个线程在执行,处理一个任务;然后应用进入这样一个循环:每 15 秒,负载平均有 2 个任务要执行。第一次进入这个循环时,线程池会创建第 2 个线程,此时,让这个新创建的线程在池中至少留存一段时间是有意义的。我们希望避免这种情况:第 2 个线程创建出来后,5 秒钟内结束其任务,空闲 5 秒,然后退出了。而 5 秒之后又需要为下一个任务创建一个线程。一般而言,对于线程数为最小值的线程池,一个新线程一旦创建出来,至少应该留存几分钟,以处理任何负载飙升。如果任务到达率有个比较好的模型,可以基于这个模型设置空闲时间。另外,空闲时间应该以分钟计,而且至少在 10 分钟到 30 分钟之间。

留存一些空闲线程,对应用性能的影响通常微乎其微。一般而言,线程对象本身不会占用大量的堆空间。除非线程保持了大量的线程局部存储,或者线程的 Runnable 对象引用了大量内存。不管是哪种情况,释放这样的线程都会显著减少堆中的活数据(这反过来又会影响 GC 的效率)。

不过对线程池而言,这些情况并不多见。当池中的某个对象空闲时,它就不应该再引用任何 Runnable 对象(如果引用了,就说明哪个地方有 bug 了)。根据线程池的实现情况,线程局部变量可能会继续保留;尽管在某些情况下,线程局部变量可以有效促成对象重用,但是那些线程局部对象所占用的总的内存量,应该加以限制。

对于可能会增长到非常大(当然也是运行在规模很大的机器上)的线程池,这个规则有个重要的特例。举例而言,假设某个线程池的任务队列预计平均有 20 个任务,那么 20 就是很好的最小值。再假设这个池运行在一个规模很大的机器上,它被设计为可以处理 2000 个任务的峰值负载。如果在池中留存 2000 个空闲线程,则当只有 20 个任务时,对性能会有所影响:如果只有核心的 20 个线程忙碌,与有 1980 个空闲线程相比,前者的吞吐量可能是后者的 50%。线程池一般不会遇到这样的问题,但如果遇到了,那就应该确认一下池的合适的最小值了。

线程池任务大小

等待线程池来执行的任务会被保存到某类队列或列表中;当池中有线程可以执行任务时,就从队列中拉出一个。这会导致不均衡:队列中任务的数量有可能变得非常大。如果队列太大,其中的任务就必须等待很长时间,直到前面的任务执行完毕。例如一个超负荷的 Web 服务器:如果有个任务被添加到队列中,但是没有在 3 秒钟内执行,那用户很可能就去看另一个页面了。

因此,对于容纳等待执行任务的队列,线程池通常会限制其大小。根据用于容纳等待执行任务的数据结构的不同,ThreadPoolExecutor 会有不同的处理方式(下一节会更详细地介绍);应用服务器通常有一些调优参数,可以调整这个值。

就像线程池的最大线程数,这个值应该如何调优,并没有一个通用的规则。举例而言,假设某个应用服务器的任务队列中有 30 000 个任务,有 4 个 CPU 可用,如果执行一个任务只需要 50 毫秒,同时假设这段时间不会到达新任务,则清空任务队列需要 6 分钟。这可能是可以接受的,但如果每个任务需要 1 秒钟,则清空任务队列需要 2 小时。因此,若要确定使用哪个值能带来我们需要的性能,测量我们的真实应用是唯一的途径。

不管是哪种情况,如果达到了队列数限制,再添加任务就会失败。ThreadPoolExecutor 有一个 rejectedExecution 方法,用于处理这种情况(默认会抛出 RejectedExecutionException)。应用服务器会向用户返回某个错误:或者是 HTTP 状态码 500(内部错误),或者是 Web 服务器捕获错误,并向用户给出合理的解释消息——其中后者是最理想的。

设置ThreadPoolExecutor的大小

线程池的一般行为是这样的:创建时准备好最小数目的线程,如果来了一个任务,而此时所有的线程都在忙碌,则启动一个新线程(一直到达到最大线程数),任务就可以立即执行了。否则,任务被加入等待队列,如果任务队列中已经无法加入新任务,则拒绝之。不过,ThreadPoolExecutor 的表现可能和这种标准行为有点不同。

根据所选任务队列的类型,ThreadPoolExecutor 会决定何时启动一个新线程。有以下 3 种可能。

SynchronousQueue

  如果 ThreadPoolExecutor 搭配的是 SynchronousQueue,则线程池的行为会和我们预计的一样,它会考虑线程数:如果所有的线程都在忙碌,而且池中的线程数尚未达到最大,则新任务会启动一个新线程。然而,这个队列没办法保存等待的任务:如果来了一个任务,创建的线程数已经达到最大值,而且所有线程都在忙碌,则新的任务总是会被拒绝。所以如果只是管理少量的任务,这是个不错的选择;但是对于其他情况,就不合适了。该类文档建议将最大线程数指定为一个非常大的值,如果任务完全是 CPU 密集型的,这可能行得通,但是我们会看到,其他情况下可能会适得其反。另一方面,如果需要一个容易调整线程数的线程池,这种选择会更好。

无界队列

  如果 ThreadPoolExecutor 搭配的是无界队列(比如 LinkedBlockedingQueue),则不会拒绝任何任务(因为队列大小没有限制)。这种情况下,ThreadPoolExecutor 最多仅会按最小线程数创建线程,也就是说,最大线程池大小被忽略了。如果最大线程数和最小线程数相同,则这种选择和配置了固定线程数的传统线程池运行机制最为接近。

有界队列

  在决定何时启动一个新线程时,使用了有界队列(如 ArrayBlockingQueue)的 ThreadPoolExecutor 会采用一个非常复杂的算法。比如,假设池的核心大小为 4,最大为 8,所用的 ArrayBlockingQueue 最大为 10。随着任务到达并被放到队列中,线程池中最多会运行 4 个线程(也就是核心大小)。即使队列完全填满,也就是说有 10 个处于等待状态的任务,ThreadPoolExecutor 也是只利用 4 个线程。

  如果队列已满,而又有新任务加进来,此时才会启动一个新线程。这里不会因为队列已满而拒绝该任务,相反,会启动一个新线程。新线程会运行队列中的第一个任务,为新来的任务腾出空间。

  在这个例子中,池中会有 8 个线程(最大线程数)的唯一一种情形是,有 7 个任务正在处理,队列中有 10 个任务,这时又来了一个新任务。

  这个算法背后的理念是,该池大部分时间仅使用核心线程(4 个),即使有适量的任务在队列中等待运行。这时线程池就可以用作节流阀(这是很有好处的)。如果积压的请求变得非常多,该池就会尝试运行更多线程来清理;这时第二个节流阀——最大线程数——就起作用了。

  如果系统没有外部瓶颈,CPU 周期也足够,那一切就都解决了:加入新的线程可以更快地处理任务队列,并很可能使其回到预期大小。该算法所适合的用例当然也很容易构造。

  另一方面,该算法并不知道队列为何会突然增大。如果是因为外部的任务积压,那么加入更多线程并非明智之举。如果该线程所运行的机器已经是 CPU 密集型的,加入更多线程也是错误的。只有当任务积压是由额外的负载进入系统(比如有更多客户端发起 HTTP 请求)引发时,增加线程才是有意义的。(如果是这种情况,为什么要等到队列已经接近某个边界时才增加呢?如果有额外的资源供更多线程使用,则尽早增加线程将改善系统的整体性能。)

对于上面提到的每一种选择,都能找到很多支持或反对的论据,但是在尝试获得最好的性能时,可以应用 KISS 原则“Keep it simple, stupid”。可以将 ThreadPoolExecutor 的核心线程数和最大线程数设为相同,在保存等待任务方面,如果适合使用无界任务列表,则选择 LinkedBlockingQueue;如果适合使用有界任务列表,则选择 ArrayBlockingQueue。

线程池优化小结

  1. 有时对象池也是不错的选择,线程池就是情形之一:线程初始化的成本很高,线程池使得系统上的线程数容易控制。

  2. 线程池必须仔细调优。盲目向池中添加新线程,在某些情况下对性能会有不利影响。

  3. 在使用 ThreadPoolExecutor 时,选择更简单的选项通常会带来最好的、最能预见的性能。

ForkJoinPool

ava 7 引入了一个新的线程池:ForkJoinPool 类。这个类看上去和其他任何线程池都很像;和 ThreadPoolExecutor 类一样,它也实现了 Executor 和 ExecutorService 接口。在支持这些接口方面,ForkJoinPool 在内部会使用一个无界任务列表,供构造器中所指定数目(如果所选的是无参构造器,则为该机器上的 CPU 数)的线程来运行。

ForkJoinPool 类是为配合分治算法的使用而设计的:任务可以递归地分解为子集。这些子集可以并行处理,然后每个子集的结果被归并到一个结果中。一个经典的例子就是快速排序算法。

分治算法的重点是,算法会创建大量的任务,而这些任务只有相对较少的几个线程来管理。比如要排序一个包含 1000 万个元素的数组。首先创建单独的任务来执行 3 个操作:排序包含前面 500 万个元素的子数组,再排序包含后面 500 万个元素的子数组,然后合并两个子数组。

类似地,要排序包含 500 万个元素的数组,可以分别排序包含 250 万个元素的子数组,然后合并子数组。一直递归到某个点(比如到子数组包含 10 个元素时),这时在子数组上使用插入排序直接处理更为高效。下图演示了其工作方式。

递归快速排序中的任务

最后会有超过 100 万个任务来排序叶子数组(每个数组少于 10 个元素,这时候直接排序即可;这里只是用 10 来举例,实际值会随实现的不同而有所变化。在目前的 Java 库实现中,当数组少于 47 个元素时,会采用插入排序)。需要 50 多万个任务来归并那些排好序的数组,归并下一级又需要 25 万个任务,依此类推。最后会有 2 097 151 个任务。

更大的问题是,所有任务都要等待它们派生出的任务先完成,然后才能完成。对于元素数少于 10 的子数组,直接对它们做排序的任务必须优先完成;在此之后,创建相应子数组的任务才能归并其子数组的结果,依此类推:链条上的所有任务依次归并,直到整个数组被归并为最终的、排序好的结果。

因为父任务必须等待子任务完成,所以无法使用 ThreadPoolExecutor 高效实现这个算法。ThreadPoolExecutor 内的线程无法将另一个任务添加到队列中并等待其完成:一旦线程进入等待状态,就无法使用该线程执行它的某个子任务了。另一方面,ForkJoinPool 则允许其中的线程创建新任务,之后挂起当前的任务。当任务被挂起时,线程可以执行其他等待的任务。

举个简单的例子:比如说有个 double 数组,我们想计算数组中小于 0.5 的元素的个数。顺序扫描比较简单(可能还有优势,本节后面会看到),但是为了说明问题,现在把数组划分为子数组,并行扫描(模仿更复杂的快速排序和其他分治算法)。使用 ForkJoinPool 实现这一功能的代码如下:

public class ForkJoinTest {
    private double[] d;

    private class ForkJoinTask extends RecursiveTask<Integer> {
        private int first;
        private int last;

        public ForkJoinTask(int first, int last) {
            this.first = first;
            this.last = last;
        }

        protected Integer compute() {
            int subCount;
            if (last - first < 10) {
                subCount = 0;
                for (int i = first; i <= last; i++) {
                    if (d[i] < 0.5)
                        subCount++;
                }
            }
            else {
                int mid = (first + last) >>> 1;
                ForkJoinTask left = new ForkJoinTask(first, mid);
                left.fork();
                ForkJoinTask right = new ForkJoinTask(mid + 1, last);
                right.fork();
                subCount = left.join();
                subCount += right.join();
            }
            return subCount;
        }
    }

    public static void main(String[] args) {
        d = createArrayOfRandomDoubles();
        int n = new ForkJoinPool().invoke(new ForkJoinTask(0, 9999999));
        System.out.println("Found " + n + " values");
    }
}

fork() 和 join() 方法是这里的关键:没有这些方法,实现这类递归会非常痛苦(在由 ThreadPoolExecutor 执行的任务中就没有这些方法)。这些方法使用了一系列内部的、从属于每个线程的队列来操纵任务,并将线程从执行一个任务切换到执行另一个。细节对开发者是透明的,不过如果对算法感兴趣,其代码读起来也很有意思。这里我们重点关注的是性能:ForkJoinPool 和 ThreadPoolExecutor 这两个类之间有什么权衡取舍呢?

首先,fork/join 范型所实现的挂起,使得所有任务可以交由少量的线程执行。使用该示例代码计算包含 1000 万个元素的数组中的 double 值,会创建 200 多万个任务,但这些任务很容易交由少量一些线程执行(甚至是一个线程,如果这对运行测试的机器有意义的话)。使用 ThreadPoolExecutor 运行类似算法则需要 200 多万个线程,因为每个线程必须等待其子任务完成,而且那些子任务只有在池中有可用线程时才能完成。有了 fork/join,我们可以实现用 ThreadPoolExecutor 无法实现的算法,这就是一个性能优势。

尽管分治技术非常强大,但是滥用也可能会导致性能变糟糕。在计数的这个例子中,可以使用一个线程来扫描数组并计数,虽然未必能像并行运行 fork/join 算法那样快。然而,把原数组划分为多个断,使用 ThreadPoolExecutor 让多个线程扫描数组,也是非常容易的:


public class ThreadPoolTest {
    private double[] d;

    private class ThreadPoolExecutorTask implements Callable<Integer> {
        private int first;
        private int last;

        public ThreadPoolExecutorTask(int first, int last) {
            this.first = first;
            this.last = last;
        }

        public Integer call() {
            int subCount = 0;
            for (int i = first; i <= last; i++) {
                if (d[i] < 0.5) {
                    subCount++;
                }
            }
            return subCount;
        }
    }

    public static void main(String[] args) {
        d = createArrayOfRandomDoubles();
        ThreadPoolExecutor tpe = new ThreadPoolExecutor(4, 4,
                                        Long.MAX_VALUE,
                                        TimeUnit.SECONDS,
                                        new LinkedBlockingQueue());
        Future[] f = new Future[4];
        int size = d.length / 4;
        for (int i = 0; i < 3; i++) {
            f[i] = tpe.submit(
                       new ThreadPoolExecutorTask(i * size, (i + 1) * size - 1);
        }
        f[3] = tpe.submit(new ThreadPoolExecutorTask(3 * size, d.length - 1);
        int n = 0;
        for (int i = 0; i < 4; i++) {
            n += f.get();
        }
        System.out.println("Found " + n + " values");
    }
}

在一个配备了 4 个 CPU 的机器上,这段代码可以充分利用所有可用的 CPU,并行处理数组,同时避免像 fork/join 示例中那样创建和排队处理 200 万个任务。可以预见性能会快些,如下表所示。

线程数ForkJoinPool(秒)ThreadPoolExecutor(秒)
13.20.31
41.90.15

测试所用的机器有 4 个 CPU,4 GB 固定内存。测试中,ThreadPoolExecutor 完全不需要 GC,而每个 ForkJoinPool 测试会花 1.2 秒在 GC 上。对于性能差异而言,这一点所占比重很大,但这并非故事的全部:创建和管理任务对象的开销也会伤害 ForkJoinPool 的性能。如果有类似的替代方案,很可能会更快,至少在这个简单的例子中是这样。

ForkJoinPool 还有一个额外的特性,它实现了工作窃取(work-stealing)。这基本上就是一个实现细节了;这意味着池中的每个线程都有自己所创建任务的队列。线程会优先处理自己队列中的任务,但如果这个队列已空,它会从其他线程的队列中窃取任务。其结果是,即使 200 万个任务中有一个需要很长的执行时间,ForkJoinPool 中的其他线程也可以完成其余的随便什么任务。ThreadPoolExecutor 则不会这样:如果一个任务需要很长的时间,其他线程并不能处理额外的工作。

示例代码先是计算数组中小于 0.5 的元素数。此外,如果代码中还计算了一个新的值,并保存到数组中了,会发生什么?一个没有实际意义但却是 CPU 密集型的实现可以执行以下代码:

for (int i = first; i <= last; i++) {
    if (d[i] < 0.5) {
        subCount++;
    }
    for (int j = 0; j < d.length - i; j++) {
        for (int k = 0; k < 100; k++) {
            dummy = j * k + i; // dummy is volatile, so multiple writes occur
            d[i] = dummy;
        }
    }
}

因为用 j 索引的外部循环是基于元素在数组中的位置处理的,所以计算所需要的时间和元素位置成比例关系:计算 d[0] 的值需要很长的时间,而计算 d[d.length - 1] 则只需要很短的时间。

简单地将数组分为 4 段,用 ThreadPoolExecutor 处理,这个测试有一个不好的地方。计算数组第 1 段的线程需要很长的时间才能完成,比处理数组最后一段的第 4 个线程所需的时间长得多。一旦第 4 个线程结束,它就会处于空闲状态:所有线程都要等第 1 个线程完成它的耗时较长的任务。

在粒度为 200 万个任务的 ForkJoinPool 中,尽管有一个线程会忙于针对数组中的前 10 个元素的非常耗时的计算,但是其余线程都有工作可做,在大部分测试过程中,CPU 会保持忙碌。区别如表所示。

线程数ForkJoinPool(秒)ThreadPoolExecutor(秒)
154.553.3
416.624.2

当池中只有一个线程时,计算所花的时间基本一样。这可以理解:不管池如何实现,计算量是一样的;而且因为那些计算绝对不会并行进行,所以可以预计它们所需的时间是一样的(尽管创建 200 万个任务会有少量开销)。但是当池中包含 4 个线程时,ForkJoinPool 中任务的粒度会带来一个决定性的优势:几乎在测试的整个过程中,都能保持 CPU 的忙碌状态。

这种情况就叫作“不均衡”,因为某些任务所花的时间比其他任务长(因此前面例子中的任务可以说是“均衡的”)。一般而言,如果任务是均衡的,使用分段的 ThreadPoolExecutor 性能更好;而如果任务是不均衡的,则使用 ForkJoinPool 性能更好。

还有一个更微妙的性能方面的建议:请仔细考虑 fork/join 范型应该在哪个点结束递归。在这个例子中,我信手选择了当数组大小小于 10 时结束。如果在数组大小为 250 万时停止递归,那么 fork/join 测试(在搭载 4 个 CPU 的机器上,处理 1000 万个元素的平衡代码)会只创建 4 个任务,其性能基本和 ThreadPoolExecutor 一样。

另一方面,对于这个例子,在非平衡的测试中,继续递归会有更好的性能,即使创建更多任务。下表给出了一些有代表性的数据点。

叶子数组大小ForkJoinPool(秒)
2017.8
1016.6
515.6
116.8

ForkJoinPool

Java 8 向 Java 中引入了自动并行化特定种类代码的能力。这种并行化就依赖于 ForkJoinPool 类的使用。Java 8 为这个类加入了一个新特性:一个公共的池,可供任何没有显式指定给某个特定池的 ForkJoinTask 使用。这个公共池是 ForkJoinPool 类的一个 static 元素,其大小默认设置为目标机器上的处理器数。

这种并行化在 Arrays 类的很多新方法中都会发生,包括使用并行快速排序处理数组的方法,操作数组的每个元素的方法,等等。在 Java 8 的 Stream 特性中也有应用,支持在集合中的每个元素上(或顺序或并行地)执行操作。Stream 的一些基本的性能特性会在比较靠后的章节讨论;在本节中,我们看一下 Stream 是如何自动地并行处理的。

给定一个包含一系列整型数的集合,下列代码会计算与给定整型数匹配的股票代号的价格历史:

Stream<Integer> stream = arrayList.parallelStream();
stream.forEach(a -> {
    String symbol = StockPriceUtils.makeSymbol(a);
    StockPriceHistory sph = new StockPriceHistoryImpl(symbol, startDate,
                                     endDate, entityManager);
});

这段代码会并行计算模拟价格历史:forEach() 方法将为数组列表中的每个元素创建一个任务,每个任务都会由公共的 ForkJoinTask 池处理。它在功能上与本章开始所做的测试是等价的,那个测试是用一个线程池来并行计算价格历史(不过与显式使用线程池相比,这段代码写起来更容易)。

设置 ForkJoinTask 池的大小和设置其他任何线程池同样重要。默认情况下,公共池的线程数等于机器上的 CPU 数。如果在同一机器上运行着多个 JVM,则应限制这个线程数,以防这些 JVM 彼此争用 CPU。类似地,如果 Servlet 代码会执行某个并行任务,而我们想确保 CPU 可供其他任务使用,可以考虑减小公共池的线程数。另外,如果公共池中的任务会阻塞等待 I/O 或其他数据,也可以考虑增大线程数。

这个值可以通过设置系统属性 -Djava.util.concurrent.ForkJoinPool.common.parallelism=N 来指定。

在本章前面的第一个表中,曾经对比过线程数对并行计算股票历史价格的影响。下表使用共同的 ForkJoinPool(将 parallelism 系统属性设置为给定的值)将那个数据与 forEach() 构造作了比较。

线程数ThreadPoolExecutor(秒)ForkJoinPool(秒)
1255.6135.4
2134.8110.2
477.096.5
881.784.0
1585.684.6

默认情况下,公共池有 4 个线程(在这个配置了 4 个 CPU 的机器上),所以表中的第 3 行为一般情况。在线程数为 1 和 2 时,这类结果会让性能工程师很不开心:它们看上去很不协调,而当某一项测试出现这样的情况时,最常见的原因是测试错误。这里的原因是 forEach() 方法有些奇怪的行为:它使用了一个线程执行语句,还使用了公共池中的线程处理来自 Stream 的数据。即使在第 1 个测试中,公共池也是配置为使用一个线程,总的还是会使用两个线程来计算结果。(因此,使用了 2 个线程的 ThreadPoolExecutor 和使用了 1 个线程的 ForkJoinPool 的耗时基本相同。)

在使用并行 Stream 构造或其他自动并行化特性时,如果需要调整公共池的大小,可以考虑将所需的值减 1。

ForkJoinPool小结

  1. ForkJoinPool 类应该用于递归、分治算法。

  2. 应该花些心思来确定,算法中的递归任务何时结束最为合适。创建太多任务会降低性能,但如果任务太少,而任务所需的执行时间又长短不一,也会降低性能。

更新时间:2020-03-15 11:32:01

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/java性能调优13线程与同步的性能上
最后更新:2020-03-15 11:32:01

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×