本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連接 html
上節咱們介紹了函數的基本概念,在最後咱們提到了一個系統異常java.lang.StackOverflowError,棧溢出錯誤,要理解這個錯誤,咱們須要理解函數調用的實現機制。本節就從概念模型的角度談談它的基本原理。java
咱們以前談過程序執行的基本原理:CPU有一個指令指示器,指向下一條要執行的指令,要麼順序執行,要麼進行跳轉(條件跳轉或無條件跳轉)。編程
基本上,這依然是成立的,程序從main函數開始順序執行,函數調用能夠看作是一個無條件跳轉,跳轉到對應函數的指令處開始執行,碰到return語句或者函數結尾的時候,再執行一次無條件跳轉,跳轉回調用方,執行調用函數後的下一條指令。數組
但這裏面有幾個問題:微信
解決思路是使用內存來存放這些數據,函數調用方和函數本身就如何存放和使用這些數據達成一個一致的協議或約定。這個約定在各類計算機系統中都是相似的,存放這些數據的內存有一個相同的名字,叫棧。函數
棧是一塊內存,但它的使用有特別的約定,通常是先進後出,相似於一個桶,往棧裏放數據,咱們稱爲入棧,最下面的咱們稱爲棧底,最上面的咱們稱爲棧頂,從棧頂拿出數據,一般稱爲出棧。棧通常是從高位地址向低位地址擴展,換句話說,棧底的內存地址是最高的,棧頂的是最小的。spa
計算機系統主要使用棧來存放函數調用過程當中須要的數據,包括參數、返回地址,函數內定義的局部變量也放在棧中。計算機系統就如何在棧中存放這些數據,調用者和函數如何協做作了約定。返回值不太同樣,它可能放在棧中,但它使用的棧和局部變量不徹底同樣,有的系統使用CPU內的一個存儲器存儲返回值,咱們能夠簡單認爲存在一個專門的返回值存儲器。 main函數的相關數據放在棧的最下面,每調用一次函數,都會將相關函數的數據入棧,調用結束會出棧。3d
以上描述可能有點抽象,咱們經過一個例子來講明。code
咱們從一個簡單例子開始,下面是代碼:cdn
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編程及計算機技術的本質。原創文章,保留全部版權。