1. 程式人生 > >JVM效能優化(三):垃圾收集

JVM效能優化(三):垃圾收集

原文地址,譯文地址,譯者:Greenster

Java平臺的垃圾收集機制顯著提高了開發者的效率,但是一個實現糟糕的垃圾收集器可能過多地消耗應用程式的資源。在Java虛擬機器效能優化系列的第三部分,Eva Andreasson向Java初學者介紹了Java平臺的記憶體模型和垃圾收集機制。她解釋了為什麼碎片化(而不是垃圾收集)是Java應用程式效能的主要問題所在,以及為什麼分代垃圾收集和壓縮是目前處理Java應用程式碎片化的主要辦法(但不是最有新意的)。

垃圾收集(GC)的目的是釋放那些不再被任何活動物件引用的Java物件所佔用的記憶體,它是Java虛擬機器動態記憶體管理機制的核心部分。在一個典型的垃圾收集週期裡,所有仍然被引用的物件(因此是可達的)都將被保留,而那些不再被引用的物件將被釋放、其所佔用的空間將被回收用來分配給新的物件。

為了理解垃圾收集機制和各種垃圾收集演算法,首先需要知道關於Java平臺記憶體模型的一些知識。

垃圾收集和Java平臺記憶體模型

當用命令列啟動一個Java程式並指定啟動引數-Xmx時(例如:java -Xmx:2g MyApp),指定大小的記憶體就分配給了Java程序,這就是所謂的Java堆。這個專用的記憶體地址空間用於儲存Java程式(有時是JVM)所建立的物件。隨著應用程式執行並不斷為新物件分配記憶體,Java堆(即專門的記憶體地址空間)就會慢慢被填滿。

最終Java堆會被填滿,也就是說記憶體分配執行緒找不到一塊足夠大的連續空間為新物件分配記憶體,這時JVM決定要通知垃圾收集器並啟動垃圾收集。垃圾收集也可以通過在程式中呼叫System.gc()

來觸發,但使用System.gc()並不能確保垃圾收集一定被執行。在任何一次垃圾收集之前,垃圾收集機制都會首先判斷執行垃圾收集是否安全,當應用程式的所有活動執行緒都處於安全點時就可以開始執行一次垃圾收集。例如:當正在為物件分配記憶體時就不能執行垃圾收集,或者是正在優化CPU指令時也不能執行垃圾收集,因為這樣很可能會丟失上下文從而搞錯最終結果。

垃圾收集器不能回收任何一個有活動引用的物件,那將破壞Java虛擬機器規範。也無需立即回收死物件,因為死物件最終還是會被後續的垃圾收集所回收。儘管有很多種垃圾收集的實現方法,但以上兩點對所有垃圾收集實現都是相同的。垃圾收集真正的挑戰在於如何識別物件是否存活以及如何在儘量不影響應用程式的情況下回收記憶體,因此垃圾收集器的目標有以下兩個:

  1. 迅速釋放沒有引用的記憶體以滿足應用程式的記憶體分配需要從而避免記憶體溢位。
  2. 回收記憶體時對正在執行的應用程式效能(延遲和吞吐量)的影響最小化。

兩類垃圾收集

在本系列的第一篇中,我介紹了兩種垃圾收集的方法,即引用計數和跟蹤收集。接下來我們進一步探討這兩種方法,並介紹一些在生產環境中使用的跟蹤收集演算法。

引用計數收集器

引用計數收集器記錄了指向每個Java物件的引用數,一旦指向某個物件的引用數為0,那麼就可以立即回收該物件。這種即時性是引用計數收集器的主要優點,而且維護那些沒有引用指向的記憶體幾乎沒有開銷,不過為每個物件記錄最新的引用數卻是代價高昂的。

引用計數收集器的主要難點在於如何保證引用計數的準確性,另外一個眾所周知的難點是如何處理迴圈引用的情況。如果兩個物件彼此引用,而且沒有被其他活動物件所引用,那麼這兩個物件的記憶體永遠都不會被回收,因為指向這兩個物件的引用數都不為0。對迴圈引用結構的記憶體回收需要major analysis(譯者注:Java堆上的全域性分析),這將增加演算法的複雜性,從而也給應用程式帶來額外的開銷。

跟蹤收集器

跟蹤收集器基於這樣的假設:所有的活動物件都可以通過一個已知的初始活動物件集合的迭代引用(引用以及引用的引用)找到。可以通過分析暫存器、全域性物件和棧幀來確定初始活動物件集合(也被稱為根物件)。確定了初始物件集合後,跟蹤收集器順著這些物件的引用關係依次將引用所指向的物件標註為活動物件,就這樣已知的活動物件集合不斷擴大。這一過程持續進行直到所有被引用的物件都被標註為活動物件,而那些沒有被標註過的物件的記憶體就被回收。

跟蹤收集器不同於引用計數收集器主要在於它可以處理迴圈引用結構。多數的跟蹤收集器都是在標記階段發現那些迴圈引用結構中的無引用物件。

跟蹤收集器是動態語言中最常用的記憶體管理方式,也是目前Java中最常見的方式,同時在生產環境中也被驗證了很多年。下面我將從實現跟蹤收集的一些演算法開始介紹跟蹤收集器。

跟蹤收集演算法

複製垃圾收集器和標記-清除垃圾收集器並不是什麼新東西,但它們仍然是目前實現跟蹤收集的兩種最常見演算法。

複製垃圾收集器

傳統的複製垃圾收集器使用堆中的兩個地址空間(即from空間和to空間),當執行垃圾收集時from空間的活動物件被複制到to空間,當from空間的所有活動物件都被移出(譯者注:複製到to空間或者老年代)後,就可以回收整個from空間了,當再次開始分配空間時將首先使用to空間(譯者注:即上一輪的to空間作為新一輪的from空間)。

在該演算法的早期實現中,from空間和to空間不斷變換位置,也就是說當to空間滿了,觸發了垃圾收集,to空間就成為了from空間,如圖1所示。

圖1 傳統的複製垃圾收集順序

最新的複製演算法允許堆內任意地址空間作為to空間和from空間。這樣它們不需要彼此交換位置,而只是邏輯上變換了位置。

複製收集器的優點是在to空間被複制的物件緊湊排列,完全沒有碎片。而碎片化正是其他垃圾收集器所面臨的一個共同問題,也是我之後主要討論的問題。

複製收集器的缺陷

通常來說複製收集器是stop-the-world的,也就是說只要垃圾收集在進行,應用程式就無法執行。對於這種實現來說,你需要複製的東西越多,對應用程式效能的影響就越大。對於那些響應時間敏感的應用來說這是個缺點。使用複製收集器時,你還要考慮最壞的場景(即from空間中的所有物件都是活動物件),這時你需要為移動這些活動物件準備足夠大的空間,因此to空間必須大到可以裝下from空間的所有物件。由於這個限制,複製演算法的記憶體利用率稍有不足(譯者注:在最壞的情況下to空間需要和from空間大小相同,所以只有50%的利用率)。

標記-清除收集器

部署在企業生產環境上的大多數商業JVM採用的都是標記-清除(或者叫標記)收集器,因為它沒有複製垃圾收集器對應用程式效能的影響問題。其中最有名的標記收集器包括CMS、G1、GenPar和DeterministicGC。

標記-清除收集器跟蹤物件引用,並且用標誌位將每個找到的物件標記為live。這個標誌位通常對應堆上的一個地址或是一組地址。例如:活動位可以是物件頭的一個位(譯者注:bit)或是一個位向量、一個位圖。

在標記完成之後就進入了清除階段。清除階段通常都會再次遍歷堆(不僅是標記為live的物件,而是整個堆),用來定位那些沒有標記的連續記憶體地址空間(沒有被標記的記憶體就是空閒並可回收的),然後收集器將它們整理為空閒列表。垃圾收集器可以有多個空閒列表(通常按照記憶體塊的大小劃分),有些JVM(例如:JRockit Real Time)的收集器甚至基於應用程式的效能分析和物件大小的統計結果來動態劃分空閒列表。

