1. 程式人生 > >Java虛擬機器(JVM)執行時記憶體區域劃分詳解

Java虛擬機器(JVM)執行時記憶體區域劃分詳解

Java虛擬機器(JVM)記憶體區域劃分詳解

最近一直沒有怎麼更新自己的部落格,主要是由於老哥公司最近的一個招標專案忙得焦頭爛額,心力憔悴(ಥ_ಥ),趁著專案的空檔期來重構一下以前的一篇關於jvm記憶體區域劃分的部落格,仔細閱讀了一下之前的部落格,大量的文字敘述可能對於讀者來說,看到的第一眼就不想讀下去了,吸收各方面的意見,為了讓初學者更好的理解,趁著這段時間還比較自由,就來重構一下這篇部落格。

一、java語言的優勢

1、跨平臺


Java語言的廣泛的使用一個最主要的原因是其跨平臺的優勢,正如其所宣稱的Write Once Run Everywhere(一次編寫到處執行),而其跨平臺執行主要就是依賴於JVM的存在,我們知道同一指令在不同的平臺(如:Windows、Linux、Unix)去執行其控制代碼(handle)是不同的,而憑藉著JVM的轉換使得我們的程式無需考慮這些。最終實現跨平臺的執行。

2、jvm記憶體管理


學過C++的老哥都清楚,在C++中每塊記憶體管控是有你自己來完成的,比如你new了一個物件申請了一塊記憶體區域,當物件的生命週期結束時,需要手動的free掉你的申請的記憶體,每塊記憶體的申請和釋放都十分小心。相對比而言用Java開發的程式設計師就幸福多了,因為java中記憶體的管控是由jvm來完成的,我們申請完記憶體空間後,不需要手動去釋放,jvm會在合適的時間去釋放這塊記憶體空間。這樣就不會帶來由於忘記釋放記憶體而帶來記憶體洩漏。

二、Jvm記憶體區域的劃分


我們知道了java中記憶體的管理是由Jvm來完成的,那麼jvm中記憶體的區域分為哪幾部分呢?記憶體區域是如何劃分的呢?
jvm的記憶體區域主要分為以下五個部分:程式計數器、虛擬機器棧、本地方法棧、方法區、堆,如下圖所示:

java虛擬機器執行時資料區 接下來我們就分別來介紹每一部分的作用:

1. 程式計數器


程式計數器:指向當前執行緒所執行的位元組碼指令的地址,通常也叫做行號指示器。
Java是一門解釋型的語言,.java檔案被javac指令編譯成.class的位元組碼檔案。位元組碼直譯器會將編譯好的位元組碼檔案解釋執行,這也真是java語言可以很好的實現的跨平臺的真正原因。位元組碼直譯器工作時,就是通過改變程式計數器的值來選擇下一條需要執行的位元組碼指令,分支,跳轉,迴圈,異常處理等基礎功能都需要依賴這個計數器來完成。

java是一門支援多執行緒的語言,所謂的多執行緒實則是通過CPU的時間片輪轉排程演算法來實現的,在一個時間片內,處理器只會執行一個執行緒中的指令。為了保證執行緒切換過程時可以恢復到原來的執行位置,每條執行緒都會有一個獨立的程式計數器,各執行緒之間互不影響,獨立儲存。

java程式中程式計數器記錄位元組碼指令的地址。Native方法程式計數器則為空。

2. Java虛擬機器棧

上學的時老師常把java虛擬機器的記憶體分為堆記憶體(heap)和棧記憶體(stack),這種分配方式實際上非常的粗糙,實際的記憶體劃分遠比這複雜。這麼劃分其實是為了讓程式設計師理解物件記憶體分配最密切的這兩個區域。所說的棧就是指虛擬機器棧,或者說是虛擬機器棧中的棧禎的區域性變量表。堆部分下文會詳細講解。
虛擬機器棧描述的是java方法執行的記憶體模型,方法的執行的同時會建立一個棧禎,用於儲存方法中的區域性變量表、運算元棧、動態連結、方法的出口等資訊,每個方法從呼叫直到執行完成的過程,就對應著一個棧禎在虛擬機器棧中入棧到出棧的過程
本文關於虛擬機器的記憶體區域的定義摘自第二版《深入理解java虛擬機器》,感興趣的老哥可以深入的讀一讀你會收穫頗深。
沒了解過jvm的老哥可能對個定義還是一頭霧水,沒關係!我們接下來就舉例說明,當呼叫方法時,虛擬機器棧是如何工作的。
首先我們將一個java檔案通過javac編譯成位元組碼檔案。之後我們通過javap反彙編位元組碼檔案。具體javap -c +位元組碼檔案路徑,如下圖所示:

這裡寫圖片描述

下面就是我們編譯的方法:methodOne()

    private void methodOne() {

       int a = 2;

       float b = 4.5f;

       String name = "ss";

       Object object = new Object();

       float sum = a + b;

    }


javap -c反彙編之後的方法如下:操作碼對應含義

public void methodOne();
    Code:
       0: iconst_2         //將整型2入棧(int a = 2;)
       1: istore_1         //將棧頂元素賦值給方法中第一個變數
       2: ldc           #2 //將4.5入棧(float b = 4.5f;)              // float 4.5f
       4: fstore_2         //將棧頂元素賦值給方法中第二個變數
       5: ldc           #3 //將ss入棧 (String name = "ss";)                // String ss
       7: astore_3         //將棧頂元素賦值給方法中第三個變數
       8: new           #4 //申請塊堆記憶體,將申請到的堆記憶體地址壓入棧(Object object = new Object();) // class java/lang/Object
      11: dup              //複製棧頂元素,並將賦值的棧頂元素壓入棧
      12: invokespecial #1 //初始化物件(注意,此時當前棧頂的資料會出棧)// Method java/lang/Object."<init>":()V
      15: astore        4  //當前棧頂元素賦值給方法中,第四個變數
      17: iload_1          //將第一個變數值入棧(float sum = a + b;)
      18: i2f              //將棧頂int型別值轉換為float型別值。
      19: fload_2          //將第二個變數值入棧
      20: fadd             //將棧頂兩個數值相加結果壓入棧
      21: fstore        5  //將棧頂的元素出棧賦值給方法中第五個變數
      23: return           //void函式返回
}


方法的呼叫過程中,虛擬機器棧的操作如下圖示:


這裡寫圖片描述

首先我們看下面這句在程式碼在虛擬機器棧中的執行過程:

 int a = 2;

第一步:當方法被呼叫時,methodOne()方法入棧,在虛擬棧中為methodOne()方法初始化一個棧幀。

第二步:將整型2入運算元棧

第三步:將運算元棧棧頂元素賦值給方法中第一個變數,即區域性變量表中int a=2;被初始化完成。

methodOne()方法的其他語句見上圖反彙編的後的程式碼註釋已經給出,在此不再贅述。

接下來我們具體闡述虛擬機器棧中每個區域:

(1)區域性變量表

區域性變量表中存放了方法的引數和方法內部定義的區域性變數。boolean、byte、char、short、int、float、reference、returnAddress。

(2)運算元棧

Java虛擬機器的解釋執行引擎被稱為”基於棧的執行引擎”,其中所指的棧就是指:運算元棧。運算元棧也常被稱為操作棧。 和區域性變數區一樣,運算元棧也是被組織成一個以字長為單位的陣列。但是和前者不同的是,它不是通過索引來訪問,而是通過標準的棧操作:壓棧和出棧來訪問的。比如,如果某個指令把一個值壓入到運算元棧中,稍後另一個指令就可以彈出這個值來使用。

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

虛擬機器把運算元棧作為它的工作區——大多數指令都要從這裡彈出資料,執行運算,然後把結果壓回運算元棧。比如,fadd指令就要從運算元棧中彈出兩個浮點數(如上圖中的float sum = a + b),執行加法運算,其結果又壓回到運算元棧中。

(3)動態連結

每個棧幀都包含一個指向執行時常量池(方法區的一部分)中該棧幀所屬方法的引用。持有這個引用是為了支援方法呼叫過程中的動態連線。Class檔案的常量池中有大量的符號引用如下圖所示:
這裡寫圖片描述
位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用為引數。這些符號引用一部分會在類載入階段或第一次使用的時候轉化為直接引用,這種轉化稱為靜態解析。另一部分將在每一次的執行期間轉化為直接應用,這部分稱為動態連線。比如我們的執行時的多型就是動態連結的。在執行時指定屬性的型別。如下:

 private void methodTwo() {
        A a;//執行時指定
        a=new A1();
        a=new A2();
    }

    interface A {
    }

    class A1 implements A {

    }

    class A2 implements A {

    }
(4)方法出口資訊

當一個方法被執行後,有兩種方式退出該方法:執行引擎遇到了任意一個方法返回的位元組碼指令或遇到了異常,並且該異常沒有在方法體內得到處理。無論採用何種退出方式,在方法退出之後,都需要返回到方法被呼叫的位置,程式才能繼續執行。方法返回時可能需要在棧幀中儲存一些資訊,用來幫助恢復它的上層方法的執行狀態。

方法正常退出時,呼叫者的PC計數器的值就可以作為返回地址,棧幀中很可能儲存了這個計數器值,而方法異常退出時,返回地址是要通過異常處理器來確定的,棧幀中一般不會儲存這部分資訊。

方法退出的過程實際上等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的區域性變量表和運算元棧,如果有返回值,則把它壓入呼叫者棧幀的運算元棧中,調整PC計數器的值以指向方法呼叫指令後面的一條指令。

注意:

1、當方法巢狀呼叫時,虛擬機器棧的工作如下:

 private void methodOne() {
        methodTwo();
    }

    private void methodTwo() {
    }


當前執行緒虛擬機器棧的模型如下圖所示:

這裡寫圖片描述

2、當方法遞迴呼叫時

    private void methodOne() {
        methodOne();
    }

這裡寫圖片描述

當我們無限遞迴時,就會產生我們的StackOverflow異常,棧溢位。

這裡寫圖片描述

3.本地方法棧

本地方法棧和虛擬機器棧很相似,區別在於而本地方法棧是為Native方法服務而虛擬機器棧是為java方法服務,在本地方法棧也會丟擲StackOverFlowError異常、OutOfMemoryError異常。有興趣的老哥可以自己深入的研究一下本文不做過多介紹。

4.Java堆

java堆是Java虛擬機器所管理的記憶體中最大的一塊。java堆是被所有執行緒所共享的一塊記憶體區域,虛擬機器啟動時建立,幾乎所有物件的例項都儲存在堆中,所有的物件和陣列都要在堆上分配記憶體。
java堆是垃圾收集器(GC)管理的主要區域,java堆中可以劃分出多執行緒私有的緩衝區,但是無論怎麼劃分物件的例項仍然儲存在堆中。java堆允許處於不連續的實體記憶體空間中,只要邏輯連續即可。堆中如果沒有空間完成例項分配無法擴充套件時將會丟擲OutOfMemoryError異常。

這裡寫圖片描述

JVM 的堆分為兩個區域,新生代、老年代。同時新生代又分為eden區,s0(From Survivor)、s1(To Survivor)三個區域,通常Eden和S區域是大小是8:1的關係。注*在有些資料中介紹堆中還有一塊區域叫永久代,在JDK的HotSpot虛擬機器中,可以認為方法區就是永久代,JVM規範把它描述為堆的一個邏輯部分,但是他卻有一個別名叫Non-Heap目的就是為了與堆區分開來。

4.1、新生代

新生代主要存放的是那些很快就會被GC回收掉的朝生夕死的物件,或者不是特別大的物件。(可配置,大於閾值會直接放入老年代)新生代中的GC操作是MinorGC,Eden區的物件經過第一次Minor GC後,如果仍然存活,將會被移到Survivor區。物件在Survivor區中每熬過一次Minor GC,年齡就會增加1歲,當它的年齡增加到一定程度時,就會被移動到老年代中。

MinorGC採用的是複製演算法。複製演算法的基本思想就是將記憶體分為兩塊,每次只用其中一塊,當這一塊記憶體用完,就將還活著的物件複製到另外一塊上面。複製演算法不會產生記憶體碎片。

在GC最開始的時候,新生代物件只會存在於Eden區和s0區,s1是空的。緊接著進行MinorGC,Eden區中所有存活的物件都會被複制到“s1”,而在“s0”區中,仍存活的物件會根據他們的年齡值來決定去向。年齡達到一定值(年齡閾值,可以通過-XX:MaxTenuringThreshold來設定)的物件會被移動到老年代中,沒有達到閾值的物件會被複制到“s1”區域。經過這次GC後,Eden區和s0區已經被清空。這個時候,“s0”和“s1”會交換他們的角色,也就是新的“s1”就是上次GC前的“s0”,新的“s0”就是上次GC前的“s1”。不管怎樣,都會保證名為s1的區域是空的。Minor GC會一直重複這樣的過程,直到“s1”區被填滿,“s1”區被填滿之後,會將所有物件移動到年老代中。

4.2、老年代

老年代則是存放那些在程式中經歷了好幾次MinorGC仍然還活著的或者特別大的物件。老年代發生的是GC是MajorGC或者FullGC

面試題:(敲黑板!!!)

1、為什麼堆區域要分代?

分代的目的就是為了效能的優化,提高GC的效率,反過來想一下,如果沒有分代那麼當堆發生GC操作時,會怎麼做呢?GC會對整個堆進行掃描,找到該回收的物件回收。而我們的很多物件都是朝生夕死的,如果分代的話,我們把新建立的物件放到某一地方,當GC的時候先把這塊存“朝生夕死”物件的區域進行回收,這樣GC的效率會很高。

2、新生代為什麼分割槽?

如果不分割槽,新生代每進行一次Minor GC,存活的物件就會被送到老年代。老年代很快被填滿,觸發Major GC(因為Major GC一般伴隨著Minor GC,也可以看做觸發了Full GC)。老年代的記憶體空間遠大於新生代,進行一次Full GC消耗的時間比Minor GC長得多。你也許會問,執行時間長有什麼壞處?頻發的Full GC消耗的時間是非常可觀的,這一點會影響大型程式的執行和響應速度,更不要說某些連線會因為超時發生連線錯誤了。

Survivor的存在意義,就是減少被送到老年代的物件,進而減少Full GC的發生,Survivor的預篩選保證,只有經歷16次Minor GC還能在新生代中存活的物件,才會被送到老年代。

5.方法區

方法區與堆一樣所有執行緒所共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、及時編譯器編譯後的程式碼等資料。java虛擬機器對方法區的限制非常寬鬆,除了和堆一樣不需要連續的記憶體和可擴充套件,還可以不實現垃圾收集,相對而言,垃圾收集機制在這個區域出現的較少,當方法區無法分配足夠記憶體時,將會丟擲OutOfMemoryError異常。

6.執行時常量池

執行時常量池是方法區的一部分,Class檔案中除了有類的版本、欄位、方法、介面、等描述資訊外,還有一項資訊就是常量池,用於存放編譯時期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行時常量池中存放。一般來說,除了儲存Class檔案中的描述符號引用外,還會把翻譯出來的直接引用也儲存在執行時常量池中。

執行時常量池相對於Class檔案常量池的另一個重要特徵是具備動態性,java語言並不要求常量一定只有編譯時期才能產生,也就是說並非預置入Class檔案中常量池的內容才能進入方法區執行時常量池,執行期間也可以將新的常量放入常量池,用的較多的如String的intern()方法。

執行時常量池是方法區的一部分,自然當方法區無法分配足夠記憶體時,將會丟擲OutOfMemoryError異常。

7.直接記憶體

直接記憶體並不是虛擬機器執行時資料區的一部分,也不是java虛擬機器規範中定義的記憶體區域,但這部分記憶體也被頻繁的使用,而且也會導致OutOfMemoryError異常。

在JDK1.4中新加入了NIO類,引入了一種基於通道的與緩衝區的I/O方式,他可以是使用Native函式直接分配對外記憶體,然後通過位於堆中的DirectByteBuffer物件作為這塊記憶體的引用進行操作,這樣可以顯著提高一些效能,因為避免Java堆和Native堆中來回的複製資料。

顯然,直接記憶體不會受到Java堆記憶體的大小的影響,但是既然是記憶體,肯定還是受到本機記憶體大小以及處理器定址範圍限制。當各個記憶體區域的總和大於物理儲存限制,從而導致動態擴充套件是出現OutOfMemoryError異常。

到此有關Java虛擬計的記憶體區域以及各個區域的會丟擲的異常就介紹完了,如果你認真讀並且仔細思考相信你一定能有所收穫,你的理解一定不再侷限於大學時老師所講的堆和棧的理解,對Java記憶體的區域的深入理解對於今後的學習一定會有很大的幫助。同時對OutOfMemoryError異常也會有更為深刻的認知,對今後的工作中遇到的問題解決的更為輕鬆。

歡迎留言指出問題。期待一起進步!

如有疑問歡迎大家留言指正。祝大家生活愉快。

最後歡迎對Android開發感興趣的老哥一起討論。