前言
线程池可以说是 Java 进阶必备的知识点了,也是面试中必备的考点,可能不少人看了一些文章后能对线程池工作原理说上一二,但这还远远不够,如果碰到比较有经验的面试官再继续追问,很可能会被吊打,考虑如下问题:
- Tomcat 的线程池和 JDK 的线程池实现有啥区别, Dubbo 中有类似 Tomcat 的线程池实现吗?
- 我司网关 dubbo 调用线程池曾经出现过这样的一个问题:压测时接口可以正常返回,但接口 RT 很高,假设设置的核心线程大小为 500,最大线程为 800,缓冲队列为 5000,你能从这个设置中发现出一些问题并对这些参数进行调优吗?
- 线程池里的线程真的有核心线程和非核心线程之分?
- 线程池被 shutdown 后,还能产生新的线程?
- 线程把任务丢给线程池后肯定就马上返回了?
- 线程池里的线程异常后会再次新增线程吗,如何捕获这些线程抛出的异常?
- 线程池的大小如何设置,如何动态设置线程池的参数
- 线程池的状态机画一下?
- 阿里 Java 代码规范为什么不允许使用 Executors 快速创建线程池?
- 使用线程池应该避免哪些问题,能否简单说下线程池的最佳实践?
- 如何优雅关闭线程池
- 如何对线程池进行监控
相信不少人看了这些问题会有些懵逼
其实这些问题的答案大多数都藏在线程池的源码里,所以深入了解线程池的源码非常重要,本章我们将会来学习一下线程池的源码,相信看完之后,以上的问题大部分都能回答,另外一些问题我们也会在文中与大家一起探讨。
本文将会从以下几个方面来介绍线程池的原理。
- 为什么要用线程池
- 线程池是如何工作的
- 线程池提交任务的两种方式
- ThreadPoolExecutor 源码剖析
- 解答开篇的问题
- 线程池的最佳实践
- 总结
相信大家看完对线程池的理解会更进一步,肝文不易,看完别完了三连哦。
为什么要用线程池
创建线程有三大开销,如下:
1、其实 Java 中的线程模型是基于操作系统原生线程模型实现的,也就是说 Java 中的线程其实是基于内核线程实现的,线程的创建,析构与同步都需要进行系统调用,而系统调用需要在用户态与内核中来回切换,代价相对较高,线程的生命周期包括「线程创建时间」,「线程执行任务时间」,「线程销毁时间」,创建和销毁都需要导致系统调用。2、每个 Thread 都需要有一个内核线程的支持,也就意味着每个 Thread 都需要消耗一定的内核资源(如内核线程的栈空间),因此能创建的 Thread 是有限的,默认一个线程的线程栈大小是 1 M,有图有真相
图中所示,在 Java 8 下,创建 19 个线程(thread #19)需要创建 19535 KB,即 1 M 左右,reserved 代表如果创建 19 个线程,操作系统保证会为其分配这么多空间(实际上并不一定分配),committed 则表示实际已分配的空间大小。
画外音:注意,这是在 Java 8 下的线程占用空间情况,但在 Java 11 中,对线程作了很大的优化,创建一个线程大概只需要 40 KB,空间消耗大大减少
3、线程多了,导致不可忽视的上下文切换开销。
由此可见,线程的创建是昂贵的,所以必须以线程池的形式来管理这些线程,在线程池中合理设置线程大小和管理线程,以达到以合理的创建线程大小以达到最大化收益,最小化风险的目的,对于开发人员来说,要完成任务不用关心线程如何创建,如何销毁,如何协作,只需要关心提交的任务何时完成即可,对线程的调优,监控等这些细枝末节的工作通通交给线程池来实现,所以也让开发人员得到极大的解脱!
类似线程池的这种池化思想应用在很多地方,比如数据库连接池,Http 连接池等,避免了昂贵资源的创建,提升了性能,也解放了开发人员。
ThreadPoolExecutor 设计架构图
首先我们来看看 Executor 框架的设计图
- Executor: 最顶层的 Executor 接口只提供了一个 execute 接口,实现了提交任务与执行任务的解藕,这个方法是最核心的,也是我们源码剖析的重点,此方法最终是由 ThreadPoolExecutor 实现的,
- ExecutorService 扩展了 Executor 接口,实现了终止执行器,单个/批量提交任务等方法
- AbstractExecutorService 实现了 ExecutorService 接口,实现了除 execute 以外的所有方法,只将一个最重要的 execute 方法交给 ThreadPoolExecutor 实现。
这样的分层设计虽然层次看起来挺多,但每一层每司其职,逻辑清晰,值得借鉴。
线程池是如何工作的
首先我们来看下如何创建一个线程池
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(10, 20, 600L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(4096), new NamedThreadFactory("common-work-thread")); // 设置拒绝策略,默认为 AbortPolicy threadPool.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
看下其构造方法签名如下
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { // 省略代码若干 }
要理解这些参数具体代表的意义,必须清楚线程池提交任务与执行任务流程,如下
图片来自美团技术团队
步骤如下
1、corePoolSize:如果提交任务后线程还在运行,当线程数小于 corePoolSize 值时,无论线程池中的线程是否忙碌,都会创建线程,并把任务交给此新创建的线程进行处理,如果线程数少于等于 corePoolSize,那么这些线程不会回收,除非将 allowCoreThreadTimeOut 设置为 true,但一般不这么干,因为频繁地创建销毁线程会极大地增加系统调用的开销。
2、workQueue:如果线程数大于核心数(corePoolSize)且小于最大线程数(maximumPoolSize),则会将任务先丢到阻塞队列里,然后线程自己去阻塞队列中拉取任务执行。
3、maximumPoolSize: 线程池中最大可创建的线程数,如果提交任务时队列满了且线程数未到达这个设定值,则会创建线程并执行此次提交的任务,如果提交任务时队列满了但线池数已经到达了这个值,此时说明已经超出了线池程的负载能力,就会执行拒绝策略,这也好理解,总不能让源源不断地任务进来把线程池给压垮了吧,我们首先要保证线程池能正常工作。
4、RejectedExecutionHandler:一共有以下四种拒绝策略
- AbortPolicy:丢弃任务并抛出异常,这也是默认策略;
- CallerRunsPolicy:用调用者所在的线程来执行任务,所以开头的问题「线程把任务丢给线程池后肯定就马上返回了?」我们可以回答了,如果用的是 CallerRunsPolicy 策略,提交任务的线程(比如主线程)提交任务后并不能保证马上就返回,当触发了这个 reject 策略不得不亲自来处理这个任务。
- DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务。
- DiscardPolicy:直接丢弃任务,不抛出任何异常,这种策略只适用于不重要的任务。
5、keepAliveTime: 线程存活时间,如果在此时间内超出 corePoolSize 大小的线程处于 idle 状态,这些线程会被回收
6、threadFactory:可以用此参数设置线程池的命名,指定 defaultUncaughtExceptionHandler(有啥用,后文阐述),甚至可以设定线程为守护线程。
现在问题来了,该如何合理设置这些参数呢。
首先来看线程大小设置
<<Java 并发编程实战>>告诉我们应该分两种情况
- 针对 CPU 密集型的任务,在有 Ncpu个处理器的系统上,当线程池的大小为 Ncpu + 1 时,通常能实现最优的利用率,+1 是因为当计算密集型线程偶尔由于缺页故障或其他原因而暂停工作时,这个"额外"的线程也能确保 CPU 的时钟周期不会被浪费,所谓 CPU 密集,就是线程一直在忙碌,这样将线程池的大小设置为 Ncpu + 1 避免了线程的上下文切换,让线程时刻处于忙碌状态,将 CPU 的利用率最大化。
- 针对 IO 密集型的任务,它也给出了如下计算公式
这些公式看看就好,实际的业务场景中基本用不上,这些公式太过理论化了,脱离业务场景,仅可作个理论参考,举个例子,你说 CPU 密集型任务设置线程池大小为 N + 1个,但实际上在业务中往往不只设置一个线程池,这种情况套用的公式就懵逼了
再来看 workQueue 的大小设置
由上文可知,如果最大线程大于核心线程数,当且仅当核心线程满了且 workQueue 也满的情况下,才会新增新的线程,也就是说如果 workQueue 是无界队列,那么当线程数增加到 corePoolSize 后,永远不会再新增新的线程了,也就是说此时 maximumPoolSize 的设置就无效了,也无法触发 RejectedExecutionHandler 拒绝策略,任务只会源源不断地填充到 workQueue,直到 OOM。
所以 workQueue 应该为有界队列,至少保证在任务过载的情况下线程池还能正常工作,那么哪些是有有界队列,哪些是无界队列呢。
有界队列我们常用的以下两个
- LinkedBlockingQueue: 链表构成的有界队列,按先进先出(FIFO)的顺序对元素进行排列,但注意在创建时需指定其大小,否则其大小默认为 Integer.MAX_VALUE,相当于无界队列了
- ArrayBlockingQueue: 数组实现的有界队列,按先进先出(FIFO)的顺序对元素进行排列。
无界队列我们常用 PriorityBlockingQueue 这个优先级队列,任务插入的时候可以指定其权重以让这些任务优先执行,但这个队列很少用,原因很简单,线程池里的任务执行顺序一般是平等的,如果真有必须某些类型的任务需要优先执行,大不了再开个线程池好了,将不同的任务类型用不同的线程池隔离开来,也是合理利用线程池的一种实践。
说到这我相信大家应该能回答开头的问题「阿里 Java 代码规范为什么不允许使用 Executors 快速创建线程池?」,最常见的是以下两种创建方式
image-20201109002227476
newCachedThreadPool 方法的最大线程数设置成了 Integer.MAX_VALUE,而 newSingleThreadExecutor 方法创建 workQueue 时 LinkedBlockingQueue 未声明大小,相当于创建了无界队列,一不小心就会导致 OOM。
threadFactory 如何设置
一般业务中会有多个线程池,如果某个线程池出现了问题,定位是哪一个线程出问题很重要,所以为每个线程池取一个名字就很有必要了,我司用的 dubbo 的 NamedThreadFactory 来生成 threadFactory,创建很简单
new NamedThreadFactory("demo-work")
它的实现还是很巧妙的,有兴趣地可以看看它的源码,每调用一次,底层有个计数器会加一,会依次命名为 「demo-work-thread-1」, 「demo-work-thread-2」, 「demo-work-thread-3」这样递增的字符串。
在实际的业务场景中,一般很难确定 corePoolSize, workQueue,maximumPoolSize 的大小,如果出问题了,一般来说只能重新设置一下这些参数再发布,这样往往需要耗费一些时间,美团的这篇文章给出了让人眼前一亮的解决方案,当发现问题(线程池监控告警)时,动态调整这些参数,可以让这些参数实时生效,能在发现问题时及时解决,确实是个很好的思路。