vlambda博客
学习文章列表

彻底搞懂线程池,通俗易懂超级好

前言

多线程系列我们前面已经更新过七个章节了,强烈建议小伙伴按照顺序学习:

《蹲坑也能进大厂》多线程系列文章目录

前面几章我们学习了线程的使用方法以及原理,但是认真的小伙伴会思考了,线程是不是可以无限创建呢,那我们处理并发不就很轻松了,瞬间感觉撑住淘宝双十一的并发都不是问题了呢。

其实并不是这样的,如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。本章就会和小伙伴们介绍如何解决这种问题,「即线程池的用法。」

正文

「我:狗哥,你用过线程池吗?我看大家都在用这个,直接创建线程不香吗?」

我先举个「栗子」,我们都去火锅店吃过饭,假如一家火锅店有固定餐位10桌,当有10桌及10桌以内的客人来消费时,火锅店是可以同时招待的,而如果客人数量大于10桌时,我们就会搬个凳子,打开王者,进入漫长的等待。直到其他客人吃饱喝足之后,才会按照顺序轮到下一位客人进场。

但是到了周五晚上(不知道大家什么时候),反正我们公司小伙伴就非常喜欢周五晚上聚餐,这个时候也是人特别多的晚上,队伍可能都会排几十米,这个时候精明的老板想呀,这么多客人,等那么久,那这客户体验也太差了,于是老板赶忙从隔壁冷清的不知名火锅店租来5张桌子放入店内,这样店铺内有15个餐桌,大大减少了客户等待时间。

但是有可能即使租来了5张餐桌,等待的队伍还是非常长,那这时候老板只能告诉后来的客人,说今天爆满了,对不起了。等到工作日的时候,大家又开始划水了,因为没人吃饭,老板把借来的餐桌又还了回去。

「让我们通过一张流程图来解读这个过程:」

image.png

「我:狗子,你是来凑字数的吧,说这么多?」

你这是狗咬吕洞宾,说这么多不还是为了你和小伙伴们好理解嘛。

「我:好吧好吧,我错怪你了,给你点个赞。那用线程池有啥具体的好处呢,我只关心面试的时候该怎样回答?不能把这个例子说给面试官听吧....」

傻狗...这个例子只是为了理解。线程池的优点我总结了有以下三点:

  • 「降低资源消耗」。反复创建线程开销大,线程池通过重复利用已创建的线程降低消耗。
  • 「提高响应速度」。线程池维护部分核心线程,执行完任务不会被销毁,当下个任务进来时无需创建就能立即执行。
  • 「提高线程可控性」。使用线程池可以d对线程统一分配,调优和监控。

「我:那我应该怎样使用线程池呢?」

线程池最核心的一个类是java.uitl.concurrent.ThreadPoolExecutor,它的构造方法有4种:

public class ThreadPoolExecutor extends AbstractExecutorService {
    .....
    public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
            BlockingQueue<Runnable> workQueue)
;
 
    public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
            BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory)
;
 
    public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
            BlockingQueue<Runnable> workQueue,RejectedExecutionHandler handler)
;
 
    public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,
        BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler)
;
    .....
}

综合上面的4种创建方式,可以整理成以下几种参数:

  • 「corePoolSize:」 核心线程数的大小,默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到 corePoolSize后,就会把到达的任务放到缓存队列当中;
  • 「maximumPoolSize:」 线程池允许创建的最大线程数,当 活跃线程数达到该数值后,后续的新任务将会阻塞;
  • 「keepAliveTime:」 线程闲置超时时长。默认情况下,当线程数大于 核心线程数时,keepAliveTime才会起作用,即一个线程空闲的时间达到keepAliveTime,就会被回收,直到线程数不超过 核心线程数。但是如果调用了 「allowCoreThreadTimeOut(boolean)」 方法,核心线程数也会被回收,直到线程池中的线程数为0;
  • 「unit:」 指定 keepAliveTime 参数的时间单位,常用值有TimeUnit.SECONDS(秒);
  • 「workQueue:」 阻塞队列,用于存储等待执行的任务,常用队列下方具体介绍;
  • 「threadFactory:」 线程工厂。用于指定为线程池创建新线程的方式;
  • 「handler:」 拒绝策略。当达到最大线程数时需要执行的饱和策略。

「我:这一波介绍我头皮发麻,完全懵逼了。」

不要急,大胸弟,这里看下流程图,一图胜千言:

彻底搞懂线程池,通俗易懂超级好
image.png

通过上图,再结合各个参数解释,大家已经对所有参数有了大致的了解。核心线程数、最大线程数及超时时间都非常容易理解,下面再对任务队列、拒绝策略做详细的说明。

「我:那先说说我们有哪些常用的队列吧?」

