引言

在Objective - C的世界里,Runtime(运行时系统)犹如幕后的神秘操控者,它赋予了这门语言动态特性,使得很多在编译时无法确定的操作能够在运行时灵活处理。了解Runtime的底层原理,对于深入掌握Objective - C编程、优化代码性能以及解决复杂问题都有着至关重要的意义。本文将深入剖析Runtime中的几个核心概念,包括isa指针、class的结构以及方法缓存。

一、isa指针详解

1.1 isa指针的基本概念

在Objective - C里,几乎所有的对象都是objc_object结构体的实例,而objc_object结构体中最重要的成员就是isa指针。isa指针的主要作用是指向对象所属的类,通过这个指针,对象能够知道自己应该调用哪个类的方法。

下面是objc_object结构体的简化定义:

1
2
3
struct objc_object {
Class isa;
};

这里的Class实际上是一个指向objc_class结构体的指针,也就是说isa指针指向的是一个objc_class类型的实例,这个实例代表了对象所属的类。

1.2 isa的本质

在早期的Objective - C版本中,isa 确实只是一个简单的指针,直接指向对象所属的类。但从64位系统开始,isa 不再仅仅是一个单纯的指针,而是被设计成了一个 isa_t 类型的共用体(union)。共用体的特点是所有成员共享同一块内存空间,这使得 isa 可以在有限的内存空间里存储更多的信息。

isa_t 共用体的简化定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
union isa_t {
Class cls;
uintptr_t bits;
struct {
uintptr_t nonpointer : 1;
uintptr_t has_assoc : 1;
uintptr_t has_cxx_dtor : 1;
uintptr_t shiftcls : 33;
uintptr_t magic : 6;
uintptr_t weakly_referenced : 1;
uintptr_t deallocating : 1;
uintptr_t has_sidetable_rc : 1;
uintptr_t extra_rc : 19;
};
};

1.3 isa中存储的内容

  • nonpointer:这是一个标志位,占1位。当它的值为 0 时,表示 isa 是一个普通的指针,仅存储类的地址;当值为 1 时,意味着 isa 是一个非指针类型,除了存储类的地址外,还会存储其他额外信息。
  • has_assoc:同样是一个标志位,占1位。用于标记对象是否有关联对象。如果对象使用了 objc_setAssociatedObject 方法关联了其他对象,这个标志位会被置为 1。
  • has_cxx_dtor:1位的标志位,用于判断对象是否有 C++ 析构函数。若对象有 C++ 析构函数,该标志位为 1,在对象销毁时需要调用相应的析构函数。
  • shiftcls:占据 33 位,这部分存储的是类的指针值。由于内存对齐的原因,类的地址会有一定的偏移,通过右移操作可以得到真正的类地址。
  • magic:占 6 位,用于调试时判断对象是否已经完成初始化。
  • weakly_referenced:1位标志位,用来标记对象是否被弱引用指向。如果对象被弱引用引用,该标志位为 1。
  • deallocating:1位标志位,指示对象是否正在进行析构操作。
  • has_sidetable_rc:1位标志位,判断对象的引用计数是否存储在 sidetable 中。当对象的引用计数过大,extra_rc 无法存储时,会将部分引用计数存储在 sidetable 中,此时该标志位为 1。
  • extra_rc:占据 19 位,存储对象的引用计数。当对象的引用计数增加时,extra_rc 的值会相应增加。

1.4 isa指针的指向规则

实例对象的isa指针

实例对象的isa指针指向其所属的类。例如,当我们创建一个Person类的实例person时,person对象的isa指针就指向Person类。

类对象的isa指针

类对象的isa指针指向元类(meta - class)。元类是一种特殊的类,它存储了类方法的信息。当我们调用一个类方法时,实际上是通过类对象的isa指针找到对应的元类,然后在元类中查找并调用相应的方法。

元类的isa指针

元类的isa指针指向根元类(root meta - class)。根元类是所有元类的基类,它的isa指针指向自己,形成一个闭环。

1.5 isa指针的走位图

通过一个简单的示例和对应的走位图能更直观地理解isa指针的指向关系。假设我们有一个继承自NSObjectPerson类:

1
2
3
4
5
@interface Person : NSObject
@end

@implementation Person
@end

对应的isa指针走位图如下:

img

二、class的结构

2.1 class的基本结构

在Runtime中,类是通过objc_class结构体来表示的。下面是objc_class结构体的简化定义:

1
2
3
4
5
6
struct objc_class {
Class isa;
Class superclass;
cache_t cache;
class_data_bits_t bits;
};

isa指针

前面已经详细介绍过,它指向类的元类,用于确定类方法的查找路径。

superclass指针

superclass指针指向父类,通过这个指针可以实现类的继承关系。当在当前类中找不到某个方法时,Runtime会沿着superclass指针的路径到父类中继续查找。

cache(方法缓存)

cache是一个cache_t类型的结构体,用于缓存已经调用过的方法,以提高方法查找的效率。后续会详细介绍方法缓存的相关内容。

bits(类的数据信息)

bits是一个class_data_bits_t类型的结构体,它存储了类的具体数据信息,包括方法列表、属性列表、协议列表等。

2.2 从bits中获取类的信息

通过bits可以获取类的各种信息,例如方法列表、属性列表等。以下是一个简单的示例代码,展示如何获取类的方法列表:

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
#import <objc/runtime.h>

@interface Person : NSObject
- (void)sayHello;
@end

