1. 程式人生 > >Java記憶體分配詳解(堆記憶體、棧記憶體、常量池)

Java記憶體分配詳解(堆記憶體、棧記憶體、常量池)

  Java程式是執行在JVM(Java虛擬機器)上的,因此Java的記憶體分配是在JVM中進行的,JVM是記憶體分配的基礎和前提。Java程式的執行會涉及以下的記憶體區域:
1. 暫存器:JVM內部虛擬暫存器,存取速度非常快,程式不可控制。
2. 棧:存放基本型別的資料和物件的引用,但物件本身不存放在棧中,而是存放在堆中。
3. 堆:存放new出來的物件,注意創建出來的物件只包含各自的成員變數,不包括成員方法。
4. 常量池:存放常量,如基本型別的包裝類(Integer、Short)和String,注意常量池位於堆中。
5. 程式碼段:用來存放從硬碟上讀取的源程式程式碼。
6. 資料段

:用來存放static修飾的靜態成員。

下圖表示了程式大致的記憶體分配情況:
這裡寫圖片描述
下面通過具體的程式碼說明程式在記憶體中是如何執行的(圖片來自尚學堂馬士兵老師課件)。
這裡寫圖片描述
  1. 首先JVM找到main方法作為入口,執行第一句程式碼後,在堆中建立一個Test例項,並在棧中分配一塊記憶體,存放指向堆中例項的引用變數(110925),該引用變量表示的是堆記憶體中物件的地址。
  2. 建立一個int型的變數date,由於是基本型別,直接在棧中存放date對應的值9。
  3. 在堆記憶體中建立兩個BirthDate類的例項d1、d2,在棧中存放了指向各自物件的引用變數,表示例項物件的地址。在堆記憶體中建立的例項物件還包含各自的成員變數。

這裡寫圖片描述
  呼叫test物件的change1方法,並以date作為引數傳入,此時JVM會為change1方法在棧中分配相應的記憶體,並將該方法的區域性變數i存放在棧記憶體中,將date的值賦給i,因此i的值為9。

這裡寫圖片描述
  執行change1方法中的程式碼,將i的值修改為1234。

這裡寫圖片描述
  change1方法執行完畢,釋放其佔用的棧記憶體。

這裡寫圖片描述
  呼叫test物件的change2方法,JVM為該方法分配棧記憶體,將d1作為實參傳入,並將區域性變數b入棧。由於是引用型別,b中儲存的同樣是物件的地下。此時b和d1指向的是堆中的同一個物件。

這裡寫圖片描述
  change2方法中又例項化了一個BirthDate物件,並且賦給b。其執行過程為:在堆中new了一個物件,並將該物件的地址存放在棧中b對應記憶體,此時b不再指向d1所指向的物件。但是d1所指向的物件沒有發生改變,對d1沒有造成任何影響。

這裡寫圖片描述
  change2方法執行完畢,釋放變數b所佔的棧空間,注意只是釋放了棧空間,堆空間物件要等待自動回收。

這裡寫圖片描述
  呼叫test例項的change3方法,傳入引數d2,JVM會為變數b在棧中分配空間,並將d2的地址賦給b,此時d2和b指向同一個物件,再呼叫例項b的setDay方法,其實就是呼叫d2指向的物件的setDay方法。

這裡寫圖片描述
  呼叫例項b的setDay方法會影響d2,因為二者指向的是同一個物件。

這裡寫圖片描述
  change3方法執行完畢,立即釋區域性引用變數b佔用的棧記憶體。

  從以上的Java程式執行時記憶體的分配我們可以得出以下結論:
  1. Java中有兩種型別,分別是基本型別和引用型別。如果是基本型別則直接在棧中儲存值,如果是引用型別,則真正new出來的物件會存放在堆記憶體中,棧記憶體中會儲存指向該物件的引用,即物件在堆記憶體中的地址。
  2. 棧中的資料和堆中的資料銷燬並不是同步的。每個方法在執行時都會建立自己的棧區,方法一旦結束,棧中的區域性變數立即銷燬,但是堆中物件不一定銷燬。因為可能有其他變數也指向了這個物件,直到棧中沒有變數指向堆中的物件時,它才銷燬,而且還不是馬上銷燬,要等垃圾回收才可以被銷燬,這個是由JVM決定的。
  3. 類中定義的例項成員變數在不同物件中各不相同,都有自己的儲存空間(成員變數在堆中的物件中)。而類中定義的方法卻是該類的所有物件共享的,只有一套,物件使用方法的時候方法才被壓入棧,方法不使用則不佔用記憶體。、

常量池

  java中的常量池技術,是為了方便快捷地建立某些物件而出現的,當需要一個物件時,就可以從池中取一個出來(如果池中沒有則建立一個),則在需要重複建立相等變數時節省了很多時間。常量池其實也就是一塊記憶體空間,不同於使用new關鍵字建立的物件所在的堆空間。
  java中的基本型別有:byte、short、char、int、long、boolean。其對應的包裝類分別是:Byte、Short、Character、Integer、Long、Boolean。上邊提到的這些包裝類都實現了常量池技術,而兩種浮點數型別的包裝類則沒有實現。另外,String型別也實現了常量池技術。

