1: 启动时间的组成:
冷启动 & 热启动
热启动:应用和数据已经被加载到内存中
冷启动:应用尚未被加载到操作系统内核的缓冲缓存
冷启动和热启动的时间不同,我们更需要关注冷启动的耗时。
在测试冷启动的耗时时,需要重启设备。
在你优化热启动过程的时候,冷启动过程也会被优化。
所以在多次测量热启动过程之后,也可以进行一次冷启动过程的测量。
关于APP启动时间的分析和优化可以以main()为分界点,分为main()方法执行之前的加载时间(pre-main time)和main()之后的加载时间。
那么,如何定量的测量这两个阶段具体的执行时间呢,下面先给出测量方法,看一下自己项目启动时间是否合理:
2: 启动时间的检测:
1: xcode添加环境变量DYLD_PRINT_STATISTICS检测
在xcode中, 通过Edit scheme -> Run -> Auguments添加DYLD_PRINT_STATISTICS或者DYLD_PRINT_STATISTICS_DETAILS(更详细), 并设置值为YES来查看.
设置好环境变量之后运行一下工程, 就可以在输出里面查看到:
premain阶段分为下列过程:
值得注意的是, 这两个环境变量在
ios 15或Xcode13之后下面并不work了, 如果环境变量不work, 还可以自己写一个获取启动时间的工具类LaunchTime来获得启动时间(gitee.com/ZhongBangKe…):
#import "LaunchTime.h"
#import <sys/sysctl.h>
#import <mach/mach.h>
@implementation LaunchTime
double __t1; // 创建进程时间
double __t2; // before main
double __t3; // didfinsh
// 获取进程创建时间
+ (CFAbsoluteTime)processStartTime {
if (__t1 == 0) {
struct kinfo_proc procInfo;
int pid = [[NSProcessInfo processInfo] processIdentifier];
int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(procInfo);
if (sysctl(cmd, sizeof(cmd)/sizeof(*cmd), &procInfo, &size, NULL, 0) == 0) {
__t1 = procInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + procInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
}
}
return __t1;
}
// 开始记录:在DidFinish中调用
+ (void)mark {
double __t1 = [LGAppLaunchTime processStartTime];
dispatch_async(dispatch_get_main_queue(), ^{ // 确保didFihish代码执行后调用
if (__t3 == 0) {
__t3 = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
double pret = __t2 - __t1 / 1000;
double didfinish = __t3 - __t2;
double total = __t3 - __t1 / 1000;
NSLog(@"----------App启动---------耗时:pre-main:%f",pret);
NSLog(@"----------App启动---------耗时:didfinish:%f",didfinish);
NSLog(@"----------App启动---------耗时:total:%f",total);
});
}
// 构造方法在main调用前调用
// 获取pre-main()阶段的结束时间点相对容易,可以直接取main()主函数的开始执行时间点.推荐使用__attribute__((constructor)) 构建器函数的被调用时间点作为pre-main()阶段结束时间点:__t2能最大程度实现解耦:
void static __attribute__((constructor)) before_main() {
if (__t2 == 0) {
__t2 = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
}
@end
然后在application调用mark也会得到launchTime
#import "AppDelegate.h"
#import "LaunchTime.h"
@interface AppDelegate ()
@end
@implementation AppDelegate
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
// Insert code here to initialize your application
[LaunchTime mark];
}
会得到这样的输出:
2022-07-01 14:42:16.641650+0800 testTime[40118:413613] ----------App启动---------耗时:pre-main:0.839135
2022-07-01 14:42:16.641719+0800 testTime[40118:413613] ----------App启动---------耗时:didfinish:0.225325
2022-07-01 14:42:16.641759+0800 testTime[40118:413613] ----------App启动---------耗时:total:1.064460
3: premain时间的组成
1: dylib loading time
动态链接器会把所有可执行文件所依赖的动态库递归的加载到内存中
分析每个dylib(大部分是系统的),找到其Mach-O文件, 打开并读取验证有效性
找到代码签名注册到内核,最后对dylib的每个segment调用mmap()。
在dylib的加载过程中系统为了安全考虑引入了ASLR(Address Space Layout Randomization)技术和代码签名。
ASLR技术: 镜像Image、可执行文件、dylib、bundle在加载的时候,会在其指向的地址(preferred_address)前面添加一个随机数偏差(slide),防止应用内部地址被定位。
我们无法提前计算加载这些 dylibs 所需的时间,解决方案就是尽量少加载 dylibs
- 合并现有的 dylibs
- 使用静态库
- 使用 dlopen() 进行懒加载(dlopen()可能导致其他问题,甚至会做更多工作,所以不推荐)
- 对于程序员自做的动态库可以通过`减少`动态库个数来优化, 苹果的建议是在一个app里面, 自做的动态库`不要超过6个`是最好的.
- 而对于系统的动态库例如`libSystem.B.dylib`和`Foundation`, 是存放于共享缓存里的, 都经过了系统高效的优化了, 比程序员自己制作的动态库要快.
2: rebase/binding time
在dylib加载完成之后,它们处于相互独立的状态,需要绑定起来;
rebase:rebase主要是做指针的修复, App在编译时,会生成二进制文件,在文件内部的所有方法和函数,都记录了一个偏移地址.
当程序被加载到内存的
物理地址是随机的(可执行文件里的数据访问每次被加载都是物理地址随机的),但是在虚拟地址每次都是从0开始的。这样的不安全的.
所以苹果引入了ASLR技术(Address Space Layout Randomization,地址空间布局随机化),它是为了让程序每次启动时的虚拟地址不是从0开始,而是从一个随机的值开始.
并将随机值插入到二进制文件的开头,每个方法和函数加载在内存中的真实地址即为: ASLR随机值 + 偏移值
这样,每次运行,都会重新分配ASLR随机值,都要偏移修正重新加载,这就导致耗时。性能消耗主要在IO;
-
binding:binding把符号和符号实现的地址绑定. 性能消耗主要在CPU计算。在xcode上新建一个
commandline tool, 然后用xcrun nm -nm $path查看文件中的符号, 输出如下:
xcrun nm -nm ~/Desktop/testBind/build/Debug/testBind
(undefined) external _NSLog (from Foundation)
(undefined) external ___CFConstantStringClassReference (from CoreFoundation)
(undefined) external _objc_autoreleasePoolPop (from libobjc)
(undefined) external _objc_autoreleasePoolPush (from libobjc)
(undefined) external dyld_stub_binder (from libSystem)
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100003f20 (__TEXT,__text) external _main
0000000100008018 (__DATA,__data) non-external __dyld_private
在上面的输出中可以看到有五个没有定义的符号. 其中dyld_stub_binder(并不是唯一的绑定器)就是用来符号绑定的.
在进行静态链接的时候, 没定义的符号就会在链接的时候被标记是在哪个动态库里面的.
要调用函数就要知道函数实现的确切地址, 这个时候就需要dyld_stub_binder的绑定.
例如当程序执行的时候遇到了_NSLog符号, dyld_stub_binder 会把_NSLog符号和NSLog方法在Foundation里实现的地址进行绑定, 从而执行函数的时候能找到具体的函数实现. 这个过程叫做binding.
3: Objc setup time
runtime初始化, 这个阶段会注册所有的类
runtime会维护一张类名与类的方法列表的全局表- 读取所有类,将类对象其注册到这个全局表中(
class registration) - 读取所有分类,把分类加载到类对象中(
category registration) - 检查
selector的唯一性(selector uniquing), 可以提升方法的响应速度, sel能更快的找到imp 所以程序中, 如果有类没有使用到, 或者方法没有使用到, 都会可以影响启动时间.
4: load&initialize&constructor time
各种初始化的操作
- 调用所有类的
+load方法 - 初始化C&C++静态化2常量
- 调用__attribute__((constructor))修饰的函数
不要在+load函数⾥⾯做⼀些耗时的操作,或者把⼀些操作延时的放在+initialize⾥⾯去执⾏
4: 优化方向
-
a.移除不需要用到的动态库,尽量使用系统库,且苹果建议
动态库数量控制在 6 个以下(超过6个建议合并),防止劣化,需要严格管控动态库的引入; -
b. 可以通过
CocoaPods转静态库, reference: www.jianshu.com/p/01948ba03… -
c.移除不需要用到的类; 合并功能类似的类和扩展;
经测试 20000 个类会增加约 800毫秒; -
d.尽量进行
懒加载,尽量避免在load()方法里执行操作,把操作推迟到initialize()方法; -
e.利用好
多核多线程,但也要注意控制好线程的数量和优先级; -
f.可以尝试让代码执行更快。比如,频繁访问的可以只获取一次就存下来。
5: 好用的工具
1: fui: 可以检测到工程未使用的类 github.com/dblock/fui
2: 检查未被使用的图片 github.com/tinymind/LS…
6: 物理内存和虚拟内存
1: 物理内存
物理内存就是你的机器本身内存(如内存条的大小)。物理内存就是CPU的地址线可以直接进行寻址的内存空间大小。
在以前的古老操作系统app占用的是物理内存,比如说一个app占用4G内存,启用4个app就用完了, 当运行多个程序时,经常会出现以下问题:
- 1.进程地址空间不隔离,没有权限保护。 由于程序都是直接访问物理内存,所以一个进程可以修改其他进程的内存数据, 甚至修改内核地址空间中的数据。
- 2.内存使用效率低 当内存空间不足时,要将其他程序暂时拷贝到硬盘,然后将新的程序装入内存运行。 由于大量的数据装入装出,内存使用效率会十分低下。
- 3.程序运行的地址不确定 因为内存地址是随机分配的,所以程序运行的地址也是不确定的。
2: 虚拟内存概念
虚拟内存技术,即拿出一部分硬盘空间来充当内存使用,当内存占用完时,电脑就会自动调用硬盘来充当内存,以缓解内存的紧张。比如说当电脑要读取一个比物理内存还要大的文件时,就要用到虚拟内存,文件被内存读取之后就会先储存到虚拟内存,等待内存把文件全部储存到虚拟内存之后,就把虚拟内里储存的文件释放到原来的目录里了。
计算机的内存大小等于实际物理内存容量加上分页文件(就是交换文件)的大小。
内存是分页管理的,映射表不能以字节为单位,是以页为单位。
Linux以4K为一页macOS以4K为一页iOS以16K一页 可以在终端输入pageSize
$ pageSize
4096
3: 虚拟内存的工作原理:
引用了虚拟内存后 , 在我们认为进程中有一大片连续的内存空间,也就是说从 0x000000 ~ 0xffffff 我们是都可以访问的。但是实际上这个内存地址只是一个虚拟地址,而这个虚拟地址通过一张映射表映射后才可以获取到真实的物理地址。
也就是说,系统对真实物理内存访问做了一层限制,只有被写到映射表中的地址才是被认可可以访问的。虚拟地址 0x000000 ~ 0xffffff 这个范围内的任意地址我们都可以访问,但是这个虚拟地址对应的实际物理地址是计算机来随机分配到内存页上的。如图所示:
显然 , 引用虚拟内存后就不存在通过偏移可以访问到其他进程的地址空间的问题了 。
因为每个进程的映射表是单独的,在你的进程中随便你怎么访问,这些地址都是受映射表限制的,其真实物理地址永远在规定范围内,也就不存在通过偏移获取到其他进程的内存空间的问题了。
而且 , 应用每次被加载到内存中 , 实际分配的物理内存并不一定是固定或者连续的,这是因为内存分页以及懒加载以及 ASLR 。
Android 4.0、Apple iOS4.3、OS X Mountain Lion10.8 开始全民引入 ASLR 技术,而实际上自从引入 ASLR 后,黑客的门槛也自此被拉高,不再是人人都可做黑客的年代了。
4: 地址翻译
cpu寻址过程: 通过虚拟内存地址,找到对应进程的映射表, 会通过这张表查询该虚拟地址是否已经在物理内存中申请了空间.
如果已经申请了则通过表的记录访问物理内存地址.
如果没有申请则申请一块物理内存空间并记录在表中(Page Fault).
通过映射表找到其对应的真实物理地址,进而找到数据。这个过程被称为 地址翻译,这个过程是由操作系统以及 cpu 上集成的一个 硬件单元 MMU 协同来完成的. 这个过程需要CPU和操作系统的配合.
5: Page Fault
当应用被加载到内存中时 ,并不会将整个应用加载到内存中。只会放用到的那一部分。也就是 懒加载 , 换句话说就是应用使用多少 , 实际物理内存就分配多少。
当数据未在物理内存会进行下列操作:
1.系统阻塞该进程(终断进程), 触发缺页中断
2.当一个缺页中断被触发,操作系统会从磁盘中重新读取这页数据到物理内存上,然后将映射表中虚拟内存指向对应物理内存。
上述行为就就是Page Fault ,Page Fault的数量和加载耗时长都会随着代码增加而增加
通过PAGESIZE查看系统在一页page的大小, M1芯片16K、inter芯片4K, linux内存 4kb一页,iOS是16kb
$ PAGESIZE
4096
0 和 1 代表当前地址有没有在物理内存中。 从上图我们也可以看出,进程的实际物理内存地址并不是连续的,而是由若干完整的内存分页组成。
这样做带来的好处是:
- 灵活内存:
如果当前内存
已满,操作系统会通过置换页算法找一页数据进行覆盖。这也是为什么开再多的应用也不会崩掉,但是之前开的应用再打开,就会重新启动的根本原因。 - 解决安全问题:
空间问题已经解决了,但是安全问题是怎么解决的呢?
在dylib的加载过程中系统为了安全考虑引入了ASLR(Address Space Layout Randomization)技术和代码签名。
ASLR技术:镜像Image、可执行文件、dylib、bundle在加载的时候,会在其指向的地址(preferred_address)前面添加一个 随机数偏差(slide),防止应用内部地址被定位。
Reference:
www.jianshu.com/p/d724ebff9… blog.csdn.net/qq_34888036… www.jianshu.com/p/cbc9f5972…