iOS記憶體管理的那些事兒-原理及實現

餓了麼物流技術團隊發表於2019-03-03

作者簡介

boyce,餓了麼物流團隊資深iOS開發。曾在格瓦拉等公司從事iOS相關研發工作。

注:本篇文章是《iOS記憶體管理的那些事兒》系列文章的第一部分。稍後我們會持續更新第二部分(開源監測記憶體洩漏的實現)和第三部分(如何利用開源工具做相關的APM),感興趣的童鞋可以關注我們專欄並獲取實時推送資訊哦~

為什麼要寫這篇文章

最近在做記憶體優化相關的問題,趁著這個機會把記憶體相關知識捋一捋。雖然現在語言設計的趨勢之一就是讓程式設計師不在關心記憶體管理這件事。但是作為一名程式開發,如果因為語言這個特性,而忽略這方面的知識的話,那是非常不可取的,不懂這方面知識,遇到問題會讓我們知其然還不知其所以然。因為記憶體設計的知識比較多,因此我把他做成了系列。第一部分講下基礎的知識和原理,第二部分講下一些開源監測記憶體洩漏的實現。第三部分講下如何利用開源工具做相關的APM。文章中難免有出錯的地方,還請各位斧正。

為什麼要進行記憶體管理

記憶體是計算機的稀缺資源,在移動裝置乃至嵌入裝置就顯得更為稀缺。不同的作業系統對程式執行時所佔用的記憶體要求不一樣。在這裡我們主要說一下移動作業系統對執行中App所佔用的記憶體限制。Android不同Rom在預設情況下,對單個App所能申請的記憶體是有上限。這裡的上限沒有一個統一的具體值,但可以肯定的是,這個上限是存在的。iOS也同樣如此。做移動開發的同學對此應該都會有所感受。記憶體管理是移動日常開發中非常重要的一環。因此,作為移動開發的我們,不僅要知其然,也要知其所以然。

程式記憶體空間佈局

一個程式被載入到記憶體中,記憶體佈局通常是分為如下幾塊。主要分為,程式碼段,資料段,棧,堆。不同語言的程式可能有所不同,比如C++還會具體區分為全域性/靜態儲存區,常量區,自由儲存區。這裡主要關注,屬於程式設計師可以分配和釋放的部分。雖然有些語言使用了GC技術,但是我們在寫程式碼時候依然要關注記憶體的分配和釋放。

常見的記憶體管理技術

現代的記憶體管理技術主要集中在GC(Garbage Collection)上,現在很多語言也在使用GC技術,GC中的記憶體管理技術主要是有以下這些:

  • 標記清除演算法

    標記清除演算法是有兩個部分組成,分別是標記階段和清除階段。標記階段就是對物件進行遍歷,將所有可達的物件進行標記。在清除階段,會將那些沒有被標記的物件進行回收,收回記憶體。這個演算法的缺點是容易造成記憶體碎片

  • 標記複製演算法

    標記複製演算法就是把活動物件複製到新的空間,然後把舊的控制元件全部釋放掉。這個演算法不會像清除演算法一樣產生大量的碎片,因為他是一次把就有空間釋放掉,因此吞吐量比較大。速度較快。他缺點也很明顯,演算法使用可能會用到AB兩個空間,對的使用率較低,同時在實現的時候不可能避免的產生遞迴呼叫

  • 標記壓縮演算法

    相比較上面的標記清除演算法,標記壓縮演算法會把可達的物件重新排列起來,減少可達物件之間的間隙。這樣就不產生記憶體碎片。相比複製演算法不用開闢兩個空間,也節約了空間。

  • 引用計數法

    引用計數法,內部儲存一個計數器,儲存了被多少個程式引用。當沒有被其他程式引用時候,記憶體會被回收。相比於其他的演算法,引用技術法。有以下的優點,可以及時的回收垃圾,查詢次數少。但引用計數有一個比較致命的缺點,無法解決迴圈引用問題。

通過邊對記憶體管理技術介紹,作為iOS開發會對引用計數法有種熟悉的感覺。iOS也是用到了這個技術,只是實現有所不同。

iOS的記憶體管理技術

MRC

通過上面關於常見記憶體管理技術的介紹,我們知道iOS使用的是引用計數這一技術。在前幾年iOS是手動管理引用計數的也就是MRC(manual retain-release),MRC,需要程式設計師自己管理一個物件的引用計數。隨著ARC(Automatic Reference Counting)技術的發展。現在已經很少看到MRC的程式碼。在MRC時代,程式設計師要手動管理引用計數,通常要遵循一下幾個原則

  • 開頭為allocnewcopymutableCopy的方法建立的物件,引用計數都會被+1;
  • 如果需要對物件進行引用,可以通過retain來使引用計數+1;
  • 不再使用該物件時候,通過release使應用計數-1;
  • 不要release你沒有持有的物件。

ARC

在ARC時代,我們不需要手動retain,relase。由於ARC是一種編譯器的技術,因此他本質上並沒有變。以前MRC的知識依然是有用且是必要的。ARC引入了一些新的關鍵詞,如strong,weak,__strong,__weak,__unsafe_reatian等等,值得關注是weak,__weak。這兩個關鍵詞會在物件釋放後,會將引用置位nil,從而避免了野指標的問題。同時,我們也要注意ARC所能管理的只是OC物件,對於非OC的物件,ARC並不會管理他們的記憶體問題。所以在一個物件轉成C的時候,我們要進行橋接。告訴這個編譯器物件生命週期有程式設計師自己來控制;這時候程式設計師需要手動管理c指標的生命週期。同時C指標轉化為OC物件時候,也要進行橋接,這時候橋接的含義則生命週期管理交由ARC管理。你要對它負責。因此我們可以看出來ARC相對於MRC來說,減輕了程式設計師的負擔,不用寫大量的retain,relase的程式碼,同時使用weak,__weak關鍵字可以有效的避免野指標的問題。其背後的原理則沒有變。

iOS記憶體的程式碼實現

蘋果的runtime原始碼可以在這裡看runtime,如果你覺得這樣看不方便的話,你可以通過wget把原始碼現在下來看,具體命令如下所示

wget -c -r -np -k -L -p https://opensource.apple.com/source/objc4/objc4-723/
複製程式碼

下面我看看蘋果的原始碼是如何實現。 https://opensource.apple.com/source/objc4/objc4-723/runtime/NSObject.mm.auto.html

alloc

使用一個物件,首先我們得要物件分配記憶體,所以我們首先來看下alloc的實現吧: alloc方法很簡單,裡邊只是呼叫了一個C函式 _objc_rootAlloc(Class cls);

+ (id)alloc {
    return _objc_rootAlloc(self);
}

複製程式碼

_objc_rootAlloc則呼叫了callAlloc(Class cls, bool checkNil, bool allocWithZone=false)函式;

id _objc_rootAlloc(Class cls)
{
    return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);
}

複製程式碼

因此我們只需要重點關注callAlloc這個函式的邏輯,剖析這個函式的行為和功能。

static ALWAYS_INLINE id callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
    if (slowpath(checkNil && !cls)) return nil;

#if __OBJC2__
    if (fastpath(!cls->ISA()->hasCustomAWZ())) {
        if (fastpath(cls->canAllocFast())) {
            bool dtor = cls->hasCxxDtor();
            id obj = (id)calloc(1, cls->bits.fastInstanceSize());
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            obj->initInstanceIsa(cls, dtor);
            return obj;
        }
        else {
            id obj = class_createInstance(cls, 0);
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            return obj;
        }
    }
#endif

    if (allocWithZone) return [cls allocWithZone:nil];
    return [cls alloc];
}

複製程式碼
fastpath(!cls->ISA()->hasCustomAWZ())

複製程式碼

fastpath 是一個編譯優化的巨集,他會告訴編譯器刮號裡邊的值大概率是什麼,從而編譯器在程式碼優化過程中進行相應彙編指令的優化。這裡主要是判斷子類或者當前類有沒有實現alloc/allocWithZone。如果有實現的話則直接進入

   if (allocWithZone) return [cls allocWithZone:nil];
   return [cls alloc];
複製程式碼

沒有實現的話,那麼會進入稍複雜的判斷邏輯裡邊,通過巨集定義可以看出我們是不支援fastalloc的,所以相關部分邏輯我們暫時忽略過。所以我們只需要關注class_createInstance這個函式的實現。

id class_createInstance(Class cls, size_t extraBytes)
{
    return _class_createInstanceFromZone(cls, extraBytes, nil);
}

static __attribute__((always_inline))  id _class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, 
                              bool cxxConstruct = true, 
                              size_t *outAllocatedSize = nil)
{
    if (!cls) return nil;

    assert(cls->isRealized());

    bool hasCxxCtor = cls->hasCxxCtor();
    bool hasCxxDtor = cls->hasCxxDtor();
    bool fast = cls->canAllocNonpointer();

    size_t size = cls->instanceSize(extraBytes);
    if (outAllocatedSize) *outAllocatedSize = size;

    id obj;
    if (!zone  &&  fast) {
        obj = (id)calloc(1, size);
        if (!obj) return nil;
        obj->initInstanceIsa(cls, hasCxxDtor);
    } 
    else {
        if (zone) {
            obj = (id)malloc_zone_calloc ((malloc_zone_t *)zone, 1, size);
        } else {
            obj = (id)calloc(1, size);
        }
        if (!obj) return nil;
        obj->initIsa(cls);
    }

    if (cxxConstruct && hasCxxCtor) {
        obj = _objc_constructOrFree(obj, cls);
    }

    return obj;
}

複製程式碼

在這個_class_createInstanceFromZone方法中給物件分配了相應的記憶體。而初始化則呼叫了initInstanceIsainitIsa兩個方法。而 initInstanceIsa 只是在呼叫initIsa前進行了判斷。因此我們只需要分析initIsa方法。從方法名字看,似乎是對isa進行初始化。是不是這樣呢?我們進入到方法內部看看具體實現:

inline void objc_object::initIsa(Class cls)
{
    initIsa(cls, false, false);
}

inline void objc_object::initIsa(Class cls, bool nonpointer, bool hasCxxDtor) 
{ 
    assert(!isTaggedPointer()); 
    
    if (!nonpointer) {
        isa.cls = cls;
    } else {
        assert(!DisableNonpointerIsa);
        assert(!cls->instancesRequireRawIsa());
        isa_t newisa(0);

#if SUPPORT_INDEXED_ISA
        assert(cls->classArrayIndex() > 0);
        newisa.bits = ISA_INDEX_MAGIC_VALUE;
        newisa.has_cxx_dtor = hasCxxDtor;
        newisa.indexcls = (uintptr_t)cls->classArrayIndex();
#else
        newisa.bits = ISA_MAGIC_VALUE;
        newisa.has_cxx_dtor = hasCxxDtor;
        newisa.shiftcls = (uintptr_t)cls >> 3;
#endif

        isa = newisa;
    }
}


複製程式碼

這裡程式碼很簡單只是簡單的賦值操作這裡不做細講,可以說從名字上就可以看出來這個函式要幹嘛了。

retain

retain是對引用計數+1操作。分配完記憶體後我來看看retain是如何實現的

- (id)retain {
    return ((id)self)->rootRetain();
}

ALWAYS_INLINE id objc_object::rootRetain()
{
    return rootRetain(false, false);
}

ALWAYS_INLINE id objc_object::rootRetain(bool tryRetain, bool handleOverflow)
{
    if (isTaggedPointer()) return (id)this;

    bool sideTableLocked = false;
    bool transcribeToSideTable = false;

    isa_t oldisa;
    isa_t newisa;

    do {
        transcribeToSideTable = false;
        oldisa = LoadExclusive(&isa.bits);
        newisa = oldisa;
        if (slowpath(!newisa.nonpointer)) {
            ClearExclusive(&isa.bits);
            if (!tryRetain && sideTableLocked) sidetable_unlock();
            if (tryRetain) return sidetable_tryRetain() ? (id)this : nil;
            else return sidetable_retain();
        }
     
        if (slowpath(tryRetain && newisa.deallocating)) {
            ClearExclusive(&isa.bits);
            if (!tryRetain && sideTableLocked) sidetable_unlock();
            return nil;
        }
        uintptr_t carry;
        newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry);  // extra_rc++

        if (slowpath(carry)) {
     
            if (!handleOverflow) {
                ClearExclusive(&isa.bits);
                return rootRetain_overflow(tryRetain);
            }
    
            if (!tryRetain && !sideTableLocked) sidetable_lock();
            sideTableLocked = true;
            transcribeToSideTable = true;
            newisa.extra_rc = RC_HALF;
            newisa.has_sidetable_rc = true;
        }
    } while (slowpath(!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits)));

    if (slowpath(transcribeToSideTable)) {
        sidetable_addExtraRC_nolock(RC_HALF);
    }

    if (slowpath(!tryRetain && sideTableLocked)) sidetable_unlock();
    return (id)this;
}


複製程式碼

我們來主要看rootRetain的邏輯,他接受兩個bool引數。如果是TaggedPointer物件的話直接返回this。因此TaggedPointer的物件呼叫reatin不會改變引用計數。這個函式裡邊有個do{}while()的迴圈,當isa.bits中的值被更新後則迴圈結束。我們一步一步看下do裡邊的邏輯。

  if (slowpath(!newisa.nonpointer)) {
      ClearExclusive(&isa.bits);
      if (!tryRetain && sideTableLocked) sidetable_unlock();
      if (tryRetain) return sidetable_tryRetain() ? (id)this : nil;
      else return sidetable_retain();
   }
複製程式碼

這段邏輯主要處理當前類沒有開啟進行記憶體優化的情況。這裡主要有兩個函式sidetable_tryRetainsidetable_retain


bool objc_object::sidetable_tryRetain()
{
#if SUPPORT_NONPOINTER_ISA
    assert(!isa.nonpointer);
#endif
    SideTable& table = SideTables()[this];
    bool result = true;
    RefcountMap::iterator it = table.refcnts.find(this);
    if (it == table.refcnts.end()) {
        table.refcnts[this] = SIDE_TABLE_RC_ONE;
    } else if (it->second & SIDE_TABLE_DEALLOCATING) {
        result = false;
    } else if (! (it->second & SIDE_TABLE_RC_PINNED)) {
        it->second += SIDE_TABLE_RC_ONE;
    }
    
    return result;
}

id objc_object::sidetable_retain()
{
#if SUPPORT_NONPOINTER_ISA
    assert(!isa.nonpointer);
#endif
    SideTable& table = SideTables()[this];
    
    table.lock();
    size_t& refcntStorage = table.refcnts[this];
    if (! (refcntStorage & SIDE_TABLE_RC_PINNED)) {
        refcntStorage += SIDE_TABLE_RC_ONE;
    }
    table.unlock();

    return (id)this;
}


複製程式碼

sidetable_tryRetain函式主要做了這幾件事,先從雜湊表中取出數值,如果這個數值找不到,就在Map新增 SIDE_TABLE_RC_ONE 值,如果這個數值所在的物件正在析構,那麼將result置位false。最後檢查下這個數字是否溢位,如果沒有溢位則將引用計數+1;而sidetable_retain函式加了個自旋鎖,同時邏輯更簡單些。檢查是否數值是否溢位,沒有溢位則引用計數+1; 說完這兩個函式,我們在回到rootTryRetain()函式。

 if (slowpath(tryRetain && newisa.deallocating)) {
     ClearExclusive(&isa.bits);
     if (!tryRetain && sideTableLocked) sidetable_unlock();
     return nil;
 }

複製程式碼

