0%

《iOS内卷》卷一:从最熟悉的alloc和init聊起

.png

一、从最熟悉的代码入手

iOS开发者接触的第一行代码,除了NSLog(@"Hello workd!")外,大概就是[[NSObject alloc] init]了,这应该是我们最熟悉的代码。但是,对于alloc init,你真的够熟悉吗?

思考以下代码的输出:

1
2
3
4
5
6
7
Person *p1 = [Person alloc] init];
Person *p2 = [p1 init];
Person *p3 = [p1 init];

NSLog(@"%@-%p-%p", p1, p1, &p1);
NSLog(@"%@-%p-%p", p2, p2, &p2);
NSLog(@"%@-%p-%p", p3, p3, &p3);

控制台输出:

1
2
3
<Person: 0x6000003000f0> - 0x6000003000f0 - 0x7ffeee282028
<Person: 0x6000003000f0> - 0x6000003000f0 - 0x7ffeee282020
<Person: 0x6000003000f0> - 0x6000003000f0 - 0x7ffeee282018

看完输出是不是觉得[[NSObject alloc] init]没那么熟悉了?
为什么p1、p2、p3的值是一样的?init咋看起来没啥卵用?allocinit到底做了什么?
按住Control键,想要查看alloc源码,却发现啥也没有,该如何解决?

带着以上问题,我们开始今天的探索之旅。

二、探索的三种思路

符号断点调试

在哪里打断点?断点符号有哪些?
既然我们不知道要将断点打到哪,我们将先对已知的alloc打一个断点,断点类型选择Symbolic Breakpoint

image.png

在断点处按住control + step into,你会发现最后来到了这个地方:

image.png

objc_alloc就是我们需要下的另一个断点。

循环上述步骤,把过程中出现的函数,都打上符号断点,就可以继续探索。

汇编跟踪

汇编跟踪是最直观的方式。虽然有些晦涩难懂,但是关键方法的调用都可以看到。
[Person alloc] init];处打上普通断点,然后勾选Debug -> Debug Workflow -> Always Show Disassembly

image.png

可以看到objc_alloc是下一个要执行的函数。

运行源码

无论是符号断点调试,还是汇编跟踪,调试起来其实都相当麻烦,所以有没有更好的方法?
很多开发者以为苹果是闭源的,但实际上,苹果已逐渐将部分源码开放出来。

我们需要的是Objc4的源码:

image.png

GitHub上有可以直接运行的版本:Objc4

通过运行源码,跟踪调用流程,我们完全摆脱意淫式的底层探索。

三、alloc的主线调用流程

image.png

alloc的调用开始,我们可以在源码中大概找到这些核心方法:
1、alloc

1
2
3
+ (id)alloc {
return _objc_rootAlloc(self);
}

2、_objc_rootAlloc

1
2
3
4
id _objc_rootAlloc(Class cls)
{
return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);
}

3、callAlloc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
#if __OBJC2__
if (slowpath(checkNil && !cls)) return nil;
if (fastpath(!cls->ISA()->hasCustomAWZ())) {
return _objc_rootAllocWithZone(cls, nil);
}
#endif

// No shortcuts available.
if (allocWithZone) {
return ((id(*)(id, SEL, struct _NSZone *))objc_msgSend)(cls, @selector(allocWithZone:), nil);
}
return ((id(*)(id, SEL))objc_msgSend)(cls, @selector(alloc));
}

4、_objc_rootAllocWithZone

1
2
3
4
5
6
7
NEVER_INLINE
id _objc_rootAllocWithZone(Class cls, malloc_zone_t *zone __unused)
{
// allocWithZone under __OBJC2__ ignores the zone parameter
return _class_createInstanceFromZone(cls, 0, nil,
OBJECT_CONSTRUCT_CALL_BADALLOC);
}

5、_class_createInstanceFromZone

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
static ALWAYS_INLINE id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone,
int construct_flags = OBJECT_CONSTRUCT_NONE,
bool cxxConstruct = true,
size_t *outAllocatedSize = nil)
{
ASSERT(cls->isRealized());

// Read class's info bits all at once for performance
bool hasCxxCtor = cxxConstruct && cls->hasCxxCtor();
bool hasCxxDtor = cls->hasCxxDtor();
bool fast = cls->canAllocNonpointer();
size_t size;

//计算obj需要使用的内存空间大小
size = cls->instanceSize(extraBytes);
if (outAllocatedSize) *outAllocatedSize = size;

//开辟内存空间
id obj;
if (zone) {
obj = (id)malloc_zone_calloc((malloc_zone_t *)zone, 1, size);
} else {
obj = (id)calloc(1, size);
}

//此时obj还没有和类绑定在一起,只是单纯的内存区域
if (slowpath(!obj)) {
if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) {
return _objc_callBadAllocHandler(cls);
}
return nil;
}

//obj和类绑定
if (!zone && fast) {
obj->initInstanceIsa(cls, hasCxxDtor);
} else {
// Use raw pointer isa on the assumption that they might be
// doing something weird with the zone or RR.
obj->initIsa(cls);
}

if (fastpath(!hasCxxCtor)) {
return obj;
}

construct_flags |= OBJECT_CONSTRUCT_FREE_ONFAILURE;
return object_cxxConstructFromClass(obj, cls, construct_flags);
}

需要注意的是,虽然我们从源码中看到调用顺序如上面所示,但是实际上的调用顺序要复杂的多。原因是:有一些方法被苹果hook住,做了方法交换,用于苹果自身的某些功能实现。例如:埋点、统计等。

主要关注fixupMessageRef这个方法:

image.png
可以看到,当msgselalloc的时候,它的imp会被替换成objc_alloc。这就解释了,为什么我们调用的明明是alloc,但是通过汇编看到的却是objc_alloc

四、对象大小的计算

在上面的_class_createInstanceFromZone方法中,有这么一句代码:size = cls->instanceSize(extraBytes);,它用来计算初始化当前实例变量需要占用多少内存空间。

1、instanceSize

初始化当前类的实例对象,需要占用多少内存空间。

1
2
3
4
5
6
7
8
9
10
11
inline size_t instanceSize(size_t extraBytes) const {
if (fastpath(cache.hasFastInstanceSize(extraBytes))) {
return cache.fastInstanceSize(extraBytes);
}

//计算需要占用多少内存空间
size_t size = alignedInstanceSize() + extraBytes;
// CF requires all objects be at least 16 bytes.
if (size < 16) size = 16;  
return size;
}

if (size < 16) size = 16;:规定内存分配,最少分配16个字节。

2、alignedInstanceSize

8字节对齐。

1
2
3
4
// Class's ivar size rounded up to a pointer-size boundary.
uint32_t alignedInstanceSize() const {
return word_align(unalignedInstanceSize());
}

注释写得很明白:类的ivar大小向上舍入到指针大小边界。意思是:类ivar的大小最小都是8个字节。
unalignedInstanceSize:未做对齐的实例变量字节大小。

3、word_align

8字节对齐的实现。

1
2
3
static inline uint32_t word_align(uint32_t x) {
return (x + WORD_MASK) & ~WORD_MASK;
}
1
#   define WORD_MASK 7UL

x做8字节对齐:如果x是7,那么对齐之后就是8,如果输入的是12,那么对齐之后就是16。

五、字节对齐算法

我们详细剖析一下这句公式:(x + WORD_MASK) & ~WORD_MASK


假设我们要对7做8字节对齐。从宏定义中已知WORD_MASK7,代入公式得:

1
(7 + 7) & ~7

即:14 & ~7

用二进制表示:

1
2
3
4
   0000 1110
& 1111 1000
------------ = 8
0000 1000

得到结果8
这里最巧妙的就是~7(1111 1000)。任何数按位与~7之后,都会把低三位舍弃掉,这个数就会变成8的倍数了。
同理,后续如果我们需要对x16字节对齐,只需要 (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
2
3
- (id)init {
return _objc_rootInit(self);
}
1
2
3
4
5
6
id _objc_rootInit(id obj)
{
// In practice, it will be hard to rely on this function.
// Many classes do not properly chain -init calls.
return obj;
}

确实啥都没有做,直接把本身返回。从官方英文注解可以看到,很多类根本就没有使用init,所以苹果没有在init里面做任何额外的工作。唯一的作用,可能就是給开发者重写,让开发者做额外的设置。

new

1
2
3
+ (id)new {
return [callAlloc(self, false/*checkNil*/) init];
}

new方法的本质,等同于alloc init。所以,平时使用newalloc init,本质上没有有什么差别,但更推荐alloc init的形式,扩展性、定制性更好。

七、总结

本节主要讨论了:如何探索iOS的底层、alloc的主线流程、以及主线流程中出现的一些技术细节。除了我们已经讨论过的,还有大部分技术细节没有讲述到。探索allocinit,只是万里长征的第一步,但是既然选择了iOS这条路,就坚定的走下去吧 ~

道阻且长,行则将至,行而不辍,未来可期