7.3.5 幽灵引用
幽灵引用(phantom reference)是强度最弱的一种引用类型,用java.lang.ref.PhantomReference类来表示。幽灵引用的主要目的是在一个对象所占的内存被实际回收之前得到通知,从而可以进行一些相关的清理工作。幽灵引用在使用方式上与之前介绍的两种引用类型有很大不同:首先幽灵引用在创建时必须提供一个引用队列作为参数;其次幽灵引用对象的get方法总是返回null,因此无法通过幽灵引用来获取被引用的对象。
幽灵引用在使用的时候只能通过引用队列来操作。幽灵引用的最大优势在于引用对象被添加到队列中的时机。Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。Object类提供了finalize方法来添加自定义的销毁逻辑。如果一个类有特殊的销毁逻辑,可以覆写finalize方法。从功能上来说,finalize方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize方法在本质上不同于C++中的析构函数。当垃圾回收器发现没有引用指向一个对象时,会调用这个对象的finalize方法。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。由于finalize方法的存在,虚拟机中的对象一般处于三种可能的状态。第一种是可达状态,当有引用指向该对象时,该对象处于可达状态。根据引用类型的不同,有可能处于强引用可达、软引用可达或弱引用可达状态。第二种是可复活状态,如果对象的类覆写了finalize方法,则对象有可能处于该状态。虽然垃圾回收器是在对象没有引用的情况下才调用其finalize方法,但是在finalize方法的实现中可能为当前对象添加新的引用。因此在finalize方法运行完成之后,垃圾回收器需要重新检查该对象的引用。如果发现新的引用,那么对象会回到可达状态,相当于该对象被复活;否则对象会变成不可达状态。当对象从可复活状态变成可达状态之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可达状态,也就是说,一个对象的finalize方法只会被调用一次。第三种是不可达状态,在这个状态下,垃圾回收器可以自由地释放对象所占用的内存空间。对象终止机制在第10章进行详细介绍。
软引用和弱引用在其可达状态达到时就可能被添加到对应的引用队列中。也就是说,当一个对象变成软引用可达或弱引用可达的时候,指向这个对象的引用对象就可能被添加到引用队列中。在添加到队列之前,垃圾回收器会清除掉这个引用对象的引用关系。当软引用和弱引用进入队列之后,对象的finalize方法可能还没有被调用。在finalize方法执行之后,该对象有可能重新回到可达状态。如果该对象回到了可达状态,而指向该对象的软引用或弱引用对象的引用关系已经被清除,那么就无法再通过引用对象来查找这个对象。而幽灵引用则不同,只有在对象的finalize方法被运行之后,幽灵引用才会被添加到队列中。与软引用和弱引用不同的是,幽灵引用在被添加到队列之前,垃圾回收器不会自动清除其引用关系,需要通过clear方法来显式地清除。当幽灵引用被清除之后,对象就进入了不可达状态,垃圾回收器可以回收其内存。当幽灵引用被添加到队列之后,由于PhantomReference类的get方法总是返回null,程序也不能对幽灵引用所指向的对象进行任何操作。这就避免了finalize方法可能会出现的对象复活的问题。幽灵引用是作为一个通知机制而存在的。程序应该在得到通知之后进行与当前对象相关的清理工作。
代码清单7-10给出了幽灵引用对象的使用示例。在类ReferencedObject中覆写finalize方法来提供自定义的销毁逻辑。这里只是简单地在控制台输出提示信息。在使用幽灵引用队列时,通过队列的poll方法来进行轮询。如果队列为空,就通过System.gc方法来建议垃圾回收器运行。运行示例之后会发现finalize方法中输出的消息总是最早出现,这说明当幽灵引用进入队列之后,finalize方法已经被运行过了。如果改用软引用或弱引用来进行相同试验,会发现多次运行的结果并不一致,这是因为软引用和弱引用进入队列的时机和finalize方法的调用之间并没有必然的先后关系。
代码清单7-10 幽灵引用对象的使用示例
public class UseReferenceQueue{
private static class ReferencedObject{
protected void finalize()throws Throwable{
System.out.println("finalize方法被调用。");
super.finalize();
}
}
public void phantomReferenceQueue(){
ReferenceQueue<ReferencedObject>queue=new ReferenceQueue<>();
ReferencedObject obj=new ReferencedObject();
PhantomReference<ReferencedObject>phantomRef=new PhantomReference<Refe rencedObject>(obj, queue);
obj=null;
Reference<?extends ReferencedObject>ref=null;
while((ref=queue.poll())==null){
System.gc();
}
phantomRef.clear();
System.out.println(ref==phantomRef);//值为true
System.out.println("幽灵引用被清除。");
}
}
如果希望在一个对象的内存被回收之前进行某些清理工作,那么相对于使用finalize方法来说,使用幽灵引用是更好的选择。幽灵引用避免了finalize方法可能造成对象复活的问题,减少了开发时可能出现的错误。不过幽灵引用的使用比finalize方法要复杂得多。最主要的问题是从引用队列中获取幽灵引用之后,无法获取其指向的对象,也就无法对这个对象进行操作。幽灵引用本身只作为一个通知机制存在,必须存在其他指向此对象的引用。因此,相对于使用幽灵引用,开发人员更倾向于谨慎使用finalize方法。只要finalize方法的实现避免了对象复活的问题,就不失为一个不错的选择。
一个比较实际的幽灵引用的应用是在虚拟机内存总量受限的情况下。当内存总量受限时,可能需要等待一个占用内存空间较大的对象被回收之后再申请新的内存空间。通过这种方式,可以把程序中某部分占用的内存控制在一定的范围之内。代码清单7-11给出了一个示例。类PhantomAllocator用来分配一个字节数组供调用者使用。当每次分配新的字节数组时,会先确保之前分配的内存空间被成功释放。在每次分配新的字节数组之前,引用队列的remove方法会处于阻塞状态,直到有新的引用对象被添加到队列中。remove方法返回之后,之前的字节数组的内存已经可以被释放,通过调用System.gc方法要求垃圾回收器马上回收这些内存。等内存回收之后再创建新的字节数组,创建一个幽灵引用指向新的字节数组,并与引用队列关联起来。
代码清单7-11 使用幽灵引用的内存分配方式
public class PhantomAllocator{
private byte[]data=null;
private ReferenceQueue<byte[]>queue=new ReferenceQueue<byte[]>();
private Reference<?extends byte[]>ref=null;
public byte[]get(int size){
if(ref==null){
data=new byte[size];
ref=new PhantomReference<byte[]>(data, queue);
}
else{
data=null;
System.gc();
try{
ref=queue.remove();
ref.clear();
ref=null;
System.gc();
data=new byte[size];
ref=new PhantomReference<byte[]>(data, queue);
}catch(InterruptedException e){
e.printStackTrace();
}
}
return data;
}
}
在上面的代码中,通过“data=null”来清除PhantomAllocator类对象本身对字节数组的强引用。在进行测试的时候,可以进行多次字节数组分配操作,同时使用工具来观察程序所占用的堆内存的情况。实际的运行结果是,程序的堆内存的占用量的峰值会维持在一个相对稳定的值。