筆記來源:尚矽谷 JVM 全套教程,百萬播放,全網巅峰(宋紅康詳解 java 虛拟機)
文章目錄
- 3. 運作時資料區及程式計數器
- 3.1. 運作時資料區
- 3.1.1. 概述
- 3.1.2. 線程
- 3.1.3. JVM 系統線程
- 3.2. 程式計數器(PC 寄存器)
- 4. 虛拟機棧
- 4.1. 虛拟機棧概述
- 4.1.1. 虛拟機棧出現的背景
- 4.1.2. 初步印象
- 4.1.3. 記憶體中的棧與堆
- 4.1.4. 虛拟機棧基本内容
- Java 虛拟機棧是什麼?
- 生命周期
- 作用
- 棧的特點
- 面試題:開發中遇到哪些異常?
- 4.2. 棧的存儲機關
- 4.2.1. 棧中存儲什麼?
- 4.2.2. 棧運作原理
- 4.2.3. 棧幀的内部結構
- 4.3. 局部變量表(Local Variables)
- 4.3.1. 關于 Slot 的了解
- 4.3.2. Slot 的重複利用
- 4.3.3. 靜态變量與局部變量的對比
- 4.3.4. 補充說明
- 4.4. 操作數棧(Operand Stack)
- 4.5. 代碼追蹤
- 4.6. 棧頂緩存技術(Top Of Stack Cashing)技術
- 4.7. 動态連結(Dynamic Linking)
- 4.8. 方法的調用:解析與配置設定
- 4.8.1. 靜态連結
- 4.8.2. 動态連結
- 4.8.3. 早期綁定
- 4.8.4. 晚期綁定
- 4.8.5. 虛方法和非虛方法
- 普通調用指令:
- 動态調用指令:
- 動态類型語言和靜态類型語言
- 4.8.6. 方法重寫的本質
- 4.8.7. 方法的調用:虛方法表
- 4.9. 方法傳回位址(return address)
- 4.10. 一些附加資訊
- 4.11. 棧的相關面試題
3. 運作時資料區及程式計數器
3.1. 運作時資料區
3.1.1. 概述
本節主要講的是運作時資料區,也就是下圖這部分,它是在類加載完成後的階段
當我們通過前面的:類的加載-> 驗證 -> 準備 -> 解析 -> 初始化 這幾個階段完成後,就會用到執行引擎對我們的類進行使用,同時執行引擎将會使用到我們運作時資料區
記憶體是非常重要的系統資源,是硬碟和 CPU 的中間倉庫及橋梁,承載着作業系統和應用程式的實時運作 JVM 記憶體布局規定了 Java 在運作過程中記憶體申請、配置設定、管理的政策,保證了 JVM 的高效穩定運作。不同的 JVM 對于記憶體的劃分方式和管理機制存在着部分差異。結合 JVM 虛拟機規範,來探讨一下經典的 JVM 記憶體布局。
我們把大廚後面的東西(切好的菜,刀,調料),比作是運作時資料區。而廚師可以類比于執行引擎,将通過準備的東西進行制作成精美的菜品
我們通過磁盤或者網絡 IO 得到的資料,都需要先加載到記憶體中,然後 CPU 從記憶體中擷取資料進行讀取,也就是說記憶體充當了 CPU 和磁盤之間的橋梁
Java 虛拟機定義了若幹種程式運作期間會使用到的運作時資料區,其中有一些會随着虛拟機啟動而建立,随着虛拟機退出而銷毀。另外一些則是與線程一一對應的,這些與線程對應的資料區域會随着線程開始和結束而建立和銷毀。
灰色的為單獨線程私有的,紅色的為多個線程共享的。即:
- 每個線程:獨立包括程式計數器、棧、本地棧。
- 線程間共享:堆、堆外記憶體(永久代或元空間、代碼緩存) ==> 堆外記憶體可以了解為方法區
每個 JVM 隻有一個 Runtime 執行個體。即為運作時環境,相當于記憶體結構的中間的那個框框:運作時環境。
3.1.2. 線程
線程是一個程式裡的運作單元。JVM 允許一個應用有多個線程并行的執行。 在 Hotspot JVM 裡,每個線程都與作業系統的本地線程直接映射。
當一個 Java 線程準備好執行以後,此時一個作業系統的本地線程也同時建立。Java 線程執行終止後,本地線程也會回收。
作業系統負責所有線程的安排排程到任何一個可用的 CPU 上。一旦本地線程初始化成功,它就會調用 Java 線程中的 run()方法。
3.1.3. JVM 系統線程
如果你使用 Jconsole 或者是任何一個調試工具,都能看到在背景有許多線程在運作。這些背景線程不包括調用
public static void main(String[] args)
的 main 線程以及所有這個 main 線程自己建立的線程。
這些主要的背景系統線程在 Hotspot JVM 裡主要是以下幾個:
- 虛拟機線程:這種線程的操作是需要 JVM 達到安全點才會出現。這些操作必須在不同的線程中發生的原因是他們都需要 JVM 達到安全點,這樣堆才不會變化。這種線程的執行類型包括"stop-the-world"的垃圾收集,線程棧收集,線程挂起以及偏向鎖撤銷。
- 周期任務線程:這種線程是時間周期事件的展現(比如中斷),他們一般用于周期性操作的排程執行。
- GC 線程:這種線程對在 JVM 裡不同種類的垃圾收集行為提供了支援。
- 編譯線程:這種線程在運作時會将位元組碼編譯成到本地代碼。
- 信号排程線程:這種線程接收信号并發送給 JVM,在它内部通過調用适當的方法進行處理。
3.2. 程式計數器(PC 寄存器)
JVM 中的程式計數寄存器(Program Counter Register)中,Register 的命名源于 CPU 的寄存器,寄存器存儲指令相關的現場資訊。CPU 隻有把資料裝載到寄存器才能夠運作。這裡,并非是廣義上所指的實體寄存器,或許将其翻譯為 PC 計數器(或指令計數器)會更加貼切(也稱為程式鈎子),并且也不容易引起一些不必要的誤會。JVM 中的 PC 寄存器是對實體 PC 寄存器的一種抽象模拟。
作用
PC 寄存器用來存儲指向下一條指令的位址,也即将要執行的指令代碼。由執行引擎讀取下一條指令。
它是一塊很小的記憶體空間,幾乎可以忽略不記。也是運作速度最快的存儲區域。
在 JVM 規範中,每個線程都有它自己的程式計數器,是線程私有的,生命周期與線程的生命周期保持一緻。
任何時間一個線程都隻有一個方法在執行,也就是所謂的目前方法。程式計數器會存儲目前線程正在執行的 Java 方法的 JVM 指令位址;或者,如果是在執行 native 方法(C/C++編寫的),則是未指定值(undefined)。
它是程式控制流的訓示器,分支、循環、跳轉、異常處理、線程恢複等基礎功能都需要依賴這個計數器來完成。==> 類似于Mysql中的遊标,Java集合中的疊代器…
位元組碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令。
它是唯一一個在 Java 虛拟機規範中沒有規定任何 OutofMemoryError 情況的區域。
備注:棧區和PC計數器都無GC.PC隻負責擷取下一條要執行的指令,也無OOM(OutofMemoryError),而堆,棧,方法區都可能出現OOM.是以PC計數器既沒有GC有沒有OOM,是一個很特殊的區域.
舉例說明
public class PCRegisterTest {
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
String s = "abc";
System.out.println(i);
System.out.println(k);
}
}
位元組碼檔案:
0: bipush 10 # 指派
2: istore_1 # 儲存
3: bipush 20 # 指派
5: istore_2 # 儲存
6: iload_1 # 加載進來
7: iload_2
8: iadd # 相加
9: istore_3 # 儲存
10: ldc #2 // String abc
12: astore 4
14: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
17: iload_1
18: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
21: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
24: iload_3
25: invokevirtual #4 // Method java/io/PrintStream.println:(I)V
28: return
圖解:
使用 PC 寄存器存儲位元組碼指令位址有什麼用呢?為什麼使用 PC 寄存器記錄目前線程的執行位址呢?
因為 CPU 需要不停的切換各個線程,這時候切換回來以後,就得知道接着從哪開始繼續執行。
JVM 的位元組碼解釋器就需要通過改變 PC 寄存器的值來明确下一條應該執行什麼樣的位元組碼指令。
PC 寄存器為什麼被設定為私有的?
我們都知道所謂的多線程在一個特定的時間段内隻會執行其中某一個線程的方法,CPU 會不停地做任務切換,這樣必然導緻經常中斷或恢複,如何保證分毫無差呢?為了能夠準确地記錄各個線程正在執行的目前位元組碼指令位址,最好的辦法自然是為每一個線程都配置設定一個 PC 寄存器,這樣一來各個線程之間便可以進行獨立計算,進而不會出現互相幹擾的情況。
由于 CPU 時間片輪限制,衆多線程在并發執行過程中,任何一個确定的時刻,一個處理器或者多核處理器中的一個核心,隻會執行某個線程中的一條指令。
這樣必然導緻經常中斷或恢複,如何保證分毫無差呢?每個線程在建立後,都會産生自己的程式計數器和棧幀,程式計數器在各個線程之間互不影響。
CPU 時間片
CPU 時間片即 CPU 配置設定給各個程式的時間,每個線程被配置設定一個時間段,稱作它的時間片。
在宏觀上:我們可以同時打開多個應用程式,每個程式并行不悖,同時運作。
但在微觀上:由于隻有一個 CPU,一次隻能處理程式要求的一部分,如何處理公平,一種方法就是引入時間片,每個程式輪流執行。
補充:
- 并行 VS 串行 并行:同一時間點都在執行, 串行:排隊依次執行.
- 并發 : 同一時間段都在執行,但同一時間點不一定都執行
例子:并發是兩個隊列交替使用一台咖啡機,并行是兩個隊列同時使用兩台咖啡機.
4. 虛拟機棧
4.1. 虛拟機棧概述
4.1.1. 虛拟機棧出現的背景
由于跨平台性的設計,Java 的指令都是根據棧來設計的。不同平台 CPU 架構不同,是以不能設計為基于寄存器的。
優點是跨平台,指令集小,編譯器容易實作,缺點是性能下降,實作同樣的功能需要更多的指令。
4.1.2. 初步印象
有不少 Java 開發人員一提到 Java 記憶體結構,就會非常粗粒度地将 JVM 中的記憶體區了解為僅有 Java 堆(heap)和 Java 棧(stack)?為什麼?
4.1.3. 記憶體中的棧與堆
棧是運作時的機關,而堆是存儲的機關
- 棧解決程式的運作問題,即程式如何執行,或者說如何處理資料。
- 堆解決的是資料存儲的問題,即資料怎麼放,放哪裡
4.1.4. 虛拟機棧基本内容
Java 虛拟機棧是什麼?
Java 虛拟機棧(Java Virtual Machine Stack),早期也叫 Java 棧。每個線程在建立時都會建立一個虛拟機棧,其内部儲存一個個的棧幀(Stack Frame),對應着一次次的 Java 方法調用,是線程私有的。
生命周期
生命周期和線程一緻
作用
主管 Java 程式的運作,它儲存方法的局部變量(8種基本資料類型、對象的引用類型)、部分結果,并參與方法的調用和傳回。
補充:
- 局部變量 VS 成員變量(或屬性)
- 基本資料變量 VS 引用類型變量(類、數組、接口)
棧的特點
棧是一種快速有效的配置設定存儲方式,通路速度僅次于PC計數器。
JVM 直接對 Java 棧的操作隻有兩個:
- 每個方法執行,伴随着進棧(入棧、壓棧)
- 執行結束後的出棧工作
對于棧來說不存在垃圾回收問題(棧存在溢出的情況)
面試題:開發中遇到哪些異常?
棧中可能出現的異常
Java 虛拟機規範允許Java 棧的大小是動态的或者是固定不變的。
- 如果采用固定大小的 Java 虛拟機棧,那每一個線程的 Java 虛拟機棧容量可以線上程建立的時候獨立標明。如果線程請求配置設定的棧容量超過 Java 虛拟機棧允許的最大容量,Java 虛拟機将會抛出一個StackOverflowError 異常。
- 如果 Java 虛拟機棧可以動态擴充,并且在嘗試擴充的時候無法申請到足夠的記憶體,或者在建立新的線程時沒有足夠的記憶體去建立對應的虛拟機棧,那 Java 虛拟機将會抛出一個 OutOfMemoryError 異常。
public static void main(String[] args) {
main(args);
}
//抛出異常:Exception in thread "main" java.lang.StackOverflowError
//程式不斷的進行遞歸調用,而且沒有退出條件,就會導緻不斷地進行壓棧。
設定棧記憶體大小
我們可以使用參數 -Xss 選項來設定線程的最大棧空間,棧的大小直接決定了函數調用的最大可達深度
/**
* 示範棧中的異常:StackOverflowError
* @author shkstart
* @create 2020 下午 9:08
*
* 預設情況下:count : 11417
* 設定棧的大小: -Xss256k : count : 2460
*/
public class StackErrorTest {
private static int count = 1;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
}
4.2. 棧的存儲機關
4.2.1. 棧中存儲什麼?
每個線程都有自己的棧,棧中的資料都是以棧幀(Stack Frame)的格式存在。
在這個線程上正在執行的每個方法都各自對應一個棧幀(Stack Frame)。
棧幀是一個記憶體區塊,是一個資料集,維系着方法執行過程中的各種資料資訊。
4.2.2. 棧運作原理
JVM 直接對 Java 棧的操作隻有兩個,就是對棧幀的壓棧和出棧,遵循“先進後出”/“後進先出”原則。
在一條活動線程中,一個時間點上,隻會有一個活動的棧幀。即隻有目前正在執行的方法的棧幀(棧頂棧幀)是有效的,這個棧幀被稱為目前棧幀(Current Frame),與目前棧幀相對應的方法就是目前方法(Current Method),定義這個方法的類就是目前類(Current Class)。
執行引擎運作的所有位元組碼指令隻針對目前棧幀進行操作。
如果在該方法中調用了其他方法,對應的新的棧幀會被建立出來,放在棧的頂端,成為新的目前幀。
不同線程中所包含的棧幀是不允許存在互相引用的,即不可能在一個棧幀之中引用另外一個線程的棧幀。
如果目前方法調用了其他方法,方法傳回之際,目前棧幀會傳回此方法的執行結果給前一個棧幀,接着,虛拟機會丢棄目前棧幀,使得前一個棧幀重新成為目前棧幀。
Java 方法有兩種傳回函數的方式,一種是正常的函數傳回,使用 return 指令;另外一種是抛出異常。不管使用哪種方式,都會導緻棧幀被彈出。
/**
* @author shkstart
* @create 2020 下午 4:11
*
* 方法的結束方式分為兩種:① 正常結束,以return為代表 ② 方法執行中出現未捕獲處理的異常,以抛出異常的方式結束
*
*/
public class StackFrameTest {
public static void main(String[] args) {
try {
StackFrameTest test = new StackFrameTest();
test.method1();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("main()正常結束");
}
public void method1(){
System.out.println("method1()開始執行...");
method2();
System.out.println("method1()執行結束...");
}
public int method2() {
System.out.println("method2()開始執行...");
int i = 10;
int m = (int) method3();
System.out.println("method2()即将結束...");
return i + m;
}
public double method3() {
System.out.println("method3()開始執行...");
double j = 20.0;
System.out.println("method3()即将結束...");
return j;
}
}
4.2.3. 棧幀的内部結構
每個棧幀中存儲着:
- 局部變量表(Local Variables)
- 操作數棧(operand Stack)(或表達式棧)
- 動态連結(DynamicLinking)(或指向運作時常量池的方法引用)
- 方法傳回位址(Return Address)(或方法正常退出或者異常退出的定義)
- 一些附加資訊
并行每個線程下的棧都是私有的,是以每個線程都有自己各自的棧,并且每個棧裡面都有很多棧幀,棧幀的大小主要由局部變量表 和 操作數棧決定的
4.3. 局部變量表(Local Variables)
局部變量表也被稱之為局部變量數組或本地變量表
- 定義為一個數字數組,主要用于存儲方法參數和定義在方法體内的局部變量,這些資料類型包括各類基本資料類型、對象引用(reference),以及returnAddress類型。
- 由于局部變量表是建立線上程的棧上,是線程的私有資料,是以不存在資料安全問題
- 局部變量表所需的容量大小是在編譯期确定下來的,并儲存在方法的 Code 屬性的maximum local variables資料項中。在方法運作期間是不會改變局部變量表的大小的。
- 方法嵌套調用的次數由棧的大小決定。一般來說,棧越大,方法嵌套調用次數越多。對一個函數而言,它的參數和局部變量越多,使得局部變量表膨脹,它的棧幀就越大,以滿足方法調用所需傳遞的資訊增大的需求。進而函數調用就會占用更多的棧空間,導緻其嵌套調用次數就會減少。
- 局部變量表中的變量隻在目前方法調用中有效。在方法執行時,虛拟機通過使用局部變量表完成參數值到參數變量清單的傳遞過程。當方法調用結束後,随着方法棧幀的銷毀,局部變量表也會随之銷毀。
補充: 棧的大小決定方法嵌套的次數,也就是棧幀的多少,棧幀的大小由局部變量表決定.
代碼驗證觀察局部變量表:
/**
* @author shkstart
* @create 2020 下午 6:13
*/
public class LocalVariablesTest {
private int count = 0;
public static void main(String[] args) {
LocalVariablesTest test = new LocalVariablesTest();
int num = 10;
test.test1();
}
public void test1() {
Date date = new Date();
String name1 = "atguigu.com";
test2(date, name1);
System.out.println(date + name1);
}
}
- 利用javap反編譯觀察main方法結果:
通過jclasslib進行觀察:
根據jclasslib詳細分析代碼執行結構:
補充: 位元組碼中方法内部結構的剖析圖(結合Jclasslib)
4.3.1. 關于 Slot 的了解
- 局部變量表,最基本的存儲單元是 Slot(變量槽)
- 參數值的存放總是在局部變量數組的 index 0 開始,到數組長度-1 的索引結束。
- 局部變量表中存放編譯期可知的各種基本資料類型(8 種),引用類型(reference),returnAddress 類型的變量。
- 在局部變量表裡,32 位以内的類型隻占用一個 slot(包括 returnAddress 類型),64 位的類型(long 和 double)占用兩個 slot。
- byte、short、char 在存儲前被轉換為 int,boolean 也被轉換為 int,0 表示 false,非 0 表示 true。
- long和double則占據兩個Slot
- JVM 會為局部變量表中的每一個 Slot 都配置設定一個通路索引,通過這個索引即可成功通路到局部變量表中指定的局部變量值
- 當一個執行個體方法被調用的時候,它的方法參數和方法體内部定義的局部變量将會按照順序被複制到局部變量表中的每一個 slot 上
- 如果需要通路局部變量表中一個 64bit 的局部變量值時,隻需要使用前一個索引即可。(比如:通路 long 或 double 類型變量)
- 如果目前幀是由構造方法或者執行個體方法建立的,那麼該對象引用 this 将會存放在 index 為 0 的 slot 處,其餘的參數按照參數表順序繼續排列。
方法與局部變量表對照圖:
4.3.2. Slot 的重複利用
棧幀中的局部變量表中的槽位是可以重用的,如果一個局部變量過了其作用域,那麼在其作用域之後申明的新的局部變就很有可能會複用過期局部變量的槽位,進而達到節省資源的目的。
Slot重複利用示意圖:
4.3.3. 靜态變量與局部變量的對比
參數表配置設定完畢之後,再根據方法體内定義的變量的順序和作用域配置設定。
我們知道類變量表有兩次初始化的機會,第一次是在“準備階段”,執行系統初始化,對類變量設定零值,另一次則是在“初始化”階段,賦予程式員在代碼中定義的初始值。
和類變量初始化不同的是,局部變量表不存在系統初始化的過程,這意味着一旦定義了局部變量則必須人為的初始化,否則無法使用。
public void test(){
int i;
System. out. println(i);//System.out.println(num);//錯誤資訊:Variable 'num' might not have been initialized
}
這樣的代碼是錯誤的,沒有指派不能夠使用。
**補充:**變量的分類:
- 按照資料類型分:① 基本資料類型 ② 引用資料類型
- 按照在類中聲明的位置分:
- 成員變量:在使用前,都經曆過預設初始化指派.
- 類變量(靜态變量): linking的prepare階段:給類變量預設指派 —> initial階段:給類變量顯式指派即靜态代碼塊指派
- 執行個體變量:随着對象的建立,會在堆空間中配置設定執行個體變量空間,并進行預設指派
- 局部變量:在使用前,必須要進行顯式指派的!否則,編譯不通過
4.3.4. 補充說明
在棧幀中,與性能調優關系最為密切的部分就是前面提到的局部變量表。在方法執行時,虛拟機使用局部變量表完成方法的傳遞。
局部變量表中的變量也是重要的垃圾回收根節點,隻要被局部變量表中直接或間接引用的對象都不會被回收。
4.4. 操作數棧(Operand Stack)
每一個獨立的棧幀除了包含局部變量表以外,還包含一個後進先出(Last-In-First-Out)的 操作數棧,也可以稱之為表達式棧(Expression Stack)
操作數棧,在方法執行過程中,根據位元組碼指令,往棧中寫入資料或提取資料,即入棧(push)和 出棧(pop)
- 某些位元組碼指令将值壓入操作數棧,其餘的位元組碼指令将操作數取出棧。使用它們後再把結果壓入棧
- 比如:執行複制、交換、求和等操作
代碼舉例
public void testAddOperation(){
byte i = 15;
int j = 8;
int k = i + j;
}
位元組碼指令資訊
public void testAddOperation();
Code:
0: bipush 15
2: istore_1
3: bipush 8
5: istore_2
6:iload_1
7:iload_2
8:iadd
9:istore_3
10:return
操作數棧,主要用于儲存計算過程的中間結果,同時作為計算過程中變量臨時的存儲空間。
操作數棧就是 JVM 執行引擎的一個工作區,當一個方法剛開始執行的時候,一個新的棧幀也會随之被建立出來,這個方法的操作數棧是空的。
每一個操作數棧都會擁有一個明确的棧深度用于存儲數值,其所需的最大深度在編譯期就定義好了,儲存在方法的 Code 屬性中,為 max_stack 的值。
棧中的任何一個元素都是可以任意的 Java 資料類型
- 32bit 的類型占用一個棧機關深度
- 64bit 的類型占用兩個棧機關深度
操作數棧并非采用通路索引的方式來進行資料通路的,而是隻能通過标準的入棧和出棧操作來完成一次資料通路
如果被調用的方法帶有傳回值的話,其傳回值将會被壓入目前棧幀的操作數棧中,并更新 PC 寄存器中下一條需要執行的位元組碼指令。
操作數棧中元素的資料類型必須與位元組碼指令的序列嚴格比對,這由編譯器在編譯器期間進行驗證,同時在類加載過程中的類檢驗階段的資料流分析階段要再次驗證。
另外,我們說 Java 虛拟機的解釋引擎是基于棧的執行引擎,其中的棧指的就是操作數棧。
**備注:**操作數棧和局部變量表的底層都是數組,是以對于double和long類型資料需要兩個機關存儲.
4.5. 代碼追蹤
public void testAddOperation() {
byte i = 15;
int j = 8;
int k = i + j;
}
使用 javap 指令反編譯 class 檔案:
javap -v 類名.class
public void testAddoperation();
Code:
0 bipush 15
2 istore_1
3 bipush 8
5 istore_2
6 iload_1
7 iload_2
8 iadd
9 istore_3
10 return
涉及操作數棧的位元組碼指令執行分析:
- istore 指令會導緻出棧 并且寫入局部變量表 ipush:放入操作數棧
- istore和istore一樣,隻不過istore隻有0到3(其實是四個不同的指令,操作數隐式指定),再往後就得用istore了,因為還需要顯式指出槽位,是以要占兩個位元組.
4.6. 棧頂緩存技術(Top Of Stack Cashing)技術
前面提過,基于棧式架構的虛拟機所使用的零位址指令更加緊湊,但完成一項操作的時候必然需要使用更多的入棧和出棧指令,這同時也就意味着将需要更多的指令分派(instruction dispatch)次數和記憶體讀/寫次數。
由于操作數是存儲在記憶體中的,是以頻繁地執行記憶體讀/寫操作必然會影響執行速度。為了解決這個問題,HotSpot JVM 的設計者們提出了棧頂緩存(Tos,Top-of-Stack Cashing)技術,将棧頂元素全部緩存在實體 CPU 的寄存器中,以此降低對記憶體的讀/寫次數,提升執行引擎的執行效率。
**寄存器的優點:**指令更少,執行速度更快
4.7. 動态連結(Dynamic Linking)
動态連結、方法傳回位址、附加資訊 : 有些地方被稱為幀資料區
每一個棧幀内部都包含一個指向運作時常量池中該棧幀所屬方法的引用。包含這個引用的目的就是為了支援目前方法的代碼能夠實作動态連結(Dynamic Linking)。比如:invokedynamic 指令
在 Java 源檔案被編譯到位元組碼檔案中時,所有的變量和方法引用都作為符号引用(Symbolic Reference)儲存在 class 檔案的常量池裡。比如:描述一個方法調用了另外的其他方法時,就是通過常量池中指向方法的符号引用來表示的,那麼動态連結的作用就是為了将這些符号引用轉換為調用方法的直接引用。
代碼示範:
public class DynamicLinkingTest {
int num = 10;
public void methodA(){
System.out.println("methodA()....");
}
public void methodB(){
System.out.println("methodB()....");
methodA();
num++;
}
}
為什麼需要運作時常量池呢?
常量池的作用:就是為了提供一些符号和常量,便于指令的識别. 也便于變量或者方法引用的多次引用.另外位元組碼檔案中需要很多資料的支援,通常這些資料很大,我們不能直接儲存在位元組碼中,是以我們通過符号引用相關的結構
4.8. 方法的調用:解析與配置設定
在 JVM 中,将符号引用轉換為調用方法的直接引用與方法的綁定機制相關
4.8.1. 靜态連結
當一個位元組碼檔案被裝載進 JVM 内部時,如果被調用的目标方法在編譯期可知,且運作期保持不變時,這種情況下将調用方法的符号引用轉換為直接引用的過程稱之為靜态連結
4.8.2. 動态連結
如果被調用的方法在編譯期無法被确定下來,隻能夠在程式運作期将調用的方法的符号轉換為直接引用,由于這種引用轉換過程具備動态性,是以也被稱之為動态連結。
靜态連結和動态連結不是名詞,而是動詞,這是了解的關鍵。
對應的方法的綁定機制為:早期綁定(Early Binding)和晚期綁定(Late Binding)。綁定是一個字段、方法或者類在符号引用被替換為直接引用的過程,這僅僅發生一次。
4.8.3. 早期綁定
早期綁定就是指被調用的目标方法如果在編譯期可知,且運作期保持不變時,即可将這個方法與所屬的類型進行綁定,這樣一來,由于明确了被調用的目标方法究竟是哪一個,是以也就可以使用靜态連結的方式将符号引用轉換為直接引用。
4.8.4. 晚期綁定
如果被調用的方法在編譯期無法被确定下來,隻能夠在程式運作期根據實際的類型綁定相關的方法,這種綁定方式也就被稱之為晚期綁定。
随着進階語言的橫空出世,類似于 Java 一樣的基于面向對象的程式設計語言如今越來越多,盡管這類程式設計語言在文法風格上存在一定的差别,但是它們彼此之間始終保持着一個共性,那就是都支援封裝、繼承和多态等面向對象特性,既然這一類的程式設計語言具備多态特征,那麼自然也就具備早期綁定和晚期綁定兩種綁定方式。
Java 中任何一個普通的方法其實都具備虛函數的特征,它們相當于 C++語言中的虛函數(C++中則需要使用關鍵字 virtual 來顯式定義)。如果在 Java 程式中不希望某個方法擁有虛函數的特征時,則可以使用關鍵字 final 來标記這個方法。
代碼示範:
/**
* 說明早期綁定和晚期綁定的例子
* @author shkstart
* @create 2020 上午 11:59
*/
class Animal{
public void eat(){
System.out.println("動物進食");
}
}
interface Huntable{
void hunt();
}
class Dog extends Animal implements Huntable{
@Override
public void eat() {
System.out.println("狗吃骨頭");
}
@Override
public void hunt() {
System.out.println("捕食耗子,多管閑事");
}
}
class Cat extends Animal implements Huntable{
public Cat(){
super();//表現為:早期綁定
}
public Cat(String name){
this();//表現為:早期綁定
}
@Override
public void eat() {
super.eat();//表現為:早期綁定
System.out.println("貓吃魚");
}
@Override
public void hunt() {
System.out.println("捕食耗子,天經地義");
}
}
public class AnimalTest {
public void showAnimal(Animal animal){
animal.eat();//表現為:晚期綁定
}
public void showHunt(Huntable h){
h.hunt();//表現為:晚期綁定
}
}
4.8.5. 虛方法和非虛方法
如果方法在編譯期就确定了具體的調用版本,這個版本在運作時是不可變的。這樣的方法稱為非虛方法。
靜态方法、私有方法、final 方法、執行個體構造器、父類方法都是非虛方法。其他方法稱為虛方法。
虛拟機中提供了以下幾條方法調用指令:
普通調用指令:
- invokestatic:調用靜态方法,解析階段确定唯一方法版本
- invokespecial:調用方法、私有及父類方法,解析階段确定唯一方法版本
- invokevirtual:調用所有虛方法
- invokeinterface:調用接口方法
代碼示範
/**
* 解析調用中非虛方法、虛方法的測試
*
* invokestatic指令和invokespecial指令調用的方法稱為非虛方法
* @author shkstart
* @create 2020 下午 12:07
*/
class Father {
public Father() {
System.out.println("father的構造器");
}
public static void showStatic(String str) {
System.out.println("father " + str);
}
public final void showFinal() {
System.out.println("father show final");
}
public void showCommon() {
System.out.println("father 普通方法");
}
}
public class Son extends Father {
public Son() {
//invokespecial
super();
}
public Son(int age) {
//invokespecial
this();
}
//不是重寫的父類的靜态方法,因為靜态方法不能被重寫!
public static void showStatic(String str) {
System.out.println("son " + str);
}
private void showPrivate(String str) {
System.out.println("son private" + str);
}
public void show() {
//invokestatic
showStatic("atguigu.com");
//invokestatic
super.showStatic("good!");
//invokespecial
showPrivate("hello!");
//invokespecial
super.showCommon();
//invokespecial:非虛方法,編譯期便可知道.
super.showFinal();
//invokevirtual
showFinal();//因為此方法聲明有final,不能被子類重寫(但是可以直接調用啊.),是以也認為此方法是非虛方法。備注:雖然invokevirtual一般調用的是虛方法,但是 final修飾的方法例外.
//虛方法如下:
//invokevirtual
showCommon();//沒有super調用,且目前類可能重寫該方法.是以無法确定.
//info 在編譯期間無法确定下來.首先它不屬于父類的方法,是子類中額外加入的功能方法.如果Son的子類對其進行重寫,可以構成多态.
//例:如果存在一個類繼承了Son,那具體用的是Son的info還是Son子類的info,編譯器就确定不了了
info();
MethodInterface in = null;
//invokeinterface
in.methodA();
}
public void info(){
}
public void display(Father f){
f.showCommon();
}
public static void main(String[] args) {
Son so = new Son();
so.show();
}
}
interface MethodInterface{
void methodA();
}
動态調用指令:
- invokedynamic:動态解析出需要調用的方法,然後執行
前四條指令固化在虛拟機内部,方法的調用執行不可人為幹預,而 invokedynamic 指令則支援由使用者确定方法版本。其中 invokestatic 指令和 invokespecial 指令調用的方法稱為非虛方法,其餘的(fina1 修飾的除外)稱為虛方法。
關于 invokedynamic 指令
- JVM 位元組碼指令集一直比較穩定,一直到 Java7 中才增加了一個 invokedynamic 指令,這是Java 為了實作「動态類型語言」支援而做的一種改進。(Java本身還是一種靜态類型語言)
- 但是在 Java7 中并沒有提供直接生成 invokedynamic 指令的方法,需要借助 ASM 這種底層位元組碼工具來産生 invokedynamic 指令。直到 Java8 的 Lambda 表達式的出現,invokedynamic 指令的生成,在 Java 中才有了直接的生成方式。
- Java7 中增加的動态語言類型支援的本質是對 Java 虛拟機規範的修改,而不是對 Java 語言規則的修改,這一塊相對來講比較複雜,增加了虛拟機中的方法調用,最直接的受益者就是運作在 Java 平台的動态語言的編譯器。
代碼示範:
/**
* 體會invokedynamic指令
* @author shkstart
* @create 2020 下午 3:09
*/
@FunctionalInterface
interface Func {
public boolean func(String str);
}
public class Lambda {
public void lambda(Func func) {
return;
}
public static void main(String[] args) {
Lambda lambda = new Lambda();
Func func = s -> {
return true;
};
lambda.lambda(func);
lambda.lambda(s -> {
return true;
});
}
}
運作結果:
動态類型語言和靜态類型語言
動态類型語言和靜态類型語言兩者的差別就在于對類型的檢查是在編譯期還是在運作期,滿足前者就是靜态類型語言,反之是動态類型語言。
說的再直白一點就是,靜态類型語言是判斷變量自身的類型資訊;動态類型語言是判斷變量值的類型資訊,變量沒有類型資訊,變量值才有類型資訊,這是動态語言的一個重要特征。
舉例
- Java:String info = “lxylovejava”; // info = 123 這樣就會報錯. info被賦予String的類型資訊.
- JS:var name = “lxy123456”; var name = 10;//兩種寫法都可以,因為是運作期根據值确定類型的 name沒有類型資訊
- Python: info = 130.5; //Python更牛叉,類型聲明都不需要了…
4.8.6. 方法重寫的本質
Java 語言中方法重寫的本質:
- 找到操作數棧頂的第一個元素所執行的對象的實際類型,記作 C。(也就是說重寫會去操作數棧棧頂擷取到對象的引用類型,也就是符号引用,通過這個對象的符号引用就可以在堆中找到這個對象.)
- 如果在類型 C 中找到與常量中的描述符合簡單名稱都相符的方法,則進行通路權限校驗,如果通過則傳回這個方法的直接引用,查找過程結束;如果不通過,則傳回 java.lang.IllegalAccessError 異常。
- 否則,按照繼承關系從下往上依次對 C 的各個父類進行第 2 步的搜尋和驗證過程。
- 如果始終沒有找到合适的方法,則抛出 java.lang.AbstractMethodsError 異常。
總結:
- 在編譯階段,編譯器隻知道對象的靜态類型(類),而不知道實際類型,是以隻能在class檔案中确定調用父類的方法。
- 在執行過程中,它将判斷對象的實際類型。如果實際類型實作了這種方法,它将被直接調用。如果沒有實作,它将根據繼承關系從下到上進行檢索。隻要檢索到,它将被調用。如果沒有檢索到,它将被抛棄。繼續向上層尋找.如果最後沒有找到,則說明抽象方法沒有被實作,則抛出AbstractMethodsError
IllegalAccessError 介紹
程式試圖通路或修改一個屬性或調用一個方法,這個屬性或方法,你沒有權限通路。一般的,這個會引起編譯器異常。這個錯誤如果發生在運作時,就說明一個類發生了不相容的改變。
4.8.7. 方法的調用:虛方法表
在面向對象的程式設計中,會很頻繁的使用到動态分派,如果在每次動态分派(invokevirtual)的過程中都要重新在類的方法中繼資料中搜尋合适的目标的話就可能影響到執行效率。是以,為了提高性能,JVM 采用在類的方法區建立一個虛方法表 (virtual method table)(非虛方法不會出現在表中)來實作。使用索引表來代替查找。
每個類中都有一個虛方法表,表中存放着各個方法的實際入口。(每次調用方法,直接從虛方法表中找各個方法的是哪個類型的資訊)
虛方法表是什麼時候被建立的呢?
虛方法表會在類加載的連結階段被建立并開始初始化,類的變量初始值準備完成之後,JVM 會把該類的方法表也初始化完畢。
代碼示範:
舉例 1:
舉例 2:
/**
* 虛方法表的舉例
*
* @author shkstart
* @create 2020 下午 1:11
*/
interface Friendly {
void sayHello();
void sayGoodbye();
}
class Dog {
public void sayHello() {
}
public String toString() {
return "Dog";
}
}
class Cat implements Friendly {
public void eat() {
}
public void sayHello() {
}
public void sayGoodbye() {
}
protected void finalize() {
}
public String toString(){
return "Cat";
}
}
class CockerSpaniel extends Dog implements Friendly {
public void sayHello() {
super.sayHello();
}
public void sayGoodbye() {
}
}
public class VirtualMethodTable {
}
4.9. 方法傳回位址(return address)
存放調用該方法的 pc 寄存器的值。一個方法的結束,有兩種方式:
- 正常執行完成
- 出現未處理的異常,非正常退出
無論通過哪種方式退出,在方法退出後都傳回到該方法被調用的位置。方法正常退出時,調用者的 pc 計數器的值作為傳回位址,即調用該方法的指令的下一條指令的位址。而通過異常退出的,傳回位址是要通過異常表來确定,棧幀中一般不會儲存這部分資訊。
總結:
- 當執行到A調用B的方法時,pc記錄的是A的下一條指令,當B的棧幀被建立并作為目前棧幀時同時也擷取到pc中的值并生成了傳回位址,當B方法return,pc的值就是傳回位址 (注意:傳回位址和傳回值是兩回事哦)
- 方法傳回位址記錄的是目前棧幀的上一級棧幀的執行位置 而pc寄存器存儲的永遠是目前棧幀的執行位置
當一個方法開始執行後,隻有兩種方式可以退出這個方法:
- 執行引擎遇到任意一個方法傳回的位元組碼指令(return),會有傳回值傳遞給上層的方法調用者,簡稱正常完成出口;
- 一個方法在正常調用完成之後,究竟需要使用哪一個傳回指令,還需要根據方法傳回值的實際資料類型而定。
- 在位元組碼指令中,傳回指令包含 ireturn(當傳回值是 boolean,byte,char,short 和 int 類型時使用),lreturn(Long 類型),freturn(Float 類型),dreturn(Double 類型),areturn。另外還有一個 return 指令聲明為 void 的方法,執行個體初始化方法,類和接口的初始化方法使用。
- 在方法執行過程中遇到異常(Exception),并且這個異常沒有在方法内進行處理,也就是隻要在本方法的異常表中沒有搜尋到比對的異常處理器,就會導緻方法退出,簡稱異常完成出口。
代碼示範:
/**
*
* 傳回指令包含ireturn(當傳回值是boolean、byte、char、short和int類型時使用)、
* lreturn、freturn、dreturn以及areturn,另外還有一個return指令供聲明為void的方法、
* 執行個體初始化方法、類和接口的初始化方法使用。
*
* @author shkstart
* @create 2020 下午 4:05
*/
public class ReturnAddressTest {
//構造方法傳回指令:return
public boolean methodBoolean() {
return false;//ireturn;
}
public byte methodByte() {
return 0;//ireturn;
}
public short methodShort() {
return 0;//ireturn;
}
public char methodChar() {
return 'a';//ireturn;
}
public int methodInt() {
return 0;//ireturn;
}
public long methodLong() {
return 0L;//lreturn;
}
public float methodFloat() {
return 0.0f;//freturn;
}
public double methodDouble() {
return 0.0;//dreturn;
}
public String methodString() {
return null;//areturn;
}
public Date methodDate() {
return null;//areturn;
}
public void methodVoid() {//return;
}
static {
int i = 10;
}
//
public void method2() {
methodVoid();
try {
method1();
} catch (IOException e) {
e.printStackTrace();
}
}
public void method1() throws IOException {
FileReader fis = new FileReader("atguigu.txt");
char[] cBuffer = new char[1024];
int len;
while ((len = fis.read(cBuffer)) != -1) {
String str = new String(cBuffer, 0, len);
System.out.println(str);
}
fis.close();
}
}
方法執行過程中,抛出異常時的異常處理,存儲在一個異常處理表,友善在發生異常的時候找到處理異常的代碼,如下圖所示:
Exception table:
from to target type
4 16 19 any //位元組碼4-16行出任何問題了,按照19行的解決方法處理.
19 21 19
本質上,方法的退出就是目前棧幀出棧的過程。此時,需要恢複上層方法的局部變量表、操作數棧、将傳回值壓入調用者棧幀的操作數棧、設定 PC 寄存器值(用傳回位址)等,讓調用者方法繼續執行下去。
正常完成出口和異常完成出口的差別在于:通過異常完成出口退出的不會給他的上層調用者産生任何的傳回值。
注意:
- 傳回位址和傳回值是兩回事,目前方法正常執行兩者都有,既能接續上層方法又向其傳遞傳回值;目前方法異常且未處理兩者都沒,此時能否接續上層方法的依據是上層方法的異常表.
- 每個方法對應一個異常處理表,方法對應着棧幀,棧幀存在于Java虛拟機棧,Java虛拟機棧和本地方法棧兩者是不一樣的.
4.10. 一些附加資訊
棧幀中還允許攜帶與 Java 虛拟機實作相關的一些附加資訊。例如:對程式調試提供支援的資訊。
4.11. 棧的相關面試題
- 舉例棧溢出的情況?(StackOverflowError)
- 通過 -Xss 設定棧的大小 (記憶體空間不足出現OOM)
- 調整棧大小,就能保證不出現溢出麼?
- 不能保證不溢出 (可以延遲其溢出時間…)
- 配置設定的棧記憶體越大越好麼?
- 不是,一定時間内降低了 OOM 機率,但是會擠占其它的線程空間,因為整個空間是有限的。
- 垃圾回收是否涉及到虛拟機棧?
- 不會
- 方法中定義的局部變量是否線程安全?
- 具體問題具體分析。如果對象是在内部産生,并在内部消亡,沒有傳回到外部,那麼它就是線程安全的,反之則是線程不安全的。
方法中定義的局部變量是否線程安全?
代碼示範
/**
* 面試題:
* 方法中定義的局部變量是否線程安全?具體情況具體分析
*
* 何為線程安全?
* 如果隻有一個線程才可以操作此資料,則必是線程安全的。
* 如果有多個線程操作此資料,則此資料是共享資料。如果不考慮同步機制的話,會存線上程安全問題。
* @author shkstart
* @create 2020 下午 7:48
*/
public class StringBuilderTest {
int num = 10;
//s1的聲明方式是線程安全的 内部産生内部消亡就是線程安全的!
public static void method1(){
//StringBuilder:線程不安全
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
//...
}
//sBuilder的操作過程:是線程不安全的 sBuilder建立後除了目前方法,可能被其他多個線程操作..
public static void method2(StringBuilder sBuilder){
sBuilder.append("a");
sBuilder.append("b");
//...
}
//s1的操作:是線程不安全的 因為有傳回值,可能被多個線程搶用.
public static StringBuilder method3(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1;
}
//s1的操作:是線程安全的 類似于情況1
public static String method4(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1.toString();//傳回的String可能被多個線程強用,但是StringBuilder是安全的,随着方法的消亡而消亡.
}
public static void main(String[] args) {
StringBuilder s = new StringBuilder();
new Thread(() -> {//可以看出method2是線程不安全的.
s.append("a");
s.append("b");
}).start();
method2(s);
}
}
- 比較運作時資料區的Error和GC的情況
運作時資料區 | 是否存在 Error | 是否存在 GC |
程式計數器 | 否 | 否 |
虛拟機棧 | 是(SOE) | 否 |
本地方法棧 | 是 | 否 |
方法區 | 是(OOM) | 是 |
堆 |