线程池的总结和理解

1.为什么要使用线程池

其实这个问题和数据库连接池、字符串常量一样,都是使用了池化技术,池化技术可以降低每次重复创建和销毁的开销,尤其是对于线程或者数据库连接这种大对象,使用池化技术是非常必要的,如果并发的线程数量比较多,每个线程只是执行一个时间很短的任务就结束了,这样频繁的创建和销毁线程会大大降低系统的性能

那么下面来详细说一下线程池的好处:

  • 降低资源消耗:通过重复利用已创建的线程来降低线程创建和销毁的消耗
  • 提高响应速度:当任务到达时,不必等待创建新的线程,任务到达可以立即执行
  • 提高系统的管理性:线程是一个很大的对象,不能无限制的创建,需要使用线程池来进行统一的控制

2.线程池基本结构

image.png

线程池的工作模型主要两部分组成,一部分是运行Runnable的Thread对象,另一部分就是阻塞队列。

由线程池创建的Thread对象其内部的run方法会通过阻塞队列的take方法获取一个Runnable对象,然后执行这个Runnable对象的run方法(即,在Thread的run方法中调用Runnable对象的run方法)。当Runnable对象的run方法执行完毕以后,Thread中的run方法又循环的从阻塞队列中获取下一个Runnable对象继续执行。这样就实现了Thread对象的重复利用,也就减少了创建线程和销毁线程所消耗的资源。

当需要向线程池提交任务时会调用阻塞队列的offer方法向队列的尾部添加任务。提交的任务实际上就是是Runnable对象或Callable对象。

3.Java中的ThreadPoolExecutor类

java.uitl.concurrent.ThreadPoolExecutor类是线程池中最核心的一个类,如果我们想要深入理解线程池,就一定要熟悉这个类

ThreadPoolExecutor类提供了四个构造方法:

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), defaultHandler);
    }
 public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             threadFactory, defaultHandler);
    }
public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              RejectedExecutionHandler handler) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), handler);
    }
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.acc = System.getSecurityManager() == null ?
                null :
                AccessController.getContext();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }

仔细观察源码我们可以看到,前三个构造方法最终都是调用了第四个构造方法,也就是参数最多的那个构造方法,这种情况在JDK底层实在是太常见了,往往存在着其他构造方法调用参数最齐全的那一个,而一些没有指定的参数就直接作为默认的了

下面来分别解释一下最全面的构造方法中,每个参数的意义,一共有一个参数:

  • corePoolSize:线程池中常驻核心线程数,当线程池中线程数目达到corePoolSize之后,就会把把之后到达的任务放在阻塞队列中进行排队
  • maximumPoolSize:线程池的最大容量
  • keepAliveTime:一般情况下,只有线程池中的线程数大于corePoolSize之后这个参数才会起作用,表示空闲线程没有任务执行时,最多保持多长时间会终止
  • unit:unit:参数keepAliveTime的时间单位,有7种取值,在TimeUnit类中有7种静态属性:
TimeUnit.DAYS;               //天
TimeUnit.HOURS;             //小时
TimeUnit.MINUTES;           //分钟
TimeUnit.SECONDS;           //秒
TimeUnit.MILLISECONDS;      //毫秒
TimeUnit.MICROSECONDS;      //微妙
TimeUnit.NANOSECONDS;       //纳秒
  • workQueue:就是我们之前将的阻塞队列,用来存放等待执行的任务
  • threadFactory:线程工厂,主要用来创建线程;
  • handler:当阻塞队列满了,并且线程池中没有了可用容量时候,做出的拒绝策略:
ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。 
ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。 
ThreadPoolExecutor.DiscardOldestPolicy:丢弃等待时间最长的任务,然后重新尝试执行任务(重复此过程)
ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务 

接下来我们看一下这个类的继承关系图:

image

下面是几个很重要的方法:

  • execute():这个方法在Executor类中就已经声明,而且Executor类中只有这么一个方法:
public interface Executor {

    /**
     * Executes the given command at some time in the future.  The command
     * may execute in a new thread, in a pooled thread, or in the calling
     * thread, at the discretion of the {@code Executor} implementation.
     *
     * @param command the runnable task
     * @throws RejectedExecutionException if this task cannot be
     * accepted for execution
     * @throws NullPointerException if command is null
     */
    void execute(Runnable command);
}

ThreadPoolExecutor类对这个方法进行了重写,通过这个方法可以向线程池中提交一个任务,交由线程池执行

  • submit():这个方法是在ExecutorService接口中声明的,在AbstractExecutorService中已经得到了具体的声明,在ThreadPoolService中并没有对其进行重写,这个方法也是用来向线程池提交任务,但是不同的是这个方法可以获得任务执行的结果,这个方法底层也是调用了execute()方法,不过它使用了Future接口来获取任务的执行结果,这个做法和之前使用Callable接口创建线程,并获取到返回值是一样的
  • shutdown()用来关闭线程池

当然还有其他非常重要的方法,这里不多说了,如果使用,可以查看相关api

4.线程池实现原理

如果说上面还有参考源码的约束,那么下面涉及到理解的部分,我又可以天马行空的发挥了,总之在保证理论正确的情况下理解就好

