每天資訊好文推薦:JVM之記憶體模型

菜單

好文推薦:JVM之記憶體模型

JVM定義了若干個程式執行期間使用的資料區域。這個區域裡的一些資料在JVM啟動的時候建立,在JVM退出的時候銷燬。而其他的資料依賴於每一個執行緒,線上程建立時建立,線上程退出時銷燬。

好文推薦:JVM之記憶體模型

1、程式計數器

程式計數器是一塊較小的記憶體空間,可以看作是當前執行緒所執行的位元組碼的行號指示器。分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

由於Java 虛擬機器的多執行緒是透過執行緒輪流切換並分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個核心)只會執行一條執行緒中的指令。因此,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各條執行緒之間的計數器互不影響,獨立儲存,我們稱這類記憶體區域為“

執行緒私有

”的記憶體。

如果執行緒正在執行的是一個Java 方法,這個計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是Natvie 方法,這個計數器值則為空(Undefined)。

此記憶體區域是唯一一個在Java

虛擬機器規範中沒有規定任何OutOfMemoryError

情況的區域。

2、虛擬機器棧

執行緒私有,它的生命週期與執行緒相同。虛擬機器棧描述的是Java 方法執行的記憶體模型:

每個方法被執行的時候都會同時建立一個棧幀(**Stack Frame**)用於儲存區域性變量表、操作棧、動態連結、方法出口等資訊

動畫是由一幀一幀圖片連續切換結果的結果而產生的,其實虛擬機器的執行和動畫也類似,每個在虛擬機器中執行的程式也是由許多的幀的切換產生的結果,只是這些幀裡面存放的是方法的區域性變數,運算元棧,動態連結,方法返回地址和一些額外的附加資訊組成。每一個方法被呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。

對於執行引擎來說,活動執行緒中,只有棧頂的棧幀是有效的,稱為

當前棧幀

,這個棧幀所關聯的方法稱為

當前方法

執行引擎所執行的所有位元組碼指令都只針對當前棧幀進行操作

2。1 區域性變量表

區域性變量表是一組變數值儲存空間,

用於存放方法引數和方法內部定義的區域性變數

。在Java程式被編譯成Class檔案時,就在方法的Code屬性的max_locals資料項中確定了該方法所需要分配的最大區域性變量表的容量。

區域性變量表的容量以

變數槽

(Slot)為最小單位,32位虛擬機器中一個Slot可以存放一個32位以內的資料型別(boolean、byte、char、short、int、float、reference和returnAddress八種)。

reference型別虛擬機器規範沒有明確說明它的長度,但一般來說,虛擬機器實現至少都應當能從此引用中直接或者間接地查詢到物件在Java堆中的起始地址索引和方法區中的物件型別資料。

returnAddress型別是為位元組碼指令jsr、jsr_w和ret服務的,它指向了一條位元組碼指令的地址。

虛擬機器是使用區域性變量表完成引數值到引數變數列表的傳遞過程的

,如果是例項方法(非static),那麼區域性變量表的第0位索引的Slot預設是用於傳遞方法所屬物件例項的引用,在方法中透過this訪問。

Slot是可以重用的,當Slot中的變數超出了作用域,那麼下一次分配Slot的時候,將會覆蓋原來的資料。Slot對物件的引用會影響GC(要是被引用,將不會被回收)。

系統不會為區域性變數賦予初始值(例項變數和類變數都會被賦予初始值)

。也就是說不存在類變數那樣的準備階段。

2。2 運算元棧

和區域性變數區一樣,運算元棧也是被組織成一個以字長為單位的陣列。但是和前者不同的是,它不是透過索引來訪問,而是透過標準的棧操作——壓棧和出棧—來訪問的。比如,如果某個指令把一個值壓入到運算元棧中,稍後另一個指令就可以彈出這個值來使用。

