当前位置:   article > 正文

JVM面试(八)-类加载机制、类加载器、双亲委派_如果在jvm中存在包名和类名相同的两个类,则该类将 无法被加载,jvm也无法完成类加

如果在jvm中存在包名和类名相同的两个类,则该类将 无法被加载,jvm也无法完成类加

什么是类加载?

虚拟机 把 描述类的数据 从Class文件 加载到内存,并对数据进行 校验、转换解析、初始化,最终形成 可以被虚拟机直接使用的Java类型,这就是虚拟机的 类加载机制

类加载的时机

类型的加载、连接、初始化3个过程 都是在 程序运行期间完成的

类加载过程

JVM的类加载分为 5个阶段:加载、连接(验证、准备、解析)、卸载、使用、初始化
在这里插入图片描述
接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正用到父接口的时候(例如引用父接口定义的常量)才会初始化

加载

指的是JVM读取Class文件,并且根据Class文件描述 创建 java.lang.Class对象的过程
类加载过程 主要包含 将Class文件 读取到 运行时区域的方法区内,在堆中创建java.lang.Class对象,并 封装(动词) 类在方法区的数据结构 的过程

类加载的最终产物就是堆内存中的class对象,对同一个ClassLoader来讲,不管一个类被加载了多少次,对应到堆内存中的Class对象始终是同一个。虚拟机规范中指出了类的加载是通过一个全限定名(包名+类名)来获取二进制数据流,但是并没有限定必须通过某种方式去获得。在读取Class文件时既可以通过文件的形式读取,也可以通过jar包、war包读取,还可以通过代理自动生成Class或其他方式读取

类加载过程如下:

  • 通过一个类的 全限定名 来获取此类的 二进制字节流
  • 将这个字节流 所代表的 静态存储结构 转化为 方法区的 运行时数据结构,方法区在jdk7及以前称为永久代,JDK8及之后称为元空间
  • 在内存中 生成一个 代表这个类的 java.lang.Class类型的对象,作为 方法区 这个类的各种数据 的 访问入口
  • 数组本身不通过类加载器创建,由java虚拟机直接创建

加载.class文件方式

  • 从本地系统中直接加载
  • 通过网络获取,Web Applet
  • 从Zip压缩包中获取
  • 运行时计算生成,动态代理
  • 由其他文件生成,JSP
  • 从专有数据库中提取.class文件
  • 从加密文件中获取

连接1-验证

主要用于确保Class文件 符合 当前虚拟机的要求,保障虚拟机自身的安全,只有通过验证的Class文件才能被JVM加载
当字节流的信息不符合要求时,则会抛出VerifyError这样的异常或者是其子异常

  • 文件格式验证,验证 字节流 是否符合 Class文件格式的规范。通过这个阶段之后,字节流 才会进入 内存的方法区中进行存储,后面的3个验证 都是基于 方法区 的 存储结构进行的,不会再直接操作字节流
  • 元数据验证,对 字节码描述的信息 进行 语义验证,保证 不存在 不符合Java语言规范 的 元数据信息。
  • 字节码验证,对类的方法体进行校验,主要是验证程序的控制流程,比如循环、分支等
  • 符号引用验证,发生在虚拟机 将 符号引用 转化为 直接引用的时候。这个验证将在 连接3-解析阶段 发生。是对 类自身以外(常量池中的各种符号引用)的 信息进行匹配性校验

文件格式验证

  • 验证二进制文件的魔术因子,class文件的魔术因子是0xCAFEBABE
  • 验证主次版本号,Java的版本是在不断升级的,JVM规范同样也在不断升级,比如用高版本的JDK编译的class就不能够被低版本的JVM所兼容,在验证的过程中,还需要查看当前的class文件版本是否符合当前JDK所处理的范围
  • ·构成class文件的字节流 是否存在 残缺或者其他附加信息,主要是看class的MD5信息(每一个类在编译阶段经过MD5摘要算法计算之后,都会将结果一并附加给class字节流作为字节流的一部分)
  • 常量池中的常量 是否存在 不被支持的变量类型,比如int64
  • ·指向常量中的引用 是否 指到了 不存在的常量 或者 该常量的类型不被支持