這裡的邏輯判斷物件是否在析構。如果在析構則會進行相關處理操作。這下來我們看看開啟了指標優化後的retain邏輯

 newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry); 
複製程式碼

這行也是對引用計數+1的,是對其中的extra_rc進行+1

 if (slowpath(carry)) {
     if (!handleOverflow) {
         ClearExclusive(&isa.bits);
         return rootRetain_overflow(tryRetain);
      }
     if (!tryRetain && !sideTableLocked) sidetable_lock();
     sideTableLocked = true;
     transcribeToSideTable = true;
     newisa.extra_rc = RC_HALF;
     newisa.has_sidetable_rc = true;
}

複製程式碼

這裡判斷是否溢位,如果溢位了就會進入到rootRetain_overflow函式裡邊,而rootRetain_overflow函式則又呼叫了rootRetain,只不過handleOverflow會傳true,同時會處理溢位的情況,這時候transcribeToSideTable為true,在結束後就會呼叫sidetable_addExtraRC_nolock(RC_HALF);,我們來看下這個函式的實現。

bool 
objc_object::sidetable_addExtraRC_nolock(size_t delta_rc)
{
    SideTable& table = SideTables()[this];

    size_t& refcntStorage = table.refcnts[this];
    size_t oldRefcnt = refcntStorage;
  
    if (oldRefcnt & SIDE_TABLE_RC_PINNED) return true;

    uintptr_t carry;
    size_t newRefcnt = 
        addc(oldRefcnt, delta_rc << SIDE_TABLE_RC_SHIFT, 0, &carry);
    if (carry) {
        refcntStorage =
            SIDE_TABLE_RC_PINNED | (oldRefcnt & SIDE_TABLE_FLAG_MASK);
        return true;
    }
    else {
        refcntStorage = newRefcnt;
        return false;
    }
}

複製程式碼

之前我們呼叫addc發現溢位後,我們把newisa.extra_rc 置位RC_HALF,同時我們呼叫sidetable_addExtraRC_nolock同時把剩下的RC_HALF加入雜湊表中;也是通過addc進行操作。如果這是溢位則恢復雜湊表中的值,至此retain的邏輯差不多結束了。

release

看完retain原始碼,喘口氣繼續看看release是怎麼實現的吧

- (oneway void)release {
    ((id)self)->rootRelease();
}

ALWAYS_INLINE bool objc_object::rootRelease()
{
    return rootRelease(true, false);
}

ALWAYS_INLINE bool objc_object::rootRelease(bool performDealloc, bool handleUnderflow)
{
    if (isTaggedPointer()) return false;

    bool sideTableLocked = false;

    isa_t oldisa;
    isa_t newisa;

 retry:
    do {
        oldisa = LoadExclusive(&isa.bits);
        newisa = oldisa;
        if (slowpath(!newisa.nonpointer)) {
            ClearExclusive(&isa.bits);
            if (sideTableLocked) sidetable_unlock();
            return sidetable_release(performDealloc);
        }
 
        uintptr_t carry;
        newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry);
        if (slowpath(carry)) {
            goto underflow;
        }
    } while (slowpath(!StoreReleaseExclusive(&isa.bits, 
                                             oldisa.bits, newisa.bits)));

    if (slowpath(sideTableLocked)) sidetable_unlock();
    return false;

 underflow:
    newisa = oldisa;

    if (slowpath(newisa.has_sidetable_rc)) {
        if (!handleUnderflow) {
            ClearExclusive(&isa.bits);
            return rootRelease_underflow(performDealloc);
        }

        if (!sideTableLocked) {
            ClearExclusive(&isa.bits);
            sidetable_lock();
            sideTableLocked = true;
            goto retry;
        }
        
        size_t borrowed = sidetable_subExtraRC_nolock(RC_HALF);

        if (borrowed > 0) {
            newisa.extra_rc = borrowed - 1;  
            bool stored = StoreReleaseExclusive(&isa.bits, 
                                                oldisa.bits, newisa.bits);
            if (!stored) {
            
                isa_t oldisa2 = LoadExclusive(&isa.bits);
                isa_t newisa2 = oldisa2;
                if (newisa2.nonpointer) {
                    uintptr_t overflow;
                    newisa2.bits = 
                        addc(newisa2.bits, RC_ONE * (borrowed-1), 0, &overflow);
                    if (!overflow) {
                        stored = StoreReleaseExclusive(&isa.bits, oldisa2.bits, 
                                                       newisa2.bits);
                    }
                }
            }

            if (!stored) {
                sidetable_addExtraRC_nolock(borrowed);
                goto retry;
            }

            sidetable_unlock();
            return false;
        }
        else {
        
        }
    }

    if (slowpath(newisa.deallocating)) {
        ClearExclusive(&isa.bits);
        if (sideTableLocked) sidetable_unlock();
        return overrelease_error();
    }
    newisa.deallocating = true;
    if (!StoreExclusive(&isa.bits, oldisa.bits, newisa.bits)) goto retry;

    if (slowpath(sideTableLocked)) sidetable_unlock();

    __sync_synchronize();
    if (performDealloc) {
        ((void(*)(objc_object *, SEL))objc_msgSend)(this, SEL_dealloc);
    }
    return true;
}

複製程式碼

看完呼叫順序後,我們著重分析下這個函式吧

