一文加深你对Java线程池的了解与使用—筑基篇

前言

Java中的线程池是一个很重要的概念,它的应用场景十分广泛,可以被广泛的用于高并发的处理场景。J.U.C提供的线程池:ThreadPoolExecutor类,可以帮助我们管理线程并方便地并行执行任务。因此了解并合理使用线程池非常重要。

本文对线程池采用 3W 的策略结合源码进行思考逐层分析,即是什么为什么怎么做。

什么是线程池

线程池的本质是对任务和线程的管理,做到了将任务线程两者解耦。线程池对任务的管理可看作生产者消费者的关系,通过阻塞队列的存与取。阻塞队列缓存待执行的任务,工作线程从阻塞队列中获取任务。线程池对线程的管理,是结合线程池状态,已有线程的状态,核心线程数和最大线程数、阻塞队列状态做出增加、执行任务、回收、复用等操作,体现了享元模式和池化思想。

享元模式:

主要目的是实现对象的共享,运用共享技术有效地支持大量细粒度的对象,避免大量相类似的开销。当系统中对象多的时候可以减少内存的开销,通常与搭配工厂模式使用。

池化思想:

在多种使用对象的策略上,主张让使用的代价最小化。在重新创建对象的代价 远大于更换状态,复用对象的代价的前提下,将可以复用的对象放入池中待复用,以此降低使用的代价。

为什么要用线程池

线程池的优点,也是它为什么被流行使用的原因:

  • 重用线程池中的线程,避免因为线程的创建和销毁带来性能开销。
  • 能有效控制线程池的最大并发数,能提供定时执行以及定间隔循环执行等功能。
  • 线程池还提供了一种方法来约束和管理执行一组任务时消耗的资源(包括线程),避免大量的线程之间因互相抢占系统资源而导致的阻塞现象。
  • 可维护一些基本统计信息,比如已完成任务的数量。

主要的缺点:

  • 线程池的参数不存在完美的配置,高度依赖于开发者的经验,使用不当容易造成线上的危机
  • 线程池执行的情况和任务类型相关性较大,IO密集型和CPU密集型的任务运行起来的情况差异非常大,业界并没有一些成熟的经验策略帮助开发人员参考。

怎么用线程池

先了解线程池的相关重要概念:

Core and maximum pool sizes

核心线程数以及最大线程数,这是构造一个线程池所必需的参数。

不同的搭配会有不同效果的线程池,也是线程池判断在运行任务前是否需创建新线程的重要依据。

ThreadFactory

线程工厂,这是构造一个线程池的参数。

提供线程的创建,如果构建线程池不指定ThreadFactory,则使用默认线程工厂,创建的线程默认进入同一个ThreadGroup和默认线程优先级。

Keep-alive times

存活时间,这是构造一个线程池的参数。

如果线程池当前有超过corePoolSize大小的线程,如果非核心线程的空闲时间超过了keepAliveTime,则被视为可回收的多余线程,被终止

Queuing

任务/阻塞队列,这是构造一个线程池的参数。

不同类型的阻塞队列可以构造出适合不同场景的线程池。最常见的四种线程池就有着不同类型的阻塞队列。

作为任务的缓冲停留区,线程池管理线程的机制核心之一。

生产者消费者模式的体现,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

  • 在队列为空时,获取元素的线程会等待队列变为非空再尝试获取
  • 当队列满时,存储元素的线程会等待队列可用再尝试存储

一文加深你对Java线程池的了解与使用—筑基篇_第1张图片

Rejected tasks

拒绝任务后的策略,这是构造一个线程池的参数

加入任务时,根据线程池当前状态是否停止销毁、线程数是否以及饱和,判断是否拒绝本次任务的加入。若拒绝任务就会执行拒绝任务后的策略。默认的拒绝后的策略是抛出运行期异常RejectedExecutionException

On-demand construction

需求到达才创建,默认情况下,即使是核心线程最初也只有在新任务到达时才创建和启动,但是可以使用prestartCoreThread。如果使用非空队列构造池,可能需要预启动线程。

Hook methods

钩子方法

可重写的方法,beforeExecute(Runnable)afterExecute(Runnable,Throwable)terminated ,在执行每个任务之前和之后,线程池被完全终止后会被回调。可以用来执行特殊任务:重新初始化ThreadLocal变量、收集统计信息或添加日志条目。

