JVM(4):垃圾回收器和內存分配策略

GC(Garbage Collection)的歷史比java 久遠。1960 年誕生於MIT 的Lisp 是第一門真正使用內存動態分配和垃圾收集技術的語言。GC 一直致力於解決的問題:

哪些內存需要回收(what)?

什麼時候回收(when)?

如何回收(how)?

在虛擬機運行時數據區中,最頻繁使用的是堆,管理的內存最大的一塊,還是堆.JVM 堆是垃圾收集器管理的主要區域.因此很多時候也被稱作"GC 堆。

 

1.爲什麼要了解垃圾回收

經過半個多世紀的發展,目前內存的動態分配與內存回收技術已經相當成熟,一切看起來都進入了"自動化"時代,那爲什麼我們還要去了解GC 和內存分配呢?

當需要排查各種內存溢出,內存泄漏問題時,當垃圾收集成爲系統達到更高併發量的瓶頸時,我們就需要對這些"自動化"的技術實施必要的監控和調節。

程序計數器、虛擬機棧、本地方法棧3 個區域隨線程生滅(因爲是線程私有),棧中的棧幀隨着方法的進入和退出而有條不紊地執行着出棧和入棧操作。所以這三塊區域的內存,我們不需要過多考慮回收問題。

Java 堆以及方法區則不一樣,一個接口中的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存也可能不一樣,我們只有在程序處於運行期才知道那些對象會創建,這部分內存的分配和回收都是動態的,垃圾回收器所關注的就是這部分內存。

 

2.對象存活判斷

垃圾回收器,會針對沒有任何引用的”死去”的對象進行回收.垃圾收集器在對堆進行回收前,第一件事情就是要確定這些對象之後哪些還"存活",哪些已經變成垃圾"死去"。

2.1 引用計數算法

給對象添加一個引用計數器.有一個地方引用時,計數器+1,失去引用,計數器-1.爲0 表示就是垃圾.但是難以解決循環引用問題.比如A 只有B,B 只有A.主流的java 虛擬機沒有選用引用計數算法來判定對象的生死。

測試代碼:

public class ReferenceCountingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;
    // 使用該變量表示在內存中佔用空間,以便可以方便觀察日誌.
    private byte[] bigSize = new byte[2 * _1MB];
    public static void main(String[] args) {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        // 設置兩個變量爲null,那麼對象理論上來說就是垃圾.
        objA = null;
        objB = null;
        // 假設這裏發生GC,查看日誌,觀察兩個對象是否被回收.
        System.gc();
    }
}

運行參數:-XX:+PrintGCDetails

測試結果:

[GC [PSYoungGen: 5427K->584K(38400K)] 5427K->584K(124416K), 0.0010157 secs] [Times: user=0.00 sys=0.00,
real=0.00 secs]
[Full GC [PSYoungGen: 584K->0K(38400K)] [ParOldGen: 0K->465K(86016K)] 584K->465K(124416K) [PSPermGen:
2557K->2556K(21504K)], 0.0076511 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 38400K, used 998K [0x00000007d5e80000, 0x00000007d8900000, 0x0000000800000000)
eden space 33280K, 3% used [0x00000007d5e80000,0x00000007d5f79a60,0x00000007d7f00000)
from space 5120K, 0% used [0x00000007d7f00000,0x00000007d7f00000,0x00000007d8400000)
to space 5120K, 0% used [0x00000007d8400000,0x00000007d8400000,0x00000007d8900000)
ParOldGen total 86016K, used 465K [0x0000000781c00000, 0x0000000787000000, 0x00000007d5e80000)
object space 86016K, 0% used [0x0000000781c00000,0x0000000781c74508,0x0000000787000000)
PSPermGen total 21504K, used 2563K [0x000000077ca00000, 0x000000077df00000, 0x0000000781c00000)
object space 21504K, 11% used [0x000000077ca00000,0x000000077cc80f38,0x000000077df00000)

可以先嚐試性的看一下GC 的日誌,可以發現,在年輕代,發生了GC 之後,年輕代的空間使用爲0,的的確確表示對象被GC 了.所以從
這個案例我們可以證明,JVM 虛擬機沒有使用引用計數算法。

 

2.2 可達性算法

通過一系列的‘GC Roots’ 的對象作爲起始點,從這些節點出發所走過的路徑稱爲引用鏈。當一個對象到GC Roots 沒有任何引用鏈相連的時候說明對象不可用,如下圖,GC Roots 對象不可達的對象,就是可以回收的垃圾對象。

 

可作爲GC Roots 的對象:

1.虛擬機棧(棧幀中的本地變量表)中引用的對象

2.方法區中類靜態屬性引用的對象

3.方法區中常量引用的對象

4.本地方法棧中JNI(即一般說的Native 方法) 引用的對象

 

2.2.1 HotSpot 的可達性分析

HotSpot 虛擬機在實現上面的算法的時候,必須要經過更嚴格的考量,才能保證虛擬機高效運行.比如,在上面的可達性分析中,就存在執行效率的問題:

1.從GC Roots 節點找引用鏈,可是現在很多應用的引用比較複雜,比如方法區就有數百兆,如果要逐個檢查這裏面的引用,必然消耗很多的時間。

2.爲了保證整個分析期間整個執行系統被凍結,而不產生新的引用,會導致java 執行線程停頓(stop the world)。

爲了解決上面的兩個問題:

1.枚舉根節點,使用一組OopMap 的數據結構來存放對象引用,這個數據結構在類加載完成的時候,就已經計算出來了,GC 在掃描的時候就可以得知這些信息,從而降低GC Roots 時間以及減少停頓時間。

2.OopMap 中的引用關係可能會變化.或者OopMap 的指令太多,反而需要更多的空間.此時解決方案是,OopMap 會根據虛擬機選定的安全點(safepoint,可以簡單理解爲執行到哪一行),在這個安全點內去生成指令的OopMap.在GC 的時候,驅使所有的線程都"跑"到最近的安全點,STW 才發生,應用才停頓。

3.對於掛起的線程來說,比如處於sleep 或者blocked 狀態的,是不能"跑"到安全點的,那麼此時解決方案就是,增大安全域(SafeRegion).如果線程已經達到安全域,做一個標記,GC 就不需要管這些線程。

 

2.3 對象自我拯救

即使在可達性分析算法中不可達的對象,也並非是“非死不可”的,這時候它們暫時出於“緩刑”階段,一個對象的真正死亡至少要經歷兩次標記過程:

1.如果對象在進行中可達性分析後發現沒有與GC Roots 相連接的引用鏈,那他將會被第一次標記並且進行一次篩選,篩選條件是此對象是否有必要執行finalize() 方法。當對象沒有覆蓋finalize() 方法,或者finalize() 方法已經被虛擬機調用過,虛擬機將這兩種情況都視爲“沒有必要執行”,這種情況就活不了。

2.如果這個對象被判定爲有必要執行finalize() 方法,那麼這個對象竟會放置在一個叫做F-Queue 的隊列中,並在稍後由一個由虛擬機自動建立的、低優先級的Finalizer 線程去執行它。這裏所謂的“執行”是指虛擬機會出發這個方法,並不承諾或等待他運行結束。finalize() 方法是對象逃脫死亡命運的最後一次機會,稍後GC 將對F-Queue 中的對象進行第二次小規模的標記,如果對象要在finalize() 中成功拯救自己—— 只要重新與引用鏈上的任何一個對象簡歷關聯即可。

finalize() 方法只會被系統自動調用一次。

建議大家儘量避免使用它,因爲他不是c/c++的析構函數,而是java 剛誕生的時候爲了使c/c++程序員更容易接受它所作出的一個妥協.代價高昂,不確定性大,無法保證各個對象的調用順序.大家完全可以忘掉java 語言中有這個方法的存在。

 

2.4 再談引用

無論引用計數算法或者是可達性分析算法,都是用的是引用.在JDK1.2 之前,java 中的引用定義爲,如果reference 類型的數據中存儲的數值代表的是另外一塊內存的起始地址,就稱這塊內存代表着一個引用.在JDK1.2 之後,java 對引用的概念進行了擴充。

1.強引用:類似於Object obj = new Object(); 創建的,只要強引用在就不回收。

2.軟引用:SoftReference 類實現軟引用。在系統要發生內存溢出異常之前,將會把這些對象列進回收範圍之中進行二次回收。

3.弱引用:WeakReference 類實現弱引用。對象只能生存到下一次垃圾收集之前。在垃圾收集器工作時,無論內存是否足夠都會回收掉只被弱引用關聯的對象。

4.虛引用:PhantomReference 類實現虛引用。無法通過虛引用獲取一個對象的實例,爲一個對象設置虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知。

我們可以使用上面四種引用,根據內存是足夠或者緊張的應用場景,具體的來選擇使用何種引用方式。

 

3.方法區回收

方法區(Hotspot 中的永久代),也是可以存在垃圾回收的,儘管在java 虛擬機規範中確實表示可以不要求虛擬機在方法區實現垃圾回收。且性價比比較低。我們可以通過配置相關參數,在何時的時候來回收永久代,以保證不會溢出。

1.永久代回收信息:廢棄的常量和無用的類。

廢棄的常量:沒有任何地方使用常量引用,也沒有任何地方使用常量對應的字面量。

無用的類:該類所有的實例被回收,加載該類的類加載器被回收.該類的字節碼對象沒有被任何地方引用。

2.在大量使用反射,動態代理,CGLib 等ByteCode 框架,動態生成JSP 以及OSGi 這類頻繁自定義ClassLoader 的場景都需要虛擬機具備類卸載的功能。

 

4.垃圾收集算法

知道了要回收哪些對象之後,就是具體的垃圾回收了.虛擬機規範中沒有限定只能使用何種方式去清理垃圾,所以,不同的虛擬機可以使用不同的回收算法或者組合使用。

4.1標記-清除

標記-清除”(Mark-Sweep)算法,如它的名字一樣,算法分爲“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成後統一回收掉所有被標記的對象。之所以說它是最基礎的收集算法,是因爲後續的收集算法都是基於這種思路並對其缺點進行改進而得到的。

它的主要缺點有兩個:一個是效率問題,標記和清除過程的效率都不高;另外一個是空間問題,標記清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致,當程序在以後的運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作。

此算法需要暫停整個應用(Stop The World)。

 

4.2複製

“複製”(Copying)的收集算法,它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。

這樣使得每次都是對其中的一塊進行內存回收,內存分配時也就不用考慮內存碎片等複雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。只是這種算法的代價是將內存縮小爲原來的一半,持續複製長生存期的對象則導致效率降低。

 

4.3標記-整理

“標記-整理”(Mark-Compact)算法,標記過程仍然與“標記-清除”算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存。

 

4.4分代收集

GC 分代的基本假設:絕大部分對象的生命週期都非常短暫,存活時間短, 並且不同的對象的生命週期是不一樣的。

“分代收集”(Generational Collection)算法,把Java 堆分爲新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集算法。在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用複製算法,只需要付出少量存活對象的複製成本就可以完成收集。而老年代中因爲對象存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記-清理”或“標記-整理”算法來進行回收。

4.4.1爲什麼要分代

爲什麼需要把堆分代?不分代不能完成他所做的事情麼?其實不分代完全可以,分代的唯一理由就是優化GC 性能。

JVM 在程序運行過程當中,會創建大量的對象,這些對象,大部分是短週期的對象,小部分是長週期的對象,對於短週期的對象,需要頻繁地進行垃圾回收以保證無用對象儘早被釋放掉,對於長週期對象,則不需要頻率垃圾回收以確保無謂地垃圾掃描檢測。如果沒有分代,那我們所有的對象都在一塊,GC 的時候我們要找到哪些對象沒用,這樣就會對堆的所有區域進行掃描.爲解決這種矛盾,Sun JVM 的內存管理採用分代的策略。如果分代的話,我們把新創建的對象放到某一地方,當GC 的時候先把這塊存“朝生夕死”對象的區域進行回收,這樣就會騰出很大的空間出來。

新生代和老年代存在於堆中,而永久代是方法區的實現方式(java7 逐步取代永久代,在java8 之後,完全去掉永久代,使用Metaspace 元數據區)。

 

4.4.2 新生代

新生代/年輕代(Young Gen):年輕代主要存放新創建的對象,內存大小相對會比較小,垃圾回收會比較頻繁。年輕代分成1 個Eden Space 和2 個Survivor Space。

當對象在堆創建時,將進入年輕代的Eden(伊甸園) Space。垃圾回收器進行垃圾回收時,掃描Eden Space 和A Survivor(倖存區) Space,如果對象仍然存活,則複製到B Survivor Space,如果B Survivor Space 已經滿,則複製到Old Gen。同時,在掃描A Survivor Space 時,如果對象已經經過了幾次的掃描仍然存活,JVM 認爲其爲一個持久化對象,則將其移到Old Gen。掃描完畢後,JVM 將Eden Space 和A Survivor Space 清空,然後交換A 和B 的角色(即下次垃圾回收時會掃描Eden Space 和B Survivor Space。這麼做主要是爲了減少內存碎片的產生。

Young Gen 垃圾回收時,採用將存活對象複製到到空的Survivor Space 的方式來確保儘量不存在內存碎片,採用空間換時間的方式來加速內存中不再被持有的對象儘快能夠得到回收。

 

4.4.3 老年代

老年代/年老代(Tenured Gen):年老代主要存放JVM 認爲生命週期比較長的對象(經過幾次的Young Gen 的垃圾回收後仍然存在),內存大小相對會比較大,垃圾回收也相對沒有那麼頻繁(譬如可能幾個小時一次)。年老代主要採用壓縮的方式來避免內存碎片(將存活對象移動到內存片的一邊,也就是內存整理)。當然,有些垃圾回收器(譬如CMS 垃圾回收器)出於效率的原因,可能會不進行壓縮。

 

4.4.4 虛擬機如何管理新生代和老年代

虛擬機一般是這樣管理新生代和老年代的:

1.當一個對象被創建的時候(new)首先會在年輕代的Eden 區被創建,直到當GC 的時候,根據可達性算法,看一個對象是否消亡,沒有消亡的對象會被放入新生代的Survivor 區,消亡的直接被Minor GC(次要的,普通的GC) Kill 掉。

2.進入到Survivor 區的對象也不是安全的,當下一次Minor GC 來的時候還是會檢查Enden 和Survivor 存放對象區域中對象是否存活,存活放入另外一塊Survivor 區域。

3.當2 個Survivor 區切換幾次以後,會直接進入老年代,當然進入到老年代也不是安全的,當老年代內存空間不足的時候,會觸發Major GC(主要的,全局的GC),已經消亡的依然還是被Kill 掉。

 

4.4.5 按系統線程分

串行收集:串行收集使用單線程處理所有垃圾回收工作, 因爲無需多線程交互,實現容易,而且效率比較高。但是,其侷限性也比較明顯,即無法使用多處理器的優勢,所以此收集適合單處理器機器。當然,此收集器也可以用在小數據量(100M 左右)情況下的多處理器機器上。

並行收集:並行收集使用多線程處理垃圾回收工作,因而速度快,效率高。而且理論上CPU 數目越多,越能體現出並行收集器的優勢。

併發收集:相對於串行收集和並行收集而言,前面兩個在進行垃圾回收工作時,需要暫停整個運行環境(STW),而只有垃圾回收程序在運行,因此,系統在垃圾回收時會有明顯的暫停,而且暫停時間會因爲堆越大而越長。

 

5 垃圾回收器

如果說收集算法是內存回收的方法論,那麼垃圾收集器就是內存回收的具體實現.java 虛擬機規範中沒有對垃圾收集器應該如何實

現做任何規定,因此,不同的廠商可能會有很大的差別.

新生代:Serial, ParNew, Parallel Scaveage,G1

年老代:CMS, Serial Old, Parallel Old, G1

組合方式:

Serial + CMS

Serial + Serial Old

ParNew + CMS

ParNew + Serial Old

Parallel Scaveage + Serial Old

Parallel Scaveage + Parallel Old

G1 + G1

5.1 Serial/Serial Old 收集器

串行收集器是最古老,最穩定以及效率高的收集器,可能會產生較長的停頓,只使用一個線程去回收。新生代、老年代使用串行回收;新生代複製算法、老年代標記-壓縮;垃圾收集的過程中會Stop The World(服務暫停)

參數控制:-XX:+UseSerialGC 串行收集器+ Serial Old

 

5.2 ParNew 收集器

ParNew 收集器其實就是Serial 收集器的多線程版本.用戶線程需要等待(STW).

參數控制:-XX:+UseParNewGC ParNew 收集器+ Serial Old

                  -XX:ParallelGCThreads 限制線程數量

 

5.3 Parallel/Parallel Old 收集器

Parallel Scavenge 收集器類似ParNew 收集器,Parallel 收集器更關注系統的吞吐量。可以通過參數來打開自適應調節策略,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或最大的吞吐量;也可以通過參數控制GC 的時間不大於多少毫秒或者比例;新生代複製算法、老年代標記-壓縮

參數控制:-XX:+UseParallelGC 年輕代使用Parallel 收集器+ 老年代串行(Serial Old)

Parallel Old 是Parallel Scavenge 收集器的老年代版本,使用多線程和“標記-整理”算法。這個收集器是在JDK 1.6 中才開始提供。

參數控制: -XX:+UseParallelOldGC 年輕代使用Parallel 收集器+ 老年代並行(ParallelOld)

注意:此時用戶線程處於等待狀態(STW)。

 

5.4 CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間爲目標的收集器。目前很大一部分的Java 應用都集中在互聯網站或B/S 系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗。

從名字(包含“Mark Sweep”)上就可以看出CMS 收集器是基於“標記-清除”算法實現的,它的運作過程相對於前面幾種收集器來說要更復雜一些,整個過程分爲4 個步驟,包括:

初始標記(CMS initial mark)

併發標記(CMS concurrent mark)

重新標記(CMS remark)

併發清除(CMS concurrent sweep)

其中初始標記、重新標記這兩個步驟仍然需要“Stop The World”。初始標記僅僅只是標記一下GC Roots 能直接關聯到的對象,速度很快,併發標記階段就是進行GC Roots Tracing 的過程,而重新標記階段則是爲了修正併發標記期間,因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短。

由於整個過程中耗時最長的併發標記和併發清除過程中,收集器線程都可以與用戶線程一起工作,所以總體上來說,CMS 收集器的內存回收過程是與用戶線程一起併發地執行。是一個老年代收集器(新生代使用ParNew)

優點:併發收集、低停頓

缺點:使用標記--清除算法,產生大量空間碎片、併發階段會降低吞吐量, 對CPU 資源敏感. 無法收集浮動垃圾,需要預留一部分內存在GC 的時候供程序運作,如果預留空間不足,可能會出現”Concurrent Mode Failure”失敗而導致觸發一次Full GC.

參數控制:

-XX:+UseConcMarkSweepGC 使用CMS 收集器

-XX:+ UseCMSCompactAtFullCollection Full GC 後,進行一次碎片整理;整理過程是獨佔的,會引起停頓時間變長

-XX:+CMSFullGCsBeforeCompaction 設置進行幾次Full GC 後,進行一次碎片整理

-XX:ParallelCMSThreads 設定CMS 的線程數量(一般情況約等於可用CPU 數量)

 

5.5 G1 收集器

G1 是目前技術發展的最前沿成果之一,HotSpot 開發團隊賦予它的使命是未來可以替換掉JDK1.5 中發佈的CMS 收集器。與CMS 收集器相比G1 收集器有以下特點:

1. 空間整合,G1 收集器採用標記整理算法,不會產生內存空間碎片。分配大對象時不會因爲無法找到連續空間而提前觸發下一次GC。

2. 可預測停頓,這是G1 的另一大優勢,降低停頓時間是G1 和CMS 的共同關注點,但G1 除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度爲M 毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N 毫秒,這幾乎已經是實時Java(RTSJ)的垃圾收集器的特徵了。

上面提到的垃圾收集器,收集的範圍都是整個新生代或者老年代,而G1 不再是這樣。使用G1 收集器時,Java 堆的內存佈局與其他收集器有很大差別,它將整個Java 堆劃分爲多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔閡了,它們都是一部分(可以不連續)Region 的集合。

優點:並行與併發、分代收集、空間整合、可預測停頓。

步驟:

初始標記(Initial Marking)

併發標記(Concurrent Marking)

最終標記(Final Marking)

篩選回收(Live Data Counting and Evacuation)

 

6 理解GC 日誌

閱讀GC 日誌是處理java 虛擬機內存問題的基本技能,它只是一些人爲確定的規則,沒有太多的技術含量.

在現實應用中,比較常見的組合使用大概就四種:

年輕代和老年代均使用Serial GC。

年輕代和老年代均使用Parallel GC。

年輕代使用ParNew GC,老年代使用CMS 收集器。

不進行年輕代和老年代區分,使用G1 收集器。

爲了觸發各類GC 組合觸發垃圾收集,本文將使用如下代碼:

public class GCLogDetailTest {
    // 1M
    private static int _1MB = 1024 * 1024;
    // -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:+PrintGCDateStamps {定義垃圾收集器}
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];// Eden 申請2M
        allocation1 = new byte[2 * _1MB];// Eden 申請2M,現在Eden 共4M,第一次的2M 變成垃圾
        allocation2 = new byte[2 * _1MB];// Eden 申請2M,現在Eden 共6M.
        allocation3 = new byte[2 * _1MB];// 新生代最大10M, Eden 只有8M,此時申請空間失敗,出發一次Minor GC.
        // 觸發GC 時,會將Eden 中存活對象複製到Survivor 空間.
        // Survivor 只有1M,空間不足,直接將Eden 複製到老年代.並清空Eden.
        // 清空Eden 中,申請這次的2M 空間.此時Eden 共2M.
        allocation4 = new byte[4 * _1MB];// Eden 申請4M,現在Eden 共6M.
        allocation4 = null; // allocation4 的空間變成垃圾
        System.gc(); // 觸發Full GC,回收4M 內存,並且Eden 中存活的2M 會複製到老年代,清空Eden.
    }
}

 

 

