JVM入门:官网了解JVM、Java源文件运行过程、什么是类加载器、Java的类加载机制的三种方式、Tomcat的自定义类加载器
- 通过官网了解JVM
- 官网jdk8结构图
- 什么是JVM
- 查看官网Java语言和虚拟机规范
- Java源文件运行过程
- 1.Java源文件经过Javac编译成字节码文件
- 如何手动编译一个Java源文件
- 官网查看如何阅读编译后的.class文件
- 官网class文件结构格式说明
- 字节码文件中内容格式是什么?
- javap反编译字节码文件并输出到指定文件
- 2.将Javac编译后的字节码文件交给JVM去run
- .class文件交给JVM run的过程,即类加载过程(类的生命周期)
- 1.装载
- 2.链接
- 验证阶段
- 准备阶段
- 解析阶段
- 动态invokedynamic指令(lambda表达式)
- 3.初始化
- 初始化什么时候会被触发?
- 卸载
- 类加载机制应该做的事图示
- 什么是类加载器?
- Java的类加载机制的三种方式
- 1.全盘负责
- 2.父类委托
- 3. 缓存机制
- 父类委托(双亲委派)
- 自定义类加载器
- Tomcat的自定义类加载器
通过官网了解JVM
官网jdk8结构图
jdk8文档官网:https://docs.oracle.com/javase/8/docs/
什么是JVM
JVM是Java虚拟机,全称Java Virtual Machine。是一个抽象的计算机器,它有一个指令集并在运行时操作内存。可以将JVM想象成是一个物理机,而一个物理机必然遵循冯诺依曼计算机模型体系。
物理机接收的是0101这样的组合,因此JVM接收的也是0101这样的组合,因此输入设备这里的就是0101这样形式的。而JVM中字节码文件是作为输入设备的,这样也就可以理解为什么字节码文件是二进制文件了。
Java虚拟机被移植到不同的平台上,以提供硬件和操作系统的独立性。Java平台标准版提供了Java虚拟机(VM)的两种实现:
①Java HotSpot客户端虚拟机
客户端虚拟机是通常用于客户端应用程序的平台的实现。
客户端虚拟机经过优化,可以减少启动时间和内存占用。
它可以通过使用-client启动应用程序时的命令行选项。
②Java HotSpot服务器虚拟机
服务器虚拟机是一种实现,旨在实现最高的程序执行速度,在启动时间和内存之间进行权衡。
它可以通过使用-server启动应用程序时的命令行选项。
Java HotSpot技术的一些特性是两种VM实现共有的,如下所示:
- 自适应编译器-应用程序使用标准解释器启动,但随后在运行时对代码进行分析,以检测性能瓶颈或“热点”。Java HotSpot VMs编译代码中对性能至关重要的部分以提高性能,同时避免对很少使用的代码(大部分程序)进行不必要的编译。Java HotSpot VMs也使用自适应编译器来动态地决定如何通过内联等技术来优化编译后的代码。编译器执行的运行时分析允许它在确定哪些优化将产生最大的性能优势时消除猜测。
- 快速内存分配和垃圾收集- Java HotSpot技术为对象提供了快速的内存分配,并提供了快速、高效、先进的垃圾收集器选择。
- 线程同步Java编程语言允许使用多个并发的程序执行路径(称为“线程”)。Java HotSpot技术提供了一种线程处理功能,这种功能设计成可以在大型共享内存多处理器服务器中使用。
查看官网Java语言和虚拟机规范
官网Java语言和虚拟机规范:https://docs.oracle.com/javase/specs/index.html
找到自己需要的Java版本
选择Java虚拟机规范,点HTML和PDF都可以。
Java源文件运行过程
为什么Java一次编译到处运行?
Java源文件经过Javac编译称为.class文件,编译称字节码文件之后,交给JVM去run这个.class文件,run的一个过程显然是将字节码文件交给JVM里面去进行运行和流转。
为什么要将字节码文件交给JVM去run呢?
很多生产计算机的不同厂商、不同的CPU、包括在硬件上面进行演化,演化出不同的操作系统,不同的操作系统解析可能会有差别,为了让Java源文件,能够运行在不同的机器上,所以要对不同的操作系统去做一个屏蔽,将字节码文件交给JVM去run就可以做到,这也就是为什么Java能够做到一次编译到处运行。
1.Java源文件经过Javac编译成字节码文件
如何手动编译一个Java源文件
在这里插入图片描述
打开一个存放Java源文件的目录,进入dos窗口,执行命令
javac .\文件名
Test.class打开是这样的
官网查看如何阅读编译后的.class文件
首先,去官网Java语言和虚拟机规范:https://docs.oracle.com/javase/specs/index.html
找到自己需要的Java版本
选择Java虚拟机规范,点HTML和PDF都可以。
第4点就是class文件格式描述:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html
官网class文件结构格式说明
ClassFile {
u4 magic;//字节码文件的规范,一般value是以0xCAFEBABE开头,俗称为魔术开头
u2 minor_version;//最小版本号
u2 major_version;//最大版本号
u2 constant_pool_count;//常量池数量
cp_info constant_pool[constant_pool_count-1];//常量池,这里指的是静态常量池
u2 access_flags;//访问比齐奥基
u2 this_class;//当前类
u2 super_class;//超类
u2 interfaces_count;//接口数量
u2 interfaces[interfaces_count];
u2 fields_count;//字段数量
field_info fields[fields_count];
u2 methods_count;//方法数量
method_info methods[methods_count];
u2 attributes_count;//属性数量
attribute_info attributes[attributes_count];
}
u2,u4,u8是无符号数据类型,是2的n次方。
注意:class文件结构里的常量池是指静态常量池
常量池一般分为三类,静态常量池,运行时常量池,字符串常量池。而class文件结构这里说的常量池指的是静态常量池。
静态常量池当中存放两个东西:
1.字面量:文本;字符串;final修饰的(final修饰的不一定都为常量)
2.符号引用:类、接口、字段、方法的一些描述信息
constant_pool是一个结构表,表示在结构及其子结构中引用的各种字符串常量、类和接口名称、字段名称和其他常量。每个表项的格式由其第一个“标记”字节表示。
字节码文件中内容格式是什么?
使用notepad查看编译后的.class文件,需要安装进制转换插件
安装之后,选中进制转换
可以看到字节码文件格式化后是以16进制展示的,它的本质是16进制文件吗?
不是,其实是一个二进制文件,里面存放的是16进制的字节元,而它的本质是二进制文件。
javap反编译字节码文件并输出到指定文件
在当前字节码文件目录执行命令(内容随便写写就行,先掌握输出反编译的字节码文件即可,后面会具体操作),将对应的Test.class反编译成汇编语言,并输出为Test.txt文件
javap -p -v .\Test.class >Test.txt
打开Test.txt文件
2.将Javac编译后的字节码文件交给JVM去run
.class文件交给JVM run的过程,即类加载过程(类的生命周期)
这里面就涉及到面试经常问的类加载机制。顾名思义,就是把类加载到JVM当中去。
官方文档由这块内容的说明是:装载、链接、初始化,位置在第5章节,https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html
类加载过程(类的生命周期)包括以下几个阶段,装载、链接、初始化、使用、卸载。
装载、验证、准备,初始化,这四个阶段的发生顺序是给固定的,而解析阶段不一定,因为有些情况可以在初始化完成之后才会进行解析操作,因为Java中有一个特殊的场景,叫运行时绑定,也叫晚期绑定。
装载、验证、准备,初始化,这几个阶段是按照顺序开始,但不一定按照顺序结束。因为这些阶段通常交叉混合进行,包括验证。
其实在整个类加载的过程中间,除了加载阶段,我们的用户、应用程序可以自定义类加载器以及我们用Java agent增强字节码以外,其它所有动作都由JVM主导,以及控制,所以说到了初始化才开始执行类中定义的Java程序代码,或者说字节码,所以说执行代码在初始化这里仅仅只是开端,仅限于class init方法。
类加载的过程,主要是将字节码文件,加载到虚拟机当中。
1.装载
字节码文件需要读取到内从中去,首先需要做的是先找到这个字节码文件,要想找到字节码文件,则这个字节码文件一定是一个流文件,所以第一步一定是:字节码文件==>字节流文件。
加载字节码文件的方式:
- 从本地系统中加载
- 通过网络下载.class文件。(小程序的包)
- 从归档文件中加载.class文件。(归档文件,包括jar、war、zip包去提取,都是可以的)
- 从专有数据库中提取,class文件。(jsp中会有这样的案例,但是很少)
- 将Java源文件动态编译为.class文件,也就是运行时计算,即动态代理
- 从加密文件中获取,也就是防止.class文件被反编译,从而直接获取到了流转信息,因此会对文件进行加密
根据名字去找对应的.class文件,可能不同包下有重名的,因此是根据Java源文件的全限定名获取这个类的二进制字节流,而能够获取到二进制字节流的前提是,二进制文件已经变成了二进制字节流。
那么是如何获取二进制字节流的?
需要一个字节流寻找工具,Java中有这样一个模块,可以通过类的全限定名来获取二进制字节流这样的一个动作,而且这个动作是放在JVM的外部来实现的,为什么要放在外部来实现?因为应用程序要决定如何获取所需要的类,所以说并不是在JVM内部去实现的,实现这个动作的代码模块叫类加载器。
获取到字节流之后,需要将字节流所代表的静态存储结构转换成可以放入内存里面的东西,怎么放?
能够被找到的一定是静态存储结构,这个时候能够被找到的一定首秀按满足静态存储结构,这个时候就会将字节流所代表的静态存储结构转换成放入内存里面的东西。
方法区: 在内存中划分出一个区域,叫方法区,变成方法区所谓的运行时数据结构,因为这个时候需要run起来了。
堆: 同时,这个时候,还需要生成一个数据放入口。而这个数据访问入口同时也可以在内存当中生成,这个时候就会在堆中间生成一个,代表这些数据可以被访问到的这些数据的访问入口,这个时候用一个数据结构来装,叫做堆,即生成一个class对象作为代表这个类的数据访问入口,这个时候装载就完成了。
总结:装载就干两件事。装载完之后,就有了方法区,方法区里面有类信息、静态变量和常量。堆内存中只有代表加载进来的对应类的class对象存在堆内存中。
装载是类加载过程中最重要的一部分,因为是我们Java程序员最关注的一个阶段,也只能在这个阶段我们可以对类加载器进行操控。操控什么呢?在这个阶段可以对类加载器进行操控,比如自定义类加载器来进行加载,或者使用Java Agent来完成对字节码的增强操作(Java Agent常用来做架构,写开源组件等,用于在装载阶段做字节码增强)。
2.链接
链接可以细分为三个阶段,验证、准备、解析。
验证阶段
验证就是为了确保所谓的字节码文件中的字节流的信息包含的信息完全符合当前JVM的规范要求,即①会验证字节码文件不能出错,②并且信息不能危害JVM自身的安全,这是两个动作。
文件格式验证
文件格式验证:发生在进入方法区之前,只有经过这个阶段的验证之后,字节流才会进入内存的方法区区进行储存。而后面的验证都不是基于方法区的储存来进行验证,而是基于方法区的储存结构区进行验证。
比如:
- 是否以16进制CAFEBABE开头
- 版本号是否正确
元数据验证
元数据验证:对类的元数据信息进行语义校验,这个元数据校验是校验的Java语法,这个校验能保证的是,不符合Java语法规范的元数据信息,无法进入到方法区。
比如:
- 是否有父类
- 是否继承了final类(final类不能被继承,继承会有问题)
- 一个非抽象类是否实现了所有的抽象方法
字节码验证
字节码验证:进行数据流和控制流的分析,主要校验数据对JVM的危害,并不一定语法错误
比如:
- 运行检查
- 栈数据类型和操作码操作参数是否吻合
符号引用验证
符号引用验证:这是最后一个阶段的验证,发生在付符号引用转化为直接引用的时候,即解析阶段。对类自身以外的信息进行验证,比如常量池的各项引用,这个不算是类信息,而是包含的各种符号引用后进行匹配性的校验,是为了确保解析动作能够正常执行,因此来进行的验证。
比如:
- 常量池中描述类是否存在
- 访问的方法或者字段是否存在且具有足够的权限
准备阶段
为类变量(类变量就是静态变量,static修饰的变量)分配内存,并且为类变量设置系Java默认的初始值,即分配零值 。
这个时候,一般情况下,类变量的默认初始值,也就是说当前类型的零值会有区别,如下:
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | ‘\u0000’ |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference(引用类型) | null |
不会为所谓的实例变量(类变量是加static修饰的,代表需要分配初始化,实例变量就是指没有static修饰的变量)去分配空间,类变量会直接分配空间在方法区,而实例变量会随着所谓的对象一起分配到Java堆中。
private static int a = 1;
上面代码,在实际的类加载阶段,我们需要为它在内存上真正开辟一个空间,在准备阶段其实a = 0的,这里是不会为实例变量赋值我们代码定义的默认值1,而是赋值Java的int类型的默认值0。
private final static int a = 1;
增加final修饰,实际上,在类的字段表属性中会存在一个特殊的属性,叫做ConstantValue,在准备阶段,这个变量就会被赋值初始化为ConstantValue属性所指向的值,ConstantValue这个属性的作用,就是通知JVM自动为静态变量赋值,只有被static修饰过的才能够使用这项属性,而非static类型的变量的赋值 是在构造器中进行的。
static修饰的分为两种,一种加final修饰将变量标记为常量,一种不加final,而在类构造器中赋值,或者是class init方法,或者是ConstantValue属性直接在准备阶段进行赋值。
那么在程序中,什么时候才会用到ConstantValue,只有同时被static和final修饰的常量,才会有这个属性,而且只基于基本类型和String,而在编译时,Javac会自动为这个常量生成这样一个ConstantValue这样的一个属性,以便我们在准备的阶段就为这个ConstantValue的常量设置了值,并进行一个赋值操作,也就是说,static final 修饰的会在准备的这个阶段去直接赋值,并不需要去开辟内存,如果说它不是基本数据类型或者是字符串,那么我们会选择再类构造器中去进行初始化,也就是class init去做。
那么为什么局限于基本数据类型,以及String呢?
因为常量池,常量池中只能引到基本数据类型,以及String,所以这个也就是ConstantValue只能引到基本数据类型,以及String的原因。
在准备阶段,JVM就会为
public class TestJvm {
private static final int A=1;// 编译时Javac实际上就会为A生成ConstantValue属性,在准备阶段,JVM就会根据ConstantValue它的值,将A赋值为1,可以理解为在编译期就已经将结果放入了调用它的常量池当中
public static void main(String[] args) {
System.out.println(A);
}
}
完整的反编译之后的晚间内容如下:
Classfile /D:/HAOKAI/haokai-framework/haokai-common/src/main/java/com/haokai/common/test/TestJvm.class
Last modified 2023-5-29; size 452 bytes
MD5 checksum 9ebfc3d20bdc8b726ca5249d1bad64fe
Compiled from "TestJvm.java"
public class com.haokai.common.test.TestJvm
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#18 // java/lang/Object."<init>":()V
#2 = Fieldref #19.#20 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Class #21 // com/haokai/common/test/TestJvm
#4 = Methodref #22.#23 // java/io/PrintStream.println:(I)V
#5 = Class #24 // java/lang/Object
#6 = Utf8 A
#7 = Utf8 I
#8 = Utf8 ConstantValue
#9 = Integer 1
#10 = Utf8 <init>
#11 = Utf8 ()V
#12 = Utf8 Code
#13 = Utf8 LineNumberTable
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 SourceFile
#17 = Utf8 TestJvm.java
#18 = NameAndType #10:#11 // "<init>":()V
#19 = Class #25 // java/lang/System
#20 = NameAndType #26:#27 // out:Ljava/io/PrintStream;
#21 = Utf8 com/haokai/common/test/TestJvm
#22 = Class #28 // java/io/PrintStream
#23 = NameAndType #29:#30 // println:(I)V
#24 = Utf8 java/lang/Object
#25 = Utf8 java/lang/System
#26 = Utf8 out
#27 = Utf8 Ljava/io/PrintStream;
#28 = Utf8 java/io/PrintStream
#29 = Utf8 println
#30 = Utf8 (I)V
{
private static final int A;
descriptor: I
flags: ACC_PRIVATE, ACC_STATIC, ACC_FINAL
ConstantValue: int 1
public com.haokai.common.test.TestJvm();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iconst_1
4: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
7: return
LineNumberTable:
line 8: 0
line 9: 7
}
SourceFile: "TestJvm.java"
解析阶段
解析阶段官网介绍的这段话,其实就是一个意思,符号引用转变为直接引用。
解析阶段就干了一件事情,把类中的符号引用,转换为直接引用。而符号引用在反编译的字节码文件中,就是用来描述引用的目标的,可以是任何的字面量。如下图:
符号引用:是在文件中的,并没有被加载到内存中。
直接引用:就是这些符号引用它要变成所谓的运行时数据结构,真正的指向目标的地址,它要储存一个指针。也就是说,直接引用就是直接指向目标的指针,对于在文件上中符号引用内容,为其真正的开辟内存,并指向它。也就是把文件上的东西真正在内存上做一个落地。
所以解析阶段就干一件事,就是将常量池(Constant pool)内的符号引用替换为直接引用的过程,而直接引用是和JVM内存布局相关的,同一个符号引用在不同的JVM上,实际上翻译出来的直接引用,一般不会相同。
如果有直接引用,直接引用的目标必定会在内存当中,而符号引用则不一定。因为符号引用指的就是文件上的那些标识符,所以它叫符号。
那么同一个符号,有可能会进行多次解析,除了动态invokedynamic指令以外(lambda表达式),JVM可以对第一次的解析结果进行缓存,也就是说,除了invokedynamic指令,只要是符号引用解析后变为直接引用,就会进行缓存,来避免解析动作重复进行,至于是否执行力多次解析操作,这个时候,其实JVM保证了如果一个符号引用已经被解析过了,那么后续这个解析就直接返回它成功的结果,如果说第一次解析失败,那以后的解析也会是失败的。
动态invokedynamic指令(lambda表达式)
动态invokedynamic指令,其实就是Java7中间引入的一个新的JVM的指令,也是继1.0之后,第一次加入新的指令,虽然是Java7开始引入,但是直到Java8才开始使用。
Java8中有一个特性,lambda表达式。它与其它invoke执行方法不同的是,它允许在应用级别的方法来决定方法解析,也就是说需要动态的去解析,因此加入了invokedynamic指令。
这块内容知道即可,符号引用内容会被缓存,而invokedynamic指令除外,也就是说lambda表达式除外,知道这个就行。
3.初始化
初始化阶段讲的简单点,就是执行class init方法的过程。
初始化可以分为三种情况:类变量初始化、类初始化、有直接父类的类初始化。
类变量初始化:之前在准备阶段,类变量已经赋值过一次系统要求的默认值了,但是这是JVM给赋的默认值,而在初始化阶段,需要赋值上程序员真正想要给到它的值。而Java中间,对类变量设置初始值只有两种方式:
- 指令类变量值:直接指定类变量值
- 静态代码块
按照程序员的逻辑来看,必须将静态变量定义在静态代码块之前。因为这两个的执行,是根据代码编写的顺序来的,也就是说需要把静态变量写在静态代码块之前,顺序不对可能会影响业务代码。
类初始化:在对于类的初始化的时候,JVM是按需加载,用到的时候才会加载,假如这个类想要用到的时候,还没有进行装载和链接这两个步骤,需要先进行这两步才能继续初始化。
有直接父类的类初始化:如果说这个类有直接父类,那么这个时候需要先初始化这个父类,才会去初始化子类。如果类中有初始化语句,还会一次执行这些初始化语句。
初始化什么时候会被触发?
初始化什么时候会被触发?换句话说,类初始化的时机是什么?
实际上,只有用到这个类的时候才回去初始化,因为是按需来的,即主动使用到的时候,才会导致类进行一个初始化,这叫类的主动引用。
主动引用分为下面6中情况:
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(如Class.forName(“com.aaa.Test”))
- 初始化某个类的子类,则其父类也会被初始化
- Java虚拟机启动时被标名为启动类的类(JvmCaseApplication,就是你带main方法的那个类),直接使用java.exe命令来运行某个主类、
被动引用:还会有一种情况,在不经意之间,有可能会用到其它的类,但是并不进行类的初始化,这种情况叫做被动引用 。
被动引用分为三种情况:
- 子类引用父类的静态字段,只会引起父类初始化,而不会引起子类初始化
- 使用类的final常量不会引起类的初始化,因为常量赋值是ConstantValue
- 定义类组不会引起类的初始化
主动引用和被动引用面试会问,笔试题也会有
卸载
类卸载的情况是非常非常少的,需要同时满足下面三种情况,才会被卸载:
- 该类所有的实例都已被回收,也就是Java堆中不存在该类的任何实例
- 加载该类的ClassLoader(ClassLoader是加载这个类的类加载器)已经被回收
- 该类对应的java.lang.Class对象没有任何地方引用,无法在任何地方通过反射访问该类的方法
JVM本身都会引用这些ClassLoader,而这些ClassLoader本身始终会引用加载它的Class对象,所以,一般情况下,2和3的要求是很难达到的。
但是有一种情况会达到,比如说自定义的类加载器,有被卸载的需求,一般情况下会调用它的System.gc(),System.gc()只是通知JVM想去做一次GC操作,会通知GC进行回收,而并不是一调用,就会马上进行回收,因此一般在System.gc()之后,会睡个500ms~1000ms。
所以说卸载的时间是不确定的,一般要对类加载器进行自定义的话,一般对卸载这块是没有需求进行开发的。从正常业务代码的角度来看,是接触不到2和3的,因为卸载不了,正常代码中,一般页不会写到回收机制这块。
类加载机制应该做的事图示
什么是类加载器?
类加载器是负责读取Java字节码代码,并且将其转换成一个java.lang.Class这样的一个实例的一个代码模块。
还有一个功效是用于确定类在虚拟机中的唯一性?
什么意思呢,就是一个类,在加载到同一个类加载器中具有唯一性,不同的类加载器是允许同名类存在的,而相同的类加载器是不允许同名类存在的。
比如你自己建一个java.lang.String这样一个类,也是可以正常加载的,这是因为类加载器是有分层的,类加载器是分级别的。
代码:
public class Demo {
public static void main(String[] args) {
// AppClassLoader
System.out.println(new Demo().getClass().getClassLoader());
// ExtClassLoader
System.out.println(new Demo().getClass().getClassLoader().getParent());
// Bootstrap ClassLoader
System.out.println(new Demo().getClass().getClassLoader().getParent().getParent());
System.out.println(new String().getClass().getClassLoader());
/**
* 输出结果如下:之所以还有null,是因为Bootstrap ClassLoader的本质是C层面的东西,在Java层面看不到
* sun.misc.Launcher$AppClassLoader@18b4aac2
* sun.misc.Launcher$ExtClassLoader@1e80bfe8
* null
* null
*/
}
}
Java的类加载机制的三种方式
1.全盘负责
全盘负责机制也叫做当前类加载机制。当一个类加载器负责加载某个class的时候,它所依赖和引用的其它class都应该由当前的这个类加载器负责载入,除非是你显式要求使用另一个加载器,否则就是当前的加载器一起加载。
2.父类委托
也就是常说的双亲委派机制(之所以叫双亲委派是翻译问题)。比如你自定义了一个String类,负责加载的类加载器是AppClassLoader,那么它还是会往上再去寻找更高的信任级别的类加载器,ExtClassLoader,Bootstrap ClassLoader,如果说Bootstrap ClassLoader没有,才会去加载下面层级的。只有当父类的加载器找不到字节码文件的时候,才会从自己的类路径中查找并装载目标类。父类委托加载的具体过程:首先一定是判断顶层类是否被加载,如果顶层类有被加载的话,剩下的全都不管,永远只加载一个,永远只加载级别高的。父类委派仅仅只是Java推荐的机制,可以通过继承它的ClassLoader去实现自己的类加载器。
3. 缓存机制
会保证所有加载过的class,都在内存中进行缓存。当程序需要用某个class的时候。类加载器肯定首先优先从内存的那块区域中进行寻找,找该块class,只有当缓存区不存在的时候,才会去读类对应的二进制数据,再将它转换为对应的class对象,如果有就不读,这也是为什么修改了class的时候,必须重启JVM的原因,重启才会生效。
对于类加载器来说,相同的全限定名,永远只加载一次。
而jdk8用到的是直接内存,也就是元空间,所以说,我们会用到直接内存来做缓存,这也是为什么类变量指挥初始化一次。这个可以看ClassLoader源码发现:
loadClass()方法
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 第一步再虚拟机的内存当中检查你需要选择的类是否已经加载完成
Class<?> c = findLoadedClass(name);// 这个方法就是类缓问题存在的主要方法
if (c == null) {
long t0 = System.nanoTime();
try {
// 这段代码就是父类委派机制的代码
if (parent != null) {// 之前有说过,有父类即代表parent不为空,因此会一直找上层
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);// 如果都没有,会调用findClass(name)这个方法,所以大部分重写类加载器只要重写这个方法即可,并不需要整体重写
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
/**
* 该方法是字节码加载到内存中进行到链接操作,也就是说在这个方法中,
* 会对文件格式以及字节码进行一个验证,并且在这里会为static修饰的开辟初始空间,
* 即准备阶段也是在这里完成的,包括符号引用转换为直接引用,访问控制以及方法覆盖都是在这个方法里的
*/
}
return c;
}
}
父类委托(双亲委派)
父类委托模型实际上并不是一个强制模型,它会带来一些问题,比如说,Java中有一个类叫java.sql.Driver,驱动类,jdk只能提供一个这样的接口规范,不能提供实现,而提供实现的是我们的数据库厂商,而提供商的依赖库,总不能放在Bootstrap ClassLoader下面,它应该是属于扩展类的,也就是ExtClassLoader,而如果根据父类委托这个机制,则在Bootstrap ClassLoader区进行加载,只能加载到java.sql.Driver的接口,而不能加载到它的实现,因此需要打破父类委托机制。
父类委托机制有一种特别的方式可以去进行打破,比如Java1.6中,有一种方式叫做SPI(Service Provide interface,服务提供接口),即jdk只要提供类似java.sql.Driver的接口,供应商提供服务,编程人员编码的时候,直接面向接口编程,直接去做实现,然后jdk又可以自动找到这个实现,包括Java在核心类库中定义了很多接口,并且我们会针对这些接口做调用逻辑。
Java是一门静态语言,如果语言无法动态,那么程序上如何做成动态的呢?
代码热部署,代码热替换,也就是机器不重启,部署上就可以用,但是这样会有问题,就是所谓的OSGI,它可以实现模块化热部署,它的这么做的?它自定义了一个类加载器,然后每一个程序模块都会有自己的类加载器,当需要更换一个程序模块的时候,就将这个程序模块连同类加载器一起干掉,以此实现代码的热替换,能行是能行,但是太恶心了,基本上没人用。这个时候,还有一种方案,就是我们自定义类加载器。
自定义类加载器
合理使用自定义类加载器,最好不要重写loadClass()方法,findClass()方法最好也不要重新给,因为会破坏双亲委派机制。
package com.haokai.common.test;
import java.io.*;
public class MyClassLoader extends ClassLoader {
private String root;
/**
* 核心在于字节吗文件的获取,这里如果字节码有加密。需要在这里进行相应的解密操作
*
* @return
* @throws ClassNotFoundException
*/
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
// 此方法负责将二进制的字节码转换为class对象
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] loadClassData(String className) {
String fileName = root +
File.separatorChar + className.replace('.', File.separatorChar)
+ ".class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public static void main(String[] args) {
MyClassLoader loaderDemo1 = new MyClassLoader();
MyClassLoader loaderDemo2 = new MyClassLoader();
// 这里传入的一定是类的全限定名,也就是你存放该class文件的路径
loaderDemo1.setRoot("D:\\HAOKAI\\haokai-framework\\haokai-common\\src\\main\\java");
loaderDemo2.setRoot("D:\\classPath");
Class<?> demo1Class = null;
Class<?> demo2Class = null;
try {
// 这里传入的一定是类的全限定名,包括文件需要给到相应的权限
demo1Class = loaderDemo1.loadClass("com.haokai.common.test.TestDemo");
System.out.println(demo1Class);
Object demo1 = demo1Class.newInstance();
System.out.println(demo1.getClass().getClassLoader());
// Demo2的java文件和class文件不放在放在类路径下
demo2Class = loaderDemo2.loadClass("TestDemo");
System.out.println(demo2Class);
Object demo2 = demo2Class.newInstance();
System.out.println(demo2.getClass().getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解
密,自定义类加载器常用于加密。由于这里只是演示,我并未对class文件进行加密,因此没有解密的过程。这里有几点需要注意:
- 这里传递的文件名需要是类的全限定性名称,因为 defineClass 方法是按这种格式进行处理的。
如果没有全限定名,那么我们需要做的事情就是将类的全路径加载进去,而我们的setRoot就是前缀地址 setRoot + loadClass的路径就是文件的绝对路径 - 最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。
- 类路径下的com.haokai.common.test.TestDemo类本身可以被 AppClassLoader 类加载,因此我们如果想要使用自定义类加载器进行加载,就不能把 TestDemo.class 放在类路径下。否则,由于双亲委托机制的存在,当前类路径下会优先使用父类加载器进行加载,会直接导致该类由AppClassLoader 加载,而不会通过我们自定义类加载器来加载。
运行时注意,需要手动先将Java文件编译成class文件,下面是两个TestDemo的代码
当前类路径下会优先使用父类加载器进行加载,会直接导致该类由AppClassLoader 加载,com.haokai.common.test.TestDemo,注意这里有包名的
package com.haokai.common.test;
public class Demo2 {
public static void main(String[] args) {
System.out.println();
}
}
使用自定义类加载器进行加载,不放在类路径下,自己定义一个路径,
TestDemo,注意这里是没有包名的
public class TestDemo {
public static void main(String[] args) {
System.out.println();
}
}
输出如下:可以看到第二个是用的是MyClassLoader,我们自己定义的加载器
class com.haokai.common.test.TestDemo
sun.misc.Launcher$AppClassLoader@18b4aac2
class TestDemo
com.haokai.common.test.MyClassLoader@1e80bfe8
Tomcat的自定义类加载器
Tomcat也是重写了类加载器,Tomcat的自定义类加载器的所在目录8.0版本和8.5之后的版本不在一个目录,8.0版本已经过时了。
在tomcat8.5后的版本中,放在src\java\org\apache\catalina\loader中。
下载源码,我这里用8.5.89的版本,https://tomcat.apache.org/download-80.cgi
官网链接,点击复制后面的地址即可触发下载:https://dlcdn.apache.org/tomcat/tomcat-8/v8.5.89/src/apache-tomcat-8.5.89-src.zip
云盘链接:源码下载-apache-tomcat-8.5.89-src
解压后,进入apache-tomcat-8.5.89-src\java\org\apache\catalina\loader目录,找到ClassLoader相关的类,
打开可以发现WebappClassLoader extends WebappClassLoaderBase,找到WebappClassLoaderBase的findClass()方法:
@Override
public Class<?> findClass(String name) throws ClassNotFoundException {
if (log.isDebugEnabled()) {
log.debug(" findClass(" + name + ")");
}
checkStateForClassLoading(name);
// (1) Permission to define this class when using a SecurityManager
if (securityManager != null) {
int i = name.lastIndexOf('.');
if (i >= 0) {
try {
if (log.isTraceEnabled()) {
log.trace(" securityManager.checkPackageDefinition");
}
securityManager.checkPackageDefinition(name.substring(0,i));
} catch (Exception se) {
if (log.isTraceEnabled()) {
log.trace(" -->Exception-->ClassNotFoundException", se);
}
throw new ClassNotFoundException(name, se);
}
}
}
// Ask our superclass to locate this class, if possible
// (throws ClassNotFoundException if it is not found)
Class<?> clazz = null;
try {
if (log.isTraceEnabled()) {
log.trace(" findClassInternal(" + name + ")");
}
try {
if (securityManager != null) {
PrivilegedAction<Class<?>> dp =
new PrivilegedFindClassByName(name);
clazz = AccessController.doPrivileged(dp);
} else {
clazz = findClassInternal(name);
}
} catch(AccessControlException ace) {
log.warn(sm.getString("webappClassLoader.securityException", name,
ace.getMessage()), ace);
throw new ClassNotFoundException(name, ace);
} catch (RuntimeException e) {
if (log.isTraceEnabled()) {
log.trace(" -->RuntimeException Rethrown", e);
}
throw e;
}
if ((clazz == null) && hasExternalRepositories) {
try {
clazz = super.findClass(name);
} catch(AccessControlException ace) {
log.warn(sm.getString("webappClassLoader.securityException", name,
ace.getMessage()), ace);
throw new ClassNotFoundException(name, ace);
} catch (RuntimeException e) {
if (log.isTraceEnabled()) {
log.trace(" -->RuntimeException Rethrown", e);
}
throw e;
}
}
if (clazz == null) {
if (log.isDebugEnabled()) {
log.debug(" --> Returning ClassNotFoundException");
}
throw new ClassNotFoundException(name);
}
} catch (ClassNotFoundException e) {
if (log.isTraceEnabled()) {
log.trace(" --> Passing on ClassNotFoundException");
}
throw e;
}
// Return the class we have located
if (log.isTraceEnabled()) {
log.debug(" Returning class " + clazz);
}
if (log.isTraceEnabled()) {
ClassLoader cl;
if (Globals.IS_SECURITY_ENABLED){
cl = AccessController.doPrivileged(
new PrivilegedGetClassLoader(clazz));
} else {
cl = clazz.getClassLoader();
}
log.debug(" Loaded by " + cl.toString());
}
return clazz;
}
为什么要重写?有时候我们需要用到一些扩展类,Java会默认提供一些个该扩展类的接口规范,但是我们要想要操作的话,需要去实现它的类加载器,因为不想让它加载到他的父类上去。