速度优化:CPU 优化(上)

3,583 阅读14分钟

在上一章中,我们已经认识到了 CPU 对应用的速度至关重要,并且介绍了“指令数、时钟时间、指令平均时钟时间”这三个影响 CPU 时间的关键因素,以及基于这三个因素衍生出的系列优化方案,这些方案都是为了能充分发挥 CPU 的利用率。

那接下来,我会带着你一起深入到具体的优化方案中。我们会详细讲解下面这 3 种方案:

  1. 合理的线程池和线程的使用;

  2. 充分利用 CPU 闲置时间;

  3. 减少 CPU 的等待。

之所以详细介绍这三种方案,是因为它们在发挥 CPU 的利用率上投入产出比最高,也最容易落地的。为了方便大家吸收和理解,这三种方案我们会分两章来讲,这一章主要介绍线程池和线程的使用。

话不多说,我们直接开始今天的学习吧!

线程池的重要性

作为开发人员必备的知识,线程池的重要性不言而喻。之前在讲通过减少线程的数量来降低虚拟内存的优化方案时,我们已经介绍过一些线程池的知识,但还不够深入。在提升应用速度上,合理使用线程能极大地提高 CPU 的利用率,那怎样才是合理地使用线程呢?我觉得应该符合这几个条件,

  1. 线程不能太多也不能太少: 线程太多会浪费 CPU 资源用于任务调度上,并且会减少了核心线程在单位时间内所能消耗的 CPU 资源。线程太少了则发挥不出 CPU 的性能,浪费了 CPU 资源。
  1. 减少线程创建及状态切换导致的 CPU 损耗: 线程的频繁创建销毁,或者频繁的状态切换,如休眠状态切换到运行状态,或者运行状态切换都休眠状态,这些都是对 CPU 资源的损耗。

如何才能在使用线程的时候符合上面讲的两个条件呢?我觉得要尽量做到两点:第一点是要收敛应用中的线程,包括野线程和各个业务的自定义线程池等,具体方法我们已经在第 7 章讲过;第二点就是要使用线程池, 我们在应用开发过程中使用的线程,最好全部都是从线程池创建的,并且我们还要能正确地使用线程池, 我们可以从以下 3 方面来入手:

  1. 如何创建线程池;
  1. 线程池的类型和特性;
  1. 如何使用线程池。

下面我们先看如何创建线程池。

线程池创建

刚开始接触线程的开发者大都是通过 Executors 对象来创建线程池的,这个对象是 Java 提供给我们用来创建线程池的工具类,并且大家可能会被 Executors 中众多创建线程池的方法所困扰,如下图中所示。Executors 这个对象里面有十几个 newxxxThreadPool 的静态方法来创建线程池,我该选择哪一个呢?

通过查看这些 newxxxThreadPool 的实现,发现这里创建的其实只是下面这三个对象中的一个而已。

首先,可以从源码中看到,newSingleThreadExecutor、newFixedThreadPool、newCacheThreadPool 等实际都是创建了 ThreadPoolExecutor 这个对象,ThreadPoolExecutor 是我们使用最多的线程池,IO 线程池或 CPU 线程池都是 ThreadPoolExecutor 不同入参的实例。

其次,ScheduledThreadPoolExecutor 实际是调度线程池,如果我们想要执行延时任务或者周期性任务,就需要使用这个线程池,通过源码可以看到 newSingleThreadScheduledExecutor、newSingleThreadScheduledExecutor、newScheduledThreadPool 等方法中创建的都是这个对象。ScheduledThreadPoolExecutor 调度线程池实际也是继承自 ThreadPoolExecutor 然后进行的封装,所以熟悉了 ThreadPoolExecutor,基本也就熟悉了调度线程池。

剩下的 newxxxThreadPool 创建了 ForkJoinPool 这个线程池,它其实是在 Java 1.8 才出现的一种线程池,专门用来处理并发类算法,使用场景较少,所以这里不用太关心它。

分析 Executors 对象创建线程池的方法实现,我们可以发现 ThreadPoolExecutor 才是真正的线程池实现类,所以我们一起深入了解一下 ThreadPoolExecutor。

线程池构造函数分析

我们创建的线程池大都是 ThreadPoolExecutor 不同入参的实现类,所以我们先看一下它入参最多的构造函数:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)

如果我们将 ThreadPoolExecutor 构造函数中的入参全部熟悉了,我们也就完全掌握了线程池的用法,下面就详细讲解一下该构造函数中每个入参的含义:

入参说明
int corePoolSize表示核心线程数量:在创建了线程池后,线程池中此时线程数为 0,当有任务来到需要执行时,就会创建一个线程去执行任务,当线程池中的线程数目达到 corePoolSize 后,就会把后面到来的任务放到缓存队列中。除非手动调用了allowCoreThreadTimeOut(boolean) 这个方法,用来申明核心线程需要退出,否则核心线程启动后便一直是存活不退出的状态。
int maximumPoolSize表示线程池中最多能创建线程数量:当核心线程全在执行任务时,又有新任务到来,任务会放在缓存队列中,如果缓存队列也满了,才会启动新的线程来执行这些任务,这些线程也成为非核心线程,非核心线程的数量加上核心线程的数量就是线程池最多能创建的线程数量。
long keepAliveTime表示非核心线程的存活时间:当线程池中某个非核心线程线程空闲的时间达到 keepAliveTime,该线程就会退出,直到线程池中的线程数不超过 corePoolSize,所以这个参数对核心线程是无效的,因为核心线程不会退出,只对非核心线程有效。
TimeUnit unit表示 keepAliveTime 的时间单位,如秒、毫秒等
BlockingQueue workQueue表示任务缓存队列:常见的缓存队列有这些:1. LinkedBlockingDeque 是一个双向的并发队列,主要用于 CPU 线程池;2. SynchronousQueue 虽然也是一个队列,但它并不能存储 task,所以每当这个队列添加一个 task 时,由于超出了存储队列的容量线程,线程池这个时候都会创建一个新线程来执行这个 task,用于 IO 线程池中。
ThreadFactory threadFactory线程工厂:可自定义创建线程的方式,设置线程名称,可以默认使用Executors.DefaultThreadFactory("线程名"),在虚拟内存优化时,也提到过可以使用自定义的线程工厂,来创建栈空间只有 512 KB 的线程。
RejectedExecutionHandler handler异常处理:所以因异常而无法执行的线程,比如线程池已经满了之后,新的任务就无法执行了,都会放在 RejectedExecutionHandler 中做兜底处理。

这里需要特别注意,只有缓存队列容量满了,即缓存队列中缓存的 task 达到上限时,才会开始创建非核心线程,我们可以通过 ThreadPoolExecutor 的 execute 方法实现证实这一点:

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();

    int c = ctl.get();
    //当线程数小于核心线程,则直接创建线程执行任务
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    //当核心线程已满并且都在运行状态,则将task添加到workQueue缓存队列中
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            //如果线程数为 0,则调用addWorker创建线程
            addWorker(null, false);
    }
    //当task往队列添加失败时,才会调用addWorker启动新的线程
    else if (!addWorker(command, false))
        reject(command);
}

通过上面对入参的解释,我们基本能看懂 Executors 对象中创建的线程池代码,也能自己去创建一个线程池了,但想要创建一个能正确匹配业务场景的线程池,还需要对线程池的类型有深入了解,下面我们就看一下线程池类型有哪些,它们又各有什么特性。

线程池类型及特性

Executors 对象中有很多线程池的静态方法,如 newFixedThreadPool、newFixedThreadPool、newCachedThreadPool 等等,这些方法通过不同入参来实现不同类型的 ThreadPoolExecutor 线程池实例,但是我们先不用关心这些线程池的用法,而是来创建符合自己业务的线程池。那在业务中使用最频繁的,主要是以下 3 类线程池。

  1. CPU 线程池:用来处理 CPU 类型任务,如计算,逻辑操作,UI 渲染等。
  1. IO 线程池:用来处理 IO 类型任务,如拉取网络数据,往本地磁盘、数据读写数据等。
  1. 其他线程池:自定义用来满足业务独特化需求的线程池。

不同类型的线程池有不同的职责,专门用来处理对应类型的任务,下面一起来看一下如何创建不同类型的线程池。

CPU 线程池

首先是 corePoolSize 核心线程数。CPU 线程池是用来执行 CPU 类型任务的,所以它的核心线程数量一般为 CPU 的核数,理想情况下等于核数的线程数量性能是最高的,因为我们既能充分发挥 CPU 的性能,还减少了频繁调度导致的 CPU 损耗。不过,程序在实际运行过程中无法达到理想情况,所以将核心线程数设置为 CPU 核数个可能不是最优的,但绝对是最稳妥且相对较优的方案。

接着是 maximumPoolSize 最大线程数。对于 CPU 线程池来说,最大线程数就是核心线程数,为什么呢?因为 CPU 的最大利用率就是每个核都满载,想要达到满载只需要核数个并发线程就行了,我们已经设置了等于核数量的核心线程,那核心线程就能够完全发挥出 CPU 资源了,所以即使我们用更多的线程,只会增加 CPU 调度的损耗。既然最大线程数就是核心线程数,那 keepAliveTime 这个非核心线程数的存活时间就是零了。

然后是 workQueue 存储队列。CPU 线程池中统一使用 LinkedBlockingDeque,这是一个可以设置容量并支持并发的队列。由于 CPU 线程池的线程数量较少,如果较多任务来临的话,就需要放在存储队列中,所以这个存储队列不能太小,否则队列满了之后,新来的任务就会进入到错误兜底的处理逻辑中。我们可以将存储队列设置成无限大,但如果想要追求更好的程序稳定性则不建议这样做了。

如果程序有些异常的死循环逻辑不断地往队列添加任务,而这个队列就能一直接受任务,但是却会导致程序表现异常,因为 CPU 线程池全部用来执行这个异常任务了。但是当我们将这个队列设置成有限的,比如 64 个,那这个异常的死循环就会将队列打满,让接下来的任务进入到兜底逻辑中,而我们可以在兜底逻辑中设置监控,就能及时发现这个异常了。

至于 ThreadFactory 线程工程和 RejectedExecutionHandler 兜底处理的 handler 逻辑,可以使用默认的,如果我们有特别的需要,比如通过 ThreadFactory 设置优先级,线程名或者优化线程栈大小,或者在兜底逻辑中增加监控,都可以通过继承对应的类来进行扩展。

了解了 CPU 线程需要的入参,我们再来看 Exectors 工具类,就可以发现通过 newFixedThreadPool 创建的线程池实际上就是 CPU 线程池的,通过命名也可以猜到,这是一个线程数固定的线程池,所以符合 CPU 线程池线程数固定是 CPU 核数个这一特性。我们在使用的时候,还可以通过带 ThreadFactory 入参的这个方法 ,调整 FixedThreadPool 线程池的线程优先级。

IO 线程池

IO 线程池主要用来执行 IO 任务,IO 任务实际上消耗的 CPU 资源是非常少的,当我们要读写数据的时候,会交给 DMA (直接存储器访问)芯片去做,此时调度器就会把 CPU 资源切换给其他的线程去使用。因为 IO 任务对 CPU 资源消耗少,所以每来一个 IO 任务就直接启动一个线程去执行它就行了,不需要放入缓存队列中,即使此时执行了非常多的 IO 任务,也都是 DMA 芯片在处理,和 CPU 无关。了解了这一特性,我们再来看看 IO 线程池的入参如何设置。

corePoolSize 核线程数没有定性规定,它和我们 App 的类型有关 如果 IO 任务比较多,比如新闻咨询类的应用或者大型应用,可以设置得多一些,十几个也可以,太少了就会因为 IO 线程频率创建和销毁而产生损耗。如果应用较少,IO 任务不多,直接设置为 0 个也没问题。

