加入收藏 | 设为首页 | 会员中心 | 我要投稿 源码网 (https://www.900php.com/)- 科技、建站、经验、云计算、5G、大数据,站长网!
当前位置: 首页 > 站长资讯 > 外闻 > 正文

为何服务器QPS上不去?Java线程调优权威指南

发布时间:2019-07-16 16:20:21 所属栏目:外闻 来源:今日头条
导读:副标题#e# 从刚问世起,Java 的部分魅力就来自其多线程。即便在多核和多 CPU 系统司空见惯之前,能够轻松编写多线程程序也是 Java 的一个标志性特征。 Java 性能方面的吸引力显而易见:如果有两个 CPU 可用,那么一个应用能够完成的工作量可能是原来的 2 倍

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

为何服务器QPS上不去?Java线程调优权威指南

递归快速排序中的任务

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

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

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

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

为何服务器QPS上不去?Java线程调优权威指南

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

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

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

为何服务器QPS上不去?Java线程调优权威指南

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

表4:对1亿个元素做计数处理

为何服务器QPS上不去?Java线程调优权威指南

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

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

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

为何服务器QPS上不去?Java线程调优权威指南

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

(编辑:源码网)

【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!

热点阅读