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

摘要

只要用到Objective-C,我們每天都會(huì)跟方法調(diào)用打交道。我們都知道Objective-C的方法決議是動(dòng)態(tài)的,但是在底層一個(gè)方法究竟是怎么找到的,方法緩存又是怎么運(yùn)作的卻鮮為人知。本文主要從源碼角度探究了Objective-C在runtime層的方法決議(Method resolving)過程和方法緩存(Method cache)的實(shí)現(xiàn)。

簡(jiǎn)介

本文作者來自美團(tuán)酒店旅游事業(yè)群iOS研發(fā)組。我們致力于創(chuàng)造價(jià)值、提升效率、追求卓越。歡迎大家加入我們(簡(jiǎn)歷請(qǐng)發(fā)送到郵箱 majia03@meituan.com )。
本文系學(xué)習(xí)Objective-C的runtime源碼時(shí)整理所成,主要剖析了Objective-C在runtime層的方法決議過程和方法緩存,內(nèi)容包括:

  • 從消息決議說起
  • 緩存為誰(shuí)而生
  • 追本溯源,何為方法緩存
  • 緩存和散列
  • 十萬個(gè)為什么
  • 緩存 - 性能優(yōu)化的萬金油?
  • 優(yōu)化,永無止境

從消息決議說起

我們都知道,在Objective-C里調(diào)用一個(gè)方法是這樣的:

[object methodA];

這表示我們想去調(diào)用object的methodA。但是在Objective-C里面調(diào)用一個(gè)方法到底意味著什么呢,是否和C++一樣,任何一個(gè)非虛方法都會(huì)被編譯成一個(gè)唯一的符號(hào),在調(diào)用的時(shí)候去查找符號(hào)表,找到這個(gè)方法然后調(diào)用呢?答案是否定的。在Objective-C里面調(diào)用一個(gè)方法的時(shí)候,runtime層會(huì)將這個(gè)調(diào)用翻譯成

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

而objc_msgSend具體又是如何分發(fā)的呢? 我們來看下runtime層objc_msgSend的源碼。在objc-msg-arm.s中,objc_msgSend的代碼如下:(ps:Apple為了高度優(yōu)化objc_msgSend的性能,這個(gè)文件是匯編寫成的,不過即使我們不懂匯編,詳盡的注釋也可以讓我們一窺其真面目)

ENTRY objc_msgSend
# check whether receiver is nil
teq     a1, #0
    beq     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_uncached

LMsgSendNilReceiver:
    mov     a2, #0
    bx      lr

LMsgSendExit:
END_ENTRY objc_msgSend


STATIC_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平臺(tái)而言)的消息分發(fā)分為以下幾個(gè)步驟:

  • 判斷receiver是否為nil,也就是objc_msgSend的第一個(gè)參數(shù)self,也就是要調(diào)用的那個(gè)方法所屬對(duì)象

  • 從緩存里尋找,找到了則分發(fā),否則

  • 利用objc-class.mm中_class_lookupMethodAndLoadCache3(為什么有個(gè)這么奇怪的方法。本文末尾會(huì)解釋)方法去尋找selector

    • 如果支持GC,忽略掉非GC環(huán)境的方法(retain等)
    • 從本class的method list尋找selector,如果找到,填充到緩存中,并返回selector,否則
    • 尋找父類的method list,并依次往上尋找,直到找到selector,填充到緩存中,并返回selector,否則
    • 調(diào)用_class_resolveMethod,如果可以動(dòng)態(tài)resolve為一個(gè)selector,不緩存,方法返回,否則
      轉(zhuǎn)發(fā)這個(gè)selector,否則
  • 報(bào)錯(cuò),拋出異常

緩存為誰(shuí)而生

從上面的分析中我們可以看到,當(dāng)一個(gè)方法在比較“上層”的類中,用比較“下層”(繼承關(guān)系上的上下層)對(duì)象去調(diào)用的時(shí)候,如果沒有緩存,那么整個(gè)查找鏈?zhǔn)窍喈?dāng)長(zhǎng)的。就算方法是在這個(gè)類里面,當(dāng)方法比較多的時(shí)候,每次都查找也是費(fèi)事費(fèi)力的一件事情。考慮下面的一個(gè)調(diào)用過程:

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