objc_object::rootRelease(bool performDealloc, bool handleUnderflow)
複製程式碼

同樣如果是TaggedPointer物件直接返回 false。我們先看retry:程式碼段 這裡邊的部分邏輯與retain相似,我們不一一分析。如果沒有開啟指標優化的話會有呼叫這樣關鍵函式

uintptr_t
objc_object::sidetable_release(bool performDealloc)
{
#if SUPPORT_NONPOINTER_ISA
    assert(!isa.nonpointer);
#endif
    SideTable& table = SideTables()[this];

    bool do_dealloc = false;

    table.lock();
    RefcountMap::iterator it = table.refcnts.find(this);
    if (it == table.refcnts.end()) {
        do_dealloc = true;
        table.refcnts[this] = SIDE_TABLE_DEALLOCATING;
    } else if (it->second < SIDE_TABLE_DEALLOCATING) {
        do_dealloc = true;
        it->second |= SIDE_TABLE_DEALLOCATING;
    } else if (! (it->second & SIDE_TABLE_RC_PINNED)) {
        it->second -= SIDE_TABLE_RC_ONE;
    }
    table.unlock();
    if (do_dealloc  &&  performDealloc) {
        ((void(*)(objc_object *, SEL))objc_msgSend)(this, SEL_dealloc);
    }
    return do_dealloc;
}

複製程式碼

這裡主要做了這幾個邏輯,如果在雜湊表中沒有找到物件,那麼將其中的值置為SIDE_TABLE_DEALLOCATING。如果找到值比SIDE_TABLE_DEALLOCATING還小那麼將it中second置位SIDE_TABLE_DEALLOCATING。如果找到的值不屬於上面情況。那麼檢查是否溢位,沒有溢位則引用計數-1;最後如果這個do_dealloc為true(這個鏈路裡邊的performDealloc為true)那麼就給會給傳送一個SEL_dealloc 的訊息進行釋放。分析完這個函式後我們繼續回到rootRelease中,下面程式碼是開啟了指標優化的情況,接下來會呼叫

 uintptr_t carry;
 newisa.bits = subc(newisa.bits, RC_ONE, 0, &carry); 
複製程式碼

將引用計數-1;同時 會做溢位判斷,如果已經溢位了,則會跳到underflow:程式碼段。這段程式碼的主要邏輯在一個長長的if語句裡邊。這裡邊先判斷has_sidetable_rc這個屬性,這個屬性代表如果為yes,那麼代表會有部分引用計數存到一table裡邊。如果沒有那麼說明已經沒有引用了。直接走釋放邏輯。如果有的話,那麼要從table中取出引用計數,然後進行-1操作,然後賦值給newisa.extra_rc,如果-1操作失敗會立即進行一次。如果還是失敗那麼要table中引用計數恢復,然後進入retry程式碼重複這樣的邏輯.

autolrease

最後說一下autolrease吧,先貼上呼叫棧。 @autoreleasepool{}經過clang -rewrite-objc命令後,我們可以看到

struct __AtAutoreleasePool {
  __AtAutoreleasePool() {atautoreleasepoolobj = objc_autoreleasePoolPush();}
  ~__AtAutoreleasePool() {objc_autoreleasePoolPop(atautoreleasepoolobj);}
  void * atautoreleasepoolobj;
};
複製程式碼

這樣的結構體。初始化的時候會呼叫objc_autoreleasePoolPush()方法,~__AtAutoreleasePool() 是c++結構體中的析構方法,類似於OC中的delloc方法,他會呼叫objc_autoreleasePoolPop(atautoreleasepoolobj)方法,傳入的引數就是我們剛剛通過objc_autoreleasePoolPush()生成的物件。關於@autoreleasepool{}的建立和釋放邏輯我們看這兩個函式就可以了。我們先從objc_autoreleasePoolPush()這個函式開始。

objc_autoreleasePoolPush(void)
{
    return AutoreleasePoolPage::push();
}

static inline void *push() 
{
    id *dest;
    if (DebugPoolAllocation) {
        dest = autoreleaseNewPage(POOL_BOUNDARY);
    } else {
        dest = autoreleaseFast(POOL_BOUNDARY);
    }
    assert(dest == EMPTY_POOL_PLACEHOLDER || *dest == POOL_BOUNDARY);
    return dest;
}

static inline id *autoreleaseFast(id obj)
{
  AutoreleasePoolPage *page = hotPage();
  if (page && !page->full()) {
      return page->add(obj);
  } else if (page) {
      return autoreleaseFullPage(obj, page);
  } else {
      return autoreleaseNoPage(obj);
 }
}

複製程式碼

這裡邊會呼叫AutoreleasePoolPage類的push()方法,我們看一下AutoreleasePoolPage結構

class AutoreleasePoolPage 
{
 
#   define EMPTY_POOL_PLACEHOLDER ((id*)1)
#   define POOL_BOUNDARY nil

    static pthread_key_t const key = AUTORELEASE_POOL_KEY;
    static uint8_t const SCRIBBLE = 0xA3;  // 0xA3A3A3A3 after releasing
    static size_t const SIZE = 
#if PROTECT_AUTORELEASEPOOL
        PAGE_MAX_SIZE;  // must be multiple of vm page size
#else
        PAGE_MAX_SIZE;  // size and alignment, power of 2
#endif

