上個月,我們分析了引用計數、複製、標記-清除和標記-整理這些經典的垃 圾收集技術。其中每一種方法在特定條件下都有其優點和缺點。例如,當有很多 對象成為垃圾時,複製可以做得很好,但是有許多長壽對象時它就變得很糟(要 反覆複製它們)。相反,標記-整理對於長壽對象可以做得很好(只複製一次) ,但是當有許多短壽對象時就沒有那麼好了。JVM 1.2 及以後版本使用的技術稱 為 分代垃圾收集(generational garbage collection),它結合了這兩種技術 以結合二者的長處,結果就是對象分配開銷非常小。
老對象和年輕對象
在任何一個應用程式堆中,一些對象在建立後很快就成為垃圾,另一些 則在程式的整個運行期間一直保持生存。經驗分析表明,對於大多數物件導向的 語言,包括 Java 語言,絕大多數對象――可以多達 98%(這取決於您對年輕對 象的衡量標準)是在年輕的時候死亡的。可以用時鐘秒數、對象分配以後�h記憶體管理子系統分配的總位元組或者對象分配後經曆的垃圾收集的次數 來計算對象的壽命。但是不管您如何計量,分析表明了同一件事――大多數對象 是在年輕的時候死亡的。大多數對象在年輕時死亡這一事實對於收集器的選擇很 有意義。特別是,當大多數對象在年輕時死亡時,複製收集器可以執行得相當好 ,因為複製收集器完全不訪問死亡的對象,它們只是將活的對象複製到另一個堆 地區中,然後一次性收回所有的剩餘空間。
那些經曆過第一次垃圾收集 後仍能生存的對象,很大部分會成為長壽的或者永久的對象。根據短壽對象和長 壽對象的混合比例,不同垃圾收集策略的效能會有非常大的差別。當大多數對象 在年輕時死亡時,複製收集器可以工作得很好,因為年輕時死亡的對象永遠不需 要複製。不過,複製收集器處理長壽對象卻很糟糕,它要從一個半空間向另一個 半空間反覆來回複製這些對象。相反,標記-整理收集器對於長壽對象可以工作 得很好,因為長壽對象趨向於沉在堆的底部,從而不用再複製。不過,標記-清 除和標記-理整收集器要做很多額外的分析死亡對象的工作,因為在清除階段它 們必須分析堆中的每一個對象。
分代收集
分代收集器(generializational collector)將堆分為多個代。在年輕的代中 建立對象,滿足某些提升標準的對象,如經曆了特定次數垃圾收集的對象,將被 提升到下一更老的代。分代收集器對不同的代可以自由使用不同的收集策略,對 各代分別進行垃圾收集。
小的收集
分代收集的一個優點是它不同時收集所有的代,因此可以使垃圾收集暫停更 短。當分配器不能滿足分配請求時,它首先觸發一個 小的收集(minor collection),它只收集最年輕的代。因為年輕代中的許多個物件已經死亡,複製 收集器完全不用分析死亡的對象,所以小的收集的暫停可以相當短並通常可以回 收大量的堆空間。如果小的收集釋放了足夠的堆空間,那麼使用者程式就可以立即 恢複。如果它不能釋放足夠的堆空間,那麼它就繼續收集上一代,直到回收了足 夠的記憶體。(在垃圾收集器進行了全部收集以後仍不能回收足夠的記憶體時,它將 擴充堆或者拋出 OutOfMemoryError )。
代間引用
跟蹤垃圾收集器,如複製、標記-清除和標記-整理等垃圾收集器,都是從根集 (root set)開始掃描,遍曆對象間的引用,直到訪問了所有活的對象。
分代跟蹤收集器從根集開始,但是並不遍曆指向更老一代中對象的引用,這 減少了要跟蹤的對象圖的大小。但是這也帶來一個問題――如果更老一代中的對 象引用一個不能通過從根開始的所有其他引用鏈到達的更年輕的對象該怎麼辦?
為瞭解決這個問題,分代收集器必須顯式地跟蹤從老對象到年輕對象的引用 並將這些老到年輕的引用加入到小的收集的根集中。有兩種建立從老對象到年輕 對象的引用的方法。要麼是將老對象中包含的引用修改為指向年輕對象,要麼是 將引用其他年輕對象的年輕對象提升為更老的一代。
跟蹤代間引用
不管一個老到年輕的引用是通過提升還是指標修改建立的,垃圾收集器在進 行小的收集時需要有全部老到年輕的引用。做到這一點的一種方法是跟蹤老的代 ,但是這顯然有很大的開銷。更好的一種方法是線性掃描老的代以尋找對年輕對 象的引用。這種方法比跟蹤更快並有更好的地區性(locality),但是仍然有很 大的工作量。
賦值函數(mutator)和垃圾收集器可以共同工作以在建立老到年輕的引用時 維護它們的完整列表。當對象提升為更老一代時,垃圾收集器可以記錄所有由於 這種提升而建立的老到年輕的引用,這樣就只需要跟蹤由指標修改所建立的代間 引用。
垃圾收集器可以有幾種方法跟蹤由於修改現有對象中的引用而產生的老到年 輕的引用。它可以使用在引用計數收集器中維護引用計數的同樣方法(編譯器可 以產生圍繞指標賦值的附加指令)跟蹤它們,也可以在老一代堆上使用虛擬記憶體 保護以捕獲向老對象的寫入。另一種可能更有效虛擬記憶體方法是在老一代堆中 使用頁修改髒位(page modification dirty bit),以確定為找到包含老到年 輕指標的對象時要掃描的塊。
用一點小技巧,就可以避免跟蹤每一個指標修改並檢查它是否跨越代邊界的 開銷。例如,不需要跟蹤針對本地或者靜態變數的儲存,因為它們已經是根集的 一部分了。也可以避免跟蹤儲存在某些建構函式中的指標,這些建構函式只用於 初始化建立對象的欄位(即所謂 初始化儲存(initializing stores)),因為 (幾乎)所有對象都是分配到年輕代中。不管是什麼情況,運行庫都必須維護一 個老對象到年輕對象的引用集並在收集年輕代時將這些引用添加到根集中。
在圖 1 中,箭頭表示堆中對象間的引用。紅色箭頭表示必須添加到根集中供 小的收集使用的老到年輕的引用。藍色箭頭表示從根集或者年輕代到老對象的引 用,在只收集年輕代時不需要跟蹤它們。
圖 1. 代間引用