线程池(Thread Pool):顾名思义,就是类似于一个充满了线程的池子,它其实是一种线程的使用模式,是一种池化技术的应用。因为频繁创建和销毁线程会导致线程调度效率降低,进而影响整体性能。所以为了降低这种影响,就出现了线程池。虽然线程的创建和销毁相对于进程来说已经很轻量化,但是仍然无法避免创建、销毁以及切换带来的性能损耗。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务,当任务处理完线程不会销毁,会进入线程池,任务来临,直接从线程池中取线程去处理,从而省去了创建和销毁的性能损耗,提升了程序性能。
池化技术
池化技术:简单点来说,就是提前保存大量的资源,以备不时之需。在机器资源有限的情况下,使用池化技术可以大大的提高资源的利用率,提升性能等。但是同时它也需要一定的资源来维护这个“池”资源。
有非常多的典型应用场景:线程池、数据库连接时候用到的连接池、内存池和对象池等。
- 内存池(C++中比较常用):它是一种内存的分配方式,平时编码我们习惯于直接new对象来实现内存的申请,这样做的缺点在于:如果频繁多次申请内存,且申请的内存大小不固定,就会造成大量的内存碎片从而降低程序的性能。内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够再继续申请新的内存,这样就显著提高了内存分配的效率。
- 对象池:就是在程序运行时,生成一块内存区域存放若干个对象实例,需要时就从池中获取,不需要时重新放入对象池中,这样一方面避免了频繁的产生和销毁实例对象,另一方面,对象池中的实例如果不够程序调用才会继续产生实例,这大大节省了性能。在游戏中比如:子弹、小兵等等都可以采用这种方式,在Java中也有相应的应用,如:除了Double和Float两种类型之外的其他六种基本类型的包装类都采用了这种对象池的技术。
线程池简单使用
在Java中,ThreadPool
是通过ExecutorService
来实现的,例如下面举例的一个最简单的线程池使用方式:
ExecutorService service = new ThreadPoolExecutor(1, 1, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10));
service.execute(() -> System.out.println("Hello ThreadPool!"));
service.shutdown();
上面这个例子很简单,就是创建一个内部只有一个线程的线程池,它的空闲时长为60秒,超过就会被回收,任务队列最多只能存储10条任务。调用service
的execute()
方法,传入一个实现了Runnable接口的类对象,这里我采用了Java8新增的Lambda表达式的方式,只是一种语法糖而已。
这里的shutdown
方法我查阅了以下源码的注释,上面的解释是:
调用该方法后,会启动有序关闭,其中先前提交的任务将被执行,但不会接受任何新任务。如果已经关闭,则没有其他影响。此方法不会等待先前提交的任务完成执行。
它其实就是一个关闭线程池的操作,因为线程池如果没有特殊原因,它会一直都在运行,随时等候接收任务并处理。如果不对其进行释放,就会占用一部分系统资源,如果过多,可能会造成资源开销问题。调用shutdown只能保证其中的任务可以执行,但是并不保证是否能够执行完成。
上面的使用示例中涉及到了一些参数传入,这些参数都有各自的概念,下面来解释一下线程池内部的一些名词概念。
名词概念
如果把线程池比作一个公司。公司会有正式员工(核心线程数coreSize)处理正常业务,如果工作量大的话,会雇佣外包人员(maximumSize - coreSize)来工作。闲时就可以释放外包人员以减少公司管理开销。而且公司因为成本关系,雇佣的人员始终是有最大数(maximumSize)。如果这时候还有任务处理不过来,就走需求池排任务(任务队列(Queue))。
那么上面这个比喻基本上就覆盖了线程池中一部分的基本概念,所谓线程池本质是一个hashSet。多余的任务会放在阻塞队列中。只有当阻塞队列满了后,才会触发非核心线程的创建。所以非核心线程只是临时过来打杂的。直到空闲了,然后自己关闭了。线程池提供了两个钩子(beforeExecute
,afterExecute
)给我们,我们继承线程池,在执行任务前后做一些事情。
任务队列
- 无界队列:大小无限制,常用的就是
LinkedBlockingQueue
。使用该队列做为阻塞队列时要尤其当心,当任务耗时较长时可能会导致大量新任务在队列中堆积最终导致OOM。而Java的Executors.newFixedThreadPool
内部采用的就是这种无界队列。 - 有界队列:常用的有两类,一类是遵循FIFO原则的队列如
ArrayBlockingQueue
与有界的LinkedBlockingQueue
,另一类是优先级队列如PriorityBlockingQueue
。PriorityBlockingQueue
中的优先级由任务的Comparator
决定。 使用有界队列时队列大小需和线程池大小互相配合,线程池较小有界队列较大时可减少内存消耗,降低cpu使用率和上下文切换,但是可能会限制系统吞吐量。 - 同步移交队列:如果不希望任务在队列中等待而是希望将任务直接移交给工作线程,可使用
SynchronousQueue
作为等待队列。SynchronousQueue
不是一个真正的队列,而是一种线程之间移交的机制。要将一个元素放入SynchronousQueue
中,必须有另一个线程正在等待接收这个元素。降低了将数据从生产者移动到消费者的延迟。因为SynchronousQueue
没有存储功能,因此put
和take
会一直阻塞,直到有另一个线程已经准备好参与到交付过程中。仅当有足够多的消费者,并且总是有一个消费者准备好获取交付的工作时,才适合使用同步队列。
ThreadPoolExecutor
现在再回过头来看ThreadPoolExecutor
类的源码,就能明白其中的一些概念了:
- acc : 获取调用上下文
- handler:线程池拒绝策略,什么意思呢?就是当任务实在是太多,人也不够,需求池也排满了,还有任务咋办?默认是不处理,抛出异常告诉任务提交者,我这忙不过来了。
- corePoolSize: 核心线程数量,可以类比正式员工数量,常驻线程数量。
- maximumPoolSize: 最大的线程数量,公司最多雇佣员工数量。常驻+临时线程数量。注意只有当线程数已经达到了核心线程数,并且任务队列也已经满了,才会开启最大线程数的判断。
- workQueue:多余任务等待队列,再多的人都处理不过来了,需要等着,在这个地方等。
- keepAliveTime:非核心线程空闲时间,就是外包人员等了多久,如果还没有活干,解雇了。
- threadFactory: 创建线程的工厂,在这个地方可以统一处理创建的线程的属性。每个公司对员工的要求不一样,恩,在这里设置员工的属性。
handler
这里需要着重强调一下handler的策略问题,当队列和线程池都达到饱和状态,需要一个对应的饱和处理策略来处理后续涌入的线程任务,JDK主要提供了4种饱和策略供选择。4种策略都做为静态内部类在ThreadPoolExcutor
中进行实现。
- AbortPolicy中止策略:它是默认的饱和策略,使用该策略时在饱和时会抛出
RejectedExecutionException
(继承自RuntimeException
),调用者可捕获该异常自行处理。 - DiscardPolicy抛弃策略:不做任务处理,直接抛弃,它的源码中也是如此,方法体内部是空的。
- DiscardOldestPolicy抛弃旧任务策略:先将阻塞队列中的头元素出队抛弃,再尝试提交任务。如果此时阻塞队列使用
PriorityBlockingQueue
优先级队列,将会导致优先级最高的任务被抛弃,因此不建议将该种策略配合优先级队列使用。 - CallerRunsPolicy调用者运行:既不抛弃任务也不抛出异常,直接运行任务的
run()
方法,换言之将任务回退给调用者来直接运行。使用该策略时线程池饱和后将由调用线程池的主线程自己来执行任务,因此在执行任务的这段时间里主线程无法再提交新任务,从而使线程池中工作线程有时间将正在处理的任务处理完成。
execute()
- 首先得到线程池的当前线程数,如果线程数小于
corePoolSize
,则执行addWorker
方法创建新的线程执行任务;- 然后判断线程池是否在运行,如果在,任务队列是否允许插入,插入成功再次验证线程池是否运行,如果不在运行,移除插入的任务,然后抛出拒绝策略。如果在运行,没有线程了,就启用一个线程。否则如果添加非核心线程失败,就直接拒绝了。
简而言之就是:execute
方法中传入的内容,会根据当前线程池的状态,来确定是直接加入到核心线程池中启动线程运行,还是加入到等待队列中等待运行,甚至是直接拒绝运行。
Java内置的四种常用线程池
newCachedThreadPool
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue());
}
它的核心线程数为0,任务队列采用的是同步移交队列,要将一个元素放入
SynchronousQueue
中,必须有另一个线程正在等待接收这个元素。因此即便SynchronousQueue
一开始为空且大小为1,第一个任务也无法放入其中,因为没有线程在等待从SynchronousQueue
中取走元素。所以第一个任务到达时便会创建一个新线程执行该任务。
newFixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue());
}
线程数量固定,使用无限大的队列。因为是无限大的任务队列,会有OOM的风险,慎重使用。
newScheduledThreadPool
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE,
DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,
new DelayedWorkQueue());
}
创建一个定长线程池,支持定时及周期性任务执行。这里有一个
DelayedWorkQueue
队列,它作为静态内部类就在ScheduledThreadPoolExecutor
中进行了实现,实际上它是一个无界队列,能按一定的顺序对工作队列中的元素进行排列。
newSingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue()));
}
创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。它实际上就是增强了
ScheduledExecutorService(1)
的功能,不仅确保只有一个线程顺序执行任务,也保证线程意外终止后会重新创建一个线程继续执行任务。
注意:在阿里巴巴Java开发手册中也明确指出,而且用的词是『不允许』使用
Executors
创建线程池。
何时建议使用线程池?
何时可以使用线程池,这里有个简单的公式对比:
假如T1为 创建线程时间,T2 为在线程中执行任务的时间,T3 为销毁线程时间。那么当T1 + T3 远大于 T2,则可以采用线程池,以提高服务器性能。
换句话说,如果出现了频繁的创建和销毁线程过程,但是每次线程执行的任务都比较简单快速,就强烈建议使用线程池,这种情况完美符合了线程池的使用场景,可以最大限度地提升程序的性能。