线程池-原理篇

前言

好久没更新文章了,不是去偷懒了,而是自己又开了一个课题(Spring)。然后学习的过程中心态被搞爆炸了,所以写写其他的东西缓解一下心情。等 Spring 整理完了,会出相应的文章。

线程池背景

一个应用,我们肯定要多次使用线程,那也就意味着我们要多次创建销毁线程。而创建并销毁线程的过程势必会消耗内存。而在 Java 中,内存资源是及其宝贵的,所以,我们就提出了线程池的概念。

当然,使用线程池可以带来一系列好处:

  • 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。
  • 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。

线程池的参数结构

要想用好线程池,我们一定要了解它的底层设计。那么今天,让我和大家一起来梳理一下吧。

通过源码我们可以发现线程池系列都是套娃 ThreadPoolExecutor 方法的。所以搞清楚 ThreadPoolExecutor 才是弄懂线程池的关键。

    public ThreadPoolExecutor(int corePoolSize, //核心线程数限制
                              int maximumPoolSize,//最大线程限制
                              long keepAliveTime,// 空闲线程存活时间
                              TimeUnit unit, // 时间单位
                              BlockingQueue<Runnable> workQueue,// 任务队列
                              ThreadFactory threadFactory,// 线程工厂
                              RejectedExecutionHandler handler) {// 拒绝策略   
复制代码

这 7 个参数请务必记下来。
下面这张图是线程池的运行机制:

image.png
结合这张图,我们来进一步了解各个参数的含义。

线程数

线程分为核心线程和非核心线程,非核心线程数 = 最大线程数 – 核心线程数
简单帮大家梳理一下线程池的工作流程:

  1. 如果有任务,首先要分配给核心线程的。
  2. 如果核心线程没有空闲,都有活干,那么再存到阻塞队列
  3. 如果阻塞队列满了,再分配给最非核心线程
  4. 如果非核心线程没有空闲则采取拒绝策略

空闲线程存活时间

keepAliveTime, 空闲线程存活时间,当前线程池数量超过 corePoolSize 时,当空闲时间达到 keepAliveTime 值

任务队列 BlockingQueue

BlockingQueue不能够添加null对象,否则会抛出空指针异常。

常见的任务队列有

  • LinkedBlockingQueue

       基于链表结构的阻塞队列,按FIFO排序任务,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。

  • ArrayBlockingQueue

       是一个用数组实现的有界阻塞队列,按FIFO排序量。

  • SynchronousQueue

       同步队列,一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态

  • PriorityBlockingQueue

       是带排序的 BlockingQueue 实现,队列为无界队列

记住我标粗的内容,后面会有大用。

线程工厂

在使用线程池时,强烈推荐使用自己定义的线程工厂,这样能为线程池中的线程进行命名,方便跟大家使用 jsatck 命令查看线程栈时,能快速识别对应的线程。

拒绝策略

拒绝策略一共有四种 AbortPolicy,CallerRunsPolicy,DiscardOldestPolicy 和 DiscardPolicy。那么我们分别介绍一下这四种拒绝策略:

  • AbortPolicy:默认策略,如果当前线程池的状态不是 RUNNING,队列已满,当前线程最大数也达到了最大值,再来任务会抛异常。
  • CallerRunsPolicy:此拒绝策略由调用线程(提交任务的线程)直接执行被丢弃的任务。
  • DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务。
  • DiscardPolicy:丢弃任务,但是不抛出异常。如果线程队列已满,则后续提交的任务都会被丢弃,且是静默丢弃。

如何合理地配置线程池参数

现在去百度会有两个分别关于 IO 密集型和 CPU 密集型的计算公式,但我看了很多大佬博客发现他们都不是很赞同这样的观点,所以最后的结论是:以本人 4 核机器:

线程数 = CPU核心数/(1-阻塞系数)

阻塞系数通常在(0.8 ~ 0.9)。阻塞系数取 0.8 ,线程数为 20 。阻塞系数取 0.9,大概线程数40,20个线程数我觉得可以。下图是美团到店研发所用的线程池参数,我们可以借鉴一下。

image.png

值得注意:所谓 CPU 密集型,是指纯计算类,例如计算圆周率的位数,当然我们基本接触不到,我们接触的业务还是要通过请求和数据库,中间件等去进行关联。如果非要说 CPU 密集型,那么 CPU 密集型不宜设置过多线程,因为是会造成线程切换,反而损耗性能。比上面公式得出的结果小一些就可以吧。(这里确实没找到合适资料,毕竟参数设置还是需要根据具体业务来判定)

顺便说下,如何得到当前服务器核心线程数:

System.out.println(Runtime.getRuntime().availableProcessors());
复制代码

本人电脑结果为 4 核:

image.png

简单入门

我们都知道,线程池一共有 5 种,其中 2 种为定时类型的,不是我们这次讨论的目标。这次主要探讨另三种,分别为 newFixedThreadPool,newSingleThreadExecutor 和 newCachedThreadPool。先看一个简单小例子。

public class ThreadPoolDemo {
    public static void main(String[] args) {
        ExecutorService es1 = Executors.newCachedThreadPool();
        ExecutorService es2 = Executors.newFixedThreadPool(10);
        ExecutorService es3 = Executors.newSingleThreadExecutor();
        es1.execute(new MyTask(i));
        //es2.execute(new MyTask(i));
        //es3.execute(new MyTask(i));
    }

}
class MyTask implements Runnable {
    int i = 0;
    public MyTask(int i) {
        this.i = i;
    }
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
复制代码

了解完小例子,我们分别介绍一下这三种线程池。

newCachedThreadPool

public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                    60L, TimeUnit.SECONDS,
                                    new SynchronousQueue<Runnable>());
    }
