想象一下我们有两个任务A和B,A和B之间没有依赖关系。如果想要异步执行,那么最容易想到的是使用将A和B在不同的线程中执行。代码如下所示。

public class ThreadDemo {
    public static void main(String[] args) {
        var start = System.currentTimeMillis();
        // 创建一个线程,创建一个任务,并立即做事情A,不等待A返回。
        new Thread(() -> doSomethingA()).start();

        doSomethingB();
        System.out.println(System.currentTimeMillis() - start);
// Thread.currentThread().join();
    }

    private static void doSomethingB() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("---doSomethingB---");
    }

    private static void doSomethingA() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("---doSomethingA---");
    }
}

直接创建和使用线程的缺点有如下这些:

  • 线程的创建和销毁都是有开销的,更好的做法是复用线程,即采用线程池。
  • 没有限制的创建线程,导致线程个数过多,会耗尽系统资源。
  • 使用起来不方便,增加了RD的使用负担。

我们一般都会将任务提交到线程池执行。

1 初步体验线程池

看下面的一个例子,将异步执行的任务(这里是事情A)交给线程池来处理,主线程来执行另一个任务(这里是事情B)。这样,两个任务就可以同时执行了。

public class ExecutorDemo {
    private final static int PROCESSOR = Runtime.getRuntime().availableProcessors();
    // 创建一个线程池
    private final static ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(PROCESSOR, PROCESSOR * 2, 1, TimeUnit.MINUTES,
            new LinkedBlockingQueue<>(5), new ThreadPoolExecutor.CallerRunsPolicy());
    public static void main(String[] args) throws InterruptedException {
        var start = System.currentTimeMillis();
        // 在线程池中执行方法A
        EXECUTOR.execute(() -> {
            try {
                doSomethingA();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        // 主线程执行方法A之后,不等待结果,接着执行方法B
        doSomethingB();
        System.out.println(System.currentTimeMillis() - start);
    }

    private static void doSomethingB() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("---doSomethingB---");
    }

    private static void doSomethingA() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("---doSomethingA---");
    }
}

如果采用同步执行的方式,计算图(计算图是一幅图,其中每个节点对应于一个操作或一个变量。变量可以将其值输入操作,操作可以将其结果输入其他操作。)如下

如果采用异步的方式,则计算图如下

显式采用线程池的方式来实现异步有如下缺点:

  • 如果提交的任务是Runnable类型,则无法获取任务的执行结果。
  • 如果提交的任务是Callable类型,则可以通过Future来获取任务执行结果,但必须以同步阻塞的方式获取。
  • 当任务的计算图节点比较多时,显式使用线程池的方式很吃力或者无法完成。例如下面的计算图,箭头表示依赖关系,任务C和D均依赖于任务A执行完成,任务C和任务D可以独立执行,任务E依赖于任务C、D和F执行完成。如果显式使用线程池,很难实现。之后,我们会介绍解决该问题的反应式流框架rxjava框架和reactor框架。

采用线程池的方式,就需要对线程池的原理和各个参数有了解,下面简要介绍一下。

2 了解线程池

ThreadPoolExecutor是线程池的实现的关键类,用来提交任务到线程池中并在线程中执行,可以认为它就是一个任务的执行器,包括提交任务和执行任务。

类图

ThreadPoolExecutor的类图如下

  • Executor 接口类,定义了void execute(Runnable command)方法,该接口将任务提交和任务执行两个逻辑解耦。
  • ExecutorService 接口类,继承了Executor,提供了停止任务执行的方法和获取异步任务执行结果的方法。
  • AbstractExecutorService 抽象类,实现了ExecutorService的部分方法。
  • ThreadPoolExecutor 继承了抽象类AbstractExecutorService,实现了线程池的功能。
  • ThreadFactory 线程池工厂,提供了方法Thread newThread(Runnable r),用于创建新的线程来执行任务。
  • RejectedExecutionHandler 接口类,任务拒绝执行处理器,当线程池ThreadPoolExecutor无法处理任务时,会调用RejectedExecutionHandler来处理。
  • Worker 实际执行任务的类

创建ThreadPoolExecutor对象的核心参数

public ThreadPoolExecutor(int corePoolSize,
                      int maximumPoolSize,
                      long keepAliveTime,
                      TimeUnit unit,
                      BlockingQueue<Runnable> workQueue,
                      ThreadFactory threadFactory,
                      RejectedExecutionHandler handler)
  • corePoolSize 核心线程个数
  • maximumPoolSize 最大线程个数
  • keepAliveTime 非核心不活跃线程的最大存活时间
  • unit keepAliveTime的单位
  • workQueue 阻塞队列,用来存放将要被执行的任务。类型为BlockingQueue。
  • threadFactory 线程池ThreadPoolExecutor用来创建线程的工厂
  • handler 当线程池饱和(达到了最大线程个数,且阻塞队列已满)之后,任务的拒绝策略。

线程池状态机

(图片来自https://www.jianshu.com/p/d2729853c4da)

线程池有5种状态:

  • RUNNING 能接收新提交的任务和处理阻塞队列中的任务。
  • SHUTDOWN 不再接收提交的任务,但可以处理阻塞队列中现有的任务。
  • STOP 不再接收提交的任务,不再处理阻塞队列中现有的任务,中断正在处理任务的线程。
  • TIDYING 所有的任务已经被终止,workerCount为0。进入该状态后,会执行terminated()方法来进入TERMINATED状态。
  • TERMINATED terminated()方法执行完成

execute方法执行流程

用户线程向线程池提交任务的方法为execute(Runnable command)。该方法的执行逻辑如下:

(图片来自https://www.jianshu.com/p/d2729853c4da)

可以看出,主要是判断核心线程数、阻塞队列和最大线程数这三个参数是否满足条件。因此,在创建线程池时,这三个参数很重要。

拒绝策略

当满足一下任意一个条件时,向线程池提交任务就会被拒绝:

  • 线程池已经被关闭
  • 线程池使用有界的任务阻塞队列和有限的maximumPoolSize,且两者都饱和了。

当提交任务被拒绝后,有一些处理策略:

  • ThreadPoolExecutor.AbortPolicy 直接抛出RejectedExecutionException异常
  • ThreadPoolExecutor.CallerRunsPolicy 调用execute方法的线程来执行该任务。这种拒绝策略下,会降低任务的提交速率,可以认为是一种简单的反馈控制机制,类似于反应式编程中的回压(反馈)。
  • ThreadPoolExecutor.DiscardPolicy 默默的丢弃任务,不会抛异常。
  • ThreadPoolExecutor.DiscardOldestPolicy 阻塞队列头部(最早提交的)的任务会被丢弃,尝试再次提交任务。注意:再次提交也可能失败,从而重复这个过程。
  • 自定义的RejectedExecutionHandler 用户需要实现RejectedExecutionHandler接口,定义自己的拒绝策略。

阻塞队列大小和maximumPoolSize的tradeoff

阻塞队列用于暂存提交的任务,它与线程池大小有很大的关联:

  • 如果运行的线程数量少于corePoolSize,则执行器总是倾向于添加新线程,而不是排队。
  • 如果等于或者超过corePoolSize的线程正在运行,则执行器会将请求排队,而不是添加新线程来执行任务。
  • 如果阻塞队列已满,且未达到maximumPoolSize,则创建一个新线程来执行提交的任务。若阻塞队列已满,且达到maximumPoolSize,则任务将被拒绝。

阻塞队列有三种选择:

  • 直接递交:例如SynchronousQueue,不会保存任务。每收到一个任务,则需要一个线程来处理。如果当前没有线程,且无法创建新的线程,则任务提交失败。
    • 为了防止任务提交失败,该队列需要一个无限大的maximumPoolSize的线程池。由于使用了无限大的maximumPoolSize的线程池,当任务提交速度超过处理速度时,会导致线程池无限增长,导致资源耗尽。
  • 无界队列:例如LinkedBlockingQueue,若所有的核心线程处于忙碌状态时,会导致接下来接收到的任务都会被排队。换句话说,线程池不会有超过corePoolSize个数的线程,maximumPoolSize的定义失去意义。
    • 当任务的提交速度超过处理速度时,会导致阻塞队列无限增长,导致资源耗尽。
  • 有界队列:例如ArrayBlockingQueue,通过控制maximumPoolSize,可以防止资源耗尽。但是,使用有界队列会更加难以控制线程池的任务运行,阻塞队列大小和maximumPoolSize需要tradeoff。
    • 如果使用较大的阻塞队列和较小的maximumPoolSize,可以降低CPU繁忙程度、降低操作系统资源使用和降低上下文切换的开销,但是,会导致吞吐量较低。
    • 如果使用较小的阻塞队列和较大的maximumPoolSize,会增加CPU繁忙程度和增加上下文切换开销,也会导致吞吐量降低。

3 总结

这篇文章主要是介绍了显式使用线程池的方式来实现异步,并简单介绍了线程池。