3.深入理解java虛擬機--第二部分--- 垃圾收集器與內存分配策略

3.1概述

哪些內存需要回收?[插圖]什麼時候回收?[插圖]如何回收?

3.2對象已死了嗎?

在堆中存放在幾乎所有的java對象的實例,垃圾回收器在回收前,第一件事就是確定哪些對象是活的哪些對象已經死了

3.2.1引用計數算法

常用的是引用及計數算法:即給對象添加一個計數器,每當有地方引用它,計數器就加1,當引用失效,計數器就減一,任何時候計數器爲0 的對象就是不可再用的對象,就是垃圾回收的目標.但是主流的java虛擬機沒有使用引用計數算,因爲很難解決對象之間的相互循環引用的問題.

舉個簡單的例子,testGC()方法:對象objA和objB都有字段instance,賦值令objA.instance=objB及objB.instance=objA,除此之外,這兩個對象再無任何引用,實際上這兩個對象已經不可能再被訪問,但是它們因爲互相引用着對方,導致它們的引用計數都不爲0,於是引用計數算法無法通知GC收集器回收它們。

從運行結果中可以清楚看到,GC日誌中包含“4603K->210K”,意味着虛擬機並沒有因爲這兩個對象互相引用就不回收它們,這也從側面說明虛擬機並不是通過引用計數算法來判斷對象是否存活的。

3.2.2可達性分析算法

在主流的語言,java,C++稱都是使用可達性分析算法.判斷對象是否存活.其基本思路的是,從一個叫GCRoots對象作爲起點,向下搜索,當一個對象與gcroot之間沒有引用鏈時,這個對象就是被回收的目標,即使這個對象與其他對象有關聯,仍然是被回收的目標.

 

在Java語言中,可作爲GC Roots的對象包括下面幾種:

  • 虛擬機棧(棧幀中的本地變量表)中引用的對象。
  • 方法區中類靜態屬性引用的對象。
  • 方法區中常量引用的對象。
  • 本地方法棧中JNI(即一般說的Native方法)引用的對象。

\

3.2.3再談引用

在JDK 1.2之後,Java對引用的概念進行了擴充,將引用分爲強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4種,這4種引用強度依次逐漸減弱。

強引用就是指在程序代碼之中普遍存在的,類似“Object obj = new Object()”這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象

軟引用是用來描述一些還有用但並非必需的對象。對於軟引用關聯着的對象,在系統將要發生內存溢出異常之前,將會把這些對象列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的內存,纔會拋出內存溢出異常。在JDK 1.2之後,提供了SoftReference類來實現軟引用

弱引用也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象。在JDK 1.2之後,提供了WeakReference類來實現弱引用

虛引用也稱爲幽靈引用或者幻影引用,它是最弱的一種引用關係。一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。爲一個對象設置虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知。在JDK1.2之後,提供了PhantomReference類來實現虛引用。

3.2.4生存還是死亡

即使在可達性分析算法中不可達的對象,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程:如果對象在進行可達性分析後發現沒有與GC Roots相連接的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。當對象沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機調用過,虛擬機將這兩種情況都視爲“沒有必要執行”。

finalize()方法是對象逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的對象進行第二次小規模的標記,如果對象要在finalize()中成功拯救自己——只要重新與引用鏈上的任何一個對象建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變量或者對象的成員變量,那在第二次標記時它將被移除出“即將回收”的集合;如果對象這時候還沒有逃脫,那基本上它就真的被回收了。從代碼清單3-2中我們可以看到一個對象的finalize()被執行,但是它仍然可以存活。

從finalize()方法中我們可以看出,對象可以自救,並且只有一次自救機會

3.2.5回收方法區

永久代的垃圾收集主要回收兩部分內容:廢棄常量和無用的類.在方法區裏使用垃圾回收效率很差,性價比很低.回收同堆裏的對象回收,就是看有無String對象引用這個常量,如果沒有,就會發生回收

判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。類需要同時滿足下面3個條件才能算是“無用的類”:(判斷方法區裏的類是否是無用的類,需要滿足三個條件,①該類的實例在堆中已經被回收了,②該類的類加載器已經被回收③關於該類的class對象沒有在任何地方唄引用,也沒任何地方使用反射訪問該類的方法)

  • 該類所有的實例都已經被回收,也就是Java堆中不存在該類的任何實例。
  • 加載該類的ClassLoader已經被回收。
  • 該類對應的java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

