天天看點

從原理聊JVM(四):JVM中的方法調用原理

1 引言

多态是Java語言極為重要的一個特性,可以說是Java語言動态性的根本,那麼線程執行一個方法時到底在記憶體中經曆了什麼,JVM又是如何确定方法執行版本的呢?

2 棧幀

JVM中由棧幀存儲方法的局部變量表、操作數棧、動态連接配接和方法傳回位址等資訊。每一個方法的調用就是從入棧到出棧到過程。

從原理聊JVM(四):JVM中的方法調用原理

2.1 局部變量表

局部變量表由變量槽組成,《Java虛拟機規範》指出:“每個變量槽都應該能存放一個boolean、byte、char、short、int、float、reference或returnAddress類型的資料”。

這八種資料類型都可以使用32位或更小的實體記憶體來存儲,如果是64位虛拟機環境下,虛拟機需要通過對齊填充來使變量槽與在32位虛拟機環境下外觀一緻。

如果是64位的資料類型,比如long和double,JVM會以高位對齊的方式為其配置設定兩個連續的變量槽空間。且規定不允許以任何方式通路這兩個變量槽的其中一個,類加載的校驗階段會針對違反規定的行為抛出異常。

類變量會有兩次指派,一次是準備階段給指派一個預設值,二是初始化階段,賦予程式定義的值。但方法變量沒有準備階段,是以沒指派的方法變量不能被使用。

2.2 變量槽的複用

為了節省記憶體空間,變量槽是可以複用的。當程式計數器的值超過方法體中定義的變量的作用域時,這個變量的變量槽就可以被其他變量複用了。不過雖然這樣可以節省記憶體空間,但對GC有一定影響。

舉個例子,如果沒有發生即時編譯的前提下,在方法清單1中placeholder不會被回收。原因是,方法清單1中gc發生時,變量槽仍然保持着對placeholder的引用,是以不會被标記為可回收對象。而在方法清單2中國呢增加了int a = 0後,placeholder原有的變量槽被變量a複用了,也就不存在引用placeholder的變量槽了,是以placeholder就可以被回收了。

方法清單1:

public static void main (String[] args) {
  {    
    byte[] placeholder = new byte[64 * 1024 * 1024];
  }  
  System.gc();
}           

方法清單2:

public static void main (String[] args) {
  {
    byte[] placeholder = new byte[64 * 1024 * 1024];
  }  
  int a = 0;  
  System.gc();
}           

但是實際上,大部分程式都是運作在即時編譯下的,是以編譯器會對其進行優化,實際情況下方法清單1中placeholder也能被回收。

2.3 操作數棧

操作數棧主要作用有二:

1.作為計算過程中的所需變量的臨時存儲空間

2.存儲系統運作過程中的計算中間結果

操作數棧不能通過指針通路,隻能通過彈棧和壓棧來操作其内部元素。當執行某項指令前會将所需變量壓入棧頂,然後真正執行指令時從棧頂依次取出用來執行具體指令,執行完成後會将結果在壓入操作數棧。

大多數虛拟機實作會有一些優化處理,将兩個棧幀部分重疊:上一個棧幀的部分操作數棧和下一個棧幀的部分局部變量表。不僅節約空間,還讓下面棧的操作可以直接使用上面棧的内容,減少了參數傳遞。

2.4 動态連結

Java檔案被編譯成Class檔案後,變量和方法的引用都作為符号引用儲存在Class檔案中的常量池中。而對于方法的引用,某些可以在編譯期就确定下來稱為“直接引用”,而有些方法隻能在運作期才能确定下來(比如方法的重載)。

動态連結的作用就是在運作期将符号引用轉換為直接引用。

2.5 方法傳回位址

一個方法執行完成後,有兩種方式退出:正常完成和抛出異常。

當方法A中調用方法B時,A的棧幀中會儲存程式計數器的值作為傳回位址。而異常退出時,傳回位址是要通過異常處理器表來确定的。

方法傳回後還會進行幾個操作:

1.恢複主調線程對應棧幀中的局部變量表和操作數棧

2.把傳回值壓入主調線程的棧幀中

3.調整程式計數器到方法調用指令的下一條指令

2.6 附加資訊

不同虛拟機在實作時可以自定義一些例如調試、性能收集等資訊放到棧幀之中。

3 方法調用

一切方法調用在Class檔案裡面存儲的都隻是符号引用,某些調用需要在類加載時甚至運作期間才能确定目标方法的直接引用,這是Java強大的動态擴充能力的基礎。

3.1 方法調用指令

JVM共支援以下5種方法調用位元組碼指令:

•invokestatic調用靜态方法

•invokespecial調用構造器<init>()方法、私有方法和父類中的方法

•invokevirtual調用所有虛方法

•invokeinterface調用接口方法,運作期會确定具體實作該接口的對象

•invokedynamic調用運作期動态解析出具體調用的方法

其中,invokestatic和invokespecial指令調用的方法,都可以類加載的解析階段确定調用的方法版本,Java中符合這個條件的方法共有五種:靜态方法、私有方法、執行個體構造器、父類方法和final修飾的方法(它使用invokevirtual指令調用)。

這五種方法稱為“非虛方法”(Non-Virtual Method),剩下的均為“虛方法”(Virtual Method)。

3.2 解析

如果一個方法在類加載的解析階段就能确定方法的調用版本,那麼這類方法的調用被稱為解析(Resolution)。

Java中符合解析标準的主要是靜态方法和私有方法。前者與類型直接相關,後者對外不可見。

方法調用指令中,invokestatic和invokespecial指令調用的方法,再加上final修飾的方法,都被稱作“非虛方法”,他們都可以在解析階段确定唯一的調用版本。其他的方法都被稱作“虛方法”。

3.3 分派

在編譯階段,依賴靜态類型确定方法的調用版本,這就叫做“靜态分派”。

而在運作期,根據實際類型确定方法調用版本被稱作“動态分派”。

3.3.1 靜态分派

直接上個:

public class StaticDispatch {
  
  static abstract class Human {}
  
  static class Man extends Human {}
  
  static class Woman extends Human {}
  
  public void sayHello (Human guy) {
    System.out.println("hello,guy!");
  }
  public void sayHello (Man guy) {
    System.out.println("hello,gentleman!");
  }
  public void sayHello (Woman guy) {
    System.out.println("hello,lady!");
  }
  public static void main (String[] args) {
    StaticDispatch sd = new StaticDispatch();
    Human man = new Man();
    Human woman = new Woman();
    sd.sayHello(man);
    sd.sayHello(woman);
  }
}           

輸出如下:

hello,guy!
hello,guy!           

首先介紹一下靜态類型和實際類型。

當聲明變量man時:

Human man = new Man();           

其中Human被稱為“靜态類型”,Man和Woman叫做“實際類型”。靜态類型在編譯期可知,實際類型需要等到運作期才能确定。

回到上面的示例,為什麼兩次輸出都是hello,guy!呢?

這是因為編譯器在重載時是通過參數的靜态類型而不是實際類型作為判定依據的。是以上面代碼示例中,多次不同對象調用sayHello()均找到各自的靜态類型對應的方法版本,即sayHello (Human guy)。

方法版本的選擇順序

事實上,雖然編譯器能确定方法的重載版本,但往往這并不是唯一的,僅僅隻能确定一個“相對更合适的“版本。

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           

如果删除掉sayHello (char arg)方法,則會比對到sayHello (int arg)方法,編譯器比對的轉型順序是char > int > long > float > double,但不會比對到byte和short類型的重載,因為char到byte或short的轉型是不安全的。

3.3.2 動态分派

差別于靜态分派,如果需要在運作期根據對象類型來确定方法版本,則屬于動态分派。

public class DynamicDispatch {
    static abstract class Human {
        public void speak() {
            System.out.println("I'm human");
        }
    }
    static class Man extends Human {
        @Override
        public void speak() {
            System.out.println("I'm man");
        }
    }
    static class Woman extends Human {
        @Override
        public void speak() {
            System.out.println("I'm woman");
        }
    }
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.speak();
        woman.speak();
	      man = new Woman();
        man.speak();
    }
}           

輸出如下:

I'm man
I'm woman
I'm woman           

在上面的例子中,對于speak()方法的調用在編譯期完全無法确定,是以隻能動态比對對應的方法版本。那麼JVM是如何進行比對呢?答案就是invokevirtual指令。

invokevirtual指令運作過程

Java做到多态的根本原因是invokevirtual指令的執行邏輯,該指令的運作過程如下:

1.找到操作數棧頂元素的對象的實際類型,記作C。

2.如果在C中找到與常量中描述符和簡單名稱都一樣的方法,則進行通路權限校驗,通過則傳回方法的直接引用,否則抛出異常java.lang.IllegalAccessError。

3.否則,按照繼承關系從下往上依次尋找C類的父類,來進行第二步中的方法查找和校驗。

4.最後仍未能找到方法則抛出異常java.lang.AbastractMethodError。

注意:由此也能看出,Java中隻有虛方法,沒有“虛字段”,如果子類和父類存在相同名稱的字段,子類中的字段會覆寫父類中的字段。

3.3.3 靜态分派和動态分派的對比

分派類型 原理 發生階段 應用場景
靜态分派 根據靜态類型判斷方法版本 編譯期 重載
動态分派 根據實際類型判斷方法版本 運作期 重寫

作者:京東科技 康志興

來源:京東雲開發者社群

繼續閱讀