重載和重寫的底層原理——虛擬機字節(jié)碼執(zhí)行引擎
虛擬機字節(jié)碼執(zhí)行引擎
代碼編譯的結(jié)果從本地機器碼轉(zhuǎn)變?yōu)樽止?jié)碼,是存儲格式發(fā)展的一小步,卻是編程語言發(fā)展的一大步。
是物理機的執(zhí)行引擎是直接建立在處理器、緩存、指令集和操作系統(tǒng)層 面上的,而虛擬機的執(zhí)行引擎則是由軟件自行實現(xiàn)的,因此可以不受物理條件制約地定制指令集與執(zhí) 行引擎的結(jié)構(gòu)體系,能夠執(zhí)行那些不被硬件直接支持的指令集格式。
Java虛擬機以方法作為最基本的執(zhí)行單元,“棧幀”(Stack Frame)則是用于支持虛擬機進行方法調(diào)用和方法執(zhí)行背后的數(shù)據(jù)結(jié)構(gòu),它也是虛擬機運行時數(shù)據(jù)區(qū)中的虛擬機棧(Virtual Machine Stack)[1]的棧元素。棧幀存儲了方法的局部變量表、操作數(shù)棧、動態(tài)連接和方法返回地址等信息,如果讀者認真閱讀過第6章JVM類文件結(jié)構(gòu),應(yīng)該能從Class文件格式的方法表中找到以上大多數(shù)概念的靜態(tài)對照物。每一個方法從調(diào)用開始至執(zhí)行結(jié)束的過程,都對應(yīng)著一個棧幀在虛擬機棧里面從入棧到出棧的過程。
java程序從main開始,從main結(jié)束
每一個棧幀都包括了局部變量表、操作數(shù)棧、動態(tài)連接、方法返回地址和一些額外的附加信息。 在編譯Java程序源碼的時候,棧幀中需要多大的局部變量表,需要多深的操作數(shù)棧就已經(jīng)被分析計算 出來,并且寫入到方法表的Code屬性之中[2]。換言之,一個棧幀需要分配多少內(nèi)存,并不會受到程序運行期變量數(shù)據(jù)的影響,而僅僅取決于程序源碼和具體的虛擬機實現(xiàn)的棧內(nèi)存布局形式?!贘ava語言中符合“編譯期可知,運行期不可變”這個要求的方法,
8.2運行時棧幀結(jié)構(gòu)
一個線程中的方法調(diào)用鏈可能會很長,以Java程序的角度來看,同一時刻、同一條線程里面,在 調(diào)用堆棧的所有方法都同時處于執(zhí)行狀態(tài)。而對于執(zhí)行引擎來講,在活動線程中,只有位于棧頂?shù)姆?法才是在運行的,只有位于棧頂?shù)臈攀巧У?,其被稱為“當(dāng)前棧幀”(Current Stack Frame),與 這個棧幀所關(guān)聯(lián)的方法被稱為“當(dāng)前方法”(Current Method)。執(zhí)行引擎所運行的所有字節(jié)碼指令都只 針對當(dāng)前棧幀進行操作,在概念模型上,典型的棧幀結(jié)構(gòu)如圖8-1所示。 圖8-1所示的就是虛擬機棧和棧幀的總體結(jié)構(gòu),接下來,我們將會詳細了解棧幀中的局部變量表、 操作數(shù)棧、動態(tài)連接、方法返回地址等各個部分的作用和數(shù)據(jù)結(jié)構(gòu)。

