前言

上一篇讲 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);
}
  1. 初始化 Runtime 自己的内部环境, 比如 runtime_init 里创建了两个关键表:
void runtime_init(void)
{
    objc::unattachedCategories.init(32);   // 存放还没找到"宿主"的分类
    objc::allocatedClasses.init();          // 存放所有已分配的类
}
  1. 向 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 做的事情其实很轻量:

  1. 读取类的名字
  2. 检查父类是不是弱链接且不存在 → 是的话返回 nil
  3. 通过 addNamedClass 把"类名 → Class 对象"的映射加入 gdb_objc_realized_classes
  4. 通过 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

  1. ro(read-only):不可以改,存在Mach-O 文件里(Clean Memory),存放类名、方法列表、属性列表、协议列表、成员变量
  2. rw(read-write):可以改,堆上动态分配(Dirty Memory),存放指向 ro 的指针 + 运行时状态
  3. 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 只在有分类要附加到当前类时,才会在 methodizeClassattachToClass 中调用 extAllocIfNeeded 来分配

这是苹果的优化:大部分类都没有分类,不需要提前分配 rwe 浪费内存。只有大约 10% 的类真的需要 rwe

第二步:递归处理继承链

supercls = realizeClassWithoutSwift(remapClass(cls->getSuperclass()), nil);
metacls = realizeClassWithoutSwift(remapClass(cls->ISA()), nil);

这两行代码做了递归:如果父类还没 realize,先 realize 父类;如果元类还没 realize,先 realize 元类。其中 remapClass 的作用是处理被共享缓存优化过的类指针,确保拿到正确的类地址

递归的终止条件有两个:

  1. cls == nil → NSObject 的父类就是 nil,到这里停止
  2. cls->isRealized() → 已经实现过了,直接返回

递归返回后,通过 addSubclassaddRootClass 建立父子双向链表。这个链表在后面方法查找(沿着继承链往上找)和 +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]));
}

这段代码的意思是:把原来的方法列表往后搬,把分类的方法列表放到前面。因为方法查找是从前往后遍历的,分类的方法排在前面,所以优先被找到

这就是分类方法能"覆盖"主类方法的本质原因——不是真的覆盖,是插到了前面,查找时先找到了分类的。同理,如果有多个分类实现了同一个方法,后编译的那个排在更前面。

分类附加时的几个细节:

  1. 逆序处理:后编译的分类,数据先被处理,最终插入后排在更前面
  2. 64 个一批:每满 64 个分类处理一次
  3. rwe 在这里创建:第一次有分类要附加时,调用 extAllocIfNeeded 分配 rwe
  4. 分类不能添加成员变量:成员变量存在 ro 里(刻在 Mach-O 里),ro 是只读的不能改。分类只能添加方法、遵守协议、声明属性(但 @property 不会生成 _ 成员变量)。如果想给分类关联数据,要用关联对象(objc_setAssociatedObject

懒加载类 & 非懒加载类

怎么区分

  1. 非懒加载类(实现了+load):_read_images 第9步,启动时立即 realize
  2. 懒加载类(没有+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 的类和分类。

对于类的 +loadschedule_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 (...还有类和分类没调完);
}

执行顺序总结:

  1. 父类的 +load 先于子类
  2. 所有类的 +load 执行完毕,才开始调分类的 +load
  3. 如果动态加载了新类且有 +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_imagesload_images回调

  • dyld每映射一个镜像 -> 调用map_images内部调_read_images

  • _read_images十个阶段

    1. 创建类名查找表
    2. 统一@selector地址
    3. readClass -> 注册类(名字加地址)

    4 - 7. 修复引用(类、消息、协议)

    1. 标记分类
    2. realizeClassWithoutSwift -> 真正实现非懒加载类
    3. 处理Future Class
  • realizeClassWithoutSwift(三步):

    1. 分配 rw,指向 ro(读数据)
    2. 递归 realize 父类和元类(串继承链)
    3. methodizeClass(贴方法、附加分类)
  • load_images → 执行所有 +load 方法

从数据流动的角度看:

Mach-O 里的二进制数据
  ↓ readClass
知道了"类名叫 Person,地址在 0x..."
  ↓ realizeClassWithoutSwift
分配了 rw,ro 里的方法/属性/协议可以访问了
  ↓ methodizeClass
方法列表排序完成,分类内容已插入到头部
  ↓ load_images -> call_load_methods
+load 被调用,类完全可用

整个类的加载过程可以抽象为三个核心阶段:

  1. readClass — 注册阶段:将 Mach-O 中的类元数据读入 Runtime 的全局哈希表,建立类名到 Class 对象的映射关系,此时类仅有地址和名称,尚未具备完整的方法与属性结构。

  2. realizeClassWithoutSwift — 实现阶段:为类分配运行时数据结构 rw,将其与编译期只读数据 ro 关联,同时递归处理父类与元类的 realize,构建完整的继承链体系。至此,类的方法列表、属性列表、成员变量等数据变得可访问。

  3. methodizeClass — 方法化阶段:将 ro 中的基础方法、属性、协议注册到 rw 中,并按需将分类的内容(通过 rwe)附加到主类方法列表头部,完成类在运行时的最终形态装配。

三个阶段的执行顺序和数据流向构成了 ObjC 类加载的完整骨架。在这个基础之上,分类机制、关联对象、方法交换等运行时特性,本质上都是在方法化这一环节对类数据结构进行的动态扩展

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