一:LLVM拦截优化
话接上文,分析过alloc方法底层调用逻辑之后,本以为已经搞明白了alloc的底层调用流程,不成想看到debug里的函数调用栈,如下图
分析发现:
- 在
alloc方法调用之前还调用了objc_alloc和callAlloc方法,且整个调用顺序为objc_alloc->callAlloc->alloc->_objc_rootAlloc->callAlloc->_objc_rootAllocWithZone->_class_createInstanceFromZone(NSObject比较特殊,它的alloc方法调用顺序为objc_alloc->callAlloc->_objc_rootAllocWithZone->_class_createInstanceFromZone),如下图:
callAlloc方法调用了两次,且每次方法内部走的流程还不一样,而且我们通过control+command+step into这样跟流程的方式是没法进入objc_alloc和第一次callAlloc方法里面的。
那么objc_alloc方法和第一次callAlloc方法为什么会出现在调用流程里面呢?苹果到底为什么要这样做呢?下面就带着这些问题一起来探索。
首先需要找出objc_alloc方法是在什么时候、什么位置调用的,但是函数调用栈和汇编代码等方式都没有线索。只有使用最原始的办法:在源码工程里全局搜索objc_alloc方法,一个一个去找了。
皇天不负有心人,终于被我找到了线索,发现了一个修复函数fixupMessageRef,根据代码可知:if (msg->sel == @selector(alloc))的情况下,就讲IMP替换成objc_alloc。
接下来继续顺藤摸瓜(逆向查找)找出调用顺序,看看程序在什么情况下进入这里替换IMP。
- 全局搜索
fixupMessageRef,找到调用者_read_images。
- 由
_read_images的注释可知调用者为map_images_nolock;
- 根据注释可知
map_images_nolock调用者可能为map_images,全局搜索map_images_nolock,确定调用者确实是map_images。
- 全局搜索
map_images,找到了_objc_init里面的_dyld_objc_notify_register函数,继续搜索_objc_init和_dyld_objc_notify_register函数的话不会有什么收获,所以据此判断,至此跟fixupMessageRef函数相关的调用流程逆向查找也就结束了。
- 正向验证:根据上面探索的结果可以确定正向调用顺序为
_objc_init->_dyld_objc_notify_register->map_images->map_images_nolock->_read_image->fixupMessageRef,在这些函数里设置断点,然后运行源码调试验证。
结论:通过逆向查找源码调用流程,正向运行程序验证,得出的结论是alloc方法一定会被替换成objc_alloc,但是却并不是在上述fixupMessageRef函数内修改的。那么苹果为什么会提供一个可能不会执行的修复函数呢?这里发散思维,难道在程序编译阶段就会有相应的类似操作,这里只是容错处理!
带着这样的猜想,接下来下载LLVM的源码,然后拖到Visual Studio Code里探索验证一下:
MachOVie验证.app的可执行文件,也可以发现在汇编阶段就已经存在objc_alloc符号:
最终结论:程序在LLVM编译阶段就已经完成了alloc->objc_alloc的替换,而且还替换了retain、release、autorelease等等。至于为什么要hook这些函数,推测可能是对象的创建、释放等跟内存相关,所以系统做了相应的监控。
探索到这里还有一个问题困扰着我们,就是系统为什么要在objc源码内添加修复函数fixupMessageRef这个容错处理?什么情况下LLVM编译器会出错,从而触发fixupMessageRef函数?这个问题留待后续探索。
流程总结:
alloc等一系列特殊方法在编译阶段LLVM会对其进行hook,其中alloc被替换成了objc_alloc函数,这样在运行时声明一个XJPerson类的对象并且为其开辟内存时调用alloc方法,首先响应的就是objc_alloc,接着进入callAlloc,第一次永远不满足判断条件if (fastpath(!cls->ISA()->hasCustomAWZ()))从而触发((id(*)(id, SEL))objc_msgSend)(cls, @selector(alloc)),给XJPerson发送了alloc消息,这个时候alloc方法才真正被调用,然后进入_objc_rootAlloc->callAlloc->_objc_rootAllocWithZone->_class_createInstanceFromZone此方法里面做三件事:字节对齐、开辟内存空间、与对象绑定。
流程图:
二:对象内存的影响因素
探索方向:
- 空对象,不声明任何成员变量、属性和方法。
- 只声明属性(成员变量)。
- 只声明方法。
结论:
- 不声明成员变量、属性和方法的情况下,类的实例对象的内存大小为
NSObject的isa指针的8字节。 - 方法不会对类的实例对象的内存大小产生任何影响,方法不存在对象内(实例方法在类的
method_list,类方法在元类的method_list)。 - 添加属性(成员变量)的情况下,类的实例对象的内存大小为各成员变量所占内存大小加上
isa指针的8字节,然后再以8字节对齐。
三:字节对齐
8字节对齐算法:
(x + WORD_MASK) & ~WORD_MASK,x是已知参数,类型是size_t,代表当前对象声明成员变量的字节数instanceSize,WORD_MASK是宏定义,64位系统下值是7,假设x = 8,那么表达式就是:
(8 + 7) & ~7
= 15 & ~7 (7 = 0000 0111, ~7 = 1111 1000)
= 0000 1111 & 1111 1000
= 0000 1000
= 8
16字节对齐算法:
(x + size_t(15)) & ~size_t(15),x是已知参数,类型是size_t,代表当前对象声明成员变量的字节数instanceSize,假设x = 21那么表达式就是:
(21 + 15) & ~15
= 36 & ~15 (15 = 0000 1111, ~15 = 1111 0000)
= 0010 0100 & 1111 0000
= 0010 0000
= 32
为什么需要内存对齐?
原理图解:
四:结构体内存对齐
内存对齐原则:
- 数据成员对齐规则:结构体(struct)或联合体(union)的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员的存储位置要从该成员大小或成员的子成员大小(只要该成员有子成员,比如说是数组结构体等)的整数倍开始(比如int是4字节,则要从4的整数倍地址开始存储)。
- 结构体作为成员,如果一个结构体里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储(struct a里面有struct b,b里面有char,int,double等元素,那b应该从8的整数倍开始存储)。
- 收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员大小的整数倍,不足的要补齐。
补充资料:基础类型占用字节数
五:malloc探索
为什么要探索malloc?
探索打印实例对象占用内存的时候,出现了意料之外的结果:
遂找到libmalloc-317.40.8源码,并在其中找到了核心代码:
#define SHIFT_NANO_QUANTUM 4
#define NANO_REGIME_QUANTA_SIZE (1 << SHIFT_NANO_QUANTUM) // 16
static MALLOC_INLINE size_t
segregated_size_to_fit(nanozone_t *nanozone, size_t size, size_t *pKey)
{
size_t k, slot_bytes;
if (0 == size) {
size = NANO_REGIME_QUANTA_SIZE; // Historical behavior
}
k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM; // round up and shift for number of quanta
slot_bytes = k << SHIFT_NANO_QUANTUM; // multiply by power of two quanta size
*pKey = k - 1; // Zero-based!
return slot_bytes;
}
malloc源码流程图:
结论:
- 对象的内存,16字节对齐。
- 成员变量,8字节对齐,相加不满8字节的优化放在一起,不足的补0。
- 对象与对象,16字节对齐。