一只黑色的猫 阅读(29) 评论(0)

今天,我们就来解决一个问题,一个类实例究竟要经过多少个步骤才能被创建出来,也就是下面这行代码的背后,JVM 做了哪些事情?

Object obj = new Object();

当虚拟机接受到一条 new 指令时,首先会拿指令后的参数,也就是我们类的符号引用,于方法区中进行检查,看是否该类已经被加载,如果没有则需要先进行该类的加载操作。

一旦该类已经被加载,那么虚拟机会根据类型信息在堆中分配该类对象所需要的内存空间,然后返回该对象在堆中的引用地址。

一般而言,虚拟机会在 new 指令执行结束后,显式调用该类的对象的 方法,这个方法需要程序员在定义类的时候给出,否则编译器将在编译期间添加一个空方法体的 方法。

以上步骤完成后,基本上一个类的实例对象就算是被创建完成了,才能够为我们程序中使用,下面我们详细的了解每个步骤的细节之处。

初始化父类

知乎上看到一个问题:

Java中,创建子类对象时,父类对象会也被一起创建么?

有关这个问题,我还特意去搜了一下,很多人都说,一个子类对象的创建,会对应一个父类对象的创建,并且这个子类对象会保存这个父类对象的引用以便访问父类对象中各项信息

这个答案肯定是不对的,如果每一个子类对象的创建都要创建其所有直接或间接的父类对象,那么整个堆空间岂不是充斥着大量重复的对象?这种内存空间的使用效率也会很低。

我猜这样的误解来源于 《Thinking In Java》 中的一句话,可能大家误解了这段话,原话很多很抽象,我简单总结了下:

虚拟机保证一个类实例初始化之前,其直接父类或间接父类的初始化过程执行结束

看一段代码:

public class Father {
    public Father(){
        System.out.println("father's constructor has been called....");
    }
}
public class Son extends Father {
    public Son(){
        System.out.println("son's constructor has been called ...");
    }
}
public static void main(String[] args){
    Son son = new Son();
}

输出结果:

father's constructor has been called....
son's constructor has been called ...

这里说的很明白,只是保证父类的初始化动作先执行,并没有说一定会创建一个父类对象引用。

这里很多人会有疑惑,虚拟机保证子类对象的初始化操作之前,先完成父类的初始化动作,那么如果没有创建父类对象,父类的初始化动作操作的对象是谁?

这就涉及到对象的内存布局,一个对象在堆中究竟由哪些部分组成?

HotSpot 虚拟机中,一个对象在内存中的布局由三个区域组成:对象头,实例数据,对齐填充。

对象头中保存了两部分内容,其一是自身运行的相关信息,例如:对象哈希码,分代年龄,锁信息等。其二是一个指向方法区类型信息的引用。

对象实例数据中存储的才是一个对象内部数据,程序中定义的所有字段,以及从父类继承而来的字段都会被记录保存。

像这样:

image

当然,这里父类的成员方法和属性必须是可以被子类继承的,无法继承的属性和方法自然是不会出现在子类实例对象中了。

粗糙点来说,我们父类的初始化动作指的就是,调用父类的 方法,以及实例代码块,完成对继承而来的父类成员属性的初始化过程。

对齐填充其实也没什么实际的含义,只是起到一个占位符的作用,因为 HotSpot 虚拟机要求对象的大小是 8 的整数倍,如果对象的大小不足 8 的整数倍时,会使用对齐填充进行补全。

所以不存在说,一个子类对象中会包含其所有父类的实例引用,只不过继承了可继承的所有属性及方法,而所谓的「父类初始化」动作,其实就是对父类 方法的调用而已。

this 与 super 关键字

this 关键字代表着当前对象,它只能使用在类的内部,通过它可以显式的调用同一个类下的其他方法,例如:

public class Son {

    public void sayHello(){
        System.out.println("hello");
    }
    public void introduce(String name){
        System.out.println("my name is:" + name);

        this.sayHello();
    }
}

因为每一个方法的调用都必须有一个调用者,无论你是类方法,或是一个实例方法,所以理论上,即便在同一个类下,调用另一个方法也是需要指定调用者的,就像这里使用 this 来调用 sayHello 方法一样。

并且编译器允许我们在调用同类的其他实例方法时,省略 this。

其实每个实例方法在调用的时候都默认会传入一个当前实例的引用,这个值最终被传递赋值给变量 this。例如我们在主函数中调用一个 sayHello 方法:

public static void main(String[] args){
    Son son = new Son();
    son.sayHello();
}

我们反编译主函数所在的类:

image

字节码指令第七行,astore_1 将第四行返回的 Son 实例引用存入局部变量表,aload_1 加载该实例引用到操作数栈。

接着,invokevirtual #4 会调用一个虚方法(也就是一个实例方法),该方法的符号引用为常量池第四项,除此之外,编译器还会将操作数栈顶的当前实例引用作为方法的一个参数传入。

image

可以看到,sayHello 方法的局部变量表中的 this 的值 就是方法调用时隐式传入的。这样你在一个实例方法中不加 this 的调用其他任意实例方法,其实调用的都是同一个实例的其他方法。

总的来说,对于关键字 this 的理解,只需要抓住一个关键点就好:它代表的是当前类实例,并且每个非静态方法的调用都必定会传入当前的实例对象,而被调用的方法默认会用一个名为 this 的变量进行接收。

这样做的唯一目的是,实例方法是可以访问实例属性的,也就是说实例方法是可以修改实例属性数据值的,所以任何的实例方法调用都需要给定一个实例对象,否则这些方法将不知道读写哪个对象的属性值。

那么 super 关键字又代表着谁,能够用来做什么呢?

我们说了,一个实例对象的创建是不会创建其父类对象的,而是直接继承的父类可继承的字段,大致的对象内存布局如下:

image

this 关键字可以引用到当前实例对象的所有信息,而 super 则只能引用从直接父类那继承来的成员信息。

看一段代码:

public class Father {
    public String name = "father";
}
public class Son extends Father{
    public String name = "son";
    public void showName(){
        System.out.println(super.name);
        System.out.println(this.name);
    }
}

主函数中调用这个 showName 方法,输出结果如下:

father
son

应该不难理解,无论是 this.name 或是 super.name 它们对应的字节码指令是一样的,只是参数不同而已。而这个参数,编译器又是如何确定的呢?

如果是 this,编译器优先从当前类实例中查找匹配的属性字段,没有找到的话将递归向父类中继续查询。而如果是 super 的话,将直接从父类开始查找匹配的字段属性,没有找到的话一样会递归向上继续查询。

完整的初始化过程

下面我们以两道面试题,加深一下对于对象的创建与初始化的相关细节理解。

面试题一:

public class A {
    static {
        System.out.println("1");
    }
    public A(){
        System.out.println("2");
    }
}
public class B extends A {
    static{
        System.out.println("a");
    }
    public B(){
        System.out.println("b");
    }
}

Main 函数调用:

public static void main(String[] args){
    A ab = new B();
    ab = new B();
}

大家不妨可以思考一下,最终的输出结果是什么。

输出结果如下:

1
a
2
b
2
b

我们来解释一下,第一条语句:

A ab = new B();

首先发现类 A 并没有被加载,于是进行 A 的类加载过程,类加载的最后阶段,初始化阶段会调用编译器生成的

紧接着会去加载类 B,同样的过程,打印了字符「a」。

最后调用 new 指令,于堆上分配内存,并开始实例初始化操作,调用自身构造器之前会首先调用一下父类 A 的构造器保证对 A 的初始化,于是打印了字符「2」,接着调用字节的构造器,打印字符「b」。

至此,第一条语句算是执行结束了。

第二条语句:

ab = new B();

由于类型 B 已经被加载进方法区了,虚拟机不会重复加载,直接进入实例化的过程,同样的过程,分别打印字符「2」和「b」。

这一道题目应该算简单的,只要理解了类加载过程中的初始化过程和实例对象的初始化过程,应该是手到擒来。

面试题二:

public class X {
    Y y = new Y();
    public X(){
        System.out.println("X");
    }
}
public class Y {
    public Y(){
        System.out.println("Y");
    }
}
public class Z extends X {
    Y y  = new Y();
    public Z(){
        System.out.println("Z");
    }
}

Main 函数调用:

public static void main(String[] args){
    new Z();
}

同样的,大家可以先自行分析分析运行的结果是什么。

输出结果如下:

Y
X
Y
Z

我们一起来分析一下,首先这个主函数中的代码很简单,就是实例化一个 Z 类型的对象,虚拟机一样的会先进行 Z 的类加载过程。

发现并没有静态语句需要执行,于是直接进入实例化阶段。实例化阶段主要分为三个部分,实例属性字段的初始化,实例代码块的执行,构造函数的执行。 而实际上,对于实例属性字段的赋值与实例代码块中代码都会被编译器放入构造函数中一起运行。

所以,在执行 Z 的构造器之前会先进入 X 的构造器,而 X 中的实例属性会按序被编译器放入构造器。也就是说,X 构造器的第一步其实是这条语句的执行:

Y y = new Y();

所以,进行类型 Y 的类加载与实例化过程,结束后会打印字符「Y」。

然后,进入 X 的构造器继续执行,打印字符「X」。

至此,父类的所有初始化动作完成。

最后,进行 Z 本身的构造器的初始化过程,一样会先初始化实例属性,再执行构造函数方法体,输出字符「Y」和「Z」。

有关类对象的创建与初始化过程,这两道题目算是很好的检验了,其实这些初始化过程并不复杂,只需要你理解清楚各个步骤的初始化顺序即可。


文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:扑在代码上的高尔基,所有文章都将同步在公众号上。

image