JVM系列-虚拟机类加载机制

前言

虚拟机把描述的类的数据从 Class 文件加载进内存里面,同时对数据进行验证、解析、以及初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。

对类的类型进行加载、链接、和初始化的过程,是发生在程序的运行期间完成的,会使得类在加载时稍微增加一些额外的开销,但是这样使得 Java 这种语言具有可动态扩展的语言特性:

  • 如编写一个面向接口的应用程序,可以等到运行时在指定其实际的实现类;

  • 如用户可以通过 Java 预定义或者自定义的类加载器,在程序运行时在其他地方加载二进制流作为程序的一部分;

类从加载进虚拟机内存,到被虚拟机卸载的生命周期:

上图,类的生命周期里面的加载、验证、准备、以及初始化的顺序时确定的,而解析动作则在某些情况下,有可能在初始化之后才开始。这是为了支持 Java 语言运行时绑定。

类的生命周期各个阶段之间时互相交叉混合式进行的,通常在一个阶段调用、激活另外一个阶段。

类的生命周期

# 加载

虚拟机规范没有进行强制约束,交给虚拟机的具体实现来自行把握。在类加载阶段,虚拟机需要完成以下 3 件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流;

  • 将这个字节流所代表的静态结构转化为方法区的运行时的数据结构;

  • 在内存中生成一个 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口;

虚拟机规范的这三点并没有很具体,例如没有具体说到类的二进制字节流可以从哪里获取、以及如何获取,提供了很大的灵活性:

  • 从 ZIP 包中获取,这最终成为日后 JAR、EAR、WAR格式的基础;

  • 从网络中获取,这种场景场景最典型的应用就是 Applet。

  • 运行时计算生成,这种场景使用最多的就是动态代理技术,在 java.lang.reflect

  • Proxy 中,就是用了 ProxyGenerator.generateProxyClass 来为特定接口生成形式为 *$Proxy 的代理类的二进制字节流。

  • 由其他文件生成,典型场景是 JSP 应用,即由 JSP 文件生成对应的 Class 类。

  • 从数据库中读取,这种场景相对少见些。

非数组类的加载(即在加载的阶段获取二进制字节流的动作)的方式比较灵活,可以通过虚拟机预设的类加载器去加载,也可以通过自定义的类加载器去获取(通过重写类加载器的 loadClass() 方法)。

对于数组,因为数组不是通过类加载的方式创建的,它是直接通过虚拟机直接创建的,但数组的元素还是通过类加载器去创建的,一个数组类遵循以下规则:

  • 如果数组的组件类型(ComponentType,指的是数组去掉一个维度的类型)是应引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型。

  • 如果数组的组件类型不是引用类型(如 int[] 数组),Java 虚拟机将会把数组标识为引导类加载器关联。

  • 数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那么数组类的可见性将默认为 public。

# 验证

验证阶段的主要的作用是确保 Class 文件的字节码中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证的阶段大致会完成下面的 4 个阶段:文件格式验证元数据验证字节码验证符号引用验证

  • 文件格式验证主要是要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机的所处理(部分),这个步骤主要是保证输入的字节流能正确地解析并存储于方法区之内:

    • 是否以魔数 0xCAFEBABE 开头;
    • 主、次版本号是否在当前虚拟机处理范围之内;
    • 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志);
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量;
    • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF-8 编码的数据;
    • Class 文件中各个部分及文件本身是否有被删除的或被附加的其他信息;
  • 元数据验证这个步骤主要是验证字节码描述的信息是否满足 Java 的语言规范,可能包含的验证点有(语法校验):

    • 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应该有父类);
    • 是否继承了不允许被继承的类(如继承了被 final 修饰的类);
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法;
    • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现了不符合规定的方法重载,例如方法参数都一样,返回值却不相同)
  • 字节码验证通过数据流、控制流分析,确定程序语意是合法的、符合逻辑的。在元数据验证步骤中主要对数据的类型进行验证之后,在字节码验证步骤则主要对类中的方法体进行验证分析,确保类的方法在运行时不会危害虚拟机:

    • 保证操作数栈的数据类型和指令代码序列都能配合工作,避免出现:在操作数栈放置一个 int 类型长度的数据变量,但却按 long 类型的来加载如本地变量表中;

    • 保证跳转指令不会跳转到方法体以外的字节码指令上;

    • 保证方法体中的转换是有效的;

  • 符号引用验证这个步骤主要是在符号引用转化为直接引用之前,而这个转化的动作是发生在解析阶段,对字节码中的符号引用:如是否可以通过全限定名找到对应的类名、字段描述符是否对应方法名、简单名称是否对应字段、以及权限符(private、protected、pubilc、default)是否可被当前类访问。

