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