深入理解 Objective-C:方法缓存

摘要

只要用到Objective-C,我们每天都会跟方法调用打交道。我们都知道Objective-C的方法决议是动态的,但是在底层一个方法究竟是怎么找到的,方法缓存又是怎么运作的却鲜为人知。本文主要从源码角度探究了Objective-C在Runtime层的方法决议(Method resolving)过程和方法缓存(Method cache)的实现。

简介

本文作者来自美团酒店旅游事业群iOS研发组。我们致力于创造价值、提升效率、追求卓越。欢迎大家加入我们(简历请发送到邮箱 majia03@meituan.com )。

本文系学习Objective-C的Runtime源码时整理所成,主要剖析了Objective-C在Runtime层的方法决议过程和方法缓存,内容包括:

  • 从消息决议说起
  • 缓存为谁而生
  • 追本溯源,何为方法缓存
  • 缓存和散列
  • 十万个为什么
  • 缓存 - 性能优化的万金油?
  • 优化,永无止境

从消息决议说起

我们都知道,在Objective-C里调用一个方法是这样的:

[object methodA];

这表示我们想去调用object的methodA。

但是在Objective-C里面调用一个方法到底意味着什么呢,是否和C++一样,任何一个非虚方法都会被编译成一个唯一的符号,在调用的时候去查找符号表,找到这个方法然后调用呢?

答案是否定的。在Objective-C里面调用一个方法的时候,Runtime层会将这个调用翻译成

objc_msgSend(id self, SEL op, ...)

而objc_msgSend具体又是如何分发的呢? 我们来看下Runtime层objc_msgSend的源码。

在objc-msg-arm.s中,objc_msgSend的代码如下:

(ps:Apple为了高度优化objc_msgSend的性能,这个文件是汇编写成的,不过即使我们不懂汇编,详尽的注释也可以让我们一窥其真面目)

