JVM詳解(四)——執行時資料區-堆

L發表於2021-10-18

一、堆

1、介紹

  Java執行程式對應一個程式,一個程式就對應一個JVM例項。一個JVM例項就有一個執行時資料區(Runtime),Runtime裡面,就只有一個堆,一個方法區。這裡也闡述了,方法區和堆是一個程式一份。而一個程式當中,可以有多個執行緒,那就意味著一個程式中的多個執行緒會共享堆空間和方法區。
  一個JVM例項只存在一個堆記憶體,堆也是Java記憶體管理的核心區域。堆在JVM啟動的時候被建立,其空間大小也就確定了,是JVM管理的最大一塊記憶體空間,堆記憶體大小是可以調節的。
  Java虛擬機器規範規定,堆可以處於物理上不連續的記憶體空間中,但在邏輯上它應該被視為連續的。
  所有的執行緒共享Java堆,在這裡還可以劃分執行緒私有的緩衝區(TLAB)。

  堆空間中,有一部分執行緒私有的緩衝區,叫TLAB,它不是所有執行緒共享的區域。

  《Java虛擬機器規範》中對堆的描述是:所有的物件例項以及陣列都應當在執行時分配在堆上。其實,從實際使用角度來看,是"幾乎"所有的物件例項都在這裡分配記憶體。
  陣列和物件可能永遠不會儲存在棧上,因為棧幀中儲存引用,這個引用指向物件或陣列在堆中的位置。在方法結束後,堆中的物件不會馬上被移除,僅僅在垃圾收集的時候才會被移除。堆,是GC執行垃圾回收的重點區域。而頻繁的GC會影響使用者執行緒的執行。
  為什麼是幾乎?逃逸分析,會去判斷在方法中物件是否發生了逃逸,如果沒有的話,會在棧上分配。

2、堆的記憶體結構

  堆空間細分

  JDK7:

  JDK8:

  永久代-->元空間

3、設定堆記憶體大小與OOM

  Java堆用於儲存Java物件例項,在JVM啟動時就已經設定好了。可以通過-Xmx和-Xms來進行設定。一旦堆區的記憶體大小超過-Xmx所指定的最大記憶體時,將會丟擲OutOfMemoryError異常。

  -Xms:堆區的初始記憶體大小
  -Xmx:堆區的最大記憶體大小

  -X:是JVM的執行引數
  ms:是memory start

  通常會將-Xms和-Xmx兩個引數配置相同的值,其目的是為了能夠在Java垃圾回收機制清理完堆區後不需要重新分割計算堆區的大小,從而提高效能。
  理由:初始設定一個值之後,如果堆空間不夠的話,需要不斷的擴容。後續不用的話,也需要釋放。那麼,在伺服器使用的時候,堆空間不斷的擴容。在空閒的時候,也需要把堆空間做釋放,那頻繁的擴充套件和釋放,會造成不必要的系統壓力。
  預設情況下,初始記憶體大小:物理電腦記憶體大小/64。最大記憶體:物理電腦記憶體大小/4。
  程式碼示例:設定堆記憶體大小

 1 // 預設情況
 2 public class Main {
 3     public static void main(String[] args) {
 4 
 5         // 返回Java虛擬機器中的堆記憶體總量
 6         long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
 7         // 返回Java虛擬機器試圖使用的最大堆記憶體量
 8         long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
 9 
10         System.out.println("-Xms : " + initialMemory + "M");
11         System.out.println("-Xmx : " + maxMemory + "M");
12 
13         System.out.println("系統記憶體大小為:" + initialMemory * 64.0 / 1024 + "G");
14         System.out.println("系統記憶體大小為:" + maxMemory * 4.0 / 1024 + "G");
15 
16 //        try {
17 //            Thread.sleep(1000000);
18 //        } catch (InterruptedException e) {
19 //            e.printStackTrace();
20 //        }
21     }
22 }
23 
24 // 預設值
25 // -Xms : 245M (約為 16G / 64)
26 // -Xmx : 3628M
27 // 系統記憶體大小為:15.3125G (這個值約等於系統記憶體)
28 // 系統記憶體大小為:14.171875G
29 
30 // 設定堆引數:-Xms600m -Xmx600m
31 // -Xms : 575M
32 // -Xmx : 575M

  為什麼設定的是600M。列印出來卻是575M呢?檢視設定的引數:
  方式一:jps / jstat -gc #{程式id}
  方式二:-XX:+PrintGCDetails
  因為統計是:eden + s0 + old

4、新生代與老年代

  儲存在JVM中的Java物件可以被劃分為兩類:①生命週期較短的瞬時物件,這類物件的建立和消亡都非常迅速。②生命週期非常長的物件,在某些極端情況下還能夠與JVM的生命週期保持一致。
  如下圖所示:堆空間的劃分,預設,新生代:老年代 = 1:2,S0:S1:Eden = 1:1:8。
  可以通過-XX:NewRatio=2,-XX:SurvivorRatio=8來設定比例。

  程式碼示例:檢視比例關係

1 // 設定:-Xms600m -Xmx600m
2 // 可通過命令列的指令檢視比例
3 jinfo -flag NewRatio #{程式id}
4 -XX:NewRatio=2
5 
6 jinfo -flag SurvivorRatio #{程式id}
7 -XX:SurvivorRatio=8

  可以發現,-XX:NewRatio的值是2,並且用jvisualvm.exe檢視,記憶體的大小也是一致的。但是-XX:SurvivorRatio的值是8,但是檢視的卻是6。怎麼回事呢?
  這裡,官網文件裡給的是8,檢視的也是8,但是實際執行記憶體分配是6。手動設定一下吧。
  幾乎所有的Java物件都是在Eden區被new出來的。絕大部分的Java物件的銷燬都在新生代進行。新生代中80%的物件都是朝生夕死的。
  可以使用-Xmn設定新生代的記憶體大小。
  通常情況下,絕大多數Java物件的生命週期都是很短的。survivor區放的就是從Eden區通過minor gc存活下來的。老年代,存放新生代中經歷多次GC仍然存活的物件。

5、圖解物件分配過程(重要)

  為新物件分配記憶體是一件非常嚴謹和複雜的任務,JVM的設計者們不僅需要考慮記憶體如何分配、在哪裡分配等問題,並且由於記憶體分配演算法與記憶體回收演算法密切相關,所以還需要考慮GC執行完記憶體回收後是否會在記憶體空間中產生記憶體碎片。圖解物件分配過程:

  說明:綠色,存活;紅色,垃圾。
  第一次:物件在Eden區產生,Eden區滿,如果再來物件,會進行一次gc,叫YGC,或者Minor GC,這時會觸發一個STW(stop the world),使用者執行緒會停止。
  這時,Eden區放了5個物件,會去判斷誰是垃圾,誰不是垃圾。會清理掉紅色。把綠色的全部挪到S0區,且年齡計數器(每一個物件都有一個年齡計數器age) + 1。
  第一次完畢:這時Eden區的資料被全部清空,只有S0區有兩個物件。

  第二次:物件在Eden區產生,當他又滿了,會又觸發YGC(Minor GC)。被清理掉4個垃圾,存活1個,這時會把它放到S1區。
  同一次過程中(YGC),會把S0區的兩個物件也要判斷是否為垃圾,是就回收;這裡不是,把S0的兩個也放到S1中,且age +1。
  第二次完畢:這時Eden區的資料被全部清空,S0區被全部清空。只有S1區有三個物件。
  ……
  重複上述過程,直到。
  第N次:當物件 age 達到15(預設值)後,S1區的兩個物件會晉升,它不再是挪到S0區,而是挪到老年代。

  注意:上面說到Eden區一滿,如果再來物件,會進行一次gc,叫YGC,或者Minor GC。如果是S0區滿了,不會觸發YGC。YGC就會回收 eden + s0/s1區。
  在老年代,相對悠閒。當老年代記憶體不足時,會觸發GC(Major GC),對老年代的記憶體清理。若執行之後發現依然無法進行物件的儲存,會報OOM。
  關於垃圾回收:頻繁在新生代,很少在老年代,幾乎不在永久代/元空間。

  物件分配的特殊情況:
  ①新生代區,在沒有達到15的時候,有沒有可能直接晉升到老年代呢?可能的!
  ②一出生就到老年代,也是有可能的。大物件Eden區放不下的話,直接放到老年代。

  程式碼示例:JVisualVM演示物件的分配過程

