jvm內存分配

1.對象分配

堆上分配

  • 優先在Eden區分配

在JVM新生代堆內存一般由一塊Eden區和兩塊Survivor區組成。在大多數情況下, 對象在新生代Eden區中分配, 當Eden區沒有足夠空間分配時, JVM會發起一次Minor GC, 將Eden區和其中一塊Survivor區內存活的對象放入另一塊Survivor區, 如果在Minor GC期間發現新生代存活對象無法放入空閒的Survivor區, 則會將對象提前放入老年代。

  • 大對象直接進入老年代

    1、需要分配的大小超過eden space大小;
    2、在配置了PretenureSizeThreshold的情況下,對象大小大於此值。
    所謂“大對象”一般指需要大量連續內存的Java對象, 如很長的字符串或者數組。

新生代的Serial和ParNew兩款收集器提供了-XX:PretenureSizeThreshold的參數, 可以讓大於該值的對象直接在老年代中分配。
這樣做的目的是避免在Eden區和Survivor區之間進行大量的內存複製, 從而獲得足夠的連續空間。

  • 長期存活的對象進入老年代

VM給每個對象定義了一個對象年齡計數器。如果對象在Eden出生並經過第一次GC後仍然存活,並能夠被survivor容納的話,將被移動到survivor區域,並且對象年齡設爲1。對象在survivor每熬過一次gc,年齡就+1,當它的年齡達到一定值後(默認爲15),就會被移動到老年代。可以通過-XX:MaxTenuringThreshhold設置。
VM並不會永遠要求對象的年齡達到了MaxTenuringThreshhold的值才能晉升老年代。
如果survivor區中相同年齡所有對象的大小總和大於survivor空間的一半,年齡大於或者等於該年齡的對象就可以直接進入老年代。

private static final int _1MB = 1024 * 1024;

/**
 * VM參數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
 * -XX:+PrintTenuringDistribution
 */
@SuppressWarnings("unused")
public static void testTenuringThreshold2() {
    byte[] allocation1, allocation2, allocation3, allocation4;
    allocation1 = new byte[_1MB / 4];   // allocation1+allocation2大於survivo空間一半
    allocation2 = new byte[_1MB / 4]; 
    allocation3 = new byte[4 * _1MB];
    allocation4 = new byte[4 * _1MB];
    allocation4 = null;
    allocation4 = new byte[4 * _1MB];
}
  • 空間分配擔保

在minor gc前,vm會先檢查老年代最大可用連續空間是否大於新生代所有對象總空間,
如果條件成立,則順利執行minor gc。
如果不滿足,則查看HandlePromotionFailure設置值是否允許擔保失敗,如果允許,則檢查老年代最大可用連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,則嘗試進行一次minor gc,這次gc是有風險的。如果小於或者不允許冒險,則會進行一次full gc。

private static final int _1MB = 1024 * 1024;

/**
 * VM參數:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-HandlePromotionFailure
 */
@SuppressWarnings("unused")
public static void testHandlePromotion() {
    byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7;
    allocation1 = new byte[2 * _1MB];
    allocation2 = new byte[2 * _1MB];
    allocation3 = new byte[2 * _1MB];
    allocation1 = null;
    allocation4 = new byte[2 * _1MB];
    allocation5 = new byte[2 * _1MB];
    allocation6 = new byte[2 * _1MB];
    allocation4 = null;
    allocation5 = null;
    allocation6 = null;
    allocation7 = new byte[2 * _1MB];
}
  • 棧上分配

待研究

  • 堆外分配

直接內存並不是JVM運行時數據區的一部分, 但也會被頻繁的使用: 在JDK 1.4引入的NIO提供了基於Channel與Buffer的IO方式, 它可以使用Native函數庫直接分配堆外內存, 然後使用DirectByteBuffer對象作爲這塊內存的引用進行操作, 這樣就避免了在Java堆和Native堆中來回複製數據, 因此在一些場景中可以顯著提高性能.
顯然, 本機直接內存的分配不會受到Java堆大小的限制(即不會遵守-Xms、-Xmx等設置), 但既然是內存, 則肯定還是會受到本機總內存大小及處理器尋址空間的限制, 因此動態擴展時也會出現OutOfMemoryError異常.

  • TLAB

