聊聊JVM垃圾收集機制

分享者:銳哥

一、運行時數據區域

JVM在執行java程序的過程當中會把它所管理的內存劃分紅若干個不一樣的數據區域。java


(1)程序計數器算法

        程序計數器(Program Counter Register)是一塊比較小的內存區域,它能夠看做是當前線程所執行的字節碼指令的行號計數器。在虛擬機的概念模型裏,字節碼解釋器工做時就是經過改變這個計數器的值來選取下一條要執行的字節碼指令。 c#

        因爲java虛擬機的多線程是經過線程的輪流切換並分配CPU時間片來實現的,在任何一個肯定的時刻,一個核只會執行一條線程中的指令,爲了線程切換後能恢復到正確的執行位置,每一條線程都須要有一個獨立的程序計數器,所以,程序計數器是線程私有的內存。 數組

        若是線程正在執行的是一個java方法,那麼程序計數器中的值是正在執行的虛擬機字節碼指令的地址;若是是一個Native方法,這個計數器的值爲空(undefined)。此內存區域是java虛擬機規範中惟一一個沒有定義任何OutOfMemoryError狀況的內存區域。 

(2)虛擬機棧安全

        虛擬機棧(Java Virtual Machine Stack)也是線程私有的,它的生命週期與線程相同。虛擬機棧描述的是java方法執行的內存模型:每一個方法在執行的同時都會建立一個稱爲棧幀(Stack Frame)的東西,用於存儲局部變量表、操做數棧、動態連接、方法出口等信息。每一個方法從調用開始直至執行完成,都對應着一個棧幀在虛擬機棧中入棧和出棧的過程。 多線程

        一般所說的棧,通常指的是虛擬機棧中的局部變量表部分。局部變量表存儲了編譯期可知的基本數據類型、引用類型和returnAddress類型(指向一條字節碼指令的地址)。局部變量表所需的內存空間在編譯期間完成分配,進入一個方法時,這個方法須要在棧幀中分配多大的局部變量空間是徹底肯定的。

        若是線程請求的棧深度超過了虛擬機的最大深度,那麼就會拋出StackOverFlowError異常;若是虛擬機能夠動態拓展而且在拓展時沒法申請到足夠的內存,將拋出OutOfMemoryError異常。 
併發

(3)本地方法棧佈局

        本地方法棧(Native Method Stack)和虛擬機棧同樣,都是線程私有的,只不過虛擬機棧爲虛擬機執行java方法服務,而本地方法棧則爲虛擬機執行native方法服務。
性能

(4)Java堆spa

        對大多數應用程序來講,java堆(Java Heap)都是java虛擬機所管理的內存區域中最大的一塊。java堆是被全部線程所共享的一塊內存區域,在虛擬機啓動時建立。此內存區域存在的惟一目的就是存放對象實例,幾乎全部的對象都在此內存區域上進行分配。

        根據java虛擬機規範的規定,java堆能夠處於物理上不連續的內存空間,只要邏輯上是連續的便可。在實現時,能夠實現成固定大小的,也能夠實現成可拓展的。當拓展時,若是沒法申請到足夠的內存,將拋出OutOfMemoryError異常。 

(5)方法區

        方法區(Method Area)也是被各個線程所共享的內存區域,它用於存儲已經被虛擬機加載的類信息、常量、靜態變量、JIT編譯器編譯後的代碼等數據。

        對於習慣在HotSpot虛擬機上開發、部署程序的的開發者來講,更習慣於把方法區稱爲」永久代「,但本質上二者並不等價。

         方法區能夠處於不連續的內存空間,也能夠選擇成可拓展,還能夠選擇不實現垃圾收集。當方法區沒法知足內存分配需求時,將拋出OutOfMemoryError異常。 

(6)運行時常量池

        運行時常量池(Runtime Constant Pool)是方法區的一部分。class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各類字面量和符號引用,這部份內容將在類加載之後進入方法區的運行時常量池中存放。

        運行時常量池除了保存class文件中描述的符號引用外,還會把翻譯出來的直接引用也存儲在運行時常量池中,所以,運行時常量池相對於class文件常量池的一個重要特徵是具有動態性。

        符號引用用一組符號來描述所引用的目標,符號能夠是任何形式的字面量,只要使用時可以無歧義地定位到目標便可。符號引用與虛擬機的內存佈局無關,引用的目標不必定加載到內存中。例如org.simple.People類引用了org.simple.Language類,在編譯時,People類並不知道Language類的實際內存地址,所以只能使用符號來代替,這就是符號引用。 

二、對象的內存佈局

        HotSpot虛擬機中,對象在內存中存儲的佈局能夠分爲3塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。

        HotSpot虛擬機的對象頭包含2部分的信息。第一部分用於存儲對象自身運行時數據如哈希碼、GC分代年齡、鎖狀態標誌等等,官方稱之爲「Mark Word」。另外一部分是類型指針,即對象指向它的類元數據的指針,虛擬機經過這個指針來肯定這個對象是哪一個類的實例,但並非全部的虛擬機實現都必須在對象頭中保留類型指針。另外,若是對象是一個Java數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,由於虛擬機能夠經過普通Java對象的元數據信息肯定Java對象的大小,可是沒法從數組的元數據中肯定數組的大小。

        實例數據部分是對象真正存儲的有效信息。這部分的存儲順序會受到虛擬機分配策略參數和字段在Java源碼中定義順序的影響。

       對齊填充並非必須的。由於HotSpot虛擬機的自動內存管理系統要求對象的起始地址必須是8字節的整數倍(爲何這麼要求?),也就是說對象的大小必須是8字節的整數倍。 

三、對象的訪問定位

        棧上的reference數據定位和訪問堆中的具體對象的方式取決於虛擬機實現,目前主要有句柄和直接指針兩種。

        使用句柄訪問方式的話,java堆中將會劃分出一塊內存來做爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例和類型數據各自具體的地址信息。

        若是使用直接指針訪問,那麼java堆對象的佈局就必須考慮如何放置訪問類型數據的相關信息,而reference中存儲的就是對象地址。

        使用句柄訪問的最大好處就是reference中存儲的是穩定的句柄地址,在對象被移動時只會改變句柄中的實例數據指針,而reference自己不須要修改。使用直接指針訪問的最大好處是速度更快,節省了一次指針定位的時間開銷。HotSpot虛擬機採用的是直接指針方式。 

四、垃圾收集器

        1960年誕生於MIT的Lisp是第一門真正使用內存動態分配和垃圾收集技術的語言。當Lisp還在胚胎時期,人們就在思考GC須要完成的三件事:
        (1)哪些內存須要回收
        (2)何時回收
        (3)如何回收

        對於第一個問題,哪些內存須要回收,就是哪些對象是不可用的。第二個問題,何時回收,一句話概述就是內存不夠用的時候進行回收。第三個問題,就涉及到回收的具體實現上了。

4.1 對象的存活斷定

(1)引用計數算法

        給對象添加一個引用計數器,每當有一個地方引用它時,計數器就加1;當引用失效時,計數器值就減1;任什麼時候刻,計數器爲0的對象就是不可能再被使用的對象。
        引用計數算法沒法解決對象之間循環引用的問題。

(2)可達性分析

        這種算法的基本思想就是經過一系列的稱爲」GC Roots"的對象做爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連時,則證實此對象不可用。

       在Java中,能夠做爲GC Roots的對象包括下面幾種:

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

        b. 方法區中類靜態變量所引用的對象

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

        d. 本地方法棧中JNI(即Native方法)引用的對象 

        主流的商用程序語言(java、c#等)都是採用的可達性分析算法來斷定對象是否存活。

4.2 垃圾收集算法

(1)標記-清除算法

        標記-清除算法(Mark-Sweep)是最基礎的收集算法。算法分爲」標記」和」清除"兩個階段:首先標記出全部須要回收的對象,在標記完成以後統一回收被標記的對象。

        這種算法存在兩個缺點:

        a. 效率問題。」標記」和」清除」兩個階段的效率都不高

        b. 內存碎片問題。標記清除以後會產生大量不連續的內存碎片,當之後要分配較大的對象時,可能會由於找不到足夠的連續內存而不得不提早觸發另外一次垃圾收集動做。 

(2)複製算法

        複製算法(Copying)是爲了解決標記清除算法的效率問題而提出。該算法將可用內存劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊內存使用完畢,就將還存活着的對象複製到另外一塊內存上面,而後再把已使用過的內存空間一次清理掉,這樣使得每次都是對整個半區進行回收,分配內存時,也不用考慮內存碎片的問題,只須要移動堆頂指針便可。

        這種算法的缺點是內存利用率只有50%

        現代的商業虛擬機都採用這種算法來回收新生代。由於新生代中的對象的存活率比較低,因此並不須要按照1:1來劃份內存空間,而是將內存劃分爲一塊較大的Eden空間和兩塊較小的Survivor空間,每次只使用Eden和其中的一塊Survivor。當回收時,將Eden和Survivor中還存活着的對象一次性的複製到另外一塊Survivor空間中,最後清理掉Eden和剛纔使用過的Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比率是8:1,也就是說每次新生代中可用內存空間爲整個新生代的90%,只有10%的內存會被浪費掉。若是某一次回收時,對象的存活率超過了10%,也就意味着to survivor空間不夠用,那麼此時就須要依賴老年代來進行分配擔保(Handle Promotion),也即這些還存活的對象將直接進入老年代。

(3)標記-整理算法

        標記-整理算法(Mark-Compact)是針對老年代的特色(對象存活率較高、沒有額外空間進行分配擔保)而提出的。這種算法分爲標記和整理兩個階段,標記階段和標記-清除算法的標記階段一致,可是整理階段不是直接對標記對象進行回收,而是讓還存活的對象向一端移動,而後一次性清理掉端邊界之外的內存。

(4)分代收集

        當前商業虛擬機的垃圾收集都採用的是分代收集算法。分代收集並無什麼新的思想,只是根據對象存活週期的不一樣將內存劃分爲幾塊。通常是把java堆劃分爲新生代和老年代,這樣就能夠根據各個年代的特色採用最合適的收集算法。

        新生代中,對象的存活率較低,就採用複製算法,只須要付出少許存活對象的複製成本就能夠完成收集;老年代中由於對象存活率高,也沒有額外空間進行分配擔保,因此採用標記-清除算法或者標記-整理算法。 

4.3 垃圾收集器

        若是說垃圾收集算法是內存回收的方法論,那麼垃圾收集器就是內存回收的具體實現。


(1)Serial收集器

        serial收集器是最基本、發展歷史最悠久的收集器。它是一個單線程的收集器,在它工做時,必須暫停全部其餘的工做線程,直到它收集結束,這也被稱爲」Stop The World」。

        它是虛擬機運行在client模式下的新生代默認的收集器:由於它簡單而高效,對於限定單個CPU的環境來講,Serial收集器由於沒有線程交互的開銷,專心作垃圾收集天然能夠得到最高的單線程收集效率。

(2)ParNew收集器

        ParNew 收集器是Serial收集器的多線程版本。

        ParNew收集器是許多運行在Server模式下的虛擬機中首選的新生代收集器,一個很重要可是與性能無關的緣由是:除了Serial收集器外,只有它能與CMS收集器配合工做。

        ParNew 收集器默認開啓的收集線程數與CPU的數量相同,可使用參數-XX:ParallelGCThreads來控制垃圾收集的線程數量。

(3)Parallel Scavenge收集器

        Parallel Scavenge收集器看起來和ParNew收集器沒什麼區別,但其實 Parallel Scavenge收集器的關注點與其餘收集器不一樣,CMS等收集器的關注點是儘量地縮短Stop The World的時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput),所以 Parallel Scavenge收集器也稱爲「吞吐量優先」的收集器 。所謂吞吐量就是CPU用於運行用戶代碼的時間與CPU總消耗時間的比值。

        停頓時間越短,就越適合須要與用戶交互的程序,良好的響應速度可以提高用戶體驗,而高吞吐量則能夠高效率地利用CPU時間,儘快完成程序的運算任務,主要適合在後臺運算而不須要太多交互的任務。

        Parallel Scavenge收集器提供了兩個參數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間的 -XX:MaxGCPauseMillis 參數和直接設置吞吐量大小的 -XX:GCTimeRatio 參數。

        除上述兩個參數以外,Parallel Scavenge還提供一個參數 -XX:UseAdaptiveSizePolicy。若是開啓了這個參數,就不須要手動指定新生代的大小(-Xmn)、Eden與Survivor的比率(-XX:SurvivorRatio)、晉升老年代對象的年齡(-XX:PretenureSizeThreshold)等細節參數了,虛擬機會根據當前系統的運行狀況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量,這種調節方式稱爲 GC自適應調節策略。 

