线程与硬件
CPU的核心数量和超线程都能影响软件线程的性能,通过使物理核心数量翻倍,也同样能让程序运行性能翻倍甚至更多,但是超线程技术并不能。
本文接下来的例子都会在一个4物理核心,8逻辑核心的CPU上运行,用以展示超线程CPU和非超线程CPU的区别。(并不是说超线程不重要,毕竟是免费的20%-40%的性能或者吞吐量提升,而且在某些苛刻的条件下,提升的幅度要更高些,比如你的大量代码都是逻辑无关的,具体可以看超线程技术的维基百科)。在Java编程的方面,我们也始终应当把超线程视作实际意义上的CPU。
线程池和ThreadPoolExecutor
在Java当中,线程可以自行编写代码管理,也可以利用线程池来进行管理。Java服务器通常都是会利用一个或多个线程池处理客户端的请求,其他Java应用同理也可以使用其提供的ThreadPoolExecutor类来并行执行任务。
一些Web框架也利用了ThreadPoolExecutor,比如Spring中的ThreadPoolTaskExecutor,就是对ThreadPoolExecutor的一次再封装,以允许在Spring中用Bean的形式去配置ThreadPoolExecutor,且几个核心参数——corePoolSize,maxPoolSize,keepAliveSeconds都支持运行时更新,因此该类很适合用于开发应用的实时线程管理和监控等功能。
当然也有一些框架不用ThreadPoolExecutor来管理线程,大部分是由于它们出现的比ThreadPoolExecutor这个类还要早。
虽然说线程池的实现可能各个框架会略有差异,但它们的基本概念大致都是相同的。
使用一个线程池的最最关键的因素,就是线程池的大小。线程池的性能会随其大小变化,在某些情况下一个过大的尺寸会使其性能下滑。
各类型的线程池工作方式大体类似,任务先被提交到一个或多个队列,然后一定数量的线程从队列中提取出任务并执行,如果是在Web服务器的话任务的执行结果会发送回客户端,其他情况也有可能会存储在本地内存,持久化到DB等等。在线程完成任务之后,它又会去检索队列当中的其他任务并执行,如果没有其他需要执行的任务,就等待下一个任务到来。
线程池有最小数量线程和最大数量线程。其中最小数量线程是长期存在的,等待任务分配到它们身上。因为创建一个线程是相当昂贵的操作,让一部分线程长期存在可以加速一个任务的执行流程,另一方面,也是因为线程需要分配包括宿主机内存在内的系统资源,那么如果有太多的空闲线程存在的话,是对系统资源的浪费,它们占用了本可以给其他程序使用的资源。最大线程数量是作为一个必要的阈值,以防一下子有太多的任务一起执行。
设置最大线程数量
在给定的硬件条件和工作负载下,最佳的最大线程数量应该如何设置?这并不是一个简单的计算题,就像GC调优一样,取决于工作负载的特性和硬件条件。当然也有比较突出的一个影响点,即任务的阻塞频率。
接下来的讨论会围绕一个4物理核心的CPU展开。
很明显,最大线程数量至少也需要设置为4。当然,JVM当中的一些线程也可能在做其他事情,但是往往它们并不会占用一整个核心,除非是使用了并发/并行的垃圾收集器,像是G1 GC,ZGC,Shenandoah GC,它们需要足够的线程来回收内存。
那么4个以上的线程数量,是否有意义呢?以一个简单的情况举例,所有的任务都是CPU计算,它们不存在任何的网络IO,也不存在锁竞争。我使用递归去计算斐波那契数列(这里我是为了让程序执行足够久的时间才用递归),同样的任务执行16次,并统计耗时。
public static void main(String[] args) throws InterruptedException {
ExecutorService e = Executors.newFixedThreadPool(1);
List<FiboTask> tasks = new ArrayList<>();
for (int i = 0; i < 16; i++) {
tasks.add(new FiboTask());
}
long t = System.currentTimeMillis();
for (FiboTask ft : tasks) {
e.submit(ft);
}
e.shutdown();
e.awaitTermination(1, TimeUnit.DAYS);
System.out.println(System.currentTimeMillis() - t);
}
- 运行结果
| 线程数量 | 耗时(ms) | 基准线 |
| 1 | 70505 | 100% |
| 2 | 37990 | 53.88% |
| 4 | 18646 | 26.45% |
| 8 | 18911 | 26.82% |
| 16 | 19294 | 27.37% |
可以看到,尽管可能是由于其他程序影响,测试结果略有误差,但是大体上还是能说明,当分别用2个线程和4个线程完全并行的去运行程序的时候,耗时在50%和25%。但是在实际上,这种线性扩展是不可能实现的,原因有以下几点:一般来说线程之间必须互相协调才能从队列中取出一个任务,当使用4个线程的时候,系统的CPU占用已经达到100%,即便排除其他用户程序的影响,系统进程本身也会占用一定的CPU,所以JVM往往并不能利用所有的CPU周期。另外还可以看到,在这次测试当中,即便使用了远高于CPU核心数量的线程,对性能的负面影响其实也相当小。
接下来尝试下,在一台2物理核心的CPU上开启超线程后会怎么样(用Docker运行限制容器可用CPU)。在开启超线程后,现在拥有2个物理核心,4个逻辑核心。测试结果如下:
| 线程数量 | 耗时(ms) | 基准线 |
| 1 | 70671 | 100% |
| 2 | 37528 | 53.10% |
| 4 | 35094 | 49.66% |
| 8 | 35977 | 50.91% |
| 16 | 36113 | 51.10% |
直到2个线程为止,性能提升还是线性的,但是再往后的提升就微乎其微了。但是在线程有IO操作,或者有等待锁的情况时,超线程带来的好处还是比较明显的。
在之前有提到,性能瓶颈分析是性能调优的关键之一。在以上的例子上,瓶颈显然是在CPU上,使用4个以上的线程是毫无意义的。
这个例子是比较极端的,通常情况下线程都会有IO操作,尤其是Web服务器,线程有可能会操作数据库,也有可能做磁盘写入等等,在这种情况下,CPU就不一定是瓶颈了,瓶颈可能会发生在外部,比如数据库所在服务器或者磁盘性能。
如果瓶颈确实是在外部,那么这个时候扩容线程池也是不利的。举个简单的例子,现在我们有一个用于发起HTTP请求的客户端,还有一个部署了应用的Web服务器。我们先不考虑服务器的承受能力,我们在客户端上用一个线程对服务器发起请求,也许这个时候服务器的CPU占用是25%,但是客户端的CPU几乎完全是空闲的,然后再把客户端的线程数调到4个,这个时候服务器的CPU占用已经达到了100%,而客户端可能是20%。
只看客户端的话,确实客户端的资源有相当的浪费,但是这就代表我们还可以给客户端增加更多的线程吗?接下来做个具体的测试,看看测试结果如何:
| 客户端线程数量 | 平均响应时间(ms) | 基准线 |
| 1 | 232 | 100% |
| 2 | 277 | 119.40% |
| 4 | 287 | 123.71% |
| 8 | 297 | 128.02% |
| 16 | 354 | 152.59% |
| 32 | 539 | 232.33% |
当线程增加到32个的时候,服务的响应速度已经被严重拖慢了。由此可见,在该例中,如果服务端成为瓶颈,客户端继续增加线程是相当不利的。与数据库交互时也同理,而且数据库成为瓶颈时的情况会更加严重。
这也是为什么线程池的自动调整是不易实现的原因之一,即便线程池对其处理的工作量,它所处的硬件环境有一定的可见性,它对包含外部环境在内的整体环境缺乏可见性。有人可能会说Java有提供可以自动扩容缓存线程池,但是我一点都不觉得生产中应该使用它,使用它的风险是相当高的。
在这个测试当中,服务器的配置是4物理核心CPU,服务启动时默认创建了16个线程,对于Web服务器来说这是有意义的,之前也提到过,这些线程是可以预期到的。当调用阻塞等待响应的时候,其他线程就可以运行其他任务,因此在Web服务器中创建较多的线程是一个合理的折中方案。对于主要是CPU计算的任务,这会有轻微的惩罚,对于IO密集的任务,这能够增加吞吐量。
综上,总结下来,就是线程池的最大尺寸调整更像是一门艺术,没有什么固定的做法。生产当中,一个能做到自我调整的线程池一般可以发挥出80%-90%的性能,即便对实际需要的线程数量过高估计,带来的性能惩罚也不是不能接受。但是一旦线程池尺寸引起了问题,那么这个问题绝对会是个大问题。为了尽可能降低这方面的风险,完善的测试是必备的。
设置最小线程数量
探讨完最大线程数量的设置后,接下来就是最小线程数量了。在绝大部分情况下,两者都可以设置为相同的值。
设置最小线程数量为其他值(如1个)的理由,是可以防止系统创建过多的线程,从而节省系统资源。但是实际上,我们设计系统的时候总是需要考虑其最大吞吐量,因此就需要创建预期需要的最大数量的线程,如果系统无法处理最大线程数,就算调整最小线程数也毫无意义。
是否应该预创建线程?
默认情况下,当你创建一个ThreadPoolExecutor时,线程数量为1。假如一个线程池要求8个核心线程和16个最大线程,在这种情况下,核心线程数即最小线程数,因为即使它们是空闲的,也始终保持在池中,而另外8个线程,则是按需创建,然后保持在池中。
在服务器当中,也就意味着前8个请求会略微有延迟,但是延迟很小,你完全可以预创建这些线程。参考这个方法:
/** * Starts a core thread, causing it to idly wait for work.
This * overrides the default policy of starting core threads
only when * new tasks are executed. This method will return
{@code false} * if all core threads have already been started. * *
@return {@code true} if a thread was started */
public boolean prestartCoreThread() {
return workerCountOf(ctl.get()) < corePoolSize &&
addWorker(null, true);
}
另一方面,指定最小线程数量的缺陷也只是名义上的。这个缺陷仅发生在最初有多个任务要执行的时候,然后线程池就会创建新线程以满足需求,而创建线程对性能是有不利影响的,这也是为什么会需要线程池的原因,但只要线程之后一直都保持在池中,这种一次性的创建成本也是能忽略的。
比如在BI程序等批处理程序中,无论是创建时分配还是按需分配线程,都无关紧要。在其他程序中,新线程可能会在预热期间分配,对应用程序的性能影响可以忽略不计。再退一步,线程的创建发生在对外提供服务期间,只要创建的线程数量有限,产生的影响也不见得能注意得到。
这里还有一个需要关注的调优点,就是线程的空闲时间。假设有一个最小线程数量为1,最大线程数量为4的线程池,然后程序开始一个每15秒两个任务的执行周期。在第一次循环周期中,线程池会创建第二个线程。这时候就自然会了解到,第二个线程在线程池中保留一段时间是有意义的,因为我们需要避免出现这种情况——第二个线程创建后,在5秒内完成了任务,在后续5秒内空闲,然后退出。因为再过5秒后,下一个周期已经开始,将会有任务分配到第二个线程上。一般来说,在线程池中创建最小线程后,它至少应该保留在池中几分钟,以应对后续可能的峰值。如果有一个靠得住的排队论模型,可以根据模型来计算保留时间。否则,保留时间应当以分钟为单位,至少再10-30分钟之间。
保持空闲线程对应用程序的影响通常不大。一般来说,线程对象本身并不会占用多大的堆空间。除非线程持有大量的线程本地存储(ThreadLocal Storage),或者大量的内存都是通过线程运行时对象引用的。在这两种情况下,释放线程则可以大大节省堆空间。当然了,这两种情况本身也就不该发生,当线程池中的线程处于空闲状态时,一定要确保它不再引用任何运行时对象,如果引用了,一定是某个地方有BUG。根据线程池的实现,线程局部变量可能由于某些情况下需要重用等原因依然会保留,但这些局部对象占用的内存总量必须受到限制。
这个规则也有一个重要的例外,就是当线程池可能会成长为一个巨型线程池的时候。假设一个任务队列预计每个执行周期会有20个任务,那么20就是这个线程池比较推荐的最小线程数量。
假如线程池运行在一个高配机上,它能承受的峰值任务数量是2000。在这个池中保留2000个空闲线程,当它只执行20个任务的时候,就会影响到执行性能。一般情况下,对于中小公司而言是不会碰到这样的问题的,但是如果遇到这种问题,就要确保线程池有一个合适的最小值。
线程池任务数量
线程池的待处理任务保存在队列或者列表中,当线程池中的一个线程可以执行任务时,它就会从队列中抽取出一个任务。这可能会导致生产消费的速度不平衡,因为队列上的任务数量可能会变得非常大。如果队列太长,队列中的任务将会等待非常多时间,直到前面的任务完成执行。试想一下,一个Web服务器正处于高负载下,如果一个任务被添加到队列中,3秒后都没有被执行,那么用户的体验是极差的。因此,线程池通常会限制待处理任务队列的大小。ThreadPoolExecutor依据其配置的数据结构以多种方式来实现这一点,对于服务器,则通常有一个参数可以用来调整这个值,比如Tomcat中的accept-count。
与线程池的最大线程数量一样,任务数量没有什么通用的调整策略。假设一台服务器中,存在一个30000长度的队列,有4个可用CPU,如果执行一个任务仅需50ms,那么只需要6分钟就可以消耗完这个队列。这时候可能还是可以接受的,但是如果每个任务需要1秒来执行,就需要2个小时才能消耗完了。再次强调,预估和测量应用的实际需求,才能确定该值的调整策略。
在任何情况下,当到达队列长度限制的时候,向队列继续添加任务将会抛出异常。ThreadPoolExecutor有个rejectedExecution()方法来处理这种溢出情况,默认情况下会抛出一个RejectedExecutionException。这种时候,服务器需要返回给客户端合理的响应,比如用429(请求太多)或者503(服务不可用)的状态码。
调整ThreadPoolExecutor
线程池的一般策略是:以最小线程数启动,如果在所有线程都繁忙时有新的任务到来,则启动新的线程并立即执行任务。如果已经启动了最大线程数,但它们都处于繁忙状态,则任务会被排队。除非队列已经满了,在这种情况下任务会被拒绝。但实际上ThreadPoolExecutor的行为是可以不同的。
ThreadPoolExecutor根据用来存放任务的队列类型来决定何时启动新的线程。有以下三种可能:
- SynchronousQueue
当使用SynchronousQueue时,线程池在线程数量的表现上会符合预期:如果所有线程都繁忙,并且池中的线程数量小于最大线程数,则启动新线程。但是这个队列没办法保留待处理的任务,如果一个任务到达,而最大线程都已经繁忙,那么这个任务总是被拒绝。所以这个选择对于管理少量任务的时候是很好的,但是在其他方面可能不太合适。这个类的JDK文档中建议指定一个非常大的数字作为最大线程大小。如果任务完全是CPU密集的,这也许可以考虑,但在其他情况下可能会适得其反。另一方面,如果你需要一个线程数量易于调整的线程池,这也是一个很好的选择。
在这种情况下,核心线程数即最小线程数——即使线程处于空闲状态也会保持的线程数量。最大线程数是池中的最大线程数量。
- Unbounded queues
当使用无界队列(如LinkedBlockingQueue)时,任何任务都不会被拒绝,因为队列长度是无限的。在这种情况下,线程池最多使用核心线程数量大小的线程数量,此时最大线程数量被忽略。这本质上是在模仿传统的线程池,其中核心线程数被当做最大线程数,不过由于队列长度无限,如果任务提交的速度远超过消耗速度,就会有内存消耗过度的风险。
这也是Executors中newFixedThreadPool()和newSingleThreadScheduledExecutor()方法返回的线程池类型,前者的核心线程(或者说最大线程)数是用以构造线程池的参数,后者的核心线程数是1。
- Bounded queues
使用有界队列(如ArrayBlockingQueue)的线程池,采用复杂的算法来决定何时启动新的线程。例如,假设线程池的核心线程数为4,最大线程数为8,ArrayBlockingQueue的最大长度为10。当任务到达并存入队列中,线程池将最多运行4个线程。即便队列已满,执行器也仅运行4个线程。只有当队列满并且又有一个新的任务要添加到队列中的时候,新线程才会被创建。
线程池并不会因队列满而拒绝该任务,而是启动一个新线程。这个线程会运行队列中的第一个任务,来为新到来的任务腾出空间。
在该例中,仅当有7个任务正在执行中,队列中有10个待处理任务,第11个任务将要被添加到队列中的时候,线程池中才会出现第八个线程,达到其指定的最大线程数量。
这个算法的思想是:即使队列中有适量的任务正在等待运行,线程池在大部分时间里也只使用配置的核心线程数大小的线程来运行。这使得线程池可以作为一个节流器。如果积压的请求过大,线程池就会尝试运行更多的线程来消费请求(受制于第二个节流器,即最大线程数)。
如果系统不存在外部瓶颈,而且有充分的CPU资源,那么这个算法思路没有问题:增加新的线程以更快的消费队列中的任务。
另一方面,这个算法无法感知到为什么队列长度会增加。如果这个原因来自于外部,增加更多的线程就是错误的做法了。如果线程池运行在一个CPU资源不足的机器上,这也同样是错误的做法。只有当系统中出现了额外的压力而发生任务积压时(比如客户端请求数增加),增加线程才有意义。(那其实也不必等到队列达到一定界限时才添加线程了,既然有额外的资源可以利用,早点创建他不香吗?)
这里提供的选择,它们既有赞同也有反对的声音在。但是当我们尝试最大限度的提高程序性能时,可以应用KISS原则(Keep It Simple, Stupid),保持简单,和蠢。有个通用的建议,就是生产中尽量不要使用Executors类来提供默认的,不受约束的线程池,这些线程池不允许你控制程序的内存占用。你需要构建自己的ThreadPoolExecutor,最好拥有相同数量的核心线程和最大线程,并且利用ArrayBlockingQueue来限制内存中等待执行的请求数量。
简单总结
线程池是对象池的一种,线程的创建成本很高,而线程池可以很好的限制系统上的线程数量。
线程池必须小心调整,不可盲目的增加线程数,在某些情况下这会降低性能。
为ThreadPoolExecutor使用更简单的配置,通常会提供最好也最可预测的性能。
参考资料:《OReilly.Java Performance》