为何服务器QPS上不去?Java线程调优权威指南
从刚问世起,Java 的部分魅力就来自其多线程。即便在多核和多 CPU 系统司空见惯之前,能够轻松编写多线程程序也是 Java 的一个标志性特征。
Java 性能方面的吸引力显而易见:如果有两个 CPU 可用,那么一个应用能够完成的工作量可能是原来的 2 倍。当然这是在假设任务可以分解成离散的片段的前提之下的,因为 Java 不能自动找出算法性部分并实现并行化的语言。幸运的是,今日所见之计算,往往是离散性的任务。
下面探讨的主题是,如何挖掘出 Java 线程和同步设施的最大性能。
线程池与 ThreadPoolExecutor
(Thread Pool 示意图,来源 wikipedia)
在 Java 中,线程可以使用自己代码来管理,也可以利用线程池,使用 ThreadPoolExecutor 并行执行任务。
在使用线程池时,有一个因素非常关键:调节线程池的大小对获得最好的性能至关重要。线程池的性能会随线程池大小这一基本选择而有所不同,在某些条件下,线程池过大对性能也有很大的不利影响。
所有线程池的工作方式本质是一样的:
有一个队列,任务被提交到这个队列中。一定数量的线程会从该队列中取任务,然后执行。
任务的结果可以发回客户端(比如应用服务器的情况下),或保存到数据库中,或保存到某个内部数据结构中,等等。但是在执行完任务后,这个线程会返回任务队列,检索另一个任务并执行,如果没有更多任务要执行,该线程会等待下一个任务。
线程池有最小线程数和最大线程数。池中会有最小数目的线程随时待命,等待任务指派给它们。因为创建线程的成本非常高昂,这样可以提高任务提交时的整体性能:已有的线程会拿到该任务并处理。另一方面,线程需要一些系统资源,包括栈所需的原生内存,如果空闲线程太多,就会消耗本来可以分配给其他进程的资源。最大线程数还是一个必要的限流阀,防止一次执行太多线程。
ThreadPoolExecutor 和相关的类将最小线程数称作核心池大小,如果有个任务要执行,而所有的并发线程都在忙于执行另一个任务,就启动一个新线程,直到创建的线程达到最大线程数。
设置最大线程数
对于给定硬件上的给定负载,最大线程数设置为多少最好呢?
这个问题回答起来并不简单;它取决于负载特性以及底层硬件。特别是,最优线程数还与每个任务阻塞的频率有关。
为方便讨论,假设 JVM 有 4 个 CPU 可用。我们的目标就是最大化这 4 个 CPU 的利用率。
很明显,最大线程数至少要设置为 4。的确,除了处理这些任务,JVM 中还有些线程要做其他的事,但是它们几乎从来不会占用一个完整的 CPU。如果使用的是并发垃圾收集器,这是个例外,后台线程必须有足够的 CPU 来运行,以免在处理堆这方面落后。
如果线程数多于 4,会有帮助吗?这时就要看负载特性了。考虑最简单的情况,假定任务都是计算密集型的:没有外部网络调用(比如不会访问数据库),也不会激烈地竞争内部锁。在使用模实体管理器(mock entity manager)的情况下,股价历史批处理程序就是一个这样的应用:实体上的数据完全可以并行计算。
下面就使用线程池计算一下 10,000 个模股票实体的历史,假设机器有 4 个 CPU,使用不同的线程数测试,具体的性能数据见表1。如果池中只有 1 个线程,计算数据集需要 255.6 秒;用 4 个线程,则只需要 77 秒。如果线程数超过 4 个,随着线程数的增加,需要的时间会稍多一些。
表1:计算 10,000 个模的价格历史所需时间
线程数 | 所需秒数 | 与基准的百分比 |
---|---|---|
1 | 255.6 | 100% |
2 | 134.8 | 52.7% |
4 | 77.0 | 30.1% |
8 | 81.7 | 31.9% |
15 | 85.6 | 33.5% |
如果应用中的任务是完全并行的,则在有 2 个线程时,“与基准的百分比”这列为 50%;在有 4 个线程时,这列为 25%。但是这种完全线性的比例不可能出现,原因有这么几点:如果没有其他线程帮助,这些线程必须自己来协同,实现从运行队列中选取任务(一般而言,通常会有更多同步)。到了使用 4 个线程的时候,系统会 100% 消耗可用的 CPU,尽管机器可能没有运行其他用户级的应用,但是会有各种系统级的进程进来,并使用 CPU,从而使得 JVM 无法 100% 地使用所有 CPU 周期。
尽管如此,这个应用在伸缩性方面表现还不错,且即使池中的线程数被显著高估,性能损失也比较轻微。
不过在其他情况下,性能损失可能会很大。在 Servlet 版的股票历史计算程序中,线程太多的话,影响会很大,如表2 所示。应用服务器分别配置成不同的线程数,有一个负载生成器会向该服务器发送 20 个同步的(simultaneous)请求。
表2:每秒通过 Servlet 的操作
线程数 | 每秒操作数 | 与基准的百分比 |
---|---|---|
4 | 77.43 | 100% |
8 | 75.93 | 98.8% |
16 | 71.65 | 92.5% |
32 | 69.34 | 89.5% |
64 | 60.44 | 78.1% |
鉴于应用服务器有 4 个 CPU 可用,最大吞吐量可以通过将池中的线程数设置为 4 来实现。
在研究性能问题时确定瓶颈在哪儿比较重要。在这个例子中,瓶颈很明显是 CPU:4 个线程时,CPU 利用率为 100%。不过加入更多线程的影响其实很小,至少当线程数是原来的 8 倍时才会有明显的差别。
如果瓶颈在其他地方呢?这个例子有点不同寻常,任务完全是 CPU 密集型的:没有 I/O。一般来说,线程有可能会调用数据库,或者把输出写到某个地方,甚至是会合其他某些资源。在那种情况下,瓶颈未必是 CPU,而可能是外部资源。
对于此类情况,添加线程非常有害。虽然我们经常说数据库总是瓶颈,但是瓶颈可能是任何外部资源。
仍以股票 Servlet 为例,我们把目标变一下:如果目标是最大限度地利用负载生成器机器,又会如何,是简单地运行一个多线程的 Java 程序吗?
在典型的用法中,如果 Servlet 应用运行在一个有 4 个 CPU 的应用服务器上,而且只有一个客户端请求数据,那么,应用服务器大约会 25% 忙碌,客户端机器几乎总是空闲的。如果负载增加到 4 个并发的客户端,则应用服务器会 100% 忙碌,客户端机器可能只有 20% 的忙碌。
只看客户端,很容易得出这样的结论:因为客户端 CPU 大量过剩,应该可以添加更多线程,改善其伸缩性。表3 说明了这种假设何其错误:当客户端再加入一些线程时,性能会受到极大影响。
表3:计算模拟股票价格历史的平均响应时间
客户端线程数 | 平均响应时间(秒) | 与基准的百分比 |
---|---|---|
1 | 0.05 | 100% |
2 | 0.05 | 100% |
4 | 0.05 | 100% |
6 | 0.076 | 152% |
8 | 0.104 | 208% |
16 | 0.212 | 424% |
32 | 0.437 | 874% |
64 | 0.909 | 1818% |
在这个例子中,一旦应用服务器成为瓶颈(也就是说,线程数达到 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 种可能。
1. SynchronousQueue
如果 ThreadPoolExecutor 搭配的是 SynchronousQueue,则线程池的行为会和我们预计的一样,它会考虑线程数:如果所有的线程都在忙碌,而且池中的线程数尚未达到最大,则新任务会启动一个新线程。然而,这个队列没办法保存等待的任务:如果来了一个任务,创建的线程数已经达到最大值,而且所有线程都在忙碌,则新的任务总是会被拒绝。所以如果只是管理少量的任务,这是个不错的选择;但是对于其他情况,就不合适了。该类文档建议将最大线程数指定为一个非常大的值,如果任务完全是 CPU 密集型的,这可能行得通,但是我们会看到,其他情况下可能会适得其反。另一方面,如果需要一个容易调整线程数的线程池,这种选择会更好。
2. 无界队列
如果 ThreadPoolExecutor 搭配的是无界队列(比如 LinkedBlockedingQueue),则不会拒绝任何任务(因为队列大小没有限制)。这种情况下,ThreadPoolExecutor 最多仅会按最小线程数创建线程,也就是说,最大线程池大小被忽略了。如果最大线程数和最小线程数相同,则这种选择和配置了固定线程数的传统线程池运行机制最为接近。
3. 有界队列
在决定何时启动一个新线程时,使用了有界队列(如 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。
快速小结
有时对象池也是不错的选择,线程池就是情形之一:线程初始化的成本很高,线程池使得系统上的线程数容易控制。
线程池必须仔细调优。盲目向池中添加新线程,在某些情况下对性能会有不利影响。
在使用 ThreadPoolExecutor 时,选择更简单的选项通常会带来最好的、最能预见的性能。
ForkJoinPool
Java 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 实现这一功能的代码如下:
(点击图片可放大)
fork() 和 join() 方法是这里的关键:没有这些方法,实现这类递归会非常痛苦(在由ThreadPoolExecutor 执行的任务中就没有这些方法)。这些方法使用了一系列内部的、从属于每个线程的队列来操纵任务,并将线程从执行一个任务切换到执行另一个。细节对开发者是透明的,不过如果对算法感兴趣,其代码读起来也很有意思。这里我们重点关注的是性能:ForkJoinPool和 ThreadPoolExecutor 这两个类之间有什么权衡取舍呢?
首先,fork/join 范型所实现的挂起,使得所有任务可以交由少量的线程执行。使用该示例代码计算包含 1000 万个元素的数组中的 double 值,会创建 200 多万个任务,但这些任务很容易交由少量一些线程执行(甚至是一个线程,如果这对运行测试的机器有意义的话)。使用ThreadPoolExecutor 运行类似算法则需要 200 多万个线程,因为每个线程必须等待其子任务完成,而且那些子任务只有在池中有可用线程时才能完成。有了 fork/join,我们可以实现用ThreadPoolExecutor 无法实现的算法,这就是一个性能优势。
尽管分治技术非常强大,但是滥用也可能会导致性能变糟糕。在计数的这个例子中,可以使用一个线程来扫描数组并计数,虽然未必能像并行运行 fork/join 算法那样快。然而,把原数组划分为多个断,使用 ThreadPoolExecutor 让多个线程扫描数组,也是非常容易的:
(点击图片可放大)
在一个配备了 4 个 CPU 的机器上,这段代码可以充分利用所有可用的 CPU,并行处理数组,同时避免像 fork/join 示例中那样创建和排队处理 200 万个任务。可以预见性能会快些,如表4 所示。
表4:对1亿个元素做计数处理
线程数 |
|
|
---|---|---|
1 | 3.2 | 0.31 |
4 | 1.9 | 0.15 |
测试所用的机器有 4 个 CPU,4 GB 固定内存。测试中,ThreadPoolExecutor 完全不需要 GC,而每个 ForkJoinPool 测试会花 1.2 秒在 GC 上。对于性能差异而言,这一点所占比重很大,但这并非故事的全部:创建和管理任务对象的开销也会伤害 ForkJoinPool 的性能。如果有类似的替代方案,很可能会更快,至少在这个简单的例子中是这样。
ForkJoinPool 还有一个额外的特性,它实现了工作窃取(work-stealing)。这基本上就是一个实现细节了;这意味着池中的每个线程都有自己所创建任务的队列。线程会优先处理自己队列中的任务,但如果这个队列已空,它会从其他线程的队列中窃取任务。其结果是,即使 200 万个任务中有一个需要很长的执行时间,ForkJoinPool 中的其他线程也可以完成其余的随便什么任务。ThreadPoolExecutor 则不会这样:如果一个任务需要很长的时间,其他线程并不能处理额外的工作。
示例代码先是计算数组中小于 0.5 的元素数。此外,如果代码中还计算了一个新的值,并保存到数组中了,会发生什么?一个没有实际意义但却是 CPU 密集型的实现可以执行以下代码:
因为用 j 索引的外部循环是基于元素在数组中的位置处理的,所以计算所需要的时间和元素位置成比例关系:计算 d[0] 的值需要很长的时间,而计算 d[d.length - 1] 则只需要很短的时间。
简单地将数组分为 4 段,用 ThreadPoolExecutor 处理,这个测试有一个不好的地方。计算数组第 1 段的线程需要很长的时间才能完成,比处理数组最后一段的第 4 个线程所需的时间长得多。一旦第 4 个线程结束,它就会处于空闲状态:所有线程都要等第 1 个线程完成它的耗时较长的任务。
在粒度为200万个任务的 ForkJoinPool 中,尽管有一个线程会忙于针对数组中的前 10 个元素的非常耗时的计算,但是其余线程都有工作可做,在大部分测试过程中,CPU 会保持忙碌。区别如表5 所示。
表5:处理包含10 000个元素的数组的时间
线程数 |
|
|
---|---|---|
1 | 54.5 | 53.3 |
4 | 16.6 | 24.2 |
当池中只有一个线程时,计算所花的时间基本一样。这可以理解:不管池如何实现,计算量是一样的;而且因为那些计算绝对不会并行进行,所以可以预计它们所需的时间是一样的(尽管创建 200 万个任务会有少量开销)。但是当池中包含 4 个线程时,ForkJoinPool 中任务的粒度会带来一个决定性的优势:几乎在测试的整个过程中,都能保持 CPU 的忙碌状态。
这种情况就叫作“不均衡”,因为某些任务所花的时间比其他任务长(因此前面例子中的任务可以说是“均衡的”)。一般而言,如果任务是均衡的,使用分段的 ThreadPoolExecutor 性能更好;而如果任务是不均衡的,则使用 ForkJoinPool 性能更好。
还有一个更微妙的性能方面的建议:请仔细考虑 fork/join 范型应该在哪个点结束递归。在这个例子中,我信手选择了当数组大小小于 10 时结束。如果在数组大小为 250 万时停止递归,那么 fork/join 测试(在搭载 4 个 CPU 的机器上,处理 1000 万个元素的平衡代码)会只创建 4 个任务,其性能基本和 ThreadPoolExecutor 一样。
另一方面,对于这个例子,在非平衡的测试中,继续递归会有更好的性能,即使创建更多任务。表6 给出了一些有代表性的数据点。
表6:处理包含10 000个元素的数组的时间
叶子数组大小 |
|
---|---|
20 | 17.8 |
10 | 16.6 |
5 | 15.6 |
1 | 16.8 |
自动并行化
Java 8 向 Java 中引入了自动并行化特定种类代码的能力。这种并行化就依赖于 ForkJoinPool 类的使用。Java 8 为这个类加入了一个新特性:
一个公共的池,可供任何没有显式指定给某个特定池的 ForkJoinTask 使用。
这个公共池是 ForkJoinPool 类的一个 static 元素,其大小默认设置为目标机器上的处理器数。
这种并行化在 Arrays 类的很多新方法中都会发生,包括使用并行快速排序处理数组的方法,操作数组的每个元素的方法,等等。在 Java 8 的 Stream 特性中也有应用,支持在集合中的每个元素上(或顺序或并行地)执行操作。这里不讨论Stream 的一些基本的性能特性,而是看一下 Stream 是如何自动地并行处理的。
给定一个包含一系列整型数的集合,下列代码会计算与给定整型数匹配的股票代号的价格历史:
(点击图片可放大)
这段代码会并行计算模拟价格历史:forEach() 方法将为数组列表中的每个元素创建一个任务,每个任务都会由公共的 ForkJoinTask 池处理。它在功能上与开始所做的测试是等价的,那个测试是用一个线程池来并行计算价格历史(不过与显式使用线程池相比,这段代码写起来更容易)。
设置 ForkJoinTask 池的大小和设置其他任何线程池同样重要。默认情况下,公共池的线程数等于机器上的 CPU 数。如果在同一机器上运行着多个 JVM,则应限制这个线程数,以防这些 JVM 彼此争用 CPU。类似地,如果 Servlet 代码会执行某个并行任务,而我们想确保 CPU 可供其他任务使用,可以考虑减小公共池的线程数。另外,如果公共池中的任务会阻塞等待 I/O 或其他数据,也可以考虑增大线程数。
这个值可以通过设置系统属性 -Djava.util.concurrent.ForkJoinPool.common.parallelism=N 来指定。
前面的表1 中,曾经对比过线程数对并行计算股票历史价格的影响。表7 使用共同的ForkJoinPool(将 parallelism 系统属性设置为给定的值)将那个数据与 forEach() 构造作了比较。
表7:计算10 000支模拟股票价格历史所需的时间
线程数 |
|
|
---|---|---|
1 | 255.6 | 135.4 |
2 | 134.8 | 110.2 |
4 | 77.0 | 96.5 |
8 | 81.7 | 84.0 |
15 | 85.6 | 84.6 |
默认情况下,公共池有 4 个线程(在这个配置了 4 个 CPU 的机器上),所以表中的第 3 行为一般情况。在线程数为 1 和 2 时,这类结果会让性能工程师很不开心:它们看上去很不协调,而当某一项测试出现这样的情况时,最常见的原因是测试错误。这里的原因是 forEach() 方法有些奇怪的行为:它使用了一个线程执行语句,还使用了公共池中的线程处理来自 Stream 的数据。即使在第 1 个测试中,公共池也是配置为使用一个线程,总的还是会使用两个线程来计算结果。(因此,使用了 2 个线程的 ThreadPoolExecutor 和使用了 1 个线程的 ForkJoinPool 的耗时基本相同。)
在使用并行 Stream 构造或其他自动并行化特性时,如果需要调整公共池的大小,可以考虑将所需的值减 1。
快速小结
ForkJoinPool 类应该用于递归、分治算法。
应该花些心思来确定,算法中的递归任务何时结束最为合适。创建太多任务会降低性能,但如果任务太少,而任务所需的执行时间又长短不一,也会降低性能。
Java 8 中使用了自动并行化的特性会用到一个公共的 ForkJoinPool 实例。我们可能需要根据实际情况调整这个实例的默认大小。
理解线程如何运作,可以获得很大的性能优势。不过就线程的性能而言,其实没有太多可以调优的:可以修改的 JVM 标志相当少,而且那些标志的效果也很有限。
相反,较好的线程性能是这么来的:遵循管理线程数、限制同步带来的影响的一系列最佳实践原则。借助适当的剖析工具和锁分析工具,可以检查并修改应用,以避免线程和锁的问题给性能带来负面影响。
调节线程栈大小
当空间非常珍贵时,可以调节线程所用的内存。每个线程都有一个原生栈,操作系统用它来保存该线程的调用栈信息(比如,main() 方法调用了 calculate() 方法,而 calculate() 方法又调用了 add() 方法,栈会把这些信息记录下来)。
不同的 JVM 版本,其线程栈的默认大小也有所差别,具体如下所示。一表般而言,如果在 32 位 JVM 上有 128 KB 的栈,在 64 位 JVM 上有 256 KB 的栈,很多应用实际就可以运行了。如果这个值设置得太小,潜在的缺点是,当某个线程的调用栈非常大时,会抛出 StackOverflowError。
几种 JVM 的默认栈大小
操作系统 | 32位 | 64位 |
---|---|---|
Linux | 320 KB | 1 MB |
Mac OS | N/A | 1 MB |
Solaris Sparc | 512 KB | 1 MB |
Solaris X86 | 320 KB | 1 MB |
Windows | 320 KB | 1 MB |
在 64 位的 JVM 中,除非物理内存非常有限,并且较小的栈可以防止耗尽原生内存,否则没有理由设置这个值。另一方面,在 32 位的 JVM 上,使用较小的栈(比如 128 KB)往往是个不错的选择,因为这样可以在进程空间中释放部分内存,使得 JVM 的堆可以大一些。
耗尽原生内存
没有足够的原生内存来创建线程,也可能会抛出 OutOfMemoryError。这意味着可能出现了以下 3 种情况之一。
在 32 位的 JVM 上,进程所占空间达到了 4 GB 的最大值(或者小于 4 GB,取决于操作系统)。
系统实际已经耗尽了虚拟内存。
在 Unix 风格的系统上,用户创建的进程数已经达到配额限制。这方面单独的线程会被看作一个进程。
减少栈的大小可以克服前两个问题,但是对第三个问题没什么效果。遗憾的是,我们无法从 JVM 报错看出到底是哪种情况,只能在遇到错误时依次排查。
要改变线程的栈大小,可以使用 -Xss=N 标志(例如 -Xss=256k)。
小结
在内存比较稀缺的机器上,可以减少线程栈大小。
在 32 位的 JVM 上,可以减少线程栈大小,以便在 4 GB 进程空间限制的条件下,稍稍增加堆可以使用的内存。
监控线程与锁
在对应用中的线程和同步的效率作性能分析时,有两点需要注意:总的线程数(既不能太大,也不能太小)和线程花在等待锁或其他资源上的时间。
1. 查看线程
几乎所有的 JVM 监控工具都提供了线程数(以及这些线程在干什么)相关的信息。像 jconsole这样的交互式工具还能显示 JVM 内线程的状态。在 jconsole 的 Threads 面板上,可以实时观察程序执行期间线程数的增减。下图是一个例子。
在某个时间点,应用(NetBeans)最多使用了 45 个线程。图中刚开始有一个爆发点,最多会使用 38 个线程,后来线程数稳定在 30 到 31 之间。jconsole 可以打印每个单独线程的栈信息;如图所示,Java2D Disposer 线程正在某个引用队列的锁上等待。
JConsole 中的活跃线程视图
2. 查看阻塞线程
如果想了解应用中有什么线程在运行这类高层视图,实时线程监控会很有用,但至于那些线程在做什么,实际上没有提供任何数据。要确定线程的 CPU 周期都耗在哪儿了,则需要使用分析器(profiler)。利用分析器可以很好地观察哪些线程在执行。而且分析器一般非常成熟,可以指出那些能够通过更好的算法、更好的代码选择来加速整体执行效果的代码区域。
诊断阻塞的线程更为困难,尽管这类信息对应用的整体执行而言往往更为重要,特别是当代码运行在多 CPU 系统上,但没有利用起所有可用的 CPU 时。一般有三种执行此类诊断的方法。方法之一还是使用分析器,因为大部分分析工具都会提供线程执行的时间线信息,这就可以看到线程被阻塞的时间点。
被阻塞线程与JFR
要了解线程是何时被阻塞的,迄今为止最好的方式是使用可以窥探 JVM 内部、并且可以在较低的层次确定线程被阻塞时间的工具。Java 飞行记录器(Java Flight Recorder,JFR)就是一款这样的工具。我们可以深入到 JFR 捕获的事件中,并寻找那些引发线程阻塞的事件(比如等待获取某个 Monitor,或是等待读写 Socket,不过写的情况较为少见)。
借助 JMC 的直方图面板可以很方便地查看这些事件,如下图所示。
JFR 中被某个 Monitor 阻塞的线程
在这个示例中,与 sun.awt.AppContext.get() 方法中的 HashMap 关联的锁被竞争了 163 次(超过 66 秒),使得所测量的请求响应时间平均增加了 31 毫秒。栈轨迹表明竞争源于 JSP 写java.util.Date 对象的方式。要改进这段代码的可伸缩性,可以使用线程局部的日期格式化对象,而不是简单地调用日期对象的 toString() 方法。
从直方图中选择阻塞事件,然后检查调用代码,这个流程适合任何阻塞事件;这款与 JVM 紧密集成的工具使这一流程就成为可能。
被阻塞线程与
JStack
如果没有商用的 JVM 可用,替代方案之一是从程序中拿到大量的线程栈并加以检查。jstack、jcmd 和其他工具可以提供虚拟机中每个线程状态相关的信息,包括线程是在运行、等待锁还是等待 I/O 等。对于确定应用中正在进行的是什么,这可能非常有用,不过输出中也有很多我们不需要的。
在查看线程栈时,有两点需要注意。第一,JVM 只能在特定的位置(safepoint,安全点)转储出一个线程的栈。第二,每次只能针对一个线程转储出栈信息,所以可能会看到彼此冲突的信息:比如两个线程持有同一个锁,或者一个线程正在等待的锁并未被其他线程持有。
JStack 分析器
人们很容易认为,连续快速地抓取多个栈转储信息,就能将其用作一个简单快速的分析器。毕竟,采样分析器本质上就是这么工作的:周期性地探测线程的执行栈,基于这些信息推断在方法上花了多少时间。但是在安全点和不一致的快照之间,这么做不是很有效;通过查看这些线程栈,有时可以从较高的层次上大概获知执行成本较高的方法,但是一款真正的分析器提供的信息要精确得多。
从线程栈可以看出线程阻塞的严重程度(因为阻塞的线程已经在某个安全点上)。如果有连续的线程转储信息表明大量的线程阻塞在某个锁上,那么就可以断定这个锁上有严重的竞争。如果有连续的线程转储信息表明大量的线程在阻塞等待 I/O,则可以断定需要优化正在进行的 I/O 读操作(比如,如果是数据库调用,应该优化 SQL 执行,或者是优化数据库本身)。
Jstack 的输出有个问题,即不同版本之间可能会有变化,所以开发一个健壮的解析器比较困难。不能保证这个解析器可以不加修改地应用于你所使用的特定的 JVM。
jstack 解析器的基本输出像下面这样:
(点击图片可放大)
解析器聚合了所有的线程,可以显示处于各种状态的线程分别有多少。8 个线程正在运行(它们碰巧正在获取栈轨迹信息,这个操作成本非常高,最好避免)。
41 个线程被某个锁阻塞了。所报告的方法是栈轨迹中第一个非 JDK 方法,在这个例子中是 GlassFish 的 EJBClassLoader.getResourceAsStream()。下一步就是考虑栈轨迹信息,搜索这个方法,看看线程是阻塞到什么资源上了。
在这个例子中,所有线程都被阻塞了,在等待读取同一个 JAR 文件;这些线程的栈轨迹表明,所有调用都来自实例化新 SAX 实例的操作。SAX 解析器可以通过列出应用 JAR 文件中 manifest 文件内的资源来动态定义,这意味着 JDK 必须搜索整个类路径来寻找那些条目,直到找到应用想使用的一个(或者是找不到,回到系统解析器)。因为读取这个 JAR 文件需要一个同步锁,所以所有尝试创建一个解析器的线程最终都会竞争同一个锁,这会极大影响应用的吞吐量。(建议设置 -Djavax.xml.parsers.SAXParserFactory 属性来避免这些查找,原因就在于此。)
更重要的一点是,大量被阻塞的线程会成为影响性能的问题。不管阻塞的根源是什么,都要对配置或应用加以修改,以避免之。
等待通知的线程又是什么样的情况呢?那些线程在等待其他事件发生。它们往往是在某个池中,等待任务就绪(比如,上面输出中的 getTask() 方法在等待请求)这类通知。系统线程会在处理像 RMI 分布式 GC 或 JMX 监控这样的事情,它们以栈中只有 JDK 类这类线程的形式出现在 jstack的输出中。这些条件不一定表明有性能问题;对这些线程而言,等待通知是正常现象。
如果线程正在进行的是阻塞式 I/O 读取(通常是 socketRead0() 方法),也会导致问题。这也会影响吞吐量:线程正在等待某个后端资源回复其请求。这时候应该检查数据库或其他后端资源的性能。
小结
利用系统提供的线程基本信息,可以对正在运行的线程的数目有个大致了解。
就性能分析而言,当线程阻塞在某个资源或 I/O 上时,能够看到线程的相关细节就显得比较重要。
JFR 使得我们可以很方便地检查引发线程阻塞的事件。
利用 jstack,一定程度上可以检查线程是阻塞在什么资源上。
以上内容整理自《Java性能权威指南》第 9 章
作者:Scott Oaks
译者:柳飞,陆明刚,臧秀涛
《Java 性能权威指南》对 Java 7 和 Java 8 中影响性能的因素展开了全面深入的介绍,讲解传统上影响应用性能的JVM特征。内容包括:用 G1 垃圾收集器最大化应用的吞吐量;使用 Java飞行记录器查看性能细节,而不必借助专业的分析工具;堆内存与原生内存最佳实践;线程与同步的性能,以及数据库性能最佳实践等。
本文由人民邮电出版社授权「高可用架构」发表。转载请注明来自高可用架构「ArchNotes」微信公众号及包含以下二维码。
高可用架构
改变互联网的构建方式
长按二维码 关注「高可用架构」公众号