从Java虚拟机看Java类和对象的初始化

类变量的初始化

类变量是之类中的static变量,在Java程序运行时它存储于方法区中,可以被认为是类信息(java.lang.Class对象)的一部分。

《Java编程思想》中描述了static变量的初始化时机:创建类的第一个对象时,或者是访问static域或者static方法时。

上述语句是有依据的,在Java虚拟机规范中严格规定了有且只有5种情况必须立即对类进行初始化:

1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

5)当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

这里1)、3)、4)条与本文直接相关,其中第一条解释《Java编程思想》中的那句话,new时创建对象,getstatic、putstatic是读取有关的static变量,invokestatic是调用静态方法。而第三条其实可以归纳到第一条,因为main方法实际上是主类的静态方法(static void main函数)。而第三条表示初始化时有向上递归初始化父类的过程。

这里要提醒一点的是,许多人会把加载过程(Loading)和类加载(Class Loading)混在一起,这里要区分开来。类加载(Class Loading)包括加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initializa-tion)、使用(Using)和卸载(Unloading)7个阶段。其中加载(Loading)阶段,虚拟机规范中规定了要完成3件事:

1)通过一个类的全限定名来获取定义此类的二进制字节流。

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

而此时类还没有进行初始化,一般虚拟机只有在碰到上面说的的5种情况才进行初始化。但此时类变量的值也不是莫名其妙的值,因为加载时会将类变量赋值为默认的0值(基本数据类型为0,引用类型为null)。

事实上javac编译器会把静态变量的初始化(包括静态块中的初始化和直接在定义时赋值初始化)收集到一个名为<clinit>方法中,该方法不能被用户调用,但它会在类需要被初始化时被虚拟机自动调用。

此外所有的类只能被初始化一次!

讲了这么多,我们来看一个例子:

class Animal {
    public static String type="animal";
    static {
        System.out.println("animal static init!");
    }
}
class Cat extends Animal {
    public static String catType="cat";
    static {
        System.out.println("cat static init!");
    }
}
public class Main {
    public static void main(String[] args) {
        System.out.println(Cat.type);
    }
}

输出结果:

animal static init! animal

​ 上述代码运行之后,只会输出“animal static init!”,而不会输出"cat static init!"。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

​ 简单描述下虚拟机中上述运行过程,要输出Cat.type,则需要调用getstatic方法,因此需要对其类进行初始化,那么初始化的到底时Animal类还时Cat类呢?反编译Main(javap -verbose Main.class)的Class文件后我们发现get static后面跟时常量池中的一个Fieldref常量,它指向的是Cat类,但是在运行getstatic指令时,会去Cat的类对象(Java.lang.class类对象)中找名为type的变量,找到后发现type指向的类是Animal类(直接定义这个字段的类),因此最后我们初始化的类对象是Animal类。

我们继续改变Main函数,代码如下(Animal和Cat代码不变):

public class Main {
    public static void main(String[] args) {
        System.out.println(Cat.catType);
    }
}

输出结果:

animal static init! cat static init! cat

可以看到这次animal类和cat类均被初始化。这正是应了虚拟机规范中的第三条:3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

在虚拟机实现中,当调用子类的<clinit>方法时我们先回调用其父类的<clinit>方法,这样一步步递归向上,直至最上层的类被初始化。

然后我们继续改变Cat类,其余类代码不变,将catType改为static final,代码如下:

class Cat extends Animal {
    public static final String catType="cat";
    static {
        System.out.println("cat static init!");
    }
}
public class Main {
    public static void main(String[] args) {
        System.out.println(Cat.catType);
    }
}

输出结果:

cat

这个时候只输出了cat,为什么呢?我们反编译Main.class文件

反编译常量

可以看到Main函数的Cat.catType没有用getstatic指令,而时用了ldc(从常量池中去数据指令)从常量池中取出了cat字符串。这是为什么呢?我们看规范中的第一条有这么一句话:读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。这是一个常量传播优化问题,虽然Java源码中引用了Cat类中的常量catType,但在javac编译阶段通过常量传播优化,已经将此常量的值“ca't”存储到了Main类的常量池中,以后Main对常量Cat.catType的引用实际都被转化为Main类对自身常量池的引用了。

实例变量的初始化

