原文鏈接:http://weblogs.java.net/blog/2006/11/30/cvm-stacks-and-code-execution
CVM棧和代碼的執行
歡迎來繼續討論phoneME Advanced VM(CVM)的內部結構。如果你錯誤過了我們之前的討論,就先從一個高度來回顧CVM架構圖中主要的VM的數據結構。現在,將會深入Java代碼的執行,以揭示運行時Java方法構成的棧的結構。這個棧記錄了Java線程運行時的方法的信息,而不是包含了Java虛擬機的運行環境的棧,也不是API層所謂的棧。這一節會揭示CVM中Java代碼執行控制流的細節(也就是某個時刻,哪個方法正在佔用CPU時間)。
所有的源代碼可以參考phoneME Advanced工程中的src/share/javavm/include或src/share/javavm/runtime兩個目錄。Include目錄中包含頭文件,runtime目錄中包含C源文件。
執行引擎
在CVM中,除了一個解釋器之外,還有一個動態適配編譯器(即爲人們熟悉的JIT)。理論上,解釋器只是一個很大的switch語句,其中每個case語句對應執行一個字節碼(見executejava_standard.c中的CVMgcUnsafeExecuteJavaMethod())。解釋器不斷循環執行這個switch語句,直到字節碼執行結束。對執行頻率很高的方法(常被稱爲hot),JIT就會將這些方法編譯爲本地機器碼。執行編譯後的方法的代碼代替瞭解釋字節碼。
測量一個方法執行頻度的辦法有很多。CLDC VM(phoneME Feature)使用一個基於取樣機制的計時器。在撰寫本文之前,CVM在解釋時保存方法的調用計數。當達到一個閾值時,就將該方法編譯爲本地機器碼。那麼如何從解釋字節碼轉爲執行編譯後的方法?爲理解這一點(以及所有Java代碼執行與其它代碼執行的細微差別),必須看一下Java代碼執行時,運行時棧都做了些什麼。
運行時棧
如前所述,CVM中的每個Java線程有兩個棧:一個本地棧和一個Java棧。Java棧一般也被稱爲解釋器棧。CVM中的每個線程有一個指向CVMExecEnv記錄的指針,一般把它稱爲ee。在得到ee之後,就可以得到它的Java棧,通過以下語句:
CVMStack *currentStack = &ee->interpreterStack;
Java棧
Java棧是一個CVMStack類型的結構(見stacks.h和stacks.c)。每個棧有一組棧段構成的鏈表。當在CVMinitStack()初始化棧的時候,就用malloc分配一個棧段。隨着棧需要的內存越來越多,就需要分配更多的棧段。因此,Java棧以棧段的形式增長。理論上,棧段也可以收縮,但現在的代碼沒有實現這一點。
方法的執行過程記錄在棧段中。最基本的幀是CVMFrame(見stacks.h)。還有CVMFreeListFrame(見stacks.h)、CVMJavaFrame、CVMTransitionFrame和CVMCompiledFrame (見interpreter.h)。這些幀結構都是CVMFrame的派生結構。注意:這裏之所以不將它們稱爲子類是因爲CVM是用C寫的,但採用了面向對象的概念進行設計,很多數據結構都可以理解爲多態,因爲這樣做很有意義。
這些CVMFrames組成一組幀的鏈表並分跨在棧段中。鏈表的頭(也就是第一幀,棧底)也被認爲是第一段的開始。CVMStack中的currentFrame指針指向鏈表中的最後一幀(也就是棧頂)。
CVMJavaFrame
CVMJavaFrame是放置被解釋成字節碼後的幀的位置。在調用一個方法之前,VM會向棧中推入一幀CVMJavaFrame。初始化這一幀時,也會初始化一些信息,如CVMMethodBlock * 信息。方法的元數據保存在被稱爲CVMMethodBlock的數據結構中(通常叫做mb或MB)。MB的地址也被作爲這個方法的全局唯一標識。這幀中保存了這些信息。幀中還包含一個程序計數器值(PC)。PC指向下一條將要執行的字節碼(就像方法調用中的調用者PC一樣)。當前PC並不總是填充到當前幀之中,而保存在解釋器循環的本地狀態中。
幀的結構如下所示:
|-----------------|
start of frame ---> | locals ... |
|-----------------|
| frame info |
| (CVMJavaFrame) |
|-----------------|
top of stack ---> | operand stack |
| ... |
|-----------------|
local段保存了Java的局部變量(正如VM規範中聲明的那樣),操作數棧段用於保存作爲VM字節碼計算時推入或彈出的操作數,或爲下一個被調用方法的參數,或爲方法調用結束的返回值。VM規範中聲明給定字節碼的方法在調用之前就應確定局部變量的個數和操作數佔用空間的大小。因此,在向棧推入一幀之前就應確保棧段還有足夠的空間。如果沒有足夠的空間,就應分配一個新的棧段,這一幀會被推入新的棧段中。
因爲輸出參數(用於下一個將被調用的方法)保存在操作數棧中,這部分操作數棧作爲下一幀的local段,如下所示:
|-----------------|
start of frame ---> | locals ... |
|-----------------|
| Method 1 |
| frame info |
|-----------------|
| operand stack |
| |-----------------|
start of next frame ---> | outgoing args = incoming locals |
| | |
|-----------------| |
|-----------------|
| Method 2 |
| frame info |
|-----------------|
top of stack ---------------------> | operand stack |
| |
|-----------------|
這就與VM規範一致了。因爲規範聲明輸入參數從幀的local段的0地址處開始。
注意:在CVM中,local段和操作數棧段的基本單位是word。在32位系統上,這表示32位的內存。棧指針以word爲單位增加。這些word會包含Java原始類型的數據(64位值將佔兩個單元)或對象指針。
CVMFreeListFrame
JNI方法將幀作爲空閒列表來使用。幀的結構如下所示:
|--------------------|
start of frame ---> | frame info |
| (CVMFreeListFrame) |
|--------------------|
| operand stack |
top of stack ----> | ... |
|--------------------|
JNI方法幀沒有輸入參數或任何局部變量,輸入參數被保存在本地方法的本地棧幀上。這些參數從調用者幀操作數棧上的輸出參數複製而來。這是由交合代碼中invokeNative彙編程序段列集操作完成的(如invokeNative_arm.S中的CVMjniInvokeNative)。
JNI方法與Java方法的另一個區別是它的操作數棧段只被用於保存對象指針。其它操作數保存在CPU寄存器或本地棧中(這依賴於編譯這個本地方法的C編譯器)。在JNI中,當使用NewLocalRef分配本地引用,空閒列表幀分配了一段內存單元保存對象指針。當使用DeleteLocalRef釋放分配的棧段時,這些已分配單元會被鏈入freeList(就是freeList幀的名字)的鏈表中。鏈表的頭部在CVMFreeListFrame記錄中。JNI方法的MB指針的一個副本也被保存在那裏。當分配一個本地引用,首先檢查freeList是否有可供使用的引用。如果有一個可供使用,這個引用就從鏈表中刪除並返回。如果鏈表中沒有可供使用,就增加棧頂指針的值並從操作數棧頂部開始分配。
與Java字節碼方法不同的是,JNI方法並不知道最多要分配多少個操作數。幸運的是,並不必須知道。與Java幀不同,freeList幀可以跨越棧段。如果當前棧段空間不夠,就加入另一棧段,並從新的棧段上分配。
正如之前的文章所述,freeList幀的另一個用途是爲了實現GC根棧。GC根棧的實現是使用一個只包含一個freeList幀的CVMStack。GC根棧只是對象引用的一個列表,它扮演着GC時對象引用的根的角色,這個根中的單元可以被分配和釋放(比如,當調用JNI的NewGlobalRoot()和DeleteGlobalRoot())。freeList幀有效地實現了這個功能。
CVMTransitionFrame
轉換幀是爲了省去大量用於膠合的代碼而使解釋器直接調用方法的技巧。其工作原理就是使用指向被調用方法的常量池條目來模擬字節碼方法。常量池條目並不是一個常量,在解釋器循環中,是會改變的變量。解釋器常量池條目設爲指向目標方法的MB。然後,它調用4條稱爲轉換方法的僞方法(見executejava_standard.c文件中的CVMinvokeStaticTransitionCode,CVMinvokeVirtualTransitionCode, CVMinvokeNonVirtualTransitionCode,和CVMinvokeInterfaceTransitionCode)。使用哪個轉換方法依賴於方法調用的類型。
這個機制的用途之一是調用靜態初始化方法(<clinit>)。這個機制還用於調用Java代碼的第一個方法時。
CVMCompiledFrame
最後介紹一下預編譯幀。幀的結構如下所示:
|--------------------|
start of frame ---> | locals ... |
|--------------------|
| frame info |
| (CVMCompiledFrame) |
|--------------------|
| spill/temp area |
|--------------------|
| ... |
top of stack ----> | operand stack |
| ... |
|--------------------|
就像Java幀一樣,CVMCompiledFrame包含一個MB指針和一個PC。在這種情況下,PC指向下一條要執行的指令(包括返回時的調用者PC)。VM將要調用一個方法時,首先檢查這個方法是否被預編譯,或是否是本地方法。如果是本地方法,在使用用於膠合的invokeNative彙編程序調用方法之前會推入一個freeList幀。如果是非預編譯的字節碼方法,就推入一個Java幀,繼續解釋器循環的執行。如果方法是預編譯的,就會推入一個預編譯的幀,然後VM跳入預編譯的方法的入口處繼續執行。
棧上替換(OSR)
如果一個被解釋的方法在循環中要運行很長時間,同時又要預編譯它。如何在一個被解釋了的方法運行到一半時轉而繼續運行它的預編譯版本?可以使用一個稱爲棧上替換(OSR)的特性。OSR允許將棧中的Java幀替換爲一個等價的預編譯幀。
預編譯幀的結構很像一個Java幀。這裏可見的唯一區別是多了一個填充段/臨時段。這個段有固定的大小,而且在方法被編譯以後這個段的大小就確定了(也就是說這一幀被推入之前)。預編譯幀可以擁有比等價的Java幀更多的局部變量,因爲預編譯幀的方法是內聯的。操作數棧的大小也不同。在設計上,CVM JIT爲預編譯方法保存和等價的字節碼方法同樣多的局部變量。爲方法內聯而增加的局部變量擁有較大的下標。這意味着將Java幀的局部變量映射到預編譯幀的局部變量是很容易的。而預編譯幀的額外信息可方便地由Java幀計算得到。
剩下的是關於填充段和操作數棧的內容。基於對Java語言編譯後的字節碼的觀察,在循環開始時操作數棧是空的。以現今的javac編譯器來說,99%的情況是這樣。運行頻率最高的循環是適合使用OSR的。開始一個循環不需要對操作數棧做任何映射,可以利用這一點運用OSR。
對於填充段而言,開始循環時,CVM JIT也不會產生任何填充內容。因此,此時唯一需要做的是爲新幀保留一些空間。正因如此,可以將被部分解釋的運行頻率高的循環替換爲預編譯的等價的代碼。
注意:CVM僅支持將Java幀替換爲等價的預編譯幀的OSR,反之則不可。執行相反方向的OSR的代價昂貴得多,不適用於JavaME系統。這也是Java社區應在未來關心的問題之一。關於反向的OSR有些有趣的高級特性,會留在未來使用它時再討論。
關於下一講
現在已經介紹了CVM的棧結構。下一次將要簡要地介紹本地棧幀(任何嵌入式程序員應已非常熟悉),這將會更有趣,還會介紹兩者的相互影響。那麼,讓我們期待這個討論的第二部分。