12-探究iOS底层原理|Runtime【isa详解、class的结构、方法缓存cache_t】

前言

之前,我们在探索动画及渲染相关原理的时候,我们输出了几篇文章,解答了iOS动画是如何渲染,特效是如何工作的疑惑。我们深感系统设计者在创作这些系统框架的时候,是如此脑洞大开,也 深深意识到了解一门技术的底层原理对于从事该方面工作的重要性。

因此我们决定 进一步探究iOS底层原理的任务。在这篇文章中我们围绕Runtime展开,会逐个探索:isa详解class的结构方法缓存cache_tobjc_msgSend消息转发动态方法解析super的本质Runtime的相关应用

一、Runtime简介

1. OC语言的本质回顾

我们 之前在 探索 OC语言的本质时,了解到Apple官网对OC的介绍: image.png

  • Objective-C是程序员在为OS X和iOS编写软件时使用的主要编程语言(之一,现在已经还有Swift语言)
  • 是C编程语言的超集,提供面向对象的功能和动态运行
  • Objective-C继承了C语言的语法基本类型流控制语句,并添加了用于定义类和方法的语法。(OC完全兼容标准C语言)
  • 它还增加了面向对象管理和对象字面量的语言级别支持,同时提供动态类型和绑定,将许多责任推迟到运行时

2. Runtime

官网中介绍OC语言时,提及的动态运行动态类型和绑定、将许多责任推迟到运行时等许多运行时特性,就是讲通过Runtime这套底层API来实现的。

虽然,Objective-C是一门闭源的语言,但官方也对该语言有了适当的开源。我们通常可以通过该地址去查找苹果官方开源的一些源码:opensource.apple.com/tarballs/

image.png 通过全局搜索 objc,可以找到objc4,然后下载最新的开源版本代码 image.png 我们可以从官方开源的代码中也可以看到 官方开源的一些实现,其中就包含了runtime的一些实现 image.png

综上,我们不难得出结论:

  • Objective-C是一门动态性比较强的编程语言,跟C、C++等语言有着很大的不同;
  • Objective-C的动态性是由Runtime API来支撑的
  • Runtime API提供的接口基本都是C语言的,源码由C\C++\汇编语言编写

二、isa详解

前面,我们在探索 OC中的几种对象和对象的isa指针的时候得出一些结论,我们简单回顾一下: Objective-C中的对象,简称OC对象,主要可以分为3种

  • instance对象(实例对象)
  • class对象(类对象)
  • meta-class对象(元类对象)

1. instance对象

instance对象就是通过类alloc出来的对象,每次调用alloc都会产生新的instance对象

image.png

  • object1、object2是NSObject的instance对象(实例对象)
  • 它们是不同的两个对象,分别占据着两块不同的内存
    image.png image.png
  • instance对象在内存中存储的信息包括
    • isa指针
    • 其他成员变量

2. class对象

image.png

扫描二维码关注公众号,回复: 14359999 查看本文章
  • objectClass1 ~ objectClass5都是NSObject的class对象(类对象)
  • 它们是同一个对象。每个类在 内存中有且只有一个 class对象

image.png

  • class对象在内存中存储的信息主要包括:
    • isa指针
    • superclass指针
    • 类的属性信息(@property)、类的对象方法信息(instance method)
    • 类的协议信息(protocol)、类的成员变量信息(ivar)
    • ......

3. meta-class对象

image.png

  • objectMetaClass是NSObject的meta-class对象(元类对象)
  • 每个类在内存中有且只有一个meta-class对象

image.png

  • meta-class对象和class对象的内存结构是一样的,但是用途不一样,在内存中存储的信息主要包括
    • isa指针
    • superclass指针
    • 类的类方法信息(class method)
    • ......

4. isa指针

  • instanceisa指向class

    • 当调用对象方法时,通过instanceisa找到class,最后找到对象方法的实现进行调用
  • classisa指向meta-class

    • 当调用类方法时,通过classisa找到meta-class,最后找到类方法的实现进行调用

class对象的superclass指针

image.png

  • 当Student的instance对象要调用Person的对象方法时,会先通过isa找到Student的class
  • 然后通过superclass找到Person的class,最后找到对象方法的实现进行调用

meta-class对象的superclass指针

image.png

  • 当Student的class要调用Person的类方法时,会先通过isa找到Student的meta-class
  • 然后通过superclass找到Person的meta-class,最后找到类方法的实现进行调用

5. 对isasuperclass总结

isa

  • instanceisa指向class
  • classisa指向meta-class
  • meta-classisa指向基类的meta-class

superclass

  • classsuperclass指向父类的class
    • 如果没有父类,superclass指针为nil
  • meta-classsuperclass指向父类的meta-class
    • 基类的meta-class的superclass指向基类的class

方法调用

  • instance调用对象方法的轨迹

    • isa找到class,方法不存在,就通过superclass找父类
  • class调用类方法的轨迹

    • isa找meta-class,方法不存在,就通过superclass找父类

6. 综上

结合前面的结论,我们不难得知,OC语言中的三类对象,是通过isa指针建立联系的,而OC的运行时特性所依赖的的RuntimeAPI正是在一定程度上基于isa指针建立的三类对象的联系,实现 动态运行时的。

因此,要想学习Runtime,首先要了解它底层的一些常用数据结构,比如isa指针

  • 在arm64架构之前,isa就是一个普通的指针,存储着ClassMeta-Class对象的内存地址
  • 从arm64架构开始,对isa进行了优化,变成了一个共用体(union)结构,还使用位域来存储更多的信息。需要通过ISA_MASK进行一定的位运算才能进一步获取具体的信息

image.png

7. isa的本质

在arm64架构之后 OC对象的isa指针并不是直接指向类对象或者元类对象,而是需要&ISA_MASK通过位运算才能获取到类对象或者元类对象的地址。
今天来探寻一下为什么需要&ISA_MASK才能获取到类对象或者元类对象的地址,以及这样的好处。(苹果官方为什么做这个优化呢?我们来一步一步探索一下!)

首先在源码中找到isa指针,看一下isa指针的本质。

// 截取objc_object内部分代码
struct objc_object {
private:
    isa_t isa;
} 

isa指针其实是一个isa_t类型的共用体,来到isa_t内部查看其结构

// 精简过的isa_t共用体
union isa_t 
{
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    Class cls;
    uintptr_t bits;

#if SUPPORT_PACKED_ISA
# if __arm64__      
#   define ISA_MASK        0x0000000ffffffff8ULL
#   define ISA_MAGIC_MASK  0x000003f000000001ULL
#   define ISA_MAGIC_VALUE 0x000001a000000001ULL
    struct {
        uintptr_t nonpointer        : 1;
        uintptr_t has_assoc         : 1;
        uintptr_t has_cxx_dtor      : 1;
        uintptr_t shiftcls          : 33; // MACH_VM_MAX_ADDRESS 0x1000000000
        uintptr_t magic             : 6;
        uintptr_t weakly_referenced : 1;
        uintptr_t deallocating      : 1;
        uintptr_t has_sidetable_rc  : 1;
        uintptr_t extra_rc          : 19;
    #       define RC_ONE   (1ULL<<45)
    #       define RC_HALF  (1ULL<<18)
    };

# elif __x86_64__     
#   define ISA_MASK        0x00007ffffffffff8ULL
#   define ISA_MAGIC_MASK  0x001f800000000001ULL
#   define ISA_MAGIC_VALUE 0x001d800000000001ULL
    struct {
        uintptr_t nonpointer        : 1;
        uintptr_t has_assoc         : 1;
        uintptr_t has_cxx_dtor      : 1;
        uintptr_t shiftcls          : 44; // MACH_VM_MAX_ADDRESS 0x7fffffe00000
        uintptr_t magic             : 6;
        uintptr_t weakly_referenced : 1;
        uintptr_t deallocating      : 1;
        uintptr_t has_sidetable_rc  : 1;
        uintptr_t extra_rc          : 8;
#       define RC_ONE   (1ULL<<56)
#       define RC_HALF  (1ULL<<7)
    };

# else
#   error unknown architecture for packed isa
# endif
#endif 

上述源码中isa_tunion类型,union表示共用体。

从源码中我们可以看到:

  • 共用体中有一个结构体
  • 结构体内部分别定义了一些变量
  • 变量后面的值代表的是该变量占用多少个字节,也就是位域技术

了解共用体

  • 在进行某些算法的C语言编程的时候,需要使几种不同类型的变量的值存放到同一段内存单元中;
  • 这种几个不同的变量共同占用一段内存的结构,在C语言中,被称作“共用体”类型结构,简称共用体

接下来使用共用体的方式来深入的了解apple为什么要使用共用体,以及使用共用体的好处。

7.1 探寻过程

7.1.1 模仿底层对数据的存储

接下来使用代码来模仿底层的做法,创建一个person类并含有三个BOOL类型的成员变量。

@interface Person : NSObject
@property (nonatomic, assign, getter = isTall) BOOL tall;
@property (nonatomic, assign, getter = isRich) BOOL rich;
@property (nonatomic, assign, getter = isHansome) BOOL handsome;
@end 
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSLog(@"%zd", class_getInstanceSize([Person class]));
    }
    return 0;
}
// 打印内容
// Runtime - union探寻[52235:3160607] 16

上述代码中Person含有3个BOOL类型的属性,打印Person类对象占据内存空间为16

  • 也就是(isa指针 = 8) + (BOOL tall = 1) + (BOOL rich = 1) + (BOOL handsome = 1) = 13
  • 因为内存对齐原则所以Person类对象占据内存空间为16(关于这内存对齐相关的知识,我们在这篇文章介绍过)

通过共用体技术,可以使几个不同的变量存放到同一段内存中去,可以很大程度上节省内存空间

尝试用一个字节存储三个BOOL类型的变量的值

  • 那么我们知道BOOL值只有两种情况 0 或者 1,但是却占据了一个字节的内存空间
  • 而一个内存空间中有8个二进制位,并且二进制只有 0 或者 1
    • 那么是否可以使用1个二进制位来表示一个BOOL值
    • 也就是说3个BOOL值最终只使用3个二进制位,也就是一个内存空间即可呢?
    • 如何实现这种方式?

首先如果使用这种方式 需要自己写setter、getter方法的声明与实现:

  • 不可以写属性声明,因为一旦写属性,系统会自动帮我们添加成员变量(会开辟内存空间、也会实现setter、getter。我为了探索要规避系统的自动生成)

另外想要将三个BOOL值存放在一个字节中,我们可以添加一个char类型的成员变量

  • char类型占据一个字节内存空间,也就是8个二进制位
  • 可以使用其中最后三个二进制位来存储3个BOOL值。
@interface Person()
{
    char _tallRichHandsome;
} 

例如_tallRichHansome的值为 0b 0000 0010 ,那么只使用8个二进制位中的最后3个,分别为其赋值0或者1来代表tall、rich、handsome的值。如下图所示:

存储方式

那么现在面临的问题就是如何取出8个二进制位中的某一位的值,或者为某一位赋值呢?

a.) 取值

假如将三个BOOL变量的值 存在 一个字节里面,我们首先探讨一下如何从一个字节里面 取出 这三个变量的具体值。

可以使用1个二进制位来表示一个BOOL值,那么从低位开始,一个二进制位代表一个值。

  • 假如char类型的成员变量中存储的二进制为0b 0000 0010
  • 如果想将倒数第2位的值也就是rich的值取出来,则需要进行 进制位的 位运算
  • 我们可以使用&进行按位与运算进而取出相应位置的值

了解【&:按位与】 同真为真,其他都为假

// 示例
// 取出倒数第三位 tall
  0000 0010
& 0000 0100
------------
  0000 0000  // 取出倒数第三位的值为0,其他位都置为0

// 取出倒数第二位 rich
  0000 0010
& 0000 0010
------------
  0000 0010 // 取出倒数第二位的值为1,其他位都置为0

结论: 按位与可以用来取出特定的二进制位的值

  • 想取出哪一位就将那一位置为1,其他为都置为0
  • 然后同原数据进行按位与计算,即可取出特定的位

按位与 运算来实现get方法

#define TallMask 0b00000100 // 4
#define RichMask 0b00000010 // 2
#define HandsomeMask 0b00000001 // 1

- (BOOL)tall
{
    return !!(_tallRichHandsome & TallMask);
}
- (BOOL)rich
{
    return !!(_tallRichHandsome & RichMask);
}
- (BOOL)handsome
{
    return !!(_tallRichHandsome & HandsomeMask);
} 

上述代码中使用两个!!(非)来将值改为bool类型。同样使用上面的例子

// 取出倒数第二位 rich
  0000 0010  // _tallRichHandsome
& 0000 0010 // RichMask
------------
  0000 0010 // 取出rich的值为1,其他位都置为0 

上述代码中(_tallRichHandsome & TallMask)的值为0000 0010也就是2,但是我们需要的是一个BOOL类型的值 0 或者 1

  • 那么!!2就将 2 先转化为 0 ,之后又转化为 1
  • 相反如果按位与取得的值为 0 时,!!0将 0 先转化为 1 之后又转化为 0
  • 因此使用!!两个非操作将值转化为 0 或者 1 来表示相应的值。

7.1.2 优化掩码,使其增加可读性

掩码: 一般用来 进行 按位与(&)运算的值称之为掩码

  • 上述代码中定义了三个宏,用来分别进行按位与运算而取出相应的值
  • 三个宏的具体值都是掩码
  • 为了能更清晰的表明掩码是为了取出哪一位的值,上述三个宏的定义可以使用左移运算符:<<来优化

左移运算符 A<<n ,表示在A数值的二进制数据中左移n位得到一个值

<<左移操作符示例

那么上述宏定义可以使用<<(左移)优化成如下代码

#define TallMask (1<<2) // 0b00000100 4
#define RichMask (1<<1) // 0b00000010 2
#define HandsomeMask (1<<0) // 0b00000001 1 
b.) 设值

我们如果想给某一个二进制位赋值 0或者1,依然可以使用 位运算

如果想设置某个值的某个二进制位的值为1,那么只要在该二进制位 与1进行 |(按位或运算即可

按位或 运算: | : 按位或,只要有一个1即为1,否则为0。

在当前谈论的案例中,也可以说:
如果想设置BOOL值为YES的话,那么将原本的值与掩码(该位置二进制位的值是1)进行按位或的操作即可。
例如我们想将tall置为1

// 将倒数第三位 tall置为1
  0000 0010  // _tallRichHandsome
| 0000 0100  // TallMask
------------
  0000 0110 // 将tall置为1,其他位值都不变 

按位与 运算: &: 按位与,同真为真,其他都为假

在当前谈论的案例中,也可以说:
如果想设置BOOL值为NO的话,需要将掩码按位取反(~ : 按位取反符)(该位置二进制位的值即变成0),之后在与原本的值进行按位与操作即可。

// 将倒数第二位 rich置为0
  0000 0010  // _tallRichHandsome
& 1111 1101  // RichMask按位取反
------------
  0000 0000 // 将rich置为0,其他位值都不变

此时set方法内部实现如下

- (void)setTall:(BOOL)tall
{
    if (tall) { // 如果需要将值置为1  // 按位或掩码
        _tallRichHandsome |= TallMask;
    }else{ // 如果需要将值置为0 // 按位与(按位取反的掩码)
        _tallRichHandsome &= ~TallMask; 
    }
}
- (void)setRich:(BOOL)rich
{
    if (rich) {
        _tallRichHandsome |= RichMask;
    }else{
        _tallRichHandsome &= ~RichMask;
    }
}
- (void)setHandsome:(BOOL)handsome
{
    if (handsome) {
        _tallRichHandsome |= HandsomeMask;
    }else{
        _tallRichHandsome &= ~HandsomeMask;
    }
} 

写完set、get方法之后通过代码来查看一下是否可以设值、取值成功。

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *person  = [[Person alloc] init];
        person.tall = YES;
        person.rich = NO;
        person.handsome = YES;
        NSLog(@"tall : %d, rich : %d, handsome : %d", person.tall,person.rich,person.handsome);
    }
    return 0;
} 

打印内容

Runtime - union探寻[58212:3857728] tall : 1, rich : 0, handsome : 1

可以看出上述代码可以正常赋值和取值。但是代码还是有一定的局限性:

  • 当需要添加新属性的时候,需要重复上述工作,并且代码可读性比较差
  • 接下来使用结构体的位域特性来优化上述代码

7.1.3 用 位域 技术 实现 变量的 存取

将上述代码进行优化,使用结构体位域,可以使代码可读性更高。 位域声明 位域名 : 位域长度;

使用位域需要注意以下3点:

  • 1. 如果一个字节所剩空间不够存放另一位域时,应从下一单元起存放该位域。
    • 也可以有意使某位域从下一单元开始
    1. 位域的长度不能大于数据类型本身的长度
    • 比如int类型就不能超过32位二进位。
  • 3. 位域可以无位域名,这时它只用来作填充或调整位置
    • 无名的位域是不能使用的

上述代码使用结构体位域优化之后。

@interface Person()
{
    struct {
        char handsome : 1; // 位域,代表占用一位空间
        char rich : 1;  // 按照顺序只占一位空间
        char tall : 1; 
    }_tallRichHandsome;
} 

set、get方法中可以直接通过结构体赋值和取值

- (void)setTall:(BOOL)tall
{
    _tallRichHandsome.tall = tall;
}
- (void)setRich:(BOOL)rich
{
    _tallRichHandsome.rich = rich;
}
- (void)setHandsome:(BOOL)handsome
{
    _tallRichHandsome.handsome = handsome;
}
- (BOOL)tall
{
    return _tallRichHandsome.tall;
}
- (BOOL)rich
{
    return _tallRichHandsome.rich;
}
- (BOOL)handsome
{
    return _tallRichHandsome.handsome;
} 

通过代码验证一下是否可以赋值或取值正确

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *person  = [[Person alloc] init];
        person.tall = YES;
        person.rich = NO;
        person.handsome = YES;
        NSLog(@"tall : %d, rich : %d, handsome : %d", person.tall,person.rich,person.handsome);
    }
    return 0;
} 

首先在log处打个断点,查看_tallRichHandsome内存储的值

_tallRichHandsome内存储的值

因为_tallRichHandsome占据一个内存空间,也就是8个二进制位,我们将05十六进制转化为二进制查看

05转化为二进制

上图中可以发现,倒数第三位也就是tall值为1,倒数第二位也就是rich值为0,倒数一位也就是handsome值为1,如此看来和上述代码中我们设置的值一样。可以成功赋值。

接着继续打印内容: Runtime - union探寻[59366:4053478] tall : -1, rich : 0, handsome : -1

此时可以发现问题,tall与handsome我们设值为YES,讲道理应该输出的值为1为何上面输出为-1呢?

并且上面通过打印_tallRichHandsome中存储的值,也确认tallhandsome的值都为1。我们再次打印_tallRichHandsome结构体内变量的值。

person内部_tallRichHandsome结构体变量

上图中可以发现,handsome的值为0x01,通过计算器将其转化为二进制

0x01二进制数

可以看到值确实为1的,为什么打印出来值为-1呢?此时应该可以想到应该是get方法内部有问题。我们来到get方法内部通过打印断点查看获取到的值。

- (BOOL)handsome
{
    BOOL ret = _tallRichHandsome.handsome;
    return ret;
} 

打印ret的值

po ret的值

通过打印ret的值发现其值为255,也就是1111 1111,此时也就能解释为什么打印出来值为 -1了,首先此时通过结构体获取到的handsome的值为0b1只占一个内存空间中的1位,但是BOOL值占据一个内存空间,也就是8位。当仅有1位的值扩展成8位的话,其余空位就会根据前面一位的值全部补位成1,因此此时ret的值就被映射成了0b 11111 1111

11111111 在一个字节时,有符号数则为-1,无符号数则为255。因此我们在打印时候打印出的值为-1

为了验证当1位的值扩展成8位时,会全部补位,我们将tall、rich、handsome值设置为占据两位。

@interface Person()
{
    struct {
        char tall : 2;
        char rich : 2;
        char handsome : 2;
    }_tallRichHandsome;
} 

此时在打印就发现值可以正常打印出来。 Runtime - union探寻[60827:4259630] tall : 1, rich : 0, handsome : 1

这是因为,在get方法内部获取到的_tallRichHandsome.handsome为两位的也就是0b 01,此时在赋值给8位的BOOL类型的值时,前面的空值就会自动根据前面一位补全为0,因此返回的值为0b 0000 0001,因此打印出的值也就为1了。

因此上述问题同样可以使用!!双感叹号来解决问题。!!的原理上面已经讲解过,这里不再赘述了。

使用结构体位域优化之后的代码

@interface Person()
{
    struct {
        char tall : 1;
        char rich : 1;
        char handsome : 1;
    }_tallRichHandsome;
}
@end

@implementation Person

- (void)setTall:(BOOL)tall
{
    _tallRichHandsome.tall = tall;
}
- (void)setRich:(BOOL)rich
{
    _tallRichHandsome.rich = rich;
}
- (void)setHandsome:(BOOL)handsome
{
    _tallRichHandsome.handsome = handsome;
}
- (BOOL)tall
{
    return !!_tallRichHandsome.tall;
}
- (BOOL)rich
{
    return !!_tallRichHandsome.rich;
}
- (BOOL)handsome
{
    return !!_tallRichHandsome.handsome;
} 

上述代码中使用结构体的位域则不在需要使用掩码,使代码可读性增强了很多,但是效率相比直接使用位运算的方式来说差很多,如果想要高效率的进行数据的读取与存储同时又有较强的可读性就需要使用到共用体了。

7.1.4 用 共用体 和 来存储 变量的值

为了使代码存储数据高效率的同时,有较强的可读性,可以使用共用体来增强代码可读性,同时使用位运算来提高数据存取的效率。

使用共用体优化的代码

#define TallMask (1<<2) // 0b00000100 4
#define RichMask (1<<1) // 0b00000010 2
#define HandsomeMask (1<<0) // 0b00000001 1

@interface Person()
{
    union {
        char bits;
       // 结构体仅仅是为了增强代码可读性,无实质用处
        struct {
            char tall : 1;
            char rich : 1;
            char handsome : 1;
        };
    }_tallRichHandsome;
}
@end

@implementation Person

- (void)setTall:(BOOL)tall
{
    if (tall) {
        _tallRichHandsome.bits |= TallMask;
    }else{
        _tallRichHandsome.bits &= ~TallMask;
    }
}
- (void)setRich:(BOOL)rich
{
    if (rich) {
        _tallRichHandsome.bits |= RichMask;
    }else{
        _tallRichHandsome.bits &= ~RichMask;
    }
}
- (void)setHandsome:(BOOL)handsome
{
    if (handsome) {
        _tallRichHandsome.bits |= HandsomeMask;
    }else{
        _tallRichHandsome.bits &= ~HandsomeMask;
    }
}
- (BOOL)tall
{
    return !!(_tallRichHandsome.bits & TallMask);
}
- (BOOL)rich
{
    return !!(_tallRichHandsome.bits & RichMask);
}
- (BOOL)handsome
{
    return !!(_tallRichHandsome.bits & HandsomeMask);
} 

上述代码中使用位运算这种比较高效的方式存取值,使用union共用体来对数据进行存储。增加读取效率的同时增强代码可读性。

其中_tallRichHandsome共用体只占用一个字节,因为结构体中tall、rich、handsome都只占一位二进制空间,所以结构体只占一个字节,而char类型的bits也只占一个字节,他们都在共用体中,因此共用一个字节的内存即可。

并且在get、set方法中并没有使用到结构体,结构体仅仅为了增加代码可读性,指明共用体中存储了哪些值,以及这些值各占多少位空间。同时存值取值还使用位运算来增加效率,存储使用共用体,存放的位置依然通过与掩码进行位运算来控制。

此时代码已经算是优化完成了,高效的同时可读性高,那么此时在回头看isa_t共用体的源码

7.2 isa_t源码

此时我们在回头查看isa_t源码

// 精简过的isa_t共用体
union isa_t 
{
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }

    Class cls;
    uintptr_t bits;

# if __arm64__
#   define ISA_MASK        0x0000000ffffffff8ULL
#   define ISA_MAGIC_MASK  0x000003f000000001ULL
#   define ISA_MAGIC_VALUE 0x000001a000000001ULL
    struct {
        uintptr_t nonpointer        : 1;
        uintptr_t has_assoc         : 1;
        uintptr_t has_cxx_dtor      : 1;
        uintptr_t shiftcls          : 33; // MACH_VM_MAX_ADDRESS 0x1000000000
        uintptr_t magic             : 6;
        uintptr_t weakly_referenced : 1;
        uintptr_t deallocating      : 1;
        uintptr_t has_sidetable_rc  : 1;
        uintptr_t extra_rc          : 19;
#       define RC_ONE   (1ULL<<45)
#       define RC_HALF  (1ULL<<18)
    };
#endif
}; 

经过前面 对位运算位域以及共用体的介绍,现在再来看源码已经可以很清晰的理解其中的内容:

  • 源码中通过共用体的形式存储了64位的值,这些值在结构体中被展示出来,通过对bits进行位运算而取出相应位置的值
  • shiftcls
    • shiftcls中存储着Class、Meta-Class对象的内存地址信息
    • 我们之前在OC对象的本质中提到过,对象的isa指针需要同ISA_MASK经过一次&(按位与)运算才能得出真正的Class对象地址

isa指针按位与得到Class对象地址

那么此时我们重新来看ISA_MASK的值0x0000000ffffffff8ULL,我们将其转化为二进制数

0x0000000ffffffff8ULL二进制

  • 上图中可以看出ISA_MASK的值转化为二进制中有33位都为1,前面提到过按位与的作用是可以取出这33位中的值
  • 那么此时很明显了,同ISA_MASK进行按位与运算即可以取出ClassMeta-Class的值。

同时可以看出ISA_MASK最后三位的值为0,那么任何数同ISA_MASK按位与运算之后,得到的最后三位必定都为0,因此任何类对象或元类对象的内存地址最后三位必定为0,转化为十六进制末位必定为8或者0。

7.3 isa中存储的信息及作用

将结构体取出来标记一下这些信息的作用。

struct {
    // 0代表普通的指针,存储着Class,Meta-Class对象的内存地址。
    // 1代表优化后的使用位域存储更多的信息。
    uintptr_t nonpointer        : 1; 

   // 是否有设置过关联对象,如果没有,释放时会更快
    uintptr_t has_assoc         : 1;

    // 是否有C++析构函数,如果没有,释放时会更快
    uintptr_t has_cxx_dtor      : 1;

    // 存储着Class、Meta-Class对象的内存地址信息
    uintptr_t shiftcls          : 33; 

    // 用于在调试时分辨对象是否未完成初始化
    uintptr_t magic             : 6;

    // 是否有被弱引用指向过,如果没有,释放时会更快
    uintptr_t weakly_referenced : 1;

    // 对象是否正在释放
    uintptr_t deallocating      : 1;


    // 里面存储的值是引用计数器减1
    uintptr_t extra_rc          : 19;
    
    
    // 引用计数器是否过大无法存储在isa中
    // 如果为1,那么引用计数会存储在一个叫SideTable的类的属性中
    uintptr_t has_sidetable_rc  : 1;
}; 

7.3.1 验证 isa中存储的信息是否可靠

通过下面一段代码验证上述信息存储的位置及作用

// 以下代码需要在真机中运行,因为真机中才是__arm64__ 位架构
- (void)viewDidLoad {
    [super viewDidLoad];
    Person *person = [[Person alloc] init];
    NSLog(@"%p",[person class]);
    NSLog(@"%@",person);
} 

首先打印person类对象的地址,之后通过断点打印一下person对象的isa指针地址。

首先来看一下打印的内容

打印内容

将类对象地址转化为二进制

类对象地址

将person的isa指针地址转化为二进制

person对象的isa指针地址

shiftcls : shiftcls中存储类对象地址,通过上面两张图对比可以发现存储类对象地址的33位二进制内容完全相同。

extra_rc : extra_rc的19位中存储着的值为引用计数减一,因为此时person的引用计数为1,因此此时extra_rc的19位二进制中存储的是0。

magic : magic的6位用于在调试时分辨对象是否未完成初始化,上述代码中person已经完成初始化,那么此时这6位二进制中存储的值011010即为共用体中定义的宏# define ISA_MAGIC_VALUE 0x000001a000000001ULL的值。

nonpointer : 这里肯定是使用的优化后的isa,因此nonpointer的值肯定为1

因为此时person对象没有关联对象并且没有弱指针引用过,可以看出has_assocweakly_referenced值都为0,接着我们为person对象添加弱引用和关联对象,来观察一下has_assocweakly_referenced的变化。

- (void)viewDidLoad {
    [super viewDidLoad];
    Person *person = [[Person alloc] init];
    NSLog(@"%p",[person class]);
    // 为person添加弱引用
    __weak Person *weakPerson = person;
    // 为person添加关联对象
    objc_setAssociatedObject(person, @"name", @"xx_cc", OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    NSLog(@"%@",person);
} 

重新打印person的isa指针地址将其转化为二进制可以看到has_assocweakly_referenced的值都变成了1

has_assoc和weakly_referenced的变化

注意:只要设置过关联对象或者弱引用引用过对象has_assocweakly_referenced的值就会变成1,不论之后是否将关联对象置为nil或断开弱引用。

如果没有设置过关联对象,对象释放时会更快,这是因为对象在销毁时会判断是否有关联对象进而对关联对象释放。来看一下对象销毁的源码

void *objc_destructInstance(id obj) 
{
    if (obj) {
        Class isa = obj->getIsa();
        // 是否有c++析构函数
        if (isa->hasCxxDtor()) {
            object_cxxDestruct(obj);
        }
        // 是否有关联对象,如果有则移除
        if (isa->instancesHaveAssociatedObjects()) {
            _object_remove_assocations(obj);
        }
        objc_clear_deallocating(obj);
    }
    return obj;
} 

相信至此我们已经对isa指针有了新的认识:

  • arm64架构之后,isa指针不单单只存储了ClassMeta-Class的地址,而是使用共用体的方式存储了更多信息
  • 其中shiftcls存储了ClassMeta-Class的地址,需要同ISA_MASK进行按位&运算才可以取出其内存地址值。

三、class的结构

1. 回顾一下Class的内部结构

我们在之前在探索OC的三类对象的时候,从简单探索过Class的内部结构,且对Class结构的认识最后以一张图作总结: image.png

我们在前面的篇幅中对isa指针有了新的认识之后,也需要基于此 对Class有 进一步的探索,重新认识Class内部结构:

首先回顾一下Class的内部结构相关的源码:

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

    class_rw_t *data() { 
        return bits.data();
    }
    void setData(class_rw_t *newData) {
        bits.setData(newData);
    }
} 
class_rw_t* data() {
    return (class_rw_t *)(bits & FAST_DATA_MASK);
}  

1.1 class_rw_t

从源码中我们不难得知:

  • bits & FAST_DATA_MASK位运算之后,可以得到class_rw_t
  • class_rw_t中存储着方法列表、属性列表以及协议列表等
  • 来看一下class_rw_t部分代码:
    struct class_rw_t {
        // Be warned that Symbolication knows the layout of this structure.
        uint32_t flags;
        uint32_t version;
    
        const class_ro_t *ro;
    
        method_array_t methods; // 方法列表
        property_array_t properties; // 属性列表
        protocol_array_t protocols; // 协议列表
    
        Class firstSubclass;
        Class nextSiblingClass;
    
        char *demangledName;
    }; 
    
    • class_rw_t结构体内部的成员:method_array_tproperty_array_tprotocol_array_t其实都是二维数组
    • 我们可以去看下method_array_tproperty_array_tprotocol_array_t的内部结构:
      class method_array_t : 
          public list_array_tt<method_t, method_list_t> 
      {
          typedef list_array_tt<method_t, method_list_t> Super;
      
       public:
          method_list_t **beginCategoryMethodLists() {
              return beginLists();
          }
      
          method_list_t **endCategoryMethodLists(Class cls);
      
          method_array_t duplicate() {
              return Super::duplicate<method_array_t>();
          }
      };
      
      
      class property_array_t : 
          public list_array_tt<property_t, property_list_t> 
      {
          typedef list_array_tt<property_t, property_list_t> Super;
      
       public:
          property_array_t duplicate() {
              return Super::duplicate<property_array_t>();
          }
      };
      
      
      class protocol_array_t : 
          public list_array_tt<protocol_ref_t, protocol_list_t> 
      {
          typedef list_array_tt<protocol_ref_t, protocol_list_t> Super;
      
       public:
          protocol_array_t duplicate() {
              return Super::duplicate<protocol_array_t>();
          }
      };
      
    • 我们这里以method_array_t为例,分析一下其二维数组的构成:
      • method_array_t本身就是一个数组,数组里面存放的是数组method_list_t
      • method_list_t里面最终存放的是method_t
      • method_t是一个方法对象

class_rw_t里面的methods、properties、protocols是二维数组,是可读可写的,其中包含了类的初始内容以及分类的内容。 (这里以methods为例,实际上propertiesprotocols都是类似的构成) image.png

1.2 class_ro_t

我们之前提到过class_ro_t中也有存储方法属性协议列表,另外还有成员变量列表。

接着来看一下class_ro_t部分代码

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;
#ifdef __LP64__
    uint32_t reserved;
#endif

    const uint8_t * ivarLayout;
    
    const char * name;//类名
    method_list_t * baseMethodList;//方法列表
    protocol_list_t * baseProtocols;//协议列表
    const ivar_list_t * ivars;//成员变量

    const uint8_t * weakIvarLayout;
    property_list_t *baseProperties;//属性列表

    method_list_t *baseMethods() const {
        return baseMethodList;
    }
}; 

class_rw_t的源码中我们可以看到class_ro_t *ro成员,但是其是被const修饰的,也就是是只读,不可修改的。我们进一步去看一下class_ro_t的内部结构我们可以得知:

  • 内部直接存储的直接就是method_list_t、protocol_list_t 、property_list_t类型的一维数组
  • 数组里面分别存放的是类的初始信息
  • method_list_t为例,method_list_t中直接存放的就是method_t,但是是只读的,不允许增删改查。

1.3 总结

以方法列表为例,class_rw_t中的methods是二维数组的结构,并且可读可写

  • 因此可以动态的添加方法,并且更加便于分类方法的添加
  • 因为我们在Category的本质里面提到过,attachList函数内通过memmove 和 memcpy两个操作将分类的方法列表合并在本类的方法列表中(也即是class_rw_tmethods中)
  • 那么此时就将分类的方法和本类的方法统一整合到一起了

