JVM类加载机制

类加载时机

类的生命周期

一个类从加载进内存到卸载出内存为止,一共经历7个阶段:
加载——>验证——>准备——>解析——>初始化——>使用——>卸载

类的生命周期

其中,类加载包括5个阶段:
加载——>验证——>准备——>解析——>初始化

在类加载的过程中,以下3个过程称为连接:
验证——>准备——>解析

因此,JVM的类加载过程也可以概括为3个过程:
加载——>连接——>初始化

C/C++在运行前需要完成预处理、编译、汇编、链接;而在Java中,类加载(加载、连接、初始化)是在程序运行期间完成的。
在程序运行期间进行类加载会稍微增加程序的开销,但随之会带来更大的好处——提高程序的灵活性。Java语言的灵活性体现在它可以在运行期间动态扩展,所谓动态扩展就是在运行期间动态加载动态连接

类加载的时机

类加载过程中每个步骤的顺序

类加载的过程包括:加载、连接、初始化,连接又分为:验证、准备、解析,所以说类加载一共分为5步:加载、验证、准备、解析、初始化。

其中加载、验证、准备、初始化的开始顺序是依次进行的,这些步骤开始之后的过程可能会有重叠。 而解析过程会发生在初始化过程中。

类加载过程中“初始化”开始的时机

JVM规范中只定义了类加载过程中初始化过程开始的时机,加载、连接过程都应该在初始化之前开始(解析除外),这些过程具体在何时开始,JVM规范并没有定义,不同的虚拟机可以根据具体的需求自定义。

初始化开始的时机:

  1. 在运行过程中遇到如下字节码指令时,如果类尚未初始化,那就要进行初始化:new、getstatic、putstatic、invokestatic。这四个指令对应的Java代码场景是:
    • 通过new创建对象;
    • 读取、设置一个类的静态成员变量(不包括final修饰的静态变量);
    • 调用一个类的静态成员函数。
  2. 使用java.lang.reflect进行反射调用的时候,如果类没有初始化,那就需要初始化;
  3. 当初始化一个类的时候,若其父类尚未初始化,那就先要让其父类初始化,然后再初始化本类;
  4. 当虚拟机启动时,虚拟机会首先初始化带有main方法的类,即主类;
  5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需先触发其初始化。

主动引用 与 被动引用

JVM规范中要求在程序运行过程中,“当且仅当”出现上述4个条件之一的情况才会初始化一个类。如果间接满足上述初始化条件是不会初始化类的。
其中,直接满足上述初始化条件的情况叫做主动引用;间接满足上述初始化过程的情况叫做被动引用。
那么,只有当程序在运行过程中满足主动引用的时候才会初始化一个类,若满足被动引用就不会初始化一个类。

被动引用的场景示例

示例一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Fu{
public static String name = "类初始化的时机";
static{
System.out.println("父类被初始化!");
}
}

public class Zi{
static{
System.out.println("子类被初始化!");
}
}

public static void main(String[] args){
System.out.println(Zi.name);
}

输出结果:

1
2
父类被初始化! 
类初始化的时机

原因分析:

本示例看似满足初始化时机的第一条:当要获取某一个类的静态成员变量的时候如果该类尚未初始化,则对该类进行初始化。
但由于这个静态成员变量属于Fu类,Zi类只是间接调用Fu类中的静态成员变量,因此Zi类调用name属性属于间接引用,而Fu类调用name属性属于直接引用,由于JVM只初始化直接引用的类,因此只有Fu类被初始化。

示例二

1
2
3
4
5
public class Test{
public static void main(String[] args){
Fu[] arr = new Fu[10];
}
}

输出结果:

并没有输出“父类被初始化!”

原因分析:

这个过程看似满足初始化时机的第一条:遇到new创建对象时若类没被初始化,则初始化该类。
但现在通过new要创建的是一个数组对象,而非Fu类对象,因此也属于间接引用,不会初始化Fu类。

示例三

1
2
3
4
5
6
7
8
9
10
11
12
public class Fu{
public static final String name = "类初始化的时机";
static{
System.out.println("父类被初始化!");
}
}

public class Son{
public static void main(String[] args){
System.out.println(Fu.name);
}
}

输出结果:

类初始化的时机

原因分析:

本示例看似满足类初始化时机的第一个条件:获取一个类静态成员变量的时候若类尚未初始化则初始化类。
但是,Fu类的静态成员变量被final修饰,它已经是一个常量。被final修饰的常量在Java代码编译的过程中就会被放入它被引用的class文件的常量池中(这里是A的常量池)。所以程序在运行期间如果需要调用这个常量,直接去当前类的常量池中取,而不需要初始化这个类。

接口的初始化

接口和类都需要初始化,接口和类的初始化过程基本一样,不同点在于:类初始化时,如果发现父类尚未被初始化,则先要初始化父类,然后再初始化自己;但接口初始化时,并不要求父接口已经全部初始化,只有程序在运行过程中用到当父接口中的东西时才初始化父接口。

类加载的过程

加载

注意:“加载”是“类加载”过程的第一步,千万不要混淆。

加载的过程

在加载过程中,JVM主要做3件事情:

  1. 通过一个类的全限定名来获取这个类的二进制字节流,即class文件:
  2. 在程序运行过程中,当要访问一个类时,若发现这个类尚未被加载,并满足类初始化时机的条件时,就根据要被初始化的这个类的全限定名找到该类的二进制字节流,开始加载过程。
    • 将二进制字节流的存储结构转化为特定的数据结构,存储在方法区中;
    • 在内存中创建一个java.lang.Class类型的对象:
  3. 接下来程序在运行过程中所有对该类的访问都通过这个类对象,也就是这个Class类型的类对象是提供给外界访问该类的接口。

从哪里加载

JVM规范对于加载过程给予了较大的宽松度。一般二进制字节流都从已经编译好的本地class文件中读取,此外还可以从以下地方读取:

  • 从压缩包中读取,如:Jar、War、Ear等。
  • 从其它文件中动态生成,如:从JSP文件中生成Class类。
  • 从数据库中读取,将二进制字节流存储至数据库中,然后在加载时从数据库中读取。有些中间件会这么做,用来实现代码在集群间分发。
  • 从网络中获取,从网络中获取二进制字节流。典型就是Applet。

类 和 数组加载过程的区别

数组也有类型,称为“数组类型”。如:String[] str = new String[10]; 这个数组的数组类型是Ljava.lang.String,而String只是这个数组中元素的类型。

当程序在运行过程中遇到new关键字创建一个数组时,由JVM直接创建数组类,再由类加载器创建数组中的元素类。

而普通类的加载由类加载器完成。既可以使用系统提供的引导类加载器,也可以使用用户自定义的类加载器。

加载过程的注意点

  1. JVM规范并未给出类在方法区中存放的数据结构
    类完成加载后,二进制字节流就以特定的数据结构存储在方法区中,但存储的数据结构是由虚拟机自己定义的,JVM规范并没有指定。

  2. JVM规范并没有指定Class对象存放的位置
    在二进制字节流以特定格式存储在方法区后,JVM会创建一个java.lang.Class类型的对象,作为本类的外部接口。既然是对象就应该存放在堆内存中,不过JVM规范并没有给出限制,不同的虚拟机根据自己的需求存放这个对象。HotSpot将Class对象存放在方法区。

  3. 加载阶段和连接阶段是交叉的
    通过前文可知,类加载过程中每个步骤的开始顺序都有严格限制,但每个步骤的结束顺序没有限制。也就是说,类加载过程中,必须按照如下顺序开始:
    加载、连接、初始化,但结束顺序无所谓,因此由于每个步骤处理时间的长短不一就会导致有些步骤会出现交叉。

验证

验证阶段比较耗时,它非常重要但不一定必要,如果所运行的代码已经被反复使用和验证过,那么可以使用-Xverify:none参数关闭,以缩短类加载时间。

验证的目的

验证是为了保证二进制字节流中的信息符合虚拟机规范,并没有安全问题。

为什么需要验证

虽然Java语言是一门安全的语言,它能确保程序猿无法访问数组边界以外的内存、避免让一个对象转换成任意类型、避免跳转到不存在的代码行,如果出现这些情况,编译无法通过。也就是说,Java语言的安全性是通过编译器来保证的。

但是编译器和虚拟机是两个独立的东西,虚拟机只认二进制字节流,它不会管所获得的二进制字节流是哪来的,当然,如果是编译器给它的,那么就相对安全,但如果是从其它途径获得的,那么无法确保该二进制字节流是安全的。通过上文可知,虚拟机规范中没有限制二进制字节流的来源,那么任意来源的二进制字节流虚拟机都能接受,为了防止字节流中有安全问题,因此需要验证!

验证的过程

  1. 文件格式验证
    • 是否以魔数 0xCAFEBABE 开头
    • 主、次版本号是否在当前虚拟机处理范围之内
    • 常量池的常量是否有不被支持常量的类型(检查常量 tag 标志)
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
    • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据
    • Class 文件中各个部分集文件本身是否有被删除的附加的其他信息
    • ……

这个阶段主要验证输入的二进制字节流是否符合class文件结构的规范。二进制字节流只有通过了本阶段的验证,才会被允许存入到方法区中。
本验证阶段是基于二进制字节流的,而后面的三个验证阶段都是在方法区中进行,并基于类特定的数据结构的。

通过上文可知,加载开始前,二进制字节流还没进方法区,而加载完成后,二进制字节流已经存入方法区。而在文件格式验证前,二进制字节流尚未进入方法区,文件格式验证通过之后才进入方法区。也就是说,加载开始后,立即启动了文件格式验证,本阶段验证通过后,二进制字节流被转换成特定数据结构存储至方法区中,继而开始下阶段的验证和创建Class对象等操作。这个过程印证了:加载和验证是交叉进行的。

  1. 元数据验证
    • 这个类是否有父类(除 java.lang.Object 之外)
    • 这个类的父类是否继承了不允许被继承的类(final 修饰的类)
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
    • 类中的字段、方法是否与父类产生矛盾(覆盖父类 final 字段、出现不符合规范的重载)

本阶段对方法区中的字节码描述信息进行语义分析,确保其符合Java语法规范。

  1. 字节码验证
    • 保证任意时刻操作数栈的数据类型与指令代码序列都鞥配合工作(不会出现按照 long 类型读一个 int 型数据)
    • 保证跳转指令不会跳转到方法体以外的字节码指令上
    • 保证方法体中的类型转换是有效的(子类对象赋值给父类数据类型是安全的,反过来不合法的)
    • ……

这是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这个阶段对类的方法体进行校验分析,保证校验类的方法在运行时不会做出危害虚拟机安全的事件。

  1. 符号引用验证
    • 符号引用中通过字符创描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符方法的字段描述符以及简单名称所描述的方法和字段
    • 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问
    • ……

本阶段验证发生在解析阶段,确保解析能正常执行。如果无法通过符号引用验证将抛出一个 java.lang.IncompatibleClass.ChangeError 异常的子类。如 java.lang.IllegalAccessErrorjava.lang.NoSuchFieldErrorjava.lang.NoSuchMethodError 等。

准备

准备阶段完成两件事情:

  1. 为已经在方法区中的类中的静态成员变量分配内存
    类的静态成员变量也存储在方法区中。

  2. 为静态成员变量设置初始值
    初始值为0、false、null等。

数据类型的零值:

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

示例1:

1
public static String name = "测试类加载";

