【讀書會第十二期】20張圖帶你了解JVM運行時數據區
我們的JVM系列已經斷更好幾天了,小伙伴們在后臺瘋狂私信阿Q,想看后續內容,今天它來了。相信大家在上篇文章中已經對類加載子系統有了清晰的認識,接下來就讓我們來揭開“運行時數據區”的神秘面紗吧。
運行時數據區總覽
內存是非常重要的系統資源,是硬盤和CPU的中間倉庫及橋梁,承載著操作系統和應用程序的實時運行。JVM內存布局規定了Java在運行過程中內存申請、分配、管理的策略,保證了JVM的高效穩定運行。不同的JVM對于內存的劃分方式和管理機制存在著部分差異。下圖就是HotSpot的經典的內存布局:
圖中的CodeCache在JVM官方文檔中被歸于元空間,而在阿里的官方文檔中被單獨摘了出來,此處區別并不影響我們對它的學習。
Java虛擬機在執行Java程序的過程中,會將涉及到的數據劃分到不同的內存區域去管理,而這部分區域就是我們接下來要講的Java虛擬機的運行時數據區。
如上圖所示,我們的運行時數據區分為PC寄存器、方法區、堆、本地方法棧和虛擬機棧五個部分。其中上文中所說的元空間就是方法區的具體落地實現。估計有的老鐵會問:不是還有直接內存嗎?其實直接內存并不屬于運行時數據區的一部分,也不是java虛擬機規范中的區域,它的大小不受java堆大小的限制,是使用Native函數庫直接分配的堆外內存,會被頻繁使用。它存儲著堆與本地方法相關的數據,可以避免在Java堆和Native堆中來回復制數據,能夠提高效率。
細心的老鐵應該會發現,上圖中阿Q用了紅藍兩種顏色來區分五個部分,其中紅色的方法區和堆是線程間共享的,即它們會隨著虛擬機啟動而創建,隨著虛擬機退出而銷毀;而藍色的部分為每個線程單獨享有的,即它們與線程是一一對應的,會隨著線程開始和結束而創建和銷毀。在HotSpot JVM中,每個線程都與操作系統的本地線程直接映射:當一個java線程準備好執行之后,此時一個操作系統的本地線程也同時創建,java線程執行終止后,本地線程也會回收。操作系統負責所有線程的安排調度到任何一個可用的CPU上,一旦本地線程初始化成功,它就會調用Java現成的run()方法。
我們可以翻看官方文檔了解一下Runtime類:
Every Java application has a single instance of class Runtime that allows the application to interface with the environment in which the application is running. The current runtime can be obtained from the getRuntime method.
譯: 每個Java應用程序都有一個類運行時實例,該實例允許應用程序與運行應用程序的環境交互。當前運行時可以從getRuntime方法獲得。
看到這如果大家對運行數據區還沒有大致的概念的話,給大家舉個小例子,大家一看便知:
如上圖所示,廚師正在烹飪佳肴,我們如果把廚師炒菜比作我們的虛擬機執行代碼的話,廚師就是我們后文中將要提到的執行引擎,而廚師后方的工具類和食材就相當于我們的運行時數據區。在寫這篇文章的過程中發現知識點有點多,所以阿Q把它分為兩部分進行講解,該篇文章先說一下線程的私有區域:PC寄存器、本地方法棧和虛擬機棧。
PC寄存器(程序計數器)
這里的寄存器并不是廣義上所指的物理寄存器,而是對物理寄存器的抽象模擬,把它稱為PC計數器(或指令計數器)更為合適。
介紹
Java虛擬機可以一次支持多個執行線程,每個Java虛擬機線程都有其自己的PC寄存器即為線程獨有。PC寄存器會隨著線程的創建而創建,會隨著線程的結束而死亡。正因為程序計數器記錄的是指令地址,所以它占用的內存空間較少,因此它是運行速度最快的存儲區域,也是唯一一個在Java虛擬機規范中沒有規定任何OutOtMemoryError(內存溢出)情況的區域。在任何時候,每個Java虛擬機線程都在執行單個方法的代碼,即該線程的當前方法。如果線程當前正在執行的方法不是native,則該pc寄存器包含當前正在執行的Java虛擬機指令的地址;如果線程當前正在執行的方法是native,則Java虛擬機的pc寄存器值未定義undefned。
作用
PC寄存器的作用就是用來存儲指向下一條指令的地址,也就是即將要執行的指令代碼,由執行引擎讀取該指令并交由cpu執行。它是程序控制流的指示器,分支,循環,跳轉,異常處理,線程恢復等基礎功能都需要依賴這個計數器來完成。我們可以把PC寄存器理解為一個記錄著當前線程所執行的字節碼的行號指示器,也可以理解為一個游標,來告訴程序按照我指定的順序執行。接下來用例子來演示下它所處的位置與作用。
例:
如圖所示,PC寄存器中存儲著指向“操作指令”的“指令地址”。假如現在PC寄存器中存儲的指令地址是“5”,則執行引擎會取出對應的操作指令,然后做兩件事:一是操作局部變量表、操作數棧等完成數據的存、取、加減等操作;二是將操作指令翻譯成CPU能識別的機器指令,最后由CPU執行;此時字節碼解釋器就會改變PC寄存器中的值為“6”,以此類推。
面試題分析
(1)為什么要使用PC寄存器記錄當前線程的執行地址呢?
JVM的多線程是通過CPU時間片輪轉(即線程輪流切換并分配處理器執行時間)算法來實現的。也就是說,某個線程在執行過程中可能會因為時間片耗盡而被掛起,而另一個線程獲取到時間片開始執行。當被掛起的線程重新獲取到時間片的時候,它要想從被掛起的地方繼續執行,就必須知道它上次執行到哪個位置,這時候就需要PC寄存器來記錄某個線程的字節碼執行位置,如果虛擬機是單線程也就沒必要用程序計數器記錄每個線程的位置了。
(2)PC寄存器為什么會被設定為線程私有呢?
由于jvm的多線程是通過線程輪流切換并分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器都只會執行一條線程中的指令。因此為了能夠準確的記錄各個線程正在執行的當前字節碼指令地址,最好的辦法自然就是為每一個線程都分配一個PC寄存器。這樣各條線程之間計數器互不影響,獨立存儲。
虛擬機棧
棧的介紹
正如我們的《JVM集合之開篇點題》中所說,由于跨平臺性的設計,Java的指令都是根據棧來設計的,它遵循“先進先出、后進后出”的原則。它的優點就是跨平臺、指令集小,編譯器更容易實現。
在這里我們要對“棧”和“堆”做一個簡單的區分,其中棧是運行時的單位,它解決的是程序運行的問題,即程序如何執行,或者說是如何處理數據;堆是存儲的單位,它解決的是數據存儲的問題,即數據怎么放、放在哪。我們舉個簡單的例子:假如你正在修理汽車,我們可以把修車的操作步驟看做是棧操作,而把汽車的零件一個個放到汽車中就可以看做是堆存儲。
虛擬機棧介紹
Java虛擬機棧,早期也叫Java棧。每個線程在創建時都會創建一個虛擬機棧,所以虛擬機棧是線程私有的,當線程結束時虛擬機棧也就結束了。JVM對虛擬機棧的操作只有進棧和出棧,所以它的訪問速度僅次于程序計數器,也是一種快速有效的分配存儲方式。對于虛擬機棧來說它不存在垃圾回收問題,但是虛擬機棧的大小是動態的或者固定不變的,所以它會存在棧溢出或者內存溢出問題:
棧溢出:如果采用固定大小的虛擬機棧,那每一個線程的虛擬機棧容量可以在線程創建的時候獨立選定。如果線程請求分配的棧容量超過虛擬機棧允許的最大容量,虛擬機棧會拋出StackOverflowError異常。
內存溢出:如果虛擬機棧可以動態擴展,并且在嘗試擴展的時候無法申請到足夠的內存,或者在創建新的線程時沒有足夠的內存去創建對應的虛擬機棧,那虛擬機將會拋出OutOfMemoryError異常。
棧的大小直接決定了函數調用的最大可達深度,我們可以通過-Xss參數來配置棧內存,追加字母k或K表示KB,m或M表示MB,g或G表示GB,示例:-Xss1m。
棧幀的運行原理
虛擬機棧主管Java程序的運行,保存方法的局部變量(8種基本數據類型、對象的引用地址)、部分結果,并參與方法的調用和返回,那它內部到底是什么構造呢?虛擬機棧內部保存著一個一個的棧幀(Stack Frame),每個棧幀與該線程正在執行的每個方法都是一一對應的。棧幀是一個內存區塊,是一個數據集,維系著方法執行過程中的各種數據信息。在一條活動線程中,一個時間點上,只會有一個活動的棧幀。即只有當前正在執行的方法的棧幀(棧頂棧幀)是有效的,這個棧幀被稱為當前棧幀 (Current Frame),與當前棧幀相對應的方法就是當前方法(Current Method),定義這個方法的類就是當前類(Current Class)。執行引擎運行的所有字節碼指令只針對當前棧幀進行操作。執行過程如下圖:
程序開始執行,首先方法1入棧,為棧幀1,此時棧幀1為當前棧幀;隨后方法1調用方法2,方法2入棧,為棧幀2,此時棧幀2為當前棧幀,以此類推;當方法4入棧成為棧幀4并且執行代碼,在方法4返回之際,棧幀4會傳回方法4的執行結果給棧幀3,接著,虛擬機會丟棄棧幀4即棧幀4出棧,使得棧幀3重新成為當前棧幀,以此類推,直到方法1執行完成,棧幀1出棧,虛擬機棧被回收。
Java方法有兩種返回函數的方式,一種是正常的函數返回,使用return指令(包含void返回類型);一種是拋出異常(指的是未處理的異常,如果是try...catch過了,算第一種)。不管使用哪種方式,都會導致棧幀出棧。不同線程中所包含的棧幀是不允許存在互相引用的,即不可能在一個棧幀之中引用另外一個線程的棧幀。
如圖所示,棧幀由局部變量表、操作數棧、動態鏈接、方法返回地址和一些附加信息組成,接下來就讓我們逐個來了解一下吧。
局部變量表 Local Variables
局部變量表也被稱之為局部變量數組或本地變量表,實際上是一個“數字”數組,主要用于存儲方法的參數和定義在方法體內的局部變量(包括各類基本數據類型、對象引用、returnAddress類型),虛擬機使用局部變量表完成方法返回。因為局部變量表是建立在線程的虛擬機棧上,是線程的私有數據,所以不會存在數據安全問題。另外棧幀的大小主要受局部變量表的影響,而局部變量表所需的容量大小是在編譯期確定下來的,并保存在方法的Code屬性的maxmum_local_variables數據項中,所以在方法運行期間是不會改變局部變量表的大小的,因此一個棧幀需要分配多少內存,不會受到程序運行期變量數據的影響,而僅僅取決于具體的虛擬機實現。一般來說在,在虛擬機棧大小固定的前提下,它的局部變量表越大,它的棧幀就越大,那它的嵌套調用次數(方法調用數)也就越少,即棧的深度越淺。用幾張字節碼的圖來說明一下局部變量表中的內容:
局部變量表中的數據只有在當前方法中有效。在方法執行時,虛擬機通過使用局部變量表完成參數值到參數變量列表的傳遞過程,當方法調用結束后,隨著方法棧幀的銷毀,局部變量表也會隨之銷毀。
參數的存放總是在局部變量數組的索引0開始,到數組長度減1的索引結束,它最基本的存儲單元就是Slot(變量槽)。當一個實例方法被調用的時候,它的方法參數和方法體內部定義的局部變量將會按照順序被復制到局部變量表中的每一個Slot上。JVM會為局部變量表中的每個Slot都分配一個訪問索引,通過這個索引即可成功訪問到局部變量表中指定的局部變量值。其中32位以內的類型只占用一個slot(包含returnAddress類型,byte、short、char、float都轉化為int類型,而boolean類型是0為false,非0為true),64位的類型(long和double)占用兩個slot。如果需要訪問局部變量表中一個64bit的局部變量值時,只需要使用前一個索引即可。
如果當前幀是由構造方法或者實例方法創建的,那么該對象的引用this將會存放在index為0的slot處,其余的參數按照參數表順序繼續排列,而this變量不存在于靜態方法的局部變量表中,所以上文中的main方法中不存在this變量。
另外Slot是可以復用的,如果一個局部變量過了其作用域,那么在其作用域之后聲明的新的局部變量就很有可能會復用該局部變量的slot,從而達到節省資源的目的。
補充知識點:
變量按照在類中的位置可以分為成員變量和局部變量,其中成員變量又分為類變量和實例變量。
成員變量在使用前,都會默認初始化賦值,其中類變量是在類加載子系統的準備階段進行默認賦值,在初始化階段顯示賦值;
實例變量會隨著對象的創建,在堆空間中分配實例變量空間,并進行默認賦值;
局部變量是不會進行默認賦值的,所以在使用前必須進行顯示賦值,否則編譯不通過。
局部變量表中的變量也是重要的垃圾回收根節點,只要被局部變量表中直接或者間接引用的對象都不會被回收。
操作數棧 Operand Stack
操作數棧又稱為表達式棧,在方法執行的過程中,根據字節碼指令,往棧中寫入數據或提取數據,即入棧和出棧。操作數棧主要用于保存計算過程的中間結果,同時作為計算過程中變量臨時的存儲空間。每一個操作數棧都會擁有一個明確的棧深度用于存儲數據值,其所需要的最大深度在編譯期間就定義好了,保存在方法的code屬性中,為max_stack的值(與上邊局部變量表類似)。棧中的元素可以是任意的Java數據類型,其中32bit的用一個棧單位深度,64bit的用兩個棧單位深度。操作數棧中元素的數據類型必須與字節碼指令的序列嚴格匹配,這由編譯器在編譯期間進行驗證,同時在類加載過程中的類檢驗階段的數據流分析階段要再次驗證。我們所說的Java虛擬機的解釋引擎是基于棧的執行引擎,其中的棧指的就是操作數棧。有了上述的理論,估計你會是這樣的
阿Q特地制作了一張動態圖來說明一下字節碼指令執行時PC寄存器、局部變量表和操作數棧的運行過程:
public void test() { byte i = 15; int j = 8; int k = i + j; }
在編譯期間局部變量表和操作數棧的大小已經確定了:
首先將要執行的指令地址0存放到PC寄存器中,此時,局部變量表和操作數棧的數據為空;
當執行第一條指令bipush時,將操作數15放入操作數棧中,然后將PC寄存器的值置為下一條指令的執行地址,即2;
當執行指令地址為2的操作指令時,將操作數棧中的數據取出來,存到局部變量表的1位置,因為該方法是實例方法,所以0位置存的是this的值,PC寄存器中的值變為3;
同步驟2和3將8先放入操作數棧,然后取出來存到局部變量表中,PC寄存器中的值也由3->5->6;
當執行到地址指令為6、7、8時,將局部變量表中索引位置為1和2的數據重新加載到操作數棧中并進行iadd加操作,將得到的結果值存到操作數棧中,PC寄存器中的值也由6->7->8->9;
執行操作指令istore_3,將操作數棧中的數據取出存到局部變量表中索引為3的位置,執行return指令,方法結束。
如果被調用的方法帶有返回值,其返回值會被壓入當前棧幀的操作數棧中,并更新pc寄存器中下一條需要執行的字節碼指令。
棧頂緩存技術:將棧頂的元素全部緩存到物理CPU的寄存器中,以此降低對內存的讀/寫次數,提升執行引擎的執行效率。
動態鏈接 Dynamic Linking
在Java源文件被編譯成字節碼文件時,所有的變量和方法引用都作為符號引用保存在class文件的常量池中。
當字節碼文件被加載到虛擬機后,字節碼文件中的一些數據,如類型信息、域信息、方法信息等,就會被放置到方法區中,而字節碼文件中的常量池則會進入方法區中的運行時常量池。每一個棧幀內部都包含一個指向運行時常量池中該棧幀所屬方法的引用,包含這個引用的目的就是為了支持當前方法的代碼能夠實現動態鏈接。動態鏈接就是在“類加載”中“鏈接”的“解析階段”將符號引用轉化為直接引用的過程。
為什么字節碼文件需要常量池?因為字節碼文件需要數據支持,通常這種數據會很大,以至于不能直接存放到字節碼中,換一種方式,可以將指向這些數據的符號引用存到字節碼文件的常量池中,這樣字節碼只需使用常量池就可以在運行時通過動態鏈接找到相應的數據并使用。
方法返回地址 Return Address
方法返回地址是用來存放調用該方法的PC寄存器的值的。我們都知道,方法的結束有兩種方式:一種是正常執行完成;一種是出現未處理的異常,非正常退出。無論哪種方式退出,在方法退出后都返回到該方法被調用的位置,程序才能繼續執行,方法返回時可能需要在棧幀中保存一些信息,用來幫助恢復它的上層主調方法的執行狀態。方法正常退出時,當前線程的pc寄存器的值作為返回地址,即調用該方法的指令的下一條指令的地址。而通過異常退出的,返回地址是要通過異常表來確定的,棧幀中一般不會保存這部分信息。本質上,方法的退出就是當前棧幀出棧的過程。此時,需要恢復上層方法的局部變量表、操作數棧,將返回值壓入調用者棧幀的操作數棧,設置PC寄存器的值等,讓調用者方法繼續執行下去。
按照方法完成出口方式的不同又分為正常完成出口和異常完成出口:
正常完成出口的字節碼指令中的返回值類型為ireturn(boolean、byte、char、short和int)、lreturn(long)、freturn(float)、dreturn(double)、areturn(引用類型)和return(void、實例初始化方法、類和接口的初始化方法)。
在方法執行過程中遇到了異常,并且這個異常沒有在方法內進行處理,也就是只要在本方法的異常處理表中沒有搜索到匹配的異常處理器,就會導致方法的退出,簡稱異常完成出口。異常處理表是用來存儲方法執行過程中拋出異常時的異常處理的,方便在發生異常的時候找到處理異常的代碼。
兩種方式的本質區別就是異常完成出口退出時不會給他的上層調用者產生任何的返回值。
一些附加信息
棧幀中還允許攜帶與Java虛擬機實現相關的一些附加信息,例如對程序調試提供支持的信息。
本地方法棧
要說起本地方法棧,我們先來介紹一下本地方法。
本地方法 Native Method
首先本地方法是不在運行時數據區中的,它的位置如圖所示:
本地方法其實就是java調用非java代碼的接口,該接口由非java語言實現。本地接口的作用是融合不同的編程語言為java所用,它的初衷是融合C/C++程序。
native可以與所有其他的java標識符連用,但是abstract除外。
為什么要使用Native Method?
與Java環境外交互:有時候java應用需要與java外邊的環境進行交互;
與操作系統進行交互:使用本地方法,我們可以用java實現jre與底層系統的交互;
Sun’s Java:Sun的解釋器是用C實現的,這使得它能像一些普通的C一樣與外部交互。
本地方法棧 Native Method Stack
本地方法棧是用來管理本地方法的調用的,也是線程私有的。他也允許被實現成固定或者可動態擴展的內存大小,在內存溢出方面與虛擬機棧類似。本地方法棧的具體做法是Native Method Stack中登記native方法,在Execution Engine執行時加載本地方法庫。
當某個線程調用本地方法時,他就進入了一個全新的并且不再受虛擬機限制的世界,他和虛擬機擁有同樣的權限:
本地方法可以通過本地方法接口來訪問虛擬機內部的運行時數據區;
可以直接使用本地處理器中的寄存區;
直接從本地內存的堆中分配任意數量的內存。
阿Q將持續更新java實戰方面的文章,感興趣的可以關注下,也可以來技術群討論問題呦!
Java JVM 任務調度
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。