Java类加载机制


序言

今天我们聊一聊Java中类加载机制,简单来说,就是程序运行过程中,虚拟机把类加载到内存中,可以给程序使用。
我们先介绍原理,掌握原理之后,我们就应用这个原理,来分析两个具体的例子。

类文件基本结构

我们都知道,程序中的java文件,编译之后,会生成对应的Class文件,Class文件是一组以8字节为单位的二进制流,各数据项按照严格的顺序紧密排列,中间没有任何间隔。
这么说可能有点抽象,我们举个例子:

public class Test {
    public int add(int a, int b) {
        return a + b;
    }
}

经过编译之后得到Test.class文件,然后我们执行下面命令:
hexdump Test.class

image.png

上图是用十六进制来表示Test.class二进制流。每一位排列紧密,都有其含义。
下面,我们介绍一下Class文件中具体包含哪些内容:

  • 魔数:1-4字节,用来确定这个文件是否JVM认可的Class文件,它的值位:0xCAFEBABE
  • 版本号:5-6字节标识次版本号(minor version),7-8字节表示主版本号(major version)。
  • 常量池:常量池中常量的数量不是固定的,主要存放字面量(文本字符串、final常量)和符号引用(类和接口全限定名、字段名称与描述、方法名称与发描述)
  • 访问标志:主要用来识别类和接口的访问信息(是否为public、是否为final、是否为接口、是否为抽象、是否为注解、是否为枚举等)。
  • 类、父类、接口索引:用来确定该类和父类的全限定名,以及描述该类实现了哪些接口。
  • 字段表集合:描述接口或者类中声明的变量、字段。包括类变量和实例变量,不包括方法内的局部变量。
  • 方法表集合:用来描述方法相关信息

知道了Class存储格式细节,那么类是如何加载到JVM中的呢?不急,下面我们会介绍。

扩展:结合上一篇文章JVM内存结构,我们就不难知道,类结构被加载到JVM后存储在JVM方法区中

类加载流程

类的加载是虚拟机通过类的全限定名来获取此类的二进制字节流
我们先看一下,类加载流程图:
[图片上传失败...(image-c9e3f4-1548600350447)]
由图可知,类加载流程有七个步骤,分别是:加载、验证、准备、解析、初始化、使用、卸载。下面依次介绍这几个步骤:

  • 加载:类加载的第一个阶段,JVM将字节码从各个位置(网络、磁盘)转换为二进制字节流加载到内存中,接着在JVM的方法区为这个类创建一个Class对象,这个Class对象是该类各种数据的访问入口。
  • 验证:类加载完之后,JVM会对二进制字节流进行校验,只有符合规范的文件才能被正确执行。校验包括JVM规范校验(如文件是否已0xcafebabe开头,主次版本号是否在JVM处理范围内等)代码逻辑校验(主要是针对代码的数据流和控制流,确保运行该字节码不会出现致命错误。例如方法接受int型参数,却传一个String类型)
  • 准备:这个阶段,JVM为类变量(static修饰,区别成员变量)分配内存并初始化,这里的初始化是指为类变量赋予Java中该数据类型的默认值(如String的默认值是null,int默认值是0),但如果类变量被final修饰,则会直接赋予用户代码中的初始值(这点也比较好理解,final修饰的变量一旦赋值就不能修改,所以只能一开始就直接赋予用户想要的值)。
  • 解析:准备阶段过后,JVM针对类和接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符7类引用进行解析。主要任务是把它在常量池中的符号引用替换为其在内存中的直接引用。(这个阶段堆我们来说几乎透明,了解一下就行了)
  • 初始化:用户编写的Java代码从这个阶段才开始执行。在这个阶段,JVM会根据语句执行顺序对类对象进行初始化,一般来说,JVM遇到5种情况会触发初始化。
    • 遇到new、getstataic、putstatic、invokestatic字节码指令时,如果类没有初始化,就会触发其进行初始化。场景包括通过new实例化对象、读取或设置类的静态字段(final修饰除外)、调用类的静态方法
    • 对类进行反射调用时,如果类没有初始化,则会触发其初始化。
    • 初始化一个类的时候,如果发现其父类还未初始化,则会先触发其父类初始化。
    • 虚拟机启动时,用户指定一个执行的主类(包含main()的那个类),虚拟机会先初始化这个主类。
    • 使用动态语言支持时(jdk1.7开始),如果一个MethodHandle实例最后解析结果REF_getstatic、REF_putstatic、REF_invokestatic的方法句柄,并且这个方法句柄对应的类未初始化,则先触发其初始化。
  • 使用:JVM从入口方法开始执行用户代码。(了解一下就行)
  • 卸载:用户程序执行完毕之后,JVM开始销毁创建的Class对象。最后负责运行的JVM也退出内存。(了解一下就行)

类加载器

类加载器是用来执行类加载动作的角色。

类和类加载器息息相关,判断两个类是否相等,只有在这两个类被同一个类加载器加载的情况下才有意义,否则即使是同一个类,被不同的类加载器加载,他们也不是相等的

类加载器可以分为三类:

  • 启动类加载器:负责加载JAVA_HOME\lib目录下的类库到内存中。
  • 扩展类加载器:负责加载JAVA_HOME\lib\ext目录下的类库到内存中
  • 应用类加载器:负责加载用户类路径上的类库,如果应用程序没有实现自己的类加载器,默认使用这个类加载器去加载程序中的类库。

既然有这么多加载器,那么加载类的时候会选择什么类加载器呢?
着这个时候,需要提到类加载器的双亲委派模型,流程图如下:


image.png

如果一个类加载器收到加载类的请求,它不会立刻去加载,它会先请求父类加载器,每个层次的类加载器都是如此。层层传递,知道最高层的类加载器,只有当父类加载器反馈自己无法加载这个类,才会由当前子类加载器去加载该类。

为什么要这么做呢?这是为了让越基础的类由越高层的类加载器去加载,如Object类,最后都会传递给最高层类加载器去加载。类的相等性,是由类与类加载器共同决定,这样无论在何种类加载器环境下都是同一个类。相反,如果没有双亲委派模型,每个类加载器都会去加载Object,系统中就会出现多个不同Object类,如此一来系统最基础的行为都无法保证了。

举例分析原理

为了巩固上面的类加载原理,下面给出两个例子,供大家分析。

例子一

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

    static Book book = new Book();

    public Book() {
        System.out.println("Book构造方法");
        System.out.println("Book的price="+price+",amount="+amount);
    }

    {
        System.out.println("Book中普通代码块");
    }

    int price = 110;

    static {
        System.out.println("Book中静态代码块");
    }

    public static void staticMethod() {
        System.out.println("Book中静态方法");
        System.out.println("Book amount=" + amount);
    }

    static int amount =  112;
}

给各位同学5分钟,写出这个程序输出的内容。
1.,,2,,,3,,,,4,,,,,5
各位同学有答案了吗,正确的答案如下:

Book中普通代码块
Book构造方法
Book的price=110,amount=0
Book中静态代码块
Book中静态方法
Book amount=112

如果你的答案跟这个一样,恭喜你,你对类加载机制已经有了深刻的认识。
下面,我解释一下,答案为什么是这样的:

  • JVM在准备阶段,会为类变量分配内存并且初始化类变量。此时,变量book初始化为null,变量amount初始化为0。
  • 进入初始化阶段后,main方法是程序的入口,所以Book是我们指定的主类,这时候JVM会初始化Book,即执行类构造器。
  • JVM对Book进行初始化,首先是执行类构造器(按顺序收集类中的静态代码块和静态变量赋值语句就组成类构造器),后执行对象构造器(按顺序收集成员变量赋值语句和普通代码块,最后收集对象构造器,组成对象构造器)。

