线程池ThreadPoolExecutor总结

之前在源码|从串行线程封闭到对象池、线程池中挖坑说要精炼一篇短文。本文填坑,总结线程池的种类、应用场景、ThreadPoolExecutor参数含义,最后简单介绍如何估算线程池大小。

JDK版本:oracle java 1.8.0_102

不同语言、同一语言不同库的线程池实现有差别,不要拘泥于Java这一种,没事看看work stealing等方式也挺有意思的。

三句话拿下线程池

线程池内部是一个生产者-消费者模型:

  • 用户是生产者。提交任务(task)相当于生产产品。
  • 线程池中的线程(worker)是消费者,执行任务相当于消费产品。

详见源码|从串行线程封闭到对象池、线程池

线程池的种类

可通过Executors中的静态工厂方法创建不同特点的线程池,包括:

  • FixedThreadPool:维持固定nThreads个线程的线程池;使用无界的异步阻塞队列LinkedBlockingQueue作为任务队列。
  • CachedThreadPool:维持最少0、最多Integer.MAX_VALUE个线程的线程池,限制线程可缓存60s,超时销毁;使用无界的同步队列SynchronousQueue作为任务队列。
  • SingleThreadExecutor:维持固定1个线程的FixedThreadPool。
  • ScheduledThreadPool:维持固定corePoolSize个线程的线程池;使用无界的延迟队列DelayedWorkQueue作为任务队列。
  • SingleThreadScheduledExecutor:维持固定1个线程的ScheduledThreadPool。
  • WorkStealingPool:并行度为parallelism的ForkJoinPool,暂不讨论。

忽略WorkStealingPool,则其他线程池底层都使用了ThreadPoolExecutor,只是参数不同。

应用场景

线程池就像水管,任务是水。

  • 如果期望水管出水的速度固定,就使用FixedThreadPool。
  • 如果期望水管出水的速度可以在水流大时增大,水流小时变小,就使用CachedThreadPool。
  • 如果期望水管出水速度恒定为1,就使用SingleThreadExecutor。
  • 如果期望水管延迟出水(延迟可控,或周期性),就使用ScheduledThreadPool。

ThreadPoolExecutor各参数的意义

  • corePoolSize:线程池维持的最小线程数(ScheduledThreadPool的该参数设计有误),懒初始化。
  • maximumPoolSize:线程池维持的最大线程数。
  • keepAliveTime,unit:如果线程池中的线程数大于corePoolSize小于等于maximumPoolSize,则选择闲置时间超过keepAliveTime(单位为unit)的线程销毁,直到无线程可销毁或线程数等于corePoolSize。
  • workQueue:应该被称为taskQueue,维护提交到线程池的task。如果设置为无界队列,则线程数量将维持为corePoolSize;否则,当队列满时,尝试增加线程数直到maximumPoolSize或队列不满。
  • threadFactory:任务以Runnable、Callable的形式提交,生产线程时使用threadFactory的工厂方法。默认Executors.defaultThreadFactory()
  • handler:如果workQueue是有界的,那么当workQueue满时,使用handler处理无法提交的新任务。默认Executors.defaultHandler,workQueue满时抛出RejectedExecutionException。

观察corePoolSize、maximumPoolSize、workQueue三者的关系可知:

  • 如果使用有界线程池,则最好搭配有界队列,否则maximumPoolSize参数无效。
  • 相对的,CachedThreadPool被设计为一种maximumPoolSize无效的缓冲池,同时,因此,必须使用无界的同步队列,让“入队”直接变成“执行”。

线程池大小的估算

最简化公式

  • CPU 密集型应用:线程池大小设置为 N + 1
  • IO 密集型应用:线程池大小设置为 2N

公式的意义在于避免陷入极端情况。其中,计算密集型任务假设“等待时间/计算时间”等于0,IO密集型任务假设“等待时间/计算时间”等于1。

为什么要有+1呢?

这是因为,就算是计算密集型任务,也可能存在缺页等问题(需要了解虚拟内存和物理内存的分配),产生“隐式”的IO。多一个额外的线程能确保CPU时钟周期不会被浪费,又不至于增加太多线程调度成本。

严格公式:

假设每个线程的“等待时间 / 计算时间”大小相等,显然,“计算时间 / (计算时间 + 等待时间)”也相等。对1个线程而言,只有计算时间占用了逻辑CPU,假设这个线程一直运行在同1个逻辑CPU上,显然,该逻辑CPU的CPU利用率即等于“计算时间 / (计算时间 + 等待时间)”。

对于多个线程的情况是一样的,则有公式:

1
逻辑CPU数 * CPU利用率 / 线程数 = 计算时间 / (计算时间 + 等待时间)

倒腾倒腾,得到:

1
线程数 = (1 + 等待时间/计算时间) * 逻辑CPU数 * CPU利用率
  • 1 + 等待时间/计算时间” 只与任务本身有关。
  • 逻辑CPU数可通过cat /proc/cpuinfo | grep -c processor得到。
  • 标准的CPU利用率要通过实际监控得到,但在估算线程池大小时,应看做“期望得到的CPU利用率”,即可分配给该任务的CPU比例。如果只打算分配一半CPU给任务的话,就是0.5。

如果估算得到的线程数比较多,那么还要适当提高可分配的CPU比例,因为线程切换的成本随线程数增加而增加。如果竞争较激烈,则可以适当降低可分配的CPU比例,因为竞争通常也会导致线程阻塞,使CPU空闲。

扫描微信关注我
微信公众号二维码
本文链接:线程池ThreadPoolExecutor总结
作者:猴子007
出处:https://monkeysayhi.github.io
本文基于 知识共享署名-相同方式共享 4.0 国际许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名及链接。
我是猴子007,<br>一只非常特殊的动物,<br>可以从事程序的开发、维护,<br>经常因寻找香蕉或母猿而无心工作。