Java虚拟机结构分析
歡迎支持筆者新作:《深入理解Kafka:核心設計與實踐原理》和《RabbitMQ實戰指南》,同時歡迎關注筆者的微信公眾號:朱小廝的博客。
歡迎跳轉到本文的原文鏈接:https://honeypps.com/java/structure-analysis-of-java-virtual-machine/
本博文主要介紹了JVM(Java Virtual Machine)的組成部分以及它們內部的工作機制和原理。需要注意的是,雖然平時我們用的大多是Sun(現已被Oracle收購)JDK提供的JVM,但是JVM本身是一個規范,所以可以有多種實現,除了Hotspot外,還有諸如Oracle的JRockit、IBM的J9也都是非常有名的JVM。
1. 結構
下圖展示了JVM的主要結構:
可以看出,JVM主要由類加載器子系統、運行時數據區(內存空間)、執行引擎以及與本地方法接口等組成。其中運行時數據區又由方法區、堆、Java棧、PC寄存器、本地方法棧組成。
從上圖中還可以看出,在內存空間中方法區和堆是所有Java線程共享的,而Java棧、本地方法棧、PC寄存器則由每個線程私有,這會引出一些問題,后文會進行具體討論。
眾所周知,Java語言具有跨平臺的特性,這也是由JVM來實現的。更準確地說,是Sun利用JVM在不同平臺上的實現幫我們把平臺相關性的問題給解決了,這就好比是HTML語言可以在不同廠商的瀏覽器上呈現元素(雖然某些瀏覽器在對W3C標準的支持上還有一些問題)。同時,Java語言支持通過JNI(Java Native Interface)來實現本地方法的調用,但是需要注意到,如果你在Java程序用調用了本地方法,那么你的程序就很可能不再具有跨平臺性,即本地方法會破壞平臺無關性。
2. 類加載器子系統(Class Loader)
類加載器子系統負責加載編譯好的.class字節碼文件,并裝入內存,使JVM可以實例化或以其它方式使用加載后的類。JVM的類加載子系統支持在運行時的動態加載,動態加載的優點有很多,例如可以節省內存空間、靈活地從網絡上加載類,動態加載的另一好處是可以通過命名空間的分隔來實現類的隔離,增強了整個系統的安全性。
2.1 ClassLoader的分類
- 啟動類加載器(BootStrap Class Loader):負責加載rt.jar文件中所有的Java類,即Java的核心類都是由該ClassLoader加載。在Sun JDK中,這個類加載器是由C++實現的,并且在Java語言中無法獲得它的引用。
- 擴展類加載器(Extension Class Loader):負責加載一些擴展功能的jar包。
- 系統類加載器(System Class Loader):負責加載啟動參數中指定的Classpath中的jar包及目錄,通常我們自己寫的Java類也是由該ClassLoader加載。在Sun JDK中,系統類加載器的名字叫AppClassLoader。
- 用戶自定義類加載器(User Defined Class Loader):由用戶自定義類的加載規則,可以手動控制加載過程中的步驟。
2.2 ClassLoader的工作原理
類加載分為裝載、鏈接、初始化三步。
2.2.1 裝載
通過類的全限定名和ClassLoader加載類,主要是將指定的.class文件加載至JVM。當類被加載以后,在JVM內部就以“類的全限定名+ClassLoader實例ID”來標明類。
在內存中,ClassLoader實例和類的實例都位于堆中,它們的類信息都位于方法區。
裝載過程采用了一種被稱為“雙親委派模型(Parent Delegation Model)”的方式,當一個ClassLoader要加載類時,它會先請求它的雙親ClassLoader(其實這里只有兩個ClassLoader,所以稱為父ClassLoader可能更容易理解)加載類,而它的雙親ClassLoader會繼續把加載請求提交再上一級的ClassLoader,直到啟動類加載器。只有其雙親ClassLoader無法加載指定的類時,它才會自己加載類。
雙親委派模型是JVM的第一道安全防線,它保證了類的安全加載,這里同時依賴了類加載器隔離的原理:不同類加載器加載的類之間是無法直接交互的,即使是同一個類,被不同的ClassLoader加載,它們也無法感知到彼此的存在。這樣即使有惡意的類冒充自己在核心包(例如java.lang)下,由于它無法被啟動類加載器加載,也造成不了危害。
由此也可見,如果用戶自定義了類加載器,那就必須自己保障類加載過程中的安全。
2.2.2 鏈接
鏈接的任務是把二進制的類型信息合并到JVM運行時狀態中去。
鏈接分為以下三步:
2.2.3 初始化
初始化類中的靜態變量,并執行類中的static代碼、構造函數。
JVM規范嚴格定義了何時需要對類進行初始化:
更詳細的虛擬機加載機制可以參考《Java虛擬機類加載機制》。
3. 運行時數據區
運行時數據區由方法區、堆、Java棧、PC寄存器、本地方法棧組成。
3.1 Java棧(Java Stack)
Java棧的主要任務是存儲方法參數、局部變量、中間運算結果,并且提供部分其它模塊工作需要的數據。
Java棧總是與線程關聯在一起的,每當創建一個線程,JVM就會為該線程創建對應的Java棧,在這個Java棧中又會包含多個棧幀(Stack Frame),這些棧幀是與每個方法關聯起來的,每運行一個方法就創建一個棧幀,每個棧幀會含有一些局部變量、操作棧和方法返回值等信息。每當一個方法執行完成時,該棧幀就會彈出棧幀的元素作為這個方法的返回值,并且清除這個棧幀,Java棧的棧頂的棧幀就是當前正在執行的活動棧,也就是當前正在執行的方法,PC寄存器也會指向該地址。只有這個活動的棧幀的本地變量可以被操作棧使用,當在這個棧幀中調用另外一個方法時,與之對應的一個新的棧幀被創建,這個新創建的棧幀被放到Java棧的棧頂,變為當前的活動棧。同樣現在只有這個棧的本地變量才能被使用,當這個棧幀中所有指令都完成時,這個棧幀被移除Java棧,剛才的那個棧幀變為活動棧幀,前面棧幀的返回值變為這個棧幀的操作棧的一個操作數。
由于Java棧是與線程對應起來的,Java棧數據不是線程共有的,所以不需要關心其數據一致性,也不會存在同步鎖的問題。
它分為三部分:局部變量區、操作數棧、幀數據區。
3.1.1 局部變量區
局部變量區是以字長為單位的數組,在這里,byte、short、char類型會被轉換成int類型存儲,除了long和double類型占兩個字長以外,其余類型都只占用一個字長。特別地,boolean類型在編譯時會被轉換成int或byte類型,boolean數組會被當做byte類型數組來處理。局部變量區也會包含對象的引用,包括類引用、接口引用以及數組引用。
局部變量區包含了方法參數和局部變量,此外,實例方法隱含第一個局部變量this,它指向調用該方法的對象引用。對于對象,局部變量區中永遠只有指向堆的引用。
3.1.2 操作數棧
操作數棧也是以字長為單位的數組,但是正如其名,它只能進行入棧出棧的基本操作。在進行計算時,操作數被彈出棧,計算完畢后再入棧。
3.1.3 幀數據區
幀數據區的任務主要有:
記錄指向類的常量池的指針,以便于解析。
幫助方法的正常返回,包括恢復調用該方法的棧幀,設置PC寄存器指向調用方法對應的下一條指令,把返回值壓入調用棧幀的操作數棧中。
記錄異常表,發生異常時將控制權交由對應異常的catch子句,如果沒有找到對應的catch子句,會恢復調用方法的棧幀并重新拋出異常。
局部變量區和操作數棧的大小依照具體方法在編譯時就已經確定。調用方法時會從方法區中找到對應類的類型信息,從中得到具體方法的局部變量區和操作數棧的大小,依此分配棧幀內存,壓入Java棧。
在Java虛擬機規范中,對這個區域規定了兩種異常狀況:如果線程請求的棧深度大于虛擬機所允許的深度,將拋出StackOverflowError異常;如果虛擬機可以動態擴展,如果擴展時無法申請到足夠的內存,就會拋出OutOfMemoryError異常。
3.2 本地方法棧(Native Method Stack)
本地方法棧類似于Java棧,主要存儲了本地方法調用的狀態。區別不過是Java棧為JVM執行Java方法服務,而本地方法棧為JVM執行Native方法服務。本地方法棧也會拋出StackOverflowError和OutOfMemoryError異常。在Sun JDK中,本地方法棧和Java棧是同一個。
3.3 PC寄存器/程序計數器(Program Count Register)
嚴格來說是一個數據結構,用于保存當前正在執行的程序的內存地址,由于Java是支持多線程執行的,所以程序執行的軌跡不可能一直都是線性執行。當有多個線程交叉執行時,被中斷的線程的程序當前執行到哪條內存地址必然要保存下來,以便用于被中斷的線程恢復執行時再按照被中斷時的指令地址繼續執行下去。為了線程切換后能恢復到正確的執行位置,每個線程都需要有一個獨立的程序計數器,各個線程之間計數器互不影響,獨立存儲,我們稱這類內存區域為“線程私有”的內存,這在某種程度上有點類似于“ThreadLocal”,是線程安全的。
3.4 方法區(Method Area)
類型信息和類的靜態變量都存儲在方法區中。方法區中對于每個類存儲了以下數據:
- 類及其父類的全限定名(java.lang.Object沒有父類)
- 類的類型(Class or Interface)
- 訪問修飾符(public, abstract, final)
- 實現的接口的全限定名的列表
- 常量池
- 字段信息
- 方法信息
- 靜態變量
- ClassLoader引用
- Class引用
可見類的所有信息都存儲在方法區中。由于方法區是所有線程共享的,所以必須保證線程安全,舉例來說,如果兩個類同時要加載一個尚未被加載的類,那么一個類會請求它的ClassLoader去加載需要的類,另一個類只能等待而不會重復加載。
常量池本身是方法區中的一個數據結構。常量池中存儲了如字符串、final變量值、類名和方法名常量。常量池在編譯期間就被確定,并保存在已編譯的.class文件中。一般分為兩類:字面量和應用量。字面量就是字符串、final變量等。類名和方法名屬于引用量。引用量最常見的是在調用方法的時候,根據方法名找到方法的引用,并以此定為到函數體進行函數代碼的執行。引用量包含:類和接口的權限定名、字段的名稱和描述符,方法的名稱和描述符。
此外為了加快調用方法的速度,通常還會為每個非抽象類創建私有的方法表,方法表是一個數組,存放了實例可能被調用的實例方法的直接引用。
在Sun JDK中,方法區對應了持久代(Permanent Generation),默認最小值為16MB,最大值為64MB。大小可以通過參數來設置,可以通過-XX:PermSize指定初始值,-XX:MaxPermSize指定最大值。
3.5 堆(Heap)
堆是JVM所管理的內存中最大的一塊,是被所有Java線程鎖共享的,不是線程安全的,在JVM啟動時創建。
堆用于存儲對象實例以及數組值。堆是存儲Java對象的地方,這一點Java虛擬機規范中描述是:所有的對象實例以及數組都要在堆上分配。堆中有指向類數據的指針,該指針指向了方法區中對應的類型信息。堆中還可能存放了指向方法表的指針。堆是所有線程共享的,所以在進行實例化對象等操作時,需要解決同步問題。此外,堆中的實例數據中還包含了對象鎖,并且針對不同的垃圾收集策略,可能存放了引用計數或清掃標記等數據。
在堆的管理上,Sun JDK從1.2版本開始引入了分代管理的方式。主要分為新生代、舊生代。分代方式大大改善了垃圾收集的效率。
1、新生代(New Generation)
大多數情況下新對象都被分配在新生代中,新生代由Eden Space和兩塊相同大小的Survivor Space組成,后兩者主要用于Minor GC時的對象復制(Minor GC的過程在此不詳細討論)。
JVM在Eden Space中會開辟一小塊獨立的TLAB(Thread Local Allocation Buffer)區域用于更高效的內存分配,我們知道在堆上分配內存需要鎖定整個堆,而在TLAB上則不需要,JVM在分配對象時會盡量在TLAB上分配,以提高效率。
2、老年代(Old Generation/Tenuring Generation)
在新生代中存活時間較久的對象將會被轉入老年代,老年代進行垃圾收集的頻率沒有新生代高。
4. 執行引擎
執行引擎是JVM執行Java字節碼的核心,執行方式主要分為解釋執行、編譯執行、自適應優化執行、硬件芯片執行方式。
JVM的指令集是基于棧而非寄存器的,這樣做的好處在于可以使指令盡可能緊湊,便于快速地在網絡上傳輸(別忘了Java最初就是為網絡設計的),同時也很容易適應通用寄存器較少的平臺,并且有利于代碼優化,由于Java棧和PC寄存器是線程私有的,線程之間無法互相干涉彼此的棧。每個線程擁有獨立的JVM執行引擎實例。
JVM指令由單字節操作碼和若干操作數組成。對于需要操作數的指令,通常是先把操作數壓入操作數棧,即使是對局部變量賦值,也會先入棧再賦值。注意這里是“通常”情況,之后會講到由于優化導致的例外。
4.1 解釋執行
和一些動態語言類似,JVM可以解釋執行字節碼。Sun JDK采用了token-threading的方式,感興趣的同學可以深入了解一下。解釋執行中有幾種優化方式:
- 棧頂緩存:將位于操作數棧頂的值直接緩存在寄存器上,對于大部分只需要一個操作數的指令而言,就無需再入棧,可以直接在寄存器上進行計算,結果壓入操作數站。這樣便減少了寄存器和內存的交換開銷。
- 部分棧幀共享:被調用方法可將調用方法棧幀中的操作數棧作為自己的局部變量區,這樣在獲取方法參數時減少了復制參數的開銷。
- 執行機器指令:在一些特殊情況下,JVM會執行機器指令以提高速度。
4.2 編譯執行
為了提升執行速度,Sun JDK提供了將字節碼編譯為機器指令的支持,主要利用了JIT(Just-In-Time)編譯器在運行時進行編譯,它會在第一次執行時編譯字節碼為機器碼并緩存,之后就可以重復利用。Oracle JRockit采用的是完全的編譯執行。
4.3 自適應優化執行
自適應優化執行的思想是程序中10%20%的代碼占據了80%90%的執行時間,所以通過將那少部分代碼編譯為優化過的機器碼就可以大大提升執行效率。自適應優化的典型代表是Sun的Hotspot VM,正如其名,JVM會監測代碼的執行情況,當判斷特定方法是瓶頸或熱點時,將會啟動一個后臺線程,把該方法的字節碼編譯為極度優化的、靜態鏈接的C++代碼。當方法不再是熱區時,則會取消編譯過的代碼,重新進行解釋執行。
自適應優化不僅通過利用小部分的編譯時間獲得大部分的效率提升,而且由于在執行過程中時刻監測,對內聯代碼等優化也起到了很大的作用。由于面向對象的多態性,一個方法可能對應了很多種不同實現,自適應優化就可以通過監測只內聯那些用到的代碼,大大減少了內聯函數的大小。
Sun JDK在編譯上采用了兩種模式:Client和Server模式。前者較為輕量級,占用內存較少。后者的優化程序更高,占用內存更多。
在Server模式中會進行對象的逃逸分析,即方法中的對象是否會在方法外使用,如果被其它方法使用了,則該對象是逃逸的。對于非逃逸對象,JVM會在棧上直接分配對象(所以對象不一定是在堆上分配的),線程獲取對象會更加快速,同時當方法返回時,由于棧幀被拋棄,也有利于對象的垃圾收集。Server模式還會通過分析去除一些不必要的同步,感興趣的同學可以研究一下Sun JDK 6引入的Biased Locking機制。
此外,執行引擎也必須保證線程安全性,因而JMM(Java Memory Model)也是由執行引擎確保的。
參考資料:
歡迎跳轉到本文的原文鏈接:https://honeypps.com/java/structure-analysis-of-java-virtual-machine/
歡迎支持筆者新作:《深入理解Kafka:核心設計與實踐原理》和《RabbitMQ實戰指南》,同時歡迎關注筆者的微信公眾號:朱小廝的博客。
總結
以上是生活随笔為你收集整理的Java虚拟机结构分析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Java多线程知识小抄集(三)
- 下一篇: Java多线程知识小抄集(四)——完结