ENTRY objc_msgSend
# check whether receiver is nil
teq     a1, #0beq     LMsgSendNilReceiver# save registers and load receiver's class for CacheLookup
stmfd   sp!, {a4,v1}
ldr     v1, [a1, #ISA]# receiver is non-nil: search the cache
CacheLookup a2, v1, LMsgSendCacheMiss# cache hit (imp in ip) and CacheLookup returns with nonstret (eq) set, restore registers and call
ldmfd   sp!, {a4,v1}
bx      ip# cache miss: go search the method lists
LMsgSendCacheMiss:
ldmfd sp!, {a4,v1}
b _objc_msgSend_uncachedLMsgSendNilReceiver:mov     a2, #0bx      lrLMsgSendExit:
END_ENTRY objc_msgSendSTATIC_ENTRY objc_msgSend_uncached# Push stack frame
stmfd sp!, {a1-a4,r7,lr}
add     r7, sp, #16# Load class and selector
ldr a3, [a1, #ISA] /* class = receiver->isa  */
/* selector already in a2 */
/* receiver already in a1 */# Do the lookup
MI_CALL_EXTERNAL(__class_lookupMethodAndLoadCache3)
MOVE    ip, a1# Prep for forwarding, Pop stack frame and call imp
teq v1, v1 /* set nonstret (eq) */
ldmfd sp!, {a1-a4,r7,lr}
bx ip

从上述代码中可以看到,objc_msgSend(就arm平台而言)的消息分发分为以下几个步骤:

  1. 判断receiver是否为nil,也就是objc_msgSend的第一个参数self,也就是要调用的那个方法所属对象
  2. 从缓存里寻找,找到了则分发,否则
  3. 利用objc-class.mm中_class_lookupMethodAndLoadCache3(为什么有个这么奇怪的方法。本文末尾会解释)方法去寻找selector
    1. 如果支持GC,忽略掉非GC环境的方法(retain等)
    2. 从本class的method list寻找selector,如果找到,填充到缓存中,并返回selector,否则
    3. 寻找父类的method list,并依次往上寻找,直到找到selector,填充到缓存中,并返回selector,否则
    4. 调用_class_resolveMethod,如果可以动态resolve为一个selector,不缓存,方法返回,否则
    5. 转发这个selector,否则
  4. 报错,抛出异常

缓存为谁而生

从上面的分析中我们可以看到,当一个方法在比较“上层”的类中,用比较“下层”(继承关系上的上下层)对象去调用的时候,如果没有缓存,那么整个查找链是相当长的。就算方法是在这个类里面,当方法比较多的时候,每次都查找也是费事费力的一件事情。

考虑下面的一个调用过程:

for ( int i = 0; i < 100000; ++i) {MyClass *myObject = myObjects[i];[myObject methodA];
}

当我们需要去调用一个方法数十万次甚至更多地时候,查找方法的消耗会变的非常显著。

就算我们平常的非大规模调用,除非一个方法只会调用一次,否则缓存都是有用的。在运行时,那么多对象,那么多方法调用,节省下来的时间也是非常可观的。

追本溯源,何为方法缓存

本着源码面前,了无秘密的原则,我们看下源码中的方法缓存到底是什么,在objc-cache.mm中,objc_cache的定义如下:

struct objc_cache {uintptr_t mask;            /* total = mask + 1 */uintptr_t occupied;       cache_entry *buckets[1];
};

嗯,objc_cache的定义看起来很简单,它包含了下面三个变量:

  1. mask:可以认为是当前能达到的最大index(从0开始的),所以缓存的size(total)是mask+1
  2. occupied:被占用的槽位,因为缓存是以散列表的形式存在的,所以会有空槽,而occupied表示当前被占用的数目
  3. buckets:用数组表示的hash表,cache_entry类型,每一个cache_entry代表一个方法缓存

(buckets定义在objc_cache的最后,说明这是一个可变长度的数组)

而cache_entry的定义如下:

typedef struct {SEL name;     // same layout as struct old_methodvoid *unused;IMP imp;  // same layout as struct old_method
} cache_entry;

cache_entry定义也包含了三个字段,分别是:

  1. name,被缓存的方法名字
  2. unused,保留字段,还没被使用。
  3. imp,方法实现

缓存和散列

缓存的存储使用了散列表。

为什么要用散列表呢?因为散列表检索起来更快,我们来看下是方法缓存如何散列和检索的:

// Scan for the first unused slot and insert there.
// There is guaranteed to be an empty slot because the 
// minimum size is 4 and we resized at 3/4 full.
buckets = (cache_entry **)cache->buckets;
for (index = CACHE_HASH(sel, cache->mask); buckets[index] != NULL; index = (index+1) & cache->mask)
{// empty
}
buckets[index] = entry;

这是往方法缓存里存放一个方法的代码片段,我们可以看到sel被散列后找到一个空槽放在buckets中,而CACHE_HASH的定义如下:

#define CACHE_HASH(sel, mask) (((uintptr_t)(sel)>>2) & (mask))

这段代码就是利用了sel的指针地址和mask做了一下简单计算得出的。

而从散列表取缓存则是利用汇编语言写成的(是为了高度优化objc_msgSend而使用汇编的)。我们看objc-msg-arm.mm 里面的CacheLookup方法:

.macro CacheLookup /* selReg, classReg, missLabel */MOVE r9, $0, LSR #2          /* index = (sel >> 2) */ldr     a4, [$1, #CACHE]        /* cache = class->cache */add     a4, a4, #BUCKETS        /* buckets = &cache->buckets *//* search the cache */
/* a1=receiver, a2 or a3=sel, r9=index, a4=buckets, $1=method */
1:ldr     ip, [a4, #NEGMASK]      /* mask = cache->mask */and     r9, r9, ip              /* index &= mask           */ldr     $1, [a4, r9, LSL #2]    /* method = buckets[index] */teq     $1, #0                  /* if (method == NULL)     */add     r9, r9, #1              /* index++                 */beq     $2                      /*     goto cacheMissLabel */ldr     ip, [$1, #METHOD_NAME]  /* load method->method_name        */teq     $0, ip                  /* if (method->method_name != sel) */bne     1b                      /*     retry                       *//* cache hit, $1 == method triplet address */
/* Return triplet in $1 and imp in ip      */ldr     ip, [$1, #METHOD_IMP]   /* imp = method->method_imp */.endmacro

虽然是汇编,但是注释太详尽了,理解起来并不难,还是求hash,去buckets里找,找不到按照hash冲突的规则继续向下,直到最后。

十万个为什么

了解了方法缓存的定义之后,我们提出几个问题并一一解答

方法缓存存在什么地方?

让我们去翻看类的定义,在Objective-C 2.0中,Class的定义大致是这样的(见objc-Runtime.mm)

struct _class_t {struct _class_t *isa;struct _class_t *superclass;void *cache;void *vtable;struct _class_ro_t *ro;
};

我们看到在类的定义里就有cache字段,没错,类的所有缓存都存在metaclass上,所以每个类都只有一份方法缓存,而不是每一个类的object都保存一份。

父类方法的缓存只存在父类么,还是子类也会缓存父类的方法?

在第一节对objc_msgSend的追溯中我们可以看到,即便是从父类取到的方法,也会存在类本身的方法缓存里。而当用一个父类对象去调用那个方法的时候,也会在父类的metaclass里缓存一份。

类的方法缓存大小有没有限制?

要回答这个问题,我们需要再看一下源码,在objc-cache.mm有一个变量定义如下:

/* When _class_slow_grow is non-zero, any given cache is actually grown* only on the odd-numbered times it becomes full; on the even-numbered* times, it is simply emptied and re-used.  When this flag is zero,* caches are grown every time. */
static const int _class_slow_grow = 1;

其实不用再看进一步的代码片段,仅从注释我们就可以看到问题的答案。注释中说明,当_class_slow_grow是非0值的时候,只有当方法缓存第奇数次满(使用的槽位超过3/4)的时候,方法缓存的大小才会增长(会清空缓存,否则hash值就不对了);当第偶数次满的时候,方法缓存会被清空并重新利用。 如果_class_slow_grow值为0,那么每一次方法缓存满的时候,其大小都会增长。

所以单就问题而言,答案是没有限制,虽然这个值被设置为1,方法缓存的大小增速会慢一点,但是确实是没有上限的。

为什么类的方法列表不直接做成散列表呢,做成list,还要单独缓存,多费事?

这个问题么,我觉得有以下三个原因:

  • 散列表是没有顺序的,Objective-C的方法列表是一个list,是有顺序的;Objective-C在查找方法的时候会顺着list依次寻找,并且category的方法在原始方法list的前面,需要先被找到,如果直接用hash存方法,方法的顺序就没法保证。
  • list的方法还保存了除了selector和imp之外其他很多属性
  • 散列表是有空槽的,会浪费空间

缓存 - 性能优化的万金油?

非也,就算有了有了Objective-C本身的方法缓存,我们还是有很多调用方法的优化空间,对于这件事情,这篇文章讲的非常详细,大家可以自行移步观摩http://www.mulle-kybernetik.com/artikel/Optimization/opti-3-imp-deluxe.html (强烈推荐,虽然我们一般不会遇到需要这么强度优化的地方,但是这种精神和思想是值得我们学习的)

优化,永无止境

在文章末尾,我们再来回答一下第一节提出的问题:“为什么会有_class_lookupMethodAndLoadCache3这个方法?”

这个方法的实现如下所示:

/***********************************************************************
* _class_lookupMethodAndLoadCache.
* Method lookup for dispatchers ONLY. OTHER CODE SHOULD USE lookUpImp().
* This lookup avoids optimistic cache scan because the dispatcher
* already tried that.
**********************************************************************/
IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls)
{return lookUpImpOrForward(cls, sel, obj, YES/*initialize*/, NO/*cache*/, YES/*resolver*/);
}

如果单纯看方法名,这个方法应该会从缓存和方法列表中查找一个方法,但是如第一节所讲,在调用这个方法之前,我们已经是从缓存无法找到这个方法了,所以这个方法避免了再去扫描缓存查找方法的过程,而是直接从方法列表找起。从Apple代码的注释,我们也完全可以了解这一点。不顾一切地追求完美和性能,是一种品质。

后记

本文是 Objective-C Runtime 源码研究的第二篇,主要对 Objective-C 的方法决议和方法缓存做了剖析。

Runtime的源代码可以在 http://www.opensource.apple.com/tarballs/ 下载。如有错误,敬请指正。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/477981.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

学术联赛 | 运用知识图谱技术,赋能多领域应用 ——“未来杯”AI学术联赛总决赛暨颁奖典礼圆满落幕...

本文转载自公众号&#xff1a;AI学习社。由北京大学软件工程国家工程研究中心主办&#xff0c;华为终端有限公司及中软国际教育科技集团全程战略支持&#xff0c;STEER TECH科技平台、北京乐智元素科技有限公司、艾肯文化传媒&#xff08;北京&#xff09;有限公司、AI TIME承办…

LeetCode 1013. 将数组分成和相等的三个部分

1. 题目 给定一个整数数组 A&#xff0c;只有我们可以将其划分为三个和相等的非空部分时才返回 true&#xff0c;否则返回 false。 形式上&#xff0c;如果我们可以找出索引 i1 < j 且满足 (A[0] A[1] … A[i] A[i1] A[i2] … A[j-1] A[j] A[j-1] … A[A.lengt…

异常值检测

#设定异常值比例 outliers_fraction 0.01# 初始化 LSCP 探测集 detector_list [LOF(n_neighbors5), LOF(n_neighbors10), LOF(n_neighbors15),LOF(n_neighbors20), LOF(n_neighbors25), LOF(n_neighbors30),LOF(n_neighbors35), LOF(n_neighbors40), LOF(n_neighbors45),LOF(…

谷歌 | 多任务学习,如何挑选有效的辅助任务?只需一个公式!

文 | 小伟编 | 小轶前言说到多任务学习&#xff0c;大家都不陌生&#xff0c;不管是在学术界还是工业界都已经有了很多成熟的探索与应用。在理想的多任务学习中&#xff0c;各个任务对彼此应当是有益的&#xff0c;所有任务相互促进&#xff0c;从而达到超过单任务学习的效果。…

LeetCode 888. 公平的糖果交换(哈希set)

文章目录1. 题目2. 解题2.1 暴力查找2.2 哈希set1. 题目 爱丽丝和鲍勃有不同大小的糖果棒&#xff1a;A[i] 是爱丽丝拥有的第 i 块糖的大小&#xff0c;B[j] 是鲍勃拥有的第 j 块糖的大小。 因为他们是朋友&#xff0c;所以他们想交换一个糖果棒&#xff0c;这样交换后&#…

剑指offer-python代码解释-习题解答-空白请点击阅读更多

版权声明&#xff1a;本文为博主原创文章&#xff0c;未经博主允许不得转载。 https://blog.csdn.net/u012505432/article/details/52071537 </div><link rel"stylesheet" href"https://csdnimg.cn/release/phoenix/template/css/ck_htmledit_v…

OpenKG开源系列 | 面向知识的推理问答编程语言KoPL(清华大学)

OpenKG地址&#xff1a;http://openkg.cn/tool/koplGitHub地址&#xff1a;https://github.com/THU-KEG/KoPL网站地址&#xff1a;https://kopl.xlore.cn开放许可协议&#xff1a;MIT License贡献者&#xff1a;清华大学&#xff08;曹书林、史佳欣、姚子俊、吕鑫、聂麟骁、逄凡…

前端组件化开发实践

前言 一位计算机前辈曾说过&#xff1a; Controlling complexity is the essence of computer programming.随着前端开发复杂度的日益提升&#xff0c;组件化开发应运而生&#xff0c;并随着 FIS、React 等优秀框架的出现遍地开花。这一过程同样发生在美团&#xff0c;面临业务…

LeetCode 937. 重新排列日志文件(自定义排序)

1. 题目 你有一个日志数组 logs。每条日志都是以空格分隔的字串。 对于每条日志&#xff0c;其第一个字为字母数字标识符。然后&#xff0c;要么&#xff1a; 标识符后面的每个字将仅由小写字母组成&#xff0c;或&#xff1b;标识符后面的每个字将仅由数字组成。 我们将这…

预训练时代微调新范式,高性能加速2800%,NLPer赶紧看过来!

一、导读PaddleNLP 是兼具科研学习和产业实践能力的 Python NLP 工具包&#xff0c;提供中文领域丰富的预训练模型和部署工具&#xff0c;被高校、企业开发者广泛应用。近日&#xff0c;PaddleNLP v2.1正式发布&#xff0c;为开发者带来三项重要更新&#xff1a;开箱即用的工业…

论文浅尝 | GaussianPath: 用于知识图谱推理的贝叶斯多跳推理框架

笔记整理&#xff1a;谭亦鸣&#xff0c;东南大学博士生来源&#xff1a;AAAI’21链接&#xff1a;https://ojs.aaai.org/index.php/AAAI/article/view/16565多跳推理由于对下游任务例如问答和图谱补全的可解释性受到关注。多跳推理是一个典型的顺序决策过程&#xff0c;可表述…

老子【道德经】全文翻译(全81章)

老子道德经全文翻译&#xff08;全81章&#xff09;阅读 &#xff1a; 【老子第一章】 道可道&#xff0c;非常道。名可名&#xff0c;非常名[1]。 无名天地之始&#xff1b;有名万物之母。 故常无&#xff0c;欲以观其妙&#xff1b;常有&#xff0c;欲以观其徼。 此两者&…

RACSignal的Subscription深入分析

ReactiveCocoa是一个FRP的思想在Objective-C中的实现框架&#xff0c;目前在美团的项目中被广泛使用。对于ReactiveCocoa的基本用法&#xff0c;网上有很多相关的资料&#xff0c;本文不再讨论。RACSignal是ReactiveCocoa中一个非常重要的概念&#xff0c;而本文主要关注RACSig…

AllenAI 发布万能问答系统 MACAW!各类题型样样精通,性能大幅超越 GPT-3!

文 | python前言GPT-3 等超大规模预训练语言模型&#xff0c;在少监督任务&#xff08;few-shot tasks&#xff09;上取得了令人瞩目的成绩。而这篇文章中&#xff0c;AllenAI的研究员提出了大规模生成式问答模型&#xff0c;MACAW。基于多角度预训练&#xff0c;MACAW可以用于…

论文浅尝 | SMBOP: Semi-autoregressive Bottom-up Semantic Parsing

笔记整理&#xff1a;陈永锐&#xff0c;东南大学博士来源&#xff1a;NAACL 2021概述近年来语义解析的事实上的标准解码方法是使用自顶向下的深度优先遍历对目标程序的抽象语法树进行自回归解码。该工作提出了一种替代方法&#xff1a;半自回归自底向上解析器&#xff08;SMBO…

美团酒店Node全栈开发实践

前后端分离的背景 “前后端分离”显然已不是什么新鲜的话题&#xff0c;Zakas在2013年10月份就曾发表过一篇博客《Node.js and the new web front-end》讨论Node背景下新时代的前端。毫无疑问&#xff0c;Node的出现给JavaScript语言带来了新的生机&#xff0c;也使得前端开发者…

统计学习方法总结

统计学习方法总结 阅读目录(Content)0. 相关知识点0x1: 监督学习1. 模型假设空间2. 生成模型与判别模型的联系与区别 3. 学习策略4. 分类问题与回归问题5. 利用模型进行预测和分析0x2&#xff1a;模型评估与模型选择1. 训练误差与测试误差2. 过拟合与模型选择0x3&#xff1a;正…

LeetCode 997. 找到小镇的法官(图的出度和入度)

1. 题目 在一个小镇里&#xff0c;按从 1 到 N 标记了 N 个人。传言称&#xff0c;这些人中有一个是小镇上的秘密法官。 如果小镇的法官真的存在&#xff0c;那么&#xff1a; 小镇的法官不相信任何人。每个人&#xff08;除了小镇法官外&#xff09;都信任小镇的法官。只有…

哈工大|NLP数据增强方法?我有15种

文 | rumor源 | 李rumor卷友们好&#xff0c;我是rumor。十一假期过的太快了&#xff0c;不知道你们缓过来没有&#xff0c;没有的话今天我们就来一起读一篇综述缓缓&#xff0c;弥补假期没学习的遗憾。这篇40多页的综述出自哈工大车万翔老师的团队&#xff0c;一共总结了15种N…

论文浅尝 | Wordly Wise(WoW) - 用于语音视觉知识问答的跨语言知识融合模型

笔记整理: 谭亦鸣&#xff0c;东南大学博士生来源&#xff1a;NAACL’21链接&#xff1a;https://aclanthology.org/2021.naacl-main.153.pdf论文提出了一种新的知识图谱问答数据集命名为FVSQA&#xff0c;这是一种语音视觉知识问答类型的任务&#xff0c;即问题形式为音频&…