(转) Java 理论与实践: 用弱引用堵住内存泄漏

弱引用使得表达对象生命周期关系变得容易了

虽然用 Java™ 语言编写的程序在理论上是不会出现“内存泄漏”的,但是有时对象在不再作为程序的逻辑状态的一部分之后仍然不被垃圾收集。本月,负责保障应用程序健康的工程师 Brian Goetz 探讨了无意识的对象保留的常见原因,并展示了如何用弱引用堵住泄漏。

要让垃圾收集(GC)回收程序不再使用的对象,对象的逻辑生命周期(应用程序使用它的时间)和对该对象拥有的引用的实际生命周期必须是相同的。在大多数时候,好的软件工程技术保证这是自动实现的,不用我们对对象生命周期问题花费过多心思。但是偶尔我们会创建一个引用,它在内存中包含对象的时间比我们预期的要长得多,这种情况称为无意识的对象保留(unintentional object retention)

全局 Map 造成的内存泄漏

无意识对象保留最常见的原因是使用Map将元数据与临时对象(transient object)相关联。假定一个对象具有中等生命周期,比分配它的那个方法调用的生命周期长,但是比应用程序的生命周期短,如客户机的套接字连接。需要将一些元数据与这个套接字关联,如生成连接的用户的标识。在创建Socket时是不知道这些信息的,并且不能将数据添加到Socket对象上,因为不能控制Socket类或者它的子类。这时,典型的方法就是在一个全局Map中存储这些信息,如清单 1 中的SocketManager类所示:

清单 1. 使用一个全局 Map 将元数据关联到一个对象

public class SocketManager {

private Map m = new HashMap();

public void setUser(Socket s, User u) {

m.put(s, u);

}

public User getUser(Socket s) {

return m.get(s);

}

public void removeUser(Socket s) {

m.remove(s);

}

}

SocketManager socketManager;

...

socketManager.setUser(socket, user);

这种方法的问题是元数据的生命周期需要与套接字的生命周期挂钩,但是除非准确地知道什么时候程序不再需要这个套接字,并记住从Map中删除相应的映射,否则,Socket和User对象将会永远留在Map中,远远超过响应了请求和关闭套接字的时间。这会阻止Socket和User对象被垃圾收集,即使应用程序不会再使用它们。这些对象留下来不受控制,很容易造成程序在长时间运行后内存爆满。除了最简单的情况,在几乎所有情况下找出什么时候Socket不再被程序使用是一件很烦人和容易出错的任务,需要人工对内存进行管理。

回页首

找出内存泄漏

程序有内存泄漏的第一个迹象通常是它抛出一个OutOfMemoryError,或者因为频繁的垃圾收集而表现出糟糕的性能。幸运的是,垃圾收集可以提供能够用来诊断内存泄漏的大量信息。如果以-verbose:gc或者-Xloggc选项调用 JVM,那么每次 GC 运行时在控制台上或者日志文件中会打印出一个诊断信息,包括它所花费的时间、当前堆使用情况以及恢复了多少内存。记录 GC 使用情况并不具有干扰性,因此如果需要分析内存问题或者调优垃圾收集器,在生产环境中默认启用 GC 日志是值得的。

有工具可以利用 GC 日志输出并以图形方式将它显示出来,JTune 就是这样的一种工具(请参阅参考资料)。观察 GC 之后堆大小的图,可以看到程序内存使用的趋势。对于大多数程序来说,可以将内存使用分为两部分:baseline使用和current load使用。对于服务器应用程序,baseline 使用就是应用程序在没有任何负荷、但是已经准备好接受请求时的内存使用,current load 使用是在处理请求过程中使用的、但是在请求处理完成后会释放的内存。只要负荷大体上是恒定的,应用程序通常会很快达到一个稳定的内存使用水平。如果在应用程序已经完成了其初始化并且负荷没有增加的情况下,内存使用持续增加,那么程序就可能在处理前面的请求时保留了生成的对象。

