第五章 初始化与清理
随着计算机革命的发展,不安全的编程方式已逐渐成为编程代价高昂的主因之一。初始化和清理正是设计安全的两个问题。许多程序的错误都源于程序员忘记初始化变量,特别是在使用程序库时。当使用完一个元素时,很容易把对你不再有影响的元素忘记,这样一来这个元素占用的资源就会一直得不到释放,结果是资源的耗尽,特别是内存资源。
Java采用了构造器(constructor),并额外提供了垃圾回收器,对于不再使用的内存资源,垃圾回收器能自动将其释放。
5.1 用构造器确保初始化
可以假象为编写的每个类都定义一个initialize()方法,在你使用其对象之前应该首先调用initialize()方法。这同时也意味着用户必须记得自己去调用这个方法。在Java中,通过停供构造器,类的设计者可以确保每个对象都会得到初始化。创建对象时,如果其类具有构造器,Java就会在用有能力操作对象之前自动调用相应的构造器,从而保证初始化的进行。在Java中采用了和C++类似的命名方法,也就是构造器与类采用相同的名称。
以下是一个带有构造器的简单类:
class Rock { Rock(){ System.out.print("Rock "); } } public class test { public static void main(String[] args) { for (int i = 0; i < 10; i++){ new Rock(); } } }
输出结果如下:
现在创建对象时将会为对象分配存储空间,并调用相应的构造器,这就确保你在操作对象之前它就已经被初始化了。
不接受任何参数的构造器叫做默认构造器,Java文档中通常使用术语无参构造器,但和其他方法一样,构造器也能带有形式参数以便制定如何创建对象,对上面的例子稍加修改:
class Rock2 { Rock2(int i){ System.out.print("Rock " + i + " "); } } public class test { public static void main(String[] args) { for (int i = 0; i < 8; i++){ new Rock2(i); } } }
输出结果如下:
有了构造器形式参数,就可以在初始化对象时提供实际参数。如果上述例子中,Rock2(int)是Rock类中唯一的构造器,那么编译器将不会允许你以任何其他方式创建Rock对象。
构造器有助于减少错误,使代码更易于阅读。在Java中,初始化和创建两者捆绑在一起,不能分离。构造器是一种特殊类型的方法,它没有返回值,这不同于void,void是空返回值。
5.2 方法重载
任何程序设计语言都具备的一项重要特性就是对名字的运用,当创建一个对象时,也就给这个对象分配到的存储空间取了一个名字,通过使用名字,你可以引用所有的对象和方法。
在Java中,构造器是强制重载方法名的另一个原因,既然构造器的名字已经由类名决定,就只能有一个构造器名,如果想要用多种方式创建一个对象,我们就需要有两个甚至多个构造器。由于都是构造器,他们必须有相同的名字,为了让方法名相同而形式参数不同的构造器同时存在,必须用到方法重载。
示例使用重载的构造器和重载方法:
class Tree { int height; Tree(){ System.out.println("Planting a seeding"); height = 0; } Tree(int initialHeight){ height = initialHeight; System.out.println("Creating new Tree that is " + height + " feet tail"); } void info(){ System.out.println("Tree is " + height + " feet tail"); } void info(String s){ System.out.println(s + ": Tree is " + height + " feet tail"); } } public class test { public static void main(String[] args) { for (int i = 0; i < 5; i++){ Tree t = new Tree(i); t.info(); t.info("overloaded method"); } new Tree(); } }
输出结果如下:
Creating new Tree that is 0 feet tail
Tree is 0 feet tail
overloaded method: Tree is 0 feet tail
Creating new Tree that is 1 feet tail
Tree is 1 feet tail
overloaded method: Tree is 1 feet tail
Creating new Tree that is 2 feet tail
Tree is 2 feet tail
overloaded method: Tree is 2 feet tail
Creating new Tree that is 3 feet tail
Tree is 3 feet tail
overloaded method: Tree is 3 feet tail
Creating new Tree that is 4 feet tail
Tree is 4 feet tail
overloaded method: Tree is 4 feet tail
Planting a seeding
5.2.1 区分重载方法
如果几个方法有相同的名字,Java该如何进行区分?规则就是,每个重载的方法都必须有独一无二的参数列表。例如void f(String s, int i)同void f(int i, String s)就是两个不同的方法。
5.2.2 设计基本类型的重载
如果传入的数据类型(实际参数类型)小于方法中声明的形式参数类型,实际数据类型就会被提升。char型有所不同,如果无法找到恰好接收char参数的方法,就会把char直接提升至int。
如果传入的实际参数大于重载方法声明的形式参数,就得通过类型转换来执行窄化转换,不这样做的话编译器就会报错。
5.2.3 以返回值区分重载方法
例如这两个方法:void f() {} 同int f() {},这两个方法虽然有相同的名字和形式参数,但是很容易就能区分它们。
5.3 默认构造器
默认构造器又称无参构造器,是没有形式参数的,它的作用是创建一个默认对象,如果你的类中没有构造器,编译器会自动帮你创建一个默认构造器。例如:
class Bird {} public class DefaultConstructor { public static void main(String[] args) { Bird b = new Bird(); } }
表达式new Bird()创建了一个新对象,并调用其默认构造器,没有这个默认构造器的话就无法创建对象。但如果已经定义了一个构造器,编译器就不会帮你自动创建构造器。
5.4 this关键字
this关键词只能在方法内部使用,表示对“调用方法的那个对象”的引用,如果在方法内部调用同一个类的不同方法就不必使用this,直接调用即可。例如:
public class test() { void func1() { /* ... */ } void func2() { pick(); /* ... */ } }
在func2()内部,你可以写func1()但是没有这个必要,编译器会帮你自动添加,只有当需要明确指出当前对象的引用时才需要使用this关键字。
5.4.1 在构造器中调用构造器
可能会为了一个类写多个构造器,还有时可能在一个构造器中调用另一个构造器,这时可以用this关键字做到这一点。
通常用this时,都是指“这个对象”、“当前对象”,表示对当前对象的引用。在构造器中,如果this添加了参数列表那么就有了不同的含义,这将产生对符合此参数列表的某个构造器的明确使用,这样,调用其他构造器就有了直接的途径。
尽管可以使用this调用一个构造器,但是不能调用两个,此外必须将构造器调用置于最起始处,否则编译器会报错。如果参数名称和数据成员名字相同会产生歧义,使用this也能解决这个问题。
5.4.2 static的含义
static方法就是没有this的方法,在static方法内部不能调用非静态方法,反过来倒是可以的,而且可以在没有创建任何对象的前提下,仅仅通过类本身来调用static方法,这实际上正式static方法的主要用途,它很像全局方法,Java中是禁止全局方法的,但你在类中置入static方法就可以访问其他static方法和static域。
5.5 清理:终结处理和垃圾回收
程序员都了解初始化的重要性,但常常会忘记同样重要的清理工作,把一个对象用完后就弃之不顾的做法并非总是安全的,Java有垃圾回收器负责回收无用对象占据的内存资源,但也有特殊情况,假定你的对象(并非使用new)获得了一块特殊的内存区域,由于垃圾回收器只知道释放哪些经由new分配的内存,所以它不知道怎么释放该对象的内存。为应对这种情况,Java允许在类中定义一个名为finalize()的方法,它的工作原理是:一旦垃圾回收器准备好释放对象占用的存储空间,将首先调用finalize()方法,并且在下一次垃圾回收动作发生时,才会真正回收对象占用的内存。
这里的finalize()和C++中的析构函数并不一样,在C++中,对象一定会被销毁,而Java里的对象却并非总是被垃圾回收,在Java中,对象可能不被垃圾回收,垃圾回收并不等于析构。Java并未提供析构函数或者相似的概念,要做类似的工作必须自己动手创建一个执行清理工作的普通方法。
5.5.1 finalize()的用途何在
通过上述论述,我们应该知道不应该将finalize()作为通用的清理方法,那么finalize()真正用途是什么?
首先我们要知道,垃圾回收只与内存有关,也就是说,使用垃圾回收器的唯一原因是为了回收程序不再使用的内存,所以对于与垃圾回收有关的任何行为来说,尤其是finalize()方法,它们也必须同内存及其回收有关。但这不意味着当对象中含有其他对象时,finalize()方法就要明确释放那些对象,不管对象时如何创建的,垃圾回收器都会负责释放对象占据的所有内存。这就将finalize()的需求限制到了一种特殊情况,即通过某种创建对象方式以外的方式为对象分配了存储空间。
之所以要有finalize(),是由于在分配内存时可能采用了类似C语言中的做法,所以实际上在非Java代码中,也许会调用C的malloc()函数系列来分配存储空间,而且除非调用了free()函数,否则存储空间将得不到释放从而造成内存泄露。
5.5.2 你必须实施清理
要清理一个对象,用户必须在需要清理的时刻调用执行清理的方法。Java不允许创建局部对象,必须使用new创建对象。在Java中,可以肤浅地认为正是由于垃圾收集机制的存在,使得Java没有析构函数,但是随着学习的深入,我们应该知道垃圾回收器的存在并不能完全代替析构函数。如果希望进行除释放存储空间之外的清理工作,还是得明确调用某个恰当的Java方法。
5.5.3 终结条件
通常,Java不能指望finalize(),必须创建其他的清理方法,并且明确调用他们。finalize()还有一个有趣的用法,它并不依赖于每次都要对finalize()进行调用,这就是对象终结条件的验证。
当对某个对象不再感兴趣,也就是它可以被清理时,这个对象应该处于某种状态,使它占用的内存可以安全地被释放。只要对象中存在没有被适当清理的部分,程序就存在很隐晦的缺陷,finalize()可以用来最终发现这种情况,尽管它并不是总会被调用。
5.5.4 垃圾回收器如何工作
有一种做法名叫停止-复制(stop-and-copy),显然这意味着,先暂停程序的运行,然后将所有的存活对象从当前堆复制到另一个堆,没有被复制的全是垃圾。当对象被复制到新堆时,他们是一个挨着一个的,所以新堆保持紧凑队列,然后就可以直接分配新空间了。
但是对于这种所谓的复制式回收器而言,效率会降低。首先,得有两个堆,然后得在这两个分离的堆之间来回倒腾,从而得维护比实际需要多一倍的空间。第二个问题在于复制,程序进入稳定状态之后可能只会产生少量垃圾,甚至没有垃圾,尽管如此,复制式回收器仍然会将所有内存从一处复制到另一处,这很浪费。为了避免这种情形,一些Java虚拟机会进行检查:要是没有垃圾产生,就会转换到另一种工作模式(自适应),这种模式成为标记-清扫(mark-and-sweep)。对于一般用途而言,这种方式线速度相当慢,但是当你知道只会有少量垃圾甚至没有垃圾时它的速度就很快了。
标记-清扫所依据的思路同样是从堆栈和静态存储区出发,遍历所有的引用,进而找出所有存活的对象,每找到一个就给对象设一个标记,这个过程不回收任何对象,只有当全部的标记工作完成的时候才会开始清理。在清理过程中,没有标记的对象将被释放,所以剩下的堆空间是不连续的,要是想得到连续空间的话就得重新整理剩下的对象。
5.6 成员初始化
Java尽力保证所有在使用前都能得到恰当的初始化,对于方法的局部变量,Java以编译错误的形式来贯彻这种保证。所以如果写成:
void f() { int i; i++; }
就会得到一条出错的消息,告诉你i尚未初始化。强制程序员提供一个初始值,往往能够帮助找出程序里的缺陷。
5.6.1 指定初始化
如果想为某个变量赋初值,最直接的办法就是在定义类成员变量的地方为其赋值。
public class Init { int i = 999; boolean bool = true; float f = 3.14f; //... }
也可以采用同样的方法初始化非基本类型的对象,如果Depth是一个类,那么可以像下面这样创建一个对象并初始化它:
public class Measurement { Depth d = new Depth(); //... }
如果没有为d指定初始值就尝试使用它就会出现运行错误,告诉你一个异常。
还可以通过调用某个方法来提供初值:
public class MethodInit { int i = f(); int f() { return 11; } }
5.7 构造器初始化
可以使用构造器来进行初始化,在运行时可以调用方法或执行某些动作来确定初值,但是我们无法阻止自动初始化的进行,它将在构造器被调用之前发生。例如:
public class Counter { int i; Counter() { i = 7; } }
那么i首先被置为0,然后变成7.编译器不会强制你一定要在构造器的某个地方或在使用它们之前对元素进行初始化,因为初始化早已得到了保证。
5.7.1 初始化顺序
在类的内部,变量定义的先后顺序决定了初始化的顺序,即使变量定义散布于方法定义之间,它们仍旧会在任何方法(包括构造器)之前得到初始化。例如:
package com.example.demo; class Window { Window(int marker){ System.out.print("Window(" + marker + ")\n"); } } class House{ Window w1 = new Window(1); House(){ System.out.print("House()\n"); w3 = new Window(33); } Window w2 = new Window(2); void f(){ System.out.print("f()\n"); } Window w3 = new Window(3); } public class test { public static void main(String[] args) { House h = new House(); h.f(); } }
输出结果为:
由输出可见,w3这个引用会被初始化两次,一次是在调用构造器前,一次是在调用期间,第一次引用的对象将被丢弃并作为垃圾回收。
5.7.2 静态数据的初始化
无论创建多少个对象,静态数据都只占用一份存储区域,static关键字不能应用于局部变量,因此它只能作用于域。如果一个域是静态的基本类型域,且没有对它进行初始化,那么它就会获得基本类型的标准初值,如果他是一个对象引用,那么他的默认初始化值及时null。
看看下面这个例子:
package com.example.demo; class Bowl { Bowl(int marker) { System.out.print("Bowl(" + marker + ")\n"); } void f1(int marker) { System.out.print("f1(" + marker + ")\n"); } } class Table { static Bowl bowl1 = new Bowl(1); Table() { System.out.print("Table()\n"); bowl2.f1(1); } void f2(int marker) { System.out.print("f2(" + marker + ")\n"); } static Bowl bowl2 = new Bowl(2); } class Cupboard { Bowl bowl3 = new Bowl(3); static Bowl bowl4 = new Bowl(4); Cupboard() { System.out.print("Cupboard()\n"); bowl4.f1(2); } void f3(int marker) { System.out.print("f3(" + marker + ")\n"); } static Bowl bowl5 = new Bowl(5); } public class StaticInitialization { public static void main(String[] args){ System.out.print("Creating new Cupboard() in main\n"); new Cupboard(); System.out.print("Creating new Cupboard() in main\n"); new Cupboard(); table.f2(1); cupboard.f3(1); } static Table table = new Table(); static Cupboard cupboard = new Cupboard(); }
输出结果如下:
Bowl(1)
Bowl(2)
Table()
f1(1)
Bowl(4)
Bowl(5)
Bowl(3)
Cupboard()
f1(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f1(2)
Creating new Cupboard() in main
Bowl(3)
Cupboard()
f1(2)
f2(1)
f3(1)
由输出可见,静态初始化只有在必要时才会进行,此后,静态对象不会再次被初始化。
初始化的顺序是先静态对象,而后是非静态对象。从输出结果可以观察到这一点,要执行main()就必须加载StaticInitialization类,然后其静态域table和cupboard被初始化,浙江导致他们对应的类也被加载,并且由于他们也都包含静态的Bowl对象,因此Bowl随后也被加载,这样,在这份特殊的程序中的所有类在main()开始之前就都被加载了。但实际情况通常并非如此,不会像这个例子一样把所有的事物都通过static联系起来。
总结:假设有一个名为Dog的类,
1、即使没有显示使用static关键字,构造器实际上也是静态方法,当首次创建Dog对象时,或者Dog类的静态方法/静态域被首次访问时,Java解释器必须查找类路径,以此定位Dog.class文件。
2、然后载入Dog.class,有关静态初始化的所有动作都会执行,因此静态初始化只在Class对象首次加载的时候进行一次。
3、当用new Dog()创建对象时,首相将在堆上为Dog对象分配足够的存储空间。
4、这块存储空间会被清零,这就自动地将Dog对象中所有基本类型数据都设置成了默认值,而引用则被设置为null。
5、执行所有出现于字段定义处的初始化动作。
6、执行构造器。
5.7.3 显式的静态初始化
Java允许将多个静态初始化动作组织成一个特殊的静态字句(静态块),例如:
public class Spoon { static int i; static { i = 47; } }
尽管上面的方法看起来像个方法,但它实际只是一段跟在static关键词后的代码,与其他静态初始化动作一样,这段代码仅执行一次。
5.7.4 非静态实例初始化
Java中也有被称为实例初始化的类似语法,用来初始化每一个对象的非静态变量。看起来与静态初始化子句一模一样只不过少了static关键字。这种语法对于支持匿名内部类的初始化是必须的,但它也是的你可以保证无论调用了哪个显式构造器,某些操作都会发生。实例化子句是在构造器之前执行的。
5.8 数组初始化
数组是相同类型的、用一个标识符名称封装到一起的一个对象序列或基本类型数据序列。数组是通过方括号下标操作符[]来定义和使用的,要定义一个数组只需要在类型后加上一对空的方括号即可:
int[] a1; int a1[];
两种格式的含义是一样的,后一种更符合C和C++程序员的习惯,不过前一种格式或许更合理。编译器不允许指定数组的大小。
在Java中可以将一个数字赋值给另一个数组: a2 = a1,但是真正做的只是复制了一个数组赋值给另一个数组,示例:
package com.example.demo; public class ArrayOfPrimitives { public static void main(String[] args) { int[] a1 = { 1, 2, 3, 4, 5}; int[] a2; a2 = a1; for(int i = 0; i < a2.length; i++) { a2[i] = a2[i] + 1; } for(int i = 0; i < a1.length; i++) { System.out.print("a1[" + i + "] = " + a1[i] + "\n"); } } }
输出结果为:
a1[0] = 2
a1[1] = 3
a1[2] = 4
a1[3] = 5
a1[4] = 6
可以看出,代码中给出了a1的初值但a2却没有,在上述代码中,a2是在后面被赋给另一个数组,由于a2和a1是相同数组的别名,因此通过a2所做的修改可以在a1中可以看到。
所有数组都有一个固定成员,也就是length,可以通过他获取数组包含元素个数,且不可以修改。Java数组和C、C++类似,从0开始计数,最大下标为length-1,要是超出这个边界,Java会出现运行异常。
5.8.1 可变参数列表
第二种形式提供了一张方便的语法来创建对象并调用方法,以获得与C的可变参数列表一样的效果,这可以应用于参数个数或类型未知的场合,由于所有的类都直接或间接继承于Object类,所以可以创建Object数组为参数的方法,像下面这样调用:
package com.example.demo; class A {} public class VarArgs { static void printArray(Object[] args) { for(Object obj : args) System.out.print(obj + " "); System.out.println(); } public static void main(String[] args) { printArray(new Object[]{ new Integer(47), new Float(3.14),new Double(11.11) }); printArray(new Object[]{"one", "two", "three"}); printArray(new Object[]{new A(), new A(), new A()}); } }
输出结果如下:
47 3.14 11.11
one two three
com.example.demo.A@649d209a com.example.demo.A@6adca536 com.example.demo.A@357246de
可以看到print方法使用Object数组作为参数,使用foreach语法进行遍历,打印每个对象,打印出的内容只是类的名称已经后面跟一个@符号以及多个十六进制数字,于是默认就是打印类的名字和对象地址。有了可变参数,就再也不用显示编写数组语法了,当你指定参数时编译器会为你去填充数组。
5.9 枚举类型
在Java SE5中添加了一个看似很小的特性,即enum关键字,它是的我们在需要群组合并使用枚举类型集时,可以很方便地处理,在此之前你需要创建一个整数常量集,但是这些枚举值并不会必然的将其自身的取值限制在这个常量集的范围之内,因此他们显得更有风险,并且难以使用。一个简单的例子:
public enum Spiciness { NOT, MILD, MEDIUM, HOT, FLAMING }
这里创建了一个名为Spiciness的枚举类型,它具有五个具名值,由于枚举类型的实例是常量,因此按照命名惯例它们都适用大写字母表示。
为了适用enum,需要创建一个该类型的引用,并将其赋值给某个实例:
public class SimpleEnumUse { public static void main(String[] args) { Spiciness howHot = Spiciness.MEDIUM; System.out.println(howHot); } }
输出结果为:MEDIUM
在创建enum时,编译器会自动添加一些有用的特性,例如toString(),你可以很方便地显示某个enum实例的名字;ordinal(),用来表示某个特定enum常量的声明顺序;static values(),用来按照enum常量的声明顺序,产生由这些常量值构成的数组:
public class VarArgs { public static void main(String[] args) { for(Spiciness s : Spiciness.values()) System.out.println(s + ".ordinal " + s.ordinal()); } }
输出结果为:
NOT.ordinal 0
MILD.ordinal 1
MEDIUM.ordinal 2
HOT.ordinal 3
FLAMING.ordinal 4
由于switch是要在有限的可能值集合中进行选择,因此它与enum是绝佳的组合。大体上你可以将enum用作另外一种创建数据类型的方式,然后直接将所得到的类型拿来使用。