JVM內存分配過程與原理解析(雷驚風)

   之前對java虛擬機對於內存的分配與管理不是很瞭解,這段時間工作不是很忙,想藉此機會深入的瞭解一下,在網上看了很多文章,對其詳情也有了一定的認識,但是隻是看看肯定是不行的,爲了加深印象同時使自己能夠理解的更深刻,我決定寫這篇文章,同時希望對大家也有一定的幫助。文章裏引用了其他前輩的一些資源,在這裏表示感謝,那麼我們就先從內存區域說起吧!

 

一.內存分區。

   首先Java程序運行Java代碼是發生在JVM上的,JMV相當於是java程序與操作系統的橋樑,JVM具有平臺無關特性,所以java程序便可以在不同的操作系統上運行。Java的內存分配就是發生在JVM上的。對於java的內存回收我們並不用像其他有些語言一樣手動回收,虛擬機就幫我們解決了,也正因爲如此,如果我們寫代碼的時候不注意,很容易出現內存泄漏或者內存溢出(OOM),一旦出現問題,排查也不是很容易,所以只有瞭解了java的內存機制,才能更好的處理代碼,優化代碼。下邊我們看一下java內存的幾個部分,如下圖:


   由上圖可知java內存共由java堆區(Heap)、java棧區(Stack)、方法區(Method Area)、本地方法棧(Native Method Stack)、程序計數器 五部分組成,下面我們一一簡單的講解一下每一個區間的不同作用。

1.java堆區

      首先要講的就是我們的java堆,也就是人們常說的堆棧堆棧裏邊的堆,通過上圖可知堆區是JVM中所有線程共享的內存區域,當運行一個應用程序的時候就會初始化一個相應的堆區,堆區可以動態擴展,如果我們需要的內存不夠了,並且內存不能擴展了,那麼就會報OOM了。引用java虛擬機規範中的一段話:所有的對象實例和數據都要在堆上進行分配。比如我們通過new來創建一個對象,創建出來的對象在堆區只包含屬於各自的成員變量,並不包括成員方法。因爲同一個類型的不同對象擁有各自的成員變量,存儲在各自的堆中,但是他們共享該類的方法,並不是每創建一個對象就把成員方法複製一次。給對象分配內存就是把一塊確定大小的從堆內存中劃分出來,一般有兩種方式:指針碰撞法:假設堆中內存是完整的,已分配的內存和空閒內存分別在不同的一側,通過一個指針作爲分界點,需要分配內存時,僅僅需要把指針往空閒的一端移動與對象所需內存大小相等的距離。‚空閒列表法:事實上,Java堆的內存並不是完整的,已分配的內存和空閒內存相互交錯,JVM通過維護一個列表,記錄可用的內存塊信息,當需要分配內存時,從列表中找到一個足夠大的內存塊分配給對象實例,並更新列表上的記錄。然而創建是一個非常頻繁的行爲,進行堆內存分配時還需要考慮多線程併發問題,可能出現正在給對象A分配內存,指針或記錄還未更新,對象B又同時分配到原來的內存,解決這個問題有兩種方案:1、採用CAS(原子性操作)保證數據更新操作的原子性;2、把內存分配按照線程進行劃分,在不同的空間中進行,每個線程在Java堆中預先分配一個內存塊,稱爲本地線程分配緩衝(Thread Local Allocation Buffer, TLAB)。在堆中分配的內存,是由java虛擬機管理回收的。在堆中產生一個對象或數組,在棧中我們可以寫多個不同的引用變量指向他,那麼我們多個引用相當於指向了堆內存中的同一個內存地址,那麼我們用“==”作比較時,就會返回true。我們的引用變量在棧區中分配,當程序執行完我們的某個引用變量時,我們的引用變量便會自動釋放,而他指向的堆區的對象不會被回收或者說不會被馬上回收,而是在後續的某個不確定的時刻GC去檢查該對象還有沒有被引用,如果沒有被引用纔會回收所佔內存區域。這也是java比較佔內存的一個原因。