虛擬機可以對滿足上述3個條件的無用類進行回收,這裏說的僅僅是“可以”,而並不是和對象一樣,不使用了就必然會回收。是否對類進行回收,HotSpot虛擬機提供了-Xnoclassgc參數進行控制,還可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看類加載和卸載信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虛擬機中使用,-XX:+TraceClassUnLoading參數需要FastDebug版的虛擬機支持。

3.3垃圾收集算法

3.3.1標記清除算法

最基礎的算法是標記清除算法.(mark-sweep) ,算法分爲標記和清除兩個階段:先標記要回收的對象在標記完成後統一進行回收被標記的對象.這種方法有兩種不足:①效率問題,②空間問題標記清除之後會產生大量不連續的內存碎片

 

3.2.2複製算法

爲了解決效率問題,一種稱爲“複製”(Copying)的收集算法出現了,它將可用內存按容量劃分爲大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活着的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。這樣使得每次都是對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等複雜情況,只要移動堆頂指針,按順序分配內存即可,實現簡單,運行高效。只是這種算法的代價是將內存縮小爲了原來的一半,未免太高了一點。複製算法的執行過程如圖3-3所示。

現在的商業虛擬機都採用這種收集算法來回收新生代,IBM公司的專門研究表明,新生代中的對象98%是“朝生夕死”的,所以並不需要按照1∶1的比例來劃分內存空間,而是將內存分爲一塊較大的Eden(伊甸園)空間和兩塊較小的Survivor(倖存者)空間,每次使用Eden和其中一塊Survivor[插圖]。當回收時,將Eden和Survivor中還存活着的對象一次性地複製到另外一塊Survivor空間上,最後清理掉Eden和剛纔用過的Survivor空間。HotSpot虛擬機默認Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用內存空間爲整個新生代容量的90% (80%+10%),只有10%的內存會被“浪費”。當然,98%的對象可回收只是一般場景下的數據,我們沒有辦法保證每次回收都只有不多於10%的對象存活,當Survivor空間不夠用時,需要依賴其他內存(這裏指老年代)進行分配擔保(HandlePromotion)。

3.3.3標記-整理算法

複製算法的在成活率比較高的情況下進行復制,其效率會降低.在老年代中一般不使用複製算法

標記-整理mark-compact,同標記-清除一樣,先標記,標記完後不是清除,而是將標記的移到一邊,然後直接清理掉端邊界以外的內存

3.3.4分代收集算法

當前垃圾收集都使用的是分代收集算法

這種算法就是將內存分快,不同的區域採用不同的手機算法,新生代採用複製算法,老年代採用標記清理-標記-整理算法

這種算法並沒有什麼新的思想,只是根據對象存活週期的不同將內存劃分爲幾塊。一般是把Java堆分爲新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集算法。在新生代中,每次垃圾收集時都發現有大批對象死去,只有少量存活,那就選用複製算法,只需要付出少量存活對象的複製成本就可以完成收集。而老年代中因爲對象存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記—清理”或者“標記—整理”算法來進行回收。

3.4Hotspot的算法實現

3.2節和3.3節從理論上介紹了對象存活判定算法和垃圾收集算法,而在HotSpot虛擬機上實現這些算法時,必須對算法的執行效率有嚴格的考量,才能保證虛擬機高效運行。

3.4.1枚舉根節點

,可達性分析對執行時間的敏感還體現在GC停頓上,因爲這項分析工作必須在一個能確保一致性的快照中進行——這裏“一致性”的意思是指在整個分析期間整個執行系統看起來就像被凍結在某個時間點上,不可以出現分析過程中對象引用關係還在不斷變化的情況,該點不滿足的話分析結果準確性就無法得到保證。這點是導致GC進行時必須停頓所有Java執行線程(Sun將這件事情稱爲“Stop The World”)的其中一個重要原因,即使是在號稱(幾乎)不會發生停頓的CMS收集器中,枚舉根節點時也是必須要停頓的

目前都是使用的是準確式GC,系統停頓下來時候,並不是一個不漏的檢查引用關係,而是在類加載時候用OOpmap的結構來達到目的,類加載時候對象的偏移量類型都計算出來 了,GC掃描時候就是直接得知.

3.4.2安全點

實際上,HotSpot也的確沒有爲每條指令都生成OopMap,前面已經提到,只是在“特定的位置”記錄了這些信息,這些位置稱爲安全點(Safepoint),即程序執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。Safepoint的選定既不能太少以致於讓GC等待時間太長,也不能過於頻繁以致於過分增大運行時的負荷。所以,安全點的選定基本上是以程序“是否具有讓程序長時間執行的特徵”爲標準進行選定的——因爲每條指令執行的時間都非常短暫,程序不太可能因爲指令流長度太長這個原因而過長時間運行,“長時間執行”的最明顯特徵就是指令序列複用,例如方法調用、循環跳轉、異常跳轉等,所以具有這些功能的指令纔會產生Safepoint。