(4)Serial Old收集器

        Serial Old收集器是Serial收集器的老年代版本,使用」標記-整理」算法。這個收集器的主要意義也是給client模式下的虛擬機使用。若是在Server模式下,它還有另外兩個做用:一種用途是在JDK 1.5及以前的版本中與Parallel Scavenge收集器搭配使用,另外一種用途就是做爲CMS收集器的後備預案,在CMS收集器發生Concurrent Mode Failer時使用。

(5)Parallel Old收集器

        Parallel Old收集器是Parallel Scavenge的老年代版本,使用」標記-整理」算法。這個收集器是在JDK 1.6 中提供的。在此以前,Parallel Scavenge一直處在比較尷尬的狀態。緣由是:若是新生代中選擇了Parallel Scavenge收集器,那麼老年代只能選擇Serial Old收集器,而Serial Old收集器在服務端應用性能上不是很突出,致使使用了Parallel Scavenge收集器也未必能在總體上得到吞吐量最大化的效果。
        直到Parallel Old出現,「吞吐量優先」收集器終於有了比較名副其實的應用組合。在注重吞吐量以及CPU資源敏感的場合,均可以優先考慮 Parallel Scavenge + Parallel Old 的組合。 

(6)CMS收集器

        CMS收集器(Concurrent Mark Sweep,併發標記清除)是一種以獲取最短停頓時間爲目標的收集器。基於「標記-清除」算法實現,它的運做過程大體分爲4個步驟:

        a. 初始標記

        b. 併發標記

        c. 從新標記

        d. 併發清除

        其中,初始標記和從新標記過程仍然須要「Stop The World」。初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快。從新標記階段就是爲了修正併發標記階段因用戶程序繼續運行而致使標記產生變更的那一部分對象的標記記錄。

        因爲整個過程當中耗時最長的併發標記和併發清除兩個過程,收集器線程均可以和用戶線程一塊兒工做,因此,從整體上來講,CMS收集器的內存回收過程是與用戶線程一塊兒併發執行的。

        這個收集器存在3個明顯的缺點:

        a. CMS收集器對CPU資源很是敏感。在併發階段,它雖然不會致使用戶線程停頓,可是會由於佔用了一部分的線程(或者說是CPU資源)而致使應用程序變慢,總吞吐量會下降。CMS收集器默認開啓的回收線程數量是 (CPU數量+3)/4,也就是說當CPU在4個以上時,併發回收時垃圾收集線程至少要佔用 25% 的CPU資源。

        b. CMS沒法處理浮動垃圾(Floating Garbage),可能出現「Concurrent Mode Failer」失敗而致使另外一次Full GC的產生。併發清理階段,用戶程序還在運行着,伴隨着就會產生新的垃圾,這一部分的垃圾出如今標記以後,沒法在當次收集中處理掉,只好等待下一次收集時處理,這一部分垃圾稱之爲「浮動垃圾」。也是由於併發清除階段,用戶程序還在運行,那也就必需要預留一部份內存空間給併發收集時用戶程序使用。在JDK1.5的默認設置下,CMS收集器在當老年代使用了68%的空間後就會被激活,在JDK1.6種,這個閾值被提高到了92%。要是CMS運行期間預留的內存沒法知足程序須要,就會出現一次「Concurrent Mode Failer」失敗,這時虛擬機將啓動後背預案:臨時啓用Serial Old收集器來從新進行老年代的垃圾收集。

        c. 由標記清除算法引發的空間碎片問題 。

(7)G1收集器

        G1(Garbage First)收集器是一款面向服務端應用的垃圾收集器,JDK 1.7中才出現。相比其餘的垃圾收集器,G1具備更加明顯的優點:

        a. G1中雖然還保留分代的概念,可是G1能獨立管理整個Java堆,再也不須要像之前那樣要多個收集器配合。由於G1將整個堆劃分紅多個大小相等的獨立區域(Region),新生代和老年代的概念雖然保留着,可是再也不是物理隔離的了,他們都是一部分Region(不須要連續)的集合。

        b. 空間整合。G1從總體上來看是基於標記-整理算法實現,從局部(兩個Region之間)來看是基於複製算法實現,避免了內存碎片的問題。

        c. 可預測的停頓。這是G1相對於CMS的另外一大優點。G1除了追求低停頓之外,還能創建可預測的停頓時間模型,能讓使用者指明在一個長度爲M毫秒的時間片斷內,消耗在垃圾收集上的時間不得超過N毫秒。

        G1之因此能創建可預測的停頓時間模型,是由於它能夠有計劃的避免在整個Java堆中進行全區域的垃圾收集。G1跟蹤每一個Region裏面的垃圾堆積的價值大小(回收這塊Region所得到的空間大小以及回收所需時間的經驗值),在後臺維護一個優先列表,每次根據容許的收集時間,優先回收價值最大的Region(這也是Garbage First名稱的由來)。

五、內存分配與回收策略

Java技術體系中所講的自動內存管理最終能夠歸結爲自動化的解決了兩個問題:給對象分配內存以及回收分配給對象的內存。內存的回收上面已經說過原理,下面看一下內存分配。

(1)對象優先在Eden區上分配

        對象在新生代Eden區中分配,當Eden區中沒有足夠的內存進行分配時,虛擬機將發起一次Minor GC。

(2)大對象直接進入老年代

        虛擬機提供了一個參數 -XX:PretenureSizeThreshold ,大於這個參數設置值的對象將不會在Eden區域上進行分配,而是直接在老年代進行分配。這樣作的目的是爲了不在Eden區以及兩個Survivor區之間發生大量的內存複製(當大對象的存活率比較高時)。

(3)長期存活的對象將進入老年代

        前面在說對象的內存佈局時提到對象頭中有一部分存儲對象自身運行時所須要的數據,例如哈希碼、GC分代年齡,這裏面的GC分代年齡指的就是對象在新生代中熬過的GC次數,每熬過一次Minor GC,對象的年齡就增長一歲,當它的年齡增長到必定程度(默認是15歲)就會被晉升到老年代。這個晉升老年代的閾值能夠經過參數 -XX:MaxTenuringThreshold 來設置。

(4)動態對象年齡斷定

對象的年齡不必定非要達到 MaxTenuringThreshold 設置的值才能晉升老年代。若是在Survivor中相同年齡的對象的大小之和超過了Survivor空間大小的一半,那麼年齡大於或等於該年齡的對象將直接進入老年代,無需等到 MaxTenuringThreshold 要求的年齡。

(5)分配擔保

在新生代進行Minor GC以前,虛擬機會作以下的事情:

(1)先檢查老年代最大可用的連續內存空間是否大於新生代全部對象總空間,若是大於,那麼Minor GC就是安全的,由於不會有對象進入老年代。不然進行步驟(2)

(2)查看 HandlePromotionFailure設置值是否容許分配擔保失敗,若是不容許,那麼也要進行一次Full GC。不然進行步驟(3)

(3)檢查老年代最大可用連續空間是否大於歷次晉升老年代的平均大小,若是大於,會嘗試進行一次Minor GC,可是可能會有風險。不然進行步驟(4)

(4)也要進行一次Full GC。

        步驟(3)中提到會嘗試進行一次Minor GC,但此次GC會存在風險,這句話是什麼意思呢?前面在講複製算法時提到,新生代在進行GC時,若是Eden空間和From Survivor空間中存活的對象大小之和超過了To Survivor空間的大小,那麼這些存活對象將經過分配擔保機制進入老年代。由於尚未進行Minor GC,因此虛擬機並不知道本次GC中存活對象的大小,因此只好採用歷次晉升老年代對象的平均值來做爲經驗值,若是老年代可用的最大連續內存空間大於經驗值,那麼頗有可能說明老年代可用的最大連續內存空間也大於本次Minor GC存活的對象大小之和,因此會嘗試進行Minor GC。可是若是在進行Minor GC後發現存活對象大小之和大於老年代最大可用連續內存空間,那麼這時老年代將沒法存放這部分存活對象,這就是風險所在,此時老年代就要來一次Full GC以騰出空間。

相關文章
相關標籤/搜索