Java中的垃圾回收

Java中的垃圾回收

前文中對標記刪除算法的介紹更多仍是偏理論性質的。實踐中,爲了更好地知足現實的場景及需求,還須要對算法進行大量的調整。舉個簡單的例子,咱們來看下JVM須要記錄哪些信息才能讓咱們得以安全地分配對象空間。html

碎片及整理(Fragmenting and Compacting)

JVM在清除不可達對象以後,還得確保它們所在的空間是能夠進行復用的。對象刪除會致使碎片的出現,這有點相似於磁盤碎片,這會帶來兩個問題:java

  • 寫操做會變得更加費時,由於查找下一個可用空閒塊已再也不是一個簡單操做。
  • JVM在建立新對象的,會在連續的區塊中分配內存。所以若是碎片已經嚴重到沒有一個空閒塊能足夠容納新建立的對象時,內存分配便會報錯。

爲了不此類情形,JVM須要確保碎片化在可控範圍內。所以,在垃圾回收的過程當中,除了進行標記和刪除外,還有一個「內存去碎片化」的過程。在這個過程中,會給可達對象從新分配空間,讓它們互相緊挨着對方,這樣即可以去除碎片。下圖展現的即是這一過程:算法

分代假設

如前所述,垃圾回收須要徹底停止應用運行。顯然,對象越多,回收的時間也越長。那麼咱們能不能在更小的內存區域上進行回收呢?經過可行性調查,一組研究人員發現應用中絕大多數的內存分配會分爲兩大類:json

  • 絕大部分的對象很快會變爲不可用狀態。
  • 還有一些,它們的存活時間一般也不會很長。

這些結論最終構成了弱分代假設(Weak Generational Hypothesis)。基於這一假設,虛擬機內的內存被分爲兩類,新生代(Young Generation)及老生代(Old Generation)。後者又被稱爲年老代(Tenured Generation)。安全

有了各自獨立的可清除區域後,這纔出現了衆多不一樣的回收算法,正是它們一直以來在持續提高着GC的性能。併發

這並不說明這樣的方式是沒有問題的。好比說,不一樣分代中的對象可能彼此間有引用,在進行分代回收時,它們便爲視爲是「事實上」的GC根對象(GC roots)。oracle

而更爲重要的是,分代假設對於某些應用來講並不成立。因爲GC算法主要是爲那些「快速消失」或者「永久存活」的對象而進行的優化,所以對於那些生命週期「適中的對象,JVM就顯得無能爲力了。app

內存池

在堆裏面進行內存池的劃分對你們來講應該是很是熟悉的了。不過你們可能不太清楚的是在不一樣的內存池中,垃圾回收是如何履行它的職責的。值得注意的是,雖然不一樣的GC算法細節實現上有所不一樣,可是本章中所提到的概念倒是大同小異的。jvm

伊甸區(Eden)

新對象被建立時,一般便會被分配到伊甸區。因爲一般都會有多個線程在同時分配大量的對象,由於伊甸區又被進一步劃分紅一個或多個線程本地分配緩衝(Thread Local Allocation Buffer,簡稱TLAB)。有了這些緩衝區使得JVM中大多數對象的分配均可以在各個線程本身對應的TLAB中完成,從而避免了線程間昂貴的同步開銷。ide

若是在TLAB中沒法完成分配(一般是因爲沒有足夠的空間),便會到伊甸區的共享空間中進行分配。若是這裏仍是沒有足夠的空間,則會觸發一次新生代垃圾回收的過程來釋放空間。若是垃圾回收後伊甸區仍是沒有足夠的空間,那麼這個對象便會到老生代中去分配。

當進行伊甸區的回收時,垃圾回收器會從根對象開始遍歷全部的可達對象,並將它們標記爲存活狀態。

前面咱們已經提到,對象間可能會存在跨代引用,所以最直觀的作法即是掃描其它分區到伊甸區的全部引用。但不幸的是這麼作會作成分代的作法變得毫無心義。JVM對此有它本身的妙招:卡片式標記(card-marking)。基本的作法是,JVM將伊甸區中可能存在老生代引用的對象標記爲"髒」對象。關於這點Nitsan的博客這裏有更進一步的介紹。

標記完成後,全部存活對象會被複制到其中的一個存活區。因而整個伊甸區即可認爲是清空了,又能夠從新用來分配對象了。這一過程便被稱爲」標記複製「:存活對象先被標記,隨後被複制到存活區中。

存活區(Survivor)

緊挨着伊甸區的是兩個存活區,分別是from區和to區。值得一提的是其中的一個存活區始終都是空的。

空的存活區會在下一次新生代GC的時候迎來它的居民。整個新生代中的全部存活對象(包含伊甸區以及那個非空的名爲from的存活區)都會被複制到to區中。一旦完成以後,對象便都跑到to區中而from區則被清空了。這時二者的角色便會發生調轉。

存活對象會不斷地在兩個存活區之間來回地複製,直到其中的一些對象被認爲是已經成熟,「足夠老」了。請記住這點,基於分代假設,已經存活了一段時間的對象,在至關長的一段時間內仍可能繼續存活。

這些「年老」的對象會被提高至老年代空間。出現對象提高的時候,這些對象則不會再被複制到另外一個存活區,而是直接複製到老年代中,它們會一直待到再也不被引用爲止。

垃圾回收器會跟蹤每一個對象歷經的回收次數,來判斷它們是否已經「足夠年老」,能夠傳播至老年代中。在一輪GC完成以後,每一個分區中存活下來的對象的計數便會加一。當一個對象的年齡超過了一個特定的年老閾值以後,它便會被提高到老年代中。

JVM會動態地調整實際的年齡閾值,不過經過指定-XX:+MaxTenuringThreshold參數能夠給該值設置一個上限。將-XX:+MaxTenuringThreshold設置爲0則當即觸發對象提高,而不會複製到存活區中。在現代的JVM中,這個值默認會被設置爲15個GC週期。在HotSpot虛擬機中這也是該值的上限。

若是存活區的大小不足以存放全部的新生代存活對象,則會出現過早提高。

老生代

老生代的內存空間的實現則更爲複雜。老生代的空間一般都會很是大,裏面存放的對象都是不太可能會被回收的。

老生代的GC比新生代的GC發生的頻率要少得多。因爲老生代中的多數對象都被認爲是存活的,也就不會存在標記-複製操做了。在GC中,這些對象會被挪到一塊兒以減小碎片。老生代的回收算法一般都是根據不一樣的理論來構建的。不過大致上都會分紅以下幾步:

  • 標記可達對象,設置GC根對象可達的全部對象後的標記位
  • 刪除不可達對象
  • 整理老生代空間的對象,將存活對象複製到老生代開始的連續空間內。

從以上描述中可知,爲了不過分碎片化,老生代的GC是明確須要進行整理操做的。

持久代

在Java 8之前還有一個特殊的空間叫作持久代(Permanent Generation)。這是元數據好比類相關數據存放的地方。除此以外,像駐留的字符串(internalized string)也會被存放在持久代中。這的確給Java開發人員帶來了很多麻煩事,由於很難評估這究竟會使用到多少空間。評估不到位偏會拋出java.lang.OutOfMemoryError: Permgen space的異常。只要不是真的由於內存泄漏而引發的OutOfMemoryError異常,能夠經過增長持久代空間的大小來解決這一問題,好比下例中的把持久代最大空間設置爲256MB:

java -XX:MaxPermSize=256m com.mycompany.MyApplication

元空間

因爲元數據空間大小的預測是件繁瑣且低效的工做,因而Java 8中乾脆就去掉了持久代,轉而推出了元空間。今後之後,那些個雜七雜八的東西便都存儲到正常的Java堆了。

