標籤:
從記憶體的角度看棧和堆!
我們介紹了函數的基本概念,在最後我們提到了一個系統異常java.lang.StackOverflowError,棧溢出錯誤,要理解這個錯誤,我們需要理解函數調用的實現機制。本節就從概念性模型的角度談談它的基本原理。
我們之前談過程式執行的基本原理:CPU有一個指令指標,指向下一條要執行的指令,要麼順序執行,要麼進行跳轉(條件跳轉或無條件跳轉)。
基本上,這依然是成立的,程式從main函數開始順序執行,函數調用可以看做是一個無條件跳轉,跳轉到對應函數的指令處開始執行,碰到return語句或者函數結尾的時候,再執行一次無條件跳轉,跳轉會調用方,執行調用函數後的下一條指令。
但這裡面有幾個問題:
- 參數如何傳遞?
- 函數如何知道返回到什麼地方?在if/else, for中,跳轉的地址都是確定的,但函數自己並不知道會被誰調用,而且可能會被很多地方調用,它並不能提前知道執行結束後返回哪裡。
- 函數結果如何傳給調用方?
解決思路是使用記憶體來存放這些資料,函數調用方和函數自己就如何存放和使用這些資料達成一個一致的協議或約定。這個約定在各種電腦系統中都是類似的,存放這些資料的記憶體有一個相同的名字,叫棧。
棧是一塊記憶體,但它的使用有特別的約定,一般是先進後出,類似於一個桶,往棧裡放資料,我們稱為入棧,最下面的我們稱為棧底,最上面的我們稱為棧頂,從棧頂拿出資料,通常稱為出棧。棧一般是從高位地址向低位地址擴充,換句話說,棧底的記憶體位址是最高的,棧頂的是最小的。
電腦系統主要使用棧來存放函數調用過程中需要的資料,包括參數、返回地址,函數內定義的局部變數也放在棧中。電腦系統就如何在棧中存放這些資料,調用者和函數如何協作做了約定。傳回值不太一樣,它可能放在棧中,但它使用的棧和局部變數不完全一樣,有的系統使用CPU內的一個儲存空間儲存傳回值,我們可以簡單認為存在一個專門的傳回值儲存空間。 main函數的相關資料放在棧的最下面,每調用一次函數,都會將相關函數的資料入棧,調用結束會出棧。
以上描述可能有點抽象,我們通過一個例子來說明。
一個簡單的例子
我們從一個簡單例子開始,下面是代碼:
1 public class Sum { 2 3 public static int sum(int a, int b) { 4 int c = a + b; 5 return c; 6 } 7 8 public static void main(String[] args) { 9 int d = Sum.sum(1, 2);10 System.out.println(d);11 }12 13 }
這是一個簡單的例子,main函數調用了sum函數,計算1和2的和,然後輸出計算結果,從概念上,這是容易理解的,讓我們從棧的角度來討論下。
當程式在main函數調用Sum.sum之前,棧的情況大概是這樣的:
主要存放了兩個變數args和d。在程式執行到Sum.sum的函數內部,準備返回之前,即第5行,棧的情況大概是這樣的:
我們解釋下,在main函數調用Sum.sum時,首先將參數1和2入棧,然後將返回地址(也就是調用函數結束後要執行的指令地址)入棧,接著跳轉到sum 函數,在sum函數內部,需要為局部變數c分配一個空間,而參數變數a和b則直接對應於入棧的資料1和2,在返回之前,傳回值儲存到了專門的傳回值儲存空間 中。
在調用return後,程式會跳轉到棧中儲存的返回地址,即main的下一條指令地址,而sum函數相關的資料會出棧,從而又變回下面這樣:
main的下一條指令是根據函數傳回值給變數d賦值,傳回值從專門的傳回值儲存空間中獲得。
函數執行的基本原理,簡單來說就是這樣。但有一些需要介紹的點,我們討論一下。
變數的生命週期
我們在第一節的時候說過,定義一個變數就會分配一塊記憶體,但我們並沒有具體談什麼時候分配記憶體,具體分配在哪裡,什麼時候釋放記憶體。
從以上關於棧的描述我們可以看出,函數中的參數和函數內定義的變數,都分配在棧中,這些變數只有在函數被調用的時候才分配,而且在調用結束後就被釋放了。但這個說法主要針對基礎資料型別 (Elementary Data Type),接下來我們談數組和對象。
數組和對象
對於數組和物件類型,我們介紹過,它們都有兩塊記憶體,一塊存放實際的內容,一塊存放實際內容的地址,實際的內容空間一般不是分配在棧上的,而是分配在堆(也是記憶體的一部分,後續文章介紹)中,但存放地址的空間是分配在棧上的。
我們來看個例子,下面是代碼:
public class ArrayMax { public static int max(int min, int[] arr) { int max = min; for(int a : arr){ if(a>max){ max = a; } } return max; } public static void main(String[] args) { int[] arr = new int[]{2,3,4}; int ret = max(0, arr); System.out.println(ret); }}
這個程式也很簡單,main函數建立了一個數組,然後調用函數max計算0和數組中元素的最大值,在程式執行到max函數的return語句之前的時候,記憶體中棧和堆的情況大概是這樣的:
對於數組arr,在棧中存放的是實際內容的地址0x1000,存放地址的棧空間會隨著入棧分配,出棧釋放,但存放實際內容的堆空間不受影響。
但說堆空間完全不受影響是不正確的,在這個例子中,當main函數執行結束,棧空間沒有變數指向它的時候,Java系統會自動進行記憶體回收,從而釋放這塊空間。
遞迴調用
我們再通過棧的角度來理解一下遞迴函式的調用過程,代碼如下:
public static long factorial(int n){ long result = 1; for(int i=1; i<=n; i++){ result*=i; } return result;}
在factorial第一次被調用的時候,n是4,在執行到 n*factorial(n-1),即4*factorial(3)之前的時候,棧的情況大概是:
注意傳回值儲存空間是沒有值的,在調用factorial(3)後,棧的情況變為了:
棧的深度增加了,傳回值儲存空間依然為空白,就這樣,每遞迴調用一次,棧的深度就增加一層,每次調用都會分配對應的參數和局部變數,也都會儲存調用的返回地址,在調用到n等於0的時候,棧的情況是:
這個時候,終於有傳回值了,我們將factorial簡寫為f。f(0)的傳回值為1,f(0)返回到f(1),f(1)執行1*f(0),結果也是1,然 後返回到f(2),f(2)執行2*f(1),結果是2,然後接著返回到f(3),f(3)執行3*f(2),結果是6,然後返回到f(4),執行 4*f(3),結果是24。
以上就是遞迴函式的執行過程,函數代碼雖然只有一份,但在執行的過程中,每調用一次,就會有一次入棧,產生一份不同的參數、局部變數和返回地址。
函數調用的成本
從函數調用的過程我們可以看出,調用是有成本的,每一次調用都需要分配額外的棧空間用於儲存參數、局部變數以及返回地址,需要進行額外的入棧和出棧操作。
在遞迴調用的情況下,如果遞迴的次數比較多,這個成本是比較可觀的,所以,如果程式可以比較容易的改為別的方式,應該考慮別的方式。
另外,棧的空間不是無限的,一般正常調用都是沒有問題的,但像上節介紹的例子,棧空間過深,系統就會拋出錯誤,java.lang.StackOverflowError,即棧溢出。
小結
本節介紹了函數調用的基本原理,函數調用主要是通過棧來儲存相關資料的,系統就函數調用者和函數如何使用棧做了約定,傳回值我們簡化認為是通過一個專門的傳回值儲存空間儲存的,我們主要從概念上介紹了其基本原理,忽略了一些細節。
在本節中,我們假設函數的修飾符都是public static,如果不是static的,則會略有差別,後續文章會介紹。
我們談到,在Java中,函數必須放在類中,目前我們簡化認為類只是函數的容器,但類在Java中遠不止有這個功能,它還承載了很多概念和思維方式,在接下來的幾節中,讓我們一起來探索類的世界。
參考:http://www.cnblogs.com/swiftma/p/5468104.html
電腦程式的思維邏輯- 函數調用的基本原理