【死磕JVM】五年 整整五年了 该知道JVM加载机制了!
类加载
和那些编译时需要连接工作的语言不同,在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 实例最后解析的结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄锁对应的类没有进行过初始化时 |
关于序号1的详细解释:
- 使用 new 关键字实例化对象时
- 读取类的静态变量时(被 final修饰,已在编译期把结果放入常量池的静态字段除外)
- 设置类的静态变量时
- 调用一个类的静态方法时
注意: newarray
指令触发的只是数组类型本身的初始化,而不会导致其相关类型的初始化,比如,new String[]
只会直接触发 String[]
类的初始化,也就是触发对类[Ljava.lang.String
的初始化,而直接不会触发String
类的初始化。
生成这四条指令最常见的Java代码场景是:
对于这5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:“有且只有”,这5种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为 被动引用。
需要特别指出的是,类的实例化和类的初始化是两个完全不同的概念:
- 类的实例化是指创建一个类的实例(对象)的过程;
- 类的初始化是指为类各个成员赋初始值的过程,是类生命周期中的一个阶段;
被动引用的三个场景:
- 通过子类引用父类的静态字段,不会导致子类初始化
/**
* @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始终不会被初始化;
- 通过数组定义来引用类,不会触发此类的初始化
/**
* @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!!!”,说明没有触发类的初始化阶段
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
/**
* @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
对象
在加载阶段,虚拟机需要完成以下三个事情:
- 通过一个类的全限定名类获取定义此类的二进制字节流(没有指明只能从一个Class文件中获取,可以从其他渠道,如:网络、动态生成、数据库等)
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
类加载器通常无须等到“首次使用”该类时才加载该类,Java虚拟机规范允许系统预先加载某些类。加载阶段与连接阶段的部分内容是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在夹在阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
2、连接
当类被加载之后,系统会生成一个对应的Class对象,就会进入 连接阶段,连接阶段负责把类的二进制数据合并到JRE中,连接阶段又分为三个小阶段
1.1 验证
验证是连接阶段的第一步,这一阶段的主要目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。Java语言相对于 C/C++ 来说本身是相对安全的语言,验证阶段是非常重要的,这个阶段是否严谨,决定了Java虚拟机能不能承受恶意代码的攻击,当验证输入的字节流不符合Class文件格式的约束时,虚拟机会抛出一个 java.lang.VerifyError
异常或者子类异常,从大体来说验证主要分为四个校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证
文件格式验证: 主要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。主要包含以下几个方面:
- 文件格式是否以
CAFEBABE
开头 - 主次版本是否在虚拟机处理的范围内
- 常量池的常量是否有不被支持的常量类型
- 指向常量的各种索引值是否有指向不存在的常量或者不符合类型的常量
- CONSTANT_Utf8_info 型的常量是否有不符合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内库被随意更改,其次是效率问题,如果有缓存在,直接从缓存里面拿,就不用一遍一遍的去遍历查询我们的父类或者子类了。
原创不易,一键三连是个好习惯!
我是牧小农,怕什么真理无穷,进一步有进一步的欢喜,大家加油!!!
本文地址:https://blog.csdn.net/qq_14996421/article/details/114162705