写点什么

【死磕 JVM】五年 整整五年了 该知道 JVM 加载机制了!

用户头像
牧小农
关注
发布于: 2021 年 02 月 28 日
【死磕JVM】五年 整整五年了 该知道JVM加载机制了!

类加载

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


和那些编译时需要连接工作的语言不同,在 Java 语言里,类型的加载,连接和初始化过程都是在程序 运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为 java 应用程序提供比较高的灵活性。


当我们使用到某个类的时候,如果这个类还未从磁盘上加载到内存中,JVM 就会通过三步走策略(加载、连接、初始化)来对这个类进行初始化,JVM 完成这三个步骤的名称,就叫做类加载或者类初始化



类加载的时机

什么情况下需要开始类加载的第一个阶段——加载 ,在 Java 虚拟机规范中没有进行强制约束,而是交给虚拟机的具体实现来进行把握,但是对于初始化阶段,虚拟机规范严格规定了 “有且只有” 五种情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前开始),具体情况如下所示:


class 文件的加载时机:


序号 | 内容

-------- | -----

1 | 遇到 new、getstatic、putstatic、或 invokestatic 这四条字节码指令

2 | 使用 java.lang.reflect 包的方法对类进行反射调用的时候

3 | 初始化类时,父类没有被初始化,先初始化父类

4 | 虚拟机启动时,用户指定的主类(包含 main()的那个类)

5 | 当使用 JDK1.7 动态语言支持的时,如果一个 java.lang.invoke.MethodHandle 实例最后解析的结果 REFgetStatic、REFputStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄锁对应的类没有进行过初始化时


关于序号 1 的详细解释:


  1. 使用 new 关键字实例化对象时

  2. 读取类的静态变量时(被 final 修饰,已在编译期把结果放入常量池的静态字段除外)

  3. 设置类的静态变量时

  4. 调用一个类的静态方法时


注意: ``newarray`指令触发的只是数组类型本身的初始化,而不会导致其相关类型的初始化,比如,`new String[]`只会直接触发 `String[] `类的初始化,也就是触发对类`[Ljava.lang.String`的初始化,而直接不会触发`String``类的初始化。


生成这四条指令最常见的 Java 代码场景是:


对于这 5 种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:“有且只有”,这 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为 被动引用


需要特别指出的是,类的实例化和类的初始化是两个完全不同的概念:

  • 类的实例化是指创建一个类的实例(对象)的过程;

  • 类的初始化是指为类各个成员赋初始值的过程,是类生命周期中的一个阶段;


被动引用的三个场景:


  1. 通过子类引用父类的静态字段,不会导致子类初始化

/** * @program: jvm * @ClassName Test1 * @Description:通过子类引用父类的静态字段,不会导致子类初始化 * @author: 牧小农 * @create: 2021-02-27 11:42 * @Version 1.0 **/public class Test1 {
static { System.out.println("Init Superclass!!!"); }
public static void main(String[] args) { int x = Son.count; }
}
class Father extends Test1{ static int count = 1; static { System.out.println("Init father!!!"); }}
class Son extends Father{ static { System.out.println("Init son!!!"); }}
复制代码

输出:

Init Superclass!!!Init father!!!
复制代码

对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证,在虚拟机中并未明确规定,这点取决于虚拟机的具体实现。对于 Sun HotSpot 虚拟机来说,可通过-XX:+TraceClassLoading 参数观察到此操作会导致子类的加载。


上面的案例中,由于 count 字段是在 Father 类中定义的,因此该类会被初始化,此外,在初始化类 Father 的时候,虚拟机发现其父类 Test1 还没被初始化,因此虚拟机将先初始化其父类 Test1 ,然后初始化子类 Father,而 Son 始终不会被初始化;


  1. 通过数组定义来引用类,不会触发此类的初始化


/** * @program: jvm * @ClassName Test2 * @description: * @author: muxiaonong * @create: 2021-02-27 12:03 * @Version 1.0 **/public class Test2 {
public static void main(String[] args) { M[] m = new M[8]; }
}
class M{ static { System.out.println("Init M!!!"); }}
复制代码


运行之后我们会发现没有输出 "Init M!!!",说明没有触发类的初始化阶段


  1. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

/** * @program: jvm * @ClassName Test3 * @description: * @author: muxiaonong * @create: 2021-02-27 12:05 * @Version 1.0 **/public class Test3 {
public static void main(String[] args) { System.out.println(ConstClass.COUNT); }
}
class ConstClass{ static final int COUNT = 1; static{ System.out.println("Init ConstClass!!!"); }}
复制代码

上面代码运行后也没有输出 ``Init ConstClass!!!`,这是因为虽然在Java源码中引用了ConstClass 类中的常量COUNT ,但其实在编译阶段通过常量传播优化,已经将常量的值 `"1"``存储到 Test3 常量池中了,对常量 ConstClass.COUNT 的引用实际都被转化为 Test3 类对自身常量池的引用了,也就是说,实际上 Test3 的 Class 文件之中并没有 ConstClass 类的符号引用入口,这两个类在编译为 Class 文件之后就不存在关系

类加载过程

有一个名叫 Class 文件,它静静的躺在了硬盘上,吃香的喝辣的,他究竟需要一个怎么样的过程经历了什么,才能够从舒服的硬盘中到内存中呢?class 进入内存总共有三大步。


  • 加载(Loading)

  • 连接(Linking)

  • 初始化(Initlalizing)


1、加载

加载 是 类加载(Class Loading) 过程的一个阶段,加载 是 类加载(Class Loading) 过程的一个阶段,加载是指将当前类的 class 文件读入内存中,并且创建一个 ``java.lang.Class`的对象,也就是说,当程序中使用任何类的时候,系统都会创建一个叫 `java.lang.Class``对象


在加载阶段,虚拟机需要完成以下三个事情:

  1. 通过一个类的全限定名类获取定义此类的二进制字节流(没有指明只能从一个 Class 文件中获取,可以从其他渠道,如:网络、动态生成、数据库等)

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

  3. 在内存中生成一个代表这个类的``java.lang.Class``对象,作为方法区这个类的各种数据的访问入口


 类加载器通常无须等到“首次使用”该类时才加载该类,Java 虚拟机规范允许系统预先加载某些类。加载阶段与连接阶段的部分内容是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在夹在阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。


2、连接

当类被加载之后,系统会生成一个对应的 Class 对象,就会进入 连接阶段,连接阶段负责把类的二进制数据合并到 JRE 中,连接阶段又分为三个小阶段


###### 1.1 验证

验证是连接阶段的第一步,这一阶段的主要目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。Java 语言相对于 C/C++ 来说本身是相对安全的语言,验证阶段是非常重要的,这个阶段是否严谨,决定了 Java 虚拟机能不能承受恶意代码的攻击,当验证输入的字节流不符合 Class 文件格式的约束时,虚拟机会抛出一个 ``java.lang.VerifyError``异常或者子类异常,从大体来说验证主要分为四个校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证


文件格式验证: 主要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。主要包含以下几个方面:

  • 文件格式是否以 ``CAFEBABE``开头

  • 主次版本是否在虚拟机处理的范围内

  • 常量池的常量是否有不被支持的常量类型

  • 指向常量的各种索引值是否有指向不存在的常量或者不符合类型的常量

  • CONSTANTUtf8info 型的常量是否有不符合 UTF8 编码的数据

  • Class 文件中各个部分及文件本身是否有被删除的活附件的信息


元数据验证: 主要是对字节码描述的信息进行语义分析,主要目的是对类的元数据进行语义校验,分析是否符合 Java 的 语言语法的规范,保证不存在不符合 Java 语言的规范的元数据的信息,该阶段主要验证的方面包含以下几个方面:

  • 这个类是否有父类(除 java.lang.Object)

  • 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)

  • 如果这个类不是抽象类,是否实现了父类或接口之中要求的所有方法

  • 类中的字段、方法是否和父类产生矛盾


