【Java 并发编程】线程池

线程池

线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。

使用线程池的好处:

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

  • 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行。

  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

线程池创建方式

通过 ThreadPoolExecutor 创建

ThreadPoolExecutor 的构造函数:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
  • corePoolSize:任务队列未达到队列容量时,最大可以同时运行的线程数量。

  • maximumPoolSize:任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。

  • workQueue:新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

  • keepAliveTime:当线程池中空闲线程数量超过 corePoolSize 时,多余的线程会在多长时间内被销毁。

    当线程池中的线程数量大于 corePoolSize 的时候,这时如果没有新的任务提交,多余的空闲线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime 才会被回收销毁。

    线程池回收线程时,会对核心线程和非核心线程一视同仁,直到线程池中线程的数量等于 corePoolSize ,回收过程才会停止。

  • unit:keepAliveTime 的单位

  • threadFactory:线程工厂,用于创建线程,一般用默认即可。

  • handler:拒绝策略;当任务太多来不及处理时,如何拒绝任务;

    可以选择的拒绝策略:ThreadPoolExecutor.AbortPolicy、ThreadPoolExecutor.CallerRunsPolicy、ThreadPoolExecutor.DiscardOldestPolicy、ThreadPoolExecutor.DiscardPolicy

线程池中各个参数的相互关系:

image

使用线程池的好处是:减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

如何给线程池命名

给线程池里的线程命名通常有下面两种方式:

ThreadFactoryBuilder

示例:

import java.util.concurrent.ThreadFactory;
import com.google.common.util.concurrent.ThreadFactoryBuilder;

ThreadFactory threadFactory = new ThreadFactoryBuilder()
        .setNameFormat(threadNamePrefix + "-%d")
        .setDaemon(true).build();
ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory);
实现一个 ThreadFactory

示例:

import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

public final class NamingThreadFactory implements ThreadFactory {

    private final AtomicInteger threadNum = new AtomicInteger();
    private final ThreadFactory delegate;
    private final String name;

    public NamingThreadFactory(ThreadFactory delegate, String name) {
        this.delegate = delegate;
        this.name = name;
    }

    @Override
    public Thread newThread(Runnable r) {
        Thread t = delegate.newThread(r);
        t.setName(name + " [#" + threadNum.incrementAndGet() + "]");
        return t;
    }
}

通过 JUC 工具类创建

通过 java.util.concurrent.Executors 工具类,我们可以创建多种类型的 ThreadPoolExecutor:

  • FixedThreadPool:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。

  • SingleThreadExecutor: 该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。

  • CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。初始大小为0。当有新任务提交时,如果当前线程池中没有线程可用,它会创建一个新的线程来处理该任务。如果在一段时间内(默认为60秒)没有新任务提交,核心线程会超时并被销毁,从而缩小线程池的大小。

  • ScheduledThreadPool:该方法返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池

使用 Executors 创建线程池对象的弊端如下:

  • FixedThreadPool 和 SingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

  • CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。

  • ScheduledThreadPool 和 SingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

线程池的核心设计与实现

ThreadPoolExecutor 的总体设计

ThreadPoolExecutor 的继承关系

ThreadPoolExecutor 的 UML 类图如下:

image

ThreadPoolExecutor 的运行机制

ThreadPoolExecutor 运行机制如下图所示:

image

线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好地缓冲任务,复用线程。

线程池的运行主要分成两部分:任务管理、线程管理。

  • 任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:

    • 直接申请线程执行该任务;

    • 缓冲到队列中等待线程执行;

    • 拒绝该任务。

  • 线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。

ThreadPoolExecutor 生命周期管理

线程池内部使用一个变量维护两个值:运行状态(runState)和线程数量(workerCount),其源码如下:

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

ctl 这个 AtomicInteger 类型,是对线程池的运行状态和线程池中有效线程的数量进行控制的一个字段,它同时包含两部分的信息:

  • 线程池的运行状态(runState):变量 ctl 的高 3 位保存 runState;

  • 线程池内有效线程的数量(workerCount):变量 ctl 的低 29 位保存 workerCount。

其中,两个变量之间互不干扰,用一个变量去存储两个值,可避免在做相关决策时,出现不一致的情况,不必为了维护两者的一致,而占用锁资源。通过阅读线程池源代码也可以发现,经常出现要同时判断线程池运行状态和线程数量的情况。线程池也提供了若干方法去供用户获得线程池当前的运行状态、线程个数。这里都使用的是位运算的方式,相比于基本运算,速度也会快很多。

ThreadPoolExecutor的运行状态有5种,分别为:

运行状态 描述
RUNNING 能接受新提交的任务,并且也能处理阻塞队列中的任务。
SHUTDOWN 关闭状态,不再接受新提交的任务,但却可以继续处理阻塞队列中已保存的任务。
STOP 不能接受新任务,也不处理队列中的任务,会中断正在处理任务的线程。
TIDYING 所有的任务都已终止了,有效线程数(workCount)为 0
TERMINATED 在 terminated() 方法执行完成后,进入该状态

其生命周期流转状态如下:

image

任务执行机制

任务调度

线程池的执行流程如下图所示:

image

首先,所有任务的调度都是由 execute 方法完成的,这部分完成的工作是:检查现在线程池的运行状态、运行线程数、运行策略,决定接下来执行的流程,是直接申请线程执行,或是缓冲到队列中执行,亦或是直接拒绝该任务。其执行过程如下:

  • 首先检测线程池运行状态,如果不是 RUNNING,则直接拒绝,线程池要保证在 RUNNING 的状态下执行任务。

    • 如果 workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。

    • 如果 workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。

    • 如果 workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。

    • 如果 workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。

任务缓冲

线程池的本质是对任务和线程的管理,而做到这一点最关键的思想就是将任务和线程两者解耦,不让两者直接关联,才可以做后续的分配工作。线程池中是以生产者消费者模式,通过一个阻塞队列来实现的。阻塞队列缓存任务,工作线程从阻塞队列中获取任务。

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

下图中展示了线程1往阻塞队列中添加元素,而线程2从阻塞队列中移除元素:

image

使用不同的队列可以实现不一样的任务存取策略,如下表所示:

阻塞队列 描述
ArrayBlockingQueue 一个用数组实现的有界阻塞队列,此队列按照先进先出(FIFO)的原则对元素进行排序。支持公平锁和非公平锁。
LinkedBlockingQueue 一个用链表结构注册的有界队列,此队列按照先进先出(FIFO)的原则对元素进行排序。
队列默认长度为 Integer.MAX_VALUE,所以,默认创建的该队列有 OOM 风险。
PriorityBlockingQueue 一个支持线程优先级排序的无界队列,默认自然序进行排序,也可以自定义实现 compareTo() 方法用于指定元素的排序规则。
不能保证同优先级元素的顺序。
DelayQueue 一个使用 PriorityBlockingQueue 实现延迟获取的无界队列,在创建元素时,可以指定多久才能从队列中获取当前元素。
只有延时期满后才能从队列中获取元素。
SynchronousQueue 一个不存储元素的阻塞队列,每一个 put 操作必须等待 take 操作,否则不能添加元素。支持公平锁和非公平锁。
LinkedTransferQueue 一个由链表结构组成的无界阻塞队列,与其他队列相比,LinkedTransferQueue 多了 tansfer 和 tryTransfer 方法。
LinkedBlockingQueue 一个由链表结构组成的双向阻塞队列,队列头部和尾部都可以添加和移除元素,多线程并发时,可以将锁的竞争最多降到一半。

任务申请

线程需要从任务缓存模块中不断地取任务执行,帮助线程从阻塞队列中获取任务,实现线程管理模块和任务管理模块之间的通信。

这部分策略由 getTask 方法实现,其源码如下:

public class ThreadPoolExecutor extends AbstractExecutorService {
    ...
    private Runnable getTask() {
        boolean timedOut = false; // Did the last poll() time out?
        for (;;) {
            int c = ctl.get(); // 获取线程池状态、任务数量
            // Check if queue empty only if necessary.
            // 线程池是否已经停止,或者工作队列是否为空
            if (runStateAtLeast(c, SHUTDOWN)
                && (runStateAtLeast(c, STOP) || workQueue.isEmpty())) {
                decrementWorkerCount();
                return null; // 返回 null
            }
            int wc = workerCountOf(c);
            // Are workers subject to culling?
            boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
            if ((wc > maximumPoolSize || (timed && timedOut))
                && (wc > 1 || workQueue.isEmpty())) { // 线程数是否过多
                if (compareAndDecrementWorkerCount(c))
                    return null; // 返回 null
                continue;
            }
            try {
                Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take(); // 如果线程是可回收线程,则限时获取任务;否则,阻塞获取任务
                if (r != null)
                    return r; // 如果是获取成功,则返回该任务
                timedOut = true;
            } catch (InterruptedException retry) {
                timedOut = false;
            }
        }
    }
    ...
}

其执行流程如下图所示:

image

任务拒绝

