iOS-底层原理28-启动优化

《iOS底层原理文章汇总》

本文主要介绍,程序的启动时间,虚拟内存和物理内存之间的映射,二进制重排优化启动时间的原理

查看程序的启动时间

添加环境变量Edit Scheme...->Arguments->+->DYLD_PRINT_STATISTICS,查看启动时间

DYLD_PRINT_STATISTICS@2x.png
启动时间@2x.png
35.gif
  • 查看微信的启动时间,怎么运行微信呢?

1.在工程目录下新建APP文件夹,将微信的.ipa文件copy到目录下

微信-7.0.8.ipa和appSign.sh@2x.png

2.在Build Phases中添加Run Scripts,./appSign.sh,appSign.sh里面的内容如下:

appSign.sh@2x.png
# ${SRCROOT} 它是工程文件所在的目录
TEMP_PATH="${SRCROOT}/Temp"
#资源文件夹,我们提前在工程目录下新建一个APP文件夹,里面放ipa包
ASSETS_PATH="${SRCROOT}/APP"
#目标ipa包路径
TARGET_IPA_PATH="${ASSETS_PATH}/*.ipa"
#清空Temp文件夹
rm -rf "${SRCROOT}/Temp"
mkdir -p "${SRCROOT}/Temp"



#----------------------------------------
# 1. 解压IPA到Temp下
unzip -oqq "$TARGET_IPA_PATH" -d "$TEMP_PATH"
# 拿到解压的临时的APP的路径
TEMP_APP_PATH=$(set -- "$TEMP_PATH/Payload/"*.app;echo "$1")
# echo "路径是:$TEMP_APP_PATH"


#----------------------------------------
# 2. 将解压出来的.app拷贝进入工程下
# BUILT_PRODUCTS_DIR 工程生成的APP包的路径
# TARGET_NAME target名称
TARGET_APP_PATH="$BUILT_PRODUCTS_DIR/$TARGET_NAME.app"
echo "app路径:$TARGET_APP_PATH"

rm -rf "$TARGET_APP_PATH"
mkdir -p "$TARGET_APP_PATH"
cp -rf "$TEMP_APP_PATH/" "$TARGET_APP_PATH"



#----------------------------------------
# 3. 删除extension和WatchAPP.个人证书没法签名Extention
rm -rf "$TARGET_APP_PATH/PlugIns"
rm -rf "$TARGET_APP_PATH/Watch"



#----------------------------------------
# 4. 更新info.plist文件 CFBundleIdentifier
#  设置:"Set : KEY Value" "目标文件路径"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $PRODUCT_BUNDLE_IDENTIFIER" "$TARGET_APP_PATH/Info.plist"


#----------------------------------------
# 5. 给MachO文件上执行权限
# 拿到MachO文件的路径
APP_BINARY=`plutil -convert xml1 -o - $TARGET_APP_PATH/Info.plist|grep -A1 Exec|tail -n1|cut -f2 -d\>|cut -f1 -d\<`
#上可执行权限
chmod +x "$TARGET_APP_PATH/$APP_BINARY"



#----------------------------------------
# 6. 重签名第三方 FrameWorks
TARGET_APP_FRAMEWORKS_PATH="$TARGET_APP_PATH/Frameworks"
if [ -d "$TARGET_APP_FRAMEWORKS_PATH" ];
then
for FRAMEWORK in "$TARGET_APP_FRAMEWORKS_PATH/"*
do

#签名
/usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$FRAMEWORK"
done
fi


#注入
#yololib "$TARGET_APP_PATH/$APP_BINARY" "Frameworks/HankHook.framework/HankHook"

上面的脚本为嘛这么写,后面再分析???

pre-main-time@2x.png

