JVM - 內功修煉之內存分配與回收策略

JVM - 內功修煉之內存分配與回收策略

 看過前面幾篇博客的同學應該已經對JVM以及其垃圾收集算法和垃圾回收器都有了一定的認識,而JVM中核心的自動內存管理其實從本質上主要解決了兩個問題:一個是對象內存分配,另一個就是對象內存回收。接下來我們主要就是針對內存分配以及回收策略進行詳細的介紹。

1.HotSpot VM GC

 在我們對內存管理進行了解之前,我們先來了解一個從我們剛剛接觸Java這門語言就肯定聽過的好東西-GC,這裏就以我們平常使用的HotSpot VMGC進行進一步的探索。

1.1 GC有幾種?

 其實針對HotSpot VM實現來說,其GC主要分爲兩大類:Partial GCFull GC

1.1.1 Partial GC

Partial GC:不對整個GC堆進行回收的一種模式

  1. Young GC(Minor GC):只收集young gen的GC。
  2. Old GC(Major GC):只收集old gen的GC。只有CMS的concurrent collection是這個模式。
  3. Mixed GC:收集整個young gen以及部分old gen的GC。只有G1有這個模式。

1.1.2 Full GC

Full GC:收集整個GC堆,包括young gen、old gen、perm gen(JDK1.8起移除永久代)等所有部分的模式。

1.2 GC觸發條件

 上面我們對GC有了一個簡單的認識,對於Minor GC來說,觸發條件其實比較簡單:當young gen中Eden區分配滿時觸發。由於Young GC後部分存活的對象會晉升至old gen,所以一般Young GCold gen佔有量會提升。

 而Full GC則相對較複雜,這裏給大家介紹一下我所瞭解的幾種情況:

  1. 調用System.gc()

     這種方式應用程序只是建議虛擬機去執行Full GC,而虛擬器並不一定會去真正執行。可通過-XX:+ DisableExplicitGC來禁止RMI調用System.gc。

     我們試想一個場景,假設總共有50M內存,第一次操作申請30M內存,操作完成後VM仍佔有25M,此時再進行一次同樣的操作申請30M則會出現OOM異常,大部分這種情況其實都發生在我們處理大數量級別的數據時,比如大數據量Excel操作。不過我們可以通過一些其他方式避免這種情況的發生比如調整整個虛擬機的配置。

     隨着JVM的發展,對於內存管理也在不斷地優化,所以一般情況下仍不建議手動去調用System.gc()而是讓虛擬機管理內存,最主要的原因就是其未知性、不可控性。但是它的存在也給一些特定情況提供了一種選擇和可能性。
  2. 老年代(Old Generation)空間不足:

     老年代空間不足的原因有很多,比較常見的場景就是大對象在新生代無法分配從而直接進入老年代,或者長期存活的對象進入老年代等。對於大對象直接在老年代分配我們可以通過儘量不創建過大的對象或者數組去避免,或者通過-Xmn等參數調整新生代的容量,使對象在新生代被回收。另外還可以通過-XX:MaxTenuringThreshold參數將對象晉升老年代的閾值進行調整。
  3. 永久代(Permanet Generation)空間不足:

     我們都知道JDK1.8之前HotSpot虛擬機中是存在永久代的,而方法區正是基於永久代中實現的,其中會存儲已被虛擬機加載的類信息(例:類版本號、方法、接口等)、常量、靜態變量、即時編譯器編譯後的代碼等數據。

     如果系統中所需加載的類、反射的類或者調用的方法過多時,永久代是有可能被佔滿的。可以適當調整永久代空間避免該問題。
  4. 空間分配擔保失敗(promotion failed)

     看過前面文章的同學已經知道了,採用複製算法的Minor GC需要老年代的空間作爲擔保,如果擔保空間不足失敗的話就會進行一次Full GC。這種情況也可以通過調整survivor space空間儘量避免。
  5. Concurrent Mode Failure

     首先從字面來看,就是併發模式的一種失敗情況。我們知道當我們使用CMS收集器時分爲很多階段,其中一個階段就是和用戶線程併發執行GC操作,此時如果有對象正在晉升到老年代並老年代空間不足時,就會引發Concurrent Mode Failure,從而觸發Full GC。這種情況同樣也可以通過調整survivor spaceold gen或調低併發GC頻率儘量避免。
  6. 統計比較晉升老年代平均大小和當前老年代剩餘空間:

     這種情況放在最後介紹主要是因爲相比之前的幾種情況稍微複雜一些。Hotspot VM爲了避免當新生代對象晉升老年代時,由於老年代空間不足而出現晉升失敗的現象,在進行Minor GC時,都會先去比較之前統計的Minor GC晉升到老年代的平均大小是否大於當前老年代的剩餘空間,大於的話則不會觸發Young GC而轉爲觸發Full GC

     這裏我們舉個例子更加形象有助於大家理解。例如第一次觸發Minor GC時,有10MB對象晉升到老年代,則第二次發生Minor GC時會先檢查老年代剩餘空間是否大於10MB,否則直接進行Full GC