maximumPoolSize 最大线程数可以多设置一些,确保每个 IO 任务都能有线程来执行,毕竟 IO 任务对 CPU 的消耗不高。一般来说,中小型应用设置 60 个左右就足够了,大型应用则可以设置 100 个以上。这里不建议将数量设置得特别大,是为了防止程序出现异常 BUG创建大量的 IO 线程(比如某个场景标志位错误导致逻辑不退出,然后一直创建 IO 线程) ,虽然 IO 任务执行消耗 CPU 资源不多,但是线程的创建和销毁是需要消耗 CPU 资源的。

接着是 IO 线程池的缓存队列,对于 IO 线程池来说,是不需要缓存队列的,因为每来一个 IO 任务,都会创建一个新的线程去执行,但是为了符合线程池的设计架构,还是需要传一个队列数据结构进去,所以传入 SynchronousQueue 这个队列即可,它是一个容量为 0 的队列。

了解了上面的知识,我们再来看 Exectors 工具类,发现通过 newCacheThreadPool 创建的线程池实际上就是对应 IO 线程池的,但是通过 newCacheThreadPool 创建出来的 IO 线程池并不是最优的。我们可以看到,它的核心线程池数量为 0,并且最大线程数量为无限大。我们完全可以抛弃Exectors 提供的方法,按照自己的规则去创建 IO 线程池。这里需要注意的是,我们在设置 IO 线程池的线程优先级时,需要比 CPU 线程池的线程优先级高一些,因为 IO 线程中的任务是不怎么消耗 CPU 资源的 优先级 更高一些,可以避免得不到调度的情况出现。

其他线程池

其他类线程池有很多,但这里都统一归于一类,这些线程都是为了满足特定业务使用,并不是每个业务都需要用到。比如说,我们有很多需要执行延时任务或者周期性任务的业务,这时就需要使用 ScheduledThreadPoolExecutor 调度线程池,该线程池也是继承自 ThreadPoolExecutor 对象然后进行的封装,所以和 ThreadPoolExecutor 的原理和用法差别并不大。像是 Java 1.8 版本中才开始出现的 ForkJoinPool,就是专门用来处理并发类算法,一般在服务端或者特殊的 App 上才用到;比如对于既有IO 逻辑又有 CPU 计算逻辑,还无法拆开的任务,我们还可以创建混合型线程池,用来执行这种混合型任务。 其他类型的线程池就不展开讲了,如果你有兴趣也可以自己学习一下相关知识。

线程池使用

当我们创建好 ThreadPoolExecutor 实例后,直接调用 ThreadPoolExecutor 的 execute(Runnable command) 方法就能执行任务了。但是我们在前面学了那么多线程池相关的知识,所以再也不会像开发新手一样,随随便便调用 execute 方法来执行任务了,而是会根据任务的类型来进行调度。如果是 CPU 类型的任务,就需要放在 CPU 线程池中去运行,如果是 IO 类型任务,就需要放在 IO 线程池去运行 。那 如果我们对所运行的任务类型不清楚怎么办?我们可以通过插桩将 Runnable 的 run 方法的执行时间以及对应的线程池打印出来,如果任务耗时较久, 是在 CPU 线程池执行的,那我们就需要考虑该任务是否需要放在 IO 线程池去执行了。

小结

正确地使用线程池可以帮助我们合理地使用线程,并将 CPU 的性能充分发挥。因此,熟练掌握线程池是 Android 开发者进阶的必经之路。

所以这一章详细介绍了线程池的知识,包括如何创建线程池,以及线程池的种类和特性,特别是 CPU 线程池和 IO 线程池的定义,它们是如何创建的。当我们能了解线程池的原理、了解如何合理设置线程池的数据、了解各类线程池的特性后,使用线程池就容易很多了,将特定的任务放入特定的线程池中执行,各司其职即可。