8.2.1局部變量表(Local Variables Table)
是一組變量值的存儲空間,用于存放方法參數(shù)和方法內(nèi)部定義的局部變量。在Java程序被編譯為Class文件時,就在方法的Code屬性的max_locals數(shù)據(jù)項中確定了該方法所需分配的局部變量表的最大容量。
為了盡可能節(jié)省棧幀耗用的內(nèi)存空間,局部變量表中的變量槽是可以重用的.
紅字前為擴展內(nèi)容
方法體中定義的變量,其作用域并不一定會覆蓋整個方法體,如果當(dāng)前字節(jié)碼PC計數(shù)器的值已經(jīng)超出了某個變量的作用域,那這個變量對應(yīng)的變量槽就可以交給其他變量來重用。不過,這樣的設(shè)計除了節(jié)省棧幀空間以外,還會伴隨有少量額外的副作用,例如在某些情況下變量槽的復(fù)用會直接影響到系統(tǒng)的垃圾收集行為,請看代碼清單8-1、代碼清單8-2和代碼清單8-3的3個演示。
// 內(nèi)存真的被回收掉了 placeholder能否被回收的根本原因就是:局部變量表中的變量槽是否還存有?關(guān)于placeholder數(shù)組對象的引用。第一次修改中,代碼雖然已經(jīng)離開了placeholder的作用域,但在此之?后,再沒有發(fā)生過任何對局部變量表的讀寫操作,placeholder原本所占用的變量槽還沒有被其他變量?所復(fù)用,所以作為GC Roots一部分的局部變量表仍然保持著對它的關(guān)聯(lián)。這種關(guān)聯(lián)沒有被及時打斷, 絕大部分情況下影響都很輕微。但如果遇到一個方法,其后面的代碼有一些耗時很長的操作,而前面?又定義了占用了大量內(nèi)存但實際上已經(jīng)不會再使用的變量,手動將其設(shè)置為null值(用來代替那句int a=0,把變量對應(yīng)的局部變量槽清空)便不見得是一個絕對無意義的操作,這種操作可以作為一種在極 特殊情形(對象占用內(nèi)存大、此方法的棧幀長時間不能被回收、方法調(diào)用次數(shù)達不到即時編譯器的編 譯條件)下的“奇技”來使用。經(jīng)過第一次修改的代碼,System.gc()執(zhí)行時就可以正確地回收內(nèi)存,根本無須寫成代碼清單 8-3的樣子。
在實際情況中,即時編譯才是虛擬機執(zhí)行代碼的主要方式,賦null值的操作在經(jīng)過即時編譯優(yōu)化后幾乎是 一定會被當(dāng)作無效操作消除掉的
8.2.2 操作數(shù)棧
操作數(shù)棧(Operand Stack)也常被稱為操作棧,它是一個后入先出(Last In First Out,LIFO)棧。同局部變量表一樣,操作數(shù)棧的最大深度也在編譯的時候被寫入到Code屬性的max_stacks數(shù)據(jù)項 之中。操作數(shù)棧的每一個元素都可以是包括long和double在內(nèi)的任意Java數(shù)據(jù)類型。32位數(shù)據(jù)類型所占 的棧容量為1,64位數(shù)據(jù)類型所占的棧容量為2。Javac編譯器的數(shù)據(jù)流分析工作保證了在方法執(zhí)行的任何時候,操作數(shù)棧的深度都不會超過在max_stacks數(shù)據(jù)項中設(shè)定的最大值。
當(dāng)一個方法剛剛開始執(zhí)行的時候,這個方法的操作數(shù)棧是空的,在方法的執(zhí)行過程中,會有各種字節(jié)碼指令往操作數(shù)棧中寫入和提取內(nèi)容,也就是出棧和入棧操作。譬如在做算術(shù)運算的時候是通過 將運算涉及的操作數(shù)棧壓入棧頂后調(diào)用運算指令來進行的,又譬如在調(diào)用其他方法的時候是通過操作數(shù)棧來進行方法參數(shù)的傳遞。舉個例子,例如整數(shù)加法的字節(jié)碼指令iadd,這條指令在運行的時候要 求操作數(shù)棧中最接近棧頂?shù)膬蓚€元素已經(jīng)存入了兩個int型的數(shù)值,當(dāng)執(zhí)行這個指令時,會把這兩個int 值出棧并相加,然后將相加的結(jié)果重新入棧。
操作數(shù)棧中元素的數(shù)據(jù)類型必須與字節(jié)碼指令的序列嚴格匹配,在編譯程序代碼的時候,編譯器 必須要嚴格保證這一點,在類校驗階段的數(shù)據(jù)流分析中還要再次驗證這一點。再以上面的iadd指令為 例,這個指令只能用于整型數(shù)的加法,它在執(zhí)行時,最接近棧頂?shù)膬蓚€元素的數(shù)據(jù)類型必須為int型, 不能出現(xiàn)一個long和一個float使用iadd命令相加的情況。
另外在概念模型中,兩個不同棧幀作為不同方法的虛擬機棧的元素,是完全相互獨立的。但是在 大多虛擬機的實現(xiàn)里都會進行一些優(yōu)化處理,令兩個棧幀出現(xiàn)一部分重疊。讓下面棧幀的部分操作數(shù) 棧與上面棧幀的部分局部變量表重疊在一起,這樣做不僅節(jié)約了一些空間,更重要的是在進行方法調(diào) 用時就可以直接共用一部分數(shù)據(jù),無須進行額外的參數(shù)復(fù)制傳遞了,重疊的過程如圖8-2所示。 Java虛擬機的解釋執(zhí)行引擎被稱為“基于棧的執(zhí)行引擎”,里面的“?!本褪遣僮鲾?shù)棧。后文會對基于棧的代碼執(zhí)行過程進行更詳細的講解,介紹它與更常見的基于寄存器的執(zhí)行引擎有哪些差別。

