一篇文章帶你搞懂JVM的記憶體結構(執行時數據區)

2020-08-07 21:50:34

JVM 執行時數據區 簡介

在这里插入图片描述

  • 當位元組碼通過前面的:類的載入-> 驗證 -> 準備 -> 解析 -> 初始化 這幾個階段完成後,就會用到執行引擎對類進行使用,同時執行引擎將會使用到我們執行時數據區
    在这里插入图片描述

  • 記憶體是非常重要的系統資源,是硬碟和CPU的中間倉庫及橋樑,承載着操作系統和應用程式的實時執行 JVM 記憶體佈局規定了Java在執行過程中記憶體申請分配管理的策略,保證了 JVM 的高效穩定執行。

  • 不同的JVM對於記憶體的劃分方式和管理機制 機製存在着部分差異。結合JVM虛擬機器規範,來探討一下經典的JVM記憶體佈局。

我們通過磁碟或者網路 IO 得到的數據,都需要先載入到記憶體中,然後CPU從記憶體中獲取數據進行讀取,也就是說記憶體充當了CPU和磁碟之間的橋樑

在这里插入图片描述

  • JVM 的執行時數據區包括:堆區、方法區(上圖的元數據區和JIT編譯產物)、程式計數器、本地方法棧、虛擬機器棧
  • 我們常說的JVM調優,90%都是在對堆區進行調優
  • Java 虛擬機器定義了若幹種程式執行期間會使用到的執行時數據區,其中有一些會隨着虛擬機器啓動而建立,隨着虛擬機器退出而銷燬,即生命週期和虛擬機器一致。另外一些則是與執行緒一一對應的,這些與執行緒對應的數據區域會隨着執行緒開始和結束而建立和銷燬,即生命週期與執行緒一致。
  • 其中,每個執行緒獨享的有:獨立包括程式計數器、棧、本地棧。(圖中灰色塊)
  • 執行緒之間共用的有:堆、堆外記憶體(永久代或元空間、程式碼快取)。(圖中紅色塊)
    在这里插入图片描述

執行緒簡介

  • 執行緒是一個程式裡的執行單元。JVM 允許一個應用有多個執行緒並行的執行
  • 在 Hotspot JVM 裡,每個執行緒都與操作系統的本地執行緒直接對映
  • 當一個 Java執行緒 準備好執行以後,此時一個操作系統的本地執行緒也同時建立。Java執行緒執行終止後,本地執行緒也會回收,資源也會被釋放。
  • 操作系統負責所有執行緒的安排排程到任何一個可用的 CPU 上。一旦本地執行緒初始化成功,它就會呼叫 Java 執行緒中的run()方法。
  • 如果run()方法能夠正常執行完,以及對產生的異常能夠進行處理,就算是正常的執行完畢;否則就終止 Java執行緒
  • 當終止 Java執行緒之後,本地執行緒還需要判斷需不需要終止 JVM,要判斷當前的執行緒是不是最後一個非守護執行緒,如果當前程式只剩下守護執行緒了,就可以終止退出了。

JVM 系統執行緒

  • 如果你使用 jconsole 或者是任何一個偵錯工具,都能看到在後台有許多執行緒在執行。
  • 這些後臺執行緒不包括呼叫public static void main(String[])main執行緒以及所有這個main執行緒自己建立的執行緒。
  • 這些主要的後臺系統執行緒在Hotspot JVM裡主要是以下幾個:
    • 虛擬機器執行緒:這種執行緒的操作是需要 JVM 達到安全點纔會出現。這些操作必須在不同的執行緒中發生的原因是他們都需要 JVM 達到安全點,這樣堆纔不會變化。這種執行緒的執行型別包括"stop-the-world"的垃圾收集,執行緒棧收集,執行緒掛起以及偏向鎖復原。
    • 週期任務執行緒:這種執行緒是時間週期事件的體現(比如中斷),他們一般用於週期性操作的排程執行。
    • GC執行緒:這種執行緒對在 JVM 裡不同種類的垃圾收集行爲提供了支援。
    • 編譯執行緒:這種執行緒在執行時會將位元組碼編譯成到原生代碼。
    • 信號排程執行緒:這種執行緒接收信號併發送給 JVM,在它內部通過呼叫適當的方法進行處理。

程式計數器(Program Counter 暫存器)

在这里插入图片描述

  • JVM中的程式計數暫存器Program Counter Register)中,Register的命名源於CPU的暫存器,暫存器儲存指令相關的現場資訊。
  • CPU只有把數據裝載到暫存器才能 纔能夠執行。
  • 這裏的暫存器,並非是廣義上所指的物理暫存器,或許將其翻譯爲 PC計數器(或指令計數器)會更加貼切(也稱爲程式勾點),並且也不容易引起一些不必要的誤會。
  • JVM中的PC暫存器是對物理PC暫存器的一種抽象模擬

作用

  • PC暫存器儲存指向下一條指令的地址,也就是將要執行的指令程式碼,再由執行引擎讀取下一條指令。
    在这里插入图片描述

