開篇
眾所周知,Java程序的執(zhí)行需要依賴于JVM(Java 虛擬機)。JVM 會將Java源代碼編譯成字節(jié)碼文件,然后使用類加載器將其加載到運行時數(shù)據(jù)區(qū)中執(zhí)行,垃圾收集器也會針對運行時數(shù)據(jù)區(qū)進行對象回收的工作。今天就來說說JVM的運行時數(shù)據(jù)區(qū)。
運行時數(shù)據(jù)區(qū)概述
在計算機世界中,內(nèi)存是十分重要的系統(tǒng)資源,它承載著操作系統(tǒng)和應(yīng)用程序?qū)崟r運行的責(zé)任。JVM內(nèi)存布局規(guī)定了Java在運行過程中內(nèi)存申請、分配、管理的策略,從而保證了JVM的高效穩(wěn)定運行。
Java虛擬機在執(zhí)行Java程序的過程中,會將涉及到的數(shù)據(jù)劃分到不同的內(nèi)存區(qū)域去管理,在這些數(shù)據(jù)區(qū)域,有些是隨著虛擬機啟動而創(chuàng)建,虛擬機關(guān)閉而銷毀。還有一部分是隨著線程生命周期創(chuàng)建銷毀的。這部分區(qū)域就是接下來要講的Java虛擬機的運行時數(shù)據(jù)區(qū)。
圖1 運行時數(shù)據(jù)區(qū)
如圖1所示,紅色的部分就是運行時數(shù)據(jù)區(qū),它包括:方法區(qū)、堆、虛擬機棧、本地方法棧以及程序計數(shù)器五個部分。
圖1中標注為黃色的方法區(qū)和堆是線程間共享的,也就是說它們會隨著虛擬機啟動而創(chuàng)建,隨著虛擬機退出而銷毀。橙色部分為每個線程單獨享有的,即它們與線程是一一對應(yīng)的,會隨著線程開始和結(jié)束而創(chuàng)建和銷毀。在HotSpot JVM中,每個線程都與操作系統(tǒng)的本地線程直接映射,例如:有一個Java線程準備好執(zhí)行時,就有一個操作系統(tǒng)的本地線程被創(chuàng)建并且與Java 線程對應(yīng),當Java線程執(zhí)行終止后,本地線程也會被回收。同時操作系統(tǒng)負責(zé)線程調(diào)度,及分配對應(yīng)的CPU執(zhí)行線程,一旦操作系統(tǒng)的本地線程初始化成功,它就會調(diào)用Java線程中的的run()方法去執(zhí)行Java線程。
褐色部分的執(zhí)行引擎就負責(zé)讀取指令并且交由CPU執(zhí)行,它包括解釋器、JIT(即時編譯器),GC(垃圾回收器)。而另外一個褐色的本地庫接口會提供Java程序調(diào)用的native方法。
另外,運行時數(shù)據(jù)區(qū)的劃分也隨著JDK的發(fā)展不斷變遷,如圖2 所示, JDK 1.6、JDK 1.7、JDK 1.8 的內(nèi)存劃分都會有所不同。
圖2 運行時數(shù)據(jù)區(qū)的變遷
如圖2 所示,在JDK 1.8 中加入了元數(shù)據(jù)區(qū)的概念,將原來保存在方法區(qū)中的運行時常量池和類常量池都包括其中。
虛擬機棧
上面介紹了JVM 運行時數(shù)據(jù)區(qū)的概念和組成,接下來一次介紹每個組成部分,首先從虛擬機棧開始。
每個Java線程都會對應(yīng)一個虛擬機棧,換句話說多個線程就對應(yīng)多個虛擬機棧。上面講過了虛擬機棧是線程私有,虛擬機棧中包含多個棧幀(Stack Frame),每一個棧幀是為方法執(zhí)行而創(chuàng)建的,棧幀中描述的是Java方法執(zhí)行的內(nèi)存模型。每個方法從調(diào)用開始直到完成的全過程都對應(yīng)著一個棧幀。棧幀是用來管理Java程序的運行,并保存方法的局部變量、部分結(jié)果、并參與方法的調(diào)用與返回。在活動線程中,只有一個棧幀是處于活躍狀態(tài)的,也就是說只有位于棧頂?shù)臈攀怯行У模Q為當前棧幀,與這個棧幀相關(guān)聯(lián)的方法稱為當前方法。執(zhí)行引擎運行的所有字節(jié)碼指令都只針對當前棧幀進行操作。
如圖3 所示,每個Java 方法都會對應(yīng)一個棧幀,左邊的四個方法就對應(yīng)了四個棧幀,從下往上依次是方法調(diào)用的順序,最終方法1 會調(diào)用方法4, 此時正在執(zhí)行方法4 ,它對應(yīng)的棧幀4 就是“當前棧幀”,就是出于活躍狀態(tài)的,其包含了局部變量表、操作數(shù)棧、動態(tài)鏈接以及返回地址等信息。
圖3 棧幀結(jié)構(gòu)
局部變量表
它定義為數(shù)字數(shù)組,主要用于存儲方法參數(shù)和定義在方體內(nèi)的局部變量,包含基本數(shù)據(jù)類型,對象引用,以及returnAddress類型。它建立在線程的棧上,是線程的私有數(shù)據(jù),因此不存在數(shù)據(jù)的安全問題。
局部變量表所需的容量在編譯期間確定,在運行期間是不改變其容量。方法嵌套調(diào)用的次數(shù)由棧的容量來決定,例如圖3就進行了4個方法的嵌套,也就是說棧越大,方法嵌套調(diào)用次數(shù)越多。對一個函數(shù)而言,它的參數(shù)和局部變量越多,對應(yīng)的棧幀就越大。因此,函數(shù)調(diào)用就會占用更多的棧空間。局部變量表中的變量只在當前方法調(diào)用中有效。在方法執(zhí)行時,虛擬機通過使用局部變量表完成參數(shù)值到參數(shù)變量列表的傳遞。當方法調(diào)用結(jié)束后,隨著方法棧幀的銷毀,局部變量表也會隨之銷毀。
操作數(shù)棧
它是一個后進先出的棧,在方法執(zhí)行的過程中,根據(jù)字節(jié)碼指令、往棧中寫入或取出數(shù)據(jù),即入棧/出棧。字節(jié)碼指令將值壓入操作棧,其余的字節(jié)碼指令將操作數(shù)取出棧,進行操作之后再將結(jié)果壓入棧。操作包括:復(fù)制、交換、求和等。
這樣講比較抽象,來看一個具體的例子。
如圖4 所示,生成一個testAdd 方法,給變量i和j 分別賦值為1 和2 ,然后讓其相加并且把結(jié)果賦值給k。
圖4 操作數(shù)棧代碼
使用jclasslib反編譯上面的代碼得到圖5 的結(jié)果。
圖5 jclasslib反編譯結(jié)果
如圖6 所示,當執(zhí)行地址 0 的時候操作指令為bipush,此時程序寄存器的地址顯示為0 ,bipush 命令將 1 壓入到操作數(shù)棧的頂部。
圖6
如圖7 所示,當指令地址到2 的時候,程序寄存器顯示為2, 此時執(zhí)行istore_1 的指令,將棧頂?shù)臄?shù)字1 保存到局部變量表中。
圖7
如圖8所示,指令地址執(zhí)行到3 的時候,程序寄存器為3 , bipush指令把2 壓入到操作數(shù)棧的頂部。
圖8
在指令地址為5 的時候,程序寄存器的值為5, istore_2指令將操作數(shù)棧中的2 保存到局部變量表中的2 的位置。
圖9
如圖10所示,指令地址為6 的時候,執(zhí)行iload_1 指令獲取局部變量表中 位置為1 的值,也就是1 并且把它放到操作數(shù)棧的頂部。
圖10
如圖11所示,指令地址為7 的時候,執(zhí)行iload_2 指令,從局部變量表2 的位置取出值2 放到操作數(shù)棧的頂部。
圖11
如圖12 所示,在指令地址為8 時,執(zhí)行iadd 指令,將操作數(shù)棧的兩個數(shù)字1和2 相加結(jié)果為3,并且將其放到操作數(shù)棧的頂部。
圖12
如圖13 所示,接著執(zhí)行指令地址 9 , istore_3 執(zhí)行之后將操作數(shù)棧頂?shù)? 保存到局部變量表3 的位置,完成相加的操作,最后通過指令地址10 中的return指令返回方法。
圖13
動態(tài)鏈接
在介紹動態(tài)鏈接之前先說說靜態(tài)鏈接,即字節(jié)碼文件被裝載進JVM內(nèi)部時,如果被調(diào)用的目標方法在編譯期可知,且運行期間保持不變時。這種情況下將調(diào)用方法的符號引用轉(zhuǎn)換為直接引用的過程稱之為靜態(tài)鏈接。但是,如果被調(diào)用方法在編譯期間無法被確定下來,只能在程序運行時將調(diào)用方法的符號引用轉(zhuǎn)換為直接引用,由于這種引用轉(zhuǎn)換的過程具備動態(tài)性,被稱為動態(tài)鏈接。
如圖14所示,上面是反編譯的字節(jié)碼部分,對應(yīng)的#3、#6、#5等等就是符號引用,下面的Constant pool就是常量池。在Java源文件被編譯成字節(jié)碼文件時,所有的變量和方法引用都作為符號引用保存在class文件的常量池中。例如在指令第9行會執(zhí)行invokevirtual的指令,對應(yīng)的符號引用就是#7,所對應(yīng)常量池中的#7 就是Methodref,也就是方法引用,這里對應(yīng)的方法是com.itcast.java.DynamicLinkTest中的methodA方法。
圖14 從字節(jié)碼到常量池中的方法引用
如圖15所示,當字節(jié)碼文件被加載后,字節(jié)碼文件中的一些數(shù)據(jù),如類型信息、域信息、方法信息等,就會被放置到方法區(qū)中。而棧幀中的當前類常量池引用(Current Class Constant Pool Reference)保存的是方法符號引用,真正的方法引用放在了方法區(qū)(Method Area)中的方法引用(method reference)中了,這個方法引用是為了支持代碼的動態(tài)鏈接。動態(tài)鏈接就是將符號引用轉(zhuǎn)化為直接引用。
圖15 棧幀中的當前類常量池引用對應(yīng)方法區(qū)中的方法引用
JVM之所以這么設(shè)計是因為字節(jié)碼文件需要數(shù)據(jù)支持的量會很大,因此不能直接將這些數(shù)據(jù)存放到字節(jié)碼中。針對方法的引用創(chuàng)建符號引用,這個符號引用放在棧幀的常量池引用中,而實際的方法和符號引用的對照表卻放在方法區(qū)的常量池中,這樣字節(jié)碼就可以通過常量池中的對照關(guān)系找到引用的方法,并且也不會增加棧幀的容量。
方法返回地址
當一個方法開始執(zhí)行后,可以通過兩種方式退出該方法。第一種是執(zhí)行引擎遇到方法返回的字節(jié)碼指令,此時返回值會傳遞到上層調(diào)用者,這種方式稱為正常完成出口。另外一種退出方式是在方法執(zhí)行中遇到異常,這個異常在方法體內(nèi)沒有得到處理,就會導(dǎo)致方法退出,這種方式稱為異常完成出口。由于是異常退出,就不會給上層調(diào)用者任何返回值。無論采取上面那種退出方式,方法都會到處調(diào)用它的位置,程序才能繼續(xù)執(zhí)行。方法在返回的時候需要在棧幀中保存一些信息,用來恢復(fù)調(diào)用該方法的上層方法的執(zhí)行狀態(tài)。這里可以通過方法調(diào)用者的程序計數(shù)器存放返回地址,如果是正常退出方法,上層方法會從程序計數(shù)器中保存的地址繼續(xù)執(zhí)行接下來的步驟。如果是異常退出的情況,返回地址就需要異常處理器來確定了。
程序計數(shù)器
有了上面虛擬機棧的講解,對于程序計數(shù)器的理解會相對簡單點。記得在虛擬機棧中的操作數(shù)棧的例子中,提到了使用程序計數(shù)器記錄操作指令的地址。程序計數(shù)器就是一塊較小的內(nèi)存空間,它是當前線程執(zhí)行的字節(jié)碼的行號(操作指令的地址)指示器。在棧幀中字節(jié)碼解釋器就是通過改變計數(shù)器的值來選去下一條要執(zhí)行的字節(jié)碼指令的,例如:分支、循環(huán)、跳轉(zhuǎn)、異常處理、線程恢復(fù)等。
上面講虛擬機棧的時候提到過,多個執(zhí)行的Java線程就是多個虛擬機棧,每個棧中存在多個棧幀,在一個時刻只有一個棧幀執(zhí)行,也就是當前棧幀。也就是說在一個時刻一個處理只會對一個線程中的一個幀棧執(zhí)行一條指令,而每個棧幀都會維護一個屬于自己的程序計數(shù)器,這個計數(shù)器就是來記錄指令執(zhí)行的地址的。每個線程的計數(shù)器不會相互影響,這也保證了在Java 多線程進行切換的時候,每個線程都能夠保證正確的指令地址被讀取。
如圖 16所示,在invokevirtual的框圖中存在多個線程,每個線程就是一個虛擬機棧,每個線程中包含多個Frame 也就是棧幀,針對每個線程都會維護一個PC Registers也就是程序寄存器,它會記錄指令地址信息,從而讓方法實現(xiàn):跳轉(zhuǎn)、分支、循環(huán)、異常處理和線程恢復(fù)的功能。
圖16 程序計數(shù)器
本地方法棧
本地方法棧與虛擬機棧所發(fā)揮的作用是非常相似的,它們之間的區(qū)別是虛擬機棧為虛擬機執(zhí)行Java方法(也就是字節(jié)碼)服務(wù),而本地方法棧為虛擬機所使用到的Native方法服務(wù)。本地方法棧也會拋出StackOverflowError和OutOfMemoryError異常。
說白了,本地方法(Native Method)就是一個Java調(diào)用非Java代碼的接口。 當Java應(yīng)用需要與Java之外的環(huán)境交互時就需要使用本地方法,特別與底層系統(tǒng)、操作系統(tǒng)以及硬件打交道時就會用到本地方法。大家可以把本地方法理解為一種交流機制:它提供了一個對外的簡潔的接口,讓我們無需去了解Java應(yīng)用之外的細節(jié)。
那么JVM是如何使用Native Method的呢?當一個類第一次被使用時,類的字節(jié)碼會被加載到內(nèi)存,在字節(jié)碼的入口維持著該類所有方法描述符的list,包括:方法代碼來源,參數(shù),方法描述符(例如:public)等等。
如果方法描述符是native,同時描述符塊將有一個指向該方法實現(xiàn)的指針,而具體實現(xiàn)在DLL文件內(nèi),此時DLL文件會被操作系統(tǒng)加載到Java程序的地址空間里。當一個帶有本地方法的類被加載時,其相關(guān)的DLL并未被加載,因此指向方法實現(xiàn)的指針并不會被設(shè)置。當本地方法被調(diào)用之前, DLL才會被加載,即通過調(diào)用java.system.loadLibrary()實現(xiàn)的。
堆和方法區(qū)
上面說的虛擬機棧、程序計數(shù)器和本地方法棧都是線程私有的,而接下來說的方法區(qū)和堆是線程共享的。這里把堆和方法區(qū)合起來說。
堆
Java堆是Java虛擬機所管理內(nèi)存中最大的一塊,在虛擬機啟動時創(chuàng)建,被所有線程共享。Java對象實例以及數(shù)組都在堆上分配。堆的大小可以是固定的,也可以根據(jù)計算的需要進行擴展,如果不需要更大的堆,則可以收縮。堆的內(nèi)存不需要是連續(xù)的。Java虛擬機實現(xiàn)可以為程序員或用戶提供對堆初始大小的控制,如果可以動態(tài)擴展或收縮堆,還可以控制堆的最大和最小大小。
Java堆是垃圾收集器管理的主要區(qū)域,所以也被稱為GC堆。從內(nèi)存回收的角度來看,由于現(xiàn)在收集器基本都采用分代收集算法,所以Java堆中還可以細分為:新生代和老年代;新生代再細分就是:Eden空間、From Survivor空間、ToSurvivor空間等。從內(nèi)存分配的角度來看,線程共享的Java堆中可能劃分出多個線程私有的分配緩沖區(qū)(Thread Local Allocation Buffer,TLAB)。不論如何劃分,都與存放內(nèi)容無關(guān),無論哪個區(qū)域,存放的都仍然是對象實例;進一步劃分的目的是為了更好的回收內(nèi)存,或者更快地分配內(nèi)存。
對于堆中垃圾回收的部分這里不展開說明,后面會有文章去介紹。
方法區(qū)
方法區(qū)和堆一樣是線程共享的內(nèi)存區(qū)域,它用來存放被虛擬機加載的類型信息、運行時常量池、靜態(tài)變量、JIT代碼緩存、域信息、方法信息等。方法區(qū)(Method Area)與Java堆一樣,是各個線程共享的內(nèi)存區(qū)域,有如下特點:
- 方法區(qū)在JVM啟動的時候被創(chuàng)建,并且它的實際的物理內(nèi)存空間和Java堆區(qū)一樣都可以是不連續(xù)的。
- 方法區(qū)的大小,和堆空間一樣,可以選擇固定大小和可擴展。
- 方法區(qū)的大小決定了系統(tǒng)可以保存多少個類,如果系統(tǒng)定義了太多的類,導(dǎo)致方法區(qū)溢出,虛擬機就會拋出內(nèi)存溢出錯誤:
java.lang.OutOfMemoryError:PermGenspace或者 java.lang.OutOfMemoryError: Metaspace。
- 關(guān)閉JVM就會釋放這個區(qū)域的內(nèi)存。
這里把堆、方法區(qū)和虛擬機棧的關(guān)系整理一下。如圖17 所示,在右邊創(chuàng)建了AppMain 類,在運行時JVM 會把AppMain的信息放入到方法區(qū),因為方法區(qū)會存放類型信息。同時main 的方法本身也會放入到方法區(qū)。接下來的new Sample(“測試1”)的語句中Sample的自定義對象會放到堆里面,而對應(yīng)的test1 應(yīng)用會放入到虛擬機棧中,對應(yīng)的test1.printName()方法的執(zhí)行會在虛擬機棧中的棧幀中通過指令執(zhí)行完成。另外下面的class Sample也是放到方法區(qū)中的,聲明的private name,其中name的引用放在虛擬機棧中,name對應(yīng)的對象放在堆中。對應(yīng)的printName方法是放在方法區(qū)中的。
圖17 棧、堆、方法區(qū)關(guān)系
總結(jié)
JVM 會把Java的字節(jié)碼加載到運行時數(shù)據(jù)區(qū)內(nèi),這個內(nèi)存區(qū)域分為:方法區(qū)、堆、虛擬機棧、本地方法棧以及程序計數(shù)器。堆里面放對象,也是垃圾回收器要處理的對象;方法區(qū)放類型、方法描述、方法本體;程序計數(shù)器負責(zé)記錄虛擬機棧中指令執(zhí)行的地址;虛擬機棧對應(yīng)Java執(zhí)行的線程,對象的引用都保存在棧幀中,通過指令地址和指令執(zhí)行方法中的內(nèi)容;本地方法棧用來調(diào)用Java 之外的系統(tǒng)級別的接口。
譯者介紹
崔皓,51CTO社區(qū)編輯,資深架構(gòu)師,擁有18年的軟件開發(fā)和架構(gòu)經(jīng)驗,10年分布式架構(gòu)經(jīng)驗。曾任惠普技術(shù)專家。樂于分享,撰寫了很多熱門技術(shù)文章,閱讀量超過60萬。《分布式架構(gòu)原理與實踐》作者。