[Golang实现JVM第三篇] 解释器雏形

在上一篇中我们已经完成了class文件的解析工作,虽然没有解析所有的属性,但是已经足够支持一些基本的算法题Java代码编译生成的class文件了。有了这一步,日后如果遇到新的特性需要支持,只需缺哪补哪,补上对应属性的解析逻辑就可以了。下一步就是实现一个基本的执行引擎,即解释器,并且支持基本的栈操作相关的指令,比如iconst_x, istore_x, bipush等。

基于栈的指令集和基于寄存器的指令集

JVM字节码是一套基于栈的指令集,也就是说操作数栈是一切计算的基本容器,大部分指令都是围绕着操作数栈展开的。相对应的还有一种基于寄存器的指令集,这种指令集的特点是指令中就携带寄存器地址,对寄存器进行操作,优点指令短小精悍,执行效率高,缺点是会依赖于特定的硬件,可移植性差。栈指令集的优缺点则刚好相反,因为栈是一种抽象数据结构而不是具体的硬件设施,因此可移植性强,但是指令的数量往往比较臃肿,执行效率相对较低。

举个例子,计算1+1,用Javac编译后的对应字节码是这样的:

iconst_1
iconst_1
iadd
istore_0

其中前两条iconst_1表示将整数1压入操作数栈,这时候操作数栈就有两个1了。iadd指令则表示从操作数栈中连续出栈两次,将值相加,最后再把结果压入栈中。istore_0则表示将栈顶元素(计算结果2)出栈,存入本地变量表索引为0的槽位中。可以看到,明明是很简单的计算,却多出了很多压栈出栈操作。

如果是基于寄存器的指令集,那一般会长这样:

mov exa,1
add exa,1

即先将1存入exa寄存器,然后直接把寄存器中的值+1完成计算,指令数量少了很多。

不过并不是说JVM的字节码一定就会比寄存器指令慢,毕竟JVM 中有大量的优化,字节码可能会被省略、被乱序执行,或者直接被JIT编译成本地语言,也就是基于寄存器的指令了。当然,优化并不在我们实现JVM的目标范围内。

解释器实现思路

想实现JVM的朋友应该都对JVM的基本构成有了解了,什么方法区、堆区、方法栈等等。还是那句话,千万不要一上来就考虑这么复杂的因素,这样只会掉坑里爬不出来,正确的方法是先实现一个最简单的能跑的例子,然后再根据真实的JVM结构慢慢扩充。比如,对于字节码iconst_1来说,他的含义就是将整数1压如操作数栈,那么实现一个栈,遇到这条指令就压栈不就完事了吗?什么方法栈、堆区、类加载器、垃圾回收、线程调度,现阶段通通都不要考虑,随着字节码越来越复杂,这些总会有的。

一个解释器应该具备的最基本的要素,就两条,一是死循环,二是指向下一条指令的程序计数器(Program Counter, 简称PC),golang伪代码如下:

pc := 0
for {
  byteCode := code[pc] // 取出pc指向的指令
  execute(byteCode, &pc) // 执行指令,同时传入PC的指针,因为执行的过程可能需要修改pc的值
  if 结束? {
    break
  }
}

我们可以先定义一个结构体MiniJvm来表示一个JVM:

// VM定义
type MiniJvm struct {
    // 方法区
    MethodArea *MethodArea

    // MainClass全限定性名
    MainClass string

    // 执行引擎
    ExecutionEngine ExecutionEngine

    // 本地方法表
    NativeMethodTable *NativeMethodTable

    // 保存调用print的历史记录, 单元测试用
    DebugPrintHistory []interface{}
}

这里有很多现阶段用不到的字段,忽略即可,等解释到对应的字节码以后再回头加上;

然后我们定义出执行引擎接口,为啥用接口呢,因为现在是解释的,万一以后牛逼了想搞个编译的呢?

type ExecutionEngine interface {
    Execute(file *class.DefFile, methodName string) error
}

想想JVM在运行时都需要指定一个主类,所以第一个参数可以是主类的class定义,DefFile类型这个在上一篇解析class文件中就已经定义过了,里面包含一个类的全部信息。methodName指定要从哪个方法开始执行,为了简单起见,直接传入方法的简单名。

然后就可以定义一个执行引擎的具体实现了:

// 解释执行引擎
type InterpretedExecutionEngine struct {
    miniJvm *MiniJvm
}

好了,现在就可以开始解释字节码了!

解释字节码

直接解释字节码,很多人可能会问,符号引用解析了吗?方法描述符解析了吗?访问权限验证(public, private)做了吗?方法栈哪去了?本地变量表还没有呢?这些一堆的问题。但是,这些边边角角的东西对我们的MiniJvm来说,现在都还不重要。还是那句话,如果过早的陷入到繁杂的细节中就会失去对问题核心的把控。所以,接下来要做的就是:

  • 遍历DefClass结构体的Methods字段
  • 根据传入的方法名,找到目标方法
  • 取出code属性
  • 遍历字节码,解释执行

搜索方法的简化代码如下(完整代码可参考https://github.com/wanghongfei/mini-jvm/blob/master/vm/interpreted_execution_engine.go):

// 查找方法定义;
// def: 当前class定义
// methodName: 目标方法简单名
// methodDescriptor: 目标方法描述符
func (i *InterpretedExecutionEngine) findMethod(def *class.DefFile, methodName string, methodDescriptor string) (*class.MethodInfo, error) {
    currentClassDef := def
    for {
        for _, method := range currentClassDef.Methods {
            name := def.ConstPool[method.NameIndex].(*class.Utf8InfoConst).String()
            descriptor := def.ConstPool[method.DescriptorIndex].(*class.Utf8InfoConst).String()
            // 匹配简单名和描述符
            if name == methodName && descriptor == methodDescriptor {
                return method, nil
            }
        }

        // 从父类中寻找
        // ... 省略
    
        // 取出父类全名
        // .. 省略

        // 加载父类
        // .. 省略

        currentClassDef = parentDef
    }


    return nil, fmt.Errorf("method '%s' not found", methodName)
}

忽略方法描述符参数,最最基本的逻辑其实就是遍历数组、从常量池中取出方法名、判断是否跟目标名称匹配、返回。

找到方法后就可以提取字节码了:

func (i *InterpretedExecutionEngine) findCodeAttr(method *class.MethodInfo) (*class.CodeAttr, error) {
    for _, attrGeneric := range method.Attrs {
        attr, ok := attrGeneric.(*class.CodeAttr)
        if ok {
            return attr, nil
        }
    }

    // native方法没有code属性
    return nil, nil
}

这个方法返回一个CodeAddr类型的指针,这个类型的定义在上一篇解析class文件中就定义好了,可能文章里没有,但是github项目里有,结构如下:

// code属性
type CodeAttr struct {
    AttrLength uint32

    MaxStack uint16
    MaxLocals uint16

    // 字节码长度
    CodeLength uint32
    Code []byte

    // 异常表
    ExceptionTableLength uint16
    ExceptionTable []*ExceptionTable

    AttrCount uint16
    Attrs []interface{}
}

可以明显的看到,Code字段就是我们要找的字节码了!

接下的要做的就更简单了,遍历,执行:

for {
        // 取出pc指向的字节码
        byteCode := codeAttr.Code[frame.pc]

        exitLoop := false

        // 执行
        switch byteCode {
        case bcode.Iconst0:
            // 将0压栈
            frame.opStack.Push(0)

        default:
            return fmt.Errorf("unsupported byte code %s", hex.EncodeToString([]byte{byteCode}))
        }

        if exitLoop {
            break
        }

        // 移动程序计数器
        frame.pc++
    }

    return nil

由于从code中读取出来的是byte类型,所以我们需要定义一下每一个byte对应哪条指令,例如:

package bcode

const (
    Nop byte = 0x00

    Iconst0 = 0x03
    // .. 省略 ..
)

这样就可以用switch case非常直观的处理字节码了。

对于icons_0这条指令,是要我们向操作数栈中压如一个整数0,所以,还需要一个栈:

// 操作数栈
type OpStack struct {
    elems []interface{}

    // 永远指向栈顶元素
    topIndex int
}


func NewOpStack(maxDepth int) *OpStack {
    return &OpStack{
        elems:        make([]interface{}, maxDepth),
        topIndex:    -1,
    }
}

完整代码可以参考: https://github.com/wanghongfei/mini-jvm/blob/master/vm/op_stack.go

有了栈就可以解释这条指令了:

frame.opStack.Push(0)

注意frameMethodStackFrame类型的指针,表示一个方法栈的栈帧(现阶段可以不实现,直接操作栈即可),里面有程序计数器和本地变量表,完整定义可参考https://github.com/wanghongfei/mini-jvm/blob/master/vm/method_stack.go

至此,我们的Mini-JVM已经完成了第一条字节码指令的解释,算是迈出了万里长征第二步。完成第一条指令的解释后,我们就可以照葫芦画瓢,解释第二、第三条,当发现缺少执行这条指令的基础设施时再去实现这些设施,而不是一开始就想太多。

实际上当严格按照规范完成全部200多条字节码的解释后,JVM就基本完工了。虽然后面的指令会越来越复杂,解释所需要做的工作也越来越多,但是我们可以把支持的字节码数量当做衡量JVM进展的里程碑,相当于把一个天文工程划分成了200多个小步,这样写起来就能及时看到成果,也很有意思,不是吗?

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342