物理内存和虚拟内存

  • 1.物理内存:物理内存(Physical memory)是相对于虚拟内存而言的。物理内存指通过物理内存条而获得的内存空间,而虚拟内存则是指将硬盘的一块区域划分来作为内存。内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存。常见的物理内存规格有256M、512M、1G、2G等,现如今随着计算机硬件的发展,已经出现4G、8G甚至更高容量的内存规格。 当物理内存不足时,可以用虚拟内存代替。在应用中,自然是顾名思义, 物理 上,真实存在的插在 主板 内存槽上的 内存 条的 容量 的大小。看 计算机 配置的时候,主要看的就是这个物理内存。

  • 2.虚拟内存:一个进程运行时都会得到4G的虚拟内存。这个虚拟内存你可以认为,每个进程都认为自己拥有4G的空间,这只是每个进程认为的,但是实际上,在虚拟内存对应的物理内存上,可能只对应的一点点的物理内存,实际用了多少内存,就会对应多少物理内存。进程得到的这4G虚拟内存是一个连续的地址空间(这也只是进程认为),而实际上,它通常是被分隔成多个物理内存碎片,还有一部分存储在外部磁盘存储器上,在需要时进行数据交换。

虚拟内存的工作原理:当每个进程创建的时候,内核会为进程分配4G的虚拟内存,当进程还没有开始运行时,这只是一个内存布局。实际上并不立即就把虚拟内存对应位置的程序数据和代码(比如.text .data段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射)。这个时候数据和代码还是在磁盘上的。当运行到对应的程序时,进程去寻找页表,发现页表中地址没有存放在物理内存上,而是在磁盘上,于是发生缺页异常,于是将磁盘上的数据拷贝到物理内存中。

另外在进程运行过程中,要通过malloc来动态分配内存时,也只是分配了虚拟内存,即为这块虚拟内存对应的页表项做相应设置,当进程真正访问到此数据时,才引发缺页异常。

可以认为虚拟空间都被映射到了磁盘空间中(事实上也是按需要映射到磁盘空间上,通过mmap,mmap是用来建立虚拟空间和磁盘空间的映射关系的)

利用虚拟内存机制的优点

1.既然每个进程的内存空间都是一致而且固定的(32位平台下都是4G),所以链接器在链接可执行文件时,可以设定内存地址,而不用去管这些数据最终实际内存地址,这交给内核来完成映射关系

2.当不同的进程使用同一段代码时,比如库文件的代码,在物理内存中可以只存储一份这样的代码,不同进程只要将自己的虚拟内存映射过去就好了,这样可以节省物理内存

3.在程序需要分配连续空间的时候,只需要在虚拟内存分配连续空间,而不需要物理内存时连续的,实际上,往往物理内存都是断断续续的内存碎片。这样就可以有效地利用我们的物理内存

测试APP的启动分为两个阶段,main函数之前和main函数之后

main函数之后的函数调用的时间记录,在main函数中打个点,在main函数之后的第一个界面ViewController的viewdidload中打个点,计算下耗时

rebase/binding偏移和符号绑定

1.rebase偏移修正:App生成二进制文件后,二进制文件内部所有里面的方法,函数调用都有个地址,这个地址是在当前二进制文件当中的偏移地址,funcA,地址为0x0001,运行到内存时,经过ASLR(安全机制)分配一个随机数值插入二进制开头,0x1000,运行时刻访问funcA,需要访问内存地址0x0001+0x1000 = 0x1001,内存值是8个字节,64位。

2.binding绑定:viewdidload方法中写一句NSLog(@"123");Cmd + B后,NSLog的真实地址Xcode知道吗?Xcode不知道,NSLog调用的Foundation框架里面,属于动态库在外部,编译时期NSLog的真实地址拿不到怎么办呢?Cmd+B编译后生成的MachO文件中创建一个符号NSLog,指向一个随机的地址或固定的值,在MachO文件的数据端,在程序真正运行时,将NSLog符号指向的NSLog地址和动态库中的NSLog进行关联,关联的过程叫绑定。MachO文件在磁盘中,运行时copy到内存中,由dyld操作这个过程,foundation在哪,NSLog在哪,dyld知道告诉并绑定,符号在内存中只有一份,绑定的过程其实是给符号赋值的过程。

如果想绑定时间变少,则使用外部动态库使用少一点,少绑定操作

官方建议自定义的动态库不要多于6个,多于6个则要合并,静态库直接打包在MachO主程序中了,动态库打包在framework文件夹目录下

ObjC setup time:减少OC类

initializer time:不必要在load加载的内容不写在load方法中,可以延迟到initialize方法或main以后的方法中

main函数以后

1.能懒加载的就懒加载

2.删除不用的文件,已经下线的业务删掉

3.启动加载的东西尽量使用多线程,启动那一刻加载,尽可能把CPU的性能发挥出来,时间降低

4.启动时刻展示的页面一定不要用Xib或storyboard,用纯代码写,xib或storyboard中间要做代码的解析或页面的渲染,首屏的页面和UI的主框架tabbarController

5.业务逻辑层面的优化,OC两万个类会增加800毫秒

二进制重排原理:为什么二进制重排能优化启动时间呢?

先了解一个应用程序是怎么加载到内存并被CPU调度运行的?

每一个应用程序加载进内存前,操作系统为每一个应用程序分配一个进程,内核为进程分配4G的虚拟内存空间

用户使用的功能是部分活跃的,部分不活跃,很少使用的,并不会将应用程序一下子完全加载进内存,而是分

页来管理,将应用程序按照每页16K的大小分为很多页,放到4G的虚拟内存空间,每个进程有4GB的虚拟地址

空间,每个进程自己的一套页表。程序中使用的都是4GB地址空间中的虚拟地址。而访问物理内存,需要使用

物理地址。通过MMU访问页表将虚拟地址映射到物理地址上。

虚拟内存指向物理内存.png
  • 当用户点击应用程序APP启动的时候,CPU上面的MMU(Memory Management Unit内存管理单元)访问页表。用来匹配虚拟内存和物理内存。页表中每个项通常为32位,即4byte,除了存储虚拟地址和页框地址之外,还会存储一些标志位,比如是否缺页,是否修改过,写保护等。因为页表中每个条目是4字节,现在的32位操作系统虚拟地址空间是232,假设每页分为4k,也需(232/(42^10))4=4M的空间,为每个进程建立一个4M的页表并不明智。因此在页表的概念上进行推广,产生二级页表,虽然页表条目没有减少,但内存中可以仅仅存放需要使用的二级页表和一级页表,大大减少了内存的使用。一个页表的大小为4K字节,页表项(PTE, page table entry)的大小为4个字节(32bit),所以一个页表中有1024个页表项
虚拟内存的页及页码.png
  • Linux,MacOS每页大小为4K,iOS每页大小为16K。虚拟地址和物理地址前面的偏移量是为了数据安全,ASLR机制。若不加偏移量,每个地址都是从零开始。


    页表.png
  • 页表的的工作原理:处理器遇到的地址都是虚拟地址。虚拟地址和物理地址都分成页码(页框码)和偏移值两部分。在由虚拟地址转化成物理地址的过程中,偏移值不变。而页码和页框码之间的映射就在一个映射记录表——页表中。

页码-页框码-页表.png

当处理器试图访问一个虚存页面时,首先到页表中去查询该页是否已映射到物理页框中,并记录在页表中。如果在,则MMU会把页码转换成页框码,并加上虚拟地址提供的页内偏移量形成物理地址后去访问物理内存;如果不在,则意味着该虚存页面还没有被载入内存,这时MMU就会通知操作系统:发生了一个页面访问错误(页面错误PageFault阻塞当前进程,从磁盘中去拷贝16K的数据,用户几乎感知不到),接下来系统会启动所谓的“请页”机制,即调用相应的系统操作函数,判断该虚拟地址是否为有效地址。

物理内存和虚拟内存.001.jpeg

如果是有效的地址,就从虚拟内存中将该地址指向的页面读入到内存中的一个空闲页框中,并在页表中添加上相对应的表项,最后处理器将从发生页面错误的地方重新开始运行;如果是无效的地址,则表明进程在试图访问一个不存在的虚拟地址,此时操作系统将终止此次访问。

当然,也存在这样的情况:在请页成功之后,内存中已没有空闲物理页框了。这是,系统必须启动所谓地“交换”机制,即调用相应的内核操作函数,在物理页框中寻找一个当前不再使用或者近期可能不会用到的页面所占据的页框。找到后,就把其中的页移出,以装载新的页面。对移出页面根据两种情况来处理:如果该页未被修改过,则删除它;如果该页曾经被修改过,则系统必须将该页写回辅存。

系统请页处理过程.png
页表的工作原理.png

每个应用分成若干个页,也就是若干个16K,一个16K一个16K进行加载的,只会覆盖,没有删除操作,覆盖掉不活跃的一页16K数据。

ASLR是为了解决虚拟地址万年不变产生安全隐患的问题,加上偏移地址

内存满了@2x.png

为了公平地选择将要从系统中抛弃的页面,Linux系统使用最近最少使用(LRU)页面的衰老算法。这种策略根据系统中每个页面被访问的频率,为物理页框中的页面设置了一个叫做年龄的属性。页面被访问的次数越多,则页面的年龄最小;相反,则越大。而年龄较大的页面就是待换出页面的最佳候选者。

如果只是有一个缺页异常PageFault,耽误的时间是毫秒,用户感知不到,若发生很多个PageFault呢,用户能感知到吗?大量的毫秒堆积,形成了一秒钟。用户能感知到。在什么情况下会发生很多个缺页异常(PageFault)呢?

应用程序启动时刻,会加载成百上千页数据,会发生多个缺页异常,属于耗时操作,进程中断,去磁盘拷贝数据,缓存,修改页表,每一页数据16K,可能这16K数据里面只有一个函数是只需要在启动时刻调用的。能不能把所有在启动时刻调用的函数,放在一页中呢?

文件中函数的执行顺序:load()->test2()->viewDidLoad()->test1()

ViewController@2x.png

代码编译成二进制文件后的排列顺序呢?每一个方法的排列顺序是什么样的?

编译后,building setting中搜索map,将Write Link Map File文件改为YES,在Product同级目录Intermediates.noindex/Demo.build/Debug-iphoneos/Demo.build/Demo-LinkMap-normal-arm64.txt中查看文件中每一个方法的排列顺序

方法的顺序@2x.png
34.gif

文件的顺序为编译的顺序,与Build Phases中的文件的编译顺序相同

文件的顺序@2x.png
BuildPhases@2x.png

综上,二进制文件的排列顺序是先按照文件的编译顺序Build Phases中的顺序,再按照单个文件中的函数的的书写顺序,这种排列方式就会导致在启动时刻的代码分布在了不同的文件里面,从而在不同的分页里面,导致启动方法并没有集中在一起,从而导致启动时刻有大量的PageFault。

优化思路:将所有启动时刻调用的方法排列在一起

二进制重排

查看ObjC源文件中的.order文件,发现libobjc.order里面是源码的函数编译顺序

libobjc.order@2x.png
libobjc.order编译顺序@2x.png
  • 查看微信在启动时刻一共有多少次PageFault

运行程序,Cmd + I打开Instruments,选择System Trace,点击运行按钮,待微信首屏出来后,点击停止按钮,此时搜索主线程Main Thread,过滤虚拟内存,得到此刻运行在手机上的微信的PageFault的次数

SystemTrace@2x.png
运行首屏@2x.png
停止@2x.png
Analyzing@2x.png
MainThread@2x.png
Virtual Memory@2x.png
微信PageFault@2x.png

二进制重排初体验

现在希望ViewController中的方法ViewDidLoad排在第一位,load方法排在第二位,test1排在第三位,test2排在第四位

  • 对比LinkMap文件和Demo的可执行文件在MachOView中的地址,发现相等,Demo的可执行文件放入MachOView文件中查看地址
LinkMap文件和Demo的可执行文件对比@2x.png
37.gif
  • 1.编辑cloud.order文件在工程根目录下,编辑文件的加载顺序_main,-[ViewController viewDidLoad],-[cloud hello] (一个不存在的对象方法),_test1,_test2
cloud.order@2x.png
  • 2.按照cloud.order的顺序进行编译,Build Settings中搜索order file文件
cloud.order文件编译顺序@2x.png
  • 3.查看LinkMap中的文件执行顺序是否真的按照cloud.order中的执行顺序呢?一模一样,不存在的对象方法[cloud hello]链接器自动过滤
38.gif
二进制重排后的LinkMap和cloud.order中函数顺序对比@2x.png

此时如果优化启动时间,必须知道启动时刻调用了哪些方法。怎么知道启动时刻调用了哪些方法呢?请看下一篇文章《二进制重排优化启动时间》。

推荐阅读更多精彩内容