Java基础——线程池

Java支持多线程,虽然启动一个新线程很容易,但是创建线程需要向操作系统申请资源,消耗大量时间,因此便有了线程池。线程池是池化技术的一种使用,池化技术指的是提前准备一定的资源,只要池中有足够的资源,在需要时可以随时从池中取出资源使用,使用完后将资源归还到池中。池化技术带来了显著的优点:主要是可重复使用资源,避免动态创建资源、释放资源带来的额外开销和等待时间。在服务器资源有限的情况下,使用池化技术可以提高资源的利用率,从而提升性能。池化技术比较典型的用途有:连接池、线程池、内存池、对象池。

线程池继承关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                           Executor(I)
|
ExecutorService(I)
|
-------------------------------------
| |
AbstractExecutorService(C) ScheduledExecutorService(I)
| |
------------------------ |
| | |
ForkJoinPool(C) ThreadPoolExecutor(C) |
| |
------------------------
|
ScheduledThreadPoolExecutor(C)

所有的线程池最终都实现自 ExecutorService 接口,线程池可分为带定时调度的线程池和普通线程池。

线程池的使用

使用线程池时,可配置的选项有 线程名称、核心线程数、最大线程数、队列容量、空闲时间、空闲时间的时间单位、拒绝策略 等。

线程名称可自行指定用于调试、日志时方便区分,也可以使用 Google Guava提供的 ThreadFactory 来构建带数字后缀的线程。

线程池中至少会有一个队列用于线程繁忙时暂存工作任务,等待线程空闲后执行。

核心线程数是常驻的线程,在存储任务信息的队列未满之前,线程池中的线程数量等于核心线程数;当添加一个任务时,核心线程数都在工作中,但线程池中的线程数量还没达到最大线程数,存储信息的工作队列已满的情况下,会新建一个临时线程执行任务,新建的临时线程经过空闲时间没有分配任务则会被回收。

线程池使用完成后如果不需要重复使用,必须调用 shutdown() 方法关闭线程池,工程中通常会将线程池注册为 Spring Bean 常驻内存,随时取用,此时就不需要关闭线程池。

  • ThreadPoolExecutor

ThreadPoolExecutor 是常用的线程池,线程池中所有的线程共用一个队列。

1
2
3
4
5
6
7
public ThreadPoolExecutor newExecutor() {
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
.setNameFormat("thread-name-%d")
.build();
return new ThreadPoolExecutor(corePoolSize(), maxPoolSize(), keepAliveSeconds(), TimeUnit.SECONDS,
new LinkedBlockingQueue<>(queueCapacity()), namedThreadFactory);
}
  • ForkJoinPool

ForkJoinPool 线程池使用分治算法,用相对少的线程处理大量的任务,将一个大任务一拆为二,以此类推,每个子任务再拆分一半,直到达到最细颗粒度为止,即设置的阈值停止拆分,然后从最底层的任务开始计算,往上一层一层合并结果。最适合计算密集型任务,而且最好是非阻塞任务。与 ThreadPoolExecutor 稍有不同的地方是 ForkJoinPool 的每个线程都有各自独立的双端队列(Deque),当某个线程的任务队列中没有可执行任务的时候,从其他线程的任务队列尾部窃取任务来执行,以充分利用工作线程的计算能力,减少线程由于获取不到任务而造成的空闲浪费。JDK8 Stream 中的 parallelStream() 是基于此线程池实现的,CompletableFuture 的异步回调 future 内部使用的线程池也是 ForkJoinPool。

  • ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutor 是一个带定时调度功能的线程池.

1
2
3
4
5
6
7
8
9
10
// 只在delay的时间后执行一次
scheduledThreadPool.schedule(command, delay, TimeUnit.SECONDS);

// 采用已固定的频率周期执行任务。该方法设置了执行周期,下一次执行时间相当于是上一次的执行时间加上period,必须等上个任务执行完毕,下个任务才能开始执行
// 例如 每2秒执行一次,20:00:00 触发任务,20:00:03才执行完成,但 20:00:04 就会触发下一次任务
scheduledThreadPool.scheduleAtFixedRate(command, initialDelay, period, TimeUnit.SECONDS);

// 采用相对固定的延迟来执行任务,该方法设置了执行周期,下一次执行时间是上一次任务执行完的系统时间加上period,具体执行时间不是固定的,但周期是固定的
// 例如 每2秒执行一次,20:00:00 触发任务,20:00:03才执行完成,需要等到 20:00:05 就会触发下一次任务
scheduledThreadPool.scheduleWithFixedDelay(command, initialDelay, period, TimeUnit.SECONDS);
  • Executors

JDK 提供了 Executors 类来快速创建线程池,但 Executors 创建的线程池并没有定义暂存队列的容量,默认队列是无界队列,最多可容纳 Integer.MAX_VALUE 的任务信息,可能导致应用出现OOM的情况,因此在工程中最好手动创建线程池。

线程池拒绝策略

当线程池线程数已满,并且工作队列达到限制,新提交的任务使用拒绝策略处理。可以自定义拒绝策略,拒绝策略需要实现 RejectedExecutionHandler 接口。

JDK默认的拒绝策略有四种:

  1. AbortPolicy:丢弃任务并抛出 RejectedExecutionException 异常。
  2. DiscardPolicy:丢弃任务,但是不抛出异常。可能导致无法发现系统的异常状态。
  3. DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务。
  4. CallerRunsPolicy:任务将转回发起调用的线程完成,可能会阻塞发起调用的线程。

线程池中线程数量应该如何设置

线程池中的线程数量需要根据实际情况恰当设置,如果线程数量太多会导致频繁的上下文切换,影响线程的运行效率,上下文切换时线程暂停的时间用于执行任务可能任务已经结束了;如果线程数量太少则无法充分的利用处理器的多核心。理想的线程数取决于处理器的核心数,在理想情况下,每个核心运行一个线程是最高效的,线程池只需要设置为处理器的核心数量即可。实际上线程数的设定需要根据应用程序的需求和运行环境来决定,没有一个固定的最佳值。

如果是CPU密集型的任务,可将线程数设置为处理器核心数量的1到1.5倍(如N+1);如果是IO密集型任务,因为任务涉及到大量的阻塞等待,可以配置2倍或者更多的数量(如2N+1)。相关的公式仅供参考,可在业务部署时先设置为公式的最大值,然后进行压力测试,根据压力测试的反馈动态调整线程数量。