没有对象怎么办?new 一个,在 Objective-C 中我们可以通过 alloc 或 new 创建一个对象,那么问题来了?它底层是怎么实现的呢?

overview

在 iOS 开发中,当我们创建一个类的实例的时候,会不假思索的使用以下两种来创建对象。

1
2
[XXObject alloc] init];
[XXObject new];

question

先来个思考题,对于如下的代码,输出结果与你分析的一样么?

1
2
3
4
5
6
RDPerson *p1 = [RDPerson alloc];
RDPerson *p2 = [p1 init];
RDPerson *p3 = [p1 init];
Log(@"%@ -- %p -- %p",p1, p1, &p1);
Log(@"%@ -- %p -- %p",p2, p2, &p2);
Log(@"%@ -- %p -- %p",p3, p3, &p3);

Log 语句打印的第一个元素是变量指向的对象,第二个元素是指向对象的内存地址,第三个是元素变量的内存地址。
其中 RDPerson 为继承于 NSObject 的一个类,目前没有任何属性和方法。

可能你对 %@ %p 的格式含义不清楚。

  • %@ 是打印指定对象的 description 属性,一般是一个字符串。
  • %p 是以 0x 开头,打印出指针的值,也就是内存地址。
  • 更多可以参考官网文档对 String Format Specifiers 的描述。

Log 是一个宏定义,是对 printf 函数的封装。

1
2
3
4
5
#ifdef DEBUG
#define Log(format, ...) printf("%s\n", [[NSString stringWithFormat:format, ## __VA_ARGS__] UTF8String]);
#else
#define Log(format, ...);
#endif

从左往右,第三列是打印变量 p1, p2, p3 的内存地址,肯定是不相同的。那么第一列和第二列的打印内容是否是相同的?或者不同的呢?记一下你现在的思考答案。

代码的运行结果出来了,看有没有出乎你所料。

1
2
3
<RDPerson: 0x10123c230> -- 0x10123c230 -- 0x7ffeefbff528
<RDPerson: 0x10123c230> -- 0x10123c230 -- 0x7ffeefbff520
<RDPerson: 0x10123c230> -- 0x10123c230 -- 0x7ffeefbff518

根据打印结果可以看出,变量 p1, p2, p3 中存储的都是 RDPerson 对象的内存地址,也就是它们指向了同一个对象。

这三个变量的地址是依次递减的,也印证了之前说的,变量是存储在栈上,栈底是高地址,栈顶是低地址,它们是由高到低进行分布的。

alloc 的流程是什么样的? init 又做了啥?通过 Apple 开源的 runtime 源码 objc4, 我们可以一探究竟,本文参考的是 objc-781 版本。

alloc

NSObject.mm 文件中,我们可以找到 alloc 的代码实现

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

跟着这个调用路径,我们可以挖掘出如下调用流程:

1
2
3
4
1 _objc_rootAlloc(self)
2 callAlloc(cls, false, true)
3 _objc_rootAllocWithZone(cls, nil)
4 _class_createInstanceFromZone(cls, 0, nil, OBJECT_CONSTRUCT_CALL_BADALLOC)

比较关键的方法是 _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
_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;

// 1、计算需要开辟内存空间的大小,以16字节对齐
size = cls->instanceSize(extraBytes);
if (outAllocatedSize) *outAllocatedSize = size;

id obj;
if (zone) {
obj = (id)malloc_zone_calloc((malloc_zone_t *)zone, 1, size);
} else {
// 2、申请内存空间
obj = (id)calloc(1, size);
}
if (slowpath(!obj)) {
if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) {
return _objc_callBadAllocHandler(cls);
}
return nil;
}

if (!zone && fast) {
// 3、初始化isa,关联对象
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);
}

fastpath 和 slowpath 是啥?找找声明的地方。

1
2
#define fastpath(x) (__builtin_expect(bool(x), 1))
#define slowpath(x) (__builtin_expect(bool(x), 0))

这是两个宏定义。__builtin_expect 又是什么呢?通过查询 gcc 资料得知,__builtin_expect 是 gcc 编译器的内置函数,用于程序员将分支预测信息告诉编译器,从而让编译器优化我们的代码,提高指令跳转性能。在底层我们知道,分支语句都是通过 jmp 跳转指令来实现的,jmp 要跳转的指令地址越近,做的计算越少,则性能越好。

内存空间计算

第一步,对象占用内存的空间是怎么计算的呢?这引起了我的兴趣。

1
2
3
4
5
6
7
8
9
10
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;
}

这里计算的内存空间至少是 16 字节的。size_t 是啥?

1
typedef __SIZE_TYPE__ size_t;
1
2
3
#ifndef __SIZE_TYPE__
#define __SIZE_TYPE__ long unsigned int
#endif

stddef.h 文件中可以找到 \_\_SIZE_TYPE__ 的宏定义,因此 size_t 类型也就是 long unsigned int 的 typedef。

接着看看 cache.fastInstanceSize 做了啥?

1
2
3
4
5
6
7
8
9
10
11
12
13
size_t fastInstanceSize(size_t extra) const
{
ASSERT(hasFastInstanceSize(extra));

if (__builtin_constant_p(extra) && extra == 0) {
return _flags & FAST_CACHE_ALLOC_MASK16;
} else {
size_t size = _flags & FAST_CACHE_ALLOC_MASK;
// remove the FAST_CACHE_ALLOC_DELTA16 that was added
// by setFastInstanceSize
return align16(size + extra - FAST_CACHE_ALLOC_DELTA16);
}
}

