什么是虛擬機
虛擬機是借助於操作系統對物理機器的一種模擬。但是我們今天所講述的虛擬機概念比較狹義,與vmware或者virtual-box不同,而是針對具體語言所實現的虛擬機。例如在JVM或者CPython中,JAVA或者python源碼會被編譯成相關字節碼,然后在對應虛擬機上運行,JVM或CPython會對這些字節碼進行取指令,譯碼,執行,結果回寫等操作,這些步驟和真實物理機器上的概念都很相似。相對應的二進制指令是在物理機器上運行,物理機器從內存中取指令,通過總線傳輸到CPU,然后譯碼、執行、結果存儲。
虛擬機為了能夠執行字節碼,需要模擬出物理CPU能夠執行的相關操作,與虛擬機實現相關的概念如下:
(1)將源碼編譯成VM所能執行的具體字節碼。
(2)字節碼格式(指令格式),例如三元式,樹還是前綴波蘭式。
(3)函數調用相關的棧結構,函數的入口,出口,返回以及如何傳參。還有為了能夠順利返回所需的相關棧幀信息如何布置。
(4)一個“指令指針”,指向下一條待執行的指令(內存中),對應物理機器的EIP。
(5)一個虛擬“CPU”-指令調度器,
- 獲取下一條指令
- 對操作數進行解碼
- 執行這條指令
這三點是解釋器執行字節碼最重要的開銷。
虛擬機的實現方式
如今虛擬機的實現方式有兩種,基於棧的和基於寄存器的,這兩種實現方式各有優劣,也都有標志性的產品。基於棧的虛擬機,有JVM,CPython以及.Net CLR。基於寄存器的,有Dalvik以及Lua5.0,另外Perl聽說也要改為基於寄存器方式。無論這兩種方式實現機制如何,都要實現以下幾點:
- 取指令,其中指令來源於內存
- 譯碼,決定指令類型(執行何種操作)。另外譯碼的過程要包括從內存中取操作數
- 執行。指令譯碼后,被虛擬機執行(其實最終都會借助於物理機資源)
- 存儲計算結果
其實這和物理機CPU的執行是很相似的,都包括取值,譯碼,執行,回寫等步驟。但是不同的一點是虛擬機應該模仿不出流水線,例如在當前指令譯碼完成之后,CPU中的譯碼部件處於空閑狀態,可以用來對下一條指令進行譯碼,所以流水線有多少級就相當於可以並行執行多少指令。當然中間還有些指令相關和亂序的概念,這里就不詳說了。
下圖中一個典型的指令流水線結構,由於虛擬機在操作系統上通過程序模擬,遵循馮諾依曼結構順序執行的,應該很難實現出流水線結構。
基於棧的虛擬機
基於棧的虛擬機有一個操作數棧的概念,虛擬機在進行真正的運算時都是直接與操作數棧(operand stack)進行交互,不能直接操作內存中數據(其實這句話不嚴謹的,虛擬機的操作數棧也是布局在內存上的),也就是說不管進行何種操作都要通過操作數棧來進行,即使是數據傳遞這種簡單的操作。這樣做的直接好處就是虛擬機可以無視具體的物理架構,特別是寄存器。但缺點也顯而易見,就是速度慢,因為無論什么操作都要通過操作數棧這一結構。
由於執行時默認都是從操作數棧上取數據,那么就無需指定操作數。例如,x86匯編”ADD EAX, EBX”,就需要指定這次運算需要從什么地方取操作數,執行完結果存放在何處。但是基於棧的虛擬機的指令就無需指定,例如加法操作就一個簡單的”Add”就可以了,因為默認操作數存放在操作數棧上,直接從操作數棧上pop出兩條數據直接執行加法運算,運算后的結果默認存放在棧頂。其中操作數棧(operand stack)的深度由編譯器靜態確定,方便給棧幀預分配空間。這個和不能再棧上定義變長數組相似(其實這句話不嚴謹的,棧上分配變長數組,需要編譯器的支持,分配在棧頂),由於局部變量的地址只能在編譯期(compile time)確定針對當前棧幀的offset,如果中間有一個變量是一個變長數組的話,那么后面變量的offset就無法確定了(vector的數據是分配在堆上的,自己控制)。
例如執行”a = b + c”,在基於棧的虛擬機上字節碼指令如下所示:
- 1
- 2
- 3
- 4
- 1
- 2
- 3
- 4
由於操作數都是隱式地,所以指令可以做的很短,一般都是一個或者兩個字節。但是顯而易見就是指令條數會顯著增加。而基於寄存器虛擬機執行該操作只有一條指令,
- 1
- 1
其中a,b,c都是虛擬寄存器。操作數棧上的變化如下圖所示:
首先從符號表上讀取數據壓入操作數棧,
然后從棧中彈出操作數執行加法運算,這步操作有物理機器執行,如下圖所示:
從圖示中可以看出,數據從局部變量表中還要經過一次操作數棧的操作,注意操作數棧和局部變量表都是存放在內存上,內存到內存的數據傳輸在x86的機器上都是要經過一次數據總線傳輸的。可以得出一次簡單的加法基本上需要9次數據傳輸,想想都很慢。
但是基於棧的虛擬機優點就是可移植,寄存器由硬件直接提供。使用棧架構的指令集,用戶程序(編譯后的字節碼)不會直接使用硬件中的寄存器,同時為了提高運行時的速度,可以將一些訪問比較頻繁的數據存放到寄存器中以獲取盡量好的性能。另外,基於棧的虛擬機中指令更加緊湊,一個字節或者兩個字節即可存儲,同時編譯器實現也比較簡單,不用進行寄存器分配。寄存器分配是一門大學問。
基於寄存器的虛擬機
前面提到過基於棧的虛擬機,這里我們簡要介紹一下基於寄存器的虛擬機運行機制。
基於寄存器的虛擬機中沒有操作數棧的概念,但是有很多虛擬寄存器,一般情況下這些寄存器(操作數)都是別名,需要執行引擎對這些寄存器(操作數)的解析,找出操作數的具體位置,然后取出操作數進行運算。
既然是虛擬寄存器,那么肯定不在CPU中(想想也不應該在CPU中,虛擬機的根本目的就是跨平台和兼容性),其實和操作數棧相同,這些寄存器也存放在運行時棧中,本質上就是一個數組。
新的虛擬機也用棧分配活動記錄,寄存器就在該活動記錄中。當進入Lua程序的函數體時,函數從棧中分配一個足以容納該函數所有寄存器的活動記錄。函數的所有局部變量都各占據一個寄存器。因此,存取局部變量是相當高效的。
上面就是Lua虛擬機對寄存器的相關描述,示意圖如下:
從上圖中我們可以看到,其實“寄存器”的概念只是當前棧幀中一塊連續的內存區域。這些數據在運算的時候,直接送入物理CPU進行計算,無需再傳送到operand stack上然后再進行運算。例如”ADD R3, R2, R1”的示意圖就如下所示:
其實”ADD R3, R2, R1”還要經過譯碼的一個過程,當然當前這條指令的種類和操作數由虛擬機進行解釋。后面我們會看到,在有些實現中,有一個很大的switch-case來進行指令的分派及真正的運算過程。
下圖是Lua虛擬機的一些指令,該圖片來自這篇文章,中譯文這里。
使用寄存器式虛擬機沒有基於棧的虛擬機在拷貝數據而使用的大量的出入棧(push/pop)指令。同時指令更緊湊更簡潔。但是由於顯示指定了操作數,所以基於寄存器的代碼會比基於棧的代碼要大,但是由於指令數量的減少,其實沒有大多少。
棧式虛擬機 VS 寄存器式虛擬機
(1)指令條數:棧式虛擬機多
(2)代碼尺寸:棧式虛擬機
(3)移植性:棧式虛擬機移植性更好
(4)指令優化:寄存器式虛擬機更能優化
棧式 VS 寄存器式 | 對比 |
---|---|
指令條數 | 棧式 > 寄存器式 |
代碼尺寸 | 棧式 < 寄存器式 |
移植性 | 棧式優於寄存器式 |
指令優化 | 棧式更不易優化 |
解釋器執行速度 | 棧式解釋器速度稍慢 |
代碼生成難度 | 棧式簡單 |
簡易實現中的數據移動次數 | 棧式移動次數多 |
解釋器最重要的開銷在於指令調度(instruction dispatch),指令調度主要操作包括從內存中取出指令,然后跳轉到解釋器相對應的代碼段,然后執行這條指令。其中一個簡易實現就是使用switch-based的方式來進行,這種方式簡單易實現,另外任何語言都有相應的switch語句。switch-based的指令調度,通過一個死循環不斷的從內存取出指令來執行,針對不同的指令選擇不同的執行方式。
一種JVM基於SBD實現方式如下圖所示:
注:圖片來自這里
這種方式實現加單,代碼移植性好,但是有一個缺點就是分支預測失效的概率比較高。
現在的CPU都是基於流水線結構的,間接跳轉指令的跳轉結果需要等到執行級才能知曉,如果預測失敗需要排空流水線,流水線級數越多分支預測失敗導致流水線排空的時間越長。
由於編譯后的指令是隨機的,不太可能提取出預測模式。《》