1.3 談一談我對Minor GC、Major GC、Full GC的理解

 其實當大家對JVM挖掘進行到這一步的時候,對於JVM自身的內容已經有了一定的認知並且有了自己的理解,從而在某種程度上對於同一個知識點會有分歧。這裏我就先談一談最爲容易理解的Minor GC
Minor GC其實也就是我們上面所描述的Young GC,這種GC策略指只發生在新生代上的垃圾回收,由於我們Java應用中大多數對象都具有朝存夕亡的特點,所以Minor GC發生地非常頻繁,並且一般情況下回收速度都比較快。


 其實由於HotSpot VM發展了這麼多年,對於很多概念性的名詞大家可能都已經被弄混了,我也翻閱了一部分官方資料也沒有找到詳細的介紹。我個人的理解其實Major GCFull GC在大部分情況下都是等價的,都是發生在老年代的GC。

 當進行Major GC時,經常會伴隨着至少一次Minor GC。當然這並不是一定的,例如在Parallel Scavenge收集器的收集策略中可以進行相應的直接進行Major GC的策略選擇。而Major GC的速度一般會比Minor GC慢10倍以上,這個想必大家也通過各種資料瞭解到了。

 而Full GC從命名上大家可能認爲,這是一個回收整個GC堆的策略。但其實Full GC本身並不會先進行Minor GC,只不過我們可以通過配置指定讓Full GC執行前先進行一次Minor GC。而這和我們上面講的Major GC大部分情況下又很類似,主要是因爲老年代中很多對象都會引用到新生代中的對象,先進行一次Minor GC可以提高之後老年代GC的回收效率。例如當我們老年代使用CMS時,可以通過設置CMSScavengeBeforeRemark使其在重新標記前先進行一次Minor GC

Minor GCMajor GC其實我們可以看作是年輕代GC和老年代GC的俗稱。而我們之前所瞭解的Serial GCParallel GCCMSG1 GC等收集器其實就是可以對應到某個Young GCOld GC的算法組合。例如CMS並不完全等於Full GC或者Major GC,對其有深入瞭解的同學可能知道,CMS其實分爲多個階段,而只有SST(stop the world)階段才被計入Full GC的次數和時間,而與應用業務線程併發執行的GC次數以及時間則不會被計入。

2.堆內存分配及分配規則

 我們從之前幾章可以瞭解到,在JVM中的一些內存分佈結構,有新生代中的EdenSurvivor FromSurvivor To以及老年代永久代(JDK1.8爲Metaspace)。其實對於整個分佈我們已經有了一個清晰的認識,但是都是基於概念性的理解,這裏我們就從實際出發去真正觸摸到這些區域的分配規則。

2.1 對象優先在新生代的Eden區分配

 我們先來一個簡單的例子,來感受一下JVM其實就在我們身邊。先上一段樸素的代碼。

public class EdenAllocation {

   public static final int byteSize = 1024 * 1024;

   public static void main(String[] args) {
       byte[] bytes = new byte[2 * byteSize];
   }
}

 JVM提供了-verbose:gc-XX:+PrintGCDetails參數可以開啓打印GC時各內存區域的詳細情況,在實際開發中我們可以將內存回收的日誌打印到特定日誌文件中然後進行單獨分析,後面也會給大家介紹一些不錯的在線分析的工具。這裏我們先將參數加上然後運行程序。