字节码验证步骤对虚拟机加载机制来说是很重要的,但不是一定必须的,如某一份字节码已经经过多次使用和验证确保无误以后,可以通过 -Xverify:none 参数来关闭大部分的类的验证来减少类加载的时间。

# 准备阶段

准备阶段主要是为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。需要注意的是,在这里所指的类变量是指被 static 修饰符修饰的类变量,没有包括实例变量。实例变量将会在对象初始化的时候随着对象一起被分配 Java 堆中。同时,这里说的类变量的初始值通常为零值。如:

上述的代码的 value 在准备阶段初始值为 0 而不是 123,而被赋值为 123 的阶段是在初始化阶段的 <clinit>() 方法之中。

而当字段的属性表中存在 ConstantValue 属性的时候,那么在准备阶段虚拟机就会根据 ConstantValue 把 value 设置为 123;

数据类型 零值
int 0
long 0L
short (short)0
char ‘\u0000’
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null
# 解析

解析阶段主要是把方法区内的常量池的符号引用转换为直接引用的过程。如把全限定名、字段描述符、简单名称转换为直接引用。

符号引用指的是用一组符号来描述来描述所引用的目标,符号可以是任何形式的字面量,只要使用时可以无歧义地定位到目标即可。符号引用被明确定义在 Class 文件格式中。

直接引用指的是可以直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。如果直接引用存在,那么引用的目标必定已经存在。

# 初始化阶段

虚拟机规范明确规定有且只有 5 中情况必须立即对类进行初始化:

  • 遇到 newgetstaticputstatic、或 invokestatic 这四条字节码指令时,如果类没有初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:使用 new 关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放进常量池的静态常量除外)的时候,以及调用一个类的静态方法的时候。

  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。

  • 当初始化一个类的时候,如果发现其父类还没有初始化,则先对其父类进行初始化。

  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。

  • 使用 JDK 1.7 的动态语言支持时,如果 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putstatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有初始化,则需要先触发其初始化。

上面 5 种情况称为对一个类的主动引用。而所有引用类的方式都不会触发初始化,称为被动引用:

  • 通过子类引用父类的静态字段,不会导致子类的初始化;

  • 通过数组定义来引用类,不会触发此类的初始化;

  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发类的初始化;

接口与普通类的初始化,只和上面的 5 种主动引用的第三种情况不同(真正的区别):普通类的初始化要求父类先初始化,而接口的父类只有在用到的时候(如引用接口中定义的定量)才会进行初始化。

初始化阶段是真正地执行定义的 Java 代码(或者说字节码)。在准备阶段类变量已经被赋过一次系统要求的初值,而在初始化阶段,这是初始化程序的类变量和其他的资源。初始化也可以说是执行类构造器 <clinit>() 方法的过程

下面是关于 <clinit>() 方法在执行过程中可能影响程序运行行为的特点和细节:

  • <clinit>() 方法是由编译器自动收集类中的类变量和静态语句块中的语句合并而产生的。收集的顺序由语句在类文件中出现的顺序决定。

  • 虚拟机会保证父类的 <clinit>() 方法先执行完毕,然后再执行子类的 <clinit>(),不需要我们显式调用,与实例构造器 <init>() 不同。因而在虚拟机中首先被调用的 <clinit>() 方法是在 java.lang.Object.

  • 父类的静态语句优于子类的静态语句先执行。

  • 接口中不可以定义静态语句块,但仍然有变量初始化的赋值操作,因此接口与类都会生成 <clinit>() 方法。接口中不需要先执行父类的 <clinit>() 方法,只有当父类中定义的变量被使用的时候,父类才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。

  • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确地加锁、同步。

类加载器

一个完整的 Java 程序是由许多的 .class 文件所组成的,在程序运行的过程中,只有这些 .class 文件加载进 JVM 中才可以被使用,而加载进虚拟机这个过程需要类加载器来完成。

Java 中的类加载器自带的有 3 中:

通常情况下,.class 文件被主动加载进 JVM 的方式有:

  • 调用构造方法;

  • 调用类中的静态方法或者静态属性;

Java 中自带的类加载器分别有启动类加载器(Bootstrap ClassLoader)扩展类加载器(ExtClassLoader)应用类加载器(AppClassLoader)

# AppClassLoader

我们通过分析 AppClassLoader 的源码:

从源码中可以知道,java.class.path 路径就是我们的环境配置 CLASS_PATH 路径,因而 AppClassLoader 加载的类是我们编写的类或者第三方 jar 包中的 .class 文件。