对于Book类,其类构造器可以表示为:

static Book book = new Book();

static {
  System.out.println("Book中静态代码块");
}

static int amount =  112;

首先执行static Book book = new Book();,这条语句会触发类的实例化,于是JVM执行对象构造器,对象构造器可以表示为:

{
    System.out.println("Book中普通代码块");
}

int price = 110;

public Book() {
    System.out.println("Book构造方法");
    System.out.println("Book的price="+price+",amount="+amount);
}

首先,输出Book中普通代码块,然后给price赋值为110,接着执行对象构造方法,先输出Book构造方法,再输出Book的price=110,amount=0(静态变量amount再准备阶段赋值为0)。
Book对象构造器执行完毕之后,继续执行静态代码块,输出Book中静态代码块,然后给静态变量赋值为112,这时类构造器也执行完毕。
回到入口方法main中,执行staticMethod方法,先输出Book中静态方法,在输出Book amount=112,到这里,整个程序执行完毕。
看了分析之后,有没有一种豁然开朗的感觉呢!!

例子二

class Grandpa {
    static {
        System.out.println("Grandpa静态代码块");
    }

    public Grandpa() {
        System.out.println("我是爷爷");
    }
}

class Father extends Grandpa {
    static {
        System.out.println("Father静态代码块");
    }

    public Father() {
        System.out.println("我是爸爸");
    }

    static int age = 26;
}

class Son extends Father {
    static {
        System.out.println("Son静态代码块");
    }

    public Son() {
        System.out.println("我是儿子");
    }
}

public class SonTest {
    public static void main(String[] args) {
        System.out.println("爸爸的岁数: " + Son.age);
        new Son();
    }
}

理解了第一个例子,相信这个例子就难不倒大家了,直接公布答案吧:

Grandpa静态代码块
Father静态代码块
爸爸的岁数: 26
Son静态代码块
我是爷爷
我是爸爸
我是儿子

解释:程序入口为main方法,首先会初始化SonTest类,而SonTest中并没有内容,所以不用管,直接进入main方法中,调用Son.age,而age是父类中的静态字段,就会直接初始化父类Father,而不会初始化子类(规则:对于静态字段,只有直接定义这个字段的类才会被初始化)。
初始化Father的时候,发现它继承Grandpa,而Grandpa此时也还没有初始化,所以此时先初始化Grandpa。Grandpa类构造器中只有一段静态代码块,会输出Grandpa静态代码块,然后初始化Father构造器,输出Father构造器,接着就会输出main方法中的第一句爸爸的岁数为:26
接着是实例化Son对象,调用Son的对象构造方法,会先调用父类Father对象构造方法,而Father又继承Grandpa,所以先调用Grandpa对象构造方法,所以最后一次输出我是爷爷我是爸爸我是儿子

到这里,相信大家知道怎样分析这类问题。总结一个方法论:

  1. 确定类变量的初始值:在类加载准备阶段,JVM为类变量(静态变量)初始化默认值,如果被final修饰,则会直接初始化用户赋予的值。
  2. 初始化入口方法:进入类加载初始化阶段,JVM会寻找main方法入口,从而初始化main方法所在的类,当需要初始化一个类时,先初始化类构造器,之后初始化对象构造器
  3. 初始化类构造器:JVM按照顺序收集类变量赋值语句,静态代码块,最终组成类构造器,JVM执行这个类构造器。
  4. 初始化对象构造器:JVM按照顺序收集成员变量赋值语句,普通代码块,最后收集对象构造方法,经他们组成对象构造器,最终由JVM执行。

总结

本文先介绍类加载机制的原理,然后举例2个,帮助大家应用原理来分析具体场景,最后总结一套方法论,如果遇到同样的问题,可以使用这套方法论来解决。希望对大家有帮助。

你可能感兴趣的:(Java类加载机制)