虛擬機隨談:解釋器,樹遍歷解釋器,基於棧與基於寄存器,大雜燴

  [b] 虛擬機隨談:解釋器,樹遍歷解釋器,基於棧與基於寄存器,大雜燴 [/b][b]收藏 [/b]
  大前天收到一條PM: 你好,很冒昧的向你發短消 息,我現在在看JS引擎,能過看博客發現你對js engine很瞭解,我想請教一下你 基於棧的解析器與基於寄存器的解析器有什麼同,javascriptcore是基於寄存器的,V8是基於棧的,能不能說一下這兩者有什麼一樣嗎?能推薦一 點資料嗎?謝謝。
  我剛收到的時候很興奮,就開始寫回復。寫啊寫發覺已經比我平時發的帖還要長了,想着乾脆把回覆直接發出來好了。於是下面就是回覆:
  你好 ^ ^ 很抱歉拖了這麼久纔回復。碼字和畫圖太耗時間了。
  別說冒昧了,我只是個普通的剛畢業的學生而已,擔當不起啊 =_=||||
  而且我也不敢說"很"瞭解,只是有所接觸而已。很高興有人來一起討論JavaScript引擎的設計與實現,總覺得身邊對這個有興趣的人不多,或者是很少冒出來討論。如果你發個帖或者blog來討論這方面的內容我也會很感興趣的~
  想拿出幾點來討論一下。上面提出的問題我希望能夠一一給予回答,不過首先得做些鋪墊。
  另外先提一點:JavaScriptCore從SquirrelFish版開始是"基於寄存器"的,V8則不適合用"基於棧"或者"基於寄存器"的說法來描述。
  1、解析器與解釋器
  解析器是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 )混爲一談真是詭異。算了,我還是不要淌這渾水的好……打住。
  2、"解釋器"到底是什麼?"解釋型語言"呢?
  很多資料會說,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的文件,用下列命令: Command prompt代碼type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=java%20-jar%20asc.jar%20-impo rt%20builtin.abc%20-import%20toplevel.abc%20test.as " quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  就是用ASC將test.as編譯,得到test.abc。接着用: Command prompt代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=avmplus%20test.abc" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  就是用AVM2去執行程序了。很生動的體現出"編譯器+虛擬機"的實現方式。
  這個"裸"的AVM2沒有帶Flash或Flex的類庫,能用的函數和類都有限。不過AS3語言實現是完整的。可以用print()函數來向標準輸出流寫東西。
  Well……其實寫Java程序不也是這樣麼?現在也確實還有很多人把Java稱爲"解釋型語言",完全無視Java代碼通常是經過顯式編譯步驟纔得到.class文件,而有些JVM是採用純JIT編譯方式實現的,內部沒解釋器,例如Jikes RVM 。我愈發感到"解釋型語言"是個應該避開的用語 =_=
  關於虛擬機,有本很好的書絕對值得一讀,《虛擬機--系統與進程的通用平臺》(Virtual Machines: Versatile Platforms for Systems and Processes)。國內有影印版也有中文版,我是讀了影印版,不太清楚中文版的翻譯質量如何。據說翻譯得還行,我無法印證。
  3、基於棧與基於寄存器的指令集架構
  用C的語法來寫這麼一個語句: C代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=a%20%3D%20b%20%2B%20c%3B" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  a = b + c;
  如果把它變成這種形式:
  add a, b, c
  那看起來就更像機器指令了,對吧?這種就是所謂"三地址指令"(3-address instruction),一般形式爲:
  op dest, src1, src2
  許多操作都是二元運算+賦值。三地址指令正好可以指定兩個源和一個目標,能非常靈活的支持二元操作與賦值的組合。ARM處理器的主要指令集就是三地址形式的。
  C裏要是這樣寫的話: C代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=a%20%2B%3D%20b%3B" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  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來寫就類似: C代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=acc%20%2B%3D%205%3B%0Aacc%20- %3D%203%3B" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  只不過acc是"隱藏"的目標。基於累加器的架構近來比較少見了,在很老的機器上繁榮過一段時間。
  那"n地址"的n如果是0的話呢?
  看這樣一段Java字節碼: Java bytecode代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=iconst_1%0Aiconst_2%0Aiadd%0A istore_0" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  注意那個iadd(表示整型加法)指令並沒有任何參數。連源都無法指定了,零地址指令有什麼用??
  零地址意味着源與目標都是隱含參數,其實現依賴於一種常見的數據結構--沒錯,就是棧。上面的iconst_1、iconst_2兩條指令,分別 向一個叫做"求值棧"(evaluation stack,也叫做operand stack"操作數棧"或者expression stack"表達式棧")的地方壓入整型常量1、2。iadd指令則從求值棧頂彈出2個值,將值相加,然後把結果壓回到棧頂。istore_0指令從求值 棧頂彈出一個值,並將值保存到局部變量區的第一個位置(slot 0)。
  零地址形式的指令集一般就是通過"基於棧的架構"來實現的。請一定要注意,這個棧是指"求值棧",而不是與系統調用棧(system call stack,或者就叫system stack)。千萬別弄混了。有些虛擬機把求值棧實現在系統調用棧上,但兩者概念上不是一個東西。
  由於指令的源與目標都是隱含的,零地址指令的"密度"可以非常高--可以用更少空間放下更多條指令。因此在空間緊缺的環境中,零地址指令是種可取 的設計。但零地址指令要完成一件事情,一般會比二地址或者三地址指令許多更多條指令。上面Java字節碼做的加法,如果用x86指令兩條就能完成了: X86 asm代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=mov%20%20eax%2C%201%0Aadd%20% 20eax%2C%202" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  (好吧我犯規了,istore_0對應的保存我沒寫。但假如局部變量比較少的話也不必把EAX的值保存到調用棧上,就這樣吧 =_=
  其實就算把結果保存到棧上也就是多一條指令而已……)
  一些比較老的解釋器,例如CRuby 在1.9引入YARV 作 爲新的VM之前的解釋器,還有SquirrleFish之前的老JavaScriptCore,它們內部是樹遍歷式解釋器;解釋器遞歸遍歷樹,樹的每個節 點的操作依賴於解釋其各個子節點返回的值。這種解釋器裏沒有所謂的求值棧,也沒有所謂的虛擬寄存器,所以不適合以"基於棧"或"基於寄存器"去描述。
  而像V8那樣直接編譯JavaScript生成機器碼,而不通過中間的字節碼的中間表示的JavaScript引擎,它內部有虛擬寄存器的概念,但那只是普通native編譯器的正常組成部分。我覺得也不應該用"基於棧"或"基於寄存器"去描述它。
  V8在內部也用了"求值棧"(在V8裏具體叫"表達式棧")的概念來簡化生成代碼的過程,使用所謂"虛擬棧幀"來記錄局部變量與求值棧的狀態;但 在真正生成代碼的時候會做窺孔優化,消除冗餘的push/pop,將許多對求值棧的操作轉變爲對寄存器的操作,以此提高代碼質量。於是最終生成出來的代碼 看起來就不像是基於棧的代碼了。
  關於JavaScript引擎的實現方式,下文會再提到。
  4、基於棧與基於寄存器架構的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組織的一個會議,專門研討虛擬機的設計與實現的。可以去找找這個會議往年的論文,很多都值得讀。
  5、樹遍歷解釋器圖解
  在演示基於棧與基於寄存器的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從左到右求值順序的例子: Java代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=public%20class%20EvalOrderDem o%20%7B%0A%20%20%20%20public%20static%20void%20main (String%5B%5D%20args)%20%7B%0A%20%20%20%20%20%20%20 %20int%5B%5D%20arr%20%3D%20new%20int%5B1%5D%3B%0A%2 0%20%20%20%20%20%20%20int%20a%20%3D%201%3B%0A%20%20 %20%20%20%20%20%20int%20b%20%3D%202%3B%0A%20%20%20% 20%20%20%20%20arr%5B0%5D%20%3D%20a%20%2B%20b%3B%0A% 20%20%20%20%7D%0A%7D" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  由javac編譯,得到arr[0] = a + b對應的字節碼是: Java bytecode代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=%2F%2F%20%E5%B7%A6%E5%AD%90%E 6%A0%91%EF%BC%9A%E6%95%B0%E7%BB%84%E4%B8%8B%E6%A0%8 7%0A%2F%2F%20a%5B0%5D%0Aaload_1%0Aiconst_0%0A%0A%2F %2F%20%E5%8F%B3%E5%AD%90%E6%A0%91%EF%BC%9A%E5%8A%A0 %E6%B3%95%0A%2F%2F%20a%0Aiload_2%0A%2F%2F%20b%0Ailo ad_3%0A%2F%2F%20%2B%0Aiadd%0A%0A%2F%2F%20%E6%A0%B9% E8%8A%82%E7%82%B9%EF%BC%9A%E8%B5%8B%E5%80%BC%0Aiast ore" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  6、從樹遍歷解釋器進化爲基於棧的字節碼解釋器的前端
  如果你看到樹形結構與後序遍歷,並且知道後綴記法(或者逆波蘭記法,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快很多。
  當解釋器中用於解釋執行的中間代碼是樹形時,其中能被稱爲"編譯器"的部分基本上就是解析器;中間代碼是線性形式(如字節碼)時,其中能被稱爲編 譯器的部分就包括上述的代碼生成器部分,更接近於所謂"完整的編譯器";如果虛擬機是基於寄存器架構的,那麼編譯器裏至少還得有虛擬寄存器分配器,又更接近"完整的編譯器"了。
  7、基於棧與基於寄存器架構的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文件,內容爲: Java代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=public%20class%20Demo%20%7B%0 A%20%20%20%20public%20static%20void%20foo()%20%7B%0 A%20%20%20%20%20%20%20%20int%20a%20%3D%201%3B%0A%20 %20%20%20%20%20%20%20int%20b%20%3D%202%3B%0A%20%20% 20%20%20%20%20%20int%20c%20%3D%20(a%20%2B%20b)%20*% 205%3B%0A%20%20%20%20%7D%0A%7D" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  通過javac編譯,得到Demo.class。通過javap可以看到foo()方法的字節碼是: Java bytecode代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=0%3A%20%20iconst_1%0A1%3A%20% 20istore_0%0A2%3A%20%20iconst_2%0A3%3A%20%20istore_ 1%0A4%3A%20%20iload_0%0A5%3A%20%20iload_1%0A6%3A%20 %20iadd%0A7%3A%20%20iconst_5%0A8%3A%20%20imul%0A9%3 A%20%20istore_2%0A10%3A%20return" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  接着用Android SDK裏platforms\android-1.6\tools目錄中的dx工具將Demo.class轉換爲dex格式。轉換時可以直接以文本形式dump出dex文件的內容。使用下面的命令: Command prompt代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=dx%20--dex%20--verbose%20--du mp-to%3DDemo.dex.txt%20--dump-method%3DDemo.foo%20- -verbose-dump%20Demo.class" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  可以看到foo()方法的字節碼是: Dalvik bytecode代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=0000%3A%20const%2F4%20%20%20% 20%20%20%20v0%2C%20%23int%201%20%2F%2F%20%231%0A000 1%3A%20const%2F4%20%20%20%20%20%20%20v1%2C%20%23int %202%20%2F%2F%20%232%0A0002%3A%20add-int%2F2addr%20 v0%2C%20v1%0A0003%3A%20mul-int%2Flit8%20%20v0%2C%20 v0%2C%20%23int%205%20%2F%2F%20%2305%0A0005%3A%20ret urn-void" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  (原本的輸出裏還有些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:
  
  說明:Dalvik字節碼以16位爲單元(或許叫"雙字節碼"更準確 =_=|||)。上面代碼中有5條指令,其中mul-int/lit8指令佔2單元,其餘每條都只佔1單元,共6單元==12字節。
  與JVM相似,在Dalvik VM中每個線程都有自己的PC和調用棧,方法調用的活動記錄以幀爲單位保存在調用棧上。PC記錄的是以16位爲單位的偏移量而不是以字節爲單位的。
  與JVM不同的是,Dalvik VM的棧幀中沒有局部變量區與求值棧,取而代之的是一組虛擬寄存器。每個方法被調用時都會得到自己的一組虛擬寄存器。常用v0-v15這16個,也有少數 指令可以訪問v0-v255範圍內的256個虛擬寄存器。與JVM相同的是,每個方法所需要的虛擬寄存器個數都能夠在編譯時確定,並且記錄在.dex文件 裏;每個寄存器都是字長(32位),相鄰的一對寄存器可用於保存64位數據。方法的參數按源碼中從左到右的順序保存在末尾的幾個虛擬寄存器裏。
  與JVM版相比,可以發現Dalvik版程序的指令數明顯減少了,數據移動次數也明顯減少了,用於保存臨時結果的存儲單元也減少了。
  你可能會抱怨:上面兩個版本的代碼明明不對應:JVM版到return前完好持有a、b、c三個變量的值;而Dalvik版到return-void前只持有b與c的值(分別位於v0與v1),a的值被刷掉了。
  但注意到a與b的特徵:它們都只在聲明時接受過一次賦值,賦值的源是常量。這樣就可以對它們應用常量傳播 ,將 Java代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=int%20c%20%3D%20(a%20%2B%20b) %20*%205%3B" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  替換爲 Java代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=int%20c%20%3D%20(1%20%2B%202) %20*%205%3B" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  然後可以再對c的初始化表達式應用常量摺疊,進一步替換爲: Java代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=int%20c%20%3D%2015%3B" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  把變量的每次狀態更新(包括初始賦值在內)稱爲變量的一次"定義"(definition),把每次訪問變量(從變量讀取值)稱爲變量的一次"使用"(use),則可以把代碼整理爲"使用-定義鏈"(簡稱UD鏈,use-define chain )。顯然,一個變量的某次定義要被使用過纔有意義。上面的例子經過常量傳播與摺疊後,我們可以分析得知變量a、b、c都只被定義而沒有被使用。於是它們的定義就成爲了無用代碼(dead code),可以安全的被消除。
  上面一段的分析用一句話描述就是:由於foo()裏沒有產生外部可見的副作用,所以foo()的整個方法體都可以被優化爲空。經過dx工具處理後,Dalvik版程序相對JVM版確實是稍微優化了一些,不過沒有影響程序的語義,程序的正確性是沒問題的。這是其一。
  其二是Dalvik版代碼只要多分配一個虛擬寄存器就能在return-void前同時持有a、b、c三個變量的值,指令幾乎沒有變化: Dalvik bytecode代碼 type="application/x-shockwave-flash" width="14" height="15" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" src="http://www.javaeye.com/javascripts/syntaxhigh lighter/clipboard_new.swf" flashvars="clipboard=0000%3A%20const%2F4%20%20%20% 20%20%20v0%2C%20%23int%201%20%2F%2F%20%231%0A0001%3 A%20const%2F4%20%20%20%20%20%20v1%2C%20%23int%202%2 0%2F%2F%20%232%0A0002%3A%20add-int%20%20%20%20%20%2 0v2%2C%20v0%2C%20v1%0A0004%3A%20mul-int%2Flit8%20v2 %2C%20v2%2C%20%23int%205%20%2F%2F%20%2305%0A0006%3A %20return-void" quality="high" allowscriptaccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflash player" width="14" height="15">
  這樣比原先的版本多使用了一個虛擬寄存器,指令方面也多用了一個單元(add-int指令佔2單元);但指令的條數沒變,仍然是5條,數據移動的次數也沒變。
  題外話1:Dalvik VM是基於寄存器的,x86也是基於寄存器的,但兩者的"寄存器"卻相當不同:前者的寄存器是每個方法被調用時都有自己一組私有的,後者的寄存器則是全局 的。也就是說,Dalvik VM字節碼中不用擔心保護寄存器的問題,某個方法在調用了別的方法返回過來後自己的寄存器的值肯定跟調用前一樣。而x86程序在調用函數時要考慮清楚calling convention ,調用方在調用前要不要保護某些寄存器的當前狀態,還是說被調用方會處理好這些問題,麻煩事不少。Dalvik VM這種虛擬寄存器讓人想起一些實際處理器的"寄存器窗口",例如SPARC的Register Windows 也是保證每個函數都覺得自己有"私有的一組寄存器",減輕了在代碼裏處理寄存器保護的麻煩--扔給硬件和操作系統解決了。
  題外話2:Dalvik的.dex文件在未壓縮狀態下的體積通常比同等內容的.jar文件在deflate壓縮後還要小。但光從字節碼 看,Java字節碼幾乎總是比Dalvik的小,那.dex文件的體積是從哪裏來減出來的呢?這主要得益與.dex文件對常量池的壓縮,一個.dex文件 中所有類都共享常量池,使得相同的字符串、相同的數字常量等都只出現一次,自然能大大減小體積。相比之下,.jar文件中每個類都持有自己的常量池,諸如"Ljava/lang/Object;"這種常見的字符串會被重複多次。Sun自己也有進一步壓縮JAR的工具,Pack200,對應的標準是JSR 200 。它的主要應用場景是作爲JAR的網絡傳輸格式,以更高的壓縮比來減少文件傳輸時間。在官方文檔 提到了Pack200所用到的壓縮技巧, Pack200 works most efficiently on Java class files. It uses several techniques to efficiently reduce the size of JAR files:
  It merges and sorts the constant-pool data in the class files and co-locates them in the archive.
  It removes redundant class attributes.
  It stores internal data structures.
  It use delta and variable length encoding.
  It chooses optimum coding types for secondary compression.
  可見.dex文件與Pack200採用了一些相似的減小體積的方法。很可惜目前還沒有正式發佈的JVM支持直接加載Pack200格式的歸檔,畢竟網絡傳輸纔是Pack200最初構想的應用場景。
  再次提醒注意,上面的描述是針對概念上的JVM與Dalvik VM,而不是針對它們的具體實現 。實現VM時可以採用許多優化技巧去減少性能損失,使得實際的運行方式與概念中的不完全相符,只要最終的運行結果滿足原本概念上的VM所實現的語義就行。
  ================================================== =========================
  上面"簡單"的提了些討論點,不過還沒具體到JavaScript引擎,抱歉。弄得太長了,只好在這裏先拆分一次……有些東西想寫的,洗個澡又忘記了。等想起來再補充 orz
  "簡單"是相對於實際應該掌握的信息量而言。上面寫的都還沒撓上癢癢,心虛。
  Anyway。根據拆分的現狀,下一篇應該是討論動態語言與編譯的問題,然後再下一篇會看看解釋器的演化方法,再接着會看看JavaScript引擎的狀況(主要針對V8和Nitro,也會談談Tamarin。就不討論JScript了)。
  關於推薦資料,在"我的收藏"的virtual machine標籤 裏就有不少值得一讀的資料。如果只是對JavaScript引擎相關感興趣的話也可以選着讀些。我的收藏裏還有v8和tamarin等標籤的,資料有的是 ^ ^
  能有耐心讀到結尾的同學們,歡迎提出意見和建議,以及指出文中的錯漏 ^_^
  不像抓到蟲就給美分的大師,我沒那種信心……錯漏難免,我也需要進一步學習。拜託大家了~
  P.S. 畫圖真的很辛苦,加上JavaEye的帶寬也不是無限的……所以拜託不要直接鏈接這帖裏的圖
  有需要原始圖片的可以跟我聯繫。我是畫成多幀PNG然後轉換爲GIF發出來的。上面的PNG圖片都還保留有原始的圖層信息,要拿去再編輯也很方便 ^ ^
  更新1:
  原本在樹遍歷解釋器圖解的小節中,我用的是這幅圖:
  
  其實上圖畫得不準確,a、b、c的右值不應該畫在節點上的;節點應該只保存了它們的左值纔對,要獲取對應的右值就要查詢變量表。我修改了圖更新到正文了。原本的圖裏對i的賦值看起來很奇怪,就像是遍歷過程經過了兩次i節點一般,而事實不是那樣的。
  轉自: http://www.javaeye.com/topic/492667
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章