Java 理论与实践:用弱引用堵住内存泄漏
【IT168 技术文档】
虽然用 Java™ 语言编写的程序在理论上是不会出现“内存泄漏”的,但是有时对象在不再作为程序的逻辑状态的一部分之后仍然不被垃圾收集。本月,负责保障应用程序健康的工程师 Brian Goetz 探讨了无意识的对象保留的常见原因,并展示了如何用弱引用堵住泄漏。
要让垃圾收集(GC)回收程序不再使用的对象,对象的逻辑 生命周期(应用程序使用它的时间)和对该对象拥有的引用的实际生命周期必须是相同的。在大多数时候,好的软件工程技术保证这是自动实现的,不用我们对对象生命周期问题花费过多心思。但是偶尔我们会创建一个引用,它在内存中包含对象的时间比我们预期的要长得多,这种情况称为无意识的对象保留(unintentional object retention)。
全局 Map 造成的内存泄漏
无意识对象保留最常见的原因是使用 Map 将元数据与临时对象(transient object)相关联。假定一个对象具有中等生命周期,比分配它的那个方法调用的生命周期长,但是比应用程序的生命周期短,如客户机的套接字连接。需要将一些元数据与这个套接字关联,如生成连接的用户的标识。在创建 Socket 时是不知道这些信息的,并且不能将数据添加到 Socket 对象上,因为不能控制 Socket 类或者它的子类。这时,典型的方法就是在一个全局 Map 中存储这些信息,如清单 1 中的 SocketManager 类所示:
清单 2. 具有基于 Map 的内存泄漏的程序

public class MapLeaker ...{
public ExecutorService exec = Executors.newFixedThreadPool(5);
public Map<Task, TaskStatus> taskStatus
= Collections.synchronizedMap(new HashMap<Task, TaskStatus>());
private Random random = new Random();

private enum TaskStatus ...{ NOT_STARTED, STARTED, FINISHED };

private class Task implements Runnable ...{
private int[] numbers = new int[random.nextInt(200)];

public void run() ...{
int[] temp = new int[random.nextInt(10000)];
taskStatus.put(this, TaskStatus.STARTED);
doSomeWork();
taskStatus.put(this, TaskStatus.FINISHED);
}
}

public Task newTask() ...{
Task t = new Task();
taskStatus.put(t, TaskStatus.NOT_STARTED);
exec.execute(t);
return t;
}
}
图 1 显示 MapLeaker GC 之后应用程序堆大小随着时间的变化图。上升趋势是存在内存泄漏的警示信号。(在真实的应用程序中,坡度不会这么大,但是在收集了足够长时间的 GC 数据后,上升趋势通常会表现得很明显。)

