JVM - Class Loading Process


 

JVM - Class Loading Process

Java 虚拟机类加载过程

0x00. TOC

0x01. 概述

类加载:虚拟机把描述类的数据从字节码文件加载待内存中,并对数据进行校验、转换解析和初始化,最终形成可被虚拟机直接使用的 Java 类型的过程。

0x02. 类的生命周期

class_life

其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持 Java 语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

0x03. 加载时机

JVMS 规定有且只有下面5种发生主动引用的场景才会触发类的初始化,这意味着在此之前的加载、验证和准备也会随之发生:TOC

  • 遇到newgetstaticputstaticinvokestatic这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:
    • 使用 new 关键字实例化对象
    • 读取或设置一个类的静态字段(static 修饰,已在编译期把结果放人常量池的静态字段除外〉
    • 调用一个类的静态方法
  • 使用·java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类时,发现其父类未初始化,则需先初始化其父类。
  • 虚拟机启动时先初始化用户指定的执行主类(main 方法所在类)
  • 当使用 JDK 1.7 的动态语言支持时,若一个java.lang.invoke.MethodHandle实例最后解析为REF_getStaticREF_putStaticREF_invokeStatic的方法句柄,且该方法对应的类未初始化,则先触发其初始化。

除了以上五种虚拟机限定的触发类初始化的场景外,其他的引用类的场景都不会触发类初始化,如:

  • 尝试通过子类来引用父类中定义的静态变量,只会触发父类的初始化而不会触发子类的初始化。
  • 数组对象的初始化由 JVM 实现,不会触发数组元素类的初始化,而且会做越界检查,防止缓冲区溢出。
  • 每个类都有属于自己的运行时常量池,当类A尝试访问类B中的常量C时,javac会将常量C复制一份到类A的字节码文件中,到运行时再装入运行时常量池,所以访问某个类的常量时不会触发其初始化。

0x04 加载过程

1. 加载

查找并加载类的二进制数据加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:TOC

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

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以通过重写类加载器的loadClass()方法自定义自己的类加载器来完成加载。二进制流的加载源可以来自以下的方向:

  • 从ZIP、JAR、EAR、WAR等压缩包中获取
  • 从网络中获取,如Applet
  • 在运行时计算得到,如动态代理
  • 由其他文件生成,如由JSP文件生成对应的 Class 类

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在内存中(并未明确规定在 Java 堆中,对 HotSpot VM 而言,Class对象较为特殊,不存储在堆,而存储在方法区中)创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

2. 连接

2.1 验证

对于Java编译器对数组的长度的检查在编译器已经完成,如果发生越界将不能通过编译,这就避免了缓冲区溢出攻击,然而,由于虚拟机加载的字节码文件流可以来自任何途径,甚至可以来自人为地在语义层面上书写,如果虚拟机完全信任编译器而不进行检查的话,那么很可能出现恶意代码攻击等安全问题。TOC

另一方面,用户可能已经编译了一个叫PurchaseStockOptions的类,它是TradingClass的子类,但是TradingClass的定义可能在以一种与现有二进制文件不兼容的方式编译该类之后发生了变化。方法可能已被删除或其返回类型或修饰符已更改、字段可能更改了类型,从实例变量更改为类变量、方法或变量的访问修饰符可能已经从public更改为private

由于这些存在的问题,JVM 需要对字节码文件进行验证,以确保字节码文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的安全。验证阶段大致会完成4个阶段的检验动作:

  • 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。文件格式验证是基于字节流进行的,在这个验证阶段后,字节流才能在加载阶段中存储至内存的方法区中,而后面三个阶段都是基于方法区存储结构进行的
    • 是否以0xCAFEBABE开头
    • 主次版本号是否在当前虚拟机的处理范围之内
    • 常量池中的常量是否有不被支持的类型
  • 元数据验证:对字节码描述的信息进行语义分析(注意:对比 javac 编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:
    • 这个类是否有除了java.lang.Object之外的父类。
    • 这个类的父类是否继承了不允许被继承的类(final修饰的类)
    • 这个类不是抽象类的话,是否实现了其父类或接口之中要求实现的所有方法
  • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,这里主要是对类的方法体进行检验分析,保证被检验类的方法在运行时不会危害虚拟机安全。JDK 1.6 后使用在Code属性中增加StackMapTable属性的方法将字节码验证从类型推导改为类型检查方式。验证工作如:
    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现:在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中
    • 保证跳转指令不会跳到方法体外的字节码指令中
    • 保证方法体中的类型转换是有效的
  • 符号引用验证:符号引用验证发生在将符号引用转换为直接引用即解析阶段,所以这个验证阶段是为了确保解析动作能正确执行的。通常会检查
    • 符号引用中通过全限定名是否能找到对应的类
    • 在指定类中是否有符合方法的字段描述符以及简单名称所描述的方法和字段
    • 符号引用中的类、字段、方法的访问性是否能被当前类访问等。

验证阶段重要而非必须,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

2.2 准备

准备阶段是正式为类变量在方法区分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:TOC

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
  • 这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。假设一个类变量的定义为:public static int value = 3;那么变量 value 在准备阶段过后的初始值为 0,而不是 3,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 3 的putstatic指令是在程序编译后,存放于类构造器<clinit>()方法之中的,所以把 value 赋值为 3 的动作将在初始化阶段才会执行。
  • 如果类字段的字段属性表中存在ConstantValue属性,即同时被final static修饰,那么在准备阶段变量 value 就会被初始化为ConstValue属性所指定的值,即Javac会为 value 生成ConstValue属性,在准备阶段虚拟机会直接将ConstValue属性指定的值赋给 value 。
2.3 解析

虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

符号引用:符号引用时以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要能无歧义地定位到目标即可。引用目标不一定已经加载到内存中。在编译时,java类并不知道引用类的实际内存地址,因此只能使用符号引用来代替。

直接引用:直接引用就是能定位至对应内存位置上的,可以是直接指向目标的指针、相对偏移量 或者是一个能间接定位到目标的句柄。此时引用目标肯定已经存在在内存中。

对于除了invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存。无论是否真正执行了多次解析动作,虚拟机需要保证的是在同一个实体中。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。TOC

  • 类或接口解析:对于在类D中需要解析一个没被解析过的类或接口C的符号引用,会有以下步骤
    • 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器进行加载C,然后进行一系列包括其父类或实现的接口的加载和验证过程。一旦这个加载过程出现任何异常,解析过程就算是失败。
    • 如果C是一个数组类型,并且数组元素类型是对象,就会按照上一点规则进行加载,接着由虚拟机生成一个代表次数组维度和元素的数组对象。
    • 如果经过以上步骤没有出现异常,那么C在虚拟机中实际已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,验证D是否有对C的访问权限,若没有则会抛出java.lang.IllegalAccessError异常
  • 字段解析:要对一个没被解析过的字段进行解析的话,会先对其所在的类或接口C的符号引用进行解析,没有异常才会对字段符号引用进行解析
    • 首先会在C中字段进行扫描,如果包含简单名称与字段描述符都与目标匹配的字段,则返回该字段直接引用
    • 若没有,则会在C中实现的接口,依照按照继承关系从下向上递归查找各个接口和它的父接口,如果包含简单名称与字段描述符都与目标匹配的字段,则返回该字段直接引用
    • 若没有,如果C不是java.lang.Object的话,将会按照继承关系从下向上递归查找其父类,如果包含简单名称与字段描述符都与目标匹配的字段,则返回该字段直接引用
    • 都没有的话,查找失败抛出java.lang.NoSuchFieldError异常
    • 如果查找成功还会进行字段的访问权限验证,若没有则会抛出java.lang.IllegalAccessError异常
  • 类方法解析:类方法解析也需要先对其所属的类C的符号引用进行解析,然后对类方法符号引用进行解析,然后进行以下步骤
    • 如果在类方法表中发现class_index索引的C是一个接口,则会抛出java.lang.IncompatibleClassChangeError异常
    • 如果C是一个类,则会先在类中查找是否有简单名称和描述符匹配的,有则返回其直接引用
    • 若没有,则从其父类中递归进行查找,有则返回其直接引用
    • 如没有,则在C实现的接口列表和它们的父接口中递归查找,如有说明C是一个抽象类,则抛出java.lang.AbstractMethodError异常。
    • 都没有查找失败,抛出java.lang.NoSuchMethodError异常
    • 如果查找成功,同样的也会进行方法访问权限验证,若没有则会抛出java.lang.IllegalAccessError异常
  • 接口方法解析:接口方法解析与类方法解析类似,不过是先对其所属的接口C符号引用进行解析,然后进行以下步骤
    • 如果在接口方法表发现class_index中的索引C是一个类而不是一个接口,抛出java.lang.IncompatibleClassChangeError异常
    • 然后在接口C中进行查找是否有简单名称和描述符都匹配的方法,有则返回方法的直接引用
    • 若没有,则在接口C的父接口进行递归查找,直到java.lang.Object(包括Object),查找是否有符合的方法,有则返回直接引用
    • 都没有,则查找失败,抛出java.lang.NoSuchMethodError异常
    • 由于接口方法默认public,所以不用进行访问权限验证

3. 初始化

类初始化阶段主要是 JVM 通过执行类构造器<clinit>()方法对类进行初始化,为类变量赋予正确的初始值。关于<clinit>()方法的介绍如下:TOC

  • <clinit>()方法由 Javac 自动收集类中所有类变量的赋值动作和静态代码块中的语句合并生成,编译器收集的顺序由源文件中的语句顺序决定,所以静态代码块只能访问到在其之前的定义的类变量,之后的类变量静态代码块可以赋值不能访问。
  • <clinit>()方法非必须,当类或接口中没有类变量和静态代码块,编译器不会为期生成<clinit>()
  • 虚拟机会保证子类的<clinit>()方法前执行完成其父类的<clinit>()方法,但接口不同于类,执行接口的<clinit>()时不会先执行父接口的<clinit>()方法,只有在用到父接口的定义的变量时才会初始化。
  • 虚拟机会保证同一个类的<clinit>()方法在多线程下执行时,只会初始化一次,如果在一个类中<clinit>()方法中有耗时很长的操作,则会造成多个线程阻塞。

0x05. 类加载器

类加载器指在类加载过程中执行通过一个类的全限定名来获取描述一个类的二进制字节流的代码模块。对于两个类是否相等(包括代表类的Class对象的equals()方法,isAssignableFrom()方法,isInstance()方法的返回结果)的判断,要判断是否来源于同一个字节码文件以及是否由同一个类加载器进行加载的。

class_loader

在 Java 程序中主要会使用到以下三种系统提供的类加载器,这几种类加载器的层次关系如上右图所示:

  • 启动类加载器(Bootstrap ClassLoader):负责将存放在<JAVA_HOME>\lib目录中或者被-Xbootclasspath参数所指定的路径中,并且是被虚拟机所识别的(仅按照文件名识别,名字不符合的类库放入 lib 目录也不会被加载),注意到上图左边代码及其运行结果,并没有获取到ExtClassLoader的父加载器,原因是Bootstrap Loader是用 C++ 语言实现的,是虚拟机的一部分,无法被 Java 程序直接引用,因为找不到一个确定的返回父类加载器的方式,于是就返回 null。
  • 扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>\lib\ext目录中或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):负责加载用户类路径(ClassPath)上指定的路径,开发者若无自定义类加载器,一般默认使用该类加载器。

1. 双亲委派模型

它并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的一种类加载器实现方式。工作过程:如果一个类加载器接收到了类加载的请求,它不会马上去尝试加载这个类,而是会把这个请求委派给其父加载器去处理,直至传送到最顶层的类加载器中,只有当父加载器无法完成加载请求时,子加载器才会尝试自己处理。示例:

  • AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
  • ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
  • 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
  • ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException

使用双亲委派模型来组织类加载器的层次关系的好处是 Java 类随着它的加载器一起具备了一种带有优先级的层次关系,就像 Object 类,存放在rt.jar中无论哪一个类加载器要加载这个类都会传至最顶层的启动类加载器进行加载,这样就可以保证系统将不会存在多个不同 Object 类,导致混乱。

对于特殊情况时父加载器需要用到子加载器加载范围的类库时,Java 引用了线程上下文类加载器(Thread Context ClassLoader)。可以通过java.lang.ThreadsetContextClassLoader()方法设置这个类加载器来达成父加载器来请求子加载器去完成类加载的过程。

2. Tomcat类加载器

Tomcat 作为一个 Java Web 服务器,首先我们先看它需要解决些什么问题:TOC

  • 对于部署在同一个服务器下可能会部署多个 Web 应用,而多个应用程序可能会依赖同一个第三方类库的不同版本,所以服务器应该保证多个应用程序的类库可以互相独立使用,实现相互隔离,这是最基本的需求。
  • 而对于在同一个服务器上的两个 Web 应用程序所使用的相同版本的相同类库可以共享,不然 10 个应用程序分别使用 10 个相同版本的相同类库,同时要将这 10 个类库加载进服务器内存中,将会造成资源浪费,虚拟机的方法区也会很容易出现过度膨胀的风险。
  • 服务器还要保证自身安全不受部署的 Web 应用程序影响,基于安全考虑,服务器所使用的类库应该要与应用程序所使用的类库相互独立。
  • 服务器要支持 JSP 应用的HotSwap功能,JSP 文件最终都会被编译成 Class 文件才能被虚拟机执行,但是程序运行时修改 JSP 文件的概率也很大,所以要支持 JSP 生成类的热替换。

Tomcat 的类加载器系统就是为了针对以上问题进行设计的。Tomcat 中划分了三组目录来存放Java类库,/common/*/server/*/shared/*(在 tomcat 6 之后已经合并到根目录下的 lib 目录下),再加上 Web 应用程序中的/WebApp/WEB-INF/*中的 Java 类库一共 4 组

img

除了原来三个与默认的类加载器相同以外还有五个类加载器

  • Common类加载器:加载/common目录下的类库,Tomcat的最基本类加载器,类库可以被Tomcat和所有的Web应用程序共同使用,实现了Tomcat和Web应用的公共类库
  • Catalina类加载器:加载/server目录中的类库,Tomcat的私有类库,可被Tomcat使用,对Web应用不可见,实现了Tomcat类库与应用程序隔离的目的
  • Shared类加载器:加载/shared目录中的类库,可以对所有Web应用程序共同使用,对Tomcat自己不可见,实现了对Web应用程序公有类库的分享
  • WebApp类加载器:加载/WebApp/WEB-INF目录中的类库,类库仅对此Web应用程序可见,对Tomcat和其他Web应用程序都不可见,实现了不同Web应用程序的隔离
  • Jsp类加载器:用于加载一个JSP文件所编译出来的那一个Class,当服务器检测到JSP文件发生修改时,会替换掉目前的JasperLoder的实例,并通过再建立一个新的Jsp类加载器来实现HotSwap功能

0x06. 参考

0x07. 相关文章

Java, Security developer https://jordonyang.github.io/ Guangzhou, China 本站所有文章如未说明均为原创,请勿随意转载,如需转载请联系我 (linfengit@qq.com)