任务拒绝模块是线程池的保护部分,线程池有一个最大的容量,当线程池的任务缓存队列已满,并且线程池中的线程数目达到maximumPoolSize时,就需要拒绝掉该任务,采取任务拒绝策略,保护线程池。

拒绝策略是一个接口,其设计如下:

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

用户可以通过实现这个接口去定制拒绝策略,也可以选择JDK提供的四种已有拒绝策略,其特点如下:

拒绝策略 描述
ThreadPoolExecutor.AbortPolicy 丢弃任务,并抛出 RejectedExecutionException 异常。这是线程池默认的拒绝策略,在任务不能再提交时,抛出异常,及时反馈程序运行状态。如果是关键业务,推荐使用此拒绝策略,这样系统不能承受更大的并发量时,可以及时通过异常发现。
ThreadPoolExecutor.DiscardPolicy 丢弃任务,但不抛出异常。使用此策略,可能会使我们无法发现系统的异常状态。建议对于一些无关紧要的业务采用此策略。
ThreadPoolExecutor.DiscardOldestPolicy 丢弃队列最前面的任务,然后重新提交被拒绝的任务。需要根据实际业务场景是否允许丢弃老任务,决定是否采用该拒绝策略
ThreadPoolExecutor.CallerRunsPolicy 由调用线程(提交任务的线程)处理该任务。这种情况是需要让所有的任务都执行完毕,那么,就适合大量计算的任务类型去执行,多线程只是增大吞吐量的手段,最终必须让每个任务都执行完毕。

Worker线程管理

线程池处理任务的流程

线程池为了掌握线程的状态并维护线程的生命周期,设计了线程池内的工作线程 Worker。Worker 的部分代码如下:

private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
    final Thread thread; // Worker持有的线程
    Runnable firstTask; // 初始化的任务,可以为null
}

Worker 这个工作线程,实现了 Runnable 接口,并持有一个线程 thread,一个初始化的任务 firstTask。

  • thread 是在调用构造方法时通过 ThreadFactory 来创建的线程,可以用来执行任务;

  • firstTask 用它来保存传入的第一个任务,这个任务可以有也可以为 null。

    • 如果 firstTask 的值是非空的,那么,线程就会在启动初期立即执行这个任务,也就对应核心线程创建时的情况;

    • 如果 firstTask 的值是 null,那么,就需要创建一个线程去执行任务列表(workQueue)中的任务,也就是非核心线程的创建。

Worker 执行任务的模型如下图所示:

image

线程池需要管理线程的生命周期,需要在线程长时间不运行的时候进行回收。线程池使用一张 Hash 表去持有线程的引用,这样可以通过添加引用、移除引用这样的操作来控制线程的生命周期。这个时候重要的就是如何判断线程是否在运行。

Worker 是通过继承 AQS,使用 AQS 来实现独占锁这个功能。没有使用可重入锁 ReentrantLock,而是使用 AQS,为的就是实现不可重入的特性去反应线程现在的执行状态。

  • lock 方法一旦获取了独占锁,表示当前线程正在执行任务中。

  • 如果正在执行任务,则不应该中断线程。

  • 如果该线程现在不是独占锁的状态,也就是空闲的状态,说明它没有在处理任务,这时可以对该线程进行中断。

  • 线程池在执行 shutdown 方法或 tryTerminate 方法时会调用 interruptIdleWorkers 方法,来中断空闲的线程,interruptIdleWorkers 方法会使用 tryLock 方法,来判断线程池中的线程是否是空闲状态;如果线程是空闲状态,则可以安全回收。

在线程回收过程中就使用到了这种特性,回收过程如下图所示:

image

Worker 线程增加

增加线程是通过 ThreadPoolExecutor 的 private boolean addWorker(Runnable firstTask, boolean core) 方法,该方法的功能就是增加一个线程,该方法不考虑线程池是在哪个阶段增加的该线程,这个分配线程的策略是在上个步骤完成的,该步骤仅仅完成增加线程,并使它运行,最后返回是否成功这个结果。

其中,对于 private boolean addWorker(Runnable firstTask, boolean core) 方法:

  • firstTask参数用于指定新增的线程执行的第一个任务,该参数可以为空;

  • 对于 core 参数:

    • core参数为 true 表示在新增线程时会判断当前活动线程数是否少于 corePoolSize;

    • core参数为 false 表示新增线程前需要判断当前活动线程数是否少于 maximumPoolSize。

其执行流程如下图所示

image

Worker 线程回收

线程池中线程的销毁依赖 JVM 自动的回收,线程池做的工作是根据当前线程池的状态维护一定数量的线程引用,防止这部分线程被 JVM 回收,当线程池决定哪些线程需要回收时,只需要将其引用消除即可。