在這裏插入圖片描述
在這裏插入圖片描述
 這裏我們可以看到只有Young GenEden區被使用了,這也能夠驗證我們所說的對象是會優先在新生代的Eden區上進行分配的。

 這裏我們再將例子進階一下,將我們之前所瞭解到的知識點更加具體化。我們首先通過-Xms20M -Xmx20M -Xmn10M限制整個Java堆的初始大小爲20M,並且最大內存也爲20M,另外將新生代的大小設置爲10M。
在這裏插入圖片描述
 我們先來看一種特殊的情況。

public class EdenAllocation {

   public static final int byteSize = 1024 * 1024;

   public static void main(String[] args) {}
}

在這裏插入圖片描述
 這裏我們先解釋一個之前的概念,就是Eden區和Survivor區我們之前知道是8:1:1,這裏從YoungGen中我們可以很明顯地看出實際上就是嚴格按照這個比例進行分配的,並且我們可以通過-XX:SurvivorRatio去控制其空間比例,這裏我們先不進行修改。

 我們可以看到當我們只聲明瞭一個靜態常量沒有其他任何業務代碼時此時年輕代仍然佔有2647K的大小,那麼我們對上面的程序進行一點修改再來看看結果如何。

public class EdenAllocation {

   public static final int byteSize = 1024 * 1024;

   public static void main(String[] args) {
       byte[] edenAllocation1 = new byte[2 * byteSize];
       byte[] edenAllocation2 = new byte[2 * byteSize];
       byte[] edenAllocation3 = new byte[2 * byteSize];
  }
}

在這裏插入圖片描述
 當我們進行程序修改後,GC日誌發生了一些變化,我們一起來看一下。

 首先我們之前知道了在沒有其他業務代碼時年輕代佔有2M左右空間。然後我們修改了對應程序,當edenAllocation1edenAllocation2分配完後,年輕代空間大概佔有6M多。此時我們進行edenAllocation3的內存分配時發生了Allocation Failure觸發了Minor GC,這是由於年輕代剩餘可用空間已經不足以給edenAllocation3進行分配,而edenAllocation1edenAllocation2仍然存活並且無法放入Survivor區,所以通過擔保機制將edenAllocation3放至老年代中。

 所以在這次GC結束後,通過日誌也可以驗證我們上面總結的整個流程。

2.2 線程本地分配緩存區-TLAB

 在介紹TLAB之前我先來帶大家回憶一個概念-指針碰撞,如果不記得的同學別急,這裏我帶大家一步步來回顧。而在講指針碰撞之前先從我們編程的起源new對象開始。

public class TlabAllocation{

   public void newObject(){
		User user = new User();
	}	
}

 這裏我們來看一下,這裏我們new了一個User對象,但是這種對象的作用域不會逃逸出方法外,關於方法逃逸棧上分配後面會提到,大概意思就是說該類對象生命週期隨着方法開始而開始,隨着方法調用結束而結束,也就是隨着棧幀的入棧創建、出棧銷燬。

 按照我們一般的想法來說,JVM中的對象都放在堆內存中,那麼當方法結束了之後沒有指向該對象的引用,該對象就需要被GC回收掉,而如果同時存在大量這種情況的話對於GC來說也是鴨梨山大的。

 這裏我們開始回想一下之前我們講的指針碰撞,在JVM - 探索HotSpot虛擬機中的對象中我們介紹過分配內存的幾種方式,下面這張圖不知道大家是否還有印象。
在這裏插入圖片描述
 指針碰撞方式分配內存時,當我們申請新的內存空間時,只需將指針向空閒內存空間移動指定內存空間大小的位置即可。那麼大家有沒有思考過一個問題:如果線程A正在給A1對象分配內存,此時指針並未來得及修改,而線程B又正在爲B1對象分配內存,那麼線程A和線程B所引用的指針地址就相同了,這樣是會出現問題的。解決這種情況的方式有很多,這裏就來講一講我們這裏的主角TLAB

2.2.1 TLAB是什麼

TLAB全稱Thread Local Allocation Buffer,即線程本地分配緩存區,這是一個線程專屬的內存分配區域。我們可以通過-XX:UseTLAB參數開啓TLAB,當每個線程初始化時會給他額外申請一塊指定大小的內存,這塊內存區域只能被當前線程使用。通過這種方式每個線程就會擁有一個獨立的內存空間,在進行內存分配時只在自己空間上分配而不會影響其他線程分配內存,既可以避免併發衝突的情況又可以提高分配效率。

TLAB的空間非常小,默認情況下佔Eden區域的1%,我們可以通過-XX:TLABWasteTargetPercent調整TLAB所佔Eden區域的百分比。另外我們可以通過-XX:+PrintTLAB來跟蹤TLAB的情況。

 我相信大家對指針碰撞其實已經很熟悉了,而TLAB的本質其實也是通過三個指針(start、end、top)來進行管理的。我們上面知道了線程初始化時會額外申請一塊指定大小的內存,而startend就是用來標記Eden區中某塊連續區域被TLAB所使用的佔位標記,而這塊空間則不允許被其他線程使用。top指針則是用於這一塊TLAB區域中分配對象使用,初始化時和start指向同一個位置,隨着這塊區域不停分配對象,top指針會逐步移向end指針所處位置,當達到某些條件時則會觸發TLAB refill,關於更詳細的內容大家有興趣可以自行深入學習瞭解。

2.2.2 TLAB有什麼缺點?

 通過上面的瞭解,我們知道了TLAB確實給內存分配以及GC帶來了很大的幫助,但是往往一件事的特性也可能會成爲他的缺點。

  1. 上面我們介紹了TLAB的空間其實是非常小的,也就是說當一個線程申請了一塊TLAB空間時其大小就固定了,假如此時需要給大對象進行分配時,則TLAB就無法分配成功了。
  2. 另一種情況就是假如TLAB空間有10kb,已經佔有6kb,此時需要爲一個5kb的對象分配內存,發現剩餘空間不足那麼JVM是怎麼處理的?其實對於TLAB有一個最大浪費空間的設置,當剩餘空間(4kb)小於最大浪費空間(假設爲5kb)則當前線程會重新在Eden區申請一個TLAB空間用於創建,若依然不夠則會直接在Eden區分配;當剩餘空間(4kb)大於最大浪費空間(假設爲3kb)則直接在Eden區分配。
  3. 正是因爲上面這點存在,雖然解決了一些問題但又引發了其他問題。當這種重複申請的行爲多了之後,首先造成的一個問題就是Eden區域空間不連續產生大量的空間碎片,另一個問題就是由於TLAB區域是允許浪費空間的,但是這部分壓力會在短時間內施加給Eden區,從而可能會觸發更多的GC。

2.3 大對象直接在老年代分配

2.3.1 大對象是什麼?

 要說大對象直接在老年代分配,那我們首先就要知道大對象到底是什麼?

 我們所說的大對象其實就是指需要大量連續內存空間的對象,我們比較熟悉的就是長數組或者字符串,這也就是我們爲什麼使用數組來進行示例的原因,對於控制內存空間大小比較明顯。過多的大對象其實對JVM的內存分配來說其實是一件不好的事情,尤其是生命週期特別短的大對象更是JVM的天敵,經常出現過多的大對象很容易導致內存有不少空間但是不得不提前觸發GC來完成分配。

2.3.2 垃圾收集器再回首

 這裏爲什麼我要再提垃圾收集器,主要原因當然是接下來我們所要涉及的內容與其關係緊密,並且我個人認爲概念性的知識與實踐的碰撞才能夠讓仍更加深刻認識和記憶這個知識點,這裏恰到好處。

 相信有心的同學應該已經注意到了,在我們之前的例子中並沒有對特定垃圾回收器進行限制,但是對於我們接下來的理解必須先來熟悉一下我們所使用的環境到底使用的是何種垃圾回收器組合。

 我們通過java -XX:+PrintCommandLineFlags -version命令來看一看到底我們的環境真實的情況是怎樣的。

-XX:InitialHeapSize=133473088 // 初始堆大小
-XX:MaxHeapSize=2135569408  // 最大堆大小
-XX:+PrintCommandLineFlags 
-XX:+UseCompressedClassPointers // 默認開啓類指針壓縮
-XX:+UseCompressedOops // 默認開啓對象指針壓縮
-XX:-UseLargePagesIndividualAllocation 
-XX:+UseParallelGC // 默認使用Parallel垃圾收集器
java version "1.8.0_131"
Java(TM) SE Runtime Environment (build 1.8.0_131-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.131-b11, mixed mode)

 這裏我們只看我們這裏要了解的關鍵信息-XX:+UseParallelGC,那麼這對應的是哪種GC組合呢?

UseGC Young Gen Old Gen 描述
UseSerialGC Serial Serial Old
UseParNewGC ParNew Serial Old
UseConcMarkSweepGC ParNew CMS
UseParallelGC Parallel Scavenge Parallel Old
UseParallelOldGC Parallel Scavenge Parallel Old UseParallelGC和UseParallelOldGC的區別

 這也就和我們之前打印的GC日誌相印證了。
在這裏插入圖片描述

2.3.3 大對象真的會直接在老年代分配嗎?

 我們在上面2.1 對象優先在新生代的Eden區分配中提到了一個例子,當分配三個2M大小的數組時,會觸發一次GC將年輕代中已經分配內存的兩個數組晉升到老年代中,大家印象不深刻的可以往回看一看。這裏我們在不修改垃圾收集器的情況下將他做一個簡單的修改。

public class EdenAllocation {

   public static final int byteSize = 1024 * 1024;

   public static void main(String[] args) {
       byte[] edenAllocation1 = new byte[2 * byteSize];
       byte[] edenAllocation2 = new byte[2 * byteSize];
       byte[] edenAllocation3 = new byte[4 * byteSize];
   }
}

 當我們將第三個數組改爲4MB那麼效果是不是也一樣呢?
在這裏插入圖片描述
 這裏我們看到,並沒有觸發GC機制,在我們看是將edenAllocation3作爲大對象直接放在老年代中進行分配了。通過JVM文檔得知其提供了-XX:PretenureSizeThreshold參數可用於調整對象直接在老年代分配的閾值,這麼做的目的是爲了避免在Eden區和兩個Survivor區之間進行大量內存複製的操作,那麼我們添加這個參數看一下效果-verbose:gc -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:PretenureSizeThreshold=5242880,這裏主要注意一點該參數不支持直接配置M單位級別數值,這裏我們配置爲5 x 1024 x 1024。
在這裏插入圖片描述
 按照理解,超過5M大小則直接在老年代分配,而我們這個例子似乎應該會進行一次GC纔對?其實不然,大家是否還記得Parallel Scavenge收集器是一款專注於吞吐量的垃圾收集器,其實它有一套自己獨立的大對象分配規則。那麼這裏我們將垃圾收集器指定爲Serial試一下效果如何,這裏我們先不去修改PretenureSizeThreshold參數,-verbose:gc -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC
在這裏插入圖片描述
 首先第一點這裏我們可以看到框起來的年輕代和老年代標識已經變更,並且觸發了一次GC。那麼我們來設置一下XX:PretenureSizeThreshold=3145728
在這裏插入圖片描述
 這一次GC沒有發生,當一個大對象達到了其設定的閾值時,不管年輕代中是否能夠分配都會直接在老年代中分配,這裏我們用SerialGC進行了驗證,至於其他的大家有興趣可以自己去檢驗。而Parallel Scavenge收集器是根據我們“碰巧”設置的4MB爲閾值去區分大對象還是有其一套獨特的算法和規定這個大家可以自己去深入探索。

 儘管很多同學平時出於各種原因會儘可能去避免修改一些JVM的相關配置害怕出現各種自己不熟悉的問題,但是內存回收和垃圾收集器在很多時候都是影響系統性能和併發能力的重要因素之一。而JVM提供各種各樣的收集器和調節參數,就是因爲各種業務場景需要不同的組合選擇才能達到最優的性能。

 所以對於JVM來說,並沒有固定的收集器、參數組合以及最優的調優方式,也就不會存在一定性的內容分配和回收策略。而我們去學習這些知識、瞭解各種設計的優劣,就是因爲JVM給了我們一片天地,我們不僅僅是隻爲生存(也就是使用),而更長遠的則是需要我們去創造(根據實際需要進行調整和嘗試)。

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