安卓入门六十三 稳定性与流畅性

稳定性就是减少crash

流畅性就是优化gpu与cpu使用

GPU优化

通过上述内存优化检查GPU消耗对代码进行优化

CPU优化

如何从 CPU 层面进行速度优化?

我们知道,所有的程序最终会被编译成机器码指令,然后交给 CPU 执行,CPU 以流水线的形式一条一条执行程序的机器码指令。当我们想要提升某些场景(如启动、打开页面、滑动等)的速度时,本质上就是降低 CPU 执行完这些场景指令的时间,这个时间简称为 CPU 时间。想要降低 CPU 时间,我们需要先知道程序所消耗 CPU 时间的计算公式:CPU 时间=程序的指令数 x 时钟周期时间 x 每条指令的平均时钟周期数。下面一一解释一下这三项因子的含义。

  • 程序的指令数:这一项很好理解,就是程序编译成机器码指令后的指令数量。

  • 时钟周期时间:每一次时钟周期内,CPU 仅完成一次执行,所以时钟周期时间越短,CPU 执行得越快。或许你对时钟周期时间不熟悉,但是它的倒数也就是时钟周期频率,你肯定听说过。1 纳秒的时钟周期时间就是 1 GHZ 的时钟周期频率,厂商发布新手机或者我们购买新手机时,都或多或少会提到 CPU 的时钟频率,比如高通骁龙 888 这款 CPU 的时钟频率是 2.8 GHZ,这个指标也是衡量 CPU 性能最重要的一个指标。

扫描二维码关注公众号,回复: 17562126 查看本文章
  • 每条指令的平均时间周期:是指令执行完毕所消耗的平均时间周期,指令不同所需的机器周期数也不同。对于一些简单的单字节指令,在取指令周期中,指令取出到指令寄存器后会立即译码执行,不再需要其它的机器周期。对于一些比较复杂的指令,例如转移指令、乘法指令,则需要两个或者两个以上的机器周期。

从 CPU 来看,当我们想要提升程序的速度时,优化这三项因子中的任何一项都可以达到目的。那基于这三项因子有哪些通用方案可以借鉴呢?

减少程序的指令数

通过减少程序的指令数来提升速度,是我们最常用也是优化方案最多的方式,比如下面这些方案都是通过减少指令数来提升速度的。

  • 利用手机的多核:当我们将要提速的场景的程序指令交给多个 CPU 同时执行时,对于单个 CPU 来说,需要执行的指令数就变少了,那 CPU 时间自然就降低了,也就是并发的思想。但要注意的是,并发只有在多核下才能实现,如果只有一个 CPU,即使我们将场景的指令拆分成多份,对于这个 CPU 来说,程序的指令数依然没有变少。如何才能发挥机器的多核呢?使用多线程即可,如果我们的手机是 4 核的,就能同时并发的运行 4 个线程。

  • 更简洁的代码逻辑和更优的算法:这一点很好理解,同样的功能用更简洁或更优的代码来实现,指令数也会减少,指令数少了程序的速度自然也就快了。具体落地这一类优化时,我们可以用抓 trace 或者在函数前后统计耗时的方式去分析耗时,将这些耗时久的方法用更优的方式实现。

  • 减少 CPU 的闲置:通过在 CPU 闲置的时候,执行预创建 View,预准备数据等预加载逻辑,也是减少指令数的一种优化方案,我们需要加速场景的指令数量由于预加载执行了一部分而变少了,自然也就快了。

  • 通过其他设备来减少当前设备程序的指令数:这一点也衍生很多优化方案,比如 Google 商店会把某些设备中程序的机器码上传,这样其他用户下载这个程序时,便不需要自己的设备再进行编译操作,因为提升了安装或者启动速度。再比如在打开一些 WebView 网页时,服务端会通过预渲染处理,将 IO 数据都处理完成,直接展示给用户一个静态页面,这样就能极大提高页面打开速度。

上面提到的这些方案都是我们最常用的方案,基于指令数这一基本原理,还能衍生出很多方案来提升速度,这里没法一一列全,大家也可以自己想一想还能扩展出哪些方案出来。

降低时钟周期时间

想要降低手机的时钟周期,一般只能通过升级 CPU 做到,每次新出一款 CPU,相比上一代,不仅在时钟周期时间上有优化,每个周期内可执行的指令也都会有优化。比如高通骁龙 888 这款 CPU 的大核时钟周期频率为 2.84GHz,而最新的 Gen 2 这款 CPU 则达到了 3.50GHz。

虽然我们没法降低设备的时钟周期,但是应该避免设备提高时钟周期时间,也就是降频现象,当手机发热发烫时,CPU 往往都会通过降频来减少设备的发热现象,具体的方式就是通过合理的线程使用或者代码逻辑优化,来减少程序长时间超负荷的使用 CPU。

降低每条指令的平均时间周期

在降低每条指令的平均时间周期上,我们能做的其实也不多,因为它和 CPU 的性能有很大的关系,但除了 CPU 的性能,以下几个方面也会影响到指令的时间周期。

  • 编程语言:Java 翻译成机器码后有更多的简介调用,所以比 C++ 代码编译成的机器码指令的平均时间周期更长。

  • 编译程序:一个好的编译程序可以通过优化指令来降低程序指令的平均时间周期。

  • 降低 IO 等待:从严格意义来说,IO 等待的时间并不能算到指令执行的耗时中,因为 CPU 在等待 IO 时会休眠或者去执行其他任务。但是等待 IO 会使执行完指令的时间变长,所以这里依然把减少 IO 等待算入是降低每条指令的平均时间周期的优化方案之一。

如何从缓存层面进行速度优化?

程序的指令并不是直接就能被 CPU 执行的,而是要放在缓存中,CPU 从缓存中读取,而且一个程序也不可能全是 CPU 计算逻辑,必然也会涉及到 IO 的操作或等待,比如往磁盘或者内存中读写数据成功后才能继续执行后面的逻辑,所以缓存也是决定应用速度的关键因素之一。缓存对程序速度的影响主要体现在 2 个方面:

  • 缓存的读写速度;
  • 缓存的命中率。

下面就详细讲解一下这 2 方面对速度的影响。

缓存的读写速度

手机或电脑的存储设备都被组织成了一个存储器层次结构,在这个层次结构中,从上至下,设备的访问速度越来越慢,但容量也越来越大,并且每字节的造价也越来越便宜。寄存器文件在层次结构中位于最顶部,也就是第 0 级。下图展示的是三层高速缓存的存储结构。

高速缓存是属于 CPU 的组成部分,并且实际有几层高速缓存也是由 CPU 决定的。以下图高通骁龙 888 的芯片为例,它是 8 块核组成的 CPU,从架构图上可以看到,它的 L2 是 1M 大小(没有 L1 是因为这其实只是序号称呼上的不同而已,你也可以理解成 L1),L3 是 3M 大小,并且所有核共享。

不同层之间的读写速度差距是很大的,所以为了能提高场景的速度,我们需要将和核心场景相关的资源(代码、数据等)尽量存储在靠上层的存储器中。 基于这一原理,便能衍生出了非常多的优化方案,比如常用的加载图片的框架 Fresco,请求网络的框架 OkHttp 等等,都会想尽办法将数据缓存在内存中,其次是磁盘中,以此来提高速度。

缓存的命中率

将数据放在缓存中是一种非常入门的优化思想,也是非常容易办到的,即使是开发新手都能想到以此来提升速度。但是我们的缓存容量是有限的,越上层的缓存虽然访问越快,但是容量越少,价格也越贵,所以我们只能将有限的数据存放在缓存中,在这样的制约下,提升缓存的命中率往往是一件非常难的事情。

一个好的编译器可以提升寄存器的命中率,好的操作系统可以提升高速缓存的命中率,对于我们应用来说,好的优化方案可以提升主存和硬盘的命中率,比如我们常用的 LruCache 等数据结构都是用来提升主存命中率的。除了提升应用的主存,应用也可以提升高速缓存的命中率,只是能做的事情不多,后面的章节中也会介绍如何通过 Dex 中 class 文件重排,来提升高速缓存读取类文件时的命中率。

想要提高缓存命中率,一般都是利用局部性原理(局部性原理指如果某数据被访问,则不久之后该数据可能再次被访问,或者程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问)或者通过行为预测,分析大概率事件等多种原理来提高缓存命中率。

如何从任务调度层面进行速度优化?

我们学过操作系统为了能同时运行多个程序,所以诞生了虚拟内存这个技术,但只有虚拟内存技术是不够的,还需要任务调度机制,所以任务调度也属于操作系统关键的组成之一。有了任务调度机制,我们的程序才能获得 CPU 的资源并正常跑起来,所以任务调度也是影响程序速度的本质因素之一。

我们从两个方面来熟悉任务调度机制,一是调度机制的原理,二是任务的载体,即进程的生命周期。

在 Linux 系统中,任务调度的维度是进程,Java 线程也属于轻量级的进程,所以线程也是遵循 Linux 系统的任务调度规则的,那进程的调度规则又是怎样的呢?Linux 系统将进程分为了实时进程和普通进程这两类,实时进程需要响应技术的进程,比如 UI 交互进程,而普通进程对响应速度要求不是非常高,比如读写文件、下载等进程。两种类型的进程的调度规则也不一样,我们分别来说。

首先是实时进程的调度规则。Linux 系统对实时进程的调度策略有两种:先进先出(SCHED_FIFO)和循环(SCHED_RR)。Android 只使用了 SCHED_FIFO 这一策略,所以我们主要介绍 SCHED_FIFO 。当系统使用先进先出的策略来调度进程时,如果某个进程占有 CPU 时间片,此时没有更高优先级的实时进程抢占 CPU,或该进程主动让出,那么该进程就始终保持使用 CPU 的状态。这种策略会提高进程运行的持续时间,减少被打断或被切换的次数,所以响应更及时。Android 中的 AudIO、SurfaceFlinger、Zygote 等系统核心进程都是实时进程。

而非实时进程也称为普通进程,针对普通进程,Linux 系统则采用了一种完全公平调度算法来实现对进程的切换调度,我们可以不需要知道这一算法的实现细节,但需要了解它的原理。在完全公平调度算法中,进程的优先级由 nice 值表示,nice 值越低代表优先级越大,但是调度器并不是直接根据 nice 值的大小作为优先级来进行任务调度的,当每次进程的时间片执行完后,调度器就会寻找所有进程中运行时间最少的进程来执行。

既然调度器是根据进程的运行时间来进行任务调度,那进程优先级即 nice 值的作用又体现在哪呢?实际上,这里进程的运行时间并不是真实的物理运行时间,而是进行了加权计算的虚拟时间,这个权值系数就是 nice 值,所以同样的物理时间内,nice 值越低的进程所记录的运行时间实际越少,运行时间更少就更容易被调度器所选择,优先级也就这样表现出来了。在 Android 中,除了部分核心进程,其他大部分都是普通进程。

了解了进程的调度原理,我们再来了解一下进程的生命周期。

通过上图可以看到,进程可能有以下几种状态。并且运行、等待和睡眠这三种状态之间是可以互相转换的。

  • 运行:该进程此刻正在执行。

  • 等待:进程能够运行,但没有得到许可,因为 CPU 分配给另一个进程。调度器可以在下一次任务切换时选择该进程。

  • 睡眠:进程正在睡眠无法运行,因为它在等待一个外部事件。调度器无法在下一次任务切换时选择该进程。

  • 终止:进程终止。

知道了任务调度相关的原理后,怎样根据这些原理性知识来优化应用场景的速度呢?实际上,我们对进程的优先级做不了太大的改变,即使改变了也产生不了太大的作用,但是前面提到了线程实际是轻量级的进程,同样遵循上面的调度原理和规则,所以我们真正落地的场景在线程的优化上。基于任务调度的原理,我们可以衍生出这 2 类的优化思路:

  • 提高线程的优先级:对于关键的线程,比如主线程,我们可以提高它的优先级,来帮助我们提升速度。除了直接提高线程的优先级,我们还可以将关键线程绑定 CPU 的大核这一种特殊的方式来提高该线程的执行效率。

  • 减少线程创建或者状态切换的耗时:这一点可以通过在线程池中设置合理的常驻线程,线程保活时间等参数来减少线程频繁创建或者状态切换的耗时。



https://juejin.cn/post/7235279096312856637

“指令数、时钟时间、指令平均时钟时间”这三个是影响 CPU 时间的关键因素

下面这 3 种方案:

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

充分利用 CPU 闲置时间;

减少 CPU 的等待。

线程池的重要性

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

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

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

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

线程池类型及特性

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 线程池去执行了。

https://juejin.cn/post/7253062314307223611

充分利用 CPU 闲置时刻

除了游戏类视频类应用,很少有应用会持续以较高的状态消耗 CPU,并且大部分情况下,CPU 都可能处于闲置状态。如果我们能充分利用 CPU的 闲置时刻,把核心场景运行时需要执行的任务或者数据放在闲置时去提前预加载,那在打开这些场景时,CPU 需要执行的指令数就会减少,速度也就会有提升。

为什么预加载任务要放在 CPU 的闲置时刻呢?如果预加载任务不是放在 CPU 的闲置时刻就会和核心场景抢占资源,导致核心场景速度变慢。比如,我们经常会在启动时预加载一些逻辑以此来提升后面场景的速度,但这样会导致启动变慢。如果把这些任务放在 CPU 闲置后再执行,就能做到既不影响启动的速度,又能提升后面场景的速度了。

想要充分利用 CPU 的闲置时刻,首先需要知道 CPU 已经闲置了,想要做到这一点,我们可以启动一个定时任务,每 5 秒检测一次 CPU 是否已经闲置,如果已经闲置了,则回调通知各个业务进行预加载任务的执行(5 秒不是固定值,需要根据所开发应用的类型来调整,比如是属于 CPU 密集型还是计算密集型)。

方案中最关键的一步就是检测到 CPU 已经闲置,我在这里介绍两种方案:

  1. 通过读取 proc 文件节点下的 CPU 数据判断 CPU 是否闲置;
  1. 通过 times 函数判断 cpu 是否闲置。

下面先从第 1 种讲起。

读取 proc 节点文件

我们知道在 Linux 系统上,设备和应用的大部分信息和数据都会记录在 proc 目录下的某个文件中,比如前面反复提到的 maps 文件。CPU 相关的数据同样也可以在 proc 下的文件中获取,我们可以通过读取 /proc/stat 节点获取 CPU 的总运行时间,通过读取 /proc/应用进程id/stat 文件获取具体某个进程的 CPU 消耗时间。那么我们只需要读取在一段时间内(如 5 秒)应用的 CPU 消耗时间,然后除以 CPU 的总运行时间,就能得出 CPU 占用率这个指标 。

如果 CPU 占用率是较低的,比如低于 30 % 的阈值,就认为 CPU 已经闲置了。当然,30% 这个值也不是固定的,对于一个 8 核的 CPU 来说,极限情况下也就是所有核都在为这个进程服务的时候,CPU 占用率可以接近 800%,而一个 4 核手机,极限情况 CPU 占用率也只能接近 400%,所以对于性能高的手机,这个阈值可以设置高一点,性能差的手机,这个阈值可以设置的低一点。

进程 CPU 使用率

有了上面的数据,接下来我们就可以开始计算 CPU 的使用率了。CPU 使用率是限定在一定的时间范围的,如果在这个时间范围内,使用率低就说明应用没怎么使用 CPU ,即处于闲置状态。这个时间范围不宜太长,5 秒到 60 秒之间都可以。如果我们预加载任务比较多,时间可以缩短一些,比如 5 秒检测一次,在这 5 秒内如果 CPU 是闲置的,就执行预加载任务。但注意预加载任务需要打散,也就是每个闲置周期不能执行太多的预加载任务,避免所有预加载任务都一次执行而导致 CPU 过载。 那么如何计算进程的 CPU 使用率呢?操作如下:

获取 CPU 总运行时间以及进程 CPU 时间:

每隔一段时间片(这里取 5 秒)获取一次数据,这里就可以用调度线程池来执行周期任务了

前后两次相减的进程 CPU 时间除以前后两次相减的总 CPU 时间,就是这 5 秒的 CPU 使用率了

