isa的本質
在學習Runtime之前首先需要對isa的本質有一定的瞭解,這樣之後學習Runtime會更便於理解。
回顧OC物件的本質,每個OC物件都含有一個isa指標,__arm64__
之前,isa僅僅是一個指標,儲存著物件或類物件記憶體地址,在__arm64__
架構之後,apple對isa進行了優化,變成了一個共用體(union)結構,同時使用位域來儲存更多的資訊。
我們知道OC物件的isa指標並不是直接指向類物件或者元類物件,而是需要&ISA_MASK
通過位運算才能獲取到類物件或者元類物件的地址。今天來探尋一下為什麼需要&ISA_MASK
才能獲取到類物件或者元類物件的地址,以及這樣的好處。
首先在原始碼中找到isa指標,看一下isa指標的本質。
// 擷取objc_object內部分程式碼
struct objc_object {
private:
isa_t isa;
}
複製程式碼
isa指標其實是一個isa_t型別的共用體,來到isa_t內部檢視其結構
// 精簡過的isa_t共用體
union isa_t
{
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }
Class cls;
uintptr_t bits;
#if SUPPORT_PACKED_ISA
# if __arm64__
# define ISA_MASK 0x0000000ffffffff8ULL
# define ISA_MAGIC_MASK 0x000003f000000001ULL
# define ISA_MAGIC_VALUE 0x000001a000000001ULL
struct {
uintptr_t nonpointer : 1;
uintptr_t has_assoc : 1;
uintptr_t has_cxx_dtor : 1;
uintptr_t shiftcls : 33; // MACH_VM_MAX_ADDRESS 0x1000000000
uintptr_t magic : 6;
uintptr_t weakly_referenced : 1;
uintptr_t deallocating : 1;
uintptr_t has_sidetable_rc : 1;
uintptr_t extra_rc : 19;
# define RC_ONE (1ULL<<45)
# define RC_HALF (1ULL<<18)
};
# elif __x86_64__
# define ISA_MASK 0x00007ffffffffff8ULL
# define ISA_MAGIC_MASK 0x001f800000000001ULL
# define ISA_MAGIC_VALUE 0x001d800000000001ULL
struct {
uintptr_t nonpointer : 1;
uintptr_t has_assoc : 1;
uintptr_t has_cxx_dtor : 1;
uintptr_t shiftcls : 44; // MACH_VM_MAX_ADDRESS 0x7fffffe00000
uintptr_t magic : 6;
uintptr_t weakly_referenced : 1;
uintptr_t deallocating : 1;
uintptr_t has_sidetable_rc : 1;
uintptr_t extra_rc : 8;
# define RC_ONE (1ULL<<56)
# define RC_HALF (1ULL<<7)
};
# else
# error unknown architecture for packed isa
# endif
#endif
複製程式碼
上述原始碼中isa_t
是union型別,union表示共用體。可以看到共用體中有一個結構體,結構體內部分別定義了一些變數,變數後面的值代表的是該變數佔用多少個位元組,也就是位域技術。
共用體:在進行某些演算法的C語言程式設計的時候,需要使幾種不同型別的變數存放到同一段記憶體單元中。也就是使用覆蓋技術,幾個變數互相覆蓋。這種幾個不同的變數共同佔用一段記憶體的結構,在C語言中,被稱作“共用體”型別結構,簡稱共用體。
接下來使用共用體的方式來深入的瞭解apple為什麼要使用共用體,以及使用共用體的好處。
探尋過程
接下來使用程式碼來模仿底層的做法,建立一個person類並含有三個BOOL型別的成員變數。
@interface Person : NSObject
@property (nonatomic, assign, getter = isTall) BOOL tall;
@property (nonatomic, assign, getter = isRich) BOOL rich;
@property (nonatomic, assign, getter = isHansome) BOOL handsome;
@end
複製程式碼
int main(int argc, const char * argv[]) {
@autoreleasepool {
NSLog(@"%zd", class_getInstanceSize([Person class]));
}
return 0;
}
// 列印內容
// Runtime - union探尋[52235:3160607] 16
複製程式碼
上述程式碼中Person含有3個BOOL型別的屬性,列印Person類物件佔據記憶體空間為16,也就是(isa指標 = 8) + (BOOL tall = 1) + (BOOL rich = 1) + (BOOL handsome = 1) = 13
。因為記憶體對齊原則所以Person類物件佔據記憶體空間為16。
上面提到過共用體中變數可以相互覆蓋,可以使幾個不同的變數存放到同一段記憶體單元中,可以很大程度上節省記憶體空間。
那麼我們知道BOOL值只有兩種情況 0 或者 1,但是卻佔據了一個位元組的記憶體空間,而一個記憶體空間中有8個二進位制位,並且二進位制只有 0 或者 1 。那麼是否可以使用1個二進位制位來表示一個BOOL值,也就是說3個BOOL值最終只使用3個二進位制位,也就是一個記憶體空間即可呢?如何實現這種方式?
首先如果使用這種方式需要自己寫方法宣告與實現,不可以寫屬性,因為一旦寫屬性,系統會自動幫我們新增成員變數。
另外想要將三個BOOL值存放在一個位元組中,我們可以新增一個char
型別的成員變數,char
型別佔據一個位元組記憶體空間,也就是8個二進位制位。可以使用其中最後三個二進位制位來儲存3個BOOL值。
@interface Person()
{
char _tallRichHandsome;
}
複製程式碼
例如_tallRichHansome的值為 0b 0000 0010
,那麼只使用8個二進位制位中的最後3個,分別為其賦值0或者1來代表tall、rich、handsome
的值。如下圖所示
那麼現在面臨的問題就是如何取出8個二進位制位中的某一位的值,或者為某一位賦值呢?
取值
首先來看一下取值,假如char型別的成員變數中儲存的二進位制為0b 0000 0010
如果想將倒數第2位的值也就是rich的值取出來,可以使用&進行按位與運算進而去除相應位置的值。
&:按位與,同真為真,其他都為假。
// 示例
// 取出倒數第三位 tall
0000 0010
& 0000 0100
------------
0000 0000 // 取出倒數第三位的值為0,其他位都置為0
// 取出倒數第二位 rich
0000 0010
& 0000 0010
------------
0000 0010 // 取出倒數第二位的值為1,其他位都置為0
複製程式碼
按位與可以用來取出特定的位,想取出哪一位就將那一位置為1,其他為都置為0,然後同原資料進行按位與計算,即可取出特定的位。
那麼此時可以將get方法寫成如下方式
#define TallMask 0b00000100 // 4
#define RichMask 0b00000010 // 2
#define HandsomeMask 0b00000001 // 1
- (BOOL)tall
{
return !!(_tallRichHandsome & TallMask);
}
- (BOOL)rich
{
return !!(_tallRichHandsome & RichMask);
}
- (BOOL)handsome
{
return !!(_tallRichHandsome & HandsomeMask);
}
複製程式碼
上述程式碼中使用兩個!!(非)
來將值改為bool型別。同樣使用上面的例子
// 取出倒數第二位 rich
0000 0010 // _tallRichHandsome
& 0000 0010 // RichMask
------------
0000 0010 // 取出rich的值為1,其他位都置為0
複製程式碼
上述程式碼中(_tallRichHandsome & TallMask)
的值為0000 0010
也就是2,但是我們需要的是一個BOOL型別的值 0 或者 1 ,那麼!!2
就將 2 先轉化為 0 ,之後又轉化為 1。相反如果按位與取得的值為 0 時,!!0
將 0 先轉化為 1 之後又轉化為 0。
因此使用!!
兩個非操作將值轉化為 0 或者 1 來表示相應的值。
掩碼 : 上述程式碼中定義了三個巨集,用來分別進行按位與運算而取出相應的值,一般用來按位與(&)運算的值稱之為掩碼。
為了能更清晰的表明掩碼是為了取出哪一位的值,上述三個巨集的定義可以使用<<(左移)
來優化
<<:表示左移一位,下圖為例。
那麼上述巨集定義可以使用<<(左移)
優化成如下程式碼
#define TallMask (1<<2) // 0b00000100 4
#define RichMask (1<<1) // 0b00000010 2
#define HandsomeMask (1<<0) // 0b00000001 1
複製程式碼
設值
設值即是將某一位設值為0或者1,可以使用|(按位或)
操作符。
| : 按位或,只要有一個1即為1,否則為0。
如果想將某一位置為1的話,那麼將原本的值與掩碼進行按位或的操作即可,例如我們想將tall置為1
// 將倒數第三位 tall置為1
0000 0010 // _tallRichHandsome
| 0000 0100 // TallMask
------------
0000 0110 // 將tall置為1,其他位值都不變
複製程式碼
如果想將某一位置為0的話,需要將掩碼按位取反(~ : 按位取反符),之後在與原本的值進行按位與操作即可。
// 將倒數第二位 rich置為0
0000 0010 // _tallRichHandsome
& 1111 1101 // RichMask按位取反
------------
0000 0000 // 將rich置為0,其他位值都不變
複製程式碼
此時set方法內部實現如下
- (void)setTall:(BOOL)tall
{
if (tall) { // 如果需要將值置為1 // 按位或掩碼
_tallRichHandsome |= TallMask;
}else{ // 如果需要將值置為0 // 按位與(按位取反的掩碼)
_tallRichHandsome &= ~TallMask;
}
}
- (void)setRich:(BOOL)rich
{
if (rich) {
_tallRichHandsome |= RichMask;
}else{
_tallRichHandsome &= ~RichMask;
}
}
- (void)setHandsome:(BOOL)handsome
{
if (handsome) {
_tallRichHandsome |= HandsomeMask;
}else{
_tallRichHandsome &= ~HandsomeMask;
}
}
複製程式碼
寫完set、get方法之後通過程式碼來檢視一下是否可以設值、取值成功。
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *person = [[Person alloc] init];
person.tall = YES;
person.rich = NO;
person.handsome = YES;
NSLog(@"tall : %d, rich : %d, handsome : %d", person.tall,person.rich,person.handsome);
}
return 0;
}
複製程式碼
列印內容
Runtime - union探尋[58212:3857728] tall : 1, rich : 0, handsome : 1
複製程式碼
可以看出上述程式碼可以正常賦值和取值。但是程式碼還是有一定的侷限性,當需要新增新屬性的時候,需要重複上述工作,並且程式碼可讀性比較差。接下來使用結構體的位域特性來優化上述程式碼。
位域
將上述程式碼進行優化,使用結構體位域,可以使程式碼可讀性更高。
位域宣告 位域名 : 位域長度;
使用位域需要注意以下3點: 1. 如果一個位元組所剩空間不夠存放另一位域時,應從下一單元起存放該位域。也可以有意使某位域從下一單元開始。 2. 位域的長度不能大於資料型別本身的長度,比如int型別就不能超過32位二進位。 3. 位域可以無位域名,這時它只用來作填充或調整位置。無名的位域是不能使用的。
上述程式碼使用結構體位域優化之後。
@interface Person()
{
struct {
char handsome : 1; // 位域,代表佔用一位空間
char rich : 1; // 按照順序只佔一位空間
char tall : 1;
}_tallRichHandsome;
}
複製程式碼
set、get方法中可以直接通過結構體賦值和取值
- (void)setTall:(BOOL)tall
{
_tallRichHandsome.tall = tall;
}
- (void)setRich:(BOOL)rich
{
_tallRichHandsome.rich = rich;
}
- (void)setHandsome:(BOOL)handsome
{
_tallRichHandsome.handsome = handsome;
}
- (BOOL)tall
{
return _tallRichHandsome.tall;
}
- (BOOL)rich
{
return _tallRichHandsome.rich;
}
- (BOOL)handsome
{
return _tallRichHandsome.handsome;
}
複製程式碼
通過程式碼驗證一下是否可以賦值或取值正確
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *person = [[Person alloc] init];
person.tall = YES;
person.rich = NO;
person.handsome = YES;
NSLog(@"tall : %d, rich : %d, handsome : %d", person.tall,person.rich,person.handsome);
}
return 0;
}
複製程式碼
首先在log處打個斷點,檢視_tallRichHandsome記憶體儲的值
因為_tallRichHandsome
佔據一個記憶體空間,也就是8個二進位制位,我們將05十六進位制轉化為二進位制檢視
上圖中可以發現,倒數第三位也就是tall值為1,倒數第二位也就是rich值為0,倒數一位也就是handsome值為1,如此看來和上述程式碼中我們設定的值一樣。可以成功賦值。
接著繼續列印內容:
Runtime - union探尋[59366:4053478] tall : -1, rich : 0, handsome : -1
此時可以發現問題,tall與handsome我們設值為YES,講道理應該輸出的值為1為何上面輸出為-1呢?
並且上面通過列印_tallRichHandsome
中儲存的值,也確認tall
和handsome
的值都為1。我們再次列印_tallRichHandsome
結構體內變數的值。
上圖中可以發現,handsome的值為0x01,通過計算器將其轉化為二進位制
可以看到值確實為1的,為什麼列印出來值為-1呢?此時應該可以想到應該是get方法內部有問題。我們來到get方法內部通過列印斷點檢視獲取到的值。
- (BOOL)handsome
{
BOOL ret = _tallRichHandsome.handsome;
return ret;
}
複製程式碼
列印ret的值
通過列印ret的值發現其值為255,也就是1111 1111
,此時也就能解釋為什麼列印出來值為 -1了,首先此時通過結構體獲取到的handsome
的值為0b1
只佔一個記憶體空間中的1位,但是BOOL值佔據一個記憶體空間,也就是8位。當僅有1位的值擴充套件成8位的話,其餘空位就會根據前面一位的值全部補位成1,因此此時ret的值就被對映成了0b 11111 1111
。
11111111
在一個位元組時,有符號數則為-1,無符號數則為255。因此我們在列印時候列印出的值為-1
為了驗證當1位的值擴充套件成8位時,會全部補位,我們將tall、rich、handsome值設定為佔據兩位。
@interface Person()
{
struct {
char tall : 2;
char rich : 2;
char handsome : 2;
}_tallRichHandsome;
}
複製程式碼
此時在列印就發現值可以正常列印出來。
Runtime - union探尋[60827:4259630] tall : 1, rich : 0, handsome : 1
這是因為,在get方法內部獲取到的_tallRichHandsome.handsome
為兩位的也就是0b 01
,此時在賦值給8位的BOOL型別的值時,前面的空值就會自動根據前面一位補全為0,因此返回的值為0b 0000 0001
,因此列印出的值也就為1了。
因此上述問題同樣可以使用!!
雙感嘆號來解決問題。!!
的原理上面已經講解過,這裡不再贅述了。
使用結構體位域優化之後的程式碼
@interface Person()
{
struct {
char tall : 1;
char rich : 1;
char handsome : 1;
}_tallRichHandsome;
}
@end
@implementation Person
- (void)setTall:(BOOL)tall
{
_tallRichHandsome.tall = tall;
}
- (void)setRich:(BOOL)rich
{
_tallRichHandsome.rich = rich;
}
- (void)setHandsome:(BOOL)handsome
{
_tallRichHandsome.handsome = handsome;
}
- (BOOL)tall
{
return !!_tallRichHandsome.tall;
}
- (BOOL)rich
{
return !!_tallRichHandsome.rich;
}
- (BOOL)handsome
{
return !!_tallRichHandsome.handsome;
}
複製程式碼
上述程式碼中使用結構體的位域則不在需要使用掩碼,使程式碼可讀性增強了很多,但是效率相比直接使用位運算的方式來說差很多,如果想要高效率的進行資料的讀取與儲存同時又有較強的可讀性就需要使用到共用體了。
共用體
為了使程式碼儲存資料高效率的同時,有較強的可讀性,可以使用共用體來增強程式碼可讀性,同時使用位運算來提高資料存取的效率。
使用共用體優化的程式碼
#define TallMask (1<<2) // 0b00000100 4
#define RichMask (1<<1) // 0b00000010 2
#define HandsomeMask (1<<0) // 0b00000001 1
@interface Person()
{
union {
char bits;
// 結構體僅僅是為了增強程式碼可讀性,無實質用處
struct {
char tall : 1;
char rich : 1;
char handsome : 1;
};
}_tallRichHandsome;
}
@end
@implementation Person
- (void)setTall:(BOOL)tall
{
if (tall) {
_tallRichHandsome.bits |= TallMask;
}else{
_tallRichHandsome.bits &= ~TallMask;
}
}
- (void)setRich:(BOOL)rich
{
if (rich) {
_tallRichHandsome.bits |= RichMask;
}else{
_tallRichHandsome.bits &= ~RichMask;
}
}
- (void)setHandsome:(BOOL)handsome
{
if (handsome) {
_tallRichHandsome.bits |= HandsomeMask;
}else{
_tallRichHandsome.bits &= ~HandsomeMask;
}
}
- (BOOL)tall
{
return !!(_tallRichHandsome.bits & TallMask);
}
- (BOOL)rich
{
return !!(_tallRichHandsome.bits & RichMask);
}
- (BOOL)handsome
{
return !!(_tallRichHandsome.bits & HandsomeMask);
}
複製程式碼
上述程式碼中使用位運算這種比較高效的方式存取值,使用union共用體來對資料進行儲存。增加讀取效率的同時增強程式碼可讀性。
其中_tallRichHandsome
共用體只佔用一個位元組,因為結構體中tall、rich、handsome都只佔一位二進位制空間,所以結構體只佔一個位元組,而char型別的bits也只佔一個位元組,他們都在共用體中,因此共用一個位元組的記憶體即可。
並且在get、set
方法中並沒有使用到結構體,結構體僅僅為了增加程式碼可讀性,指明共用體中儲存了哪些值,以及這些值各佔多少位空間。同時存值取值還使用位運算來增加效率,儲存使用共用體,存放的位置依然通過與掩碼進行位運算來控制。
此時程式碼已經算是優化完成了,高效的同時可讀性高,那麼此時在回頭看isa_t
共用體的原始碼
isa_t原始碼
此時我們在回頭檢視isa_t原始碼
// 精簡過的isa_t共用體
union isa_t
{
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }
Class cls;
uintptr_t bits;
# if __arm64__
# define ISA_MASK 0x0000000ffffffff8ULL
# define ISA_MAGIC_MASK 0x000003f000000001ULL
# define ISA_MAGIC_VALUE 0x000001a000000001ULL
struct {
uintptr_t nonpointer : 1;
uintptr_t has_assoc : 1;
uintptr_t has_cxx_dtor : 1;
uintptr_t shiftcls : 33; // MACH_VM_MAX_ADDRESS 0x1000000000
uintptr_t magic : 6;
uintptr_t weakly_referenced : 1;
uintptr_t deallocating : 1;
uintptr_t has_sidetable_rc : 1;
uintptr_t extra_rc : 19;
# define RC_ONE (1ULL<<45)
# define RC_HALF (1ULL<<18)
};
#endif
};
複製程式碼
經過上面對位運算、位域以及共用體的分析,現在再來看原始碼已經可以很清晰的理解其中的內容。原始碼中通過共用體的形式儲存了64位的值,這些值在結構體中被展示出來,通過對bits
進行位運算而取出相應位置的值。
這裡主要關注一下shiftcls
,shiftcls
中儲存著Class、Meta-Class
物件的記憶體地址資訊,我們之前在OC物件的本質中提到過,物件的isa指標需要同ISA_MASK
經過一次&(按位與)運算才能得出真正的Class物件地址。
那麼此時我們重新來看ISA_MASK
的值0x0000000ffffffff8ULL
,我們將其轉化為二進位制數
上圖中可以看出ISA_MASK
的值轉化為二進位制中有33位都為1,上面提到過按位與的作用是可以取出這33位中的值。那麼此時很明顯了,同ISA_MASK
進行按位與運算即可以取出Class或Meta-Class的值。
同時可以看出ISA_MASK
最後三位的值為0,那麼任何數同ISA_MASK
按位與運算之後,得到的最後三位必定都為0,因此任何類物件或元類物件的記憶體地址最後三位必定為0,轉化為十六進位制末位必定為8或者0。
isa中儲存的資訊及作用
將結構體取出來標記一下這些資訊的作用。
struct {
// 0代表普通的指標,儲存著Class,Meta-Class物件的記憶體地址。
// 1代表優化後的使用位域儲存更多的資訊。
uintptr_t nonpointer : 1;
// 是否有設定過關聯物件,如果沒有,釋放時會更快
uintptr_t has_assoc : 1;
// 是否有C++解構函式,如果沒有,釋放時會更快
uintptr_t has_cxx_dtor : 1;
// 儲存著Class、Meta-Class物件的記憶體地址資訊
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;
};
複製程式碼
驗證
通過下面一段程式碼驗證上述資訊儲存的位置及作用
// 以下程式碼需要在真機中執行,因為真機中才是__arm64__ 位架構
- (void)viewDidLoad {
[super viewDidLoad];
Person *person = [[Person alloc] init];
NSLog(@"%p",[person class]);
NSLog(@"%@",person);
}
複製程式碼
首先列印person類物件的地址,之後通過斷點列印一下person物件的isa指標地址。
首先來看一下列印的內容
將類物件地址轉化為二進位制
將person的isa指標地址轉化為二進位制
shiftcls : shiftcls
中儲存類物件地址,通過上面兩張圖對比可以發現儲存類物件地址的33位二進位制內容完全相同。
extra_rc : extra_rc
的19位中儲存著的值為引用計數減一,因為此時person的引用計數為1,因此此時extra_rc
的19位二進位制中儲存的是0。
magic : magic
的6位用於在除錯時分辨物件是否未完成初始化,上述程式碼中person已經完成初始化,那麼此時這6位二進位制中儲存的值011010
即為共用體中定義的巨集# define ISA_MAGIC_VALUE 0x000001a000000001ULL
的值。
nonpointer : 這裡肯定是使用的優化後的isa,因此nonpointer
的值肯定為1
因為此時person物件沒有關聯物件並且沒有弱指標引用過,可以看出has_assoc
和weakly_referenced
值都為0,接著我們為person物件新增弱引用和關聯物件,來觀察一下has_assoc
和weakly_referenced
的變化。
- (void)viewDidLoad {
[super viewDidLoad];
Person *person = [[Person alloc] init];
NSLog(@"%p",[person class]);
// 為person新增弱引用
__weak Person *weakPerson = person;
// 為person新增關聯物件
objc_setAssociatedObject(person, @"name", @"xx_cc", OBJC_ASSOCIATION_RETAIN_NONATOMIC);
NSLog(@"%@",person);
}
複製程式碼
重新列印person的isa指標地址將其轉化為二進位制可以看到has_assoc
和weakly_referenced
的值都變成了1
注意:只要設定過關聯物件或者弱引用引用過物件has_assoc
和weakly_referenced
的值就會變成1,不論之後是否將關聯物件置為nil或斷開弱引用。
如果沒有設定過關聯物件,物件釋放時會更快,這是因為物件在銷燬時會判斷是否有關聯物件進而對關聯物件釋放。來看一下物件銷燬的原始碼
void *objc_destructInstance(id obj)
{
if (obj) {
Class isa = obj->getIsa();
// 是否有c++解構函式
if (isa->hasCxxDtor()) {
object_cxxDestruct(obj);
}
// 是否有關聯物件,如果有則移除
if (isa->instancesHaveAssociatedObjects()) {
_object_remove_assocations(obj);
}
objc_clear_deallocating(obj);
}
return obj;
}
複製程式碼
相信至此我們已經對isa指標有了新的認識,__arm64__
架構之後,isa指標不單單隻儲存了Class或Meta-Class的地址,而是使用共用體的方式儲存了更多資訊,其中shiftcls
儲存了Class或Meta-Class的地址,需要同ISA_MASK
進行按位&運算才可以取出其記憶體地址值。
底層原理文章專欄
文中如果有不對的地方歡迎指出。我是xx_cc,一隻長大很久但還沒有二夠的傢伙。需要視訊一起探討學習的coder可以加我Q:2336684744