JAVA线程池详解
This is a hidden message
随着计算机技术的飞速发展,摩尔定律失效,多核CPU成为了主流;越来越多的开发者使用多线程技术来提升服务器性能,而线程池的使用方式也变得至关重要。
什么是线程池
线程池(thread pool):一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
为什么要使用线程池
如果没有线程池,我们需要额外的线程来执行任务时,就需要手动创建线程,从而产生一些资源管理的问题:
- 频繁申请和销毁线程资源,会带来额外的损耗;
- 对线程资源的申请没有限制,可能会出现资源耗尽的情况;
- 手动创建的线程不利于系统管理,系统稳定性降低。
而为了解决线程资源分配的问题,我们就需要线程池来进行线程资源的管理。
设计与实现
JAVA中线程池的核心实现类是ThreadPoolExecutor
,下面是ThreadPoolExecutor
的UML类图:
Executor
作为ThreadPoolExecutor
的顶层接口提供了一种设计思想:将任务提交和任务执行进行解耦,用户无需关心线程是如何执行和调度的,只需要将实现了Runnable
接口的任务对象提交到执行器,然后执行器会完成线程的调度以及任务的执行;
1 | public interface Executor { |
ExecutorService
接口提供了一些额外的能力:
- 提供了管控线程池的方法,包括一系列的停止线程池的方法;
- 新增了可执行
Callable
任务的方法,以及Future
接口来获取异步计算的结果。
AbstractExecutorService
则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可;
ThreadPoolExecutor
是线程池最复杂的运行部分,维持了自身的生命周期,实现了线程和任务的管理。
ThreadPoolExecutor
类分析
ThreadPoolExecutor
类中提供了四个构造方法,我们直接看最长的那个即可,其他三个都是以这个构造方法为基础产生的:
1 | public ThreadPoolExecutor(int corePoolSize, |
ThreadPoolExecutor
的三个核心参数:
corePoolSize
:核心线程池的数量,定义了最小可同时运行的线程数;maximumPoolSize
:最大线程池容量,定义线程池可同时运行的最大线程数;workQueue
:任务列队,当线程池中的核心线程都被使用时,新来的任务会被推入任务列队,此处可以定义列队的长度;
其他参数:
keepAliveTime
:保持时间,当线程池中有非核心的空闲线程时,这些线程不会被立即销毁,而是会等待一段时间之后被销毁,这个时间就是保持时间;unit
:keepAliveTime
的时间单位;threadFactory
:线程工厂,一般使用默认即可;handler
:饱和策略,当线程池中所有的线程都被使用,且任务列队也满了,这时候需要根据饱和策略来拒绝新来的任务或者调用主线程执行;这里ThreadPoolExecutor
提供了四种基础的策略,我们也可以通过实现RejectedExecutionHandler
接口自定义策略。
ThreadPoolExecutor
运行机制
ThreadPoolExecutor
运行机制图:
线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程;
线程池的运行主要分成两部分:任务管理、线程管理。任务管理部分充当生产者的角色;
当任务提交后,线程池会判断该任务后续的流转:
(1)直接申请线程执行该任务;
(2)缓冲到队列中等待线程执行;
(3)拒绝该任务。线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。
其他常用的线程池
除了上面讲的ThreadPoolExecutor
线程池,JDK还提供了Executors
工具类,用来创建其他几种线程池。
FixedThreadPool
FixedThreadPool
被称为可重用固定线程数的线程池,让我们来看一下他是如何创建的:
1 | public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) { |
可以看到corePoolSize
和maximumPoolSize
都被设置成了相同的值,通过nThreads
传入;
这里不推荐使用FixedThreadPool
,因为这里的等待列队是一个无边界的列队,所以运行中的线程池不会拒绝任务,而是无限的塞入等待列队中,最终会导致OOM。
SingleThreadExecutor
SingleThreadExecutor
是只有一个线程的线程池,让我们来看看实现:
1 | public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) { |
这个线程池也不推荐使用,原因和上面的一样。
CachedThreadPool
CachedThreadPool
是一个会根据需要创建新线程的线程池,它的初始线程数为0,让我们来看看实现:
1 | public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) { |
由于corePoolSize
被设置为0,maximumPoolSize
被设置为Integer.MAX_VALUE
,所以他是无边界的;
如果主线程中提交任务的速度比线程池中执行任务的速度要快,CachedThreadPool
就会不断的创建新线程,从而导致CPU和内存资源耗尽,同样不推荐使用。
ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor
主要用来在给定的延迟后执行任务,或者定时执行任务;由于现在大多数定时任务都依赖于任务调度平台,所以实际中很少用到。
如何确定线程池的大小
线程池的大小对程序的稳定性和资源的利用率起着非常重要的作用;如果设置的太大,大量的线程会同时争夺CPU资源,导致上下文切换非常的耗时,从而增加任务的执行时间;如果设置的太小,可能会导致大量的任务堆积在任务等待列队,最终导致OOM,而且CPU的使用率也会很低,导致资源的浪费。
所以这里推荐一个常用的公式:
- CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
- I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。