JVM堆詳述

堆得核心概述

  • 一個JVM實例只存在一個堆內存,堆也是java內存管理的核心區域
  • Java堆區在jvm啓動的時候被創建,其空間大小也就確定了。是jvm管理的最大一塊內存空間。(堆內存的大小可以調節)
  • 《java虛擬機規範》規定,堆可以處於物理上不連續的內存空間中,但在邏輯上它應該被視爲連續的
  • 所有的線程共享java堆,在這裏還可以劃分線程私有的緩衝區
  • 《Java虛擬機規範》中對Java堆的描述是:所有對象實例以及數組都應該運行時分配在堆上
  • 數組和對象可能永遠不會存儲在棧上,因爲棧幀中保存引用,這個引用指向對象或數組在對中的位置
  • 在方法結束後,堆中對象不會馬上移除,僅僅在垃圾收集的時候纔會被移除
  • 堆是GC(Garbage Collection)執行垃圾回收的重點區域
內存細分

現代垃圾收集器大部分都基於分代收集理論設計,堆空間分爲:

  • java7之前堆內存邏輯上分爲三部分:新生區+養老區+永久區
  • java8之後堆內存邏輯上分爲三部分:新生區+養老區+元空間

設置堆內存大小與OOM

設置堆空間大小的參數

  1. 設置堆空間大小的參數
    -Xms 用來設置堆空間(年輕代+老年代)的初始內存大小
    -X 是jvm的運行參數
    ms 是memory start
    -Xmx 用來設置堆空間(年輕代+老年代)的最大內存大小
  2. 默認堆空間的大小
    初始內存大小:物理電腦內存大小 / 64
    最大內存大小:物理電腦內存大小 / 4
  3. 手動設置:-Xms600m -Xmx600m
    開發中建議將初始堆內存和最大的堆內存設置成相同的值。
  4. 查看設置的參數:方式一: jps / jstat -gc 進程id
    方式二:-XX:+PrintGCDetails

OutOfMemory舉例

public class OOMTest {
    public static void main(String[] args) {
        ArrayList<Picture> list = new ArrayList<>();
        while(true){
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            list.add(new Picture(new Random().nextInt(1024 * 1024)));
        }
    }
}
class Picture{
    private byte[] pixels;
    public Picture(int length) {
        this.pixels = new byte[length];
    }
}
//Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

年輕代與老年代

  • 存儲在JVM中的java對象可以被劃分爲兩類:

    • 一類是生命週期較短的瞬時對象,這類對象的創建和消亡都非常迅速
    • 另外一類對象的生命週期卻非常短,在某些極端的情況下還能夠與JVM的生命週期保持一致
  • java堆區進一步細分的話,可以分爲年輕代和老年代

  • 其中年輕代又可以劃分爲Eden空間,Survivor0和Survivor1空間(也叫from,to區)
    在這裏插入圖片描述
    在這裏插入圖片描述

  • 配置新生代與老年代在堆結構的佔比

    • 默認-XX:NewRation=2,表示新生代佔1,老年代佔2,新生代佔整個堆的1/3
  • 在HotSpot中,Eden空間和另外兩個Survivor空間大小所佔比例爲8:1:1

  • 可以通過-XX:SurvivorRatio調整空間比例

  • 幾乎所有的Java對象都是在Eden區被new出來的

  • 絕大部分的Java對象的銷燬都在新生代進行了

  • 可以通過-Xmn設置新生代的最大內存大小

對象分配過程

概述:

爲新對象分配內存是一件非常嚴謹和複雜的任務,JVM的設計者們不僅需要考慮內存如何分配,在哪裏分配等問題,並且由於內存分配算法與內存回收算法密切相關,所以還需要考慮GC執行完內存回收後是否會在內存空間中產生內存碎片

  • new的對象先伊甸園(Eden)。此區有大小限制
  • 當伊甸園的空間填滿時,程序有需要創建對象,JVM的垃圾回收器將對伊甸園區進行垃圾回收(Minor GC),將伊甸園區中的不在被其他對象所引用的對象進行銷燬。在加載新的對象放到伊甸園區
  • 然後將伊甸園中的剩餘對象移動到倖存者0區
  • 如果再次觸發垃圾回收,此時會重新放回倖存者0區,接着再去倖存者1區
  • 什麼時候進入養老區?可以設置次數。默認爲15
    • 可以設置參數:-XX:MaxTenuringThreshold=進行設置

注意事項

survivor區滿了不會進行垃圾回收,而是在伊甸園區滿了之後垃圾回收算法對伊甸園區進行回收的同時,survivor區會被動的進行垃圾回收

總結

  • 針對倖存者S0,S1區的總結:複製之後有交換,誰空誰是to
  • 關於垃圾回收:頻繁在新生區收集,很少在養老區收集,幾乎不在永久區/元空間收集

Minor GC,Major GC,Full GC概念

JVM在進行GC時,並非每次都對上面三個內存(新生代,老年代,方法區)區域一起回收的,大部分的時候回收都是指新生代

針對Hotspot VM的實現,它裏面的GC按照回收區域又分爲兩大種類型:一種是部分收集(Partial GC),一種是整堆收集(FULL GC)

  • 部分收集:不是完整收集java堆的垃圾收集。其中又分爲:

    • 新生代收集(Minor GC/Young GC):只是新生代(Eden/S0,S1)的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
      • 只有CMS GC會有單獨收集老年代的行爲
      • 注意:很多時候Major GC會和FULL GC混淆使用,需要具體分辨是老年代回收,還是整堆回收
    • 混合收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集
      • 目前只有G1 GC會有這種行爲
  • 整堆收集(FULL GC):收集整個java堆和方法區的垃圾收集

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

    • 當年輕代空間不足時,就會觸發Minor GC,這裏的年輕代滿指的是Eden區滿,Survivor滿不會引發GC(每次Minor GC會清理年輕代的內存)
    • 因爲Java對象大多都是朝生熄滅的特徵,所以Minor GC非常頻繁,一般回收速度比較快。
    • Minor GC會引發STW,暫停其他用戶的線程,等垃圾回收結束,用戶線程才恢復運行
  • 老年代GC (Major GC/Full GC)觸發機制:

    • 指發生在老 年代的GC,對象從老年代消失時,我們說“Major GC” 或“Fu1l GC”發生了。
    • 出現了Major GC,經常會伴隨至少一.次的Minor GC (但非絕對的,在ParallelScavenge收集器的收集策略裏就有直接進行MajorGC的策略選擇過程)
      • 也就是在老年代空間不足時,會先嚐試觸發Minor GC。 如果之後空間還不足,則觸發Major GC
    • Major GC的速度一般會比Minor Gc慢10倍以上,STW的時間更長。
    • 如果Major GC後,內存還不足,就報00M了。
  • Fu11 GC觸發機制
    觸發Fu1l GC執行的情況有如下五種:

    • 調用System. gc()時,系統建議執行Full GC,但是不必然執行
    • 老年代空間不足
    • 方法區空間不足
    • 通過Minor GC後進入老年代的平均大小大於老年代的可用內存
    • 由Eden區、survivor space0 (From Space) 區向survivor space1 (ToSpace) 區複製時,對象大小大於To Space可用內存,則把該對象轉存到老年代,且老年代的可用內存小於該對象大小

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

堆空間分代思想

爲什麼要把java堆分代?

  • 經研究表明:不同對象的生命週期不同。70%-99%的對象都是臨時對象
    • 新生代:有Eden,兩塊大小相同的Survivor(from/to或S0/S1)構成,其中to總爲空

不分代能正常工作嗎?

其實不分代完全可以,分代的唯一理由就是優化GC性能。如果沒有分代,那所有的對象都在一塊,就如同把一個學校的人都關在一個教室。GC的時候要找到哪些對象沒用這樣就會對堆的所有區域進行掃描。而很多對象都是朝生夕死的,如果分代的話,把新創建的對象放到某一地方,當GC的時候先把這塊存儲“朝生夕死”對象的區域進行回收,這樣就會騰出很大的空間出來。

內存分配策略

如果對象在Eden出生並經過第一次MinorGC 後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並將對象年齡設爲1。對象在Survivor區中每熬過一次MinorGC ,年齡就增加1 歲,當它的年齡增加到一定程度(默認爲15歲,其實每個JVM、每個GC都有所不同)時,就會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過選項-XX : MaxTenuringThreshold來設置

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

  • 優先分配到Eden
  • 大對象直接分配到老年代
    • 儘量避免程序中出現過多的大對象
  • 長期存活的對象分配到老年代
  • 動態對象年齡判斷
    • 如果Survivor 區中相同年齡的所有對象大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的對象可以直接進入老年代,無須等到MaxTenur ingThreshold中要求的年齡。
  • 空間分配擔保
    • -XX:HandlePromotionFailure