Worker 被创建出来后,就会不断地进行轮询,然后获取任务去执行,核心线程可以无限等待获取任务,非核心线程要限时获取任务。当 Worker 无法获取到任务,也就是获取的任务为空时,循环会结束,Worker会主动消除自身在线程池内的引用。

线程回收的源码如下:

public class ThreadPoolExecutor extends AbstractExecutorService {
    ...
    private void processWorkerExit(Worker w, boolean completedAbruptly) {
        if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
            decrementWorkerCount();

        final ReentrantLock mainLock = this.mainLock;
        mainLock.lock();
        try {
            completedTaskCount += w.completedTasks;
            workers.remove(w);
        } finally {
            mainLock.unlock();
        }

        tryTerminate();

        int c = ctl.get();
        if (runStateLessThan(c, STOP)) {
            if (!completedAbruptly) {
                int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
                if (min == 0 && ! workQueue.isEmpty())
                    min = 1;
                if (workerCountOf(c) >= min)
                    return; // replacement not needed
            }
            addWorker(null, false);
        }
    }
    ...
}

其流程图如下:

image

事实上,在这个方法中,将线程引用移出线程池就已经结束了线程销毁的部分。但由于引起线程销毁的可能性有很多,线程池还要判断是什么引发了这次销毁,是否要改变线程池的现阶段状态,是否要根据新状态,重新分配线程。

Worker 线程执行任务

在 Worker 类中的 run 方法调用了 runWorker 方法来执行任务,runWorker 方法的执行过程如下:

  • while 循环不断地通过 getTask() 方法获取任务。

  • getTask() 方法从阻塞队列中取任务。

  • 如果线程池正在停止,那么要保证当前线程是中断状态,否则要保证当前线程不是中断状态。

  • 执行任务。

  • 如果 getTask 结果为 null,则跳出循环,执行 processWorkerExit() 方法,销毁线程。

其源码如下:

public class ThreadPoolExecutor extends AbstractExecutorService {
    ...
    final void runWorker(Worker w) {
        Thread wt = Thread.currentThread();
        Runnable task = w.firstTask;
        w.firstTask = null;
        w.unlock(); // allow interrupts
        boolean completedAbruptly = true;
        try {
            while (task != null || (task = getTask()) != null) {
                w.lock();
                // If pool is stopping, ensure thread is interrupted;
                // if not, ensure thread is not interrupted.  This
                // requires a recheck in second case to deal with
                // shutdownNow race while clearing interrupt
                if ((runStateAtLeast(ctl.get(), STOP) ||
                     (Thread.interrupted() &&
                      runStateAtLeast(ctl.get(), STOP))) &&
                    !wt.isInterrupted())
                    wt.interrupt();
                try {
                    beforeExecute(wt, task);
                    try {
                        task.run();
                        afterExecute(task, null);
                    } catch (Throwable ex) {
                        afterExecute(task, ex);
                        throw ex;
                    }
                } finally {
                    task = null;
                    w.completedTasks++;
                    w.unlock();
                }
            }
            completedAbruptly = false;
        } finally {
            processWorkerExit(w, completedAbruptly);
        }
    }
    ...
}

执行流程如下图所示:

image

线程池参数设置注意事项

核心线程数偏小,导致大量任务被拒绝

在高并发场景下,如果实际需要并发的任务数量,小于最大线程数,会导致大量任务被拒绝,容易引发业务熔断或者降级,例如:

image

线程池的阻塞队列设置过长,导致任务堆积

在高并发场景下,如果队列长度设置过长、核心线程数过小,会导致最大线程数失效。此时,如果请求数量过高时,会有大量的任务堆积在队列中,导致请求的延迟实际过长,引发业务请求调用大量超时失败。例如:

image

如何设定线程池的大小

并发任务的执行情况和任务类型相关,I/O 密集型和 CPU 密集型的任务运行起来的情况差异非常大,但这种占比是较难合理预估的,这导致很难有一个简单有效的通用公式帮我们直接计算出结果。

这里有一个简单并且适用面比较广的公式,假设 N 为 CPU 核心数:

  • CPU 密集型任务:N + 1

    CPU 密集型任务消耗的主要是 CPU 资源,可以将线程数设置为:N + 1。

    比 CPU 核心数多出来的一个线程,是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。

  • I/O 密集型任务:2N

    I/O 密集型任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此,在 I/O 密集型任务的应用中,我们可以多配置一些线程。

参考:

posted @ 2023-10-12 10:54  LARRY1024  阅读(102)  评论(0编辑  收藏  举报