@[toc] 有小伙伴最近在面试过程中遇到这样一个问题:
Java 中的类是如何加载的?
这个问题还是很有意思,今天松哥来尝试和大伙梳理一下。
一 整体思路
整体上来说,类的加载主要是下面这几个步骤:
上面这张图就是一个类的完整生命周期了,一共要经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)七个不同的步骤。
这七个步骤中,验证、准备和解析一般又统一称之为 Linking。
这是整体的流程,接下来,松哥就和大家来分析每一个具体的步骤都干了啥。
二 Loading
首先第一步 Loading,也就是加载类。
这里如果被面试官细问,有两个方向:
- 什么时候加载?
- 怎么加载?
2.1 类的加载时机
先说类的加载时机。
如果需要一个权威的文档来说明问题,抱歉,官方没有任何文档来说明类在什么时候会被加载。但是,官方文档给出了六种类必须进行初始化的场景,毫无疑问,如果需要对类进行初始化,那么就必须先 Loading。
这六种场景分别是:
- new 一个类或者使用某一个类的静态属性/静态方法,给某个类的静态属性赋值等等,不过对于被 final 修饰的的 static 变量除外。
- 通过反射调用某个类的时候。
- 当要初始化某个类,但是发现其父类尚未初始化,那么就要去初始化父类(如果一个接口在初始化的时候发现其父类未初始化,这个时候并不会初始化其父类,只有在真正用到了其父类的时候,才会初始化)。
- main 方法所在的主类。
- 对于含有 default 方法的接口,如果该接口的实现类需要进行初始化,那么就会触发该类的加载。
- 最后一种情况和动态语言相关的,跟我们 Java 关系不大,这里就不讨论了(因为 Java 虚拟机不仅能跑 Java,也能跑 Groovy、Kotlin 等,所以虚拟机支持的内容会更加广泛一些)。
只有这六种场景会触发类的初始化,凡是不符合这六种情况的,都不会触发类的初始化。
这是类的加载时机问题。
2.2 类的加载步骤
那么怎么加载呢?这就涉及到类加载的双亲委派问题,这个问题网上有很多文章介绍,内容本身也不算难,这里松哥就不啰嗦了。
通过双亲委派找到具体的类加载器之后,接下来就要开始执行加载了。
加载主要干三件事。
- 通过类的全限定名来获取定义该类的二进制字节流。
全限定名也就是类的全路径,例如 org.javaboy.HelloWorld 这种,通过这个名字去获取类的二进制字节流。去哪里获取呢?可以从磁盘上获取,这是我们最容易想到的,除了从磁盘上获取之外,也可以从网络获取,甚至可以在运行时通过动态计算生成,我们所熟知的 Java 动态代理就属于这种情况。
-
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
-
在内存中生成一个代表这个类的 Class 对象,作为方法区这个类的各种数据的访问入口。
三 Linking
Linking 这个环节分为三个步骤,分别是:
- Verification
- Preparing
- Resolution
我们分别来看。
3.1 Verification
验证这个环节就要就是检查输入的二进制字节流是否符合要求。
正常来说,我们的 Java 代码写完之后会进行编译,有问题的话,编译阶段就报错了,等不到类加载阶段。
不过由于 JVM 读取的二进制字节流不一定是通过 Java 源代码编译后获取到的,也有可能是其他语言编译得到的,甚至可能有某个大神直接用二进制编辑器 0、1、0、1 这样敲出来的,所以站在 JVM 的角度,必须要对输入的二进制流进行校验,确保读取的数据没有问题。
验证的内容主要有这些:
- 魔数是否以
0xCAFEBABE
开始。
魔数是 Class 文件的开始标记,这个位置是一个固定的字符,CAFE BABE。
松哥这里随便用二进制编辑器打开一个 Class 文件给大家看下:
- 主次版本号是否在当前 Java 虚拟机所能接受的范围内。
CAFEBABE 后面紧跟着的是次版本号,次版本号后面紧跟着的是主版本号。以上图为例,次版本号为 0,主版本号 3D 转为十进制是 61。高版本的 JDK 可以向下兼容以前旧版本的 Class 文件,但是无法运行以后版本的 Class 文件,Class 文件的主版本号和 JDK 的关系如下图。
JDK 版本号 | Class 主版本号 |
---|---|
JDK 19 | 63 |
JDK 18 | 62 |
JDK 17 | 61 |
JDK 16 | 60 |
JDK 15 | 59 |
JDK 14 | 58 |
JDK 13 | 57 |
JDK 12 | 56 |
JDK 11 | 55 |
JDK 10 | 54 |
JDK 9 | 53 |
JDK 8 | 52 |
JDK 7 | 51 |
JDK 6.0 | 50 |
JDK 5.0 | 49 |
JDK 1.4 | 48 |
JDK 1.3 | 47 |
JDK 1.2 | 46 |
JDK 1.1 | 45.0-45.6 |
- 常量池中是否有不被支持的常量类型
- 当前类是否存在父类(所有类都应当有父类)?当前类是否继承了 final 类(不应当继承 final 类)?如果当前类不是抽象类,是否实现了其父类或者接口中要求实现的方法等等。
- 对字节码进行校验。
- 符号引用能否找到对应的类,符号引用中涉及到的类、字段、方法等的访问性是否满足要求。
由于验证这块的环节非常复杂,流程也多,因此,如果自己有办法确认自己的代码是 OK 的,那么也可以使用 -Xverify:none 来关闭大部分的类验证,这样可以缩短虚拟机加载类的时间。
这里检查的内容其实非常多,官方文档足足有 100 多页,松哥这里就不逐一列举了,小伙伴们主要是知道这里的核心目的是检查并确保读入到内存中的字节流是没有问题的。
3.2 Preparing
这一阶段主要是给类中的静态属性设置初始值。
例如定义了 public static int a = 5;
,那么就会为该变量在内存中(堆)分配存储空间,并设置初始值(int 类型初始值是 0),注意这个时候并不会将 a 设置为 5,因为还没到最终的初始化阶段。
但是如果属性在定义的时候就已经定义为常量了,例如 public final static int a = 5;
,则会直接给属性最终赋值。
3.3 Resolution
接下来是解析,解析主要是将常量池内的符号引用替换为直接引用的过程。
什么是符号引用呢?
符号引用是以一组符号来描述引用的目标,因为在编译阶段,虚拟机并不知道所引用的类的具体位置,因此就使用符号引用来代替。符号可以是任何字面量,只要在使用时能够无歧义的定位到目标即可。
什么是直接引用呢?
直接引用就是一个可以直接指向目标的指针,相对偏移量等。
所以,符号引用转为直接引用其实就是原本是通过字符去引用某个变量,现在直接改为通过内存地址来访问该变量了。
解析的符号主要有七种,分别是类或者接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符。
松哥这里以一个类的解析为例,和小伙伴们简单说明一下这个解析过程。
假设当前类是 C1,当前类中存在一个符号引用 F,我们要将这个符号引用 F 解析为一个类 C2,那么流程是这样:
- 如果 C2 是一个普通对象而不是接口,那么 JVM 会把代表 F 的全限定名传递给 C1 的类加载器去加载这个类,当然,这个加载过程又是一整套的类加载流程。
- 如果 C2 是一个对象数组,那么首先按照第一步的方式先去加载数组中的元素类型,然后由虚拟机去生成一个代表该数组的对象。
就这样简单两个步骤,当然,在这个流程中,也会去检查 C1 是否具备对 C2 的访问权限,这个主要是检查 module 访问权限和类的访问权限。
四 Initialization
接下来就是类的初始化阶段了,如果想让这个阶段更加具象化,那么这个阶段实际上是调用类的 clinit 方法,这个方法并不是开发者写的,而是由 javac 编译器自动生成的。
javac 自动生成的 clinit 方法主要是将静态变量赋值和静态代码块的相关内容合并起来。在执行 clinit 方法的过程中,并不会显式的调用父类的 clinit 方法,而是由虚拟机去确保在执行子类的 clinit 方法之前,父类的 clinit 方法已经被执行过了。
例如为 static 类型的变量赋值,就是在这个环节完成的。
五 Using/Unloading
最后就是 Using 和 Unloading 了,这块就简单了,不多说。