likes
comments
collection
share

程序员第一个(玩具)JVM

作者站长头像
站长
· 阅读数 21

程序员第一个(玩具)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文件。

程序员第一个(玩具)JVM

如果查看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 150x15换成十进制是21,规格说明里有这么一句话:

The constant_pool table is indexed from 1 to constant_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 KindTag
CONSTANT_Class7
CONSTANT_Fieldref9
CONSTANT_Methodref10
CONSTANT_InterfaceMethodref11
CONSTANT_String8
CONSTANT_Integer3
CONSTANT_Float4
CONSTANT_Long5
CONSTANT_Double6
CONSTANT_NameAndType12
CONSTANT_Utf81
CONSTANT_MethodHandle15
CONSTANT_MethodType16
CONSTANT_Dynamic17
CONSTANT_InvokeDynamic18
CONSTANT_Module19
CONSTANT_Package20

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 be void.

原来表示的是初始化方法,继续看文档我们又找到了一句话看完之后就更明确了:

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规格说明第六章中已经详细说明了每个指令码的码值,我们按图索骥。其中0x2Aaload_0 = 42 (0x2a)0xB7invokespecial = 183 (0xb7)因为它的格式带了两个参数,所以00 01都是它的入参,这条指令的目的是为了执行Add类的构造方法。

//invokespecial 指令的结构
Format
invokespecial
indexbyte1
indexbyte2

0xB1return = 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_0iload_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中

程序员第一个(玩具)JVM

这里有个问题是,为什么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_0iload_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还差了什么?

  1. 内存管理

    我们的玩具JVM还是建立JVM之上的,所以对内存的掌控还是依赖JVM的内存管理。但想摆脱JVM也不是很难,假如我们用的是C语言实现的JVM,我们在解析Class后,在运行时完全可以自己管理内存的分配释放,甚至实现自己的垃圾回收机制。

  2. 指令的实现

    还有很多指令没有实现,我们只实现了int类型的load,Java中还有long/float/double等等类型都有对应的load指令,还有goto/if等跳转指令,还有pop/dup/swap用于操作操作数栈,还有最关键的invoke相关的用于引用的处理。

  3. 面相对象的实现

    需要考虑对象模型的实现,如何存储对象和类,如何处理继承等,如何实现new指令。还有需要注意方法调用的指令,每种指令都有略微的不同。

    invokestatic:对一个类调用静态方法。
    invokespecial:直接调用实例方法,例如私有方法。
    invokeinterface:调用接口方法
    invokedynamic:调用动态计算的方法调用点。(这个我暂时也不清楚是干啥的)
    
  4. 垃圾回收

    上文也提到了实现垃圾回收,实现垃圾回收需要考虑怎么判断对象是否为垃圾,引用的计数等,这里是JVM实现者可以自主发挥最大的地方,我们熟知的垃圾回收方法都有好几种了。

  5. 异常机制的实现

    如何实现athrow指令,如何使用异常表将异常在帧中传播。

JVM需要实现的东西还有很多我这就不一一列举了。

总结

实现自己的JVM是一个有趣的过程,在实现的过程中终于提起兴趣翻看JAVA规格说明,我的实现很简陋,如果有更多兴趣的读者,可以参考《自己动手写Java虚拟机》这本书,这本书使用GO语言从无到有的实现了一个能打印出helloworld的JVM。

本文源代码:gitee.com/kagami1/sim…