對於Sefepoint,另一個需要考慮的問題是如何在GC發生時讓所有線程(這裏不包括執行JNI調用的線程)都“跑”到最近的安全點上再停頓下來。這裏有兩種方案可供選擇:搶先式中斷,和主動式中斷

其中搶先式中斷不需要線程的執行代碼主動去配合,在GC發生時,首先把所有線程全部中斷,如果發現有線程中斷的地方不在安全點上,就恢復線程,讓它“跑”到安全點上。現在幾乎沒有虛擬機實現採用搶先式中斷來暫停線程從而響應GC事件。

而主動式中斷的思想是當GC需要中斷線程的時候,不直接對線程操作,僅僅簡單地設置一個標誌,各個線程執行時主動去輪詢這個標誌,發現中斷標誌爲真時就自己中斷掛起。

3.4.3安全區域

安全區域解決的是:在程序“不執行”的時候.所謂的程序不執行就是沒有分配CPU時間,典型的例子就是線程處於Sleep狀態或者Blocked狀態,這時候線程無法響應JVM的中斷請求,“走”到安全的地方去中斷掛起,JVM也顯然不太可能等待線程重新被分配CPU時間。對於這種情況,就需要安全區域(SafeRegion)來解決。

安全區域是指在一段代碼片段之中,引用關係不會發生變化。在這個區域中的任意地方開始GC都是安全的。我們也可以把Safe Region看做是被擴展了的Safepoint

3.5垃圾收集器

下圖是JDK1.7,update14以後的收集器,圖中展示了7種作用於不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用。虛擬機所處的區域,則表示它是屬於新生代收集器還是老年代收集器。

我們先來明確一個觀點:雖然我們是在對各個收集器進行比較,但並非爲了挑選出一個最好的收集器。因爲直到現在爲止還沒有最好的收集器出現,更加沒有萬能的收集器,所以我們選擇的只是對具體應用最合適的收集器。這點不需要多加解釋就能證明:如果有一種放之四海皆準、任何場景下都適用的完美收集器存在,那HotSpot虛擬機就沒必要實現那麼多不同的收集器了。

3.5.1serial收集器

Serial收集器是最基本、發展歷史最悠久的收集器,曾經(在JDK 1.3.1之前)是虛擬機新生代收集的唯一選擇。大家看名字就會知道,這個收集器是一個單線程的收集器,但它的“單線程”的意義並不僅僅說明它只會使用一個CPU或一條收集線程去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作線程,直到它收集結束。“Stop The World”這個名字也許聽起來很酷,但這項工作實際上是由虛擬機在後臺自動發起和自動完成的,在用戶不可見的情況下把用戶正常工作的線程全部停掉,這對很多應用來說都是難以接受的。讀者不妨試想一下,要是你的計算機每運行一個小時就會暫停響應5分鐘,你會有什麼樣的心情?圖3-6示意了Serial/Serial Old收集器的運行過程。

比較幽默哈:

對於“Stop The World”帶給用戶的不良體驗,虛擬機的設計者們表示完全理解,但也表示非常委屈:“你媽媽在給你打掃房間的時候,肯定也會讓你老老實實地在椅子上或者房間外待着,如果她一邊打掃,你一邊亂扔紙屑,這房間還能打掃完?”這確實是一個合情合理的矛盾,雖然垃圾收集這項工作聽起來和打掃房間屬於一個性質的,但實際上肯定還要比打掃房間複雜得多啊!

寫到這裏,筆者似乎已經把Serial收集器描述成一個“老而無用、食之無味棄之可惜”的雞肋了,但實際上到現在爲止,它依然是虛擬機運行在Client模式下的默認新生代收集器。它也有着優於其他收集器的地方:簡單而高效(與其他收集器的單線程比),對於限定單個CPU的環境來說,Serial收集器由於沒有線程交互的開銷,專心做垃圾收集自然可以獲得最高的單線程收集效率。在用戶的桌面應用場景中,分配給虛擬機管理的內存一般來說不會很大,收集幾十兆甚至一兩百兆的新生代(僅僅是新生代使用的內存,桌面應用基本上不會再大了),停頓時間完全可以控制在幾十毫秒最多一百多毫秒以內,只要不是頻繁發生,這點停頓是可以接受的。所以,Serial收集器對於運行在Client模式下的虛擬機來說是一個很好的選擇