堆內的對象數據是各個線程所共享的,所以當在堆內創建新的對象時,就需要進行鎖操作。鎖操作是比較耗時,因此JVM爲每個線程在堆上分配了一塊“自留地”——TLAB(全稱是Thread Local Allocation Buffer),位於堆內存的新生代,也就是Eden區。每個線程在創建新的對象時,會首先嚐試在自己的TLAB裏進行分配,如果成功就返回,失敗了再到共享的Eden區裏去申請空間。
在Java程序中很多對象都是小對象且用過即丟,它們不存在線程共享也適合被快速GC,所以對於小對象通常JVM會優先分配在TLAB上,並且TLAB上的分配由於是線程私有所以沒有鎖開銷。因此在實踐中分配多個小對象的效率通常比分配一個大對象的效率要高。
在線程自己的TLAB區域創建對象失敗一般有兩個原因:一是對象太大,二是自己的TLAB區剩餘空間不夠。通常默認的TLAB區域大小是Eden區域的1%,當然也可以手工進行調整,對應的JVM參數是-XX:TLABWasteTargetPercent。

2.對象晉升

  • 年齡閾值

JVM爲每個對象定義了一個對象年齡(Age)計數器, 對象在Eden出生如果經第一次Minor GC後仍然存活,且能被Survivor容納的話, 將被移動到Survivor空間中, 並將年齡設爲1。 以後對象在Survivor區中每熬過一次Minor GC年齡就+1. 當增加到一定程度(-XX:MaxTenuringThreshold, 默認15), 將會晉升到老年代。

  • 提前晉升: 動態年齡判定

然而JVM並不總是要求對象的年齡必須達到MaxTenuringThreshold才能晉升老年代: 如果在Survivor空間中相同年齡所有對象大小的總和大於Survivor空間的一半, 年齡大於或等於該年齡的對象就可以直接進入老年代, 而無須等到晉升年齡。

3.對象存儲結構

HotSpot VM內, 對象在內存中的存儲佈局可以分爲三塊區域:對象頭、實例數據和對齊填充:

3.1.對象頭

對象頭包含兩部分:類型指針和運行時數據

  • 類型指針

對象指向它的類元數據的指針: VM通過該指針確定該對象屬於哪個類實例。另外, 如果對象是一個數組, 那在對象頭中還必須有一塊數據用於記錄數組長度。
注意: 並非所有VM實現都必須在對象數據上保留類型指針, 也就是說查找對象的元數據並非一定要經過對象本身。

  • 運行時數據

運行時數據包括:HashCode、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等, 這部分數據的長度在32位和64位的VM(暫不考慮開啓壓縮指針)中分別爲32bit和64bit, 官方稱之爲“Mark Word”;
爲了用最少的空間存儲更多的信息,Mark Word被設計成非固定的數據結構。它會根據對象的狀態複用自己的存儲空間。
比如對象處在非鎖定狀態,那麼存儲內容= 對象哈希碼(25位)+ 對象的分代年齡(4位)+ 鎖標誌位(2位)+0(1位)。
其他狀態下(輕量級鎖定、重量級鎖定、GC標記、可偏向),對象的存儲格式如下:


存儲內容 | 狀態 | 標誌位
對象哈希碼、對象分代年齡 | 未鎖定 | 01
指向鎖記錄的指針 | 輕量級鎖定 | 00
執行重量級鎖定的指針 | 膨脹(重量級鎖定) | 10
空(不需要記錄信息) | GC標記 | 11
偏向線程ID、偏向時間戳、對象分代年齡 | 可偏向 | 01


3.2.實例數據

這部分是對象真正存儲的有效信息, 也就是我們在代碼裏所定義的各種類型的字段內容(無論是從父類繼承下來的, 還是在子類中定義的都需要記錄下來)。這部分的存儲順序會受到虛擬機分配策略參數和字段在Java源碼中定義順序的影響。
HotSpot默認的分配策略爲longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers), 相同寬度的字段總是被分配到一起, 在滿足這個前提條件下, 在父類中定義的變量會出現在子類之前。如果CompactFields參數值爲true(默認), 那子類中較窄的變量也可能會插入到父類變量的空隙中。

