虛擬機器棧概述
由於跨平臺性的設計,Java 的指令都是根據棧來設計的。不同平臺CPU架構不同,所以不能設計為基於暫存器的。 棧實現的優點是跨平臺,指令集小,編譯器容易實現,缺點是效能下降,實現同樣的功能需要更多的指令。
有不少Java開發人員一提到Java記憶體結構,就會非常粗略度地將JVM中的記憶體區理解為僅有Java堆(heap)和Java棧(stack),這種劃分方式直接繼承自傳統的C、C++程式的記憶體佈局結構,在Java語言裡就顯得有些粗糙了,實際的記憶體區域劃分要比這更復雜。
首先我們要明確:
棧是執行時的單位,而堆是儲存的單位。
☆棧解決程式的執行問題,即程式如何執行,或者說如何處理資料。
☆堆解決的是資料儲存的問題,即資料怎麼放,放哪裡。
Java虛擬機器棧是什麼
Java虛擬機器棧(Java Virtual Machine Stack),早期也叫Java棧。每個執行緒在建立時都會建立一個虛擬機器棧,其內部儲存一個個的棧幀(Stack Frame),對應著一次次的Java方法呼叫。
棧幀(Stack Frame)用於儲存區域性變數表、運算元棧、動態連線、方法返回地址等資訊。每一個方法被呼叫直至執行完畢的過程就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。
生命週期
與程式計數器一樣Java虛擬機器棧(Java Virtual Machine Stack)也是執行緒私有的,它的生命週期與執行緒相同。
作用
主管Java程式的執行,它儲存方法的區域性變數、部分結果,並參與方法的呼叫和返回。
區域性變數,它是相比於成員變數來說的(或屬性)。
基本資料型別變數 VS 引用型別變數(類、陣列、介面)。
棧是一種快速有效的分配儲存方式,訪問速度僅次於程式計數器。JVM直接對Java棧的操作只有兩個:
① 每個方法執行,伴隨著進棧(入棧、壓棧)。
② 執行結束後的出棧工作。
對於棧來說不存在垃圾回收問題(棧存在溢位的情況)。
虛擬機器棧
關於棧的異常
Java 虛擬機器規範允許Java棧的大小是動態的或者是固定不變的。
如果採用固定大小的Java虛擬機器棧,那每一個執行緒的Java虛擬機器棧容量可以線上程建立的時候獨立選定。如果執行緒請求分配的棧容量超過Java虛擬機器棧允許的最大容量,Java虛擬機器將會丟擲一個 StackOverflowError異常(棧溢位)。
如果Java虛擬機器棧可以動態擴充套件,並且在嘗試擴充套件的時候無法申請到足夠的記憶體,或者在建立新的執行緒時沒有足夠的記憶體去建立對應的虛擬機器棧,那Java虛擬機器將會丟擲一個 outOfMemoryError異常(記憶體溢位)。
我們可以測試一下:
public class StackErrorTest {
private static int count = 1;
public static void main(String[] args) {
System.out.println(count++);
main(args);
}
}
執行結果:
當棧深度達到15419的時候,就出現棧記憶體空間不足。
設定棧記憶體大小
我們可以使用引數 -Xss選項來設定執行緒的最大棧空間,棧的大小直接決定了函式呼叫的最大可達深度。
示例:
✔設定棧的大小為1MB:-Xss1m。
✔設定棧的大小為1KB:-Xss1k。
Eclise 中,對當前類設定棧記憶體大小:
程式碼區域點選右鍵 --> Run As -- >RunConfigurations --> 左邊選擇要設定的類 --> 右邊點選Arguments選項 --> VM arguments引數設定 --> 然後Apply --> Run。
作用域:只對這個工程起作用。
Eclipse 中,對全域性設定棧記憶體大小:
選單欄Window --> preference --> Java --> Installed JREs --> 選擇當前所用包 --> Edit --> Default VM Arguments引數設定 --> Finish。
作用域:對所有工程都起作用。
棧的儲存單位
每個執行緒都有自己的棧,棧中的資料都是以棧幀(Stack Frame)的格式存在。
一個執行緒上正在執行的每個方法都各自對應一個棧幀(Stack Frame)。
棧幀是一個記憶體區塊,是一個資料集,維繫著方法執行過程中的各種資料資訊。
每個執行緒都有自己的棧,棧中的資料都是以棧幀(Stack Frame)的格式存在。在這個執行緒上正在執行的每個方法都各自對應一個棧幀(Stack Frame)。棧幀是一個記憶體區塊,是一個資料集,維繫著方法執行過程中的各種資料資訊。
JVM直接對Java棧的操作只有兩個,就是對棧幀的壓棧和出棧,遵循“先進後出”/“後進先出”原則。
在一條活動執行緒中,一個時間點上,只會有一個活動的棧幀。即只有當前正在執行的方法的棧幀(棧頂棧幀)是有效的,這個棧幀被稱為當前棧幀(Current Frame),與當前棧幀相對應的方法就是當前方法(Current Method),定義這個方法的類就是當前類(Current Class)。
執行引擎執行的所有位元組碼指令只針對當前棧幀進行操作。
如果在該方法中呼叫了其他方法,對應的新的棧幀會被建立出來,放在棧的頂端,成為新的當前幀。
棧的執行原理
不同執行緒中所包含的棧幀是不允許存在相互引用的,即不可能在一個棧幀之中引用另外一個執行緒的棧幀。
如果當前方法呼叫了其他方法,方法返回之際,當前棧幀會傳回此方法的執行結果給前一個棧幀,接著,虛擬機器會丟棄當前棧幀,使得前一個棧幀重新成為當前棧幀。
Java方法有兩種返回函式的方式,一種是正常的函式返回,使用return指令;另外一種是丟擲異常。不管使用哪種方式,都會導致棧幀被彈出。
棧的記憶體結構
每個棧幀中儲存著:
▶ 區域性變數表(Local Variables)
▶ 運算元棧(operand Stack)(或表示式棧)
▶ 動態連結(DynamicLinking)(或指向執行時常量池的方法引用)
▶ 方法返回地址(Return Address)(或方法正常退出或者異常退出的定義)
▶ 一些附加資訊
並行每個執行緒下的棧都是私有的,因此每個執行緒都有自己各自的棧,並且每個棧裡面都有很多棧幀,棧幀的大小主要由區域性變數表 和 運算元棧決定。
區域性變數表
區域性變數表(Local Variables),被稱之為區域性變數陣列或本地變數表。
定義為一個數字陣列,主要用於儲存方法引數和定義在方法體內的區域性變數這些資料型別包括各類基本資料型別、物件引用(reference),以及returnAddress型別。
區域性變數表所需的容量大小是在編譯期確定下來的,並儲存在方法的Code屬性的maximum local variables資料項中。在方法執行期間是不會改變區域性變數表的大小的。
方法巢狀呼叫的次數由棧的大小決定。一般來說,棧越大,方法巢狀呼叫次數越多。對一個函式而言,它的引數和區域性變數越多,使得區域性變數表膨脹,它的棧幀就越大,以滿足方法呼叫所需傳遞的資訊增大的需求。進而函式呼叫就會佔用更多的棧空間,導致其巢狀呼叫次數就會減少。
區域性變數表中的變數只在當前方法呼叫中有效。在方法執行時,虛擬機器通過使用區域性變數表完成引數值到引數變數列表的傳遞過程。當方法呼叫結束後,隨著方法棧幀的銷燬,區域性變數表也會隨之銷燬。
變數槽(Slot)
引數值的存放總是在區域性變數陣列的index 0開始,到陣列長度-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上,如果需要訪問區域性變數表中一個64 bit的區域性變數值時,只需要使用前一個索引即可(比如:訪問long或double型別變數)。
普通方法的區域性變數表
如果當前幀是由構造方法或者例項方法建立的,那麼該物件引用this將會存放在index為0的Slot處,其餘的引數按照參數列順序繼續排列。
(構造方法或)例項方法的區域性變數表
變數槽的重複利用
棧幀中的區域性變數表中的槽位是可以重用的,如果一個區域性變數過了其作用域,那麼在其作用域之後申明的新的區域性變就很有可能會複用過期區域性變數的槽位,從而達到節省資源的目的。
靜態變數與區域性變數的對比
變數的分類:
★ 按資料型別分:基本資料型別、引用資料型別。
★ 按類中宣告的位置分:成員變數(類變數,例項變數)、區域性變數。
☆ 類變數:linking的paper階段,給類變數預設賦值,init階段給類變數顯示賦值,即靜態程式碼塊。
☆ 例項變數:隨著物件建立,會在堆空間中分配例項變數空間,並進行預設賦值。
☆ 區域性變數:在使用前必須進行顯式賦值,不然編譯不通過。
參數列分配完畢之後,再根據方法體內定義的變數的順序和作用域分配。
我們知道類變數表有兩次初始化的機會,第一次是在“準備階段”,執行系統初始化,對類變數設定零值,另一次則是在“初始化”階段,賦予程式設計師在程式碼中定義的初始值。
和類變數初始化不同的是,區域性變數表不存在系統初始化的過程,這意味著一旦定義了區域性變數則必須人為的初始化,否則無法使用。
在棧幀中,與效能調優關係最為密切的部分就是前面提到的區域性變數表。在方法執行時,虛擬機器使用區域性變數表完成方法的傳遞。
區域性變數表中的變數也是重要的垃圾回收根節點,只要被區域性變數表中直接或間接引用的物件都不會被回收。
運算元棧
運算元棧(Operand Stack),每一個獨立的棧幀除了包含區域性變數表以外,還包含一個後進先出(Last-In-First-Out)的運算元棧,也可以稱之為 表示式棧(Expression Stack)。
概述
運算元棧,在方法執行過程中,根據位元組碼指令,往棧中寫入資料或提取資料,即入棧(push)和 出棧(pop)。
某些位元組碼指令將值壓入運算元棧,其餘的位元組碼指令將運算元取出棧,使用它們後再把結果壓入棧。比如:執行復制、交換、求和等操作。
求和指令
程式碼舉例:
運算元棧,主要用於儲存計算過程的中間結果,同時作為計算過程中變數臨時的儲存空間。
運算元棧就是JVM執行引擎的一個工作區,當一個方法剛開始執行的時候,一個新的棧幀也會隨之被建立出來,這個方法的運算元棧是空的。注意:這個時候陣列是有長度的,因為陣列一旦建立,那麼就是不可變的。
每一個運算元棧都會擁有一個明確的棧深度用於儲存數值,其所需的最大深度在編譯期就定義好了,儲存在方法的Code屬性中名為maxstack的值中。
棧中的任何一個元素都是可以任意的Java資料型別:
♦ 32bit的型別佔用一個棧單位深度。
♦ 64bit的型別佔用兩個棧單位深度。
運算元棧並非採用訪問索引的方式來進行資料訪問的,而是隻能通過標準的入棧和出棧操作來完成一次資料訪問。如果被呼叫的方法帶有返回值的話,其返回值將會被壓入當前棧幀的運算元棧中,並更新PC暫存器中下一條需要執行的位元組碼指令。
運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配,這由編譯器在編譯器期間進行驗證,同時在類載入過程中的類檢驗階段的資料流分析階段要再次驗證。
另外,我們說Java虛擬機器的解釋引擎是基於棧的執行引擎,其中的棧指的就是運算元棧。
程式碼追蹤
我們先寫一段程式碼:
public void testAddOperation() {
byte i = 15;
int j = 8;
int k = i + j;
}
使用javap 命令(javap -v 類名.class)反編譯class檔案:
byte、short、char、boolean 內部都是使用int型來進行儲存的。
從上面的程式碼我們可以知道,我們都是通過bipush對運算元 15 和 8進行入棧操作
同時使用的是 iadd方法進行相加操作,i 代表的就是 int,也就是int型別的加法操作
首先執行第一條語句,PC暫存器指向的是0,也就是指令地址為0,然後使用bipush讓運算元15入棧。
執行完後,讓PC + 1,指向下一行程式碼,下一行程式碼就是將運算元棧的元素儲存到區域性變數表1的位置,我們可以看到區域性變數表的已經增加了一個元素:
為什麼區域性變數表不是從0開始的呢?
然後PC+1,指向的是下一行。讓運算元8也入棧,同時執行store操作,存入區域性變數表中:
然後從區域性變數表中,依次將資料放在運算元棧中:
然後將運算元棧中的兩個元素執行相加操作,並儲存在區域性變數表3的位置:
最後PC暫存器的位置指向10,也就是return方法,則直接退出方法。
棧頂快取技術
棧頂快取技術:Top Of Stack Cashing。
前面提過,基於棧式架構的虛擬機器所使用的零地址指令更加緊湊,但完成一項操作的時候必然需要使用更多的入棧和出棧指令,這同時也就意味著將需要更多的指令分派(instruction dispatch)次數和記憶體讀/寫次數。
由於運算元是儲存在記憶體中的,因此頻繁地執行記憶體讀/寫操作必然會影響執行速度。為了解決這個問題,HotSpot JVM的設計者們提出了棧頂快取(Tos,Top-of-Stack Cashing)技術,將棧頂元素全部快取在物理CPU的暫存器中,以此降低對記憶體的讀/寫次數,提升執行引擎的執行效率。
基於暫存器架構:指令更少,執行速度快。
動態連結
動態連結、方法返回地址、附加資訊 : 這些區域在有些地方被稱為幀資料區。
動態連結:Dynamic Linking。每一個棧幀內部都包含一個指向執行時常量池中該棧幀所屬方法的引用,包含這個引用的目的就是為了支援當前方法的程式碼能夠實現動態連結(Dynamic Linking)。比如:invokedynamic指令。
在Java原始檔被編譯到位元組碼檔案中時,所有的變數和方法引用都作為符號引用(symbolic Reference)儲存在class檔案的常量池裡。
比如:描述一個方法呼叫了另外的其他方法時,就是通過常量池中指向方法的符號引用來表示的,那麼動態連結的作用就是為了將這些符號引用轉換為呼叫方法的直接引用。
因為在不同的方法裡,都可能呼叫同一常量或者方法,所以只需要儲存一份即可,節省空間。
方法呼叫:解析與分配
在JVM中,將符號引用轉換為呼叫方法的直接引用與方法的繫結機制相關。
連結
靜態連結
當一個位元組碼檔案被裝載進JVM內部時,如果被呼叫的目標方法在編譯期可知,且執行期保持不變時,這種情況下降呼叫方法的符號引用轉換為直接引用的過程稱之為靜態連結。
動態連結
如果被呼叫的方法在編譯期無法被確定下來,也就是說,只能夠在程式執行期將呼叫的方法的符號轉換為直接引用,由於這種引用轉換過程具備動態性,因此也被稱之為動態連結。
繫結機制
連結對應的方法的繫結機制為:早期繫結(Early Binding)和晚期繫結(Late Binding)。繫結是一個欄位、方法或者類在符號引用被替換為直接引用的過程,這僅僅發生一次。
早期繫結
早期繫結就是指被呼叫的目標方法如果在編譯期可知,且執行期保持不變時,即可將這個方法與所屬的型別進行繫結,這樣一來,由於明確了被呼叫的目標方法究竟是哪一個,因此也就可以使用靜態連結的方式將符號引用轉換為直接引用。
晚期繫結
如果被呼叫的方法在編譯期無法被確定下來,只能夠在程式執行期根據實際的型別繫結相關的方法,這種繫結方式也就被稱之為晚期繫結。
如果方法在編譯期就確定了具體的呼叫版本,這個版本在執行時是不可變的,這樣的方法稱為非虛方法。
靜態方法、私有方法、fina1方法、例項構造器、父類方法都是非虛方法。
其他方法稱為虛方法。
子類物件的多型的使用前提:
類的繼承關係
方法的重寫
虛擬機器中提供了以下幾條方法呼叫指令:
普通呼叫指令:
-
-
invokestatic:呼叫靜態方法,解析階段確定唯一方法版本。
-
invokespecial:呼叫<init>方法、私有及父類方法,解析階段確定唯一方法版本。
-
invokevirtual:呼叫所有虛方法。
-
invokeinterface:呼叫介面方法。
-
動態呼叫指令:
-
-
invokedynamic:動態解析出需要呼叫的方法,然後執行
-
前四條指令固化在虛擬機器內部,方法的呼叫執行不可人為干預,而invokedynamic指令則支援由使用者確定方法版本。其中invokestatic指令和invokespecial指令呼叫的方法稱為非虛方法,其餘的(fina1修飾的除外)稱為虛方法。
invokednamic指令
JVM位元組碼指令集一直比較穩定,一直到Java7中才增加了一個invokedynamic指令,這是Java為了實現『動態型別語言』支援而做的一種改進。
但是在Java7中並沒有提供直接生成invokedynamic指令的方法,需要藉助ASM這種底層位元組碼工具來產生invokedynamic指令。直到Java8的Lambda表示式的出現,invokedynamic指令的生成,在Java中才有了直接的生成方式。
動態型別語言和靜態型別語言
動態型別語言和靜態型別語言兩者的區別就在於:對型別的檢查是在編譯期還是在執行期,滿足前者就是靜態型別語言,反之是動態型別語言。
說的再直白一點就是,靜態型別語言是判斷變數自身的型別資訊;動態型別語言是判斷變數值的型別資訊,變數沒有型別資訊,變數值才有型別資訊,這是動態語言的一個重要特徵。
Java:String info = "mogu blog"; (Java是靜態型別語言的,會先編譯就進行型別檢查)
JS:var name = "shkstart"; var name = 10; (JS是動態語言,執行時才進行檢查)
方法重寫的本質
Java 語言中方法重寫的本質:
✔ 找到運算元棧頂的第一個元素所執行的物件的實際型別,記作C。
✔ 如果在型別C中找到與常量中的描述、簡單名稱都相符的方法,則進行訪問許可權校驗,如果通過則返回這個方法的直接引用,查詢過程結束;如果不通過,則返回java.lang.IllegalAccessError 異常。
✔ 否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜尋和驗證過程。
✔ 如果始終沒有找到合適的方法,則丟擲java.lang.AbstractMethodError異常。
IllegalAccessError介紹
程式試圖訪問或修改一個屬性或呼叫一個方法,卻沒有許可權訪問,一般會引起編譯器異常。這個錯誤如果發生在執行時,就說明一個類發生了不相容的改變。
方法的呼叫:虛方法表
在物件導向的程式設計中,會很頻繁的使用到動態分派,如果在每次動態分派的過程中都要重新在類的方法後設資料中搜尋合適的目標的話就可能影響到執行效率。因此,為了提高效能,JVM採用在類的方法區建立一個虛方法表 (virtual method table)(非虛方法不會出現在表中)來實現,使用索引表來代替查詢。
每個類中都有一個虛方法表,表中存放著各個方法的實際入口。
虛方法表是什麼時候被建立的呢?
虛方法表會在類載入的連結階段被建立並開始初始化,類的變數初始值準備完成之後,JVM會把該類的方法表也初始化完畢。
如圖所示,當類中重寫了方法,那麼呼叫的時候,就會直接在虛方法表中查詢,否則將會直接連線到Object的方法中。
方法返回地址
方法返回地址中存放的是:呼叫該方法的上級方法的PC暫存器中所存的值。
一個方法的結束,有兩種方式:
1.正常執行完成
2.出現未處理的異常,非正常退出
無論通過哪種方式退出,在方法退出後都返回到該方法被呼叫的位置。方法正常退出時,呼叫者的PC計數器的值作為返回地址,即呼叫該方法的指令的下一條指令的地址。而通過異常退出的,返回地址是要通過異常表來確定,棧幀中一般不會儲存這部分資訊。
當一個方法開始執行後,只有兩種方式可以退出這個方法:
① 執行引擎遇到任意一個方法返回的位元組碼指令(return),會有返回值傳遞給上層的方法呼叫者,簡稱正常完成出口。
▶ 一個方法在正常呼叫完成之後,究竟需要使用哪一個返回指令,還需要根據方法返回值的實際資料型別而定。
▶ 在位元組碼指令中,返回指令包含ireturn(當返回值是boolean,byte,char,short和int型別時使用),lreturn(Long型別),freturn(Float型別),dreturn(Double型別),areturn(引用型別)。另外還有一個return指令宣告為void的方法,例項初始化方法,類和介面的初始化方法使用。
② 在方法執行過程中遇到異常(Exception),並且這個異常沒有在方法內進行處理,也就是隻要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出,簡稱異常完成出口。
▶ 方法執行過程中,丟擲異常時的異常處理,儲存在一個異常處理表,方便在發生異常的時候找到處理異常的程式碼。
本質上,方法的退出就是當前棧幀出棧的過程。此時,需要恢復上層方法的區域性變數表、運算元棧、將返回值壓入呼叫者棧幀的運算元棧、設定PC暫存器值等,讓呼叫者方法繼續執行下去。
正常完成出口和異常完成出口的區別在於:通過異常完成出口退出的不會給它的上層呼叫者產生任何的返回值。
一些附加資訊
相關的面試題
舉例棧溢位的情況?
(StackOverflowError)
如何設定棧的大小?
通過 -Xss 引數。
調整棧大小,就能保證不出現溢位麼?
不能保證不溢位。
分配的棧記憶體越大越好麼?
不是,棧記憶體分配越大,一定時間內降低了OOM概率,但是會擠佔其它的執行緒空間,因為整個記憶體空間是有限的。
垃圾回收是否涉及到虛擬機器棧?
執行緒私有,不涉及。
方法中定義的區域性變數是否執行緒安全?
執行時資料區,是否存在Error和GC?