一、垃圾回收器
Java中存在着垃圾回收器来回收无用对象的内存,但是存在着一种特殊的情况:因为垃圾回收器只知道如何释放用new创建的对象的内存,假如我们创建的对象不是通过new来分配内存的,那么垃圾回收器就不知道如何回收这部分内存。为了处理这种情况,Java允许在类中定义一个名为finalize()
的方法。
它的工作原理"假定"是这样的:当垃圾回收器准备回收对象的内存时,首先会调用其 finalize() 方法,并在下一轮的垃圾回收动作发生时,才会真正回收对象占用的内存。所以如果你打算使用 finalize() ,就能在垃圾回收时做一些重要的清理工作。finalize() 是一个潜在的编程陷阱,因为一些程序员(尤其是 C++ 程序员)会一开始把它误认为是 C++ 中的析构函数(C++ 在销毁对象时会调用这个函数)。所以有必要明确区分一下:在 C++ 中,对象总是被销毁的(在一个 bug-free 的程序中),而在 Java 中,对象并非总是被垃圾回收,或者换句话说:
- 对象可能不被垃圾回收。
- 垃圾回收不等同于析构。
这意味着在你不再需要某个对象之前,如果必须执行某些动作,你得自己去做。Java 没有析构器或类似的概念,所以你必须得自己创建一个普通的方法完成这项清理工作。例如,对象在创建的过程中会将自己绘制到屏幕上。如果不是明确地从屏幕上将其擦除,它可能永远得不到清理。如果在 finalize() 方法中加入某种擦除功能,那么当垃圾回收发生时,finalize() 方法被调用(不保证一定会发生),图像就会被擦除,要是"垃圾回收"没有发生,图像则仍会保留下来。
也许你会发现,只要程序没有濒临内存用完的那一刻,对象占用的空间就总也得不到释放。如果程序执行结束,而垃圾回收器一直没有释放你创建的任何对象的内存,则当程序退出时,那些资源会全部交还给操作系统。这个策略是恰当的,因为垃圾回收本身也有开销,要是不使用它,那就不用支付这部分开销了。
1.1 finalize()的用途
使用垃圾回收的唯一原因就是为了回收程序员不再使用的内存。所以对于与垃圾回收有关的任何行为来说(尤其是finalize()
方法),它们也必须同内存及其回收有关。
在Java中,无论对象是如何创建的,垃圾回收器都会负责释放对象所占用的所有内存,这就将finalize()
的需求限制到一种特殊情况,即某些对象的存储空间是通过某种创建对象方式之外的方式分配的。但是,在Java中万物皆对象,这种情况是怎么发生的?
看起来之所以有 finalize() 方法,是因为在分配内存时可能采用了类似 C 语言中的做法,而非 Java 中的通常做法。这种情况主要发生在使用"本地方法"的情况下,本地方法是一种用 Java 语言调用非 Java 语言代码的形式(关于本地方法的讨论,见本书电子版第2版的附录B)。本地方法目前只支持 C 和 C++,但是它们可以调用其他语言写的代码,所以实际上可以调用任何代码。在非 Java 代码中,也许会调用 C 的 malloc() 函数系列来分配存储空间,而且除非调用 free() 函数,不然存储空间永远得不到释放,造成内存泄露。但是,free() 是 C 和 C++ 中的函数,所以你需要在 finalize() 方法里用本地方法调用它。
1.2 Java进行内存清理的方式
要清理一个对象,用户必须在需要清理的时候调用执行清理动作的方法。这听上去相当直接,但却与 C++ 中的"析构函数"的概念稍有抵触。在 C++ 中,所有对象都会被销毁,或者说应该被销毁。如果在 C++ 中创建了一个局部对象(在栈上创建,在 Java 中不行),此时的销毁动作发生在以"右花括号"为边界的、此对象作用域的末尾处。如果对象是用 new 创建的(类似于 Java 中),那么当程序员调用 C++ 的 delete 操作符时(Java 中不存在),就会调用相应的析构函数。如果程序员忘记调用 delete,那么永远不会调用析构函数,这样就会导致内存泄露,对象的其他部分也不会得到清理。这种 bug 很难跟踪,也是让 C++ 程序员转向 Java 的一个主要因素。相反,在 Java 中,没有用于释放对象的 delete,因为垃圾回收器会帮助你释放存储空间。甚至可以肤浅地认为,正是由于垃圾回收的存在,使得 Java 没有析构函数。然而,随着学习的深入,你会明白垃圾回收器的存在并不能完全替代析构函数(而且绝对不能直接调用 finalize(),所以这也不是一种解决方案)。如果希望进行除释放存储空间之外的清理工作,还是得明确调用某个恰当的 Java 方法:这就等同于使用析构函数了,只是没有它方便。
记住,无论是"垃圾回收"还是"终结",都不保证一定会发生。如果 Java 虚拟机(JVM)并未面临内存耗尽的情形,它可能不会浪费时间执行垃圾回收以恢复内存。
1.3 终结条件
在大多数情况下, finalize()
方法是不能被使用的 ,所以我们必须创建其他的"清理"方法,并明确地调用它们。所以看起来,finalize() 只对大部分程序员很难用到的一些晦涩内存清理里有用了。但是,finalize() 还有一个有趣的用法,它不依赖于每次都要对 finalize() 进行调用,这就是对象终结条件的验证。
当某个对象即将被清理时,这个对象应该处于某种状态,这种状态下它占用的内存可以被安全地释放掉。例如,如果对象代表了一个打开的文件,在对象被垃圾回收之前程序员应该关闭这个文件。如果对象中存在没有被适当清理的部分,那么程序就会埋下很隐晦的 bug。finalize() 可以用来发现这种情况,尽管它并不总是被调用。如果某次 finalize() 的动作使得 bug 被发现,那么就可以据此找出问题所在——这才是我们真正关心的。以下是个简单的例子,示范了 finalize() 的可能使用方式:
// housekeeping/TerminationCondition.java
// Using finalize() to detect a object that
// hasn't been properly cleaned up
import onjava.*;
class Book {
boolean checkedOut = false;
Book(boolean checkOut) {
checkedOut = checkOut;
}
void checkIn() {
checkedOut = false;
}
@Override
protected void finalize() throws Throwable {
if (checkedOut) {
System.out.println("Error: checked out");
}
// Normally, you'll also do this:
// super.finalize(); // Call the base-class version
}
}
public class TerminationCondition {
public static void main(String[] args) {
Book novel = new Book(true);
// Proper cleanup:
novel.checkIn();
// Drop the reference, forget to clean up:
new Book(true);
// Force garbage collection & finalization:
System.gc();
new Nap(1); // One second delay
}
}
输出:
Error: checked out
本例的终结条件是:所有的 Book 对象在被垃圾回收之前必须被登记。但在 main() 方法中,有一本书没有登记。要是没有 finalize() 方法来验证终结条件,将会很难发现这个 bug。
你可能注意到使用了 @Override。@ 意味着这是一个注解,注解是关于代码的额外信息。在这里,该注解告诉编译器这不是偶然地重定义在每个对象中都存在的 finalize() 方法——程序员知道自己在做什么。编译器确保你没有拼错方法名,而且确保那个方法存在于基类中。注解也是对读者的提醒,@Override 在 Java 5 引入,在 Java 7 中改善,本书通篇会出现。
注意,System.gc() 用于强制进行终结动作。但是即使不这么做,只要重复地执行程序(假设程序将分配大量的存储空间而导致垃圾回收动作的执行),最终也能找出错误的 Book 对象。
你应该总是假设基类版本的 finalize() 也要做一些重要的事情,使用 super 调用它,就像在 Book.finalize() 中看到的那样。本例中,它被注释掉了,因为它需要进行异常处理,而我们到现在还没有涉及到。
二、垃圾回收器的工作方式
在一部分语言中,堆上分配对象的代价是十分高昂的,然而,Java垃圾回收器可以显著地提高对象的创建速度。这意味着,Java 从堆空间分配的速度可以和其他语言在栈上分配空间的速度相媲美。
例如,你可以把 C++ 里的堆想象成一个院子,里面每个对象都负责管理自己的地盘。一段时间后,对象可能被销毁,但地盘必须复用。在某些 Java 虚拟机中,堆的实现截然不同:它更像一个传送带,每分配一个新对象,它就向前移动一格。这意味着对象存储空间的分配速度特别快。Java 的"堆指针"只是简单地移动到尚未分配的区域,所以它的效率与 C++ 在栈上分配空间的效率相当。
但是注意,Java 中的堆并非完全像传送带那样工作。要是那样的话,势必会导致频繁的内存页面调度——将其移进移出硬盘,页面调度会显著影响性能。其中的秘密在于垃圾回收器的介入。当它工作时,一边回收内存,一边使堆中的对象紧凑排列,这样"堆指针"就可以很容易地移动到更靠近传送带的开始处,也就尽量避免了页面错误。垃圾回收器通过重新排列对象,实现了一种高速的、有无限空间可分配的堆模型。
2.1 引用计数
在理解 Java 中的垃圾回收时,首先了解其他系统中的垃圾回收机制将会很有帮助。一种简单但速度很慢的垃圾回收机制叫做引用计数:这种方法给对象添加一个引用计数器,每当有一个地方引用它时,计数器的值就+1;当引用失效时,值就-1;任何时刻计数器为0的对象就是不能再被使用的。垃圾回收器会遍历含有全部对象的列表,当发现某个对象的引用计数为 0 时,就释放其占用的空间(但是,引用计数模式经常会在计数为 0 时立即释放对象)。这个机制存在一个缺点:如果对象之间存在循环引用,那么它们的引用计数都不为 0,就会出现应该被回收但无法被回收的情况。对垃圾回收器而言,定位这样的循环引用所需的工作量极大。引用计数常用来说明垃圾回收的工作方式,但似乎从未被应用于任何一种 Java 虚拟机实现中。
如下的例子说明了循环引用的情况:
public class abc_test {
public static void main(String[] args) {
// TODO Auto-generated method stub
MyObject object1=new MyObject();
MyObject object2=new MyObject();
object1.object=object2;
object2.object=object1;
object1=null;
object2=null;
}
}
class MyObject{
MyObject object;
}
后两句将object1和object2都赋值为null,也就是说object1和object2两个引用失效,原对象无法被访问,但是因为它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器永远不会回收它们。
2.2 可达性分析
主流的Java虚拟机采用这样的一种思想:对于任意"活"的对象,一定能最终追溯到其存活在栈或静态存储区中的引用。这个引用链条可能会穿过数个对象层次,由此,如果从栈或静态存储区出发,遍历所有的引用,你将会发现所有"活"的对象。对于发现的每个引用,必须追踪它所引用的对象,然后是该对象包含的所有引用,如此反复进行,直到访问完"根源于栈或静态存储区的引用"所形成的整个网络,你所访问过的对象一定是"活"的。
这种思想称为可达性分析算法,存活在栈或静态存储区中的引用称为”GC Roots“,算法将“GC Roots”作为起始点,从这些结点开始向下搜索,搜索所经过的路径成为“引用链”,当一个对象到GC Roots没有任何引用链时,则证明此对象是不可用的。
如下图所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象,对象间的循环引用问题在可达性分析算法就得到了解决,因为GC Root在对象图之外,是特别定义的“起点”,不可能被对象图内的对象所引用。
在Java语言中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般说的native方法)引用的对象
在可达性分析算法执行后,我们就知道了哪些对象是”垃圾“而哪些对象依然存活,总的来说,Java虚拟机采用了一种自适应的垃圾回收技术,至于如何处理找到的存活对象,取决于不同的Java虚拟机实现。
2.2.1 复制算法
其中一种做法叫做复制算法,它开始时把堆分成一个对象面和多个空闲面, 程序从对象面为对象分配空间,当对象满了,基于复制算法的垃圾收集器就从根集合(GC Roots)中扫描活动对象,并将每个活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲空间),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。
这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半;并且复制算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低(因为一旦程序进入稳定状态后,可能只会产生少量垃圾,甚至没有垃圾。尽管如此,复制回收器仍然会将所有内存从一处复制到另一处,这很浪费)。
2.2.2 标记-清除/整理算法
所以为了解决上述内存以及效率问题,一些Java虚拟机会进行检查:要是没有新垃圾产生,就会转换到另一种模式(这里就体现了”自适应“的过程),这种模式被称为标记-清除算法,这种算法从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如下图所示。标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。如果垃圾回收器希望得到连续的内存空间,那么就需要重新整理剩下的对象,这被称为标记-整理算法。
标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间前,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。具体流程见下图:
注意上面提到的三种算法,垃圾回收动作都不是在后台进行的,相反,当垃圾回收动作发生的同时,程序将会暂停。
2.2.3 分代收集算法
在我们所讨论的Java虚拟机中,内存分配以较大的”块“为单位。如果对象较大,它会占用单独的块。在每块内存上,我们都可以引入”年代数“的概念,这也引出了分代收集算法,一般来讲,分代收集算法将堆划分为老年代和新生代,老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。
目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间(一般为8:1:1),每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。
而由于老年代的特点是每次回收都只回收少量对象,一般使用的是标记-整理算法。
年轻代的回收算法:
- 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
- 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空(为什么保持survivor1为空,答案:为了让eden和survivor0 交换存活对象), 如此往复。当Eden没有足够空间的时候就会 触发jvm发起一次Minor GC
- 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC(Major GC),也就是新生代、老年代都进行回收。
- 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。
老年代的回收算法:
- 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
- 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。
新生代和老年代的区别:
所谓的新生代和老年代是针对于分代收集算法来定义的,新生代又分为Eden和Survivor两个区。加上老年代就这三个区。数据会首先分配到Eden区 当中(当然也有特殊情况,如果是大对象那么会直接放入到老年代(大对象是指需要大量连续内存空间的java对象)。),当Eden没有足够空间的时候就会 触发jvm发起一次Minor GC。如果对象经过一次Minor GC还存活,并且又能被Survivor空间接受,那么将被移动到Survivor空 间当中。并将其年龄设为1,对象在Survivor每熬过一次Minor GC,年龄就加1,当年龄达到一定的程度(默认为15)时,就会被晋升到老年代 中了,当然晋升老年代的年龄是可以设置的。如果老年代满了就执行:Full GC 因为不经常执行,因此采用了标记-整理算法清理
其实新生代和老年代就是针对于对象做分区存储,更便于回收等等
2.2.4 垃圾回收算法小结
如前文所述,这里讨论的 Java 虚拟机中,内存分配以较大的"块"为单位。如果对象较大,它会占用单独的块。严格来说,复制算法要求在释放旧对象之前,必须先将所有存活对象从旧堆复制到新堆,这导致了大量的内存复制行为。有了块,垃圾回收器就可以把对象复制到废弃的块。每个块都有年代数来记录自己是否存活。通常,如果块在某处被引用,其年代数加 1,垃圾回收器会对上次回收动作之后新分配的块进行整理。这对处理大量短命的临时对象很有帮助。垃圾回收器会定期进行完整的清理动作——大型对象仍然不会复制(只是年代数会增加),含有小型对象的那些块则被复制并整理。Java 虚拟机会监视,如果所有对象都很稳定,垃圾回收的效率降低的话,就切换到标记清除算法。同样,Java 虚拟机会跟踪标记清除算法的效果,如果堆空间出现很多碎片,就会切换回复制方式。这就是"自适应"的由来,你可以给它个啰嗦的称呼:"自适应的、分代的、复制、标记清除"式的垃圾回收器。