可是,類定義現在則是存儲到了元空間裏。它存儲在本地內存中,不會與堆 內存相混雜。默認狀況下,元空間的大小隻受限於Java進程的可用本地內存的大小。這大大解放了開發人員,他們不會再由於多增長了一個類而引起java.lang.OutOfMemoryError: Permgen space異常了。值得注意的是,雖然看似元空間大小毫無限制了,但這一些並不是是沒有代價的——若是任由元空間無節制地增加,你可能會面臨的是頻繁的內存交換(swapping)或者是本地內存分配失敗。

若是你但願避免此類狀況,能夠像下例中這樣限制一下元空間的大小,將它設置成好比256MB:

java -XX:MaxMetaspaceSize=256m com.mycompany.MyApplication

新生代GC(Minor GC) vs 老生代GC(Major GC) vs Full GC

清除堆內存不一樣區域的垃圾回收事件又被稱爲新生代GC,老生代GC,以及Full GC事件。本章咱們將介紹一下不一樣事件的區別在哪裏。不過你會發現其實各自的差異並非那麼重要。

重要的是咱們但願知道應用是否到達它的服務能力上限了,而這又只能去監控應用的處理延時或者吞吐量。只有在這個時間GC事件才能派上用場。這些事件的關鍵之處在於它們是否中止了應用的運行,以及停了多久。

不過因爲新生代GC,老生代GC,Full GC這幾個術語被普遍使用卻又沒有一個清晰的定義,咱們仍是先來詳細地介紹一下它們的區別再說吧。

新生代GC

新生代垃圾的回收被稱做Minor GC。這個定義很是清晰,理解起來也不會有什麼歧義。不過當處理新生代GC事件時,仍是有一些有意思的東西值得注意的:

  1. 只要JVM沒法爲新建立的對象分配空間,就確定會觸發新生代GC,比方說Eden區滿了。所以對象建立得越頻繁,新生代GC確定也更頻繁。
  2. 一旦內存池滿了,它的全部內容就會被拷貝走,指針又將從新歸零。所以和經典的標記(Mark),清除(Sweep),整理(Compact)的過程不一樣的是,Eden區和Survivor區的清理只涉及到標記和拷貝。在它們中是不會出現碎片的。寫指針始終在當前使用區的頂部。
  3. 在一次新生代GC事件中,一般不涉及到年老代。年老代到年輕代的引用被認爲是GC的根對象。而在標記階段中,從年輕代到年老代的引用則會被忽略掉。
  4. 和一般所理解的不同的是,全部的新生代GC都會觸發「stop-the-world」暫停,這會中斷應用程序的線程。對絕大多數應用而言,暫停的時間是能夠忽略不計的。若是Eden區中的大多數對象都是垃圾對象而且永遠不會被拷貝到Survivor區/年老代中的話,這麼作是合理的。若是剛好相反的話,那麼絕大多數的新生對象都不該該被回收,新生代GC的暫停時間就會變得相對較長了。

如今來看新生代GC仍是很清晰的——每一次新生代GC都會對年輕代進行垃圾清除

老年代GC與Full GC

你會發現關於這兩種GC其實並無明確的定義。JVM規範或者垃圾回收相關的論文中都沒有說起。不過從直覺來講,根據新生代GC(Minor GC)清理的是新生代空間的認識來看,不可貴出如下推論(這裏應當從英文出發來理解,Minor, Major與Full GC,翻譯過來的名稱已經帶有明顯的釋義了):

  • Major GC清理的是老年代的空間。
  • Full GC清理的是整個堆——包括新生代與老年代空間

不幸的是這麼理解會有一點複雜與困惑。首先——許多老年代GC實際上是由新生代GC觸發的,所以在不少狀況下二者沒法孤立來看待。另外一方面——許多現代的垃圾回收器會對老年代進行部分清理,所以,使用「清理」這個術語則顯得有點牽強。

那麼問題就來了,先別再糾結某次GC究竟是老年代GC仍是Full GC了,你應該關注的是此次GC是否中斷了應用線程仍是可以和應用線程併發地執行

