1. 程式人生 > 實用技巧 >20200730 尚矽谷 JVM 08 - 堆

20200730 尚矽谷 JVM 08 - 堆

08 - 堆

1 - 堆的核心概述

  • 一個 JVM 例項對應一個程序,執行時資料區中的方法區和堆是程序所有的,程式計數器、本地方法棧和虛擬機器棧是執行緒所有的
  • 一個 JVM 例項只存在一個堆記憶體,堆也是 Java 記憶體管理的核心區域。
  • Java 堆區在 JVM 啟動的時候即被建立,其空間大小也就確定了。是 JVM 管理的最大一塊記憶體空間。
    • 堆記憶體的大小是可以調節的。
  • 《 Java 虛擬機器規範 》規定,堆可以處於物理上不連續的記憶體空間中,但在邏輯上它應該被視為連續的。
  • 所有的執行緒共享 Java 堆,在這裡還可以劃分執行緒私有的緩衝區( Thread Local Allocation Buffer , TLAB )
  • 通過 JDK 中的工具 jvisualvm.exe 可以檢視 JVM 的記憶體佔用,需要安裝 Visual GC 外掛
  • 《 Java 虛擬機器規範》中對 Java 堆的描述是:所有的物件例項以及陣列都應當在執行時分配在堆上。( The heap is the run-time data area from which memory for all class instances and arrays is allocated )
    • 我要說的是:“幾乎” 所有的物件例項都在這裡分配記憶體。——從實際使用角度看的。
  • 陣列和物件可能永遠不會儲存在棧上,因為棧幀中儲存引用,這個引用指向物件或者陣列在堆中的位置。
  • 在方法結束後,堆中的物件不會馬上被移除,僅僅在垃圾收集的時候才會被移除。
  • 堆,是 GC ( Garbage Collection ,垃圾收集器)執行垃圾回收的重點區域

堆的核心概述:記憶體細分

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

  • Java 7 及之前堆記憶體邏輯上分為三部分:新生區+養老區+永久區
    • Young Generation Space 新生區 Young/New
      • 又被劃分為 Eden 區和 Survivor 區
    • Tenure Generation Space 養老區 Old/Tenure
    • Permanent Space 永久區 Perm
  • Java 8 及之後堆記憶體邏輯上分為三部分:新生區+養老區+元空間
    • Young Generation Space 新生區 Young/New
      • 又被劃分為 Eden 區和 Survivor 區
    • Tenure Generation Space 養老區 Old/Tenure
    • Meta Space 元空間 Meta
  • 約定:新生區 == 新生代 == 年輕代 養老區 == 老年區 == 老年代 永久區 == 永久代

堆空間內部結構(JDK 7)

堆空間內部結構(JDK 8)

2 - 設定堆記憶體大小與 OOM

堆空間大小的設定

  • Java 堆區用於儲存 Java 物件例項,那麼堆的大小在 JVM 啟動時就已經設定好了,大家可以通過選項 -Xmx-Xms 來進行設定。
    • -Xms 用於表示堆區的起始記憶體,等價於 -XX:InitialHeapSize
    • -Xmx 則用於表示堆區的最大記憶體,等價於 -XX:MaxHeapSize
  • 一旦堆區中的記憶體大小超過 -Xmx 所指定的最大記憶體時,將會丟擲 OutOfMemoryError 異常。
  • 通常會將 -Xms-Xmx 兩個引數配置相同的值,其目的是為了能夠在 Java 垃圾回收機制清理完堆區後不需要重新分隔計算堆區的大小,從而提高效能
  • 預設情況下,初始記憶體大小 = 物理電腦記憶體大小 / 64;最大記憶體大小=物理電腦記憶體大小 / 4
  • 檢視設定的引數:
    • 方式一: jps / jstat -gc 程序id
    • 方式二:-XX:+PrintGCDetails
  • 年輕代因為 S0 和 S1 同時只使用其中之一儲存,所以計算大小時,只計算其中一個的大小

