虛擬機棧
虛擬機棧概述
- 虛擬機棧出現背景
由於java的虛擬機是基於棧的指令集架構,所以就有了虛擬機棧。- 由於跨平臺性的設計,Java的指令都是根據棧來設計的。不同平臺CPU架構不同,所以不能設計爲基於寄存器的。
- 優點是跨平臺,指令集小,編譯器容易實現,缺點是性能下降,實現同樣的功能需要更多的指令。
- 內存中的棧與堆
棧是運行時的單位,而堆是存儲的單位- 棧解決程序的運行問題,即程序如何執行,或者說如何處理數據。
- 堆解決的是數據存儲的問題,即數據怎麼放,放哪裏
虛擬機棧的基本內容
-
Java虛擬機棧是什麼
Java虛擬機棧(Java Virtual Machine Stack),早期也叫Java棧。每個線程在創建時都會創建一個虛擬機棧,所以棧是線程私有的。
Java虛擬機棧內部保存一個個的棧幀(Stack Frame),一個棧幀對應一個java方法。
-
Java虛擬機棧生命週期
生命週期與線程一致,也就是線程結束了,該虛擬機棧也銷燬了 -
Java虛擬機棧作用
主管Java程序的運行,它保存方法的局部變量(8 種基本數據類型、對象的引用地址)、部分結果,並參與方法的調用和返回。
虛擬機棧的主要特點
- 棧是一種快速有效的分配存儲方式,訪問速度僅次於程序計數器。
- JVM直接對Java棧的操作只有兩個:
- 每個方法執行,伴隨着進棧(入棧、壓棧)
- 執行結束後的出棧工作
- 對於棧來說不存在垃圾回收問題即GC,但棧存在溢出的情況即OOM
虛擬機棧的異常
Java 虛擬機規範允許Java棧的大小是動態的或者是固定不變的。
-
採用固定大小的JAVA虛擬機棧,會出現StackoverflowError 異常
如果採用固定大小的Java虛擬機棧,那每一個線程的Java虛擬機棧容量可以在線程創建的時候獨立選定。如果線程請求分配的棧容量超過Java虛擬機棧允許的最大容量,Java虛擬機將會拋出一個StackoverflowError 異常。
示例:
一直自己調自己
出現StackoverflowError 異常
-
Java虛擬機棧可以動態擴展,會出現OutofMemoryError 異常
如果Java虛擬機棧可以動態擴展,並且在嘗試擴展的時候無法申請到足夠的內存,或者在創建新的線程時沒有足夠的內存去創建對應的虛擬機棧,那Java虛擬機將會拋出一個 OutofMemoryError 異常。
設置棧內存大小
我們可以使用參數 -Xss 選項來設置線程的最大棧空間,棧的大小直接決定了函數調用的最大可達深度。
官方文檔介紹:
-Xss1024m // 棧內存爲 1024MBS
-Xss1024k // 棧內存爲 1024KB
在IDEA中運行時進行設置:
棧的存儲單位
- 每個線程都有自己的棧,棧中的數據都是以棧幀(Stack Frame)的格式存在
線程>–<棧 - 在這個線程上正在執行的每個方法都各自對應一個棧幀(Stack Frame)。
方法>–<棧幀 - 棧幀是一個內存區塊,是一個數據集,維繫着方法執行過程中的各種數據信息。
棧的運行原理
- JVM直接對Java棧的操作只有兩個,就是對棧幀的壓棧和出棧,遵循先進後出(後進先出)原則
- 在一條活動線程中,一個時間點上,只會有一個活動的棧幀。即只有當前正在執行的方法的棧幀(棧頂棧幀)是有效的
- 這個棧幀被稱爲當前棧幀(Current Frame)
- 與當前棧幀相對應的方法就是當前方法(Current Method)
- 定義這個方法的類就是當前類(Current Class)
- 執行引擎運行的所有字節碼指令只針對當前棧幀進行操作。
- 如果在該方法中調用了其他方法,對應的新的棧幀會被創建出來,放在棧的頂端,成爲新的當前幀。
- 不同線程中所包含的棧幀是不允許存在相互引用的,即不可能在一個棧幀之中引用另外一個線程的棧幀。
- Java方法有兩種返回函數的方式,但不管使用哪種方式,都會導致棧幀被彈出
- 正常的函數返回,使用return指令
如果對異常進行trycatch了 那也是這種正常函數返回的情況 - 以拋出異常的方式結束
方法執行中未出現未捕獲處理的異常,就會以拋出異常的方式結束
- 正常的函數返回,使用return指令
棧幀的內部結構
每個棧幀中存儲着:
- 局部變量表(Local Variables)
- 操作數棧(Operand Stack)(或表達式棧)
- 動態鏈接(Dynamic Linking)(或指向運行時常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或者異常退出的定義)
- 一些附加信息
有些地方將動態鏈接、方法返回地址、一些附加信息 統稱爲 幀數據區
並行每個線程下的棧都是私有的,因此每個線程都有自己各自的棧,並且每個棧裏面都有很多棧幀,棧幀的大小主要由局部變量表 和 操作數棧決定的
局部變量表
- 在棧幀中,與性能調優關係最爲密切的部分就是局部變量表。在方法執行時,虛擬機使用局部變量表完成方法的傳遞。
- 局部變量表中的變量也是重要的垃圾回收根節點,只要被局部變量表中直接或間接引用的對象都不會被回收。
局部變量表概念
局部變量表:Local Variables,被稱之爲局部變量數組或本地變量表
-
局部變量表定義爲一個數字數組,主要用於存儲方法參數和定義在方法體內的局部變量,這些數據類型包括各類基本數據類型、對象引用(reference),以及returnAddress類型。
-
由於局部變量表是建立在線程的棧上,是線程的私有數據,因此不存在數據安全問題
-
局部變量表所需的容量大小是在編譯期確定下來的,並保存在方法的Code屬性的maximum local variables數據項中。在方法運行期間是不會改變局部變量表的大小的。
-
方法嵌套調用的次數由棧的大小決定。一般來說,棧越大,方法嵌套調用次數越多。
- 對一個函數而言,它的參數和局部變量越多,使得局部變量表膨脹,它的棧幀就越大,以滿足方法調用所需傳遞的信息增大的需求。
- 進而函數調用就會佔用更多的棧空間,導致其嵌套調用次數就會減少。
-
局部變量表中的變量只在當前方法調用中有效。
- 在方法執行時,虛擬機通過使用局部變量表完成參數值到參數變量列表的傳遞過程。
- 當方法調用結束後,隨着方法棧幀的銷燬,局部變量表也會隨之銷燬。
字節碼中方法內部結構的剖析
-
Methods下的main
- 方法形參類型 [Ljava/lang/String] :
- [] 表示數組
- L 表示引用類型
- java/lang/String 表示 java.lang.String
- 方法形參類型 [Ljava/lang/String] :
-
main下的Code
-
ByteCode 字節碼指令
push 就是把數放到操作數棧上
store_index就是保存在局部變量index位置
load_index是從局部變量取index位置上的值 -
Exception table 方法異常表
-
Misc 雜項
有局部變量個數、字節碼長度
-
-
LineNumberTable行號表:
java代碼行號和字節碼指令行號的對應關係
-
LocalVariableTable 局部變量表
- Start PC:字節碼指令的行號,表示當前變量作用域起始的位置,即聲明的下一行
- length:和Start PC一起表示當前局部變量作用域的範圍
- description:是變量類型
局部變量表的基本存儲單元:Slot
-
參數值的存放總是從局部變量數組索引 0 的位置開始,到數組長度-1的索引結束。
-
局部變量表,最基本的存儲單元是Slot(變量槽),局部變量表中存放編譯期可知的各種基本數據類型(8種),引用類型(reference),returnAddress類型的變量。
-
在局部變量表裏,32位以內的類型只佔用一個slot(包括returnAddress類型、引用類型),64位的類型佔用兩個slot(1ong和double)。
-
JVM會爲局部變量表中的每一個Slot都分配一個訪問索引,通過這個索引即可成功訪問到局部變量表中指定的局部變量值
佔兩個slot的會調它的起始索引 -
當一個實例方法被調用的時候,它的方法參數和方法體內部定義的局部變量將會按照順序被複制到局部變量表中的每一個slot上
-
如果當前幀是由構造方法或者實例方法創建的,那麼該對象引用this將會存放在index爲0的slot處,其餘的參數按照參數表順序繼續排列。
-
實例方法
-
構造器
-
靜態方法(無this)
從這裏也可以得知 靜態方法裏不能調this,因爲this不存在與靜態方法的局部變量表中。
-
Slot的重複利用
棧幀中的局部變量表中的槽位是可以重用的,如果一個局部變量過了其作用域,那麼在其作用域之後申明新的局部變量變就很有可能會複用過期局部變量的槽位,從而達到節省資源的目的。
靜態變量和局部變量的對比
- 變量的分類:
- 按照數據類型分:
- 基本數據類型
- 引用數據類型
- 按照在類中聲明的方式分:
- 成員變量
成員變量在使用前都經歷過系統的默認初始化賦值- 類變量(靜態變量)
在linking的prepare階段給類變量默認賦值爲0;在initial階段給類變量顯式賦值 - 實例變量
隨着對象的創建,會在堆空間中分配實例變量空間,並進行默認賦值
- 類變量(靜態變量)
- 局部變量
和類變量不同,局部變量表不存在系統初始化的過程
因此局部變量在使用前,必須要進行人爲顯式賦值的否則,編譯不通過
- 成員變量
- 按照數據類型分:
操作數棧
操作數棧:Operand Stack
-
操作數棧,在方法執行過程中,根據字節碼指令,往棧中寫入數據或提取數據,即入棧(push)和 出棧(pop)
雖然操作數棧是用數組實現的,但是操作數棧並非採用訪問索引的方式來進行數據訪問的,而是只能通過標準的入棧和出棧操作來完成一次數據訪問 -
操作數棧,主要用於保存計算過程的中間結果,同時作爲計算過程中變量臨時的存儲空間。
-
操作數棧就是JVM執行引擎的一個工作區,當一個方法剛開始執行的時候,一個新的棧幀也會隨之被創建出來,這時方法的操作數棧是空的
- 創建出來長度就已經確定好了,只是裏面沒有內容
- 每一個操作數棧都會擁有一個明確的棧深度用於存儲數值,其所需的最大深度在編譯期就定義好了,保存在方法的Code屬性中,爲maxstack的值。
-
棧中的任何一個元素都是可以任意的Java數據類型
- 32bit的類型佔用一個棧單位深度
- 64bit的類型佔用兩個棧單位深度
-
如果被調用的方法帶有返回值的話,其返回值將會被壓入當前棧幀的操作數棧中(被調用的方法的ireturn中壓入的調用它的方法的棧幀),並更新PC寄存器中下一條需要執行的字節碼指令。
比如下面我們用testGetSum()方法調getSum()-
getSum()的ireturn 將結果壓到調它的方法的棧幀的操作數棧中
ireturn方法描述如下:
-
testGetSum()
invoke那一句去調getSum,然後getSum的ireturn把返回值放到testSum的操作數棧。
然後istore再把操作數棧的值放到局部變量表中
-
-
Java虛擬機的解釋引擎是基於棧的執行引擎,其中的棧指的就是操作數棧。
操作數棧執行流程
對以下java代碼
public void testAddOperation() {
//byte、short、char、boolean:都以int型來保存
byte i = 15;
int j = 8;
int k = i + j;
}
字節碼指令如下:
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
執行流程圖示:
-
0 bipush 15
首先執行第一條語句,PC寄存器指向的是0,也就是指令地址爲0,然後使用bipush讓操作數15入操作數棧。
執行完後PC寄存器指針下移
-
2 istore_1
將15存到局部變量表索引爲1的位置,然後操作數棧出棧。PC指向下一行
(索引爲0的位置存的是this
-
3 bipush 8 5 istore_2
-
iload_1 iload_2
iload_1:把局部變量表索引爲1的值入棧到操作數棧
iload_2:
把局部變量表索引爲2的值入棧到操作數棧 -
iadd
將操作數棧中的兩個元素執行相加操作,存到操作數棧中
-
istore_3
將操作數棧棧頂元素存儲在局部變量表3的位置,並出棧
int i = 8
我們定義的是int i =8,由於byte可以存下8,所以我們可以看到bipush 8 是把8作爲byte入到操作數棧的,而istore存到局部變量表的時候還是用的int
i++和++i
-
i++
先將 i 的值加載到操作數棧,再將 i 的值加 1
-
++i
先將 i 的值加 1,在將 i 的值加載到操作數棧
棧頂緩存技術
- 基於棧式架構的虛擬機所使用的零地址指令更加緊湊,但完成一項操作的時候必然需要使用更多的入棧和出棧指令,這同時也就意味着將需要更多的指令分派(instruction dispatch)次數和內存讀/寫次數。
- 由於操作數是存儲在內存中的,因此頻繁地執行內存讀/寫操作必然會影響執行速度。爲了解決這個問題,HotSpot JVM的設計者們提出了棧頂緩存(Tos,Top-of-Stack Cashing)技術,將棧頂元素全部緩存在物理CPU的寄存器中,以此降低對內存的讀/寫次數,提升執行引擎的執行效率。
寄存器的主要優點:指令更少,執行速度快
動態鏈接(指向運行時常量池的方法引用)
-
每一個棧幀內部都包含一個指向運行時常量池中該棧幀所屬方法的引用,這就是動態鏈接
通過動態鏈接就知道你調的方法是哪個方法 -
在Java源文件被編譯到字節碼文件中時,所有的變量和方法引用都作爲符號引用(Symbolic Reference)保存在class文件的常量池裏,動態鏈接的作用就是爲了將這些符號引用轉換爲調用方法的直接引用
常量池:
爲什麼要用常量池呢?- 因爲在不同的方法,都可能調用常量或者方法,所以只需要存儲一份即可,然後記錄其引用即可,節省了空間
- 常量池的作用:就是爲了提供一些符號和常量,便於指令的識別
方法返回地址
-
方法返回地址是用來存放 調用該方法的pc寄存器的值的
就是方法A調了方法B後,方法B 結束執行引擎要知道該去調哪裏,這時方法返回地址只要把方法A的pc寄存器的值存下來, 那執行引擎通過方法返回地址就去調方法A對應的地方了。 -
無論通過哪種方式退出,在方法退出後都返回到該方法被調用的位置。方法結束的兩種方式
- 正常執行完成
方法正常退出時,調用者的pc計數器的值作爲返回地址,即調用該方法的指令的下一條指令的地址。
一個方法在正常調用完成之後,究竟需要使用哪一個返回指令,還需要根據方法返回值的實際數據類型而定。
ireturn:當返回值是boolean,byte,char,short和int類型時使用
lreturn:Long類型
freturn:Float類型
dreturn:Double類型
areturn:引用類型
return:返回值類型爲void的方法、實例初始化方法、類和接口的初始化方法 - 出現未處理的異常,非正常退出
而通過異常退出的,返回地址是要通過異常表來確定,棧幀中一般不會保存這部分信息。
異常表:
- 正常執行完成
-
本質上,方法的退出就是當前棧幀出棧的過程。此時,需要恢復上層方法的局部變量表、操作數棧、將返回值壓入調用者棧幀的操作數棧、設置PC寄存器值等,讓調用者方法繼續執行下去。
-
正常完成出口和異常完成出口的區別在於:通過異常完成出口退出的不會給他的上層調用者產生任何的返回值。
一些附加信息
不一定會有
棧幀中還允許攜帶與Java虛擬機實現相關的一些附加信息。例如:對程序調試提供支持的信息。
方法的調用
靜態鏈接與動態鏈接
在JVM中,將符號引用轉換爲調用方法的直接引用與方法的綁定機制相關。根據轉換的時機可以分爲靜態鏈接和動態鏈接。
靜態鏈接
當一個字節碼文件被裝載進JVM內部時,如果被調用的目標方法在編譯期確定,且運行期保持不變時,這種情況下將調用方法的符號引用轉換爲直接引用的過程稱之爲靜態鏈接
動態鏈接
如果被調用的方法在編譯期無法被確定下來==,也就是說,只能夠在程序運行期將調用的方法的符號轉換爲直接引用==,由於這種引用轉換過程具備動態性,因此也被稱之爲動態鏈接。
早期綁定與晚期綁定
靜態鏈接和動態鏈接對應的方法的綁定機制爲:早期綁定(Early Binding)和晚期綁定(Late Binding)。綁定是一個字段、方法或者類在符號引用被替換爲直接引用的過程,這僅僅發生一次。
早期綁定
早期綁定就是指被調用的目標方法如果在編譯期可知,且運行期保持不變時,即可將這個方法與所屬的類型進行綁定,這樣一來,由於明確了被調用的目標方法究竟是哪一個,因此也就可以使用靜態鏈接的方式將符號引用轉換爲直接引用。
invokespecial 體現爲早期綁定
晚期綁定
如果被調用的方法在編譯期無法被確定下來,只能夠在程序運行期根據實際的類型綁定相關的方法, 這種綁定方式也就被稱之爲晚期綁定。
invokeinterface 體現爲晚期綁定
invokevirtual 體現爲晚期綁定
虛方法與非虛方法
- 如果方法在編譯期就確定了具體的調用版本,這個版本在運行時是不可變的。這樣的方法稱爲非虛方法。
- 靜態方法、私有方法、fina1方法、實例構造器、父類方法都是非虛方法。
子類對象的多態的使用前提:- 類的繼承關係
- 方法的重寫
靜態方法、私有方法、final方法都不能被重寫,自然不具有多態性,也就是提前能確定下來的,所以是虛方法
- 其他方法稱爲虛方法。
虛擬機中調用方法的指令
其中invokestatic指令和invokespecial指令調用的方法稱爲非虛方法,其餘的(fina1修飾的除外)稱爲虛方法。
-
四條普通指令:
前四條指令固化在虛擬機內部,方法的調用執行不可人爲干預- invokestatic:調用靜態方法,解析階段確定唯一方法版本 非虛方法
- invokespecial:調用方法、私有及父類方法,解析階段確定唯一方法版本非虛方法
- invokevirtual:調用所有虛方法
- invokeinterface:調用接口方法
-
一條動態調用指令
invokedynamic指令則支持由用戶確定方法版本- invokedynamic:動態解析出需要調用的方法,然後執行
- JVM字節碼指令集一直比較穩定,一直到Java7中才增加了一個invokedynamic指令,這是Java爲了實現【動態類型語言】支持而做的一種改進。
動態類型語言和靜態類型語言的區別就是對類型的檢查是在編譯期還是運行期。- 靜態類型語言
JAVA屬於靜態類型語言。靜態類型語言是判斷變量自身的類型信息 - 動態類型語言
動態類型語言是判斷變量值的類型信息,變量並沒有類型信息。
- 靜態類型語言
- Java7中增加的動態語言類型支持的本質是對Java虛擬機規範的修改,而不是對Java語言規則的修改,這一塊相對來講比較複雜,增加了虛擬機中的方法調用,最直接的受益者就是運行在Java平臺的動態語言的編譯器。
- 但是在Java7中並沒有提供直接生成invokedynamic指令的方法,需要藉助ASM這種底層字節碼工具來產生invokedynamic指令。直到Java8的Lambda表達式的出現,invokedynamic指令的生成,在Java中才有了直接的生成方式。
- JVM字節碼指令集一直比較穩定,一直到Java7中才增加了一個invokedynamic指令,這是Java爲了實現【動態類型語言】支持而做的一種改進。
- invokedynamic:動態解析出需要調用的方法,然後執行
方法重寫
Java 語言中方法重寫的本質:
-
找到操作數棧頂的第一個元素所執行的對象的實際類型,記作C。
-
如果在類型C中找到與常量中的描述符合簡單名稱都相符的方法,則進行訪問權限校驗
- 如果通過則返回這個方法的直接引用,查找過程結束
- 如果不通過,則返回java.1ang.IllegalAccessError 異常
- 程序試圖訪問或修改一個屬性或調用一個方法,這個屬性或方法,你沒有權限訪問。
- 一般的,這個會引起編譯器異常。這個錯誤如果發生在運行時,就說明一個類發生了不兼容的改變。
比如,你把應該有的jar包放從工程中拿走了,或者Maven中存在jar包衝突
-
否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜索和驗證過程。
-
如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
虛方法表
- 在面向對象的編程中,會很頻繁的使用到動態分派,如果在每次動態分派的過程中都要重新在類的方法元數據中搜索合適的目標的話就可能影響到執行效率。
- 因此,爲了提高性能,JVM採用在類的方法區建立一個虛方法表(virtual method table)來實現,使用索引表來代替查找。非虛方法不會出現在表中。
- 每個類中都有一個虛方法表,表中存放着各個方法的實際入口。
- 虛方法表會在類加載的鏈接階段被創建並開始初始化,類的變量初始值準備完成之後,JVM會把該類的虛方法表也初始化完畢。
也就是虛方法表中放了每個方法應該 去具體調的方法,這樣不用每次調的時候都要一層一層向上找。
虛擬機棧相關面試題
- 舉例棧溢出的情況
StackOverflowError - 調整棧大小,就能保證不出現溢出麼?
不能保證不溢出,對於一直遞歸的,或者調整更大我運行更多次也是會溢出的。 - 分配的棧內存越大越好麼?
不是。內存空間是有限的,棧內存越大那其他東西分配的空間就會相應減少,可能會引起線程數減少。 - 垃圾回收是否涉及到虛擬機棧?
不會。 - 運行時數據區的哪些部分存在Error和GC
- 方法中定義的局部變量是否線程安全?
具體問題具體分析,如果對象是在內部產生,並在內部消亡,沒有返回到外部,那麼它就是線程安全的,反之則是線程不安全的。
什麼是線程安全?-
如果只有一個線程纔可以操作此數據,則必是線程安全的。
- StringBuilder只存在方法的內部
method1() 被一個線程調了以後,method1()就是在這個線程的虛擬機棧一個棧幀,s1在這個棧幀中,當然也不會被其他線程調用,所以是線程安全的。
- StringBuilder只存在方法的內部
- StringBuilder只存在方法的內部
-
如果有多個線程操作此數據,則此數據是共享數據。如果不考慮同步機制的話,就會存在線程安全問題。
-
sBuilder是從外面傳過來的,有可能被多個線程調用,所以是線程不安全
即下面用兩個線程都用到了s
-
StringBuilder返回出去了
-
-