likes
comments
collection
share

面试上岸篇之线程池的工作原理与使用

作者站长头像
站长
· 阅读数 4

前言

在面试的时候,无论是刚毕业或者 3 年 5 年的程序员都绕不开的一个知识,那就是多线程。在 Java 中,我们通常使用 ThreadPoolExecutor 类来创建线程池,而不推荐使用 Executors 工厂类。这是为什么?因为是大佬说的【狗头】

看看阿里开发手册是怎么说的

面试上岸篇之线程池的工作原理与使用

线程池

什么是线程池?

  • 线程池是一种利用池化技术思想来实现的线程管理技术。
  • 主要目的是复用已创建的线程,降低频繁创建和销毁线程的资源消耗。
  • 线程池能够统一分配、调优和监控线程,提高线程的可管理性。

为什么使用线程池?

  • 在没有线程池之前,每次执行任务都需要创建新的线程,这会导致线程创建的开销很高。

    • 创建线程的开销

      • 创建新线程需要分配内存、初始化线程数据结构等操作,这些都是相对耗时的。
      • 如果频繁创建线程,会导致系统不断进行这些开销,降低了系统的响应速度。
    • 销毁线程的开销

      • 线程销毁也需要一定的时间,包括资源回收、清理线程数据结构等操作。
      • 频繁销毁线程会增加系统的开销,影响性能。
    • 上下文切换开销

      • 当线程切换执行上下文时,需要保存当前线程的状态并加载新线程的状态。

      • 这个过程称为上下文切换,它会消耗CPU资源。

      • 过多的线程会导致频繁的上下文切换,降低系统的效率。

      什么是线程上下文切换?

      • 当一个CPU在某一时刻只能运行一个线程时,如果当前线程的时间片耗尽或出现阻塞等情况,CPU会切换到另一个线程执行。

      • 在切换时,需要保存当前线程的运行状态,以便下次切换回来时能够继续执行之前的状态。

      • 线程池并不会完全消除上下文切换。主要是在于减少创建和销毁的开销

      • 当线程池中的线程切换执行不同任务时,仍然需要进行上下文切换,包括用户态和内核态的切换。

  • 线程池解决了这些问题,具体如下:

    • 降低资源消耗复用已创建的线程,减少创建和销毁线程的消耗。
    • 提高响应速度:任务到达时,可以立即执行,无需等待线程的创建。
    • 提高线程的可管理性统一分配、调优和监控线程。
  • 过多的线程池也会对系统性能造成影响

    • 线程池也会占用一定的系统资源,包括内存、CPU等

    • 合理设置线程池的参数,避免过多的线程占用过多资源。

    • 如果线程池数量过多,会导致上下文切换开销增加,影响系统性能。

举个例子,假设你是一家快递公司的老板,你需要处理大量的快递订单。这里有两种方式来处理这些订单:

  1. 每次有订单就雇佣一个新的快递小哥
    • 每当有一个新的订单,你都得雇佣一个新的快递小哥,让他去送货。
    • 他送完货之后就给他结账,让他走人。
    • 这样做会导致频繁地雇佣和解雇快递小哥,浪费了不少时间和资源。
  2. 使用线程池
    • 现在,你决定创建一个快递小哥队伍,里面有一些已经准备好的快递小哥。
    • 当有订单到达时,你只需将订单放入队列中,队伍里的快递小哥立刻会从队列中取出订单并送货。
    • 这样,你不必频繁雇佣和解雇快递小哥,而是复用已有的队伍,提高了效率。

线程池的工作原理

ThreadPoolExecutor中的参数

  • corePoolSize 表示线程池的核心线程数。当有任务提交到线程池时,如果线程池中的线程数小于corePoolSize,那么则直接创建新的线程来执行任务。

    如果核心线程处于空闲,为什么不直接使用空闲,而是创建新的核心线程数?

    • 上面说过创建线程是很耗性能的,假如核心线程数是 10,目前核心线程只创建了一个线程,目前也是只有一个任务执行时只是不断使用这一个线程,万一一下来了10 个线程,这下就得再创建 9 个新的线程来应对,这就很耗性能了。
    • 如果在一开始有线程来就创建好之后,一下子来 10 个就能立刻分配这 10 个核心线程去执行了。
    • 所以使用线程池也是耗资源的,由我们自己评估系统当前线程池核心线程是多少才好。
  • workQueue 任务队列,它是一个阻塞队列,用于存储来不及执行的任务的队列。当有任务提交到线程池的时候,如果线程池中的线程数大于等于corePoolSize,那么这个任务则会先被放到这个队列中,等待执行。

    也就是说,如果核心线程数是 10,队列大小是 10,如果有 15 个任务一起进来时,就用 10 个任务能分配到线程执行,其余 5 个去等待队列中等待

  • maximumPoolSize 表示线程池支持的最大线程数量。当一个任务提交到线程池时,线程池中的线程数大于corePoolSize,并且workQueue已满,那么则会创建新的线程执行任务,但是线程数要小于等于maximumPoolSize

    • 如果核心线程数是 10,队列大小是 10,最大线程数是 20
    • 如果有 20 个任务一起进来时,就用 10 个任务能分配到线程执行,其余 10 个去等待队列中等待并且此时核心数满了,队列也已经满了
    • 此时再来一个任务,那么线程池就会再创建一个非核心线程,然后在队列中取出一个任务去执行,新来的去排队
    • 因为最大线程数是 20,所以非核心线程是最多创建 10 个,也就是在上面的基础上还能再新增 10 个任务
    • 如果同时请求的任务多于最大线程数 + 队列大小,那之后来的就不要了(执行拒绝策略 handler)。
  • keepAliveTime 非核心线程空闲时保持存活的时间。非核心线程即workQueue满了之后,再提交任务时创建的线程,因为这些线程不是核心线程,所以它空闲时间超过keepAliveTime后则会被回收。

    也就是说上面的例子,创建的那 10 个非核心数,到时间就会被系统回收

  • unit 非核心线程空闲时保持存活的时间的单位

  • threadFactory 创建线程的工厂,可以在这里统一处理创建线程的属性

  • handler 拒绝策略,当线程池中的线程达到maximumPoolSize线程数后且workQueue已满的情况下,再向线程池提交任务则执行对应的拒绝策略

    1. AbortPolicy(默认策略)
      • 当线程池无法接受新任务时,会抛出RejectedExecutionException异常,拒绝新任务。
      • 示例:假设线程池已满,有一个新任务到达,但是无法执行,因此抛出异常。
    2. CallerRunsPolicy
      • 当线程池无法接受新任务时,新任务会由提交任务的线程(调用者线程)执行,而不是创建新线程
      • 示例:线程池已满,新任务被调用者线程直接执行,假如 main 线程调用的,那么就由 main 执行该任务。
    3. DiscardPolicy
      • 当线程池无法接受新任务时,直接丢弃新任务不抛出异常
      • 示例:线程池已满,新任务被丢弃。
    4. DiscardOldestPolicy
      • 当线程池无法接受新任务时,丢弃队列中最旧的任务,然后尝试执行新任务
      • 示例:比如阻塞队列中有 12345 五个任务在等,并且已经满了,现在再来个老 6,则将 6 放进队列中,将 1 移出去。
    5. 自定义策略
      • 您可以实现自己的拒绝策略,例如将任务记录到日志、发送警报等。
      • 示例:自定义策略根据业务需求执行特定操作。

    一个线程池的工作大概流程图

面试上岸篇之线程池的工作原理与使用

execute() 与submit()

这两个都是提交任务的方式,他们的区别如下

  • execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;

面试上岸篇之线程池的工作原理与使用

  • submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功

    • 并且可以通过 Futureget()方法来获取返回值

    • get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

面试上岸篇之线程池的工作原理与使用

shutdown()与shutdownNow()

  • shutdown() :关闭线程池,线程池的状态变为 SHUTDOWN。线程池不再接受新任务了,但是队列里的任务得执行完毕。
  • shutdownNow() :关闭线程池,线程的状态变为 STOP。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。

isTerminated() 与isShutdown()

  • isShutDown 当调用 shutdown() 方法后返回为 true。
  • isTerminated 当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true

线程池的使用

FixedThreadPool(固定大小线程池)

  • 这种线程池维护固定数量的线程,适用于处理长期运行的任务。

    ExecutorService executor = Executors.newFixedThreadPool(10);
    for (int i = 0; i < 20; i++) {
        executor.execute(() -> System.out.println("Task " + i));
    }
    executor.shutdown();
    

看一下源码

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

public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}
  • 这里的核心线程与最大线程都是 nThreas
  • 阻塞队列的大小则是Integer.MAX_VALUE的无限队列,所以maximumPoolSize其实是一个无效参数,可能会堆积大量的请求,从而导致 OOM
  • 所以这个线程不会出现非核心线程,所以这里的keepAliveTime也设置为 0

SingleThreadExecutor(单线程线程池)

  • 只有一个工作线程的线程池,适用于顺序执行任务。

    ExecutorService executor = Executors.newSingleThreadExecutor();
    for (int i = 0; i < 5; i++) {
        executor.execute(() -> System.out.println("Task " + i));
    }
    executor.shutdown();
    

    看一下源码

    public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>(),
                                    threadFactory));
    }
    
  • 这里的核心线程与最大线程都是1

  • 阻塞队列也是一个无限队列,同理可能会堆积大量的请求,从而导致 OOM

  • 所以这个线程不会出现非核心线程,所以这里的keepAliveTime也设置为 0

CachedThreadPool(缓存线程池)

  • 此线程池根据需要创建新线程,适用于短期任务。

    ExecutorService executor = Executors.newCachedThreadPool();
    for (int i = 0; i < 10; i++) {
        executor.execute(() -> System.out.println("Task " + i));
    }
    executor.shutdown();
    

看一下源码

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>(),
                                  threadFactory);
}
  • 从源码可以看到,核心线程为 0,最大核心线程数为Integer.MAX.VALUE,即它是无界的
  • 所以每次有任务进来,如何没有线程空闲,则会新创建线程,空闲超过 60S 就会被回收。理论上是能无限创建线程的,直到 CPU 沦陷
  • 所以允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

ScheduledThreadPool(定时任务线程池)

ScheduledThreadPoolExecutor 是一个用于执行定时任务的线程池类。相较于 Java 中提供的另一个执行定时任务的类 TimerScheduledThreadPoolExecutor 具有以下优点:

  1. 多线程执行任务:不用担心任务执行时间过长而导致任务相互阻塞。相比之下,Timer 是单线程执行的,可能会出现任务相互阻塞的问题。
  2. 动态线程创建:如果线程失活,ScheduledThreadPoolExecutor 会新建线程执行任务。而 Timer 类的单线程挂掉后不会重新创建线程执行后续任务。

除了上述优点,ScheduledThreadPoolExecutor 还提供了灵活的 API 用于执行任务。它的任务执行策略主要分为两大类:

  • 在一定延迟之后只执行一次某个任务。

  • 在一定延迟之后周期性地执行某个任务。

以下是 ScheduledThreadPoolExecutor 的主要 API:

public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit);

public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit);

public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);

public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit);

其中:

  • 前两个方法属于第一类,即在指定的延迟之后执行任务。区别在于第二个方法执行后会有返回值,而第一个方法执行后没有返回值。
  • 后两个方法属于第二类,即在指定的延迟之后开始周期性地执行任务。其中,scheduleWithFixedDelay 方法的执行间隔是固定的,而 scheduleAtFixedRate 方法的执行时间间隔是不固定的,会在上一个任务执行完成后才开始计时。

以下是使用 scheduleWithFixedDelay()scheduleAtFixedRate() 方法编写的测试用例:

import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ScheduledThreadPoolExecutorTest {
    private ScheduledThreadPoolExecutor executor;
    private Runnable task;

    // 初始化线程池和任务
    // ...

    @Test
    public void testFixedTask() {
        System.out.println("start main thread");
        executor.scheduleAtFixedRate(task, 15, 30, TimeUnit.SECONDS);
        // 等待一段时间
        System.out.println("end main thread");
    }

    @Test
    public void testDelayedTask() {
        System.out.println("start main thread");
        executor.scheduleWithFixedDelay(task, 15, 30, TimeUnit.SECONDS);
        // 等待一段时间
        System.out.println("end main thread");
    }

    // ...
}

对比上述执行结果,可以看出:

  • scheduleAtFixedRate() 方法每次执行任务的开始时间间隔都是固定的,与任务执行时长无关。
  • scheduleWithFixedDelay() 方法每次执行任务的开始时间间隔是上次任务执行时间加上指定的时间间隔。

看一下源码

public ScheduledThreadPoolExecutor(int corePoolSize) {
    super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
          new DelayedWorkQueue());
}
  • 允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM

常见的这几种的线程池都会有弊端,根据自己的业务选择合适的就好

线程数设置多少最合适

本来 CPU 的资源就有限,原本 5 个人(线程)能完成的工作,非要再找几个七大姑八大姨来分一杯羹,这里用的资源多了,其他用的资源就少了,而且多了没啥用的线程,线程上下文切换也要耗时间,那设置多大合适呢

  1. 核心线程数(Core Pool Size)
    • 合理的核心线程数取决于应用程序需求和系统资源。以下是一些建议:
      • 如果应用程序是 CPU 密集型(计算密集型),可以将核心线程数设置为 CPU 核心数的两倍,以充分利用 CPU 资源。
      • 如果应用程序是 I/O 密集型(例如网络请求、数据库查询等),可以根据系统的 I/O 能力和响应时间需求来设置核心线程数。
  2. 最大线程数(Maximum Pool Size)
    • 最大线程数是线程池中允许的最大线程数。当任务队列已满且核心线程都在执行任务时,线程池会创建新的线程,直到达到最大线程数。
    • 合理的最大线程数应该根据系统的负载、资源和并发请求量来设置。不宜过大,以避免过多的线程上下文切换和资源浪费。
  3. 任务队列(Task Queue)
    • 任务队列用于存储待执行的任务。线程池中的线程会从任务队列中取出任务并执行。
    • 队列大小的设置应根据系统的并发请求量、任务执行时间和内存资源来决定。如果任务量较大,可以适当增大队列大小。