即使是在JVM的官方工具中,也存在着這一困擾。經過一個例子來講明應該更容易理解一些。咱們用兩款工具來跟蹤某個運行着CMS回收器的JVM,來比較下它們的輸出有什麼不一樣:

首先經過jstat的輸出來查看下GC的信息:

my-precious: me$ jstat -gc -t 4235 1s

Time S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT

5.7 34048.0 34048.0 0.0 34048.0 272640.0 194699.7 1756416.0 181419.9 18304.0 17865.1 2688.0 2497.6 3 0.275 0 0.000 0.275

6.7 34048.0 34048.0 34048.0 0.0 272640.0 247555.4 1756416.0 263447.9 18816.0 18123.3 2688.0 2523.1 4 0.359 0 0.000 0.359

7.7 34048.0 34048.0 0.0 34048.0 272640.0 257729.3 1756416.0 345109.8 19072.0 18396.6 2688.0 2550.3 5 0.451 0 0.000 0.451

8.7 34048.0 34048.0 34048.0 34048.0 272640.0 272640.0 1756416.0 444982.5 19456.0 18681.3 2816.0 2575.8 7 0.550 0 0.000 0.550

9.7 34048.0 34048.0 34046.7 0.0 272640.0 16777.0 1756416.0 587906.3 20096.0 19235.1 2944.0 2631.8 8 0.720 0 0.000 0.720

10.7 34048.0 34048.0 0.0 34046.2 272640.0 80171.6 1756416.0 664913.4 20352.0 19495.9 2944.0 2657.4 9 0.810 0 0.000 0.810

11.7 34048.0 34048.0 34048.0 0.0 272640.0 129480.8 1756416.0 745100.2 20608.0 19704.5 2944.0 2678.4 10 0.896 0 0.000 0.896

12.7 34048.0 34048.0 0.0 34046.6 272640.0 164070.7 1756416.0 822073.7 20992.0 19937.1 3072.0 2702.8 11 0.978 0 0.000 0.978

13.7 34048.0 34048.0 34048.0 0.0 272640.0 211949.9 1756416.0 897364.4 21248.0 20179.6 3072.0 2728.1 12 1.087 1 0.004 1.091

14.7 34048.0 34048.0 0.0 34047.1 272640.0 245801.5 1756416.0 597362.6 21504.0 20390.6 3072.0 2750.3 13 1.183 2 0.050 1.233

15.7 34048.0 34048.0 0.0 34048.0 272640.0 21474.1 1756416.0 757347.0 22012.0 20792.0 3200.0 2791.0 15 1.336 2 0.050 1.386

16.7 34048.0 34048.0 34047.0 0.0 272640.0 48378.0 1756416.0 838594.4 22268.0 21003.5 3200.0 2813.2 16 1.433 2 0.050 1.484

這段輸出是從JVM啓動後第17秒開始截取的。從中能夠看出,在通過了12次新生代GC後出現了兩次Full GC,共耗時50ms。經過GUI的工具也能夠獲取到一樣的信息,好比說jsonsole或者是jvisualvm

在接受這一結論前,咱們再來看下一樣是此次JVM啓動後所輸出的GC日誌。很明顯-XX:+PrintGCDetails給咱們講述的是一段大相徑庭卻更爲詳盡的故事:

java -XX:+PrintGCDetails -XX:+UseConcMarkSweepGC eu.plumbr.demo.GarbageProducer

3.157: [GC (Allocation Failure) 3.157: [ParNew: 272640K->34048K(306688K), 0.0844702 secs] 272640K->69574K(2063104K), 0.0845560 secs] [Times: user=0.23 sys=0.03, real=0.09 secs]

4.092: [GC (Allocation Failure) 4.092: [ParNew: 306688K->34048K(306688K), 0.1013723 secs] 342214K->136584K(2063104K), 0.1014307 secs] [Times: user=0.25 sys=0.05, real=0.10 secs]

... cut for brevity ...

11.292: [GC (Allocation Failure) 11.292: [ParNew: 306686K->34048K(306688K), 0.0857219 secs] 971599K->779148K(2063104K), 0.0857875 secs] [Times: user=0.26 sys=0.04, real=0.09 secs]

12.140: [GC (Allocation Failure) 12.140: [ParNew: 306688K->34046K(306688K), 0.0821774 secs] 1051788K->856120K(2063104K), 0.0822400 secs] [Times: user=0.25 sys=0.03, real=0.08 secs]

12.989: [GC (Allocation Failure) 12.989: [ParNew: 306686K->34048K(306688K), 0.1086667 secs] 1128760K->931412K(2063104K), 0.1087416 secs] [Times: user=0.24 sys=0.04, real=0.11 secs]

13.098: [GC (CMS Initial Mark) [1 CMS-initial-mark: 897364K(1756416K)] 936667K(2063104K), 0.0041705 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]

13.102: [CMS-concurrent-mark-start]

13.341: [CMS-concurrent-mark: 0.238/0.238 secs] [Times: user=0.36 sys=0.01, real=0.24 secs]

13.341: [CMS-concurrent-preclean-start]

13.350: [CMS-concurrent-preclean: 0.009/0.009 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]

13.350: [CMS-concurrent-abortable-preclean-start]

13.878: [GC (Allocation Failure) 13.878: [ParNew: 306688K->34047K(306688K), 0.0960456 secs] 1204052K->1010638K(2063104K), 0.0961542 secs] [Times: user=0.29 sys=0.04, real=0.09 secs]

14.366: [CMS-concurrent-abortable-preclean: 0.917/1.016 secs] [Times: user=2.22 sys=0.07, real=1.01 secs]

14.366: [GC (CMS Final Remark) [YG occupancy: 182593 K (306688 K)]14.366: [Rescan (parallel) , 0.0291598 secs]14.395: [weak refs processing, 0.0000232 secs]14.395: [class unloading, 0.0117661 secs]14.407: [scrub symbol table, 0.0015323 secs]

14.409: [scrub string table, 0.0003221 secs][1 CMS-remark: 976591K(1756416K)] 1159184K(2063104K), 0.0462010 secs] [Times: user=0.14 sys=0.00, real=0.05 secs]

14.412: [CMS-concurrent-sweep-start]

14.633: [CMS-concurrent-sweep: 0.221/0.221 secs] [Times: user=0.37 sys=0.00, real=0.22 secs]

14.633: [CMS-concurrent-reset-start]

14.636: [CMS-concurrent-reset: 0.002/0.002 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

從以上可以看出,在運行了12次新生代GC後的確出現了一些「不太尋常」的事情。但並非執行了兩次Full GC,這個「不尋常」的事情其實只是在年老代中執行了一次包含了數個階段的GC而已:

  • 初始標記階段,從0.0041705 秒或者說4ms的時候開始。這一階段是一次「stop-the-world」事件,全部的應用線程都被暫停以便進行初始標記。
  • 併發地執行標記和預清理(Preclean)的階段。這是和應用線程一塊兒併發執行的。
  • 最終標記階段,從0.0462010秒或者說46毫秒的時候開始。這一階段也一樣是「stop-the-world」的。
  • 併發地進行清除操做。正如名字所說的,這一階段也無需中斷應用線程,能夠併發地執行。

所以咱們從實際的GC日誌中所看到的是這樣——其實沒有什麼兩次所謂的Full GC,只有一次清理年老代空間的Major GC而已。

若是你再看下jstat輸出的結果,就不可貴出結論了。它確切地指出了兩次stop-the-world事件,總耗時50ms,這段時間內全部活躍線程都會出現延遲響應。不過若是你想據此來優化吞吐量的話,極可能會徒勞無功——jstat只列出了兩次stop-the-world的初始標記及最終標記的部分,而併發執行的那部分工做卻被它給隱藏掉了。

英文原文連接

相關文章
相關標籤/搜索