探索底層原理,積累從點滴做起。大家好,我是Mars。
往期回顧
iOS底層原理探索—OC對象的本質
iOS底層原理探索—class的本質
iOS底層原理探索—KVO的本質
iOS底層原理探索— KVC的本質
iOS底層原理探索— Category的本質(一)
iOS底層原理探索— Category的本質(二)
iOS底層原理探索— 關聯對象的本質
iOS底層原理探索— block的本質(一)
iOS底層原理探索— block的本質(二)
今天繼續帶領大家探索iOS之Runtime
的本質。
前言
OC是一門動態性比較強的編程語言,它的動態性是基于Runtime
的API
。Runtime
在我們的實際開發中占據著重要的地位,在面試過程中也經常遇到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)結構,同時使用位域來存儲更多的信息:
也就是說,我們之前熟知的OC對象的
isa
指針并不是直接指向類對象或者元類對象的內存地址,而是需要&ISA_MASK
通過位運算才能獲取到類對象或者元類對象的地址。
現在大家可能心存疑問,什么是共用體?什么是位域?位運算又是什么?不要著急,接下來一一為大家解答。
1、位域
位域是指信息在存儲時,并不需要占用一個完整的字節, 而只需占一個或幾個二進制位。例如生活中的電燈開關,它只有“開”、“關”兩種狀態,那我們就可以用 1
和 0
來分別代表這兩種狀態,這樣我們就僅僅用了一個二進制位就保存了開關的狀態。這樣一來不僅節省存儲空間,還使處理更加簡便。
2、位運算符
在計算機語言中,除了加、減、乘、除等這樣的算術運算符之外還有很多運算符,這里只為大家簡單講解一下位運算符。
位運算符用來對二進制位進行操作,當然,操作數只能為整型和字符型數據。C
語言中六種位運算符: &
按位與、 |
按位或、 ^
按位異或、 ~
非、 <<
左移和 >>
右移。
我們依舊引用上面的電燈開關論,只不過現在我們有兩個開關,1
代表開,0
代表關。
1) 按位與 &
有0出0,全1為1。
我們可以理解為在按位與運算中,兩個開關是串聯的,如果我們想要燈亮,需要兩個開關都打開燈才會亮,所以是
1 & 1 = 1
。如果任意一個開關沒打開,燈都不會亮,所以其他運算都是0。
2) 按位或 |
有1出1,全0出0。
在按位或運算中,我們可以理解為兩個開關是并聯的,即一個開關開,燈就會亮。只有當兩個開關都是關的,燈才不會亮。
3) 按位異或 ^
相同為0,不同為1。
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
類型的屬性,分別為tall
、rich
、handsome
,通過這三個屬性來判斷這個人是否高富帥。
然后我們查看一下一個
Man
類對象所占據的內存大小:我們看到,一個
Man
類的對象占16個字節。其中包括一個isa
指針和三個BOOL
類型的屬性,8+1+1+1=11,根據內存對齊原則所以一個Man
類的對象占16個字節。
我們知道,BOOL
值只有兩種情況:0
或 1
,占據一個字節的內存空間。而一個字節的內存空間中又有8個二進制位,并且二進制同樣只有 0
或 1
,那么我們完全可以使用1個二進制位來表示一個BOOL
值。也就是說我們上面聲明的三個BOOL
值最終只使用3個二進制位就可以,這樣就節省了內存空間。那我們如何實現呢?
想要實現三個BOOL
值存放在一個字節中,我們可以通過char
類型的成員變量來實現。char
類型占一個字節內存空間,也就是8個二進制位。可以使用其中最后三個二進制位來存儲3個BOOL
值。
當然我們不能把char
類型寫成屬性,因為一旦寫成屬性,系統會自動幫我們添加成員變量,自動實現set
和get
方法。
@interface Man()
{
char _tallRichHandsome;
}
如果我們賦值_tallRichHansome
為1
,即0b 0000 0001
,只使用8個二進制位中的最后3個分別用0
或者1
來代表tall
、rich
、handsome
的值。那么此時tall
、rich
、handsome
的狀態為:
結合我們上文將的6中位運算符以及使用場景,我們可以分別聲明
tall
、rich
、handsome
的掩碼,來方便我們進行下一步的位運算取值和賦值:
#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
代碼執行后,返回的肯定是一個整型數,如當tall
為YES
時,說明二進制數為0b 0000 0100
,對應的十進制數為4,那么進行一次邏輯非運算后,!(4)
的值為0
,對0
再進行一次邏輯非運算!(0)
,結果就成了1
,那么正好跟tall
為YES
對應。所以此處進行兩次邏輯非運算,!!
。
當然,還要實現初始化方法:
- (instancetype)init
{
if (self = [super init]) {
_tallRichHandsome = 0b00000100;
}
return self;
}
通過測試驗證,我們完成了取值和賦值:
使用結構體位域優化代碼
我們上文講到了位域
的概念,那么我們就可以使用結構體位域
來優化一下我們的代碼。這樣就不用再額外聲明上面代碼中的掩碼部分。位域聲明的格式是位域名 : 位域長度
。
在使用位域
的過程中需要注意以下幾點:
1.如果一個字節所剩空間不夠存放另一位域時,應從下一單元起存放該位域。
2.位域的長度不能大于數據類型本身的長度,比如int
類型就不能超過32位二進位。
3.位域可以無位域名,這時它只用來作填充或調整位置。無名的位域是不能使用的。
使用位域優化以后:
測試看一下是否正確,這次我們將
tall
設為YES
、rich
設為NO
、handsome
設為YES
:依舊完成賦值和取值。
但是代碼這樣優化后我們去掉了掩碼和初始化的代碼,可讀性很差,我們繼續使用共用體進行優化:
使用共用體優化代碼
我們可以使用比較高效的位運算來進行賦值和取值,使用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
共用體只占用一個字節,因為結構體中tall
、rich
、handsome
都只占一位二進制空間,所以結構體只占一個字節,而char
類型的bits
也只占一個字節,他們都在共用體中,因此共用一個字節的內存即可。
而且我們在set
、get
方法中的賦值和取值通過使用掩碼進行位運算來增加效率,整體邏輯也就很清晰了。
但是,如果我們在日常開發中這樣寫代碼的話,很可能會被同事打死。雖然代碼已經很清晰了,但是整體閱讀起來很是很吃力的。我們在這里學習位運算以及共用體這些知識,更多的是為了方便我們閱讀OC底層的代碼。下面我們就回到本文主題,查看一下isa_t
共用體的源碼。
isa_t共用體
我們發現在
isa_t
共用體內用宏ISA_BITFIELD
定義了位域,我們進入位域內查看源碼:我們看到,在內部分別定義了
arm64
位架構和x86_64
架構的掩碼和位域。我們只分析arm64
位架構下的部分內容(紅色標注部分)。可以清楚看到
ISA_BITFIELD
位域的內容以及掩碼ISA_MASK
的值:0x0000000ffffffff8ULL
。我們重點看一下
uintptr_t shiftcls : 33;
,在shiftcls
中存儲著類對象和元類對象的內存地址信息,我們上文講到,對象的isa
指針需要同ISA_MASK
經過一次按位與運算才能得出真正的類對象地址。那么我們將ISA_MASK
的值0x0000000ffffffff8ULL
轉化為二進制數分析一下:從圖中可以看到
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