iOS管理物件記憶體的資料結構以及操作演算法--SideTables、RefcountMap、weak_table_t-一

iOS入門級攻城屍發表於2017-03-16

    第一次寫文章語言表達能力太差。如果有哪裡表達的不夠清晰可以直接評論回覆我,我來加以修改。這篇文章力求脫離語言的特性,我們們多講結構和演算法。即使你不懂iOS開發,不懂Objective-C語言也可以看這篇文章。
    通過閱讀本文你可以瞭解iOS管理物件記憶體的資料結構是什麼樣的,以及操作邏輯。物件的reatin、release、dealloc操作是該通過怎樣的演算法實現的,weak指標是如何自動變nil的。
    本文所闡述的內容程式碼部分在蘋果的開源專案objc4-706中。

本文流程:
一、引用計數的概念
二、丟擲問題
三、資料結構分析( SideTables、RefcountMap、weak_table_t)

#一、引用計數的概念

    這一部分是寫給非iOS工程師的,便於大家瞭解引用計數、迴圈引用、弱引用的概念。如果已經瞭解相關概念可以直接跳過第一部分。

    大家都知道想要佔用一塊記憶體很容易,我們們 new 一個物件就完事兒了。但是什麼時候回收?不回收自然是不成的,記憶體再大也不能完全不回收利用。回收早了的話,真正用到的時候會出現野指標問題。回收晚了又浪費寶貴的記憶體資源。我們們得拿出一套管理記憶體的方法才成。本文只討論iOS管理物件記憶體的引用計數法。
    記憶體中每一個物件都有一個屬於自己的引用計數器。當某個物件A被另一個傢伙引用時,A的引用計數器就+1,如果再有一個傢伙引用到A,那麼A的引用計數器就再+1。當其中某個傢伙不再引用A了,A的引用計數器會-1。直到A的引用計數減到了0,那麼就沒有人再需要它了,就是時候把它釋放掉了。

在引用計數中,每一個物件負責維護物件所有引用的計數值。當一個新的引用指向物件時,引用計數器就遞增,當去掉一個引用時,引用計數就遞減。當引用計數到零時,該物件就將釋放佔有的資源。

    採用上述機制看似就可以知道物件在記憶體中應該何時釋放了,但是還有一個迴圈引用的問題需要我們解決。

iOS管理物件記憶體的資料結構以及操作演算法--SideTables、RefcountMap、weak_table_t-一
9F4E7963-0B4B-4153-A9FD-C3E9689B545B.png

現在記憶體中有兩個物件,A和B。

A.x = B;
B.y = A;複製程式碼
  • 假如A是做視訊處理的,B是處理音訊的。
  • 現在A的引用計數是1(被B.y引用)。
  • 現在B的引用計數也是1(被A.x引用)。
  • 那麼當A處理完它的視訊工作以後,發現自己的引用計數是1不是0,他心裡想"哦還有人需要我,我還不能被釋放。"
  • 當B處理完音訊操作以後他發現他的引用計數也是1,他心裡也覺得"我還不能被釋放還有人需要我。"

    這樣兩個物件互相迴圈引用著對方誰都不會被釋放就造成了記憶體洩露。為了解決這個問題我們來引入弱引用的概念。
    弱引用指向要引用的物件,但是不會增加那個物件的引用計數。就像下面這個圖這樣。虛線為弱引用 (艾瑪我畫圖畫的真醜)

iOS管理物件記憶體的資料結構以及操作演算法--SideTables、RefcountMap、weak_table_t-一
EFDCA2C8-4E42-48EF-AE5F-3D4607B6CF68.png

        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)

iOS管理物件記憶體的資料結構以及操作演算法--SideTables、RefcountMap、weak_table_t-一
9BE315AE-E25E-41D1-99FD-883EDC5884F6.png

很多人反應看了這篇文章後還是對SideTables、SideTable、RefcountMap三者的關係不太清楚。可能是我這篇文章講述的不太好。大家可以看我第二篇文章中有一個大學宿舍樓的例子,結合這個例子看或許可以有助於理解三者關係。
我們們先來討論最頂層的SideTables
iOS管理物件記憶體的資料結構以及操作演算法--SideTables、RefcountMap、weak_table_t-一
EA251BCE-F990-4CA6-B66E-8822D8089D61.png

    為了管理所有物件的引用計數和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、...... 0x000e、0x000f
    我們們建立一個SideTables[8]來存放這16個物件,那麼查詢的時候發生Hash衝突的概率就是八分之一。
    假設SideTables[0x0000]和SideTables[0x0x000f]衝突,對映到相同的結果。

SideTables[0x0000] == SideTables[0x0x000f]  ==> 都指向同一個SideTable複製程式碼

    蘋果把兩個物件的記憶體管理都放到裡同一個SideTable中。你在這個SideTable中需要再次呼叫table.refcnts.find(0x0000)或者table.refcnts.find(0x000f)來找到他們真正的引用計數器。
    這裡是一個分流。記憶體中物件的數量實在是太龐大了我們通過第一個Hash表只是過濾了第一次,然後我們還需要再通過這個Map才能精確的定位到我們要找的物件的引用計數器。
引用計數器的儲存結構如下
注意這裡討論的是table.refcnts.find(this)得到的value的結構,至於RefcountMap是什麼結構我們在下一篇文章中討論

iOS管理物件記憶體的資料結構以及操作演算法--SideTables、RefcountMap、weak_table_t-一
77490066-7101-4F70-BF50-604D3658F7C4.png

引用計數器的資料型別是:

typedef __darwin_size_t        size_t;複製程式碼

再進一步看它的定義其實是unsigned long,在32位和64位作業系統中,它分別佔用32和64個bit。
蘋果經常使用bit mask技術。這裡也不例外。拿32位系統為例的話,可以理解成有32個盒子排成一排橫著放在你面前。盒子裡可以裝0或者1兩個數字。我們規定最後邊的盒子是低位,左邊的盒子是高位。

  • (1UL<<0)的意思是將一個"1"放到最右側的盒子裡,然後將這個"1"向左移動0位(就是原地不動):0b0000 0000 0000 0000 0000 0000 0000 0001
  • (1UL<<1)的意思是將一個"1"放到最右側的盒子裡,然後將這個"1"向左移動1位:0b0000 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;

iOS管理物件記憶體的資料結構以及操作演算法--SideTables、RefcountMap、weak_table_t-一
9BE315AE-E25E-41D1-99FD-883EDC5884F6.png

    上面的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中取出引用計數
//這裡是table是SideTable、refcnts是RefcountMap
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);
//table.refcnts.end()表示使用一個iterator迭代器到達了end()狀態
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的操作流程過一遍了,希望大家看的開心。
  歡迎加我的微博weibo.com/xuyang186
  轉載請註明出處,謝謝。

參考文獻

相關文章