Runtime 一: OC 方法的底层数据结构和缓存机制

今天研究一下 OC 中方法的底层实现原理,在研究method之前,我们先搞清楚Class的底层数据结构.
先用一张图说明类的底层数据结构,然后我们在从runtime源码中验证:

类的底层数据结构图

我们在runtime源码中搜索struct objc_class {知道类的底层数据结构主要如下:

struct objc_class {
    Class ISA;
    Class superclass;
    cache_t cache; // 方法缓存
    class_data_bits_t bits; // 获取具体的类信息
}

class_data_bits_t bits中存储具体的类的信息,在class_data_bits_t结构体内部仔细查找,发现有这么一句代码:

class_rw_t* data() {
        return (class_rw_t *)(bits & FAST_DATA_MASK);
    }

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;
}

class_ro_t是只读表,里面存储着类的原始信息:

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;//instance对象占用的内存空间,class_getInstanceSize
#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的,是runtime后来创建的,这一点我们也可以从源码中找到:

class_rw_t 的创建

现在结合rutime源码和截图,我们总结一下class底层结构关系:

  • 1: Class底层结构体主要有4个成员变量:isa , superClass , catche , bits.
  • 2:catche中存储的调用过的方法的缓存,这个我们下面会讲.
  • 3:bits & FAST_DATA_MASK会得到一个可读可写的数据表:class_rw_t,class_rw_t用来存储类原始信息和分类附加的信息.需要注意的是,class_rw_t这个表一开始是不存在的,后来需要的时候才创建的.
  • 4:class_ro_t是只可读的数据表,它里面存储着类原始的信息

OC方法的调用顺序是如果是调用实例方法,就通过实例对象的isa指针找到类对象,从类对象的方法列表中查找,如果如果没找到,在通过superClass从父类的方法列表中查找,这样一层一层往上找;如果是调用类方法,就通过类对象的isa找到元类,从元类的方法列表中查找,如果没找到再打通过superClass到元类的父类中查找...
但是我们想想,如果一个方法调用的很频繁,难道每次都要通过这种方式一遍遍查找吗?显然这种方式是低效的,runtime采用了一种更高效的方式来处理这种情况:如果方法第一次被调用后,会缓存到 cache 中,下次再调用的时候直接从 cache 中查找.
cache的底层结构如下:

cache 底层结构

bucket_t的底层结构如下:
bucket_t 底层结构

buckets就是一个数组,里面存放着一个一个的bucket_t:
bucekts 数组

那么runtime是如何从cache中查找方法的呢?难道也是遍历buckets数组吗?肯定不是,遍历数组的那不就跟没优化一样吗?cache的工作原理是:采用散列表的方式把方法插入到buckets时,会用 SEL & _mask得到一个索引值,直接把bucket_t插入到索引值所在的位置.
这样的话就不用每次一个个遍历去查找方法,效率很高.但是这样会有个问题:SEL & _mask 得到索引值并不是按顺序的,他是无序的.比如说:如果 SEL & _mask = 20,那么前面 19 个内存单元就要置为 null 了,这就是 散列表的弊端,牺牲空间换时间.
这种方式虽然效率大大提升了,但是会有个弊端:如果两个 SEL 按位与 _mask 得到的索引相同怎么办?这种情况是很可能发生的.我们从runtime源代码中看看是怎么处理这种情况的.
cache_t结构体中有一个struct bucket_t * find(cache_key_t key, id receiver)方法,这个方法里面就是从buckets查找方法的逻辑:
find 方法

继续进入chche_next():
chache_next() 方法

从上图可以看到,如果索引相等,从buckets中找到bucket_t,然后取出bucket_t中的key和传入的key判断,如果两个key不相等,在arm64环境下,会先把i - 1后再&_mask得到一个新的索引,继续查找,直到找到两个key相等位置.
现在我们已经知道了Class的底层数据机构以及runtime是如何存储和查找方法的.下面我们将研究一下method_t:
method_t 在 class_rw_t 中的位置

method_t 结构体

  • SEL: 方法名,函数名,一般叫做选择器,底层结构跟 char *类似
    · 可以通过@selectorsel_registerName()获得.
    · 可以通过sel_getName()NSStringFromSelector()转成字符串.
    · 不同类中相同名字的方法,所对应的方法选择器是相同的.
  • IMP: 函数的具体实现
  • types: 包含了函数的返回值类型,参数类型编码的字符串
    例如我们随便声明一个函数- (void)test;,他的types就是v16@0:8.代表的意思是:
    types 解释图

    有人可能会觉得奇怪,- (void)test方法并没有参数呀,为什么types会多出两个参数?
    实际上,每一个OC方法都会默认有两个参数,比如说- (void)test的完整形式就是:- (void)test:(id self SEL sel),这也就是为什么我们能在每个方法中调用self,其实是方法参数.至于为什么voidv表示,id@,这都是苹果规定的,在苹果官方文档中有对照表:
    对照表

另外iOS中还提供了一个@encode()指令,可以将具体类型转换成字符串编码.

encode

总结:

  • OC 方法在第一次调用后,会被添加到cache_t缓存中,下次调用时直接从缓存中查找.
  • cache_t中有三个成员变量buckets,_mask,_occupied:
    buckets是一个数组,存放着一个个的bucket_t.
    _maskbuckets数组的数量减 1,外部传入的 SEL & _mask得到buckets数组中的索引(下标).
    _occupied:已经缓存的方法.
  • bucket_t有两个成员key,imp
    key就是SEL;imp就是方法的实现地址.
  • OC 方法的底层是method_t 结构体,主要有三个成员:
    name:函数名称;
    types:编码,(函数返回值类型和参数类型);
    imp:函数地址

验证:
上面讲的都是从源码中推测出来的理论,实际上是不是这样呢?我们自己敲代码验证一番.
我们创建3个类Son , Mother , Person,他们之间的继承关系是:Son : Mother : Person,这3个类中都有一个- (void)personTest;方法.

方法调用之前

方法调用之后

_buckets 扩容:
现在我们更改一下代码,创建一个Son的实例对象son,分别调用Son , Mother , Persontest方法:

扩容之前

我们过掉断点看看会发生什么:
扩容之后

从结果中我们可以看到_mask的数量从4变成了8,并且_buckets中之前缓存的方法也没有了,只缓存了一个方法personTest.
这是由于_buckets的扩容机制造成的.我们在objc-cache.mm中查找void cache_t::expand()方法:
expand()

我们在进入reallocate方法:
reallocate 重新分配内存

OK,通过上面两张图我们知道了buckets是如何扩展容量的:如果 buckets 的容量不够用了,就直接用旧容量 乘以 2 ,重新分配内存空间.并且把旧的缓存方法都清除.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,734评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,931评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,133评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,532评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,585评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,462评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,262评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,153评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,587评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,792评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,919评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,635评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,237评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,855评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,983评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,048评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,864评论 2 354

推荐阅读更多精彩内容