不管我们喜欢与否,Java都是使用最广泛的编程语言之一。然而,由于Java中的大多数应用程序要么太无聊要么太复杂,不是每个Java开发人员都有足够的好奇心去深入了解JVM是如何工作的。
在这篇文章中,我将尝试编写一个简单式且不完整的JVM来展示其背后的核心原则,希望能激发你进一步学习它的兴趣。
编写目标
让我们从最简单的开始:
让我们使用javac Add.java编译类,并生成Add.class。此类文件是JVM可以执行的实际二进制文件,剩下要做的就是实现可以正确执行它的JVM。
如果我们在hexdump中查看Add.class的内容,那么我们可能不会留下深刻的印象:
尽管我们在这里还没有看到一个清晰的结构,但我们需要找到一种方法来解析它:这些()V和(II)I是什么,什么是< init >,为什么它以“cafe babe”开始?
你可能见过另一种转储类文件的方法,它通常更有用:
现在,我们看到我们的类,其构造函数和方法。构造函数和方法都包含一些指令,现在add()方法所做的工作或多或少变得清楚了:它加载两个参数(iload_0和iload_1) ,将它们相加并返回结果。 JVM是一个堆栈计算机,因此没有寄存器,指令的所有参数都存储在内部堆栈中,结果也被推到堆栈上。
类加载器
现在,我们如何实现javap在这里所做的,如何解析类文件?
如果我们研究一下JVM规范,就会了解类文件结构。它总是以4字节的签名(CAFEBABE)开始,然后是2+2字节的版本,听起来很简单。
因为我们必须从二进制文件中读取字节、short、int和字节序列,我们可以这样开始实现我们的加载器:
然后规范告诉我们需要解析常数池,它是什么?它是类文件的一个特殊部分,包含运行类所需的常量。所有字符串、数字常量和引用都存储在那里,并且每个都有一个惟一的uint16索引(因此,一个类可能有最多64K个常量)。
池中有几种类型的常数,每种常数包含一组不同的值。我们会对以下内容感兴趣:
UTF8:纯字符串文字;
类:类名称字符串的索引(间接引用);
名称和类型:类型名称和描述符的索引,用于字段和方法;
字段和方法引用:引用类和名称和类型常量的索引。
正如你看到的,池中的常量经常相互引用。由于我们在Go中实现了JVM,并且没有联合类型,让我们创建一个包含各种可能的常量字段的Const类型:
然后,按照JVM规范,我们可以像这样解析常量池数据:
以上只是理论上的简单,但是在真正的JVM中,我们必须通过插入一个额外的未使用的const项来唯一地对待long和double常量类型,就像JVM规范告诉我们的那样(因为const项被认为是32位的)。
为了更轻松地通过索引获取字符串文字,我们将实现Resolve(index uint16)字符串方法:
现在,我们必须添加类似的助手来解析类接口、字段和方法及其属性列表:
字段和方法都表示为字段,这是非常幸运的,并且节省了我们一些时间。最后,我们可以将它们组合在一起,解析我们的完整类:
现在,如果我们查看生成的类信息,我们将看到它具有零字段和两个方法-< init > :()V和add:(II)I。这些看起来像带有括号的罗马数字的东西是什么?这些是描述符,它们定义方法采用什么类型的参数以及返回什么类型。在本例中,,< init >(一种合成方法,用于在构造对象时初始化对象)不带参数也不返回任何内容(V = void),而“add”方法带两个整数(I = int32)并返回一个整数。
字节码
如果我们仔细观察,会发现解析后的类中的每个方法都有一个名为“Code”的属性。此属性有一个字节片作为有效载荷。字节如下:
如果我们看一下规范,这一次在字节码部分,我们将看到“代码”属性以maxstack值(2个字节),maxlocals(2个字节),代码长度(4个字节),然后是实际代码开头。因此我们的属性可以这样读取:
是的,每个方法中只有4和5个字节的代码。这些字节是什么意思?
就像我说的那样,JVM是一个堆栈计算机。每条指令都编码为一个字节,后面可以跟一些其他参数。如果看一下规范,我们将看到“add”方法具有以下说明:
就像我们在一开始在javap输出中看到的一样!但是我们应该如何执行呢?
JVM框架
当一种方法在JVM内部执行时,它具有自己的临时操作数堆栈,自己的局部变量和自己要执行的代码块。所有这些参数都存储在单个执行框架中。此外,帧还包含当前的指令指针(执行字节码时,我们前进了多远)和指向类的指针(包含方法)。需要后者才能访问该类的const池以及其他详细信息。
让我们创建一个为给定方法构造框架的方法,该框架将使用给定参数进行调用。我将在这里使用interface {}类型作为Value类型,尽管正确的union类型当然是更安全的选择。
因此,我们得到了带有初始化局部变量,空堆栈和预加载字节码的框架。现在该执行字节码了:
最后,我们可以把它们放在一起,通过调用add()方法运行:
因此,它有效。是的,这是一个非常糟糕和可怜的JVM,但是它仍然可以完成JVM的工作-加载字节码并对其进行解释。当然,真正的JVM做的远不止这些。
另外的一些指令
在虚拟化软件(如VMWare Workstation,VirtualBox等)中,通常会有一个可安装在访客OS中的软件套件(提供程序之间的名称有所不同:VMWare称其为VMWare工具,VirtualBox称其为Guest Additions)。 该软件的目的是通过提供与主机的直接通信通道来丰富访客OS的功能。增强功能包括剪贴板共享,拖放文件复制等功能。实现上述功能的是特殊的虚拟化操作码,该操作码用于从访客到主机的直接通信。操作码因架构而异,有时也因制造商而异:Intel使用 vmcall,AMD使用vmmcall,ARM使用hvc。由于执行iOS映像的QEMU系统类似于虚拟机管理程序,我们便选择采用类似的方法,并定义可被访客(iOS)用来调用主机(QEMU)的操作码,以获取任意功能(我们称之为QEMU Call)。我们希望将对QEMU核心代码的更改最小化,因此,我们不希望引入新的操作码。覆盖hvc的功能也是我们要避免的选项。但是,QEMU支持以用户自定义的实现方式来将系统寄存器定制化。这非常适合我们,并提供了一个绝佳的位置来引入在访客(iOS)需要来自主机QEMU的服务时执行的回调:当用户空间应用程序需要执行由操作系统实现的操作(例如,文件系统访问、网络访问等)时,它会进行系统调用。这与访客操作系统执行对虚拟机管理程序的调用的方式非常相似(实际上,虚拟机管理程序的调用功能就是参照着系统调用功能而经过精心设计的)。系统调用和系统管理程序调用通常都通过第一个寄存器中传递的数字(即系统调用号或系统调用号)来标识所需的功能。附加参数通常在其他寄存器中或在寄存器所指向的内存中传递。我们决定在进行QEMU通话时遵循类似的约定,但有一点点改动。为了最大程度地减少对内联汇编的需求(这通常是进行系统和虚拟机管理程序调用所必需的,因为参数必须存储在特定的寄存器中),我们选择将所有参数(包括QEMU调用的数量)存储在内存中。我们定义了以下结构以简化数据处理。但是QEMU Call的处理程序如何知道在哪里查找该数据?由于我们通过对的(写入)访问实现QEMU调用REG_QEMU_CALL,因此我们可以简单地将QEMU调用数据的地址用作写入的值。这样,writefn执行回调时,我们只需从写入的地址中读取数据。
另外200条指令、运行时、OOP类型系统和其他一些东西。
其他两百条指令,运行时,OOP类型系统以及其他一些内容。共有11组指令,其中大多数是微不足道的:
· 常量(将null或少量或常量池中的值放入堆栈);
· 加载(将本地变量放入堆栈),共有32条这样的指令;
· 存储(从堆栈弹出到局部变量),还有32条无聊的指令;
· 堆栈(pop/dup/swap),就像每个堆栈计算机一样;
· 数学(add / sub / div / mul / rem / shift / logic),对于不同的值类型,总共36条指令;
· 转换(将int转换为short,将int转换为float,...);
· 比较(eq / ne / le /…),用于生成条件语句,如if/else。
· 控制(goto /返回),对循环和子程序很有用;
· 参考文献最有趣的部分是字段和方法、异常和对象监视器;
· 扩展:这看起来像是一个复杂的解决方案,它可能不会随着时间的推移而改变;
· 保留:断点指令0xca到这里。
大多数指令实现起来都很简单,它们从堆栈中取出一个或两个参数,对它们执行一些操作,然后推入结果。这里唯一要记住的是,长指令和双指令期望每个值在堆栈上占用两个插槽,因此你可能需要额外的push()和pop(),这使得对指令进行分组变得更加困难。
实现引用需要考虑对象模型,如何存储对象及其类,如何表示继承,在哪里存储实例字段和类字段。同时,这也是你在方法分派时必须小心的地方,有多个“调用”指令,它们的行为方式略有不同:
· invokestatic:在类上调用静态方法,毫不奇怪;
· invokespecial:直接调用实例方法,通常用于合成方法,例如< init >或私人方法。
· invokevirtual:基于类hierachy调用实例方法;
· invokeinterface:调用接口方法,类似于invokevirtual,但是执行不同的检查和优化;
· invokedynamic:调用Java 7中新增的动态计算的调用站点,对动态方法和MethodHandles有用。
如果你用一种没有垃圾收集的语言实现JVM,这也是你应该考虑如何执行垃圾收集的地方:引用计数,标记和清除等等。通过实现athrow处理异常,通过框架传播它们并使用异常表处理它们是另一个有趣的话题。
最后,如果没有运行时类,JVM仍然是无用的。如果没有java/lang/Object,你甚至不可能通过构造新对象来了解新指令是如何工作的。运行时可能会提供来自java.lang,java.io和java.util程序包的一些常见JRE类,或者它可能是特定于域的某些内容。最有可能的是,类中的一些方法必须以本机方式实现,而不是用Java实现。这将引发有关如何查找和执行此类方法的问题,这将成为JVM的另一种极端情况。
换句话说,实现一个合适的JVM并不是那么简单,但是理解它是如何实现的也不是那么复杂。
本文翻译自:https://zserge.com/posts/jvm/如若转载,请注明原文地址: