一夜搞懂 | JVM 位元組碼執行引擎

許朋友愛玩?發表於2020-04-06

前言

本文已經收錄到我的 Github 個人部落格,歡迎大佬們光臨寒舍:

我的 GIthub 部落格

學習導圖

學習導圖

一.為什麼要學習位元組碼執行引擎?

程式碼編譯的結果從本地機器碼轉變為位元組碼,是儲存格式發展的一小步,卻是程式語言發展的一大步

首先,丟擲靈魂三問:

  • 虛擬機器在執行程式碼的時候,如何找到正確的方法呢?
  • 如何執行方法內的位元組碼呢?
  • 執行程式碼時涉及的記憶體結構有哪些呢?

如果你對上述問題理解得還不是特別透徹的話,可以看下這篇文章;如果理解了,你可以關閉網頁,開啟遊戲放鬆了hhh

下面,筆者將帶你探究 JVM 核心的組成部分之一——執行引擎。

二.核心知識點歸納

2.1 概述

Q1:虛擬機器與物理機的異同

  • 相同點:都有程式碼執行能力
  • 不同點:
  • 物理機的執行引擎是直接建立在處理器、硬體、指令集和作業系統層面上的
  • 虛擬機器的執行引擎是由自定義的,可自行制定指令集與執行引擎的結構體系,且能夠執行不被硬體直接支援的指令集格式

Q2:有關 JVM 位元組碼執行引擎的概念模型

  • 外觀上:所有 JVM 的執行引擎都是一致的。輸入的是位元組碼檔案,處理的是位元組碼解析的等效過程,輸出的是執行結果

執行引擎的外觀

  • 從實現上,執行引擎有多種執行 Java 程式碼的選擇
  • 解釋執行:通過直譯器執行
  • 編譯執行:通過即時編譯器產生原生程式碼執行
  • 兩者兼備,甚至還會包含幾個不同級別的編譯器執行引擎

2.2 執行時棧幀結構

2.2.1 基本概念

筆者之前在 一文洞悉 JVM 記憶體管理機制 中就談到過虛擬機器棧,相信看過的讀者都有印象

  • 棧幀:用於支援虛擬機器進行方法呼叫和方法執行的資料結構,是虛擬機器棧的棧元素
  • 儲存內容:方法的區域性變數表、運算元棧、動態連線、方法返回地址和一些額外的附加資訊
  • 每一個方法從呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程
  • 一個棧幀需要分配多少記憶體在程式編譯期就已確定,而不會受到程式執行期變數資料的影響
  • 對於執行引擎來說,只有位於棧頂的棧幀(當前棧幀)才是有效的,即所有位元組碼指令只對當前棧幀進行操作,與當前棧幀相關聯的方法稱為當前方法

棧幀結構

2.2.2 區域性變數表

  • 定義:區域性變數表是一組變數值儲存空間
  • 作用:存放方法引數和方法內部定義的區域性變數
  • 分配時期:Java 程式編譯Class 檔案時,會在方法的 Code 屬性的 max_locals 資料項中確定了該方法所需要分配的區域性變數表的最大容量
  • 最小單位:變數槽
  • 大小:虛擬機器規範中沒有明確指明一個變數槽佔用的記憶體空間大小,允許變數槽長度隨著處理器、作業系統或虛擬機器的不同而發生變化
    1. 對於 32 位以內的資料型別(booleanbytecharshortintfloatreferencereturnAddress ),虛擬機器會為其分配一個變數槽空間
    2. 對於 64 位的資料型別(longdouble ),虛擬機器會以高位對齊的方式為其分配兩個連續的變數槽空間
  • 特點:可重用。為了儘可能節省棧幀空間,若當前位元組碼 PC 計數器的值已超出了某個變數的作用域,則該變數對應的變數槽可交給其他變數使用
  • 訪問方式:通過索引定位。索引值的範圍是從 0 開始至區域性變數表最大的變數槽數量

  • 區域性變數表第一項是名為 this 的一個當前類引用,它指向堆中當前物件的引用(由反編譯得到的區域性變數表可知)

    區域性變數表

2.2.3 運算元棧

  • 運算元棧是一個後入先出棧

  • 作用:在方法執行過程中,寫入(進棧)和提取(出棧)各種位元組碼指令

  • 分配時期:同上,在編譯時會在方法的 Code 屬性的 max_stacks 資料項中確定運算元棧的最大深度

  • 棧容量:運算元棧的每一個元素可以是任意的 Java 資料型別 ——32 位資料型別所佔的棧容量為 164 位資料型別所佔的棧容量為 2

  • 注意:運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配,在編譯時編譯器需要驗證一次、在類校驗階段的資料流分析中還要再次驗證

2.2.4 動態連線

  • 定義:每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線
  • 靜態解析和動態連線區別:

Class 檔案的常量池中存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用作為引數,這些符號引用:

  • 一部分會在類載入階段或者第一次使用的時候就轉化為直接引用(靜態解析
  • 另一部分會在每一次執行期間轉化為直接引用(動態連線

2.2.5 方法返回地址

  • 方法退出的兩種方式:
  • 正常退出:執行中遇到任意一個方法返回的位元組碼指令
  • 異常退出:執行中遇到異常、且在本方法的異常表中沒有搜尋到匹配的異常處理器區處理
  • 作用:在方法返回時都可能在棧幀中儲存一些資訊,用於恢復上層方法呼叫者的執行狀態
  • 正常退出時,呼叫者的 PC 計數器的值可以作為返回地址
  • 異常退出時,通過異常處理器表來確定返回地址
  • 方法退出的執行操作:
  • 恢復上層方法的區域性變數表和運算元棧
  • 若有返回值把它壓入呼叫者棧幀的運算元棧中
  • 調整 PC 計數器的值以指向方法呼叫指令後面的一條指令等

在實際開發中,一般會把動態連線、方法返回地址與其他附加資訊全部一起稱為棧幀資訊

2.3 方法呼叫

  • 方法呼叫是最普遍且頻繁的操作
  • 任務:確定被呼叫方法的版本,即呼叫哪一個方法,不涉及方法內部的具體執行過程

下面筆者將為大家詳細講解方法呼叫的型別

2.3.1 解析呼叫

筆者之前在 一夜搞懂 | JVM 類載入機制中就談到過解析,感覺有點混淆的,可以回去看下

  • 特點:
    1. 是靜態過程
    2. 在編譯期間就完全確定,在類裝載解析階段就會把涉及的符號引用全部轉變為可確定的直接引用,而不會延遲到執行期再去完成,即編譯期可知、執行期不變
  • 適用物件:private 修飾的私有方法,類靜態方法,類例項構造器父類方法

2.3.2 分派呼叫

Q1:什麼是靜態型別?什麼是實際型別?

A1:這個用程式碼來說比較簡便, Talk is cheap ! Show me the code !

//父類
public class Human {
}
複製程式碼
//子類
public class Man extends Human {
}
複製程式碼
public class Main {

    public static void main(String[] args) {
        //這裡的 Human 是靜態型別,Man 是實際型別
        Human man=new Man();
    }

}
複製程式碼

1.靜態分派

  • 依賴靜態型別來定位方法的執行版本
  • 典型應用是方法過載
  • 發生在編譯階段,不由 JVM 來執行

單純說未免有些許抽象,所以特地用下面的 DEMO 來幫助瞭解

public class Father {
}
public class Son extends Father {
}
public class Daughter extends Father {
}
複製程式碼
public class Hello {
    public void sayHello(Father father){
        System.out.println("hello , i am the father");
    }
    public void sayHello(Daughter daughter){
        System.out.println("hello i am the daughter");
    }
    public void sayHello(Son son){
        System.out.println("hello i am the son");
    }
}
複製程式碼
public static void main(String[] args){
    Father son = new Son();
    Father daughter = new Daughter();
    Hello hello = new Hello();
    hello.sayHello(son);
    hello.sayHello(daughter);
}
複製程式碼

輸出結果如下:

hello , i am the father

hello , i am the father

我們的編譯器在生成位元組碼指令的時候會根據變數的靜態型別選擇呼叫合適的方法。就我們上述的例子而言:

位元組碼指令呼叫情況

2.動態分派

  • 依賴動態型別來定位方法的執行版本

  • 典型應用是方法重寫

  • 發生在執行階段,由 JVM 來執行

    單純說未免有些許抽象,所以特地用下面的 DEMO 來幫助瞭解

public class Father {
    public void sayHello(){
        System.out.println("hello world ---- father");
    }
}

//繼承 + 方法重寫
public class Son extends Father {
    @Override
    public void sayHello(){
        System.out.println("hello world ---- son");
    }
}

複製程式碼
public static void main(String[] args){
    Father son = new Son();
    son.sayHello();
}

複製程式碼

輸出結果如下:

hello world ---- son

我們接著來看一下位元組碼指令呼叫情況

位元組碼指令

位元組碼指令

疑惑來了,我們可以看到,JVM 選擇呼叫的是靜態型別的對應方法,但是為什麼最終的結果卻呼叫了是實際型別的對應方法呢?

當我們將要呼叫某個型別例項的具體方法時,會首先將當前例項壓入運算元棧,然後我們的 invokevirtual 指令需要完成以下幾個步驟才能實現對一個方法的呼叫:

一夜搞懂 | JVM 位元組碼執行引擎

因此,疑惑自然解決了

3.單分派

  • 含義:根據一個宗量對目標方法進行選擇(方法的接受者與方法的引數統稱為方法的宗量)

4.多分派

  • 含義:根據多於一個宗量對目標方法進行選擇

想了解 靜態多分派,動態單分派 的可以看下這篇文章:Java 中的靜態單多分派與動態單分派

三.碎碎念

恭喜你!已經看完了前面的文章,相信你對JVM位元組碼執行引擎已經有一定深度的瞭解!你可以稍微放鬆獎勵自己一下,可以睡一個美美的覺,明天起來繼續沖沖衝!!!


如果文章對您有一點幫助的話,希望您能點一下贊,您的點贊,是我前進的動力

本文參考連結:

相關文章