當(dāng)我們需要去調(diào)用一個(gè)方法數(shù)十萬次甚至更多地時(shí)候,查找方法的消耗會(huì)變的非常顯著。就算我們平常的非大規(guī)模調(diào)用,除非一個(gè)方法只會(huì)調(diào)用一次,否則緩存都是有用的。在運(yùn)行時(shí),那么多對(duì)象,那么多方法調(diào)用,節(jié)省下來的時(shí)間也是非常可觀的。

追本溯源,何為方法緩存

本著源碼面前,了無秘密的原則,我們看下源碼中的方法緩存到底是什么,在objc-cache.mm中,objc_cache的定義如下:

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

嗯,objc_cache的定義看起來很簡(jiǎn)單,它包含了下面三個(gè)變量:
1)、mask:可以認(rèn)為是當(dāng)前能達(dá)到的最大index(從0開始的),所以緩存的size(total)是mask+1
2)、occupied:被占用的槽位,因?yàn)榫彺媸且陨⒘斜淼男问酱嬖诘模詴?huì)有空槽,而occupied表示當(dāng)前被占用的數(shù)目
3)、buckets:用數(shù)組表示的hash表,cache_entry類型,每一個(gè)cache_entry代表一個(gè)方法緩存(buckets定義在objc_cache的最后,說明這是一個(gè)可變長(zhǎng)度的數(shù)組)
而cache_entry的定義如下:

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

cache_entry定義也包含了三個(gè)字段,分別是:
1)、name,被緩存的方法名字
2)、unused,保留字段,還沒被使用。
3)、imp,方法實(shí)現(xiàn)

緩存和散列

緩存的存儲(chǔ)使用了散列表。為什么要用散列表呢?因?yàn)樯⒘斜頇z索起來更快,我們來看下是方法緩存如何散列和檢索的:

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

這是往方法緩存里存放一個(gè)方法的代碼片段,我們可以看到sel被散列后找到一個(gè)空槽放在buckets中,而CACHE_HASH的定義如下:

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

這段代碼就是利用了sel的指針地址和mask做了一下簡(jiǎn)單計(jì)算得出的。而從散列表取緩存則是利用匯編語(yǔ)言寫成的(是為了高度優(yōu)化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沖突的規(guī)則繼續(xù)向下,直到最后。

十萬個(gè)為什么

了解了方法緩存的定義之后,我們提出幾個(gè)問題并一一解答

  • 方法緩存存在什么地方?讓我們?nèi)シ搭惖亩x,在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字段,沒錯(cuò),類的所有緩存都存在metaclass上,所以每個(gè)類都只有一份方法緩存,而不是每一個(gè)類的object都保存一份。

  • 父類方法的緩存只存在父類么,還是子類也會(huì)緩存父類的方法?在第一節(jié)對(duì)objc_msgSend的追溯中我們可以看到,即便是從父類取到的方法,也會(huì)存在類本身的方法緩存里。而當(dāng)用一個(gè)父類對(duì)象去調(diào)用那個(gè)方法的時(shí)候,也會(huì)在父類的metaclass里緩存一份。

  • 類的方法緩存大小有沒有限制?要回答這個(gè)問題,我們需要再看一下源碼,在objc-cache.mm有一個(gè)變量定義如下:

/* 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;

其實(shí)不用再看進(jìn)一步的代碼片段,僅從注釋我們就可以看到問題的答案。注釋中說明,當(dāng)_class_slow_grow是非0值的時(shí)候,只有當(dāng)方法緩存第奇數(shù)次滿(使用的槽位超過3/4)的時(shí)候,方法緩存的大小才會(huì)增長(zhǎng)(會(huì)清空緩存,否則hash值就不對(duì)了);當(dāng)?shù)谂紨?shù)次滿的時(shí)候,方法緩存會(huì)被清空并重新利用。 如果_class_slow_grow值為0,那么每一次方法緩存滿的時(shí)候,其大小都會(huì)增長(zhǎng)。所以單就問題而言,答案是沒有限制,雖然這個(gè)值被設(shè)置為1,方法緩存的大小增速會(huì)慢一點(diǎn),但是確實(shí)是沒有上限的。

  • 為什么類的方法列表不直接做成散列表呢,做成list,還要單獨(dú)緩存,多費(fèi)事?這個(gè)問題么,我覺得有以下三個(gè)原因:
    • 散列表是沒有順序的,Objective-C的方法列表是一個(gè)list,是有順序的;Objective-C在查找方法的時(shí)候 會(huì)順著list依次尋找,并且category的方法在原始方法list的前面,需要先被找到,如果直接用hash存方法,方法的順序就沒法保證。
    • list的方法還保存了除了selector和imp之外其他很多屬性
    • 散列表是有空槽的,會(huì)浪費(fèi)空間

緩存 - 性能優(yōu)化的萬金油?

非也,就算有了有了Objective-C本身的方法緩存,我們還是有很多調(diào)用方法的優(yōu)化空間,對(duì)于這件事情,這篇文章講的非常詳細(xì),大家可以自行移步觀摩http://www.mulle-kybernetik.com/artikel/Optimization/opti-3-imp-deluxe.html (強(qiáng)烈推薦,雖然我們一般不會(huì)遇到需要這么強(qiáng)度優(yōu)化的地方,但是這種精神和思想是值得我們學(xué)習(xí)的)

優(yōu)化,永無止境

在文章末尾,我們?cè)賮砘卮鹨幌碌谝还?jié)提出的問題:“為什么會(huì)有_class_lookupMethodAndLoadCache3這個(gè)方法?”這個(gè)方法的實(shí)現(xiàn)如下所示:

/***********************************************************************
* _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*/);

如果單純看方法名,這個(gè)方法應(yīng)該會(huì)從緩存和方法列表中查找一個(gè)方法,但是如第一節(jié)所講,在調(diào)用這個(gè)方法之前,我們已經(jīng)是從緩存無法找到這個(gè)方法了,所以這個(gè)方法避免了再去掃描緩存查找方法的過程,而是直接從方法列表找起。從Apple代碼的注釋,我們也完全可以了解這一點(diǎn)。不顧一切地追求完美和性能,是一種品質(zhì)。

后記

本文是Objective-C runtime源碼研究的第二篇,主要對(duì)Objective-C的方法決議和方法緩存做了剖析。runtime的源代碼可以在 http://www.opensource.apple.com/tarballs/ 下載。如有錯(cuò)誤,敬請(qǐng)指正。

本文源自: http://tech.meituan.com/DiveIntoMethodCache.html

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 轉(zhuǎn)至元數(shù)據(jù)結(jié)尾創(chuàng)建: 董瀟偉,最新修改于: 十二月 23, 2016 轉(zhuǎn)至元數(shù)據(jù)起始第一章:isa和Class一....
    40c0490e5268閱讀 1,757評(píng)論 0 9
  • 本文詳細(xì)整理了 Cocoa 的 Runtime 系統(tǒng)的知識(shí),它使得 Objective-C 如虎添翼,具備了靈活的...
    lylaut閱讀 821評(píng)論 0 4
  • objc_getAssociatedObject返回與給定鍵的特定對(duì)象關(guān)聯(lián)的值。ID objc_getAssoci...
    有一種再見叫青春閱讀 1,617評(píng)論 0 7
  • 轉(zhuǎn)載:http://yulingtianxia.com/blog/2014/11/05/objective-c-r...
    F麥子閱讀 760評(píng)論 0 2
  • 祈愿通過學(xué)習(xí)和實(shí)踐金剛智慧,實(shí)現(xiàn)自我成長(zhǎng),妥善處理家庭關(guān)系,社會(huì)關(guān)系,成為智慧的榜樣,并且通過幫助更多的人讓世界變...
    瑩火蟲兒閱讀 133評(píng)論 0 0