Java中記憶體中的Heap、Stack與程式執行的關係

九天高遠發表於2013-08-18

堆和棧的記憶體管理

棧的記憶體管理是順序分配的,而且定長,不存在記憶體回收問題;而堆 則是隨機分配記憶體,不定長度,存在記憶體分配和回收的問題;
堆記憶體和棧記憶體的區別可以用如下的比喻來看出:使用堆記憶體就象是自己動手做喜歡吃的菜餚,比較麻煩,但是比較符合自己的口味,而且自由度大。使用棧記憶體就象我們去飯館裡吃飯,只管點菜(發出申請)、付錢和吃(使用),吃飽了就走,不必理會切菜、洗菜等準備工作和洗碗、刷鍋等掃尾工作,他的好處是快捷,但是自由度小。作業系統中所說的堆記憶體和棧記憶體,在操作上有上述的特點,這裡的堆記憶體實際上指的就是(滿足堆記憶體性質的)優先佇列的一種資料結構,第1個元素有最高的優先權;棧記憶體實際上就是滿足先進後出的性質的數學或資料結構。

在Java中堆是Java虛擬機器JVM的記憶體資料區。Heap 的管理很複雜,每次分配不定長的記憶體空間,專門用來儲存物件的例項(new 建立的物件和陣列)。在Heap 中分配一定的記憶體來儲存物件例項,實際上也只是儲存物件例項的屬性值,屬性的型別和物件本身的型別標記等,並不儲存物件的方法(方法是指令,儲存在Stack中)在Heap 中分配一定的記憶體儲存物件例項和物件的序列化比較類似。而物件例項在Heap 中分配好以後,需要在Stack中儲存一個4位元組的Heap記憶體地址,用來定位該物件例項在Heap 中的位置,便於找到該物件例項

資料型別

Java虛擬機器中,資料型別可以分為兩類:基本型別引用型別。基本型別的變數儲存原始值,即:他代表的值就是數值本身;而引用型別的變數儲存引用值。“引用值”代表了某個物件的引用,而不是物件本身,物件本身存放在這個引用值所表示的地址的位置。
基本型別包括:byte,short,int,long,char,float,double,Boolean,returnAddress
引用型別包括:類型別,介面型別和陣列。

Java資料型別的關係如下圖所示:

 


物件控制程式碼:如String s = "asdf"; 初始化後的控制程式碼。以及String s; 未初始化的控制程式碼。

 

在函式中定義的一些基本型別的變數和物件的引用變數(物件控制程式碼)都是在函式的記憶體中分配。當在一段程式碼塊中定義一個變數時,java就在棧中為這個變數分配記憶體空間,當超過變數的作用域後,java會自動釋放掉為該變數分配的記憶體空間,該記憶體空間可以立刻被另作他用。

     記憶體用於存放由new建立的物件和陣列。在堆中分配的記憶體,由java虛擬機器自動垃圾回收器來管理。在堆中產生了一個陣列或者物件後,還可以在棧中定義一個特殊的變數,這個變數的取值等於陣列或者物件在堆記憶體中的首地址,在棧中的這個特殊的變數就變成了陣列或者物件的引用變數,以後就可以在程式中使用棧記憶體中的引用變數來訪問堆中的陣列或者物件,引用變數相當於為陣列或者物件起的一個別名,或者代號。

     引用變數是普通變數,定義時在棧中分配記憶體,引用變數在程式執行到作用域外釋放。而陣列&物件本身在堆中分配,即使程式執行到使用new產生陣列和物件的語句所在地程式碼塊之外,陣列和物件本身佔用的堆記憶體也不會被釋放,陣列和物件在沒有引用變數指向它的時候,才變成垃圾,不能再被使用,但是仍然佔著記憶體,在隨後的一個不確定的時間被垃圾回收器釋放掉。這個也是java比較佔記憶體的主要原因,實際上,棧中的變數指向堆記憶體中的變數,這就是 Java 中的指標!

      Java中所有物件的儲存空間都是在堆中分配的,但是這個物件的引用卻是在棧中分配,也就是說在建立一個物件時從兩個地方都分配記憶體,在堆中分配的記憶體實際建立這個物件,而在棧中分配的記憶體只是一個指向這個堆物件的指標(引用)而已。

 

