1. 程式人生 > 實用技巧 >【JVM】堆中GC與物件分配記憶體(九)

【JVM】堆中GC與物件分配記憶體(九)

  本章節內容接上一章【JVM】堆內部劃分與物件分配(八)

五、GC 垃圾回收器

5.1、分代收集思想

  Minor GC、Major GC、Full GC

  1. 我們都知道,JVM的調優的一個環節,也就是垃圾收集,我們需要儘量的避免垃圾回收,因為在垃圾回收的過程中,容易出現STW(Stop the World)的問題,而 Major GC 和 Full GC出現STW的時間,是Minor GC的10倍以上

  2. JVM在進行GC時,並非每次都對上面三個記憶體區域一起回收的,大部分時候回收的都是指新生代。針對Hotspot VM的實現,它裡面的GC按照回收區域又分為兩大種類型:一種是部分收集(Partial GC),一種是整堆收集(FullGC)

  分代收集:  

  1. 部分收集:不是完整收集整個Java堆的垃圾收集。其中又分為:

    • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集

    • 老年代收集(Major GC/Old GC):只是老年代的圾收集。

      • 目前,只有CMS GC會有單獨收集老年代的行為。

      • 注意,很多時候Major GC會和Full GC混淆使用,需要具體分辨是老年代回收還是整堆回收

    • 混合收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集。

      • 目前,只有G1 GC會有這種行為

  2. 整堆收集(Full GC):收集整個java堆和方法區的垃圾收集。

5.2、Young GC

  年輕代 GC(Minor GC)觸發機制

  1. 當年輕代空間不足時,就會觸發Minor GC,這裡的年輕代滿指的是Eden代滿,Survivor滿不會引發GC。(每次Minor GC會清理年輕代的記憶體)

  2. 因為Java物件大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。這一定義既清晰又易於理解。

  3. Minor GC會引發STW,暫停其它使用者的執行緒,等垃圾回收結束,使用者執行緒才恢復執行

  

5.3、Major/Full GC

  老年代 GC(MajorGC/Full GC)觸發機制  

  1. 指發生在老年代的GC,物件從老年代消失時,我們說 “Major Gc” 或 “Full GC” 發生了

  2. 出現了MajorGc,經常會伴隨至少一次的Minor GC

    • 但非絕對的,在Parallel Scavenge收集器的收集策略裡就有直接進行MajorGC的策略選擇過程

    • 也就是在老年代空間不足時,會先嚐試觸發Minor GC,如果之後空間還不足,則觸發Major GC

  3. Major GC的速度一般會比Minor GC慢10倍以上,STW的時間更長,如果Major GC後,記憶體還不足,就報OOM了

  Full GC 觸發機制

  觸發Full GC執行的情況有如下五種:

  1. 呼叫System.gc()時,系統建議執行Fu11GC,但是不必然執行

  2. 老年代空間不足

  3. 方法區空間不足

  4. 通過Minor GC後進入老年代的平均大小大於老年代的可用記憶體

  5. 由Eden區、survivor spacee(From Space)區向survivor space1(To Space)區複製時,物件大小大於To Space可用記憶體,則把該物件轉存到老年代,且老年代的可用記憶體小於該物件大小

  說明:Full GC 是開發或調優中儘量要避免的。這樣STW時間會短一些

5.4、GC 日誌分析

  • 程式碼
     1 /**
     2  * 測試MinorGC 、 MajorGC、FullGC
     3  * -Xms9m -Xmx9m -XX:+PrintGCDetails
     4  *
     5  */
     6 public class GCTest {
     7     public static void main(String[] args) {
     8         int i = 0;
     9         try {
    10             List<String> list = new ArrayList<>();
    11             String a = "test.com";
    12             while (true) {
    13                 list.add(a);
    14                 a = a + a;
    15                 i++;
    16             }
    17 
    18         } catch (Throwable t) {
    19             t.printStackTrace();
    20             System.out.println("遍歷次數為:" + i);
    21         }
    22     }
    23 }
  • JVM 引數(PrintGCDetails列印GC詳情日誌)

    -Xms9m -Xmx9m -XX:+PrintGCDetails

  • GC 日誌:在 OOM 之前,一定會觸發一次 Full GC ,因為只有在老年代空間不足時候,才會爆出OOM異常
     1 [GC (Allocation Failure) [PSYoungGen: 2048K->480K(2560K)] 2048K->640K(9728K), 0.0023805 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
     2 [GC (Allocation Failure) [PSYoungGen: 2235K->496K(2560K)] 2395K->1460K(9728K), 0.0020698 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
     3 [GC (Allocation Failure) [PSYoungGen: 1588K->256K(2560K)] 7672K->7108K(9728K), 0.0012447 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
     4 [Full GC (Ergonomics) [PSYoungGen: 256K->0K(2560K)] [ParOldGen: 6852K->4494K(7168K)] 7108K->4494K(9728K), [Metaspace: 3287K->3287K(1056768K)], 0.0057478 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
     5 [GC (Allocation Failure) [PSYoungGen: 80K->32K(2560K)] 6622K->6574K(9728K), 0.0007642 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
     6 [Full GC (Ergonomics) [PSYoungGen: 32K->0K(2560K)] [ParOldGen: 6542K->4488K(7168K)] 6574K->4488K(9728K), [Metaspace: 3287K->3287K(1056768K)], 0.0034269 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
     7 [GC (Allocation Failure) [PSYoungGen: 49K->64K(2560K)] 6585K->6600K(9728K), 0.0007259 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
     8 [Full GC (Ergonomics) [PSYoungGen: 64K->0K(2560K)] [ParOldGen: 6536K->6536K(7168K)] 6600K->6536K(9728K), [Metaspace: 3295K->3295K(1056768K)], 0.0056462 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
     9 [GC (Allocation Failure) [PSYoungGen: 0K->0K(1536K)] 6536K->6536K(8704K), 0.0004218 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    10 [Full GC (Allocation Failure) [PSYoungGen: 0K->0K(1536K)] [ParOldGen: 6536K->6518K(7168K)] 6536K->6518K(8704K), [Metaspace: 3295K->3295K(1056768K)], 0.0048128 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
    11 遍歷次數為:17
    12 Heap
    13  PSYoungGen      total 1536K, used 79K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000)
    14   eden space 1024K, 7% used [0x00000007bfd00000,0x00000007bfd13c30,0x00000007bfe00000)
    15   from space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
    16   to   space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
    17  ParOldGen       total 7168K, used 6518K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000)
    18   object space 7168K, 90% used [0x00000007bf600000,0x00000007bfc5da28,0x00000007bfd00000)
    19  Metaspace       used 3332K, capacity 4496K, committed 4864K, reserved 1056768K
    20   class space    used 368K, capacity 388K, committed 512K, reserved 1048576K
    21 java.lang.OutOfMemoryError: Java heap space
    22     at java.util.Arrays.copyOf(Arrays.java:3332)
    23     at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
    24     at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
    25     at java.lang.StringBuilder.append(StringBuilder.java:136)
    26     at com.test.jvm2.GCTest.main(GCTest.java:19)
    27 
    28 Process finished with exit code 0
  • 分析第四行Full GC

    • [PSYoungGen: 256K->0K(2560K)]:年輕代總空間為 2560K,當前佔用 256K,經過垃圾回收後剩餘 0K

    • [ParOldGen: 6852K->4494K(7168K)]:老年代總空間為 7168K,當前佔用 6852K,經過垃圾回收後剩餘4494K

    • 7108K->4494K(9728K):堆記憶體總空間為 9728K ,當前佔用 7108K,經過垃圾回收後剩餘4494K

    • [Metaspace: 3287K->3287K(1056768K)]:元空間總空間為 1056768K,當前佔用 3452K,經過垃圾回收後剩餘3452K

    • 0.0057478secs :垃圾回收用時 0.0057478secs

六、堆空間分代思想

  為什麼需要分代?

  1. 為什麼要把Java堆分代?不分代就不能正常工作了嗎?經研究,不同物件的生命週期不同。70%-99%的物件是臨時物件。

    • 新生代:有Eden、兩塊大小相同的survivor(又稱為from/to,s0/s1)構成,to總為空。

    • 老年代:存放新生代中經歷多次GC仍然存活的物件。

  2. 其實不分代完全可以,分代的唯一理由就是優化GC效能。

    • 如果沒有分代,那所有的物件都在一塊,就如同把一個學校的人都關在一個教室。GC的時候要找到哪些物件沒用,這樣就會對堆的所有區域進行掃描。

    • 而很多物件都是朝生夕死的,如果分代的話,把新建立的物件放到某一地方,當GC的時候先把這塊儲存“朝生夕死”物件的區域進行回收,這樣就會騰出很大的空間出來。

  JDK7 與 JDK8記憶體劃分區別

  

  

七、記憶體分配策略

  記憶體分配策略或物件提升(Promotion)規則  

  1. 如果物件在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並將物件年齡設為1。

  2. 物件在Survivor區中每熬過一次MinorGC,年齡就增加1歲,當它的年齡增加到一定程度(預設為15歲,其實每個JVM、每個GC都有所不同)時,就會被晉升到老年代

  3. 物件晉升老年代的年齡閥值,可以通過選項**-XX:MaxTenuringThreshold**來設定

  針對不同年齡段的物件分配原則如下所示:

  1. 優先分配到Eden:開發中比較長的字串或者陣列,會直接存在老年代,但是因為新建立的物件都是朝生夕死的,所以這個大物件可能也很快被回收,但是因為老年代觸發Major GC的次數比 Minor GC要更少,因此可能回收起來就會比較慢

  2. 大物件直接分配到老年代:儘量避免程式中出現過多的大物件

  3. 長期存活的物件分配到老年代

  4. 動態物件年齡判斷:如果Survivor區中相同年齡的所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

  5. 空間分配擔保: -XX:HandlePromotionFailure ,也就是經過Minor GC後,所有的物件都存活,因為Survivor比較小,所以就需要將Survivor無法容納的物件,存放到老年代中。

  程式碼示例

  • 程式碼
     1 /**
     2  * 測試:大物件直接進入老年代
     3  * -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
     4  *
     5  *  引數設定後:
     6  *      新生代--20m
     7  *          Eden--16m  S0--2m S1--2m
     8  *      老年代--40m
     9  */
    10 public class YoungOldAreaTest {
    11     public static void main(String[] args) {
    12         byte[] buffer = new byte[1024 * 1024 * 20];//20m
    13 
    14     }
    15 }
  • JVM 引數

    -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails

  • 整個過程並沒有進行垃圾回收,並且 ParOldGen 區直接佔用了 20MB 的空間,說明大物件直接懟到了老年代中

    

八、為物件分配記憶體

8.1、什麼是 TLAB(Thread Local Allocation Buffer)

  1. 從記憶體模型而不是垃圾收集的角度,對Eden區域繼續進行劃分,JVM為每個執行緒分配了一個私有快取區域,它包含在Eden空間內。

  2. 多執行緒同時分配記憶體時,使用TLAB可以避免一系列的非執行緒安全問題,同時還能夠提升記憶體分配的吞吐量,因此我們可以將這種記憶體分配方式稱之為快速分配策略。

  3. 據我所知所有OpenJDK衍生出來的JVM都提供了TLAB的設計。

  

8.2、為什麼有 TLAB(Thread Local Allocation Buffer)

  思考問題:堆空間都是共享的麼?

  不一定,因為還有TLAB這個概念,在堆中劃分出一塊區域,為每個執行緒所獨佔

  為什麼有TLAB?  

  堆區是執行緒共享區域,任何執行緒都可以訪問到堆區中的共享資料,由於物件例項的建立在JVM中非常頻繁,因此在併發環境下從堆區中劃分記憶體空間是執行緒不安全的,為避免多個執行緒操作同一地址,即在多執行緒建立物件時,都需要從堆空間申請記憶體空間來存放物件,那麼需要使用加鎖等機制,來確保多執行緒不是使用同一地址來建立物件,而加鎖進而影響分配速度,以及程式執行速度。

8.3、TLAB 分配過程

  1. 儘管不是所有的物件例項都能夠在TLAB中成功分配記憶體,但JVM確實是將TLAB作為記憶體分配的首選。

  2. 在程式中,開發人員可以通過選項“-XX:UseTLAB”設定是否開啟TLAB空間,預設開啟。

  3. 預設情況下,TLAB空間的記憶體非常小,僅佔有整個Eden空間的1%,當然我們可以通過選項“-XX:TLABWasteTargetPercent”設定TLAB空間所佔用Eden空間的百分比大小。

  4. 一旦物件在TLAB空間分配記憶體失敗時,JVM就會嘗試著通過使用加鎖機制確保資料操作的原子性,從而直接在Eden空間中分配記憶體。

  TLAB 分配過程圖

  

  程式碼示例

  • 程式碼

     1 /**
     2  * 測試-XX:UseTLAB引數是否開啟的情況:預設情況是開啟的
     3  *
     4  */
     5 public class TLABArgsTest {
     6     public static void main(String[] args) {
     7         System.out.println("我只是來打個醬油~");
     8         try {
     9             Thread.sleep(1000000);
    10         } catch (InterruptedException e) {
    11             e.printStackTrace();
    12         }
    13     }
    14 }
    View Code
  • 檢視 UseTLAB 標誌位的狀態

    命令:jps

    命令:jinfo -flag UseTLAB 程序id

  • 沒有設定任何 JVM 引數,通過命令列檢視 TLAB 是否開啟:結論是預設情況是開啟 TLAB

    

九、堆空間引數設定

9.1、常用引數設定

  官方文件:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

  常用引數設定  

  1. -XX:+PrintFlagsInitial:檢視所有的引數的預設初始值

  2. -XX:+PrintFlagsFinal:檢視所有的引數的最終值(可能會存在修改,不再是初始值)

  3. -Xms:初始堆空間記憶體(預設為實體記憶體的1/64)

  4. -Xmx:最大堆空間記憶體(預設為實體記憶體的1/4)

  5. -Xmn:設定新生代的大小(初始值及最大值)

  6. -XX:NewRatio:配置新生代與老年代在堆結構的佔比

  7. -XX:SurvivorRatio:設定新生代中Eden和S0/S1空間的比例

  8. -XX:MaxTenuringThreshold:設定新生代垃圾的最大年齡

  9. -XX:+PrintGCDetails:輸出詳細的GC處理日誌

  10. -XX:+PrintGC-verbose:gc :列印gc簡要資訊

  11. -XX:HandlePromotionFalilure:是否設定空間分配擔保

9.2、空間分配擔保

  在發生Minor GC之前,虛擬機器會檢查老年代最大可用的連續空間是否大於新生代所有物件的總空間。

  • 如果大於,則此次Minor GC是安全的

  • 如果小於,則虛擬機器會檢視**-XX:HandlePromotionFailure**設定值是否允擔保失敗。

    • 如果HandlePromotionFailure=true,那麼會繼續檢查老年代最大可用連續空間是否大於歷次晉升到老年代的物件的平均大小。

      • 如果大於,則嘗試進行一次Minor GC,但這次Minor GC依然是有風險的;

      • 如果小於,則進行一次Full GC。

    • 如果HandlePromotionFailure=false,則進行一次Full GC。

9.3、程式碼示例

 1 /**
 2  * 測試堆空間常用的jvm引數:
 3  * -XX:+PrintFlagsInitial : 檢視所有的引數的預設初始值
 4  * -XX:+PrintFlagsFinal  :檢視所有的引數的最終值(可能會存在修改,不再是初始值)
 5  * 具體檢視某個引數的指令:
 6  *      jps:檢視當前執行中的程序
 7  *      jinfo -flag SurvivorRatio 程序id
 8  * -Xms:初始堆空間記憶體 (預設為實體記憶體的1/64)
 9  * -Xmx:最大堆空間記憶體(預設為實體記憶體的1/4)
10  * -Xmn:設定新生代的大小。(初始值及最大值)
11  * -XX:NewRatio:配置新生代與老年代在堆結構的佔比
12  * -XX:SurvivorRatio:設定新生代中Eden和S0/S1空間的比例
13  * -XX:MaxTenuringThreshold:設定新生代垃圾的最大年齡
14  * -XX:+PrintGCDetails:輸出詳細的GC處理日誌
15  * 列印gc簡要資訊:① -XX:+PrintGC   ② -verbose:gc
16  * -XX:HandlePromotionFailure:是否設定空間分配擔保
17  *
18  */
19 public class HeapArgsTest {
20     public static void main(String[] args) {
21 
22     }
23 }
View Code

十、逃逸分析

  面試題:堆是分配物件的唯一選擇麼?

  在《深入理解Java虛擬機器》中關於Java堆記憶體有這樣一段描述:

  1. 隨著JIT編譯期的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化,所有的物件都分配到堆上也漸漸變得不那麼“絕對”了。

  2. 在Java虛擬機器中,物件是在Java堆中分配記憶體的,這是一個普遍的常識。但是,有一種特殊情況,那就是如果經過逃逸分析(Escape Analysis)後發現,一個物件並沒有逃逸出方法的話,那麼就可能被優化成棧上分配。這樣就無需在堆上分配記憶體,也無須進行垃圾回收了。這也是最常見的堆外儲存技術。

  3. 此外,前面提到的基於OpenJDK深度定製的TaoBao VM,其中創新的GCIH(GC invisible heap)技術實現off-heap,將生命週期較長的Java物件從heap中移至heap外,並且GC不能管理GCIH內部的Java物件,以此達到降低GC的回收頻率和提升GC的回收效率的目的

  如何將堆上的物件分配到棧,需要使用逃逸分析手段。

  逃逸分析介紹

  1. 這是一種可以有效減少Java程式中同步負載和記憶體堆分配壓力的跨函式全域性資料流分析演算法。

  2. 通過逃逸分析,Java Hotspot編譯器能夠分析出一個新的物件的引用的使用範圍從而決定是否要將這個物件分配到堆上。

  3. 逃逸分析的基本行為就是分析物件動態作用域:

    • 當一個物件在方法中被定義後,物件只在方法內部使用,則認為沒有發生逃逸。

    • 當一個物件在方法中被定義後,它被外部方法所引用,則認為發生逃逸。例如作為呼叫引數傳遞到其他地方中。  

10.1、逃逸分析

  • 逃逸分析的舉例
     1 /**
     2  * 逃逸分析
     3  *
     4  * 如何快速的判斷是否發生了逃逸分析,大家就看new的物件實體是否有可能在方法外被呼叫。
     5  */
     6 public class EscapeAnalysis {
     7 
     8     public EscapeAnalysis obj;
     9 
    10     /*
    11     方法返回EscapeAnalysis物件,發生逃逸
    12      */
    13     public EscapeAnalysis getInstance(){
    14         return obj == null? new EscapeAnalysis() : obj;
    15     }
    16 
    17     /*
    18     為成員屬性賦值,發生逃逸
    19      */
    20     public void setObj(){
    21         this.obj = new EscapeAnalysis();
    22     }
    23     //思考:如果當前的obj引用宣告為static的?仍然會發生逃逸。
    24 
    25     /*
    26     物件的作用域僅在當前方法中有效,沒有發生逃逸
    27      */
    28 
    29     public void useEscapeAnalysis(){
    30         EscapeAnalysis e = new EscapeAnalysis();
    31     }
    32 
    33     /*
    34     引用成員變數的值,發生逃逸
    35      */
    36     public void useEscapeAnalysis1(){
    37         EscapeAnalysis e = getInstance();
    38         //getInstance().xxx()同樣會發生逃逸
    39     }
    40 }
  • 逃逸分析引數設定

    1. 在JDK 1.7 版本之後,HotSpot中預設就已經開啟了逃逸分析

    2. 如果使用的是較早的版本,開發人員則可以通過:

      • 選項“-XX:+DoEscapeAnalysis"顯式  ,DoEscapeAnalysis預設開啟

      • 通過選項“-XX:+PrintEscapeAnalysis"檢視逃逸分析的篩選結果

  逃逸分析結論

    開發中能使用區域性變數的,就不要使用在方法外定義。

  • 逃逸分析之程式碼優化

  使用逃逸分析,編譯器可以對程式碼做如下優化:

  1. 棧上分配:將堆分配轉化為棧分配。如果一個物件在子程式中被分配,要使指向該物件的指標永遠不會發生逃逸,物件可能是棧上分配的候選,而不是堆上分配

  2. 同步省略:如果一個物件被發現只有一個執行緒被訪問到,那麼對於這個物件的操作可以不考慮同步。

  3. 分離物件或標量替換:有的物件可能不需要作為一個連續的記憶體結構存在也可以被訪問到,那麼物件的部分(或全部)可以不儲存在記憶體,而是儲存在CPU暫存器中。

10.2、棧上分配

  棧上分配

  1. JIT編譯器在編譯期間根據逃逸分析的結果,發現如果一個物件並沒有逃逸出方法的話,就可能被優化成棧上分配。

  2. 分配完成後,繼續在呼叫棧內執行,最後執行緒結束,棧空間被回收,區域性變數物件也被回收。這樣就無須進行垃圾回收了。

  3. 常見的棧上分配的場景:在逃逸分析中,已經說明了,分別是給成員變數賦值、方法返回值、例項引用傳遞。

  棧上分配舉例

  • 程式碼
     1 /**
     2  * 棧上分配測試
     3  * 引數:-Xmx256m -Xms256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
     4  *
     5  * DoEscapeAnalysis預設開啟
     6  * -XX:-DoEscapeAnalysis:關閉逃逸分析
     7  */
     8 public class StackAllocation {
     9     public static void main(String[] args) {
    10         long start = System.currentTimeMillis();
    11 
    12         for (int i = 0; i < 10000000; i++) {
    13             alloc();
    14         }
    15         // 檢視執行時間
    16         long end = System.currentTimeMillis();
    17         System.out.println("花費的時間為: " + (end - start) + " ms");
    18         // 為了方便檢視堆記憶體中物件個數,執行緒sleep
    19         try {
    20             Thread.sleep(1000000);
    21         } catch (InterruptedException e1) {
    22             e1.printStackTrace();
    23         }
    24     }
    25 
    26     private static void alloc() {
    27         User user = new User();//未發生逃逸
    28     }
    29 
    30     static class User {
    31 
    32     }
    33 }
  • 未開啟逃逸分析的情況

    • JVM 引數設定:-Xmx256m -Xms256m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails

    • 執行程式,日誌列印:發生了 GC ,耗時 72ms

    • 堆上面有好多好多 User 物件

    

  • 開啟逃逸分析的情況

    • JVM 引數設定:-Xmx256m -Xms256m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails

    • 執行程式,日誌列印:並沒有發生 GC ,耗時 8ms ,棧上分配是真的快啊

    

  總結

    通過情況1:未開啟逃逸,有GC日誌列印,且對中User物件很多,情況2:開啟逃逸,無GC日誌,且堆中物件少;說明部分物件不在堆中建立的,那麼間接說明了棧上分配

  Java物件分配流程總結

    

10.3、同步省略

  同步省略  

  1. 執行緒同步的代價是相當高的,同步的後果是降低併發性和效能

  2. 在動態編譯同步塊的時候,JIT編譯器可以藉助逃逸分析來判斷同步塊所使用的鎖物件是否只能夠被一個執行緒訪問而沒有被髮布到其他執行緒。

  3. 如果沒有,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分程式碼的同步。這樣就能大大提高併發性和效能。這個取消同步的過程就叫同步省略,也叫鎖消除。

  同步省略程式碼舉例

  • 程式碼1
    1 public void f() {
    2     Object o = new Object();
    3     synchronized(o) {
    4         System.out.println(o);
    5     }
    6 }
  • 程式碼中對o這個物件加鎖,但是o物件的生命週期只在f()方法中,並不會被其他執行緒所訪問到,所以在JIT編譯階段就會被優化掉,優化成程式碼2:

    1 public void f() {
    2     Object o = new Object();
    3     System.out.println(o);
    4 }  
  • 位元組碼分析程式碼1

    

  • 注意:位元組碼檔案中並沒有進行優化,可以看到加鎖和釋放鎖的操作依然存在,同步省略操作是在解釋執行時發生的

10.4、標量替換

  分離物件或標量替換

  1. 標量(scalar)是指一個無法再分解成更小的資料的資料。Java中的原始資料型別就是標量

  2. 相對的,那些還可以分解的資料叫做聚合量(Aggregate),Java中的物件就是聚合量,因為他可以分解成其他聚合量和標量。

  3. 在JIT階段,如果經過逃逸分析,發現一個物件不會被外界訪問的話,那麼經過JIT優化,就會把這個物件拆解成若干個其中包含的若干個成員變數來代替。這個過程就是標量替換

  標量替換舉例

  • 程式碼
     1 public static void main(String args[]) {
     2     alloc();
     3 }
     4 class Point {
     5     private int x;
     6     private int y;
     7 }
     8 private static void alloc() {
     9     Point point = new Point(1,2);
    10     System.out.println("point.x" + point.x + ";point.y" + point.y);
    11 }
  • 以上程式碼,經過標量替換後,就會變成
    1 private static void alloc() {
    2     int x = 1;
    3     int y = 2;
    4     System.out.println("point.x = " + x + "; point.y=" + y);
    5 }

  結論:

  1. 可以看到,Point這個聚合量經過逃逸分析後,發現他並沒有逃逸,就被替換成兩個聚合量了。

  2. 那麼標量替換有什麼好處呢?就是可以大大減少堆記憶體的佔用。因為一旦不需要建立物件了,那麼就不再需要分配堆記憶體了。

  3. 標量替換為棧上分配提供了很好的基礎。  

  標量替換引數設定

  引數 -XX:+ElimilnateAllocations:開啟了標量替換(預設開啟),允許將物件打散分配在棧上。

  程式碼示例

  • 程式碼

     1 /**
     2  * 標量替換測試
     3  * -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
     4  *
     5  */
     6 public class ScalarReplace {
     7     public static class User {
     8         public int id;
     9         public String name;
    10     }
    11 
    12     public static void alloc() {
    13         User u = new User();//未發生逃逸
    14         u.id = 5;
    15         u.name = "www.test.com";
    16     }
    17 
    18     public static void main(String[] args) {
    19         long start = System.currentTimeMillis();
    20         for (int i = 0; i < 10000000; i++) {
    21             alloc();
    22         }
    23         long end = System.currentTimeMillis();
    24         System.out.println("花費的時間為: " + (end - start) + " ms");
    25     }
    26 }
    View Code

  • 未開啟標量替換

    • JVM 引數:-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations

    • 日誌分析:伴隨著 GC 的垃圾回收,用時 112ms

  • 開啟標量替換

    • JVM 引數:-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations

    • 日誌分析:無垃圾回收,用時 9ms

  逃逸分析引數設定總結

  1. 上述程式碼在主函式中呼叫了1億次alloc()方法,進行物件建立

  2. 由於User物件例項需要佔據約16位元組的空間,因此累計分配空間達到將近1.5GB。

  3. 如果堆空間小於這個值,就必然會發生GC。使用如下引數執行上述程式碼:

    1. -server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations

  這裡設定引數如下:

  1. 引數 -server:啟動Server模式,因為在server模式下,才可以啟用逃逸分析。

  2. 引數 -XX:+DoEscapeAnalysis:啟用逃逸分析

  3. 引數 -Xmx10m:指定了堆空間最大為10MB

  4. 引數 -XX:+PrintGC:將列印GC日誌。

  5. 引數 -XX:+EliminateAllocations:開啟了標量替換(預設開啟),允許將物件打散分配在棧上,比如物件擁有id和name兩個欄位,那麼這兩個欄位將會被視為兩個獨立的區域性變數進行分配

10.5、逃逸分析的不足 

  1. 關於逃逸分析的論文在1999年就已經發表了,但直到JDK1.6才有實現,而且這項技術到如今也並不是十分成熟的。

  2. 其根本原因就是無法保證逃逸分析的效能消耗一定能高於他的消耗。雖然經過逃逸分析可以做標量替換、棧上分配、和鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程。一個極端的例子,就是經過逃逸分析之後,發現沒有一個物件是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。

  3. 雖然這項技術並不十分成熟,但是它也是即時編譯器優化技術中一個十分重要的手段。注意到有一些觀點,認為通過逃逸分析,JVM會在棧上分配那些不會逃逸的物件,這在理論上是可行的,但是取決於JVM設計者的選擇。

  4. 據瞭解,Oracle Hotspot JVM中並未這麼做,這一點在逃逸分析相關的文件裡已經說明,所以可以明確所有的物件例項都是建立在堆上。

  5. 目前很多書籍還是基於JDK7以前的版本,JDK已經發生了很大變化,intern字串的快取和靜態變數曾經都被分配在永久代上,而永久代已經被元資料區取代。但是intern字串快取和靜態變數並不是被轉移到元資料區,而是直接在堆上分配,所以這一點同樣符合前面一點的結論:物件例項都是分配在堆上。

十一、堆小結

  1. 年輕代是物件的誕生、成長、消亡的區域,一個物件在這裡產生、應用,最後被垃圾回收器收集、結束生命。

  2. 老年代放置長生命週期的物件,通常都是從Survivor區域篩選拷貝過來的Java物件。

  3. 當然,也有特殊情況,我們知道普通的物件可能會被分配在TLAB上;

  4. 如果物件較大,無法分配在 TLAB 上,則JVM會試圖直接分配在Eden其他位置上;

  5. 如果物件太大,完全無法在新生代找到足夠長的連續空閒空間,JVM就會直接分配到老年代。

  6. 當GC只發生在年輕代中,回收年輕代物件的行為被稱為Minor GC。

  7. 當GC發生在老年代時則被稱為Major GC或者Full GC。

  8. 一般的,Minor GC的發生頻率要比Major GC高很多,即老年代中垃圾回收發生的頻率將大大低於年輕代。