这个时候,如果 cpuUsage 小于阈值,我们就可以通知任务队列或者各个业务执行预加载任务啦。从代码中可以看到,读取 proc 节点需要读取并解析文件,如果我们 5 秒读一次的话,对性能的损耗会比较高,所以为了降低性能损耗,我们可以放在 Native 层通过 C++ 代码来读取和解析。当然,即使通过 C++ 来读取,也还是对性能有一定的损耗,所以接下来给你介绍第二种方式:通过 Times 函数这一种低性能损耗的方式来判断 CPU 是否闲置。

Times 函数

通过文档可以看到,Times 函数可以直接返回用户的 CPU 时间以及系统的 CPU 时间,times 函数是系统函数,会直接从内核拿数据,所以不需要解析文件。

因为 Times 函数是一个系统函数,我们需要在 Native 层才能调用。所以我们直接写一个 Native 方法,然后在 Java 层通过 Jni 执行这个 Native 方法,就能高效获取到进行所消耗的 CPU 时间了。

但是 Times 函数只能读取到应用消耗的 CPU 时间,没法获取总的 CPU 时间,我们怎么计算 CPU 使用率来判断 CPU 是否处于闲置状态呢?实际上,我们并不是一定需要 CPU 使用率才能判断 CPU 是否已经闲置,我们还可以通过应用的 CPU 速率来判断 CPU 是否已经闲置。什么是 CPU 速率呢?CPU 速率 = 单位时间内进程内消耗的 CPU 时间 / 单位时间。以 5 秒为例,则 CPU 速率的计算如下

通过 Demo 的数据可以验证,当应用处于闲置状态时,CpuSpeed 一定在 0.1 以下,我们可以根据应用的特性,通过经验值设定一个闲置的阈值。

通过 Times 函数来计算 CPU 速率,以此判断 CPU 是否已经闲置实现起来比较简单,并且性能也高,所以对于想要判断 CPU 是否闲置来进行预加载任务这一情况,我推荐使用 Times 函数。

只要能判断出 CPU 是否处于空闲状态,我们就能对应用中很多场景的性能带来很大的提升。我们在闲置时可以预执行的事情很多,比如预创建页面的 View,预拉取数据,预创建次级页面的关键对象等等。

减少 CPU 的等待

讲完了充分利用 CPU 的闲置时刻,我们再来看看如何减少 CPU 的等待。那什么是 CPU 等待呢?它和 CPU 的闲置有什么区别呢?为什么减少 CPU 的等待能提高速度,又要如何减少呢?带着这几个问题,我们一起往下看。

从底层来看,CPU 实际上没有等待这种状态,CPU 要么是运行的,要么是闲置的。但是从上层来看,如果某个线程或进程拥有 CPU 的时间片,但是 CPU 却在当前指令段停下来,长时间无法接着执行后面的代码指令的情况,都可以看做是 CPU 的等待。此时,CPU 之所无法继续执行后面的代码,可能是因为代码陷入了空循环导致 CPU 空转,或者 CPU 被切走去执行其他线程了。 总的来说,有两种情况经常导致 CPU 等待,一是等待锁,二是等待 IO。

等待锁

我们先来看等待锁。在使用 Java 进行应用开发遇到多线程并发任务时,我们通常都用 synchronize 来对方法或者数据加锁。当这个锁被某一个线程持有时,另一个线程就需要等待锁释放后,才能对方法和数据进行访问。

请求 synchronize 锁的流程是这样的:首先判断这个锁是否被其他线程持有,如果持有则通过多次的循环来判断锁是否释放,这个过程就会导致 CPU 的空转,如果多次空转后还是无法获得锁,请求锁的线程便会陷入休眠并加入等待队列,待锁释放后被唤醒。从这个流程可以看到,请求锁时,不管是空转还是休眠都会导致当前线程无法获得 CPU 资源。如果这个线程是核心线程,比如主线程和渲染线程,就会导致应用的体验速度变慢。

锁优化

