Java类的加载机制
1 概述
首先,先看如下的Java代码执行流程图
类的加载是指图中:字节码进入Java虚拟机后的一系列过程。具体是:虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
简单来说,类的加载过程(从加载到虚拟机内存到卸出):包括了加载,验证,准备,解析,初始化,使用,卸载。
其他的过程顺序都是确定的,除了解析,它在某些情况下可以在初始化阶段后开始。注意:这里的顺序指的是按顺序开始,但之后执行与完成可能不是顺序的,这些阶段通常是交叉执行的。
2 类的加载过程
2.1 加载
在这一阶段,虚拟机通常需要完成三件事情:
1)通过类的全限定名(包名+类名),获取到该类的.class
文件的二进制字节流
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
3)在Java堆中生成一个代表这个类的java.lang.Class
对象,作为方法区这些数据的访问入口
简单来说,就如下图
下面对再稍微细说下这三阶段
加载二进制数据到内存
这一阶段并没有说明从哪里获取以及怎么获取,这是交给开发人员自定义的,一般的读取方式有以下
- 从ZIP包中获取,或者JAR包(很常见现在),WAR包等
- 从网络获取,例如Applet
- 运行时计算生成,使用动态代理技术
- ……
映射jvm能够识别的结构
将映射后可以被虚拟机识别的格式存储在方法区中,这种数据存储格式是由虚拟机自行定义
在内存中生成class文件
在Java堆中实例化一个
java.lang.Class
类的对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口
2.2 验证
该阶段是连接阶段的第一步,这是要确保前面加载进来的字节流包含的数据符合要求,不会危害到虚拟机的安全
一般有以下四个阶段的检验过程
文件格式检验
字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理
。例如魔数开头是否正确,版本号是否在范围内,常量池是否有不支持的常量等元数据验证
对描述信息进行语义分析,确保描述的信息符合Java语言规范的要求
。例如该类是否有父类,是否继承了不允许被继承的类,如果不是抽象类,是否实现了该父类或接口要求实现的所有方法。字节码验证
进行数据流和控制流分析(对类的方法体进行校验分析)
。例如保证操作数栈的数据类型和指令代码序列能够配合工作,跳转指令不会跳转到方法体以外的字节码指令上,保证方法体类型转化有效。符号引用验证
这会发生在虚拟机符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段发生。
这里符号引用是指任何形式的字面量,这与内存布局无关,引用的目标不一定加载到内存中,例如,引用了
org.simple.people
,那么则使用org.simple.people
来表示该类的地址。而直接引用是则可以是指向目标的指针,相对偏移量,句柄等,该引用的目标必定存在于虚拟机内存中 通常这里要检查以下内容
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
- ….
2.3 准备
这一阶段将会为类变量分配内存并设置类变量初始值,这些内存都会在方法区中分配(JDK1.6)。注意,这里的类变量是指被static修饰的变量
,实例变量将会在对象实例化随着对象分配到Java堆中。并且,初始值是指数据类型的零值
1 |
|
这里,准备阶段后,初始值为0,不是123,将value赋值为123的操作在初始化阶段才会执行
但会存在特殊情况,如果某个变量是不可变
的,例如上述变量定义为:
1 |
|
这样,准备阶段虚拟机就会根据ConstantValue
的设置将value
赋值为123
2.4 解析
这一阶段将会将常量池内的符号引用替换为直接引用。符号引用的解析可以出现多次,虚拟机实现可能会对第一次解析的结果进行缓存,以便后续使用。
解析动作主要针对以下四类进行
- 类或接口的解析
- 字段解析
- 类方法解析
- 接口方法解析
由于这些篇幅过长,就不细说,具体查看《深入理解Java虚拟机》
2.5 初始化
这一阶段是通过程序制定的主观计划去初始化类变量和其他资源,从另一方面说,是执行类构造器<clinit>()
方法的过程。这一方法运行的行为和细节如下:
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值操作和static{}块
合并产生。<clinit>()
方法与类的构造函数不同,不需要显式调用父类构造器,父类的<clinit>()
方法已经执行完毕- 通过以上两点可知,父类定义的静态语句块要优于子类的变量赋值操作
<clinit>()
方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()
方法。- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成
<clinit>()
方法。但接口与类不同的是,执行接口的<clinit>()
方法不需要先执行父接口的<clinit>()
方法。只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。 - 虚拟机会保证一个类的
<clinit>()
方法在多线程环境中被正确地加锁和同步
3 类加载器
通过对前面类加载过程的阐述,可以发现除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。类加载器提高了JVM的可扩展性,是Java语言流行一大原因。
3.1 双亲委派模型
绝大部分Java程序都会使用到以下三种系统提供的类加载器:
- 启动类加载器(
Bootstrap ClassLoader
) - 扩展类加载器(
Extension ClassLoader
) - 应用程序类加载器(
Application ClassLoader
)
应用程序都是由这三类类加载器互相配合进行加载的,如果有必要,可以加入自定义的类加载器
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。
双亲委派模型的工作流程:
- 一个类加载器收到类加载的请求,不会首先自己尝试加载该类
- 将该请求委派给父类加载器去完成,对于每个层次的类加载器都是如此
- 所有的加载请求最终都应该传送到顶层的启动类加载器中
- 只有当父加载器反馈自己无法完成这个请求时,子加载器才会尝试自己去加载
使用该模型的好处有如下:
Java类和它的类加载器一起具备一种带有优先级的层次关系
例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序也将会变得一片混乱。如果您有
上面的好处也从一定程度上防止了危险代码的植入
参考
《深入理解Java虚拟机-第3版》
jvm类加载器,类加载机制详解,看这一篇就够了 - SegmentFault 思否
Java类加载机制(全套) - 掘金 (juejin.cn)