Java編程的邏輯 (12) - 函數調用的基本原理


本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》,由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買,京東自營鏈接http://item.jd.com/12299018.html



上節我們介紹了函數的基本概念,在最后我們提到了一個系統異常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編程及計算機技術的本質。原創文章,保留所有版權。

-----------

更多相關原創文章

計算機程序的思維邏輯 (9) - 條件執行的本質

計算機程序的思維邏輯 (10) - 強大的循環

計算機程序的思維邏輯 (11) - 初識函數


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM