JUC 线程池

665 阅读17分钟

Java 里面线程池的核心类是 ThreadPoolExecutor

使用线程池好在哪里?

为什么要使用线程池

假如我们不适用线程池的话,我们执行一个任务就要创建一个线程,执行 1000 个任务就要创建 1000 个线程。

出现的问题

第一点:反复创建销毁线程系统开销比较大,每个线程创建和销毁都需要时间,如果任务比较简单,那么就有可能导致创建和销毁线程消耗的资源比线程执行任务本身消耗的资源还要大。

第二点:假如创建的线程过多,过多的线程会占用过多的内存等资源,还会带来过多的上下文切换,同时还会导致系统不稳定。

线程池是如何解决的

(1)针对反复创建销毁线程系统开销比较大这个问题,线程池用一些固定的线程一直保持存活状态来反复执行任务,也就是核心线程数

(2)针对过多线程占用太多内存资源这个问题,线程池会根据需要创建线程,控制线程的总数量,避免占用过多内存资源。有核心线程数、最大线程数、阻塞队列等概念引入。

如何使用线程池

Java 里面线程池的核心类是 ThreadPoolExecutor。每个参数的作用后面会细说。

public static void main(String[] args) {
    // 创建一个线程池
    ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
            // 核心线程数
            Runtime.getRuntime().availableProcessors(),
            // 最大线程数
            Runtime.getRuntime().availableProcessors() * 2 + 1,
            // 存活时间
            5,
            // 时间单位
            TimeUnit.SECONDS,
            // 阻塞队列
            new ArrayBlockingQueue<>(10),
            // 创建线程的工厂
            Executors.defaultThreadFactory(),
            // 拒绝策略
            new ThreadPoolExecutor.AbortPolicy());
    // 通过线程池执行任务
    poolExecutor.execute(() -> {
        System.out.println("通过线程池执行任务");
    });
}

使用线程池的好处

三点好处:

1、线程池可以解决线程创建销毁的系统开销问题,同时还可以加快响应速度

因为线程池中的线程是可以复用的,我们只用少量的线程去执行大量的任务,这就大大减小了线程生命周期的开销。而且线程通常不是等接到任务后再临时创建(这里为什么要用通常呢?因为即使是核心线程,也是等任务来了后才进行创建的,而不是一开始就直接创建的),而是已经创建好时刻准备执行任务,这样就消除了线程创建所带来的延迟,提升了响应速度,增强了用户体验。

2、线程池可以管理好线程的数量,避免资源使用不当

线程池会根据配置和任务数量灵活地控制线程数量,不够的时候就创建,太多的时候就回收,避免线程过多导致内存溢出,或线程太少导致 CPU 资源浪费,达到了一个完美的平衡。

3、线程池可以统一管理资源

比如线程池可以统一管理任务队列和线程,可以统一开始或结束任务,比单个线程逐一处理任务要更方便、更易于管理,同时也有利于数据统计,比如我们可以很方便地统计出已经执行过的任务的数量。

线程池处理流程

image-20220209151253269

(1)提交任务到线程池

(2)判断核心线程是否都在工作状态:

  • 如果都在工作状态,则说明核心线程数量已经达到规定的数量了,提交到阻塞队列中等待处理。
  • 如果有核心线程不在工作状态,则说明有核心线程是空闲的,则调用核心线程来处理任务;另一种情况是已有的核心线程都在工作状态,但是核心线程数量并没有达到规定的数量,则创建一个线程作为核心线程来处理任务。

(3)判断队列是否已满:

  • 如果队列未满,直接提交到队列中,等待处理。
  • 如果队列已满,则进入判断当前线程数量是否超过最大线程数逻辑。

(4)判断当前线程数量是否超过最大线程数逻辑:

  • 如果已经超过,则调用相应的策略来处理任务(比如说直接丢弃啊,JDK 提供了四种拒绝策略,后面会说)。
  • 如果未超过,则创建一个线程来执行任务。

线程池七大参数

ThreadPoolExecutor 构造方法源码:

image-20220209152353891

corePoolSize

corePoolSize 指的是核心线程数,线程池初始化时线程数默认为 0,当有新的任务提交后,会创建新线程执行任务,如果不做特殊设置,此后线程数通常不会再小于 corePoolSize ,因为它们是核心线程,即便未来可能没有可执行的任务也不会被销毁。

maximumPoolSize

maximumPoolSize 指的是线程池的最大线程数量。在队列满了之后,线程池会进一步创建新线程,最多可以达到 maximumPoolSize 来应对任务多的场景,如果未来线程有空闲,大于 corePoolSize 数量的线程会被回收。

正常情况下,线程池中的线程数量会处在 corePoolSizemaximumPoolSize 的闭区间内。

keepAliveTime

keepAliveTime 指的是线程的存活时间,这个参数是为 maximumPoolSize 这个参数设置的。

当线程池中线程数量多于核心线程数时,而此时又没有任务可做,线程池就会检测线程的 keepAliveTime,如果超过规定的时间,无事可做的线程就会被销毁,以便减少内存的占用和资源消耗。

TimeUnit

TimeUnit 指的是 keepAliveTime 的时间单位。

这个类常用于线程的休眠,如 TimeUnit.SECONDS.sleep(1); 代表线程休眠 1 秒。

workQueue

workQueue 指的是暂存任务的阻塞队列,后面细说。

threadFactory

threadFactory 指的是线程工厂,它的作用是生产线程以便执行任务。。

我们可以选择使用默认的线程工厂,创建的线程都会在同一个线程组,并拥有一样的优先级,且都不是守护线程,我们也可以选择自己定制线程工厂,以方便给线程自定义命名,不同的线程池内的线程通常会根据具体业务来定制不同的线程名。

接口源码如下:

public interface ThreadFactory {

    /**
     * Constructs a new {@code Thread}.  Implementations may also initialize
     * priority, name, daemon status, {@code ThreadGroup}, etc.
     *
     * @param r a runnable to be executed by new thread instance
     * @return constructed thread, or {@code null} if the request to
     *         create a thread is rejected
     */
    Thread newThread(Runnable r);
}

handler

handler 指的是任务的拒绝策略,当队列和最大线程都已满的时候,就会启用这个策略来决定这个任务的处理办法。后面细说。

线程池常用的阻塞队列

ArrayBlockingQueue

它的底层是一个数组,使用它的时候必须要指定容量。

public ArrayBlockingQueue(int capacity) {
    this(capacity, false);
}

LinkedBlockingQueue

它的底层是一个链表,如果我们不指定容量的话,它的默认大小就是 Integer 的最大值。

public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}

DelayedWorkQueue

DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构。

SynchronousQueue

这个队列有点特殊,没有容量。也就是说我们不能把任务存放到这个队列中去。

如果应用这个队列的意思就是:我提交的任务必须马上执行,别让我等。因为假如核心线程都在工作,这时候会到提交到队列这一步,这个队列就直接不收,你得创建一个线程来执行这个任务,别存我这。

线程池 4 种拒绝策略

线程池会在以下两种情况下会拒绝新提交的任务。

  • 第一种情况是当我们调用 shutdown 等方法关闭线程池后,即便此时可能线程池内部依然有没执行完的任务正在执行,但是由于线程池已经关闭,此时如果再向线程池内提交任务,就会遭到拒绝。
  • 第二种情况是线程池没有能力继续处理新提交的任务的时候。

image-20220209155552924

AbortPolicy

这个拒绝策略在拒绝任务时,会直接抛出一个类型为 RejectedExecutionExceptionRuntimeException,让你感知到任务被拒绝了,于是你便可以根据业务逻辑选择重试或者放弃提交等策略。

DiscardPolicy

这个拒绝策略正如它的名字所描述的一样,当新任务被提交后直接被丢弃掉,也不会给你任何的通知,相对而言存在一定的风险,因为我们提交的时候根本不知道这个任务会被丢弃,可能造成数据丢失。

DiscardOldestPolicy

如果线程池没被关闭且没有能力执行,则会丢弃任务队列中的头结点,通常是存活时间最长的任务,这种策略与第二种不同之处在于它丢弃的不是最新提交的,而是队列中存活时间最长的,这样就可以腾出空间给新提交的任务,但同理它也存在一定的数据丢失风险。

CallerRunsPolicy

当有新任务提交后,如果线程池没被关闭且没有能力执行,则把这个任务交于提交任务的线程执行,也就是谁提交任务,谁就负责执行任务。

这样做主要有两点好处:

  • 第一点新提交的任务不会被丢弃,这样也就不会造成业务损失。
  • 第二点好处是,由于谁提交任务谁就要负责执行任务,这样提交任务的线程就得负责执行任务,而执行任务又是比较耗时的,在这段期间,提交任务的线程被占用,也就不会再提交新的任务,减缓了任务提交的速度,相当于是一个负反馈。在此期间,线程池中的线程也可以充分利用这段时间来执行掉一部分任务,腾出一定的空间,相当于是给了线程池一定的缓冲期。

