启动性能检测Main函数
启动的过程一般是指从用户点击app图标开始
到AppDelegate 的didFinishLaunching
方法执行完成为止,其中启动也分为冷启动
和热启动
-
冷启动
:当启动应用时,后台没有该应用的进程,这时系统会重新创建一个新的进程分配给该应用,这个启动方式就是冷启动 -
热启动
:当启动应用时,后台已有该应用的进程(例:按home键回到桌面,但是该应用的进程是依然会保留在后台,可进入任务列表查看),所以在已有进程的情况下,这种启动会从已有的进程中来启动应用,这个方式叫热启动。
下面说的启动优化是指冷启动
情况下的,这种情况下应用的启动时间
一般分为Main函数执行之前
和之后
-
pre-main阶段
即main函数之前,操作系统加载App可执行文件
到内存,执行一系列的加载&链接
等工作,简单来说,就是dyld加载过程
-
main函数之后
即从main函数开始,到Appdelegate
的didFinishLaunching
方法执行完成为止,主要是构建第一个界面,并完成渲染
系统提供了环境变量,让开发者可以看到pre-main
过程中的耗时。
- 创建空工程
WeChatDemo
查看pre-main
阶段的耗时 - 查看方式:在Xcode中选择项目
Product
-->Scheme
-->Edit Scheme
-->Run
-->Arguments
-->Environment Variables
-->点击+添加环境变量 DYLD_PRINT_STATISTICS设为YES
- 运行项目,查看耗时信息
-
dylib loading time
:动态库的载入耗时
动态库的载入肯定会存在耗时,并且动态库会存在依赖关系。系统动态库存在于共享缓存
,但自定义动态库没有这个待遇,所以苹果官方建议不要超过6个自定义动态库
,超过可进行多个动态库合并,以此来优化动态库加载的耗时。
动态库的合并,需要源码才能进行
。所以我们只能合并自己开发的动态库,日常使用的三方SDK可能无法合并。 -
rebase/binding time
:重定位符号和符号绑定的耗时(运行时期绑定,编译时期链接)
rebase
:系统采用ASLR技术,保证地址空间随机化。所以在运行时,需要通过rebase进行重定位符号
,使用ASLR+偏移地址
binding
:使用外部符号
,编译时无法找到函数地址。所以在运行时,dyld加载共享缓存
,加载链接动态库之后,进行binding操作,重新绑定外部符号
。 -
ObjC setup time
:注册OC类的耗时
注册OC类的过程,读取二进制的data段
找到OC的相关信息,然后注册OC类。应用启动时,系统会生成类
和分类
的两张表,OC类和分类的注册,会插入到这两张表中,所以会造成一定的时间消耗;
这部分时间很难优化,除非减少项目中类和分类的定义
;
减少类和所属分类load方法的使用
,让类以懒加载的方式加载。 -
initializer time
:执行load以及C++构造函数的耗时
尽可能使用initialize
方法代替load方法,或者把一些耗时的操作放入子线程 -
slowest intializers
:列举出几个比较耗时的动态库
这一阶段主要是防止资源浪费
(比如OC定义的非常多,自定义动态库非常多),优化建议
-
减少OC类
,因为OC类越多越耗时。比如在一些老旧项目中,可以写一套脚本
来监测已经不再使用的OC类进行删除 - 将不必须在
+load
方法中做的事情延迟到+initialize
中,尽量不要用C++虚函数
- 如果是
swift
,尽量使用struct
虚拟地址的概念
早期的程序比较小,在运行时会将整个程序
全部加载到内存中。但随着软件的发展,程序越来越大,导致内存越来越紧张。这就是早期系统中,为什么经常出现内存不足的提示。
早期的数据访问是直接通过物理地址
访问的,这种方式有以下两个问题
内存不够用
- 内存数据的
安全问题
:跨进程访问导致数据不安全
虚拟内存
针对上面两个问题,我们在进程和物理内存之间增加一个中间层
,这个中间层就是所谓的虚拟内存
,主要用于解决当多个进程同时存在时,对物理内存的管理。提高了CPU的利用率
,使多个进程可以同时、按需加载
。所以虚拟内存其本质就是一张虚拟地址
和物理地址
对应关系的映射表
- 每个进程都有一个独立的
虚拟内存
,其地址都是从0开始
,大小是4G
固定的,每个虚拟内存又会划分为一个一个的页(页的大小在iOS中是16K
,Mac中是4K
),每次加载都是以页为单位加载的,进程间是无法互相访问的,保证了进程间数据的安全性
。 - 一个进程中,只有部分功能是活跃的,所以只需要
将进程中活跃的部分放入物理内存
,避免物理内存的浪费 - 当CPU需要访问数据时,首先是访问
虚拟内存
,然后通过虚拟内存去寻址,即可以理解为在表中找对应的物理地址
,然后对相应的物理地址进行访问。地址翻译的过程,由CPU上的内存管理单元
(MMU)完成。 - 如果在访问时,虚拟地址的内容未加载到物理内存,会发生
缺页异常
(pagefault),将当前进程阻塞掉,此时需要先将数据载入到物理内存,然后再寻址进行读取,这样就避免了内存浪费。
使用虚拟内存的优势:
- 程序以
懒加载
的方式加载到内存中,按需加载避免内存浪费
。 - 将
程序
和物理内存
完全阻隔开,无法跨进程访问数据更安全
。
进程通信由系统提供API
使用kernel
发送信号。但不能直接跨进程访问,保证数据的安全
虚拟内存:内存分页
页表中记录了内存页的状态、虚拟内存和物理内存的对应关系。其中状态分为:未分配
(Unallocated)、未缓存
(Uncached)和已缓存
(Cached)
-
未分配的内存页
,是没有被进程申请使用的,也就是空闲的虚拟内存,不占用虚拟内存
磁盘的任何空间。 -
未缓存的内存页
,仅在虚拟内存中,没有被物理内存缓存。 -
已缓存的内存页
,同时存在于虚拟内存和物理内存中。
缺页异常
- 当程序访问
未被缓存
的内存页时,就会触发缺页异常(缺页中断) - 缺页中断会将当前进程阻塞掉,此时需要先将数据载入到
物理内存
,然后再寻址
进行读取; - 部分情况下,被访问的页面已经加载到
物理内存
中,但页表中并不存在该对应关系,这时只需要在页表中建立虚拟内存
到物理内存
的关系即可; - 其他情况下,操作系统需要将磁盘上
未被缓存
的虚拟页
加载到物理内存
中。
页面置换
物理内存
的空间是有限的,当内存中没有空间时,操作系统会从选择合适的物理内存页驱逐回磁盘
,为新的内存页让出位置,选择待驱逐页
的过程在操作系统
中叫做页面置换
。也就是覆盖掉不那么活跃的物理内存
例如,同一台设备上,依次打开微信、微博、淘宝、京东、抖音,此时再回到微信,又会看到微信的启动界面。因为系统在内存紧张的时候
,会按照活跃度
将最不活跃的内存进行覆盖
ASLR技术
程序的代码在不修改的情况下,每次加载到虚拟内存
中的地址都是一样的,这种方式并不安全
。为了解决地址固定的问题,出现了ASLR技术
。
ASLR的概念:(Address Space Layout Randomization ) 地址空间配置随机加载,是一种针对缓冲区溢出的安全保护技术
,通过对堆
、栈
、共享库映射
等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码位置
,达到阻止溢出攻击
的目的的一种技术。
其目的是通过利用随机方式配置数据地址空间
,使某些敏感数据
(例如APP登录注册、支付相关代码)配置到一个恶意程序无法事先获知的地址
,令攻击者难以进行攻击。
由于ASLR的存在,导致可执行文件
和动态链接库
在虚拟内存中的加载地址每次启动都不固定,所以需要在编译时来修复镜像中的资源指针,来指向正确的地址。即正确的内存地址 = ASLR地址 + 偏移值
iOS/MacOS操作系统实现了ASLR
-
Mac OS X
:Apple在Mac OS X Leopard10.5
(2007年十月发行)中某些库导入了随机地址偏移,但其实现并没有提供ASLR所定义的完整保护能力。而Mac OS X Lion10.7
则对所有的应用程序均提供了ASLR支持。Apple宣称为应用程序改善了这项技术的支持,能让32
及64
位的应用程序避开更多此类攻击。从OS X Mountain Lion10.8
开始,核心及核心扩充(kext)与zones
在系统启动时也会随机配置; -
iOS
(iPhone、iPod touch、iPad):Apple在iOS4.3
内导入了ASLR
。
PageFault调试&启动优化
缺页中断消耗
当系统访问虚拟内存
时,发现数据还未加载到物理内存
中,会触发缺页中断
(Page Fault),造成进程阻塞。此时系统会先将数据加载到物理内存
中,进程才能继续运行。虽然每一页数据加载到内存的速度很快毫秒级别
,但在应用冷启动时
可能会出现大量的缺页中断
,对启动速度带来一定的时间消耗。
PageFault调试
- 运行
测试项目
查看应用启动
过程中Page Fault
所带来的消耗,Xcode菜单中
->选择Product
-->Profile
,打开Instruments
- 运行
测试项目
当第一个界面出来后即可停止,搜索main thread
-> 选择Summary: Virtual Memory
虚拟内存
缺页中断564
次,耗时196.95ms
。一次缺页中断耗时 大概 0.35ms,冷启动时间 231.17ms
。
启动优化
- 创建测试项目,查看
代码执行顺序
- 项目
Build Settings
-->Write Link Map File
设置为YES
-
编译项目
来到工程的Build目录
下查找LinkMap文件
-
LinkMap文件
保存了项目在编译链接时的符号顺序
以方法/函数为单位排列
# Symbols:
# Address Size File Name
0x100001E60 0x00000040 [ 5] -[SceneDelegate sceneWillResignActive:]
0x100001EA0 0x00000040 [ 5] -[SceneDelegate sceneWillEnterForeground:]
0x100001EE0 0x00000040 [ 5] -[SceneDelegate sceneDidEnterBackground:]
0x100001F20 0x00000020 [ 5] -[SceneDelegate window]
0x100001F40 0x0000008E [ 4] _main
0x100001FD0 0x00000030 [ 2] +[ViewController load]
0x100002000 0x00000030 [ 3] +[AppDelegate load]
0x100002030 0x00000039 [ 2] -[ViewController viewDidLoad]
0x100002070 0x00000080 [ 3] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x1000020F0 0x00000120 [ 3] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x100002210 0x00000070 [ 3] -[AppDelegate application:didDiscardSceneSessions:]
0x100002280 0x000000B0 [ 5] -[SceneDelegate scene:willConnectToSession:options:]
0x100002330 0x00000040 [ 5] -[SceneDelegate sceneDidDisconnect:]
0x100002370 0x00000040 [ 5] -[SceneDelegate sceneDidBecomeActive:]
0x1000023B0 0x00000040 [ 5] -[SceneDelegate setWindow:]
0x1000023F0 0x00000033 [ 5] -[SceneDelegate .cxx_destruct]
0x100002424 0x00000006 [ 6] _NSLog
......
-
文件编译顺序
:Xcode中Build Phases
->Compile Sources
的文件排列顺序
- 文件中
方法/函数的符号顺序
就是代码的书写顺序
#import "ViewController.h"
@interface ViewController ()
@end
@implementation ViewController
+(void)load {
NSLog(@"123");
}
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
}
@end
以ViewController.m
为例load
方法在viewDidLoad
方法之前,和LinkMap
文件中的顺序一致。
按照默认配置,在应用启动时会加载到大量与启动时无关的代码,导致Page Fault
的次数增长影响启动时间
。如果可以将启动时需要的方法/函数排列在最前面
,就能大大降低缺页中断
的可能性,从而提升应用的启动速度,这就是二进制重排
的核心原理。
以下图为例,方法 1
和方法 3
是启动的时候调用的,为了执行对应的代码,就需要两次Page Fault
。假如我们把方法 1
和3
排列到一起,那么只需要一次Page Fault
,从而提升启动速度。
二进制重排体验
二进制重排
的方案最开始是由抖音的这篇文章抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%火起来的。
链接器ld
有个参数-order_file
支持按照符号的方式排列二进制。所以需要在工程中创建.order文件
,按固定格式将启动时需要的方法/函数顺序排列
,然后在Xcode中使用.order文件即可。通过LinkMap
文件中的顺序,查看最终的排序是否符合预期。
- 在工程根目录创建
.order文件
- 在
hk.order文件
中写入启动时需要的方法/函数
_main
+[ViewController load]
+[AppDelegate load]
- 在Xcode使用
.order文件
,在Build Setting
->Order File
中配置
-
编译项目
打开LinkMap文件
# Symbols:
# Address Size File Name
0x100001E60 0x0000008E [ 4] _main
0x100001EF0 0x00000030 [ 2] +[ViewController load]
0x100001F20 0x00000030 [ 3] +[AppDelegate load]
0x100001F50 0x00000039 [ 2] -[ViewController viewDidLoad]
0x100001F90 0x00000080 [ 3] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100002010 0x00000120 [ 3] -[AppDelegate application:configurationForConnectingSceneSession:options:]
......
最前面三个方法/函数
,按照.order
文件中的顺序排列
由此可见如果我们将项目中启动时需要调用的所有方法/函数都找到
,把它们全部写入到.order
文件中,就能大大降低缺页中断
的可能性。
二进制重排方案小结:
- 定位到APP启动时调用的项目中的方法;
- 生成order文件;
- 配置二进制重排。
这里的难点是,如何找到启动时项目调用了哪些方法?
- 1、
hook objc_msgSend
:我们知道,函数的本质是发送消息
,在底层都会来到objc_msgSend
,但是由于objc_msgSend
的参数是可变的需要通过汇编获取
,对开发人员要求较高。而且也只能拿到OC
和swift中@objc
后的方法 - 2、
静态扫描
:扫描Mach-O
特定段和节里面所存储的符号以及函数数据 - 3、
Clang插桩
:即批量hook,可以实现100%符号覆盖,即完全获取swift
、OC
、C
、block函数