本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結
棧
上節我們介紹了函式的基本概念,在最後我們提到了一個系統異常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賦值,返回值從專門的返回值儲存器中獲得。
函式執行的基本原理,簡單來說就是這樣。但有一些需要介紹的點,我們討論一下。
變數的生命週期
我們在第一節的時候說過,定義一個變數就會分配一塊記憶體,但我們並沒有具體談什麼時候分配記憶體,具體分配在哪裡,什麼時候釋放記憶體。
從以上關於棧的描述我們可以看出,函式中的引數和函式內定義的變數,都分配在棧中,這些變數只有在函式被呼叫的時候才分配,而且在呼叫結束後就被釋放了。但這個說法主要針對基本資料型別,接下來我們談陣列和物件。
陣列和物件
對於陣列和物件型別,我們介紹過,它們都有兩塊記憶體,一塊存放實際的內容,一塊存放實際內容的地址,實際的內容空間一般不是分配在棧上的,而是分配在堆(也是記憶體的一部分,後續文章介紹)中,但存放地址的空間是分配在棧上的。
我們來看個例子,下面是程式碼:
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 int factorial(int n) {
if(n==0){
return 1;
}else{
return n*factorial(n-1);
}
}
public static void main(String[] args) {
int ret = factorial(4);
System.out.println(ret);
}
複製程式碼
在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中遠不止有這個功能,它還承載了很多概念和思維方式,在接下來的幾節中,讓我們一起來探索類的世界。
未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。原創文章,保留所有版權。