五、初始化与清理
1.用构造器确保初始化
在Java中,通过提供构造器,类的设计者可确保每个对象都会得到初始化。创建对象时,如果其类具有构造器,Java就会在用户有能力操作对象之前自动调用相应的构造器,从而保证了初始化的进行。构造器的名称与类的名称相同。(“每个方法首字母小写”的编码风格并不适用于构造器)
构造器有助于减少错误,并使代码更易于阅读。从概念上讲,“初始化”与“创建”是彼此和独立的。在Java中,“初始化”和“创建”捆绑在一起,两者不能分离。
构造器是一种特殊类型的方法,因为它没有返回值。这与返回值为空(void)明显不同。构造器不会返回任何东西(new 表达式确实返回了对新建对象的引用,但构造器本身并没有任何返回值)。
2.方法重载
在Java里,构造器是强制重载方法名的一个原因。既然构造器的名字已经由类名所决定,就只能有一个构造器名。那么要想用多种方式创建一个对象该怎么办?假设你要创建一个类,即可以用标准方式进行初始化,也可以从文件里读取信息来初始化。这就需要两个构造器:一个默认构造器,另一个取字符串作为形式参数——该字符串表示初始化对象所需的文件名称。由于都是构造器,所它们必须有相同的名字,即类名。为了让方法名相同而形式参数不同的构造器同时存在,必须用到方法重载。
①区分重载方法
每个重载方法都必须有一个独一无二的参数类型列表。甚至参数顺序的不同也足以区分两个方法。不过,不建议这么做。
②涉及基本类型的重载
基本类型能从一个“较小”的类型自动提升至一个“较大”的类型,此过程一旦牵涉到重载,可能会造成了一些混淆。
常数值会被当作int值处理。
如果传入的数据类型(实际数据类型)小于方法中声明的形式参数类型,数据类型就会被提升。char类型略有不同,如果无法找到恰好接受char参数的方法,就会把char直接提升至int型。
如果传入的实际参数大于重载方法声明的形式参数,就得通过类型转换来执行窄化转换。如果不这样做,编译器就会出错。
③以返回值区分重载方法
在区分重载方法时,为什么只能以类名和方法的形参列表作为标准呢?能否用方法的返回值来区分呢?
答案是不能。
因为有时,我们并不关心方法的返回值,我们想要的是方法调用的其他效果,这时我们会调用方法而忽略返回值。例如这样调用方法: f();
3.默认构造器
默认构造器(又名“无参”构造器)是没有形式参数的——它的作用是创建一个“默认对象”。如果你写的类中没有构造器,则编译器会自动帮你创建一个默认构造器。但是,如果已经定义了一个构造器(无论是否有参数),编译器就不会自动创建默认构造器。
4.this关键字
如果有同一类型的两个对象,分别是a和b。你可能想知道,如何才能让这两个对象都能调用同一个方法呢:
class Banana { void peel(int i) { /*.....*/ } } public class BananaPeel { public static void main(String[] args){ Banana a = new Banana(), b = new Banana(); a.peel(1); b.peel(2); } }
如果只有一个peel方法,它如何知道是被a调用还是被b调用呢?
为了能用简便、面向对象的语法来编写代码——即“发送消息给对象”,编译器做了一些幕后工作。它暗自把“所操作的对象的引用”作为第一个参数传递给peel()。所以上述两个方法的调用就变成了这样:
Banana.peel(a,1);
Banana.peel(b,2);
这时内部的表示形式。我们并不能这样书写代码。
假设你希望在方法的内部获得对当前对象的引用。由于这个引用是由编译器“偷偷”传入的,所以没有标识符可用。但是,为此有个专门的关键字:this。this关键字只能在方法内部使用,表示对“调用方法的那个对象”的引用。this的用法和其他对象引用并无不同。但要注意,如果在方法内部调用用一个类的另一个方法,就不必使用this,直接调用即可。
只在必要处使用this。
①在构造器中使用构造器
可能为了一个类写了多个构造器,有时可能想在一个构造器中调用另一个构造器,以避免重复代码。可用this关键字做到这一点。
通常写this的时候,都是指“这个对象”或“当前对象”,而且它本身表示对当前对象的引用。在构造器中,如果为this添加了参数列表,那么就有了不同的含义。这将产生对符合此参数列表的某个构造器的明确调用。另外,必须把构造器调用置于最起始处,否则编译器会报错。
②static的含义
static方法就是没有this的方法。在static方法的内部不能调用非静态方法,反过来倒是可以的。(静态方法可以创建自身的引用,和this效果一样,通过这个引用可调用非静态方法,例如main方法。)而且可以在没有创建任何对象的前提下,仅仅通过类本身来调用static方法。
5.清理:终结处理和垃圾回收
Java有垃圾回收器负责回收无用对象占据的内存资源。但也有特殊情况:假定你的对象(并非使用new)获得了一块“特殊”的内存区域,由于垃圾回收器只知道释放那些经由new分配的内存,所以它不知道该如何释放该对象的这块“特殊”内存。所以,Java允许在类中定义一个名为finalize()的方法。它的工作原理“假定”是这样的:一旦垃圾回收器准备好释放对象占用的存储空间,将首先调用finalize()方法,并且在下一次垃圾回收动作发生时,才真正回收对象占用的内存。
这里有一个潜在的编程陷阱,因为有些程序员(特别是C++程序员)刚开始可能会误把finalize()当作C++中的折构函数(C++中销毁对象必须用到这个函数)。所以有必要明确区分一下:在C++中,对象一定会被销毁;而Java里的对象却并非总是被垃圾回收。或者换句话说:
1.对象可能不被垃圾回收。
2.垃圾回收不等于“折构”。
这意味着在你不再需要某个对象之前,如果必须执行某些动作,那么你得自己去做。Java并未提供“折构函数”或相似的概念,要做类似的清理工作,必须自己动手创建一个执行清理工作的普通方法。例如,假设某个对象在创建过程中会将自己绘制到屏幕上,如果不是明确地从屏幕上擦除,它可能永远都得不到清理。如果在finalize()里加入某种擦除功能,当“垃圾回收”发生时(不能保证一定发生),finalize()得到了调用,图像就会被擦除。要是“垃圾回收”没有发生,图像就会一直保留下来。
· 也许你会发现,只要程序没有濒临存储空间用完的那一刻,对象占用的空间就不会被释放。如果程序执行结束,并且垃圾回收器一直都没有释放你创建的任何对象的存储空间,则随着程序的退出,那些资源也会全部交还给操作系统。这个策略是恰当的,因为垃圾回收本身也有开销,要是不使用它,那就不用支付这部分的开销了。
①finalize()的用途何在
finalize()的真正用途是什么呢?
这就引出需要记住的第三点:
3.垃圾回收只与内存有关。
也就是说,使用垃圾回收器的唯一原因是为了回收程序不用的内存。无论对象是怎样创建的,垃圾回收器都会负责释放对象占据的所以内存。这就将对finalize()的需求限制到一种特殊情况,即通过某种创建对象方式以外的方式为对象分配了存储空间。但是,Java中一切皆为对象,这种特殊情况又是怎么回事呢?
之所以要有finalize(),是由于在分配内存时可能采用了类似C语言中的做法。这种情况主要发生在使用“本地方法”的情况下,本地方法是一种在Java中调用非Java代码的方式。本地方法目前只支持C和C++,但它们可以调用其他语言的代码,所以实际上可以调用任何代码。在非Java代码中,可能会调用C的malloc()函数系列来分配存储空间,而且除非调用了free()函数,否则存储空间将得不到释放,从而造成内存泄漏。当然,free()是C和C++中的函数,所以需要在finalize()中用本地方法调用它。
②你必须实施清理
要清理一个对象,用户必须在需要清理的时刻调用执行清理动作的方法。
Java不允许创建局部对象,必须使用new创建对象。记住,无论是垃圾回收还是终结,都不保证一定发生。如果Java虚拟机并未面临内存耗尽的情形,它是不会浪费时间去执行垃圾回收的。
③终结条件
通常,不能指望finalize(),必须创建其他的“清理”方法,并且明确地调用它们。看来,finalize()只能存在于程序员很难用到的一些晦涩用法里了。不过,finalize()还有一个有趣的用法,它并不依赖每次都要对finalize()进行调用,这就是对象终结条件的验证。
当对某个对象不再感兴趣——也就是它可以被清理了,这个对象应该处于某种状态,使它占用的内存可以被安全的释放。例如,要是对象代表了一个打开的文件,在对象被回收前程序员应该关闭这个文件。只要对象中存在没有被适当清理的部分,程序就存在很隐晦的缺陷finalize()可以用来最终发现这种情况——尽管它并不总是被调用。如果某次finalize()的动作使得缺陷被发现,那么就可据此找出问题所在——这才是人们真正关心的。
以下是个简单的例子:
class Book { boolean checkedOut = false; Book(boolean checkedOut) { this.checkedOut = checkedOut; } void checkIn() { checkedOut = false; } protected void finalize() { if(checkedOut) { System.out.println("Error: checked out"); // 一般需要这么做,假设基类版本的finalize()也要做某些事情 //由于需要异常处理,这里省略 // super.fianlize(); } } public class TerminationCondition { public static void main(String[] args) { Book novel = new Book(true); novel.checkIn(); new Book(true); Sysytem.gc(); } }
本例的终结条件是:所有的Book对象在被当作垃圾回收前都应该被签入(check in)。但在main()方法中,由于程序员的错误,有一本书未被签入。要是没有finalize()来验证终结条件,将很难发现这种缺陷。
注意,System.gc()用于强制进行终结动作。即使不这么做,通过重复地执行程序,最终也能找出错误的Book对象。
④垃圾回收器如何工作
Java从堆分配空间的速度,可以和其他语言从堆栈上分配空间的速度相媲美。
打个比方,你可以把C++里的堆想象成一个院子,里面每个对象都负责管理自己的地盘。一段时间后,对象可能被销毁,但地盘必须加以重用。在某些Java虚拟机中,堆的实现截然不同:它更像一个传送带,每分配一个新对象,它就往前移动一格。这意味着对象存储空间的分配速度非常快。Java的“堆指针”只是简单地移动到尚未分配的区域,其效率比得上C++在堆栈上分配空间的效率。
事实上,Java中的堆未必完全像传送带那样工作。要真是那样的话,势必会导致频繁的内存页面调度——将其移出移进硬盘,因此会显得需要拥有比实际需要更多的内存。页面调度会显著地影响性能,最终,在创建了足够多的对象之后,内存资源将耗尽。其中的秘密在于垃圾回收器的介入。当它工作时,将一边回收空间,一边使堆中的对象紧凑排列,这样“堆指针”就可以很容易移动到更靠近传送带的开始处,也就尽量避免了页面错误。通过垃圾回收器对对象的重新排列,实现了一种高速的、有无限空间可供分配的堆模型。
“自适应的、分代的、停止-复制、标记-清扫”式垃圾回收器。
Java虚拟机中有许多附加技术用以提升速度。尤其是与加载器操作有关的,被称为“即时”编译器的技术。这种技术可以把程序全部或部分翻译成本机机器码,程序运行速度得以提升。当需要装载某个类(通常是在为该类创建第一个对象)时,编译器会先找到其.class文件,然后将该类的字节码装入内存。此时,有两种方案可供选择。一种是就让即时编译器编译所有代码,另一种则为即时编译器只在需要时才编译代码,被称为惰性评估。新版JDK中的Java HotSpot技术采用了类似方法,代码每次被执行的时候都会做一些优化,所以执行的次数越多,它的速度越快。
6.成员初始化
Java尽力保证:所有变量在使用前都能得到恰当的初始话化。对于方法的局部变量,Java以编译时错误的形式来贯彻这种保证。
7.构造器初始化
可以用构造器来进行初始化。但要牢记:无法阻止自动初始化的进行,它将在构造器被调用之前发生。
①初始化的顺序
在类的内部,变量定义的先后顺序决定了初始化的顺序。即使变量定义散布于方法定义之间,它们仍旧会在任何方法(包括构造器)被调用之前得到初始化。
②静态数据的初始化
无论创建多少个对象,静态数据都只占用一份存储区域。static关键字不能应用于局部变量,因此它只能作用于域。
静态初始化只在必要时才会进行。只有在第一次创建对象(或者第一次访问静态数据)的时候,它们才会被初始化。此后,静态对象不会再次被初始化。
初始化的顺序是先静态对象,而后是“非静态”对象。
总结一下对象的创建过程,假设有个名为Dog的类:
1.即使没有显式地使用static关键字,构造器实际上也是静态方法。因此,当首次创建类型为Dog的对象时(构造器可以看成静态方法),或者Dog类的静态方法/静态域首次被访问时,Java解释器必须查找类路径,以定位Dog.class文件。
2.然后载入Dog.class(这会创建一个Class对象),有关静态初始化的所有动作都会执行。因此,静态初始化只在Class对象首次加载的时候进行一次。
3.当用new Dog()创建对象时,首先在堆上为Dog对象分配足够的存储空间。
4.这块存储空间会被清零,这就自动地将Dog对象中的所有基本类型数据都设置成了默认值,而引用则设置成null。
5.执行所有出现于字段定义处的初始化动作。
6.执行构造器。
③显式的静态初始化
Java允许将多个静态初始化动作组织成一个特殊的“静态子句”(有时叫做“静态块”):
public class fool { static int i; static { i = 666; } }
与其他静态初始化动作一样,这段代码仅执行一次:当首次生成这个类的一个对象时,或者首次访问属于那个类的静态数据成员时(即使从未生成过那个类的对象)。
④非静态实例初始化
Java中也有被称为实例初始化的类似语法,用来初始化每一个对象的非静态变量。
它与静态初始化子句一模一样,只不过少了static关键字。这种语法对于支持“匿名内部类”的初始化是必须的。
实例初始化子句是在构造器之前执行的。
8.数组初始化
数组只是相同类型的,用一个标识符名称封装到一起的一个对象序列或基本类型数据序列。数组是通过方括号下标操作符[ ]来定义和使用的。
编译器不允许指定数组的大小。这就又把我们带回有关“引用”的问题上。现在拥有的只是对数组的一个引用(你已经为该引用分配了足够的存储空间),而且也没给数组对象本身分配任何空间。可以用由一对花括号括起来的值执行数组的初始化。
也可以用花括号括起来的列表来初始化对象数组。
①可变参数列表
可变参数列表表现形式如下:
void f(int... a){ }
这样就相当于有个int[] 的参数。有了可变参数列表,就再也不用显式地编写数组语法了,当你指定参数时,编译器实际上会为你去填充数组。你获取的仍旧是一个数组。
注意:你应该总是只在重载方法的一个版本上使用可变参数列表,或者压根就不是用它。
9.枚举类型
在Java SE5中添加了enum关键字,它使得我们在需要群组并使用枚举类型时,可以很方便地处理。下面是一个简单的例子:
public enum Spiciness { NOT, MILD, MEDIUM, HOT, FAMING }
这里创建了一个名为Spiciness的枚举类型,它具有5个具名值。由于枚举类型的实例是常量,因此按照命名习惯它们都用大写字母表示。
为了使用enum,需要创建一个该类型的引用,并将其赋值给某个实例:
public class SimpleEnumUse { public static void main(String[] args) { Spiciness howHot = Spiciness.MEDIUM; Sysytem.out.println(howHot); } } /*output MEDIUM */
枚举类型可以使用ordinal()方法,用于表示某个特定enum常量的声明顺序,以及static value()方法,用来按照enum常量的声明顺序,产生这些常量值构成的数组。
尽管enum看起来像是一种新的数据类型,但是这个关键字只是为enum生成对应的类时,产生了某些编译器行为,因此在很大程度上,你可以将enum当作其他任何类来处理。事实上,enum确实时类,并且具有自己的方法。
enum可以在switch语句内使用。
10.总结
初始化在Java中占有至关重要的低位。
学好内存分析对深刻理解Java运行机制十分重要,本文总结的仍旧很不全面,只是取了一部分,任重而道远啊!