字节码验证: 最重要也是最复杂的校验环节,通过数据流和控制流分析程序语义是否合法、符合逻辑的。主要针对类的方法体进行校验分析,保证被校验的类在运行时不会危害虚拟机安全的事情


  • 保证任何时候操作数栈的数据类型和指令代码序列都能配合工作(例如在操作栈上有一个 int 类型的数据,保证不会在使用的时候按照 long 类型来加载到本地变量表中)

  • 跳转指令不会条状到方法体以外的字节码指令上

  • 保证方法体中的数据转换是有效的,例如可以把一个子类对象赋值给父类数据类型,但是不能把父类赋值给子类数据类型


符号引用验证: 针对符号引用转换直接引用的时候,这个装换工作会在第三阶段(字节码验证)解析阶段中发生。主要是保证引用一定会被访问到,不会出现类无法访问的问题。


###### 1.2 准备

为类变量 分配内存并设置类变量初始值的阶段,这些变量所使用的内存都会在方法区进行分配,在准备阶段是把 class 文件静态变量赋默认值,注意:不是赋初始值,比如我们 ``public static int i = 8 ``,在这个步骤 并不是把 i 赋值成 8 ,而是先赋值为 0


基本类型的默认值:


数据类型| 默认值

-------- | -----

int| 0

long | 0L

short | (short)0

char| '\u0000'

byte| (byte)0

boolean| false

float| 0.0f

double| 0.0d

reference| null


在通常情况下初始值是 0,但是如果我们把上面的常量加一个 final 类修饰的话,那么这个时候初始值就会编程我们指定的值 ``public static final int i = 8 ``

编译的时候 Javac 会把 i 的初始值变为 8,


###### 1.3 解析

把 class 文件常量池里面用到的符号引用转换为直接内存地址,直接可以访问到的内容

符号引用:以一组符号来描述所引用的目标,符号可以是任何字面形式的字面量,只要不会出现冲突能够定位到就可以

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


3、初始化

初始化是给类的静态变量赋正确的初始值,刚才我们有讲到准备阶段是复制默认值,而初始化是给静态变量赋值初始值,看下面的语句:

``public static int i = 8``


首先字节码文件被加载到内存后,先进行连接验证,通过准备阶段,给 i 分配内存,因为是 static,所以这个时候 i 等于 int 类型的默认初始值是 0,所以 i 现在是 0,到了初始化的时候,才会真正把 i 赋值为 8


类加载器

类加载器负责加载所有的类,并且为载入内存中的类生成一个 java.lang.Class 实例对象,如果一个类被加载到 JVM 中后,同一个类不会再次被载入,就像对象有一个唯一的标识,同样载入的 JVM 的类也有一个唯一的标识。JVM 本身有一个类加载器的层次,这个类加载器本身就是一个普通的 Class,所有的 Class 都是被类加载器加载到内存中,我们可以称之为 ClassLoader,一个顶级的父类,也是一个 abstract 抽象类。

Bootstrap: 类加载器的加载过程,分成不同的层次来进行加载,不同的类加载器加载不同的 Class,作为最顶层的 Bootstrap,它加载 lib 里 JDK 最核心的内容,比如说 rt.jar charset.jar 等核心类,当我们调用 getClassLoader()拿到这个加载器结果是一个 Null 的时候,代表我们已经达到了最顶层的加载器


Extension: Extension 加载器扩展类,加载扩展包里的各种各样的文件,这些扩展包在 JDK 安装目录 jre/lib/ext 下的 jar


App: 就是我们平时用到的 application ,用来加载 classpath 指定的内容


Custom ClassLoader: 自定义 ClassLoader,加载自己自定义的加载器 Custom ClassLoader 的父类加载器是 application 的父类加载器是 Extension 的父类加载器是 Bootstrap


注意:他们不是继承关系,而是委托关系


public class ClassLoaderTest {    public static void main(String[] args) {        // 查看是谁Load到内存的,执行结果是null,因为Bootstrap使用C++实现的        // 在Java里面没有class和它对应        System.out.println(String.class.getClassLoader());
//这个是核心类库某个包里的类执行,执行结果是Null,因为该类也是被Bootstrap加载的 System.out.println(sun.awt.HKSCS.class.getClassLoader());
//这个类是位于ext目录下某个jar文件里面,当我们调用他执行结果就是sun.misc.Launcher$ExtClassLoader@a09ee92 System.out.println(sun.net.spi.nameservice.dns.DNSNameService.class.getClassLoader());
// 这个是我们自己写的ClassLoad加载器,由sun.misc.Launcher$AppClassLoader@18b4aac2加载 System.out.println(ClassLoaderTest.class.getClassLoader());
// 是Exe的ClassLoader 调用它的getclass(),它本身也是一个class,调用它的getClassLoader,他的ClassLoader的ClassLoader就是我们的Bootstrap所以结果为Null System.out.println(sun.net.spi.nameservice.dns.DNSNameService.class.getClassLoader().getClass().getClassLoader()); }}
复制代码


类加载器继承关系

这个图讲的是 ClassLoader 从语法上是从谁继承的,这个图只是单纯的一个语法关系,不是继承关系,大家可以记住,和上面的类加载没有一点关系,过分的大家其实可以忽略这个图


双亲委派

父加载器: 父加载器不是"类加载器的加载器",也不是"类加载器的父类加载器"

双亲委派是一个孩子向父亲的方向,然后父亲向孩子方向的双亲委派过程


当一个类加载器收到了类加载请求时候,他会先尝试从自定义里面去找,同时它内部还维护了缓存,如果在缓存中找到了就直接返回结果,如果没有找到,就向父类进行委托,父类再去缓存中找,一直到最顶级的父类,如果这个时候还没有从缓存中获取到我们想要的结果,这个时候父亲就说我你这个事情,我办不了,你要自己动,然后儿子就自己去查询对应的 class 类并加载,如果到了最小的一个儿子还是没有找到对应的类,就会抛出异常 Class Not Found Exception


为什么要弄双亲委派?


这个是类加载器必问的一个面试题。


主要为了安全,如果任何一个 Class 都可以把他 load 到内存中的话,那么我写一个 java.lang.String,如果我写入了有危险的代码,是不是就会发生安全问题,并且可以保证 Java 核心 api 中定义的类型不会被随意替换,可以防止 API 内库被随意更改,其次是效率问题,如果有缓存在,直接从缓存里面拿,就不用一遍一遍的去遍历查询我们的父类或者子类了。


原创不易,一键三连是个好习惯!


我是牧小农,怕什么真理无穷,进一步有进一步的欢喜,大家加油!!!


发布于: 2021 年 02 月 28 日阅读数: 19
用户头像

牧小农

关注

业精于勤,荒于嬉;行成于思,毁于随! 2019.02.13 加入

业精于勤荒于嬉,行成于思毁于随

评论

发布
暂无评论
【死磕JVM】五年 整整五年了 该知道JVM加载机制了!