Java 虚拟机类加载机制

基础知识

运行时数据区
  • 静态存储区(方法区):程序运行期间均存在,主要用于存放静态数据、常量和方法、类
  • 栈区:当方法执行时,在栈区内存中创建方法的局部变量,方法结束后自动释放变量占用的内存
  • 堆区:存放 new 出来的对象,由 Java 垃圾回收器进行回收

Java_Memory

Java 中的绑定

通常把一个方法的调用与方法的类(方法主体)关联起来,称为绑定,绑定又分为静态绑定和动态绑定。

  • 静态绑定:前期绑定,在程序执行前就已经绑定,由编译器(或具有相似功能的编译工具)实现,针对 Java ,理解为程序编译期间的绑定,其中之后 final、static、private 和构造方法是前期绑定。
  • 动态绑定:晚期绑定、运行时绑定,在运行时根据具体对象的类型进行绑定,在 Java 中,几乎所有的方法都是后期绑定。

类加载过程

一个类从加载到虚拟机开始,到卸载出内存,整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载七个阶段。

其中类的加载过程包含:加载、验证、准备、解析、初始化五个阶段。五个阶段中,加载、验证、准备和初始化的顺序是确定的,而解析时间则并不固定:

  • 解析可以在初始化之后开始,作用是为了支持 Java 语言中的运行时绑定(也称为动态绑定或晚期绑定)
  • 上述阶段是按照顺序开始 ,不是按照顺序进行完成,因为这些阶段通常是相互交叉混合进行,在一个阶段执行的过程中调用或激活另一阶段。

所谓类加载,因为在编写 Java 程序之后,会通过编译器得到一个 class 文件,类加载所做的就是将描述类的数据从 class 文件中加载到内存里,并对数据进行校验、转换、解析和初始化,最终形成被虚拟机直接使用的 Java 类型。

加载

加载为类加载过程的第一个阶段,在加载阶段,虚拟机主要工作:

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

第一步,通过一个类的全限定名来获取此类的二进制字节流,这里是各种 Java 技术能够实现的基础,这一步拥有很大的空间,比如可以通过 zip 包中获取,而后 jar 乃至网络都可以获取 applet 类,或者动态代理,运行时再生成特定的 class 二进制流,或者由其他文件生成,比如 JSP ,由 JSP 文件生成对应的 class 文件。

所以,为什么会出现如此种类繁多的加载技术?原因是 Java 中通过一个类的全限定名来获取定义此类的二进制字节流这一动作放到 Java 虚拟机外部去实现,为了方便让应用自己去决定如何获取所需的类,实现这个动作的功能即常见的类加载器。

类加载器 功能为获取 class 文件,并且将其转换为 class 对象,因为 class 对象是由类加载器得到的,类加载器只用于实现类加载动作,但是在 Java 中由类加载器加载过的类,由类加载器来确定其在 Java 虚拟机中的唯一性,所以如果比较两个类是否相等,那么必须在同一个类加载器的前提下(equals、instanceOf 等判断)。

JDK 中有着很多的类,程序开发又会新建许多自己编写的类,而这些并不是同一个类加载器加载的。

从开发者的角度,Java 类加载器有以下几类:

  • 启动类加载器

    Bootstrap ClassLoader

    负责加载存放在 JDK\jre\lib ( JDK 代表 JDK 的安装目录,下同)、被 -Xbootclasspath 参数指定的路径中的,并且能被虚拟机识别的类库(如 rt.jar,所有的 java.* 开头的类均被 Bootstrap ClassLoader 加载)

    启动类加载器是无法被Java程序直接引用的。

  • 扩展类加载器

    Extension ClassLoader

    该加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 JDK\jre\lib\ext 目录中,或者由 java.ext.dirs 系统变量指定的路径中的所有类库(如 javax.* 开头的类)

    开发者可以直接使用扩展类加载器。

  • 应用程序类加载器

    Application ClassLoader

    该类加载器由 sun.misc.Launcher$AppClassLoader 来实现,它负责加载用户类路径(ClassPath)所指定的类。

    开发者可以直接使用该类加载器。

    如果应用程序中没有自定义过自己的类加载器,一般情况会使用程序中默认的类加载器。

应用程序是由三种类加载器相互配合进行加载的,如果有必要,可以加入自定义的类加载器,因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的 Java class 文件,如果需要从网络或者数据库等地方加载,便需要自定义的类加载器。

双亲委派模型

bootloader

双亲委派模型工作流程

如果一个类加载器收到了类加载的过程,首先不会去尝试加载这个类,而是把这个类委派给自己的父类加载器去完成,每个层次的加载器都是如此,因为所有类的加载请求都会最终传到顶层的启动类加载器中,只有父类加载器无法完成此加载请求时,子加载器才回去尝试自己加载。