​ 实例变量的初始化时调用构造器(即使你的源码中没有为你的类写构造器,也没有定义时赋值初始化,编译器也会自动添加一个空构造器),若是在定义时赋值初始化,比如public int a=1;编译器会将该语句自动放入构造器中

​ Java中用这样的语句进行初始化:A a=new A(); 这样的语句其实具有迷惑性,因为Java编译器会将该语句编译成四条字节码指令,分成三步来完成(插一句,这也是为什么单例模式的双校验锁要加volatie的原因),我们看一下反编译以后的代码:

new的过程

​ 先解释一下,第一步时new指令,会去虚拟机堆中申请一块A对象大小的内存,然后将对象地址放到虚拟机栈上,dup指令在虚拟机栈上重新复制一块对象地址,运行invokespecial指令调用A的构造函数,最后astore把对象地址给局部变量a。

​ 我们抛开dup指令(该指令是因为调用invokespecial指令会弹出对象地址,如果不复制一份,astore指令无法将对象地址给局部变量a),可以将其归纳成三步:1.申请堆空间2.调用构造函数3对象地址给引用。

​ 这里我们要注意有两次初始化,第一次是在new指令中,虚拟机申请堆空间后,会将实例变量刷成默认的0值(基本数据类型为0,引用类型为null)。第二次是在invokespecial调用构造函数中,对实例变量按照构造器进行初始化。

​ 第一次的初始化使得Java语言保证了安全性,即使某个实例变量忘记初始化也不会有莫名其妙的值出现。

​ 例子可以在下面的综合构造器中的多态的章节中看到。

​ 如果有继承关系的话,子类的构造器会先调用父类的构造器,但要注意这不是由虚拟机来保证的,而是由javac的编译器来保证的。我们看这样一个例子:

class A{}

class B extends A{}

public class Test {
	public static void main(String[] args) {
        new B();
    }    
}

​ 对程序进行反编译,我们可以看到:

​ Test中调用了B的构造方法:

构造器1

​ 构造器B中调用A的构造方法:

构造器2

​ 构造器A中调用Object类的构造方法:

构造器3

初始化顺序

​ 在类的内部,变量定义的先后顺序决定了初始化的顺序,例子如下(《Java编程思想》第四版P94):

class Door{  
    Door(int marker){  
        System.out.println("Door("+marker+")");  
    }  
}  
  
class House{  
    Door d1=new Door(1);  //定义发生在构造器调用之前  
    House(){  
        System.out.println("House()");  
        d3=new Door(8);   //对d3的重新定义  
    }  
    Door d2=new Door(2);  //定义发生在构造器调用之后  
    void f(){  
        System.out.println("f()");  
    }  
    Door d3=new Door(3);  //定义发生在最后  
}  
  
public class Order {  
    public static void main(String args[])  
    {  
        House h=new House();  
        h.f();  
    }  
}  

输出结果:

Door(1) Door(1) Door(1) House() Door(8) f()

但我们也要注意构造块会优先于构造函数执行,我们看这样一个例子:

public class ConstructBlock {
    ConstructBlock(){
        a=3;
    }
    {
        a=2;
    }
    public int a=1;
    public static void main(String[] args) {
        System.out.println(new ConstructBlock().a);
    }
}

输出结果:

3

我们看一下反编译的结果:

构造器初始化顺序

构造器中实际初始化顺序为a先=2在=1最后=3。

综合

我们常常会看到书中写:初始化的顺序是先静态变量(如果它们尚未因前面的对象创建而被初始化),而后是“非静态”变量。

现在我们知道为什么这样了, new A()这条语句会先执行new指令,若类还未被初始化的话,碰到new指令会先初始化类变量,然后调用invokespecial指令运行构造函数。

下面我们看一个有继承,有类变量和实例变量初始化的例子(《Java编程思想》第四版P146):

class Insect {  
  private int i = 9;  
  protected int j;  
  Insect() {  
    System.out.println("i = " + i + ", j = " + j);  
    j = 39;  
  }  
  private static int x1 =  
    printInit("static Insect.x1 initialized");  
  static int printInit(String s) {  
      System.out.println(s);  
    return 47;  
  }  
}  
  
public class Beetle extends Insect {  
  private int k = printInit("Beetle.k initialized");  
  public Beetle() {  
      System.out.println("k = " + k);  
    System.out.println("j = " + j);  
  }  
  private static int x2 =  
    printInit("static Beetle.x2 initialized");  
  public static void main(String[] args) {  
      System.out.println("Beetle constructor");  
    Beetle b = new Beetle();  
  }  
}

