类加载机制
你多学一样本事,就少说一句求人的话
参考学习于 :
《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》
https://segmentfault.com/a/1190000037574626
https://juejin.cn/post/6959008770051538952
在学习 java 的时候,我们一般都不需要考虑 jvm 是如何去找到并加载一个类到内存中的,但是在慢慢学习的过程中,我们还是需要去思考这个问题,并深入了解它。
一、 类的生命周期
一个 java 类的生命周期,如下图所示:
二、类的加载过程
一个类在被加载到内存中开始,直到从内存中卸载的这段时间,一共会经历以下几个过程:
加载(Loading) —> 验证(Verfication) —> 准备(Preparation) —> 解析(Resolution) —> 初始化(Initialization) —> 使用(Using) —> 卸载(Unloading)
-
加载(Loading)
是整个
类加载
的一个阶段,不能把它们混淆了,在加载阶段,Java 虚拟机需要完成以下三件事:- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据。
- 在内存中(这里主要说的是 Java 的 堆)生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据访问的入口。
注意点:
- 首先,类从哪个地方获取二进制字节流,这个是范围很大的,可以通过 zip 文件、jar、war、网络、动态代理等方式进行获取。
- 相对于其他的阶段,类加载的阶段的可扩展性最高,也是写代码的时候最好把控的一个地方,我们可以使用 Java 中默认的引导类加载器 (bootstrap classloader) 来进行加载,也可以使用 自定义的加载器 进行加载。
- 对于数组类型来说,并非由类加载器来进行创建,而是通过 Java 虚拟机直接在内存中动态的构造出来的。但是数组对象还是和类加载器有很大的关联,毕竟数组中的元素类型(Element Type,指的是数组去除掉所有维度的类型) 最终还是要靠类加载器来完成加载的。 关于数组的加载,后续会单独学习相关细节
-
验证
作为连接操作的第一步,为了确保 Class 文件的字节流包含的信息符合《Java 虚拟机规范》的全部约束要去,确保这些代码运行过后,不会影响 JVM自身的安全。需要验证的内容如下:
-
文件格式的验证 :
验证是否符合 Class 文件的规范,比如:
- 是否以魔数 咖啡宝贝开头
- 主 & 次版本号是否合法
- 常量池的常量是否支持
- Class 文件是否完整。
-
元数据的验证 ,主要是数据类型校验
- 包含是否有父类
- 父类是否继承了错误的类(比如 final修饰的类)
- 如果这个类不是抽象类,是否实现了父类或者接口 之中要求实现的所有方法。
- 字段是否合格?是否重写了父类不能重写的方法?或者覆盖父类的 final 参数?
-
字节码的验证
主要是通过
数据流分析
和控制流分析
,确定程序的语义是否为合法的、符合逻辑的。主要是对方法体内容进行校验,保证方法不会做出影响虚拟机的安全行为,比如:- 保证
任意时刻
操作数栈的数据类型和指令代码序列都能配合工作,例如不会出现 ” 在操作数栈放置了一个 int 类型的数据,使用时却按照 long 类型来加载入本地变量表中“ 这样的情况。 - 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换总是有效的,例如可以把一个子类的对象赋值给父类数据类型,这是安全的,但是如果把父类对象赋值给子类数据类型,甚至把对象赋值给它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
这里如果有任何一个方法体的字节码没有通过验证,就肯定是不安全的,但是,反之如果通过了验证,也并不能说是一定就是安全的,可以参考一个叫做 停机问题。后续去了解这个方法。
从 JDK 1.6过后,由于数据流和控制流的复杂性,在 Class 文件中,出现了一个新的属性 StackMapTable : 描述了方法体的所有基本块,开始时本地变量表和操作栈应有的状态,这样,后续就不在需要使用程序来推导是否合理,验证 StackMapTable 即可。
- 保证
-
符号引用验证
发生在虚拟机将符号引用转换为直接引用的时候,主要发生在第三个阶段——解析阶段。校验以下内容:
- 符号引用中中通过字符串描述的权限定名是否能找到对应的类。
- 在执行类中是否存在符合方法的字段描述以及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法的可访问性(private、protected、public、) 是否可以被当前类访问。
如果验证失败,会抛出一个常见的异常 : java.lang.IncompatiableClassChangeError 的子类异常,比如: java.lang.NoSuchFieldError 、 java.lang.NoSuchMethodError等。
这个验证的过程,并非一定需要,如果代码已经经过反复运行和验证,可以使用参数 : -Xverify:none 来关闭大部分的验证措施,然后缩短虚拟机类加载的时间。
-
准备
分配所有变量的内存空间并设置初始值的步骤,所有的变量都应该在方法区上分配好对应的内存,但是,但是,但是,方法区只是一个逻辑上的概念。
注意点:
- 分配内存的时候,只是包括了类变量,并不包括实例变量,实例变量会随着类的初始化而加载到堆内存中。
- 初始值也通常指的是对应的0️⃣值。
- 0️⃣值也存在特例,如果变量是使用 final 修饰的,那么就会存在 ConstantValue 属性,然后就会初始化为对应的值。而不是 0 值。
-
解析
解析常量池中的符号引用,并转换为直接引用。
在 Class 文件中存放了字面量和符号引用,符号引用包括了类和接口的权限定名以及字段和方法的名称与描述符。
在 JVM 动态链接的时候,需要根据这些符号引用来转换为直接引用存放内存使用。
-
初始化
类加载的最后一步了,这个时候,类的变量已经赋过了初值了,在初始化阶段,将会被赋予代码希望的值。执行构造方法的阶段。
-
三、类加载的时间
Java 虚拟机并没有强制性规定加载(Loading)的时机,但是严格规定有且在以下5中情况时如果类没有初始化,则需要先触发其初始化(Initialization)。初始化之前,自然会存在 加载
和 链接
。
以下五种场景的行为,成为对一个类 主动引用
。除此之外,所有引用类的方式都不会触发类的初始化,成为 被动引用
。
- 遇到 以下关键字的时候,所在的类,需要被初始化
- new (实例化对象)
- getstatic (读取除开常量外的静态字段)
- putstatic (设置读取除开常量外静态字段)
- invokestatic (调用类的静态方法)。
- new (实例化对象)
- 使用 java.lang.reflect 包的方法对类进行反射调用的时候。
- 初始化一个类时,如果其父类没有被初始化,先对其父类进行初始化 (接口除外,只有使用到父类的时候才会被初始化)
- 虚拟机启动时,会先初始化用户指定的执行主类 (包含 main 方法的类)
- 使用 JDK 1.7 动态语言支持的时候, java.lang.invoke.MethodHandle 实例最后解析的结果为REF_getStatic 、 REF_putStatic 、 REF_invokeStatic 的方法句柄的时候,则这个方法句柄对应的类需要被初始化。
四、 Java 字节码文件中的 JVM 指令
使用以下的代码,进行反编译过后,学习以下 Java 字节码文件中有哪些常见的 JVM 指令。
public class Test {
public static final int NUMBER = 10;
public static String name = "张三";
public static void main(String[] args) {
if (NUMBER == 10) {
name = "李四";
System.out.println("我是 : " + name);
}
}
}
编译方式 :
- 在 Idea 中直接运行,即可编译对应的 Test.class 文件
- 直接使用 javac Test.java,编译对应的 class 文件
编译完成过后,执行反编译该 class 文件,否则都是二进制,我们看不懂,反编译命令如下 :
javap -c Test.class
反编译过后的内容如下:
Compiled from "Test.java"
public class Test {
public static final int NUMBER;
public static java.lang.String name;
public Test();
Code:
0: aload_0
1: invokespecial #2 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #3 // String 李四
2: putstatic #4 // Field name:Ljava/lang/String;
5: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
8: getstatic #4 // Field name:Ljava/lang/String;
11: invokedynamic #6, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
16: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
19: return
static {};
Code:
0: ldc #8 // String 张三
2: putstatic #4 // Field name:Ljava/lang/String;
5: return
}
上述 JVM 指令解析 :
- 第 1 行,表示从 Test.java 编译而来
- 第 7 行,表示当前构造方法开始
- 第 10 行,表示执行父类的构造方法,这里没有使用 extends 关键字,则父类就默认为 顶级父类 Object。也就是无参的构造方法。
- 第 13 行,表示执行当前 main 方法,并执行内部判断,判断值是否等于10,以及输出打印。
- 第 23 行,静态方法,调用设置值。
五、类加载器
常见的类加载器如下 :
-
启动类加载器 (Bootstrap ClassLoader)
使用 C/C++ 实现的,java 程序无法直接操作这个类。用来加载 Java 核心类库,如 : jre/lib 包中的文件,用于提供 jvm 运行所需要的包。
该加载器没有父类加载器。
它加载
扩展类加载器
和应用程序加载器
出于安全考虑,启动类只会加载包名为 : java 、javax 、 sun 开头的类
-
扩展类加载器 (Extension ClassLoader)
Java 语言编写,由
un.misc.Lanucher$ExtClassLoader
实现,可以用 Java 程序操作这个类加载器派生继承自java.lang.ClassLoader
,父类加载器为启动类加载器
从系统属性 :
-
java.ext.dirs 目录中加载类库.
-
从 JDK 安装目录 : jre/lib/ext 目录下加载类库。
我们可以将我们自己的包放在以上目录下,就能自动加载进来了。
-
-
应用程序加载器 (Application ClassLoader)
Java语言编写,由
sun.misc.Launcher$AppClassLoader
实现。派生继承自
java.lang.ClassLoader
,父类加载器为启动类加载器
负责环境变量
classpath
或者 系统属性java.class.path
指定下的类库。程序的默认类加载器,Java 程序中的类,都是由它加载完成的。
我们可以通过
ClassLoader#getSystemClassLoader()
获取并操作这个加载器 -
自定义加载器 (User ClassLoader)
一般情况下,以上3种加载器能满足我们日常的开发工作,不满足时,我们还可以
自定义加载器
比如用网络加载Java类,为了保证传输中的安全性,采用了加密操作,那么以上3种加载器就无法
加载这个类,这时候就需要
自定义加载器
。暂时不记录这个加载器。
获取 ClassLoader 的几种方式 :
-
获取当前的 ClassLoader
Class clazz = Test.class; clazz.getClassLoader()
-
获取当前线程上下文的 ClassLoader
Thread.currentThread().getContextClassLoader()
-
获取系统的 ClassLoader
ClassLoader.getSystemClassLoader()
-
获取 调用者的 ClassLoader
DriverManager.getCallerClassLoader()
六、其他
一个小 tips :
以前在公司写代码的时候,将一个类存到了 redis 中,后续取出来的时候,发现出现了同一个类的强转失败了,报错居然不是同一个类,很奇怪,同一个类放出来,取出来就不是那个类了。原因是因为啥呢?
原因是因为,使用了 devtools ,spring 的一款热部署工具,细究问题,找到问题所在,其实就类似于 类加载机制的问题,热部署工具,和 代码本身使用的 类加载器是不同的,也就是不同类加载器加载同一个类,在强转的时候,认为不是同一个类,也就报错了。
具体的说明,在这里 : devtools