作者:Mike Ash
譯者:Swift GG 團隊:BigNerdCoding
校對:Yousanflics,numbbbbb,Cee
定稿:CMB
Swift 開源不久我就寫了篇關於弱引用實現的文章。時移勢易,Swift 4 中的弱引用實現已經與舊文不一致了。應 Guillaume Lessard 建議,今天我將會介紹新版實現,並比較其與老版的區別。
舊實現考慮到有些人可能已經忘記了舊實現並且不願重看前面的文章,下面我們就一起簡要的回顧下之前的實現方式。
在舊實現中,Swift 對象有兩個引用計數:強引用計數和弱引用計數。當強引用計數為 0 而弱引用計數不為 0 時,對象會被銷毀,但是內存並不會被立即釋放。內存中會保留弱引用指向的殭屍對象。
在加載弱引用時,運行時會對引用對象進行檢查。如果是殭屍對象,則會對弱引用計數進行遞減操作。一旦弱引用計數為 0,對象內存將會被釋放。換句話說,殭屍對象的所有弱引用被加載訪問後殭屍對象才會真正被清空。
雖然我喜歡該實現的簡單性,但它有一些缺陷。其中一個就是,殭屍對象可能會長時間停留在內存中。對於那些擁有很多實例的類(因為它們包含許多屬性,或使用類似 ManagedBuffer 分配了內聯的額外內存),這會造成嚴重的內存浪費。
另外,在寫完舊文後我還發現:對於並發讀取,該實現是非線程安全的。雖然已經有補丁修復了這個問題,但從相關討論可以看出,開發者希望找到一個更好的實現方式,避免出現類似問題。
對象數據Swift 中的 「對象」 其實是由一組數據構成。
首先,最容易想到的就是源碼中聲明的那些可直接訪問的存儲屬性。
其次就是對象的類信息。該信息主要被用於動態派發和 type(of: ) 內置函數。雖然動態派發和 type(of: ) 內置函數從側面暗示了它的存在,但是實際上該信息大多是被隱藏的。
第三種就是各種引用計數信息。除非你進行一些非常規操作,例如,讀取對象的原始內存或說服編譯器讓你調用 CFGetRetainCount,否則這些信息對你來說是完全透明不可見的。
第四種就是 Objective-C 運行時存儲的輔助信息,例如 Objective-C 弱引用列表(Objective-C 的弱引用實現是通過單獨追蹤每個弱引用)和關聯對象。
那麼這些信息最終都存儲在哪裡呢?
在 Objective-C 中,類信息和存儲屬性(例如,實例變量)內聯在對象內存中。其中類信息位於指針所在第一塊內存,其後才是實例變量。輔助類信息則保存在外部表中。當你需要操作關聯對象時,運行時機制會使用內存地址去一個大的哈希表中查找它。為了實現多線程安全,該表在操作時會加鎖,所以存在一定程度訪問速度問題。引用計數的保存位置,則取決於具體作業系統版本和 CPU 架構,它有時位於對象內存中,而有時又存儲在外部表中。
在 Swift 舊有實現中,類信息,引用計數和存儲屬性全部內聯在對象內存中。而輔助信息則依舊存儲在單獨的外部表中。
下面我們不妨將具體實現代碼先放一邊,仔細思考下:理論上應該如何存儲這些信息呢?
每種存儲方案都有利弊。將數據存儲在對象內存中雖然能提高訪問速度,但是會讓內存空間變得吃緊。與之相對,外部存儲方案則是通過犧牲速度來換空間。
Objective-C 傳統存儲方案不將對象引用計數保存在內存中,部分原因正是基於此。因為在 Objective-C 引入引用計數概念時,設備的性能遠不如現在,而且內存容量也極為有限。Objective-C 程序中大多數對象只有一個所有者,即引用計數為 1 。此時在對象內存中騰出 4 個字節空間存儲該引用計數 1 是很浪費的。而外部表方案中,數值 1 可以通過預設默認值方式表示從而減少內存消耗。
每次進行動態方法派發時都需要對象的類信息,所有作為最常用信息,類信息應該直接保存在內存中,存在外部表中是不合適的。
而實例變量這類存儲屬性在編譯期就確定了,而且有現實的訪問速度需求,所以存在對象內存中也是最合理的設計。另外,當對象沒有存儲屬性時,系統不會為其分配內存空間也就不存在浪費問題。
每個對象都需要保留引用計數。雖然不是每個對象的引用計數都為 1,但它依舊是一個相對常見的情形,加上現在內存足夠,它可以直接保存在內存中。
大多數對象都不會有弱引用或關聯對象數據,所有它們應該保存在外部以期節約內存空間。
對於那些有弱引用或關聯對象數據的對象來說,訪問速度確實不夠快但這是合理的權衡結果。那麼問題來了,該舊實現有沒有改進空間和可行方法呢?
Side Tables在 Swift 弱引用的新版實現代碼中,引入了 side tables 概念來改進上訴缺陷。
Side table 本質就是用於保存額外信息的單獨內存塊,並且它還是可選的。也就是說,對於那些無需保存額外信息的對象來說並沒有多餘開銷。
每個對象都有一個指向其對應 side table 的指針,而 side table 也有一個指針指向該對象。另外,side table 可以存儲關聯的對象數據等其他信息。
為了避免 side table 帶來的 8 字節空間開銷,Swift 做了一個漂亮的優化。通常內存中的第一個字(Word)是類信息,第二個字則是引用計數。當對象存在 side table 需求時,第二個字將保存指向 side table 的指針。因為引用計數是必要信息,所以此時會將引用計數保存到 side table 中。至於程序運行時到底是哪種情形,則由該塊內存中的一個標誌位進行區分。
通過將弱引用從指向對象本身改為指向 side table ,Swift 得以在保留原有引用計數設計的同時修復了舊設計中的缺陷。
因為 side table 比較小並且弱引用不再指向對象本身,這樣之前大型殭屍對象的內存空間將能立即釋放從而降低了內存浪費。同時該實現也讓線程安全問題變得更易解決:不再需要提前將弱引用置空。因為 side table 比較小,指向它的弱引用可以持續保留,直到這些引用自身被覆寫或銷毀。
這裡需要提醒一下,當前 side table 實現中只保存引用計數和指向原始對象的指針。類似保存關聯對象等用途只是一個猜想和假設。因為 Swift 還沒有內建關聯對象功能,而 Objective-C API 仍在使用全局表。
該技術還有不少潛力可挖,也許在不久的將來能看到其應用在關聯對象等內容上。我希望它能為類拓展中的存儲屬性和其他有趣的功能打開一扇新窗。
代碼因為 Swift 已經開源,所有相關代碼都能直接訪問。
關於 side table 的大部分代碼都在 stdlib/public/SwiftShims/RefCount.h。
高層級的弱引用 API 以及相關注釋都在 swift/stdlib/public/runtime/WeakReference.h。
更多關於堆對象的實現和注釋在 stdlib/public/runtime/HeapObject.cpp。
上述連結其實帶著版本信息,以便後面的讀者也能找到本文內容當時的上下文。如果你想看最新的實現代碼,你在點擊連結後切換到 master 分支即可。
總結弱引用是一個重要的語言特性。Swift 最初的實現方式非常聰明,也有一些不錯的特性,但是同時也存在一些問題。通過引入 side table,Swift 開發工程師在保留原有特點的同時還解決了這些缺陷。Side table 的實現也為將來更多新特性創造了更多可能性。
今天內容到此為止。下次我還會帶來與編程和代碼相關的新內容。當然你也可以將你感興趣的話題發送給我。
本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 http://swift.gg。
推薦閱讀
Swift 4.2 新特性預覽