# ExtClassLoader

同样,我们通过分析 ExtClassLoader 源码:

通过分析源码,我们可以知道,ExtClassLoader 是加载 java.ext.dirs 文件下的 .class 文件,我们可以通过打印分析 ExtClassLoader 类加载器具体加载的文件:

# BootstrapClassLoader

BootstrapClassLoader 类加载器和上面 AppClassLoaderExtClassLoader 不同,AppClassLoaderExtClassLoader 都是基于 Java 语言实现的类加载器,而 BootstrapClassLoader 则是基于 C/C++ 语言实现的。

在 Java 层无法直接获取 BootstrapClassLoader 的引用,如果尝试获取则返回 null。BootstrapClassLoader 加载系统属性 sun.boot.classpath 配置下的字节码文件,我们可以通过打印:

可以看出,BootstrapClassLoader 加载的是 jre 目录下的文件或 .class 文件。

双亲委派模式

在 Java 中既然已经有了 3 中类加载器,那么在加载 .class 文件的时候,虚拟机是如何知道选择哪一种类加载器进行加载的呢?答案就是接下来要说到的双亲委派模式了。

所谓的双亲委派模式,就是在当类加载器接收到加载的任务的时候,首先把任务委托给父类进行加载,只有父类发现找不到资源或者找不到指定的类的时候,才自己执行实际的加载过程。

我们可以在 ClassLoader.java#loadClass(String, boolean) 找到双亲委派模式的影子,代码如下:

逻辑说明:

    1. 先通过 findLoadedClass(String) 检查需要加载的字节码文件是否已经被加载,已经被加载则直接返回;
    1. 如果需要加载的字节码文件为空,即没有被加载,则检查自己的父类加载器是否为空,不为空的时候,通过调用父类的 loadClass(String) 进行加载;
    1. 如果父类为空,则通过 findBootstrapClassOrNull(String) 委托给启动类加载器加载;
    1. 如果启动类加载器找不到指定的类或找不到资源,就自己调用 findClass(String) 自己执行实际的加载;

那么,在上述的代码中,parent 所代表的的是什么呢?通过查看 ClassLoader 的构造方法:

我们可以发现,parent 的确指代的是父类加载器,而我们通过翻阅源码可以发现,AppClassLoader 的父类加载器是 ExtClassLoader, 而 ExtClassLoader 的父类加载器为 null。

# 举例说明

通过上面双亲委派模式的理论可知,虚拟机首先会把类加载给 AppClassLoader 进行加载 LearnClassLoad 类。

  • AppClassLoader 首先会把加载任务委托其父类加载器 ExtClassLoader 进行加载;

  • ExtClassLoader 同样也会把这加载任务委托给其父类,但发现自己的父类加载器为空,就把加载任务委托给 BootstrapClassLoader 进行加载;

  • BootstrapClassLoaderjdk\lib 目录下无法找到 LearnClassLoad,因此返回的 Class 都为 null;

  • 因为 parentBootstrapClassLoader 都没有加载成功,所以只能通过调用自己的 findClass 去加载;

最终 LearnClassLoad 是被 AppClassLoader 加载进虚拟机内存的,我们可以通过代码验证:

有以上代码的打印结果,我们发现,LearnClassLoad 是被 AppClassLoader 加载进虚拟机内存的。

# 注意

双亲委派机制只是 Java 推荐的类加载机制,并不是强制要求的。我们可以通过继承 java.lang.ClassLoader 来实现自己的类加载器。如果我们想保留双亲委派机制,我们可以重写 findClass(String) 方法,如果我们想要破坏双亲委派模型,我们可以重写 loadClass(name) 方法。

小结

我主要从类加载进虚拟机的生命周期加载、连接(验证、准备、解析)、初始化来概述字节码加载进虚拟机的过程,以及详细地描述了 Java 的类加载器以及双亲委派模式,从而比较全面的描述了虚拟机的加载机制。但通过虚拟机的内存区域的划分、垃圾回收机制、以及虚拟机类加载机制的学习,才发现,这其实只是了解 Java 虚拟机的开始,深入学习 JVM 的路还很长很长…

本文标题:JVM系列-虚拟机类加载机制

文章作者:

发布时间:2020年04月08日 - 19:04

最后更新:2021年06月20日 - 19:06

原始链接:https://hndroid.github.io/2020/04/08/JVM%E7%B3%BB%E5%88%97-%E8%99%9A%E6%8B%9F%E6%9C%BA%E7%B1%BB%E5%8A%A0%E8%BD%BD%E6%9C%BA%E5%88%B6/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。