深入理解Java虛擬機:堆詳解
前言
本節(jié)主要講的是運行時數(shù)據(jù)區(qū)(堆),也就是下圖這部分,它是在類加載完成后的階段:
圖片
- 每個線程:獨立包括程序計數(shù)器、棧、本地棧
- 線程間共享:堆、堆外內存(永久代或元空間、代碼緩存)
當我們通過前面的:類的加載-> 驗證 -> 準備 -> 解析 -> 初始化 這幾個階段完成后,就會用到執(zhí)行引擎對我們的類進行使用,同時執(zhí)行引擎將會使用到我們運行時數(shù)據(jù)區(qū)。
內存是非常重要的系統(tǒng)資源,是硬盤和CPU的中間倉庫及橋梁,承載著操作系統(tǒng)和應用程序的實時運行JVM內存布局規(guī)定了Java在運行過程中內存申請、分配、管理的策略,保證了JVM的高效穩(wěn)定運行。不同的JVM對于內存的劃分方式和管理機制存在著部分差異。
正文
我們通過磁盤或者網(wǎng)絡IO得到的數(shù)據(jù),都需要先加載到內存中,然后CPU從內存中獲取數(shù)據(jù)進行讀取,也就是說內存充當了CPU和磁盤之間的橋梁。
圖片
線程
線程是一個程序里的運行單元。JVM允許一個應用有多個線程并行的執(zhí)行。在Hotspot JVM里,每個線程都與操作系統(tǒng)的本地線程直接映射。
當一個Java線程準備好執(zhí)行以后,此時一個操作系統(tǒng)的本地線程也同時創(chuàng)建。Java線程執(zhí)行終止后,本地線程也會回收。
操作系統(tǒng)負責所有線程的安排調度到任何一個可用的CPU上。一旦本地線程初始化成功,它就會調用Java線程中的run()方法。
JVM系統(tǒng)線程:
- 虛擬機線程:需要JVM達到安全點才會出現(xiàn)。這些操作必須在不同的線程中發(fā)生的,原因是他們都需要JVM達到安全點,這樣堆才不會變化。這種線程的執(zhí)行類型包括stop-the-world的垃圾收集,線程棧收集,線程掛起以及偏向鎖撤銷。
- 周期任務線程:這種線程是時間周期事件的體現(xiàn)(比如中斷),他們一般用于周期性操作的調度執(zhí)行。
- GC線程:這種線程對在JVM里不同種類的垃圾收集行為提供了支持。
- 編譯線程:這種線程在運行時會將字節(jié)碼編譯成到本地代碼。
- 信號調度線程:這種線程接收信號并發(fā)送給JVM,在它內部通過調用適當?shù)姆椒ㄟM行處理。
堆
堆針對一個JVM進程來說是唯一的,也就是一個進程只有一個JVM,但是進程包含多個線程,他們是共享同一堆空間的。
圖片
數(shù)組和對象可能永遠不會存儲在棧上,因為棧幀中保存引用,這個引用指向對象或者數(shù)組在堆中的位置,在方法結束后,堆中的對象不會馬上被移除,僅僅在垃圾收集的時候才會被移除。
堆內存細分
Java 7及之前堆內存邏輯上分為三部分:新生區(qū)+老年區(qū)+永久區(qū)
- Young Generation Space 新生區(qū),又被劃分為Eden區(qū)和Survivor區(qū)
- Tenure generation space 老年區(qū)
- Permanent Space 永久區(qū)
Java 8及之后堆內存邏輯上分為三部分:新生區(qū)+老年區(qū)+元空間
- Young Generation Space 新生區(qū),又被劃分為Eden區(qū)和Survivor區(qū)
- Tenure generation space 老年區(qū)
- Meta Space 元空間
Jdk1.6
圖片
Jdk1.7
圖片
Jdk1.8
圖片
設置堆內存大小
- -Xms用于表示堆區(qū)的起始內存,等價于-XX:InitialHeapSize,默認物理電腦內存大小/64
- -Xmx則用于表示堆區(qū)的最大內存,等價于-XX:MaxHeapSize,默認物理電腦內存大小/4
通常會將-Xms和-Xmx兩個參數(shù)配置相同的值,其目的是為了能夠在Java垃圾回收機制清理完堆區(qū)后不需要重新分隔計算堆區(qū)的大小,從而提高性能。
一旦堆區(qū)中的內存大小超過-Xmx所指定的最大內存時,將會拋出OutOfMemoryError異常
年輕代與老年代
存儲在JVM中的Java對象可以被劃分為兩類:
- 生命周期較短的瞬時對象,這類對象的創(chuàng)建和消亡都非常迅速。
- 生命周期非常長,在某些極端的情況下還能夠與JVM的生命周期保持一致。
圖片
- 默認-XX:NewRatio=2,表示新生代占1,老年代占2。
- Eden空間和另外兩個survivor空間缺省所占的比例是8:1:1。
圖片
- jinfo -flag NewRatio 進程號 可查看相關屬性值
- jinfo -flag SurvivorRatio 進程號 可查看相關屬性值
對象分配過程
為新對象分配內存是一件非常嚴謹和復雜的任務,JVM的設計者們不僅需要考慮內存如何分配、在哪里分配等問題,并且由于內存分配算法與內存回收算法密切相關,所以還需要考慮GC執(zhí)行完內存回收后是否會在內存空間中產(chǎn)生內存碎片
圖片
- new的對象先放伊甸園區(qū)。此區(qū)有大小限制。
- 當伊甸園的空間填滿時,程序又需要創(chuàng)建對象,JVM的垃圾回收器將對伊甸園區(qū)進行垃圾回收(MinorGC),將伊甸園區(qū)中的不再被其他對象所引用的對象進行銷毀,再加載新的對象放到伊甸園區(qū)。
- 然后將伊甸園中的剩余對象移動到幸存者s0區(qū)。
- 如果再次觸發(fā)垃圾回收,此時上次幸存下來的放到幸存者s0區(qū)的,如果沒有回收,就會放到幸存者s1區(qū)。
- 如果再次經(jīng)歷垃圾回收,此時會重新放回幸存者s0區(qū),接著再去幸存者s1區(qū)。
- 啥時候能去養(yǎng)老區(qū)呢?可以設置次數(shù)。默認是15次 ,進行設置-Xx:MaxTenuringThreshold= N。
- 在養(yǎng)老區(qū),相對悠閑。當養(yǎng)老區(qū)內存不足時,再次觸發(fā)GC:Major GC,進行養(yǎng)老區(qū)的內存清理。
- 若養(yǎng)老區(qū)執(zhí)行了Major GC之后,發(fā)現(xiàn)依然無法進行對象的保存,就會產(chǎn)生OOM異常。
- 針對幸存者s0,s1區(qū)的總結:復制之后又交換,誰空誰是to。
- 垃圾回收:頻繁在新生區(qū)收集,很少在老年代收集,幾乎不在永久代和元空間進行收集。
Minor GC,MajorGC、Full GC
JVM在進行GC時,并非每次都對上面三個內存區(qū)域一起回收的,大部分時候回收的都是指新生代。
針對Hotspot VM的實現(xiàn),它里面的GC按照回收區(qū)域又分為兩大種類型:一種是部分收集(Partial GC),一種是整堆收集(FullGC)
- 部分收集:不是完整收集整個Java堆的垃圾收集。其中又分為:
新生代收集(Minor GC / Young GC):只是新生代的垃圾收集。
老年代收集(Major GC / Old GC):只是老年代的圾收集。
混合收集(MixedGC):收集整個新生代以及部分老年代的垃圾收集。
- 整堆收集(Full GC):收集整個Java堆和方法區(qū)的垃圾收集。
- 目前,只有CMS GC會有單獨收集老年代的行為,很多時候Major GC會和Full GC混淆使用,需要具體分辨是老年代回收還是整堆回收。
- 目前,只有G1 GC會有混合收集。
年輕代GC(Minor GC)觸發(fā)機制
- 當年輕代空間不足時,就會觸發(fā)MinorGC,這里的年輕代滿指的是Eden代滿,Survivor滿不會引發(fā)GC。(每次Minor GC會清理年輕代的內存。)
- Minor GC會引發(fā)STW,暫停其它用戶的線程,等垃圾回收結束,用戶線程才恢復運行 。
老年代GC(Major GC / Full GC)觸發(fā)機制
- 對象從老年代消失時,我們說Major GC或 Full GC發(fā)生了。
- 出現(xiàn)了Major Gc,經(jīng)常會伴隨至少一次的Minor GC。
- 如果Major GC后,內存還不足,就報OOM。
內存分配策略
如果對象在Eden出生并經(jīng)過第一次Minor GC后仍然存活,并且能被Survivor容納的話,將被移動到survivor空間中,并將對象年齡設為1。對象在survivor區(qū)中每熬過一次MinorGC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15歲,其實每個JVM、每個GC都有所不同)時,就會被晉升到老年代。
對不同年齡段的對象分配原則如下所示:
- 優(yōu)先分配到Eden
- 大對象直接分配到老年代(盡量避免程序中出現(xiàn)過多的大對象)
- 長期存活的對象分配到老年代
- 動態(tài)對象年齡判斷:如果survivor區(qū)中相同年齡的所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。
- 空間分配擔保:-XX:HandlePromotionFailure
圖片
TLAB
為什么有TLAB
- 堆區(qū)是線程共享區(qū)域,任何線程都可以訪問到堆區(qū)中的共享數(shù)據(jù) 。
- 由于對象實例的創(chuàng)建在JVM中非常頻繁,因此在并發(fā)環(huán)境下從堆區(qū)中劃分內存空間是線程不安全的 。
- 為避免多個線程操作同一地址,需要使用加鎖等機制,進而影響分配速度。
什么是TLAB
圖片
- 從內存模型而不是垃圾收集的角度,對Eden區(qū)域繼續(xù)進行劃分,JVM為每個線程分配了一個私有緩存區(qū)域,它包含在Eden空間內。
- 多線程同時分配內存時,使用TLAB可以避免一系列的非線程安全問題,同時還能夠提升內存分配的吞吐量,因此我們可以將這種內存分配方式稱之為快速分配策略 。
盡管不是所有的對象實例都能夠在TLAB中成功分配內存,但JVM確實是將TLAB作為內存分配的首選。
圖片
堆空間的參數(shù)設置
-XX:+PrintFlagsInitial //查看所有的參數(shù)的默認初始值
-XX:+PrintFlagsFinal //查看所有的參數(shù)的最終值(可能會存在修改,不再是初始值)
-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:HandlePromotionFalilure://是否設置空間分配擔保
堆是分配對象的唯一選擇么?
隨著JIT編譯期的發(fā)展與逃逸分析技術逐漸成熟,棧上分配、標量替換優(yōu)化技術將會導致一些微妙的變化,所有的對象都分配到堆上也漸漸變得不那么絕對了。
在Java虛擬機中,對象是在Java堆中分配內存的,這是一個普遍的常識。但是,有一種特殊情況,那就是如果經(jīng)過逃逸分析(Escape Analysis)后發(fā)現(xiàn),一個對象并沒有逃逸出方法的話,那么就可能被優(yōu)化成棧上分配。這樣就無需在堆上分配內存,也無須進行垃圾回收了。這也是最常見的堆外存儲技術。
逃逸分析的基本行為就是分析對象動態(tài)作用域:
- 當一個對象在方法中被定義后,對象只在方法內部使用,則認為沒有發(fā)生逃逸。
- 當一個對象在方法中被定義后,它被外部方法所引用,則認為發(fā)生逃逸。例如作為調用參數(shù)傳遞到其他地方中。
public class EscapeAnalysis {
public EscapeAnalysis obj;
/**
* 方法返回EscapeAnalysis對象,發(fā)生逃逸
* @return
*/
public EscapeAnalysis getInstance() {
return obj == null ? new EscapeAnalysis() : obj;
}
/**
* 為成員屬性賦值,發(fā)生逃逸
*/
public void setObj() {
this.obj = new EscapeAnalysis();
}
/**
* 對象的作用于僅在當前方法中有效,沒有發(fā)生逃逸
*/
public void useEscapeAnalysis() {
EscapeAnalysis e = new EscapeAnalysis();
}
/**
* 引用成員變量的值,發(fā)生逃逸
*/
public void useEscapeAnalysis2() {
EscapeAnalysis e = getInstance();
}
}
使用逃逸分析,編譯器可以對代碼做如下優(yōu)化:
- 一、棧上分配:將堆分配轉化為棧分配。如果一個對象在子程序中被分配,要使指向該對象的指針永遠不會發(fā)生逃逸,對象可能是棧上分配的候選,而不是堆上分配。
- 二、同步省略:如果一個對象被發(fā)現(xiàn)只有一個線程被訪問到,那么對于這個對象的操作可以不考慮同步。
- 三、分離對象或標量替換:有的對象可能不需要作為一個連續(xù)的內存結構存在也可以被訪問到,那么對象的部分(或全部)可以不存儲在內存,而是存儲在CPU寄存器中。