其實,對于寫代碼來說,也有垃圾回收這個問題,這裡所說的垃圾,指的是程式中不再需要的記憶體空間,垃圾回收指的是回收這些不再需要的記憶體空間,讓程式可以重新利用這些釋放的記憶體空間。
那麼垃圾回收是怎麼,是不采用算法來實作呢?本次課時,我們就一起來探讨 Java 的垃圾回收算法。
标記-清除算法(Mark-Sweep)
标記-清除,顧名思義,先标記垃圾,再清除。它是垃圾回收最基礎的算法,後續很多算法都是基于它上面去改進的。标記-清除算法主要分成兩個階段:
标記階段:需要回收的對象。那麼這個過程其實就是使用可達性分析去判斷一個對象是不是垃圾的過程。
清除階段:标記完成之後,就會統一清理掉要回收的對象。
用圖表示出來大緻如下圖所示:

先去标記哪些對象是存活的,哪些對象是可以回收的。标記完成之後把它回收的對象直接删掉。從這張圖可以看出來。标記清除它存在一定的缺點,标記清除後會産生大量不連續的記憶體碎片,空間碎片太多可能會導緻程式在運作過程中需要配置設定較大對象時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。比如現在假設我們想在記憶體裡面配置設定一段連續三個機關的記憶體空間,要配置設定一個超大的位元組數組,在這樣的記憶體結構下就沒有辦法做到了。
标記-整理算法(Mark-compact)
下面來看一下做标記-整理算法,也有一些文章會把它翻譯成标記-壓縮,本課程裡面統一稱作是标記-整理算法,那麼标記-整理算法是怎玩的。
首先也是标記要回收的對象,這個過程和标記清除是一樣的,但是在标記完成之後并不是直接清除掉要回收的對象,而是把所有的存活對象都壓縮到記憶體的一端,最後在清理掉邊界之外的所有空間,是以不會産生記憶體碎片,提高了記憶體的使用率,這種算法适用于老年代。用圖表示出來大概如下圖所示:
先去标記哪些對象是存活的,哪些對象可以回收,然後把存活的對象往記憶體的一端壓縮,最後再把可以回收的對象除清除。這樣就可以避免記憶體碎片的問題,但是這種方式在标記和整理移動的過程中也是耗時的。
複制算法(Copy)
複制算法大緻上是這樣玩的,把記憶體分成兩塊,每次隻使用其中的一塊,然後把正在使用的這塊記憶體裡面的存活對象複制到不使用的記憶體裡面去,然後再清理掉正在使用的這塊記憶體裡面的所有對象,接着交換兩塊記憶體的角色,等待下一次回收。用圖表示大概如下圖所示:
把一塊記憶體分成了兩塊,每次隻使用其中的一塊,在做垃圾回收的時候,把存活的對象移動到另外一端記憶體裡面去,然後清除掉這塊記憶體裡面的所有對象。那麼在下一次回收的時候也是一樣,把存活的對象移動回來,然後清除掉這一塊記憶體裡面的所有對象。
三種算法對比
以上是三種比較基礎的垃圾回收算法,下面來對比一下這三種算法。
标記-清除算法的優點:實作起來比較簡單。
缺點:存在記憶體碎片。另外使用标記清除算法的時候,配置設定記憶體的速度也會受到影響,這是為什麼呢?你想,假設現在有一個比較大的對象,因為現在有很多碎片化的記憶體空間。那麼想找到一塊連續空間的話,就需要去便利空閑連結清單,進而值查找哪一塊記憶體可以存放這樣的對象。那麼在極端場景下,需要把整個連結清單全部周遊完,才能知道這個對象該配置設定到哪裡去,又或者根本沒有辦法配置設定。那麼周遊空閑連結清單是需要時間的,是以配置設定記憶體的速度會受到影響。
标記-整理算法的優點:沒有碎片。
缺點:整理存在開銷。因為标記整理需要把對象集中起來,放到記憶體的一端,這個過程需要計算以及時間,并且對象越多,占的記憶體越大,整理的開銷也會越大。
複制算法的優點是性能好,沒有碎片。一般來講複制算法比标記-清除算法以及标記-整理算法性能要好一些,因為它不像标記-清除算法或者标記-整理算法那樣,需要标記哪些對象是存活的,哪些對象可以回收,而隻需要找到存活的對象。然後移動這個存活的對象。是以性能上會有一些優勢。
缺點:記憶體使用率低。我們一次隻能使用整個記憶體的一半,記憶體使用率最多隻會達到 50%。
兩種綜合的算法
好,探讨完三種基礎的垃圾回收算法之後,再來探讨 Java 裡面兩種相對綜合的垃圾回收算法。
第一種是分代收集算法。分代收集算法的思路是把一個記憶體分成多個區域,不同的區域使用不同的回收算法去回收。代收集算法比較複雜,而且細節極其之多。我們将在下面詳細讨論。
第二種是增量算法。你想,如果你的記憶體非常的大,如果一次收集所有垃圾,那麼需要耗費的時間就會比較的長,有可能會造成系統長時間的停頓。那麼增量算法的思想是每次隻收集一小片區域記憶體空間的垃圾,這樣就可以減少系統的停頓。
分代收集算法
目前各種商業虛拟機,它的堆記憶體的回收基本上都采用了分代收集的方式。是以可想而知,分代收集算法有多麼重要。
現在設計算法的思想是根據對象的存活周期,把記憶體分成多個區域,然後不同的區域使用不同的垃圾回收算法去回收對象。Java 把堆分成了新生代和老年代。下圖前面探讨 Java 記憶體結構的時候已經詳細介紹過了。
那麼經過分代之後啊,垃圾回收可以分成以下幾類:
一是新生代的回收(Minor GC 或者 Young GC)。
二是老年代的回收(Major GC)。
三是整個堆的回收(Full GC)。
那麼由于執行 Major GC 的時候,一般也會伴随着一次 Minor GC,是以可以認為 Major GC ≈ Full GC 。那麼很多時候,程式員在聊 Major GC 以及 Full GC 的時候,聊的就是一件事兒。
下面來看一下對象是怎麼樣配置設定到堆記憶體的,我們還是對照堆記憶體的結構去講解。對象在建立的時候會先存放到 Eden。當 Eden 滿了之後就會觸發垃圾回收,這個回收的過程是把 Eden 裡面存活的對象拷貝的存活區裡面的 From Survivor 或者是 To Survivor 裡面去。
比如第一次回收 From Survivor 裡面去了,那麼下一次回收就會把 From Survivor 裡面存活的對象拷貝到 To Survivor 裡面去,再下一次就會把 From Survivor 面裡面存活的對象拷貝的 From Survivor 裡面去,周而複始。
不難發現這個回收的過程使用了複制算法,這也是為什麼新生代要有兩個 Survivor 的原因。因為複制算法需要把一個記憶體分成兩塊。那麼對象每經曆一次垃圾回收之後,如果還存活的話,它的年齡就會增加 +1。當對象的年齡達到門檻值的話,預設情況下是 15,就會晉升到老年代。
老年代裡面的對象存活率一般是比較高的,因為你想,都回收 15 次了,還是沒能回收得了,是以後面繼續存活的可能性依然是比較大的。
那麼老年代的對象一般會使用标記-清除算法或者是标記-整理算法去進行回收。這裡需要說明一下,這個對象配置設定的過程隻是一個典型的配置設定流程,實際情況是存在例外的:
一是,一個建立對象,并利率總是會配置設定到 Eden,也可能會直接進入到老年代。主要有兩種場景。第一,如果你的對象大小,大于這個門檻值就會直接配置設定到老年代。當然了,預設情況下這個參數的值是零,也就是說不做限制,所有的對象都會優先在 Eden 裡面配置設定。第二,如果你的對象非常的大,比方說一個超大的數組,新生代的空間根本都不夠,那麼這個時候也會直接把這個對象放到老年代去擔保。之是以要允許對象直接配置設定到老年代,主要是因為新生代采用的是複制算法,在 Eden 裡面配置設定大對象的話,将會導緻 Eden 和兩個 Survivor 區之間大量的記憶體拷貝。
二是,對象也不一定要達到年齡門檻值,才會進入到老年代。虛拟機有一個動态年齡的概念,就是說如果存活區裡面,所有相同年齡對象的大小的總和已經大于 Survivor 空間的一半了。這個時候,如果某個對象的年齡大于這個年齡的話,會直接進入老年代,比如有一堆的對象,它的年齡值是 9,年齡都是 9 的所有對象它的總和以及大于 Survivor 空間的一半了,那麼年齡大于 9 的對象就會直接進入老年代。
觸發垃圾回收的條件
下面我們來看一下不同區域觸發垃圾回收的條件:
先來看一下新生代回收的觸發條件,Eden 空間不足就會進行 Minor GC 回收新生代。
再來看一下 Full GC 的觸發條件,Full GC 的觸發條件要複雜一些,主要有這麼幾種場景:
一是,老年代空間不足,老年代代空間不足又分為兩種情況:第一是空間真的不足了。第二是記憶體碎片,導緻沒有連續的記憶體去配置設定對象,觸發 Full GC。
二是,元空間不足。
三是,在某一次新生代回收之後,要晉升到老年代的對象所占用的空間大于了老年代的剩餘空間,這個時候也會觸發 Full GG。
四是,顯式調用了 System.gc()。System.gc() 的作用是建議垃圾回收容器直徑垃圾回收,這個代碼是會觸發 Full GC 的,你也可以使用這個參數:-XX:DisableExplicitGC 去忽略掉 System.gc() 的調用。
好,介紹到這裡可以簡單總結一下,分代收集算法是根據對象的生命周期,把記憶體做的分代。然後在配置設定對象的時候,把不同生命周期的對象放在不同代裡面,不同的代上選用合适的回收算法進行回收。
比如,新生代裡面的對象存活周期一般都比較的短,每次垃圾回收的時候都會發現有大量的對象死去。那麼 IBM 有做過研究,98% 以上的對象都是會很快消亡的,隻有少量的對象能夠存活,是以新生代可以使用複制算法來完成垃圾收集。而老年代裡面的對象存活率比較高,是以就采用标記-清除算法或者是标記-整理算法進行回收。
那麼相比單純的标記-清除算法、标記-整理算方法以及複制算法三代帶來了什麼好處呢?
首先,分代可以更有效的清除不需要的對象,對于生命周期比較短的對象,對象還處于新生代的時候就會被回收掉了。
其次,分代提升的垃圾回收的效率。如果不做分代的話,那麼需要掃描整個堆裡面的對象。而現在的話隻要掃描新生代或者老年代就可以了。
總結
好,簡單總結一下,本課時課我們探讨了五種垃圾口的算法。基礎的垃圾回收算法有标記-清除算法、标記-整理以及複制算法。另外還探讨了兩種綜合性的垃圾回收算法,即分代收集算法以及增量算法,同時詳細探讨分代收集算法。
最後來總結一下分代收集算法的調優原則:
第一,要合理的設定 Survivor 區的大小,因為 Survivor 區對記憶體的使用率不高。如果配置的過大的話,記憶體浪費就會比較嚴重。
第二,要讓 GC 盡量的發生在新生代,也就是讓 GC 停留在 Minor GC 的級别。
盡量減少 Full GC 的發生。
參數 | 作用 | 預設 |
---|---|---|
-XX:NewRatio=n | 老年代:新生代記憶體大小比值 | 2 |
-XX:SurvivorRatio=n | 伊甸園:survivor區記憶體大小比值 | 8 |
-XX:PretenureSizeThreshold=n | 對象大小該值就在老年代配置設定,0表示不做限制 | |
-Xms | 需小堆記憶體 | - |
-Xmx | 最大堆記憶體 | |
-Xmn | 新生代大小 | |
-XX:+DisableExplicitGC | 忽略掉 System.gc() 的調用 | 啟用 |
-XX:NewSize=n | 新生代初始記憶體大小 | |
-XX:MaxNewSize=n | 新生代最大記憶體 |