3 - 年輕代與老年代

  • 儲存在 JVM 中的 Java 物件可以被劃分為兩類:
    • 一類是生命期較短的瞬時物件,這類物件的建立和消亡都非常迅速
    • 另外一類物件的生命週期卻非常長,在某些極端的情況下還能夠與 JVM 的生命週期保持一致。
  • Java 堆區進一步細分的話,可以劃分為年輕代( YoungGen )和老年代( OldGen )
  • 其中年輕代又可以劃分為 Eden 空間、 Survivor0 空間和 Survivor1 空間(有時也叫做 from 區、 to 區)

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

    • 預設 -XX:NewRatio=2 ,表示新生代佔比 1 ,老年代佔比 2 ,新生代佔整個堆的 1/3
    • 可以修改 -XX:NewRatio=4 ,表示新生代佔比 1 ,老年代佔比 4 ,新生代佔整個堆的 1/5
  • 在 HotSpot 中, Eden 空間和另外兩個 Survivor 空間預設所佔的比例是 8:1:1

    • 實際上,需要顯示設定 -XX:SurvivorRatio=8
  • 當然開發人員可以通過選項 -XX:SurvivorRatio 調整這個空間比例。比如 -XX:SurvivorRatio=8

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

  • 絕大部分的 Java 物件的銷燬都在新生代進行了。

    • IBM 公司的專門研究表明,新生代中 80% 的物件都是“朝生夕死”的。
  • -XX:-UseAdaptiveSizePolicy :關閉自適應的記憶體分配策略,暫時用不到

  • 可以使用選項 -Xmn 設定新生代最大記憶體大小,優先順序高於 -XX:NewRatio

    • 這個引數一般使用預設值就可以了
C:\Users\80953>jps
16880 EdenSurvivorTest
22384 Jps
7088
1156 MainApplication
18312 Launcher
4984 Launcher

C:\Users\80953>jinfo -flag SurvivorRatio 16880
-XX:SurvivorRatio=8

C:\Users\80953>jinfo -flag NewRatio 16880
-XX:NewRatio=2

4 - 圖解物件分配過程

概述

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

物件分配過程

  1. new 的物件先放伊甸園區。此區有大小限制。
  2. 當伊甸園的空間填滿時,程式又需要建立物件, JVM 的垃圾回收器將對伊甸園區進行垃圾回收( YGC / Minor GC ),將伊甸園區中的不再被其他物件所引用的物件進行銷燬。再載入新的物件放到伊甸園區
  3. 然後將伊甸園中的剩餘物件移動到倖存者 0 區。
  4. 如果再次觸發垃圾回收,此時上次倖存下來的放到倖存者 0 區的,如果沒有回收,就會放到倖存者 1 區。
  5. 如果再次經歷垃圾回收,此時會重新放回倖存者 0 區,接著再去倖存者 1 區。
  6. 啥時候能去養老區呢?可以設定次數。預設是 15 次可以
    • 設定引數:-XX:MaxTenuringThreshold =<N> 進行設定。
  7. 在養老區,相對悠閒。當養老區記憶體不足時,再次觸發 GC( Major GC ),進行養老區的記憶體清理
  8. 若養老區執行了 Major GC 之後發現依然無法進行物件的儲存,就會產生 OOM 異常
    • java.lang.OutOfMemoryError: Java heap space
  • 總結:
    • 針對倖存者 s0,s1 區的總結:複製之後有交換,誰空誰是 to
    • 關於垃圾回收:頻繁在新生區收集,很少在養老區收集,幾乎不在永久區/元空間收集。

物件分配的特殊情況

  • 使用 JProfiler 觀察 JVM 堆記憶體佔用情況

5 - Minor GC、 Major GC、 Full GC

JVM 在進行 GC 時,並非每次都對上面三個記憶體區域一起回收的,大部分時候回收的都是指新生代。

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

-XX:+PrintGCDetails 可以列印 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 策略的觸發條件

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

    • 當年輕代空間不足時,就會觸發 Minor GC ,這裡的年輕代滿指的是 Eden 代滿, Survivor 滿不會引發 GC 。(每次 Minor GC 會清理年輕代的記憶體。)
    • 因為 Java 物件大多都具備朝生夕滅的特性,所以 Minor GC 非常頻繁,一般回收速度也比較快。這一定義既清晰又易於理解。
    • Minor GC 會引發 STW ,暫停其它使用者的執行緒,等垃圾回收結束,使用者執行緒才恢復執行
  • 老年代 GC( Major GC / Full GC )觸發機制:

    • 指發生在老年代的 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 觸發機制:

    • 觸發 Full GC 執行的情況有如下五種:
      • 呼叫 System.gc() 時,系統建議執行 Full GC ,但是不必然執行
      • 老年代空間不足
      • 方法區空間不足
      • 通過 Minor GC 後進入老年代的平均大小大於老年代的可用記憶體
      • 由 Eden 區、 survivor space0( From Space )區向 survivor space1( To Space )區複製時,物件大小大於 To Space 可用記憶體,則把該物件轉存到老年代,且老年代的可用記憶體小於該物件大小
    • 說明:Full GC是開發或調優中儘量要避免的。這樣暫停時間會短一些。

6 - 堆空間分代思想

為什麼需要把 Java 堆分代?不分代就不能正常工作了嗎?

  • 經研究,不同物件的生命週期不同。 70% - 99% 的物件是臨時物件。
    • 新生代:有 Eden 、兩塊大小相同的 Survivor (又稱為 from/to , s0/s1 )構成, to 總為空。
    • 老年代:存放新生代中經歷多次 GC 仍然存活的物件
  • 其實不分代完全可以,分代的唯一理由就是優化 GC 效能。如果沒有有分代,那所有的物件都在一塊,就如同把一個學校的人都關在一個教室。 GC 的時候要找到哪些物件沒用,這樣就會對堆的所有區域進行掃描。而很多物件都是朝生夕死的,如果分代的話,把新建立的物件放到某一地方,當 GC 的時候先把這塊儲存“朝生夕死”物件的區域進行回收,這樣就會騰出很大的空間出來。

7 - 記憶體分配策略

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

如果物件在 Eden 出生並經過第一次 Minor GC 後仍然存活,並且能被 Survivor 容納的話,將被移動到 Survivor 空間中,並將物件年齡設為 1 。物件在 Survivor 區中每熬過一次 Minor GC ,年齡就増加 1 歲,當它的年齡増加到一定程度(預設為 15 歲,其實每個 JVM 、每個 GC 都有所不同)時,就會被晉升到老年代中

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

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

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

8 - 為物件分配記憶體 : TLAB

為什麼有 TLAB ( Thread Local Allocation Buffer )?

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

什麼是 TLAB ?

  • 從記憶體模型而不是垃圾收集的角度,對 Eden 區域繼續進行劃分, JVM 為每個執行緒分配了一個私有快取區域,它包含在 Eden 空間內
  • 多執行緒同時分配記憶體時,使用 TLAB 可以避免一系列的非執行緒安全問題,同時還能夠提升記憶體分配的吞吐量,因此我們可以將這種記憶體分配方式稱之為快速分配策略
  • 據我所知,所有 OpenJDK 衍生出來的 JVM 都提供了 TLAB 的設計

TLAB 的再說明

  • 儘管不是所有的物件例項都能夠在 TLAB 中成功分配記憶體,但 JVM 確實是將 TLAB 作為記憶體分配的首選
  • 在程式中,開發人員可以通過選項 -XX:UseTLAB 設定是否開啟 TLAB 空間。
  • 預設情況下,TLAB 空間的記憶體非常小,僅佔有整個 Eden 空間的 1%,當然我們可以通過選項 -XX:TLABWasteTargetPercent 設定 TLAB 空間所佔用 Eden 空間的百分比大小。
  • 且物件在 TLAB 空間分配記憶體失敗時, JVM 就會嘗試著通過使用加鎖機制確保資料操作的原子性,從而直接在 Eden 空間中分配記憶體。

物件分配過程:TLAB

9 - 小結堆空間的引數設定

  • 官網說明

  • -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:是否設定空間分配擔保

-XX:HandlePromotionFailure

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

  • 如果大於,則此次 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

10 - 堆是分配物件儲存的唯一選擇嗎

堆外儲存技術

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

隨著 JIT 編譯期的發展與逃逸分析技術逐漸成熟,棧上分配標量替換優化技術將會導致一些微妙的變化,所有的物件都分配到堆上也漸漸變得不那麼“絕對”了。在 Java 虛擬機器中,物件是在 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:+DoEscapeAnalysis 顯式開啟逃逸分析通過選項 -XX:+PrintEscapeAnalysis 査看逃逸分析的篩選結果。
  • 結論:開發中能使用區域性變數的,就不要使用在方法外定義。

逃逸分析:程式碼優化

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

  1. 棧上分配。將堆分配轉化為棧分配。如果一個物件在子程式中被分配,要使指向該物件的指標永遠不會逃逸,物件可能是棧分配的候選,而不是堆分配。
  2. 同步省略。如果一個物件被發現只能從一個執行緒被訪問到,那麼對於這物件的操作可以不考慮同步。
  3. 分離物件或標量替換。有的物件可能不需要作為一個連續的記憶體結構存在也可以被訪問到,那麼物件的部分(或全部)可以不儲存在記憶體,而是儲存在 CPU 暫存器中。

程式碼優化之棧上分配

  • JIT 編譯器在編譯期間根據逃逸分析的結果,發現如果一個物件並沒有逃逸出方法的話,就可能被優化成棧上分配。分配完成後,繼續在呼叫棧內執行,最後執行緒結束,棧空間被回收,區域性變數物件也被回收。這樣就無須進行垃圾回收了
  • 常見的棧上分配的場景
    • 在逃逸分析中,已經說明了。分別是給成員變數賦值、方法返回值例項引用傳遞。

程式碼優化之同步省略(消除)

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

程式碼優化之標量替換

  • 標量( Scalar )是指一個無法再分解成更小的資料的資料。 Java 中的原始資料型別就是標量
  • 相對的,那些還可以分解的資料叫做聚合量( Aggregate ), Java 中的物件就是聚合量,因為他可以分解成其他聚合量和標量。
  • 在 JIT 階段,如果經過逃逸分析,發現一個物件不會被外界訪問的話,那麼經過 JIT 優化,就會把這個物件拆解成若干個其中包含的若干個成員變數來代替。這個過程就是標量替換
  • 標量替換為棧上分配提供了很好的基礎

逃逸分析小結:逃逸分析並不成熟

  • 關於逃逸分析的論文在 1999 年就已經發表了,但直到 JDK 1. 6 才有實現,而且這項技術到如今也並不是十分成熟的。
  • 其根本原因就是無法保證逃逸分析的效能消耗一定能高於他的消耗。雖然經過逃逸分析可以做標量替換、棧上分配、和鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程
  • 一個極端的例子,就是經過逃逸分析之後,發現沒有一個物件是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。
  • 雖然這項技術並不十分成熟,但是它也是即時編譯器優化技術中一個十分重要的手段
  • 注意到有一些觀點,認為通過逃逸分析, JVM 會在棧上分配那些不會逃逸的物件,這在理論上是可行的,但是取決於 JVM 設計者的選擇。據我所知, Oracle Hotspot JVM 中並未這麼做,這一點在逃逸分析相關的文件裡已經說明,所以可以明確所有的物件例項都是建立在堆上。
  • 目前很多書籍還是基於 JDK 7 以前的版本, JDK 已經發生了很大變化, intern 字串的快取和靜態變數曾經都被分配在永久代上,而永久代已經被元資料區取代。但是,intern 字串快取和靜態變數並不是被轉移到元資料區,而是直接在堆上分配,所以這一點同樣符合前面一點的結論:物件例項都是分配在堆上

本章小結

  • 年輕代是物件的誕生、成長、消亡的區域,一個物件在這裡產生、應用,最後被垃圾回收器收集、結束生命。
  • 老年代放置長生命週期的物件,通常都是從 Survivor 區域篩選拷貝過來的 Java 物件。當然,也有特殊情況,我們知道普通的物件會被分配在 TLAB 上;如果物件較大, JVM 會試圖直接分配在 Eden 其他位置上;如果物件太大,完全無法在新生代找到足夠長的連續空閒空間, JVM 就會直接分配到老年代
  • 當 GC 只發生在年輕代中,回收年輕代物件的行為被稱為 Minor GC 。當 GC 發生在老年代時則被稱為 Major GC 或者 Full GC 。一般的, Minor GC 的發生頻率要比 Major GC 高很多,即老年代中垃圾回收發生的頻率將大大低於年輕代。