Java 類機制(4)---- 字節碼和方法執行

前言

​ 大家好,不知不覺已經到 9 月份了,本篇文章是 Java 類機制的最後一篇,我們來一起探討一下關於 Java 的字節碼和方法調用。本篇文章參考了《深入理解 JVM 虛擬機》一書。

在開始之前我們先回顧一下在之前講過的內容,在 Java 類機制(3)---- 類文件結構 中我們解析了已經編譯好的 .class 文件的內容結構。其中包括

magicminor_versionmajor_versionmethods_countmethods 等結構。通過上篇文章我們已經知道一個 methods 結構中包含了某個方法的具體信息,其中就包含了這個方法代碼的字節碼錶示,我們再來看看 methods 的結構:

類型 名稱 數量 含義
u2 access_flag 1 方法的訪問標識
u2 name_index 1 方法名常量在常量池中的下標
u2 descriptor_index 1 方法描述常量在常量池中的下標
u2 attributes_count 1 額外屬性信息數量
attribute_info attributes attributes_count 額外屬性信息

其中,attritube_info 描述了方法的額外信息,其表結構如下:

類型 名稱 數量 含義
u2 attribute_name_index 1 屬性名在常量池中的常量下標
u4 attributes_length 1 屬性數據的長度
u1 info(這裏是統稱,實際的數據由具體的表類型決定) attributes_length 屬性數據

注意這裏的 attritube_info 描述的是一類屬性表的一般結構,並不是具體的某一個屬性表,attritube_info 表的 info 字段描述的是這個屬性表的數據,具體是何種數據需要根據 attribute_name_index 指向的常量池中的常量來決定,比如說當 attribute_name_index 指向的常量值爲 ConstantValue 時,代表這個額外屬性表的具體類型爲 ConstantValue 類型,這個類型的表結構如下:

類型 名稱 數量 含義
u2 attribute_name_index 1 屬性名在常量池中的常量下標
u4 attributes_length 1 屬性數據的長度
u1 constantValue_index attributes_length 指向常量池中下標爲 constantValue_index 的常量

對應上面的 attribute_info 表:其 info 字段在 ConstantValue 表中的具體體現即爲 constantValue_index 字段。而如果 attritube_info 表中的 attribute_name_index 字段指向的常量值爲 Code,則代表當前的屬性表的具體類型爲 Code 類型,一個方法(即 methods 結構)中是一定會有一個名爲 Code 的額外屬性表的。這個表的具體類型如下:

類型 名稱 數量 含義
u2 attribute_name_index 1 屬性表名在常量池中的常量下標
u4 attributes_length 1 屬性數據的長度
u2 max_stack 1 方法執行時操作數棧深度的最大值
u2 max_locals 1 方法執行時局部變量表所需要的存儲空間,這裏的單位 Slot,一個 Slot 可以儲存長度不超過32位的局部變量值
u4 code_length 1 代碼編譯成字節碼之後的代碼長度
u1 code code_length 代碼內容,每一個字節數據對應一個字節碼
u2 exception_table_length 1 方法的異常表長度
exception_info exception_table exception_table_length 方法拋出的異常信息
u2 attribute_count 1 額外屬性表數目
attribute_info attributes attribute_count 額外屬性表信息

這個時候 attribute_info 表中的 info 字段的具體體現即爲 Code 表中除了最上面的兩個 attribute_name_indexattributes_length 外的所有部分。

因此在 attribute_info 表中,info 字段的具體值需要確定了具體的表類型之後才能確定。故上文說 attribute_info 是描述一類表指的就是這個意思。除了 CodeConstantValue 表之外,attribute_info 還可以有其他的具體類型表,小夥伴們可以參考該系列的 上一篇文章。因爲本文討論的是字節碼和方法的執行,因此我們這篇文章的重點就是放在 Code 表上。

Code 表解析

其實我們在上一篇文章中已經分析過了 Code 表的二進制數據,解析出來的數據結構對應的就是上面列出的 Code 表,我們來詳細解釋一下其中包含的字段和相關含義:

attribute_name_index:屬性表名在常量池中的常量所在的下標,u2 類型,即爲無符號佔用 2 個字節內存空間的無符號整數。這裏其指向的常量一定爲 Code,因爲這個屬性表就是 Code 類型的表。

attribute_length:屬性數據的長度,u4 類型,即爲無符號佔用 4 個字節內存空間的無符號整數,它的值代表了這個 Code 表中除了 attribute_name_indexattribute_length 屬性之外的其餘屬性所佔用的內存空間數。

max_stack:方法的操作數棧的最大深度值,Java 方法在 JVM 中執行時採用的是棧模型,在執行字節碼時會從棧中取出對應的操作數,並將操作結果(如果有並且需要的話)壓回操作數棧中,這樣的話整個 Java 方法執行的過程就是不斷的對這個操作數棧進行入棧和出棧的過程,因此就一定會有一個操作數棧中元素最多的節點,而在這個節點的操作數棧的元素數量即爲 max_stack 的值。

max_locals:方法執行時局部變量表所需要的最大儲存空間,單位是 Slot,這裏的局部變量表儲存了方法參數,一個 Slot 可以儲存不超過 32 位的一個局部變量值,因此方法參數的個數決定了這個值的大小,如果是非靜態方法,則這個值至少爲 1(會隱式的把對應的對象引用傳入局部變量表,作爲其第一個元素),而靜態方法的 max_locals 最小值可以爲 0,因爲在靜態方法中不能訪問所在的類對象中的非靜態屬性(即 this)。

code_length:這個值表示的是真正的字節碼的數量,這是一個 u4 類型,即爲佔用 4 個字節內存空間的無符號整數,所以其可以表示的最大字節碼數量爲 2^32 - 1 個。一個 Java 方法中的代碼在編譯成字節碼後的數量幾乎不可能超過這個數。

code:這個值就是表示了具體的字節碼,類型爲 u1,即爲佔用 1 個字節內存空間的無符號整數,其可以表示的範圍爲 0~255,每一個具體的值都對應一個具體的字節碼,比如 0x00 對應的是 nop0xb1 對應的是 return。JVM 中現有的字節碼有兩百多個,但是這個值沒有超過 255。

exception_table_length:這個值表示了方法中的異常表信息的數量,類型爲 u2,即爲佔用 2 個字節內存空間的無符號整數,比如我們有如下代碼:

public void exception() {
    try {
        // do something
    } catch (Exception e) {
        // do something
    }
}

那麼這個方法在 .class 文件中的 exception_table_length 的值就爲 1,因爲我們在這裏嘗試 catch 了一個異常。

exception_table:異常表信息,標識了代碼塊中可能出現的某個異常的相關信息,這個表的數量爲 exception_table_length 的值,因爲在上一篇文章中由於篇幅原因沒有解析這個表的信息,因此我們在這裏來嘗試對 exception_table 的數據來做一個例子解析。

ExceptionTable

在開始之前我們先看看 exception_table 表的結構:

類型 名稱 含義 數量
u2 start_pc 異常判斷開始字節碼所在行 1
u2 end_pc 異常判斷結束行(不包含) 1
u2 catch_pc 嘗試捕獲的異常類型 1
u2 handler_pc 捕獲到 catch_pc 指定類型異常後轉到處理的代碼行 1

這個表的作用可以用一句話來概括:當字節碼在第 start_pc 行到 end_pc 行(不包含)之間出現了類型爲 catch_type 或者其子類的異常(catch_type 指向了一個 CONSTANT_Class_info 類型常量的索引),則轉到第 handler_pc 行繼續處理。當 catch_type 值爲 0 時,代表任何的異常情況都需要轉向到 handler_pc 處進行處理。

下面我們來通過一個小例子看一下:

public class ExceptionTest {
    
    public void exception() {
        try {
            Integer t = null;
            int tt = t;
        } catch (NullPointerException e) {
            e.printStackTrace();
        }
    }
    
    public static void main(String[] args) {
        new ExceptionTest().exception();
	}
}

這段代碼的結果顯而易見:
在這裏插入圖片描述
我們來看一下編譯出來的 .class 文件的二進制內容:
在這裏插入圖片描述
這裏我直接標出了編譯後類中的 exception 方法在 .class 文件中的二進制數據,藍色背景標註開頭的 00 0B 即爲 10 進制的 11,我們藉助 javap 工具來看一下這個類的常量池內容:
在這裏插入圖片描述
可以看到,第 11 號常量的值爲 Code,也就是說當前選中的是表示一個 Code 表類型的數據。這裏爲了方便,我直接將 Code 中的 exception_info 表的數據標註出來了,4 部分 8 個字節,對應 start_pcend_pchandler_pccatch_type ,在這裏這四個部分的 10 進制值爲 07103。對照 4 個字段的含義翻譯過來就是:如果在執行方法的第 0 行到第 10 行(不包括 10)字節碼中發生了 NullPointerException 及其子類的異常,則跳轉到第 7 行字節碼繼續執行。 既然涉及到這個方法的字節碼,那麼我們就來看一下這個方法的字節碼內容,同樣藉助 javap 工具:
在這裏插入圖片描述
結合字節碼,我們可以很容易的總結出方法的運行規則:

嘗試執行 0~6 這幾行字節碼,如果這個過程中發生了 NullPointerException 異常,則跳轉到第 10 行字節碼執行,否則會順序執行到第 7 行,這是是一個 goto 語句,直接跳轉到第 15 行,第 15 行是一個 return 指令,意味着結束方法的執行並且返回 null。如果跳轉到第 10 行,證明在 0 ~ 6 行字節碼的執行過程中發生了 NullPointerException 異常,此時會順序執行第 10,、11、12、15 行字節碼,也就是將異常信息打印後退出方法的執行。

其他信息

其實在通過 javap 工具我們就可以看到 exceptionCode 屬性的全部信息,因爲在圖中我們可以看到 Code 下方還有好幾個屬性表數據,我們來看一下:

LineNumberTable 表代表該方法 Java 代碼行數到字節碼行數的映射,比如第 10 行 Java 代碼對應的是該方法中第 0 行代碼,我們可以看一下 Java 代碼行數信息:
在這裏插入圖片描述
LocalVariableTable 表代表的是該方法的本地變量數據,方法內定義的局部變量儲存在這個表中。

StackMapTable 存儲了一些類型信息,用於提供數據給 Type Checker 檢查和處理目標方法的局部變量和操作數棧所需要的數據類型是否匹配。

我們接下來爲 exception 方法添加 finally 代碼塊,代碼如下:

public void exception() {
    try {
        Integer t = null;
        int tt = t;
    } catch (NullPointerException e) {
        e.printStackTrace();
    } finally {
        int x = 1;
    }
}

再來通過 javap 工具看一下 exception 方法的相關數據:
在這裏插入圖片描述
這裏的 ExceptionTable 發生了一些變化,下面多了兩行跳轉信息,並且 typeany,也就是說從 0 ~ 7行和12 ~ 17 行字節碼的執行過程中,無論有沒有發生異常,其都會跳轉到 22 行字節碼進行執行,那麼我們根據 finally 關鍵字的特性(無論 try 代碼塊中有沒有發生異常finally 關鍵字中的代碼塊都會被執行),就可以猜出第 22 行字節碼是 finally 代碼塊的開始,事實也確實如此:
在這裏插入圖片描述
可以看到在下面的 LineNumberTable 表中已經有 Java 代碼行數到字節碼行數的映射,而 15 對應的正是 22。

好了,到這裏我們就把方法中的 Code 表的相關信息和作用通過一個例子解析了一遍。在之後再有分析 .class 文件的內容,我們將直接藉助 javap 工具來完成。

方法的執行

從上面的內容中我們已經知道 Java 類中方法中的代碼經過編譯器編譯後會作爲字節碼儲存在 method_info 中的額外屬性 Code 表中,也就是說我們寫的 Java 代碼在虛擬機執行的時候是執行一行行的字節碼,上面我們已經瞭解過了關於字節碼的概念,我們可以把它看成 Java 語言的 “彙編指令”,每一個字節碼都有一個一個字節的數據值與其對應,相當於一個字節數據到字節碼的映射表。這樣虛擬機在碰見對應的數據值的時候就可以通過這個映射表來找到對應的字節碼並執行。因爲數據值的佔用的大小是一個字節,因此最多可以有 256 個字節碼(2^8)。

我們都知道方法代碼在線程中執行,當然線程只是一個抽象概念,真正執行代碼指令的只有 CPU,拿到這裏來說就是線程在獲取計算機相關資源的時候執行方法中的字節碼,在虛擬機中每一個線程都會有一個專有的 虛擬機棧,當線程執行一個方法時,會將保存該方法相關信息的棧幀壓入該線程的虛擬機棧中,一個棧幀包含了方法的以下信息:局部變量表(Local Variable Table)、操作數棧(Operand Stack)、動態鏈接(Dynamic Linking)、返回地址(Return Address)等。

局部變量表

局部變量表是一組變量值儲存空間,方法參數和方法內定義的變量的值都儲存在局部變量表中,在 Java 編譯器編譯 Java 文件成 Class 文件時,就在該方法的 Code 額外屬性表中確定了該方法所需要分配的局部變量表的最大容量,並儲存在 Code 表中的 max_local 字段中,我們來看看上面的方法的局部變量表信息
在這裏插入圖片描述
可以看到每一個局部變量表有 4 個屬性 startlengthslot (槽)和 namestartlength 代表變量作用域開始的字節碼行數和作用域持續行數,這樣算起來變量作用域的行數範圍就是:[start, start + length)slot 代表的是儲存該變量需要佔用的 slot 數,在局部變量表中每一個變量佔用空間不是以字節爲單位,而是以 slot 爲單位,slot 相當於對變量儲存的一個抽象,虛擬機沒有明確對應一個 slot 需要佔用多少字節的內存空間,只是規定了一個 slot 可以儲存一個 booleanbytecharshortintfloatreferencereturnAddress 類型的數據。而對於 doublelong,則需要 2 個連續的 slot 進行儲存。我們回到上圖中的局部變量表信息,發現有兩個局部變量(ttx)沒有出現在局部變量表內,這是因爲我們在代碼中只定義了這兩個變量,但是並沒有使用它們,也就是說這兩個變量是無用變量,虛擬機在執行時並不會把它們放入局部變量表中以節省空間。我們繼續看:名爲 this 的局部變量儲存在編號爲 0 的 slot 中,而 et 這兩個變量儲存在同一個 slot 中,可能有些小夥伴會問了,這是有問題嗎?其實這是由於虛擬機的一種優化機制,因爲變量 e 和變量 t 的作用域沒有重疊的部分,從 Java 代碼中看,ttry 語句中定義,而 ecatch 語句中定義,因此當變量 t 聲明週期結束了之後變量 e 就可以複用變量 tslot 了,達到節省空間的目的。

操作數棧

操作數棧是一個棧結構,也就是說它裏面的元素是先進後出的,和局部變量表一樣,操作數棧的最大深度也是在編譯的時候就決定並且寫到 Code 表中的 max_stack 屬性中了。操作數棧中的數據類型可以是任意 Java 數據類型,包括 longdouble,32 位數據類型佔用 1 個棧元素,64 爲數據類型佔用 2 個棧元素,在方法執行的任何時候,操作數棧的深度都不會超過 max_stack 數據項中設定的最大值。

動態鏈接

每一個棧幀都有一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是爲了支持方法調用過程中的動態鏈接,我們知道在 .class 文件的常量池中存在大量的符號引用(MethodRefClassRef 等),有了指向這些符號引用的數據,當在使用這些符號引用指向的類和方法時虛擬機就可以將其轉換爲直接引用,比如 invokevirtual 指令就是調用某個類對象的實例方法,虛擬機在執行這個指令時就需要知道這個指令需要調用的類和方法的直接引用,如果還沒有對應的直接引用,則需要通過指向它的符號引用來進行轉換。

方法返回地址

當一個方法開始執行後,要退出這個方法的執行有兩種方法:一種是方法在執行過程中遇到了 return 系列的指令,這種方式爲方法正常退出。另外一種是方法在執行過程中遇到了未在方法內捕獲並處理的異常,此時會導致方法異常退出,這種方式是不會給調用者返回任何返回值的。

不管是哪種退出方式,在方法退出之後,調用者都需要確定方法被調用的位置,才能繼續往下執行代碼,如果是方法正常退出,方法調用者的 PC 計數器值可以作爲返回地址,如果異常退出,則返回地址需要通過異常處理表確定。PC 計數器即爲 程序計數器,是 Java 虛擬機內存的一部分,它保存了當前線程下一條要執行指令的地址,代碼中的各種循環跳轉邏輯就是依賴程序計數器實現的。同時,一個 Java 線程中有一個 程序計數器,即 程序計數器 是線程獨立的,多個線程之間互不影響,想想也非常合理,因爲一個單核處理器每一時刻只能執行一個線程的代碼,那麼其他線程總會有休眠的時候,在線程進入休眠(失去 CPU 資源)之前通過程序計數器保存這個線程下一條要執行的指令的地址就非常重要了,在線程重新獲取 CPU 資源的時候,程序計數器可以幫助線程繼續往下執行代碼,而不用 “重新來過”。

方法執行其實就是把一個新的棧幀壓入執行這個方法的線程的虛擬機棧中,同理,方法退出也就是將這個方法的棧幀從執行這個方法的線程的虛擬機棧中移除,如果這個方法有返回值,則會把返回值壓入方法調用者的操作數棧中,調整 PC 計數器的值以指向這個方法調用指令的下一條指令以繼續執行代碼。

字節碼

我們再之前已經多次接觸過了字節碼了,也知道了字節碼的概念和意義,這裏給出 Java 虛擬機字節碼指令對照表以供使用時參考:字節碼指令對照

我們來通過一段簡單的代碼來解析一下 Java 方法的執行過程:

public int byteCode() {
    int a = 100;
    int b = 200;
    int c = a + b;
    return c;
}

通過 javap -v xxx.class 來看一下這個方法字節碼:
在這裏插入圖片描述
編譯出來的 Code 信息中告訴我們這個方法的操作數棧最大深度爲 2,需要 4 個 slot 的局部變量空間。我們一行一行指令往下看。

首先執行偏移地址爲 0 的字節碼,bipush 指令的作用是將單個字節的整型常量(-128~127)推入操作數棧頂,這個指令後跟隨一個參數,指名推送的常量值,這裏是 100。

接下來執行偏移地址爲 2 的字節碼,istore_1 指令的作用是將操作數棧頂部的整型值出棧並存入局部變量表的第一個 slot 中(注意:局部變量表的 slot 序號從 0 開始,序號爲 0 的 slot 用於存類對象的引用)。接下來的兩條指令也是做同樣的事,不過數值變成了 200,同時存入的局部變量表的 slot 變成了 2(序號爲 1 的 slot 已經存了 100 ),這裏略過。

接下來是 iload_1iload_2 指令,這兩條執行分別將局部變量表中 slot 序號爲 1 和 2 儲存的整型值複製到操作數棧頂。此時操作數棧已經有兩個整形值元素:100 和 200,其中 200 在棧頂,100 在棧底。

接下來是 iadd 指令,這條指令將操作數棧中的棧頂的兩個元素出棧,做整型加法,並將結果重新入棧,在 iadd 指令執行完成之後,操作數棧中只有一個整型元素,值爲 300。

接下來是 istore_3 指令,將操作數棧棧頂的整形元素出棧並存入局部變量表的第三個 slot 中。執行完這條指令後操作數棧爲空。

接下來是 iload_3 指令,將局部變量表中第 3 個 slot 儲存的整型值複製到操作數棧頂,執行完成後操作數棧有一個值爲 300 的整型值元素。

最後是一個 ireturn 指令,這個指令是返回系列指令之一,將操作數棧頂的整型值(這裏即爲 300)返回給此方法的調用者。方法執行結束。

從這個指令執行過程可以看出虛擬機屏蔽了底層 CPU 的運算實現細節(基於寄存器模型),向上層提供了一個基於操作數棧的指令執行模型,這樣的話一來可以統一虛擬機的指令執行機制,不管是在基於 x86 架構 CPU 的機器上還是 arm 架構 CPU 的機器上都可以獲得統一的執行效果,底層的複雜操作則由對應的虛擬機負責處理。真正做到了:“一次編譯,處處運行”。但是相對於基於寄存器模型來說,基於棧的指令執行模型也有缺點:我們可以很明顯的觀察到每次只能操作棧頂元素,如果需要操作棧頂以下的元素,則需要先將棧頂元素出棧並儲存到局部變量表中。這樣的話實現同樣的功能會多使用一些指令,從某個角度上來說犧牲了一些效率,但是從虛擬機出現的目的來說,犧牲這些效率來提供一個統一的指令執行標準是值得的。

好了,到這裏我們就將 Java 類機制介紹完了,這個系列從反射開始、到類的加載過程、再到類文件結構、最後是虛擬機的字節碼和指令執行模型,希望這一系列對你理解 Java 中的類機制會有所幫助。

如果文章中有什麼不正確的地方,請多多指點,如果覺得本篇文章對你有幫助,請不要吝嗇你的贊。

謝謝觀看。。。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章