淺談 C 語言和 Java 的記憶體管理(六)

<< 上一篇

獅子的鬃毛--Java 的物件傳遞是 Call By Reference

傳說拔到獅子的鬃毛,你的 Java 程式行為就會符合預期……別再相信沒有根據的說法了

在上一篇當中,我們提到了 C 語言當中,函式呼叫時他的參數存放的位置,以及 C 語言在傳遞參數給函式時的做法,也知道了 C 語言所有的參數傳遞都是 Call By Value,會把傳進函式的值複製一份到 Stack 上該函式的區域。

接下來的這一篇,我們就來看看在 Java 裡這些事情又是如何運作。

事實上,如果你去翻網路上的文章,甚至是一些書,他們很可能會在討論 Java 的參數傳遞時,告訴你類似下面的內容:

Java 的物件傳遞是 Call By Reference

拜託,下次當你看到一本寫 Java 的書告訴你 Java 的物件傳遞是 Call By Reference 的時候,請直接把那本書燒了;如果這是教你寫 Java 的老師告訴你的,就請他別再開 Java 的課來誤人子弟了。

因為在 Programming Language 理論理,Call By Reference 有很嚴謹的定義,而 Java 的參數傳遞從頭到尾都只有 Call By Value 這種方式。

所以在看這篇文章正文之前,請先跟著我唸五遍:

  1. 從盤谷開天闢地開始,Java 的參數傳遞就只有 Call By Value 一種
  2. 從盤谷開天闢地開始,Java 的參數傳遞就只有 Call By Value 一種
  3. 從盤谷開天闢地開始,Java 的參數傳遞就只有 Call By Value 一種
  4. 從盤谷開天闢地開始,Java 的參數傳遞就只有 Call By Value 一種
  5. 從盤谷開天闢地開始,Java 的參數傳遞就只有 Call By Value 一種

很多人會覺得 Java 傳遞參數時的行為很複雜難懂,就是因為誤以為 Java 的參數傳遞有很多種。

但當你看完這篇之後,就會發現不管你傳的是什麼東西,Java 的行為通通是相同的--所以請僅記,Java 的參數傳遞方法就只有 Call By Value 這唯一的一種。

一樣是整數的交換的函式

上一篇我們的第一個例子,講的是 C 語言的整數交換的範例,如果把他改寫成 Java 版的話,就會像下面這樣:

public class SwapInt
{
    public static void swap(int x, int y)
    {
        int tmp = x;
        x = y;
        y = tmp;
    }

    public static void main(String [] args)
    {
        int x = 3;
        int y = 5;

        swap(x, y);

        System.out.println("x = " + x + ", y = " + y);
    }
}

如果你是從這系列的第一篇看起,一直看到現在,你現在應該知道兩件事了:

  1. 區域變數是存在 Stack 上
  2. 因為 Java 是 Call By Value,所以會把傳入的值複製一份到 swap() 的函式在 Stack 上被配置到的空間。

所以現在的你,也應該馬上可以回答出來,這個程式的執行結果是:

x = 3, y = 5

因為如果我們再次把整個執行過程的記憶體裡 Stack 的狀態畫出來的話,會發現他和 C 語言版的 swap() 一模一樣:

Stack Status
Stack Status
Stack Status
Stack Status
Stack Status

如果我就是要更動原本的變數的內容呢?

很簡單--Java 做不到!

因為 Java 當中的 Reference(實際上就是 Pointer),就只能指到 Heap 上的 Java 物件,而沒有辦法指到位於 Stack 上的變數。

在這樣的情況下,而 Java 的參數傳遞方式又只有 Call By Value 這一種,所以你永遠不可能更動到 main() 裡的 xy 的值。

可是書上都說用物件包起來就好耶?

我知道,你一定會跟我說:

「嗨,我只要把這個整數用個物件包起來,這樣就可以交換 main() 裡的 xy 啦!」

聽起來好像沒錯,所以我們來實作一個把整數包在物件裡的版本好了:

public class MyObject
{
    int value;

    public MyObject(int value)
    {
        this.value = value;
    }

    public static void swap(MyObject x, MyObject y)
    {
        int tmp = x.value;
        x.value = y.value;
        y.value = tmp;
    }

    public static void main(String [] args)
    {
        MyObject x = new MyObject(3);
        MyObject y = new MyObject(5);

        swap(x, y);

        System.out.println("x = " + x.value + ", y = " + y.value);
    }
}

你看,現在印出來的就是 x = 5, y = 3 啦!我們把兩個整數交換了啊!

沒錯,他的確印出了 x = 5, y = 3 的訊息,可是你有仔細想過你改到的到底是什麼東西嗎?現在就讓我們來認真瞧瞧看這段程式發生了什麼事情。

首先要回答的就是,當程式一開始跑,執行到 main() 裡的第二行時,記憶體裡的狀態到底是怎樣?

現在的你應該已經可以很容易回答出來了--在 Stack 上的 main() 裡面,有著 x 和 y 兩個變數,存的是 Heap 上的兩個 MyObject 物件所在的記憶體地址,所以可以畫出像下面的圖:

Stack Status

接著,我們一樣要思考的是 swap(MyObject x, MyObject y) 裡的 xy 是什麼,又放在哪裡?

現在的我們,已經知道 xy 既然不是那八個 primitive type,那他們就一定是存放記憶體地址的 Reference,再加上我們也知道 Java 只有 Call By Value,所以這兩個變數一定是放在 Stack 上!

有了這樣的認知,就可以畫出像下面這樣的圖:

Stack Status

那現在 swap() 裡的 xy 到底會被放進什麼呢?還記得 Java 是 Call By Value,而我們傳進去的 xy 存的又是「存記憶體地址」嗎?

沒錯!在呼叫 swap(x, y) 的時候,會把 main() 裡的 x 和 y 所存的記憶體地址,複製一份到 swap() 上的 xy 當中,所以現在我們知道當進入 swap() 函式的時候,記憶體狀態如下所示:

Stack Status

接著 int tmp = x.value 這一行,會把 swap() 上 x 指到的物件裡的 value 變數的值複製一份到 tmp 裡面:

Stack Status

接著執行 x.value = y.value,此時的記憶體狀態如下:

Stack Status

之後的 y.value = tmp 再把 swap() 上的 tmp 指定給 swap() 上的 y 所指到的物件當中的 value 成員變數:

Stack Status

最後 swap() 函式返回,Stack 上配置給 swap() 函式用的空間被清空:

Stack Status

這個時候,你會發現如果我們在 main() 裡把 x.valuey.value 把 x 和 y 印出來,就會看起來好像 x 和 y 兩個變數交換了!

但是請注意一件事--

在上面的五張圖當中,main() 裡的 ``x`` 和 ``y`` 的內容從來都沒被更改過,一直都分別是 0x00010 和 0x00011!

你交換的是 x 和 y 所指到的物件的狀態,而不像 C 語言的指標那樣,直接改變了 Stack 上的 x 和 y 這兩個變數本身自己的內容!

Java 傳遞的不是「物件」

你可能常常會聽到有人告訴你「Java 在傳遞物件時的行為是……blah blah blah」之類的,但實際上這句話有非常大的問題--Java 的參數傳遞不會傳物件!

嚴格來說,在 Java 裡你只能傳以下九種資料給函式(方法):

  • byte
  • short
  • int
  • long
  • float
  • double
  • char
  • boolean
  • 記憶體地址(也就是 Java 當中的 Reference 的「值 / Value」)

Note

這裡最主要的觀念,是 Java 裡的 Reference 實際上就只是一個存放記憶體地址的東西,他和一般的整數並沒有什麼不同,都是一種「數值」,而我們在做函式傳遞時,是把那個「數值」複製一份到新的地方。

重申一次!Java 不會傳遞物件!而且參數的傳遞方式就只有 Call By Value 這一種!也就是當你把上面的九種資料傳給某個函式時,他們的內容都會被複製一份到 Stack 上該函式所屬的空間當中。

接下來,我們來看看下面的程式會發生什麼事:

public class MyObject
{
    int value;

    public MyObject(int value)
    {
        this.value = value;
    }

    public static void test(MyObject x)
    {
        x = new MyObject(10);
    }

    public static void main(String [] args)
    {
        MyObject x = new MyObject(3);

        test(x);

        // 請問這一行會印出什麼
        System.out.println("x = " + x.value);
    }
}

同樣的,要回答這個問題的關鍵,是 x = new MyObject(10) 這行,到底在記憶體裡動了什麼手腳。

而現在的我們已經知道:

  1. testx 是放在 Stack 屬於 test() 自己的區塊上,存的是記憶體地址
  2. new MyObject(10) 會在 Heap 上產生一個新的物件,並返回他的記憶體地址

所以如果我們把整個流程畫出來,就會如下所示:

Stack Status
Stack Status
Stack Status
Stack Status

現在,你可以知道為什麼印出來的會是 x = 3 而不是 x = 10 了吧?

同時你也應該會發現,這樣子做的話,實際上會在 Heap 上產生一個垃圾物件,因為只要你一離開了 test() 函式,就沒有人可以尋著任何 Reference 拿到你在 test() 裡 new 出來的 MyObject 囉。

傳 Java 陣列時發生什麼事

Java 的陣列到底是什麼

和 C 語言一樣,當我們在看 Java 中丟一個陣列給函式會發生什麼事的時候,要先了解 Java 的陣列是什麼才行。

public class Test
{
    public static void main (String [] args) {
        int [] x = {1, 2, 3};
    }
}

注意!問題來了!上面的 Java 程式裡的 x 是什麼?!

「我知道,是一個長度為 3 的整數陣列!」

錯!錯!錯!老師在講你都沒有在聽嘛--我們之前就看過 Java 對於「物件」這兩個字的定義了:

An object is a class instance or an array.

沒錯,在 Java 中陣列是個物件,而物件是存在 Heap 上的,然後在 Java 裡要操作 Heap 上的物件就只能依靠名叫 Reference 的 Pointer,所以這個問題的標準答案應該是:

「x 是一個指向長度為 3 的整數陣列的 Reference」

換句話說,在這個程式裡,我們的記憶體長相如下:

Stack Status

傳陣列到函式裡

只要你弄懂了上面所說的 Java 的陣列其實是物件,和上述的程式碼裡的 x 實際上是 Reference 後,其參數的傳遞好像就沒啥好講的了,因為前面都提過了。

例如當你看到以下的程式碼:

public class Test
{
    public static void addByOne(int [] y)
    {
        y[0] = y[0] + 1;
        y[1] = y[1] + 1;
        y[2] = y[2] + 1;
    }

    public static void main (String [] args) {
        int [] x = {1, 2, 3};

        addByOne(x);

        System.out.println("x[0] = " + x[0]);
        System.out.println("x[1] = " + x[1]);
        System.out.println("x[2] = " + x[2]);
    }
}

應該可以馬上一步一步畫出記憶體裡的狀態圖了:

Stack Status
Stack Status
Stack Status
Stack Status
Stack Status
Stack Status

然後你也應該很容易就回答得出來,這個程式印出來的會是以下的結果了:

x[0] = 2
x[1] = 3
x[2] = 4

小結

在這一篇裡面,我們提到了 Java 的參數傳遞方式,以對於 Java 的參數傳遞有多種模式這個常見的誤解。

而要了解這一切,最重要的一個概念就是 Java 只有 Call By Value,並且你能傳的就只有八種 Primitive Type 再加上記憶體位址這九種資料而已。

然而在這篇文章當中,我們討論的都僅止於 static method 的參數傳遞而已,如果是 instance method 的話 Java 的行為又會是如何呢?這會是我們下一篇討論的重點。

下一篇 >>

回響