Java填坑之从JVM上理解对象的创建

在写代码的过程中,或者在看面试题的时候,遇到一些刁钻古怪的代码,非常难以理解,如果不去了解JVM原理,感觉就像盲人摸象(此时又想吐槽一下大学的课程),所以接下来的学习过程中,我会试着将代码结合上虚拟机的原理,让写每一条代码,或者在跑程序的过程中,脑子里面都有一个大概的结构图。

Java是一门面向对象的编程语言,Java程序在无时无刻中都有对象被创建出来;在语言层面上,创建对象仅仅是一个new关键字而已,而在虚拟机中,对象的创建又是一个怎么样的过程呢?
先从我们最常使用的new关键字说起,当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,那必须先执行相应的类加载过程。
在这里插入图片描述
这里说到最常见的,能够触发类的加载过程的new关键字,那还有哪些能触发类加载呢?
我们先来看一下类的生命周期:
在这里插入图片描述
虚拟机把Class文件加载到内存,然后进行校验,解析和初始化,最终形成java类型,这就是虚拟机的类加载机制。加载,验证,准备,初始化,卸载这5个阶段的顺序是确定的,类的加载过程,必须按照这种顺序开始。这些阶段通常是相互交叉和混合进行的。解析阶段在某些情况下,可以在初始化阶段之后再开始—为了支持java语言的运行时绑定。
关于什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对初始化阶段,《Java虚拟机规范》严格要求来有且仅有六种情况必须立即对类进行“初始化”,而加载、验证、准备自然需要在此之前开始。

  1. 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段;能够触发这四条指令的Java代码场景有:使用new关键字实例化对象的时候;读取或者设置一个类型的静态字段(被final修饰,已在编译器把结果放入常量池的静态字段除外)的时候;调用一个类型的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putPutStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK8新加入的默认方法时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

加载,验证,准备

加载就是通过指定的类全限定名,获取此类的二进制字节流,然后将此二进制字节流转化为方法区的数据结构,在内存中生成一个代表这个类的Class对象验证是为了确保Class文件中的字节流符合虚拟机的要求,并且不会危害虚拟机的安全。加载和验证阶段比较容易理解,这里就不再过多的解释。解析阶段比较特殊,解析阶段是虚拟机将常量池中的符号引用转换为直接引用的过程。如果想明白解析的过程,得先了解一点class文件的一些信息。class文件采用一种类似C语言的结构体的伪结构来存储我们编码的java类的各种信息。其中,class文件中常量池(constant_pool)是一个类似表格的仓库,里面存储了我们编写的java类的类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。在java虚拟机将class文件加载到虚拟机内存之后,class类文件中的常量池信息以及其他的数据会被保存到java虚拟机内存的方法区。我们知道class文件的常量池存放的是java类的全名,接口的全名和字段名称描述符,方法的名称和描述符等信息,这些数据加载到jvm内存的方法区之后,被称做是符号引用。而把这些类的全限定名,方法描述符等转化为jvm可以直接获取的jvm内存地址,指针等的过程,就是解析。虚拟机实现可以对第一次的解析结果进行缓存,避免解析动作的重复执行。在解析类的全限定名的时候,假设当前所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或者接口C的直接引用,具体的执行办法就是虚拟机会把代表N的全限定名传递给D的类加载器去加载这个类C。这块可能不太好理解,但是我们可以直接理解为调用D类的ClassLoader来加载N,然后就完成了N—>C的解析,就可以了。

准备阶段

之所以把在解析阶段前面的准备阶段,拿到解析阶段之后讲,是因为,准备阶段已经涉及到了类数据的初始化赋值。和我们本文讲的初始化有关系,所以,就拿到这里来讲述。在java虚拟机加载class文件并且验证完毕之后,就会正式给类变量分配内存并设置类变量的初始值。这些变量所使用的内存都将在方法区分配。注意这里说的是类变量,也就是static修饰符修饰的变量,在此时已经开始做内存分配,同时也设置了初始值。比如在 Public static int value = 123 这句话中,在执行准备阶段的时候,会给value分配内存并设置初始值0, 而不是我们想象中的123. 那么什么时候 才会将我们写的123 赋值给 value呢?就是我们下面要讲的初始化阶段。

初始化

类初始化阶段是类加载过程的最后阶段。在这个阶段,java虚拟机才真正开始执行类定义中的java程序代码。Java虚拟机是怎么完成初始化的呢?这要从编译开始讲起。在编译的时候,编译器会自动收集类中的所有静态变量(类变量)和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是根据语句在java代码中的顺序决定的。收集完成之后,会编译成java类的 static{} 方法,java虚拟机则会保证一个类的static{} 方法在多线程或者单线程环境中正确的执行,并且只执行一次。在执行的过程中,便完成了类变量的初始化。值得说明的是,如果我们的java类中,没有显式声明static{}块,如果类中有静态变量,编译器会默认给我们生成一个static{}方法。这个时候要注意啦,静态初始化在程序运行过程中只会在 Class 对象首次加载的时候运行一次。这些资源都会放在 jvm 的方法区。方法区又叫静态区,跟堆一样,被所有的线程共享。方法区中包含的都是在整个程序中永远唯一的元素,包含所有的 class 和 static 变量。

继承

如果出现继承呢?如果有继承的话,父类中的类变量该如何初始化?这点由虚拟机来解决:虚拟机会保证在子类的static{}方法执行之前,父类的static{}方法已经执行完毕。由于父类的static{}方法先执行,也就意味着父类的静态变量要优先于子类的静态变量赋值操作。

public class Insect {
    private int i = 9;
    protected int j;
    
    protected static int x1 = printInit("static Insect.x1 initialized");
    
    Insect() {
        System.out.println("基类构造函数阶段: i = " + i + ", j = " + j);
        j = 39;
    }
    
    static int printInit(String s) {
        System.out.println(s);
        return 47;
    }
}

public class Beetle extends Insect {
    
    protected int k = printInit("Beetle.k initialized");
    
    protected static int x2 = printInit("static Beetle.x2 initialized");    
    
    public static void main(String[] args) {        
       v
    }
}

上面例子来自《java编程思想》,以上代码的执行结果是什么呢?如果对上面我们讲的理论理解的话,很容易就知道结果是:

static Insect.x1 initialized

static Beetle.x2 initialized

基类构造函数阶段: i = 9, j = 0

Beetle.k initialized

具体的执行结果过程是:

在执行Beetle 类的 main方法的时候,因为该main方法是static方法,我们在上面已经知道,在执行类的static方法的时候,如果该类没有初始化,则要进行初始化,

因此,我们在执行main方法的时候,会执行加载–验证–准备–解析—初始化这个过程。在进行最后的初始化的时候,又有一个约束:虚拟机会保证在子类的static{}

方法执行之前,父类的static{}方法已经执行完毕。所以,在执行完解析之后,会先执行父类的初始化,在执行父类初始化的时候,

输出: static Insect.x1 initialized

然后接着初始化子类,输出:static Beetle.x2 initialized

以上两行输出,是静态变量的初始化,是在第一次调用静态方法,即,在执行new、getstatic、putstatic、或者invokestatic 这4条字节码指令时候触发的。所以,

你如果把上例中的static main 方法中的 Beetle b = new Beetle();
在这里插入图片描述

注释掉,上面两行仍然会输出出来。然后就是执行Beetle b = new Beetle();这句代码了。我们知道,在实例化子类对象的时候,会自动调用父类的构造函数。

所以,接着就输出:基类构造函数阶段: i = 9, j = 0

紧接着是执行自己的构造函数,在堆上创建类实例对象,实例对象空间清零,然后执行赋值语句k = printInit(“Beetle.k initialized”);

输出: Beetle.k initialized

至此,整个类加载并初始化完毕。

个人理解误区

之前看书和一些博客的时候,把初始化跟实例化混在一起了,其实类进行初始化是不一样要进行实例化的,只有在代码中写了诸如上面的: Beetle b = new Beetle(); 调用了构造器,才会执行构造器中的语句;验证:

public class Father {

    public Father() {
        System.out.println(" i am constructor");
    }

    public static void speak() {
        System.out.println("sppppeak");
    }

}

main类:
在这里插入图片描述
调用了Father类的静态方法,触发Father类初始化,输出结果:
在这里插入图片描述
并没有触发构造器,所以类初始化后执行构造器不是必然的,要根据情况分析。

继承中的初始化典型例子

既然说到继承了,上一个例子:

public class Base {

    Base() {
        preProcess();
    }

    void preProcess() {
    }
}

public class Derived extends Base {

    public String whenAmISet = "set when declared"; 

    @Override 
    void preProcess() {

        whenAmISet = "set in preProcess";

    }    

    public static void main(String[] args) {
        Derived d = new Derived();
        System.out.println(d.whenAmISet);
    }
}

输出的是什么呢?

  1. 执行Derived 类 static main 方法的时候,执行类变量初始化,但是此例中父类和子类都没有类变量,所以此步骤什么都不做,进行实例变量初始化

  2. 执行new Derived()的时候,先调用了父类的构造函数,因为子类的重载,调用了子类的preProcess方法,为实例变量whenAmISet 赋值为"set in preProcess"

  3. 然后执行子类Derived 的构造函数,在构造函数中,有编译器为我们收集生成的实例变量赋值语句,最终,又将实例变量whenAmISet 赋值为"set when declared"

  4. 所以最终的输出是: set when declared

我第一次看这个代码的时候很懵,不太明白为什么父类的构造器调用的是被子类重写了的;原因是:
通过继承相同的父类,初始化子类时,父类会调用不同子类的不同复写方法,从而实现多态性。
对象的多态性还有这么一句描述:子类如果重写父类的方法,通过子类对象调用的一定是子类重写的代码。
我们主函数中: Derived d = new Derived(); 这不就是通过子类对象调用了吗?如果换成new Base(); 自然不会调用被重写的方法,测试一下,修改Base代码:

public class Base {

    Base() {
        preProcess();
    }

    void preProcess() {
        System.out.println("i am Base");
    }
}

在main中调用:
在这里插入图片描述
输出结果自然是:

i am Base

思考:为什么要先执行父类的构造器呢?

因为子类的非静态变量和方法的初始化有可能使用到其父类的属性或方法,所以子类构造默认的属性和方法之后不应该进行赋值,而要跳转到父类的构造方法完成父类对象的构造之后,才来对自己的属性和方法进行初始化。
创建子类对象会调用父类构造方法但不会创建父类对象,只是调用父类构造方法初始化父类成员属性
补充一下构造方法的作用:

  • 为了初始化成员属性,而不是初始化对象,初始化对象是通过new关键字实现的
  • 通过new调用构造方法初始化对象,编译时根据参数签名来检查构造函数,称为静态联编和编译多态
    (参数签名:参数的类型,参数个数和参数顺序)
  • 创建子类对象会调用父类构造方法但不会创建父类对象,只是调用父类构造方法初始化父类成员属性;

思考:为什么对属性和方法初始化之后再执行构造函数?

因为构造函数中的显式部分有可能使用到对象的属性和方法。

典型例子2:

我们再来看一段这样的代码:

class SingleTon {
    private static SingleTon singleTon = new SingleTon();
    public static int count1;
    public static int count2 = 0;
 
    private SingleTon() {
        count1++;
        count2++;
    }
 
    public static SingleTon getInstance() {
        return singleTon;
    }
}
 
public class Test {
    public static void main(String[] args) {
        SingleTon singleTon = SingleTon.getInstance();
        System.out.println("count1=" + singleTon.count1);
        System.out.println("count2=" + singleTon.count2);
    }
}

输出结果:
count1=1
count2=0

分析

1.SingleTon singleTon = SingleTon.getInstance();调用了类的SingleTon调用了类的静态方法,触发类的初始化
2.类加载的时候在准备过程中为类的静态变量分配内存并初始化默认值 singleton=null count1=0,count2=0
3.类初始化,为类的静态变量赋值和执行静态代码快。singleton赋值为new SingleTon()调用类的构造方法
4.调用类的构造方法后count=1;count2=1
5.继续为count1与count2赋值,此时count1没有赋值操作,所有count1为1,但是count2执行赋值操作就变为0

整体例子:

看完上面之后应该对类的加载初始化有一个比较清晰的概念了,这里引用一个大神博客的内容:

public class Demo_Student {  
  
