http://blog.youxu.info/2014/05/11/language-and-vm/前端
導言編程
編程語言的發展歷史,總的來講,是一個從抽象機器操做逐步進化爲抽象人的思惟的過程。機器操做和人的思惟如一枚硬幣的兩面,而語言編譯器就像是個雙面膠,將這兩面粘在一塊兒,保證編程語言源程序和機器代碼在行爲上等價。固然,人自己並非一個完美的編譯器,不能無錯的將思惟表達爲高級語言程序,這種誤差,即Bug。由於編譯器的幫助,咱們能夠脫離機器細節,只關心表達思惟和程序行爲這一面。後端
編程語言的發展突飛猛進。特別是隨着對問題的深刻理解,新的設計思想,語法構建和新的領域相關語言(DSL)層出不窮。而硬幣的另外一面彷佛一直波瀾不驚。這是天然的——無需關心底層架構的變化,或者目標代碼生成優化等技術的進化,正是編譯器帶給咱們的好處,由於這些細節和要解決的問題每每關係不大。瀏覽器
儘管所受的關注度不高,這些底層的技術一直在持續地進步。特別是這十年來,一場大的變革正在悄悄發生。這場變革,就是中間語言和虛擬機幾乎成爲了編程語言的標配——編譯器再也不以機器的CPU指令集做爲編譯目標,而是生成針對某種中間語言或虛擬機指令集的目標代碼。這場變化是深入的,它意味着編程語言的設計者自此徹底脫離了具體硬件平臺的束縛,語言如何設計和如何執行成爲了兩個徹底正交的系統。這個變革大幅度下降了創造一個新語言的成本,一會兒把咱們推入了一個語言井噴的時代。安全
從抽象語法樹到中間語言性能優化
熟悉編譯器設計的讀者都知道,編譯的第一步是構建一個叫抽象語法樹(AST)的數據結構 (腳註: 語法樹這個概念來源於 LISP)。有了這樣的數據結構後,解釋器和編譯器在此分野。以 AST爲起點,解釋器徹底能夠遍歷語法樹,遞歸執行每一個子結點。IEEE POSIX (或稱標準UNIX) 規定的 AWK 語言,其經典實現就是一個生成和遍歷語法樹的過程:數據結構
…
syminit();
compile_time = 1;
Node *winner ; /* root of parse tree */
yyparse(); /* generate parse tree */
…
if (errorflag == 0) {
compile_time = 0; /* switch to execution */
run(winner); /* execution of parse tree starts here */
}
…架構
Awk 這樣的傳統解釋器的優勢在於結構簡單,開發便利。事實上許多領域專用語言都採起這種方式實現,如 PostScript, Matlab, R 等。編程語言
解釋執行的缺點也是顯而易見的。首要的一點就是每次執行都須要從新生成語法樹。領域專用語言或許能夠忍受每次零點幾秒的重複解釋過程,而對於能夠開發大型應用的通用編程語言來講,這一點是致命的。每次從新生成語法樹也意味着這樣的語言難以用於資源受限系統,由於
語言自己語法結構複雜,佈置一個解釋模塊的代價每每很是高昂。爲了不解釋執行的這些弊端,傳統的編譯器致力於只解釋一次,將通用語言的語法樹,直接轉變爲目標機器的 CPU 指令。傳統的 FORTRAN 和 C 編譯器就是如此設計的。有些編程構建,如 C 語言中的 i++, 甚至是直接受 CPU 指令影響的產物。函數
上個世紀 80 年代後期,隨着對程序效率優化和 LISP 機器的研究,研究者們認識到,其實傳統的編譯和解釋並非對立的概念。特別的,編程語言的語法樹轉變能夠爲一種中間指令格式。這種中間指令格式貼近機器指令,能夠進行運行效率優化。傳統的以生成目標指令的編譯器,能夠將中間語言簡單轉爲機器指令。而解釋器,也省卻了屢次的語法樹生成,而直接解釋相對簡單的中間語言。
較早在中間語言上進行探索的是 MIT 的 LISP 機器。如 Thomas Knight,他的研究集中在如何在硬件上實現一個高效的 LISP 環境。顯然,沒有一個硅片能夠直接運行 mapcar,但設計一個支持 mapcar 的中間語言並不困難,只須要支持一些基本的列表操做便可。這種設計思想影響了不少後來的系統。流行的 GCC 編譯器,從結構上來講分前端和代碼生成端兩部分。鏈接二者的中間語言 RTL 的基本一些指令,均可以追溯到 LIPS 機器的指令集。
中間語言和虛擬機
中間語言可用於程序優化的緣由是顯而易見的:這種中間格式既貼近機器代碼,又保存了原有程序的結構。程序優化並非一門魔術。像循環展開,死代碼消除等技術,都依賴於程序控制結構,而中間語言能夠保持這樣的控制結構。事實上,目前咱們所知的編譯優化技術,無一不是創建在結構分析之上。中間語言的出現讓程序優化成爲了一個獨立的問題。本來單列的 C 程序優化, FORTRAN 程序優化現在統一歸結爲 RTL 程序優化。編譯器前端能夠千差萬別支持許多語言,但負責優化和翻譯爲目標代碼的後端均歸爲一個,就此一點,就大大簡化了語言編譯器的設計門檻。現現在,幾乎沒有一個語言設計者須要考慮如何生成高效目標代碼了。
固然,中間語言的做用並不只限於目標代碼優化。若是咱們把中間語言也看成一種語言的話,不難發現中間語言甚至比原語言更加普及。 好比,Java 虛擬機(JVM) 語言其實是一個比 Java 語言成功許多倍的產品。JVM 存在於衆多Java 語言不存在的地方。像 Jython, Scala 和 JRuby 這樣的語言,均依賴於 JVM, 而非 Java 語言自己。
語言的虛擬機的本質,是一個能夠運行中間語言的機器。 在實際硬件上,程序和數據是兩個大相徑庭的概念;而對於虛擬機來說,中間語言程序,只是虛擬機程序的輸入數據罷了。這種將程序看成數據的處理方式,帶來了咱們熟知的許多虛擬機的優勢,如跨平臺特性,安全性等等。 由於程序便是數據,爲虛擬機讀取中間語言程序方便,其指令每每都是以字節爲單位,故稱爲字節碼 (bytecode)。 相比之下,計算機的 CPU 指令則可長度不一,也不必定佔據整數個字節。
程序是數據這個特性使得虛擬機能夠作到跨平臺和沙箱安全;反過來,數據是程序又使得虛擬機能夠用在一些意想不到的地方,使數據更加靈活。 目前通行的輪廓字體描述語言 TrueType 就是成功運用虛擬機來更加靈活地處理字體的一個例子。
TrueType 是一種採用數學函數描述字體的矢量字體。 矢量字體在理論上能夠自由縮放。而實踐中,由於顯示器本質上是點陣的,全部的矢量字形都要通過柵格化 (rasterization) , 將矢量輪廓近似轉化爲像素點的透明度。 然而,這種近似並非隨意的。 以漢字 「中」 爲例,爲保證其對稱美觀,咱們必須約束柵格化程序,保證任什麼時候候左右兩個豎線與中間一豎的距離相等,哪怕爲此不惜將此字縮減或放寬一兩個像素。 這類約束又被稱做提示 (hinting)。 它對於字體相當重要—缺乏提示的矢量字體在字形較小時不可避免地會出現失真,變形和鋸齒等現象。 不難理解,本質上「提示」是一個以字體輪廓和字形大小爲輸入,以柵格數據爲輸出的程序。 由於此,TrueType 包含了一套虛擬機指令,方便字體設計者表達這種提示。 能夠想象,若是沒有這個虛擬機的存在,設計靈活的矢量字體是不可能完成的任務。 實際上,咱們所見到的幾乎全部的矢量字體文件,都是一個數據和程序的混合物。 從另外一方面來講,每一個字形都須要一個專門的「提示」,也從一個側面說明了設計高質量的中文字體之難度。
基於棧,仍是基於寄存器
凡提到虛擬機,繞不過去的第一個問題就是這個虛擬機是基於棧的,仍是基於寄存器的(有些虛擬機,如 LISP 機器,能夠同時有棧和寄存器)? 儘管這裏「寄存器」和「棧」,都不必定直接對應到機器CPU的寄存器或者內存裏的棧。這個問題之因此重要,由於它直接決定了虛擬機的應用場景。通常說來,基於棧的虛擬機結構相對簡單,且更加適合資源受限系統。 好比上文咱們說的 TrueType 虛擬機,結構簡單,功能專注,就是基於棧的。
儘管全部的計算機的存儲模型都是構建在圖靈機的無窮紙帶模型上,實踐中全部語言都或多或少依賴於棧模型。特別的,函數調用就等價於棧的推入和彈入操做,其餘操做都可抽象爲對棧頂元素進行。相比之下,寄存器模型雖然貼近真實機器,卻並不夠直接:不多有高級語言直接制定寄存器如何分配的,所以編譯器的做者須要考量寄存器分配問題。而基於棧的虛擬機的全部指令均可默認爲對棧頂元素操做,結構簡單,且暫時繞開了寄存器分配難題。
基於棧的虛擬機更加適合內存和 CPU 處理速度等方面有限的系統。一樣的源程序,在目標代碼的體積上,面向棧虛擬機上生成的代碼更加小。這是容易理解的:基於棧的虛擬機的指令默認對棧頂元素操做,所以指令只需爲 OP 格式,無需 OP Reg1, Reg2, Reg3 等額外指定寄存器。這個設計也繞開了指令解碼問題。平均上說,基於寄存器的虛擬機生成的指令的體積比基於棧的要大。咱們見到的許多基於棧的虛擬機,都是爲資源受限系統設計的。JVM 的初衷是一個運行在電視機頂盒中的小系統,後來精簡版本的 JVM 甚至能夠放到智能卡上;Forth 語言的虛擬機是要用在計算機固件(Open Firmware),航空系統和嵌入式系統中;控制打印的 Postscript 是用於高品質打印機中。很顯然,機頂盒,引導固件和打印機都是資源受限的系統,這些系統中的虛擬機,不約而同都是基於棧的。值得一提的是,由於實現簡單,許多並不是用於受限系統的通用語言的虛擬機也是基於棧的,如 Python, Ruby, .NET 的 CLR 等。
基於寄存器的虛擬機,是爲性能所生。引入寄存器假設當然關上了用於資源受限系統的門,卻也打開了一扇通向進一步性能優化的窗。棧虛擬機的一大缺點就是要不停地將操做數在堆和棧之間來回拷貝。比方說一個簡單的三個參數的函數調用,在傳遞參數上就須要至少三次入棧和出棧操做,而在寄存器上只要指定三個寄存器便可。現代處理器提供的通用寄存器支持,自己就是爲了減小這類值的來回拷貝。儘管有 Hotspot 這樣的技術可以將一段棧虛擬機指令轉化爲基於寄存器的機器指令,可畢竟沒有直接從支持寄存器的中間語言翻譯直接。前面說過,保持程序的結構是優化的先決條件。失去了「指定三個值」這樣的結構的棧虛擬機,須要運行時間接的推斷這個操做。而直接指定這些訪問結構,將值直接映射到 CPU 的寄存器,正是這類虛擬機運行效率高的要點所在。Android 的 Dalvik, Perl 的 Parrot 都是基於寄存器的虛擬機,而 LLVM 則是基於寄存器假設的中間語言。其中,爲了讓 Android 程序更加快的運行,Google 不惜放棄 JVM 的指令集,而選擇將 JVM 指令轉化爲基於有限個寄存器的 Dalvik 指令集。 Parrot 和 LLVM 則更加自由一些,假設了無窮多個寄存器。不管是有限仍是無限個寄存器,省卻沒必要要的值拷貝是這類中間語言的最大優勢。
JIT 和直接執行
JIT (Just-in-time) 是運行時的動態編譯技術。不難看出,JIT 是針對中間語言的——將原語言的編譯推遲到運行時並沒有意義,將中間語言的解釋,部分轉化爲編譯後的機器代碼,則能夠優化運行效率。JIT 之因此可行,一個基本假設是程序大多存在熱點。D. E. Knuth 三十年前觀察到的一個現象: 一段 FORTRAN 程序中不到 4% 的部分每每佔用超過 50%的運行時間。所以,在運行時識別這樣的熱點並優化,能夠事半功倍地提升執行效率。
按照 Jython 做者 Jim Hugunin 的觀測, JIT 技術出現後,一樣功能的程序,運行於 Java 虛擬機上的字節碼和直接編譯成二進制代碼的 C 程序幾乎同樣快,有的甚至比 C 快。乍一看虛擬機比原生代碼快,理論上是不可能的。而實踐中,由於 JIT 編譯器能夠識別運行時熱點作出特別優化。相比之下,靜態編譯器的代碼優化並不能徹底推斷出運行時熱點。並且,有些優化技術,如將虛函數調用靜態化,只有在運行時才能作到。在對熱點深度優化的狀況下,JIT 比直接生成的機器代碼執行效率高並非一件神奇的事情。引入了 JIT 的,以 Python 書寫的 Python 執行器 pypy, 運行速度要比以 C 實現的 CPython 解釋器快一到五倍,就是 JIT 技術魅力的一個明證。
儘管 JIT 技術看上去很炫,實踐中也可以作到幾乎和原生二進制代碼速度相近,咱們必須認可,這只是一種補救相對慢的中間語言解釋的一種措施罷了。設計語言平臺時,設計者可能由於這樣那樣的緣由而選擇中間語言/虛擬機解決方案,或由於針對嵌入式系統(Java),或由於跨平臺要求(Android Dalvik),或者僅僅由於設計者想偷懶不肯寫一個從語言到CPU指令的編譯器(Python/Ruby)。不管緣由爲什麼,當最初的緣由已經不存在或不重要,而性能又成爲重要考量的話,採用中間語言就顯得捨近求遠。JavaScript 引擎的進化就是一個生動的例子。
JavaScript 語言最初只是一種協助 HTML 完成動態客戶端內容的小語言。Netscape 瀏覽器中的JS 引擎,最初只是一個簡單的解釋器。自2004 年 Google 發佈 Gmail 以後, Ajax 技術的發展對 JS 引擎的速度提出了更高的挑戰。JavaScript 引擎的速度被當成一個瀏覽器是否領先於對手的關鍵指標。在此狀況下,衆多瀏覽器廠商紛紛捲入了一輪 JS 引擎速度的軍備競賽。
最早挑起這場戰爭的是 Firefox, 目標是當時佔據90%市場的 IE。Firefox 3 於2008年6月登場,其 JS 引擎 TraceMonkey 在棧虛擬機的基礎上首次採用了 JIT 技術,在當時衆多標準評測中超越了IE7。就在當月,WebKit 開發小組宣佈了基於寄存器的 Squirrelfish 引擎,異曲同工,也是基於中間語言,儘管二者互相不兼容。
到9月,Google 發佈了第一個版本的 Chrome 瀏覽器以及新的 JS 引擎: V8。V8一反使用中間語言的設計套路,力求將 JS 直接編譯到本地代碼。Google 絕不掩飾 V8 在標準評測上比其餘瀏覽器快的結果,所以形成了 Firefox 和Safari 開發者對各自 JS 引擎速度評測的一場惡戰。到了9月的時候,Firefox 和 Safari 各自的引擎都比6月份的結果快到 20%到60% 不等。 而 V8 也贏得了許多眼球,催生了以後的 Node.js 項目。
這場軍備競賽的一個結果,就是 V8 之外的引擎,也開始探索繞過中間語言從 JavaScript 直接生成二進制的可能性。SquirrelFish Extreme 就是自 Squirrelfish 衍生出來的一步本地代碼的引擎。值得注意的是,儘管都是生成本地代碼,V8 和 SquirrelFish Extreme 這樣的編譯器,並非退回到傳統的編譯器技術上,由於他們已經吸取了許多對 JIT 編譯器性能的研究成果。
就在我寫這篇文章的時候,Google 正在將 Android 執行環境,從原來的 Dalvik 虛擬機,換成能夠直接生成機器代碼的 ART 架構。ART 負責在 App 安裝後一次將跨平臺的字節碼分發格式,編譯成原生機器代碼。20 多年前,爲了跨平臺,Java 採起了虛擬機的設計方案。現在,中間語言的跨平臺的部分依然保留,但做爲已經不直接參與執行了。硬件的進步帶來的中間語言和虛擬機設計的進化,是當時的設計者如何也想不到的事情了。