8.2.3 動態(tài)連接
每個棧幀都包含一個指向運行時常量池(插入第六章的文章鏈接)中該棧幀所屬方法的引用,持有這個引用是為了支持方法調(diào)用過程中的動態(tài)連接(Dynamic Linking)。通過第6章的講解,我們知道Class文件的常量池中存 有大量的符號引用,字節(jié)碼中的方法調(diào)用指令就以常量池里指向方法的符號引用作為參數(shù)。這些符號引用一部分會在類加載階段或者第一次使用的時候就被轉(zhuǎn)化為直接引用,這種轉(zhuǎn)化被稱為靜態(tài)解析。 另外一部分將在每一次運行期間都轉(zhuǎn)化為直接引用,這部分就稱為動態(tài)連接。關(guān)于這兩個轉(zhuǎn)化過程的 具體過程,將在8.3節(jié)中再詳細講解。
8.2.4 方法返回地址
當(dāng)一個方法開始執(zhí)行后,只有兩種方式退出這個方法。
第一種方式是執(zhí)行引擎遇到任意一個方法 返回的字節(jié)碼指令,這時候可能會有返回值傳遞給上層的調(diào)用者。稱之為“正常調(diào)用完成”(Normal Method Invocation Completion)。
另外一種退出方式是在方法執(zhí)行的過程中遇到了異常,并且這個異常沒有在方法體內(nèi)得到妥善處理,是不會給它 的上層調(diào)用者提供任何返回值的。
無論采用何種退出方式,在方法退出之后,都必須返回到最初方法被調(diào)用時的位置,程序才能繼續(xù)執(zhí)行,方法返回時可能需要在棧幀中保存一些信息,用來幫助恢復(fù)它的上層主調(diào)方法的執(zhí)行狀態(tài)。 一般來說,方法正常退出時,主調(diào)方法的PC計數(shù)器的值就可以作為返回地址,棧幀中很可能會保存這 個計數(shù)器值。而方法異常退出時,返回地址是要通過異常處理器表(插入第六章文章鏈接)來確定的,棧幀中就一般不會保存 這部分信息。
8.3 方法調(diào)用
方法調(diào)用并不等同于方法中的代碼被執(zhí)行,方法調(diào)用階段唯一的任務(wù)就是確定被調(diào)用方法的版本?(即調(diào)用哪一個方法,其實就是執(zhí)行父類的版本還是子類的版本),暫時還未涉及方法內(nèi)部的具體運行過程。在程序運行時,進行方法調(diào)用是最普遍、最頻繁的操作之一,但第7章中已經(jīng)講過,Class文件的編譯過程中不包含傳統(tǒng)程序語言編譯的連接步驟,一切方法調(diào)用在Class文件里面存儲的都只是符號引用,而不是方法在實際運行時內(nèi)存布局中的入口地址(也就是之前說的直接引用)。這個特性給Java帶來了更強大的動態(tài)擴展能力,但也使 得Java方法調(diào)用過程變得相對復(fù)雜,某些調(diào)用需要在類加載期間,甚至到運行期間才能確定目標方法 的直接引用。
主要有靜態(tài)方法和私有方法兩大類,前者與類型直接關(guān)聯(lián),后者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫出其他版本,因此它們都適合在類加載階段進行解析。
8.3.1 解析
承接前面關(guān)于方法調(diào)用的話題,所有方法調(diào)用的目標方法在Class文件里面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉(zhuǎn)化為直接引用,這種解析能夠成立的前提是:方法在程序真正運行之前就有一個可確定的調(diào)用版本,并且這個方法的調(diào)用版本在運行期是不可改變的。
換句話說,調(diào)用目標在程序代碼寫好、編譯器進行編譯那一刻就已經(jīng)確定下來。這類方法 的調(diào)用被稱為解析(Resolution)。
在Java語言中符合“編譯期可知,運行期不可變”這個要求的方法,主要有靜態(tài)方法和私有方法兩 大類,前者與類型直接關(guān)聯(lián),后者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通 過繼承或別的方式重寫出其他版本,因此它們都適合在類加載階段進行解析。?>
調(diào)用不同類型的方法,字節(jié)碼指令集里設(shè)計了不同的指令。
在Java虛擬機支持以下5條方法調(diào)用字節(jié)碼指令,分別是:
invokestatic。用于調(diào)用靜態(tài)方法。
invokespecial。用于調(diào)用實例構(gòu)造器()方法、私有方法和父類中的方法。
invokevirtual。用于調(diào)用所有的虛方法。
invokeinterface。用于調(diào)用接口方法,會在運行時再確定一個實現(xiàn)該接口的對象。
invokedynamic。先在運行時動態(tài)解析出調(diào)用點限定符所引用的方法,然后再執(zhí)行該方法。
前面4 條調(diào)用指令,分派邏輯都固化在Java虛擬機內(nèi)部,而invokedynamic指令的分派邏輯是由用戶設(shè)定的引導(dǎo)方法來決定的。?只要能被invokestatic和invokespecial指令調(diào)用的方法,都可以在解析階段中確定唯一的調(diào)用版本, Java語言里符合這個條件的方法共有靜態(tài)方法、私有方法、實例構(gòu)造器、父類方法4種,再加上被final 修飾的方法(盡管它使用invokevirtual指令調(diào)用,因為無法被覆蓋,沒有其他版本可能),這5種方法調(diào)用會在類加載的時候就可以把符號引用解析為該方法的直接引用。這些方法統(tǒng)稱為“非虛方法”(Non-Virtual Method),與之相反,其他方法就被稱為“虛方法”(Virtual Method)。
代碼清單8-5演示了一種常見的解析調(diào)用的例子,該樣例中,靜態(tài)方法sayHello()只可能屬于類型 StaticResolution,沒有任何途徑可以覆蓋或隱藏這個方法。 代碼清單8-5 方法靜態(tài)解析演示
使用javap命令查看這段程序?qū)?yīng)的字節(jié)碼,會發(fā)現(xiàn)的確是通過invokestatic命令來調(diào)用sayHello()方 法,而且其調(diào)用的方法版本已經(jīng)在編譯時就明確以常量池項的形式固化在字節(jié)碼指令的參數(shù)之中(代碼里的31號常量池項):
解析調(diào)用一定是個靜態(tài)的過程,在編譯期間就完全確定,在類加載的解析階段就會把涉及的符號引用全部轉(zhuǎn)變?yōu)槊鞔_的直接引用,不必延遲到運行期再去完成。而另一種主要的方法調(diào)用形式:分派 (Dispatch)調(diào)用則要復(fù)雜許多,它可能是靜態(tài)的也可能是動態(tài)的,按照分派依據(jù)的宗量數(shù)可分為單分派和多分派[1]。這兩類分派方式兩兩組合就構(gòu)成了靜態(tài)單分派、靜態(tài)多分派、動態(tài)單分派、動態(tài)多分派4種分派組合情況,下面我們來看看虛擬機中的方法分派是如何進行的。
這就是之前面試死記硬背的先執(zhí)行靜態(tài)代碼塊,在執(zhí)行靜態(tài)方法再執(zhí)行父類方法再執(zhí)行子類方法的底層原因。
8.3.2 分派
眾所周知,Java是一門面向?qū)ο蟮某绦蛘Z言,因為Java具備面向?qū)ο蟮?個基本特征:繼承、封裝 和多態(tài)。
本節(jié)講解的分派調(diào)用過程將會揭示多態(tài)性特征的一些最基本的體現(xiàn),如“重載”和“重寫”在 Java虛擬機之中是如何實現(xiàn)的,這里的實現(xiàn)當(dāng)然不是語法上該如何寫,我們關(guān)心的依然是虛擬機如何確定正確的目標方法。
1.靜態(tài)分派(重載的本質(zhì))
代碼8-7
代碼清單8-7演示了編譯期間選擇靜態(tài)分派目標的過程,這個過程也是Java語言實現(xiàn)方法重載的本質(zhì)。
演示所用的這段程序無疑是屬于很極端的例子,除了用作面試題為難求職者之外,在實際工作中 幾乎不可能存在任何有價值的用途,筆者拿來做演示僅僅是用于講解重載時目標方法選擇的過程,對絕大多數(shù)下進行這樣極端的重載都可算作真正的“關(guān)于茴香豆的茴有幾種寫法的研究”。無論對重載的認識有多么深刻,一個合格的程序員都不應(yīng)該在實際應(yīng)用中寫這種晦澀的重載代碼。
2.動態(tài)分派(重寫的本質(zhì))
代碼8-8
java程序員認為這個結(jié)果理所當(dāng)然,那么Java虛擬機是如何判斷應(yīng)該調(diào)用哪個方法的
我們使用javap命令輸出這段代碼的字節(jié)碼
正是因為invokevirtual指令執(zhí)行的第一步就是在運行期確定接收者的實際類型,所以兩次調(diào)用中的 invokevirtual指令并不是把常量池中方法的符號引用解析到直接引用上就結(jié)束了,還會根據(jù)方法接收者的實際類型來選擇方法版本,這個過程就是Java語言中方法重寫的本質(zhì)。我們把這種在運行期根據(jù)實際類型確定方法執(zhí)行版本的分派過程稱為動態(tài)分派。 既然這種多態(tài)性的根源在于虛方法調(diào)用指令invokevirtual的執(zhí)行邏輯,那自然我們得出的結(jié)論就只會對方法有效,對字段是無的,因為字段不使用這條指令。(這也就是子類字段是私有的方法是公有的原因)事實上,在Java里面只有虛方法存在,字段永遠不可能是虛的,換句話說,字段永遠不參與多態(tài),哪個類的方法訪問某個名字的字段時,該名字指的就是這個類能看到的那個字段。當(dāng)子類聲明了與父類同名的字段時,雖然在子類的內(nèi)存中兩個字段都會存在,但是子類的字段會遮蔽父類的同名字段。
3.單分派與多分派
方法的接收者與方法的參數(shù)統(tǒng)稱為方法的宗量,這個定義最早應(yīng)該來源于著名的《Java與模式》 一書。根據(jù)分派基于多少種宗量,可以將分派劃分為單分派和多分派兩種。單分派是根據(jù)一個宗量對目標方法進行選擇,多分派則是根據(jù)多于一個宗量對目標方法進行選擇。
代碼清單8-11中舉了一個Father和Son一起來做出“一個艱難的決定[5]”的例子
在main()里調(diào)用了兩次hardChoice()方法,這兩次hardChoice()方法的選擇結(jié)果在程序輸出中已經(jīng)顯 示得很清楚了。我們關(guān)注的首先是編譯階段中編譯器的選擇過程,也就是靜態(tài)分派的過程。這時候選 擇目標方法的依據(jù)有兩點:一是靜態(tài)類型是Father還是Son,二是方法參數(shù)是QQ還是360。這次選擇結(jié)果的最終產(chǎn)物是產(chǎn)生了兩條invokevirtual指令,兩條指令的參數(shù)分別為常量池中指向 Father::hardChoice(360)及Father::hardChoice(QQ)方法的符號引用。因為是根據(jù)兩個宗量進行選擇,所以 Java語言的靜態(tài)分派屬于多分派類型。
再看看運行階段中虛擬機的選擇,也就是動態(tài)分派的過程。在執(zhí)行“son.hardChoice(new QQ())”這行代碼時,更準確地說,是在執(zhí)行這行代碼所對應(yīng)的invokevirtual指令時,由于編譯期已經(jīng)決定目標方法的簽名必須為hardChoice(QQ),虛擬機此時不會關(guān)心傳遞過來的參數(shù)“QQ”到底是“騰訊QQ”還是“奇 瑞QQ”,因為這時候參數(shù)的靜態(tài)類型、實際類型都對方法的選擇不會構(gòu)成任何影響,唯一可以影響虛擬機選擇的因素只有該方法的接受者的實際類型是Father還是Son。因為只有一個宗量作為選擇依據(jù),?所以Java語言的動態(tài)分派屬于單分派類型。
根據(jù)上述論證的結(jié)果,我們可以總結(jié)一句:如今(直至本書編寫的Java 12和預(yù)覽版的Java 13)的 Java語言是一門靜態(tài)多分派、動態(tài)單分派的語言。?強調(diào)“如今的Java語言”是因為這個結(jié)論未必會恒久不 變,按照目前Java語言的發(fā)展趨勢,它并沒有直接變?yōu)閯討B(tài)語言的跡象,而是通過內(nèi)置動態(tài)語言(如 JavaScript)執(zhí)行引擎、加強與其他Java虛擬機上動態(tài)語言交互能力的方式來間接地滿足動態(tài)性的需求。
4.虛擬機動態(tài)分派的實現(xiàn)
前面介紹的分派過程,作為對Java虛擬機概念模型的解釋基本上已經(jīng)足夠了,它已經(jīng)解決了虛擬 機在分派中“會做什么”這個問題。但如果問Java虛擬機“具體如何做到”的,答案則可能因各種虛擬機的實現(xiàn)不同會有些差別。在此不過多介紹
8.4 動態(tài)類型語言支持
Java虛擬機的字節(jié)碼指令集的數(shù)量自從Sun公司的第一款Java虛擬機問世至今,二十余年間只新增過一條指令,它就是隨著JDK 7的發(fā)布的字節(jié)碼首位新成員——invokedynamic指令。這條新增加的指令是JDK 7的項目目標:實現(xiàn)動態(tài)類型語言(Dynamically Typed Language)支持而進行的改進之一, 也是為JDK 8里可以順利實現(xiàn)Lambda表達式而做的技術(shù)儲備。在本節(jié)中,我們將詳細了解動態(tài)語言支持這項特性出現(xiàn)的前因后果和它的意義與價值。
8.4.1 動態(tài)類型語言
在介紹Java虛擬機的動態(tài)類型語言支持之前,我們要先弄明白動態(tài)類型語言是什么?它與Java語 言、Java虛擬機有什么關(guān)系?了解Java虛擬機提供動態(tài)類型語言支持的技術(shù)背景,對理解這個語言特性 是非常有必要的。 何謂動態(tài)類型語言[1]?
動態(tài)類型語言的關(guān)鍵特征是它的類型檢查的主體過程是在運行期而不是編譯期進行的,滿足這個特征的語言有很多,常用的包括:APL、Clojure、Erlang、Groovy、 JavaScript、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk、Tcl,等等。那相對地,在編譯期就進行類型檢查過程的語言,譬如C++和Java等就是最常用的靜態(tài)類型語言。
如果讀者覺得上面的定義過于概念化,那我們不妨通過兩個例子以最淺顯的方式來說明什么是“類 型檢查”和什么叫“在編譯期還是在運行期進行”。首先看下面這段簡單的Java代碼,思考一下它是否能 正常編譯和運行?
上面這段Java代碼能夠正常編譯,但運行的時候會出現(xiàn)NegativeArraySizeException異常。在《Java 虛擬機規(guī)范》中明確規(guī)定了NegativeArraySizeException是一個運行時異常(Runtime Exception),通俗 一點說,運行時異常就是指只要代碼不執(zhí)行到這一行就不會出現(xiàn)問題。與運行時異常相對應(yīng)的概念是 連接時異常,例如很常見的NoClassDefFoundError便屬于連接時異常,即使導(dǎo)致連接時異常的代碼放 在一條根本無法被執(zhí)行到的路徑分支上,類加載時(第7章解釋過Java的連接過程不在編譯階段,而在類加載階段)也照樣會拋出異常。
不過,在C語言里,語義相同的代碼就會在編譯期就直接報錯,而不是等到運行時才出現(xiàn)異常:
由此看來,一門語言的哪一種檢查行為要在運行期進行,哪一種檢查要在編譯期進行并沒有什么 必然的因果邏輯關(guān)系,關(guān)鍵是在語言規(guī)范中人為設(shè)立的約定。 解答了什么是“連接時、運行時”,筆者再舉一個例子來解釋什么是“類型檢查”,例如下面這一句再普通不過的代碼:
雖然正在閱讀本書的每一位讀者都能看懂這行代碼要做什么,但對于計算機來講,這一行“沒頭沒 尾”的代碼是無法執(zhí)行的,它需要一個具體的上下文中(譬如程序語言是什么、obj是什么類型)才有 討論的意義。 現(xiàn)在先假設(shè)這行代碼是在Java語言中,并且變量obj的靜態(tài)類型為java.io.PrintStream,那變量obj的實際類型就必須是PrintStream的子類(實現(xiàn)了PrintStream接口的類)才是合法的。否則,哪怕obj屬于一個確實包含有println(String)方法相同簽名方法的類型,但只要它與PrintStream接口沒有繼承關(guān)系,代碼依然不可能運行——因為類型檢查不合法。
但是相同的代碼在JavaScript中情況則不一樣,無論obj具體是何種類型,無論其繼承關(guān)系如何,只要這種類型的方法定義中確實包含有println(String)方法,能夠找到相同簽名的方法,調(diào)用便可成功。
產(chǎn)生這種差別產(chǎn)生的根本原因是Java語言在編譯期間卻已將println(String)方法完整的符號引用(本 例中為一項CONSTANT_InterfaceMethodref_info常量)生成出來,并作為方法調(diào)用指令的參數(shù)存儲到 Class文件中,例如下面這個樣子:
這個符號引用包含了該方法定義在哪個具體類型之中、方法的名字以及參數(shù)順序、參數(shù)類型和方 法返回值等信息,通過這個符號引用,Java虛擬機就可以翻譯出該方法的直接引用。而JavaScript等動態(tài)類型語言與Java有一個核心的差異就是變量obj本身并沒有類型,變量obj的值才具有類型,所以編譯器在編譯時最多只能確定方法名稱、參數(shù)、返回值這些信息,而不會去確定方法所在的具體類型 (即方法接收者不固定)。“變量無類型而變量值才有類型”這個特點也是動態(tài)類型語言的一個核心特征。
了解了動態(tài)類型和靜態(tài)類型語言的區(qū)別后,也許讀者的下一個問題就是動態(tài)、靜態(tài)類型語言兩者誰更好,或者誰更加先進呢?這種比較不會有確切答案,它們都有自己的優(yōu)點,選擇哪種語言是需要權(quán)衡的事情。靜態(tài)類型語言能夠在編譯期確定變量類型,最顯著的好處是編譯器可以提供全面嚴謹?shù)念愋蜋z查,這樣與數(shù)據(jù)類型相關(guān)的潛在問題就能在編碼時被及時發(fā)現(xiàn),利于穩(wěn)定性及讓項目容易達到 更大的規(guī)模。而動態(tài)類型語言在運行期才確定類型,這可以為開發(fā)人員提供極大的靈活性,某些在靜態(tài)類型語言中要花大量臃腫代碼來實現(xiàn)的功能,由動態(tài)類型語言去做可能會很清晰簡潔,清晰簡潔通常也就意味著開發(fā)效率的提升。?[1] 注意,動態(tài)類型語言與動態(tài)語言、弱類型語言并不是一個概念,需要區(qū)別對待。
8.4.2 Java與動態(tài)類型(了解)
JDK 7時新加入的java.lang.invoke包,提供一種新的動態(tài)確定目標方法的機制,稱為“方法句柄”(Method Handle)。
Reflection和MethodHandle機制本質(zhì)上都是在模擬方法調(diào)用,但是Reflection是在模擬Java代碼層次 的方法調(diào)用,而MethodHandle是在模擬字節(jié)碼層次的方法調(diào)用。在MethodHandles.Lookup上的3個方法 findStatic()、findVirtual()、findSpecial()正是為了對應(yīng)于invokestatic、invokevirtual(以及 invokeinterface)和invokespecial這幾條字節(jié)碼指令的執(zhí)行權(quán)限校驗行為,而這些底層細節(jié)在使用 Reflection API時是不需要關(guān)心的。
8.4.5 實戰(zhàn):掌控方法分派規(guī)則
invokedynamic指令與此前4條傳統(tǒng)的“invoke*”指令的最大區(qū)別就是它的分派邏輯不是由虛擬機決 定的,而是由程序員決定。在介紹Java虛擬機動態(tài)語言支持的最后一節(jié)中,筆者希望通過一個簡單例子(如代碼清單8-15所示),幫助讀者理解程序員可以掌控方法分派規(guī)則之后,我們能做什么以前無 法做到的事情。
代碼清單8-15方法調(diào)用的問題
在Java程序中,可以通過“super”關(guān)鍵字很方便地調(diào)用到父類中的方法,但如果要訪問祖類的方法呢?
在擁有invokedynamic和java.lang.invoke包之前(在JDK 7之前有),使用純粹的Java語言很難處理這個問題(使用ASM 等字節(jié)碼工具直接生成字節(jié)碼當(dāng)然還是可以處理的,但這已經(jīng)是在字節(jié)碼而不是Java語言層面來解決 問題了),原因是在Son類的thinking()方法中根本無法獲取到一個實際類型是GrandFather的對象引用, 而invokevirtual指令的分派邏輯是固定的,只能按照方法接收者的實際類型進行分派,這個邏輯完全固化在虛擬機中,程序員無法改變。
8.5 基于棧的字節(jié)碼解釋執(zhí)行引擎
關(guān)于Java虛擬機是如何調(diào)用方法、進行版本選擇的內(nèi)容已經(jīng)全部講解完畢,從本節(jié)開始,我們來探討虛擬機是如何執(zhí)行方法里面的字節(jié)碼指令的。概述中曾提到過,許多Java虛擬機的執(zhí)行引擎在執(zhí)行Java代碼的時候都有解釋執(zhí)行(通過解釋器執(zhí)行)和編譯執(zhí)行(通過即時編譯器產(chǎn)生本地代碼執(zhí)行)兩種選擇,在本節(jié)中,我們將會分析在概念模型下的Java虛擬機解釋執(zhí)行字節(jié)碼時,其執(zhí)行引擎 是如何工作的。筆者在本章多次強調(diào)了“概念模型”,是因為實際的虛擬機實現(xiàn),譬如HotSpot的模板解釋器工作的時候,并不是按照下文中的動作一板一眼地進行機械式計算,而是動態(tài)產(chǎn)生每條字節(jié)碼對應(yīng)的匯編代碼來運行,這與概念模型中執(zhí)行過程的差異很大,但是結(jié)果卻能保證是一致的。
8.5.1 解釋執(zhí)行
但當(dāng)主流的虛擬機中都包含了即時編譯器后,Class文件中的代碼到底會被解釋執(zhí)行還是編譯執(zhí)行,就成了只有虛擬機自己才能準確判斷的事?,F(xiàn)在只有確定了談?wù)搶ο笫悄撤N具體的Java實現(xiàn)版本和執(zhí)行引擎運行模式時,談解釋執(zhí)行還是編譯執(zhí)行才會比較合理確切。
中間的那條分支,就是解釋執(zhí)行的過程。

如今,基于物理機、Java虛擬機,或者是非Java的其他高級語言虛擬機(HLLVM)的代碼執(zhí)行過 程,大體上都會遵循這種符合現(xiàn)代經(jīng)典編譯原理的思路,在執(zhí)行前先對程序源碼進行詞法分析和語法分析處理,把源碼轉(zhuǎn)化為抽象語法樹(Abstract Syntax Tree,AST)。對于一門具體語言的實現(xiàn)來說, 詞法、語法分析以至后面的優(yōu)化器和目標代碼生成器都可以選擇獨立于執(zhí)行引擎,形成一個完整意義的編譯器去實現(xiàn),這類代表是C/C++語言。也可以選擇把其中一部分步驟(如生成抽象語法樹之前的步驟)實現(xiàn)為一個半獨立的編譯器,這類代表是Java語言。又或者把這些步驟和執(zhí)行引擎全部集中封裝在一個封閉的黑匣子之中,如大多數(shù)的JavaScript執(zhí)行引擎。?在Java語言中,Javac編譯器完成了程序代碼經(jīng)過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的字節(jié)碼指令流的過程。因為這一部分動作是在Java虛擬機之外進行的,而解釋器在虛擬機的內(nèi)部,所以Java程序的編譯就是半獨立的實現(xiàn)
8.5.2 基于棧的指令集與基于寄存器的指令集
Javac編譯器輸出的字節(jié)碼指令流,基本上[1]是一種基于棧的指令集架構(gòu)(Instruction Set Architecture,ISA),字節(jié)碼指令流里面的指令大部分都是零地址指令,它們依賴操作數(shù)棧進行工 作。與之相對的另外一套常用的指令集架構(gòu)是基于寄存器的指令集,最典型的就是x86的二地址指令 集,如果說得更通俗一些就是現(xiàn)在我們主流PC機中物理硬件直接支持的指令集架構(gòu),這些指令依賴寄 存器進行工作。
基于棧的指令集主要優(yōu)點是可移植,因為寄存器由硬件直接提供[2],程序直接依賴這些硬件寄存器則不可避免地要受到硬件的約束。
8.5.3 基于棧的解釋器執(zhí)行過程(重點!?。。?/h1>
關(guān)于棧架構(gòu)執(zhí)行引擎的必要前置知識已經(jīng)全部講解完畢了,本節(jié)筆者準備了一段Java代碼以便向讀者實際展示在虛擬機里字節(jié)碼是如何執(zhí)行的。
代碼清單8-17 一段簡單的算術(shù)代碼
這段代碼從Java語言的角度沒有任何談?wù)摰谋匾苯邮褂胘avap命令看看它的字節(jié)碼指令,如代 碼清單8-18所示。 代碼清單8-18 一段簡單的算術(shù)代碼的字節(jié)碼表示
javap提示這段代碼需要深度為2的操作數(shù)棧和4個變量槽的局部變量空間,筆者就根據(jù)這些信息畫 了圖8-5至圖8-11共7張圖片,來描述代碼清單8-13執(zhí)行過程中的代碼、操作數(shù)棧和局部變量表的變化情況。

首先,執(zhí)行偏移地址為0的指令,Bipush指令的作用是將單字節(jié)的整型常量值(-128~127)推入操作數(shù)棧頂,跟隨有一個參數(shù),指明推送的常量值,這里是100。

執(zhí)行偏移地址為2的指令,istore_1指令的作用是將操作數(shù)棧頂?shù)恼椭党鰲2⒋娣诺降?個局部變 量槽中。后續(xù)4條指令(直到偏移為11的指令為止)都是做一樣的事情,也就是在對應(yīng)代碼中把變量 a、b、c賦值為100、200、300。這4條指令的圖示略過。

執(zhí)行偏移地址為11的指令,iload_1指令的作用是將局部變量表第1個變量槽中的整型值復(fù)制到操作數(shù)棧頂。

執(zhí)行偏移地址為12的指令,iload_2指令的執(zhí)行過程與iload_1類似,把第2個變量槽的整型值入棧。 畫出這個指令的圖示主要是為了顯示下一條iadd指令執(zhí)行前的堆棧狀況。

執(zhí)行偏移地址為13的指令,iadd指令的作用是將操作數(shù)棧中頭兩個棧頂元素出棧,做整型加法, 然后把結(jié)果重新入棧。在iadd指令執(zhí)行完畢后,棧中原有的100和200被出棧,它們的和300被重新入棧。

執(zhí)行偏移地址為14的指令,iload_3指令把存放在第3個局部變量槽中的300入棧到操作數(shù)棧中。這時操作數(shù)棧為兩個整數(shù)300。下一條指令imul是將操作數(shù)棧中頭兩個棧頂元素出棧,做整型乘法,然后 把結(jié)果重新入棧,與iadd完全類似,所以筆者省略圖示。

執(zhí)行偏移地址為16的指令,ireturn指令是方法返回指令之一,它將結(jié)束方法執(zhí)行并將操作數(shù)棧頂?shù)恼椭捣祷亟o該方法的調(diào)用者。到此為止,這段方法執(zhí)行結(jié)束。 再次強調(diào)上面的執(zhí)行過程僅僅是一種概念模型,虛擬機最終會對執(zhí)行過程做出一系列優(yōu)化來提高性能,實際的運作過程并不會完全符合概念模型的描述。更確切地說,實際情況會和上面描述的概念模型差距非常大,差距產(chǎn)生的根本原因是虛擬機中解析器和即時編譯器都會對輸入的字節(jié)碼進行優(yōu)化,即使解釋器中也不是按照字節(jié)碼指令去逐條執(zhí)行的。例如在HotSpot虛擬機中,就有很多 以“fast_”開頭的非標準字節(jié)碼指令用于合并、替換輸入的字節(jié)碼以提升解釋執(zhí)行性能,即時編譯器的優(yōu)化手段則更是花樣繁多[1]
不過我們從這段程序的執(zhí)行中也可以看出棧結(jié)構(gòu)指令集的一般運行過程,整個運算過程的中間變 量都以操作數(shù)棧的出棧、入棧為信息交換途徑,符合我們在前面分析的特點。
8.6 本章小結(jié)
本章中,我們分析了虛擬機在執(zhí)行代碼時,如何找到正確的方法,如何執(zhí)行方法內(nèi)的字節(jié)碼,以及執(zhí)行代碼時涉及的內(nèi)存結(jié)構(gòu)。在第6~8章里面,我們針對Java程序是如何存儲的、如何載入(創(chuàng)建)的,以及如何執(zhí)行的問題,把相關(guān)知識系統(tǒng)地介紹了一遍,第9章我們將一起看看這些理論知識在具體開發(fā)之中的典型應(yīng)用
補充 再看一遍JVM的結(jié)構(gòu),消化一下內(nèi)容,這節(jié)過后就是實戰(zhàn)了,堅持住