JVM詳解(四)——執行時資料區-堆
 1 // 用JVisualVM檢視各個區的記憶體變化情況
 2 // -Xms600m -Xmx600m
 3 public class Main {
 4     byte[] buffer = new byte[new Random().nextInt(1024 * 200)];
 5 
 6     public static void main(String[] args) {
 7         ArrayList<Test> list = new ArrayList<>();
 8         while (true) {
 9             list.add(new Test());
10             try {
11                 Thread.sleep(10);
12             } catch (InterruptedException e) {
13                 e.printStackTrace();
14             }
15         }
16     }
17 }
分配過程

6、Minor GC、Major GC、Full GC(重要)

  調優:主要就是要減少GC次數。
  JVM在GC時,並不是每次都對新生代、老年代、方法區一起回收,大部分回收指的新生代。針對HotSpot VM的實現,它裡面的GC按照回收區域分為兩大型別,一種是部分收集(Partial GC),一種是整堆收集(Full GC)。
  部分收集(Partial GC):不是完整收集整個Java堆,又分為:
  (1)新生代收集(Minor GC / Young GC):只是新生代(Eden,S0,S1)的收集。
  (2)老年代收集(Major GC / Old GC):只是老年代的收集。目前,只有CMS GC會有單獨收集老年代的行為。
  (3)混合收集(Mixed GC):收集整個新生代以及部分老年代。目前,只有G1 GC會有這種行為。
  整堆收集(Full GC):收集整個Java堆和方法區。
  注意:很多時候Major GC和Full GC會混淆使用,需要具體分辨是老年代回收還是整堆回收。重點關注Major GC、Full GC。因為他產生的使用者執行緒暫停時間比Minor GC高10倍以上。

  Minor GC
  觸發條件:Eden區滿。S0、S1區滿不會引發。回收:Eden + S0/S01。因為大多數Java物件都是朝生夕死,所有Minor GC非常頻繁,回收速度也比較快。Minor GC會引發STW,暫停其他使用者執行緒,等垃圾回收結束,使用者執行緒才恢復執行。

  Major GC
  觸發條件:老年代滿。物件從老年代消失時,我們說Major GC或Full GC發生了。出現了Major GC,至少會伴隨至少一次Minor GC(不是絕對是,在Parallel Scavenge收集器的收集策略裡就有直接進行Major GC的策略選擇過程)。也就是老年代空間不足時,會先嚐試Minor GC,如果還不足,則Major GC。
  Major GC的速度一般比Minor GC慢10倍以上,STW的時間也更長。
  若Major GC後,記憶體還不足,報OOM。

  Full GC
  觸發條件:有以下5個。
  (1)呼叫System.gc()時,建議系統執行Full GC,但這不是必然執行的。
  (2)老年代空間不足。
  (3)方法區空間不足。
  (4)通過MinorGC後進入老年代的平均大小大於老年代的可用記憶體。
  (5)由Eden區,S0區向S1區複製時,物件大小大於S1可用記憶體,則把該物件轉存到老年代,且老年代的可用記憶體小於該物件大小。
  Full GC是開發或調優中需要儘量避免的。
  程式碼示例:GC舉例,用於日誌分析

