Vincent

          Vicent's blog
          隨筆 - 74, 文章 - 0, 評(píng)論 - 5, 引用 - 0
          數(shù)據(jù)加載中……

          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. 典型代碼中的別名
          												
          														    Collection c1, c2;
              
              public void makeSomething {
                  Something something = new Something();
                  c1.add(something);
                  registerSomething(something);
              }
          
              private void registerSomething(Something aSomething) {
                  c2.add(aSomething);
              }
          
          												
          										

          在非垃圾收集語言中需要避免兩個(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)
          除了垃圾收集之外,通常還有其他兩種方法用于處理別名問題: 引用計(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)模板庫(Standard Template Library,STL)允許創(chuàng)建“靈巧”指針,而不能自動(dòng)實(shí)現(xiàn)引用計(jì)數(shù)(要查看一些例子,請(qǐng)參見開放源代碼 Boost 庫中的 shared_ptr 類,或者參見STL中的更加簡(jiǎn)單的 auto_ptr 類)。

          所有權(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)生混淆。

          一個(gè)貼切的例子

          考慮清單 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ì)象傳遞給外部方法是不可取的
          												
          														    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 程序中依然存在,而且存在得很好,只是沒有說明而已。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的值語義以及引用語義
          												
          														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)在來考慮 setLocation 看起來是無意的使用 :

          												
          														    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 之下,可能看起來好像第一個(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)了:

          												
          														    Point p = w1.getLocation();
              . . .
              p.x = 0;
          
          												
          										

          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)該這樣去做:

          												
          														    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è)在里面。

          文檔說明存取器語義

          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è)現(xiàn)實(shí)的例子

          下面是懸空別名問題的另一個(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ù)組別名
          												
          														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)備編寫比 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

          主站蜘蛛池模板: 甘德县| 九台市| 宁陵县| 涞水县| 夹江县| 偃师市| 张家港市| 苍南县| 洪江市| 富蕴县| 潜江市| 台东市| 宁乡县| 邯郸市| 凤山县| 恩施市| 天台县| 台江县| 都江堰市| 米易县| 通化市| 花莲市| 科尔| 应城市| 云和县| 石棉县| 福安市| 綦江县| 阿城市| 襄城县| 清涧县| 闻喜县| 万宁市| 大冶市| 仙桃市| 虞城县| 康保县| 志丹县| 冕宁县| 隆昌县| 平度市|