本文主要摘自《深刻理解Java虛擬機》,內容較多,儘可能全面歸納了 Java 垃圾回收機制、垃圾回收器以及內存分配策略等內容。瞭解 Java 垃圾回收以前,須要先了解 Java內存區域html
Java 內存運行時區域中的程序計數器、虛擬機棧、本地方法棧隨線程而生滅;棧中的棧幀隨着方法的進入和退出而有條不紊地執行着出棧和入棧操做。每個棧幀中分配多少內存基本上是在類結構肯定下來時就已知的(儘管在運行期會由 JIT 編譯器進行一些優化),所以這幾個區域的內存分配和回收都具有肯定性,不須要過多考慮回收的問題,由於方法結束或者線程結束時,內存天然就跟隨着回收了。java
而 Java 堆不同,一個接口中的多個實現類須要的內存可能不同,一個方法中的多個分支須要的內存也可能不同,咱們只有在程序處於運行期間時才能知道會建立哪些對象,這部份內存的分配和回收都是動態的,垃圾收集器所關注的是這部份內存。算法
有如下兩種方法:編程
在Java語言中,可做爲 GC Roots 的對象包括下面幾種:數組
a. 虛擬機棧(棧幀中的本地變量表)中引用的對象。安全
b. 方法區中類靜態屬性引用的對象。服務器
c. 方法區中常量引用的對象。數據結構
d. 本地方法棧中 JNI(Native方法)引用的對象多線程
做爲 GC Roots 的節點主要在全局性的引用與執行上下文中。要明確的是,tracing gc必須以當前存活的對象集爲 Roots,所以必須選取肯定存活的引用類型對象。併發
GC 管理的區域是 Java 堆,虛擬機棧、方法區和本地方法棧不被 GC 所管理,所以選用這些區域內引用的對象做爲 GC Roots,是不會被 GC 所回收的。
其中虛擬機棧和本地方法棧都是線程私有的內存區域,只要線程沒有終止,就能確保它們中引用的對象的存活。而方法區中類靜態屬性引用的對象是顯然存活的。常量引用的對象在當前可能存活,所以,也多是 GC roots 的一部分。
JDK1.2 之前,一個對象只有被引用和沒有被引用兩種狀態。
後來,Java 對引用的概念進行了擴充,將引用分爲強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4 種,這 4 種引用強度依次逐漸減弱。
不可達的對象將暫時處於「緩刑」階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程:
finalize()
方法。finalize()
方法,或者 finalize()
方法已經被虛擬機調用過,虛擬機將這兩種狀況都視爲「沒有必要執行」,直接進行第二次標記。finalize()
方法,那麼這個對象將會放置在一個叫作 F-Queue 的隊列之中,並在稍後由一個由虛擬機自動創建的、低優先級的 Finalizer 線程去執行它。這裏所謂的「執行」是指虛擬機會觸發這個方法,但並不承諾會等待它運行結束,由於若是一個對象在 finalize()
方法中執行緩慢,將極可能會一直阻塞 F-Queue 隊列,甚至致使整個內存回收系統崩潰。
測試程序:
public class FinalizerTest { public static FinalizerTest object; public void isAlive() { System.out.println("I'm alive"); } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("method finalize is running"); object = this; } public static void main(String[] args) throws Exception { object = new FinalizerTest(); // 第一次執行,finalize方法會自救 object = null; System.gc(); Thread.sleep(500); if (object != null) { object.isAlive(); } else { System.out.println("I'm dead"); } // 第二次執行,finalize方法已經執行過 object = null; System.gc(); Thread.sleep(500); if (object != null) { object.isAlive(); } else { System.out.println("I'm dead"); } } }
輸出以下:
method finalize is running I'm alive I'm dead
若是不重寫 finalize()
,輸出將會是:
I'm dead I'm dead
從執行結果能夠看出:
第一次發生 GC 時,finalize()
方法的確執行了,而且在被回收以前成功逃脫;
第二次發生 GC 時,因爲 finalize()
方法只會被 JVM 調用一次,object 被回收。
值得注意的是,使用 finalize()
方法來「拯救」對象是不值得提倡的,它的運行代價高昂,不肯定性大,沒法保證各個對象的調用順序。finalize()
能作的工做,使用 try-finally 或者其它方法都更適合、及時。
本段程序引用自 Java GC的那些事(上)
永久代的垃圾收集主要回收兩部份內容:廢棄常量和無用的類。
a. 該類全部的實例都已經被回收,也就是 Java 堆中不存在該類的任何實例。
b. 加載該類的 ClassLoader 已經被回收。
c. 該類對應的 java.lang.Class 對象沒有在任何地方被引用,沒法在任何地方經過反射訪問該類的方法。**
虛擬機能夠對知足上述 3 個條件的無用類進行回收,這裏說的僅僅是「能夠」,而並非和對象同樣,不使用了就必然會回收。
在大量使用反射、動態代理、CGLib 等 ByteCode 框架、動態生成 JSP 以及 OSGi 這類頻繁自定義 ClassLoader 的場景都須要虛擬機具有類卸載的功能,以保證永久代不會溢出。
一共有 4 種:
最基礎的收集算法是「標記-清除」(Mark-Sweep)算法,分爲「標記」和「清除」兩個階段:首先標記出全部須要回收的對象,在標記完成後統一回收全部被標記的對象。
它的主要不足有兩個:
標記—清除算法的執行過程以下圖。
爲了解決效率問題,一種稱爲「複製」(Copying)的收集算法出現了,它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另一塊上面,而後再把已使用過的內存空間一次清理掉。
這樣使得每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等複雜狀況,只要移動堆頂指針,按順序分配內存便可,實現簡單,運行高效。只是這種算法的代價是將內存縮小爲了原來的一半。複製算法的執行過程以下圖:
如今的商業虛擬機都採用這種算法來回收新生代,IBM 研究指出新生代中的對象 98% 是「朝生夕死」的,因此並不須要按照 1:1 的比例來劃份內存空間,而是將內存分爲一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor 。
當回收時,將 Eden 和 Survivor 中還存活着的對象一次性地複製到另一塊 Survivor 空間上,最後清理掉 Eden 和剛纔用過的 Survivor 空間。HotSpot 虛擬機默認 Eden:Survivor = 8:1,也就是每次新生代中可用內存空間爲整個新生代容量的 90%(其中一塊Survivor不可用),只有 10% 的內存會被「浪費」。
固然,98%的對象可回收只是通常場景下的數據,咱們沒有辦法保證每次回收都只有很少於 10% 的對象存活,當 Survivor 空間不夠用時,須要依賴其餘內存(這裏指老年代)進行分配擔保(Handle Promotion)。
內存的分配擔保就比如咱們去銀行借款,若是咱們信譽很好,在 98% 的狀況下都能按時償還,因而銀行可能會默認咱們下一次也能按時按量地償還貸款,只須要有一個擔保人能保證若是我不能還款時,能夠從他的帳戶扣錢,那銀行就認爲沒有風險了。
內存的分配擔保也同樣,若是另一塊 Survivor 空間沒有足夠空間存放上一次新生代收集下來的存活對象時,這些對象將直接經過分配擔保機制進入老年代。關於對新生代進行分配擔保的內容,在本章稍後在講解垃圾收集器執行規則時還會再詳細講解。
複製算法在對象存活率較高時就要進行較多的複製操做,效率將會變低。更關鍵的是,若是不想浪費 50% 的空間,就須要有額外的空間進行分配擔保,以應對被使用的內存中全部對象都 100% 存活的極端狀況,因此在老年代通常不能直接選用這種算法。
根據老年代的特色,有人提出了另一種「標記-整理」(Mark-Compact)算法,標記過程仍然與「標記-清除」算法同樣,但後續步驟不是直接對可回收對象進行清理,而是讓全部存活的對象都向一端移動,而後直接清理掉端邊界之外的內存,「標記-整理」算法的示意圖以下:
當前商業虛擬機的垃圾收集都採用「分代收集」(Generational Collection)算法,根據對象存活週期的不一樣將內存劃分爲幾塊並採用不用的垃圾收集算法。
通常是把 Java 堆分爲新生代和老年代,這樣就能夠根據各個年代的特色採用最適當的收集算法。在新生代中,每次垃圾收集時都發現有大批對象死去,只有少許存活,那就選用複製算法,只須要付出少許存活對象的複製成本就能夠完成收集。而老年代中由於對象存活率高、沒有額外空間對它進行分配擔保,就必須使用「標記—清理」或者「標記—整理」算法來進行回收。
以可達性分析中從 GC Roots 節點找引用鏈這個操做爲例,可做爲 GC Roots 的節點主要在全局性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中,如今不少應用僅僅方法區就有數百兆,若是要逐個檢查這裏面的引用,那麼必然會消耗不少時間。
另外,可達性分析對執行時間的敏感還體如今 GC 停頓上,由於這項分析工做必須不能夠出現分析過程當中對象引用關係還在不斷變化的狀況,不然分析結果準確性就沒法獲得保證。這點是致使 GC 進行時必須停頓全部 Java 執行線程(Sun將這件事情稱爲"Stop The World")的其中一個重要緣由,即便是在號稱(幾乎)不會發生停頓的 CMS 收集器中,枚舉根節點時也是必需要停頓的。
所以,目前的主流 Java 虛擬機使用的都是準確式 GC(即虛擬機能夠知道內存中某個位置的數據具體是什麼類型。),因此當執行系統停頓下來後,並不須要一個不漏地檢查完全部執行上下文和全局的引用位置,虛擬機應當是有辦法直接得知哪些地方存放着對象引用。
在 HotSpot 的實現中,是使用一組稱爲 OopMap 的數據結構來達到這個目的的,在類加載完成的時候,HotSpot 就把對象內什麼偏移量上是什麼類型的數據計算出來,在 JIT 編譯過程當中,也會在特定的位置記錄棧和寄存器中哪些位置是引用。這樣, GC 在掃描時就能夠直接得知這些信息了。
在 OopMap 的協助下,HotSpot 能夠快速且準確地完成 GC Roots 枚舉,但一個很現實的問題隨之而來:可能致使引用關係變化,或者說 OopMap 內容變化的指令很是多,若是爲每一條指令都生成對應的 OopMap,那將會須要大量的額外空間,這樣 GC 的空間成本將會變得很高。
實際上,HotSpot 也的確沒有爲每條指令都生成 OopMap,前面已經提到,只是在「特定的位置」記錄了這些信息,這些位置稱爲安全點,即程序執行時並不是在全部地方都能停頓下來開始 GC ,只有在到達安全點時才能暫停。
Safepoint 的選定既不能太少以至於 GC 過少,也不能過於頻繁以至於過度增大運行時的負荷。
對於 Safepoint,另外一個須要考慮的問題是如何在 GC 發生時讓全部線程都「跑」到最近的安全點上再停頓下來。這裏有兩種方案可供選擇:搶先式中斷(Preemptive Suspension)和主動式中斷(Voluntary Suspension)。
其中搶先式中斷不須要線程的執行代碼主動去配合,在 GC 發生時,首先把全部線程所有中斷,若是發現有線程中斷的地方不在安全點上,就恢復線程,讓它「跑」到安全點上。如今幾乎沒有虛擬機實現採用搶先式中斷來暫停線程從而響應 GC 事件。
而主動式中斷的思想是當 GC 須要中斷線程的時候,不直接對線程操做,僅僅簡單地設置一個標誌,各個線程執行時主動去輪詢這個標誌,發現中斷標誌爲真時就本身中斷掛起。輪詢標誌的地方和安全點是重合的,另外再加上建立對象須要分配內存的地方。
使用 Safepoint 彷佛已經完美地解決了如何進入 GC 的問題,但實際狀況卻並不必定。Safepoint 機制保證了程序執行時,在不太長的時間內就會遇到可進入 GC 的Safepoint。可是,程序「不執行」的時候呢?
所謂的程序不執行就是沒有分配 CPU 時間,典型的例子就是線程處於 Sleep 狀態或者 Blocked 狀態,這時候線程沒法響應 JVM 的中斷請求,「走」到安全的地方去中斷掛起,JVM 也顯然不太可能等待線程從新被分配 CPU 時間。對於這種狀況,就須要安全區域(Safe Region)來解決。
安全區域是指在一段代碼片斷之中,引用關係不會發生變化。
在這個區域中的任意地方開始 GC 都是安全的。咱們也能夠把 Safe Region 看作是被擴展了的 Safepoint。在線程執行到 Safe Region 中的代碼時,首先標識本身已經進入了 Safe Region,那樣,當在這段時間裏 JVM 要發起 GC 時,就不用管標識本身爲 Safe Region 狀態的線程了。在線程要離開 Safe Region 時,它要檢查系統是否已經完成了根節點枚舉(或者是整個 GC 過程),若是完成了,那線程就繼續執行,不然它就必須等待直到收到能夠安全離開 Safe Region 的信號爲止。
若是說收集算法是內存回收的方法論,那麼垃圾收集器就是內存回收的具體實現。這裏討論的收集器基於JDK 1.7 Update 14以後的 HotSpot 虛擬機,這個虛擬機包含的全部收集器以下圖所示
上圖展現了 7 種做用於不一樣分代的收集器,若是兩個收集器之間存在連線,就說明它們能夠搭配使用。虛擬機所處的區域,則表示它是屬於新生代收集器仍是老年代收集器。接下來將逐一介紹這些收集器的特性、基本原理和使用場景,並重點分析 CMS 和 G1 這兩款相對複雜的收集器,瞭解它們的部分運做細節。
Serial 收集器是最基本、發展歷史最悠久的收集器,曾經是虛擬機新生代收集的惟一選擇。這是一個單線程的收集器,但它的「單線程」的意義並不只僅說明它只會使用一個 CPU 或一條收集線程去完成垃圾收集工做,更重要的是在它進行垃圾收集時,必須暫停其餘全部的工做線程,直到它收集結束。
"Stop The World"這個名字也許聽起來很酷,但這項工做其實是由虛擬機在後臺自動發起和自動完成的,在用戶不可見的狀況下把用戶正常工做的線程所有停掉,這對不少應用來講都是難以接受的。下圖示意了 Serial/Serial Old 收集器的運行過程。
實際上到如今爲止,它依然是虛擬機運行在 Client 模式下的默認新生代收集器。它也有着優於其餘收集器的地方:簡單而高效(與其餘收集器的單線程比),對於限定單個 CPU 的環境來講,Serial 收集器因爲沒有線程交互的開銷,專心作垃圾收集天然能夠得到最高的單線程收集效率。
在用戶的桌面應用場景中,分配給虛擬機管理的內存通常來講不會很大,收集幾十兆甚至一兩百兆的新生代(僅僅是新生代使用的內存,桌面應用基本上不會再大了),停頓時間徹底能夠控制在幾十毫秒最多一百多毫秒之內,只要不是頻繁發生,這點停頓是能夠接受的。因此,Serial 收集器對於運行在 Client 模式下的虛擬機來講是一個很好的選擇。
ParNew 收集器其實就是 Serial 收集器的多線程版本,除了使用多條線程進行垃圾收集以外,其他行爲包括 Serial 收集器可用的全部控制參數(例如:-XX:SurvivorRatio
、-XX:PretenureSizeThreshold
、-XX:HandlePromotionFailure
等)、收集算法、Stop The World、對象分配規則、回收策略等都與 Serial 收集器徹底同樣,在實現上,這兩種收集器也共用了至關多的代碼。ParNew 收集器的工做過程以下圖所示。
ParNew 收集器除了多線程收集以外,其餘與 Serial 收集器相比並無太多創新之處,但它倒是許多運行在 Server 模式下的虛擬機中首選的新生代收集器,其中有一個與性能無關但很重要的緣由是,除了 Serial 收集器外,目前只有它能與 CMS 收集器(併發收集器,後面有介紹)配合工做。
ParNew 收集器在單 CPU 的環境中不會有比 Serial 收集器更好的效果,甚至因爲存在線程交互的開銷,該收集器在經過超線程技術實現的兩個 CPU 的環境中都不能百分之百地保證能夠超越 Serial 收集器。
固然,隨着能夠使用的 CPU 的數量的增長,它對於 GC 時系統資源的有效利用仍是頗有好處的。它默認開啓的收集線程數與 CPU 的數量相同,在 CPU 很是多(如 32 個)的環境下,能夠使用-XX:ParallelGCThreads
參數來限制垃圾收集的線程數。
注意,從 ParNew 收集器開始,後面還會接觸到幾款併發和並行的收集器。這裏有必要先解釋兩個名詞:併發和並行。這兩個名詞都是併發編程中的概念,在談論垃圾收集器的上下文語境中,它們能夠解釋以下。
Parallel Scavenge 收集器是一個新生代收集器,它也是使用複製算法的收集器,又是並行的多線程收集器……看上去和 ParNew 都同樣,那它有什麼特別之處呢?
Parallel Scavenge 收集器的特色是它的關注點與其餘收集器不一樣,CMS 等收集器的關注點是儘量地縮短垃圾收集時用戶線程的停頓時間,而 Parallel Scavenge 收集器的目標則是達到一個可控制的吞吐量(Throughput)。
所謂吞吐量就是 CPU 用於運行用戶代碼的時間與 CPU 總消耗時間的比值,即吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間),虛擬機總共運行了 100 分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99% 。
停頓時間越短就越適合須要與用戶交互的程序,良好的響應速度能提高用戶體驗,而高吞吐量則能夠高效率地利用 CPU 時間,儘快完成程序的運算任務,主要適合在後臺運算而不須要太多交互的任務。
Parallel Scavenge收集器提供了兩個參數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis
參數以及直接設置吞吐量大小的-XX:GCTimeRatio
參數。
MaxGCPauseMillis
參數容許的值是一個大於 0 的毫秒數,收集器將盡量地保證內存回收花費的時間不超過設定值。
不過你們不要認爲若是把這個參數的值設置得稍小一點就能使得系統的垃圾收集速度變得更快,GC停頓時間縮短是以犧牲吞吐量和新生代空間來換取的:系統把新生代調小一些,收集 300MB 新生代確定比收集 500MB 快吧,這也直接致使垃圾收集發生得更頻繁一些,原來10秒收集一次、每次停頓100毫秒,如今變成5秒收集一次、每次停頓70毫秒。停頓時間的確在降低,但吞吐量也降下來了。
GCTimeRatio 參數的值應當是一個 0 到 100 的整數,也就是垃圾收集時間佔總時間的比率,至關因而吞吐量的倒數。若是把此參數設置爲 19,那容許的最大 GC 時間就佔總時間的 5%(即 1/(1+19)),默認值爲 99 ,就是容許最大 1%(即 1/(1+99))的垃圾收集時間。
因爲與吞吐量關係密切,Parallel Scavenge 收集器也常常稱爲「吞吐量優先」收集器。除上述兩個參數以外,Parallel Scavenge 收集器還有一個參數-XX:+UseAdaptiveSizePolicy
值得關注。這是一個開關參數,當這個參數打開以後,就不須要手工指定新生代的大小(-Xmn)、Eden 與 Survivor 區的比例(-XX:SurvivorRatio
)、晉升老年代對象年齡(-XX:PretenureSizeThreshold
)等細節參數了,虛擬機會根據當前系統的運行狀況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量,這種調節方式稱爲 GC 自適應的調節策略(GC Ergonomics)。
Serial Old 是 Serial 收集器的老年代版本,它一樣是一個單線程收集器,使用「標記-整理」算法。這個收集器的主要意義也是在於給 Client 模式下的虛擬機使用。若是在 Server 模式下,那麼它主要還有兩大用途:一種用途是在 JDK 1.5 以及以前的版本中與 Parallel Scavenge 收集器搭配使用,另外一種用途就是做爲 CMS 收集器的後備預案,在併發收集發生 Concurrent Mode Failure 時使用。這兩點都將在後面的內容中詳細講解。Serial Old 收集器的工做過程以下圖所示。
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多線程和「標記-整理」算法。這個收集器是在 JDK 1.6 中才開始提供的,在此以前,新生代的 Parallel Scavenge 收集器一直處於比較尷尬的狀態。
緣由是,若是新生代選擇了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外別無選擇(Parallel Scavenge 收集器沒法與 CMS 收集器配合工做)。
因爲老年代 Serial Old 收集器在服務端應用性能上的「拖累」,使用了 Parallel Scavenge 收集器也未必能在總體應用上得到吞吐量最大化的效果,因爲單線程的老年代收集中沒法充分利用服務器多 CPU 的處理能力,在老年代很大並且硬件比較高級的環境中,這種組合的吞吐量甚至還不必定有 ParNew 加 CMS 的組合「給力」。
直到 Parallel Old 收集器出現後,「吞吐量優先」收集器終於有了比較名副其實的應用組合,在注重吞吐量以及 CPU 資源敏感的場合,均可以優先考慮 Parallel Scavenge 加 Parallel Old 收集器。Parallel Old 收集器的工做過程以下圖所示。
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間爲目標的收集器。
目前很大一部分的 Java 應用集中在互聯網站或者 B/S 系統的服務端上,這類應用尤爲重視服務的響應速度,但願系統停頓時間最短,以給用戶帶來較好的體驗。CMS 收集器就很是符合這類應用的需求。
從名字(包含"Mark Sweep")上就能夠看出,CMS 收集器是基於「標記—清除」算法實現的,它的運做過程相對於前面幾種收集器來講更復雜一些,整個過程分爲4個步驟,包括:
其中,初始標記、從新標記這兩個步驟仍然須要"Stop The World"。初始標記僅僅只是標記一下 GC Roots 能直接關聯到的對象,速度很快,併發標記階段就是進行 GC RootsTracing 的過程,而從新標記階段則是爲了修正併發標記期間因用戶程序繼續運做而致使標記產生變更的那一部分對象的標記記錄,這個階段的停頓時間通常會比初始標記階段稍長一些,但遠比並發標記的時間短。
因爲整個過程當中耗時最長的併發標記和併發清除過程收集器線程均可以與用戶線程一塊兒工做,因此,從整體上來講,CMS 收集器的內存回收過程是與用戶線程一塊兒併發執行的。
CMS 是一款優秀的收集器,它的主要優勢在名字上已經體現出來了:併發收集、低停頓,可是 CMS 還遠達不到完美的程度,它有如下 3 個明顯的缺點:
第1、致使吞吐量下降。CMS 收集器對 CPU 資源很是敏感。其實,面向併發設計的程序都對 CPU 資源比較敏感。在併發階段,它雖然不會致使用戶線程停頓,可是會由於佔用了一部分線程(或者說CPU資源)而致使應用程序變慢,總吞吐量會下降。
CMS 默認啓動的回收線程數是(CPU數量+3)/4,也就是當 CPU 在4個以上時,併發回收時垃圾收集線程很多於 25% 的 CPU 資源,而且隨着 CPU 數量的增長而降低。可是當 CPU 不足 4 個(譬如2個)時,CMS 對用戶程序的影響就可能變得很大,若是原本 CPU 負載就比較大,還分出一半的運算能力去執行收集器線程,就可能致使用戶程序的執行速度突然下降了 50%,其實也讓人沒法接受。
第2、CMS 收集器沒法處理浮動垃圾(Floating Garbage),可能出現"Concurrent Mode Failure"失敗而致使另外一次 Full GC(新生代和老年代同時回收) 的產生。因爲 CMS 併發清理階段用戶線程還在運行着,伴隨程序運行天然就還會有新的垃圾不斷產生,這一部分垃圾出如今標記過程以後,CMS 沒法在當次收集中處理掉它們,只好留待下一次 GC 時再清理掉。這一部分垃圾就稱爲「浮動垃圾」。
也是因爲在垃圾收集階段用戶線程還須要運行,那也就還須要預留有足夠的內存空間給用戶線程使用,所以 CMS 收集器不能像其餘收集器那樣等到老年代幾乎徹底被填滿了再進行收集,須要預留一部分空間提供併發收集時的程序運做使用。
在 JDK 1.5 的默認設置下,CMS 收集器當老年代使用了 68% 的空間後就會被激活,這是一個偏保守的設置,若是在應用中老年代增加不是太快,能夠適當調高參數-XX:CMSInitiatingOccupancyFraction
的值來提升觸發百分比,以便下降內存回收次數從而獲取更好的性能,在 JDK 1.6 中,CMS 收集器的啓動閾值已經提高至 92% 。
要是 CMS 運行期間預留的內存沒法知足程序須要,就會出現一次"Concurrent Mode Failure"失敗,這時虛擬機將啓動後備預案:臨時啓用 Serial Old 收集器來從新進行老年代的垃圾收集,這樣停頓時間就很長了。因此說參數-XX:CM SInitiatingOccupancyFraction
設置得過高很容易致使大量"Concurrent Mode Failure"失敗,性能反而下降。
第3、產生空間碎片。 CMS 是一款基於「標記—清除」算法實現的收集器,這意味着收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,每每會出現老年代還有很大空間剩餘,可是沒法找到足夠大的連續空間來分配當前對象,不得不提早觸發一次 Full GC 。
爲了解決這個問題,CMS 收集器提供了一個-XX:+UseCMSCompactAtFullCollection
開關參數(默認就是開啓的),用於在CMS收集器頂不住要進行 FullGC 時開啓內存碎片的合併整理過程,內存整理的過程是沒法併發的,空間碎片問題沒有了,但停頓時間不得不變長。虛擬機設計者還提供了另一個參數-XX:CMSFullGCsBeforeCompaction
,這個參數是用於設置執行多少次不壓縮的 Full GC 後,跟着來一次帶壓縮的(默認值爲0,表示每次進入Full GC時都進行碎片整理)。
G1(Garbage-First)收集器是當今收集器技術發展的最前沿成果之一,G1 是一款面向服務端應用的垃圾收集器。HotSpot 開發團隊賦予它的使命是(在比較長期的)將來能夠替換掉 JDK 1.5 中發佈的 CMS 收集器。與其餘 GC 收集器相比,G1 具有以下特色。
並行與併發: G1 能充分利用多 CPU、多核環境下的硬件優點,使用多個CPU(CPU或者CPU核心)來縮短 Stop-The-World 停頓的時間,部分其餘收集器本來須要停頓 Java 線程執行的 GC 動做,G1 收集器仍然能夠經過併發的方式讓 Java 程序繼續執行。
分代收集: 與其餘收集器同樣,分代概念在 G1 中依然得以保留。雖然 G1 能夠不須要其餘收集器配合就能獨立管理整個 GC 堆,但它可以採用不一樣的方式去處理新建立的對象和已經存活了一段時間、熬過屢次 GC 的舊對象以獲取更好的收集效果。
空間整合: 與 CMS 的「標記—清理」算法不一樣,G1 從總體來看是基於「標記—整理」算法實現的收集器,從局部(兩個 Region 之間)上來看是基於「複製」算法實現的,但不管如何,這兩種算法都意味着 G1 運做期間不會產生內存空間碎片,收集後能提供規整的可用內存。這種特性有利於程序長時間運行,分配大對象時不會由於沒法找到連續內存空間而提早觸發下一次 GC 。
可預測的停頓: 這是 G1 相對於 CMS 的另外一大優點,下降停頓時間是 G1 和 CMS 共同的關注點,但 G1 除了追求低停頓外,還能創建可預測的停頓時間模型,能讓使用者明確指定在一個長度爲M毫秒的時間片斷內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已是實時 Java(RTSJ)的垃圾收集器的特徵了。
在 G1 以前的其餘收集器進行收集的範圍都是整個新生代或者老年代,而 G1 再也不是這樣。使用 G1 收集器時,Java 堆的內存佈局就與其餘收集器有很大差異,它將整個 Java 堆劃分爲多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代再也不是物理隔離的了,它們都是一部分 Region (不須要連續)的集合。
G1 收集器之因此能創建可預測的停頓時間模型,是由於它能夠有計劃地避免在整個Java堆中進行全區域的垃圾收集。G1 在後臺維護一個優先列表,每次根據容許的收集時間,優先回收價值最大的 Region(這也就是Garbage-First名稱的來由),保證了 G1 收集器在有限的時間內能夠獲取儘量高的收集效率。
在 G1 收集器中,Region 之間的對象引用以及其餘收集器中的新生代與老年代之間的對象引用,虛擬機都是使用 Remembered Set 來避免全堆掃描的。
G1 中每一個Region 都有一個與之對應的 Remembered Set,虛擬機發現程序在對 Reference 類型的數據進行寫操做時,會產生一個 Write Barrier 暫時中斷寫操做,檢查 Reference 引用的對象是否處於不一樣的 Region 之中(在分代的例子中就是檢查是否老年代中的對象引用了新生代中的對象),若是是,便經過 CardTable 把相關引用信息記錄到被引用對象所屬的 Region 的 Remembered Set 之中。當進行內存回收時,在 GC 根節點的枚舉範圍中加入 Remembered Set 便可保證不對全堆掃描也不會有遺漏。
若是不計算維護 Remembered Set 的操做,G1 收集器的運做大體可劃分爲如下幾個步驟:
G1 的前幾個步驟的運做過程和 CMS 有不少類似之處。
初始標記階段僅僅只是標記一下 GC Roots 能直接關聯到的對象,而且修改 TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序併發運行時,能在正確可用的 Region 中建立新對象,這階段須要停頓線程,但耗時很短。
併發標記階段是從 GC Root 開始對堆中對象進行可達性分析,找出存活的對象,這階段耗時較長,但可與用戶程序併發執行。
而最終標記階段則是爲了修正在併發標記期間因用戶程序繼續運做而致使標記產生變更的那一部分標記記錄,虛擬機將這段時間對象變化記錄在線程 Remembered Set Logs 裏面,最終標記階段須要把 Remembered Set Logs 的數據合併到 Remembered Set 中,這階段須要停頓線程,可是可並行執行。
最後在篩選回收階段首先對各個 Region 的回收價值和成本進行排序,根據用戶所指望的 GC 停頓時間來制定回收計劃,從Sun公司透露出來的信息來看,這個階段其實也能夠作到與用戶程序一塊兒併發執行,可是由於只回收一部分 Region,時間是用戶可控制的,並且停頓用戶線程將大幅提升收集效率。經過下圖能夠比較清楚地看到G1收集器的運做步驟中併發和須要停頓的階段。
閱讀 GC 日誌是處理 Java 虛擬機內存問題的基礎技能,它只是一些人爲肯定的規則,沒有太多技術含量。
每一種收集器的日誌形式都是由它們自身的實現所決定的,換而言之,每一個收集器的日誌格式均可以不同。但虛擬機設計者爲了方便用戶閱讀,將各個收集器的日誌都維持必定的共性,例如如下兩段典型的 GC 日誌:
33.125:[GC[DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs] 100.667:[Full GC[Tenured:0 K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
最前面的數字33.125:
和 100.667:
表明了 GC 發生的時間,這個數字的含義是從 Java 虛擬機啓動以來通過的秒數。
GC 日誌開頭的 [GC
和 [Full GC
說明了此次垃圾收集的停頓類型,而不是用來區分新生代 GC 仍是老年代 GC 的。
若是有 Full
,說明此次 GC 是發生了 Stop-The-World 的,例以下面這段新生代收集器 ParNew 的日誌也會出現 [Full GC
(這通常是由於出現了分配擔保失敗之類的問題,因此才致使 STW)。若是是調用 System.gc() 方法所觸發的收集,那麼在這裏將顯示 [Full GC(System)
。
[Full GC 283.736:[ParNew:261599K->261599K(261952K),0.0000288 secs]
接下來的 [DefNew
、[Tenured
、[Perm
表示 GC 發生的區域,這裏顯示的區域名稱與使用的 GC 收集器是密切相關的,例如上面樣例所使用的 Serial 收集器中的新生代名爲 "Default New Generation",因此顯示的是 [DefNew
。若是是 ParNew 收集器,新生代名稱就會變爲 [ParNew
,意爲 "Parallel New Generation"。若是採用 Parallel Scavenge 收集器,那它配套的新生代稱爲 PSYoungGen
,老年代和永久代同理,名稱也是由收集器決定的。
後面方括號內部的 3324K->152K(3712K)
含義是GC 前該內存區域已使用容量
->
GC 後該內存區域已使用容量
(該內存區域總容量)
。而在方括號以外的 3324K->152K(11904K)
表示 GC 前 Java 堆已使用容量
->
GC 後 Java 堆已使用容量
(Java 堆總容量)
。
再日後,0.0025925 secs
表示該內存區域 GC 所佔用的時間,單位是秒。有的收集器會給出更具體的時間數據,如 [Times:user=0.01 sys=0.00,real=0.02 secs]
,這裏面的 user、sys 和 real 與 Linux 的 time 命令所輸出的時間含義一致,分別表明用戶態消耗的 CPU 時間、內核態消耗的 CPU 事件和操做從開始到結束所通過的牆鍾時間(Wall Clock Time)。
CPU 時間與牆鍾時間的區別是,牆鍾時間包括各類非運算的等待耗時,例如等待磁盤 I/O、等待線程阻塞,而 CPU 時間不包括這些耗時,但當系統有多 CPU 或者多核的話,多線程操做會疊加這些 CPU 時間,因此讀者看到 user 或 sys 時間超過 real 時間是徹底正常的。
JDK 1.7 中的各類垃圾收集器到此已所有介紹完畢,在描述過程當中提到了不少虛擬機非穩定的運行參數,在表3-2中整理了這些參數供讀者實踐時參考。
對象的內存分配,往大方向講,就是在堆上分配,對象主要分配在新生代的Eden區上。少數狀況下也可能會直接分配在老年代中,分配的規則並非百分之百固定的,其細節取決於當前使用的是哪種垃圾收集器組合,還有虛擬機中與內存相關的參數的設置。
大多數狀況下,對象在新生代 Eden 區中分配。當 Eden 區沒有足夠空間進行分配時,虛擬機將發起一次 Minor GC。
虛擬機提供了-XX:+PrintGCDetails
這個收集器日誌參數,告訴虛擬機在發生垃圾收集行爲時打印內存回收日誌,而且在進程退出的時候輸出當前的內存各區域分配狀況。
private static final int_1MB=1024 * 1024; /** *VM參數:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails -XX:SurvivorRatio=8 */ public static void testAllocation () { byte[] allocation1,allocation2,allocation3,allocation4; allocation1 = new byte[2 * _1MB]; allocation2 = new byte[2 * _1MB]; allocation3 = new byte[2 * _1MB]; allocation4 = new byte[4 * _1MB];//出現一次Minor GC }
運行結果:
[GC[DefNew:6651K->148K(9216K),0.0070106 secs]6651K->6292K(19456K), 0.0070426 secs][Times:user=0.00 sys=0.00,real=0.00 secs] Heap def new generation total 9216K,used 4326K[0x029d0000,0x033d0000,0x033d0000) eden space 8192K,51%used[0x029d0000,0x02de4828,0x031d0000) from space 1024K,14%used[0x032d0000,0x032f5370,0x033d0000) to space 1024K,0%used[0x031d0000,0x031d0000,0x032d0000) tenured generation total 10240K,used 6144K[0x033d0000,0x03dd0000,0x03dd0000) the space 10240K,60%used[0x033d0000,0x039d0030,0x039d0200,0x03dd0000) compacting perm gen total 12288K,used 2114K[0x03dd0000,0x049d0000,0x07dd0000) the space 12288K,17%used[0x03dd0000,0x03fe0998,0x03fe0a00,0x049d0000) No shared spaces configured.
上方代碼的 testAllocation()
方法中,嘗試分配 3 個 2MB 大小和 1 個 4MB 大小的對象,在運行時經過-Xms20M
、-Xmx20M
、-Xmn10M
這 3 個參數限制了 Java 堆大小爲 20MB ,不可擴展,其中 10MB 分配給新生代,剩下的 10MB 分配給老年代。-XX:SurvivorRatio=8
決定了新生代中 Eden 區與一個 Survivor 區的空間比例是 8:1,從輸出的結果也能夠清晰地看到 eden space 8192K、from space 1024K、to space 1024K
的信息,新生代總可用空間爲 9216KB(Eden區+1個Survivor區的總容量)。
執行 testAllocation()
中分配 allocation4 對象的語句時會發生一次 Minor GC,此次 GC 的結果是新生代 6651KB 變爲 148KB ,而總內存佔用量則幾乎沒有減小(由於 allocation一、allocation二、allocation3 三個對象都是存活的,虛擬機幾乎沒有找到可回收的對象)。
此次 GC 發生的緣由是給 allocation4 分配內存的時候,發現 Eden 已經被佔用了 6MB,剩餘空間已不足以分配 allocation4 所需的 4MB 內存,所以發生 Minor GC。GC 期間虛擬機又發現已有的 3 個 2MB 大小的對象所有沒法放入 Survivor 空間(Survivor 空間只有 1MB 大小),因此只好經過分配擔保機制提早轉移到老年代去。
此次 GC 結束後,4MB 的 allocation4 對象順利分配在 Eden 中,所以程序執行完的結果是 Eden 佔用 4MB(被allocation4佔用),Survivor 空閒,老年代被佔用 6MB(被allocation一、allocation二、allocation3佔用)。經過 GC 日誌能夠證明這一點。
新生代 GC(Minor GC):指發生在新生代的垃圾收集動做,由於 Java 對象大多都具有朝生夕滅的特性,因此 Minor GC 很是頻繁,通常回收速度也比較快。
老年代 GC(Major GC/Full GC):指發生在老年代的 GC,出現了 Major GC,常常會伴隨至少一次的 Minor GC(但非絕對的,在 Parallel Scavenge 收集器的收集策略裏就有直接進行 Major GC 的策略選擇過程)。Major GC 的速度通常會比 Minor GC 慢 10 倍以上。
所謂的大對象是指,須要大量連續內存空間的 Java 對象,最典型的大對象就是那種很長的字符串以及數組( byte[] 數組就是典型的大對象)。大對象對虛擬機的內存分配來講就是一個壞消息(特別是短命大對象,寫程序的時候應當避免),常常出現大對象容易致使內存還有很多空間時就提早觸發垃圾收集以獲取足夠的連續空間來「安置」它們。
虛擬機提供了一個 -XX:PretenureSizeThreshold
參數,令大於這個設置值的對象直接在老年代分配。這樣作的目的是避免在 Eden 區及兩個 Survivor 區之間發生大量的內存複製。
private static final int_1MB=1024 * 1024; /** *VM參數:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8 *-XX:PretenureSizeThreshold=3145728 */ public static void testPretenureSizeThreshold () { byte[] allocation; allocation = new byte[4 * _1MB];//直接分配在老年代中 }
運行結果:
Heap def new generation total 9216K,used 671K[0x029d0000,0x033d0000,0x033d0000) eden space 8192K,8%used[0x029d0000,0x02a77e98,0x031d0000) from space 1024K,0%used[0x031d0000,0x031d0000,0x032d0000) to space 1024K,0%used[0x032d0000,0x032d0000,0x033d0000) tenured generation total 10240K,used 4096K[0x033d0000,0x03dd0000,0x03dd0000) the space 10240K,40%used[0x033d0000,0x037d0010,0x037d0200,0x03dd0000) compacting perm gen total 12288K,used 2107K[0x03dd0000,0x049d0000,0x07dd0000) the space 12288K,17%used[0x03dd0000,0x03fdefd0,0x03fdf000,0x049d0000) No shared spaces configured.
執行以上代碼中的 testPretenureSizeThreshold()
方法後,咱們看到 Eden 空間幾乎沒有被使用,而老年代的 10MB 空間被使用了 40%,也就是 4MB 的 allocation 對象直接就分配在老年代中,這是由於 PretenureSizeThreshold 參數被設置爲 3MB(就是 3145728,這個參數不能像 -Xmx 之類的參數同樣直接寫 3MB),所以超過 3MB 的對象都會直接在老年代進行分配。
注意 PretenureSizeThreshold 參數只對 Serial 和 ParNew 兩款收集器有效,Parallel Scavenge 收集器不認識這個參數,Parallel Scavenge 收集器通常並不須要設置。若是遇到必須使用此參數的場合,能夠考慮 ParNew 加 CMS 的收集器組合。
虛擬機給每一個對象定義了一個對象年齡(Age)計數器。
若是對象在 Eden 出生並通過第一次 Minor GC 後仍然存活,而且能被 Survivor 容納的話,將被移動到 Survivor 空間中,而且對象年齡設爲 1 。對象在 Survivor 區中每「熬過」一次 Minor GC,年齡就增長 1 歲,當它的年齡增長到必定程度(默認爲 15 歲),就將會被晉升到老年代中。
對象晉升老年代的年齡閾值,能夠經過參數-XX:MaxTenuringThreshold
設置。
爲了能更好地適應不一樣程序的內存情況,無須等到 MaxTenuringThreshold 中要求的年齡,同年對象達到 Survivor 空間的一半後,他們以及年齡大於他們的對象都將直接進入老年代。
在發生 Minor GC 以前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代
全部對象總空間,若是這個條件成立,那麼 Minor GC 能夠確保是安全的。
只要老年代的連續空間大於新生代對象總大小或者歷次晉升的平均大小就會進行 Minor GC ,不然將進行 Full GC 。
感謝:
深刻理解Java虛擬機(第2版)