iOS管理對象內存的數據結構以及操作算法--SideTables、RefcountMap、weak_table_t
第一次寫文章語言表達能力太差。如果有哪里表達的不夠清晰可以直接評論回復我,我來加以修改。這篇文章力求脫離語言的特性,咱們多講結構和算法。即使你不懂iOS開發,不懂Objective-C語言也可以看這篇文章。
通過閱讀本文你可以了解iOS管理對象內存的數據結構是什么樣的,以及操作邏輯。對象的reatin、release、dealloc操作是該通過怎樣的算法實現的,weak指針是如何自動變nil的。
一、引用計數的概念
這一部分是寫給非iOS工程師的,便于大家了解引用計數、循環引用、弱引用的概念。如果已經了解相關概念可以直接跳過第一部分。
大家都知道想要占用一塊內存很容易,咱們 new 一個對象就完事兒了。但是什么時候回收?不回收自然是不成的,內存再大也不能完全不回收利用。回收早了的話,真正用到的時候會出現 野指針 問題。回收晚了又浪費寶貴的內存資源。咱們得拿出一套管理內存的方法才成。本文只討論iOS管理對象內存的 引用計數 法。
內存中每一個對象都有一個屬于自己的引用計數器。當某個對象A被另一個家伙引用時,A的引用計數器就+1,如果再有一個家伙引用到A,那么A的引用計數就再+1。當其中某個家伙不再引用A了,A的引用計數會-1。直到A的引用計數減到了0,那么就沒有人再需要它了,就是時候把它釋放掉了。
在引用計數中,每一個對象負責維護對象所有引用的計數值。當一個新的引用指向對象時,引用計數器就遞增,當去掉一個引用時,引用計數就遞減。當引用計數到零時,該對象就將釋放占有的資源。
采用上述機制看似就可以知道對象在內存中應該何時釋放了,但是還有一個 循環引用 的問題需要我們解決。
現在內存中有兩個對象,A和B。
A.x = B;
B.y = A;
- 假如A是做視頻處理的,B是處理音頻的。
- 現在A的引用計數是1(被B.y引用)。
- 現在B的引用計數也是1(被A.x引用)。
- 那么當A處理完它的視頻工作以后,發現自己的引用計數是1不是0,他心里想"哦還有人需要我,我還不能被釋放。"
- 當B處理完音頻操作以后他發現他的引用計數也是1,他心里也覺得"我還不能被釋放還有人需要我。"
這樣兩個對象互相循環引用著對方誰都不會被釋放就造成了內存泄露。為了解決這個問題我們來引入 弱引用 的概念。
弱引用指向要引用的對象,但是不會增加那個對象的引用計數。就像下面這個圖這樣。 虛線為弱引用 (艾瑪我畫圖畫的真丑)
A.x = B;
__weak B.y = A;
這里我們讓B的y是一個弱引用,它還可以指向A但是不增加A的引用計數。
- 所以A的引用計數是0,B的引用計數是1(被A.x引用)。
- 當A處理完他的視頻操作以后,發現自己的引用計數是0了,ok他可以釋放了。
- 隨之A.x也被釋放了。( A.x是對象A內部的一個變量 )
- A.x被釋放了以后B的引用計數就也變成0了。
- 然后B處理完他的音頻操作以后也可以釋放了。
循環引用的問題解決了。我們不妨思考一下,這套方案還會不會有其它的問題?
思考中...
還有一個 野指針 的問題等待我們解決。
- 如果A先處理完他的視頻任務之后被釋放了。
- 這時候B還在處理中。
- 但是處理過程中B需要訪問A (B.y)來獲取一些數據。
- 由于A已經被釋放了,所以再訪問的時候就造成了 野指針 錯誤。
因此我們還需要一個機制,可以讓A釋放之后,我再訪問所有指向A的指針( 比如B.y )的時候都可以友好的得知A已經不存在了,從而避免出錯。
我們這里假設用一個數組,把所有指向A的弱引用都存起來,然后當A被釋放的時候把數組內所有的若引用都設置成nil( 相當于其他語言中的NULL )。這樣當B再訪問B.y的時候就會返回nil。通過判空的方式就可以避免野指針錯誤了。當然說起來簡單,下面我們來看看蘋果是如何實現的。
二、拋出問題
前面絮絮叨叨說了一大堆,其實真正現在才拋出本次討論的問題。
- 1、如何實現的引用計數管理,控制加一減一和釋放?
- 2、為何維護的weak指針防止野指針錯誤?
三、數據結構分析( SideTables、RefcountMap、weak_table_t )
咱們先來討論最頂層的 SideTables
為了管理所有對象的引用計數和weak指針,蘋果創建了一個全局的SideTables,雖然名字后面有個"s"不過他其實是一個全局的 Hash 表,里面的內容裝的都是 SideTable 結構體而已。它使用對象的 內存地址當它的key 。管理引用計數和weak指針就靠它了。
因為對象引用計數相關操作應該是 原子性 的。不然如果多個線程同時去寫一個對象的引用計數,那就會造成數據錯亂,失去了內存管理的意義。同時又因為內存中對象的數量是 非常非常龐大 的需要非常頻繁的操作SideTables,所以 不 能對整個Hash表加鎖。蘋果采用了 分離鎖 技術。
分離鎖和分拆鎖的區別
降低鎖競爭的另一種方法是降低線程請求鎖的頻率。分拆鎖 (lock splitting) 和分離鎖 (lock striping) 是達到此目的兩種方式。相互獨立的狀態變量,應該使用獨立的鎖進行保護。有時開發人員會錯誤地使用一個鎖保護所有的狀態變量。這些技術減小了鎖的粒度,實現了更好的可伸縮性。但是,這些鎖需要仔細地分配,以降低發生死鎖的危險。
如果一個鎖守護多個相互獨立的狀態變量,你可能能夠通過分拆鎖,使每一個鎖守護不同的變量,從而改進可伸縮性。通過這樣的改變,使每一個鎖被請求的頻率都變小了。分拆鎖對于中等競爭強度的鎖,能夠有效地把它們大部分轉化為非競爭的鎖,使性能和可伸縮性都得到提高。
分拆鎖有時候可以被擴展,分成若干加鎖塊的集合,并且它們歸屬于相互獨立的對象,這樣的情況就是分離鎖。
因為是使用對象的內存地址當key所以Hash的分部也很平均。假設Hash表有n個元素,則可以將Hash的沖突減少到n分之一,支持n路的并發寫操作。
SideTable
當我們通過SideTables[key]來得到SideTable的時候,SideTable的結構如下:
1,一把自旋鎖。 spinlock_t slock;
自旋鎖 比較適用于鎖使用者保持鎖時間比較短的情況。正是由于自旋鎖使用者一般保持鎖時間非常短,因此選擇自旋而不是睡眠是非常必要的,自旋鎖的效率遠高于互斥鎖。信號量和讀寫信號量適合于保持時間較長的情況,它們會導致調用者睡眠,因此只能在進程上下文使用,而自旋鎖適合于保持時間非常短的情況,它可以在任何上下文使用。
它的作用是在操作引用技術的時候對SideTable加鎖,避免數據錯誤。
蘋果在對鎖的選擇上可以說是精益求精。蘋果知道對于引用計數的操作其實是非常快的。所以選擇了雖然不是那么高級但是確實效率高的自旋鎖,我在這里只能說"雙擊666,老鐵們! 沒毛病!"
2,引用計數器 RefcountMap refcnts;
對象具體的引用計數數量是記錄在這里的。
這里注意RefcountMap其實是個C++的 Map 。為什么Hash以后還需要個Map?其實蘋果采用的是分塊化的方法。
舉個例子
假設現在內存中有16個對象。
0x0000、0x0001、0x0010、0x0011、0x0100......
咱們創建一個SideTables[8]來存放這16個對象,那么查找的時候發生Hash沖突的概率就是八分之一。
假設SideTables[0x0000]和SideTables[0x1111]沖突,映射到相同的結果。
SideTables[0x0000] == SideTables[0x1111] ==> 都指向同一個SideTable
蘋果把兩個對象的內存管理都放到里同一個SideTable中。你在這個SideTable中需要再次調用 table.refcnts.find(0x0000 )或者 table.refcnts.find(0x1111) 來找到他們真正的引用計數。
這里是一個分流。內存中對象的數量實在是太龐大了我們通過第一個Hash表只是過濾了第一次,然后我們還需要再通過這個Map才能精確的定位到我們要找的對象的引用計數器。
引用計數器的存儲結構如下
引用計數器的數據類型是:
typedef __darwin_size_t size_t;
再進一步看它的定義其實是 unsigned long ,在32位和64位操作系統中,它分別占用32和64個bit。
蘋果經常使用 bit mask 技術。這里也不例外。拿32位系統為例的話,可以理解成有32個盒子排成一排橫著放在你面前。盒子里可以裝0或者1兩個數字。我們規定最后邊的盒子是低位,左邊的盒子是高位。
- (1UL<<0)的意思是將一個"1"放到最右側的盒子里,然后將這個"1"向左移動0位(就是原地不動):0x0000 0000 0000 0000 0000 0000 0000 0001
- (1UL<<1)的意思是將一個"1"放到最右側的盒子里,然后將這個"1"向左移動1位:0x0000 0000 0000 0000 0000 0000 0000 0010
下面來分析引用計數器( 圖中右側 )的結構,從低位到高位。
-
(1UL<<0) WEAKLY_REFERENCED
表示是否有弱引用指向這個對象,如果有的話(值為1)在對象釋放的時候需要把所有指向它的弱引用都變成nil( 相當于其他語言的NULL ),避免野指針錯誤。
-
(1UL<<1) DEALLOCATING
表示對象是否正在被釋放。1正在釋放,0沒有。
-
REAL COUNT
圖中REAL COUNT的部分才是對象真正的引用計數存儲區。所以咱們說的引用計數加一或者減一,實際上是對整個unsigned long加四或者減四,因為真正的計數是從2^2位開始的。
-
(1UL<<(WORD_BITS-1)) SIDE_TABLE_RC_PINNED
其中WORD_BITS在32位和64位系統的時候分別等于32和64。其實這一位沒啥具體意義,就是隨著對象的引用計數不斷變大。如果這一位都變成1了,就表示引用計數已經最大了不能再增加了。
3,維護weak指針的結構體 weak_table_t weak_table;
上面的RefcountMap refcnts;是一個一層結構,可以通過key直接找到對應的value。而這里是一個兩層結構。
第一層結構體中包含兩個元素。
第一個元素 weak_entry_t *weak_entries; 是一個數組,上面的RefcountMap是要通過find(key)來找到精確的元素的。weak_entries則是通過循環遍歷來找到對應的entry。
(上面管理引用計數蘋果使用的是Map,這里管理weak指針蘋果使用的是數組,有興趣的朋友可以思考一下為什么蘋果會分別采用這兩種不同的結構)
第二個元素num_entries是用來維護保證數組始終有一個合適的size。比如數組中元素的數量超過3/4的時候將數組的大小乘以2。
第二層weak_entry_t的結構包含3個部分
- 1,referent:
被指對象的地址。前面循環遍歷查找的時候就是判斷目標地址是否和他相等。 - 2,referrers
可變數組,里面保存著所有指向這個對象的弱引用的地址。當這個對象被釋放的時候,referrers里的所有指針都會被設置成nil。 - 3,inline_referrers
只有4個元素的數組,默認情況下用它來存儲弱引用的指針。當大于4個的時候使用referrers來存儲指針。
OK大家來看著圖看著偽代碼走一遍流程
1,alloc
這時候其實并不操作SideTable,具體可以參考:
深入淺出ARC(上)
Objc使用了類似散列表的結構來記錄引用計數。并且在初始化的時候設為了一。
2,retain: NSObject.mm line:1402-1417
//1、通過對象內存地址,在SideTables找到對應的SideTable
SideTable& table = SideTables()[this];
//2、通過對象內存地址,在refcnts中取出引用計數
size_t& refcntStorage = table.refcnts[this];
//3、判斷PINNED位,不為1則+4
if (! (refcntStorage & PINNED)) {
refcntStorage += (1UL<<2);
}
3,release NSObject.mm line:1524-1551
table.lock();
引用計數 = table.refcnts.find(this);
if (引用計數 == table.refcnts.end()) {
//標記對象為正在釋放
table.refcnts[this] = SIDE_TABLE_DEALLOCATING;
} else if (引用計數 < SIDE_TABLE_DEALLOCATING) {
//這里很有意思,當出現小余(1UL<<1) 的情況的時候
//就是前面引用計數位都是0,后面弱引用標記位WEAKLY_REFERENCED可能有弱引用1
//或者沒弱引用0
//為了不去影響WEAKLY_REFERENCED的狀態
引用計數 |= SIDE_TABLE_DEALLOCATING;
} else if ( SIDE_TABLE_RC_PINNED位為0) {
引用計數 -= SIDE_TABLE_RC_ONE;
}
table.unlock();
如果做完上述操作后如果需要釋放對象,則調用dealloc
4,dealloc NSObject.mm line:1555-1571
dealloc操作也做了大量了邏輯判斷和其它處理,咱們這里拋開那些邏輯只討論下面部分 sidetable_clearDeallocating()
SideTable& table = SideTables()[this];
table.lock();
引用計數 = table.refcnts.find(this);
if (引用計數 != table.refcnts.end()) {
if (引用計數中SIDE_TABLE_WEAKLY_REFERENCED標志位為1) {
weak_clear_no_lock(&table.weak_table, (id)this);
}
//從refcnts中刪除引用計數
table.refcnts.erase(it);
}
table.unlock();
weak_clear_no_lock() 是關鍵,它才是在對象被銷毀的時候處理所有弱引用指針的方法。
weak_clear_no_lock objc-weak.mm line:461-504
void
weak_clear_no_lock(weak_table_t *weak_table, id referent_id)
{
//1、拿到被銷毀對象的指針
objc_object *referent = (objc_object *)referent_id;
//2、通過 指針 在weak_table中查找出對應的entry
weak_entry_t *entry = weak_entry_for_referent(weak_table, referent);
if (entry == nil) {
/// XXX shouldn't happen, but does with mismatched CF/objc
//printf("XXX no entry for clear deallocating %p\n", referent);
return;
}
//3、將所有的引用設置成nil
weak_referrer_t *referrers;
size_t count;
if (entry->out_of_line()) {
//3.1、如果弱引用超過4個則將referrers數組內的弱引用都置成nil。
referrers = entry->referrers;
count = TABLE_SIZE(entry);
}
else {
//3.2、不超過4個則將inline_referrers數組內的弱引用都置成nil
referrers = entry->inline_referrers;
count = WEAK_INLINE_COUNT;
}
//循環設置所有的引用為nil
for (size_t i = 0; i < count; ++i) {
objc_object **referrer = referrers[i];
if (referrer) {
if (*referrer == referent) {
*referrer = nil;
}
else if (*referrer) {
_objc_inform("__weak variable at %p holds %p instead of %p. "
"This is probably incorrect use of "
"objc_storeWeak() and objc_loadWeak(). "
"Break on objc_weak_error to debug.\n",
referrer, (void*)*referrer, (void*)referent);
objc_weak_error();
}
}
}
//4、從weak_table中移除entry
weak_entry_remove(weak_table, entry);
}
講到這里我們就已經把SideTables的操作流程過一遍了,希望大家看的開心。
參考文獻
- iOS進階——iOS(Objective-C)內存管理·二
- 深入淺出ARC(上)
- 我們的對象會經歷什么
- Objective-C 引用計數原理
- 神經病院Objective-C Runtime入院第一天——isa和Class
- 深入理解Tagged Pointer
- Why is weak_table_t a member of SideTable in Objective-C runtime?
- How can the Objective-C runtime know whether a weakly referenced object is still alive?
來自:http://www.jianshu.com/p/ef6d9bf8fe59