虛擬機器在運算元棧中儲存資料的方式和在區域性變數區中是一樣的:如int、long、float、double、reference和returnType的儲存。對於byte、short以及char型別的值在壓入到運算元棧之前,也會被轉換為int。

虛擬機器把運算元棧作為它的工作區——大多數指令都要從這裡彈出資料,執行運算,然後把結果壓回運算元棧。比如,iadd指令就要從運算元棧中彈出兩個整數,執行加法運算,其結果又壓回到運算元棧中,看看下面的示例,它演示了虛擬機器是如何把兩個int型別的區域性變數相加,再把結果儲存到第三個區域性變數的:

begin

iload_0

// push the int in local variable 0 onto the stack

iload_1

// push the int in local variable 1 onto the stack

iadd

// pop two ints, add them, push result

istore_2

// pop int, store into local variable 2

end

在這個位元組碼序列裡,前兩個指令iload_0和iload_1將儲存在區域性變數中索引為0和1的整數壓入運算元棧中,其後iadd指令從運算元棧中彈出那兩個整數相加,再將結果壓入運算元棧。第四條指令istore_2則從運算元棧中彈出結果,並把它儲存到區域性變數區索引為2的位置。下圖詳細表述了這個過程中區域性變數和運算元棧的狀態變化,圖中沒有使用的區域性變數區和運算元棧區域以空白表示。

好文推薦:JVM之記憶體模型

2。3 動態連線

虛擬機器執行的時候,執行時常量池會儲存大量的符號引用,這些符號引用可以看成是每個方法的間接引用。如果代表棧幀A的方法想呼叫代表棧幀B的方法,那麼這個虛擬機器的方法呼叫指令就會以B方法的符號引用作為引數,但是因為符號引用並不是直接指向代表B方法的記憶體位置,所以在呼叫之前還必須要將符號引用轉換為直接引用,然後透過直接引用才可以訪問到真正的方法。

如果符號引用是在類載入階段或者第一次使用的時候轉化為直接應用,那麼這種轉換成為

靜態解析

,如果是在執行期間轉換為直接引用,那麼這種轉換就成為

動態連線。

好文推薦:JVM之記憶體模型

2。4 返回地址

方法的返回分為兩種情況,一種是正常退出,退出後會根據方法的定義來決定是否要傳返回值給上層的呼叫者,一種是異常導致的方法結束,這種情況是不會傳返回值給上層的呼叫方法。

不過無論是那種方式的方法結束,在退出當前方法時都會跳轉到當前方法被呼叫的位置,如果方法是正常退出的,則呼叫者的PC計數器的值就可以作為返回地址,,果是因為異常退出的,則是需要透過異常處理表來確定。

方法的的一次呼叫就對應著棧幀在虛擬機器棧中的一次入棧出棧操作,因此方法退出時可能做的事情包括:恢復上層方法的區域性變量表以及運算元棧,如果有返回值的話,就把返回值壓入到呼叫者棧幀的運算元棧中,還會把PC計數器的值調整為方法呼叫入口的下一條指令。

2。5 異常

在Java 虛擬機器規範中,對虛擬機器棧規定了兩種異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲

StackOverflowError

異常;如果虛擬機器棧可以動態擴充套件(當前大部分的Java 虛擬機器都可動態擴充套件,只不過Java 虛擬機器規範中也允許固定長度的虛擬機器棧),當擴充套件時無法申請到足夠的記憶體時會丟擲

OutOfMemoryError

異常。

3、本地方法棧

本地方法棧(Native Method Stacks)與虛擬機器棧所發揮的作用是非常相似的,其區別不過是虛擬機器棧為虛擬機器執行Java 方法(也就是位元組碼)服務,而本地方法棧則是為虛擬機器使用到的Native 方法服務。虛擬機器規範中對本地方法棧中的方法使用的語言、使用方式與資料結構並沒有強制規定,因此具體的虛擬機器可以自由實現它。甚至有的虛擬機器(譬如

Sun HotSpot*

*虛擬機器)直接就把本地方法棧和虛擬機器棧合二為一**。

