Java类加载及对象初始化分析

1. Java类的初始化顺序

在深入探究前,我们先通过两类常见的问题,先给出结论性的初始化顺序,再探究为什么。

1.1 无父类初始化

对象在class文件加载完毕,以及为各成员在方法区开辟好内存空间之后,就开始初始化过程,对于无父类的情况,类中各成分的初始化顺序为:(静态变量、静态初始化块)>(变量、初始化块)>构造器。

/**
 * 以下示例的输出为:
 *    静态变量
 *    静态初始化块
 *    变量
 *    初始化块
 *    构造器
 */
public class InitialOrderTest {
    /* 静态变量 */
    public static String staticField = "静态变量";
    /* 变量 */
    public String field = "变量";
    /* 静态初始化块 */
    static {
        System.out.println( staticField );
        System.out.println( "静态初始化块" );
    }
    /* 初始化块 */
    {
        System.out.println( field );
        System.out.println( "初始化块" );
    }
    /* 构造器 */
    public InitialOrderTest(){
        System.out.println( "构造器" );
    }


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

总结:先初始化类变量然后赋值,再初始化实例变量然后赋值。

1.2 有父类初始化

在有父类的情况下,初始化的顺序为:

  1. 父类静态代码块、父类静态成员字段(并列优先级,按代码中出现先后顺序顺序执行)(只在第一次加载类时执行);
  2. 子类静态代码块、子类静态成员字段(并列优先级,按代码...)(只在第一次加载类时执行);
  3. 父类普通代码块,父类普通成员字段(并列优先级,按代码...)
  4. 父类构造器
  5. 子类普通代码块,子类普通成员字段(并列优先级,按代码...)
  6. 子类构造器
/**
 *以下示例的输出为:
 *    父类--静态变量
 *    父类--静态初始化块
 *    子类--静态变量
 *    子类--静态初始化块
 *    子类main方法
 *    父类--变量
 *    父类--初始化块
 *    父类--构造器
 *    i=9, j=0
 *    子类--变量
 *    子类--初始化块
 *    子类--构造器
 *    i=9,j=20
 */
class Parent {
    /* 静态变量 */
    public static String p_StaticField = "父类--静态变量";
    /* 变量 */
    public String    p_Field = "父类--变量";
    protected int    i    = 9;
    protected int    j    = 0;
    /* 静态初始化块 */
    static {
        System.out.println( p_StaticField );
        System.out.println( "父类--静态初始化块" );
    }
    /* 初始化块 */
    {
        System.out.println( p_Field );
        System.out.println( "父类--初始化块" );
    }
    /* 构造器 */
    public Parent(){
        System.out.println( "父类--构造器" );
        System.out.println( "i=" + i + ", j=" + j );
        j = 20;
    }
}

public class SubClass extends Parent {
	/* 静态变量 */
    public static String s_StaticField = "子类--静态变量";
    /* 变量 */
    public String s_Field = "子类--变量";
	/* 静态初始化块 */
    static {
        System.out.println( s_StaticField );
        System.out.println( "子类--静态初始化块" );
    }
    /* 初始化块 */
    {
        System.out.println( s_Field );
        System.out.println( "子类--初始化块" );
    }
    /* 构造器 */
    public SubClass(){
        System.out.println( "子类--构造器" );
        System.out.println( "i=" + i + ",j=" + j );
    }

    /* 程序入口 */
    public static void main( String[] args ){
        System.out.println( "子类main方法" );
        new SubClass();
    }
}

总结:

  • 父类优于子类,静态优于非静态,只有在第一次创建对象的时候才会初始化静态块和静态成员。
  • 当父类和子类的静态内容都初始化好后,就可以开始调用静态方法了,进一步说明静态方法的调用不依赖于具体的类。
1.3 两道典型面试题

有了上面的基础之后,让我们来分析两道题目,首先:

class X{
    Y y=new Y();
    public X(){
        System.out.print("X");
    }
}
class Y{
    public Y(){
        System.out.print("Y");
    }
}
public class Z extends X{
    Y y=new Y();
    public Z(){
        System.out.print("Z");
    }
    public static void main(String[] args) {
        new Z();
    }
}

这题的主要困惑点在于子类Z和父类Z有一个成员是同名的,所以会因为子类是否会覆盖父类成员而困扰,但其实即使子类声明了与父类完全一样的成员变量,也不会覆盖掉父类的成员变量。而是在子类实例化时,会同时定义两个成员变量,子类也可以同时访问到这两个成员变量,但父类不能访问到子类的成员变量(父类不知道子类的存在)。

关于这个问题,不是我们这里关注的重点,可以参考如下博客:

解决了这个困扰,我们可以很快给出答案:YXYZ。然后再看后面一道比较难一点的:

public class Base{
    private String baseName = "base";
    public Base(){
        callName();
    }
 
    public void callName(){
        System. out. println(baseName);
    }
 
    static class Sub extends Base{
        private String baseName = "sub";
        public void callName()
        {
            System. out. println (baseName) ;
        }
    }
    public static void main(String[] args){
        Base b = new Sub();
    }
}

这里程序输出的结果是 base 还是 sub 呢?

答案是都不是,结果应该是 null 。为什么?Base b = new Sub(); 它为多态的一种表现形式,声明是 Base ,实现是 Sub 类,理解为 b 编译时表现为 Base 类特性,运行时表现为 Sub 类特性。

当子类覆盖了父类的方法后,意思是父类的方法已经被重写,题中父类初始化调用的方法为子类实现的方法,子类实现的方法中调用的baseName为子类中的私有属性。

参照本节的初始化顺序,此时只运行到步骤4,子类的非静态代码块和初始化步骤还没有到,子类中的baseName还没有被初始化。所以此时baseName为空,所以结果应该是null。

2. 类加载的过程

看了上面的例程,我们不禁要产生疑问:为什么会是这样的一个顺序呢?这就要从类在JVM中的加载过程说起。

2.1 类的生命周期

类从被加载到虚拟机内存中开始,到卸载出内存为止,经历了如下包含七个阶段的生命周期: 类的生命周期

这其中,验证、准备和解析三个部分统称为连接。而加载、验证、准备、初始化和写在这五个阶段的顺序使确定的,类的加载过程必须按照这种顺序按部就班地开始(!!注意只是“开始”,因为这些阶段通常都是相互交叉混合式的进行的,通常会在一个阶段执行的过程中调用或激活另一个阶段)。解析阶段之所以不一定,是因为它在某些情况下可以在 Initialization 之后再进行,这是为了支持Java语言的运行时绑定(动态绑定)

2.2 类加载的过程
  1. 加载: : Java虚拟机把Class类文件加载到内存中,并对Class文件中的数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型的过程。在此阶段,JVM主要完成3件事: - 通过一个类的全限定名来获取定义此类的二进制字节流。 - 将定义类的二进制字节流所代表的静态存储结构转换为方法区的运行时数据结构。 - 在java堆中生成一个代表该类的java.lang.Class对象,作为方法区数据的访问入口。

  2. 验证: : 验证是连接阶段的第一步,其目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的安全,如果验证失败,会抛出java.lang.VerifyError异常。

  3. 准备: : 准备阶段是正式为类变量(静态变量,注意不是实例变量)分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。注意哦!这里进行内存分配(方法区)的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。 - 对于普通非final的类变量,如public static int value = 123; 在准备阶段过后的初始值是0(数据类型的零值),而不是123,而把123赋值给value是在初始化阶段才进行的动作。 - 对于final的类变量,即常量,如public staticfinal int value =123; 在准备阶段过程的初始值直接就是123了,不需要准备为零值。

  4. 解析: : 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 - 符号引用(SymbolicReference):以一组符号来描述所引用的目标,与虚拟机内存布局无关,引用的目标不一定已经被加载到虚拟机内存中。 - 直接引用(DirectReference):可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关,同一个符号引用在不同虚拟机上翻译处理的直接引用不一定相同,如果有了直接引用,则引用的目标对象必须已经被加载到虚拟机内存中。

  5. 初始化: : 初始化是类使用前的最后一个阶段,在初始化阶段java虚拟机真正开始执行类中定义的java程序代码。具体的内容我们在下一段讨论。

  6. 使用: : 当初始化完成之后,java虚拟机就可以执行Class的业务逻辑指令,通过堆中java.lang.Class对象的入口地址,调用方法区的方法逻辑,最后将方法的运算结果通过方法返回地址存放到方法区或堆中。

  7. 卸载: : 当对象不再被使用时,java虚拟机的垃圾收集器将会回收堆中的对象,方法区中不再被使用的Class也要被卸载,否则方法区(Sun HotSpot永久代)会内存溢出。
    Java虚拟机规定只有当加载该类型的类加载器实例为unreachable状态时,当前被加载的类型才被卸载.启动类加载器实例永远为reachable状态,由启动类加载器加载的类型可能永远不会被卸载,类型卸载仅仅是作为一种减少内存使用的性能优化措施存在的,具体和虚拟机实现有关,对开发者来说是透明的.

2.3 初始化阶段

在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。

2.3.1 初始化的时机

对于什么时候开始类加载的第一个过程(即加载),虚拟机规范并没有进行强制约束,交由JVM的具体实现来自由把握。但是对于 Initialization 阶段,虚拟机规范则严格规定了有且只有四种情况必须立即对类进行初始化(而加载、验证准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类还没有初始化过,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new来实例化对象、get或set一个类的静态字段以及调用一个类的静态方法时。
  2. 使用java.lang.reflect包的方法对类进行反射调用(Class.forName(…)),而类还未初始化过时。
  3. 当初始化一个类,而其父类还未初始化过时,初始化其父类。
  4. JVM启动时,用户需要指定一个要执行的主类(包含了main()方法的那个类),JVM会先初始化这个主类。

以上四种场景称为对一个类进行主动引用,除此之外的所有引用类的方式,都不会触发类的初始化,称为被动引用。

2.3.2 类构造器执行时的特点和注意事项

类构造器<clinit>()方法是由编译器自动收集类中所有类变量(静态非final变量)赋值动作和静态初始化块(static{……})中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定。静态初始化块中只能访问到定义在它之前的类变量,定义在它之后的类变量,在前面的静态初始化中可以赋值,但是不能访问。

因为它的产生方式,所以它具有一些特点:

  • 类构造器<clinit>()方法与实例构造器<init>()方法不同,它不需要显式地调用父类构造器方法,虚拟机会保证在调用子类构造器方法之前,父类的构造器<clinit>()方法已经执行完毕。因此,在虚拟机中第一个执行的<clinit>()方法的类肯定是java.lang.Object。
  • 由于父类构造器<clint>方法先于子类构造器执行,因此父类中定义的静态初始化块要先于子类的类变量赋值操作。(解释了示例中的顺序问题)
  • 类构造器<clint>方法对于类和接口并不是必须的,如果一个类中没有静态初始化块,也没有类变量赋值操作,则编译器可以不为该类生成类构造器<clint>方法。
  • 接口中不能使用静态初始化块,但可以有类变量赋值操作,因此接口与类一样都可以生成类构造器<clint>方法。<br/> 接口与类不同的是:
    1. 执行接口的类构造器<clint>方法时不需要先执行父接口的类构造器<clint>方法,只有当父接口中定义的静态变量被使用时,父接口才会被初始化。
    2. 接口的实现类在初始化时同样不会执行接口的类构造器<clint>方法。
  • java虚拟机会保证一个类的<clint>方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,只会有一个线程去执行这个类的<clint>方法,其他线程都需要阻塞等待,直到活动线程执行<clint>方法完毕。

到这里我们就可以完整地解释上面示例中出现那样的顺序的原因了:初始化阶段,当执行完类构造器<clint>方法之后,才会执行实例构造器的<init>方法,实例构造方法同样是按照先父类,后子类,先成员变量,后实例构造方法的顺序执行。

3. 遗留问题

  • 解析阶段如何支持Java的动态绑定?
  • 添加一些被动引用的例子,或者加一些链接
  • 回答接口中为什么不用能使用静态语句块?
  • 补充一下类变量和方法变量的默认值问题

4. 可用的材料

当一个类使用new关键字来创建新的对象的时候,比如Person per = new Person();JVM根据Person()寻找匹配的类,然后找到这个类相匹配的构造方法,这里是无参构造,如果程序中没有给出任何构造方法,则JVM默认会给出一个无参构造。当创建一个对象的时候一定对调用该类的构造方法,构造方法就是为了对对象的数据进行初始化。JVM会对给这个对象分配内存空间,也就是对类的成员变量进行分配内存空间,如果类中在定义成员变量就赋值的话,就按初始值进行运算,如果只是声明没有赋初始值的话,JVM会按照规则自动进行初始化赋值。而成员方法是在对象调用这个方法的时候才会从方法区中加载到栈内存中,用完就立即释放内存空间。

转载于:https://my.oschina.net/huangmc/blog/2414270

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值