今天在做马士兵的坦克项目时,有个问题让我困惑了很久,翻阅了《深入理解Java虚拟机》以及《Java核心技术》之后,稍微找到了一些眉目,在这里做个笔记。
问题出处:
为了实现Models与View分离,将坦克大战程序的显示部分全部放入TankFrame部分,将游戏模型、内部逻辑部分放入到GameModel部分,并将GameModel设计为单例模式,保证只被创建一次。
在GameModel中期望每次初始化都首先添加一个myTank对象(也就是自己可以控制的坦克对象)
但是在TankFrame中企图使用GameModel的静态方法getINSTANCE()得到实例的时候,总是报错,提示在初始化Tank()对象的时候GameModel.getINSTANCE().add(this)有一个空指针异常。程序逻辑如下图所示:
此时,我瞬间反应过来,应该是在Tank的构造方法中调用GameModel.getINSTANCE()时,还没有完成对GameModel的实例化。所以,Java类中数据初始化的顺序到底是怎样进行的呢?
public class TankFrame extends Frame{
GameModel gm = GameModel.getINSTANCE();
...
}
问题剖析:
首先我们要分清一个概念,类的初始化和对象的初始化。类的初始化发生在类加载时,在以下五种情况下可以发生类加载
- 第一次使用new关键字实例化对象的时候
- 首次访问类的静态变量或静态方法时。
- Class.forName
- 子类初始化,如果父类还没初始化,会引发
- 虚拟机启动时,初始化主类(main类)
类加载时便会完成类的初始化,所谓初始化就是给类的静态变量附初值,初始化过程为:
- 在类的第一次加载时,将静态域初始化为默认值
- 所有的静态初始化语句以及静态初始化块都依照类定义的顺序执行(构成cinit方法,该方法在类加载的初始化阶段被调用),另外静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,前面的静态语句块可以赋值,但不能访问
public class Test{
static {
i=0; //正确
System.out.print(i); //非法前向引用
}
static int i=1;
}
而对象的初始化则发生在new一个类对象时候,这回调用该类的构造函数,但是在运行构造函数体之前会发生以下操作:
- 所有数据域都被初始化为默认值
- 按照在类声明中出现的次序,依次执行所有域初始化语句和初始化块
- 如果构造器第一行调用了第二个构造器,则执行第二个构造器主体
- 执行这个构造器的主体。
我们也可以从JVM的角度来验证以上两种初始化步骤。
public class Demo{
static int i=10;
static{
i=20;
}
static{
i=30;
}
}
我们将上面代码的字节码反编译后得到以下结果,会发现编译器会按上至下,收集所有static静态代码和静态成员赋值的代码,合并成一个特殊的方法()V
static {};
descriptor: ()V
flags: (0x0008) ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: bipush 10
2: putstatic #2 // Field i:I
5: bipush 20
7: putstatic #2 // Field i:I
10: bipush 30
12: putstatic #2 // Field i:I
15: return
LineNumberTable:
line 2: 0
line 5: 5
line 8: 10
line 9: 15
}
接下来我们看非静态变量初始化过程:
public class Demo{
private String a=“s1”;
{
b=20;
}
private int b=20;
{
a=“s2”;
}
public Demo(String a, int b){
this.a=a;
this.b=b;
}
public static void main(String[] args) {
Demo d=new Demo(“s3”, 30);
System.out.println(d.a);
System.out.println(d.b);
}
}
上面代码的字节码经过反编译后,得到以下结果,我们会发现编译器按从上到下的顺序,收集所有代码和成员变量复制的代码,形成新的构造方法,但原始构造方法会被放在最后,如下图28-35的内容
public Demo(java.lang.String, int);
descriptor: (Ljava/lang/String;I)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String s1
7: putfield #3 // Field a:Ljava/lang/String;
10: aload_0
11: bipush 20
13: putfield #4 // Field b:I
16: aload_0
17: bipush 20
19: putfield #4 // Field b:I
22: aload_0
23: ldc #5 // String s2
25: putfield #3 // Field a:Ljava/lang/String;
28: aload_0 //-----------------------------------
29: aload_1
30: putfield #3 // Field a:Ljava/lang/String;
33: aload_0
34: iload_2
35: putfield #4 // Field b:I-------------------------
38: return
这时我们发现,对象的初始化必然会导致类的初始化(第一次new类对象会加载类),但类的初始化不一定导致对象的初始化(使用类的静态变量和方法)
所以在调用GameModel.getINSTANCE()时进行的一系列动作如下图所示,为什么报错也就很明朗了
解决办法:
static{
try{
INSTANCE = new GameModel();
INSTANCE.init();
}catch ...
}
Tank myTank=null;
在GameModel中先给成员变量myTank赋值为null,那么在创建GamModel对象时就不会创建Tank对象。待GameModel对象创建完成后,再使用init()函数,让myTank=new Tank( ).创建Tank对象,此时GameModel对象已经创建完成(单例),在Tank的构造方法中调用GameModel.getInstance.add(this)就不会报空指针异常了。
总结:
总结起来,不要把创建对象和类加载混为一谈,创建对象时会首先检查类是否被加载,如果没有则触发类加载的一系列行动。类加载时会涉及静态变量的初始化操作,而创建对象会涉及成员变量的初始化。
稍微拓展一下,成员变量中能否持有自身类的对象,比如
class A{
private A a=new A();
}
又比如,静态变量能否持有自身类对象
class A{
private static A a=new A();
}
答案是第一种情况会发生StackOverFlow,因为会陷入创建A对象的死循环;第二种情况是正常的,因为在类加载的初始化阶段,会创建一个A对象,此时虽然类加载并没有完成,但是也不会再次触发类加载,而是进入创建类对象的阶段。其实,第二种情况正是单例模式中所运用的。