重學iOS系列之底層基礎(二)類的本質

? ? ? ? 在上個章節,我們學習了對象的本質,對isa有了一個籠統的概念,了解到對象的本質其實就是一個包含了變量和isa指針的結構體。并且可以通過實例對象的isa獲取到類對象,然后通過類對象的isa獲取到元類的對象。但是我們并不清楚類對象中的具體結構,我們定義的成員變量,屬性,協議,實例方法、類方法都保存在哪?在這個章節中,我們會對這些進行詳細的分析。

? ? ? ? 在分析對象本質的時候,我們知道OC底層其實是用C++編寫的,所以用下面命令將main.m轉換成了c++代碼:

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main__.cpp

我們打開這個文件看看能不能找到一些有用的信息。

查找信息肯定要有針對性才有結果,我們要分析類的本質,應該從Class這個類型出發,我們全局搜索一下看看有沒有針對Class類型的定義。

如果有讀者在Xcode中通過runtime.h進入到頭文件中應該也會發現下面的這個定義

那么這個objc_class結構體內部包含了哪些成員呢?全局搜索一下,沒有發現定義,不過已經有眉目就好辦了,打開objc源碼,全局搜索objc_class。

不過找到的信息似乎已經過期了,OBJC2_UNAVAILABLE表示在OBJC2下已經失效了,并且最后一行的注釋也說明這個結構體被Class替代了,但是Class不是本來就是struct objc_class的重定義嗎?既然是過期,就應該會有新的定義,我們修改一下搜索關鍵詞,將關鍵詞objc_class換成struct objc_class,得到如下結果

可以看到 objc-runtime 分為 new 和 old 2個頭文件,我們要找的肯定是最新的,所以在new 頭文件中找到了上圖的objc_class定義,objc_class繼承自objc_object(這是C++的寫法,C++中類和結構體都是可以繼承的),那么objc_object又是個什么結構呢?

objc_object 內部其實只有一個成員,isa。剩余的都是結構體內部的函數。


從宏觀來看,我們可以知道類的本質是objc_class結構體,里面包含了以下4個成員以及大量的函數:

1、isa_t isa;

2、Class????superclass;

3、cache_t????cache;? ?

4、class_data_bits_t????bits;? ?

。。。。。。。。其它函數

isa在上一節已經分析過了,不再重復分析。

Class其實就是objc_class *,所以第二個成員又可以寫成objc_class * superclass;

那么我們需要分析的就剩下2個了

cache_t????cache

class_data_bits_t????bits


cache_t????cache結構體比較復雜,我們先挑簡單的分析,先分析class_data_bits_t????bits


class_data_bits_t

首先定義了一個friend objc_class,然后是一個bits 指針

friend代表什么意思呢?

friend關鍵字用于修飾C++中的有元類,被修飾的的類可以訪問當前類的私有成員或者調用私有函數。

大家注意看上圖紅線的私有函數,getBits、setBits 等在objc_class中是有調用的

上圖可以看到objc_calss直接用bits.getBit訪問了私有函數,這就是friend修飾后的效果。

說白了,class_data_bits_t其實就是和isa一樣的指針,通過bits將各種信息存儲到8個字節64位中。可以看到上圖 bits.getBit(XX)傳入的是一個宏,該宏的實際值是:

#define FAST_HAS_DEFAULT_RR? ? (1UL<<2)?? ? 1左移2位

也就是說hasCustomRR() 其實是判斷bits 64位中第3個bit位中的值是0還是1。

和isa取值的原理是完全一樣的,通過 & 上不同的宏,來獲取不同bit位上的值,從而得到相關數據。


那么我們再往下翻,看看bits具體能獲取到什么東西

class_rw_t

通過點語法調用了bits的data()函數,來獲取一個類型為class_rw_t的結構體。其實這個結構體在之前的文章已經分析過了。

重學iOS系列之APP啟動(三)objc(runtime)

用一幅圖來表示class_rw_t、ro_or_rw_ext_t、class_ro_t 這3個結構體的關系更好理解

重點解釋下:ro_or_rw_ext_t = objc::PointerUnion<const?class_ro_t, class_rw_ext_t, PTRAUTH_STR("class_ro_t"), PTRAUTH_STR("class_rw_ext_t")>;

這是個什么呢?這是一個Union聯合體(不懂的讀者自行查找資料)。聯合的是傳入的參數 class_ro_t 或 class_rw_ext_t ,也就是說 rw 結構體中有且僅有一個ro或者一個rwe。如果get_ro_or_rwe()返回的是rwe,則取ro是這樣的 ro = rwe -> ro.

總結下:

ro 是體量最小的,而且不可變的只讀的,里面存儲著類的成員變量,基本的方法、屬性、協議等。

rwe 是可變的,可讀寫的,其中包含了ro,而且還有3個數組用于存儲方法、屬性、協議。

rw 中包含了rwe或者ro,如果該類有category則rw中存儲的則是rwe,因為存儲category需要對內存進行寫入,不管是取ro還是取rwe都是調用同一個函數get_ro_or_rwe()。如果是取ro,rw中還有一個單獨的ro()函數,里面的實現也可以說明rw中存儲的要么是rwe要么是ro。


那么什么情況會返回ro,什么情況下會返回rwe呢?

如果有認真閱讀過重學iOS系列之APP啟動(三)objc(runtime)肯定能回答。

如果類存在category,則需要將category中的所有信息附加到類中,需要對類動態的修改內存,所以這種情況下返回的是rwe;

如果只存在單獨的類,則不需要對內存進行修改,則返回的是ro。


Talk is cheap, show me your code.

我們來寫代碼驗證一下,class_rw_t 的結構是否真的如 圖中一樣。

先在objc源碼的main.mm文件中添加一個Student類、category,然后打好斷點運行起來。

然后通過lldb打印出數據如下

$0 是Student 類對象的地址,我們再看看類的結構體

想通過 $0->data() 拿到?class_rw_t 結構體,發現行不通。那么我們只能通過內存偏移來獲取 bits 的地址,再通過 bits->data() 來拿到class_rw_t數據了。

大家都知道結構體成員在內存中都是緊挨著的,如上圖所示:

Student 內存 前8個字節是 isa 指針,后8個字節是 superclass 指針, 然后是cache_t 結構體,只要算出cache_t占用多少字節就可以算出 bits 在內存中的偏移量了。先通過源碼看看?cache_t 結構體包含什么成員。

cache_t結構體成員? ? ? ? ? ? ? ? ? ? ? ? ? ?類型 ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?大小(字節)

_bucketsAndMaybeMask?????????????uintptr_t? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?8字節

_maybeMask??????????????????????????????????mask_t? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? 32bits(4字節)

_flags? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?uint16_t ? ? ? ? ? ? ? ? ? ? ? ? ? ?????16bits(2字節)

_occupied ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ??uint16_t????????????????????????????????16bits(2字節)???

由上述計算可以得?cache_t 占用了 16個字節

那么 bits 的偏移量 = 8 + 8 + 16 = 32 字節 = 16進制 0x20

那么我們就可以這樣來獲取 bits?

?????0x00000001000086d0 是Person class的地址 + 0x20 就是偏移32個字節,這樣就拿到了 bits?

然后通過 ->data() 拿到class_rw_t

在通過 p *$2 打印出class_rw_t 的內容


再看看class_rw_t 中有什么函數可以獲取到類的相關信息

上圖可以知道class_rw_t結構體中有直接獲取 方法、屬性、協議的成員函數。

3個函數的內部實現非常相似都是通過get_ro_or_rwe() 拿到?ro_or_rw_ext_t 類型的一個結構,ro_or_rw_ext_t 前面分析過是一個聯合體,內部存儲的是ro 或者 rw,我們也可以通過下面的命令來獲取

但是在我們嘗試獲取他的具體類型class_rw_ext_t 時失敗了,目前筆者還未找到方法來打印該聯合體的具體類型,有了解的讀者可以私信,不勝感激!

