大家好,我是小水珠。
还记得我在16讲中说过“线程池的线程数量过多会导致线程竞争激烈”吗?今天再补一句,如果线程数量设置过少的话,该会导致系统无法充分利用计算机资源。那么如何设置才不会影响系统性能呢?
其实线程池的设置是有方法的,不是凭借简单的估算来决定的。今天我们就来看看究竟有哪些计算方法可以使用,线程池中各个参数之前又存在怎样的关系。
一 线程池原理
在HotSpot VM线程模型中,Java线程被一对一映射为内核线程。Java在使用线程执行程序时,需要创建一个内核线程;当该Java线程被终止时,这个内核线程也会被回收。因此,Java线程的创建与销毁将会消耗一定的计算机资源,从而增加系统的性能开销。
除此之外,大量创建线程同样会给系统带来性能问题,因为内存和CPU资源都将被线程抢占,如果处理不当,就会发生内存溢出,CPU使用率超负荷等问题。
为了解决上述两类问题,Java提供了线程池概念,对于频繁创建线程的业务场景,线程池可以创建固定的线程数量,并且在操作系统底层,轻量级进程将会把这些线程映射到内核。
二 线程池框架Executor
Java最开始通过ThreadPool实现了线程池,为了更好的实现用户级的线程调度,更有效的帮助开发人员进行多线程开发,Java提供了一套Executor框架。
这个框架中包括了ScheduledThreadPoolExecutor和ThreadPoolExecutor两个核心线程池。
Executors实现了以下四种类型的ThreadPoolExecutor:
这里我建议你使用ThreadPoolExecutor自我定制一套线程池。进入四种工厂类后,我们可以发现除了newScheduledThreadPool类,其它类均使用了ThreadPoolExecutor类进行实现,你可以通过以下代码简单看下该方法:
我们还可以通过下面这张图来了解下线程池中各个参数的相互关系:
通过上图,我们发现线程池有两个线程数的设置,一个为核心线程数,一个为最大线程数。在创建完线程池之后,默认情况下,线程池中并没有任何线程,等到有任务来才创建线程去执行任务。
但有一种情况排除在外,就是调用prestartAllCoreThreads()或者prestartCoreThread()方法的话,可以提前创建等于核心线程数的线程数量,这种方式被称为预热,在抢购系统中经常被用到。
线程池回收线程时,会对所谓的“核心线程”和“非核心线程”一视同仁,直到线程池中线程的数量等于设置的corePoolSize参数,回收过程才会停止。
我们可以通过allowCoreThreadTimeOut设置项要求线程池:将包括“核心线程”在内的,没有任务分配的所有线程,在等待keepAliveTime时间后全部回收掉。
我们可以通过下面这张图来了解下线程池的线程分配流程:
三 计算线程数量
1. CPU密集型任务
这种任务消耗的主要是CPU资源,可以将线程数设置为N(CPU核心数)+1
下面我们通过一个例子来验证下这个方法的可行性:
测试代码在4核 Intel i5 CPU机器上运行时间变化如下:
2. I/O密集型任务
这种任务应用起来,系统会用大部分的时间来处理I/O交互,而线程在处理I/O的时间段内不会占用CPU来处理,在I/O密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是2N。
这里我们还是通过一个例子来验证下这个公式是否可以标准化:
看完以上两种情况下的线程计算方法,你可能还想说,在平常的应用场景中,我们常常遇不到这两种极端场景,那么碰上一些常规的业务场景,我们该如何设置线程池的数量呢?
此时我们可以参考以下公式来计算线程数量:
线程数=N(CPU核数)x (1+WT(线程等待时间)/ ST(线程时间运行时间))
我们可以通过JDK自带的工具VisualVM来查看WT/ST比例。
四 总结
今天我们主要学习了线程池的实现原理,Java线程的创建和销毁会给线程带来性能开销,因此Java提供了线程池来复用线程,提高程序的并发效率。
Java通过用户线程和内核线程结合的1:1线程模型来实现,Java将线程的调度和管理设置在了用户态,提供了一套Executor框架来帮助开发人员提高效率,Executor框架不仅包括了线程池的管理,还提供了线程工厂,队列以及拒绝策略等,可以说Executor框架为并发编程提供了一个完善的框架体系。
在不同的业务场景以及不同配置的部署机器中,线程池的线程数量设置是不一样的。其设置不宜过大,也不宜过小,需根据具体情况,计算出一个大概的数值,再通过实际的性能测试,计算出一个合理的线程数量。