Finalization和 GC
1
初始化很重要,而清理工作也同样重要。毕竟,谁会去清理一个 int?但使用完一个对象就不管了,这并非总是安全的操作。
1.1
特殊场景
创建的对象不是通过 new 来分配内存,而GC只懂释放用 new 创建的对象的内存。为了处理这种情况,Java 允许在类中定义一个名为 finalize()
的方法。
1.2
工作原理
当GC准备回收时,首先会调用 finalize()
方法,并在下一轮的gc发生时,才会真正回收对象占用的内存。所以如果你打算使用 finalize()
,就能在GC时先做一些重要的清理工作。
finalize()
是一个潜在危险,因为一些程序员(尤为 C++)会把它误认为析构函数。
所以有必要明确区分:在 C++ 中,对象总是被销毁的,而在 Java 中,对象并非总是被,或者换句话说:
在不再需要某个对象前,如果必须执行某些动作,你得自己去做。Java 没有析构器或类似概念,必须得自己手动创建一个普通方法完成清理。
例如,对象在创建的过程中会将自己绘制到屏幕。如果不明确地从屏幕上将其擦除,它可能永远得不到清理。如果在 finalize()
方法中加入某种擦除功能,那么当GC时,finalize()
方法被调用(不保证一定会发生),图像就会被擦除,要是GC没发生,图像仍会保留下来。
只要程序没有将内存用尽,对象所占空间就永远得不到释放。如果程序执行完成,而GC一直没有释放你创建的任何对象的内存,则当程序退出时,那些资源会全部交还给OS。这个策略是恰当的,因为gc本身也有开销,要是不使用,就不用支付这开销。
1.3
finalize()的作用
如果不能将 finalize()
作为通用的清理方法,那它有什么用?
记住的第3点:
finalize()
),也必须仅和内存及其回收有关。但这并不代表如果对象中包含其他对象,finalize()
方法就该明确释放它们。
无论对象如何创建的,GC都会负责释放对象所占用的所有内存。
这就将对 finalize()
的需求限制到一种特殊情况:通过某种创建对象方式之外的方式为对象分配了存储空间。而Java 中万物皆对象,这种情况怎么可能发生呢?
可以猜测之所以有 finalize()
,是因为在分配内存时采用了类似 C 语言中的机制。这种情况主要发生在使用 native方法 的情况。本地方法目前只支持 C 和 C++,但是它们却可以调用其他语言写的代码,所以实际上还是可以高效地调用任何代码。
在非 Java 代码中,也许会调用 C 的 malloc()
函数家族来分配存储空间,而且除非调用 free()
函数,不然存储空间永远得不到释放,造成内存泄露。但是,free()
是 C 和 C++ 中的函数,所以你需要在 finalize()
方法里用native方法调用它。
所以其实我们并不会过多使用 finalize()
方法。它确实不是普通清理的合适场所。那么问题又来了,普通的清理在哪里执行?
1.4
必须执行清理
要清理一个对象,用户必须在需要清理时调用执行清理方法。这听上去简单粗暴,但与 C++ 的析构函数抵触。在 C++ 中,所有对象都应该被销毁。如果在 C++ 中创建了一个局部对象(比如栈,在 Java 中不可能),销毁发生在右花括号边界的、此对象作用域的末尾。
如果对象是用 new 创建的(似于 Java 中),那么当SE调用 C++ 的 delete 操作符时(Java 中不存在),就会调用相应的析构函数。忘记调用 delete,就永远不会调用析构函数,导致内存泄露。
相反,在 Java 中,没有用于释放对象的 delete,因为GC会帮助你释放。甚至可以肤浅地认为,正是由于GC的存在,使得 Java 没有析构函数。然而,随着学习的深入,会明白GC的存在并不能完全替代析构函数(而且绝对不能直接调用 finalize()
,所以这也不是一种解决方案)。如果希望进行除释放存储空间之外的清理工作,还是得明确调用某个恰当的 Java 方法:这就等同于使用析构函数了,只是没有它方便。
记住,无论gc还是finalize,都不保证一定发生。如果JVM并未面临内存耗尽的情形,它可能不会浪费时间执行gc。
1.5
终结条件
不要指望 finalize()
,必须创建其他的"清理"方法,并显式调用。
所以finalize()
仅仅对大部分SE永远用不到的一些晦涩内存清理有用。
但finalize()
还有一个有趣用法,它不依赖于每次都要对 finalize()
进行调用,这就是对象终结条件的验证。
当对某对象不再感兴趣——也就是将被清理,这个对象应该处于某种状态,这种状态下它占用的内存可以被安全释放。
例如,如果对象代表一个打开的文件,在对象被gc前SE应该关闭该文件。只要对象中存在没有被适当清理部分,程序就存在bug。finalize()
可以用来发现这个情况,尽管它并不总是被调。如果某次 finalize()
使得 bug 被发现,那么就可以据此找出问题所在。
示例如下:
// 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);
// 适当地清理:
novel.checkIn();
// Drop the reference,忘记清理:
new Book(true);
// 强制 gc & finalization:
System.gc();
new Nap(1); // 延迟 1s
}
}
终结条件是:所有Book对象在被gc前被checkIn。但 main()
中,有一本书没有checkIn。要是没有 finalize()
方法验证终结条件,很难发现这个 bug。
在这里,使用重写注解告诉编译器这不是偶然地重定义 Object 类中的 finalize()
方法——程序员知道自己在做什么。编译器确保你没有拼错方法名,而且确保那个方法存于父类。注解也是对读者的提醒。
你应该总是假设父类的 finalize()
也做一些重要的事情,使用 super 调之(只是当前把它注释了而已)。
1.6
垃圾回收器工作原理
垃圾回收器能显著提高对象的创建速度。存储空间的释放影响了存储空间的分配?这确实是某JVM的工作方式。Java 从堆空间分配的速度可以和其他语言在栈上分配空间的速度媲美。
可以把 C++ 里的堆想象成一个院子,里面每个对象都负责管理自己的地盘。一段时间后,对象可能被销毁,但地盘必须复用。
在某些JVM,堆更像是传送带,每分配一个新对象,它就向前移动。这意味着对象存储空间的分配速度特别快。Java 的"堆指针"只是简单地移动到尚未分配的区域,所以它的效率与 C++ 在栈上分配空间的效率相当。(在簿记方面还有少量额外开销,但是这部分开销比不上查找可用空间)
Java 堆并非完全像传送带那样工作。要是那样,势必导致频繁内存页面调度——将其移进移出硬盘,因此会显得拥有比实际存在更多的内存。页面调度会显著影响性能。最终,在创建了足够多的对象后,内存资源被耗尽。
秘密就在于GC。它工作时,一边回收内存,一边使堆中对象紧凑,这样"堆指针"就很容易移动到更靠近传送带的开始处,就尽量避免了页面错误。GC通过重排对象,实现了一种高速的、有无限空间可分配的堆模型。
1.6.1
引用计数
一种简单但很慢的gc机制。每个对象中含有一个引用计数器:
因此,管理引用计数开销不大但是在程序的整个生命周期频繁发生。垃圾回收器会遍历含有全部对象的列表,当发现某个对象的引用计数为 0 时,就释放其所占空间(但是,引用计数经常会在计数为 0 时立即释放对象)。
在更快的策略中,依据的是:对于任意"活"对象,一定能追溯到其存活在栈或静态存储区中的引用。该链可能会穿过若干层对象。由此,如果从栈或静态存储区出发,遍历所有的引用,将会发现所有"活"的对象。
对于发现的每个引用,必须追踪它所引用的对象,然后是该对象包含的所有引用,如此反复进行,直到访问完"根源于栈或静态存储区的引用"所形成的整个网络。你所访问过的对象必须都是"活"的。
这也解决了对象间循环引用问题,这些对象不会被发现,因此也就被自动回收。
JVM采用一种自适应的垃圾回收技术。至于如何处理找到的存活对象,取决JVM 具体实现。其中有一种做法叫做
1
停止-复制(stop-and-copy)
先暂停程序的运行(所以不属于后台回收),然后将所有存活对象从当前堆复制到另一个,遗留的就是垃圾对象。被复制到新堆时,对象是一个挨着一个紧凑排列,然后就可以按照前面描述的那样简单粗暴分配新空间。
当对象被复制移动,其所有引用都得修正。位于栈或静态存储区的引用可以马上被修正,但可能还有其他引用,它们在遍历过程中才能被找到(可以想象成一个表格,将旧地址映射到新地址)。对于这种"复制回收器"
效率低下的两大主因
Sun 早期版本的JVM一直使用该技术。一般而言,"标记-清扫"相当慢,但当你知道程序只会产生少量垃圾甚至不产生垃圾时,它就很快了。
"标记-清扫"所依据的思路仍是从栈和静态存储区出发,遍历所有引用,找出所有存活的对象。但是,每当找到一个存活对象,就给对象设一个标记,并不回收它。只有当标记过程完成后,清理动作才开始。
在清理过程中,没有标记的对象将被释放,不会发生任何复制动作。"标记-清扫"后剩下的堆空间是不连续的,要是想得到连续空间,就得整理。
"停止-复制"指的是这种gc不是在后台进行,而是gc发生同时,程序就会暂停。在 Oracle 文档中会发现,许多参考文献将gc过程视为低优先级的后台进程,但早期版本JVM并非这么实现。而是当可用内存较低时, GC会暂停程序。同样,"标记-清扫"工作也要求程序暂停。
这里讨论的JVM,内存分配以较大的"块"为单位。如果分配的对象较大,它会占用单独的块。严格的"停止-复制"要求在释放旧对象之前,必须先将所有存活对象从旧堆复制到新堆,这导致大量的内存复制。有了块,垃圾回收器就可以把对象复制到废弃的块。每个块都有年代数来记录自己是否存活。通常,如果块在某处被引用,其年代数加 1,垃圾回收器会对上次回收动作之后新分配的块进行整理。这对处理大量短命的临时对象很有帮助。垃圾回收器会定期进行完整的清理动作——大型对象仍然不会复制(只是年代数会增加),含有小型对象的那些块则被复制并整理。Java 虚拟机会监视,如果所有对象都很稳定,垃圾回收的效率降低的话,就切换到"标记-清扫"方式。
同样,Java 虚拟机会跟踪"标记-清扫"的效果,如果堆空间出现很多碎片,就会切换回"停止-复制"方式。这就是"自适应"的由来,你可以给它个啰嗦的称呼:"自适应的、分代的、停止-复制、标记-清扫"式的垃圾回收器。
1.7
JIT 即时解释器
Java 虚拟机中有许多附加技术用来提升速度。尤其是与加载器操作有关的-"即时"(Just-In-Time, JIT)编译器的技术。可以把程序全部或部分翻译成本地机器码,所以不需要 JVM 来进行翻译,因此运行得更快。当需要装载某个类(通常是创建该类的第一个对象)时,编译器会先找到其 .class 文件,然后将该类的字节码装入内存。
可以让即时编译器编译所有代码,缺点:
1. 这种加载动作贯穿整个程序生命周期内,累加起来需要花更多时间
2.增加可执行代码的长度(字节码要比即时编译器展开后的本地机器码小很多),这会导致页面调度,从而一定降低程序速度。另一种做法称为惰性评估,意味着即时编译器只有在必要的时候才编译代码。这样,从未被执行的代码也许就压根不会被 JIT 编译。新版 JDK 中的 Java HotSpot 技术就采用了类似的做法,代码每被执行一次就优化一些,所以执行的次数越多,它的速度就越快。