想要学好一个方向的编程语言,底层基础必定是个必不可少的前提。而在 Objective-C
这个篇章中,类与对象更是基础中的基础,它是能让咱们串联起万物的基石。算法
所以,本篇文章就来好好探索一下 类的本质,类的结构,类的懒加载概念 以及 从编译时到运行时 到底作了什么事情,来完全的了解一下它 。编程
objc源码 .设计模式
OC类对象/实例对象/元类解析数据结构
Objective-C
是一门面向对象的编程语言。每一个对象都是其 类 的实例 , 被称为实例对象 . 每个对象都有一个名为 isa 的指针,指向该对象的类。多线程
而类自己也是一个对象。为何这么说呢?架构
来看下源码app
typedef struct objc_object *id;
typedef struct objc_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
/**/
}
struct objc_object {
private:
isa_t isa;
public:
// ISA() assumes this is NOT a tagged pointer object
Class ISA();
// getIsa() allows this to be a tagged pointer object
Class getIsa();
/*...*/
}
复制代码
首先,对象是一个 id
类型 , 也就是一个指向 objc_object
结构体的指针。而咱们看到 Class
是一个指向 objc_class
结构体指针,而 objc_class
继承于 objc_object
, 所以,咱们说 类也是一个对象 。
其实 对象是类的实例,而类是由元类对象的实例,实例方法存储在类中,一样,类方法则存储在元类中。一样咱们能够得出结论:Objective-C 对象都是 C 语言结构体实现的。
总结:
在
Objective-C
中,每一个对象 ( 其根本是一个objc_object
结构体指针 ) 都有一个名为isa
的指针,指向该对象的类 ( 其根本是一个objc_class
结构体 )。每个类实际上也是一个对象 ( 由于
objc_class
继承与objc_object
),所以每个类也能够接受消息,即调用类方法,而接受者就是类对象isa
所指向的元类。
NSObject
与NSProxy
是两个基类,他们都遵循了<NSObject>
协议,以此为其继承的子类提供了公共接口和能力。
提示 :
OBJC2
中 如下 objc_class
已经被弃用 .
struct objc_class {
Class _Nonnull isa OBJC_ISA_AVAILABILITY;
#if !__OBJC2__
Class _Nullable super_class OBJC2_UNAVAILABLE;
const char * _Nonnull name OBJC2_UNAVAILABLE;
long version OBJC2_UNAVAILABLE;
long info OBJC2_UNAVAILABLE;
long instance_size OBJC2_UNAVAILABLE;
struct objc_ivar_list * _Nullable ivars OBJC2_UNAVAILABLE;
struct objc_method_list * _Nullable * _Nullable methodLists OBJC2_UNAVAILABLE;
struct objc_cache * _Nonnull cache OBJC2_UNAVAILABLE;
struct objc_protocol_list * _Nullable protocols OBJC2_UNAVAILABLE;
#endif
} OBJC2_UNAVAILABLE;
复制代码
类的结构体源码以下 :
typedef struct objc_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
/**/
}
复制代码
首先看到类的本质是一个结构体 , 这也是为何咱们都说 OC 对象的本质其实是个结构体指针的缘由 .
结构体内部结构以下 :
Class ISA
:指向关联类 , 继承自 objc_object
. 参考 isa 的前世此生Class superclass
:父类指针 , 一样参考上述文章中有详细指向探索 .cache_t cache
, 方法缓存存储数据结构 .class_data_bits_t bits
, bits
中存储了属性,方法等类的源数据。接下来咱们就来一一探索一下 cache_t
与 class_data_bits_t
.
struct class_data_bits_t {
uintptr_t bits;
class_rw_t* data() {
return (class_rw_t *)(bits & FAST_DATA_MASK);
}
/*.其余一些方法省略..*/
}
typedef unsigned long uintptr_t;
// data pointer
#define FAST_DATA_MASK 0x00007ffffffffff8UL
复制代码
点进去咱们看到 , 其实这个 bits
, 跟咱们讲述 isa
( isa 的前世此生 ) 时开启了 isa
优化的状况下是大体相同的 .
也就是说 ,
bits
使用了 8 个字节总共 64 个二进制位来存储更多内容 , 读取时经过mask
进行位运算获取所存储指针数据 .
例如 :
class_rw_t* data() {
return (class_rw_t *)(bits & FAST_DATA_MASK);
}
复制代码
经过 bits & FAST_DATA_MASK
获取其中固定二进制位下的数据 , 转化为 class_rw_t
类型的指针 . ( 其实 objc
源码中大多数 bits
字样的写法都是这种处理方法 , 也是一种优化措施 , 被称为 享元设计模式 ) .
class_rw_t
数据结构以下 :
struct class_rw_t {
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;
#if SUPPORT_INDEXED_ISA
uint32_t index;
#endif
/*...*/
}
复制代码
这个就是咱们所熟悉的 方法列表 , 属性列表 , 协议列表等等数据信息了 .
其中方法列表须要注意的是 : (类对象存放对象方法,元类对象存放类方法)
还有一个值得提的就是 const class_ro_t *ro;
. 其源码以下 :
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;
}
复制代码
能够看到 ro
中一样存储了 baseMethodList
, baseProtocols
, ivars
等数据 , 可是 ro
是被 const
修饰的 , 也就是不可变 .
其结构以下图 :
那么 rw
与 ro
是什么关系 , 为何须要重复存储呢 ?
先说结论 .
其实如名称同样 ,
rw
即read write
,ro
即read only
.OC
为了动态的特性 , 在编译器肯定并保存了一份 类的结构数据在ro
中 , 另外存储一份在运行时加载到rw
中 , 供runtime
动态修改使用 .
ro
是不可变的 , 而rw
中methods
,properties
以及protocols
内存空间是可变的 . 这也是 已有类 为何能够动态添加方法 , 确不能动态添加属性的缘由 ( 添加属性会一样添加成员变量 , 也就是 ivar . 而 ivar 是存储在 ro 中的 ) .一样分类不能添加属性的缘由也是如此 ( 关联属性是单独存储在
ObjectAssociationMap
中的 , 跟类的原理并不同 )
首先在 从头梳理 dyld 加载流程 中咱们提到过 , libobjc
的初始化是从 _objc_init
开始的 , 而这个函数中调用了 map_images
, load_images
, 以及 unmap_image
这三个函数 .
其实也就是 dyld
负责将应用由磁盘加载到运行内存中 , 而也是在此时注册的类及元类的数据和内存结构 . 也就是在 map_images
中 .
提示:
那咱们来从源码看下 .
1️⃣、 当
dyld
加载到开始连接主程序的时候 , 递归调用recursiveInitialization
函数 .2️⃣、 这个函数第一次执行 , 进行
libsystem
的初始化 . 会走到doInitialization
->doModInitFunctions
->libSystemInitialized
.3️⃣、
Libsystem
的初始化 , 它会调用起libdispatch_init
,libdispatch
的init
会调用_os_object_init
, 这个函数里面调用了_objc_init
.4️⃣、
_objc_init
中注册并保存了map_images
,load_images
,unmap_image
函数地址.5️⃣ : 注册完毕继续回到
recursiveInitialization
递归下一次调用 , 例如libobjc
, 当libobjc
来到recursiveInitialization
调用时 , 会触发libsystem
调用到_objc_init
里注册好的回调函数进行调用 . 就来到了libobjc
, 调用map_images
.
void map_images(unsigned count, const char * const paths[], const struct mach_header * const mhdrs[]) {
mutex_locker_t lock(runtimeLock);
return map_images_nolock(count, paths, mhdrs);
}
void map_images_nolock(unsigned mhCount, const char * const mhPaths[], const struct mach_header * const mhdrs[]) {
if (hCount > 0) {
_read_images(hList, hCount, totalClasses, unoptimizedTotalClasses);
}
}
复制代码
↓↓
void _read_images(header_info **hList, uint32_t hCount, int totalClasses, int unoptimizedTotalClasses)
{
for (EACH_HEADER) {
classref_t *classlist = _getObjc2ClassList(hi, &count);
for (i = 0; i < count; i++) {
Class cls = (Class)classlist[i];
Class newCls = readClass(cls, headerIsBundle, headerIsPreoptimized);
}
}
}
Class readClass(Class cls, bool headerIsBundle, bool headerIsPreoptimized) {
Class replacing = nil;
if (Class newCls = popFutureNamedClass(mangledName)) {
class_rw_t *rw = newCls->data();
const class_ro_t *old_ro = rw->ro;
memcpy(newCls, cls, sizeof(objc_class));
rw->ro = (class_ro_t *)newCls->data();
newCls->setData(rw);
freeIfMutable((char *)old_ro->name);
free((void *)old_ro);
addRemappedClass(cls, newCls);
replacing = cls;
cls = newCls;
}
}
复制代码
结论 :
能够看到 , 在 dyld
加载类过程当中 , 将 ro
中数据拷贝到 rw
中 , 另外在 realizeClassWithoutSwift
中也有体现 , 这里就不贴出来了 .
而在这以前 , ro
的数据已是处理完毕的 , 也就是说类的结构体在编译期 ro
的数据已经处理完毕 .
那么首先编译期 ro
数据肯定咱们如何验证 ?
答案是明显的 , clang
+ MachoView
ro
数据验证新建一个类 , clang readwrite
一下 , 打开 main.cpp
. 查看到以下 :
static struct _class_ro_t _OBJC_METACLASS_RO_$_LBPerson __attribute__ ((used, section ("__DATA,__objc_const"))) = {
1, sizeof(struct _class_t), sizeof(struct _class_t),
(unsigned int)0,
0,
"LBPerson",
0,
0,
0,
0,
0,
};
static struct _class_ro_t _OBJC_CLASS_RO_$_LBPerson __attribute__ ((used, section ("__DATA,__objc_const"))) = {
0, __OFFSETOFIVAR__(struct LBPerson, name), sizeof(struct LBPerson_IMPL),
(unsigned int)0,
0,
"LBPerson",
(const struct _method_list_t *)&_OBJC_$_INSTANCE_METHODS_LBPerson,
0,
(const struct _ivar_list_t *)&_OBJC_$_INSTANCE_VARIABLES_LBPerson,
0,
(const struct _prop_list_t *)&_OBJC_$_PROP_LIST_LBPerson,
};
复制代码
由上述 clang
中咱们获得 , ro
是存储在 __DATA
段 _objc_const
节中 , 使用 MachOView
打开 macho
文件看到以下 :
获得验证 , ro
中数据在编译期肯定并存储完毕 , 运行时没法修改 .
那么接下来 , 咱们就使用 lldb
来调试一下 , 来实际看下 类中数据的内存布局 .
代码准备
@interface LBPerson : NSObject{
@public
NSString *ivarName;
}
@property (nonatomic, copy) NSString *propertyName;
+ (void)testClassMethod;
- (void)testInstanceMethod;
@end
@implementation LBPerson
+ (void)testClassMethod{
NSLog(@"%s",__func__);
}
- (void)testInstanceMethod{
NSLog(@"%s",__func__);
}
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
LBPerson * person = [[LBPerson alloc] init];
person->ivarName = @"ivar";
person.propertyName = @"property";
[person propertyName];
[person testInstanceMethod];
NSLog(@"123");
}
return 0;
}
复制代码
添加断点到 NSLog
处 , 运行工程 .
lldb
输入指令 p/x LBPerson.class
. 打印以下 :
0x20
个字节就是 bits
呢 ?
isa
联合体 8
字节 , superclass
指针 8
字节 , cache_t
结构体 16
字节.struct objc_class : objc_object {
// Class ISA; // 8
Class superclass; // 8
cache_t cache; // 16
class_data_bits_t bits;
}
struct cache_t {
struct bucket_t *_buckets; // 8
mask_t _mask; // 4
mask_t _occupied; // 4
}
复制代码
另外注意 : 要在 objc
可编译源码进行上述 lldb
调试 , 不然没法强转 class_data_bits_t
.
找到 rw
咱们就分别来看下 成员变量 , 属性 , 方法的存储位置 .
首先在 rw
中咱们看到并无 ivar
. 所以来到 ro
中 .
这里咱们跟 rw
比较一下发现 , rw
与 ro
中 protocols
, properties
与 methods
内存地址是如出一辙的 . 说明了运行时 ro
读取到 rw
中的时候 , 这三个列表是浅拷贝 .
继续获取 ivar
.
ivarName
, 还有自动生成的
_propertyName
. 定义属性时自动会生成
_ + 名称
的实例变量 .
getter
与 setter
, 这也是属性跟成员变量的区别 .getter
与 setter
以上就是类中主要存储数据的内容了 . 探索完 bits
. 下面咱们来看看 cache_t
, 顺便了解了解方法缓存的原理 .
cache_t
做为存储方法缓存的数据结构 , 咱们就来探索一下方法缓存的原理 .
struct cache_t {
struct bucket_t *_buckets; // 缓存数组,即哈希桶
mask_t _mask; // 缓存数组的容量临界值,其实是为了 capacity 服务
mask_t _occupied; // 缓存数组中已缓存方法数量
/* ... */
}
#if __LP64__
typedef uint32_t mask_t; // x86_64 & arm64 asm are less efficient with 16-bits
#else
typedef uint16_t mask_t;
#endif
struct bucket_t {
private:
#if __arm64__
uintptr_t _imp;
SEL _sel;
#else
SEL _sel;
uintptr_t _imp;
#endif
}
复制代码
从源码得知 , cache_t
在 64 位下占用 16 个字节 , 而 imp
与 sel
就是存储在 bucket_t
结构中的.
接下来 , 咱们就使用 lldb
来实际探索一下 方法缓存的原理 .
@interface LBObj : NSObject
- (void)testFunc1;
- (void)testFunc2;
- (void)testFunc3;
@end
@implementation LBObj
- (void)testFunc1{
NSLog(@"%s",__FUNCTION__);
}
- (void)testFunc2{
NSLog(@"%s",__FUNCTION__);
}
- (void)testFunc3{
NSLog(@"%s",__FUNCTION__);
}
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
LBObj * obj = [[LBObj alloc] init];
[obj testFunc1];
[obj testFunc2];
[obj testFunc3];
}
return 0;
}
复制代码
提示 : 在 objc 源码中跑项目 , 不然 lldb 强转会提示找不到 cache_t .
在对象建立前加上断点 , 先来看下缓存桶中数据 .
过掉建立对象断点 , 来到调用方法处 , 再次查看 .
init
的 sel
以及 imp
都已经缓存到了桶中 , 已占用 _occupied
变为 1 , _mask
变为 3 .另外咱们有两个疑问 .
1️⃣ : alloc
方法为何没有缓存 ?
2️⃣ : 为何 init
方法不在 _buckets
第一个位置 ?
答 :
alloc
为何在类的cache_t
中没有缓存呢 ? 答案很明显 , 类方法以及类方法的缓存都是存储在元类中的 . 有兴趣的同窗能够去验证一下 .为何不是按顺序存储呢 , 这个涉及到哈希表的结构设计 , 本篇文章很少作讲述了 . 熟悉的同窗应该清楚 , 另外还有
key
值编码 , 哈希表扩容 , 以及哈希冲突的处理 , 是一个比较经典的题目 , iOS 中使用的也比较多 .
过掉断点执行 testFunc1
. 继续查看 .
init
以及 testFunc1
的 sel
以及 imp
都已经缓存到了桶中 , 已占用 _occupied
变为 2 , _mask
为 3 .过掉断点执行 testFunc2
. 继续查看 .
init
、testFunc1
、testFunc2
的 sel
以及 imp
都已经缓存到了桶中 , 已占用 _occupied
变为 3 , _mask
为 3 .过掉断点执行 testFunc3
. 继续查看 .
这里就比较有意思了 , 以前存储的方法缓存已被清理 , testFunc3
的 sel
以及 imp
缓存到了桶中 . 已占用 _occupied
变为 1 , _mask
为 7 , 为何会这样呢 ? 下面咱们会详细探索 .
实际上缓存容量并不取
_mask
属性 , 而是调用cache_t
结构体中的mask_t capacity();
方法 .
mask_t cache_t::capacity()
{
return mask() ? mask()+1 : 0;
}
mask_t cache_t::mask()
{
return _mask;
}
复制代码
当没有调用过方法 ,
_mask
为 0 , 容量capacity
也为 0 , 当_mask
有值时 , 实际容量为_mask + 1
.
当咱们对 cache_t
的内存结构有了深刻了解以后 , 就能够来看下 OC 调用方法时 , 究竟是如何查找缓存的 . 当缓存到达临界值时 , 又是如何扩容 , 或者说缓存的淘汰策略又是什么样的呢 ?
在 探索OC方法的本质 与 OC方法查找与消息转发 这两篇文章中咱们详细探索了方法的本质和方法查找的完整流程 . 其中第一部分就是 objc_MsgSend
的汇编查找缓存部分 .
以 arm64
为例 ( 不一样架构下对应着不一样的汇编指令集 ) .
ENTRY _objc_msgSend
UNWIND _objc_msgSend, NoFrame
cmp p0, #0 // nil check and tagged pointer check
#if SUPPORT_TAGGED_POINTERS
b.le LNilOrTagged // (MSB tagged pointer looks negative)
#else
b.eq LReturnZero
#endif
ldr p13, [x0] // p13 = isa
GetClassFromIsa_p16 p13 // p16 = class
LGetIsaDone:
CacheLookup NORMAL // calls imp or objc_msgSend_uncached
#if SUPPORT_TAGGED_POINTERS
LNilOrTagged:
b.eq LReturnZero // nil check
// tagged
adrp x10, _objc_debug_taggedpointer_classes@PAGE
add x10, x10, _objc_debug_taggedpointer_classes@PAGEOFF
ubfx x11, x0, #60, #4
ldr x16, [x10, x11, LSL #3]
adrp x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGE
add x10, x10, _OBJC_CLASS_$___NSUnrecognizedTaggedPointer@PAGEOFF
cmp x10, x16
b.ne LGetIsaDone
// ext tagged
adrp x10, _objc_debug_taggedpointer_ext_classes@PAGE
add x10, x10, _objc_debug_taggedpointer_ext_classes@PAGEOFF
ubfx x11, x0, #52, #8
ldr x16, [x10, x11, LSL #3]
b LGetIsaDone
// SUPPORT_TAGGED_POINTERS
#endif
LReturnZero:
// x0 is already zero
mov x1, #0
movi d0, #0
movi d1, #0
movi d2, #0
movi d3, #0
ret
END_ENTRY _objc_msgSend
复制代码
在有了对
isa
的详细了解之后 , 看这些汇编指令仍是比较清楚的 . 不太清楚isa
的同窗能够去查阅查阅 isa 的前世此生 .对于汇编指令不太熟悉的同窗能够借助
Hopper
与IDA
, 他们上面是有汇编语言还原高级伪代码的功能 , 帮助查看 .
在整个 _objc_msgSend
汇编部分 , 其实就是查找缓存的流程 , 也就是咱们所说的快速查找流程 ( 缓存 ) . 这个流程具体步骤以下 :
ENTRY _objc_msgSend
UNWIND _objc_msgSend, NoFrame
cmp p0, #0
#if SUPPORT_TAGGED_POINTERS
LNilOrTagged:
b.eq LReturnZero // nil check
复制代码
方法调用者便是咱们 OC
隐藏参数的第一个 , 在 arm
汇编中 , 方法的第一个参数以及返回值会存储在 x0
寄存器中 .
所以 , 此处就是对 x0
寄存器中的消息接收者是否为空的判断 , 为空就直接返回了 , 这也是为何向空对象发送消息 , 不会崩溃也不会调用的缘由 .
#if SUPPORT_TAGGED_POINTERS
b.le LNilOrTagged // (MSB tagged pointer looks negative)
#else
b.eq LReturnZero
#endif
ldr p13, [x0] // p13 = isa
GetClassFromIsa_p16 p13 // p16 = class
复制代码
因为对象的根本是 objc_object
, 所以每一个对象都有一个 isa
指针 , 而经过对象获取 isa
的时候 , 因为 isa 优化 ( 是否为 nonpointer_isa
以及是不是 taggedPoint ) 须要不一样处理 .
若是这部分不太熟悉的同窗请阅读 isa 的前世此生 , 里面有很是详细的讲述 .
汇编代码具体获取 isa 以下 :
.macro GetClassFromIsa_p16 /* src */
#if SUPPORT_INDEXED_ISA
// Indexed isa
mov p16, $0 // optimistically set dst = src
tbz p16, #ISA_INDEX_IS_NPI_BIT, 1f // done if not non-pointer isa
// isa in p16 is indexed
adrp x10, _objc_indexed_classes@PAGE
add x10, x10, _objc_indexed_classes@PAGEOFF
ubfx p16, p16, #ISA_INDEX_SHIFT, #ISA_INDEX_BITS // extract index
ldr p16, [x10, p16, UXTP #PTRSHIFT] // load class from array
1:
#elif __LP64__
// 64-bit packed isa
and p16, $0, #ISA_MASK
#else
// 32-bit raw isa
mov p16, $0
#endif
.endmacro
复制代码
这里就获取到了 isa
, 放在了 p16
寄存器中 .
其实跟 objc
源码中 getIsa
中的基本差很少 , 只不过一个是用汇编 , 一个是用 c
来实现的 .
inline Class objc_object::getIsa()
{
if (!isTaggedPointer()) return ISA();
uintptr_t ptr = (uintptr_t)this;
if (isExtTaggedPointer()) {
uintptr_t slot =
(ptr >> _OBJC_TAG_EXT_SLOT_SHIFT) & _OBJC_TAG_EXT_SLOT_MASK;
return objc_tag_ext_classes[slot];
} else {
uintptr_t slot =
(ptr >> _OBJC_TAG_SLOT_SHIFT) & _OBJC_TAG_SLOT_MASK;
return objc_tag_classes[slot];
}
}
复制代码
汇编源码 : CacheLookup
.macro CacheLookup
// p1 = SEL, p16 = isa
ldp p10, p11, [x16, #CACHE] // p10 = buckets, p11 = occupied|mask
#if !__LP64__
and w11, w11, 0xffff // p11 = mask
#endif
and w12, w1, w11 // x12 = _cmd & mask
add p12, p10, p12, LSL #(1+PTRSHIFT)
// p12 = buckets + ((_cmd & mask) << (1+PTRSHIFT))
ldp p17, p9, [x12] // {imp, sel} = *bucket
1: cmp p9, p1 // if (bucket->sel != _cmd)
b.ne 2f // scan more
CacheHit $0 // call or return imp
2: // not hit: p12 = not-hit bucket
CheckMiss $0 // miss if bucket->sel == 0
cmp p12, p10 // wrap if bucket == buckets
b.eq 3f
ldp p17, p9, [x12, #-BUCKET_SIZE]! // {imp, sel} = *--bucket
b 1b // loop
3: // wrap: p12 = first bucket, w11 = mask
add p12, p12, w11, UXTW #(1+PTRSHIFT)
// p12 = buckets + (mask << 1+PTRSHIFT)
ldp p17, p9, [x12] // {imp, sel} = *bucket
1: cmp p9, p1 // if (bucket->sel != _cmd)
b.ne 2f // scan more
CacheHit $0 // call or return imp
2: // not hit: p12 = not-hit bucket
CheckMiss $0 // miss if bucket->sel == 0
cmp p12, p10 // wrap if bucket == buckets
b.eq 3f
ldp p17, p9, [x12, #-BUCKET_SIZE]! // {imp, sel} = *--bucket
b 1b // loop
3: // double wrap
JumpMiss $0
.endmacro
复制代码
CacheLookup
有三种状况 , NORMAL
, GETIMP
, LOOKUP
. 咱们这里先走的是 NORMAL
状况 .
根据咱们前面提到 类的结构 , 8 个字节 isa
+ 8 个字节 superClass
, 而后就是 cache_t
了 .
ldp p10, p11, [x16, #CACHE] // p10 = buckets, p11 = occupied|mask
and w12, w1, w11 // x12 = _cmd & mask
add p12, p10, p12, LSL
复制代码
3.1 、 这里就是偏移 16 字节 , 获取到 cache_t
中的 buckets
和 occupied
放到 p10
, p11
中 . 而后将方法名强转成 cache_key_t
与 mask
进行与运算 ( and 指令便是 & )
这里稍微描述一下 , 首先咱们已经知道
mask
的值是缓存中桶的数量减 1,一个类初始缓存中的桶的数量是 4 ,每次桶数量扩容时都乘 2 。也就是说
mask
的值的二进制的全部bit
位数全都是 1,那么方法名转成cache_key_t
和mask
进行与操做时也就是取其中低mask
位数来命中哈希桶中的元素。所以这个哈希算法所获得的
index
索引值必定是小于缓存中桶的数量而不会出现越界的状况。方法查找时是这个流程 , 那么存的时候 也必定是这样 . 下文咱们会继续讲述 .
3.2 、 当经过哈希算法获得对应的索引值后 , 循环遍历比较所要查找的 方法名与桶中所存储方法名 , 每次查找索引值 index--
.
CacheHit $0
, 直接 call or return imp
key
则 if $0 == NORMAL cbz p9, __objc_msgSend_uncached
.key
, 可是没有对应 imp
, 哈希冲突 , index--
继续查找 . 4️⃣、 缓存没有查找到 调用 : __objc_msgSend_uncached
.
STATIC_ENTRY __objc_msgSend_uncached
UNWIND __objc_msgSend_uncached, FrameWithNoSaves
MethodTableLookup
TailCallFunctionPointer x17
END_ENTRY __objc_msgSend_uncached
复制代码
MethodTableLookup
源码以下 :
.macro MethodTableLookup
SignLR
stp fp, lr, [sp, #-16]!
mov fp, sp
/*省略*/
// receiver and selector already in x0 and x1
mov x2, x16
bl __class_lookupMethodAndLoadCache3
.endmacro
复制代码
class_lookupMethodAndLoadCache3
就来到了 C 函数的流程 , 也就是咱们所说的 类和父类消息查找以及转发流程 .
对这部分感兴趣的同窗能够阅读 探索OC方法的本质 与 OC方法查找与消息转发
这两篇文章中咱们详细探索了方法的本质和方法查找的完整流程 .
在知道了方法调用时 , 如何查找缓存 , 咱们来看下当没有找到缓存 ( 也就是结束汇编查找来到了 C 函数的部分 ) 时, 对方法如何进行存储到缓存的 . 达到临界值时又是如何扩容以及存储的 .
在 OC方法查找与消息转发 中咱们说过 , 查找完本身的类以及父类的方法列表后 .
imp
, 会调用到 log_and_fill_cache
.imp
, 首先会有一次动态方法解析的机会 , 最后会来到 获取到 resolveInstanceMethod
中返回的 imp
而且进行 cache_fill
.源码以下 : ( 截取片断 )
/**查找本类 */
{
Method meth = getMethodNoSuper_nolock(cls, sel);
if (meth) {
log_and_fill_cache(cls, meth->imp, sel, inst, cls);
imp = meth->imp;
goto done;
}
}
/**遍历查找父类 */
unsigned attempts = unreasonableClassCount();
for (Class curClass = cls->superclass;
curClass != nil;
curClass = curClass->superclass)
{
if (imp) {
if (imp != (IMP)_objc_msgForward_impcache) {
// Found the method in a superclass. Cache it in this class.
log_and_fill_cache(cls, imp, sel, inst, curClass);
goto done;
}
}
/* 动态方法解析后 */
imp = (IMP)_objc_msgForward_impcache;
cache_fill(cls, sel, imp, inst);
复制代码
log_and_fill_cache
内部也是调用的 cache_fill
, cache_fill
内部调用 cache_fill_nolock
, 所以咱们直接来到这个方法实现 .
static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver) {
cacheUpdateLock.assertLocked();
if (!cls->isInitialized()) return;
// 再查一次 , 确保没有其余线程恰好已经将该方法存储了
if (cache_getImp(cls, sel)) return;
// 取到 cls 类/元类的 cache_t
cache_t *cache = getCache(cls);
// 新的占用数 为旧的 + 1
mask_t newOccupied = cache->occupied() + 1;
mask_t capacity = cache->capacity();
if (cache->isConstantEmptyCache()) {
// 检查是不是类第一次缓存 occupied == 0 && buckets() == emptyBuckets
// cache_t 默认 为 read-only , 替换掉 .
cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE);
}
else if (newOccupied <= capacity / 4 * 3) {
// 加上本次后新的占用数若是不超过总容量的 四分之三 不做处理
}
else {
// 加上本次后新的占用数超过总容量的四分之三 , 进行扩容
cache->expand();
}
// 经过本sel查找缓存 , 找到则占用数无须增长,直接更新该 sel 对应的 imp
//没找到则占用数 + 1 , 并把 sel 与 imp 设置到这个 bucket 中
bucket_t *bucket = cache->find(sel, receiver);
if (bucket->sel() == 0) cache->incrementOccupied();
bucket->set<Atomic>(sel, imp);
}
复制代码
缓存方法的填充在上述源码注释咱们已经解释的很是清楚了 .
咱们来看下是如何扩容的 , 以了解咱们上述探索中 , 调用完 testFunc3
后 _occupied
变为 1
, _mask
为 7
的缘由 .
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(oldCapacity, newCapacity);
}
enum {
INIT_CACHE_SIZE_LOG2 = 2,
INIT_CACHE_SIZE = (1 << INIT_CACHE_SIZE_LOG2)
};
复制代码
能够看到 新容量有如下几个条件 :
INIT_CACHE_SIZE
, 也就是 1 << 2
, 就是 4 .oldCapacity
不为 0 , 则开辟老的容量的两倍 .4
字节 , 新开辟容量从新置为老容量 .容量处理完以后 , reallocate
, 重置哈希桶 , 这也是咱们上面调用完为何 testFunc3
以前存储的缓存没了的缘由 .
🔐 1 、为何每次扩容都要重置哈希桶 ?
🔑答 :
1️⃣ : 因为哈希表的特性 -- 地址映射 , 当每次总表扩容时 , 全部元素的映射都会失效 , 由于总容量变了 , 下标哈希结果也会改变 .
2️⃣ : 若是须要以前全部所缓存的方法都从新存储 , 消耗与花费有点过于大了 .
3️⃣ : 所以知足最新的方法会缓存的前提下 , 老的方法若是每次扩容须要从新存储 , 对于设计缓存自己的意义 ( 为了提高查找方法的效率 ) 就冲突了 .
🔐 2 、为何缓存是在占用了 四分之三 就进行扩容呢 ? iOS
中不少处淘汰策略都采用了 3/4
处理措施 .
🔑答 :
1️⃣ : 扩容并不必定会成功 , 这样就算不成功仍是能保证有空间存储 , 若是用完再扩容 , 扩容失败那么本次方法缓存就会存储失败了 , 基于最多见的
LRU
( 最近最少访问原则 ) 来看 , 显然最新的方法不该该存储失败 .2️⃣ : 综合 空间 和 时间 的利用率以及 哈希冲突问题来看 , 3/4 显然是最佳处理策略 .
3️⃣ : 当为系统老是会将空桶的数量保证有
1/4
的空闲的状况下 ,循环遍历查找缓存时必定会出现命中缓存或者会出现key == NULL
的状况而退出循环 , 以此来避免死循环的状况发生 。
本部分知识文章参考
首先 , 读取缓存并不会进行写入操做 , 所以多个线程都是读取缓存的状况下 , 是不会有资源多线程安全问题的 , 所以 为了效率考量 , 在 _objc_msgSend
汇编读取缓存部分为了效率没有加锁的处理 .
在上述 expand
扩容 , 以及 cache_fill_nolock
中第一步都有一个
cacheUpdateLock.assertLocked();
/*********************************************************************** * Lock management **********************************************************************/
mutex_t runtimeLock;
mutex_t selLock;
mutex_t cacheUpdateLock;
recursive_mutex_t loadMethodLock;
复制代码
系统使用了一个全局的互斥锁 , 在填充缓存时 咱们说到了写入前会 再查一次 , 确保没有其余线程恰好已经将该方法存储了 , 所以多线程写入缓存安全问题得以确保 .
首先咱们来思考一下 , 多线程读写缓存会发生什么问题 .
bucket
中 sel
对应的 imp
, 其余线程读取一样会出现问题 .buckets
, mask
变大 , 而其余线程读取到了旧的 buckets
和新的 mask
, 就会出现越界状况 .那么带着上述问题咱们来探索下 libobjc
是如何保证线程安全的同时最大化不影响性能的需求的 .
问题 1 :
答 : 🔑 为了保证扩容清零不对其余线程读取有影响 为了解决这个问题系统将全部会访问到 Class
对象中的 cache
数据的 6 个 API
函数的开始地址和结束地址保存到了两个全局的数组中:
extern "C" uintptr_t objc_entryPoints[];
extern "C" uintptr_t objc_exitPoints[];
复制代码
当某个写线程对
Class
对象cache
中的哈希桶进行扩充时,会先将已经分配的老的须要销毁的哈希桶内存块地址,保存到一个全局的垃圾回收数组变量garbage_refs
中.而后再遍历当前进程中的全部线程,并查看线程状态中的当前 PC 寄存器中的值是否在
objc_entryPoints
和objc_exitPoints
这个范围内。也就是说查看是否有线程正在执行
objc_entryPoints
列表中的函数 .
- 若是没有则代表此时没有任何函数会访问
Class
对象中的cache
数据,这时候就能够放心的将全局垃圾回收数组变量garbage_refs
中的全部待销毁的哈希桶内存块执行真正的销毁操做;- 而若是有任何一个线程正在执行
objc_entryPoints
列表中的函数则不作处理,而等待下次再检查并在适当的时候进行销毁。这样也就保证了读线程在访问
Class
对象中的cache
中的buckets
时不会产生内存访问异常。
问题 2 / 3 :
答 : 🔑在上述 _objc_msgSend
汇编查找缓存部分 , 咱们看到
ldp x10, x11, [x16, #CACHE] // x10 = buckets, x11 = occupied|mask
复制代码
在整个查找缓存方法中 , 将 buckets
与 mask
( occupied 就是 mask + 1 , mask 为 0 时 occupied 也为 0 , 无须读取 ) 读取到了 x10
与 x11
寄存器中 , 后续处理都是使用的这两个寄存器里的值 .
而汇编指令因为自己就是最小执行单位 . ( 原子(atom)指化学反应不可再分的基本微粒,原子在化学反应中不可分割 ) , 所以绝大多数状况下 , 汇编指令能够保证原子性 .
lock
指令前缀来保护指令执行过程层中的数据安全 .lock
修饰的单条编译指令以及这些特殊的安全指令才算是真正的原子操做lock addl $0x1 %r8d
因此 只要保证读取 _buckets
与 _mask
到 x10 , x11
寄存器 这条指令读取到的是互相匹配的(即要么同时是扩容前的数据,要么同时是扩容后的数据),那么即便在这个过程当中该 bucket
被删掉了 , 也不会影响本次调用 , 由于寄存器中已经存储了 。
那么如何保证 这条指令读取到的 _buckets
与 _mask
是匹配的 , 不会被编译器优化所影响呢 ?
首先咱们知道 _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;
}
复制代码
上述代码中注释已经写的很清楚了 , 利用编译内存屏障仅仅是保证 _buckets
的赋值会优先于 _mask
的赋值,也就是说,指令
ldp x10, x11, [x16, #CACHE]
复制代码
执行后,要么是旧的 _mask
和 旧的 buckets
, 要么是新的 buckets
和旧的 mask
, 都不会出现越界的状况 , 综上这些手段来保证了多线程读写缓存的安全 .
至此 , 方法的缓存查找以及缓存的读取咱们已经探索完毕了 .
cache_t
的 buckets
哈希桶中 . 消息发送时会先查找缓存 , 找到则不会继续方法查找和转发的流程 .cache_t
使用 capacity
( mask = 0 ? mask + 1 : 0
)来记录当前最大容量 .cache_t
使用 occupied
来记录当前已经使用容量 .3/4
时 , 哈希桶会进行扩容 , 扩容容量为当前容量的两倍 ( 当使用超过 4 字节不扩容 ) . 扩容时会清空历史缓存 , 只保留最新 sel
和 imp
.关于类的基础知识 , 所涉及的知识点咱们基本上都讲述完毕了 , 接下来会继续带来 iOS 相关底层知识文章 , 敬请关注 .