既然我們拿不到具體類型,就無法確定 是 ro 還是rwe ,我們再從源碼找找是否有其他的函數可以調用

找到可以直接獲取class_ro_t 的 ro() 函數,嘗試調用一下

打印數據非常完整,我們拿到了 ro

ro 里是否真的保存了之前列舉的那些數據呢?我們打印一下看看

那么怎么取ivars中的具體值呢?我們先看看 ivar_list_t 類型定義

發現 property_list_t 和?ivar_list_t 都是繼承自同一個類型entsize_list_tt

entsize_list_tt 內部定義了一個成員函數 get(下標)可以獲取到 list 中的 Element ,那么我們就可以直接調用 get() 函數來打印成員變量 和 屬性

實際上我們只定義了3個成員變量,由于@property 定義的屬性系統會自動幫我們生成帶下劃線的成員變量,所以這里的count 為 6 。

那么我們再看看屬性

Student只定義了3個屬性,但是這里打印卻出現了7個,由此可見,llvm幫我們自動幫我們定義了4個屬性:hash、superclass、description、debugDescription。

所以這也是為什么我們可以用點語法獲取類的superclass、description等,這2個屬性應該是相對來說使用頻率高的。


獲取方法列表

咦,怎么返回值是 void * ,我們再找找是否有函數可以調用來獲取MethodList

找到了,執行下面的命令調用get()函數來獲取具體的方法元素

注意 上面?p $6.baseMethodList ?和?p $6.baseMethods() 返回的地址是一樣的,只有類型是不同的,后面會分析為什么

但是為什么get函數返回的值會是空的,我們看看?method_t 結構體是怎么定義的

從注釋可以得知具體的數據被封裝到了 struct big {} 中,神仙操作啊

修改命令,用點語法調用big再重新獲取

方法列表更離譜,竟然有10個

我們明明才定義了5個方法,2個對象方法,2個類方法,1個協議方法 (包括category)

從上圖可以發現,2個類方法不在里面,多出來的方法是?llvm 自動生成的?property 屬性?get、set 方法。

并且,注意看p $30->get(3).big()的結果

(method_t::big) $38 = {

? name = "categoryFunc"

? types = 0x0000000100003d4f "v16@0:8"

? imp = 0x0000000100003b30 (KCObjcBuild`-[Student(TestCategory) categoryFunc] at main.m:55)

}

category的對象方法竟然也在 ro 的baseMethod里面,結合$6.baseMethodList ?和?p $6.baseMethods() 返回的地址一樣,可以分析出類的方法列表有且只有一份存檔,?ro ?和 rw 的方法列表指針都是指向這一份內存地址的。這點和之前版本的 ro 、 rw 的結構是不一樣的,要注意!!!


最后我們把協議也打印下看看

最終打印的數據竟然不能看,這里肯定有什么誤會,我們再從源碼著手,看看是否是類型錯誤導致的

注意看紅線位置的注釋,protocol_ref_t 其實是指向protocol_t *的指針。那么我們可以直接強制轉換類型,將$49 和 $50 強制轉換成?protocol_t * 類型打印

到此,成員變量、屬性、方法、協議 都已經驗證完畢,最后還剩余2個類方法沒有被發現,類方法是保存在類的元類中的,怎么獲取元類呢?其實在上一個章節已經教過大家怎么獲取元類了,方法和實例對象獲取類對象的地址是一樣的,通過 isa 指針 & 上一個宏常量Mask,就能得到元類的地址。

$0 = 0x00000001000086d0 是 類對象的地址

大家注意2根綠線的位置,0x00000001000086a8 是 Student 類對象的 isa 指針,與 Mask 進行 & 計算后 獲取到 元類對象的地址,0x00000001000086a8,沒錯,就是 isa的地址。這可能是個巧合?

然后繼續使用之前獲取類信息的步驟,一步一步獲取到元類的 ro

注意綠線的位置,這次筆者是直接使用強制類型轉換,將原本是 void * 強制轉換成了?method_list_t * ?, 效果是一樣的。

下面的打印和我們在Student類中定義的是一樣的,并且這次LLVM沒有幫我們生成其他的類方法。


結論 :8.18版本的objc 類的信息都可以通過?class_rw_t 的 ro() 函數來獲取到。


彩蛋:在閱讀objc_class 結構體大量的成員函數中發現了3個有意思的函數實現,相信大家看到了也會很吃驚!

getMeta() 獲取元類, 判斷本身是否是元類,如果是,則返回自己,否則調用ISA()函數進行Mask計算。

證明了元類是類對象通過 isa 計算出來的。

isRootClass() ?內部直接判斷 當前類的superclass 是否為nil

證明了根類NSObjcet的父類 為nil。

isRootMetaclass 判斷通過 isa 計算得到的地址是否就是自己

證明 根元類的 isa 指針其實指向的就是自己


結合下圖,會對 類 、 元類、isa 、superclass 之間的關聯 有更深刻的理解

還沒結束呢,我們還有?catch_t 這個結構體沒有分析呢!!!


catch_t

下面分析 catch_t ,顧名思義,從命名來看,catch_t應該是跟緩存有關的,那么緩存了類的什么信息呢?先從?catch_t 結構體的源碼找找線索

從上圖來看(注意看綠線劃的2個函數的參數),catch_t 內部有這么一個函數

?void? ? insert(SEL? ? sel,?????IMP? ? imp,????id? ? receiver)

我們可以大膽的猜想這個函數應該是將緩存的信息插入到容器中,那么再看具體的參數

SEL ?sel ?這不就是方法的符號信息么

IMP? ? imp 方法的實現,或者說imp里存儲著方法執行的首地址

id? ? receiver ? ?接收者,其實就是調用方法的對象

大家再看另外一個函數????cache_getImp(cls(), sel) == imp,該函數的定義就在 cache_t 的上面,從注釋可以知道?cache_getImp 就是從緩存中查找方法的 imp

從cache中拿到imp,由此可以大概的確定?catch_t 應該就是緩存類的方法的,那么具體緩存的是什么方法呢?什么時候會觸發?insert 的調用呢?

實戰打印catch_t

我們用打印 bits 的方式來打印一下 catch_t ,至于為什么要用偏移量的方式打印,是因為正常打印會報錯失敗,和直接打印bits一樣。

在此之前,既然跟方法有關,那么我們就先在Student類添加幾個對象方法,如下

修改main函數內部代碼如下,打上斷點然后運行工程

注意:

1、Student 類只 alloc 申請了內存空間,沒有調用init方法。

2、student.name 是會觸發 屬性的 setName 方法調用的。

也就是說,目前 student 對象沒有調用任何一個方法。

運行結果如上圖,在沒有調用方法的情況下,_occupied 和?_maybeMask 的值都為0

過掉斷點,執行?student.name=@"ZYYC" 語句,這句代碼會調用 setName 方法

可以發現劃線的_maybeMask由之前的0變為了3,_occupied由原來的0變為了1。這中間的變化就是調用了一個方法。我們繼續過斷點?

_maybeMask 的值不變,_occupied 又加 1 了,再繼續過斷點

_maybeMask 竟然直接變成7了,?_occupied 竟然變成了1,這很不符合我們的預期,說明有問題,我們再繼續過斷點看看會發生什么其他的變化

_maybeMask 還是7 ,?_occupied 又增加了 1 。 值的變化不是很有規律,從調用一次方法變化一次的情況,_maybeMask 和?_occupied 的變化可能和 insert()函數有關,我們查看下 insert() 源碼實現邏輯,源碼比較多,挑重點截下來分析

簡述下 insert() 的 流程 :

1、拿到_occupied,并且將值加 1,驗證了調用一次方法值會加 1 的變化。

2、拿到舊的容量oldCapacity,并且賦值到新創建的capacity 中。

3、判斷緩存是否為空,如果為空,則調用reallocate進行初始化操作,申請空間,稍后分析reallocate函數的具體實現。在調用reallocate之前,會對capacity的值做判斷,如果沒有值,則賦值為默認值 4 。

4、判斷是否達到容量的 3/4 或者 7/8,沒有達到則什么都不做

5、判斷是否支持裝滿容量,如果支持,并且容量還未裝滿,則什么都不做

6、上述3、4、5 步驟都判斷失敗的話,則進行擴容操作,擴充的容量為之前的 2 倍,擴容之前判斷是否擴充的容量超過了最大值 2 的 16 次方,如果超過了,則賦值為最大值。最后調用reallocate進行擴容。

7、mask 的值 為容量?capacity - 1,因為?capacity默認為4,所以_maybeMask第一次的值為 3 。并且擴容后容量為 4*2 = 8,_maybeMask = 7。

但是上述流程并沒有解釋 為什么 ?_occupied 的值會在擴容的時候重新賦值為 1 。

我們看看?reallocate 的實現

buckets() 其實就是返回通過?_bucketsAndMaybeMask 拿到地址,然后再做一次 & 計算得到的地址指針。?_bucketsAndMaybeMask 是cache_t 的第一個成員,不知道大家還記得嗎?

沒有發現?_occupied 相關代碼,繼續進入?setBucketsAndMask 函數內部

發現了 在新建的時候以及擴容的時候?_occupied 的值會被重新初始化為 0 。

并且上面的注釋也透露了一點 objc_msgSend 的秘密。

我們都知道OC的方法調用底層其實就是?objc_msgSend 函數的調用,可見objc_msgSend 在查找方法的時候會先到cache_t 緩存中查詢,如果查詢到的話就直接調用,大大的降低了方法查詢的時間。


從上述代碼可以得出我們的方法緩存存儲的容器其實就是 buckets()

從 bucket_t 的結構體成員 也驗證了存儲的確實就是 方法符號 和 方法地址。


有創建就有回收,我們看看官方是怎么回收緩存內存的

調用?_garbage_make_room 創建了一個垃圾場,用于存放需要回收的緩存數據

garbage_refs 是垃圾場的存儲容器

真正回收內存的函數是?cache_t::collectNolock(false) 這句

先看看?_garbage_make_room 里面是怎么創建垃圾場的

如果是第一次進入就申請內存,創建容器空間。否則判斷容器是否滿,滿了就擴容為 2 倍。

然后看看?collectNolock 的內部實現

看劃綠線的位置,判斷 需要回收的垃圾大小是否達到一個閾值,如果沒有達到閾值則不會回收,直接return了

綠圈內部才是真正進行內存回收的邏輯,調用了非常熟悉的 free 函數進行內存回收。

擴容這部分代碼分析完畢,繼續分析?insert 后半部分插入的邏輯

上述代碼進行真正的插入邏輯,該邏輯會進行檢查防止重復緩存。

再回過頭來分析下?isConstantEmptyCache 內部是怎么判斷為空的

內部調用了emptyBucketsForCapacity ,注意第二個參數傳入了 false?

emptyBucketsList 是一個二維數組,所有空的Buckets都是從這個二維數組中取的。

源碼分析完畢,上述打印的結果得到驗證,又到了實戰環節了,我們這次把緩存的方法符號給打印出來。


之前斷點運行到了下圖所示位置

目前?_occupied = 2 , 說明緩存中有2個方法,以上圖斷點的位置,應該存儲的是 instenceFunc2 和?instenceFunc3

打印第一個緩存的方法符號如下,正是????instenceFunc3?

繼續打印

咦,怎么是nil,有點奇怪哦,繼續打印

找到了????instenceFunc2

筆者將剩余的位置打印出來了,都是nil,就不再將截圖傳上來。

結論: ? cache_t ? ?會將對象調用的方法進行緩存, 緩存的方法不是按照順序存儲的,是以hash算法計算位置查詢空插槽進行插入的,并且在每次擴容的時候會將之前存儲的方法全部清空。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,321評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,559評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,442評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,835評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,581評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,922評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,931評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,096評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,639評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,374評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,591評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,104評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,789評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,196評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,524評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,322評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,554評論 2 379

推薦閱讀更多精彩內容