堆和棧

堆和棧是程式執行的關鍵,應該將其理解清楚。

棧是執行時的單位,而堆是儲存時的單位。

 棧解決程式的執行問題,即程式如何執行,或者說如何處理資料;堆解決的是資料儲存的問題,即資料怎麼放、放在哪兒。
在Java中一個執行緒就會相應有一個執行緒棧與之對應,這點很容易理解,因為不同的執行緒執行邏輯有所不同,因此需要一個獨立的執行緒棧。而堆則是所有執行緒共享的。棧因為是執行單位,因此裡面儲存的資訊都是跟當前執行緒(或程式)相關資訊的。包括區域性變數、程式執行狀態、方法返回值等等;而堆只負責儲存物件資訊。

為什麼要把堆和棧區分出來呢?棧中不是也可以儲存資料嗎?


第一,從軟體設計的角度看,棧代表了處理邏輯,而堆代表了資料。這樣分開,使得處理邏輯更為清晰。分而治之的思想。這種隔離、模組化的思想在軟體設計的方方面面都有體現。
第二,堆與棧的分離,使得堆中的內容可以被多個棧共享(也可以理解為多個執行緒訪問同一個物件)。這種共享的收益是很多的。一方面這種共享提供了一種有效的資料互動方式(如:共享記憶體),另一方面,堆中的共享常量和快取可以被所有棧訪問,節省了空間。
第三,棧因為執行時的需要,比如儲存系統執行的上下文,需要進行地址段的劃分。由於棧只能向上增長,因此就會限制住棧儲存內容的能力。而堆不同,堆中的物件是可以根據需要動態增長的,因此棧和堆的拆分,使得動態增長成為可能,相應棧中只需記錄堆中的一個地址即可。
第四,物件導向就是堆和棧的完美結合。其實,物件導向方式的程式與以前結構化的程式在執行上沒有任何區別。但是,物件導向的引入,使得對待問題的思考方式發生了改變,而更接近於自然方式的思考。當我們把物件拆開,你會發現,物件的屬性其實就是資料,存放在堆中;而物件的行為(方法),就是執行邏輯,放在棧中。我們在編寫物件的時候,其實即編寫了資料結構,也編寫的處理資料的邏輯。不得不承認,物件導向的設計,確實很美。

在Java中,Main函式就是棧的起始點,也是程式的起始點

  程式要執行總是有一個起點的。同C語言一樣,java中的Main就是那個起點。無論什麼java程式,找到main就找到了程式執行的入口。

堆中存什麼?棧中存什麼?

 

堆中存的是物件棧中存的是基本資料型別堆中物件的引用。一個物件的大小是不可估計的,或者說是可以動態變化的,但是在棧中,一個物件只對應了一個4btye的引用(堆疊分離的好處)。
為什麼不把基本型別放堆中呢?因為其佔用的空間一般是1~8個位元組——需要空間比較少,而且因為是基本型別,所以不會出現動態增長的情況——長度固定,因此棧中儲存就夠了,如果把他存在堆中是沒有什麼意義的(還會浪費空間,後面說明)。可以這麼說,基本型別和物件的引用都是存放在棧中,而且都是幾個位元組的一個數,因此在程式執行時,他們的處理方式是統一的。但是基本型別、物件引用和物件本身就有所區別了,因為一個是棧中的資料一個是堆中的資料。最常見的一個問題就是,Java中引數傳遞時的問題。

Java中的引數傳遞時傳值呢?還是傳引用?