對象分配內存:TLAB(Thread Local Allocation Buffer)

  • 堆區是線程共享的區域,任何線程都可以訪問到堆區中的共享數據
  • 由於對象實例的創建在JVM中非常頻繁,因此在併發環境下從堆區中劃分內存空間是線程不安全的
  • 爲避免多個線程操作同一地址,需要使用加鎖等機制,進而影響分配速度

什麼是TLAB

  • 從內存模型而不是垃圾收集的角度,對Eden區域繼續進行劃分,JVM爲每個線程分配了一個私有緩衝區,它包含在Eden空間內
  • 多線程同時分配內存時,使用TLAB可以避免一系列的非線程安全問題,同時還能夠提升內存分配的吞吐量,因此我們可以將這種內存分配方式稱之爲快速分配策略

  • 儘管不是所有的對象實例都能夠在TLAB中成功分配內存,但JVM確實是將TLAB作爲內存分配的首選。
  • 在程序中,開發人員可以通過選項“-XX :UseTLAB”設置是否開啓TLAB空間。
  • 默認情況下,TLAB空間的內存非常小,佔有整個Eden空間的1%,當然我們可以通過選項“-XX:TLABWasteTargetPercent”設置TLAB空間所佔用Eden空間的百分比大小。
  • 一旦對象在TLAB空間分配內存失敗時,JVM就會嘗試着通過使用加鎖機制確保數據操作的原子性,從而直接在Eden空間中分配內存。

分配過程

堆空間的參數配置

測試堆空間常用的jvm參數:

  • -XX:+PrintFlagsInitial : 查看所有的參數的默認初始值
  • -XX:+PrintFlagsFinal :查看所有的參數的最終值(可能會存在修改,不再是初始值)
    •  具體查看某個參數的指令: jps:查看當前運行中的進程
      
    •  jinfo -flag SurvivorRatio 進程id
      
  • -Xms:初始堆空間內存 (默認爲物理內存的1/64)
  • -Xmx:最大堆空間內存(默認爲物理內存的1/4)
  • -Xmn:設置新生代的大小。(初始值及最大值)
  • -XX:NewRatio:配置新生代與老年代在堆結構的佔比
  • -XX:SurvivorRatio:設置新生代中Eden和S0/S1空間的比例
  • -XX:MaxTenuringThreshold:設置新生代垃圾的最大年齡
  • -XX:+PrintGCDetails:輸出詳細的GC處理日誌
  • 打印gc簡要信息:① -XX:+PrintGC ② -verbose:gc
  • -XX:HandlePromotionFailure:是否設置空間分配擔保

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

  • 如果大於,則此次Minor GC是安全的
  • 如果小於,則虛擬機會查看-XX: HandlePromotionFailure設置值是否允許擔保失敗。
    • 如果HandlePromotionFailure=true, 那麼會繼續檢查老年代最大可用連續空間是否大於歷次晉升到老年代的對象的平均大小。
      • 如果大於,則嘗試進行一次Minor GC,但這次Minor GC依然是有風險的;
      • 如果小於,則改爲進行一-次Full GC。
    • 如果HandlePromotionFailure=false, 則改爲進行一次Full GC。

在JDK6 Update24之 後,HandlePromotionFailure參數不會再影響到虛擬機的空間分配擔保策略,觀察OpenJDK中的源碼變化,雖然源碼中還定義了
HandlePromotionFailure參數,但是在代碼中已經不會再使用它。JDK6 Update24之後的規則變爲只要老年代的連續空間大於新生代對象總大小或者歷次晉升的平均大小就會進行Minor GC,否則將進行Full GC。

分配對象的選擇

在《深入理解Java虛擬機》中關於Java堆內存有這樣一段描述:隨着JIT編譯期的發展與逃逸分析技術逐漸成熟,棧上分配,標量替換優化技術將會導致一些微妙的變化,所有的對象都分配到堆上也漸漸變得不那麼絕對了

