iconst_xistore_xbipush
基于栈的指令集和基于寄存器的指令集
JVM字节码是一套基于栈的指令集,也就是说操作数栈是一切计算的基本容器,大部分指令都是围绕着操作数栈展开的。相对应的还有一种基于寄存器的指令集,这种指令集的特点是指令中就携带寄存器地址,对寄存器进行操作,优点指令短小精悍,执行效率高,缺点是会依赖于特定的硬件,可移植性差。栈指令集的优缺点则刚好相反,因为栈是一种抽象数据结构而不是具体的硬件设施,因此可移植性强,但是指令的数量往往比较臃肿,执行效率相对较低。
举个例子,计算1+1,用Javac编译后的对应字节码是这样的:
iconst_1
iconst_1
iadd
istore_0
iconst_1iaddistore_0
如果是基于寄存器的指令集,那一般会长这样:
mov exa,1
add exa,1
即先将1存入exa寄存器,然后直接把寄存器中的值+1完成计算,指令数量少了很多。
不过并不是说JVM的字节码一定就会比寄存器指令慢,毕竟JVM 中有大量的优化,字节码可能会被省略、被乱序执行,或者直接被JIT编译成本地语言,也就是基于寄存器的指令了。当然,优化并不在我们实现JVM的目标范围内。
解释器实现思路
iconst_1
一个解释器应该具备的最基本的要素,就两条,一是死循环,二是指向下一条指令的程序计数器(Program Counter, 简称PC),golang伪代码如下:
pc := 0
for {
byteCode := code[pc] // 取出pc指向的指令
execute(byteCode, &pc) // 执行指令,同时传入PC的指针,因为执行的过程可能需要修改pc的值
if 结束? {
break
}
}
MiniJvm
// 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
}
DefFilemethodName
然后就可以定义一个执行引擎的具体实现了:
// 解释执行引擎
type InterpretedExecutionEngine struct {
miniJvm *MiniJvm
}
好了,现在就可以开始解释字节码了!
解释字节码
直接解释字节码,很多人可能会问,符号引用解析了吗?方法描述符解析了吗?访问权限验证(public, private)做了吗?方法栈哪去了?本地变量表还没有呢?这些一堆的问题。但是,这些边边角角的东西对我们的MiniJvm来说,现在都还不重要。还是那句话,如果过早的陷入到繁杂的细节中就会失去对问题核心的把控。所以,接下来要做的就是:
DefClassMethodscode
搜索方法的简化代码如下(完整代码可参考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
// 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
codebyte
package bcode
const (
Nop byte = 0x00
Iconst0 = 0x03
// .. 省略 ..
)
这样就可以用switch case非常直观的处理字节码了。
icons_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
至此,我们的Mini-JVM已经完成了第一条字节码指令的解释,算是迈出了万里长征第二步。完成第一条指令的解释后,我们就可以照葫芦画瓢,解释第二、第三条,当发现缺少执行这条指令的基础设施时再去实现这些设施,而不是一开始就想太多。
实际上当严格按照规范完成全部200多条字节码的解释后,JVM就基本完工了。虽然后面的指令会越来越复杂,解释所需要做的工作也越来越多,但是我们可以把支持的字节码数量当做衡量JVM进展的里程碑,相当于把一个天文工程划分成了200多个小步,这样写起来就能及时看到成果,也很有意思,不是吗?