1. 程式人生 > 遊戲攻略 >《艾爾登法環》前期開荒實用小貼士

《艾爾登法環》前期開荒實用小貼士

JVM垃圾回收

當需要排查各種記憶體溢位問題、當垃圾收整合為系統達到更高併發的瓶頸時,我們就需要對這些“自動化”的技術實施必要的監控和調節。

JVM記憶體分配

Java 的自動記憶體管理主要是針對物件記憶體的回收和物件記憶體的分配。同時,Java 自動記憶體管理最核心的功能是 堆 記憶體中物件的分配與回收。

Java 堆是垃圾收集器管理的主要區域,因此也被稱作GC 堆(Garbage Collected Heap).從垃圾回收的角度,由於現在收集器基本都採用分代垃圾收集演算法,所以 Java 堆還可以細分為:新生代和老年代:再細緻一點有:Eden 空間、From Survivor、To Survivor 空間等。進一步劃分的目的是更好地回收記憶體,或者更快地分配記憶體。

大部分情況,物件都會首先在 Eden 區域分配,在一次新生代垃圾回收後,如果物件還存活,則會進入 s0 或者 s1,並且物件的年齡還會加 1(Eden 區->Survivor 區後物件的初始年齡變為 1),當它的年齡增加到一定程度(預設為大於 15 歲),就會被晉升到老年代中。

物件晉升到老年代的年齡閾值,可以通過引數 -XX:MaxTenuringThreshold 來設定預設值,這個值會在虛擬機器執行過程中進行調整,可以通過-XX:+PrintTenuringDistribution來打印出當次 GC 後的 Threshold。

“Hotspot 遍歷所有物件時,按照年齡從小到大對其所佔用的大小進行累積,當累積的某個年齡大小超過了 survivor 區的一半時,取這個年齡和 MaxTenuringThreshold 中更小的一個值,作為新的晉升年齡閾值”。

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空間的大小
size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
  //sizes陣列是每個年齡段物件大小
  total += sizes[age];
  if (total > desired_survivor_size) {
      break;
  }
  age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}

經過這次 GC 後,Eden 區和"From"區已經被清空。這個時候,"From"和"To"會交換他們的角色,也就是新的"To"就是上次 GC 前的“From”,新的"From"就是上次 GC 前的"To"。不管怎樣,都會保證名為 To 的 Survivor 區域是空的。Minor GC 會一直重複這樣的過程,在這個過程中,有可能當次 Minor GC 後,Survivor 的"From"區域空間不夠用,有一些還達不到進入老年代條件的例項放不下,則放不下的部分會提前進入老年代。

除錯程式碼引數如下

-verbose:gc
-Xmx200M
-Xms200M
-Xmn50M
-XX:+PrintGCDetails
-XX:TargetSurvivorRatio=60
-XX:+PrintTenuringDistribution
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:MaxTenuringThreshold=3
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC

示例程式碼:

/*
* 本例項用於java GC以後,新生代survivor區域的變化,以及晉升到老年代的時間和方式的測試程式碼。需要自行分步註釋不需要的程式碼進行反覆測試對比
*
* 由於java的main函式以及其他基礎服務也會佔用一些eden空間,所以要提前空跑一次main函式,來看看這部分佔用。
*
* 自定義的程式碼中,我們使用堆內分配陣列和棧內分配陣列的方式來分別模擬不可被GC的和可被GC的資源。
*
*
* */

public class JavaGcTest {

    public static void main(String[] args) throws InterruptedException {
        //空跑一次main函式來檢視java服務本身佔用的空間大小,我這裡是佔用了3M。所以40-3=37,下面分配三個1M的陣列和一個34M的垃圾陣列。


        // 為了達到TargetSurvivorRatio(期望佔用的Survivor區域的大小)這個比例指定的值, 即5M*60%=3M(Desired survivor size),
        // 這裡用1M的陣列的分配來達到Desired survivor size
        //說明: 5M為S區的From或To的大小,60%為TargetSurvivorRatio引數指定,可以更改引數獲取不同的效果。
        byte[] byte1m_1 = new byte[1 * 1024 * 1024];
        byte[] byte1m_2 = new byte[1 * 1024 * 1024];
        byte[] byte1m_3 = new byte[1 * 1024 * 1024];

        //使用函式方式來申請空間,函式執行完畢以後,就會變成垃圾等待回收。此時應保證eden的區域佔用達到100%。可以通過調整傳入值來達到效果。
        makeGarbage(34);

        //再次申請一個數組,因為eden已經滿了,所以這裡會觸發Minor GC
        byte[] byteArr = new byte[10*1024*1024];
        // 這次Minor Gc時, 三個1M的陣列因為尚有引用,所以進入From區域(因為是第一次GC)age為1
        // 且由於From區已經佔用達到了60%(-XX:TargetSurvivorRatio=60), 所以會重新計算物件晉升的age。
        // 計算方法見上文,計算出age:min(age, MaxTenuringThreshold) = 1,輸出中會有Desired survivor size 3145728 bytes, new threshold 1 (max 3)字樣
        //新的陣列byteArr進入eden區域。


        //再次觸發垃圾回收,證明三個1M的陣列會因為其第二次回收後age為2,大於上一次計算出的new threshold 1,所以進入老年代。
        //而byteArr因為超過survivor的單個區域,直接進入了老年代。
        makeGarbage(34);
    }
    private static void makeGarbage(int size){
        byte[] byteArrTemp = new byte[size * 1024 * 1024];
    }
}

堆記憶體分為新生代和老年代,新生代是用於存放使用後準備被回收的物件,老年代是用於存放生命週期比較長的物件。

物件優先在Eden中進行分配

大部分我們建立的物件,都屬於生命週期比較短的,所以會存放在新生代。新生代又細分Eden空間、From Survivor空間、To Survivor空間,我們建立的物件,物件優先在Eden分配。

隨著物件的建立,Eden剩餘記憶體空間越來越少,就會觸發Minor GC,於是Eden的存活物件會放入From Survivor空間。

Minor GC後,新物件依然會往Eden分配。

Eden剩餘記憶體空間越來越少,又會觸發Minor GC,於是Eden和From Survivor的存活物件會放入To Survivor空間。

大物件直接進老年代

在上面的流程中,如果一個物件很大,一直在Survivor空間複製來複制去,那很費效能,所以這些大物件直接進入老年代。

可以用XX:PretenureSizeThreshold來設定這些大物件的閾值。

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

在上面的流程中,如果一個物件Hello_A,已經經歷了15次Minor GC還存活在Survivor空間中,那他即將轉移到老年代。這個15可以通過-XX:MaxTenuringThreshold來設定的,預設是15。

虛擬機器為了給物件計算他到底經歷了幾次Minor GC,會給每個物件定義了一個物件年齡計數器。如果物件在Eden中經過第一次Minor GC後仍然存活,移動到Survivor空間年齡加1,在Survivor區中每經歷過Minor GC後仍然存活年齡再加1。年齡到了15,就到了老年代。

動態年齡判斷

除了年齡達到MaxTenuringThreshold的值,還有另外一個方式進入老年代,那就是動態年齡判斷在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代。

比如Survivor是100M,Hello1和Hello2都是3歲,且總和超過了50M,Hello3是4歲,這個時候,這三個物件都將到老年代。

主要進行GC的區域

針對 HotSpot VM 的實現,它裡面的 GC 其實準確分類只有兩大種:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只對新生代進行垃圾收集;
  • 老年代收集(Major GC / Old GC):只對老年代進行垃圾收集。需要注意的是 Major GC 在有的語境中也用於指代整堆收集;
  • 混合收集(Mixed GC):對整個新生代和部分老年代進行垃圾收集。

整堆收集 (Full GC):收集整個 Java 堆和方法區。

空間分配擔保

上面的流程提過,存活的物件都會放入另外一個Survivor空間,如果這些存活的物件比Survivor空間還大呢?整個流程如下:

  1. Minor GC之前,虛擬機器會先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,如果大於,則發起Minor GC
  2. 如果小於,則看HandlePromotionFailure有沒有設定,如果沒有設定,就發起full gc。
  3. 如果設定了HandlePromotionFailure,則看老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果小於,就發起full gc。
  4. 如果大於,發起Minor GC。Minor GC後,看Survivor空間是否足夠存放存活物件,如果不夠,就放入老年代,如果夠放,就直接存放Survivor空間。如果老年代都不夠放存活物件,擔保失敗(Handle Promotion Failure),發起full gc。

如何判斷物件存活

堆中幾乎放著所有的物件例項,對堆垃圾回收前的第一步就是要判斷哪些物件已經死亡(即不能再被任何途徑使用的物件)。

當我們呼叫一個方法的時候,就會建立這個方法的棧幀,當方法呼叫結束的時候,這個棧幀出棧,棧幀所佔用的記憶體也隨之釋放。

如果這個執行緒銷燬了,那與這個執行緒相關的棧以及程式計數器的記憶體也隨之被回收,那在堆記憶體中建立的物件怎麼辦這些物件可是都佔著很多的記憶體資源的。因此我們需要知道哪些物件是可以回收的,哪些物件是不能回收的。

引用計數法

給物件中新增一個引用計數器,每當有一個地方引用它,計數器就加 1;當引用失效,計數器就減 1;任何時候計數器為 0 的物件就是不可能再被使用的。