输出结果:

static Insect.x1 initialized

static Beetle.x2 initialized

Beetle constructor

i = 9, j = 0

Beetle.k initialized

k = 47

j = 39

​ 解释一下:

​ 在Java上运行Beetle时,所发生的第一件事情就是试图访问Beetle.main()(一个static方法)(上面5条情况中的第四条:当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类),于是加载器开始启动并找出Beetle类的编译代码(在名为Beetle.class的文件之中)。在对它进行加载的过程中,编译器注意到它有一个基类(这是由关键字extends得知的),于是它继续进行加载。不管你是否打算产生一个该基类的对象,这都要发生。

​ 如果该基类还有其自身的基类,那么第二个基类就会被加载,以此类推。接下来,根基类的static初始化(在此例中为Insect)即会被执行,然后是下一个导出类,以此类推。这种方式很重要,因为导出类的static初始化可能会依赖于基类成员能否被正确初始化。

​ 至此为止,必要的类都已经加载完毕,对象就可以被创建了。首先,对象中所有的基本类型都会被设为默认值,对象引用被设为null----这是通过将对象内存设为二进制零值而一举生成的。然后,基类的构造器会被调用。在本例中,它是被自动调用的。但也可以用super来指定对基类构造器的调用(正如**Beetle()**构造器中的第一部操作)。基类构造器和导出类的构造器一样,以相同的顺序来经历相同的过程。在基类构造器完成之后,实例变量按其顺序被初始化。最后,构造器的其余部分被执行。

进阶

数组初始化

class Animal {
    static {
        System.out.println("animal static init!");
    }
}
public class Main {
    public static void main(String[] args) {
        Animal[] x=new Animal[4];
    }
}

​ 运行结果没有任何输出。明明有new,为什么没有初始化Animal类呢?我们看一下反编译结果:

new数组

​ 其实并没有运行new指令,运行的时anewarray指令,这里面触发了另外一个名为“[LAnimal”的类的初始化阶段,对于用户代码来说,这并不是一个合法的类名称,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类。这个类代表了一个元素类型为Animal的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。但是该指令并不会初始Animal类。

构造器中的多态

​ 如果在一个构造器的内部调用正在构造的对象的某个动态绑定方法,会发生什么情况呢?我们看如下的例子(《Java编程思想》第四版P163):

public class PloyConstructors {
    public static void main(String[] args) {
        new RoundGlyph(4);
    }
}

class Glyph{
    void draw(){
        System.out.println("Glyph draw: ");
    }
    Glyph(){
        System.out.println("Glyph before draw()");
        //考虑动态绑定的问题在父类中如果某种情况下
        //调用被子类覆写的方法,会发生什么?
        draw();
        System.out.println("Glyph after draw()");
    }
}

class RoundGlyph extends Glyph{
    private int radius = 1;
    RoundGlyph(int r){
        //super();
        radius = r;
        System.out.println("RoundGlyph.RoundGlyph,radius " + radius);
    }
    //通常情况下子类覆写父类方法获得多态性
    @Override
    void draw(){
        System.out.println("RoundGlyph.draw(),radius " + radius);
    }
}

输出结果:

Glyph before draw() RoundGlyph.draw(),radius 0 Glyph after draw() RoundGlyph.RoundGlyph,radius 4

初始化的实际过程:   1.存储空间被分配出来准备创建一个类实例,存储空间被初始化为0   2.调用父类构造器,初始化父类对象,这时会调用被覆盖的draw()方法,由于步骤1,此时radius=0   3.调用成员的初始化部分   4.调用子类的构造器

但是为什么对象还在构造阶段就能实现多态呢?

这就要牵扯到invokevirtual方法,Java的动态绑定是依靠该指令实现的,方法最后会在运行invokevirtual指令时栈顶的对象所对应的类中找方法(在这里new 的是RoundGlyph对象,就会在RoundGlyph类中找)中找方法。上面的程序中父类构造器中调用了draw(),会调用invokevirtual指令,然后在栈顶RoundGlyph对象所在的RoundGlyph类中找方法(找方法的过程是先找当前类,若当前类没有该方法去找父类),因此必然调用的是子类的方法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章