正所谓技术来源生活而高于生活,我们完全可以把线程池理解为一个银行业务大厅,每一个办理窗口都表示一个线程,而大厅里面的一排排的椅子就是阻塞队列,每个顾客就是任务,以这个为一条主线,我们来看一下线程池的工作过程:

  • 在创建了线程池之后,等待要提交过来的任务请求

  • 当调用execute()方法添加一个请求任务时,线程池会进行如下判断:

    • 如果正在运行的线程数小于corePoolSize,那么那么马上创建线程执行这个任务,这一点需要理解,当创建好一个新的线程池时,池中是没有线程池的,用的时候再使用线程工厂创建
    • 如果正在运行的线程数大于等于corePoolSize,那么将任务放到阻塞队列里面
    • 如果这时候阻塞队列满了,并且正在运行的线程数还是小于maximumPoolSize,那么创建非核心线程立即执行这个任务
    • 如果阻塞队列满了,并且线程数已经达到了maximumPoolSize,这时候启用相应的拒绝策略
  • 当一个线程完成任务时,他会从阻塞队列中取下一个任务来执行

  • 当一个线程无事可做超过一段时间(keepAliveTime)时,线程池就会进行判断:

如果当前运行的线程数大于corePoolSize,那么就会停掉这个线程,所以当队列中的全部任务执行完毕之后,线程池中的线程数最终会缩减为corePoolSize的大小


下面我们换一种场景来看一下这个过程,线程池创建完毕,等待任务提交,也就是银行服务大厅打开,等待顾客上门,假如这个服务大厅一共有五个服务窗口,其中有三个常驻窗口,也就是一周七天一直工作的,可以对应为corePoolSize,而maxmumPolSize指的就是一共那五个窗口,当一个顾客来的时候,首先看一直上班的那三个窗口是否有闲着的,如果有,执行办理业务,如果这三个窗口已经满了,那就去大厅里面的椅子上做一回,有的还可以看看报纸看看电视,不过这个和线程池没啥关系😄,也就是线程进入阻塞队列,这时候顾客等着被这三个窗口中的一个叫号,也就是一个线程执行完任务时,会从阻塞队列中取出下一个任务,那么如果等待的椅子没有位置了怎么办?这时候大厅就会开放另外两个备用窗口,这时候大厅里面连坐的位置都没有的人直接在备用窗口办理业务,但是如果这个时间段办理业务的人特别多,不仅椅子不够用,而且五个窗口都招架不住的时候怎么办呢?那么大厅就要采取相应的策略了,下面我们来看一下JDK中内置的四种拒绝策略:

  • AbortPolicy(默认):直接抛出RejectedExecutionException异常阻止系统正常运行。
  • CallerRunsPolicy:“调用者运行”一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。
  • DiscardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务。
  • DiscardPolicy:直接丢弃任务,不予任何处理也不抛出异常。如果允许任务丢失,这是最好的一种方案。

以上内置拒绝策略均实现了RejectedExecutionHandler接口

那么对应的银行大厅怎么操作的呢?首先第一种,我觉得是一种很脆弱的一种做法,银行直接关门不干了,正在办理业务的窗口也干不了了,要知道直接抛出异常并阻止系统正常运行是一种很恶劣的做法。第二种就是考虑的比较周到了,什么叫调用者呢,假如当前大厅的顾客是另外一个银行安排过来的,说你去他家办理吧,那么这时候如果我自己本身都招架不住这么多顾客了,那么我就把这些顾客给你还回去,我不要了。第三种是一种非常赖皮的做法,直接把在椅子上面等待最长时间的顾客拉出去,换成新的顾客放进去,这在计算机领域看起来还是比较合理的,因为这么长时间没有被执行说明接下来被调用的可能性也不大,但对于人来讲是非常不可理解的。最后一种做法就是佛系做法,也是符合人类社会银行做法的一种,如果新的顾客来了,看见人已经都满了,那么就出去不办理业务了,去干一些其他事情,这在人类社会很常见,但是在计算机领域往往代表一个任务的丢失。

5.你经常使用哪种线程池?

这个问题真的是一个超级大坑,如果回答错了,那么面试官一下子就能看出来你是没用过线程池的。

正确的答案是哪个都不用,自己定义线程池,配置参数,为什么呢?Executors中的JDK已经给你提供了,为啥不用呢?

我们来分别看一下Executors工具类给我们提供的三种实现方式:

  • newSingleThreadExecutor()
  public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }
  • newFixedThreadPool(int nThreads)
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}
  • newCachedThreadPool()
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

我们来看一下阿里巴巴Java开发手册是如何规定的:

image

既然需要自定义线程池,那么我们应该如何合理的设置参数呢?这需要由任务本身来判断,任务分为CPU密集型和IO密集型

  • CPU密集型:这个任务需要大量的CPU的运算,就可以理解为顾客的这次事务需要这个窗口里面的营业员进行大量工作,而这个营业员一直保持工作,用一个我自己创造的名词就是有效服务时间很长,在营业员一直工作的情况下,不需要开太多的窗口,也就是线程池中的线程少一点就好了
  • IO密集型:也就是这个任务需要进行大量的IO,当前线程会进行大量的阻塞,这时候就需要对配置一些线程数,例如营业员在进行服务时,你磨磨唧唧的,掏钱特别慢,然后银行卡还找不到了,营业员一直在等你呢,有效服务时间很短,如果要是都是这样的顾客的话,那么银行大厅就会考虑多开几个窗口

这篇文章对线程池的理解与总结就到这里,这里只是对理论有了一个比较深刻形象的理解,但是并没有扒太多源码,以后如有需要,可以参考文章:https://blog.csdn.net/weixin_28760063/article/details/81266152?depth_1-utm_source=distribute.pc_relevant.none-task&utm_source=distribute.pc_relevant.none-task