簡述JVM基礎(六):虛擬機器位元組碼執行引擎
作者 | 井方哥
地址 | https://zhuanlan.zhihu.com/p/31235268
宣告 | 本文是 井方哥 原創,已獲授權釋出,未經原作者允許請勿轉載
前言
物理機的執行引擎是直接在物理硬體如CPU、作業系統、指令集上執行的,但是對於虛擬機器來講,他的執行引擎由自己實現。 執行引擎有統一的外觀(Java虛擬機器規範),不同型別的虛擬機器都遵循了這一規範,輸入位元組碼檔案,解析位元組碼處理,然後輸出結果。
執行時棧幀結構
1、棧幀概念
棧幀(Stack Frame)用於支援方法呼叫和執行的資料結構,包含了區域性變量表、運算元棧、動態連線和方法返回地址。
區域性變量表大小(max_locals),棧幀深度在編譯時已經確定,並寫入到了Code屬性中;
執行引擎執行的所有位元組碼指令都只針對當前棧進行操作;
2、區域性變量表
區域性變量表儲存了方法引數以及方法內定義的區域性變數。
Slot(變數槽):區域性變量表容量最小單位,可以存放32位以內的資料型別;
refrence:
直接或者間接找到到該物件在“堆記憶體”中資料存放的起始地址索引;
直接或者間接找到物件所屬資料型別在方法區中儲存的型別資訊;
區域性變量表建立線上程的堆疊上,所以操作兩個連續的slot是否為原子操作,都不會引起資料安全問題,但是如果是64位的話,不允許任何方式單獨訪問其中的一個;
this:例項方法(非static)預設第一個(第0位索引)slot為當前物件自己的引用;
slot重用:
當前位元組碼的pc計數器超出某個變數的作用域,那這個變數的slot可以交給別的變數使用
影響到正常的Java垃圾回收機制;
賦null:因為上述slot重用的原因,當方法域內前面有區域性變數定義了大記憶體實際不再使用的變數,緊接著後面的程式碼又是一個耗時的操作,這個時候及時賦null就顯得有大的意義。因為一旦觸發後,這部分的slot就可以被重用了。看起來就像是方法區內部進行“類gc"操作一樣。但是,並不是任何時候都要進行賦null.以恰當的變數作用域來控制變量回收時間才是最優雅的方式,並且賦null值操作在經過JIT編譯優化後會被消除掉,這樣的話實際是沒有任何意義的。
初始值:和類變數不同,區域性變數系統不會自動賦初始值,所以沒有賦值是無法使用的,編譯都無法通過。即使通過,位元組碼校驗階段也會檢查出來而導致類載入失敗;
3、運算元棧(Operand Stack)
操作棧,後入先出;
最大深度:Code屬性表中的max_stacks;
32位資料型別所佔棧容量為1,64位所佔容量為2;
棧元素的資料型別必須和棧指令保持一致
兩個棧幀之間可以存在一部分的重疊,共享資料,這樣在方法呼叫的時候避免的額外的引數複製。
Java虛擬機器的解釋執行引擎也是:基於棧的執行引擎;
4、動態連線(Dynamic Linking)
位元組碼中的方法的呼叫都是通過常量池中指定方法的符號作為引數
靜態解析:這種符號有的是類載入階段或者首次使用初始化的時候轉化為直接的引用
動態連線:另外一部分是在執行時轉化為直接引用
5、方法返回地址
退出:
正常退出:遇到返回的位元組碼指令;
異常退出:本方法異常表中沒有匹配的異常;
退出後,恢復上層方法的區域性變量表和操作棧,有返回值就把返回值壓入上層呼叫者的棧中;
方法呼叫
1、定義
確定被呼叫方法的版本
1、解析
編譯器可知,執行期不可變。這類方法的呼叫成為解析,在類載入階段進行解析。
靜態方法、私有方法、例項構造器方法、父類方法,符合上述條件。特點是:
只能被invokestatic和invokespecial指令呼叫
不可繼承或者重寫,編譯時已經確定了一個版本。
在類載入時會把符合引用解析為該方法的直接引用。
非虛方法(注意final也是非虛方法,其他的都是虛方法)
2、靜態分派
概念:根據靜態型別來定位方法的執行版本
典型代表:方法的過載(方法名相同,引數型別不同)
發生時間:編譯階段
3、動態分派
概念:呼叫invokevirtual時,把常量池中的類方法符號解析到了不同的直接引用上。
典型代表:重寫,多型的重要體現
過程:
執行invokevitual指令
在虛方法表(類載入階段,類變數初始化結束後會初始化虛方法表)中查詢方法,沒有向上的父類進行查詢
方法宗量:方法的接收者與方法引數的總稱
單分派和多分派:
只有一個宗量作為方法的選擇依據,稱為單分派。多個,則稱為多分派。
當前的Java是靜態多分派、動態單分派的語言;
動態語言支援
特點:變數無型別,變數的值才有型別
invoke包:Java實現動態語言新增的包
指令集
基於棧的指令集
過程:入棧、計算、出棧
優點:
可移植性,不依賴於硬體
程式碼緊湊
缺點:
速度較慢
產生相當多的指令數量
頻繁記憶體訪問
基於暫存器的指令集
代表:x86
方法內聯
方法內聯的方式是通過吧“目標方法”的程式碼複製到發起呼叫的方法內,避免真實的方法呼叫。
內聯消除了方法呼叫的成本,還為其他優化手段建立良好的基礎。
編譯器在進行內聯時,如果是非虛方法,那麼直接內聯。如果遇到虛方法,則會查詢當前程式下是否有多個目標版本可供選擇,如果查詢結果只有一個版本,那麼也可以內聯,不過這種內聯屬於激進優化,需要預留一個逃生門(Guard條件不成立時的Slow Path),稱為守護內聯。
如果程式的後續執行過程中,虛擬機器一直沒有載入到會令這個方法的接受者的繼承關係發現變化的類,那麼內聯優化的程式碼可以一直使用。否則需要拋棄掉已經編譯的程式碼,退回到解釋狀態執行,或者重新進行編譯。
逃逸分析
逃逸分析的基本行為就是分析物件動態作用域:當一個物件在方法裡面被定義後,它可能被外部方法所引用,這種行為被稱為方法逃逸。被外部執行緒訪問到,被稱為執行緒逃逸。
如果物件不會逃逸到方法或執行緒外,可以做什麼優化?
棧上分配:一般物件都是分配在Java堆中的,對於各個執行緒都是共享和可見的,只要持有這個物件的引用,就可以訪問堆中儲存的物件資料。但是垃圾回收和整理都會耗時,如果一個物件不會逃逸出方法,可以讓這個物件在棧上分配記憶體,物件所佔用的記憶體空間就可以隨著棧幀出棧而銷燬。如果能使用棧上分配,那大量的物件會隨著方法的結束而自動銷燬,垃圾回收的壓力會小很多。
同步消除:執行緒同步本身就是很耗時的過程。如果逃逸分析能確定一個變數不會逃逸出執行緒,那這個變數的讀寫肯定就不會有競爭,同步措施就可以消除掉。
標量替換:不建立這個物件,直接建立它的若干個被這個方法使用到的成員變數來替換。
小結
在前面我們已經瞭解到棧幀、方法區的記憶體時執行緒私有的,本篇更加詳細的講了方法是怎麼找到並執行的。Java虛擬機器規範:輸入位元組碼,解析位元組碼處理,輸出結果。首先,棧幀包含了區域性變量表、運算元棧、動態連線、方法返回地址。位元組碼中的方法都是通過常量池中的符號作為引數指定的,有些編譯解析確定,有些執行行時轉化為直接引用。首先記住,JVM是基於棧的執行引擎。棧有著先入後出的特點,執行引擎的指令也僅執行當前棧。而區域性變量表儲存了方法內需要的變數資訊,是以Slot 為單位進行儲存,超出操作域後,原本佔用的記憶體區域可以被其他的區域性變數使用,類似“回收”。然後,記住Java是靜態多分派,動態單分派的語言。靜態分派,如方法的過載。通過方法的引數不同就可以確定要呼叫哪個方法,這個再編譯階段就定好。動態分派,如方法的重寫。執行方法時,有一個虛方法表。這這個表裡搜尋,自己有就執行自己的,沒有向上找父類的。這個是Java實現多型的重要原理。Java也有支援動態語言的invoke包,平時用的較少。
說明: 本系列多處摘抄《深入理解Java虛擬機器》中內容,主要精簡了本書的要點,並敘述自己對本書的理解。本人才疏學淺,文章中有不對的地方,還望批評指教。
往期