與虛擬機器棧一樣,本地方法棧區域也會丟擲StackOverflowError 和OutOfMemoryError異常。

4、堆

堆是Java 虛擬機器所管理的記憶體中最大的一塊。Java 堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項都在這裡分配記憶體。但是隨著JIT 編譯器的發展與逃逸分析技術的逐漸成熟,棧上分配、標量替換最佳化技術將會導致一些微妙的變化發生,所有的物件都分配在堆上也漸漸變得不是那麼“絕對”了。

堆是垃圾收集器管理的主要區域

,因此很多時候也被稱做“

GC

”。

堆的大小可以透過

-Xms(

最小值)和

-Xmx

(最大值)引數設定,-Xms為JVM啟動時申請的最小記憶體,預設為作業系統物理記憶體的1/64但小於1G,-Xmx為JVM可申請的最大記憶體,預設為物理記憶體的1/4但小於1G,預設當空餘堆記憶體小於40%時,JVM會增大Heap到-Xmx指定的大小,可透過-XX:MinHeapFreeRation=來指定這個比列;當空餘堆記憶體大於70%時,JVM會減小heap的大小到-Xms指定的大小,可透過XX:MaxHeapFreeRation=來指定這個比列,對於執行系統,為避免在執行時頻繁調整Heap的大小,通常-Xms與-Xmx的值設成一樣。

如果從記憶體回收的角度看,由於現在收集器基本都是採用的分代收集演算法,所以Java 堆中還可以細分為:新生代和老年代;

新生代

程式新建立的物件都是從新生代分配記憶體

,新生代由

Eden Space

和兩塊相同大小的

Survivor Space

(通常又稱S0和S1或From和To)構成,可透過

-Xmn

引數來指定新生代的大小,也可以透過

-XX:SurvivorRation

來調整Eden Space及Survivor Space的大小。

老年代

:用於存放經過多次新生代GC仍然存活的物件,例如快取物件,新建的物件也有可能直接進入老年代,主要有兩種情況:1、大物件,可透過啟動引數設定

-XX:PretenureSizeThreshold

=1024(單位為位元組,默 認為0)來代表超過多大時就不在新生代分配,而是直接在老年代分配。2、大的陣列物件,且陣列中無引用外部物件。

老年代所佔的記憶體大小為-Xmx對應的值減去-Xmn對應的值。

如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError 異常。

5、方法區

方法區在一個jvm例項的內部,

型別資訊

被儲存在一個稱為方法區的記憶體邏輯區中。型別資訊是由類載入器在類載入時從類檔案中提取出來的。類(靜態)變數也儲存在方法區中。

