Java 线程池深入理解

目录

线程池体系

创建线程池

newSingleThreadExecutor

newCachedThreadPool

newFixedThreadPool

newScheduledThreadPool

线程池工作原理分析

现实案例

线程池结构

参数分析

流程解析

为何禁止使用 Executors

总结


        我们已经了解了 Java 中线程的创建以及上下文切换是比消耗性能的,因此引入了偏向锁、轻量级锁等优化技术,目的就是减少用户态和核心态之间的切换频率。但是在这些优化基础之上,还有另外一个角度值得思考:创建和销毁线程非常损耗性能,那有没有可能复用一些已经被创建好的线程呢?答案是肯定的,那就是线程池。

        另外,线程的创建需要开辟虚拟机栈、本地方法栈、程序计数器等线程私有的内存空间,在线程销毁时需要回收这些系统资源,频繁地创建销毁线程会浪费大量资源,而通过复用已有线程可以更好地管理和协调线程的工作。

线程池主要解决两个问题:

  • 一、 当执行大量异步任务时线程池能够提供很好的性能。

  • 二、 线程池提供了一种资源限制和管理的手段,比如可以限制线程的个数,动态新增线程等。

线程池体系

用一张图来表示线程池体系如下:

解释说明:

  • Executor 是线程池最顶层的接口,在 Executor 中只有一个 execute 方法,用于执行任务。至于线程的创建、调度等细节由子类实现。

  • ExecutorService 继承并拓展了 Executor,在 ExecutorService 内部提供了更全面的任务提交机制以及线程池关闭方法。

  • ThreadPoolExecutor 是 ExecutorService 的默认实现,所谓的线程池机制也大多封装在此类当中,因此它是本课时分析的重点。

  • ScheduledExecutorService 继承自 ExecutorService,增加了定时任务相关方法。

  • ScheduledThreadPoolExecutor 继承自 ThreadPoolExecutor,并实现了 ScheduledExecutorService 接口。

  • ForkJoinPool 是一种支持任务分解的线程池,一般要配合可分解任务接口 ForkJoinTask 来使用。

创建线程池

        为了开发者可以更方便地使用线程池,JDK 中给我们提供了一个线程池的工厂类—Executors。在 Executors 中定义了多个静态方法,用来创建不同配置的线程池。常见有以下几种。

newSingleThreadExecutor

        创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按先进先出的顺序执行。

执行上述代码结果如下,可以看出所有的 task 始终是在同一个线程中被执行的。

newCachedThreadPool

        创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

执行效果如下:

        从上面日志中可以看出,缓存线程池会创建新的线程来执行任务。但是如果将代码修改一下,在提交任务之前休眠 1 秒钟,如下:

        再次执行则打印日志同 SingleThreadPool 一模一样,原因是提交的任务只需要 500 毫秒即可执行完毕,休眠 1 秒导致在新的任务提交之前,线程 “pool-1-thread-1” 已经处于空闲状态,可以被复用执行任务。

newFixedThreadPool

创建一个固定数目的、可重用的线程池。

        上述代码创建了一个固定数量 3 的线程池,因此虽然向线程池提交了 10 个任务,但是这 10 个任务只会被 3 个线程分配执行,执行效果如下:

newScheduledThreadPool

        创建一个定时线程池,支持定时及周期性任务执行。

        上面代码创建了一个线程数量为 2 的定时任务线程池,通过 scheduleAtFixedRate 方法,指定每隔 500 毫秒执行一次任务,并且在 5 秒钟之后通过 shutdown 方法关闭定时任务。执行效果如下:

        上面这几种就是常用到的线程池使用方式,但是,在 阿里Java开发手册 中已经严禁使用 Executors 来创建线程池,这是为什么呢?要回答这个问题需要先了解线程池的工作原理。

线程池工作原理分析