要說明這個問題,先要明確兩點:
1.不要試圖與C進行類比,Java中沒有指標的概念
2.程式執行永遠都是在棧中進行的,因而引數傳遞時,只存在傳遞基本型別和物件引用的問題。不會直接傳物件本身。


    明確以上兩點後。Java在方法呼叫傳遞引數時,因為沒有指標,所以它都是進行傳值呼叫(這點可以參考C的傳值呼叫)。因此,很多書裡面都說Java是進行傳值呼叫,這點沒有問題,而且也簡化的C中複雜性。

      但是傳引用的錯覺是如何造成的呢?在執行棧中,基本型別和引用的處理是一樣的,都是傳值,所以,如果是傳引用的方法呼叫,也同時可以理解為“傳引用值”的傳值呼叫,即引用的處理跟基本型別是完全一樣的。但是當進入被呼叫方法時,被傳遞的這個引用的值,被程式解釋(或者查詢)到堆中的物件,這個時候才對應到真正的物件。如果此時進行修改,修改的是引用對應的物件,而不是引用本身,即:修改的是堆中的資料。所以這個修改是可以保持的了。
      物件,從某種意義上說,是由基本型別組成的。可以把一個物件看作為一棵樹,物件的屬性如果還是物件,則還是一顆樹(即非葉子節點),基本型別則為樹的葉子節點。程式引數傳遞時,被傳遞的值本身都是不能進行修改的,但是,如果這個值是一個非葉子節點(即一個物件引用),則可以修改這個節點下面的所有內容。 
      堆和棧中,棧是程式執行最根本的東西。程式執行可以沒有堆,但是不能沒有棧。而堆是為棧進行資料儲存服務,說白了堆就是一塊共享的記憶體。不過,正是因為堆和棧的分離的思想,才使得Java的垃圾回收成為可能。
       Java中,棧的大小透過-Xss來設定,當棧中儲存資料比較多時,需要適當調大這個值,否則會出現java.lang.StackOverflowError異常。常見的出現這個異常的是無法返回的遞迴,因為此時棧中儲存的資訊都是方法返回的記錄點。

Java物件的大小

基本資料的型別的大小是固定的,這裡就不多說了。對於非基本型別的Java物件,其大小就值得商榷。
在Java中,一個空Object物件的大小是8byte,這個大小隻是儲存堆中一個沒有任何屬性的物件的大小。看下面語句:
Object ob = new Object();
這樣在程式中完成了一個Java物件的生命,但是它所佔的空間為:4byte+8byte。4byte是上面部分所說的Java棧中儲存引用的所需要的空間。而那8byte則是Java堆中物件的資訊。因為所有的Java非基本型別的物件都需要預設繼承Object物件,因此不論什麼樣的Java物件,其大小都必須是大於8byte。
有了Object物件的大小,我們就可以計算其他物件的大小了。
Class NewObject {
int count;
boolean flag;
Object ob;
}
其大小為:空物件大小(8byte)+int大小(4byte)+Boolean大小(1byte)+空Object引用的大小(4byte)=17byte。但是因為Java在對物件記憶體分配時都是以8的整數倍來分,因此大於17byte的最接近8的整數倍的是24,因此此物件的大小為24byte。

基本資料型別

包裝類 (是否實現了常量池技術)

 byte

 Byte     是

 boolean

 Boolean        是

 short

 Short            是

 char

 Character      是

 int

 Integer         是

 long

 Long            是

 float

 Float          否

 double

 Double       否

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

常量池技術詳解

   1) java中基本型別的包裝類的大部分都實現了常量池技術,這些類是Byte,Short,Integer,Long,Character,Boolean,另外兩種浮點數型別的包裝類則沒有實現。

   2) Byte,Short,Integer,Long,Character這5種整型的包裝類也只是在對應值小於等於127時才可使用物件池

   下面我們主要使用Long型別來進行講解吧。

 首先我們先寫一個測試類:

 LongTypeTest.java

package Test;

public class LongTypeTest {
    
    public static void main(String[] args) {
        long longParam = 50L;
        Long longParam2 = 50L;
    }
}
View Code

我們透過javac命令編譯後,用DJ Java Decompiler開啟,選擇View->Bytecode View,得到如下:

注意:反編譯後也可以用命令javap -c LongTypeTest > LongTypeTest.bc,在CMD下用type LongTypeTest.bc檢視也可以。

 1 // Decompiled by DJ v3.12.12.96 Copyright 2011 Atanas Neshkov  Date: 2013/9/15 17:06:18
 2 // Home Page: http://members.fortunecity.com/neshkov/dj.html  http://www.neshkov.com/dj.html - Check often for new version!
 3 // Decompiler options: packimports(3) disassembler 
 4 // Source File Name:   LongTypeTest.java
 5 
 6 package Test;
 7 
 8 
 9 public class LongTypeTest
