一、从最熟悉的代码入手
iOS开发者接触的第一行代码,除了NSLog(@"Hello workd!")
外,大概就是[[NSObject alloc] init]
了,这应该是我们最熟悉的代码。但是,对于alloc init
,你真的够熟悉吗?
思考以下代码的输出:
1 | Person *p1 = [Person alloc] init]; |
控制台输出:
1 | <Person: 0x6000003000f0> - 0x6000003000f0 - 0x7ffeee282028 |
看完输出是不是觉得[[NSObject alloc] init]
没那么熟悉了?
为什么p1、p2、p3的值是一样的?init
咋看起来没啥卵用?alloc
和init
到底做了什么?
按住Control键,想要查看alloc
源码,却发现啥也没有,该如何解决?
带着以上问题,我们开始今天的探索之旅。
二、探索的三种思路
符号断点调试
在哪里打断点?断点符号有哪些?
既然我们不知道要将断点打到哪,我们将先对已知的alloc
打一个断点,断点类型选择Symbolic Breakpoint
。
在断点处按住control
+ step into
,你会发现最后来到了这个地方:
objc_alloc
就是我们需要下的另一个断点。
循环上述步骤,把过程中出现的函数,都打上符号断点,就可以继续探索。
汇编跟踪
汇编跟踪是最直观的方式。虽然有些晦涩难懂,但是关键方法的调用都可以看到。
在[Person alloc] init];
处打上普通断点,然后勾选Debug -> Debug Workflow -> Always Show Disassembly
:
可以看到objc_alloc
是下一个要执行的函数。
运行源码
无论是符号断点调试,还是汇编跟踪,调试起来其实都相当麻烦,所以有没有更好的方法?
很多开发者以为苹果是闭源的,但实际上,苹果已逐渐将部分源码开放出来。
我们需要的是Objc4
的源码:
GitHub上有可以直接运行的版本:Objc4
通过运行源码,跟踪调用流程,我们完全摆脱意淫式的底层探索。
三、alloc的主线调用流程
从alloc
的调用开始,我们可以在源码中大概找到这些核心方法:
1、alloc
1 | + (id)alloc { |
2、_objc_rootAlloc
1 | id _objc_rootAlloc(Class cls) |
3、callAlloc
1 | static ALWAYS_INLINE id |
4、_objc_rootAllocWithZone
1 | NEVER_INLINE |
5、_class_createInstanceFromZone
1 | static ALWAYS_INLINE id |
需要注意的是,虽然我们从源码中看到调用顺序如上面所示,但是实际上的调用顺序要复杂的多。原因是:有一些方法被苹果hook住,做了方法交换,用于苹果自身的某些功能实现。例如:埋点、统计等。
主要关注fixupMessageRef
这个方法:
可以看到,当msg
的sel
是alloc
的时候,它的imp
会被替换成objc_alloc
。这就解释了,为什么我们调用的明明是alloc
,但是通过汇编看到的却是objc_alloc
。
四、对象大小的计算
在上面的_class_createInstanceFromZone
方法中,有这么一句代码:size = cls->instanceSize(extraBytes);
,它用来计算初始化当前实例变量需要占用多少内存空间。
1、instanceSize
初始化当前类的实例对象,需要占用多少内存空间。
1 | inline size_t instanceSize(size_t extraBytes) const { |
if (size < 16) size = 16;
:规定内存分配,最少分配16个字节。
2、alignedInstanceSize
8字节对齐。
1 | // Class's ivar size rounded up to a pointer-size boundary. |
注释写得很明白:类的ivar大小向上舍入到指针大小边界。意思是:类ivar的大小最小都是8个字节。unalignedInstanceSize
:未做对齐的实例变量字节大小。
3、word_align
8字节对齐的实现。
1 | static inline uint32_t word_align(uint32_t x) { |
1 | # define WORD_MASK 7UL |
对x
做8字节对齐:如果x
是7,那么对齐之后就是8,如果输入的是12,那么对齐之后就是16。
五、字节对齐算法
我们详细剖析一下这句公式:(x + WORD_MASK) & ~WORD_MASK
。
假设我们要对
7
做8字节对齐。从宏定义中已知WORD_MASK
是7
,代入公式得:
1 | (7 + 7) & ~7 |
即:14 & ~7
用二进制表示:
1 | 0000 1110 |
得到结果8
。
这里最巧妙的就是~7(1111 1000)
。任何数按位与~7
之后,都会把低三位舍弃掉,这个数就会变成8的倍数了。
同理,后续如果我们需要对x
16字节对齐,只需要 (x + 15) & ~15
就行了。
除了(x + 7) & ~7
外,8字节对齐还有另外一种写法:
1 | (x + 7) >> 3 << 3 |
也是同样的原理,将低三位的数字进行舍弃。
为什么以8为倍数对齐?
有些同学可能会有疑问,为什么以8为倍数对齐,而不是以16、以32对齐呢?又或者为什么不以7为倍数对齐呢?
这个问题其实可以拆分成两个小问题:
1、为什么要对齐?。
计算机的IO操作是非常消耗资源的。如果计算机每次读取数据时,都要根据数据类型的大小进行读取,那么对IO操作无疑雪上加霜。如果我们我们将内存空间分割成一个个小格子,将不同大小的数据都放进这些小格子,那么计算机读取数据时,只需要以格子为单位进行读取就行了,而不需要判断具体数据类型的大小。字节对齐的作用可以理解为:将数据放进这些固定大小的格子里,虽然有些格子的空间可能有些浪费,但是这将大大提高读取的速度,这就是典型的以空间换时间。
2、为什么是以8字节对齐,而不是以16字节对齐?
在Arm64
中,数据的类型有char
(1字节),int
(4字节),long long
(8字节)等等,这些数据类型最大也不过是8字节。而最关键的点在于,使用最频繁的指针,大小是8个字节。以8字节对齐,足以囊括所有基本数据类型。
因此,没必要以16或32为倍数,浪费额外的内存空间。
六、alloc init与new的区别
alloc init
从最开始的例子可以看到,我们对p1调用了两次init,但是对p1的内存地址没有任何影响。
init是不是啥也没做?
1 | - (id)init { |
1 | id _objc_rootInit(id obj) |
确实啥都没有做,直接把本身返回。从官方英文注解可以看到,很多类根本就没有使用init
,所以苹果没有在init
里面做任何额外的工作。唯一的作用,可能就是給开发者重写,让开发者做额外的设置。
new
1 | + (id)new { |
new
方法的本质,等同于alloc init
。所以,平时使用new
和alloc init
,本质上没有有什么差别,但更推荐alloc init
的形式,扩展性、定制性更好。
七、总结
本节主要讨论了:如何探索iOS的底层、alloc的主线流程、以及主线流程中出现的一些技术细节。除了我们已经讨论过的,还有大部分技术细节没有讲述到。探索alloc
和init
,只是万里长征的第一步,但是既然选择了iOS这条路,就坚定的走下去吧 ~
道阻且长,行则将至,行而不辍,未来可期