如果你从诸如 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 条)。你可以对它进行全面的测试,它都可以出色地通过,但程序中潜藏着一个问题。 不严格地讲,程序中存在着“内存泄漏(memory leak)”,随着垃圾收集器的活动增加或者内存占用增加,程序性能的下降会逐渐表现出来。在极端情况下,这样的内存泄漏会导致磁盘交换(disk paging),甚至出现 OutOfMemoryError错误导致程序失败 ,不过这种失败的情形相对比较少见。
那么,程序中哪里发生了内存泄漏呢?如果一个栈先增长大然后缩小,那么垃圾回收机制不会回收那些从栈中弹出来的对象,即使使用栈的程序不再引用这些对象,它们也不会被回收。这是因为栈内部维护着对这些对象的过期引用(obsolete reference)。所谓的过期引用,是指永远也不会再被释放的引用。在这个例子中,除了 elements 数组中的“活跃部分(active portion)“,其余的所有引用都是过时的。活跃部分是指下标小于 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 类的哪方面特性使其易受内存泄漏的影响呢?简单来说,问题在于 Stack 类自己管理内存(manage its own memory)。存储池(storage pool)包含 elements 数组的元素(对象引用单元,而不是对象本身)。数组的活跃部分(同前面定义的)的元素是已分配的(allocated)状态,而数组其余部分则是自由的(free)状态。垃圾回收器并不知道这一点;对垃圾回收器而言,elements 数组中所有的对象引用都是同等有效的。只有程序员知道~~,~~数组的非活跃部分(inactive portion)是无关紧要的。程序员可以高效地把这一情况告知垃圾回收器,一旦数组元素变成非活跃部分的一部分,程序员就手动清空数组元素。
一般而言,无论任何时候一个类自己管理内存,程序员都应该警惕内存泄漏 。一个元素无论任何时候被释放,该元素中包含的任何对象引用都应当被清空。
内存泄漏的另一个常见来源是缓存。一旦你把一个对象引用放入缓存中,它就会很容易被遗忘掉,并且在它变得无关紧要后很长一段时间内仍然留在缓存中。对于这个问题有多个解决方案。如果你足够幸运的话 ,只要在缓存之外有对某个项的键(key)的引用,就可以实现与缓存项(entry)完全相关的缓存,那么就可以用 WeakHashMap 代替。在缓存中的项过期后,它们将会被自动清除。记住只有当所要的缓存项的生命周期是由该键的外部引用决定而不是值来决定时,WeakHashMap 才是有用的。
更常见的情况是,缓存项的可用寿命定义不太清楚。随着时间的推移,缓存项会变得越来越没有价值。在这种情况下,缓存应该不时地清除已经不用的项。这个任务可以由后台线程(可能是 ScheduledThreadPoolExecutor)来完成,或者也可以在向缓存中添加新缓存项时顺便清理。LinkedHashMap 类使用 removeEldestEntry 方法可以轻松实现后者。 对于更复杂的缓存,你可能需要直接使用 java.lang.ref。
内存泄露的第三个常见来源是监听器(listener)和其它回调(callback)。如果你实现了一个 API,客户端在这个 API 中注册回调但未明确撤销它们,那么除非采取一些措施,否则它们就会累积。确保回调被及时垃圾回收的一种方式,就是仅存储对它们的弱引用(weak reference) ,例如,仅将它们存储为 WeakHashMap 中的键。
由于内存泄漏通常不会表现为明显的错误,所以它们可能在一个系统中存在多年。往往只有通过仔细的代码检查或者借助被称为 heap profile 的调试工具才能发现。因此,学会在内存泄漏发生之前预测到这样的问题,并阻止它们发生,那是最好不过的了。
翻译:Inno
校对:Inger