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)