一直對虛擬機這個黑盒很是感興趣,因爲從前都是直接學習x86或者ARM這些實際的體系結構,什麼寄存器、ALU、CPU、總線、亂序執行和Cache等相關的觀念都已經爛熟於心。另外在學習C++或者C語言時,對函數調用棧幀很是熟悉,什麼函數調用前壓參、保存寄存器值、EBP、ESP或者函數返回值如何傳遞,更深層次的如對象的this指針如何傳遞,或者C++的RTTI以及C++內部的實現機制。可是對java裏面的實現機制確實只知其一;不知其二,爲何人們說對象都是分配在堆上(這個是Java語義模型決定的,C++是值模型,而Java是值模型和引用模型混合的,Builtin Type是值模型,UserDefined Type是引用模型,也就是分配在堆上 — 固然JVM應該有相應的優化措施,由於大量簡單的小對象也分配在堆上的話,會增長GC的壓力),JVM中的棧和C++中的棧不是一種概念。html
剛接觸到虛擬機這個概念的時候,有點兒茫然,雖然知道JVM相關的概念,什麼字節碼,什麼JIT,什麼GC啊,可是這些瞭解只是淺嘗輒止,並無什麼實質性的認識。再趕上lua或者python的實現機制,更是雲裏霧裏。java
那麼虛擬機究竟是什麼,是怎麼工做的,爲何要設計成這樣?在這篇文章中,我就簡單敘述一下最近對虛擬機的理解。node
虛擬機是藉助於操做系統對物理機器的一種模擬。可是咱們今天所講述的虛擬機概念比較狹義,與vmware或者virtual-box不一樣,而是針對具體語言所實現的虛擬機。例如在JVM或者CPython中,JAVA或者python源碼會被編譯成相關字節碼,而後在對應虛擬機上運行,JVM或CPython會對這些字節碼進行取指令,譯碼,執行,結果回寫等操做,這些步驟和真實物理機器上的概念都很類似。相對應的二進制指令是在物理機器上運行,物理機器從內存中取指令,經過總線傳輸到CPU,而後譯碼、執行、結果存儲。python
虛擬機爲了可以執行字節碼,須要模擬出物理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」,在基於棧的虛擬機上字節碼指令以下所示:
I1: LOAD C I2: LOAD B I3: ADD I4: STORE A
因爲操做數都是隱式地,因此指令能夠作的很短,通常都是一個或者兩個字節。可是顯而易見就是指令條數會顯著增長。而基於寄存器虛擬機執行該操做只有一條指令,
I1: add a, b, c
其中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)指令。同時指令更緊湊更簡潔。可是因爲顯示指定了操做數,因此基於寄存器的代碼會比基於棧的代碼要大,可是因爲指令數量的減小,其實沒有大多少。
(1)指令條數:棧式虛擬機多
(2)代碼尺寸:棧式虛擬機
(3)移植性:棧式虛擬機移植性更好
(4)指令優化:寄存器式虛擬機更能優化
棧式 VS 寄存器式 | 對比 |
---|---|
指令條數 | 棧式 > 寄存器式 |
代碼尺寸 | 棧式 < 寄存器式 |
移植性 | 棧式優於寄存器式 |
指令優化 | 棧式更不易優化 |
解釋器執行速度 | 棧式解釋器速度稍慢 |
代碼生成難度 | 棧式簡單 |
簡易實現中的數據移動次數 | 棧式移動次數多 |
解釋器最重要的開銷在於指令調度(instruction dispatch),指令調度主要操做包括從內存中取出指令,而後跳轉到解釋器相對應的代碼段,而後執行這條指令。其中一個簡易實現就是使用switch-based的方式來進行,這種方式簡單易實現,另外任何語言都有相應的switch語句。switch-based的指令調度,經過一個死循環不斷的從內存取出指令來執行,針對不一樣的指令選擇不一樣的執行方式。
一種JVM基於SBD實現方式以下圖所示:
注:圖片來自這裏
這種方式實現加單,代碼移植性好,可是有一個缺點就是分支預測失效的機率比較高。
如今的CPU都是基於流水線結構的,間接跳轉指令的跳轉結果須要等到執行級才能知曉,若是預測失敗須要排空流水線,流水線級數越多分支預測失敗致使流水線排空的時間越長。
因爲編譯後的指令是隨機的,不太可能提取出預測模式。《》