運作時資料區概述和線程
1.運作時資料區概述
!
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAzNfRHLGZkRGZkRfJ3bs92YsYTMfVmepNHL0kFVOFTSU9kMRpHW3BjMMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnL4AjM5IzNzAjMyIjNwEjMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
記憶體是非常重要的系統資源,是硬碟和CPU的中間倉庫及橋梁,承載着作業系統和應用程式的實時運作。JVM記憶體布局規定了Java在運作過程中記憶體申請、配置設定、管理的政策,保證了JVM的高效穩定運作。不同的JVM對于記憶體的劃分方式和管理機制存在着部分差異。結合JVM虛拟機規範,來探讨一下經典額JVM記憶體布局。
Java虛拟機定義了若幹種程式運作期間會使用到的運作時資料區,其中有一些會随着虛拟機的啟動而建立,随着虛拟機退出而銷毀。另外一些則是與線程一一對應的,這些與線程對應的資料區域會随着線程開始和結束而建立和銷毀。
灰色的為單獨線程私有的,紅色的為多個線程共享的。即:
- 每個線程:獨立包括程式計數器、棧、本地棧。
- 線程間共享:堆、堆外記憶體(永久代或元空間、代碼緩存)
每個JVM隻有一個Runtime執行個體。即為運作時環境,相當于記憶體結構的中間的那個框框:運作時環境。
2.線程
- 線程是一個程式裡的運作單元。JVM允許一個應用有多個線程并行的執行。
- 在Hotspot JVM裡,每個線程都與作業系統的本地線程直接映射。
- 當一個Java線程準備好執行以後,此時一個作業系統的本地線程也同時建立。Java線程執行終止後,本地線程也會回收。
- 作業系統負責所有線程的安排排程到任何一個可用的CPU上。一旦本地線程初始化成功,它就會調用Java線程中的run()方法。
- 如果你使用jconsole或者是任何一個調試工具,都能看到在背景有許多線程在運作。這些背景線程不包括調用public static void main(String[] args)的main線程以及所有這個main線程自己建立 線程。
- 這些主要的背景系統線程在Hotspot JVM裡主要是以下幾個:
- 虛拟機線程:這種線程的操作是需要JVM達到安全點才會出現。這些操作必須在不同的線程中發生的原因是他們都需要JVM達到安全點,這樣堆才不會變化。這種線程的執行類型包括“stop-the-world”的垃圾收集,線程棧收集,線程挂起以及偏向鎖撤銷。
- 周期任務線程:這種線程是時間周期時間的展現(比如中斷),他們一般用于周期性操作的排程執行。
- GC線程:這種線程對在JVM裡不同種類的垃圾收集行為提供了支援。
- 編譯線程:這種線程在運作時會将位元組碼編譯成本地代碼。
- 信号排程線程:這種線程接收信号并發送給JVM,在它内部通過調用适當的方法進行處理。
3.程式計數器(PC寄存器)
3.1 PC Register介紹
JVM中的程式計數寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存儲指令相關的現場資訊。CPU隻有把資料裝載到寄存器才能夠運作。
這裡,并非是廣義上所指的實體寄存器,或許将其翻譯為PC計數器(或指令計數器)會更加貼切(也稱為程式鈎子),并且也不容易引起一些不必要的誤會。JVM中的PC寄存器是對實體PC寄存器的一種抽閑模拟。
作用:PC寄存器用來存儲指向下一條指令的位址,也即将要執行的指令代碼。由執行引擎讀取下一條指令。
- 它是一塊很小的記憶體空間,幾乎可以忽略不記。也是運作速度最快的存儲區域。
- 在JVM規範中,每個線程都有它自己的程式計數器,是線程私有的,生命周期與線程的生命周期保持一緻。
- 任何時間一個線程都隻有一個方法在執行,也就是所謂的目前方法。程式計數器會存儲目前線程正在執行的Java方法的JVM指令位址;或者,如果是在執行native方法,則是未指定值(undefined)。
- 它是程式控制流的訓示器,分支、循環、跳轉、異常處理、線程恢複等基礎功能都需要依賴這個計數器來完成。
- 位元組碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令。
- 它是唯一一個在Java虛拟機規範中沒有定任何OutOfMemoryError情況的區域。
3.2 PC寄存器使用舉例
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);
}
}
3.3 PC寄存器的兩個常見問題
使用PC寄存器存儲位元組碼指令位址有什麼用呢?
為什麼使用PC寄存器記錄目前線程的執行位址呢?
因為CPU需要不停的切換各個線程,這時候切換回來以後,就得知道接着從哪開始繼續執行。
JVM的位元組碼解釋器就需要通過改變pc寄存器的值來明确下一條應該執行什麼樣的位元組碼指令。
PC寄存器為什麼會被設定為線程私有?
我們都知道所謂的多線程在一個特定的時間段内隻會執行其中某一個線程的方法,CPU會不停的做任務切換,這樣必然導緻經常中斷或恢複,如何保證分毫無差呢?為了能夠準确的記錄各個線程正在執行的目前位元組碼指令位址,最好的辦法自然是為每一個線程都配置設定一個pc寄存器,這樣一來各個線程之間便可以進行獨立計算,進而不會出現互相幹擾的情況。
由于CPU時間片輪轉限制,衆多線程在并發執行過程中,任何一個确定的時刻,一個處理器或者多核處理器中的一個核心,隻會執行某個線程中的一條指令。
這樣必然導緻經常中斷或恢複,如何保證分毫無差呢?每個線程在建立後,都會産生自己的程式計數器和棧幀,程式計數器在各個線程之間互不影響。
CPU時間片及CPU配置設定給各個程式的時間,每個線程被配置設定一個時間段,稱作它的時間片。
在宏觀上:我們可以同僚打開多個應用程式,每個程式并行不悖,同時運作。
但在微觀上:由于隻有一個CPU,一次隻能處理程式要求的一部分,如何處理公平,一種方法就是引入時間片,每個程式輪流執行。
4. 虛拟機棧
4.1 虛拟機棧概述
虛拟機棧出現的背景
由于跨平台性的設計,Java的指令都是根據棧來設計的。不同平台CPU架構不同,是以不能設計為基于寄存器的。
優點是跨平台,指令集小,編譯器容易實作,缺點是性能下降,實作同樣的功能需要更多的指令。
有不少Java開發人員一提到Java記憶體結構,就會非常粗粒度的将JVM中的記憶體區了解為僅有Java堆(heap)和Java棧(stack)?為什麼?
記憶體中的棧與堆
棧是運作時的機關,而堆是存儲的機關。
即:棧解決程式的運作問題,即程式如何執行,或者說如何處理資料。堆解決的是資料存儲的問題,即資料怎麼存放、放在哪兒。
4.2 虛拟機棧基本内容
- Java虛拟機棧(Java virtual Machine Stack),早起也叫Java棧。每個線程在建立時都會建立一個虛拟機棧,其内部儲存一個個的棧幀(Stack Frame),對應這一次次的Java方法調用。(是線程私有的)
- 生命周期:生命周期和線程一緻
- 作用:主管Java程式的運作,它儲存方法的局部變量(8中基本資料類型、對象的引用位址)、部分結果,并參與方法的調用和傳回。(局部變量vs成員變量——屬性;基本資料類型變量vs引用資料類型變量——類、數組、接口)
package com.atguigu.java;
/**
* @author shkstart
* @create 2020 下午 8:32
*/
public class StackTest {
public static void main(String[] args) {
StackTest test = new StackTest();
test.methodA();
}
public void methodA() {
int i = 10;
int j = 20;
methodB();
}
public void methodB(){
int k = 30;
int m = 40;
}
}
棧的特點(優點):
- 棧是一種快速有效的配置設定存儲方式,通路速度僅次于程式計數器
- JVM直接堆Java棧的操作隻有兩個:
- 每個方法執行,伴随着進棧(入棧和出棧)
- 執行結束後的出棧操作
- 對于棧來說不存在垃圾回收問題
棧中可能出現的異常:
- Java虛拟機規範允許Java棧的大小是動态的或者是固定不變的。
- 如果采用固定大小的Java虛拟機棧,那每一個線程的Java虛拟機棧容量可以線上程建立的時候獨立標明。如果線程請求配置設定的棧容量超過Java虛拟機棧允許的最大容量,Java虛拟機将會抛出一個StackOverflowError異常。
- 如果Java虛拟機棧可以動态擴充,并且在嘗試擴充的時候無法申請到足夠的記憶體,或者在建立新的線程時沒有足夠的記憶體區建立對應的虛拟機棧,那麼Java虛拟機将會抛出OutOfMemoryError異常。
例:示範棧中的異常
/**
* 示範棧中的異常:StackOverflowError
* @author shkstart
* @create 2020 下午 9:08
*
* 預設情況下:count : 11323
* 設定棧的大小: -Xss256k : count : 2461
*/
public class StackErrorTest {
private static int count = 1;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
}
設定棧記憶體大小:
我們可以使用-Xss選項來設定線程的最大空間,棧的大小直接決定了函數調用的最大可達深度。
4.3 棧的存儲機關
棧中存儲什麼?
- 每個線程都有自己的棧,棧中的資料都是以棧幀(Stack Frame)的格式存在。
- 在這個線程上正在執行的每個方法都各自對應一個棧幀(Stack Frame)。
- 棧幀是一個記憶體區塊,是一個資料集,維系着方法執行過程中的各種資料資訊。
棧運作原理:
- JVM直接堆Java棧的操作隻有兩個,就是堆棧幀的壓棧和出棧,遵循“先進後出”/“後進先出”原則。
- 在一條活動線程中,一個時間點上,隻會有一個活動的棧幀。即隻有目前正在執行的方法的棧幀(棧頂棧幀)是有效的,這個棧幀被稱為目前棧幀(Current Frame),與目前棧幀對應的方法就是目前方法(Current Method),定義這個方法的類就是目前類(Current Class)。
- 執行引擎運作的所有位元組碼指令隻針對目前棧幀進行操作。
- 如果在該方法中調用了其他方法,對應的新的棧幀會被建立出來,放在棧的頂端,稱為新的目前棧幀
/**
* @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()執行結束...");
// System.out.println(10 / 0);
// return ;//可以省略
}
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;
}
}
- 不同線程中所包含的棧幀是不允許存在互相引用的,即不可能在一個棧幀中引用另外一個線程的棧幀。
- 如果目前方法調用了其他方法,方法傳回之際,目前棧幀會傳回此方法的執行結果給前一個棧幀,接着虛拟機會丢棄目前棧幀,使得前一個棧幀重新稱為目前棧幀。
- Java方法有兩種傳回函數的方式,一種是正常的函數傳回,使用return指令;另一種是抛出異常,不管使用哪種方式,都會導緻棧幀被彈出。
4.4 棧幀的内部結構
每個棧幀中存儲着:
- 局部變量表(Local Variables)
- 操作數棧(Operand Stack)(或表達式棧)
- 動态連結(Dynamic Linking)(或執行運作時常量池的方法引用)
- 方法傳回位址(Return Address)(或方法正常退出或者異常退出的定義)
- 依稀附加資訊
多個線程的情況
4.4.1 局部變量表
示例:驗證局部變量表的大小是在編譯器确定下來的
import java.util.Date;
/**
* @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 static void testStatic(){
LocalVariablesTest test = new LocalVariablesTest();
Date date = new Date();
int count = 10;
System.out.println(count);
//因為this變量不存在于目前方法的局部變量表中!!
// System.out.println(this.count);
}
//關于Slot的使用的了解
public LocalVariablesTest(){
this.count = 1;
}
public void test1() {
Date date = new Date();
String name1 = "atguigu.com";
String result = test2(date, name1);
System.out.println(date + name1);
}
public String test2(Date dateP, String name2) {
dateP = null;
name2 = "songhongkang";
double weight = 130.5;//占據兩個slot
char gender = '男';
return dateP + name2;
}
public void test3() {
this.count++;
}
public void test4() {
int a = 0;
{
int b = 0;
b = a + 1;
}
//變量c使用之前已經銷毀的變量b占據的slot的位置
int c = a + 1;
}
/*
變量的分類:按照資料類型分:① 基本資料類型 ② 引用資料類型
按照在類中聲明的位置分:① 成員變量:在使用前,都經曆過預設初始化指派
類變量: linking的prepare階段:給類變量預設指派 ---> initial階段:給類變量顯式指派即靜态代碼塊指派
執行個體變量:随着對象的建立,會在堆空間中配置設定執行個體變量空間,并進行預設指派
② 局部變量:在使用前,必須要進行顯式指派的!否則,編譯不通過
*/
public void test5Temp(){
int num;
//System.out.println(num);//錯誤資訊:變量num未進行初始化
}
}
使用javap -v LocalVariablesTest.class 指令或者 jclasslib工具(idea插件)可以看到
使用jclasslib插件檢視
- 方法嵌套調用的次數由棧的大小決定。一般來說,棧越大,方法嵌套調用次數越多。對一個函數而言,他的參數和局部變量表越多,使得局部變量表膨脹,他的棧幀就越大,以滿足方法調用所需傳遞的資訊增大的需求。進而函數調用就會占用更多的棧空間,導緻其嵌套調用次數就會減少。
- 局部變量表中的變量隻在目前方法調用中有效。在方法執行時,虛拟機通過使用局部變量表完成參數值到參數變量清單的傳遞過程。當方法調用結束後,随着方法棧幀的銷毀,局部變量表也會随之銷毀
示範靜态函數的位元組碼 代完成
上圖中Start PC是變量作用域起始的位置,length是作用域的長度
關于Slot的了解
- 參數值得存放總是在局部變量數組的index0開始,到數組長度-1的索引結束。
- 局部變量表,最基本的存儲單元是Slot(變量槽)
- 局部變量表中存放編譯期可知的各種基本資料類型(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處,其餘的參數按照參數表順序繼續排列
例:
例:
Slot的重複利用
棧幀中的局部變量表中的槽位是可以重用的。如果一個局部變量過了其作用域,那麼在其作用域之後聲明的新的局部變量就會有可能會複用過期局部變量的槽位,進而達到節省資源的目的。
例:
例:
舉例:靜态變量與局部變量的對比
- 參數表配置設定完畢之後,再根據方法體内定義的變量的順序和作用域配置設定。
- 我們知道類變量表由兩次初始化的機會,第一次是在“準備階段”,執行系統初始化,對類變量設定零值,另一次則是在“初始化”階段,賦予程式員在代碼中定義的初始值。
- 和類變量初始化不同的是,局部變量表不存在系統初始化的過程,這意味着一旦定義了局部變量則必須人為的初始化,否則無法使用。
例:這樣的代碼是錯誤的,沒有指派不能夠使用
變量的分類:
按照資料類型分:①基本資料類型 ②引用資料類型
按照在類中聲明的位置分:①成員變量:在使用前,都經過預設初始化指派(類變量-》linking的prepare階段:給類變量預設指派,initial階段:給類變量顯示指派,即靜态代碼塊指派;執行個體變量-》随着對象的建立,會在堆空間中配置設定執行個體變量空間,并進行預設指派);②局部變量:在使用前必須要進行顯示指派,否則,編譯不通過。
補充說明:
- 在棧幀中,與性能調優關系最為密切的部分就是前面提到的局部變量表。在方法執行時,虛拟機使用局部變量表完成方法的傳遞。
- 局部變量表中的變量也是重要的垃圾回收根節點,隻要被局部變量表中直接或間接引用的對象都不會被回收
4.4.2 操作數棧 (Operand Stack)
棧:先進後出,後進先出,可以由數組和連結清單實作
- 每一個獨立的棧幀中除了包含局部變量表以外,還包含一個後進先出(Last-In-First-Out)的操作數棧,也可以稱為表達式棧(Expression Stack)
- 操作數棧,在方法執行過程中,根據位元組碼指令,往棧中寫入資料或提取資料,即入棧(push)/出棧(pop)。
- 某些位元組碼指令将值壓入操作數棧,其餘的位元組碼指令将操作數取出棧。使用他們後再把結果壓入棧。
- 比如執行指派、交換、求和等操作
例:
- 操作數棧,主要用于儲存計算過程的中間結果,同時作為計算過程中變量臨時的存儲空間
- 操作數棧就是JVM執行引擎的一個工作區,當一個方法剛開始執行的時候,一個新的棧幀也會随之被建立出來,此時這個方法的操作數棧是空的。
- 每一個操作數棧都會擁有一個明确的棧深度用于存儲數值,其所需的最大深度在編譯器就定義好了,儲存在方法的Code屬性中,為max_stack的值。
- 棧中的任何一個元素都是可以任意的Java資料類型。
- 32bit的類型占用一個棧機關深度
- 64bit的類型占用兩個棧機關深度
- 操作數棧并非采用方位索引的方式來進行資料通路的,而是隻能通過标準的入棧(push)和出棧(pop)操作來完成一次資料通路
- 如果被調用的方法帶有傳回值的話,其傳回值将會被壓入目前棧幀的操作數棧中,并更新PC寄存器中下一條需要執行的位元組碼指令。
- 操作數棧中元素的資料類型必須與位元組碼指令的序列嚴格比對,這由編譯器在編譯期間進行驗證,同時在類加載過程中的類檢驗階段的資料流分析階段要再次驗證。
- 另外,我們說java虛拟機的解釋引擎是基于棧的執行引擎,其中棧指的就是操作數棧。
4.4.3 代碼追蹤
bipush:把15 放到操作數棧,此時pc寄存器的指令位址為0;接着pc寄存器指令位址下移到指令位址為2的指令,istore_1,i表示int類型,_1表示将棧頂資料拿出儲存在局部變量表中索引為1的變量中;
iload_1、iload_2表示将局部變量表中索引為1和2的變量的值取出,以此入棧
iadd是将操作數棧中的資料依次出棧,進行add操作;istore_3表示将add的結果以int類型儲存在局部變量表中索引為3的變量中;
帶傳回值的例子
--------在面試過程中經常遇到的i++和++i的差別 -------------
package com.atguigu.java1;
/**
* @author shkstart
* @create 2020 下午 10:25
*/
public class OperandStackTest {
public void testAddOperation() {
//byte、short、char、boolean:都以int型來儲存
byte i = 15;
int j = 8;
int k = i + j;
// int m = 800;
}
public int getSum(){
int m = 10;
int n = 20;
int k = m + n;
return k;
}
public void testGetSum(){
//擷取上一個棧桢傳回的結果,并儲存在操作數棧中
int i = getSum();
int j = 10;
}
/*
程式員面試過程中, 常見的i++和++i 的差別,放到位元組碼篇章時再介紹。
*/
public void add(){
//第1類問題:
int i1 = 10;
i1++;
int i2 = 10;
++i2;
//第2類問題:
int i3 = 10;
int i4 = i3++;
int i5 = 10;
int i6 = ++i5;
//第3類問題:
int i7 = 10;
i7 = i7++;
int i8 = 10;
i8 = ++i8;
//第4類問題:
int i9 = 10;
int i10 = i9++ + ++i9;
}
}
4.4.4 棧頂緩存技術(Top-of-stack Cashing)
前面提過,基于棧式架構的虛拟機所使用的領位址指令更加緊湊,但完成一項操作的時候必然需要使用更多的入棧和出棧指令,這同時也就意味着将需要更多的指令分派(instruction dispatch)次數和記憶體讀寫次數。
由于操作數棧是存儲在記憶體中的,是以頻繁地執行記憶體讀寫操作必然會影響執行速度。為了解決這個問題,HotSpot JVM的設計者們提出了棧頂緩存(ToS,Top-of-Stack Cashing)技術,将棧頂元素全部緩存在實體cpu的寄存器中,以此降低對記憶體的讀寫次數,提升執行引擎的執行效率。
4.4.5 動态連結 (Dynamic Linking)
動态連結(或指向運作時常量池的方法引用)
- 每一個棧幀内部都包含一個執行運作時常量池中該棧幀所屬方法的引用。包含這個引用的目的就是為了支援目前方法的代碼能夠實作動态連結(Dynamic Linking)。比如invokedynamic指令
- 在java源檔案被編譯到位元組碼檔案中時,所有的變量和方法引用都作為符号引用(Symbolic Reference)儲存在class檔案的常量池裡。比如:描述一個方法調用了另外的其他方法時,就是通過常量池中指向方法的符号引用來表示的,那麼動态連結的作用就是為了将這些符号引用轉換為調用方法的直接引用。
package com.atguigu.java1;
/**
* @author shkstart
* @create 2020 下午 10:25
*/
public class DynamicLinkingTest {
int num = 10;
public void methodA(){
System.out.println("methodA()....");
}
public void methodB(){
System.out.println("methodB()....");
methodA();
num++;
}
}
javap -v DynamicLinkingTest.class
看methodB方法的位元組碼指令和動态連結
#開始的為符号引用,箭頭沒有說明的是直接引用
為什麼需要常量池?
常量池的作用,就是為了提供一些符合和常量,便于指令的識别。
4.4.6 方法的調用
在JVM中,将符号引用轉換為調用方法的直接引用與方法的綁定機制相關。
- 靜态連結:
- 當一個位元組碼檔案被裝載今JVM内部時,如果被調用的目标方法在編譯期可知,且運作期保持不變時。這種情況下降調用方法的符号引用轉換為直接引用的過程稱之為靜态連結
- 動态連結:
- 如果被調用的方法在編譯期無法被确定下來,也就是說,隻能夠在程式運作期将調用方法的符号引用轉換為直接引用,由于這用引用轉換過程具備動态性,是以也就被稱之為動态連結。
對應的方法的綁定機制為:早起綁定(Early Binding)和晚期綁定(Late Binding)。綁定是一個字段、方法或者類在符号引用被替換直接引用的過程,這僅僅發生一次。
- 早起綁定:
- 早起綁定就是指被調用的目标方法如果在編譯期可知,且運作期保持不變時,即可将這個方法與所屬的類型進行綁定,這樣一來,由于明确了被調用的目标方法究竟是哪一個,是以也就可以使用靜态連結的方式将符号引用轉換為直接引用。
- 晚期綁定:
- 如果被調用的方法在編譯期無法被确定下來,隻能夠在程式運作期根據實際的類型綁定相關的方法,這種綁定方式也就被稱之為晚期綁定。
例:
package com.atguigu.java2;
/**
* 說明早期綁定和晚期綁定的例子
*
* @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();//表現為:晚期綁定
}
}
多态的調用 屬于晚期引用
多個構造函數調用父類的構造方法或其他構造方法,即方法調用指定的方法屬于早起綁定
随着進階語言的橫空出世,類似于Java一樣的基于面向對象的程式設計語言如今越來越多,盡管這類程式設計語言在文法風格上存在一定的差别,但是他們彼此之間始終保持着一個共性,那就是都支援封裝、繼承和多态等面向對象特性,既然這一類的程式設計語言具備多态特性,那麼自然也就具備早起綁定和晚期綁定兩種綁定方式。
Java中任何一個普通的方法其實都具備虛函數的特征,他們相當于C++語言中的虛函數(C++中則需要使用關鍵字virtual來顯示定義)。如果在Java程式中不希望某個方法擁有虛函數的特征時,則可以使用關鍵字final來标記這個方法。
虛函數就是在運作期才能确定下來的方法
虛方法與非虛方法:
非虛方法:
- 如果方法在編譯期就确定了具體的調用版本,這個版本在運作時時不可變的。這樣的方法稱為非虛方法。
- 靜态方法、私有方法、final方法、執行個體構造器、父類方法都是非虛方法。
- 其他方法稱為虛方法。
虛拟機中提供了以下幾條方法調用指令:
- 普通調用指令:
- invokestactic:調用靜态方法,解析階段确定唯一方法版本
- invokespecial:調用<init>方法、私有及父類方法,解析階段确定唯一方法版本
- invokevirtual:調用所有虛方法
- invokeinterface:調用接口方法
- 動态調用指令:
- invokedynamic:動态解析出需要調用的方法,然後執行
前四條指令固化在虛拟機内部,方法的調用執行不可人為幹預,而invokedynamic指令則支援由使用者确定方法版本。其中invokestatic指令和invokespecial指令調用的方法稱為非虛方法,其餘的(final修飾的除外)稱為虛方法。
例:
package com.atguigu.java2;
/**
* 解析調用中非虛方法、虛方法的測試
* <p>
* 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();
//invokevirtual
showFinal();//因為此方法聲明有final,不能被子類重寫,是以也認為此方法是非虛方法。
//虛方法如下:
//invokevirtual
showCommon();
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指令
- JVM位元組碼指令集一直比較穩定,一直到Java7中才增加了一個invokedynamic指令,這是Java為了實作 “動态類型語言”支援而做的一種改進。
- 但是在Java7中并沒有提供直接生成invokedynamic指令的方法,需要借助ASM這種底層位元組碼工具來産生invokedynamic指令。直到Java8的Lamdba表達式的出現,invokedynamic指令的生成,在Java中才有了直接的生成方式。
- Java7中增加的動态語言類型支援的本質是對Java虛拟機規範的修改,而不是對Java語言規則的修改,這一塊相對來講比較複雜,增加了虛拟機中的方法調用,最直接的受益者就是運作在Java平台的動态語言的編譯器。
動态類型語言和靜态類型語言
動态類型語言和靜态類型語言兩者的差別就在于對類型的檢查是在編譯期還是在運作期,滿足前者就是靜态類型語言,反之是動态類型語言。
說的再直白一點就是,靜态類型語言是判斷變量自身的類型資訊;動态類型語言是判斷變量值的類型資訊,變量沒有類型資訊,變量值才有類型資訊,這是動态語言的一個重要特征。
例:
package com.atguigu.java2;
/**
* 體會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語言中方法重寫的本質:
- 找到操作數棧頂的第一個元素所執行的對象的實際類型,記作C。
- 如果在類型C中找到與常量中的面熟符合簡單名稱都相符的方法,則進行通路權限校驗,如果通過則傳回這個方法的直接引用,查找過程結束;如果不通過,則傳回java.lang.IllegalAccessError異常。
- 否則,按照繼承關系從下往上依次對C的各個父類進行第2步的搜尋和驗證過程。
- 如果始終沒有找到合适的方法,則抛出java.lang.AbstractMethodError異常。
IllegalAccessError介紹:
程式試圖通路或修改一個屬性或調用一個方法,這個屬性或方法,你沒有權限通路。一般的,這個會引起編譯器異常。這個錯誤如果發生在運作期,就說明一個類發生了不相容的改變。
需方發表
- 在面向對象的程式設計中,會很頻繁地使用到動态分派,如果在每次動态分派的過程中都要重新在類的方法中繼資料中搜尋合适的目标的話就可能影響到執行效率。是以,為了提高性能,JVM采用在類的方法區建立一個虛方法表(Virtual method table)(非虛方法不會出現在表中)來實作。使用索引表來代替查找。
- 每個類都有一個虛方法表,表中存放着各個方法的實際入口。
- 那麼虛方法表什麼時候被建立?虛方法表會在類加載的連結階段被建立并開始初始化,類的變量初始值準備完成之後,JVM會把該類的方發表也初始化完畢。
package com.atguigu.java3;
/**
* 虛方法表的舉例
*
* @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 {
}
方法傳回位址(return address)
- 存放調用該方法的pc寄存器的值。
- 一個方法的結束,有兩種方式:
- 正常執行完成
- 出現未處理的異常,非正常退出
- 無論通過那種方式退出,在方法退出後都傳回到該方法被調用的位置。方法正常退出時,調用者的pc計數器的值作為傳回位址,及調用該方法的指定的下一條指定的位址。而通過異常退出的,傳回位址是要通過異常表來确定,棧幀中一般不會儲存這部分資訊。
當一個方法開始執行後,隻有兩種方式可以退出這個方法:
- 執行引擎遇到任何一個方法傳回的位元組碼指令(return),會有傳回值傳遞給上層的方法調用者,簡稱正常完成出口。
- 一個方法在正常調用完成之後究竟需要使用哪一個傳回指令還需要根據方法傳回值的實際資料類型而定。
- 在位元組碼指令中,傳回指令包含ireturn(當傳回值是boolean、byte、、char、short和int類型是使用)、lreturn、freturn、dreturn以及areturn,另外還有return指令供聲明為void的方法、執行個體初始化方法、類和接口的初始化方法使用。
- 在方法執行的過程中遇到了異常(Exception),并且這個異常沒有在方法内部進行處理,也就是隻要在笨方法的異常表中沒有搜尋到比對的異常處理器,就會導緻方法退出,簡稱異常完成出口。
方法執行過程中抛出異常時的異常處理,存儲在一個異常處理表,友善在發生異常的時候找到處理異常的代碼。
例:
本質上,方法的退出就是目前棧幀出棧的過程。此時,需要恢複上層方法的局部變量表、操作數棧、将傳回值壓入調用者棧幀的操作數棧、設定PC寄存器值等,讓調用者方法繼續執行下去。
正常完成出口和異常完成出口的卻别在于:通過異常完成出口退出的不會給他的上層調用者産生任何傳回值。
例:
package com.atguigu.java3;
import java.io.FileReader;
import java.io.IOException;
import java.util.Date;
/**
*
* 傳回指令包含ireturn(當傳回值是boolean、byte、char、short和int類型時使用)、
* lreturn、freturn、dreturn以及areturn,另外還有一個return指令供聲明為void的方法、
* 執行個體初始化方法、類和接口的初始化方法使用。
*
* @author shkstart
* @create 2020 下午 4:05
*/
public class ReturnAddressTest {
public boolean methodBoolean() {
return false;
}
public byte methodByte() {
return 0;
}
public short methodShort() {
return 0;
}
public char methodChar() {
return 'a';
}
public int methodInt() {
return 0;
}
public long methodLong() {
return 0L;
}
public float methodFloat() {
return 0.0f;
}
public double methodDouble() {
return 0.0;
}
public String methodString() {
return null;
}
public Date methodDate() {
return null;
}
public void methodVoid() {
}
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();
}
}
4.4.8 一些附加資訊
棧幀中還允許攜帶與Java虛拟機實作相關的一些附加資訊。例如,對程式調試提供支援的資訊。
4.4.9 棧的相關面試題
- 舉例棧溢出的情況?
- 調整棧大小,就能保證不出現溢出嗎?
- 配置設定的棧記憶體越大越好嗎?
- 垃圾回收是否會設計到虛拟機棧?
- 方法中定義的局部變量是否線程安全?
例:
package com.atguigu.java3;
/**
* 面試題:
* 方法中定義的局部變量是否線程安全?具體情況具體分析
*
* 何為線程安全?
* 如果隻有一個線程才可以操作此資料,則必是線程安全的。
* 如果有多個線程操作此資料,則此資料是共享資料。如果不考慮同步機制的話,會存線上程安全問題。
* @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的操作過程:是線程不安全的
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的操作:是線程安全的
public static String method4(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1.toString();
}
public static void main(String[] args) {
StringBuilder s = new StringBuilder();
new Thread(() -> {
s.append("a");
s.append("b");
}).start();
method2(s);
}
}
4.5 本地方法接口
設麼是本地方法?
簡單的講,一個Native Method就是一個Jva調用非Java代碼的接口。一個Native Method是這樣一個Java方法:該方法的實作是有非Java語言實作,比如C。這個特征并發Java所特有,很多其他的程式設計語言都有這一機制,比如在C++中,你可以用extern“C”告知C++編譯器去調用一個C函數。
“A native ,ethdo is a Java method whose implementation is ptovided by non-java code.”
在定義一個native method時,并不提供實作體(有些像定義一個Java interface),因為其實作體是有非java語言在外面實作的。
本地接口的作用時融合不同的程式設計語言為Java所用,它的初衷時融合C/C++程式。
為什麼要使用Native Method?
Java使用起來分成友善,然後有些層次的任務用Java實作起來不容易,或者我們對程式的效率很在意時,問題就來了。
- 與Java環境外互動:
- 有時Java應用需要與Java以外的環境互動,這是本地方法存在的主要原因。你可以想象Java需要與一些底層系統,如作業系統或某些硬體交換資訊時的情況,本地方法正是這樣一種交流機制:他為我們提供了一個非常簡潔的接口,而且我們無需去了解Java應用之外的繁瑣的細節。
- 與作業系統互動:
- JVM支援着Java語言本身和運作時庫,它是Java程式賴以生存的平台,它由一個解釋器(解釋位元組碼)和一些連接配接到本地代碼的庫組成。然而不管怎樣,它畢竟不是一個完整的系統,它經常依賴于一些底層系統的支援。這些底層系統常常是強大的作業系統。通過使用本地方法,我們得以用Java實作了jre的與底層系統的互動,甚至JVM的一些部分就是用C寫的。還有,如果我們要使用一些Java語言本身沒有提供封裝的作業系統的特性時,我們也需要使用本地方法。
- Sun's Java:
- Sun的解釋器使用C實作的,這使得它能像一些普通的C一樣與外部互動。jre大部分使用Java實作的,他也通過一些本地方法與外界互動。例如:類java.lang.Thread的setPriority()方法使用Java實作的,但是它實作調用的是該類裡的本地方法setPriority()。這個本地方法使用C實作的,并被植入JVM内部,在Windows 95的平台上,這個本地方法最終将調用Win32 SetPriority() API。這是一個本地方法的具體實作由JVM直接提供,更多的情況是本地方法由外部的動态連結庫(external dynamic link library)提供,然後被JVM調用。
現狀: 目前該方法使用的越來越少了,除非是與硬體有關的應用,比如通過Java程式驅動列印機或者Java系統管理生産裝置,在企業級應用中已經比較少見。因為現在的異構領域間的通信很發達,比如可以使用Socket通信,也可以使用Web Service等等,不多做介紹。
4.6 本地方法棧(Native Method Stack)
- Java虛拟機棧用于管理Java方法的調用,而本地方法棧用于管理本地方法的調用。
- 本地方法棧,也是線程私有的。
- 允許被實作成固定或者是可動态擴充的記憶體大小。(在記憶體溢出方面是相同的)
- 如果線程請求配置設定額棧容量超過本地方法棧允許的最大容量,Java虛拟機将會抛出一個StackOverflowError異常。
- 如果本地方法棧可以動态擴充,并且在嘗試擴充的時候無法申請到足夠的記憶體,或者在建立新的線程時沒有足夠的記憶體區建立對應的本地方法棧,那麼Java虛拟機将會抛出一個OutOfMemoryError異常。
- 本地方法是使用C語言實作的。
- 它的具體做法是Native Method Stack中登記native方法,在Execution Engine執行時加載本地方法庫。
- 當某個線程調用一個本地方法時,它就進入了一個全新的并且不再受虛拟機限制的世界。它和虛拟機擁有同樣的權限。
- 本地方法可以通過本地方法接口來通路虛拟機内部的運作時資料區。
- 它甚至可以直接使用本地處理器中的寄存器。
- 直接從本地記憶體的堆中配置設定任意數量的記憶體。
- 并不是所有的JVM都支援本地方法。因為Java虛拟機規範并沒有明确要求本地方法棧的使用語言、具體實作方式、資料結構等。如果JVM産品不打算支援native方法,也可以無需實作本地方法棧。
- 在Hotspot JVM中,直接将本地方法棧和虛拟機棧合二為一。