设计目的 在于:保持类在整个 JVM 的唯一性,只有同一个类加载器加载出来的类才是相等的,如果写一个 Object 类,而不采用双亲委派模型去加载,那么会加载出很多不同的 Object 类。

验证

验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同虚拟机对验证的实现会有所不同,但是大致阶段为以下四个:

  • 文件格式验证:验证字节流是否符合 Class 文件格式的规范,并且能被当前虚拟机所处理,目的为保证输入的字节流能正确的解析并存储于方法区之内。

    有文件头、主次版本验证等等。

    经过该阶段的验证,字节流才会进入内存的方法区存储,而后的三个验证均为建立在该验证之上。

  • 元数据验证:对类的元数据进行语义校验(对类中的个数据类型进行语法校验),保证不存在不符合 Java 语法规范的元数据信息。

    主要验证点:

    • 类是否有父类
    • 是否继承了不允许被继承的类(final修饰过的类)
    • 如果这个类不是抽象类,是否实现其父类或接口中所有要求实现的方法
    • 类中的字段、方法是否与父类产生矛盾(如:覆盖父类final类型的字段,或者不符合个则的方法)
  • 字节码验证:进行数据量和控制流分析,对类的方法进行校验,确保程序语义是合法的,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。

  • 符号引用验证:验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用时候(解析阶段中发生该转化),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性校验。

    主要方面:

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

准备阶段是正式为类和变量分配内存并设置变量初始值的阶段,将会在内存的方法区中分配。

例如:public static int value=123; 初始后为 value=0;

​ 对于static final类型,在准备阶段会被赋予正确的值

​ public static final value=123; 初始化为 value=123;

​ boolean 值默认为 false

​ 对象引用默认为 null ……

Not而是:

该阶段只设置类中的静态变量(方法区中),不包括实例变量(堆内存中),实例变量是在对象实例化的时候初始化分配值的

解析

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

  1. 符号引用:简单的理解就是字符串,比如引用一个类,java.util.ArrayList 这就是一个符号引用,符号引用的对象不一定被加载。

    符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中,各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。

  2. 直接引用:指针或者地址偏移量。引用对象一定在内存(已经加载)

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

对同一个符号引用进行多次解析请求时很常见的,虚拟机实现可能会对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标示为已解析状态),从而避免解析动作重复进行。

解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中一下四种常量类型:

  • CONSTANT_Class_info
  • CONSTANT_Fieldref_info
  • CONSTANT_Methodref_info
  • CONSTANT_InterfaceMethodref_info
初始化

上述过程,除了类加载阶段外,其余的均是由 JVM 主导,初始化阶段是真正执行类中定义的 Java 程序代码。

准备阶段,类变量已经被赋予过一次初始值,在初始化阶段,则是根据程序员指定的主观计划去初始化类变量和其他资源,从另一个角度来表达:初始化阶段是执行类构造器<clinit>() 方法的过程。<clinit>() 是由编译器自动收集类中类变量的赋值操作和静态语句合并而成,虚拟机保证 <clinit>() 方法执行之前,父类的 <clinit>() 已经执行完毕,如果一个类中没有静态变量赋值也没有静态语句块,编译器可以不为这个类生成 <clinit>() 方法。

以下情况不会进行类初始化:

  • 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化

  • 定义对象数组,不会触发该类的初始化

  • 被动引用:

    • 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义该常量所在的类。
  • 通过类名获取 Class 对象,不会触发该类的初始化
  • 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false,也不会触发类的初始化,该参数也是在告诉虚拟机,是否对类进行初始化。

  • 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。

使用与卸载

在执行完初始化操作后,类对象会被使用,即在程序中被引用之类操作,关于使用的内容多数是看开发人员如何设计,而卸载,则需要满足以下的条件:

  1. 该类在堆中的所有实例都已经被回收,即在堆中不存在该类的实例对象。
  2. 加载该类的 classLoader 已经被回收
  3. 该类对应的 Class 对象没有任何地方可以被引用,通过反射访问不到该 Class 对象

满足以上的条件,JVM 会在 GC 的时候,对类进行卸载,即在方法区清除类的信息。

Java 对象基本是在 JVM 的堆区创建,在创建之前,会触发类的加载、验证、准备、解析初始化等过程,当类的初始化完成后,根据类信息在堆区实例化对象,初始化非静态变量、非静态代码及默认构造方法,当对象使用完后会在合适的时候被 JVM 的垃圾回收器回收。

坚持原创技术分享,您的支持将鼓励我继续创作!
0%