java的垃圾回收器负责回收无用对象占据的内存资源,不再需要程序员手动释放,避免了“内存泄漏”的风险。然而将清理工作交给垃圾回收器并非万无一失的,垃圾回收器只会清理通过new来创建的对象,假如说你的对象获得了一块“特殊”的内存资源(并非使用new),垃圾回收器是不会释放这块资源的。
为了应对这种情况,java允许在类中定义一个名为finalize()的方法,它的工作原理是:一旦垃圾回收器准备好释放对象占用的存储空间,将首先调用其finalize()方法,并且在下一次垃圾回收动作发生时,才会真正回收对象占用的内存。所以可以用finalize()来清理上述“特殊”资源。
这里面有几个注意事项
1:对象可能不被垃圾回收
垃圾回收并不是时刻发生的,只有当程序濒临存储空间用完的那一刻,对象占用的空间才会得到释放。如果一直到程序结束,垃圾回收器也没有释放任何对象的存储空间,则随着程序的退出,那些资源也会全部交还给操作系统。
2:垃圾回收只与内存有关
使用垃圾回收器的唯一原因是为了回收程序不再使用的内存,所以对于与垃圾回收有关的任何行为来说(尤其是finalize()方法),它们也必须同内存及其回收有关。java中一切皆是对象(由new()创建),那么有哪些特殊情况需要由finalize()来清理内存?这种特殊情况主要发生在使用“本地方法”时,本地方法是一种在java中调用非java代码的方式,比如C和C++。在非java代码中也许会调用C的malloc()函数来分配内存,即便将包含本地方法的对象回收了,这块资源也是一直被占用的,从而造成内存泄漏,所以需要在finalize()中调用C的free(),当然也是通过本地方法来调用。
所以不能将finalize()当做普通的清理方法来使用,而应该仅仅用它来清理内存。对于其他的一些清理工作,程序员应当创建一个普通的方法,并在适当的时刻明确调用它。
关于finalize()还有一种常见的用法,即“对象终结条件的验证”,例如:
class Book{
boolean checkOut = false;
Book(boolean checkOut){
this.checkOut = checkOut;
}
void checkIn(){
checkOut = false;
}
protected void finalize(){
if(checkOut){
System.out.println("Error");
}
}
}
public class Test{
public static void main(String[] args){
Book book1 = new Book(true);
book1.checkIn();
new Book(true);
System.gc(); //强制执行垃圾回收动作
}
}
本例的终结条件是:所有的Book对象在被当做垃圾回收前都应该被签入(check in),但在main()中,由于程序员的错误,有一本书未被签入。要是没有finalize()来验证终结条件,将很难发现这种缺陷。
那么、垃圾回收器是如何工作的呢?
一般来说,在堆上分配对象的速度是比较慢的,而java中所有的对象(基本类型除外)都在堆上分配,java通过垃圾回收器,有效的提高了对象的创建速度,使得java从堆分配空间的速度,可以和其他语言从堆栈上分配空间的速度相媲美。打个比方,可以把C++里的堆想象成一个“院子”,里面每个对象都负责管理自己的地盘,一段时间后,对象可能被销毁,但是地盘必须重用,C++将创建对象的大部分时间花费在寻找这块地盘上。而java截然不同,它更像一个传送带,每分配一个对象,传送带就往前移动一格,java的“堆指针”只是简单地移动到尚未分配的区域,这意味着对象存储空间的分配速度非常快。
然而java中的堆并非完全像传送带那样工作,真要是那样的话,内存资源将很快耗尽。其中的秘密就在于垃圾回收器的介入,当它工作时,将一面回收空间,一面使堆中的对象紧凑排列。这样“堆指针”将很容易移动到传送带的开始处,通过垃圾回收器对对象重新排列,实现了一种高速的,有无限空间可供分配的堆模型。
然后我们来了解下垃圾回收的机制。
第一种叫做引用计数的方式,这种计数简单但是速度很慢。每个对象都含有一个引用计数器,当有引用连接到对象时,引用计数+1,当引用离开作用域或被置为null时,引用计数-1。垃圾回收器会在含有全部对象的列表上遍历,当发现某个对象的引用计数为0时,就释放其占用的空间。然而这种方法有个缺陷,如果对象之间存在循环引用,可能会出现“对象应该被回收,但引用计数却不为0”的情况。例如:
public class GcDemo {
public static void main(String[] args) {
GcObject obj1 = new GcObject(); //Step 1: 对象1的引用计数为1
GcObject obj2 = new GcObject(); //Step 2: 对象2的引用计数为1
obj1.instance = obj2; //Step 3: 对象2的引用计数+1=2
obj2.instance = obj1; //Step 4: 对象1的引用计数+1=2
obj1 = null; //Step 5:对象1的引用计数-1=1
obj2 = null; //Step 6:对象2的引用计数-1=1
}
}
class GcObject{
public Object instance = null;
}
上述例子中,两个对象都已经是垃圾了,但是引用计数却还是1。所以从没有哪个java虚拟机用过这种清理方式。
在一些更快的方式中,并非依赖引用计数。它们依据的思想是:对任何“活”的对象,一定能最终追溯到其存活在堆栈或静态存储区中的引用。这个引用链可能会穿过多个对象层次。由此,如果从堆栈和静态存储区开始,遍历所有的引用,就能找到所有“活”的对象。对于发现的每个引用,必定追溯它所引用的对象,然后是此对象包含的引用,如此反复进行,直到“根源于堆栈和静态存储区的引用”所形成的网络被全部访问为止,所有未被发现的对象就会被自动回收。
在这种方式下,java虚拟机采用一种自适应的垃圾回收技术。有一种模式叫做停止—复制:先暂停程序的运行,然后将所有存活的对象从当前堆复制到另一个堆,就可以把旧堆当做垃圾清理掉,当对象被复制到新堆时,它们是一个挨着一个紧凑排列的,这样就可以按照之前的办法简单、直接、快速地分配新空间了。这种方法也有其问题,当对象从一处搬到另一处时,所有指向它的那些引用都必须修正,还有一些包含在对象中的引用,只有在遍历的过程中才能被找到,这会导致效率较低,并且在把对象在两个堆中来回倒腾,从而得维护比实际需要多一倍的空间。对此某些java虚拟机的做法是:按需从堆中分配几块比较大的内存,复制动作发生在这些大块内存之间。第二个问题在于复制,程序进入稳定状态后,可能只会产生少量垃圾,甚至没有垃圾。尽管如此,回收器也会将所有的对象从一处复制到另一处,这很浪费,为了避免这种情况,一些java虚拟机会进行检查,要是没有新垃圾产生,就会转换到另一种模式。
这种模式叫做标记—清扫,,这种模式对于一般用途而言,速度相当慢,但是当你知道只会产生少量垃圾甚至不会产生垃圾时,它的速度就很快了。标记—清扫所依据的思路同样是从堆栈和静态存储区出发,遍历所有的引用,进而找到所有存活的对象。每当它找到一个存活的对象,就会给对象设一个标记,当全部标记工作完成的时候,清理动作才会开始。清理过程中,没有标记的对象将被释放,不会发生任何复制动作,所以剩下的堆空间是不连续的,垃圾回收器希望得到连续空间,还得重新整理剩下的对象(这一步比较费时)。
如上所述,清理效率较高的java虚拟机中,内存分配以比较大的“块”为单位,如果对象较大,它会占用单独的块。有了块之后,垃圾回收器就可以往废弃的块里复制对象了。每个块都有相应的代数来记录它是否还存活,如果块在某处被引用,其代数会增加。垃圾回收器在执行清理动作时,代数为0的块被废弃,大型对象不会被复制(它是单独的块),内含小型对象的那些块会被复制并整理。同时java虚拟机会进行监视,如果所有对象都很稳定,垃圾回收器的效率降低的话,就切换到标记—清扫模式(只清理,不整理)。同样,java虚拟机会跟踪标记清扫的效果,如果堆空间出现很多碎片,就会切换回停止—复制方式,这就是自适应垃圾回收技术。