    static size_t const COUNT = SIZE / sizeof(id);

    magic_t const magic;
    id *next;
    pthread_t const thread;
    AutoreleasePoolPage * const parent;
    AutoreleasePoolPage *child;
    uint32_t const depth;
    uint32_t hiwat;
    
 }
複製程式碼

EMPTY_POOL_PLACEHOLDER這個巨集看名字意思是佔位的意思。

從作用上來看,當一個外部呼叫第一次呼叫建立AutoreleasePoolPage,但是沒有任何要進棧的物件時候,那麼他不會先建立一個AutoreleasePoolPage物件,而是把EMPTY_POOL_PLACEHOLDER作為指標返回,並用TLS技術繫結當前執行緒。這樣的實現有點像懶載入,在需要的時候才建立物件。

POOL_BOUNDARY這個之前是POOL_SENTINEL,他們同樣值都是nil。

作用都是在第一次有物件入棧時候會push一個空的物件。這樣以後在pop的時候通過判斷值是不是nil,知道是不是棧底了。相比於POOL_SENTINEL我更覺得POOL_BOUNDARY意思簡潔明瞭。

static pthread_key_t const key = AUTORELEASE_POOL_KEY 這個這個就是TLS把當前hotpage或者EMPTY_POOL_PLACEHOLDER儲存在當前執行緒的key。沒有什麼好說的。

static uint8_t const SCRIBBLE = 0xA3;這個是常數值,唯一的作用就是在releasing的時候通過memset((void*)page->next, SCRIBBLE, sizeof(*page->next));把page的next置位0xA3A3A3A3

magic_t const magic;這個magic用來校驗類的完整性。 id *next;棧的指標。 pthread_t const thread;用於儲存執行緒。

AutoreleasePoolPage * const parent;
AutoreleasePoolPage *child;
uint32_t const depth;
uint32_t hiwat;
複製程式碼

這幾個屬性都是跟雙向連結串列有關係,parent指向父節點,child指向子節點。depth這個是層級,hiwat這個應該棧裡資料的數量。

分析完這個類的結構。我們繼續看呼叫的流程。再呼叫到static inline id *autoreleaseFast(id obj)方法時,裡邊有三個分支走向。我們首先看下一個關鍵一行 AutoreleasePoolPage *page = hotPage();這個hotPage()是通過TLS取當前的AutoreleasePoolPage的。如果是EMPTY_POOL_PLACEHOLDER的話直接返回nil,否則的話就會返回AutoreleasePoolPage,返回之前會做一個完整性檢測。

if (page && !page->full()) {
      return page->add(obj);
  } else if (page) {
      return autoreleaseFullPage(obj, page);
  } else {
      return autoreleaseNoPage(obj);
 }
複製程式碼

這個判斷也是比較簡單的,如果當前不為nil,且沒有滿則直接呼叫add函式,新增obj。這個add函式也是比較簡單入棧操作。只是在入棧的時候做了執行緒保護。當然我們根據巨集是沒有啟用這個執行緒保護功能的。如果當前page已經滿了,那麼會呼叫autoreleaseFullPage方法。我們看下autoreleaseFullPage怎麼實現的。

  static __attribute__((noinline))
    id *autoreleaseFullPage(id obj, AutoreleasePoolPage *page)
    {
        assert(page == hotPage());
        assert(page->full()  ||  DebugPoolAllocation);

        do {
            if (page->child) page = page->child;
            else page = new AutoreleasePoolPage(page);
        } while (page->full());

        setHotPage(page);
        return page->add(obj);
    }
複製程式碼

這個方法的邏輯也沒有複雜的地方。你遍歷子節點直到找到沒有滿的page,如果最後都沒有找到,那麼就新建一個page,然後把這個page繫結到當前執行緒。同時呼叫add方法新增這個obj。然後我們再看下最後一個分支走向autoreleaseNoPage(obj)方法

  static __attribute__((noinline))
    id *autoreleaseNoPage(id obj)
    {
        
        assert(!hotPage());

        bool pushExtraBoundary = false;
        
        if (haveEmptyPoolPlaceholder()) {
            
            pushExtraBoundary = true;
        }
        else if (obj != POOL_BOUNDARY  &&  DebugMissingPools) {
            _objc_inform("MISSING POOLS: (%p) Object %p of class %s "
                         "autoreleased with no pool in place - "
                         "just leaking - break on "
                         "objc_autoreleaseNoPool() to debug", 
                         pthread_self(), (void*)obj, object_getClassName(obj));
            objc_autoreleaseNoPool(obj);
            return nil;
        }
        else if (obj == POOL_BOUNDARY  &&  !DebugPoolAllocation) {
            
            return setEmptyPoolPlaceholder();
        }

       AutoreleasePoolPage *page = new AutoreleasePoolPage(nil);
       setHotPage(page);
       
       if (pushExtraBoundary) {
           page->add(POOL_BOUNDARY);
       }
 
       return page->add(obj);
    }


複製程式碼

相比於前幾個方法這個方法邏輯就稍稍複雜了點。bool pushExtraBoundary = false;這個屬性表示要不要像棧裡邊新增POOL_BOUNDARY,這個只有在棧為空的時候才會是true。第二個if判斷主要是用debug相關,這裡先不管。第三個判斷,如果傳的是一個POOL_BOUNDARY物件且沒有除錯alloc的時候,會將當前執行緒繫結一個EMPTY_POOL_PLACEHOLDER的佔位物件,並返回。經過這些判斷,我們走到了這裡

AutoreleasePoolPage *page = new AutoreleasePoolPage(nil);
       setHotPage(page);
       
if (pushExtraBoundary) {
    page->add(POOL_BOUNDARY);
}
 
return page->add(obj);
複製程式碼

這裡的程式碼比較簡單,新建一個AutoreleasePoolPage物件,並且設定為hotpage,然後如果pushExtraBoundary為true,則把POOL_BOUNDARY入棧,然後把obj入棧。最後返回page物件。這裡大家可能有疑問了,這裡有條件的將POOL_BOUNDARY入棧,為不為導致底不是POOL_BOUNDARY,有這個疑問是很好的。可以我們看整個NSObject.mm的程式碼,可以看到不會出現棧底元素不是POOL_BOUNDARY的。至此,我們把@autorelease{}程式碼的新建邏輯分析完畢。下面我們來看釋放邏輯。

void
objc_autoreleasePoolPop(void *ctxt)
{
    AutoreleasePoolPage::pop(ctxt);
}

 static inline void pop(void *token) 
    {
        AutoreleasePoolPage *page;
        id *stop;

        if (token == (void*)EMPTY_POOL_PLACEHOLDER) {
            if (hotPage()) {
                pop(coldPage()->begin());
            } else {
                setHotPage(nil);
            }
            return;
        }

        page = pageForPointer(token);
        stop = (id *)token;
        if (*stop != POOL_BOUNDARY) {
            if (stop == page->begin()  &&  !page->parent) {
            
            } else {
                return badPop(token);
            }
        }

        if (PrintPoolHiwat) printHiwat();

        page->releaseUntil(stop);

        if (DebugPoolAllocation  &&  page->empty()) {
            AutoreleasePoolPage *parent = page->parent;
            page->kill();
            setHotPage(parent);
        } else if (DebugMissingPools  &&  page->empty()  &&  !page->parent) {
            page->kill();
            setHotPage(nil);
        } 
        else if (page->child) {
            if (page->lessThanHalfFull()) {
                page->child->kill();
            }
            else if (page->child->child) {
                page->child->child->kill();
            }
        }
    }

複製程式碼

看呼叫流程,我們著重分析下pop(void *token)方法,我們先看下段程式碼塊的邏輯:

if (token == (void*)EMPTY_POOL_PLACEHOLDER) {

    if (hotPage()) {
       pop(coldPage()->begin());
    } else {
       setHotPage(nil);
    }
     return;
     
}
複製程式碼

這段邏輯主要判斷如果pop的是一個EMPTY_POOL_PLACEHOLDER,這個就是我們之前空池佔位。那麼先判斷是否存在hotpage,若果存在的話,那麼將呼叫pop方法,同時傳入當前hotpage的最初的父節點,coldPage()返回的是第一個節點。如果不存在hotpage,那麼將TLS繫結的值置位nil。我們繼續看下面的程式碼塊:

page = pageForPointer(token);
stop = (id *)token;
if (*stop != POOL_BOUNDARY) {
    if (stop == page->begin()  &&  !page->parent) {

     } else {             
	     return badPop(token);
     }
}

複製程式碼

page = pageForPointer(token);這個函式根據傳入的token獲取page的首指標。獲取到page後,下面檢查一下token,通常下我們pop最終會傳入一個page的beigin指標。這個通常應該是POOL_BOUNDARY,這裡主要是做異常處理。接下來我們會走到這個函式

page->releaseUntil(stop);

複製程式碼

這個函式的實現如下:

 void releaseUntil(id *stop) 
 {
     
   while (this->next != stop) {
           
     AutoreleasePoolPage *page = hotPage();
     
     while (page->empty()) {
     page = page->parent;
     setHotPage(page);
     }

     page->unprotect();
     id obj = *--page->next;
     memset((void*)page->next, SCRIBBLE, sizeof(*page->next));
     page->protect();

     if (obj != POOL_BOUNDARY) {
     	objc_release(obj);
     }
     }

     setHotPage(this);

}

複製程式碼

這個函式的實現邏輯還是比較清楚的,他依次釋放棧的內容直到遇到stop,並且把next指向的區域置為SCRIBBLE,然後把最近的棧為非空的置為當前的hotpage。最後我們看一下kill的相關邏輯

  if (page->lessThanHalfFull()) {
      page->child->kill();
  }else if (page->child->child) {
      page->child->child->kill();
  }

複製程式碼

上面的判斷邏輯主要是經過releaseUntil後,當前的page的棧已經被清空了,當前棧如果有子節點那麼就釋放子節點。最後我們看一下kill方法。

void kill() 
{
    AutoreleasePoolPage *page = this;
    while (page->child) page = page->child;

    AutoreleasePoolPage *deathptr;
    do {
        deathptr = page;
         page = page->parent;
         if (page) {
          page->unprotect();
          page->child = nil;
         page->protect();
        }
            delete deathptr;
   } while (deathptr != this);
   
}

複製程式碼

這段邏輯就相當簡單了,依次釋放子節點。至此@autorelease{}就分析完畢了,關於autorelease方法這裡就不再分析了,autorelease邏輯基本上與我們上面分析的高度重合,這裡不展開。

常見的容易造成洩漏的點

分析完原始碼後,我們知道iOS中的引用計數是怎麼實現的,但這只是初步。記憶體管理難點不是在原理,而是在複雜的場景下怎麼保證記憶體不洩漏,這才是最難的。我們先列舉常見的容易造成洩漏的點:

迴圈引用

引用計數計數最大的缺點就是他無法解決迴圈引用的問題。如果出現迴圈引用了,需要我們手動打破迴圈引用。否則會一直佔用記憶體。常見的迴圈引用情況主要是block。因為block會強引用外部變數,如果外部變數也在強引用這個block。那麼他們就會造成迴圈引用。比如

 HasBlock *hasBlock = [[HasBlock alloc] init];

[hasBlock setBlock:^{
        hasBlock.name = @"abc";
 }];
複製程式碼

修改方法也很簡單通過一個弱引用間接使用改造如下

 HasBlock *hasBlock = [[HasBlock alloc] init];
 __weak HasBlock* weakHasBlock = hasBlock;
[hasBlock setBlock:^{
        weakHasBlock.name = @"abc";
 }];
複製程式碼

這樣就可以解決迴圈引用,這個是比較常見迴圈引用情況網上有很多巨集解決這個問題。這裡不展開。

使用單例的的一些情況

在使用單例的時候要注意,特別是單例含有block回撥方法時候。有些單例會強持有這些block。這種情況雖然不是迴圈引用,但也是造成了喜歡引用。所以在使用單例的時候要清楚。如系統有些方法這樣使用會造成無法釋放:

- (void)viewDidLoad {
    [super viewDidLoad];
    self.obser = [[NSNotificationCenter defaultCenter] addObserverForName:@"boyce" object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) {
        self.name = @"boyce";
    }];
    
}

- (void)dealloc{
    [[NSNotificationCenter defaultCenter] removeObserver:self.obser];
}

複製程式碼

這裡就造成了記憶體洩漏,這是因為NSNotificationCenter強引用了usingBlock,而usingBlock強引用了self,而NSNotificationCenter是個單例不會被釋放,而self在被釋放的時候才會去把self.obser從NSNotificationCenter中移除。類似的情況還有很多,比如一個陣列中物件等等。這些記憶體洩漏不容易發現。

NSTimer

NSTimer會強引用傳入的target,這時候如果加入NSRunLoop這個timer又會被NSRunLoop強引用

NSTimer *timer = [NSTimer timerWithTimeInterval:10 target:self selector:@selector(commentAnimation) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];

複製程式碼

解決這個方法主動stoptimer,至少是不能在dealloc中stoptimer的。另外可以設定一箇中間類,把target變成中間類。

NSURLSession

這個問題和上面的NSTimer類似

NSURLSession *section = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]
                                                              delegate:self
                                                         delegateQueue:[[NSOperationQueue alloc] init]];
NSURLSessionDataTask *task = [section dataTaskWithURL:[NSURL URLWithString:path]
                                            completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
                                               //Do something
                                            }];
[task resume];
複製程式碼

這裡NSURLSession會強引用了self。同時本地SSL會對一個NSURLSession快取一段時間。所以即使沒有強引用。也會造成記憶體洩漏。這裡比較好的使用單例[NSURLSession sharedSession]

非OC物件的記憶體問題

在OC物件轉換為非OC物件時候,要進行橋接。要把物件的控制權由ARC轉換為程式設計師自己控制,這時候程式設計師要自己控制物件建立和釋放。如下面的簡單程式碼

NSString *name = @"boyce";
CFStringRef cfStringRef = (__bridge CFStringRef) name;
CFRelease(cfStringRef);

複製程式碼

其他洩漏情況

如果present一個UINavigationController,如果返回的姿勢不正確。會造成記憶體洩漏

 UIViewController *vc = [[UIViewController alloc]init];
   UINavigationController *nav = [[UINavigationController alloc]initWithRootViewController:vc];
   [self presentViewController:nav animated:YES completion:NULL];
複製程式碼

如果在UIViewController裡邊呼叫的是

 [self dismissViewControllerAnimated:YES completion:NULL];
複製程式碼

那麼就會造成記憶體洩漏,這裡邊測試發現vc是沒有被釋放的。需要這樣呼叫

 if (self.navigationController.topViewController == self) {
        [self.navigationController dismissViewControllerAnimated:YES completion:nil];
    }

複製程式碼

想說的

我認為記憶體管理的一些基本原理還是比較簡單容易理解,難就難在結合複雜的場景,在一些複雜的場景下我們比較不容易發現記憶體洩漏的點。但是當我們把記憶體洩漏解決後你會發現,原來就是這麼回事!!!

結束語

這部分就到此結束了,我們介紹了記憶體管理的原理,實現以及造成洩漏的常見場景。下篇介紹一些開源檢測記憶體洩漏工具以及他們的實現。謝謝大家。




閱讀部落格還不過癮?

歡迎大家掃二維碼通過新增群助手,加入交流群,討論和部落格有關的技術問題,還可以和博主有更多互動

iOS記憶體管理的那些事兒-原理及實現

部落格轉載、線下活動及合作等問題請郵件至 shadowfly_zyl@hotmail.com 進行溝通

相關文章