JVM虚拟机(二)类加载器、双亲委派模型、类装载的执行过程

一、类加载器

类装载器负责从文件系统或是网络中加载.class文件,class文件在文件开头有特定的文件标识。把加载后的class类信息存放于方法区,除了类信息之外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射);类装载器只负责class文件的加载,至于class文件是否可以允许,则由执行引擎决定。

1.1 类加载器的分类 

 

  • 启动类加载器:或者叫引导类加载器(BootStrap ClassLoader)。用 C++ 语言编写,嵌套在 JVM 内部,主要用来加载 Java 的核心库。它会去加载 JAVA_HOME/jre/lib 路径下的所有 jar 包。 
  • 扩展类加载器(ExtClassLoader):,它主要加载 JAVA_HOME/jre/lib/ext 路径下的所有 jar 包,其实就是扩展目录。如果用户自己的 jar 包也放在 JAVA_HOME/jre/lib/ext 目录下的话,也会自动使用扩展类加载器去加载里面的类。
  • 应用类加载器(AppClassLoader):它负责加载环境变量 CLASSPATH 下面所有的类,是默认的类加载器。一般来说,Java 应用的类都是由它去完成加载。
  • 自定义类加载器(CustomizeClassLoader):自定义类继承 ClassLoader,实现自定义类加载规则。这个在我们平时的开发中用的并不是很多。

二、双亲委派模型 

2.1 双亲委派模型简介 

双亲委派模型 Parent Delegation Model,这里的 “双亲” 实际指的是父级。加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器再尝试加载该类。 

2.2示例一:加载自己创建的类

举个例子,我们自己编了一个 Student 类: 

  • 按照类加载器的分类,应该是在应用类加载器(AppClassLoader)中加载。
  • 双亲委派的意思就是先不进行加载,它要委托上级加载器进行加载,也就是扩展类加载器(ExtClassLoader)。
  • 扩展类加载器也有上级,所以它会继续向上委托,就找到了启动类加载器(BootStrap ClassLoader)。 
  • 但是我们会发现,Student 类并不在 JAVA_HOME/jre/lib 和 JAVA_HOME/jre/lib/ext 目录下,所以说这两个类加载器并不能加载这个类。
  • 这个时候才会由应用类加载器(AppClassLoader)去加载 Student 类。

 

2.3 示例二:加载JDK原有的类 

java.lang.String 类是我们在 Java 开发过程中经常会用到的一个字符串类,假如我们在当前的代码中用到了 String 类: 

  • 首先会来到应用类加载器(AppClassLoader),它会向上委托。
  • 但是扩展类加载器(ExtClassLoader)也有上级,它也会向上委托。
  • 然后启动类加载器(BootStrap ClassLoader)就会去 lib 目录中找有没有 String 类,找到后就会去加载 String 类,然后将加载好的 String 类返回给 AppClassLoader,让它直接去使用就可以了。

以上就是双亲委派模式,但是面试官还会问:为什么会采用双亲委派机制呢?

 2.4 使用双亲委派模型的原因

  • 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
  • 为了安全,保证类库 API 不会被修改。 

举个例子:比如我们自己定义了一个 String 类,如下所示:

package java.lang;

public class String {
    public static void main(String[] args) {
        System.out.println("demo info");
    }
}

 此时执行 main() 方法,就会出现异常:在类 java.lang.String 中找不到 main 方法。原因是:由于是双亲委派的机制,自己定义的 java.lang.String 类在启动类加载器中并没有得到加载,而是加载了核心 jre 库中有相同名字的类文件,但该类中并没有 main() 方法。

三、类加载的过程 

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

 

3.1 加载 

加载阶段,主要做了以下事情: 

  • 通过类的全名,获取类的二进制数据流;
  • 解析类的二进制数据流为方法区内的数据结构(Java类模型),也就是把类的信息存入方法区;
  • 创建 java.lang.Class 类的实例,表示该类型。作为方法区这个类的各种数据的访问入口。 

为了方便理解,我们来看一下下面这个图:

 比如说现在有一个 Person 类,它被类加载器加载之后就会存入运行时数据区。在运行时数据区中,有两个区域进行存储,当然作用是不一样的:

  • 第一个是方法区/元空间(Metaspace),存储的是 Person 类的信息。比如:Person 类的构造函数、方法、字段等。它都是存储的类的结构。
  • 第二个是堆(Heap),在堆中会开辟一段空间去存储类的 Class 对象。这有什么作用呢?后期当我们去创建对象的时候,比如说创建了 “张三” 和 “李四” 两个对象:(1)这两个对象都是基于 Person 的 Class 去创建的,所以每个对象的对象头都会指向 Person 的 Class 对象。 (2)但是 “张三” 和 “李四” 这些具体类中的数据(比如:方法、构造函数、字段)需要通过 方法区 才能获得。这个时候,Person 的 Class 对象就能找到方法区中 Person 类对应的数据结构,来创建这两个对象。

 

这个就是加载过程中所要做的事了,主要就是先把二进制数据流读入到运行时数据区,在元空间中存储类的信息,在堆中开辟一块空间来存储这个类的 Class 对象,方便后期创建对象时使用。 

3.2 验证 

验证阶段,属于连接的一部分,主要是验证类是否符合 JVM规范,进行安全性检查。检查内容分为四项: 

  • 文件格式验证;
  • 元数据验证;
  • 字节码验证; 

前三项都属于 格式检查,如:文件格式是否错误、语法是否错误、字节码是否合规。 

  • 符号引用验证:Class 文件在其常量池中会通过字符串记录自己将要使用的其他类或者方法,检查它们是否存在。

    比如下面这个使用 javap 命令看到的字节码中对的常量池,方法在引用的时候,都会到常量池中查表翻译,找到对应的类或者方法去执行。所以这里验证的主要是这些方法或者类是否存在,如果不存在就会报类或者方法找不到(ClassNotFoundExcaption、MethodNotFoundException)。这也属于安全校验的一部分。 

3.3 准备 

准备阶段,也属于连接的一部分,主要是为类变量分配内存,并设置类变量初始值。那什么是类变量呢?类变量,也称静态变量,就是类中被 static 修饰的变量。

准备阶段分为三种情况:

  • static 修饰的变量,分配空间的时候在当前的准备阶段完成(设置默认值),真正赋值的时候在初始化阶段才会完成。 

例如:下图中的 b 变量,在当前准备阶段,它只会赋一个默认值,由于 b 是 int 类型,那么默认值是 0。真正把 b 赋值为 10 的时候是在初始化阶段才会完成。

  • static 修饰的变量是 final 的基本类型(int、long等),或者字符串常量,这个时候值已经是确定的,所以在准备阶段就完成了赋值的操作。 

例如:下图中的 c、d 变量,它们是被 static 和 final 同时修饰的基本类型,这种情况就会在准备阶段直接完成赋值。

  • static 修饰的变量是 final 的引用类型(Object等),那么赋值也会在初始化阶段完成。 

例如:下图中的 obj 变量,它是被 static 和 final 同时修饰的 Object,这种情况并不会在当前准备阶段去赋值,而是和没有被 final 修饰的基本类型一样,也是在初始化阶段完成赋值。 

 3.4 解析

解析阶段,也属于连接的一部分,主要是把类中的符号引用转换为直接引用 

什么是符号引用?什么是直接引用呢? 

比如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。我们还是使用之前 javap 解析出来的字节码为例: 

 

左边是 main() 方法的执行指令,右边是常量池,执行的时候我们需要到常量池中查表翻译,这里就会找到当前某一个方法(Methodref 表示方法定义),#4 会引用 #24 和 # 25:

 

可以看到 #24 是一个 Class,#25 是 NameAndType,其中 #24 还引用了 #31,#25 还引用了 #32 和 #33。

其实当前的 #1、#2、……、#33 就是 符号引用,我们真正要去执行的就是根据符号引用找到对应的类,并且找到这个类中对应的方法。如果是直接找到了对应的类或方法的话,就是 直接引用。 

3.5 初始化 

初始化阶段,主要是对类的静态变量,静态代码块执行初始化操作。这里面有两项要求: 

  • 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
  • 如果同时包含多个静态变量或静态代码块,则按照自上而下的顺序依次执行。 

下面我们来演示一遍代码:

public class Application {

    public static void main(String[] args) {
        // 1.首次访问这个类的静态变量或静态方法时
        System.out.println(Animal.num);
        // 2.子类初始化,如果父类还没初始化,会引发父类先初始化
//        System.out.println(Cat.sex);
        // 3.子类访问父类静态变量,只触发父类初始化
//        System.out.println(Cat.num);
    }
}

class Animal {
    static int num = 55;
    static {
        System.out.println("Animal 静态代码块...");
    }
}

class Cat extends Animal {
    static boolean sex = false;
    static {
        System.out.printf("Cat 静态代码块...1");
    }

    static {
        System.out.printf("Cat 静态代码块...2");
    }
}

 测试1: 如果是首次访问这个类的静态变量或静态方法时,就会去初始化。

我们直接 Animal 类的静态变量 num,看下会不会初始化,代码和执行结果如下:

System.out.println(Animal.num);

 

可以看到,Animal 中的静态代码块正常执行了,我们也拿到了静态变量。 

测试2: 子类初始化,如果父类还没初始化,就会引发父类先初始化。

代码中 Cat 是 Animal 的子类,当我们调用了 Cat 的静态变量 sex 的时候,它会去检查当前父类 Animal 有没有初始化, 假如 Animal 没有被初始化,它就会先去初始化父类。代码和执行结果如下:

System.out.println(Cat.sex); 

 

可以看到,先去初始化了父类 Animal 的静态代码块,然后才会执行 Cat 类的静态代码块,最后打印变量值 。

如果同时包含了多个静态代码块,比如 Cat 类,那么它会按照从上到下的顺序去执行。

测试3: 直接用子类去调用父类的静态变量,这时候只会触发父类的初始化,并不会初始化子类。代码和执行结果如下:

 System.out.println(Cat.num);

 

可以看到,当子类调用父类的静态变量时,只初始化了父类的代码块。 

3.6 使用 

有两种情况代表我们使用了这个类: 

  • 当我们调用静态类成员信息(比如:静态字段、静态方法),就代表我们使用了这个类。
  • 使用 new 关键字为这个类创建了对象实例,也代表我们使用了这个类。 

3.7 卸载 

当程序代码执行完毕之后,JVM 就开始销毁创建的 Class 对象了,这个时候就相当于把类删除卸载了。

3.8 小结 

  • 加载: 查找和导入 class 文件。
  • 验证: 保证加载类的准确性。
  • 准备: 为类变量分配内存并设置类变量初始值。
  • 解析: 把类中的符号引用转换为直接引用。
  • 初始化: 对类的静态变量,静态代码块执行初始化操作。
  • 使用: JVM 开始从入口方法开始执行用户的程序代码。
  • 卸载: 当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Aplis

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值