介紹

  • 它是一塊很小的記憶體空間,幾乎可以忽略不記。也是執行速度最快的儲存區域。
  • 在JVM規範中,每個執行緒都有它自己的程式計數器,是執行緒私有的,生命週期與執行緒的生命週期保持一致。
  • 任何時間一個執行緒都只有一個方法在執行,也就是所謂的當前方法(上圖中的當前棧幀)。程式計數器會儲存當前執行緒正在執行的Java方法的JVM指令地址;或者,如果是在執行native方法,則是未指定值undefined)。
  • 它是程式控制流的指示器,分支、回圈、跳轉、例外處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。
  • 位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令
  • 它是唯一一個在 Java虛擬機器規範 中沒有規定任何OutOfMemoryError情況的區域,也沒有GC垃圾回收機制 機製作用。即:PC暫存器既沒有OOM,也沒有GC。

舉例演示

public class PCRegisterTest {
    public static void main(String[] args) {
        int i = 10;
        int j = 20;
        int k = i + j;
    }
}

將程式碼編譯成位元組碼檔案,使用 jclasslib 檢視;在位元組碼的左邊有一個行號標識,它其實就是指令地址,用於指向當前執行到哪裏;右邊就是操作指令

// 拿到 10
0: bipush        10
// 存入索引爲 1 的地方
2: istore_1
// 拿到 20
3: bipush        20
// 存入索引爲 2 的地方
5: istore_2
// 載入索引爲 1 的值
6: iload_1
// 載入索引爲 2 的值
7: iload_2
// 執行加法運算
8: iadd
// 放入索引爲 3 的地方
9: istore_3
// 返回
10: return

在这里插入图片描述

可以看到有一些操作指令的後面有一個#2這樣的字元,這就是對應的參照物件地址,表示下一步從常數池中的#2這個地方獲取值

在这里插入图片描述

面試考點

使用PC暫存器儲存位元組碼指令地址有什麼用呢?

  1. 因爲 CPU 需要不停的切換各個執行緒,這時候切換回來以後,就得知道接着從哪開始繼續執行。
  2. JVM的 位元組碼直譯器 就需要通過改變 PC暫存器 的值來明確下一條應該執行什麼樣的位元組碼指令。

PC暫存器爲什麼被設定爲私有的?

  1. 我們都知道所謂的多執行緒在一個特定的時間段內只會執行其中某一個執行緒的方法(併發和並行的區別),CPU會不停地做工作切換,這樣必然導致經常中斷或恢復,如何保證分毫無差呢?爲了能夠準確地記錄各個執行緒正在執行的當前位元組碼指令地址,最好的辦法自然是爲每一個執行緒都分配一個PC暫存器,這樣一來各個執行緒之間便可以進行獨立計算,從而不會出現相互幹擾的情況。
  2. 由於 CPU時間片 限制,衆多執行緒在併發執行過程中,任何一個確定的時刻,一個處理器或者多核處理器中的一個內核,只會執行某個執行緒中的一條指令。
  3. 這樣必然導致經常中斷或恢復,如何保證分毫無差呢?每個執行緒在建立後,都會產生自己的程式計數器和棧幀,程式計數器在各個執行緒之間互不影響。

CPU 時間片

  1. CPU時間片 即 CPU分配給各個程式的時間,每個執行緒被分配一個時間段,稱作它的時間片。
  2. 在宏觀上:俄們可以同時開啓多個應用程式,每個程式並行不悖,同時執行。
  3. 但在微觀上:由於只有一個CPU,一次只能處理程式要求的一部分,如何處理公平,一種方法就是引入時間片,每個程式輪流執行。
  4. 併發即一段時間內有多個任務進行;並行即這一時刻有多個任務同時在進行,對應序列.

在这里插入图片描述

虛擬機器棧

  • 由於跨平臺性的設計,Java的指令都是根據棧來設計的。不同平臺CPU架構不同,所以不能設計爲基於暫存器的。
  • 基於棧結構的【優點】是跨平臺,指令集小,編譯器容易實現;【缺點】是效能下降,實現同樣的功能需要更多的指令。
    在这里插入图片描述

棧和堆的區別

棧是執行時的單位,而堆是儲存的單位

  • 棧解決程式的執行問題,即程式如何執行,或者說如何處理數據。
  • 堆解決的是數據儲存的問題,即數據怎麼放,放哪裏

在这里插入图片描述

  • 當然這也不是絕對的,大部分的數據也是放在堆區(物件),但是棧區也會存放一些數據(基本數據型別的區域性變數,參照數據型別的地址)

Java 虛擬機器棧

  • Java虛擬機器棧(Java Virtual Machine Stack),早期也叫 Java棧。每個執行緒在建立時都會建立一個虛擬機器棧,其內部儲存一個個的棧幀Stack Frame),對應着一次次的 Java方法 呼叫。
  • 執行緒私有的,生命週期和執行緒一致

在这里插入图片描述

  • 作用】主管Java程式的執行,它儲存方法的區域性變數(8中基本數據型別、物件的參照地址)、部分結果,並參與方法的呼叫和返回。

棧的特點,爲什麼要使用棧結構呢?

  • 棧是一種快速有效的分配儲存方式,存取速度僅次於程式計數器。
  • JVM 直接對Java棧的操作只有兩個:
    • 每個方法執行,伴隨着進棧(入棧、壓棧)
    • 執行結束後的出棧工作
  • 對於棧來說,不存在垃圾回收問題,但是存在OOM記憶體溢位問題。

在这里插入图片描述

開發過程中,在棧中可能出現的異常

Java 虛擬機器規範允許Java棧的大小是動態的或者是固定不變的

  • 如果採用固定大小的Java虛擬機器棧,那每一個執行緒的Java虛擬機器棧容量可以線上程建立的時候獨立選定。如果執行緒請求分配的棧容量超過Java虛擬機器棧允許的最大容量,Java虛擬機器將會拋出一個StackOverflowError 異常。常見的就是遞回:當遞回層次非常多,或遞回沒有出口的時候,就會產生這個異常。
  • 如果Java虛擬機器棧可以動態擴充套件,並且在嘗試擴充套件的時候無法申請到足夠的記憶體,或者在建立新的執行緒時沒有足夠的記憶體去建立對應的虛擬機器棧,那Java虛擬機器將會拋出一個 OutOfMemoryError 異常。

【拓展】怎麼設定棧記憶體大小?

  • 官網介紹:https://docs.oracle.com/en/java/javase/11/tools/java.htmlMain Tools to Create and Build Applications -> java -> java Command-Line Argument Files -> 搜尋 -Xss
    在这里插入图片描述

  • 我們可以使用參數 -Xss 選項來設定執行緒的最大棧空間,棧的大小直接決定了函數呼叫的最大可達深度

-Xss1m
-Xss1k

// 其他命令
-Xms設定堆的最小空間大小。
-Xmx設定堆的最大空間大小。
-XX:NewSize設定新生代最小空間大小。
-XX:MaxNewSize設定新生代最大空間大小。
-XX:PermSize設定永久代最小空間大小。
-XX:MaxPermSize設定永久代最大空間大小。
-Xss設定每個執行緒的堆疊大小。

// 可以通過輸出遞回層數來檢視是否設定成功
// count 記錄層數
public class StackErrorTest {
    private static int count = 1;
    public static void main(String[] args) {
        System.out.println(count++);
        main(args);
    }
}

棧的儲存單位:棧幀

  • 每個執行緒都有自己的棧,棧中的數據都是以棧幀Stack Frame)的格式存在。
  • 在這個執行緒上正在執行的每個方法都各自對應一個棧幀(Stack Frame)。
  • 棧幀是一個記憶體區塊,是一個數據集,維繫着方法執行過程中的各種數據資訊。

棧執行原理

  • JVM直接對Java棧的操作只有兩個,就是對棧幀的壓棧和出棧,遵循先進後出 / 後進先出原則。

  • 在一條活動執行緒中,一個時間點上,只會有一個活動的棧幀。即只有當前正在執行的方法的棧幀(棧頂棧幀)是有效的,這個棧幀被稱爲當前棧幀Current Frame),與當前棧幀相對應的方法就是當前方法Current Method),定義這個方法的類就是當前類Current Class)。

  • 執行引擎執行的所有位元組碼指令只針對當前棧幀進行操作。

  • 如果在該方法中呼叫了其他方法,對應的新的棧幀會被建立出來,放在棧的頂端,成爲新的當前幀。

在这里插入图片描述

程式碼演示

public class StackFrameTest {
    public static void main(String[] args) {
        method01();
    }

    private static int method01() {
        System.out.println("方法1的開始");
        int i = method02();
        System.out.println("方法1的結束");
        return i;
    }

    private static int method02() {
        System.out.println("方法2的開始");
        int i = method03();;
        System.out.println("方法2的結束");
        return i;
    }
    private static int method03() {
        System.out.println("方法3的開始");
        int i = 30;
        System.out.println("方法3的結束");
        return i;
    }
}
/* 輸出結果 
方法1的開始
方法2的開始
方法3的開始
方法3的結束
方法2的結束
方法1的結束
*/
  • 不同線程中所包含的棧幀是不允許存在相互參照的,即不可能在一個棧幀之中參照另外一個執行緒的棧幀

  • 如果當前方法呼叫了其他方法,方法返回之際,當前棧幀會傳回此方法的執行結果給前一個棧幀,接着,虛擬機器會丟棄當前棧幀,使得前一個棧幀重新成爲當前棧幀。

  • Java方法有兩種返回函數的方式,一種是正常的函數返回,使用return指令;另外一種是拋出異常。不管使用哪種方式,都會導致棧幀被彈出

棧幀的內部結構

每個棧幀中儲存着:

  • 區域性變數表Local Variables
  • 運算元棧Operand Stack)(或表達式棧)
  • 動態鏈接(DynamicLinking)(或指向執行時常數池的方法參照)
  • 方法返回地址(Return Address)(或方法正常退出或者異常退出的定義)
  • 一些附加資訊

