《深刻理解Java虛擬機》讀書筆記七

第八章 虛擬機字節碼執行引擎html

一、運行時棧幀結構java

概述:git

  • 棧幀是用於支持虛擬機進行方法調用的和方法執行的數據結構,他是虛擬機運行時數據區中的虛擬機棧的棧元素,棧幀存儲了方法的局部變量,操做數棧,動態鏈接和方法返回值等信息,每一個方法從調用開始到執行完成的過程都對應着一個棧幀在虛擬機棧裏面從入棧到出棧的過程。
  • 一個線程中的方法調用鏈會很長,只有位於棧頂的棧幀纔有效,稱爲當前棧幀,與這個棧幀相關聯的方法稱爲當前方法。執行引擎運行全部字節碼指令都只針對當前棧幀進行操做。

局部變量表:github

  • 局部變量表是一組變量存儲空間,用於存放方法參數和方法內部定義的局部變量。
  • 在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計數器的值以指向方法調用指令後面的一條指令等。

附加信息:jvm

  • 虛擬機規範容許具體的虛擬機實現增長一些規範裏沒有描述的信息到棧幀之中,例如與調試相關的信息。
  • 通常會把動態鏈接、方法返回地址與其餘附加信息所有歸爲一類,稱爲棧幀信息。

二、方法調用ide

解析調用:

  • 解析就是將方法的符號引用轉化成直接引用的,解析的前提是方法須在方法運行前就肯定一個可調用的版本,而且這個版本在運行階段是不可改變的(編譯期可知,運行期不可變)。
  • 只有用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 {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }

    static class Woman extends Human {
        @Override
        protected 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的區別
    1. 從本質上講,Reflection和MethodHandle機制都是在模擬方法調用,但Reflection是在模擬Java代碼層次的方法調用,而MethodHandle是在模擬字節碼層次的方法調用。在MethodHandles.lookup中的3個方法——findStatic()、findVirtual()、findSpecial()正是爲了對應於invokestatic、invokevirtual&invokeinterface和invokespecial這幾條字節碼指令的執行權限校驗行爲,而這些底層細節在使用Reflection API時是不須要關心的。
    2. Reflection中的java.lang.reflect.Method對象遠比MethodHandle機制中的java.lang.invoke.MethodHandle對象所包含的信息多。前者是方法在Java一端的全面映像,包含了方法的簽名、描述符以及方法屬性表中各類屬性的Java端表示方式,還包含執行權限等的運行期信息。然後者僅僅包含與執行該方法相關的信息。用通俗的話來說,Reflection是重量級,而MethodHandle是輕量級。
    3. 因爲MethodHandle是對字節碼的方法指令調用的模擬,因此理論上虛擬機在這方面作的各類優化(如方法內聯),在MethodHandle上也應當能夠採用相似思路去支持(但目前實現還不完善)。而經過反射去調用方法則不行。
    4. MethodHandle與Reflection除了上面列舉的區別外,最關鍵的一點還在於去掉前面討論施加的前提「僅站在Java語言的角度來看」:Reflection API的設計目標是隻爲Java語言服務的,而MethodHandle則設計成可服務於全部Java虛擬機之上的語言,其中也包括Java語言。
  • nvokedynamic指令與MethodHandle機制的做用是同樣的,都是爲了解決原有4條「invoke*」指令方法分派規則固化在虛擬機之中的問題,把如何查找目標方法的決定權從虛擬機轉嫁到具體用戶代碼之中,讓用戶(包含其餘語言的設計者)有更高的自由度。

三、基於棧的字節碼解釋引擎

解釋執行的過程:

執行和編譯的兩種選擇:

  • 基於棧的指令集與基於寄存器的指令集
  • 基於棧的指令集主要的優勢就是可移植
  • 棧架構指令集的主要缺點是執行速度相對來講會稍慢一些由於出棧、入棧操做自己就產生了至關多的指令數量。更重要的是,棧實如今內存之中,頻繁的棧訪問也就意味着頻繁的內存訪問,相對於處理器來講,內存始終是執行速度的瓶頸。儘管虛擬機能夠採起棧頂緩存的手段,把最經常使用的操做映射到寄存器中避免直接內存訪問,但這也只能是優化措施而不是解決本質問題的方法。因爲指令數量和內存訪問的緣由,因此致使了棧架構指令集的執行速度會相對較慢。

源碼地址:

https://github.com/SaberZheng/jvm-test

推薦博客:

https://www.cnblogs.com/wade-luffy/archive/2016/11/13.html

轉載請於明顯處標明出處:

http://www.javashuo.com/article/p-czvetgax-u.html

相關文章
相關標籤/搜索