元数据验证

  • 检查这个类是否存在父类,是否继承了某个接口,这些父类和接口是否合法,或者是否真实存在
  • 检查该类是否继承了被final修饰的类,被final修饰的类是不允许被继承并且其中的方法是不允许被override的
  • 检查该类是否为抽象类,如果不是抽象类,那么它是否实现了父类的抽象方法或者接口中的所有方法。
  • 检查方法重载的合法性,比如相同的方法名称、相同的参数但是返回类型不相同,这都是不被允许的
  • 其他语义验证

字节码验证

  • 保证当前线程 在程序计数器中的指令 不会跳转到 不合法的字节码指令中去
  • 保证类型的转换是合法的,比如用A声明的引用,不能用B进行强制类型转换
  • 保证任意时刻,虚拟机栈中的操作栈类型与指令代码都能正确地被执行,比如在压栈的时候传入的是一个A类型的引用,在使用的时候却将B类型载入了本地变量表
  • 其他验证

符号引用验证

符号引用的验证 目的是 为了保证 解析动作 的顺利执行,如果某个类的字段不存在,则会抛出NoSuchFieldError,若方法不存在时则抛出NoSuchMethodError等,在使用反射的时候会遇到这样的异常信息

  • 通过 符号引用描述的字符串全限定名称 是否 能够顺利地找到相关的类
  • 符号引用中的类、字段、方法,是否对当前类可见,比如不能访问引用类的私有方法
  • 其他

连接2-准备

  • 主要工作 是 在方法区中为 类变量 分配内存空间 并设置 类中变量的初始值
  • 初始值指不同数据类型的默认值,这里需要注意 final类型的变量 和 非final类型的变量 在准备阶段的数据初始化过程不同
  • 正式为 类变量 分配内存 并 设置 类变量初始值 的阶段,分配内存 仅包括 类变量(static),不包括实例变量,实例变量将会在 对象实例化时,随着对象一起分配在java堆中。这里的初始值 指的是 变量的零值
  • 对于final修饰的static变量,在编译时期就已经分配空间了,在准备阶段会显示初始化。例如:final static int b=10 在 类的编译阶段 javac会将其value生成一个ConstantValue属性,直接赋予10

连接3-解析

  • 所谓解析 就是 在常量池中 寻找类、接口、字段和方法的符号引用,并且将这些 符号引用 替换成 直接引用的过程。JVM会将 常量池中 的 符号引用 替换 为直接引用
  • 虚拟机规范规定了,在anewarray、checkcast、getfield、getstatic、instanceof、invok-e-in-terface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield、putstatic这13个操作符号引用的字节码指令之前,必须对所有的符号提前进行解析
  • 解析动作 主要针对 类/接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符 7类符号引用 进行处理,分别对应到常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、Constant_Methodref_info和Constant_InterfaceMethodred_info
  • 符号引用:用一组符号 来描述 所引用的目标。与虚拟机的内存布局无关,引用的目标不一定 已经加载到内存中
  • 直接引用:直接指向 目标的指针、相对偏移量、或者一个能间接定位到指针的句柄。直接引用和虚拟机内存相关,引用的目标 必定已经在 内存中存在

类/接口解析

在类接口的解析完成之后,还需要进行符号引用的验证

字段解析

所谓字段的解析,就是解析 所访问的类或者接口中的字段,在解析类或者变量的时候,如果该字段不存在,或者出现错误,就会抛出异常,不再进行下面的解析

类方法解析

类方法和接口方法有所不同,类方法可以直接使用该类进行调用,而接口方法必须要有相应的实现类继承才能够进行调用

接口方法解析

初始化

类加载过程的最后一步,执行 类 的 构造器方法<clinit>的过程,clinit是class initialize前面几个字母的简写

  • <clinit>()不是自己定义的,是javac编译器 自动收集 类中的所有类变量(指的是静态成员变量)的赋值动作 和 静态代码块中的语句 合并而来。(也就是 将 类变量的初始化 以及 静态代码块中的语句集合在<clinit>()方法中)。如果不存在 static成员变量 以及 静态代码块,就不会生成<clinit>()方法
  • <clinit>()方法中 所有的 类变量 都会被赋予正确的值,也就是在程序编写的时候指定的值
  • <clinit>()方法是在编译阶段生成的,也就是说它已经包含在了class文件中了
  • <clinit>()初始化的顺序是根据语句在源文件中出现的顺序决定的
  • <clinit>()不同于类的构造器,类的构造器 是 虚拟机视角下的<init>()方法。(java类默认都存在默认的构造方法,所以在虚拟机视角下,对于任何一个java类都会生成一个<init>()方法)
  • 若该类具有父类,JVM会保证子类的<clinit()>方法执行前先执行父类的<clinit>()方法,因此父类的静态变量总是能够得到优先赋值
  • 多线程情况下,一个类的<clinit>()方法是被同步加锁的。(一个类只会被加载一次)
  • 如果某个类中既没有静态代码块,也没有静态变量,那么它就没有生成<clinit>()方法的必要了,接口中同样也是如此
    只有当接口中有变量的初始化操作时才会生成<clinit>()方法

JVM对类的初始化是一个延迟的机制,即使用的是lazy的方式,当一个类在首次使用的时候才会被初始化,在同一个运行时包下,一个Class只会被初始化一次
虚拟机严格规定了有且只有6中情况必须要对类进行初始化

  • 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果 类没有进行初始化,则需要先触发其初始化,生成这4条指令最常见的代码场景式:使用new、读取/设置类的一个静态字段(被final修饰)、调用类的一个静态方法的时候
  • 使用reflect包的方法 对类进行反射调用的时候
  • 当初始化一个类的时候,如果发现 其父类 还没有进行过初始化,则要先触发 其父类的初始化
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个类

JVM同时规范了以下6种主动使用类的场景,具体如下:(同上)

  • 通过new关键字会导致类的初始化,这种是大家经常采用的初始化一个类的方式,它肯定会导致类的加载并且最终初始化
  • 访问类的静态变量,包括读取和更新会导致类的初始化
  • 访问类的静态方法,会导致类的初始化
  • 对某个类进行反射操作,会导致类的初始化
  • 初始化子类会导致父类的初始化
  • 启动类,也就是执行main函数所在的类会导致该类的初始化,比如使用java命令运行上文中的ActiveLoadTest类

除了上述6种情况,其余的都称为被动使用,不会导致类的加载和初始化

在发生以下几种情况时,JVM不会执行类的初始化流程

  • 常量在编译时 会将其 常量值 存入使用该常量的类的常量池中,该过程不需要调用常量所在的类,因此不会触发该常量类的初始化
  • 在子类引用父类的静态字段时,不会触发子类的初始化,只会触发父类的初始化
  • 定义对象数组,不会触发该(对象)类的初始化
  • 在 使用 类名 获取 Class对象时 不会触发类的初始化
  • 在 使用 Class.forName 加载 指定的类时,可以通过 initialize参数 设置 是否需要对类进行初始化
  • 在 使用 ClassLoader默认的loadClass方法加载类时 不会触发该类的初始化

阅读参考

JVM类的加载过程-加载,链接,初始化

类加载器

ClassLoader-类加载器的主要职责 就是 负责加载各种class文件 到JVM中
ClassLoader是一个抽象的class,给定一个class的二进制文件名,ClassLoader会尝试加载 并且 在JVM中生成构成这个类的各个数据结构,然后使其分布在JVM对应的内存区域中

JVM支持两种类型的类加载器,分别为 引导类加载器(Bootstrap ClassLoader)和 自定义类加载器(User-Defined ClassLoader)
这里的自定义加载器 指的不是开发人员自己定义的类加载器,而是指的 所有 继承自ClassLoader的类加载器。包括扩展类加载器、应用程序类加载器、用户自定义类加载器(程序员自己写的)三种

在这里插入图片描述阅读参考类加载器与类的加载过程概述(认真阅读)

启动类加载器-Bootstrap ClassLoader

