深入理解Java虚拟机:(二)Java虚拟机的类加载机制

一、概述

在上一篇我么了解到 Java 源文件经过编译后成 Class 文件,在 Class 文件中描述的各种信息,最终都需要加载到虚拟机中才能运行与使用。而虚拟机如何加载这些 Class 文件的?Class 文件进入到虚拟机中间又发生了什么?这就是本篇要讲的内容。

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行验证、准备、解析和初始化,最终形成可以被虚拟机这届使用的 Java 类型,这就是虚拟机的类加载机制。

二、类加载的时机

类从被加载到虚拟机内存开始,到卸载内存为止。它的整个生命周期如下图:
在这里插入图片描述

什么情况下开始类加载的第一个阶段:加载?Java 虚拟机中并没有强制约束,这点可以交给虚拟机的具体实现来自由把握。但对于初始化阶段,虚拟机规范则是严格规定了有且只有 5 种情况必须立即对类进行“初始化”。(而加载、验证、准备自然需要在此之前开始)。

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

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

(3)、当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化。

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

(5)、当使用 jdk 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandler 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化。

这5种“有且只有”的场景的行为称为对一个类进行主动引用。除此之外,所有引用类的方式不会触发初始化,称之为被动引用。

被动引用侧场景如:

  • 通过子类引用父类的静态字段,不会导致子类初始化。
  • 通过数组定义来引用类,不会触发此类的初始化。
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此,不会触发定义常量的类的初始化。

这里要注意一点:

接口的加载过程与类的加载过程稍有一点不同。类的初始化可以用静态语句块“static{}”来输出初始化信息的,而接口不能使用“static{}”语句块,但编译器仍然会为接口生成“<clinit>{}” 类构造器,用于初始化接口中所定义的成员变量。真正有所区别的的是前面讲的5中“有且只有”的第3种:当一个类在初始化时,要求其父类全部都已经初始化了;但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

三、类加载的过程

接下来,我们来详细介绍下 Java 虚拟机中类加载的全过程,也就是加载、验证、准备、解析和初始化5个阶段所执行的具体动作。

1、加载

加载,是指通过类的全限定名来获取此类的二进制文件流,将这个字节流所代表的静态存储结构转化为方法区的运行时的数据结构,然后在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

以我要盖房子为例,正好我的好兄弟树根是一级建筑师,我叫他帮我设计一套“四房两厅两卫” 的房型。这里的房型相当于类,而我的好兄弟树根(建筑师)就相当于类加载。

树根是学建筑的,他们班有好多建筑师,但他们有共同的老师——学校的建筑教授,叫启动类加载器(bootstrap class loader)。启动类加载器是由 C++ 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。换句话说,教授不喜欢像 树根 这样的小角色来打扰他,所以谁也没有教授的联系方式。

除了启动类加载器之外,其他的类加载器都是 java.lang.ClassLoader 的子类,因此有对应的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中,方能执行类加载。

学校的建筑师有一个潜规则,就是接到了单子自己不能着手干,得先给教授过过目,教授不接手的情况下,才能自己来。在 Java 虚拟机中,这个潜规则有个特别的名字,叫双亲委派模型。每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。

在 Java 9 之前,启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由 Java 核心类库提供。

扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。

应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

除了由 Java 核心类库提供的类加载器外,我们还可以加入自定义的类加载器,来实现特殊的加载方式。举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解密。

在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

2、连接

连接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段。

(1)、验证

验证阶段是非常重要的,这个阶段是否严谨,直接决定可 Java 虚拟机是否能承受恶意代码的攻击。从整体上看,大致会完成下面4个阶段的校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 文件格式验证
    是否以魔数 0xCAFEBABE 开头。
    常量池的常量是否有不被支持的常量类型(检查常量 flag 标志)。
    指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量。
    Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。

  • 元数据验证
    这个类是否有父类。
    这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。
    如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
    类中的字段、方法是否与父类产生矛盾。

  • 字节码验证
    保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作。
    保证跳转指令不会跳转到方法体以外的字节码指令上。
    保证方法体中的类型转换是有效的。

  • 符号引用验证
    符号引用中通过字符串描述的全限定名是否能找到对应的类。
    符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。

(2)、准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这里有两个点容易混淆强调一下,首先,这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。其次,这里所说的初始值通常情况下是数据类型的零值,假设一个类变量的定义为:

public static int value = 123;

那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器 <clinit>() 方法之中,所以把 value 赋值为 123 的动作将在初始化阶段才会执行。

下表是 Java 所有基本数据类型所对应的零值。

在这里插入图片描述

上面提到,在通常情况下初始值是零值,那相对的会有一些特殊情况:如果类变量 value 的定义变为:

public static final int value = 123;

编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。

(3)、解析

解析阶段是虚拟机将常量池内的符号引用替换成直接引用的过程。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

3、初始化

类初始化阶段是类加载过程的最后一步,初始化阶段是执行类构造器 <clinit>()方法的过程。Java 虚拟机会通过加锁来确保类的 <clinit>() 方法仅被执行一次。

只有当初始化完成之后,类才正式成为可执行的状态。这放在我们盖房子的例子中就是,只有当房子装修过后,我才能住进去。

四、总结

我们主要介绍了类加载的时机以及类加载的过程。加载中主要是 Java 虚拟机将字节流转化为 Java 类的过程。这个过程可分为加载、连接以及初始化三大步骤。

加载是指查找字节流,并且据此创建类的过程。加载需要借助类加载器,在 Java 虚拟机中,类加载器使用了双亲委派模型,即接收到加载请求时,会先将请求转发给父类加载器。

连接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。连接还分验证、准备和解析三个阶段。其中,解析阶段为非必须的。

初始化,则是为标记为常量值的字段赋值,以及执行 <clinit>() 方法的过程。类的初始化仅会被执行一次,这个特性被用来实现单例的延迟初始化。

发布了332 篇原创文章 · 获赞 198 · 访问量 12万+

猜你喜欢

转载自blog.csdn.net/riemann_/article/details/103848936