JVM学习-字节码执行引擎
文章原文:https://gaoyubo.cn/blogs/e6df8766.html
一、执行引擎
在Java虚拟机的概念模型中,执行引擎是负责执行虚拟机中的字节码指令集的组件。执行引擎的工作涉及到方法的调用和字节码的执行。在不同的虚拟机实现中,执行引擎的具体实现方式可能会有所不同,但从外观上看,其输入和输出是一致的。执行引擎接收字节码的二进制流作为输入,经过处理执行字节码指令,然后输出执行结果。
执行引擎的工作方式通常包括解释执行和编译执行两种选择,有些虚拟机甚至同时支持两者。具体来说:
- 解释执行: 在解释执行模式下,执行引擎会逐条解释字节码指令,并将其转换为对应的本地机器指令执行。这种方式的优点是简单直观,不需要等待编译过程,可以快速执行。缺点是解释执行效率相对较低,因为每次执行都需要解释一次。
- 编译执行: 在编译执行模式下,执行引擎会通过即时编译器将字节码直接编译成本地机器代码,然后执行编译后的本地代码。这种方式的优点是执行效率高,因为避免了重复解释的开销。缺点是需要在程序运行之前进行编译,可能会增加启动时间。
概念模型提供了虚拟机执行引擎的统一外观,使得不同的虚拟机实现可以在一致的概念框架下工作。这种设计使得Java程序在不同的虚拟机上具有较好的可移植性。
二、运行时栈帧
Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法 调用和方法执行背后的数据结构
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。 在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中
一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
在Java程序中,一个线程中的方法调用链可能会很长。然而,从执行引擎的角度来看,在同一时刻、同一条线程里,只有调用堆栈的栈顶方法处于执行状态。这个正在执行的方法所对应的栈帧被称为当前栈帧(Current Stack Frame),而与这个栈帧相关联的方法被称为当前方法(Current Method)。模型如下图:
执行引擎在活动线程中只关注当前栈帧,只有当前栈帧中的方法才是正在执行的。这种设计简化了执行引擎的实现,使其能够专注于当前正在执行的方法,而不必考虑整个调用链。在执行引擎执行字节码指令时,它只关注当前栈帧,即当前方法的栈帧,所有的操作都是针对这个栈帧进行的。
这种方式也符合Java程序员的直觉,因为在编写Java程序时,通常是从一个方法调用到另一个方法,形成调用链。而在执行过程中,只有最顶层的方法(当前方法)才是直接在执行,其余的方法调用都处于等待状态。这种调用链的执行方式保证了在单线程执行中,方法调用的顺序和预期一致。
2.1局部变量表
- 存放方法参数和方法内部定义的局部变量;
- Java 程序编译为 class 文件时,就确定了每个方法需要分配的局部变量表的最大容量。
- 最小单位:Slot;
- 一个 Slot 中可以存放:boolean,byte,char,short,int,float,reference(对象的实例引用),returnAddress (少见,指向字节码指令的地址,之前用来异常时跳转,后来被异常表取代);
- 虚拟机可通过局部变量表中的 reference 做到:
- 查找 Java 堆中的实例对象的起始地址;
- 查找方法区中的 Class 对象。
局部变量表的空间分配
Slot 的复用
定义: 如果当前位置已经超过某个变量的作用域时,例如出了定义这个变量的代码块,这个变量对应的 Slot 就可以给其他变量使用了。但同时也说明,只要其他变量没有使用这部分 Slot 区域,这个变量就还保存在那里,这会对 GC 操作产生影响。
对 GC 操作的影响:
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
-verbose:gc
输出:
[GC (System.gc()) 68813K->66304K(123904K), 0.0034797 secs]
[Full GC (System.gc()) 66304K->66204K(123904K), 0.0086225 secs] // 没有被回收
进行如下修改:
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 1; // 新加一个赋值操作
System.gc();
}
-verbose:gc
输出:
[GC (System.gc()) 68813K->66320K(123904K), 0.0017394 secs]
[Full GC (System.gc()) 66320K->668K(123904K), 0.0084337 secs] // 被回收了
第二次修改后,placeholder 能被回收的原因?
- placeholder 能否被回收的关键:局部变量表中的 Slot 是否还存在关于 placeholder 的引用;
- 出了 placeholder 所在的代码块后,还没有进行其他操作,所以 placeholder 所在的 Slot 还没有被其他变量复用,也就是说,局部变量表的 Slot 中依然存在着 placeholder 的引用;
- 第二次修改后,int a 占用了原来 placeholder 所在的 Slot,所以可以被 GC 掉了。
2.2操作数栈
- 元素可以是任意 Java 类型,32 位数据占 1 个栈容量,64 位数据占 2 个栈容量;
- Java 虚拟机的解释执行称为:基于栈的执行引擎,其中 “栈” 指的就是操作数栈;
在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。
但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。
让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了
2.3动态连接
- 指向运行时常量池中该栈帧所属方法的引用;
- 为了支持方法调用过程中的动态连接,什么是动态连接会在下一篇文章进行讲解,先知道有这么个东西就行。
2.4方法返回地址
-
两种退出方法的方式:
- 遇到 return;
- 遇到异常。
无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,即有一个返回到最初方法的地址。
正常退出:主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。
异常退出:返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。 -
退出方法时可能执行的操作:
- 恢复上层方法的局部变量表和操作数栈;
- 把返回值压入调用者栈帧的操作数栈;
- 调整 PC 计数器指向方法调用后面的指令。
2.5附加信息
Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、 性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。
在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
三、方法调用
Java 的方法的执行分为两个部分:
- 方法调用:确定被调用的方法是哪一个;
- 基于栈的解释执行:真正的执行方法的字节码。
在本节中我们将对方法调用进行详细的讲解,我们知道,一切方法的调用在 Class 文件中存储的都是常量池中的符号引用,而不是方法实际运行时的入口地址(直接引用),直到类加载的时候,甚至是实际运行的时候才回去会去确定要被运行的方法的直接引用,而确定要被运行的方法的直接引用的过程就叫做方法调用。
3.1字节码指令
Java 虚拟机提供了 5 个职责不同的方法调用字节码指令:
invokestatic
:调用静态方法;invokespecial
:调用构造器方法、私有方法、父类方法;invokevirtual
:调用所有虚方法,除了静态方法、构造器方法、私有方法、父类方法、final 方法的其他方法叫虚方法;invokeinterface
:调用接口方法,会在运行时确定一个该接口的实现对象;invokedynamic
:在运行时动态解析出调用点限定符引用的方法,再执行该方法。
除了 invokedynamic
,其他 4 种方法的第一个参数都是被调用的方法的符号引用,是在编译时确定的,所以它们缺乏动态类型语言支持,因为动态类型语言只有在运行期才能确定接收者的类型,即变量的类型检查的主体过程在运行期,而非编译期。
final 方法虽然是通过
invokevirtual
调用的,但是其无法被覆盖,没有其他版本,无需对接收者进行多态选择,或者说多态选择的结果是唯一的,所以属于非虚方法。
3.2解析调用
解析调用,正如其名,就是 在类加载的解析阶段,就确定了方法的调用版本 。我们知道类加载的解析阶段会将一部分符号引用转化为直接引用,这一过程就叫做解析调用。因为是在程序真正运行前就确定了要调用哪一个方法,所以 解析调用能成立的前提就是:方法在程序真正运行前就有一个明确的调用版本了,并且这个调用版本不会在运行期发生改变。
符合这两个要求的只有以下两类方法:
- 通过
invokestatic
调用的方法:静态方法; - 通过
invokespecial
调用的方法:私有方法、构造器方法、父类方法;
这两类方法根本不可能通过继承或者别的方式重写出来其他版本,也就是说,在运行前就可以确定调用版本了,十分适合在类加载阶段就解析好。它们会在类加载的解析阶被解析为直接引用,即确定调用版本。
3.3分派解析
Java 所具备的面向对象的 3 个基本特征:封装,继承,多态。
其中多态最基本的体现就是重载和重写了,重载和重写的一个重要特征就是方法名相同,其他各种不同:
- 重载:发生在同一个类中,入参必须不同,返回类型、访问修饰符、抛出的异常都可以不同;
- 重写:发生在子父类中,入参和返回类型必须相同,访问修饰符大于等于被重写的方法,不能抛出新的异常。
相同的方法名实际上给虚拟机的调用带来了困惑,因为虚拟机需要判断,它到底应该调用哪个方法,而这个过程会在分派调用中体现出来。其中:
- 方法重载 —— 静态分派
- 方法重写 —— 动态分派
静态分派(方法重载)
在介绍静态分派前,我们先来介绍一下什么是变量的静态类型和实际类型。
public class StaticDispatch {
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
public void sayHello(Human guy) {
System.out.println("Hello guy!");
}
public void sayHello(Man man) {
System.out.println("Hello man!");
}
public void sayHello(Woman woman) {
System.out.println("Hello woman!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
/* 输出:
Hello guy!
Hello guy!
因为是根据变量的静态类型,也就是左面的类型:Human 来判断调用哪个方法,
所以调用的都是 public void sayHello(Human guy)
*/
}
}
其中 Human
称为变量的静态类型,Man
称为变量的实际类型。
在重载时,编译器是通过方法参数的静态类型,而不是实际类型,来判断应该调用哪个方法的。
静态分派就是通过方法的参数(类型 & 个数 & 顺序)这种静态的东西来判断到底调用哪个方法的过程。**
重载方法匹配优先级
例如一个字符 'a' 作为入参
- 基本类型
- char
- int
- long
- float
- double
- Character
- Serializable(Character 实现的接口)
- 同时出现两个优先级相同的接口,如 Serializable 和 Comparable,会提示类型模糊,拒绝编译。
- Object
- char...(变长参数优先级最低)
动态分派(方法重写)
动态分派就是在运行时,根据实际类型确定方法执行版本的分派过程。
动态分派的过程
我们先来看一个例子:
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
protected void sayHello() {
System.out.println("Hello man");
}
}
static class Woman extends Human {
protected void sayHello() {
System.out.println("Hello woman");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = woman;
man.sayHello();
/* 输出
Hello man
Hello woman
Hello woman
*/
}
}
字节码分析:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class com/jvm/ch8/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method com/jvm/ch8/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class com/jvm/ch8/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method com/jvm/ch8/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1 // 把刚创建的对象的引用压到操作数栈顶,
// 供之后执行sayHello时确定是执行哪个对象的sayHello
17: invokevirtual #6 // 方法调用
20: aload_2 // 把刚创建的对象的引用压到操作数栈顶,
// 供之后执行sayHello时确定是执行哪个对象的sayHello
21: invokevirtual #6 // 方法调用
24: aload_2
25: astore_1
26: aload_1
27: invokevirtual #6 // Method com/jvm/ch8/DynamicDispatch$Human.sayHello:()V
30: return
通过字节码分析可以看出,invokevirtual
指令的运行过程大致为:
- 去操作数栈顶取出将要执行的方法的所有者,记作 C;
- 查找此方法:
-
- 在 C 中查找此方法;
- C中不存在则在 C 的各个父类中查找;
- 查找过程:
- 查找与常量的描述符和简单名称都相同的方法;
- 进行访问权限验证,不通过抛出:IllegalAccessError 异常;
- 通过访问权限验证则返回直接引用;
- 没找到则抛出:AbstractMethodError 异常,即该方法没被实现。
动态分派的实现
动态分派在虚拟机种执行的非常频繁,而且方法查找的过程要在类的方法元数据中搜索合适的目标,从性能上考虑,不太可能进行如此频繁的搜索,需要进行性能上的优化。
常用优化手段: 在类的方法区中建立一个虚方法表。
- 虚方法表中存放着各个方法的实际入口地址,如果某个方法没有被子类方法重写,那子类方法表中该方法的入口地址 = 父类方法表中该方法的入口地址;
- 使用这个方法表索引代替在元数据中查找;
- 该方法表会在类加载的连接阶段初始化好。
通俗的讲,动态分派就是通过方法的接收者这种动态的东西来判断到底调用哪个方法的过程。
静态分派看左面,动态分派看右面。
字段没有多态性
多态性的根源是虚方法调用指令invokevirtual
的执行逻辑。这种多态性仅适用于方法,对字段是无效的,因为字段不使用这条指令。在Java中,只有方法存在虚拟性,字段永远不可能是虚拟的。
换句话说,字段不参与多态性。当一个类的方法访问某个字段时,该字段指的是该类能够看到的那个字段。当子类声明了与父类同名的字段时,尽管在子类的内存中两个字段都存在,但子类的字段会遮蔽父类的同名字段。
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, I have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Son, I have $" + money);
}
}
public static void main(String[] args) {
Father guy = new Son();
System.out.println("This guy has $" + guy.money);
/*
输出结果:
I am Son, i have $0
I am Son, i have $4
This gay has $2
*/
}
}
解释:
输出两句都是“I am Son”,这是因为Son类在创建的时候,首先隐式调用了Father的构造函数,而Father构造函数中对showMeTheMoney()的调用是一次虚方法调用,实际执行的版本是Son::showMeTheMoney()方法,所以输出的是“I am Son”。
- 此时但Son::showMeTheMoney()方法中访问的却是子类的money字段,这时候结果自然还是0,因为它要到子类的构造函数执行时才会被初始化。
- 然后显示调用Son类的构造函数,此时money已经被初始化为4了,所以输出4。
- main()的最后一句通过静态类型访问到了父类中的money,输出了2。
3.4单分派与多分派
除了静态分派和动态分派这种分派分类方式,还有一种根据宗量分类的方式,可以将方法分派分为单分派和多分派。
宗量:方法的接收者 & 方法的参数。
Java 语言的静态分派属于多分派,根据 方法接收者的静态类型 和 方法参数类型 两个宗量进行选择。
Java 语言的动态分派属于单分派,只根据 方法接收者的实际类型 一个宗量进行选择。
3.5总结
Java语言可以被分类为静态多分派、动态单分派的语言,这个结论适用于Java 12和Java 13这些版本。需要强调的是,这个结论并不是永久不变的,未来Java语言的发展可能会引入新的特性改变这个分类。
举例来说,C#在3.0版本及之前与Java类似,也是动态单分派语言。然而,C#在4.0版本引入了dynamic
类型,使得动态多分派变得更加方便。
在Java中,虽然在JDK 10中引入了var
关键字,但是请不要将其与C#中的dynamic
类型混淆。实际上,Java的var
与C#的var
是相对应的特性,它们都是语法糖,用于在编译时根据右侧表达式的类型进行静态类型推断。
与C#的dynamic
类型相对应的,是在JDK 9中引入的jdk.dynalink
模块。通过使用jdk.dynalink
,可以在Java表达式中使用动态类型。编译器(Javac)会将这些动态类型的操作转化为invokedynamic
指令的调用点。
- Java语言在静态类型分派上是多分派的,即根据方法参数的静态类型来决定调用哪个方法。
- 在动态类型分派上是单分派的,即根据接收者的动态类型来决定调用哪个方法。
- 需要注意的是,Java的
var
关键字与C#的dynamic
类型是不同的概念,Java中与C#的dynamic
类型类似的功能可以通过jdk.dynalink
模块来实现。
四、动态类型语言支持
什么是动态类型语言?
就是类型检查的主体过程在运行期,而非编译期的编程语言。
动/静态类型语言各自的优点?
- 动态类型语言:灵活性高,开发效率高。
- 静态类型语言:编译器提供了严谨的类型检查,类型相关的问题能在编码的时候就发现。
Java虚拟机层面提供的动态类型支持:
invokedynamic
指令- java.lang.invoke 包
java.lang.invoke 包
目的: 在之前的依靠符号引用确定调用的目标方法的方式之外,提供了 MethodHandle 这种动态确定目标方法的调用机制。
MethodHandle 的使用
-
获得方法的参数描述,第一个参数是方法返回值的类型,之后的参数是方法的入参:
MethodType mt = MethodType.methodType(void.class, String.class);
-
获取一个普通方法的调用:
/** * 需要的参数: * 1. 被调用方法所属类的类对象 * 2. 方法名 * 3. MethodType 对象 mt * 4. 调用该方法的对象 */ MethodHandle.lookup().findVirtual(receiver.getClass(), "方法名", mt).bindTo(receiver);
-
获取一个父类方法的调用:
/** * 需要的参数: * 1. 被调用方法所属类的类对象 * 2. 方法名 * 3. MethodType 对象 mt * 4. 调用这个方法的类的类对象 */ MethodHandle.lookup().findSpecial(GrandFather.class, "方法名", mt, getClass());
-
通过
MethodHandle mh
执行方法:/* invoke() 和 invokeExact() 的区别: - invokeExact() 要求更严格,要求严格的类型匹配,方法的返回值类型也在考虑范围之内 - invoke() 允许更加松散的调用方式 */ mh.invoke("Hello world"); mh.invokeExact("Hello world");
使用示例:
public class MethodHandleTest {
static class ClassA {
public void println(String s) {
System.out.println(s);
}
}
public static void main(String[] args) throws Throwable {
/*
obj的静态类型是Object,是没有println方法的,所以尽管obj的实际类型都包含println方法,
它还是不能调用println方法
*/
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
/*
invoke()和invokeExact()的区别:
- invokeExact()要求更严格,要求严格的类型匹配,方法的返回值类型也在考虑范围之内
- invoke()允许更加松散的调用方式
*/
getPrintlnMH(obj).invoke("Hello world");
getPrintlnMH(obj).invokeExact("Hello world");
}
private static MethodHandle getPrintlnMH(Object receiver)
throws NoSuchMethodException, IllegalAccessException {
/* MethodType代表方法类型,第一个参数是方法返回值的类型,之后的参数是方法的入参 */
MethodType mt = MethodType.methodType(void.class, String.class);
/*
lookup()方法来自于MethodHandles.lookup,
这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄
*/
/*
因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,
也即是this指向的对象,这个参数以前是放在参数列表中进行传递,现在提供了bindTo()方法来完成这件事情
*/
return MethodHandles.lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
}
}
MethodHandles.lookup 中 3 个方法对应的字节码指令:
findStatic()
:对应 invokestaticfindVirtual()
:对应 invokevirtual & invokeinterfacefindSpecial()
:对应 invokespecial
MethodHandle 和 Reflection 的区别
- 本质区别: 它们都在模拟方法调用,但是
- Reflection 模拟的是 Java 代码层次的调用;
- MethodHandle 模拟的是字节码层次的调用。
- 包含信息的区别:
- Reflection 的 Method 对象包含的信息多,包括:方法签名、方法描述符、方法的各种属性的Java端表达方式、方法执行权限等;
- MethodHandle 对象包含的信息比较少,既包含与执行该方法相关的信息。
invokedynamic
指令
Lambda 表达式就是通过 invokedynamic
指令实现的。
五、基于栈的字节码解释执行引擎
这个栈,就是栈帧中的操作数栈。
与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令 集,如果说得更通俗一些就是现在我们主流PC机中物理硬件直接支持的指令集架构,这些指令依赖寄 存器进行工作。
解释执行
先通过 javac 将代码编译成字节码,虚拟机再通过加载字节码文件,解释执行字节码文件生成机器码,解释执行的流程如下:
词法分析 -> 语法分析 -> 形成抽象语法树 -> 遍历语法树生成线性字节码指令流
指令集分类
基于栈的指令集
-
优点:
- 可移植:寄存器由硬件直接提供,程序如果直接依赖这些硬件寄存器,会不可避免的受到硬件的约束;
- 代码更紧凑:字节码中每个字节对应一条指令,多地址指令集中还需要存放参数;
- 编译器实现更简单:不需要考虑空间分配问题,所需的空间都在栈上操作。
-
缺点: 执行速度稍慢
- 完成相同的功能,需要更多的指令,因为出入栈本身就产生相当多的指令;
- 频繁的栈访问导致频繁的内存访问,对于处理器而言,内存是执行速度的瓶颈。
-
示例: 两数相加
iconst_1 // 把常量1入栈 iconst_1 iadd // 把栈顶两元素出栈相加,结果入栈 istore_0 // 把栈顶值存入第0个Slot中
基于寄存器的指令集
示例: 两数相加
mov eax, 1
add eax, 1
执行过程分析
public class Architecture {
/*
calc函数的字节码分析:
public int calc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1 // stack=2,说明需要深度为2的操作数栈
// locals=4,说明需要4个Slot的局部变量表
0: bipush 100 // 将单字节的整型常数值push到操作数栈
2: istore_1 // 将操作数栈顶的整型值出栈并存放到第一个局部变量Slot中
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1 // 将局部变量表第一个Slot中的整型值复制到操作数栈顶
12: iload_2
13: iadd // 将操作数栈中头两个元素出栈并相加,将结果重新入栈
14: iload_3
15: imul // 将操作数栈中头两个元素出栈并相乘,将结果重新入栈
16: ireturn // 返回指令,结束方法执行,将操作数栈顶的整型值返回给此方法的调用者
*/
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
public static void main(String[] args) {
Architecture architecture = new Architecture();
architecture.calc();
}
}
六、实现 Java 类的热替换
什么是热替换及其实现原理
- 热替换是在不停止正在运行的系统的情况下进行类(对象)的升级替换;
- 这要求虚拟机中要存在同一个类的两个不同版本。可我们知道,我们是无法将同一个类加载两遍的,想要实现这点,我们需要让虚拟机认为这是两个不同的类,即用两个不同的类加载器去加载这个类不同版本的 class 文件;
- 因此,这个工作就不能由系统提供给我们的启动类加载器,扩展类加载器或者应用程序类加载器来完成,因为这三个类加载器在同一个虚拟机中只有一份,不仅如此,我们还要跳过这些类加载器;
- 想要跳过这些类加载器可不是只要不用这些类加载器就行了,还需要我们跳过双亲委派模型,否则类的加载还会被委派到这些个类加载器,如果恰好某个类之前是由这三个类加载器中的一个加载的,虚拟机就不会再次加载新版本的类了,就无法实现类的热替换了。
实现简单的 Java 类热替换
需求分析
现有一 Foo 类,可以在控制台持续打印:Hello world! version one,我们将在该类运行时,将其 .class 文件替换为修改后的 Foo 类的 .class 文件,修改后的 Foo 会在控制台持续打印:Hello world! version two。也就是说,替换之后,控制台打印的内容发生变化,就说明类的热替换实现成功。
Foo 类的实现:
public class Foo {
public void sayHello() {
System.out.println("Hello world! version one");
// System.out.println("Hello world! version two"); // 之后替换成这个
}
}
然后我们通过如下程序运行 Foo 类:
public class Task extends TimerTask {
@Override
public void run() {
String basePath = "C:\\Users\\Bean\\IdeaProjects\\USTJ\\target\\classes";
// 每执行一次任务都 new 一个新的类加载器
HotswapClassLoader cl = new HotswapClassLoader(
basePath, new String[]{"com.jvm.ch7.hotswap.Foo"});
try {
// 通过我们自己实现的类加载器加载 Foo 类
Class cls = cl.loadClass("com.jvm.ch7.hotswap.Foo", true);
Object foo = cls.newInstance();
Method method = cls.getMethod("sayHello", new Class[]{});
method.invoke(foo, new Object[]{});
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new Task(), 0, 1000);
}
}
实现类加载器
HotswapClassLoader
的实现如下,具体的讲解已被写入注释中:
public class HotswapClassLoader extends ClassLoader {
private String basePath;
private HashSet<String> loadedClass; // 用来记录被这个类加载器所加载的类
public HotswapClassLoader(String basePath, String[] classList) {
// 跳过父类加载器,把它设为null
super(null);
this.basePath = basePath;
loadedClass = new HashSet<>();
// 该类加载器在初始化的时候会直接把应该它负责加载的类加载好,
// 这样之后 loadClass 时,会在第一步检验该类是否已经被加载时发现该类已经被加载过了,
// 就无需执行 loadClass 之后的流程,直接返回虚拟机中被加载好的类即可,
// 这样虽然初始化的时间长了点,但是之后 loadClass 时会比较省时间
loadClassByMe(classList);
}
/**
* 加载给定的的 classList 中的类到虚拟机
*/
private void loadClassByMe(String[] classList) {
for (int i = 0; i < classList.length; i++) {
Class cls = loadClassDirectly(classList[i]);
if (cls != null) {
loadedClass.add(classList[i]);
}
}
}
/**
* 通过文件名直接加载类,得到Class对象
*/
private Class loadClassDirectly(String className) {
Class cls = null;
StringBuilder sb = new StringBuilder(basePath);
String classPath = className.replace(".", File.separator) + ".class";
sb.append(File.separator + classPath);
File file = new File(sb.toString());
InputStream fin = null;
try {
fin = new FileInputStream(file);
// 将字节流转化成内存中的Class对象
cls = instantiateClass(className, fin, (int) file.length());
return cls;
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fin != null) {
try {
fin.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
/**
* 将字节流转化成内存中的Class对象啊,使用defineClass方法!
*/
private Class instantiateClass(String name, InputStream fin, int len) {
byte[] buffer = new byte[len];
try {
fin.read(buffer);
return defineClass(name, buffer, 0, len);
} catch (IOException e) {
e.printStackTrace();
} finally {
if (fin != null) {
try {
fin.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return null;
}
/**
* 覆盖原有的loadClass规则,
*/
public Class loadClass(String name, boolean resolve) throws ClassNotFoundException {
Class cls = null;
// 应该由 HotswapClassLoader 负责加载的类会通过下面这一行得到类的 Class 对象,
// 因为早在 HotswapClassLoader 类加载器执行构造函数时,它们就被加载好了
cls = findLoadedClass(name);
// 只有在这个类没有被加载,且!这个类不是当前这个类加载器负责加载的时候,才去使用启动类加载器
if (cls == null && !loadedClass.contains(name)) {
cls = findSystemClass(name);
}
if (cls == null) {
throw new ClassNotFoundException(name);
}
// resolveClass是进行连接操作的,即"验证+准备+解析",之后就可以进行初始化了
if (resolve) {
resolveClass(cls);
}
return cls;
}
}