JDK 提供的线程池

FixedThreadPool

通过 Executors 工具类创建,源码如下:

Executors.newFixedThreadPool(5);

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

我们可以发现,它的核心线程数量和最大线程数量是一样的。

CachedThreadPool

通过 Executors 工具类创建,源码如下:

Executors.newCachedThreadPool();

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

我们可以发现,它的核心线程数量为 0,最大线程数量为 Integer 的最大值,这也就意味着你来多少任务它就创建多少线程,不建议使用这个线程池。

ScheduledThreadPool

它支持定时或周期性执行任务。比如每隔 10 秒钟执行一次任务,而实现这种功能的方法主要有 3 种,代码:

ScheduledExecutorService service = Executors.newScheduledThreadPool(10);
// (1)
service.schedule(new Task(), 10, TimeUnit.SECONDS);
// (2)
service.scheduleAtFixedRate(new Task(), 10, 10, TimeUnit.SECONDS);
// (3)
service.scheduleWithFixedDelay(new Task(), 10, 10, TimeUnit.SECONDS);
  • 第一种方法 schedule 比较简单,表示延迟指定时间后执行一次任务,如果代码中设置参数为 10 秒,也就是 10 秒后执行一次任务后就结束。
  • 第二种方法 scheduleAtFixedRate 表示以固定的频率执行任务,它的第二个参数 initialDelay 表示第一次延时时间,第三个参数 period 表示周期,也就是第一次延时后每次延时多长时间执行一次任务。
  • 第三种方法 scheduleWithFixedDelay 与第二种方法类似,也是周期执行任务,区别在于对周期的定义,之前的 scheduleAtFixedRate 是以任务开始的时间为时间起点开始计时,时间到就开始执行第二次任务,而不管任务需要花多久执行;而 scheduleWithFixedDelay 方法以任务结束的时间为下一次循环的时间起点开始计时。

SingleThreadExecutor

通过 Executors 工具类创建,源码如下:

Executors.newSingleThreadExecutor();

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

我们可以发现,它的核心线程数量为 1,最大线程数量也为 1。适合用于所有任务都需要按被提交的顺序依次执行的场景。

ForkJoinPool

与其他线程池有两点不同:

(1)非常适合执行可以产生子任务的任务。

(2)ForkJoinPool 线程池中每个线程都有自己独立的任务队列

ForkJoinPool 非常适合用于递归的场景,例如树的遍历、最优路径搜索等场景。

JDK 提供的线程池如何选择?

先说答案:都不用,一般都用自己自定义的线程池

下面来一个个分析 JDK 提供的线程池都有哪些缺点。

FixedThreadPool

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

重点是使用的队列是容量没有上限的 LinkedBlockingQueue,如果我们对任务的处理速度比较慢,那么随着请求的增多,队列中堆积的任务也会越来越多,最终大量堆积的任务会占用大量内存,并发生 OOM ,这会影响到整个程序,会造成很严重的后果。

SingleThreadExecutor

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

任务队列仍是无界的 LinkedBlockingQueue,所以也会导致同样的问题,也就是当任务堆积时,可能会占用大量的内存并导致 OOM。

CachedThreadPool

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

最大线程数量被设置成了 Integer.MAX_VALUE。当任务数量特别多的时候,就可能会导致创建非常多的线程,最终超过了操作系统的上限而无法创建新线程,或者导致内存不足。

CPU 核心数和线程数的关系?

我们调整线程池中的线程数量的最主要的目的是为了充分并合理地使用 CPU 和内存等资源,从而最大限度地提高程序的性能。在实际工作中,我们需要根据任务类型的不同选择对应的策略。

CPU 密集型任务

如加密、解密、压缩、计算等一系列需要大量耗费 CPU 资源的任务。

这样的任务最佳的线程数为 CPU 核心数的 1~2 倍。

耗时 IO 型任务

如数据库、文件的读写,网络通信等任务,这种任务的特点是并不会特别消耗 CPU 资源,但是 IO 操作很耗时,总体会占用比较多的时间。

《Java并发编程实战》的作者 Brain Goetz 推荐的计算方法:

线程数 = CPU 核心数 *(1 + 平均等待时间/平均工作时间)

结论

  • 线程的平均工作时间所占比例越高,就需要越少的线程;
  • 线程的平均等待时间所占比例越高,就需要越多的线程;
  • 针对不同的程序,进行对应的实际测试就可以得到最合适的选择。

自定义线程池的参数如何设置?

核心线程数

线程的平均工作时间所占比例越高,就需要越少的线程;线程的平均等待时间所占比例越高,就需要越多的线程。

对于最大线程数而言,如果我们执行的任务类型不是固定的,比如可能一段时间是 CPU 密集型,另一段时间是 IO 密集型,或是同时有两种任务相互混搭。那么在这种情况下,我们可以把最大线程数设置成核心线程数的几倍,以便应对任务突发情况。

更好的办法是用不同的线程池执行不同类型的任务,让任务按照类型区分开,而不是混杂在一起。

阻塞队列

常用的阻塞队列叫 ArrayBlockingQueue,它也经常被用于线程池中,这种阻塞队列内部是用数组实现的,在新建对象的时候要求传入容量值,且后期不能扩容,所以 ArrayBlockingQueue 的最大的特点就是容量是有限的。

线程工厂

可以使用默认的 defaultThreadFactory,也可以传入自定义的有额外能力的线程工厂,因为我们可能有多个线程池,而不同的线程池之间有必要通过不同的名字来进行区分,所以可以传入能根据业务信息进行命名的线程工厂,以便后续可以根据线程名区分不同的业务进而快速定位问题代码。

可以通过 com.google.common.util.concurrent.ThreadFactoryBuilder 来实现:

ThreadFactoryBuilder builder = new ThreadFactoryBuilder();
ThreadFactory rpcFactory = builder.setNameFormat("rpc-pool-%d").build();

上面代码生成的线程的名字是有固定格式的,它生成的线程的名字分别为"rpc-pool-1","rpc-pool-2" ,以此类推。

线程池的参数其实是不好设置的,因为我们不好事前估计

拒绝策略

根据业务需求可以选择 JDK 提供的拒绝策略,也可以实现 RejectedExecutionHandler 来自定义策略,执行例如打印日志、暂存任务、重新执行等自定义的拒绝策略,以便满足业务需求。

关闭线程池

shutdown()

它可以安全地关闭一个线程池。

调用 shutdown() 方法之后线程池并不是立刻就被关闭,因为这时线程池中可能还有很多任务正在被执行,或是任务队列中有大量正在等待被执行的任务,调用 shutdown() 方法后线程池会在执行完正在执行的任务和队列中等待的任务后才彻底关闭。

调用 shutdown() 方法后如果还有新的任务被提交,线程池则会根据拒绝策略直接拒绝后续新提交的任务。

isShutdown()

它可以返回 true 或者 false 来判断线程池是否已经开始了关闭工作,也就是是否执行了 shutdown 或者 shutdownNow 方法。

注意,如果调用 isShutdown() 方法的返回的结果为 true 并不代表线程池此时已经彻底关闭了,这仅仅代表线程池开始了关闭的流程,也就是说,此时可能线程池中依然有线程在执行任务,队列里也可能有等待被执行的任务。

isTerminated()

这个方法可以检测线程池是否真正“终结”了,这不仅代表线程池已关闭,同时代表线程池中的所有任务都已经都执行完毕了。

awaitTermination()

这个方法主要用来判断线程池状态的。

调用 awaitTermination 方法后当前线程会尝试等待一段指定的时间,如果在等待时间内,线程池已关闭并且内部的任务都执行完毕了,也就是说线程池真正“终结”了,那么方法就返回 true,否则超时返回 fasle

shutdownNow()

这个方法的作用是立刻关闭线程池

在执行 shutdownNow 方法之后,首先会给所有线程池中的线程发送 interrupt 中断信号,尝试中断这些任务的执行,然后会将任务队列中正在等待的所有任务转移到一个 List 中并返回,我们可以根据返回的任务 List 来进行一些补救的操作,例如记录在案并在后期重试。

源码如下:

public List<Runnable> shutdownNow() {
    List<Runnable> tasks;
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try {
        checkShutdownAccess();
        advanceRunState(STOP);
        // 发送中断信号
        interruptWorkers();
        tasks = drainQueue();
    } finally {
        mainLock.unlock();
    }
    tryTerminate();
    // 返回等待的任务
    return tasks;
}

注意的是,由于 Java 中不推荐强行停止线程的机制的限制,即便我们调用了 shutdownNow 方法,如果被中断的线程对于中断信号不理不睬,那么依然有可能导致任务不会停止。

拓展阅读

Java线程池实现原理及其在美团业务中的实践

记一次故障引发的线程池使用的思考

一次线上线程池任务问题处理历程