ITEM 7: ELIMINATE OBSOLETE OBJECT REFERENCES
如果你从手动管理内存的语言(如C或c++)切换到垃圾收集的语言(如Java),那么你的工作就会变得容易得多,因为当你不再使用对象后,对象会自动被回收。这几乎就像魔法一样,垃圾收集让人觉得不必考虑内存管理,但事实并非如此。考虑下面一个简单的栈的实现:
// Can you spot the "memory leak"?
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];
}
/**
* Ensure space for at least one more element, roughly
* doubling the capacity each time the array needs to grow. */
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
这个程序看起来没有什么明显的问题,我们可以对它进行详尽的测试,它会出色地通过所有测试,但是这里存在一个潜在的问题。粗略地说,程序存在“内存泄漏”,内存泄漏会导致垃圾收集器活动更频繁和更高的内存占用。在极端情况下,内存泄漏可能导致磁盘分页(disk paging),甚至程序失败,抛出OutOfMemoryError错误,但这种故障相对较少。
那么内存泄漏在哪里呢? 如果堆栈增长然后收缩,则已经出栈的对象将不会被垃圾收集,即使使用栈的程序不再引用它们。这是因为栈维护这些对象的过期引用。一个过期的引用是一个再也不会被取消的引用。在这个例子里,元素数组的“活动部分”之外的任何引用都是过期的,活动部分由索引小于 size 的元素组成。
垃圾收集的语言中的内存泄漏(更确切地说是无意对象保留)是不明显的。如果无意中保留了对象引用,那么不仅该对象被排除在垃圾收集之外,而且该对象引用的任何对象也被排除在垃圾收集之外,依此类推。即使无意中只保留了几个对象引用,也可以导致许多对象不能被回收,从而对性能产生潜在的巨大影响。
解决这类问题的方法很简单:一旦引用过时,就将它们置为null。在栈的例子中,每一项的引用一旦从栈中弹出就会过时,pop方法的修正版本是这样的:
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference return result;
}
清除过期引用的另一个好处是,如果随后它们被错误地引用,程序将立即抛出NullPointerException 异常,而不是悄悄地执行错误的操作。 尽可能快地检测编程错误总是有益的。
当程序员第一次被这个问题所困扰时,他们可能会过度反应 —— 一旦程序使用完,他们就会把每个对象引用都清空。这既没有必要,也不可取:它将使程序变得混乱。空出对象引用应该是例外而不是规范。消除过期引用的最佳方法是让包含引用的变量超出范围。如果我们定义每个变量都在尽可能小的范围内,那么这种情况会很自然地发生。
那么什么时候应该将过期引用置为null呢? Stack 类的哪个方面使它容易受到内存泄漏的影响? 简单地说,它管理自己的内存。存储池由elements数组的元素组成(对象引用,而不是对象本身)。数组的活动部分(如前面定义的)中的元素被分配,数组其余部分中的元素是空闲的,垃圾收集器无法知道这一点。对于垃圾收集器来说,elements数组中的所有对象引用都是有效的。只有程序员知道数组中不活动的部分是不重要的。为了将数组元素成为非活动部分这一事实传递给垃圾收集器,程序员需要手动地使它们为null。
一般来说,每当一个类管理它自己的内存时,程序员应该警惕内存泄漏。每当释放一个元素时,该元素中包含的任何对象引用都应该为空。
另一个常见的内存泄漏源是缓存。一旦将对象引用放入缓存,就很容易忘记它的存在,并在它变得无关紧要之后很长时间内还将其留在缓存中。这个问题有几种解决办法。如果我们需要实现一个缓存,其中的项目只要在缓存之外有对其键的引用,就需要让其存活,那么可以用 WeakHashMap 实现缓存,当项过期后,它将被自动删除。要注意,WeakHashMap 只有在缓存项的生存期是由 key 的外部引用(而不是value)决定时才有用。
更常见的情况是,缓存项的有效生存期不是很好定义,随着时间的推移,项会越来越没有价值。在这种情况下,缓存应该偶尔清除掉已经停用的项。这可以通过后台线程(ScheduledThreadPoolExecutor) 或向缓存添加新项来顺便触发。LinkedHashMap类通过 removeEldestEntry() 方法实现了后一种方案。对于更复杂的缓存,您可能需要直接使用java.lang.ref 。
内存泄漏的第三个常见情况是监听器和其他回调。如果您实现了一个API,其中客户端注册回调,但不显式地撤销它们,除非采取一些操作,否则它们将会积累。确保回调被及时垃圾收集的一种方法是只存储对它们的弱引用,例如,只将它们作为 key 存储在WeakHashMap中。
由于内存泄漏通常不会表现为明显的故障,它们可能会在系统中存在多年。它们通常只能通过仔细的代码检查或堆分析器(heap profiler)调试工具来发现。因此,学会在类似的问题发生之前预测并防止它们的发生是非常重要的。
网友评论