【学习笔记】深入理解JVM之类加载机制
以后基本上都在语雀上面更新,大家有兴趣可以看看嗷!
首发地址: 知识库
文章流程图:
1、概述
首先我们先来看看一个 Class
文件所需要经过的一个流程图:
而我们今天要重点需讲的就是 类加载器
这部分。
在讲类加载器之前先问一个问题——什么是
Class
文件?
Class
文件是一组 8
字节为基础单位的二进制文件,各个数据项项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符,这使得 Class
文件中存储的内存内容部分都是程序运行的必要数据,没有空隙存在。
通过上面的回答我们知道
Class
文件,是一个以8
自己为基础单位的二进制文件,那如果遇到一个需要 占用8字节以上的的空间数据项时会怎么办?
当出现占用超过 8
以上空间的数据项时,则会采用 高位在前 的方式分割成若干个 8
个字节进行存储。
补充:
一般
Class
文件的头四个字节被称为 魔数(Magic Number) ,它的作用就是确定这个文件是否为一个能被虚拟机接受的Class
文件。
2、类加载机制
那什么又是类加载机制呢?
Java虚拟机把描述类的数据从 Class
文件加载到内存,并对数据进行 校验、转换解析和初始化
,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
类加载的过程图:
2.1 加载
注意此处的 “加载” 是 ‘’类加载“ 过程中的一个阶段, 大家不要弄混了嗷。
在加载阶段,Java
虚拟机需要完成以下三件事:
- 通过一个类的
全限定名
来获取定义此类的二进制字节流。 - 将这个
字节流
所代表的静态存储结构
转化为方法区的运行时数据结构
。 - 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
补充:
可以获取二进制字节流的方式:
- 从ZIP压缩包中读取,这很常见,最中成为日后JAR、EAR、WAR的格式基础。
- 从网络中获取,这种场景最低昂行的应用就是
Web Applet
。- 运行时计算生成,这种场景使用得最多的就是 动态代理 技术,在
java.lang.reflect.Proxy
中,就是用了ProxyGenerator.generateProxyClass()
来为特定接口生成形式为"*$Proxy"
的代理类的二进制字节流。- 从数据库中读取。
- 其他文件生成。
- …
2.2 验证
验证是链接阶段的第一步,这一阶段的目的是确保 Class
文件的字节流中包含的信息符合 《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
举个例子:
我们知道 Class
文件并不一定都是 Java
源码编译而来,它可以使用包括靠键盘 0和1
直接在二进制编辑器中敲出 Class
文件。如果不进行此方面的验证,对其完全信任的话,可能会有恶意的企图的字节码流程,而导致整个系统搜到破坏。
所以说验证字节码是 Java
虚拟机自我保护的一项必要措施。
验证阶段是是十分严谨的,直接决定了 Java虚拟机
是否能承受恶意代码的攻击。验证阶段大致分为以下四个阶段:
文件格式验证
元数据验证
字节码验证
符号引用验证
♣️ 2.2.1 文件格式验证
第一阶段就是需要验证字节流是否符合 Class
文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:
- 是否以魔数
0xCAFEBABE
开头。 - 主、次版本号是否在当前的
Java
虚拟机接受范围之内。 - 常量池的常量中是否有不被支持的常量类型。
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据。
Class
文件中各个部分及文件本身是否有被删除的或附加的其他信息。- …
目的:
上述的验证阶段主要是为了保证输入的字节流能正确地解析并存储于方法区内。这个阶段是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储。所以后面的三个验证阶段都是基于 方法区的存储结构来进行的
。
♣️ 2.2.2 元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》要求,这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了
java.lang.Object
之外,所有的类都应当有父类)。 - 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但是返回值类型却不同等)。
- …
目的:
是对类的元数据信息进行语义校验,保证不存在《Java语言规范》定义相悖的元数据信息。
♣️ 2.2.3 字节码验证
本阶段是真个验证阶段最为复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。本阶段是对 方法体(Class文件中的Code属性) 进行校验分析,保证被校验类的方法在运行时不会做出未来虚拟机安全的行为。
例如:
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个int类型的数据,使用时却按照long类型来加载本地变量表中” 这样的情况。
- 保证跳转指令都不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换总是有效的,例如可以把一个子类的对象赋值给父类数据类型,这样是安全的,但是把父类对象赋值给自类数据类型,甚至把对象赋值给与它毫无继承关系、完全不想干的一个数据类型,则是危险和不合法的。
如果有一个方法体中的字节码没有通过字节码的验证,那它肯定是有问题的。但是如果通过了验证也不能百分之百的保证他是安全的。
♣️ 2.2.4 符号引用验证
最后一个阶段的校验行为发生在虚拟机将符号转化为直接引用的时候,这个转化动作将在连接的第三阶段( 解析阶段中发生 )。其可以看作是对类自身以为(常量池中各个符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:
- 符号引用中通过
字符串描述
的全限定名
是否能找到对应的类
。 - 在
指定类中
是否存在符合方法的字段描述符
及简单名称
所描述的方法和字段
。 - 符号引用中的类、字段、方法的可访问性(
private、protected、public、<package>
) 是否可被当前类访问。 - …
本阶段的主要目的就是确保解析行为能正常执行,如果无法通过 符号引用验证 阶段 Java虚拟机 将会抛出一个 java.lang.IncompatibleClassChangeError
的子类异常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError
等。
♣️ 2.2.5 总结
验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响,如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过了,再生产环境的实施阶段即可以考虑使用 -X verify:none
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
2.3 准备
准备阶段是正式为类中定义的变量( 静态变量,被 static
修饰的变量 ) 分配内存并设置变量的初始值的阶段。从概念上讲,这些变量所使用的内存都应当在方法区中进行配置,但应该注意方法区本生就是一个逻辑上的区域。在 JDK1.8
之后,类变量会随着 Class
对象一起存放在 Java
堆中。
注意:
准备阶段进行内存分配的仅包括类变量,而不是实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中
。
我们通常情况下数据类型 零值 。
例如:
public static int value = 123;
如上述代码,value
在准备阶段过后的初始值为0而不是123,因为 这时尚未开始执行任何Java方法,而把 value
赋值为123的 putstatic
指令是程序被编译后,存放于类构造器 <clinit>()
方法之中,所以把 value
赋值为123的动作要到类的初始化阶段才会被执行。
零值表补充:
但是会出现特殊情况:
如果类字段的字段属性表中存在 ConstantValue
属性,那在准备阶段变量值就会被初始化为 ConstantValue
属性所指定的初始值,如果上面代码改为以下情况:
public static final int value = 123;
编译时 Javac
会将为 value
值生成 ConstantValue
属性,在准备阶段虚拟机就会根据 ConstantValue
的设置将 value
赋值为 123。
总结:
- 为类变量分配内存并切设置该类变量的默认初始值,即零值。
- 这里不包含使用
final
修饰的static
,因为final
在编译的时候机会分配,准备阶段会显示初始化。 - 这里不会为 实例变量 分配初始化,类变量会分配在方法区中,而实例变量实惠随着对象一起分配到 Java堆中。
2.3 解析
解析过程是 Java虚拟机
将常量池内的 符号引用
替换为 直接引用
的过程。
-
符号引用: 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
-
直接引用: 直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
解析的流程有以下四个:
类或接口的解析
字段解析
方法解析
接口方法解析
事实上解析操作都是往往伴随着 JVM
在执行过程中完成初始化之后再执行。
2.4 初始化
类的初始化阶段是类加载过程的最后一个步骤,之前介绍的的几个类的加载动作里,除了在 加载阶段
用户应用程序可以通过自定义类加载器的方式局部参与,其余动作完全由 Java
虚拟机来主导控制权。直到 初始化阶段 ,Java
虚拟机才开始真正开始执行类中编写的 Java
程序代码,将主导权移交给应用程序。
而在初始化阶段,则会根据程序员通过程序编码指定的主观计划去初始化类变量和其他资源。
总结:
- 初始化阶段就是执行类构造器
<clinit>()
方法的过程。 - 此方法不需要被定义,而javac编译器自动收集类中所有变量值的赋值动作和静态代码块中语句合并而来。
- 构造器方法中指令按语句在源文件中出现的顺序执行。
<clinet>()
不同于类的构造器(<init>()
)- 若该类具有父类,
JVM
保证子类的<client>()
执行前,父类的<clinit>()
已经执行完毕。 - 虚拟机必须保证一个类的
<clinit>()
方法再多线程下被同步加锁。
3、类加载器
首先我们要知道什么是 类加载器
?
通过一个类的全限定名来获取描述该类的二进制字节流
这个动作放到Java
虚拟机外部去实现,以便让应用程序自己去决定如何获取所需的类。实现这个动作的代码被称为类加载器(Class Loader)
。
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 java
虚拟机中的 唯一性 ,每一个类加载器都拥有一个独立的类名称空间。通俗一点讲就是:比较两个类是否相等,只有在这两个类是同一个类加载器加载的前提下才有意义。
类加载器一共有以下几种:
名称 | 加载哪的类 | 说明 |
---|---|---|
Bootstrap ClassLoader(启动类加载器) | JAVA_HOME/jre/lib | 无法直接访问 |
Extension ClassLoader(扩展类加载器) | JAVA_HOME/jre/lib/ext | 上级为 Bootstrap,显示为 null |
Application ClassLoader(应用程序类加载器) | classpath | 上级为 Extension |
自定义类加载器 | 自定义 | 上级为 Application |
类加载器的优先级(由高到低):启动类加载器 -> 扩展类加载器 -> 应用程序类加载器 -> 自定义类加载器 。
3.1 启动类加载器(Bootstrap ClassLoader)
- 这个类加载器是使用
C++/C
语言来实现的,嵌套在JVM
内部。 - 它用来加载
Java
的核心库(JAVA_HOME/jre/lib
),用于提供JVM
自身需要的类。 - 并不是继承自
java.lang.Class.ClassLoader
,没有父加载器。 - 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。
获取启动类能够加载的路径:
public static void main(String[] args) {
URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
for(URL element: urLs){
System.out.println(element.toExternalForm());
}
}
file:/Users/tiejiaxiaobao/Library/Java/JavaVirtualMachines/liberica-1.8.0_345/jre/lib/resources.jar
file:/Users/tiejiaxiaobao/Library/Java/JavaVirtualMachines/liberica-1.8.0_345/jre/lib/rt.jar
file:/Users/tiejiaxiaobao/Library/Java/JavaVirtualMachines/liberica-1.8.0_345/jre/lib/sunrsasign.jar
file:/Users/tiejiaxiaobao/Library/Java/JavaVirtualMachines/liberica-1.8.0_345/jre/lib/jsse.jar
file:/Users/tiejiaxiaobao/Library/Java/JavaVirtualMachines/liberica-1.8.0_345/jre/lib/jce.jar
file:/Users/tiejiaxiaobao/Library/Java/JavaVirtualMachines/liberica-1.8.0_345/jre/lib/charsets.jar
file:/Users/tiejiaxiaobao/Library/Java/JavaVirtualMachines/liberica-1.8.0_345/jre/lib/jfr.jar
file:/Users/tiejiaxiaobao/Library/Java/JavaVirtualMachines/liberica-1.8.0_345/jre/classes
3.2 扩展类加载器(Extension ClassLoader)
- Java语言编写,由
sun.misc.Launcher$ExtClassLoader
实现。 - 派生于ClassLoader类。
- 父类加载器为启动类加载器。
- 从
java.ext.dirs
系统属性所指定的目录中加载类库,或从 JDK 的安装目录的jre/lib/ext
子目录(扩展目录)下加载类库。如果用户创建JAR在此目录下,则会自由由扩展类加载器加载。
//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);
// sun.misc.Launcher$AppClassLoader@18b4aac2
例如:
public static void main(String[] args) {
String property = System.getProperty("java.ext.dirs");
for(String p: property.split(":")){
System.out.println(p);
}
}
输出:
/Users/tiejiaxiaobao/Library/Java/Extensions
/Users/tiejiaxiaobao/Library/Java/JavaVirtualMachines/liberica-1.8.0_345/jre/lib/ext
/Library/Java/Extensions
/Network/Library/Java/Extensions
/System/Library/Java/Extensions
/usr/lib/java
3.3 应用程序加载器(AppClassLoader)
- 由Java语言编写,由
sun.misc.Launcher$AppClassLoader
实现。 - 派生于
ClassLoader
类。 - 父类加载器为扩展加载器。
- 它负责加载环境变量
classpath
或系统属性java.class.path
指定路径下的类库。
3.4 用户自定义类加载器
为什么要自定义类加载器呢?
- 隔离加载类
- 修改类加载的方式
- 扩展加载源
- 防止源码泄露
那又什么时候去定义呢?
- 1)想加载非 classpath 随意路径中的类文件
- 2)都是通过接口来使用实现,希望解耦时,常用在框架设计
- 3)这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器
步骤:
- 继承 ClassLoader 父类。
- 要遵从双亲委派机制,重写 findClass 方法 注意不是重写 loadClass 方法,否则不会走双亲委派机制。
- 读取类文件的字节码。
- 调用父类的 defineClass 方法来加载类。
- 使用者调用该类加载器的 loadClass 方法。
4、双亲委派模型
♣️ 什么是双亲委派模型呢?
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
实现:
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 首先,检查请求的类是否已经被加载过了 Class c = findLoadedClass(name); if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name); }
} catch (ClassNotFoundException e) {
// 如果父类加载器抛出ClassNotFoundException // 说明父类加载器无法完成加载请求
}
if (c == null) {
// 在父类加载器无法加载时
// 再调用本身的findClass方法来进行类加载 c = findClass(name);
}
}
if (resolve) { resolveClass(c);
}
return c;
}
♣️为什么使用双亲委派模型呢?(好处)
- 采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父加载器已经加载了该类时,就没有必要子加载器再加载一次。
- 其次是考虑到安全因素,java 核心 api 中定义类型不会被随意替换,假设通过网络传递一个名为
java.lang.Integer
的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer
,而直接返回已加载过的Integer.class
,这样便可以防止核心API库被随意篡改。
参考
本篇笔记参考:尚硅谷JVM 26 - 36 集
《深入理解Java虚拟机》第三版
第六章—类文件结构
第七章—虚拟机类加载机制
文章地址:https://blog.csdn.net/weixin_43591980/article/details/119916684