深入浅出Java类和对象的初始化

最近项目调试中出现了类初始化的问题,虽然事后证明是Eclipse的问题,但也暴露了对Java初始化机制的欠缺,在此翻译一篇javaworld上的文章。这篇文章很好,深入浅出地介绍了Java的初始化细节。

-------------分割线--------------

初始化(Initialization ),是用来准备(prepare)类和对象以在程序执行期间使用它们。尽管我们常常把初始化当作是给变量赋值,但实际上初始化做的要比这多的多。例如,初始化可能涉及打开文件并读写文件内容到内存、注册数据库驱动、预备内容来存放图片、获取资源以播放视频等等。任何操作,只要是用来准备一个类或者一个对象以便在程序中使用,那么都可以认为是初始化。

Java通过一些语言特性来支持初始化,这些语言特性统称为初始化器(initializer)。Java中类初始化(class initialization)的处理是不同于对象初始化( object initialization)的,因为类的初始化先于对象,所以我们接下来会首先探索类初始化和针对类的初始化器;之后,我们再探讨对象初始化和针对对象的初始化器。

类初始化

程序是由类组成的。在一个Java应用运行之前,Java的类加载器会加载该应用的启动类——拥有 public static void main(String [] args) 方法的类,Java的字节码校验器(byte code verifier)对这个类进行校验。然后这个类进行初始化。最简单的类初始就是类字段自动初始化为默认值。清单1展示了这种类型的初始化:

清单1. ClassInitializationDemo1.java

// ClassInitializationDemo1.java
class ClassInitializationDemo1
{
   static boolean b;
   static byte by;
   static char c;
   static double d;
   static float f;
   static int i;
   static long l;
   static short s;
   static String st;
   public static void main (String [] args)
   {
      System.out.println ("b = " + b);
      System.out.println ("by = " + by);
      System.out.println ("c = " + c);
      System.out.println ("d = " + d);
      System.out.println ("f = " + f);
      System.out.println ("i = " + i);
      System.out.println ("l = " + l);
      System.out.println ("s = " + s);
      System.out.println ("st = " + st);
   }
}

ClassInitializationDemo1中的static关键字引入了多种类型的类字段。从上面代码可以看到,所有的类字段都没有明确赋值。可当你运行程序,你会看到如下输出:

b = false
by = 0
c =  
d = 0.0
f = 0.0
i = 0
l = 0
s = 0
st = null

false、0、0.0、null等,是针对特定类型的默认值的表现形式。它们代表了各个类字段的所有位(bit)都自动设置为0的结果。那么是谁自动设置这些位为0的呢?是Java虚拟机(JVM),Java虚拟机在类经过校验之后执行的置0操作。(注意:在上面的输出结果中,我们看到 c= 旁边并没有值,这是因为JVM把字符串类型c的默认值解析为非显示类型的空值字符)。

类字段初始化器

继自动初始化后,接下来最简单的类初始化方式是类字段的显式初始化。各类字段通过一个类字段初始化器明确地初始化为某个值。清单2展示了几个类字段初始化器:

清单2. ClassInitializationDemo2.java

// ClassInitializationDemo2.java
class ClassInitializationDemo2
{
   static boolean b = true;
   static byte by = 1;
   static char c = 'A';
   static double d = 1.2;
   static float f = 3.4f;
   static int i = 2;
   static long l = 3;
   static short s = 4;
   static String st = "abc";
   public static void main (String [] args)
   {
      System.out.println ("b = " + b);
      System.out.println ("by = " + by);
      System.out.println ("c = " + c);
      System.out.println ("d = " + d);
      System.out.println ("f = " + f);
      System.out.println ("i = " + i);
      System.out.println ("l = " + l);
      System.out.println ("s = " + s);
      System.out.println ("st = " + st);
   }
}

和ClassInitializationDemo1相比,ClassInitializationDemo2通过类字段初始化器显式地为每个类字段指定了非默认值。简言之,一个类字段初始化器由赋值符号(=)和一个表达式组成,这个表达式由JVM于类加载之后、且该类的所有开发者定义(developer-specified)方法执行之前计算。赋值符号把表达式的计算结果赋值给相应的类字段。当上面代码运行时,产生如下输出:

b = true
by = 1
c = A
d = 1.2
f = 3.4
i = 2
l = 3
s = 4
st = abc

虽然上面的输出正如预期,但这里有个问题:是谁负责执行这些类字段初始化器,以初始化ClassInitializationDemo2的类字段呢?答案是:在JVM将所有类字段的位(bit)置0后,JVM会调用一个特殊的、虚拟机层级(JVM-level)的方法,用以执行包含了类字段初始化器的字节码指令。这个方法叫做 <clinit>。

当对类进行编译时,如果该类含有类字段初始化器(即使一个),编译器就会为 <clinit> 生成代码。当依次进行了:JVM的类加载器加载该类字节码校验器校验该类的字节码JVM为类字段分配内存并零化这些字段的位,JVM才调用该类的 <clinit> 的方法(如果存在)。 <clinit> 方法的字节码指令会执行所有的类字段初始化器。为了弄明白这些字节码指令到底是什么样子,这里仍以ClassInitializationDemo2为例,其对应的指令内容如清单3:

清单3. ClassInitializationDemo2's <clinit> method

 0   iconst_1
 1   putstatic ClassInitializationDemo2/b Z          // boolean b = true;
 4   iconst_1
 5   putstatic ClassInitializationDemo2/by B         // byte by = 1;
 8   bipush 65
10   putstatic ClassInitializationDemo2/c C          // char c = 'A';
13   ldc2_w #1.200000
16   putstatic ClassInitializationDemo2/d D          // double d = 1.2;
19   ldc #3.400000
21   putstatic ClassInitializationDemo2/f F          // float f = 3.4f; 
24   iconst_2
25   putstatic ClassInitializationDemo2/i I          // int i = 2;
28   ldc2_w #3
31   putstatic ClassInitializationDemo2/l J          // long l = 3; 
34   iconst_4
35   putstatic ClassInitializationDemo2/s S          // short s = 4;
38   ldc "abc"
40   putstatic ClassInitializationDemo2/st Ljava/lang/String;  // String st = "abc";
43   return

清单3较深入地展示了 ClassInitializationDemo2的 <clinit> 方法的执行原理。每一行有一个数字以及一条字节码指令。其中,数字代表了这条指令的地址(地址是基于0的),我们这里并不会重点讨论它。第一条指令 iconst_1 ,把整型常量 1 压入堆栈;第二条指令 putstatic ClassInitializationDemo2/b Z,把前面的常量 1 出栈,并赋值给boolean类型的类字段b。(在JVM层级,至少对Sun的JVM而言,Boolean的true值是用整型常量1表示的。)第二条指令最右边的 Z 标识了b的类型为 Boolean。相似地,B表示byte类型,C表示字符(character )类型,D表示双精度浮点类型(double),F表示浮点类型(float),J表示长整型类型,S表示短整型(short )。其余的指令bipush、ldc2_w、ldc以及其他iconst指令把其他常量压入堆栈,各自相对应的putstatic指令将对应的值出栈并赋值给类字段。最后的 return 指令使得执行流程离开 <clinit> 方法,从而将程序控制权交还给主程序。此时, main() 方法开始执行。

在有些程序中,后面类字段的初始化会依赖于之前声明的类字段。Java是通过如下方式支持这个需求的,即 允许你在后续声明的类字段的类字段初始化器的表达式部分使用之前声明的类字段的名字,示例程序如清单4所示。

清单 4. ClassInitializationDemo3.java

// ClassInitializationDemo3.java
class ClassInitializationDemo3
{
   static int first = 3;
   static int second = 1 + first;
   public static void main (String [] args)
   {
      System.out.println ("first = " + first);
      System.out.println ("second = " + second);
   }
}
ClassInitializationDemo3声明类字段 first ,并显式赋值为3。然后,ClassInitializationDemo3 声明了类字段 second,并在该字段的类字段初始化器中引用了 first。当运行该程序时,会看到如下输出:

first = 3
second = 4
如果你查看ClassInitializationDemo3的<clinit>方法的字节码指令,将会如清单5所示:

清单 5. ClassInitializationDemo3's <clinit> method

 0   iconst_3
 1   putstatic ClassInitializationDemo3/first I   // first = 3;
 4   iconst_1
 5   getstatic ClassInitializationDemo3/first I
 8   iadd
 9   putstatic ClassInitializationDemo3/second I  // second = 1 + first;
12   return
清单5展示了用于执行操作——“把3赋值给first、并把常数1和first的值相加”——的字节码指令。相加的结果保存在一个临时的栈变量中。后续的指令把这个临时栈上的变量赋值给second。

尽管后面声明的类字段可以引用前面声明的类字段,反过来却行不通,即在代码中我们不能在前面的类字段初始化器的声明中引用后面才声明的类字段。换言之,Java不允许在类字段初始化器中使用向前引用(forward references),如下面代码所示:

static int second = 1 + first;
static int first = 3;
在编译过程中,当编译器遇到上面的代码或者其他有向前引用的代码时,它会产生一条错误信息,因为开发者的意图不明确。编译器该如何选择呢?是把 first 当作0,这样second的初始化值为1;把 first 当作3,second的初始化值就是4。为了避免这种混淆,Java禁止在类字段初始化器中向前引用其他字段。

类块初始化器

尽管类字段初始化器对于类字段的初始化已经足够,但对于更复杂的类初始化它就显得力不从心。例如,设想你需要在 main() 方法执行之前读取一个文件的内容到内存中,你会怎么做?为了满足类似要求,Java提供了类块初始化器(class block initializer)。类块初始化器由关键字static,以及紧随其后的开括号字符 { 、初始化代码、闭括号字符 }组成。此外,一个类块初始化器是位于类中的,而不是该类的哪个方法中,如清单6所示:

清单 6. ClassInitializationDemo4.java

// ClassInitializationDemo4.java
import java.io.*;
class ClassInitializationDemo4
{
   static String [] filenames;
   static
   {
      System.out.println ("Acquiring filenames");
      filenames = new File (".").list ();
      System.out.println ("Filenames acquired");
   }
   public static void main (String [] args)
   {
      System.out.println ("Displaying filenames\n");
      for (int i = 0; i < filenames.length; i++)
           System.out.println (filenames [i]);
   }
}
ClassInitializationDemo4 声明了数组字段 filenames,然后声明了一个类块初始化器。该初始化器先输出一条状态信息,接着获取当前目录下所有文件的名字所组成的列表,最后输出第二条状态信息。所有这些活动都是在main() 方法执行之前发生的。当 main() 执行时,先输出一条状态信息,紧接着是文件名字列表。输出结果如下所示:

Acquiring filenames
Filenames acquired
Displaying filenames
ClassInitializationDemo4.java
ClassInitializationDemo4.class
编译器除了把类字段初始化器的字节码指令放入 <clinit> 方法中,也会把所遇到的类块初始化器的字节码指令逐一放入同一个方法 <clinit> 中。编译器是按照特定的顺序把这些指令放入<clinit>方法中的。因为编译器采用自顶向下的方式对类进行编译,所以它按照自顶向下的顺序,把所遇到每一个类字段初始化器/类块初始化器的对应的字节码指令放入 <clinit> 方法中。回顾清单6:编译器把类块初始化器的字节码指令放入ClassInitializationDemo4 的 <clinit>方法中。如果该类为类字段filenames指定了一个类字段初始化器,包含这个类字段初始化器的字节码指令会先于类块初始化器的字节码指令出现在<clinit>方法中。

在研究了ClassInitializationDemo4的源码之后,你可能会感到怀疑:类块初始化器到底有什么用处呢?毕竟,我们可以非常容易地把ClassInitializationDemo4的类块初始化器内的代码 移动到它的main() 方法中。然而,类块初始化器是很有用的。例如,Sun的JDBC(Java Database Connectivity) API就采用类块初始化器来简化数据库驱动的注册。考虑如下代码片段:

Class.forName ("sun.jdbc.odbc.JdbcOdbcDriver");

上面的代码段通过调用 Class 的 forName() 方法来加载 JdbcOdbcDriver 类(该类在sun.jdbc.odbc包内)。一旦这段代码执行完毕,JdbcOdbcDriver 类就会被加载,与之相关联的数据库驱动在JDBC得到注册。那么是什么引起了注册的发生?答案是构成JdbcOdbcDriver的类块初始化器的Java语句。

当使用类块初始化器时,要记住两点:第一,在类块初始化器内定义的任何变量,其作用域局限于相应的块,块外面的代码是无法访问这些变量的。第二,Java允许你定义不带类字段初始化器的常量型类字段,前提是你需要在类块初始化器中显式地初始化那个常量类字段。同时,在这个类块初始化器中,你必须先初始化这个常量,然后才能读取它的值。清单7展示了这两点:

清单7. ClassInitializationDemo5.java

// ClassInitializationDemo5.java
import java.io.*;
class ClassInitializationDemo5
{
   final static double PI;
   static
   {
      PI = 3.14159;
      int i;
      for (i = 0; i < 5; i++)
           System.out.println (i);
   }
   static int j = i;
   public static void main (String [] args)
   {
      System.out.println ("PI = " + PI);
   }
}

在编译ClassInitializationDemo5时,编译器遇到 static int j = i; 时会报错,因为编译器找不到变量i——i是类块初始化器的局部变量。然而,如果你注释掉该行代码 static int j = i;  并重新编译,将不会再出现编译错误。相反地,会得到如下的输出结果:

0
1
2
3
4
PI = 3.14159
当你看到常量PI的声明中并没有类字段初始化器时,可能会感到奇怪。然而,无论在一个类字段初始化器中还是在一个类块初始化器中,只要PI显式地初始化为 3.14159 ,编译器都可以接受的。

类初始化与类层次

到目前为止,我们看到的都是在单一类的情况下的类字段初始化器、类块初始化器。那么在含有类层次的情况中,类初始化是如何进行的呢?当涉及到类层次时,编译器会为类层次中的每个类产生各自的 <clinit> 方法。在运行期间,JVM加载层次结构中的所有类,并按照自顶向下的顺序调用它们的 <clinit> 方法。这意味着,最基础的父类的 <clinit> 方法(即Object类的 <clinit> 方法)会首先执行。在Object的 <clinit> 方法执行之后,第二最基础的父类的 <clinit> 方法执行。这个过程按照自顶向下的顺序进行,直到遇见含有main() 函数的类的 <clinit> 方法(如果存在的话)执行为止。清单8展示了 <clinit> 方法的执行顺序:

清单8. ClassInitializationDemo6.java

// ClassInitializationDemo6.java
class Parent
{
   static int a = 1;
   static
   {
      System.out.println ("a = " + a);
      System.out.println ("Parent initializer");
   }
}
class ClassInitializationDemo6 extends Parent
{
   static int b = 2 + a;
   static
   {
      System.out.println ("b = " + b);
      System.out.println ("Child initializer");
      System.out.println ("a = " + a);
   }
   public static void main (String [] args)
   {
   }
}
ClassInitializationDemo6包含两个类:Parent 和 ClassInitializationDemo6。每个类的 <clinit> 方法执行用于初始化的字节码指令,这些指令构成了对应类的类字段初始化器和类块初始化器。为了证明 Parent 类的 <clinit> 方法要先于ClassInitializationDemo6类的 <clinit> 方法执行,只需核查下面的输出:

a = 1
Parent initializer
b = 3
Child initializer
a = 1

输出表明: Parent 的类字段初始化器 = 1; 首先执行。接着 Parent的类块初始化器执行。再往后,ClassInitializationDemo6的类字段初始化器= 2 + a;执行。最后ClassInitializationDemo6的类块初始化器执行。有关类初始化与类层次,以上几乎就是我们所需要了解的全部。

对象初始化

既然你已经了解了类初始化,是时候继续学习对象的初始化了。你将会发现,执行对象初始化的初始化器借鉴了那些用于类初始化的初始化器,两者非常相似。正如类初始化,最简单的对象初始化方法是对象字段自动初始化为默认值。清单9展示了这种初始化方式:

清单9. ObjectInitializationDemo1.java

// ObjectInitializationDemo1.java
class ObjectInitializationDemo1
{
   boolean b;
   byte by;
   char c;
   double d;
   float f;
   int i;
   long l;
   short s;
   String st;
   public static void main (String [] args)
   {
      ObjectInitializationDemo1 oid1 = new ObjectInitializationDemo1 ();
      System.out.println ("oid1.b = " + oid1.b);
      System.out.println ("oid1.by = " + oid1.by);
      System.out.println ("oid1.c = " + oid1.c);
      System.out.println ("oid1.d = " + oid1.d);
      System.out.println ("oid1.f = " + oid1.f);
      System.out.println ("oid1.i = " + oid1.i);
      System.out.println ("oid1.l = " + oid1.l);
      System.out.println ("oid1.s = " + oid1.s);
      System.out.println ("oid1.st = " + oid1.st);
   }
}
ObjectInitializationDemo1 借鉴 ClassInitializationDemo1 ,也引入了多种类型的字段,更准确地说是对象字段;并且,这些对象字段并没有被明确地赋值。当程序运行时,可以看到如下输出:

b = false
by = 0
c =  
d = 0.0
f = 0.0
i = 0
l = 0
s = 0
st = null

这个例子中JVM零化所有对象字段的位(bit)。这和ClassInitializationDemo1是不同的,在 ClassInitializationDemo1 中JVM在类加载、检验之后零化所有的类字段,而这里JVM只是在创建该类的对象时零化对象字段。当你意识到对象字段是跟对象绑定时,上述的行为就不足为奇了。因此,只有当对象创建后才存在对象字段。此外,每个对象会获得类的对象字段的一个副本。

对象字段初始化器

接下来最简单的对象初始化方式是对象字段显式地初始化为具体值。每个对象字段通过对象字段初始化器显式地初始化为某个值。清单10展示了几个对象字段初始化器:

清单10. ObjectInitializationDemo2.java

// ObjectInitializationDemo2.java
class ObjectInitializationDemo2
{
   boolean b = true;
   byte by = 1;
   char c = 'A';
   double d = 1.2;
   float f = 3.4f;
   int i = 2;
   long l = 3;
   short s = 4;
   String st = "abc";
   public static void main (String [] args)
   {
      ObjectInitializationDemo2 oid2 = new ObjectInitializationDemo2 ();
      System.out.println ("oid2.b = " + oid2.b);
      System.out.println ("oid2.by = " + oid2.by);
      System.out.println ("oid2.c = " + oid2.c);
      System.out.println ("oid2.d = " + oid2.d);
      System.out.println ("oid2.f = " + oid2.f);
      System.out.println ("oid2.i = " + oid2.i);
      System.out.println ("oid2.l = " + oid2.l);
      System.out.println ("oid2.s = " + oid2.s);
      System.out.println ("oid2.st = " + oid2.st);
   }
}
与ObjectInitializationDemo1相比,ObjectInitializationDemo2中的对象字段初始化器显式地给每个对象字段赋值了一个非缺省值。本质上,一个对象字段初始化器由赋值符号= 和一个表达式组成,该表达式在对象创建时计算。赋值符号会将表达式的计算结果赋值给相应的对象字段。当上面程序运行时,得到如下输出: 

oid2.b = true
oid2.by = 1
oid2.c = A
oid2.d = 1.2
oid2.f = 3.4
oid2.i = 2
oid2.l = 3
oid2.s = 4
oid2.st = abc

负责执行对象字段初始化器是什么?你会相信是构造函数吗?看起来似乎有点奇怪——编译器会把字节码指令插入类的构造函数中,从而执行对象字段初始化器。事实上不仅如此:当你从JVM的角度来看构造函数时,你根本看不到构造函数。相反地,你会发现JVM所指的是 <init> 方法。

类编译期间,编译器会为该类的每个构造函数分别生成一个 <init> 方法。如果该类不包含任何构造函数(如ObjectInitializationDemo2),编译器也会生成一个 <init> 方法,以匹配缺省的无参构造函数。认识到如下两点是很重要的:第一,每个构造函数有它自己相应应的 <init> 方法;第二,除了你指定的指令(通过Java代码),编译器会把其余的字节码指令放入 <init> 方法。其中的一些指令用来执行对象字段初始化器。

仔细看一下清单10,你并没有看到一个构造函数,然而这个构造函数却是真实存在的。如果你能看到这个(隐藏的缺省)构造函数,它大概如下面代码所示:

ObjectInitializationDemo2 ()
{
}
这个构造函数似乎是空的。事实上的确如此!如果你反编译ObjectInitializationDemo2的class文件。在反编译时,你会遇到一个没有参数的 <init> 方法,这个方法与缺省的无参构造函数相匹配。这个 <init> 方法中的指令如清单11所示:

清单11. ObjectInitializationDemo2's no-argument <init> method

 0        aload_0
 1        invokespecial java/lang/Object/<init>()V
 4        aload_0
 5        iconst_1
 6        putfield ObjectInitializationDemo2/b Z
 9        aload_0
10        iconst_1
11        putfield ObjectInitializationDemo2/by B
14        aload_0
15        bipush 65
17        putfield ObjectInitializationDemo2/c C
20        aload_0
21        ldc2_w #1.200000
24        putfield ObjectInitializationDemo2/d D
27        aload_0
28        ldc #3.400000
30        putfield ObjectInitializationDemo2/f F
33        aload_0
34        iconst_2
35        putfield ObjectInitializationDemo2/i I
38        aload_0
39        ldc2_w #3
42        putfield ObjectInitializationDemo2/l J
45        aload_0
46        iconst_4
47        putfield ObjectInitializationDemo2/s S
50        aload_0
51        ldc "abc"
53        putfield ObjectInitializationDemo2/st Ljava/lang/String;
56        return

清单11中的字节码指令负责执行ObjectInitializationDemo2的对象字段初始器,除此之外,对清单11的进一步分析还发现了一些有趣的内容,它们是关于Java的工作机制的:

  • 指令 aload_0 。这条指令的作用是把一个地址压入堆栈,那这个地址是什么地址呢?是当前对象的地址——与代码中的 this 关键字的含义是一样的。  指令invokespecial 、 putfield,将这个地址出栈,并在对象方法调用或者对象字段写操作时用于识别对象本身。
  • 指令  invokespecial java/lang/Object/<init>()V 。该指令调用父类Object中的缺省无参构造函数——准确地说,是缺省无参的 <init> 方法。(还记得通过关键字 super 来调用父类的构造函数么?没错,刚刚这条指令展示的正是Java在字节码级别上如何使用这个关键字。)
  • 指令 invokespecial java/lang/Object/<init>()V 的位置。编译器把这条指令作为第二条放入缺省无参 <init> 方法中并非偶然。按照Java的工作机制,一个构造函数要么首先调用同一个类的另一个构造函数、要么首先调用父类中的一个构造函数,两者必选其一。如果某构造函数没有显式地调用该类的其他构造函数(通过this关键字)和父类的构造函数(通过super),那么编译器此时生成的字节码指令等价于构造函数开始处写上super();语句的情况,即编译器对缺省情况进行了处理。

在本节的后续部分中我们会分析对象初始化与类层次的关系,将涉及上面列表中的第二和第三项,到时会继续探究。但首先,我们需要研究的是对象字段初始化器、向前引用以及对象块初始化器。

与类字段一样,有些程序需要对象字段引用之前声明的对象字段。Java是通过如下方式来支持这种需求,即允许你在后续声明的字段的初始化器的表达式部分使用之前声明的对象字段的名字。然而,正如类字段初始化器禁止使用向前引用,你也不能在对象字段初始化器中使用向前引用。清单12展示了这两个概念:

清单12. ObjectInitializationDemo3.java

// ObjectInitializationDemo3.java
class ObjectInitializationDemo3
{
//   int forwardReference = first;
   int first = 3;
   int second = 1 + first;
   public static void main (String [] args)
   {
      ObjectInitializationDemo3 oid3 = new ObjectInitializationDemo3 ();
      System.out.println ("oid3.first = " + oid3.first);
      System.out.println ("oid3.second = " + oid3.second);
   }
}
int second = 1 + first;  可以合法地引用 first ,因为 first 的声明先于second 的声明。但是,  int forwardReference = first;引用first是不合法的,因为这是向前引用。为了证明在先声明的对象字段中不能使用向前引用来引用后声明的字段,你只需把  int forwardReference = first;前的注释去掉,然后重新试着编译。

对象块初始化器

对象字段初始化器对于对象字段的初始化而言已经足够了;然而,对于其他更复杂的对象初始化来说却力不从心。为了解决复杂的对象初始化,Java引入了与类块初始化器相似的对象块初始化器。一个对象块初始化器由开放的括号字符 { 、初始化代码以及关闭的括号字符 } 组成。此外,对象块初始化器位于类中而不是哪个类方法内,如清单13所示:

清单13. ObjectInitializationDemo4.java

// ObjectInitializationDemo4.java
import java.io.*;
class ObjectInitializationDemo4
{
   {
      System.out.println ("Initializing object " + hashCode ());
      int localVariable = 1;
   }
   ObjectInitializationDemo4 (String msg)
   {
      System.out.println (msg);
//      System.out.println (localVariable);
   }
   public static void main (String [] args)
   {
      ObjectInitializationDemo4 oid41 = new ObjectInitializationDemo4 ("1");
      ObjectInitializationDemo4 oid42 = new ObjectInitializationDemo4 ("2");
   }
}
ObjectInitializationDemo4的main() 方法创建了两个ObjectInitializationDemo4对象。对于每个对象来说,对象块初始化器的执行先于构造函数。该初始化器首先会输出一条了含有对象hashcode的信息,接着声明并初始化了一个局部的正型变量。(为了证明不能再对象块初始化器的外面访问其内部的局部变量,只需移除掉示例中语句 System.out.println (localVariable); 的注释,并再次编译。)一旦对象块初始化器执行完,构造函数就会执行。下面是示例程序的输出结果,表明了对象块初始化器的执行是先于构造函数的:
Initializing object 2765838
1
Initializing object 4177328
2

在许多情况下,我们并不会使用对象块初始化器,因为完全可以在构造函数中进行复杂的初始化操作。然而,有些特定场景就需要对象块初始化器。例如,匿名内部类常常需要在对象块初始化器中进行复杂的初始化任务。匿名内部类之所以需要对象块初始化器,是因为匿名内部类没有类名,而构造函数是以类名命名的,我们不能在没有名字的类中声明构造函数。

对象初始化与类层次

