YY's Studio.

【虚拟机简史】生命的起源—类加载机制

2018/08/16

前言

DNA是生命的背后基石,支持着生命的基本构造和性能。储存着生命的种族、血型、孕育、生长、凋亡等过程的全部信息。

同样的在JVM中,Class文件储存着对象的类型、字段、方法、可见性,GC操作等过程的全部信息。

在日常的开发中。我们接触最多的操作就是实例化对象,那么对象在JVM中是怎么产生的呢?本篇承接上文Class文件,将分析一下类的加载机制。

为了更好的理解,我们按照黄金思维法则来学习:

  • What: 类加载机制是什么?
  • Why: 为什么会有类加载机制?
  • How: 类是怎么被加载的?
  • When: 类加载发生在什么时候?

什么是类加载

将类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。这就是虚拟机的类加载机制。 —— 周志明《深入理解Java虚拟机》

简单点就是,Class文件开始加载到可以被JVM使用的过程就是类加载。

为什么会有类加载机制

首先Java在编译期间不会与物理层做交互,因此需要在运行期间将Class文件动态加载到内存中(包括内存地址等都是在运行期才得以确定的)。

这样的优点就是增加运行期间灵活,即可以动态加载Class文件。缺点是增加了类加载到内存的时间,因为期间需要做校验、解析等耗时的工作。

类是怎么被加载的

这部分将是本篇的重点,将会重点描述。将重点阐述以下内容

upload successful

首先,我们回想一个class文件的本质——二进制字节码。首先需要被加载的就是这些二进制字节码。这些都将被类加载器处理,最后形成JVM可以使用Java类型。因此类加载到内存的入口就是类加载器

类加载器(ClassLoader)

类加载器是Java流行的原因之一,因为它不全在JVM中,而是将能力暴露出给开发者,让其自行决定所需的类。因此可以在运行时,实现动态加载Class。了解类加载器,我们先要了解它的几个重要组成:加载方式和双亲委派结构。

upload successful

加载方式:

首先,类加载器需要找到指定的类。JVM将在当前ClassPath下,通过类的全限定名来找到指定的类。比如String的全限定名是java.lang.String,在本地中的路径结构则是java\lang\String。类加载器则会通过这个路径来确定唯一类。

双亲委派结构

JVM存在两种类加载器,虚拟机内部由C++实现的Bootstrap加载器,与虚拟机外部由Java实现的Extension加载器和Application加载器。

接下来,我们分别了解一下它们。

  • Bootstrap ClassLoader:
    引导类加载器,负责加载 \lib目录中的指定的Class文件(终于知道我们设置环境变量的原因了)。我们打开目录来看看到底有哪些文件可以被加载。

upload successful

上图中所有以jar结尾的文件都可以在Bootstrap ClassLoader加载。比如rt.jar文件中,就包含着日常开发最常用的几个包。

以下为JD-GUI反编译结果

upload successful

  • Extension ClassLoader:

扩展类加载器,负责加载<\JAVA_HOME>\lib\ext目录下的文件,开发者也可以自定义指定需要加载的文件目录。

  • Application ClassLoader:

应用加载器,这个ClassLoader是开发者直接使用的类加载器,如果应用程序中没有自定义过自己的类加载器,这个就是默认的类加载。其次,这个类是ClassLoader中getSystemClassLoaderde方法的返回值。(不过在Android中被改成了PathClassLoader)。自定义的ClassLoader的父加载器也都是来自于这个类

upload successful

以上的三种ClassLoader最后会相互配合进行加载的,它们三者运作的模型就被称作双亲加载(这里利用的是委派而不是继承,在一定时候可以修改委派关系)

upload successful

一般说道双亲加载模型的时候,就会有三个关键字

那么应如何理解这个模型呢?

从网上找到一段这样的描述,我觉得很容易理解

  1. 当Application ClassLoader 收到一个类加载请求时,他首先不会自己去尝试加载这个类,而是将这个请求委派给父类加载器Extension ClassLoader去完成。

  2. 当Extension ClassLoader收到一个类加载请求时,他首先也不会自己去尝试加载这个类,而是将请求委派给父类加载器Bootstrap ClassLoader去完成。

  3. 如果Bootstrap ClassLoader加载失败(在<\JAVA_HOME>\lib中未找到所需类),就会让Extension ClassLoader尝试加载。

  4. 如果Extension ClassLoader也加载失败,就会使用Application ClassLoader加载。

  5. 如果Application ClassLoader也加载失败,就会使用自定义加载器去尝试加载。

  6. 如果均加载失败,就会抛出ClassNotFoundException异常。

upload successful

要更好的理解这个模型可以想象 “尊老的家庭”

儿子们买了吃的想给自己的爸爸,爸爸也是个孝子,马上就转手给了爷爷(BootStrap ClassLoader)。爷爷如果吃不下,就还给爸爸。爸爸要是也刚好不饿再还给儿子。

那这样的好处是什么呢?

  1. 分层清晰,职责不同更容于扩展
  2. 确保同一个类不会被加载多次,确保了类的唯一性,保证了运行的稳定性。

一个类需要与ClassLoader一起确定唯一性。对于不存在双亲关系的ClassLoder来说,两者加载的同一个Class文件,创建的类是不同的。

这里再补充Android的类加载器,这部分是与Java不同的。因为Android加载的文件结构是Dex文件,与Java的Class文件结构不一样,因此需要自定义类加载器。和上面的结构类似,Dalivk也有三个类加载器。不同的是在Android中ClassLoader都是由Java实现的。

upload successful

  1. BootClassLoader:等效于BootStrap ClassLoader,负责预先加载类库
  2. PathClassLoader:负责加载系统类和应用程序的类
  3. DexClassLoader:负责加载dex文件以及包含dex的apk文件或jar文件,也支持从SD卡进行加载。常见作用在热修复和插件化。

类的生命周期

类的生命周期一共为7个阶段:

  1. 加载
  2. 验证
  3. 准备
  4. 解析
  5. 初始化
  6. 使用
  7. 卸载

其中 2~4 又被统称为连接阶段。这七个部分并不是严格意义上的串行执行,比如加载与链接就是并行关系。这里我会将前面5步,6和7不是我们需要了解的重点。

加载阶段

加载阶段主要做了三件事:

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

这里要补充一下,数组类是不通过类加载器创建的,它是由虚拟机直接创建。但里面的Type是由类加载器创建。

这里就解答了我们的第最后一个问题,类是什么时候被加载的。

验证阶段

在加载过程中,还会同步进行验证的步骤。主要分为4步验证

  1. 文件格式验证:这部分是对Class文件格式进行校验。如magic数、JVM版本号、不被的解析信息等。
  2. 元数据验证:对字节码描述的信息进行语义分析,保证字节码符合Java规范。如是否有父类,是否实现了父类的抽象方法等。
  3. 字节码验证:校验方法的字节码的逻辑性,合法性。如int值用long类型来存等类型错误问题,return指令错误。这部分操作很耗时,为了节约检验时间,后续编译器通过StackMapTable保存验证信息来节约验证推导时间。
  4. 符号引用验证:校验常量池中的符号引用是否正确。如常量池中的全限定名是否能找到对应类(发生在解析阶段)
准备阶段

准备阶段是正式为静态变量分配内存,并设置静态变量的初始值阶段。
如以下代码

1
static int value = 1

在准备阶段会为value分配内存空间,不过初始值是0。将value设置为1的指令在<\cinit>中,只有在初始化才会被执行。

但是如果代码为

1
final static int value = 1

这样value的值在字段表中的ConstantValue中,这时候准备阶段就会将其值设置为1。

解析阶段

这一部分内容,和之前Class文件息息相关。这个步骤解析的到底是啥呢?符号引用!这一步将会把符号引用改为直接引用。

我们先看看以下代码

1
2
3
4
5
6
7
public class Demo {

public static void main(String args[]) {
HelloWorld helloWorld = new HelloWorld();
}

}

编译完之后,反编译class文件,可以看到main方法的指令如下

upload successful

new的指令会 映射到常量池#2的字面量 com/yanis/sample/HelloWorld 。

在解析的过程会将对字面量的引用转化为对HelloWorld类数据区真实地址的指针,即直接引用。如果这时候HelloWorld类不在内存中,则会加载HelloWorld类。

upload successful

不仅仅是针对引用类,字段表、方法表也会在这时候转化为直接引用。流程是先从自身开始,按继承关系从下往上寻找所需的字段/方法。内容相似,这里不做展开。

初始化阶段

类的初始化,是整个流程的最后一步。这一阶段只会执行一个方法 <\cinit>。 这个方法可以理解成为是当前类所有静态类的一个集合,比如以下代码

1
2
3
4
5
6
public class Test{
public static i= 1
static{
System.out.print(i);
}
}

以上的对i的赋值,与打印i都将会合并在 <\cinit>方法中。初始化阶段会自定执行该方法。如果父类也有<\cinit>则,JVM一定会先保证父类的 <\cinit>先被执行。一般情况下,这个方法有且只会被调用一次。

类是什么时候被加载的

那么什么时候会引发类加载机制呢?
JVM中没有规定什么时候需要对类进行加载、验证、准备具体发生的时机(可以是JVM一启动或者是按需加载)。因此可以在我想来,至少可以选择JVM开启的时候全部加载、或者是按需加载。

但是JVM规定了什么时候需要初始化。因此只要触发了初始化,说明前面的加载、验证、准备阶段一定要完成。

  1. 遇到 new、get/put static 或 invoke static等指令时候;
  2. 使用反射对类进行调用的时候,如果类没有初始化,也会对其进行初始化;
  3. 当初始化一个类的时候,如果父类还没有执行,就先要执行父类的初始化;
  4. 当虚拟机启动时,用户指定一个含main的方法的类,虚拟机会先初始化这个类;
  5. 在JDK1.7的时候,如果一个MethodHandle解析后的结果为REF_getStatic、REF_putStatic、REF_invkeStatic的时候,如果这个类没有初始化过,要先对其先初始化

以上没有说到解析阶段,解析阶段规定了在涉及常量池指令前触发就好了,时机可以在类加载的第一时间也可以在第一次调用该指令前。

总结

类加载可以说它是整个JVM世界生命的起源。经过了这个步骤才能在JVM中就可以正式的引用类。

在类加载机制中,重点在于加载过程中的5个阶段和双亲加载。我们只需要记住一个核心价值观,就可以很抽象地记忆里面的核心规律:

所有的事情,都先和爸爸打声招呼。

比如,类加载器双亲加载的模型、加载阶段子类,同时父类也要被加载等,都是需要先涉及相关的上一个元素,再确定自身的。

CATALOG
  1. 1. 前言
  2. 2. 什么是类加载
  3. 3. 为什么会有类加载机制
  4. 4. 类是怎么被加载的
    1. 4.1. 类加载器(ClassLoader)
      1. 4.1.1. 加载方式:
      2. 4.1.2. 双亲委派结构
    2. 4.2. 类的生命周期
      1. 4.2.1. 加载阶段
      2. 4.2.2. 验证阶段
      3. 4.2.3. 准备阶段
      4. 4.2.4. 解析阶段
      5. 4.2.5. 初始化阶段
  5. 5. 类是什么时候被加载的
  6. 6. 总结