程序员第一个(玩具)JVM
程序员第一个(玩具)JVM
我们都知道Java程序要运行在JVM之上,我们除了面试时会了解下JVM的面试题,之外可能很少会去想JVM是如何工作的。在这篇文章中,我会尝试写一个玩具JVM来展示其背后的核心原理,希望激发你进一步学习的兴趣。
一个简单的目标
package me.kagami.myjvm;
public class Add {
public static int add(int a, int b) {
return a + b;
}
}
首先使用javac Add.java
编译后得到Add.class
文件。该文件是JVM可以执行的二进制文件。接下来要做的事情就是正确地实现一个能够执行Add.class
文件的JVM。
CLASS LOADER
JVM的工作之一是类加载,那么我们需要了解class文件的内容。
如果我们用hexdump插件以16进制视图打开Add.class
文件,我们可以看到class文件的组织形式,但我们现在还完看不懂这个文件。没关系,本文将手把手介绍怎么阅读class文件。
如果查看Java规格说明,那么我们可以学习到classFile的结构。可以看到classFile文件总是以4字节的magic数开头(CAFEBABE)然后是2+2的版本信息 ,以此类推。而cp_info,field_info,method_info,attribute_info会比较复杂,本文具体以cp_info详细说明,只要会看cp_info后,其他三个都一样。
ClassFile {
u4 magic;
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];
}
我们以上文的Add.class
举例
magic[CA FE BA BE] minor_version[00 00] major_version[00 34] constant_pool_count[00 15] 0A 00 03 00 12 07
常量池计数是00 15
,0x15
换成十进制是21,规格说明里有这么一句话:
The
constant_pool
table is indexed from 1 toconstant_pool_count
- 1.
说明常量的个数应该是21-1为20个。那么说明constant_pool_count后面有20个常量信息,那么我们来看看常量池是怎么排列的吧。
根据Java规则说明,cp_info的结构如下:
cp_info {
u1 tag;
u1 info[];
}
忽略info前面的u1,因为规格说明里有这么一句话,说明tag后面是可能有多个字节的:
Each tag byte must be followed by two or more bytes giving information about the specific constant.
我们以上文的Add.class
举例,constant_pool_count后第一个tag是 0A
CA FE BA BE 00 00 00 34 constant_pool_count[00 15] 0A 00 03 00 12 07
根据Java规格说明,我们查看tag表类别
Constant Kind | Tag |
---|---|
CONSTANT_Class | 7 |
CONSTANT_Fieldref | 9 |
CONSTANT_Methodref | 10 |
CONSTANT_InterfaceMethodref | 11 |
CONSTANT_String | 8 |
CONSTANT_Integer | 3 |
CONSTANT_Float | 4 |
CONSTANT_Long | 5 |
CONSTANT_Double | 6 |
CONSTANT_NameAndType | 12 |
CONSTANT_Utf8 | 1 |
CONSTANT_MethodHandle | 15 |
CONSTANT_MethodType | 16 |
CONSTANT_Dynamic | 17 |
CONSTANT_InvokeDynamic | 18 |
CONSTANT_Module | 19 |
CONSTANT_Package | 20 |
0A
换成十进制是10,所以第一个常量应该是CONSTANT_Methodref类型,那么我们再根据Java规格说明查看CONSTANT_Methodref类型的格式为:
CONSTANT_Methodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
所以我们继续看Add.class
文件,class_index和name_and_type_index都是常量池的引用,也就是说,class_index指向的是常量池的第3常量,name_and_type_index指向的是第18(0x12为十进制18)常量。
CA FE BA BE 00 00 00 34 constant_pool_count[00 15] CONSTANT_Methodref_info[0A class_index(00 03) name_and_type_index(00 12)] 07
常量池的看法我想你应该能看懂了,那么我现在直接给出常量池的全部解析后的结果,我们直接看第3和第18常量是什么吧。以下是按照tag表分出来的20个常量:
CA FE BA BE 00 00 00 34 constant_pool_count[00 15] [0A 00 03 00 12] [0700 13] [07 00 14] CONSTANT_Utf8_info [01 (00 06) 3C 69 6E 69 74 3E] [01 00 03 28 29 56] [01 00 04 43 6F 64 65] [01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65] [01 00 12 4C 6F 63 61 6C 56 61 72 69 61 62 6C 65 54 61 62 6C 65] [01 00 04 74 68 69 73] [01 00 15 4C 6D 65 2F 6B 61 67 61 6D 69 2F 6D 79 6A 76 6D 2F 41 64 64 3B] [01 00 03 61 64 64] [01 00 05 28 49 49 29 49] [01 00 01 61] [01 00 01 49] [01 00 01 62] [01 00 0A 53 6F 75 72 63 65 46 69 6C 65] [01 00 08 41 64 64 2E 6A 61 76 61] [0C 00 04 00 05] [01 00 13 6D 65 2F 6B 61 67 61 6D 69 2F 6D 79 6A 76 6D 2F 41 64 64] [01 00 10 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74] 00 21 00 02 00 03 00 00 00 00 00 02 00 01 00
....
从中可以看出第3常量是[07 00 14]
其中tag为CONSTANT_Class,其结构为:
CONSTANT_Class_info {
u1 tag;
u2 name_index;
}
说明name_index也是一个常量的引用,0x14
指向的是第20常量,它是一个CONSTANT_Utf8_info常量,这种常量是utf8表示的字符串,结构是:
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
所以第20常量[01 00 10 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74]
的长度是0x0010
,也就是16个字符,从dump列可以用看出这16个字符是:java/lang/Object
,同理可得第18常量为:0x04
是<init>
,0x05
是()V
。
那么第1个常量连起来就是java/lang/Object<init>()V
,但这表示是什么意思呢 ?我们翻阅Java规格说明针对CONSTANT_Methodref_info找到了这么一句话:
If the name of the method in a
CONSTANT_Methodref_info
structure begins with a '<
' ('\u003c
'), then the name must be the special name<init>
, representing an instance initialization method (§2.9.1). The return type of such a method must bevoid
.
原来表示的是初始化方法,继续看文档我们又找到了一句话看完之后就更明确了:
A method is an instance initialization method if all of the following are true:
- It is defined in a class (not an interface).
- It has the special name
<init>
.- It is
void
(§4.3.3).
综上所述,第1个常量应该表示的是初始化方法的字符引用。
好了,cp_info我们都能看懂了,那么剩下的应该都不足为惧了,我们看看还剩下什么:
......
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];
这里我先给出所有剩余字段的解析,可以看出剩下的字节码中,大部分是和method_info相关的,而对method_info的解析也是我们实现JVM的关键一步,所以单独拿出来说一下。(因为method_info里有非常关键的Code结构)
常量池结尾 access_flags[00 21] this_class[00 02] super_class[00 03] interfaces_count[00 00] fields_count[00 00] methods_count[00 02] method_info[(00 01) (00 04) (00 05) (00 01) Code(00 06) (00 00 00 2F) (00 01) (00 01) (00 00 00 05) (2A B7 00 01 B1) (00 00) attributes_count(00 02) LineNumberTable(00 07) (00 00 00 06) (00 01) (00 00) (00 03) LocalVariableTable(00 08) (00 00 00 0C) (00 01) (00 00) (00 05) (00 09) (00 0A) (00 00) method_info(00 09) (00 0B) (00 0C) (00 01) Code(00 06) (00 00 00 38) (00 02) (00 02) (00 00 00 04) (1A 1B 60 AC) (00 00) attributes_count(00 02) LineNumberTable(00 07) (00 00 00 06) (00 01) (00 00) (00 05) LocalVariableTable(00 08) (00 00 00 16) (00 02) (00 00) (00 04) (00 0D) (00 0E) (00 00) (00 00) (00 04) (00 0F) (00 0E) (00 01)]
attributes_count[00 01] [SourceFile(00 10) (00 00 00 02) (00 11)]
通过methods_count我们知道这个类有两个方法。要看懂method_info我们需要先了解method_info的结构,在Java规格说明中有:
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
然后我们先看第一个方法:
(00 01) (00 04) (00 05) (00 01) Code(00 06) (00 00 00 2F) (00 01) (00 01) (00
00 00 05) (2A B7 00 01 B1) (00 00) attributes_count(00 02) LineNumberTable(00 07) (00 00
00 06) (00 01) (00 00) (00 03) LocalVariableTable(00 08) (00 00 00 0C) (00 01)
(00 00) (00 05) (00 09) (00 0A) (00 00)
说明这个方法的access_flags是public,name_index指向的是第4个常量指向了一个字符串<init>
,descriptor_index的意思是方法描述第5个常量指向字符串()V
。attributes_count是1。
我在最开始看Java规格说明的时候对attribute这个东西比较模糊,为什么attribute在field_info有?在method_info也有?在ClassFile中也有?感觉到处都是attribute,我现在的理解是attribute是对当前结构进行说明的东西,各种信息都可以在attributes里体现,举个例子:ClassFile中有个attribute叫SourceFile说明class的源文件是哪个。
源文件?好像与运行时没有太大的关系,但还是通过attribute附加到ClassFile中(可能是dubug工具需要SourceFile信息)。Java规格说明定义了28种attribute,其中大部分attribute都是可选的。
我们继续看Add.class
关键的地方来了,第一个方法有一个attribute指向了0x06
常量,这个常量对应的字符串是Code,所以这个attribute是一个Code attribute他的结构是。
Code_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
{ u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
很长,但是包括了很多重要的信息,比如max_stack是执行这个方法所需的最大栈长度,max_locals是执行这个方法所需要的本地变量数,以及最关键的code_length与code信息,code里包括了字节码的逻辑,也就是说你写的代码的逻辑就放在code里!第一个方法的code指令是:
2A (B7 00 01) B1
在Java规格说明第六章中已经详细说明了每个指令码的码值,我们按图索骥。其中0x2A
是aload_0 = 42 (0x2a)
,0xB7
是invokespecial = 183 (0xb7)
因为它的格式带了两个参数,所以00 01
都是它的入参,这条指令的目的是为了执行Add类的构造方法。
//invokespecial 指令的结构
Format
invokespecial
indexbyte1
indexbyte2
0xB1
是return = 177 (0xb1)
表示void方法返回。
继续看,第一个方法exception_table_length为0,没有异常处理,然后它的attributes_count为2说明有两个attribute分别是LineNumberTable和LocalVariableTable,这两个东西我们本文不关注,你只要知道他们是在debug时用的就行了。
我们本次写的JVM是以一个非常简单的JVM,不考虑实现面向对象的功能,所以我们就不继续关注构造方法了。我们这次需要关注的是第二个方法,add方法,这个方法是我们本次实现JVM的重点。add方法的method_info描述如下
(00 09) (00 0B) (00 0C)
(00 01) Code(00 06) (00 00 00 38) (00 02) (00 02) (00 00 00 04)
(1A 1B 60 AC) (00 00) attributes_count(00 02) LineNumberTable(00 07) (00 00 00 06) (00 01)
(00 00) (00 05) LocalVariableTable(00 08) (00 00 00 16) (00 02) (00 00) (00 04)
(00 0D) (00 0E) (00 00) (00 00) (00 04) (00 0F) (00 0E) (00 01)
这个方法的access_flags是0x0009
表示这个方法是public(0x00001
) + static(0x00008
)。name_index指向常量add
,其他的基本与第一个方法差不多,我们只关注add方法的code片段:
1A 1B 60 AC
翻译过来就是:
1A = iload_0
1B = iload_1
60 = iadd
AC = ireturn
如果你懂一点汇编的话就能理解这4条操作码其实实现了一个加法。JVM是基于栈来运行字节码的,iload_0
与iload_1
意思是把当前帧的本地0变量和1变量入栈,然后调用iadd
将栈的前两个元素相加后把结果再压入栈,最后使用ireturn
将栈顶元素返回。
文章写到这里所有的困难已经扫清了,我们的目标也明确了:我们需要按照规则解析class文件,然后调用add方法,解析add方法的字节码,并且得到返回值。
我们现在应该能够隐约感觉到解析class文件是一个体力活,既然是体力活,那么肯定有人已经做过了。确实有这么一个开源库实现了解析class文件,它就是:asm库。这个库可谓是非常专业解析java字节码的库了,官网上说openjdk中也用到了这个库。
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.5</version>
</dependency>
它的使用也非常简单
//从文件流中加载,返回的ClassNode就包括了Class文件的所有解析后结果
ClassNode classNode = loadInitialClass(classLoader.getResourceAsStream("me/kagami/myjvm/Add.class"));
//通过名称找到add方法
MethodNode addMethod = MyJvm.findPublicStaticMethod(classNode, "add");
我们关注的重点Code指令存放在instructions中
这里有个问题是,为什么Class文件中指令明明是1A 1B
但asm解析出来是21呢?我翻看Java规格说明原来iload
有一系列指令
iload = 21 (0x15)
iload_0 = 26 (0x1a)
iload_1 = 27 (0x1b)
iload_2 = 28 (0x1c)
iload_3 = 29 (0x1d)
这里肯定是asm替我们做了转换了,翻看源码果然如此,位于org.objectweb.asm.ClassReader类的2237行左右
case Constants.ALOAD_3:
opcode -= Constants.ILOAD_0;
//会把iload_x的x存放在第二个参数中
methodVisitor.visitVarInsn(Opcodes.ILOAD + (opcode >> 2), opcode & 0x3);
currentOffset += 1;
break;
题外话:javap命令
直接看16进制的字节码也太困难了,如果工作上有需求要看字节码我们不可能对着文档一个一个看,太慢了。那么有什么好的方法直接可以看解析后的结果呢?是有的,就是JDK自带的javap
命令。我们只要输入
javap -v Add.class
就可以看到以下输出,常量池,代码区,都非常清晰。
Classfile /C:/Users/Tian/Desktop/Add.class
Last modified 2023-8-8; size 362 bytes
MD5 checksum 3737aee44531207ae6270a32364fbc5d
Compiled from "Add.java"
public class me.kagami.myjvm.Add
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #3.#18 // java/lang/Object."<init>":()V
#2 = Class #19 // me/kagami/myjvm/Add
#3 = Class #20 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 Lme/kagami/myjvm/Add;
#11 = Utf8 add
#12 = Utf8 (II)I
#13 = Utf8 a
#14 = Utf8 I
#15 = Utf8 b
#16 = Utf8 SourceFile
#17 = Utf8 Add.java
#18 = NameAndType #4:#5 // "<init>":()V
#19 = Utf8 me/kagami/myjvm/Add
#20 = Utf8 java/lang/Object
{
public me.kagami.myjvm.Add();
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
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lme/kagami/myjvm/Add;
public static int add(int, int);
descriptor: (II)I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=2
0: iload_0
1: iload_1
2: iadd
3: ireturn
LineNumberTable:
line 5: 0
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 a I
0 4 1 b I
}
SourceFile: "Add.java"
JVM 栈帧
方法执行时会像JVM申请一个执行栈,栈我们应该都很熟悉了(栈溢出的异常都见过吧),它包括了操作数栈、本地变量表,代码块。此外还包含当前指令指针(记录执行字节码时已经进行了多少步)和一个指向包含该方法的类的指针,用于访问类的常量池以及其他细节。我打算这么实现栈帧
public class Frame {
//操作数栈
private final Object[] operands;
//本地变量表
private final Object[] locals;
//操作数栈指针
private int stackPtr;
//指令列表
private final InsnList instructions;
//当前指令指针
private AbstractInsnNode programCtr;
//用于记录返回值
private Object returnObj;
//这里我们用到了,CodeAttribute中的stack=2, locals=2字段,用于声明操作栈和本地变量表大小
public Frame(int maxStack, int maxLocals, InsnList instructions, Object[] args) {
this.operands = new Object[maxStack];
this.locals = new Object[maxLocals];
this.programCtr = null;
this.instructions = instructions;
for (int i = 0; i < args.length; i++) {
store(i, args[i]);
}
}
//操作数栈的push
public void push(Object value) {
this.operands[this.stackPtr++] = value;
}
//操作数栈的pop
public <T> T pop(Class<T> clazz) {
return clazz.cast(this.operands[--this.stackPtr]);
}
//本地变量表
public void store(int var, Object value) {
this.locals[var] = value;
}
//本地变量表
public <T> T load(int var, Class<T> clazz) {
return clazz.cast(this.locals[var]);
}
//省略....
}
我们现在得到了栈帧,并且已经初始化好,现在我们开始利用栈帧执行指令了:
public static void executeFrame(Frame frame) {
InsnList instructions = frame.getInstructions();
//利用循环一条一条执行指令
while (frame.next()) {
int opcode = frame.getProgramCtr().getOpcode();
//查找指令的实现类
OpCodeInterface opCodeInterface = OpCodeService.CODE_MAP.get(opcode);
if (opCodeInterface == null) {
System.out.println("异常,指令没有实现类" + opcode);
return;
}
opCodeInterface.handle(frame);
}
}
要做的就是用frame的操作数栈和本地变量表,一条一条执行指令,我们这次的指令用到了
1A = iload_0
1B = iload_1
60 = iadd
AC = ireturn
其中iload_0
和iload_1
被统一成iload
所以执行add方法只需要实现三个指令。
public class OpCode21iloadHandler implements OpCodeInterface{
@Override
public int getOpCode() {
return 21;
}
// iload的实现
@Override
public void handle(Frame frame) {
//从本地变量表中取出指定第几个变量,并存放在操作数栈中
System.out.println("BE OP:" + getOpCode() + " Stack:" + Arrays.toString(frame.getOperands()));
frame.push(frame.getLocals()[((VarInsnNode) frame.getProgramCtr()).var]);
System.out.println("AF OP:" + getOpCode() + " Stack:" + Arrays.toString(frame.getOperands()));
}
}
public class OpCode96iaddHandler implements OpCodeInterface {
@Override
public int getOpCode() {
return 96;
}
// iadd的实现
@Override
public void handle(Frame frame) {
System.out.println("BE OP:" + getOpCode() + " Stack:" + Arrays.toString(frame.getOperands()));
Integer v1 = frame.pop(Integer.class);
Integer v2 = frame.pop(Integer.class);
frame.push(v1 + v2);
//从操作栈栈顶的两个数据pop后相加在push到栈里
System.out.println("AF OP:" + getOpCode() + " Stack:" + Arrays.toString(frame.getOperands()));
}
}
public class OpCode172ireturnHandler implements OpCodeInterface {
@Override
public int getOpCode() {
return 172;
}
//ireturn的实现
@Override
public void handle(Frame frame) {
System.out.println("BE OP:" + getOpCode() + " Stack:" + Arrays.toString(frame.getOperands()));
Integer pop = frame.pop(Integer.class);
//存放返回值
frame.setReturnObj(pop);
System.out.println("AF OP:" + getOpCode() + " Stack:" + Arrays.toString(frame.getOperands()));
}
}
最后,我们就可以调用add()方法了
public static void main(String[] args) throws IOException {
ClassLoader classLoader = Main.class.getClassLoader();
//加载Class文件
ClassNode classNode = loadInitialClass(classLoader.getResourceAsStream("me/kagami/myjvm/Add.class"));
//找到add()方法
MethodNode addMethod = MyJvm.findPublicStaticMethod(classNode, "add");
//这里我们直接调用add方法,给add()方法传入参 1和2
MyJvm.run(classNode, addMethod, new Integer(1), new Integer(2));
}
我们运行后可以得到以下输出,打印了每个指令执行时操作数栈的前后变化情况。它正常运转了!打印出了最终的结果:3!
它是一个非常简单并且简陋的JVM,但是它做到了JVM应该做的事:加载字节码,并且执行字节码(当然,真正的JVM需要做的东西还有很多)。
输出如下:
BE OP:21 Stack:[null, null]
AF OP:21 Stack:[1, null]
BE OP:21 Stack:[1, null]
AF OP:21 Stack:[1, 2]
BE OP:96 Stack:[1, 2]
AF OP:96 Stack:[3, null]
BE OP:172 Stack:[3, null]
AF OP:172 Stack:[null, null]
3
离真正的JVM还差了什么?
-
内存管理
我们的玩具JVM还是建立JVM之上的,所以对内存的掌控还是依赖JVM的内存管理。但想摆脱JVM也不是很难,假如我们用的是C语言实现的JVM,我们在解析Class后,在运行时完全可以自己管理内存的分配释放,甚至实现自己的垃圾回收机制。
-
指令的实现
还有很多指令没有实现,我们只实现了
int
类型的load
,Java中还有long/float/double
等等类型都有对应的load
指令,还有goto/if
等跳转指令,还有pop/dup/swap
用于操作操作数栈,还有最关键的invoke
相关的用于引用的处理。 -
面相对象的实现
需要考虑对象模型的实现,如何存储对象和类,如何处理继承等,如何实现
new
指令。还有需要注意方法调用的指令,每种指令都有略微的不同。invokestatic:对一个类调用静态方法。 invokespecial:直接调用实例方法,例如私有方法。 invokeinterface:调用接口方法 invokedynamic:调用动态计算的方法调用点。(这个我暂时也不清楚是干啥的)
-
垃圾回收
上文也提到了实现垃圾回收,实现垃圾回收需要考虑怎么判断对象是否为垃圾,引用的计数等,这里是JVM实现者可以自主发挥最大的地方,我们熟知的垃圾回收方法都有好几种了。
-
异常机制的实现
如何实现
athrow
指令,如何使用异常表将异常在帧中传播。
JVM需要实现的东西还有很多我这就不一一列举了。
总结
实现自己的JVM是一个有趣的过程,在实现的过程中终于提起兴趣翻看JAVA规格说明,我的实现很简陋,如果有更多兴趣的读者,可以参考《自己动手写Java虚拟机》这本书,这本书使用GO语言从无到有的实现了一个能打印出helloworld的JVM。
本文源代码:gitee.com/kagami1/sim…
转载自:https://juejin.cn/post/7277917854430609423