现实案例

        先来看一个现实生活中的实际案例,某工艺品加工厂有 3 台加工机器用来生产订单所需的产品,正常情况下 3 台机器能够保证所有订单按时按需生产完毕,如下图所示:

        如果订单量突然大幅增加,3 台机器已经处于满负荷状态,一时间无法完成新增的订单任务。那怎么办呢?正所谓钱是不可能不挣的,只能硬着头皮接下新来的订单,但是会将新来的订单暂存在仓库中,当有加工机器空闲下来之后,再用来生产仓库中的订单,如下图所示:

        如果订单量持续快速增长,导致仓库也存储满了。那怎么办呢? 正常情况下加工厂肯定会通过购买新的加工机器来满足订单需求,如下所示:

        有了仓库和新购买的加工机器加持,加工厂业务还是能够正常流转。但是当某些极端情况发生,比如双十一搞活动之后订单爆单了。这时新增的订单任务连仓库以及所有的加工机器都已经无法容纳,说明加工厂已经不能接受新的订单任务了,因此只能拒绝所有新的订单。

        线程池的工作流程同上面描述的加工厂完成订单任务非常相似,并且在线程池的构造器中,通过传入的参数可以设置默认有多少台加工机器、仓库的大小、可以购买新的加工机器的最大数量等等。

线程池结构

        从上图中可以大体看出,在线程池内部主要包含以下几个部分:

        worker 集合:保存所有的核心线程和非核心线程(类比加工厂的加工机器),其本质是一个HashSet。

        等待任务队列:当核心线程的个数达到 corePoolSize 时,新提交的任务会被先保存在等待队列中(类比加工厂中的仓库),其本质是一个阻塞队列 BlockingQueue。

        ctl:是一个 AtomicInteger 类型,二进制高 3 位用来标识线程池的状态,低 29 位用来记录池中线程的数量。

        获取线程池状态、工作线程数量、修改 ctl 的方法分别如下:

        线程池主要有以下几种运行状态:

参数分析

        线程池的构造器如下:

构造参数说明。

  • corePoolSize:表示核心线程数量。

  • maximumPoolSize:表示线程池最大能够容纳同时执行的线程数,必须大于或等于 1。如果和 corePoolSize 相等即是固定大小线程池。

  • keepAliveTime:表示线程池中的线程空闲时间,当空闲时间达到此值时,线程会被销毁直到剩下 corePoolSize 个线程。

  • unit:用来指定 keepAliveTime 的时间单位,有 MILLISECONDS、SECONDS、MINUTES、HOURS 等。

  • workQueue:等待队列,BlockingQueue 类型。当请求任务数大于 corePoolSize 时,任务将被缓存在此 BlockingQueue 中。

  • threadFactory:线程工厂,线程池中使用它来创建线程,如果传入的是 null,则使用默认工厂类 DefaultThreadFactory。

  • handler:执行拒绝策略的对象。当 workQueue 满了之后并且活动线程数大于 maximumPoolSize 的时候,线程池通过该策略处理请求。

注意:当 ThreadPoolExecutor 的 allowCoreThreadTimeOut 设置为 true 时,核心线程超时后也会被销毁。