在类层次的情况中,对象字段初始化器和对象块初始化器是如何工作的呢?为了深入理解,我们需要分析一下 <init> 方法,并再次查看清单11。该清单给我们展示了初始化的一个原则:如果子类没有声明构造函数,编译器会生成一个相应的 <init> 方法,其显式地调用了父类的缺省无参 <init> 方法。并且,对于每个显式调用父类构造函数的子类构造函数,编译器会在子类构造函数的 <init> 方法的开始位置插入对父类构造函数等价的 <init> 方法的调用(而这个父类构造函数即是子类构造函数中显式调用的那个)。

父类 <init> 方法被调用之后,子类的 <init> 方法为子类的每个对象字段初始化器/对象块初始化器执行相应的字节码指令。字节码指令执行初始化器的顺序与初始化器在代码中中的顺序是一致的。此外,这些字节码指令在那些调用父类构造函数的子类构造函数中都会复制一份。如清单14所示,复制操作是很有必要的,因为在不考虑开发者主动选择子类构造函数的情况下,开发者可能调用子类中的任何一个构造函数,而无论哪个,对象字段初始化器和对象块初始化器是必须执行。

清单 14. ObjectInitializationDemo5.java

// ObjectInitializationDemo5.java
class Parent
{
   int x = 1;
   {
       System.out.println ("x = " + ++x);
   }
   Parent ()
   {
       System.out.println ("Executing superclass constructor");
   }
}
class ObjectInitializationDemo5 extends Parent
{
   int a = 2;
   {
      System.out.println ("a = " + ++ a);
   }
   ObjectInitializationDemo5 ()
   {
      System.out.println ("Executing subclass constructor");
   }
   ObjectInitializationDemo5 (String msg)
   {
      System.out.println (msg);
   }
   public static void main (String [] args)
   {
      ObjectInitializationDemo5 oid51 = new ObjectInitializationDemo5 ();
      ObjectInitializationDemo5 oid52 =
       new ObjectInitializationDemo5 ("Executing other subclass constructor");
   }
}

ObjectInitializationDemo5 定义了两个类:Parent、ObjectInitializationDemo5 。每个类都声明了一个对象字段初始化器、对象块初始化器和一个构造函数。为了证明之前所述的 <init> 方法调用顺序以及对象字段初始化器/对象块初始化器的字节码指令的复制操作,只需核查一下下面的输出:

x = 2
Executing superclass constructor
a = 3
Executing subclass constructor
x = 2
Executing superclass constructor
a = 3
Executing other subclass constructor
前4行输出体现了对象oid51的初始化过程,后4行体现了对象oid52的初始化过程。这里的初始化是按照什么顺序进行的?让我们来分析一下。首先,因为ObjectInitializationDemo5 中不含类字段初始化器和类块初始化器,所以没有类初始化操作。相反地,程序是从main()方法开始执行的。main() 先创建了一个名为oid51的ObjectInitializationDemo5 对象。注意此处调用的是默认构造函数ObjectInitializationDemo5() 。

在构造函数ObjectInitializationDemo5() ——亦即ObjectInitializationDemo5的无参 <init> 方法——执行 System.out.println ("Executing subclass constructor"); 之前,<init> 方法会调用 Parent 的无参方法<init>。相应地,Parent 的无参方法 <init> 会调用Object 的缺省无参方法 <init>。一旦 Object 的缺省无参方法 <init> 执行完,Parent 的 <init> 方法就会继续,并执行与对象字段初始化器 = 1 相对应的字节码指令。然后,与 Parent 的对象块初始化器内  System.out.println ("x = " + ++x);相对应的字节码指令执行。紧接着,与 Parent 构造函数内 System.out.println ("Executing superclass constructor"); 相对应的字节码指令执行。然后,Parent 的无参方法 <init> 就执行结束,程序控制权交还给ObjectInitializationDemo5的 <init> 方法。

ObjectInitializationDemo5的 <init> 方法继续,并执行与对象字段初始化器 = 2 相对应的字节码指令。接着,与 ObjectInitializationDemo5 的对象块初始化器内的 System.out.println ("a = " + ++ a);  相对应的字节码指令执行。最后,与ObjectInitializationDemo5构造函数内 System.out.println ("Executing subclass constructor");

 相对应的字节码指令执行,这样ObjectInitializationDemo5的无参方法 <init> 执行结束。而后ObjectInitializationDemo5 oid52 = new ObjectInitializationDemo5 ("Executing other subclass constructor"); 的初始化会继续进行,与oid51相似,留给读者自己推演。

当你知道父类的对象字段/块初始化器可以访问子类的字段时,可能会感到惊讶。然后,允许这种操作并不是一个好主意,因为父类初始化是先于子类初始化的,因此子类字段在父类初始化时都是默认值。因此,父类初始化器对子类字段的访问可能会产生错误结果。清单15是对这种情况的展示:

清单15. ObjectInitializationDemo6.java

// ObjectInitializationDemo6.java
class Parent
{
   {
      System.out.println ("a = " + ((ObjectInitializationDemo6) this).a);
   }
}
class ObjectInitializationDemo6 extends Parent
{
   int a = 2;
   public static void main (String [] args)
   {
      new ObjectInitializationDemo6 ();
   }
}
当你运行ObjectInitializationDemo6时,输出结果是 a = 0 。显然 a 的值并不是 2 ,因为彼时ObjectInitializationDemo6的对象初始化器还没有运行呢!

在我们结束 对象初始化与类层次 话题之前,以下内容可能会激起你的兴趣:即存在一种情形,你可以声明一个类层次,其中的每个类拥有各自的初始化器,当你创建对象时,并没有初始化器执行。这种情形在如下情况时发生:当子类构造函数A(通过this)调用了该类的另一个构造函数B时。

在JVM角度看,那意味着相应的 <init> 方法A(在同一个子类中)调用了 <init> 方法B。Java假设方法B要么调用跟某父类构造函数相应的 <init> 方法,要么调用另一个子类构造函数的 <init> 方法。因此, <init> 方法A起始处的字节码指令是调用 <init> 方法B。然而,在这个调用之后, <init> 方法A不再执行对象字段/块初始化器的字节码指令。相反,它执行的是与开发者编写的代码相对应的字节码指令。方法A之所以不再需要执行这些初始化器的指令,是因为Java期望另一个构造函数/<init>方法(这个方法调用了父类的构造函数)去执行这项任务。如果没有正确处理,这种情形可能导致一些怪异的事情,如清单16所示:

清单16. ObjectInitializationDemo7.java

// ObjectInitializationDemo7.java
class Parent
{
   int a = 3;
   {
      System.out.println ("a = " + a);
   }
}
class ObjectInitializationDemo7 extends Parent
{
   int b = 1;
   {
      System.out.println ("b = " + b);
   }
   ObjectInitializationDemo7 ()
   {
      this (1);
   }
   ObjectInitializationDemo7 (int x)
   {
      this ();
   }
   public static void main (String [] args)
   {
      System.out.println (new ObjectInitializationDemo7 ().a);
   }
}
ObjectInitializationDemo7中,无论父类Parent还是子类ObjectInitializationDemo7都不会执行对象字段/块初始化器。这是因为每个构造函数编译为一个 <init> 方法,这个方法的第一个字节码指令递归地调用另一个 <init> 方法。然后这种情况继续下去,直到JVM报告栈溢出错误。如果你查看ObjectInitializationDemo7() 和 ObjectInitializationDemo7(int x) 的反编译内容,你在里面根本找不到执行对象字段/块初始化器的字节码指令。你能看到的所有内容只是调用另一个 <init> 方法的字节码指令。通过避开这种代码结构缺陷,你可以确保类中的对象字段/块初始化器的执行。

回顾总结

这篇文章讨论了类和对象的初始化机制。从中 ,你学习了各种类型的初始化器,包括类字段初始化器、类块初始化器、对象字段初始化器和对象块初始化器。你还了解了看似陌生的、JVM级别的 <clinit>、<init> 方法,并看到它们并没有你想象的那般陌生。最后,你透过代码,深入接触了真正执行各种初始化器的Java字节码指令。这种方式让你更透彻地理解了类层次中的初始化器运行原理。希望你可以运用这些知识,避免一些问题:父类初始化代码试图在子类字段初始化之前访问它们、递归的构造函数调用导致栈溢出以及无初始化。

作者简介

Jeff Friesen has been involved with computers for the past 20 years. He holds a degree in computer science and has worked with many computer languages. Jeff has also taught introductory Java programming at the college level. In addition to writing for JavaWorld, he wrote his own Java book for beginners -- Java 2 By Example (QUE, 2000) -- and helped write a second Java book, Special Edition Using Java 2 Platform (QUE, 2001). Jeff goes by the nickname Java Jeff (or JavaJeff). To see what he's working on, check out his Website at http://www.javajeff.com.


参考资料

1.http://www.javaworld.com/article/2075796/java-platform/java-101--class-and-object-initialization.html


  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值