2.Java棧區

   由上圖可知,棧區是線程私有的,也就是說,每一個線程都會對應一個自己的棧區,生命週期也線程相同,棧中只保存基礎數據類型的對象和自定義對象的引用(不是對象),也就是指針,對象都存放在堆區中,每個棧中的數據(原始類型和對象引用)都是私有的,其他棧不能訪問。虛擬機棧描述的是Java方法執行的內存模型:每個線程在執行一個方法時會創建一個對應的棧幀(Stack Frame),棧幀負責存儲局部變量變量表、操作數棧、動態鏈接和方法返回地址等信息。每個方法的調用過程,相當於棧幀在Java棧的入棧和出棧過程。當在一段代碼塊定義一個變量時,Java就在棧中 爲這個變量分配內存空間,當該變量退出該作用域後,Java會自動釋放掉爲該變量所分配的內存空間,該內存空間可以立即被另作他用。Java的數學計算是在內存棧裏操作的。

3.方法區

   方法區也是線程共享的區域,用於存儲已經被虛擬機加載的類信息,常量,靜態變量和即時編譯器(JIT)編譯後的代碼等數據。Java虛擬機把方法區描述爲堆的一個邏輯分區,不過方法區有一個別名Non-Heap(非堆),用於區別於Java堆區。方法區包含所有的classstatic變量。運行時常量池是方法區的一部分,Class文件中除了有類的版本,字段,方法,接口等信息以外,還有一項信息是常量池用於存儲編譯器生成的各種字面量和符號引用,這部分信息將在類加載後存放到方法區的運行時常量池中。Java虛擬機對類的每一部分(包括常量池)都有嚴格的規定,每個字節用於存儲哪種數據都必須有規範上的要求,這樣才能夠被虛擬機認可,裝載和執行。一般來說,除了保存Class文件中描述的符號引用外,還會把翻譯出來的直接引用也存儲在運行時常量池中。運行時常量池相對於Class文件常量池的另外一個重要特徵是具備動態性,Java虛擬機並不要求常量只能在編譯期產生,也就是並非預置入Class文件常量池的內容才能進入方法區的運行時常量池中,運行期間也可將新的常量放入常量池中。常量池是方法區的一部分,所以受到內存的限制,當無法申請到足夠內存時會拋出OutOfMemoryError異常。

4.本地方法棧

   本地方法棧和虛擬機棧基本類似,只不過Java虛擬機棧執行的是Java代碼(字節碼),本地方法棧中執行的是本地方法的服務。本地方法棧中也會拋出StackOverflowErrorOutOfMemory異常。

5.程序計數器

   程序計數器是線程私有的,每個線程都有獨立的指令計數器,計數器記錄着虛擬機正在執行的字節碼指令的地址,分支、循環、跳轉、異常處理和線程恢復等操作都依賴這個計數器完成。如果線程執行的是native方法,這個計數器則爲空。java虛擬機的多線程是通過輪流切換並分配處理器執行時間來完成,一個處理器同一時間只會執行一條線程中的指令。爲了線程恢復後能夠恢復正確的執行位置,每條線程都需要一個獨立的程序計數器,以確保線程之間互不影響。所以程序計數器是線程私有的內存。如果虛擬機正在執行的是一個Java方法,則計數器指定的是字節碼指令對應的地址,如果正在執行的是一個本地方法,則計數器指定問空undefined。程序計數器區域是Java虛擬機中唯一沒有定義OutOfMemory異常的區域。

   到這裏我們的java內存分區就說完了,那麼我們從網上摘一個例子給大家加深一下理解,如下程序代碼:

       上圖左側爲程序內存分部情況,右邊爲代碼及執行情況,當代碼執行到右邊代碼紅點處時,內存共進行了如下三步操作(就不自己寫了,前人總結的已經很好了):

1.JVM自動尋找main方法,執行第一句代碼,創建一個Test類的實例,在棧中分配一塊內存,存放一個指向堆區對象的引用變量(指針110925),java中的引用變量就是C語言中指針的一個包裝,所以引用變量中存放的還是堆內存中對象的地址。

