速度优化:缓存优化

我们知道,缓存对提升速度来说至关重要,但缓存始终受着容量的制约,所以我们做缓存时,始终要考虑在有限性的容量内需要缓存哪些数据,以及如何提升缓存的命中率 。缓存哪些数据和业务有很大的关系,我们缓存的一定都是对我们业务来说最常用或者最重要的数据,这一块没有太大的通用性,就不展开来讲了。这一章节,我们重点来讲如何提升缓存命中率。

那么,提升缓存命中率有什么好的思路呢?首先,我们需要建立对缓存命中率这个概念的认知, 命中率 = 成功从缓存中取到数据 / 请求数据的次数。所以我们可以将命中率做一个监控,看看业务中在缓存中请求图片、请求数据时的命中率分别有多少。当命中率较低时,业务的速度就会变慢,此时我们就需要想办法提升命中率了。

其次,我们要掌握提升命中率的手段。 我们所有提升命中率的优化方案,大都离不开以下 2 种思路:

  1. 优化缓存中数据淘汰策略;

  2. 通过预加载接下会使用或者大概率会使用的数据。

那接下来,我们就通过冷热端分类的 LruCache 和 Dex 类文件重排序这两种方案,来详细展示如何提升命中率。

冷热端分离的 LruCache

LruCache 是我们常用来缓存数据的工具之一,工作中我们都会用它来存储图片数据。我们知道,直接从内存中读取图片比从硬盘或者通过网络读取图片快很多,页面的展示速度也会因此提升不少,但是图片占用的内存过高,所以我们只能存储有限的图片到内存中。当存储图片的缓存满了之后,就需要淘汰已放入缓存的部分图片以便腾出空间放入新的图片。

LruCache 实际上已经帮我们设计了一套缓存的淘汰策略:最近最少被使用的图片就会被淘汰。当我们访问一张图片时(这张图片可能没在缓存中,也可能在缓存中),LruCache 便会将这张图片放在缓存队列的最前面,当缓存满了之后,便会从缓存队列的最后面开始淘汰,缓存最后面的图片都是我们最近没有使用过的。

这个策略很多情况下都是很优的,这也是为什么我们需要缓存图片时,会毫不迟疑地使用 LruCache。但是 LruCache 的淘汰策略在所有场景下都是最优的吗?在使用它的时候,你有考虑过它的命中率吗?

我们可以想象这样一个场景,一款聊天类的 App 运行在一台内存容量较低的低端设备上,此时该应用 LruCache 的容量很小,假如只能放 10 张图片。这款聊天应用的核心场景是在会话页中,如果此时在会话页中打开一篇公众号,这个公众号的图片又比较多,很快就会达到 LruCache 的容量上限,于是 LruCache 便把缓存的 10 张和会话页相关的图片都淘汰了。可这篇公众号我们实际上看过一遍就不会再打开了,会话页却会被反复打开,所以这时使用 LruCache 策略就会导致缓存的命中率很低,会话页的页面展示速度自然也就慢了很多。

因此,在缓存容量较低的场景下,LruCache 的表现其实并不好,因为最近最少被使用的这张图片并不代表这张图片不是被频繁使用的图片。 对于上面的这个场景,我们就可以根据使用频率来淘汰。下面就介绍一下如何设计这套淘汰策略。

还是以容量为 10 缓存为例,我们可以将这个容量为 10 的缓存队列拆分成两个容量为 5 的缓存队列。其中一个缓存队列存放使用频率较高的数据,称为热数据,另一个缓存队列存放使用频率较低的数据,称为冷数据。什么才是热数据,什么才是冷数据呢?

我们每次读取一张图片时,就将这张图片的使用次数加一,如果使用次数等于一,也就是第一次使用这张图片,就放入冷端最前面,同时将缓存中所有其他的图片使用次数减一,这个时候如果冷端满了,就执行淘汰策略,将最尾端的缓存淘汰。

如果使用次数大于 1,我们就将这张图片按照使用次数插入热数据缓存队列中,如果热端此时满了,则将热端队尾的数据存放到冷端堆头。

冷热端分离的 LruCache 实际是上在冷端中执行的还是最近最少被使用的淘汰策略缓存,但是我们把高频使用的图片放入了热端中,就减少了前面那种场景的发生,提高了缓存的命中率。

了解了这种思路和原理,代码实现起来就比较简单了,文章中就不讲了,如果你感兴趣可以课后实现一遍。如果你的应用在使用 LruCache 时命中率较低,不妨换这种策略试试看能否提高命中率。

Dex 类文件重排序

讲完了第一种提升命中率的方案,我们再来看看第二种:Dex 类文件的重排序。这一优化方案实际是从操作系统和硬件层的角度去思考的,程序运行实际上是 CPU 在不断读取程序指令并执行的过程。CPU 在读取指令时,会先从寄存器读,寄存器没有再从高速缓存读,最后才从主存读,读取到指令后,也会先从主存加载到高速缓存,再从高速缓存加载到寄存器。高速缓存从主存读取的数据量的大小是有限的,这个大小为 cache line 个字节,高速缓存实际上也是被分为了一个个cache line 大小的块。cache line 的大小和 CPU 型号有关,主流的是 64 个字节。

高速缓存读取数据时,会一次读满 cache line 大小的数据,即使 CPU 需要的数据只有 4 个字节,高速缓存也会读满 64 个字节的数据,所以剩下的 60 个字节实际这次用不到,但为了能降低高度缓存读主存的次数,需要尽量确保这多出来的 60 个字节的数据能在接下来被使用到,这样就能提升高速缓存的命中率,让 CPU 更快执行指令了了。但是这个流程中的难点在于,如果确保这 60 个字节的数据接下来能大概率被 CPU 使用到呢?这就用到了局部性的原理。

局部性原理

局部性是一个很重要的概念,计算机硬件中用到了大量的局部性原理来提升命中率。局部性通常有两种不同的形式:时间局部性和空间局部性。时间局部性表示被使用过一次的数据很可能在后面还会再被多次使用。空间局部性表示如果一个数据被使用了一次,那么接下里这个数据附近的数据也很很可能被使用。高速缓存读取数据就是按照空间局部性来读的,也就是读取当前需要被使用的数据,以及在内存上紧挨着的数据,总共凑齐 cache line 大小的数据后再加载进高速缓存中。

了解了高速缓存读取数据的原理后,我们就能利用这个规则来优化程序的速度了。在程序执行的过程中,对于第一次使用到的对象,高速缓存中是没有的,所以需要向主存读取这个对象数据,并且高速缓存不仅仅读取这一个对象,还会读取这个对象后面紧挨的一些对象,直到数据量达到 64 个字节。如果这个对象在内存上紧挨着的对象就是接下来马上被用到的,高速缓存就不需要多次读取数据了,CPU 也减少了等待数据读取的时间,能更快执行程序的指令,我们程序也能运行得更快了。

当我们的项目被编译成 APK 包,所有的 class 文件会整合后放在一个个 dex 文件中。这个时候,dex 文件中 class 文件的顺序并不是按照程序执行顺序存放的,因为我们也不知道 class 文件的执行顺序。如果我们能提前将程序运行一遍,把其中 class 对象的使用顺序收集下来,再按照这个顺序重新调整 dex 文件中类文件的顺序,就能加速程序运行的速度了,如果我们将所有启动相关的类文件,都放在主 dex 文件中,启动当然就会更快了。

上面的流程是美好的,但实现起来还是很复杂的,我们需要对每个对象插桩后才能知道对象的先后运行顺序,而且我们也需要对 dex 文件结构非常熟悉,这样才能正确重排 dex 文件中类文件的顺序。幸运的是,这一套流程也有成熟的开源框架可以直接使用。这里介绍一下 Facebook 的开源工具:redex

Redex 使用流程

Redex 需要在 MAC 或者 Linux 系统下使用(官方文档说 Window 也可以,不过我没有试验过),只需要 3 个主要的步骤。

  1. 下载 redex 及相关环境并进行编译:

git clone github.com/facebook/re...

Shell 复制代码
//下载相关环境
xcode-select --install
brew install autoconf automake libtool python3
brew install boost jsoncpp

//进行编译
cd redex
autoreconf -ivf && ./configure && make
sudo make install
  1. 打开配置文件开启 dex 类重排优化,并添加重排类文件的顺序:
XML 复制代码
//打开 redex 的配置文件
cd redex/config/
vim default.config

//在配置文件增加InterDexPass开启,以及新增coldstart_classes,指定 class 调用顺序
{
  "redex" : {
    "passes" : [
      "ReBindRefsPass",
      "BridgePass",
      "SynthPass",
      "FinalInlinePass",
      "DelSuperPass",
      "SingleImplPass",
      "SimpleInlinePass",
      "StaticReloPass",
      "RemoveEmptyClassesPass",
      "ShortenSrcStringsPass",
      "InterDexPass"
    ],
   "coldstart_classes" : "app_list_of_classes.txt" //class调用顺序列表
  }
}
  1. 获得启动 class 加载顺序列表:
Shell 复制代码
//1. 获取你的应用的 pid
adb shell ps | grep 应用包名

//2. 收集堆内存,需要 root 权限
adb root
adb shell am dumpheap YOUR_PID /data/local/tmp/SOMEDUMP.hprof

//3. 把堆内存文件拉取到电脑本地
adb pull /data/local/tmp/SOMEDUMP.hprof 本地路径

//4. 通过redex提供的python脚本解析堆内存,生成类加载顺序列表
python redex/tools/hprof/dump_classes_from_hprof.py --hprof SOMEDUMP.hprof > app_list_of_classes.txt

//5. 执行redex逻辑,生成新的apk包
ANDROID_SDK=你的Android sdk路径 redex input.apk -o output.apk

//6. 最后将这个包重新签名即可

Redex 的优化项比较多,dex 类文件重排只是其中一个,使用起来也比较简单,是很容易落地的一项优化。了解了如何通过 dex 类文件重排序来提升速度之后,你也通过 redex 优化下应用吧,看看优化之后启动速度能提升多少。

小结

这一章中我们讲了两个提升缓存命中率的方法,第一种冷热端分离的 LruCache 方案是从应用层出发,提升自身应用中主存的命中率。第二种方案是从底层出发,基于操作系统和硬件读取数据的原理的原理,提升应用代码的执行速度。

我们从这两个维度中各选了一个案例来讲解,也是为了让大家能从不同的维度进行更全面的思考。当然,这也需要我们熟悉各个层次、硬件、操作系统和应用层。虽然各个层加起来的知识点庞大而复杂,但要在技术上突破瓶颈,迈入高手的行列,这是我们的必经之路。

相关推荐
Dingdangr3 小时前
Android中的Intent的作用
android
技术无疆3 小时前
快速开发与维护:探索 AndroidAnnotations
android·java·android studio·android-studio·androidx·代码注入
GEEKVIP3 小时前
Android 恢复挑战和解决方案:如何从 Android 设备恢复删除的文件
android·笔记·安全·macos·智能手机·电脑·笔记本电脑
鱼跃鹰飞9 小时前
Leetcode面试经典150题-349.两个数组的交集
算法·leetcode·面试
Jouzzy10 小时前
【Android安全】Ubuntu 16.04安装GDB和GEF
android·ubuntu·gdb
极客先躯10 小时前
java和kotlin 可以同时运行吗
android·java·开发语言·kotlin·同时运行
Good_tea_h13 小时前
Android中的单例模式
android·单例模式
黑狼传说14 小时前
前端项目优化:极致最优 vs 相对最优 —— 深入探索与实践
前端·性能优化
程序猿进阶15 小时前
如何在 Visual Studio Code 中反编译具有正确行号的 Java 类?
java·ide·vscode·算法·面试·职场和发展·架构
无名之逆15 小时前
云原生(Cloud Native)
开发语言·c++·算法·云原生·面试·职场和发展·大学期末