最常见最常用的线程池

Executors类提供的也是最常见的线程池种类,配置,以及它们维护的阻塞队列类型,使用场景如下:

类型 核心线程数 最大线程数 阻塞队列 说明/使用场景
FixedThreadPool 构造时传入 与核心线程数相同 LinkedBlockingQueue 线程数量固定,只有核心线程并且不会被回收,没有超时机制
CachedThreadPool 0 Integer.MAX_VALUE SynchronousQueue 线程数量不固定的线程池,只有非核心的线程,当线程都处于活动状态时,直接创建新线程来处理新任务,否则就利用空闲的线程。处于空闲状态超过60s的线程被回收
ScheduledThreadPool 构造时传入 Integer.MAX_VALUE DelayedWorkQueue 非核心线程在闲置时立刻回收,主要用于执行定时任务和固定周期的重复任务
SingleThreadExecutor 1 1 LinkedBlockingQueue 只有一个核心线程,确保所有任务在同一线程中按顺序执行

分析创建这四个线程池的方法的源码,最后都来到了ThreadPoolExecutor类的ThreadPoolExecutor构造方法,由此可见ThreadPoolExecutor才是真正的线程池。Executors作为线程池工厂,提供的四种线程池是利用不同参数创建的适应不同使用场景的线程池。

//ThreadPoolExecutor.java

/**
    * @param corePoolSize 核心线程数
    * @param maximumPoolSize 最大线程数
    * @param keepAliveTime 非核心线程闲置的超时时长
    * @param unit 用于指定 keepAliveTime 参数的时间单位
    * @param 任务队列,通过线程池的 execute 方法提交的 Runnable 对象会存储在这个参数中
    * @param threadFactory 线程工厂,用于提供新线程
    * @param handler 任务队列已满或者是无法成功执行任务时调用
    */
public ThreadPoolExecutor(int corePoolSize,
                            int maximumPoolSize,
                            long keepAliveTime,
                            TimeUnit unit,
                            BlockingQueue workQueue,
                            ThreadFactory threadFactory,
                            RejectedExecutionHandler handler) {
    //···
}

线程池的简单使用

以手动创建一个核心数为5,最大线程数为7,空闲超时为20s,阻塞队列为数组实现的有界队列的ThreadPoolExecutor为例子:

        ExecutorService executor = new ThreadPoolExecutor(
                5, 7, 20L, TimeUnit.SECONDS, 
                new ArrayBlockingQueue(8)
        );
        for(int i = 0; i < 9; i++){
            final int index = i;
            executor.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(1500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(String.valueOf(index)+ " " +Thread.currentThread().getName());
                }
            });
        }

手动创建线程池的好处

一文加深你对Java线程池的了解与使用—筑基篇_第2张图片

阿里巴巴Java开发手册中使用强制标注:需通过手动创建 ThreadPoolExecutor 取代使用 Executors 提供的工厂方法。数据量并发量很大或难以把握时,应避免直接使用 Executors 提供的线程池,防止资源被耗尽

以CachedThreadPool为例子,CachedThreadPool将空闲线程销毁前的等待时间设置成了60s,同时阻塞队列类型是SynchronousQueue,不存储元素的队列。 CachedThreadPool 在一定程度上能够应对不断突增的并发任务,但是一旦任务量远远大于处理量,会造成线程数量的激增和资源的消耗,容易引发OOM。

手动创建线程池可以更好规范该线程池的职责,更好地管理这个线程池,让线程池在合适的场景下,可以用来处理适当的任务,而不是一颗随时会被引爆的炸弹。

总结

线程池,基于池化思想,体现了享元模式,可以用来管理线程并方便地并行执行任务的工具。本质上是对任务和线程解耦后进行管理,利用不同的构造参数可以构造出适合不同场景的线程池。优点是降低资源消耗提高响应速度提高线程的可管理性可拓展性良好。缺点是参数不易配置,出错后易造成OOM。

篇幅问题,对线程池的设计和管理机制的分析安排在下一篇文章~

参考资料

  • [1] JDK 1.8源码
  • [2] Java线程池实现原理及其在美团业务中的实践
  • [3] Google Developer Doucumentation - ThreadPoolExecutor

你可能感兴趣的:(一文加深你对Java线程池的了解与使用—筑基篇)