其实一开始类的方法,属性,成员变量属性协议等等都是存放在class_ro_t中的

  • 当程序运行的时候,需要将分类中的列表跟类初始的列表合并在一起的时,就会将class_ro_t中的列表和分类中的列表合并起来存放在class_rw_t
  • 也就是说class_rw_t中有部分列表是从class_ro_t里面拿出来的。并且最终和分类的方法合并
  • 可以通过源码看到这一部分的实现:
    static Class realizeClass(Class cls)
    {
        runtimeLock.assertWriting();
    
        const class_ro_t *ro;
        class_rw_t *rw;
        Class supercls;
        Class metacls;
        bool isMeta;
    
        if (!cls) return nil;
        if (cls->isRealized()) return cls;
        assert(cls == remapClass(cls));
    
        // 最开始cls->data是指向ro的
        ro = (const class_ro_t *)cls->data();
    
        if (ro->flags & RO_FUTURE) { 
            // rw已经初始化并且分配内存空间
            rw = cls->data();  // cls->data指向rw
            ro = cls->data()->ro;  // cls->data()->ro指向ro
            cls->changeInfo(RW_REALIZED|RW_REALIZING, RW_FUTURE);
        } else { 
            // 如果rw并不存在,则为rw分配空间
            rw = (class_rw_t *)calloc(sizeof(class_rw_t), 1); // 分配空间
            rw->ro = ro;  // rw->ro重新指向ro
            rw->flags = RW_REALIZED|RW_REALIZING;
            // 将rw传入setData函数,等于cls->data()重新指向rw
            cls->setData(rw); 
        }
    } 
    

源码解读:

那么从上述源码中可以发现:

  • 类的初始信息本来其实是存储在class_ro_t中的
    • 并且ro本来是指向cls->data()
    • 也就是说bits.data()得到的是ro
  • 但是在运行过程中创建了class_rw_t,并将cls->data指向rw
    • 同时将初始信息ro赋值给rw中的ro
    • 最后在通过setData(rw)设置data
  • 那么此时bits.data()得到的就是rw
  • 之后再去检查是否有分类,同时将分类的方法属性协议列表整合存储在class_rw_t方法属性协议列表中

通过上述对源码的分析,我们对class_rw_t内存储方法属性协议列表的过程有了更清晰的认识,那么接下来探寻class_rw_t中是如何存储方法的。

2. class_rw_t中是如何存储方法的?

2.1 method_t

我们知道 method_array_t中最终存储的是method_t

  • method_t是对方法、函数的封装,每一个方法对象就是一个method_t
  • 通过源码看一下method_t的结构体:
struct method_t {
    SEL name;  // 函数名
    const char *types;  // 编码(返回值类型,参数类型)
    IMP imp; // 指向函数的指针(函数地址)
}; 

method_t结构体中可以看到三个成员,我们依次来看看这三个成员变量分别代表什么:

2.1.1 SEL

SEL代表方法\函数名,一般叫做选择器,底层结构跟char *类似

  • SEL可以通过@selector()sel_registerName()获得
    SEL sel1 = @selector(test);
    SEL sel2 = sel_registerName("test");
    
  • 也可以通过sel_getName()NSStringFromSelector()SEL转成字符串
    char *string = sel_getName(sel1);
    NSString *string2 = NSStringFromSelector(sel2);
    
  • 不同类中相同名字的方法,所对应的方法选择器是相同的
    • SEL仅仅代表方法的名字,并且不同类中相同的方法名的SEL是全局唯一的。
    NSLog(@"%p,%p", sel1,sel2);
    Runtime-test[23738:8888825] 0x1017718a3,0x1017718a3
    

typedef struct objc_selector *SEL;,可以把SEL看做是方法名字符串。

2.1.2 types

types包含了函数返回值,参数编码的字符串

  • 通过字符串拼接的方式将返回值和参数拼接成一个字符串
  • 这个字符串可以用于 代表函数返回值参数

image.png

我们通过代码查看一下types是如何代表函数返回值及参数的:

  • 首先通过在本地写几个与runtime底层实现class一样的结构体,用于模拟Class的内部实现
  • 我们曾在探寻Class的本质时,做过该操作:通过类型强制转化来探寻内部数据
Person *person = [[Person alloc] init];
xx_objc_class *cls = (__bridge xx_objc_class *)[Person class];
class_rw_t *data = cls->data();

通过断点可以在data中找到types的值

data中types的值

  • 上图中可以看出types的值为v16@0:8
  • 那么这个值代表什么呢?
  • apple为了能够清晰的使用字符串表示方法及其返回值,制定了一系列对应规则,通过下表可以看到一一对应关系 Objective-C type encodings

将types的值同表中的一一对照查看types的值v16@0:8 代表什么

- (void) test;

 v    16      @     0     :     8
void         id          SEL
// 16表示参数的占用空间大小,id后面跟的0表示从0位开始存储,id占8位空间。
// SEL后面的8表示从第8位开始存储,SEL同样占8位空间

我们知道任何方法都默认有两个参数的,id类型的self,和SEL类型的_cmd,而上述通过对types的分析同时也验证了这个说法。

为了能够看的更加清晰,我们为test添加返回值及参数之后重新查看types的值。

types的值

同样通过上表找出一一对应的值,查看types的值代表的方法

- (int)testWithAge:(int)age Height:(float)height
{
    return 0;
}
  i    24    @    0    :    8    i    16    f    20
int         id        SEL       int        float
// 参数的总占用空间为 8 + 8 + 4 + 4 = 24
// id 从第0位开始占据8位空间
// SEL 从第8位开始占据8位空间
// int 从第16位开始占据4位空间
// float 从第20位开始占据4位空间

iOS提供了@encode的指令,可以将具体的类型转化成字符串编码。

NSLog(@"%s",@encode(int));
NSLog(@"%s",@encode(float));
NSLog(@"%s",@encode(id));
NSLog(@"%s",@encode(SEL));

// 打印内容
Runtime-test[25275:9144176] i
Runtime-test[25275:9144176] f
Runtime-test[25275:9144176] @
Runtime-test[25275:9144176] :

上述代码中可以看到,对应关系确实如上表所示。

2.1.3 IMP

image.png IMP代表函数的具体实现

  • 存储的内容是函数地址
  • 也就是说当找到IMP的时候就可以找到函数实现,进而对函数进行调用

在上述代码中打印IMP的值

Printing description of data->methods->first.imp:
(IMP) imp = 0x000000010c66a4a0 (Runtime-test`-[Person testWithAge:Height:] at Person.m:13)

之后在test方法内部打印断点,并来到其方法内部可以看出IMP中的存储的地址也就是方法实现的地址。

四、cache_t方法缓存

通过前面的探索我们知道了方法列表是如何存储在Class类对象中的

  • 但是当多次继承的子类想要调用基类方法时,就需要通过superclass指针一层一层找到基类,在从基类方法列表中找到对应的方法进行调用
  • 如果多次调用基类方法,那么就需要多次遍历每一层父类的方法列表,这对性能来说无疑是伤害巨大的

Apple通过方法缓存技术的形式解决了这一问题,接下来我们来探寻Class类对象是如何进行方法缓存的

回到类对象结构体objc_class。里面有一个成员变量cache

  • 这个cache成员变量就是用于实现 方法缓存技术 的支撑
    struct objc_class : objc_object {
        // Class ISA;
        Class superclass;
        cache_t cache; // 方法缓存            // formerly cache pointer and vtable
        class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags
    
        class_rw_t *data() { 
            return bits.data();
        }
        void setData(class_rw_t *newData) {
            bits.setData(newData);
        }
    } 
    
  • Class内部结构中有个方法缓存(cache_t),用散列表哈希表)来缓存曾经调用过的方法,可以提高方法的查找速度

回顾方法调用过程:

  • 调用方法的时候,需要去方法列表里面进行遍历查找
    • 如果方法不在列表里面,就会通过superclass找到父类的类对象,在去父类类对象方法列表里面遍历查找。
  • 如果方法需要调用很多次的话,那就相当于每次调用都需要去遍历多次方法列表

cache_t技术

  • 为了能够快速查找方法,apple设计了cache_t来进行方法缓存:
  • 每当调用方法的时候,会先去cache中查找是否有缓存的方法:
    • 如果没有缓存,在去类对象方法列表中查找,以此类推直到找到方法之后,就会将方法直接存储在cache
    • 下一次在调用这个方法的时候,就会在类对象的cache里面找到这个方法,直接调用了

1. cache_t 如何进行缓存

那么cache_t是如何对方法进行缓存的呢?首先来看一下cache_t的内部结构。

struct cache_t {
    struct bucket_t *_buckets; // 散列表 数组
    mask_t _mask; // 散列表的长度 -1
    mask_t _occupied; // 已经缓存的方法数量
}; 

bucket_t是以数组的方式存储方法列表的,看一下bucket_t内部结构

struct bucket_t {
private:
    cache_key_t _key; // SEL作为Key
    IMP _imp; // 函数的内存地址
}; 

从源码中可以看出:

  • bucket_t中存储着SEL_imp
  • 通过key->value的形式:
    • SELkey
    • 函数实现的内存地址 _impvalue来存储方法

通过一张图来展示一下cache_t的结构

image.png

方法散列表bucket_t

  • 上述bucket_t列表我们称之为散列表(哈希表)
  • 散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构
  • 也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。
  • 这个映射函数叫做散列函数,存放记录的数组叫做散列表

那么apple如何在散列表中快速并且准确的找到对应的key以及函数实现呢?
这就需要我们通过源码来看一下apple的散列函数是如何设计的:

2.散列函数及散列表原理

首先来看一下方法缓存的源码(主要查看几个函数,关键代码都有注释,便不再展开介绍)

2.1 cache_fill 及 cache_fill_nolock 函数

void cache_fill(Class cls, SEL sel, IMP imp, id receiver)
{
#if !DEBUG_TASK_THREADS
    mutex_locker_t lock(cacheUpdateLock);
    cache_fill_nolock(cls, sel, imp, receiver);
#else
    _collecting_in_critical();
    return;
#endif
}

static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver)
{
    cacheUpdateLock.assertLocked();
    // 如果没有initialize直接return
    if (!cls->isInitialized()) return;
    // 确保线程安全,没有其他线程添加缓存
    if (cache_getImp(cls, sel)) return;
    // 通过类对象获取到cache 
    cache_t *cache = getCache(cls);
    // 将SEL包装成Key
    cache_key_t key = getKey(sel);
   // 占用空间+1
    mask_t newOccupied = cache->occupied() + 1;
   // 获取缓存列表的缓存能力,能存储多少个键值对
    mask_t capacity = cache->capacity();
    if (cache->isConstantEmptyCache()) {
        // 如果为空的,则创建空间,这里创建的空间为4个。
        cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
    }
    else if (newOccupied <= capacity / 4 * 3) {
        // 如果所占用的空间占总数的3/4一下,则继续使用现在的空间
    }
    else {
       // 如果占用空间超过3/4则扩展空间
        cache->expand();
    }
    // 通过key查找合适的存储空间。
    bucket_t *bucket = cache->find(key, receiver);
    // 如果key==0则说明之前未存储过这个key,占用空间+1
    if (bucket->key() == 0) cache->incrementOccupied();
    // 存储key,imp 
    bucket->set(key, imp);
} 

2.2 expand ()函数

当散列表的空间被占用超过3/4的时候,散列表会调用expand ()函数进行扩展,我们来看一下expand ()函数内散列表如何进行扩展的。

void cache_t::expand()
{
    cacheUpdateLock.assertLocked();
    // 获取旧的散列表的存储空间
    uint32_t oldCapacity = capacity();
    // 将旧的散列表存储空间扩容至两倍
    uint32_t newCapacity = oldCapacity ? oldCapacity*2 : INIT_CACHE_SIZE;
    // 为新的存储空间赋值
    if ((uint32_t)(mask_t)newCapacity != newCapacity) {
        newCapacity = oldCapacity;
    }
    // 调用reallocate函数,重新创建存储空间
    reallocate(oldCapacity, newCapacity);
} 

2.3 reallocate 函数

通过上述源码看到reallocate函数负责分配散列表空间,来到reallocate函数内部。

void cache_t::reallocate(mask_t oldCapacity, mask_t newCapacity)
{
    // 旧的散列表能否被释放
    bool freeOld = canBeFreed();
    // 获取旧的散列表
    bucket_t *oldBuckets = buckets();
    // 通过新的空间需求量创建新的散列表
    bucket_t *newBuckets = allocateBuckets(newCapacity);

    assert(newCapacity > 0);
    assert((uintptr_t)(mask_t)(newCapacity-1) == newCapacity-1);
    // 设置Buckets和Mash,Mask的值为散列表长度-1
    setBucketsAndMask(newBuckets, newCapacity - 1);
    // 释放旧的散列表
    if (freeOld) {
        cache_collect_free(oldBuckets, oldCapacity);
        cache_collect(false);
    }
} 

上述源码中首次传入reallocate函数的newCapacityINIT_CACHE_SIZEINIT_CACHE_SIZE是个枚举值,也就是4。因此散列表最初创建的空间就是4个。

enum {
    INIT_CACHE_SIZE_LOG2 = 2,
    INIT_CACHE_SIZE      = (1 << INIT_CACHE_SIZE_LOG2)
}; 

上述源码中可以发现散列表进行扩容时会将容量增至之前的2倍。

2.4 find 函数

最后来看一下散列表中如何快速的通过key找到相应的bucket呢?我们来到find函数内部

bucket_t * cache_t::find(cache_key_t k, id receiver)
{
    assert(k != 0);
    // 获取散列表
    bucket_t *b = buckets();
    // 获取mask
    mask_t m = mask();
    // 通过key找到key在散列表中存储的下标
    mask_t begin = cache_hash(k, m);
    // 将下标赋值给i
    mask_t i = begin;
    // 如果下标i中存储的bucket的key==0说明当前没有存储相应的key,将b[i]返回出去进行存储
    // 如果下标i中存储的bucket的key==k,说明当前空间内已经存储了相应key,将b[i]返回出去进行存储
    do {
        if (b[i].key() == 0  ||  b[i].key() == k) {
            // 如果满足条件则直接reutrn出去
            return &b[i];
        }
    // 如果走到这里说明上面不满足,那么会往前移动一个空间重新进行判定,知道可以成功return为止
    } while ((i = cache_next(i, m)) != begin);

    // hack
    Class cls = (Class)((uintptr_t)this - offsetof(objc_class, cache));
    cache_t::bad_cache(receiver, (SEL)k, cls);
} 

函数cache_hash (k, m)用来通过key找到方法在散列表中存储的下标,来到cache_hash (k, m)函数内部

static inline mask_t cache_hash(cache_key_t key, mask_t mask) 
{
    return (mask_t)(key & mask);
} 

可以发现cache_hash (k, m)函数内部仅仅是进行了key & mask的按位与运算,得到下标即存储在相应的位置上。按位与运算在上文中已详细讲解过,这里不在赘述。

2.5 _mask

通过上面的分析我们知道_mask的值是散列表的长度减一,那么任何数通过与_mask进行按位与运算之后获得的值都会小于等于_mask,因此不会出现数组溢出的情况。

举个例子,假设散列表的长度为8,那么mask的值为7

  0101 1011  // 任意值
& 0000 0111  // mask = 7
------------
  0000 0011 //获取的值始终等于或小于mask的值 

3.方法调用总结

  • 首次方法查找与缓存:
    • 首次方法查找: 当第一次使用方法时,消息机制通过isa指针找到class/meta-class
    • 方法缓存: 遍历方法列表找到方法之后(如果找不到就调用superclass去父类中找),会对方法以SEL为keyIMP为value的方式缓存在cache_buckets
    • 散列表下标: 当第一次存储的时候,会创建具有4个空间的散列表,并将_mask的值置为散列表的长度减一,之后通过SEL & mask计算出方法存储的下标值,并将方法存储在散列表中
      • 举个例子,如果计算出下标值为3,那么就将方法直接存储在下标为3的空间中,前面的空间会留空
  • 散列表扩容:
    • 当散列表中存储的方法占据散列表长度超过3/4的时候,散列表会进行扩容操作:
      • 将创建一个新的散列表并且空间扩容至原来空间的两倍
      • 并重置_mask的值
      • 最后释放旧的散列表
    • 此时再有方法要进行缓存的话,就需要重新通过SEL & mask计算出下标值之后在按照下标进行存储了
  • 散列表下标计算:
    • 如果一个类中方法很多,其中很可能会出现多个方法的SEL & mask得到的值为同一个下标值
    • 如果计算出来的下标有值,那么会调用cache_next函数往下标值-1位去进行存储
    • 如果下标值-1位空间中有存储方法,并且key不与要存储的key相同,那么再到前面一位进行比较,直到找到一位空间没有存储方法或者key与要存储的key相同为止
    • 如果到下标0的话就会到下标为_mask的空间也就是最大空间处进行比较。
  • 非首次方法查找:
    • 当要查找方法时,并不需要遍历散列表,同样通过SEL & mask计算出下标值,直接去下标值的空间取值即可
    • 同上,如果下标值中存储的key与要查找的key不相同,就去前面一位查找。
    • 这样虽然占用了少量空间,但是大大节省了时间,也就是说其实apple是使用空间换取时间的一种方法查找算法优化测策略。

通过一张图更清晰的看一下其中的流程:

散列表内部存取逻辑

4. 验证上述流程

通过一段代码演示一下 。同样使用仿照objc_class结构体自定义一个结构体,并进行强制转化来查看其内部数据,自定义结构体在之前的文章中使用过多次这里不在赘述。

我们创建Person类继承NSObjectStudent类继承PersonCollegeStudent继承Student。三个类分别有personTest,studentTest,colleaeStudentTest方法

通过打印断点来看一下方法缓存的过程

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        
        CollegeStudent *collegeStudent = [[CollegeStudent alloc] init];
        xx_objc_class *collegeStudentClass = (__bridge xx_objc_class *)[CollegeStudent class];
        
        cache_t cache = collegeStudentClass->cache;
        bucket_t *buckets = cache._buckets;
        
        [collegeStudent personTest];
        [collegeStudent studentTest];
        
        NSLog(@"----------------------------");
        for (int i = 0; i <= cache._mask; i++) {
            bucket_t bucket = buckets[i];
            NSLog(@"%s %p", bucket._key, bucket._imp);
        }
        NSLog(@"----------------------------");
        
        [collegeStudent colleaeStudentTest];

        cache = collegeStudentClass->cache;
        buckets = cache._buckets;
        NSLog(@"----------------------------");
        for (int i = 0; i <= cache._mask; i++) {
            bucket_t bucket = buckets[i];
            NSLog(@"%s %p", bucket._key, bucket._imp);
        }
        NSLog(@"----------------------------");
        
        NSLog(@"%p",@selector(colleaeStudentTest));
        NSLog(@"----------------------------");
    }
    return 0;
} 

我们分别在collegeStudent实例对象调用personTest,studentTest,colleaeStudentTest方法处打断点查看cache的变化。

personTest方法调用之前:

personTest方法调用之前

从上图中可以发现:

  • personTest方法调用之前,cache中仅仅存储了init方法
  • 上图中可以看出init方法恰好存储在下标为0的位置因此我们可以看到
  • _mask的值为3验证我们上述源码中提到的散列表第一次存储时会分配4个内存空间
  • _occupied的值为1证明此时_buckets中仅仅存储了一个方法。

collegeStudent在调用personTest的时候:

  • 首先发现collegeStudent类对象cache中没有personTest方法,就会去collegeStudent类对象的方法列表中查找
  • 方法列表中也没有,那么就通过superclass指针找到Student类对象
  • Studeng类对象cache和方法列表同样没有,再通过superclass指针找到Person类对象
  • 最终在Person类对象方法列表中找到之后进行调用,并缓存在collegeStudent类对象cache中。

执行personTest方法之后查看cache方法的变化:

上图中可以发现:

  • _occupied值为2,说明此时personTest方法已经被缓存在collegeStudent类对象cache

同理执行过studentTest方法之后,我们通过打印查看一下此时cache内存储的信息

cache内存储的信息

上图中可以看到cache中确实存储了 init 、personTest 、studentTest三个方法。

那么执行过colleaeStudentTest方法之后此时cache中应该对colleaeStudentTest方法进行缓存。

前面源码提到过,当存储的方法数超过散列表长度的3/4时,系统会重新创建一个容量为原来两倍的新的散列表替代原来的散列表。
过掉colleaeStudentTest方法,重新打印cache内存储的方法查看:

从图中可看出:

  • _bucket散列表扩容之后仅仅存储了colleaeStudentTest方法
  • 并且上图中打印SEL & _mask 位运算得出下标的值确实是_bucket列表中colleaeStudentTest方法存储的位置

至此已经对Class的结构及方法缓存的过程有了新的认知:

  • apple通过散列表的形式对方法进行缓存,以少量的空间节省了大量查找方法的时间

猜你喜欢

转载自juejin.im/post/7116103432095662111