深入理解 JVM 中的类加载机制
0、引言
现如今,各种 IDE 越来越智能,我们程序员的日常开发基本上都是在 IDE 上完成的,它可以帮助我们将更多的注意力放在实际的业务处理中,随着这种安逸的编码生活的持续,我们慢慢也就忘记了代码运行的底层原理。如果不学习,好像也没啥问题,毕竟我们的关注重点是代码逻辑实现上,当出现问题了,百度,谷歌一下,或者问问公司的狠人,问题好像也能愉快的解决,自己好像也理解了似的。但事实上呢,依此周而复始,仍旧不理解,学习一门技术,只有我们真正懂得了其底层原理,才能更好的解决问题。
1、类加载概述
我们在前面几篇文章中分别讲解了类文件结构,JVM内存管理。这两篇文章详细描述了 Class 文件存储格式的具体细节及 JVM 运行时数据区。而今天这篇文章将会讲解 Class 文件中的信息进入到虚拟机中会发生什么变化。
**先来个官方叙述:**类加载是 Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化、最终形成可以被虚拟机直接使用的 Java 类型。通俗来讲,就是我们在完成代码的编写后,编译器会将我们的 java 文件编译成对应的 class 文件(二进制字节码文件),通过类载器将这些 class 的时候将其加载到 JVM 中,生成对应的 class 对象。下面,让我们详细来分析下类加载过程。
2、类加载过程
对于任意一个类,类加载过程可以分为加载
、验证
、准备
、解析
、使用
和卸载
七个阶段,如下图所示:
图中的加载
、验证
、准备
、初始化
和卸载
这五个阶段的顺序是确定的,而解析
则不一定,为了支持 java 语言的运行时绑定特性
,解析这个阶段可以发生在初始化阶段后。接下来我们详细分析类加载过程中这几个模块的作用。
2.1 加载
类加载
阶段是将字节码文件.Class 的二进制数据读入内存中的方法区中,然后在堆中创建一个Java.lang.Class对象
,对于加载阶段的任意一个类都对应着一个 Class 类型的对象,可以通过getClass()
来获取。对于确定的类 Class,无论该类生成多少个对象,其 Class 类型的对象只有一个,Class 类是整个反射的入口。
因此,在类加载阶段,Java 虚拟机主要完成以下几类任务:
通过一个类的全限定类名来获取定义此类的二进制字节流
将这个字节流所代表的的静态存储结构转化为方法区的的运行时数据结构
在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据访问的入口
2.2 验证
验证是连接阶段的第一步,其目的是为了确保 Class 文件内的字节流包含的信息符是否符合 Java 虚拟机规范的要求,保证输入的字节流不会危害到虚拟机自身的安全。我们也许会有疑问,我们印象中的 Java 语言是一门相对安全的语言啊(相比较于 C++),如单纯的使用 Java 代码是无法访问到边界以外的数据,如果我们非要这么做,编译器就会拒绝编译。但是,回到字节码层面,一切都变得不可控起来,这是因为 Class 文件可以采用很多途径来产生,并不一定要求用 Java 源码编译出来,如果 JVM 虚拟机不检查输入的字节流,对其完全信任的话,很可能就会因为载入有害的的字节流导致系统的崩溃。因此,验证阶段在类加载过程中占有很大的比重,它验证的项目可以大致分为以下几个:文件格式的验证、元数据验证、字节码验证和符号引用验证,下面我们一一介绍:
文件格式验证
文件格式的验证就是检查字节流是否符合 Class 文件格式的规范,不熟悉 Class 文件格式的可以看我的上一篇文章类文件结构,文件格式通常检查一下几个要素:
魔数,是否以 0xCAFEBABE 开头
主次版本号是否在合适的范围
常量池中的常量是否有不被支持的常量类型
指向常量的各种索引值是否有指向不存在的常量或者不符合类型的常量
...........
元数据验证
元数据的验证是对字节码描述的信息进行语义分析,验证的要素主要包含以下几点:
是否有父类,除了 Object 外,都有父类
这个类的父类是否继承被 final 修饰的类
若这个类不是抽象类,是否实现了父类中的所有方法
...........
字节码验证
字节码验证是整个验证过程中的最复杂的一个阶段,它主要通过数据流
和控制流
分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中。
保证跳转指令不会跳转到方法体以外的字节码指令上。
保证方法体中的类型转换是有效地,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象复制给子类数据类型,甚至把对象赋值给与他毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
.........
符号引用验证
符号引用验证可以看做是对类自身(常量池中的各种符号引用)的信息进行匹配性校验
,它的目的是确保解析动作能够正常执行,如果无法通过符号的引用验证,则会抛出异常。符号引用验证阶段通常需要校验以下内容:
符号引用中通过字符串描述的全限定名是否能找到对应的类。
在制定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问。
......
2.3 准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量(被static修饰的变量)
,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。这里所说的初始值“通常情况”下是数据类型的零值。
public static int number=10
类变量 number 在准备阶段值是 0 而不是 10,因为这时候尚未开始执行任何 Java 方法,而把 number 赋值为 10 的 putstatic 指令是程序被编译后,存放于类构造器()方法之中,所以把 number 赋值为 10 的动作将在初始化阶段才会执行。下表列出了所有 Java 基础类型的零值:
2.4 解析
解析阶段就是将 Class 中的常量池中的符号引用
解析为直接引用
。符号引是使用一组符号描述被引用的目标,引用的目标不一定加载到内存中;直接引用可以使直接指向目标地址的指针,相对偏移量或者间接定位到目标的句柄,有了直接引用,引用的目标一定存在在虚拟机中。主要包括四种类型引用的解析,分别是类或接口解析、字段解析、方法解析和接口方法解析。下面以字段解析和方法解析为例:
2.5 初始化
初始化是类加载过程的最后一步,到了初始化阶段,才开始正真的执行字节码文件,根据字节码文件的内容对类的各个字段进行赋值;初始化是执行类构造器()方法的过程。实际上,在连接的准备阶段,类变量已赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员自己写的逻辑去初始化类变量和其他资源,举例如下:
在准备阶段 number1 和 number2 都等于 0;在初始化阶段 number1 和 number2 分别等于 5 和 6。
总结一下初始化发生的条件:
创建一个新的对象实例时(比如 new、反射、序列化)
调用一个类型的静态方法时(即在字节码中执行 invokestatic 指令)
调用一个类型或接口的静态字段,或者对这些静态字段执行赋值操作时(即在字节码中,执行 getstatic 或者 putstatic 指令),不过用 final 修饰的静态字段除外,它被初始化为一个编译时常量表达式
调用 JavaAPI 中的反射方法时(比如调用 java.lang.Class 中的方法,或者 java.lang.reflect 包中其他类的方法)
初始化一个类的派生类时(Java 虚拟机规范明确要求初始化一个类时,它的超类必须提前完成初始化操作,接口例外)
JVM 启动包含 main 方法的启动类时。
使用阶段是当执行完初始化后,就可以根据自己的实际需要使用具体的类;当我们在程序中执行 System.exit(),加载的类会从内存中卸载,通常情况下,当程序正常执行结束后、或者发生错误而终止都会使得已加载的类对象被卸载。
通过以上的讲解,我们知道了类 Class 文件被虚拟机加载、使用直至卸载需要经历的步骤,但是我们忽略了一个非常重要的问题,类是如何被加载器加载的,加载器需要满足什么样的规律?下面我们一一来讲解。
3、类加载器与双亲委派模型
3.1 类加载器
类的加载是使用类加载器通过查询路径的方式进行的,加载阶段既可以使用虚拟机里内置的引导类加载器来完成,也可以由用户自定义类加载器来完成 Java 中的类加载器通常分为四类:启动类加载器
(Bootstrap ClassLoader)、扩展类加载器
(Extension ClassLoader)、应用程序类加载器
(Application ClassLoader)、用户自定义类加载器
(User ClassLoader)。不同的类加载器负责不同区域的类的加载。
image-20210128211514071
启动类加载器
扩展类加载器
应用程序类加载器
用户自定义类加载器
3.2 双亲委派模型
上面我们讲到不同的类加载器都有不同的加载范围,当某个类加载器要加载某个.class 文件时,它首先把这个任务委托给他的上级类加载器,递归这个操作,如果上级的类加载器没有加载,自己才会去加载这个类。因此,不同类加载器相互配合就形成类双亲委派模型。
image-20210128213431950
我们先分析以下加载流程:
我们在上图可以看到,除了启动类加载器,每一个类加载器都有一个父类加载器。当一个类加载器加载一个类时,首先会把加载动作委派给他的父加载器,如果父加载器无法完成这个加载动作时才由该类加载器进行加载。由于类加载器会向上传递加载请求,所以一个类加载时,首先尝试加载它的肯定是启动类加载器(逐级向上传递请求,直到启动类加载器,它没有父加载器),之后根据是否能加载的结果逐级让子类加载器尝试加载,直到加载成功。
双亲委派模型的作用:
防止重复加载同一个
.class
。通过委托去向上面问一问,加载过了,就不用再加载一遍。保证数据安全保证核心
.class
不能被篡改。通过委托方式,不会去篡改核心.clas
,即使篡改也不会去加载,即使加载也不会是同一个.class
对象了。不同的加载器加载同一个.class
也不是同一个Class
对象。这样保证了Class
执行安全。
参考文献:
[1]周志华.深入理解 Java 虚拟机(第三版)
[2]https://blog.csdn.net/en_joker/article/details/79959330
[3]https://www.cnblogs.com/aspirant/p/7200523.html
版权声明: 本文为 InfoQ 作者【Simon郎】的原创文章。
原文链接:【http://xie.infoq.cn/article/d883282e2f4fe322a9c9daf2c】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论