iOS 底层探索 - 分类的加载
初探懒加载类
上一章我们探索了 iOS
中类的加载,让我们简单回顾一下大概的流程。
类的加载回顾
libObjc
向dyld
注册了回调_dyld_objc_notify_register
,当dyld
把App
以及App
所依赖的一系列Mach-O
镜像加载到当前App
被分配的内存空间之后,dyld
会通过_dyld_objc_notify_mapped
也就是map_images
来通知libObjc
来完成具体的加载工作,map_images
被调用之后会来到_read_images
_read_images
- 主要会进行类的加载工作,会插入 所有的类 到
gdb_objc_realized_classes
哈希表中(插入方式为 类名为key
,类对象为value
, 不包括通过 共享缓存 里面的类),同时还会把类插入到allocatedClasses
这个集合里面,注意,allocatedClasses
的类型为NXHashTable
,可以类比为NSSet
,而gdb_objc_realized_classes
的类型为NXMapTable
,可以类比为NSDictionary
- 对所有的类进行重映射
- 将所有的
SEL
插入到namedSelectors
哈希表中(插入方式为:SEL
名称为key
,SEL
为value
) - 修复函数指针遗留
- 将所有的
Protocol
插入到readProtocol
哈希表中(插入方式为:Protocol
名称为key
,Protocol
为value
) - 对所有的
Protocol
做重映射 - 初始化所有的非懒加载类,包括
rw
和ro
的初始化操作 - 处理所有的分类(包括类的分类和元类的分类)
- 主要会进行类的加载工作,会插入 所有的类 到
懒加载类的发现
我们这个时候观察 _read_images
源码这部分的注释:
Realize non-lazy classes (for +load methods and static instances)
实现非懒加载类(实现了
+load
方法和静态实例)
什么意思呢,我们这里其实打印的都是所谓的非懒加载类,这里除了我们自己实现了 +load
方法的两个类之外,其他的内容都是系统内置的类,包括我们十分熟悉的 NSObject
类。通过这里其实反过来推论,我们没有实现 +load
方法就是所谓的懒加载类,这种类并不会在 _read_images
环节被加载,那么应该是在哪里加载呢?我们稍微思考一下,我们一般第一次操作一个类是不是在初始化这个类的时候,而初始化类不就是发送alloc
消息吗,而根据我们前面探索消息查找的知识,在第一次发送某个消息的时候,是没有缓存的,所以会来到一个非常重要的方法叫lookUpImpOrForward
,我们在main.m
中LGPerson
类初始化的地方和lookUpImpOrForward
入口处打上断点:
Tips: 这里有个小技巧,我们先打开 main.m
文件中的断点,等断点来到了我们想要探索的 LGPerson
初始化的位置的时候,我们再打开 lookUpImpOrForward
处的断点,这样才能确保当前执行 lookUpImpOrForward
的是我们的研究对象 LGPerson
因为我们断点的位置是 LGPerson
类发送 alloc
消息,而显然 alloc
作为类方法是存储在元类上的,也就是说 lookUpImpOrForward
的 cls
其实是 LGPerson
元类。那么 inst
就应该是真正的对象.
我们总结一下,如果类没有实现 load
方法,那么这个类就是懒加载类,反之、这个类如果实现了 load
方法,那么这个类就是非懒加载类。
懒加载类的流程
关于非懒加载类的加载流程我们已经很熟悉了,我们总结下懒加载类的流程:
- 类第一次发送消息的时候是没有缓存的,所以会来到
_class_lookupMethodAndLoadCache3
,关于这个方法我们在前面的消息查找章节已经介绍过了,不熟悉的同学可以去查阅一下。 _class_lookupMethodAndLoadCache3
会调用lookUpImpOrForward
,这个方法的重要性在我们学习Runtime
的过程中不言而喻lookUpImpOrForward
内部会进行一下判断,如果cls
没有被实现,会调用realizeClassMaybeSwiftAndLeaveLocked
方法realizeClassMaybeSwiftAndLeaveLocked
方法又会调用realizeClassMaybeSwiftMaybeRelock
方法realizeClassMaybeSwiftMaybeRelock
方法内部会进行一下是否是Swift
的判断,如果不是Swift
环境的话,就会来到realizeClassWithoutSwift
,也就是最终的类的加载的地方
分类的底层实现
分类作为 Objective-C
中常见的特性,相信大家都不会陌生,不过在底层它是怎么实现的呢?
分类的定义
我们根据 _category_t
来到 libObjc
源码中进行查找,不过我们需要去掉一下 _category_t
的下划线,然后不难找到分类真正的定义所在:
1 | struct category_t { |
根据刚才 clang
重写之后的内容,我们不难看出
name
: 是分类所关联的类,也就是类的名字,而不是分类的名字cls
: 我们在前面可以看到clang
重写后这个值为 0,但是后面有注释为&OBJC_CLASS_$_LGTeacher
,也就是我们的类对象的定义,所以这里其实就是我们要扩展的类对象,只是在编译期这个值并不存在instanceMethods
: 分类上存储的实例方法classMethods
:分类上存储的类方法protocols
:分类所实现的协议instanceProperties
:分类所定义的实例属性,不过我们一般在分类中添加属性都是通过关联对象来实现的_classProperties
:分类所定义的类属性。这里有一行注释:Fields below this point are not always present on disk.
下面的内容并不是一直在磁盘上保存
也就是说 _classProperties
其实是一个私有属性,但并不是一直都存在的。
分类的加载
我们现在知道了类分为了 懒加载类
和 非懒加载类
,它们的加载时机是不一样的,那么分类的加载又是怎么样的呢?我们还是同样的先分析没有实现 load
方法的分类的情况:
但是我们在分析前,还要搞清楚一点,分类必须依附于类而存在,如果只有分类,没有类,那么从逻辑上是说不通的,就算实现了,编译器也会忽略掉。而关于类是懒加载还是非懒加载的,所以这里我们还要再细分一次。
- 懒加载分类与懒加载类
- 懒加载分类和非懒加载类
没有实现 load 的分类
与懒加载类配合加载
我们先分析第一种情况,也就是类和分类都不实现 load
方法的情况。
首先,懒加载类的流程上面我们已经探索过了,在向类第一次发送消息的时候,懒加载类才会开始加载,而根据我们上一章类的加载探索内容,在 realizeClassWithoutSwift
方法的最后有一个 methodizeClass
方法,在这个方法里面会有一个 Attach categories
的地方:
1 | // Attach categories. |
我们进入unattachedCategories
的attachToClass
方法
1 | void attachToClass(Class cls, Class previously, int flags) |
但是我们断点之后发现这个时候通过 unattachedCategoriesForClass
方法并没有取到分类,我们此时不妨通过 LLDB
打印一下当前类里面是否已经把分类的内容附加上了。
前面的流程大家都很熟悉了,我们直接看 cls
的 rw
中的 methods
是否有内容:
这样进一步证明了,如果是懒加载类,并且分类也是懒加载,那么分类的加载并不会来到 unattachedCategoriesForClass
,而是直接在编译时加载到了类的 ro
里面,然后在运行时被拷贝到了类的 rw
里面。这一点可以通过下面的 LLDB
打印来证明。
与非懒加载类配合加载
同样的道理,当类为非懒加载类的时候,走的是 _read_images
里面的流程,这个时候我们的懒加载分类是在哪加载的呢?
分类还是不在这,同时通过 LLDB
打印,发现分类的方法已经在类的 ro
里面了,所以说分类的加载其实跟类的懒加载与否并没有关系,也就是说懒加载的分类都是在编译时期被加载的。
实现了 load 的分类
我们再接着分下下面两种情况:
- 非懒加载分类与懒加载类
- 非懒加载分类和非懒加载类
与懒加载类配合加载
其实懒加载和非懒加载的最大区别就是加载是否提前,而实现了 +load
方法的分类,面对的是懒加载的类,
而懒加载的类我们前面已经知道了,是在第一次发送消息的时候才会被加载的,那我们直接在lookupImpOrForward
=> realizeClassMaybeSwiftAndLeaveLocked
=> realizeClassMaybeSwiftMaybeRelock
=> realizeClassWithoutSwift
=> methodizeClass
流程中的 methodizeClass
打上断点,看下在这里分类会不会被加载:
这一次通过 unattachedCategoriesForClass
取出来值了,并且在这之前 cls
的 ro
中并没有分类的 initialize
方法:
为什么走的不是发送消息的流程,而走的是 load_images
里面的 prepare_load_methods
方法呢?我们来到 prepare_load_methods
方法处:
可以看到,其实是在这里调用了 realizeClassWithoutSwift
方法来加载类的。而上面的 _getObjc2NonlazyCategoryList
方法显示就是获取的所有的非懒加载分类,然后遍历这些非懒加载分类,然后去加载这些分类所依赖的类。这个逻辑很好理解,非懒加载分类让我们的懒加载类实现提前了,所以说懒加载类并不一定只会在第一次消息发送的时候加载,还要取决于有没有非懒加载的分类,如果有非懒加载的分类,那么就走的是 load_images
里面的 prepare_load_methods
的 realizeClassWithoutSwift
。
与非懒加载类配合加载
非懒加载类的流程我们也十分熟悉了,在 _read_images
里面进行加载,而此时,分类也是非懒加载。我们还是在 methodizeClass
里面进行断点:
结果如上图所示,这次从 unattachedCategoriesForClass
方法取出来的是 NULL
值,显然分类不是在这个地方被加载的,我们回到 _read_images
方法,还记得那个 Discover categories
流程吗,我们打开里面的断点:
其实 attachCategories
这个方法只会在实现了 load
方法的分类下才会被调用,而来到 attachCategories
之前又取决于类是否为懒加载,如果是懒加载,那么就在 load_images
里面去处理,如果是非懒加载,那么就在 map_images
里面去处理。
总结
我们今天探索的内容可能会有点绕,不过其实探索下来,我们只需要保持研究重点就很简单。分类的加载其实可以笼统的分为实现 load
方法和没有实现 load
方法:
- 没有实现
load
方法的分类由编译时确定 - 实现了
load
方法的分类由运行时去确定
这也说明分类的加载和类的加载是不一样的,而结合着类的懒加载与否,我们有以下的结论:
- 懒加载分类 + 懒加载类
类的加载在第一次消息发送的时候,而分类的加载则在编译时
- 懒加载分类 + 非懒加载类
类的加载在
_read_images
处,分类的加载还是在编译时
- 非懒加载分类 + 懒加载类
类的加载在
load_images
内部,分类的加载在类加载之后的methodizeClass
- 非懒加载分类 + 非懒加载类
类的加载在
_read_images
处,分类的加载在类加载之后的reMethodizeClass
iOS 底层探索 - 类的加载
应用的加载回顾
上一章我们对应用的加载有了初步的认识,我们知道了
- 系统调用
exec()
会我们的应用映射到新的地址空间 - 然后通过
dyld
进行加载、链接、初始化主程序和主程序所依赖的各种动态库 - 最后在
initializeMainExecutable
方法中经过一系列初始化调用notifySingle
函数,该函数会执行一个load_images
的回调 - 然后在
doModinitFuntions
函数内部会调用__attribute__((constructor))
的c
函数 - 然后
dyld
返回主程序的入口函数,开始进入主程序的main
函数 在main
函数执行执行,其实dyld
还会在流程中初始化libSystem
,而libSystem
又会去初始化libDispatch
,在libDispatch
初始化方法里面又会有一步_os_object_init
,在_os_object_init
内部就会调起_objc_init
。而对于_objc_init
我们还需要继续探索,因为这里面会进行类的加载等一系列重要的工作。
探索_objc_init
首先来到 libObjc
源码的 _objc_init
方法处,你可以直接添加一个符号断点 _objc_init
或者全局搜索关键字来到这里:
1 | /*********************************************************************** |
environ_init
我们直接看调用的第一个方法environ_init
1 | /*********************************************************************** |
我们可以看到,这里主要是读取影响 Runtime
的一些环境变量,如果需要,还可以打印环境变量帮助提示。
我们可以在终端测试一下,直接输入export OBJC-HELP=1
:
1 | jason@192 ~ % export OBJC_HELP=1 |
可以看到不同的环境变量对应的内容都被打印出来了。
tls_init
接着看tls_init
方法内部:
1 | void tls_init(void) |
这里执行的是关于线程 key
的绑定,比如每个线程数据的析构函数。
static_init
1 | /*********************************************************************** |
这里会运行 C++
的静态构造函数,在 dyld
调用我们的静态构造函数之前,libc
会调用 _objc_init
,所以这里我们必须自己来做,并且这里只会初始化系统内置的 C++
静态构造函数,我们自己代码里面写的并不会在这里初始化。
runtime_init
1 | void runtime_init(void) |
Runtime环境初始化,unattachedCategories和allocatedClasses两张表的初始化工作
exception_init
1 | /*********************************************************************** |
这里是初始化 libobjc
的异常处理系统,我们程序触发的异常都会来到:
1 | /*********************************************************************** |
我们可以看到 _objc_terminate
是未处理异常的回调函数,其内部逻辑如下:
- 检查是否是一个活跃的异常
- 如果是活跃的异常,检查是否是
OC
抛出的异常 - 如果是
OC
抛出的异常,调用uncaught_handeler
回调函数指针 - 如果不是
OC
抛出的异常,则继续C++
终止操作
cache_t::init()
看字面量很容易猜到,cache_t
属性的初始化
_imp_implementationWithBlock_init
1 | /// Initialize the trampoline machinery. Normally this does nothing, as |
启动回调机制。通常不会做什么,因为所有的初始化都是惰性的,但是对于某些进程,我们会迫不及待地加载trampolines dylib。
_dyld_objc_notify_register
接下来是我们今天探索的重点了: _dyld_objc_notify_register
,我们先看下它的定义:
1 | // |
注意:仅供 objc
运行时使用
当 objc
镜像被映射(mapped)、卸载(unmapped)和初始化(initialized)的时候,注册的回调函数就会被调用。
这个方法是 dlyd
中声明的,一旦调用该方法,调用结果会作为该函数的参数回传回来。比如,当所有的 images
以及 section
为 objc-image-info
被加载之后会回调 mapped
方法。load
方法也将在这个方法中被调用。
_dyld_objc_notify_register
方法的三个参数 map_images
、 load_images
、 unmap_image
其实都是函数指针:
1 | typedef void (*_dyld_objc_notify_mapped)(unsigned count, const char* const paths[], const struct mach_header* const mh[]); |
这三个函数指针是在 dyld
中回调的,我们打开 dyld
的源码即可一探究竟,我们直接搜索 _dyld_objc_notify_register
:
1 | void _dyld_objc_notify_register(_dyld_objc_notify_mapped mapped, |
接着来到 dyld
的 registerObjCNotifiers
方法内部:
1 | void registerObjCNotifiers(_dyld_objc_notify_mapped mapped, _dyld_objc_notify_init init, _dyld_objc_notify_unmapped unmapped) |
1 | static _dyld_objc_notify_mapped sNotifyObjCMapped; |
通过上面的代码的内容说明在registerObjCNotifiers
内部, libObjc
传过来的这三个函数指针被 dyld
保存在了本地静态变量中。换句话来说,最终函数指针是否能被调用,取决于这三个静态变量:
sNotifyObjCMapped
sNotifyObjCInit
sNotifyObjCUnmapped
我们注意到 registerObjCNotifiers
的 try-catch
语句中的 try
分支注释如下:
call ‘mapped’ function with all images mapped so far
调用mapped
函数来映射所有的镜像
那么也就是说 notifyBatchPartial
里面会进行真正的函数指针的调用,我们进入这个方法内部:
1 | static void notifyBatchPartial(dyld_image_states state, bool orLater, dyld_image_state_change_handler onlyHandler, bool preflightOnly, bool onlyObjCMappedNotification) |
我们可以看到,在 notifyBatchPartial
方法内部,这里的注释:
tell objc about new images 告诉
objc
镜像已经映射完成了
弄清楚了三个函数指针是怎么调用的还不够,接下来我们要深入各个函数的内部看里面究竟做了什么样的事情。
探索 map_images
首先是 map_images
,我们来到它的实现:
1 | /*********************************************************************** |
Process the given images which are being mapped in by dyld.
Calls ABI-agnostic code after taking ABI-specific locks.
处理由
dyld
映射的给定镜像
取得特定于ABI
的锁后,调用与ABI
无关的代码。
这里会继续往下走到 map_images_nolock
map_images_nolock
内部代码十分冗长,我们经过分析之后,前面的工作基本上都是进行镜像文件信息的提取与统计,所以可以定位到最后的 _read_images
:
1 | if (hCount > 0) { |
这里进入 _read_images
的条件是 hCount
大于 0, hCount
表示的是 Mach-O
中 header
的数量
OK,我们的主角登场了, _read_images
和 lookupImpOrForward
可以说是我们学习 Runtime
和 iOS
底层里面非常重要的两个概念了, lookUpImpOrForward
已经探索过了,剩下的 _read_images
我们也不能落下。
_read_images定义
Perform initial processing of the headers in the linked list beginning with headerList.
从 headerList
开始,对已经链接了的 Mach-O
镜像表中的头部进行初始化处理
我们可以看到,整个 _read_images
有接近 400 行代码。
通过查看代码,以及日志打印提示信息,我们大致可以将 _read_images
分为下面几个流程:
_read_images具体流程
doneOnce 流程
我们从第一个分支 doneOnce
开始,这个名词顾名思义,只会执行一次:
1 | #define EACH_HEADER \ |
- 通过宏
SUPPORT_NONPOINTER_ISA
判断当前是否支持开启内存优化的isa
- 如果支持,则在某些条件下需要禁用这个优化
- 通过宏
SUPPORT_INDEXED_ISA
判断当前是否是将类存储在isa
作为类表索引- 如果是的话,再递归遍历所有的
Mach-O
的头部,并且判断如果是Swift 3.0
之前的代码,就需要禁用对isa
的内存优化
- 如果是的话,再递归遍历所有的
1 | # if TARGET_OS_OSX |
- 通过宏
TARGET_OS_OSX
判断是否是macOS
执行环境 - 判断
macOS
的系统版本,如果小于10.11
则说明app
太陈旧了,需要禁用掉non-pointer isa
- 然后再遍历所有的
Mach-O
的头部,判断如果有__DATA__,__objc_rawisa
段的存在,则禁用掉non-pointer isa
,因为很多新的app
加载老的扩展的时候会需要这样的判断操作。
1 | // namedClasses |
预先优化过的类不会加入到 gdb_objc_realized_classes
这个哈希表中来, gdb_objc_realized_classes
哈希表的装载因子为 0.75,这是一个经过验证的效率很高的扩容临界值。
- 加载所有类到类的
gdb_objc_realized_classes
表中来
1 | // This is a misnomer: gdb_objc_realized_classes is actually a list of |
这是一个误称:gdb_objc_realized_classes 表实际上存储的是不在 dyld
共享缓存里面的命名类,无论这些类是否实现
除了 gdb_objc_realized_classes
表之外,还有一张表 allocatedClasses
:
其实 gdb_objc_realized_classes
对 allocatedClasses
是一种包含的关系,一张是类的总表,一张是已经开辟了内存的类表.
Discover classes 流程
1 | // Discover classes. Fix up unresolved future classes. Mark bundle classes. |
Discover classes. Fix up unresolved future classes. Mark bundle classes.
发现类。修正未解析的 future
类,标记 bundle
类。
- 先通过
_getObjc2ClassList
来获取到所有的类 - 接着还是遍历所有的
Mach-O
的header
部分,然后通过mustReadClasses
来判断哪些条件可以跳过读取类这一步骤 - 读取
header
是否是Bundle
- 读取
header
是否开启了 预优化 - 遍历
_getObjc2ClassList
取出的所有的类- 通过
readClass
来读取类信息 - 判断如果不相等并且
readClass
结果不为空,则需要重新为类开辟内存
- 通过
Fix up remapped classes 流程
1 | // Fix up remapped classes |
修复 重映射类
类表和非懒加载类表没有被重映射 (也就是 _objc_classlist)
由于消息转发,类引用和父类引用会被重映射 (也就是 _objc_classrefs)
- 通过
noClassesRemapped
方法判断是否有类引用(_objc_classrefs)需要进行重映射- 如果需要,则遍历
EACH_HEADER
- 通过
_getObjc2ClassRefs
和_getObjc2SuperRefs
取出当前遍历到的Mach-O
的类引用和父类引用,然后调用remapClassRef
进行重映射
- 如果需要,则遍历
Fix up @selector references 流程
1 | // Fix up @selector references |
修正
SEL
引用
- 操作前先加一个
selLock
锁 - 然后遍历
EACH_HEADER
- 如果开启了预优化,contiue 到下一个
Mach-O
- 通过
_getObjc2SelectorRefs
拿到所有的SEL
引用 - 然后对所有的
SEL
引用调用sel_registerNameNoLock
进行注册
- 如果开启了预优化,contiue 到下一个
也就是说这一流程最主要的目的就是注册 SEL
,我们注册真正发生的地方: __sel_registerName
,这个函数如果大家经常玩 Runtime
肯定不会陌生:
1 | static SEL __sel_registerName(const char *name, bool shouldLock, bool copy) |
我们简单分析一下 __sel_registerName
方法的流程:
- 判断是否要加锁
- 如果
sel
为空,则返回一个空的SEL
- 从
builtins
中搜索,看是否已经注册过,如果找到,直接返回结果 - 从
namedSelectors
哈希表中查询,找到了就返回结果 - 如果
namedSelectors
未初始化,则创建一下这个哈希表 - 如果上面的流程都没有找到,则需要调用
sel_alloc
来创建一下SEL
,然后把新创建的SEL
插入哈希表中进行缓存的填充
Fix up old objc_msgSend_fixup call sites 流程
1 | #if SUPPORT_FIXUP |
修正旧的 objc_msgSend_fixup
调用
这个流程的执行前提是 FIXUP
被开启。
- 还是老套路,遍历
EACH_HEADER
- 通过
_getObjc2MessageRefs
方法来获取当前遍历到的Mach-O
镜像的所有消息引用 - 然后遍历这些消息引用,然后调用
fixupMessageRef
进行修正
- 通过
Discover protocols 流程
1 | // Discover protocols. Fix up protocol refs. |
发现协议,并修正协议引用
Fix up @protocol references 流程
1 | // Fix up @protocol references |
对所有的协议做重映射
Realize non-lazy classes 流程
1 | // Realize non-lazy classes (for +load methods and static instances) |
初始化非懒加载类( +load
方法和静态实例)**
Realize newly-resolved future classes 流程
1 | // Realize newly-resolved future classes, in case CF manipulates them |
初始化新解析出来的 future
类
Discover categories 流程
1 | // Discover categories. Only do this after the initial category |
处理所有的分类,包括类和元类
到这里, _read_images
的流程就分析完毕,我们可以新建一个文件来去掉一些干扰的信息,只保留核心的逻辑,这样从宏观的角度来分析更直观:
Q & A 环节
Q: dyld
主要逻辑是加载库,也就是镜像文件,但是加载完是怎么读取的呢?
A: _read_images
是真正读取的地方
Q: SEL
方法编号何时加载?
A: _read_images
read_class 分析
我们探索了 _read_images
方法的流程,接下来让我们把目光放到本文的主题 - 类的加载
既然是类的加载,那么我们在前面所探索的类的结构中出现的内容都会一一重现。
所以我们不妨直接进行断点调试,让我们略过其它干扰信息,聚焦于类的加载。
- 根据上一小节我们探索的结果,
doneOnce
流程中会创建两个哈希表,并没有涉及到类的加载,所以我们跳过 - 我们来到第二个流程 - 类处理
我们在_read_images
函数,定位到下面这行代码:
1 | Class cls = (Class)classlist[i];//断点 |
可以看到 cls
的属性、方法、协议以及类名都为空,说明这里类并没有被真正加载完成,我们接着聚焦到 read_class
函数上面,我们进入其内部实现,我们大致浏览之后会定位到如下图所示的代码:
1 | /*********************************************************************** |
看起来类的信息在这里完成了加载,那么为了验证我们的猜想,直接断点调试一下但发现断点根本走不进来,原因在于这里的判断语句
1 | if (Class newCls = popFutureNamedClass(mangledName)) |
判断当前传入的类的类名是否有 future
类的实现,但是我们刚才已经打印了,类名是空的,所以肯定不会执行这里。我们接着往下走:
- addNamedClass 内部其实是将
cls
插入到gdb_objc_realized_classes
表 - addclassTableEntry 内部是将
cls
插入到allocatedClasses
表
分析完 read_class
,我们回到 _read_images
方法
我们可以看到 read_class
返回的 newCls
会进行一个判断,判断与传入 read_class
之前的 cls
是否相等,而在 read_class
内部只有一个地方对类的内容进行了改动,但是我们刚才测试了是进不去的,所以这个 if
里面的内容我们可以略过,也就是说 resolvedFutureClasses
的内容我们都可以暂时略过。
总结一下 readClass
:
- 判断是不是要后期处理的类
- 如果是的话,就取出后期处理的类,读取这个类的
data()
类设置ro/rw
- 如果是的话,就取出后期处理的类,读取这个类的
- addNamedClass 插入总表
- addClassTableEntry 插入已开辟内存的类的表
realizeClassWithoutSwift 分析
通过分析 read_class
,我们可以得知,类已经被注册到两个哈希表中去了,那么现在一切时机都已经成熟了。但是我们还是要略过像 Fix up remapped classes
、 Fix up @selector references
、 fix up old objc_msgSend_fixup call sites
、 Discover protocols. Fix up protocol refs
、 Fix up @protocol references
,因为我们的重点是类的加载,我们最终来到了 Realize non-lazy classes (for +load methods and static instances)
,略去无关信息之后,我们可以看到我们的
主角 realizeClassWithoutSwift
闪亮登场了:
1 | /*********************************************************************** |
从方法的名称以及方法注释我们可以知道, realizeClassWithoutSwift
是进行类的第一次初始化操作,包括分配读写数据也就是我们常说的 rw
,但是并不会进行任何的 Swift
端初始化。我们直接聚焦下面的代码:
1 | // Normal class. Allocate writeable class data. |
- 通过
zalloc
开辟内存空间,返回一个新的rw
- 把
cls
取出来的ro
赋值给这个rw
- 将
rw
设置到cls
身上
可以清楚地看到,此时 rw
还是为空,说明这里只是对 rw
进行了初始化,但是方法、属性、协议这些都没有被添加上。
我们接着往下走:
1 | // Realize superclass and metaclass, if they aren't already. |
这里可以看到父类和元类都会递归调用 realizeClassWithoutSwift
来初始化各自的 rw
。为什么在类的加载操作里面要去加载类和元类呢?回忆一下类的结构,答案很简单,要保证 superclass
和 isa
的完整性,也就是保证类的完整性,
1 | // Update superclass and metaclass in case of remapping |
上面的代码就是最好的证明,初始化完毕的父类和元类被赋值到了类的 superclass
和 isa
上面。
接着往下走可以看到,不光要把父类关联到类上面,还要让父类知道子类的存在。
最后一行代码是 methodizeClass(cls)
,注释显示的是 attach categories
,附加分类到类?我们进入其内部实现一探究竟。
在探索 methodizeClass
前,我们先总结一下 realizeClassWithoutSwift
:
- 读取
class
的data()
ro/rw
赋值- 父类和元类实现
- supercls = realizeClassWithoutSwift(remapClass(cls->superclass))
- metacls = realizeClassWithoutSwift(remapClass(cls->ISA()))
- 父类和元类归属关系
- cls->superclass = supercls
- cls->initClassIsa(metacls)
- 将当前类链接到其父类的子类列表 addSubclass(supercls, cls)
methodizeClass 分析
realizeClassWithoutSwift方法最后一行调用的是methodizeClass
方法
1 | /*********************************************************************** |
对类的方法列表、协议列表和属性列表进行修正
附加 category
到类上面来
我们直接往下面走:
1 | // Install methods and properties that the class implements itself. |
- 从
ro
中取出方法列表附加到rw
上
1 | property_list_t *proplist = ro->baseProperties; |
- 从
ro
中取出属性列表附加到rw
上
1 | protocol_list_t *protolist = ro->baseProtocols; |
- 从
ro
中取出协议列表附加到rw
上
1 | // Attach categories. |
- 从
cls
中取出未附加的分类进行附加操作
探索 load_images
我们接着探索 _dyld_objc_notify_register
的第二个参数 load_images
,这个函数指针是在什么时候调用的呢,同样的,我们接着在 dyld
源码中搜索对应的函数指针 sNotifyObjCInit
:
1 | for (std::vector<ImageLoader*>::iterator it=sAllImages.begin(); it != sAllImages.end(); it++) { |
可以看到,在 notifySingle
方法内部, sNotifyObjCInit
函数指针被调用了。根据我们上一篇文章探索 dyld
底层可以知道, _load_images
应该是对于每一个加载进来的 Mach-O
镜像都会递归调用一次。
我们来到 libObjc
源码中 load_images
的定义处:
1 | /*********************************************************************** |
处理由
dyld
映射的给定镜像中的+load
方法
- 判断是否有
load
方法,如果没有,直接返回 - 搜索
load
方法,具体实现通过prepare_load_methods
- 调用
load
方法,具体实现通过call_load_methods
prepare_load_methods 分析
从这个方法名称,我们猜测这里应该做的是 load
方法的一些预处理工作,让我们来到源码进行分析:
1 | void prepare_load_methods(const headerType *mhdr) |
首先通过
_getObjc2NonlazyClassList
获取所有已经加载进去的类列表然后通过
schedule_class_load
遍历这些类- 递归调用遍历父类的
load
方法,确保父类的load
方法顺序排在子类的前面 - 通过
add_class_to_loadable_list
, 把类的load
方法存在loadable_classes
里面
- 递归调用遍历父类的
完成
schedule_class_load
之后,通过_getObjc2NonlazyCategoryList
取出所有分类数据然后遍历这些分类
- 通过
realizeClassWithoutSwift
来防止类没有初始化,如果已经初始化了则不影响 - 通过
add_category_to_loadable_list
,加载分类中的load
方法到loadable_categories
里面
- 通过
call_load_methods 分析
通过名称我们可以知道 call_load_methods
应该就是 load
方法被调用的地方了。我们直接看源码:
1 | /*********************************************************************** |
call_load_methods
调用类和类别中所有未决的+load
方法
类里面+load
方法是父类优先调用的
而在父类的+load
之后才会调用分类的+load
方法
- 通过
objc_autoreleasePoolPush
压栈一个自动释放池 do-while
循环开始- 循环调用类的
+load
方法直到找不到为止 - 调用一次分类中的
+load
方法
- 循环调用类的
- 通过
objc_autoreleasePoolPop
出栈一个自动释放池
总结
至此, _objc_init
和 _dyld_objc_notify_register
我们就分析完了,我们对类的加载有了更细致的认知。 iOS
底层有时候探索起来确实很枯燥,但是如果能找到高效的方法以及明确自己的所探索的方向,会让自己从宏观上重新审视这门技术。是的,技术只是工具,我们不能被技术所绑架,我们要做到有的放矢的去探索,这样才能事半功倍。
iOS底层:应用加载流程 main()函数之前
App从点击图标就开启了生命周期,本文从App启动开始探索。
前导知识
Mach-O文件
Mach-O is a bunch of file types for different run time executables.Mach-O
是 iOS
系统不同运行时期可执行的文件的文件类型统称。
维基百科上关于 Mach-O
的描述:
Mach-O 是 Mach object 文件格式的缩写,它是一种用于记录可执行文件、对象代码、共享库、动态加载代码和内存转储的文件格式。作为 a.out 格式的替代品,Mach-O 提供了更好的扩展性,并提升了符号表中信息的访问速度。
大多数基于 Mach 内核的操作系统都使用 Mach-O。NeXTSTEP、OS X 和 iOS 是使用这种格式作为本地可执行文件、库和对象代码的例子。
Mach-O
有三种文件类型:Executable、
Dylib、
Bundle
ExecutableL类型
So the first executable, that’s the main binary in an app, it’s also the main binary in an app extension.
Executable
是app
的二进制主文件,同时也是app extension
的二进制主文件我们一般可以在
Xcode
项目中的Products
文件夹中找到它.Dylib
类型A dylib is a dynamic library, on other platforms meet, you may know those as DSOs or DLLs.
dylib
是动态库,在其他平台也叫DSO
或者DLL
。对于接触
iOS
开发比较早的同学,可能知道我们在Xcode 7
之前添加一些比如sqlite
的库的时候,其后缀名为dylib
,而Xcode 7
之后后缀名都改成了tbd
。这里引用 StackoverFlow 上的一篇回答。
So it appears that the .dylib file is the actual library of binary code that your project is using and is located in the /usr/lib/ directory on the user’s device. The .tbd file, on the other hand, is just a text file that is included in your project and serves as a link to the required .dylib binary. Since this text file is much smaller than the binary library, it makes the SDK’s download size smaller.
刚刚我们简单介绍了动态库,还有一种库是静态库,他们的区别是什么呢。
我们先看一下编译的过程:
当然,这个过程中间其实还设计到编译器前端的
词法分析
、语法分析
、语义分析
、优化
等流程,我们在后面探索LLVM
和Clang
的时候会详细介绍。回到刚才的话题,静态库和动态库的区别:
Static frameworks are linked at compile time. Dynamic frameworks are linked at runtime.
静态库和动态库都是编译好的二进制文件,只是用法不同。那为什么要分动态和静态库呢?
通过上面两幅图我们可以知道:
- 静态库表现为:在链接阶段会将汇编生成的目标文件和引用的库一起链接打包到可执行文件中。
- 动态库的表现为:程序编译并不会链接到目标代码中,在程序可执行文件里面会保留对动态库的引用,其中动态库分为动态链接库和动态加载库。
- 动态链接库:在没有被加载到内存的前提下,当可执行文件被加载,动态库也随着被加载到内存中。在
Linked Framework and Libraries
设置的一些share libraries
。【随着程序启动而启动】 - 动态加载库:当需要的时候再使用
dlopen
等通过代码或者命令的方式来加载。【在程序启动之后】
- 动态链接库:在没有被加载到内存的前提下,当可执行文件被加载,动态库也随着被加载到内存中。在
Bundle
类型Now a bundle’s a special kind of dylib that you cannot link against, all you can do is load it at run time by an dlopen and that’s used on a Mac OS for plug-ins.
现阶段 Bundle 是一种特殊类型的 dylib,你是无法对其进行链接的。你所能做的是在 Runtime 运行时去通过 dlopen 来加载它,它可以在 macOS 上用于插件。Image
和Framework
Image refers to any of these three types.
镜像文件包含了上述的三种文件类型a framework is a dylib with a special directory structure around it to holds files needed by that dylib.
有很多东西都叫做 Framework,但在本文中,Framework 指的是一个 dylib,它周围有一个特殊的目录结构来保存该 dylib 所需的文件。
Mach-O结构分析
segment
段
Mach-O
镜像文件是由segments
段组成的。
段的名称为大写格式
所有的段都是
page size
的倍数。arm64 上段大小为
16
字节其它架构为
4
字节
这里再提及一下虚拟内存和内存页的知识:
具有 VM 机制的操作系统,会对每个运行的进程创建一个逻辑地址空间 logical address space 或者叫虚拟地址空间 virtual address space;该空间的大小由操作系统位数决定:32 位的操作系统,其逻辑地址空间的大小为 4GB,64位的操作系统为 2^34 GB(其计算方式是 2^32 || 2^64(理论上是64位,x86 Intel 是48位))。
虚拟地址空间(或者逻辑地址空间)会被分为相同大小的块,这些块被称为内存页(page)。计算机处理器和它的内存管理单元(MMU - memory management uinit)维护着一张将程序的逻辑地址空间映射到物理地址上的分页表
page table。
在
masOS和早版本的
iOS中,分页的大小为
4kB。在之后的基于
A7和
A8的系统中,虚拟内存(
64位的地址空间)地址空间的分页大小变为了
16KB,而物理RAM上的内存分页大小仍然维持在
4KB;基于A9及之后的系统,虚拟内存和物理内存的分页都是
16KB。
section
在 segment 段内部还有许多的 section 区。section 名称为小写格式。
But sections are really just a subrange of a segment, they don’t have any of the constraints of being page size, but they are non-overlapping.
但是 sections 节实际上只是一个 segment 段的子范围,它们没有页面大小的任何限制,但是它们是不重叠的。
通过MachOView
工具查看App的二进制可执行文件可以看到:
常见的segments
_TEXT
:代码段,包括头文件、代码和常量。只读不可修改_DATA
:数据段,包括全局变量,静态变量等。可读可写。_LINKEDIT
:如何加载程序的元数据, 包含了方法和变量的元数据(位置,偏移量),以及代码签名等信息。只读不可修改。
Mach-O Universal Files
Mach-O
通用文件,将多种架构的 Mach-O
文件合并而成。它通过 header
来记录不同架构在文件中的偏移量,segement
占多个分页,header
占一页的空间。可能有人会觉得 header
单独占一页会浪费空间,但这有利于虚拟内存的实现。
虚拟内存
虚拟内存是一层间接寻址。
虚拟内存解决的是管理所有进程使用物理 RAM 的问题。通过添加间接层来让每个进程使用逻辑地址空间,它可以映射到 RAM 上的某个物理页上。这种映射不是一对一的,逻辑地址可能映射不到 RAM 上,也可能有多个逻辑地址映射到同一个物理 RAM 上。
- 针对第一种情况,当进程要存储逻辑地址内容时会触发 page fault。
- 而第二种情况就是多进程共享内存。
- 对于文件可以不用一次性读入整个文件,可以使用分页映射 mmap() 的方式读取。也就是把文件某个片段映射到进程逻辑内存的某个页上。当某个想要读取的页没有在内存中,就会触发 page fault,内核只会读入那一页,实现文件的懒加载。也就是说 Mach-O 文件中的 __TEXT 段可以映射到多个进程,并可以懒加载,且进程之间共享内存。
- __DATA 段是可读写的。这里使用到了 Copy-On-Write 技术,简称 COW。也就是多个进程共享一页内存空间时,一旦有进程要做写操作,它会先将这页内存内容复制一份出来,然后重新映射逻辑地址到新的 RAM 页上。也就是这个进程自己拥有了那页内存的拷贝。这就涉及到了 clean/dirty page 的概念。dirty page 含有进程自己的信息,而 clean page 可以被内核重新生成(重新读磁盘)。所以 dirty page 的代价大于 clean page。
多进程加载Mach-O
镜像
- 所以在多个进程加载 Mach-O 镜像时 __TEXT 和 __LINKEDIT 因为只读,都是可以共享内存的,读取速度就会很快。
- 而 __DATA 因为可读写,就有可能会产生 dirty page,如果检测到有 clean page 就可以直接使用,反之就需要重新读取 DATA page。一旦产生了 dirty page,当 dyld 执行结束后,__LINKEDIT 需要通知内核当前页面不再需要了,当别人需要的使用时候就可以重新 clean 这些页面。
ASLR
ASLR
(Address Space Layout Randomization) 地址空间布局随机化,镜像会在随机的地址上加载。
Code Signing
可能我们认为 Xcode
会把整个文件都做加密 hash
并用做数字签名。其实为了在运行时验证 Mach-O
文件的签名,并不是每次重复读入整个文件,而是把每页内容都生成一个单独的加密散列值,并存储在 __LINKEDIT
中。这使得文件每页的内容都能及时被校验确并保不被篡改。
exec()
Exec is a system call. When you trap into the kernel, you basically say I want to replace this process with this new program.
exec() 是一个系统调用。系统内核把应用映射到新的地址空间,且每次起始位置都是随机的(因为使用 ASLR)。并将起始位置到 0x000000 这段范围的进程权限都标记为不可读写不可执行。如果是 32 位进程,这个范围至少是 4KB;对于 64 位进程则至少是 4GB 。NULL 指针引用和指针截断误差都是会被它捕获。这个范围也叫做 PAGEZERO。
dyld
Unix 的前二十年很安逸,因为那时还没有发明动态链接库。有了动态链接库后,一个用于加载链接库的帮助程序被创建。在苹果的平台里是 dyld,其他 Unix 系统也有 ld.so。 当内核完成映射进程的工作后会将名字为 dyld 的 Mach-O 文件映射到进程中的随机地址,它将 PC 寄存器设为 dyld 的地址并运行。dyld 在应用进程中运行的工作是加载应用依赖的所有动态链接库,准备好运行所需的一切,它拥有的权限跟应用一样。
dyld
流程
Load dylibs
从主执行文件的 header 获取到需要加载的所依赖动态库列表,而 header 早就被内核映射过。然后它需要找到每个 dylib,然后打开文件读取文件起始位置,确保它是 Mach-O 文件。接着会找到代码签名并将其注册到内核。然后在 dylib 文件的每个 segment 上调用 mmap()。应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以 dyld 所需要加载的是动态库列表一个递归依赖的集合。一般应用会加载 100 到 400 个 dylib 文件,但大部分都是系统 dylib,它们会被预先计算和缓存起来,加载速度很快。
Fix-ups
在加载所有的动态链接库之后,它们只是处在相互独立的状态,需要将它们绑定起来,这就是 Fix-ups。代码签名使得我们不能修改指令,那样就不能让一个 dylib 的调用另一个 dylib。这时需要加很多间接层。 现代 code-gen 被叫做动态 PIC(Position Independent Code),意味着代码可以被加载到间接的地址上。当调用发生时,code-gen 实际上会在 __DATA 段中创建一个指向被调用者的指针,然后加载指针并跳转过去。所以 dyld 做的事情就是修正(fix-up)指针和数据。Fix-up 有两种类型,rebasing 和 binding。
Rebasing 和 Binding
Rebasing:在镜像内部调整指针的指向 Binding:将指针指向镜像外部的内容
dyld
的时间线由上图可知为:
Load dylibs -> Rebase -> Bind -> ObjC -> Initializers
dyld2 && dyld3
在 iOS 13
之前,所有的第三方 App
都是通过 dyld 2
来启动 App
的,主要过程如下:
- 解析
Mach-O
的Header
和Load Commands
,找到其依赖的库,并递归找到所有依赖的库 - 加载
Mach-O
文件 - 进行符号查找
- 绑定和变基
- 运行初始化程序
dyld3 被分为了三个组件:
- 一个进程外的 MachO 解析器
- 预先处理了所有可能影响启动速度的 search path、@rpaths 和环境变量
- 然后分析 Mach-O 的 Header 和依赖,并完成了所有符号查找的工作
- 最后将这些结果创建成了一个启动闭包
- 这是一个普通的 daemon 进程,可以使用通常的测试架构
- 一个进程内的引擎,用来运行启动闭包
- 这部分在进程中处理
- 验证启动闭包的安全性,然后映射到 dylib 之中,再跳转到 main 函数
- 不需要解析 Mach-O 的 Header 和依赖,也不需要符号查找。
- 一个启动闭包缓存服务
- 系统 App 的启动闭包被构建在一个 Shared Cache 中, 我们甚至不需要打开一个单独的文件
- 对于第三方的 App,我们会在 App 安装或者升级的时候构建这个启动闭包。
- 在 iOS、tvOS、watchOS中,这这一切都是 App 启动之前完成的。在 macOS 上,由于有 Side Load App,进程内引擎会在首次启动的时候启动一个 daemon 进程,之后就可以使用启动闭包启动了。
dyld 3 把很多耗时的查找、计算和 I/O 的事前都预先处理好了,这使得启动速度有了很大的提升。
好了,先导知识就总结到这里,接下来让我们调整呼吸进入下一章~
App加载分析
我们在探索 iOS
底层的时候,对于对象、类、方法有了一定的认知哦,接下来我们就一起来探索一下应用是怎么加载的。
我们直接新建一个 Single View App
的项目,然后在 main.m
中打一个断点:
可以看到堆栈信息为:
可以看到堆栈栈底是_dyld_start
,说明App加载是从_dyld_start
开始的
_dyld_start
我们在源码里全局搜索_dyld_start。源码下载地址,我这里下载的版本是832.7.3,我们可以来到 dyldStartup.s
这个汇编文件,然后我们聚焦于 arm64
架构下的汇编代码:
1 | #if __arm64__ && !TARGET_OS_SIMULATOR |
对于这里的汇编代码,我们肯定也没必要逐行分析,我们直接定位到 bl
语句后面(bl
在汇编层面是跳转的意思):
1 | // call dyldbootstrap::start(app_mh, argc, argv, dyld_mh, &startGlue) |
看注释可以知道,调用位于 dyldbootstrap 命名空间下的 start 方法,我们继续搜索一下这个 start 方法,结果位于 dyldInitialization.cpp 文件(从文件名我们可以看出该文件主要是用来初始化 dyld),这里查找 start 的时候可能会有很多结果,我们其实可以先搜索命名空间,再搜索 start 方法。
1 | // |
start
方法主要做了很多dyld
的初始化
工作:
rebaseDyld
dyld重定位
__guard_setup
栈溢出保护
在结束dyld初始化
工作后,函数调用 dyld::_main()
函数,再将返回值传递给__dyld_start
去调用真正的main
函数。
dyld::_main
1 | // |
_main方法
官方的注释如下:
dyld
的入口。内核加载了dyld
然后跳转到__dyld_start
来设置一些寄存器的值然后调用到了这个方法。
返回__dyld_start
所跳转到的目标程序的main
函数地址。
dyld
流程可总结为九个步骤:
- 第一步:
设置运行环境
- 第二步:
加载共享缓存
- 第三步:
实例化主程序
- 第四步:
加载插入的动态库
- 第五步:
链接主程序
- 第六步:
链接插入的动态库
- 第七步:
执行弱引用绑定
- 第八步:
执行初始化方法
- 第九步:
查找程序入口main然后返回
第一步:设置运行环境
这一步主要是设置程序的运行环境
,运行条件
等准备工作,包括环境
,平台
,版本
,路径
,主机
信息,设置程序上下文信息
等
1 | // 获取主程序的hash |
第二步:加载共享缓存
首先检查dyld
共享缓存区是否禁用
,iOS
必须开启
,在checkSharedRegionDisable
里面iOS
环境下注释:
// iOS cannot run without shared region
1 | // 检查缓存共享区域是否开启 |
mapSharedCache
函数主要调用loadDyldCache
函数,loadDyldCache
函数主要有三种方式加载共享缓存:
mapCachePrivate()
仅加载到当前进程- 共享缓存已经加载过,不做任何处理
mapCacheSystemWide()
未加载过,首次加载
第三步:实例化主程序
这一步主要是将主程序的Mach-O
加载进内存,并实例化一个ImageLoader
。instantiateFromLoadedImage()
首先调用isCompatibleMachO()
函数检测当前进程的magic
、cputype
、cpusubtype
等相关属性,判断Mach-O
文件的兼容性
,如果兼容性满足,就调用ImageLoaderMachO::instantiateMainExecutable()
实例化主程序的ImageLoader
:
1 | // 第三步:实例化主程序 |
1 | // The kernel maps in main executable before dyld gets control. We need to |
1 | // create image for main executable |
instantiateMainExecutable()
函数中通过sniffLoadCommands()
判断这个mach-o
文件是普通的
还是压缩
的LINKEDIT,以及它有多少段。
最后根据compressed
是否压缩来实例化最后返回的ImageLoader
。
第四步:加载插入的动态库
1 | // 第四步:加载插入的动态库 |
loadInsertedDylib()
函数中设置了一个LoadContext
,并为它配置一些参数后,调用load()
方法:
1 | ImageLoader* load(const char* path, const LoadContext& context, unsigned& cacheIndex) |
第五步:链接主程序
1 | // 第五步:链接主程序 Executable:可执行的意思 |
调用link()
函数将实例化后的主程序进行动态修正
,让二进制变为可正常执行的状态。link()
函数内部调用了ImgaeLoader::link()
函数,主要做了下面几件事:
- this->
recursiveLoadLibraries()
递归加载依赖库进内存 - this->
recursiveUpdateDepth()
递归更新依赖库的路径 - this->
recursiveRebaseWithAccounting()
递归重定位主程序和依赖库 - this->
recursiveBindWithAccounting()
递归将主程序和依赖库执行符号表绑定(链接动态库使用) - this->
weakBind()
如果不是正在链接主程序二进制,那就主程序弱符号绑定(链接动态库使用) - this->
recursiveApplyInterposing()
递归申请可插入依赖库权限 - this->
recursiveMakeDataReadOnly()
递归设置所有信息只读(链接动态库使用) - this->
recursiveGetDOFSections()
注册DOF节
代码如下所示:
1 | void ImageLoader::link(const LinkContext& context, bool forceLazysBound, bool preflightOnly, bool neverUnload, const RPathChain& loaderRPaths, const char* imagePath) |
第六步:链接插入的动态库
这一步跟链接主程序一样,将sAllImages
中的ImageLoader
遍历出来,然后调用link()
进行链接,需要注意的是,sAllImages
中保存的第一个是主程序
的镜像,所以要获取所有的动态库的ImageLoader
,就必须从i + 1
开始遍历:
1 | for(unsigned int i=0; i < sInsertedDylibCount; ++i) { |
第七步:执行主程序弱符号绑定
weakBind()
首先通过getCoalescedImages()
合并所有动态库的弱符号
到一个列表里,然后调用initializeCoalIterator()
对需要绑定的弱符号进行排序,接着调用incrementCoalIterator()
读取dyld_info_command
结构的weak_bind_off
和weak_bind_size
字段,确定弱符号的数据偏移
与大小,最终进行弱符号绑定
第八步:执行初始化方法
这一步就开始进行初始化工作了,initializeMainExecutable()
函数中调用 runInitializers()
函数,接着依次调用 processInitializers()
函数进行一些初始化准备工作,接着recursiveInitialization()
函数调用进行初始化工作,接着全局搜索recursiveInitialization(
,找到ImageLoader.cpp
中的此方法定义,看重点,函数里面我们看到noffitySingle()单个通知注入
:
1 | void ImageLoader::recursiveInitialization(const LinkContext& context, mach_port_t this_thread, const char* pathToInitialize, |
探索 _objc_init
1 | void _objc_init(void) |
通过LLDB来断掉调试_objc_init
,然后通过bt
命令打印出当前的调用堆栈,根据上一节我们探索dyld
源码,感觉很清晰:
1 | (lldb) bt |
我们可以看到 dyld
的最后一个流程是 doModInitFunctions
方法的执行。
我们打开 libSystem
的源码,全局搜索 libSystem_initializer
可以看到:
1 |
|
然后我们打开 libDispatch
的源码,全局搜索 libdispatch_init
可以看到:
1 | void |
我们再搜索_os_object_init
1 | void |
完美~,_objc_init
在这里就被调用了。所以 _objc_init
的流程是
dyld -> libSystem -> libDispatch -> libObjc -> _objc_init
总结
本文主要探索了 app 启动之后 dyld 的流程,整个分析过程确实比较复杂,但在探索的过程中,我们不仅对底层源码有了新的认知,同时对于优化我们 app 启动也是有很多好处的。下一章,我们会对 objc_init 内部的 map_images 和 load_images 进行更深入的分析。
iOS底层:static_init、gdb_objc_realized_classes&remapped_clasass_map&future_named_class_map
static_init
1 | /*********************************************************************** |
这里会运行 C++
的静态构造函数,在 dyld
调用我们的静态构造函数之前,libc
会调用 _objc_init
,所以这里我们必须自己来做,并且这里只会初始化系统内置的 C++
静态构造函数,我们自己代码里面写的并不会在这里初始化。
通过其注释,我们大概知道static_init函数的作用是运行C++的静态构造函数。其原因在于dyld调用我们的静态构造函数晚于libc调用_objc_init函数。
继续深入讲解static_init
方法,可以看出,getLibobjcInitializers
方法是它的实现主体,点击进入可以看到如下实现:
1 | GETSECT(getLibobjcInitializers, UnsignedInitializer, "__objc_init_func"); |
这就能理解我们文章开头提出的static_init()
方法的含义了。其实就是找出__objc_init_func
区的数据,获取了Initializer
指针,然后按顺序调用。
由于全局变量都在 mod_init_func
这个区中,那这就给我们优化 App 启动提供了一个思路:我们可以通过 hook 这个区中所有的函数为自己的函数,并在我们的函数中添加时间节点来计时,从而了解在 main 函数之前的耗时情况,这也可以为我们 APP 提供优化借鉴。
其想法很简单,就是在 load 方法中 hook __mod_init_func
的方法。部分源代码如下:
1 | + (void)load{ |
以及
1 | static void hookModInitFunc(){ |
最后是展示方法以及消耗时长:
1 | void myInitFunc_Initializer(int argc, const char* argv[], const char* envp[], const char* apple[], const struct MyProgramVars* vars){ |
gdb_objc_realized_classes
gdb_objc_realized_classes 的作用已经很明显了,即是对所有的类进行缓存:从对应的 section 中读取所有的类,取出来后以 mangledName 作为键,以 class 结构体作为值。
remapped_class_map
1 | /*********************************************************************** |
remap class,字面意思是 重新映射 class,那肯定有一个映射者和映射结果。map 的键是 cls,也就是 section 中拿到的 cls,而 value 就是我们 remap 的结果
1 | Class readClass(Class cls, bool headerIsBundle, bool headerIsPreoptimized) |
通过这个函数我们发现,有两个分支有机会进入方法 addRemappedClass,一个是 missingWeakSuperclass 方法是否为真,另外一个是 popFutureNamedClass(mangledName) 方法返回是否为真。这里会有两个概念
- WeakSuperclass
- FutureNamedClass
而这两个条件为true 的情况就是需要 remap 的情况。
missingWeakSuperclass
1 | /*********************************************************************** |
代码的注释已经一目了然:该函数就是判断某个 class 的父类是否是 weak-linked 并且缺失了。那 什么叫做 weak-linked?比如 NSObject 是否是 weak-linbked ?很显然不是的。那么什么是 weak-linked?
weak-linked
我们开发的时候,都会使用最新的SDK,但是为了让老的设备可以下载并运行我们的应用,就要将Deployment Target设置成之前系统的版本号。例如我们应用使用iOS 8.1的SDK,Deployment Target设置成iOS 5.1.1,虽然我们开发的时候使用的是8.1的SDK,但是程序运行在的设备中却可能是6.0 or 7.0的SDK上,按照苹果的说法,如果我们应用使用了最新SDK引入的特性,比如符号、函数等,那么在版本较旧的设备上就运行不了。下面是苹果官方文档的一段话:
Normally, if an application uses a new feature in a framework, it is unable to run on earlier versions of the framework that do not support that feature. Such applications would either fail to launch or crash when an attempt to use the feature was made.
那么为什么我们使用最新的SDK开发的应用却可以运行在旧的系统中呢?答案是使用了弱引用。资料里面说过,我们自己创建的framework,如果需要做版本兼容,那么就要对今后加入的符号等使用弱引用,使用了弱引用之后,即使在版本较旧的环境下跑,也可以运行,只是相应的符号是NULL,下面就是教我们怎样定义弱引用。有一点需要说明的是,如果一个framework没有为新加入的符号加入弱引用,那也不必担心,我们只要在链接时弱引用整个framework就好,方法就是链接的时候使用 -weak_framework frameworkName
一个使用 weak 的实例为:
1 | extern int MyWeakLinkedFunction() __attribute__((weak_import)); |
以上代码片段来自苹果官网。
这么一来, missingWeakSuperclass 的作用的作用就不言而喻了:查看 cls 类的祖宗类中是否有类是 weak-linked 的,并且已经 missing,祖宗类里有 missing weak-linked 的,则 cls 的所有信息也是不可信的,所以将其添加到重映射表里,映射为nil,即 cls -> nil。
future_named_class_map
1 | /*********************************************************************** |
上面代码是创建代码,下面我们看一下使用
1 | /** |
看注释有个警告,不要主动调用这个函数。下面是具体的实现:
1 | /*********************************************************************** |
其中函数 look_up_class 这里先不多做介绍了,里面逻辑较多,而且不是这个函数的重点,重点是最后一句:_objc_allocateFutureClass(name)
它才是从 hash map 中获取对应值的函数,其实现如下 :
1 | /*********************************************************************** |
很容易理解:有的话就通过方法 NXMapGet 取出来,没有的话则创建。
我们再看一下一出代码
1 | /*********************************************************************** |
这个方法有点熟悉了,正是上文介绍的 remap 的条件之一。上一篇文章讲述的是第一个条件,本文讲的是第二个条件。这两个条件无论哪一个符合都会调用方法:addRemappedClass,即向 remapped_class_map 中插入数据
总结
future named class 的介绍就到这里了,希望大家有所收获。其实笔者看来,不管是上一篇文章的 remapped_class_map 还是本文的 future_named_class_map 里面的数据都是空。remapped_class_map 有数据是需要一定条件,而 future_named_class_map 有数据也是需要先 add 的,所以大家对于这两个 map 只需要有个大概的概念就好,后面如果真的碰到他们有数据,我们在详细分析。
iOS底层:懒加载类与非懒加载类 realizeClass、methodizeClass
懒加载类其实就是指类的加载在第一次消息发送之前,但是如果我们在类中实现了+load方法,那么类的加载就会提前到pre-main之前,提前加载的类就称之为非懒加载类。
类的加载
realizeClassWithoutSwift我们先看其源码实现,因为我们这里主要探究的是加载,其中加载具体做的事情不做过多说明,把部分源码进行了省略。
1 | static Class realizeClassWithoutSwift(Class cls, Class previously) |
methodizeClass
关注到methodizeClass(cls,previously)
方法。
1 | static void methodizeClass(Class cls, Class previously) |
由此可得出以下结论:
- 虽然实现类调用的
methodizeClass
->attachToClass
后没调用attachCategories
方法,但是load_images
->loadAllCategories
后会调用attachCategories
方法。 attachCategories
方法中进行了rwe
初始化。
根据方法的注释我们可以解读出以下信息:
- 对类cls执行首次初始化
- 包括分配读写数据。
- 不执行任何Swift侧初始化。
- 返回类的实际类结构。
- 锁定:runtimeLock必须由调用者写锁
对方法的实现的一些概念进行解读
ro:干净内存(Clean Memory),存放的是类的原始数据
rw:脏内存(Dirty Memory) ,运行时会对类内存进行动态的修改所以才有rw,rw最初是从ro中读取的数据。
rwe:新增内容,运行时动态修改类才会生成rwe,rwe的原始数据是从rw中读取的。
supercls = realizeClassWithoutSwift(remapClass(cls->superclass), nil);沿着继承链递归调用realizeClassWithoutSwift。
metacls = realizeClassWithoutSwift(remapClass(cls->ISA()), nil);沿着isa走位递归调用realizeClassWithoutSwift。
所以如果一个类加载了,其继承链上的父类、isa对应的元类等都会加载。
懒加载类
我们实现了+load方法类的加载就会提前,+load是如何影响类的加载的时机的呢?
load_images源码中有说明在dyld映射的镜像中处理+load,我们需要去看看load_images中是如何处理+load方法的。
1 | void |
hasLoadMethods:
判断的条件有两个:
- _getObjc2NonlazyClassList:获取所有类中的Load方法数量
- _getObjc2NonlazyCategoryList:获取所有分类中的Load方法数量
1 | // Quick scan for +load methods that doesn't take a lock. |
如果存在+load
方法,后面加锁后开始进入prepare_load_methods
函数,它的作用是发现所有的+load
方法。
1 | void prepare_load_methods(const headerType *mhdr) |
prepare_load_methods中分为两部分:
1.获取非懒加载类列表,猜测这里应该已经加载了对应的类,循环遍历加载非懒加载类的load方法到loadable_classes.其中关键的方法schedule_class_load、add_class_to_loadable_list。
2.获取非懒加载分类列表,循环遍历去加载非懒加载分类的 load 方法到 loadable_categories。
其中关键的方法add_category_to_loadable_list。
非懒加载分类遍历时,有一个处理realizeClassWithoutSwift(cls, nil),在遍历加载非懒加载类的load方法时,会调用realizeClassWithoutSwift,如果分类对应的类没有记载,在这里就会被加载。
懒加载类
对于懒加载类,是在第一次消息发送objc_msgSend
,调用lookUpImpOrForward
1 | NEVER_INLINE |
1 | static Class |
我们可以清晰的看到lookUpImpOrForward中也会调用到realizeClassWithoutSwift,对类进行加载。
总结
懒加载类情况 类加载延迟到第一次消息发送。
lookUpImOrForward
realizeClassMaybeSwiftMaybeRelock
relizeClassWithoutSwift
methodizeClass
非懒记载类调用了+load方法,类就会提前加载。
getObjc2NonlazyClassList
readClass
realizeClassWithoutSwift
methodizeClass
iOS底层:cache_t
前文我们探索了iOS类的底层原理,简单了解了四个重要的属性。这篇文章主要探索第三个属性cache_t
,对于这个属性,我们可以学习到苹果对于缓存的设计和理解。
探索cache_t
从数据结构开始
cache_t
的基本结构
我们看一下源码:
1 | struct cache_t { |
发现有一个buckets()
成员,它是一个结构体bucket_t
1 | struct bucket_t { |
从源码定义中不难看出,bucket_t
其实缓存的是方法实现 IMP
。这里有一个注意点,就是 IMP-first
和 SEL-first
。
IMP-first is better for arm64e ptrauth and no worse for arm64.
- IMP-first 对 arm64e 的效果更好,对 arm64 不会有坏的影响。
SEL-first is better for armv7* and i386 and x86_64.
- SEL-first 适用于 armv7 * 和 i386 和 x86_64。
通过上面的源码,我们大致了解了 bucket_t 类型的结构,那么现在问题来了,类中的 cache 是在什么时候以什么样的方式来进行缓存的呢?我们使用LLDB查看一下:
1 | lldb) x pClass |
_occupied
应该是表示当前已经占用了多少缓存(每调用一个实例方法会+1)。下面验证一下
1 | int main(int argc, const char * argv[]) { |
1 | (lldb) x pClass |
可以看到_occupied = 2
验证了我们的猜想,我们继续走断点发现:
1 | int main(int argc, const char * argv[]) { |
我们发现_occupied=1
,_maybeMask
的Value=7
(上一步=3),这是为什么
如果读者了解并掌握散列表这种数据结构的话,相信已经看出端倪了。是的,这里其实就是用到了 开放寻址法 来解决散列冲突(哈希冲突)。
关于哈希冲突,可以借助鸽笼理论,即把 11 只鸽子放进 10 个抽屉里面,肯定会有一个抽屉里面有 2 只鸽子。是不是理解起来很简单?
通过上面的测试,我们明确了方法缓存使用的是哈希表存储,并且为了解决无法避免的哈希冲突使用的是开放寻址法,而开放寻址法必然要在合适的时机进行扩容,这个时机肯定不是会在数据已经装满的时候,我们可以进源码探索一下,我们快速定位到 cache_t
的源码处:
1 | void cache_t::insert(SEL sel, IMP imp, id receiver) |
1 | #if CACHE_END_MARKER || (__arm64__ && !__LP64__) |
看上面代码可以看出,换出的初始大小为4(metaclasses这种有很少imps为2),最后调用reallocate
函数
1 | void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity, bool freeOld) |
可以看到_bucketsAndMaybeMask里的newBuckets
为newCapacity - 1
,我们看capacity()
方法也能验证这一点
1 | unsigned cache_t::capacity() const |
继续探索cache_t
通过前面的探索,我们知道了 cache_t
实质上是缓存了我们类的实例方法,那么对于类方法来说,自然就是缓存在了元类上了。这一点我相信读者应该都能理解。
方法缓存策略
直观的感受就是会在insert的时候缓存,我们继续看insert
方法
1 | void cache_t::insert(SEL sel, IMP imp, id receiver) |
方法缓存是否有序
方法缓存是无序的,这是因为计算缓存下标是一个哈希算法:
1 | static inline mask_t cache_hash(cache_key_t key, mask_t mask) |
通过 cache_hash
之后计算出来的下标并不是有序的,下标值取决于 key
和 mask
的值。
bucket 与 mask, capacity, sel, imp 的关系
一个类有一个属性 cache_t
,而一个 cache_t
的 buckets
会有多个 bucket
。一个 bucket
存储的是 imp
和 cache_key_t
。
mask
的值对于 bucket
来说,主要是用来在缓存查找时的哈希算法。
而 capacity
则可以获取到 cache_t
中 bucket
的数量。
sel
在缓存的时候是被强转成了 cache_key_t
的形式,更方便查询使用。imp
则是函数指针,也就是方法的具体实现,缓存的主要目的就是通过一系列策略让编译器更快的执行消息发送的逻辑。
总结
OC
中实例方法缓存在类上面,类方法缓存在元类上面。cache_t
缓存会提前进行扩容防止溢出。- 方法缓存是为了最大化的提高程序的执行效率。
- 苹果在方法缓存这里用的是
开放寻址法
来解决哈希冲突。 - 通过
cache_t
我们可以进一步延伸去探究objc_msgSend
,因为查找方法缓存是属于objc_msgSend
查找方法实现的快速流程。
iOS底层:类
class的本质
类是我们面向对象开发中使用很频繁的概念,我们看一下类的结构是什么。
首先我们定义一个类Person
1 | NS_ASSUME_NONNULL_BEGIN |
我们在main函数里初始化Person
对象
1 | int main(int argc, const char * argv[]) { |
在terminal里执行clang
命令
clang -rewrite-objc main.m -o main.cpp
会生成main.cpp文件,在main.cpp文件里搜索Person找到下面这段代码:
1 | #ifndef _REWRITER_typedef_Person |
我们主要看代码注释1、2、3的位置,全局搜索typedef struct objc_object
,发现有845个结果,结果数有点多,我们再全局搜索*Class
,这次比较幸运,只有7个结果,我们可以快速定位到其中的一行
typedef struct objc_class *Class;
看到这里我们就知道了Class
类型的实际是一个叫objc_class
的结构体。我们去objc源码里搜索struct objc_class
1 | struct objc_class : objc_object { |
看源码可以知道objc_class
继承自objc_object
,说明Class其实也是一个对象。
类的结构
看上面这段源码,我们知道类的本质也是对象,我们日常开发中常见的成员变量、属性、方法、协议等都在类里面存在的,我们接下来探索一下这些内容在雷利是怎么存储的。
从上一小节最后的源码我们看到,类中的四个属性:
isa
指针superclass
指针cache
bits
isa
指针是隐藏属性(继承自objc_object
)。
isa
指针
我们之前已经探索过,在对象初始化的时候,通过isa
可以让对象和类关联,类中isa
的作用也类似,关联类和元类。
superclass
指针
这个看名字就很好理解,指向了父类,一般来说,类的根父类都是NSObject
类。根元类的父类也是NSObject
类。
cache
cache_t
里的东西比较多,我们暂时先了解这个是缓存,里面会有方法的缓存。具体内容后面再探究。
bits
属性
bits
的数据结构类型是 class_data_bits_t
,同时也是一个结构体类型。而我们阅读 objc_class
源码的时候,会发现很多地方都有 bits
的身影,比如:
1 | class_rw_t *data() { |
这里值得我们注意的是,objc_class
的 data()
方法其实是返回的 bits
的 data()
方法,而通过这个 data()
方法,我们发现诸如类的字节对齐、ARC
、元类等特性都有 data()
的出现,这间接说明 bits
属性其实是个大容器,有关于内存管理、C++ 析构等内容在其中有定义。
这里我们会遇到一个十分重要的知识点: class_rw_t
,data()
方法的返回值就是 class_rw_t
类型的指针对象。我们在本文后面会重点介绍。
探索 bits
属性
bits
属性的结构体是class_data_bits_t
,看一下它的源码,class_data_bits_t
声明了objc_class
是它的友元类,可见关系不一般。
1 | struct class_data_bits_t { |
我们主要看下面两块代码。
1 | class_rw_t* data() const { |
先看class_rw_t
1 | struct class_rw_t { |
这个结构体里有一些我们熟悉的字样methods
、properties
、protocols
。这里的properties
是不是就是存储属性的地方呢。让我们验证一下。
1 | #import <Foundation/Foundation.h> |
我们定义一个Person类,在里面定义一个成员变量和一个属性。在main函数里打断点,我们用LLDB打印看一下pClass
。
1 | (lldb) x/4gx pClass |
可以看最后$18
里面并没有打印出属性列表。我们接着探索class_ro_t
1 | struct class_ro_t { |
发现class_ro_t
接口中包含了baseMethodList
、baseProtocols
、baseProperties
等属性。验证一下属性是不是存储在这里了。和上面方式类似,我们利用LLDB查看
1 | p (class_data_bits_t *)0x1000081f0 |
可以看到了nickName属性,这里的count是1,说明只有这一个属性。我们接下来看一下ivars
属性,继续使用LLDB
1 | (lldb) p $20.ivars |
可以看到ivars属性里一共有两个值,_nickName和hobby。这一结果证实了编译器会帮助我们给属性 nickName
生成一个带下划线前缀的实例变量 _nickName
。
至此,我们可以得出以下结论:
class_ro_t
是在编译时就已经确定了的,存储的是类的成员变量、属性、方法和协议等内容。class_rw_t
是可以在运行时来拓展类的一些属性、方法和协议等内容。
类的方法存储在哪
我们用相同的方法验证一下,先在Person类中增加一个sayHello
的实例方法和一个 sayHappy
的类方法。
1 | NS_ASSUME_NONNULL_BEGIN |
按照上面的思路,我们直接读取 class_ro_t
中的 baseMethods()
的内容:
1 | (lldb) p $3.baseMethods() |
可以看到baseMethods有四个方法。
类的类方法存储在哪
我们用Runtime的API来实际测试一下
1 | // main.m |
运行后打印结果:
1 | 0x100008110-0x0-0x0-0x1000080a8 |
首先 testInstanceMethod_classToMetaclass
方法测试的是分别从类和元类去获取实例方法、类方法的结果。由打印结果我们可以知道:
- 对于类对象来说,
sayHello
是实例方法,存储于类对象的内存中,不存在于元类对象中。而sayHappy
是类方法,存储于元类对象的内存中,不存在于类对象中。 - 对于元类对象来说,
sayHello
是类对象的实例方法,跟元类没关系;sayHappy
是元类对象的实例方法,所以存在元类中。
类和元类的创建时机
先看结论:
类和元类是在编译期创建的,即在进行alloc操作之前,类和元类就已经被编译器床架你出来了。
我们通过LLDB打印类和元类的指针来验证一下,断点打在main的第一行,这时候Person对象还没有初始化。
1 | (lldb) p/x Person.class |
还有一种方式是通过MachoView
应用打开编译的二进制mach-o文件,MachoView
的使用就不介绍了。
总结
- 类和元类创建于编译时,可以通过
LLDB
来打印类和元类的指针,或者MachOView
查看二进制可执行文件 - 万物皆对象:类的本质就是对象
- 类在
class_ro_t
结构中存储了编译时确定的属性、成员变量、方法和协议等内容。 - 实例方法存放在类中
- 类方法存放在元类中
iOS底层:calloc和isa
calloc
底层
calloc的源码在libMalloc
,不是之前用的libObjc
1 | void * |
可以看出calloc
调用了_malloc_zone_calloc
函数
1 | MALLOC_NOINLINE |
可以看到,如果继续调用就又回到了calloc
函数,这个时候我们借助LLDB打印内存地址。
1 | p zone->calloc |
可以看出这里调用了malloc.c
文件的385行
1 | static void * |
default_zone_calloc
里依然会调用calloc
,我们用相同的方法查看具体执行的代码。
1 | p zone->calloc |
这次执行的代码在nano_malloc.c的878行。nano_calloc函数的实现
1 | static void * |
看代码,我们后面应该继续看_nano_malloc_check_clear
函数,分析_nano_malloc_check_clear
函数,内存大小分配应该是调用segregated_size_to_fit
函数。
1 | size_t slot_bytes = segregated_size_to_fit(nanozone, size, &slot_key); |
1 | static MALLOC_INLINE size_t |
这里可以看出进行的是 16 字节对齐,那么也就是说我们传入的 size
是 40,在经过 (40 + 16 - 1) >> 4 << 4 操作后,结果为48,也就是16的整数倍。
小结:
对象的属性是进行8字节对齐
对象自己进行的是16字节对齐,原因:1、内存是连续的,通过 16 字节对齐规避风险和容错,防止访问溢出。2、提高了寻址访问效率,也就是空间换时间
isa
底层
isa
是一个联合体,是从内存管理层面来设计的,因为联合体是所有成员共享一个内存,联合体内存的大小取决于内部成员内存大小最大的那个元素,对应isa
指针来说,就不用额外声明很多属性,直接在内部ISA_BITFIELD
保存信息。同时由于联合体属性间是互斥的,所以 cls
和 bits
在 isa
初始化流程时是在两个分支中被赋值的。
1 | union isa_t { |
下面是isa
的初始化
1 | inline void |
isa
是一个联合体,有一个结构体属性是ISA_BITFIELD
,其大小为 8 个字节,也就是 64 位。
1 | # define ISA_BITFIELD \ |
nonpointer
: 表示是否对isa
指针开启指针优化 0:纯isa
指针 1:不止是类对象地址,isa
中包含了类信息、对象的引用计数等- has_assoc: 关联对象标志位,0 没有,1 存在
- has_cxx_dtor: 该对象是否有 C++ 或者 Objc 的析构器,如果有析构函数,则需要做析构逻辑, 如果没有,则可以更快的释放对象
- shiftcls: 存储类指针的值。开启指针优化的情况下,在 arm64 架构中有 33 位用来存储类指针。
- magic: 用于调试器判断当前对象是真的对象还是没有初始化的空间
- weakly_referenced: 标志对象是否被指向或者曾经指向一个 ARC 的弱变量,没有弱引用的对象可以更快释放。
- has_sidetable_rc: 当对象引用技术大于 10 时,则需要借用该变量存储进位
- extra_rc: 当表示该对象的引用计数值,实际上是引用计数值减 1, 例如,如果对象的引用计数为 10,那么 extra_rc 为 9。如果引用计数大于 10, 则需要使用到下面的 has_sidetable_rc。
isa
关联对象和类
isa
是对象中的第一个属性,因为这一步是在继承的时候发生的,要早于对象的成员变量,属性列表,方法列表以及所遵循的协议列表。
我们在探索 alloc
底层原理的时候,有一个方法叫做 initIsa
。
这个方法的作用就是初始化 isa
联合体位域。其中有这么一行代码:
1 | newisa.shiftcls = (uintptr_t)cls >> 3; |
通过这行代码,我们知道 shiftcls
这个位域其实存储的是类的信息。这个类就是实例化对象所指向的那个类。
这里的左移右移操作其实很好理解,首先我们先观察 isa
的 ISA_BITFIELD
位域的结构:
1 | # define ISA_BITFIELD \ |
我们可以看到,ISA_BITFIELD
的前 3 位是 nonpointer
,has_assoc
,has_cxx_dtor
,中间 44 位是 shiftcls
,后面 17 位是剩余的内容,同时因为 iOS 是小端模式,那么我们就需要去掉右边的 3 位和左边的 17位,所以就会采用 >>3<<3 然后 <<17>>17 的操作了。
通过这个测试,我们就知道了 isa
实现了对象与类之间的关联。
我们还可以探索 object_getClass
底层,可以发现有这样一行代码:
1 | return (Class)(isa.bits & ISA_MASK); |
这行代码就是将 isa
中的联合体位域与上一个蒙版,这个蒙版定义是怎么样的呢?
1 | # define ISA_MASK 0x00007ffffffffff8ULL |
x00007ffffffffff8ULL
这个值我们转成二进制表示:
1 | 0000 0000 0000 0000 0111 1111 1111 1111 |
结果一目了然,这个蒙版就是帮我们去过滤掉除 shiftcls
之外的内容。
我们直接将对象的 isa
地址与上这个mask之后,就会得到 object.class
一样的内存地址。
isa走位分析
类与元类
我们都知道对象可以创建多个,但是类是否可以创建多个呢?
答案很简单,一个。那么如果来验证呢?
1 | //MARK: - 分析类对象内存存在个数 |
所以我们就知道了类在内存中只会存在一份。
1 | (lldb) x/4gx LGTeacher.class |
我们通过上面的打印,就发现 类的内存结构里面的第一个结构打印出来还是 LGTeacher
,那么是不是就意味着 对象->类->类 这样的死循环呢?这里的第二个类其实是 元类
。是由系统帮我们创建的。这个元类也无法被我们实例化。
对象的本质
在我们认知里面,OC
对象的本质就是一个结构体,这个结论在 libObjc
源码的 objc-private.h
源文件中可以得到证实。
1 | struct objc_object { |
而对于对象所属的类来说,我们也可以在 objc-runtime-new.h
源文件中找到
1 | struct objc_class : objc_object { |
也就是说 objc_class
内存中第一个位置是 isa
,第二个位置是 superclass
。
不过我们本着求真的态度可以用 clang
来重写我们的 OC
源文件来查看是不是这么回事。
1 | clang -rewrite-objc main.m -o main.cpp |
这行命令会把我们的 main.m
文件编译成 C++
格式,输出为 main.cpp
。
我们可以看到 LGPerson
对象在底层其实是一个结构体 objc_object
。
而我们的 Class
在底层也是一个结构体 objc_class
。
iOS的内存对齐
上文中在分析alloc函数的时候,发现有个步骤是给对象分配内存空间,在分配内存空间的时候有一个内存对齐的概念,本文就详细说明一下内存对齐。
NSObject对象占用多少内存空间
看下面这段代码
1 | NSObject *obj = [[NSObject alloc] init]; |
可以看到打印结果是
1 | 实际占用: class_getInstanceSize = 8 |
说明NSObject对象占用的内存空间是8个字节,但是系统分配的空间是16字节。这是为什么,我们看一下核心代码,根据上文可以知道,各个函数的调用是allocWithZone
->_objc_rootAllocWithZone
->_class-createInstanceFromZone
,前两个函数都是简单的调用后一个函数,代码集中在_class-createInstanceFromZone
里
1 | static ALWAYS_INLINE id |
最后我们看instanceSize
函数
1 | inline size_t instanceSize(size_t extraBytes) const { |
可以看到当size<16的时候,系统会分配16个字节。
内存对齐规则
1、结构体变量的首地址是其最长基本类型成员的整数倍;
2、结构体每个成员相对于结构体首地址的偏移量(offset)都是成员大小的整数倍,如不满足,对前一个成员填充字节以满足;
3、结构体的总大小为结构体最大基本类型成员变量大小的整数倍;
4、结构体中的成员变量都是分配在连续的内存空间中。
自定义一个类验证一下
1 | @interface TestMemory : NSObject{ |
像刚开始一样,我们打印TestMemory
对象的内存大小
1 | TestMemory *obj = [[TestMemory alloc] init]; |
打印结果
1 | 实际占用: class_getInstanceSize = 32 |
将TestMemory
转成C++代码看一下结构体内容
1 | xcrun -sdk iphonesimulator clang -rewrite-objc TestMemory.m |
结构体内容
1 | struct TestMemory_IMPL { |
根据内存对齐规则2,_name变量的起始地址将会是8的整数倍,_age变量后面空了4个字节,可以计算出大小为8+4+4(填充4字节)+8+4=28,根据规则3,需要是最大成员的整数倍,所以28向上对8取整,内存大小为32。
下面我们修改一下TestMemory
成员变量的顺序
1 | @interface TestMemory : NSObject{ |
现在打印的结果是:
1 | 实际占用: class_getInstanceSize = 24 |
可以看到实际占用的大小为24了,我们看一下c++的结构体代码
1 | struct TestMemory_IMPL { |
因为我们调整了一下顺序,_name的起始地址是8的倍数,所以不需要再_weight字节后面填充字节,大小就为8+4+4+8=24,因为24是8的倍数,所以也不需要根据规则3对8向上取整,实际占用内存空间就是24。
到这里我们可以得出一个结论,成员变量顺序不同可以影响对象站内内存的空间大小。现在我们可能会想如果我们实际开发中定义属性的顺序会不会影响内存大小呢,如果影响如果不注意岂不是对象的内存大小会有很大差别?带着这个问题我们看一下定义属性的c++代码。
1 | @interface TestMemory : NSObject{ |
我们定义属性的顺序和第一次测试的顺序相同。c++代码
1 | struct TestMemory_IMPL { |
可以看到结构体里成员变量的顺序已经改变过了,说明苹果已经进行了优化,我们实际开发定义属性的时候,不会因为属性定义顺序的不同而影响对象占用的内存大小。