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

原文鏈接:http://ifeve.com/jvm-3-gc/

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)垃圾收集算法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章