底层原理-29-启动优化及其原理

422 阅读9分钟

1. 前言

我们日常开发中,经常会经过长时间迭代后应用变的越来越大,启动也会随之变慢,那么有什么解决办法吗? 我们先看下应用启动的时间。

1.1 打印应用启动时间

我们在工程设置中添加 DYLD_PRINT_STATISTICSDYLD_PRINT_STATISTICS_DETAILS打印启动信息。

image.png

在模拟器iPhone12上运行,运行结果如下连续运行2次的情况下: image.png
进程杀死一段时间后: image.png 在真机上会少一点时间,模拟器性能稍微差点。
pre-main 阶段主要做了

  1. dylib loading:之前分析了dyld的时候了解了,动态链接主要链接我们的动态库。
  2. rebase/binding:重定向/绑定
  3. ObjC setup:OC类的注册包括分类。
  4. initializer: 类的初始化和构造 在total下显示的具体的详细细节。total time: 4.0 seconds总共启动用的起时间,total images loaded:  475 (445 from dyld shared cache)

1.2 优化思路

针对这3个方面我们可以进行以下优化:

  1. 尽量少使用动态库链接,移除不需要的动态库,尽量使用系统库,且苹果建议数量控制在 6个以下。
  2. 移除用不到的类,合并类似的类和分类。
  3. 减少+load()的使用,使用懒加载,使用+initialize()替换。

2. 关于启动优化的需要了解的概念

我们进行启动优化就要了解下相关的概念,以便我们了解其实现的原理。

2.1 物理内存和虚拟内存

在最早开发中,我们程序在操作系统中运行。根据操作系统的位数来支持最大运行内存空间,比如32位操作系统就是2^32 = 4GB大小,64位操作系统就是 2^64 = 8GB大小。我们程序在运行的时候会加载到物理的内存条中。物理内存指通过物理内存条而获得的内存空间,内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存。常见的物理内存规格有256M、512M、1G、2G等,现如今随着计算机硬件的发展,已经出现4G、8G甚至更高容量的内存规格 。

2.1.1 内存不够怎么办?

我们的操作系统为了支持多个程序运行,单纯的使用物理内存就会捉襟见肘,比如剩200M的物理内存,程序A要占用100M程序B需150M,这个时候运行A之后在运行B就会报错,很早之前的windos就会报错“系统错,内存不足,稍后再试”,但是实际上并不会程序A使用100M的内存,加载进内存,有的功能用户并没有使用,就会造成极大的浪费,这就是内存使用效率低。同时也会面临一个问题,我们程序运行的时候加载物理内存是连续的,如果程序A访问修改数据时越界了,就会造成程序B的内存数据被修改,或造成错误。早期外挂也是根据这样原理进行修改数据的,比如某一个游戏的数值可以被外挂修改器进行修改。因此也会出现程序不安全的情况,这就是地址空间不隔离。还有一个问题,我们每次程序运行的时候,都要在内存空间中分配一个足够大的空闲区域,但是这个区域不固定,但是很多数据的读写和指令跳转目标地址都是固定的,会有一个重定向的问题。我们希望每个程序都可以独占内存,不受他人影响,独占CPU。

2.1.2 虚拟内存

增加中间层,在开发中,我们常用的方法,使用间接的地址访问方法。大概这样的,我们把程序给出的地址看作是一种虚拟地址,然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址,这样,只要我们能够妥善的控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程序所能访问的物理内存区域更另外一个程序互相不重叠,达到地址空间隔离的效果。
虚拟地址空间是指虚拟的。人们想象出来的地址空间,其实并不存在,每个进程都有自己的虚拟空间,而且每个进程只能访问自己的地址空间,这样就有效的做到了进程的隔离

2.2 分段

分段是最开始使用的方法,基本思路是把一段与程序所需要的内存空间大小的虚拟空间映射到某一个地址空间,我们把这2个相同大小的地址空间一一映射,即虚拟空间的每个字节对于物理空间的每个字节,这个过程由软件来设置,比如操作系统来设置这个映射函数,实际地址转换由硬件完成,下图就是程序A和B在分段情况下的虚拟空间和物理空间映射关系:

image.png 分段的方法基本上解决了上面3个问题的2个,做到了地址隔离,程序A访问虚拟空间地址超出本身范围会被系统定义非法访问。其次,对于每个程序来说,无论分到物理地址是哪个区域,对于程序来说都是透明的,不需要关心物理地址的变化,程序不再需要重定位。但是分段方法没有解决使用效率的问题,分段对内存区域的映射还是按照程序位单位,内存不足,被换入换出到磁盘都是整个程序,造成大量的磁盘访问,从而严重影响速度

2.3 分页

分段的粒度比较大,人们想到了更小粒度的内存分割和映射的方法,使得程序的局部性原理得到充分利用,大大提高内存的使用率,这种方法就是分页。分页的基本方法是把地址空间人为地等分固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。目前在iOS中一页的大小是16k,Mac(PAGESIZE命令)上是4k。虚拟空间的页叫作虚拟页,物理空间的页叫作物理页,磁盘中的页叫作磁盘页。当使用某一页的时候,但是还没加载到内存中操作系统会发出缺页异常缺页中断) ,这个时候CPU要执行代码会中断掉,操作系统会把需要的数据加载到物理内存中,哪里有空闲位置就插入到这里,一般来说,手机启动后一段时间,基本没有空闲位置,操作系统会通过页面置换算法覆盖掉不活跃的内存

image.png

虚拟存储的实现需要硬件的支持,对于不同CPU来说是不同的,但是几乎所有的硬件都采用一个叫MMUMemory Management Unit)的部件来进行映射。

image.png

3. pageFault调试&启动优化的原理

在虚拟内存部分,我们知道,当进程访问一个虚拟内存page,而对应的物理内存不存在时,会触发缺页中断(Page Fault),因此阻塞进程。此时就需要先加载数据到物理内存,然后再继续访问。这个对性能是有一定影响的。
我们使用脚本运行WeChat-7.0.8 ,pre-main 耗时2.3秒。

image.png

我们看下具体的PageFault的次数,打开Instruments

  • CMD+i快捷键,选择System Trace

image.png 点击左上方record image.png 找到Main Thread 后选择 sunmmary:virtual memery 第三个 image.png 我这里使用了大量的缓存,因此耗时较少。

image.png 手机关机重启下,清除缓存数据,即可。

3.1 PageFault产生

我们在Build Setting 搜下Write Link Map File,设置Yes。 image.png 编译后,在show in finder

image.png

在所在层级上一层级选择

image.png 打开后

image.png

我们调用的函数方法都有,这里是所有方法代码实现的排列的顺序。 这里的Address+ASLR就是在方法在虚拟内存的地址。我们添加一些方法

image.png 再次编译

image.png 从上面的Page Fault的次数以及加载顺序,可以发现其实导致Page Fault次数过多的根本原因是启动时刻需要调用的方法,处于不同的Page导致的。因此,我们的优化思路就是:将所有启动时刻需要调用的方法,排列在一起,即放在一个页中,这样就从多个Page Fault变成了一个Page Fault

3.2 二进制重排

我们在objc源码文件中,发现一个libobjc.order文件,打开如下所示:

image.png 这里显示的都是一个个的符号名称,这个order是给编译器用到,当编译读取到这个oder文件时,会按照这里的顺序对二进制进行排序。
我们新建一个order文件,放在根目下,然后编辑,如下所示

image.png

我们在Build Settings,搜索Order File

image.png

image.png 再次编译

image.png

因此我们可以在启动中把我们需要的方法放在.order中就可以达到减少缺页中断导致加载时间过长,至于怎么做,下篇将会探讨具体的实现。

4. 总结

任何优化都是建立在浪费的基础上,我们在进行启动优化的时候除了常规的方法减少+load的使用,使用懒加载移除合并一些类之后我们还可以进行二进制重排,通过减少页的中断,把启动需要加载的类和方法放到同一页中,按照我们想要的顺序排列到.order文件,从而减少加载页的时间,达到启动时间减少的目的。另外文章中的一些概念描述来自《程序员的自我修养》,感兴趣的小伙伴可以看看。