当你从手工管理内存的语言(如C、C++)转到自动垃圾回收的语言后,编程工作会变得更加轻松,因为对象用完后会被自动回收。当你第一次经历自动垃圾回收的时候,会觉得不可思议。这容易给人一个印象:你无需考虑内存管理。其实不然。
【例】考虑下面这个简单的stack实现:
// 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]; } private void ensureCapacity(){ if(elements.length == size) elements = Arrays.copyOf(elemnts, 2 * size + 1); } }这个程序没有明显的错误(他的泛型版本可见Item26)。你用尽一切办法来测试,它都会成功通过每一项测试,但是其中隐藏着一个问题。不严格地说,这段程序会“内存泄露”,随着垃圾回收器活动的增加,或者内存占用的增加,程序性能会越来越低。在极端情况下,这种内存泄露会导致磁盘交换,甚至会导致程序OutOfMemoryError错误,不过这种失败情况比较少见。
那么,程序中哪里发生了内存泄露呢?如果栈先增长,接着收缩,被弹出栈的对象并不会被垃圾回收掉,及时程序中没有任何引用指向这些对象。因为栈本身维护者一个无用的引用(obsolete reference)指向这些对象。所谓无用的引用,是指永远都不会被再次用到(dereference)的引用。本例中,数组“活动部分”之外的所有引用都是无用的,活动部分是指下标小于size的那些元素。
在支持垃圾回收的语言中,内存溢出(称为“无意识的对象保持”会更合适)是很隐蔽的。如果一个对象引用被无意识地保留了,则不仅该对象无法被垃圾回收,该对应引用的其他对象也不会被垃圾回收。即使只有很少的对象引用被无意识保留,也会有许多许多对象呗排除在垃圾回收机制之外,从而对性能造成潜在的重大影响。
解决这种问题的办法很简单:当引用变为无用时,就将他们置为null。对于上述Stack类,pop方法的正确版本如下:
public Object pop(){ if(size == 0) throw new EmptyStackException(); Object result = elements[--size]; elements[size] = null; // Eliminate obsolete reference return result;}这样做的另外一个好处是,如果无用对象之后又被错误地使用了,程序会立即抛出NullPointerException,而不是安静地错误地运行下去。尽快地检测出程序错误总是有益的。
但程序员第一次被这种问题困扰之后,他们也许会矫枉过正,每当程序用完一个对象引用后,都马上置为null。这既不必要也不合适,会把程序弄得很乱。将对象引用置为null应该是一种例外,而不是常规行为。消除无用的对象引用的最佳方式,是让包含该引用的变量超出作用域范围。如果你的每个变量都定义在最紧凑的作用域内(Item45),那这种情形就会自然而然地发生。
那么何时需要将引用置空呢?Stack类的哪些特征使之可能会内存泄露呢?简而言之,当一个类自己管理内存时,就需要将无用引用置空。【例】Stack中的存储池包括elements数组(Object reference cells,而非对象本身)中的元素;数组中活动部分的元素是已分配的;数组中其他部分则是自由的。垃圾回收器并不知道这一点;对于垃圾回收器而言,elements数组中的所有对象引用都是同等有效的。只有程序员知道数组的非活动部分是不重要的。程序员可以把这个情况告诉垃圾回收器:一旦数组元素变为非活动部分,则手动将其置为null。
一般而言,只要一个类管理自己的内存,那么就应该警惕内存泄露问题。当元素被释放掉,则该元素中包含的任何对象引用都应该置为null。
内存泄露的另一个常见来源是缓存。一旦你把对象引用放到缓存中,很容易会忘掉它直到它不再有用后仍然停留在缓存中。对这个问题,有几种解决方案。如果要实现这样的缓存:只要缓存外有引用指向某个实体的键,就认为该实体有意义,那么只需要将缓存定义为WeakHashMap即可;当实体变为无用后,会自动从Map中移除。记住只有当被缓存的实体的生命周期是由其键的外部引用决定,而不是值的外部引用时,才可以使用WeakHashMap。
更常见的情况是缓存实体的生命周期不容易确定,随着时间推移,实体的价值越来越低。在这种情况下,缓存应该不定期地清理无用的实体。可以通过一个后台线程来清理(可能是Timer或ScheduledThreadPoolExecutor),也可以在给缓存添加新实体时进行清理。【例】LikedHashMap就是利用其removeEldestEntry来实现后一种方案的。对于更加复杂的缓存,你可能要直接使用java.lang.ref。
public class LinkedHashMap...{ void addEntry(int hash, K key, V value, int bucketIndex) { createEntry(hash, key, value, bucketIndex); // Remove eldest entry if instructed, else grow capacity if appropriate Entry<K,V> eldest = header.after; if (removeEldestEntry(eldest)) { removeEntryForKey(eldest.key); } else { if (size >= threshold) resize(2 * table.length); } } protected boolean removeEldestEntry(Map.Entry<K,V> eldest) { return false; }}——可以继承LinkedHashMap,覆盖其removeEldestEntry方法。
内存泄露的第三个常见来源是监听器和其他回调。如果你实现了一个API,客户端在其中注册回调,但是没有显式地取消注册,那么这些回调就会积累起来除非你采取某些行动。确保回调能被即时地垃圾回收的最好途径是,值保存他们的弱引用,例如,将他们作为WeakHashMap的键来保存。
由于内存泄露通常不会表现为明显的失败,所以它们可能会在系统中存在很多年。往往只能通过仔细的代码检查,或借助调试工具(heap profiler)的帮助,才能发现这些问题。所以,在内存泄露发生之前预测此类问题,以防它们发生,是极好的。