JAVA线程池详解

JAVA线程池详解

随着计算机技术的飞速发展,摩尔定律失效,多核CPU成为了主流;越来越多的开发者使用多线程技术来提升服务器性能,而线程池的使用方式也变得至关重要。

什么是线程池

线程池(thread pool):一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。

为什么要使用线程池

如果没有线程池,我们需要额外的线程来执行任务时,就需要手动创建线程,从而产生一些资源管理的问题:

  1. 频繁申请和销毁线程资源,会带来额外的损耗;
  2. 对线程资源的申请没有限制,可能会出现资源耗尽的情况;
  3. 手动创建的线程不利于系统管理,系统稳定性降低。

而为了解决线程资源分配的问题,我们就需要线程池来进行线程资源的管理。

设计与实现

JAVA中线程池的核心实现类是ThreadPoolExecutor,下面是ThreadPoolExecutor的UML类图:

Executor作为ThreadPoolExecutor的顶层接口提供了一种设计思想:将任务提交和任务执行进行解耦,用户无需关心线程是如何执行和调度的,只需要将实现了Runnable接口的任务对象提交到执行器,然后执行器会完成线程的调度以及任务的执行;

1
2
3
public interface Executor {
void execute(Runnable command);
}

ExecutorService接口提供了一些额外的能力:

  1. 提供了管控线程池的方法,包括一系列的停止线程池的方法;
  2. 新增了可执行Callable任务的方法,以及Future接口来获取异步计算的结果。

AbstractExecutorService则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可;

ThreadPoolExecutor是线程池最复杂的运行部分,维持了自身的生命周期,实现了线程和任务的管理。

ThreadPoolExecutor 类分析

ThreadPoolExecutor类中提供了四个构造方法,我们直接看最长的那个即可,其他三个都是以这个构造方法为基础产生的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
if (corePoolSize < 0 ||
maximumPoolSize <= 0 ||
maximumPoolSize < corePoolSize ||
keepAliveTime < 0)
throw new IllegalArgumentException();
if (workQueue == null || threadFactory == null || handler == null)
throw new NullPointerException();
this.corePoolSize = corePoolSize;
this.maximumPoolSize = maximumPoolSize;
this.workQueue = workQueue;
this.keepAliveTime = unit.toNanos(keepAliveTime);
this.threadFactory = threadFactory;
this.handler = handler;
}

ThreadPoolExecutor的三个核心参数:

  • corePoolSize:核心线程池的数量,定义了最小可同时运行的线程数;
  • maximumPoolSize:最大线程池容量,定义线程池可同时运行的最大线程数;
  • workQueue:任务列队,当线程池中的核心线程都被使用时,新来的任务会被推入任务列队,此处可以定义列队的长度;

其他参数:

  • keepAliveTime:保持时间,当线程池中有非核心的空闲线程时,这些线程不会被立即销毁,而是会等待一段时间之后被销毁,这个时间就是保持时间;
  • unitkeepAliveTime的时间单位;
  • threadFactory:线程工厂,一般使用默认即可;
  • handler:饱和策略,当线程池中所有的线程都被使用,且任务列队也满了,这时候需要根据饱和策略来拒绝新来的任务或者调用主线程执行;这里ThreadPoolExecutor提供了四种基础的策略,我们也可以通过实现RejectedExecutionHandler接口自定义策略。

ThreadPoolExecutor 运行机制

ThreadPoolExecutor 运行机制图:

线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程;

线程池的运行主要分成两部分:任务管理、线程管理。任务管理部分充当生产者的角色;

当任务提交后,线程池会判断该任务后续的流转:

(1)直接申请线程执行该任务;

(2)缓冲到队列中等待线程执行;

(3)拒绝该任务。线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。

其他常用的线程池

除了上面讲的ThreadPoolExecutor 线程池,JDK还提供了Executors工具类,用来创建其他几种线程池。

FixedThreadPool

FixedThreadPool 被称为可重用固定线程数的线程池,让我们来看一下他是如何创建的:

1
2
3
4
5
6
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory);
}

可以看到corePoolSizemaximumPoolSize都被设置成了相同的值,通过nThreads传入;

这里不推荐使用FixedThreadPool,因为这里的等待列队是一个无边界的列队,所以运行中的线程池不会拒绝任务,而是无限的塞入等待列队中,最终会导致OOM。

SingleThreadExecutor

SingleThreadExecutor是只有一个线程的线程池,让我们来看看实现:

1
2
3
4
5
6
7
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(),
threadFactory));
}

这个线程池也不推荐使用,原因和上面的一样。

CachedThreadPool

CachedThreadPool 是一个会根据需要创建新线程的线程池,它的初始线程数为0,让我们来看看实现:

1
2
3
4
5
6
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
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。
作者

ero

发布于

2022-02-15

更新于

2022-06-11

许可协议

评论