如何编写(玩具)JVM

2020-06-03 02:36:26

不管我们喜欢与否,Java是使用最广泛的编程语言之一。然而,由于Java中的大多数应用程序要么太乏味,要么太复杂--并不是每个Java开发人员都有足够的好奇心去深入了解JVM是如何工作的。

在这篇文章中,我将尝试编写一个玩具(和不完整的)JVM,以展示其背后的核心原则,并希望能激发您进一步学习它的兴趣。

public class add{public static int add(int a,int b){return a+b;}}。

我们用javac Add.java编译我们的类,结果是Add.class。这个类文件是JVM可以执行的实际二进制文件。剩下要做的就是实现这样一个能够正确执行它的JVM。

如果我们使用一个六进制转储来查看Add.类内部-我们可能不会得到太多印象:

00000000 CAFE BA为00 00 00 34 00 0f 0A 00 03 00 0C 07|.4.|00000010 00 0D 07 00 0E 01 00 06 3C 69 6E 69 74 3E 01 00|.<;init>;..|00000020 03 28 29 56 01 00 04 43 6f 64 65 01 00 0f 4c 69|.()V.代码.LI|00000030 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 01 00 03|neNumberTable.|00000040 61 64 01 00 05 28 49 49 29 49 01 00 0A 53 6f|添加.(Ii)i.SO|00000050 75 72 63 65 46 69 6c 65 01 00 08 41 64 2e 6a|资源文件.添加j|00000060 61 76。ava.add..|00000070 10 6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63|.java/lang/objec|00000080 74 00 21 00 02 00 03 00 00 00 02 00 01 00|t.!.|00000090 04 00 05 00 01 00 00 00 1d 00 01 00 01 00|.|000000a0 00 00 05 2a b7 00 01 b1 00 00 00 01 00 07 00。.*.|000000b0 00 06 00 01 00 00 00 01 00 09 00 08 00 09 00 01|.|0000c0 00 06 00 00 00 1c 00 02 00 02 00 00 00 04 1a 1b|.|000000d0 60 ac 00 00 01 00 07 00 00 00 06 00 01 00 00|`.|000000e0 00 03。00 01 00 0a 00 00 00 02 00 0b|.|。

虽然我们在这里还没有看到一个清晰的结构,但我们需要找到一种方法来解析它:()V和(Ii)I是什么,什么是<;init>;,为什么它以“Cafe Babe”开头?

您可能见过另一种转储类文件的方法,它通常更有用:

$javap-c AddCompiled from";Add.java";public class add{public add();Code:0:aload_0 1:invokSpecial#1//method java/lang/Object.";<;init>;";:()V4:return public static int add(int,int);Code:0:iLoad_0 1:iLoad_1 2:iadd 3:ireturn}。

现在我们看到我们的类、它的构造函数和一个方法。构造函数和方法都包含一些指令,现在我们的add()方法的作用或多或少变得很清楚了:它加载两个参数(iLoad_0和iLoad_1),将它们相加,然后返回结果。JVM是堆栈机器,所以没有寄存器,指令的所有参数都存储在内部堆栈中,结果也会被推送到堆栈上。

现在,我们如何实现javap在这里所做的事情,我们如何解析类文件?

如果我们研究JVM规范,就会了解类文件结构。它总是以4个字节的签名(CAFEBABE)开头,然后是2+2个字节的版本,听起来很简单。

因为我们必须从二进制文件中读取字节、短码、整数和字节序列,所以我们可以开始实现我们的加载器,如下所示:

type loader struct{r io.Reader Err Err}func(l*loader)bytes(N Int)[]byte{b:=make([]byte,n,n)//我们不使用';如果l.err==nil{_,l.err=io.ReadFull(L.R,b)}return b}func(l*loader)u1()uint8{return l.bytes(1)[0]}func(l*loader)u2()uint16{return binary.BigEndian.Uint16(l.bytes(2))}func(l*loader)u4()uint32{return binary.BigEndian.Uint32(l.bytes(4))}func(l*loader)U8(。Add.class&34;)loader:=&;loader{r:f}afebabe:=loader.u4()主要:=loader.u2()次要:=loader.u2()。

然后规范告诉我们需要解析常量池。那是什么?它是类文件的特殊部分,包含运行类所需的常量。所有字符串、数值常量和引用都存储在那里,并且每个字符串、数值常量和引用都有一个惟一的uint16索引(因此,一个类最多可以有64K常量)。

池中有几种类型的常量,每种类型都包含一组不同的值。我们将感兴趣的是:

名称和类型:类型名称和描述符的索引,用于字段和方法。

如您所见,池中的常量经常相互引用。由于我们在GO中实现了一个JVM,并且没有联合类型,因此让我们创建一个包含各种可能的常量字段的常量类型:

type const struct{Tag byte NameIndex uint16 ClassIndex uint16 NameAndTypeIndex uint16 StringIndex uint16 DescIndex uint16 String}type ConstPool[]const。

func(l*loader)cpinfo()(StPool ConstPool){stPoolCount:=l.u2()//i:=uint16(1);i<;conPoolCount;的有效常量池索引从1开始;i++{c:=const{tag:l.u1()}开关c.Tag{case 0x01://utf8字符串文字,2字节长度+数据c.String=string(l.bytes(int(l.u2()case 0x07://类索引c.NameIndex=l.u2()case 0x08://字符串引用索引c.StringIndex=l.u2()case 0x09,0x0a://字段和方法:类索引+NAT索引c.ClassIndex=l.u2()c.NameAndTypeIndex=l.u2()case 0x0c://name-and-type c.NameIndex,c.DescIndex=l.u2(),l.u2()默认:l.err=fmt.Errorf(";不支持的标记:%d";,c.Tag)}constPool=append(constPool,c)}return constPool}。

我们在这里保持简单,但是在实际的JVM中,我们必须通过插入一个额外的未使用的常量项来唯一地对待Long和Double常量类型,正如JVM规范告诉我们的那样(因为常量项被认为是32位的)。

为了更容易地按索引获取字符串文字,我们将实现Resolve(Index Uint16)String方法:

func(Cp ConstPool)Resolve(Index Uint16)String{if cp[index-1].Tag==0x01{return cp[index-1].String}return";";}。

现在,我们必须添加类似的帮助器来解析类接口、字段和方法及其属性的列表:

func(l*loader)interface(Cp ConstPool)(interface[]string){interfaceCount:=l.u2()for i:=uint16(0);i<;interfaceCount;i++{interface=append(interface,cp.Resolve(l.u2()}return interface}//field type同时用于field和method类型Field struct{Flags uint16 Name String Descriptor String Attributes[]。属性,包含实际字节代码类型属性struct{name string data[]byte}func(l*loader)field(Cp ConstPool)(field[]field){fieldsCount:=l.u2()for i:=uint16(0);i<;fieldsCount;i++{field=append(field,Field{Flags:l.u2(),Name:cp.Resolve(l.u2()),Descriptor:cp.Resolve(l.u2()),Attributes:l.attrs(Cp),})}return field}func(l*loader)attrs(Cp ConstPool)(attrs[]Attribute){AttributesCount:=l.u2()for i:=uu。i++{attrs=append(attrs,attribute{name:cp.Resolve(l.u2()),data:l.bytes(int(l.u4(),})}return attrs}。

字段和方法都表示为Fields,这是非常幸运的,并且为我们节省了一些时间。最后,我们可以将它们组合在一起并解析完整的类:

type Class struct{ConstPool ConstPool Name String Super String Flags uint16 Interfaces[]String Fields[]Field Methods[]Field Attributes[]Attribute}Funcc Load(r io.Reader)(Class,Error){loader:=&;加载器{r:r}c:=Class{}loader.u8()//Magic(U32),Minor(U16),main(U16)cp:=loader.cpinfo()//const池信息c.ConstPool=cp c.Flags=loader.u2()//访问标志c.Name=cp.Resolve(loader.u2())//这个类c.Super=cp.Resolve(loader.u2())//超类c.Interfaces=loader.interface(Cp)c.Fields=loader.field(Cp)//field c.Methods=loader.field(Cp)loader.err}。

现在,如果我们查看生成的类信息,我们会发现它没有字段和两个方法-<;init>;:()V和add:(Ii)V。这些看起来像带括号的罗马数字是什么?这些都是描述符,它们定义方法接受什么类型的参数以及返回什么。在本例中,<;init>;(一种合成方法,用于在构造对象时对其进行初始化)不接受任何参数,也不返回任何内容(V=void),而“add”方法接受两个int(i=int32),也不返回任何内容。

如果我们仔细观察,我们会发现我们解析的类中的每个方法都有一个名为“Code”的属性。此属性有一段字节作为有效负载。字节如下:

<;init>;:[0 1 0 1 0 0 5 42 183 0 1 177 0 0 1 0 7 0 0 0 6 0 1 0 0 0 1]add:[0 2 0 2 0 0 0 4 26 27 96 172 0 0 1 0 7 0 0 0 6 0 1 0 0 0 3]。

如果我们看一下规范,这一次是在字节码部分,我们将看到“Code”属性以maxstack value(2字节)开头,然后是maxlocals(2字节),然后是代码长度(4字节),然后是实际代码。因此,我们的属性可以这样解读:

<;init>;:maxstack:1,maxlocals:1,code:[42 183 0 1177]add:maxstack:2,maxlocals:2,code:[26 27 96172]

是的,我们在每个方法中只有4到5个字节的代码。这些字节意味着什么?

就像我说的,JVM是一台堆栈机器。每条指令都编码为一个字节,后面可能跟一些额外的参数。如果我们查看规范,我们将看到“add”方法具有以下说明:

与我们开始时在javap输出中看到的完全一样!但是我们该如何执行呢?

当方法在JVM内执行时,它有自己的临时操作数堆栈、自己的局部变量和自己要执行的代码块。所有这些参数都存储在单个执行帧中。此外,帧包含当前指令指针(我们在执行字节码时前进了多远)和指向包含该方法的类的指针。后者是访问类的常量池以及其他细节所必需的。

让我们创建一个方法,该方法为使用给定参数调用的给定方法构造一个框架。这里我将使用interface{}类型作为值类型,尽管正确的联合类型当然会是更安全的选择。

type frame struct{Class Class IP uint32 Code[]byte locals[]interface{}Stack[]interface{}}func(C Class)frame(method string,args.interface{})frame{for_,m:=range c.Methods{if m.Name==method{for_,a:=range m.Attributes{if a.Name==";Code";&;&;len(a.Data)>;8{maxLocals:=binary.BigEndian.Uint16(a.Data[2:4])frame:=frame{Class:c,Code:a.Data[8:],locals:make([]interface{},maxLocals,maxLocals),}for i:=0;i<;len(Args);i++{frame.Locals[i]=args[i]}死机(";找不到方法";)}。

因此,我们得到了框架,它具有初始化的局部变量、空的堆栈和预加载的字节码。现在是执行字节码的时候了:

Func Exec(F Frame)接口{}{for{op:=f.Code[f.ip]log.Printf(";op:%02x堆栈:%v";,op,f.Stack)n:=len(f.Stack)开关op{案例26://iLoad_0 f.Stack=append(f.Stack,f.Locals[0])案例27://iLoad_1 f.Stack=append(f.Stack,f.Locals[1])案例96:a:=f.Stack[n-1].(Int32)b:=f.Stack[n-2].(Int32)f.Stack[n-2]=a+b f.Stack=f.Stack[:n-1]案例172://i return v:=f.Stack[n-1]f.Stack=f.Stack[:n-1]return v}f.ip++}}

F,_:=os.Open(";Add.class";)class,_:=加载(F)Frame:=class.Frame(";add";,int32(2),int32(3))结果:=Exec(Frame)日志。Println(Result)//输出:OP:1A堆栈:[]OP:1B堆栈:[2]OP:60堆栈:[2 3]OP:AC堆栈:[5]5。

所以,它起作用了。是的,这是一个非常糟糕和可怜的JVM,但它仍然做JVM做的事情-加载字节码并解释它(当然,真正的JVM做的远远不止这些)。

另外两百条指令、运行库、OOP类型系统以及其他一些东西。

常量(将来自常量池的空值或小数或值放入堆栈)。

延长了。乍一看看起来像是难看的变通方法。而且它可能不会随着时间的推移而改变。

大多数指令的实现都很简单-它们从堆栈中获取一个或两个参数,对它们执行一些操作,然后推送结果。这里要记住的唯一一件事是,长指令和双精度指令期望每个值占用堆栈上的两个槽,因此您可能需要额外的ush()和op(),这使得对指令进行分组变得更加困难。

实现引用需要考虑对象模型-您希望如何存储对象及其类,如何表示继承,在哪里存储实例字段和类字段。此外,这也是您必须小心方法分派的地方-有多个“调用”指令,它们的行为方式略有不同:

invokSpecial:直接调用实例方法,主要用于合成方法,如<;init>;或私有方法。

invkedynamic:调用动态计算的调用站点,这是Java7中的新功能,对动态方法和方法句柄非常有用。

如果您在没有垃圾收集的语言中实现JVM-这也是您应该考虑如何执行垃圾收集的地方:引用计数、标记和清除等。通过实现athrow来处理异常、在框架中传播它们并使用异常表来处理它们是另一个有趣的主题。

最后,如果没有运行时类,您的JVM也将毫无用处。如果没有java/lang/object,您甚至不可能通过构造新对象来看到新指令是如何工作的。您的运行时可能会从java.lang、java.io和java.util包中提供一些常见的JRE类,也可能是更特定于领域的类。最有可能的情况是,类中的某些方法必须在本地实现,而不是用Java实现。这将引发如何查找和执行此类方法的问题,这将成为JVM的另一个边缘案例。

换句话说,实现一个合适的JVM并不是那么简单,然而,理解它是如何实现的也不是那么复杂。

我只有一个夏天的周末可以腾出时间,而且我的jvm还有很长的路要走,但是结构看起来或多或少很清楚:https://github.com/zserge/tojvm(总是欢迎PR!)。

这篇博客文章中的实际代码片段甚至更小,可以作为要点使用。

如果您想更深入地探讨这个主题-您可以考虑看看小型JVM:

我希望这篇文章没有让您远离Java。虚拟机很有趣,JVM确实配得上它的位置。