簡單說方法區用來儲存型別的元資料資訊,一個。class檔案是類被java虛擬機器使用之前的表現形式,一旦這個類要被使用,java虛擬機器就會對其進行裝載、連線(驗證、準備、解析)和初始化。而裝載(後的結果就是由。class檔案轉變為方法區中的一段特定的資料結構。這個資料結構會儲存如下資訊:

型別資訊

這個型別的全限定名

這個型別的直接超類的全限定名

這個型別是類型別還是介面型別

這個型別的訪問修飾符

任何直接超介面的全限定名的有序列表

欄位資訊

欄位名

欄位型別

欄位的修飾符

方法資訊

方法名

方法返回型別

方法引數的數量和型別(按照順序)

方法的修飾符

其他資訊

除了常量以外的所有類(靜態)變數

一個指向ClassLoader的指標

一個指向Class物件的指標

常量池(常量資料以及對其他型別的符號引用)

JVM為每個已載入的型別都維護一個

常量池

。常量池就是這個型別用到的常量的一個有序集合,包括實際的常量和對型別,域和方法的符號引用。池中的資料項象陣列項一樣,

是透過索引訪問的

每個類的這些元資料,無論是在構建這個類的例項還是呼叫這個類某個物件的方法,都會訪問方法區的這些元資料。

構建一個物件時,JVM會在堆中給物件分配空間,這些空間用來儲存當前物件例項屬性以及其父類的例項屬性(而這些屬性資訊都是從方法區獲得),注意,這裡並不是僅僅為當前物件的例項屬性分配空間,還需要給父類的例項屬性分配,到此其實我們就可以回答第一個問題了,即例項化父類的某個子類時,JVM也會同時構建父類的一個物件。從另外一個角度也可以印證這個問題:呼叫當前類的構造方法時,首先會呼叫其父類的構造方法直到Object,而構造方法的呼叫意味著例項的建立,所以子類例項化時,父類肯定也會被例項化。

類變數被類的所有例項共享,即使沒有類例項時你也可以訪問它。

這些變數只與類相關,所以在方法區中

,它們成為類資料在邏輯上的一部分。在JVM使用一個類之前,它必須在方法區中為每個non-final類變數分配空間。

方法區主要有以下幾個特點:

方法區是執行緒安全的。由於所有的執行緒都共享方法區,所以,方法區裡的資料訪問必須被設計成執行緒安全的。例如,假如同時有兩個執行緒都企圖訪問方法區中的同一個類,而這個類還沒有被裝入JVM,那麼只允許一個執行緒去裝載它,而其它執行緒必須等待

方法區的大小不必是固定的,JVM可根據應用需要動態調整。同時,方法區也不一定是連續的,方法區可以在一個堆(甚至是JVM自己的堆)中自由分配。

方法區也可被垃圾收集,當某個類不在被使用(不可觸及)時,JVM將解除安裝這個類,進行垃圾收集

可以透過

-XX:PermSize

-XX:MaxPermSize

引數限制方法區的大小。

對於習慣在HotSpot 虛擬機器上開發和部署程式的開發者來說,很多人願意把方法區稱為“

永久代

”(Permanent Generation),本質上兩者並不等價,僅僅是因為HotSpot 虛擬機器的設計團隊選擇把GC 分代收集擴充套件至方法區,或者說使用永久代來實現方法區而已。對於其他虛擬機器(如BEA JRockit、IBM J9 等)來說是不存在永久代的概念的。

相對而言,垃圾收集行為在這個區域是比較少出現的,但並非資料進入了方法區就如永久代的名字一樣“永久”存在了。這個區域的記憶體回收目標主要是針對常量池的回收和對型別的解除安裝。

當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError 異常。

6、總結

7、擴充套件

7。1 直接記憶體

直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域,但是這部分記憶體也被頻繁地使用,而且也可能導致OutOfMemoryError 異常出現。

在JDK 1。4 中新加入了NIO(New Input/Output)類,引入了一種基於通道(Channel)與緩衝區(Buffer)的I/O 方式,它可以使用Native 函式庫直接分配堆外記憶體,然後透過一個儲存在Java 堆裡面的DirectByteBuffer 物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了在Java 堆和Native 堆中來回複製資料。

7。2 metaspace

絕大部分 Java 程式設計師應該都見過 “

java.lang.OutOfMemoryError: PermGen space

”這個異常。這裡的 “PermGen space”其實指的就是方法區。不過方法區和“PermGen space”又有著本質的區別。前者是 JVM 的規範,而後者則是 JVM 規範的一種實現,並且只有 HotSpot 才有 “PermGen space”,而對於其他型別的虛擬機器,如 JRockit(Oracle)、J9(IBM) 並沒有“PermGen space”。由於方法區主要儲存類的相關資訊,所以對於動態生成類的情況比較容易出現永久代的記憶體溢位。一個典型的場景就是在 jsp 頁面比較多的情況,容易出現永久代記憶體溢位。

在 JDK 1。8 中, HotSpot 已經沒有 “PermGen space”這個區間了,取而代之是一個叫做

Metaspace

(元空間) 的東西。下面我們就來看看 Metaspace 與 PermGen space 的區別。

其實,移除永久代的工作從JDK1。7就開始了。JDK1。7中,儲存在永久代的部分資料就已經轉移到了Java Heap或者是 Native Heap。但永久代仍存在於JDK1。7中,並沒完全移除,譬如符號引用(Symbols)轉移到了native heap;字面量(interned strings)轉移到了java heap;類的靜態變數(class statics)轉移到了java heap。我們可以透過一段程式來比較 JDK 1。6 與 JDK 1。7及 JDK 1。8 的區別,以字串常量為例:

public

class

StringOomMock

{

static

String

base

=

“string”

public

static

void

main

String

[]

args

) {

List

<

String

>

list

=

new

ArrayList

<

String

>

();

for

int

i

=

0

i

<

Integer

MAX_VALUE

i

++

) {

String

str

=

base

+

base

base

=

str

list

add

str

intern

());

}

}

}

