一篇文章寫明白Java的值傳遞
先從一道面試題說起:
以下程序輸出結果是什麼:
public class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public void setLocation(int x, int y) {
this.x = x;
this.y = y;
}
private static void modifyPoint(Point p1, Point p2) {
Point tmpPoint = p1;
p1 = p2;
p2 = tmpPoint;
p1.setLocation(5, 5);
p2 = new Point(5, 5);
}
public static void main(String[] args) {
Point p1 = new Point(0, 0);
Point p2 = new Point(0, 0);
modifyPoint(p1, p2);
System.out.println("[" + p1.x + "," + p1.y + "],[" + p2.x + "," + p2.y + "]");
}
}
答案是: [0,0],[5,5]
值與引用
爲了糾正值傳遞和引用傳遞的一些誤解,此處探討的並不是值類型和引用類型,而是賦值操作時對各部分的名稱。
以上面面試題爲例,有 Point p1 = new Point(0, 0);
變量p1裏存儲着實際對象的地址,一般稱這種變量爲"引用",引用指向實際對象,我們稱實際對象爲該引用的值;賦值操作符=實際上做的就是將引用指向值的地址的工作,如果我們有p1 = new Point(3,3);
的話,情形就是這樣:
我們要注意到,在堆中的對象Print(0,0)
並沒有發生改變,改變的只有引用_p1_ 指向的地址。
值與引用就是以上這些,而值傳遞和引用傳遞,和這些一點關係也沒有。
值傳遞和引用傳遞
我們可以找到網上資料對值傳遞的定義,大多是這樣的:
在方法調用時,傳入方法內部的是實參引用的拷貝,因此對形參的任何操作都不會影響到實參。
這句話本身對值傳遞的定義是比較準確的,但由於概念盲區造成的前後矛盾,讓我們理解起來產生了歧義,概況起來歧義有三:
- 既然傳遞的是引用,那麼應該是引用傳遞纔對,爲什麼叫值傳遞;
- 引用本身也是值,本質就是個指針,所以所有傳遞都是值傳遞;
- 大部分會被上述概念的後半句“形參的任何操作都不會影響到實參”誤導,因爲我們很容易就可以做到使用形參改變實參,比如上述面試題中的
p1.setLocation(5, 5);
或下面代碼:
public static void main(String[] args) {
List<String> colorList = new ArrayList<>();
colorList.add("BLUE");
colorList.add("RED");
colorList.add("GRAY");
System.out.println(colorList);
removeFirst(colorList);
System.out.println(colorList);
}
private static void removeFirst(List colorList) {
if (!colorList.isEmpty()) colorList.remove(0);
}
// [BLUE, RED, GRAY]
// [RED, GRAY]
很多時候我們對值傳遞有疑惑,本質原因是沒有弄明白值傳遞和引用傳遞到底是在描述什麼,我們錯以爲它們的名字是自解釋的:傳遞的是值就是值傳遞,傳遞的是引用就是引用傳遞,Java是值傳遞意味着java在方法調用時傳遞的是值。這就是我們上面說的概念盲區,對值傳遞和引用轉遞的正確解釋其實是這樣的:
值傳遞(Call by value)和引用傳遞(Call by reference),描述的是函數調用時參數的求值策略(Evaluation strategy),是對調用函數時,求值和取值方式的描述,而非傳遞的內容。
值與引用描述了兩種內存分配方式,值在堆上分配,引用在棧上分配(在這裏要區分值類型和引用類型)。而值傳遞和引用傳遞描述的則是參數求值策略,兩者之間不存在任何依賴關係。
綜上,我們可以得出Java是值傳遞這種說法也是不準確的,完整的說法應該是 java在函數調用時採用的求值策略爲值傳遞。 嚴格意義上講,求值策略也不僅僅有值傳遞和引用傳遞,還有 [共享對象傳遞(Call by sharing)]
和[值-返回傳遞(Call by copy-restore)]
,但這些概念對於我們理解Java值傳遞並無益處,如果感興趣可以點擊鏈接參見詳細。
理清了值傳遞和引用傳遞所描述的具體內容,我們現在來看關於Java值傳遞概念的第三點歧義:形參的任何操作都不會影響到實參。以上述_colorList
_ 代碼爲例,我們已經知道值傳遞這種求值策略在函數調用時會把實參引用的拷貝傳入函數內,所以在調用removeFirst()
方法時有:
形參colorList’作爲實參colorList的拷貝,它們寄存着一樣的地址,都指向堆中的同一個實例 ColorList [BLUE, RED, GRAY]。
我們在removeFirst()
方法內操作的是形參colorList’。但是剛剛說到,無論是形參還是實參,它們都只會存儲實例對象的地址,真正的對象仍是堆中它們共同指向的_ColorList 。而我們在removeFirst()
方法內調用的remove()
,是實例對象_ColorList_ 本身提供的可以改變自身的函數。所以,當removeFirst()
方法內執行colorList'.remove(0)
後,形參colorList’ 所指向的實例對象_ColorList_ 就變成了_ColorList[RED, GRAY]_ ,而實參colorList仍然指向了這個實例對象,所以當方法調用完成後,實例對象改變了。
這樣看來,關於“形參的任何操作都不會影響到實參”確實是不嚴謹的,那麼這句話在什麼情況下生效呢,我們將removeFirst()
方法改成如下代碼:
private static void removeFirst(List colorList) {
//if (!colorList.isEmpty()) colorList.remove(0);
if (!colorList.isEmpty())
colorList = colorList.subList(1, colorList.size());
}
//再次運行的結果爲:
//[BLUE, RED, GRAY]
//[BLUE, RED, GRAY]
在這個新的removeFirst()
方法中有:
按照值傳遞的求值策略規定,傳入實參引用的拷貝這一步沒變。在“值與引用”小節中我們說到 賦值操作符的實際工作是將引用指向值,在這個removeFirst()
方法中我們將形參_colorList’_ 的引用做了重賦值操作,所以現在它指向了一個新的實例_ColorList [ RED, GRAY]_ ,但是隨着removeFirst()
方法執行完畢後退出,形參colorList’也會被回收,而實參colorList指向的_ColorList [BLUE, RED, GRAY]_ 並沒有發生改變(變的是形參指向的實例,但是在方法退出後,方法參數_colorList’_ 就會被回收,而它指向的實例也就會變成垃圾對象)。
關於值傳遞概念的三點歧義就解釋完畢了,我們在這裏可以給出java值傳遞一個正確完整的概念了:
java 使用的是一種名爲值傳遞的求值策略,這種策略在傳值過程中會複製實參的引用,並將這份拷貝傳入到方法形參中,所以對形參的任何重賦值操作都不會對實參產生影響。
在下一小節中我們會解析開篇的面試題,如果你對Java值傳遞仍然理解不好,我會在下一節中分享一個技巧幫助你更徹底的弄明白Java值傳遞。
面試題解析
回到開篇的面試題,在modifyPoint()
方法中,頭三行代碼使用了一個臨時變量_tmpPoint_ 互換了形參_p1_ 和_p2_ 的值,所以在方法內部,形參_p1_ 實際上指向了實參_p2_ ,形參_p2_ 指向了實參_p1_ :
Point tmpPoint = p1;
p1 = p2;
p2 = tmpPoint;
關於Java的值傳遞策略,有一種比較取巧的理解,就是完全可以把函數的傳參過程等同於賦值操作符=來理解,我們在調用modifyPoint(p1,p2)時,可以理解在方法內部有形參p1’=實參p1,形參p2’=實參p2,這樣我們在方法裏操作的p1’和p2’實際只是個臨時變量,它的生命週期僅限於方法裏。
在形參_p1’_ 與形參_p2’_ 互換後,堆棧信息簡易表示爲:
然後調用了p1'.setLocation(5, 5);
我們在上節說過,如果實例對象本身提供了改變自身的方法,那麼在形參調用該方法後也會改變實參的,因爲它們都指向了同一個實例,所以這時實參_p2_ 也變爲_Point[5.5]_ 。
代碼走到下一行p2' = new Point(5, 5);
基於整篇文章的論述,這一行我們可以直接跳過不管了,因爲形參的重賦值操作不會影響到實參。最後的堆棧信息如下:
所以最終答案顯而易見:[0,0],[5,5]