如果您在1997年之前学习编程,那么很可能您学到的第一门编程语言没有提供透明的垃圾回收。 每个new
操作都必须通过相应的delete
进行平衡,否则您的程序将泄漏内存,最终内存分配器将失败并且您的程序将崩溃。 每当为对象分配new
,您都必须问自己,谁将删除该对象,何时删除?
别名,也称为...
造成内存管理复杂性的主要原因之一是别名 :具有一个或多个指向同一块内存或对象的指针或引用的副本。 别名总是自然发生。 例如,在清单1中,至少有四个对makeSomething
第一行中创建的Something
对象的makeSomething
:
-
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语言中以及1990年代中期以前的大多数C ++版本中,这不可能自动完成。 与可以自动进行引用计数的标准模板库(STL)相比,它可以创建“智能”指针(有关示例,请参见开源Boost库中的shared_ptr
类,或者参阅STL中的更简单的auto_ptr
类)。
所有权管理是以下过程:将一个指针指定为“拥有”指针,将所有其他别名指定为仅临时的第二类副本,并仅在释放拥有指针时才删除该对象。 在某些情况下,所有权可以从一个指针“转移”到另一个指针,例如一种写入套接字的方法,该方法接受缓冲区作为参数,并在写入完成后删除缓冲区(此类方法通常称为接收器 )。 在这种情况下,缓冲区的所有权已被有效地转移,并且调用对象返回时,调用代码必须假定缓冲区已被删除。 (可以通过确保所有别名指针都具有与调用堆栈绑定的范围(例如方法参数或局部变量),并通过复制对象(如果要由非堆栈持有引用的对象),可以进一步简化所有权管理。范围变量。)
所以呢?
在这一点上,您可能想知道为什么我什至在谈论内存管理,别名和对象所有权。 毕竟,垃圾回收是Java语言的核心功能之一,而内存管理是过去的事! 只是让垃圾收集器处理它即可。 这就是它的工作。 那些从内存管理的繁琐工作中解脱出来的人不想回头,而那些从不去处理它的人甚至无法想象,在过去的糟糕日子里,作为一名程序员,生活有多么可怕? 1996年。
提防悬挂的别名
那么这是否意味着我们可以告别对象所有权的概念? 是的,没有。 确实,在大多数情况下,垃圾回收确实消除了对显式资源重新分配的需求。 (我将在以后的专栏中讨论一些例外情况。)但是,在某些领域中,所有权管理在Java程序中仍然是一个很大的问题,那就是悬挂别名的问题。 Java开发人员通常依赖于对象所有权的隐式假设,以确定哪些引用应被视为只读(在C ++中用const
指针表示),以及哪些引用可用于修改引用对象的状态。 当两个类各自(错误地)认为它们持有对给定对象的唯一可写引用时,就会出现悬挂的别名。 发生这种情况时,当对象的状态意外更改时,一个或两个类将被混淆。
案例
考虑清单2中的代码,其中UI组件包含一个Point
对象来表示其位置。 调用MathUtil.calculateDistance
来计算对象已移动的距离时,我们所依据的是一个隐式和微妙的假设- calculateDistance
距离不会改变传递给它的Point
对象的状态,或者更糟的是,保持对Point
的引用对象(例如,通过将它们存储在集合中或将它们传递给另一个线程),这些对象随后可能在calculateDistance
返回之后用于改变其状态。 在的情况下calculateDistance
,它似乎荒谬担心这样的行为,因为这显然是一种难言的失礼。 但是,通过将可变对象传递给另一种方法,仅仅是一种信念行为,即该对象将不受损害地返回给您,并且将来不会对该对象的状态造成任何未记录的副作用(例如该方法与另一对象共享引用)线程,可能需要等待五分钟, 然后更改对象的状态)。
清单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. getter和setter的值和引用语义
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
当前所在的位置,但是实际上您是在约束w2
每次w1
移动时都跟随w1
。
防御副本
setLocation
版本2更好:它复制了传递给它的参数,以确保Point
别名没有任何可能意外改变其状态的别名。 但这还远远不够,因为以下代码也将具有(可能不希望的)在Widget
情况下移动Widget
效果:
Point p = w1.getLocation();
. . .
p.x = 0;
getLocation
和setLocation
版本3完全可以防止恶意或粗心使用别名引用。 这种安全性是以性能为代价的:每次调用getter或setter时都要创建一个新对象。
getLocation
和setLocation
的不同版本具有不同的语义,通常称为值语义(版本3)和引用语义(版本1)。 不幸的是,很少记录实现者打算使用的语义。 结果是该类的用户不知道,因此应该假设最坏的情况。
第3版的getLocation
和setLocation
使用的技术称为防御性复制 ,尽管有明显的性能损失,但在返回或存储对可变对象或数组的引用时,您应该几乎始终习惯使用它,尤其是当编写一个通用工具,该工具可能会被您尚未亲自编写的代码调用(甚至在那时甚至没有)。 别名可变对象被意外修改的情况可能会以许多微妙和令人惊讶的方式出现,并且调试它们非常困难。
但情况变得更糟。 假设作为Widget
类的用户,您不知道访问器是否具有值或引用语义。 出于谨慎考虑,在打电话时也需要防御性副本。 所以,如果你想转移w2
到的当前位置w1
,你就必须做到这一点:
Point p = w1.getLocation();
w2.setLocation(new Point(p.x, p.y));
如果Widget
在版本2或版本3中实现其访问器,那么现在我们将为每个调用创建两个临时对象-一个在setLocation
调用之外,另一个在内部。
文档访问器语义
第1版的getLocation
和setLocation
的真正问题不是它们容易受到混淆的别名副作用(它们是)的影响,而是它们的语义没有得到正确记录。 如果访问者被明确记录为具有参考语义(而不是通常假定的值语义),则调用者可能更有可能意识到,当他们调用setLocation
,他们会将那个Point
对象的所有权转移到另一个实体,并且不太可能假设他们仍然拥有它并且可以重用它。
营救的一成不变
这些问题Point
可以,如果很容易地得到解决Point
已经在第一时间作出不可改变的。 不变对象不会有任何副作用,并且缓存对不变对象的引用始终可以避免别名问题。 如果Point
是不可变的,则将明确确定所有与setLocation
和getLocation
访问器的语义有关的问题。 不变属性的访问器将始终具有值语义,并且不需要在调用的任何一侧进行防御性复制,从而使它们更加有效。
那么为什么不首先将Point
变成不可变的呢? 这可能是出于性能原因; 早期的JVM具有效率较低的垃圾收集器。 每次屏幕上的一个对象(甚至是鼠标)移动时创建一个新Point
的对象创建开销在当时似乎是艰巨的,而制作防御性副本的开销似乎是不可能的。
事后看来,使Point
可变的决定对于程序的清晰度和性能而言代价很高。 Point
类的可变性为每个接受Point
参数或返回Point
方法带来了文档负担。 也就是说,返回Point
后它会改变该Point
还是保留对其的引用? 鉴于实际上很少有此类文档包含此类文档,因此在调用不记录其调用语义或副作用行为的方法时,安全的策略是在将其传递给任何此类方法之前创建防御性副本。
具有讽刺意味的是,使Point
可变的决定的性能优势与Point
的可变性所需的防御性复制的额外成本相形见war。 在没有明确的文件(或信仰的飞跃),防御性复制,需要在方法调用的两边 -调用者,因为它不知道,如果被叫方将毫不客气地发生变异的Point
,以及由被叫(如果它保留对Point
的引用)。
一个真实的例子
这是一个悬而未决的别名问题的示例,它与我最近在服务器应用程序中看到的非常相似。 该应用程序内部使用了发布和订阅消息传递,以将事件和状态更新传递给服务器内的其他代理。 代理可以订阅他们感兴趣的任何消息流。 发布后,传递给其他代理的消息可能会在以后的其他线程中进行处理。
清单4显示了一个典型的消息传递事件,在拍卖系统中发布新的高出价的通知以及生成该事件的代码。 不幸的是,消息传递事件实现与调用者实现的交互作用共同创建了一个悬挂的别名。 通过简单地复制而不是克隆数组引用,消息和生成该数组引用的类都拥有对先前bids数组的主副本的引用。 如果在发布消息和使用消息之间存在任何延迟,则订阅者可能会看到与发布时相比, previous5Bids
5Bids数组的值与发布时当前的值不同,并且多个订阅者可能会看到每个消息的先前出价的值不同其他。 在这种情况下,订户将看到当前出价的历史值,以及先前出价的更多最新值,从而产生一种错觉,即先前出价高于当前出价。 不难想象这将如何导致问题-更糟糕的是,只有在应用程序负载沉重时,这种问题才会显现出来。 使消息类不可变并在构造时克隆可变引用(例如数组)可以避免此问题。
清单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
的所有权,那么您也必须对此进行记录。 您还必须准备接受必要时制作防御性副本的性能开销。
我们不得不处理对象所有权问题的一种特殊情况是使用数组,因为数组不能是不变的。 在将数组引用传递给另一个类时,可能需要制作防御性副本,但是除非您确定另一个类自己创建副本或不保存该引用的时间长于调用持续时间,否则您必须这样做。可能希望在传递数组之前进行复制。 否则,您很容易陷入这种情况,即调用双方的类都隐式地假定它们拥有该数组,并且结果无法预测。
摘要
处理可变类比不可变类需要更多的照顾。 在方法之间传递对可变对象的引用时,您需要清楚地记录在什么情况下转移对象的所有权。 而且,在缺乏清晰文档的情况下,您必须在方法调用的两侧都制作防御性副本。 尽管可变性的理由通常基于性能,但是由于不需要每次状态更改时都创建一个新对象,因此防御性复制的性能成本可以轻易地超过因减少对象创建而节省的性能。
翻译自: https://www.ibm.com/developerworks/java/library/j-jtp06243/index.html