程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> Java理論與實踐 - 它是誰的對象?

Java理論與實踐 - 它是誰的對象?

編輯:關於JAVA

在沒有垃圾收集的語言中,比如C++,必須特別關注內存管理。對於每個動態 對象,必須要麼實現引用計數以模擬 垃圾收集效果,要麼管理每個對象的“所 有權”――確定哪個類負責刪除一個對象。通常,對這種所有權的維護並沒有什 麼成文的規則,而是按照約定(通常是不成文的)進行維護。盡管垃圾收集意味 著Java開發者不必太多地擔心內存 洩漏,有時我們仍然需要擔心對象所有權, 以防止數據爭用(data races)和不必要的副作用。在這篇文章中,Brian Goetz 指出了一些這樣的情況,即Java開發者必須注意對象所有權。

如果您是在1997年之前開始學習編程,那麼可能您學習的第一種編程語言沒 有提供透明的垃圾收集。每一個new 操作必須有相應的delete操作 ,否則您的 程序就會洩漏內存,最終內存分配器(memory allocator )就會出故障,而您 的程序就會崩潰。每當利用 new 分配一個對象時,您就得問自己,誰將刪除該 對象?何時刪除?

別名, 也叫做 ...

內存管理復雜性的主要原因是別名使用:同一塊內存或對象具有 多個指針或 引用。別名在任何時候都會很自然地出現。例如,在清單 1 中,在 makeSomething 的第一行創建的 Something 對象至少有四個引用:

something 引用。

集合 c1 中至少有一個引用。

當 something 被作為參數傳遞給 registerSomething 時,會創建臨時 aSomething 引用。

集合 c2 中至少有一個引用。

清單 1. 典型代碼中的別名

Collection c1, c2;

   public void makeSomething {
     Something something = new Something();
     c1.add(something);
     registerSomething(something);
   }
   private void registerSomething(Something aSomething) {
     c2.add(aSomething);
   }

在非垃圾收集語言中需要避免兩個主要的內存管理危險:內存洩漏和懸空指 針。為了防止內存洩漏,必須確保每個分配了內存的對象最終都會被刪除。為了 避免懸空指針(一種危險的情況,即一塊內存已經被釋放了,而一個指針還在引 用它),必須在最後的引用釋放之後才刪除對象。為滿足這兩條約束,采用一定 的策略是很重要的。

為內存管理而管理對象所有權

除了垃圾收集之外,通常還有其他兩種方法用於處理別名問題: 引用計數和 所有權管理。引用計數(reference counting)是對一個給定的對象當前有多少 指向它的引用保留有一個計數,然後當最後一個引用被釋放時自動刪除該對象。 在 C和20世紀90年代中期之前的多數 C++ 版本中,這是不可能自動完成的。標 准模板庫(Standard Template Library,STL)允許創建“靈巧”指針,而不能 自動實現引用計數(要查看一些例子,請參見開放源代碼 Boost 庫中的 shared_ptr 類,或者參見STL中的更加簡單的 auto_ptr 類)。

所有權管理(ownership management) 是這樣一個過程,該過程指明一個指 針是“擁有”指針("owning" pointer),而 所有其他別名只是臨時的二類副 本( temporary second-class copies),並且只在所擁有的指針被釋放時才刪 除對象。在有些情況下,所有權可以從一個指針“轉移”到另一個指針,比如一 個這樣的方法,它以一個緩沖區作為參數,該方法用於向一個套接字寫數據,並 且在寫操作完成時刪除這個緩沖區。這樣的方法通常叫做接收器 (sinks)。在 這個例子中,緩沖區的所有權已經被有效地轉移,因而進行調用的代碼必須假設 在被調用方法返回時緩沖區已經被刪除。(通過確保所有的別名指針都具有與調 用堆棧(比如方法參數或局部變量)一致的作用域(scope ),可以進一步簡化 所有權管理,如果引用將由非堆棧作用域的變量保存,則通過復制對象來進行簡 化。)

那麼,怎麼著?

此時,您可能正納悶,為什麼我還要討論內存管理、別名和對象所有權。畢 竟,垃圾收集是 Java語言的核心特性之一,而內存管理是已經過時的一件麻煩 事。就讓垃圾收集器來處理這件事吧,這正是它的工作。那些從內存管理的麻煩 中解脫出來的人不願意再回到過去,而那些從未處理過內存管理的人則根本無法 想象在過去倒霉的日子裡――比如1996年――程序員的編程是多麼可怕。

提防懸空別名

那麼這意味著我們可以與對象所有權的概念說再見了嗎?可以說是,也可以 說不是。大多數情況下,垃圾收集確實消除了顯式資源存儲單元分配(explicit resource deallocation)的必要(在以後的專欄中我將討論一些例外)。但是 ,有一個區域中,所有權管理仍然是Java 程序中的一個問題,而這就是懸空別 名(dangling aliases)問題。Java 開發者通常依賴於這樣一個隱含的假設, 即假設由對象所有權來確定哪些引用應該被看作是只讀的 (在C++中就是一個 const 指針),哪些引用可以用來修改被引用的對象的狀態。當兩個類都(錯誤 地)認為自己保存有對給定對象的惟一可寫的引用時,就會出現懸空指針。發生 這種情況時,如果對象的狀態被意外地更改,這兩個類中的一個或兩者將會產生 混淆。

一個貼切的例子

考慮清單 2 中的代碼,其中的 UI 組件保存有一個 Point 對象,用於表示 它的位置。當調用 MathUtil.calculateDistance 來計算對象移動了多遠時,我 們依賴於一個隱含而微妙的假設――即 calculateDistance 不會改變傳遞給它 的 Point 對象的狀態,或者情況更壞,維護著對那些 Point 對象的一個引用( 比如通過將它們保存在集合中或者將它們傳遞到另一個線程),然後這個引用將 用於在 calculateDistance 返回後更改Point 對象的狀態。在 calculateDistance的例子中,為這種行為擔心似乎有些可笑,因為這明顯是一 個可怕的違背慣例的情況。但是,如果要說將一個可變的對象傳遞給一個方法, 之後對象還能夠毫發無損地返回來,並且將來對於對象的狀態也不會有不可預料 的副作用(比如該方法與另一個線程共享引用,該線程可能會等待5分鐘,然後 更改對象的狀態),那麼這只不過是一廂情願的想法而已。

清單 2. 將可變對象傳遞給外部方法是不可取的

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;
   }

一個愚蠢的例子

大家對該例子明顯而普遍的反應就是――這是一個愚蠢的例子――只是強調 了這樣一個事實,即對象所有權的概念在 Java 程序中依然存在,而且存在得很 好,只是沒有說明而已。calculateDistance 方法不應該改變它的參數的狀態, 因為它並不“擁有”它們――當然,調用方法擁有它們。因此說不用考慮對象所 有權。

下面是一個更加實用的例子,它說明了不知道誰擁有對象就有可能會引起混 淆。再次考慮一個以Point 屬性 來表示其位置的 UI組件。清單 3 顯示了實現 存取器方法 setLocation 和 getLocation的三種方式。第一種方式是最懶散的 ,並且提供了最好的性能,但是對於蓄意攻擊和無意識的失誤,它有幾個薄弱環 節。

清單 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();
   }
}

現在來考慮 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存取器實現的版本 1 之下,可能看起來好像第 一個Widget的 位置是 (1, 1) ,第二個Widget的位置是 (2, 2),而事實上,二 者都是 (2, 2)。這可能對於調用者(因為第一個Widget意外地移動了)和 Widget 類(因為它的位置改變了,而與Widget代碼無關)來說都會產生混淆。 在第二個例子中,您可能認為自己只是將Widget w2移動到 Widget w1當前所在 的位置 ,但是實際上您這樣做便規定了每次w1 移動時w2都跟隨w1 。

防御性副本

setLocation 的版本 2 做得更好:它創建了傳遞給它的參數的一個副本,以 確保不存在可以意外改變其狀態的 Point的別名。但是它也並非無可挑剔,因為 下面的代碼也將具有一個很可能不希望出現的效果,即Widget在不知情的情況下 被移動了:

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

getLocation 和 setLocation 的版本 3 對於別名引用的惡意或無意使用是 完全安全的。這一安全是以一些性能為代價換來的:每次調用一個 getter 或 setter 都會創建一個新對象。

getLocation 和 setLocation 的不同版本具有不同的語義,通常這些語義被 稱作值語義(版本 1)和引用語義(版本 3)。不幸的是,通常沒有說明實現者 應該使用的是哪種語義。結果,這個類的使用者並不清楚這一點,從而作出了更 差的假設(即選擇了不是最合適的語義)。

getLocation 和 setLocation 的版本 3 所使用的技術叫做防御性復制( defensive copying),盡管存在著明顯的性能上的代價,您也應該養成這樣的 習慣,即幾乎每次返回和存儲對可變對象或數組的引用時都使用這一技術,尤其 是在您編寫一個通用的可能被不是您自己編寫的代碼調用(事實上這很常見)的 工具時更是如此。有別名的可變對象被意外修改的情況會以許多微妙且令人驚奇 的方式突然出現,並且調試起來相當困難。

而且情況還會變得更壞。假設您是Widget類的一個使用者,您並不知道存取 器具有值語義還是引用語義。謹慎的做法是,在調用存取器方法時也使用防御性 副本。所以,如果您想要將 w2 移動到 w1 的當前位置,您應該這樣去做:

Point p = w1.getLocation();
w2.setLocation(new Point(p.x, p.y));

如果 Widget 像在版本 2 或 3 中一樣實現其存取器,那麼我們將為每個調 用創建兩個臨時對象 ――一個在 setLocation 調用的外面,一個在裡面。

文檔說明存取器語義

getLocation 和 setLocation 的版本 1 的真正問題不是它們易受混淆別名 副作用的不良影響(確實是這樣),而是它們的語義沒有清楚的說明。如果存取 器被清楚地說明為具有引用語義(而不是像通常那樣被假設為值語義),那麼調 用者將更可能認識到,在它們調用setLocation時,它們是將Point對象的所有權 轉移給另一個實體,並且也不大可能仍然認為它們還擁有Point對象的所有權, 因而還能夠再次使用它。

利用不可改變性解決以上問題

如果一開始就使得Point 成為不可變的,那麼這些與 Point 有關的問題早就 迎刃而解了。不可變對象上沒有副作用,並且緩存不可變對象的引用總是安全的 ,不會出現別名問題。如果 Point是不可變的,那麼與setLocation 和 getLocation存取器的語義有關的所有問題都是非常確定的 。不可變屬性的存取 器將總是具有值引用,因而調用的任何一方都不需要防御性復制,這使得它們效 率更高。

那麼為什麼不在一開始就使得Point 成為不可變的呢?這可能是出於性能上 的原因,因為早期的 JVM具有不太有效的垃圾收集器。那時,每當一個對象(甚 至是鼠標)在屏幕上移動就創建一個新的Point的對象創建開銷可能有些讓人生 畏,而創建防御性副本的開銷則不在話下。

依後見之明,使Point成為可變的這個決定被證明對於程序清晰性和性能是昂 貴的代價。Point類的可變性使得每一個接受Point作為參數或者要返回一個 Point的方法背上了編寫文檔說明的沉重負擔。也就是說,它得說明它是要改變 Point,還是在返回之後保留對Point的一個引用。因為很少有類真正包含這樣的 文檔,所以在調用一個沒有用文檔說明其調用語義或副作用行為的方法時,安全 的策略是在傳遞它到任何這樣的方法之前創建一份防御副本。

有諷刺意味的是,使 Point成為可變的這個決定所帶來的性能優勢被由於 Point的可變性而需要進行的防御性復制給抵消了。由於缺乏清晰的文檔說明( 或者缺少信任),在方法調用的兩邊都需要創建防御副本 ――調用者需要這樣 做是因為它不知道被調用者是否會粗暴地改變 Point,而被調用者需要這樣做是 因為它不知道是否保留了對 Point 的引用。

一個現實的例子

下面是懸空別名問題的另一個例子,該例子非常類似於我最近在一個服務器 應用中所看到的。該應用在內部使用了發布-訂閱式消息傳遞方式,以將事件和 狀態更新傳達到服務器內的其他代理。這些代理可以訂閱任何一個它們感興趣的 消息流。一旦發布之後,傳遞到其他代理的消息就可能在將來某個時候在一個不 同的線程中被處理。

清單 4 顯示了一個典型的消息傳遞事件(即發布拍賣系統中一個新的高投標 通知)和產生該事件的代碼。不幸的是,消息傳遞事件實現和調用者實現的交互 合起來創建了一個懸空別名。通過簡單地復制而不是克隆數組引用,消息和產生 消息的類都保存了前一投標數組的主副本的一個引用。如果消息發布時的時間和 消費時的時間有任何延遲,那麼訂閱者看到的 previous5Bids 數組的值將不同 於消息發布時的時間,並且多個訂閱者看到的前面投標的值可能會互不相同。在 這個例子中,訂閱者將看到當前投標的歷史值和前面投標的更接近現在的值,從 而形成了這樣的錯覺,認為前面投標比當前投標的值要高。不難設想這將如何引 起問題――這還不算,當應用在很大的負載下時,這樣一個問題則更是暴露無遺 。使得消息類不可變並在構造時克隆像數組這樣的可變引用,就可以防止該問題 。

清單 4. 發布-訂閱式消息傳遞代碼中的懸空數組別名

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));
   }
  }
}

可變對象的指導

如果您要創建一個可變類 M,那麼您應該准備編寫比 M 是不可變的情況下多 得多的文檔說明,以說明怎樣處理 M 的引用。首先,您必須選擇以 M 為參數或 返回 M 對象的方法是使用值語義還是引用語義,並准備在每一個在其接口內使 用 M 的其他類中清晰地文檔說明這一點 。如果接受或返回 M 對象的任何方法 隱式地假設 M 的所有權被轉移,那麼您必須也文檔說明這一點。您還要准備著 接受在必要時創建防御副本的性能開銷。

一個必須處理對象所有權問題的特殊情況是數組,因為數組不可以是不可變 的。當傳遞一個數組引用到另一個類時,可能有創建防御副本的代價,除非您能 確保其他類要麼創建了它自己的副本,要麼只在調用期間保存引用,否則您可能 需要在傳遞數組之前創建副本。另外,您可以容易地結束這樣一種情形,即調用 的兩邊的類都隱式地假設它們擁有數組,只是這樣會有不可預知的結果出現。

結束語

處理可變的類比處理不可變的類需要更細心。當在方法之間傳遞對可變對象 的引用時,您需要清楚地文檔說明哪些情況下對象的所有權被轉移。而缺乏清楚 的文檔說明時,您必須在方法調用的兩邊都創建防御副本。認為可變性更合理是 基於性能方面的考慮,因為不需要在每次狀態改變時都創建一個新對象,然而, 由防御性復制所招致的性能代價能輕而易舉地抵消掉因為減少了對象創建而節省 下來的性能。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved