JAVA虚拟机 类加载机制理解

前些时间坐车无聊翻手机的时候,无意中看到一篇之前别人写的关于分析JVM类加载机制的文章。其中作者列举了一段很有意思的代码,当时大概看了之后,也是很有兴趣的推测了一下运行结果,在推敲的过程中开始发现一些疑问,所以本身对结果就比较没把握。后来有空也是敲了一遍代码,发现果不其然真实的运行结果与自己想象的结果并不相同,囧~~ 周末大概回忆了一下,大概就是类似下面这样的代码逻辑:

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

    static Test sInstance = new Test();

    static
    {
        System.out.println("1");
    }

    {
        System.out.println("2");
    }

    Test()
    {
        System.out.println("3");
        System.out.println("a="+a+",b="+b);
    }

    public static void staticMethod(){
        System.out.println("4");
    }

    int a = 100;

    static int b = 200;
}

有兴趣的朋友也可以先自己在脑子里想一想以上代码的运行结果,然后去实际验证一下程序的运行结果是否和你推断的一样。如果你能轻松的推断出正确的答案,那么相信你对于这一块的知识点的掌握一定是足够的;而反之,也就可以思考一下自身需要补充的不足了。像我写这篇博客的原因,也是于此发现自己之前对于Java中类的初始化顺序的理解还是有问题的,而我推断的结果出现误差的原因其实主要就是对于JVM类加载机制的理解还是太浅显了。所以也是希望通过本文重新简单的总结一下关于JVM的类加载机制,加强自己的印象和理解。

推断出的错误结果可能有很多种,而对应的导致相应错误的原因自然也不同。以我自己来说,个人最初对于该段程序的运行结果推测如下:

1
4
2
3
a=100,b=200

其实造成这种错误的想法的原因并不难理解,因为通常来说我们对于JAVA当中类的初始化工作的执行顺序都是这样理解的:

  1. 父类的静态域(静态变量/静态代码块)。
  2. 子类的静态域(静态变量/静态代码块)。
  3. 父类的实例域(成员变量/构造代码块)。
  4. 父类的构造器
  5. 子类的实例域(成员变量/构造代码块)。
  6. 子类的构造器

事实上,我们并不能说以上的规则是错误的。但如果只是记忆这种规则,但不去理解究竟是什么样的内部机制衍生出这种初始化顺序,也可以说如果只是知其然但并不知其所以然,那么可能就会导致错误的理解和使用。就比如在这里讨论的用例中,如果只是套用这种初始化的顺序规则,就会发现自己很难确定该段程序究竟会输出什么样的结果。所以,这里我们的目的自然就是更深入一点,试着去了解:在JAVA当中,一个类从创建到消亡,究竟会走过怎么样的生命周期呢?而又究竟是什么样的内部机制决定着它们的走势?


回归一下本质的话,其实当我们在代码中需要使用到某个类的时候,无非就是两种情况:使用它的静态成员或是使用它的实例成员。例如:

public class MyClass {

    private void memberMethod(){

    }

    private static void staticMethod(){

    }

    public static void main(String[] args) {
        // 1.
        MyClass myClass = new MyClass();
        myClass.memberMethod();
        // 2. 
        staticMethod();
    }
}

那么我们有没有想过,就比如当我们试着调用类的某个方法时,虚拟机内部究竟是做了什么样的工作从而确保其正确执行的呢?分析一下来说,首先我们日常所做的编程工作 本质其实就是根据不同的业务需求编写对应的JAVA代码,编写好的代码最终被保存在一份对应的.java文件当中。问题在于:.java文件中的代码格式显然是面向我们编写者的,所以其实计算机显然是无法直接解析和执行其中的代码的。正是因为这个原因,所以当我们编写完成代码后,如果想要执行它,则还需要将其进行编译。编译通过后,才会得到一份能够被虚拟机解析和执行的.class文件。简单来说,这也才是我们本文中理解上的“类”的概念。

