1. 使用线程池的好处
- 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程可以进行统一的分配、调优和监控。
2. 线程池实现类 ThreadPoolExecutor 介绍
2.1 Executor 接口的设计
首先来看 Executor 框架的设计图:
- Executor:最顶层的 Executor 接口只提供了一个 execute 接口,实现了提交任务与执行任务的解耦,这个方法是最核心的,此方法最终是由 ThreadPoolExecutor 实现的。
- ExecutorService:扩展了 Executor 接口,实现了终止执行器,单个/批量提交任务等方法。
- AbstractExecutorService:实现了 ExecutorService 接口,实现了除 executor 以外的所有方法,只将一个最重要的 execute 方法交给 ThreadPoolExecutor 实现。
这样的分层设计虽然层次看起来挺多,但每一层各司其职,逻辑清晰,值得借鉴。
2.2 ThreadPoolExecutor 实现类的几个重要参数
线程池实现类ThreadPoolExecutor
是Executor
框架最核心的类。它的构造方法中有几个重要参数:
- corePoolSize(线程池基本大小):当提交一个任务到线程池时,如果当前
运行线程数 < corePoolSize
时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建。需要注意的是,在线程池刚创建时,里面并没有建好的线程,只有当有任务来的时候才会创建。 - workQueue(任务队列):当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,线程就会被存放在队列中。
- maximumPoolSize(线程池最大数量):当任务提交时,
corePoolSize < 运行线程数 <= maximumPoolSize
且队列没有满时,任务提交到队列中。如果队列满了,在 maximumPoolSize 允许的范围内新建线程。如果线程数量达到 maximumPoolSize,还有任务要增加进来的时候,就需要按照拒绝策略来处理(或者丢弃新任务,或者拒绝新任务,或者挤占已有的任务)。 - ejectedExecutionHandler(拒绝策略):
- AbortPolicy(默认):表示拒绝任务并抛出一个异常
RejectedExecutionException
。这个我称之为“正式拒绝”,比如你面完了最后一轮面试,最终接到 HR 的拒信。 - CallerRunsPolicy:直接调用线程处理该任务,就是 VIP 嘛。
- DiscardOldestPolicy:抛弃队列中等待最久的任务,然后将当前任务增加进去。
- DiscardPolicy:拒绝任务但不吭声。这个就是“默拒”,比如大部分公司拒简历的时候都是默拒。
- AbortPolicy(默认):表示拒绝任务并抛出一个异常
- keepAliveTime(线程存活时间):如果在此时间内超出 corePoolSize 大小的线程处于 idle 状态,这些线程会被回收。
threadFactory:可以用次参数设置线程池的命名,指定defaultUncaughtExceptionHandler,甚至可以设定线程为守护线程。
2.3 为什么推荐使用 ThreadPoolExecutor 创建线程池?
创建线程池的方法有两种:
通过 ThreadPoolExecutor 的构造方法。
- 通过 Executor 框架的工具类 Executors。
使用 ThreadPoolExecutor 的构造函数创建线程池可以让我们更加明确线程池的运行规则,规避资源耗尽的风险。使用 Executors 工具类中的方法(FixedThreadPool、SingleThreadPool、CachedThreadPool、ScheduledThreadPool)返回线程池对象其实最后也是调用了 ThreadPoolExecutor ,而且这样做会导致 OOM 的问题:
- FixedThreadPool 和 SingleThreadPool:允许任务队列 workQueue 的长度为
Integer.MAX_VALUE
,可能堆积大量的请求,从而导致 OOM。 - CachedThreadPool 和 ScheduledThreadPool:允许线程池最大数量 maximumPoolSize 为
Integer.MAX_VALUE
,可能会创建大量线程,从而导致 OOM。
说白了就是:使用有界队列,控制线程创建数量。除了避免 OOM 的原因之外,不推荐使用Executors
提供的两种快捷的线程池的原因还有:
- 实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。
- 我们应该显示地给我们的线程池命名,这样有助于我们定位问题。
3. 线程池中的线程数量怎么设置?
如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。但是,如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
因此,设置线程池中的线程数量需要具体问题具体分析:
- CPU 密集型任务
**N+1**
:这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+ 1,比 CPU 核心数多出来的一个线程是为了防止线程偶尔发生的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。 - I/O 密集型任务
**N * (1 + WT/CT)**
:这种情况下就稍微复杂一些了,需要利用性能测试工具评估出用在 I/O 等待上的时间,这里记为 WT(wait time),以及CPU计算所需要的时间,这里记为 CT(computing time),那么对于一个 N 核的系统,合适的线程数大概是N * (1 + WT/CT)
,假设 I/O 等待时间和计算时间相同,那么大概需要2N
个线程才能充分利用 CPU 资源,注意这只是一个理论值,具体设置多少需要根据真实的业务场景进行测试。 - 混合型任务:如果可以拆分,需要拆分成 IO 密集型和 CPU 密集型放到不同的线程池中进行处理。但前提是两者运行的时间是差不多的,如果处理时间相差很大,则没必要拆分了。
- 并发高、业务执行时间长的任务:这种情形单纯靠线程池解决方案是不合适的,即使服务器有再高的资源配置,每个任务长周期地占用着资源,最终服务器资源也会很快被耗尽。因此对于这种情况,应该配合业务解耦,做些模块拆分优化整个系统结构。
4. 线程池提交任务的两种方式
线程池创建好了,该怎么给它提交任务呢?有两种方式,调用 execute 和 submit 方法,来看下这两个方法的方法签名: ```java // 方式一:execute 方法
public void execute(Runnable command) {
}
// 方式二:ExecutorService 中 submit 的三个方法
Future submit(Callable task);
Future submit(Runnable task, T result);
Future submit(Runnable task);
```
- execute() 方法用于将不需要返回值的任务提交给线程池执行,所以无法判断任务是否被线程池执行成功与否。
- submit() 方法用于将需要返回值的任务提交给线程池执行。线程池会返回一个 Future 类型的对象。可以用 Future 取消任务,判断任务是否已取消/完成,甚至可以阻塞等待结果。通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get() 方法来获取返回值,get() 方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit) 方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。
具体的测试代码可见 剑指offer.test.ThreadPool.java。
6. Runnable 和 Callable 的区别?
Runnable
自 Java 1.0 以来一直存在,但Callable
仅在 Java 1.5 中引入,目的就是为了来处理Runnable
不支持的用例。Runnable
接口不会返回结果或抛出检查异常,但是Callable
接口可以。
工具类Executors
可以实现Runnable
对象和Callable
对象之间的相互转换。(Executors.callable(Runnable task)
)。
7. 线程池使用的最佳实践
使用线程池前需要考虑:
- 充分理解你的任务,是长任务还是短任务、是CPU密集型还是I/O密集型,如果两种都有,那么一种可能更好的办法是把这两类任务放到不同的线程池中,这样也许可以更好的确定线程数量。
- 如果线程池中的任务有I/O操作,那么务必对此任务设置超时,否则处理该任务的线程可能会一直阻塞下去。
- 线程池中的任务最好不要同步等待其它任务的结果。
- 尽量不要所有业务都共用一个线程池,需要考虑『线程池隔离』。就是不同的关键业务,分配不同的线程池,然后线程池参数也要考虑恰当。
8. 线程池源码学习
线程池其实看懂了也很简单参考