阅读本文后你将会进一步了解 Runtime 的实现, 享元设计模式的实践, 内存数据存储优化, 编译内存屏障, 多线程无锁读写实现, 垃圾回收等相关的技术点.
objc_class(Class 对象)结构简介
熟悉 OC 语言的 Runtime(运行时)机制以及对象方法调用机制的开发者都知道, 所有 OC 方法调用在编译时都会转化为对 C 函数 objc_msgSend 的调用.
- /* 下面的例子是在 arm64 体系下的函数调用实现, 本文中如果没有特殊说明都是指在 arm64 体系下的结论 */
- // [view1 addSubview:view2];
- objc_msgSend(view1, "addSubview:", view2);
- // CGSize size = [view1 sizeThatFits:CGSizeZero];
- CGSize size = objc_msgSend(view1, "sizeThatFits:", CGSizeZero);
- // CGFloat alpha = view1.alpha;
- CGFloat alpha = objc_msgSend(view1, "alpha");
系统的 Runtime 库通过函数 objc_msgSend 以及 OC 对象中隐藏的 isa 数据成员来实现多态和运行时方法查找以及执行. 每个对象的 isa 中保存着这个对象的类对象指针, 类对象是一个 Class 类型的数据, 而 Class 则是一个 objc_class 结构体指针类型的别名 , 它被定义如下:
typedef struct objc_class * Class;
虽然在对外公开暴露的头文件 #import 中可以看到关于 struct objc_class 的定义, 但可惜的是那只是 objc1.0 版本的定义, 而目前所运行的 objc2.0 版本运行时库并没有暴露出 struct objc_class 所定义的详细内容.
你可以在 https://opensource.apple.com/source/objc4/objc4-723/ 中下载和查看开源的最新版本的 Runtime 库源代码. Runtime 库的源代码是用汇编和 C++ 混合实现的, 你可以在头文件 https://opensource.apple.com/source/objc4/objc4-723/runtime/objc-runtime-new.h.auto.html 中看到关于 struct objc_class 结构的详细定义. objc_class 结构体用来描述一个 OC 类的类信息: 包括类的名字, 所继承的基类, 类中定义的方法列表描述, 属性列表描述, 实现的协议描述, 定义的成员变量描述等等信息. 在 OC 中类信息也是一个对象, 所以又称类信息为 Class 对象. 下面是一张 objc_class 结构体定义的静态类图:
objc_class 类结构图
图片最左边显示的内容有一个编辑错误, 不应该是 NSObject 而应该是 objc_class.
objc_class 结构体中的数据成员非常的多也非常的复杂, 这里并不打算深入的去介绍它, 本文主要介绍的是 objc_msgSend 函数内部的实现, 因此在下面的代码中将会隐藏大部分数据成员的定义, 并在不改变真实结构体定义的基础上只列出 objc_msgSend 方法内部会访问和使用到的数据成员.
objc_msgSend 函数的内部实现
objc_msgSend 函数是所有 OC 方法调用的核心引擎, 它负责查找真实的类或者对象方法的实现, 并去执行这些方法函数. 因调用频率是如此之高, 所以要求其内部实现近可能达到最高的性能. 这个函数的内部代码实现是用汇编语言来编写的, 并且其中并没有涉及任何需要线程同步和锁相关的代码. 你可以在上面说到的开源 URL 链接中的 https://opensource.apple.com/source/objc4/objc4-723/runtime/Messengers.subproj/ 文件夹下查看各种体系架构下的汇编语言的实现.
; 这里列出的是在 arm64 位真机模式下的汇编代码实现.
- 0x18378c420 <+0>: cmp x0, #0x0 ; =0x0
- 0x18378c424 <+4>: b.le 0x18378c48c ; <+108>
- 0x18378c428 <+8>: ldr x13, [x0]
- 0x18378c42c <+12>: and x16, x13, #0xffffffff8
- 0x18378c430 <+16>: ldp x10, x11, [x16, #0x10]
- 0x18378c434 <+20>: and w12, w1, w11
- 0x18378c438 <+24>: add x12, x10, x12, lsl #4
- 0x18378c43c <+28>: ldp x9, x17, [x12]
- 0x18378c440 <+32>: cmp x9, x1
- 0x18378c444 <+36>: b.ne 0x18378c44c ; <+44>
- 0x18378c448 <+40>: br x17
- 0x18378c44c <+44>: cbz x9, 0x18378c720 ; _objc_msgSend_uncached
- 0x18378c450 <+48>: cmp x12, x10
- 0x18378c454 <+52>: b.eq 0x18378c460 ; <+64>
- 0x18378c458 <+56>: ldp x9, x17, [x12, #-0x10]!
- 0x18378c45c <+60>: b 0x18378c440 ; <+32>
- 0x18378c460 <+64>: add x12, x12, w11, uxtw #4
- 0x18378c464 <+68>: ldp x9, x17, [x12]
- 0x18378c468 <+72>: cmp x9, x1
- 0x18378c46c <+76>: b.ne 0x18378c474 ; <+84>
- 0x18378c470 <+80>: br x17
- 0x18378c474 <+84>: cbz x9, 0x18378c720 ; _objc_msgSend_uncached
- 0x18378c478 <+88>: cmp x12, x10
- 0x18378c47c <+92>: b.eq 0x18378c488 ; <+104>
- 0x18378c480 <+96>: ldp x9, x17, [x12, #-0x10]!
- 0x18378c484 <+100>: b 0x18378c468 ; <+72>
- 0x18378c488 <+104>: b 0x18378c720 ; _objc_msgSend_uncached
- 0x18378c48c <+108>: b.eq 0x18378c4c4 ; <+164>
- 0x18378c490 <+112>: mov x10, #-0x1000000000000000
- 0x18378c494 <+116>: cmp x0, x10
- 0x18378c498 <+120>: b.hs 0x18378c4b0 ; <+144>
- 0x18378c49c <+124>: adrp x10, 202775
- 0x18378c4a0 <+128>: add x10, x10, #0x220 ; =0x220
- 0x18378c4a4 <+132>: lsr x11, x0, #60
- 0x18378c4a8 <+136>: ldr x16, [x10, x11, lsl #3]
- 0x18378c4ac <+140>: b 0x18378c430 ; <+16>
- 0x18378c4b0 <+144>: adrp x10, 202775
- 0x18378c4b4 <+148>: add x10, x10, #0x2a0 ; =0x2a0
- 0x18378c4b8 <+152>: ubfx x11, x0, #52, #8
- 0x18378c4bc <+156>: ldr x16, [x10, x11, lsl #3]
- 0x18378c4c0 <+160>: b 0x18378c430 ; <+16>
- 0x18378c4c4 <+164>: mov x1, #0x0
- 0x18378c4c8 <+168>: movi d0, #0000000000000000
- 0x18378c4cc <+172>: movi d1, #0000000000000000
- 0x18378c4d0 <+176>: movi d2, #0000000000000000
- 0x18378c4d4 <+180>: movi d3, #0000000000000000
- 0x18378c4d8 <+184>: ret
- 0x18378c4dc <+188>: nop
毕竟汇编语言代码比较晦涩难懂, 因此这里将函数的实现反汇编成 C 语言的伪代码:
- // 下面的结构体中只列出 objc_msgSend 函数内部访问用到的那些数据结构和成员.
- /*
- 其实 SEL 类型就是一个字符串指针类型, 所描述的就是方法字符串指针
- */
- typedef char * SEL;
- /*
- IMP 类型就是所有 OC 方法的函数原型类型.
- */
- typedef id (*IMP)(id self, SEL _cmd, ...);
- /*
- 方法名和方法实现桶结构体
- */
- struct bucket_t {
- SEL key; // 方法名称
- IMP imp; // 方法的实现, imp 是一个函数指针类型
- };
- /*
- 用于加快方法执行的缓存结构体. 这个结构体其实就是一个基于开地址冲突解决法的哈希桶.
- */
- struct cache_t {
- struct bucket_t *buckets; // 缓存方法的哈希桶数组指针, 桶的数量 = mask + 1
- int mask; // 桶的数量 - 1
- int occupied; // 桶中已经缓存的方法数量.
- };
- /*
- OC 对象的类结构体描述表示, 所有 OC 对象的第一个参数保存是的一个 isa 指针.
- */
- struct objc_object {
- void *isa;
- };
- /*
- OC 类信息结构体, 这里只展示出了必要的数据成员.
- */
- struct objc_class : objc_object {
- struct objc_class * superclass; // 基类信息结构体.
- cache_t cache; // 方法缓存哈希表
- //... 其他数据成员忽略.
- };
- /*
- objc_msgSend 的 C 语言版本伪代码实现.
- receiver: 是调用方法的对象
- op: 是要调用的方法名称字符串
- */
- id objc_msgSend(id receiver, SEL op, ...)
- {
- //1............................ 对象空值判断.
- // 如果传入的对象是 nil 则直接返回 nil
- if (receiver == nil)
- return nil;
- //2............................ 获取或者构造对象的 isa 数据.
- void *isa = NULL;
- // 如果对象的地址最高位为 0 则表明是普通的 OC 对象, 否则就是 Tagged Pointer 类型的对象
- if ((receiver & 0x8000000000000000) == 0) {
- struct objc_object *ocobj = (struct objc_object*) receiver;
- isa = ocobj->isa;
- }
- else { //Tagged Pointer 类型的对象中没有直接保存 isa 数据, 所以需要特殊处理来查找对应的 isa 数据.
- // 如果对象地址的最高 4 位为 0xF, 那么表示是一个用户自定义扩展的 Tagged Pointer 类型对象
- if (((NSUInteger) receiver)>= 0xf000000000000000) {
- // 自定义扩展的 Tagged Pointer 类型对象中的 52-59 位保存的是一个全局扩展 Tagged Pointer 类数组的索引值.
- int classidx = (receiver & 0xFF0000000000000)>> 52
- isa = objc_debug_taggedpointer_ext_classes[classidx];
- }
- else {
- // 系统自带的 Tagged Pointer 类型对象中的 60-63 位保存的是一个全局 Tagged Pointer 类数组的索引值.
- int classidx = ((NSUInteger) receiver)>> 60;
- isa = objc_debug_taggedpointer_classes[classidx];
- }
- }
- // 因为内存地址对齐的原因和虚拟内存空间的约束原因,
- // 以及 isa 定义的原因需要将 isa 与上 0xffffffff8 才能得到对象所属的 Class 对象.
- struct objc_class *cls = (struct objc_class *)(isa & 0xffffffff8);
- //3............................ 遍历缓存哈希桶并查找缓存中的方法实现.
- IMP imp = NULL;
- //cmd 与 cache 中的 mask 进行与计算得到哈希桶中的索引, 来查找方法是否已经放入缓存 cache 哈希桶中.
- int index = cls->cache.mask & op;
- while (true) {
- // 如果缓存哈希桶中命中了对应的方法实现, 则保存到 imp 中并退出循环.
- if (cls->cache.buckets[index].key == op) {
- imp = cls->cache.buckets[index].imp;
- break;
- }
- // 方法实现并没有被缓存, 并且对应的桶的数据是空的就退出循环
- if (cls->cache.buckets[index].key == NULL) {
- break;
- }
- // 如果哈希桶中对应的项已经被占用但是又不是要执行的方法, 则通过开地址法来继续寻找缓存该方法的桶.
- if (index == 0) {
- index = cls->cache.mask; // 从尾部寻找
- }
- else {
- index--; // 索引减 1 继续寻找.
- }
- } /*end while*/
- //4............................ 执行方法实现或方法未命中缓存处理函数
- if (imp != NULL)
- return imp(receiver, op, ...); // 这里的... 是指传递给 objc_msgSend 的 OC 方法中的参数.
- else
- return objc_msgSend_uncached(receiver, op, cls, ...);
- }
- /*
- 方法未命中缓存处理函数: objc_msgSend_uncached 的 C 语言版本伪代码实现, 这个函数也是用汇编语言编写.
- */
- id objc_msgSend_uncached(id receiver, SEL op, struct objc_class *cls)
- {
- // 这个函数很简单就是直接调用了_class_lookupMethodAndLoadCache3 来查找方法并缓存到 struct objc_class 中的 cache 中, 最后再返回 IMP 类型.
- IMP imp = _class_lookupMethodAndLoadCache3(receiver, op, cls);
- return imp(receiver, op, ....);
- }
可以看出 objc_msgSend 函数的实现逻辑主要分为 4 个部分:
1. 对象空值判断
首先对传进来的方法接收者 receiver 进行是否为空判断, 如果是 nil 则函数直接返回, 这也就说明了当对一个 nil 对象调用方法时, 不会产生崩溃, 也不会进入到对应的方法实现中去, 整个过程其实什么也不会发生而是直接返回 nil.
2. 获取或者构造对象的 isa 数据
通常情况下每个 OC 对象的最开始处都有一个隐藏的数据成员 isa,isa 保存有类的描述信息, 所以在执行方法前就需要从对象处获取到这个指针值. 为了减少内存资源的浪费, 苹果提出了 Tagged Pointer https://en.wikipedia.org/wiki/Tagged_pointer 类型对象的概念. 比如一些 NSString 和 NSNumber 类型的实例对象就会被定义为 Tagged Pointer 类型的对象. Tagged Pointer 类型的对象采用一个跟机器字长一样长度的整数来表示一个 OC 对象, 而为了跟普通 OC 对象区分开来, 每个 Tagged Pointer 类型对象的最高位为 1 而普通的 OC 对象的最高位为 0. 因此上面的代码中如果对象 receiver 地址的最高位为 1 则会将对象当做 Tagged Pointer 对象来处理. 从代码实现中还可以看出系统中存在两种类型的 Tagged Pointer 对象: 如果是高四位全为 1 则是用户自定义扩展的 Tagged Pointer 对象, 否则就是系统内置的 Tagged Pointer 对象. 因为 Tagged Pointer 对象中是不可能保存一个 isa 的信息的, 而是用 Tagged Pointer 类型的对象中的某些 bit 位来保存所属的类信息的索引值. 系统分别定义了两个全局数组变量:
- extern "C" {
- extern Class objc_debug_taggedpointer_classes[16*2];
- extern Class objc_debug_taggedpointer_ext_classes[256];
- }
来保存所有的 Tagged Pointer 类型的类信息. 对于内置 Tagged Pointer 类型的对象来说, 其中的高四位保存的是一个索引值, 通过这个索引值可以在 objc_debug_taggedpointer_classes 数组中查找到对象所属的 Class 对象; 对于自定义扩展 Tagged Pointer 类型的对象来说, 其中的高 52 位到 59 位这 8 位 bit 保存的是一个索引值, 通过这个索引值可以在 objc_debug_taggedpointer_ext_classes 数组中查找到对象所属的 Class 对象.
思考和实践: Tagged Pointer 类型的对象中获取 isa 数据的方式采用的是享元设计模式, 这种设计模式 https://baike.baidu.com/item/享元模式/10541959 在一定程度上还可以缩小一个对象占用的内存尺寸. 还有比如 256 色的位图中每个像素位置中保存的是颜色索引值而非颜色的 RGB 值, 从而减少了低色彩位图的文件存储空间. 保存一个对象引用可能需要占用 8 个字节, 而保存一个索引值时可能只需要占用 1 个字节.
在第二步中不管是普通的 OC 对象还是 Tagged Pointer 类型的对象都需要找到对象所属的 isa 信息, 并进一步找到所属的类对象, 只有找到了类对象才能查找到对应的方法的实现.
isa 的内部结构
上面的代码实现中, 在将 isa 转化为 struct objc_class 时发现还进行一次和 0xffffffff8 的与操作. 虽然 isa 是一个长度为 8 字节的指针值, 但是它保存的值并不一定是一个 struct objc_class 对象的指针. 在 arm64 位体系架构下的用户进程最大可访问的虚拟内存地址范围是 0x0000000000 - 0x1000000000, 也就是每个用户进程的可用虚拟内存空间是 64GB. 同时因为一个指针类型的变量存在着内存地址对齐的因素所以指针变量的最低 3 位一定是 0. 所以将 isa 中保存的内容和 0xffffffff8 进行与操作得到的值才是真正的对象的 Class 对象指针. arm64 体系架构对 isa 中的内容进行了优化设计, 它除了保存着 Class 对象的指针外, 还保存着诸如 OC 对象自身的引用计数值, 对象是否被弱引用标志, 对象是否建立了关联对象标志, 对象是否正在销毁中等等信息. 如果要想更加详细的了解 isa 的内部结构请参考文章: https://blog.csdn.net/u012581760/article/details/81230721 中的介绍.
思考和实践: 对于所有指针类型的数据, 我们也可以利用其中的特性来使用 0-2 以及 36-63 这两个区段的 bit 位进行一些特定数据的存储和设置, 从而减少一些内存的浪费和开销.
3. 遍历缓存哈希桶并查找缓存中的方法实现
一个 Class 对象的数据成员中有一个方法列表数组保存着这个类的所有方法的描述和实现的函数地址入口. 如果每次方法调用时都要进行一次这样的查找, 而且当调用基类方法时, 还需要遍历基类进行方法查找, 这样势必会对性能造成非常大的损耗. 为了解决这个问题系统为每个类建立了一个哈希表进行方法缓存 (objc_class 中的数据成员 cache 是一个 cache_t 类型的对象) . 这个哈希表缓存由哈希桶来实现, 每次当执行一个方法调用时, 总是优先从这个缓存中进行方法查找, 如果找到则执行缓存中保存的方法函数, 如果不在缓存中才到 Class 对象中的方法列表数组或者基类的方法列表数组中去查找, 当找到后将方法名和方法函数地址保存到缓存中以便下次加速执行. 所以 objc_msgSend 函数第 3 部分的内容主要实现的就是在 Class 对象的缓存哈希表中进行对应方法的查找:
3.1 函数首先将方法名 op 与 cache 中的 mask 进行与操作. 这个 mask 的值是缓存中桶的数量减 1, 一个类初始缓存中的桶的数量是 4, 每次桶数量扩容时都乘 2. 也就是说 mask 的值的二进制的所有 bit 位数全都是 1, 这样当 op 和 mask 进行与操作时也就是取 op 中的低 mask 位数来命中哈希桶中的元素. 因此这个哈希算法所得到的 index 索引值一定是小于缓存中桶的数量而不会出现越界的情况.
3.2 当通过哈希算法得到对应的索引值后, 接下来便判断对应的桶中的 key 值是否和 op 相等. 每个桶是一个 struct bucket_t 结构, 里面保存这方法的名称 (key) 和方法的实现地址(imp). 一旦 key 值和 op 值相等则表明缓存命中, 然后将其中的 imp 值进行保存并结束查找跳出循环; 而一旦 key 值为 NULL 时则表明此方法尚未被缓存, 需要跳出循环进行方法未命中缓存处理; 而当 key 为非 NULL 但是又不等于 op 时则表明出现冲突了, 这里解决冲突的机制是采用开地址法将索引值减 1 来继续循环来查找缓存.
当你读完第 3 部分代码时是否会产生如下几个问题的思考:
问题一: 缓存中哈希桶的数量会随着方法访问的数量增加而动态增加, 那么它又是如何增加的?
问题二: 缓存循环查找是否会出现死循环的情况?
问题三: 当桶数量增加后 mask 的值也会跟着变化, 那么就会存在着前后两次计算 index 的值不一致的情况, 这又如何解决?
问题四: 既然哈希桶的数量会在运行时动态添加那么在多线程访问环境下又是如何做同步和安全处理的?
这四个问题都会在第 4 步中的 objc_msgSend_uncached 函数内部实现中找到答案.
4. 执行方法实现或方法未命中缓存处理函数
当方法在哈希桶中被命中并且存在对应的方法函数实现时就会调用对应的方法实现并且函数返回, 整个函数执行完成. 而当方法没有被缓存时则会调用 objc_msgSend_uncached 函数, 这个函数的实现也是用汇编语言编写的, 它的函数内部做了两件事情: 一是调用_class_lookupMethodAndLoadCache3 函数在 Class 对象中查找方法的实现体函数并返回; 二是调用返回的实现体函数来执行对应的方法. 可以从 _class_lookupMethodAndLoadCache3 https://opensource.apple.com/source/objc4/objc4-723/runtime/objc-runtime-new.mm.auto.html 函数名中看出它的功能实现就是先查找后缓存, 而这个函数则是用 C 语言实现的, 因此可以很清晰的去阅读它的源代码实现._class_lookupMethodAndLoadCache3 函数的源代码实现主要就是先从 Class 对象的方法列表或者基类的方法列表中查找对应的方法和实现, 并且更新到 Class 对象的缓存 cache 中. 如果你仔细阅读里面的源代码就可以很容易回答在第 3 步所提出的四个问题:
问题一: 缓存中哈希桶的数量会随着方法访问的数量增加而动态增加, 那么它又是如何增加的?
答: 每个 Class 类对象初始化时会为缓存分配 4 个桶, 并且 cache 中有一个数据成员 occupied 来保存缓存中已经使用的桶的数量, 这样每当将一个方法的缓存信息保存到桶中时 occupied 的数量加 1, 如果数量到达桶容量的 3/4 时, 系统就会将桶的容量增大 2 倍变, 并按照这个规则依次继续扩展下去.
问题二: 缓存循环查找是否会出现死循环的情况?
答: 不会, 因为系统总是会将空桶的数量保证有 1/4 的空闲, 因此当循环遍历时一定会出现命中缓存或者会出现 key == NULL 的情况而退出循环.
问题三: 当桶数量增加后 mask 的值也会跟着变化, 那么就会存在着前后两次计算 index 的值不一致的情况, 这又如何解决?
答: 每次哈希桶的数量扩容后, 系统会为缓存分配一批新的空桶, 并且不会维护原来老的缓存中的桶的信息. 这样就相当于当对桶数量扩充后每个方法都是需要进行重新缓存, 所有缓存的信息都清 0 并重新开始. 因此不会出现两次 index 计算不一致的问题.
问题四: 既然哈希桶的数量会在运行时动态添加那么在多线程访问环境下又是如何做同步和安全处理的?
答: 在整个 objc_msgSend 函数中对方法缓存的读取操作并没有增加任何的锁和同步信息, 这样目的是为了达到最佳的性能. 在多线程环境下为了保证对数据的安全和同步访问, 需要在写写和读写两种场景下进行安全和同步处理:
首先来考察多线程同时写 cache 缓存的处理方法. 假如两个线程都检测到方法并未在缓存中而需要扩充缓存或者写桶数据时, 在扩充缓存和写桶数据之前使用了一个全局的互斥锁来保证写入的同步处理, 而且在锁住的范围内部还做了一次查缓存的处理, 这样即使在两个线程调用相同的方法时也不会出现写两次缓存的情况. 因此多线程同时写入的解决方法只需要简单的引入一个互斥锁即可解决问题.
再来考察多线程同时读写 cache 缓存的处理方法. 上面有提到当对缓存中的哈希桶进行扩充时, 系统采用的解决方法是完全丢弃掉老缓存的内存数据, 而重新开辟一块新的哈希桶内存并更新 Class 对象 cache 中的所有数据成员. 因此如果处理不当就会在 objc_msgSend 函数的第 3 步中访问 cache 中的数据成员时发生异常. 为了解决这个问题在 objc_msgSend 函数的第四条指令中采用了一种非常巧妙的方法:
0x18378c430 <+16>: ldp x10, x11, [x16, #0x10]
这条指令中会把 cache 中的哈希桶 buckets 和 mask|occupied 整个结构体数据成员分别读取到 x10 和 x11 两个寄存器中去. 因为 CPU 能保证单条指令执行的原子性, 而且在整个后续的汇编代码中函数并没有再次去读取 cache 中的 buckets 和 mask 数据成员, 而是一直使用 x10 和 x11 两个寄存器中的值来进行哈希表的查找. 所以即使其他写线程扩充了 cache 中的哈希桶的数量和重新分配了内存也不会影响当前读线程的数据访问. 在写入线程扩充哈希桶数量时会更新 cache 中的 buckets 和 mask 两个数据成员的值. 这部分的实现代码如下:
- // 设置更新缓存的哈希桶内存和 mask 值.
- void cache_t::setBucketsAndMask(struct bucket_t *newBuckets, mask_t newMask)
- {
- // objc_msgSend uses mask and buckets with no locks.
- // It is safe for objc_msgSend to see new buckets but old mask.
- // (It will get a cache miss but not overrun the buckets' bounds).
- // It is unsafe for objc_msgSend to see old buckets and new mask.
- // Therefore we write new buckets, wait a lot, then write new mask.
- // objc_msgSend reads mask first, then buckets.
- // ensure other threads see buckets contents before buckets pointer
- mega_barrier();
- buckets = newBuckets;
- // ensure other threads see new buckets before new mask
- mega_barrier();
- mask = newMask;
- occupied = 0;
- }
这段代码是用 C++ 编写实现的. 代码中先修改哈希桶数据成员 buckets 再修改 mask 中的值. 为了保证赋值的顺序不被编译器优化这里添加了 mega_baerrier()来实现 编译内存屏障 (Compiler Memory Barrier) https://blog.csdn.net/world_hello_100/article/details/50131497 . 假如不添加编译内存屏障的话, 编译器有可能会优化代码让 mask 先赋值而 buckets 后赋值, 这样会造成什么后果呢? 当写线程先执行完 mask 赋值并在执行 buckets 赋值前读线程执行 ldp x10, x11, [x16, #0x10] 指令时就有可能读取到新的 mask 值和老的 buckets 值, 而新的 mask 值要比老的 mask 值大, 这样就会出现内存数组越界的情况而产生崩溃. 而如果添加了编译内存屏障, 就会保证先执行 buckets 赋值而后执行 mask 赋值, 这样即使在写线程执行完 buckets 赋值后而在执行 mask 赋值前, 读线程执行 ldp x10, x11, [x16, #0x10]时得到新的 buckets 值和老的 mask 值是也不会出现异常. 可见可以在一定的程度上借助编译内存屏障相关的技巧来实现无锁读写同步技术 . 当然假如这段代码不用高级语言而用汇编语言来编写则可以不用编译内存屏障技术而是用 stp 指令来写入新的 buckets 和 mask 值也能实现无锁的读写.
思考和实践: 如果你想了解编译屏障相关的知识请参考文章 https://blog.csdn.net/world_hello_100/article/details/50131497 的介绍
对于多线程读写的情况还有一个问题需要解决, 就是因为写线程对缓存进行了扩充而分配了新的哈希桶内存, 同时会销毁老的哈希桶内存, 而此时如果读线程中正在访问的是老缓存时, 就有可能会因为处理不当时会发生读内存异常而系统崩溃. 为了解决这个问题系统将所有会访问到 Class 对象中的 cache 数据的 6 个 API 函数的开始地址和结束地址保存到了两个全局的数组中:
- uintptr_t objc_entryPoints[] = {cache_getImp, objc_msgSend, objc_msgSendSuper, objc_msgSendSuper2, objc_msgLookup, objc_msgLookupSuper2};
- //LExit 开头的表示的是函数的结束地址.
- uintptr_t objc_exitPoints[] = {LExit_cache_getImp,LExit_objc_msgSend, LExit_objc_msgSendSuper, LExit_objc_msgSendSuper2, LExit_objc_msgLookup,LExit_objc_msgLookupSuper2};
当某个写线程对 Class 对象 cache 中的哈希桶进行扩充时, 会先将已经分配的老的需要销毁的哈希桶内存块地址, 保存到一个全局的垃圾回收数组变量 garbage_refs 中, 然后再遍历当前进程中的所有线程, 并查看线程状态中的当前 PC 寄存器中的值是否在 objc_entryPoints 和 objc_exitPoints 这个范围内. 也就是说查看是否有线程正在执行 objc_entryPoints 列表中的函数, 如果没有则表明此时没有任何函数会访问 Class 对象中的 cache 数据, 这时候就可以放心的将全局垃圾回收数组变量 garbage_refs 中的所有待销毁的哈希桶内存块执行真正的销毁操作; 而如果有任何一个线程正在执行 objc_entryPoints 列表中的函数则不做处理, 而等待下次再检查并在适当的时候进行销毁. 这样也就保证了读线程在访问 Class 对象中的 cache 中的 buckets 时不会产生内存访问异常.
思考和实践: 上面描述的技术解决方案其实就是一种垃圾回收技术的实现. 垃圾回收时不立即将内存进行释放, 而是暂时将内存放到某处进行统一管理, 当满足特定条件时才将所有分配的内存进行统一销毁释放处理.
objc2.0 的 runtime 巧妙的利用了 ldp 指令, 编译内存屏障技术, 内存垃圾回收技术等多种手段来解决多线程数据读写的无锁处理方案, 提升了系统的性能, 你是否 get 到这些技能了呢?
小结
上面就是 objc_msgSend 函数内部实现的所有要说的东西, 您是否在这篇文章中又收获了新的知识? 是否对 Runtime 又有了进一步的认识? 在介绍这些东西时, 还顺便介绍了享元模式的相关概念, 以及对指针类型数据的内存使用优化, 还介绍了多线程下的无锁读写相关的实现技巧等等. 如果你喜欢这篇文章就记得为我点一个赞?? 吧,
欢迎大家访问我的 https://github.com/youngsoft 地址和 简书 https://www.jianshu.com/u/3c9287519f58 地址
作者: 欧阳大哥 2013
来源: http://www.tuicool.com/articles/N7zQ3qi