复制代码

       通过源代码我们可以看出 newCachedThreadPool 的 核心线程数为 0,最大线程数为 Integer.MAX_VALUE(即 2^31 – 1 = 2147483647,21亿多,没有哪台机器可以承受吧,所以可以理解为不受限制),然后线程的生命时长为 60s 重点是任务队列为 BlockingQueue。

       如图我们可以看出,线程池中的所有线程都是在非核心线程区创建的(由于核心线程区为 0),相当于假如我有100份工作,而我又能找到 100 个工人帮我去做,那么很快就可以完成,但我们又知道,线程数和 cpu 息息相关。由于创建线程没有限制,那么所有的线程都在非核心线程区创建,CPU 会消耗大量性能去创建线程,所以 newCachedThreadPool 极有可能造成 CPU 100%。

newCachedThreadPool.png

newFixedThreadPool

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads, 
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}
复制代码

       核心线程数和最大线程数都是传入参数,空闲线程存活时间为 0s(keepAliveTime 为非核心线程使用完后会多久回收,由于这里没有设置非核心线程数,所以线程使用完不回收。),注意这个任务队列为 LinkedBlockingQueue。LinkedBlockingQueue 内部基于链表来存放元素,它如果不指定容量,默认为 Integer.MAX_VALUE,也就是无界队列。(通常用的时候会设置大小,而这里没设置,为默认值。)。但是 newFixedThreadPool 有个问题,由于没有设置 LinkedBlockingQueue 的初始大小,所以它会无限大,极有可能会造成 OOM。

newFixedThreadPool.png

newSingleThreadExecutor

public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
      (new ThreadPoolExecutor(1, 1,
                              0L, TimeUnit.MILLISECONDS,
                              new LinkedBlockingQueue<Runnable>()));
}
复制代码

       除了核心线程数和最大线程数为 1 以外,其他的和 newFixedThreadPool 一样。也就是说它是一个任务一个任务的进行,所以 newSingleThreadExecutor 是三个里最慢的。当然了,newSingleThreadExecutory 依然有发生 OOM 的问题。

阿里开发手册强制使用自定义线程池。
image.png

线程池的状态

       聊完了参数和用法,我们再聊聊线程池的状态。线程池有五种状态 RUNNING、SHOUTDOWN、STOP、TIDYING 和 TERMINATED.

image.png
       我们简单聊一下 showdown 和 shutdownNow 方法吧,当我们任务队列中还有任务时,如果调用 showdown 方法,我们会将线程池中和工作队列中的任务都执行完再转换为 TIDYING 状态,再结束,比较友好;而 shutdownNow 只会将当前线程池中的任务执行完毕,不会再管工作队列中是否还有任务,就结束,比较暴力些。

总结

       本篇我和大家一起简单了解一下线程池的原理,其中线程池的配置参数尤为重要,如何合理的配置也需要格外关心。同时,有简单展示创建线程池,以及线程池原生 api 的底层逻辑,并叙述了为什么不推荐使用原生 api 而使用自定义线程池。下图是线程池的工作流程,简单明了。源码会在下一篇展示,马上奉上。
image.png

站在巨人肩膀

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享