在没有垃圾收集的语言中,比如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 的所有权被转移,那么您必须也文档说明这一点。您还要准备着接受在必要时创建防御副本的性能开销。
一个必须处理对象所有权问题的特殊情况是数组,因为数组不可以是不可变的。当传递一个数组引用到另一个类时,可能有创建防御副本的代价,除非您能确保其他类要么创建了它自己的副本,要么只在调用期间保存引用,否则您可能需要在传递数组之前创建副本。另外,您可以容易地结束这样一种情形,即调用的两边的类都隐式地假设它们拥有数组,只是这样会有不可预知的结果出现。