大部分開發者都認為自動垃圾回收器是理所當然的。實際上,這只是語言執行時提供的一項實用功能,旨在簡化我們的開發工作。
但是如果嘗試著瞭解垃圾回收器的內部原理,你會發現很難弄明白。除非熟悉它的工作流程和錯誤處理方式,否則內部成千上萬的實現細節會讓你不知所措。
我編譯了一個有五種不同的垃圾回收演算法工具。程式執行時會建立一個動畫介面。你可以從github.com/kenfox/gc-viz上獲取動畫和程式碼來實現。非常讓我驚訝的是,這個簡單的動畫顯現出這些重要的演算法。
任務完成後清理: aka No GC
清理垃圾最簡單可行的方法就是等一項任務完成之後,一次性處理所有的垃圾。這項技術非常有用,特別是如果能將一項任務分解成許多小任務。例如,Apache網路伺服器在每次請求時建立一個小記憶體池並在請求完成後將建立的整個記憶體池完全釋放。
右圖的動畫顯示了一個正在執行的程式。整張圖片代表程式的記憶體區。記憶體區在開始時是黑色,黑色表明記憶體尚未被使用。閃著鮮綠色和黃色的區域表明該記憶體區域正在讀寫。顏色隨著時間變化,你可以觀察記憶體的使用情況,也可以看到當前的活動情況。如果仔細觀察,你會發現記憶體區域中開始出現一些程式執行過程中會忽略的區域。這些區域就成了所謂的垃圾——程式不能訪問和使用。垃圾區域之外的的記憶體區域是可用的。
該程式的記憶體充足,所以不必擔心程式執行時垃圾的清理。在後面的例子中我將一直使用這個簡單的程式。
引用計數回收器
另一個簡單的解決方案是對你使用的資源(此處指記憶體中的物件)進行計數,當計數值變為0時,對其進行處理。這是一項廣泛使用的技術,當開發者將垃圾回收新增到現有系統中時——這是唯一一個容易與其他資源管理器和現有程式碼庫整合的垃圾回收器。蘋果在為Objective-C釋出了標誌-擦除垃圾回收器後明白這個事實。釋出產品出現很多問題以致於他們不得不廢棄該項特性,取而代之的是效能良好的自動引用計數回收器。
上面的動畫顯示了相同的程式,但是此時它將通過對記憶體中每一物件引用計數來處理垃圾。紅色閃爍表示引用計數行為。引用計數的優勢在於垃圾會被很快檢測到——你可以看到紅色閃爍過後緊接著該區域變黑。
遺憾的是引用計數存在諸多問題。最糟糕的是,它不能處理迴圈結構。而迴圈結構非常常見——繼承或反向引用都將建立一個迴圈,該結構將造成記憶體洩露。引用計數的開銷也很大 ——從動畫中可以看到即使當記憶體使用不在增長時,紅色閃爍一直持續。CPU運算速度很快,但記憶體讀寫很慢,而計數器不斷被載入並儲存至記憶體。所有這些計數器的更新很難保證資料的只讀或執行緒安全。
引用計數是一種分攤演算法(開銷遍佈整個程式執行時),但這是種分攤演算法具有偶然性,不能保證反應時間。例如,程式中存在一個很大的樹型結構。最後一段使用樹的程式將觸發對整個樹的處理,墨菲說過事情如果有變壞的可能,不管這種可能性有多小,它總會發生。這裡沒有其他的分攤演算法,所以分攤的偶然特徵可能取決於資料。(所有這些演算法有併發或部分併發的命令,但這些都是超出了程式可演示的範圍。)
標記-擦除回收器
標記-擦除消除了引用計數存在的一些問題。它能夠輕鬆解決迴圈結構在引用技術中存在的問題,由於不需維持計數,系統開銷比較低。
該演算法捨棄垃圾檢測的實時性。動畫中,有一段執行時間沒有任何紅色的閃爍,然後突然出現許多紅色閃爍表明當前正在標記活動物件。在標記完成後,程式要遍歷整個記憶體空間並處理垃圾。在動畫中你還將注意到—— 許多區域立刻變黑而不像引用計數方式那樣隨著時間慢慢變黑。
標記-擦除比引用計數要求更高的一致性實現,而且很難移植到現有系統中。在標記階段需要遍歷所有活動資料,甚至是封裝在對像中的資料。如果一個物件不支援遍歷,那麼嘗試將標記-擦除移植到程式碼中風險太大。標記-擦除的另一個不足之處在於擦除階段必須遍歷整個記憶體來查詢垃圾。對於一個產生垃圾較少的系統,這不是問題,但現在的函數語言程式設計風格產生了大量的垃圾。
標記-壓縮回收器
在前面的動畫中你可能注意到一點,物件從不移動。一旦物件在記憶體中分配,該物件的儲存位置就不會再改變,即使被散步在黑色區域的記憶體碎片包圍。下面兩種演算法用完全不同的方式改變了這種現象。
標記-壓縮演算法不是僅通過標記記憶體區域是否空閒來處理記憶體,而是通過將物件移動到空閒表來實現。物件通常按照記憶體順序儲存,先分配的物件在記憶體的低地址空間——但是處理物件造成的空缺將隨著物件的移動變大。
移動物件意味著新物件只能在已使用記憶體的末尾建立。這就是所謂的“bunp”分配器,和棧分配器一樣,但不限制棧空間。有些使用bump分配器的系統甚至不用呼叫棧儲存資料,他們只在堆中分配呼叫幀,像其他物件一樣對待。
有時理論高於實踐,另一個優勢是當物件被壓縮後,程式能夠像訪問硬體快取記憶體一樣訪問記憶體。不確定你能否看到這個好處——儘管引用計數和標記-擦除使用的記憶體分配器很複雜,但除錯效果很好,效率也很高。
標記-壓縮是演算法很複雜,需要多次遍歷所有分配物件。在動畫中可以看到緊隨紅色閃爍的活動物件其後的是大量讀和寫標記為目的地計算,物件被移動,最終引用固定指向移動後的物件。這個複雜程式背後最大的優點是記憶體開銷非常小。Oracle的Hotspot JVM使用了多種不同垃圾回收演算法。而全域性物件空間使用標記-壓縮回收演算法。
拷貝回收器
最後使用動畫顯示的演算法是大多數高效能垃圾收集系統的基礎。它和標記-壓縮是一樣的移動回收器,但是相比之下實現卻非常簡單。它使用兩塊記憶體空間,在兩個記憶體間交替複製活動物件。實際上,空間不止兩塊,這些空間用於不同代物件,新的物件在一個空間中建立,如果生命週期沒有結束就會被複制到另一個空間,如果長期存在就會被複制到一個永久性空間。如果你聽說一個垃圾收集器是分代的或短暫的,通常是多空間拷貝回收器。
除了簡單性和靈活性,該演算法的主要優勢在於只要在活動物件上花時間。沒有獨立的標記階段必須被擦除或壓縮。在遍歷活動物件期間,物件會被立即複製,彌補了以往物件在引用計數時的不足。
在動畫中,你可以看到回收過程中乎所有的資料從一個空間複製到另一個空間。對該演算法來說是個糟糕的情況,這是人們談論優化垃圾收集器的一個原因。如果你能調整記憶體並有優化分配,使得在回收開始前大部分物件都廢棄了,那麼你就能兼顧安全函數語言程式設計風格和高效能。
(注:限於譯者水平有限,不足之處懇請指正。)