在上一篇中我們已經完成了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)
注意frame
是MethodStackFrame
類型的指針,表示一個方法棧的棧幀(現階段可以不實現,直接操作棧即可),裏面有程序計數器和本地變量表,完整定義可參考https://github.com/wanghongfei/mini-jvm/blob/master/vm/method_stack.go
至此,我們的Mini-JVM已經完成了第一條字節碼指令的解釋,算是邁出了萬里長征第二步。完成第一條指令的解釋後,我們就可以照葫蘆畫瓢,解釋第二、第三條,當發現缺少執行這條指令的基礎設施時再去實現這些設施,而不是一開始就想太多。
實際上當嚴格按照規範完成全部200多條字節碼的解釋後,JVM就基本完工了。雖然後面的指令會越來越複雜,解釋所需要做的工作也越來越多,但是我們可以把支持的字節碼數量當做衡量JVM進展的里程碑,相當於把一個天文工程劃分成了200多個小步,這樣寫起來就能及時看到成果,也很有意思,不是嗎?