發表於 2016-05-29 | 分類於 Java , GC | html
GC 是每個Java程序員不可繞過的話題。GC 是在某些時候
對內存
的垃圾對象數據
進行搜尋定位
,而後進行內存空間回收
。根據這個定義,則學習GC相關知識,須要關注:對JVM整個內存結構中哪些區域進行垃圾回收;在這些內存區域中的類數據或者實例數據等數據結構是什麼樣子的;而後想一想如何在JVM內存空間中分配內存給這些實例數據;在全部已分配了的實例裏,怎麼找出須要回收的數據。java
綜上,對於JVM GC知識體系來講,就是弄清楚JVM在何時,對什麼對象,進行什麼操做來回收內存空間。node
既然是對內存進行GC操做,那麼首先須要瞭解 JVM 的內存結構了。git
在Oracle的官方文檔【Java Garbage Collection Basics】中,給出了JVM的總體架構圖,以下所示:程序員
也就是說,對一個JVM來講,其主要由 ClassLoader
和 Runtime Data 區域
,執行引擎
以及本地方法
四大部分組成。(關鍵的性能優化集中在Heap
,JIT編譯
和GC
三大塊)。github
從架構圖中,能夠看出,ClassLoader
是用來加載java class文件的,也就是說,在java的世界裏,全部的*.class
都必須經過ClassLoader
加載到JVM虛擬機中,才能被執行。web
對於每個JVM來講,都會有一個默認的ClassLoader
,用來加載二進制文件到內存中。固然,既然是默認,必然就存在自定義的ClassLoader
了。算法
可是,在JVM中,存在自定義的ClassLoader
,就須要考慮若是不讓自定義的實現類加載邏輯,致使java基礎類庫的對象發生不可預知的安全問題,因此JVM 的ClassLoader
對類加載的順序邏輯進行的限制。這就是所謂的雙親委派模型
。編程
上圖(來自【深刻分析Java ClassLoader原理】)展現了JVM ClassLoader 的三個模型的加載器以及應用能夠本身實現的自定義加載器。從圖上能夠看出除了Bootstrap ClassLoader
不存在父類以後,其餘ClassLoader都有父類加載器。segmentfault
雙親委派模型
就是說,ClassLoader 在加載類的時候,其首先去父類裏查找是否其已經加載了該類,一層層遞歸查找,沒有的話,就依次本身嘗試加載該類,若是沒找到,則會拋出ClassNotFoundException
,當最外層的子類依然沒加載成功,則最終的依次將會拋出到應用中。
簡單來講,就是首先向上(父類方向)查找類是否加載,而後若是沒有已經被加載,則向下(子類方向)嘗試去加載類,直到加載成功,或者拋出異常。
簡單地源碼以下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false);//向上查找 } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); c = findClass(name);//本身加載 // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } } |
上圖【JVM 的 工做原理,層次結構 以及 GC工做原理】中能夠看出線程和內存區域之間的關係。對於具體的內存空間結構,以下圖所示:
上圖包含除ClassLoader
以外,JVM架構的其餘三個部分。執行引擎中JIT是對java程序執行時的優化操做,GC引擎則是本文的重點,後面會詳細介紹。本地方法則是使用C/C++編寫的底層代碼程序,這裏不做介紹。
下面重點來講說,綠色區域的運行時數據區。
本地方法棧和虛擬機棧在HotSpot實現裏,是合併在一塊兒的,統稱爲棧。
如上圖,方法區中其中一個重要內容就是運行時常量池,可是這個在JDK7中已經移到堆中存儲了。運行時常量池,包括編譯後的字符常量,以及運行時的常量。所以,之前String.itern()致使的常量池爆掉拋出
java.lang.OutOfMemoryError: PermGen space
錯誤,如今沒有了。
JVM 的內存結構,實際上就是堆和棧,棧是線程私有的,所以上面的對象不須要同步操做,而且重點是因爲棧幀是伴隨着線程方法的生命週期的,因此其內部的垃圾回收不用考慮(這裏說的是存儲在棧上的數據,固然方法裏的變量,實例數據等分配在堆上的,仍是須要垃圾回收的)。
堆上面包含幾乎全部交互操做數據,各個對象實例,變量等信息都是由堆來分配管理,所以,JVM的垃圾回收就是基於堆的垃圾回收。
垃圾回收是回收內存中標記爲再也不使用的對象實例,所以,須要瞭解對象的結構和對應的引用被訪問的方式。
在C的世界裏,咱們會常常計算一個數據結構所佔用的內存空間;那麼對於Java而言,一個對象的數據結構和內存佔用是怎樣的呢。
對象的實例內存佈局主要包括三部分:對象頭(Header)、實例數據(Instance Data)和對其數據(Padding)。
對於JDK默認的虛擬機HotSpot來講,其對象頭主要包含兩部分:
Mark Word
,用於存儲對象自身的運行時數據,好比hashcode,GC分代年齡
(這個在分代GC中很重要)以及鎖狀態標識和線程ID等相關信息。這部分在32bit和64bit的JVM上佔用的空間是不一樣的,分別爲4B 和 8B。以下圖所示:無論32仍是64,其GC分代年齡都是4bit,所以,分代年齡最高爲15。
ins instanceOf Clazz
方法,就是首先經過ins對象頭中該指針來找到方法區中該類的相關信息,而後匹配Clazz以及其super鏈,匹配上了,則說明是其對應的實例對象。此外,若是是數組類型的話,則在對象頭中還有一塊空間是用來存儲數組長度,無論32仍是64,這個長度佔用的空間都是4B,所以,數組最大長度爲
Integer.MAX_VALUE
。通常的,對象頭在32bit下,大小爲4B+4B=8B,而64bit爲8B+8B=16B。(開啓指針壓縮,則爲8B+4B=12B)
顧名思義,就是對象真正存儲有效信息的地方。不管數據是從父類繼承下來的,仍是子類中定義的,都在這裏記錄下來。java中基礎類型的內存佔用以下:
類型 | 佔用字節bytes |
---|---|
boolean | 1 |
byte | 1 |
short | 2 |
char | 2 |
int | 4 |
float | 4 |
long | 8 |
double | 8 |
ref | 4/8 |
通常來講,一個對象實例原生數據和實例在一塊兒,而最引用類型只是一個地址,具體的數據,在指向堆上其餘對應的實例。
在HotSpot JVM 中,內存中的數據都要求是按8B字節對其的,若是一個對象大小不是8B,則會自動填充對其到8B的倍數。
如上文所述,一個對象的數據,可能會包含其餘對象的引用,那麼在使用的時候,就須要找到對應的對象數據信息。
【HotSpot虛擬機對象探祕】
Java程序須要經過棧上的reference數據來操做堆上的具體對象。因爲reference類型在Java虛擬機規範裏面只規定了是一個指向對象的引用,並無定義這個引用應該經過什麼種方式去定位、訪問到堆中的對象的具體位置,對象訪問方式也是取決於虛擬機實現而定的。主流的訪問方式有使用句柄和直接指針兩種。
這兩種對象訪問方式各有優點,使用句柄來訪問的最大好處就是reference中存儲的是穩定句柄地址,在對象被移動(垃圾收集時移動對象是很是廣泛的行爲)時只會改變句柄中的實例數據指針,而reference自己不須要被修改。
使用直接指針來訪問最大的好處就是速度更快,它節省了一次指針定位的時間開銷,因爲對象訪問的在Java中很是頻繁,所以這類開銷積小成多也是一項很是可觀的執行成本。從上一部分講解的對象內存佈局能夠看出,就虛擬機HotSpot
而言,它是使用第二種方式進行對象訪問,但在整個軟件開發的範圍來看,各類語言、框架中使用句柄來訪問的狀況也十分常見。
垃圾對象查找,首先須要明確什麼對象能夠認爲是垃圾對象。
判斷一個對象是否是垃圾對象,須要去回收其佔用的內存空間,通常有兩張方法,一種是基於引用計數,一種是可達性分析。
引用計數,操做起來很是簡單。就是在全局維護一個Map,當引用一個對象的時候,就給對應的map entry 的value加1;當這個引用失效的時候,就將對應的value減1。當value的值爲0的時候,就說明此刻該對象是垃圾對象,能夠被回收掉。
所以,引用計數基本上能夠作到實時去回收空間,可是它有一個大問題,就是沒法處理循環引用的問題。此外,對每個對象的申請或者銷燬都會致使其內部引用的對象計數進行實時更改,這個操做量級仍是至關大的,這種開銷有時候對應用來講可能都不能容忍。好比,有一個對象內部包含了很是多的局部變量,而且引用的這些變量可能全局Map都剩1,則當這個對象銷燬的時候,其會觸發大量的引用計數爲0,從而大量對象進行銷燬回收操做,這些操做可能致使系統暫時沒法響應其餘請求。
所以,如今一些編譯器即便使用引用計數自動回收垃圾,也會加上其餘輔助算法,好比標記-清除。
可達性分享,也就是 Tracing GC
。Tracing GC的核心操做之一就是從給定的根集合出發去遍歷對象圖。對象圖是一種有向圖,該圖的節點是對象,邊是引用。遍歷它有兩種典型順序:深度優先(DFS)和廣度優先(BFS)。【HotSpot VM Serial GC的一個問題】
因爲深度優先DFS通常採用遞歸方式實現,處理tracing的時候,可能會致使棧空間溢出,因此通常採用廣度優先來實現tracing。
廣度優先遍歷其實很簡單,就是將遍歷到了的節點處理,而後將子節點放入到隊列中,迭代處理每一個隊列節點,直到隊列爲空。以下圖所示:
對於HotSpot來講,其GC Roots
爲:
所以,在遍歷以前,將這些對象放入掃描的隊列中,而後依次迭代遍歷。
下面給出了一個簡化版的遍歷算法實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
void breadth_first_search(Graph* graph) { // 記錄灰色對象的隊列 Queue<Node*> scanning; // 1. 一開始對象都是白色的 // 2. 把根集合的引用能碰到的對象標記爲灰色 // 因爲根集合的引用有可能有重複,因此這裏也必須 // 在把對象加入隊列前先檢查它是否已經被掃描到了 for (Node* node : graph->root_edges()) { // 若是出邊指向的對象尚未被掃描過 if (node != nullptr && !node->is_marked()) { node->set_marked(); // 記錄下它已經被掃描到了 scanning.enqueue(child); // 也把該對象放進灰色隊列裏等待掃描 } } // 3. 逐個掃描灰色對象的出邊直到沒有灰色對象 while (!scanning.is_empty()) { Node* parent = scanning.dequeue(); for (Node* child : parent->child_nodes() { // 掃描灰色對象的出邊 // 若是出邊指向的對象尚未被掃描過 if (child != nullptr && !child->is_marked()) { child->set_marked(); // 把它記錄到黑色集合裏 scanning.enqueue(child); // 也把該對象放進灰色隊列裏等待掃描 } } } } |
如上遍歷完以後,若是某些對象沒有被遍歷標記到,則它就有可能
會被回收了。
可能被回收,則說明也有可能不會被回收。其實,在HotSpot的實現中,對象是有兩次機會逃過被垃圾回收銷燬的命運的。
若是對象進行可達性遍歷以後,發現沒有和GC Roots有相連的引用,則將該對象標記爲不可達對象,而且進行篩選:對象是否覆蓋finalize方法或者是否已經執行了該方法來決定是否接下來執行
finalize()
方法。若是對象斷定爲須要執行finalize()方法,則將該對象放入
F-Queue
隊列中,而後由一個虛擬機建立的低優先級Finalizer線程去觸發執行隊列中的finalize()方法,可是不必定會被執行(可能前面的finalize方法致使線程一直阻塞沒法繼續遍歷執行下去)。所以,這種狀況下,對象可能並不會被銷燬回收。稍後GC將對
F-Queue
隊列進行第二次標記,若是此次標記還無效的話,則真的被回收銷燬了。
【這段 Java 代碼中的局部變量可以被提早回收嗎?編譯器或 VM 可以實現以下的人工優化嗎?】
【找出棧上的指針/引用】
在前面章節中已經介紹了內存對象結構和訪問方式,而且知道棧幀中的引用類型是GC Roots集合中的一個重要的部分,所以要獲取這些引用類型數據就是GC的重要一步了。
若是JVM選擇不記錄任何這種類型的數據,那麼它就沒法區份內存裏某個位置上的數據到底應該解讀爲引用類型仍是原生類型。這種GC就是「保守式GC(conservative GC)」。在進行GC的時候,JVM開始從一些已知位置(例如說JVM棧)開始掃描內存,掃描的時候每看到一個數字就看看它「像不像是一個指向GC堆中的指針」。這裏會涉及上下邊界檢查(GC堆的上下界是已知的)、對齊檢查(一般分配空間的時候會有對齊要求,假如說是8字節對齊,那麼不能被8整除的數字就確定不是指針),之類的。而後遞歸的這麼掃描出去。
保守式GC的好處是相對來講實現簡單些,並且能夠方便的用在對GC沒有特別支持的編程語言裏提供自動內存管理功能。好比在C或者C++語言中本身實現。
保守式GC的缺點有:
會有部分對象原本應該已經死了,但有疑似指針指向它們,使它們逃過GC的收集。這對程序語義來講是安全的,由於全部應該活着的對象都會是活的;但對內存佔用量來講就不是件好事,總會有一些已經不須要的數據還佔用着GC堆空間。具體實現能夠經過一些調節來讓這種無用對象的比例少一些,能夠緩解(但不能根治)內存佔用量大的問題。
因爲不知道疑似指針是否真的是指針,因此它們的值都不能改寫;移動對象就意味着要修正指針。換言之,對象就不可移動
了。有一種辦法能夠在使用保守式GC的同時支持對象的移動,那就是增長一個間接層,不直接經過指針來實現引用,而是添加一層「句柄」(handle)在中間,全部引用先指到一個句柄表裏,再從句柄表找到實際對象。這樣,要移動對象的話,只要修改句柄表裏的內容便可。可是這樣的話引用的訪問速度就下降了。Sun JDK的Classic VM曾經用過這種全handle的設計,但效果實在算不上好。
因爲JVM要支持豐富的反射功能,原本就須要讓對象能瞭解自身的結構,並且這種信息GC的時候也能夠利用上,因此JVM都會保留一些信息在對象上,而不會採用徹底保守式的GC。
JVM能夠選擇在棧上不記錄類型信息,而在對象上記錄類型信息。這樣的話,掃描棧的時候仍然會跟上面說的過程同樣,但掃描到GC堆內的對象時,由於對象帶有足夠類型信息,JVM就夠判斷出在該對象內什麼位置的數據是引用類型了。這種是「半保守式GC」。
爲了支持半保守式GC,運行時須要在對象上帶有足夠的元數據。若是是JVM的話,這些數據可能在類加載器或者對象模型的模塊裏計算獲得,但不須要JIT編譯器的特別支持。
因爲半保守式GC在堆內部的數據是準確的,因此它能夠在直接使用指針來實現引用的條件下支持部分對象的移動,方法是隻將保守掃描能直接掃到的對象設置爲不可移動(pinned),而從它們出發再掃描到的對象就能夠移動了。
徹底保守的GC一般使用不移動對象的算法,例如mark-sweep。半保守方式的GC既可使用mark-sweep,也可使用移動部分對象的算法。半保守式GC對JNI方法調用的支持會比較容易:管它是否是JNI方法調用,是棧都掃過去,不須要對引用作任何額外的處理。固然代價跟徹底保守式同樣,會有「疑似指針」的問題。
與保守式GC相對的是「準確式GC」。也就是說給定某個位置上的某塊數據,要能知道它的準確類型是什麼,這樣才能夠合理地解讀數據的含義;GC所關心的含義就是「這塊數據是否是指針」。 要實現這樣的GC,JVM就要可以判斷出全部位置上的數據是否是指向GC堆裏的引用,包括活動記錄(棧+寄存器)裏的數據。
實現方法是:從外部記錄下類型信息,存成映射表。HotSpot把這樣的數據結構叫作OopMap。其每次都遍歷原始的映射表,循環的一個個偏移量掃描過去;這種用法也叫「解釋式」;HotSpot採用的是這種方式。(示例能夠參考safePoint例子)
在HotSpot中,對象的類型信息裏有記錄本身的OopMap,記錄了在該類型的對象內什麼偏移量上是什麼類型的數據。因此從對象開始向外的掃描能夠是準確的;這些數據是在類加載過程當中計算獲得的。
每一個被JIT編譯事後的方法也會在一些特定的位置記錄下OopMap,記錄了執行到該方法的某條指令的時候,棧上和寄存器裏哪些位置是引用。這樣GC在掃描棧的時候就會查詢這些OopMap就知道哪裏是引用了。這些位置就是「安全點」(safepoint)。之因此要選擇一些特定的位置來記錄OopMap,是由於若是對每條指令(的位置)都記錄OopMap的話,這些記錄就會比較大,那麼空間開銷會顯得不值得。選用一些比較關鍵的點來記錄就能有效的縮小須要記錄的數據量,但仍然能達到區分引用的目的。由於這樣,HotSpot中GC不是在任意位置均可以進入,而只能在safepoint處進入。而仍然在解釋器中執行的方法(非JIT優化的代碼)則能夠經過解釋器裏的功能自動生成出OopMap出來給GC用。
平時這些OopMap都是壓縮了存在內存裏的;在GC的時候才按需解壓出來使用。
在HotSpot JVM中,解釋執行的方法能夠在任何字節碼邊界上執行GC,可是對於採用JIT編譯執行的方法,則不能想GC就GC了,必須進入到GC SafePoint 位置。
好比下面的這個簡單方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public static void main() { LargeObject lo = new LargeObject(); // safepoint 1/2: 被動safepoint // OopMap 1: 空。 // 該棧幀裏還沒有有任何引用類型的局部變量是活躍的——new還沒執行完 // OopMap 2: 記錄了一個變量:剛分配的對象的引用是活的,在OopMap裏;不過這還不是局部變量lo而是求值棧上的一個slot lo.doSomeThing(); // safepoint 3: 被動safepoint // OopMap 3: 空。局部變量lo的最後一次使用是做爲上面方法的"this"參數傳遞出去; // 維持"this"的存活是被調用方法的責任而不是調用方法的責任。此後局部變量lo再也沒有被使用過,因此對main()來講lo在此處已死。 while (true) { whatever(); // safepoint 4: 被動safepoint // OopMap 4: 空。 // safepoint 5: 主動safepoint:循環回跳輪詢(backedge poll) // OopMap 5: 空。 } // 上面循環是無限循環,因此下面若是有代碼都屬於不可到達的代碼(unreachable code) // 若是上面的循環不是無限循環的話,則: // safepoint 6: 主動safepoint:返回前輪詢(return poll) // OopMap 6: 空 } |
前面講了這麼多,都是在介紹JVM內存中一些區域的劃分,對象在內存中的結構,以及HotSpot JVM 支持的精確式GC所採用的數據結構等等。
下面,進入重點。Java 中建立一個對象時,JVM 內存如何處理,當內存空間不夠時,又是如何觸發垃圾回收的。
通常的,在java裏面討論內存分配都覺得是創建在堆上,但這個不熟絕對的。細分的話,給一個新對象分配內存空間,多是在棧上,TLAB(Thread Local Allocation Buffer) 或者堆上。
在 JVM中提供了一種內存分配優化的方式,就是基於逃逸分析技術,將新的對象實例分配到棧上。所謂逃逸分析,就是分析指針動態範圍
的方法。簡而言之,就是當一個對象的指針被多個方法或線程引用時,咱們稱這個指針發生了逃逸。而用來分析這種逃逸現象的方法,就稱之爲逃逸分析。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class Test{ public static List<Integer> list ; public void setList(){ list = new ArrayList<>(); // list是全局變量,發生逃逸 } public List<Integer> getListValue(){ return new ArrayList<>();; // 返回了list,發生了逃逸 } public void echoTmp(){ TestClass clazz = new TestClass();//未發生逃逸 clazz.echo(); System.out.println('ok.'); } } |
那麼,當一個方法中有變量沒有發生逃逸事件,則VM能夠根據設置進行內存分配優化。
從上文的對象定位能夠看到,通常狀況下,java對象都是在堆上分配,而後其引用指針保存在調用棧對應位置,而後在訪問的時候,須要兩次查找才能獲取完全部的須要的對象信息,而在棧上分配對象內存的話,就避免了這個耗時。此外,當對象不在使用的時候,因爲只是方法內部使用,因此下次GC就須要回收這些對象,這又須要GC線程去遍歷對象樹來回收內存。
逃逸分析的內存優化並不能在靜態編譯的時候進行,而須要在JIT動態編譯的時候觸發。逃逸分析是基於方法內部執行來的,當時在靜態編譯的時候,因爲Java動態代理,反射等特性致使最終運行時候的邏輯是沒法知道的。關於這個話題具體討論參考【逃逸分析爲什麼不能在編譯期進行?】
在測試的時候,你須要
-XX:+DoEscapeAnalysis
設置來打開逃逸分析的優化技術。可是因爲基於逃逸分析開銷會有一些,而這些未必能抵住得到的性能,因此須要仔細測試是否須要打開該項優化。
在建立新的對象而申請內存的時候,則須要去堆上獲取內存空間,在多線程的狀況下,則須要進行同步鎖操做,進而影響分配效率,所以,JVM在內存新生代Eden Space
中開闢了一小塊線程私有的區域,稱做TLAB。默認設定爲佔用Eden Space的1%。
在Java程序中不少對象都是小對象且用完以後就須要回收,它們不存在線程共享也適合被快速GC,因此對於小對象一般JVM會優先分配在TLAB上,而且TLAB上的分配因爲是線程私有因此沒有鎖開銷。所以在實踐中分配多個小對象的效率一般比分配一個大對象的效率要高。
TLAB是針對線程的小對象的,若是須要分配的對象過大,仍是須要在堆上進行分配。
-XX:TLABWasteTargetPercent
來設置TLAB佔用Eden區的比例。
若是優化全開,則JVM會先進行逃逸分析,若是未逃逸則在棧上進行分配;不然在TLAB是否有足夠的空間分配,若是有,則在TLAB上分配空間;若是依然失敗,則要在堆上進行分配了。
Notes:因爲堆是全部線程共享的,因此分配時是須要加鎖同步的。
Oracle官方參考文檔【Java Garbage Collection Basics】
首先,來看看JVM 堆空間詳細的分代劃分:
通常,咱們使用
-Xms
來設置JVM初始內存空間大小,-Xmx
設計JVM最大內存空間大小,-Xmn
來設置新生代的內存大小(推薦爲整個堆的3/8),-Xss
來設置虛擬機棧的空間大小。默認,Eden區和Survivor區的內存空間比例爲8:1:1
,
而後,看一個新對象是如何建立的:
循環進行,當下一個minor GC發生的時候,不存活的對象被刪除回收,存活的對象被copy到survivor1中。此外,上一次 minor GC被遷移到Survior0上的對象,存活的遷移到Survivor1上,而且對應的對象分代年齡上的value+1。所以,在S1上,就存在不一樣分代年齡的對象了。
在下一次minor GC中,依然重複上面的動做,將Eden區和S1區的仍然存活的對象copy到S0上,而且對應對象的分代年齡+1。
在minor GC 以後,若是存在對象的分代年齡超過設置的-XX:MaxTenuringThreshold
值時,則copy到年老代Tenured區。
最後,隨着Tenured區的對象愈來愈多,從而觸發了major GC,改GC會將年老代的不用對象進行清除,而後壓縮年老代內存空間。
當須要分配的對象內存足夠大時,則直接分配到Tenured區。Full GC和major GC 不同,其GC時,包含minor GC 和 major GC。
在介紹內存回收以前,須要對內存對象生命週期進行分析。IBM研究代表,絕大部分新生對象都是朝生夕死的,也就是一次GC以後就會把對應內存空間釋放出來,最後存活下來的對象不多。以下圖所示(顯然絕大部分對象在第一次GC的時候就被回收掉了):
上圖還代表,不一樣的對象的生命週期不一樣,對不一樣的GC策略影響也是不一樣的。這種表現行爲說明,須要對內存對象空間進行分類,所以,在HotSpot中,進行了分代:年輕代和年老代,簡單來理解(不徹底):年輕代都是最近才分配內存空間的對象,而年老代則是在年輕代存活足夠多的時間升級到年老代來的對象。它的分代內存空間能夠參考上圖(內存分配)。
常見的GC策略:
標記-清除策略(Mark-Sweep)。
標記-清除策略應該是垃圾回收策略裏面最基礎最容易想到的算法。其核心思想,就是將須要進行回收的對象進行標記,而後再將沒有標記的內存對象進行回收銷燬。所以,這個算法存在兩個階段,首先須要從GC Rootes開始根據前面介紹的可達性分析來遍歷整個內存對象樹,把遍歷過得對象進行標記;而後,GC算法將全部未marked的對象delete掉。整個算法很容易明白,可是對於內存分配和回收策略來講,其存在二個比較大的問題:
複製策略(Copying)。
因爲標記-清除策略效率不高,對於高併發實時服務來講,是很致命的。
根據內存生命週期的特色,咱們將新生的對象放在一個新生代的Eden內存區域中,而後GC存活下來的對象複製到另外一塊小的內存區域S0/S1中。此外,新生代可能內存不足或者一些須要長期存活的對象,這些對象就須要分配移動到年老代的內存區域中。
對於標記-清除兩步驟的操做,複製策略因爲採起的內存對象操做不一樣,致使其只須要一個步驟就能完成GC操做。在遍歷GC Roots 對象樹的時候,同時將標記到得對象複製到新生代小的S0/S1內存區域中,這樣,當咱們遍歷完對象空間的時候,能夠將整個新生代Eden區域內存分配的當前有效內存開始指針置爲開始位置,這樣,咱們就操做完了對新生代對象的GC回收。
標記-整理策略(Mark-Compact/Sweep)。
複製策略效率很是高,可是其是針對新生代的對象特性而優化的,對於年老代的對象來講,其沒有那麼高的效率,此外主要的就是其浪費大量的內存空間用於複製GC下來的對象。所以,標記-清除策略對於老年代來講,比複製算法仍是更好的,咱們只須要解決內存碎片化問題便可。所以,這就是標記-整理算法了。
也就是,咱們在標記完整個內存區域的對象樹以後,將存活下來的對象依次移動到內存的一端,而後將內存分配指針設置爲存活對象的最後,從而完成內存的清理。
HotSpot VM 對外提供的垃圾回收:
如上圖所示,對於年輕代的垃圾回收主要有三種:Serial GC,ParNew GC,ParallelScavenge(PS)。
單線程垃圾回收。單線程串行的,因此在進行垃圾回收的時候,須要全部的應用線程都暫停(Stop The World
)。全局單線程執行垃圾回收,會獲得最好的單線程收集效率,所以對於client來講,其實默認的垃圾回收策略。
顧名思義,就是Serial GC的多線程版本,一樣也是Stop The World
。因爲ParNew GC 是目前惟一一個能夠專一於老年代GC收集器CMS 配合的並行年輕代GC收集器,CMS優秀的GC性能,致使ParNew GC 被不少server應用採用。
併發和並行收集器:併發指應用工做線程和GC線程能夠同時執行,分別在不一樣地CPU上;並行指的是多個GC線程同時工做,此時的工做線程是STOP的狀態。
ParallelSvavenge 收集器也是採用複製算法的多線程年輕代收集器。和ParNew GC不一樣的是,其關注點在可控吞吐量上,然後者則重視的是GC時間。所以,ParNew收集器適用於用戶交互場景,而PS則適用於CPU計算而少交互的場景。吞吐量指CPU用於運行用戶代碼的時間和CPU總運行時間的比例,即吞吐量=用戶代碼運行時間/(用戶代碼運行時間+GC時間)。
ParallelScavenge和ParNew都是並行GC,主要是並行收集年輕代,目的和性能其實都差很少。
最明顯的區別有下面幾點:
在JDK6u18以後,PS只用深度優先遍歷。ParNew則是一直都只用廣度優先順序來遍歷。
PS完整實現了自適應大小策略,而ParNew內則沒有實現完。因此千萬別在用
ParNew + CMS
的組合下用UseAdaptiveSizePolicy,請只在使用UseParallelGC或UseParallelOldGC的時候用它。- 因爲在」分代式GC框架」內,ParNew能夠跟CMS搭配使用,而ParallelScavenge不能。當時ParNew GC被從Exact VM移植到HotSpot VM的最大緣由就是爲了跟CMS搭配使用。
如上圖所示,對於年輕代的垃圾回收主要有三種:Serial Old,Parallel Old,Concurrent Mark Sweep(CMS)。
老年代單線程收集器,使用標記整理策略。Serial Old的標記-整理策略是先標記存活對象,而後先清理掉須要回收的對象,而後將存活的對象進行移動,保證一部分都是存活對象,一部分是空閒的內存空間。和SerialGC 同樣,都須要暫停全部工做線程。
client模式下默認的Old GC。在Server模式下,當CMS GC時出現
Concurrent Mode Failure
,則回退使用Serial Old 收集器。
Parallel Old 是多線程並行的老年代GC收集器,其主要是配合年輕代PS收集器的老年代版本。
和Serial Old不一樣的整理策略,其首先彙總存活的對象複製到一個區域,而後壓縮起來,而不是直接清除再壓縮(爲何不直接將存活的對象移動到一側呢?)。
以上全部的收集器在執行GC的整個過程當中都須要Stop The World,即不容許工做線程在GC的時候運行。
遇到的絕大部分server通常都是採用ParNew + CMS 收集器策略。
CMS收集器目標是獲取最短停頓時間的收集器,因此和上面的收集器不一樣,雖然其也會Stop The World
,可是在有些階段是能夠併發進行的。所以,特別適合用戶交互場景的服務系統。
CMS 主要分爲4個階段:
上面四個步驟中,1和3是須要暫停工做線程的。
因爲在實際中,應用特別多,因此這裏具體分析下CMS收集器。
【Java系列筆記(3) - Java 內存區域和GC機制】
CMS收集的執行過程是:初始標記(CMS-initial-mark) -> 併發標記(CMS-concurrent-mark) –>預清理(CMS-concurrent-preclean)–>可控預清理(CMS-concurrent-abortable-preclean)-> 從新標記(CMS-remark) -> 併發清除(CMS-concurrent-sweep) ->併發重設狀態等待下次CMS的觸發(CMS-concurrent-reset)
具體的說,先2次標記,1次預清理,1次從新標記,再1次清除。
此階段會打印2條日誌:CMS-concurrent-mark-start,CMS-concurrent-mark
YG occupancy:964861K(2403008K),指執行時young代的狀況 CMS remark:961330K(1572864K),指執行時old代的狀況此外,還打印出了弱引用處理、類卸載等過程的耗時
有2種狀況會觸發CMS 的悲觀full gc,在悲觀full gc時,整個應用會暫停
A,concurrent-mode-failure:預清理階段可能出現,當cms gc正進行時,此時有新的對象要進行old代,可是old代空間不足形成的。其可能性有:1,O區空間不足以讓新生代晉級,2,O區空間用完以前,沒法完成對無引用的對象的清理。這代表,當前有大量數據進入內存且沒法釋放。
B,promotion-failed:新生代young gc可能出現,當進行young gc時,有部分young代對象仍然可用,可是S1或S2放不下,所以須要放到old代,但此時old代空間沒法容納此。
影響cms gc時長及觸發的參數是如下2個:
-XX:CMSMaxAbortablePrecleanTime=5000
-XX:CMSInitiatingOccupancyFraction=80
解決也是針對這兩個參數來的,根本的緣由是每次請求消耗的內存量過大
解決方式:
A,針對cms gc的觸發階段,調整-XX:CMSInitiatingOccupancyFraction=50,提前觸發cms gc,就能夠緩解當old代達到80%,cms gc處理不完,從而形成concurrent mode failure引起full gc
B,修改-XX:CMSMaxAbortablePrecleanTime=500,縮小CMS-concurrent-abortable-preclean階段的時間
C,考慮到cms gc時不會進行compact,所以加入-XX:+UseCMSCompactAtFullCollection
(cms gc後會進行內存的compact)和-XX:CMSFullGCsBeforeCompaction=4(在full gc4次後會進行compact)參數
在CMS清理過程當中,只有初始標記和從新標記須要短暫停頓,併發標記和併發清除都不須要暫停用戶線程,所以效率很高,很適合高交互的場合。 CMS也有缺點,它須要消耗額外的CPU和內存資源,在CPU和內存資源緊張,CPU較少時,會加劇系統負擔(CMS默認啓動線程數爲(CPU數量+3)/4)。 另外,在併發收集過程當中,用戶線程仍然在運行,仍然產生內存垃圾,因此可能產生「浮動垃圾」,本次沒法清理,只能下一次Full GC才清理,所以在GC期間,須要預留足夠的內存給用戶線程使用。因此使用CMS的收集器並非老年代滿了才觸發Full GC,而是在使用了一大半(默認68%,即2/3,使用-XX:CMSInitiatingOccupancyFraction來設置)的時候就要進行Full GC,若是用戶線程消耗內存不是特別大,能夠適當調高-XX:CMSInitiatingOccupancyFraction以下降GC次數,提升性能,若是預留的用戶線程內存不夠,則會觸發Concurrent Mode Failure,此時,將觸發備用方案:使用Serial Old 收集器進行收集,但這樣停頓時間就長了,所以-XX:CMSInitiatingOccupancyFraction不宜設的過大。 還有,CMS採用的是標記清除算法,會致使內存碎片的產生,可使用-XX:+UseCMSCompactAtFullCollection來設置是否在Full GC以後進行碎片整理,用-XX:CMSFullGCsBeforeCompaction來設置在執行多少次不壓縮的Full GC以後,來一次帶壓縮的Full GC。