八、堆
1、堆的核心概述
- 一個JVM實例只存在一個堆內存,堆也是Java內存管理的核心區域。
- Java 堆區在JVM啓動的時候即被創建,其空間大小也就確定了。是JVM管理的最大一塊內存空間。
>堆內存的大小是可以調節的。
- 《Java虛擬機規範》規定,堆可以處於物理上不連續的內存空間中,但在邏輯上它應該被視爲連續的。
- 所有的線程共享Java堆,在這裏還可以劃分線程私有的緩衝區(ThreadLocal Allocation Buffer, TLAB)
- 《Java虛擬機規範》中對Java堆的描述是:所有的對象實例以及數組都應當在運行時分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated )
>我要說的是: “幾乎”所有的對象實例都在這裏分配內存。——從實際使用角度看的。
- 數組和對象可能永遠不會存儲在棧上,因爲棧幀中保存引用,這個引用指向對象或者數組在堆中的位置。
- 在方法結束後,堆中的對象不會馬上被移除,僅僅在垃圾收集的時候纔會被移除。
- 堆,是GC ( Garbage Collection,垃圾收集器)執行垃圾回收的重點區域。
堆的核心概述:內存細分
現代垃圾收集器大部分都基於分代收集理論設計,堆空間細分爲:
2、設置堆內存大小與OOM
- Java堆區用於存儲Java對象實例,那麼堆的大小在JVM啓動時就已經設定好了,大家可以通過選項"-Xmx"和"-Xms"來進行設置。
① "-Xms" 用於表示堆區的起始內存,等價於-XX: InitialHeapSize
② "-Xmx" 則用於表示堆區的最大內存,等價於-XX:MaxHeapSize
- 一旦堆區中的內存大小超過 "-Xmx" 所指定的最大內存時,將會拋出 OutOfMemoryError 異常。
- 通常會將 -Xms 和 -Xmx 兩個參數配置相同的值,其目的是爲了能夠在java垃圾回收機制清理完堆區後不需要重新分隔計算堆區的大小,從而提高性能。
- 默認情況下,初始內存大小: 物理電腦內存大小 / 64
- 最大內存大小: 物理電腦內存大小 / 4
3、年輕代與老年代
- 存儲在JVM中的Java對象可以被劃分爲兩類:
> 一類是生命週期較短的瞬時對象,這類對象的創建和消亡都非常迅速
> 另外一類對象的生命週期卻非常長,在某些極端的情況下還能夠與JVM的生命週期
保持一致。
- Java 堆區進一步細分的話,可以劃分爲年輕代(YoungGen)和老年代(0ldGen)
- 其中年輕代又可以劃分爲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" 調整這個空間比例。
比如 -XX:SurvivorRatio=8
- 幾乎所有的 Java 對象都是在Eden區被new出來的。
- 絕大部分的 Java 對象的銷燬都在新生代進行了。
> IBM 公司的專門研究表明,新生代中 80% 的對象都是“朝生夕死”的。
- 可以使用選項 "-Xmn" 設置新生代最大內存大小
> 這個參數一般使用默認值就可以了。
4、圖解對象分配過程
概述
爲新對象分配內存是一件非常嚴謹和複雜的任務, JVM的設計者們不僅需要考慮內存如何分配、在哪裏分配等問題,並且由於內存分配算法與內存回收算法密切相關,所以還需要考慮GC執行完內存回收後是否會在內存空間中產生內存碎片。
① new的對象先放伊甸園區。此區有大小限制
② 當伊甸園的空間填滿時,程序又需要創建對象, JVM的垃圾回收器將對伊甸園區進行垃圾回收(Minor GC),將伊甸園區中的不再被其他對象所引用的對象進行銷燬。再加載新的對象放到伊甸園區
③ 然後將伊甸園中的剩餘對象移動到倖存者0區。
④ 如果再次觸發垃圾回收,此時上次倖存下來的放到倖存者0區的,如果沒有回收,就會放到倖存者1區。
⑤ 如果再次經歷垃圾回收,此時會重新放回倖存者0區,接着再去倖存者1區。
⑥ 啥時候能去養老區呢?可以設置次數。默認是15次。
可以設置參數: -XX:MaxTenuringThreshold=<N> 進行設置。
⑦ 在養老區,相對悠閒。當養老區內存不足時,再次觸發GC: Major GC,進行養老區的內存清理
⑧ 若養老區執行了Major GC 之後發現依然無法進行對象的保存,就會產生OOM異常
java.lang.OutOfMemoryError: Java heap space
總結:
- 針對倖存者s0,s1區的總結:複製之後有交換,誰空誰是to.
- 關於垃圾回收:頻繁在新生區收集,很少在養老區收集,幾乎不在永久區/元空間收集。
常用調優工具
- JDK命令行
- Eclipse: Memory Analyzer Tool
- Jconsole
- VisualVM
- Jprofiler
- Java Flight Recorder
- GCViewer
- GC Easy
5、Minor GC, Major GC, Full GC
JVM 在進行 GC 時,並非每次都對上面三個內存(新生代、老年代;方法區)區域一起回收的,大部分時候回收的都是指新生代。
針對HotSpot VM的實現,它裏面的GC按照回收區域又分爲兩大種類型:一種是部分收集(Partial GC) ,一種是整堆收集(Full GC)
- 部分收集:不是完整收集整個Java堆的垃圾收集。其中又分爲:
> 新生代收集(Minor GC / Young GC) :只是新生代的垃圾收集
> 老年代收集(Major GC/ Old GC) :只是老年代的垃圾收集。
√ 目前,只有CMS GC會有單獨收集老年代的行爲。
√ 注意,很多時候Major GC會和Full GC混淆使用,需要具體分辨是老年代
回收還是整堆回收。
> 混合收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集。
√ 目前,只有G1 GC 會有這種行爲
- 整堆收集(Full GC):收集整個java堆和方法區的垃圾收集。
最簡單的分代式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 (但非絕對的,在ParallelScavenge收集器的收集策略裏就有直接進行Major GC的策略選擇過程)
√ 也就是在老年代空間不足時,會先嚐試觸發Minor GC。如果之後空間還不足,
則觸發Major GC
- Major GC的速度一般會比Minor GC慢10倍以上, STW的時間更長。
- 如果Major GC後,內存還不足,就報OOM了。
Full GC觸發機制: (後面細講)
觸發Full GC執行的情況有如下五種:
1)調用System.gc()時,系統建議執行Full GC,但是不必然執行
2)老年代空間不足
3)方法區空間不足
4)通過Minor GC後進入老年代的平均大小大於老年代的可用內存
5)由Eden區、survivor space0 (From Space)區向survivor space1 (ToSpace)區複製時,對象大小大於To Space可用內存,則把該對象轉存到老年代,且老年代的可用內存小於該對象大小
說明: full gc是開發或調優中儘量要避免的。這樣暫時時間會短一些。
6、堆空間分代思想
爲什麼需要把Java堆分代?不分代就不能正常工作了嗎?
- 經研究,不同對象的生命週期不同。70%-99%的對象是臨時對象。
> 新生代:有Eden、兩塊大小相同的Survivor(又稱爲from/to, s0/s1)構成,to總爲空。
> 老年代:存放新生代中經歷多次GC仍然存活的對象。
- 其實不分代完全可以,分代的唯一理由就是優化GC性能。如果沒有分代,那所有的對象都在一塊,就如同把一個學校的人都關在一個教室。GC的時候要找到哪些對象沒用,這樣就會對堆的所有區域進行掃描。而很多對象都是朝生夕死的,如果分代的話,把新創建的對象放到某一地方,當GC的時候先把這塊存儲“朝生夕死”對象的區域進行回收,這樣就會騰出很大的空間出來。
7、內存分配策略
如果對象在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到survivor空間中, 並將對象年齡設爲1。對象在Survivor 區中每熬過一次MinorGC , 年齡就增加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空間中分配內存。
9、小結堆空間的參數設置
- 官網說明 : https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
- -XX:+PrintFlagsInitial : 查看所有的參數的默認初始值
- -XX:+PrintFlagsFinal : 查看所有的參數的最終值(可能會存在修改,不再是初始值)
- -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 : 是否設置空間分配擔保
在發生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" 查看逃逸分析的篩選結果。
結論:
開發中能使用局部變量的,就不要使用在方法外定義。
使用逃逸分析,編譯器可以對代碼做如下優化:
一、棧上分配。將堆分配轉化爲棧分配。如果一個對象在子程序中被分配,要使指向該對象的指針永遠不會逃逸,對象可能是棧分配的候選,而不是堆分配。
二、同步省略。如果一個對象被發現只能從一個線程被訪問到,那麼對於這個對象的操作可以不考慮同步。
三、分離對象或標量替換。有的對象可能不需要作爲一個連續的內存結構存在也可以被訪問到,那麼對象的部分(或全部)可以不存儲在內存,而是存儲在CPU寄存器中。
代碼優化之棧上分配
- JIT編譯器在編譯期間根據逃逸分析的結果,發現如果一個對象並沒有逃逸出方法的話,就可能被優化成棧上分配。分配完成後,繼續在調用棧內執行,最後線程結束,棧空間被回收,局部變量對象也被回收。這樣就無須進行垃圾回收了。
- 常見的棧上分配的場景
> 在逃逸分析中,已經說明了。分別是給成員變量賦值、方法返回值實例引用傳遞。
代碼優化之同步省略(消除)
- 線程同步的代價是相當高的,同步的後果是降低併發性和性能。
- 在動態編譯同步塊的時候, JIT編譯器可以藉助逃逸分析來判斷同步塊所使用的鎖對象是否只能夠被一個線程訪問而沒有被髮布到其他線程。如果沒有,那麼JIT編譯器在編譯這個同步塊的時候就會取消對這部分代碼的同步。這樣就能大大提高併發性和性能。這個取消同步的過程就叫同步省略,也叫鎖消除。
代碼優化之標量替換
標量(Scalar)是指一個無法再分解成更小的數據的數據。Java中的原始數據類型就是標量。相對的,那些還可以分解的數據叫做聚合量(Aggregate) , Java中的對象就是聚合量,因爲他可以分解成其他聚合量和標量。
在JIT階段,如果經過逃逸分析,發現一個對象不會被外界訪問的話,那麼經過JIT優化,就會把這個對象拆解成若干個其中包含的若干個成員變量來代替。這個過程就是標量替換。
以上代碼,經過標量替換後,就會變成:
可以看到, Point這個聚合量經過逃逸分析後,發現他並沒有逃逸,就被替換成兩個聚合量了。那麼標量替換有什麼好處呢?就是可以大大減少堆內存的佔用。因爲一旦不需要創建對象了,那麼就不再需要分配堆內存了。
標量替換爲棧上分配提供了很好的基礎。
標量替換參數設置:
參數 -XX:+EliminateAllocations : 開啓了標量替換(默認打開),允許將對象打散分配在棧上。
/** * 標量替換測試 * -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations * @author shkstart [email protected] * @create 2020 12:01 */ public class ScalarReplace { public static class User { public int id; public String name; } public static void alloc() { User u = new User();//未發生逃逸 u.id = 5; u.name = "www.atguigu.com"; } public static void main(String[] args) { long start = System.currentTimeMillis(); for (int i = 0; i < 10000000; i++) { alloc(); } long end = System.currentTimeMillis(); System.out.println("花費的時間爲: " + (end - start) + " ms"); } }
上述代碼在主函數中進行了1億次alloc。調用進行對象創建,由於User對象實例需要佔據約16字節的空間,因此累計分配空間達到將近1.5GB,如果堆空間小於這個值,就必然會發生GC。使用如下參數運行上述代碼:
這裏使用參數如下:
- 參數 -server : 啓動Server模式,因爲在Server模式下,纔可以啓用逃逸分析。
- 參數-XX:+DoEscapeAnalysis : 啓用逃逸分析
- 參數-Xmx100m : 指定了堆空間最大爲100MB
- 參數-XX:+PrintGC : 將打印GC日誌。
- 參數-XX:+EliminateAllocations : 開啓了標量替換(默認打開),允許將對象打散分配在棧上, 比如對象擁有id和name兩個字段,那麼這兩個字段將會被視爲兩個獨立的局部變量進行分配。
逃逸分析小結:逃逸分析並不成熟
- 關於逃逸分析的論文在1999年就已經發表了,但直到JDK 1.6纔有實現,而且這項技術到如今也並不是十分成熟的
- 其根本原因就是無法保證逃逸分析的性能消耗一定能高於他的消耗。雖然經過逃逸分析可以做標量替換、棧上分配、和鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程
- 一個極端的例子,就是經過逃逸分析之後,發現沒有一個對象是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。
- 雖然這項技術並不十分成熟,但是它也是即時編譯器優化技術中一個十分重要的手段。
- 注意到有一些觀點,認爲通過逃逸分析, JVM會在棧上分配那些不會逃逸的對象,這在理論上是可行的,但是取決於JvM設計者的選擇。據我所知, Oracle Hotspot JVM中並未這麼做,這一點在逃逸分析相關的文檔裏已經說明,所以可以明確所有的對象實例都是創建在堆上。
- 目前很多書籍還是基於JDK 7以前的版本, JDK已經發生了很大變化, intern字符串的緩存和靜態變量曾經都被分配在永久代上,而永久代已經被元數據區取代。但是,intern字符串緩存和靜態變量並不是被轉移到元數據區,而是直接在堆上分配,所以這一點同樣符合前面一點的結論:對象實例都是分配在堆上。
本章小結
- 年輕代是對象的誕生、成長、消亡的區域,一個對象在這裏產生、應用,最後被垃圾回收器收集、結束生命。
- 老年代放置長生命週期的對象,通常都是從Survivor區域篩選拷貝過來的Java對象。當然,也有特殊情況,我們知道普通的對象會被分配在TLAB上;如果對象較大, JVM會試圖直接分配在Eden其他位置上;如果對象太大,完全無法在新生代找到足夠長的連續空閒空間, JVM就會直接分配到老年代
- 當GC只發生在年輕代中,回收年輕代對象的行爲被稱爲MinorGC。當GC發生在老年代時則被稱爲MajorGC或者FulIGC。一般的, MinorGC的發生頻率要比MajorGC高很多,即老年代中垃圾回收發生的頻率將大大低於年輕代。