为了减少抢占和等待锁导致的 CPU 等待,我们需要对锁进行优化。锁优化是一个很庞大的课题,这里我们就不展开详细来讲啦,主要介绍一下锁的优化方法论。

  1. 无锁比有锁好:除了不加锁,还有线程本地存储,偏向锁等方案,都属于无锁优化。
  1. 合理细化锁的粒度:减少同步的代码块数量来优化锁的性能,比如将 Synchronize 锁住整个方法细化成只锁住方法内可能会产生线程安全的代码块。
  1. 合理粗化锁的粒度:通过适当粗化锁也能优化性能,比如当我们同时多次调用 Java API 提供的 StringBuffer.append 方法时,虚拟机会将每个 append 方法内部的锁进行粗化,变成在多个连续的 append 方法都只共用一把锁。
  1. 合理增加锁的数量:和细化锁的粒度类似,只不过是通过增加锁的数量来细化粒度。

等待 IO

当一个线程执行 IO 任务时,比如往内存读写数据,此时实际是 DMA(直接存储器访问) 芯片在执行操作,并不关 CPU 什么事。当前就会出现两种情况:一是有其他线程执行 CPU 任务,任务调度器会将 CPU 切换给其他线程去使用;二是没有其他 CPU 相关任务,CPU 就会一直等待,直到 DMA 芯片完成读写内存数据的操作,再接着执行后面的代码逻辑。

不管是让 CPU 去等待 IO 完成,还是让 CPU 切换到其他线程去执行任务,对于这个线程来说,执行完所有指令的时间变长了,也就是指令执行所消耗的平均时钟周期时间变长了。如果这个线程是主线程或者渲染线程,同样也会导致应用的体验速度变慢。

那么,怎样才能减少等待 IO 导致的 CPU 使用率下降呢?我在这里介绍 2 种优化方案,分别是 IO 任务分离和使用协程。 下面我们一起来详细看看它们。

IO 任务分离

IO 任务分离就是将 IO 的任务从主线程或者主流程中分离出来,单独用 IO 线程池去处理,上一章讲线程池时我们已经详细说了该怎么去创建一个合适的 IO 线程池,这里就不重复了。当 IO 线程将这个 IO 任务处理完成,再通知主流程拿处理完成的结果进行接下来的逻辑。

但你可能会有疑问,某些情况下,主线程必须要先拿到 IO 任务的结果,才能进行后面逻辑的话,主线程不还是需要等待 IO 吗? 实际上,IO 任务分离并不能做到不等待 IO,但是可以缩短我们等待 IO 的时间,只要你将主流程中的任务拆分得够细,一定有可以先执行的任务,比如我想渲染某个界面,需要 IO 任务读取界面的展示数据。这个时候,如果我们可以先将界面创建并渲染出来,然后用默认静态数据替代,等 IO 拿到最新数据后再进行界面的更新,那主线程等待 IO 的时间就缩短了很多。

协程

现在我们知道,一个 Java 线程在进行 IO 时实际会陷入休眠来等待 IO 完成。那为什么这个线程要陷入休眠呢?这个线程此时不能去做其他事情吗?事实上,Java 线程确实做不到。首先这个线程的代码逻辑需要按顺序先后执行,此时代码已经在执行 IO 这一步了,所以线程没法去执行后面或者其他的代码。那么我们有什么办法可以让线程等待 IO 时去做其他事情吗?有办法,想要实现这个场景,就需要使用 Kotlin 协程了。

一个 Java 线程实际上就是一个进程,进程的状态在很多时候是受操作系统管控的,比如调度器调度的时候,会切换进程状态;等待 IO 时,进程会陷入休眠等等。但 Kotlin 的协程不受内存调度器的限制,Kotlin 的协程不能理解为线程。实际上,当你创建协程时,这些协程实际都在同一个进程上运行,Kotlin 内部实现了调度机制,就像内存的进程调度机制一样,去调度执行这个协程任务。

所以通过协程任务,我们就能在等待 IO 的时候去执行其他任务,并且进程也不会休眠,而是一直运行的状态。实际上,灵活的使用协程对于 io 密集型应用来说帮助是很大的,会让我们程序性能表现的更好。协程的详细使用属于 kotlin 的基础知识,这里就不详细介绍了,建议你能在课后熟悉熟悉协程,并在应用中使用几次。