如何写一个玩具JVM

500次阅读  |  发布于4年以前

毋庸置疑,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 是什么? 代表什么?为什么有的内容会以“cafe babe”为前缀?还可以 dump class 文件,结果看起来似乎更好理解:

$ javap -c Add
Compiled 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个常量)。常量池中支持下面几种类型,每种类型包含一组不同的值:

可以看到,池中的常量经常互相引用。由于本文使用 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
}

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

上面的例子中, 不接受任何参数(构造对象时调用),也不返回任何东西 (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 = iadd
172 = 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组指令,其中大多数都很简单:

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

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

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

如果 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 实现:

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

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8