流程解析

        当我们调用 execute 或者 submit,将一个任务提交给线程池,线程池收到这个任务请求后,有以下几种处理情况:

        1.当前线程池中运行的线程数量还没有达到 corePoolSize 大小时,线程池会创建一个新线程执行提交的任务,无论之前创建的线程是否处于空闲状态。 举例:

        上面代码创建了 3 个固定数量的线程池,每次提交的任务耗时 100 毫秒。每次提交任务之前都会延迟2秒,保证线程池中的工作线程都已经执行完毕,但是执行效果如下:

        可以看出虽然线程 1 和线程 2 都已执行完毕并且处于空闲状态,但是线程池还是会尝试创建新的线程去执行新提交的任务,直到线程数量达到 corePoolSize。

        2.当前线程池中运行的线程数量已经达到 corePoolSize 大小时,线程池会把任务加入到等待队列中,直到某一个线程空闲了,线程池会根据我们设置的等待队列规则,从队列中取出一个新的任务执行。举例:

        上述代码提交的任务耗时 4 秒,因此前 2 个任务会占用线程池中的 2 个核心线程。此时有新的任务提交给线程池时,任务会被缓存到等待队列中,结果如下:

        可以看到红框 1 中通过 2 个核心线程直接执行提交的任务,因此等待队列中的数量为 0;而红框 2 中表明,此时核心线程都已经被占用,新提交的任务都被放入等待队列中。

        3.如果线程数大于 corePoolSize 数量但是还没有达到最大线程数 maximumPoolSize,并且等待队列已满,则线程池会创建新的线程来执行任务。

        上述代码创建了一个核心线程数为 2,最大线程数为 10,等待队列长度为 2 的线程池。执行效果如下

        解释说明:

  • 1 处表示线程数量已经达到 corePoolSize;

  • 2 处表明等待队列已满;

  • 3 处会创建新的线程执行任务。

        4.最后如果提交的任务,无法被核心线程直接执行,又无法加入等待队列,又无法创建“非核心线程”直接执行,线程池将根据拒绝处理器定义的策略处理这个任务。比如在 ThreadPoolExecutor 中,如果你没有为线程池设置 RejectedExecutionHandler。这时线程池会抛出 RejectedExecutionException 异常,即线程池拒绝接受这个任务。

        将上面非核心线程的代码稍微修改一下,如下:

        修改最大线程数为 3,并提交 6 次任务给线程池,执行效果如下:

        程序会报异常 RejectedExecutionException,拒绝策略是线程池的一种保护机制,目的就是当这种无节制的线程资源申请发生时,拒绝新的任务保护线程池。默认拒绝策略会直接报异常,但是 JDK 中一共提供了 4 种保护策略,如下:

         实际上拒绝策略都是实现自接口 RejectedExecutionException,开发者也可以通过实现此接口,定制自己的拒绝策略。

为何禁止使用 Executors

        现在再回头看一下为何在阿里 Java 开发手册中严禁使用 Executors 工具类来创建线程池。尤其是 newFixedThreadPool 和 newCachedThreadPool 这两个方法。

        比如如下使用 newFixedThreadPool 方法创建线程的案例:

        上述代码创建了一个固定数量为 2 的线程池,并通过 for 循环向线程池中提交 100 万个任务。 通过 java -Xms4m -Xmx4m FixedThreadPoolOOM 执行上述代码:

        可以发现当任务添加到 7 万多个时,程序发生 OOM。这是为什么呢? 看一下newSingleThreadExecutor 和 newFixedThreadPool() 的具体实现,如下:

        可以看到传入的是一个无界的阻塞队列,理论上可以无限添加任务到线程池。当核心线程执行时间很长(比如 sleep10s),则新提交的任务还在不断地插入到阻塞队列中,最终造成 OOM。

        再看下 newCachedThreadPool 会有什么问题。

        上述代码同样会报 OOM,只是错误的 log 信息有点区别:无法创建新的线程。

        看一下 newCachedThreadPool 的实现:

        可以看到,缓存线程池的最大线程数为 Integer 最大值。当核心线程耗时很久,线程池会尝试创建新的线程来执行提交的任务,当内存不足时就会报无法创建线程的错误。

         以上两种情况如果发生在生产环境将会是致命的,从阿里手册中严禁使用 Executors 的态度上也能看出,阿里也是经历过血淋淋的教训。

总结

        线程池是一把双刃剑,使用得当会使代码如虎添翼;但是使用不当将会造成重大性灾难。而剑柄是握在开发者手中,只有理解线程池的运行原理,熟知它的工作机制与使用场景,才会使这把双刃剑发挥更好的作用。

猜你喜欢

转载自blog.csdn.net/g984160547/article/details/135131108