清单 2 展示了一个有内存泄漏的程序。MapLeaker在线程池中处理任务,并在一个Map中记录每一项任务的状态。不幸的是,在任务完成后它不会删除那一项,因此状态项和任务对象(以及它们的内部状态)会不断地积累。

清单 2. 具有基于 Map 的内存泄漏的程序

public class MapLeaker {

public ExecutorService exec = Executors.newFixedThreadPool(5);

public Map taskStatus

= Collections.synchronizedMap(new HashMap());

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 显示MapLeakerGC 之后应用程序堆大小随着时间的变化图。上升趋势是存在内存泄漏的警示信号。(在真实的应用程序中,坡度不会这么大,但是在收集了足够长时间的 GC 数据后,上升趋势通常会表现得很明显。)

图 1. 持续上升的内存使用趋势

确信有了内存泄漏后,下一步就是找出哪种对象造成了这个问题。所有内存分析器都可以生成按照对象类进行分解的堆快照。有一些很好的商业堆分析工具,但是找出内存泄漏不一定要花钱买这些工具 —— 内置的hprof工具也可完成这项工作。要使用hprof并让它跟踪内存使用,需要以-Xrunhprof:heap=sites选项调用 JVM。

清单 3 显示分解了应用程序内存使用的hprof输出的相关部分。(hprof工具在应用程序退出时,或者用kill -3或在 Windows 中按 Ctrl+Break 时生成使用分解。)注意两次快照相比,Map.Entry、Task和int[]对象有了显著增加。

请参阅清单 3

清单 4 展示了hprof输出的另一部分,给出了Map.Entry对象的分配点的调用堆栈信息。这个输出告诉我们哪些调用链生成了Map.Entry对象,并带有一些程序分析,找出内存泄漏来源一般来说是相当容易的。

清单 4. HPROF 输出,显示 Map.Entry 对象的分配点

TRACE 300446:

java.util.HashMap$Entry.(:Unknown line)

java.util.HashMap.addEntry(:Unknown line)

java.util.HashMap.put(:Unknown line)

java.util.Collections$SynchronizedMap.put(:Unknown line)

com.quiotix.dummy.MapLeaker.newTask(MapLeaker.java:48)

com.quiotix.dummy.MapLeaker.main(MapLeaker.java:64)

回页首

弱引用来救援了

SocketManager的问题是Socket-User映射的生命周期应当与Socket的生命周期相匹配,但是语言没有提供任何容易的方法实施这项规则。这使得程序不得不使用人工内存管理的老技术。幸运的是,从 JDK 1.2 开始,垃圾收集器提供了一种声明这种对象生命周期依赖性的方法,这样垃圾收集器就可以帮助我们防止这种内存泄漏 —— 利用弱引用

弱引用是对一个对象(称为referent)的引用的持有者。使用弱引用后,可以维持对 referent 的引用,而不会阻止它被垃圾收集。当垃圾收集器跟踪堆的时候,如果对一个对象的引用只有弱引用,那么这个 referent 就会成为垃圾收集的候选对象,就像没有任何剩余的引用一样,而且所有剩余的弱引用都被清除。(只有弱引用的对象称为弱可及(weakly reachable)。)

WeakReference的 referent 是在构造时设置的,在没有被清除之前,可以用get()获取它的值。如果弱引用被清除了(不管是 referent 已经被垃圾收集了,还是有人调用了WeakReference.clear()),get()会返回null。相应地,在使用其结果之前,应当总是检查get()是否返回一个非 null 值,因为 referent 最终总是会被垃圾收集的。

用一个普通的(强)引用拷贝一个对象引用时,限制 referent 的生命周期至少与被拷贝的引用的生命周期一样长。如果不小心,那么它可能就与程序的生命周期一样 —— 如果将一个对象放入一个全局集合中的话。另一方面,在创建对一个对象的弱引用时,完全没有扩展 referent 的生命周期,只是在对象仍然存活的时候,保持另一种到达它的方法。

弱引用对于构造弱集合最有用,如那些在应用程序的其余部分使用对象期间存储关于这些对象的元数据的集合 —— 这就是SocketManager类所要做的工作。因为这是弱引用最常见的用法,WeakHashMap也被添加到 JDK 1.2 的类库中,它对键(而不是对值)使用弱引用。如果在一个普通HashMap中用一个对象作为键,那么这个对象在映射从Map中删除之前不能被回收,WeakHashMap使您可以用一个对象作为Map键,同时不会阻止这个对象被垃圾收集。清单 5 给出了WeakHashMap的get()方法的一种可能实现,它展示了弱引用的使用:

清单 5. WeakReference.get() 的一种可能实现

public class WeakHashMap implements Map {

private static class Entry extends WeakReference

implements Map.Entry {

private V value;

private final int hash;

private Entry next;

...

}

public V get(Object key) {

int hash = getHash(key);

Entry e = getChain(hash);

while (e != null) {

K eKey= e.get();

if (e.hash == hash && (key == eKey || key.equals(eKey)))

return e.value;

e = e.next;

}

return null;

}

调用WeakReference.get()时,它返回一个对 referent 的强引用(如果它仍然存活的话),因此不需要担心映射在while循环体中消失,因为强引用会防止它被垃圾收集。WeakHashMap的实现展示了弱引用的一种常见用法 —— 一些内部对象扩展WeakReference。其原因在下面一节讨论引用队列时会得到解释。

在向WeakHashMap中添加映射时,请记住映射可能会在以后“脱离”,因为键被垃圾收集了。在这种情况下,get()返回null,这使得测试get()的返回值是否为null变得比平时更重要了。

用 WeakHashMap 堵住泄漏

在SocketManager中防止泄漏很容易,只要用WeakHashMap代替HashMap就行了,如清单 6 所示。(如果SocketManager需要线程安全,那么可以用Collections.synchronizedMap()包装WeakHashMap)。当映射的生命周期必须与键的生命周期联系在一起时,可以使用这种方法。不过,应当小心不滥用这种技术,大多数时候还是应当使用普通的HashMap作为Map的实现。

清单 6. 用 WeakHashMap 修复 SocketManager

public class SocketManager {

private Map m = new WeakHashMap();

public void setUser(Socket s, User u) {

m.put(s, u);

}

public User getUser(Socket s) {

return m.get(s);

}

}

引用队列

WeakHashMap用弱引用承载映射键,这使得应用程序不再使用键对象时它们可以被垃圾收集,get()实现可以根据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 e;

while ( (e = (Entry) queue.poll()) != null) {

int hash = e.hash;

Entry prev = getChain(hash);

Entry cur = prev;

while (cur != null) {

Entry next = cur.next;

if (cur == e) {

if (prev == e)

setChain(hash, next);

else

prev.next = next;

break;

}

prev = cur;

cur = next;

}

}

}

回页首

结束语

弱引用和弱集合是对堆进行管理的强大工具,使得应用程序可以使用更复杂的可及性方案,而不只是由普通(强)引用所提供的“要么全部要么没有”可及性。下个月,我们将分析与弱引用有关的软引用,将分析在使用弱引用和软引用时,垃圾收集器的行为。

Java 理论与实践: 用弱引用堵住内存泄漏

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容

  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,293评论 18 399
  • 内存管理的目的就是让我们在开发中怎么有效的避免我们的应用出现内存泄漏的问题。内存泄漏大家都不陌生了,简单粗俗的讲,...
    宇宙只有巴掌大阅读 2,329评论 0 12
  • Java SE 基础: 封装、继承、多态 封装: 概念:就是把对象的属性和操作(或服务)结合为一个独立的整体,并尽...
    Jayden_Cao阅读 2,048评论 0 8
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,100评论 18 139
  • 之前和人说起,面试的一个经典问题是“从url输入到页面呈现,有哪些过程”。之所以说经典,是不管从哪个方面都可以说一...
    公子七阅读 227评论 0 0