    public static void main(String[] args) {  
        Student s = new Student();  
        s.show();  
    }  
  
}  
  
class Student {  
    private String name = "张三";  
    private int age = 23;  
    public Student() {  
        name = "李四";  
        age = 24;  
    }  
    public void show() {  
        System.out.println("我叫:"+name+",今年"+age+"岁");  
    }  
}

首先,程序运行时,会将Demo_Student加载进内存,随后,其主方法main入栈。紧接着发现了new Student(),所以又将Student加载进内存:

  • 字段信息:存放类中声明的每一个字段的信息,包括字段的名、类型、修饰符。
  • 方法信息:类中声明的每一个方法的信息,包括方法名、返回值类型、参数类型、修饰符、异常、方法的字节码。
    在这里插入图片描述
    然后在栈内存分配一块空间(Student s),声明Student的引用。new Studetn() 在堆内存开辟空间,进行默认初始化和显示初始化。
    在这里插入图片描述
    调用构造方法,系统默认调用。构造方法进栈,对对象进行初始化,初始化完成后,弹栈。此时对象已经创建完毕。将其地址值赋值给变量s
    在这里插入图片描述
    可通过s其地址值找到对应堆内存空间的实体,调用show方法时,show进栈,其内部有个隐藏的this引用,根据该引用找到堆内存实体,并打印相应内容
    在这里插入图片描述
    随后main方法也执行完毕,弹栈,程序执行完毕

总结

Student s = new Student(); 在内存中到底执行了哪些步骤?

1,加载Sutdent.class文件进内存(类加载器)

2,在栈内存为 s 变量申请一个空间

3,在堆内存为Student对象申请空间

4,对类中的成员变量进行默认初始化

5,对类中的成员变量进行显示初始化

6,有构造代码块就先执行构造代码块,如果没有,则省略(此步上文未体现)

7,执行构造方法,通过构造方法对对对象数据进行初始化

8,堆内存中的数据初始化完毕,把内存值复制给 s 变量

总结代码

最后的最后补一个整体流程的例子串通一下:

//父类Animal
class Animal {  
/*8、执行初始化*/  
    private int i = 9;  
    protected int j;  
 
/*7、调用构造方法,创建默认属性和方法,完成后发现自己没有父类*/  
    public Animal() {  
/*9、执行构造方法剩下的内容,结束后回到子类构造函数中*/  
        System.out.println("i = " + i + ", j = " + j);  
        j = 39;  
     }  
 
/*2、初始化根基类的静态对象和静态方法*/  
    private static int x1 = print("static Animal.x1 initialized");  
    static int print(String s) {  
        System.out.println(s);  
        return 47;  
    }  
}  
 
//子类 Dog
public class Dog extends Animal {  
/*10、初始化默认的属性和方法*/ 
    private int k = print("Dog.k initialized");  
 
/*6、开始创建对象,即分配存储空间->创建默认的属性和方法。 
     * 遇到隐式或者显式写出的super()跳转到父类Animal的构造函数。
     * super()要写在构造函数第一行 */  
    public Dog() { 
/*11、初始化结束执行剩下的语句*/
        System.out.println("k = " + k);  
        System.out.println("j = " + j);  
    }  
 
/*3、初始化子类的静态对象静态方法,当然mian函数也是静态方法*/  
    private static int x2 = print("static Dog.x2 initialized");
 
/*1、要执行静态main,首先要加载Dog.class文件,加载过程中发现有父类Animal, 
    *所以也要加载Animal.class文件,直至找到根基类,这里就是Animal*/       
    public static void main(String[] args) {  
 
/*4、前面步骤完成后执行main方法,输出语句*/ 
        System.out.println("Dog constructor"); 
/*5、遇到new Dog(),调用Dog对象的构造函数*/  
        Dog dog = new Dog();   
/*12、运行main函数余下的部分程序*/            
        System.out.println("Main Left"); 
    }  
}

引用链接:https://www.cnblogs.com/wxw7blog/p/7349204.html
https://www.runoob.com/w3cnote/java-init-object-process.html
https://www.cnblogs.com/javaee6/p/3714716.html
https://www.cnblogs.com/jimxz/p/3974939.html

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值