发布于 

Java虚拟机-类加载机制

本站字数:106.9k    本文字数:3k    预计阅读时长:10min    访问次数:

类的加载在Java虚拟机中的地位是非常高的。因为类加载允许来自不同地方的字节码,加载到Java虚拟机中,这样的操作,就使得Java虚拟机获得了极高的灵活性。

带来灵活性的同时,也会带来诸多问题:文件格式是够合法,字节码格式是否合法,字节码内容是否违背安全,

类加载的时机

类的生命周期主要包括:加载 -> 链接[验证 -> 准备 -> 解析] -> 初始化 -> 使用 -> 卸载。

类加载的过程是类声明周期的使用前的过程:加载 -> 链接[验证 -> 准备 -> 解析] -> 初始化。一个类要使用首先通过类加载器,将类的字节码加载到虚拟机中,然后在虚拟机中进行链接工作,首先进行的就是验证过程,也是链接过程中比较繁琐的过程,需要执行比如字节码文件格式校验,元数据校验,字节码验证等等环节,验证通过后会由准备程序,在方法区为类变量开辟空间,赋予0值,最后在合适的时间节点将类解析完成,最后完成类的初始化工作,完成整个类加载过程。

其中类加载的链接中的载入的时间节点最为灵活。

类加载的时机主要是在new一个对象之前,设置或者读取(非final)静态变量之前,调用静态方法之前,父类在子类加载之前,程序启动加载主类之前等等时间节点需要将类加载到虚拟机中。

类加载的过程

JVM-加载流程
JVM-加载流程

类加载的主要过程包括:加载,验证,准备,载入,初始化。

加载 - 整活专用

加载阶段的主要任务是:

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

二进制字节码流给类加载过程带来很高的的灵活性。类的加载过程需要加载字节码二进制流,这个二进制流,一般情况下是通过文件获取二进制流,加载到虚拟机中。但是,Java虚拟机规范中规定通过限定名获取一个类的二进制字节码流就可以。这样的设计就给Java的类加载过程带来了极大的灵活性。常用的加载方式一般有以下几种。

  1. 从文件系统中加载,最常见的方式
  2. 通过网络获取,Web Applet
  3. 从压缩文件中获取,例如zip和jar
  4. 运行时计算生成,例如各种动态代理技术
  5. 其他文件生成。JSP
  6. 从数据库获取
  7. 从加密文件获取

【链接】验证 - 合法性检验,麻烦但是可以优化

验证是链接阶段的第一道门槛,主要用来验证字节码文件的正确性,合法性。保证这些信息不会危害计虚拟机的安全。

验证阶段的主要工作包括:文件格式验证、元数据验证、字节码验证和符号引用验证。

  • 文件格式验证:主要验证是否符合Class文件格式规范
  • 元数据验证:对字节码描述的信息进行语义分析,保证描述的信息符合规范
  • 字节码验证:最复杂的一个验证过程,主要进行数据流分析和控制流分析,确定程序的语义是合法合乎逻辑的
  • 符号引用验证:这个验证主要在解析阶段进行验证,校验符号引用是否缺少或者被禁止访问某些外部的类,方法,字段等资源。

验证过程对于虚拟机的加载机制来说是一个非常重要的过程,但是却不是必须要执行的。因为验证阶段只有通过或者不通过的问题。程序代码被反复验证过,生产环境也没有必要再次验证,这样可以减少部署阶段的加载时间。通过参数-Xverify:none就可以关闭大部分的验证,缩短虚拟机类加载的时间。

【链接】准备 - 为类变量开辟空间,赋予类变量0值

准备阶段是正式为类中定义的变量(也就是静态变量)分配内存并且分配零值(不包括final)的阶段。

方法区,永久代和元数据区

方法区是在Java虚拟机规范中规定的一块JVM运行时内存区域。在JDK 7以前,使用永久代来实现方法区,这样的实现完全是逻辑上的实现。在JDK 8 以后类变量会随着Class对象一起存放到堆区中,这个时候“类变量在方法区”完全就是一种逻辑概念的表述了。

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

准备过程,正常情况下会为类变量赋0值。也有例外,如果类变量是final类型,那么编译时生程序性,准备阶段就会直接赋值为123。

【链接】载入 - 一个具有“弹性工作时间”的过程

载入就是将常量池内的符号引用替换为直接引用的过程。

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。
  • 直接引用(Direct References):直接引用时可以直接指向目标的指针相对偏移量或者是一个能间接定位到的目标的句柄

解析动作主要目标是:接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用。

初始化 - 程序员拥有了话语权

在准备阶段,就已经为变量赋值一次了,在初始化阶段会根据程序员定义的代码去初始化这些类变量或者其他资源。初始化阶段就是执行类构造器<clinit>()方法的过程。

关于<clinit>()的相关信息:

  • <clinit>()是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的。静态语句块中只能访问到定义在静态语句块之前的变量
  • <clinit>()不需要显式调用父类的类构造方法,Java虚拟机可以保证父类的类构造方法会在子类之前执行完毕。因此,Java虚拟机中第一个被执行的<clinit>()方法肯定是java.lang.Object
  • <clinit>()方法对于类或者接口来说并不是必须的。
  • 接口中不能使用静态语句块,但是可以声明静态字段,因此接口和类一样也会生成<clinit>()方法,但是执行接口的<clinit>()方法不需要先执行父类的<clinit>()接口。
  • Java虚拟机保证一个类的<clinit>()方法在多线程环境中正确的加锁同步。尽管如此,<clinit>()方法依然可能会造成很隐蔽的阻塞问题。

类加载的“双亲”委派机制

类加载器(Class Loader):“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便于应用程序自己决定如何去获取所需要的类,完成这个动作的代码成为类加载器。

类的相等性检查:比较两个类是由同一个类加载器加载的前提下才有意义,否则,及时这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载他们的类加载器不同,那这两个类必定不相等。

类加载器

Java虚拟机角度有两种不同的类加载器,一种是启动类加载器(Bootstrap Class Loader),这个类加载器由C++实现,是虚拟机的一部分;另一种是其他所有的类加载器,这些类加载器由Java代码实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader

  • 启动类加载器(Bootstrap Class Loader)
    加载最基础的类库,一般按照文件名识别rt.jar,tools.jar等文件。
  • 扩展类加载器(Extension Class Loader)
    负责加载<JAVA_HOME>/lib/ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。JDK9之后,这种扩展机制被模块化带来的天然扩展能力所取代。JDK 9 以后的这一层加载器叫做平台类加载器(Platform Class Loader)
  • 系统/应用程序类加载器(Application Class Loader)
    负责加载用户类路径上的所有类库。
  • 自定义类加载器(User Class Loader)

什么是双亲委派机制

JVM-双亲委派模型
JVM-双亲委派模型

如上图所示,上面的类加载器层次结构称之为类加载器的“双亲委派模型(Parent Delegation Model)”。类加载器之间的层次关系,不是以继承的方式来实现的,而是通过组合的方式来复用父加载器的代码。

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

双亲委派机制实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 首先检查这个classsh是否已经加载过了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// c==null表示没有加载,如果有父类的加载器则让父类加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
//如果父类的加载器为空 则说明递归到bootStrapClassloader了
//bootStrapClassloader比较特殊无法通过get获取
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {}
if (c == null) {
//如果bootstrapClassLoader 仍然没有加载过,则递归回来,尝试自己去加载class
long t1 = System.nanoTime();
c = findClass(name);
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

为什么又要破坏双亲委派机制?

  1. JDK 1.2 之前双亲委派机制出现之前,存在“破坏双亲委派”的问题,但是后面为了兼容,不得不去维护。
  2. 由于模型自身导致,双亲委派能够很好的完成各个类加载器协作时基础类型的一致性问题。但是如果由基础类型又要去调回用户代码,那么就会出现问题。
  3. 用户对程序动态性的追求导致了违背了双亲委派机制,参考OSGi。

参考资料

  1. [书籍] 深入理解Java虚拟机(第三版)
  2. [Bilibili] 尚硅谷宋红康JVM全套教程(详解java虚拟机)