开门见山

首先引入一道面试题

class Single {
    private static Single single = new Single();
    public static int count1;
    public static int count2 = 0;
    private Single() {
        count1++;
        count2++;
    }
    public static Single getInstance() {
        return single;
    }
}
public class Test {
    public static void main(String[] args) {
        Single single = Single.getInstance();
        System.out.println("count1=" + single.count1);
        System.out.println("count2=" + single.count2);
    }
}
复制代码

错误答案:count1=1;count2=1 正确答案:count1=1;count2=0

为神马?为神马?这要从java的类加载时机说起。

本来是准备把分析结果写在最下面的但是怕大家没有耐心看到最后我这边先大概分析下,如果看不懂下面的分析。建议大家能看到最后,文章不算长。

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

类的加载时机

类从被加载到虚拟机内存中开始,直到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)。

其中加载、验证、准备、初始化和卸载五个步骤的顺序都是确定的,解析阶段在某些情况下有可能发生在初始化之后,这是为了支持 Java 语言的运行期绑定的特性。

 

何时开始类的初始化

什么情况下需要开始类加载过程的第一个阶段:"加载"。虚拟机规范中并没强行约束,这点可以交给虚拟机的的具体实现自由把握,但是对于初始化阶段虚拟机规范是严格规定了如下几种情况,如果类未初始化会对类进行初始化。

  1. 创建类的实例
  2. 访问类的静态变量(除常量【被final修辞的静态变量】原因:常量一种特殊的变量,因为编译器把他们当作值(value)而不是域(field)来对待。如果你的代码中用到了常变量(constant variable),编译器并不会生成字节码来从对象中载入域的值,而是直接把这个值插入到字节码中。这是一种很有用的优化,但是如果你需要改变final域的值那么每一块用到那个域的代码都需要重新编译。
  3. 访问类的静态方法
  4. 反射如(Class.forName("my.xyz.Test"))
  5. 当初始化一个类时,发现其父类还未初始化,则先出发父类的初始化
  6. 虚拟机启动时,定义了main()方法的那个类先初始化
  • 主动引用:上面这些种行为称为对一个类的的主动引用,会触发类的初始化
  • 被动引用:除上面五种主动引用之外,其他引用类的方式都不会触发类的初始化,称为类的被动引用

接口的加载过程与类的加载过程稍有不同。接口中不能使用static{}块。当一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有真正在使用到父接口时(例如引用接口中定义的常量)才会初始化。

被动引用例子

被动引用示例一

对于静态字段,只有直接定义这个字段的类会被初始化,如果是通过子类引用父类的字段,父类会被初始化,子类不一定会被初始化,子类会不会被初始化 JVM 虚拟机规范并没有明确规定,取决于虚拟机的具体实现

public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 1;
}
public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

public class Demo {
    public static void main(String[] args){
        System.out.println("The value is " + Subclass.value);
    }
}
复制代码

上面代码运行之后输出结果如下所示

SuperClass init!
The value is 24
复制代码

被动引用示例二

public class SubClass {
    static {
        System.out.println("SubClass init!");
    }
}
public class Demo {
    public static void main(String[] args){
        SubClass[] subClassArray = new SubClass[10];
    }
}
复制代码

上面代码运行之后,并不会输出 "SubClass init!",因为在上面Demo#main()方法中,并没有初始化SubClass类,而是初始化了一个SubClass[]数组类,SubClass[]数组类代表了一个元素类型为SubClass的一维数组,继承自Object类,由newarray字节码创建。

被动引用示例三

public class Constant {
    static {
        System.out.println("Constant init!");
    }
    public static final String VALUE = "Hello World!";
}
public class Demo {
    public static void main(String[] args){
        System.out.println(Constant.VALUE);
    }
}
复制代码

上面代码运行之后也并不会输出"Constant init!",因为这涉及到一个概念 ---- “常量传播优化”。虽然在代码中Demo类引用了Constant类中的常量VALUE,但是在编译阶段,会将VALUE的实际值"Hello World!"放到Demo类中的常量池中,Demo类每次使用"Hello World!"常量的时候都会从自己的常量池中去找。Demo类不会持有Constant类的符号引用,所以Constant类也并不会被初始化。

类的加载过程

加载

在加载阶段有三个步骤:

  1. 通过一个类的全限定名获取定义此类的二进制字节流
  2. 将二进制字节流所代表的静态存储结构转换为方法区中的运行时数据结构
  3. 在内存中生成一个代表此类的java.lang.Class的对象,作为方法区这些数据的访问入口 在这个阶段,有两点需要注意:
  4. 并没有规定从哪里获取二进制字节流。我们可以从.class静态存储文件中获取,也可以从zip、jar等包中读取,可以从数据库中读取,也可以从网络中获取,甚至我们自己可以在运行时自动生成。
  5. 在内存中实例化一个代表此类的java.lang.Class对象之后,并没有规定此Class对象是方法Java堆中的,有些虚拟机就会将Class对象放到方法区中,比如HotSpot

验证

验证是连接阶段的第一个步骤,验证的目的是为了确保.class文件中的字节流所包含的信息是符合当前虚拟机的要求,并且不会危害到虚拟机自身的安全的。

Java语言本身是相对安全的语言,使用Java编码是无法做到如访问数组边界以外的数据、将一个对象转型为它并未实现的类型等,如果这样做了,编译器将拒绝编译。但是,Class文件并不一定是由Java源码编译而来,可以使用任何途径,包括用十六进制编辑器(如UltraEdit)直接编写。如果直接编写了有害的“代码”(字节流),而虚拟机在加载该Class时不进行检查的话,就有可能危害到虚拟机或程序的安全。

不同的虚拟机,对类验证的实现可能有所不同,但大致都会完成下面四个阶段的验证:文件格式验证、元数据验证、字节码验证和符号引用验证。

  1. 文件格式验证,是要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。如验证魔数是否0xCAFEBABE;主、次版本号是否正在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型……该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区中,经过这个阶段的验证后,字节流才会进入内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构进行的。
  2. 元数据验证,是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。可能包括的验证如:这个类是否有父类;这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法……
  3. 字节码验证,主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。
  4. 符号引用验证,发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在“解析阶段”中发生。验证符号引用中通过字符串描述的权限定名是否能找到对应的类;在指定类中是否存在符合方法字段的描述符及简单名称所描述的方法和字段;符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问

验证阶段对于虚拟机的类加载机制来说,不一定是必要的阶段。如果所运行的全部代码确认是安全的,可以使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载时间。

准备

准备阶段是为类的静态变量分配内存并将其初始化为默认值,这些内存都将在方法区中进行分配。准备阶段不分配类中的实例变量的内存,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

有三点需要注意:

  1. 在方法区中分配内存的只有类变量(被static修饰的变量),而不包括实例变量,实例变量将会跟随着对象在 Java 堆中为其分配内存
  2. 初始化类变量的时候,是将类变量初始化为其类型对应的0值,比如有如下类变量,在准备阶段完成之后val的值是0而不是 123,为 val复制为123,是在后面要讲的初始化阶段之后
 public static int val=123;//在准备阶段value初始值为0 。在初始化阶段才会变为123 。
复制代码
  1. 对于常量,其对应的值会在编译阶段就存储在字段表的ConstantValue属性当中,所以在准备阶段结束之后,常量的值就是ConstantValue所指定的值了,比如如下,在准备阶段结束之后,val的值就是123了。
public static final int val = 123;
复制代码

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。 直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定已经在内存中存在。

初始化

类的初始化阶段才是真正开始执行类中定义的 Java 程序代码。初始化说白了就是调用类构造器<clinit>()的过程,在类的构造器中会为类变量初始化定义的值,会执行静态代码块中的内容。下面将介绍几点和开发者关系较为紧密的注意点

  1. 类构造器<clinit>()是由编译器自动收集类中出现的类变量、静态代码块中的语句合并产生的,收集的顺序是在源文件中出现的顺序决定的,静态代码块可以访问出现在静态代码块之前的类变量,出现的静态代码块之后的类变量,只可以赋值,但是不能访问,比如如下代码
public class Demo {
    private static String before = "before";
    static {
            after = "after";                    // 赋值合法
            System.out.println(before);         // 访问合法,因为出现在 static{} 之前
            System.out.println(after);          // 访问不合法,因为出现在 static{} 之后
    }
    private static String after;
}
复制代码
  1. <clinit>()类构造器和<init>()实例构造器不同,类构造器不需要显示的父类的类构造,在子类的类构造器调用之前,会自动的调用父类的类构造器。因此虚拟机中第一个被调用的<clinit>()方法是 java.lang.Object的类构造器
  2. 由于父类的类构造器优先于子类的类构造器执行,所以父类中的static{}代码块也优先于子类的static{}执行
  3. 类构造器<clinit>()对于类来说并不是必需的,如果一个类中没有类变量,也没有static{},那这个类不会有类构造器<clinit>()
  4. 接口中不能有static{},但是接口中也可以有类变量,所以接口中也可以有类构造器 <clinit>{},但是接口的类构造器和类的类构造器有所不同,接口在调用类构造器的时候,如果不需要,不用调用父接口的类构造器,除非用到了父接口中的类变量,接口的实现类在初始化的时候也不会调用接口的类构造器
  5. 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的类构造器<clinit>(),其他线程会被阻塞,直到活动线程执行完类构造器<clinit>()方法

结束语

看到这里不容易了,大家应该都理解类加载的流程了吧,希望以后遇到这样的面试题能想起这篇文章