从流程图中可以看到,当核心线程数耗尽,来不及处理的任务就会进入阻塞队列,等待被执行。最常用的有三种队列,如下:

  • 「ArrayBlockingQueue」:一个由数组结构组成的有界阻塞队列(数组结构可配合指针实现一个环形队列)。

  • 「SynchronousQueue」:一个不存储元素的阻塞队列,消费者线程调用 take() 方法的时候就会发生阻塞,直到有一个生产者线程生产了一个元素,消费者线程就可以拿到这个元素并返回;生产者线程调用 put() 方法的时候也会发生阻塞,直到有一个消费者线程消费了一个元素,生产者才会返回。

  • 「LinkedBlockingDeque」:使用双向队列实现的有界双端阻塞队列。双端意味着可以像普通队列一样 FIFO(先进先出),也可以像栈一样 FILO(先进后出)。

「我:原来是这样(疯狂暗示自己已经掌握了),那还要一个拒绝策略是什么呢?」

当线程池中新建的线程数到达最大线程数时,就会执行拒绝策略,Executors为我们提供4种拒绝策略:

  • 「AbortPolicy」:默认拒绝策略。丢弃任务并抛出RejectedExecutionException异常。
  • 「DiscardPolicy」:丢弃任务,但是不抛出异常。
  • 「DiscardOldestPolicy」:丢弃队列最前面的任务,然后重新尝试执行任务
  • 「CallerRunsPolicy」:由调用线程处理该任务

「我:我的天哪,创建一个线程池怎么要考虑这么多,不玩了。」

别急,稳住,如果我们要求不高,又嫌上面使用线程池的方法太麻烦?其实Executors已经为我们封装好了 4 种常见的功能线程池,可以直接使用,如下:

  • 「newFixedThreadPool」:最大线程数等于核心线程数,使用的队列为LinkedBlockingQueue,是没有容量上线的,所以当请求越来越多,并且无法及时处理完毕的情况下,也不会新增线程,只是将等待执行的线程放入队列中,这样会造成内存大量被占用,可能会导致OOM。
彻底搞懂线程池,通俗易懂超级好
image.png

使用场景:通过限制最大线程数,控制并发数。

代码demo:

ExecutorService service = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
    service.execute(new Runnable() {
        @Override
        public void run() {
            System.out.println("线程名:" + Thread.currentThread().getName());
        }
    });
}
  • 「newSingleThreadExecutor」:和newFixedThreadPool原理基本一样,只是把线程数直接设置成1,因此也会导致同样的问题。
彻底搞懂线程池,通俗易懂超级好
image.png

使用场景:不适用并发场景。

代码demo:

ExecutorService service = Executors.newSingleThreadExecutor();
for (int i = 0; i < 1000; i++) {
    service.execute(new Runnable() {
        @Override
        public void run() {
            System.out.println("线程名:" + Thread.currentThread().getName());
        }
    });
}
  • 「CachedThreadPool」:可以无限制的创建线程,使用SynchronousQueue作为队列(不存储元素),具有自动回收多余线程的功能,其弊端在于maximumPoolSize(最大线程数)设置成Integer.MAX_VALUE,这样有可能导致创建非常多的线程,造成OOM。
彻底搞懂线程池,通俗易懂超级好
image.png

使用场景:适合处理大量并发请求,且每个请求耗时短的场景。

代码demo:

ExecutorService service = Executors.newCachedThreadPool();
for (int i = 0; i < 1000; i++) {
    service.execute(new Runnable() {
        @Override
        public void run() {
            System.out.println("线程名:" + Thread.currentThread().getName());
        }
    });
}
  • 「newScheduledThreadPool」:支持定时及周期性执行任务的线程池。
彻底搞懂线程池,通俗易懂超级好
image.png

使用场景:执行定时或周期性的任务。

代码demo:

public class ThreadPoolDemo {
 public static void main(String[] args) {
    //用法一:3秒后执行
    ScheduledExecutorService service = Executors.newScheduledThreadPool(10);
    service.schedule(new ThreadTask(),3,TimeUnit.SECONDS);

    //用法二:第一次3秒后执行,以后每个5秒执行
    service.scheduleAtFixedRate(new ThreadTask(),3,5,TimeUnit.SECONDS);
 }
}
class ThreadTask implements Runnable{
 @Override
 public void run() {
     System.out.println("大家好,我是花Gie");
 }
}
image.png

「我:感觉奇怪的知识又增加了,这么多线程池,我应该如何选择呢?」

我们需要根据自己的业务场景,来选择符合自己的线程池,比如我们想自定义线程名称、任务被拒绝后记录日志以及结合自身内存大小等因素进行选择。

如果想要深入的了解线程池,源码还是非常有必要看一下的,我现在和你说一下....

「我:打住....我现在都快炸裂了,等我消化一波,明天再说」

总结

以上就是线程池的基本用法,以及常用的参数介绍,里面涉及了一部分队列知识,后续也会给大家详细介绍。学会了用法,下一章我们将会对线程池的源码进行解析,知其然还要知其所以然,这样才能彻底掌握其精髓。

点关注,防走丢

以上就是本期全部内容,「如有纰漏之处,请留言指教,非常感谢」。我是花GieGie ,有问题大家随时留言讨论 ,我们下期见🦮。

「原创不易,切勿白嫖」,如果你觉得这篇文章对你有点用的话,感谢老铁为本文点个赞、评论或转发一下,因为这将是我输出更多优质文章的动力,感谢!