2.創建一個int型的變量date,由於是基本類型,直接在棧中存放date對應的值9

3.創建兩個BirthDate類的實例d1d2,在棧中分別存放了對應的指針指向各自的對象。他們在實例化時調用了有參數的構造方法,因此對象中有自定義初始值。 

代碼繼續向下走:

調用test對象的change1方法,並且以date爲參數。JVM讀到這段代碼時,檢測到i是局部變量,因此會把i放在棧中,並且把date的值賦給i


1234賦給i。很簡單的一步。


change1方法執行完畢,立即釋放局部變量i所佔用的棧空間。


調用test對象的change2方法,以實例d1爲參數。JVM檢測到change2方法中的b參數爲局部變量,立即加入到棧中,由於是引用類型的變量,所以b中保存的是d1中的指針,此時bd1指向同一個堆中的對象。在bd1之間傳遞是指針。


change2方法中又實例化了一個BirthDate對象,並且賦給b。在內部執行過程是:在堆區new了一個對象,並且把該對象的指針保存在棧中的b對應空間,此時實例b不再指向實例d1所指向的對象,但是實例d1所指向的對象並無變化,這樣無法對d1造成任何影響。



change2方法執行完畢,立即釋放局部引用變量b所佔的棧空間,注意只是釋放了棧空間,堆空間要等待自動回收。



調用test實例的change3方法,以實例d2爲參數。同理,JVM會在棧中爲局部引用變量b分配空間,並且把d2中的指針存放在b中,此時d2b指向同一個對象。再調用實例bsetDay方法,其實就是調用d2指向的對象的setDay方法。



調用實例bsetDay方法會影響d2,因爲二者指向的是同一個對象。



change3方法執行完畢,立即釋放局部引用變量b

        通過以上一個在實際代碼中運行的例子,相信大家對堆棧的內存分配有了更加深刻的理解。

 

        下邊我們瞭解一下基本類型和基本類型的包裝類的一寫關於常量池的知識:

基本類型有:byteshortcharintlongboolean。基本類型的包裝類分別是:ByteShortCharacterIntegerLongBoolean。注意區分大小寫。二者的區別是:基本類型體現在程序中是普通變量,基本類型的包裝類是類,體現在程序中是引用變量。因此二者在內存中的存儲位置不同:基本類型存儲在棧中,而基本類型包裝類存儲在堆中。上邊提到的這些包裝類都實現了常量池技術,而兩種浮點數類型的包裝類則沒有實現。另外,String類型也實現了常量池技術。

舉例幫大家理解:

public class test {  
    public static void main(String[] args) {      
        objPoolTest();  
    }  
  
    public static void objPoolTest() {  
        int i = 40;  
        int i0 = 40;  
        Integer i1 = 40;  
        Integer i2 = 40;  
        Integer i3 = 0;  
        Integer i4 = new Integer(40);  
        Integer i5 = new Integer(40);  
        Integer i6 = new Integer(0);  
        Double d1=1.0;  
        Double d2=1.0;  
          //在java中對於引用變量來說“==”就是判斷這兩個引用變量所引用的是不是同一個對象
        System.out.println("i==i0\t" + (i == i0));  
        System.out.println("i1==i2\t" + (i1 == i2));  
        System.out.println("i1==i2+i3\t" + (i1 == i2 + i3));  
        System.out.println("i4==i5\t" + (i4 == i5));  
        System.out.println("i4==i5+i6\t" + (i4 == i5 + i6));      
        System.out.println("d1==d2\t" + (d1==d2));   
          
        System.out.println();          
    }  
}

運行結果如下:

i==i0    true  
i1==i2   true  
i1==i2+i3 true  
i4==i5   false  
i4==i5+i6 true  
d1==d2   false

分析一下上述結果:

1.ii0均是普通類型(int)的變量,所以數據直接存儲在棧中,而棧有一個很重要的特性:棧中的數據可以共享。當我們定義了int i = 40;,再定義int i0 = 40;這時候會自動檢查棧中是否有40這個數據,如果有,i0會直接指向i40,不會再添加一個新的40

2.i1i2均是引用類型,在棧中存儲指針,因爲Integer是包裝類。由於Integer包裝類實現了常量池技術,因此i1i240均是從常量池中獲取的,均指向同一個地址,因此i1==12

3.很明顯這是一個加法運算,Java的數學運算都是在棧中進行的,Java會自動對i1i2進行拆箱操作轉化成整型,因此i1在數值上等於i2+i3

4.i4i5均是引用類型,在棧中存儲指針,因爲Integer是包裝類。但是由於他們各自都是new出來的,因此不再從常量池尋找數據,而是從堆中各自new一個對象,然後各自保存指向對象的指針,所以i4i5不相等,因爲他們所存地址不同,所引用到的對象不同。

5.這也是一個加法運算,和3同理。

6.d1d2均是引用類型,在棧中存儲指針,因爲Double是包裝類。但Double包裝類沒有實現常量池技術,因此Doubled1=1.0;相當於Double d1=new Double(1.0);,是從堆new一個對象,d2同理。因此d1d2存放的指針不同,指向的對象不同,所以不相等。

注意:

1.以上提到的幾種基本類型包裝類均實現了常量池技術,但他們維護的常量僅僅是【-128127】這個範圍內的常量,如果常量值超過這個範圍,就會從堆中創建對象,不再從常量池中取。比如,把上邊例子改成Integer i1 = 400; Integer i2 = 400;,很明顯超過了127,無法從常量池獲取常量,就要從堆中new新的Integer對象,這時i1i2就不相等了。

2.String類型也實現了常量池技術,但是稍微有點不同。String型是先檢測常量池中有沒有對應字符串,如果有,則取出來;如果沒有,則把當前的添加進去。

下邊是String的幾個例子:

String a = "a1";   
String b = "a" + 1;   
System.out.println((a == b)); //result = true  
String a = "atrue";   
String b = "a" + "true";   
System.out.println((a == b)); //result = true  
String a = "a3.4";   
String b = "a" + 3.4;   
System.out.println((a == b)); //result = true

分析:JVM對於字符串常量的"+"號連接,將程序編譯期,JVM就將常量字符串的"+"連接優化爲連接後的值,拿"a" + 1來說,經編譯器優化後在class中就已經是a1。在編譯期其字符串常量的值就確定下來,故上面程序最終的結果都爲true


String a = "ab";   
String bb = "b";   
String b = "a" + bb;   
System.out.println((a == b)); //result = false

分析:JVM對於字符串引用,由於在字符串的"+"連接中,有字符串引用存在,而引用的值在程序編譯期是無法確定的,即"a" + bb無法被編譯器優化,只有在程序運行期來動態分配並將連接後的新地址賦給b。所以上面程序的結果也就爲false


String a = "ab";   
final String bb = "b";   
String b = "a" + bb;   
System.out.println((a == b)); //result = true

分析:和[3]中唯一不同的是bb字符串加了final修飾,對於final修飾的變量,它在編譯時被解析爲常量值的一個本地拷貝存儲到自己的常量 池中或嵌入到它的字節碼流中。所以此時的"a" + bb"a" + "b"效果是一樣的。故上面程序的結果爲true

String a = "ab";   
final String bb = getBB();   
String b = "a" + bb;   
System.out.println((a == b)); //result = false   
private static String getBB() {  
return "b";   
}

分析:JVM對於字符串引用bb,它的值在編譯期無法確定,只有在程序運行期調用方法後,將方法的返回值和"a"來動態連接並分配地址爲b,故上面 程序的結果爲false


  到現在爲止,相信您對java虛擬機內存分配有了一個新的瞭解,這篇文章到此就結束了,謝謝大家的關注。


發佈了29 篇原創文章 · 獲贊 29 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章