1. 程式人生 > >JVM(一)——GC,記憶體分配和垃圾回收

JVM(一)——GC,記憶體分配和垃圾回收

心得:Java中垃圾回收和記憶體可以實現高度的自動化,棧幀可以由JVM自動分配和回收,區域性變量表和運算元棧也可以在編譯時就確定好,堆中的記憶體分配和回收才是JVM關注的重點,JVM實現大多采用可達性分析來標記存活物件,什麼時候標記?讓使用者執行緒主動跑到那些安全的地方(引用關係不變的時候,SafePoint和Safe Region),再由GC收集器來標記進行處理。

不同的垃圾收集器甚至可以決定堆的記憶體佈局,比如G1的“化增為零”一方面藉助Remember Set可以更細粒度的進行併發標記和回收。

分代是GC種重要的思想,對不同特點物件進行各自適合的回收策略,Minor GC一般是新生代採用Copying演算法,“空間換時間”,也是因為新生代大部分物件“朝生夕死”;Full GC在老年代一般是Mark and Compact兩個步驟,不用額外空間,但停頓長,可謂“用時間省空間”;

CMS和G1更是採用並行+併發的手段,但一個新的問題,就是併發期間的使用者執行緒的記憶體開銷(CMS和G1各有對應策略)和物件引用關係的變化,因此它們都有remark的過程。

總之,不同的場景用不同的技術,“知其然”的同時能夠“知其所以然”才能在實際的場景下選擇“對的”技術。

垃圾回收是一個複雜的系統問題,本人認識還是十分有限。。。

一個小栗子(我在知乎的提問)

一個問題Java中的物件到底佔多少記憶體?
JVM規範也不能回答這個問題,因為它是一個公有設計;

The Java Virtual Machine does not mandate any particular internal structure for objects.
看過書的同學應該都知道,物件由物件頭+例項資料+padding組成;我利用Instrumentation做了一個小小的實驗,基於64位JDK 8的Hotspot:

/*
        基本資訊:物件記憶體佈局,物件的大小
        注意:以HotSpot為例,Java中的物件記憶體佈局包括:MarkWord,ClassPointer,例項資料,padding
        如果是陣列還有陣列長度;如果開啟了欄位壓縮,會進行指標壓縮,子類的窄變數會插入父類的寬變數之中
        */
        public static void main(String[] args) {
            //Object
            System.out.println(ClassUtils.sizeOf(new Object())); //16 8位元組MarkWord,4位元組klass指標,4位元組padding
//陣列 System.out.println(ClassUtils.sizeOf(new byte[0])); //16 8位元組MarkWord,4位元組klass指標,4位元組陣列長度 System.out.println(ClassUtils.sizeOf(new byte[7])); //24 padding補齊 System.out.println(ClassUtils.sizeOf(new byte[_1MB])); //1024 * 1024 + 16 = 1048592 //窄物件 System.out.println(ClassUtils.sizeOf(new Integer(1))); //16 int和klass補齊 System.out.println(ClassUtils.sizeOf(new Byte("1"))); //16 byte和klass補齊 System.out.println(ClassUtils.sizeOf(new Character('a'))); //16 char和klass補齊 }

因為物件的大小一定是8的倍數,可以看到Hotspot很節省的將型別指標和陣列長度或者int,byte,char合併儲存了,而64位的Hotspot中reference的長度仍然是4個位元組,一些部落格上有說成8個位元組的。

1. 確定回收物件

引用計數和可達性分析(counting和tracing)

前者通過物件的引用計數器來記錄被引次數,顯然的一個問題是迴圈引用;Java採用的是可達性分析;

從GC Roots出發,延引用鏈對物件進行搜尋,沒有任何引用鏈和GC Roots相連的物件就被成為不可達的,被判定為可回收的物件;

GC Roots(方法區和棧中):
(1)虛擬機器棧(棧幀中的區域性變量表);
(2)本地方法棧JNI引用的物件;
(3)方法區中類靜態屬性;
(4)方法區中常量引用;

引用型別

就像程序的狀態不能由簡單的執行和終止描述一樣;引用也需要進行一步細分:

強引用:永遠不會被回收掉的物件;
軟引用:如果一次回收後,記憶體還是不足,才進行回收,如果再不夠,OOM;
弱引用:發生GC時,無論記憶體是否足夠都會被回收;
虛引用:程式不能引用到,但是被回收時可以收到一個通知;

一個很重要的應用就是快取,在記憶體中快取一定要注意防止記憶體洩漏,在Java Collection Framework中,容器在刪除是都執行置空的操作;
另一個注意的是可以使用WeakHashMap作為快取容器;如果不是WeakHashMap一定要控制數量和及時清除(Integer.valueOf等就控制了數量);

終結(finalize)

從物件的可觸及性來說還有2個狀態:
可復活和不可觸及(兩次標記):
在判定為不可達後:
(1)可復活:一次標記,物件分為沒有必要執行finalize方法(包括沒有覆蓋和已經執行兩種)需要執行finalize方法,前者直接就可以被回收;
(2)有必要執行finalize的方法被放在F-Queue佇列中,有JVM中一個低優先順序的Finalizer執行緒去觸發它們的finalize方法(不會等待方法結束),如果在finalize方法中物件有引用鏈建立了連線就會被“復活”,否則就Over;

一個物件的finalize方法只能被執行一次,也就是說一個物件甭想自救兩次!

方法區中的回收

對於常量來說,沒有任何東西引用,那麼也是可以被回收的;

類的解除安裝:條件非常苛刻(JVM規範沒有要求在方法區中實現垃圾回收,Hotspot中有但是類還是很難被解除安裝)
(1)該類所有的例項被回收;
(2)對應的ClassLoader被回收;
(3)對應的Class物件沒有引用;

棧的回收是虛擬機器靜態分配和回收,棧幀的大小可以在編譯時確定,JVM通過棧幀的分配和回收很容易(開銷很低)就完成;
方法區的回收中類的解除安裝很棘手,對於大量用反射,動態代理,CGLIB等技術的程式,JVM要能夠解除安裝類;
主要的一個問題就是堆中記憶體的分配和解除安裝;

2. 垃圾回收演算法

標記-清除演算法(Mark-Sweep)

問題:
(1)空間碎片;
(2)效率:標記和清除兩個過程相對來說不高;

複製演算法(Copying)

原理:兩塊記憶體來回複製,因為有一塊空白的記憶體可以直接複製,因此不用再分Mark,Sweep或者Compact多個階段了,空間換時間;
當然我們知道最後的設計是:一個Eden+兩個較小的Survivor,這是由於Java中物件98%的新物件都可以被回收的統計資料得來的經驗;

標記-整理演算法

區別與“標記-清除”,整理指的是不再原來位置直接進行回收,而是存活的物件向一端移動,最後界限之外的部分直接清理掉;

根據不同物件的特點,採用分代的方式垃圾回收;

3. HotSpot的垃圾回收演算法實現

什麼時候回收垃圾,怎樣儘量降低對使用者執行緒的影響不同的業務需求對垃圾回收有什麼不同的要求?

列舉根節點

一致性:進行引用鏈分析顯然要基於一個一致性的快照,不能因為分析過程中引用關係變化而導致錯誤;

Stop the world:一個簡單直接的辦法,但是顯然會產生停頓;

OopMap:為了避免進行全盤掃描,藉助與OopMap這樣的資料結構儲存物件的被引用範圍,告訴JVM哪些地方存摺物件的引用;

安全點(節省開銷,安全性)

定義:the thread’s representation of it’s Java machine state is well described

OopMap可以幫助快速的完成GC Roots列舉,但是顯然並不能每條指令都帶上OopMap;通過SafePoint的地方儲存OopMap,執行到安全點上可以進行GC活動;

如果要觸發一次GC,那麼JVM裡的所有Java執行緒都必須到達GC safepoint;

哪些地方可以選為SafePoint(不同的JVM實現可能不同):
因此防止“長時間執行”,而導致GC活動等待某個執行緒遲遲不能進入;
方法呼叫,迴圈跳轉,異常跳轉等:
(1)迴圈的末尾;
(2)方法返回之前;
(3)呼叫方法的call之後;
(4)丟擲異常的位置;

PS:Java中用到SafePoint的地方
1. Garbage collection pauses;
2. Code deoptimization;
3. Flushing code cache
4. Class redefinition (e.g. hot swap or instrumentation)
5. Biased lock revocation
6. Various debug operation (e.g. deadlock check or stacktrace dump)

主動式中斷:

設定標誌,讓工作執行緒主動輪詢到標誌進行掛起;
在JIT執行方式下:JIT編譯的時候直接把safepoint的檢查程式碼加入了生成的原生代碼,當JVM需要讓Java執行緒進入safepoint的時候,只需要設定一個標誌位,讓Java執行緒執行到safepoint的時候主動檢查這個標誌位,如果標誌被設定,那麼執行緒停頓,如果沒有被設定,那麼繼續執行。
例如hotspot在x86中為輪詢safepoint會生成一條類似於“test %eax,0x160100”的指令,JVM需要進入gc前,先把0x160100設定為不可讀,那所有執行緒執行到檢查0x160100的test指令後都會停頓下來;

在直譯器執行方式下:JVM會設定一個2位元組的dispatch tables,直譯器執行的時候會經常去檢查這個dispatch tables,當有SafePoint請求的時候,就會讓執行緒去進行SafePoint檢查。
VMThread會一直等待直到VMOperationQueue(訊息佇列)中有操作請求出現,比如GC請求。而VMThread要開始工作必須要等到所有的Java執行緒進入到SafePoint。
JVM維護了一個數據結構,記錄了所有的執行緒,所以它可以快速檢查所有執行緒的狀態。當有GC請求時,所有進入到SafePoint的Java執行緒會在一個Thread_Lock鎖阻塞,直到當JVM操作完成後,VM釋放Thread_Lock(通知),阻塞的Java執行緒才能繼續執行(STW)。
GC stop the world的時候,所有執行Java code的執行緒被阻塞,如果執行native code執行緒不去和Java程式碼互動,那麼這些執行緒不需要阻塞。VM操作相關的執行緒也不會被阻塞。

PS:輸出安全點統計資訊
-XX:+PrintSafepointStatistics
-XX:PrintSafepointStatisticsCount=1

安全區域(Safe Region)

GC活動設定狀態等待Java執行緒主動輪詢到狀態被Thread_Lock阻塞,但是如果處於阻塞狀態的執行緒怎麼辦,它們已經被阻塞了,這就需要Safe Region;

相對的,Safe Region指在一段程式碼片段之後,引用關係不會發生變化;執行緒是BLOCKED那麼它的引用關係就不會被修改,JVM可以安全進行標記;

過程:如果執行緒進入到Safe region時,首先標識自己進入了Safe region,在退出時(比如一個阻塞的執行緒被喚醒),先檢查是否能夠離開,如果GC已經完成,那麼可以離開,否則等待直到GC完成;

4. 垃圾回收器

從實現框架上來看:

Serial,ParNew,Serial Old,CMS是一個分代式GC框架中的,可以任意的搭配;

Parallel ScavengeG1有各自的框架,Parallel Scavenge不能和CMS搭配,只能和Serial Old(MSC,MarkSweepCompact),然後有了Parallel Old(標記-整理,PS Mark Sweep);

這個PS MarkSweep預設的實現實際上是一層皮,它底下真正做mark-sweep-compact工作的程式碼是跟分代式GC框架裡的serial old(這個collector名字叫做MarkSweepCompact)是共用同一份程式碼的。也就是說實際上PS MarkSweep與MarkSweepCompact在HotSpot VM裡是同一個collector實現,包了兩張不同的皮;一個並行,一個序列。

從序列,並行,併發執行方式看

序列的有:Serial,Serial Old, CMS的Concurrent Mode Fail情況(使用Serial Old);

並行的有:ParNew,Parallel Scavenge,CMS的remark,G1的最終標記,篩選回收(也可併發);

併發的有:CMS的concurrent mark,G1;

從效能特點和適用情景看

Serial:簡單,快捷,適合與記憶體不大(幾十上百MB)的client模式;

Parallel Scavenge:從吞吐量的角度控制堆的劃分和GC活動;

CMS:目標也是低停頓,在停頓控制上不如G1,但是如果在停頓都接受可以接受的範圍內,吞吐量ParNew+CMS的組合可能要比G1更好;

G1:G1的首要目標是為需要大量記憶體的系統提供一個保證GC低延遲的解決方案,也就是說堆記憶體在6GB及以上,穩定和可預測的暫停時間小於0.5秒;

如果應用程式具有如下的一個或多個特徵,那麼將垃圾收集器從CMS或ParallelOldGC切換到G1將會大大提升效能.
(1)Full GC 次數太頻繁或者消耗時間太長;
(2)物件分配的頻率或代數提升(promotion)顯著變化;
(3)受夠了太長的垃圾回收或記憶體整理時間(超過0.5~1秒);

CMS收集器(低停頓,B/S系統,側重響應速度)

過程:
(1)初始標記(initial mark,停頓);
(2)併發標記(concurrent mark);
(3)重新標記(parallel remark,停頓);
(4)併發清除(concurrent sweep);
(5)重置(reset,清理資料結構,為下次併發收集做準備);

問題
(1)對CPU資源敏感,很簡單,因為它使用了併發,在CPU核數較少的機器上會對使用者執行緒影響較大;
(2)浮動垃圾,併發標記的過程可能會產生新的垃圾,這一部分垃圾只能在下一次GC進行清理;並且垃圾回收階段也是併發的,必須為使用者執行緒預留一些記憶體空間,因此:
JDK 1.5老年代68%,啟用CMS,JDK 1.6 預設為92%;
-XX:CMSInitiatingOccupacyFraction設定;
如果預留空間無法滿足,造成“Concurrent Mode Failure”,臨時使用Serial Old進行回收;
(3)碎片,通過-XX:+UseCMSCompactAtFullCollection開啟在要FullGC前進行異常整理;-XX:+CMSFullGCsBeforeCompaction可以設定多少次不壓縮的FullGC之後來一次帶壓縮的FullGC(預設為0);

G1收集器(Garbage First,可預測的低停頓,伺服器,化整為零)

將整個Java堆劃分成多個大小相等的獨立區域(Region),新生代和老年代分佈在這些region上(可以不連續);

G1並不是實時垃圾收集器:基於以前收集的各種監控資料,G1會根據使用者指定的目標時間來預估能回收多少個heap區。因此,收集器有一個相當精確的heap區耗時計算模型,並根據該模型來確定在給定時間內去回收哪些heap區。

優點
(1)並行與併發;
(2)分代收集:雖然不像其他收集器老年代和新生代是物理隔離的,但是老物件,仍然會採用不同的方式處理;
(3)空間整合:從整體上看是“標記-整理”,從區域性上來看是“複製”,不會產生空間碎片;
(4)可預測的停頓:建立了可預測的停頓時間模型(並且後臺維護了一個優先列表),可以通過引數控制在M毫秒的時間段內,GC消耗的時間不得超過N毫秒;

G1如何進行垃圾回收的
後臺維護一個優先列表,每次回收先會回收GC價值最大的region;

問題:G1如何保證各個Region在各自回收時和其他region物件之間引用關係被正確處理?
Remembered Set,記錄其他region對自己region中物件的引用記錄(write barrier+CardTable),以避免掃描全域性;

G1的回收步驟:
(1)初始標記(Initial mark);
(2)併發標記(Concurrent mark);
(3)最終標記(final mark,將併發過程物件變化的Rememered Set log合併,將併發標記中的空區域回收,計算所有區域的活躍度(live的程度);
(4)篩選掃描(live data counting and evacuation,拷貝和回收);

G1中的轉移失敗(Evacuation Failure))
對Survivor或promoted Objects進行GC時如果JVM的heap區不足就會發生提升失敗(promotion failure),堆記憶體不能繼續擴充,因為已經達到了最大值,日誌輸出to-space overflow;
也就是說GC的效率趕不上空間的消耗導致“碰到天花板”了;

解決(加快GC的速度,增加對記憶體):
-XX:G1ResrvePercent:保留記憶體,也就是來個“假天花板”;
-XX:ConcGCThreads=n增加標記執行緒數量;

5. 物件分配與回收策略

注意啟動了本地執行緒分配,按執行緒優先分配在TLAB上;

優先在Eden分配

大物件直接分配在老年代

通過-XX:PretenureSizeThreshold控制;
防止提前觸發minor GC,減少新生代的複製;

長期存活的物件將進入老年代

根據Age計數器大小,設定-XX:MaxTenuringThreshold設定;

動態物件年齡判斷

如果survivor空間中相同年齡所有物件大小總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代,無須等到MaxTenuringThreshold設定的大小;

空間分配擔保

在Minor GC前(關鍵是Old最大可用連續大小):

Created with Raphaël 2.1.0Start大於New物件總和?HandlePromotionFailure?大於晉升平均大小?Minor GC成功minorGC?EndFull GCMinor GCyesnoyesnoyesnoyesno

問題和總結

1. Minor GC,Major GC和Full GC的區別和聯絡?

首先這些術語在Java語言規範和JVM規範中都沒有正式的定義,但是Hotspot中使用了它們,並在日誌中進行輸出,在分析問題時也不能夠簡單的用“Full GC發生次數多少”來作為一個標準,比如一次CMS GC產生了兩次STW(initial mark和remark)因此在日誌中會輸出為2次Full GC,但是CMS的STW時間一般很短。

總之,這些概念只是輔助標識,關鍵還是監控延遲或者吞吐量,結合GC分析導致問題的原因。

Minor GC是發生在新生代的,基於Serial(DefNew),ParNew,Parallel Scavenge(PS Yong)都是STW的,大多數年輕代中物件都進不了老年代,也就是說能挺到一次Minor GC的物件並不多,這也為什麼大部分年輕代GC都使用Copying,一個大的Eden和兩個小的Survivor;
如果Minor GC的時間很長,可能是因為新生代存活的物件太多了,都要進行復制,超過了Survivor區的大小的話,要進入老年代,這就有關空間擔保分配了。

Major GCFull GC可以看作是相同的意思,對老年代/永久代進行垃圾回收,因為一些歷史原因,這兩個概念的定義也挺混亂的,糾結這兩個概念並沒有什麼意義。

Full GC:在Serial GC(UseSerialGC)、Parallel GC(UseParallelGC)中,只有Full GC才會收集老年代(實際收集整個GC堆,包括老年代在內),使用Mark-Compact演算法;
Full GC的次數等於老年代GC時STW的次數,時間為STW總時間;
對於CMS收集器來說,Full GC只是一次CMS兩個階段或者在擔保失敗的情況下用Serial Old來代替了,因此也不能簡單Full GC的情況來分析。

Minor GC和Full GC的聯絡:從一般的程式設計習慣來看,老的物件引用新建立的物件的情況要多於新物件引用老的物件,因此老年代中的GC一般要從年輕代的引用鏈開始分析,故而可以設定Full GC進行一次Minor GC,來提高老年代GC的速度。

2. GC和Stop The World(STW):

GC總是會發生停頓,也是“Stop the World”,問題是停頓時間的長短,從這個角度上看,不同GC演算法是在努力減少停頓的同時權衡對吞吐量影響或者其他開銷。並行是為了利用多核CPU來縮短停頓的時間總量,併發是為了儘可能找出那些可以並行的部分,是進一步利用多核CPU將任務細化,減少STW,G1更是添加了預測模型來控制(儘可能)停頓的時間。

從具體的實現演算法來看,Copying和Compact的過程需要移動物件,因此在整理記憶體階段需要暫停使用者執行緒。

同步使用者執行緒和GC活動有兩種方式,一是read barrier,而是write barrier,前者的開銷更大,很少有GC使用read barrier,如果使用write barrier那麼在“移動物件”必須要暫停使用者執行緒,從而產生STW。

基於下面的列表,Serial,ParNew,PS,PS old要麼是Copying,要麼是Mark-Compact,當然它們也都是STW的。

CMS採用了以Mark-Sweep為主的方式,因此可以併發標記和併發重置;

G1從整體上來是Mark-Compact的,區域性(region之間)是複製的,但是它是把記憶體分成一個個region來處理的,可以做到每次compact一部分,而不像Serial等是一口氣Compact老年代,因此也可以縮短STW,甚至實現增量式/併發;

Serial:單執行緒STW,Copying;
Serial Old:單執行緒STW,Mark&Compact;
ParNew:多執行緒並行STW,Copying;
Parallel Scavenge:多執行緒並行,Copying;
PS Old:並行STW,Mark&Compact;
CMS:多執行緒併發/並行,initial mark,remark是STW,Mark&Sweep/Mark&Compact;
G1:多執行緒併發/並行,initial mark,remark,Mark&Compact;

3. CMS為什麼不採用Mark&Compact而是Mark&Sweep?

CMS在老年代上工作,採用的是Mark&Sweep,不直接使用Mark&Compact, 而是通過-XX:+UseCMSCompactAtFullCollection和-XX:+CMSFullGCsBeforeCompaction兩個引數來決定什麼時候採用一個壓縮,這可以說是一種混合的方式;

使用Mark&Sweep的考慮有:
(1)老年代中一個傳統的假設是物件的存活率比較高,我們可以以通過相關的引數控制進入老年代物件的大小和年齡(也就是說進入老年代的物件本來就是經過一次或多次篩選的)。基於這樣的場景,使用Copying演算法顯然是不划算的;
(2)另外一個考慮就是,Mark-Compact和Copying都是要移動物件的,因此還需要修改引用鏈中的直接引用的地址值,這對於併發重置的CMS來說顯然是一個更為複雜的問題,在Mark-Sweep模式下,不需要修改所有指標,因此也不需要暫停使用者執行緒,從而實現併發;

因此,CMS使用這種以Mark-Sweep為主,Mark-Compact為輔的GC方式是一種基於場景的折中方案;