从接触第一门编程语言开始我们就在不断强调,初始化变量是很重要的一件事情,尤其在C和C++中很明显,对于指针来说,定义一个指针不对它进行初始化,很有可能引发内存上的严重问题。但是我们也会常常忘记同样重要的清理工作。在Java中,垃圾回收器就负责回收无用对象占据的内存资源。
在C++中,所有对象都会被销毁,或者说应该被销毁。如果C++创建了一个局部对象,此时的销毁动作发生在“右括号”为边界,此对象作用域的末尾处。如果对象是用new创建的,那么我们应该调用C++的delete操作符,也就会调用相应的析构函数,如果我们忘记调用delete,那么永远不会调用析构函数,这样就会出现内存泄漏,对象的其他部分也就得不到清理。
相反的是,Java不允许创建局部对象,必须使用new创建对象。在Java中也没有用于释放对象的delete,所以垃圾回收器会帮你释放存储空间。但是随着我们学习的深入,发现垃圾回收并不是万能的,也就是说我们不能认为垃圾回收器可以替代C++中的析构函数。所以如果我们希望进行除释放空间之外的清理工作,还是得明确调用某个恰当的Java方法。
我们须记得,无论是“垃圾回收”还是“终结”,都不一定保证会发生,如果Java虚拟机并未面临内存耗尽的情形,他是不会浪费时间去执行垃圾回收以恢复内存的。
现在我们开始大家感兴趣的话题,垃圾回收器到底是怎么工作的。首先从我们以前的认识上来说,在堆上创建对象的代价十分高昂,因此我们会自然的认为Java把所有的对象都在堆上创建的代价也十分高昂。但垃圾回收器对于提高对象的创建速度却有十分明显的效果。这的确是Java虚拟机的工作方式,所以Java从堆分配空间的速度,可以和其他语言从堆栈上分配空间的速度相媲美。
我们可以想象,在Java中堆就像一个传送带,每分配一个新对象,他就往前移动一格,这意味着对象存储空间的分配速度非常的快,Java的“堆指针”只是简单的移动到尚未分配的区域,其效率比得上C++在堆栈上分配空间的效率。
实际上,Java的堆并不完全像传送带那样,因为这势必会导致频繁的内存页面调度,将其移进移出硬盘,页面调度会显著的影响性能,最终在创建了足够多的对象之后,内存资源将会被耗尽。秘密就在垃圾回收器,垃圾回收器在工作的时候会一边回收内存,一边将堆中的对象紧凑排列,这样“堆指针”就能很容易的移动到更靠近传送带的开始处,也就尽量避免了内存错误。这是一种高速的,有无限空间可供分配的堆模型。
我们如果想要先了解Java中的垃圾回收,先来看一种垃圾回收机制:引用计数。
它是一种很简单但速度很慢的垃圾回收技术。每个对象都有一个计数器,当有引用接至对象的时候,引用计数加1,当引用离开作用域或被置为null时,计数器减一,虽然对于它的管理开销不大,但是他会持续整个程序的生命周期。垃圾回收器会在含有全部对象的列表上遍历,当发现某个对象的引用计数为0时,就释放其占用的空间。这个方法有个缺陷,就是当对象进行循环引用的时候,对象应该会被回收,但引用计数却不为0。实际上,引用计数经常用来说明垃圾回收的工作方式,但似乎并未被应用于任何一种Java虚拟机中。
还有一些更快的模式,他们引用的思想是:对于任何“活”的对象,一定能最终追溯到其存活在堆栈或静态存储区之中的引用。这个引用链条可能会穿过数个对象层次。如果从堆栈和静态存储区开始,遍历所有的引用,就能找到所有活的对象,对于发现的每个引用,必须追踪他所引用的对象,然后是此对象包含的所有引用,如此反复进行,直到“根源于堆栈和静态存储区的引用”所形成的网络全部被访问为止,倒是有点类似于图的广度优先搜索。这就解决了对象的交互自引用的现象。
在这种技术中有一种名为“停止-复制”的方法,用来处理找到的活的对象。它是先暂停程序的运行,然后将所有存活的对象从当前堆复制到另一个堆,没有被复制的全部是垃圾,当对象被复制到新堆时,他们是一个紧挨一个的,就可以使用前述方法简单的,直接的分配新的空间了。
但是这种方法效率会比较低,有两个原因:首先得有两个堆,然后在这分离的两个堆之间来回倒腾,从而维护比实际需要多一倍的空间。某些java虚拟机会按需从堆中分配几块较大的内存,复制动作发生在这些大块内存之间。第二在于复制。程序进入稳定状态之后,可能只会产生少量垃圾,甚至没有垃圾,尽管如此,复制式回收器仍然会将所有内存自一处复制到另一处,很浪费。为了避免这种情况,一些Java虚拟机就会进行检查:要是没有新垃圾产生,就会转换到另一种工作模式。这种模式称为“标记-清扫”,它的思路和解决计数引所产生的对象自引用的思路很相似,只不过,它是找到一个“活”的对象就给它一个标记,这个过程并会回收任何对象,只有当标记工作完成的时候,清理动作才会开始,在清理的过程中,没有标记的对象将被释放,不会发生任何复制动作,因此得到的内存是不连续的。
在这里所讨论的Java虚拟机,内存分配都以较大的“块”为单位。如果对象较大,它会占用单独的块。严格来说,“停止-复制”要求在释放旧有对象之前,必须先把所有的存活对象从旧堆复制到新堆,这将导致大量的内存复制行为,有了“块”以后,垃圾回收器在回收的时候就可以直接往废弃的块里拷贝对象了。每个块都用相应的代数来记录它是否还存活,如果块在某处被引用,其代数会增加,垃圾回收器对上次回收动作之后新分配的块进行整理,这对处理大量短命的临时对象很有帮助。垃圾回收器会定期进行完整的清理动作。Java虚拟机会进行监视,如果所有对象都很稳定,垃圾回收器的效率低的话,就切换到“标记-清扫”的方式,最后如果堆空间又出现很多碎片,就会切换回“停止-复制”的方式。这就是“自适应技术”。
Java虚拟机中还有很多附加技术用以提升速度,好比:“即时”编译器技术和“惰性评估”,后一种方法现在比较普遍。
在Java中,有一个名为finalize()的方法,他的工作原理假定是这样:一旦垃圾回收器准备好释放对象占用的存储空间,将首先调用其finalize方法,并且在下一次垃圾回收动作发生时,才会真正回收对象占用的内存。所以你要是打算用finalize方法,就能在垃圾回收时刻做一些重要的清理工作。
为什么有这个方法,因为在Java中有些对象可能不被垃圾回收;垃圾回收不等于C++中的析构。所以我们在不需要某个对象之前必须执行某些动作。Java并不提供析构,要做类似的工作,必须自己手动创建一个执行清理工作的普通方法。
其实垃圾回收器的运行时间是不确定的,由JVM确定,即使执行,也是间歇性执行,但JVM通常会在感到内存紧缺的时候去进行垃圾回收操作,也为执行垃圾回收也是需要开销的,如果过于频繁,势必导致效率的下降,如果随着程序的执行结束,垃圾回收器还是没有释放你创建的任何对象的存储空间,则随着程序的退出,资源会全部交还给OS。
无论是“垃圾回收”还是“终结”,都不保证一定会发生。如果Java虚拟机并未面临内存耗尽的情形,它是不会浪费时间去执行垃圾回收以恢复内存的。
只要对象存在没有被适当清理的部分,程序就有很隐晦的缺陷。finalize方法可以用来最终发现这种情况,但是我们不能指望finalize方法。
以下是一个简单的例子:
import static java.lang.System.out;
/** * Created by paranoid on 17-3-21. */
class Book{
boolean checkedout = false;
Book(boolean checkout){
checkedout = checkout;
}
void checkIn(){
checkedout = false;
}
//进行终结处理
protected void finalize(){
if(checkedout){
out.println("Error: checked out");
}
}
}
public class TerminationCondition {
public static void main(String[] args){
Book novel = new Book(true);
novel.checkIn();
new Book(true);
System.gc();
}
}
所有的Book对象在被当作垃圾回收之前都应该被签入(check in)。但是本例中有一本书未被签入。要是
没有finalize方法验证终结条件,将很难发现这种缺陷。
注:系统调用finalize方法的时间是不确定的,虽然System.gc()是强制回收但也只是通知系统尽快去处理。