這個方法實現簡單,效率高,但是目前主流的虛擬機器中並沒有選擇這個演算法來管理記憶體,其最主要的原因是它很難解決物件之間相互迴圈引用的問題。 所謂物件之間的相互引用問題,如下面程式碼所示:除了物件 objA 和 objB 相互引用著對方之外,這兩個物件之間再無任何引用。但是他們因為互相引用對方,導致它們的引用計數器都不為 0,於是引用計數演算法無法通知 GC 回收器回收他們。

public class ReferenceCountingGc {
    Object instance = null;
	public static void main(String[] args) {
		ReferenceCountingGc objA = new ReferenceCountingGc();
		ReferenceCountingGc objB = new ReferenceCountingGc();
		objA.instance = objB;
		objB.instance = objA;
		objA = null;
		objB = null;

	}
}

可達性分析演算法

可達性演算法就是從GC Roots出發,去搜索他引用的物件,然後根據這個引用的物件,繼續查詢他引用的物件。

如果一個物件到GC Roots沒有任何引用鏈相連,說明他是不可用的,這個類就可以回收,比如下圖的object5、object6、object7。

我們回憶一下合併圖:

  1. 類載入到方法區的時候,初始化階段會為靜態變數賦值,他所引用的物件可以做GC Roots。
  2. 同樣的,方法區的常量引用的物件可以做GC Roots。
  3. 呼叫方法的時候,會建立方法的棧幀,棧幀裡的區域性變數引用的物件,可以做GC Roots。
  4. 同樣的,本地方法棧中棧幀裡的區域性變數引用的物件,可以做GC Roots。
  5. 所有被同步鎖持有的物件

物件可以被回收,就代表一定會被回收嗎?

即使在可達性分析法中不可達的物件,也並非是“非死不可”的,這時候它們暫時處於“緩刑階段”,要真正宣告一個物件死亡,至少要經歷兩次標記過程;可達性分析法中不可達的物件被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行 finalize 方法。當物件沒有覆蓋 finalize 方法,或 finalize 方法已經被虛擬機器呼叫過時,虛擬機器將這兩種情況視為沒有必要執行。

被判定為需要執行的物件將會被放在一個佇列中進行第二次標記,除非這個物件與引用鏈上的任何一個物件建立關聯,否則就會被真的回收。

可達性演算法除了GC Roots,還有一個引用,引用分以下幾種:

  1. 強引用(Strong Reference):只要強引用還存在,垃圾收集器永遠不會回收被引用的物件。
  2. 軟引用(Soft Reference):在系統將要發生記憶體溢位異常之前,將會把這些物件列進回收範圍之中進行第二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常。
  3. 弱引用(Weak Reference ):被弱引用關聯的物件只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前記憶體是否足夠, 都會回收掉只被弱引用關聯的物件。
  4. 虛引用(Phantom Reference):一個物件是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個物件例項。為一個物件設定虛引用關聯的唯一目的就是能在這個物件被收集器回收時收到一個系統通知。

如何判斷一個常量是廢棄常量?

執行時常量池主要回收的是廢棄的常量。那麼,我們如何判斷一個常量是廢棄常量呢?

  1. JDK1.7 之前執行時常量池邏輯包含字串常量池存放在方法區, 此時 hotspot 虛擬機器對方法區的實現為永久代
  2. JDK1.7 字串常量池被從方法區拿到了堆中, 這裡沒有提到執行時常量池,也就是說字串常量池被單獨拿到堆,執行時常量池剩下的東西還在方法區, 也就是 hotspot 中的永久代
  3. JDK1.8 hotspot 移除了永久代用元空間(Metaspace)取而代之, 這時候字串常量池還在堆, 執行時常量池還在方法區, 只不過方法區的實現從永久代變成了元空間(Metaspace)

假如在字串常量池中存在字串 "abc",如果當前沒有任何 String 物件引用該字串常量的話,就說明常量 "abc" 就是廢棄常量,如果這時發生記憶體回收的話而且有必要的話,"abc" 就會被系統清理出常量池了。

如何判斷一個類是無用的類?

方法區主要回收的是無用的類,那麼如何判斷一個類是無用的類的呢?

判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。類需要同時滿足下面 3 個條件才能算是 “無用的類”

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

虛擬機器可以對滿足上述 3 個條件的無用類進行回收,這裡說的僅僅是“可以”,而並不是和物件一樣不使用了就會必然被回收。

垃圾回收演算法

標記-清除演算法

標記-清除演算法就是,先標記哪些物件是存活的,哪些物件是可以回收的,然後再把標記為可回收的物件清除掉。

從下面的圖可以看到,回收後,產生了大量的不連續的記憶體碎片,如果這個時候,有一個比較大的物件進來,卻沒有一個連續的記憶體空間給他使用,又會觸發一次垃圾收集動作。

