其動機是在各種情況下(例如在Cassandra郵件列表中)不斷出現與垃圾回收相關的問題。 嘗試提供幫助時的問題是,在針對特定情況定制的郵件列表回復中臨時解釋垃圾收集的細微差別是一項過多的工作,而您幾乎沒有關于這種情況的足夠信息來告訴某人他們的情況特殊問題是由引起的。
我希望本指南將成為我回答這些問題的參考。 我希望它會足夠詳細,以便有用,但易于消化,并且對于廣泛的讀者來說也不夠學術性。
我非常感謝您對我需要澄清,改進,徹底淘汰等方面的任何反饋。
這里的許多信息并非特定于Java。 但是,為了避免不斷調用通用和抽象術語,我將在可能的地方用Hotspot JVM的具體術語進行發言。
為什么有人要關心垃圾收集器?
這是一個好問題。 完美的垃圾收集器可以在沒有人注意到它存在的情況下完成其工作。 不幸的是,沒有已知的完美的垃圾回收算法。 此外,實際上對于大多數人可用的垃圾收集器的選擇還限于實際上實施的垃圾收集算法的子集。 (類似地, malloc
也不是完美的,并且存在其問題,有多種實現方式具有不同的特性。但是,盡管這是一個有趣的話題,但是本文并未嘗試對比自動和顯式內存管理。)
現實情況是,與許多技術問題一樣,需要權衡取舍。 根據經驗,如果您使用的是可免費使用的基于Hotspot的JVM:s(Oracle / Sun,OpenJDK), 那么您最關心的就是垃圾回收器(如果您擔心延遲) 。 如果您不這樣做,那么垃圾回收器將不會很麻煩–除了可能選擇與默認值不同的最大堆大小之外。
所謂等待時間,是指垃圾收集的暫停時間 。 垃圾收集器有時需要暫停應用程序才能完成其某些工作。 這通常被稱為停止這世界的停頓(“世界”是從Java應用程序的GC說話的角度,或突變可觀測宇宙(因為它是變異堆,而垃圾收集器試圖收集重要的是要注意,盡管所有實際可用的垃圾收集器都在應用程序上施加了世界暫停,但這些暫停的頻率和持續時間隨垃圾收集器,垃圾收集器設置和應用程序行為的選擇而變化很大。
就像我們將看到的那樣,存在垃圾收集算法,這些算法試圖避免需要在停頓世界的暫停中收集整個堆。 這是一個重要屬性的原因是,如果在任何時候(即使很少)停止應用程序以完全收集堆,則應用程序所遭受的暫停時間將與堆大小成正比 。 通常,這是您在關心延遲時要避免的主要事情。 也有其他問題,但這通常是一個大問題。
跟蹤與參考計數
您可能聽說過正在使用引用計數 (例如,cPython在大多數垃圾收集工作中都使用了引用計數方案)。 我不會談論太多,因為它與JVM:s無關,只說兩件事:
- 計數垃圾回收的引用具有的一個屬性是,將在刪除最后一個引用時立即知道該對象是不可訪問的。
- 引用計數將不會檢測為不可訪問的循環數據結構,并且還有其他一些問題使其無法成為所有垃圾收集的最終選擇。
JVM而是使用所謂的跟蹤垃圾收集器。 之所以稱為跟蹤,是因為至少在抽象級別上,識別垃圾的過程涉及獲取根集 (例如堆棧上的局部變量或全局變量之類的東西),并跟蹤從那些對象到直接或間接所有對象的路徑。從所述根集合可以間接到達。 一旦確定了所有可到達的(活動的)對象,就可以通過消除過程來標識符合垃圾收集器釋放條件的對象。
基本停止,標記,掃動,恢復
一個非常簡單的跟蹤垃圾收集器使用以下過程工作:
- 完全暫停應用程序。
- 通過跟蹤對象圖(即,遞歸地遵循引用),標記所有可到達的對象(從根集開始,參見上文)。
- 釋放所有無法訪問的對象。
- 恢復應用程序。
在單線程環境中,這很容易想象:負責分配新對象的調用將立即返回新對象,或者,如果堆已滿,則啟動上述過程以釋放空間,然后執行通過完成分配并返回對象。
沒有一個JVM垃圾收集器像這樣工作。 但是,最好理解垃圾收集器的這種基本形式,因為可用的垃圾收集器實質上是上述過程的優化。 JVM不實現這種垃圾回收的兩個主要原因是:
- 每個垃圾回收暫停將足以收集整個堆。 換句話說,它的延遲很差。
- 對于幾乎所有現實應用程序而言,它都不是執行垃圾回收的最有效方法(它具有很高的CPU開銷)。
壓縮與非壓縮垃圾回收
垃圾收集器之間的一個重要區別是它們是否正在壓縮 。 壓縮是指將對象移動(在內存中)以將其收集在一個密集的內存區域中,而不是稀疏地散布在較大的區域中。
真實世界的類比:考慮一個隨機空間中地板上滿是東西的房間。 拿走所有這些東西并將其緊緊塞在角落里實際上就是將它們壓實。 釋放空間。 記住什么是壓實的另一種方法是,設想其中的一臺機器會像汽車一樣將其壓實成一塊金屬,從而消除了空氣所占的全部空間,從而比原來的汽車占用更少的空間(但是有人指出,雖然汽車ID遭到破壞,但堆上的物體卻沒有!)。
相比之下,非緊湊型收集器從不移動對象。 將對象分配到內存中的特定位置后,該對象將一直保留在那里或直到釋放為止。
兩者都有一些有趣的屬性:
- 執行壓縮收集的成本是堆上實時數據量的函數。 如果只有1%的數據處于活動狀態,則僅需要壓縮1%的數據(復制到內存中)。
- 相比之下,在非緊湊型收集器中,不再可訪問的對象仍然意味著記賬,因為它們的存儲位置必須保持釋放狀態,以便將來分配使用。
- 在壓縮收集器中,分配通常是通過“ 碰到指針”方法來完成的。 您有一些空間區域,并保持當前的分配指針。 如果您分配一個n字節的對象,則只需將該指針加n(我就避免了諸如多線程和暗示的優化之類的復雜性)。
- 在一個非壓實集電極,分配涉及找到其中使用一些機構,其依賴于用于跟蹤的空閑存儲器的可用性的確切機制來分配。 為了滿足n字節的分配,必須找到n字節可用空間的連續區域。 如果找不到一個(因為堆是碎片化的 ,這意味著它由可用空間和分配的空間混合在一起),分配將失敗。
現實類比:再次考慮您的房間。 假設您是一個壓縮收集器。 您可以在閑暇時隨意在地板上移動東西。 當您需要為地板中間的那個大沙發騰出空間時,可以四處移動其他東西以騰出適當大小的沙發空間。 另一方面,如果您是一個不緊湊的收藏家,那么地板上的所有東西都會被釘牢,并且無法移動。 盡管您有足夠的可用地板空間,但大沙發可能不適合放置–只有一個單獨的空間不足以容納沙發。
分代垃圾收集
大多數現實世界中的應用程序傾向于對短期對象(即已分配的對象,短暫使用的對象,然后不再引用)執行大量分配。 分代垃圾收集器嘗試利用此觀察結果以提高CPU效率(換句話說,具有更高的吞吐量 )。 (更正式地說,大多數應用程序具有此行為的假設被稱為弱代假設 。)
之所以稱其為“世代”,是因為對象分為幾代 。 收集器之間的細節會有所不同,但此時的合理近似值是將對象分為兩代:
- 年輕的一代是最初分配對象的地方。 換句話說,所有物體都始于年輕一代。
- 老一輩是反對“花錢”的對象,因為他們在年輕一代中度過了一段時間。
代收集者通常更高效的原因是,他們與老一代分開收集年輕一代。 處于穩定狀態下進行分配的應用程序的典型行為是,在收集年輕代時經常出現短暫的停頓–不經常出現,但在老一代填滿并觸發整個堆(舊的和新的)的完整收集時會出現較長的停頓。 如果查看典型應用程序的堆使用情況圖,它將類似于以下內容:
|
吞吐量收集器使用堆的典型鋸齒行為 |
鋸齒狀外觀的出現是年輕一代垃圾收集的結果。 接近尾聲的時候是老一代人變滿了,而JVM對整個堆進行了完整的收集。 該下降結束時的堆使用量是該時間點實際活動集的合理近似值。 (注意:這是針對配置為使用默認JVM吞吐量收集器的Cassandra實例運行壓力測試的圖;它不反映Cassandra的即開即用行為。)
請注意,僅在該圖上的任意時間點選擇“當前堆使用情況” 都不會使您了解應用程序的內存使用情況 。 我不能足夠強調這一點。 通常認為內存“使用”是活動集 ,而不是任何特定時間的堆使用情況。 堆的使用更多取決于垃圾收集器的實現細節。 應用程序的內存使用量對堆使用量的唯一影響是,它為堆使用量提供了一個下限 。
現在,回到為什么代收集者通常更高效的原因。
假設我們的假設應用是所有物體中有90% 早逝 ; 換句話說,它們永遠無法生存到足以被提升為老一代的程度。 此外,假設我們的年輕一代集合實際上是緊湊的(請參閱前面的部分)。 現在收集年輕一代的成本大約是跟蹤和復制它所包含的對象的10%的成本。 剩下的90%的成本很小。 年輕一代的收藏會在充滿時發生,并且是世界停下來的停頓。
幸存的對象的10%可能會立即升級為老一代,或者它們可能在年輕一代中再生存一輪或兩輪(取決于各種因素)。 但是,要理解的重要總體行為是,對象從年輕一代開始,并由于在年輕一代中生存而提升為老一代。
(精明的讀者可能已經注意到,不可能完全分開收集年輕一代–如果舊一代中的對象引用了新一代中的對象該怎么辦?這確實是垃圾收集器必須處理的事情;以后的文章會談論這個。)
優化過程很大程度上取決于年輕一代的規模 。 如果大小太大,則可能太大,以至于與收集它相關的暫停時間是一個明顯的問題。 如果大小太小,則可能甚至死得很年輕的物體也不會足夠快地死去, 以至于它們死后仍處于年輕一代中。
回想一下,年輕的一代是在變得飽滿時收集的; 這意味著它越小,收集它的頻率就越高。 進一步回想一下,當對象在年輕一代中幸存下來時,它們將被提升為老一代。 如果大多數對象(盡管早逝)都不會因為其太小而在年輕一代中死亡-他們將被提升到老一代,而代際垃圾收集器試圖進行的優化將失敗,而您將承擔以后在老一代中收集對象的全部費用(加上從年輕一代中復制對象的前期費用)。
平行收集
擁有分代收集器的目的是為了優化吞吐量 ; 換句話說,應用程序在特定時間內完成的工作總量。 副作用是,由于垃圾收集而引起的大多數暫停也會變得更短。 但是,沒有嘗試消除周期性的完整收集,這意味著完成完整收集所需的暫停時間。
為了減輕這種情況,吞吐量收集器做了一件值得一提的事情:它是并行的 ,這意味著它同時使用多個CPU內核來加速垃圾收集。 確實可以縮短停頓時間,但是您可以走多遠還是有一個限制–即使在線性加速的不現實完美情況下(意味著雙CPU計數->收集時間的一半),您也會受到數量的限制系統上的CPU內核數。 如果要收集30 GB的堆,即使使用16個并行線程,也將花費大量時間。
用垃圾回收的話來說,并行一詞用于表示同時在多個CPU內核上工作的收集器。
增量收集
垃圾回收上下文中的增量是指將需要完成的工作分成較小的塊,通常目的是將應用程序暫停多個短暫的時間而不是一個長時間的暫停。 從年輕的一代收集器構成增量功的意義上講,上述一代收集器的行為是部分增量的。 但是,從總體上看,收集過程不是增量的,因為在舊的一代變滿時會發生全部堆收集。
其他形式的增量收集也是可能的; 例如,對于應用程序執行的每個分配,收集器可以執行少量的垃圾收集工作。 該概念與特定的實施策略無關。
并發收集
垃圾回收上下文中的并發是指與應用程序(變異器) 同時執行垃圾回收工作。 例如,在8核系統上,垃圾收集器可能保留兩個后臺線程,這些線程在應用程序運行時執行垃圾收集工作。 這允許完成大量工作而不會導致應用程序暫停,通常會以一定的吞吐量和實現復雜性為代價(對于垃圾收集器實現者)。
可用的熱點垃圾收集器
Hotspot中垃圾收集器的默認選擇是吞吐量收集器,它是一個世代的并行壓縮收集器。 完全針對吞吐量進行了優化; 在給定時間段內應用程序完成的工作總量。
CMS收集器是解決延遲/暫停時間問題的傳統替代方法。 CMS代表并發標記和掃描 ,是指收集器使用的機制。 收集器的目的是最大程度地減少甚至消除長時間的停頓,將垃圾回收工作限制為較短的停頓(通常是并行)停頓,并與應用程序同時執行更長的工作相結合。 CMS收集器的一個重要屬性是它不緊湊,因此存在碎片問題(有關詳細信息,請參閱后面的博客文章)。
在JDK 1.6和JDK 1.7的更高版本中,有一個新的垃圾收集器,稱為G1 (代表Garbage First )。 像CMS收集器一樣,其目的是嘗試減輕或消除長時間停頓世界停頓的需求,并且它的大部分工作都是在短暫的停頓世界漸進停頓的同時進行的,其中一些工作也在??完成中與應用程序同時進行。 與CMS相反,G1 是緊湊的收集器,并且沒有碎片問題的困擾-而是有其他折衷的選擇(同樣,在以后的博客文章中將對此進行更多討論)。
觀察垃圾收集器行為
我鼓勵讀者嘗試使用垃圾收集器的行為。 使用jconsole(與JDK一起提供)或VisualVM (在本文較早的時候生成了該圖)來可視化正在運行的JVM上的行為。 但是,尤其要開始運行JVM,以開始熟悉垃圾收集日志的輸出(已更新jbellis的反饋–謝謝!):
-
-XX:+PrintGC
-
-XX:+PrintGCDetails
-
-XX:+PrintGCDateStamps
-
-XX:+PrintGCApplicationStoppedTime
-
-XX:+PrintPromotionFailure
也有用但冗長(含義在以后的文章中解釋):
-
-XX:+PrintHeapAtGC
-
-XX:+PrintTenuringDistribution
-
-XX:PrintFLSStatistics=1
對于吞吐量收集器,輸出非常容易讀取。 對于CMS和G1,在沒有介紹的情況下,輸出對于分析而言更加不透明。 我希望在以后的更新中對此進行介紹。
同時,得出的結論是,每當懷疑與GC相關的問題時,上面的那些選項可能就是您要使用的第一件事。 當人們開始假設GC問題時,這幾乎總是我告訴人們的第一件事。 您是否看過GC日志? 如果您還沒有,那可能是在浪費時間猜測GC。
結論
我試圖制作一個速成課程介紹,希望對我有啟發性,但主要是作為后續文章的背景。 我歡迎任何反饋,尤其是在情況不清楚或我做出太多假設的情況下。 正如我一開始所說的那樣,我希望這個系列能夠被廣泛的讀者所接受,盡管我當然確實具有一定的專業水平。 但是,不需要垃圾收集方面的知識。 如果是,我已經失敗了-請讓我知道。
參考: 實用垃圾收集,第1部分–我們的JCG合作伙伴 Peter Schuller在(mod:world:scode)博客上的介紹
翻譯自: https://www.javacodegeeks.com/2012/01/practical-garbage-collection-part-1.html