iOS記憶體管理的那些事兒-原理及實現
作者簡介
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)技術的發展。現在已經很少看到ARC的程式碼。在MRC時代,程式設計師要手動管理引用計數,通常要遵循一下幾個原則
- 開頭為
alloc
,new
,copy
,mutableCopy
的方法建立的物件,引用計數都會被+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
方法中給物件分配了相應的記憶體。而初始化則呼叫了 initInstanceIsa
和 initIsa
兩個方法。而 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_tryRetain
和 sidetable_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()方法,~相當於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]; [[NSNotificationCenter defaultCenter] addObserverForName:@"boyce" object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) { self.name = @"boyce"; }]; } - (void)dealloc{ [[NSNotificationCenter defaultCenter] removeObserver:self]; } 複製程式碼
這裡就造成了記憶體洩漏,這是因為NSNotificationCenter強引用了usingBlock,而usingBlock強引用了self,而NSNotificationCenter是個單例不會被釋放,而self在被釋放的時候才會去把自己從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]; } 複製程式碼
想說的
我認為記憶體管理的一些基本原理還是比較簡單容易理解,難就難在結合複雜的場景,在一些複雜的場景下我們比較不容易發現記憶體洩漏的點。但是當我們把記憶體洩漏解決後你會發現,原來就是這麼回事!!!
結束語
這部分就到此結束了,我們介紹了記憶體管理的原理,實現以及造成洩漏的常見場景。下篇介紹一些開源檢測記憶體洩漏工具以及他們的實現。謝謝大家。
閱讀部落格還不過癮?
歡迎大家掃二維碼通過新增群助手,加入交流群,討論和部落格有關的技術問題,還可以和博主有更多互動

部落格轉載、線下活動及合作等問題請郵件至 ofollow,noindex">[email protected] 進行溝通