7.3 引用类型
在Java程序中可以通过System.gc方法来建议垃圾回收器立即进行回收工作。除此之外,Java程序本身与垃圾回收器能够进行的直接交互比较有限。垃圾回收器在进行回收时并不了解运行程序的具体特征。因此,在某些情况下,垃圾回收器可能并不能够按照程序所期望的方式工作。比如,一个图像处理程序可能同时打开多个图像文件进行编辑,而同一时刻只有一张图片处于编辑状态。当同时打开的图片过多时,程序所占用的内存空间会变大,垃圾回收器无法回收这些处于活动状态的对象所占用的内存,而使虚拟机中的可用内存不断减少。当垃圾回收器无法找到可用的空闲内存时,创建新对象的操作会抛出java.lang.OutOfMemoryError错误,导致虚拟机退出。而就这个图像处理程序的特征来说,当可用内存不足的时候,可以把那些当前不处于编辑状态的图像所占用的内存释放掉,这样的垃圾回收操作对这个程序来说是合理的。同样,其他的程序也可能有类似的情况。
对于以上情况,Java程序需要通过一种方式把其中的对象在内存需求方面的特征传达给垃圾回收器。垃圾回收器根据对象的特征可以更好地进行回收。比如,在虚拟机可用内存不足的时候,释放程序中更多的内存。这种传达方式是通过Java中对象的引用类型来实现的。在程序的运行过程中,对于同一个对象,可能存在多个指向它的引用。如果不再有引用指向一个对象,那么这个对象会成为垃圾回收的候选目标。Java语言中存在不同的对象引用类型。不同类型的引用对垃圾回收器的含义是不同的。
7.3.1 强引用
在Java程序中,最常见的引用类型是强引用(strong reference),它也是默认的引用类型。当在Java语言中使用new操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。在前面提到过,判断一个对象是否存活的标准为是否存在指向这个对象的引用。垃圾回收器可能采取不同的算法来判断对象的引用是否存在。一个常见的做法是使用引用计数器。当有新的引用指向某个对象时,把该计数器的值加1;当一个引用失效时,就把该计数器的值减1。例如,显式地把一个引用某个对象的变量的值设为null,该对象的引用计数器的值会减1。当一个对象的引用计数器的值变为0的时候,说明不存在任何指向该对象的引用,该对象可以被垃圾回收器回收。引用计数器的原理比较简单,但是实现起来需要编译器的支持,另外使用引用计数器不能解决循环引用孤岛的回收问题。比如,3个对象之间互相存在引用关系,但是并不存在指向这3个对象的其他引用,这三个对象实际上就成为了内存区域中的一个孤岛。这3个对象的引用计数器的值都不为0,因此无法通过引用计数器的方式来回收。
由于引用计数器存在无法处理“孤岛”的问题,Java虚拟机的垃圾回收器没有采用这种做法,而是采取跟踪对象引用的做法。这种做法会从虚拟机内存中的某些存活对象开始,递归检查这些对象所引用的其他对象,直到找到不引用其他对象的对象为止。在这个过程中所发现的所有对象都会被标记为存活的,而其他对象则是可以被回收的。这个遍历过程的起始对象是一个集合,称之为根集合。根集合中一般包含系统类、程序寄存器、JNI全局引用、静态变量和线程的当前活动栈中的变量所指向的对象等。可以将这个跟踪过程看成是基于引用关系的树的遍历。在跟踪过程中发现的存活对象被称为可达的(reachable)。将从遍历的根节点到当前存活对象的路径称为可达路径。这条路径的边对应的是对象之间的引用。如果一个对象的可达路径中只包含强引用,则把这个对象称为强引用可达的(strongly reachable)。程序中的大多数存活对象都是强引用可达的。
对于垃圾回收器来说,强引用的存在会阻止一个对象被回收。在垃圾回收器遍历对象引用并进行标记之后,如果一个对象是强引用可达的,那么这个对象不会作为垃圾回收的候选。因为该对象仍然被程序所使用,回收其内存显然是一个错误的做法。虽然由于垃圾回收器的存在,Java虚拟机中并不存在真正意义上的内存泄露,但是某些错误的用法会对程序中所能使用的内存空间造成影响。这些情况可以看成是另外一种意义上的内存泄露,这些内存泄露的发生也都和强引用的使用有关。
通常来说,这种意义上的内存泄露有两种情况:一种是虚拟机中存在程序无法使用的内存区域。这些内存区域被程序中一些无法使用的存活对象占用。这些对象由于存在隐式的强引用,无法对其进行垃圾回收。但是在程序的正常运行过程中,这些对象也无法被使用。造成这种问题的原因通常是程序编写时的逻辑错误。另一种情况是程序中存在大量存活时间过长的对象。这些对象的存活时间长于使用它们的对象。在正常情况下,这些对象在引用它们的对象被回收之后,也应该被回收,但是由于某些程序中的错误而没有被回收。这些对象无法被回收,仍然占据着虚拟机中的内存资源。时间长了,虚拟机会因为没有足够的内存分配给新的对象而抛出OutOfMemoryError错误。下面会通过示例对这两种情况进行具体的说明。
对于第一种情况,代码清单7-1中给出了一个简单的先进先出队列的实现。它在内部使用了一个java.util.List接口的实现对象来保存队列中的对象。向队列中添加的新对象会被直接放在List接口实现对象的末尾。队列的队首位置则由内部变量topIndex来维护。每次有对象被移出队列时,topIndex的值会增加1。这个队列实现的问题在于出队列的方法只简单地改变了topIndex的值,并没有把对象从队列中删除。在经过若干次队列操作之后,topIndex的值会逐渐变大。变量backendList所指向的对象中包含的序号小于topIndex的对象无法被队列的使用者通过正常的方式来访问。由于backendList所指向的对象仍然包含指向这些对象的强引用,因此这些对象也无法被垃圾回收,这些对象占用的内存就成为虚拟机中无法使用的区域。
代码清单7-1 产生内存泄露的先进先出队列的实现
public class LeakingQueue<T>{
private List<T>backendList=new ArrayList<T>();
private int topIndex=0;
public void enqueue(T value){
backendList.add(value);
}
public T dequeue(){
T result=backendList.get(topIndex);
topIndex++;
return result;
}
}
第二种情况的典型情景发生在使用基于内存实现的缓存的时候。代码清单7-2中给出了一个示例。利用calculate方法进行实际运算所需的时间可能比较长,因此使用了一个java.util.HashMap类的对象来保存之前计算的结果。在calculate方法被调用时,会先检查缓存中是否已经存在之前计算出来的结果,这样可以避免重复的计算,进而提高性能。不过这种做法延长了计算结果对象的存活时间。在不使用缓存的情况下,在calculate方法的调用者获得计算结果对象,并完成对该对象的使用之后,就可以对该对象进行垃圾回收。当使用了缓存之后,计算结果对象的存活时间就变得至少和用来进行缓存的HashMap类的对象一样长。因为HashMap类的对象有其所包含的所有计算结果对象的引用,所以,只要HashMap类的对象无法被回收,其中所包含的计算结果对象也无法被回收。在calculate方法被多次调用之后,缓存中包含的对象会越来越多,导致占用的内存越来越大,而程序中其他部分可用的内存则越来越少。
代码清单7-2 使用缓存造成对象存活时间过长的示例
public class Calculator{
private Map<String, Object>cache=new HashMap<String, Object>();
public Object calculate(String expr){
if(cache.containsKey(expr)){
return cache.get(expr);
}
Object result=doCalculate(expr);
cache.put(expr, result);
return result;
}
private Object doCalculate(String expr){
return new Object();
}
}
从前面两个示例中可以看出,强引用所提供的与垃圾回收器的交互功能非常有限。当强引用存在的时候,所指向的对象无法被垃圾回收。为了增强程序与垃圾回收器的交互能力,JDK 1.2引入了java.lang.ref包,提供了3种新的引用类型,分别是软引用、弱引用和幽灵引用。这些引用类型除了可以引用对象之外,还可以在不同程度上影响垃圾回收器对被引用对象的处理行为。