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