在JVM中,對象是在java堆中分配內存的,這是一個普遍的常識,但是,有一種特殊的情況,那就是如果經過逃逸分析(Escape Analysis)後發現,一個對象沒有逃逸出方法的話,那麼久可能被優化成棧上分配。這樣就無需在堆上分配內存,也無需進行;垃圾回收了。這也是最常見的堆外存儲技術。

此外,在基於OpenJdk深度指定的TaoBaoVm,其中創新的GCIH(GC invisible heap)技術實現off-heap,將生命週期較長的Java對象從heap中移至heap外,並且GC不能管理GCIH內部的java對象,以此達到降低GC的回收頻率和提升GC的回收率的目的

逃逸分析概述
  • 如何將堆上的對象分配到棧,需要使用逃逸分析手段
  • 這是一種有效減少Java程序中同步負載和內存堆分配壓力的跨函數全局數據流分析算法
  • 通過逃逸分析,Java HotSpot編譯器能夠分析出一個新的對象引用的使用範圍從而決定是否要將這個對象分配到堆上
  • 逃逸分析的基本行爲就是分析對象動態作用域
    • 當一個對象在方法中被定義後,對象只在方法內部使用,則認爲沒有發生逃逸
    • 當一個對象在方法中被定義後,被外部方法所引用,則認爲發生逃逸。例如作爲調用參數專遞到其他地方中

參數設置:
在JDK 6u23版本之後,HotSpot中默認就已經開啓了逃逸分析。
如果使用的是較早的版本,開發人員則可以通過:
選項“-XX: fDoEscapeAnalysis"顯式開啓逃逸分析
通過選項“-XX: +PrintEscapeAnalysis" 查看逃逸分析的篩選結果。

代碼優化

  • 棧上分配。將堆分配轉化爲棧分配,如果一個對象在子程序中被分配,要使指向該對象的指針永遠不會逃逸,對象可能是棧分配的候選,而不是堆分配
  • 同步省略。如果一個對象被發現只能從一個線程被訪問到,那麼對於這個對象的操作可以不考慮同步
  • 分離對象或標量替換。有的對象可能不需要作爲一個連續的內存結構存在也可以被訪問到,那麼對象的部分或全部可以不存儲在內存,而是存儲在CPU的寄存器中

棧上分配

JIT編譯器在編譯期間根據逃逸分析的結果,發現如果一個對象並沒有逃逸出方法的話,就可能被優化成棧上分配。分配完成之後,繼續在調用棧內執行,最後線程結束,棧空間被回收,局部變量對象也被回收,這樣就無需進行垃圾回收了。

常見的棧上分配場景:給成員變量賦值,方法返回值,實例引用傳遞

同步省略

  • 線程同步的代價是相當高的,同步的後果是降低併發性和性能。
  • 在動態編譯同步塊的時候,JIT編譯器可以藉助逃逸分析來判斷同步塊所使用的鎖對象是否能夠被同一個線程訪問而沒有被髮布到其他線程。如果沒有,那麼JIT編譯器在編譯這個同步塊的時候就去取消對這部分代碼的同步功能,這樣就能大大提高併發性和性能,這個取消同步的過程就叫同步省略,也叫鎖消除
public void f(){
    Object hollis = new Object();
    synchronized(hollis){
        System.out.print(hollis)
    }
}

代碼中hollis這個對象進行加鎖,但是hollis對象的生命週期只在f()方法中,並不會被其他線程所訪問,所以在JIT編譯階段就會被優化掉。優化成:

public void f(){
    Object hollis = new Object();
    System.out.print(hollis)
}

標量替換

標量(Scalar)是指一個無法在分解成更小的數據的數據。Java中的原始數據類型就是標重.

相對的,那些還可以分解的數據叫做聚合量(Aggregate),Java中的對象就是聚合量,因爲他可以分解成其他聚合量和標量。

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

   public static void main(String[] args) {
        alloc();
    }
    private static void alloc(){
        Point point = new Point(1,2);
    }
    class Point{
        private int x;
        private int y;
        public Point(int x,int y){
            this.x = x;
            this.y = y;
        }
    }

以上代碼經過標量替換後就會變成

 private static void alloc(){
        int x = 1;
        int y = 2;
  }

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

標量替換的好處:可以大大減少堆內存的佔用,因爲一旦不需要創建了,那麼就不需要分配堆內存了

標量替換參數

-XX:+EliminateAllocations:開啓了標量替換(默認打開),允許對象打散分配在棧上

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