3.3.對齊填充

這部分並不是必然存在的, 僅起到佔位符的作用, 原因是HotSpot自動內存管理系統要求對象起始地址必須是8字節的整數倍, 即對象的大小必須是8字節的整數倍。

4.對象創建的過程

new一個Java Object(包括數組和Class對象), 在JVM中會進行如下操作:

  • VM遇到new指令: 首先去檢查該指令的參數是否能在常量池中定位到一個類的符號引用, 並檢查這個符號引用代表的類是否已被加載、解析和初始化過. 如果沒有, 必須先執行相應的類加載過程。
  • 類加載檢查通過後: VM將爲新生對象分配內存(對象所需內存的大小在類加載完成後便可完全確定)。

VM採用 指針碰撞 和 空閒列表 方式來劃分內存。


|方式 |適用場景 |描述 |收集器|
指針碰撞 |內存規整 指針向空閒空間那邊挪動一段與對象大小相等的距離 |Serial、ParNew等有內存壓縮整理功能的收集器|
|空閒列表 |內存不規整 |VM維護一個列表,記錄哪些內存塊可用。在分配的時候,選擇一塊足夠大的空間劃分給對象實例。 |CMS這種基於Mark-Sweep算法的收集器,|

  • 除了考慮如何劃分可用空間外, 由於在VM上創建對象的行爲非常頻繁, 因此需要考慮內存分配的併發問題. 解決方案有兩個:
    對分配內存空間的動作進行同步 -採用 CAS配上失敗重試 方式保證更新操作的原子性;
    把內存分配的動作按照線程劃分在不同的空間之中進行 -每個線程在Java堆中預先分配一小塊內存, 稱爲本地線程分配緩衝TLAB, 各線程首先在TLAB上分配, 只有TLAB用完, 分配新的TLAB時才需要同步鎖定(使用-XX:+/-UseTLAB參數設定)。

  • 接下來將分配到的內存空間初始化爲零值(不包括對象頭, 且如果使用TLAB這一個工作也可以提前至TLAB分配時進行). 這一步保證了對象的實例字段可以不賦初始值就直接使用(訪問到這些字段的數據類型所對應的零值)。

  • 然後要對對象進行必要的設置: 如該對象所屬的類實例、如何能訪問到類的元數據信息、對象的哈希碼、對象的GC分代年齡等, 這部分息放在對象頭中。根據虛擬機運行狀態的不同,比如是否啓用偏向鎖等,對象頭會有不同的設置方式。

  • 上面工作都完成之後, 在虛擬機角度一個新對象已經產生, 但在Java視角對象的創建纔剛剛開始(方法尚未執行, 所有字段還都爲零)。 所以new指令之後一般會(由字節碼中是否跟隨有invokespecial指令所決定–Interface一般不會有, 而Class一般會有)接着執行方法, 把對象按照程序員的意願進行初始化, 這樣一個真正可用的對象纔算完全產生出來。

5.對象的訪問定位

建立對象是爲了使用對象, Java程序需要通過棧上的reference來操作堆上的具體對象. 主流的有 句柄 和 直接指針 兩種方式去定位和訪問堆上的對象:

句柄

Java堆中將會劃分出一塊內存來作爲句柄池, reference中存儲對象的句柄地址, 而句柄中包含了對象實例數據與類型數據的具體各自的地址信息:
這裏寫圖片描述

直接指針(HotSpot使用)

該方式Java堆對象的佈局中就必須考慮如何放置訪問類型數據的相關信息, reference中存儲的直接就是對象地址:
這裏寫圖片描述
這兩種對象訪問方式各有優勢: 使用句柄來訪問的最大好處是reference中存儲的是穩定句柄地址, 在對象被移動(垃圾收集時移動對象是非常普遍的行爲)時只會改變句柄中的實例數據指針,而reference本身不變。而使用直接指針最大的好處就是速度更快, 它節省了一次指針定位的時間開銷,由於對象訪問非常頻繁, 因此這類開銷積小成多也是一項非常可觀的執行成本。

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