程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> Java理論與實踐: JVM 1.4.1中的垃圾收集

Java理論與實踐: JVM 1.4.1中的垃圾收集

編輯:關於JAVA

上個月,我們分析了引用計數、復制、標記-清除和標記-整理這些經典的垃 圾收集技術。其中每一種方法在特定條件下都有其優點和缺點。例如,當有很多 對象成為垃圾時,復制可以做得很好,但是有許多長壽對象時它就變得很糟(要 反復復制它們)。相反,標記-整理對於長壽對象可以做得很好(只復制一次) ,但是當有許多短壽對象時就沒有那麼好了。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. 代間引用

卡片標記

Sun JDK 使用一種稱為 卡片標記(card marking)算法的改進算法以標識對 老一代對象的字段中包含的指針的修改。在這種方法中,堆分為一組 卡片,每 個卡片一般都小於一個內存頁。JVM 維護著一個卡片映射,對應於堆中的每一個 卡片都有一個位(在某些實現中是一個字節)。每次修改堆中對象中的指針字段 時,就在卡片映射中設置對應那張卡片的相應位。在垃圾收集時,就對與老一代 中卡片相關聯的標記位進行檢查,對髒的卡片掃描以尋找對年輕代有引用的對象 。然後清除標記位。卡片標記有幾項開銷――卡片映射所需的額外空間、對每一 個指針存儲所做的額外工作,以及在垃圾收集時做的額外工作。對每一個非初始 化堆指針存儲,卡片標記算法可以只增加兩到三個機器指令,並要求在小的收集 時對所有髒卡片上的對象進行掃描。

JDK 1.4.1 默認收集器

在默認情況下,JDK 1.4.1 將堆分為兩部分,一個年輕的代和一個老的代( 實際上,還有第三部分――永久空間,它用於存儲裝載的類和方法對象)。借助 於復制收集器,年輕的代又分為一個創建空間(通常稱為 Eden)和兩個生存半 空間。

老的代使用標記-整理收集器。對象在經歷了幾次復制後提升到老的代。小的 收集將活的對象從 Eden 和一個生存半空間復制到另一個生存半空間,並可能提 升一些對象到老的代。大的收集(major collection)既會收集年輕的代,也會 收集老的代。System.gc() 方法總是觸發一個大的收集,這就是應該盡量少用( 如果不能完全不用的話) System.gc() 的原因之一,因為大的收集要比小的收 集花費長得多的時間。沒有辦法以編程方式觸發小的收集。

其他收集選項

除了默認情況下使用的復制收集器和標記-整理收集器,JDK 1.4.1 還包含其 他四種垃圾收集算法,每一種適用於不同的目的。JDK 1.4.1 包含一個增量收集 器(自 JDK 1.2 就已經出現了)和三種在多處理器系統中進行更有效收集的新 收集器――並行復制收集器、並行清除(scavenging)收集器和並發標記-清除 收集器。這些新收集器是為了解決在多處理器系統中垃圾收集器成為伸縮性瓶頸 這一問題的。圖 2 顯示了在什麼時候選擇備用收集選項的指導。

圖 2. 1.4.1 垃圾收集選項(Folgmann IT-Consulting 提供)

增量收集

增量收集選項自 1.2 起就成為 JDK 的一部分。增量收集減少了垃圾收集暫 停,以犧牲吞吐能力為代價,這使它只在更短的收集暫停非常重要時才值得考慮 ,如接近實時的系統。

Train算法是 JDK 用於增量收集的算法,它在堆中老的代和年輕的代之間創 建一個新區域。這些堆區域劃分為“火車(train)”,每個火車又分為一系列 的“車廂(car)”。每個車廂可以分別收集。結果,每個火車車廂組成單獨的 一代,這意味著不但要跟蹤老到年輕的引用,而且還要跟蹤從老的火車到年輕的 火車以及老的車廂到年輕的車廂的引用。這為賦值函數(mutator)和垃圾收集 器帶來了大量的額外工作,但是可以得到更短的收集暫停。

並行收集器和並發收集器

JDK 1.4.1 中新的收集器都是為解決多處理器系統中垃圾收集器的問題而設 計的。因為大多數垃圾收集算法會在一段時間裡使系統停止,單線程的收集器很 快會成為伸縮性瓶頸,因為在垃圾收集器將用戶程序線程掛起時,除了一個處理 器之外,其他的處理器都是空閒的。新收集器中的兩個――並行復制收集器和並 發標記-清除收集器――設計為減少收集暫停時間。另一個是並行清除收集器, 它是為在大堆上的更高吞吐能力而設計的。

並行復制收集器用 JVM 選項 -XX:+UseParNewGC 啟用,是一個年輕代復制收 集器,它將垃圾收集的工作分為與 CPU 數量一樣多的線程。並發標記-清除收集 器由 -XX:+UseConcMarkSweepGC 選項啟用,它是一個老代標記-清除收集器,它 在初始標記階段(及在以後暫短重新標記階段)暫短地停止整個系統,然後恢復 用戶程序,同時垃圾收集器線程與用戶程序並發地執行。並行復制收集器和並發 標記-清除收集器基本上是默認的復制收集器和標記-整理收集器的並發版本。由 -XX:+UseParallelGC 啟用的並行清除收集器是年輕代收集器,針對多處理器系 統上非常大(吉字節以及更大的)堆進行了優化。

選擇一種算法

有六種算法可以選擇,您可能不知道要使用哪一種。圖 2提供了一些指導, 將收集器分為單線程和並發的,以及分為短暫停和高吞吐能力的。只要您掌握了 應用程序和部署環境的信息,就足以選擇合適的算法。對於許多應用程序,默認 的收集器可以工作得很好――因此如果您沒有性能問題,那麼就沒必要加入更多 的復雜性。不過,如果您的應用程序是部署在多處理器系統上或者使用非常大的 堆,那麼改變收集器選項可能會有巨大的性能提升。

微調垃圾收集器

JDK 1.4.1 還包括大量的微調垃圾收集的選項。調整這些選項並衡量它們的 效果可能會花費您大量時間,因此在試圖微調垃圾收集器之前先對您的應用程序 進行徹底的配置(profile)和優化,這樣您的微調工作可能會得到更好的結果 。

微調垃圾收集首先要做的是檢查冗長的 GC 輸出。這會使您得到垃圾收集操 作的頻率、定時和持續時間等信息。最簡單的垃圾收集微調就是擴大最大堆的大 小( -Xmx )。隨著堆的增大,復制收集會變得更有效,所以在增大堆時,您就 減少了每個對象的收集成本。除了增加最大堆的大小,還可以用選項 - XX:NewRatio 增加分配給年輕代的空間份額。也可以用 -Xmn 選項顯式指定年輕 代的大小。

結束語

隨著 JVM 的發展,默認垃圾收集器變得越來越好了。JDK 1.2 及以後版本所 使用的分代垃圾收集器提供了比早期 JDK 所使用的標記-清除-整理收集器好得 多的分配和收集性能。JDK 1.4.1 通過增加新的針對多處理器系統和非常大的堆 的多線程收集選項,進一步改進了垃圾收集的效率。

下個月,我們將討論一些有關垃圾收集的性能神話(hints and myths),包 括對象分配的真實成本、顯式賦空的代價和好處以及結束(finalization)的代 價,以此來完成我們對垃圾收集的探討。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved