概述
當我們創建一個對象時:
SWHunter *hunter = [[SWHunter alloc] init];
上面這行代碼在棧
上創建了hunter
指針,并在堆
上創建了一個SWHunter對象
。目前,iOS并不支持在棧
上創建對象。
iOS 內存分區
iOS的內存管理是基于虛擬內存的。虛擬內存能夠讓每一個進程都能夠在邏輯上“獨占”整個設備的內存。關于虛擬內存,可以參考這里。
iOS又將虛擬內存按照地址由低到高劃分為如下五個區:
- 代碼區: 存放APP二進制代碼
- 常量區:存放程序中定義的各種常量, 包括字符串常量,各種被const修飾的常量
- 全局/靜態區: 全局變量,靜態變量就放在這里
- 堆區:在程序運行時調用
alloc
,copy
,mutablecopy
,new
會在堆上分配內存。堆內存需要程序員手動釋放,這在ARC中是通過引用計數的形式表現的。堆分配地址不連續,但整體是地址從低到高地址分配 - 棧區:存放局部變量,當變量超出作用域時,內存會被系統自動釋放。棧上的地址連續分配,在內存地址由高向低增長
在程序運行時,代碼區,常量區以及全局靜態區的大小是固定的,會變化的只有棧和堆的大小。而棧的內存是有操作系統自動釋放的,我們平常說所的iOS內存引用計數,其實是就堆上的對象來說的。
下面,我們就來看一下,在runtime
中,是如何通過引用計數來管理內存的。
tagged pointer
首先,來想這么一個問題,在平常的編程中,我們使用的NSNumber對象來表示數字,最大會有多大?幾萬?幾千萬?甚至上億?
我相信,對于絕大多數程序來說,用不到上億的數字。同樣,對于字符串類型,絕大多數時間,字符個數也在8個以內。
再想另一個方面,自2013年蘋果推出iphone5s之后,iOS的尋址空間擴大到了64位。我們可以用63位來表示一個數字(一位做符號位),這是一個什么樣的概念?231=2147483648,也達到了20多億,而263這個數字,用到的概率基本為零。比如NSNumber *num=@10000
的話,在內存中則會留下很多無用的空位。這顯然浪費了內存空間。
蘋果當然也發現了這個問題,于是就引入了tagged pointer
。tagged pointer
是一種特殊的“指針”,其特殊在于,其實它存儲的并不是地址,而是<font color=orange>真實的數據和一些附加的信息</font>。
在引入tagged pointer
之前,iOS對象的內存結構如下所示(摘自唐巧博客):
[圖片上傳失敗...(image-a44d7c-1548215032013)]
顯然,本來4字節就可以表示的數值,現在卻用了8字節,明顯的內存浪費。而引入了tagged pointer
后, 其內存布局如下
可以看到,利用tagged pointer
后,“指針”又存儲了對本身,也存儲了和對象相關的標記。這時的tagged pointer里面存儲的不是地址,而是一個數據集合。同時,其占用的內存空間也由16字節縮減為8字節。
我們可以在WWDC2013的《Session 404 Advanced in Objective-C》視頻中,看到蘋果對于Tagged Pointer特點的介紹:
- Tagged Pointer專門用來存儲小的對象,例如NSNumber, NSDate, NSString。
- Tagged Pointer指針的值不再是地址了,而是真正的值。所以,實際上它不再是一個對象了,它只是一個披著對象皮的普通變量而已。所以,它的內存并不存儲在堆中,也不需要malloc和free。
- 在內存讀取上有著3倍的效率,創建時比以前快106倍。
運行如下代碼:
NSMutableString *mutableStr = [NSMutableString string];
NSString *immutable = nil;
#define _OBJC_TAG_MASK (1UL<<63)
char c = 'a';
do {
[mutableStr appendFormat:@"%c", c++];
immutable = [mutableStr copy];
NSLog(@"%p %@ %@", immutable, immutable, immutable.class);
}while(((uintptr_t)immutable & _OBJC_TAG_MASK) == _OBJC_TAG_MASK);
輸出為:
我們看到,字符串由‘a’增長到‘abcdefghi’的過程中,其地址開頭都是0xa
而結尾也很有規律,是1到9遞增,正好對應著我們的字符串長度,同時,其輸出的class類型為NSTaggedPointerString
。在字符串長度在9個以內時,iOS其實使用了tagged pointer
做了優化的。
直到字符串長度大于9,字符串才真正成為了__NSCFString
類型。
我們回頭分析一下上面的代碼。
首先,iOS需要一個標志位來判斷當前指針是真正的指針
還是tagged pointer
。這里有一個宏定義_OBJC_TAG_MASK (1UL<<63)
,它表明如果64位數據中,最高位是1的話,則表明當前是一個tagged pointer
類型。
然后,既然使用了tagged pointer
,那么就失去了iOS對象的數據結構,但是,系統還是需要有個標志位表明當前的tagged pointer
表示的是什么類型的對象。這個標志位,也是在最高4位
來表示的。我們將0xa
轉換為二進制,得到
1010
,其中最高位1xxx
表明這是一個tagged pointer
,而剩下的3位010
,表示了這是一個NSString
類型。010
轉換為十進制即為2
。也就是說,<font color=red>標志位是2的tagger pointer表示這是一個NSString對象。</font>
在runtime源碼的objc-internal.h中,有關于標志位的定義如下:
{
OBJC_TAG_NSAtom = 0,
OBJC_TAG_1 = 1,
OBJC_TAG_NSString = 2,
OBJC_TAG_NSNumber = 3,
OBJC_TAG_NSIndexPath = 4,
OBJC_TAG_NSManagedObjectID = 5,
OBJC_TAG_NSDate = 6,
OBJC_TAG_RESERVED_7 = 7,
OBJC_TAG_First60BitPayload = 0,
OBJC_TAG_Last60BitPayload = 6,
OBJC_TAG_First52BitPayload = 8,
OBJC_TAG_Last52BitPayload = 263,
OBJC_TAG_RESERVED_264 = 264
};
最后,讓我們再嘗試分析一下NSString類型的tagged pointer是如何實現的。
我們前面已經知道,在總共64位數據中,高4位被用于標志tagged pointer以及對象類型標識。低1位用于記錄字符串字符個數,那么還剩下59位可以讓我們表示數據內容。
對于字符串格式,怎么來表示內容呢?自然的,我們想到了ASCII碼。對應ASCII碼,a用16進制ASCII碼表示為0x61
,b為0x62
, 依次類推。在字符串長度增加到8個之前,tagged pointer
的內容如下。可以看到,從最低2位開始,分別為61,62,63... 這正對應了字符串中字符的ASCII碼。
直到字符串增加到7個之上,我們仍然可以分辨出tagged pointer
中的標志位以及字符串長度,但是中間的內容部分,卻不符合ASCII的編碼規范了。
這是因為,iOS對字符串使用了壓縮算法,使得tagged pointer
表示的字符串長度最大能夠達到9個。關于具體的壓縮算法,我們就不再討論了。由于蘋果內部會對實現邏輯作出修改,因此我們只要知道有tagged pointer
的概念就好了。有興趣的同學可以看采用Tagged Pointer的字符串,但其內容也有些過時了,和我們的實驗結果并不一致。
我們順便看一下NSNumber的tagged pointer實現:
NSNumber *number1 = @(0x1);
NSNumber *number2 = @(0x20);
NSNumber *number3 = @(0x3F);
NSNumber *numberFFFF = @(0xFFFFFFFFFFEFE);
NSNumber *maxNum = @(MAXFLOAT);
NSLog(@"number1 pointer is %p class is %@", number1, number1.class);
NSLog(@"number2 pointer is %p class is %@", number2, number2.class);
NSLog(@"number3 pointer is %p class is %@", number3, number3.class);
NSLog(@"numberffff pointer is %p class is %@", numberFFFF, numberFFFF.class);
NSLog(@"maxNum pointer is %p class is %@", maxNum, maxNum.class);
可以看到,對于MAXFLOAT
,系統無法進行優化,輸出的是一個正常的NSNumber對象地址。而對于其他的number值,系統采用了tagged pointer
,其‘地址’都是以0xb
開頭,轉換為二進制就是1011
, 首位1表示這是一個tagged pointer
,而011
轉換為十進制是3
,參考前面tagged pointer
的類型枚舉,這是一個NSNumber
類型。接下來幾位,就是以16進制表示的NSNumber的值,而對于最后一位,應該是一個標志位,具體作用,筆者也不是很清楚。
isa
由于一個tagged pointer所指向的并不是一個真正的OC對象,它其實是沒有isa屬性的。
在runtime中,可以這樣獲取isa的內容:
#define _OBJC_TAG_SLOT_SHIFT 60
#define _OBJC_TAG_EXT_SLOT_MASK 0xff
inline Class
objc_object::getIsa()
{
// 如果不是tagged pointer,則返回ISA()
if (!isTaggedPointer()) return ISA();
// 如果是tagged pointer,取出高4位的內容,查找對應的class
uintptr_t ptr = (uintptr_t)this;
uintptr_t slot = (ptr >> _OBJC_TAG_SLOT_SHIFT) & _OBJC_TAG_SLOT_MASK;
return objc_tag_classes[slot];
}
在runtime中,還有專用的方法用于判斷指針是tagged pointer
還是普通指針:
# define _OBJC_TAG_MASK (1UL<<63)
static inline bool
_objc_isTaggedPointer(const void * _Nullable ptr)
{
return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}
isa 指針(NONPOINTER_ISA)
對象的isa指針,用來表明對象所屬的類類型。
但是如果isa
指針僅表示類型的話,對內存顯然也是一個極大的浪費。于是,就像tagged pointer
一樣,對于isa
指針,蘋果同樣進行了優化。isa指針表示的內容變得更為豐富,除了表明對象屬于哪個類之外,還附加了引用計數extra_rc
,是否有被weak引用標志位weakly_referenced
,是否有附加對象標志位has_assoc
等信息。
這里,我們僅關注isa
中和內存引用計數有關的extra_rc
以及相關內容。
首先,我們回顧一下isa指針是怎么在一個對象中存儲的。下面是runtime相關的源碼:
@interface NSObject <NSObject> {
Class isa OBJC_ISA_AVAILABILITY;
}
typedef struct objc_class *Class;
// ============ 注意!從這一行開始,其定義就和在XCode中objc.h看到的定義不一致,我們需要閱讀runtime的源碼,才能看到其真實的定義!下面是簡化版的定義:============
struct objc_class : objc_object {
Class superclass;
cache_t cache; // formerly cache pointer and vtable
class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags
}
struct objc_object {
private:
isa_t isa;
}
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)
};
}
結合下面的圖,我們可以更清楚的了解runtime中對象和類的結構定義,顯然,類也是一種對象,這就是類對象的含義。
從圖中可以看出,我們所謂的isa指針,最后實際上落腳于isa_t
的聯合類型
。聯合類型
是C語言中的一種類型,簡單來說,就是一種n選1的關系。比如isa_t
中包含有cls
,bits
, struct
三個變量,它們的內存空間是重疊
的。在實際使用時,僅能夠使用它們中的一種,你把它當做cls
,就不能當bits
訪問,你把它當bits
,就不能用cls
來訪問。
<font color=orange>聯合的作用在于,用更少的空間,表示了更多的可能的類型,雖然這些類型是不能夠共存的。</font>
將注意力集中在isa_t聯合上,我們該怎樣理解它呢?
首先它有兩個構造函數isa_t()
, isa_t(uintptr_value)
, 這兩個定義很清晰,無需多言。
然后它有三個數據成員Class cls
, uintptr_t bits
, struct
。 其中uintptr_t
被定義為typedef unsigned long uintptr_t
,占據64位內存。
關于上面三個成員, uintptr_t bits
和 struct
其實是一個成員,它們都占據64位內存空間,之前已經說過,聯合類型的成員內存空間是重疊的。在這里,由于uintptr_t bits
和 struct
都是占據64位內存,因此它們的內存空間是完全重疊的。而你將這塊64位內存當做是uintptr_t bits
還是 struct
,則完全是邏輯上的區分,在內存空間上,其實是一個東西。
<font color=blue>即uintptr_t bits
和 struct
是一個東西的兩種表現形式。</font>
實際上在runtime中,任何對struct
的操作和獲取某些值,如extra_rc
,實際上都是通過對uintptr_t bits
做位操作實現的。uintptr_t bits
和 struct
的關系可以看做,uintptr_t bits
向外提供了操作struct
的接口,而struct
本身則說明了uintptr_t bits
中各個二進制位的定義。
理解了uintptr_t bits
和 struct
關系后,則isa_t
其實可以看做有兩個可能的取值,Class cls
或struct
。如下圖所示:
當
isa_t
作為Class cls
使用時,這符合了我們之前一貫的認知:isa是一個指向對象所屬Class類型的指針。然而,僅讓一個64位的指針表示一個類型,顯然不劃算。
因此,<font color=blue>絕大多數情況下,蘋果采用了優化的isa策略,即,isa_t
類型并不等同而Class cls
, 而是struct
。</font>這種情況對于我們自己創建的類對象以及系統對象都是如此,稍后我們會對這一結論進行驗證。
先讓我們集中精力來看一下struct
的結構 :
# 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)
};
struct
共占用64位,從低位到高位依次是nonpointer
到extra_rc
。成員后面的:
表明了該成員占用幾個bit。成員的含義如下:
成員 | 位 | 含義 |
---|---|---|
nonpointer | 1bit | 標志位。1(奇數)表示開啟了isa優化,0(偶數)表示沒有啟用isa優化。所以,我們可以通過判斷isa是否為奇數來判斷對象是否啟用了isa優化。 |
has_assoc | 1bit | 標志位。表明對象是否有關聯對象。沒有關聯對象的對象釋放的更快。 |
has_cxx_dtor | 1bit | 標志位。表明對象是否有C++或ARC析構函數。沒有析構函數的對象釋放的更快。 |
shiftcls | 33bit | 類指針的非零位。 |
magic | 6bit | 固定為0x1a,用于在調試時區分對象是否已經初始化。 |
weakly_referenced | 1bit | 標志位。用于表示該對象是否被別的對象弱引用。沒有被弱引用的對象釋放的更快。 |
deallocating | 1bit | 標志位。用于表示該對象是否正在被釋放。 |
has_sidetable_rc | 1bit | 標志位。用于標識是否當前的引用計數過大,無法在isa中存儲,而需要借用sidetable來存儲。(這種情況大多不會發生) |
extra_rc | 19bit | 對象的引用計數減1。比如,一個object對象的引用計數為7,則此時extra_rc的值為6。 |
由上表可以看出,和對象引用計數相關的有兩個成員:extra_rc
和has_sidetable_rc
。iOS用19位的extra_rc
來記錄對象的引用次數,當extra_rc
不夠用時,還會借助sidetable
來存儲計數值,這時,has_sidetable_rc
會被標志為1。
我們可以算一下,對于19位的extra_rc
,其數值可以表示2^19 - 1 = 524287。 52萬多,相信絕大多數情況下,都夠用了。
現在,我們來真正的驗證一下,我們上述的結論。<font color=red>注意,做驗證試驗時,必須要使用真機,因為模擬器默認是不開啟isa優化的。</font>
要做驗證試驗,我們必須要得到isa_t
的值。在蘋果提供的公共接口中,是無法獲取到它的。不過,通過對象指針,我們確實是可以獲取到isa_t
的值。
讓我們看一下當我們創建一個對象時,實際上是獲得到了什么。
NSObject *obj = [[NSObject alloc] init];
我們得到了obj
這個對象,實質上obj
是一個指向對象的指針
, 即
obj == NSObject *
。
而在NSObject
中,又有唯一的成員Class isa
, 而Class
實質上是objc_class *
。這樣,我們可以用objc_class *
替換掉 NSObject
,得到
obj == objc_class **
再看objc_class
的定義:
struct objc_class : objc_object {
。。。
}
objc_class
繼承自objc_object
, 因此,在objc_class
內存布局的首地址肯定存放的是繼承自objc_object
的內容。從內存布局的角度,我們可以將objc_class
替換為 objc_object
。得到:
obj == objc_object **
而objc_object
的定義如下,僅含有一個成員isa_t
:
struct objc_object {
private:
isa_t isa;
}
因此,我們又可以將objc_object
替換為isa_t
。得到:
obj == isa_t **
<font color = orange>好了,這里到了關鍵的地方</font>,從現在看,我們得到的obj應該是一個指向 isa_t *
的指針,即 obj是一個指針的指針,obj指向一個指針。 但是,obj真的是指向了一個指針嗎?
我們再來看一下isa_t
的定義,我們看標志為注意!!!
的地方:
# if __arm64__
# define ISA_MASK 0x0000000ffffffff8ULL
# define ISA_MAGIC_MASK 0x000003f000000001ULL
# define ISA_MAGIC_VALUE 0x000001a000000001ULL
struct {
uintptr_t nonpointer : 1; // 注意!!! 標志位,表明isa_t *是否是一個真正的指針!!!
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)
};
也就是說,當開啟了isa_t優化,nonpointer
置位為1, 這時,isa_t *
其實不是一個地址,而是一個實實在在有意義的值,也就是說,蘋果用isa_t *
所占用的64位空間,表示了一個有意義的值,而這64位值的定義,就符合我們上面struct的定義。
這時,我們可以將isa_t *
改寫為isa_t
,這是因為isa_t *的64位并沒有指向任何地址,而是實際表示了isa_t的內容。
繼續上面的公式推導,得到結論:
obj == *isa_t
哈哈,有意思嗎?obj實際上是指向isa_t的指針
。繞了這里大一圈,結論竟如此直白。
如果我們想得到isa_t
的值,只需要做*obj操作
即可,即
NSLog(@"isa_t = %p", *obj);
之所以用%p輸出,是因為我們要isa_t*本身的值,而不是要取它指向的值。
得出了這個結論,我們就可以通過obj
打印出isa_t
中存儲的內容了(中間需要做幾次類型轉換,但是實質和上面是一樣的):
NSLog(@"isa_t = %p", *(void **)(__bridge void*)obj);
我們的實驗代碼如下:
@interface MyObj : NSObject
@end
@implementation MyObj
@end
@interface ViewController ()
@property(nonatomic, strong) MyObj *obj1;
@property(nonatomic, strong) MyObj *obj2;
@property(nonatomic, weak) MyObj *weakRefObj;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
MyObj *obj = [[MyObj alloc] init];
NSLog(@"1. obj isa_t = %p", *(void **)(__bridge void*)obj);
_obj1 = obj;
MyObj *tmpObj = obj;
NSLog(@"2. obj isa_t = %p", *(void **)(__bridge void*)obj);
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
NSLog(@"3. obj isa_t = %p", *(void **)(__bridge void*)_obj1);
_obj2 = _obj1;
NSLog(@"4. obj isa_t = %p", *(void **)(__bridge void*)_obj1);
_weakRefObj = _obj1;
NSLog(@"5. obj isa_t = %p", *(void **)(__bridge void*)_obj1);
NSObject *attachObj = [[NSObject alloc] init];
objc_setAssociatedObject(_obj1, "attachKey", attachObj, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
NSLog(@"6. obj isa_t = %p", *(void **)(__bridge void*)_obj1);
}
@end
其輸出為:
直觀的可以看到isa_t
的內容都是奇數,說明開啟了isa優化。(nonpointer == 1
)
接下來我們一行行的分析代碼以及相應的isa_t
內容變化:
首先在viewDidLoad方法中,我們創建了一個MyObj實例,并接著打印出isa_t的內容,這時候,MyObj的引用計數應該是1:
- (void)viewDidLoad {
...
MyObj *obj = [[MyObj alloc] init];
NSLog(@"1. obj isa_t = %p", *(void **)(__bridge void*)obj);
...
}
對應的輸出內容為0x1a1000a0ff9
:
大家可以在圖中直觀的看到isa_t此時各位的內容,注意到extra_rc
此時為0
,因為引用計數等于extra_rc + 1
,因此,MyObj對象
的引用計數為1
,和我們的預期一致。
接下來執行
_obj1 = obj;
MyObj *tmpObj = obj;
NSLog(@"2. obj isa_t = %p", *(void **)(__bridge void*)obj);
由于_obj1
對MyObj對象
是強引用,同時,tmpObj
的賦值也默認是強引用,obj
的引用計數加2
,應該等于3
。
輸出為0x41a1000a0ff9
:
引用計數等于extra_rc + 1 = 2 + 1 = 3
, 符合預期。
然后,程序執行到了viewDidAppear
方法,并立刻輸出MyObj對象
的引用計數。因為此時棧上變量obj
,tmpObj
已經釋放,因此引用計數應該減2
,等于1
。
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
NSLog(@"3. obj isa_t = %p", *(void **)(__bridge void*)_obj1);
...
}
輸出為 0x1a1000a0ff9
:
引用計數等于extra_rc + 1 = 0 + 1 = 1
, 符合預期。
接下來我們又賦值了一個強引用_obj2, 引用計數加1,等于2。
...
_obj2 = _obj1;
NSLog(@"4. obj isa_t = %p", *(void **)(__bridge void*)_obj1);
...
輸出為0x21a1000a0ff9
:
引用計數等于extra_rc + 1 = 1 + 1 = 2
, 符合預期。
接下來,我們又將MyObj對象
賦值給一個weak引用,此時,引用計數應該保持不變,但是weakly_referenced
位應該置1
。
...
_weakRefObj = _obj1;
NSLog(@"5. obj isa_t = %p", *(void **)(__bridge void*)_obj1);
...
輸出0x25a1000a0ff9
:
可以看到引用計數仍是2
,但是weakly_referenced
位已經置位1
,符合預期。
最后,我們向MyObj對象
添加了一個關聯對象,此時,isa_t
的其他位應該保持不變,只有has_assoc
標志位應該置位1
。
...
NSObject *attachObj = [[NSObject alloc] init];
objc_setAssociatedObject(_obj1, "attachKey", attachObj, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
NSLog(@"6. obj isa_t = %p", *(void **)(__bridge void*)_obj1);
...
輸出0x25a1000a0ffb
:
可以看到,其他位保持不變,只有has_assoc
被設置為1
,符合預期。
OK,通過上面的分析,你現在應該很清楚rumtime里面isa
究竟是怎么回事了吧?
PS: 筆者所實驗的環境為iPhone5s + iOS 10。
SideTable
其實在絕大多數情況下,僅用優化的isa_t
來記錄對象的引用計數就足夠了。只有在19位的extra_rc
盛放不了那么大的引用計數時,才會借助SideTable
出馬。
SideTable
是一個全局的引用計數表,它記錄了所有對象的引用計數。
為了弄清extra_rc
和sidetable
的關系,我們首先看runtime添加對象引用計數時的簡化代碼。不過在看代碼之前,我們需要弄清楚slowpath
和fastpath
是干啥的。
我們在runtime源碼中有時候,有時在if
語句中會看到類似下面這些代碼:
if (fastpath(cls->canAllocFast())){
...
}
if (slowpath(!newisa.nonpointer)) {
...
}
其實將fastpath
和slowpath
去掉是完全不影響任何功能的。之所以將fastpath
和slowpath
放到if
語句中,是為了告訴編譯器,if
中的條件是大概率(fastpath
)還是小概率(slowpath
)事件,從而讓編譯器對代碼進行優化。知道了這些,我們就可以來繼續看源碼了:
# define RC_HALF (1ULL<<18)
ALWAYS_INLINE id
objc_object::rootRetain(bool tryRetain, bool handleOverflow)
{
// 如果是tagged pointer,直接返回this,因為tagged pointer不用記錄引用次數
if (isTaggedPointer()) return (id)this;
// transcribeToSideTable用于表示extra_rc是否溢出,默認為false
bool transcribeToSideTable = false;
isa_t oldisa;
isa_t newisa;
do {
transcribeToSideTable = false;
oldisa = LoadExclusive(&isa.bits); // 將isa_t提取出來
newisa = oldisa;
if (slowpath(!newisa.nonpointer)) { // 如果沒有采用isa優化, 則返回sidetable記錄的內容, 此處slowpath表明這不是一個大概率事件
return sidetable_retain();
}
// 如果對象正在析構,則直接返回nil
if (slowpath(tryRetain && newisa.deallocating)) {
return nil;
}
// 采用了isa優化,做extra_rc++,同時檢查是否extra_rc溢出,若溢出,則extra_rc減半,并將另一半轉存至sidetable
uintptr_t carry;
newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry); // extra_rc++
if (slowpath(carry)) { // 有carry值,表示extra_rc 溢出
// newisa.extra_rc++ overflowed
if (!handleOverflow) { // 如果不處理溢出情況,則在這里會遞歸調用一次,再進來的時候,handleOverflow會被rootRetain_overflow設置為true,從而進入到下面的溢出處理流程
return rootRetain_overflow(tryRetain);
}
// 進行溢出處理:邏輯很簡單,先在extra_rc中引用計數減半,同時把has_sidetable_rc設置為true,表明借用了sidetable。然后把另一半放到sidetable中
sideTableLocked = true;
transcribeToSideTable = true;
newisa.extra_rc = RC_HALF;
newisa.has_sidetable_rc = true;
}
} while (slowpath(!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits))); // 將oldisa 替換為 newisa,并賦值給isa.bits(更新isa_t), 如果不成功,do while再試一遍
//isa的extra_rc溢出,將一半的refer count值放到sidetable中
if (slowpath(transcribeToSideTable)) {
// Copy the other half of the retain counts to the side table.
sidetable_addExtraRC_nolock(RC_HALF);
}
return (id)this;
}
添加對象引用計數的源碼邏輯還算清晰,重點看當extra_rc溢出后,runtime是怎么處理的。
在iOS中,extra_rc
占有19位,也就是最大能夠表示2^19-1, 用二進制表示就是19個1。當extra_rc
等于2^19時,溢出,此時的二進制位是一個1后面跟19個0, 即10000...00。將會溢出的值2^19除以2,相當于將10000...00向右移動一位。也就等于RC_HALF(1ULL<<18)
,即一個1后面跟18個0。
然后,調用
sidetable_addExtraRC_nolock(RC_HALF);
將另一半的引用計數RC_HALF
放到sidetable
中。
SideTable數據結構
在runtime中,通過SideTable
來管理對象的引用計數以及weak
引用。<font color=red>這里要注意,一張SideTable會管理多個對象,而并非一個。</font>
而這一個個的SideTable
又構成了一個集合,叫SideTables
。SideTables
在系統中是全局唯一的。
SideTable
,SideTables
的關系如下圖所示(這張圖會隨著分析的深入逐漸擴充):
SideTables
的類型是是template<typename T> class StripedMap,StripedMap<SideTable>
。我們可以簡單的理解為一個64 * sizeof(SideTable)
的哈希線性數組。
每個對象可以通過StripedMap
所對應的哈希算法,找到其對應的SideTable
。StripedMap
的哈希算法如下,其參數是對象的地址。
static unsigned int indexForPointer(const void *p) {
uintptr_t addr = reinterpret_cast<uintptr_t>(p);
return ((addr >> 4) ^ (addr >> 9)) % StripeCount; // 這里 %StripeCount 保證了所有的對象對應的SideTable均在這個64長度數組中。
}
注意到這個SideTables
哈希數組是全局的,因此,對于我們APP中所有的對象的引用計數,也就都存在于這64個SideTable
中。
具體到每個SideTable
, 其中有存儲了若干對象的引用計數。SideTable
的定義如下:
struct SideTable {
spinlock_t slock;
RefcountMap refcnts;
weak_table_t weak_table;
SideTable() {
memset(&weak_table, 0, sizeof(weak_table));
}
~SideTable() {
_objc_fatal("Do not delete SideTable.");
}
};
SideTable
包含三個成員:
-
spinlock_t slock
:自旋鎖。防止多線程訪問SideTable
沖突 -
RefcountMap refcnts
:用于存儲對象引用計數的map -
weak_table_t weak_table
: 用于存儲對象弱引用的map
這里我們暫且不去管weak_table
, 先看存儲對象引用計數的成員RefcountMap refcnts
。
RefcountMap
類型實際是DenseMap
,這是一個模板類
。
typedef objc::DenseMap<DisguisedPtr<objc_object>,size_t,true> RefcountMap;
關于DenseMap
的實際定義,有點復雜,暫時不想看:(
這里只需要將RefcountMap
簡單的的理解為是一個map
,key
是DisguisedPtr<objc_object>
,value
是對象的引用計數。同時,這個map
還有個加強版功能,當引用計數為0時,會自動將對象數據清除。
這也是
objc::DenseMap<DisguisedPtr<objc_object>,size_t,true> RefcountMap
的含義,即模板類型分別對應:
key,DisguisedPtr<objc_object>類型。
value,size_t類型。
是否清除為vlaue==0的數據,true。
DisguisedPtr<objc_object>中的采樣方法是:
static uintptr_t disguise(T* ptr) {
return -(uintptr_t)ptr;
}
// 將T按照模板替換為objc_object,即是:
static uintptr_t disguise(objc_object* ptr) {
return -(uintptr_t)ptr;
}
所以,對象引用計數map RefcountMap
的key是:-(object *)
,就是對象地址取負
。value就是該對象的引用計數。
我們來看一下OC是如何獲取對象引用計數的:
inline uintptr_t
objc_object::rootRetainCount()
{
//case 1: 如果是tagged pointer,則直接返回this,因為tagged pointer是不需要引用計數的
if (isTaggedPointer()) return (uintptr_t)this;
// 將objcet對應的sidetable上鎖
sidetable_lock();
isa_t bits = LoadExclusive(&isa.bits);
ClearExclusive(&isa.bits);
// case 2: 如果采用了優化的isa指針
if (bits.nonpointer) {
uintptr_t rc = 1 + bits.extra_rc; // 先讀取isa.extra_rc
if (bits.has_sidetable_rc) { // 如果extra_rc不夠大, 還需要讀取sidetable中的數據
rc += sidetable_getExtraRC_nolock(); // 總引用計數= rc + sidetable count
}
sidetable_unlock();
return rc;
}
// case 3:如果沒采用優化的isa指針,則直接返回sidetable中的值
sidetable_unlock(); // 將objcet對應的sidetable解鎖,因為sidetable_retainCount()中會上鎖
return sidetable_retainCount();
}
可以看到,runtime在獲取對象引用計數的時候,是考慮了三種情況:(1)tagged pointer
, (2)優化的isa
, (3)未優化的isa
。
我們來看一下(2)優化的isa
的情況下:
首先,會讀取extra_rc
中的數據,因為extra_rc
中存儲的是引用計數減一,所以這里要加回去。
如果extra_rc
不夠大的話,還需要讀取sidetable
,調用sidetable_getExtraRC_nolock
:
#define SIDE_TABLE_RC_SHIFT 2
size_t
objc_object::sidetable_getExtraRC_nolock()
{
assert(isa.nonpointer);
SideTable& table = SideTables()[this];
RefcountMap::iterator it = table.refcnts.find(this);
if (it == table.refcnts.end()) return 0;
else return it->second >> SIDE_TABLE_RC_SHIFT;
}
注意,這里在返回引用計數前,還做了個右移2位
的位操作it->second >> SIDE_TABLE_RC_SHIFT
。這是因為在sidetable
中,引用計數的低2位不是用來記錄引用次數的,而是分別表示對象是否有弱引用計數,以及是否在deallocing,這估計是為了兼容未優化的isa而設計的:
#define SIDE_TABLE_WEAKLY_REFERENCED (1UL<<0)
#define SIDE_TABLE_DEALLOCATING (1UL<<1) // MSB-ward of weak bit
所以,在sidetable中做加引用加一操作時,需要在第3位上+1:
#define SIDE_TABLE_RC_ONE (1UL<<2) // MSB-ward of deallocating bit
refcntStorage += SIDE_TABLE_RC_ONE;
這里sidetable的引用計數值還有一個SIDE_TABLE_RC_PINNED
狀態,表明這個引用計數太大了,連sidetable都表示不出來:
#define SIDE_TABLE_RC_PINNED (1UL<<(WORD_BITS-1))
OK,到此為止,我們就學習完了runtime中所有的引用計數實現方式。接下來我們還會繼續看和引用計數相關的兩個概念:弱引用和autorelease。
Weekly reference
再來回看一下sidetable
的定義如下:
struct SideTable {
spinlock_t slock; // 自旋鎖,防止多線程訪問沖突
RefcountMap refcnts; // 對象引用計數map
weak_table_t weak_table; // 對象弱引用map
}
spinlock_t slock
、RefcountMap refcnts
的定義我們已經清楚,下面就來看一下weak_table_t weak_table
,它記錄了所有弱引用對象的集合。
weak_table_t
定義如下:
/**
* The global weak references table. Stores object ids as keys,
* and weak_entry_t structs as their values.
*/
struct weak_table_t {
weak_entry_t *weak_entries; // hash數組,用來存儲弱引用對象的相關信息weak_entry_t
size_t num_entries; // hash數組中的元素個數
uintptr_t mask; // hash數組長度-1,會參與hash計算。(注意,這里是hash數組的長度,而不是元素個數。比如,數組長度可能是64,而元素個數僅存了2個)
uintptr_t max_hash_displacement; // 可能會發生的hash沖突的最大次數
};
weak_table_t
包含一個weak_entry_t
類型的數組,可以通過hash算法找到對應object在數組中的index。這種結構,和sidetables
類似,不同的是,weak_table_t
是可以動態擴展的,而不是寫死的64個。
weak_entries
實質上是一個hash數組,數組中存儲weak_entry_t
類型的元素。weak_entry_t
的定義如下:
typedef DisguisedPtr<objc_object *> weak_referrer_t;
#define PTR_MINUS_2 62
/**
* The internal structure stored in the weak references table.
* It maintains and stores
* a hash set of weak references pointing to an object.
* If out_of_line_ness != REFERRERS_OUT_OF_LINE then the set
* is instead a small inline array.
*/
#define WEAK_INLINE_COUNT 4
// out_of_line_ness field overlaps with the low two bits of inline_referrers[1].
// inline_referrers[1] is a DisguisedPtr of a pointer-aligned address.
// The low two bits of a pointer-aligned DisguisedPtr will always be 0b00
// (disguised nil or 0x80..00) or 0b11 (any other address).
// Therefore out_of_line_ness == 0b10 is used to mark the out-of-line state.
#define REFERRERS_OUT_OF_LINE 2
struct weak_entry_t {
DisguisedPtr<objc_object> referent; // 被弱引用的對象
// 引用該對象的對象列表,聯合。 引用個數小于4,用inline_referrers數組。 用個數大于4,用動態數組weak_referrer_t *referrers
union {
struct {
weak_referrer_t *referrers; // 弱引用該對象的對象列表的動態數組
uintptr_t out_of_line_ness : 2; // 是否使用動態數組標記位
uintptr_t num_refs : PTR_MINUS_2; // 動態數組中元素的個數
uintptr_t mask; // 用于hash確定動態數組index,值實際上是動態數組空間長度-1(它和num_refs不一樣,這里是記錄的是數組中位置的個數,而不是數組中實際存儲的元素個數)。
uintptr_t max_hash_displacement; // 最大的hash沖突次數(說明了最多做max_hash_displacement次hash沖突,肯定會找到對應的數據)
};
struct {
// out_of_line_ness field is low bits of inline_referrers[1]
weak_referrer_t inline_referrers[WEAK_INLINE_COUNT];
};
};
bool out_of_line() {
return (out_of_line_ness == REFERRERS_OUT_OF_LINE);
}
weak_entry_t& operator=(const weak_entry_t& other) {
memcpy(this, &other, sizeof(other));
return *this;
}
weak_entry_t(objc_object *newReferent, objc_object **newReferrer)
: referent(newReferent)
{
inline_referrers[0] = newReferrer;
for (int i = 1; i < WEAK_INLINE_COUNT; i++) {
inline_referrers[i] = nil;
}
}
};
根據注釋,DisguisedPtr
方法返回的hash值得最低2個字節應該是0b00
或0b11
,因此可以用out_of_line_ness == 0b10
來表明當前是否在使用數組或動態數組來保存引用該對象的列表。
這樣,sidetable
中的weak_table_t
weak_table成員的結構如下所示:
回頭再來看一下,會發現在weak talbe
中存在兩個
hash 表。
一個是weak_table_t
自身。它可以通過對象地址做hash(hash_pointer(objc_object *) & weak_table->mask
),直接找到weak_entries
中該對象對應的weak_entry_t
。
另一個是weak_entry_t
中的weak_referrer_t *referrers
。它可以通過弱引用該對象的對象指針的指針做hash(w_hash_pointer(objc_object **) & (entry->mask)
),直接找到對象指針的指針在referrers
中對應的weak_referrer_t *
。
雖然weak_table_t
和referrers
是表示意義不同的hash表,但他們的實現以是一樣的,可以看做是同一種hash表。而且還設計的很有技巧。下面,我們就來詳細學習一下hash 表示怎么實現的。
weak table的實現細節
由于weak_entries和referrers中的實現類似,這里我們就以weak_table_t
為例,來分析hash表的實現。
weak_table_t
定義如下:
/**
* The global weak references table. Stores object ids as keys,
* and weak_entry_t structs as their values.
*/
struct weak_table_t {
weak_entry_t *weak_entries; // hash數組,用來存儲弱引用對象的相關信息weak_entry_t
size_t num_entries; // hash數組中的元素個數
uintptr_t mask; // hash數組長度-1,用于和hash值做位與計算,來確定數組下標。(注意,這里是hash數組的長度,而不是元素個數。比如,數組長度可能是64,而元素個數僅存了2個)
uintptr_t max_hash_displacement; // 可能會發生的hash沖突的最大次數
};
hash定位
當向weak_table_t
中插入或查找某個元素時,是通過如下hash算法的(以查找為例):
static weak_entry_t *
weak_entry_for_referent(weak_table_t *weak_table, objc_object *referent)
{
assert(referent);
weak_entry_t *weak_entries = weak_table->weak_entries;
if (!weak_entries) return nil;
size_t begin = hash_pointer(referent) & weak_table->mask;
size_t index = begin;
size_t hash_displacement = 0;
while (weak_table->weak_entries[index].referent != referent) {
index = (index+1) & weak_table->mask;
if (index == begin) bad_weak_table(weak_table->weak_entries); // 觸發bad weak table crash
hash_displacement++;
if (hash_displacement > weak_table->max_hash_displacement) {
return nil;
}
}
return &weak_table->weak_entries[index];
}
首先,確定hash值可能對應的數組下標begin
:
size_t begin = hash_pointer(referent) & weak_table->mask;
hash_pointer(referent)
將會對referent
進行hash操作:
static inline uint32_t ptr_hash(uint64_t key)
{
key ^= key >> 4;
key *= 0x8a970be7488fda55;
key ^= __builtin_bswap64(key);
return (uint32_t)key;
}
這個算法不用深究,知道就是一個hash操作就好了。
有技巧的是后半部分& weak_table->mask
,將hash值和mask
做位與
運算。
之前說過,mask
的值等于數組長度-1。而在下面的小節你會了解到,hash數組的長度會以64,128,256規律遞增。總之,數組長度表現為二進制會是1000...0
這種形式,即首位1
,后面跟n個0
。而這個值減1的話,則會變為011...1
這種形式,即首位0
,后面跟n個1
,這即mask
的二進制形式。那么用mask & hash_pointer(referent)
時,就會保留hash_pointer(referent)
的后n位的值,而首位被位與操作置為了0
。那么這個值肯定是小于首位是1
的數值的,也就是肯定會小于數組的長度。
因此,
begin
是一個小于數組長度的一個數組下標,且這個下標對應著目標元素的hash值。
確定了初始的數組下標后,就開始嘗試確定元素的真正位置:
while (weak_table->weak_entries[index].referent != referent) {
index = (index+1) & weak_table->mask; // hash沖突,做index+1,嘗試下一個相鄰位置,& weak_table->mask 確保了index不會越界,而且會使index自動find數組一圈
if (index == begin) bad_weak_table(weak_table->weak_entries); // 在數組中轉了一圈還沒找到目標元素,觸發bad weak table crash
hash_displacement++;
if (hash_displacement > weak_table->max_hash_displacement) { // 如果hash沖突大于了最大可能的沖突次數,則說明目標對象不存在于數組中,返回nil
return nil;
}
}
這里,產生了hash沖突后,系統會依次線性循環尋找目標對象的位置。直到找了一圈又回到了起點或大于了可能的hash沖突值。這個max_hash_displacement
值是在每個元素插入的時候更新的,它總是記錄在插入時,所發生的hash沖突的最大值。因此在查找時,hash沖突的次數肯定不會大于這個值。
這里最巧妙的是這條語句:
index = (index+1) & weak_table->mask
<font color=orange>它即會讓你向下一個相鄰位置尋找,同時當尋找到最后一個位置時,它又會自動讓你從數組的第一個位置開始尋找。這一切,都歸功于二進制運算的巧妙運用。</font>
hash表自動擴容
這里的weak table的大小是不固定的。當插入新元素時,會調用weak_grow_maybe
方法,來判斷是否要做hash表的擴容。該方法實現如下:
#define TABLE_SIZE(entry) (entry->mask ? entry->mask + 1 : 0)
// Grow the given zone's table of weak references if it is full.
static void weak_grow_maybe(weak_table_t *weak_table)
{
size_t old_size = TABLE_SIZE(weak_table);
// Grow if at least 3/4 full.
if (weak_table->num_entries >= old_size * 3 / 4) { // 當大于現有長度的3/4時,會做數組擴容操作。
weak_resize(weak_table, old_size ? old_size*2 : 64); // 初次會分配64個位置,之后在原有基礎上*2
}
}
這里的擴容會調用weak_resize
方法。每次擴容都會是原有長度的一倍。這樣,每次擴容的新增空間都會比上一次要大一倍,而不是固定的擴容n個空間。這么做的目的在于,系統認為,當你有擴容需求時,之后又擴容需求的概率就會變大,為了防止頻繁的申請內存,所以,每次擴容強度都會比上一次要大。
hash表自動收縮
當從weak table
中刪除元素時,系統會調用weak_compact_maybe
判斷是否需要收縮hash數組的空間 :
// Shrink the table if it is mostly empty.
static void weak_compact_maybe(weak_table_t *weak_table)
{
size_t old_size = TABLE_SIZE(weak_table);
// Shrink if larger than 1024 buckets and at most 1/16 full.
if (old_size >= 1024 && old_size / 16 >= weak_table->num_entries) { // 當前數組長度大于1024,且實際使用空間最多只有1/16時,需要做收縮操作
weak_resize(weak_table, old_size / 8); // 縮小8倍
// leaves new table no more than 1/2 full
}
}
hash表resize
無論是擴容還是收縮,最終都會調用到weak_resize
方法:
static void weak_resize(weak_table_t *weak_table, size_t new_size)
{
size_t old_size = TABLE_SIZE(weak_table);
weak_entry_t *old_entries = weak_table->weak_entries; // 先把老數據取出來
weak_entry_t *new_entries = (weak_entry_t *) // 在為新的size申請內存
calloc(new_size, sizeof(weak_entry_t));
// 重置weak_table的各成員
weak_table->mask = new_size - 1;
weak_table->weak_entries = new_entries;
weak_table->max_hash_displacement = 0;
weak_table->num_entries = 0; // restored by weak_entry_insert below
if (old_entries) {
weak_entry_t *entry;
weak_entry_t *end = old_entries + old_size;
for (entry = old_entries; entry < end; entry++) {
if (entry->referent) { // 依次將老的數據插入到新的內存空間
weak_entry_insert(weak_table, entry);
}
}
free(old_entries); // 釋放老的內存空間
}
}
/**
* Add new_entry to the object's table of weak references.
* Does not check whether the referent is already in the table.
*/
static void weak_entry_insert(weak_table_t *weak_table, weak_entry_t *new_entry)
{
weak_entry_t *weak_entries = weak_table->weak_entries;
assert(weak_entries != nil);
size_t begin = hash_pointer(new_entry->referent) & (weak_table->mask);
size_t index = begin;
size_t hash_displacement = 0;
while (weak_entries[index].referent != nil) {
index = (index+1) & weak_table->mask;
if (index == begin) bad_weak_table(weak_entries);
hash_displacement++;
}
weak_entries[index] = *new_entry;
weak_table->num_entries++;
if (hash_displacement > weak_table->max_hash_displacement) { // 這里記錄最大的hash沖突次數,當查找元素時,hash沖突肯定不會大于這個值
weak_table->max_hash_displacement = hash_displacement;
}
}
OK, 上面就是對runtime中weak引用的相關數據結構的分析。關于weak引用數據,是存在于hash表中的。
這關于hash算法映射到數組下標,以及hash表動態的擴容/收縮,還是很有意思的。