JVM性能優化(三):垃圾收集
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虛擬機規範。也無需立即回收死對象,因為死對象最終還是會被後續的垃圾收集所回收。盡管有很多種垃圾收集的實現方法,但以上兩點對所有垃圾收集實現都是相同的。垃圾收集真正的挑戰在於如何識別對象是否存活以及如何在盡量不影響應用程序的情況下回收內存,因此垃圾收集器的目標有以下兩個:
- 迅速釋放沒有引用的內存以滿足應用程序的內存分配需要從而避免內存溢出。
- 回收內存時對正在運行的應用程序性能(延遲和吞吐量)的影響最小化。
兩類垃圾收集
在本係列的第一篇中,我介紹了兩種垃圾收集的方法,即引用計數和跟蹤收集。接下來我們進一步探討這兩種方法,並介紹一些在生產環境中使用的跟蹤收集算法。
引用計數收集器
引用計數收集器記錄了指向每個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、垃圾收集器和性能調優參數讓開發者花費大量的時間在無休無盡的性能調優方麵。這使有些人因此得出結論:垃圾收集是糟糕的,通過調優使垃圾收集較少發生或者持續時間較短是一個很好的變通辦法,不過這樣做是有風險的。
考慮一下針對具體應用程序的調優,多數的調優參數(例如內存分配率、對象大小、響應時間)都是基於當前測試的數據量對應用程序的內存分配率(譯者注:或者其他參數)調整。最終可能造成以下兩個結果:
- 在測試中通過的用例在生產環境中失敗。
- 數據量的變化或者應用程序的變化要求重新調優。
調優是需要反複的,特別是並發垃圾收集器可能需要很多調優(尤其在生產環境中)。需要啟發式方法來滿足應用程序的需要。為了要滿足最壞的情況,調優的結果可能是一個非常死板的配置,這也導致了大量的資源浪費。這種調優方法是一種堂吉訶德式的探索。事實上,你越是優化垃圾收集器來匹配特定的負載,越是遠離了Java運行時的動態特性。畢竟有多少應用程序的負載是穩定的呢,你所預期的負載的可靠性又有多高呢?
那麼如果你不將注意力放在調優上,能夠做些什麼來防止內存溢出錯誤和提高響應時間呢?首要的事情就是找到影響Java應用程序性能的主要因素。
碎片化
影響Java應用程序性能的因素不是垃圾收集器,而是碎片化以及垃圾收集器如何處理碎片化。所謂碎片化是這樣一種狀態:堆空間中有空閑可用的空間,但並沒有足夠大的連續內存空間,以至於無法為新對象分配內存。正如在第一篇中提到的,內存碎片要麼是堆中殘留的一段空間TLAB,要麼是在長期存活對象中間被釋放的小對象所占用的空間。
隨著時間的推移和應用程序的運行,這些碎片就會遍布在堆中。在某些情況下,使用了靜態化調優的參數可能會更糟,因為這些參數無法滿足應用程序的動態需要。應用程序無法有效利用這些碎片化的空間。如果不做任何事情,那麼將導致接連不斷的垃圾收集,垃圾收集器嚐試釋放內存分配給新對象。在最壞的情況下,即使是接連不斷的垃圾收集也無法釋放更多的內存(碎片太多),然後JVM不得不拋出內存溢出的錯誤。你可以通過重啟應用程序來解決碎片化,這樣Java堆就有連續的內存空間可以分配給新對象。重啟程序導致宕機,而且一段時間後Java堆將再次充滿碎片,不得不再次重啟。
內存溢出錯誤會掛起進程,日誌顯示垃圾收集器正在超負荷工作,這些都顯示垃圾收集正試圖釋放內存,也表明堆中碎片很多。一些程序員會試圖通過再次優化垃圾收集器來解決碎片化問題。但我認為應該尋找更有新意的辦法解決這個問題。接下來的部分將重點討論解決碎片化的兩個辦法:分代垃圾收集和壓縮。
分代垃圾收集
你可能聽過這樣的理論:在生產環境中絕大多數對象的存活時間都很短。分代垃圾收集正是由這一理論衍生出的一種垃圾收集策略。在分代垃圾收集中,我們將堆分為不同的空間(或者叫做代),每個空間中保存著不同年齡的對象,所謂對象的年齡就是對象存活的垃圾收集周期數(也就是該對象多少個垃圾收集周期後仍然被引用)。
當新生代沒有剩餘空間可分配時,新生代的活動對象會被移動到老年代中(通常隻有兩個代。譯者注:隻有滿足一定年齡的對象才會被移動到老年代),分代垃圾收集常常使用單向的複製收集器,一些更現代的JVM新生代中使用的是並行收集器,當然也可以為新生代和老年代分別實現不同的垃圾收集算法。如果你使用並行收集器或複製收集器,那麼你的新生代收集器就是一個stop-the-world的收集器(參見之前的解釋)。
老年代分配給那些從新生代移出的對象,這些對象要麼是被引用很長一段時間,要麼是被一些新生代中對象集合所引用。偶爾也有大對象直接被分配到了老年代,因為移動大對象的成本相對較高。
分代垃圾收集技術
在分代垃圾收集中,老年代運行垃圾收集的頻率較低,而在新生代運行垃圾收集的頻率較高,而我們也希望在新生代中垃圾收集周期更短。在極少的情況下,新生代的垃圾收集可能會比老年代的垃圾收集更頻繁。如果你將新生代設置的太大時並且應用程序中的多數對象都存活較長時間,這種情況就可能會發生。在這種情況下,如果老年代設置的太小以至於無法容納所有的長時間存活的對象,老年代的垃圾收集也會掙紮於釋放空間給那些被移動進來的對象。不過通常來說分代垃圾收集可以使應用程序獲得更好的性能。
劃分出新生代的另一個好處是某種程度上解決了碎片化問題,或者說將最壞的情況推遲了。那些存活時間短的小對象本來可能產生碎片化問題,但都在新生代的垃圾收集中被清理了。由於存活時間長的對象被移到老年代時被更緊湊的分配空間,老年代也更加緊湊了。隨著時間推移(如果你的應用運行時間足夠長),老年代也會產生碎片化,這時需要運行一次或是幾次完全垃圾收集,同時JVM也有可能拋出內存溢出錯誤。但是劃分出新生代推遲了出現最壞情況的時間,這對於很多應用程序來說已經足夠了。對於多數應用程序而言,它的確降低了stop-the-world垃圾收集的頻率和內存溢出錯誤的機會。
優化分代垃圾收集
正如之前提到的,使用分代垃圾收集帶來了重複的調優工作,例如調整新生代大小、提升率等。我無法針對具體應用運行時來強調怎樣做取舍:選擇固定的大小固然可以優化應用程序,但同時也減少了垃圾收集器應對動態變化的能力,而變化是不可避免的。
對於新生代首要原則就是在確保stop-the-world垃圾收集期間延遲時間前提下盡可能的加大,同時也要為那些長期存活的對象在堆中保留足夠大的空間。下麵是在調整分代垃圾收集器時要考慮的一些額外因素:
- 新生代中多數都是stop-the-world垃圾收集器,新生代設置的越大,相應的暫停時間就越長。因此對於那些受垃圾收集暫停時間影響大的應用程序來說,要仔細考慮將新生代設置為多大合適。
- 可以在不同的代上使用不同的垃圾收集算法。例如在新生代中使用並行垃圾收集,在老年代中使用並發垃圾收集。
- 當發現頻繁的提升(譯者注:從新生代移動到老年代)失敗時說明老年代中碎片太多了,也就是說老年代中沒有足夠的空間來存放從新生代移出的對象。這時你可以調整一下提升率(即調整提升的年齡),或者確保老年代中的垃圾收集算法會進行壓縮(將在下一段討論)並調整壓縮以適應應用程序的負載。也可以增加堆大小和各個代大小,但是這樣更會進一步延長老年代上的暫停時間。要知道碎片化是無法避免的。
- 分代垃圾收集最適合這樣的應用程序,他們有很多存活時間很短的小對象,很多對象在第一輪垃圾收集周期就被回收了。對於這種應用程序分代垃圾收集可以很好的減少碎片化,並將碎片化產生影響的時機推遲。
壓縮
盡管分代垃圾收集延遲了出現碎片化和內存溢出錯誤的時間,然而壓縮才是真正解決碎片化問題的唯一辦法。壓縮是指通過移動對象來釋放連續內存塊的垃圾收集策略,這樣通過壓縮為創建新對象釋放了足夠大的空間。
移動對象並更新對象引用是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)垃圾收集算法。
最後更新:2017-05-23 17:32:14
上一篇:
Java創建線程安全的方法
下一篇:
Quartz教程:快速入門
Sql_Server中如何判斷表中某列是否存在
IoC+AOP的簡單實現
PHP如何過濾★等特殊符號
連載:麵向對象葵花寶典:思想、技巧與實踐(29) - 高內聚低耦合
WinForm中提示Circular base class dependency involving 'TestEncryption.Form' and 'TestEncryption.Form
SQL Server插入中文數據出現亂碼問題
Hbase行健設計原則
PHP之父安迪:穀歌支持PHP一點也不奇怪
機器視覺在智能交通中的常見應用
《Hadoop與大數據挖掘》一1.1 大數據概述