__builtin_constant_p 是 gcc 内置函数,用于判断是否是编译器常量。

解开神秘计算的最后一道面纱了,align16 函数。

1
2
3
static inline size_t align16(size_t x) {
return (x + size_t(15)) & ~size_t(15);
}

看上去一眼懵逼,知道是个计算,啥意思,笨办法,代入 x 一个值,看看啥效果。这里假如 x = 9,别问为啥是 9,我的幸运数字。当然你可以随便选个数字。

1
2
3
4
5
0000 0000 0000 0000 0000 0000 0000 1001   // 9 用 32 位二进制表示
0000 0000 0000 0000 0000 0000 0000 1111 // 15 用32位二进制表示
0000 0000 0000 0000 0000 0000 0001 1000 // 9 + 15 = 24
1111 1111 1111 1111 1111 1111 1111 0000 // 15 取反的二进制表示
0000 0000 0000 0000 0000 0000 0001 0000 // 按位与的结果,是 16,也就是输入9,输出 16

我们可以得出一个结论,align16 的作用是以 16 为基准,进行向上取整。小于 16 的数字,取 16,大于 16 小于 32 的数字取 32,以此类推。

为什么要这么处理呢?这就涉及到了内存字节对齐的知识了。感兴趣的可以查查为什么内存要字节对齐呢。

内存申请

第二步中,calloc 是 c 标准库函数,void *calloc(size_t __count, size_t __size) 分配内存空间,并返回一个指向它的指针。其中第一个参数是要被分配的元素的个数,第二个参数是元素的大小。

初始化 isa

第三步,initIsa 是设置生成对象 obj 的 isa。通过源码,发现最终调用的是这个函数。

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
inline void 
objc_object::initIsa(Class cls, bool nonpointer, bool hasCxxDtor)
{
ASSERT(!isTaggedPointer());

if (!nonpointer) {
isa = isa_t((uintptr_t)cls);
} else {
ASSERT(!DisableNonpointerIsa);
ASSERT(!cls->instancesRequireRawIsa());

isa_t newisa(0);

#if SUPPORT_INDEXED_ISA
ASSERT(cls->classArrayIndex() > 0);
newisa.bits = ISA_INDEX_MAGIC_VALUE;
newisa.has_cxx_dtor = hasCxxDtor;
newisa.indexcls = (uintptr_t)cls->classArrayIndex();
#else
newisa.bits = ISA_MAGIC_VALUE;
newisa.has_cxx_dtor = hasCxxDtor;
newisa.shiftcls = (uintptr_t)cls >> 3;
#endif

isa = newisa;
}
}

根据前面的分析,我们知道 shiftcls 存储的是对象所属的类对象的地址。在这里给 shiftcls 赋值的时候,为什么 cls 要右移三位呢?

很多人估计想的是因为 isa 前三位表示的是 nonpointer, has_assoc, has_cxx_dtor 所以要避开这三位存储。这么想的同学,你忽略了 isa 的结构,isa 是联合体位域,给位域的元素赋值,就是给位域中的指定位赋值。

这里真正的原因是: 类的指针要按照字节 (8bits) 内存对齐,所以任何对象的后三位都是 0,右移三位的原因是舍弃无意义数据,减小内存的的消耗。不信的同学,你可以多创建一个对象,然后观察对象的地址,看看是不是有这样的规律。

其中 uintptr_t 和 isa_t 的定义如下:

1
typedef unsigned long           uintptr_t;
1
2
3
4
5
6
7
8
9
10
11
12
13
//isa_t 定义 联合
union isa_t {
isa_t() { } // 构造函数 1
isa_t(uintptr_t value) : bits(value) { } // 构造函数 2

Class cls;
uintptr_t bits;
#if defined(ISA_BITFIELD)
struct {
ISA_BITFIELD; // defined in isa.h
};
#endif
};

这里的 struct 和 ISA_BITFIELD 是定义了一个位域的数据结构,后面的文章会详细说明 isa 的结构的。

initIsa 方法根据是否是 nonpointer 走不同的 isa 生成逻辑。如果不是 nonpointer 的话,isa 直接赋值为 cls 的地址。 这下真相大白了。

new

通过 new 创建对象的流程是咋样的呢?在源码面前了无秘密。

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

所以使用 new 就相当于 [[XXX alloc] init]。如果子类的实现中重写了 init 方法,new 会调用子类的 init 方法。没有重写则调用父类方法。

如果你有携带初始化属性的 initWithXXX 初始化方法,使用 new 来创建对象则是不是调用这个方法的。

init

init 的作用是啥呢?字面理解是初始化数据,看看源码是否如我们猜想的?

1
2
3
4
5
6
7
8
9
10
11
12
+ (id)init {
return (id)self;
}

- (id)init {
return _objc_rootInit(self);
}

id _objc_rootInit(id obj)
{
return obj;
}

居然什么也没做,仅仅返回了 self 而已,这是为什么呢?这是一个不错的思考题。

sum-up

通过一幅图,我们可以总结 alloc 的调用流程。

reference

这是我写这篇文章时用到的资料,感兴趣可以看看。

我是穆哥,卖码维生的一朵小浪花,我们下期见。