6.1 Serial GC

開發人員可通過-XX:+UseSerialGC 啓用Serial GC,此時,年輕代將使用標記-複製(mark-copy)算法,老年代使用標記-清理-壓縮(mark-sweep-compact)算法,並且均以單線程,stop-the-world 的方式運行垃圾收集過程。

 

6.2 Parallel GC

開發人員可通過-XX:+UseParallelGC 啓用Parallel GC,此時,年輕代將使用標記-複製(mark-copy)算法,老年代使用標記-清理-壓縮(mark-sweep-compact)算法,並且均以多線程,stop-the-world 的方式運行垃圾收集過程,還可通過

-XX:ParallelGCThreads=N 來指定運行垃圾收集過程的線程數,默認爲CPU 核數。Parallel GC 收集器將有效提升應用吞吐量,也常被稱爲吞吐量收集器。

 

6.3 Concurrent Mark and Sweep(CMS)

在Java 內存管理基礎一文中,已經知道CMS 並非完全併發執行,僅第二個和第四個階段是併發執行的。當使用CMS 垃圾收集器時,年輕代將採用並行,stop-the-world,標記-複製的收集算法,老年代則採用併發-標記-清理(Concurrent Mark-Sweep)的收集算法,該算法有利於降低應用暫定時間,可通過-XX:+UseConcMarkSweepGC 開啓。

 

6.4 G1(Garbage First)

G1 垃圾收集器的關鍵設計之一則是使得由於垃圾收集造成的應用暫停時間和分佈變得可預測,可配置。G1 物理上將整個堆分成了不同大小的固定區域(差不多2048 個),每個區域都可以被稱爲Eden 區,Survivor 區,或Old 區,邏輯上Eden 區+Survivor 區爲年輕代,Old 區則爲老年代,從而避免了每次GC 時,回收整個堆空間.可通過-XX:+UseG1GC 開啓。

以上,就是JVM 使用不同垃圾收集器時,記錄了一些GC 日誌,這將有助於開發人員在遇到Java 應用出現一些GC 性能問題時,能分析其中有可能的原因及其問題根源。

 

7 內存分配與回收策略

7.1 對象優先在Eden 區分配

大多數情況下,對象在新生代Eden 區分配內存.當Eden 區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC。

 

7.2 大對象直接進入老年代

測試代碼:

private static final int _1MB = 1024 * 1024;

/*

-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

輸出GC 信息最小堆最大堆新生代空間打印詳細日誌Eden 和一個Survivor 比例8:1

*/

@Test

public void testAllocation() throws Exception {

    byte[] allocation1, allocation2;

    allocation1 = new byte[3 * _1MB];

    allocation2 = new byte[5 * _1MB];// 新生代最大10M, Eden 只有8M,理論上會觸發一次Minor GC

}

執行上面的案例,我們發現,有的對象是不會在Eden 中進行分配而是直接進入老年代.這種現象就是:大對象直接進入老年代.大對象需要連續內存空間,比如典型的長字符串以及數組,經常出現大對象,就容易導致內存還有不少空間的時候就要觸發垃圾回收。

虛擬機可以配置超過多少空間纔是大對象: -XX:PretenureSizeThreshold(只有在Serial 收集器中有效,Parallel 默認,如果Eden空間不足,對象超過了Eden 一半空間則直接進入到老年代,如上面的案例)。

如果有過多的這些對象,就不要放在Eden 區,頻繁的GC,直接進入到老年代。

 

7.3 長期存活的對象將進入老年代

如果對象在Eden 出生並經歷過一次Minor GC 之後仍然存活,並且能被Survivor 容納的話,將被移動到Survivor 區,熬過一次Minor GC, 年齡就增加1.當達到一定的閥值,就會移動到老年代。

-XX:MaxTenuringThreshold(注意,該參數僅僅對串行有效,對並行無效.JDK7 默認是並行收集器.)

測試代碼:

public static void main(String[] args) throws Exception {

    byte[] allocation1, allocation2, allocation3;

    // 注意,先設置一個較小的值,否則可能因爲系統的一些數據,導致從倖存區移動到了老年區,就看不到效果了.

    allocation1 = new byte[_1MB / 100];

    allocation2 = new byte[4 * _1MB];

    allocation3 = new byte[4 * _1MB];// 申請Eden 空間不足,觸發GC.

    allocation3 = null;

    allocation3 = new byte[4 * _1MB];// 申請Eden 空間不足,觸發GC.

}

VM 參數:

-verbose:gc

-Xms20M

-Xmx20M

-Xmn10M

-XX:+PrintGCDetails

-XX:SurvivorRatio=8

-XX:MaxTenuringThreshold=1

-XX:+UseSerialGC

將最大年齡設置爲1,表示第一次放在倖存區survivor,當第二次GC 時,如果還存活,直接移動到老年代.

運行結果:

[GC[DefNew: 4940K->479K(9216K), 0.0029811 secs] 4940K->4575K(19456K), 0.0030190 secs] [Times: user=0.02sys=0.00, real=0.00 secs]

[GC[DefNew: 4660K->0K(9216K), 0.0010267 secs] 8756K->4575K(19456K), 0.0010390 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

Heap

def new generation total 9216K, used 4260K [0x00000000f9a00000, 0x00000000fa400000,0x00000000fa400000)

eden space 8192K, 52% used [0x00000000f9a00000, 0x00000000f9e28fd0, 0x00000000fa200000)

from space 1024K, 0% used [0x00000000fa200000, 0x00000000fa200088, 0x00000000fa300000)

to space 1024K, 0% used [0x00000000fa300000, 0x00000000fa300000, 0x00000000fa400000)

tenured generation total 10240K, used 4575K [0x00000000fa400000, 0x00000000fae00000,0x00000000fae00000)

the space 10240K, 44% used [0x00000000fa400000, 0x00000000fa877c50, 0x00000000fa877e00,0x00000000fae00000)

compacting perm gen total 21248K, used 2564K [0x00000000fae00000, 0x00000000fc2c0000,0x0000000100000000)

the space 21248K, 12% used [0x00000000fae00000, 0x00000000fb081350, 0x00000000fb081400,0x00000000fc2c0000)

No shared spaces configured.

從上面的結果我們發現,果然,因爲已經設置了最大年齡是1,所以,在第二次GC 之後,倖存區的數據移動到老年代了.

修改VM 參數:

-XX:MaxTenuringThreshold=10

表示,倖存區的對象,可以存活到10 歲.

運行結果:

[GC[DefNew: 4940K->479K(9216K), 0.0028465 secs] 4940K->4575K(19456K), 0.0028801 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

[GC[DefNew: 4660K->479K(9216K), 0.0011175 secs] 8756K->4575K(19456K), 0.0011317 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

Heap

def new generation total 9216K, used 4739K [0x00000000f9a00000, 0x00000000fa400000,0x00000000fa400000)

eden space 8192K, 52% used [0x00000000f9a00000, 0x00000000f9e28fd0,0x00000000fa200000)

from space 1024K, 46% used [0x00000000fa200000, 0x00000000fa277cc8,0x00000000fa300000)

to space 1024K, 0% used [0x00000000fa300000, 0x00000000fa300000,0x00000000fa400000)

tenured generation total 10240K, used 4096K [0x00000000fa400000,0x00000000fae00000,0x00000000fae00000)

the space 10240K, 40% used [0x00000000fa400000, 0x00000000fa800010,0x00000000fa800200,0x00000000fae00000)

compacting perm gen total 21248K, used 2564K [0x00000000fae00000,0x00000000fc2c0000,0x0000000100000000)

the space 21248K, 12% used [0x00000000fae00000, 0x00000000fb081350,0x00000000fb081400,0x00000000fc2c0000)

No shared spaces configured.

從運行結果我們發現,果然如此,如果最大年齡超過1,那麼經過兩次的GC ,仍然在倖存區,沒有移動到老年代.

 

7.4 動態對象年齡判斷

那麼,在上面的案例中,我讓大家在設置survivor 空間的時候,設置一個較小的值,如果值過大,可能效果不明顯.那麼我們在-XX:MaxTenuringThreshold=10 的情況下,來試試一個較大的值.

從結果我們能很容易發現:本應該在GC 的時候,複製到from survivor 中的數據,在最大年齡爲10,第二次GC 就已經移動到了老年代,爲什麼呢?這就是動態判斷。

爲了能更好的適應不同程序的內存狀況,虛擬機並不是永遠要求達到年齡才能晉升老年代.比如Survivor 空間不足,比如在Survivor 空間中相同年齡所有對象大小超過了Survivor 空間的一半,那麼年齡大於或等於該年齡的對象就可以直接進入老年代。

而在上面的案例中,我們設置的allocation1 + 系統的一些數據,超過了survivor 空間的一半,所以通過動態的計算,直接將倖存區的數據複製到了老年代。

 

7.5 空間分配擔保

在發生Minor GC 之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間.如果可以容納,那麼MinorGC 可以確保是安全的.如果不成立,則虛擬機會查看HandlePromotionFailure 設置值是否允許擔保失敗.如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,嘗試進行一次Minor GC. 否則,進行一次Full GC.可以理解爲,老年代,就是Survivor 空間的擔保.

測試代碼:

private static final int _1MB = 1024 * 1024;

public static void main(String[] args) throws Exception {

    byte[] allocation1,

    allocation2, allocation3,

    allocation4, allocation5,

    allocation6, allocation7;
    
    allocation1 = new byte[2 * _1MB];

    allocation2 = new byte[2 * _1MB];

    allocation3 = new byte[2 * _1MB];

    allocation1 = null;

    allocation4 = new byte[2 * _1MB];// Minor GC

    allocation5 = new byte[2 * _1MB];

    allocation6 = new byte[2 * _1MB];

    allocation4 = null;

    allocation5 = null;

    allocation6 = null;

    allocation7 = new byte[2 * _1MB];// Minor GC

}

VM 參數:

-verbose:gc

-Xms20M

-Xmx20M

-Xmn10M

-XX:+PrintGCDetails

-XX:SurvivorRatio=8

-XX:-HandlePromotionFailure

-XX:+UseSerialGC

運行結果:

Java HotSpot(TM) 64-Bit Server VM warning: ignoring option HandlePromotionFailure; support was removed in 6.0_24

[GC[DefNew: 6988K->469K(9216K), 0.0030782 secs] 6988K->4565K(19456K), 0.0031339 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

[GC[DefNew: 6786K->468K(9216K), 0.0017479 secs] 10882K->4564K(19456K), 0.0017945 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

Heap

def new generation total 9216K, used 2598K [0x00000000f9a00000, 0x00000000fa400000,
0x00000000fa400000)

eden space 8192K, 26% used [0x00000000f9a00000, 0x00000000f9c14820,0x00000000fa200000)

from space 1024K, 45% used [0x00000000fa200000, 0x00000000fa2753c0,0x00000000fa300000)

to space 1024K, 0% used [0x00000000fa300000, 0x00000000fa300000,0x00000000fa400000)

tenured generation total 10240K, used 4096K [0x00000000fa400000,0x00000000fae00000,
0x00000000fae00000)

the space 10240K, 40% used [0x00000000fa400000, 0x00000000fa800020,0x00000000fa800200,0x00000000fae00000)

compacting perm gen total 21248K, used 2564K [0x00000000fae00000,0x00000000fc2c0000,0x0000000100000000)

the space 21248K, 12% used [0x00000000fae00000, 0x00000000fb0813c8,0x00000000fb081400,0x00000000fc2c0000)

No shared spaces configured.

結果分析:

從運行結果我們可以看到,這個參數在JDK6.0_24 已經不支持了,而在這個版本之後的設定爲:只要老年代的連續空間大於新生代對象總大小或者歷次晉升的平均大小,就執行Minor GC, 否則進行Full GC.

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章