那么,其实我们可以将一个Java项目理解为现实生活中我们的一个工具箱,项目中不同的类就是工具箱内各种不同的工具。所以假设我现在想要使用“锤子”,首先应该怎么做呢?显然就是现在工具箱中找到正确的位置,将锤子从给取出来。那么对应来说,比如我们想使用这里的MyClass类,JVM首先需要做的工作就是通过相应的途径获取到这个类(通常来说就是查找到对应的.class文件进行获取)。于是,也就涉及到了我们本文中试图理解的的类加载机制。而这个所谓的类加载机制究竟是怎么一回事呢?我们接着进行分析。

显然首先我们需要理解的就是在什么样的情况下,一个类才会被进行装载?继续以我们工具箱的例子来说,如果我们目前只是需要使用锤子,那么显然我们没必要将工具箱所有的工具都给倒腾出来,这显然是一种浪费的行为。而对应于JVM也一样,一个成熟的项目可能会含有成千上万个类,如果程序开始运行,就将所有的类进行加载,那么显然这种内存开销和时间消费都是不能接受的。所以,就像我们需要使用锤子,就拿锤子一样;JVM会在某个类需要被使用的时候,对其进行加载。而如果更具体一点来说,这个所谓的“需要被使用”就是指:当一个类的静态成员被访问的时候。这个应该很好理解:

public class MyClass {

    ... 

    private static void staticMethod(){

    }

    private static int a = 1;

    public static void main(String[] args) {
        // 1.
        System.out.println(a);
        // 2. 
        staticMethod();
    }
}

这时我们就是在访问类的静态成员,当然,你可能会说更多的时候,其实我们还是访问类的实例成员的情况更多一些:

public class MyClass {

    private void memberMethod(){

    }

    ...

    private int b = 2;

    public static void main(String[] args) {
        MyClass myClass = new MyClass();
        // 1.
        System.out.println(myClass.b);
        // 2. 
        myClass.memberMethod();
    }
}

但显然我们要明白的是:当我们需要访问一个类的实例域的时候,前提则是构建该类的实例对象。构建类的实例对象归根结底往往就离不开类的构造器。而虽然通常我们在定义构造器的时候并没有显式的将其定义为static,但构造器默认的就被设定为静态成员。所以其实我们通过构造器创建类的对象的时候,其实也是在访问类的静态成员。所以更加通俗一点来说,当我们访问一个类的静态变量,静态方法,或者构建类的对象的时候,都会触发该类的加载工作。


由此我们应该已经清楚了在什么样的条件下,类装载器ClassLoader就会开始类的加载工作。所以我们接下来关心的,自然就是在虚拟机当中,这个加载工作到底都做了什么?简单来说,通常JAVA中一个类的生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。

简单分析一下来说,由加载 → 初始化的这个阶段,其实就是从一个类从被加载到程序内存当中开始,到最后完成相应的初始化赋值工作的过程。当这个阶段的工作完成,我们就能够对类进行使用了。对应的,卸载则是指类在使用完成后,垃圾处理机制对无用的类进行垃圾回收,从而将其从内存中清理出去的工作。所以,对应于我们本文开头提出的程序代码来说,这里我们关注的重点显然就是由加载 → 初始化的这一阶段的工作,这其实也就是通常我们所说的Java当中的类加载机制。接着,我们就来看看这一过程中,每个步骤都做了哪些对应的工作。


加载

这是类加载过程中的第一个阶段,在这个阶段中会在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的入口。简单来说,这里做的工作就是:首先通过类的全限定名到相应的路径下查找到对应的.class文件获取到对应的二进制字节流(但这也不是绝对的,也有其他方式可以获取,比如:网络、其他文件、动态生成等等);然后将读取到的数据从静态存储结构的形式转化为方法区的运行时数据结构,转而在内存中生成一个代表这个类的java.lang.Class对象。

(注:java.lang.Class对象我们应该不会陌生,它又被称为class of classes。通常我们通过类字面常量”.class”或者”Class.forName()”都可以获取到这个对象。如果我们将平时使用的类的对象视为钞票,那么这个class对象就可以视为打印钞票的模板。由此来说,其实这个阶段的工作的意义也就更好理解了:比如我们想要创建某个类的对象,那么虚拟机究竟如何才能完成呢?这就好比我们想要印钞自然先需要拿到雕版一样,一个类当中包括类名,访问权限,其内部定义的字段,方法等信息都保存在编译后的字节码文件当中。所以当我们想要在程序中使用它们时,自然就需要先将这些信息读取进内存才行。而这些信息在字节码文件中的结构遵循某种数据格式,当它们被读取进内存后,虚拟机则将这些数据重新转化为自身认可的运行时的数据结构)

验证

这一阶段就不过多描述了,其主要目的是确保Class文件的字节流中包含的信息是否符合当前虚拟机的要求,且不会危害虚拟机自身的安全。

准备

这个阶段所做的工作其实就是正式为类或者接口的静态字段分配内存,并使用 默认值 初始化这些字段,而这些静态字段所使用的内存都将在方法区中进行分配。需要注意的是这时候进行内存分配的仅包括类或者接口的静态字段,但不包括类的成员(实例)字段。成员字段的内存分配工作将会在对象实例化时进行,并且我们都知道为它们分配的内存空间也不再是方法区,而是位于堆当中。另外,我们说了在这个阶段中,除了为类变量分配内存空间之外,还会为其设置一个初始值。而在这里会有一个差异点是需要我们记住的,举例来说:

public class Test{
    public static int num = 10;
}

就比如这里的静态变量num在准备阶段后,值就将会被初始化为默认值0,而不是10。但是对于被final修饰的静态变量,也就是通常所谓的静态常量来说,情况则有所不同:

public class Test{
    public static final int num = 10;
}

在这里,经过准备阶段过后,num的值将被设置为我们指定的10,而不再是被初始化为0 。那么,为什么会造成这种差异呢?我们先来看看这两种不同的方式编译后的.class文件会体现什么差异?

前后两图分别对应此前使用静态变量和静态常量的字节码文件,我们可以从中看到两者的一些差异。而其中最关键的是,可以看到用final修饰num过后,字节码文件中对于num的描述多了一个ConstantValue的字段,且这个常量值作为编译期常量 还将被写入到字节码文件的编译时常量池当中。那么书归正传,虚拟机在 准备阶段 面对这种情况,即当类的静态字段的属性含有ConstantValue时,会在准备阶段初始化为指定的值,而不再是初始化为一个默认的值。

(P.S:虽然在这里我们使用的是静态常量,但实际上不使用static而只被final修饰的成员常量其实也会带有ConstantValue属性。所以在字节码文件的常量池中仍然存储了相应的常量。那么,我们说当字节码文件被装载进方法区时,其常量池中的常量也会进入内存。于是这就很容易让人困惑,因为这样的话是不是就意味着即使我们只是访问类中的某个静态成员,但是该成员常量却依然会被分配内存呢?针对这个疑问也是在网上看了很久,但是都没看到说到点子上的。但是在《JAVA虚拟机规范(SE7版本)》一书中提到:如果 field_info 结构表示的非静态字段包含了 ConstantValue 属性,那么这个属性必须被虚拟机所忽略。所有 Java 虚拟机实现必须能够识别 ConstantValue 属性。由此来说,我们可以推测出非静态的final字段,在类的装载阶段应当是会被忽略的,它的内存分配及初始化等工作应当还是随着对象的实例化在堆中进行)

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。仅仅通过文字描述,我们可能不太好理解所谓的符号引用和直接引用究竟是啥意思。其实很简单,前面我们也截图了部分.class文件的信息,其中Constant Pool当中的内容就是这里所说的常量池,那么这里面哪些部分是所谓的符号引用呢?

  • CONSTANT_Class_info
  • CONSTANT_InterfaceMethodref_info
  • CONSTANT_Fieldref_info
  • CONSTANT_Methodref_info
  • CONSTANT_NameAndType_info

比如常量池中以上类型的常量都是指所谓的符号引用。而举例来说,比如之前我们的第一幅class文件的截图中,因为变量num在编译时,编译器并不能确定它在进入运行期被写入到内存后,具体的内存地址。所以编译器只能用Filedref以及NameAndType这样的符号引用来标示这个变量。但是当类开始装载之后,因为我们已经知道在准备阶段过后,类的静态字段就会在方法区内被分配内存。所以这个时候我们已经能够得知其在内存中的实际地址,所以自然也就应该用指向实际内存地址的直接引用来替换掉符号引用了。

初始化

这是类加载过程的最后一步,到了初始化阶段,才真正开始执行类中定义的java程序代码。这是什么意思呢?前面说到在准备阶段,变量已经经历过一次系统要求的初始值。但是到了初始化阶段,JVM才会真正根据我们实际指定的计划去初始化类变量和其他资源。而这个过程实际就是执行字节码文件中<clinit>方法的过程。

所谓的 <clinit>方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块static{}中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态代码块只能访问到定义在静态代码块之前的变量,定义在它之后的变量,在前面的静态代码块可以赋值,但是不能访问。当然, 如果一个类中没有对静态变量赋值也没有静态代码块,那么编译器可以不为这个类生成<clinit>方法。

另外,我们需要明白的是,虚拟机同样会保证<clinit>方法执行之前,父类的<clinit>方法已经执行完毕。这也是为什么前面说到的父类的静态域的初始化工作会在子类的静态初始化工作之前的原因。但是,是不是任何情况下,初始化这一阶段都会得到执行呢?其实并不是的,以下等情况就不会执行初始化阶段的工作:

  • 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
public class Test {
    public static void main(String[] args) {
        System.out.println(Son.num);
    }

}

class Parent {
    public static int num = 10;
}

class Son extends Parent {
}

在如上代码中,就只有Parent会执行初始化阶段的工作,Son则不会。也就是说,如果这个时候子类Son当中还存在静态变量,那么它的值则仍然会是准备阶段的初始的默认值;如果存在静态代码块,那么静态代码块中的代码并不会执行。

  • 定义对象数组,不会触发该类的初始化。
public class Test {

    public static void main(String[] args) {
        Parent [] parents = new Parent[10];
    }

}


class Parent{
   ...
}
  • 对于访问类中的编译时常量,不会触发类的初始化。
public class Test {

    public static void main(String[] args) {
        System.out.println(Parent.a); // 会触发初始化
        System.out.println(Parent.b); // 不会触发初始化
    }

}


class Parent{
    public static int a = 10;
    public static final int b = 10;

    static{
        System.out.println("init");
    }
}

这里需要注意的就是,在上述代码中不会触发类的初始化的情况仅仅是针对于编译时常量。但如果是访问运行时常量,则仍然会触发类的初始化。其原因不难理解,这是因为我们前面说到,编译时常量在准备阶段就可以被设定为正确的值,所以不用再进行初始化阶段的工作,我们仍然能正确的访问到这个静态字段。但是反过来,运行时常量则意味着该常量的值只有等到程序真正进入运行期后才能确定,所以仍然需要进行初始化才能确定。比如:

public class Test
{
    public static void main(String[] args)
    {
        System.out.println(RuntimeConstant.NUM);
    }


}

class RuntimeConstant{
    public static final int NUM = new Random().nextInt(100);

    static{
        System.out.println("static init");
    }
}

就像这里,虽然NUM是一个常量,但因为它的值需要通过调用Random类的实例方法nextInt来获取,所以在编译期是无法确定的,只能等到程序进入运行期后被最终确定。所以,在这种情况下,仍然会导致RuntimeConstant类的初始化。

  • 通过类名获取Class对象,不会触发类的初始化。
public class Test {

    public static void main(String[] args) {
        System.out.println(Parent.class.getName());
    }

}


class Parent{
    ...
}
  • 通过Class.forName加载指定类时,如果明确指定参数initialize为false时,也不会触发类初始化。
public class Test {

    public static void main(String[] args) {
        Class<Parent> clazz = null;

        try {
            clazz = (Class<Parent>) Class.forName("Parent"); // 会触发初始化
            clazz = (Class<Parent>) Class.forName("Parent", false,Test.class.getClassLoader());  // 不会触发初始化
        } catch (ClassNotFoundException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
        System.out.println(clazz.getName());
    }

}


class Parent{
    ...
}

OK,相信到了这里,我们应该都已经了解了一个类从被类装载器加载到内存当中至初始化完毕,到最终能够被我们使用,都会经历哪些阶段的工作了。而额外值得一提的就是,前面我们已经说到通常一个类被加载的过程,就是由加载 → 验证 → 准备 → 解析 → 初始化的过程。这其中,加载、验证、准备、初始化的顺序是固定的,但解析则存在一定不确定性,它在某些情况下可以在初始化阶段之后再开始。而原因我们也不难猜到,就是为了支持Java语言的运行时绑定。举例来说:

public class Test {

    private static Parent p = new Parent();
    private static Parent p1;

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

    public static void initP(Parent parent){
        p1 = parent;
    }
}


class Parent{

}

class Son extends Parent{}

如上代码中,静态变量p和p1,它们解析阶段的工作都将在初始化阶段之后。p1可能相对更好理解,因为在编译期我们没有为静态变量p明确指定任何值,只有当程序进入运行期调用initP方法过后,变量p才会被真正的初始化为某个对象,所以显然在这之前也无法完成由符号引用转变为直接引用的解析工作。而对于p可能更容易造成疑惑,那就是之前的例子当中为什么静态变量num就是按照正常的顺序完成装载的呢?

答案其实不难得出:显然关键就在于它们的数据类型是基础数据类型int和对象类型的区别。对于int这种基础数据类型来说,因为内存中需要存放的实际本身就是它所对应的值。所以对于基础数据类型的静态变量来说,前面我们也说到它们本身就是被写入到方法区中存放,所以在准备阶段之后,我们已经能够得到它们在内存中对应的的直接引用,所以自然就能马上接着进行解析阶段的工作。

而对于对象来说,我们都知道对象是在程序运行期在堆内存中分配内存。所以对于对象类型的静态变量来说,在准备阶段虽然说是将作为变量的对象引用在方法区中分配了内存,但是因为其指向的对象还没有实例化,也就意味着内存中目前根本没有相关的对象,所以在准备阶段过后方法区其实仍然存放的是对应的符号引用。只有当初始化阶段结束后,其赋值的对象已经实例化完成,在堆中分配了内存空间之后,才有办法替换为对应的直接引用。所以这也就是为什么,这时候解析阶段的工作只能在初始化阶段之后才能够进行的原因。


案例解析

好了,有了以上基础,现在我们回到文章开头提出的那段程序当中。重新来分析一下,其中的代码究竟会如何运行?其实只要是对Java类的初始化工作顺序有一定了解,那么这段代码中容易让人出错的地方其实就是对于sInstance的初始化赋值工作。因为通常来讲,我们对于类的初始化顺序的认知,简单概括来说都是:静态域>实例域>构造器。但是这段代码中的特殊之处就在于,静态变量sInstance它的初始化赋值工作又涉及到它本身的构造器,也就是说出现了一个静态域与构造器的初始化嵌套。所以,如果我们对类的加载机制不了解的话,就确定不了它们这时究竟会按照什么顺序执行。

下面我们就一步一步来分析一下这个东西。总体来说,其实对于类的初始化我们其实可以理解为分为两个部分,即静态初始化和实例初始化,对这两部分的初始化工作其实我们要避免一些误区。在这其中,静态初始化其实就是我们本文中一直在分析的东西,当我们访问一个类的静态成员的时候,对于类的加载的工作实际上就是在做类的静态初始化工作。而需要明白的是,如果只是访问类的静态成员,那么在这期间 类的实例成员是并不会进行初始化工作的。而实例成员在什么时候会进行初始化呢?答案就是当我们试图获取一个类的实例对象的时候,最常见的就是我们试图通过new调用构造器实例化一个类的对象的时候。

但是,前面我们说到类的构造器本质上其实也是类的静态成员,所以它也可能会导致类的静态初始化。但就像我之前说的,我们需要避免的误区是,当我们试图去获取一个类的实例对象的时候,静态初始化的工作并不是绝对的!因为有了本文之前对于类加载机制的分析,我们可以知道,类的装载工作只会在该类还从未被装载过的时候执行一次,所以类的静态初始化工作也只会在这时执行唯一 一次。那么也就是说,如果某个类已经进行过装载,则即使再次实例化该类的对象,也不会再执行静态初始化了。看如下代码:

public class Test {

    {
        System.out.println("instance init");
    }

    static{
        System.out.println("static init");
    }

    public static void main(String[] args) {
        Test t = new Test();
        System.out.println("xxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
        Test t1 = new Test();
    }

}

这段代码的运行结果为,由此也可以验证我们的推断:

static init
instance init
xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
instance init

但是相信这时我们可能会说,这似乎还是没有完全解释我们之前的疑问。没错,这也就是我们需要避免的另一个误区:那就是类的实例初始化工作并不是说绝对需要在静态初始化之后才能进行。所以,现在让我们重新来分析一下文初的代码的执行顺序到底是如何的:

  • 首先,因为静态方法main方法的调用,显然类装载器就会开始Test类的加载工作,即静态初始化工作将开始进行。

  • 接着,在准备阶段过后,静态成员“sInstance”与“b”就被分配了内存空间,并且分别被初始化为null和0。

  • 然后,当最终进入到初始化阶段后:按照定义的先后顺序,于是将首先执行“sInstance”的初始化工作。
    所以Test类的构造器就会被调用。而其实关键也就在这里,因为构造器的调用,意味着Test类的实例初始化将开始。

  • 而前面也说到,我们需要避免的一个误区就是实例初始化并不是必须在静态初始化之后才能进行。
    同时,虽然访问构造器也可能触发类的加载,但显然此时该类已经开始了一次加载工作,所以将直接开始实例初始化的工作。

  • 而对于实例化初始化来说,因为首先将按照定义的先后顺序执行成员变量的初始化赋值和构造代码块。
    所以,首先构造代码块将执行,于是输出“2”。接着,之后的成员变量“a”将执行初始化赋值工作,值被设置为100;

  • 当成员变量和构造代码块初始化执行完毕后,就轮到了类构造器的执行,由此将首先输出3,然后是a=100;

  • 不同的是,在输出“b”的值得时候,显然此时静态变量“b”还没来得及完成初始化工作。
    所以,到目前为止,它的值仍然还是在准备阶段时初始化的值 0,所以会接着输出输出b=0;

  • 由此也完成了对象的实例初始化,“sInstance”也将进入解析阶段,将符号引用替换为指向堆内存中Test对象的直接引用。

  • 接着,按照顺序,继续执行静态代码块,输出1。然后静态变量“b”也将完成它的初始化,被赋值为200。
    到此所有的初始化工作都已完成,类便正式进入了“使用”阶段,由此staticMethod得以执行,输出4。
    (并且,我们也不难想到,如果在该方法内再次试图打印“b”的值,显然就会输出200了。)

也就是说,经过分析,正确来说这段程序的运行结果应该如下,而实际运行程序加以验证,也可以发现结果完全吻合:

2
3
a=100,b=0
1
4

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值