在准备阶段,JVM会在方法区中为name分配内存空间,并赋上初始值null。
给name赋上”测试类加载”是在初始化阶段完成的。

示例2:

1
public static final String name = "测试类加载";

被final修饰的常量如果有初始值,那么在编译阶段就会将初始值存入constantValue属性中,在准备阶段就将constantValue的值赋给该字段。

解析

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

初始化

初始化阶段就是执行类构造器clinit()的过程。
clinit()方法由编译器自动产生,收集类中static{}代码块中的类变量赋值语句和类中静态成员变量的赋值语句。在准备阶段,类中静态成员变量已经完成了默认初始化,而在初始化阶段,clinit()方法对静态成员变量进行显示初始化。

初始化过程的注意点

  1. clinit()方法中静态成员变量的赋值顺序是根据Java代码中成员变量的出现的顺序决定的。
  2. 静态代码块能访问出现在静态代码块之前的静态成员变量,无法访问出现在静态代码块之后的成员变量。
  3. 静态代码块能给出现在静态代码块之后的静态成员变量赋值。
  4. 构造函数init()需要显示调用父类构造函数,而类的构造函数clinit()不需要调用父类的类构造函数,因为虚拟机会确保子类的clinit()方法执行前已经执行了父类的clinit()方法。
  5. 如果一个类/接口中没有静态代码块,也没有静态成员变量的赋值操作,那么编译器就不会生成clinit()方法。
  6. 接口也需要通过clinit()方法为接口中定义的静态成员变量显示初始化。
  7. 接口中不能使用静态代码块。
  8. 接口在执行clinit()方法前,虚拟机不会确保其父接口的clinit()方法被执行,只有当父接口中的静态成员变量被使用到时才会执行父接口的clinit()方法。
  9. 虚拟机会给clinit()方法加锁,因此当多条线程同时执行某一个类的clinit()方法时,只有一个方法会被执行,其它的方法都被阻塞。并且,只要有一个clinit()方法执行完,其它的clinit()方法就不会再被执行。因此,在同一个类加载器下,同一个类只会被初始化一次。

类加载器

java.lang.ClassLoader类的基本职责就是根据一个指定的类的名称,找到或者生成其对应的字节码,然后从这些字节代码中定义出一个 Java 类,即 java.lang.Class类的一个实例。除此之外,ClassLoader还负责加载 Java 应用所需的资源,如图像文件和配置文件等。为了完成加载类的这个职责,ClassLoader提供了一系列的方法:

  • getParent() 返回该类加载器的父类加载器。
  • loadClass(String name) 加载名称为 name的类,返回的结果是 java.lang.Class类的实例。
  • findClass(String name) 查找名称为 name的类,返回的结果是 java.lang.Class类的实例。
  • findLoadedClass(String name) 查找名称为 name的已经被加载过的类,返回的结果是 java.lang.Class类的实例。
  • defineClass(String name, byte[] b, int off, int len) 把字节数组 b中的内容转换成 Java 类,返回的结果是java.lang.Class类的实例。这个方法被声明为 final的。
  • resolveClass(Class)

类加载器种类

JVM提供如下四种类加载器:

  • 启动类加载器
    负责加载Java_Home\lib中的class文件。
  • 扩展类加载器
    负责加载Java_Home\lib\ext目录下的class文件。
  • 应用程序类加载器
    负责加载用户classpath下的class文件。
  • 自定义类加载器
    类加载器种类

类加载的内存分布情况

引导类加载器(Bootstrap ClassLoader)加载系统类后,JVM内存会呈现如下格局:

  1. 引导类加载器将类信息加载到方法区中,以特定方式组织,对于某一个特定的类而言,在方法区中它应该有:运行时常量池、类型信息、字段信息、方法信息、类加载器的引用,对应class实例的引用等信息。
  2. 类加载器的引用,由于这些类是由引导类加载器(Bootstrap Classloader)进行加载的,而引导类加载器是有C++语言实现的,所以是无法访问的,故而该引用为NULL。
  3. 对应class实例的引用,类加载器在加载类信息放到方法区中后,会创建一个对应的Class 类型的实例放到堆(Heap)中, 作为开发人员访问方法区中类定义的入口和切入点。

双亲委派模型

工作过程

如果一个类加载器收到了加载类的请求,它首先将请求交由父类加载器加载;若父类加载器加载失败,当前类加载器才会自己加载类。

作用

java.lang.Object这些存放在rt.jar中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不同加载器加载的Object类都是同一个。

原理

双亲委派模型的代码在java.lang.ClassLoader类中的loadClass函数中实现,其逻辑如下:

(1)首先检查类是否被加载;
(2)若未加载,则调用父类加载器的loadClass方法;
(3)若该方法抛出ClassNotFoundException异常,则表示父类加载器无法加载,则当前类加载器调用findClass加载类;
(4)若父类加载器可以加载,则直接返回Class对象;

加载类的过程

类在加载过程中类加载器会首先委托给其它类加载器来尝试加载某个类。

这就意味着真正完成类的加载工作的类加载器和启动这个加载过程的类加载器,有可能不是同一个。

真正完成类的加载工作是通过调用 defineClass来实现的;而启动类的加载过程是通过调用 loadClass来实现的。

前者称为一个类的定义加载器defining loader),后者称为初始加载器initiating loader)。

在 Java 虚拟机判断两个类是否相同的时候,使用的是类的定义加载器。也就是说,哪个类加载器启动类的加载过程并不重要,重要的是最终定义这个类的加载器。

两种类加载器的关联之处在于:一个类的定义加载器是它引用的其它类的初始加载器。如类 com.example.Outer引用了类 com.example.Inner,则由类 com.example.Outer的定义加载器负责启动类 com.example.Inner的加载过程。方法 loadClass()抛出的是 java.lang.ClassNotFoundException异常;方法 defineClass()抛出的是 java.lang.NoClassDefFoundError异常。

类加载器在成功加载某个类之后,会把得到的 java.lang.Class类的实例缓存起来。下次再请求加载该类的时候,类加载器会直接使用缓存的类的实例,而不会尝试再次加载。也就是说,对于一个类加载器实例来说,相同全名的类只加载一次,即 loadClass方法不会被重复调用。

ClassNotFoundException

当应用程序试图使用以下方法通过字符串名加载类时,抛出该异常:

  • Class 类中的 forName 方法。
  • ClassLoader 类中的 findSystemClass 方法。
  • ClassLoader 类中的 loadClass 方法。

但是没有找到具有指定名称的类的定义。从 1.4 版本开始,此异常已经更新,以符合通用的异常链机制。

在构造时提供并通过 getException() 方法访问的“加载类时引发的可选异常”,现在被称为原因,它可以通过 Throwable.getCause() 方法以及与上面提到的“遗留方法”来访问。

NoClassDefFoundError

当 Java 虚拟机或 ClassLoader 实例试图在类的定义中加载(作为通常方法调用的一部分或者作为使用 new 表达式创建的新实例的一部分),但无法找到该类的定义时,抛出此异常。

引起NoClassDefFoundError的三种情况:

  1. JAR重复引入,版本不一致导至
  2. 打程序版本时,没有把关联类打出去(这种情况一般是) java.lang.nosuchmethoderror
  3. 还有一种情况是A引用B时,B初始化失败时也会导致以上的错误出现。

本文标题:JVM类加载机制

文章作者:王洪博

发布时间:2018年09月13日 - 12:09

最后更新:2019年09月23日 - 08:09

原始链接:http://whb1990.github.io/posts/de06556f.html

▄︻┻═┳一如果你喜欢这篇文章,请点击下方"打赏"按钮请我喝杯 ☕
0%