運行時數據區

本文作者:李敏,叩丁狼高級講師。原創文章,轉載請註明出處。

前言

爲什麼要了解虛擬機如何操作內存?

java與c/c++之間有一堵由內存動態分配和垃圾收集技術所圍成的"高牆",牆外面的人想進去,牆裏面的人卻想出來.

對於java程序員來說,在虛擬機自動內存管理機制的幫助下,不再需要爲每一個new操作去寫配對的delete/free代碼,不容易出現內存泄漏和內存溢出.有虛擬機管理內存,這一切看起來都很美好.但是,也正因爲java程序員把內存控制的權力給了java虛擬機,一旦出現內存泄漏和溢出方面的問題,如果不瞭解虛擬機是怎麼樣使用內存的,那麼排查錯誤將會成爲一項異常艱難的工作.

3.1 虛擬機內存模型

java虛擬機在執行java程序的過程中,會把所有它管理的內存劃分爲若干個不同的數據區域.這些區域都有各自的用途,.根據的規定,java虛擬機所管理的內存將會包括以下幾個運行時數據區域:

1. 程序計數器

2. java虛擬機棧

3. 本地方法棧

4. java堆

5. 方法區

6. 運行時常量池

7. 直接內存

image

3.1.1 程序計數器

Program Counter Register

內存空間小,線程私有.它可以看做是當前線程所執行的字節碼的行號指示器.也就是說,線程主要是執行任務,而執行到哪裏,需要使用程序計數器來記錄.字節碼解釋器工作是就是通過改變這個計數器的值來選取下一條需要執行指令的字節碼指令,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴計數器完成.

由於java虛擬機的多線程是通過線程輪流切換並分配處理器執行時間的方式來實現的,所以,爲了線程切換後能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲,所以我們說,它是線程私有的.

如果線程正在執行一個 Java 方法,這個計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是 Native 方法,這個計數器的值則爲 (Undefined)。此內存區域是唯一一個在 Java 虛擬機規範中沒有規定任何 OutOfMemoryError 情況的區域。

3.1.2 虛擬機棧

java virtual Machine Stacks

線程私有,生命週期和線程一致。描述的是 Java 方法執行的內存模型:每個方法在執行時都會創建一個棧幀(Stack Frame)用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。每一個方法從調用直至執行結束,就對應着一個棧幀從虛擬機棧中入棧到出棧的過程。

在編譯程序代碼的時候,棧幀中需要多大的局部變量表,多深的操作數棧都已經完全確定了,因此一個棧幀需要分配多少內存,不會受程序運行時期變量數據的影響.

一個線程中的方法調用鏈可能會很長,很多方法都處於同時執行狀態.對於執行引擎來說,在活動線程中,只有位於棧頂的棧幀纔是有效的,執行引擎運行的所有的字節碼指令都只針對當前棧幀來進行操作的.

局部變量表

局部變量表是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量.

操作數棧

Operand Stack

是一個後進先出棧.其最大深度在編譯的時候已經確定了.當一個方法剛剛開始執行的時候,這個方法的操作數佔是空的,在方法的執行過程中,會有各種字節碼指令往操作數佔中寫入和提取內容,這就是出棧/入棧動作.

另外,在概念模型中,兩個棧幀作爲虛擬機棧的元素,是完全相互獨立的,但大多數虛擬機的實現都會做一些優化處理,讓兩個棧幀出現部分重疊,讓下面的棧幀的部分操作數棧與上面棧幀的部分局部變量表重疊在一起,這樣在進行方法調用的時候就可以共用一部分數據.無須進行額外的參數的複製傳遞.

image

動態連接

每一個棧幀都包含一個執行運行時常量池中該棧幀所屬方法的引用.持有這個引用是爲了支持方法調用過程中的動態連接.

這個引用是一個符號引用,不是方法實際運行的入口地址,需要動態的找到具體的方法入口.

這個特性給java帶來了更強大的動態擴展能力,但也使得java方法調用過程變得相對複雜起來,需要在類加載期間,甚至到運行期間才能夠確定目標方法的直接引用.

方法返回地址

正常完成出口:方法正確執行,執行引擎遇到方法返回的指令,回到上層的方法調用者.

異常完成出口:方法執行過程中發生異常,並且沒有處理異常,這樣是不會給上層調用者產生任何返回值.

方法正常退出,將會返回程序結束其的值給上層方法,經過調整之後以指向方法調用指令後面的一條指令,繼續執行上層方法.

3.1.3 本地方法棧

Native Method Stack

區別於Java 虛擬機棧的是,Java 虛擬機棧爲虛擬機執行 Java 方法(也就是字節碼)服務,而本地方法棧則爲虛擬機使用到的Native 方法服務。也會有 StackOverflowError 和 OutOfMemoryError 異常。

3.1.4 堆

heap

對於絕大多數應用來說,這塊區域是 JVM 所管理的內存中最大的一塊。線程共享,主要是存放對象實例和數組。內部可以設置劃分出多個線程私有的分配緩衝區(Thread Local Allocation Buffer, TLAB)。可以位於物理上不連續的空間,但是邏輯上要連續。

從內存回收的角度來看,由於現在收集器基本上都採用分代收集算法,所以對空間還可以細分爲:新生代(年輕代),老年代(年老代).再細緻一點,可以分爲Eden空間,From Survivor空間, To Survivor空間.

不論如何劃分,都與存放內容無關,都是存放的是對象實例,進一步劃分的目的是爲了更好的回收內存,或者更快的分配內存.

3.1.5 方法區

Method Area

屬於共享內存區域,存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。

對於習慣在HotSpot虛擬機上開發和部署程序的開發者來說,很多人願意把方法區稱爲“永久代”(Permanent Generation)(java8之前,使用永久代來實現方法區,在java8之後,廢除永久代,將字符串常量池移動到堆中,並新增Meta space,直接在系統內存中.),本質上兩者並不等價,僅僅是因爲HotSpot虛擬機的設計團隊選擇把GC分代收集擴展至方法區,或者說使用永久代來實現方法區而已。

Java虛擬機規範對這個區域的限制非常寬鬆,除了和Java堆一樣不需要連續的內存和可以選擇固定大小或者可擴展外,還可以選擇不實現垃圾收集。相對而言,垃圾收集行爲在這個區域是比較少出現的,但並非數據進入了方法區就如永久代的名字一樣“永久”存在了。這個區域的內存回收目標主要是針對常量池的回收和對類型的卸載,一般來說這個區域的回收“成績”比較難以令人滿意,尤其是類型的卸載,條件相當苛刻,但是這部分區域的回收確實是有必要的。

3.1.6 運行時常量池

Runtime Constant Pool

屬於方法區一部分,用於存放編譯期生成的各種字面量和符號引用。內存有限,無法申請時拋出 OutOfMemoryError.

3.1.7 直接內存

Direct Memory

非虛擬機運行時數據區的部分.

在 JDK 1.4 中新加入 NIO (New Input/Output) 類,引入了一種基於通道(Channel)和緩存(Buffer)的 I/O 方式,它可以使用 Native 函數庫直接分配堆外內存,然後通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作爲這塊內存的引用進行操作。可以避免在 Java 堆和 Native 堆中來回的數據耗時操作。

本機直接內存的分配不會受到java堆大小的限制,但是,既然是內存,肯定還是會受到本機總內存大小的限制.所以我們在配置虛擬機參數時,不要忽略直接內存,否則可能因爲動態擴展導致出現OutOfMemoryError.

3.2 基於棧的執行過程分析

下面我們通過一個小程序,來分析一下,虛擬機中實際是如何執行代碼的.

public int calc(){

    int a = 100;

    int b = 200;

    int c = 300;

    return (a + b) * c;

}

代碼非常簡單,我們可以直接使用javap命令(javap -c CalcTest.class > calc.txt),通過反彙編操作,來查看對應的字節碼指令.

image

查閱虛擬機字節碼指令表,我們先將上面的反彙編代碼翻譯一下:

image

我們從這段程序的執行中,也可以回過來再次認識棧結構.整個過程的中間變量都是以操作數棧的出棧,入棧爲信息交換途徑.

3.3 HotSpot虛擬機對象探祕

堆,是我們最實用的一塊內存空間,分析完棧幀的執行過程之後,現在我們再來分析一下,虛擬機在java堆中對象的創建,佈局和訪問的過程.

3.3.1 對象的創建

Java是一門面向對象的語言,在運行過程中無時無刻都有對象的創建,在語言層面,僅僅是一個關鍵字new,那麼在虛擬機中,對象是如何創建出來的呢?

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

爲新生對象分配內存:類加載檢查通過之後,爲新對象分配內存(內存大小在類加載完成後便可確認)。

如果堆內存絕對規整,使用指針碰撞.否則使用空閒列表,找到一塊足夠大的內存劃分給對象實例.

image

堆內存是否規整,主要是看GC回收了內存之後是否包含壓縮或者整理功能.如果有,那麼內存就比較規整.否則如果沒有,創建對象就需要採用空閒列表的方式.

比如:

serial,ParNew等帶有整理的收集器,可以使用指針碰撞.

CMS使用簡單清除的算法,可以使用空閒列表.

如果線程支持在堆中都有私有的分配緩衝區(TLAB),這樣可以很大程度避免在併發情況下頻繁創建對象造成的線程不安全。

內存空間分配完成後會將整個空間都初始化爲零值(不包括對象頭).

接下來就是填充對象頭,把對象是哪個類的實例、如何才能找到類的元數據信息、對象的哈希碼、對象的 GC 分代年齡等信息存入對象頭。

執行 new 指令後,執行 init 方法(由字節碼指令invokespecial決定,執行初始化方法)後,纔算一份真正可用的對象創建完成.

3.3.2 對象的內存佈局

在上文中,我們講到一個步驟是,填充對象頭.那什麼是對象頭呢?

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

對象頭(Header)

包含兩部分,第一部分用於存儲對象自身的運行時數據,如哈希碼、GC 分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程 ID、偏向時間戳等,根據不同的系統爲數固定大小.官方稱爲 ‘Mark Word’。第二部分是類型指針,即對象指向它的類的元數據指針,虛擬機通過這個指針確定這個對象是哪個類的實例。另外,如果是 Java 數組,對象頭中還必須有一塊用於記錄數組長度的數據,因爲普通對象可以通過 Java 對象元數據確定大小,而數組對象不可以。

實例數據(Instance Data):程序代碼中所定義的各種類型的字段內容(包含父類繼承下來的和子類中定義的)。

對齊填充(Padding):不是必然需要,主要是佔位,保證對象大小是某個字節的整數倍。

3.3.3 對象的訪問定位

使用對象時,通過棧上的 reference 數據來操作堆上的具體對象

由於java虛擬機只規定要一個執行對象的引用,而沒有規定以何種方式去定位.所以對象訪問方式取決於虛擬機的實現.主流的方式有兩種:

1.通過句柄訪問.Java 堆中會分配一塊內存作爲句柄池。reference 存儲的是句柄地址.

2.使用指針訪問.reference 中直接存儲對象地址.

比較:使用句柄的最大好處是 reference 中存儲的是穩定的句柄地址,在對象移動(GC)是隻改變實例數據指針地址,reference 自身不需要修改。直接指針訪問的最大好處是速度快,節省了一次指針定位的時間開銷。如果是對象頻繁 GC 那麼句柄方法好,如果是對象頻繁訪問則直接指針訪問好.

HotSpot使用第二種方式進行對象訪問的.

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