並行每個執行緒下的棧都是私有的,因此每個執行緒都有自己各自的棧,並且每個棧裏面都有很多棧幀,棧幀的大小主要由 區域性變數表 和 運算元棧決定的,棧幀的大小也影響了棧能存放的棧幀的個數。

在这里插入图片描述

區域性變數表

區域性變數表:Local Variables,被稱之爲區域性變數陣列本地變數表

  • 定義爲一個數位陣列,主要用於儲存方法參數和定義在方法體內的區域性變數,這些數據型別包括各類基本數據型別、物件參照(reference),以及ReturnAddress型別。
    【一些基本數據型別可以轉換成數位,然後存入】

  • 由於區域性變數表是建立線上程的棧上,是執行緒的私有數據,因此不存在數據安全問題

  • 區域性變數表所需的容量大小是在編譯期確定下來的,並儲存在方法的Code屬性的maximum local variables數據項中。在方法執行期間是不會改變 區域性變數表的大小的。

在这里插入图片描述

  • 方法巢狀呼叫的次數由棧的大小決定。一般來說,棧越大,方法巢狀呼叫次數越多。對一個函數而言,它的參數和區域性變數越多,使得區域性變數表膨脹,它的棧幀就越大,以滿足方法呼叫所需傳遞的資訊增大的需求。進而函數呼叫就會佔用更多的棧空間,導致其巢狀呼叫次數就會減少。

  • 區域性變數表中的變數只在當前方法呼叫中有效。在方法執行時,虛擬機器通過使用區域性變數表完成參數值到參數變數列表的傳遞過程。當方法呼叫結束後,隨着方法棧幀的銷燬,區域性變數表也會隨之銷燬

理解 Slot(變數槽) ---- 區域性變數表的最基本的儲存單位

  • 參數值的存放總是在區域性變數陣列的index0開始,到陣列長度-1的索引結束。

  • 區域性變數表中存放編譯期可知的各種基本數據型別(8種),參照型別(reference),returnAddress型別的變數。

  • 在區域性變數表裏,32位元以內的型別只佔用一個slot(包括returnAddress型別),64位元的型別(1ong和double)佔用兩個slot
    byte、short、char 在儲存前被轉換爲intboolean也被轉換爲int0表示false非0表示truelongdouble則佔據兩個slot

  • JVM會爲區域性變數表中的每一個Slot都分配一個存取索引,通過這個索引即可成功存取到區域性變數表中指定的區域性變數值

  • 當一個實體方法被呼叫的時候,它的方法參數方法體內部定義的區域性變數將會按照順序複製區域性變數表中的每一個slot上

  • 如果需要存取區域性變數表中一個64bit的區域性變數值時,只需要使用前一個索引即可。(比如:存取long或double型別變數)

  • 如果當前幀是由構造方法或者實體方法建立的,那麼該物件參照this將會存放在index0slot,其餘的參數按照參數表順序繼續排列。【這也就是普通實體方法中無法使用 this 關鍵字的原因(因爲 this 變數不存在於當前方法的區域性變數表中)】

在这里插入图片描述

  • 棧幀中的區域性變數表中的槽位(Slot)是可以重用的,如果一個區域性變數過了其作用域,那麼在其作用域之後申明的新的區域性變數就很有可能會複用過期區域性變數的槽位,從而達到節省資源的目的。

在这里插入图片描述

舉例:靜態變數和區域性變數的對比

變數的分類

  • 按照數據型別分 :基本數據型別參照數據型別
  • 按照在類中宣告的位置分:成員變數(使用靜態修飾的稱爲類變數,沒有被靜態修飾的稱爲範例變數)、區域性變數

三者的區別

  • 類變數linkingprepare階段,給類變數預設賦值 ====> init階段給類變數顯示賦值,即靜態程式碼塊賦值
  • 範例變數:隨着物件建立,會在堆空間中分配範例變數空間,並進行預設賦值
  • 區域性變數:在使用前必須進行顯式賦值,不然編譯不通過。
  • 即:成員變數使用前都經過預設初始化賦值;區域性變數在使用前則必須顯示賦值
  • 【原因】:區域性變數表不存在系統初始化的過程,這意味着一旦定義了區域性變數則必須人爲的初始化,否則無法使用。

在棧幀中,與效能調優關係最爲密切的部分就是區域性變數表。在方法執行時,虛擬機器使用區域性變數表完成方法的傳遞。

區域性變數表中的變數也是重要的垃圾回收根節點,只要被區域性變數表直接或間接參照的物件都不會被回收,也就是如果物件在棧中的地址被清除掉,那麼在堆中的物件就會被當成垃圾清除

運算元棧 (Operand Stack)

  • 每一個獨立的棧幀除了包含區域性變數表以外,還包含一個後進先出(Last - In - First -Out)的 運算元棧,也可以稱之爲 表達式棧(Expression Stack
  • 運算元棧:在方法執行過程中,根據位元組碼指令,往棧中寫入數據提取數據,即入棧(push)和 出棧(pop)

在这里插入图片描述

  • 運算元棧,主要用於儲存計算過程的中間結果同時作爲計算過程中變數臨時的儲存空間

  • 運算元棧就是 JVM 執行引擎的一個工作區,當一個方法剛開始執行的時候,一個新的棧幀也會隨之被建立出來,這個方法的運算元棧是空的,這個時候陣列是有長度的,而且陣列一旦建立,那麼就是不可變

  • 每一個運算元棧都會擁有一個明確的棧深度用於儲存數值,其所需的最大深度在編譯期就定義好了,儲存在方法的Code屬性中,爲max_stack的值。

在这里插入图片描述

  • 棧中的任何一個元素都可以是任意的 Java數據型別【32bit的型別佔用一個棧單位深度;64bit的型別佔用兩個棧單位深度】

  • 運算元棧儘管使用的是陣列,但是並非採用存取索引的方式來進行數據存取的,而是隻能通過標準的入棧和出棧操作來完成一次數據存取,只是通過陣列實現的而已。

  • 如果被呼叫的方法帶有返回值的話,其返回值將會被壓入當前棧幀的運算元棧中,並更新PC暫存器中下一條需要執行的位元組碼指令。
    在这里插入图片描述

  • 運算元棧中元素的數據型別必須與位元組碼指令的序列嚴格匹配,這由編譯器在編譯器期間進行驗證,同時在類載入過程中的類檢驗階段的數據流分析階段要再次驗證。

  • 另外,我們說Java虛擬機器的解釋引擎是基於棧的執行引擎,其中的棧指的就是運算元棧

詳細分析一下位元組碼每個步驟對應的區域性變數表和運算元棧的變化過程

在这里插入图片描述

  • 上面案例中,所需要的區域性變數表的長度是4,運算元棧的最大深度是2
  • bipush表示把byte型別轉換成int型別,因爲10、20、30都可以使用byte來儲存,但是存入區域性變數表的時候,就自動轉換成int型別了,例如istore

拓展: 通過分析位元組碼檔案瞭解 i++++i 的區別

在这里插入图片描述

棧頂快取技術:Top Of Stack Cashing

  • 前面提過,基於棧式架構的虛擬機器所使用的零地址指令更加緊湊,但完成一項操作的時候必然需要使用更多的入棧和出棧指令,這同時也就意味着將需要更多的指令分派(instruction dispatch)次數和記憶體讀/寫次數。
  • 由於運算元是儲存在記憶體中的,因此頻繁地執行記憶體讀/寫操作必然會影響執行速度。爲了解決這個問題,HotSpot JVM的設計者們提出了棧頂快取(ToSTop-of-Stack Cashing)技術,將棧頂元素全部快取在物理CPU的暫存器中,以此降低對記憶體的 讀 / 寫 次數提升執行引擎的執行效率
  • 【暫存器:指令更少,執行速度快】

幀數據區(動態鏈接、方法返回地址、附加資訊)

動態鏈接(Dynamic Linking)

  • 動態鏈接,也稱 指向執行時常數池的方法參照

  • 每一個棧幀內部都包含一個指向執行時常數池中該棧幀所屬方法的參照,包含這個參照的目的就是爲了支援當前方法的程式碼能夠實現動態鏈接(Dynamic Linking)。比如:invokedynamic指令

  • 在Java原始檔被編譯到位元組碼檔案中時,所有的變數和方法參照都作爲符號參照Symbolic Reference)儲存在 class檔案 的常數池裏。

  • 比如:描述一個方法呼叫了另外的其他方法時,就是通過常數池中指向方法的符號參照來表示的,那麼動態鏈接的作用就是爲了將這些符號參照轉換爲呼叫方法的直接參照

在这里插入图片描述

在这里插入图片描述

  • 之所以使用常數池,是因爲在不同的方法,都可能呼叫常數或者方法,所以只需要儲存一份即可,節省了空間
  • 常數池的作用:就是爲了提供一些符號和常數,便於指令的識別

方法呼叫(重點)

  • 在JVM中,將符號參照轉換爲呼叫方法的直接參照與方法的系結機制 機製相關

兩種鏈接方式:靜態鏈接和動態鏈接

靜態鏈接

  • 當一個位元組碼檔案被裝載進 JVM 內部時,如果被呼叫的目標方法在編譯期可知,且執行期保持不變時,這種情況下將呼叫方法的符號參照轉換爲直接參照的過程稱之爲靜態鏈接

動態鏈接

  • 如果被呼叫的方法在編譯期無法被確定下來,也就是說,只能夠在程式執行期將呼叫的方法的符號轉換爲直接參照,由於這種參照轉換過程具備動態性,因此也被稱之爲動態鏈接

系結機制 機製

  • 對應的方法的系結機制 機製爲:早期系結(Early Binding)和晚期系結(Late Binding)。系結是一個欄位、方法或者類在符號參照被替換爲直接參照的過程,這僅僅發生一次