3.5.2parNew收集器

ParNew收集器其實就是Serial收集器的多線程版本,除了使用多條線程進行垃圾收集之外,其餘行爲包括Serial收集器可用的所有控制參數(例如:-XX:SurvivorRatio、-X X:P retenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、對象分配規則、回收策略等都與Serial收集器完全一樣,在實現上,這兩種收集器也共用了相當多的代碼。

parNew許多運行在Server模式下的虛擬機中首選的新生代收集器,因爲只有它與老生代的CMS配合工作(CMS支持併發收集)

ParNew收集器在單CPU的環境中絕對不會有比Serial收集器更好的效果,甚至由於存在線程交互的開銷,該收集器在通過超線程技術實現的兩個CPU的環境中都不能百分之百地保證可以超越Serial收集器。當然,隨着可以使用的CPU的數量的增加,它對於GC時系統資源的有效利用還是很有好處的。它默認開啓的收集線程數與CPU的數量相同,在CPU非常多(譬如32個,現在CPU動輒就4核加超線程,服務器超過32個邏輯CPU的情況越來越多了)的環境下,可以使用-XX:ParallelGCThreads參數來限制垃圾收集的線程數。

從ParNew收集器開始,後面還會接觸到幾款併發和並行的收集器。在大家可能產生疑惑之前,有必要先解釋兩個名詞:併發和並行。這兩個名詞都是併發編程中的概念,在談論垃圾收集器的上下文語境中,它們可以解釋如下。

● 並行(Parallel):指多條垃圾收集線程並行工作,但此時用戶線程仍然處於等待狀態。

● 併發(Concurrent):指用戶線程與垃圾收集線程同時執行(但不一定是並行的,可能會交替執行),用戶程序在繼續運行,而垃圾收集程序運行於另一個CPU上。

3.5.3 Parallel Scavenge收集器

Parallel Scavenge收集器的特點是它的關注點與其他收集器不同,CMS等收集器的關注點是儘可能地縮短垃圾收集時用戶線程的停頓時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput)

所謂吞吐量就是CPU用於運行用戶代碼的時間與CPU總消耗時間的比值,即吞吐量 = 運行用戶代碼時間 /(運行用戶代碼時間 +垃圾收集時間),虛擬機總共運行了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%

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收集器還有一個參數-XX:+UseAdaptiveSizePolicy值得關注。這是一個開關參數,當這個參數打開之後,就不需要手工指定新生代的大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRatio)、晉升老年代對象年齡(-XX:PretenureSizeThreshold)等細節參數了,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量,這種調節方式稱爲GC自適應的調節策略.

自適應調節策略也是Parallel Scavenge收集器與ParNew收集器的一個重要區別。

3.5.4serial old收集器

Serial Old是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用“標記-整理”算法

3.5.5Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多線程和“標記-整理”算法

3.5.6CMS收集器

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

它的運作過程相對於前面幾種收集器來說更復雜一些,整個過程分爲4個步驟,包括:

  • 初始標記(CMS initial mark)
  • 併發標記(CMS concurrent mark)
  • 重新標記(CMS remark)
  • 併發清除(CMS concurrent sweep)

其中,初始標記、重新標記這兩個步驟仍然需要“Stop The World”。初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快,併發標記階段就是進行GC Roots Tracing的過程,而重新標記階段則是爲了修正併發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短。由於整個過程中耗時最長的併發標記和併發清除過程收集器線程都可以與用戶線程一起工作,所以,從總體上來說,CMS收集器的內存回收過程是與用戶線程一起併發執行的。通過圖3-10可以比較清楚地看到CMS收集器的運作步驟中併發和需要停頓的時間。

CMS是一款優秀的收集器,它的主要優點在名字上已經體現出來了:併發收集、低停頓

但是它也有三個明顯的缺點:

  • CMS收集器對CPU資源非常敏感。其實,面向併發設計的程序都對CPU資源比較敏感。在併發階段,它雖然不會導致用戶線程停頓,但是會因爲佔用了一部分線程(或者說CPU資源)而導致應用程序變慢,總吞吐量會降低。CMS默認啓動的回收線程數是(CPU數量+3)/ 4,也就是當CPU在4個以上時,併發回收時垃圾收集線程不少於25%的CPU資源,並且隨着CPU數量的增加而下降。但是當CPU不足4個(譬如2個)時,CMS對用戶程序的影響就可能變得很大,如果本來CPU負載就比較大,還分出一半的運算能力去執行收集器線程,就可能導致用戶程序的執行速度忽然降低了50%,其實也讓人無法接受。爲了應付這種情況,虛擬機提供了一種稱爲“增量式併發收集器”(Incremental Concurrent MarkSweep/i-CMS)的CMS收集器變種,所做的事情和單CPU年代PC機操作系統使用搶佔式來模擬多任務機制的思想一樣,就是在併發標記、清理的時候讓GC線程、用戶線程交替運行,儘量減少GC線程的獨佔資源的時間,這樣整個垃圾收集的過程會更長,但對用戶程序的影響就會顯得少一些,也就是速度下降沒有那麼明顯。實踐證明,增量時的CMS收集器效果很一般,在目前版本中,i-CMS已經被聲明爲“deprecated”,即不再提倡用戶使用。
  • CMS收集器無法處理浮動垃圾(Floating Garbage),可能出現“Concurrent ModeFailure”失敗而導致另一次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”失敗,性能反而降低
  • 還有最後一個缺點,在本節開頭說過,CMS是一款基於“標記—清除”算法實現的收集器,如果讀者對前面這種算法介紹還有印象的話,就可能想到這意味着收集結束時會有大量空間碎片產生。空間碎片過多時,將會給大對象分配帶來很大麻煩,往往會出現老年代還有很大空間剩餘,但是無法找到足夠大的連續空間來分配當前對象,不得不提前觸發一次FullGC。爲了解決這個問題,CMS收集器提供了一個-XX:+UseCMSCompactAtFullCollection開關參數(默認就是開啓的),用於在CMS收集器頂不住要進行FullGC時開啓內存碎片的合併整理過程,內存整理的過程是無法併發的,空間碎片問題沒有了,但停頓時間不得不變長。虛擬機設計者還提供了另外一個參數-XX:CMSFullGCsBeforeCompaction,這個參數是用於設置執行多少次不壓縮的Full GC後,跟着來一次帶壓縮的(默認值爲0,表示每次進入Full GC時都進行碎片整理)。

3.5.7 G1收集器

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)的垃圾收集器的特徵了

如果不計算維護Remembered Set的操作,G1收集器的運作大致可劃分爲以下幾個步驟:

  • 初始標記(Initial Marking)
  • 併發標記(Concurrent Marking)
  • 最終標記(Final Marking)
  • 篩選回收(Live Data Counting and Evacuation)

 

3.5.8理解GC日誌

每一種收集器的日誌形式都是由它們自身的實現所決定的,換而言之,每個收集器的日誌格式都可以不一樣。但虛擬機設計者爲了方便用戶閱讀,將各個收集器的日誌都維持一定的共性,例如以下兩段典型的GC日誌:

最前面的數字“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)”。

接下來的“[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時間是完全正常的。

3.5.9垃圾收集器參數的總結

3.6內存分配與回收策略

3.6.1對象優先在新生代Eden中分配 

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

虛擬機提供了-XX:+PrintGCDetails這個收集器日誌參數,告訴虛擬機在發生垃圾收集行爲時打印內存回收日誌,並且在進程退出的時候輸出當前的內存各區域分配情況。在實際應用中,內存回收日誌一般是打印到文件後通過日誌工具進行分析,不過本實驗的日誌並不多,直接閱讀就能看得很清楚。

代碼清單3-5的testAllocation()方法中,嘗試分配3個2MB大小和1個4MB大小的對象,在運行時通過-Xms20M、-Xmx20M、-Xmn10M這3個參數限制了Java堆大小爲20MB,不可擴展,其中10MB分配給新生代,剩下的10MB分配給老年代。-XX:SurvivorRatio=8決定了新生代中Eden區與一個Survivor區的空間比例是8∶1,從輸出的結果也可以清晰地看到“eden space8192K、from space 1024K、to space 1024K”的信息,新生代總可用空間爲9216KB(Eden區+1個Survivor區的總容量)。

執行testAllocation()中分配allocation4對象的語句時會發生一次Minor GC,這次GC的結果是新生代6651KB變爲148KB,而總內存佔用量則幾乎沒有減少(因爲allocation1、allocation2、allocation3三個對象都是存活的,虛擬機幾乎沒有找到可回收的對象)。這次GC發生的原因是給allocation4分配內存的時候,發現Eden已經被佔用了6MB,剩餘空間已不足以分配allocation4所需的4MB內存,因此發生Minor GC。GC期間虛擬機又發現已有的3個2MB大小的對象全部無法放入Survivor空間(Survivor空間只有1MB大小),所以只好通過分配擔保機制提前轉移到老年代去。

這次GC結束後,4MB的allocation4對象順利分配在Eden中,因此程序執行完的結果是Eden佔用4MB(被allocation4佔用),Survivor空閒,老年代被佔用6MB(被allocation1、allocation2、allocation3佔用)。通過GC日誌可以證實這一點。

3.6.2大對象直接進入老年代

所謂的大對象是指,需要大量連續內存空間的Java對象,最典型的大對象就是那種很長的字符串以及數組(替Java虛擬機抱怨一句,比遇到一個大對象更加壞的消息就是遇到一羣“朝生夕滅”的“短命大對象”,寫程序的時候應當避免)經常出現大對象容易導致內存還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們.

虛擬機提供了一個-XX:PretenureSizeThreshold參數,令大於這個設置值的對象直接在老年代分配。這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的內存複製(複習一下:新生代採用複製算法收集內存)。

執行代碼清單3-6中的testPretenureSizeThreshold()方法後,我們看到Eden空間幾乎沒有被使用,而老年代的10MB空間被使用了40%,也就是4MB的allocation對象直接就分配在老年代中,這是因爲PretenureSizeThreshold被設置爲3MB(就是3145728,這個參數不能像-Xmx之類的參數一樣直接寫3MB),因此超過3MB的對象都會直接在老年代進行分配。

注意

PretenureSizeThreshold參數只對Serial和ParNew兩款收集器有效,Parallel Scavenge收集器不認識這個參數,Parallel Scavenge收集器一般並不需要設置。如果遇到必須使用此參數的場合,可以考慮ParNew加CMS的收集器組合

3.6.3長期存活的對象將直接進入老年代

既然虛擬機採用了分代收集的思想來管理內存,那麼內存回收時就必須能識別哪些對象應放在新生代,哪些對象應放在老年代中。爲了做到這點,虛擬機給每個對象定義了一個對象年齡(Age)計數器。如果對象在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並且對象年齡設爲1。對象在Survivor區中每“熬過”一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認爲15歲),就將

會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold設置.

讀者可以試試分別以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15兩種設置來執行代碼清單3-7中的testTenuringThreshold()方法,此方法中的allocation1對象需要256KB內存,Survivor空間可以容納。當MaxTenuringThreshold=1時,allocation1對象在第二次GC發生時進入老年代,新生代已使用的內存GC後非常乾淨地變成0KB。而MaxTenuringThreshold=15時,第二次GC發生後,allocation1對象則還留在新生代Survivor空間,這時新生代仍然有404KB被佔用。

3.6.4動態對象年齡判斷

爲了能更好地適應不同程序的內存狀況,虛擬機並不是永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡

3.6.5空間分配擔保

在發生Minor GC之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果這個條件成立,那麼Minor GC可以確保是安全的。如果不成立,則虛擬機會查看HandlePromotionFailure設置值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試着進行一次MinorGC,儘管這次Minor GC是有風險的;如果小於,或者HandlePromotionFailure設置不允許冒險,那這時也要改爲進行一次Full GC。

下面解釋一下“冒險”是冒了什麼風險,前面提到過,新生代使用複製收集算法,但爲了內存利用率,只使用其中一個Survivor空間來作爲輪換備份,因此當出現大量對象在Minor GC後仍然存活的情況(最極端的情況就是內存回收後新生代中所有對象都存活),就需要老年代進行分配擔保,把Survivor無法容納的對象直接進入老年代。與生活中的貸款擔保類似,老年代要進行這樣的擔保,前提是老年代本身還有容納這些對象的剩餘空間,一共有多少對象會活下來在實際完成內存回收之前是無法明確知道的,所以只好取之前每一次回收晉升到老年代對象容量的平均大小值作爲經驗值,與老年代的剩餘空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。取平均值進行比較其實仍然是一種動態概率的手段,也就是說,如果某次Minor GC存活後的對象突增,遠遠高於平均值的話,依然會導致擔保失敗(Handle Promotion Failure)。如果出現了HandlePromotionFailure失敗,那就只好在失敗後重新發起一次Full GC。雖然擔保失敗時繞的圈子是最大的,但大部分情況下都還是會將HandlePromotionFailure開關打開,避免FullGC過於頻繁,參見代碼清單3-9,請讀者在JDK 6 Update 24之前的版本中運行測試。

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