標記-複製演算法

為了解決效率問題,“標記-複製”收集演算法出現了。它可以將記憶體分為大小相同的兩塊,每次使用其中的一塊。當這一塊的記憶體使用完後,就將還存活的物件複製到另一塊去,然後再把使用的空間一次清理掉。這樣就使每次的記憶體回收都是對記憶體區間的一半進行回收。

複製演算法是通過兩個記憶體空間,把一方存活的物件複製到另外一個記憶體空間上,然後再把自己的記憶體空間直接清理。標記後,此時情況如下:

然後再把左邊的存活物件複製到右邊:

複製完後,再清理自己的記憶體空間:

右邊的空間開始回收,再複製到座標,如此往復。這樣就可以讓存活的物件緊密的靠在一起,騰出了連續的記憶體空間。

缺點就是空間少了一半,這少了一半的空間用於複製存活的物件。但是在實際過程中,大部分的物件的存活時間都非常短,也就是說這些物件都可以被回收的。

比如說左邊有100M空間,但是隻有1M的物件是存活的,那我們右邊就不需要100M來存放這個1M的存活物件。

因此我們的新生代是分成3個記憶體塊的:Eden空間、From Survivor空間、To Survivor空間,他們的預設比例是8:1:1。

也就是說,平常的時候有Eden+Survivor=90M可以使用,10M用於存放存活物件(假設100M空間)。

標記-整理演算法

除了新生代,老年代的記憶體也要清理的,但是上面的演算法並不適合老年代。

因為老年代物件的生命週期都比較長,那就不能像新生代一樣僅浪費10%的記憶體空間,而是浪費一半的記憶體空間。

標記-整理與標記-清除都是先標記哪些物件存活,哪些物件可以回收,不同的是他並沒有直接清理可回收的物件,而是先把存活的物件進行移動,這樣存活的物件就緊密的靠在一起,最後才把垃圾回收掉。

回收前,存活物件是不連續的。

回收中,存活物件是連續的。

回收後,回收垃圾物件。

分代收集演算法

當前虛擬機器的垃圾收集都採用分代收集演算法,這種演算法沒有什麼新的思想,只是根據物件存活週期的不同將記憶體分為幾塊。一般將 java 堆分為新生代和老年代,這樣我們就可以根據各個年代的特點選擇合適的垃圾收集演算法。

比如在新生代中,每次收集都會有大量物件死去,所以可以選擇”標記-複製“演算法,只需要付出少量物件的複製成本就可以完成每次垃圾收集。而老年代的物件存活機率是比較高的,而且沒有額外的空間對它進行分配擔保,所以我們必須選擇“標記-清除”或“標記-整理”演算法進行垃圾收集。

延伸的面試題:HotSpot 為什麼要分為新生代和老年代?根據上面的對分代收集演算法的介紹回答。

效能與優化

由於每次GC,都會Stop The World,也就是說,此時虛擬機器會把所有工作的執行緒都停掉,會給使用者帶來不良的體驗及影響,所以我們要儘量減少GC的次數。

針對新生代,Minor GC觸發的原因就是新生代的Eden區滿了,所以為了減少Minor GC,我們新生代的記憶體空間不要太小。如果說我們給新生代的記憶體已經到達機器的極限了,那隻能做叢集了,把壓力分擔出去。

老年代的垃圾回收速度比新生代要慢10倍,所以我們也要儘量減少發生Full GC。

根據前面的JVM記憶體分配,我們知道有幾種物件直接進入老年代:

  • 大物件直接進入老年代:這個沒辦法優化,總不能調大物件大小吧,那這些大物件在新生代的複製就會很慢了。
  • 長期存活的物件將進入老年代:年齡的增加,是每次Minor GC的時候,所以我們可以減少Minor GC(這個方法上面提到了),這樣年齡就不會一直增加。
  • 動態年齡判斷:有一個重要的條件就是在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,所以要加大新生代的記憶體空間。
  • 空間分配擔保:這裡面有一個條件是Minor GC後,Survivor空間放不下的就存放到老年代,為了讓存活不到老年代,我們可以加大Survivor空間。

垃圾收集器

如果說收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。

雖然我們對各個收集器進行比較,但並非要挑選出一個最好的收集器。因為直到現在為止還沒有最好的垃圾收集器出現,更加沒有萬能的垃圾收集器,我們能做的就是根據具體應用場景選擇適合自己的垃圾收集器。試想一下:如果有一種四海之內、任何場景下都適用的完美收集器存在,那麼我們的 HotSpot 虛擬機器就不會實現那麼多不同的垃圾收集器了。

JVM CMS垃圾收集器