JVM 类加载机制及双亲委派模型

2,752 阅读9分钟

Java 程序是如何跑起来的呢,如何从一个 .java 源文件到控制台的输出结果?
要回答类似的问题就需要学习虚拟机类加载机制。

整体的流程

Java 中的所有类,必须被装载到 jvm 中才能运行,这个装载工作是由 jvm 中的类加载器完成的,类加载器所做的工作实质是把类文件从硬盘读取到内存中,JVM 在加载类的时候,都是通过 ClassLoader 的 loadClass()方法来加载 class 的,loadClass 使用双亲委派模型。

以上流程中出现了很多陌生的名词,本篇文章就是解析这些名词,当你回头再看这句话便豁然开朗

类的声明周期
先解析一下这张图,图表示类的整个声明周期,类从被加载到虚拟机内存开始,到卸载出内存为止,包含 7 个阶段,其中验证、准备、解析 3 个阶段统称为连接。

加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持 Java 语言的运行时绑定(动态绑定或晚期绑定)

装载

装载两个字说起来简单,但是对于 JVM 来说,这是个复杂的流程,也就是虚拟机的类加载机制:虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型。

加载

这里所说的「加载」是「类加载」过程的一个阶段,「类加载」描述的是整个过程,「加载」仅表示「类加载」的第一阶段,需要完成以下三件事情:

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

说这么多其实就完成了一件事情:根据一个类的名字(全限定名)在内存中生成一个 Class 对象,注意 Class 对象不是关键字 new 出来的那个对象,Class 是一种类型,表示的是一个对象的运行时类型信息。

接下来的三个阶段,都属于连接(Linking)。加载阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。

连接 - 验证

验证是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。如果验证到输入的字节流不符合 Class 文件格式的约束,虚拟机就会抛出一个 java.lang.VerifyError 异常或其子类异常。

验证阶段大致完成 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

连接 - 准备

准备阶段是正式为类变量(static 修饰的变量)分配内存并设置类变量初始值的极端,这些变量所使用的内存都将在方法区中进行分配。注意此时进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。

并且这里提到的初始值是指零值,每种基本数据类型都有对应的零值。

假设一个类变量的定义为:
public static int value = 123

那这个变量在准备阶段过后的初始值是 0 而不是 123,把 value 赋值为 123 的动作将在初始化阶段才会执行

连接 - 解析

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

符号引用:只包含语义信息,不涉及具体实现,以一组符号来描述引用目标,是字面量;符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。

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

初始化

初始化阶段,才真正开始执行类中定义的 Java 程序代码(或者说是字节码)

在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源。

也就是我们通常理解的赋初始值以及执行静态代码块。

类加载器

类与类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

比较两个类是否「相等」,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

加载器的种类(从开发人员的角度)

  • 启动类加载器(Bootstrap ClassLoader):负责将存放在 <JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中
  • 扩展类加载器(Extension ClassLoader):负责加载 <JAVA_HOME>\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库
  • 应用程序类加载器(Application ClassLoader):也称为系统类加载器,负责加载用户类路径(ClassPath)上所指定的类库。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

双亲委派模型

类加载器双亲委派模型
上图所示的类加载器之间的层次关系,称为类加载器的双亲委派模型。

双亲委派模型除了要求顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承的关系类实现,而是都使用组合关系来复用父加载器的代码。

双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

为什么要使用双亲委派模型

借用一个例子:黑客自定义一个java.lang.String类,该String类具有系统的String类一样的功能,只是在某个函数稍作修改。比如equals函数,这个函数经常使用,如果在这这个函数中,黑客加入一些“病毒代码”。并且通过自定义类加载器加入到JVM中。此时,如果没有双亲委派模型,那么JVM就可能误以为黑客自定义的java.lang.String类是系统的String类,导致“病毒代码”被执行。

而有了双亲委派模型,黑客自定义的java.lang.String类永远都不会被加载进内存。因为首先是最顶端的类加载器加载系统的java.lang.String类,最终自定义的类加载器无法加载java.lang.String类。

也就是说,无论那一个类加载器去加载一个系统中已有的类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此系统里在程序的各种类加载器环境中都是同一个类。

双亲委派模型是如何实现的

实现双亲委派的代码都几种在 java.lang.ClassLoader 的 loadClass() 方法中:先检查是否已经被加载过,若没有加载则调用父加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父加载器加载失败,抛出 ClassNotFoundException 异常后,再调用自己的 findClass() 方法进行加载。(看源码后发现这里的抛出异常是被吞了,catch 之后不会做任何操作)

破坏双亲委派模型

双亲委派模型并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的类加载器的实现方式。大部分的类加载器都遵循这个模型,但双亲委派模型也可以被破坏,破坏并不是不好,而是在有足够意义和理由的情况下,突破已有的规则进行创建,实现特定的功能。

三种破坏双亲委派模型的方式

  • 重写 loadClass() 方法
  • 逆向使用类加载器,引入线程上下文类加载器
  • 追求程序的动态性:代码热替换、模块热部署等技术

文中如有错误或不妥之处,麻烦指出,谢谢!

文章参考来源:
《深入理解Java虚拟机》
Java类的加载、链接和初始化
双亲委派模型与自定义类加载器