【JVM学习】6.玩转类加载与类加载器


0 前言

【JVM学习】3.JVM中的对象一文中,我们给出了一个JVM对象的详细创建流程,整个流程图如下:

JVM对象的创建过程

本章将会对类的加载过程及常见的类加载器做出详细梳理和介绍。

1 类的生命周期

从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:

  • 加载(Loading)
  • 验证(Verification)
  • 准备(Preparation)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

7 个阶段。其中验证准备解析3个部分统称为连接(Linking)。

加载验证准备初始化卸载这五个阶段的顺序确定的,但是对于解析阶段则不一定,它在某些情况下可以在初始化之后再开始,这样做是为了支持java的运行时绑定特征(也称为动态绑定晚期绑定)。

1.1 加载

1.1.1 时机

关于在什么情况下需要开始类加载过程的第一个阶段加载《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前开始):

  • 遇到newgetstaticputstaticinvokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。
  • 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
  • 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  • 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  • 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

1.1.2 干什么?

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

1.2 连接

1.2.1 验证

验证是连接阶段的第一步,这一阶段的目的

确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

从整体上看,验证阶段大致上会完成下面四个阶段的检验动作(FMBS):

1.2.1.1 文件格式验证(File Verification,FV)

  • 主要目的:保证输入的字节流能正确地解析存储于方法区之内,格式上符合描述一个Java类型信息的要求。
  • 验证点:
    • 是否以魔数0xCAFEBABE开头。
    • 主、次版本号是否在当前Java虚拟机接受范围之内。
    • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
    • 指向常量的各种索引值中是否有指向不存在的常量不符合类型的常量
    • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据。
    • Class文件中各个部分及文件本身是否有被删除的附加的其他信息。
    • ……

1.2.1.2 元数据验证(Metadata Verification,MV)

  • 主要目的:对类的元数据信息进行语义校验对元数据信息中的数据类型校验),保证不存在与《Java语言规范》定义相悖的元数据信息。
  • 验证点:
    • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
    • 这个类的父类是否继承不允许被继承的类(被final修饰的类)。
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
    • 类中的字段方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
    • ……

1.2.1.3 字节码验证(Bytecode Verification,BV)

  • 主要目的:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。(对类的方法体(Class文件中的Code属性)进行校验分析
  • 验证点:
    • 保证任意时刻操作数栈的数据类型指令代码序列都能配合工作
    • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
    • 保证方法体中的类型转换总是有效的
    • ……

1.2.1.4 符号引用验证(Symbol Verification,SV)

最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。

  • 主要目的:确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常(对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验
  • 验证点:
    • 符号引用中通过字符串描述的全限定名是否能找到对应的
    • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
    • 符号引用中的字段方法的可访问性(privateprotectedpublic<package>)是否可被当前类访问。
    • ……

1.2.2 准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存设置类变量初始值的阶段。

注意:

这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
其次是这里所说的初始值“通常情况”下是数据类型的零值
“特殊情况”:基本类型的常量直接赋值,如:public static final int value = 123,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123

1.2.3 解析

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

符号引用(Symbolic References):是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。比如

  • 类和接口的全限定名
  • 字段名称和描述符
  • 方法名称和描述符
  • 方法句柄和方法类型
  • 动态调用点和动态常量
  • ……

等都是符号引用

直接引用(Direct References):直接引用是可以直接指向目标的指针相对偏移量或者是一个能间接定位到目标的句柄

1.3 初始化

类的初始化阶段是类加载过程的最后一个步骤。其主要工作就是:

根据程序猿通过程序制定的主观计划初始化类变量和其他资源,或者说:初始化阶段是执行类构造器<clinit>()方法的过程.

1.4 使用

略。

1.5 卸载

略。

2 类加载器

除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。

实现类加载的代码即为类加载器

类加载器做的就是上面 5 个步骤的事(加载、验证、准备、解析、初始化)。

2.1 JDK 提供的三层类加载器

2.1.1 Bootstrap ClassLoader

加载核心类库,也就是 rt.jarresources.jarcharsets.jar等。当然这些 jar包的路径是可以指定的,-Xbootclasspath参数可以完成指定操作。

这个加载器是C++编写的,随着JVM启动。

2.1.2 Extention ClassLoader

扩展类加载器,主要用于加载lib/ext目录下的jar包.class文件。同样的,通过系统变量java.ext.dirs可以指定这个目录。

这个加载器是个Java类,继承自URLClassLoader

2.1.3 Application/System ClassLoader

程序员自己写的Java 类默认加载器。一般用来加载classpath下的其他所有jar包.class文件,我们写的代码,会首先尝试使用这个类加载器进行加载。

2.1.4 Custom ClassLoader

自定义加载器,支持一些个性化的扩展功能。

2.2 类加载器的问题

如果你在项目代码里,写一个java.lang的包,然后改写String类的一些行为,编译后,发现并不能生效。JRE的类当然不能轻易被覆盖,否则会被别有用心的人利用,这就太危险了

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:

比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等
这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

2.3 双亲委派

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器

这里类加载器之间的父子关系一般不会继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

双亲委派模型

2.3.1 工作过程

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

子类加载器加载之前,有限询问父级类加载器是否加载过,父类未加载过在进行加载.

2.3.2 解决的问题

  • 类重复加载
  • 避免java核心API被篡改

2.3.2 破坏双亲委派模型

2.3.2.1 Tomcat 类加载机制

Tomcat类加载器的层次结构

对于一些需要加载的非基础类,会由一个叫作WebAppClassLoader的类加载器优先加载。等它加载不到的时候,再交给上层的ClassLoader进行加载。这个加载器用来隔绝不同应用的.class 文件,比如你的两个应用,可能会依赖同一个第三方的不同版本,它们是相互没有影响的。

如何在同一个JVM里,运行着不兼容的两个版本,当然是需要自定义加载器才能完成的事。

那么tomcat是怎么打破双亲委派机制的呢?可以看图中的WebAppClassLoader,它加载自己目录下的.class文件并不会传递给父类的加载器

2.3.2.2 SPI

全称是Service Provider Interface,是Java提供的一套用来被第三方实现或者扩展的API,它可以用来启用框架扩展和替换组件。

JNDI已经是JAVA的标准服务,其存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码。

即:使用线程上下文类加载器(Thread Context ClassLoader)去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则。

2.3.2.3 OSGi

OSGi实现模块化热部署。其关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。


文章作者: Kezade
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Kezade !
评论
  目录