+-
如何写一个玩具JVM

(给ImportNew加星标,提高Java技能)

转自:覃佑桦

毋庸置疑,Java 已经成为最受欢迎的编程语言之一。然而,并非每位 Java 开发者都会满怀好奇地探索 JVM 的底层工作机制。这篇短文编写了一个玩具 JVM,旨在抛砖引玉希望能借此激发大家的探索欲望。

0. 一个小目标


下面是一段极其简单的代码:



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


    执行 javac Add.java,编译后会生成 Add.class。后者是一个二进制文件,交由 JVM 执行。剩下来只需要实现一个 JVM 运行 class 文件即可。让我们用 hexdump 对 Add.class 一探究竟。打开文件,输出的内容也许让您感觉云里雾里:



  • 00000000 ca fe ba be 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...Code...Li|00000030 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65 01 00 03 |neNumberTable...|00000040 61 64 64 01 00 05 28 49 49 29 49 01 00 0a 53 6f |add...(II)I...So|00000050 75 72 63 65 46 69 6c 65 01 00 08 41 64 64 2e 6a |urceFile...Add.j|00000060 61 76 61 0c 00 04 00 05 01 00 03 41 64 64 01 00 |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 00 00 02 00 01 00 |t.!.............|00000090 04 00 05 00 01 00 06 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 00 |...*............|000000b0 00 06 00 01 00 00 00 01 00 09 00 08 00 09 00 01 |................|000000c0 00 06 00 00 00 1c 00 02 00 02 00 00 00 04 1a 1b |................|000000d0 60 ac 00 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”为前缀?还可以 dump class 文件,结果看起来似乎更好理解:


  • $ javap -c AddCompiled from "Add.java"public class Add {  public Add();    Code:       0: aload_0       1: invokespecial #1                  // Method java/lang/Object."<init>":()V       4: return
    public static int add(int, int); Code: 0: iload_0 1: iload_1 2: iadd 3: ireturn}


    上面可以看到 class、构造函数和 add 方法。方法中包含了一些指令。透过指令似乎可以猜到 add() 方法的作用:加载两个参数(iload_0 和 iload_1),相加后存储结果。JVM 是一种堆栈机器(Stack Machine),没有寄存器。所有参数都存储在内部的堆栈上,所有结果也存储在堆栈上。


    1. 类加载器


    接下来,如何像 javap 那样解析 class 文件?


    class 文件结构可以在 JVM 规范文档中找到,听起来很简单。通常以4字节签名(CAFEBABE)开头,接着是2+2字节的版本信息。加载器的第一个实现看起来像下面这样,从二进制文件中读取 byte、short、int 和字节流:


  • type loader struct {  r   io.Reader  err error}
    func (l *loader) bytes(n int) []byte { b := make([]byte, n, n) // 每步执行中没有处理错误, // 只存储解析过程中发现的第一个错误, // 没有进行出错处理 if 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() uint64 { return binary.BigEndian.Uint64(l.bytes(8)) }
    // 调用f, _ := os.Open("Add.class")loader := &loader{r: f}cafebabe := loader.u4()major := loader.u2()minor := loader.u2()


    按照 JVM 规范,接下来要解析常量池(Constant Pool):它是 class 文件中的一个特殊区域,所有字符串、数字常量和引用都存储在这里。每个存储都具有一个唯一的 uint16 类型的索引号(因此 class 文件最多有 64K个常量)。常量池中支持下面几种类型,每种类型包含一组不同的值:


  • UTF8:纯字符串;

  • Class: 类名的字符串索引(间接引用);

  • 名字和类型:字段和方法的类名和描述符索引;

  • 字段和方法引用:类名和类型名引用索引。


  • 可以看到,池中的常量经常互相引用。由于本文使用 Go 来实现 JVM,因此没有 union 类型。下面是一个 Const 类型,包含了许多字段:



  • type Const struct { Tag byte NameIndex uint16 ClassIndex uint16 NameAndTypeIndex uint16 StringIndex uint16 DescIndex uint16 String string}type ConstPool []Const


    接下来按照 JVM 规范开始解析常量池数据:


  • func (l *loader) cpinfo() (constPool ConstPool) {  constPoolCount := l.u2()  // 从1开始验证常量池索引  for i := uint16(1); i < constPoolCount; i++ {    c := Const{Tag: l.u1()}    switch c.Tag {    case 0x01: // UTF8 字符串, 长度 2 个字节 + data      c.String = string(l.bytes(int(l.u2())))    case 0x07: // Class 索引      c.NameIndex = l.u2()    case 0x08: // String 引用索引      c.StringIndex = l.u2()    case 0x09, 0x0a: // 字段和方法:class 索引 + NaT 索引      c.ClassIndex = l.u2()      c.NameAndTypeIndex = l.u2()    case 0x0c: // 名字与类型      c.NameIndex, c.DescIndex = l.u2(), l.u2()    default:      l.err = fmt.Errorf("unsupported tag: %d", c.Tag)    }    constPool = append(constPool, c)  }  return constPool}


    上面的例子很简单,实际 JVM 处理 long 和 double 常量时会插入未使用的常量以进行区别处理。按照 JVM 的规定,常量会按照32bit 处理。 下面的 Resolve(index uint16) string 方法让按索引号处理字符串变得更简单:


  • func (cp ConstPool) Resolve(index uint16) string {  if cp[index-1].Tag == 0x01 {    return cp[index-1].String  }  return ""}


    现在开始解析类、接口、字段、方法和它们的属性,通过下面的 helper 方法实现:


  • func (l *loader) interfaces(cp ConstPool) (interfaces []string) {  interfaceCount := l.u2()  for i := uint16(0); i < interfaceCount; i++ {    interfaces = append(interfaces, cp.Resolve(l.u2()))  }  return interfaces}
    // Field 类型适用于字段和方法type Field struct { Flags uint16 Name string Descriptor string Attributes []Attribute }
    // Attribute 提供了字段和类的额外信息// 最有用 "Code" 属性,包含了实际的字节码信息type Attribute struct { Name string Data []byte}
    func (l *loader) fields(cp ConstPool) (fields []Field) { fieldsCount := l.u2() for i := uint16(0); i < fieldsCount; i++ { fields = append(fields, Field{ Flags: l.u2(), Name: cp.Resolve(l.u2()), Descriptor: cp.Resolve(l.u2()), Attributes: l.attrs(cp), }) } return fields}
    func (l *loader) attrs(cp ConstPool) (attrs []Attribute) { attributesCount := l.u2() for i := uint16(0); i < attributesCount; i++ { attrs = append(attrs, Attribute{ Name: cp.Resolve(l.u2()), Data: l.bytes(int(l.u4())), }) } return attrs}


    用 Field 代表字段和方法,可以帮助我们节省很多时间。最后把上面的代码合起来解析一个完整的 class 文件。


  • type Class struct {  ConstPool  ConstPool  Name       string  Super      string  Flags      uint16  Interfaces []string  Fields     []Field  Methods    []Field  Attributes []Attribute}
    func Load(r io.Reader) (Class, error) { loader := &loader{r: r} c := Class{} loader.u8() // magic (u32), minor (u16), major (u16) cp := loader.cpinfo() // 常量池信息 c.ConstPool = cp c.Flags = loader.u2() // 访问标记 c.Name = cp.Resolve(loader.u2()) // 当前类 c.Super = cp.Resolve(loader.u2()) // 父类 c.Interfaces = loader.interfaces(cp) c.Fields = loader.fields(cp) // 字段 c.Methods = loader.fields(cp) // 方法 c.Attributes = loader.attrs(cp) // 方法 return c, loader.err}


    进一步深入类信息可以看到其中包含了零个字段和两个方法:<init>:()V 和 add:(II)I。这些看起来像罗马数字和括号的东西又是什么?它们是描述符。定义了方法接受什么类型的参数以及返回值类型。


    上面的例子中,<init> 不接受任何参数(构造对象时调用),也不返回任何东西 (V=void);“add” 方法接受两个 int (I=int32) 并返回一个整数。


    2. 字节码


    仔细观察,会发现解析后的类中每个方法都有一个名为 “Code” 的属性。该属性带有一个字节的信息,其二进制输出如下:


  • <init>:[0 1 0 1 0 0 0 5 42 183 0 1 177 0 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 0 1 0 7 0 0 0 6 0 1 0 0 0 3]


    如果仔细查看规范文档中的 bytecode 章节,会发现 “Code” 属性从 maxstack(2个字节)开始,接着是 maxlocals(2个字节),然后是代码长度(4个字节),最后是实际代码。示例如下:


  • <init>: maxstack: 1, maxlocals: 1, code: [42 183 0 1 177]add: maxstack: 2, maxlocals: 2, code: [26 27 96 172]


    每个方法只包含4和5个字节代码。这些字节代表什么含义?


    正如之前提到的,JVM 是一个堆栈机器。每条指令都会被编码成单个字节,后面跟一些附加参数。如果仔细阅读规范会看到 “add” 方法包含了下面的指令:


  •  26 = iload_0 27 = iload_1 96 = iadd172 = ireturn


    和文章开始的时候看到的 javap 输出的结果一样!接下来要如何执行?


    3. JVM 帧


    JVM 内部执行方法时,每个方法有自己的堆栈用来存储临时操作数、本地变量和待执行的代码块。所有这些参数都存储在一个执行帧(Frame)中。此外,执行帧还包含当前指令指针和包含了该方法的类指针。后者用于访问类的常量池以及其他细节。


    下面的代码中,方法为需要调用的方法创建了一个帧。这里使用 interface{} 作为值类型,当然 union 类型是另一种更安全的选择。


  • 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] } return frame } } } } panic("method not found")}


    有了帧、初始化的局部变量、空堆栈、预加载的字节码,现在开始执行:


  • func Exec(f Frame) interface{} {  for {    op := f.Code[f.IP]    log.Printf("OP:%02x STACK:%v", op, f.Stack)    n := len(f.Stack)    switch op {    case 26: // iload_0      f.Stack = append(f.Stack, f.Locals[0])    case 27: // iload_1      f.Stack = append(f.Stack, f.Locals[1])    case 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]    case 172: // ireturn      v := f.Stack[n-1]      f.Stack = f.Stack[:n-1]      return v    }    f.IP++  }}


    最后整合上面的工作,调用 add() 方法:


  • f, _ := os.Open("Add.class")class, _ := Load(f)frame := class.Frame("add", int32(2), int32(3))result := Exec(frame)log.Println(result)
    // 输出OP:1a STACK:[]OP:1b STACK:[2]OP:60 STACK:[2 3]OP:ac STACK:[5]5


    最终,我们有了一个可以工作的 JVM。虽然它非常简陋,但仍然做了 JVM 该做的事情:加载字节码并进行解析(当然,真正的 JVM 完成的工作不止于此)。


    4. 还缺点什么


    还要支持200条指令、运行时、面向对象类型系统以及其他一些东西。JVM 总共有11组指令,其中大多数都很简单:


  • 常量:将 null 值或常量池数值加载到堆栈上;

  • Load:将局部变量加载到堆栈上。此外还有32条类似指令;

  • Store:从堆栈弹出局部变量。此外还有32条类似指令;

  • Stack:pop、dup、swap。每个堆栈机器都支持类似的指令;

  • Math:add、sub、div、mul、rem、shift、logic。针对不同的值类型,总工有36条指令;

  • 转换:int 转 short,int 转 float 等;

  • 比较:eq、ne、le 等。用来生成像 if/else 这样的条件指令;

  • 控制:goto、return。对循环和子程序很有用;

  • 引用:最有趣的部分是字段、方法、异常和对象监视器;

  • 扩展:看起是一种丑陋的解决方案,可能不会随着时间改变;

  • 预留:断点指令 0xca 可归为这一类。


  • 大多数指令的实现都很简单:从堆栈中取出一个或者两个个参数,对它们执行一些操作,然后把结果推入堆栈。这里需要记住的是:long 和 double 每个值在堆栈上要占用两个槽(Slot),因此可能需要额外增加 push() 和 pop()。这样会提高指令分组难度。 


    实现应用需要考虑对象模型:如何存储对象和它们的类,如何表达继承,如何存储实例与类的字段。


    不仅如此,还要仔细设计方法分派:又很多不同的“invoke”指令,使用时要注意细微的区别:


  • invokestatic:调用类中的静态方法;

  • invokespecial:直接调用实例方法,主要用于合成方法(Synthetic Method)与私有方法。<init> 就是一个典型的合成方法;

  • invokevirtual:按照类的继承链调用实例方法;

  • invokeinterface:调用接口方法。与 invokevirtual 类似,但是检查与优化有所不同;

  • invokedynamic:动态计算调用站点(Call Site)进行调用。此功能在 Java 7 中引入,适用于动态方法和 MethodHandle。


  • 如果 JVM 的实现语言不支持,还需要考虑如何实现垃圾回收:引用计数、标记和清除等。此外还要处理异常:通过 athrow 指令实现异常处理。在不同的调用帧中传递异常并通过异常表进行处理。


    最后,如果没有运行时类 JVM 也没法使用。例如,没有 java/lang/Object ,就不可能使用 new 指令构造新对象。设计的运行时需要提供一些通用的 JRE 类,例如 java.lang、java.io 和 java.util 包提供的类。此外,还需要一些提供特定功能的类。这些类包含的方法,有一些不得不用非 Java 语言提供本地实现。这就为 JVM 引入了一些边界场景,需要考虑如何找到并且执行这些方法。


    换句话说,实现一个 JVM 并不是那么简单,但理解它的实现机制也没那么复杂。

    以上的工作只花了一个周末。虽然这个 JVM 看起来还有很长的路要走,但是结构看起来还是比较清晰的:https://github.com/zserge/tojvm(欢迎提交 PR)。

    最终完成的代码不多,可以作为实现参考要点。


    如果对 JVM 底层实现这个主题想要了解更多,推荐下面的 JVM 实现:


  • Mika:https://github.com/kifferltd/open-mika

  • Avian:https://github.com/ReadyTalk/avian

  • NanoVM:https://github.com/harbaum/NanoVM

  • Luje:https://github.com/davidgiven/luje(一个出色的 LuaJIT)


  • 希望这篇文章不会为你带来困扰。虚拟机是一个很有意思的主题,JVM 的地位名副其实。

    推荐阅读   点击标题可跳转

    JVM 解剖公园:初始化开销

    JVM 史上最最最完整深入解析

    JVM 性能调优监控工具 jps、jstack、jmap、jhat、jstat、hprof 使用详解


    看完本文有收获?请转发分享给更多人

    关注「ImportNew」,提升Java技能

    好文章,我在看❤️