文章目录
- 前言
- 基本调试方法
- alloc
- alloc函数逐步调试
- 空类检查
- 快速路径:默认内存分配
- 慢速路径:自定义内存分配
- slowpath和fastpath
- alloc核心操作
- cls->instanceSize:计算所需内存大小
- 内存字节对齐原则
- 为什么要16字节对齐
- 内存字节对齐总结
- 其他
- 总结
前言
之前笔者简单学习了Objective-c,这段时间在阅读OC源码,通过阅读obj838来深入理解代码的运行机制、设计思想及实现细节。本篇博客主要分享一下我通过阅读和调试alloc部分的源码所学到的东西和遇到的问题。(笔者这里调试的源码为906,点击下载)
首先,阅读源码时我们发现,虽然我们编程使用的是OC语言,但是底层源码大部分使用的是C语言或者C++。
基本调试方法
首先打开我们随机一个项目,在左侧找到断点图标。
找到断点图标后,点击下方添加按钮选择“Symbolic Breakpoint”进行断点添加。
在“Symbol”里输入想要添加断点的地方,然后点击下面的“Add Action”。
在对alloc方法进行断点测试的时候,一个项目中可能涉及多个alloc,我们可以在具体需要测试的位置上进行添加断点,运行程序时在走到这部分断点的时候,再打开上面新增的alloc符号断点,因为alloc的调用有很多,如果开启了就不能准确的定位到我们设定的断点处。
断点打开状态:
断点关闭状态:
然后看情况选择点击Continue、Step Over或Step In进行逐步调试。
xcode调试按钮从左到右依次是:
断点符:可以用来整体关闭或打开断点。
继续运行(Continue):恢复程序执行,直到遇到下一个断点或程序结束。
步过 (Step Over):逐行执行代码,不进入函数/方法内部。
步进进入 (Step In):进入当前行调用的函数/方法内部(逐行调试)。
步出 (Step Out):执行完当前函数剩余代码,直接跳出到调用位置。
alloc
在OC语言中,alloc方法是创建对象实例的核心入口,其底层实现涉及内存分配、类结构管理和运行时机制。
上述代码依次输出GGObject对象的内容、内存地址、指针地址。
首先,我们通过运行这部分代码可以发现obj1、obj2、obj3指向的是同一个内存空间,因为其内容、内存地址是相同的,但是他们的指针地址是不同的。下面我们来探索alloc在这中间到底做了什么。
alloc函数逐步调试
我们添加断点,运行代码,打开alloc断点,然后根据不同情况进行逐步调试。
第一步,点击Step In进入alloc的源码,我们可以看到:
第二步,继续Step In就可以进入_objc_rootAlloc的源码实现:
第三步,接着Step In进入callAlloc的源码:
这里是用于对象内存分配的核心函数,其作用是根据类的内存分配策略(默认或自定义)选择合适的分配方法。
首先,函数签名是:
static ALWAYS_INLINE id //ALWAYS_INLINE:强制内联展开,避免函数调用开销(性能敏感场景)
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
//参数一:目标类(Class 类型) 参数二:是否检查类是否为 nil 参数三:是否调用 allocWithZone: 方法(默认调用 alloc)
空类检查
if (slowpath(checkNil && !cls)) return nil;//slowpath:宏定义,表示“慢速路径”(通常用于条件分支预测失败的场景)
主要是为了防止向空类发送消息,避免程序崩溃。(如果 checkNil 为 true 且类指针 cls 为 nil,直接返回 nil)
快速路径:默认内存分配
if (fastpath(!cls->ISA()->hasCustomAWZ())) {return _objc_rootAllocWithZone(cls, nil);
}
//fastpath:宏定义,表示“快速路径”(条件分支预测成功的场景)
//cls->ISA()->hasCustomAWZ():检查类或其元类是否实现了自定义的 alloc/allocWithZone: 方法
//(hasCustomAWZ:全称 hasCustomAllocationWithZone;若返回 false,表示使用默认内存分配逻辑)
//_objc_rootAllocWithZone:默认内存分配函数,直接调用 malloc 或内存池分配内存,不调用 alloc 方法
慢速路径:自定义内存分配
if (allocWithZone) {return ((id(*)(id, SEL, struct _NSZone *))objc_msgSend)(cls, @selector(allocWithZone:), nil);
} else {return ((id(*)(id, SEL))objc_msgSend)(cls, @selector(alloc));
}
//objc_msgSend:Objective-C 运行时核心函数,用于向对象发送消息(调用方法)
//类型转换:将objc_msgSend强制转换为特定签名的函数指针,确保参数和返回值正确
//参数:allocWithZone::传递 nil 作为 _NSZone 参数
如果类实现了自定义的 alloc/allocWithZone: 方法,则通过消息发送机制调用。若 allocWithZone 为 true,调用 allocWithZone:;
否则,调用 alloc。
slowpath和fastpath
首先,这里的slowpath和fastpath是Objective-C运行时中用于分支预测优化的宏。这里的fastpath用于提示编译器某个条件大概率成立,而slowpath则相反,提示条件大概率不成立。
  这些宏通常使用__builtin_expect来实现,帮助编译器优化代码布局,提高执行效率。这里我们通过在代码中搜索#define fastpath可以找到这个宏定义的位置文件。
在C/C++中,__builtin_expect是GCC的一个内置函数,用于给编译器提示条件分支的预期走向,这样编译器可以进行优化,比如调整指令顺序,让经常执行的分支更靠近处理器的前端,减少分支预测失败的开销。
这里,通过 __builtin_expect 提示编译器条件分支的预期走向,优化指令布局,减少分支预测失败的开销,分离高频路径(fastpath)和低频路径(slowpath),提升代码执行效率。
fastpath的参数x被期望为真(即1),而slowpath的参数x被期望为假(0),将 x 为真的分支代码紧凑排列,减少跳转;将 x 为假的分支代码放在较远位置,减少对主流程的影响。
了解完上述代码中涉及的内容后,我们进行Step Over,此时来到fastpath。
关于这里的!cls->ISA()->hasCustomAWZ():
cls——目标类对象(Class 类型)。
cls->ISA()——获取类的元类(Meta Class)。
hasCustomAWZ()——检查类或其元类是否实现了自定义的 alloc 或 allocWithZone: 方法。
!——逻辑取反,表示“没有自定义分配方法”。
这里主要用于判断是否使用默认内存分配路径(快速路径)或自定义分配路径(慢速路径)。
条件为真时(!hasCustomAWZ()):类或其元类未实现自定义的 alloc/allocWithZone:,使用默认分配逻辑(_objc_rootAllocWithZone),即进入这里:
条件为假时:存在自定义分配方法,需通过消息发送(objc_msgSend)调用,即进入这里:
在这个过程中:
cls->ISA() 返回类的元类指针(例如:NSObject 的元类是 NSObject 自身(根元类))。
元类(Meta Class):
在 Objective-C 中,每个类都有一个元类,用于存储类方法(如 alloc、allocWithZone:)。
实例方法的查找路径:实例 -> 类 -> 父类。
类方法的查找路径:类 -> 元类 -> 根元类。
我们Step Over到fastpath的返回语句里,Step In就可以看到_objc_rootAllocWithZone的实现源码:
_objc_rootAllocWithZone 是 Objective-C 运行时中用于创建对象实例的底层函数,主要负责分配内存并初始化对象头信息(如 isa 指针)。它是 alloc 和 allocWithZone: 方法的最终实现,直接与内存分配器交互。
参数一(cls)——目标类。
参数二(flags(第二个参数 0))——控制分配行为的标志位。常见值:
OBJECT_CONSTRUCT_CALL_BADALLOC:在内存分配失败时抛出异常。
其他标志可能控制是否跳过初始化(如 FAST_ALLOC)。
参数三(OBJECT_CONSTRUCT_CALL_BADALLOC)——宏定义,表示分配失败时调用 objc_abort 或抛出异常。
随后,我们继续进入到_class_createInstance源码(这里是alloc实现的核心源码),为了方便做注释,笔者将这部分代码copy了过来:
static ALWAYS_INLINE id
_class_createInstance(Class cls, //目标类,必须已加载(isRealized())size_t extraBytes,//额外内存(通常为0),用于子类扩展int construct_flags = OBJECT_CONSTRUCT_NONE,//构造标志bool cxxConstruct = true,//是否调用C++构造函数size_t *outAllocatedSize = nil)//返回实际分配的内存大小
{ASSERT(cls->isRealized());//(isRealized())确保类已加载到运行时;未实现时,触发断言,防止未初始化类被使用// Read class's info bits all at once for performancebool hasCxxCtor = cxxConstruct && cls->hasCxxCtor();//类是否有C++构造函数bool hasCxxDtor = cls->hasCxxDtor();//类是否有C++析构函数bool fast = cls->canAllocNonpointer();//是否使用非指针isa(优化引用计数存储)size_t size;size = cls->instanceSize(extraBytes);//返回类实例的总内存大小(包括 isa、引用计数、成员变量)if (outAllocatedSize) *outAllocatedSize = size;id obj = objc::malloc_instance(size, cls);//底层内存分配函数,可能调用 malloc 或内存池。if (slowpath(!obj)) {if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) {return _objc_callBadAllocHandler(cls);}return nil;}//初始化isa指针if (fast) {obj->initInstanceIsa(cls, hasCxxDtor);//使用非指针isa} else {// Use raw pointer isa on the assumption that they might be// doing something weird with the zone or RR.obj->initIsa(cls);//使用原始指针isa}if (fastpath(!hasCxxCtor)) {return obj;//无C++构造函数}//有C++构造函数construct_flags |= OBJECT_CONSTRUCT_FREE_ONFAILURE;return object_cxxConstructFromClass(obj, cls, construct_flags);
}
在obj838中,这部分源码如下:
_class_createInstance 函数是对象实例化的核心,主要完成以下任务:
内存分配:调用 malloc 或类似函数分配内存。
对象头初始化:设置 isa 指针(指向类的元类)。
引用计数初始化:初始化 retainCount 等字段。
构造函数调用:若类实现了 +alloc 或 +allocWithZone: 的自定义逻辑,可能在这里调用。
由此,我们可以大致了解_class_createInstance函数的实现流程(与_class_createInstanceFromZone同理):
alloc核心操作
核心操作都位于calloc方法中
cls->instanceSize:计算所需内存大小
计算需要开辟内存的大小的执行流程如下所示:
-
进入instanceSize的源码实现
-
进入fastInstanceSize(快速计算内存大小)的源码实现
-
跳转至align16(16字节对齐算法)的源码实现
内存字节对齐原则
- 对齐值
数据类型的对齐值是其大小和硬件架构共同决定的。
常见对齐值:
- 起始地址必须是整数倍
数据的内存起始地址必须是对齐值的整数倍。
示例:
一个 int(4字节)的起始地址应为 0x1000, 0x1004, 0x1008 等。
一个 double(8字节)的起始地址应为 0x2000, 0x2008 等。
- 结构体/类的对齐
结构体或类的对齐值由其成员的最大对齐值决定。
示例:
struct Example {char c; // 1字节(对齐值1)int i; // 4字节(对齐值4)
};
字符 c 后需插入 3 字节填充,使 i 从 4 字节地址开始。
结构体总大小为 1 + 3 (填充) + 4 = 8 字节,对齐值为 4 字节。
为什么要16字节对齐
- 对齐访问允许 CPU 通过单次内存操作读取或写入数据:
通常内存是由一个个字节组成的,cpu在存取数据时,并不是以字节为单位存储,而是以块为单位存取,块的大小为内存存取力度。频繁存取字节未对齐的数据,会极大降低cpu的性能,所以可以通过减少存取次数来降低cpu的开销。
eg:例如:64 位 CPU 一次可读取 8 字节数据。若数据对齐到 8 字节边界,只需一次访问;若未对齐,可能需要两次访问。
- 16字节对齐有利于性能优化,提高内存访问效率:
16字节对齐,是由于在一个对象中,第一个属性isa占8字节,当然一个对象肯定还有其他属性,当无属性时,会预留8字节,即16字节对齐,如果不预留,相当于这个对象的isa和其他对象的isa紧挨着,容易造成访问混乱。
内存字节对齐总结
在字节对齐算法中,对齐的主要是对象,而对象的本质则是一个 struct objc_object的结构体。
结构体在内存中是连续存放的,所以可以利用这点对结构体进行强转。
苹果早期是8字节对齐,现在是16字节对齐。
算法原理
目标:将原始内存大小 x 向上取整到最近的 16字节对齐 的边界。
公式:对齐后的大小=(x+15) & ( 15)
步骤:
- 加15:将原始大小 x 加上 15,确保结果覆盖所有可能的余数。
- 取反15:~15 的二进制为 1111 1111 1111 0000(以32位为例),其作用是保留高位,清零低4位。
- 按位与操作:通过 (x + 15) & (~15),将低4位清零,得到16的整数倍。
图例解析
以 x = 8 为例:
- 原始大小:8 字节。
- 加15:8 + 15 = 23(二进制 0000 0000 0001 0111)。
- 取反15:~15 = 1111 1111 1111 0000。
- 按位与:
为什么要加15
确保任何余数(1~15)都能通过进位达到下一个16的倍数。例如:
若 x = 1 → 1 + 15 = 16 → 直接对齐。
若 x = 17 → 17 + 15 = 32 → 对齐到32字节。
为什么用按位与
通过清零低4位(~15 的低4位为0),强制将结果对齐到16字节边界。
由上述我们可以得出alloc方法的源码大概实现流程:
其他
这段代码是C++模板类Zone的一个特化版本,专为布尔模板参数true设计,提供基于C标准库的简单内存管理。其中的alloc() 方法是通过 ::calloc 分配一块大小为 sizeof(T) 的内存,并初始化为零值。
calloc 的特点:分配内存并清零(适合需要初始化的场景)。其返回 void*,需强制转换为 T*。
reinterpret_cast 的作用:将 void* 转换为 T*,直接返回原始内存地址。
从这里我们可以知道alloc方法只适用于T类型(平凡类型),如int、struct{int x;},而且比较适用于需要快速分配且零初始化的内存(如高频创建的临时对象)。
注意:
若T是非平凡类型(含构造函数/析构函数),跳过构造函数会导致未定义行为(UB)
若T对齐要求高于16字节,calloc可能无法满足(但Darwin系统默认对齐到16 字节)
未定义行为:是指程序的行为不可预测,可能表现为崩溃、数据损坏、安全漏洞等。编译器无需对 UB 情况做任何处理,甚至可能优化掉相关代码。
关于构造函数与析构函数,笔者之前发过:【C++】构造函数与析构函数浅知
总结
笔者初次进行源码阅读,学习重点总是捉摸不清,读起来比较费时间,也比较难读懂,本篇文章好多地方还没完善,若有错误,还请斧正,后续笔者将会继续对obj838源码进行阅读解析。