负责加载Java_HOME/lib目录中的类库,或 通过-Xbootclasspath参数 指定 路径中 被虚拟机认可的类库

  • 启动类加载器 使用 C/C++语言实现,在JVM内部,无法被java程序直接引用
  • 用于加载Java核心类库(Java_HOME/lib目录中的类库)
  • 不继承ClassLoader
  • 还用于 加载 扩展类加载器 和 应用程序类加载器
  • 只加载包名为java,javax,sun开头的类
  • 可以通过系统属性System.getProperty("sun.boot.class.path")来得知当前JVM的根加载器都加载了哪些资源

扩展类加载器-Extension ClassLoader

负责加载Java_HOME/lib/ext目录中的类库,或通过java.ext.dirs系统变量加载指定路径中的类库

  • 使用java语言编写,JVM自带,sun.misc.Launcher$ExtClassLoader
  • 继承自ClassLoader
  • 父类加载器 为 启动类加载器
  • 从java.ext.dirs指定的路径下加载类库,或者从JDK安装目录的jre/lib/ext目录下加载类库
  • 如果用户自定义的jar包 放在jre/lib/ext下,也会自动由扩展类加载器加载
  • 扩展类加载器所加载的类库可以通过系统属性java.ext.dirs获得

应用程序类加载器-Application ClassLoader

负责加载 用户路径(classpath)上的类库

  • 使用java语言编写,JVM自带,AppClassLoader
  • 继承自ClassLoader
  • 父类加载器 为 扩展类加载器,同时 是 自定义类加载器 的 默认父加载器
  • 负责 加载 环境变量classpath 或 系统属性java.class.path指定的类库
  • java中自己写的类都是由应用程序类加载器加载的
  • 可以通过ClassLoader.getSystemClassLoader()方法获取该类加载器
  • 应用程序类加载器所加载的类库可以通过系统属性java.class.path进行获取

自定义的类加载器

所有的自定义类加载器都是ClassLoader的直接子类或者间接子类

为什么要自定义类加载器?

  • 隔离加载类
  • 修改 类加载的方式
  • 扩展加载源
  • 防止密码泄露

实现自定义类加载器

继承ClassLoader抽象类,覆写findClass方法,java.lang.ClassLoader是一个抽象类,它里面并没有抽象方法,但是有findClass方法,务必实现该方法,否则将会抛出Class找不到的异常
调用ClassLoader#defineClass方法 将 二进制数据 转换成 Class对象

上下文 类加载器(重要)

  • 通过Thread.currentThread().getContextClassLoader() 获取 线程上下文 的 类加载器,简单来说 就是这个线程 是由哪个类加器 加载的
  • 如果是在没有修改线程上下文类加载器的情况下,则保持与父线程同样的类加载器
  • Thread#setContextClassLoader(ClassLoader cl)设置该线程的类加载器。这个方法 可以打破 JAVA类加载器的父委托机制,有时候该方法也被称为JAVA类加载器的后门

问题:为什么setContextClassLoader可以打破双亲委派机制?

如何判断两个类相等

每一个 类加载器,都拥有一个 独立的 类命名空间。
比较 两个类 是否相等,只有在 这两个类 是由同一个类加载器 加载的前提下才有意义,然后才是完整类名必须一致,包括包名

类加载器的引用 的 保存

JVM必须知道 一个类 是由 启动类加载器加载 还是由 用户类加载器加载(除了启动类加载器之外的加载器)
如果 一个类 是由 用户类加载器加载的,那么 JVM 会将 这个类加载器的一个引用 作为类信息的一部分 保存在 方法区中
当 解析一个类型 到 另一个类型的引用 的时候,JVM 需要保证 这两个类型 的 类加载器 是相同的

类加载器的命名空间

每一个类加载器实例都有各自的命名空间,命名空间 是由 该加载器及其所有父加载器所构成的,因此在每个类加载器中同一个class都是独一无二的
使用不同的类加载器,或者 同一个类加载器的不同实例,去加载同一个class,则会在堆内存和方法区产生多个class的对象

运行时包

在JVM运行时 class会有一个 运行时包,运行时的包 是由 类加载器的命名空间 和 类的全限定名称 共同组成的
这样做的好处是出于安全和封装的考虑

初始类加载器

JVM规定了 不同的运行时包下的类 彼此之间是不可以进行访问的,那为何我们可以访问java.lang.String,毕竟它是 由启动类加载器 加载的

如果某个类C 被 类加载器CL加载,那么CL 就被称为 C 的 初始类加载器
JVM为每一个类加载器维护了一个列表,该 列表中 记录了 将该类加载器 作为 初始类加载器 的 所有class
在加载一个类时,JVM使用这些列表来判断该类是否已经被加载过了,是否需要首次加载
根据JVM规范的规定,在类的加载过程中,所有参与的类加载器,即使没有亲自加载过该类,也都会被标识为该类的初始类加载器

类的卸载

关于JVM在运行期间到底加载了多少class,可以在启动JVM时指定-verbose:class参数观察得到
JVM规定了一个Class只有在满足下面三个条件的时候才会被GC回收,也就是类被卸载

  • 该类所有的实例都已经被GC,比如Simple.class的所有Simple实例都被回收掉
  • 加载该类的ClassLoader实例被回收
  • 该类的class实例没有在其他地方被引用
    参考回收方法区-回收类

阅读参考

双亲委派机制

JVM通过双亲委派机制对类进行加载
双亲委派机制 指 一个类 在收到 类加载请求后 不会尝试自己加载这个类,而是把该类加载请求 向上委派给 其父类去完成,其父类在接收到该类加载请求后又会将其委派给自己的父类,以此类推,这样所有的类加载请求都被向上委派到启动类加载器中
若父类加载器 在接收到 类加载请求后 发现自己也无法加载该类(通常原因是该类的Class文件在父类的类加载路径中不存在),则 父类 会将该信息 反馈给子类 并向下委派 子类加载器 加载该类,直到该类被成功加载,若找不到该类,则JVM会抛出ClassNotFoud异常。

在这里插入图片描述

  1. 将 自定义加载器 挂载到 应用程序类加载器
  2. 应用程序类加载器 将 类加载请求 委托给 扩展类加载器
  3. 扩展类加载器 将 类加载请求 委托给 启动类加载器
  4. 启动类加载器 在加载路径下 查找并加载 Class文件,如果未找到目标Class文件,则交由扩展类加载器加载
  5. 扩展类加载器 在加载路径下 查找并加载 Class文件,如果未找到目标Class文件,则交由应用程序类加载器加载
  6. 应用程序类加载器 在加载路径下 查找并加载 Class文件,如果未找到目标Class文件,则交由自定义加载器加载
  7. 在自定义加载器下 查找并加载 用户指定目录下的Class文件,如果在自定义加载路径下未找到目标Class文件,则抛出ClassNotFoud异常

由于loadClass指定了resolve为false,所以不会进行 连接阶段 的执行,这也就解释了为什么通过类加载器加载类并不会导致类的初始化

双亲委派机制的核心是保障类的 唯一性 和 安全性(防止核心API被篡改)。例如在加载rt.jar包中的java.lang.Object类时,无论是哪个类加载器加载这个类,最终都将类加载请求委托给启动类加载器加载,这样就保证了类加载的唯一性。如果在JVM中存在包名和类名相同的两个类,则该类将无法被加载,JVM也无法完成类加载流程

阅读参考JVM双亲委派机制

破坏双亲委派机制(重要)

JNDI是Java的标准服务,它的代码由启动类加载器去加载(在JDK1.3时放进rt.jar)
但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码
但启动类加载器不可能“认识”之些代码,该怎么办?
为了解决这个困境,只好引入了一个不太优雅的设计:线程上下文件类加载器(Thread Context ClassLoader)
这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置
如果创建线程时 还未设置,它将会从父线程中继承一个
如果在应用程序的全局范围内都没有设置过,那么这个类加载器 默认就是 应用程序类加载器
有了线程上下文类加载器,JNDI服务 使用 这个线程上下文类加载器 去加载 所需要的SPI代码,也就是 父类加载器 请求 子类加载器 去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型
Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等

OSGI(待完善)

Open Service Gateway Initiative

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/菜鸟追梦旅行/article/detail/291119
推荐阅读
相关标签
  

闽ICP备14008679号