JVM詳解(四)——執行時資料區-堆
 1 // 測試MinorGC、MajorGC、FullGC
 2 // -Xms9m -Xmx9m -XX:+PrintGCDetails
 3 public class GCTest {
 4     public static void main(String[] args) {
 5         int i = 0;
 6         try {
 7             List<String> list = new ArrayList<>();
 8             String a = "baidu.com";
 9             while (true) {
10                 list.add(a);
11                 a = a + a;
12                 i++;
13             }
14         } catch (Throwable t) {
15             t.printStackTrace();
16             System.out.println("遍歷次數為:" + i);
17         }
18     }
19 }
gc

7、堆空間分代思想

  為什麼需要把堆空間分代?不分代就不能正常工作了嗎?經研究,不同物件的生命週期不同,70%~90%的物件是臨時物件。
  不分代也可以。分代的唯一理由就是優化GC效能。如果沒有分代,所有的物件都在一塊,就如同把一個學校的人都關在一個教室,GC的時候要找到哪些物件是垃圾,就會對堆的所有區域進行掃描。而很多物件是朝生夕死的,如果分代的話,把新建立的物件放到一個地方,GC的時候先把這塊儲存朝生夕死的物件的區域進行回收,會騰出很大的空間出來,也有利於提供GC效率。

8、記憶體分配策略(物件提升規則)

  針對不同年齡段的物件分配原則如下:
  (1)優先分配到Eden。
  (2)大物件直接分配到老年代,儘量避免程式中出現過多的大物件。
  (3)長期存活的物件分配到老年代。
  (4)動態物件年齡判斷:如果S0區中相同年齡的所有物件大小的總和大於S0空間的一半,年齡大於或等於該年齡的物件可以直接進入老年代,無須等到年齡閾值。
  (5)空間分配擔保:-XX:HandlePromotionFailure
  程式碼示例:大物件直接進入老年代

1 // -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
2 public class YoungOldAreaTest {
3     public static void main(String[] args) {
4         // 20m
5         byte[] buffer = new byte[1024 * 1024 * 20];
6     }
7 }

  結果:可以看到,這個大物件直接出現在了老年代。

9、為物件分配記憶體:TLAB

  為什麼會有TLAB(Thread Local Allocation Buffer)?堆是執行緒共享區域,任何執行緒都可以訪問到堆中的共享資料。由於物件例項的建立在JVM中非常頻繁,因此在併發環境下從堆中劃分記憶體空間是不安全的,為避免多個執行緒操作同一地址,需要使用加鎖等機制,進而會影響分配速度。
  什麼是TLAB?從記憶體模型而不是垃圾收集的角度,對Eden區繼續進行劃分,JVM為每個執行緒分配了一個私有快取區域,它包含在Eden空間內。多執行緒同時分配記憶體時,使用TLAB可以避免一系列的執行緒安全問題。同時還能夠提升記憶體內配的吞吐量,因此我們可以將這種記憶體分配方式稱之為快速分配策略。
  據我所知,所有OpenJDK衍生出來的JVM都提供了TLAB的設計。

  儘管不是所有的物件例項都能夠在TLAB中成功分配記憶體,但JVM確實是將TLAB作為記憶體分配的首選。預設情況下,TLAB空間記憶體非常小,僅佔整個Eden區的1%。一旦物件在TLAB空間分配記憶體失敗時,JVM就會嘗試著通過使用加鎖機制確保資料操作的原子性,從而直接在Eden區中分配記憶體。
  程式碼示例:

 1 // 無引數設定。UseTLAB預設是開啟的
 2 public class TLABArgsTest {
 3     public static void main(String[] args) {
 4         System.out.println("我只是來打個醬油~");
 5         
 6         try {
 7             Thread.sleep(1000_000);
 8         } catch (InterruptedException e) {
 9             e.printStackTrace();
10         }
11     }
12 }

  物件分配過程:

二、逃逸分析

1、介紹

  堆是分配物件的唯一選擇嗎?不是。棧上分配,標量替換。
  在《深入理解Java虛擬機器》中關於Java堆記憶體有這樣一段描述:
  隨著JIT編譯期的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化,所有的物件都分配到堆上也漸漸變得不那麼"絕對"了。
  在Java虛擬機器中,物件是在Java堆中分配記憶體的,這是一個普遍的常識。但是有一種特殊情況,就是如果經過逃逸分析後發現,這個物件並沒有逃逸出方法,那麼就可能被優化成棧上分配。這樣就無須在堆上分配記憶體,也無須進行垃圾回收了,這也是最常見的堆外儲存技術。
  此外,前面提到的基於OpenJDK深度定製的TaoBaoVM,其中建立的GCIH(GC invisible heap)技術實現off-heap,將生命週期較長的Java物件從heap中移至heap外,並且GC不能管理GCIH內部的Java物件,以此達到降低GC的回收頻率和提升GC的回收效率的目的。

2、逃逸分析:程式碼優化

  使用逃逸分析,編譯器可以對程式碼做如下優化:
  棧上分配:將堆分配轉化為棧分配,如果一個物件在子程式中被分配,如果指向該物件的指標永遠不會逃逸,物件可能是棧分配的候選,而不是堆分配。
  同步省略:如果一個物件被發現只能從一個執行緒被訪問到,那麼對於這個物件的操作可以不考慮同步。
  標量替換(分離物件):有的物件可能不需要作為一個連續的記憶體結構存在,也可以被訪問到,那麼物件的部分(或全部)可以不儲存在記憶體,而是儲存在CPU暫存器中。
  這是一種可以有效減少Java程式中同步負載和記憶體堆分配壓力的跨函式全域性資料流分析演算法。通過逃逸分析,Java編譯器能夠分析出一個新的物件的引用的使用範圍從而決定是否要將這個物件分配到堆上。
  逃逸分析的基本行為就是分析物件動態作用域:
  (1)當一個物件在方法中被定義後,物件只在方法內部使用,則認為沒有發生逃逸。
  (2)當一個物件在方法中被定義後,它被外部方法所引用,則認為發生逃逸。例如作為呼叫引數傳遞到其他方法中。
  程式碼示例:沒有逃逸

1 public void method1(){
2     V v = new V();
3     // use v
4     // ...
5     v = null;
6 }

  沒有發生逃逸的物件,則可以分配到站上,隨著方法執行結束,棧空間就被移除。

  如何快速的判斷是否發生了逃逸?就看new的物件實體是否有可能在方法外被呼叫。

3、棧上分配

  JIT編譯器在編譯期間根據逃逸分析的結果,發現如果一個物件並沒有逃逸出方法的話,就可能被優化成棧上分配。分配完成後,繼續在呼叫棧內執行,最後執行緒結束,棧空間被回收,區域性變數物件也被回收。這樣就無須進行垃圾回收了。
  程式碼示例:棧上分配

 1 // -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
 2 public class StackAllocation {
 3     public static void main(String[] args) {
 4         long start = System.currentTimeMillis();
 5 
 6         for (int i = 0; i < 10000000; i++) {
 7             alloc();
 8         }
 9 
10         // 檢視執行時間
11         long end = System.currentTimeMillis();
12         System.out.println("花費的時間為: " + (end - start) + " ms");
13        
14         // 為了方便檢視堆記憶體中物件個數,執行緒sleep
15         try {
16             Thread.sleep(1000_000);
17         } catch (InterruptedException e1) {
18             e1.printStackTrace();
19         }
20     }
21 
22     private static void alloc() {
23         //未發生逃逸
24         User user = new User();
25     }
26 
27     static class User {
28 
29     }
30 }
31 
32 // -XX:-DoEscapeAnalysis
33 // 花費的時間為:108 ms
34 
35 // -XX:+DoEscapeAnalysis
36 // 花費的時間為:4 ms

  結果:未開啟逃逸分析

  結果:開啟逃逸分析

1 // -Xmx256m -Xms256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
2 // 不開啟逃逸分析,這種情況,有GC
3 
4 // -Xmx256m -Xms256m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
5 // 開啟逃逸分析,這種情況,沒有GC

4、同步省略

  執行緒同步的代價是相當高的,同步的後果是降低併發性和效能。
  在動態編譯同步塊的時候,JIT編譯器可以藉助逃逸分析來判斷同步塊所使用的鎖物件是否只能夠被一個執行緒訪問而沒有被髮布到其他執行緒。如果沒有,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分程式碼的同步。這樣就能大大提高併發性和效能。這個取消同步的過程就叫同步省略,也叫鎖消除。

5、標量替換

  標量:指一個無法再分解成更小的資料的資料。Java中的原始資料型別就是標量。
  聚合量:相對標量,還可以再分解的資料。Java中的物件就是聚合量。
  標量替換:在JIT階段,如果經過逃逸分析,發現一個物件不會被外界訪問的話,那麼經過JIT優化,就會把這個物件拆解成若干個標量來代替。這個過程就是標量替換,表示允許將物件打散分配到棧上。

  可以看到,Point這個聚合量經過逃逸分析後,發現它沒有逃逸,就被替換成兩個標量了。
  標量替換的好處就是可以大大減少堆記憶體的佔用。因為一旦需要建立物件了,那麼就不再需要分配堆記憶體了。標量替換為棧上分配提供了很好的基礎。
  程式碼示例:標量替換

JVM詳解(四)——執行時資料區-堆
 1 public class Main {
 2 
 3     public static void main(String[] args) {
 4         long start = System.currentTimeMillis();
 5 
 6         for (int i = 0; i < 10000000; i++) {
 7             alloc();
 8         }
 9 
10         long end = System.currentTimeMillis();
11         System.out.println("花費的時間為: " + (end - start) + " ms");
12     }
13 
14     public static void alloc() {
15         User u = new User(); // 未發生逃逸
16         u.id = 5;
17         u.name = "www.baidu.com";
18     }
19 
20     static class User {
21         public int id;
22         public String name;
23     }
24 
25 }
標量替換

  結果:

JVM詳解(四)——執行時資料區-堆
 1 // -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC 
 2 // -XX:-EliminateAllocations
 3 // 一、開啟逃逸分析,不開啟標量替換,有GC
 4 [GC (Allocation Failure)  25600K->784K(98304K), 0.0012734 secs]
 5 [GC (Allocation Failure)  26384K->736K(98304K), 0.0031470 secs]
 6 [GC (Allocation Failure)  26336K->720K(98304K), 0.0007684 secs]
 7 [GC (Allocation Failure)  26320K->720K(98304K), 0.0007718 secs]
 8 [GC (Allocation Failure)  26320K->720K(98304K), 0.0006689 secs]
 9 [GC (Allocation Failure)  26320K->752K(101376K), 0.0007525 secs]
10 [GC (Allocation Failure)  32496K->692K(101376K), 0.0006699 secs]
11 [GC (Allocation Failure)  32436K->692K(101376K), 0.0002453 secs]
12 花費的時間為: 69 ms
13 
14 // -XX:+EliminateAllocations
15 // 二、開啟逃逸分析,開啟標量替換,無GC
16 花費的時間為: 4 ms
結果

6、小結

  逃逸分析並不成熟。其根本原因就是無法保證逃逸分析的效能消耗一定能高於其他的消耗。雖然經過逃逸分析可以做棧上分配、標量替換、鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程。
  一個極端的例子,就是經過逃逸分析之後,發現沒有一個物件是不逃逸的,那這個逃逸分析的過程就白白浪費了。
  雖然這項技術並不十分成熟,但是它也是即時編譯器優化技術中一個十分重要的手段。
  JVM會在棧上分配不會逃逸的物件,理論是可行的,但是取決於JVM設計者的選擇。據我所知,Hot Spot JVM並沒有這麼做,這一點在逃逸分析相關的文件裡已經說明,所以可以明確所有的物件例項都是建立在堆上的。
  intern字串的快取和靜態變數曾經都被分配在永久代上,而永久代已經被元空間取代。但是,intern字串快取和靜態變數並沒有被轉移到元空間,而是在堆上分配。所以這一點同樣符合前面的結論:物件例項都是分配在堆上的。

相關文章