如果你是从手动内存管理的语言(比如C或者C++)切换到垃圾回收语言(比如Java),作为程序员你的工作会变得更容易,因为当你用完了对象时会被自动回收。当你第一次经历的时候,这好像是魔法一样。这可能容易导致这种印象:你不必要考虑内存管理,但是这不完全正确的。
考虑如下栈实现的例子:
// 你能指出“内存泄漏”吗?
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];
}
/**
* 保证至少有一个以上的元素的空间,每次队列需要增长时大约使容量加倍
* */
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
这个程序没有明显的错误(但是参考条目29的泛型版本)。你可以彻底测试它,而且它会成功通过每项测试,但是有个潜在的问题。大约地讲,这个程序有个“内存泄漏”,当由于垃圾回收器活动增加和内存占用增加而性能降低时,会悄悄的显露出来。在极端情况下,这样的内存泄漏可以导致磁盘分页,甚至OutOfMemoryError的程序失败,但是这样的失败是相当稀少的。
那么内存泄漏在哪里呢?如果栈增长然后收缩,从栈弹出的对象不会被垃圾回收,即使程序已经没有对它们的引用。这是因为栈维持着对这些对象的过期引用(obsolete reference)。过期引用仅仅是一个没有再次解引用的引用。从这个情况下,在元素队列中的“有效区域”之外的任何引用都是过期的。有效区域包含索引小于大小(size)的元素。
垃圾回收的语言中内存泄漏(叫做无意对象留存(unintentional object retention)更合适)是潜隐的。如果一个对象引用无意留存了,不仅是对象被垃圾回收排除之外,而且由这个对象引用的任何其他对象也是如此(诸如此类)。即使只有一些对象引用无意存留,许许多多的对象也被阻止垃圾回收,这对性能可能有很大影响。
这种问题的解决方案很简单:当引用过期后置空。在我们的Stack类的情形中,对一个项的引用当被弹出栈时,会成为过期。pop方法的纠正版本就像下面:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 消除过期引用
return result;
}
置空过期引用的额外好处是,如果后来它们被错误地被解引用,这个程序立即以NullPointerException方式失败,而不是悄悄地做错误的事情。尽快检测到程序错误,这总是有益的。
当程序员第一次被这个问题困扰时,他们可能过度补偿:一旦程序完成使用,就置空每个对象引用。这既不必须也不合适;这不必要地把代码凌乱了。置空对象引用应该是特例而不是规范。消除过期引用的最佳方式是让包含引用的变量掉出作用域。如果你在尽可能窄的域中定义每个变量,自然而然就会发生(条目57)。
那么你什么时候置空一个引用呢?Stack类的什么方面使得内存泄漏容易呢?简单来讲,它自己管理自己的内存(manages its own memory)。存储池(storage pool)包含元素队列的元素(对象引用格(cell),而不是对象本身)。队列有效区域的元素是被分配的,而队列其他的元素是空闲的。垃圾回收不会知道这些;对于垃圾回收器来说,元素队列里面的所有对象引用是同样有效。只有程序员知道队列的无效部分是不重要的。一旦队列元素变为不有效部分的一部分,就手动地置空队列元素,通过这种方式,程序员有效地和垃圾回收器沟通这个事实。
通常来说,无任何时一个类管理自己的内存,程序员应该警惕内存泄漏。无任何时一个元素被释放,一个元素内含的对象引用应该被置空。
另外一个内存泄漏的来源是缓存。一旦你把对象引用放入到缓存时,容易忘记它还在那里,在它变得不相关的时候让它久存在缓存中。这个问题有几个解决方案。只要在缓存之外键值对(entry)的的键有引用,键值对就有意义,如果你足够幸运实现这样的缓存,可以用WeakHashMap呈现这个缓存;在键值对过期之后,它们可以自动的被移除。记住,只有在缓存键值对的期望生命周期是由键的外部引用决定,而不是值的时候,WeakHashMap才是有用的。
更普遍地,随着时间键值对变得越来越没有价值,缓存键值对的有用生命周期也没有很好的定义。在这些情况下,缓存应该不定期清理不再用的键值对。这个可以用后台线程(或许是一个ScheduledThreadPoolExecutor)完成,或者可以作为添加新的键值对到缓存的副作用。通过它的removeEldestEntry方法,LinkedHashMap类使得后面这种方法更加容易。对于更复杂的缓存,你可以直接用java.lang.ref。
第三个内存泄漏的来源是监听器(listener)和其他的回调(callback)。如果你实现了一个API,客户端可以注册回调,但是不会显式地反注册,除非你采取一些行动,否则它们将积累。一个保证回调立即被垃圾回收的方式是,只存储对它们的虚引用,比如,仅仅把他们作为WeakHashMap的键来存储。
因为内存泄漏通常不会像明显的失败来显现,它们可能在一个系统中存在数年。它们通常仅仅通过仔细的代码检查,或者在调试工具(叫做heap profiler)的帮助下被发现。所以,这是非常值得的,学会在这样的问题发生之前预见到它们,并阻止它们发生。