《深入理解Java虛擬機:JVM高級特性與最佳實踐(最新第二版)》第二章(二:虛hostSpot虛擬機對象的創建過程和內存分配)

《深入理解Java虛擬機:JVM高級特性與最佳實踐(最新第二版)》第二章(一:虛hostSpot虛擬機對象的創建過程和內存分配)

1、對象的創建

​ java是一門面向對象的編程語言,在java=程序運行過程中無時無刻都有對象唄創建出來。在語言層面上,創建對象(例如:克隆,反序列化)通暢僅僅需要一個new關鍵字,而在虛擬機中,對象(討論的僅限於java普通對象,不包括數組和class對象等)的創建又是怎麼樣的一個過程呢?

​ 虛擬機遇到一條new指令時,首先將去檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已經被加載、解析和初始化過。如果沒有,那必須先執行相應的類加載過程。

​ 在類加載檢車通過後,接下來虛擬機將爲新生對象分配內存。對象所需內存的大小在類加載完成後,便可以完全確定,爲對象分配空間的任務等同於把一塊確定大小的內存從java堆中劃分出來,假設java中的內存是絕對規整的,所有用過的內存都放在一邊,空閒的內存放在另一邊,中間放着一個指針作爲分界點的指示器,那所有分配內存就僅僅是把那個指針向空閒空間那邊挪動一段與對象大小相等的距離,這種分配方式稱爲“指針碰撞(Bump the Pointer)”。如果java堆中的內存並不是規整的,已經使用的內存和空閒的內存相互交錯,那就沒有辦法進行簡單的指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內存塊是可以用的,在分配的時候,從列表中找到一塊足夠大的空間劃分對象實例,並更新列表上的記錄,這種分配方式稱爲“空閒列表(Free List)”。選擇哪種分配方式由java堆是否規整決定,而java堆是否有規整又是由所採用的垃圾收集器是否帶有壓縮整理功能決定的。因此,在使用Serial、ParNew、等待Compact過程的收集器時,系統通常採用空閒列表。

​ 除如何劃分可用空間之外,還有一個需要考慮的問題是對象創建在虛擬機中是非常頻繁的行爲,即使是僅僅修改一個真真所指向的位置,在併發情況下也並不是線程安全的,可能出現正在給對象A分配內存,指針還沒有來得及修改,對象B又同時使用了原來的指針來分配內存的情況。解決這個問題有兩種方案,一種是對分配內存空間的動作進行同步處理—實際上虛擬機採用CAS配上失敗重試的方式保證更新操作的原子性;另一種是把內存分配的動作按照線程劃分在不同的空間中進行,即每個線程在java堆中預先分配一小塊內存,稱爲本地線程分配緩衝區(Thread Loacl Allocation Buffer,TLAB)。哪個線程需要分配內存,就在哪個線程的TLAB上分配,只有TLAB用完並分配新的TLAB時,才需要同步鎖定,虛擬機是否使用TLAb,可以通過–XX:+/UseTLAB參數來設定

​ 內存分配完成後,虛擬機將要分配到的內存空間都初始化爲零值(不包括對象頭),如果使用TLAb,這一工作過程也可以提前至TLAB分配時進行。這一步操作,保證了對象的實例字段在java代碼中可以不賦是初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。

​ 接下來,虛擬機要對對象進行必要的設置,例如這個對象是哪個類的實例,如何才能找到類的元數據信息,對象的哈希碼、對象的GC分代年齡等信息。這些信息存放在對象的對象頭(Object Header)之中。根據虛擬機當前的運行狀態的不同,例如:是否啓用偏向鎖等,對象頭會有不用的設置方式。

’ 在上面工作都完成之後,從虛擬機的視角來看,一個新的對象已經產生了,但從java程序的視角來看,對象創建纔剛剛開始,init方法還沒有執行,所有的字段都還是零。所以,一般來說執行new指令後,會接着執行init方法,把對象按照程序員的意願進行初始化,這樣一個真正可用的對象纔算完全產生出來。

2、對象的內存佈局

在HostSpot虛擬機中,對象在內存中存儲的佈局分爲3塊區域:對象頭(Object Header)、實例數據(Instance Data)和對齊填充(Padding)

​ HostSpot虛擬機的對象頭包括兩部分信息:

第一部分用於存儲對象自身的運行時數據,如哈希碼、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等,這部分數據的長度在32位和64位的虛擬機(未開啓壓縮指針)中分別爲32bit和64bit,官方稱它爲“Mark Word”。對象需要存儲的運行時數據很多,其實已經超出了32位、64位bitmap結構所能記錄的限度,但是對象頭信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存存儲儘量多的信息,它會根據對象的狀態複用自己的存儲空間。例如:在32位的HotSpot虛擬機中,如果對象處於未被鎖定的狀態下,那麼Mark Word的32bit空間中的25bit用於存儲對象的哈希碼,4bit用於存儲對象的分代年齡,2bit用於存儲鎖標誌位,1bit固定爲0,而在其他狀態(輕量級鎖定,重量級鎖定,GC標記,可偏向)下對象的存儲內容如下表

​ **對象頭的另外一個部分是類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。**並不是所有的虛擬機實現都必須在對象數據上保留類型指針,換句話說,查找對象的元數據信息並不一定要經過對象本身,如果對象是一個java數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,運維虛擬機可以通過普通java對象的元數據信息確定java對象的大小,但是從數組的元數據中卻無法確定數組的大小。

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

第三部分對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起着佔位符的作用。由於HotSpot VM的自動內存管理系統要求對象起始地址必須是8字節的整倍數,換句話說,就是對象的大小必須是8字節的整倍數。而對象頭部分正好是8字節的倍數(1倍或者2倍),因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充來補全。

3、對象的訪問定位

建立對象是爲使用對象,我們的java程序需要通過棧上的reference數據來操作堆上的具體對象。由於reference類型在java虛擬機規範中只規定了一個指向對象的引用,並沒有定義這個引用應該通過何種方式去定位、訪問堆中的對象的具體位置,所有對象訪問方式也是取決於虛擬機實現而定的。 目前主流的訪問方式有使用句柄和直接指針兩種。

​ 如果使用句柄訪問的話,那麼java堆中將會劃分出一塊內存來作爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息,如圖所示

​ 如果使用直接指針訪問,那麼java堆對象的佈局中就必須考慮如何方式訪問類型數據的相關信息,而reference中存儲的直接就是對象地址,如圖所示

​ 這兩種對象訪問方式各有優勢,使用句柄來訪問的最大好處就是reference中儲存的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行爲)時只會改變句柄中的實例數據指針,而reference本身不需要修改。

​ 使用直接指針訪問方式的最大好處就是速度更快,它節省了一次指針定位的時間開銷,由於對象的訪問在java中非常的頻繁,因此這類開銷積小成多後也是一項非常可觀的執行成本。就本書討論的主題主要虛擬機Sun HostSpot而言,它就是使用第二種方式進行對象訪問的,但從整個軟件開發的範圍來看,各種語言和框架使用句柄來訪問的情況也十分常見。

4.實戰OutOfMemoryError 異常

在java虛擬機規範中,除了程序計數器外,虛擬機內存的其他幾個運行時區都有發生OutOfMemoryError異常的可能

4.1java堆溢出

java堆用於存儲對象實例,主要不斷的創建對象,並且保證GC Roots 到對象之間有可達路徑來避免垃圾回收機制清除這些對象,那麼在對象數量到達最大堆的容量限制後,就會產生內存溢出異常。

​ 將堆的最小值-Xms參數與最大值-Xmx參數設置成一樣,避免堆自動擴展,通過-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機出現內存溢出異常時候,dump出當前內存的快照以便事後分析

java堆內存的OOM異常是實際應用中常見的內存溢出情況。當出現java堆內存溢出時,異常堆棧信息

java.lang.OutOfMemoryError: Java heap space;

/**
 * 堆內存溢出
 * @author liqi
 * VM Args: 
 * -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/Users/liqi/Downloads
 */
public class HeapOOM {

    static class OOMObject{}
    
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true) {
           list.add(new OOMObject());
        }
    }
}

要解決這個區域的異常,一般的手段先通過內存分析工具(Eclipse Memory Analyzer)對Dump出來的堆轉儲快照進行分析,主要區分是內存泄漏(Memory Leak)還是內存溢出(Memory Overflow);

內存泄漏:進一步通過工具查詢泄漏對象到GC Roots的引用鏈。可以分析出什麼原因導致垃圾收集器無法回收它們,就可以準確的找到泄漏代碼的位置。

內存溢出:就是內存中對象還活着,那就檢查虛擬機的堆參數與物理內存的情況,是否還可以調大,檢查代碼的某些對象生命週期是否過長持有狀態時間過長等,減少程序運行期的內存消耗。

4.2虛擬機棧和本地方法棧溢出

在hostSpot虛擬機中,並不區分虛擬機棧和本地方法棧,因此,對於HostSpot來說,雖然-Xoss參數(設置本地方法棧大小)存在,但實際上是無效的,棧容量是由-Xss參數設定。關於虛擬機棧和本地方法棧,在java虛擬機規範中描述兩種異常:

  1. 如果線程請求的棧深度大於虛擬機所允許的最大深度,將拋出StackOverflowError異常。
  2. 如果虛擬機在擴展時無法申請到足夠的內存空間,則拋出OutofMemoryError異常。

定義互相重合:當棧空間無法繼續分配時,到底是內存太小,還是已經使用的棧空間太大,本質上是對同一件事的兩種描述。

測試代碼就不寫了,基本上測試個遞歸就可以出來

使用-Xss參數減少棧內存容量,結果:StackOverflowError異常,異常輸出的堆棧深度相應縮小

定義了大量本地變量,增大此方法幀中本地變量表的長度,結果StackOverflowError異常,異常時輸入的堆棧深度相應縮小。

4.3方法區和運行時常量池溢出

-XX:PermSize和-XX:MaxPermSize限制方法區大小

使用String.intern()產生大量運行時常量,就會常量池溢出

使用反射或者動態代理,jsp頁面等的操作,在運行時,大量產生類的信息存入方法區,就會導致方法區溢出

4.4本機內存直接溢出

-XX:MaxDirectMemorySize 指定,如果不指定,則默認與java堆最大值(-Xmx指定)一樣;

本文內容來源於《深入理解Java虛擬機:JVM高級特性與最佳實踐(最新第二版)》的讀書筆記,供大家參考學習

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