iOS底層原理探索— Runtime之isa的本質

探索底層原理,積累從點滴做起。大家好,我是Mars。

往期回顧

iOS底層原理探索—OC對象的本質
iOS底層原理探索—class的本質
iOS底層原理探索—KVO的本質
iOS底層原理探索— KVC的本質
iOS底層原理探索— Category的本質(一)
iOS底層原理探索— Category的本質(二)
iOS底層原理探索— 關聯對象的本質
iOS底層原理探索— block的本質(一)
iOS底層原理探索— block的本質(二)

今天繼續帶領大家探索iOS之Runtime的本質。

前言

OC是一門動態性比較強的編程語言,它的動態性是基于RuntimeAPIRuntime在我們的實際開發中占據著重要的地位,在面試過程中也經常遇到Runtime相關的面試題,我們在之前幾期的探索分析時也經常會到Runtime的底層源碼中查看相關實現。Runtime對于iOS開發者的重要性不言而喻,想要學習和掌握Runtime的相關技術,就要從Runtime底層的一些常用數據結構入手。掌握了它的底層結構,我們學習起來也能達到事半功倍的效果。今天先學習isa

isa

我們在iOS底層原理探索—OC對象的本質一文中講解OC對象本質的時候提到,每個OC對象的底層結構體中都包含一個isa指針:

struct NSObject_IMPL {
    Class isa;
};

arm64架構之前,isa僅是一個指針,保存著類對象(Class)或元類對象(Meta-Class)的內存地址,在arm64架構之后,蘋果對isa進行了優化,變成了一個isa_t類型的共用體(union)結構,同時使用位域來存儲更多的信息:

isa.h中objc_object部分代碼.png

也就是說,我們之前熟知的OC對象的isa指針并不是直接指向類對象或者元類對象的內存地址,而是需要&ISA_MASK通過位運算才能獲取到類對象或者元類對象的地址。

現在大家可能心存疑問,什么是共用體?什么是位域?位運算又是什么?不要著急,接下來一一為大家解答。

1、位域

位域是指信息在存儲時,并不需要占用一個完整的字節, 而只需占一個或幾個二進制位。例如生活中的電燈開關,它只有“開”、“關”兩種狀態,那我們就可以用 10 來分別代表這兩種狀態,這樣我們就僅僅用了一個二進制位就保存了開關的狀態。這樣一來不僅節省存儲空間,還使處理更加簡便。

2、位運算符

在計算機語言中,除了加、減、乘、除等這樣的算術運算符之外還有很多運算符,這里只為大家簡單講解一下位運算符。
位運算符用來對二進制位進行操作,當然,操作數只能為整型和字符型數據。C語言中六種位運算符: & 按位與、 | 按位或、 ^ 按位異或、 ~ 非、 << 左移和 >> 右移。
我們依舊引用上面的電燈開關論,只不過現在我們有兩個開關,1代表開,0代表關。

1) 按位與 &

有0出0,全1為1。

按位與.png

我們可以理解為在按位與運算中,兩個開關是串聯的,如果我們想要燈亮,需要兩個開關都打開燈才會亮,所以是1 & 1 = 1。如果任意一個開關沒打開,燈都不會亮,所以其他運算都是0。

2) 按位或 |

有1出1,全0出0。


按位或.png

在按位或運算中,我們可以理解為兩個開關是并聯的,即一個開關開,燈就會亮。只有當兩個開關都是關的,燈才不會亮。

3) 按位異或 ^

相同為0,不同為1。


按位異或.png

4) 非 ~

非運算即取反運算,在二進制中 1 變 0 ,0 變 1。例如110101進行非運算后為001010,即1010

5) 左移 <<

左移運算就是把<<左邊的運算數的各二進位全部左移若干位,移動的位數即<<右邊的數的數值,高位丟棄,低位補0。
左移n位就是乘以2的n次方。例如:a<<4是指把a的各二進位向左移動4位。如a=00000011(十進制3),左移4位后為00110000(十進制48)。

6) 右移 >>

右移運算就是把>>左邊的運算數的各二進位全部右移若干位,>>右邊的數指定移動的位數。例如:設 a=15,a>>2 表示把00001111右移為00000011(十進制3)。

簡單了解了位運算符后,下面為大家介紹位運算符的兩種運用場景。

位運算符的運用

1、取值

可以利用按位與 & 運算取出指定位的值,具體操作是想取出哪一位的值就將那一位置為1,其它位都為0,然后同原數據進行按位與計算,即可取出特定的位。

例:0000 0011取出倒數第三位的值

// 想取出倒數第三位的值,就將倒數第三位的值置為1,其它位為0,跟原數據按位與運算
  0000 0011
& 0000 0100
------------
  0000 0000  // 得出按位與運算后的結果,即可拿到原數據中倒數第三位的值為0

上面的例子中,我們從0000 0011中取值,則0000 0011被稱之為源碼。進行按位與操作設定的0000 0100稱之為掩碼

2、設值

可以通過按位或 |運算符將某一位的值設為1或0。具體操作是:
想將某一位的值置為1的話,那么就將掩碼中對應位的值設為1,掩碼其它位為0,將源碼與掩碼進行按位或操作即可。

例:將0000 0011倒數第三位的值改為1

// 改變倒數第三位的值,就將掩碼倒數第三位的值置為1,其它位為0,跟源碼按位或運算
  0000 0011
| 0000 0100
------------
  0000 0111  // 即可將源碼中倒數第三位的值改為1

想將某一位的值置為0的話,那么就將掩碼中對應位的值設為0,掩碼其它位為1,將源碼與掩碼進行按位或操作即可。

例:將0000 0011倒數第二位的值改為0

// 改變倒數第二位的值,就將掩碼倒數第二位的值置為0,其它位為1,跟源碼按位或運算
  0000 0011
| 1111 1101
------------
  0000 0001  // 即可將源碼中倒數第二位的值改為0

到這里相信大家對位運算符有了一定的了解,下面我們通過OC代碼的一個例子,來將位運算符運用到實際代碼開發中。

我們聲明一個Man類,類中有三個BOOL類型的屬性,分別為tallrichhandsome,通過這三個屬性來判斷這個人是否高富帥。

Man類.png

然后我們查看一下一個Man類對象所占據的內存大小:
Man類對象占據內存.png

我們看到,一個Man類的對象占16個字節。其中包括一個isa指針和三個BOOL類型的屬性,8+1+1+1=11,根據內存對齊原則所以一個Man類的對象占16個字節。

我們知道,BOOL值只有兩種情況:01,占據一個字節的內存空間。而一個字節的內存空間中又有8個二進制位,并且二進制同樣只有 01 ,那么我們完全可以使用1個二進制位來表示一個BOOL值。也就是說我們上面聲明的三個BOOL值最終只使用3個二進制位就可以,這樣就節省了內存空間。那我們如何實現呢?

想要實現三個BOOL值存放在一個字節中,我們可以通過char類型的成員變量來實現。char類型占一個字節內存空間,也就是8個二進制位。可以使用其中最后三個二進制位來存儲3個BOOL值。

當然我們不能把char類型寫成屬性,因為一旦寫成屬性,系統會自動幫我們添加成員變量,自動實現setget方法。

@interface Man()
{
    char _tallRichHandsome;
}

如果我們賦值_tallRichHansome1,即0b 0000 0001 ,只使用8個二進制位中的最后3個分別用0或者1來代表tallrichhandsome的值。那么此時tallrichhandsome的狀態為:

char類型含義.png

結合我們上文將的6中位運算符以及使用場景,我們可以分別聲明tallrichhandsome的掩碼,來方便我們進行下一步的位運算取值和賦值:

#define Tall_Mask 0b00000100 //此二進制數對應十進制數為 4
#define Rich_Mask 0b00000010 //此二進制數對應十進制數為 2
#define Handsome_Mask 0b00000001 //此二進制數對應十進制數為 1

通過對位運算符的左移 <<右移 >>的了解,我們可以將上面的代碼優化成:

