アプリケーション速度の最適化は、最も頻繁に使用され、最も重要なアプリケーションの最適化の 1 つであり、起動速度の最適化、ページを開く速度の最適化、機能またはビジネスの実行速度の最適化などが含まれ、アプリケーションのユーザー エクスペリエンスを直接向上させることができます。したがって、Android 開発者である限り、速度関連の最適化について多かれ少なかれ経験が必要です。しかし、ほとんどの人は速度の最適化について話すとき、マルチスレッドやプリロードなどの断片的な最適化ポイントしか思いつきません。これだけでは速度を向上させるのに十分ではありません。速度を向上させたい場合は、次の質問について考えてみるとよいでしょう。
-
私たちの最適化計画は包括的かつ体系的ですか?
-
なぜ私たちのソリューションで速度が向上するのでしょうか?
-
私たちの計画はどれほど効果的ですか?
これらの質問にうまく答えるには、アプリケーションの速度に影響を与え、それを決定する根本的な原理と本質を理解する必要があります。根本的な観点から見ると、CPU、キャッシュ、タスクのスケジューリングは、アプリケーションの速度を決定する最も重要な要素です。CPU とキャッシュは両方ともハードウェア層に属し、タスク スケジューリング メカニズムはオペレーティング システム層に属します。
このクラスでは、ハードウェアとオペレーティング システムを深く掘り下げて、上記の 3 つの要素がアプリケーションの速度をどのように決定するかを理解し、アプリケーションの速度の最適化を再理解し、下から上への速度最適化のための認識システムと方法を確立します。
CPU レベルから速度を最適化するにはどうすればよいですか?
すべてのプログラムは最終的に機械語命令にコンパイルされ、実行のために CPU に渡されることがわかっており、CPU はプログラムの機械語命令をパイプラインの形式で 1 つずつ実行します。特定のシナリオ (起動、ページを開く、スライドなど) の速度を上げたい場合、本質的には、これらのシナリオで CPU が命令を実行する時間を短縮することになります。これを略して CPU 時間と呼びます。。CPU 時間を短縮するには、プログラムが消費する CPU 時間の計算式を知る必要があります。CPU 時間 = プログラムの命令数 x クロック サイクル タイム x 各命令の平均クロック サイクル数 です。これら 3 つの要素の意味を以下に説明します。
-
プログラムの命令数: この項目はよく理解されており、プログラムが機械語命令にコンパイルされた後の命令の数です。
-
クロック サイクル タイム: CPU はクロック サイクルごとに 1 回の実行のみを完了するため、クロック サイクル タイムが短いほど、CPU の実行は速くなります。クロック サイクル タイムについては詳しくないかもしれませんが、その逆数がクロック サイクル周波数であるため、聞いたことはあるはずです。1 ナノ秒のクロック サイクル タイムは 1 GHz のクロック サイクル周波数です。メーカーが新しい携帯電話を発売するとき、または私たちが新しい携帯電話を購入するとき、クアルコムのクロック周波数など、多かれ少なかれ CPU のクロック周波数について言及します。 Snapdragon 888 CPU. 2.8 GHz であり、これは CPU パフォーマンスを測定するための最も重要な指標でもあります。
-
各命令の平均時間: 命令の実行にかかる平均時間であり、命令ごとに必要なマシン サイクル数も異なります。一部の単純なシングルバイト命令の場合、命令フェッチ サイクルで、命令は命令レジスタにフェッチされた直後にデコードされて実行され、他のマシン サイクルは必要ありません。転送命令や乗算命令などのより複雑な命令の場合は、2 マシン サイクル以上が必要です。
CPU の観点から見ると、プログラムの速度を向上させたい場合、これら 3 つの要素のいずれかを最適化することで目標を達成できます。では、これら 3 つの要素に基づいて、どのような一般的な解決策を参考にできるでしょうか?
プログラム内の命令の数を減らす
プログラム内の命令数を減らして速度を上げるのは、最も一般的に使用される方法であり、最も最適化されたソリューションです。たとえば、次のソリューションは命令数を減らして速度を上げます。
-
携帯電話のマルチコアの活用:高速化したいシーンのプログラム命令を複数のCPUに引き渡して同時実行すると、単一のCPUの場合、実行する必要のある命令の数が減り、CPUの時間は自然に短縮され、つまり同時思考が可能になります。ただし、同時実行はマルチコアでのみ実現できるので、CPU が 1 つしかない場合、シーンの命令を複数に分割しても、この 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 的大核这一种特殊的方式来提高该线程的执行效率。
-
减少线程创建或者状态切换的耗时:这一点可以通过在线程池中设置合理的常驻线程,线程保活时间等参数来减少线程频繁创建或者状态切换的耗时。因为线程池非常重要,我们后面会专门用一节课来详细讲解。
小结
在这一节中,我们详细介绍了影响程序速度的三个本质因素,并基于这三个因素,介绍了许多衍生而来优化思路,这其实就是一种自下而上的性能优化思路,也就是从底层原理出发去寻找方案,这样我们在进行优化时,才能更加全面和体系。
希望你通过这一节的学习,能对速度优化建立起一个体系的认知。当然,你可能会觉得我们这一节介绍的优化思路太过简洁,不必担心,在后面的章节中,我们会基于 CPU、缓存和任务调度这三个维度,挑选出一些优化效果较好的方案,进行更加深入的详细讲解。