iOS 中的引用計數
最近面試,遇到引用計數的問題
並且由引用計數引出來的關聯問題,在這説明一下
1、引用計數是什麼
通常情況下,某一塊地址有多少個指針指向了它,那麼這個多少就是引用計數的值。是iOS 使用自動引用計數來管理內存。
2、引用計數的存儲
總的來説,引用計數的存儲位置可以分為三種情況,其目標是在保證正確性的前提下,最大限度地優化性能和內存使用
引用計數(retain count)的存儲位置取決於對象所處的狀態,主要有以下三種方式:
**1. 存儲在對象的 isa指針的額外比特位中(優化情況)
存儲在 isa指針中(非指針型 isa/ Tagged Pointer)
這是蘋果進行的最重要的優化之一,目的是為了在多數常見情況下,不產生額外的存儲開銷。
a) 非指針型 isa
在 64 位系統後,一個指針地址是 64 位(8字節),但實際尋址並不需要全部 64 位。蘋果利用了這些多餘的比特位來存儲信息,包括引用計數。
原理:對象的 isa指針不再直接指向類對象的內存地址,而是一個包含了類對象地址和對象狀態信息的位域。
存儲內容:isa結構中的 extra_rc字段(例如 19 個比特位)用於存儲額外的引用計數。
當一個對象的引用計數為 1 時(即剛創建時),extra_rc的值為 0。
當有新的強引用持有該對象時,retain操作會嘗試先給 extra_rc加 1。
只要 extra_rc沒有溢出(即引用計數不太大),所有的引用計數操作都直接在這個 isa指針內完成,速度極快,且沒有額外的內存訪問。
b) Tagged Pointer(特殊情況)
對於某些小對象(如短字符串 NSString、小數字 NSNumber等),蘋果使用了 Tagged Pointer 技術。此時,對象的值直接存儲在其指針值中,它根本不是堆上的一個真正對象。
特點:對於 Tagged Pointer,不存在引用計數的概念。retain和 release操作都是空操作,因為它的“內存管理”就是簡單的指針賦值和銷燬,效率極高。
2. 存儲在對象的 Side Table中(溢出情況)
當存儲在 isa中的引用計數不夠用時,系統會使用 Side Table。
何時觸發:當對象的引用計數持續增加,導致 isa中的 extra_rc字段被塞滿(溢出)時。
工作原理:
一半一半策略:當 extra_rc快滿時,retain操作會將 extra_rc的大約一半值轉移到一個全局的 SideTables中。
SideTables是一個哈希表,根據對象的地址可以找到對應的 Side Table。
每個 Side Table中有一個 RefcountMap(引用計數表),它以對象地址為 key,存儲其額外的引用計數。
此時,isa中的 extra_rc會保留剩餘的一半計數值,並設置一個標誌位 has_sidetable_rc為 1,表示此對象有部分引用計數存儲在 Side Table中。
操作:後續的 retain/release操作會先嚐試修改 isa.extra_rc,如果不夠,再去操作 Side Table中的值。
為什麼這樣設計?
這是一種緩存思想。將最常用的、較小的引用計數放在訪問速度最快的 isa指針中(相當於 L1 緩存),將不常用的、較大的計數部分放在訪問稍慢的 Side Table中(相當於內存)。這保證了在絕大多數情況下(對象的引用數不多)性能最優。
3.兩者結合使用(現代運行時的主流方式)
對於一個普通的 Objective-C 對象,其引用計數的存儲是分級和混合的:
創建時:引用計數為 1,存儲在 isa.extra_rc中(值為 0,表示實際計數是 extra_rc + 1)。
頻繁引用時:retain操作優先增加 isa.extra_rc。
計數溢出時:將 isa.extra_rc的一部分轉移到 Side Table中,isa只保留一部分。後續操作會同時檢查兩者。
釋放時:release操作先減少 isa.extra_rc,如果它為 0 且 Side Table中有值,則再從 Side Table中借一些計數填回 isa.extra_rc。當所有計數歸零時,對象被銷燬。
3.為什麼對象的isa.extra_rc 中會溢出,該怎麼理解這種溢出
假設 isa.extra_rc字段的比特位數為 8 位。這意味着它能存儲的最大無符號整數值是 2^8 - 1 = 255。
根據蘋果的優化策略,當 extra_rc快滿時(比如達到 255 的一半,即 127 左右),系統會進行“分半”處理,而不是等到完全溢出(255)才處理,以防止溢出錯誤。
場景:一個被大量強引用的單例對象或管理器對象
假設我們有一個 NetworkManager的單例對象,它在 App 啓動時被創建。然後,很多個網絡請求模塊(比如 200 個 RequestHandler對象)都需要強引用這個管理器來發送請求。
第一步:對象創建
操作:NetworkManager *manager = [[NetworkManager alloc] init];
引用計數:1
存儲方式:因為是初始狀態,引用計數為 1。在優化實現中,isa.extra_rc的實際值被設為 0,因為真正的引用計數是 isa.extra_rc + 1。這樣設計可以多存一個計數。
isa.extra_rc= 0
isa.has_sidetable_rc= 0 (false,表示未使用 Side Table)
第二步:前 127 次 retain(假設沒有 release)
操作:200 個 RequestHandler對象開始創建,並強引用 manager。我們執行了 127 次 retain操作。
引用計數變化:從 1 增加到 1 + 127 = 128
存儲方式:所有的 retain操作都只是簡單地增加 isa.extra_rc的值。
isa.extra_rc= 127 (因為實際計數是 127 + 1 = 128)
isa.has_sidetable_rc= 0
此時狀態:引用計數完全存儲在 isa指針中,速度極快。
第三步:第 128 次 retain- 觸發溢出處理
這是最關鍵的一步。當系統發現 extra_rc的值已經比較大(比如達到了閾值 127,即 255 的一半),為了給後續的 retain留出空間,它會主動進行“分半”處理,將一部分計數轉移到 Side Table。
操作:第 128 個 RequestHandler強引用 manager,觸發第 128 次 retain。
處理流程:
a. 準備轉移:系統決定將 isa.extra_rc中的大約一半(比如 128 的一半,64)轉移出去。
b. 操作 Side Table:
在全局的 SideTables中,根據 manager對象的內存地址找到對應的 Side Table和它的 RefcountMap。
在 RefcountMap中為 manager創建一個條目,並將其引用計數值設置為 64。
c. 更新 isa:
將 isa.extra_rc的值更新為 128 - 64 - 1 = 63。(解釋:原來的 128 次計數,減去移出去的 64,再減去對象本身佔用的 1,剩下 63 存在 extra_rc中)。
將 isa.has_sidetable_rc標誌位設置為 1,告訴運行時:“這個對象的部分引用計數在 Side Table 裏,以後操作要注意。”
最終存儲狀態(第 128 次 retain 後):
總引用計數 = 1 (對象本身) + isa.extra_rc(63) + Side Table(64) = 128。結果正確。
isa.extra_rc= 63
isa.has_sidetable_rc= 1
Side Table RefcountMap中 key(manager)對應的 value= 64
第四步:後續的 retain操作(第 129 次到第 200 次)
現在對象處於混合存儲模式。
操作:繼續創建 RequestHandler,執行第 129 次到第 200 次 retain(共 72 次)。
處理流程:每次 retain,系統會優先嚐試增加 isa.extra_rc。
假設 isa.extra_rc從 63 開始增加,它最多能增加到 255。所以這 72 次 retain可以完全由 isa.extra_rc吸收。
最終存儲狀態(第 200 次 retain 後):
isa.extra_rc= 63 + 72 = 135
isa.has_sidetable_rc= 1
Side Table中的值保持不變,仍然是 64
總引用計數 = 1 + 135 + 64 = 200。結果正確。
相反的過程:release
當 RequestHandler們開始釋放時:
前 135 次 release會先減少 isa.extra_rc,從 135 減到 0。這個過程很快。
當 isa.extra_rc減為 0,但 has_sidetable_rc為 1 時,系統知道 Side Table 裏還有計數。
隨後的 release操作會從 Side Table 中“借回”一部分計數到 isa.extra_rc中,然後再減少它。例如,系統可能會從 Side Table 的 64 中轉移 50 到 isa.extra_rc,然後開始減少這 50。
如此循環,直到 Side Table 和 isa.extra_rc中的計數都歸零,對象被正確銷燬。