《深入理解Java虚拟机》读书笔记七
第八章 虛擬機字節碼執行引擎
1、運行時棧幀結構
概述:
- 棧幀是用于支持虛擬機進行方法調用的和方法執行的數據結構,他是虛擬機運行時數據區中的虛擬機棧的棧元素,棧幀存儲了方法的局部變量,操作數棧,動態連接和方法返回值等信息,每個方法從調用開始到執行完成的過程都對應著一個棧幀在虛擬機棧里面從入棧到出棧的過程。
- 一個線程中的方法調用鏈會很長,只有位于棧頂的棧幀才有效,稱為當前棧幀,與這個棧幀相關聯的方法稱為當前方法。執行引擎運行所有字節碼指令都只針對當前棧幀進行操作。
局部變量表:
- 局部變量表是一組變量存儲空間,用于存放方法參數和方法內部定義的局部變量。
- 在Java程序編譯為class文件時就在方法的code屬性的max_locals數據項中確定該方法所需要分配的局部變量表的最大容量。局部變量表的容量已變量槽為最小單位。
- 虛擬機通過索引定位的方式使用局部變量表,索引值的范圍從0開始至局部變量表最大Slot數量。
- 在方法執行時,虛擬機是使用局部變量表完成參數值到參數變量列表的傳遞過程的,如果是實例方法(非static的方法),那么局部變量表中第0位索引的Slot默認是用于傳遞方法所屬對象實例的引用,在方法中可以通過關鍵字“this”來訪問這個隱含的參數。其余參數則按照參數表的順序來排列,占用從1開始的局部變量Slot,參數表分配完畢后,再根據方法體內部定義的變量順序和作用域分配其余的Slot。
- 局部變量表中的Slot是可重用的,方法體中定義的變量,其作用域并不一定會覆蓋整個方法體,如果當前字節碼PC計數器的值已經超出了某個變量的作用域,那么這個變量對應的Slot就可以交給其他變量使用。這樣的設計不僅僅是為了節省棧空間,在某些情況下Slot的復用會直接影響到系統的垃圾收集行為。 package com.ecut.stack;/*** -verbose:gc*/
public class SlotTest {public static void main(String[] args) {//placeholder的作用域被限制在花括號之內
{byte[] placeholder = new byte[64 * 1024 * 1024];}//如果不增加這行,即沒有任何對局部變量表的讀寫操作,placeholder原本所占用的Slot還沒有被其他變量所復用,所以作為GC Roots一部分的局部變量表仍然保持著對它的關聯。int a = 0 ;System.gc();}
}
運行結果:
[GC (System.gc()) 68864K->66256K(125952K), 0.0020403 secs] [Full GC (System.gc()) 66256K->664K(125952K), 0.0095304 secs] - 局部變量定義了但是沒有初始化時不能使用的。
操作數棧:
- 也稱為操作棧,他是一個后入先出棧的棧,同局部變量一樣,操作數棧的最大深度也在編譯的時候寫入到了code屬性的max_stacks數據中,在方法執行的任何時候,操作數棧的深度都不會超過在max_stacks數據項中設定的最大值。
- 當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法的執行過程中,會有各種字節碼指令向操作數棧中寫入和提取內容,也就是入棧出棧操作。
- Java虛擬機的解釋執行引擎稱為“基于棧的執行引擎”,其中所指的“棧”就是操作數棧。
動態連接:
- 每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支持方法調用過程中的動態連接。
- 字節碼中的方法調用指令就以常量池中指向方法的符號引用為參數,這些符號引用一部分會在類加載階段或第一次使用的時候轉化為直接引用,這種轉化稱為靜態解析。另外一部分將在每一次的運行期間轉化為直接引用,這部分稱為動態連接。
方法返回地址:
- 第一種退出方式是執行引擎遇到任意一個方法返回的字節碼指令,這時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱為調用者),是否有返回值和返回值的類型將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為正常完成出口(Normal Method Invocation Completion)。
- 另外一種退出方式是,在方法執行過程中遇到了異常,并且這個異常沒有在方法體內得到處理,無論是Java虛擬機內部產生的異常,還是代碼中使用athrow字節碼指令產生的異常,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口(Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者產生任何返回值的。
- 方法正常退出時,調用者的PC計數器的值就可以作為返回地址,棧幀中很可能會保存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器來確定的,棧幀中一般不會保存這部分信息。
- 方法退出的過程實際上等同于把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的局部變量表和操作數棧,把返回值(如果有的話)壓入調用者棧幀的操作數棧中,調整PC計數器的值以指向方法調用指令后面的一條指令等。
附加信息:
- 虛擬機規范允許具體的虛擬機實現增加一些規范里沒有描述的信息到棧幀之中,例如與調試相關的信息。
- 一般會把動態連接、方法返回地址與其他附加信息全部歸為一類,稱為棧幀信息。
2、方法調用
解析調用:
- 解析就是將方法的符號引用轉化成直接引用的,解析的前提是方法須在方法運行前就確定一個可調用的版本,并且這個版本在運行階段是不可改變的(編譯期可知,運行期不可變)。
- 只有用invokestatic和invokespecial指令調用的方法,都可以在解析階段確定調用版本,符合此條件的有靜態方法,私有方法,實例構造器和父類方法四類。它們在類加載時即把符號引用解析為該方法的直接引用.這些方法可以稱為非虛方法。
- 解析調用是一個靜態過程,編譯期間就可以確定,分派調用可能是靜態的也可能是動態的,是實現多態性的體現。
靜態分派:
package com.ecut.stack;public class StaticDispatch {static abstract class Human {}static class Man extends Human {}static class Woman extends Human {}public static void sayHello(Human guy) {System.out.println("hello guy");}public static void sayHello(Man guy) {System.out.println("hello gentleman");}public static void sayHello(Woman guy) {System.out.println("hello lady");}public static void main(String[] args) {Human man = new Man();Human woman = new Woman();sayHello(man);sayHello(woman);} }運行結果:
hello guy hello guy“Human”稱為變量的靜態類型,后面的“Man”稱為變量的實際類型。虛擬機(準確地說是編譯器)在重載時是通過參數的靜態類型而不是實際類型作為判定依據的。因此,在編譯階段,Javac編譯器會根據參數的靜態類型決定使用哪個重載版本。
所有依賴靜態類型來定位方法執行版本的分派動作稱為靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行的。
編譯器雖然能確定出方法的重載版本,但在很多情況下這個重載版本并不是“唯一的”,往往只能確定一個“更加合適的”版本。
package com.ecut.stack;import java.io.Serializable;public class Overload {public static void sayHello(Object arg) {System.out.println("hello Object");}public static void sayHello(int arg) {System.out.println("hello int");}public static void sayHello(long arg) {System.out.println("hello long");}public static void sayHello(Character arg) {System.out.println("hello Character");}public static void sayHello(char arg) {System.out.println("hello char");}public static void sayHello(char... arg) {System.out.println("hello char……");}public static void sayHello(Serializable arg) {System.out.println("hello Serializable");}public static void main(String[] args) {sayHello('a');} }運行結果:
hello char這很好理解,'a'是一個char類型的數據,自然會尋找參數類型為char的重載方法,如果注釋掉sayHello(char arg)方法,那輸出會變為:hello int這時發生了一次自動類型轉換,'a'除了可以代表一個字符串,還可以代表數字97(字符'a'的Unicode數值為十進制數字97),因此參數類型為int的重載也是合適的。我們繼續注釋掉sayHello(int arg)方法,那輸出會變為:hello long這時發生了兩次自動類型轉換,'a'轉型為整數97之后,進一步轉型為長整數97L,匹配了參數類型為long的重載。筆者在代碼中沒有寫其他的類型如float、double等的重載,不過實際上自動轉型還能繼續發生多次,按照char->int->long->float->double的順序轉型進行匹配。但不會匹配到byte和short類型的重載,因為char到byte或short的轉型是不安全的。我們繼續注釋掉sayHello(long arg)方法,那輸出會變為:hello Character這時發生了一次自動裝箱,'a'被包裝為它的封裝類型java.lang.Character,所以匹配到了參數類型為Character的重載,繼續注釋掉sayHello(Character arg)方法,那輸出會變為:hello Serializable這個輸出可能會讓人感覺摸不著頭腦,一個字符或數字與序列化有什么關系?出現hello Serializable,是因為java.lang.Serializable是java.lang.Character類實現的一個接口,當自動裝箱之后發現還是找不到裝箱類,但是找到了裝箱類實現了的接口類型,所以緊接著又發生一次自動轉型。char可以轉型成int,但是Character是絕對不會轉型為Integer的,它只能安全地轉型為它實現的接口或父類。Character還實現了另外一個接口java.lang.Comparable<Character>,如果同時出現兩個參數分別為Serializable和Comparable<Character>的重載方法,那它們在此時的優先級是一樣的。編譯器無法確定要自動轉型為哪種類型,會提示類型模糊,拒絕編譯。程序必須在調用時顯式地指定字面量的靜態類型,如:sayHello((Comparable<Character>)'a'),才能編譯通過。下面繼續注釋掉sayHello(Serializable arg)方法,輸出會變為:hello Object這時是char裝箱后轉型為父類了,如果有多個父類,那將在繼承關系中從下往上開始搜索,越接近上層的優先級越低。即使方法調用傳入的參數值為null時,這個規則仍然適用。我們把sayHello(Object arg)也注釋掉,輸出將會變為:hello char……解析與分派這兩者之間的關系并不是二選一的排他關系,它們是在不同層次上去篩選、確定目標方法的過程。例如,前面說過,靜態方法會在類加載期就進行解析,而靜態方法顯然也是可以擁有重載版本的,選擇重載版本的過程也是通過靜態分派完成的。
動態分派:
package com.ecut.stack;public class DynamicDispatch {static abstract class Human {protected abstract void sayHello();}static class Man extends Human {@Overrideprotected void sayHello() {System.out.println("man say hello");}}static class Woman extends Human {@Overrideprotected void sayHello() {System.out.println("woman say hello");}}public static void main(String[] args) {Human man = new Man();Human woman = new Woman();man.sayHello();woman.sayHello();man = new Woman();man.sayHello();} }運行結果如下:
man say hello woman say hello woman say hello使用javap -verbose DynamicDispatch .class命令
invokevirtual指令的運行時解析過程大致分為以下幾個步驟:
- 找到操作數棧頂的第一個元素所指向的對象的實際類型,記作C。
- 如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,則返回java.lang.IllegalAccessError異常。
- 否則,按照繼承關系從下往上依次對C的各個父類進行第2步的搜索和驗證過程。
- 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
由于invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,所以兩次調用中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。我們把這種在運行期根據實際類型確定方法執行版本的分派過程稱為動態分派。
單分派與多分派:
- 方法的接收者與方法的參數統稱為方法的宗量
- 根據分派基于多少種宗量,可以將分派劃分為單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多于一個宗量對目標方法進行選擇。 package com.ecut.stack;public class Dispatch {static class QQ {}static class _360 {}public static class Father {public void hardChoice(QQ arg) {System.out.println("father choose qq");}public void hardChoice(_360 arg) {System.out.println("father choose 360");}}public static class Son extends Father {public void hardChoice(QQ arg) {System.out.println("son choose qq");}public void hardChoice(_360 arg) {System.out.println("son choose 360");}}public static void main(String[] args) {Father father = new Father();Father son = new Son();father.hardChoice(new _360());son.hardChoice(new QQ());}
}
運行結果如下:
father choose 360 son choose qq我們來看看編譯階段編譯器的選擇過程,也就是靜態分派的過程。這時選擇目標方法的依據有兩點:一是靜態類型是Father還是Son,二是方法參數是QQ還是360。這次選擇結果的最終產物是產生了兩條invokevirtual指令,兩條指令的參數分別為常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符號引用。因為是根據兩個宗量進行選擇,所以Java語言的靜態分派屬于多分派類型。
再看看運行階段虛擬機的選擇,也就是動態分派的過程。在執行“son.hardChoice(new QQ())”這句代碼時,更準確地說,是在執行這句代碼所對應的invokevirtual指令時,由于編譯期已經決定目標方法的簽名必須為hardChoice(QQ),虛擬機此時不會關心傳遞過來的參數“QQ”到底是“騰訊QQ”還是“奇瑞QQ”,因為這時參數的靜態類型、實際類型都對方法的選擇不會構成任何影響,唯一可以影響虛擬機選擇的因素只有此方法的接受者的實際類型是Father還是Son。因為只有一個宗量作為選擇依據,所以Java語言的動態分派屬于單分派類型。
虛擬機動態分派的實現:
- 由于動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要運行時在類的方法元數據中搜索合適的目標方法,因此在虛擬機的實際實現中基于性能的考慮,大部分實現都不會真正地進行如此頻繁的搜索。面對這種情況,最常用的“穩定優化”手段就是為類在方法區中建立一個虛方法表(Vritual Method Table,也稱為vtable,與此對應的,在invokeinterface執行時也會用到接口方法表——Inteface Method Table,簡稱itable),使用虛方法表索引來代替元數據查找以提高性能。
- 虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表里面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了這個方法,子類方法表中的地址將會替換為指向子類實現版本的入口地址。Son重寫了來自Father的全部方法,因此Son的方法表沒有指向Father類型數據的箭頭。但是Son和Father都沒有重寫來自Object的方法,所以它們的方法表中所有從Object繼承來的方法都指向了Object的數據類型。
- 方法表一般在類加載的連接階段進行初始化,準備了類的變量初始值后,虛擬機會把該類的方法表也初始化完畢。
動態類型語言支持:
- 動態類型語言的關鍵特征是它的類型檢查的主體過程是在運行期而不是編譯期,滿足這個特征的語言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等。
- 相對的,在編譯期就進行類型檢查過程的語言(如C++和Java等)就是最常用的靜態類型語言。
-
JDK 1.7實現了JSR-292,新加入的java.lang.invoke包。這個包的主要目的是在之前單純依靠符號引用來確定調用的目標方法這種方式以外,提供一種新的動態確定目標方法的機制,稱為MethodHandle。
package com.ecut.stack;import static java.lang.invoke.MethodHandles.lookup; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodType; public class MethodHandleTest{static class ClassA{public void println(String s){System.out.println(s);}}public static void main(String[] args)throws Throwable{Object obj=System.currentTimeMillis()%2==0?System.out:new ClassA();/*無論obj最終是哪個實現類,下面這句都能正確調用到println方法*/getPrintlnMH(obj).invokeExact("MethodHandleTest");}private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable{/*MethodType:代表“方法類型”,包含了方法的返回值(methodType()的第一個參數)和具體參數(methodType()第二個及以后的參數)*/MethodType mt=MethodType.methodType(void.class,String.class);/*lookup()方法來自于MethodHandles.lookup,這句的作用是在指定類中查找符合給定的方法名稱、方法類型,并且符合調用權限的方法句柄因為這里調用的是一個虛方法,按照Java語言的規則,方法第一個參數是隱式的,代表該方法的接收者,也即是this指向的對象,這個參數以前是放在參數列表中進行傳遞的,而現在提供了bindTo()方法來完成這件事情*/return lookup().findVirtual(reveiver.getClass(),"println",mt).bindTo(reveiver);} }MethodHandle的基本用途,無論obj是何種類型(臨時定義的ClassA抑或是實現PrintStream接口的實現類System.out),都可以正確地調用到println()方法。
- MethodHandle與Reflection的區別
- 從本質上講,Reflection和MethodHandle機制都是在模擬方法調用,但Reflection是在模擬Java代碼層次的方法調用,而MethodHandle是在模擬字節碼層次的方法調用。在MethodHandles.lookup中的3個方法——findStatic()、findVirtual()、findSpecial()正是為了對應于invokestatic、invokevirtual&invokeinterface和invokespecial這幾條字節碼指令的執行權限校驗行為,而這些底層細節在使用Reflection API時是不需要關心的。
- Reflection中的java.lang.reflect.Method對象遠比MethodHandle機制中的java.lang.invoke.MethodHandle對象所包含的信息多。前者是方法在Java一端的全面映像,包含了方法的簽名、描述符以及方法屬性表中各種屬性的Java端表示方式,還包含執行權限等的運行期信息。而后者僅僅包含與執行該方法相關的信息。用通俗的話來講,Reflection是重量級,而MethodHandle是輕量級。
- 由于MethodHandle是對字節碼的方法指令調用的模擬,所以理論上虛擬機在這方面做的各種優化(如方法內聯),在MethodHandle上也應當可以采用類似思路去支持(但目前實現還不完善)。而通過反射去調用方法則不行。
- MethodHandle與Reflection除了上面列舉的區別外,最關鍵的一點還在于去掉前面討論施加的前提“僅站在Java語言的角度來看”:Reflection API的設計目標是只為Java語言服務的,而MethodHandle則設計成可服務于所有Java虛擬機之上的語言,其中也包括Java語言。
- nvokedynamic指令與MethodHandle機制的作用是一樣的,都是為了解決原有4條“invoke*”指令方法分派規則固化在虛擬機之中的問題,把如何查找目標方法的決定權從虛擬機轉嫁到具體用戶代碼之中,讓用戶(包含其他語言的設計者)有更高的自由度。
3、基于棧的字節碼解釋引擎
解釋執行的過程:
執行和編譯的兩種選擇:
- 基于棧的指令集與基于寄存器的指令集
- 基于棧的指令集主要的優點就是可移植
- 棧架構指令集的主要缺點是執行速度相對來說會稍慢一些,因為出棧、入棧操作本身就產生了相當多的指令數量。更重要的是,棧實現在內存之中,頻繁的棧訪問也就意味著頻繁的內存訪問,相對于處理器來說,內存始終是執行速度的瓶頸。盡管虛擬機可以采取棧頂緩存的手段,把最常用的操作映射到寄存器中避免直接內存訪問,但這也只能是優化措施而不是解決本質問題的方法。由于指令數量和內存訪問的原因,所以導致了棧架構指令集的執行速度會相對較慢。
源碼地址:
https://github.com/SaberZheng/jvm-test
推薦博客:
https://www.cnblogs.com/wade-luffy/archive/2016/11/13.html
轉載請于明顯處標明出處:
https://www.cnblogs.com/AmyZheng/p/10548753.html
轉載于:https://www.cnblogs.com/AmyZheng/p/10548753.html
總結
以上是生活随笔為你收集整理的《深入理解Java虚拟机》读书笔记七的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Win7环境配置Oracle 11g安装
- 下一篇: Java 学习笔记 反射与迭代器