前一段时间当我面试有些来应聘高级java开发工程师岗位的候选人时,在我问的众多问题中,有个问题是“你能告诉我弱引用是啥吗”,我不期望得到像论文中的细节一样的答案。我很可能从有个20多年的老工程师口中得到“嗯……是不是和gc有关”这样的答案,所有哪些至少有5年以上经验的工程师只有两个人知道弱引用的存在,只有其中一个知道引用的相关知识。我甚至尝试给他们解释下看是否有人会有“哦,原来是这样”的反应,然而并没有。我不确定为啥这个知识点鲜为人知,但自Java1.2之后发布的弱引用确实是有个非常有用的功能。
虽然作为一个java工程师我不建议你成为弱引用的专家,但我认为你至少应该知道他们是啥。换句话说你应该知道如何用他们。一直以来弱引用貌似是一个鲜为人知的功能,这里简单介绍下弱引用,以及如何使用和何时使用他们。
强引用(Strong references)
首先我们需要先来复习下强引用,强引用就是你每天在java中用到的最常见的引用,例如:
StringBuffer buffer = new StringBuffer();
上面一行代码创建了一个StringBuffer对象,并且用一个变量buffer存储了它的强引用。是的,就是这么简单,但请耐心听我说完。强引用最重要的部分,它强在哪里?是如何和gc交互的? 明确的说,如果一个对象通过强引用链可达,它就不会被gc掉。因为谁也不希望垃圾收集器毁掉我们正在用的对象。
强应用太强?
应用程序使用不能合理的继承的类的情况并不少见,这些类可能被简单标记为final,或者更复杂一些,比如由工厂方法返回的接口,该方法由数量未知(甚至不可知)的具体实现支持。假设你必须使用Widget类,但因为某些原因,不可能添加新功能。
如果你想持续追踪这个对象的额外信息会发生什么? 这种情况下,假设我们需要跟踪每个Widget的序列号,但是Widget类实际上没有序列号属性,而且因为Widget不能继承,我们也加不了。没关系,我们可以用hashmap。
serialNumberMap.put(widget, widgetSerialNumber);
表面上看起来可以了,但widget的强引用肯定会导致问题。我们必须百分百确定何时Widget的序列号没有在被用了,然后我们可以从map中移除这个实体。否则就会发生内存泄露(如果未移除不用的widget)或者莫名其妙的丢失序列号(如果移除还在用的widget)。这些问题听起来很熟悉吧,这是那些没有gc的语言在尝试管理内存时遇到的问题,在java这样的现代语言中,我们不用担心这个问题。
另一个常见的强引用问题就是缓存中,尤其是缓存像图片那样非常大的数据时。假设你一个给用户提供图片的应用,就像网页设计应用工具。你很自然的想到去缓存那些图片,因为从硬盘加载成本太高了,并且你也希望避免在内存中存在两份图片副本的可能性。
因为图片缓存应该可以避免我们每次都重新加载图片,但你会很快意识到cache任何时候都会包含已经加载到内存中图片的引用。但是,对于普通的强引用,该引用本身将强制图片保留在内存中,这就要求你(如上所述)以某种方式确定何时不再需要该图片,并将其从缓存中删除,这样它就有能被gc掉了。你又被迫重复实现了垃圾收集器的功能。
弱引用(Weak references)
弱引用,简单说就是不是那么能够强到让对象保持在内存中的应用。 弱引用能让你拥有GC的能力,让你能确定对象的可达性。你不用自己做,你只需要像下面一样创建一个弱引用就行了。
WeakReference<Widget> weakWidget = new WeakReference<Widget>(widget);
在代码的其他地方你就可以用weakWidget.get() 真正的Widget对象了。弱应用没有强大到能阻挡GC,所以你会发现当没有强引用指向widget时,weakWidget.get()会返回null。
为了解决上文提到的widget序列号的问题,最简单的方式用就是用WeakHashMap,WeakHashMap和HashMap的工作方式很像,除了WeakHashMap把key替换为弱引用(不是Value),如果WeakHashMap的key变成了垃圾对象,整个entry会被自动清除。这种方式避免了我提到的陷阱,而且也只是需要把HashMap替换为WeakHashMap就足够了。如果你代码遵循Map的接口标准,甚至都不需要改其他代码。
引用队列(Reference queues)
一旦弱引用开始返回null,它指向的对象肯定已经被gc掉了,弱引用对象也没啥用了。通常这意味着可以做一些清理工作了。对于WeakHashMap而言,它会清理到没用的entry,从而避免存着越来越多的死弱引用。
引用队列让跟踪死引用变得容易。如果你给WeakReference传一个ReferenceQuene的构造参数,当弱引用所指向的对象变成垃圾对象后,引用对象会被自动插入到引用队列中。然后你就可以通过引用队列里的对象来做一些必要的清理工作了。
各种不同强度的应用 Different degrees of weakness
除了上面我提到的弱引用外,其实java总共有4中不同的引用,其引用强度从强到弱分别是强应用、软引用、弱引用、虚引用。我们上文已经讨论过强应用和弱引用,接下来我们看下软引用和虚引用。
软引用(Soft references)
软引用和弱引用很想,除了它并没有弱引用那么急着想扔掉它引用的对象。一个只被弱引用引用的对象会在下次gc的时候被处理掉,但被软引用引用的对象会存在一段时间。
软引用和弱引用行为没啥不同,但在实际过程中,只要内存足够,软引用引用的对象会一直被保留。这是作为缓存很好的一个基础,比如上面提到的图片缓存问题,然后你就可以让gc去考虑哪些对象可达和这些对象消耗了多少内存。
虚引用(Phantom references)
虚引用和软引用、弱引用都不同。他对对象的应用非常弱,弱到你都不能通过get方法获取的对象(get始终返回null)。他只能用来跟踪某个对象何时进入引用队列,只要它进队列了,就说明对象已死,但这和弱引用有什么区别?
区别就是入队的发生发生时间不一样。弱引用只要对象变成弱可达就入队列,是在finalization和GC之前,理论上,对象可以被某些非正规的finalize复活,但指向其的弱引用则不会。虚引用只会在对象从内存中移除时入队,get()始终返回null是为了防止你复活将死的对象。
那虚引用有什么好的地方?我只列举两点。首先,它可以让你判断是否一个对象已经被从内存中删除,事实上只有这一种方法判断,大部分情况下这个没啥用,但在某些非常特殊的情况下,比如操作大型图像时,它可能会派上用场:如果您确定某个映像应该被gc掉,那么你可以等到它确实被gc之后再尝试加载下一个图片,从而低OutOfMemoryError发生的可能性。
其次,虚引用避免了finalize()通过创建强应用复活一个对象的问题。你说啥?问题是如果一个对象重载了finalize()方法,通过两次gc周期它才能被回收。第一次是确定它是否是垃圾对象,然后它就变成finalization。因为有可能它在finalization过程中会被复活,gc收集器必须重新gc来确保对象被真正去除掉。并且由于finalization可能没有及时发生,因此在对象再被gc掉前可以经历了非常多次的gc周期。 这可能意味着实际清理垃圾对象的严重延迟,这就是为什么即使堆里大多数对象都是垃圾也会导致OutOfMemoryErrors。
用虚引用,这种情况是不可能出现的,绝对没有方法获取到一个指向已死对象的指针(因为已经不在内存里了)。因为虚引用不能用来复活一个对象,这个对象可以在gc的第一阶段发现只有虚引用引用的时候被清理掉。然后你可以在方便的时候处理你需要的任何资源。
可以说,finalize()最开始就不应当被提供。虚引用比finalize()更加高效和安全,放弃finalize()也可以让VM更简单。还有很长的路要走,我承认我大多数时候仍然用finalize(),但好消息是你至少有个选择。
结论
看到这你肯定已经在发恼骚了,因为我正在给你们讲已经有近10年历史的api,而且也没讲新内容。 但这确实是事实,好多java程序猿真的不了解弱引用,而且也需要学习下。我希望你能从这篇文章学到一些东西。