舉例:將i = a + b * c做爲源代碼輸入到解析器裏,則廣義上的解析器的工做流程以下圖:javascript
發表時間:2009-10-17 最後修改:2011-03-04
大前天收到一條PM:
引用
你好,很冒昧的向你發短消息,我如今在看JS引擎,能過看博客發現你對js engine很瞭解,我想請教一下你 基於棧的解析器與基於寄存器的解析器有什麼同,javascriptcore是基於寄存器的,V8是基於棧的,能不能說一下這二者有什麼同樣嗎?能推薦一點資料嗎?謝謝。
我剛收到的時候很興奮,就開始寫回復。寫啊寫發覺已經比我平時發的帖還要長了,想着乾脆把回覆直接發出來好了。因而下面就是回覆:
你好 ^ ^ 很抱歉拖了這麼久纔回復。碼字和畫圖太耗時間了。 別說冒昧了,我只是個普通的剛畢業的學生而已,擔當不起啊 =_=|||| 並且我也不敢說「很」瞭解,只是有所接觸而已。很高興有人來一塊兒討論JavaScript引擎的設計與實現,總以爲身邊對這個有興趣的人很少,或者是不多冒出來討論。若是你發個帖或者blog來討論這方面的內容我也會很感興趣的~ 想拿出幾點來討論一下。上面提出的問題我但願可以一一給予回答,不過首先得作些鋪墊。 另外先提一點:JavaScriptCore從SquirrelFish版開始是「基於寄存器」的,V8則不適合用「基於棧」或者「基於寄存器」的說法來描述。 一、解析器與解釋器 解析器是parser,而解釋器是interpreter。二者不是同同樣東西,不該該混用。 前者是編譯器/解釋器的重要組成部分,也能夠用在IDE之類的地方;其主要做用是進行語法分析,提取出句子的結構。廣義來講輸入通常是程序的源碼,輸出通常是語法樹(syntax tree,也叫parse tree等)或抽象語法樹(abstract syntax tree,AST)。進一步剝開來,廣義的解析器裏通常會有掃描器(scanner,也叫tokenizer或者lexical analyzer,詞法分析器),以及狹義的解析器(parser,也叫syntax analyzer,語法分析器)。掃描器的輸入通常是文本,通過詞法分析,輸出是將文本切割爲單詞的流。狹義的解析器輸入是單詞的流,通過語法分析,輸出是語法樹或者精簡過的AST。 (在一些編譯器/解釋器中,解析也可能與後續的語義分析、代碼生成或解釋執行等步驟融合在一塊兒,不必定真的會構造出完整的語法樹。但概念上說解析器就是用來抽取句子結構用的,而語法樹就是表示句子結構的方式。關於邊解析邊解釋執行的例子,能夠看看這帖的計算器。) 舉例:將i = a + b * c做爲源代碼輸入到解析器裏,則廣義上的解析器的工做流程以下圖: 其中詞法分析由掃描器完成,語法分析由狹義的解析器完成。 (嗯,說來其實「解析器」這詞仍是按狹義用法比較準確。把掃描器和解析器合起來叫解析器總以爲怪怪的,但很多人這麼用,這裏就將就下吧 =_= 不過近來「scannerless parsing」也挺流行的:不區分詞法分析與語法分析,沒有單獨的掃描器,直接用解析器從源碼生成語法樹。這倒整個就是解析器了,沒狹不狹義的問題) 後者則是實現程序執行的一種實現方式,與編譯器相對。它直接實現程序源碼的語義,輸入是程序源碼,輸出則是執行源碼獲得的計算結果;編譯器的輸入與解釋器相同,而輸出是用別的語言實現了輸入源碼的語義的程序。一般編譯器的輸入語言比輸出語言高級,但不必定;也有輸入輸出是同種語言的狀況,此時編譯器極可能主要用於優化代碼。 舉例:把一樣的源碼分別輸入到編譯器與解釋器中,獲得的輸出不一樣: 值得留意的是,編譯器生成出來的代碼執行後的結果應該跟解釋器輸出的結果同樣——它們都應該實現源碼所指定的語義。 在不少地方都看到解析器與解釋器兩個不一樣的東西被混爲一談,感到十分無奈。 最近某本引發不少關注的書便在開篇給讀者們當頭一棒,介紹了「JavaScript解析機制」。「編譯」和「預處理」也順帶混爲一談了,還有「預編譯」 0_0 我一直覺得「預編譯」應該是ahead-of-time compilation的翻譯,是與「即時編譯」(just-in-time compilation,JIT)相對的概念。另外就是PCH(precompile header)這種用法,把之前的編譯結果緩存下來稱爲「預編譯」。把AOT、PCH跟「預處理」(preprocess)混爲一談真是詭異。算了,我仍是不要淌這渾水的好……打住。 二、「解釋器」究竟是什麼?「解釋型語言」呢? 不少資料會說,Python、Ruby、JavaScript都是「解釋型語言」,是經過解釋器來實現的。這麼說其實很容易引發誤解:語言通常只會定義其抽象語義,而不會強制性要求採用某種實現方式。 例如說C通常被認爲是「編譯型語言」,但C的解釋器也是存在的,例如Ch。一樣,C++也有解釋器版本的實現,例如Cint。 通常被稱爲「解釋型語言」的是主流實現爲解釋器的語言,但並非說它就沒法編譯。例如說常常被認爲是「解釋型語言」的Scheme就有好幾種編譯器實現,其中率先支持R6RS規範的大部份內容的是Ikarus,支持在x86上編譯Scheme;它最終不是生成某種虛擬機的字節碼,而是直接生成x86機器碼。 解釋器就是個黑箱,輸入是源碼,輸出就是輸入程序的執行結果,對用戶來講中間沒有獨立的「編譯」步驟。這很是抽象,內部是怎麼實現的都不要緊,只要能實現語義就行。你能夠寫一個C語言的解釋器,裏面只是先用普通的C編譯器把源碼編譯爲in-memory image,而後直接調用那個image去獲得運行結果;用戶拿過去,發現直接輸入源碼能夠獲得源程序對應的運行結果就知足需求了,無需在乎解釋器這個「黑箱子」裏究竟是什麼。 實際上不少解釋器內部是以「編譯器+虛擬機」的方式來實現的,先經過編譯器將源碼轉換爲AST或者字節碼,而後由虛擬機去完成實際的執行。所謂「解釋型語言」並非不用編譯,而只是不須要用戶顯式去使用編譯器獲得可執行代碼而已。 那麼虛擬機(virtual machine,VM)又是什麼?在許多不一樣的場合,VM有着不一樣的意義。若是上下文是Java、Python這類語言,那麼通常指的是高級語言虛擬機(high-level language virtual machine,HLL VM),其意義是實現高級語言的語義。VM既然被稱爲「機器」,通常認爲輸入是知足某種指令集架構(instruction set architecture,ISA)的指令序列,中間轉換爲目標ISA的指令序列並加以執行,輸出爲程序的執行結果的,就是VM。源與目標ISA能夠是同一種,這是所謂same-ISA VM。 前面提到解釋器中的編譯器的輸出多是AST,也多是字節碼之類的指令序列;通常會把執行後者的程序稱爲VM,而執行前者的仍是籠統稱爲解釋器或者樹遍歷式解釋器(tree-walking interpreter)。這只是種習慣而已,並無多少確鑿的依據。只不過線性(相對於樹形)的指令序列看起來更像通常真正機器會執行的指令序列而已。 其實我以爲把執行AST的也叫VM也沒啥大問題。若是認同這個觀點,那麼把DLR看做一種VM也就能夠接受了——它的「指令集」就是樹形的Expression Tree。 VM並非神奇的就能執行代碼了,它也得采用某種方式去實現輸入程序的語義,而且一樣有幾種選擇:「編譯」,例如微軟的.NET中的CLR;「解釋」,例如CPython、CRuby 1.9,許多老的JavaScript引擎等;也有介於二者之間的混合式,例如Sun的JVM,HotSpot。若是採用編譯方式,VM會把輸入的指令先轉換爲某種能被底下的系統直接執行的形式(通常就是native code),而後再執行之;若是採用解釋方式,則VM會把輸入的指令逐條直接執行。 換個角度說,我以爲採用編譯和解釋方式實現虛擬機最大的區別就在因而否存下目標代碼:編譯的話會把輸入的源程序以某種單位(例如基本塊/函數/方法/trace等)翻譯生成爲目標代碼,並存下來(不管是存在內存中仍是磁盤上,無所謂),後續執行能夠複用之;解釋的話則把源程序中的指令是逐條解釋,不生成也不存下目標代碼,後續執行沒有多少可複用的信息。有些稍微先進一點的解釋器可能會優化輸入的源程序,把知足某些模式的指令序列合併爲「超級指令」;這麼作就是朝着編譯的方向推動。後面講到解釋器的演化時再討論超級指令吧。 若是一種語言的主流實現是解釋器,其內部是編譯器+虛擬機,而虛擬機又是採用解釋方式實現的,或者內部實現是編譯器+樹遍歷解釋器,那它就是名副其實的「解釋型語言」。若是內部用的虛擬機是用編譯方式實現的,其實跟廣泛印象中的「解釋器」仍是挺不一樣的…… 能夠舉這樣一個例子:ActionScript 3,通常都被認爲是「解釋型語言」對吧?但這種觀點究竟是把FlashPlayer總體當作一個解釋器,於是AS3是「解釋型語言」呢?仍是認爲FlashPlayer中的虛擬機採用解釋執行方案,於是AS3是「解釋型語言」呢? 其實Flash或Flex等從AS3生成出來的SWF文件裏就包含有AS字節碼(ActionScript Byte Code,ABC)。等到FlashPlayer去執行SWF文件,或者說等到AVM2(ActionScript Virtual Machine 2)去執行ABC時,又有解釋器和JIT編譯器兩種實現。這種須要讓用戶顯式進行編譯步驟的語言,究竟是不是「解釋型語言」呢?呵呵。因此我一直以爲「編譯型語言」跟「解釋型語言」的說法太模糊,不太好。 有興趣想體驗一下從命令行編譯「裸」的AS3文件獲得ABC文件,再從命令行調用AVM2去執行ABC文件的同窗,能夠從這帖下載我以前從源碼編譯出來的AVM2,本身玩玩看。例如說要編譯一個名爲test.as的文件,用下列命令:
java -jar asc.jar -import builtin.abc -import toplevel.abc test.as就是用ASC將test.as編譯,獲得test.abc。接着用:
avmplus test.abc就是用AVM2去執行程序了。很生動的體現出「編譯器+虛擬機」的實現方式。 這個「裸」的AVM2沒有帶Flash或Flex的類庫,能用的函數和類都有限。不過AS3語言實現是完整的。能夠用print()函數來向標準輸出流寫東西。 Well……其實寫Java程序不也是這樣麼?如今也確實還有不少人把Java稱爲「解釋型語言」,徹底無視Java代碼一般是通過顯式編譯步驟才獲得.class文件,而有些JVM是採用純JIT編譯方式實現的,內部沒解釋器,例如Jikes RVM。我愈發感到「解釋型語言」是個應該避開的用語 =_= 關於虛擬機,有本很好的書絕對值得一讀,《虛擬機——系統與進程的通用平臺》(Virtual Machines: Versatile Platforms for Systems and Processes)。國內有影印版也有中文版,我是讀了影印版,不太清楚中文版的翻譯質量如何。聽說翻譯得還行,我沒法印證。 三、基於棧與基於寄存器的指令集架構 用C的語法來寫這麼一個語句:
a = b + c;若是把它變成這種形式: add a, b, c 那看起來就更像機器指令了,對吧?這種就是所謂「三地址指令」(3-address instruction),通常形式爲: op dest, src1, src2 許多操做都是二元運算+賦值。三地址指令正好能夠指定兩個源和一個目標,能很是靈活的支持二元操做與賦值的組合。ARM處理器的主要指令集就是三地址形式的。 C裏要是這樣寫的話:
a += b;變成: add a, b 這就是所謂「二地址指令」,通常形式爲: op dest, src 它要支持二元操做,就只能把其中一個源同時也做爲目標。上面的add a, b在執行事後,就會破壞a原有的值,而b的值保持不變。x86系列的處理器就是二地址形式的。 上面提到的三地址與二地址形式的指令集,通常就是經過「基於寄存器的架構」來實現的。例如典型的RISC架構會要求除load和store之外,其它用於運算的指令的源與目標都要是寄存器。 顯然,指令集能夠是任意「n地址」的,n屬於天然數。那麼一地址形式的指令集是怎樣的呢? 想像一下這樣一組指令序列: add 5 sub 3 這隻指定了操做的源,那目標是什麼?通常來講,這種運算的目標是被稱爲「累加器」(accumulator)的專用寄存器,全部運算都靠更新累加器的狀態來完成。那麼上面兩條指令用C來寫就相似:
acc += 5; acc -= 3;只不過acc是「隱藏」的目標。基於累加器的架構近來比較少見了,在很老的機器上繁榮過一段時間。 那「n地址」的n若是是0的話呢? 看這樣一段Java字節碼:
iconst_1 iconst_2 iadd istore_0注意那個iadd(表示整型加法)指令並無任何參數。連源都沒法指定了,零地址指令有什麼用?? 零地址意味着源與目標都是隱含參數,其實現依賴於一種常見的數據結構——沒錯,就是棧。上面的iconst_一、iconst_2兩條指令,分別向一個叫作「求值棧」(evaluation stack,也叫作operand stack「操做數棧」或者expression stack「表達式棧」)的地方壓入整型常量一、2。iadd指令則從求值棧頂彈出2個值,將值相加,而後把結果壓回到棧頂。istore_0指令從求值棧頂彈出一個值,並將值保存到局部變量區的第一個位置(slot 0)。 零地址形式的指令集通常就是經過「基於棧的架構」來實現的。請必定要注意,這個棧是指「求值棧」,而不是與系統調用棧(system call stack,或者就叫system stack)。千萬別弄混了。有些虛擬機把求值棧實如今系統調用棧上,但二者概念上不是一個東西。 因爲指令的源與目標都是隱含的,零地址指令的「密度」能夠很是高——能夠用更少空間放下更多條指令。所以在空間緊缺的環境中,零地址指令是種可取的設計。但零地址指令要完成一件事情,通常會比二地址或者三地址指令許多更多條指令。上面Java字節碼作的加法,若是用x86指令兩條就能完成了:
mov eax, 1 add eax, 2(好吧我犯規了,istore_0對應的保存我沒寫。但假如局部變量比較少的話也沒必要把EAX的值保存(「溢出」,register spilling)到調用棧上,就這樣吧 =_= 其實就算把結果保存到棧上也就是多一條指令而已……) 一些比較老的解釋器,例如CRuby在1.9引入YARV做爲新的VM以前的解釋器,還有SquirrleFish以前的老JavaScriptCore,它們內部是樹遍歷式解釋器;解釋器遞歸遍歷樹,樹的每一個節點的操做依賴於解釋其各個子節點返回的值。這種解釋器裏沒有所謂的求值棧,也沒有所謂的虛擬寄存器,因此不適合以「基於棧」或「基於寄存器」去描述。 而像V8那樣直接編譯JavaScript生成機器碼,而不經過中間的字節碼的中間表示的JavaScript引擎,它內部有虛擬寄存器的概念,但那只是普通native編譯器的正常組成部分。我以爲也不該該用「基於棧」或「基於寄存器」去描述它。 V8在內部也用了「求值棧」(在V8裏具體叫「表達式棧」)的概念來簡化生成代碼的過程,使用所謂「虛擬棧幀」來記錄局部變量與求值棧的狀態;但在真正生成代碼的時候會作窺孔優化,消除冗餘的push/pop,將許多對求值棧的操做轉變爲對寄存器的操做,以此提升代碼質量。因而最終生成出來的代碼看起來就不像是基於棧的代碼了。 關於JavaScript引擎的實現方式,下文會再提到。 四、基於棧與基於寄存器架構的VM,用哪一個好? 若是是要模擬現有的處理器,那沒什麼可選的,本來處理器採用了什麼架構就只能以它爲源。但HLL VM的架構一般能夠自由構造,有很大的選擇餘地。爲何許多主流HLL VM,諸如JVM、CLI、CPython、CRuby 1.9等,都採用了基於棧的架構呢?我以爲這有三個主要緣由: ·實現簡單 因爲指令中沒必要顯式指定源與目標,VM能夠設計得很簡單,沒必要考慮爲臨時變量分配空間的問題,求值過程當中的臨時數據存儲都讓求值棧包辦就行。 更新:回帖中cscript指出了這句不太準確,應該是針對基於棧架構的指令集生成代碼的編譯器更容易實現,而不是VM更容易實現。 ·該VM是爲某類資源很是匱乏的硬件而設計的 這類硬件的存儲器可能很小,每一字節的資源都要節省。零地址指令比其它形式的指令更緊湊,因此是個天然的選擇。 ·考慮到可移植性 處理器的特性各個不一樣:典型的CISC處理器的通用寄存器數量不多,例如32位的x86就只有8個32位通用寄存器(若是不算EBP和ESP那就是6個,如今通常都算上);典型的RISC處理器的各類寄存器數量多一些,例如ARM有16個32位通用寄存器,Sun的SPARC在一個寄存器窗口裏則有24個通用寄存器(8 in,8 local,8 out)。 假如一個VM採用基於寄存器的架構(它接受的指令集大概就是二地址或者三地址形式的),爲了高效執行,通常會但願能把源架構中的寄存器映射到實際機器上寄存器上。可是VM裏有些很重要的輔助數據會常常被訪問,例如一些VM會保存源指令序列的程序計數器(program counter,PC),爲了效率,這些數據也得放在實際機器的寄存器裏。若是源架構中寄存器的數量跟實際機器的同樣,或者前者比後者更多,那源架構的寄存器就沒辦法都映射到實際機器的寄存器上;這樣VM實現起來比較麻煩,與可以所有映射相比效率也會大打折扣。 若是一個VM採用基於棧的架構,則不管在怎樣的實際機器上,都很好實現——它的源架構裏沒有任何通用寄存器,因此實現VM時能夠比較自由的分配實際機器的寄存器。因而這樣的VM可移植性就比較高。做爲優化,基於棧的VM能夠用編譯方式實現,「求值棧」實際上也能夠由編譯器映射到寄存器上,減輕數據移動的開銷。 回到主題,基於棧與基於寄存器的架構,誰更快?看看如今的實際處理器,大多都是基於寄存器的架構,從側面反映出它比基於棧的架構更優秀。 而對於VM來講,源架構的求值棧或者寄存器均可能是用實際機器的內存來模擬的,因此性能特性與實際硬件又有點不一樣。通常認爲基於寄存器的架構對VM來講也是更快的,緣由是:雖然零地址指令更緊湊,但完成操做須要更多的load/store指令,也意味着更多的指令分派(instruction dispatch)次數與內存訪問次數;訪問內存是執行速度的一個重要瓶頸,二地址或三地址指令雖然每條指令佔的空間較多,但整體來講能夠用更少的指令完成操做,指令分派與內存訪問次數都較少。 這方面有篇被引用得不少的論文講得比較清楚,Virtual Machine Showdown: Stack Versus Registers,是在VEE 2005發表的。VEE是Virtual Execution Environment的縮寫,是ACM下SIGPLAN組織的一個會議,專門研討虛擬機的設計與實現的。能夠去找找這個會議往年的論文,不少都值得讀。 五、樹遍歷解釋器圖解 在演示基於棧與基於寄存器的VM的例子前,先回頭看看更原始的解釋器形式。 前面提到解析器的時候用了i = a + b * c的例子,如今讓咱們來看看由解析器生成的AST要是交給一個樹遍歷解釋器,會如何被解釋執行呢? 用文字說不夠形象,仍是看圖吧: 這是對AST的後序遍歷:假設有一個eval(Node n)函數,用於解釋AST上的每一個節點;在解釋一個節點時若是依賴於子樹的操做,則對子節點遞歸調用eval(Node n),從這些遞歸調用的返回值獲取須要的值(或反作用)——也就是說子節點都eval好了以後,父節點才能進行本身的eval——典型的後序遍歷。 (話說,上圖中節點左下角有藍色標記的說明那是節點的「內在屬性」。從屬性語法的角度看,若是一個節點的某個屬性的值只依賴於自身或子節點,則該屬性被稱爲「綜合屬性」(synthesized attribute);若是一個節點的某個屬性只依賴於自身、父節點和兄弟節點,則該屬性被稱爲「繼承屬性」(inherited attribute)。上圖中節點右下角的紅色標記都只依賴子節點來計算,顯然是綜合屬性。) SquirrelFish以前的JavaScriptCore、CRuby 1.9以前的CRuby就都是採用這種方式來解釋執行的。 可能須要說明的: ·左值與右值 在源代碼i = a + b * c中,賦值符號左側的i是一個標識符,表示一個變量,取的是變量的「左值」(也就是與變量i綁定的存儲單元);右側的a、b、c雖然也是變量,但取的是它們的右值(也就是與變量綁定的存儲單元內的值)。在許多編程語言中,左值與右值在語法上沒有區別,它們實質的差別容易被忽視。通常來講左值能夠做爲右值使用,反之則不必定。例如數字1,它自身有值就是1,能夠做爲右值使用;但它沒有與可賦值的存儲單元相綁定,因此沒法做爲左值使用。 左值不必定只是簡單的變量,還能夠是數組元素或者結構體的域之類,可能由複雜的表達式所描述。所以左值也是須要計算的。 ·優先級、結合性與求值順序 這三個是不一樣的概念,卻常常被混淆。經過AST來看就很容易理解:(假設源碼是從左到右輸入的) 所謂優先級,就是不一樣操做相鄰出現時,AST節點與根的距離的關係。優先級高的操做會更遠離根,優先級低的操做會更接近根。爲何?由於整棵AST是之後序遍歷求值的,顯然節點離根越遠就越早被求值。 所謂結合性,就是當同類操做相鄰出現時,操做的前後順序同AST節點與根的距離的關係。若是是左結合,則先出現的操做對應的AST節點比後出現的操做的節點離根更遠;換句話說,先出現的節點會是後出現節點的子節點。 所謂求值順序,就是在遍歷子節點時的順序。對二元運算對應的節點來講,先遍歷左子節點再遍歷右子節點就是左結合,反之則是右結合。 這三個概念與運算的聯繫都很緊密,但實際描述的是不一樣的關係。前二者是解析器根據語法生成AST時就已經決定好的,後者則是解釋執行或者生成代碼而去遍歷AST時決定的。 在沒有反作用的環境中,給定優先級與結合性,則不管求值順序是怎樣的都能獲得一樣的結果;而在有反作用的環境中,求值順序會影響結果。 賦值運算雖然是右結合的,但仍然能夠用從左到右的求值順序;事實上Java、C#等許多語言都在規範裏寫明表達式的求值順序是從左到右的。上面的例子中就先遍歷的=的左側,求得i的左值;再遍歷=的右側,獲得表達式的值23;最後執行=自身,完成對i的賦值。 因此若是你要問:賦值在相似C的語言裏明明是右結合的運算,爲何你先遍歷左子樹再遍歷右子樹?上面的說明應該能讓你發現你把結合性與求值順序混爲一談了。 看看Java從左到右求值順序的例子:
public class EvalOrderDemo { public static void main(String[] args) { int[] arr = new int[1]; int a = 1; int b = 2; arr[0] = a + b; } }由javac編譯,獲得arr[0] = a + b對應的字節碼是:
// 左子樹:數組下標 // a[0] aload_1 iconst_0 // 右子樹:加法 // a iload_2 // b iload_3 // + iadd // 根節點:賦值 iastore六、從樹遍歷解釋器進化爲基於棧的字節碼解釋器的前端 若是你看到樹形結構與後序遍歷,而且知道後綴記法(或者逆波蘭記法,reverse Polish notation)的話,那敏銳的你或許已經察覺了:要解釋執行AST,能夠先經過後序遍歷AST生成對應的後綴記法的操做序列,而後再解釋執行該操做序列。這樣就把樹形結構壓扁,成爲了線性結構。 樹遍歷解釋器對AST的求值其實隱式依賴於調用棧:eval(Node n)的遞歸調用關係是靠調用棧來維護的。後綴表達式的求值則一般顯式依賴於一個棧,在遇到操做數時將其壓入棧中,遇到運算時將合適數量的值從棧頂彈出進行運算,再將結果壓回到棧上。這種描述看起來眼熟麼?沒錯,後綴記法的求值中的核心數據結構就是前文提到過的「求值棧」(或者叫操做數棧,如今應該更好理解了)。後綴記法也就與基於棧的架構聯繫了起來:後者能夠很方便的執行前者。同理,零地址指令也與樹形結構聯繫了起來:能夠經過一個棧方便的把零地址指令序列再轉換回到樹的形式。 Java字節碼與Java源碼聯繫緊密,前者能夠當作後者的後綴記法。若是想在JVM上開發一種語義能直接映射到Java上的語言,那麼編譯器很好寫:祕訣就是後序遍歷AST。 那麼讓咱們再來看看,一樣是i = a + b * c這段源碼對應的AST,生成Java字節碼的例子: (假設a、b、c、i分別被分配到局部變量區的slot 0到slot 3) 能看出Java字節碼與源碼間的對應關係了麼? 一個Java編譯器的輸入是Java源代碼,輸出是含有Java字節碼的.class文件。它裏面主要包含掃描器與解析器,語義分析器(包括類型檢查器/類型推導器等),代碼生成器等幾大部分。上圖所展現的就是代碼生成器的工做。對Java編譯器來講,代碼生成就到字節碼的層次就結束了;而對native編譯器來講,這裏剛到生成中間表示的部分,接下去是優化與最終的代碼生成。 若是你對Python、CRuby 1.9之類有所瞭解,會發現它們的字節碼跟Java字節碼在「基於棧」的這一特徵上很是類似。其實它們都是由「編譯器+VM」構成的,概念上就像是Java編譯器與JVM融爲一體通常。 從這點看,Java與Python和Ruby能夠說是一條船上的。雖然說內部具體實現的顯著差別使得先進的JVM比簡單的JVM快不少,而JVM又廣泛比Python和Ruby快不少。 當解釋器中用於解釋執行的中間代碼是樹形時,其中能被稱爲「編譯器」的部分基本上就是解析器;中間代碼是線性形式(如字節碼)時,其中能被稱爲編譯器的部分就包括上述的代碼生成器部分,更接近於所謂「完整的編譯器」;若是虛擬機是基於寄存器架構的,那麼編譯器裏至少還得有虛擬寄存器分配器,又更接近「完整的編譯器」了。 七、基於棧與基於寄存器架構的VM的一組圖解 要是拿兩個分別實現了基於棧與基於寄存器架構、但沒有直接聯繫的VM來對比,效果或許不會太好。如今恰巧有二者有緊密聯繫的例子——JVM與Dalvik VM。JVM的字節碼主要是零地址形式的,概念上說JVM是基於棧的架構。Google Android平臺上的應用程序的主要開發語言是Java,經過其中的Dalvik VM來運行Java程序。爲了能正確實現語義,Dalvik VM的許多設計都考慮到與JVM的兼容性;但它卻採用了基於寄存器的架構,其字節碼主要是二地址/三地址混合形式的,乍一看可能讓人納悶。考慮到Android有明確的目標:面向移動設備,特別是最初要對ARM提供良好的支持。ARM9有16個32位通用寄存器,Dalvik VM的架構也經常使用16個虛擬寄存器(同樣多……沒辦法把虛擬寄存器所有直接映射到硬件寄存器上了);這樣Dalvik VM就不用太顧慮可移植性的問題,優先考慮在ARM9上以高效的方式實現,發揮基於寄存器架構的優點。 Dalvik VM的主要設計者Dan Bornstein在Google I/O 2008上作過一個關於Dalvik內部實現的演講;同一演講也在Google Developer Day 2008 China和Japan等會議上重複過。這個演講中Dan特別提到了Dalvik VM與JVM在字節碼設計上的區別,指出Dalvik VM的字節碼能夠用更少指令條數、更少內存訪問次數來完成操做。(看不到YouTube的請自行想辦法) 眼見爲實。要本身動手感覺一下該例子,請先確保已經正確安裝JDK 6,並從官網獲取Android SDK 1.6R1。連不上官網的也請本身想辦法。 建立Demo.java文件,內容爲:
public class Demo { public static void foo() { int a = 1; int b = 2; int c = (a + b) * 5; } }經過javac編譯,獲得Demo.class。經過javap能夠看到foo()方法的字節碼是:
0: iconst_1 1: istore_0 2: iconst_2 3: istore_1 4: iload_0 5: iload_1 6: iadd 7: iconst_5 8: imul 9: istore_2 10: return接着用Android SDK裏platforms\android-1.6\tools目錄中的dx工具將Demo.class轉換爲dex格式。轉換時能夠直接以文本形式dump出dex文件的內容。使用下面的命令:
dx --dex --verbose --dump-to=Demo.dex.txt --dump-method=Demo.foo --verbose-dump Demo.class能夠看到foo()方法的字節碼是:
0000: const/4 v0, #int 1 // #1 0001: const/4 v1, #int 2 // #2 0002: add-int/2addr v0, v1 0003: mul-int/lit8 v0, v0, #int 5 // #05 0005: return-void(本來的輸出裏還有些code-address、local-snapshot等,那些不是字節碼的部分,能夠忽略。) 讓咱們看看兩個版本在概念上是如何工做的。 JVM: (圖中數字均以十六進制表示。其中字節碼的一列表示的是字節碼指令的實際數值,後面跟着的助記符則是其對應的文字形式。標記爲紅色的值是相對上一條指令的執行狀態有所更新的值。下同) 說明:Java字節碼以1字節爲單元。上面代碼中有11條指令,每條都只佔1單元,共11單元==11字節。 程序計數器是用於記錄程序當前執行的位置用的。對Java程序來講,每一個線程都有本身的PC。PC以字節爲單位記錄當前運行位置裏方法開頭的偏移量。 每一個線程都有一個Java棧,用於記錄Java方法調用的「活動記錄」(activation record)。Java棧以幀(frame)爲單位線程的運行狀態,每調用一個方法就會分配一個新的棧幀壓入Java棧上,每從一個方法返回則彈出並撤銷相應的棧幀。 每一個棧幀包括局部變量區、求值棧(JVM規範中將其稱爲「操做數棧」)和其它一些信息。局部變量區用於存儲方法的參數與局部變量,其中參數按源碼中從左到右順序保存在局部變量區開頭的幾個slot。求值棧用於保存求值的中間結果和調用別的方法的參數等。二者都以字長(32位的字)爲單位,每一個slot能夠保存byte、short、char、int、float、reference和returnAddress等長度小於或等於32位的類型的數據;相鄰兩項可用於保存long和double類型的數據。每一個方法所須要的局部變量區與求值棧大小都可以在編譯時肯定,而且記錄在.class文件裏。 在上面的例子中,Demo.foo()方法所須要的局部變量區大小爲3個slot,須要的求值棧大小爲2個slot。Java源碼的a、b、c分別被分配到局部變量區的slot 0、slot 1和slot 2。能夠觀察到Java字節碼是如何指示JVM將數據壓入或彈出棧,以及數據是如何在棧與局部變量區以前流動的;能夠看到數據移動的次數特別多。動畫裏可能不太明顯,iadd和imul指令都是要從求值棧彈出兩個值運算,再把結果壓回到棧上的;光這樣一條指令就有3次概念上的數據移動了。 對了,想提醒一下:Java的局部變量區並不須要把某個局部變量固定分配在某個slot裏;不只如此,在一個方法內某個slot甚至可能保存不一樣類型的數據。如何分配slot是編譯器的自由。從類型安全的角度看,只要對某個slot的一次load的類型與最近一次對它的store的類型匹配,JVM的字節碼校驗器就不會抱怨。之後再找時間寫寫這方面。 Dalvik VM: |