為什么要回收
我們知道,程序在運行的時候,為了提高性能,大部分數據都是會加載到內存中進行運算的,有些數據是需要常駐內存中的,但是有些數據,用過之后便不會再需要了,我們稱這部分數據為垃圾數據。舉個例子:有些數據new了之后,經過兩行的運算就結束生命周期了,這個就算是垃圾數據。
為了防止內存被使用完,我們需要將這些垃圾數據進行回收,即需要將這部分內存空間進行釋放,所以Java 虛擬機(JVM)提供了一種自動回收內存的機制 (GC)。
簡介
本文所介紹的垃圾回收(GC)是由 Java 虛擬機(JVM)垃圾回收器提供的一種對內存回收的一種機制,它一般會在內存空閑或者內存占用過高的時候對那些沒有任何引用的對象不定時地進行回收。
所以由上述討論我們很容易就會有如下的疑問,下文也會依照這幾點疑問來進行深入探討:
JVM 內存模型
JVM 內存大致分為 線程私有區域 和 線程共享區域,並且其主要由5個區域組成,見下圖:
由上圖可以看出,右邊3個:虛擬機棧、本地方法棧和程序計數器,這三個區域是線程私有的。比如棧幀的生命周期是和線程關聯的,即隨線程而生,隨線程而死。
(棧幀:也叫過程活動記錄,是編譯器用來實現過程/函數調用的一種數據結構,
每一次函數的調用,都會在調用棧
(call stack)上維護一個獨立的棧幀
(stack frame)每個獨立的棧幀一般包括:
- 函數的返回地址和參數
- 臨時變量:包括函數的非靜態局部變量以及編譯器自動生成的其他臨時變量
- 函數調用的上下文
)
虛擬機棧其實就是用來描述 Java 方法執行的,所以每個方法執行的時候都會創建一個棧幀,每個棧幀都包含:局部變量、操作數棧、動態鏈接、方法出口,當方法執行完成之后,對應的棧幀便會出棧。所以它的內存分配是具備確定性的。
虛擬機棧分配的內存具有確定性。私有線程區也就是右邊3塊的內存不需要太過關注,線程沒了就清理掉了。
左邊兩塊:方法區和堆內存,則是我們需要重點關注的對象。因為這兩個區域主要存放對象、數組等不具有確定性的數據,例如創建對象,每個方法運行的過程中創建的對象的數量是不確定的,即占用的內存是不確定的,可能不需要創建對象,也可能會創建很多對象,所以我們需要一套合理的內存管理機制來對這兩個區域進行維護,因此,垃圾回收就應運而生了,並且這兩個區域也是垃圾回收器進行垃圾回收的最重要的內存區域。
我們再來對堆內存和方法區進行一下划分,因為 JVM 是采用分代回收的算法,即根據對象的生命周期進行區分並進行分代存儲和回收,其主要分為年輕代、老年代、持久代,見下圖:
堆內存主要由年輕代和老年代組成,而方法區主要存儲持久代的數據,詳細的細節在下文講回收算法的時候會細說。
注意:從 JDK 1.8 開始,永久代已經被移除了,取而代之的是元空間(Meta Space),它和服務器的內存相關聯,本文暫不贅述。
內存中的垃圾
定義:程序在運行過程中會創建對象,但是當方法執行完成或當這個對象使用完畢之后,它便被定義為了“垃圾”。
判定一個對象是否是“垃圾”,即判定一個對象的存活與否,常見的算法有兩種:引用計數法 和 根搜索算法。
引用計數算法(Reference Counting Collector)
一個對象被創建之后,系統會給這個對象初始化一個引用計數器,當這個對象被引用了,則計數器 +1,而當該引用失效后,計數器便 -1,直到計數器為 0,意味着該對象不再被使用了,則可以將其進行回收了。
這種算法其實很好用,判定比較簡單,效率也很高,但是卻有一個很致命的缺點,就是它無法避免循環引用,即兩個對象之間循環引用的時候,各自的計數器始終不會變成 0,所以 引用計數算法 只出現在了早期的 JVM 中,現在基本不再使用了。
根搜索算法(Tracing Collector)
根搜索算法的中心思想,就是從某一些指定的根對象(GC Roots)出發,一步步遍歷找到和這個根對象具有引用關系的對象,然后再從這些對象開始繼續尋找,從而形成一個個的引用鏈(其實就和圖論的思想一致),然后不在這些引用鏈上面的對象便被標識為引用不可達對象,也就是我們說的“垃圾”,這些對象便需要回收掉。這種算法很好地解決了上面 引用計數算法 的循環引用的問題了。
算法的核心思想是很簡單的,就是標記不可達對象,然后交由 GC 進行回收,但是有一個點是很重要的,那就是 何為根對象(GC Roots)?
根對象,一般有如下幾種:
- 虛擬機棧中引用的對象(棧幀中的本地變量表);
- 方法區中常量引用的對象;
- 方法區中靜態屬性引用的對象;
- 本地方法棧中 JNI(Native 方法)引用的對象;
- 活躍線程。
但其實,上述算法只是一個算法的中心思想,實際執行過程是比這個復雜的,另外,GC 判斷對象是否可達其實看的還是強引用。
1、進行根搜索的時候,是需要暫停所有線程的,即執行一次 STW(Stop The World),最主要的目的是防止上述的對象圖在算法運行的過程中有變化從而影響算法的准確性。
2、線程暫停的時間長短,取決於對象的多少,和堆內存的大小無關。
3、 宣告一個對象的“死亡”其實不僅僅通過上述的算法計算,而是需要經歷兩次的標記,本文暫不進行贅述。
回收算法
除了需要上文研究的標記“垃圾對象”的算法,我們也需要“清理垃圾”的 回收算法。
常用的回收算法一般有:標記-清除算法、標記-整理算法、復制算法,以及系統自動進行判定使用的 適應性算法。
1、標記 - 清除算法(Tracing Collector)
標記-清除 算法是最基礎的收集算法,它是由 標記 和 清除 兩個步驟組成的。
標記的過程其實就是上面的 根搜索算法 所標記的不可達對象,當所有的待回收的“垃圾對象”標記完成之后,便進行第二個步驟:統一清除。
該算法的優點是當存活對象比較多的時候,性能比較高,因為該算法只需要處理待回收的對象,而不需要處理存活的對象。
但是缺點也很明顯,就是在執行完 標記-整理 之后,由於將“垃圾對象”回收掉了,所以原本連續使用的內存塊便會變得不連續,這樣會導致內存塊上面會出現很多小單元的內存區域,這些小單元的內存區域只能夠存放比較小的對象,而比較大的對象是無法直接存儲的。
即原本空閑 1M 的內存區域,有可能會出現無法直接存放 0.9M 大小的對象。
2、標記 - 整理算法(Compacting Collector)
上述的 標記-清除 算法會產生內存區域使用的間斷,所以為了將內存區域盡可能地連續使用, 標記-整理 算法應運而生。
標記-整理 算法也是由兩步組成,標記 和 整理。
第一步的 標記 動作也是使用的 根搜索算法,但是在標記完成之后的動作卻和 標記-清除算法 天壤之別,該算法並不會直接清除掉可回收對象 ,而是讓所有的對象都向一端移動,然后將端邊界以外的內存全部清理掉。
該算法所帶來的最大的優勢便是使得內存上面不會再有碎片問題,並且新對象的分配只需要通過簡單的指針碰撞便可完成。
3、復制算法(Copying Collector)
無論是標記-清除算法還是垃圾-整理算法,都會涉及句柄的開銷或是面對碎片化的內存回收,所以,復制算法 出現了。
復制算法將內存區域均分為了兩塊(記為S0和S1),而每次在創建對象的時候,只使用其中的一塊區域(例如S0),當S0使用完之后,便將S0上面存活的對象全部復制到S1上面去,然后將S0全部清理掉。
復制算法的優勢是:① 不會產生內存碎片;② 標記和復制可以同時進行;③ 復制時也只需要移動棧頂指針即可,按順序分配內存,簡單高效;④ 每次只需要回收一塊內存區域即可,而不用回收整塊內存區域,所以性能會相對高效一點。
但是缺點也是很明顯的:可用的內存減小了一半,存在內存浪費的情況。
所以 復制算法 一般會用於對象存活時間比較短的區域,例如 年輕代,而存活時間比較長的 老年代 是不適合的,因為老年代存在大量存活時間長的對象,采用復制算法的時候會要求復制的對象較多,效率也就急劇下降,所以老年代一般會使用上文提到的 標記-整理算法。
4、適應性算法(Adaptive Collector)
適應性算法 其實不是一種單獨的回收算法,他只是一種智能選擇回收算法的機制,也就是該算法會根據堆內存具體的使用情況而自動選用更適合當前情況的回收算法。
5、分代回收
分代回收 並不是一種垃圾回收算法,它是上述各種垃圾回收算法的一個落地應用方案。
因為上述各個算法都有各自的優勢,我們在內存的使用過程中,有些對象存活時間長,有些對象存活時間短,有些對象甚至一直存活着,所以根據對象的存活周期,我們將內存區域分為三大塊:年輕代、老年代 和 永久代,並且年輕代也繼續細分為:Eden區、S0 和 S1。
1、各個內存區域的內存大小可以見上文中的內存模型圖,當然,我們也可以給 JVM 傳遞參數來進行調整,這些內容本文也暫不贅述。
2、 Eden : S0 : S1 的默認比例為 8:1:1,為什么這么設計呢?其實 IBM 有專門的研究表明,年輕代中 98% 的對象都是朝生夕死的,所以只需要划分為一個較大的 Eden 區和兩個較小的 Survivor 區即可,而且這樣做的好處是只有 10% 的 Survivor 區會被浪費掉,這也是可以接受的。
下面簡單介紹下各個內存區的 GC 過程:
- 對象首次創建進行內存分配的時候,首先會放置在 Eden 區,當 Eden 區放滿了或者當該對象太大無法放進 Eden 區的時候,此時會對年輕代(Eden區 和 S0)進行一次 GC,將幸存下來的對象放置在 S1,然后清空掉 Eden區和 S0 區;(此時年輕代采用的是 復制算法)
- 在上面第一步中對年輕代進行垃圾回收的時候,同時會對幸存的對象進行標記,統計每個幸存對象經歷的 GC 次數;
- 當 S1 區滿了之后,或者年輕代的對象經歷過指定次數的 GC 之后,這部分對象會被放置到老年代之中;
- 當老年代也滿了之后,便會對老年代進行一次 GC;(老年代采用的是 標記-整理算法)
五、垃圾回收器
好了,上文介紹過了 “垃圾”的識別算法 和 “垃圾”的回收算法,那么這些算法的執行者是誰呢?就是下文介紹的 垃圾回收器(GC) 了。
1、垃圾回收器的類型
在 Java 語言中,垃圾回收器按照執行機制來進行划分,主要分為四種類型:
- 串行垃圾回收器(Serial Garbage Collector);
- 並行垃圾回收器(Parallel Garbage Collector);
- 並發標記掃描垃圾回收器(CMS Garbage Collector);
- G1垃圾回收器(G1 Garbage Collector)。
上述四種垃圾回收器都是有各自的優缺點的,我們可以通過向 JVM 傳遞參數來指定其中一款垃圾回收器。
1、串行垃圾回收器(Serial Garbage Collector)
串行垃圾回收器會暫停所有的應用程序線程,並采用單獨的的線程進行 GC。
適用於單 CPU、並且對應用程序的暫停時間要求不高的情況,所以不太適合當前的生產環境。
2、並行垃圾回收器(Parallel Garbage Collector)
並行垃圾回收器是 JVM 默認的垃圾回收器,相較於串行垃圾回收器而言性能稍有提升,它也是需要暫停所有的應用程序線程的,但是區別是它會使用多線程進行 GC。
所以並行垃圾回收器適用於多 CPU 的服務器、並且能接受短暫的應用暫停的程序。
3、並發標記掃描垃圾回收器(CMS Garbage Collector)
CMS 回收器也是一種並行的垃圾回收器,它會采用多線程來進行掃描堆內存,標記需要清理的對象並將這些對象清理掉。
但是 CMS 它需要更多的 CPU 來保證程序的吞吐量,並且它保證了最短的回收停頓時間,所以,在服務器允許的情況下,為了達到更到的性能,我們應該使用 CMS 來代替默認的 並行垃圾回收器。
4、G1 垃圾回收器(G1 Garbage Collector)
G1 垃圾回收器是在 JDK1.7 中才正式引入的一款垃圾回收器,“科技在進步,所以一般越是先進的技術一般會更好用並且會替代陳舊的技術”,好了,玩笑歸玩笑,但是 G1 的引入,目的就是為了取代 CMS 的。
不要被上面 G1 的示意圖誤導, G1 並沒有將內存進行物理划分,它只是將堆內存划分為一個個的 Region,但是也是屬於分代垃圾回收器,G1 仍然會區分年輕代和老年代,並且年輕代仍然會有 Eden 區和 Survivor 區。
這么做的目的是保證 G1 回收器在有限的時間內可以獲得盡可能高的回收效率。
2、HotSpot 虛擬機(HotSpot VM)提供的幾種垃圾收集器
HotSpot VM 提供了 7 種垃圾收集器,分別為:
- Serial
- PraNew
- Parallel Scavenge
- Serial Old
- Parallel Old
- CMS
- G1
其中,1、2、3 種適合年輕代內存區的垃圾回收,4、5、6種適合老年代內存區的垃圾回收,並且它們之間是兩兩組合來進行使用的,詳見下圖:
六、垃圾回收的時機
垃圾回收分為兩種,Full GC 和 Scavenge GC。
Full GC 發生在整個堆內存中,而 Scavenge GC 僅僅發生在年輕代的 Eden 區,所以我們應該盡可能地減少 Full GC 的次數,當然,對於 JVM 的調優,很多情況下也是在想辦法對 Full GC 進行調優。
因為 GC 是可能會對應用程序造成影響的,所以觸發 GC 也是有一定的條件的,例如:
- 當應用程序空閑時,GC 有可能會被調用,因為 GC 運行線程的優先級是相對較低的,所以當線程忙的時候,它是不會運行的,當然,內存不足的情況除外;
- 堆內存不足的時候,GC 會被調用。例如創建對象的時候,若此時內存不足,則會觸發 GC 用來給這個對象分配合適的內存,當進行完一次 GC 之后內存還是不足,則會繼續進行第二次 GC,若第二次 GC 之后內存還是不足,則一般會提示 “out of memory”異常;
小 Tip:
System.gc()
方法會顯示觸發 Full GC,但是它只是對 JVM 的一個 GC 請求,至於何時觸發,還是由 JVM 自行判斷的。
GC 的調用開銷是比較大的,所以我們需要有針對性地進行調優,一般有如下方案:
- 不要顯式調用
System.gc()
。此函數雖然是建議 JVM 進行 GC,但很多情況下它會觸發 GC,從而增加 GC 的頻率; - 盡量減少臨時對象的使用。在方法結束后,臨時對象便成為了垃圾,所以減少臨時變量的使用就相當於減少了垃圾的產生,從而減少了GC的次數;
- 對象不用時最好顯式置為 Null。一般而言,為 Null 的對象都會被作為垃圾處理,所以將不用的對象顯式地設為 Null 有利於 GC 收集器對垃圾的判定;
- 盡量使用 StringBuilder 來代替 String 的字符串累加。因為 String 的底層是 final 類型的數組,所以 String 的增加其實是建了一個新的 String,從而產生了過多的垃圾;
- 允許的情況下盡量使用基本類型(如 int)來替代 Integer 對象。因為基本類型變量比相應的對象占用的內存資源會少得多;
- 合理使用靜態對象變量。因為靜態變量屬於全局變量,不會被 GC 回收;
七、其它
JVM 的 GC,它就像能看到也能感受到的真實存在的事物,但是當我們去伸手夠它的時候,此時它又是虛無縹緲般的存在,處理它的時候還需要格外地謹慎。
因為它的不確定性,所以我們不應該去假定 GC 觸發的時間,也不要去使用類似 System.gc()
這樣顯示調用 GC 的方法,這些都是得不償失的。
最需要注意的是我們的編程習慣和編程態度,良好的編程習慣能夠幫助我們規避掉很多內存方面的問題,包括但不僅限於內存泄露等。
最后,由於垃圾回收器眾多,在特定的情況下,我們是可以指定使用垃圾回收器的類型的,例如使用:-X:+UseG1GC
來指定使用 G1 垃圾回收器。