虚拟机类加载机制

写在前面

本文作为阅读了周志明作者的 <<深入理解Java虚拟机>> 的读书笔记,同时,也结合了 SE 8 的 JAVA 虚拟机规范。

Class 文件中描述的各种信息,最终都需要加载到虚拟机中之后才能被运行和使用。而虚拟机如何加载这些 Class 文件?Classs 文件中的信息进入到虚拟机后会发生什么变化?这些都是后面要讲解的内容,没准,还有拓展的知识点。

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


类加载的时机

类加载在这里指的是 Java 虚拟机动态 加载连接初始化 类和接口 这一过程。

加载是查找具有特定名称的类或接口类型的二进制表示,并从该二进制表示创建类或接口的过程。连接是获取类或接口并将其结合到 Java 虚拟机的运行时状态以便执行的过程(符合引用转化为直接引用)。类或接口的初始化由执行类或接口的初始化方法 <clinit> 组成。

什么情况下需要开始类加载过程的第一个阶段:加载。虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握,书上这样描述到。

虚拟机规范中,在讲解具体类加载过程之前,先讲解了运行时常量池。这是因为当 Java 虚拟机创建类或接口时,将会为该类构造运行时常量池。所以,让我们先了解下运行时常量池。

后文的类或接口统称类。


运行时常量池

Java 虚拟机维护每个类型的常量池,这是一种运行时数据结构,用于传统编程语言实现符合表的用途。

还记得前文 有趣的类文件结构 中提到的 constant_pool 表吧?它将用于创建类时构造运行时常量池。所以,最初在运行时常量池中的所有引用都是符号性的。其具体结构则是由类的二进制表示形式的结构派生的。除此之外,还有一些字面量也是从 constant_pool 中找到并派生的。

通俗点来讲,类A 如果在某个方法中调用了类 B 的某个方法,那么类A在编译期生成的字节码文件,将会存储类B的全限定名以及被调用方法的描述符。这是在编译期发生的事,这时候,我们并不知道,这个全限定名的类B 该如何去获取(即类B相关的字节码),这里仅仅只有一个具有唯一性的字符串。所以,在运行时,字节码文件被加载到内存中时,就会先为该类构造一个运行时常量池,以便在后续能够将符号引用替换为直接引用。也可以这样说,字节码文件是静态的文件,虚拟机没法在运行时使用,所以,当把它加载到内存中时,需要一个动态的表示,这就是运行时常量池了(这种说法可能有点片面)。

运行时常量池只是虚拟机规范中的一个概念,它属于方法区下面。但具体虚拟机实现可能并不怎样去命名(肯定实现了相应的功能)。像 JDK8 中提供的 HotSpot,它将这种运行时数据结构放在了元数据区,字面量又放在了堆中。

上面这段是我个人的理解,尽管我是结合文档理解的,但仍然可能有误。如果有有疑问的话,欢迎交流。


类加载的过程

Java 虚拟机通过创建一个初始类启动,这个类使用引导类加载器,以依赖于实现的方式制定。然后 Java 虚拟机连接初始类,并初始化它,并调用公共类方法 void main(String[])。该方法的调用将驱动进一步执行。main 方法的执行可能导致连接(并因此创建)其它类以及调用其它方法。


创建和加载

类C 的创建将在方法区构造一个特定于实现 C 的内部表示,这个创建过程可能由另一个在运行时常量池引用了类C 的类D来触发。类C 的创建也可以通过调用类库中的反射方法来触发。

如果这个C不是数组,那么它将由类加载器加载C的字节码来创建的。数组类没有外部二进制表示,它们是由 Java 虚拟机创建的。

书中将加载阶段描述为需要完成以下三件事情:

  1. 通过一个类的全限定名来获取此类的二进制字节流。(从类D的常量池中获取到类C 的全限定名)
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。(使用类加载器)
  3. 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为方法区这些数据的访问入口。(类加载器将返回这样一个对象)

可以发现,书中描述的和前文所描述的其实是一样的,我也在括号中做了说明。然后总结为下面这张图:

类加载过程
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在 Java 堆中实例化一个 java.lang.Classs 类的对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。


连接

连接一个类需要验证和准备这个类、它的直接超类和它的元素类型(如果是数组类型),解析类中的符号引用则是连接的可选部分。规范在连接时,充分保留了其灵活性,只需要满足以下前提:

  1. 类在连接之前需要被完全加载;
  2. 类在初始化之前是完全验证和准备好的;
  3. 在连接过程中检测到错误会在程序中某个点抛出,在这个点上,程序可能直接或间接地需要连接到与错误相关的类或接口。

例如,Java 虚拟机实现可以选择在使用类或接口时分别解析每个符号引用(“惰性”或“延迟”解析),或者在验证类时一次性解析它们(即时或静态解析)。这意味着在某些实现中,在初始化类或接口之后,解析过程可能会继续。


现在来看看连接的具体三个阶段:

  • 验证:这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

    如果输入的字节流不符合 Class 文件的存储格式,就抛出一个 java.lang.VerifyError 异常或其子类异常。

  • 准备:这是正式为类变量分配内存并设置类变量(被 static 修饰的变量)初始值(这里指的是数据类型的零值)的阶段,这些内存都将在方法区中进行分配。

  • 解析:这是虚拟机将常量池内的符号引用替换为直接引用的过程。

    符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。

    直接引用可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的。如果有了直接引用,那引用的目标必定已经在内存中存在。


初始化

类或接口的初始化由执行其类或接口的初始化方法组成,这个阶段才是真正开始执行类中定义的 Java 程序代码。可以这样理解初始化阶段是执行类构造器方法的过程。

在这里可能会疑惑,为什么接口也有构造器方法?其实这不难理解,它也需要初始化字段值。通过反汇编字节码文件可以发现,构造器方法会被改为 <clinit> 或者 <init>。这两个方法内部都将由编译器收集类中的所有类变量的赋值动作和静态语句块合并产生。编译器收集的顺序是由语句在源文件中出现的顺序所决定的。

静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但不能访问。

这两个方法的不同在于,<clinit> 它不需要显示地调用父类构造器,虚拟机会保证在子类的 <clinit> 被执行之前,父类的 <clinit> 已经执行完毕。

也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

<clinit> 方法对于类或接口来说,也不是必须的。如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit> 方法。

执行接口的 <clinit> 方法不需要先执行父接口的 <clinit> 方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的 <clinit> 方法。

注意:虚拟机会保证一个类的 <clinit> 方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只有一个线程能够执行这个类的 <clinit> 方法。如果这个方法中有很耗时的操作,那就可能造成多个线程阻塞。


参考博文


Java虚拟机规范之加载、连接、初始化


我与风来


认认真真学习,做思想的产出者,而不是文字的搬运工。错误之处,还望指出!


比心

如果觉得这篇文章对你有所帮助,动动小手,点点赞,这将使我能够为你带来更好的文章。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值