首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> Java 程式設計 >> Java 相關知識

Java 理論與實踐:物件所有權——誰負責管理物件生命週期?

     編輯:Java 相關知識

在無垃圾回收語言(如 C++)中,必須特別關注記憶體管理。對每個動態物件,必須要么實現引用計數模擬垃圾回收效果,要么管理每個物件的「所有權」——確定哪個類負責刪除物件。通常對此所有權維護無成文規則,而是按約定(通常不成文)進行。雖垃圾回收意味 Java 開發者不必太擔心記憶體洩漏,有時仍需擔心物件所有權,以防止資料競爭與不必要副作用。本文指出 Java 開發者必須注意物件所有權的情況。

別名,也叫作……

記憶體管理複雜性主要源於別名使用:同一塊記憶體或物件有多個指標或參考。別名在任何時候自然出現。例如清單 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);
}

非垃圾回收語言需避免兩大記憶體管理危險:記憶體洩漏與懸空指標。為防記憶體洩漏,必須確保每個分配記憶體的物件最終被刪除。為避免懸空指標(危險情況:記憶體已釋放,指標仍引用它),必須在最後參考釋放後才刪除物件。為滿足兩約束,採用一定策略很重要。

為記憶體管理而管理物件所有權

除了垃圾回收,通常還有兩種方法處理別名問題:引用計數與所有權管理。引用計數是對給定物件當前有多少指向它的參考保留計數,最後一個參考釋放時自動刪除物件。在 C 和 90 年代中期前多數 C++ 版本中,這不可能自動完成。標準模板庫(STL)允許創建「靈巧」指標,而非自動實現引用計數(看開源 Boost 庫 shared_ptr 類,或 STL 中較簡單 auto_ptr 類)。

所有權管理是指明一個指標是「擁有」指標("owning" pointer),其他別名只是暫時二級副本(temporary second-class copies),僅在擁有指標釋放時刪除物件。某些情況下,所有權可從一指標「轉移」到另一指標,如一個方法以緩衝區為參數,用於向套接字寫資料,寫操作完成時刪除緩衝區。這類方法常稱接收器。此例中,緩衝區所有權有效轉移,調用代碼必須假設被調用方法返回時緩衝區已刪除。(透過確保所有別名指標都具有與調用堆疊一致的作用域(如方法參數或區域變數),可進一步簡化所有權管理;若參考將由非堆疊作用域變數保存,則透過複製物件簡化。)

那麼,怎麼了?

此時您可能納悶,為何還討論記憶體管理、別名與物件所有權。畢竟垃圾回收是 Java 語言核心特性,記憶體管理是過時麻煩事。讓垃圾回收器處理吧,這正是它的工作。那些從記憶體管理麻煩解脫的人不願回頭,而從未處理過記憶體管理的人根本無法想像過去倒霉日子——如 1996 年——程式設計師編程多麼可怕。

提防懸空別名

這意味著我們可與物件所有權概念說再見了嗎?可說是,也可說不是。大多數情況下,垃圾回收確實消除顯式資源釋放必要(後續專欄將討論例外)。但有一領域所有權管理仍是 Java 程式問題,即懸空別名問題。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());

在版本 1 存取器實現下,可能看起來第一個 Widget 位置是 (1, 1)、第二個是 (2, 2),但事實上兩者都是 (2, 2)。這對調用者(因第一個 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)。不幸的是,通常未說明實作者應使用哪種語義。結果,此類使用者不明此點,做出更差假設(即選擇非最適合語義)。

版本 3 使用的技術稱防禦性複製,雖有明顯效能代價,您應養成習慣:幾乎每次回傳與儲存可變物件或陣列參考時都用此技術,尤其編寫通用可能被非您編寫代碼調用(事實常見)的工具時。有別名的可變物件被意外修改情況會以許多微妙驚人方式突然出現,調試相當困難。

而且情況更糟。假設您是 Widget 類使用者,不知存取器有值語義還是參考語義。謹慎做法:調用存取器方法時也用防禦性複製。所以若想將 w2 移至 w1 當前位置,應這樣做:

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

若 Widget 像版本 2 或 3 實現存取器,我們將為每次調用創建兩個暫存物件——一個在 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