【IT168 技术文档】
确信有了内存泄漏后,下一步就是找出哪种对象造成了这个问题。所有内存分析器都可以生成按照对象类进行分解的堆快照。有一些很好的商业堆分析工具,但是找出内存泄漏不一定要花钱买这些工具 —— 内置的
hprof 工具也可完成这项工作。要使用 hprof 并让它跟踪内存使用,需要以 -Xrunhprof:heap=sites 选项调用 JVM。 清单 3 显示分解了应用程序内存使用的
hprof 输出的相关部分。(hprof 工具在应用程序退出时,或者用 kill -3 或在 Windows 中按 Ctrl+Break 时生成使用分解。)注意两次快照相比,Map.Entry、Task 和 int[] 对象有了显著增加。清单 3.HPROF 输出,显示 Map.Entry 和 Task 对象的增加
'ed stack class rank self accum bytes objs bytes objs trace name 1 70.13% 70.13% 5694888 13909 5694888 13909 300305 int[] 2 18.27% 88.40% 1483976 68 278273632 13908 300321 int[] 3 4.11% 92.51% 333816 13909 333816 13909 300310 java.util.HashMap$Entry 4 2.74% 95.25% 222544 13909 222544 13909 300304 com.quiotix.dummy.MapLeaker$Task 5 2.42% 97.67% 196640 2 262192 11 300325 java.util.HashMap$Entry[] 6 0.66% 98.33% 53680 3355 222464 13904 30032java.util.concurrent.LinkedBlockingQueue$Node SITES ENDSITES BEGIN (ordered by live bytes) Fri Oct 28 16:30:48 2005
percent live alloc
SITES BEGIN (ordered by live bytes) Fri Oct 28 16:31:32 2005 percent live alloc'ed stack class rank self accum bytes objs bytes objs trace name 1 77.07% 77.07% 41176024 100020 41176024 100020 301069 int[] 2 12.98% 90.05% 6933768 359 2001885688 100020 301093 int[] 3 4.49% 94.55% 2400480 100020 2400480 100020 301082 java.util.HashMap$Entry 4 3.00% 97.54% 1600320 100020 1600320 100020 301068 com.quiotix.dummy.MapLeaker$Task 5 1.96% 99.50% 1048592 1 2097248 14 301104 java.util.HashMap$Entry[] 6 0.05% 99.55% 25936 1621 1600240 100015 301101 java.util.concurrent.LinkedBlockingQueue$Node SITES END
清单 4 展示了 hprof 输出的另一部分,给出了 Map.Entry 对象的分配点的调用堆栈信息。这个输出告诉我们哪些调用链生成了 Map.Entry 对象,并带有一些程序分析,找出内存泄漏来源一般来说是相当容易的。
清单 4. HPROF 输出,显示 Map.Entry 对象的分配点
public class SocketManager ...{
private Map<Socket,User> m = new WeakHashMap<Socket,User>();
![]()
public void setUser(Socket s, User u) ...{
m.put(s, u);
}
public User getUser(Socket s) ...{
return m.get(s);
}
}
引用队列 用弱引用承载映射键,这使得应用程序不再使用键对象时它们可以被垃圾收集,
WeakHashMapget() 实现可以根据 WeakReference.get() 是否返回 null 来区分死的映射和活的映射。但是这只是防止 Map 的内存消耗在应用程序的生命周期中不断增加所需要做的工作的一半,还需要做一些工作以便在键对象被收集后从 Map 中删除死项。否则,Map 会充满对应于死键的项。虽然这对于应用程序是不可见的,但是它仍然会造成应用程序耗尽内存,因为即使键被收集了,Map.Entry 和值对象也不会被收集。
可以通过周期性地扫描 Map,对每一个弱引用调用 get(),并在 get() 返回 null 时删除那个映射而消除死映射。但是如果 Map 有许多活的项,那么这种方法的效率很低。如果有一种方法可以在弱引用的 referent 被垃圾收集时发出通知就好了,这就是引用队列 的作用。
引用队列是垃圾收集器向应用程序返回关于对象生命周期的信息的主要方法。弱引用有两个构造函数:一个只取 referent 作为参数,另一个还取引用队列作为参数。如果用关联的引用队列创建弱引用,在 referent 成为 GC 候选对象时,这个引用对象(不是 referent)就在引用清除后加入 到引用队列中。之后,应用程序从引用队列提取引用并了解到它的 referent 已被收集,因此可以进行相应的清理活动,如去掉已不在弱集合中的对象的项。(引用队列提供了与 BlockingQueue 同样的出列模式 —— polled、timed blocking 和 untimed blocking。)
WeakHashMap 有一个名为 expungeStaleEntries() 的私有方法,大多数 Map 操作中会调用它,它去掉引用队列中所有失效的引用,并删除关联的映射。清单 7 展示了 expungeStaleEntries() 的一种可能实现。用于存储键-值映射的 Entry 类型扩展了 WeakReference,因此当 expungeStaleEntries() 要求下一个失效的弱引用时,它得到一个 Entry。用引用队列代替定期扫描内容的方法来清理 Map 更有效,因为清理过程不会触及活的项,只有在有实际加入队列的引用时它才工作。
清单 7. WeakHashMap.expungeStaleEntries() 的可能实现
private void expungeStaleEntries() ...{
Entry<K,V> e;
while ( (e = (Entry<K,V>) queue.poll()) != null) ...{
int hash = e.hash;
![]()
Entry<K,V> prev = getChain(hash);
Entry<K,V> cur = prev;
while (cur != null) ...{
Entry<K,V> next = cur.next;
if (cur == e) ...{
if (prev == e)
setChain(hash, next);
else
prev.next = next;
break;
}
prev = cur;
cur = next;
}
}
}
结束语
弱引用和弱集合是对堆进行管理的强大工具,使得应用程序可以使用更复杂的可及性方案,而不只是由普通(强)引用所提供的“要么全部要么没有”可及性。下个月,我们将分析与弱引用有关的软引用,将分析在使用弱引用和软引用时,垃圾收集器的行为。