10 {
11 
12     public LongTypeTest()
13     {
14     //    0    0:aload_0         
15     //    1    1:invokespecial   #8   <Method void Object()>
16     //    2    4:return          
17     }
18 
19     public static void main(String args[])
20     {
21     //    0    0:ldc2w           #16  <Long 50L>
22     //    1    3:lstore_1        
23     //    2    4:ldc2w           #16  <Long 50L>
24     //    3    7:invokestatic    #18  <Method Long Long.valueOf(long)>
25     //    4   10:astore_3        
26     //    5   11:return          
27     }
28 }
View Code

 從第24行,我們可以看到,使用包裝類初始化的時候,呼叫的是Long類中的valueOf方法,下面我們看看,Long類中的該方法是怎樣的。

1 public static Long valueOf(long l) {
2         final int offset = 128;
3         //當 l >= -128 && l <= 127 時,返回常量池中快取的資料
4         if (l >= -128 && l <= 127) { // will cache
5             return LongCache.cache[(int)l + offset];
6         }
7         //否則初始化一個新的Long物件
8         return new Long(l);
9     }
View Code

從程式碼中看出,當 l 的值小於127的時候,將會呼叫LongCache.cache()中獲取常量池中的數值。其中,LongCache是一個內部類

 1 //Long類中的私有類
 2     private static class LongCache {
 3     //私有的構造方法,不允許初始化
 4         private LongCache(){}
 5     //static final型別,它的值在編譯期間將會確定下來並且被儲存到常量池中
 6         static final Long cache[] = new Long[-(-128) + 127 + 1];
 7     //靜態程式碼塊,為cache陣列賦值
 8         static {
 9             for(int i = 0; i < cache.length; i++)
10                 cache[i] = new Long(i - 128);
11         }
12     }
View Code

其他Byte,Short,Integer,Long,Character,Boolean都是差不多的,具體就不在此重複講了。

我們在Double中的valueOf中我們可以看到原始碼是這樣子的:

1  public static Double valueOf(double d) {
2      //直接初始化並返回一個Double物件
3      return new Double(d);
4  }
View Code

 Float亦是如此。


這裡需要注意一下基本型別的包裝型別的大小。因為這種包裝型別已經成為物件了,因此需要把他們作為物件來看待。包裝型別的大小至少是12byte(宣告一個空Object至少需要的空間),而且12byte沒有包含任何有效資訊,同時,因為Java物件大小是8的整數倍,因此一個基本型別包裝類的大小至少是16byte這個記憶體佔用是很恐怖的,它是使用基本型別的N倍(N>2),有些型別的記憶體佔用更是誇張(隨便想下就知道了)。因此,可能的話應儘量少使用包裝類。在JDK5.0以後,因為加入了自動型別裝換,因此,Java虛擬機器會在儲存方面進行相應的最佳化。

    //int型別會自動轉換為Integer型別
    int m = 12;
    Integer in = m;
    //Integer型別會自動轉換為int型別
    int n = in;

引用型別


物件引用型別分為強引用軟引用弱引用虛引用
強引用:就是我們一般宣告物件是時虛擬機器生成的引用,強引用環境下,垃圾回收時需要嚴格判斷當前物件是否被強引用,如果被強引用,則不會被垃圾回收
軟引用:軟引用一般被做為快取來使用。與強引用的區別是,軟引用在垃圾回收時,虛擬機器會根據當前系統的剩餘記憶體來決定是否對軟引用進行回收。如果剩餘記憶體比較緊張,則虛擬機器會回收軟引用所引用的空間;如果剩餘記憶體相對富裕,則不會進行回收。換句話說,虛擬機器在發生OutOfMemory時,肯定是沒有軟引用存在的。
弱引用:弱引用與軟引用類似,都是作為快取來使用。但與軟引用不同,弱引用在進行垃圾回收時,是一定會被回收掉的,因此其生命週期只存在於一個垃圾回收週期內。
強引用不用說,我們系統一般在使用時都是用的強引用。而“軟引用”和“弱引用”比較少見。他們一般被作為快取使用,而且一般是在記憶體大小比較受限的情況下做為快取。因為如果記憶體足夠大的話,可以直接使用強引用作為快取即可,同時可控性更高。因而,他們常見的是被使用在桌面應用系統的快取。

 

相關文章