在沒(méi)有垃圾收集的語(yǔ)言中,比如C++,必須特別關(guān)注內(nèi)存管理。對(duì)于每個(gè)動(dòng)態(tài)對(duì)象,必須要么實(shí)現(xiàn)引用計(jì)數(shù)以模擬 垃圾收集效果,要么管理每個(gè)對(duì)象的“所有權(quán)”――確定哪個(gè)類負(fù)責(zé)刪除一個(gè)對(duì)象。通常,對(duì)這種所有權(quán)的維護(hù)并沒(méi)有什么成文的規(guī)則,而是按照約定(通常是不成文的)進(jìn)行維護(hù)。盡管垃圾收集意味著Java開(kāi)發(fā)者不必太多地?fù)?dān)心內(nèi)存 泄漏,有時(shí)我們?nèi)匀恍枰獡?dān)心對(duì)象所有權(quán),以防止數(shù)據(jù)爭(zhēng)用(data races)和不必要的副作用。在這篇文章中,Brian Goetz 指出了一些這樣的情況,即Java開(kāi)發(fā)者必須注意對(duì)象所有權(quán)。請(qǐng)?jiān)?論壇上與作者及其他讀者共享您對(duì)本文的一些想法(您也可以在文章的頂部或底部點(diǎn)擊 討論來(lái)訪問(wèn)論壇)。
如果您是在1997年之前開(kāi)始學(xué)習(xí)編程,那么可能您學(xué)習(xí)的第一種編程語(yǔ)言沒(méi)有提供透明的垃圾收集。每一個(gè)new 操作必須有相應(yīng)的delete操作 ,否則您的程序就會(huì)泄漏內(nèi)存,最終內(nèi)存分配器(memory allocator )就會(huì)出故障,而您的程序就會(huì)崩潰。每當(dāng)利用 new 分配一個(gè)對(duì)象時(shí),您就得問(wèn)自己,誰(shuí)將刪除該對(duì)象?何時(shí)刪除?
別名, 也叫做 ...
內(nèi)存管理復(fù)雜性的主要原因是別名使用:同一塊內(nèi)存或?qū)ο缶哂?多個(gè)指針或引用。別名在任何時(shí)候都會(huì)很自然地出現(xiàn)。例如,在清單 1 中,在 makeSomething 的第一行創(chuàng)建的 Something 對(duì)象至少有四個(gè)引用:
- something 引用。
- 集合 c1 中至少有一個(gè)引用。
- 當(dāng) something 被作為參數(shù)傳遞給 registerSomething 時(shí),會(huì)創(chuàng)建臨時(shí) aSomething 引用。
- 集合 c2 中至少有一個(gè)引用。
清單 1. 典型代碼中的別名
Collection c1, c2;
public void makeSomething {
Something something = new Something();
c1.add(something);
registerSomething(something);
}
private void registerSomething(Something aSomething) {
c2.add(aSomething);
}
|
在非垃圾收集語(yǔ)言中需要避免兩個(gè)主要的內(nèi)存管理危險(xiǎn):內(nèi)存泄漏和懸空指針。為了防止內(nèi)存泄漏,必須確保每個(gè)分配了內(nèi)存的對(duì)象最終都會(huì)被刪除。 為了避免懸空指針(一種危險(xiǎn)的情況,即一塊內(nèi)存已經(jīng)被釋放了,而一個(gè)指針還在引用它),必須在最后的引用釋放之后才刪除對(duì)象。為滿足這兩條約束,采用一定的策略是很重要的。
為內(nèi)存管理而管理對(duì)象所有權(quán)
除了垃圾收集之外,通常還有其他兩種方法用于處理別名問(wèn)題: 引用計(jì)數(shù)和所有權(quán)管理。引用計(jì)數(shù)(reference counting)是對(duì)一個(gè)給定的對(duì)象當(dāng)前有多少指向它的引用保留有一個(gè)計(jì)數(shù),然后當(dāng)最后一個(gè)引用被釋放時(shí)自動(dòng)刪除該對(duì)象。在 C和20世紀(jì)90年代中期之前的多數(shù) C++ 版本中,這是不可能自動(dòng)完成的。標(biāo)準(zhǔn)模板庫(kù)(Standard Template Library,STL)允許創(chuàng)建“靈巧”指針,而不能自動(dòng)實(shí)現(xiàn)引用計(jì)數(shù)(要查看一些例子,請(qǐng)參見(jiàn)開(kāi)放源代碼 Boost 庫(kù)中的 shared_ptr 類,或者參見(jiàn)STL中的更加簡(jiǎn)單的 auto_ptr 類)。
所有權(quán)管理(ownership management) 是這樣一個(gè)過(guò)程,該過(guò)程指明一個(gè)指針是“擁有”指針("owning" pointer),而 所有其他別名只是臨時(shí)的二類副本( temporary second-class copies),并且只在所擁有的指針被釋放時(shí)才刪除對(duì)象。在有些情況下,所有權(quán)可以從一個(gè)指針“轉(zhuǎn)移”到另一個(gè)指針,比如一個(gè)這樣的方法,它以一個(gè)緩沖區(qū)作為參數(shù),該方法用于向一個(gè)套接字寫(xiě)數(shù)據(jù),并且在寫(xiě)操作完成時(shí)刪除這個(gè)緩沖區(qū)。這樣的方法通常叫做接收器 (sinks)。在這個(gè)例子中,緩沖區(qū)的所有權(quán)已經(jīng)被有效地轉(zhuǎn)移,因而進(jìn)行調(diào)用的代碼必須假設(shè)在被調(diào)用方法返回時(shí)緩沖區(qū)已經(jīng)被刪除。(通過(guò)確保所有的別名指針都具有與調(diào)用堆棧(比如方法參數(shù)或局部變量)一致的作用域(scope ),可以進(jìn)一步簡(jiǎn)化所有權(quán)管理,如果引用將由非堆棧作用域的變量保存,則通過(guò)復(fù)制對(duì)象來(lái)進(jìn)行簡(jiǎn)化。)
那么,怎么著?
此時(shí),您可能正納悶,為什么我還要討論內(nèi)存管理、別名和對(duì)象所有權(quán)。畢竟,垃圾收集是 Java語(yǔ)言的核心特性之一,而內(nèi)存管理是已經(jīng)過(guò)時(shí)的一件麻煩事。就讓垃圾收集器來(lái)處理這件事吧,這正是它的工作。那些從內(nèi)存管理的麻煩中解脫出來(lái)的人不愿意再回到過(guò)去,而那些從未處理過(guò)內(nèi)存管理的人則根本無(wú)法想象在過(guò)去倒霉的日子里――比如1996年――程序員的編程是多么可怕。
提防懸空別名
那么這意味著我們可以與對(duì)象所有權(quán)的概念說(shuō)再見(jiàn)了嗎?可以說(shuō)是,也可以說(shuō)不是。 大多數(shù)情況下,垃圾收集確實(shí)消除了顯式資源存儲(chǔ)單元分配(explicit resource deallocation)的必要(在以后的專欄中我將討論一些例外)。但是,有一個(gè)區(qū)域中,所有權(quán)管理仍然是Java 程序中的一個(gè)問(wèn)題,而這就是懸空別名(dangling aliases)問(wèn)題。 Java 開(kāi)發(fā)者通常依賴于這樣一個(gè)隱含的假設(shè),即假設(shè)由對(duì)象所有權(quán)來(lái)確定哪些引用應(yīng)該被看作是只讀的 (在C++中就是一個(gè) const 指針),哪些引用可以用來(lái)修改被引用的對(duì)象的狀態(tài)。當(dāng)兩個(gè)類都(錯(cuò)誤地)認(rèn)為自己保存有對(duì)給定對(duì)象的惟一可寫(xiě)的引用時(shí),就會(huì)出現(xiàn)懸空指針。發(fā)生這種情況時(shí),如果對(duì)象的狀態(tài)被意外地更改,這兩個(gè)類中的一個(gè)或兩者將會(huì)產(chǎn)生混淆。
一個(gè)貼切的例子
考慮清單 2 中的代碼,其中的 UI 組件保存有一個(gè) Point 對(duì)象,用于表示它的位置。當(dāng)調(diào)用 MathUtil.calculateDistance 來(lái)計(jì)算對(duì)象移動(dòng)了多遠(yuǎn)時(shí),我們依賴于一個(gè)隱含而微妙的假設(shè)――即 calculateDistance 不會(huì)改變傳遞給它的 Point 對(duì)象的狀態(tài),或者情況更壞,維護(hù)著對(duì)那些 Point 對(duì)象的一個(gè)引用(比如通過(guò)將它們保存在集合中或者將它們傳遞到另一個(gè)線程),然后這個(gè)引用將用于在 calculateDistance 返回后更改Point 對(duì)象的狀態(tài)。 在 calculateDistance的例子中,為這種行為擔(dān)心似乎有些可笑,因?yàn)檫@明顯是一個(gè)可怕的違背慣例的情況。但是,如果要說(shuō)將一個(gè)可變的對(duì)象傳遞給一個(gè)方法,之后對(duì)象還能夠毫發(fā)無(wú)損地返回來(lái),并且將來(lái)對(duì)于對(duì)象的狀態(tài)也不會(huì)有不可預(yù)料的副作用(比如該方法與另一個(gè)線程共享引用,該線程可能會(huì)等待5分鐘,然后更改對(duì)象的狀態(tài)),那么這只不過(guò)是一廂情愿的想法而已。
清單 2. 將可變對(duì)象傳遞給外部方法是不可取的
private Point initialLocation, currentLocation;
public Widget(Point initialLocation) {
this.initialLocation = initialLocation;
this.currentLocation = initialLocation;
}
public double getDistanceMoved() {
return MathUtil.calculateDistance(initialLocation, currentLocation);
}
. . .
// The ill-behaved utility class MathUtil
public static double calculateDistance(Point p1,
Point p2) {
double distance = Math.sqrt((p2.x - p1.x) ^ 2
+ (p2.y - p1.y) ^ 2);
p2.x = p1.x;
p2.y = p1.y;
return distance;
}
|
一個(gè)愚蠢的例子
大家對(duì)該例子明顯而普遍的反應(yīng)就是――這是一個(gè)愚蠢的例子――只是強(qiáng)調(diào)了這樣一個(gè)事實(shí),即對(duì)象所有權(quán)的概念在 Java 程序中依然存在,而且存在得很好,只是沒(méi)有說(shuō)明而已。calculateDistance 方法不應(yīng)該改變它的參數(shù)的狀態(tài),因?yàn)樗⒉弧皳碛小彼鼈儴D―當(dāng)然,調(diào)用方法擁有它們。因此說(shuō)不用考慮對(duì)象所有權(quán)。
下面是一個(gè)更加實(shí)用的例子,它說(shuō)明了不知道誰(shuí)擁有對(duì)象就有可能會(huì)引起混淆。再次考慮一個(gè)以Point 屬性 來(lái)表示其位置的 UI組件。 清單 3 顯示了實(shí)現(xiàn)存取器方法 setLocation 和 getLocation的三種方式。第一種方式是最懶散的,并且提供了最好的性能,但是對(duì)于蓄意攻擊和無(wú)意識(shí)的失誤,它有幾個(gè)薄弱環(huán)節(jié)。
清單 3. getters 和 setters的值語(yǔ)義以及引用語(yǔ)義
public class Widget {
private Point location;
// Version 1: No copying -- getter and setter implement reference
// semantics
// This approach effectively assumes that we are transferring
// ownership of the Point from the caller to the Widget, but this
// assumption is rarely explicitly documented.
public void setLocation(Point p) {
this.location = p;
}
public Point getLocation() {
return location;
}
// Version 2: Defensive copy on setter, implementing value
// semantics for the setter
// This approach effectively assumes that callers of
// getLocation will respect the assumption that the Widget
// owns the Point, but this assumption is rarely documented.
public void setLocation(Point p) {
this.location = new Point(p.x, p.y);
}
public Point getLocation() {
return location;
}
// Version 3: Defensive copy on getter and setter, implementing
// true value semantics, at a performance cost
public void setLocation(Point p) {
this.location = new Point(p.x, p.y);
}
public Point getLocation() {
return (Point) location.clone();
}
}
|
現(xiàn)在來(lái)考慮 setLocation 看起來(lái)是無(wú)意的使用 :
Widget w1, w2;
. . .
Point p = new Point();
p.x = p.y = 1;
w1.setLocation(p);
p.x = p.y = 2;
w2.setLocation(p);
|
或者是:
w2.setLocation(w1.getLocation());
|
在setLocation/getLocation存取器實(shí)現(xiàn)的版本 1 之下,可能看起來(lái)好像第一個(gè)Widget的 位置是 (1, 1) ,第二個(gè)Widget的位置是 (2, 2),而事實(shí)上,二者都是 (2, 2)。這可能對(duì)于調(diào)用者(因?yàn)榈谝粋€(gè)Widget意外地移動(dòng)了)和Widget 類(因?yàn)樗奈恢酶淖兞耍cWidget代碼無(wú)關(guān))來(lái)說(shuō)都會(huì)產(chǎn)生混淆。在第二個(gè)例子中,您可能認(rèn)為自己只是將Widget w2移動(dòng)到 Widget w1當(dāng)前所在的位置 ,但是實(shí)際上您這樣做便規(guī)定了每次w1 移動(dòng)時(shí)w2都跟隨w1 。
防御性副本
setLocation 的版本 2 做得更好:它創(chuàng)建了傳遞給它的參數(shù)的一個(gè)副本,以確保不存在可以意外改變其狀態(tài)的 Point的別名。但是它也并非無(wú)可挑剔,因?yàn)橄旅娴拇a也將具有一個(gè)很可能不希望出現(xiàn)的效果,即Widget在不知情的情況下被移動(dòng)了:
Point p = w1.getLocation();
. . .
p.x = 0;
|
getLocation 和 setLocation 的版本 3 對(duì)于別名引用的惡意或無(wú)意使用是完全安全的。這一安全是以一些性能為代價(jià)換來(lái)的:每次調(diào)用一個(gè) getter 或 setter 都會(huì)創(chuàng)建一個(gè)新對(duì)象。
getLocation 和 setLocation 的不同版本具有不同的語(yǔ)義,通常這些語(yǔ)義被稱作值語(yǔ)義(版本 1)和引用語(yǔ)義(版本 3)。不幸的是,通常沒(méi)有說(shuō)明實(shí)現(xiàn)者應(yīng)該使用的是哪種語(yǔ)義。結(jié)果,這個(gè)類的使用者并不清楚這一點(diǎn),從而作出了更差的假設(shè)(即選擇了不是最合適的語(yǔ)義)。
getLocation 和 setLocation 的版本 3 所使用的技術(shù)叫做防御性復(fù)制( defensive copying),盡管存在著明顯的性能上的代價(jià),您也應(yīng)該養(yǎng)成這樣的習(xí)慣,即幾乎每次返回和存儲(chǔ)對(duì)可變對(duì)象或數(shù)組的引用時(shí)都使用這一技術(shù),尤其是在您編寫(xiě)一個(gè)通用的可能被不是您自己編寫(xiě)的代碼調(diào)用(事實(shí)上這很常見(jiàn))的工具時(shí)更是如此。有別名的可變對(duì)象被意外修改的情況會(huì)以許多微妙且令人驚奇的方式突然出現(xiàn),并且調(diào)試起來(lái)相當(dāng)困難。
而且情況還會(huì)變得更壞。假設(shè)您是Widget類的一個(gè)使用者,您并不知道存取器具有值語(yǔ)義還是引用語(yǔ)義。 謹(jǐn)慎的做法是,在調(diào)用存取器方法時(shí)也使用防御性副本。所以,如果您想要將 w2 移動(dòng)到 w1 的當(dāng)前位置,您應(yīng)該這樣去做:
Point p = w1.getLocation();
w2.setLocation(new Point(p.x, p.y));
|
如果 Widget 像在版本 2 或 3 中一樣實(shí)現(xiàn)其存取器,那么我們將為每個(gè)調(diào)用創(chuàng)建兩個(gè)臨時(shí)對(duì)象 ――一個(gè)在 setLocation 調(diào)用的外面,一個(gè)在里面。
文檔說(shuō)明存取器語(yǔ)義
getLocation 和 setLocation 的版本 1 的真正問(wèn)題不是它們易受混淆別名副作用的不良影響(確實(shí)是這樣),而是它們的語(yǔ)義沒(méi)有清楚的說(shuō)明。如果存取器被清楚地說(shuō)明為具有引用語(yǔ)義(而不是像通常那樣被假設(shè)為值語(yǔ)義),那么調(diào)用者將更可能認(rèn)識(shí)到,在它們調(diào)用setLocation時(shí),它們是將Point對(duì)象的所有權(quán)轉(zhuǎn)移給另一個(gè)實(shí)體,并且也不大可能仍然認(rèn)為它們還擁有Point對(duì)象的所有權(quán),因而還能夠再次使用它。
利用不可改變性解決以上問(wèn)題
如果一開(kāi)始就使得Point 成為不可變的,那么這些與 Point 有關(guān)的問(wèn)題早就迎刃而解了。不可變對(duì)象上沒(méi)有副作用,并且緩存不可變對(duì)象的引用總是安全的,不會(huì)出現(xiàn)別名問(wèn)題。如果 Point是不可變的,那么與setLocation 和 getLocation存取器的語(yǔ)義有關(guān)的所有問(wèn)題都是非常確定的 。不可變屬性的存取器將總是具有值引用,因而調(diào)用的任何一方都不需要防御性復(fù)制,這使得它們效率更高。
那么為什么不在一開(kāi)始就使得Point 成為不可變的呢?這可能是出于性能上的原因,因?yàn)樵缙诘?JVM具有不太有效的垃圾收集器。 那時(shí),每當(dāng)一個(gè)對(duì)象(甚至是鼠標(biāo))在屏幕上移動(dòng)就創(chuàng)建一個(gè)新的Point的對(duì)象創(chuàng)建開(kāi)銷可能有些讓人生畏,而創(chuàng)建防御性副本的開(kāi)銷則不在話下。
依后見(jiàn)之明,使Point成為可變的這個(gè)決定被證明對(duì)于程序清晰性和性能是昂貴的代價(jià)。Point類的可變性使得每一個(gè)接受Point作為參數(shù)或者要返回一個(gè)Point的方法背上了編寫(xiě)文檔說(shuō)明的沉重負(fù)擔(dān)。也就是說(shuō),它得說(shuō)明它是要改變Point,還是在返回之后保留對(duì)Point的一個(gè)引用。因?yàn)楹苌儆蓄愓嬲@樣的文檔,所以在調(diào)用一個(gè)沒(méi)有用文檔說(shuō)明其調(diào)用語(yǔ)義或副作用行為的方法時(shí),安全的策略是在傳遞它到任何這樣的方法之前創(chuàng)建一份防御副本。
有諷刺意味的是,使 Point成為可變的這個(gè)決定所帶來(lái)的性能優(yōu)勢(shì)被由于Point的可變性而需要進(jìn)行的防御性復(fù)制給抵消了。由于缺乏清晰的文檔說(shuō)明(或者缺少信任),在方法調(diào)用的兩邊都需要?jiǎng)?chuàng)建防御副本 ――調(diào)用者需要這樣做是因?yàn)樗恢辣徽{(diào)用者是否會(huì)粗暴地改變 Point,而被調(diào)用者需要這樣做是因?yàn)樗恢朗欠癖A袅藢?duì) Point 的引用。
一個(gè)現(xiàn)實(shí)的例子
下面是懸空別名問(wèn)題的另一個(gè)例子,該例子非常類似于我最近在一個(gè)服務(wù)器應(yīng)用中所看到的。 該應(yīng)用在內(nèi)部使用了發(fā)布-訂閱式消息傳遞方式,以將事件和狀態(tài)更新傳達(dá)到服務(wù)器內(nèi)的其他代理。這些代理可以訂閱任何一個(gè)它們感興趣的消息流。一旦發(fā)布之后,傳遞到其他代理的消息就可能在將來(lái)某個(gè)時(shí)候在一個(gè)不同的線程中被處理。
清單 4 顯示了一個(gè)典型的消息傳遞事件(即發(fā)布拍賣系統(tǒng)中一個(gè)新的高投標(biāo)通知)和產(chǎn)生該事件的代碼。不幸的是,消息傳遞事件實(shí)現(xiàn)和調(diào)用者實(shí)現(xiàn)的交互合起來(lái)創(chuàng)建了一個(gè)懸空別名。通過(guò)簡(jiǎn)單地復(fù)制而不是克隆數(shù)組引用,消息和產(chǎn)生消息的類都保存了前一投標(biāo)數(shù)組的主副本的一個(gè)引用。如果消息發(fā)布時(shí)的時(shí)間和消費(fèi)時(shí)的時(shí)間有任何延遲,那么訂閱者看到的 previous5Bids 數(shù)組的值將不同于消息發(fā)布時(shí)的時(shí)間,并且多個(gè)訂閱者看到的前面投標(biāo)的值可能會(huì)互不相同。在這個(gè)例子中,訂閱者將看到當(dāng)前投標(biāo)的歷史值和前面投標(biāo)的更接近現(xiàn)在的值,從而形成了這樣的錯(cuò)覺(jué),認(rèn)為前面投標(biāo)比當(dāng)前投標(biāo)的值要高。不難設(shè)想這將如何引起問(wèn)題――這還不算,當(dāng)應(yīng)用在很大的負(fù)載下時(shí),這樣一個(gè)問(wèn)題則更是暴露無(wú)遺。 使得消息類不可變并在構(gòu)造時(shí)克隆像數(shù)組這樣的可變引用,就可以防止該問(wèn)題。
清單 4. 發(fā)布-訂閱式消息傳遞代碼中的懸空數(shù)組別名
public interface MessagingEvent { ... }
public class CurrentBidEvent implements MessagingEvent {
public final int currentBid;
public final int[] previous5Bids;
public CurrentBidEvent(int currentBid, int[] previousBids) {
this.currentBid = currentBid;
// Danger -- copying array reference instead of values
this.previous5Bids = previous5Bids;
}
...
}
// Now, somewhere in the bid-processing code, we create a
// CurrentBidEvent and publish it.
public void newBid(int newBid) {
if (newBid > currentBid) {
for (int i=1; i<5; i++)
previous5Bids[i] = previous5Bids[i-1];
previous5Bids[0] = currentBid;
currentBid = newBid;
messagingTopic.publish(new CurrentBidEvent(currentBid, previousBids));
}
}
}
|
可變對(duì)象的指導(dǎo)
如果您要?jiǎng)?chuàng)建一個(gè)可變類 M,那么您應(yīng)該準(zhǔn)備編寫(xiě)比 M 是不可變的情況下多得多的文檔說(shuō)明,以說(shuō)明怎樣處理 M 的引用。 首先,您必須選擇以 M 為參數(shù)或返回 M 對(duì)象的方法是使用值語(yǔ)義還是引用語(yǔ)義,并準(zhǔn)備在每一個(gè)在其接口內(nèi)使用 M 的其他類中清晰地文檔說(shuō)明這一點(diǎn) 。如果接受或返回 M 對(duì)象的任何方法隱式地假設(shè) M 的所有權(quán)被轉(zhuǎn)移,那么您必須也文檔說(shuō)明這一點(diǎn)。您還要準(zhǔn)備著接受在必要時(shí)創(chuàng)建防御副本的性能開(kāi)銷。
一個(gè)必須處理對(duì)象所有權(quán)問(wèn)題的特殊情況是數(shù)組,因?yàn)閿?shù)組不可以是不可變的。當(dāng)傳遞一個(gè)數(shù)組引用到另一個(gè)類時(shí),可能有創(chuàng)建防御副本的代價(jià),除非您能確保其他類要么創(chuàng)建了它自己的副本,要么只在調(diào)用期間保存引用,否則您可能需要在傳遞數(shù)組之前創(chuàng)建副本。另外,您可以容易地結(jié)束這樣一種情形,即調(diào)用的兩邊的類都隱式地假設(shè)它們擁有數(shù)組,只是這樣會(huì)有不可預(yù)知的結(jié)果出現(xiàn)。