#define Tall_Mask (1<<2) // 0b00000100
#define Rich_Mask (1<<1) // 0b00000010
#define Handsome_Mask (1<<0) // 0b00000001

自定義的set方法如下:

- (void)setTall:(BOOL)tall
{
    if (tall) { // 如果需要將值置為1,將源碼和掩碼進行按位或運算
        _tallRichHandsome |= Tall_Mask;
    }else{ // 如果需要將值置為0 // 將源碼和按位取反后的掩碼進行按位與運算
        _tallRichHandsome &= ~Tall_Mask;
    }
}
- (void)setRich:(BOOL)rich
{
    if (rich) {
        _tallRichHandsome |= Rich_Mask;
    }else{
        _tallRichHandsome &= ~Rich_Mask;
    }
}
- (void)setHandsome:(BOOL)handsome
{
    if (handsome) {
        _tallRichHandsome |= Handsome_Mask;
    }else{
        _tallRichHandsome &= ~Handsome_Mask;
    }
}

自定義的get方法如下:

- (BOOL)isTall
{
    return !!(_tallRichHandsome & Tall_Mask);
}
- (BOOL)isRich
{
    return !!(_tallRichHandsome & Rich_Mask);
}
- (BOOL)isHandsome
{
    return !!(_tallRichHandsome & Handsome_Mask);
}

此處需要注意的是,代碼中!為邏輯運算符,因為_tallRichHandsome & Tall_Mask代碼執行后,返回的肯定是一個整型數,如當tallYES時,說明二進制數為0b 0000 0100,對應的十進制數為4,那么進行一次邏輯非運算后,!(4)的值為0,對0再進行一次邏輯非運算!(0),結果就成了1,那么正好跟tallYES對應。所以此處進行兩次邏輯非運算,!!

當然,還要實現初始化方法:

- (instancetype)init
{
    if (self = [super init]) {
        _tallRichHandsome = 0b00000100;
    }
    return self;
}

通過測試驗證,我們完成了取值和賦值:


測試代碼.png

使用結構體位域優化代碼

我們上文講到了位域的概念,那么我們就可以使用結構體位域來優化一下我們的代碼。這樣就不用再額外聲明上面代碼中的掩碼部分。位域聲明的格式是位域名 : 位域長度
在使用位域的過程中需要注意以下幾點:

1.如果一個字節所剩空間不夠存放另一位域時,應從下一單元起存放該位域。
2.位域的長度不能大于數據類型本身的長度,比如int類型就不能超過32位二進位。
3.位域可以無位域名,這時它只用來作填充或調整位置。無名的位域是不能使用的。

使用位域優化以后:

使用位域優化后的代碼.png

測試看一下是否正確,這次我們將tall設為YESrich設為NOhandsome設為YES
優化后測試.png

依舊完成賦值和取值。
但是代碼這樣優化后我們去掉了掩碼和初始化的代碼,可讀性很差,我們繼續使用共用體進行優化:

使用共用體優化代碼

我們可以使用比較高效的位運算來進行賦值和取值,使用union共用體來對數據進行存儲。這樣不僅可以增加讀取效率,還可以增強代碼可讀性。

#define Tall_Mask (1<<2) // 0b00000100
#define Rich_Mask (1<<1) // 0b00000010
#define Handsome_Mask (1<<0) // 0b00000001

@interface Man()
{
    union {
        char bits;
       // 結構體僅僅是為了增強代碼可讀性
        struct {
            char tall : 1;
            char rich : 1;
            char handsome : 1;
        };
    }_tallRichHandsome;
}
@end

@implementation Man

- (void)setTall:(BOOL)tall
{
    if (tall) {
        _tallRichHandsome.bits |= Tall_Mask;
    }else{
        _tallRichHandsome.bits &= ~Tall_Mask;
    }
}
- (void)setRich:(BOOL)rich
{
    if (rich) {
        _tallRichHandsome.bits |= Rich_Mask;
    }else{
        _tallRichHandsome.bits &= ~Rich_Mask;
    }
}
- (void)setHandsome:(BOOL)handsome
{
    if (handsome) {
        _tallRichHandsome.bits |= Handsome_Mask;
    }else{
        _tallRichHandsome.bits &= ~Handsome_Mask;
    }
}
- (BOOL)isTall
{
    return !!(_tallRichHandsome.bits & Tall_Mask);
}
- (BOOL)isRich
{
    return !!(_tallRichHandsome.bits & Rich_Mask);
}
- (BOOL)isHandsome
{
    return !!(_tallRichHandsome.bits & Handsome_Mask);
}

其中_tallRichHandsome共用體只占用一個字節,因為結構體中tallrichhandsome都只占一位二進制空間,所以結構體只占一個字節,而char類型的bits也只占一個字節,他們都在共用體中,因此共用一個字節的內存即可。
而且我們在setget方法中的賦值和取值通過使用掩碼進行位運算來增加效率,整體邏輯也就很清晰了。

但是,如果我們在日常開發中這樣寫代碼的話,很可能會被同事打死。雖然代碼已經很清晰了,但是整體閱讀起來很是很吃力的。我們在這里學習位運算以及共用體這些知識,更多的是為了方便我們閱讀OC底層的代碼。下面我們就回到本文主題,查看一下isa_t共用體的源碼。

isa_t共用體

isa_t源碼.png

我們發現在isa_t共用體內用宏ISA_BITFIELD定義了位域,我們進入位域內查看源碼:
ISA_BITFIELD內部源碼.png

我們看到,在內部分別定義了arm64位架構和x86_64架構的掩碼和位域。我們只分析arm64位架構下的部分內容(紅色標注部分)。
可以清楚看到ISA_BITFIELD位域的內容以及掩碼ISA_MASK的值:0x0000000ffffffff8ULL
我們重點看一下uintptr_t shiftcls : 33;,在shiftcls中存儲著類對象和元類對象的內存地址信息,我們上文講到,對象的isa指針需要同ISA_MASK經過一次按位與運算才能得出真正的類對象地址。那么我們將ISA_MASK的值0x0000000ffffffff8ULL轉化為二進制數分析一下:
ISA_MASK轉化為二進制.png

從圖中可以看到ISA_MASK的值轉化為二進制中有33位都為1,上文講到按位與運算是可以取出這33位中的值。那么就說明同ISA_MASK進行按位與運算就可以取出類對象和元類對象的內存地址信息。

我們繼續分析一下結構體位域中其他的內容代表的含義:

struct {
    // 0代表普通的指針,存儲著類對象、元類對象的內存地址。
    // 1代表優化后的使用位域存儲更多的信息。
    uintptr_t nonpointer        : 1; 

   // 是否有設置過關聯對象,如果沒有,釋放時會更快
    uintptr_t has_assoc         : 1;

    // 是否有C++析構函數,如果沒有,釋放時會更快
    uintptr_t has_cxx_dtor      : 1;

    // 存儲著類對象、元類對象對象的內存地址信息
    uintptr_t shiftcls          : 33; 

    // 用于在調試時分辨對象是否未完成初始化
    uintptr_t magic             : 6;

    // 是否有被弱引用指向過。
    uintptr_t weakly_referenced : 1;

    // 對象是否正在釋放
    uintptr_t deallocating      : 1;

    // 引用計數器是否過大無法存儲在isa中
    // 如果為1,那么引用計數會存儲在一個叫SideTable的類的屬性中
    uintptr_t has_sidetable_rc  : 1;

    // 里面存儲的值是引用計數器減1
    uintptr_t extra_rc          : 19;
};

至此我們已經對isa指針有了新的認識,__arm64__架構之后,isa指針不單單只存儲了類對象和元類對象的內存地址,而是使用共用體的方式存儲了更多信息,其中shiftcls存儲了類對象和元類對象的內存地址,需要同ISA_MASK進行按位與 &運算才可以取出其內存地址值。

更多技術知識請關注公眾號
iOS進階


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

推薦閱讀更多精彩內容