清除階段過後,應用程式就可以再次分配記憶體了。從空閒列表中為新物件分配記憶體時,新分配的記憶體塊需要符合新物件的大小,或是執行緒的平均物件大小,或是應用程式的TLAB大小。為新物件找到大小合適的記憶體塊有助於優化記憶體和減少碎片。

標記-清除收集器的缺陷

標記階段的執行時間依賴於堆中活動物件的數量,而清除階段的執行時間依賴於堆的大小。因此對於堆設定較大並且堆中活動物件較多的情況,標記-清除演算法會有一定的暫停時間。

對於記憶體消耗很大的應用程式來說,你可以調整垃圾收集引數以適應各種應用程式的場景和需要。在很多情況下,這種調整至少推遲了標記階段/清除階段給應用程式或服務協議SLA(SLA這裡指應用程式要達到的響應時間)帶來的風險。但是調優僅僅對特定的負載和記憶體分配率有效,負載變化或是應用程式本身的修改都需要重新調優。

標記-清除收集器的實現

至少有兩種已經在商業上驗證的方法來實現標記-清除垃圾收集。一種是並行垃圾收集,另一種是併發(或者多數時間是併發)垃圾收集。

並行收集器

並行收集是指資源被垃圾收集執行緒並行使用。大多數並行收集的商業實現都是stop-the-world收集器,即所有的應用程式執行緒都暫停直到完成一次垃圾收集,因為垃圾收集器可以高效地使用資源,所以通常會在吞吐量的基準測試中得到高分,如SPECjbb。如果吞吐量對你的應用程式至關重要,那麼並行垃圾收集器是一個很好的選擇。

並行收集的主要代價(特別是對於生產環境)是應用程式執行緒在垃圾收集期間無法正常工作,就像複製收集器一樣。因此那些對於響應時間敏感的應用程式使用並行收集器會有很大的影響。特別是在堆空間中有很多複雜的活動物件結構時,有很多的物件引用需要跟蹤。(還記得嗎標記-清除收集器回收記憶體的時間取決於跟蹤活動物件集合的時間加上遍歷整個堆的時間)對於並行方法來說,整個垃圾收集時間應用程式都會暫停。

併發收集器

併發垃圾收集器更適合那些對響應時間敏感的應用程式。併發意味著垃圾收集執行緒和應用程式執行緒併發執行。垃圾收集執行緒並不獨佔所有資源,因此需要決定何時開始一次垃圾收集,需要有足夠的時間跟蹤活動物件集合並在應用程式記憶體溢位前回收記憶體。如果垃圾收集沒有及時完成,應用程式就會丟擲記憶體溢位錯誤,另一方面又不希望垃圾收集執行時間太長因為那樣會消耗應用程式的資源進而影響吞吐量。保持這種平衡是需要技巧的,因此在確定開始垃圾收集的時機以及選擇垃圾收集優化的時機時都使用了啟發式演算法。

另一個難點在於確定何時可以安全執行一些操作(需要完整準確的堆快照的操作),例如:需要知道何時標記階段完成,這樣就可以進入清理階段。對於stop-the-world的並行收集器來說這不成問題,因為世界已經暫停了(譯者注:應用程式執行緒暫停,垃圾收集執行緒獨佔資源)。但對於併發收集器而言,從標記階段立刻切換到清理階段可能不安全。如果應用程式執行緒修改了一段記憶體,而這段記憶體已經被垃圾收集器跟蹤並標註過了,這就可能產生了新的沒有標註的引用。在一些併發收集實現中,這會使應用程式陷入長時間重複標註的迴圈,當應用程式需要這段記憶體時也無法獲得空閒記憶體。

通過到目前為止的討論我們知道有很多的垃圾收集器和垃圾收集演算法,分別適合特定的應用程式型別和不同的負載。不僅是不同的演算法,還有不同的演算法實現。所以在指定垃圾收集器錢最好了解應用程式的需求以及自身特點。接下來我們將介紹Java平臺記憶體模型的一些陷阱,這裡陷阱的意思是,在動態變化的生產環境中Java程式設計師容易做出的一些使得應用程式效能變得更差的假設。

為什麼調優無法代替垃圾收集

多數的Java程式設計師都知道如果要優化Java程式可以有很多選擇。若干個可選的JVM、垃圾收集器和效能調優引數讓開發者花費大量的時間在無休無盡的效能調優方面。這使有些人因此得出結論:垃圾收集是糟糕的,通過調優使垃圾收集較少發生或者持續時間較短是一個很好的變通辦法,不過這樣做是有風險的。

考慮一下針對具體應用程式的調優,多數的調優引數(例如記憶體分配率、物件大小、響應時間)都是基於當前測試的資料量對應用程式的記憶體分配率(譯者注:或者其他引數)調整。最終可能造成以下兩個結果:

  1. 在測試中通過的用例在生產環境中失敗。
  2. 資料量的變化或者應用程式的變化要求重新調優。

調優是需要反覆的,特別是併發垃圾收集器可能需要很多調優(尤其在生產環境中)。需要啟發式方法來滿足應用程式的需要。為了要滿足最壞的情況,調優的結果可能是一個非常死板的配置,這也導致了大量的資源浪費。這種調優方法是一種堂吉訶德式的探索。事實上,你越是優化垃圾收集器來匹配特定的負載,越是遠離了Java執行時的動態特性。畢竟有多少應用程式的負載是穩定的呢,你所預期的負載的可靠性又有多高呢?

那麼如果你不將注意力放在調優上,能夠做些什麼來防止記憶體溢位錯誤和提高響應時間呢?首要的事情就是找到影響Java應用程式效能的主要因素。

碎片化

影響Java應用程式效能的因素不是垃圾收集器,而是碎片化以及垃圾收集器如何處理碎片化。所謂碎片化是這樣一種狀態:堆空間中有空閒可用的空間,但並沒有足夠大的連續記憶體空間,以至於無法為新物件分配記憶體。正如在第一篇中提到的,記憶體碎片要麼是堆中殘留的一段空間TLAB,要麼是在長期存活物件中間被釋放的小物件所佔用的空間。

隨著時間的推移和應用程式的執行,這些碎片就會遍佈在堆中。在某些情況下,使用了靜態化調優的引數可能會更糟,因為這些引數無法滿足應用程式的動態需要。應用程式無法有效利用這些碎片化的空間。如果不做任何事情,那麼將導致接連不斷的垃圾收集,垃圾收集器嘗試釋放記憶體分配給新物件。在最壞的情況下,即使是接連不斷的垃圾收集也無法釋放更多的記憶體(碎片太多),然後JVM不得不丟擲記憶體溢位的錯誤。你可以通過重啟應用程式來解決碎片化,這樣Java堆就有連續的記憶體空間可以分配給新物件。重啟程式導致宕機,而且一段時間後Java堆將再次充滿碎片,不得不再次重啟。

記憶體溢位錯誤會掛起程序,日誌顯示垃圾收集器正在超負荷工作,這些都顯示垃圾收集正試圖釋放記憶體,也表明堆中碎片很多。一些程式設計師會試圖通過再次優化垃圾收集器來解決碎片化問題。但我認為應該尋找更有新意的辦法解決這個問題。接下來的部分將重點討論解決碎片化的兩個辦法:分代垃圾收集和壓縮。

分代垃圾收集

你可能聽過這樣的理論:在生產環境中絕大多數物件的存活時間都很短。分代垃圾收集正是由這一理論衍生出的一種垃圾收集策略。在分代垃圾收集中,我們將堆分為不同的空間(或者叫做代),每個空間中儲存著不同年齡的物件,所謂物件的年齡就是物件存活的垃圾收集週期數(也就是該物件多少個垃圾收集週期後仍然被引用)。

當新生代沒有剩餘空間可分配時,新生代的活動物件會被移動到老年代中(通常只有兩個代。譯者注:只有滿足一定年齡的物件才會被移動到老年代),分代垃圾收集常常使用單向的複製收集器,一些更現代的JVM新生代中使用的是並行收集器,當然也可以為新生代和老年代分別實現不同的垃圾收集演算法。如果你使用並行收集器或複製收集器,那麼你的新生代收集器就是一個stop-the-world的收集器(參見之前的解釋)。

老年代分配給那些從新生代移出的物件,這些物件要麼是被引用很長一段時間,要麼是被一些新生代中物件集合所引用。偶爾也有大物件直接被分配到了老年代,因為移動大物件的成本相對較高。

分代垃圾收集技術

在分代垃圾收集中,老年代執行垃圾收集的頻率較低,而在新生代執行垃圾收集的頻率較高,而我們也希望在新生代中垃圾收集週期更短。在極少的情況下,新生代的垃圾收集可能會比老年代的垃圾收集更頻繁。如果你將新生代設定的太大時並且應用程式中的多數物件都存活較長時間,這種情況就可能會發生。在這種情況下,如果老年代設定的太小以至於無法容納所有的長時間存活的物件,老年代的垃圾收集也會掙扎於釋放空間給那些被移動進來的物件。不過通常來說分代垃圾收集可以使應用程式獲得更好的效能。

劃分出新生代的另一個好處是某種程度上解決了碎片化問題,或者說將最壞的情況推遲了。那些存活時間短的小物件本來可能產生碎片化問題,但都在新生代的垃圾收集中被清理了。由於存活時間長的物件被移到老年代時被更緊湊的分配空間,老年代也更加緊湊了。隨著時間推移(如果你的應用執行時間足夠長),老年代也會產生碎片化,這時需要執行一次或是幾次完全垃圾收集,同時JVM也有可能丟擲記憶體溢位錯誤。但是劃分出新生代推遲了出現最壞情況的時間,這對於很多應用程式來說已經足夠了。對於多數應用程式而言,它的確降低了stop-the-world垃圾收集的頻率和記憶體溢位錯誤的機會。

優化分代垃圾收集

正如之前提到的,使用分代垃圾收集帶來了重複的調優工作,例如調整新生代大小、提升率等。我無法針對具體應用執行時來強調怎樣做取捨:選擇固定的大小固然可以優化應用程式,但同時也減少了垃圾收集器應對動態變化的能力,而變化是不可避免的。

對於新生代首要原則就是在確保stop-the-world垃圾收集期間延遲時間前提下儘可能的加大,同時也要為那些長期存活的物件在堆中保留足夠大的空間。下面是在調整分代垃圾收集器時要考慮的一些額外因素:

  1. 新生代中多數都是stop-the-world垃圾收集器,新生代設定的越大,相應的暫停時間就越長。因此對於那些受垃圾收集暫停時間影響大的應用程式來說,要仔細考慮將新生代設定為多大合適。
  2. 可以在不同的代上使用不同的垃圾收集演算法。例如在新生代中使用並行垃圾收集,在老年代中使用併發垃圾收集。
  3. 當發現頻繁的提升(譯者注:從新生代移動到老年代)失敗時說明老年代中碎片太多了,也就是說老年代中沒有足夠的空間來存放從新生代移出的物件。這時你可以調整一下提升率(即調整提升的年齡),或者確保老年代中的垃圾收集演算法會進行壓縮(將在下一段討論)並調整壓縮以適應應用程式的負載。也可以增加堆大小和各個代大小,但是這樣更會進一步延長老年代上的暫停時間。要知道碎片化是無法避免的。
  4. 分代垃圾收集最適合這樣的應用程式,他們有很多存活時間很短的小物件,很多物件在第一輪垃圾收集週期就被回收了。對於這種應用程式分代垃圾收集可以很好的減少碎片化,並將碎片化產生影響的時機推遲。

壓縮

儘管分代垃圾收集延遲了出現碎片化和記憶體溢位錯誤的時間,然而壓縮才是真正解決碎片化問題的唯一辦法。壓縮是指通過移動物件來釋放連續記憶體塊的垃圾收集策略,這樣通過壓縮為建立新物件釋放了足夠大的空間。

移動物件並更新物件引用是stop-the-world操作,會帶來一定的消耗(有一種情況例外,將在本系列的下一篇中討論)。存活的物件越多,壓縮造成的暫停時間就越長。在剩餘空間很少並且碎片化嚴重的情況下(這通常是因為程式運行了很長的時間),壓縮存活物件較多的區域可能會有幾秒種的暫停時間,而當接近記憶體溢位時,壓縮整個堆甚至會花上幾十秒的時間。

壓縮的暫停時間取決於需要移動的記憶體大小和需要更新的引用數量。統計分析表明堆越大,需要移動的活動物件和更新的引用數量就越多。每移動1GB到2GB活動物件的暫停時間大約是1秒鐘,對於4GB大小的堆很可能有25%的活動物件,因此偶爾會有大約1秒的暫停。

壓縮和應用程式記憶體牆

應用程式記憶體牆是指在垃圾收集產生的暫停(例如:壓縮)前可以設定的堆大小。根據系統和應用的不同,大多數的Java應用程式記憶體牆都在4GB到20GB之間。這也是多數的企業應用都是部署在多個較小的JVM上,而不是少數較大的JVM上的原因。讓我們考慮一下這個問題:有多少現代企業的Java應用程式設計、部署是根據JVM的壓縮限制來定義的。在這種情況下,為了繞過整理堆碎片的暫停時間,我們接受了更耗費管理成本的多個例項部署方案。考慮到現在硬體的大容量儲存能力和企業級Java應用對增加記憶體的需求,這就有點奇怪了。為什麼為每個例項只設置了幾個GB的記憶體。併發壓縮將會打破記憶體牆,這也是我下一篇文章的主題。

總結

本文是一篇關於垃圾收集的介紹性文章,幫助你瞭解有關垃圾收集的概念和機制,並希望能夠促使你進一步閱讀相關文章。這裡討論的很多東西都已經存在了很久,在下一篇中將介紹一些新的概念。例如併發壓縮,目前是由Azul‘s Zing JVM實現的。它是一項新興的垃圾收集技術,甚至嘗試重新定義Java記憶體模型,特別是在今天記憶體和處理能力都不斷提高的情況下。

以下是我總結出的一些關於垃圾收集的要點:

  • 不同的垃圾收集演算法和實現適應不同的應用程式需要,跟蹤垃圾收集器是商業Java虛擬機器中使用的最多的垃圾收集器。
  • 並行垃圾收集在執行垃圾收集時並行使用所有資源。它通常是一個stop-the-world垃圾收集器,因此有更高的吞吐量,但是應用程式的工作執行緒必須等待垃圾收集執行緒完成,這對應用程式的響應時間有一定影響。
  • 併發垃圾收集在執行收集時,應用程式工作執行緒仍然在執行。併發垃圾收集器需要在應用程式需要記憶體之前完成垃圾收集。
  • 分代垃圾收集有助於延遲碎片化,但無法消除碎片化。分代垃圾收集將堆分為兩個空間,其中一個空間存放新物件,另一個空間存放老物件。分代垃圾收集適合有很多存活時間很短的小物件的應用程式。
  • 壓縮是解決碎片化的唯一方法。多數的垃圾收集器都是以stop-the-world的方式執行壓縮的,程式執行時間越久,物件引用越是複雜,物件的大小越是分佈不均勻都將導致壓縮時間延長。堆的大小也會影響壓縮時間,因為可能有更多的活動物件和引用需要更新。
  • 調優有助於延遲記憶體溢位錯誤。但是過度調優的結果是僵化的配置。在通過試錯的方式開始調優之前,要確保清楚生產環境的負載、應用程式的物件型別以及物件引用的特性。過於僵化的配置很可能無法應付動態負載,因此在設定非動態值時一定要了解這樣做的後果。

本系列的下一篇是:深入探討C4(Concurrent Continuously Compacting Collector)垃圾收集演算法。


[email protected]唯品會。關注Java語言、併發程式設計、Spring框架等。