早期系結

  • 早期系結就是指被呼叫的目標方法如果在編譯期可知,且執行期保持不變時,即可將這個方法與所屬的型別進行系結,這樣一來,由於明確了被呼叫的目標方法究竟是哪一個,因此也就可以使用靜態鏈接的方式將符號參照轉換爲直接參照。

晚期系結

  • 如果被呼叫的方法在編譯期無法被確定下來,只能夠在程式執行期根據實際的型別系結相關的方法,這種系結方式也就被稱之爲晚期系結。

早晚期系結的發展歷史

  • 隨着高階語言的橫空出世,類似於 Java 一樣的基於物件導向的程式語言如今越來越多,儘管這類程式語言在語法風格上存在一定的差別,但是它們彼此之間始終保持着一個共性,那就是都支援封裝、繼承和多型等物件導向特性,既然這一類的程式語言具備多型特性,那麼自然也就具備早期系結和晚期系結兩種系結方式。

  • Java 中任何一個普通的方法其實都具備虛擬函式的特徵(體現爲執行期才能 纔能確定下來),它們相當於C++ 語言中的虛擬函式( C++ 中則需要使用關鍵字virtual來顯式定義)。如果在 Java 程式中不希望某個方法擁有虛擬函式的特徵時,則可以使用關鍵字final來標記這個方法。

虛方法和非虛方法

如果方法在編譯期就確定了具體的呼叫版本,這個版本在執行時是不可變的。這樣的方法稱爲非虛方法

  • 靜態方法、私有方法、final方法、範例構造器、父類別方法都是非虛方法。

其他方法稱爲虛方法。

虛擬機器中提供了以下幾條方法呼叫指令:

普通呼叫指令:

  • invokestatic:呼叫靜態方法,解析階段確定唯一方法版本
  • invokespecial:呼叫方法、私有及父類別方法,解析階段確定唯一方法版本
  • invokevirtual:呼叫所有虛方法
  • invokeinterface:呼叫介面方法

動態呼叫指令:

  • invokedynamic:動態解析出需要呼叫的方法,然後執行

前四條指令固化在虛擬機器內部,方法的呼叫執行不可人爲幹預,而invokedynamic指令則支援由使用者確定方法版本。其中invokestatic指令和invokespecial指令呼叫的方法稱爲非虛方法,其餘的(final修飾的除外)稱爲虛方法。

在这里插入图片描述

關於 invokednamic 指令

  • JVM位元組碼指令集一直比較穩定,一直到 Java7 中才增加了一個invokedynamic指令,這是Java爲了實現【動態型別語言】支援而做的一種改進。

  • 但是在 Java7 中並沒有提供直接生成invokedynamic指令的方法,需要藉助 ASM 這種底層位元組碼工具來產生invokedynamic指令。直到 Java8 的 Lambda 表達式的出現,invokedynamic指令的生成,在Java中纔有了直接的生成方式

  • Java7 中增加的動態語言型別支援的本質是對 Java 虛擬機器規範的修改,而不是對Java語言規則的修改,這一塊相對來講比較複雜,增加了虛擬機器中的方法呼叫,最直接的受益者就是執行在 Java 平臺的動態語言的編譯器

動態型別語言和靜態型別語言

  • 動態型別語言和靜態型別語言兩者的區別就在於對型別的檢查是在編譯期還是在執行期,如果是在編譯期就是靜態型別語言執行期則是動態型別語言

  • 說的再直白一點就是,靜態型別語言是判斷變數自身的型別資訊;動態型別語言是判斷變數值的型別資訊,變數沒有型別資訊,變數值纔有型別資訊,這是動態語言的一個重要特徵。

Java:String info = "abc"; (Java是靜態型別語言的,會先編譯就進行型別檢查)

JS:var name = "abc"; var age = 10; (執行時才進行檢查, Python 也類似)

方法重寫

Java 語言中方法重寫的本質:

  • 找到運算元棧頂的第一個元素所執行的物件的實際型別,記作C。
  • 如果在型別 C 中找到與常數中的描述符合、簡單名稱都相符的方法,則進行存取許可權校驗,如果通過則返回這個方法的直接參照,查詢過程結束;如果不通過,則返回java.lang.IllegalAccessError異常。
  • 否則,按照繼承關係從下往上依次對 C 的各個父類別進行第 2 步的搜尋和驗證過程。
  • 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodsError異常。

IllegalAccessError介紹

  • 程式試圖存取或修改一個屬性或呼叫一個方法,這個屬性或方法,你沒有許可權存取。一般的,這個會引起編譯器異常。這個錯誤如果發生在執行時,就說明一個類發生了不相容的改變

虛方法表

  • 在物件導向的程式設計中,會很頻繁的使用到動態分派,如果在每次動態分派的過程中都要重新在類的方法元數據中搜尋合適的目標的話就可能影響到執行效率
  • 因此,爲了提高效能,JVM 採用在類的方法區建立一個虛方法表 (virtual method table)(非虛方法不會出現在表中)來實現。使用索引表來代替查詢。
  • 每個類中都有一個虛方法表,表中存放着各個方法的實際入口
  • 虛方法表會在類載入的鏈接階段(解析)建立開始初始化,類的變數初始值準備完成之後,JVM 會把該類的方法表也初始化完畢。

在这里插入图片描述

  • 如上圖所示:如果類中重寫了方法,那麼呼叫的時候,就會直接在虛方法表中查詢,否則將會直接連線到Object的方法中。

方法返回地址(Return Address)

存放呼叫該方法的PC暫存器的值

一個方法的結束,有兩種方式:

  • 正常執行完成
  • 出現未處理的異常,非正常退出

無論通過哪種方式退出,在方法退出後都返回到該方法被呼叫的位置。方法正常退出時,呼叫者的PC暫存器的值作爲返回地址,即:呼叫該方法的指令的下一條指令的地址。而通過異常退出的,返回地址是要通過異常表來確定,棧幀中一般不會儲存這部分資訊。

  • 正常完成出口和異常完成出口的區別在於:通過異常完成出口退出的不會給他的上層呼叫者產生任何的返回值

當一個方法開始執行後,只有兩種方式可以退出這個方法:

(1)執行引擎遇到任意一個方法返回的位元組碼指令(return),會有返回值傳遞給上層的方法呼叫者,簡稱正常完成出口

  • 一個方法在正常呼叫完成之後,究竟需要使用哪一個返回指令,還需要根據方法返回值的實際數據型別而定
  • 在位元組碼指令中,返回指令包含ireturn(當返回值是boolean,byte,char,short和int型別時使用),lreturn(Long型別),freturn(Float型別),dreturn(Double型別),areturn。另外還有一個return指令宣告爲void的方法,範例初始化方法,類和介面的初始化方法使用。

(2)在方法執行過程中遇到異常(Exception),並且這個異常沒有在方法內進行處理,也就是隻要在本方法的異常表中沒有搜尋到匹配的例外處理器,就會導致方法退出,簡稱異常完成出口

方法執行過程中,拋出異常時的例外處理,儲存在一個異常處理表,方便在發生異常的時候找到處理異常的程式碼
在这里插入图片描述

本質上,方法的退出就是當前棧幀出棧的過程。此時,需要恢復上層方法的區域性變數表、運算元棧、將返回值壓入呼叫者棧幀的運算元棧、設定PC暫存器值等,讓呼叫者方法繼續執行下去。

附加資訊

  • 棧幀中還允許攜帶與Java虛擬機器實現相關的一些附加資訊。例如:對程式偵錯提供支援的資訊。

棧的相關面試題

舉例棧溢位的情況?(StackOverflowError)

  • 通過 -Xss設定棧的大小

調整棧大小,就能保證不出現溢位麼?

  • 不能保證不溢位

分配的棧記憶體越大越好麼?

  • 不是,一定時間內降低了OOM概率,但是會擠佔其它的執行緒空間,因爲整個空間是有限的。

垃圾回收是否涉及到虛擬機器棧?

  • 不會,因爲棧結構只有出棧和入棧兩個操作,如果用不到,那就出棧了,用不着回收

方法中定義的區域性變數是否執行緒安全?

/**
 * 面試題
 * 方法中定義區域性變數是否執行緒安全?具體情況具體分析
 * 何爲執行緒安全?
 *    如果只有一個執行緒纔可以操作此數據,則必是執行緒安全的
 *    如果有多個執行緒操作,則此數據是共用數據,如果不考慮共用機制 機製,則爲執行緒不安全
 */
public class StringBuilderTest {

    // s1的宣告方式是執行緒安全的
    public static void method01() {
        // 執行緒內部建立的,屬於區域性變數
        StringBuilder s1 = new StringBuilder();
        s1.append("a");
        s1.append("b");
    }

    // 這個也是執行緒不安全的,因爲有返回值,有可能被其它的程式所呼叫
    public static StringBuilder method04() {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("a");
        stringBuilder.append("b");
        return stringBuilder;
    }

    // stringBuilder 是執行緒不安全的,操作的是共用數據
    public static void method02(StringBuilder stringBuilder) {
        stringBuilder.append("a");
        stringBuilder.append("b");
    }


    /**
     * 同時併發的執行,會出現執行緒不安全的問題
     */
    public static void method03() {
        StringBuilder stringBuilder = new StringBuilder();
        new Thread(() -> {
            stringBuilder.append("a");
            stringBuilder.append("b");
        }, "t1").start();

        method02(stringBuilder);
    }

    // 是執行緒安全的,因爲 toString 會 new 一個 String 物件範例
    // 而且 String 是 final 修飾的類,也是執行緒安全的
    public static String method05() {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("a");
        stringBuilder.append("b");
        return stringBuilder.toString();
    }
}
  • 總結一句話就是:如果物件是在內部產生,並在內部消亡,沒有返回到外部,那麼它就是執行緒安全的,反之則是執行緒不安全的。

執行時數據區,是否存在 Error 和 GC ?
在这里插入图片描述

本地方法介面

在这里插入图片描述

  • 簡單地講,一個Native Method是一個Java呼叫非Java程式碼的接囗。

  • 一個Native Method是這樣一個Java方法:該方法的實現由非Java語言實現,比如C。這個特徵並非Java所特有,很多其它的程式語言都有這一機制 機製,比如在C++中,你可以用extern 「C」 告知C++編譯器去呼叫一個 C 的函數。

  • "A native method is a Java method whose implementation is provided by non-java code."(本地方法是一個非Java的方法,它的具體實現是非Java程式碼的實現)

  • 在定義一個native method時,並不提供實現體(有些像定義一個Java Interface),因爲其實現體是由非java語言在外面實現的

  • 本地介面的作用是融合不同的程式語言爲Java所用,它的初衷是融合C/C++程式

程式碼演示如何編寫 Native 方法

/* 識別符號 native 可以與其它 java 識別符號連用,但是 abstract 除外 */
public class IhaveNatives {
    public native void Native1(int x);
    native static public long Native2();
    native synchronized private float Native3(Object o);
    native void Natives(int[] ary) throws Exception;
}

爲什麼使用Native Method?

Java使用起來非常方便,然而有些層次的任務用 Java 實現起來不容易,或者我們對程式的效率很在意時,問題就來了。

在 Java 剛發行的時候,在執行效率上遠達不到 C/C++ 的水平,但是現在 Java 的執行效率大體上已經跟 C/C++ 差不多了。

Java環境的互動

  • 有時Java應用需要與Java外面的環境互動,這是本地方法存在的主要原因。你可以想想Java需要與一些底層系統,如操作系統或某些硬體交換資訊時的情況。本地方法正是這樣一種交流機制 機製:它爲我們提供了一個非常簡潔的介面,而且我們無需去瞭解 Java 應用之外的繁瑣的細節。

操作系統的互動

  • JVM支援着Java語言本身和執行時庫,它是Java程式賴以生存的平臺,它由一個直譯器(解釋位元組碼)和一些連線到原生代碼的庫組成。然而不管怎樣,它畢竟不是一個完整的系統,它經常依賴於一底層系統的支援。這些底層系統常常是強大的操作系統。通過使用本地方法,我們得以用 Java 實現了 JRE 的與底層系統的互動,甚至JVM的一些部分就是用 C 寫的。還有,如果我們要使用一些Java語言本身沒有提供封裝的操作系統的特性時,我們也需要使用本地方法。

Sun’s Java

  • Sun的直譯器是用C實現的,這使得它能像一些普通的C一樣與外部互動。JRE 大部分是用Java實現的,它也通過一些本地方法與外界互動。例如:類java.lang.ThreadsetPriority()方法是用 Java實現的,但是它實現呼叫的是該類裡的本地方法setPriority0()。這個本地方法是用C實現的,並被植入JVM內部,在Windows 95的平臺上,這個本地方法最終將呼叫Win32 SetPriority()ApI。這是一個本地方法的具體實現由JVM直接提供,更多的情況是本地方法由外部的動態鏈接庫External Dynamic Link Library)提供,然後被JVM呼叫。

目前該方法使用的越來越少了,除非是與硬體有關的應用,比如通過Java程式驅動印表機或者Java系統管理生產裝置,在企業級應用中已經比較少見。因爲現在的異構領域間的通訊很發達,比如可以使用 Socket 通訊,也可以使用 Web Service 等等,不多做介紹。

本地方法棧

  • Java虛擬機器棧於管理Java方法的呼叫,而本地方法棧用於管理本地方法(本地方法是由C語言實現)的呼叫
  • 本地方法棧,也是執行緒私有的。
  • 允許被實現成固定或者是可動態擴充套件的記憶體大小。(在記憶體溢位方面與Java虛擬機器棧是相同的)
    • 如果執行緒請求分配的棧容量超過本地方法棧允許的最大容量,Java虛擬機器將會拋出一個StackOverflowError異常。
    • 如果本地方法棧可以動態擴充套件,並且在嘗試擴充套件的時候無法申請到足夠的記憶體,或者在建立新的執行緒時沒有足夠的記憶體去建立對應的本地方法棧,那麼Java虛擬機器將會拋出一個OutOfMemoryError異常。
  • 它的具體做法是Native Method Stack中登記native方法,在Execution Engine執行時載入本地方法庫。

在这里插入图片描述

當某個執行緒呼叫一個本地方法時,它就進入了一個全新的並且不再受虛擬機器限制的世界。它和虛擬機器擁有同樣的許可權。

  • 本地方法可以通過本地方法介面來存取虛擬機器內部的執行時數據區
  • 它甚至可以直接使用本地處理器中的暫存器
  • 直接從本地記憶體的堆中分配任意數量的記憶體

並不是所有的 JVM 都支援本地方法。因爲Java虛擬機器規範並沒有明確要求本地方法棧的使用語言、具體實現方式、數據結構等。如果JVM產品不打算支援native方法,也可以無需實現本地方法棧。

Hotspot JVM中,直接將本地方法棧和虛擬機器棧合二爲一

堆區和方法區

由於堆區和方法區篇幅較長,放在另一篇文章中

  • 堆區:
  • 方法區: