Java类中数据域初始化顺序

Posted by     "Eric" on Wednesday, December 25, 2019

今天在做马士兵的坦克项目时,有个问题让我困惑了很久,翻阅了《深入理解Java虚拟机》以及《Java核心技术》之后,稍微找到了一些眉目,在这里做个笔记。

问题出处:

为了实现Models与View分离,将坦克大战程序的显示部分全部放入TankFrame部分,将游戏模型、内部逻辑部分放入到GameModel部分,并将GameModel设计为单例模式,保证只被创建一次。

在GameModel中期望每次初始化都首先添加一个myTank对象(也就是自己可以控制的坦克对象)

但是在TankFrame中企图使用GameModel的静态方法getINSTANCE()得到实例的时候,总是报错,提示在初始化Tank()对象的时候GameModel.getINSTANCE().add(this)有一个空指针异常。程序逻辑如下图所示:

3T5nOJ.png

此时,我瞬间反应过来,应该是在Tank的构造方法中调用GameModel.getINSTANCE()时,还没有完成对GameModel的实例化。所以,Java类中数据初始化的顺序到底是怎样进行的呢?

public class TankFrame extends Frame{
    GameModel gm = GameModel.getINSTANCE();
    ...
}

3T5YlD.png

3T5DtP.png

问题剖析:

首先我们要分清一个概念,类的初始化和对象的初始化。类的初始化发生在类加载时,在以下五种情况下可以发生类加载

  1. 第一次使用new关键字实例化对象的时候
  2. 首次访问类的静态变量或静态方法时。
  3. Class.forName
  4. 子类初始化,如果父类还没初始化,会引发
  5. 虚拟机启动时,初始化主类(main类)

类加载时便会完成类的初始化,所谓初始化就是给类的静态变量附初值,初始化过程为:

  1. 在类的第一次加载时,将静态域初始化为默认值
  2. 所有的静态初始化语句以及静态初始化块都依照类定义的顺序执行(构成cinit方法,该方法在类加载的初始化阶段被调用),另外静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,前面的静态语句块可以赋值,但不能访问
public class Test{
    static {
        i=0;						//正确
        System.out.print(i);		//非法前向引用
    }
    static int i=1;
}

而对象的初始化则发生在new一个类对象时候,这回调用该类的构造函数,但是在运行构造函数体之前会发生以下操作:

  1. 所有数据域都被初始化为默认值
  2. 按照在类声明中出现的次序,依次执行所有域初始化语句和初始化块
  3. 如果构造器第一行调用了第二个构造器,则执行第二个构造器主体
  4. 执行这个构造器的主体。

我们也可以从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()时进行的一系列动作如下图所示,为什么报错也就很明朗了

3T5bX4.md.png

解决办法:

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对象,此时虽然类加载并没有完成,但是也不会再次触发类加载,而是进入创建类对象的阶段。其实,第二种情况正是单例模式中所运用的。