@implementation Person
- (void)sayHello {
NSLog(@"Hello!");
}
@end

int main(int argc, const char * argv[]) {
@autoreleasepool {
unsigned int methodCount = 0;
Method *methods = class_copyMethodList([Person class], &methodCount);
for (unsigned int i = 0; i < methodCount; i++) {
Method method = methods[i];
SEL selector = method_getName(method);
NSString *methodName = NSStringFromSelector(selector);
NSLog(@"Method name: %@", methodName);
}
free(methods);
}
return 0;
}

在这个示例中,我们使用class_copyMethodList函数从Person类的bits中获取方法列表,并遍历打印出每个方法的名称。

三、方法缓存(cache_t)

3.1 方法缓存的作用

在Objective - C中,方法调用是一个相对复杂的过程,需要在类及其父类的方法列表中查找对应的方法。为了提高方法调用的效率,Runtime引入了方法缓存机制。当一个方法被调用时,Runtime会将该方法的信息(包括方法名和方法实现)缓存到类的cache中。下次再调用相同方法时,会直接从缓存中查找,避免了重复的方法查找过程,从而显著提高了方法调用的速度。

3.2 cache_t结构体的结构

cache_t结构体的简化定义如下:

1
2
3
4
5
struct cache_t {
struct bucket_t *_buckets;
mask_t _mask;
mask_t _occupied;
};

_buckets

_buckets是一个指向bucket_t结构体数组的指针,bucket_t结构体存储了方法的具体信息,包括方法名(SEL)和方法实现(IMP)。以下是bucket_t结构体的简化定义:

1
2
3
4
struct bucket_t {
SEL _sel;
IMP _imp;
};

_mask

_mask是一个掩码,用于确定_buckets数组的大小。_mask的值通常是2^n - 1的形式,这样在进行哈希计算时可以更高效。

_occupied

_occupied表示当前_buckets数组中已经占用的桶(bucket)的数量。

3.3 cache_t是如何缓存的

缓存插入

当一个方法被调用时,首先会计算该方法选择器(SEL)的哈希值。通常使用 SEL 的值与 _mask 进行按位与运算(hash = _sel & _mask),以此来确定该方法在 _buckets 数组中的初始位置。

如果计算得到的位置为空,那么直接将该方法的信息(SELIMP)存储到这个 bucket 中。但如果该位置已经被占用,就会采用开放寻址法(通常是线性探测)来寻找下一个可用的位置。线性探测就是依次检查下一个位置,直到找到一个空的 bucket 为止。

以下是简化的插入逻辑伪代码:

1
2
3
4
5
6
7
8
hash = _sel & _mask;
index = hash;
while (_buckets[index] is not empty) {
index = (index + 1) & _mask; // 线性探测,寻找下一个位置
}
_buckets[index]._sel = _sel;
_buckets[index]._imp = _imp;
_occupied++;

缓存查找

当再次调用相同方法时,同样会先计算该方法选择器(SEL)的哈希值,使用相同的哈希计算方式(hash = _sel & _mask)得到在 _buckets 数组中的初始位置。

然后从这个位置开始检查 bucket 中的 SEL 是否与要查找的 SEL 相等。如果相等,就表示找到了对应的方法,直接调用该 bucket 中的 IMP。如果不相等,就按照线性探测的方式继续检查下一个位置,直到找到匹配的 SEL 或者遍历完整个 _buckets 数组。

以下是简化的查找逻辑伪代码:

1
2
3
4
5
6
7
8
9
hash = _sel & _mask;
index = hash;
while (_buckets[index] is not empty) {
if (_buckets[index]._sel == _sel) {
return _buckets[index]._imp; // 找到方法实现,返回
}
index = (index + 1) & _mask; // 线性探测,检查下一个位置
}
return nil; // 未找到方法实现

缓存扩容

_occupied 的值达到一定阈值(通常是 _mask3/4)时,cache_t 会进行扩容操作。扩容时会创建一个更大的 _buckets 数组,新数组的大小一般是原来的两倍。

然后将原数组中的所有 bucket 重新哈希到新数组中。重新哈希的过程就是对原数组中的每个 bucket 重新计算哈希值,然后根据新的 _mask 确定在新数组中的位置,并将其存储到新位置。

以下是简化的扩容逻辑伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
oldBuckets = _buckets;
oldMask = _mask;
// 创建新的 _buckets 数组,大小为原来的两倍
_newBuckets = createNewBuckets(oldMask * 2 + 1);
_mask = oldMask * 2 + 1;
_occupied = 0;
// 遍历原数组,将每个 bucket 重新哈希到新数组中
for (i = 0; i <= oldMask; i++) {
if (oldBuckets[i] is not empty) {
insertIntoNewBuckets(oldBuckets[i]._sel, oldBuckets[i]._imp);
}
}
free(oldBuckets);

四、总结

Objective - C的Runtime系统是一个复杂而强大的机制,其中isa指针、class的结构以及方法缓存是Runtime的核心组成部分。isa指针确定了对象、类和元类之间的关系,其本质是一个 isa_t 共用体,存储了丰富的信息,是方法调用的基础;class的结构存储了类的各种信息,包括方法、属性和协议等;方法缓存则提高了方法调用的效率,减少了不必要的方法查找开销。深入理解这些概念,有助于我们更好地掌握Objective - C的动态特性,优化代码性能,解决开发中遇到的各种问题。

希望通过本文的介绍,你能对Objective - C Runtime有更深入的认识,在实际开发中能够更加灵活地运用这些知识。