系列文章:OC底層原理系列,OC基礎知識系列
在上篇我們介紹了小對象,copy,strong的記憶體管理,介紹了release和dealloc的底層實作,這篇文章繼續研究記憶體管理中的AutoReleasePool,研究AutoReleasePool也會研究下跟AutoReleasePool關聯緊密的NSRunLoop。
AutoReleasePool 自動釋放池
自動釋放池是OC的一種記憶體自動回收機制,在MRC中可以用AutoReleasePool來延遲記憶體的釋放,在ARC中可以用AutoReleasePool将對象添加到最近的自動釋放池,不會立即釋放,會等到runloop休眠或者超出autoreleasepool作用域{}之後才會被釋放。可以通過下圖來表示
- 1.從程式啟動到加載完成,主線程對應的runloop會處于休眠狀态,等待使用者互動來喚醒runloop
- 2.使用者的每一次互動都會啟動一次runloop,用于處理使用者的所有點選、觸摸事件等
- 3.runloop在監聽到互動事件後,就會建立自動釋放池,并将所有延遲釋放的對象添加到自動釋放池中
- 4.在一次完整的runloop結束之前,會向自動釋放池中所有對象發送release消息,然後銷毀自動釋放池
C++分析
我們在main.m寫如下代碼
轉成C++代碼:
通過上圖我們知道@autoreleasepool被轉化成__AtAutoreleasePool __autoreleasepool,這是個結構體。__AtAutoreleasePool結構體定義如下:
通過上圖可以知道這個結構體提供了兩個方法(這兩個方法很重要):1.objc_autoreleasePoolPush 2.objc_autoreleasePoolPop。
通過上圖我們可以知道一下幾點:
- 1.這個結構體有構造函數+析構函數,結構體定義的對象在作用域結束後,會自動調用析構函數
- 2.其中{}是作用域,優點就是結構清晰,可讀性強,可以及時建立銷毀
關于上面說的構造和析構的調用時機和表現,我們可以寫下面的代碼來看看:
通過上面我們可以得出,在LjTest建立時,會自動調用析構函數,再出了{}作用域後,會自動調用析構函數
彙編分析
我們在main.m中加斷點
運作程式,來到斷點,開啟彙編調試
通過調試也得出clang分析的結果。
底層源碼分析
在objc源碼中,對AutoreleasePool做如下說明:
通過描述:
- 1.自動釋放池是一個關于指針的棧結構
- 2.其中的指針是指向釋放的對象或者pool_boundary哨兵(現在經常被稱為邊界)
- 3.自動釋放池是一個頁的結構(虛拟記憶體中提及過),而且這個頁是一個雙向連結清單(表示有父節點和子節點,在類中提及過,即類的繼承鍊)
- 4.自動釋放池和線程有關系
通過上面對自動釋放池的說明,我們知道我們研究的幾個方向:
- 1.自動釋放池什麼時候建立?
- 2.對象是如何加入自動釋放池的?
- 3.哪些對象才會加入自動釋放池?
帶着這些問題,我們出發來探索自動釋放池的底層原理
AutoreleasePoolPage分析
從最初的clang或者彙編分析我們了解了自動釋放池其底層是調用的objc_autoreleasePoolPush和objc_autoreleasePoolPop,它們源碼如下:
從源碼中我們可以發現,都是調用的AutoreleasePoolPage的push和pop實作,以下是其定義
從上面可以做出以下判斷:
- 1.自動釋放池是一個頁,同時也是一個對象,這個頁的大小是4096位元組
- 2.從其定義中發現,AutoreleasePoolPage是繼承自AutoreleasePoolPageData,且該類的屬性也是來自父類,以下是AutoreleasePoolPageData的定義
發現其中有AutoreleasePoolPage對象,是以有以下一個關系鍊AutoreleasePoolPage -> AutoreleasePoolPageData -> AutoreleasePoolPage,從這裡可以說明自動釋放池除了是一個頁,還是一個雙向連結清單結構
- 1.其中AutoreleasePoolPageData結構體的記憶體大小為56位元組
- 屬性magic的類型是magic_t結構體,所占記憶體大小為m[4];所占記憶體(即4*4=16位元組)
- 屬性next(指針)、thread(對象)、parent(對象)、child(對象)均占8位元組(即4*8=32位元組)
- 屬性depth、hiwat類型為uint32_t,實際類型是unsigned int類型,均占4位元組(即2*4=8位元組)
通過上面可以知道一個空的AutoreleasePoolPage的結構如下:
objc_autoreleasePoolPush 源碼分析
進入push的源碼實作:
有以下邏輯:
- 1.首先進行判斷是否存在pool
- 2.如果沒有,則通過autoreleaseNewPage方法建立
- 3.如果有,則通過autoreleaseFast壓棧哨兵對象
autoreleaseNewPage建立頁
先看下autoreleaseNewPage建立頁的實作過程
通過上面的代碼實作(autoreleaseFullPage後面會重點分析),我們知道一下結論
- 1.判斷目前頁是否存在
- 2.如果存在通過autoreleaseFullPage方法進行壓棧對象
- 3.如果不存在,則通過autoreleaseNoPage方法建立頁
- autoreleaseNoPage方法中可知目前線程的自動釋放池是通過AutoreleasePoolPage建立的(973行)
- AutoreleasePoolPage的構造方法是通過實作父類AutoreleasePoolPageData的初始化方法實作的
AutoreleasePoolPage
上面說了目前線程的自動釋放池是通過AutoreleasePoolPage建立,看下AutoreleasePoolPage構造方法:
其中AutoreleasePoolPageData方法傳入的參數含義為:
- begin()表示壓棧的位置(即下一個要釋放對象的壓棧位址)。可以通過源碼調試begin,發現其具體實作等于頁首位址+56,其中的56就是結構體AutoreleasePoolPageData的記憶體大小
我們再看下AutoreleasePoolPage初始化
- 1.objc_thread_self()是表示目前線程,而目前線程是通過tls擷取
- 2.newParent表示父節點
- 3.後續兩個參數是通過父節點的深度、最大入棧個數計算depth以及hiwat
檢視自動釋放池記憶體結構
由于在ARC模式下,是無法手動調用autorelease,是以将Demo切換至MRC模式(Build Settings -> Objectice-C Automatic Reference Counting設定為NO)
寫如下代碼:
運作結果:
通過運作結果,我們發現release是6個,但是我們壓棧對象是5個,其中的POOL表示哨兵,即邊界,其目的是為了防止越界。我們再看下列印位址,發現頁的首位址和哨兵對象相差0x38,轉成10進制正好是56,這也是AutoreleasePoolPage自身的記憶體大小。我們将循環次數改成505,再來運作一次
通過上圖我們發現第一頁滿了,存儲了504個要釋放的對象,第二頁隻存儲了一個。我們再改下循環次數,改為1015次,再看看是不是一頁隻能存504個對象
通過運作發現,第一頁存儲504,第二頁存儲505,第三頁存儲6個
通過上面我們可以得出以下結論:
- 1.第一頁可以存放504個對象,且隻有第一頁有哨兵,當一頁壓棧滿了,就會開辟新的一頁
- 2.第二頁開始,最多可以存放505個對象
- 3.一頁的大小等于505 * 8 = 4040
上面的結論我們之前講AutoreleasePoolPage的SIZE是就說了,一頁的大小為4096位元組,而在其構造函數中對象的壓棧位置,是從首位址+56開始的,是以可以一頁中實際可以存儲4096-56 = 4040位元組,轉換成對象是4040 / 8 = 505個,即一頁最多可以存儲505個對象,其中第一頁有哨兵對象隻能存儲504個。其結構圖如下:
通過上面可以知道:
- 1.一個自動釋放池隻有一個哨兵對象,且哨兵在第一頁
- 2.第一頁最多可以存504個對象,第二頁開始最多存 505個
小結
- 1.autoreleasepool其本質是一個結構體對象,一個自動釋放池對象就是頁,是棧結構存儲,符合先進後出的原則即可
- 2.頁的棧底是一個56位元組大小的空占位符,一頁總大小為4096位元組
- 3.隻有第一頁有哨兵對象,最多存儲504個對象,從第二頁開始最多存儲505個對象
- 4.autoreleasepool在加入要釋放的對象時,底層調用的是objc_autoreleasePoolPush方法
- 5.autoreleasepool在調用析構函數釋放時,内部的實作是調用objc_autoreleasePoolPop方法
壓棧對象 autoreleaseFast
進入autoreleaseFast源碼:
主要分一下幾步:
- 1.擷取目前操作頁,并判斷頁是否存在以及是否滿了
- 2.如果頁存在,且未滿,則通過add方法壓棧對象
- 3.如果頁存在,且滿了,則通過autoreleaseFullPage方法安排新的頁面
- 4.如果頁不存在,則通過autoreleaseNoPage方法建立新頁
autoreleaseFullPage 方法
檢視源碼:
這個方法主要是用于判斷目前頁是否已經存儲滿了,如果目前頁已經滿了,通過do-while循環查找子節點對應的頁,如果不存在,則建立頁,并壓棧對象從上面AutoreleasePoolPage初始化方法中可以看出,主要是通過操作child對象,将目前頁的child指向建立頁面,由此可以得出頁是通過雙向連結清單連接配接。
add 方法
這個方法主要是添加釋放對象,其底層是實作是通過next指針存儲釋放對象,并将next指針遞增,表示下一個釋放對象存儲的位置。從這裡可以看出頁是通過棧結構存儲
objc_autoreleasePoolPop 源碼分析
在objc_autoreleasePoolPop方法中有個參數,在clang分析時,發現傳入的參數是push壓棧後傳回的哨兵對象,即ctxt,其目的是避免出棧混亂,防止将别的對象出棧,其内部是調用AutoreleasePoolPage的pop方法,我們看下pop源碼
pop源碼實作,主要由以下幾步:
- 1.空頁面的處理,并根據token擷取page
- 2.容錯處理
- 3.通過popPage出棧頁
檢視popPage源碼
進入popPage源碼,其中傳入的allowDebug為false,則通過releaseUntil出棧目前頁stop位置之前的所有對象,即向棧中的對象發送release消息,直到遇到傳入的哨兵對象。
releaseUntil源碼
看源碼我們可以知道:
- 1.releaseUntil實作,主要是通過循環周遊,判斷對象是否等于stop,其目的是釋放stop之前的所有的對象
- 2.首先通過擷取page的next釋放對象(即page的最後一個對象),并對next進行遞減,擷取上一個對象
- 3.判斷是否是哨兵對象,如果不是則自動調用objc_release釋放
kill源碼實作
通過kill實作我們知道,主要是銷毀目前頁,将目前頁指派為父節點頁,并将父節點頁的child對象指針置為nil
總結
通過上面的分析,針對自動釋放池的push和pop,總結如下
- 在自動釋放池的壓棧(即push)操作中
- 當沒有pool,即隻有空占位符(存儲在tls中)時,則建立頁,壓棧哨兵對象
- 在頁中壓棧普通對象主要是通過next指針遞增進行的
- 當頁滿了時,需要設定頁的child對象為建立頁
是以,綜上所述,objc_autoreleasePush的整體底層的流程如下圖所示
- 在自動釋放池的出棧(即pop)操作中
- 在頁中出棧普通對象主要是通過next指針遞減進行的,
- 當頁空了時,需要指派頁的parent對象為目前頁
綜上所述,objc_autoreleasePoolPop出棧的流程如下所示
Runloop
Runloop源碼下載下傳位址傳送門
RunLoop和線程的關系
Runloop不支援建立,隻能擷取,目前有兩種方式擷取線程:
CFRunLoopGetMain源碼
_CFRunLoopGet0源碼
通過上面可以知道,Runloop隻有兩種,一種是主線程的,一個是其它線程的。即Runloop和線程是一一對應的
RunLoop的建立
通過上面的_CFRunLoopGet0可以知道Runloop是通過__CFRunLoopCreate建立(系統建立,開發者自己試無法建立的)。我們檢視下__CFRunLoopCreate源碼:
我們發現__CFRunLoopCreate主要是對runloop屬性的指派操作。我們看下1321行的CFRunLoopRef
- 1.根據定義得知,其實RunLoop也是一個對象。是__CFRunLoop結構體的指針類型
- 2.一個RunLoop依賴于多個Mode,意味着一個RunLoop需要處理多個事務,即一個Mode對應多個Item,而一個item中,包含了timer、source、observer,可以用下圖說明
Mode類型
其中mode在蘋果文檔中提及的有五個,而在iOS中公開暴露出來的隻有 NSDefaultRunLoopMode和NSRunLoopCommonModes。 NSRunLoopCommonModes實際上是一個Mode的集合,預設包括 NSDefaultRunLoopMode和NSEventTrackingRunLoopMode。
- NSDefaultRunLoopMode:預設的mode,正常情況下都是在這個model下運作(包括主線程)
- NSEventTrackingRunLoopMode(cocoa):追蹤mode,使用這個mode去跟蹤來自使用者互動的事件(比如UITableView上下滑動流暢,為了不受其他mode影響。)。UITrackingRunLoopMode(iOS)
- NSModalPanelRunLoopMode:處理modal panels事件。
- NSConnectionReplyMode:處理NSConnection對象相關事件,系統内部使用,使用者基本不會使用
- NSRunLoopCommonModes:這是一個僞模式,其為一組run loop mode的集合,将輸入源加入此模式意味着在Common Modes中包含的所有模式下都可以處理。在Cocoa應用程式中,預設情況下Common Modes包含default modes,modal modes,event Tracking modes。可使用CFRunLoopAddCommonMode方法想Common Modes中添加自定義modes。
Source & Timer & Observer
- Source表示可以喚醒RunLoop的一些事件,例如使用者點選了螢幕,就會建立一個RunLoop,主要分為Source0和Source1
- Source0表示非系統事件,即使用者自定義的事件
- Source1表示系統事件,主要負責底層的通訊,具備喚醒能力
- Timer就是常用NSTimer定時器這一類
- Observer主要用于監聽RunLoop的狀态變化,并作出一定響應,主要有以下一些狀态
驗證
RunLoop和mode是一對多
上面我們說過RunLoop和mode是一對多的關系,下面我們通過運作代碼來實操證明。
我們先通過lldb指令擷取mainRunloop、currentRunloop的currentMode
runloop在運作時的mode隻有一個
下面我們擷取mainRunLoop所有的模型
從上面的列印結果可以驗證runloop和CFRunloopMode具有一對多的關系
mode和Item也是一對多
我們繼續在斷點處,通過bt檢視堆棧資訊,從這裡看出timer的item類型如下所示(截取部分)
在RunLoop源碼中檢視Item類型,有以下幾種:
- block應用:__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__
- 調用timer:__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__
- 響應source0: __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__
- 響應source1:__CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__
- GCD主隊列:__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__
- observer源: __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__
我們下面以Timer為例,一般初始化timer時,都會将timer通過addTimer:forMode:方法添加到Runloop中,于是在源碼中查找addTimer的相關方法,即CFRunLoopAddTimer方法,其源碼實作如下
- 1.其實作主要判斷是否是kCFRunLoopCommonModes,然後查找runloop的mode進行比對處理
- 2.其中kCFRunLoopCommonModes不是一種模式,是一種抽象的僞模式,比defaultMode更加靈活
- 3.通過CFSetAddValue(rl->_commonModeItems, rlt);可以得知,runloop與mode是一對多的,同時可以得出mode與item也是一對多的
RunLoop執行
我們知道,RunLoop的執行依賴于run方法,從下面的堆棧資訊中可以看出,其底層執行的是__CFRunLoopRun方法
進入__CFRunLoopRun源碼:
通過__CFRunLoopRun源碼可知,針對不同的對象,有不同的處理
- 如果有observer,則調用__CFRunLoopDoObservers
- 如果有block,則調用__CFRunLoopDoBlocks
- 如果有timer,則調用__CFRunLoopDoTimers
- 如果是source0,則調用__CFRunLoopDoSources0
- 如果是source1,則調用__CFRunLoopDoSource1
_ _CFRunLoopDoTimers
檢視下__CFRunLoopDoTimers源碼
主要是通過for循環,對單個timer進行處理,下面進入__CFRunLoopDoTimer源碼
通過源碼可知:主要邏輯就是timer執行完畢後,會主動調用__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__函數,正好與timer堆棧調用中的一緻
Timer總結
- 1.為自定義的timer,設定Mode,并将其加入RunLoop中
- 2.在RunLoop的run方法執行時,會調用__CFRunLoopDoTimers執行所有timer
- 3.在__CFRunLoopDoTimers方法中,會通過for循環執行單個timer的操作
- 4.在__CFRunLoopDoTimer方法中,timer執行完畢後,會執行對應的timer回調函數
以上,是針對timer的執行分析,對于observer、block、source0、source1,其執行原理與timer是類似的,這裡就不再重複說明以下是蘋果官方文檔針對RunLoop處理不同源的圖示
RunLoop底層原理
從上述的堆棧資訊中可以看出,run在底層的實作路徑為CFRunLoopRun -> CFRunLoopRun -> __CFRunLoopRun進入CFRunLoopRun源碼,其中傳入的參數1.0e10(科學計數)等于1* e^10,用于表示逾時時間
進入CFRunLoopRunSpecific源碼:
- 首先根據modeName找到對應的mode,然後主要分為三種情況:
- 如果是entry,則通知observer,即将進入runloop
- 如果是exit,則通過observer,即将退出runloop
- 如果是其他中間狀态,主要是通過runloop處理各種源
上面說到會調用__CFRunLoopRun,上面講了這裡面會根據不同的事件源進行不同的處理,當RunLoop休眠時,可以通過相應的事件喚醒RunLoop。
綜上所述,RunLoop的執行流程
AutoreleasePool相關面試題
有關面試圖
臨時變量什麼時候釋放?
- 1.如果在正常情況下,一般是超出其作用域就會立即釋放
- 2.如果将臨時變量加入了自動釋放池,會延遲釋放,即在runloop休眠或者autoreleasepool作用域之後釋放
AutoreleasePool原理
- 1.自動釋放池的本質是一個AutoreleasePoolPage結構體對象,是一個棧結構存儲的頁,每一個AutoreleasePoolPage都是以雙向連結清單的形式連接配接
- 2.自動釋放池的壓棧和出棧主要是通過結構體的構造函數和析構函數調用底層的objc_autoreleasePoolPudh和objc_autoreleasePoolPop,實際上是調用AutoreleasePoolPage的push和pop兩個方法
- 3.每次調用push操作其實就是建立一個新的AutoreleasePoolPage,而AutoreleasePoolPage的具體操作就是插入一個POOL_BOUNDARY,并傳回插入POOL_BOUNDARY的記憶體位址。而push内部調用autoreleaseFast方法處理,主要有以下三種情況
- 當page存在,且不滿時,調用add方法将對象添加至page的next指針處,并next遞增
- 當page存在,且已滿時,調用autoreleaseFullPage初始化一個新的page,然後調用add方法将對象添加至page棧中
- 當page不存在時,調用autoreleaseNoPage建立一個hotPage,然後調用add方法将對象添加至page棧中
- 4.當執行pop操作時,會傳入一個值,這個值就是push操作的傳回值,即POOL_BOUNDARY的記憶體位址token。是以pop内部的實作就是根據token找到哨兵對象所處的page中,然後使用 objc_release釋放token之前的對象,并把next指針到正确位置
AutoreleasePool能否嵌套使用?
- 1.可以嵌套使用,其目的是可以控制應用程式的記憶體峰值,使其不要太高
- 2.可以嵌套的原因是因為自動釋放池是以棧為節點,通過雙向連結清單的形式連接配接的,且是和線程一一對應的
- 3.自動釋放池的多層嵌套其實就是不停的pushs哨兵對象,在pop時,會先釋放裡面的,在釋放外面的
哪些對象可以加入AutoreleasePool?alloc建立可以嗎?
- 1.在MRC下使用new、alloc、copy關鍵字生成的對象和retain了的對象需要手動釋放,不會被添加到自動釋放池中
- 2.在MRC下設定為autorelease的對象不需要手動釋放,會直接進入自動釋放池
- 3.所有autorelease的對象,在出了作用域之後,會被自動添加到最近建立的自動釋放池中
- 4.在ARC下隻需要關注引用計數,因為建立都是在主線程進行的,系統會自動為主線程建立AutoreleasePool,是以建立會自動放入自動釋放池
AutoreleasePool的釋放時機是什麼時候?
- 1.App啟動後,蘋果在主線程RunLoop裡注冊了兩個Observer,其回調都是_wrapRunLoopWithAutoreleasePoolHandler()。
- 2.第一個Observer監視的事件是Entry(即将進入 Loop),其回調内會調用 _objc_autoreleasePoolPush() 建立自動釋放池。其order是-2147483647,優先級最高,保證建立釋放池發生在其他所有回調之前。
- 3.第二個Observer監視了兩個事件:BeforeWaiting(準備進入休眠) 時調用 _objc_autoreleasePoolPop()和_objc_autoreleasePoolPush()釋放舊的池并建立新池;Exit(即 将退出Loop)時調用_objc_autoreleasePoolPop()來釋放自動釋放池。這個Observer的order是 2147483647,優先級最低,保證其釋放池子發生在其他所有回調之後。
thread和AutoreleasePool的關系
在官方文檔中,找到如下說明
了解如下:
- 1.每個線程,包括主線程在内都維護了自己的自動釋放池堆棧結構
- 2.新的自動釋放池在被建立時,會被添加到棧頂;當自動釋放池銷毀時,會從棧中移除
- 3.對于目前線程來說,會将自動釋放的對象放入自動釋放池的棧頂;線上程停止時,會自動釋放掉與該線程關聯的所有自動釋放池
總結:每個線程都有與之關聯的自動釋放池堆棧結構,新的pool在建立時會被壓棧到棧頂,pool銷毀時,會被出棧,對于目前線程來說,釋放對象會被壓棧到棧頂,線程停止時,會自動釋放與之關聯的自動釋放池
RunLoop和AutoreleasePool的關系
- 1.主程式的RunLoop在每次事件循環之前,會自動建立一個autoreleasePool
- 2.并且會在事件循環結束時,執行drain操作,釋放其中的對象
RunLoop相關面試題
面試題一
- 1.每個線程都有一個與之對應的RunLoop,是以RunLoop與線程是一一對應的,其綁定關系通過一個全局的DIctionary存儲,線程為key,runloop為value。
- 2.線程中的RunLoop主要是用來管理線程的,當線程的RunLoop開啟後,會在執行完任務後進行休眠狀态,當有事件觸發喚醒時,又開始工作,即有活時幹活,沒活就休息
- 3.主線程的RunLoop是預設開啟的,在程式啟動之後,會一直運作,不會退出
- 4.其他線程的RunLoop預設是不開啟的,如果需要,則手動開啟
NSRunLoop和CFRunLoopRef差別
- 1.NSRunLoop是基于CFRunLoopRef面向對象的API,是不安全的
- 2.CFRunLoopRef是基于C語言,是線程安全的
Runloop的mode作用是什麼?
- mode主要是用于指定RunLoop中事件優先級的
面試題五
- 1.timer停止的原因是因為滑動scrollView時,主線程的RunLoop會從NSDefaultRunLoopMode切換到UITrackingRunLoopMode,而timer是添加在NSDefaultRunLoopMode。是以timer不會執行
- 2.将timer放入NSRunLoopCommonModes中執行
寫到最後