這段程式以2的指數級不斷的生成新的字串,這樣可以比較快速的消耗記憶體。我們透過 JDK 1。6、JDK 1。7 和 JDK 1。8 分別執行:

JDK 1。6 的執行結果:

好文推薦:JVM之記憶體模型

JDK 1。7的執行結果:

好文推薦:JVM之記憶體模型

JDK 1。8的執行結果:

好文推薦:JVM之記憶體模型

從上述結果可以看出,JDK 1。6下,會出現“PermGen Space”的記憶體溢位,而在 JDK 1。7和 JDK 1。8 中,會出現堆記憶體溢位,並且 JDK 1。8中 PermSize 和 MaxPermGen 已經無效。因此,可以大致驗證 JDK 1。7 和 1。8 將字串常量由永久代轉移到堆中,並且 JDK 1。8 中已經不存在永久代的結論。現在我們看看元空間到底是一個什麼東西?

元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代之間最大的區別在於:

元空間並不在虛擬機器中,而是使用本地記憶體

。因此,預設情況下,元空間的大小僅受本地記憶體限制,但可以透過以下引數來指定元空間的大小:

*-X:MetaspaceSize

,初始空間大小,達到該值就會觸發垃圾收集進行型別解除安裝,同時GC會對該值進行調整:如果釋放了大量的空間,就適當降低該值;如果釋放了很少的空間,那麼在不超過MaxMetaspaceSize時,適當提高該值。

-XX:MaxMetaspaceSize

,最大空間,預設是沒有限制的。

除了上面兩個指定大小的選項以外,還有兩個與 GC 相關的屬性:

-XX:MinMetaspaceFreeRatio

,在GC之後,最小的Metaspace剩餘空間容量的百分比,減少為分配空間所導致的垃圾收集

-XX:MaxMetaspaceFreeRatio

,在GC之後,最大的Metaspace剩餘空間容量的百分比,減少為釋放空間所導致的垃圾收集

現在我們在 JDK 8下重新執行一下程式碼段 4,不過這次不再指定 PermSize 和 MaxPermSize。而是指定 MetaSpaceSize 和 MaxMetaSpaceSize的大小。輸出結果如下:

好文推薦:JVM之記憶體模型

從輸出結果,我們可以看出,這次不再出現永久代溢位,而是出現了元空間的溢位。

透過上面分析,應該大致瞭解了 JVM 的記憶體劃分,也清楚了 JDK 8 中永久代向元空間的轉換。不過這裡有一個疑問,就是為什麼要做這個轉換?大體上可以有以下幾點原因:

字串存在永久代中,容易出現效能問題和記憶體溢位。

類及方法的資訊等比較難確定其大小,因此對於永久代的大小指定比較困難,太小容易出現永久代溢位,太大則容易導致老年代溢位。

永久代會為 GC 帶來不必要的複雜度,並且回收效率偏低。