1. 基本型別和包裝類

我們先來看一個例子:

public class Test {
    public static void main(String[] args) {
        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. i和i0均是普通型別(int)的變數,所以資料直接儲存在棧中,而棧有一個很重要的特性:棧中的資料可以共享。當我們定義了int i = 40;,再定義int i0 = 40;這時候會自動檢查棧中是否有40這個資料,如果有,i0會直接指向i的40,不會再新增一個新的40。
  2. i1和i2均是引用型別,在棧中儲存物件地址,因為Integer是包裝類。由於Integer包裝類實現了常量池技術,因此i1、i2的40均是從常量池中獲取的,均指向同一個地址,因此i1==12。
  3. 很明顯這是一個加法運算,Java的數學運算都是在棧中進行的,Java會自動對i1、i2進行拆箱操作轉化成整型,因此i1在數值上等於i2+i3。
  4. .i4和i5均是引用型別,在棧中儲存地址,因為Integer是包裝類。但是由於他們各自都是new出來的,因此不再從常量池尋找資料,而是從堆中各自new一個物件,然後各自儲存指向物件的地址,所以i4和i5不相等,因為他們所存地址不同,所引用到的物件不同。
  5. 這也是一個加法運算,和3同理。
  6. d1和d2均是引用型別,在棧中儲存物件地址,因為Double是包裝類。但Double包裝類沒有實現常量池技術,因此Doubled1=1.0;相當於Double d1=new Double(1.0);,是從堆new一個物件,d2同理。因此d1和d2存放的地址不同,指向的物件不同,所以不相等。

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

2. String型別

  對於字串,其物件的引用都是儲存在棧中的,如果是編譯期已經建立好(直接用雙引號定義的)的就儲存在常量池中,如果是執行期(new出來的)才能確定的就儲存在堆中。對於equals相等的字串,在常量池中永遠只有一份,在堆中有多份。
如以下程式碼:

 String s1 = "china";
 String s2 = "china";
 String s3 = "china";

 String ss1 = new String("china");
 String ss2 = new String("china");
 String ss3 = new String("china");

這裡寫圖片描述

  從這裡可以看出,使用雙引號直接定義的String物件會指向常量池中的同一個物件,通過new產生一個字串(假設為“china”)時,會先去常量池中查詢是否已經有了“china”物件,如果沒有則在常量池中建立一個此字串物件,然後堆中再建立一個常量池中此”china”物件的拷貝物件。

下面看兩個關於String常量池的例子:
  String中有一個擴充常量池的intern()方法。當呼叫 intern 方法時,如果常量池中已經包含一個等於此 String 物件的字串(該物件由 equals(Object) 方法確定),則返回常量池中的字串引用。否則,將此 String 物件新增到池中,並且返回此 String 物件的引用。

public class Test {
    public static void main(String[] args) {
           String s0= "java";
           String s1=new String("java");
           String s2=new String("java");
           String s3=new String("java");
           s1.intern(); //intern返回的引用沒有引用變數接收~    s1.intern();等於廢程式碼.
           s3=s3.intern(); //把常量池中“kvill”的引用賦給s2
           System.out.println( s0==s1);//false s0引用指向常量池中物件,s1引用指向堆中物件
           System.out.println( s1==s2);//false s1、s2引用分別指向堆中兩個不同物件
           System.out.println( s0==s1.intern() );//true
           System.out.println( s0==s3 );//true s0、s3引用均指向常量池中物件
    }
}
public class Test {
    public static void main(String[] args) {
        //(1)
        String a = "ab";   
        String bb = "b";   
        String b = "a" + bb;   
        System.out.println((a == b)); //result = false 

        //(2)
        String a1 = "ab";   
        final String bb1 = "b";   
        String b1 = "a" + bb1;   
        System.out.println((a1 == b1)); //result = true 

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

分析:
  1. JVM對於字串引用,由於在字串的”+”連線中,有字串引用存在,而引用的值在程式編譯期是無法確定的,即”a” + bb無法被編譯器優化,只有在程式執行期來動態分配並將連線後的新地址賦給b。所以上面程式的結果也就為false。
  2. (2)和(1)中唯一不同的是bb1字串加了final修飾,對於final修飾的變數,它在編譯時被解析為常量值的一個本地拷貝儲存到自己的常量池中或嵌入到它的位元組碼流中。所以此時的”a” + bb1和”a” + “b”效果是一樣的。故上面程式的結果為true。
  3. JVM對於字串引用bb2,它的值在編譯期無法確定,只有在程式執行期呼叫方法後,將方法的返回值和”a”來動態連線並分配地址為b2,故上面程式的結果為false。