【iOS】底层原理:类的加载
文章目录
前言
上一篇讲 dyld 的博客我们学习了 App 从点击图标到 main 函数的完整过程,其中关键的一句话是:
dyld 完成动态库加载和链接后,会调用
_objc_init———— 通知 Runtime:“镜像都加载好了,你可以开始处理类了”
这里就有问题了,当Runtime 收到通知后,到底是怎么处理"类"的
一个类在编译时被编译成机器码,存在 Mach-O 文件的 __objc_classlist 段里,只是一段二进制数据。当 App 启动、dyld 把这个二进制映射到内存后,Runtime 需要把它"激活"——把它变成一个真正可用的 Class 对象,包括它的方法列表、属性列表、成员变量、协议等等。
整个过程可以分为两个大阶段:
- 第一阶段:map_images —— 把类从 Mach-O 里读出来,注册到 Runtime 的表里
- 第二阶段:load_images —— 等所有类都处理完了,执行 +load 方法
而第一阶段内部,又可以拆成三个小步骤:
- readClass(发现类,记录名字和地址)
- realizeClassWithoutSwift(实现类,加载 data 数据)
- methodizeClass(方法化,处理方法和分类)
下面我们来看看Runtime是怎么处理一个类的
从 dyld 到 objc:_objc_init
前面说了,dyld 在处理完所有动态库的加载和链接之后(也就是 Rebase + Bind 做完之后),会开始执行初始化。对于 Objc Runtime 来说,初始化入口就是 _objc_init。
void _objc_init(void)
{
static bool initialized = false;
if (initialized) return;
initialized = true;
environ_init(); // 读取环境变量
static_init(); // C++ 静态构造函数
runtime_init(); // 初始化全局表
exception_init(); // 异常处理
cache_t::init(); // 缓存初始化
_imp_implementationWithBlock_init(); // 预加载 libobjc-trampolines.dylib
// 向 dyld 注册三个回调
_dyld_objc_callbacks_v2 callbacks = {
2, // version
&map_images, // 镜像映射时调用
&load_images, // 镜像初始化时调用
unmap_image, // 镜像移除时调用
_objc_patch_root_of_class
};
_dyld_objc_register_callbacks((_dyld_objc_callbacks*)&callbacks);
}
- 初始化 Runtime 自己的内部环境, 比如
runtime_init里创建了两个关键表:
void runtime_init(void)
{
objc::unattachedCategories.init(32); // 存放还没找到"宿主"的分类
objc::allocatedClasses.init(); // 存放所有已分配的类
}
- 向 dyld 注册回调
_dyld_objc_register_callbacks告诉 dyld:“后面你加载新的镜像时,记得叫我一声,我来处理里面的 ObjC 代码”。
所谓镜像就是Mach-o文件被加载到内存之后的叫法
app启动的时候,dyld把哪些动态库或者二进制文件从磁盘读取到内存,这个时候他们在内存中的那一份拷贝就叫一个镜像
注册的三个回调各自的功能:
| 回调 | 触发时机 | 作用 |
|---|---|---|
map_images |
每个镜像被 dyld 加载进内存时 | 处理镜像里的类、协议、分类等元数据 |
load_images |
所有镜像加载链接完毕 | 执行类和分类的 +load 方法 |
unmap_image |
镜像被卸载时 | 清理资源 |
map_images 和 load_images 的区别:
map 是"一个一个来的"——每加载一个动态库就调一次。
load 是"等所有都到位了再调"——确保所有类都处理完了,再统一执行 +load
map_images:类的加载入口
map_images 本身只是一个很薄的壳,加锁后调用 map_images_nolock:
void map_images(unsigned count, const char * const paths[],
const struct mach_header * const mhdrs[])
{
mutex_locker_t lock(runtimeLock);
return map_images_nolock(count, paths, mhdrs);
}
map_images_nolock 先做一些基础工作——读取 Mach-O header、筛选有 ObjC 数据的镜像、记录镜像信息、初始化全局 selector 哈希表——然后调用 _read_images,这才是真正重要的东西
第一次调用时,会创建一个全局的 selector 哈希表(
namedSelectors),后续所有镜像里的@selector(xxx)都会统一注册到这个表中,确保同样的 selector 指向同一个 SEL 地址。
_read_images:处理类的核心函数
void _read_images(mapped_image_info infosParam[], uint32_t hCount, int totalClasses, int unoptimizedTotalClasses)这个核心函数的实现非常长,我们找里面核心的部分来看
第一阶段:创建类名查找表
static bool doneOnce;
if (!doneOnce) {
doneOnce = YES;
launchTime = YES;
gdb_objc_realized_classes = NXCreateMapTable(NXStrValueMapPrototype, namedClassesSize);
}
只执行一次。创建了哈希表 gdb_objc_realized_classes作用:给定一个类名,能快速找到对应的 Class 对象
被 dyld 共享缓存(dyld_shared_cache)预优化过的类,不会加入这个表(它们在共享缓存里已有自己的查找机制)这个表存放的是"不在共享缓存中的那些类"。
第二阶段:统一 @selector 地址
for (EACH_HEADER) {
SEL *sels = _getObjc2SelectorRefs(hi, &count);
for (i = 0; i < count; i++) {
sel_registerNameNoLock(sels[i]);
}
}
在两个不同的类里写了
- (void)viewDidLoad,两个文件分别编译,编译器会生成两个地址不同的@selector(viewDidLoad)。但在 Runtime 里,viewDidLoad这个 SEL 必须是全局唯一的。这一步就是把所有镜像里的 @selector 统一注册同样的名字 -> 同一个 SEL 指针
第三阶段:读取类 —— readClass
classref_t const *classlist = _getObjc2ClassList(hi, &count);
for (i = 0; i < count; i++) {
Class cls = (Class)classlist[i];
readClass(cls, headerIsBundle, headerIsPreoptimized);
}
这一步从 Mach-O 的 __objc_classlist 段中取出所有类,逐个调用 readClass。
__objc_classlist是什么?每个 ObjC 类(@interface … @end),编译后都会在 Mach-O 里生成一条记录,存在
__objc_classlist段中。这个段就是"该项目所有类的清单"
readClass 做的事情其实很轻量:
- 读取类的名字
- 检查父类是不是弱链接且不存在 → 是的话返回 nil
- 通过
addNamedClass把"类名 → Class 对象"的映射加入gdb_objc_realized_classes表 - 通过
addClassTableEntry把 Class 对象加入allocatedClasses表
执行完 readClass 之后,类的状态
只有两个信息:地址 和 名字。它的方法列表、属性列表、成员变量这些 data 数据还没有被加载到内存。
可以简单理解为:Runtime 现在知道了"有一个类叫 Person,它在内存的 0x1043c0000 这个地方",但 Person 有哪些方法、有哪些属性,还不知道。
第四到七阶段:修复引用
这四个阶段做的事情很类似——修复各种引用指针:
- 第四阶段:修复
__objc_classrefs段中的类引用([Person class]生成的引用) - 第五阶段:修复
__objc_msgrefs段中的消息引用 - 第六阶段:读取
__objc_protolist段中的协议,注册到协议哈希表 - 第七阶段:修复
__objc_protorefs段中的协议引用
简单说:代码里对其他类、消息、协议的引用,在这一轮中被统一修正为正确的内存地址。
第八阶段:标记分类
if (didInitialAttachCategories) {
for (EACH_HEADER) {
load_categories_nolock(hi);
}
}
这一步不真正附加分类,只是做标记。实际附加上去是在 load_images 时才执行。
第九阶段:加载非懒加载类(最核心的一步)
classref_t const *nlclslist = _getObjc2NonlazyClassList(hi, &count);
for (i = 0; i < count; i++) {
Class cls = (Class)nlclslist[i];
addClassTableEntry(cls);
realizeClassWithoutSwift(cls, nil);
}
这一步最关键。从 __objc_nlclslist 段中取出非懒加载类,调用 realizeClassWithoutSwift 真正去实现这个类。
非懒加载类:简单来说,实现了
+load方法的类,就是非懒加载类。没有实现+load的就是懒加载类。区分的原因:因为 +load 方法需要在 load_images 阶段被调用。如果一个类实现了 +load,它就"催促"Runtime:“快把我实现好,不然待会儿调用 +load 时找不到我”。而没有 +load 的类可以慢慢来——等到第一次给它发消息(比如
[Person alloc])时再 realize 也可以,这样可以加快启动速度。
所以这里的关键是 realizeClassWithoutSwift,下面我们专门说一下
第十阶段:处理 Future Class
处理通过 objc_allocateClassPair 预先分配但尚未注册的类,了解即可
至此,_read_images 的十个阶段就走完了。回头看这里的整个步骤:
第1阶段:创建查找表
第2阶段:统一 @selector 地址
第3阶段:readClass → 注册所有类(名字+地址)
第4-7阶段:修复各种引用指针
第8阶段:标记分类
第9阶段:非懒加载类 → realizeClassWithoutSwift(核心步骤)
第10阶段:处理 Future Class
realizeClassWithoutSwift:真正实现类
这是类的加载里最重要的一环。用一句话概括它的作用:
把"只有名字和地址的占位符",变成"拥有完整方法列表、属性列表、成员变量的真正类对象"
static Class realizeClassWithoutSwift(Class cls, Class previously)
{
auto ro = cls->safe_ro();
auto isMeta = ro->flags & RO_META;
//第一步:分配 rw,让 rw 指向 ro
rw = objc::zalloc<class_rw_t>();
rw->set_ro(ro);
rw->flags = RW_REALIZED|RW_REALIZING|isMeta;
cls->setData(rw);
//第二步:递归 realize 父类和元类
supercls = realizeClassWithoutSwift(remapClass(cls->getSuperclass()), nil);
metacls = realizeClassWithoutSwift(remapClass(cls->ISA()), nil);
// 建立父子关系
if (supercls) {
addSubclass(supercls, cls);
} else {
addRootClass(cls);
}
//第三步:方法化
methodizeClass(cls, previously);
return cls;
}
第一步:分配 rw,关联 ro
这里要引入三个非常重要的概念:ro、rw、rwe
- ro(read-only):不可以改,存在Mach-O 文件里(Clean Memory),存放类名、方法列表、属性列表、协议列表、成员变量
- rw(read-write):可以改,堆上动态分配(Dirty Memory),存放指向 ro 的指针 + 运行时状态
- rwe(read-write ext):可以改,堆上动态分配(按需分配),存放分类添加的方法、属性、协议
- ro 是编译时就确定好的"原始设计图纸",不能改
- rw 是一个文件夹,里面放着 ro 的复印件,你可以在文件夹上贴便签(记录运行时状态),但原件不能动
- rwe 是文件夹里的一个"附加夹层",只有当你真的有额外东西要放时才会准备这个夹层
在 realizeClassWithoutSwift 里,第一步做的就是:从 Mach-O 里取出 ro,分配一个 rw,让 rw 指向 ro
rw = objc::zalloc<class_rw_t>(); // 分配 rw
rw->set_ro(ro); // rw 指向 ro
rw->flags = RW_REALIZED|RW_REALIZING; // 标记为"已实现"
cls->setData(rw); // class 的 data 改为指向 rw
cls->data() 在 realize 之前指向的是 ro(纯 Mach-O 数据),realize 之后指向的是 rw(运行时数据结构)
注意:rwe 不会在这里创建
注意:这一步并没有分配 rwe。rwe 只在有分类要附加到当前类时,才会在 methodizeClass 的 attachToClass 中调用 extAllocIfNeeded 来分配
这是苹果的优化:大部分类都没有分类,不需要提前分配 rwe 浪费内存。只有大约 10% 的类真的需要 rwe
第二步:递归处理继承链
supercls = realizeClassWithoutSwift(remapClass(cls->getSuperclass()), nil);
metacls = realizeClassWithoutSwift(remapClass(cls->ISA()), nil);
这两行代码做了递归:如果父类还没 realize,先 realize 父类;如果元类还没 realize,先 realize 元类。其中 remapClass 的作用是处理被共享缓存优化过的类指针,确保拿到正确的类地址
递归的终止条件有两个:
cls == nil→ NSObject 的父类就是 nil,到这里停止cls->isRealized()→ 已经实现过了,直接返回
递归返回后,通过 addSubclass 或 addRootClass 建立父子双向链表。这个链表在后面方法查找(沿着继承链往上找)和 +initialize 传递中都发挥着作用
第三步:方法化
调用了 methodizeClass,这是下一步的内容
methodizeClass:方法化
它做的事情可以概括为:
把 ro 里的方法、属性、协议"贴"到 rw 里,同时把属于这个类的分类内容也附加进来。
static void methodizeClass(Class cls, Class previously)
{
auto rw = cls->data();
auto ro = rw->ro();
//把 ro 里的 baseMethods 取出来,排序
if (method_list_t *list = ro->baseMethods ...) {
prepareMethodLists(cls, &list, 1, YES, isBundleClass(cls));
}
//如果有分类,从全局表里取出来挂上
if (previously) {
objc::unattachedCategories.attachToClass(cls, previously, ...);
}
}
prepareMethodLists 会对方法列表按 selector 地址排序(std::stable_sort),排序后方法查找才能用二分查找优化。
分类是怎么附加进来的
如果这个类有分类,attachToClass 最终会调用 attachCategories,它的核心逻辑是:
把分类的方法、属性、协议插到主类的方法列表头部。
为什么是头部, attachLists 的实现:
void attachLists(List* const * lists, uint32_t mcount)
{
// 旧数据往后挪,新数据插到前面
memmove(array()->lists + mcount, array()->lists, oldCount * sizeof(array()->lists[0]));
memcpy(array()->lists, lists, mcount * sizeof(array()->lists[0]));
}
这段代码的意思是:把原来的方法列表往后搬,把分类的方法列表放到前面。因为方法查找是从前往后遍历的,分类的方法排在前面,所以优先被找到。
这就是分类方法能"覆盖"主类方法的本质原因——不是真的覆盖,是插到了前面,查找时先找到了分类的。同理,如果有多个分类实现了同一个方法,后编译的那个排在更前面。
分类附加时的几个细节:
- 逆序处理:后编译的分类,数据先被处理,最终插入后排在更前面
- 64 个一批:每满 64 个分类处理一次
- rwe 在这里创建:第一次有分类要附加时,调用
extAllocIfNeeded分配 rwe - 分类不能添加成员变量:成员变量存在 ro 里(刻在 Mach-O 里),ro 是只读的不能改。分类只能添加方法、遵守协议、声明属性(但 @property 不会生成 _ 成员变量)。如果想给分类关联数据,要用关联对象(
objc_setAssociatedObject)
懒加载类 & 非懒加载类
怎么区分
- 非懒加载类(实现了+load):
_read_images第9步,启动时立即 realize - 懒加载类(没有+load):第一次收到消息(比如
alloc)时才 realize
懒加载类什么时候 realize
懒加载类的 realize 是通过消息发送触发的。当这个类第一次收到消息时,消息发送的慢速查找流程会触发 realize:
[Person alloc]
↓ objc_msgSend 查找 imp
↓ 缓存中没有
↓ lookUpImpOrForward 慢速查找
↓ realizeClassMaybeSwiftMaybeRelock
↓ realizeClassWithoutSwift 在这里 realize
为什么要有这个区分
为了性能, 大多数类没有 +load 方法,不需要在启动阶段立刻处理。把它们推迟到第一次使用时再加载,可以缩短 _read_images 的处理时间,加快 App 启动速度。
只有那些实现了 +load 的类才需要提前 realize,因为它们需要在接下来的 load_images 阶段调用 +load
load_images:调用 +load 方法
当 _read_images 把所有类都处理完之后,dyld 进入初始化阶段,调用 load_images,开始执行 +load 方法。
void load_images(const _dyld_objc_notify_mapped_info *info)
{
loadAllCategoriesIfNeeded(); // 加载所有分类
prepare_load_methods(); // 准备 +load 调用列表
call_load_methods(); // 真正调用 +load
}
三个步骤:
第一步:loadAllCategoriesIfNeeded()——把之前推迟处理的分类正式加载进来。
第二步:prepare_load_methods()——遍历 __objc_nlclslist(非懒加载类列表)和 __objc_nlcatlist(非懒加载分类列表),收集所有实现了 +load 的类和分类。
对于类的 +load,schedule_class_load 会递归处理继承链:
static void schedule_class_load(Class cls)
{
if (cls->hasLoadMethod()) {
schedule_class_load(cls->getSuperclass()); // 先处理父类
add_class_to_loadable_list(cls); // 再处理当前类
}
}
这段递归保证了父类的 +load 比子类的 +load 先加入调用列表,所以执行时父类先调。
第三步:call_load_methods()——真正执行 +load
static void call_load_methods(void)
{
do {
while (loadable_classes_used > 0) {
call_class_loads(); // 循环调用所有类的 +load
}
call_category_loads(); // 调用一轮分类的 +load
} while (...还有类和分类没调完);
}
执行顺序总结:
- 父类的 +load 先于子类
- 所有类的 +load 执行完毕,才开始调分类的 +load
- 如果动态加载了新类且有 +load,它仍然优先于后续分类的 +load
+load 和 +initialize
上面说了 +load 的调用机制,这里把它和 +initialize 放在一起对比,因为面试中经常问到。
| 对比 | +load |
+initialize |
|---|---|---|
| 调用时机 | main 函数前,load_images 阶段 | 第一次给类发消息时(如 [Person alloc]) |
| 调用方式 | 通过函数指针直接调用 | 通过 objc_msgSend(走消息发送) |
| 父子类关系 | 各调各的,子类有 +load 就调,没有就不调 | 子类没实现 -> 调用父类的(走继承链) |
| 分类影响 | 分类的 +load 也会单独调用 | 分类实现了 -> 覆盖主类的(走消息发送) |
| 执行顺序 | 父类 -> 子类 -> 分类 | 首次发消息时触发,只调一次 |
关键区别在于调用方式:
+load 是通过函数指针直接调用的,所以不遵循消息发送的继承规则。父类实现了 +load、子类没实现,子类不会"继承"调用父类的 +load。
+initialize 是通过 objc_msgSend 调用的,所以它走的是消息发送流程。子类没实现 +initialize,第一次给子类发消息时,会沿着继承链找到父类的实现并调用。
可以这样记:
+load 像 C 函数调用,谁实现了就调用谁;+initialize 像 Objc消息发送,子类没有就找父类的
完整流程总结
现在把整个类加载路径从头串一遍:
-
点击app,内核加载dyld
-
dyld完成动态库的加载和链接
-
调用
_objc_init-> Runtime向map_images和load_images回调 -
dyld每映射一个镜像 -> 调用
map_images内部调_read_images -
_read_images十个阶段- 创建类名查找表
- 统一@selector地址
- readClass -> 注册类(名字加地址)
4 - 7. 修复引用(类、消息、协议)
- 标记分类
- realizeClassWithoutSwift -> 真正实现非懒加载类
- 处理Future Class
-
realizeClassWithoutSwift(三步):
- 分配 rw,指向 ro(读数据)
- 递归 realize 父类和元类(串继承链)
- methodizeClass(贴方法、附加分类)
-
load_images → 执行所有 +load 方法
从数据流动的角度看:
Mach-O 里的二进制数据
↓ readClass
知道了"类名叫 Person,地址在 0x..."
↓ realizeClassWithoutSwift
分配了 rw,ro 里的方法/属性/协议可以访问了
↓ methodizeClass
方法列表排序完成,分类内容已插入到头部
↓ load_images -> call_load_methods
+load 被调用,类完全可用
整个类的加载过程可以抽象为三个核心阶段:
-
readClass— 注册阶段:将 Mach-O 中的类元数据读入 Runtime 的全局哈希表,建立类名到 Class 对象的映射关系,此时类仅有地址和名称,尚未具备完整的方法与属性结构。 -
realizeClassWithoutSwift— 实现阶段:为类分配运行时数据结构 rw,将其与编译期只读数据 ro 关联,同时递归处理父类与元类的 realize,构建完整的继承链体系。至此,类的方法列表、属性列表、成员变量等数据变得可访问。 -
methodizeClass— 方法化阶段:将 ro 中的基础方法、属性、协议注册到 rw 中,并按需将分类的内容(通过 rwe)附加到主类方法列表头部,完成类在运行时的最终形态装配。
三个阶段的执行顺序和数据流向构成了 ObjC 类加载的完整骨架。在这个基础之上,分类机制、关联对象、方法交换等运行时特性,本质上都是在方法化这一环节对类数据结构进行的动态扩展
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)